返回 Skill 列表
extension
分类: 开发与工程无需 API Key

cache-management

实现生产级缓存,每个查询都有缓存键/生存时间(TTL)/一致性类别,包括SWR(重新验证前使用过期数据),显式失效,HTTP缓存头,并进行全面的陈旧读取和缓存预热测试。在为查询添加缓存、实现缓存失效、配置HTTP缓存或确保缓存一致性和性能时使用。

person作者: jakexiaohubgithub

Cache Management Skill

Context (Input)

Use this skill when:

  • Adding caching to repositories or expensive queries
  • Implementing cache invalidation via domain events
  • Defining cache keys, TTLs, and consistency requirements
  • Implementing stale-while-revalidate (SWR) pattern
  • Configuring HTTP cache headers (Cache-Control, ETag, Vary)
  • Testing cache behavior (stale reads, cold start, invalidation)
  • Reducing database load with caching
  • Setting up async event-driven cache invalidation

Task (Function)

Implement production-ready caching with proper key design, TTL management, event-driven invalidation, HTTP cache headers, and comprehensive testing.

Success Criteria:

  • Cache policy declared for each query (key, TTL, consistency class)
  • Decorator pattern with CachedXxxRepository wrapping MongoXxxRepository
  • Event-driven invalidation via domain event subscribers
  • Marker interface pattern for auto-binding cache pools
  • Best-effort invalidation (try/catch, never fail business operations)
  • HTTP cache headers configured (Cache-Control, ETag for API responses)
  • Async event processing via message queue (AP from CAP theorem)
  • Comprehensive unit tests for all cache paths
  • Cache observability (hit/miss/error logging)
  • make ci outputs "✅ CI checks successfully passed!"

⚠️ CRITICAL CACHE POLICY

╔═══════════════════════════════════════════════════════════════╗
║  ALWAYS use Decorator Pattern for caching (wrap repositories) ║
║  ALWAYS use CacheKeyBuilder service (prevent key drift)       ║
║  ALWAYS invalidate via Domain Events (decouple from business) ║
║  ALWAYS use TagAwareCacheInterface for cache tags             ║
║  ALWAYS wrap cache ops in try/catch (best-effort, no failures)║
║  ALWAYS use Marker Interface for auto-binding cache pools     ║
║  ALWAYS process invalidation async (AP from CAP theorem)      ║
║                                                               ║
║  ❌ FORBIDDEN: Caching in repository, implicit invalidation   ║
║  ✅ REQUIRED:  Decorator pattern, event-driven invalidation   ║
╚═══════════════════════════════════════════════════════════════╝

CAP Theorem: Why We Choose AP (Availability + Partition Tolerance)

Cache invalidation follows AP from CAP theorem - we prioritize:

  • Availability: Business operations never fail due to cache issues
  • Partition Tolerance: System works even when cache is unavailable

Trade-off: Brief staleness is acceptable over blocking writes.

Implementation:

  • Cache errors fallback to database (try/catch everywhere)
  • Invalidation processed asynchronously via message queue
  • Exceptions in subscribers are logged + emit metrics (self-healing)
  • Business operations complete even if cache invalidation fails

Non-negotiable requirements:

  • Use Decorator Pattern: CachedXxxRepository wraps MongoXxxRepository
  • Use centralized CacheKeyBuilder service (in Shared/Infrastructure/Cache)
  • Invalidate via Domain Event Subscribers (one subscriber per event)
  • Use Marker Interface for auto-binding cache pools via _instanceof
  • Process cache invalidation asynchronously via message queue
  • Wrap ALL cache operations in try/catch (never fail business operations)
  • Use TagAwareCacheInterface (not CacheInterface) for tag support
  • Configure test cache pools with tags: true in config/packages/test/cache.yaml
  • Log cache operations for observability

File Locations (This Codebase)

These are example locations based on the Codely/Hexagonal structure used in VilnaCRM services. Adapt the bounded context (User, OAuth, etc.) to your feature.

| Component | Typical Location | | ---------------------------- | ----------------------------------------------------------------------------------------------- | | CacheKeyBuilder | src/Shared/Infrastructure/Cache/CacheKeyBuilder.php | | CachedXxxRepository | src/{Context}/{Bounded}/Infrastructure/Repository/CachedXxxRepository.php | | Base repository (inner) | src/{Context}/{Bounded}/Infrastructure/Repository/*Repository.php | | Marker interface | src/{Context}/{Bounded}/Application/EventSubscriber/*CacheInvalidationSubscriberInterface.php | | Invalidation subscriber | src/{Context}/{Bounded}/Application/EventSubscriber/*CacheInvalidationSubscriber.php | | Cache pool config | config/packages/cache.yaml | | Test cache config | config/packages/test/cache.yaml | | Service wiring / aliases | config/services.yaml | | HTTP cache tests | tests/Integration/*HttpCacheTest.php | | Unit tests | tests/Unit/** | | Integration tests (optional) | tests/Integration/** |


TL;DR - Cache Management Checklist

Before Implementing Cache:

  • [ ] Identified slow query worth caching
  • [ ] Cache policy declared (key pattern, TTL, consistency class)
  • [ ] Cache tags defined for invalidation strategy
  • [ ] Domain events defined for cache invalidation triggers
  • [ ] HTTP cache headers strategy defined (if API endpoint)

Architecture Setup:

  • [ ] Created CachedXxxRepository decorator class
  • [ ] Created CacheKeyBuilder service (or extended existing one)
  • [ ] Created marker interface for cache invalidation subscribers
  • [ ] Created cache invalidation event subscribers (one per event)
  • [ ] Configured services.yaml with _instanceof for auto-binding cache pools
  • [ ] Configured async event processing via message bus

During Implementation:

  • [ ] Decorator wraps inner repository (not extends)
  • [ ] CacheKeyBuilder used for all cache keys (prevents drift)
  • [ ] Cache operations wrapped in try/catch (best-effort)
  • [ ] Event subscribers use same CacheKeyBuilder for tags
  • [ ] Logging added for cache hits/misses/errors
  • [ ] Repository uses TagAwareCacheInterface (required for tags)

Testing:

  • [ ] Test cache pool configured with tags: true
  • [ ] Unit tests for cache invalidation subscribers
  • [ ] Integration tests for stale reads after writes (if valuable)
  • [ ] Test: Cache error fallback to database works
  • [ ] HTTP cache tests for Cache-Control headers and ETag validation

Before Merge:

  • [ ] All cache tests pass
  • [ ] Cache observability verified (logs present)
  • [ ] HTTP cache headers verified (if API endpoint)
  • [ ] CI checks pass (make ci)
  • [ ] No cache-related stale data issues

Quick Start: Cache in 9 Steps

Step 1: Declare Cache Policy

Before writing code, declare the complete policy:

/**
 * Cache Policy for Customer By ID Query
 *
 * Key Pattern: customer.{id}
 * TTL: 600s (10 minutes)
 * Consistency: Stale-While-Revalidate
 * Invalidation: Via domain events (CustomerCreated/Updated/Deleted)
 * Tags: [customer, customer.{id}]
 * HTTP Cache: Cache-Control: max-age=600, public, s-maxage=600
 * Notes: Read-heavy operation, tolerates brief staleness
 */

Step 2: Create CacheKeyBuilder Service

Location: src/Shared/Infrastructure/Cache/CacheKeyBuilder.php

final readonly class CacheKeyBuilder
{
    public function build(string $namespace, string ...$parts): string
    {
        return $namespace . '.' . implode('.', $parts);
    }

    public function buildCustomerKey(string $customerId): string
    {
        return $this->build('customer', $customerId);
    }

    public function buildCustomerEmailKey(string $email): string
    {
        return $this->build('customer', 'email', $this->hashEmail($email));
    }

    /**
     * Build cache key for collections (filters normalized + hashed)
     * @param array<string, string|int|float|bool|array|null> $filters
     */
    public function buildCustomerCollectionKey(array $filters): string
    {
        ksort($filters);  // Normalize key order
        return $this->build(
            'customer',
            'collection',
            hash('sha256', json_encode($filters, \JSON_THROW_ON_ERROR))
        );
    }

    /**
     * Hash email consistently (lowercase + SHA256)
     * - Lowercase normalization (email case-insensitive)
     * - SHA256 hashing (fixed length, prevents key length issues)
     */
    public function hashEmail(string $email): string
    {
        return hash('sha256', strtolower($email));
    }
}

Step 3: Create Cached Repository Decorator

Location: src/{Context}/{Entity}/Infrastructure/Repository/Cached{Entity}Repository.php

final class CachedCustomerRepository implements CustomerRepositoryInterface
{
    public function __construct(
        private CustomerRepositoryInterface $inner,  // Wraps base repository
        private TagAwareCacheInterface $cache,
        private CacheKeyBuilder $cacheKeyBuilder,
        private LoggerInterface $logger
    ) {}

    /**
     * Proxy all other method calls to inner repository
     * Required for API Platform's collection provider compatibility
     * @param array<int, mixed> $arguments
     */
    public function __call(string $method, array $arguments): mixed
    {
        return $this->inner->{$method}(...$arguments);
    }

    public function find(mixed $id, int $lockMode = 0, ?int $lockVersion = null): ?Customer
    {
        $cacheKey = $this->cacheKeyBuilder->buildCustomerKey((string) $id);

        try {
            return $this->cache->get(
                $cacheKey,
                fn (ItemInterface $item) => $this->loadCustomerFromDb($id, $lockMode, $lockVersion, $cacheKey, $item),
                beta: 1.0
            );
        } catch (\Throwable $e) {
            $this->logCacheError($cacheKey, $e);
            return $this->inner->find($id, $lockMode, $lockVersion);
        }
    }

    public function save(Customer $customer): void
    {
        $this->inner->save($customer);
        // NO cache invalidation here - handled by domain event subscribers
    }

    private function loadCustomerFromDb(mixed $id, int $lockMode, ?int $lockVersion, string $cacheKey, ItemInterface $item): ?Customer
    {
        $item->expiresAfter(600);
        $item->tag(['customer', "customer.{$id}"]);

        $this->logger->info('Cache miss - loading customer from database', [
            'cache_key' => $cacheKey,
            'customer_id' => $id,
            'operation' => 'cache.miss',
        ]);

        return $this->inner->find($id, $lockMode, $lockVersion);
    }

    private function logCacheError(string $cacheKey, \Throwable $e): void
    {
        $this->logger->error('Cache error - falling back to database', [
            'cache_key' => $cacheKey,
            'error' => $e->getMessage(),
            'operation' => 'cache.error',
        ]);
    }
}

Step 4: Create Marker Interface for Auto-Binding

Location: src/{Context}/{Entity}/Application/EventSubscriber/{Entity}CacheInvalidationSubscriberInterface.php

Purpose: Enables automatic cache pool injection via _instanceof in services.yaml.

<?php

declare(strict_types=1);

namespace App\Core\Customer\Application\EventSubscriber;

use App\Shared\Domain\Bus\Event\DomainEventSubscriberInterface;

/**
 * Marker interface for customer cache invalidation subscribers.
 *
 * Used to auto-bind the customer cache pool via Symfony _instanceof configuration.
 */
interface CustomerCacheInvalidationSubscriberInterface extends DomainEventSubscriberInterface
{
}

Step 5: Create Event Subscribers for Invalidation

Location: src/{Context}/{Entity}/Application/EventSubscriber/{Event}CacheInvalidationSubscriber.php

IMPORTANT: Create ONE subscriber per event. Implement the marker interface.

/**
 * Customer Updated Event Cache Invalidation Subscriber
 *
 * ARCHITECTURAL DECISION: Processed via async queue (ResilientAsyncEventBus)
 * This subscriber runs in Symfony Messenger workers. Exceptions propagate to
 * DomainEventMessageHandler which catches, logs, and emits failure metrics.
 * We follow AP from CAP theorem (Availability + Partition tolerance over Consistency).
 */
final readonly class CustomerUpdatedCacheInvalidationSubscriber implements
    CustomerCacheInvalidationSubscriberInterface
{
    public function __construct(
        private TagAwareCacheInterface $cache,
        private CacheKeyBuilder $cacheKeyBuilder,
        private LoggerInterface $logger
    ) {}

    public function __invoke(CustomerUpdatedEvent $event): void
    {
        $tagsToInvalidate = $this->buildTagsToInvalidate($event);
        $this->cache->invalidateTags($tagsToInvalidate);
        $this->logSuccess($event);
    }

    /** @return array<class-string> */
    public function subscribedTo(): array
    {
        return [CustomerUpdatedEvent::class];
    }

    /** @return array<string> */
    private function buildTagsToInvalidate(CustomerUpdatedEvent $event): array
    {
        $tags = [
            'customer.' . $event->customerId(),
            'customer.email.' . $this->cacheKeyBuilder->hashEmail($event->currentEmail()),
            'customer.collection',
        ];

        if ($event->emailChanged() && $event->previousEmail() !== null) {
            $tags[] = 'customer.email.' . $this->cacheKeyBuilder->hashEmail($event->previousEmail());
        }

        return $tags;
    }

    private function logSuccess(CustomerUpdatedEvent $event): void
    {
        $this->logger->info('Cache invalidated after customer update', [
            'event_id' => $event->eventId(),
            'email_changed' => $event->emailChanged(),
            'operation' => 'cache.invalidation',
            'reason' => 'customer_updated',
        ]);
    }
}

Step 6: Configure services.yaml with Marker Interface

CRITICAL: Use _instanceof with the marker interface for auto-binding cache pools.

services:
  # Base repository - used by API Platform for collections
  App\Core\Customer\Infrastructure\Repository\MongoCustomerRepository:
    public: true

  # Cached repository - wraps base repository with caching
  App\Core\Customer\Infrastructure\Repository\CachedCustomerRepository:
    arguments:
      $inner: '@App\Core\Customer\Infrastructure\Repository\MongoCustomerRepository'
      $cache: '@cache.customer'

  # Alias interface to cached repository for dependency injection
  App\Core\Customer\Domain\Repository\CustomerRepositoryInterface:
    alias: App\Core\Customer\Infrastructure\Repository\CachedCustomerRepository
    public: true

  # Auto-bind cache pool to all cache invalidation subscribers via marker interface
  _instanceof:
    App\Core\Customer\Application\EventSubscriber\CustomerCacheInvalidationSubscriberInterface:
      bind:
        $cache: '@cache.customer'

    App\Shared\Domain\Bus\Event\DomainEventSubscriberInterface:
      tags: ['app.event_subscriber']

  # Async event bus for cache invalidation (AP from CAP theorem)
  App\Shared\Domain\Bus\Event\EventBusInterface:
    alias: App\Shared\Infrastructure\Bus\Event\Async\ResilientAsyncEventBus

Step 7: Configure Cache Pools

Production - config/packages/cache.yaml:

framework:
  cache:
    app: cache.adapter.redis
    default_redis_provider: '%env(resolve:REDIS_URL)%'

    pools:
      cache.customer:
        adapter: cache.adapter.redis
        default_lifetime: 600
        provider: '%env(resolve:REDIS_URL)%'
        tags: true

Test - config/packages/test/cache.yaml:

framework:
  cache:
    pools:
      cache.customer:
        adapter: cache.adapter.array
        provider: null
        tags: true

Step 8: Configure HTTP Cache Headers (API Platform)

For API endpoints, configure HTTP cache headers in your API Platform resource:

# config/api_platform/resources/customer.yaml
App\Core\Customer\Domain\Entity\Customer:
  operations:
    get:
      class: ApiPlatform\Metadata\Get
      cacheHeaders:
        max_age: 600
        shared_max_age: 600
        public: true
        vary: ['Accept', 'Accept-Language']

    get_collection:
      class: ApiPlatform\Metadata\GetCollection
      cacheHeaders:
        max_age: 300
        shared_max_age: 600
        public: true
        vary: ['Accept', 'Accept-Language']

HTTP Cache Headers Explained:

| Header | Single Resource | Collection | Purpose | | ---------- | ----------------------- | ----------------------- | -------------------- | | max-age | 600s (10 min) | 300s (5 min) | Browser cache TTL | | s-maxage | 600s | 600s | CDN/proxy cache TTL | | public | true | true | Allow shared caching | | Vary | Accept, Accept-Language | Accept, Accept-Language | Cache key variants | | ETag | Auto-generated | Auto-generated | Conditional requests |

ETag Behavior:

  • ETag is automatically generated based on resource content
  • ETag changes after resource modification
  • Clients can use If-None-Match for conditional requests
  • Returns 304 Not Modified if resource unchanged

Step 9: Verify with CI

make ci

HTTP Cache Testing

Test HTTP cache headers in integration tests:

final class CustomerHttpCacheTest extends ApiTestCase
{
    public function testGetCustomerReturnsCacheControlHeaders(): void
    {
        $client = self::createClient();
        $customer = $this->createTestCustomer();

        $client->request('GET', "/api/customers/{$customer->getUlid()}");

        self::assertResponseIsSuccessful();
        self::assertResponseHeaderSame('Cache-Control', 'max-age=600, public, s-maxage=600');
        self::assertResponseHasHeader('ETag');
    }

    public function testGetCustomerCollectionReturnsCacheControlHeaders(): void
    {
        $client = self::createClient();
        $this->createTestCustomer();

        $client->request('GET', '/api/customers');

        self::assertResponseIsSuccessful();
        self::assertResponseHeaderSame('Cache-Control', 'max-age=300, public, s-maxage=600');
    }

    public function testETagChangesAfterModification(): void
    {
        $client = self::createClient();
        $customer = $this->createTestCustomer();

        // First request to get initial ETag
        $response1 = $client->request('GET', "/api/customers/{$customer->getUlid()}");
        $etag1 = $response1->getHeaders()['etag'][0] ?? null;
        self::assertNotNull($etag1);

        // Modify customer
        $client->request('PATCH', "/api/customers/{$customer->getUlid()}", [
            'json' => ['initials' => 'Updated Name'],
            'headers' => ['Content-Type' => 'application/merge-patch+json'],
        ]);

        // Request again to get new ETag
        $response2 = $client->request('GET', "/api/customers/{$customer->getUlid()}");
        $etag2 = $response2->getHeaders()['etag'][0] ?? null;

        // ETag should change after modification
        self::assertNotEquals($etag1, $etag2);
    }
}

Async Event Processing Architecture

Cache invalidation is processed asynchronously for resilience:

┌─────────────────┐     ┌──────────────────────┐     ┌─────────────────────┐
│  Domain Event   │────▶│ ResilientAsyncEvent  │────▶│    SQS Queue        │
│  (Published)    │     │ Dispatcher           │     │                     │
└─────────────────┘     └──────────────────────┘     └─────────┬───────────┘
                                                               │
                        ┌──────────────────────┐               │
                        │  DomainEventMessage  │◀──────────────┘
                        │  Handler             │
                        └──────────┬───────────┘
                                   │
                        ┌──────────▼───────────┐
                        │  Cache Invalidation  │
                        │  Subscriber          │
                        └──────────────────────┘

Resilience Layers:

  1. Layer 1: ResilientAsyncEventDispatcher catches SQS send failures
  2. Layer 2: DomainEventMessageHandler catches subscriber failures
  3. All failures: Logged + emit metrics (self-healing pipeline)

Additional Resources

  • Policy decisions: reference/cache-policies.md
  • Invalidation patterns: reference/invalidation-strategies.md
  • SWR details: reference/swr-pattern.md
  • End-to-end example: examples/cache-implementation.md
  • Tests guide: examples/cache-testing.md