InquirerZamboanga del Sur launches tourism website, tourist policeCNN TürkAnTuTu'da rekor kırdıESPNJJ Gabriel and the 'nightmare' battle for Premier League academy talentThe Jerusalem PostIran is renewing progress on its nuclear program, Netanyahu tells UAE President MBZ - reportBollywood HungamaEXCLUSIVE: CBFC orders 45-50 cuts for Dhurandhar The Revenge’s TV Premiere; U/A 16+ version is 10 minutes shorterDaily MaverickPOLITICALLY AWEH: Inside the Gathering 2026 — South Africa’s cities crisis collides with chaotic election preparationPunchKebbi warns civil servants against lateness, absenteeismUOLAdvogado morre após quadra de tênis desabar durante temporal no ParanáThe RegisterUK rail cops' £320K face-scanning spree nets zero matchesThe Straits TimesCommunity initiative in Yishun encourages artists, public to showcase creative works at 8 bus stopsالنهاروفاة زوجة المنتج صادق الصباح.. تفاصيل الدفن والعزاء3DNewsAnthropic признала опасную зависимость от Amazon и Google — они одновременно являются её покупателями, продавцами и конкурентами
The Daily Newsstand · Free, Always
Wednesday, September 30, 2026

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

Translate

В каталоге 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». Записаться

Больше полезных материалов по бэкенд-разработке смотрите в дайджесте.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.

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