
Лимиты запросов и обратное давление — это «предохранители» для вашего продукта. Они помогают:
Хорошо спроектированные лимиты не скрывают проблемы производительности, а дают системе шанс пережить всплеск и корректно деградировать.
Лучший результат — комбинация уровней: внешние грубые лимиты + точные лимиты в приложении + физические ограничения ресурса.
Для большинства бизнес‑API хорош старт — ведро с токенами: предсказуемо, просто и даёт гибкость.
Нужно хранить состояние (токены, метка времени) и обновлять его атомарно. В Redis это удобно делать через Lua‑скрипт.
-- filename: token_bucket.lua
-- KEYS[1] = ключ ведра, например "rl:{tenant}:{route}"
-- ARGV[1] = capacity (макс. токенов)
-- ARGV[2] = refill_rate (токенов в секунду)
-- ARGV[3] = now_ms (текущее время, мс)
-- ARGV[4] = cost (стоимость запроса в токенах, обычно 1)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local cost = 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
ts = now_ms
end
-- Пополняем токены
local delta = math.max(0, now_ms - ts) / 1000.0
local new_tokens = math.min(capacity, tokens + delta * rate)
local allowed = 0
local remaining = new_tokens
if new_tokens >= cost then
allowed = 1
remaining = new_tokens - cost
ts = now_ms
end
redis.call('HMSET', key, 'tokens', remaining, 'ts', ts)
-- TTL чуть больше времени полного восстановления ведра
local ttl = math.ceil(capacity / rate) + 5
redis.call('EXPIRE', key, ttl)
return { allowed, tostring(remaining) }
package main
import (
"context"
"fmt"
"time"
redis "github.com/redis/go-redis/v9"
)
func main() {
ctx := context.Background()
rdb := redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
script := redis.NewScript(`
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now_ms = tonumber(ARGV[3])
local cost = 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; ts = now_ms end
local delta = math.max(0, now_ms - ts) / 1000.0
local new_tokens = math.min(capacity, tokens + delta * rate)
local allowed = 0
local remaining = new_tokens
if new_tokens >= cost then
allowed = 1
remaining = new_tokens - cost
ts = now_ms
end
redis.call('HMSET', key, 'tokens', remaining, 'ts', ts)
local ttl = math.ceil(capacity / rate) + 5
redis.call('EXPIRE', key, ttl)
return { allowed, tostring(remaining) }
`)
key := "rl:tenant123:/orders:create"
capacity := 50 // максимум токенов
rate := 10 // 10 токенов/секунда
cost := 1 // запрос стоит 1 токен
now := time.Now().UnixMilli()
res, err := script.Run(ctx, rdb, []string{key}, capacity, rate, now, cost).Result()
if err != nil {
panic(err)
}
vals := res.([]interface{})
allowed := vals[0].(int64) == 1
remaining := vals[1].(string)
fmt.Printf("allowed=%v remaining=%s\n", allowed, remaining)
}
Рекомендации:
Для базовой защиты на периметре достаточно пары директив.
# Если есть идентификатор клиента в заголовке — используем его, иначе IP
map $http_x_customer_id $rl_key {
default $binary_remote_addr;
~.+ $http_x_customer_id;
}
# Хранение состояния для лимитов (10 Мб под счётчики)
limit_req_zone $rl_key zone=per_client:10m rate=10r/s;
server {
listen 443 ssl;
server_name api.example.com;
location / {
limit_req zone=per_client burst=30 nodelay;
add_header X-RateLimit-Policy "10r/s; burst=30" always;
try_files $uri @app;
}
location @app {
proxy_pass http://upstream_app;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
error_page 429 = @rate_limited;
location @rate_limited {
add_header Retry-After 2 always;
add_header Content-Type application/json;
return 429 '{"error":"rate_limited","retry_after_sec":2}';
}
}
Когда запрос отклонён лимитом, важно дать клиенту шанс восстановиться корректно:
Пример ответа:
HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1712839202
Content-Type: application/json
{"error":"rate_limited","message":"Слишком много запросов, повторите через 2 с"}
С клиентской стороны настроите экспоненциальную паузу между повторами (например, 200 мс, 400 мс, 800 мс с джиттером) и уважайте Retry-After.
Помимо «внешних» лимитов, приложению нужна самозащита.
Ниже — минимальный пример воркер‑пула на Go, который мягко отказывает при заполненной очереди:
package main
import (
"context"
"errors"
"fmt"
"net/http"
"time"
)
type Job struct{ ID int }
var (
queue = make(chan Job, 100) // ограниченная очередь
workers = 8
ErrBusy = errors.New("queue is full")
)
func main() {
// поднимаем воркеров
for i := 0; i < workers; i++ {
go worker(i, queue)
}
http.HandleFunc("/process", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond)
defer cancel()
job := Job{ID: time.Now().Nanosecond()}
select {
case queue <- job: // принято
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte("{\"status\":\"accepted\"}"))
case <-ctx.Done(): // нет места/время вышло
w.Header().Set("Retry-After", "1")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte("{\"error\":\"rate_limited\"}"))
}
})
fmt.Println("listening on :8080")
_ = http.ListenAndServe(":8080", nil)
}
func worker(id int, q <-chan Job) {
for j := range q {
// имитируем работу
time.Sleep(100 * time.Millisecond)
// тут полезная логика
_ = id
_ = j
}
}
Идея проста: не бесконечный бэклог, а быстрый и предсказуемый отказ при перегрузке.
Итог: грамотно настроенные лимиты и обратное давление превращают хаотические пики в управляемые сценарии. Клиенты получают предсказуемый опыт, команда — меньше инцидентов, бизнес — стабильную выручку даже в дни распродаж.