Практика: кэш репозитория товаров (Caching proxy)
Кэширующий заместитель перед медленным репозиторием: ответы из памяти с TTL, сброс при записи и честная граница — запись мимо заместителя кэш не видит.
Базовый пример: Заместитель. Там заместитель ничего не хранит и только решает, пропустить ли вызов. Здесь у заместителя появляется состояние, а с ним вопросы: когда данные устаревают, кто их сбрасывает, как проверить время без ожидания и что будет, если писать в обход.
Задача
Карточка товара в магазине читает цену и название из репозитория, а репозиторий ходит в базу, которая под нагрузкой отвечает медленно. Одну и ту же карточку открывают сотни раз в минуту, цены меняются несколько раз в день. Первая версия кэшировала прямо в контроллере: словарь sku -> товар без срока жизни. После смены цены покупатели до перезапуска видели старую, а тест «кэш истекает через минуту» делал sleep(60) и его отключили.
Нужно, чтобы кэш был отдельно от кода, который читает товары, и чтобы его поведение проверялось за миллисекунды.
Что урезано
- Медленный репозиторий — словарь в памяти со счётчиком чтений; в проекте это SQL-запрос или HTTP-вызов каталога.
- Кэш — словарь в памяти процесса без ограничения размера и вытеснения; в проекте это LRU, Redis или APCu.
- Один процесс: инвалидация не рассылается другим экземплярам приложения.
- Нет защиты от одновременных промахов (stampede): два параллельных первых чтения оба пойдут в базу.
Решение
Интерфейс ProductRepository с методами find и save знают все, кто работает с товарами. Медленный репозиторий SlowProductRepository его реализует и считает обращения в reads, чтобы тесты видели, дошёл ли вызов до базы.
Заместитель CachedProductRepository реализует тот же интерфейс. find смотрит в свой словарь: запись есть и не истекла — попадание, иначе промах, чтение из настоящего репозитория и новая запись со сроком now + ttl. save передаёт запись дальше и удаляет запись кэша по этому sku: следующее чтение пойдёт в базу и увидит новую цену. Счётчики hits и misses — то, что в проекте ушло бы в метрики.
Время заместитель берёт не из системы, а из часов Clock, переданных в конструктор. В бою это системные часы, в тестах — подделка, которую двигают вручную.
Структура
Контракт не изменился по сравнению с репозиторием без кэша: Product неизменяем, а «товара нет» — отдельная ошибка ProductNotFound. Неизменяемость здесь важна: кэш отдаёт один и тот же объект многим читателям, и никто не может поменять цену в чужой карточке.
Часы вместо sleep
TTL — правило про время, и проверять его надо, управляя временем, а не ожидая его. Заместитель не вызывает time() или Date.now() сам: он спрашивает часы, полученные в конструкторе. Тест создаёт часы на фиксированном моменте, читает товар, сдвигает часы на 59 секунд — попадание, на 60 — промах. Граница проверяется точно, и тест идёт миллисекунды.
Истечение — строгое сравнение now < expiresAt: запись, прожившая ровно TTL, уже устарела. Это решение, и тест его фиксирует, чтобы оно не поменялось случайно при правке < на <=.
Как это собирается
Заместителя подключает DI-контейнер: интерфейс ProductRepository связан с CachedProductRepository, внутри которого медленный репозиторий и системные часы, срок жизни записи - минута. Ни карточка товара, ни админка не знают, что за интерфейсом кэш: обе получают репозиторий в конструкторе и зовут find и save. В тестах часы подменяются, в примере стоят настоящие: весь сценарий укладывается в миллисекунды, до истечения записи дело не доходит.
Три просмотра карточки дают одно чтение из базы. Админка меняет цену через тот же репозиторий, save сбрасывает запись, и следующий просмотр уже видит новую цену. Несуществующий товар превращается в 404 в контроллере: ProductNotFound проходит сквозь заместителя, а промах для него не кэшируется. Счётчики в последней строке - то, что в проекте ушло бы в метрики.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Заместитель кэширует только успешные ответы: если товара нет, ошибка проходит насквозь и ничего не записывается, поэтому товар, добавленный через секунду, сразу станет виден. Кэширование отказов (negative caching) — отдельное решение со своим TTL, его тут нет.
| Язык | Особенность |
|---|---|
| PHP | Часы — свой интерфейс Clock с DateTimeImmutable (в проекте — PSR-20 ClockInterface); запись кэша — массив-форма с типом в PHPDoc |
| Go | Часы — функциональный тип Clock func() time.Time, в тест передаётся метод-значение clock.Now; TTL — time.Duration, сравнение now.Before(expiresAt) |
| Python | Часы — Callable[[], float] с умолчанием time.monotonic: монотонные часы не прыгают при переводе системного времени |
| TypeScript | Часы — () => number с умолчанием Date.now; в тесте замыкание над переменной now, без фейковых таймеров |
Запуск: make test T=patterns/structural/proxy/practice
Что проверяют тесты
Тесты одинаковы во всех языках: товар KTL-1 за 2500, TTL 60 секунд. Повторное чтение через 59 секунд — попадание: одно чтение из базы, одно попадание, один промах. Через ровно 60 секунд запись истекла: два чтения, ноль попаданий. Запись новой цены 2990 через заместителя сбрасывает кэш, и следующее чтение возвращает 2990. Главный тест — граница: цену 2990 записали прямо в медленный репозиторий, и заместитель до истечения TTL продолжает отдавать 2500, а после — 2990. Последний тест: отсутствующий товар не кэшируется, два чтения — два обращения к базе.
Где граница
- Запись мимо заместителя. Кэш знает только о записях, которые прошли через него. Импорт цен из 1С, миграция, админка в другом сервисе пишут в базу напрямую — и до истечения TTL покупатели видят старую цену. Тест это фиксирует. TTL — страховка, а не решение: когда пишущих несколько, инвалидацию переносят в событие («цена изменилась»), на которое подписан кэш, или в саму базу (CDC).
- Несколько процессов. Кэш в памяти у каждого экземпляра свой, и
saveсбрасывает только локальный. Нужен общий кэш (Redis) или рассылка инвалидаций. - Нагрузка. Без ограничения размера словарь растёт с каталогом; без защиты от одновременных промахов истечение популярной записи шлёт в базу пачку одинаковых запросов. Это задачи готовой библиотеки кэша, и заместитель тогда остаётся тонкой обёрткой над ней.
- Избыточность. Если база отвечает быстро или данные читают редко, кэш добавляет только устаревание и сложность.
Отличия между языками
- PHP — процесс живёт один запрос, и кэш в памяти заместителя живёт столько же. Тот же заместитель с кэшем во внешнем хранилище (APCu, Redis) работает между запросами. Время —
DateTimeImmutable, его сравнение<работает напрямую. - Go — часы как функция короче интерфейса с одним методом, и в тест передаётся
clock.Now— метод, привязанный к экземпляру. Для конкурентного доступа заместителю понадобитсяsync.Mutex; здесь он опущен, как и в остальных языках. - Python —
time.monotonicподходит для TTL лучшеtime.time: перевод часов на сервере не продлит и не оборвёт срок жизни записей. Подделка часов — объект с__call__, протоколуCallableэтого достаточно. - TypeScript —
vi.useFakeTimers()тоже подменил быDate.now, но глобально и для всего теста. Явные часы в конструкторе видны в сигнатуре и не требуют магии фреймворка.
Связанные темы
- Заместитель (базовый пример)
- Практика Одиночки: тарифы, загруженные один раз — загрузка один раз без срока жизни; здесь, наоборот, данные обязаны устаревать