Кеширование методов
#[Cacheable] запоминает результат метода: первый вызов выполняет тело, следующие с
тем же ключом получают сохранённое значение без похода в базу. Набор атрибутов, их
параметры и поведение взяты у Spring Cache.
Что это и зачем
Кеширование метода — сохранение того, что метод вернул, чтобы при следующем вызове с теми же аргументами отдать готовый ответ, а не вычислять его снова.
Проблема. Часть данных читается намного чаще, чем меняется: справочник стран, карточка товара, настройки тарифа. Каждый запрос всё равно идёт за ними в базу, и нагрузка на неё растёт вместе с трафиком, хотя ответ раз за разом один и тот же. Кеш, написанный вручную, — это проверка «есть ли в кеше», запись после вычисления и удаление при изменении, размазанные по всем методам сервиса. Забытое удаление незаметно до тех пор, пока пользователь не увидит старые данные.
Решение. Пометьте метод атрибутом — фреймворк сам проверит кеш, вызовет тело при промахе и сохранит результат. Удаление при изменении объявляется так же, атрибутом на методе, который меняет данные.
Родословная
| 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 идёт через кеш.
Каждое попадание — копия. Хранилище держит сериализованное значение, и каждый вызов получает свой экземпляр. Изменить объект из кеша, а потом увидеть изменения у соседнего запроса нельзя.
Быстрый старт
Три шага: включить, объявить кеш, пометить метод.
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);
}
}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 МБ
}
}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); // выполнится один раз на код
}
}Сервис внедряется как обычно — контейнер сам отдаст версию с кешем:
#[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
// 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 байт, объект и массив той же формы весят почти одинаково. Точное число для своих данных:
echo strlen(serialize($service->all())); // байт — сравните с valueSizeСколько памяти займёт таблица
Память резервируется целиком в момент запуска — сколько бы записей потом ни появилось. Swoole округляет число строк вверх до степени двойки и добавляет запас на коллизии, поэтому оценка такая:
степень двойки ≥ 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 МБИтог виден в баннере при запуске:
web http://0.0.0.0:8000
cache 2 table · 1 memory · ≈53 MB sharedКогда значение не помещается
Значение длиннее valueSize или запись в заполненную таблицу не сохраняются:
метод всё равно возвращает правильный результат, просто без кеша. Сам Swoole
слишком длинную строку молча обрезает, а обрезанное значение уже не прочитать —
поэтому такая запись до таблицы не доходит, а старая запись под тем же ключом
удаляется, чтобы не отдавать устаревшее.
Перед отказом таблица один раз удаляет просроченные записи. Если места всё равно нет, в лог пишется предупреждение — не чаще раза в минуту на кеш, со счётчиком пропусков:
[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
// 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.
// Токен, который переживёт перезапуск и виден командам call.
$caches->file('api-token', ttl: 3300);
// Настройки, которые сбрасывает консольная команда.
$caches->file('settings', version: 'v2');Под FPM, где процесс живёт один запрос, это хранилище по-прежнему кеширует.
redis — общий для всех экземпляров
Единственное хранилище, которое разделяют несколько экземпляров приложения: реплики
в Kubernetes, серверы за балансировщиком. Удаление на одном сервере видят все.
Нужен пакет winter-redis — без него объявление redis()
остановит запуск с подсказкой, что установить.
$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] — вернуть из кеша или вычислить
#[Cacheable('products', key: '{id}', ttl: 300)]
public function find(int $id): ?Product| Параметр | По умолчанию | Что задаёт |
|---|---|---|
cache |
из #[CacheConfig] |
Имя кеша; первый позиционный аргумент |
key |
все аргументы | Шаблон ключа |
ttl |
из #[CacheConfig], затем у кеша |
Время жизни в секундах; 0 — бессрочно |
sync |
false |
Одновременные промахи по одному ключу ждут одно вычисление |
cacheNull |
true |
Сохранять ли null |
Если запись есть — тело не выполняется. Если нет — выполняется, и результат сохраняется. Исключение из тела не кешируется: следующий вызов попробует снова.
#[CachePut] — всегда выполнить и обновить
Для методов, которые меняют данные и возвращают новое состояние. Тело выполняется всегда, а результат записывается в кеш — следующее чтение его найдёт.
#[CachePut('products', key: '{result.id}')]
public function save(Product $product): ProductПараметры те же, что у #[Cacheable], кроме sync. В ключе доступен {result} —
возвращённое значение. С cacheNull: false результат null удаляет запись, а не
оставляет старую.
#[CacheEvict] — удалить
#[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] — значения по умолчанию для класса
#[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 — в секундах. Значение берётся с первого уровня, где оно задано:
- параметр атрибута на методе;
#[CacheConfig]на классе;ttlкеша вCacheConfigurer;- нигде — запись живёт, пока её не удалят или не перезапустят приложение.
ttl: 0 означает «бессрочно» и перекрывает уровень выше — так один метод в классе с
#[CacheConfig(ttl: 60)] можно сделать бессрочным. Просроченная запись считается
промахом при чтении: метод выполняется заново, результат перезаписывает её.
null тоже кешируется
По умолчанию, как в Spring, null сохраняется так же, как любое значение: «такой
записи нет» — тоже ответ, и база не должна получать один и тот же пустой запрос на
каждый вызов.
Следствие: если запись появилась позже, кеш продолжит отдавать null, пока его не
удалят. Метод создания должен сообщить об этом кешу:
#[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 первый запрос вычисляет
значение, остальные ждут его и получают готовый результат.
#[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— очистить кеш явно, например в скрипте деплоя.
php call cache clear --all # все file и redis кеши
php call runПроверяется только верхний уровень
Неполный объект, вложенный в массив, так не ловится: снаружи значение восстановилось.
Если меняете классы, которые лежат в кеше списками, меняйте version или очищайте
кеш при деплое.
Примеры
Токен внешнего API на час
Самый частый случай. Сервис авторизации выдаёт токен на час, и запрашивать его на каждый вызов — лишняя задержка и риск упереться в лимит выдачи.
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;
}
}$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', …). Атрибуты при этом не меняются.
Константы из базы
Настройки, тарифы, тексты — читаются на каждом запросе, меняются раз в неделю.
#[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);
}
}$caches->table('settings'); // до 16 КБ настроек — сотни парВсе настройки — одно значение под ключом 'all': один запрос к базе на всех, а
set() сбрасывает его сразу во всех воркерах. ttl не нужен — запись живёт, пока
её не изменят. Если настройки меняют в обход сервиса (руками в базе), добавьте
ttl, например 600: тогда изменение подхватится не позже чем через десять минут.
Справочник
Данные почти не меняются, ключ — код, удалять нечего.
#[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();
}
}$caches->memory('currencies'); // меняется только с деплоем — memory достаточноСущность: чтение, изменение, удаление
#[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);
}
}$caches
->table('products', rows: 10_000, valueSize: 2 * 1024) // ≈51 МБ
->table('product-lists', rows: 512, valueSize: 16 * 1024); // страница до 45 карточек, ≈12 МБОстатки — часто меняются, быстро устаревают
Значение живёт полминуты, а общая память не нужна: каждый воркер держит свою копию.
#[Cacheable('stock', key: '{sku}', ttl: 30)]
public function available(string $sku): int
// CacheConfig:
$caches->memory('stock', maxItems: 5_000);Дальше
- Асинхронные вызовы — тот же механизм прокси для
#[Async] - Команда cache — посмотреть и очистить кеши
fileиredis - Команда di — сборка прокси и проверка имён кешей
- Внедрение зависимостей — почему сервис нужно внедрять, а не создавать
- Настройка веб-слоя —
SERVER_WORKERSи перезапуск воркеров