IlmHamroh
JavaScript Full-stack/27-qism. Ma'lumotlar bazasi chuqur: Postgres, ORM, Mongo, Redis60/65-dars15 daqiqa
Mundarija (21)

Redis distributed lock va rate limit: SET NX PX, token, sliding window

Qisqacha: Distributed lock (taqsimlangan qulf) — bir nechta serverdan faqat bittasi ishni bajarishi uchun: SET qulf <token> NX PX 30000. Kanonda 10 parallel urinishdan 1 tasi oldi. Tuzoq: ish qulf muddatidan uzoq cho'zilsa, boshqa server qulfni oladi, birinchisi esa oxirida DEL qilib boshqaning qulfini o'chiradi — kanonda uchinchi server ham kirdi. Davosi: tasodifiy token va "faqat o'zimniki bo'lsa o'chir" Lua skripti. Rate limit: fixed window oddiy, lekin oyna chegarasida 2 baravar o'tkazadi (kanon: 1.05 s ichida 10 ta, limit 5/s); sliding window va token bucket bu muammoni yo'qotadi.

Bu darsda

  • SET NX PX bilan qulf olasiz va uni token + Lua bilan xavfsiz ochasiz.
  • Qulf muddati ish davomida tugashi xavfini va fencing token g'oyasini tushuntirasiz.
  • Redlock va unga qilingan tanqidni bilasiz.
  • Fixed window, sliding window va token bucket limitlarini yozasiz va solishtirasiz.

Oldin bilishingiz kerak: Atomiklik: MULTI, Lua va pipelining, Qulflar: row, table va advisory, String, TTL va kalit nomlash.

Oldingi darsda xizmatlar orasida xabar almashdik. Endi ular orasidagi kelishuv: kim ishni bajaradi va mijoz qancha so'rov yuborishi mumkin. Hamma chiqishlar — kanon tajribasi (Redis 8.10.2, node-redis 6.3.0).

1. Nega bu kerak?

vazifalar-api uchta serverda ishlayapti. Har kecha soat 02:00 da "kunlik hisobot" yuboriladi. Uchala server ham cron'ni ishga tushiradi — foydalanuvchi bitta hisobot o'rniga uchta email oladi. Ishni bitta server qilishi kerak. Bu — taqsimlangan qulf (distributed lock).

Ikkinchi muammo: kimdir login formasiga sekundiga 1 000 ta parol yuboryapti. Bitta IP'dan daqiqasiga 5 tadan ko'p urinishga ruxsat bermaslik kerak — va hisob uchala serverda umumiy bo'lishi kerak. Bu — rate limit (so'rovlarni cheklash).

Ikkalasi uchun ham umumiy, tez, atomik joy kerak. Postgres'da advisory lock bor, lekin u ulanishga bog'langan va har urinish bazaga yuk. Redis — tabiiy tanlov.

2. Distributed lock

2.1 SET NX PX

js
const natija = await Promise.all(Array.from({ length: 10 }, (_, i) =>
  r.set("t60:qulf:hisobot", `ishchi-${i}`, { NX: true, PX: 2000 })));
text
olganlar: 1 | egasi: ishchi-0 | PTTL: true

10 ta "server" bir vaqtda qulfni olmoqchi bo'ldi — bittasi oldi (OK), qolganlari null. NX — faqat kalit yo'q bo'lsa; PX 2000 — 2 soniyada o'zi ochiladi. Muddat majburiy: qulf egasi yiqilsa (server o'chdi), qulf abadiy qolib, hech kim ishni bajarolmay qolardi.

2.2 Tuzoq: muddat ish davomida tugadi

Kanon ataylab yomon ssenariyni sinadi: A 200 ms'lik qulf oldi, lekin ishi 400 ms davom etdi.

text
B oldi: OK
A ning DEL'idan keyin qulf: null — B himoyasiz qoldi, C ham kira oladi: OK
sequenceDiagram
  participant A
  participant R as Redis
  participant B
  participant C
  A->>R: SET qulf A NX PX 200
  Note over A: ish 400 ms davom etadi
  Note over R: 200 ms, qulf o'zi o'chdi
  B->>R: SET qulf B NX PX 2000
  R-->>B: OK
  A->>R: DEL qulf
  Note over R: B ning qulfi o'chdi!
  C->>R: SET qulf C NX PX 2000
  R-->>C: OK

Diagrammaga qarang. Qulf muddati tugadi — B uni to'g'ri oldi. Lekin A ishini tugatib, "o'z" qulfini DEL qildi — aslida B ning qulfini. Endi C ham kirdi: B va C bir vaqtda ishlayapti. Qulf himoya qilishi kerak bo'lgan narsa buzildi.

2.3 Token bilan xavfsiz ochish

Har qulf olishda tasodifiy token (UUID) yoziladi. Ochishda: "qulfdagi qiymat mening tokenim bo'lsagina o'chir". Bu ikki amal (tekshirish va o'chirish) atomik bo'lishi kerak — Lua skript (Atomiklik):

js
import { randomUUID } from "node:crypto";

const OCH = `if redis.call('GET', KEYS[1]) == ARGV[1]
  then return redis.call('DEL', KEYS[1])
  else return 0 end`;

const tokenA = randomUUID();
await r.set("t60:qulf", tokenA, { NX: true, PX: 200 });
// … A ning ishi cho'zildi, B qulfni oldi (tokenB bilan) …
await r.eval(OCH, { keys: ["t60:qulf"], arguments: [tokenA] });
text
A token bilan ochmoqchi: 0 | qulf hali B'niki: true
B ochdi: 1

Nima bo'ldi? Vaqt bo'yicha:

Qadam A B Qulfda
1 SET … tokenA NX PX 200 → OK — tokenA
2 ish cho'zildi — 200 ms o'tdi, bo'sh
3 — SET … tokenB NX → OK tokenB
4 Lua: "tokenA bo'lsa o'chir" → 0 — tokenB (tegilmadi)
5 — Lua: "tokenB bo'lsa o'chir" → 1 bo'sh

A ning urinishi — 0 (token mos emas), qulf B da qoldi. B o'z tokeni bilan ochdi — 1. Kanon skriptni bitta qatorda yozgan; bu yerda o'qish uchun bo'lindi.

2.4 Bu ham yetarli emasmi?

Token "boshqaning qulfini o'chirish"ni yo'qotdi. Lekin asosiy muammo qoldi: A ning muddati tugagan — A buni bilmaydi va ishni davom ettiryapti. B ham ishlayapti. Kanon xulosasi:

text
Eslatma: muddat tugashining o'zi (GC pauza, sekin ish) — fencing token kerak (Kleppmann tanqidi); Redlock — bir nechta mustaqil Redis, bu yerda sinalmagan.
  • GC pauza — Node'ning axlat yig'uvchisi jarayonni bir necha soniya to'xtatishi mumkin (V8 xotira modeli va garbage collection); tarmoq ham kechikadi. A uyg'onganda qulf allaqachon boshqada.
  • Fencing token (to'siq raqami) — har qulf olishda o'sib boradigan raqam (INCR). Ish natijasini yozadigan joy (baza) eski raqamli yozuvni rad etadi: "sizning raqamingiz 33, men 34 ni ko'rganman". Bu Martin Kleppmann'ning 2016-yilgi tanqidi markazidagi g'oya.
  • Redlock — Redis muallifi taklif qilgan algoritm: 5 ta mustaqil Redis'dan ko'pchiligida qulf olish. Bitta Redis o'chganda ham ishlaydi. Kleppmann ko'rsatdi: soat va pauza muammolari tufayli Redlock ham to'g'rilikni kafolatlamaydi. Kanonda sinalmagan.

Amaliy xulosa (hujjat va tanqidga ko'ra):

Maqsad Yechim
Samaradorlik: ish ikki marta bo'lsa — faqat ortiqcha xarajat (ikki email) bitta Redis, SET NX PX + token
To'g'rilik: ikki marta — zarar (ikki marta pul yechish) baza tranzaksiyasi, noyob cheklov, fencing token

Pul va qoldiq kabi narsalarni Redis qulfiga ishonib qo'ymang — ular Postgres'da qatorni qulflash yoki UNIQUE cheklov bilan himoyalanadi (Amaliyot: race condition'larni hal qilish).

Tekshirib ko'ring: Qulf muddatini 2 s qo'ydingiz, ish odatda 0.5 s. Nega baribir 30 s qo'yish va ish davomida muddatni uzaytirish tavsiya qilinadi?

Javob

"Odatda 0.5 s" — kafolat emas: baza sekinlashishi, GC pauza yoki tarmoq kechikishi ishni 2 s dan oshirishi mumkin, va qulf ish o'rtasida ochiladi. Uzunroq muddat bu xavfni kamaytiradi; yiqilgan server uchun esa muddat baribir ishlaydi (30 s keyin boshqasi oladi). Uzoq ishda qulf egasi vaqti-vaqti bilan muddatni uzaytiradi (token tekshiradigan Lua bilan PEXPIRE) — "watchdog" naqshi.

3. Rate limit

3.1 Fixed window

Eng oddiy usul: vaqt bo'laklarga (oynaga) bo'linadi, har oynada hisoblagich. Kalitga oyna qo'shilmaydi — kalitning o'zi oyna boshida yaratilib, TTL bilan o'ladi:

js
async function fixed(kalit, limit, oynaS) {
  const n = await r.multi()
    .incr(kalit)
    .expire(kalit, oynaS, "NX")
    .exec();
  return n[0] <= limit;
}

INCR + EXPIRE … NX bitta MULTI da: birinchi so'rov kalitni yaratadi va muddat qo'yadi, keyingilari faqat oshiradi (NX — muddat yo'q bo'lsagina qo'yadi; String, TTL va kalit nomlash).

text
8 so'rov: ok ok ok ok ok 429 429 429

Limit daqiqasiga 5 — oltinchidan boshlab 429 Too Many Requests (Status kodlarni to'g'ri tanlash).

Atomiklik tekshiruvi — 200 parallel so'rov, limit 50:

text
o'tdi: 50 | hisoblagich: 200

Aniq 50 — INCR atomik, ortiqcha o'tgan yo'q. Hisoblagich 200 — rad etilganlar ham sanaldi.

3.2 Chegara muammosi

text
1 s oyna, chegaradan oldin 5 + keyin 5: 10 tasi o'tdi ~1.05 s ichida (limit 5/s)

Limit sekundiga 5. Mijoz oyna oxirida 5 ta, yangi oyna boshida yana 5 ta yubordi — 1.05 soniya ichida 10 ta o'tdi. Fixed window ikki oyna chegarasida limitning ikki baravarini o'tkazib yuboradi. Login urinishlarida bu hujumchiga ikki baravar ko'p parol sinash imkonini beradi.

3.3 Sliding window (Sorted Set)

Har so'rov vaqti Sorted Set'ga ball sifatida yoziladi (Hash, List, Set va Sorted Set). Har so'rovda: oxirgi oynadan eskilarini o'chirish, yangisini qo'shish, sanash:

js
async function sliding(kalit, limit, oynaMs) {
  const hozir = Date.now();
  const [, , n] = await r.multi()
    .zRemRangeByScore(kalit, 0, hozir - oynaMs)
    .zAdd(kalit, {
      score: hozir,
      value: `${hozir}-${Math.random()}`,
    })
    .zCard(kalit)
    .pExpire(kalit, oynaMs)
    .exec();
  return n <= limit;
}
text
chegaradan 0.55 s keyin yana 5: ok ok ok ok ok 429 429 429 429 429 (rad etilganlar ham sanaladi — oddiy variant)

Birinchi 5 ta o'tdi; 0.55 soniyadan keyin yana 5 ta — hammasi rad etildi: oxirgi 1 000 ms ichida allaqachon 5 ta bor. Chegara portlashi yo'q.

Uch detal:

  • value noyob bo'lishi kerak — bir millisekundda ikki so'rov kelsa, bir xil element bitta deb sanalardi. Shuning uchun vaqt-tasodif.
  • Bu oddiy variantda rad etilgan so'rov ham Set'ga yoziladi va keyingi oynani band qiladi — doim urinayotgan mijoz hech qachon o'tolmaydi. Bu ba'zan maqsadga mos (hujumchi uchun), ba'zan yo'q. Ehtiyotkor variant: avval sanash, limit ichida bo'lsagina qo'shish (Lua bilan, atomik).
  • Narxi — xotira: har so'rov alohida element. Daqiqasiga 10 000 so'rovli limitda 10 000 element.

3.4 Token bucket (Lua)

Token bucket (tangalar chelagi): chelakda eng ko'pi bilan N ta tanga. Har so'rov bitta tanga oladi. Tanga bo'lmasa — 429. Chelak doimiy tezlik bilan to'lib turadi. Natija: qisqa "portlash"ga ruxsat (sig'imgacha), uzoq muddatda o'rtacha tezlik cheklangan.

Hisob Lua skriptda — o'qish, hisoblash va yozish atomik:

lua
local kalit, sigim, tezlik, hozir =
  KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local h = redis.call('HMGET', kalit, 'token', 'vaqt')
local token = tonumber(h[1]) or sigim
local vaqt = tonumber(h[2]) or hozir
token = math.min(sigim, token + (hozir - vaqt) / 1000 * tezlik)
local ruxsat = 0
if token >= 1 then token = token - 1; ruxsat = 1 end
redis.call('HSET', kalit, 'token', token, 'vaqt', hozir)
redis.call('PEXPIRE', kalit, math.ceil(sigim / tezlik * 1000))
return ruxsat

Holat hash'da: token (qolgan tangalar) va vaqt (oxirgi hisob). O'tgan vaqtga qarab tangalar qo'shiladi, sig'imdan oshmaydi. Kanonning birinchi qatorini telefon uchun ikkiga bo'ldik.

text
7 darhol + 1 s dan keyin 3: ok ok ok ok ok 429 429 ok ok 429

Sig'im 5, tezlik sekundiga 2. Darhol 7 so'rov: 5 tasi o'tdi (chelak to'la edi), 2 tasi — 429. 1 soniyadan keyin chelakka 2 tanga tushdi — keyingi 3 tadan 2 tasi o'tdi.

Vaqtni (hozir) skript Node'dan oldi (Date.now()). Bir nechta serverning soatlari farq qilsa, hisob buziladi. Muqobil — skript ichida Redis'ning TIME buyrug'i.

Usul Xotira Chegara portlashi Murakkablik
Fixed window 1 kalit bor (2×) juda oddiy
Sliding window (ZSET) har so'rov yo'q o'rta
Token bucket 1 hash boshqariladigan portlash Lua

Ko'p loyihalarda tayyor kutubxona ishlatiladi: rate-limiter-flexible (Redis, Postgres, xotira), express-rate-limit + Redis store. Ular ichida shu algoritmlar va Lua skriptlar. Rate limit'ni HTTP darajasida (sarlavhalar, per-IP va per-user) 28-qismda to'liq quramiz.

4. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
Ish ikki marta bajarildi qulf muddati ish davomida tugadi uzunroq muddat, watchdog, idempotent ish
Boshqa serverning qulfi o'chib ketdi ochishda oddiy DEL token + Lua
Qulf abadiy qoldi PX/EX siz SET NX muddat majburiy
Pul ikki marta yechildi to'g'rilik Redis qulfiga ishonilgan baza tranzaksiyasi, UNIQUE, fencing
Limit chegarada 2× o'tkazadi fixed window sliding window yoki token bucket
Limit hech qachon tiklanmaydi INCR bor, EXPIRE yo'q EXPIRE … NX bir MULTI da
Limit serverlar orasida farq qiladi hisob har server xotirasida Redis'da umumiy hisob

5. Mashqlar

Mashqlar kurs/mashqlar/27/60-qulf-limit/ da.

1-mashq (oson): to'ldiring

  • Qulf olish buyrug'i: SET qulf <token> PX 30000
  • Qulfni xavfsiz ochish uchun qiymatda saqlanadigan narsa —
  • Oyna chegarasida limitning ikki baravarini o'tkazadigan usul —
  • Limitdan oshganda HTTP status kodi —
  • Har qulfda o'sadigan, eski egani rad etishga yordam beradigan raqam —
Yechim
  • NX — faqat kalit yo'q bo'lsa.
  • token — tasodifiy UUID; ochishda Lua bilan solishtiriladi.
  • fixed window — kanonda 1.05 s ichida 10 ta (limit 5/s).
  • 429 — Too Many Requests.
  • fencing token — Kleppmann tanqidi.

2-mashq (o'rta): fixed va sliding

Soxta soat bilan ikkala algoritmni yozing: fixedWindow(limit, oynaMs) va slidingWindow(limit, oynaMs) — har biri (hozir) => boolean funksiya qaytarsin. Fixed: oyna raqami Math.floor(hozir / oynaMs). Sliding: so'rov vaqtlari massivi, eskilari o'chiriladi, faqat ruxsat berilganlar yoziladi. Kanon chegara ssenariysi: limit 5/1000 ms, vaqt 900–940 ms da 5 so'rov, 1000–1040 ms da yana 5 so'rov. Har biri nechta o'tkazadi?

Yechim
js
function fixedWindow(limit, oynaMs) {
  let oyna = -1;
  let soni = 0;
  return (hozir) => {
    const joriy = Math.floor(hozir / oynaMs);
    if (joriy !== oyna) {
      oyna = joriy;
      soni = 0;
    }
    soni++;
    return soni <= limit;
  };
}

function slidingWindow(limit, oynaMs) {
  let vaqtlar = [];
  return (hozir) => {
    vaqtlar = vaqtlar.filter((t) => t > hozir - oynaMs);
    if (vaqtlar.length >= limit) return false;
    vaqtlar.push(hozir);
    return true;
  };
}

const sorovlar = [
  900, 910, 920, 930, 940, 1000, 1010, 1020, 1030, 1040,
];
for (const [nom, f] of [
  ["fixed", fixedWindow(5, 1000)],
  ["sliding", slidingWindow(5, 1000)],
]) {
  const javob = sorovlar.map((t) => (f(t) ? "ok" : "429"));
  const otdi = javob.filter((x) => x === "ok").length;
  console.log(`${nom}: ${otdi} o'tdi — ${javob.join(" ")}`);
}

Konsolda:

text
fixed: 10 o'tdi — ok ok ok ok ok ok ok ok ok ok
sliding: 5 o'tdi — ok ok ok ok ok 429 429 429 429 429

Bizning ssenariyda fixed window 140 ms ichida 10 tasini o'tkazdi (kanonda ~1.05 s ichida 10 ta): 940 ms va 1000 ms turli oynalarda. Sliding window oxirgi 1 000 ms ni sanaydi — chegara yo'q. Bizning sliding'da rad etilgan so'rov yozilmaydi (ehtiyotkor variant), kanon esa hammasini yozgan; bu ssenariyda natija bir xil.

3-mashq (qiyin): token bucket

Kanondagi Lua mantiqini JavaScript'da yozing: tokenBucket(sigim, tezlik) → (hozir) => boolean. Holat: token (boshida sigim) va vaqt. Har chaqiruvda: token = min(sigim, token + (hozir - vaqt) / 1000 * tezlik), keyin 1 dan katta yoki teng bo'lsa — bittasini olib true. Kanon ssenariysini takrorlang: sig'im 5, tezlik 2/s; 0 ms da 7 so'rov, 1000 ms da 3 so'rov.

Yechim
js
function tokenBucket(sigim, tezlik) {
  let token = sigim;
  let vaqt = null;
  return (hozir) => {
    if (vaqt !== null) {
      const qoshildi = ((hozir - vaqt) / 1000) * tezlik;
      token = Math.min(sigim, token + qoshildi);
    }
    vaqt = hozir;
    if (token >= 1) {
      token -= 1;
      return true;
    }
    return false;
  };
}

const olish = tokenBucket(5, 2);
const javob = [];
for (let i = 0; i < 7; i++) javob.push(olish(0));
for (let i = 0; i < 3; i++) javob.push(olish(1000));
console.log(javob.map((x) => (x ? "ok" : "429")).join(" "));

Konsolda:

text
ok ok ok ok ok 429 429 ok ok 429

Kanon chiqishi bilan aynan bir xil. 7 ta darhol — chelakda 5 ta bor edi. 1 soniyada 2 × 1 = 2 tanga qo'shildi — 3 tadan 2 tasi o'tdi. Bitta Node jarayonida bu yopilish (closure) yetarli (Closure). Bir nechta serverda esa holat Redis hash'ida va hisob Lua'da bo'lishi kerak — aks holda har server o'z chelagini sanaydi va umumiy limit N baravar oshadi.

6. Loyiha qadami: kanon tajribasi, loyihaga hali kirmaydi

vazifalar-api ga bu darsda kod qo'shilmaydi. Rate limit ma'nosi foydalanuvchini tanishda — "kim so'rov yubordi" (IP yoki hisob) — bu 28-qism (auth va backend xavfsizligi), u yerda login urinishlari uchun Redis asosidagi limiter quramiz. Taqsimlangan qulf kerak bo'ladigan cron va fon ishlari — 29-qismda. Kesh stampede himoyasidagi SET NX PX ni Caching naqshlari darsida ko'rdik. O'zingiz sinab ko'ring: ikki terminalda bir vaqtda SET vz:qulf:sinov 1 NX PX 10000 bering — qaysi biri OK oldi?

7. Real ishda

  • Cron bir nusxada — bir nechta server/pod'da kunlik vazifani bitta bajaradi (BullMQ'ning "repeatable job" lari buni o'zi hal qiladi).
  • Login va OTP himoyasi — IP va telefon raqami bo'yicha limit (SMS pumping firibgarligiga qarshi).
  • Tashqi API kvotasi — Telegram, to'lov tizimi API'lariga sekundiga N so'rovdan oshmaslik (token bucket).
  • Intervyuda: "Redis'da distributed lock qanday quriladi?", "Nega qulfni token bilan ochish kerak?", "Redlock haqida nima bilasiz?", "Fixed va sliding window farqi?"

Xulosa

  • Qulf: SET qulf <token> NX PX <ms>; muddat majburiy; ochish — token tekshiradigan Lua.
  • Oddiy DEL boshqaning qulfini o'chiradi (kanonda C ham kirdi).
  • Muddat tugashi (GC, kechikish) qoladi — to'g'rilik kerak bo'lsa fencing token va baza; Redlock ham kafolat emas.
  • Fixed window: INCR + EXPIRE NX, atomik (200 dan aniq 50), lekin chegarada 2×.
  • Sliding window (ZSET) va token bucket (Lua) chegara portlashini yo'qotadi.

Keyingi dars: Redis persistence va operatsiya — Redis qayta ishga tushganda nima qoladi (RDB va AOF o'lchovi), xotira to'lganda nima bo'ladi va Valkey bilan farq.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Redis distributed lock va rate limit: SET NX PX, token, sliding window — IlmHamroh