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
相关推荐
Shinomiya1 小时前
虚拟地址空间:从“地址一样但物理内存不同”说起
后端
孙启超1 小时前
【AI开发之Rust】第 1 课:认识 Rust 与开发环境 —— 装好工具链,跑通第一个程序
开发语言·人工智能·后端·ai·rust·安全架构
Bs_MoneyMagnet2 小时前
基于springboot+vue的宠物殡葬管理平台的设计与实现 源码+文档
java·javascript·vue.js·spring boot·后端·宠物
萧瑟余晖2 小时前
JPA 规范与 Hibernate 入门详解
后端·架构·hibernate
程序员cxuan3 小时前
跟 WebUI 说再见了,最强 DeepSeek 桌面端来了!
人工智能·后端·程序员
geovindu3 小时前
CSharp: Strategy Pattern
开发语言·后端·c#·.net·.netcore·策略模式·行为模式
stark张宇3 小时前
从 Paxos 到 Raft:一文讲透分布式一致性与复制状态机
分布式·后端
RsThe3 小时前
Spring AI 2.0:Java AI 开发的分水岭
后端
n8n3 小时前
Spring AI 工具调用实战:模型自主决策、@Tool 方法封装与 ReAct 模式
后端