☰
SpringBoot秒杀系统架构:Redis预扣库存与MQ削峰实战
2026/10/2 22:22:15 网站建设 项目流程

简介:基于SpringBoot的电商基础秒杀项目,是一份面向毕业设计、课程设计、工程实训及学科竞赛的完整工程源码包,适合有一定Java Web基础、希望快速搭建秒杀业务场景的学习者。压缩包共2000个文件,涵盖Java后端源码(40个java)、前端页面与样式(1240个js、357个css、148个html)、项目配置与文档(34个txt、34个json、27个xml、113个md),以及少量go、py、sh等辅助脚本,总计约53.91MB,目录结构清晰,便于按模块查阅。资源经严格测试运行,功能正常,答辩评审平均分96分,可直接复现项目效果,也可作为设计报告或扩展开发的参考蓝本。已有43人学习下载,适合用于项目起步、课程作业或竞赛备赛,下载后建议先查看说明文件,如有使用问题可联系作者获得解答。

1. 电商秒杀项目的真实价值:不是“会写接口”,而是“扛得住并发”

把 SpringBoot 电商秒杀项目当成普通 CRUD 来做,答辩和压测基本都会翻车。秒杀这个场景下的所有技术选型,都冲着同一个问题去:几千人同时抢一个 SKU,怎么保证不超卖,还能让数据库不被打挂。这个标题里的项目,表面上是一套基于 SpringBoot 的商城代码加上一个秒杀模块,本质上是高并发、缓存、消息队列、幂等控制和事务一致性的组合练习题。适合做毕设、课设、实训、大作业或者竞赛的人把它当骨架,再按自己的业务改成能上台演示的完整系统。下面这些内容,就是你从拿到这套代码到能压测、能演示、能回答追问的完整路径。

2. 秒杀系统的技术拆解:为什么默认选 SpringBoot + Redis + RabbitMQ

2.1 秒杀链路的四个阶段:抢购、预扣、异步下单、超时释放

秒杀和普通下单最大的区别在时间窗:活动开始的前几秒,所有请求集中打到一个 SKU 上。你不可能在那一刻让每个请求都去写数据库,这是最直接的结论。常见的从业方案是把秒杀流程拆成四个阶段,每个阶段只解决一个问题。

第一阶段是资格校验,包括活动时间判断、用户是否已下单、商品是否上下架。第二阶段是库存预扣,用 Redis 对库存做原子扣减,保证不超卖。第三阶段是异步下单,把“抢购成功”这件事丢进消息队列,让后台消费者慢慢地创建订单、扣减数据库库存。第四阶段是超时释放,用户抢到后不在限定时间内支付,就把预扣的库存还回去。

这个链路把“高峰期的并发写”变成了“高峰期的并发读加原子扣减”,真正落库的动作被削峰到了低谷期慢慢做。你在答辩或者写文档时能讲清楚这一层,项目就立住了一半。GitHub 上这类电商项目很多,但大多数只做到 CRUD,秒杀接口还是同步扣库存,问题恰恰出在这里。

2.2 数据库扣减为什么扛不住:行锁与热点行的代价

很多人会把秒杀的难点理解成“库存不够卖”,其实真正的问题是“热点的库存那行数据被并发写”。MySQL 的 InnoDB 引擎对同一行的 UPDATE 是行锁串行执行的,不管你的连接池开多大,几千个请求同时执行UPDATE ... SET stock = stock - 1时,它们都在等同一把行锁。

表现是什么?数据库连接被占满,等待锁的请求堆积在连接池里,连接池超时,接着抛出DataAccessException,最后用户看到的是一片“系统繁忙”。此时数据库 CPU 可能并不高,问题不在计算量,而是锁等待。更麻烦的是,不带条件的扣减还会把库存扣成负数,这是后一章要展开的超卖问题,这里先记住结论:数据库行锁的串行化天然不适合承载秒杀瞬间的热点写。

那乐观锁呢?用version字段做 CAS,冲突后重试,问题是秒杀场景的重试率极高,第一次抢购冲突率可能超过 90%,重试又放大了数据库压力。悲观锁SELECT ... FOR UPDATE更不可取,锁的持有时间等于整个事务时间,平均响应时间会非常难看。数据库扣减不是不能用,而是应该放在异步消费端做兜底,而不是放在用户请求链路里做拦截。

2.3 Redis 预扣库存与 RabbitMQ 异步下单的分工

Redis 的DECR命令在单线程事件循环里执行,同一个 key 的操作天然串行,不存在两个线程同时读到旧值的情况。预扣库存时先DECR,返回值小于 0 就说明库存耗尽,这个判断是原子的。配合内存存储的特性,一个热点 key 能承受的 QPS 远超数据库一行记录,这就是秒杀接口能撑住并发的关键。

RabbitMQ 在这里的作用不是“快”,而是削峰填谷。用户请求把“创建订单”的消息丢进队列后立刻返回“排队中”,后台消费者按自己的速率处理消息。这样数据库的写入流量是匀速的,不再是瞬间尖峰。SpringBoot 项目通过spring-boot-starter-amqp就能把连接工厂和RabbitTemplate装配好,你只需要声明队列、写消费者方法。

这套组合还有一层好处是故障隔离。Redis 挂了,用户最多抢不到,不会把数据库打挂;RabbitMQ 挂了,后台攒着消息不会丢,恢复后继续消费。数据库在最底层,接收的永远是可控流量,这个分层思想比任何单个组件都重要。

2.4 四种落地方案对比:选型不是越复杂越好

如果只做课堂作业,直接更新库存就够了;如果做竞赛或答辩项目,需要向评委解释清楚“为什么用 Redis + RabbitMQ 而不是直接在数据库里扣”。下面这张表是我平时给项目选型时用的对比口径:

方案并发表现超卖风险实现成本建议场景
直接 UPDATE 扣库存行锁排队,几百 QPS 就顶满不带条件会超卖低后台管理系统
乐观锁 + 重试冲突多,重试消耗 DB 连接低低库存充足的低并发场景
Redis 分布式锁 + 同步下单锁等待时间长,接口容易超时低中小规模促销
Redis 预扣 + MQ 异步下单接口能到几千 QPS低,DB 层还兜底中高秒杀、抢购类活动

秒杀项目选第四种,不只是因为它扛得住,更因为它每一层都能单独验证。Redis 负责原子扣减,MQ 负责异步削峰,数据库负责最终一致。SpringBoot 在这里的价值不是性能,而是生态——spring-boot-starter-data-redis和spring-boot-starter-amqp这套自动装配机制,让 Redis 客户端和 MQ 客户端开箱即用,你不用去读一堆原生客户端的配置文档,这在毕设和实训的时间约束下是非常重要的。

3. 跑通秒杀最小闭环:建表、预扣库存、异步下单三步走

3.1 先建两张核心表:秒杀商品表与订单表

秒杀项目不需要一上来就建完整的电商库,商品表、用户表、订单表、秒杀活动表太多反而干扰演示。做毕设或实训时,我习惯先把最小闭环跑通,再往上面加购物车、地址、优惠券这些模块。最核心的两张表如下。

-- 秒杀商品表:一份数据既给页面展示,也当作库存的数据库兜底 CREATE TABLE `seckill_goods` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `spu_id` BIGINT NOT NULL COMMENT '对应商品表中的商品ID', `goods_name` VARCHAR(128) NOT NULL COMMENT '商品名称', `seckill_price` DECIMAL(10,2) NOT NULL COMMENT '秒杀价', `seckill_stock` INT NOT NULL DEFAULT 0 COMMENT '秒杀库存(数据库兜底)', `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', `start_time` DATETIME NOT NULL COMMENT '秒杀开始时间', `end_time` DATETIME NOT NULL COMMENT '秒杀结束时间', PRIMARY KEY (`id`), KEY `idx_spu_id` (`spu_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀商品表';

库存字段要叫seckill_stock,业务含义是“这个活动准备了 100 件”,而不是“商品总库存”。活动结束后数据库库存可以清零,但商品的真实库存不能被动,这里分开设计是为了避免活动影响普通售卖。version字段先留着,后面异步下单时做乐观锁用。

-- 秒杀订单表:唯一索引是用来防重复下单的最后防线 CREATE TABLE `seckill_order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(64) NOT NULL COMMENT '业务订单号,非自增', `user_id` BIGINT NOT NULL, `seckill_goods_id` BIGINT NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_goods` (`user_id`,`seckill_goods_id`), UNIQUE KEY `uk_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀订单表';

订单号不用自增 ID 的原因有两个。一是后续做分库分表、对接支付回调时需要全局唯一的业务单号,自增 ID 没法承担这个职责;二是前端展示订单号一般有业务规则,比如日期加流水,UUID 或雪花算法生成即可。uk_user_goods唯一索引是防重复下单的数据库层兜底,后面幂等失效时,它还能救你一命。

3.2 秒杀接口:Redis 原子预扣加防重复下单

建完表之后,核心的秒杀业务逻辑就来了。接口要做到三件事:判断活动时间、防止同一用户重复抢、用 Redis 原子扣减库存。下面是 Service 层核心方法,Controller 层就是一层薄薄的参数校验。

public SeckillResult doSeckill(Long userId, Long seckillGoodsId) { // 1. 活动时间校验 SeckillGoods goods = seckillGoodsMapper.selectById(seckillGoodsId); if (goods == null || !isBetween(goods.getStartTime(), goods.getEndTime())) { return SeckillResult.fail("不在秒杀时间段内"); } // 2. 用 setIfAbsent 做用户维度的幂等锁:并发点击只放行第一次 String onceKey = "seckill:once:" + userId + ":" + seckillGoodsId; Boolean first = stringRedisTemplate.opsForValue() .setIfAbsent(onceKey, "1", Duration.ofMinutes(5)); if (!Boolean.TRUE.equals(first)) { return SeckillResult.fail("你已经参与过该商品的秒杀"); } // 3. Redis 原子预扣库存:decr 的返回值就是剩余库存 String stockKey = "seckill:stock:" + seckillGoodsId; Long remain = stringRedisTemplate.opsForValue().decrement(stockKey); if (remain == null || remain < 0) { // 扣成负数说明库存没了,把幂等标记和库存都回滚 stringRedisTemplate.delete(onceKey); stringRedisTemplate.opsForValue().increment(stockKey); return SeckillResult.fail("手慢了,秒杀已结束"); } // 4. 异步通知下单,预扣库存与订单落库解耦 MqMessage msg = new MqMessage(userId, seckillGoodsId, orderNoGenerator()); rabbitTemplate.convertAndSend("exchange.seckill", "seckill.create", msg); return SeckillResult.success("抢购成功,请尽快支付"); }

这段代码有三个参数值得细看。onceKey的 TTL 设成 5 分钟,覆盖了“抢到但不支付”的等待期,用户取消订单后需要手动删除这个 key 才能再次抢购;decrement返回剩余库存,判断remain < 0而不是remain == 0,是因为高并发下可能同时把库存扣到 -1、-2,回滚时用increment把负值拉回 0,不会出现回滚到正数的情况;消息里的orderNo提前生成,消费者直接用,避免在消费端生成导致重复。

还有一个容易翻车的细节。setIfAbsent的返回值要判断Boolean.TRUE.equals(first),不要直接写if (first)。某些 Redis 客户端在特定序列化配置下返回的 Boolean 存在拆箱问题,自动拆箱时如果为 null 会直接抛 NPE,这属于那种排查很久最后发现是玄学的坑。

3.3 RabbitMQ 消费者:数据库真正扣减与订单落库

消息进入队列后,消费者要做两件事:用“库存大于 0”的条件把数据库库存真正减掉,然后插入订单记录。这里不能只依赖 Redis 预扣的结果,因为 Redis 和数据库是两个存储,任何一方的失败都会导致不一致。

@Component @RabbitListener(queues = "q.seckill.create") public class SeckillOrderConsumer { @Transactional public void onMessage(MqMessage msg) { // 1. 数据库层强制扣减:带 stock > 0 条件,防止并发把库存扣成负数 int updated = seckillGoodsMapper.deductStock(msg.getSeckillGoodsId()); if (updated == 0) { // 数据库没库存了,把 Redis 库存修正为 0,避免两边不一致 stringRedisTemplate.opsForValue().set( "seckill:stock:" + msg.getSeckillGoodsId(), "0"); return; } // 2. 插入订单,唯一索引防重复 seckillOrderMapper.insert(buildOrder(msg)); } }

对应的 Mapper SQL 是:

@Update("UPDATE seckill_goods SET seckill_stock = seckill_stock - 1, " + "version = version + 1 WHERE id = #{seckillGoodsId} AND seckill_stock > 0") int deductStock(@Param("seckillGoodsId") Long seckillGoodsId);

这里解释了为什么要保留version字段:stock > 0条件防超卖,version字段在订单更新等非秒杀场景中防止并发冲突,两者职责不同。消费端标了@Transactional,一旦插入订单失败,整个事务回滚,库存扣减也回滚,消息会重新进入队列,这带来了重复消费的可能,后面专门讲怎么处理。

这个阶段你可以在日志里加一条打印,记录“收到消息 -> 库存扣减成功 -> 订单创建成功”三个节点。演示时打开 RabbitMQ 管理后台,能看到消息积压数量从几千降到几十再降到零,这个画面本身就是答辩现场最好的“并发削峰”佐证。

3.4 超时未支付自动释放库存:定时任务与延迟消息

用户抢到订单后不一定支付,活动规则一般是“5 分钟未支付自动取消”。释放库存的做法有两种。

@Scheduled(fixedDelay = 30_000) public void releaseExpiredOrders() { // PageHelper.startPage(1, 500):分批扫,避免一次性加载几万订单 List<SeckillOrder> timeoutOrders = seckillOrderMapper.listTimeoutOrders(5); for (SeckillOrder order : timeoutOrders) { // 只有待支付状态能取消,防止已支付订单被重复取消 int rows = seckillOrderMapper.cancelIfPending(order.getId()); if (rows == 1) { stringRedisTemplate.opsForValue() .increment("seckill:stock:" + order.getSeckillGoodsId()); stringRedisTemplate.delete( "seckill:once:" + order.getUserId() + ":" + order.getSeckillGoodsId()); } } }

fixedDelay = 30_000的意思是任务执行完后再等 30 秒执行下一次,和fixedRate的固定频率不同,后者在任务执行时间超过间隔时会产生重叠执行,定时任务里尽量避免。cancelIfPending的 SQL 要把状态条件写进去:UPDATE seckill_order SET status = 2 WHERE id = ? AND status = 0,返回行数为 1 才说明取消成功,防止已支付订单被误取消。

扫表方案在数据量小的时候足够,如果秒杀场次多、订单量大,更合适的做法是用 RabbitMQ 延迟队列,把“订单创建”和“延迟 5 分钟检查”绑定在一起,到期自动投递一个释放消息。毕设和实训阶段,定时任务够用,答辩时提一句“生产环境会换成延迟消息”反而能体现你对方案边界的认识。

4. 压力测试与参数整定:让秒杀接口在千级并发下不翻车

4.1 三个必调配置:数据库连接池、Tomcat 线程池、Redis 超时

代码写完了,能不能扛住并发,关键在 SpringBoot 配置。很多人新建项目后保持默认配置直接压测,结果连接池被打满,误以为代码有问题。实际影响秒杀表现的就三个配置段。

spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 redis: timeout: 1000ms lettuce: pool: max-active: 32 max-idle: 8 rabbitmq: listener: simple: prefetch: 50 server: tomcat: threads: max: 200 min-spare: 20 accept-count: 100

maximum-pool-size设 20 就够了,不要给到 100。秒杀接口同步链路只访问 Redis,数据库写入被 MQ 削峰到了消费者线程,连接池不需要很大的数字。连接数过多反而增加上下文切换和数据库锁竞争,这是新手最容易踩的过度配置。connection-timeout: 3000保证数据库连接异常时快速失败,而不是让用户请求无限等待。

server.tomcat.threads.max: 200控制的是 HTTP 请求线程。每个请求在 Redis 上的操作是零点几毫秒,200 个线程保守估计能撑几千 QPS,比默认值 200 其实不高,关键是不要压测时一上来就开 1000 线程,线程池会直接把请求拒掉。accept-count: 100表示线程耗尽后还有 100 个请求在队列里排队,宁可排队也不要立刻返回 500。

RabbitMQ 消费者的prefetch: 50表示每个消费者一次最多取 50 条消息。值设太大会造成消息在消费者本地堆积,消费端重启时一批消息全部重回队列,产生大量重复消费;设太小则消费吞吐上不去。50 是个比较稳的起步值,后续按订单表写入耗时调整。

4.2 用 JMeter 或 ab 做压测:看哪些指标,怎么判断是不是有效

压测工具选择上,JMeter 适合做复杂的梯度压测,ab适合快速验证。不管用哪个,核心是能不能压出“真实用户并发”的效果,而不是一梭子打到接口上。先看快速验证的命令:

# JMeter 命令行执行测试计划(jmx 文件由 GUI 配置生成) jmeter -n -t seckill.jmx -l result.jtl -e -o ./report # 或者用 ab 快速压一把:5000 个请求,200 并发 ab -n 5000 -c 200 \ -H "token: demo-token" \ "http://localhost:8080/api/seckill?seckillGoodsId=1&userId=10086"

-n 5000是总请求数,-c 200是并发数。压测结束后看三个指标:Requests per second是吞吐量,Failed requests是失败数,Time per request的平均值要结合 95% 分位看,P95 超过 2 秒说明链路有瓶颈。JMeter 聚合报告里的95th pct才是用户真实体验的参考,平均值很容易被少数慢请求拉平。

压测时一定要分梯度加压:50 并发跑 2 分钟,100 并发跑 2 分钟,500 并发再跑 2 分钟。观察吞吐量是线性增长、放缓还是直接跌穿。如果从 100 到 500 并发吞吐量反而下降,大概率是连接池或线程池配置问题,不是代码问题。

压测的同时,在数据库执行SHOW PROCESSLIST,如果看到大量的Sleep连接,说明数据库并没有被真实打到,流量都被 Redis 和 MQ 扛住了,这正是这套架构设计要达到的效果。如果数据库连接数接近max_connections并且出现大量Lock wait timeout,说明消费者处理速度跟不上,去调prefetch和消费者线程数,而不是加数据库连接。

4.3 限流的三档做法:计数器、单机令牌桶、Redis 分布式限流

压测过后你会发现,即使秒杀链路设计得再好,接口也需要一层限流保护。常见的从业方案分三档。第一档是用户维度计数器限流,直接放秒杀接口前面,防止脚本一秒刷 100 次。

public boolean tryAcquire(String userId) { // key 精确到秒:同一用户在一秒内最多通过 10 次 String key = "seckill:rate:" + userId + ":" + System.currentTimeMillis() / 1000; Long count = stringRedisTemplate.opsForValue().increment(key); if (count != null && count == 1L) { stringRedisTemplate.expire(key, Duration.ofSeconds(1)); } return count != null && count <= 10; }

第二档是单机令牌桶,用 Guava 的RateLimiter,适合单节点部署:

// 每秒生成 500 个令牌,接口获取不到令牌就直接拒绝 RateLimiter limiter = RateLimiter.create(500); if (!limiter.tryAcquire()) { throw new BizException("系统繁忙,请稍后再试"); }

第三档是 Redis + Lua 脚本做分布式限流,多个节点共用同一个计数,适合项目里明确写了“多实例部署”的加分项。Lua 脚本的核心逻辑还是INCR + EXPIRE,和第一档相同,只是把原子性放到 Redis 端保证。

限流的阈值怎么定?先看压测结果:如果你的环境单机能扛 2000 QPS,限流阈值设 500 就是给自己留了 4 倍安全余量,确保突发流量不会把系统打穿。RateLimiter的 500 是平均速率,默认不允许瞬时突发,秒杀开启瞬间会有大量请求被拒,这是正常现象。拒绝策略不是报错,而是返回一个友好的“系统繁忙”,配合幂等锁,保证用户刷新后还能正常参与。

5. 秒杀项目最容易踩的 5 个坑:从超卖到消息重复消费

5.1 库存扣成负数:UPDATE 语句缺少 stock > 0 条件

现象:压测结束后查数据库,seckill_stock变成了 -23。前端页面明明显示已抢光,后台还在不断生成订单。

原因:扣减 SQL 只写了SET seckill_stock = seckill_stock - 1,没有加AND seckill_stock > 0。第一个请求把库存从 1 扣到 0,第二个请求继续从 0 扣到 -1,并发越高负得越多。Redis 预扣和数据库扣减两层都必须带这个条件。

解决:SQL 改成WHERE id = ? AND seckill_stock > 0,并检查返回行数,为 0 说明没库存。如果已经出现负数,先用一条修正 SQL 把库存归零,再人工核对订单,谁也不能保证负数期间超卖的那些订单能全部正常履约。这个坑属于写代码时最容易遗漏、演示时最容易暴露的问题,建议在代码审查清单里单独列一条。

5.2 Redis 有库存、数据库没库存:消费失败的补偿

现象:Redis 显示还有 12 件库存,用户也能抢到,但“我的订单”列表里就是查不到订单,活动结束时数据库库存还剩 12 件没卖出去。

原因:预扣库存成功但消息没送达消费者。常见原因有三个:RabbitMQ 消费者抛了异常没做重试、消息发送时交换机或路由配置错误、消费者停机期间消息积压没有触发告警。Redis 预扣和数据库扣减之间隔着一个消息队列,这个中间环节不是 100% 可靠的。

解决:发送消息时设置mandatory加回调,发送失败立即把 Redis 库存回补,并直接返回“活动太火爆”。消费端给onMessage加 try-catch,业务异常记录日志后返回ack,避免消息无限重回队列导致死循环;真正无法处理的进入死信队列,由人工或补偿任务处理。最后再放一个定时对账任务,每隔几分钟比对 Redis 库存和数据库库存,发现差异超过阈值就告警。注意 Redis 和数据库会短暂不一致,对账要容忍 1 到 2 分钟的时间窗口。

5.3 防重复下单失效:setIfAbsent 的返回值被忽略

现象:同一个用户开 5 个线程同时点击“立即抢购”,最终生成了 3 条订单。用日志排查,幂等判断代码执行了,但结果没生效。

原因:幂等判断用的是“先查订单表是否已存在,不存在再插入”,两个线程同时查到不存在,同时插入,数据库唯一索引uk_user_goods拦住了其中一条,但另外几条是在不同时间戳下进的,插进去了。有些实现把 Redis 的setIfAbsent返回值当成普通布尔值用,没有做空值判断,Redis 连接异常时返回 null,自动拆箱直接抛 NPE,请求直接报错而不是被拦住。

解决:用 Redis 的setIfAbsent做第一层,数据库唯一索引做第二层,两层只要有一层生效,就不会出现重复订单。同时注意setIfAbsent返回值必须用Boolean.TRUE.equals()判断,把它当成“可能为 null”的对象处理。插入订单时捕获DuplicateKeyException,捕获到就按重复抢处理,返回“你已经参与过秒杀”,而不是让用户看到 500 页面。这个组合能覆盖 Redis 宕机和并发穿透两种极端情况。

5.4 SpringBoot 版本太高导致 Redis 配置出问题

现象:照着教程在application.yml里写spring.redis.host,项目启动直接报无法解析配置;或者把 SpringBoot 升级到 3.x 后,原来的RedisTemplate注入正常,但项目跑起来一直连不上 Redis。

原因:SpringBoot 2.x 到 3.x 的改动非常大,不只是版本号变了。3.x 基于 Java 17 和 Jakarta EE,很多旧写法的配置项失效,Redis 连接工厂的内部实现也做了调整。网上大量教程和毕设源码是基于 SpringBoot 2.x 写的,直接套到新版本上必然翻车。这类问题排查起来很耗时间,而且往往不是你的业务代码出错,是框架底层行为变了。

解决:做毕设、课设和实训场景,建议锁 SpringBoot 2.7.18,这是 2.x 的最后一个版本,兼容性最好,网上资料也最多。用spring-boot-starter-data-redis搭配 Lettuce,不要自己手写连接工厂和序列化配置,默认的StringRedisTemplate和RedisTemplate足够覆盖秒杀场景。一句血泪经验:不要在演示前一两天升级 SpringBoot 版本,项目跑得好好的就别动它,版本升级属于那种“换完就后悔”的操作。

5.5 RabbitMQ 消息重复投递导致重复订单

现象:消费者服务重启过一次后,数据库里出现了两条一模一样的订单,连order_no都相同。查看 RabbitMQ 管理后台,消息并没有被消费掉,而是重新进入了队列。

原因:消费者在“插入订单”成功之后、向 RabbitMQ 发送 ack 之前宕机,消息会被判定为未消费,重新投递给其他消费者。@RabbitListener默认是自动 ack,方法执行没抛异常就确认,但 JVM 死在确认前,消息还是会回来。这不是框架 bug,是消息队列“至少一次投递”的固有语义,消费端必须设计成幂等的。

解决:数据库的uk_order_no唯一索引已经给了你最后一道防线,插入时重复会抛DuplicateKeyException,捕获后直接返回成功,不要抛出去。把消费者的 ack 模式改成手动MANUAL,处理完业务后再确认,能减少重复窗口,但手动 ack 一旦忘记确认,消息会一直积压,调试成本高。给消费者加上业务日志,记录orderNo和消费结果,排查时能直接从日志看出哪条消息被消费了几次。重复消费是消息队列的常态,不是异常,这句话可以写进项目文档。

6. 交付前的最后验证:库存对账脚本、Docker 部署与答辩追问

6.1 用脚本核对 Redis 库存与数据库库存

演示前一天,我会跑一遍对账脚本,确认 Redis 预扣库存和数据库真实库存之间没有不可解释的差异。脚本不复杂,Python 几行就能完成。

import redis import pymysql r = redis.Redis(host="localhost", port=6379, db=0) db = pymysql.connect(host="localhost", user="root", password="123456", database="seckill", charset="utf8mb4") with db.cursor() as cursor: cursor.execute("SELECT id, seckill_stock FROM seckill_goods") goods_rows = cursor.fetchall() for goods_id, db_stock in goods_rows: r_stock = r.get(f"seckill:stock:{goods_id}") if r_stock is None: print(f"警告: goods={goods_id} 在 Redis 中无库存 key") elif int(r_stock) < db_stock: print(f"不一致: goods={goods_id}, redis={int(r_stock)}, db={db_stock}")

对账脚本的判定逻辑要注意:Redis 库存和数据库库存正常情况是“Redis 小于等于数据库”,因为预扣先发生在 Redis,数据库随后才扣。如果 Redis 比数据库大,说明有预扣后没落库的消息,需要去 RabbitMQ 看死信队列;如果 Redis 比数据库小很多,说明有库存被扣了但订单没创建,可能有人恶意刷接口把库存扣没了。演示前跑一遍这个脚本,Redis 和数据库的差异能在两分钟之内暴露问题,省得现场演示时翻车。

6.2 演示参数与 Docker 部署

秒杀开始时间不要写死在代码里,配置化是最省事的做法。在application.yml里定义seckill.start-time和seckill.end-time,用@ConfigurationProperties读取,演示时可以临时调整时间范围,不用重新编译。我见过有人把时间写死在 SQL 初始化数据里,演示时活动没开始,全场对着“不在秒杀时间段内”干瞪眼。

部署到演示环境,常见做法是把 SpringBoot 应用打成 jar,用 Docker 跑起来,MySQL 和 Redis 用 docker-compose 一并编排。

FROM eclipse-temurin:17-jre WORKDIR /app COPY target/seckill.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app/app.jar", "--spring.profiles.active=prod"]
docker build -t seckill-demo . docker run -d -p 8080:8080 --name seckill \ --network seckill-net \ -e SPRING_PROFILES_ACTIVE=prod \ seckill-demo

Docker 部署要提前验证两件事。一是 MySQL、Redis、RabbitMQ 三个容器是否在同一个自定义网络里,容器之间用服务名访问而不是localhost;二是时区问题,容器默认是 UTC 时间,秒杀活动时间基于中国时区的话,数据库和 JVM 时区不一致会导致活动时间判断完全错乱,启动参数里加-Duser.timezone=Asia/Shanghai,MySQL 连接串加serverTimezone=Asia/Shanghai。这个坑在演示当天早上出现的概率极高,提前一天用 Docker 跑一遍完整流程能省掉很多尴尬。

6.3 答辩和面试的提问链路:三问三答

答辩和面试官对秒杀项目的高频追问集中在三个问题上。第一问:超卖怎么防?标准回答是三层防线——RedisDECR原子预扣解决用户请求层的并发,数据库UPDATE ... WHERE stock > 0解决最终落库的并发,唯一索引解决重复下单。不要只说一层,说三层才能体现系统性。

第二问:接口怎么防刷?回答用户维度幂等setIfAbsent加每秒限流,能主动提“计数器限流和令牌桶的差别”就很加分:计数器适合用户维度防刷,令牌桶适合全局流量整形。第三问:Redis 和数据库库存不一致怎么办?回答消费失败补偿、死信队列、定时对账脚本三层兜底,顺便可以说出对账脚本容忍秒级时间窗口这个细节,证明你真正跑过压测、见过真实数据。

最后分享一个我自己的教训。曾经演示前一天改了 RabbitMQ 的队列配置,只加了一个死信交换机,结果消费者监听注解里的队列名没改,启动时静默失败,秒杀流程一直卡在“排队中”,Redis 库存扣了一大半,订单一条没生成。后来排查到是队列绑定关系对不上,回滚配置、重建队列、重跑对账脚本才救回来。从那以后我养成了一个习惯:演示前只验证不修改,要改配置就备份原文件,出了问题能一条命令回滚。希望这个教训能帮你避开同样的坑,也希望能帮到你把这套秒杀项目真正跑成一个拿得出手的作品。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询