
Лимиты полезны на трёх уровнях:
Практический выбор:
Грубый, но эффективный щит на периметре. Пример: ограничим по API‑ключу в заголовке X-Api-Key до 50 r/s с бурстом 100.
# В http {} секции
map $http_x_api_key $ratelimit_key {
default "$http_x_api_key";
}
limit_req_zone $ratelimit_key zone=api_per_key:20m rate=50r/s;
server {
listen 80;
server_name api.example.com;
location / {
# Разрешим короткие всплески без задержки до 100 запросов
limit_req zone=api_per_key burst=100 nodelay;
limit_req_status 429;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://backend;
}
}
Плюсы: 0 доп.кода в сервисе, минимальная задержка. Минусы: не знает ваши тарифы и роли; на один ключ из разных дата‑центров лимит может применяться неравномерно.
Храним по ключу состояние ведра: сколько токенов осталось и время последнего пополнения. Всё обновление делаем в Lua атомарно.
-- token_bucket.lua
-- KEYS[1] = ключ бакета, например "rl:{api_key}"
-- ARGV[1] = capacity (число токенов)
-- ARGV[2] = refill_rate_per_sec (сколько токенов в секунду)
-- ARGV[3] = now_ms (текущее время в мс)
-- ARGV[4] = demand (сколько токенов нужно на запрос)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local need = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then tokens = capacity end
if ts == nil then ts = now end
local delta = now - ts
if delta < 0 then delta = 0 end
local new_tokens = tokens + (delta * rate / 1000.0)
if new_tokens > capacity then new_tokens = capacity end
local allowed = 0
local remaining = new_tokens
local wait_ms = 0
if new_tokens >= need then
allowed = 1
remaining = new_tokens - need
else
local deficit = need - new_tokens
if rate > 0 then
wait_ms = math.ceil(deficit * 1000.0 / rate)
else
wait_ms = -1 -- бесконечное ожидание, если rate == 0
end
end
redis.call('HMSET', key, 'tokens', remaining, 'ts', now)
-- TTL: примерно два времени полного восстановления ведра
if rate > 0 then
local ttl = math.ceil((capacity * 2) / rate)
if ttl < 1 then ttl = 1 end
redis.call('EXPIRE', key, ttl)
end
return { allowed, remaining, wait_ms }
Скрипт возвращает кортеж: [allowed(0/1), remaining_tokens, wait_ms]. Если allowed=0, клиенту стоит вернуть 429 и Retry-After.
Пример минимального middleware, вызывающего скрипт в Redis. Лимит: 100 r/s, ведро 200 токенов, 1 токен на запрос, ключ — из X-Api-Key.
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"
redis "github.com/redis/go-redis/v9"
)
var (
capacity = 200.0 // burst
ratePerSec = 100.0 // средняя скорость
demand = 1.0
)
const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local need = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then tokens = capacity end
if ts == nil then ts = now end
local delta = now - ts
if delta < 0 then delta = 0 end
local new_tokens = tokens + (delta * rate / 1000.0)
if new_tokens > capacity then new_tokens = capacity end
local allowed = 0
local remaining = new_tokens
local wait_ms = 0
if new_tokens >= need then
allowed = 1
remaining = new_tokens - need
else
local deficit = need - new_tokens
if rate > 0 then
wait_ms = math.ceil(deficit * 1000.0 / rate)
else
wait_ms = -1
end
end
redis.call('HMSET', key, 'tokens', remaining, 'ts', now)
if rate > 0 then
local ttl = math.ceil((capacity * 2) / rate)
if ttl < 1 then ttl = 1 end
redis.call('EXPIRE', key, ttl)
end
return { allowed, remaining, wait_ms }
`
func main() {
ctx := context.Background()
addr := getenv("REDIS_ADDR", "127.0.0.1:6379")
client := redis.NewClient(&redis.Options{Addr: addr})
defer client.Close()
// Проверим соединение
if err := client.Ping(ctx).Err(); err != nil {
log.Fatalf("redis ping: %v", err)
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
apiKey := r.Header.Get("X-Api-Key")
if apiKey == "" {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte("missing X-Api-Key"))
return
}
key := fmt.Sprintf("rl:{%s}", apiKey) // {} — чтоб хешировалось консистентно в кластере Redis
nowMs := time.Now().UnixMilli()
res, err := client.Eval(ctx, luaScript, []string{key},
fmt.Sprintf("%g", capacity),
fmt.Sprintf("%g", ratePerSec),
strconv.FormatInt(nowMs, 10),
fmt.Sprintf("%g", demand),
).Result()
if err != nil {
log.Printf("redis eval error: %v", err)
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("temporary unavailable"))
return
}
vals := res.([]interface{})
allowed := vals[0].(int64) == 1
remaining := toFloat(vals[1])
waitMs := toInt(vals[2])
// RFC 9239: информируем клиента о лимитах
w.Header().Set("RateLimit-Limit", fmt.Sprintf("%d;w=1", int(ratePerSec)))
w.Header().Set("RateLimit-Remaining", fmt.Sprintf("%d", int(remaining)))
if waitMs > 0 {
w.Header().Set("RateLimit-Reset", fmt.Sprintf("%d", int(waitMs/1000)))
}
if !allowed {
if waitMs > 0 {
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(waitMs/1000)))
}
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte("rate limit exceeded"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func toFloat(v interface{}) float64 {
switch x := v.(type) {
case int64:
return float64(x)
case string:
f, _ := strconv.ParseFloat(x, 64)
return f
default:
return 0
}
}
func toInt(v interface{}) int64 {
switch x := v.(type) {
case int64:
return x
case string:
i, _ := strconv.ParseInt(x, 10, 64)
return i
default:
return 0
}
}
Запускаем: REDIS_ADDR=127.0.0.1:6379 go run . и отправляем запросы с нужным X-Api-Key.
Советы по продакшену:
rl:{proj}:{api_key}) — пригодится при миграциях.100;w=1.Хороший UX: документируйте лимиты, возвращайте понятные сообщения и, по возможности, адекватные «бурсты». Для платежных клиентов — отдельные лимиты и ранний контакт с менеджером, если клиент часто упирается в потолок.
Итог: корректно внедрённые лимиты запросов дают экономию на инфраструктуре, защищают от злоупотреблений и делают поведение системы предсказуемым. При этом клиенты получают прозрачные правила и понятные сигналы, когда они близки к потолку. Это всё — без покупки «лишних серверов» и с быстрой окупаемостью.