1. 为什么“秒杀”不是个功能,而是一场系统压力测试
“秒杀系统”这四个字在招聘JD里高频出现,在技术分享会上常被当作高并发标杆案例,在团队复盘时又总带着点悲壮色彩——它从来就不是单纯写个“下单接口”就能交付的功能模块。我最早接触这个场景是在一个电商类模拟项目X中,当时以为只要加个Redis计数器+数据库行锁就能稳住,结果压测到3000QPS时,库存超卖直接突破20%,订单表写入延迟飙到8秒以上,后台日志里全是MySQL的Deadlock异常。后来才明白:秒杀本质是把业务逻辑从“正确性优先”强行切换到“可用性优先”的临界操作。它不考验你写了多少优雅代码,而是拷问你在流量洪峰下,敢不敢主动丢弃部分请求、敢不敢绕过强一致性、敢不敢让一部分用户看到“已售罄”却仍能保证最终数据准确。
这个认知转变花了我整整三个月。期间翻遍了某头部电商平台公开的技术白皮书,拆解了十几个开源秒杀Demo,甚至重写了三次核心链路——第一次用纯SpringBoot+MyBatis硬扛,第二次引入RabbitMQ削峰,第三次才真正理解“分层过滤”的价值。你会发现,所有成功的秒杀系统都遵循同一套物理法则:流量必须在抵达数据库前被层层拦截,越靠近用户侧的过滤,成本越低、效率越高、破坏力越小。比如前端按钮置灰只是第一道心理防线,真正的第一道技术防线其实是Nginx层的限流;而Redis原子扣减库存,根本不是为了“快”,而是为了用内存操作的确定性,把95%的无效请求挡在数据库门外。
关键词里虽然没填,但“SpringBoot”三个字已经框定了技术栈边界:它意味着我们不用从零造轮子,但也不能依赖框架默认配置。SpringBoot的自动装配在日常开发中是甜点,在秒杀场景下却可能变成陷阱——比如默认的Tomcat线程池(200个线程)在万级并发下会瞬间打满,而Spring的@Transactional注解若没配传播行为,一次超时回滚可能拖垮整个连接池。所以这篇内容不会教你“如何用SpringBoot写个秒杀接口”,而是带你重建一套面向失败设计的秒杀骨架:从最外层的接入网关怎么配置,到中间件怎么选型,再到数据库怎么妥协,最后连JVM参数都要为它单独调优。这不是炫技,而是当你面对真实大促流量时,唯一能让你睡得着觉的底气。
2. 四层漏斗模型:为什么99%的请求根本不该碰数据库
很多人一上来就想优化SQL,想给订单表加索引,想研究MySQL的InnoDB行锁机制——方向完全错了。真正的秒杀优化,90%的工作量都在数据库之外。我把它拆成四层物理漏斗,每一层都在用更廉价的资源,过滤掉更大比例的无效请求。这个模型不是理论推演,而是我在某公司大促保障期间,盯着监控大盘实时验证过的:当峰值流量达到12000QPS时,经过四层过滤后,最终落到MySQL上的写请求只有不到470QPS,压降比高达96.1%。
2.1 第一层:接入层限流(Nginx + Lua)
这是离用户最近、成本最低的防线。别指望SpringBoot应用层来扛流量,等请求进到Java进程里,线程、内存、GC早已开始呻吟。Nginx的limit_req模块配合Lua脚本,能在毫秒级完成令牌桶校验。关键参数不是随便填的:
# /etc/nginx/conf.d/seckill.conf limit_req_zone $binary_remote_addr zone=seckill_ip:10m rate=10r/s; limit_req_zone $server_name zone=seckill_server:10m rate=500r/s; server { location /api/seckill/buy { # 先按IP限流:单IP每秒最多10次(防脚本刷) limit_req zone=seckill_ip burst=20 nodelay; # 再按服务限流:全站每秒最多500次(保底兜底) limit_req zone=seckill_server burst=1000 nodelay; # 关键:添加自定义Header标识已过第一关 add_header X-Filter-Stage "nginx"; proxy_pass http://springboot_backend; } }提示:burst参数不是越大越好。实测发现,当burst设为500时,突发流量会堆积大量等待队列,导致平均响应时间飙升。我们最终采用“小burst+高rate”策略(burst=20, rate=10r/s),配合前端JS做平滑请求,效果远优于“大burst+低rate”。
2.2 第二层:网关层熔断(Spring Cloud Gateway)
当Nginx放行的请求抵达网关,这里要做的是业务级熔断。注意,这不是简单的Hystrix降级,而是基于实时指标的动态决策。我们用Sentinel替代了原生熔断器,原因很实在:Sentinel的QPS统计精度到毫秒,且支持运行时规则热更新。核心配置如下:
# application.yml spring: cloud: sentinel: transport: dashboard: localhost:8080 # Sentinel控制台地址 datasource: ds1: nacos: server-addr: localhost:8848 >-- seckill.lua local stockKey = KEYS[1] local logKey = KEYS[2] local userId = ARGV[1] -- 1. 获取当前库存 local stock = tonumber(redis.call('GET', stockKey)) if stock <= 0 then return {0, "库存不足"} end -- 2. 原子扣减 local result = redis.call('DECR', stockKey) if result < 0 then -- 扣减后为负,说明超卖,回滚 redis.call('INCR', stockKey) return {0, "库存超卖"} end -- 3. 记录用户抢购日志(用于后续生成订单) redis.call('SADD', logKey, userId) return {1, "抢购成功"}调用时传入KEYS和ARGV:
String script = loadScript("seckill.lua"); // 加载脚本 Object result = redisTemplate.execute( new DefaultRedisScript<>(script, List.class), Arrays.asList("seckill:stock:1001", "seckill:log:1001"), "user_88234" );实测心得:Lua脚本里绝对不要做耗时操作(如HTTP调用、复杂计算)。我们曾因在脚本里调用
TIME命令获取时间戳,导致Redis单核CPU飙到100%,最终改用客户端传入时间戳解决。另外,SADD记录日志不是为了实时查询,而是为异步订单生成提供原始凭证——这点很多教程都忽略了。
2.4 第四层:消息队列缓冲(RabbitMQ死信队列)
当Redis扣减成功,真正的订单创建才刚刚开始。但此时绝不能同步调用订单服务,否则Redis的毫秒级响应会被拖成秒级。我们的方案是:Redis扣减成功后,立即向RabbitMQ发送一条轻量消息,内容仅包含userId、skuId、timestamp三个字段。订单服务消费消息后,再执行真正的订单落库、支付回调等重操作。
关键在于死信队列的设计:
- 普通队列
seckill.order.queue设置TTL=30秒(防消息积压); - 绑定死信交换机
seckill.dlx.exchange; - 死信队列
seckill.order.dlq专门处理超时未消费的消息,由独立线程扫描并告警。
这样做的好处是:当订单服务因DB慢或网络抖动暂时不可用时,消息在普通队列中等待,30秒后自动进入死信队列,既保证了消息不丢失,又避免了消费者雪崩。
3. SpringBoot的“反直觉”配置:那些默认值正在悄悄拖垮你的系统
SpringBoot开箱即用的便利性,在秒杀场景下反而成了最大隐患。很多开发者直到线上报警才发现,问题根源竟是application.properties里一行没动过的配置。我把这些“温柔的陷阱”按风险等级归为三类,每一条都附带真实故障案例。
3.1 线程池:别让Tomcat默认配置成为性能天花板
SpringBoot 2.3+默认内嵌Tomcat,其线程池参数藏在org.apache.catalina.connector.Connector类里。默认值是:
maxThreads=200(最大工作线程数)minSpareThreads=10(最小空闲线程数)acceptCount=100(等待队列长度)
表面看200线程似乎够用,但实际压测中,当并发请求达1500时,线程池迅速打满,新请求被迫排队。而acceptCount=100意味着最多100个请求在队列中等待,超出的请求直接被Nginx拒绝(返回502)。解决方案不是盲目调大,而是精准匹配:
# application.yml server: tomcat: # 核心调整:maxThreads设为CPU核心数*4(实测最优) max-threads: 64 # 16核服务器 # acceptCount必须大于等于maxThreads,避免队列溢出 accept-count: 128 # 关键:关闭keep-alive,减少连接占用 connection-timeout: 5000 # 启用异步IO(NIO) protocol: org.apache.coyote.http11.Http11NioProtocol踩坑实录:某次大促前,运维同事将
max-threads调至1000,结果JVM频繁Full GC,GC时间占比超40%。根本原因是线程过多导致堆内存碎片化。我们最终通过jstat -gc分析确认,将线程数回归到64,并增加-XX:+UseG1GC -XX:MaxGCPauseMillis=200参数,稳定支撑了8000QPS。
3.2 数据源:HikariCP的隐藏参数比连接数更重要
HikariCP号称“史上最快连接池”,但它的默认配置在高并发下同样危险。最致命的是connection-timeout(获取连接超时)和validation-timeout(校验超时):
# application.yml spring: datasource: hikari: # 默认30秒!秒杀场景下必须压缩到1秒内 connection-timeout: 1000 # 默认5秒,校验SQL太耗时,直接禁用 validation-timeout: 1000 # 关键:启用连接泄漏检测(防未关闭连接) leak-detection-threshold: 60000 # 最大连接数不是越多越好,需匹配DB承受力 maximum-pool-size: 32 # 最小空闲连接数设为0,避免空闲连接占用资源 minimum-idle: 0为什么
minimum-idle=0?因为秒杀是脉冲式流量,空闲连接在非高峰时段就是资源浪费。我们实测发现,当minimum-idle=10时,DB连接数长期维持在10+,而实际有效连接不足3个,白白消耗DB许可。
3.3 事务管理:@Transactional的传播行为是定时炸弹
秒杀下单流程中,常见的错误写法是:
@Service public class SeckillService { @Transactional // ❌ 危险!默认REQUIRED传播行为 public Result seckill(Long skuId, String userId) { // 1. Redis扣减库存 if (!redisDecr(skuId)) return fail("库存不足"); // 2. 创建订单(同步调用) orderService.createOrder(skuId, userId); return success(); } }问题在于:如果orderService.createOrder()执行缓慢(如DB慢、网络抖动),整个事务会一直持有数据库连接,导致连接池迅速枯竭。正确做法是拆分事务边界:
@Service public class SeckillService { // 第一步:无事务,只做Redis操作 public Result preSeckill(Long skuId, String userId) { if (!redisDecr(skuId)) return fail("库存不足"); // 发送MQ消息,触发异步下单 rabbitTemplate.convertAndSend("seckill.order.exchange", "", new OrderMessage(skuId, userId)); return success(); } // 第二步:订单服务自己管理事务 @Service public class OrderService { @Transactional // ✅ 事务仅包裹DB操作 public void createOrder(OrderMessage msg) { // 插入订单表、扣减DB库存、更新用户积分... } } }经验总结:秒杀场景下,
@Transactional应该像手术刀一样精准,而不是一把大砍刀。我们团队约定:所有秒杀相关方法禁止使用@Transactional,必须显式声明事务边界,且每个事务内只做一件事。
4. 库存一致性:为什么“最终一致”才是秒杀的终极答案
几乎所有初学者都会陷入一个思维误区:必须保证Redis库存和MySQL库存实时一致。这种执念会让你走上一条不归路——要么用分布式锁强同步(性能暴跌),要么用消息队列最终一致(又怕丢消息)。其实,秒杀系统的库存一致性,只需要满足一个条件:所有成功抢购的用户,最终都能生成有效订单;所有未抢购成功的用户,绝不会产生订单。至于Redis和MySQL的数字是否时刻相等,根本不重要。
4.1 “双写”模式的幻觉与破灭
早期我们尝试过“Redis扣减+MySQL同步更新”模式:
// 伪代码:看似完美的双写 boolean redisSuccess = redisTemplate.opsForValue().decrement("stock:1001") > 0; if (redisSuccess) { // 立即更新MySQL orderMapper.updateStock(1001, -1); // 执行UPDATE语句 // 创建订单... }结果在压测中崩溃:当MySQL出现主从延迟时,updateStock返回影响行数为0(因从库还没同步到最新库存),但Redis已扣减,导致超卖。更糟的是,如果MySQL更新失败(如唯一索引冲突),Redis库存无法回滚——因为Lua脚本执行完就结束了。
4.2 “异步对账”模型的构建逻辑
我们最终采用的方案,叫“三阶段库存管理”:
- 阶段一(抢购时):只操作Redis,成功即视为抢购资格获取;
- 阶段二(下单时):订单服务消费MQ消息,检查MySQL当前库存(非Redis),若足够则创建订单并扣减DB库存;
- 阶段三(对账时):每分钟跑一次对账任务,比对Redis剩余库存与MySQL剩余库存,差异超过阈值(如5%)则告警并人工介入。
对账SQL示例:
SELECT r.stock as redis_stock, m.stock as mysql_stock, ABS(r.stock - m.stock) as diff, ROUND(ABS(r.stock - m.stock)/NULLIF(r.stock,0)*100,2) as diff_percent FROM ( SELECT CAST(GET('seckill:stock:1001') AS SIGNED) as stock ) r CROSS JOIN ( SELECT stock FROM t_sku WHERE id = 1001 ) m;关键洞察:对账不是为了“修复”数据,而是为了“发现”异常。我们曾发现Redis因网络分区丢失数据,导致库存虚高,正是通过对账任务第一时间捕获。而修复方式很简单:人工确认后,用
SET seckill:stock:1001 0重置Redis库存,再补发MQ消息重试。
4.3 超卖兜底:用“乐观锁+版本号”守住最后一道防线
即使有Redis和对账,极端情况下仍可能超卖(如Redis集群脑裂)。这时MySQL的乐观锁就是终极保险:
-- 订单表增加version字段 ALTER TABLE t_order ADD COLUMN version INT DEFAULT 0; -- 扣减库存SQL(带版本号校验) UPDATE t_sku SET stock = stock - 1, version = version + 1 WHERE id = ? AND stock >= 1 AND version = ?;Java代码中判断影响行数:
int rows = skuMapper.updateStockWithVersion(skuId, expectedVersion); if (rows == 0) { // 说明库存不足或版本号不匹配,抢购失败 throw new SeckillException("库存已被抢光"); }实战技巧:
version字段不要用数据库自增,而要用应用层生成。我们采用System.currentTimeMillis() % 1000作为初始版本号,避免高并发下版本号重复。这个细节在《MySQL实战45讲》里提过,但很少有人真正在秒杀场景中落地。
5. 压测与监控:没有数据验证的优化都是自我感动
写完代码只是开始,真正的挑战在压测和线上监控。我见过太多团队,代码写得天花乱坠,一压测就原形毕露。这里分享一套经过大促验证的“五维监控法”,每个维度都对应一个具体工具和关键指标。
5.1 接入层监控(Nginx + Prometheus)
Nginx本身不带监控,必须通过nginx-module-vts模块暴露指标。关键要看三个黄金指标:
nginx_vts_upstream_requests_total{backend="springboot_backend"}:后端请求总量;nginx_vts_upstream_request_time_seconds_sum{backend="springboot_backend"}:请求耗时总和;nginx_vts_upstream_requests_total{code=~"5.*"}:5xx错误数。
我们用Grafana配置看板,当5xx错误率超过0.5%时,自动触发告警。有一次大促前压测,发现5xx集中在/api/seckill/buy路径,排查发现是Nginx的proxy_read_timeout设为60秒,而订单服务超时设为30秒,导致Nginx提前断开连接。将proxy_read_timeout调至35秒后,5xx归零。
5.2 应用层监控(Spring Boot Actuator + Micrometer)
Actuator的/actuator/metrics端点是宝藏。重点关注:
jvm.memory.used:堆内存使用率,超过80%需警惕;http.server.requests:按URI和status聚合的请求统计;tomcat.threads.busy:Tomcat繁忙线程数,持续高于max-threads*0.8说明线程池吃紧。
我们自定义了一个健康检查端点:
@Component public class SeckillHealthIndicator implements HealthIndicator { @Override public Health health() { long redisStock = redisTemplate.opsForValue().get("seckill:stock:1001") != null ? Long.parseLong(redisTemplate.opsForValue().get("seckill:stock:1001").toString()) : 0; long dbStock = skuMapper.getStock(1001); if (Math.abs(redisStock - dbStock) > 10) { return Health.down().withDetail("reason", "库存差异过大").build(); } return Health.up().build(); } }这个端点被接入K8s的Liveness Probe,一旦库存差异超阈值,K8s自动重启Pod,避免脏数据扩散。
5.3 缓存层监控(Redis INFO + RedisInsight)
Redis的INFO命令输出上百个指标,秒杀场景只需盯死三个:
used_memory_human:内存使用量,接近maxmemory时触发淘汰;rejected_connections:被拒绝的连接数,非零说明连接数超限;expired_keys:每秒过期key数,秒杀结束时应归零。
我们用RedisInsight配置告警:当expired_keys持续10秒>1000,说明有大量key未设置过期时间,存在内存泄漏风险。
5.4 消息队列监控(RabbitMQ Management Plugin)
RabbitMQ的Web管理界面自带监控,但要重点关注:
- Ready:待消费消息数,持续增长说明消费者处理不过来;
- Unacknowledged:未确认消息数,过高说明消费者卡顿或崩溃;
- Memory:内存使用率,超过40%需扩容。
我们设置了自动扩缩容规则:当Ready消息数>5000时,自动启动新的订单服务实例;当<1000时,缩容。这套规则在去年双11期间,成功应对了三次流量峰值。
5.5 数据库监控(MySQL Performance Schema)
别再只看SHOW PROCESSLIST了。Performance Schema能告诉你更深层的问题:
-- 查看锁等待情况 SELECT * FROM performance_schema.data_lock_waits; -- 查看慢查询(执行时间>1秒) SELECT * FROM performance_schema.events_statements_summary_by_digest WHERE DIGEST_TEXT LIKE '%UPDATE%t_sku%' AND AVG_TIMER_WAIT > 1000000000000;我们曾通过此定位到一个隐藏Bug:订单服务在创建订单时,会先SELECT FOR UPDATE锁定商品行,但忘记释放——导致后续所有更新都被阻塞。修复后,data_lock_waits从每秒20+降至0。
最后一句真心话:所有没经过压测验证的“优化”,都是给简历添彩的装饰品。我们团队的铁律是——任何代码上线前,必须通过
wrk -t12 -c400 -d30s http://host/api/seckill/buy的基准压测,且错误率为0。这不是苛刻,而是对用户最基本的尊重。