简介:面向电商后端开发者与高并发学习者,这份基于SpringBoot2.0的秒杀系统源码包,完整呈现了典型抢购场景的工程化实现。包内共102个文件,以51个Java源文件为核心,覆盖秒杀接口、服务层与数据持久化逻辑;同时包含SQL脚本、YML配置、前端页面及JS/CSS资源,便于本地部署后直接运行调试,整体仅4.64MB,轻量且结构清晰。系统设计上涉及令牌桶限流、Redis缓存与分布式锁、消息队列削峰、乐观锁防超卖、Spring Security安全防护等关键思路,适合用来对照学习高并发场景下的后端架构与数据库一致性方案。整体模块划分清晰,Java源码与静态资源分离,便于按业务入口逐层阅读。除核心代码外,还附带Bootstrap和Layer UI相关静态文件,可快速搭建出可演示的管理与秒杀页面。目前已有35人学习下载,对正在准备SpringBoot项目实战或电商系统设计的开发者来说,是一份便于拆解与二次开发的参考资料。
1. 秒杀系统的技术底色:SpringBoot2.0是绕不开的载体
秒杀系统的难点从来不是某个技术点,而是把整个链路放在同一套约束下:前端的点击是脉冲式的,后端的数据却必须始终一致。用SpringBoot2.0做载体时,最常被高估的是数据库,最容易被低估的是Redis和消息队列的配合。常见的误区是直接把请求打到数据库,库存扣减用一条SQL执行完后发现依然超卖,或者接口响应慢了开始盲目加线程数。这篇文章围绕SpringBoot2.0秒杀系统的完整实现展开,先讲清楚上层架构,再给出可复现的库存防超卖方案、流量削峰方案与压测调优参数,适合正在落地秒杀项目的后端工程师,也适合用秒杀场景来系统性理解高并发的开发者。
2. SpringBoot2.0秒杀项目的依赖清单与核心配置
2.1 为什么把Redis和RabbitMQ放进秒杀链路
秒杀接口在开始售卖后的前几秒内会有大量读请求和写请求,而这些请求的大部分结果是“库存不足”或“已抢完”。如果所有请求都走到MySQL,事务日志和行锁会让数据库的连接池被打满,QPS上不去且影响其他业务。常见做法是让Redis承担两个职责:一个是秒杀商品详情的缓存,承接读请求;另一个是库存的预扣减,用它的单线程模型保证自减操作的原子性。Redis承担了第一次筛选后,真正需要写数据库的请求量就会从几万降到几百。
RabbitMQ在链路里负责削峰。预扣成功后的请求不直接更新数据库,而是作为消息发送到队列,由消费者按可控速率落库。这样做的好处是数据库的压力由队列消费速率决定,不再受瞬时点击量影响。SpringBoot2.0对Redis和AMQP都有自动装配,接入成本比自行组合客户端低很多,这也是秒杀系统最常见的组合方式。
2.2 pom.xml最小依赖清单与版本选择
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.0.9.RELEASE</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>1.3.2</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-amqp</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>这段依赖覆盖了Web入口、MySQL访问、Redis缓存与消息队列四个部分。SpringBoot2.0默认使用Lettuce作为Redis客户端,不需要额外引入Jedis,秒杀场景下Lettuce的连接池需要单独配置,稍后会在application.yml里体现。MyBatis Starter的版本不能省略,1.3.x是适配SpringBoot2.0.x的常用版本线,太高或太低都可能出现Bean装配冲突。
如果不想用MyBatis,换成Spring Data JPA也可以,但秒杀场景的SQL大多是库存扣减和订单插入,自己控制SQL更直观。RabbitMQ版本跟随Spring Boot自动管理,不要在依赖里手动指定与2.0.x不匹配的客户端版本,否则可能出现连接握手兼容问题。
2.3 application.yml中秒杀场景的关键配置
spring: datasource: url: jdbc:mysql://localhost:3306/seckill?useSSL=false&useUnicode=true&characterEncoding=utf8 username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 redis: host: 127.0.0.1 port: 6379 lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 rabbitmq: host: 127.0.0.1 port: 5672 username: guest password: guest listener: simple: acknowledge-mode: manual prefetch: 1 concurrency: 8 max-concurrency: 16HikariCP是SpringBoot2.0默认的数据库连接池,maximum-pool-size设置为20在多数秒杀场景下足够,数据库连接过多反而会增加锁等待和上下文切换。Redis的lettuce.pool.max-active控制Redis连接上限,秒杀瞬时并发高时可以适当调大,但要结合服务所在机器的文件句柄数。RabbitMQ的acknowledge-mode设为manual,是为了保证消息成功处理后再确认,避免消费者中途异常导致消息丢失;prefetch设1表示每个消费者每次只取一条消息,配合适当并发数来控制落库速率。
2.4 秒杀接口与普通接口的边界划分
秒杀系统不能只做一个“减库存”接口。常见做法是拆成三类接口。第一类读取秒杀商品详情,这类走Redis缓存和本地缓存,设计目标是高QPS、低延迟。第二类执行秒杀动作,接收用户请求,返回“排队中”或“成功”的标记。第三类查询秒杀结果,由前端轮询或等待推送,查询订单是否创建成功。
接口拆分后,数据库表只需要把关注点放在库存和订单上。库存表包含goods_id、stock和version,订单表包含user_id、goods_id和order_id。如果接口不拆分,一次请求里同时处理缓存、消息发送和数据库事务,任何一个环节的锁都会阻塞整条链路,压测时很难定位瓶颈。
3. 秒杀库存防超卖:乐观锁与Redis Lua脚本怎么写
3.1 超卖产生的两个根因
超卖的第一类原因是SQL写法不对。一条类似UPDATE seckill_goods SET stock = stock - 1 WHERE goods_id = ?的语句在并发下没有判断剩余库存,两个请求同时读取到stock为1,各自执行减一后变成-1,库存就变负了。第二类原因是事务隔离级别与锁机制配合不当:读到旧库存、在事务里做先查后改、或者表锁导致大量请求相互等待,都会造成库存数据失真。理解了这两点,才能理解为什么单纯的Redis自增减也无法直接解决库存一致,需要把缓存预扣和数据库扣减放在同一条规则里。
3.2 数据库乐观锁扣库存的SQL
数据库中存放真实库存,通过带条件的更新来保证不超卖。SQL如下:
-- 只有库存大于0时才允许扣减,影响行数为0表示已经抢完 UPDATE seckill_goods SET stock = stock - 1, version = version + 1 WHERE goods_id = #{goodsId} AND stock > 0 AND version = #{version};这条语句的关键在stock > 0条件。MySQL执行更新时会对命中行加锁,同一时间只有一个事务能成功执行,影响行数为1表示扣减成功,影响行数为0表示库存已为空。version字段用于避免旧版本覆盖新数据,也可以在应用层把version值带回,下次更新时再用。使用这种方式时,executeUpdate返回0后要立刻返回给用户“已抢完”,不要重试,因为库存已经没有了,重试只会增加数据库压力。
3.3 Redis预扣减的Lua脚本
数据库层面的乐观锁可以保证正确性,但不能承受秒杀刚开始的瞬时流量。常见的做法是在Redis里保存剩余库存,先用Lua脚本做原子扣减,把高并发的库存判断从数据库转移到内存操作。脚本如下:
-- 获取当前剩余库存,不存在或不足直接返回0 local stock = tonumber(redis.call('get', KEYS[1])) if not stock or stock <= 0 then return 0 end redis.call('decrby', KEYS[1], ARGV[1]) return 1脚本执行前先把秒杀商品的库存通过SET goods_1001_stock 100写入Redis,ARGV[1]为本次购买数量。Lua脚本在Redis中是原子执行的,中间不会被其他命令打断,因此不会出现两个请求同时读到剩余库存为1的情况。相比Java代码里先get再decr,这种方式省去了分布式锁的通信开销,性能更高。
3.4 预扣减与数据库落库的同步策略
Redis预扣成功只是“资格确认”,真正的库存扣减还是要落到数据库。如果数据库扣减失败,需要把Redis里的库存加回去。常见做法是在秒杀Controller中成功预扣后,立即把消息发送到RabbitMQ或写入本地延迟队列,消费者在事务内执行乐观锁更新。执行成功则写入订单;执行失败则调用补偿逻辑,把库存回补。
这里要注意,Redis中的库存是“可售余量”,数据库中的库存是“真实余量”,两者允许短暂不一致,但最终必须一致。补偿的触发条件要写清楚:只有数据库更新影响行数为0时才回补,不能因为消息重试就反复加库存。可以参考下面的处理矩阵:
| 数据库扣减结果 | 处理动作 | 是否回补Redis |
|---|---|---|
| 影响行数=1,事务提交成功 | 创建订单,返回成功 | 否 |
| 影响行数=0,库存不足 | 丢弃消息,返回已抢完 | 是 |
| 消费者异常,捕获后重试 | 重新执行落库逻辑 | 否 |
4. 秒杀流量削峰:RabbitMQ队列与限流拦截器的落地
4.1 消息队列在秒杀链路里解决什么问题
秒杀请求经过Redis预扣后,仍然可能有几千条写请求要进入数据库。如果这些请求同时处在同一个事务里,MySQL的锁竞争会让响应时间飙升。消息队列的作用是把“接收请求”和“处理请求”解耦:秒杀接口只负责返回“排队中”,队列消费者在后续一段时间内匀速地把订单数据写入数据库。这样做牺牲了一点响应速度,但换来了系统的整体可靠性。
4.2 生产者发送秒杀消息的代码
在秒杀Service中,预扣成功的请求不直接调用订单Service,而是构造一个消息体发送。代码示例如下:
// 预扣成功后发送消息,返回排队中 String orderNo = "SK" + System.currentTimeMillis() + userId; SeckillMessage message = new SeckillMessage(); message.setGoodsId(goodsId); message.setUserId(userId); message.setOrderNo(orderNo); rabbitTemplate.convertAndSend( "SEC_KILL_EXCHANGE", "seckill.order", JSON.toJSONString(message) );convertAndSend的三个参数分别是交换机名、路由键和消息体。这里使用直连交换机即可,路由键保持唯一,消费者绑定队列到同一个交换机并监听该路由键。消息体的序列化建议显式使用JSON字符串而不是Java对象,避免RabbitMQ的序列化器在类型转换上出问题。生产环境建议给队列设置最大长度和消息TTL,防止消费速度跟不上时消息无限堆积。
4.3 消费者消费消息与手动ACK
消费者监听队列,在收到消息后执行下单逻辑。代码框架如下:
@RabbitListener(queues = "SEC_KILL_ORDER_QUEUE") public void onMessage(String body, Channel channel, @Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException { SeckillMessage msg = JSON.parseObject(body, SeckillMessage.class); try { // 处理成功手动确认,失败则重新入队 seckillService.createOrder(msg.getUserId(), msg.getGoodsId(), msg.getOrderNo()); channel.basicAck(tag, false); } catch (Exception e) { channel.basicNack(tag, false, true); } }手动ACK的好处是消费者在处理过程中如果应用崩溃,消息不会被确认,RabbitMQ会把消息重新投递给其他消费者。注意basicNack的第三个参数requeue设为true时要谨慎,如果失败原因是库存不足或订单已存在这类明确错误,应该设为false并把消息丢弃或转存到死信队列,否则会形成死循环。
4.4 基于Redis计数器的限流与防刷
4.4.1 拦截器里做限流
秒杀接口必须做用户维度的限流。常见的做法是写一个HandlerInterceptor,在preHandle里对userId做计数。示例逻辑:
// 同一用户每日最多请求秒杀接口10次 String key = "seckill:limit:" + userId + ":" + LocalDate.now(); Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1) { redisTemplate.expire(key, 1, TimeUnit.DAYS); } if (count > 10) { throw new SeckillException("操作过于频繁"); }这个方案用Redis自增实现计数器,第一次执行时设置一天的过期时间。limit的数量要根据业务划分:同一用户对同一商品限购1件,对秒杀接口的每日请求数限制在10次以内,对防刷要求高的场景还可以加IP维度的前缀。拦截器的好处是与业务接口解耦,所有秒杀相关接口共用同一套判断。
4.4.2 前端置灰与后端幂等
前端在点击秒杀按钮后立即置灰并展示“排队中”,防止用户反复点击。后端要配合做幂等校验,在Service入口检查Redis中的setNX标记,保证同一用户对同一商品只创建一次订单记录。限流拦截器解决的是流量总量,幂等解决的是重复请求,两者不能互相替代。
5. 秒杀性能调优:JMeter压测与线程池参数调整
5.1 压测前先明确要观测的指标
压测不是简单地把线程数调大看接口报不报错。秒杀系统的核心指标是QPS、平均响应时间和错误率。QPS要看整个链路的能力,而不是只看Tomcat接收请求的数量,因为Redis和数据库都可能成为瓶颈。常见做法是先压Redis预扣接口,摸清单机上限;再压数据库落库接口,看连接池是否被打满;最后把整条链路串起来压。
5.2 Tomcat线程池与数据库连接池参数
SpringBoot2.0内嵌的Tomcat默认线程池参数偏保守,按照机器规格做调整是必要的。配置如下:
server: tomcat: # 200左右在普通物理机上已经需要留意CPU上下文切换 max-threads: 200 min-spare-threads: 20 accept-count: 200 max-connections: 10000max-threads控制同时处理请求的线程数,不是越大越好。accept-count是请求排队数,当线程数满时新的连接进入等待队列。数据库连接池由Hikari控制,建议设置max-lifetime小于数据库wait_timeout,连接池大小控制在15到30之间,避免大量连接长时间持有。
5.3 JMeter线程组的参数设置
JMeter压测秒杀接口时,线程组设置不要直接用固定线程数跑满。常见方案是使用Ultimate Thread Group,把线程数在10秒内逐步从50增加到200,观察QPS和错误率的变化。线程数分别为50、100、200时,分别记录平均响应时间和TP99。压测过程中要同步监控Redis的ops/s和数据库的活跃连接数,判断瓶颈出现在哪一层。
| 线程数 | 预期QPS | 观察重点 |
|---|---|---|
| 50 | 按接口复杂度,一般数百 | 平均响应时间是否线性增长 |
| 100 | 视依赖组件上限 | Redis ops/s、数据库活跃连接数 |
| 200 | 不应继续增长 | 错误率、超时请求、CPU使用率 |
5.4 压测结果暴露出的常见瓶颈
压测中经常出现三种情况。第一种是QPS上到几百后不再增长,错误率升高,此时Tomcat的max-threads可能已经打满,调整线程池参数后通常有改善。第二种是数据库连接池活跃数打满但CPU不高,说明SQL执行时间过长或行锁等待严重,需要检查库存更新的SQL是否走了二级索引。第三种是Redis的响应时间很高,多半是使用了过多的大key或热点key,秒杀场景要避免在Redis里保存复杂结构,直接用String存库存值。
6. 秒杀系统进阶:热点缓存、幂等控制与库存回补
6.1 本地缓存与Redis双层缓存
秒杀商品详情页的请求量远高于下单接口,如果每次都查Redis,Redis的网络IO也会成为瓶颈。常见做法是在JVM内存里维护Caffeine缓存,设置秒杀商品的短过期时间,让大多数详情请求在本地返回。Caffeine的配置可以简单写为:
// 本地缓存只放商品详情,不参与库存强一致 Cache<String, SeckillGoods> localCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(5, TimeUnit.SECONDS) .build();这里把过期时间设短,是为了在缓存更新失败时让脏数据尽快失效。本地缓存适合数据量小且读密集的场景,不能存放需要强一致的库存值。
6.2 请求幂等控制的setNX写法
// 同一用户对同一商品只允许一次成功的秒杀请求 String key = "seckill:order:" + userId + ":" + goodsId; Boolean first = redisTemplate.opsForValue().setIfAbsent(key, orderNo, 10, TimeUnit.MINUTES); if (!Boolean.TRUE.equals(first)) { throw new SeckillException("已经参与过秒杀"); }setIfAbsent在Redis中对应SETNX语义,第一次执行返回true并写入顺序号,后续相同用户和商品的请求直接失败。这里要注意使用布尔值判断,Redis操作失败时不能返回false当作重复请求处理,要区分是网络异常还是业务重复。
6.3 超时未支付与库存回补流程
秒杀订单通常有支付时限。常见做法是下单后把订单标记为“待支付”,并设置支付过期时间,通过延迟队列或定时任务扫描超时订单。回补逻辑要先更新数据库库存,成功后再把Redis库存加回,顺序不能反。如果先回补Redis,数据库库存还没有变化,新请求可能会预扣到超过数据库真实库存的数目,造成最终数据库扣减失败。整个过程可以通过记录消息的唯一订单号来保证不会重复回补。
本文还有配套的精品资源,点击获取