Thiết kế rate limiter cho API gateway với Redis và Spring Cloud Gateway
1. Tổng quan
Khi nhiều client cùng gọi API, một client lỗi hoặc cố ý spam có thể làm sập backend của mọi người. Rate limiter ở tầng API gateway chặn bớt request vượt hạn mức trước khi chúng chạm tới service phía sau.
Bài này đi qua một case study hoàn chỉnh. Các bước gồm nêu yêu cầu, chọn thuật toán, thiết kế dữ liệu và API, rồi cài đặt bằng Redis Lua script với Spring Cloud Gateway. Đọc xong, bạn sẽ dựng được một rate limiter phân tán và biết cách xử lý tranh chấp, lệch đồng hồ, hot key và sự cố Redis.
2. Yêu cầu bài toán
2.1. Yêu cầu chức năng
- Giới hạn theo API key của tenant và theo route. Ví dụ: tenant
shop-ađược gọiPOST /orderstối đa 100 request/giây, cho phép burst tới 200. - Request vượt hạn mức nhận
429 Too Many Requestskèm headerRetry-After. - Mọi response đều có header
X-RateLimit-Remainingđể client tự điều tiết. - Rule thay đổi được lúc runtime mà không cần deploy lại gateway.
2.2. Quy mô và ràng buộc
- Khoảng 50.000 RPS ở giờ cao điểm, 20 instance gateway, 100.000 tenant.
- Độ trễ thêm vào mỗi request phải dưới 2ms ở p99.
- Hạn mức phải đúng trên toàn cụm, không tính riêng từng instance. Nếu limit là 100/s, cả cụm cùng lắm cho qua khoảng 100/s.
- Rate limiter không được trở thành điểm chết duy nhất. Khi Redis gặp sự cố, gateway vẫn phải phục vụ được.
- Chấp nhận sai số nhỏ (vài %) để đổi lấy hiệu năng.
Ràng buộc thứ ba loại bỏ phương án đếm trong bộ nhớ từng instance. Ta cần một kho trạng thái dùng chung. Redis là lựa chọn tự nhiên vì có độ trễ thấp và thao tác nguyên tử qua Lua.
3. Chọn thuật toán
| Thuật toán | Ưu điểm | Nhược điểm |
|---|---|---|
| Fixed window | Đơn giản, một INCR |
Burst gấp đôi tại ranh giới cửa sổ |
| Sliding log | Chính xác tuyệt đối | Tốn bộ nhớ, lưu từng timestamp |
| Sliding window counter | Gần đúng, nhẹ | Khó biểu diễn burst |
| Token bucket | Hỗ trợ burst tự nhiên, chỉ 2 trường | Cần tính toán nạp token |
Ta chọn token bucket. Mỗi bucket có capacity (kích thước burst) và tốc độ nạp refillPerSecond. Mỗi request lấy một token. Hết token thì request bị từ chối. Trạng thái chỉ gồm hai số là tokens và ts (lần cập nhật cuối). Với 100.000 tenant nhân vài route, tổng dung lượng chỉ vài chục MB.
4. Mô hình dữ liệu
4.1. Rule cấu hình trong SQL
Rule ít thay đổi và cần được quản trị, nên ta lưu chúng trong PostgreSQL:
CREATE TABLE rate_limit_rule (
id BIGSERIAL PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
route_id VARCHAR(64) NOT NULL,
capacity INT NOT NULL CHECK (capacity > 0),
refill_per_second INT NOT NULL CHECK (refill_per_second > 0),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (tenant_id, route_id)
);
INSERT INTO rate_limit_rule (tenant_id, route_id, capacity, refill_per_second)
VALUES ('shop-a', 'create-order', 200, 100);
Gateway không đọc bảng này trên mỗi request. Mỗi instance nạp toàn bộ rule vào bộ nhớ và làm mới định kỳ, ví dụ 30 giây một lần. Một cách khác là nghe sự kiện rule-changed qua Kafka. Đường đi nóng (hot path) không bao giờ chạm vào database.
4.2. Trạng thái bucket trong Redis
Mỗi cặp tenant–route là một Redis hash:
Key: rl:{shop-a}:create-order
Fields: tokens = 153.4
ts = 1760256000123 (ms, theo đồng hồ Redis)
TTL: tự hết hạn khi bucket đầy lâu không dùng
Phần {shop-a} là hash tag. Trên Redis Cluster, mọi key của cùng một tenant rơi vào cùng một slot. Nếu sau này bạn cần kiểm tra nhiều bucket trong một script, chúng sẽ nằm cùng node.
5. API và luồng xử lý
Phía client nhìn thấy hai kiểu response:
HTTP/1.1 200 OK
X-RateLimit-Remaining: 152
HTTP/1.1 429 Too Many Requests
Retry-After: 1
X-RateLimit-Remaining: 0
Luồng xử lý trong gateway:
- Lấy
X-Api-Keyđể xác định tenant và lấy route id từ route đã match. - Tra rule trong cache bộ nhớ. Không có rule thì dùng rule mặc định.
- Gọi Lua script trên Redis để nạp token và lấy token trong một round-trip nguyên tử.
- Được phép thì gắn header và chuyển tiếp. Bị từ chối thì trả 429.
- Redis lỗi hoặc timeout thì chuyển sang limiter cục bộ (mục 7.3).
6. Cài đặt
6.1. Dependency
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
</dependencies>
6.2. Lua script token bucket
Đặt file tại src/main/resources/scripts/token_bucket.lua:
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillMs = tonumber(ARGV[2]) / 1000.0 -- token mỗi ms
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1]) or capacity
local ts = tonumber(data[2]) or now
tokens = math.min(capacity, tokens + math.max(0, now - ts) * refillMs)
local allowed = 0
if tokens >= 1 then
tokens = tokens - 1
allowed = 1
end
redis.call('HSET', key, 'tokens', tostring(tokens), 'ts', now)
redis.call('PEXPIRE', key, math.ceil(capacity / refillMs) * 2)
return { allowed, math.floor(tokens) }
Các điểm chính:
redis.call('TIME')lấy giờ của Redis thay vì giờ của gateway. 20 instance có đồng hồ lệch nhau vài chục ms, nên nếu mỗi instance gửinowriêng, token sẽ bị nạp sai. Từ Redis 7, script mặc định replicate theo hiệu ứng (effects replication), nên gọiTIMEtrước khi ghi là an toàn.- Toàn bộ chuỗi đọc, tính và ghi chạy nguyên tử, vì Redis thực thi script đơn luồng. Đây là lời giải cho tranh chấp giữa các instance.
PEXPIREbằng hai lần thời gian nạp đầy bucket. Bucket bỏ không sẽ tự biến mất. Khi xuất hiện lại, nó bắt đầu đầy, đúng như ngữ nghĩa mong muốn.- Script trả về
[allowed, remaining]. Lua chuyển số thực sang integer khi trả về, nên tafloortrước cho rõ ràng.
6.3. Gọi script từ Java
public record RateLimitRule(int capacity, int refillPerSecond) {}
public record Decision(boolean allowed, long remaining) {}
@Component
public class RedisTokenBucket {
private final ReactiveStringRedisTemplate redis;
private final RedisScript<List> script =
RedisScript.of(new ClassPathResource("scripts/token_bucket.lua"), List.class);
public RedisTokenBucket(ReactiveStringRedisTemplate redis) {
this.redis = redis;
}
@SuppressWarnings("unchecked")
public Mono<Decision> tryAcquire(String tenant, String route, RateLimitRule rule) {
String key = "rl:{" + tenant + "}:" + route;
return redis.execute(script, List.of(key),
List.of(String.valueOf(rule.capacity()),
String.valueOf(rule.refillPerSecond())))
.next()
.map(r -> {
List<Long> res = (List<Long>) r;
return new Decision(res.get(0) == 1L, res.get(1));
});
}
}
Spring tự tính SHA1 của script và dùng EVALSHA. Nếu Redis chưa cache script, Spring sẽ fallback sang EVAL. Vì vậy chỉ lần gọi đầu tiên mới gửi cả nội dung script qua mạng.
6.4. Global filter trong gateway
@Component
public class RateLimitFilter implements GlobalFilter, Ordered {
private static final RateLimitRule DEFAULT_RULE = new RateLimitRule(20, 10);
private final RedisTokenBucket redisBucket;
private final LocalFallbackLimiter fallback;
private final RuleCache rules; // Map<String, RateLimitRule> nạp từ DB định kỳ
public RateLimitFilter(RedisTokenBucket redisBucket,
LocalFallbackLimiter fallback, RuleCache rules) {
this.redisBucket = redisBucket;
this.fallback = fallback;
this.rules = rules;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String tenant = exchange.getRequest().getHeaders().getFirst("X-Api-Key");
if (tenant == null) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
String routeId = route != null ? route.getId() : "default";
RateLimitRule rule = rules.find(tenant, routeId).orElse(DEFAULT_RULE);
return redisBucket.tryAcquire(tenant, routeId, rule)
.timeout(Duration.ofMillis(5))
.onErrorResume(e -> Mono.just(fallback.tryAcquire(tenant, routeId, rule)))
.flatMap(decision -> {
var response = exchange.getResponse();
response.getHeaders().set("X-RateLimit-Remaining",
String.valueOf(decision.remaining()));
if (decision.allowed()) {
return chain.filter(exchange);
}
response.setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
response.getHeaders().set("Retry-After",
String.valueOf(Math.max(1, 1 / rule.refillPerSecond())));
return response.setComplete();
});
}
@Override
public int getOrder() {
return -100; // chạy sau khi route đã được match, trước routing filter
}
}
Filter chạy cho mọi route. Nó đặt timeout(5ms) để một node Redis chậm không kéo độ trễ của cả gateway. Khi Redis lỗi, onErrorResume chuyển sang limiter cục bộ. Thử nhanh bằng curl với rule capacity=2:
for i in 1 2 3; do
curl -s -o /dev/null -w "%{http_code}\n" -H "X-Api-Key: shop-a" -X POST localhost:8080/orders
done
200
200
429
7. Các điểm khó và cách giải
7.1. Tranh chấp giữa các instance
Cách làm ngây thơ là gọi HGET, tính trong Java rồi gọi HSET. Hai instance có thể cùng đọc tokens=1 và cùng cho request đi qua. Lua script loại bỏ hoàn toàn race condition này. Phương án WATCH/MULTI cũng đúng, nhưng phải retry khi xung đột và tốn nhiều round-trip hơn.
7.2. Hot key và tải đột biến
Một tenant lớn có thể bắn 20.000 RPS vào một key. Một node Redis chịu được khoảng 100.000 ops/s, nhưng key đó sẽ thành điểm nóng. Có hai cách giải:
- Cấp phát token theo lô (local pre-fetch): mỗi instance xin trước một lô token, ví dụ 50 token/lần, rồi tiêu dần trong bộ nhớ. Số lần gọi Redis giảm khoảng 50 lần. Đổi lại, tổng hạn mức có thể vượt tối đa
số instance × kích thước lôtrong một khoảng ngắn. Với yêu cầu “sai số vài %” ở mục 2.2, đây là mức chấp nhận được. - Chia bucket (sharding): tách hạn mức 20.000/s thành 10 sub-bucket
rl:{shop-a}:create-order:0..9, mỗi cái 2.000/s. Request được chọn sub-bucket ngẫu nhiên. Bỏ hash tag cho các key này để chúng rải ra nhiều node.
Burst hợp lệ đã được capacity hấp thụ. Đó chính là lý do chọn token bucket thay vì fixed window.
7.3. Redis không sẵn sàng: fail-open có kiểm soát
Fail-closed, tức từ chối mọi request khi Redis chết, biến một sự cố hạ tầng phụ thành sự cố toàn hệ thống. Fail-open hoàn toàn thì lại mất lớp bảo vệ. Giải pháp trung gian là limiter cục bộ với hạn mức chia đều cho số instance:
@Component
public class LocalFallbackLimiter {
private final int instanceCount;
private final LongSupplier clock;
private final ConcurrentHashMap<String, LocalBucket> buckets = new ConcurrentHashMap<>();
public LocalFallbackLimiter(@Value("${gateway.instances:20}") int instanceCount) {
this(instanceCount, System::currentTimeMillis);
}
LocalFallbackLimiter(int instanceCount, LongSupplier clock) {
this.instanceCount = instanceCount;
this.clock = clock;
}
public Decision tryAcquire(String tenant, String route, RateLimitRule rule) {
double cap = Math.max(1, rule.capacity() / (double) instanceCount);
double perMs = rule.refillPerSecond() / (double) instanceCount / 1000.0;
LocalBucket b = buckets.computeIfAbsent(tenant + ":" + route,
k -> new LocalBucket(cap, clock.getAsLong()));
return b.take(cap, perMs, clock.getAsLong());
}
static final class LocalBucket {
private double tokens;
private long ts;
LocalBucket(double tokens, long ts) {
this.tokens = tokens;
this.ts = ts;
}
synchronized Decision take(double cap, double perMs, long now) {
tokens = Math.min(cap, tokens + Math.max(0, now - ts) * perMs);
ts = now;
if (tokens >= 1) {
tokens -= 1;
return new Decision(true, (long) tokens);
}
return new Decision(false, 0);
}
}
}
Mỗi instance giữ một phần 1/instanceCount hạn mức. Tổng toàn cụm vẫn xấp xỉ đúng mà không cần Redis. Ở môi trường production, bạn nên bọc lời gọi Redis bằng circuit breaker (ví dụ Resilience4j). Khi Redis đang chết, circuit breaker giúp không tốn 5ms timeout cho mỗi request.
7.4. Nhất quán đa vùng
Nếu gateway chạy ở hai region, đồng bộ Redis xuyên vùng trên mỗi request sẽ phá vỡ yêu cầu 2ms. Cách thực dụng là chia hạn mức theo region theo tỷ lệ traffic, ví dụ 70/30, và chấp nhận rằng một tenant có thể bị chặn sớm ở region này dù region kia còn dư. Nếu cần tối ưu hơn, một job nền có thể điều chỉnh tỷ lệ mỗi vài phút dựa trên số liệu thực tế.
8. Kiểm thử
Logic token bucket nên được test độc lập với đồng hồ thật. Vì LocalFallbackLimiter nhận LongSupplier, ta tua thời gian bằng AtomicLong:
class LocalFallbackLimiterTest {
@Test
void shouldRejectWhenBucketEmptyAndRefillOverTime() {
AtomicLong now = new AtomicLong(0);
var limiter = new LocalFallbackLimiter(1, now::get);
var rule = new RateLimitRule(2, 1); // burst 2, nạp 1 token/giây
assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isTrue();
assertThat(limiter.tryAcquire("shop-a", "create-order", rule).allowed()).isFalse();
now.addAndGet(1_000); // sau 1 giây có thêm 1 token
Decision d = limiter.tryAcquire("shop-a", "create-order", rule);
assertThat(d.allowed()).isTrue();
assertThat(d.remaining()).isZero();
}
}
Test này xác nhận ba hành vi: burst được phép tới capacity, request vượt mức bị từ chối, và token được nạp lại đúng theo thời gian. Với Lua script, bạn nên viết integration test bằng Testcontainers với image redis:7. Hãy bắn 1.000 request song song và kiểm tra số request được phép không vượt capacity.
Lưu ý: Một số lỗi hay gặp:
- Dùng
System.currentTimeMillis()của gateway làmnowtrong script. Lệch đồng hồ giữa các instance sẽ làm token bị nạp sai.- Quên đặt TTL cho key. Redis sẽ phình dần theo số tenant từng xuất hiện.
- Định danh client bằng IP sau NAT hoặc load balancer. Cả một công ty sẽ chung một bucket.
- Không nên dùng rate limiter ở gateway thay cho cơ chế chống quá tải trong chính service (bulkhead, load shedding). Gateway chỉ biết số request, không biết request nào nặng.
9. Kết luận
Trong bài này, ta đã thiết kế rate limiter cho API gateway từ yêu cầu tới cài đặt. Token bucket xử lý burst gọn gàng. Lua script trên Redis giải quyết tranh chấp bằng một round-trip nguyên tử. Lấy thời gian từ Redis loại bỏ vấn đề lệch đồng hồ giữa các instance.
Các điểm khó thực sự nằm ngoài thuật toán. Hot key cần cấp phát token theo lô hoặc chia bucket. Sự cố Redis cần fail-open có kiểm soát bằng limiter cục bộ. Hệ thống đa vùng cần chia hạn mức thay vì đồng bộ chặt. Ở mỗi điểm, ta đổi một chút chính xác lấy độ trễ thấp và tính sẵn sàng, đúng với ràng buộc đã đặt ra từ đầu.