PostgreSQL vs Redis: як зупинити шторм запитів по кешу, щоб база даних не «згоріла»

Було корисно?

Сторінка швидка в staging. В продакшні вона швидка… поки ні. Один ключ кешу спливає, тисяча запитів накопичується, і раптом ваша «база істини» стає базою жалю.
Postgres починає кардіо о 3-й ночі, а Redis стоїть осторонь як швейцар, який забув, чому клуб переповнений.

Штурми кешу (aka thundering herds) — це один з тих відмов, що відчуваються несправедливими: система «працює як задумано», і в цьому проблема. Це практичний посібник із запобігання штурмам за допомогою Redis і збереження PostgreSQL живим, коли кеш бреше, спливає або його очищує хтось «просто для налагодження».

Як виглядає штурм кешу в продакшні

Штурм кешу — це не просто «кеш промахнувся». Кеш-промах — нормальна операція. Штурм — це коли промахи синхронізуються: багато клієнтів одночасно промахуються по одному й тому самому ключу і всі йдуть до бекапу даних разом.
Це координована відмова без жодних нарад.

Типовий таймлайн

  • T-0: гарячий ключ спливає, або деплой змінює неймспейс кешу, або Redis перезапустився і втратив дані.
  • T+1s: затримка запитів різко зростає; потоки додатка починають накопичуватись у очікуванні Postgres.
  • T+10s: Postgres стикається з насиченням з’єднань; CPU росте; черга I/O збільшується; autovacuum стає вашим другим вогнем.
  • T+30s: включаються повторні виклики upstream; трафік множиться; кеши стають чутками.
  • T+60s: ви починаєте масштабувати ноди додатка, що підвищує конкуренцію і робить ситуацію гіршою.

Найгірше те, що більшість систем сконструйовані так, щоб «провалюватися відкрито»: «якщо кеш промахнувся, читай з БД». Це правильно при невеликому масштабі.
На реальному масштабі це — атака відмови в обслуговуванні, яку ви проводите проти себе ввічливо, з відкритими панелями метрик.

Цитата одна, бо пасує: Надія — це не стратегія.Генерал Гордон Р. Салліван

Короткий жарт #1: штурми кешу як безкоштовні пончики в офісі — хтось згадав, і раптом ніхто не пам’ятає про «обмеження бюджету».

PostgreSQL vs Redis: ролі, сильні сторони та режими відмов

PostgreSQL — це джерело істини (і велика відповідальність)

Postgres створено для коректності: ACID-транзакції, стійкі записи, індекси, планування запитів, рівні ізоляції та запобіжні механізми, що віддають пріоритет цілісності над швидкістю.
Це не кеш. Він може поводитися як кеш, коли у вас достатньо RAM і робочий набір поміщається, але штурми не звертають уваги на ваш буфер-кеш.

Postgres виходить з ладу передбачуваними способами під час штурмів:

  • Виснаження з’єднань (занадто багато клієнтів, занадто багато одночасних запитів)
  • Насичення CPU (безліч однакових дорогих запитів з холодними кешами)
  • Зависання I/O (випадкові читання та пошуки по індексу; checkpointer/wal; латентність диска)
  • Посилення блокувань (навіть читання можуть конкурувати через метадані або ефекти черги)
  • Затримка реплік (read replicas піддаються ударам; lag стрибає; врешті-решт ви отримуєте застарілі дані)

Redis — це млин новин (швидкий, леткий і дуже корисний)

Redis — це in-memory сервер структур даних. Він швидкий, однопотоковий на шарду в класичному режимі (і досі ефективно однопотоковий для багатьох команд у складніших конфігураціях) і розрахований на низьку затримку.
Він також не чарівний. Якщо ваша захист від штурмів залежить від того, що Redis ідеально доступний, ви побудували єдину точку відмови з кращими дашбордами.

Redis відмовляє інакше:

  • Шторм витіснення, якщо політика пам’яті не відповідає розмірам ключів і TTL
  • Стрибки затримки під час персистентності (RDB/AOF fsync) або повільних команд
  • Очищення кешу (помилкове, операційне або під час failover)
  • Конкуренція за гарячі ключі (один ключ під хвилею; спайки CPU; черги по мережі)

Кордон рішень: що куди ставити

Покладіть у Postgres:

  • Системні дані та «джерело правди»
  • Шляхи запису, що потребують гарантованості і обмежень
  • Складні ad-hoc запити та аналітика по поточному стану (в розумних межах)

Покладіть у Redis:

  • Виведені дані та проєкції, оптимізовані під читання
  • Ліміти швидкості, короткоживучі локи, idempotency-ключі
  • Спільна мемоізація там, де допустиме повторне обчислення
  • Примітиви координації (обережно), такі як «single-flight» локи

Мета не в тому, щоб «Redis замінив Postgres». Мета — щоб Redis завадив Postgres бачити ту ж саму проблему тисячі разів на секунду.

Цікаві факти та коротка історія (для контексту)

  • Postgres починався як POSTGRES у середині 1980-х в UC Berkeley як наступник Ingres, з ранньою роботою над розширюваністю та правилами.
  • MVCC — це не трюк для продуктивності; це модель конкурентності. MVCC в Postgres зменшує блокування читань, але також означає надування даних і необхідність vacuum.
  • Redis створили у 2009 році і він став дефолтним вибором «швидкого кешу + простих примітивів» для покоління веб-систем.
  • Проблема «thundering herd» існувала до вебу; це класична проблема ОС і розподілених систем, коли багато очікувачів пробуджуються одночасно.
  • TTL джиттер — старий трюк з ранніх кешуючих систем: рандомізувати час життя, щоб уникнути синхронізованих інвалідатів.
  • CDN популяризували stale-while-revalidate як патерн HTTP cache-control; та сама ідея працює в Redis з невеликою дисципліною.
  • PgBouncer існує здебільшого тому, що з’єднання Postgres дорогі у порівнянні з багатьма системами; надто багато клієнтів напряму — відома пастка.
  • Персистентність Redis є опційною за дизайном; багато розгортань міняють надійність на швидкість, що нормально, поки хтось не починає вважати Redis базою даних.

Патерни, які реально зупиняють штурми

1) Об’єднання запитів («single-flight»): один перебудовник, багато очікувачів

Коли ключ промахнувся, не дозволяйте кожному запиту його відновлювати. Оберіть один запит для дорогої роботи; решта очікує недовго або отримує застарілі дані.
Це найефективніший контроль штурмів, бо він атакує множниковий фактор.

Об’єднання можна реалізувати:

  • В процесі (працює на інстанс; не координує між флітом)
  • Розподілено за допомогою Redis-локів (координує між інстансами; потребує уважних таймаутів)
  • Через виділений worker-чергу «cache builder» (найміцніший варіант, більше компонентів)

2) Stale-while-revalidate: віддавайте старі дані під час оновлення

Жорсткий TTL має обрив: при закінченні терміну або є дані, або їх немає. Stale-while-revalidate замінює цей обрив на схил:
тримайте «fresh TTL» і «stale TTL». У період stale ви можете швидко віддавати старе значення, поки один воркер його оновлює.

Цей патерн застосовується, коли важливіша доступність та затримка, ніж абсолютна свіжість. Більшість сторінок продукту, блоки рекомендацій і віджети «top N» підпадають під це.
«Баланс рахунку» — ні.

3) Soft TTL + hard TTL: два терміни життя в одній системі

Зберігайте метадані поруч із закешованим значенням:

  • soft_ttl: після цього можна оновлювати у фоні
  • hard_ttl: після цього треба обов’язково оновити або відмовитися / деградирувати

Soft/hard TTL — практичний спосіб закодувати бізнес-толерантність до застарілих даних без залежності від складної семантики Redis.

4) TTL джиттер: зупиніть синхронізоване спливання

Якщо мільйон ключів записані одною задачею з однаковим TTL, вони всі спливуть разом. Це не «невезіння», це математика.
Додавайте джиттер: TTL = базовий ± випадкове. Тримайте межі.

Добрий джиттер настільки малий, щоб не порушувати вимоги продукту, і достатній, щоб десинхронізувати перебудови. Для 10-хвилинного TTL ±60–120 секунд часто вистачає.

5) Негативне кешування: кешуйте «не знайдено» і «доступ заборонено»

Результат «не знайдено» все одно результат. Якщо відсутній user ID або сторінка продукту викликає запит до БД щоразу, зловмисники (або баги клієнта) можуть бити вас промахами.
Кешуйте 404 та «no rows» результати коротко. Тримайте TTL малим, щоб не приховувати щойно створені записи.

6) Формування запитів дружніх до Postgres: зменшіть вартість промаху

Якщо кожен промах коштує Postgres багаточленним join з сортуванням, ви ставите на те, що кеш ніколи не промахнеться. Ця ставка програє.
Створюйте read-моделі, які дешево обчислювати (або попередньо обчислені), і гарантуйте, що індекси відповідають патернам доступу.

7) Backpressure і таймаути: відпадайте швидко, щоб не стояти в черзі вічно

Вбивця штурмів, який у вас вже є — це таймаути. Проблема в тому, що більшість систем ставить їх занадто довго й непослідовно.
Ваш додаток має припиняти чекати значно раніше, ніж Postgres впаде; інакше ви нарощуєте беклог, що перетворюється на множник відмови.

Вам потрібно:

  • Коротші таймаути на шляхах перебудови кешу, ніж на інтерактивних читаннях
  • Обмеження конкурентності на ключ або на ендпоінт
  • Бюджети повторів (лімітовані повтори, експоненційний backoff, джиттер)

8) Захистіть Postgres пулами та контролем допуску

Штурми часто проявляються як «занадто багато з’єднань», бо кожен інстанс додатка відкриває більше з’єднань, намагаючись бути корисним.
Використовуйте PgBouncer, обмежуйте конкуренцію на рівні додатка і розгляньте окремі пули для:

  • Інтерактивних читань
  • Фонових перебудов кешу
  • Пакетних задач

9) Прогрівайте кеш, але як дорослі

Прогрів кешу працює, коли він обмежений і вимірюваний. Він провалюється, коли це наївне «перерахуй все зараз».
Безпечний прогрівач:

  • Пріоритезує найпопулярніші ключі
  • Працює з жорстким лімітом конкурентності
  • Зупиняється, коли Postgres під навантаженням
  • Використовує ті самі контролі штурму, що й продакшн-запити

Короткий жарт #2: «Ми просто прогріємо весь кеш» — це інфраструктурний еквівалент фрази «ми просто погасимо весь техборг цього спринту».

Швидкий план діагностики

Коли затримка зростає і ваш дашборд виглядає як сучасне мистецтво, не починайте з дебатів про архітектуру. Почніть із доведення, куди йде час.
Найшвидша діагностика — це дисциплінована послідовність кроків.

Перший крок: Redis відсутній, повільний чи порожній?

  • Перевірте затримку Redis і частоту команд.
  • Підтвердіть, що hit rate не колапсує.
  • Пошукайте події витіснення або рестарту.
  • Визначте топ гарячих ключів (або патернів), що викликають промахи.

Другий крок: Postgres насичений чи просто в черзі?

  • Перевірте активні з’єднання проти max.
  • Перевірте wait events (блокування, I/O, CPU).
  • Знайдіть повторювані запити; подивіться, чи корелюють вони з промахами кешу.
  • Перевірте лаг реплік, якщо читання йдуть на репліки.

Третій крок: чи додаток множить проблему?

  • Retries, таймаути, circuit breakers: чи вони адекватні?
  • Чи є об’єднання запитів або лок, чи ви розлітаєтеся з перебудовами?
  • Чи ваш pool з’єднань спричиняє черги (потоки чекають на DB-з’єднання)?

Точки прийняття рішень

  • Якщо Redis в порядку, а Postgres «плавиться», ймовірно у вас низький hit rate, сплив неймспейсу або новий гарячий шлях, що обходить кеш.
  • Якщо Redis повільний — виправляйте Redis першочергово; повільний кеш може бути гіршим за відсутність кешу, бо додає затримку і все одно змушує BД працювати.
  • Якщо обидва в порядку, але затримка додатка висока — можливо, у вас голодування пулу потоків або накопичення викликів вниз по ланцюжку.

Практичні завдання: команди, вихідні дані та рішення (12+)

Це перевірки рівня продакшн. Кожне завдання включає: команду, приклад виходу, що це означає і рішення, яке з цього випливає.
Вихідні дані ілюстративні; ваші числа будуть іншими. Ідея — форма даних і дія, яку вона підказує.

Task 1: Verify Redis is up and measure instantaneous latency

cr0x@server:~$ redis-cli -h 127.0.0.1 -p 6379 --latency -i 1
min: 0, max: 3, avg: 0.45 (1000 samples)
min: 0, max: 12, avg: 1.10 (1000 samples)

Що це означає: Redis відповідає, але spike-значення max (12ms) можуть травмувати tail latency, якщо ваш SLO жорсткий.

Рішення: Якщо max/avg стрибає під час інцидентів, дослідіть повільні команди, fsync персистентності чи шумних сусідів перед тим, як звинувачувати Postgres.

Task 2: Check Redis evictions and memory pressure

cr0x@server:~$ redis-cli INFO memory | egrep 'used_memory_human|maxmemory_human|mem_fragmentation_ratio'
used_memory_human:9.82G
maxmemory_human:10.00G
mem_fragmentation_ratio:1.62

Що це означає: Ви майже на межі, з фрагментацією. Ймовірні витіснення, і затримка може зрости.

Рішення: Підвищте maxmemory, зменшіть розмір ключів або змініть політику витіснення. Не ігноруйте повний кеш.

Task 3: Confirm Redis eviction policy and whether it matches your cache design

cr0x@server:~$ redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"

Що це означає: З noeviction записи відмовляються під тиском. Ваш додаток може інтерпретувати відмови як промахи і спричинити штурм до БД.

Рішення: Для кеш-навантажень віддавайте перевагу eviction-політикам на кшталт allkeys-lru або volatile-ttl залежно від стратегії ключів, і обробляйте промахи акуратно.

Task 4: Spot hot keys by sampling Redis commands

cr0x@server:~$ redis-cli MONITOR
OK
1735588430.112345 [0 10.2.3.14:52144] "GET" "product:pricing:v2:sku123"
1735588430.112612 [0 10.2.3.22:49018] "GET" "product:pricing:v2:sku123"
1735588430.113001 [0 10.2.3.19:58820] "GET" "product:pricing:v2:sku123"

Що це означає: Один ключ під хвилею. Якщо він спливе, ви це відчуєте скрізь.

Рішення: Додайте пер-key об’єднання запитів і stale-while-revalidate для цього класу ключів; розгляньте розділення ключа або кешування по сегментах.

Task 5: Check Postgres connection pressure

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select count(*) as total, sum(case when state='active' then 1 else 0 end) as active from pg_stat_activity;"
 total | active
-------+--------
  480  |    210
(1 row)

Що це означає: У вас багато сесій; багато з них активні. Якщо max_connections ≈ 500, ви близько до межі.

Рішення: Якщо ви близькі до стіни під час сплесків, посадіть клієнтів за PgBouncer і обмежте конкурентність на шляхах перебудови кешу.

Task 6: Identify what Postgres is waiting on (locks, I/O, CPU)

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select wait_event_type, wait_event, count(*) from pg_stat_activity where state='active' group by 1,2 order by 3 desc;"
 wait_event_type |     wait_event      | count
-----------------+---------------------+-------
 IO              | DataFileRead        |    88
 Lock            | relation            |    31
 CPU             |                     |    12
(3 rows)

Що це означає: Читання зависають на диску і є деяка конкуренція за блокування. Це узгоджується з атакою промахів.

Рішення: Зменшіть повторювані дорогі читання (coalescing/stale) і перевірте індекси. Для wait on lock знайдіть блокуючі запити.

Task 7: Find the blocking query chain

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select a.pid as blocked_pid, a.query as blocked_query, b.pid as blocking_pid, b.query as blocking_query from pg_stat_activity a join pg_stat_activity b on b.pid = any(pg_blocking_pids(a.pid)) where a.state='active';"
 blocked_pid |         blocked_query          | blocking_pid |        blocking_query
------------+--------------------------------+--------------+------------------------------
      9123  | update inventory set qty=qty-1 |         8871 | vacuum (analyze) inventory
(1 row)

Що це означає: Операція обслуговування блокує запис, що може каскадно спричинити повтори і більше навантаження.

Рішення: Якщо це під час інциденту, розгляньте паузу або перенесення обслуговування; потім з’ясуйте, чому оновлення inventory у гарячому шляху кешованих читань.

Task 8: Identify the most expensive repeating queries during the stampede

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select calls, total_time, mean_time, left(query,120) as query from pg_stat_statements order by total_time desc limit 5;"
 calls | total_time | mean_time |                         query
-------+------------+-----------+---------------------------------------------------------
 92000 |  812340.12 |      8.83 | select price, currency from pricing where sku=$1 and...
 48000 |  604100.55 |     12.59 | select * from product_view where sku=$1

Що це означає: Одні й ті ж запити виконуються десятки тисяч разів. Це підпис шторму промахів кешу.

Рішення: Додайте об’єднання запитів навколо цих ключів кешу та розгляньте зберігання повної read-моделі в Redis, щоб уникнути важчого запиту.

Task 9: Check replica lag if reads go to replicas

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select now() - pg_last_xact_replay_timestamp() as replica_lag;"
 replica_lag
-------------
 00:00:17.412
(1 row)

Що це означає: 17 секунд лагу може перетворити «промах кешу, читати з репліки» у «промах кешу, читати застаріло, потім інвалідовувати, потім повторити».

Рішення: Під час високого навантаження не направляйте критичні читання до відсталих реплік; віддавайте перевагу застарілому кешу замість відсталої репліки для некритичних даних.

Task 10: Inspect PgBouncer pool saturation

cr0x@server:~$ psql -h 127.0.0.1 -p 6432 -U pgbouncer -d pgbouncer -c "show pools;"
 database | user  | cl_active | cl_waiting | sv_active | sv_idle | sv_used | maxwait
----------+-------+-----------+------------+-----------+---------+---------+---------
 appdb    | app   |       120 |        380 |        80 |       0 |      80 |    12.5
(1 row)

Що це означає: Клієнти чекають (380). Серверні з’єднання обмежені і повністю використані. Ви чергуєтесь на пулі.

Рішення: Не підвищуйте розміри пулів необдумано. Додайте admission control до шляхів перебудови; збільшуйте можливості БД лише після зниження ампліфікації штурму.

Task 11: Confirm Redis keyspace hit/miss trend

cr0x@server:~$ redis-cli INFO stats | egrep 'keyspace_hits|keyspace_misses'
keyspace_hits:182334901
keyspace_misses:44211022

Що це означає: Промахів багато. Якщо рівень промахів раптово піднявся після деплою, ймовірно ви змінили формат ключа, TTL або серіалізацію.

Рішення: Відкотіть зміни неймспейсу ключів або додайте зворотно сумісне читання для старих ключів; впровадьте поетапний rollout для змін схеми кешу.

Task 12: Check for Redis persistence stalls (AOF) contributing to latency

cr0x@server:~$ redis-cli INFO persistence | egrep 'aof_enabled|aof_last_write_status|aof_delayed_fsync'
aof_enabled:1
aof_last_write_status:ok
aof_delayed_fsync:137

Що це означає: Відкладені fsync-події вказують, що ОС/сховище не встигає. Затримка Redis стрибне, і клієнти можуть таймаутитись, а потім впасти назад до БД.

Рішення: Розгляньте зміну політики AOF fsync для кеш-навантеження, або перемістіть Redis на швидше сховище / відокремте шумних сусідів. Також виправте таймаути клієнтів, щоб уникнути лавини назад до БД.

Task 13: Find whether the application is retrying too aggressively

cr0x@server:~$ grep -R "retry" -n /etc/app/config.yaml | head
42:  retries: 5
43:  retry_backoff_ms: 10
44:  retry_jitter_ms: 0

Що це означає: 5 повторів з backoff 10ms і без джиттеру буде бити залежності під час інцидентів.

Рішення: Зменшіть повтори, додайте експоненційний backoff і джиттер, і введіть retry budget для кожного класу запитів.

Task 14: Inspect top Postgres tables for cache-miss-driven I/O

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select relname, heap_blks_read, heap_blks_hit from pg_statio_user_tables order by heap_blks_read desc limit 5;"
   relname    | heap_blks_read | heap_blks_hit
--------------+----------------+---------------
 pricing      |        9203812 |      11022344
 product_view |        7112400 |      15099112
(2 rows)

Що це означає: Високі читання з диска по невеликому набору таблиць підказують, що робочий набір не в буфері, або ваш патерн доступу занадто випадковий для попадань у буфер.

Рішення: Зменшіть роботу БД на запит (coalescing/stale), додайте індекси або перерахуйте cached projection, щоб воно було дешевшим.

Три міні-історії з промислових боїв

Міні-історія 1: Інцидент через неправильне припущення

Роздрібна компанія мала «pricing cache» у Redis з TTL 5 хвилин. Команда вважала, що оскільки Redis «in-memory», він фактично завжди там і завжди швидкий.
Їхній cache-aside шлях виглядав чисто: GET, якщо промах — тоді запит в Postgres, потім SETEX. Просто. Надто просто.

Одного вечора Redis перезапустили під час планового технічного вікна. Він повернувся швидко, але датасет був фактично холодним. Сервери додатка сприйняли це як масову хвилю промахів і пішли прямо в Postgres.
Трафік не був незвичним. Натомість кеш став незвичним: він перейшов від «переважно попадань» до «переважно промахів» за секунди.

Postgres не помер одразу. Він став у чергу. Зростали з’єднання. Пули PgBouncer заповнилися. Потоки додатка блокувалися в очікуванні з’єднання, і таймаути викликали повтори.
Неправильне припущення полягало не в «Redis швидкий». Неправильне припущення було в тому, що «промахи кешу незалежні». Вони такими не були; вони синхронізувалися рестартом.

Виправлення не було екзотичним. Вони додали stale-while-revalidate з soft TTL і hard TTL, і об’єднання запитів на ключ з коротким Redis-локом.
Після цього рестарт Redis викликав більш повільні сторінки кілька хвилин, а не інцидент з базою. Це було менш драматично — найвища похвала в операціях.

Міні-історія 2: Оптимізація, яка відбилася боком

Команда B2B SaaS стала агресивною щодо «свіжості». Вони скоротили TTL з 10 хвилин до 30 секунд для набору дашборд-віджетів, бо sales хотіли швидших оновлень.
Вони також ввели фонову роботу, яка «прогрівала» кеш, перераховуючи популярні ключі кожні 25 секунд. На папері — менше промахів і свіжіші дані.

На практиці робота прогрівача і реальний трафік вирівнялися. Обидва били по тим самим ключам приблизно в один час. З коротким TTL ключі часто спливали; з роботом-прогрівачем вони постійно перебудовувались.
Вони створили постійний міні-штурм. Не один драматичний сплеск, а постійно підвищене навантаження, що робило іншу роботу Postgres крихкою.

Симптом був підступний: немає великих сплесків промахів, лише підвищена середня затримка запитів і час від часу блокування. Autovacuum почав відставати, бо система була зайнята настільки, що обслуговування ніколи не мало тихого моменту.
Інженери тижнями ганялися за «повільними запитами» і «поганими планами», бо система не горіла явно. Вона була просто завжди надто гарячою.

Виправлення — припинити перебудову за фіксованим каденсом і перейти на event-driven інвалідaцію для малого піднабору ключів, що справді потребують свіжості. Для всього іншого: довші TTL, джиттер і soft TTL-оновлення лише при доступі.
Вони також ввели жорсткі ліміти конкурентності на прогрівач і змусили його відступати, коли затримки Postgres зростали.

Свіжість не безкоштовна. Короткі TTL — це податок, який ви платите вічно, а не одноразова плата.

Міні-історія 3: Нудна, але правильна практика, що врятувала день

Медіакомпанія запускала Postgres з PgBouncer і мала жорстке правило: фонова перебудова і пакетні задачі використовують окремі пуми і окремих DB-користувачів.
Це не було гламурно. Це був spreadsheet лімітів і кілька конфіг-файлів, які ніхто не хотів чіпати.

Під час сплеску трафіку, спричиненого гарячою новиною, їхній кластер Redis почав показувати підвищену затримку через шумного сусіда на хості.
Хіт-рейт кешу впав і сервери додатка частіше відпадали назад до Postgres. Це той момент, де більшість систем падає.

Натомість розділення пулів PgBouncer тихо робило свою роботу. Інтерактивний пул залишався придатним, бо фонова черга наситилася першою. Роботи перебудови стали в чергу, а не користувацькі запити.
Сайт сповільнився, але залишився працювати. Редактори продовжували публікувати, користувачі читали, і інцидент залишився інцидентом, а не заголовком у новинах.

Після цього вони налаштували Redis, виправили ізоляцію хостів і покращили таймаути клієнтів. Але найважливіше того дня — нудна дисципліна admission control і розділення пулів.
Надійність часто — це відмова бути хитрим не там, де треба.

Типові помилки: симптом → корінь → виправлення

1) Симптом: раптовий сплеск QPS до Postgres відразу після деплою

Корінь: змінився неймспейс ключів кешу (збільшення версії префіксу), фактично глобальна інвалідaція кешу.

Виправлення: версіонуйте ключі поступово, читайте обидва неймспейси під час rollout, або прогрівайте з жорсткими обмеженнями конкурентності плюс coalescing.

2) Симптом: Redis «працює», але таймаути додатка зростають і навантаження БД росте

Корінь: стрибки затримки Redis (fsync персистентності, повільна команда, насичення CPU); клієнти таймаутяться і падають до БД.

Виправлення: спочатку виправте затримку Redis; трохи збільшіть таймаути клієнта (не безконечно), додавайте хеджування акуратно і реалізуйте stale reads, щоб fallback до БД не був дефолтом.

3) Симптом: «занадто багато з’єднань» на Postgres під час піків трафіку

Корінь: немає пулінгу або пулінг неефективний; інстанси додатка відкривають більше з’єднань під тиском; шляхи перебудови ділять пул з інтерактивними читаннями.

Виправлення: розгорніть PgBouncer, обмежте пули, розділіть пули/користувачів для фонового rebuild, додайте ліміти конкурентності на рівні додатка.

4) Симптом: хіт-рейт кешу пристойний, але БД все одно «плавиться» на кордонах TTL

Корінь: синхронізовані TTL для гарячих ключів; когорта спливає одночасно і штурмує БД.

Виправлення: TTL джиттер; soft TTL з фоновим оновленням; пер-key об’єднання запитів.

5) Симптом: зростання помилок «lock wait timeout» під час промахів кешу

Корінь: запити перебудови включають записи або важкі на блокування читання; vacuum/DDL конфліктує; повтори посилюють конкуренцію.

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

6) Симптом: пам’ять Redis досягає максимуму і ключі часто змінюються; навантаження БД стає стрибкоподібним

Корінь: невідповідність політики витіснення, надто великі значення або некерована кардинальність ключів (напр., кешування по вибуху параметрів запиту).

Виправлення: обмежте кардинальність, стисніть або зберігайте менші проєкції, оберіть політику витіснення відповідно до TTL, і моніторте evictions як сигнал першого порядку.

7) Симптом: після failover Redis все сповільнюється, хоча Redis знову доступний

Корінь: холодний старт кешу; буря перебудов; немає coalescing; додаток синхронно перебудовує на шляху запиту.

Виправлення: stale-while-revalidate, soft TTL і фонові черги перебудови. Зробіть холодні старти керованими.

8) Симптом: read replicas відстають під час сплесків, а логіка інвалідaції кешу починає біситися

Корінь: лаг реплік + логіка «перевірити в БД»; штурм кешу викликає лаг; лаг викликає більше інвалідaцій/повторів.

Виправлення: не валідовайте гарячі кешовані читання проти відсталих реплік; віддайте перевагу подачі застарілого кешу з обмеженою застарілістю і маршрутизовуйте критичні читання на primary тільки коли потрібно.

Контрольні списки / покроковий план

Покроковий план: загартувати систему cache-aside проти штурмів

  1. Інвентар гарячих ключів: визначте топ ендпоінтів і ключів за частотою запитів та впливом промахів.
  2. Визначте бюджет застарілості: для кожного класу ключів вирішіть, яка застарілість прийнятна (секунди/хвилини/години).
  3. Реалізуйте об’єднання запитів: на рівні ключа гарантуйте, що одночасно запускається лише один builder по всьому фліту.
  4. Додайте soft TTL + hard TTL: віддавайте застаріле під час soft-вікна; припиняйте сервіс після hard TTL, якщо не оновили.
  5. Додайте TTL джиттер: десинхронізуйте спливи для гарячих ключів і когорт.
  6. Додайте негативне кешування: кешуйте «не знайдено» та порожні результати коротко.
  7. Розділіть пули: інтерактивні читання vs перебудови vs пакетні задачі; забезпечте через конфіг PgBouncer і DB-користувачів.
  8. Поставте перебудови за admission control: обмежуйте конкурентність і використовуйте backpressure при зростанні затримок Postgres.
  9. Виправте повтори: впровадьте retry budgets; експоненційний backoff; джиттер; без нескінченних повторів.
  10. Спостерігайте правильні сигнали: хіт-рейт кешу, частоту перебудов, lock waits, очікуючі клієнти PgBouncer, evictions Redis, wait events Postgres.
  11. Тестуйте режими відмов: симулюйте рестарт Redis, очищення кешу і зміну неймспейсу ключів у staging з реалістичною конкуренцією.
  12. Напишіть рукопис процедури: включіть «відключити перебудови», «віддавати лише stale» і процедури зниження навантаження.

Операційний чекліст: перед релізом зміни ключа кешу

  • Чи новий формат ключа може співіснувати зі старим під час rollout?
  • Чи існує максимум конкурентності перебудови на клас ключів?
  • Чи увімкнено stale-while-revalidate для некритичних ключів?
  • Чи TTL джиттер увімкнений за замовчуванням?
  • Чи можна швидко відключити rebuild-on-miss і віддавати stale?
  • Чи evictions Redis, затримка і wait events Postgres на одному дашборді?

Аварійний чекліст: під час живого штурму

  • Припиніть погіршувати ситуацію: зменшіть повтори і вимкніть агресивні прогрівачі.
  • Увімкніть режим «віддавати stale», якщо доступний; безпечно продовжте TTL для гарячих ключів.
  • Обмежте конкурентність перебудов; ізоляція пулів від інтерактивного пулу.
  • Якщо Redis — вузьке місце, стабілізуйте Redis першим; інакше ви просто переганяєте навантаження в Postgres.
  • Якщо Postgres насичений, скорочуйте навантаження: rate limit ендпоінти, деградуйте некритичні фічі і захищайте шляхи логіну/чекауту.

FAQ

1) Чи може PostgreSQL бути кешем, якщо дати більше RAM?

Іноді. Але це пастка як первинна стратегія. Буфер-кеш Postgres допомагає при повторних читаннях, але штурми вносять конкуренцію і черги, що RAM не вирішує:
з’єднання, CPU на планування/виконання та I/O-сплески для не-резидентних сторінок. Використовуйте RAM, але також застосовуйте coalescing і admission control.

2) Чи є Redis єдиним способом запобігти штурмам кешу?

Ні. Можна робити in-process single-flight, використовувати message queue для серіалізації перебудов або попередньо обчислювати проєкції в окремому сховищі.
Redis популярний, бо вже часто присутній і дає примітиви координації. Головне — контролювати сплеск перебудов, а не бренд інструмента.

3) Чи варто використовувати розподілений лок Redis для перебудов кешу?

Так, але тримайте лок короткочасним і розглядайте його як підказку координації, а не механізм коректності. Використовуйте TTL на лок і обробляйте втрату локу безпечно.
Перебудова має бути ідемпотентною, і система повинна терпіти поодинокі подвійні перебудови. Мета — «переважно один», а не «абсолютно один».

4) Який TTL використовувати?

Чесна відповідь: той, який дозволяє вашому бюджету застарілості, плюс запас, щоб уникнути постійних перебудов. Довші TTL зменшують тиск на перебудову.
Додайте soft TTL для прийнятної свіжості і hard TTL, щоб не віддавати древні дані.

5) Чому повтори погіршують штурми?

Повтори перетворюють часткові відмови на посилене навантаження. Коли Postgres повільний, кожен таймаут може породжувати кілька додаткових запитів.
Додайте джиттер і експоненційний backoff, обмежте повтори і віддавайте перевагу stale кешу, а не «спробуй знову негайно».

6) Як зрозуміти, чи це штурм кешу vs справжній регрес БД?

Штурми мають підпис: повторювані однакові запити ростуть у pg_stat_statements, промахи кешу зростають, а затримка різко погіршується навколо TTL-кордонів або скидів кешу.
Регрес БД виглядає як один запит змінив план або одна таблиця надулася. Підтвердження — кореляція хіт/міс з топ-запитами і wait events.

7) Чи безпечний «stale-while-revalidate»?

Безпечно, коли бізнес-семантика це дозволяє. Не підходить для критичних за коректністю читань, як баланси, права доступу або коміти інвентарю.
Для таких випадків використовуйте транзакційні читання з Postgres (і кешуйте обережно з явною інвалідaцією) або проектуйте окрему read-модель із суворими правилами оновлення.

8) А як щодо кешування результатів запитів у Postgres (materialized views)?

Materialized views і summary tables можуть зменшити обчислення на запит, але самі по собі вони не вирішують штурми. Якщо шар кешу все одно спливає і тригерить перебудови, ви можете спричинити штурм при оновленні матеріалізованого вигляду.
Вони найкраще працюють як частина системи: дешевша read-модель у Postgres + Redis кеш + coalescing і віддавання stale.

9) Чи потрібні read replicas, щоб пережити штурми?

Репліки допомагають при стабільному навантаженні і прогнозованому збільшенні читань. Під час штурмів репліки можуть відставати і стати новим режимом відмови.
Виправляйте штурми на шарі кешу перш за все. Потім використовуйте репліки для steady-state масштабування, не як первинне аварійне гальмо.

Висновок: наступні кроки, які можна зробити цього тижня

Штурми кешу — не якась містична прокляття розподілених систем. Це те, що відбувається, коли «промах кешу → йдемо в DB» дозволено масштабуватися лінійно з трафіком.
Ваше завдання — розірвати цю лінійність.

Якщо ви зробите цього тижня нічого іншого, зробіть ці три речі:

  1. Додайте об’єднання запитів для ваших найгарячіших ключів (навіть грубий Redis-лок з TTL кращий за хаос).
  2. Реалізуйте stale-while-revalidate для даних, які можуть бути трохи застарілими, і додайте hard TTL, щоб уникнути зомбі-значень.
  3. Захистіть Postgres розділенням пулів і admission control, щоб робота з перебудови не могла задушити інтерактивний трафік.

Потім вимірюйте: хіт-рейт, частоту перебудов, lock waits, очікуючі клієнти PgBouncer, затримку Redis і wait events Postgres. Дашборд не запобіжить інцидентам, але він не дозволить вам гадати.
А гадання — це те, як штурми стають відмовами.

← Попередня
Адаптивні таблиці для технічної документації, які не ламаються в продакшені
Наступна →
Моніторинг затримок ZFS: графіки, що виявляють катастрофу на ранній стадії

Залишити коментар