АрхитектураАрхитектура ПОАрхитектор распределённых систем

Почему стратегия cache aside может вернуть устаревшее значение после успешной записи в основное хранилище?

Почему стратегия cache-aside может вернуть устаревшее значение после успешной записи в основное хранилище?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Cache-aside не обеспечивает атомарность между основным хранилищем и кэшем. Даже после успешной записи новые данные могут не попасть в кэш сразу, а конкурентное чтение способно записать туда старое значение уже после сброса кэша. Поэтому такая стратегия обычно обеспечивает ограниченную по времени согласованность, но не строгую.

Исторический контекст

Cache-aside появился как простой способ снизить нагрузку на основное хранилище, не заставляя его знать о кэше. Приложение само сначала проверяет кэш, затем читает источник данных при промахе и помещает результат в кэш.

Такой подход удобен тем, что кэш можно добавлять постепенно, заполнять только востребованными данными и очищать независимо от жизненного цикла записей в хранилище. Однако разделение операций чтения, записи и очистки создает временные окна рассогласования.

Постановка проблемы

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

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

Подробное решение

В cache-aside чтение при промахе выполняется отдельно от записи в кэш. Поэтому между получением данных из источника и помещением их в кэш нет автоматической проверки, что данные всё еще актуальны. Успешная транзакция в базе подтверждает изменение базы, но ничего не говорит о состоянии кэша.

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

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

Для уменьшения риска применяют комбинацию мер: обновление базы как основной операции, последующую инвалидизацию кэша, уведомление через transactional outbox, защиту от записи более старой версии и ограниченный TTL. Версия объекта или монотонный номер изменения позволяют при заполнении кэша отклонять результат, если он старше уже сохраненной версии. Важно, чтобы проверка версии и публикация результата были согласованы на уровне используемого кэша.

Если требуется строгая согласованность, одного cache-aside недостаточно. Нужно либо читать критичные данные непосредственно из источника, либо строить протокол с подтверждаемыми версиями, синхронной инвалидизацией и определенными гарантиями для конкурентных запросов. Цена этого решения — более высокая задержка, сложность и нагрузка на хранилище.

Ситуация из практики

В сервисе управления лимитами после изменения лимита пользователю иногда показывалось прежнее значение. Анализ временной последовательности показал, что запрос чтения получил старую версию из реплики, а затем записал ее в кэш уже после того, как запрос изменения удалил ключ.

Рассматривались три варианта. Полный отказ от кэширования давал простую и строгую модель, но существенно увеличивал нагрузку на базу. Синхронная запись нового значения одновременно в базу и кэш уменьшала окно рассогласования, но создавала риск частичного успеха и усложняла обработку отказов. Простая инвалидизация с коротким TTL была дешевле, но не исключала возврат старой версии сразу после записи.

Выбрали запись в базу с версией, публикацию события об изменении через transactional outbox и версионную защиту кэша. Обработчик инвалидизации удалял старые значения, а операция заполнения не могла заменить более новую версию более старой. Для операций, где требовалась немедленная гарантия, чтение выполнялось из основной базы, минуя реплику и кэш.

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

Что кандидаты часто упускают

  1. Почему удаление кэша после записи не устраняет гонку полностью?

Удаление кэша является отдельной операцией и не отменяет уже выполняющиеся чтения. Запрос мог получить старое значение до изменения базы, задержаться, а затем записать его после удаления ключа. Поэтому схема запись в базу, затем удаление кэша уменьшает длительность проблемы, но не делает операции атомарными.

  1. Почему обновление кэша новым значением иногда хуже, чем его удаление?

При непосредственном обновлении кэша приложение должно согласовать две записи и обработать частичный отказ. Если база успешно обновилась, а кэш нет, системы расходятся; если кэш обновился первым, читатели могут увидеть значение, которое еще не зафиксировано в базе. Удаление обычно проще для восстановления: источник остается единственным владельцем истины, а кэш заполняется заново, хотя окно гонки всё равно требует отдельной защиты.

  1. Когда TTL недостаточно даже при небольшом значении?

TTL не подходит как единственная гарантия, если устаревшее значение может привести к финансовому ущербу, нарушению лимита, повторной операции или нарушению прав доступа. Малый TTL лишь сокращает окно риска и увеличивает число обращений к источнику. В таких случаях нужны версии, проверка актуальности, чтение из согласованного источника или отказ от кэширования критичного участка.