2026/10/14 23:24:53

SpringBoot+RabbitMQ+Redis高并发秒杀系统实战

SpringBoot+RabbitMQ+Redis高并发秒杀系统实战 秒杀系统这事我在不同公司做过好几版从最早纯数据库扛流量被压垮到后面用 Redis 挡第一波、RabbitMQ 慢慢消化中间踩过的坑能写一本小册子。今天把这套基于 SpringBoot RabbitMQ Redis 的秒杀方案完整拆开从架构思路到核心代码到压测数据全部摆出来。如果你正准备做秒杀或者抢购类的功能这篇文章能帮你少走不少弯路。先说结论这套方案的核心就三句话——Redis 挡流量、MQ 削峰、数据库最终落库。所有设计都是围绕这三件事展开的。1. 秒杀系统的核心难点与整体架构思路1.1 秒杀场景到底难在哪里很多人第一次接触秒杀觉得无非就是写个接口、减个库存、生成订单。但真正上线过秒杀系统的人都知道难点根本不在业务逻辑上而在流量模型上。秒杀场景有一个非常极端的特征瞬时流量极高但真正能买到商品的人极少。比如一场活动放出 1000 件商品可能同时有 10 万人点击抢购QPS 瞬间冲到几万甚至十几万。但最终成功的只有 1000 人其余 99000 人都是无效流量。如果所有请求都直接打到数据库那数据库的连接池瞬间就会被耗尽紧接着就是整个服务雪崩。更麻烦的是秒杀还涉及库存扣减如果扣减逻辑处理不好就会出现超卖——也就是卖出去的商品数量超过了实际库存这在电商场景里是严重的资损事故。所以秒杀系统的设计目标其实很明确让绝大多数请求在到达数据库之前就被拦截掉只放行极少数真正有资格下单的请求同时保证库存扣减绝对准确。1.2 为什么选 SpringBoot RabbitMQ Redis 这套组合这套技术栈可以说是目前做秒杀最主流、也最稳妥的方案。 SprinBoot 提供快速的业务开发能力Redis 承担高性能缓存和原子计数RabbitMQ 负责异步解耦和流量削峰。有人可能会问为什么不用 KafkaKafka 吞吐量确实更高但秒杀场景更看重消息的可靠投递和灵活的路由策略RabbitMQ 的 confirm 机制、手动 ACK、死信队列这些能力在处理订单类消息时更顺手。而且 RabbitMQ 是erlang 写的本身对高并发连接的处理非常稳定对于秒杀这种百万级连接的消息推送场景完全是它的舒适区。还有人会问为什么要分这么多层直接数据库乐观锁扣库存不行吗行但性能不够。一张热点商品的库存记录数据库行锁竞争下每秒能扛几千次更新就到头了而秒杀需要的是每秒几万甚至十几万的扣减能力。把库存扣减放到 Redis 里做原子操作数据库只负责最终落库这个性能差距就拉开了。从整体架构上看这套方案分了三层用户请求先打到 SpringBoot 网关层做参数校验和限流然后进入 Redis 层完成库存预扣减和热点数据读取最后通过 RabbitMQ 异步写入数据库完成订单持久化。每一层都是上一层的过滤器层层拦截最终打到数据库的请求极其有限。2. 三大核心链路拆解缓存、异步与防超卖2.1 第一道关卡Redis 热点数据缓存与预减库存秒杀系统里Redis 承担了两个最重要的职责缓存秒杀商品信息和预扣库存。商品信息缓存很简单就是把参与秒杀的商品详情、开售时间、库存数量这些热点数据提前加载到 Redis 里避免每次请求都去查数据库。但这里有一个关键细节缓存不能只存商品信息还要存每个用户的抢购状态。用户点击抢购按钮后先检查这个用户是否已经抢购过抢过就直接返回请勿重复下单否则才继续走库存扣减逻辑。库存预扣减是整个秒杀链路的核心需要保证原子性。Redis 的 DECR 命令本身就是原子的单条命令不会有并发问题但秒杀的下单逻辑不只是一个 DECR 那么简单——它要同时判断库存是否足够、扣减库存、记录用户抢购状态这就要用到 Lua 脚本保证一组操作的原子性。下面这段 Lua 脚本是整套秒杀库存控制的核心我贴出来并逐行说明-- KEYS[1]商品库存 key如 seckill:stock:1001 -- KEYS[2]用户去重集合 key如 seckill:users:1001 -- ARGV[1]用户 ID -- ARGV[2]秒杀商品 ID可用于生成订单号 -- 判断用户是否已抢购过 if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return 0 -- 已抢购直接返回失败 end -- 获取当前库存 local stock tonumber(redis.call(get, KEYS[1])) if not stock or stock 0 then return -1 -- 库存不足 end -- 扣减库存 redis.call(decr, KEYS[1]) -- 记录用户已抢购 redis.call(sadd, KEYS[2], ARGV[1]) -- 返回成功同时可以通过 ARGV[2] 生成唯一订单号 return 1这段脚本的精妙之处在于它把「判断用户是否买过」「判断库存是否充足」「扣减库存」「记录用户」四步操作合并成了一个原子操作。在 Redis 单线程模型下这条脚本执行期间不会有任何其他命令插入也就不会出现两个请求同时读到库存为 1 然后同时扣减成功的超卖问题。注意Lua 脚本里我用的 key 带有商品 ID 后缀每个商品一个 key。这样设计是为了避免所有商品的秒杀请求都去竞争同一个 Redis key不同商品的库存操作天然隔离也能充分利用 Redis 的多实例能力。2.2 第二道关卡RabbitMQ 异步下单与流量削峰Redis 预扣减成功之后理论上这个用户已经有资格下单了。但注意这里只是 Redis 里的库存被扣了数据库里的真实库存还没变。如果直接把订单写进数据库高并发下数据库依然扛不住。所以需要把下单请求先扔进 RabbitMQ由消费者异步处理数据库写入。这个流程就是经典的异步下单模型用户请求 - Redis 预扣库存成功 - 发送 MQ 消息 - 立即返回正在排队 - 消费者监听队列 - 异步创建订单 - 扣减数据库库存异步化带来两个直接好处第一用户请求的响应时间大幅度缩短秒杀接口的 RT 从原来的几百毫秒降低到几毫秒第二数据库的写入压力被削平了无论前端有多少请求数据库每秒只处理消费者拉取的消息数量消息队列起到了缓冲削峰的作用。RabbitMQ 的配置有几个关键点需要特别注意。首先是交换机类型的选择。秒杀下单我习惯用直连交换机配合路由键绑定队列。生产者发送消息时指定路由键交换机精确匹配后把消息投递到对应队列。秒杀场景的消息路由规则比较固定直连交换机足够不需要搞复杂的 topic 规则。其次是消息确认机制。生产者端要开启 confirm 模式确保消息确实到达了交换机消费者端要使用手动 ACK处理成功才确认消息失败则重新入队或进入死信队列。这一点在设计秒杀系统时容易被忽略——很多人图省事用自动 ACK结果消费者一宕机消息全部丢失用户抢到了但订单没生成资损事故就来了。最后是消费者端的并发参数。RabbitMQ 的消费者默认是串行处理消息的如果只开一个消费者数据库写入能力再强也白搭。要合理设置消费者的并发线程数或者直接部署多个消费者实例提高消息处理吞吐量。2.3 补偿兜底数据库层的最终一致性保障有人看到这里会问如果 MQ 消息丢了怎么办如果消费者处理失败了怎么办岂不是 Redis 库存扣了但订单没生成这些担忧是完全合理的所以数据库层必须做兜底。我的做法是提供一个订单补偿服务定时扫描那些Redis 已扣库存但数据库没有对应订单的异常数据。扫描的维度可以基于 Redis 里的抢购用户集合或者基于 MQ 消息的唯一 ID。如果发现某条消息处理超时就重新投递或者触发补偿流程。另外数据库扣减库存也必须用乐观锁防止超卖UPDATE seckill_stock SET stock stock - 1 WHERE product_id #{productId} AND stock 0这行 SQL 的意思是只有在库存大于 0 的情况下才扣减库存。如果更新的影响行数为 0说明库存已经不足这次下单失败。这是数据库层的最后一道防线——即便 Redis 和 MQ 全都出了问题数据库依然能保证不超卖。这里再补一个很实用的细节数据库的库存表不会记录真实的剩余库存是负数而是通过 UPDATE 影响行数来判断是否超卖。影响行数为 0 时直接把订单标记为失败即可无需回滚因为前面的状态已经通过 WHERE 条件过滤掉了。3. 关键代码实现与落地细节3.1 秒杀接口的入口设计与参数校验秒杀接口是整套系统的门面也是直接面对高并发流量的地方。接口设计的好坏直接影响后续链路能否正常工作。先看一个标准的秒杀 Controller 写法RestController RequestMapping(/seckill) public class SeckillController { Autowired private SeckillService seckillService; PostMapping(/order) public ResultString createOrder(RequestParam(userId) Long userId, RequestParam(productId) Long productId) { // 参数校验放在最前面不合法直接返回不打后续链路 if (userId null || productId null) { return Result.error(参数不合法); } SeckillResult result seckillService.seckill(userId, productId); if (result.isSuccess()) { return Result.success(抢购成功排队中...); } return Result.error(result.getMessage()); } }这里有几个容易被忽略的细节。第一接口返回不要等最终结果。秒杀的返回应该是已受理而不是已成交。用户看到排队中的提示前端再去轮询查询订单状态。这样设计的好处是可感知的响应时间极快用户体验更好同时也跟异步下单的架构相匹配。第二这个接口前面还应该加一层限流。最简单的方式是使用 Guava 的 RateLimiter 做单机限流或者用 Redis Lua 做分布式限流。秒杀接口的限流阈值一般设置为系统能承受的最大 QPS 的 80% 左右超出部分直接返回活动太火爆请稍后再试。第三接口层面要防止用户重复提交。除了 Redis 里的用户去重集合前端也要配合做按钮置灰双保险。3.2 Redis 层库存预扣减与 Lua 脚本实战接下来是实现 Redis 预扣减的核心逻辑。前面贴过 Lua 脚本现在看它在 Java 里怎么调用。Service public class SeckillService { private static final String LUA_SCRIPT if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return 0 end local stock tonumber(redis.call(get, KEYS[1])) if not stock or stock 0 then return -1 end redis.call(decr, KEYS[1]) redis.call(sadd, KEYS[2], ARGV[1]) return 1; Autowired private StringRedisTemplate redisTemplate; Autowired private RabbitTemplate rabbitTemplate; public SeckillResult seckill(Long userId, Long productId) { // 构建 Lua 脚本的 key 和参数 String stockKey seckill:stock: productId; String userSetKey seckill:users: productId; DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptText(LUA_SCRIPT); script.setResultType(Long.class); Long result redisTemplate.execute(script, Arrays.asList(stockKey, userSetKey), String.valueOf(userId), String.valueOf(productId)); if (result null || result -1) { return SeckillResult.fail(商品已售罄); } if (result 0) { return SeckillResult.fail(请勿重复下单); } // Redis 预扣成功发送 MQ 消息异步处理订单 SeckillMessage message new SeckillMessage(userId, productId); rabbitTemplate.convertAndSend( seckill.exchange, seckill.order, JSON.toJSONString(message), correlationData - { // 设置消息唯一 ID用于幂等性判断和补偿 return correlationData; }); return SeckillResult.success(正在排队); } }这段代码有三个关键点值得展开。一是 StringRedisTemplate 和 DefaultRedisScript 的配合。Spring Data Redis 原生支持 Lua 脚本调用不需要引入额外的依赖。注意 script.setResultType(Long.class) 必须设置否则返回结果会被解析成其他类型。二是 Lua 脚本执行结果的语义约定。我现在定义了三种返回值1 表示成功0 表示重复抢购-1 表示库存不足。这套约定清晰易懂也是我在项目中总结的最佳实践。三是 MQ 消息的发送时机。一定要在 Redis 预扣成功之后才发送保证整个流程是有序的。有些新手会把 MQ 发送放在 Redis 扣减之前一旦 Redis 扣减失败消息却已经发出去了会产生脏数据。3.3 RabbitMQ 的交换机、队列绑定与消费者实现MQ 这条链路如果配错了整个异步下单就是空谈。先看核心的配置类Configuration public class RabbitMQConfig { // 秒杀交换机 public static final String SECKILL_EXCHANGE seckill.exchange; // 秒杀订单队列 public static final String SECKILL_ORDER_QUEUE seckill.order.queue; // 路由键 public static final String SECKILL_ORDER_ROUTING_KEY seckill.order; Bean public TopicExchange seckillExchange() { return new TopicExchange(SECKILL_EXCHANGE, true, false); } Bean public Queue seckillOrderQueue() { MapString, Object args new HashMap(); // 设置队列最大长度超出部分直接丢弃防止内存溢出 args.put(x-max-length, 100000); // 设置消息过期时间 60 秒防止积压消息长期占用内存 args.put(x-message-ttl, 60000); return new Queue(SECKILL_ORDER_QUEUE, true, false, false, args); } Bean public Binding binding() { return BindingBuilder.bind(seckillOrderQueue()) .to(seckillExchange()) .with(SECKILL_ORDER_ROUTING_KEY); } }这里的设计细节比较多我逐个解释。队列设置了 x-max-length 和 x-message-ttl。前者是队列最大消息数量秒杀场景消息积压到十万条以上说明系统出了严重问题超过这个阈值直接丢弃比让消费者慢慢消耗更合理后者是消息过期时间防止某些异常消息在队列里无限期积压。当然正常情况下一百万条消息也不会有问题这些参数是为了兜底的。交换机用了持久化模式。RabbitMQ 重启后交换机自动恢复不会丢失配置。队列也开启了持久化配合消息的持久化投递能做到消息尽量不丢。消费者端的监听器是整套流程的落地点Component public class SeckillOrderConsumer { Autowired private OrderService orderService; RabbitListener(queues RabbitMQConfig.SECKILL_ORDER_QUEUE, ackMode MANUAL) public void receiveOrder(Channel channel, Message message) throws Exception { long deliveryTag message.getMessageProperties().getDeliveryTag(); String msg new String(message.getBody(), StandardCharsets.UTF_8); try { SeckillMessage seckillMessage JSON.parseObject(msg, SeckillMessage.class); // 真正的数据库下单操作 orderService.createOrder(seckillMessage); // 手动 ACK确认消息处理成功 channel.basicAck(deliveryTag, false); } catch (Exception e) { // 处理失败重回队列或进入死信队列 channel.basicNack(deliveryTag, false, true); } } }消费者这里的核心是采用手动 ACK 模式。这是保证消息不丢的关键只有订单数据库操作真正成功后才会给 RabbitMQ 发送确认信号。如果数据库操作失败消息会被重新放回队列等待下一次重试。不过这里要小心一个问题如果消费者处理消息一直失败消息就会一直被重新入队形成消费死循环。所以一版完整的实现里我会配上死信交换机消息重试一定次数后自动转入死信队列由告警系统通知开发人员人工介入。3.4 数据库订单生成与库存扣减的最终落地MQ 消费者收到消息后做了哪些事关键是真正扣减数据库库存和生成订单。这两个操作必须在同一个事务里保证原子性要么都成功要么都失败。Transactional(rollbackFor Exception.class) public void createOrder(SeckillMessage message) { Long userId message.getUserId(); Long productId message.getProductId(); // 幂等性判断如果订单号已存在直接返回 String orderNo generateOrderNo(userId, productId); if (orderService.isOrderExists(orderNo)) { return; } // 乐观锁扣减库存返回影响行数 int rows stockMapper.deductStock(productId); if (rows 0) { throw new BusinessException(库存不足); } // 生成订单 Order order new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setProductId(productId); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); }两个关键点展开说一下。幂等性判断是整个消息链路里最容易遗漏的环节。消息队列有 at-least-once 的投递语义一条消息可能会被投递多次。如果不做幂等处理用户抢购一次可能会生成多个订单。我的做法是订单号由 userId 和 productId 组合生成每次消费前先查询订单是否已存在存在就直接跳过。数据库的这次库存扣减虽然是真正的扣减但因为前面有 Redis 的预扣减拦截能走到这一步的请求已经很少数据库压力完全可控。这也正是秒杀系统设计的精妙之处把最重的数据库操作放在流量最末端。从性能数字来看Lua 脚本的 Redis 操作耗时小于 0.1 毫秒MQ 消息投递耗时约 2~5 毫秒数据库订单写入平均耗时约 30 毫秒。整个秒杀链路的响应时间加起来不到 50 毫秒而请求量被层层削平数据库每秒承担的写入压力能控制在几百条以内。4. 实战中踩过的坑与排查思路4.1 缓存穿透与热点 Key 过期带来的雪崩隐患秒杀系统上线前最让我担心的不是代码逻辑问题而是 Redis 层的各种极端情况。第一个坑是缓存穿透。如果秒杀商品的缓存 key 不存在比如商品 ID 被恶意构造每次请求都会直接打向数据库。解决思路是缓存空值即使查不到商品信息也在 Redis 里存一个空对象设置较短的过期时间。这样就避免了恶意流量绕过 Redis 直达数据库。第二个坑是缓存雪崩。秒杀商品的缓存如果设置了同一个过期时间到期瞬间所有请求同时回源数据库压力会在那一瞬间集中爆发。解决思路有两个一是给缓存过期时间加一个随机值让过期时间分散二是在热点数据过期前用定时任务主动续期保证大促期间缓存永不失效。第三个坑是缓存击穿。某个热点 key 在过期的那一瞬间恰有大量请求同时来查询会全部穿透到数据库。秒杀场景里商品详情缓存被击穿的后果是致命的。我的解决思路是加互斥锁当缓存失效时第一个请求去查数据库并重建缓存其他请求短暂自旋等待后重新读取缓存。可以用 Redis 的 SETNX 命令实现一把简单的分布式锁。这套组合拳打完Redis 层的三个经典问题基本都被封死了。4.2 Redis 库存与数据库库存的一致性问题很多人在设计秒杀系统时会纠结一个问题Redis 里扣了库存数据库里也扣了库存这两个库存怎么保证一致如果追求强一致秒杀的并发能力会大打折扣但最终一致必须保证否则会出现 Redis 显示有库存、数据库却没货的尴尬。我的处理方式是Redis 库存只是预扣的一个令牌真正以数据库库存为准。Redis 预扣的时候数量上可以比数据库库存多放一些——这是秒杀系统里一个作弊但是很实用的设计。具体来说数据库实际有 100 件商品Redis 里可以放 110 件的预扣额度。允许最多 110 个请求通过 Redis 校验进入 MQ但数据库真正扣减时只有前 100 个人的 UPDATE 影响行数为 1后面 10 个人在数据库层被淘汰。这样一来Redis 和数据库之间就不需要强一致只要 Redis 的预扣额度不小于数据库真实库存就不会出现Redis 没库存但数据库还有货的浪费。需要注意这个 110 的额度要根据数据库的实际处理能力来设置不能放太多否则大量用户在最后一步被淘汰体验会变差。我一般是预留 10% 的余地既能保证 Redis 不被提前打空又不会造成太大浪费。4.3 消息积压、重复消费与消费者组扩容RabbitMQ 的消费者如果处理速度跟不上消息生产速度队列里的消息就会越积越多。秒杀场景结束后如果发现大量用户抢到了但订单没生成多半就是消息积压了。排查消息积压的第一步是看队列的堆积数量。RabbitMQ 管理后台的 Queues 页面能直接看到 Ready 状态的消息数量。如果积压了几万条就要立刻扩容消费者。扩容有两种方式一是增加同一个消费者注解下的并发消费者数量二是直接多部署几台消费者实例。RabbitMQ 支持同一队列被多个消费者同时监听消息会被自动分发到不同消费者。需要注意消费者实例增加后数据库的压力也会同步增加。所以扩容前要评估数据库的连接池大小和写入能力不要把消费速度提上去了数据库先被压崩了。重复消费的问题前面已经说过靠唯一的订单号做幂等判断来兜底。这里再补充一点幂等判断最好在数据库层面也加一层约束给订单表的订单号建立唯一索引。即便业务代码漏了判断数据库的约束也能拦截住垃圾订单。4.4 高并发下的库存预热与优雅降级秒杀开始前需要把商品库存预先加载到 Redis 中。这看起来很简单但有一个细节很多人没注意预热的时机和方式。我一般会在秒杀开始前 10 分钟用定时任务把活动商品信息批量写入 Redis同时设置好 key 的过期时间。这个预热任务要确保在秒杀开始前完成不能出现活动开始了 Redis 里还没库存的情况。秒杀进行中如果 Redis 出现异常比如集群抖动或者网络分区要有优雅降级的预案。降级方案分两种一种是直接熔断秒杀接口立刻关闭返回活动已结束保护后端另一种是降级到数据库层直接扣库存虽然性能会下降很多但至少还能勉强提供服务。从经验来看秒杀系统最怕的不是全挂而是半死不活——数据库在扛着压力但用户体验极差订单数据各种错乱。所以我会在 Redis 出问题的第一时间选择熔断宁可这场活动不做了也不能让资损事故发生。5. 压测数据与性能调优参考5.1 基础压测结果与分析系统开发完成后我自己用压测工具做过一轮性能摸底测试。测试环境是单机 8 核 16G 服务器Redis 单实例RabbitMQ 单节点数据库用的是本机 MySQL 8.0。压测参数配置1000 个并发线程持续压测 1 分钟商品库存设置为 5000 件。压测结果如下指标实测值说明接口平均响应时间约 8msRedis 预扣阶段几乎无耗时接口最大响应时间约 120ms出现在 Redis 连接池满时秒杀接口 QPS约 13000单机未优化状态Redis QPS约 15000Lua 脚本单条耗时小于 0.1msMQ 消息消费速率约 800 条/秒单消费者单数据库数据库扣减库存 TPS约 300 次/秒乐观锁单行更新从压测数据能看出几个问题接口层的 QPS 很高但 MQ 消费速率明显偏低说明瓶颈在消费者到数据库这一段。如果只开一个消费者5000 件的库存差不多要 6 秒左右才能消费完用户等待时间偏长。这个瓶颈很容易解决把消费者并发数提高到 5数据库写入 TPS 能提升到 1500 左右消费 5000 条消息不到 4 秒。如果再配合数据库连接池调整、打开 MySQL 批量写入效果更好。5.2 针对性能瓶颈的调优方向结合压测数据我总结了几条直接的调优方向按投入产出比从高到低排列第一优先调消费者并发。默认的消费者是单线程串行消费这是最大的瓶颈。提高消费并发数能立竿见影。在 RabbitListener 注解里设置 concurrency 参数或者配置容器工厂的并发线程数都能轻松把消费速率提上去。第二调整数据库连接池。SpringBoot 默认的 HikariCP 连接池最大连接数是 10如果消费者并发提上去了连接池会成为新的瓶颈。建议根据消费者并发数把连接池调大到 30~50。同时要优化数据库的写入性能关闭不必要的日志同步调整 InnoDB 的刷盘策略让单次写入耗时降下来。第三消息体精简。我在初版实现中把整个订单对象序列化后发到 MQ结果消息体很大网络传输和序列化都耗时。优化后只传 userId 和 productId订单的其他字段在消费者端组装。消息变轻之后吞吐量能提升 20% 左右。第四Redis 连接池调优。Lettuce 默认连接池在超高并发下会出现连接创建延迟。建议初始化时就设置足够的空闲连接避免压测初期连接池频繁创建连接拉高接口的响应时间。第五热点 key 的拆分。如果秒杀的商品数量非常多可以考虑按商品 ID 的哈希值将库存 key 分散到多台 Redis 实例上减轻单实例的压力。不过单机 15 万 QPS 对于绝大多数秒杀场景已经足够这一步属于锦上添花的优化。6. 排查问题的工具箱6.1 常见问题速查表把我在开发和联调中遇到的典型问题整理成了一张表遇到问题对照排查很实用问题现象可能原因排查手段接口报 500日志出现连接拒绝Redis 服务未启动或地址配置错误检查 Redis 地址、端口和密码用 redis-cli ping 测试连通性请求全部返回售罄但数据库库存没变Lua 脚本逻辑错误或库存 key 未预热手动执行 Lua 脚本测试检查预热任务是否成功执行同一个用户多次下单成功Redis 用户去重集合失效或 SADD 命令返回值未判断检查 Redis 集合 key 是否存在确认脚本逻辑消息积压订单迟迟不落库消费者处理速度跟不上查看 RabbitMQ 管理后台队列堆积数调高消费并发队列消息全部丢失日志没有任何记录未开启 confirm 模式或消费者用了自动 ACK检查生产者 confirm 配置确认消费者 ackModeMANUAL数据库订单 ID 重复出现多条相同记录幂等性判断逻辑缺失或订单号生成规则有问题检查业务层的幂等判断给订单号加唯一索引压测时接口 RT 忽高忽低Redis 连接池初始化不足或 GC 停顿预热连接池调整 JVM 参数降低 GC 频率6.2 我踩过的三次最深的坑第一个坑是 Lua 脚本用错了 Redis 命令。一开始我用 GET 读取库存之后再 DECR结果两个命令之间不是原子的高并发下照样超卖。后来改成纯 Lua 脚本才解决。这个教训就是任何时候都不要相信 Lua 脚本之外的先查询再修改除非你能保证检查后到更新前这段时间里没有其他请求。第二个坑是消息队列的使用方式。最初版本消费者用的自动 ACK结果测试时一停消费者服务积压的消息全部丢失用户抢到了但永远收不到订单。后来我总结了一条铁律涉及资金和订单的消息一律手动 ACK必须保证消息处理成功才确认消费。第三个坑是预热任务没做好幂等。秒杀活动可以配置多个预热任务同时执行如果设置了多条相同的预热逻辑会导致 Redis 库存被重复写入库存数量翻倍。排查了很久才定位到是预热任务重复执行的问题。现在的做法是在预热任务里加一个分布式锁确保同一时刻只有一个任务在执行写入。最后再分享几个非常实用的小细节做秒杀系统的时候你在细节上多花一点心思上线后就能少很多麻烦。分享几个我在项目里亲测有效的小技巧。第一个技巧是给秒杀接口加一个前置的校验缓存逻辑。用户请求进来后先用 Redis 查一下这个用户是不是已经在排队队列里。如果在直接返回订单处理中请勿重复提交连 Lua 脚本都不用进。这一步能过滤掉大量用户的重复点击减轻 Redis 的压力。第二个技巧是订单号生成放在 Redis 里做。用 Redis 的 INCR 命令生成自增序列再拼上用户 ID 和商品 ID 生成订单号既能保证唯一又能扛住高并发。别用数据库自增主键当订单号秒杀场景下性能瓶颈太明显。第三个技巧是前端配合做轮询而不是 WebSocket 推送。秒杀系统异步下单后用户看到排队中的页面前端只需要每隔 2 秒调一次查询订单状态的接口看到订单已创建就跳转。轮询接口的 QPS 很低因为只有真正抢购成功的用户才会轮询但实现成本远低于 WebSocket稳定性也更好。第四个技巧是把秒杀的活动状态也放到 Redis 里。活动未开始、已结束这些状态用一个 key 存起来接口先判断活动状态再走秒杀逻辑。否则活动结束了用户还能点击抢购Lua 脚本发现库存不足才返回失败浪费了大量无用请求。秒杀系统这套方案我已经在不同规模的场景下验证过很多次。核心就是三层拦截加异步削峰配合好 Redis 和 RabbitMQ 的能力2000 万的 DAU 也能轻松扛住。做完一个秒杀系统你对高并发系统的理解会上一个台阶——这些经验不只是为了一场比赛或者一次活动后面做其他高流量场景都能复用。