场景引入
事例代码在最后
在优惠券秒杀场景中,活动开始时可能有大量用户同时点击
活动开始
↓
大量用户同时点击抢券
↓
大量请求同时到达服务器
↓
秒杀接口瞬间产生高并发
如果所有请求都进入后面的业务逻辑:
请求
↓
查询优惠券
↓
查询库存
↓
创建订单
↓
扣减库存
↓
数据库事务
这就会导致同一时间有大量请求打到数据库,因此可以在秒杀接口的前面一层引入限流
为什么要使用令牌桶
按固定速率产生令牌
↓
┌───────────┐
│ 🟢 🟢 🟢 │
│ 🟢 🟢 🟢 │
│ 🟢 🟢 │
└───────────┘
↓
秒杀请求
↓
消耗一个令牌
每个秒杀请求到达后,都需要从桶中获取一个令牌,请求才可以继续往下执行,这样就可以过滤掉大量请求
为什么要做 IP + 用户两个维度
秒杀场景中,只做用户限流是完全不够的
假设每个用户 10 次/秒,当一个恶意IP创建多个账号
192.168.1.100
│
├── 用户A
├── 用户B
├── 用户C
├── 用户D
└── 用户E
这样设计是为了防止短时间内向服务器发送大量请求,造成服务器宕机
明确两个维度解决的问题:
IP->单个 IP 在短时间内发送大量请求
用户->单个用户疯狂点击秒杀按钮
Redis 中如何保存令牌桶
例如:
rate_limit:seckill:ip:192.168.1.100
rate_limit:seckill:user:10001
可以利用Redis Hash存储k:tokens v:last_ms
- tokens:当前剩余令牌数量
- last_ms:上一次更新桶的时间
桶容量和请求速率
假设令牌桶1s最多产生10个令牌,桶容量为10
则令牌生成速度ratePerMs=10/1000=0.01,表示每毫秒生成0.01个令牌,每秒生成10个令牌
为什么桶容量决定突发能力
如果桶容量capacity=10
那么秒杀开始时就允许10个请求瞬间通过,而令牌生成速度决定长期的平均请求速率
为什么使用Redis TIME
令牌桶的时间计算:当前时间-上次更新时间
如果使用Java的System.currentTimeMillis(),在分布式环境下存在多台服务器,不同机器的系统时间可能存在一点偏差
因此在Lua中使用redis.call('TIME'),直接获取Redis服务器的时间,然后转换为毫秒,这样所有限流计算都使用同一个时间标准
令牌是如何恢复的
上一次:
tokens = 3
距离上一次请求:
过去了 200ms
ratePerMs = 0.01
补充refill=200*0.01=2个令牌,新的令牌数量tokens=3+2=5个令牌
所以过去时间越长,补充的令牌就越多,请求能力逐渐恢复
为什么nowMillis-lastMs可能小于0
正常情况下,nowMillis>lastMs,但是服务器时间可能因为NTP校准等原因发生调整
所以代码:
if delta < 0 then
return 0
end
表示如果时间发生倒退,本次按照经过0毫秒处理,避免出现负数的令牌补充
核心函数tryConsume
作用:尝试从指定的令牌桶中消费一个令牌
执行流程:
读取 tokens
↓
读取 last_ms
↓
计算 delta
↓
计算 refill
↓
补充令牌
↓
不能超过 capacity
↓
tokens >= 1?
↙ ↘
是 否
↓ ↓
扣1个 拒绝
令牌
最后先判断IP维度有没有被拦截,再判断用户维度,只有两个都通过请求才可进入秒杀业务
完整流程
用户点击抢券
↓
秒杀优惠券接口
↓
Redis + Lua限流
↓
┌──────────────┐
│ IP令牌桶检查 │
└──────────────┘
↓ ↓
失败 成功
↓ ↓
返回10007 用户桶检查
↓
┌──────────────┐
│ 用户令牌桶检查 │
└──────────────┘
↓ ↓
失败 成功
↓ ↓
返回10008 进入秒杀业务
↓
Redis库存校验
↓
创建秒杀订单
↓
MQ
↓
异步下单
lua
-- IP维度桶,可选
local ipKey = KEYS[1]
-- 用户维度桶
local userKey = KEYS[2]
-- IP窗口毫秒数(用于推导速率与TTL)
local ipLimitWindowMillis = tonumber(ARGV[1] or '0')
-- IP最大尝试次数(映射为桶容量)
local ipLimitMaxAttempts = tonumber(ARGV[2] or '0')
-- 用户窗口毫秒数(用于推导速率与TTL)
local userLimitWindowMillis = tonumber(ARGV[3] or '0')
-- 用户最大尝试次数(映射为桶容量)
local userLimitMaxAttempts = tonumber(ARGV[4] or '0')
-- 返回码,与自己项目中的状态码对应
local CODE_SUCCESS = 0 -- 允许
local CODE_IP_EXCEEDED = 10007 -- IP 维度限流(超限)
local CODE_USER_EXCEEDED = 10008 -- 用户维度限流(超限)
-- 当前毫秒时间:TIME 返回 [seconds, microseconds]
local now = redis.call('TIME')
-- 统一时间基于Redis服务器
local nowMillis = now[1] * 1000 + math.floor(now[2] / 1000)
-- 安全钳制Δt的上限,避免一次性过量补充;默认设置为窗口的2倍
local function clampDelta(delta, window)
-- 防御性钳制Δt,避免因长时间空窗一次性补太多令牌
if delta < 0 then
return 0
end
local maxDelta = window > 0 and (window * 2) or 0 -- 上限:窗口的2倍
if maxDelta > 0 and delta > window then
return maxDelta
end
return delta
end
-- 计算并消费令牌
local function tryConsume(bucketKey, windowMillis, maxAttempts)
-- 返回 true 表示允许;返回 false 表示拒绝
if bucketKey == nil or bucketKey == '' or windowMillis <= 0 or maxAttempts <= 0 then
return true -- 该维度未启用或配置非法:直接通过
end
-- 桶容量(突发上限)
local capacity = maxAttempt
-- 平均令牌生成速率(每毫秒)
local ratePerMs = maxAttempts / windowMillis
local lastMs = redis.call('HGET', bucketKey, 'last_ms')
local tokens = redis.call('HGET', bucketKey, 'tokens')
if not lastMs then
-- 冷启动:初始化为满桶,能支撑首波合理突发
lastMs = nowMillis
tokens = capacity
end
-- 计算距离上次更新的时间间隔
local delta = clampDelta(nowMillis - lastMs, windowMillis)
-- 本次可补充的令牌数
local refill = delta * ratePerMs
-- 桶中令牌不能超过容量
tokens = math.min(tokens + refill, capacity)
if tokens >= 1.0 then
tokens = tokens - 1.0
-- 更新桶状态(令牌数与最后更新时间)
redis.call('HSET', bucketKey, 'tokens', tokens)
redis.call('HSET', bucketKey, 'last_ms', nowMillis)
-- TTL:窗口*2 + 随机抖动(窗口/10),降低集中过期导致负载尖峰
local ttl = windowMillis * 2 + math.random(0, math.max(1, math.floor(windowMillis / 10)))
redis.call('EXPIRE', bucketKey, ttl)
return true
else
-- 令牌不足:仍更新 last_ms 与 tokens,保证后续能按速率继续补充
redis.call('HSET', bucketKey, 'tokens', tokens)
redis.call('HSET', bucketKey, 'last_ms', nowMillis)
local ttl = windowMillis * 2 + math.random(0, math.max(1, math.floor(windowMillis / 10)))
redis.call('EXPIRE', bucketKey, ttl)
return false
end
end
-- 先按IP维度判定(若启用)
local ipAllowed = tryConsume(ipKey, ipLimitWindowMillis, ipLimitMaxAttempts)
if not ipAllowed then
return CODE_IP_EXCEEDED
end
-- 再按用户维度判定(必须)
local userAllowed = tryConsume(userKey, userLimitWindowMillis, userLimitMaxAttempts)
if not userAllowed then
return CODE_USER_EXCEEDED
end
-- 两个维度均允许
return CODE_SUCCESS