多实例各自计数限流总量对不上,用 Redis 加 Lua 做原子限流
接口被刷是线上常见问题。单机限流用Guava RateLimiter就行,分布式环境下多个实例各自计数,总量对不上。Redis做共享存储,Lua脚本保证原子性,这套组合是分布式限流的标准方案。

为什么需要Lua脚本
限流的核心逻辑是"读计数器、判断阈值、写计数器"三步。如果分三条Redis命令发,中间有并发间隙,两个请求同时读到旧值,都放行,限流就失效了。Lua脚本在Redis里单线程原子执行,三步打包成一个不可分割的操作。
令牌桶限流
-- ratelimit.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- 补充令牌
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill / 1000)
if tokens < requested then
return 0
end
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('PEXPIRE', key, capacity / refill * 1000 * 2)
return 1
KEYS[1]是限流key,ARGV依次是桶容量、补充速率(个/秒)、当前时间戳、请求消耗令牌数。脚本先补充按时间流逝应得的令牌,再判断够不够消耗,够就扣减并返回1,不够返回0。
Java集成
@Service
public class RateLimiter {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private DefaultRedisScript<Long> script;
@PostConstruct
public void init() throws IOException {
script = new DefaultRedisScript<>();
script.setScriptSource(new ResourceScriptSource(
new ClassPathResource("lua/ratelimit.lua")));
script.setResultType(Long.class);
}
public boolean allow(String key, int capacity, int refill, int requested) {
long now = System.currentTimeMillis();
Long result = redisTemplate.execute(
script,
Collections.singletonList("ratelimit:" + key),
String.valueOf(capacity),
String.valueOf(refill),
String.valueOf(now),
String.valueOf(requested)
);
return result != null && result == 1L;
}
}
@RestController
public class ApiController {
@Autowired
private RateLimiter rateLimiter;
@GetMapping("/api/data")
public ResponseEntity<?> getData(@RequestHeader("X-User-Id") String userId) {
// 每用户每秒10个请求,桶容量20
if (!rateLimiter.allow("user:" + userId, 20, 10, 1)) {
return ResponseEntity.status(429).body("rate limit exceeded");
}
return ResponseEntity.ok("data");
}
}
Python集成
Python版本用redis-py:
import redis
import time
r = redis.Redis(host='localhost', port=6379)
LUA_SCRIPT = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill / 1000)
if tokens < requested then
return 0
end
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('PEXPIRE', key, capacity / refill * 1000 * 2)
return 1
"""
script = r.register_script(LUA_SCRIPT)
def allow(user_id, capacity=20, refill=10):
now = int(time.time() * 1000)
result = script(keys=[f"ratelimit:user:{user_id}"],
args=[capacity, refill, now, 1])
return result == 1
限流算法对比
| 算法 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定窗口 | 时间段内计数 | 简单 | 窗口边界突刺 | 粗粒度限流 |
| 滑动窗口 | 时间窗口滑动 | 平滑 | 内存占用大 | 精确限流 |
| 令牌桶 | 按速率补充令牌 | 允许突发 | 实现稍复杂 | API限流 |
| 漏桶 | 固定速率出水 | 严格匀速 | 不允许突发 | 流量整形 |
令牌桶允许短时间突发(桶里有攒的令牌),又限制长期平均速率,API限流最常用。
不同维度限流
// 按用户限流
rateLimiter.allow("user:" + userId, 100, 50, 1);
// 按IP限流
rateLimiter.allow("ip:" + clientIp, 200, 100, 1);
// 按接口限流
rateLimiter.allow("api:" + endpoint, 1000, 500, 1);
// 组合维度:用户+接口
rateLimiter.allow("user:" + userId + ":api:" + endpoint, 50, 20, 1);
key的设计决定了限流粒度。按用户限流防单用户刷接口,按IP限流防爬虫,按接口限流保护下游服务,组合维度最精细。
监控和调优
// 暴露限流指标到Prometheus
@GetMapping("/metrics/ratelimit")
public Map<String, Object> rateLimitMetrics() {
// 读取各key的剩余令牌数
Set<String> keys = redisTemplate.keys("ratelimit:*");
Map<String, Object> metrics = new HashMap<>();
for (String key : keys) {
String tokens = redisTemplate.opsForHash().get(key, "tokens");
metrics.put(key, tokens);
}
return metrics;
}
限流参数别拍脑袋定,看实际流量数据调。QPS平时500,峰值1500,桶容量设1000、补充速率600,能扛住突发又不让系统过载。
分布式限流用Redis+Lua这套方案,核心就两点:Redis做共享存储解决分布式计数,Lua脚本保证原子性解决并发竞争。代码量不大,但解决了线上真实问题。令牌桶算法允许突发流量的特性对API场景特别友好,比固定窗口那种边界突刺问题好很多。
标签: Redis,Lua,分布式限流,令牌桶,微服务
总结
分布式限流的难点不在算法,而在原子性:判断和扣得分开写,并发一来就会超卖,所以整段逻辑得塞进 Lua 脚本一次执行。令牌桶适合要容忍突发的场景,固定窗口够简单但要小心边界突刺。脚本本身别写重循环,Redis 是单线程,一个脚本跑久了后面的请求全排队。
- 点赞
- 收藏
- 关注作者
评论(0)