秒杀系统如何设计
们直接看一个全局架构图心里先有个底 1.静态化/答题2.刷票/验证3.资格校验4.原子扣减库存充足库存不足异步下单5.乐观锁扣减 用户请求️ CDN 前端限流 网关层-令牌桶限流 秒杀业务层⚡ Redis Cluster 消息队列 MQ 直接返回’售罄’ 订单消费者️ MySQL数据库✅ 下单成功 用户 CDN/静态页⚖️ 负载均衡 Nginx API 网关 限流/鉴权/黑名单️ Web 服务层 Redis 库存预减请求排队 消息队列 削峰填谷️ 数据库 异步下单 从库 订单查询这个链路就是我们今天要手撕的全部。秒杀系统的三大核心挑战 ⚠️挑战 本质问题 后果瞬时高并发 短时间内流量暴增 100-1000 倍 服务器雪崩、数据库宕机超卖问题 并发读写导致库存不一致 订单量 库存量资损恶意请求 脚本刷单、黄牛抢购 普通用户抢不到体验差整体架构设计 ️秒杀系统的核心思想是分层限流 异步削峰 最终一致性把流量层层拦截最终只有极少部分请求能到达数据库。用户请求CDN静态资源前端限流Nginx负载均衡限流网关层限流黑名单秒杀服务集群Redis缓存层(库存限流用户标记)MQ消息队列(异步削峰)订单服务数据库(最终库存扣减)支付服务核心流程拆解按时间线告诉你每一步在做什么我用一个时序图把一次正确的秒杀请求走通数据库消息队列Redis网关CDN/静态页用户数据库消息队列Redis网关CDN/静态页用户前端防作弊按钮防抖、验证码、问答alt[库存 0][库存已空]打开秒杀页面静态HTMLJS返回页面秒杀开始前按钮置灰点击秒杀携带token商品ID验证码限流校验令牌桶/漏桶黑名单过滤资格预扣DECR 库存扣减成功返回成功提示“排队中请稍候”发送秒杀请求消息用户ID商品ID库存不足直接返回“已抢光”消费者拉取消息异步创建订单创建成功标记该用户已下单成功轮询/推送 获取秒杀结果查结果缓存返回状态成功页 / 失败页设计原则能在前端拦截的绝不放到后端能在缓存拦截的绝不放到数据库能异步处理的绝不同步处理高并发解决方案 前端层限流 ✋按钮置灰 倒计时防止重复点击点击抢购前先弹出个滑块验证码或者简单的数学题。别小看这一步它能直接把脚本党和手速慢的人分流出去还顺带把瞬时峰值的尖峰削平了。验证码 / 滑块验证拦截机器请求随机延迟请求分散流量峰值动静分离 CDN ️把秒杀页面做成纯静态 HTML直接甩到 CDN 上。页面里秒杀按钮开始时是灰的千万不要提前暴露真正的下单 URL。到点了才通过一个额外请求动态获取秒杀地址/getSeckillPath这个 URL 还可以做成一次性的、加盐动态生成的。秒杀最怕的就是海量请求直接把服务器打崩。所以要将 99% 的不必要流量拦截在上游。网关层限流 Nginx 配置limit_req_zone按 IP 限流黑名单机制封禁恶意 IP 和用户网关层令牌桶算法限制总 QPS在网关层给秒杀接口加上令牌桶或漏桶限流器。比如这个接口每秒只能放行 5000 个请求。多出来的直接快速失败返回“挤不进去啦请稍后再试~ ”。宁可把流量扼杀在此也别让它冲垮下游。服务层限流 ️分布式限流RedisLua 脚本实现令牌桶熔断降级秒杀服务过载时直接返回 “活动火爆”业务隔离秒杀服务独立部署不影响核心业务缓存层优化 商品详情页静态化CDN 加速活动开始前将库存预热到 Redis使用 Redis Cluster 分片提高并发能力异步削峰 库存扣减成功后发送消息到 MQ订单服务异步消费消息创建订单前端轮询 /websocket 通知用户抢购结果超卖问题的终极解决方案 ✅超卖的根本原因是多个线程同时读取到相同的库存值然后各自扣减导致最终库存为负。方案对比 方案 实现方式 优点 缺点 适用场景数据库行锁 UPDATE goods SET stockstock-1 WHERE id? AND stock0 简单可靠 数据库压力大 低并发秒杀悲观锁 SELECT … FOR UPDATE 绝对不会超卖 性能极差死锁风险 几乎不用乐观锁 版本号机制 性能较好 高并发下成功率低 中低并发RedisLua 原子脚本扣减库存 性能极高原子性 依赖 Redis 可靠性 高并发秒杀 ✅分布式锁 Redisson 分布式锁 通用性强 性能一般复杂度高 复杂业务场景面试官插话你说了那么多如果流量真的冲进来了超卖问题到底怎么解决我 这正是秒杀的灵魂所在。我们绝对不能在数据库层面直接排队扣减那样 MySQL 会直接死给你看。核心方案Redis 预减库存 数据库最终一致性扣减推荐方案RedisLua 原子脚本 这是目前互联网大厂最主流的解决方案性能最高且绝对不会超卖。我们把秒杀库存提前预热到 Redis 里。关键是扣减操作必须原子化。我们用的不是多条 Redis 命令而是一段 Lua 脚本– 原子扣减库存脚本local stockKey KEYS[1]local userKey KEYS[2]local userId ARGV[1]– 1. 判断用户是否已经抢购过if redis.call(‘sismember’, userKey, userId) 1 thenreturn -1 – 已抢购end– 2. 判断库存是否充足local stock tonumber(redis.call(‘get’, stockKey))if stock 0 thenreturn 0 – 库存不足end– 3. 扣减库存并标记用户已抢购redis.call(‘decr’, stockKey)redis.call(‘sadd’, userKey, userId)return 1 – 抢购成功为什么这样能防止超卖 因为 Redis 是单线程执行命令的Lua 脚本在执行时会把整个脚本当成一个原子操作。绝对不会出现“读到还有库存但去扣的时候被别人扣光了”的读后写竞态条件。️快返回、异步下单MQ 削峰 Redis 扣减成功后我们不立刻操作数据库。而是马上给用户返回“抢购成功正在创建订单…”。同时把“用户ID、商品ID”等必要信息闪电般地扔进 RocketMQ 或 Kafka 里。服务端就直接结束了。这一步把瞬时的百万级并发变成了后端消费服务可以慢慢处理的流式消息削峰填谷。为什么 Lua 脚本能保证原子性Redis 是单线程执行的整个 Lua 脚本会作为一个整体执行执行过程中不会被其他命令打断最终一致性保证 Redis 扣减成功后通过 MQ 异步创建订单。如果订单创建失败需要进行库存回补订单服务消费消息失败发送死信消息库存回补服务消费死信消息将库存加回 Redis定时任务扫描超时未支付订单自动取消并回补库存️ 数据库——最后的兜底防线面试官追问那如果消息队列出错了或者 Redis 有什么问题数据库层面怎么兜底我 问得好。MySQL 是最后一道闸门我们必须做最坏的打算。方案乐观锁防超卖消费服务慢慢从 MQ 拉消息真正去创建订单、扣减数据库库存时必须用乐观锁。– 不用版本号直接用库存数当条件更简洁UPDATE seckill_productSET stock stock - 1WHERE id #{productId}AND stock 1; – 核心兜底条件执行完后看返回值。如果这个 SQL 影响行数为 0说明库存其实已经没了可能 Redis 那边因为主从延迟或异常多扣了一点点。此时进行补偿操作把 Redis 里多扣的库存加回去然后标记该订单失败或回滚。整个链路最终一致。 极限挑战热点数据问题怎么解如果是不限量的茅台谁也救不了只能用更大的集群硬扛。但我们可以业务隔离把秒杀系统单独部署不和日常业务抢资源。JVM 本地缓存如果某个商品真的是绝对热点可以把它的部分库存前置到服务本地的内存里用 AtomicInteger 自旋扣减再定时异步同步给 Redis。这样连 Redis 的网络开销都省了。其他关键问题处理 ️防黄牛一人一单限制、实名认证、收货地址校验数据一致性最终一致性模型定时对账容灾备份Redis 主从 哨兵数据库主从复制监控告警实时监控 QPS、库存、订单量异常告警总结 同学我们回顾下这场秒杀设计的整个逻辑闭环一张图记牢层次 核心组件/策略 解决的核心问题 前端/网关 CDN、验证码、动态URL、网关令牌桶 挡住垃圾流量削减尖峰⚡ 核心业务 Redis Lua 原子扣减 彻底杜绝超卖 异步处理 消息队列 RocketMQ/Kafka 削峰填谷将并发变流式️ 最终兜底 数据库乐观锁库存 ≥ 1 防止极端情况下数据不一致 可观测性 监控、告警、限流动态调整 随时知道系统还能撑多久高并发秒杀系统的设计精髓就是“挡”、“削”、“异” 三个字挡在前端、网关、缓存层挡住 99.9% 的流量削用消息队列削平流量峰值异将非核心流程异步化超卖问题的最优解是RedisLua 原子脚本它在保证原子性的同时提供了极高的性能完全能扛住百万级 QPS 的秒杀场景。核心代码核心代码实现带技术亮点Redis 原子性库存预扣Lua 脚本技术亮点用 Lua 脚本将 “库存判断 扣减 用户限购” 打包成原子操作彻底解决 Redis 并发下的竞态问题比多次 Redis 调用性能高 3 倍以上。/**原子性预扣减库存Lua脚本实现param goodsId 商品IDparam userId 用户IDparam limitCount 每人限购数量return 0-成功 1-库存不足 2-已达限购上限*/public int deductStock(Long goodsId, Long userId, int limitCount) {String luaScript “”local stockKey KEYS[1]local userKey KEYS[2]local limit tonumber(ARGV[1])-- 1. 判断用户是否已达限购上限 local userBuyCount redis.call(HGET, userKey, ARGV[2]) if userBuyCount and tonumber(userBuyCount) limit then return 2 end -- 2. 判断库存是否充足 local stock redis.call(GET, stockKey) if not stock or tonumber(stock) 0 then return 1 end -- 3. 扣减库存记录用户购买数量 redis.call(DECR, stockKey) redis.call(HINCRBY, userKey, ARGV[2], 1) return 0“”;List keys Arrays.asList(“seckill:stock:” goodsId,“seckill:user:” goodsId);return (Integer) redisTemplate.execute(new DefaultRedisScript(luaScript, Integer.class),keys,limitCount,userId.toString());}分布式防重锁Redisson 可重入锁 看门狗技术亮点解决原生 SETNX 锁的 “锁过期但业务未执行完” 问题Redisson 看门狗自动续期支持可重入是生产环境唯一推荐的分布式锁实现。/**防止同一用户重复下单*/public Result seckill(Long goodsId, Long userId) {String lockKey “seckill” goodsId “:” userId;RLock lock redissonClient.getLock(lockKey);try {// 尝试获取锁最多等待0秒锁默认30秒过期看门狗自动续期if (!lock.tryLock(0, TimeUnit.SECONDS)) {return Result.error(“请勿重复提交请求”);}// 执行库存预扣减 int result deductStock(goodsId, userId, 1); if (result 1) { return Result.error(商品已售罄); } if (result 2) { return Result.error(您已参与过本次秒杀); } // 生成订单ID发送消息到Kafka String orderId UUID.randomUUID().toString(); kafkaTemplate.send(seckill-order-topic, new SeckillMessage(orderId, goodsId, userId)); return Result.success(排队成功请等待订单生成);} catch (InterruptedException e) {Thread.currentThread().interrupt();return Result.error(“系统繁忙请稍后再试”);} finally {// 释放锁if (lock.isHeldByCurrentThread()) {lock.unlock();}}}消息队列幂等性消费者 ✅技术亮点用订单 ID 作为唯一标识实现幂等性解决 Kafka 消息重复消费导致的重复下单问题同时配合事务保证消息处理的原子性。/**Kafka秒杀订单消费者幂等性实现*/KafkaListener(topics “seckill-order-topic”, groupId “seckill-group”)public void onMessage(ConsumerRecordString, String record) {SeckillMessage message JSON.parseObject(record.value(), SeckillMessage.class);String orderId message.getOrderId();// 1. 幂等性判断如果订单已存在直接返回if (orderService.existOrder(orderId)) {log.info(“订单已存在重复消费orderId: {}”, orderId);return;}// 2. 事务处理创建订单扣减数据库库存try {orderService.createOrderWithStockDeduct(message);} catch (Exception e) {log.error(“订单创建失败orderId: {}”, orderId, e);// 发送到死信队列人工处理kafkaTemplate.send(“seckill-dlq-topic”, record.value());}}数据库最终库存扣减行锁 乐观锁技术亮点用 MySQL 行锁防止超卖同时用乐观锁版本号控制并发更新在保证数据一致性的前提下最大化性能。– 最终库存扣减行锁实现绝对不会超卖UPDATE seckill_goodsSET stock_count stock_count - 1,version version 1WHERE goods_id #{goodsId}AND stock_count 0AND version #{version};5. 接口层的防刷与排队反馈限流 秒杀结果异步通知RestControllerpublic class SeckillController {Autowiredprivate SeckillStockService stockService;Autowiredprivate RocketMQTemplate mqTemplate;Autowiredprivate StringRedisTemplate redis;// 秒杀接口 PostMapping(/seckill/exec) public Result exec(RequestParam String itemId, RequestParam String captcha, HttpServletRequest request) { String uid getLoginUserId(request); // 1. 验证码校验防脚本 if (!checkCaptcha(uid, captcha)) { return Result.fail(验证码错误); } // 2. 用户级限流每人只允许一次请求 String limitKey seckill:limit: itemId : uid; Boolean limited redis.opsForValue() .setIfAbsent(limitKey, 1, Duration.ofMinutes(1)); if (Boolean.FALSE.equals(limited)) { return Result.fail(请勿重复提交); } // 3. 库存预扣 boolean deducted stockService.deductStock(itemId, 10); // 10个分片 if (!deducted) { return Result.fail(已抢光); } // 4. 发送 MQ 消息异步下单 SeckillMessage msg new SeckillMessage(uid, itemId); mqTemplate.asyncSend(seckill_order, msg, new SendCallback() { Override public void onSuccess(SendResult result) {} Override public void onException(Throwable e) { // 发送失败补偿恢复库存 删除 limitKey // ... 异常处理逻辑可发一条补偿消息 } }); // 5. 立即返回排队结果前端轮询查结果 return Result.ok(排队中请稍候查看结果); } // 查询秒杀结果 GetMapping(/seckill/result) public Result queryResult(RequestParam String itemId, HttpServletRequest req) { String uid getLoginUserId(req); String resultKey seckill:result: itemId : uid; String result redis.opsForValue().get(resultKey); if (result null) { return Result.ok(排队中); } return Result.ok(result); // success 或 fail }}技术亮点Redis 用户限流 挡掉绝大多重复请求。异步返回秒杀接口耗时控制在 10ms 内不阻塞用户。失败补偿逻辑生产需进一步细化。