Привет, Хабр!

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

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

Комментарии (0)