Пришел, почистил, положил или как сбросить кэш и не уронить бэкенд
Базовая ситуация - фронт кэширует отрендеренные страницы в Redis. Катим новый релиз, поменялся контент, по хорошему кэш надо инвалидировать.
Не медля ни секунды, полный газ
redis.flushdb()
НО независимо от того, удаляет Redis ключи синхронно или асинхронно, с точки зрения приложения ключи исчезают одновременно (почти). Значит тысячи запросов одновременно промахиваются мимо кэша и отправляются прямиком на бэкенд. В общем (и целом) случае это cache stampede. Накладывает на союзника пик нагрузки на БД.
Прежде чем чинить, давайте решим какие конкретно проблемы мы решаем:
1. Массовый cache miss после flushdb = все ключи протухают в одну секунду. Лечится это "размазыванием" инвалидации во времени.
2. Реальный Stampede на одном горячем ключе, то есть проблема когда именно один популярный ключ протухает, и N одновременных запросов все попадают (🤡) в miss и все бегут перегенерировать одно и то же. Тут нужно что-то типо single-flight или stale-while-revalidate.
Размазываем протухание во времени
В данном случае не просто delete, а expire с разными короткими TTL, чтобы промахи размазывались по окну, а не ударили разом.
Окну?
Хорошей идеей кажется взять что-нибудь типо log(n), но реальное ограничение - не размер кэша, а сколько страниц в секунду способен снова отрендерить бэкенд.
Эмпирически неплохой считается стартовая оценка n / rebuild_rate, где rebuild_rate это пропускная способность бэкенда по перерендеру страниц. В реальности пропускная способность бэкенда плавающая. Она зависит от текущей нагрузки на БД, лунных суток и сложности рендеринга конкретной страницы. В идеале rebuild_rate сделать адптивным. Брать его из метрик (утилизация connection pool базы, p95 latency эндпоинта рендера).
Размазываем
def stagger_expiry(
redis,
pattern="malutka-frontend:*",
window_seconds=None,
rebuild_rate=200,
batch_size=2000,
):
keys = list({k for k in redis.scan_iter(pattern, count=1000)})
n = len(keys)
if n == 0:
return 0
if window_seconds is None:
window_seconds = max(1, math.ceil(n / rebuild_rate))
random.shuffle(keys)
pipe = redis.pipeline(transaction=False)
for i, key in enumerate(keys, start=1):
ttl = max(1, math.ceil(i / n * window_seconds))
pipe.expire(key, ttl, lt=True)
if i % batch_size == 0:
pipe.execute()
pipe = redis.pipeline(transaction=False)
pipe.execute()
return n
Хитрость в том, что ключам ставится короткий TTL, промахи размазываются по окну без единоразового дропа. shuffle - гарантирует, что распределение TTL не зависит от порядка обхода.
А что по stampede на горячем ключе?
Размазывание выше лечит только массовый синхронный промах. Один горячий ключ всё равно протухнет в свой момент, и в эту секунду по нему прилетит столько промахов, сколько было одновременных запросов.
Это отдельная задача и решать ее надо отдельно:
1. через single-flight / лок на регенераци - когда N одновременных промахов по одному ключу схлопываются в один перерендер, остальные ждут результат.
2. stale-while-revalidate - отдаём устаревший контент, пока один воркер под локом строит свежий. По сути компромис между актуальностью и эффективностью, но на практике один из самых разумных способов убрать stampede.
3. probabilistic early expiration (XFetch) - это алгоритм перестраивает ключ вероятностно, тем чаще, чем ближе TTL и чем дороже пересчёт. Тоже достаточно популярный способ, но со своими нюансами.
Так тривиальная на первый взгляд задача по сбросу кеша (с ростом RPS на проекте) превращается в увлекательное расследование и инженерную задачу. И здорово когда ты "на глаз" и заранее сможешь это заметить 🙂
Базовая ситуация - фронт кэширует отрендеренные страницы в Redis. Катим новый релиз, поменялся контент, по хорошему кэш надо инвалидировать.
Не медля ни секунды, полный газ
redis.flushdb()
НО независимо от того, удаляет Redis ключи синхронно или асинхронно, с точки зрения приложения ключи исчезают одновременно (почти). Значит тысячи запросов одновременно промахиваются мимо кэша и отправляются прямиком на бэкенд. В общем (и целом) случае это cache stampede. Накладывает на союзника пик нагрузки на БД.
Прежде чем чинить, давайте решим какие конкретно проблемы мы решаем:
1. Массовый cache miss после flushdb = все ключи протухают в одну секунду. Лечится это "размазыванием" инвалидации во времени.
2. Реальный Stampede на одном горячем ключе, то есть проблема когда именно один популярный ключ протухает, и N одновременных запросов все попадают (🤡) в miss и все бегут перегенерировать одно и то же. Тут нужно что-то типо single-flight или stale-while-revalidate.
Размазываем протухание во времени
В данном случае не просто delete, а expire с разными короткими TTL, чтобы промахи размазывались по окну, а не ударили разом.
Окну?
Хорошей идеей кажется взять что-нибудь типо log(n), но реальное ограничение - не размер кэша, а сколько страниц в секунду способен снова отрендерить бэкенд.
Эмпирически неплохой считается стартовая оценка n / rebuild_rate, где rebuild_rate это пропускная способность бэкенда по перерендеру страниц. В реальности пропускная способность бэкенда плавающая. Она зависит от текущей нагрузки на БД, лунных суток и сложности рендеринга конкретной страницы. В идеале rebuild_rate сделать адптивным. Брать его из метрик (утилизация connection pool базы, p95 latency эндпоинта рендера).
Размазываем
def stagger_expiry(
redis,
pattern="malutka-frontend:*",
window_seconds=None,
rebuild_rate=200,
batch_size=2000,
):
keys = list({k for k in redis.scan_iter(pattern, count=1000)})
n = len(keys)
if n == 0:
return 0
if window_seconds is None:
window_seconds = max(1, math.ceil(n / rebuild_rate))
random.shuffle(keys)
pipe = redis.pipeline(transaction=False)
for i, key in enumerate(keys, start=1):
ttl = max(1, math.ceil(i / n * window_seconds))
pipe.expire(key, ttl, lt=True)
if i % batch_size == 0:
pipe.execute()
pipe = redis.pipeline(transaction=False)
pipe.execute()
return n
Хитрость в том, что ключам ставится короткий TTL, промахи размазываются по окну без единоразового дропа. shuffle - гарантирует, что распределение TTL не зависит от порядка обхода.
А что по stampede на горячем ключе?
Размазывание выше лечит только массовый синхронный промах. Один горячий ключ всё равно протухнет в свой момент, и в эту секунду по нему прилетит столько промахов, сколько было одновременных запросов.
Это отдельная задача и решать ее надо отдельно:
1. через single-flight / лок на регенераци - когда N одновременных промахов по одному ключу схлопываются в один перерендер, остальные ждут результат.
2. stale-while-revalidate - отдаём устаревший контент, пока один воркер под локом строит свежий. По сути компромис между актуальностью и эффективностью, но на практике один из самых разумных способов убрать stampede.
3. probabilistic early expiration (XFetch) - это алгоритм перестраивает ключ вероятностно, тем чаще, чем ближе TTL и чем дороже пересчёт. Тоже достаточно популярный способ, но со своими нюансами.
Так тривиальная на первый взгляд задача по сбросу кеша (с ростом RPS на проекте) превращается в увлекательное расследование и инженерную задачу. И здорово когда ты "на глаз" и заранее сможешь это заметить 🙂