分布式限流:当你的“景区”开了连锁店,如何统一管理客流量?🏰➡️🏰🏰
一个曾因单机限流导致流量“偏科”,让1号服务器累死、2号服务器闲死的架构师。现在,我是分布式限流的“区域总指挥”。🎖️
朋友们,还记得我们上次聊的单机限流吗?那就像每个景区分店各自管理自己的游客。但今天,你的景区开成了连锁品牌,在10个城市都有分店!问题来了:
用户通过官网预约,可以自由选择去任意分店。结果就是:
- 北京分店:人山人海,被挤爆了 🏃💨
- 上海分店:门可罗雀,员工在打瞌睡 😴
- 广州分店:勉强维持,但系统预警不断 ⚠️
这就是分布式限流要解决的“资源分配不均”问题:如何在多实例、多节点的集群环境中,实现全局统一的流量控制,而不是各自为政?
一、分布式限流是啥?连锁景区的“中央调度中心” 🎯
简单说,分布式限流就是在分布式系统中,协调多个服务实例,共同遵守一个全局的流量限制规则。
用“连锁景区”来比喻:
- 你的微服务集群 = 10个城市的分店
- 用户请求 = 来自全国各地的游客
- 单机限流 = 每个分店自己数人数
- 分布式限流 = 总部统一调度,控制全国总游客数,并合理分配到各分店
核心目标:让10个分店作为一个整体来接待游客,而不是各自为战,最后有的撑死有的饿死!
二、为什么单机限流不够用?因为“信息孤岛”会坏事!🏝️
你可能会想:我给每个服务实例都配置相同的单机限流不就行了?
大错特错! 看看这三大“翻车”现场:
翻车现场1:负载不均,旱的旱死,涝的涝死 🌊🏜️
# 实例A配置:每秒100请求
instance-a.rate-limit: 100
# 实例B配置:每秒100请求
instance-b.rate-limit: 100
# 实际运行结果:
# 负载均衡轮询,但实例A处理慢,实例B处理快
# 结果:实例A队列积压,实例B闲得发慌
翻车现场2:重启丢数据,限流失效 🎲
// 单机令牌桶,数据在内存
public class LocalTokenBucket {
private int tokens; // 重启就没了!
public boolean tryAcquire() {
if (tokens > 0) {
tokens--;
return true;
}
return false;
}
}
// 实例重启后,令牌桶重置,之前累积的令牌全没了!
翻车现场3:客户端绕过,直捣黄龙 🎯
用户请求 → 负载均衡器 → 随机转发到实例A/B/C
↓
聪明的黑客:发现实例C比较空闲
↓
直接攻击实例C的IP:PORT,绕过负载均衡!
↓
实例C单独被击垮 💥
三、分布式限流的四种“武器库” 🗡️🛡️
1. Redis集中计数:简单的“总部计数器” 🧮
原理:所有实例共享同一个Redis计数器
每个请求来,都去Redis检查:
- 全局计数+1
- 如果超过阈值,拒绝
- 定期(如每秒)重置计数器
代码实现:
public class RedisCounterLimiter {
private Jedis jedis;
private String key;
private int limit; // 总限制
private int period; // 时间窗口(秒)
public boolean tryAcquire() {
// 使用Lua脚本保证原子性
String luaScript =
"local current = redis.call('incr', KEYS[1])\n" +
"if current == 1 then\n" +
" redis.call('expire', KEYS[1], ARGV[2])\n" +
"end\n" +
"if current > tonumber(ARGV[1]) then\n" +
" return 0\n" +
"else\n" +
" return 1\n" +
"end";
Object result = jedis.eval(luaScript, 1,
key, String.valueOf(limit), String.valueOf(period));
return "1".equals(result.toString());
}
}
优点:简单,实现快
缺点:性能瓶颈!所有请求都要访问Redis,Redis可能成为单点瓶颈
适合:中小规模集群,QPS不高
2. 令牌桶+Redis:共享的“中央票仓” 🎫
原理:在Redis中实现一个全局令牌桶
总部有一个大票仓(Redis令牌桶)
每个分店(实例)需要票时,去总部领
总部以固定速率发票
没票了就拒绝
Redis Lua脚本实现:
-- KEYS[1]: 令牌桶key
-- ARGV[1]: 桶容量
-- ARGV[2]: 每秒添加的令牌数
-- ARGV[3]: 当前时间戳
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local last_tokens = tonumber(redis.call("get", key..":tokens"))
local last_time = tonumber(redis.call("get", key..":time"))
if last_tokens == nil then
last_tokens = capacity
last_time = now
end
-- 计算这段时间应该添加多少令牌
local delta = math.max(0, now - last_time)
local new_tokens = math.min(capacity, last_tokens + delta * rate)
local allowed = 0
if new_tokens >= 1 then
allowed = 1
new_tokens = new_tokens - 1
end
-- 更新Redis
redis.call("setex", key..":tokens", 2, new_tokens)
redis.call("setex", key..":time", 2, now)
return allowed
优点:真正的全局统一限流
缺点:Redis压力大,网络延迟影响性能
适合:对全局一致性要求极高的场景
3. 分层限流:网关+实例的“双层防线” 🛡️🛡️
原理:在网关层做第一道限流,在实例层做第二道限流
用户请求 → 网关限流(全局控制) → 实例限流(自我保护)
Spring Cloud Gateway + Redis限流:
# application.yml
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # 每秒10个
redis-rate-limiter.burstCapacity: 20 # 突发20个
key-resolver: "#{@userKeyResolver}"
实例层本地限流(作为补充):
// 即使网关限流,实例自己也做保护
@RestController
public class UserController {
private RateLimiter localLimiter = RateLimiter.create(100); // 本地限流
@GetMapping("/api/users/{id}")
public User getUser(@PathVariable Long id) {
if (!localLimiter.tryAcquire()) {
throw new TooManyRequestsException("实例繁忙,请稍后");
}
return userService.getById(id);
}
}
优点:双层保护,更安全
缺点:架构复杂,维护成本高
适合:中大型企业级应用
4. 客户端限流+服务端协调:智能的“分店自主权” 🤖
原理:每个实例根据从协调中心获取的配额,自行限流
总部(协调中心)分配配额:A店30%,B店40%,C店30%
各分店根据配额自行管理游客
配额动态调整,根据各店负载情况
Apache Sentinel 集群流控:
// 1. 部署Sentinel Dashboard(协调中心)
// 2. 客户端集成
@SentinelResource(value = "getUser", blockHandler = "handleBlock")
@GetMapping("/user/{id}")
public User getUser(@PathVariable String id) {
return userService.getById(id);
}
// 3. Sentinel Dashboard配置集群规则
// 设置全局QPS=1000,分配到3个实例
优点:智能分配,兼顾公平与效率
缺点:组件较重,学习成本高
适合:云原生环境,大规模集群
四、四大方案对比:选对“武器”才能打胜仗 🎯
| 方案 | 比喻 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Redis计数 | 总部人工计数 | 简单,强一致 | Redis压力大,性能瓶颈 | 小集群,低QPS |
| Redis令牌桶 | 中央票仓 | 全局统一,精确控制 | 网络延迟影响大 | 对一致性要求高 |
| 分层限流 | 双层安检 | 多层次保护,安全 | 架构复杂 | 中大型企业应用 |
| 客户端+协调 | 分店自主 | 智能分配,性能好 | 组件重,有学习成本 | 云原生,大规模集群 |
选择口诀:
- 小系统,用Redis计数
- 要精确,用Redis令牌桶
- 要安全,用分层限流
- 要智能,用客户端+协调
五、实战:用Redis实现分布式令牌桶 🏆
这是最常用的方案,我们来看看完整的实现:
@Component
public class DistributedTokenBucket {
@Autowired
private StringRedisTemplate redisTemplate;
// Lua脚本(一次发送,原子执行)
private static final String LUA_SCRIPT =
"local key = KEYS[1]\n" +
"local capacity = tonumber(ARGV[1])\n" +
"local rate = tonumber(ARGV[2])\n" +
"local now = tonumber(ARGV[3])\n" +
"local requested = tonumber(ARGV[4])\n" + // 本次请求的令牌数
"\n" +
"local tokens_key = key .. ':tokens'\n" +
"local timestamp_key = key .. ':timestamp'\n" +
"\n" +
"local last_tokens = tonumber(redis.call('get', tokens_key))\n" +
"if last_tokens == nil then\n" +
" last_tokens = capacity\n" +
"end\n" +
"\n" +
"local last_time = tonumber(redis.call('get', timestamp_key))\n" +
"if last_time == nil then\n" +
" last_time = now\n" +
"end\n" +
"\n" +
"-- 计算新增的令牌\n" +
"local delta = math.max(0, now - last_time)\n" +
"local new_tokens = math.min(capacity, last_tokens + delta * rate)\n" +
"\n" +
"local allowed = 0\n" +
"if new_tokens >= requested then\n" +
" allowed = 1\n" +
" new_tokens = new_tokens - requested\n" +
"end\n" +
"\n" +
"-- 更新,设置过期时间防止无限增长\n" +
"local ttl = 2 -- 过期时间2秒\n" +
"redis.call('setex', tokens_key, ttl, new_tokens)\n" +
"redis.call('setex', timestamp_key, ttl, now)\n" +
"\n" +
"return allowed";
private DefaultRedisScript<Long> redisScript;
@PostConstruct
public void init() {
redisScript = new DefaultRedisScript<>();
redisScript.setScriptText(LUA_SCRIPT);
redisScript.setResultType(Long.class);
}
/**
* 尝试获取令牌
* @param key 限流key(如接口名)
* @param capacity 桶容量
* @param rate 每秒添加的令牌数
* @param requested 请求的令牌数(默认为1)
* @return 是否获取成功
*/
public boolean tryAcquire(String key, int capacity, double rate, int requested) {
List<String> keys = Collections.singletonList(key);
// 当前时间(秒,带小数)
double now = System.currentTimeMillis() / 1000.0;
Object result = redisTemplate.execute(
redisScript,
keys,
String.valueOf(capacity),
String.valueOf(rate),
String.valueOf(now),
String.valueOf(requested)
);
return result != null && ((Long) result) == 1L;
}
// 简化版,默认请求1个令牌
public boolean tryAcquire(String key, int capacity, double rate) {
return tryAcquire(key, capacity, rate, 1);
}
}
使用示例:
@RestController
public class OrderController {
@Autowired
private DistributedTokenBucket rateLimiter;
@PostMapping("/api/orders")
public Response createOrder(@RequestBody Order order) {
// 对用户ID限流:每个用户每秒最多下3单
String key = "order:user:" + order.getUserId();
if (!rateLimiter.tryAcquire(key, 3, 3.0)) {
return Response.error("您下单太快了,请稍后再试");
}
// 全局限流:整个订单接口每秒最多1000单
String globalKey = "order:global";
if (!rateLimiter.tryAcquire(globalKey, 1000, 1000.0)) {
return Response.error("系统繁忙,请稍后重试");
}
return orderService.create(order);
}
}
六、分布式限流的“天坑”与填坑指南 🕳️
坑1:Redis单点故障
问题:Redis挂了,限流就失效了!
填坑:
// 1. Redis集群部署
@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisClusterConfiguration config = new RedisClusterConfiguration();
config.addClusterNode(new RedisNode("redis1", 6379));
config.addClusterNode(new RedisNode("redis2", 6379));
config.addClusterNode(new RedisNode("redis3", 6379));
return new JedisConnectionFactory(config);
}
}
// 2. 降级策略:Redis不可用时,降级到本地限流
public boolean tryAcquireWithFallback(String key, int capacity, double rate) {
try {
return tryAcquire(key, capacity, rate);
} catch (Exception e) {
// Redis挂了,降级到本地限流
log.warn("Redis限流降级到本地限流", e);
return localRateLimiter.tryAcquire();
}
}
坑2:网络延迟导致限流不准
问题:A实例申请令牌时,B实例也申请了,由于网络延迟,Redis处理有先后
填坑:
// 使用本地预取+异步刷新的模式
public class HybridRateLimiter {
private int localTokens; // 本地令牌
private long lastSyncTime; // 上次同步时间
public synchronized boolean tryAcquire() {
// 1. 先看本地有没有令牌
if (localTokens > 0) {
localTokens--;
return true;
}
// 2. 本地没了,去Redis申请一批
int batchSize = 10; // 一次申请10个
boolean success = redisLimiter.tryAcquire(key, batchSize);
if (success) {
localTokens = batchSize - 1; // 用掉1个,剩下9个存本地
return true;
}
return false;
}
}
坑3:时钟不同步
问题:各服务器时间不一致,导致限流计算错误
填坑:
// 不要用本地时间,用Redis的TIME命令获取统一时间
public double getRedisTime() {
// Redis的TIME命令返回服务器时间
List<Object> time = jedis.time();
long seconds = (Long) time.get(0);
long microseconds = (Long) time.get(1);
return seconds + microseconds / 1_000_000.0;
}
坑4:热点数据集中攻击
问题:所有请求都访问同一个商品,虽然全局QPS没超,但单商品超了
填坑:
// 多维度限流
public boolean shouldLimit(HttpServletRequest request) {
// 1. IP限流
String ipKey = "limit:ip:" + getClientIp(request);
// 2. 用户限流
String userKey = "limit:user:" + getUserId(request);
// 3. 接口限流
String apiKey = "limit:api:" + request.getRequestURI();
// 4. 参数限流(如商品ID)
String productId = request.getParameter("productId");
String productKey = "limit:product:" + productId;
// 任意一个维度触发限流,就拒绝
return redisLimiter.tryAcquire(ipKey, 100, 100.0)
&& redisLimiter.tryAcquire(userKey, 10, 10.0)
&& redisLimiter.tryAcquire(apiKey, 1000, 1000.0)
&& redisLimiter.tryAcquire(productKey, 50, 50.0);
}
七、生产环境最佳实践:做聪明的“区域总指挥” 🧠
1. 选择合适的方案
根据你的业务规模选择:
# 小团队(<5个实例)
方案: Redis计数器
工具: 自研简单实现
# 中型团队(5-50个实例)
方案: Redis令牌桶
工具: 自研或Redisson
# 大型团队(>50个实例)
方案: 分层限流 或 客户端+协调
工具: Spring Cloud Gateway + Sentinel
2. 监控告警必不可少
// 关键监控指标
@Bean
public MeterRegistryCustomizer<MeterRegistry> metrics() {
return registry -> {
// 1. 限流通过率
Counter.builder("rate.limit.passed")
.description("限流通过的请求数")
.register(registry);
// 2. 限流拒绝率
Counter.builder("rate.limit.blocked")
.description("限流拒绝的请求数")
.register(registry);
// 3. Redis延迟
Timer.builder("redis.latency")
.description("Redis操作延迟")
.register(registry);
};
}
// 告警规则:拒绝率>5%时告警
if (blockedRate > 0.05) {
alertService.sendAlert("限流拒绝率过高:" + blockedRate);
}
3. 动态调整阈值
// 根据系统负载动态调整限流阈值
@Scheduled(fixedRate = 10000) // 每10秒调整一次
public void adjustRateLimit() {
double cpuUsage = getCpuUsage();
double memoryUsage = getMemoryUsage();
if (cpuUsage > 0.8 || memoryUsage > 0.8) {
// 负载高,降低限流阈值
rateLimiter.adjustRate(0.8); // 降到80%
} else if (cpuUsage < 0.3 && memoryUsage < 0.3) {
// 负载低,提高限流阈值
rateLimiter.adjustRate(1.2); // 提升到120%
}
}
4. A/B测试不同的限流策略
// 对不同用户使用不同的限流策略
public RateLimiter getUserRateLimiter(String userId) {
// 根据用户分组,A组用令牌桶,B组用滑动窗口
String group = getUserGroup(userId);
if ("A".equals(group)) {
return tokenBucketLimiter;
} else if ("B".equals(group)) {
return slidingWindowLimiter;
} else {
return defaultLimiter;
}
}
八、灵魂拷问:你真的需要分布式限流吗?🤔
在投入分布式限流前,先问自己:
- 流量真的均匀吗? 如果80%流量都打到同一个实例,先解决负载均衡问题
- 能接受最终一致吗? 很多业务可以接受各实例限流略有差异
- 有更简单的方案吗? 考虑Nginx限流、API网关限流
- 成本效益如何? 分布式限流带来复杂度,是否值得?
记住:分布式限流是最后的手段,不是首选方案!
结语:分布式限流是“艺术”而不是“科学” 🎨
分布式限流就像指挥一个交响乐团,每个乐器(服务实例)既要独奏(处理自己的请求),又要合奏(遵守全局规则)。指挥家(分布式限流系统)要确保整个乐团的和谐,而不是让某件乐器声音过大或过小。
记住分布式限流的核心哲学:
- 全局视角,局部执行:总部定规则,分店去执行
- 宁可错杀,不可错放:保护系统比服务个别用户更重要
- 动态调整,智能适应:没有一成不变的规则
- 监控为王,数据说话:没有监控的限流是盲人摸象
现在,带上这份指南,去构建你的分布式限流系统吧!让它像一位经验丰富的乐团指挥,优雅地引导流量,保护你的系统在流量洪峰中翩翩起舞,而不是被冲垮。💃🌊
(当你再次面临流量冲击时,希望你能微笑着说:“来吧,我的限流系统已经准备好了!”)😎