В уроке про cache-aside кэш работал просто: нашли значение, отдали, не нашли, сходили в базу и положили. В прошлом уроке мы разобрали, как ключи протухают и вытесняются. Пока запросов мало, этого хватает. Но под нагрузкой у простого кэша есть три слабых места, и у каждого своё имя: стампед, лавина и проникновение.
Кофейня в восемь утра
Представь кофейню у метро. Бариста заранее варит большой кофейник, и все берут из него. В 8:00 кофейник кончается, а у стойки двадцать человек. Если каждый пойдёт варить себе сам, кухня встанет. Разумнее, чтобы варил один, а остальные подождали минуту.
Вторая беда: бариста сварил все кофейники в 7:00, и все они кончаются одновременно в 8:00. Лучше варить вразнобой.
Третья: человек раз за разом просит напиток, которого нет в меню, и бариста каждый раз идёт на склад проверять. Проще один раз ответить «такого нет» и запомнить.
Кофейник это кэш, кухня это база. Разберём каждую беду по очереди.
Стампед: все сразу на кухню
Карточку популярной книги читают сто раз в секунду. Ключ cache:book:1 протухает, и в следующие 200 миллисекунд, пока первый запрос ходит в базу, остальные тоже видят промах и тоже идут в базу. Это стампед: база получает сотню одинаковых запросов вместо одного.
Проверим на Node.js с библиотекой node-redis. База здесь имитирована: функция ждёт 200 миллисекунд и считает свои вызовы. Наивный cache-aside из урока 11:
import { createClient } from 'redis';
const redis = await createClient({ url: 'redis://localhost:6379' }).connect();
async function getBookNaive(id) {
const key = `cache:book:${id}`;
const cached = await redis.get(key);
if (cached !== null) return JSON.parse(cached);
const book = await loadBookFromDb(id);
await redis.set(key, JSON.stringify(book), { EX: 300 });
return book;
}
await Promise.all(Array.from({ length: 100 }, () => getBookNaive(1)));
На пустом кэше сто одновременных вызовов дали naive db queries: 100. Сто походов в базу за одной карточкой.
Лекарство: один готовит, остальные ждут
Пусть в базу идёт только тот, кто первым захватил блокировку. Блокировка в Redis это обычный ключ, который ставят командой SET с двумя флагами:
NX: записать, только если ключа ещё нет;EX 10: удалить ключ через 10 секунд, даже если владелец упал и не снял блокировку сам.
127.0.0.1:6379> SET lock:cache:book:1 1 NX EX 10
OK
127.0.0.1:6379> SET lock:cache:book:1 1 NX EX 10
(nil)
Первый получил OK и пошёл в базу. Второй получил (nil): блокировка занята, надо подождать и снова заглянуть в кэш.
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function getBook(id) {
const key = `cache:book:${id}`;
const lockKey = `lock:${key}`;
for (let attempt = 0; attempt < 50; attempt += 1) {
const cached = await redis.get(key);
if (cached !== null) return JSON.parse(cached);
const locked = await redis.set(lockKey, '1', { NX: true, EX: 10 });
if (locked === 'OK') {
try {
const book = await loadBookFromDb(id);
await redis.set(key, JSON.stringify(book), { EX: 300 });
return book;
} finally {
await redis.del(lockKey);
}
}
await sleep(50);
}
return loadBookFromDb(id);
}
Те же сто одновременных вызовов: lock db queries: 1. В базу сходил один запрос, остальные 99 подождали и взяли готовое из кэша. Последняя строка нужна на крайний случай: если за 50 попыток значение так и не появилось, идём в базу сами, а не висим вечно.
У простой блокировки есть тонкости: что, если работа длится дольше 10 секунд и блокировку снимет чужой DEL? Их разберём в уроке про распределённые блокировки. Для защиты кэша такой версии хватает.
Другой способ: обновить заранее
Можно вообще не допускать промаха. Вместе со значением храним время, когда его пора обновить, чуть раньше истечения TTL. Запрос, который заметил «пора», в фоне пересчитывает значение, а пока все читают старое. Этот приём называют ранним обновлением. Он сложнее в коде, поэтому начинают обычно с блокировки.
урок из курса
Урок из курса «Redis с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.