秒杀系统这个题目几乎每年都会出现在毕业设计的选题清单里。原因很直接它不像普通管理系统那样只做增删改查而是把高并发、缓存、消息队列、分布式锁这些后端核心知识点全部串在了一起。一个限时抢购的闪购平台做完你对 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) { ListSeckillProduct products seckillProductMapper.selectList( new LambdaQueryWrapperSeckillProduct() .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 后存到 Rediskey 为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 的 ZSetscore 为超时时间戳定时任务每 10 秒扫描一次 ZSet把已过期的订单取出来执行关单逻辑。Scheduled(fixedDelay 10000) public void scanExpiredOrders() { long now System.currentTimeMillis(); SetString 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() { ListSeckillProduct products seckillProductMapper.selectList( new LambdaQueryWrapperSeckillProduct().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 中的活动状态未开始时直接返回活动未开始。这个判断看起来不起眼但能挡掉大量提前守在页面的请求提前打到后端效果非常明显。秒杀系统值不值得做我的个人体会是它几乎是一个后端开发者成长路上的标配项目。做完之后你再去理解缓存一致性、消息可靠性、幂等设计这些概念会踏实很多。如果你也打算做类似的选题建议从本文这套架构出发先跑通主流程再逐步压测调优过程中踩过的每一个坑都会变成答辩时能讲清楚的实战经历。