Пошук уроків, статей та іншого контенту
Виявите ключі з аномально великою кількістю запитів і застосуєте реплікацію, шардинг та розподіл ключів.
Hot key — це ключ, до якого надходить непропорційно велика кількість запитів порівняно з іншими ключами.
Наприклад, сервіс зберігає перегляди публікацій:
post:101 120 запитів/сек
post:102 95 запитів/сек
post:103 80 запитів/сек
post:999 85000 запитів/секКлюч post:999 є гарячим. Навіть якщо всі ключі рівномірно розподіляються між вузлами за хешем, усі запити до цього ключа потрапляють на один вузол.
Це відрізняється від ситуації, коли навантаження нерівномірне через розмір даних:
hot key — один або кілька ключів отримують надто багато запитів;
hot partition — один розділ або шард отримує надто багато запитів;
hot node — конкретний сервер перевантажений запитами, CPU, пам’яттю або мережею.
Hot key може спричинити:
зростання затримки;
тайм-аути;
перевантаження одного вузла;
черги запитів;
каскадні відмови;
нерівномірне використання реплік.
Припустимо, ключі розподіляються між чотирма шардами:
shard = hash(key) % 4Для звичайних ключів це може працювати добре:
user:101 -> shard 0
user:102 -> shard 3
user:103 -> shard 1
user:104 -> shard 2Але всі запити до одного ключа мають однаковий результат хешування:
product:popular -> shard 1
product:popular -> shard 1
product:popular -> shard 1Збільшення кількості шард не гарантує розподілу навантаження від одного hot key. Воно лише розподіляє різні ключі між більшою кількістю вузлів.
Тому розв’язання має відповідати типу проблеми:
багато різних ключів перевантажують один шард — переглянути схему партиціювання;
один ключ перевантажує один шард — реплікувати або розділити цей ключ;
записи до одного ключа перевищують пропускну здатність системи — змінити модель запису або спосіб агрегації.
Виявлення має базуватися на вимірюваннях, а не на припущеннях.
Для кожного ключа можна рахувати кількість запитів за часовий інтервал:
ключ запитів за 10 секунд
article:1 1 200
article:2 1 100
article:3 980
article:42 850 000Важливо використовувати обмежене в часі вікно, наприклад:
1 секунда — для виявлення раптових сплесків;
1 хвилина — для оперативного моніторингу;
5–15 хвилин — для стійких аномалій.
Лічильник можна реалізувати на рівні застосунку, проксі або сховища. Однак не варто бездумно записувати кожен ключ у систему моніторингу: якщо ключів мільйони, це саме по собі створить високе навантаження.
Для кожного вузла або шарда корисні:
кількість запитів за секунду;
p95 і p99 затримки;
кількість помилок і тайм-аутів;
використання CPU;
використання пам’яті;
мережевий трафік;
довжина черги запитів;
кількість cache hit і cache miss.
Окремо варто аналізувати:
найпопулярніші ключі;
частку запитів, яку створює кожен ключ;
розподіл запитів між шардами;
різницю між найнавантаженішим і найменш навантаженим шардом.
Можна позначати ключ гарячим, якщо виконується одна з умов:
requests_per_second(key) > абсолютний_порігабо:
requests_per_second(key) >
середнє_значення_для_ключів × коефіцієнтНаприклад:
RPS ключа > 10 000або:
RPS ключа > 20 × медіана RPSАбсолютний поріг зручний для операційного захисту, а відносний — для систем, де нормальне навантаження змінюється.
Нижче наведено самодостатній приклад на JavaScript. Він підраховує запити за ключами та показує ключі, які перевищують поріг або значно перевищують медіанне навантаження.
const requests = [
"article:1",
"article:2",
"article:1",
"article:3",
"article:2",
"article:42",
"article:42",
"article:42",
"article:42",
"article:42",
"article:42",
"article:42",
"article:42",
"article:42",
"article:42",
"article:4",
"article:5",
"article:5",
];
const counts = new Map();
for (const key of requests) {
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const values = [...counts.values()].sort((a, b) => a - b);
function median(numbers) {
const middle = Math.floor(numbers.length / 2);
if (numbers.length % 2 === 0) {
return (numbers[middle - 1] + numbers[middle]) / 2;
}
return numbers[middle];
}
const medianRps = median(values);
const absoluteThreshold = 5;
const relativeThreshold = 4;
const hotKeys = [...counts.entries()]
.filter(([key, count]) => {
const exceedsAbsoluteThreshold = count >= absoluteThreshold;
const exceedsRelativeThreshold = count >= medianRps * relativeThreshold;
return exceedsAbsoluteThreshold || exceedsRelativeThreshold;
})
.sort((a, b) => b[1] - a[1]);
console.log("Кількість запитів за ключами:");
for (const [key, count] of counts) {
console.log(`${key}: ${count}`);
}
console.log(`\nМедіанне навантаження: ${medianRps}`);
console.log("\nГарячі ключі:");
for (const [key, count] of hotKeys) {
console.log(`${key}: ${count}`);
}У реальній системі такі дані зазвичай агрегують потоково або в коротких часових вікнах. Для дуже великої кількості ключів використовують наближені алгоритми пошуку найпопулярніших елементів, щоб не зберігати повну статистику в пам’яті.
Реплікація означає створення кількох копій даних. Запити до гарячого ключа можна розподілити між цими копіями.
primary
├── replica-1
├── replica-2
└── replica-3Якщо ключ використовується переважно для читання, клієнт або сервіс читання може вибирати одну з реплік:
GET popular:key -> replica-1
GET popular:key -> replica-2
GET popular:key -> replica-3Реплікація ефективна, коли:
операцій читання значно більше, ніж операцій запису;
дані можна читати з невеликою затримкою актуальності;
сховище підтримує read replicas;
запити можна розподіляти між репліками;
пропускна здатність мережі та реплік достатня для копіювання даних.
Репліка може відставати від primary. Тому після запису читання з випадкової репліки може повернути старе значення.
Наприклад:
t0: запис x = 10 у primary
t1: читання з replica-2
t1: replica-2 ще має x = 9Якщо для конкретного сценарію потрібна гарантована актуальність, можна:
читати одразу з primary після запису;
використовувати session stickiness;
передавати версію або timestamp даних;
дозволяти eventual consistency, якщо вона прийнятна.
Реплікація не усуває всі види hot key:
для записів усі зміни можуть і далі проходити через один primary;
реплікація збільшує витрати на пам’ять і мережу;
велика кількість реплік ускладнює підтримку;
затримка копіювання може зростати під навантаженням;
рівномірно розподілити запити потрібно на рівні клієнта або проксі.
Реплікація є хорошим вибором для гарячих читань, але не є універсальним рішенням для гарячих записів.
Кеш може повністю прибрати значну частину запитів до основного сховища.
клієнти -> cache -> databaseДля гарячого ключа схема виглядає так:
сервіс перевіряє кеш;
якщо значення є, повертає його без звернення до бази;
якщо значення відсутнє, читає базу;
записує результат у кеш із TTL.
Кеш особливо корисний для:
популярних об’єктів, які рідко змінюються;
конфігурації;
публічних сторінок;
результатів дорогих обчислень.
Однак для hot key може виникнути проблема cache stampede: запис у кеш одночасно протермінувався, і багато запитів пішли до бази. Для захисту застосовують:
блокування або single-flight для одного ключа;
попереднє оновлення значення до завершення TTL;
випадкове розширення TTL;
обмеження кількості одночасних запитів до джерела.
Кеш зменшує навантаження, але не завжди підходить для даних, які часто змінюються або мають суворі вимоги до актуальності.
Якщо один ключ має дуже багато запитів, його можна фізично представити кількома підключами. Цю техніку називають key splitting, key salting або розподілом гарячого ключа.
Замість:
views:article:42використовують:
views:article:42:0
views:article:42:1
views:article:42:2
views:article:42:3Запит або запис вибирає один із сегментів:
bucket = hash(requestId) % 4
key = `views:article:42:${bucket}`Тоді підключі можуть потрапити на різні шарди.
Якщо значення є лічильником, для отримання загального результату потрібно прочитати всі підключі та підсумувати їх:
views:article:42:0 = 120
views:article:42:1 = 135
views:article:42:2 = 118
views:article:42:3 = 127
загальна кількість = 500Приклад функцій для вибору підключа та агрегації лічильника:
function bucketForRequest(requestId, bucketCount) {
let hash = 0;
for (const character of requestId) {
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
}
return hash % bucketCount;
}
function keyForBucket(baseKey, bucket) {
return `${baseKey}:${bucket}`;
}
function totalCounter(values) {
return values.reduce((sum, value) => sum + value, 0);
}
const baseKey = "views:article:42";
const bucketCount = 4;
const requestId = "request-9281";
const bucket = bucketForRequest(requestId, bucketCount);
const physicalKey = keyForBucket(baseKey, bucket);
console.log(`Запит використовує: ${physicalKey}`);
const bucketValues = [120, 135, 118, 127];
console.log(`Загальне значення: ${totalCounter(bucketValues)}`);запити до одного логічного об’єкта потрапляють на кілька фізичних ключів;
можна обійти обмеження пропускної здатності одного шарда;
підхід добре працює для лічильників і агрегованих значень.
читання стає складнішим, бо потрібно об’єднувати підключі;
кількість підключів потрібно підібрати заздалегідь або підтримати їх динамічну зміну;
транзакційні операції над одним логічним ключем стають складнішими;
для операцій, які повинні змінити один об’єкт атомарно, розподіл може бути неприйнятним.
Цей підхід особливо зручний для операцій типу:
лічильник переглядів;
кількість реакцій;
кількість подій;
статистика за часовим інтервалом.
Він складніший для об’єкта, який часто оновлюється як єдине узгоджене ціле.
Шардинг розподіляє дані між кількома вузлами. Але важливо обрати ключ партиціювання, який забезпечує рівномірний розподіл.
Для користувачів часто використовують хеш від userId:
shard = hash(userId) % numberOfShardsЦе допомагає розподілити велику кількість користувачів, але не усуває проблему одного надзвичайно активного користувача.
Дані можуть розподілятися за діапазонами:
shard 1: userId 0–999999
shard 2: userId 1000000–1999999Такий підхід простий для діапазонних запитів, але нові або послідовні ідентифікатори можуть створити гарячий останній діапазон.
Якщо популярність ключів нерівномірна, одного хеша недостатньо. Можна:
винести відомі hot keys на окремі шарди;
використовувати віртуальні партиції;
збільшити кількість партицій для гарячого сегмента;
автоматично переміщувати партиції;
розподіляти навантаження за логічними групами.
Потрібно розділяти два поняття:
розподіл даних — де зберігається ключ;
розподіл запитів — на який вузол надсилається конкретний запит.
Шардинг переважно вирішує першу проблему. Для другого можуть знадобитися реплікація, кеш або поділ hot key на підключі.
Іноді найкраще рішення — не дозволити одному ключу необмежено споживати ресурси системи.
Можна застосувати:
rate limiting для клієнта або ключа;
чергу для низькопріоритетних операцій;
повернення кешованого значення;
тимчасове вимкнення другорядних функцій;
приблизні значення замість точних;
обмеження кількості одночасних запитів.
Наприклад, для лічильника переглядів не завжди потрібно синхронно записувати кожен перегляд. Можна накопичувати події та періодично виконувати агрегацію. Такий підхід зменшує кількість операцій запису, але збільшує затримку оновлення результату.
Підходять:
кеш;
read replicas;
розподіл читань між репліками;
допустима eventual consistency.
Підходять:
розподіл ключа на підключі;
накопичення подій і пакетна агрегація;
приблизні лічильники, якщо точність не є критичною;
асинхронна обробка.
Потрібно обережно оцінити поділ ключа. Якщо об’єкт повинен оновлюватися атомарно, краще розглянути:
кешування читань;
обмеження частоти записів;
чергу оновлень;
зміну моделі даних.
Потрібні:
перевірка функції хешування;
рівномірніший ключ партиціювання;
збільшення кількості партицій;
перерозподіл даних;
віртуальні партиції.
Визначити симптом.
Перевірити, чи зростає навантаження на один ключ, шард або вузол.
Зібрати статистику.
Виміряти RPS, p95, p99, помилки та розподіл запитів за ключами.
Розділити читання і записи.
Для читань часто підходять кеш і реплікація. Для записів вони можуть бути недостатніми.
Оцінити вимоги до узгодженості.
Визначити, чи можна читати трохи застарілі дані та чи потрібна атомарність.
Застосувати найменш складне рішення.
Спочатку варто розглянути кешування або обмеження, а вже потім змінювати схему даних.
Перевірити новий розподіл.
Після змін повторно виміряти навантаження на всі шарди та репліки.
Підготувати динамічну реакцію.
Популярність ключів може змінюватися, тому hot key повинен автоматично виявлятися, а не бути постійним винятком у конфігурації.
Якщо проблема створена одним hot key, він залишиться на одному шардi навіть після збільшення їхньої кількості.
Репліки не зменшать навантаження, якщо всі клієнти й далі читають лише з primary.
Якщо під час запису використовується один підключ, а під час читання випадковий інший, дані можуть бути не знайдені. Правило вибору підключа має бути детермінованим або система повинна знати, де саме зберігається значення.
Для лічильника читання всіх підключів є зрозумілим. Для складного об’єкта потрібно заздалегідь визначити, як об’єднувати частини та забезпечувати узгодженість.
Кеш може зменшити навантаження, але одночасне протермінування гарячого запису здатне створити ще більший сплеск до бази.
Середнє може бути спотворене кількома великими значеннями. Для нерівномірних розподілів корисні медіана, перцентилі та top-N ключів.
Read replicas вирішують проблему гарячих читань, але не обов’язково допомагають, якщо всі зміни проходять через один вузол.
Hot key — ключ із непропорційно великою кількістю запитів.
Звичайний хешинг розподіляє різні ключі, але не розподіляє запити до одного ключа.
Для виявлення hot keys потрібно збирати статистику за часовими вікнами та аналізувати top-N ключів, медіану й перцентилі.
Для гарячих читань застосовують кешування та реплікацію з розподілом читань між репліками.
Для гарячих записів лічильників використовують поділ логічного ключа на фізичні підключі, асинхронну агрегацію або обмеження частоти записів.
Шардинг допомагає розподіляти різні ключі, але сам по собі не вирішує проблему одного hot key.
Перед зміною архітектури потрібно врахувати актуальність даних, атомарність, вартість агрегації та складність експлуатації.