凌晨3点,监控警报刺耳地尖叫着。我盯着屏幕上垂直下跌的服务可用性曲线,意识到那个被忽视的限流配置项终于引爆了——每秒1000次的支付请求正像洪水般冲垮我们的系统。这次事故让我深刻理解:限流不是可选项,而是分布式系统的生存法则。
一、为什么传统计数算法会把你坑哭
记得刚入行时,我用简单计数器实现了人生第一个限流器:
// 新手村级别的限流 - 每分钟100次请求
public class NaiveLimiter {
private int counter = 0;
private long lastReset = System.currentTimeMillis();
public synchronized boolean allow() {
if (System.currentTimeMillis() - lastReset > 60_000) {
counter = 0;
lastReset = System.currentTimeMillis();
}
return ++counter <= 100;
}
}
直到线上出现诡异现象:每分钟59秒到01秒之间,系统总会突然卡顿。这就是临界值突变问题——当时间窗口切换时,前后窗口的请求会叠加形成流量脉冲。就像早高峰的地铁闸机,在整点交接班时突然出现双倍人流。
二、四大金刚:主流限流算法全解析
1. 滑动窗口 - 时间刺客的精准刀法
通过划分更细粒度的时间片,解决传统计数器的临界问题:
// 将1分钟划分为6个10秒窗口
class TimeWindow {
long timestamp;
int count;
}
public class SlidingWindowLimiter {
private final TimeWindow[] windows = new TimeWindow[6];
private int index = 0;
public boolean allow() {
long now = System.currentTimeMillis();
TimeWindow current = windows[index];
if (current == null || now - current.timestamp > 10_000) {
current = new TimeWindow();
current.timestamp = now;
windows[index] = current;
index = (index + 1) % windows.length;
}
return ++current.count <= 16; // 100/6≈16
}
}
2. 漏桶算法 - 恒流稳压器
像物理漏桶一样恒定控制流出速率:
public class LeakyBucketLimiter {
private long nextTime = System.currentTimeMillis();
private final long interval = 10; // 10ms处理一个请求
public synchronized boolean allow() {
long now = System.currentTimeMillis();
if (now < nextTime) return false;
nextTime = now + interval;
return true;
}
}
3. 令牌桶 - 应对突发流量的缓冲池
允许短时突发流量,适合秒杀场景:
public class TokenBucket {
private int tokens;
private long lastRefill;
private final int capacity;
private final int refillRate; // 每秒补充令牌数
public synchronized boolean allow() {
refillTokens(); // 补充令牌
if (tokens > 0) {
tokens--;
return true;
}
return false;
}
private void refillTokens() {
long now = System.currentTimeMillis();
if (now > lastRefill) {
long elapsedSec = (now - lastRefill) / 1000;
tokens = Math.min(capacity, tokens + (int)(elapsedSec * refillRate));
lastRefill = now;
}
}
}
三、分布式限流的雷区与拆弹手册
案例:Redis集群下的滑动窗口实现
// 使用Lua脚本保证原子操作
public class RedisSlidingWindow {
private final Jedis jedis;
private final String script =
"local key = KEYS[1] " +
"local now = tonumber(ARGV[1]) " +
"local window = tonumber(ARGV[2]) " +
"local limit = tonumber(ARGV[3]) " +
"redis.call('ZREMRANGEBYSCORE', key, 0, now - window) " +
"local count = redis.call('ZCARD', key) " +
"if count < limit then " +
" redis.call('ZADD', key, now, now) " +
" redis.call('EXPIRE', key, window/1000 + 1) " +
" return 1 " +
"end " +
"return 0";
public boolean allow(String key, int windowMs, int limit) {
long now = System.currentTimeMillis();
Object result = jedis.eval(script, 1, key,
String.valueOf(now),
String.valueOf(windowMs),
String.valueOf(limit));
return "1".equals(result.toString());
}
}
踩坑实录:
-
时间漂移灾难:三台服务器时间差达500ms,导致限流失效
-
解决方案:所有节点从Redis获取时间
redis.call('TIME')[1]
-
-
热key压垮集群:某秒杀商品ID的QPS达50万+
-
解决方案:分片散列
四、算法选型决策树(真实场景验证)
性能压测数据(单节点/万QPS):
算法类型 | 内存模式 | Redis模式 | 适用场景 |
---|---|---|---|
固定窗口 | 12.8万 | 3.2万 | 低精度监控 |
滑动窗口 | 8.6万 | 2.1万 | 支付接口 |
令牌桶 | 11.2万 | 2.8万 | 秒杀系统 |
漏桶 | 15.4万 | 3.8万 | API网关入口流量整形 |
五、进阶技巧:自适应限流系统
当系统过载时,传统静态限流反而会加剧雪崩。智能限流方案:
// 基于CPU负载的动态限流
public class AdaptiveLimiter {
private double limit = 1000; // 初始阈值
private long lastUpdate;
public boolean allow() {
if (System.currentTimeMillis() - lastUpdate > 5000) {
updateLimit();
}
// ... 标准限流逻辑
}
private void updateLimit() {
double cpuLoad = getCpuLoad();
if (cpuLoad > 0.8) {
limit *= 0.9; // 过载时缩容
} else if (cpuLoad < 0.3) {
limit *= 1.1; // 空闲时扩容
}
lastUpdate = System.currentTimeMillis();
}
}
组合策略实战:
-
网关层:漏桶算法平滑入口流量
-
服务层:滑动窗口保护DB访问
-
资源层:令牌桶控制线程池提交
六、血泪教训总结
-
永远设置默认值:那次故障因配置中心宕机导致限流失效
-
监控必须闭环:曾因未监控拒绝请求量,导致客户流失三天才发现
-
阶梯式拒绝策略:直接返回429不如返回"您的请求已进入排队"
-
熔断优于限流:当DB连接池耗尽时,限流已无意义
限流本质上是在流量洪峰中为系统修建导流渠。经过多年实践,我最深的体会是:没有完美的限流算法,只有与业务场景完美契合的限流策略。那些凌晨处理生产事故的经历,最终都化作了系统稳定性城墙的砖瓦。