“秒杀”这个词做后端的朋友听到耳朵都快起茧了。但说来惭愧真正把秒杀系统的每个环节都亲手撸过一遍、并且在高流量下没出事故的人其实不多。我最近刚参与了一个视频平台的在线秒杀活动复盘正好是典型的高并发场景活动一开始几万人同时点按钮去抢有限的商品背后的订单系统、库存系统、支付链路全部面临瞬时压力。整套方案最终落地用到了 Redis 加 Lua 脚本把库存扣减这个最核心的环节做成原子操作压测峰值扛住了也没出现超卖。这篇文章我会从方案选型开始把为什么用 Redis、为什么一定要扯上 Lua到库存预热、脚本编写、防刷限流、下单链路拆分再到压测遇到的问题和排查过程完整记录下来。不管是正在准备面试想搞清楚秒杀原理还是手头真有项目要做高并发库存扣减这篇文章应该都能给你提供一套可以直接落地的思路。1. 秒杀场景到底难在哪秒杀业务说白了就是一个活动有限数量的商品在极短时间内被大量用户争夺。核心矛盾就四个字——僧多粥少。在技术层面这四个字引发的问题远比想象中复杂。1.1 并发集中导致的三类典型问题先说第一类也是所有秒杀系统最容易翻车的点超卖。库存只有 100 件结果同时来了 200 个请求数据库里库存被扣成了负数订单却生成了 200 条。用户那边看起来是抢到了仓库发货的时候才发现根本不可能履约最后公司要赔钱道歉很狼狈。第二类是热点数据打崩数据库。秒杀商品的库存记录在数据库里就一条这条记录就是所谓的热点行。MySQL 对同一行记录的更新是串行化的所有扣库存的 UPDATE 语句会在行锁上排队。数据库的连接池就那么大请求一多全部阻塞在锁等待上最终表单里出现大量超时和报错。第三类是恶意请求和羊毛党。正常用户是老老实实等倒计时结束再点但攻击者、脚本党会提前把接口请求构造好用工具并发刷。如果没有防刷机制真实的用户请求大概率抢不过这些机器脚本。1.2 为什么普通接口写法顶不住很多人第一反应是不就在 Controller 里写个方法查库存、判断、减库存三步走嘛。我们来看下这常规写法在高并发下会发生什么。假设代码逻辑是这样的逻辑查询数据库商品库存是否大于 0库存大于 0 则执行 UPDATE 操作扣减库存扣减成功后生成订单这里最大的问题在于查询和更新不是一个原子操作。两个请求同时查到库存为 1都判断大于 0然后都去执行 UPDATE库存就变成 -1 了。即便你给库存字段加了乐观锁版本号也只是让其中一个更新失败但这个失败请求需要重新查询在高并发下会放大数据库压力。用悲观锁 SELECT FOR UPDATE 倒是能保证串行但数据库行锁的代价就是吞吐量断崖式下跌几百个并发就能把连接池耗尽数据库 CPU 直接飙到 100%。所以秒杀场景下核心诉求非常明确库存扣减必须原子化而且不能频繁落到数据库上。这就是 Redis 登场的原因。2. 方案选型为什么是 Redis 加 Lua说到高并发缓存Redis 几乎是刻在 DNA 里的选择。但缓存在这里不只是为了提速更重要的是解决原子性问题。而 Lua 脚本是保证原子性的关键手段这两者组合起来才能形成一个完整的秒杀扣减方案。2.1 Redis 单线程模型和原子性的关系Redis 处理命令是单线程事件循环这一点很多初学者可能没细想过。正是因为这个特性Redis 的单个命令天然是原子的不会出现两个命令同时修改一个 Key 的中间态。但秒杀扣库存的逻辑不是单个命令能搞定的。你至少需要GET 库存值判断库存是否大于 0DECR 扣减库存这三步如果分开用客户端发送多个 Redis 命令就会遇到和数据库一样的问题并发环境下多个客户端读到同一个库存值都判断能扣结果超卖。读者可能想到用 Redis 事务 MULTI 和 EXEC。MULTI 确实能把多条命令打包按顺序执行中途不会被其他命令插入能解决原子性问题。但事务的痛点在于它不支持条件判断。你没法在事务里写如果库存大于 0 才扣减这种逻辑。WATCH 乐观锁倒是可以实现判断但冲突时需要重试高并发场景下重试率会非常高性能损耗大代码也变得复杂。Lua 脚本解决了这两个痛点。Redis 从 2.6 版本开始内置了 Lua 解释器可以将多个 Redis 命令打包在一个脚本里提交执行。整个脚本作为一条命令在 Redis 服务端原子地运行中间不会有任何其他命令插入同时脚本内可以使用 if-else、逻辑比较等编程语言特性实现条件扣减。2.2 Lua 脚本带来的性能优势Lua 脚本还有一个容易被忽略的性能红利减少网络开销。没有 Lua 的时候三步操作至少需要三次网络往返。就算用 Redis 事务也需要先 WATCH再 MULTI再 EXEC多次通信。而 Lua 脚本把逻辑打包成一段文本一次发送一次执行一次返回结果整个交互过程只消耗一次网络 RTT。在几十微秒级的 Redis 操作中网络消耗往往占了很大比重减少交互次数对吞吐量的提升非常明显。另外Lua 脚本在 Redis 中是绿色线程级的执行不会触发磁盘 I/O纯内存操作单个脚本执行时间通常都在微秒到百微秒级别远小于一次数据库行锁等待的时间。这也是它能支撑万级 QPS 的根本原因。2.3 对比其他常见方案这里放个简单对比表格帮助理解不同方案的优劣。方案原子性判断能力性能表现实现复杂度数据库乐观锁版本号弱支持低需要重试低数据库悲观锁SELECT FOR UPDATE强支持极低行锁排队低Redis事务MULTI/EXEC强不支持中中Redis WATCH 乐观锁弱支持低冲突重试高Redis Lua 脚本强支持高中Redis 加 Lua 不是唯一解但确实是综合原子性、判断能力和性能之后的最优解。3. 秒杀系统整体设计与库存预热方案方案思路确定之后不能直接开始写 Lua得先把整个系统架构给捋清楚。秒杀不是一个接口而是一整套链路。前端有倒计时和抢购按钮网关层有鉴权和限流业务层有下单接口和校验逻辑Redis 层有库存预扣减最终订单状态要通过消息队列异步落库。3.1 高并发秒杀的分层架构我这边实际落地的时候把整个秒杀系统分成五层接入层Nginx 加 OpenResty 做 IP 级限流每个 IP 对秒杀接口的访问频率做限制最简单的做法是漏桶限流每秒超过阈值直接返回请求过于频繁。应用层Spring Boot 网关或 Web 层做用户级防重同一个用户 ID 对同一场秒杀只能请求一次用 Redis 的 SETNX 加过期时间实现。数据缓存层Redis 里维护商品的库存 Key、用户已购标记 Key、限流计数器 Key秒杀的核心判断都在这一层完成。异步处理层Lua 脚本扣减成功后发送一条消息到 RabbitMQ 或 RocketMQ由订单服务异步消费生成正式订单。这样下单接口的响应时间不会因为数据库写入而变慢。数据持久层数据库最终收到的是消费后的下单请求压力小了很多主要处理常规订单插入和库存最终校验。这种分层有个核心原则越往前的层处理速度要越快尽量让流量在到达数据库之前就已经被消化掉。第二层和第三层是关键因为 90% 的请求其实是没有资格的它们可以被快速拒绝根本不需要走到数据库。3.2 库存预热和 Key 设计库存数据在秒杀开始前必须提前加载到 Redis这个步骤称为库存预热。你不能等到用户点按钮了才去数据库查库存然后写入 Redis那样 Redis 层就没有意义所有请求还是会穿透到数据库。预热通过一个定时任务完成。活动开始前五分钟从数据库把商品的秒杀库存查出来写入 Redis。具体操作示例SET seckill:stock:1001 100这里 1001 是商品 ID100 是秒杀库存数量。在设计 Key 的时候有几个细节要注意Key 要带上业务前缀和活动 ID避免不同活动之间混淆。比如seckill:stock:{actId}:{skuId}这样一场活动对应一批 Key活动结束清理也方便。库存 Key 的过期时间设置为活动结束时间加上一个缓冲期比如活动 10 点结束Key 的 TTL 设置为 10 点加十分钟。这样活动结束后Key 还能保留一段时间方便对账和排查问题。除了库存 Key还需要设置一个已购买用户集合的 Key用 Redis 的 Set 类型存储已抢到的用户 ID。用于防重SADD seckill:sold:1001 9527顺便一提Key 的过期时间不要设得太短尤其在 Redis 内存不够时如果启用了内存淘汰策略不设过期时间或者 TTL 太长的 Key 可能在活动进行中就被 LRU 淘汰掉。当时我们排查过一个诡异问题库存明明没扣完却提示已售罄后来发现是库存 Key 因为内存淘汰被删除了。教训就是秒杀活动的 Key 要设置合理的 TTL并且监控 Redis 内存使用情况。3.3 前端倒计时与提前请求处理前端倒计时的作用不只是为了用户体验它还承载了一个关键任务对齐服务器时间。很多第一次做秒杀的同学会忽略这个细节直接用用户本地时间。但用户设备的时钟可能和服务器差个几秒甚至几分钟会导致有些用户提前点击有些用户延后点击。提前点击的请求到达服务器时活动还没开始按道理应该拒绝。但问题是有的服务端逻辑没有做活动时间校验结果真正活动开始前库存已经被抢掉一部分了。我们当时的处理是页面加载时通过一个时间接口校准偏差倒计时以服务器时间为准。接口层再校验一次当前时间是否在活动窗口内早于开始时间直接返回活动未开始。4. 核心环节Lua 脚本扣库存完整实现这一节是整个系统的核心说白了这个项目成败就看这段 Lua 脚本写得对不对、边界处理得全不全。我会把脚本拆开讲并完整给出可以直接用的版本。4.1 Lua 脚本完整代码先看完整脚本然后逐步解析-- KEYS[1]: 库存Key例如 seckill:stock:1001 -- KEYS[2]: 已购买用户Set的Key例如 seckill:sold:1001 -- KEYS[3]: 用户请求频率限制Key例如 seckill:limit:1001:9527 -- ARGV[1]: 用户ID -- ARGV[2]: 限制的请求次数窗口期内允许的请求次数 -- ARGV[3]: 限制的时间窗口大小秒 -- ARGV[4]: 活动ID预留用于日志记录 -- 1. 检查用户请求频率 local limit_key KEYS[3] local limit_count tonumber(ARGV[2]) local limit_window tonumber(ARGV[3]) local current redis.call(INCR, limit_key) if current 1 then redis.call(EXPIRE, limit_key, limit_window) end if current limit_count then return {errTOO_FAST, code-3} end -- 2. 检查用户是否已经购买过 local sold_key KEYS[2] local user_id ARGV[1] local is_sold redis.call(SISMEMBER, sold_key, user_id) if is_sold 1 then return {errALREADY_BOUGHT, code-2} end -- 3. 检查库存并扣减 local stock_key KEYS[1] local stock redis.call(GET, stock_key) if not stock then return {errNO_STOCK, code-1} end stock tonumber(stock) if stock 0 then return {errSOLD_OUT, code0} end -- 扣减库存 redis.call(DECR, stock_key) -- 记录用户已购买 redis.call(SADD, sold_key, user_id) -- 设置用户标记过期时间防止 Set 无限增大 redis.call(EXPIRE, sold_key, 86400) return {errSUCCESS, code1}这段脚本做好了几件关键事情限流判断利用 INCR 命令的原子性对每个用户在每个活动上的请求次数做统计超过窗口期内的阈值直接拒绝。防重复抢购使用 SISMEMBER 判断用户是否已经在已购集合中。库存原子扣减先检查库存是否大于 0再执行 DECR 扣减。整个脚本在 Redis 中是原子执行的不会出现并发超卖。事后清理策略给 Set 设置过期时间避免长期占用内存。4.2 Java 调用端代码示例Lua 脚本写好后Java 端通过 Spring Data Redis 调用完整代码如下Service public class SeckillService { Autowired private StringRedisTemplate redisTemplate; // 预热时保存的Lua脚本返回一个SHA值后续用SHA调用可以减少网络传输 private String scriptSha; PostConstruct public void init() { String script local limit_key KEYS[3] ... ; // 完整Lua脚本 scriptSha redisTemplate.execute( new DefaultRedisScript(script, String.class), Collections.emptyList() ); } public SeckillResult seckill(Long actId, Long skuId, Long userId) { String stockKey seckill:stock: actId : skuId; String soldKey seckill:sold: actId : skuId; String limitKey seckill:limit: actId : userId; DefaultRedisScriptList redisScript new DefaultRedisScript(); redisScript.setLocation(new ClassPathResource(seckill.lua)); redisScript.setResultType(List.class); ListString keys Arrays.asList(stockKey, soldKey, limitKey); Object[] args new Object[] { String.valueOf(userId), 5, // 窗口期内最多请求5次 2, // 窗口大小为2秒 actId.toString() }; ListLong result redisTemplate.execute(redisScript, keys, args); long code result.get(0); switch ((int) code) { case 1: // 发送MQ消息异步下单 sendOrderMessage(actId, skuId, userId); return SeckillResult.success(); case 0: return SeckillResult.error(已经售罄); case -1: return SeckillResult.error(库存不存在); case -2: return SeckillResult.error(你已抢购过请勿重复下单); case -3: return SeckillResult.error(请求频率过高); default: return SeckillResult.error(系统异常); } } }有几个实现细节值得注意使用ClassPathResource加载 Lua 文件便于维护和版本管理。Lua 脚本是文本直接硬编码在 Java 代码里不好看也不方便后续修改。放在 resources 目录下用纯文本文件管理更清晰。脚本执行结果要做类型转换。Lua 的 number 类型对应 Redis 返回的整数在 Java 端会被转成 Long。我用 List 接收取第一个元素就是状态码。Lua 脚本可以预加载。Spring 提供了DefaultRedisScript的脚本缓存机制执行过一次后后续调用会使用脚本的 SHA 标识减少网络传输的数据量。而且 Redis 本身对脚本有缓存机制推荐在项目启动时预加载。4.3 脚本边界情况与细节优化前面那版 Lua 脚本是能跑的状态但在生产环境里还有一些边界情况需要处理。我总结几个容易踩坑的点库存预热期间用户请求绕过如果活动开始了但预热还没完成用户请求进来GET 库存返回 nil脚本会走到if not stock分支返回库存不存在。这个返回对用户不友好应该返回活动尚未开始请稍后重试。多商品组合秒杀有些活动是主商品加赠品的组合一个用户抢购成功要同时扣减多个商品库存。这种情况需要在 Lua 脚本里循环扣减多个 Key并且任何一个 Key 库存不足时要回滚已经扣减的 Key。因为 Lua 脚本是原子的不存在中间状态被其他请求访问的情况你只需要在脚本内实现回滚逻辑即可。库存 Key 不存在时怎么区分未预热和售罄要解决这个问题预热时不应该只写入一个库存数值而是写入一个 Hash 或者一个包含状态的对象。比如用 Hash 存储{stock:100, status:1}status1 表示可售status0 表示活动结束。脚本中先判断 status 再判断 stock。库存扣减和库存查询分开在秒杀进行中用户会频繁刷新页面查看剩余库存。如果每次都通过 Lua 脚本扣减会白白浪费性能。这种场景应该直接 GET 库存 Key因为 Redis 的 GET 是 O(1) 操作性能极高不影响扣减的原子性。5. 防超卖与防刷的配套机制Lua 脚本解决了扣减库存的原子性问题但秒杀系统的安全性还需要防超卖兜底、防刷限流和风控机制的配合。这些细节决定了系统在极端情况下是否能保持稳定不会被异常的流量打垮。5.1 数据库层防超卖兜底Redis 层面的扣减虽然保证了不会超卖但既然最终订单要落库数据库层的库存字段还需要做一道兜底校验。这样做是为了防止 Redis 和数据库数据不一致时比如 Redis 被污染、脚本被误改、数据库库存被其他渠道订单占用出现超卖。数据库表设计示例CREATE TABLE seckill_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, act_id BIGINT NOT NULL COMMENT 活动ID, sku_id BIGINT NOT NULL COMMENT 商品ID, stock INT NOT NULL COMMENT 剩余库存, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_act_sku (act_id, sku_id) );插入订单前执行UPDATE seckill_stock SET stock stock - 1, version version 1 WHERE act_id ? AND sku_id ? AND stock 0 AND version ?;如果更新影响行数为 0说明库存不足或版本号冲突订单创建失败。这是数据库层的最后一道防线正常情况不会走到这里但一旦出现极端情况可以保住底线。5.2 请求频率限制与黑名单机制Lua 脚本里的限流逻辑是基础我建议在网关层再加一道独立的限流策略。这样做的原因是应用层 Lua 限流只是挡住了已到达 Redis的请求但如果 Nginx 层直接放进来大量无效请求应用服务器本身也会因为线程开销、请求解析等压力导致 CPU 升高影响整体服务稳定性。具体做法是在 Nginx 层加一层 IP User-Agent 维度的限流limit_req_zone $binary_remote_addr zoneseckill_limit:10m rate5r/s; location /api/seckill { limit_req zoneseckill_limit burst10 nodelay; proxy_pass http://backend; }这样单个 IP 每秒最多 5 个请求突发 10 个超过的直接由 Nginx 返回 503。很多机房出口是 NAT 的多个用户共用一个 IP所以阈值不能设得太严否则误伤正常用户。我们当时设置的阈值是每秒 10 次请求。对于识别出来的恶意请求 IP除了限流以外还可以加入 Redis 黑名单记录在 Set 类型的黑名单 Key 中网关层每次检查 IP 是否在黑名单集合中。黑名单的过期时间建议至少一个活动周期活动结束后可以清理。5.3 异步下单链路与消息队列的可靠性Lua 脚本扣库成功后返回成功但真正的订单创建是通过 MQ 异步完成的。这里有个关键问题要解决如果 MQ 消息丢失怎么办正常的流程是这样Lua 返回扣减成功应用层接收消息发送到 MQ订单服务消费消息创建订单如果第 2 步应用层突然宕机或者 MQ 暂时不可用导致消息没发出去那 Redis 库存已经扣了但用户没有订单。等于白白吞掉了用户的权益。解决这个问题有两个思路方案一本地消息表。在应用层数据库中创建一张消息表发送 MQ 之前先在本地记录一条消息记录状态为待发送。MQ 发送成功后更新状态。提供定时任务扫描超过 N 秒仍未发送成功的消息进行补发。方案二Redis 记录待处理任务。Lua 脚本扣库成功后同时将一条seckill:task:actId:skuId:userId记录写入 Redis 的 List 类型中。订单服务除了消费 MQ还定时从 List 中拉取待处理任务两套链路互为 backups。无论哪种方案核心原则是Redis 扣减与订单创建之间需要有可追踪、可重试的中间状态。这样才能保证用户抢到了就一定能下单成功。我当时用的是方案一因为团队对数据库事务更熟排查也方便。这里特别提一句定时任务补偿的间隔不要设置太长否则用户会投诉明明抢到了却一直看不到订单建议 10 秒内完成补偿。6. 压测过程与常见问题排查实录一个系统设计得再好没有经过压测验证上线心里都没底。秒杀系统尤其如此。压测阶段我们遇到了一堆匪夷所思的问题有些问题排查起来相当烧脑我把值得记录的几个整理了出来。6.1 压测环境准备与参数设计压测工具我们用的是 JMeter配合 InfluxDB 加 Grafana 做实时指标监控。压测前需要先准备一份秒杀商品的库存数据以及一批模拟用户数据。几个关键的压测参数参数设置值说明并发线程数1000模拟 1000 个用户同时抢购Ramp-Up 时间0 秒所有线程同时启动制造瞬时峰值循环次数3每个用户尝试 3 次请求商品库存100只有 100 件可抢因为秒杀场景的峰值就是活动开始的那一瞬间所以 Ramp-Up 必须设为 0。循环次数 3 是为了测试防重复机制是否生效——正常用户抢到一次后后续请求会被拒绝。同时要监控的指标包括Redis 的 QPS、命中率、内存使用量、慢查询、应用服务器的 CPU 和 GC 情况、数据库的连接数和活跃线程数。6.2 压测中碰到的典型问题问题一Redis 慢查询增加压测到 300 并发时Redis 慢查询日志突然出现了几条执行时间超过 100ms 的记录。排查后发现不是 Lua 脚本本身慢而是 Redis 服务所在的虚拟机 CPU 被其他容器的应用抢占导致命令执行被延后。慢查询日志记录的是从命令进入事件循环到执行完毕的时间包括排队时间。当时的解决办法是给 Redis 分配独占的 CPU 核心并且关闭了内存大页透明化避免内存分配触发阻塞。问题二库存扣减成功但用户收到系统繁忙压测过程中发现有少量用户抢到了商品但前端提示系统繁忙请重试。查日志发现Redis 扣减成功后应用层发送 MQ 消息时因为 RocketMQ 的 Broker 处理不过来send 方法超时抛异常导致整体请求返回失败。解决办法是对 MQ 发送做了异步化和重试优化同时增加了 Broker 的消费线程数。问题三已购买用户仍然能继续抢购压测时发现同一个用户 ID 在抢购成功后继续发送请求一部分请求竟然返回成功创建了多个订单。查了 Lua 脚本才发现问题出在参数传递类型上。Lua 脚本中ARGV[1]用户 ID 是作为字符串传入的而 Redis 的 SISMEMBER 命令期望参数类型一致。如果 Java 端传入的是 Long 类型而非 String 类型可能导致匹配不上的情况。修复方式是把所有参数统一转换为 String 类型再传递。问题四数据库连接池被打满即使做了 Redis 层拦截数据库中订单表的数据插入也会随着 QPS 增加而逐渐累积。当并发达到 800 以上数据库连接池开始出现等待。解决思路是消费端做批量插入优化把多条订单数据积攒后一次性 INSERT将插入性能提升了好几倍。另外订单表和库存表分库分表避免单表遇到瓶颈。6.3 压测数据与性能分析最终压测的结果比较理想1000 并发同时请求Redis 的 QPS 峰值达到 50000 左右平均响应时间 8ms99.9 分位的响应时间 25ms应用层接口的平均响应时间 15ms数据库的连接池使用率稳定在 60% 左右订单创建的延迟中位数约 300msMQ 消费链路没有出现超卖。这里想重点说明一个容易被忽视的指标99.9 分位的响应时间。平均值往往具有迷惑性如果 99.9% 的请求都能在 50ms 内完成即便平均值是 20ms也是健康的。但如果部分请求因为资源竞争被严重拖慢平均值看起来还行尾部延迟却不稳定一旦流量高峰过去系统也不会自愈。所以做秒杀压测一定要关注尾延迟指标。7. 项目上线后的监控与运维心得秒杀类项目上线才是开始。一次秒杀活动就那么几分钟但运维工作需要全天候监控。这里整理一些实际项目里比较重要的监控与运维实践经验。7.1 Redis 层面的监控指标Redis 的监控指标很多秒杀场景重点关注这三个内存使用量库存 Key 和用户集合 Key 在活动期间快速增长如果监控发现内存已用超过最大内存的 70%要及时清理不需要的 Key或者准备扩容。QPS 曲线活动开始时 QPS 会出现尖峰正常情况下应该短暂冲高然后回落。如果 QPS 一直处于高位不回落可能有人在进行刷请求攻击需要检查限流是否生效。慢查询日志设置阈值到 50ms持续记录慢查询明细方便事后复盘。7.2 活动结束后的数据一致性与对账秒杀活动结束后不能直接撒手不管还需要做对账。对账的目的有两个一是确认 Redis 扣减的库存总数和数据库实际订单数一致二是排查是否存在 Redis 扣减了但订单没创建成功的情况。对账逻辑可以是这样从 Redis 中获取已购用户集合的大小记为 soldTotal查询数据库中成功创建的秒杀订单数量记为 dbTotal两者的差值应为 0如果不为 0则说明有消息丢失触发补偿任务重新创建订单之前我就经历过一次线上事故MQ 集群抖动消息积压后部分消息被丢掉了最后对账发现 Redis 显示卖出 80 件数据库只有 76 条订单当场启动补单流程才避免了一场用户投诉。7.3 后续优化方向这个系统上线稳定运行后我们复盘了还可以继续优化的方向CDN 前置下单页面用户提交后先经过 CDN 的限速模块进一步降低源站压力。引入 Redis Cluster 集群将热点 Key 分散到多个分片节点避免单节点压力过大。实现活动级别的优雅降级当 Redis 负载达到阈值时自动启用排队模式用户提交后进入等待队列系统异步处理完成后通知结果。充分利用 Redis 的 Stream 数据结构来替代一部分 MQ 功能减少中间件依赖简化架构。8. 写在最后的一些经验总结从方案设计到压测上线整个秒杀系统做下来我最大的感受是秒杀系统的核心难点并不在于某个单一技术而在于如何把各个技术环节紧凑地衔接在一起形成一个完整的闭环。Redis 加 Lua 解决了原子扣减但如果没有库存预热、防刷限流、异步下单、数据库兜底和对账补偿系统依旧是不完备的。个人在实际操作中还有一个很重要的体会压测一定要尽早做不要等代码全写完了才去压。我们当时是在开发过程中就边写边压每完成一个模块就做一轮小型压测发现问题当场改。那种等到项目上线前才做全链路压测的做法很容易因为一个问题反复牵连多个模块定位起来非常耗时。另外安利一个小技巧Lua 脚本的调试不要一开始就在 Redis 生产环境里试。本地开发时可以先模拟 Redis 环境通过 redis-cli 结合 EVAL 命令反复调试脚本。调试时打开 Redis 的 monitor 模式可以清晰看到脚本执行时内部调用了哪些命令、命令顺序如何对排查诡异问题非常有帮助。如果这篇文章能帮你避掉几个我踩过的坑那这个复盘就算没白写。秒杀系统这个课题看着不大真做一遍收获远比想象中多。