Перейти до вмісту

Key-value і кеші

Картку товару на «Крамниці» рахує функція product_card(): рейтинг і кількість відгуків, 300 мс. Перед нею поставили Valkey із TTL 5 с. Поки ключ живий, запити обслуговуються за 2 мс і базу не чіпають. У мить, коли ключ зникає, 200 одночасних запитів не знаходять його, і всі 200 викликають функцію: відповіді приходять до 3.4 с. Кеш, який мав розвантажити базу, на мить навантажив її сильніше, ніж без нього. Це cache stampede.

Передумови. Втрачене оновлення: модуль 10. fsync і журнал: модуль 11. Асинхронна реплікація: модуль 12. Шардинг за ключем: модуль 13.

Сховище «ключ → значення» робить дві речі: кладе значення за ключем і віддає його назад. Ключ відомий наперед, значення читають і пишуть цілком, дані вміщаються в пам’ять, а відповісти треба за долі мілісекунди. Це патерн доступу (access pattern) такого сховища. На «Крамниці» так працюють лічильник переглядів, кошик, сесія, черга листів, топ товарів, готова картка товару. У PostgreSQL кожна з цих задач можлива, але лічильник там означає запис із журналом і версіями рядків (модулі 10 і 11), а топ означає агрегацію.

У курсі це Valkey, форк Redis: протокол і команди ті самі (чому форк, нижче). Найпростіший приклад, лічильник переглядів товару 39:

127.0.0.1:6379> INCR views:product:39
(integer) 1
127.0.0.1:6379> INCRBY views:product:39 9
(integer) 10

Кожна команда виконується цілком і без втручання інших, тож INCR не втрачає оновлень, як у модулі 10: читання, додавання й запис тут одна неподільна дія.

Ціна простоти: шукати можна лише за ключем, схеми немає, дані обмежені пам’яттю, а транзакції зводяться до однієї команди чи невеликого пакета. Тому Valkey стоїть поруч із базою: джерело правди — PostgreSQL, а у Valkey лежить те, що можна перерахувати.

Структури, коли рядка замало

Section titled “Структури, коли рядка замало”

Кошику чи топу рядка не вистачає: довелося б читати все значення, міняти в застосунку й писати назад. Тому є ще структури.

Структури Valkey і задачі «Крамниці»: рядок для лічильника, хеш для кошика, список і stream для черги, множина для вподобань, sorted set для топу, HyperLogLog для унікальних відвідувачіврядокviews:product:39лічильник переглядівINCR GETSET … EX 3600хешcart:1001кошик: товар → кількістьHSET HINCRBYHGETALL HDELсписок і streamqueue:emailsчерга подійLPUSH BRPOPXADD XREADGROUPмножинаlikes:39хто вподобав товарSADD SISMEMBERSINTER SCARDsorted settop:viewsтоп товарів за переглядамиZINCRBYZREVRANGE … WITHSCORESHyperLogLoguv:39унікальні відвідувачі, 12 КБPFADD PFCOUNTPFMERGE
Шість структур і задачі, які вони розв'язують на «Крамниці». Ключ названо за схемою «тип:сутність:ідентифікатор».

Хеш. Кошик: поле — товар, значення — кількість. Одне поле міняється без читання решти:

127.0.0.1:6379> HSET cart:1001 39 2 55 1
(integer) 2
127.0.0.1:6379> HINCRBY cart:1001 39 1
(integer) 3
127.0.0.1:6379> HGETALL cart:1001
1) "39"
2) "3"
3) "55"
4) "1"

Список і stream. Черга листів на списку: LPUSH додає, BRPOP бере, і якщо виконавець упав після BRPOP, лист втрачено. Stream тримає прочитане, поки виконавець не підтвердить:

127.0.0.1:6379> XGROUP CREATE events:shop workers $ MKSTREAM
OK
127.0.0.1:6379> XADD events:shop * type order_paid order_id 21728
"1790811431843-0"
127.0.0.1:6379> XREADGROUP GROUP workers w1 COUNT 1 STREAMS events:shop >
1) 1) "events:shop"
2) 1) 1) "1790811431843-0"
2) 1) "type"
2) "order_paid"
3) "order_id"
4) "21728"
127.0.0.1:6379> XPENDING events:shop workers
1) (integer) 1
127.0.0.1:6379> XACK events:shop workers 1790811431843-0
(integer) 1

До XACK повідомлення числиться за w1 як непідтверджене; якщо w1 помер, інший виконавець забере його командою XAUTOCLAIM. Це доставка «принаймні один раз»: повідомлення можуть повторитися, тож обробка має бути безпечною для повтору, як у модулі 10.

Множина. Хто вподобав товар (likes:55 заповнено так само). Повторний SADD того самого покупця нічого не міняє, а перетин відповідає на запит «хто вподобав обидва»:

127.0.0.1:6379> SADD likes:39 c:17 c:42 c:99
(integer) 3
127.0.0.1:6379> SINTER likes:39 likes:55
1) "c:42"
2) "c:99"

Sorted set. Топ товарів за переглядами. Кожен елемент має рахунок, і набір завжди впорядкований за ним, тож топ — це читання початку. Те саме в PostgreSQL на view_events — агрегація по всій таблиці (розміри датасету пояснено на сторінці Датасет):

СпробуйPostgreSQLCtrl+Enter — виконати
product_id | views
------------+-------
2410 | 3199
465 | 2959
58 | 1644
602 | 950
450 | 931

Те саме з top:views, куди кожну подію перегляду додає ZINCRBY top:views 1 <product_id>:

127.0.0.1:6379> ZREVRANGE top:views 0 2 WITHSCORES
1) "2410"
2) "3199"
3) "465"
4) "2959"
5) "58"
6) "1644"

Перші три рядки збігаються. Час: на small (52 918 переглядів) PostgreSQL 18.6 виконує запит за 7.7–30 мс (шість запусків), на medium (610 тис. подій, паралельний план) за 102–165 мс; ZREVRANGE за valkey-benchmark має медіану 0.09 мс. Різниця на два порядки, але порівняння нечесне: запит читає всі події, а набір уже містить підсумок, за який заплачено командою на кожну з них. Якщо топ потрібен раз на хвилину, вистачить матеріалізованого представлення (модуль 4); sorted set виграє, коли його читають часто, а рахунки постійно міняються.

HyperLogLog. Скільки унікальних відвідувачів переглянуло товар. Точний count(DISTINCT session_id) потребує пам’яті пропорційно до кількості сесій. HyperLogLog тримає фіксовані ≈ 12 КБ на лічильник і відповідає наближено, документація обіцяє стандартну похибку 0.81 %. На наших даних (session_id подій перегляду):

Розмір Точно PFCOUNT Похибка
small, усі товари 21 522 21 516 −0.03 %
medium, усі товари 214 707 217 549 +1.32 %

Окремо по товарах (medium): для 40 товарів із понад 1000 унікальних середня похибка 0.50 %, максимум 2.03 %. Лічильник uv:all зайняв 14 368 байтів за MEMORY USAGE, малі лічильники Valkey ущільнює (72 байти; найгірший випадок 3 проти 2). Для «приблизно 217 тисяч» цього досить, для рахунку грошей ні.

Ключ може мати час життя: SET ключ значення EX 60 або EXPIRE. Прострочений ключ зникає, коли до нього звернулись, і під час фонового вибіркового перегляду ключів із TTL. Коли пам’ять упирається в maxmemory, maxmemory-policy вирішує, що витіснити. Виміряно на екземплярах із maxmemory 16mb: 3000 «сесій» без TTL по 1 КБ, потім 40 000 карток із TTL від 1 до 60 хвилин, 500 із них «гарячі» й читаються регулярно.

Політика Сесій вціліло з 3000 Промахів по гарячих картках Інше
noeviction 3000 0 28 524 записи відхилено помилкою OOM
allkeys-lru 1 0 витіснено 28 619 ключів
volatile-lru 3000 0 витіснено лише ключі з TTL
volatile-ttl 3000 26 % серед карток вціліли переважно з довгим TTL: 9677 проти 858

allkeys-lru залишив гарячі картки, але викинув майже всі сесії: ключ без TTL, який давно не читали, нічим не відрізняється від непотрібного кешу. volatile-ttl береже ключі без TTL, проте першими викидає найкоротші TTL, тож гаряча картка з TTL 60 с вилітає раніше за холодну з TTL 3600 с. LRU у Valkey наближений: береться вибірка з maxmemory-samples ключів (типово 5) і викидається найстаріший із неї. Кеш і дані, які не можна перерахувати, в одному екземплярі не тримають; для чистого кеша ставлять allkeys-lru або allkeys-lfu.

У cache-aside кеш про базу нічого не знає, усе робить застосунок: питає кеш, на промаху бере з бази й кладе в кеш.

Cache-aside: застосунок питає Valkey, на промаху йде в PostgreSQL і кладе результат у кеш; новий відгук скидає ключValkeyзастосунокPostgreSQL123451 GET card:392 влучання: JSON клієнту, база не чіпається3 промах: SELECT * FROM product_card(39), 300 мс4 SET card:39 … EX 60: наступні читачі влучать5 новий відгук: INSERT у базу, потім DEL card:39
Cache-aside: застосунок сам читає кеш, на промаху бере з бази й кладе в кеш; новий відгук скидає ключ.
async function getCard(id) {
const hit = await redis.get(`card:${id}`);
if (hit !== null) return JSON.parse(hit);
const card = await loadCard(id); // SELECT * FROM product_card($1)
if (card) await redis.set(`card:${id}`, JSON.stringify(card), { EX: 60 });
return card;
}

У write-through кожен запис іде і в базу, і в кеш, тож кеш завжди теплий. Ціна: два записи, між якими процес може померти, і в кеш потрапляє те, що ніхто не читатиме.

Інвалідація — найважче в кешуванні. Після нового відгуку картка в кеші бреше, і є два захисти: видалення ключа після запису в базу й TTL як страхувальна сітка. Спершу INSERT, потім DEL: якщо перевернути, читач між ними візьме стару картку з бази й покладе її в кеш уже після DEL. Те саме буває й за правильного порядку, коли читач узяв стару картку до коміту відгуку, а поклав після DEL: кеш бреше до кінця TTL, тож TTL тут не формальність. Facebook у статті про Memcache для цього описав «оренди» (leases): кеш видає читачеві токен, і запис із застарілим токеном відхиляється.

Cache-aside ламається там, де почалася ця сторінка: коли ключ зникає під навантаженням.

У момент закінчення TTL 200 запитів без захисту дають 200 перерахунків, з блокуванням один, зі stale-while-revalidate один у фоні, а користувачі не чекаютьTTL минувчасбез захистувлучаннянове значення200 викликів по 300 мс, усі запити чекаютьблокування на перерахуноквлучаннянове значення1 виклик, решта 199 чекають ≈ 0.3 сstale-while-revalidateвлучаннянове значеннязастаріле віддається одразу, 1 виклик у фонізастаріле
Три стратегії в момент закінчення TTL. Червоне — виклики повільної функції, сіре — запити, що чекають на чужий перерахунок. Часова шкала умовна.

Вимірювання на PostgreSQL 18.6 і Valkey 9.1.2, застосунок із L8 на Node.js, TTL 5 с, перерахунок 300 мс. Перша серія: ключ дожив до кінця, тоді 200 запитів одночасно.

Захист Викликів product_card() Відповідь p50
немає 200 2.0 с (усі за 3.5 с)
блокування на перерахунок 1 0.69 с
stale-while-revalidate з блокуванням 1 0.31 с
stale-while-revalidate без блокування 200 0.23 с
раннє ймовірнісне оновлення 200 2.0 с

Затримки містять 0.1–0.45 с на сам вибух нових з’єднань (стільки ж і за свіжого кеша). Друга серія ближча до життя: 100 запитів на секунду на одну картку 30 секунд, п’ять закінчень TTL.

Захист Викликів Запитів повільніше 200 мс p99
немає 140 140 394 мс
блокування 5 69 269 мс
stale-while-revalidate з блокуванням 5 0 13 мс
stale-while-revalidate без блокування 134 0 13 мс
раннє ймовірнісне оновлення 11 11 15 мс

Блокування на перерахунок. Хто побачив промах, бере SET lock:card:39 <токен> NX PX 5000. Решта чекає на значення або йде в базу після таймауту. PX потрібен, щоб блокування згасло, коли власник помер; знімати його треба порівнянням токена, а не просто DEL, бо інакше власник після закінчення PX зніме вже чуже блокування. Недолік видно в таблиці: 69 запитів чекали.

Stale-while-revalidate. Назву взято з розширення HTTP Cache-Control (RFC 5861). У кеші лежить значення з позначкою «свіже до». Прострочене віддається одразу, а перерахунок запускає один запит у фоні. Користувачі не чекають, але блокування все одно потрібне: без нього база отримала ті самі 134 виклики, лише користувачі цього не відчули. Перший запит на порожній кеш (холодний старт) не захищений: у прогоні 29 запитів вийшли в базу водночас.

Раннє ймовірнісне оновлення обходиться без блокування: читач із ймовірністю, що росте до кінця TTL, оновлює значення раніше. За роботою Vattani, Chierichetti, Lowenstein (VLDB 2015) він оновлює, якщо тепер − δ · β · ln(rand) ≥ момент_закінчення, де δ — час останнього перерахунку. Працює, коли запити йдуть безперервно (11 викликів замість 140), і марне, коли ключ нікому не потрібен до самого кінця (перша серія: 200).

До TTL варто додавати випадковий розкид, щоб ключі, записані разом, не вмирали разом.

Розподілене блокування

Section titled “Розподілене блокування”

SET lock:order:21728 <токен> NX PX 10000 бере блокування: другий виклик повертає (nil), поки перше не знято або не згасло. Для stampede цього вистачає: якщо блокування дісталося двом, база отримає два виклики замість одного. Інша річ, коли блокування захищає коректність. Тут PX 1 с, власник A зависає на 1.5 с (пауза збирача сміття, своп, затримка мережі), B бере блокування й пише, а потім прокидається A і пише теж:

1 мс A взяв блокування (PX 1000), fencing-токен 1
1013 мс B взяв блокування (PX 1000), fencing-токен 2
1115 мс B прокинувся й пише
1115 мс сховище прийняло запис B: "запис від B"
1502 мс A прокинувся й пише
1502 мс сховище прийняло запис A: "запис від A"
1503 мс A знімає блокування: воно вже не наше, чуже не чіпаємо

Перевірка токена при знятті врятувала блокування B, але запис A уже в сховищі й затер B. Захищає лише перевірка на боці сховища: кожне блокування видає зростаючий fencing-токен (тут INCR), і сховище відхиляє запис із токеном, меншим за вже бачений (токен 1 < 2).

Один потік і цикл подій

Section titled “Один потік і цикл подій”

Тепер про те, чому Valkey швидкий. Команди виконує один потік. Він крутить цикл подій над epoll (модуль 13 курсу ОС): бере готові сокети, читає команди, виконує по одній, пише відповіді. Швидким це робить те, що між командами немає блокувань і перемикань контексту, дані лежать у пам’яті, а протокол простий. На нашій машині (Docker Desktop, valkey-benchmark, 50 клієнтів) SET, GET і INCR дають близько 200 тисяч операцій на секунду, а з конвеєром із 16 команд SET доходить до 1.8 мільйона.

Зворотний бік: поки виконується повільна команда, чекають усі. На 777 тисячах ключів і множині з мільйона елементів: одне з’єднання шле PING щомілісекунди, інше виконує важку команду.

Команда Час команди Найгірший PING
без навантаження 8.7 мс
KEYS * (777 тис. ключів) 323 мс 160 мс
SCAN COUNT 1000, цілий обхід 488 мс 3.3 мс
SMEMBERS (1 млн елементів) 296 мс 185 мс
DEL (1 млн, lazyfree-lazy-user-del no) 78 мс 77 мс
UNLINK (1 млн) 1 мс 1.2 мс

KEYS * потрапив у SLOWLOG із часом 75 548 мкс (поріг slowlog-log-slower-than типово 10 000 мкс). Звідси правила: замість KEYS ходити SCAN, великі ключі видаляти UNLINK (звичайний DEL у Valkey 9.1 теж звільняє пам’ять у фоні, бо lazyfree-lazy-user-del типово yes), SMEMBERS, HGETALL і LRANGE 0 -1 на великих колекціях не викликати.

Параметр io-threads типово 1. Додаткові потоки читають, розбирають команди й пишуть відповіді, а виконує їх усе одно головний; valkey.conf радить вмикати їх лише на машинах хоча б із трьома ядрами. Valkey 8.0 (2024) цю частину переробив: за блогом проєкту, SET на AWS c7g.16xlarge із 8 потоками зріс з 360 тис. до 1.19 млн запитів на секунду. У нашому Docker із 200 клієнтами io-threads 4 дав 56–99 тис. операцій на секунду проти 36–55 тис. з одним потоком.

Персистентність: RDB і AOF

Section titled “Персистентність: RDB і AOF”

Пам’ять зникає разом із процесом, тож є два способи тримати копію на диску: RDB — знімок усього набору: Valkey робить fork, дочірній процес пише файл, а батьківський працює далі. Типові правила знімків: save 3600 1 300 100 60 10000. На 776 629 ключах (59.4 МБ у пам’яті) файл вийшов 39.6 МБ, BGSAVE завершився менш ніж за секунду. AOF — журнал команд: кожну зміну дописують у файл, а appendfsync вирішує, коли робити fsync (модуль 11). Вартість за valkey-benchmark (50 клієнтів, SET, по два запуски):

Режим Операцій/с З конвеєром 16
без персистентності 135–152 тис. 1.45 млн
RDB, типові правила 134–176 тис. 1.50 млн
AOF everysec (типовий) 145–170 тис. 1.03 млн
AOF no 178–195 тис. 0.95 млн
AOF always 16–18 тис. 166 тис.

always відповідає лише після fsync (на цій машині близько 400 мкс), тож пропускна здатність падає в дев’ять разів. everysec робить fsync раз на секунду, і документація допускає втрату секунди записів при аварії. Втрату перевірено так: клієнт пише SET seq i по одному запису, рахує підтверджені, а контейнер вбивають docker kill (SIGKILL) на четвертій секунді.

Режим Підтверджено записів seq після перезапуску
без персистентності 29 186 0
RDB, типові правила 32 439 0
AOF everysec 25 303 25 303
AOF no 33 276 33 276
AOF always 2 665 2 665

RDB втратив усе: знімок за правилами ще не робився. AOF не втратив нічого навіть із no, але це не доводить, що fsync зайвий: вбито процес, а не машину. Записане write() лежить у кеші сторінок ядра й переживає смерть процесу, а appendfsync захищає від втрати живлення чи падіння ядра, чого в Docker не відтворити без зупинки віртуальної машини.

Реплікація у Valkey асинхронна: мастер виконує команду, відповідає клієнтові й лише потім надсилає її репліці (модуль 12). Скільки це коштує, видно, якщо відрізати репліку від мережі, записати 1000 ключів, вбити мастера й підняти репліку:

127.0.0.1:6379> SET before:1 1
OK
127.0.0.1:6379> WAIT 1 500
(integer) 1
$ docker network disconnect m14_default rp-b
127.0.0.1:6379> SET after:1 1
OK
127.0.0.1:6379> WAIT 1 500
(integer) 0
(0.53s)
... ще 999 записів, усі отримали OK
$ docker kill rp-a ; valkey-cli -h rp-b REPLICAOF NO ONE
127.0.0.1:6379> EXISTS before:1 after:1 after:1000
(integer) 1

Тисяча підтверджених клієнтові записів зникла. WAIT повертає, скільки реплік підтвердили останній запис цього клієнта: до розриву 1, після 0 через 0.53 с. Він змушує чекати репліку, але лінеаризованості (linearizability: ніби кожна операція діє миттєво) не дає: мастер може вмерти між підтвердженням клієнтові й відправленням репліці, а Valkey Cluster теж реплікує асинхронно й має те саме вікно втрат. Така поведінка називається eventual consistency (модуль 13): репліки збігаються з мастером лише тоді, коли записи припиняться.

Cluster ділить ключі на 16 384 хеш-слоти за CRC16(ключ) mod 16384, і кожен вузол володіє діапазоном слотів: це шардинг за ключем із модуля 13, але з фіксованою кількістю слотів замість кільця. На трьох вузлах:

127.0.0.1:6379> CLUSTER KEYSLOT foo
(integer) 12182
127.0.0.1:6379> SET foo bar
(error) MOVED 12182 172.19.0.6:6379
127.0.0.1:6379> MSET a 1 b 2
(error) CROSSSLOT Keys in request don't hash to the same slot
127.0.0.1:6379> CLUSTER KEYSLOT {cart:1001}:items
(integer) 3818
127.0.0.1:6379> CLUSTER KEYSLOT {cart:1001}:meta
(integer) 3818

Слот 12182 належить іншому вузлу, тож клієнт без -c отримує MOVED. Команда з кількома ключами працює лише в межах одного слота, тому ключі називають із хеш-тегом: хешується лише текст у фігурних дужках, і {cart:1001}:items та {cart:1001}:meta лежать разом. Транзакцій через слоти немає.

Redis до версії 7.2 поширювався за ліцензією BSD. 20 березня 2024 року Redis Inc. оголосила, що від 7.4 код іде за подвійною ліцензією RSALv2 і SSPLv1; жодна не схвалена OSI, і допис визнає, що Redis більше не є відкритим кодом за визначенням OSI. 28 березня 2024 року Linux Foundation оголосила Valkey: форк Redis 7.2.4 під BSD, який підтримали AWS, Google Cloud, Oracle, Ericsson і Snap. 1 травня 2025 року Redis додав AGPLv3 як ще один варіант ліцензії для Redis 8, куди ввійшли й модулі колишнього Redis Stack.

У нашому docker compose працює Valkey 9.1.2 (профіль redis). Клієнти й redis-cli (у образі є і valkey-cli) працюють без змін. Для сумісності сервер і досі відповідає, що він Redis 7.2.4:

$ docker compose exec redis valkey-cli INFO server
redis_version:7.2.4
valkey_version:9.1.2

Далі проєкти розходяться: Redis 8 приніс нові типи (наприклад, vector sets), Valkey 8.0 переробив багатопотоковий ввід-вивід (вище). Команду, якої немає в Redis 7.2.4, перевіряють у документації обох. Щоб узяти Redis, у docker-compose.yml підставляють image: redis:8.10.2.

Terminal window
docker compose --profile postgres --profile redis up -d --wait
docker compose exec redis valkey-cli

Після 12 секунд навантаження в 100 запитів на секунду на одну картку з L8 (TTL 5 с, з блокуванням) INFO stats показує, скільки запитів кеш обслужив:

127.0.0.1:6379> INFO stats
...
expired_keys:2
evicted_keys:0
keyspace_hits:1045
keyspace_misses:189

Відношення hits / (hits + misses) називають hit rate (частка влучань у кеш): головна цифра кеша (тут у промахи входить опитування тих, хто чекав на блокування). evicted_keys росте, коли maxmemory замалий. Для одного ключа є TTL, MEMORY USAGE і OBJECT ENCODING (як ущільнено значення). Виклики повільної функції веде вона сама: SELECT count(*) FROM l08.calls. measure.sh з L8 відтворює обидві серії про stampede.

Salvatore Sanfilippo (antirez), автор Redis, запропонував Redlock для блокування над кількома незалежними екземплярами: клієнт бере блокування на більшості з пʼяти й вважає його чинним, поки не минув термін мінус витрачений час. У лютому 2016 року Мартін Клеппман у дописі «How to do distributed locking» заперечив: паузи процесу, затримки мережі й стрибки годинника ламають припущення про обмежені інтервали, тож блокування може діяти в двох клієнтів одночасно, а Redlock не видає fencing-токена, який виправив би наслідки. Для блокування «заради ефективності» (як наше блокування на перерахунок) він вважає достатнім одного вузла, а для коректності радить систему з консенсусом (ZooKeeper) і fencing або транзакцію бази. Antirez у відповіді «Is Redlock safe?» того ж місяця заперечив, що Redlock міряє час між кроками й відкидає блокування, яке витратило забагато, що годинники не слід стрибком переводити, а випадковий токен дозволяє перевірку «check and set» і без токена, що зростає.

Висновок практичний: блокування, яке лише зменшує зайву роботу, ставимо у Valkey. Блокування, від якого залежить цілісність даних, замінюємо блокуванням рядка в PostgreSQL (SELECT … FOR UPDATE, модуль 10) або умовним записом із версією.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«Однопотоковий означає повільний». Один потік без блокувань між командами дає сотні тисяч операцій на секунду. Погано лише довгі команди: KEYS * на 777 тисячах ключів зупинив усіх на 160 мс.

«appendfsync everysec не губить даних». При падінні машини вона втрачає до секунди записів, а always коштує дев’ятикратного падіння пропускної здатності.

«Після DEL кеш уже не бреше». Читач, який узяв стару картку до коміту відгуку, кладе її в кеш після DEL, і вона живе до кінця TTL.

«Блокування усуває stampede». Воно зводить виклики до одного, але решта чекає (69 запитів повільніше 200 мс у другій серії), а блокування без PX і перевірки токена лишається назавжди або його знімає чужий клієнт.

Перевір себе

1. Кеш картки має TTL 5 с, на неї йде 200 запитів на секунду. Що буде в момент закінчення TTL без жодного захисту?
2. Екземпляр Valkey тримає і кеш карток із TTL, і сесії без TTL. Ліміт пам'яті вичерпано. Яка політика витіснення знищить сесії?
3. Valkey працює з AOF `appendfsync everysec`, процес вбили `kill -9` посеред запису. Що з даними і чому це не доводить, що `fsync` зайвий?
4. Репліку Valkey відрізано від мережі, мастер прийняв 1000 записів (відповідь `OK`) і загинув, репліку підняли. Скільки записів вціліло і що повертав `WAIT 1 500`?
5. Клієнт A узяв блокування `SET … NX PX 1000` і завис на 1.5 с, клієнт B узяв його після закінчення PX й записав. Прокинувшись, A теж пише. Що захистить дані?

L8. Кеш перед базою: поставити Valkey перед повільною карткою товару, додати інвалідацію при новому відгуку, захист від stampede і лишити застосунок живим без Valkey.