“一个没有限流的转账接口,就像一个没有闸口的水库——迟早会决堤。“
前言
限流(Rate Limiting)是保护系统的第一道防线,尤其在银行系统里:
- 资金安全:防止恶意高频转账套利
- 服务保护:下游服务(支付核心、账务系统)资源有限,超载直接导致交易失败
- 成本控制:云服务 API 调用按次计费,失控的高频调用等于直接烧钱
- 公平性:所有客户都有合理响应时间,不能被少数高频用户占满资源
1. 限流算法:四种方案的对比与选择
1.1 固定窗口(Fixed Window)
将时间划分为固定窗口,每窗口独立计数。
public class FixedWindowRateLimiter {
private final long windowSizeMs;
private final long maxRequests;
private final ConcurrentHashMap<String, WindowState> windows = new ConcurrentHashMap<>();
public boolean tryAcquire(String userId) {
long now = System.currentTimeMillis();
long windowStart = now - (now % windowSizeMs);
windows.compute(userId, (k, window) -> {
if (window == null || window.windowStart < windowStart) {
return new WindowState(1, windowStart);
}
if (window.count >= maxRequests) {
return window;
}
return new WindowState(window.count + 1, window.windowStart);
});
return windows.get(userId).count <= maxRequests;
}
record WindowState(long count, long windowStart) {}
}
致命缺陷:窗口边界处有双倍流量穿透:
时间轴: [ 0s ──────── 10s ] [ 10s ──────── 20s ]
窗口1: 1,2,3,4,5,6,7,8,9,10 → 限流10个
在9s-11s之间(跨越边界):
9s: 窗口1第10个请求 ✅
10s: 窗口1结束
11s: 窗口2开始 → 立刻又可以发10个
→ 实际 9s~11s 处理了 20 个请求,是限流上限的 2 倍!
结论:仅适用于对精确度要求极低的场景,生产不用。
1.2 滑动窗口日志(Sliding Window Log)
每个请求记录精确时间戳,统计滑动窗口内的请求数。
public class SlidingWindowLogLimiter {
private final long windowMs;
private final long maxRequests;
private final ConcurrentHashMap<String, LinkedList<Long>> logs = new ConcurrentHashMap<>();
public synchronized boolean tryAcquire(String userId) {
long now = System.currentTimeMillis();
long windowStart = now - windowMs;
LinkedList<Long> log = logs.computeIfAbsent(userId, k -> new LinkedList<>());
while (!log.isEmpty() && log.peekFirst() < windowStart) {
log.removeFirst();
}
if (log.size() >= maxRequests) {
return false;
}
log.addLast(now);
return true;
}
}
优点:精确,无边界穿透问题。 缺点:内存占用随并发用户数线性增长。百万用户 × 每用户 N 个时间戳 → 内存爆炸。
结论:适合低并发、低用户量场景。
1.3 令牌桶(Token Bucket)— 生产首选
以固定速率向桶中添加令牌,请求消耗令牌,桶满则丢弃。
令牌补充速率:每秒10个
桶容量:20个
时间轴:
t=0s: 桶满 (20个) → 可一次处理20个并发请求
t=1s: 桶补充10个,消耗X个,剩余 20-X+10
t=2s: 继续补充...
→ 允许突发流量(桶里有积累),同时限制长期平均速率
public class TokenBucketLimiter {
private final double refillRate;
private final long capacity;
private final ConcurrentHashMap<String, BucketState> buckets = new ConcurrentHashMap<>();
public boolean tryAcquire(String userId, long tokens) {
long now = System.currentTimeMillis();
buckets.compute(userId, (k, state) -> {
if (state == null) {
return new BucketState(now, capacity - tokens, capacity);
}
long elapsedMs = now - state.lastRefillMs();
double tokensToAdd = (elapsedMs / 1000.0) * refillRate;
double newTokens = Math.min(capacity, state.tokens() + tokensToAdd);
if (newTokens < tokens) {
return state;
}
return new BucketState(now, newTokens - tokens, capacity);
});
return buckets.get(userId).tokens() >= 0;
}
record BucketState(long lastRefillMs, double tokens, long capacity) {}
}
优点:允许突发流量、精确控制速率、内存占用恒定。
结论:生产环境首选,Guava 的 RateLimiter 就是这个算法。
1.4 漏桶(Leaky Bucket)
请求以任意速率进入桶,以固定速率漏出。超过桶容量则丢弃。
public class LeakyBucketLimiter {
private final long capacity;
private final long leakRateMs;
private final ConcurrentHashMap<String, BucketState> buckets = new ConcurrentHashMap<>();
public boolean tryAcquire(String userId) {
long now = System.currentTimeMillis();
buckets.compute(userId, (k, state) -> {
if (state == null) {
return new BucketState(0, now);
}
long leaked = (now - state.lastLeakMs()) / leakRateMs;
long newLevel = Math.max(0, state.level() - leaked);
if (newLevel >= capacity) {
return state;
}
return new BucketState(newLevel + 1, now);
});
return buckets.get(userId).level() < capacity;
}
record BucketState(long level, long lastLeakMs) {}
}
特点:输出速率恒定,不允许突发。适合限速输出端(如 API 下游调用)。
| 特性 | 固定窗口 | 滑动日志 | 令牌桶 | 漏桶 |
|---|---|---|---|---|
| 精确度 | 低(边界穿透) | 高 | 高 | 高 |
| 突发支持 | 不支持 | 支持 | 支持 | 不支持 |
| 内存占用 | 低 | 高 | 低 | 低 |
| 生产推荐 | ❌ | 小规模✅ | ✅ 大规模 | ✅ 下游限速 |
2. 分布式限流:Redis 原子实现
单机限流在多实例部署下失效——必须用 Redis 集中计数。
2.1 Redis 滑动窗口(精确版)
-- sliding_window.lua
-- KEYS[1] = rate limit key (e.g., "rl:user:123")
-- ARGV[1] = window size in ms
-- ARGV[2] = max requests
-- ARGV[3] = current timestamp (ms)
-- ARGV[4] = request id (for ZADD)
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local windowStart = now - window
-- 删除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)
-- 统计当前窗口内请求数
local count = redis.call('ZCARD', key)
if count >= limit then
return {0, count}
end
-- 记录本次请求
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, window)
return {1, count + 1}
public class RedisSlidingWindowLimiter {
private final RedisTemplate<String, String> redis;
public RateLimitResult tryAcquire(String key, long windowMs, int limit) {
DefaultRedisScript<ArrayList> script = new DefaultRedisScript<>();
script.setScriptText(loadScript("sliding_window.lua"));
script.setResultType(ArrayList.class);
ArrayList result = redis.execute(script,
List.of(key),
String.valueOf(windowMs),
String.valueOf(limit),
String.valueOf(System.currentTimeMillis()),
UUID.randomUUID().toString()
);
return new RateLimitResult(
((Long) result.get(0)) == 1,
((Long) result.get(1)).intValue()
);
}
private String loadScript(String name) {
try (InputStream is = getClass().getClassLoader().getResourceAsStream(name)) {
return new String(is.readAllBytes());
} catch (IOException e) {
throw new RuntimeException("Failed to load Lua script", e);
}
}
}
2.2 Redis 令牌桶(推荐生产方案)
-- token_bucket.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local tokens = tonumber(ARGV[2])
local refillRate = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
local storedTokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now
local elapsedSeconds = (now - lastRefill) / 1000.0
local newTokens = math.min(capacity, storedTokens + (elapsedSeconds * refillRate))
if newTokens < tokens then
redis.call('HMSET', key, 'tokens', newTokens, 'lastRefill', now)
redis.call('PEXPIRE', key, 60000)
return {0, newTokens}
end
redis.call('HMSET', key, 'tokens', newTokens - tokens, 'lastRefill', now)
redis.call('PEXPIRE', key, 60000)
return {1, newTokens - tokens}
3. 银行系统限流分层架构
银行系统的限流不是单一方案,而是分层部署:
前端 → API Gateway → 业务服务 → 数据库/缓存
│ │ │ │
│ ↓ ↓ ↓
│ 入口限流 接口限流 资源限流
│ (防 DDoS) (按用户/账户) (连接池)
│
└─ 防爬虫限流(UA/IP 黑名单 + 验证码)
3.1 银行常见限流维度
| 维度 | 粒度 | 场景 |
|---|---|---|
| 用户维度 | 每个用户每秒 N 次 | 通用接口保护 |
| 账户维度 | 每个账户每秒 N 笔交易 | 资金安全 |
| IP 维度 | 每个 IP 每秒 N 次 | 防爬虫/防 DDoS |
| 接口维度 | 每个接口每秒 N 次 | 保护特定接口 |
| 商户维度 | 每个商户每秒 N 次 | B2B 接口 |
3.2 账户维度限流:防止资金套利
这是银行最特殊的限流需求——按账户而不是按用户:
@Service
public class AccountRateLimitService {
private final RedissonClient redisson;
/**
* 账户维度限流:同一账户每秒最多转账 N 次
* 防止高频小额转账套取积分/返现
*/
public boolean tryAccountRateLimit(String accountId, String transactionType) {
String key = String.format("account:rl:%s:%s", accountId, transactionType);
RRateLimiter limiter = redisson.getRateLimiter(key);
limiter.setRate(RateType.OVERALL, 5, 1, RateIntervalUnit.SECONDS);
return limiter.tryAcquire();
}
/**
* 复合限流:账户维度 + 用户维度
*/
public RateLimitDecision checkPayment(String userId, String accountId) {
boolean accountOk = tryAccountRateLimit(accountId, "TRANSFER");
boolean userOk = tryUserRateLimit(userId);
if (accountOk && userOk) return RateLimitDecision.ALLOWED;
else if (!accountOk) return RateLimitDecision.ACCOUNT_LIMITED;
else return RateLimitDecision.USER_LIMITED;
}
}
enum RateLimitDecision {
ALLOWED,
ACCOUNT_LIMITED, // 账户超限(更严重)
USER_LIMITED // 用户超限(稍后再试)
}
3.3 限流触发后的用户体验设计
银行不能简单返回 429,需要考虑用户旅程:
@PostMapping("/api/payment/transfer")
public ResponseEntity<?> transfer(@RequestBody TransferRequest request,
HttpServletRequest httpRequest) {
String userId = httpRequest.getHeader("X-User-Id");
RateLimitDecision decision = rateLimitService.checkPayment(userId, request.getFromAccountId());
return switch (decision) {
case ALLOWED -> processTransfer(request);
case ACCOUNT_LIMITED -> ResponseEntity.status(429)
.header("Retry-After", "60")
.body(Map.of("code", "ACCOUNT_LIMITED",
"message", "账户操作频率超限,请1分钟后重试或联系客服"));
case USER_LIMITED -> ResponseEntity.status(429)
.header("Retry-After", "5")
.body(Map.of("code", "USER_LIMITED",
"message", "操作过于频繁,请5秒后重试"));
};
}
3.4 降级策略
网关层限流触发后,有多种降级策略:
@Service
public class DegradationService {
// 策略1:队列缓冲(适合非实时场景)
public void enqueueForLaterProcessing(PaymentRequest request) {
redisTemplate.opsForList().rightPush("payment:queue:delayed",
JsonUtil.toJson(request));
}
// 策略2:降级服务(适合查询类接口)
public AccountBalance getAccountBalance(String accountId) {
if (rateLimitService.tryAcquire(accountId)) {
return accountService.queryBalance(accountId);
} else {
return cacheService.getCachedBalance(accountId)
.orElseThrow(() -> new ServiceDegradedException("系统繁忙,请稍后重试"));
}
}
// 策略3:分时处理(适合批量场景)
public void scheduleForOffPeak(PaymentRequest request) {
int hour = LocalTime.now().getHour();
if (hour >= 9 && hour <= 11) {
// 高峰期,延迟到下午2点处理
scheduleService.schedule(request, LocalDateTime.now().plusHours(3));
} else {
processTransfer(request);
}
}
}
4. 总结:银行限流配置参考
| 限流场景 | 维度 | 阈值 | 算法 |
|---|---|---|---|
| 转账接口 | 账户 | 每账户 5次/秒 | 令牌桶 |
| 登录接口 | IP | 每IP 10次/秒 | 滑动窗口 |
| 账户查询 | 用户 | 每用户 100次/秒 | 令牌桶 |
| 支付接口 | 商户 | 每商户 1000次/秒 | 令牌桶 |
| 内部 API | 服务 | 每服务 500次/秒 | 漏桶 |
限流的核心不是”堵死”,而是在系统过载时优雅降级,让真正重要的交易还能走通。
相关阅读:Spring Cloud Gateway 银行网关实战 · [Redis 实现分布式锁](/coding/Redis/Redis 实现分布式锁) · [Redis 实现消息队列](/coding/Redis/Redis 实现消息队列)