简介:这是一套基于Spring Boot开发的电商秒杀系统实战项目,面向计算机类专业在校生、毕业设计学生及Java初学者,聚焦高并发场景下的超卖防控核心问题。项目采用MySQL+Spring Boot+Redis+RabbitMQ技术栈,通过SQL行锁校验、数据库唯一索引约束、异步消息队列三重机制协同保障库存一致性,具备完整业务闭环与工程实践参考价值。压缩包共147个文件(1.57MB),涵盖67个Java后端逻辑类、22个HTML前端页面、12个JS交互脚本、10个CSS样式文件及SQL建表语句、配置文件、文档说明等,结构清晰,开箱即用。已有128人下载学习,所有代码均经实测运行通过,可直接用于课程设计、毕设立项或二次开发——尤其适合理解分布式锁替代方案、消息削峰实践及前后端分离式秒杀流程设计。
1. 秒杀不是“快”,而是“稳”:为什么90%的SpringBoot电商秒杀项目上线即崩
你见过最离谱的秒杀现场是什么?我去年帮一家做美妆团购的创业公司做系统压测,他们用的是标准SpringBoot + MyBatis + MySQL的模板项目,没加任何限流、缓存、队列——结果在预演时,300人并发抢50支限量口红,数据库连接池直接耗尽,Tomcat线程数飙到800+,后台日志里全是Connection refused和Lock wait timeout exceeded。更讽刺的是,前端页面还在欢快地倒计时,用户点下去毫无反应,刷新后发现“已售罄”,但后台查库存根本没扣减成功,订单表里空空如也。这不是高并发,这是“高并发幻觉”。
这就是典型把“秒杀”当成了“快点提交表单”的认知误区。真正的秒杀系统,核心从来不是让请求跑得更快,而是让绝大多数无效请求在抵达数据库之前就安静消失。SpringBoot本身只是个脚手架,它不解决高并发问题,它甚至会放大问题——默认的@RestController是同步阻塞的,每个HTTP线程都卡在数据库IO上,线程池一满,新请求直接排队等死。你看到的“卡顿”,本质是线程被锁死;你看到的“超卖”,本质是数据库行锁没来得及释放就被下一个请求绕过。
所以,这个标题里的“基于SpringBoot的电商秒杀系统”,真正要拆解的不是SpringBoot怎么写Controller,而是如何用SpringBoot这把刀,切开高并发场景下的数据一致性、系统稳定性、用户体验这三块硬骨头。源代码和文档说明的价值,不在于教你复制粘贴,而在于让你看清每一行代码背后,是在对抗哪个具体的技术瓶颈:是Redis原子操作防超卖?是RabbitMQ削峰填谷?还是Sentinel熔断降级保主链路?没有这些上下文,源码就是一堆带注释的Java文件,文档就是一份格式工整的Word。
我见过太多人拿着“秒杀源码”直接部署,结果在真实流量下崩溃。原因很简单:他们只看到了“用了Redis”,没看到Redis连接池配置是否合理;只看到了“加了消息队列”,没看到消费者线程数是否匹配业务吞吐;只看到了“写了分布式锁”,没看到锁的粒度是商品ID还是SKU ID,失效时间设成30秒还是3秒。这些细节,才是决定系统生死的分水岭。接下来,我们就从最底层的流量入口开始,一层层剥开这个系统的骨架。
2. 流量洪峰的第一道闸门:前置拦截与静态资源分离
秒杀开始前5分钟,用户已经在疯狂刷新页面。这时候,你的服务器收到的不是300个下单请求,而是3000个、30000个页面加载请求——CSS、JS、图片、字体,全挤在同一个域名下。如果这些静态资源和动态接口混在一起,Nginx或Tomcat的连接数、带宽、CPU,全被无意义的HTTP GET拖垮。我亲眼见过一个项目,因为首页轮播图用了10MB的未压缩高清图,导致秒杀开始前,CDN节点带宽打满,连登录接口都响应超时。
2.1 静态资源必须物理隔离
这不是优化建议,是生存底线。所有HTML、CSS、JS、图片、字体,必须托管在独立的静态资源服务器或CDN上,域名与主站完全分离。比如主站是shop.example.com,静态资源放在static.example.com或cdn.example.com。这样做的好处是:
- 浏览器并发限制解除:现代浏览器对同一域名的并发HTTP请求数有限制(通常6~8个),但对不同域名是分别计数的。用户刷页面时,几十个静态资源请求可以并行下载,不会阻塞后续的AJAX调用。
- CDN缓存命中率拉满:CDN节点缓存的是静态文件,更新频率低、体积固定,缓存策略简单(如
Cache-Control: public, max-age=31536000)。而动态接口(如/api/seckill/start)永远不该被CDN缓存,否则用户看到的就是过期的秒杀状态。 - 后端压力归零:Tomcat不再需要处理任何静态文件的
GET请求,所有线程都留给真正的业务逻辑。实测数据:某次压测中,将静态资源剥离后,相同硬件下,Tomcat能承载的并发连接数从1200提升到4500+。
提示:SpringBoot内置的静态资源路径(
/static,/public)在生产环境必须禁用。在application.yml中明确关闭:spring: web: resources: add-mappings: false # 关键!禁止SpringBoot自动映射静态资源同时,在Nginx配置中,对
/static/、/js/、/css/等路径做location块,直接root指向CDN或本地静态目录,try_files兜底,全程不经过SpringBoot应用。
2.2 页面级缓存与防刷机制
秒杀页面本身(seckill.html)是动态生成的,但它有极强的缓存价值。用户看到的倒计时、商品信息、库存状态,在秒杀开始前几分钟内几乎是不变的。如果每次刷新都走后端渲染,又是一波无谓压力。
最佳实践是:服务端渲染一次,客户端缓存N分钟。具体做法:
- 后端用Thymeleaf或FreeMarker渲染
seckill.html,在模板中注入当前时间戳、秒杀开始时间、商品基础信息(不含实时库存)。 - 响应头强制设置缓存:
Cache-Control: public, max-age=300(5分钟)。这意味着浏览器5分钟内再次访问该URL,直接读本地缓存,不发请求。 - 实时库存、倒计时毫秒数,通过独立的、带缓存控制的AJAX接口获取(如
/api/seckill/status?goodsId=1001),该接口可设为max-age=1,每秒刷新一次,但只返回几个关键字段,体积小、速度快。
防刷则更直接:在Nginx层做基础防护。不是靠复杂算法,而是用最朴素的规则:
# 对/seckill/路径,限制单IP每分钟最多10次请求 limit_req zone=seckill burst=20 nodelay; # 对/seckill/do路径(下单接口),限制单IP每分钟最多3次 limit_req zone=do_seckill burst=5 nodelay;burst参数允许短时突发,nodelay避免排队等待,让超出的请求立刻返回503 Service Temporarily Unavailable。这比在SpringBoot里用@RateLimit注解高效得多——请求在到达Java进程前就被拦住了。
2.3 接口层的“漏斗式”过滤
到了Controller这一层,请求已经过了Nginx,但还没碰数据库。这里是第一道业务逻辑过滤网。很多开源秒杀项目在这里就栽了:一个@PostMapping("/seckill")方法,里面直接查库存、扣库存、生成订单……看似简洁,实则灾难。
正确的做法是三级漏斗:
- 资格校验漏斗:检查用户是否登录、是否在白名单(如有)、是否满足活动规则(如会员等级)。失败直接返回
{"code":403,"msg":"无参与资格"},不进下一步。 - 库存预检漏斗:不查数据库,查Redis缓存的
seckill:stock:1001值。如果为0,直接返回{"code":400,"msg":"库存已售罄"}。注意,这里查的是“预热库存”,不是实时库存,它由后台定时任务同步,有几秒延迟,但足够挡住95%的无效请求。 - 令牌桶漏斗:对通过前两关的请求,再进行速率限制。用Redis实现分布式令牌桶,每个商品ID一个桶,初始令牌数=剩余库存,每成功下单消耗1个令牌。桶满则拒绝。这比单纯限制QPS更精准,因为它和库存强绑定。
这三层漏斗下来,最终能走到“扣库存、写订单”这一步的请求,可能不到原始流量的1%。这才是“稳”的起点——不是让数据库扛住10万QPS,而是让数据库只看到1000QPS。
3. 库存扣减的生死线:Redis原子操作与分布式锁的实战边界
库存超卖是秒杀系统最经典的“灵魂拷问”。网上流传着无数种方案:MySQL乐观锁、悲观锁、Redis Lua脚本、ZooKeeper分布式锁……但真相是:没有银弹,只有取舍。选错方案,轻则超卖,重则雪崩。
3.1 为什么MySQL行锁在秒杀场景下是“纸老虎”
很多人第一反应是“用UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0”,认为WHERE条件天然防超卖。这在TPS几百的普通电商没问题,但在秒杀场景下,它会成为性能黑洞。
原因有二:
- 锁竞争激烈:所有请求都争抢同一行记录的行锁。InnoDB的行锁是基于索引的,如果
id是主键,那锁的是聚簇索引的某一行。但当并发极高时,大量线程在等待同一把锁,形成“锁队列”。一个线程持有锁10ms,后面100个线程就得排队等1秒,期间CPU在空转。 - 事务回滚成本高:当
stock > 0不成立时,UPDATE影响行数为0,事务需要回滚。虽然回滚快,但频繁的短事务+回滚,会显著增加InnoDB的undo log压力和CPU消耗。
我做过对比测试:在2000并发下,纯MySQL方案的平均响应时间从200ms飙升到1200ms,错误率(超时)达35%。而同样并发下,Redis方案稳定在50ms内,错误率<0.1%。
3.2 Redis Lua脚本:原子性扣减的黄金标准
Redis的单线程模型和Lua脚本的原子执行,是解决库存扣减的最优解。核心思想:把“查库存”和“扣库存”两个操作,封装在一个Lua脚本里,由Redis保证整个脚本执行的原子性。
标准脚本长这样:
-- KEYS[1] = 商品ID, ARGV[1] = 扣减数量 local stockKey = "seckill:stock:" .. KEYS[1] local stock = redis.call('GET', stockKey) if not stock or tonumber(stock) < tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call('DECRBY', stockKey, ARGV[1]) return 1 -- 扣减成功调用方式(SpringBoot中):
String script = "...上面的Lua脚本..."; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class); Long result = redisTemplate.execute(redisScript, Collections.singletonList("1001"), "1"); if (result == 1L) { // 扣减成功,进入下单流程 } else { // 库存不足,返回错误 }为什么这是黄金标准?
- 绝对原子性:Redis执行Lua脚本时,其他命令必须等待,不存在竞态条件。
- 网络开销最小:一次网络往返,完成“读-判-写”全过程,避免了多次Redis命令的RTT叠加。
- 性能极致:Redis内存操作,单核QPS轻松过10万,远超MySQL。
注意:
DECRBY操作后,库存值可能为负数。这没关系,因为脚本里已经做了判断。但你需要确保后续的订单创建逻辑,能正确处理“扣减成功但实际库存为0”的情况(比如用Redis的INCRBY回滚,或用消息队列异步补偿)。
3.3 分布式锁的适用场景与致命陷阱
分布式锁(如Redisson的RLock)常被误用为库存扣减的解决方案。它的定位其实是保护“非幂等性”的临界资源,比如“生成唯一订单号”、“发送短信验证码”。
举个例子:用户下单成功后,需要生成一个全局唯一的订单号(如20240520123456789)。这个动作不能重复执行,否则会产生重复订单。此时,用商品ID作为锁的key,对generateOrderNo()方法加锁,是合理的。
但如果你用分布式锁去包裹整个“查库存-扣库存-写订单”流程,就错了。原因:
- 锁粒度太大:锁住了整个业务流程,而不是最小的临界区。一个请求持锁100ms,其他99个请求全在等,QPS直接砍掉99%。
- 锁续期风险:Redisson的看门狗机制(自动续期)在GC停顿时可能失效,导致锁提前释放,引发超卖。
- 网络分区问题:Redis主从切换时,客户端可能同时在两个节点上获得锁(Redlock算法也难完全规避)。
我的经验是:分布式锁只用于“必须且只能执行一次”的操作,且该操作本身耗时要极短(<10ms)。库存扣减,交给Redis Lua;订单生成,用数据库自增ID或雪花算法;支付回调,用幂等表(order_id + pay_no唯一索引)。
4. 订单生成的异步化革命:从同步写库到消息队列削峰
秒杀的核心矛盾是:瞬时流量峰值 vs 数据库写入能力恒定。即使库存扣减用Redis解决了,订单数据的落库,依然是个大山。用户点击“立即抢购”,期望1秒内看到“下单成功”,但数据库写入一条订单记录,涉及主键生成、索引更新、事务日志刷盘,平均耗时50~200ms。1000QPS的下单请求,意味着数据库每秒要处理1000次写入,这已经逼近中小规模MySQL的极限。
4.1 同步下单的“甜蜜陷阱”
很多初学者写的秒杀代码,是这样的:
// 伪代码 if (redisDecrStock(goodsId)) { Order order = buildOrder(goodsId, userId); orderMapper.insert(order); // 同步写库 return success(); }看起来逻辑清晰,但问题巨大:
- 用户体验差:用户要等数据库写完才收到响应,网络抖动或DB慢查询,会导致前端长时间转圈。
- 系统脆弱:一旦订单表写入慢(如索引失效、磁盘IO瓶颈),整个下单链路阻塞,上游Redis库存被扣,但订单没生成,形成“幽灵订单”,后续无法对账。
- 扩展性为零:想扩容,只能垂直升级数据库,成本高昂。
4.2 RabbitMQ的“削峰填谷”实战配置
引入RabbitMQ,不是为了“高大上”,而是为了把“用户感知的下单”和“系统真实的落库”解耦。用户点击后,系统立刻返回“抢购成功,订单正在生成”,然后把订单数据发到MQ,由后台消费者慢慢处理。
关键配置点,决定了成败:
- Exchange类型:必须用
direct或topic,绝不能用fanout。fanout是广播,所有绑定队列都会收到消息,违背了“一个订单只被一个消费者处理”的原则。 - Queue持久化:
durable=true,确保MQ重启后消息不丢失。秒杀消息丢了,等于用户钱付了但没订单,这是重大事故。 - 消息持久化:
MessageProperties.setDeliveryMode(MessageDeliveryMode.PERSISTENT),配合Queue持久化,双重保险。 - Confirm机制:开启Publisher Confirm,确保消息100%投递到Broker。SpringBoot中配置:
spring: rabbitmq: publisher-confirm-type: correlated # 推荐correlated,可获取唯一ID publisher-returns: true
消费者端,更要谨慎:
- 手动ACK:
channel.basicAck(deliveryTag, false),必须在订单成功写入数据库后才ACK。否则消息丢失,订单就没了。 - 重试与死信:消费者处理失败(如DB异常),不能直接丢弃。应
basicNack并设置requeue=false,让消息进入死信队列(DLX)。死信队列绑定到另一个Exchange,由专门的“订单异常处理服务”消费,人工介入或自动补偿。
4.3 消费者线程池的黄金配比
消费者线程数不是越多越好。线程数过多,会导致数据库连接池耗尽、CPU上下文切换开销剧增。
我的经验值公式:消费者线程数 = 数据库连接池最大连接数 × 0.8。例如,HikariCP配置maximum-pool-size=20,那么消费者线程数设为16。
为什么?
- 每个消费者线程在处理消息时,都需要从连接池获取一个Connection。
- 如果消费者线程数=20,而连接池也是20,那么当所有线程都在执行SQL时,连接池已空,新来的线程会阻塞在
getConnection()上,形成“假死”。 - 留20%余量,是为了应对连接偶尔的网络抖动、慢查询等临时占用。
此外,消费者必须有幂等性设计。因为MQ有“至少一次”投递语义,同一条消息可能被消费多次。解决方案:
- 数据库唯一索引:订单表建联合唯一索引
UNIQUE KEY uk_user_goods_time (user_id, goods_id, create_time),时间精确到秒。重复消息插入时,数据库报Duplicate entry,捕获异常即可。 - Redis记录已处理ID:消息体带唯一ID(如UUID),消费者先
SETNX到Redis,成功才处理,失败则跳过。注意设置过期时间,避免Redis内存泄漏。
5. 全链路监控与降级预案:当系统开始“喘气”时该怎么办
再完美的架构,也无法100%抵御所有意外。网络抖动、Redis集群脑裂、MySQL主库负载飙升、第三方支付接口超时……这些不是“如果”,而是“何时”。一个成熟的秒杀系统,必须有清晰的“呼吸节奏”——知道什么时候该降级,降级后用户还能做什么。
5.1 监控指标的“三板斧”
不要堆砌监控图表,聚焦三个核心指标,它们是系统的“血压计”:
- Redis库存命中率:
1 - (cache_miss_count / total_request_count)。正常应>95%。如果跌到80%,说明大量请求穿透到DB,可能是缓存预热失败或缓存击穿。 - MQ积压量:
queue_depth。健康值应<1000。如果持续>5000,说明消费者处理能力不足或DB写入瓶颈,需紧急扩容消费者或优化SQL。 - 下单接口P99延迟:99%的请求响应时间。秒杀场景下,目标应<800ms。如果>2000ms,说明链路某环节严重阻塞(如DB锁、GC、网络)。
这些指标,必须接入Prometheus + Grafana,并设置告警阈值。告警不是发邮件,而是触发自动化预案。
5.2 Sentinel的“熔断-降级-限流”三位一体
Alibaba Sentinel是SpringBoot生态里最成熟的流控组件。它不是简单的“QPS限流”,而是能感知业务维度的智能守护。
- 熔断(Circuit Breaking):当
/api/seckill/do接口的错误率(5xx)在10秒内超过50%,Sentinel会自动熔断该接口5秒。熔断期间,所有请求直接返回{"code":500,"msg":"服务繁忙,请稍后再试"},不走任何业务逻辑,给后端留出恢复时间。 - 降级(Degradation):当
/api/pay/callback(支付回调)的平均响应时间>2000ms,Sentinel会降级该接口,返回一个“支付结果待确认”的兜底JSON,避免支付服务拖垮整个下单链路。 - 限流(Flow Control):对
/api/seckill/status(查秒杀状态)接口,按QPS限流。不是全局限流,而是按goodsId维度限流,确保热门商品不挤占冷门商品的资源。
配置示例(application.yml):
spring: cloud: sentinel: transport: dashboard: localhost:8080 # Sentinel控制台地址 datasource: ds1: nacos: server-addr: 127.0.0.1:8848 style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />