Redis + Lua 实现秒杀优惠券 IP + 用户双维度令牌桶限流

场景引入

事例代码在最后

在优惠券秒杀场景中,活动开始时可能有大量用户同时点击

活动开始

↓

大量用户同时点击抢券

↓

大量请求同时到达服务器

↓

秒杀接口瞬间产生高并发

如果所有请求都进入后面的业务逻辑:

请求

↓

查询优惠券

↓

查询库存

↓

创建订单

↓

扣减库存

↓

数据库事务

这就会导致同一时间有大量请求打到数据库,因此可以在秒杀接口的前面一层引入限流

为什么要使用令牌桶

按固定速率产生令牌

↓

┌───────────┐

│ 🟢 🟢 🟢 │

│ 🟢 🟢 🟢 │

│ 🟢 🟢 │

└───────────┘

↓

秒杀请求

↓

消耗一个令牌

每个秒杀请求到达后,都需要从桶中获取一个令牌,请求才可以继续往下执行,这样就可以过滤掉大量请求

为什么要做 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
相关推荐
蜗牛互联网6 小时前
WSL Containers GA:本地AI容器的生命周期、网络与治理验收
java·网络·人工智能·后端
Zhou1411367 小时前
SpringBoot_02_自动配置原理
java·spring boot·后端
子兮曰8 小时前
Node.js 50个优势场景盘点:一个人单干,为啥我多数时候只用它
前端·后端
IT_陈寒8 小时前
我花3小时debug,就因为JavaScript这个隐式转换坑
前端·人工智能·后端
程序猿阿越8 小时前
containerd如何拉取镜像
后端·kubernetes·源码阅读
一条小小yu8 小时前
为什么使用springboot
java·spring boot·后端
香瓜子rd8 小时前
MySQL InnoDB 并发控制核心原理:事务、隔离级别、MVCC 与锁机制
数据库·后端
RISCV_Explorer8 小时前
RISC-V RVV向量编程模型机制解析——vsetvl配置、掩码与尾元素策略及长度无关编程
后端·risc-v
对象存储与RustFS9 小时前
升级 RustFS 二进制不停机:一条一条换,留一条退路
后端·rust·开源
hasty9 小时前
限制写了却没生效:OpenTelemetry Go 的 Unicode 截断边界
开发语言·后端·golang