Продвинутое

Кеширование методов

#[Cacheable] запоминает результат метода: первый вызов выполняет тело, следующие с тем же ключом получают сохранённое значение без похода в базу. Набор атрибутов, их параметры и поведение взяты у Spring Cache.

Атрибуты #[Cacheable] · #[CachePut] · #[CacheEvict]Включение #[EnableCaching]Хранилища table · memory · file · redis

Что это и зачем

Кеширование метода — сохранение того, что метод вернул, чтобы при следующем вызове с теми же аргументами отдать готовый ответ, а не вычислять его снова.

Проблема. Часть данных читается намного чаще, чем меняется: справочник стран, карточка товара, настройки тарифа. Каждый запрос всё равно идёт за ними в базу, и нагрузка на неё растёт вместе с трафиком, хотя ответ раз за разом один и тот же. Кеш, написанный вручную, — это проверка «есть ли в кеше», запись после вычисления и удаление при изменении, размазанные по всем методам сервиса. Забытое удаление незаметно до тех пор, пока пользователь не увидит старые данные.

Решение. Пометьте метод атрибутом — фреймворк сам проверит кеш, вызовет тело при промахе и сохранит результат. Удаление при изменении объявляется так же, атрибутом на методе, который меняет данные.

Родословная

Winter Аналог в Spring
#[Cacheable] @Cacheable
#[CachePut] @CachePut
#[CacheEvict] @CacheEvict
#[CacheConfig] @CacheConfig
#[EnableCaching] @EnableCaching
CacheConfigurer бин CacheManager

Где Winter отличается от Spring

Ключ — шаблон, а не выражение. Вместо SpEL — подстановка параметров: '{id}', '{filter.region}:{page}'. Шаблон разбирается один раз при сборке, и опечатка в имени параметра останавливает сборку, а не превращается в ключ, который ни с чем не совпадает. Условий condition и unless нет.

Время жизни задаётся здесь же. В Spring TTL — забота конкретного хранилища; в Winter это параметр ttl у кеша и у атрибута.

Вызов соседнего метода тоже кешируется. В Spring @Cacheable не срабатывает, когда метод вызывается из того же класса через this. В Winter прокси — подкласс вашего сервиса, поэтому вызов через $this идёт через кеш.

Каждое попадание — копия. Хранилище держит сериализованное значение, и каждый вызов получает свой экземпляр. Изменить объект из кеша, а потом увидеть изменения у соседнего запроса нельзя.

Быстрый старт

Три шага: включить, объявить кеш, пометить метод.

bootstrap.php
use Flytachi\Winter\Kernel\App\Attribute\EnableCaching;
use Flytachi\Winter\Kernel\App\Attribute\EnableWeb;

#[EnableWeb]
#[EnableCaching]
final class Application extends WinterApplication
{
  public static function main(array $argv): never
  {
      parent::run($argv);
  }
}
main/Config/CacheConfig.php
namespace Main\Config;

use Flytachi\Winter\Kernel\Cache\CacheConfigurer;
use Flytachi\Winter\Kernel\Cache\Caches;

final class CacheConfig implements CacheConfigurer
{
  public function configureCaches(Caches $caches): void
  {
      $caches->table('countries');   // умолчания: 64 записи до 16 КБ, ≈1,6 МБ
  }
}
main/Service/CountryService.php
namespace Main\Service;

use Flytachi\Winter\Kernel\Cache\Attribute\Cacheable;

class CountryService
{
  #[Cacheable('countries', key: '{code}')]
  public function find(string $code): ?Country
  {
      return $this->repository->find($code);   // выполнится один раз на код
  }
}

Сервис внедряется как обычно — контейнер сам отдаст версию с кешем:

php
#[Inject]
private CountryService $countries;

$this->countries->find('UZ');   // тело выполнено, результат сохранён
$this->countries->find('UZ');   // из кеша, без запроса к базе

Без `#[EnableCaching]` атрибуты молчат

Как и в Spring, атрибуты без включающего атрибута ничего не делают: класс не проксируется, каждый вызов выполняет тело. Ошибки при этом нет.

Хранилища

Каждый кеш объявляется в CacheConfigurer и живёт в одном из четырёх хранилищ: table и memory — в памяти сервера, file и redis — вне её, поэтому их видят консольные команды и перезапуск им не страшен.

table memory file redis
Где хранится Swoole\Table в общей памяти массив PHP в воркере файлы в storage/cache сервер Redis
Видят воркеры сервера все только свой все все
Видят команды call … нет нет да да
Видят другие экземпляры нет нет только с общим диском да
#[CacheEvict] у всех воркеров только в своём воркере у всех воркеров у всех экземпляров
Перезапуск и деплой теряет теряет переживает переживает
Чтение ~0,4 мкс ~0,2 мкс ~17 мкс сетевой запрос
Память резерв при старте растёт по мере записи не занимает не занимает
Размер значения ограничен valueSize любой любой любой
Настраивать rows, valueSize — или умолчания maxItems ничего класс подключения
Что нужно ext-swoole — — пакет winter-redis

Какое выбрать

Задача Хранилище Почему
Токен внешнего API table Один токен на все воркеры и процессы: сервис авторизации не получает лишних запросов, лимиты на выдачу не тратятся
Токен, который должен пережить перезапуск или нужен командам call file Лежит на диске: перезапуск не заставит запрашивать новый, команда видит тот же
Константы и настройки из базы, которые иногда меняют table Удаление после изменения видно всем воркерам сразу
Настройки, которые меняет консольная команда file Команда сбрасывает кеш, и сервер сразу это видит
Несколько экземпляров приложения — реплики, серверы redis Единственное общее для всех: удаление на одном сервере видят остальные
Справочник, который не меняется до деплоя memory Нечего планировать, а копия в каждом воркере ничему не мешает
Мелкие быстро устаревающие данные — курсы, остатки memory Устаревают сами по ttl, общая память не нужна
Тысячи сущностей по id table с заданными rows и valueSize Предсказуемая память и один кеш на всех
Приложение под FPM file или redis Процесс живёт один запрос — table и memory там бесполезны

Короче: один сервер и данные меняются — table; нужно пережить перезапуск или видеть кеш из консоли — file; экземпляров несколько — redis; данные не меняются или сами устаревают по ttl — memory.

`#[CacheEvict]` в `memory` чистит только свой воркер

Метод изменения выполнился в одном воркере — и запись удалилась только в нём. Остальные воркеры продолжат отдавать старое значение, пока не истечёт ttl. При одном воркере (так по умолчанию) это незаметно, но стоит поднять SERVER_WORKERS — и пользователи начнут видеть то новое, то старое. Для данных, которые меняются, берите table или ставьте memory короткий ttl.

table — общая для всех процессов

Таблица создаётся в мастер-процессе до того, как от него отделяются воркеры, и поэтому общая: значение, вычисленное одним воркером, сразу отдают все остальные, а удаление в одном воркере убирает запись у всех. Её видят и процессы, поднятые рядом с сервером через #[EnableProcess], #[EnableDaemon] и #[EnableScheduler].

Параметр По умолчанию Что задаёт
rows 64 Сколько записей таблица гарантированно вместит
valueSize 16 * 1024 Наибольший размер сериализованного значения, в байтах
ttl — Время жизни записи по умолчанию, в секундах

Умолчания рассчитаны на самый частый случай — несколько значений: токен, настройки, короткий справочник. Такая таблица занимает ≈1,6 МБ.

По умолчанию воркер один

Если вы не меняли SERVER_WORKERS, воркер у сервера один. Тогда главный выигрыш table перед memory — не обмен между воркерами, а то, что кеш переживает плановый перезапуск воркера и виден процессам рядом с сервером.

Готовые настройки table

main/Config/CacheConfig.php
// 1. Несколько значений — токен, настройки, короткий список.  ≈1,6 МБ
$caches->table('api-token');

// 2. Справочник поштучно: до тысячи записей по 2 КБ.          ≈3,2 МБ
$caches->table('countries', rows: 1_024, valueSize: 2 * 1024);

// 3. Сущности по id: десять тысяч карточек по 2 КБ.           ≈51 МБ
$caches->table('products', rows: 10_000, valueSize: 2 * 1024, ttl: 300);
Вариант Что держит Память
table('api-token') до 64 значений по 16 КБ ≈1,6 МБ
rows: 1_024, valueSize: 2 * 1024 до тысячи записей по 2 КБ ≈3,2 МБ
rows: 10_000, valueSize: 2 * 1024 до десяти тысяч записей по 2 КБ ≈51 МБ

Цифры памяти — замер на живом процессе. Не нужно знать, сколько записей будет: важно, сколько их может стать. Таблица заполнится — запись не сохранится, и в логе появится предупреждение.

Что помещается в значение

valueSize ограничивает значение после serialize(). Замеры, чтобы не гадать:

Значение Размер В 16 КБ
OAuth-токен, 40 символов 48 Б ✅
JWT, ~800 символов 0,8 КБ ✅
ответ OAuth целиком: токен, срок, тип, scope 0,9 КБ ✅
20 настроек имя => значение 0,9 КБ ✅
одна сущность — 7 полей, описание ~120 символов 0,35 КБ ✅
список из 10 таких сущностей 3,4 КБ ✅
список из 45 сущностей 15,4 КБ ✅
список из 50 сущностей 17,1 КБ ❌
100 стран код => название 3,2 КБ ✅
450 стран код => название 15,2 КБ ✅

Ориентир: одна запись-сущность занимает около 350 байт, объект и массив той же формы весят почти одинаково. Точное число для своих данных:

php
echo strlen(serialize($service->all()));   // байт — сравните с valueSize

Сколько памяти займёт таблица

Память резервируется целиком в момент запуска — сколько бы записей потом ни появилось. Swoole округляет число строк вверх до степени двойки и добавляет запас на коллизии, поэтому оценка такая:

text
степень двойки ≥ rows  ×  1.5  ×  (valueSize + 120) байт

rows: 64,     valueSize: 16384  →     64 × 1.5 × 16504  ≈ 1,6 МБ
rows: 10_000, valueSize: 4096   →  16 384 × 1.5 × 4216   ≈ 99 МБ

Итог виден в баннере при запуске:

text
   web         http://0.0.0.0:8000
 cache       2 table · 1 memory · ≈53 MB shared

Когда значение не помещается

Значение длиннее valueSize или запись в заполненную таблицу не сохраняются: метод всё равно возвращает правильный результат, просто без кеша. Сам Swoole слишком длинную строку молча обрезает, а обрезанное значение уже не прочитать — поэтому такая запись до таблицы не доходит, а старая запись под тем же ключом удаляется, чтобы не отдавать устаревшее.

Перед отказом таблица один раз удаляет просроченные записи. Если места всё равно нет, в лог пишется предупреждение — не чаще раза в минуту на кеш, со счётчиком пропусков:

text
[WARN ] Cache 'settings' skipped a write: a value of 21480 bytes does not fit valueSize 16384;
raise valueSize in the CacheConfigurer. The value was returned uncached; 12 write(s) skipped
since the last warning.

Такое сообщение означает, что кеш перестал работать на часть данных и нагрузка на базу вернулась. Поднимите то, что названо в сообщении: valueSize или rows.

memory — своя у каждого воркера

Обычный массив PHP: ничего не резервируется заранее, подходит значение любого размера. Цена — у каждого воркера своя копия, и каждый вычисляет её сам.

Параметр По умолчанию Что задаёт
maxItems 1000 Сколько записей хранит каждый воркер
ttl — Время жизни записи по умолчанию, в секундах

Когда записей становится больше maxItems, вытесняется та, которую дольше всех не читали. Это обычная работа такого кеша, и предупреждений она не пишет.

Память растёт по мере записи: каждая запись стоит своего значения и ещё около 370 байт. Тысяча сущностей по ~350 байт — около 0,7 МБ на воркер, пять тысяч — около 3,4 МБ.

Готовые настройки memory

main/Config/CacheConfig.php
// 1. Справочник, неизменный до деплоя: живёт, пока жив воркер.
$caches->memory('currencies');

// 2. Курсы: сотня пар, каждая живёт минуту.
$caches->memory('rates', maxItems: 100, ttl: 60);

// 3. Остатки: тысячи позиций, полминуты.
$caches->memory('stock', maxItems: 5_000, ttl: 30);
Вариант Для чего Память на воркер
memory('currencies') данные не меняются, удалять нечего по мере заполнения, до 1000 записей
maxItems: 100, ttl: 60 значение устаревает быстро и само меньше 0,1 МБ
maxItems: 5_000, ttl: 30 много мелких горячих записей до ~3,4 МБ

file — на диске

Записи лежат файлами в storage/cache/winter-cache/<имя>/. Их читают все воркеры и процессы, видят команды call, а перезапуск и деплой им не страшны. Запись атомарная — временный файл и переименование, — поэтому половину значения никто не прочтёт.

Параметр По умолчанию Что задаёт
ttl — Время жизни записи по умолчанию, в секундах
version — Поколение записей — подкаталог; сменили версию — старые не находятся

Чтение — около 17 мкс, запись — около 150 мкс: в десятки раз медленнее table, но в сотни раз быстрее запроса к базе. Просроченные файлы удаляются при чтении и одной чисткой при запуске сервера; каталог очищает и call storage clean.

main/Config/CacheConfig.php
// Токен, который переживёт перезапуск и виден командам call.
$caches->file('api-token', ttl: 3300);

// Настройки, которые сбрасывает консольная команда.
$caches->file('settings', version: 'v2');

Под FPM, где процесс живёт один запрос, это хранилище по-прежнему кеширует.

redis — общий для всех экземпляров

Единственное хранилище, которое разделяют несколько экземпляров приложения: реплики в Kubernetes, серверы за балансировщиком. Удаление на одном сервере видят все. Нужен пакет winter-redis — без него объявление redis() остановит запуск с подсказкой, что установить.

main/Config/CacheConfig.php
$caches->redis('products', MainRedisConfig::class, ttl: 300);
Параметр По умолчанию Что задаёт
config — Класс подключения — тот же, что у хранилищ winter-redis
ttl — Время жизни записи, в секундах; истекает средствами Redis
prefix cache:<имя>: Префикс ключей; задайте свой, если одну базу делят несколько приложений
version — Поколение записей — часть префикса

Ключи в Redis видны как есть: #[Cacheable('products', key: '{id}')] с версией v2 пишет в cache:products:v2:42. allEntries и call cache clear удаляют только ключи с префиксом этого кеша — остальная база не трогается.

Если хранилище недоступно

Упавший кеш не должен ронять приложение. Если Redis не отвечает или файл нельзя записать:

  • чтение считается промахом — метод выполняется и возвращает правильный результат;
  • запись пропускается;
  • в лог пишется warning — не чаще раза в минуту на кеш, со счётчиком;
  • воркер 5 секунд не обращается к хранилищу и работает без кеша, потом пробует снова.

Пауза важна. Недоступный Redis не всегда отвечает отказом мгновенно: попытка может ждать таймаута соединения — с настройками winter-redis по умолчанию около трёх секунд. Без паузы каждый вызов платил бы их дважды, на чтение и на запись, и приложение, которое «не упало», на деле стояло бы.

Неудавшееся удаление — это `error`

Если #[CacheEvict] не смог удалить запись, она останется устаревшей, пока не истечёт ttl, — пользователи увидят старые данные. Поэтому такой сбой пишется уровнем error, а не warning. Для данных в Redis держите ttl: он ограничит, сколько проживёт запись, которую не удалось удалить.

Атрибуты

#[Cacheable] — вернуть из кеша или вычислить

php
#[Cacheable('products', key: '{id}', ttl: 300)]
public function find(int $id): ?Product
Параметр По умолчанию Что задаёт
cache из #[CacheConfig] Имя кеша; первый позиционный аргумент
key все аргументы Шаблон ключа
ttl из #[CacheConfig], затем у кеша Время жизни в секундах; 0 — бессрочно
sync false Одновременные промахи по одному ключу ждут одно вычисление
cacheNull true Сохранять ли null

Если запись есть — тело не выполняется. Если нет — выполняется, и результат сохраняется. Исключение из тела не кешируется: следующий вызов попробует снова.

#[CachePut] — всегда выполнить и обновить

Для методов, которые меняют данные и возвращают новое состояние. Тело выполняется всегда, а результат записывается в кеш — следующее чтение его найдёт.

php
#[CachePut('products', key: '{result.id}')]
public function save(Product $product): Product

Параметры те же, что у #[Cacheable], кроме sync. В ключе доступен {result} — возвращённое значение. С cacheNull: false результат null удаляет запись, а не оставляет старую.

#[CacheEvict] — удалить

php
#[CacheEvict('products', key: '{id}')]
#[CacheEvict('product-lists', allEntries: true)]
public function delete(int $id): void
Параметр По умолчанию Что задаёт
cache из #[CacheConfig] Имя кеша
key все аргументы Какую запись удалить
allEntries false Очистить кеш целиком
beforeInvocation false Удалить до тела, а не после

Атрибут повторяемый: один метод может чистить несколько кешей. Он сочетается с #[Cacheable] или #[CachePut] на том же методе.

По умолчанию запись удаляется после того, как тело завершилось успешно. Если тело бросило исключение, изменение не состоялось, и запись остаётся — она по-прежнему верна. С beforeInvocation: true запись удаляется до вызова, что бы ни случилось дальше.

#[CacheConfig] — значения по умолчанию для класса

php
#[CacheConfig('products', ttl: 300)]
class ProductService
{
  #[Cacheable(key: '{id}')]                 // кеш products, 300 секунд
  public function find(int $id): ?Product

  #[Cacheable(key: 'all', ttl: 60)]         // кеш products, 60 секунд
  public function all(): array
}

Значение на методе всегда важнее значения на классе.

Ключи

Ключ определяет, какая запись относится к вызову. Это шаблон над именами параметров метода:

Шаблон Ключ при вызове
'{id}' find(42) → 42
'product:{id}' find(42) → product:42
'{category}:{page}' list('books', 2) → books:2
'{filter.region}' публичное свойство объекта-параметра
'{result.id}' возвращённое значение — только в #[CachePut] и #[CacheEvict] после тела
'all' постоянный ключ — одна запись на метод

В ключ годятся строки, числа, bool, null, перечисления (enum), массивы и объекты с __toString(). Объект целиком в ключ не попадает — укажите его свойство: '{order.id}'.

Без key ключ строится из всех аргументов по порядку. У метода без параметров ключ постоянный — одна запись.

Имя метода в ключ не входит

Два метода одного кеша с одинаковым ключом делят запись. Это сделано намеренно: так #[CachePut] и #[CacheEvict] на других методах попадают в запись, которую сохранил #[Cacheable]. Но два разных по смыслу метода в одном кеше разведите префиксом: 'by-id:{id}' и 'by-sku:{sku}'.

Время жизни

ttl — в секундах. Значение берётся с первого уровня, где оно задано:

  1. параметр атрибута на методе;
  2. #[CacheConfig] на классе;
  3. ttl кеша в CacheConfigurer;
  4. нигде — запись живёт, пока её не удалят или не перезапустят приложение.

ttl: 0 означает «бессрочно» и перекрывает уровень выше — так один метод в классе с #[CacheConfig(ttl: 60)] можно сделать бессрочным. Просроченная запись считается промахом при чтении: метод выполняется заново, результат перезаписывает её.

null тоже кешируется

По умолчанию, как в Spring, null сохраняется так же, как любое значение: «такой записи нет» — тоже ответ, и база не должна получать один и тот же пустой запрос на каждый вызов.

Следствие: если запись появилась позже, кеш продолжит отдавать null, пока его не удалят. Метод создания должен сообщить об этом кешу:

php
#[Cacheable('users', key: '{email}')]
public function findByEmail(string $email): ?User

#[CacheEvict('users', key: '{user.email}')]
public function register(User $user): User

Если это неудобно, отключите сохранение пустого результата: cacheNull: false.

sync — одно вычисление на промах

Когда запись истекает под нагрузкой, десятки запросов разом получают промах и одновременно идут в базу за одним и тем же. С sync: true первый запрос вычисляет значение, остальные ждут его и получают готовый результат.

php
#[Cacheable('reports', key: 'daily', ttl: 600, sync: true)]
public function dailyReport(): Report

Ожидание — внутри одного воркера

Ждут друг друга корутины одного воркера. Если воркеров несколько, каждый вычислит значение сам — то есть не больше одного вычисления на воркер, а не одно на сервер.

Проверки при сборке

Всё, что можно проверить по объявлению, проверяется при call di build и при старте приложения — а не на первом вызове в проде:

Ошибка Пример сообщения
Кеш не объявлен undeclared cache 'prodcuts' used by ProductService::find()
У атрибута нет имени кеша #[Cacheable] names no cache
В шаблоне неизвестный параметр key refers to {idd}, but the method has no parameter $idd
Параметр нельзя превратить в ключ parameter $order of type Order cannot be part of one
Нечего сохранить #[Cacheable] needs a value to store, but the method returns void
Параметр по ссылке parameter $rows is passed by reference
#[Async] и кеш на одном методе the method carries both #[Async] and #[Cacheable]/…

Опечатка в имени кеша у #[CacheEvict] особенно опасна: такой метод просто ничего не удалял бы, и пользователи видели бы старые данные. Поэтому имя проверяется до запуска.

Ошибка видна и при `DEBUG=false`

Сорвавшийся запуск всегда пишет одну строку в stderr — её покажет docker logs — и завершается с кодом 1; с включённым логом та же ошибка уходит в него как CRIT. Стектрейс добавляется только при DEBUG=true.

Требования к классу и методу

Те же, что у #[Async], потому что работает тот же механизм — сгенерированный подкласс:

  • класс не final, метод не final, не static и не private;
  • сервис получен из контейнера, а не создан через new — иначе прокси не применяется, и call di build предупредит об этом;
  • #[Cacheable] и #[CachePut] — на методе, который возвращает сохраняемое значение: не void, never, Generator, Closure, callable или iterable;
  • значение должно проходить через serialize(): замыкание внутри объекта не сохранится, метод вернёт его без кеша и предупредит в логе.

Один класс может сочетать методы #[Async] и методы с кешем — у него будет один общий прокси. На одном методе их совместить нельзя.

Чего кеш не видит

  • Консольные команды — table и memory. call … — отдельный процесс со своей памятью: команда, которая меняет данные, такой кеш сервера не очистит. Если это нужно, берите file или redis — до них дойдут и #[CacheEvict] в команде, и call cache clear.
  • Другие экземпляры приложения — всё, кроме redis. Каждый контейнер держит свою таблицу и свой каталог; изменение в одном не удалит запись в другом.
  • FPM — table и memory. Процесс живёт один запрос, и такой кеш вместе с ним.

Деплой и старые данные

table и memory после перезапуска пустые. file и redis переживают деплой — а с ним и записи, сохранённые старым кодом. Если класс значения изменился, например появилось новое типизированное свойство, объект из такой записи восстановится неполным.

Против этого три меры:

  • запись, которая не восстанавливается, — исчезнувший класс, испорченные байты — считается промахом: удаляется и вычисляется заново;
  • version у кеша — сменили, и старые записи просто не находятся: в redis они истекут сами, в file их уберёт чистка;
  • call cache clear — очистить кеш явно, например в скрипте деплоя.
sh
php call cache clear --all   # все file и redis кеши
php call run

Проверяется только верхний уровень

Неполный объект, вложенный в массив, так не ловится: снаружи значение восстановилось. Если меняете классы, которые лежат в кеше списками, меняйте version или очищайте кеш при деплое.

Примеры

Токен внешнего API на час

Самый частый случай. Сервис авторизации выдаёт токен на час, и запрашивать его на каждый вызов — лишняя задержка и риск упереться в лимит выдачи.

main/Service/PaymentGateway.php
use Flytachi\Winter\Kernel\Cache\Attribute\CacheEvict;
use Flytachi\Winter\Kernel\Cache\Attribute\Cacheable;

class PaymentGateway
{
  #[Cacheable('api-token', key: 'payment', ttl: 3300, sync: true, cacheNull: false)]
  public function token(): string
  {
      $response = $this->http->post('https://auth.example.com/oauth/token', [
          'client_id'     => $this->clientId,
          'client_secret' => $this->clientSecret,
          'grant_type'    => 'client_credentials',
      ]);

      return $response['access_token'];
  }

  #[CacheEvict('api-token', key: 'payment')]
  public function forgetToken(): void
  {
  }

  public function charge(Order $order): array
  {
      $response = $this->send($order, $this->token());

      // Токен отозвали раньше срока: забыть, получить новый, повторить один раз.
      if ($response['status'] === 401) {
          $this->forgetToken();
          $response = $this->send($order, $this->token());
      }

      return $response;
  }
}
main/Config/CacheConfig.php
$caches->table('api-token');   // умолчания: ≈1,6 МБ, хватит на десятки токенов

Что здесь важно:

  • ttl: 3300 — 55 минут при часовом токене. Запас нужен, чтобы кеш не отдал токен, которому осталась секунда: запрос с ним уйдёт и вернётся с 401.
  • sync: true — когда токен истёк, десятки одновременных запросов не пойдут за новым разом: один получит, остальные подождут его.
  • cacheNull: false — если сервис авторизации вернул пустой ответ, это не запомнится на 55 минут. Исключение не кешируется в любом случае.
  • key: 'payment' — один кеш api-token может хранить токены нескольких сервисов под разными ключами.
  • $this->token() внутри charge() идёт через кеш: прокси — подкласс сервиса, поэтому вызов собственного метода тоже кешируется.
  • table, а не memory: токен один на все воркеры и процессы, а forgetToken() сбрасывает его у всех сразу. Должен пережить перезапуск — file('api-token'); экземпляров несколько — redis('api-token', …). Атрибуты при этом не меняются.

Константы из базы

Настройки, тарифы, тексты — читаются на каждом запросе, меняются раз в неделю.

main/Service/SettingsService.php
#[CacheConfig('settings')]
class SettingsService
{
  #[Cacheable(key: 'all')]
  public function all(): array
  {
      return $this->repository->pairs();   // ['name' => 'value', ...]
  }

  public function get(string $name, ?string $default = null): ?string
  {
      return $this->all()[$name] ?? $default;   // из кеша
  }

  #[CacheEvict(key: 'all')]
  public function set(string $name, string $value): void
  {
      $this->repository->save($name, $value);
  }
}
main/Config/CacheConfig.php
$caches->table('settings');   // до 16 КБ настроек — сотни пар

Все настройки — одно значение под ключом 'all': один запрос к базе на всех, а set() сбрасывает его сразу во всех воркерах. ttl не нужен — запись живёт, пока её не изменят. Если настройки меняют в обход сервиса (руками в базе), добавьте ttl, например 600: тогда изменение подхватится не позже чем через десять минут.

Справочник

Данные почти не меняются, ключ — код, удалять нечего.

main/Service/CurrencyService.php
#[CacheConfig('currencies')]
class CurrencyService
{
  #[Cacheable(key: '{code}')]
  public function find(string $code): ?Currency
  {
      return $this->repository->find($code);
  }

  #[Cacheable(key: 'all')]
  public function all(): array
  {
      return $this->repository->findAll();
  }
}
main/Config/CacheConfig.php
$caches->memory('currencies');   // меняется только с деплоем — memory достаточно

Сущность: чтение, изменение, удаление

main/Service/ProductService.php
#[CacheConfig('products', ttl: 300)]
class ProductService
{
  #[Cacheable(key: '{id}')]
  public function find(int $id): ?Product
  {
      return $this->repository->find($id);
  }

  // Новое состояние сразу кладётся в кеш, списки сбрасываются.
  #[CachePut(key: '{result.id}')]
  #[CacheEvict('product-lists', allEntries: true)]
  public function save(Product $product): Product
  {
      return $this->repository->save($product);
  }

  #[CacheEvict(key: '{id}')]
  #[CacheEvict('product-lists', allEntries: true)]
  public function delete(int $id): void
  {
      $this->repository->delete($id);
  }

  #[Cacheable('product-lists', key: '{category}:{page}', ttl: 60)]
  public function byCategory(string $category, int $page = 1): array
  {
      return $this->repository->byCategory($category, $page);
  }
}
main/Config/CacheConfig.php
$caches
  ->table('products', rows: 10_000, valueSize: 2 * 1024)         // ≈51 МБ
  ->table('product-lists', rows: 512, valueSize: 16 * 1024);     // страница до 45 карточек, ≈12 МБ

Остатки — часто меняются, быстро устаревают

Значение живёт полминуты, а общая память не нужна: каждый воркер держит свою копию.

php
#[Cacheable('stock', key: '{sku}', ttl: 30)]
public function available(string $sku): int

// CacheConfig:
$caches->memory('stock', maxItems: 5_000);

Дальше