高并发秒杀系统实战:Redis原子扣减、MQ削峰与超卖防护全解析
做一个高并发秒杀系统,是每个Java后端工程师绕不开的硬仗。这个项目看似只是一个“卖东西”的促销场景,但真正落地之后你会发现,它几乎把后端的核心技术栈全串了一遍:Redis缓存、分布式锁、消息队列削峰、接口限流、库存原子扣减、防超卖、幂等性设计,甚至还有压测调优和线上监控。
我当初把这个系统完整做下来,前后大概花了三周,代码量不大,但踩的坑比想象中多得多。尤其是“库存扣减”这一步,方案的选型会直接影响系统能不能扛住瞬时流量。这篇文章把整个项目的核心思路、落地细节和排查过程都拆开讲,代码和配置都会贴出来,照着敲一遍,你对高并发的感知会上一个台阶。
项目适合两类人:一类是在校生或初级后端,想通过一个完整的实战项目理解Spring Boot + Redis + RabbitMQ这套组合拳;另一类是有一定经验、准备面试中级岗位的工程师,想借这个项目梳理高并发系统设计的面试话术。不管哪一类,建议都自己动手把项目跑起来,光看不练等于白看。
1. 秒杀系统的核心难题拆解
秒杀系统和普通商城的最大区别,在于并发量的量级完全不同。平时一个商品页每秒可能只有几个人访问,秒杀一开,同一秒钟可能有几万个请求同时打进来。这时候如果你的系统还在用同步逻辑处理每个请求,数据库就很容易被瞬间打崩。
1.1 为什么秒杀这么容易击穿系统
我把秒杀的核心矛盾拆成三块来分析,这样后续做技术选型的时候才有据可依。
第一块是读请求量级爆炸。所有用户在做同一件事:打开商品详情页、刷新库存、点抢购按钮。这个动作会在开启瞬间形成流量峰值,绝大多数请求其实都在重复查询同一个商品的数据。如果这些查询全部打到MySQL,哪怕做了索引也扛不住,因为数据库的连接池是有限的,QPS上限大概也就几千,而秒杀场景下的读请求动辄是十万、几十万级别。
第二块是写请求瞬时集中。用户点击抢购之后,系统要做的事不仅仅是扣库存,还包括创建订单、锁定用户资格、记录购买明细。这一连串的写操作如果同步执行,数据库的写锁会严重排队,事务处理不过来,用户体验就是页面一直在转圈,然后提示系统繁忙。
第三块是库存一致性极难保证。秒杀活动对库存的要求非常严格——既不许多卖(超卖),也不许少卖(少卖意味着有库存却显示售罄)。在高并发场景下,并发扣减库存会出现经典的超卖问题,也就是多个请求同时读到剩余库存为1,然后同时通过校验并扣减,最终导致库存变成负数。
提示:超卖问题的本质是“读-判断-写”三步操作不是原子的。要解决这个问题,核心思路是把这三步压缩成一步原子操作,或者用锁和事务来保证临界区的互斥。
1.2 高并发场景下的三个关键瓶颈
除了读写层面,还有三个瓶颈往往被忽略,但实际压测之后它们最先暴露出来。
瓶颈一是Tomcat的线程池上限。默认情况下Spring Boot内嵌Tomcat的最大工作线程是200,这意味着同一时刻最多只有200个请求在真正执行,其余的全部排队。秒杀流量进来时,排队时间会越来越长,最后表现为请求超时和大量报错。所以需要在网关层、应用层前面再加一道“闸门”,让部分请求在入口就被挡掉,而不是进入业务逻辑。
瓶颈二是MySQL的连接数限制。每个活跃线程如果都持有数据库连接,200个线程已经让连接池很吃力了,加上其他服务的读写,连接数非常容易打满。一旦连接池耗尽,整个服务就会陷入雪崩。
瓶颈三是网络带宽和应用层瓶颈。用户请求包本身不大,但数量极大,Nginx、网关层的转发能力、内网带宽都可能成为瓶颈。这些瓶颈不一定能靠代码解决,很多时候要靠架构分层和流量过滤来规避。
把这个三个瓶颈列出来之后,选型方向就变得清晰了——读请求尽量交给缓存,写请求用消息队列削峰,数据库只承受真正有效的下单操作。
2. 技术选型背后的设计取舍
2.1 Redis做缓存和原子扣减,为什么是首选
秒杀系统的第一道防线,就是把热点的商品数据提前加载到Redis里。商品详情、库存数量这些数据的读频率远远大于写频率,很适合放到缓存里。Redis单机可以达到十万级别的QPS,集群模式下还能水平扩展,用来扛住读流量是最合适的。
但这还不够。库存扣减如果先在Redis里做一次,再同步到MySQL,中间就有两次操作的不一致风险。我在项目里直接用了Redis的Lua脚本来执行“判断库存是否充足,充足则扣减”的原子操作,这样既利用Redis的高性能,又保证这一步操作的原子性。
这段脚本的意思是:先读取指定key的库存值,如果小于等于0直接返回失败;否则执行减库存操作。因为Redis是单线程模型,Lua脚本整体执行时不会被其他命令插入,所以这一步天然线程安全,不存在并发扣减导致超卖的问题。
注意:Redis的Lua脚本虽然原子,但脚本本身要尽量短小,不要在里面写耗时的循环逻辑。Redis执行Lua脚本时会阻塞其他命令,脚本执行时间过长会直接影响所有对Redis的访问。
2.2 RabbitMQ做异步削峰,为什么比同步写库强
用户点击抢购后,减库存成功了,接下来要创建订单。如果这一步直接同步写MySQL,高并发瞬时下单会让数据库事务排队,整体链路被拖死。我的做法是:减库存成功之后,把用户ID和商品ID封装成一条消息,发到RabbitMQ队列里,接口立即返回“正在排队中”,订单在后台异步创建。
这一步的本质是把峰值流量“填平”。不管秒杀瞬间有多少请求进来,真正进入下单流程的消息量都取决于消费者拉取消息的速度。消费者以固定的速率消费,数据库就始终在一个可控的负载范围内。这就是削峰填谷。
RabbitMQ在这个场景中的优势有三点:一是支持ACK确认机制,消息不会因为消费者异常而丢失;二是支持手动应答和重试机制,订单创建失败后可以重新入队;三是有补偿性的事务边界,配合死信队列可以对处理失败的消息进行兜底。
2.3 MySQL承担最终数据落库,如何保证不超卖
Redis缓存的库存是“前台展示和拦截”用的,真正最终的数据依据还是MySQL。用户订单创建完成、支付成功之后,库存的最终扣减要落到数据库层面。这时候有两个手段保证不超卖:
第一种是乐观锁。在商品表加一个version字段,更新库存前先比对版本号,一致才更新。这种方案适合冲突较少的情况;但在秒杀场景下冲突非常频繁,大部分请求会因为版本号不一致而失败,用户体验不好。
第二种是条件更新。直接使用SQL的where条件来限制库存必须大于等于扣减数量:
这条SQL的意思是:只有当前库存大于等于1时才执行扣减。MySQL的行锁会在更新时锁定这一行,其他并发更新必须等待,因此不会出现两个请求同时把库存从1扣成负数的情况。受影响的记录数是1,表示扣减成功;是0,表示库存不足,这个逻辑放在事务里判断即可。
提示:条件更新和乐观锁的区别在于,条件更新把“判断库存是否足够”和“扣减库存”合在了同一句SQL里,数据库层保证了原子性,效率更高。在秒杀这种高冲突场景下,这个手段比乐观锁更实用。
3. 系统整体架构与模块设计
3.1 分层架构:网关、服务、缓存、队列、数据库各司其职
整个系统我拆成了五层,每一层的职责都控制得非常干净:
- 接入层(Nginx):负责静态资源访问、简单的限流策略和IP黑白名单。秒杀按钮的静态页面命中CDN缓存,不进入后端服务。
- 应用层(Spring Boot):提供秒杀接口、订单接口、用户认证接口。核心业务逻辑在这一层完成,但尽量不直接接触数据库。
- 缓存层(Redis):商品预热、库存预扣、用户去重标记、秒杀结果缓存。
- 异步层(RabbitMQ):订单创建消息的接收、排队、异步处理。
- 存储层(MySQL):商品表、订单表、秒杀记录表的最终数据落库。
每层之间通过明确的数据流串联起来。秒杀请求的处理路径是这样一个链条:用户请求先过Nginx限流,到达Spring Boot后先查Redis判断秒杀是否在有效期内,再用Redis Lua脚本扣减库存,扣减成功则发送RabbitMQ消息并直接返回“排队中”,消费者接收消息后在MySQL事务里创建订单。
3.2 数据库表结构设计与索引优化
数据库表我设计了四张核心表:用户表、商品表、秒杀商品表、订单表。这里重点说秒杀商品表和订单表。
秒杀商品表的核心字段要包含:秒杀商品的唯一ID、关联商品ID、秒杀库存数量、实际库存数量、秒杀开始时间、秒杀结束时间。注意“秒杀库存”和“实际库存”要分开存,因为秒杀库存是单独维护的,活动结束后需要把未卖出的秒杀库存回流到实际库存中。
订单表的主键不建议用自增ID,因为高并发下自增ID有生成瓶颈和规律性风险。我用的是雪花算法生成的分布式ID,格式是时间戳 + 机器ID + 序列号,保证全局唯一且趋势递增。订单表要冗余商品ID和商品名称,这样查询订单列表时不需要回表关联商品表,减少一次连接查询。
索引方面,秒杀商品表在“商品ID”上建唯一索引,订单表在“用户ID + 商品ID”上建联合索引。查用户是否重复秒杀时,这条联合索引可以走覆盖索引,效率极高。
3.3 接口设计:提前暴露的四个核心接口
整个后端项目对外暴露的接口只有四个,越收敛越不容易出错:
第一个是获取秒杀商品列表接口。返回正在进行的秒杀活动商品信息,商品数据从Redis直接读取,避免MySQL压力。
第二个是获取秒杀商品详情接口。返回商品图片、标题、库存、起止时间等。库存数量在Redis中为负值时直接显示“已抢光”。
第三个是秒杀抢购接口。这是核心接口,请求参数包含秒杀商品ID和用户ID,内部执行校验、限流、Lua扣库存、发消息四个动作。
第四个是查询秒杀结果接口。用户抢购之后前端轮询这个接口,根据后端反馈的“排队中”、“秒杀成功”、“秒杀失败”三种状态刷新页面展示。
接口设计的规范是:所有写接口都要求登录,用JWT鉴权;秒杀接口必须通过前置参数校验,防止恶意请求带着非法参数直接打到后端。
4. 秒杀核心流程的代码落地
4.1 秒杀接口的完整实现逻辑
我把秒杀接口的代码按照“前置拦截-库存扣减-异步下单-返回结果”四个步骤来写。
先做前置拦截。这里包含了三个校验:秒杀活动是否在有效期内,用当前时间和开始时间、结束时间对比;用户是否登录,用JWT Token的解析结果判断;用户是否已经参与过本次秒杀,在Redis中查询一个以用户ID和商品ID拼接的key是否存在。
然后是库存扣减和异步下单。库存扣减直接用Lua脚本在Redis中执行,执行成功的才发送MQ消息。
这段代码看着简单,但有几个细节要特别注意。第3步和第4步的顺序不能颠倒,先扣库存成功后再标记用户,避免用户没有抢到库存却被标记了参与资格。另外,MQ发送失败时必须对库存做回滚,否则会出现库存扣了但订单没创建的“幽灵库存”。
注意:如果消息发送失败,需要在catch块中对Redis库存做加回操作,同时删除用户标记key,保证用户下一次还能正常参与。这里可以使用Spring的@Retryable注解实现消息发送的重试。
4.2 Redis Lua脚本的封装与库存预热
库存预热的逻辑是在秒杀活动开始前,通过一个定时任务把MySQL里的库存数据同步到Redis。比如活动开始前两小时,把秒杀商品的初始库存数加载到Redis的key中,格式是 seckill:stock:{goodsId},key的过期时间要大于活动结束时间。
Lua脚本的封装我是用DefaultRedisScript来做的,序列化方式和key的编码要注意,否则会出现找不到key的情况。
Lua脚本里的key是动态传入的,不是写死的字符串,这样脚本可以被多个商品复用。脚本的核心逻辑我在2.1节已经列过,这里补充一个经验:Lua脚本里的Redis命令都建议使用redis.call来做,执行失败时会抛出异常,便于外层捕获和回滚。
4.3 异步下单消费者的幂等性处理
RabbitMQ的消费者在创建订单时,要重点处理消息的幂等性。因为RabbitMQ的自动ACK机制下,消息一旦被消费者接收就会自动确认,如果后续处理失败且没有捕获异常,这条消息就会丢失;但改成手动ACK之后,消费者处理失败时会重新投递消息,这就可能导致重复创建订单。
幂等性处理我是在订单表上做的:创建订单之前先查一下订单表里是否已经存在相同用户在相同秒杀商品下的记录。因为订单表有用户ID + 商品ID的联合索引,查询很快。存在就不重复创建,直接返回成功;不存在则执行创建逻辑。
消费者核心代码如下:
这里要说明的是basicReject(tag, false)的作用。第二个参数false表示不放回原队列,这样消息会进入死信队列或者被丢弃。为了不让失败的订单一直卡在队列里反复重试,我配置了死信队列来接收处理失败的消息,人工介入后可做补偿。
5. 抗压方案与性能优化的完整实践
5.1 接口限流:令牌桶与计数器限流的落地
秒杀系统必须做限流,而且要在多个层面做。我在应用层用了,Google Guava的RateLimiter做了接口的令牌桶限流,在Nginx层做了IP级别的计数器限流。
Guava的RateLimiter用起来非常简洁,它是“令牌桶算法”的一个实现。令牌桶的机制可以这样理解:系统以固定速率往桶里放令牌,每个请求必须拿到一个令牌才能继续,令牌不足时请求就会等待或者直接拒绝。
tryAcquire和acquire的区别是:tryAcquire拿不到令牌就立刻返回false,不阻塞线程,线程可以马上把请求驳回,这一点对秒杀来说非常重要,因为秒杀场景下的请求不允许长时间排队。acquire则会在拿不到令牌时阻塞等待,不适合用在接口入口做拦截。
Nginx层的IP限流是直接通过limit_req模块实现的:
这几行配置的作用是:每个IP每秒最多通过10个请求,超出之后会被直接拒绝。burst=20表示允许瞬时多20个请求进入排队,nodelay表示排队的同时不做延迟处理,超出的请求直接拒绝。
5.2 热点数据的多级缓存策略
秒杀商品详情页的数据是典型的读多写少热点数据。我在项目里用了“本地缓存 + Redis缓存”两级结构。
本地缓存用的是Caffeine,一个高性能的Java本地缓存库。热点商品的详情信息在秒杀开始前加载到Caffeine中,请求进来时优先查本地缓存,命中直接返回,查不到再查Redis,最后才查数据库。
这样做的好处是,Redis的请求量也会被拦掉一大截,因为大部分读取在应用本地就完成了。很多系统高并发打崩Redis,就是因为没有在Redis前面加一层本地缓存,流量全部穿透了。
注意:本地缓存有个天然的弊端——不同应用实例的数据是不共享的。秒杀中如果某个实例本地缓存过期了,库存数据会短暂不一致。所以本地缓存只用来存“商品详情”这种不敏感的数据,库存数量还是要实时从Redis读取。
5.3 Tomcat线程池与数据库连接池调优
在压测过程中,我遇到了Tomcat线程池被打满的问题。默认配置的200个线程根本不够用,我调整了Tomcat的线程池参数,同时也调整了HikariCP连接池的大小。
但这里有一个关键原则:Tomcat的线程数提升的目标不是让活跃线程增加,而是让排队情况变少,同时让线程能快速释放回线程池。如果每个请求都在同步等待数据库结果,线程数再大也没有意义,因为数据库连接池只有100个,请求最终还是要在数据库上排队。
后来我采用了更合理的做法:把秒杀接口的执行时间尽量缩短到10毫秒以内。校验和扣库存都在Redis完成,真正要等数据库的操作交给了MQ异步处理。这样汤姆cat线程的处理速度极快,整体吞吐量立刻提上来了。
6. 压测过程与性能瓶颈分析
6.1 JMeter压测方案的完整记录
我用JMeter对秒杀接口做了三轮压测。压测服务器的资源配置是4核8G,MySQL和Redis都在这台机器上,Spring Boot应用独占一个2核4G的容器。
第一轮压测的核心目的是获取“无拦截逻辑”的基准数据。直接用500个线程循环压测秒杀接口,结果非常惨:平均响应时间1.2秒,吞吐量只有大约300QPS,错误率达18%。分析后发现瓶颈在MySQL,500个线程同时执行订单创建事务,MySQL的行锁等待严重拖慢了整体吞吐。
第二轮压测在引入Redis LUA扣库存和MQ异步下单之后进行。同样的500个线程,接口平均响应时间降到了68毫秒,吞吐量约2200QPS,错误率降到了0.2%。这一轮的效果极为明显,因为接口内部已经不再直接操作数据库。
第三轮压测是给接口加上限流之后的保护性测试。1500个线程并发压测,接口的响应时间保持稳定在150毫秒以下,吞吐量稳定在3200QPS左右。超出令牌桶阈值的请求快速返回“排队人数过多”,没有任何一个请求拖垮后端服务。
6.2 从压测数据中定位到的瓶颈
压测过程暴露出来的最大瓶颈,反而不是代码逻辑,而是GC和连接池。
JDK默认的GC参数在这个场景下会出现频繁的Full GC,尤其是创建大量订单对象时。后来在启动参数里加上了G1垃圾回收器参数,Full GC次数大幅减少。
连接池的瓶颈体现在RabbitMQ的连接数上,消费者与MQ之间的连接默认是懒加载的,如果在高并发情况下连接池初始化过慢,第一条消息的消费延迟会非常明显。建议在项目启动时主动初始化连接池。
另外,压测过程中还发现MySQL的binlog和慢查询日志占用了大量磁盘IO,这直接影响了数据库的TPS。在压测环境里我把不重要的日志级别降了下去,生产环境建议把慢查询日志设置到1秒以上的阈值。
7. 秒杀项目中高频踩坑与解决方案
7.1 超卖问题在MySQL层面的兜底
Redis里做了原子扣减,逻辑上不会超卖,但MySQL作为最终数据源,依然要做兜底。我在事务中更新库存时使用条件更新,并检查更新结果。如果受影响行数为0,会抛出异常回滚整个事务,从而保证数据库层的库存永远不会小于0。
这里要补充一个非常容易踩的坑:如果条件更新写在查询库存之后,且两个操作不在同一个事务里,依然会超卖。正确的做法是把条件更新作为事务内的第一句SQL,或者干脆不查库存,直接执行条件更新然后看返回结果。很多初级工程师习惯先select stock判断再update stock,这个顺序在高并发下相当于给超卖留了后门。
7.2 用户重复下单问题
秒杀活动通常限制每个用户只能买一个,但前端只能做到按钮置灰,不能防住恶意用户的绕过手段。真正的防重需要在后端拦截。
我在Redis中对每个用户加了一个秒杀资格标记,key是seckill:user:{userId}:goods:{goodsId},标记存的是1,过期时间设置为24小时。用户在抢购接口进入时先查这个key是否存在,存在直接返回“请勿重复参与”。同时,MySQL订单表里的唯一的联合索引作为最后一层兜底。
这里有两个细节需要提醒:Redis的key要设置过期时间,否则活动结束两个月后这些key还在堆积;key的过期时间必须是活动结束时间加上一个缓冲期,确保用户在整个活动周期内都不能重复购买。
7.3 消息积压导致的下单延迟
在一次测试中,我人为制造了1万条秒杀消息,结果订单创建出现了明显延迟。排查后发现消费者的并发线程数默认只有10个,处理1万条消息需要较长时间。后来在消费者配置中把并发消费者数提升到了30个:
并发调高的效果非常明显,1万条消息在几秒内消费完毕。但这里需要提醒的是,并发数不能盲目调高,因为每增加一个消费者线程,就多一条连接和一组资源占用。需要根据订单创建接口的平均耗时和数据库的连接池上限来进行匹配。
提示:判断消息积压的一个简单办法,是观察RabbitMQ管理面板的Queue中的Ready状态消息数量。如果这个数字持续走高,说明消费者的消费能力跟不上生产速度,需要扩容消费者。
7.4 秒杀结束后Redis和MySQL的数据一致性修复
秒杀活动结束后,Redis中剩余的预扣库存和MySQL中实际剩余的库存可能会存在一定的偏差,需要在后台写一个对账任务做修复。对账逻辑是:查出MySQL中的实际剩余库存,与Redis中扣减后的剩余库存做对比,以MySQL为准覆盖Redis的数据,并把Redis中秒杀商品的数据标记为“活动已结束”。
这个对账任务在活动结束后立即执行一次,然后在半小时后再执行一次,确保数据完全一致。对账任务要使用分布式锁,防止多个实例同时执行导致重复修复。
8. 项目源码结构与学习建议
8.1 源码目录结构与启动说明
整个项目的源码包我整理成了标准的Maven结构,模块划分如下:
seckill-common:公共模块,包含统一返回结果、异常处理、JWT工具类、通用枚举。seckill-goods:商品模块,包含商品实体、秒杀商品实体、商品库存服务。seckill-order:订单模块,包含订单实体、订单服务、MQ消息消费者。seckill-user:用户模块,包含用户实体、登录鉴权逻辑。seckill-api:接口层,包含Controller、VO对象、统一拦截器。
启动项目的顺序是:先启动MySQL和Redis,再启动RabbitMQ,最后启动Spring Boot应用。端口默认配置在8080,Redis端口默认6379,RabbitMQ端口默认5672。配置项集中在application.yml里,如果本地环境和我的配置不一致,改几个连接参数即可。
8.2 学习这个项目的建议路径
拿到源码后,不建议从Controller层开始读,要从数据表和调用链开始梳理。我的建议路径是这样的:
第一步,先读懂数据库表结构,理解秒杀商品表、订单表之间的关系。
第二步,顺着“抢购接口进入,逐步追踪到Redis扣库存、MQ发消息、消费者创建订单”的完整调用链,把每个环节的输入输出理清楚。
第三步,在本地把项目跑起来,用JMeter或者Postman模拟一次完整的秒杀流程,观察数据在Redis、MQ、MySQL三个组件中的流转。
第四步,尝试修改代码,比如把Redis扣减库存改成数据库扣减库存,对比一下响应时间和吞吐量的差异,你会对缓存的价值有最直观的感受。
第五步,把项目里和面试相关的知识点整理成话术,比如“为什么用Lua脚本保证原子性”“怎么处理MQ重复消费”“超卖问题如何解决”,这几乎是面试必问的高频问题。
8.3 扩展方向:还能往这个项目上加什么
如果这个项目做完之后还有余力,可以考虑往里面加更多进阶能力。第一个可以加Sentinel或者Resilience4j做服务熔断降级,让秒杀场景下的异常处理更完善;第二个可以接入XXL-Job实现秒杀活动状态的自动流转,比如活动开始前自动预热库存,结束后自动回滚多余库存;第三个可以加一个WebSocket推送,让用户抢购成功之后页面不用轮询,而是实时收到下单结果。
这些扩展方向哪个先做,取决于你现在面试侧重什么。如果侧重Spring Cloud微服务,就往网关和熔断方向加;如果侧重消息中间件,就往RabbitMQ可靠性方向加;如果侧重中间件应用,就往Redis扩展方向加。
9. 写在最后的一点体会
这个项目做完之后,我对高并发的理解比之前靠看理论要深刻得多。以前觉得缓存就是快、MQ就是解耦,真正动手的时候才发现每一步都是取舍:Redis快,但数据可能和MySQL不一致;MQ削峰效果好,但消息积压和重复消费会带来新的复杂度;分库分表能撑更大的并发,但同样会引入分布式事务的难题。
如果你正在学这个项目,我建议你别只盯着代码本身,去把每一处设计背后“如果不这样做会怎样”的问题想透。比如把Lua脚本换成Java代码扣库存,看看并发下会不会超卖;把同步调用去掉,看看MySQL会不会被打崩。只有亲手验证过方案B的问题,你才真正理解方案A为什么好。
项目源码我放在了完整包里,注释写得比较细,每一个关键步骤都标注了“为什么这么做”。跑起来之后如果遇到启动报错或者运行异常,先把Redis版本、RabbitMQ版本、JDK版本和我的环境对照一遍,八成是版本差异导致的。剩下两成问题,就是那些值得你去搜一搜、想一想的内容。