@CacheEvict уходит раньше коммита, и кэш врёт до перезапуска

В каталоге 100 рублей, в корзине 200. Обе цифры пришли из одного сервиса и из одного метода, и живёт это расхождение до перезапуска пода или до истечения TTL.
А выглядит код самым аккуратным в проекте. Обновление цены помечено @Transactional и @CacheEvict. Ключ совпадает, транзакция на месте. Воспроизвести в один поток не выходит. Обновил вручную, прочитал — новая. Тест зелёный.
Расхождение вылезает только если чтение попадёт в промежуток, пока транзакция ещё открыта. И этот промежуток вы, скорее всего, сами себе и растянули, потому что послушали самый частый совет про @CacheEvict.
Кэш и транзакция — два независимых перехватчика
Обе аннотации работают через прокси, но обслуживают их разные перехватчики, и друг о друге они ничего не знают. @Transactional обслуживает TransactionInterceptor, @Cacheable и родня — CacheInterceptor, и на одном бине они просто становятся в цепочку. Посмотреть цепочку можно прямо в рантайме:
if (bean instanceof Advised advised) {
for (Advisor adv : advised.getAdvisors()) {
System.out.println(adv.getAdvice().getClass().getSimpleName());
}
}Вывод такой:
PriceService$$SpringCGLIB$$0
1. TransactionInterceptor
2. CacheInterceptorТранзакционный перехватчик снаружи, кэширующий внутри. Значит, к моменту, когда CacheInterceptor трогает кэш, транзакция уже открыта и ещё не закоммичена. Порядок между этими двумя ничем в вашем коде не задан. Оба советника по умолчанию получают Ordered.LOWEST_PRECEDENCE, а сортирует их AnnotationAwareOrderComparator.sort, то есть устойчивая сортировка: при равном порядке снаружи остаётся тот, чьё имя фабрика бинов вернула раньше. Мне хватило перенести @EnableCaching с главного класса в отдельную конфигурацию, чтобы порядок поменялся местами. Правда, на итог это почти не влияет, и ниже будет видно почему.
Рассуждать о таймингах вслепую не хочется. Я обернул кэш логирующим декоратором: на каждой операции он печатает поток, миллисекунды от старта и состояние транзакции.
private void log(String op, Object key) {
System.out.printf("[кэш %+5d мс] %-8s ключ=%s поток=%s транзакция=%s%n",
System.currentTimeMillis() - t0, op, key,
Thread.currentThread().getName(),
TransactionSynchronizationManager.isActualTransactionActive() ? "открыта" : "нет");
}Откат делает кэш неправдой, если вызов был вложенным
Начну со случая, который выглядит страшнее всего, а вреда не приносит. Метод с @Transactional и @CachePut сохраняет новое значение и бросает исключение. В кэше после этого остаётся прежняя сотня: @CachePut пишет в кэш только на нормальном возврате, а исключение этот путь минует. Тут Spring всё делает правильно.
А вот вариант, который живёт в любом сервисе с несколькими слоями. Метод с @CachePut вызывается из другого транзакционного метода и присоединяется к той же транзакции. Возвращается он нормально. Падает потом внешний метод:
@Transactional
public void outerFails(Long id, String value, PriceService self) {
self.cachePut(id, value);
throw new IllegalStateException("внешняя транзакция упала позже");
}Вот что вышло:
[кэш +1297 мс] put ключ=1 поток=main транзакция=нет
read() = 100
[кэш +1300 мс] put ключ=1 поток=main транзакция=открыта
исключение: внешняя транзакция упала позже
в базе 100
в кэше 999
read() = 999Запись в кэш прошла с открытой транзакцией, транзакция откатилась, в базе осталось 100, а в кэше лежит 999 — значение, которого в базе не было ни секунды. Сигнала об этом нет. Исключение вы поймали и залогировали, кэш проверять не стали.
Отравить кэш достаточно одного чтения
Предыдущий случай требовал @CachePut, и его в проектах немного. А вот метод с одним @Cacheable есть везде, и он делает то же самое, только незаметнее.
@Transactional
public void writeThenReadThenFail(Long id, String value, PriceService self) {
repo.save(new Price(id, value));
String seen = self.read(id);
throw new IllegalStateException("падаем уже после чтения");
}read тут помечен только @Cacheable. Никакой записи в кэш в нём не объявлено. Но внутри своей транзакции он видит собственную незакоммиченную правку, возвращает её и по дороге кладёт в кэш:
кэш очищен, дальше только чтение внутри транзакции
[кэш +2070 мс] get ключ=1 поток=main транзакция=открыта
[из базы] чтение 1
[кэш +2071 мс] put ключ=1 поток=main транзакция=открыта
внутри транзакции read() вернул 999
исключение: падаем уже после чтения, транзакция откачена
в базе 100
в кэше 999
read() = 999Читающий метод положил в кэш значение, которого в базе не было ни секунды, и остался в стороне от подозрений: он же ничего не пишет. Достаточно, чтобы в одной транзакции сначала прошла правка, а потом кто-то ниже по стеку прочитал ту же сущность через кэшируемый метод. Ни одна из этих двух вещей по отдельности вроде бы и не подозрительна.
Читатель, пришедший до коммита, закрепляет старое значение
Вторая половина истории про кэш сама по себе тоже безобидна. Когда цена обновляется, ключ надо выбросить из кэша, и все так и делают:
@Transactional
@CacheEvict(value = "prices", key = "#id", beforeInvocation = true)
public void update(Long id, String value, long holdMs) {
repo.save(new Price(id, value));
sleep(holdMs);
}holdMs здесь заменяет то, что в реальном методе занимает время: вторая таблица, вызов соседнего сервиса, ожидание блокировки, сам коммит. Пока метод работает, транзакция открыта, изменение не видно никому снаружи.
Теперь сведём две половины. Один поток обновляет цену и держит транзакцию 500 мс, второй через 150 мс приходит читать. База — H2 с уровнем изоляции read committed по умолчанию, так что чужие незакоммиченные правки читателю не видны:
[кэш +268 мс] put ключ=1 поток=main транзакция=нет
read() = 100
[кэш +284 мс] evictIfPresent ключ=1 поток=writer транзакция=открыта
[кэш +421 мс] get ключ=1 поток=reader транзакция=нет
[из базы] чтение 1
[кэш +424 мс] put ключ=1 поток=reader транзакция=нет
параллельное read() = 100
после коммита в базе 200
после коммита в кэше 100
read() = 100Читающий поток сходил в базу в целом нормально, но пришёл он до коммита, увидел там прежнюю сотню и положил её в кэш. Ещё через несколько сотен миллисекунд пишущий поток закоммитился, в базе стало 200, а в кэше так и осталось 100. Выбрасывать этот ключ больше некому: обновление уже прошло, событие отработало, следующий @CacheEvict случится только при следующей правке цены. Кэш будет отдавать 100 сколько угодно долго.
Окно тут не микросекундное, как в обычных гонках. Оно равно времени, на которое транзакция остаётся открытой после выброса ключа, где-то десяткам или сотням миллисекунд, а под нагрузкой на медленной базе и секундам. Сколько чтений попадёт в такое окно, зависит от вашего трафика, но попадать в него легче, чем в обычную гонку на инструкциях.
beforeInvocation = true растягивает окно на весь метод
Про beforeInvocation = true пишут в большинстве статей про кэш в Spring, и совет верный: по умолчанию @CacheEvict выбрасывает ключ после возврата метода, и при исключении ключ остаётся на месте. Ставите beforeInvocation = true, и выброс происходит до вызова, независимо от того, чем вызов закончится.
Ровно этим действием вы и растягиваете окно. Уберём beforeInvocation, всё остальное оставим как было:
[кэш +813 мс] put ключ=1 поток=main транзакция=нет
read() = 100
[кэш +965 мс] get ключ=1 поток=reader транзакция=нет
параллельное read() = 100
[кэш +1316 мс] evict ключ=1 поток=writer транзакция=открыта
после коммита в базе 200
после коммита в кэше <пусто>
read() = 200Читатель на 965-й миллисекунде получил старое значение, и это неизбежно: транзакция открыта, в базе честно лежит 100. Но в кэш он ничего не положил, потому что ключ там ещё был, а выброс случился на 1316-й — после тела метода, перед коммитом. Итоговое состояние вышло согласованным. Следующее чтение отдало 200.
Старую цену читатель видит в обоих вариантах одинаково, а расходится судьба этого чтения: при beforeInvocation = true окно между выбросом ключа и коммитом равно всему методу, и любое чтение внутри окна фиксирует в кэше до-коммитное значение навсегда, а при значении по умолчанию окно сжимается до времени самого коммита, и попасть в него нужно постараться.
Флаг во фреймворке есть, в Spring Boot его нет
Средство от всего этого в Spring лежит с версии 3.2 и называется transaction-aware кэш. Идея простая: операции записи в кэш не выполняются сразу, а откладываются до фазы after-commit успешной транзакции. Формулировка из javadoc AbstractTransactionSupportingCacheManager:
Set whether this CacheManager should expose transaction-aware Cache objects. Default is "false". Set this to "true" to synchronize cache put/evict operations with ongoing Spring-managed transactions, performing the actual cache put/evict operation only in the after-commit phase of a successful transaction.
Значение по умолчанию — false. Свойства, чтобы переключить это из application.properties, в Spring Boot нет, и появится оно вряд ли.
Дальше начинается зависимость от того, какой у вас кэш. От AbstractTransactionSupportingCacheManager наследуются JCacheCacheManager из самого фреймворка и RedisCacheManager из Spring Data Redis, так что с Redis достаточно одного вызова в билдере:
RedisCacheManager.builder(connectionFactory)
.transactionAware()
.build();CaffeineCacheManager и ConcurrentMapCacheManager от этого класса не наследуются, метода setTransactionAware у них нет вообще. Для них нужна обёртка:
@Bean
CacheManager cacheManager() {
CaffeineCacheManager caffeine = new CaffeineCacheManager("prices");
return new TransactionAwareCacheManagerProxy(caffeine);
}На вложенном откате это работает как обещано. Запись в кэш откладывается, коммита не происходит, значение 999 в кэш не попадает, после исключения там остаётся прежняя сотня. Отравление чтением лечится тем же движением: put от @Cacheable тоже ждёт коммита, поэтому после откатa кэш остаётся пустым, а следующее чтение идёт в базу и приносит 100.
evictIfPresent не откладывается никогда
А на гонке из предыдущего раздела включённый transaction-aware не меняет ничего. Всё то же самое, тот же beforeInvocation = true, кэш обёрнут в TransactionAwareCacheManagerProxy:
[кэш +337 мс] evictIfPresent ключ=1 поток=writer транзакция=открыта
[кэш +482 мс] put ключ=1 поток=reader транзакция=нет
после коммита в базе 200
после коммита в кэше 100Выброс ключа произошёл сразу, с открытой транзакцией, никуда не отложившись. Причина видна в исходниках TransactionAwareCacheDecorator из spring-context-support: откладываются ровно три операции, и evictIfPresent среди них нет.
@Override
public void evict(final Object key) {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
TransactionAwareCacheDecorator.this.targetCache.evict(key);
}
});
}
else {
this.targetCache.evict(key);
}
}
@Override
public boolean evictIfPresent(Object key) {
return this.targetCache.evictIfPresent(key);
evictIfPresent возвращает, был ли ключ в кэше, а этот ответ нельзя выдать сейчас, если саму операцию отложить на потом. В javadoc класса про это написано прямо: «Use of immediate operations such as putIfAbsent and evictIfPresent cannot be deferred to the after-commit phase of a running transaction. Use these with care in a transactional environment.»
Остаётся связать это с аннотацией, и связь оказывается в одну строчку. AbstractCacheInvoker выбирает метод кэша по флагу, который приходит прямо из beforeInvocation:
protected void doEvict(Cache cache, Object key, boolean immediate) {
try {
if (immediate) {
cache.evictIfPresent(key);
}
else {
cache.evict(key);
}
}
catch (RuntimeException ex) {
getErrorHandler().handleCacheEvictError(ex, cache, key);
}
}Заодно видно, почему порядок перехватчиков тут почти ничего не решает. Если выброс ключа не откладывается вообще, безразлично, стоит кэширующий советник снаружи транзакции или внутри: ключ в любом случае уходит из кэша раньше коммита, и окно остаётся открытым. Порядок начинает что-то значить только для put и evict, там, где transaction-aware работает.
@CacheEvict(beforeInvocation = true) означает evictIfPresent, а evictIfPresent не откладывается никогда. То есть совет, который вы применили, чтобы кэш не остался грязным при ошибке, заодно отключил единственный штатный механизм согласования кэша с транзакцией.
Что теряется вместе с включённым transaction-aware
Там, где transaction-aware действительно работает, у него есть своя цена, и она видна в том же куске кода выше. Вызов кэша обёрнут в try, а ошибки уходят в CacheErrorHandler — тот самый, через который принято глушить недоступность Redis, чтобы упавший кэш не ронял запросы. При отложенной операции реального вызова внутри этого try не происходит: там только регистрация синхронизации. Сам evict выполняется позже, в afterCommit, далеко от обработчика, и до него исключение не доходит.
А еще само чтение декоратор не откладывает. get уходит в целевой кэш сразу, а put ждёт коммита. Значение, записанное внутри транзакции, до её конца в кэше не появляется, и любое чтение того же ключа в той же транзакции идёт в базу. На горячем методе, который сначала пишет, а потом несколько раз читает, это лишние запросы там, где раньше отвечал кэш.
С включённым transaction-aware падение кэша перестаёт быть безобидным. Транзакция к этому моменту уже закоммичена, откатить её нельзя, а исключение из afterCommit прилетит вызывающему коду как ошибка запроса, хотя запись прошла.
Согласовывать приходится руками
Короче, кэш в Spring в транзакциях не участвует. Штатный способ его туда завести отложит запись до коммита, но та самая настройка @CacheEvict, которую советуют чаще всего, этот способ выключает, а включённый он стоит вам работающего обработчика ошибок кэша.
Такие нюансы появляются, когда работа с Spring выходит за рамки базовых сценариев и приходится разбираться во внутренних механизмах фреймворка. Проверить, насколько ваши знания соответствуют уровню курса «Разработчик на Spring Framework», можно с помощью вступительного теста.
Дешевле всего начать с того, чтобы убрать beforeInvocation = true там, где он стоит по привычке. Значение по умолчанию оставляет узкое окно вместо широкого, а защиту от грязного кэша при ошибке проще получить иначе. Если нужен контроль над моментом выброса, его стоит взять в свои руки и не полагаться на кэширующий советник вовсе:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPriceChanged(PriceChanged event) {
cacheManager.getCache("prices").evict(event.id());
}Итоговое состояние выходит правильным: читатель, пришедший до коммита, старую цену увидел, но в кэш её не записал, а выброс ключа случился после коммита, и следующее чтение отдало 200. Слушатель в фазе AFTER_COMMIT выполняется ещё внутри синхронизации, до отвязки ресурсов транзакции: isActualTransactionActive внутри него отвечает «открыта». Записывать из него в базу смысла нет, та транзакция уже закоммичена, а выбросить ключ из кэша — именно то, что нужно.
Второй слой защиты — короткий TTL на записях, которые обновляются по событию. Он не лечит гонку, но превращает вечное расхождение во временное, а это разница между жалобой из поддержки и записью в логе.
Проверяется всё это на своём сервисе за вечер.

Разбирая такие кейсы, легко столкнуться с ситуацией, когда код выглядит корректно, но поведение сервиса меняется под нагрузкой. На бесплатных уроках разберём похожие практические задачи из Spring-разработки: как добавлять AI-функции в приложения и как находить причины проблем в production-сценариях.
30 сентября в 20:00. «Spring AI 2.0 на практике: добавляем AI в Spring Boot и доводим до production». Записаться
22 октября в 20:00. «Spring Boot под нагрузкой: почему сервис работает локально и падает в production». Записаться
Больше полезных материалов по бэкенд-разработке смотрите в дайджесте.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.