Tarkibga o'tish

Rate limiting (Redis)

Rate limiting — ma’lum vaqt oralig‘ida klient yuborishi mumkin bo‘lgan so‘rovlar sonini cheklash. Masalan, "bitta foydalanuvchi daqiqasiga 60 tadan ko‘p so‘rov yubora olmaydi".

Nima uchun kerak?

  • Hujumlardan himoya. Parolni tanlab topish, ma’lumotlarni ommaviy yig‘ib olish (scraping) va serverni so‘rovlar bilan to‘ldirib tashlash (DoS).
  • Resurslarni adolatli taqsimlash. Bitta klientning xato yozilgan skripti boshqa barcha foydalanuvchilar uchun serverni sekinlashtirmasligi kerak.
  • Xarajatlarni nazorat qilish. Har bir so‘rov tashqi pullik API’ga, masalan SMS yuborish yoki AI modeliga murojaat qilsa, cheklovsiz endpoint katta hisob-kitobga olib kelishi mumkin.
  • Tarif rejalari. Bepul tarifda soatiga 100 so‘rov, pullik tarifda 10 000 so‘rov.

Svetoforni tasavvur qiling. U yo‘ldagi mashinalar sonini cheklamaydi, lekin chorrahaga bir vaqtda qancha mashina kirishini boshqaradi. Natijada tirbandlik va avariyalar kamayadi.

Algoritmlar

Fixed window (qat’iy oyna)

Vaqt teng oraliqlarga bo‘linadi, masalan har bir daqiqa. Har bir oraliqda so‘rovlar sanaladi:

10:00:00–10:00:59  → hisoblagich: 0, 1, 2 ... 60 → 61-so‘rov rad etiladi
10:01:00           → hisoblagich nolga tushadi
  • ✅ juda sodda: bitta hisoblagich va muddat;
  • ❌ oyna chegarasida "portlash" bo‘lishi mumkin: 10:00:59 da 60 ta va 10:01:00 da yana 60 ta so‘rov. Natijada bir soniya ichida 120 ta so‘rov o‘tadi.

Sliding window (siljuvchi oyna)

Har bir so‘rovda oxirgi 60 soniya ichidagi so‘rovlar sanaladi. Chegaradagi portlash muammosi yo‘q, lekin har bir so‘rov vaqtini saqlash kerak bo‘ladi. Amalda ko‘pincha taxminiy variant ishlatiladi: joriy va oldingi oyna hisoblagichlari vaznli o‘rtacha sifatida qo‘shiladi.

Token bucket (tokenlar chelagi)

Chelakda ko‘pi bilan N ta token bo‘ladi. Har bir so‘rov bitta token oladi. Tokenlar doimiy tezlikda to‘ldirilib turadi, masalan soniyasiga bitta. Chelak bo‘sh bo‘lsa, so‘rov rad etiladi.

Sig‘im: 10, to‘ldirish: 1 token/soniya

Klient 10 ta so‘rovni birdaniga yubora oladi (burst)
keyin soniyasiga 1 tadan
  • ✅ qisqa muddatli portlashlarga ruxsat beradi, lekin o‘rtacha tezlikni cheklaydi. Bu real foydalanuvchilar xatti-harakatiga yaqin: sahifa ochilganda bir nechta so‘rov birdaniga ketadi, keyin tanaffus bo‘ladi;
  • Go’da golang.org/x/time/rate paketi aynan shu algoritmni amalga oshiradi.

Kalitni tanlash

Rate limiter nimani sanashini kalit belgilaydi:

Kalit Qachon
Foydalanuvchi ID Tizimga kirgan foydalanuvchilar uchun. Eng aniq variant
API kaliti Serverlararo integratsiyalar va tariflar uchun
IP manzil Tizimga kirmagan so‘rovlar uchun: login, ro‘yxatdan o‘tish
Endpoint + kalit Qimmat endpointlar uchun alohida chegara: POST /sms:user:42

IP bo‘yicha cheklashda ehtiyot bo‘ling: bitta IP ortida butun ofis, universitet yoki mobil operatorning minglab foydalanuvchisi bo‘lishi mumkin (NAT). Shu sababli IP chegarasi foydalanuvchi chegarasidan kengroq qo‘yiladi.

1. Xotirada: x/time/rate

Server bitta nusxada ishlasa, eng oddiy yechim — xotiradagi token bucket:

go get golang.org/x/time/rate
type memoryLimiter struct {
    mu       sync.Mutex
    limiters map[string]*rate.Limiter
    r        rate.Limit
    burst    int
}

func (m *memoryLimiter) Allow(key string) bool {
    m.mu.Lock()
    lim, ok := m.limiters[key]
    if !ok {
        lim = rate.NewLimiter(m.r, m.burst)
        m.limiters[key] = lim
    }
    m.mu.Unlock()
    return lim.Allow()
}

// Foydalanish: soniyasiga 1 ta, birdaniga 10 tagacha
lim := &memoryLimiter{limiters: map[string]*rate.Limiter{}, r: rate.Every(time.Second), burst: 10}

Bu yechimning ikki kamchiligi bor:

  1. Map cheksiz o‘sadi. Uzoq vaqt ishlatilmagan kalitlarni vaqti-vaqti bilan o‘chirib turish kerak. Brute-force himoya darsidagi cleanup funksiyasi kabi.
  2. Bir nechta server nusxasi. Load balancer ortida 4 ta nusxa bo‘lsa, har birida alohida chegara bo‘ladi. Natijada klient haqiqiy chegaradan 4 barobar ko‘p so‘rov yubora oladi.

Ikkinchi muammo hisoblagichlarni umumiy omborda saqlash orqali hal qilinadi. Bunday ombor sifatida ko‘pincha Redis ishlatiladi.

2. Redis bilan taqsimlangan rate limiter

Redis — ma’lumotlarni xotirada saqlaydigan juda tez kalit-qiymat bazasi. U hisoblagichlar uchun ideal: INCR buyrug‘i qiymatni atomar oshiradi, EXPIRE esa kalitga yashash muddatini beradi.

Redis’ni Docker’da ishga tushiramiz:

docker run -d --name redis -p 6379:6379 redis:7

Go uchun rasmiy klient:

go get github.com/redis/go-redis/v9

Fixed window: Lua skript bilan

Oddiy yondashuv ikkita buyruqdan iborat:

INCR  ratelimit:user:42:1759226400
EXPIRE ratelimit:user:42:1759226400 60

Lekin ular orasida dastur o‘chib qolsa, kalit muddatsiz qoladi va foydalanuvchi abadiy bloklanadi. Ikki buyruqni atomar bajarish uchun Redis’da Lua skript ishlatiladi. Redis skriptni boshqa buyruqlar bilan aralashtirmasdan, bir butun holda bajaradi:

local current = redis.call("INCR", KEYS[1])
if current == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return {current, redis.call("TTL", KEYS[1])}

Skript hisoblagichni oshiradi va birinchi so‘rovda kalitga muddat beradi. U joriy qiymatni va oyna tugashigacha qolgan soniyalarni qaytaradi.

Go kodi

var fixedWindow = redis.NewScript(`
local current = redis.call("INCR", KEYS[1])
if current == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return {current, redis.call("TTL", KEYS[1])}
`)

type RedisLimiter struct {
    rdb    *redis.Client
    limit  int
    window time.Duration
}

type Result struct {
    Allowed   bool
    Limit     int
    Remaining int
    Reset     time.Duration // oyna tugashigacha
}

func (l *RedisLimiter) Allow(ctx context.Context, key string) (Result, error) {
    windowStart := time.Now().Unix() / int64(l.window.Seconds())
    redisKey := fmt.Sprintf("ratelimit:%s:%d", key, windowStart)

    vals, err := fixedWindow.Run(ctx, l.rdb, []string{redisKey}, int(l.window.Seconds())).Int64Slice()
    if err != nil {
        return Result{}, err
    }

    count, ttl := int(vals[0]), time.Duration(vals[1])*time.Second
    return Result{
        Allowed:   count <= l.limit,
        Limit:     l.limit,
        Remaining: max(l.limit-count, 0),
        Reset:     ttl,
    }, nil
}
  • redis.NewScript skriptni bir marta yuklaydi. Keyingi chaqiruvlarda faqat uning SHA xeshi yuboriladi (EVALSHA), bu esa tarmoq trafigini kamaytiradi.
  • Kalitga oyna raqami qo‘shilgan. Har bir yangi oyna o‘z hisoblagichiga ega bo‘ladi, eskilari esa EXPIRE tufayli o‘zi o‘chadi.

Middleware

func RateLimit(l *RedisLimiter, keyFunc func(*http.Request) string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            res, err := l.Allow(r.Context(), keyFunc(r))
            if err != nil {
                // Redis ishlamayapti: so‘rovni o‘tkazib yuboramiz (fail open)
                slog.Error("rate limiter", "err", err)
                next.ServeHTTP(w, r)
                return
            }

            w.Header().Set("RateLimit-Limit", strconv.Itoa(res.Limit))
            w.Header().Set("RateLimit-Remaining", strconv.Itoa(res.Remaining))
            w.Header().Set("RateLimit-Reset", strconv.Itoa(int(res.Reset.Seconds())))

            if !res.Allowed {
                w.Header().Set("Retry-After", strconv.Itoa(int(res.Reset.Seconds())))
                http.Error(w, `{"error":{"code":"rate_limited","message":"so‘rovlar juda ko‘p"}}`,
                    http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Javob sarlavhalari klientga o‘z holatini bilish imkonini beradi:

Sarlavha Ma’nosi
RateLimit-Limit Oynadagi umumiy chegara
RateLimit-Remaining Qolgan so‘rovlar soni
RateLimit-Reset Oyna tugashigacha qolgan soniyalar
Retry-After 429 javobida: qancha kutish kerak

RateLimit-* sarlavhalari IETF’da standartlashtirilmoqda. Ko‘p API’lar esa ularning X-RateLimit-* ko‘rinishidagi eski variantidan foydalanadi. Qaysi birini tanlasangiz ham, API hujjatida yozib qo‘ying.

Redis ishlamay qolsa nima bo‘ladi?

Bu muhim arxitektura qarori:

  • Fail open — Redis ishlamasa, so‘rovlar cheklovsiz o‘tkaziladi. Servis ishlashda davom etadi, lekin vaqtincha himoyasiz qoladi.
  • Fail closed — Redis ishlamasa, barcha so‘rovlar rad etiladi. Himoya saqlanadi, lekin servis to‘xtab qoladi.

Umumiy API chegaralari uchun odatda fail open tanlanadi: rate limiter ishlamay qolgani uchun butun servis to‘xtamasligi kerak. Login yoki SMS yuborish kabi nozik endpointlar uchun esa fail closed yoki xotiradagi zaxira limiterga o‘tish ma’qulroq.

To‘liq misol

main.go
package main

import (
    "context"
    "fmt"
    "log"
    "log/slog"
    "net"
    "net/http"
    "os"
    "strconv"
    "time"

    "github.com/redis/go-redis/v9"
)

var fixedWindow = redis.NewScript(`
local current = redis.call("INCR", KEYS[1])
if current == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return {current, redis.call("TTL", KEYS[1])}
`)

type RedisLimiter struct {
    rdb    *redis.Client
    name   string
    limit  int
    window time.Duration
}

type Result struct {
    Allowed   bool
    Limit     int
    Remaining int
    Reset     time.Duration
}

func (l *RedisLimiter) Allow(ctx context.Context, key string) (Result, error) {
    windowStart := time.Now().Unix() / int64(l.window.Seconds())
    redisKey := fmt.Sprintf("ratelimit:%s:%s:%d", l.name, key, windowStart)

    vals, err := fixedWindow.Run(ctx, l.rdb, []string{redisKey}, int(l.window.Seconds())).Int64Slice()
    if err != nil {
        return Result{}, err
    }
    count, ttl := int(vals[0]), time.Duration(vals[1])*time.Second
    return Result{
        Allowed:   count <= l.limit,
        Limit:     l.limit,
        Remaining: max(l.limit-count, 0),
        Reset:     ttl,
    }, nil
}

func RateLimit(l *RedisLimiter, keyFunc func(*http.Request) string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
            defer cancel()

            res, err := l.Allow(ctx, keyFunc(r))
            if err != nil {
                slog.Error("rate limiter ishlamadi, so‘rov o‘tkazildi", "err", err)
                next.ServeHTTP(w, r)
                return
            }

            w.Header().Set("RateLimit-Limit", strconv.Itoa(res.Limit))
            w.Header().Set("RateLimit-Remaining", strconv.Itoa(res.Remaining))
            w.Header().Set("RateLimit-Reset", strconv.Itoa(int(res.Reset.Seconds())))

            if !res.Allowed {
                w.Header().Set("Retry-After", strconv.Itoa(int(res.Reset.Seconds())))
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusTooManyRequests)
                fmt.Fprintln(w, `{"error":{"code":"rate_limited","message":"so‘rovlar juda ko‘p"}}`)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

// byIP — namuna uchun RemoteAddr'dan IP oladi.
// Proxy ortida ishonchli proxy'dan kelgan X-Forwarded-For ishlatiladi.
func byIP(r *http.Request) string {
    host, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        return r.RemoteAddr
    }
    return "ip:" + host
}

func main() {
    rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
    if err := rdb.Ping(context.Background()).Err(); err != nil {
        log.Fatalf("Redis'ga ulanib bo‘lmadi: %v", err)
    }

    // Umumiy API: daqiqasiga 60 so‘rov
    apiLimiter := &RedisLimiter{rdb: rdb, name: "api", limit: 60, window: time.Minute}
    // Qimmat endpoint: daqiqasiga 3 so‘rov
    smsLimiter := &RedisLimiter{rdb: rdb, name: "sms", limit: 3, window: time.Minute}

    mux := http.NewServeMux()
    mux.HandleFunc("GET /books", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, `{"data":["O‘tkan kunlar"]}`)
    })
    mux.Handle("POST /sms", RateLimit(smsLimiter, byIP)(
        http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            fmt.Fprintln(w, `{"message":"SMS yuborildi"}`)
        })))

    handler := RateLimit(apiLimiter, byIP)(mux)

    slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
    log.Println("Server: http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", handler))
}

Sinab ko‘ramiz. POST /smsga ketma-ket 5 ta so‘rov yuboramiz:

for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:8080/sms
done; echo
200 200 200 429 429

Oxirgi javobning sarlavhalarini ko‘ramiz:

curl -si -X POST http://localhost:8080/sms | grep -iE "^HTTP|ratelimit|retry"
HTTP/1.1 429 Too Many Requests
Ratelimit-Limit: 3
Ratelimit-Remaining: 0
Ratelimit-Reset: 41
Retry-After: 41

Redis ichida nima borligini ham ko‘rish mumkin:

docker exec redis redis-cli --scan --pattern 'ratelimit:*'
ratelimit:sms:ip:::1:29846577
ratelimit:api:ip:::1:29846577

Har bir limiter va har bir oyna uchun alohida kalit bor. Ular oyna tugagach o‘zi o‘chadi.

Server bir nechta nusxada ishga tushirilsa ham, barcha nusxalar bitta Redis’dan foydalanadi. Chegara esa umumiy bo‘lib qoladi.

Endi Redis’ni to‘xtatib ko‘ramiz:

docker stop redis
curl -s http://localhost:8080/books
{"data":["O‘tkan kunlar"]}

Server logida:

level=ERROR msg="rate limiter ishlamadi, so‘rov o‘tkazildi" err="context deadline exceeded"

Fail open strategiyasi ishladi: Redis ishlamasa ham API javob berishda davom etdi. Redis’ga murojaat uchun 100 ms timeout qo‘yilgan. Klient ulanishga bir necha marta urinib ko‘rdi, lekin 100 ms tugagach, so‘rov kutishni to‘xtatdi. Shu sababli Redis javob bermay qolsa ham API so‘rovlari osilib qolmaydi.

Tayyor kutubxonalar

  • github.com/go-redis/redis_rate — go-redis jamoasining kutubxonasi. U GCRA algoritmini amalga oshiradi, bu esa token bucket’ning taqsimlangan varianti. Chegara chetidagi portlash muammosi yo‘q.
  • golang.org/x/time/rate — xotirada ishlaydigan token bucket.
  • API gateway’lar, masalan nginx (limit_req), Envoy va Kong, so‘rovlar ilovaga yetib kelmasidan oldin ham cheklash imkonini beradi.

Xulosa

  • Rate limiting hujumlardan himoya qiladi, resurslarni adolatli taqsimlaydi va xarajatlarni nazorat qiladi.
  • Asosiy algoritmlar: fixed window (sodda), sliding window (aniqroq) va token bucket (portlashga ruxsat beradi).
  • Kalit sifatida foydalanuvchi ID, API kaliti yoki IP ishlatiladi. Qimmat endpointlar uchun alohida chegara qo‘yiladi.
  • Bir nechta server nusxasi uchun hisoblagichlar Redis’da saqlanadi. Atomarlik Lua skript bilan ta’minlanadi.
  • Klientga 429, Retry-After va RateLimit-* sarlavhalari qaytariladi.
  • Redis ishlamay qolganda qanday yo‘l tutish — fail open yoki fail closed — oldindan hal qilinadi.

Keyingi darsda brauzer xavfsizlik sarlavhalari va boshqa domenlardan kirishni boshqaradigan CORS bilan ishlashni ko‘ramiz.