awesome-everything EN
↑ Обратно к восхождению

Кеширование

Инвалидация кэша: гонка set-after-delete и консистентность, которую ты реально покупаешь

Суть TTL, event-driven purge, write-through, write-behind, write-around — каждая стратегия меняет свежесть на свою аварию. Сеньорская ловушка — cache-aside delete, который параллельный читатель тихо перезаписывает значением, которое ты только что обновил.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 16 min

Пользователь меняет отображаемое имя. PUT /users/{id} возвращает 200, строка в Postgres обновлена, обработчик послушно делает DEL user:42, чтобы сбросить кэш. Он обновляет страницу — и старое имя на месте. Не на секунду: на следующие двенадцать часов, пока не истёк TTL. Дежурный инженер не смог воспроизвести это локально, потому что нужно, чтобы два запроса разошлись на несколько миллисекунд. Запись удалила ключ; чтение, которое стартовало до записи и держало старую строку из БД, завершилось после удаления и записало устаревшее значение обратно. Кэш врал, база говорила правду, и они расходились полдня.

Инвалидация — это выбор, какую несвежесть отгрузить

Кэша без несвежести не бывает; есть только та несвежесть, которую ты выбрал, и та, что тебя удивила. Каждая стратегия инвалидации — это разный ответ на один вопрос: когда источник истины меняется, как об этом узнаёт копия? TTL ждёт таймер. Event-driven purge слушает запись. Write-through обновляет оба хранилища в одном вызове. Write-behind обновляет кэш сейчас, а базу — потом. Каждая покупает свою гарантию консистентности и платит своей аварией — не существует стратегии, которая одновременно свежая, быстрая и простая.

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

TTL: дёшево, в конце концов корректно и синхронизировано случайно

Time-to-live — это пол кэширования. Ты штампуешь каждую запись сроком истечения; чтения отдают её, пока таймер не вышел, затем следующее чтение перезаполняет. Это вообще не требует координации на пути записи — база даже не знает, что кэш существует. Цена — ограниченная несвежесть: при TTL 60 секунд изменённое значение может быть неверным до 60 секунд, и с этим ничего не поделать, кроме как укоротить TTL (больше нагрузки на origin) или добавить активную инвалидацию сверху.

Продакшен-ловушка TTL — не сама несвежесть, а синхронизированное истечение. Если 1000 серверов приложения кэшируют один горячий ключ с одинаковым TTL 300 секунд, и все прогрели его в одном окне деплоя, все они истекают в одном и том же окне ~100 мс. В момент T=300 каждый из них промахивается одновременно и бьёт в origin синхронно — thundering herd / cache stampede. Фикс — TTL jitter: вместо фиксированных 300 с задай каждой записи случайное значение, скажем, в диапазоне 270–330 с (±10%). Истечения рассеиваются по полосе в 60 секунд, origin видит плавный подъём вместо пика, и одна перестройка обслуживает остальных. Работа Facebook над memcache и Redis обе считают jitter базовой защитой; более агрессивный вариант — probabilistic early refresh, где каждое чтение за порогом имеет малый шанс обновиться раньше, превращая резкий пик в 100 мс в подъём на 60 секунд.

Event-driven purge и проблема двойной записи

Если ограниченной несвежести мало, инвалидируешь по самой записи: когда строка меняется, удаляешь (или обновляешь) ключ кэша. Это паттерн «cache-aside с purge», и здесь живут драконы. Теперь у тебя две записи — одна в базу, одна в кэш — и нет транзакции на обе. Это проблема двойной записи (dual-write): любое из хранилищ может успеть, пока другое падает. Если коммит в БД успешен, а DEL упал (потерянный пакет, всплеск Redis), кэш отдаёт старое значение до своего TTL — именно поэтому держишь TTL даже при активной инвалидации: это страховка для каждого пропущенного purge.

Удалять, а не обновлять ключ — это сам по себе сеньорский рефлекс: DEL идемпотентен и независим от порядка, поэтому два параллельных писателя, гоняющихся за инвалидацией, не могут оставить полузаписанное смешанное значение так, как два параллельных SET. Но delete-on-write не закрывает худшую гонку — о ней следующий раздел.

Гонка set-after-delete: почему «просто удали ключ» всё равно отдаёт устаревшее

Вот авария из хука, по порядку. Читатель R получает промах по user:42 и запрашивает базу, читая name = "Ann". Прежде чем R запишет это обратно, отрабатывает писатель W: он обновляет строку до name = "Bob" и удаляет user:42. Теперь R — всё ещё держа устаревшее “Ann”, прочитанное миллисекунды назад — завершает и делает SET user:42 = "Ann". Кэш теперь держит “Ann”, а база держит “Bob”, и так остаётся неверным, пока не истечёт TTL. Удаление произошло между чтением и записью R, поэтому запись R тихо отменила его.

tЧитатель R (промах)Писатель W (update)Состояние кэш / БД
1промах; читает БД → “Ann”БД: Ann · кэш: пусто
2(тормозит: GC-пауза, ретрай)UPDATE БД → “Bob”БД: Bob · кэш: пусто
3DEL user:42БД: Bob · кэш: пусто
4SET user:42 = “Ann”БД: Bob · кэш: Ann (устарело!)

Есть три реальных фикса, по нарастанию цены. Отложенное двойное удаление (delayed double delete): после записи делаешь DEL ключа, затем планируешь второй DEL через несколько сотен миллисекунд — достаточно долго, чтобы любой читатель в полёте завершил свой устаревший SET, и второе удаление его стёрло. Дёшево, вероятностно и самый частый патч на практике. Лизы (leases) (подход Facebook): при промахе memcache выдаёт читателю 64-битный токен, привязанный к ключу; читатель может сделать SET, только если его токен ещё валиден, а DEL инвалидирует токен — поэтому устаревший set, пришедший после удаления, отвергается. Лизы срезали пиковую нагрузку Facebook на origin с 17 000 до 1 300 запросов/сек на горячем ключе, попутно убив эту гонку. Прогнать записи через кэш (write-through), чтобы вообще не было отдельного SET от читателя, с которым можно гоняться.

Почему это работает

Почему удаление лучше обновления ключа на записи? Потому что два писателя могут переплестись. Если W1 ставит ключ в “Bob”, а W2 — в “Carol”, но их сетевые пакеты доходят до кэша в порядке, обратном их коммитам в БД, кэш может остаться на “Bob”, а БД — на “Carol”. У DEL нет значения, чтобы оно ушло не в том порядке — кто читает следующим, перезаполняет из уже устоявшейся БД. Идемпотентная инвалидация обходит проблему порядка, которую создают записи значений.

Write-through, write-behind, write-around: куда идёт запись

Стратегии выше — read-populated (cache-aside). Стратегии пути записи решают, что запись делает с кэшом напрямую. Write-through пишет в кэш и базу в одной операции, синхронно — кэш всегда консистентен с тем, что только что записали, и нет устаревшего set от читателя, с которым гоняться. Цена — латентность записи (платишь оба хранилища на критическом пути) и засорение кэша: кэшируешь данные, которые могут никогда не прочитать. Write-behind / write-back пишет в кэш немедленно, а запись в базу ставит в очередь асинхронно — быстрые записи, слияние записей под всплесками, но есть окно, где единственная копия записи лежит в волатильном кэше. Если узел умирает до сброса, эта запись потеряна; это та стратегия, которая может потерять данные, выглядевшие закоммиченными, поэтому её держат для терпимых нагрузок (счётчики, метрики) или в паре с долговечной очередью. Write-around вообще пропускает кэш на записи (только БД) и даёт чтениям заполнить позже — хорошо, когда записанные данные редко читают вскоре, плохо для read-your-writes, потому что следующее чтение самого писателя — гарантированный промах, затем устаревшее-до-заполнения.

Выбери лучший вариант

Пользователь редактирует свой профиль и сразу видит его снова (read-your-writes обязателен). Выбирая стратегию записи, что выдерживает?

Викторина

1000 серверов кэшируют один горячий ключ с одинаковым TTL 300 с, прогретым в одном деплое. В T=300 origin завален. Самый чистый первый фикс?

Викторина

При cache-aside delete-on-write ты обновляешь БД, затем DEL ключа. Почему кэш всё равно может остаться со старым значением?

Расставь шаги по порядку

Расставь события, дающие устаревшее перезаполнение set-after-delete:

  1. 1 Читатель промахивается мимо кэша и читает СТАРОЕ значение из БД
  2. 2 Писатель обновляет БД до НОВОГО значения
  3. 3 Писатель делает DEL ключа кэша (ключ теперь пуст)
  4. 4 Читатель завершает и делает SET кэша в СТАРОЕ значение из шага 1
  5. 5 Кэш теперь отдаёт СТАРОЕ значение, пока не истечёт страховочный TTL
Вспомните перед уходом
  1. 01
    Проведи коллегу через гонку set-after-delete и три способа её закрыть.
  2. 02
    Зачем держать TTL, даже когда активно инвалидируешь на каждой записи, и почему одному TTL нужен jitter?
Итог

Инвалидация кэша — это выбор, какую несвежесть отгрузить, потому что кэш и база — две системы без транзакции на обе. TTL — дешёвый пол: в конце концов корректен, ограничен сроком истечения, но склонен к синхронизированным stampede, которые ты обезвреживаешь jitter-ом (случайный TTL ±10%) и probabilistic early refresh. Event-driven purge режет несвежесть до записи, но вносит проблему двойной записи — поэтому всегда держишь TTL как страховку для каждого пропущенного удаления. Гонка, которая кусается в проде, — set-after-delete: читатель, прочитавший старую строку до записи, перезаполняет ключ сразу после DEL писателя, оставляя устаревшие данные до TTL — закрывается отложенным двойным удалением, лизами (которые также срезали нагрузку Facebook по горячему ключу с 17K до 1.3K qps) или прогоном записей через кэш. Write-through покупает консистентность и read-your-writes ценой латентности записи; write-behind покупает быстрые записи ценой окна долговечности; write-around пропускает кэш на записи и ломает read-your-writes. Назови разрыв, который открывает каждая стратегия, затем выбери тот, что твой продукт может терпеть.

Продолжить восхождение ↑Инвалидация кэша: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.