秒杀系统这东西,说难听点,就是把自己写的代码放到火上去烤。做过一次你就明白,它考验的根本不是你会不会写CRUD,而是面对瞬时高并发流量时,你的架构能不能稳住、数据能不能保底。本文基于Spring Boot微服务架构,拆解电商秒杀系统的设计思路和落地过程,覆盖库存预热、Redis+Lua原子扣减、MQ异步削峰、限流防刷等核心环节,适合想踏踏实实做一次高并发项目的Java后端同学参考。
很多朋友一听到“秒杀”两个字,第一反应就是“加个Redis、搞个队列、库存扣减别超卖”就完了。真做起来才发现,每一条线上流量背后都有无数细节:用户疯狂点按钮怎么防?库存扣减和订单创建不一致怎么处理?Redis挂了怎么办?消息丢了能不能接受?这些问题单靠某个中间件是解决不了的,得从整体架构设计层面去拆。这篇文章就把我实际做这套系统时的整体思路、模块划分、技术选型和踩坑过程完完整整写出来,争取让每个看完的人都能动手复现一个能扛得住压力的秒杀系统。
1. 秒杀场景为什么这么难:核心难点与微服务拆分依据
1.1 秒杀流量的本质:短时超高并发下的资源争抢
先别急着写代码,把问题看透了再动手。秒杀和普通购物完全不同,它把一整天的用户量压缩到几秒钟内集中爆发,这就带来三个核心矛盾:
第一是瞬时流量峰值极高。一瓶茅台只放1000件库存,结果来了50万用户抢,可能有几百万请求砸进来。单机Tomcat默认配置顶多扛几百并发,这还只是硬件层面的压力,更别提每个请求背后还要查库存、建订单、扣库存、更新商品信息。
第二是热点数据高度集中。所有请求都在抢同一个SKU、同一行库存记录,数据库里那一行会出现严重的行锁竞争。MySQL的UPDATE同一行记录时,后面的请求全部排队等待,吞吐量可以瞬间掉到个位数。
第三是业务状态必须强一致。1000件库存卖出去了1001件,那就是事故。库存不能超卖,订单不能重复,支付和库存扣减必须对得上。在分布式环境下,这个一致性保证的复杂度比单体应用高了好几个量级。
所以秒杀系统的设计本质就是在回答一个问题:如何在几秒钟内,用有限的资源,安全地处理海量的用户请求。这里没有“银弹”,必须靠架构策略把大流量切成小流量,把同步操作变成异步操作,把高代价操作变成低代价操作。
1.2 单体架构为什么扛不住:不只是性能问题
很多人问,为什么不能直接在原来的单体应用上做秒杀?如果业务量不大、并发几十,单体确实足够。可一旦流量上来,单体架构的瓶颈是全方位的:
- 所有模块部署在一起,秒杀接口的高并发会把用户注册、商品列表、订单查询这些接口也拖垮,互相干扰。
- 数据库压力无法隔离,所有业务共用同一个连接池和表空间,慢SQL扩散全站。
- 无法独立扩容,你想单独给秒杀模块加机器?不行,要么整体多部署几套,要么就一起扛。
- 发布风险集中,一个接口频繁改动,全系统都得跟着重新部署。
微服务架构的价值恰恰在这里:它不只是把代码拆开,更是把资源、容错边界和扩展单元都拆开了。秒杀服务扛不住时可以单独扩容,库存服务走独立的Redis集群,用户服务和订单服务互相隔离,任何一个服务出了故障都不会让整个系统瘫痪。
1.3 服务拆分粒度:按业务域而不是按代码层
我在设计这个秒杀系统时,没有按照Controller、Service、DAO这种代码层去拆,而是按照业务域边界来拆,这也是微服务拆分最核心的原则。最终拆出来这几个服务:
| 服务名 | 职责 | 关键依赖 |
|---|---|---|
| gateway-service | 统一入口,路由、限流、用户身份识别 | Nacos, Sentinel |
| user-service | 用户信息、登录态校验、用户等级 | MySQL, Redis |
| product-service | 商品信息、秒杀活动配置、库存预热 | MySQL, Redis |
| seckill-service | 秒杀核心逻辑:资格校验、预扣库存、发送MQ | Redis, RabbitMQ |
| order-service | 订单创建、订单状态流转、超时关单 | MySQL, RabbitMQ |
| stock-service | 库存操作、库存流水记录、回补库存 | MySQL, Redis |
每个服务独立数据库,独立部署,独立扩缩容。服务之间通过OpenFeign接口调用,异步场景通过RabbitMQ解耦。这里有个经验:不要一上来就拆十几个服务,拆得太细会带来巨大的运维和通信成本。我第一次做的时候就是拆了12个服务,结果大部分时间都花在联调和维护接口文档上了。合理粒度应该是“一个业务域一个服务,能内聚的别拆开”,比如商品和秒杀活动我一开始就是分开的两个服务,后来发现它们的数据关联太紧密,最后又合并到了product-service里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型分析:围绕Spring Boot的微服务生态
2.1 基础框架和微服务组件选择
整个系统的基底选择了Spring Boot 2.7.x,配套Spring Cloud Alibaba 2021.x。为什么选这套组合?理由很简单:生态成熟、国内资料多、中间件集成方便。
- Nacos:充当注册中心和配置中心。选它而不是Eureka,是因为Nacos同时支持服务注册发现和动态配置,省去了再单独部署配置中心的成本。
- Gateway + Sentinel:网关层做统一路由和入口限流,Sentinel负责服务级别的熔断降级。这套组合在Spring Cloud Alibaba体系里集成度最高,注解、控制台都现成。
- OpenFeign:服务间同步调用,用于查询用户信息、活动信息这种非关键路径。
- RabbitMQ:异步消息队列,承担秒杀请求的削峰和订单创建的异步化。
- Redis:库存预减、接口防重、分布式锁,另外用Redis Stream做消息队列的备选方案也验证过。
这里我得特别说一下中间件选型的教训。一开始Redis和数据库的部署都是单机,压测到4000并发时Redis连接直接被打满,Redis服务端CPU跑到90%以上。后来换成Redis Cluster 3主3从,并且优化了连接池配置,才把瓶颈挪到了服务本身。如果你的项目预算和机器资源有限,至少也要把Redis独立部署,千万别和数据库堆在同一台机器上,否则互相抢CPU和内存,压测数据会非常难看。
2.2 核心方案一:Redis + Lua脚本做原子库存扣减
秒杀系统的技术核心,就在库存扣减这一环。最直接的做法是让请求打到数据库上执行UPDATE stock SET count = count - 1 WHERE count > 0,这种方式数据绝对安全,但扛不住高并发——数据库行锁就是最大的瓶颈。
提升方案是Redis预减库存:先把库存加载到Redis,每次请求用DECR命令扣减,扣到0就返回秒杀结束。这个方案性能高,但存在原子性问题:如果判断库存大于0和扣减库存是两个独立操作,并发请求下可能出现多个线程同时判断都通过,最后扣成负数,导致超卖。
解决方法是把“检查库存 + 扣减库存”写进一个Lua脚本,让Redis保证整个流程的原子性:
lua复制-- 检查库存并扣减
if redis.call('get', KEYS[1]) <= 0 then
return -1
end
local stock = redis.call('decr', KEYS[1])
if stock < 0 then
-- 扣减后为负,回滚
redis.call('incr', KEYS[1])
return -1
end
return stock
这段脚本通过redisTemplate.execute(script, keys, args)调用即可。用Lua而不是在Java代码里做“先查后减”,是因为Redis单线程执行脚本的机制保证了这段逻辑不会被打断。这个设计是整个秒杀系统防超卖的第一道防线,也是最核心的一道。
2.3 核心方案二:消息队列削峰填谷
光有Redis预扣库存还不够,因为扣减成功只是说明“你有资格买”,真正的订单创建、库存流水落库、订单发票异步处理这些操作如果全部同步执行,数据库瞬间就会被写请求击穿。所以下一层必须削峰。
我的设计是:Redis预扣库存成功后,秒杀服务把用户ID、商品ID、活动ID封装成消息,发送到RabbitMQ的秒杀队列,然后立即给前端返回“秒杀成功,等待订单确认”。order-service异步消费消息,真正创建数据库订单,再通过WebSocket或者客户端轮询通知用户订单状态。
这里有两个容易出问题的细节:
- 消息不能丢。生产端要开启
publisher-confirm确认机制,消费端关闭自动ACK,改为手动ACK,处理成功才能确认消息,失败则重新入队。 - 消费端要做幂等。MQ的消息可能重复投递,消费端创建订单前先查幂等表(用户ID + 活动ID + 商品ID联合唯一索引),防止同一用户因为消息重复而生成多个订单。
RabbitMQ在这一层的削峰效果非常明显。我用JMeter做了对比测试,同一台8C16G的机器,同步创建订单时数据库连接池直接被打满,大量请求报错;改为MQ异步后,数据库压力降了80%,虽然下单请求的响应时间变长了一点,但系统整体吞吐量上去了几个数量级。
2.4 核心方案三:接口幂等、限流和防刷
秒杀系统面向的是全网用户,其中夹杂着大量黄牛脚本和机器请求。如果所有请求都打到业务层,再多的资源也会被耗尽。因此在网关层和业务层必须布下多道拦截机制:
- 网关层限流:基于Sentinel的路由规则,对/IP维度设置QPS阈值,超过直接返回“活动过于火爆”。一般单IP的QPS限制设为1~2就够真人用户使用了。
- 业务接口防重:同一个用户同一场秒杀只能提交一次。我这里用“用户ID + 活动ID”生成唯一业务流水号,每次请求先判断Redis里是否已存在,存在则直接拦截。
- 隐藏秒杀地址:秒杀接口的URL不直接暴露,先请求后端获取一个带时效的动态地址,有效期内才能访问真实秒杀接口。这个方案能有效挡住一部分外部批量脚本。
- 图形验证码或滑块:前端在提交秒杀请求前先完成验证码验证,服务端记录验证码的校验状态。虽然不是绝对防御,但能过滤掉大量低端自动化脚本。
这里有个小坑,防重和限流不是越严格越好。你限制太狠,正常用户也会被误伤。实际做的时候建议把限制参数做成可配置的(放在Nacos配置中心),上线前先小范围压测观察误杀率,再调整阈值。
3. 系统实现细节:从库存预热到下单完成的完整流程
3.1 秒杀活动配置与库存预热
秒杀不是商品上架就直接开卖,它需要一个独立的活动管理体系。后台运营创建活动时,需要配置活动开始时间、结束时间、参与秒杀的商品和库存数量,系统在活动开始前自动把库存加载到Redis。
库存预热这一步有个容易忽视的问题:不能只把商品总库存塞进Redis就完事。我当时的做法是给每个商品生成一个独立的库存Key,比如seckill:stock:{activityId}:{productId},同时还要维护一份活动商品列表,方便秒杀开始前快速校验用户请求的商品是否在活动范围内。
预热后要做库存校验,确认Redis里的库存数字和数据库一致。如果预热期间Redis宕机或者Key丢失,等活动一开始所有请求都会判定“无库存”,那才是大事故。所以我在预热完成后加了校验任务,定时比对Redis库存和数据库库存的差值,发现偏差立刻告警并重新加载。
3.2 秒杀请求的完整链路流转
整体链路我从请求进来到最终订单落库,一步步拆开给你们看:
code复制客户端 -> Nginx/LVS -> Gateway网关 -> Sentinel限流 -> seckill-service
-> 用户登录校验 -> 活动时间校验 -> 商品库存校验(Redis)
-> Redis Lua预扣库存 -> 发送MQ消息
-> 返回"秒杀中..."
order-service消费MQ -> 幂等校验 -> 创建订单(状态为待支付)
-> 扣减数据库库存 -> 写入库存流水 -> 标记秒杀成功
-> 通知用户支付
这条链路里,客户端拿到的不是最终结果,而是“抢购请求已受理”。真正的结果由异步流程最终确定。这种“同步收单、异步处理”的思路,是所有高并发交易系统的通用解法。你不需要在秒杀请求的同步链路里面完成所有业务,只需要保证请求不丢失、消息可靠投递、异步流程最终一致。
3.3 核心代码实现:秒杀接口与Lua扣减
下面我把秒杀接口和库存扣减的核心代码列出来,供参考。代码不追求完整,重点是表达思路。
秒杀接口入口:
java复制@PostMapping("/seckill/{activityId}/{productId}")
public Result<String> seckill(@PathVariable Long activityId,
@PathVariable Long productId) {
Long userId = UserContext.getUserId();
// 1. 活动时间校验
if (!activityService.isInProgress(activityId)) {
return Result.error("活动未开始或已结束");
}
// 2. 幂等防重校验:是否已参与该活动
if (redisTemplate.hasKey(SeckillKey.seckillUserKey(activityId, userId))) {
return Result.error("请勿重复下单");
}
// 3. Redis库存预扣
Long stock = seckillService.deductStock(activityId, productId);
if (stock == null || stock < 0) {
return Result.error("已抢光");
}
// 4. 记录用户参与标记
redisTemplate.opsForValue().set(SeckillKey.seckillUserKey(activityId, userId), "1");
// 5. 发送异步下单消息
mqProducer.sendSeckillMessage(activityId, productId, userId);
return Result.success("正在排队抢购,请稍后查看结果");
}
Lua脚本封装在SeckillService中:
java复制public Long deductStock(Long activityId, Long productId) {
String stockKey = "seckill:stock:" + activityId + ":" + productId;
String script = "if redis.call('get', KEYS[1]) <= 0 then return -1; " +
"end; local stock = redis.call('decr', KEYS[1]); " +
"if stock < 0 then redis.call('incr', KEYS[1]); return -1; " +
"end; return stock;";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
return redisTemplate.execute(redisScript, Collections.singletonList(stockKey));
}
注意第4步,用户参与标记是在预扣库存成功之后才写入的。这个顺序很重要:先扣库存再标记用户,就算标记成功但MQ发送失败,也不会出现有库存但用户标记了的情况。库存回补机制会兜底处理。
3.4 订单异步处理与库存回补机制
订单服务消费MQ时,需要认真处理失败场景。如果订单创建过程中抛异常,数据库库存已经被扣减了,那必须做库存回补,否则就会出现库存被扣但订单不存在的资产流失问题。
我的回补策略分两层:
第一层是“消费失败自动重试”。RabbitMQ的消费者在手动ACK模式下,业务逻辑抛异常就返回basicNack并重新投递。配置里设置最大重试次数为3次,超过3次进入死信队列,由定时任务扫描死信队列做人工干预或补偿。
第二层是“定时任务对账”。每5分钟跑一次任务,扫描秒杀标记成功但15分钟内没有生成有效订单的记录,主动触发补偿。补偿操作包括:释放Redis中预留的库存,清除用户参与标记,记录补偿流水。这里就用到Redis中另一个Key——用户参与标记的过期时间设置为15分钟,就是为了配合对账逻辑。
这套机制跑下来,最终一致性是有保障的:Redis是快速筛选层,决定谁有资格;MySQL是最终事实层,决定谁真正拿到了订单。两者通过MQ和定时任务不断对账,确保最终一致性。
4. 压测实战与性能调优记录
4.1 压测方案设计
系统写完不是直接上线,得先过压测这一关。我的压测环境是三台8C16G的虚拟机:一台部署Gateway和Seckill服务,一台部署Redis和RabbitMQ,一台部署MySQL和Order服务。压测工具用JMeter,模拟5000个用户同时发起请求。
压测关注的核心指标有几个:TPS(每秒事务数)、平均响应时间、错误率、系统资源使用率。我第一轮压测的结果很一般:TPS只有680,平均响应时间380ms,错误率高达30%。数据库服务器CPU直接爆到100%,MySQL连接数耗尽。
4.2 瓶颈定位与调优手段
第一轮压测的瓶颈非常明显,数据库撑不住了。定位方式很简单:看监控面板,订单服务日志里大量Connection is not available异常,MySQL的show processlist看到几百条UPDATE语句在排队执行。
针对这些瓶颈,我做了下面几项调优:
| 瓶颈点 | 触发原因 | 优化方案 |
|---|---|---|
| 数据库连接池打满 | HikariCP默认最大连接数10,高并发下完全不够 | 调整maximum-pool-size到50,并合理设置connection-timeout |
| Tomcat线程阻塞 | 默认线程池200,大量请求在等待数据库连接 | 调整server.tomcat.threads.max=500,并开启异步Servlet支持 |
| SQL执行慢 | 库存扣减SQL锁竞争激烈 | 优化SQL走主键索引,让更新操作尽量短事务 |
| Redis连接数不足 | Lettuce连接池默认配置偏低 | max-active=100,max-idle=50 |
除了这些配置项的调整,一个更关键的优化是把“数据库库存扣减”和“订单创建”合并为一个事务操作,事务内只执行必要的SQL,不在事务里调用外部接口或者做Redis操作。很多团队的事务里动不动就调远程接口,一个慢接口能拖死整个事务,这个毛病一定要改。
第二轮压测TPS提升到3800,平均响应时间降到95ms,错误率控制在0.5%以内。数据库连接池使用率稳定在70%左右,CPU虽然有波动但没有持续满载。
4.3 高可用保障与降级方案
压测通过不代表线上不会出问题。秒杀场景最怕的是Graceful degradation变成了Graceful死亡——系统崩溃。所以我在设计时就留好了降级预案:
- 缓存降级:如果Redis在秒杀进行中挂了,网关层直接把所有秒杀请求转发到静态的“活动已结束”页面,同时触发数据库库存快照校验,防止超卖。
- 服务熔断:Seckill服务调用Order服务的Feign接口如果连续失败达到阈值,Sentinel直接熔断,不再等待超时,快速返回“系统繁忙”。
- 动态限流:让限流规则支持Nacos动态修改,如果发现某个接口的流量异常,可以在不重启服务的情况下直接把QPS阈值降到更安全的水平。
- 独立部署隔离:秒杀服务、Redis、MQ和核心业务系统分开部署,物理隔离,避免秒杀流量打垮其他业务。
这里说个我真实的教训:上线第一年做跨年活动,因为我没有给Seckill服务做独立的线程池隔离,秒杀流量直接把整个服务线程池占满,导致同一个服务里的内部管理接口也全部超时,后台想操作活动状态都没办法。后来我为秒杀接口单独设置了一个ThreadPoolTaskExecutor,并配置了独立的拒绝策略,才好了一些。
5. 避坑指南:秒杀系统开发中那些容易忽视的坑
5.1 超卖问题:不只在库存扣减环节
说到超卖,很多人第一反应是“Redis预扣库存已经解决了”。但真正的超卖隐患藏在后面的异步下单环节里。订单服务消费MQ后,执行UPDATE stock SET sold = sold + 1 WHERE product_id = ?时,如果并发控制没做好,依然可能超卖。
我的做法是:在MySQL的秒杀商品表中增加一个version乐观锁字段,更新时加上WHERE version = ?,更新成功后版本号加1。如果更新影响行数为0,说明数据被其他线程改过了,直接判定当前请求失败并触发库存回补。Redis防超卖是第一道防线,数据库乐观锁才是最终兜底。
5.2 缓存穿透、击穿和雪崩
秒杀场景里缓存问题特别容易集中爆发:
- 缓存穿透:用户频繁请求一个不存在的商品ID,每次都打到底层数据库。解决方案是布隆过滤器,把所有有效商品ID提前加载进去,或者对空值做短时间缓存。
- 缓存击穿:某个热点商品的缓存Key在秒杀开始瞬间过期,大量请求同时涌入数据库加载。给热点Key的过期时间加随机值,或者使用互斥锁让同一个Key只允许一个线程去加载数据库。
- 缓存雪崩:大量Key在同一时间过期。处理方案是过期时间加随机数,让过期时间在基础值上分散,同时Redis集群要高可用。
我见过一个团队在秒杀开始前一小时把商品信息全部缓存到Redis,设置统一过期时间30分钟,结果秒杀还没结束,缓存全过期了,数据库直接被原生态流量打曝。这种错误只要提前做个过期时间随机化就能避免。
5.3 MQ消息丢失和重复消费
RabbitMQ在秒杀场景里是核心组件,消息安全至关重要。投递、存储、消费三个阶段都可能丢消息:
- 投递阶段:生产端开启
publisher-confirm,发送失败可以重发。 - 存储阶段:队列持久化和消息持久化都设置为
true,RabbitMQ重启消息不丢。 - 消费阶段:关闭自动ACK,消费成功才手动确认,失败则重回队列。
重复消费的问题我在前面提过,用幂等表就能解决。这里建议幂等表的设计不仅要有唯一索引,还要记录消息的唯一消息ID,方便排查问题的时候追踪。
5.4 微服务间事务一致性
秒杀系统跨了多个服务,天然存在分布式事务问题。一个完整的秒杀流程涉及库存预扣、订单创建、库存流水记录、支付状态变更,跨越Seckill服务、Order服务、甚至Payment服务。有团队一开始想用Seata做分布式事务,我发现性能损耗和复杂度都太高,秒杀场景根本不合适。
更务实的方案是本地消息表 + 最终一致性:秒杀服务在本地事务里记录一条“待发送消息”记录,然后通过后台任务把消息发送到MQ,MQ消费成功后回写消息状态。只要消息表不丢数据,最终的订单和库存一定是一致的。这套方案虽然实现起来比Seata多写一些代码,但压测性能很稳。
5.5 复盘一次线上事故:活动还没开始库存就没了
最后分享一个我实打实踩过的坑。有一天凌晨上线活动,运营配好活动后跟我说“系统显示库存不足”,一开始我以为是缓存预热没成功,检查后发现预热正常,数据库库存也正常。定位半天,发现是测试环境有人提前调用了秒杀接口,把Redis里的库存扣到了0,因为测试环境和生产环境共用了一套Redis集群。
这暴露的问题是环境隔离没做好。后来我强制要求:所有中间件必须按环境隔离,至少Redis和MQ要区分独立的实例;秒杀接口上线后要加一道开关,只有运营手动开启活动才允许请求进来;后台要多加一层“活动审核通过”的状态校验,没审核的活动的秒杀请求全部拒绝。这个教训听起来简单,但在实际团队里很常见,希望大家引以为戒。
做秒杀系统这几年,我最大的感受就是:高并发不是靠某一个点上的奇技淫巧,而是靠整条链路的层层设防。Redis挡住热点流量,MQ削峰填谷,数据库保证最终一致,限流和降级保证系统不被打垮,每一层都有自己的使命,少一环都不行。按照这篇文章的思路走一遍,你也能搭出一个能扛住压力的架构。过程中遇到的细节问题,欢迎留言交流,我看到都会回复。
