Почему стратегия cache-aside может вернуть устаревшее значение после успешной записи в основное хранилище?
Cache-aside не обеспечивает атомарность между основным хранилищем и кэшем. Даже после успешной записи новые данные могут не попасть в кэш сразу, а конкурентное чтение способно записать туда старое значение уже после сброса кэша. Поэтому такая стратегия обычно обеспечивает ограниченную по времени согласованность, но не строгую.
Cache-aside появился как простой способ снизить нагрузку на основное хранилище, не заставляя его знать о кэше. Приложение само сначала проверяет кэш, затем читает источник данных при промахе и помещает результат в кэш.
Такой подход удобен тем, что кэш можно добавлять постепенно, заполнять только востребованными данными и очищать независимо от жизненного цикла записей в хранилище. Однако разделение операций чтения, записи и очистки создает временные окна рассогласования.
Рассмотрим запись, которая сначала обновляет данные в базе, а затем удаляет соответствующий ключ из кэша. Между этими действиями или сразу после удаления другой запрос может прочитать старое значение из базы либо из реплики с задержкой и попытаться заполнить им кэш.
Особенно опасна последовательность, в которой запрос на чтение получил старое значение до записи, запись успешно завершилась и кэш был удален, а затем первый запрос положил устаревший результат обратно. Последствием станет возврат старых данных до следующего удаления ключа или истечения времени жизни записи.
В cache-aside чтение при промахе выполняется отдельно от записи в кэш. Поэтому между получением данных из источника и помещением их в кэш нет автоматической проверки, что данные всё еще актуальны. Успешная транзакция в базе подтверждает изменение базы, но ничего не говорит о состоянии кэша.
Наиболее распространенный вариант записи — сначала изменить базу, затем удалить кэш. Он уменьшает вероятность длительного устаревания, но не устраняет гонку между чтением и записью. Обратный порядок, то есть сначала удалить кэш, затем изменить базу, еще опаснее: запрос может заполнить кэш старым значением в промежутке до фиксации новой записи.
TTL ограничивает максимальное время жизни устаревшего значения, но не гарантирует немедленную актуальность. Повторное заполнение кэша после промаха также не должно считаться безопасным само по себе: несколько параллельных читателей могут получить разные версии данных.
Для уменьшения риска применяют комбинацию мер: обновление базы как основной операции, последующую инвалидизацию кэша, уведомление через transactional outbox, защиту от записи более старой версии и ограниченный TTL. Версия объекта или монотонный номер изменения позволяют при заполнении кэша отклонять результат, если он старше уже сохраненной версии. Важно, чтобы проверка версии и публикация результата были согласованы на уровне используемого кэша.
Если требуется строгая согласованность, одного cache-aside недостаточно. Нужно либо читать критичные данные непосредственно из источника, либо строить протокол с подтверждаемыми версиями, синхронной инвалидизацией и определенными гарантиями для конкурентных запросов. Цена этого решения — более высокая задержка, сложность и нагрузка на хранилище.
В сервисе управления лимитами после изменения лимита пользователю иногда показывалось прежнее значение. Анализ временной последовательности показал, что запрос чтения получил старую версию из реплики, а затем записал ее в кэш уже после того, как запрос изменения удалил ключ.
Рассматривались три варианта. Полный отказ от кэширования давал простую и строгую модель, но существенно увеличивал нагрузку на базу. Синхронная запись нового значения одновременно в базу и кэш уменьшала окно рассогласования, но создавала риск частичного успеха и усложняла обработку отказов. Простая инвалидизация с коротким TTL была дешевле, но не исключала возврат старой версии сразу после записи.
Выбрали запись в базу с версией, публикацию события об изменении через transactional outbox и версионную защиту кэша. Обработчик инвалидизации удалял старые значения, а операция заполнения не могла заменить более новую версию более старой. Для операций, где требовалась немедленная гарантия, чтение выполнялось из основной базы, минуя реплику и кэш.
В результате устаревшие значения перестали восстанавливаться после конкурентных чтений, а кэш сохранился для обычных запросов. Компромиссом стали дополнительное хранение версии, обработка задержек доставки событий и необходимость явно разделять строгие и допускающие задержку сценарии.
Удаление кэша является отдельной операцией и не отменяет уже выполняющиеся чтения. Запрос мог получить старое значение до изменения базы, задержаться, а затем записать его после удаления ключа. Поэтому схема запись в базу, затем удаление кэша уменьшает длительность проблемы, но не делает операции атомарными.
При непосредственном обновлении кэша приложение должно согласовать две записи и обработать частичный отказ. Если база успешно обновилась, а кэш нет, системы расходятся; если кэш обновился первым, читатели могут увидеть значение, которое еще не зафиксировано в базе. Удаление обычно проще для восстановления: источник остается единственным владельцем истины, а кэш заполняется заново, хотя окно гонки всё равно требует отдельной защиты.
TTL не подходит как единственная гарантия, если устаревшее значение может привести к финансовому ущербу, нарушению лимита, повторной операции или нарушению прав доступа. Малый TTL лишь сокращает окно риска и увеличивает число обращений к источнику. В таких случаях нужны версии, проверка актуальности, чтение из согласованного источника или отказ от кэширования критичного участка.