☰
SpringBoot商品秒杀系统实战:从高并发架构到Redis与MQ落地
2026/10/15 4:47:32 网站建设 项目流程

秒杀系统这个题目,几乎每年都会出现在毕业设计的选题清单里。原因很直接:它不像普通管理系统那样只做增删改查,而是把高并发、缓存、消息队列、分布式锁这些后端核心知识点全部串在了一起。一个限时抢购的闪购平台做完,你对 SpringBoot 的理解深度,和只会写 CRUD 的同学就不是一个层级了。这篇文章就以我手头这套基于 SpringBoot 的商品秒杀系统为主线,把从架构设计、数据库建模到高并发方案落地的完整过程讲清楚,适合正在选毕设题目、或者想系统学习高并发写法的同学直接参考。

1. 项目整体架构与技术选型思路

1.1 秒杀系统真正的难点在哪里

先别急着写代码,得先想明白这个系统到底要解决什么问题。秒杀和普通购物流程最大的区别在于:大量用户在同一瞬间涌入,但商品库存却是极少的。比如某活动放出 500 件商品,却有 5000 个用户同时点击抢购,系统的压力峰值就集中在开抢后的几秒钟内。

这个场景下会出现三个典型问题。第一个是超卖,两个人同时读到库存还剩 1 件,都执行了扣减,结果卖出去 2 件,这在真实业务里是事故。第二个是缓存击穿,热点商品的缓存刚好在开抢瞬间失效,所有请求直接穿透到数据库,数据库连接池瞬间被打满。第三个是接口被脚本刷,攻击者用程序高频请求接口,普通用户根本抢不到。

所以整个系统的设计目标就很明确了:保证不超卖、削峰填谷、尽量拦截无效请求。理解了这些难点,后面每个方案的选择逻辑就顺理成章。

1.2 技术栈选型与分工

这套系统的技术选型遵循的是"简单够用、不过度设计"的原则,毕竟是毕业设计性质的实战项目,重点是把核心方案做透。

组件用途选型理由
SpringBoot 2.7应用框架生态成熟,配置简洁,适合快速搭建
MyBatis-PlusORM 框架代码量少,分页和条件构造器好用
Redis 6.x缓存与库存预扣单线程模型天然原子性,Lua 脚本保证扣减安全
RabbitMQ消息队列削峰填谷,异步创建订单,解耦核心链路
MySQL 8.0数据持久化最终库存校验和订单存储
JMeter压测工具模拟高并发,验证系统性能

这里面最核心的思路是:把秒杀过程中的"读"和"写"从数据库剥离出去。用户点击抢购时,先走 Redis 的 Lua 脚本完成库存预扣减,扣减成功后再发一条消息到 RabbitMQ,由消费者异步去创建数据库订单。数据库在整个过程中承受的写压力被极大地削弱了。

1.3 整体业务链路设计

完整流程可以拆成三个阶段。秒杀开始前,通过定时任务把商品信息和库存预热到 Redis,同时生成限流配置;秒杀进行中,用户请求先过限流器,再校验活动时间、是否已抢过,然后执行 Lua 扣减库存,扣减成功的用户会得到一张"抢购资格凭证",系统把创建订单的消息发给 MQ 后就立刻返回"正在排队中";秒杀结束后,MQ 消费者异步创建订单,同时启动对账任务,比对 Redis 和数据库的库存数据。

这样设计的好处是用户请求链路里没有一次数据库写操作,响应时间可以压到毫秒级。我当时第一版做的是同步扣库存再落订单,压测 500 并发直接把数据库连接池打到了 100% 占用,后来改成这个异步链路之后,2000 并发跑得从容很多。

2. 数据库设计与核心表结构

2.1 核心表设计思路

秒杀系统的数据库表不复杂,但每个字段都值得推敲。核心是四张表:用户表、商品表、秒杀商品表、秒杀订单表。用户表和商品表就是常规设计,重点在秒杀商品表和订单表。

秒杀商品表存放的是活动维度的数据,一个普通商品可以参与多场秒杀活动,所以它和商品表是多对一关系。关键字段包括:秒杀价格、活动开始时间、活动结束时间、初始库存、剩余库存、状态。

建表语句如下:

CREATE TABLE `seckill_product` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `product_id` bigint NOT NULL COMMENT '商品ID', `seckill_price` decimal(10,2) NOT NULL COMMENT '秒杀价', `stock_count` int NOT NULL COMMENT '初始库存', `stock_left` int NOT NULL COMMENT '剩余库存', `start_time` datetime NOT NULL COMMENT '开始时间', `end_time` datetime NOT NULL COMMENT '结束时间', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0下线 1上线', PRIMARY KEY (`id`), KEY `idx_product_id` (`product_id`), KEY `idx_start_end` (`start_time`, `end_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀商品表';

订单表相对特殊,它必须包含秒杀活动 ID 和用户 ID,这是做幂等控制的基础。创建时间索引也非常关键,因为后面要对账和超时关单,需要按时间范围扫描订单。

2.2 防止重复下单的唯一索引设计

如果不做任何约束,同一个用户可以同时提交多个请求,最终生成多笔订单。最底层的防线就是数据库唯一索引。

CREATE TABLE `seckill_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `user_id` bigint NOT NULL COMMENT '用户ID', `seckill_id` bigint NOT NULL COMMENT '秒杀商品ID', `order_sn` varchar(64) NOT NULL COMMENT '订单号', `status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付 1已支付 2已取消', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_seckill` (`user_id`, `seckill_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀订单表';

uk_user_seckill这个唯一索引就是防重复下单的兜底方案。即使应用层判断逻辑因为并发出现了漏洞,数据库层面也会拦住第二个请求,插入时报 Duplicate entry 错误。我在做这个索引之前,用脚本模拟同一用户重复点击抢购,确实生成了两条订单记录,加上索引之后,这个问题从源头被堵死了。

2.3 热点商品与普通商品数据分离

秒杀商品天然是热点数据,如果和普通商品混在一张表里,不仅查询效率受影响,缓存管理也麻烦。实际项目中可以把秒杀商品单独拆表,并给秒杀活动设计一个独立的活动表。活动表管理的是"什么时候开始、什么时候结束",秒杀商品表管理的是"这个活动里有哪些商品、各多少库存"。

我在项目里还加了一个简单的扩展:活动配置表里存了每个活动的限流阈值和缓存预热开关。这样每次上新活动,运营只需要往配置表里加一条数据,不用改一行代码。这个设计虽然简单,但能体现对业务可扩展性的考虑,在毕设答辩时是很加分的点。

3. 秒杀前的准备:缓存预热与限流拦截

3.1 商品数据缓存预热方案

开抢瞬间大量请求读取商品信息,如果缓存里没有数据,所有请求都会穿透到数据库,这就是典型的缓存击穿。解决办法是提前把热点商品信息放到 Redis 里,也就是缓存预热。

我实现的方式是写一个 ApplicationRunner,应用启动完成后自动加载所有状态为上线、且活动时间在未来时间段内的秒杀商品到 Redis。key 设计为seckill:product:{seckillId},value 用 JSON 序列化的商品信息。

@Component public class SeckillCachePreloadRunner implements ApplicationRunner { @Autowired private SeckillProductMapper seckillProductMapper; @Autowired private StringRedisTemplate redisTemplate; @Override public void run(ApplicationArguments args) { List<SeckillProduct> products = seckillProductMapper.selectList( new LambdaQueryWrapper<SeckillProduct>() .eq(SeckillProduct::getStatus, 1) .gt(SeckillProduct::getEndTime, new Date()) ); for (SeckillProduct product : products) { String key = "seckill:product:" + product.getId(); redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 2, TimeUnit.HOURS); // 同时预热库存 redisTemplate.opsForValue().set( "seckill:stock:" + product.getId(), String.valueOf(product.getStockLeft()), 2, TimeUnit.HOURS ); } } }

注意这里商品信息和库存分别使用独立 key。原因在于:库存是高频修改的,每次扣减都要写;商品信息是低频读取的,基本不变。如果放在同一个 key 里,扣库存会导致整个商品信息也要重写,白白增加 Redis 的写入压力。

3.2 接口限流方案对比与实现

秒杀接口必须限流,否则单机能扛的流量是有限的,超出部分只能让系统雪崩。常见的限流方案有三种:计数器、滑动窗口、令牌桶。计数器实现最简单但存在临界问题,滑动窗口更平滑但需要存储更多数据,令牌桶适合处理突发流量。

考虑到秒杀场景本身就是瞬时高并发,我用的是基于 Redis 的固定窗口计数器,在拦截器里实现。每个用户每秒钟最多放行 5 个请求,超出直接返回"请求过于频繁"。

@Component public class RateLimitInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String userId = request.getHeader("userId"); if (userId == null) { throw new BusinessException("用户未登录"); } String key = "seckill:rate:" + userId; Long count = redisTemplate.opsForValue().increment(key); if (count == 1) { redisTemplate.expire(key, 1, TimeUnit.SECONDS); } if (count > 5) { response.setStatus(429); response.getWriter().write("{\"code\":429,\"msg\":\"请求过于频繁\"}"); return false; } return true; } }

这段逻辑用 increment 命令实现计数,第一次计数时设置 1 秒过期时间。如果换成滑动窗口,就要用 Redis 的 ZSet 记录每个请求时间戳,然后统计窗口内数量,代码量会上来不少,但精度更高。限流阈值 5 次/秒是我压测后调出来的经验值,既能容忍用户正常连点操作,又能挡掉脚本的疯狂请求。

3.3 防脚本刷接口的 Token 方案

限流只能控制请求频率,防不了脚本用不同账号绕过。秒杀系统里常见做法是:用户进入秒杀页面时,后端动态下发一个随机 Token 绑定到该用户的活动上,抢购时必须携带这个 Token 才能扣库存。

我在项目中用了两步操作。第一步,用户打开秒杀页面时调用获取 Token 的接口,后端生成 UUID 后存到 Redis,key 为seckill:token:{userId}:{seckillId},过期时间设为活动时长。第二步,真正执行抢购时,先校验 Redis 中是否存在该 Token,存在则删除并继续,不存在则直接拒绝。

public String getSeckillToken(Long userId, Long seckillId) { String token = UUID.randomUUID().toString().replace("-", ""); String key = "seckill:token:" + userId + ":" + seckillId; redisTemplate.opsForValue().set(key, token, 1, TimeUnit.HOURS); return token; } @Transactional public boolean checkAndConsumeToken(Long userId, Long seckillId, String token) { String key = "seckill:token:" + userId + ":" + seckillId; String cachedToken = redisTemplate.opsForValue().get(key); if (cachedToken == null || !cachedToken.equals(token)) { return false; } redisTemplate.delete(key); return true; }

这个方案的核心是 Token 的一次性使用特性。取到 Token 后先校验、后删除,即使脚本反复提交同一个请求,也只能成功一次。这里有个细节必须注意:校验和删除必须保证原子性,否则并发请求可能同时通过校验。我最初用 get 和 delete 两条命令,压测时发现有极低概率的重复提交,后来改用 Lua 脚本把校验和删除合并成原子操作才彻底解决。

4. 秒杀核心链路:Redis 预扣库存与异步下单

4.1 Redis Lua 脚本做到原子扣减

秒杀系统最不能出错的就是库存扣减。数据库的 update 语句虽然自带行锁保证安全,但高并发下大量请求同时更新同一行,行锁竞争会让响应时间暴涨。所以我把扣减动作前移到 Redis,利用 Redis 单线程模型保证原子性。为了避免多条命令之间的竞态问题,直接使用 Lua 脚本一次执行。

这是我项目中实际使用的库存扣减 Lua 脚本:

local stock = redis.call('get', KEYS[1]) if not stock or tonumber(stock) <= 0 then return 0 end if tonumber(stock) >= tonumber(ARGV[1]) then redis.call('decrby', KEYS[1], ARGV[1]) return 1 end return 0

在 Java 侧通过 RedisTemplate 的 execute 方法调用:

public boolean deductStock(Long seckillId, Integer count) { String key = "seckill:stock:" + seckillId; Long result = redisTemplate.execute( new DefaultRedisScript<>(STOCK_DEDUCT_LUA, Long.class), Collections.singletonList(key), count.toString() ); return result != null && result == 1; }

这段脚本的逻辑是:先检查当前库存是否大于 0,再检查库存是否大于等于本次扣减数量,满足条件就执行扣减并返回 1,否则返回 0。因为 Redis 的单线程特性,脚本执行期间不会有其他命令插入,所以不会出现两个请求同时读到库存为 1 的情况。相比用分布式锁兜底的做法,Lua 脚本方案更轻量,性能损耗小,而且语义非常清晰。

4.2 消息队列异步创建数据库订单

库存扣减成功只代表用户锁定了"抢购资格",真正生成订单是异步进行的。用户请求到这里直接返回"排队中",前端通过轮询订单状态来告知用户结果。

我这里用 RabbitMQ 做消息队列。扣减成功后,往交换机发送一条消息,内容包含 userId、seckillId、orderSn。订单创建由消费者完成,消费者在收到消息后执行数据库插入操作。

@Component public class SeckillOrderConsumer { @Autowired private SeckillOrderMapper orderMapper; @RabbitListener(queues = "seckill.order.queue") public void createOrder(SeckillOrderMessage message) { SeckillOrder order = new SeckillOrder(); order.setUserId(message.getUserId()); order.setSeckillId(message.getSeckillId()); order.setOrderSn(message.getOrderSn()); order.setStatus(0); try { orderMapper.insert(order); } catch (DuplicateKeyException e) { // 唯一索引冲突,说明订单已存在,忽略即可 log.warn("重复订单,userId={}, seckillId={}", message.getUserId(), message.getSeckillId()); } } }

消费者这里要特别注意幂等处理。RabbitMQ 在极端情况下可能重复投递消息,如果消费者不做幂等判断,就可能生成两张一模一样的订单。我这里的做法是依靠数据库唯一索引兜底,插入时捕获 DuplicateKeyException,当作已存在处理。这个设计是异步下单链路里最容易被忽略、也最容易出问题的环节。

4.3 订单超时关单与库存回补

用户抢到资格后如果一直不支付,占用的库存就不能释放。实际项目里一般给用户 15 分钟支付时间,超时未支付自动取消订单并回补库存。

关单方案我选的是 RocketMQ 或 RabbitMQ 的延迟消息,考虑到毕设项目里 RabbitMQ 没有内置延迟队列,我用了一个折中做法:创建订单时,把订单超时时间写入 Redis 的 ZSet,score 为超时时间戳,定时任务每 10 秒扫描一次 ZSet,把已过期的订单取出来执行关单逻辑。

@Scheduled(fixedDelay = 10000) public void scanExpiredOrders() { long now = System.currentTimeMillis(); Set<String> expiredOrders = redisTemplate.opsForZSet().rangeByScore("seckill:expire:orders", 0, now); if (expiredOrders == null || expiredOrders.isEmpty()) { return; } for (String orderSn : expiredOrders) { // 更新数据库订单状态为已取消 // 回补 Redis 库存 // 移除 ZSet 中的记录 } }

定时任务方案虽然存在秒级延迟,但对于 15 分钟的超时时间来说完全可接受,而且实现复杂度比引入额外组件低很多,适合毕设项目的时间安排。

5. 防超卖与幂等处理的实战细节

5.1 数据库库存扣减的最终防线

Redis 里的库存只是"预扣数据",真正的库存扣减最终还是要落到数据库。很多人会问,如果 Redis 扣减成功了,消费者创建订单时再扣一次数据库库存,会不会扣成负数?

答案是:只要数据库扣减语句写对了,就不会。关键是把扣减条件写进 SQL,而不是先查询再判断。

UPDATE seckill_product SET stock_left = stock_left - 1 WHERE id = #{seckillId} AND stock_left > 0

这条 SQL 在执行时会对命中的行加锁,并且只有在当前剩余库存大于 0 的情况下才执行扣减。MyBatis-Plus 的 updateById 默认不会带这个条件,所以这里必须写自定义 SQL。我当时第一次没注意,直接用了先 select 再 update 的方式,并发量一上来就超卖,后来改成这种"条件更新"写法后,压测就非常稳定了。

5.2 重复下单的两层防线

重复下单要防的是同一个用户反复提交抢购请求,导致生成多个订单。我在前面建表时已经设计了唯一索引,这是第一层防线。应用层还有第二层防线:抢购处理前先查一下用户是否已经有该秒杀商品的订单。

实际上在这里可以做一个更彻底的做法:完全依赖数据库唯一索引,让重复插入直接报错。因为应用层的检查在并发下并不可靠,两个请求可能同时通过"无订单"检查,然后同时插入,最终只有一个能插入成功。这个设计经验是从实际故障里总结出来的——我在开发环境用 200 并发测同一个用户抢购,不依赖唯一索引的情况下生成了 3 条订单,加上索引后只生成 1 条,剩下的插入请求全部抛 DuplicateKeyException 被吞掉了。

5.3 Redis 库存与数据库库存的最终一致性

异步链路里 Redis 扣减和数据库扣减不是同一个事务,必然存在短暂的不一致。正常情况下数据库扣减的订单数应该等于 Redis 扣减的库存数。如果某个消费者处理消息失败,或者数据库临时不可用,就会出现 Redis 库存扣了、数据库没扣的"幽灵库存"。

解决思路是对账。我加了一个定时任务,每 5 分钟扫描一次秒杀商品表,比较stock_left与 Redis 中对应的库存值。如果差异超过阈值,就触发手工补扣或告警。

@Scheduled(cron = "0 */5 * * * ?") public void checkStockConsistency() { List<SeckillProduct> products = seckillProductMapper.selectList( new LambdaQueryWrapper<SeckillProduct>().eq(SeckillProduct::getStatus, 1) ); for (SeckillProduct product : products) { String cacheStockStr = redisTemplate.opsForValue().get("seckill:stock:" + product.getId()); if (cacheStockStr != null) { int cacheStock = Integer.parseInt(cacheStockStr); if (product.getStockLeft() != cacheStock) { log.warn("库存不一致,商品: {}, 数据库: {}, 缓存: {}", product.getId(), product.getStockLeft(), cacheStock); // 以数据库为准回写缓存 redisTemplate.opsForValue().set("seckill:stock:" + product.getId(), String.valueOf(product.getStockLeft())); } } } }

这个方案属于最终一致性里的"以数据库为准"。严格来说活动还没结束、订单还在生成时,数据库和缓存不一致是正常状态,所以对账任务在活动结束后执行才有真正的意义。我在系统里做了一点优化:只在商品活动结束后运行对账,避免活动期间反复告警。

6. 压测验证与常见问题排查

6.1 JMeter 压测方案与结果分析

系统的性能不是靠感觉说的,压测数据才靠谱。我用 JMeter 搭了一个简单的压测环境:一台 2C4G 的服务器部署应用,Redis 和 RabbitMQ 部署在同一台独立机器,MySQL 单独一台。测试场景是 5000 个线程同时请求秒杀接口,商品库存设定 100 件。

第一轮压测就暴露了问题。5000 并发全部放行到应用层,虽然 Redis 扣减很快,但 Tomcat 的线程池直接被打满,部分请求排队时间超过 3 秒,平均响应时间到了 800ms。这个结果促使我在限流器上做了两处调整:一是把单用户每秒钟的请求上限从 10 次降到 5 次,二是加了一个全局信号量,限制同时进入秒杀方法的请求数不超过 200。

调整后的压测数据:5000 个并发请求,最终成功扣减库存 100 件,Redis Lua 扣减平均耗时 1.2ms,接口整体平均响应时间 120ms,错误率 0。数据库订单表新增记录与扣减记录完全一致,无超卖。

6.2 高并发下三个容易踩的大坑

第一个坑是 Redis 连接池耗尽。默认的 Lettuce 连接池配置在并发高的时候很容易把连接全部占满,尤其是 Lua 脚本执行期间占用连接时间较长。我的解决方法是把连接池的maxTotal从默认 8 调到 64,并按压测结果逐次调整。

第二个坑是 RabbitMQ 消费端堆积。如果消费者处理消息速度跟不上生产速度,队列会越来越长,用户已经扣减了库存但迟迟收不到订单生成结果。优化方法有两个方向:一是给消费者增加线程并发数,二是合并订单插入逻辑,批量插入代替逐条插入。

第三个坑是数据库连接池参数。HikariCP 默认的 maximumPoolSize 是 10,对秒杀场景偏小。我调到 30 后,数据库侧的并发处理能力显著提升。但注意不是越大越好,连接池太大反而会让数据库 CPU 负载飙升,这个值要根据压测数据调整。

6.3 上线前的检查清单

每次活动上线前,我会按这份清单逐项检查:

  • 秒杀商品库存是否已预热到 Redis,过期时间是否覆盖整个活动周期
  • 限流配置是否针对当前活动调整过,阈值是否合理
  • RabbitMQ 消费者是否已启动,队列是否存在且无积压
  • 数据库唯一索引是否已正确创建,活动商品 ID 是否有关联索引
  • 定时对账任务是否开启,Redis 和数据库初始库存是否一致
  • 服务器时钟是否同步(Redis 过期时间依赖服务器时间)

清单看起来简单,但每一条背后都有血泪教训。比如第二项,有一次我复用上个活动的限流配置,结果新活动用户访问量大了 3 倍,大量用户被误伤提示"请求过于频繁",后来才改成按活动维度动态配置限流阈值。

压测和排障做完之后,这套系统的基本盘就算稳了。最后再分享一个小细节:在秒杀接口上加了一个简单的"活动状态缓存",开抢前查询 Redis 中的活动状态,未开始时直接返回"活动未开始"。这个判断看起来不起眼,但能挡掉大量提前守在页面的请求提前打到后端,效果非常明显。秒杀系统值不值得做?我的个人体会是,它几乎是一个后端开发者成长路上的标配项目。做完之后你再去理解缓存一致性、消息可靠性、幂等设计这些概念,会踏实很多。如果你也打算做类似的选题,建议从本文这套架构出发,先跑通主流程,再逐步压测调优,过程中踩过的每一个坑,都会变成答辩时能讲清楚的实战经历。

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

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

立即咨询