Giới hạn theo IP, user, API key bằng RedisLimits per IP, user or API key with Redis
Bộ lọc giới hạn tốc độ đứng ở gateway, trước mọi service. Mỗi request chạy một script Lua trong Redis để lấy token; hết token thì trả 429 ngay, service phía sau không bị gọi. Bật “Redis lỗi” để xem cơ chế fail-open với bộ giới hạn cục bộ.The rate-limit filter sits at the gateway in front of every service. Each request runs one Lua script in Redis to take a token; with no token left it answers 429 at once and the service behind is never called. Turn on “Redis down” to see fail-open with a local limiter.
1Đối tác gói Pro gọi API tạo đơn bằng API key.A Pro-plan partner calls the create-order API with its API key.
GET rồi SET từ nhiều instance cho phép vượt hạn mức. INCR rồi EXPIRE là hai lệnh: chết giữa chừng thì khoá không bao giờ hết hạn. Gom tất cả vào một script Lua (hoặc SET NX EX + INCR).GET then SET from many instances lets requests slip past the quota. INCR then EXPIRE is two commands: a crash in between leaves a key that never expires. Put everything in one Lua script (or SET NX EX + INCR).
Mỗi pod có đồng hồ riêng nên tính token nạp thêm sai. Lấy thời gian bằng redis.call('TIME') bên trong script để mọi instance dùng chung một đồng hồ.Each pod has its own clock, so refills are computed wrongly. Read time with redis.call('TIME') inside the script so every instance shares one clock.
Timeout ngắn (10–20 ms), circuit breaker quanh Redis, rồi chọn: fail-open với bộ giới hạn cục bộ cho API thường, fail-closed cho đăng nhập và OTP.A short timeout (10–20 ms), a circuit breaker around Redis, then choose: fail-open with a local limiter for regular APIs, fail-closed for login and OTP.
Hai tầng: bucket cục bộ chặn bớt lưu lượng xấu trước, chỉ phần còn lại mới gọi Redis. Hash tag {subject} giữ các khoá của một chủ thể trên cùng một slot.Two levels: a local bucket sheds the worst traffic first and only the rest calls Redis. The {subject} hash tag keeps one subject's keys on the same slot.
Giới hạn theo IP không đủ vì kẻ tấn công xoay IP. Kết hợp giới hạn theo IP với đếm lần sai theo tài khoản và khoá tạm có tăng dần thời gian.A per-IP limit is not enough because attackers rotate IPs. Combine it with a per-account failure counter and a temporary lock that grows over time.
Đặt bộ lọc ở gateway hoặc middleware bọc quanh handler; logic nghiệp vụ không biết gì về hạn mức.Put the filter in the gateway or in middleware wrapped around handlers; business logic knows nothing about quotas.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
rl:{subject}:{route} | HASH tokens, ts · PEXPIRE = capacity / refill | Token bucket: số token còn lại và thời điểm nạp gần nhất theo đồng hồ RedisToken bucket: tokens left and the last refill time by the Redis clock |
rl:{subject}:{route}:{window} | STRING count · EXPIRE = 2 × window | Sliding window counter: giữ cửa sổ hiện tại và cửa sổ trước để nội suySliding window counter: keep the current and previous windows to interpolate |
login_fail:{account} | STRING count · EXPIRE 15 min · lock_until | Đếm lần đăng nhập sai theo tài khoản, độc lập với IPFailed logins per account, independent of the IP |
plan_quota | plan, route_pattern, capacity, refill_per_sec, burst | Bảng cấu hình trong DB, gateway cache vài phút để đổi hạn mức không cần deployA config table in the DB, cached by the gateway for minutes so quotas change without a deploy |
Một script Lua token bucket dùng chung cho cả hai ngôn ngữ. Bộ lọc gọi script, đặt header, trả 429 khi hết token và lùi về bộ giới hạn cục bộ khi Redis lỗi.One Lua token-bucket script shared by both languages. The filter runs it, sets the headers, answers 429 when no token is left and falls back to a local limiter when Redis fails.
@Component
@RequiredArgsConstructor
public class RateLimitFilter extends OncePerRequestFilter {
private static final RedisScript<List> BUCKET =
RedisScript.of(new ClassPathResource("token_bucket.lua"), List.class);
private final StringRedisTemplate redis;
private final PlanResolver plans;
private final LocalLimiter fallback; // Bucket4j, in memory
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
Plan plan = plans.resolve(req); // API key → user → IP
String key = "rl:{" + plan.subject() + "}:" + req.getMethod() + " " + routeOf(req);
boolean allowed;
long remaining = -1;
try {
List<Long> r = redis.execute(BUCKET, List.of(key),
String.valueOf(plan.capacity()), String.valueOf(plan.refillPerSec()));
allowed = r.get(0) == 1L;
remaining = r.get(1);
} catch (DataAccessException e) { // Redis down or timed out
allowed = fallback.tryAcquire(key, plan); // fail-open, local cap
}
res.setHeader("X-RateLimit-Limit", String.valueOf(plan.capacity()));
if (remaining >= 0) res.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));
if (!allowed) {
long retry = (long) Math.ceil(1.0 / plan.refillPerSec());
res.setHeader("Retry-After", String.valueOf(retry));
res.sendError(429, "rate limited"); // service never called
return;
}
chain.doFilter(req, res);
}
}//go:embed token_bucket.lua
var bucketLua string
var bucket = redis.NewScript(bucketLua)
type Limiter struct {
rdb *redis.Client // ReadTimeout: 20ms
plans PlanResolver
local *LocalLimiters // golang.org/x/time/rate per key
}
func (l *Limiter) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
plan := l.plans.Resolve(r) // API key → user → IP
key := fmt.Sprintf("rl:{%s}:%s %s", plan.Subject, r.Method, routeOf(r))
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(plan.Capacity))
res, err := bucket.Run(r.Context(), l.rdb, []string{key},
plan.Capacity, plan.RefillPerSec).Int64Slice()
var allowed bool
if err != nil { // Redis down or timed out
allowed = l.local.Get(key, plan).Allow() // fail-open, local cap
} else {
allowed = res[0] == 1
w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(res[1], 10))
}
if !allowed {
retry := int(math.Ceil(1 / plan.RefillPerSec))
w.Header().Set("Retry-After", strconv.Itoa(retry))
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}| Token bucketToken bucket | Sliding window counterSliding window counter | |
|---|---|---|
| BurstBursts | Cho phép burst tới capacity, sau đó đều theo tốc độ nạpAllows bursts up to capacity, then a steady refill rate | Không có burst riêng; đếm xấp xỉ theo cửa sổ trượtNo separate burst; an approximate count over a sliding window |
| Trạng thái trong RedisState in Redis | Một hash: tokens, tsOne hash: tokens, ts | Hai bộ đếm: cửa sổ hiện tại và cửa sổ trướcTwo counters: current and previous window |
| Dễ giải thích cho kháchEasy to explain to customers | Khó hơn: “tối đa 20 burst, 5/giây”Harder: “up to 20 burst, 5 per second” | Dễ: “1 000 request mỗi phút”Easy: “1,000 requests a minute” |
| Chọn khiPick it when | API tương tác, cần chịu burst ngắnInteractive APIs that must absorb short bursts | Hạn mức theo gói, tính tiền theo phút hoặc giờPlan quotas billed per minute or hour |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Biên cửa sổ: 100 request ở giây 59 và 100 request ở giây 61 đều hợp lệ, tức 200 request trong 2 giây. Sliding window log chính xác nhưng tốn bộ nhớ (một phần tử mỗi request); sliding window counter nội suy từ hai cửa sổ, sai số nhỏ, chỉ cần hai bộ đếm.The boundary: 100 requests at second 59 and 100 at second 61 both pass, so 200 in 2 seconds. A sliding window log is exact but costs memory (one entry per request); a sliding window counter interpolates two windows with a small error and needs only two counters.
Đếm ở kho chung (Redis) bằng thao tác nguyên tử, ví dụ một script Lua. Nếu muốn giảm số lần gọi Redis, dùng hai tầng: bucket cục bộ chặn lưu lượng xấu, Redis giữ hạn mức toàn cục.Count in a shared store (Redis) with an atomic operation such as a Lua script. To call Redis less, use two levels: a local bucket sheds abusive traffic and Redis holds the global quota.
Tuỳ route. API thường fail-open với trần cục bộ vì chặn hết người dùng tệ hơn để lọt một ít request. Đăng nhập, OTP, gửi tiền thì fail-closed hoặc dùng bộ cục bộ rất chặt, vì đó là mục tiêu tấn công.It depends on the route. Regular APIs fail open with a local cap, since blocking every user is worse than letting some extra traffic through. Login, OTP and payments fail closed or use a very strict local limiter, because they are attack targets.
Gateway cho hạn mức chung theo IP, API key, gói dịch vụ: chặn sớm, rẻ. Service cho giới hạn cần ngữ cảnh nghiệp vụ, như số lần đăng nhập sai theo tài khoản hay số đơn mỗi user mỗi ngày.The gateway handles generic quotas per IP, API key and plan: early and cheap. The service handles limits that need business context, such as failed logins per account or orders per user per day.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.