设计秒杀系统:库存扣减与流量削峰

FreeGuideOnline 最新 2026-06-18

设计秒杀系统:库存扣减与流量削峰

为什么秒杀系统需要特殊设计?

普通电商系统的下单流程是:用户浏览商品 → 加入购物车 → 提交订单 → 锁定库存 → 支付。这套流程在请求量突然暴增数百倍时,会直接击垮数据库和应用服务器。秒杀场景的核心矛盾在于极其有限的商品数量海量并发请求之间的冲突。如果让所有请求都直接触及数据库,就会发生超卖、数据库连接耗尽、系统崩溃等问题。

因此,秒杀系统设计的核心可以归纳为两个技术目标:正确扣减库存(不超卖)平滑流量削峰(不崩溃)


理解库存扣减的三种方式

库存扣减是整个系统的命脉,直接决定是否能做到不超卖。常见的扣减方式有三种,我们将逐一分析其优缺点。

1. 纯数据库乐观锁

这是最易实现的方案,即利用数据库的版本号或库存字段自身作为条件进行更新。

update product set stock = stock - 1
where id = #{productId} and stock >= 1;

如果受影响的行数为 0,说明库存不足或已被其他请求抢先扣减。这种方式保证了在数据库层面操作的原子性,但问题在于所有请求的写压力都会集中到数据库的同一行记录(热点行)。InnoDB 引擎对这一行加行锁,大量请求会在此排队等待行锁释放,数据库连接池迅速被占满,从而导致整个服务不可用。因此,单纯在数据库做乐观锁无法承载秒杀级流量。

2. Redis 缓存扣减

将库存预先加载到 Redis,利用 Redis 的单线程特性和高性能来扣减库存。

# 使用 DECR 原子操作扣减库存
DECR stock:product:1001

如果返回结果小于 0,则表示库存已卖完。Redis 单线程处理命令,天然避免了并发超卖问题,且 QPS 远超数据库。但纯 Redis 方案存在数据一致性问题:如果 Redis 宕机且没有及时持久化,就有已扣减库存丢失的风险。此外,实际库存数据最终仍需写入数据库,需要在某个环节同步。

3. Redis + 数据库组合(推荐方案)

这是工业界常用的方案:先在 Redis 中完成快速扣减,再通过异步方式将最终订单数据写入数据库

流程如下:

  1. 活动开始前,将秒杀商品的库存全量写入 Redis,例如 SET stock:pid:1001 100
  2. 用户请求到达后,先通过 Lua 脚本一次性完成“判断库存 + 扣减库存”操作,保证原子性。
  3. 如果扣减成功,生成订单消息发送到消息队列,由异步消费者负责订单落地、数据库最终扣减。
  4. 如果扣减失败,直接返回“已售罄”。

这种组合既利用了 Redis 扛住瞬时高并发,又通过消息队列保证了数据最终一致性和系统的削峰能力。


Redis 原子扣减实战:使用 Lua 脚本

仅仅调用 DECR 有时不够,因为业务上可能需要同时扣减多个商品的库存,或者需要先检查库存再扣减。Lua 脚本可以将多条命令打包成一个原子块在 Redis 服务端执行。

下面是一段 Lua 脚本示例,它检查某个商品的剩余库存,如果足够就扣减并返回剩余库存;如果不足则返回 -1。

-- 脚本名: deduct_stock.lua
-- KEYS[1] 是库存的键名,例如 stock:1001
-- KEYS[2] 是已购买用户集合的键名,用于防止重复购买(可选)
-- ARGV[1] 是用户ID
-- ARGV[2] 是请求购买数量,秒杀通常为1

local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) < tonumber(ARGV[2]) then
    return -1  -- 库存不足
end

-- 可选:判断用户是否已经抢购过
local is_repeat = redis.call('sismember', KEYS[2], ARGV[1])
if is_repeat == 1 then
    return -2  -- 重复购买
end

-- 扣减库存
local remain = redis.call('decrby', KEYS[1], ARGV[1])
-- 将用户ID记入已购买集合
redis.call('sadd', KEYS[2], ARGV[1])
return remain

在 Java 应用中结合 Spring Data Redis 执行:

Long result = redisTemplate.execute(
    new DefaultRedisScript<>(luaScript, Long.class),
    Arrays.asList("stock:1001", "bought:users:1001"),
    userId, "1"
);
if (result == -1) {
    // 返回“卖完了”
} else if (result == -2) {
    // 返回“您已抢购过”
} else {
    // 扣减成功,发送下单消息到队列
}

通过 Lua 脚本,我们将“检查库存、扣减库存、防重校验”合并成一个原子单元,这是秒杀系统的第一道防线


流量削峰的四大策略

仅仅处理好库存扣减还不够。大量请求一瞬间涌入,如果后端没有保护机制,再强的 Redis 也可能被网络连接耗尽CPU 打满。我们需要在不同层面削峰。

策略一:前端限流与防刷

点击按钮置灰与倒计时: 确保用户在活动开始前无法提交请求,开始后也要限制点击频率。一旦用户点击“立即秒杀”,按钮立刻变灰,并且 5 秒内禁止再次点击。这能过滤掉大部分因用户反复点击产生的重复请求。

验证码与答题: 在进入秒杀页面前引入滑动验证码或简单算术题。这不仅能过滤机器人脚本,还能人为增加用户参与的时间成本,将瞬间请求在时间轴上拉平。

策略二:网关层令牌桶限流

在高性能网关(如 Nginx、Kong、APISIX)上,对秒杀接口进行精准的流量控制。常见算法是令牌桶,可以配置为每秒只允许通过 N 个请求,多余的请求直接返回“活动太火爆,请稍后再试”,避免请求进入后端服务。

Nginx 使用 limit_req 模块:

location /seckill/submit {
    limit_req zone=seckill burst=100 nodelay;
    proxy_pass http://backend_seckill;
}

这样后端最多只需要处理 burst 大小的请求峰值,其余均被拒绝在下游。

策略三:业务层异步化与消息队列

秒杀成功的请求,并不需要立即完成所有订单逻辑。我们将“扣减库存成功”这一个信号作为事件,快速写入消息队列(如 RocketMQ、Kafka),即可响应用户“抢购成功,订单处理中”。订单入库、积分增加、短信通知等耗时长且不紧急的任务,全部由消费者异步处理。

消息队列本身就是一种天然的削峰填谷工具。它可以暂存大量消息,下游消费者以自己所能承受的速度平稳消费,从而保护数据库。

策略四:动静分离与 CDN

秒杀页面中,商品图片、详情介绍等静态资源占比很大。将页面全面静态化,并推送到 CDN 节点,可以让大部分流量被 CDN 承载,到达源站的仅剩动态的抢购接口请求。甚至在 CDN 边缘通过边缘计算(Edge Function)就可以返回“活动未开始”或“已售罄”页面,进一步减轻源站压力。


架构全景图与数据流

将上述组件组合起来,形成一个完整的秒杀系统链路:

  1. 用户端 → 经过 CDN 加载静态页面,点击秒杀按钮(防重、答题)。
  2. 网关层 → 令牌桶限流,拒绝超量请求。
  3. 应用层 → 执行 Lua 脚本操作 Redis,完成原子库存扣减和用户资格校验。
  4. 判断结果 → 如果扣减失败,返回失败信息;如果扣减成功,生成预订单消息发送至 RocketMQ。
  5. 异步处理 → 订单消费者依次消费消息,校验数据,完成数据库库存最终扣减和订单状态更新。
  6. 客户端轮询 → 用户端展示“订单处理中”,通过轮询或 WebSocket 获取最终订单状态(已确认、已取消)。

这种设计下,热点数据在 Redis,业务处理异步化,数据库只作为最终持久化存储,各个组件压力分离,整个系统可以平稳支撑极高并发。


常见坑点与进阶防范

1. Redis 热点 Key 问题

秒杀商品的 Redis 键会被所有请求集中访问,形成热点 Key。如果单实例 Redis 无法支撑,可以采用读写分离Key 分散的方案。例如,将库存拆分为多个分片 Key:stock:1001:1stock:1001:2...,请求随机路由到一个分片扣减,总库存通过各分片之和计算。不过这增加了业务复杂度,需要权衡。

2. 库存回补与取消订单

如果用户抢购成功但未在 15 分钟内支付,需要释放库存。释放时不能简单地将库存加回 Redis,因为活动可能已结束,加回会导致超卖。更可靠的做法是:支付超时后,由消费者发送库存补偿消息,通过 Lua 脚本检查活动是否结束,再决定是否回补。

3. 数据一致性保障

Redis 和数据库的库存数据可能出现短暂不一致。建议以数据库的最终数量为准,同时在后台设立对账任务,定时对比 Redis 与数据库的已售数量,发现差异时进行告警并补偿。


你学到了什么

通过本教程,你系统掌握了秒杀系统的两大核心:库存扣减流量削峰。现在你可以回答以下问题:

  • 为什么不用数据库直接扣库存?
  • Redis 扣减库存时如何保证原子性?
  • 如何利用前端、网关、消息队列分层削峰?
  • 完整的秒杀请求经历了哪些处理环节?

动手实践时,请先从单体应用 + Redis Lua 脚本开始,逐步引入消息队列和网关限流,体验每一步带来的并发能力提高。这是构建高并发系统的必经之路。