
简介基于Spring Boot的秒杀商城系统完整项目包适合Java后端学习者、电商项目实践者以及正在设计高并发秒杀场景的开发者。项目从SSM框架升级而来引入Redis缓存与RabbitMQ消息队列覆盖秒杀商品管理、用户管理、订单管理、购物车管理等核心模块能帮助读者理解缓存预热、异步下单、库存扣减等关键技术点。压缩包共78个文件其中64个Java源文件构成后端业务逻辑10个XML文件对应MyBatis映射与配置另有YAML、Lua、Properties和Markdown文档整体仅133KB代码量集中、便于快速阅读。已有284人学习下载。依据项目结构与README读者可获得清晰的秒杀流程实现思路、前后端分离的Vue页面雏形以及可直接导入运行的工程骨架适合用来巩固Spring Boot整合Redis、RabbitMQ的实战能力。1. 秒杀商城系统为什么必须“先设计再编码”手头拿到一份名为“基于Spring Boot的秒杀商城系统.zip”的项目包时我建议先别急着解压启动。秒杀场景的核心矛盾是瞬时高并发下的资源争抢1万个人抢100个库存如果代码只有“查库存-扣库存-建订单”一条链路数据库行锁会把请求排队到超时连接池被打穿库存还会变成负数。所以秒杀系统不是给普通商城加个按钮而是要在进入Controller之前把流量削掉进入数据库之前把库存管住。下文按“领域建模→缓存预减库存→异步下单→限流防刷→压测验证”的顺序讲一套可落地路径。新手能跟步骤复现老手能看参数取舍。Spring Boot 3.x和4.x的配置差异会在对应章节说明。2. Spring Boot四层架构与秒杀领域的模型划分2.1 Controller、Service、DAO在秒杀场景的职责边界Spring Boot最常见的分层是Controller、Service、DAO三层再把领域对象单独抽出来就是四层架构。在秒杀场景里Controller只做三件事参数校验、用户身份解析、调用Service。它不应该写库存SQL也不应该直接操作Redis。Service层负责业务编排比如“检查缓存中的库存→扣减→发送MQ消息”。DAO层只做数据库持久化不要在DAO里拼接业务判断。这种拆分对压测排障特别重要。如果Controller里塞满逻辑JMeter报告显示TPS低时你很难分清是参数校验慢还是数据库慢。分层清楚后可以给Service层单独打点给DAO层单独看慢SQL。很多Spring Boot教程提到的四层架构在秒杀系统里不是教条而是隔离流量压力的物理边界。2.2 用目录规范建立可维护的秒杀项目骨架秒杀系统涉及库存、订单、用户、活动多个域按功能拆包很容易让Redis和MQ的代码散落在各处。我一般按层建包再在Service下按域分子包这样新接手的人不用翻完整棵目录树就能定位问题com.example.seckill ├── controller # HTTP入口只做参数校验 │ ├── SeckillController.java │ └── OrderController.java ├── service # 业务编排 │ ├── SeckillService.java │ └── order │ ├── OrderService.java │ └── impl ├── repository # 数据库访问Mapper ├── domain # 实体与值对象 │ ├── SeckillGoods.java │ └── SeckillOrder.java ├── redis # Redis key构建与缓存读写 ├── mq # 消息生产者/消费者 └── config # RedisConfig、RabbitMQConfig等这个结构的核心约束有两个Redis相关方法必须收拢到redis包MQ相关方法必须收拢到mq包。后续排查“缓存没扣减”还是“消息没消费”时不需要去Service里逐行找。Spring Boot目录规范没有硬性标准但按这个骨架写比把RedisTemplate直接注入Controller更抗业务变化。Spring Boot 4.x调整了部分starter包名但Java包结构依然可以沿用这套写法。2.3 领域模型秒杀活动、库存、订单的字段设计秒杀库存和普通商城库存要分离。普通库存允许加购物车慢慢结账秒杀库存必须在活动开始前加载到Redis结束时回滚。下面这组秒杀商品表的字段是常见设计字段类型说明idbigint秒杀商品IDgoods_idbigint关联普通商品IDseckill_stockint秒杀剩余库存初始值等于活动放量actual_stockint数据库已卖数量用于对账start_timedatetime秒杀开始时间end_timedatetime秒杀结束时间versionint乐观锁版本号回写时使用注意把“可用库存”和“已卖数量”分开。Redis预减库存成功后只修改Redis里的剩余量数据库的actual_stock由异步消费者自增通过“初始量 - actual_stock Redis剩余量”来对账丢单。实体类的库存字段设计如下public class SeckillGoods { private Long id; private Long goodsId; private Integer seckillStock; // 秒杀剩余库存与Redis key对应 private BigDecimal seckillPrice; private LocalDateTime startTime; private LocalDateTime endTime; }这里刻意让Java实体里的seckillStock保存活动启动前的库存快照而不是把每次扣减都同步到数据库。实际库存以Redis为准活动结束后再把Redis剩余值回写数据库。回写时要加上update ... where seckill_stock ?条件防止多个实例同时覆盖同一条库存记录。3. 用Redis预减库存和分布式锁解决超卖问题3.1 数据库行锁为什么扛不住秒杀最原始的秒杀实现是对库存行加锁select stock from seckill_goods where id #{id} for update; -- 业务校验 stock 0 update seckill_goods set stock stock - 1 where id #{id};这段SQL在低并发下没有任何问题。但在秒杀场景里大量事务同时等待同一行的行锁InnoDB会把锁等待队列变成热点TPS上不去。更麻烦的是如果同步下单流程里调用了短信通知之类的外部接口事务时间被拉长锁持有时间跟着拉长数据库连接池很快耗尽。我一般会把库存扣减和订单创建拆成两个事务甚至两个服务就是为了缩短锁的持有时间。行锁方案和Redis预减库存的取舍可以看这张表对比项数据库行锁RedisLua预减库存并发上限受InnoDB锁粒度限制单节点数万QPS一致性强一致最终一致需要异步订单兜底运维成本依赖数据库连接池需要维护Redis缓存状态适用规模秒杀量低或活动准入门槛高大促、热点商品3.2 用Lua脚本预减库存保证原子性Redis单线程模型很适合扣库存。但“get→判断→decr”三步必须用Lua脚本合并成原子操作否则并发下还是超卖。下面是我常用的脚本-- KEYS[1]商品库存key例如 seckill_stock:100 -- ARGV[1]本次扣减数量默认1 local stock tonumber(redis.call(get, KEYS[1])) if stock nil then return -1 -- 缓存不存在可能是活动未加载 end if stock tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(decrby, KEYS[1], ARGV[1]) return 1 -- 扣减成功在Spring Boot中执行脚本时要显式声明返回类型否则RedisTemplate会反序列化失败DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(stock.lua)); script.setResultType(Long.class); Long result redisTemplate.execute(script, Arrays.asList(seckill_stock: activityId), String.valueOf(quantity)); if (result null || result ! 1L) { throw new SeckillException(已抢光或活动未开始); }代码里的setResultType(Long.class)不能省略它告诉RedisTemplate把Lua返回值转成Long而不是byte[]。活动开始前要由定时任务把数据库里的库存加载到Redis并设置过期时间为活动结束加上缓冲分钟数。如果Lua返回-1说明Redis key被误删或过期需要检查缓存预热任务。3.3 Redisson分布式锁的参数选择Redis预减库存解决了“超卖”但还要防止同一个用户重复下单、或者多实例并发操作同一个优惠券。Redisson是Spring Boot生态里最常用的分布式锁客户端自带看门狗续期机制避免业务没执行完锁就超时。spring: data: redis: host: 127.0.0.1 port: 6379 timeout: 3000ms lettuce: pool: max-active: 32 max-idle: 16 min-idle: 4获取锁的代码需要控制等待时间和释放时间RLock lock redissonClient.getLock(seckill:user: userId); boolean locked lock.tryLock(1, 30, TimeUnit.SECONDS); if (!locked) { throw new SeckillException(操作太频繁); } try { // 检查订单表是否已有该用户的秒杀订单 } finally { lock.unlock(); }这里的tryLock第一个参数是等待锁时间设为1秒拿不到直接拒绝避免请求堆积第二个参数是锁自动释放时间30秒配合看门狗实现自动续期。锁的粒度一定要按用户维度拆分不要用seckill:global这种全局锁否则所有用户串行化TPS比数据库行锁还差。另外finally里必须判断locked为true再解锁否则没抢到锁的线程会误删别人的锁。4. RabbitMQ异步下单与订单幂等4.1 同步扣减库存异步创建订单第3章的Lua脚本扣减完库存后订单还没有生成。如果服务宕机Redis少库存、数据库没订单就需要对账补偿。常见做法是扣减Redis成功后立刻发送一条MQ消息消费端负责创建订单和更新数据库真实库存。HTTP接口只返回“排队中”避免用户在连接里等待订单写库。这样设计后订单表写入压力被消息队列削峰数据库连接池不再是主要瓶颈。但随之而来的是消息丢失和重复消费。所以生产端要开启消息确认消费端要做幂等处理。下面这个发送消息的方法只演示核心链路public String seckill(Long goodsId, Long userId, Long activityId) { Long result seckillRedisService.preReduce(activityId, goodsId, 1); if (result ! 1) { return 已抢光; } SeckillMessage message new SeckillMessage(userId, goodsId, activityId); rabbitTemplate.convertAndSend( seckill.order.exchange, seckill.order.routing, JSON.toJSONString(message)); return 排队中; }实际项目里我很少直接用convertAndSend因为网络抖动会导致消息没到交换机。我会配合ConfirmCallback和ReturnCallback把投递失败的消息落到一张mq_message_retry表里由定时任务扫描重发。4.2 Spring Boot整合RabbitMQ核心配置配置交换机、队列和绑定关系的代码如下Configuration public class RabbitMQConfig { Bean public TopicExchange seckillOrderExchange() { return new TopicExchange(seckill.order.exchange, true, false); } Bean public Queue seckillOrderQueue() { MapString, Object args new HashMap(); args.put(x-dead-letter-exchange, seckill.order.dlx); args.put(x-dead-letter-routing-key, seckill.order.dead); return new Queue(seckill.order.queue, true, false, false, args); } Bean public Binding binding() { return BindingBuilder.bind(seckillOrderQueue()) .to(seckillOrderExchange()) .with(seckill.order.*); } }这段配置有两个关键点。Exchange构造函数里的true表示持久化队列构造里的true也表示持久化这样RabbitMQ重启后交换机、队列和消息都还在。队列参数里声明了死信交换机和死信路由key消费端重试达到上限时消息会被投递到死信队列方便人工修复后重新投入。Topic交换机采用通配符路由seckill.order.*能匹配seckill.order.routing这类key后续要加延迟队列或重试队列时不需要改Queue定义。消费端还要关注预取数量和重试参数配置项推荐值说明spring.rabbitmq.listener.simple.acknowledge-modeauto自动确认业务无异常自动ackspring.rabbitmq.listener.simple.prefetch50每个消费者预取50条防止单条处理太慢spring.rabbitmq.listener.simple.retry.enabledtrue开启消费重试spring.rabbitmq.listener.simple.retry.max-attempts3超过3次进入死信4.3 消费端幂等唯一业务流水号与去重表异步消费最怕重复消息。RabbitMQ自动重试和消费者崩溃重连都会导致同一条消息被消费两次。幂等方案我建议用一张去重表以业务流水号seckill_no作为唯一键CREATE TABLE seckill_order_unique ( id BIGINT AUTO_INCREMENT PRIMARY KEY, seckill_no VARCHAR(64) NOT NULL COMMENT 业务流水号, user_id BIGINT NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-处理中 1-成功, UNIQUE KEY uk_seckill_no (seckill_no) ) ENGINEInnoDB;消费端逻辑必须把“去重检查”和“订单写入”放在同一个本地事务里。不要先查seckill_order_unique再插入因为并发时两次查询都查不到记录重复消息会一起通过。直接用唯一键插入捕获DuplicateKeyException即可RabbitListener(queues seckill.order.queue) public void onMessage(Message message) { SeckillMessage msg JSON.parseObject(message.getBody(), SeckillMessage.class); String seckillNo msg.getSeckillNo(); try { orderUniqueMapper.insert(seckillNo, msg.getUserId(), 0); } catch (DuplicateKeyException e) { return; // 已处理过幂等返回 } try { orderService.createOrder(msg); orderUniqueMapper.updateStatus(seckillNo, 1); } catch (Exception e) { orderUniqueMapper.delete(seckillNo); throw new AmqpRejectAndDontRequeueException(处理失败进入死信); } }代码的关键点在于业务失败时删除去重记录并抛出异常让消息进入重试或死信队列如果业务成功则标记状态为1。注意不要为了省事把DuplicateKeyException之外的异常也吞掉直接返回否则订单没创建消息却确认掉了库存就白白扣掉。5. 令牌桶限流与接口防刷把恶意请求挡在业务之前5.1 本地令牌桶还是分布式限流秒杀接口除了要防超卖还要防脚本刷单。提前蹲点的脚本可以在活动开始前10毫秒发起几千个请求直接把接口打趴。限流必须放在拦截器或网关层不能等进到Controller再做。常见的方案有Guava RateLimiter、Sentinel、RedisLua令牌桶。单机部署时我用Guava就够了简单且无网络开销。但多实例部署时每个实例的Guava限流器独立计数总QPS会变成“实例数×单机阈值”阈值就失控了。秒杀场景一般会部署至少两个实例所以我默认用Redis令牌桶做分布式限流既能精确控制总量也能按用户维度拆桶。5.2 用拦截器实现用户维度令牌桶按用户维度限流比较公平避免一个脚本用户占满所有流量。下面这段Lua脚本维护了每个用户的定时令牌桶-- KEYS[1] user:limit:{userId}:{activityId} -- ARGV[1] 桶容量 capacity -- ARGV[2] 速率 rate个/秒 -- ARGV[3] 当前时间戳秒 local bucket redis.call(hgetall, KEYS[1]) local last_time 0 local tokens 0 if bucket[1] ~ false then last_time tonumber(bucket[2]) tokens tonumber(bucket[4]) end local now tonumber(ARGV[3]) tokens math.min(tonumber(ARGV[1]), tokens (now - last_time) * tonumber(ARGV[2])) if tokens 1 then return 0 -- 拒绝 end redis.call(hmset, KEYS[1], last_time, now, tokens, tokens - 1) redis.call(expire, KEYS[1], 2) return 1 -- 放行在Spring Boot拦截器里执行这个脚本并设置合理的桶容量和速率public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId request.getHeader(X-UserId); Long activityId Long.parseLong(request.getParameter(activityId)); Long allowed redisScript.execute(limitScript, Arrays.asList(user:limit: userId : activityId), 20, 10, String.valueOf(System.currentTimeMillis() / 1000)); if (allowed null || allowed 0L) { response.setStatus(429); return false; } return true; }参数里桶容量设为20速率是10个/秒也就是允许用户瞬间提交20个请求但后续每秒只能放行10个。expire 2这个参数很重要它让不活跃用户的限流Key在2秒后自动过期避免Redis里塞满大量无意义的用户记录。需要注意的是X-UserId不能直接信任必须由认证网关校验后覆盖写入否则脚本用户自己伪造Header就能绕过。5.3 秒杀地址动态签名与验证码兜底令牌桶只能限流量但没法区分“真人手速快”和“脚本连点”。我再加一道秒杀地址签名服务端先用用户ID、活动ID、随机盐生成签名存入Redis用户点击秒杀按钮时先请求这个签名下单接口再校验签名。校验通过后签名一次性消费重放请求无效。签名校验代码示例如下String sign generateMD5(userId activityId salt); if (!sign.equals(request.getParameter(sign))) { throw new SeckillException(签名错误); } Boolean consume redisTemplate.opsForValue() .setIfAbsent(seckill:sign: userId : activityId, 1, Duration.ofSeconds(5)); if (!consume) { throw new SeckillException(重复提交); }setIfAbsent在这里起到了幂等防重的作用同一个用户携带同一个签名再次提交时第一次已经写入Key第二次无法再写入直接拦截。签名有效期设为5秒是为了缓解正常用户经过验证码页面的等待时间。实际项目中验证码识别可以接到Redis里做计数超过阈值就强制要求输入验证码。下面这张表总结了三种防刷手段的定位差异手段解决的问题部署位置令牌桶限流QPS超出系统承受能力拦截器/网关动态签名重放攻击、提前预测地址Controller前置校验验证码脚本批量提交前端Redis校验6. 用JMeter和Actuator验证秒杀系统的扛压能力6.1 JMeter线程组关键参数与压测命令压测秒杀接口前先清空Redis里的用户限流Key和库存Key否则上一次压测的计数会影响结果。用命令行方式跑JMeter脚本jmeter -n -t seckill.jmx -l result.jtl -Jthreads200 -Jrampup5 -Jduration60-Jthreads200表示200个并发用户-Jrampup5表示5秒内启动完-Jduration60表示持续压测60秒。聚合报告里重点看异常率和90%响应时间90%响应时间超过500ms时优先检查Redis连接池是否打满其次看RabbitMQ消费积压。6.2 Actuator端点安全配置Spring Boot Actuator暴露的/actuator/env、/actuator/heapdump在未授权情况下会泄露配置密码和堆信息秒杀系统上线前必须处理。只开放健康检查和基础指标management: endpoints: web: exposure: include: health,metrics endpoint: health: show-details: always如果项目里引入了Spring Security还需要给health端点放行其余端点都要认证。压测时可以频繁调用/actuator/health实时确认服务存活状态但不要让压测脚本去打/actuator/heapdump那会触发Full GC影响结果。6.3 从Redis剩余量和日志定位瓶颈压测后不能只盯着TPS必须对账。执行命令查看秒杀库存Key的剩余量redis-cli get seckill_stock:100如果Redis剩余量为0但订单表成功记录数小于初始库存说明订单消息有积压或消费失败。此时打开RabbitMQ管理后台看seckill.order.queue的堆积数若堆积数持续增长就到日志里搜DeadLetter关键字。生产环境用Docker部署时我习惯把Spring Boot日志打成JSON格式用Filebeat采集到Elasticsearch快速聚合出错时间段的所有异常堆栈这个手段比一台台看控制台日志高效得多。本文还有配套的精品资源点击获取