优惠券秒杀在本地生活类平台里是个绕不过去的硬骨头尤其是“点评”这种带着 LBS、商户和用户多重角色的场景优惠券本身就是强运营工具。我去年在做某平台优惠券秒杀功能重构时第一版方案上线就撞上了库存超卖和接口抖动两个问题后来把整个链路拆开重做了一遍才把问题从根上解决。这篇就把这一版方案完整拆出来聊从数据库设计、Redis 预扣库存、Lua 原子扣减再到消息队列异步落库和定时对账每层为什么这么设计、实际踩过哪些坑都会讲到。适合准备做秒杀或正在优化领券功能的同学照着复现基本不会跑偏。1. 优惠券秒杀的业务特点与架构选型思路1.1 秒杀和普通领取的本质区别普通领券是“放着随时领”接口压力分散在全天用户在任意时刻访问都能拿到。秒杀则恰恰相反固定时间点集中放量活动开始那一秒几十万甚至上百万请求会同时涌进来。对系统来说真正要抗的不是一天的访问总量而是开闸瞬间的峰值 QPS。这个区别决定了技术方案选型普通领券用数据库 update 扣库存足够秒杀场景再用数据库直接扣就是给自己找麻烦。另外优惠券秒杀还有一个业务特性——每人领券数量通常有上限比如每人限抢 1 张但这个限制在高并发下很难靠数据库唯一索引扛。所以设计时必须把“库存校验”和“用户资格校验”拆成两个独立的控制点分别用 Redis 和数据库的不同机制去管。1.2 高并发场景下的三个典型故障超卖、击穿、雪崩先说超卖这是秒杀系统最容易出现的严重事故。本质是“检查库存”和“扣减库存”两个动作之间存在时间差多个请求同时通过检查然后一起扣减库存就变负了。抛开分布式锁这类笨办法真正有效的思路是把检查、扣减放在同一个原子操作里让数据库或者 Redis 帮你保证“这一刻只有一个请求能扣成功”。再说缓存击穿。秒杀活动通常把库存 Redis 化但如果库存 key 正好在流量高峰过期而新值还没来得及加载大量请求会同时穿透到数据库。对优惠券秒杀来说库存 key 不应该在活动期间“自然过期”应该设置成覆盖活动结束时间的长过期时间活动临近结束时主动删除或更新。缓存雪崩则是大量 key 在同一时间失效导致请求直接打到数据库。秒杀场景里 key 数量不多但也要注意不要所有券都共用同一个过期时间点。可以在基础过期时间上加上一个随机增量几十秒到几分钟之间的差异就够。1.3 整体技术选型与模块划分这一版改造用的是比较常见的组合Spring Boot Redis RabbitMQ MySQL。选择依据很简单团队对这套栈最熟运维成本低而且足够支撑单场活动几十万到上百万 PV 的流量。Redis 负责库存预扣和用户资格标记RabbitMQ 负责削峰填谷MySQL 做最终数据落库任务调度用 XXL-Job 或 Spring 自带 Scheduled 都可以。整体调用链路分四层接入层鉴权、限流、用户资格预检。秒杀处理层Redis Lua 脚本做库存扣减同步返回“抢购结果”。异步处理层发送消息到 MQ消费者创建用户券记录并写数据库。对账补偿层定时任务扫描 Redis 与 DB 的一致性处理回补和释放。这里最关键的一个设计原则是同步链路只做轻量操作所有可能导致 IO 阻塞的写操作全部异步化。用户按下“抢”按钮后接口在几十毫秒里告诉他“成功或失败”但真正发券可能在几百毫秒后才完成。只要最终保证用户能在“我的卡券”里看到这张券就是一致的。2. 库存模型设计数据库表结构与 Redis 预扣机制2.1 数据库表怎么设计才合理优惠券秒杀涉及两张核心表券模板表和用户领券记录表。模板表主要存基本信息我通常这么建CREATE TABLE coupon_template ( id BIGINT PRIMARY KEY AUTO_INCREMENT, coupon_name VARCHAR(128) NOT NULL, total_stock INT NOT NULL COMMENT 发券总量, remain_stock INT NOT NULL COMMENT 剩余可发量, limit_per_user INT DEFAULT 1 COMMENT 每人限领数, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 0 COMMENT 0未开始 1进行中 2已结束, version INT DEFAULT 0 COMMENT 乐观锁版本号, created_time DATETIME NOT NULL, updated_time DATETIME NOT NULL ) ENGINEInnoDB COMMENT 优惠券模板表;用户领券记录表是异步落库的数据表靠唯一索引防重复CREATE TABLE user_coupon_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, template_id BIGINT NOT NULL, coupon_code VARCHAR(64) NOT NULL, state TINYINT DEFAULT 0 COMMENT 0已领取 1已核销 2已过期, create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_user_template (user_id, template_id) ) ENGINEInnoDB COMMENT 用户领券记录表;唯一索引是幂等消费的最后一个兜底哪怕消息重复投递数据库也只会插入一条记录。这里要注意coupon_code需要在插入时生成唯一串通常用雪花 ID 加随机字符避免和业务主键耦合。2.2 为什么不能靠数据库 update 扛秒杀有人会问数据库用带条件的 update 不也能防超卖吗比如update coupon_template set remain_stock remain_stock - 1 where id ? and remain_stock 0这条 SQL 在单次更新里是原子的确实不会超卖。但问题出在性能上MySQL 每秒能承受的更新事务量一般也就是几百到一千左右而秒杀峰值随时可能上万。数据库的 CPU 和锁竞争会直接把接口拖垮。更麻烦的是数据库连接数是有限资源。秒杀请求同一秒进来如果每个请求都占一个事务连接去 update连接池瞬间被打满后台正常业务比如写交易流水也会被连带阻塞。所以我的结论是数据库只能用来做最终落库和人工兜底绝对不能直接扛秒杀流量。2.3 Redis 库存缓存的键设计与过期策略Redis 侧我用两个 key 管理库存seckill:stock:{templateId} - String剩余可抢数量 seckill:user:{templateId} - Set存放已成功抢到的 userId库存 key 在活动开始前由 admin 接口预热值等于数据库中的总库存。预热时同时设置过期时间为“endTime - now 300秒”活动结束后多留 5 分钟给用户继续查看和延迟处理之后 key 自动删除避免占用内存。用户 key 的作用是记录哪些人已经抢到用来做限领校验。这个 Set 会有一些内存占用但和数据库记录的价值比完全值得。后续也可以在异步消费者落库之后再删除或者保留。我建议保留到活动结束反正 Redis 内存可控。关于过期时间还要注意一个细节不要给用户 set 设置和库存 key 一样的过期时间最好让它晚一点过期。因为如果用户 set 先过期而库存 key 还在那么同一个用户就可能重复抢第二次绕过“每人限领 1 张”的限制。3. 秒杀接口的限流、防刷与原子扣减实现3.1 接口层的限流怎么做才不伤用户体验限流不是简单地限制请求数量而是把流量控制在系统能处理的范围内。我在接入层做了两层限流全局限流和用户维度的限流。全局限流用的是令牌桶算法基于 Guava RateLimiter 或者 Redis 里的分布式令牌桶都行。桶容量设置成后端能承受最大 QPS 的 1.5 倍左右比如下游支撑 2000 QPS令牌桶上限就设 3000这样既能给流量波动留余量又不至于把服务打爆。用户维度限流更关键。我的策略是同一个用户uid 设备指纹在活动开始后 1 秒内最多只允许 3 次秒杀请求超出的直接返回“操作太频繁”。为什么是 3 次因为正常用户在手速和老旧机型卡顿影响下1 秒内大概率只发 1 到 2 次请求如果达到 3 次还没有结果反而不适合继续放行可以提示稍后重试。这个阈值每周活动后根据日志微调一次。极端情况下还可以加验证码机制但秒杀场景大量用户同时答题会影响转化率。我的经验是只在系统检测到异常流量时比如同一 IP 请求数超过正常值 5 倍才动态弹出滑块验证码。平时不用保证正常用户一键参与。3.2 Lua 脚本扣减库存一个脚本解决并发原子性问题Redis 的单个命令是原子的但“查库存 扣减 记录用户”这一组动作不能简单用几条命令串联因为服务端两次命令之间有另一个请求插入执行就会出问题。正确做法是把多个操作写进一个 Lua 脚本由 Redis 保证脚本整体原子执行。我给出实际使用的核心脚本-- KEYS[1] 库存key -- KEYS[2] 用户已抢购key -- ARGV[1] 用户ID -- ARGV[2] 创建秒杀单号唯一ID local stock redis.call(get, KEYS[1]) if not stock or tonumber(stock) 0 then return 0 end local has redis.call(sismember, KEYS[2], ARGV[1]) if has 1 then return -1 end -- 这里可以继续检查用户是否已在数据库领过 -- 调用外部API判断比较重实际留给异步消费处理 redis.call(decr, KEYS[1]) redis.call(sadd, KEYS[2], ARGV[1]) redis.call(set, seckill:order: .. ARGV[2], 0, EX, 600) return 1脚本返回值的语义要约定清楚返回 1代表库存扣减成功用户进入异步发券队列返回 0代表库存不足返回 -1代表用户重复参与。在 Lua 脚本里decr和sadd是原子执行的所以不会出现库存扣了但用户没记录的情况也不会出现超卖。这里还有一个值得注意的小技巧为什么不用hincrby之类把库存和用户记录放进同一个 hash因为 Set 的sismember在判断用户是否存在时更快和 hash 字段存在性检查性能差别不大但 Set 天然支持活动结束后直接删除内存管理更简单。3.3 同步返回还是异步返回响应模型怎么选秒杀接口是否要同步等待“发券成功”绝大多数场景没有必要。用户看到“抢券成功”时不代表券已经进账他更在意的是结果反馈在几秒内出现。我的实现是接口同步校验完限流和资格后直接返回“请求已接收”随后把封装好的消息发送到 RabbitMQ。消息内容至少包含{ userId: 123456, templateId: 8888, bizId: 202501201015300123456 }bizId是整个秒杀流程唯一业务 ID消费者用它做幂等判断。发送时把bizId作为消息的correlationId这样在排查链路时能快速追踪。这里要特别注意一个反直觉的点Redis 扣减成功不等于用户一定领到券。因为后续消息可能发送失败或者消费端处理时数据库异常所以接口侧可以返回成功但最终发券结果需要额外补偿机制来保证。这也是下一部分要展开的。4. 下单与发券的解耦消息队列异步化实操4.1 消息发送、消费与重试机制RabbitMQ 队列我命名为queue.seckill.coupon交换机用 topic 类型路由键routing.key.seckill. 消息在生产者发送时也要注意不要发出去就觉得自己成功了。要在发送代码里确认channel.confirmSelect()开启发布确认并设置发送失败后的本地重试。实际代码大致是这样Autowired private RabbitTemplate rabbitTemplate; public boolean sendSeckillMessage(SeckillMessage msg) { try { CorrelationData correlationData new CorrelationData(msg.getBizId()); rabbitTemplate.convertAndSend( exchange.seckill, routing.key.seckill, msg, correlationData ); return true; } catch (Exception e) { log.error(秒杀消息发送失败, bizId{}, msg.getBizId(), e); // 进入失败补偿表等待定时任务重推 seckillFailRecordService.save(msg); return false; } }消费者这边要设置手工 ACK绝不能使用自动 ACK。自动 ACK 的意思是消费者收到消息后立刻确认如果业务代码抛出异常消息就丢了。手工 ACK 就能保证只有数据库插入成功后才调用channel.basicAck(deliveryTag, false)如果发生异常调用basicNack并重回队列等待下一次重试。消费重试次数建议限制在 3 次以内超过就丢弃并记录日志。因为如果消费者一直处理失败大概率是代码 bug 或者数据库不可用无限重试只会把队列堵死。4.2 消费者如何保证幂等性消息队列最常见的坑是重复投递。RabbitMQ 在极端情况下比如消费端宕机后重启会把未成功 ACK 的消息重新投递如果消费者不做幂等处理就会出现同一个用户领到两张券。我的消费者第一件事就是查数据库里是否已经存在这条user_coupon_record记录if (userCouponRecordMapper.existsByUserIdAndTemplateId(userId, templateId)) { // 已存在直接确认消息不重复处理 channel.basicAck(message.getDeliveryTag(), false); return; }这里有一个窗口期问题两个相同消息并发到达消费者线程两个线程同时查数据库都不存在记录然后都去插入就会导致重复。所以光靠查记录不够必须依赖数据库的唯一索引兜底。插入时如果碰到DuplicateKeyException就捕获异常后换成查询返回已存在同时 ACK 消息。双保险组合拳是标准做法。4.3 数据库落库与用户券包的最终写入消费者处理逻辑经典三步校验用户是否已在其他渠道领过同类券 - 插入用户券记录 - 更新模板表已发数量。第一步和校验 Redis Set 类似但数据库校验更准确第二步插入成功后才算真正发券第三步更新remain_stock时要用乐观锁版本号。第二步和第三步不需要强事务同生共死。只要用户券记录插入成功模板表数量即使短暂不一致后面有对账任务来修正。如果强制事务反而会让数据库事务时间变长影响插入吞吐。我用一个简单的状态机插入user_coupon_record成功但模板表更新失败记录“已发放”日志告警。模板表更新成功但插入失败说明有更严重的 bug必须走对账回滚。两者都成功正常发放完成。这个设计里用户的券包以user_coupon_record为准模板表remain_stock是统计参考。最终一致性靠定时任务对齐。5. 一致性兜底防超卖与库存回补的最后一环5.1 乐观锁和唯一索引怎么配合防超卖即使在 Redis 和消息队列之间做了层层防护也不能排除极端情况。比如 Lua 脚本执行成功后消息队列宕机队列恢复前消息堆积了几万条或者定时重试机制导致同一条消息在恢复后连续投递了几次。这些情况最终都要数据库来决定“是否真的能发”。update coupon_template set remain_stock remain_stock - 1, version version 1 where id ? and remain_stock 0 and version ?这行 SQL 是防超卖的最终兜底。在异步消费者插入用户记录时执行这条更新影响行数为 0 说明数据库侧库存已经不足需要把刚才插入的记录删除或者置为无效并记录异常。这个兜底保证了不管上层怎么出问题数据库层面永远不会出现负数库存。配合用户记录表的唯一索引就形成了两道防护唯一索引防止重复发券乐观锁防止多发券导致超卖。5.2 定时对账如何设计每分钟跑一个对账任务我们把它叫“戴森球任务”因为这任务像个吸尘器一样吸走所有不一致。任务核心逻辑是从 Redis 取出活动库存 key 当前值。扫描数据库中该活动实际发放成功的券记录数。计算期望剩余totalStock - 实际发放数。将期望剩余回写到coupon_template.remain_stock。如果 Redis 库存 key 的值和期望剩余差值大于一个阈值比如 5说明 Redis 预扣数量和实际发放数严重偏离触发告警。这一步很关键但要注意一个场景Redis 扣减了库存、用户也收到了“成功”提示但异步消费还没执行完此时实际发放数小于 Redis 扣减数remain_stock偏高是正常的。所以对账任务的阈值不能设成严格相等要允许一个时间窗口的延迟。我的做法是统计最近 5 分钟 Redis 扣减总量和最近 5 分钟实际发放总量做对比差值超过 5% 才告警。这样既不会误报也能捕获到链路故障。5.3 库存回补与释放边界用户抢到券后有时会在有效期结束前不使用这不算库存回补因为券已经发出去用户券包不会撤回。真正需要回补的是下面几种情况用户在秒杀结果返回前取消或超时实际上秒杀一般不支持取消。消息发送失败但补偿任务确认用户未领到券。用户重复代码导致 Redis Set 有记录但数据库无记录。对第一种和第二种我设计了seckill_fail_record表专门记录发送失败的消息。对账任务会检查这张表中超过 5 分钟仍未成功推送的记录确认数据库确实没有发放记录后执行 Redis 的incr回补库存并删除用户 Set 中的记录。因为bizId是唯一的整个回补过程不会和正常消费产生冲突。这里有个边界条件必须想清楚回补库存的同时用户可能已经通过其他渠道比如客服手动补发拿到了券此时再回补库存就会造成多发放。所以回补操作前必须查数据库用事务锁和唯一索引保证“没有记录才回补”如果已有记录就不回补只记录日志让人工或补偿策略决定如何处理。6. 压测、排障与参数调优实录6.1 压测指标与工具准备我在正式上线前用 JMeter 和内部压测平台做过三档压测日常流量的 5 倍、20 倍和 50 倍。核心指标只有两个接口 P99 响应时间和数据库/Redis 的 CPU 使用率。压测场景要覆盖“全部用户同一秒点领取”的最坏情况而不是均匀分布。压测时一定要记录 Redis 的info输出和 RabbitMQ 队列的堆积水位。如果队列堆积数超过消费者消费速率的 5 倍说明消费者DB写入遇到瓶颈这时候优化方向不是加机器而是调整批量消费或缩短事务粒度。6.2 三个线上高频问题与排查路径问题一活动开始后接口响应突然从 20ms 涨到 500ms。排查过程先看 Redis 慢日志再看 MySQL 线程状态最后发现是 Redis 大 key 导致。因为用户 Set 里塞了几十万个 uidsismember查询在大 key 下变慢。解决办法是把用户维度拆成 16 个分片 Set查询时按userId % 16选择分片命中任何一个分片的记录就算已抢过。实测从 500ms 恢复到 30ms 以内。问题二同一用户重复领券数据库里出现两条记录。排查发现是消费者幂等判断在并发下没有走唯一索引。因为代码先查记录再插入查询和插入不是原子两个线程都查不到就都插入了。修复办法是把幂等判断完全交给数据库插入时直接无视重复检查捕获DuplicateKeyException后再走查询分支。数据库里那条唯一索引就是权威。问题三Redis 库存没了但数据库remain_stock还有余额。这大概率是活动结束前 Redis key 被提前删除或者过期时间设置太快。检查发现我把过期时间写成了endTime忘记加缓冲活动最后一秒流量还在打key 刚好过期Lua 脚本直接返回 0。修复后改成endTime 300 秒并给 key 加了永久标志直到活动结束后由任务主动删除。6.3 参数配置与避坑清单几组值得直接抄走的配置Redis 连接池maxTotal设为 500maxIdle设为 200在压测机上这个配置支撑到了 5000 QPS 才出现连接等待。RabbitMQ prefetch 设为 1让消费者处理完一条再去下一堆避免某条异常消息占据全队队头。消费者线程数设为 16~32太高会出现数据库连接池争抢太低会浪费 Redis 性能。定时对账任务的调度间隔 1 分钟但 SQL 查询时只查最近 10 分钟和未来 10 分钟窗口内的活动避免全表扫描。避坑清单里最值得记的一条是不要把 Redis 扣库存和用户 Set 记录拆成两个独立命令去执行。哪怕你觉得 Redis 单命令是原子的两个命令之间的原子性也无法保证。必须用 Lua 或把状态合并到同一个结构里。我在早期版本就吃过这个亏两个命令之间插入了用户重复请求导致 Set 里没有记录但库存被扣了后来排查半天才发现是原子性问题。7. 从一次意外中得到的改进思路上线后第二周某场活动因为商家临时加了 5000 张券运营在活动开始前 5 分钟改了总库存结果 Redis 缓存里的值没有同步更新用户看到可抢数量多了 5000但 Redis 实际只有 3000所以最后一批用户一直提示“稍后再试”。这次事故给我的教训是运营后台的库存修改必须通过一个统一服务同时更新 MySQL 和删除 Redis key然后由预热任务重新加载。任何绕过统一入口的直接改库行为都会留下缓存不一致的坑。从那之后我把库存更新接口做了版本号管理每次改动都会生成changeIdRedis 预热的 value 带上这个版本号。查询库存时如果发现缓存里的版本号和数据库不一致直接触发懒加载刷新。这个机制虽然代码量不大但对运营同学来说省心很多也避免了许多潜在客诉。最终这套方案在几次大促和每周固定秒杀活动中运行都很稳定。我个人最大的体会是秒杀系统没有银弹每个环节都要有兜底Lua 防超卖、消息队列削峰、数据库幂等、定时对账四个缺一不可。至于更复杂的分布式事务、热点隔离方案大多数场景其实用不到先把手头这套链路做到极致比追逐新概念更有价值。如果后续你们也要做类似功能建议先按这个最小闭环跑通再在压测中逐步加固。
阅读完成 · 觉得有帮助?