尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

高并发故障排查实战:从缓存雪崩到库存扣减的修复全记录

高并发故障排查实战:从缓存雪崩到库存扣减的修复全记录 在电商、支付这类被称为千亿赛道的业务里技术人员最怕的不是需求排期而是凌晨突然收到一条监控告警。告警内容通常是“核心下单接口 5xx 比例超过 5%”紧接着是客服群里的截图、运营的连环电话以及工作群里的那句“是不是新版本上线导致的”。这就是一个技术团队最容易“社死”的时刻代码并没有在评审时暴露问题但线上流量会在高峰瞬间逼出所有隐藏缺陷错误率和用户投诉同时暴涨团队却还不知道该从哪里查起。这篇文章用一个接近真实的虚拟案例来做复盘某电商业务在促销开始时商品详情页和库存扣减接口同时出现严重故障最终结果是订单超卖、用户重复扣款、MySQL 连接池被打满。文章不会把事故归因成某个人操作失误而是把现象、排查链路、根因、修复、观测和复盘机制串成一套可复用的处理框架。适合承担核心接口开发、正在搭建高并发稳定性体系的读者也适合需要向团队解释“为什么事故复盘不是批斗会”的技术管理者。1. 从一次“社死”瞬间说起事故现象、影响范围和第一反应1.1 事故时间线错误不是一下子出现的很多人以为大流量故障是“突然崩了”实际情况是错误沿着入口、缓存、数据库、业务逻辑逐层传导每一层都需要几秒甚至几十秒被填满。下面这条时间线能说明问题时间现象00:00:00活动开始入口流量达到平时峰值 15 倍00:00:03Nginx 接入层开始出现超时错误率上升00:00:10Redis 热点缓存批量过期库存请求穿透到 MySQL00:00:30MySQL Threads_connected 达到上限慢查询堆积00:01:00订单服务抛出“无法获取数据库连接”异常00:05:00用户反馈“支付成功但订单不存在”从这条时间线可以看出真正的问题不是活动开始的第 1 秒出现的而是前 30 秒内几个条件同时叠加流量增加、缓存失效、数据库连接池耗尽。如果团队能早 20 秒发现问题影响范围会小很多。1.2 为什么会让团队“社死”事故发生后团队内部最常见的对话是开发 A“我本地测试是好的。”开发 B“订单服务日志没有报错。”运维 C“服务器 CPU 和内存都正常。”DBA 最后说“数据库连接池已经打满了慢查询正在堆积。”这种局面之所以称为“社死”不在于某一个人写错了某一行代码而在于整套系统在压力测试覆盖不足、缓存策略不合理、快速回滚预案缺失的情况下把所有基础组件的问题一次性暴露出来。更尴尬的是用户端看到的不是“系统繁忙”而是“扣款成功但订单不存在”。这种数据不一致问题会让客服、运营、技术三方同时进入高压状态。千亿赛道里的技术团队日常最容易忽略的恰恰不是业务复杂度而是基础链路的容量和异常分支。平时接口成功率 99.99%不代表连接池、缓存、线程池这些资源在极端流量下仍然够用。1.3 第一时间要做的三件事止损、留证、同步故障刚发生时最忌讳的是所有人一起看代码、一起改配置。第一反应应该是三件事。第一止损。如果流量入口有开关可以先把非核心流量切走如果新版本刚上线确认是否与本次发布强相关准备回滚。此时不要急着找根因先把爆炸半径控制住。第二保留现场。对应用进程执行线程 dump对数据库执行SHOW PROCESSLIST保存 Nginx 访问日志和业务日志。这些现场信息一旦因为应用重启而丢失事后复盘就只能靠猜测。第三同步。在值班群内按固定格式同步当前影响范围、处理动作、需要谁支持。同步的核心价值是避免产品、运营、客服反复来问“修好了没有”让技术团队有时间集中排查。检查点确认是否已经切掉非核心流量、是否保留了线程和连接池现场、是否有人专职记录时间线。2. 排查链路从接入层到数据库逐步缩小爆炸半径2.1 先看接入层流量到底有多大排障的第一步永远是确认入口流量而不是直接打开代码看逻辑。先统计 Nginx 访问日志中的状态码分布tail -n 10000 /var/log/nginx/access.log \ | awk {print $9} \ | sort | uniq -c | sort -rn | head -n 20这条命令统计最近 10000 条请求的 HTTP 状态码数量。如果大量出现 499 或 502通常是后端服务处理超时Nginx 主动断开连接如果 200 很多但业务仍然失败说明入口层正常问题在应用层或下游依赖。接下来要关注请求来源和 URL 分布。秒杀场景下热点集中在某个商品详情页或某个下单接口那么日志中一定会有大量相同的请求路径。如果请求来源非常分散需要警惕是否被人为刷量或爬虫命中。接入层排查的检查点是入口 QPS 是否符合活动预期错误集中在哪个路径错误状态码是什么。2.2 再看应用进程线程、GC、异常关键字接入层确认没有异常路由后进入应用层排查。对于 Java 应用可以先看进程状态和线程占用# 查看 Java 进程 PID jps -l # 查看进程内线程 CPU 占用 top -Hp pid # 导出线程栈间隔10秒导出两份对比 jstack -l pid /tmp/jstack1.txt sleep 10 jstack -l pid /tmp/jstack2.txt线程栈要间隔 10 秒导出两份是为了对比线程状态变化。如果大量线程一直处于 BLOCKED 或 WAITING 状态并且堆栈集中在HikariCP.getConnection、JedisConnection、FeignClient等调用上基本可以判断应用线程在等待下游资源。还要配合 GC 日志查看内存压力# 如果应用开启了 GC 日志 grep Full GC /path/to/gc.log | tail -n 50Full GC 频繁会导致应用线程长时间停顿对外表现就是接口变慢、超时率上升。此时重点不是继续改代码而是确认是内存泄漏、对象分配过大还是缓存数据量异常。应用日志也不可忽略grep -i Exception /path/to/app.log | tail -n 100出现Connection is not available, request timed out说明数据库连接池被耗尽。出现JedisConnectionException说明 Redis 连接异常。不同异常关键字会直接指向不同的依赖组件。这里有一个常见坑不要一看日志有异常就重启应用。重启确实能临时恢复但线程栈、连接池状态、GC 现场全部丢失等于把破案线索全部销毁。优先保留现场再考虑重启。2.3 缓存和数据库穿透、击穿、雪崩一次看全很多高并发故障不是数据库本身性能差而是缓存策略失效后请求全部落到数据库。在排查时要分清三种情况名称场景典型现象防护方向缓存穿透查询一个不存在的 key大量请求直接打 DB空值缓存、布隆过滤器缓存击穿单个热点 key 过期瞬间并发打到 DB互斥锁、逻辑过期缓存雪崩大量 key 同一时间过期DB 被集中打满TTL 随机、多级缓存本次事故中活动商品库存的 Redis key 过期时间设置为固定的 10 秒活动开始前大量请求同时访问缓存同时失效从而引发缓存雪崩加击穿的混合问题。Redis 侧检查命令# 查看本次连接的命中率和瞬时命令处理情况 redis-cli info stats # 找出大 key redis-cli --bigkeys # 短期观察命令执行情况只建议低峰使用 redis-cli monitorMySQL 侧检查命令-- 查看当前正在执行的 SQL SHOW PROCESSLIST; -- 查看当前连接数是否接近上限 SHOW GLOBAL STATUS LIKE Threads_connected;如果SHOW PROCESSLIST中出现大量相同形式的UPDATE stock SET stock stock - ? WHERE sku_id ?并且 State 是updating或statistics说明库存单行数据成为热点后续请求全部在等待行锁。此时已经接近根因。2.4 一套可复用的排障命令清单实际排障时应按层推进每层找完再进入下一层避免所有人扎堆查代码。下表是可以在服务端快速执行的命令清单链路层常用命令关注指标判断线索接入层tail -f /var/log/nginx/access.log状态码、QPS、请求路径499/502 增多说明后端超时应用层top -Hp pidjstack -l pid线程状态、CPU 占用大量 BLOCKED等待连接池应用层grep -i Exception app.log异常类型、堆栈连接池耗尽、Redis 超时Redisredis-cli info statshit_rate、connected_clients命中率下降连接数上涨MySQLSHOW PROCESSLIST;执行中 SQL、State大量相同 SQL 等待行锁MySQLSHOW GLOBAL STATUS LIKE Threads_connected;当前连接数达到 max_connections排障时要牢记顺序先看入口流量再看应用线程再查缓存和数据库最后才看业务代码。大多数时候代码逻辑没有变是资源水位被打满了。3. 根因还原库存扣减为什么会被流量打到“社死”3.1 原始代码最大的问题是“先查再改”事故里最常见的库存扣减代码类似下面这样Transactional public boolean deductStock(Long skuId, Integer count) { // 先从 Redis 读取库存 Integer stock (Integer) redisTemplate.opsForValue().get(stock: skuId); if (stock null) { // 缓存失效回源数据库 stock stockMapper.selectStock(skuId); redisTemplate.opsForValue().set(stock: skuId, stock, 10, TimeUnit.SECONDS); } if (stock count) { return false; } // 更新数据库库存这一行在秒杀条件下会成为热点 int rows stockMapper.deduct(skuId, count); if (rows 0) { return false; } // 更新缓存 redisTemplate.opsForValue().decrement(stock: skuId, count); return true; }单看功能这段代码能满足“库存足够则扣减”的要求。但在高并发下暴露了多个问题。第一先读 Redis 再读数据库组合操作不是原子性的。多个请求同时读到同一个库存值时都会认为自己可以扣减。第二stockMapper.deduct如果是一条UPDATE stock SET stock stock - count WHERE sku_id ?那么同一行记录会被行锁串行化。库存服务的并发能力取决于数据库单行行锁的处理速度。第三Redis 缓存 TTL 固定 10 秒所有活动商品缓存同时失效缓存雪崩直接打穿数据库。第四接口没有幂等。用户点击下单、前端超时重试、网关重试会导致同一个用户或同一个订单号被多次扣减。3.2 多因素叠加不是一行代码写错这次事故不能归因于某个人的失误而是多个设计决策叠加后的必然结果缓存 TTL 没有加随机值导致热点 key 同时过期。库存是单行热点数据库行锁成为天然瓶颈。应用连接池配置偏小数据库连接被打满后线程全部阻塞在取连接阶段。前端超时后自动重试重试请求没有业务幂等进一步放大了库存扣减量。上线前没有执行匹配活动流量的压测默认值一直沿用到生产。单独看每个问题都不会立刻致命。但活动流量把资源水位推到临界点后任何一个环节失效都会让链路整体崩溃。这就是“社死”时刻的技术本质。3.3 关键参数为什么踩坑很多团队在配置连接池、线程池、超时时没有明确依据长期使用框架默认值。不同组件在极端流量下的表现如下配置项常见初始值问题建议方向HikariCP maximum-pool-size10单机 10 个连接无法承载秒杀通过压测确认通常可设在 50~100同时评估数据库最大连接数Tomcat max-threads200 左右线程等待数据库连接造成队列堆积结合连接池容量和下游依赖吞吐调整Redis TTL固定 10 秒同时过期导致雪崩基础 TTL 随机值例如 180 秒加上 30~60 秒随机偏移MySQL innodb_lock_wait_timeout50 秒锁等待耗时过长线程长期占用连接核心写事务控制在几十毫秒内超时立即失败HTTP 客户端 timeout无或过长上游重试雪上加霜设置连接超时和读超时例如 2~5 秒这些参数没有一个万能答案。正确做法是每个环境压测后记录一组基线再根据基线上调或下调。生产环境的任何数值都应该能回答“为什么是这个值”。3.4 回滚为什么没有一次成功事故发生后的 20 分钟内团队尝试回滚新版本但并没有快速生效。原因主要有四个。第一发布批次只有一个回滚等于全量替换无法做到灰度切换。如果保留上一个版本且支持分批发布回滚可以在 2 分钟内完成。第二本次发布包含数据库变更新增了一张扣减流水表。旧版本代码不会写入新表但对新表已有依赖的定时任务仍然在运行回滚后出现数据不一致。第三团队在“是否回滚”上决策过慢。有人担心回滚会丢失新功能有人想先尝试重新推送配置。高并发故障状态下每一分钟都在扩大影响。第四回滚后没有做数据校验。已经产生的超卖订单、重复扣款仍然存在需要专门的修复任务处理。正确的回滚原则是先回滚到稳定的上一个版本再讨论新功能是否重新上线。回滚前确认数据库变更是否兼容回滚后执行数据核对脚本确认没有脏数据残留。4. 修复与加固从临时止血到容量治理4.1 临时止血限流、熔断、降级一起上修复不是把代码改成“看起来正确”而是先恢复服务再治理容量。在事故恢复阶段第一优先级是限制进入核心链路的请求量。如果使用 Sentinel可以在控制台或配置中心下发限流规则flow-rules: - resource: POST:/order/submit grade: 1 count: 2000 controlBehavior: 2这里grade: 1表示按 QPS 限流count: 2000表示每秒最多放行 2000 个请求controlBehavior: 2表示超出后快速失败而不是排队等待。快速失败比让请求长时间阻塞更合理因为下单请求失败后用户还能重新提交而排队请求会同时占住线程和连接池。库存查询可以降级。当 Redis 不可用或缓存未命中时不再实时回源数据库而是返回一个保守的库存值真正扣减时继续以数据库为准。这种降级会损失一定的展示准确性但能保护数据库不被击穿。熔断针对下游依赖。当下单成功后调用支付确认接口的错误率超过阈值应快速失败避免下游已经故障的节点拖垮上游。4.2 库存扣减的正确实现用 Redis Lua 预扣再做异步落库对于秒杀和促销场景推荐“Redis Lua 预扣 异步数据库落库”的组合。先让 Redis 对单 key 的操作变成原子操作避免并发扣减超卖if redis.call(exists, KEYS[1]) 1 then local stock tonumber(redis.call(get, KEYS[1])) local num tonumber(ARGV[1]) if stock num then redis.call(decrby, KEYS[1], num) return 1 end return 0 end return -1这个脚本的含义是如果 key 不存在返回 -1表示库存初始化失败如果库存不足返回 0如果扣减成功返回 1。因为 Redis 的 Lua 脚本在单实例上是原子执行的所以不存在多个请求同时读到旧库存的问题。数据库落库可以使用条件更新UPDATE stock SET stock stock - #{count} WHERE sku_id #{skuId} AND stock #{count};这里的AND stock #{count}是关键它保证数据库层也不会透支。即使 Redis 预扣成功数据库更新失败仍然可以通过异步任务把 Redis 中的预扣值回滚掉。方案选型要结合实际场景方案优点缺点适用场景纯 DB 条件更新简单可靠无中间状态热点行锁吞吐有限普通商品下单低并发Redis Lua 异步 DB吞吐高Redis 原子操作需要补偿和幂等秒杀、促销、热点库存消息队列串行扣减并发完全串行不易超卖吞吐受队列限制架构复杂银行、钱包等高一致性场景生产环境不能只依赖一种方案。核心原则是Redis 负责流量削峰数据库负责最终一致中间要有幂等和对账。4.3 接口幂等与分布式锁“支付成功但订单不存在”通常不是库存扣减本身的问题而是下单接口被重复调用后订单状态未正确关联。需要在下单入口处增加幂等机制。最简单可靠的方式是使用requestId作为唯一键在扣减流水表中约束唯一性CREATE TABLE stock_deduct_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, sku_id BIGINT NOT NULL, user_id BIGINT NOT NULL, deduct_count INT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_request_id (request_id) );每次下单请求都带一个requestId在事务里先插入扣减流水。如果插入时发生唯一键冲突说明是重复请求直接返回上一次处理结果不再执行扣减。这种方式比分布式锁更轻量也更适合扣减这种高频操作。分布式锁适合保护跨多个资源的操作例如“先扣库存再创建订单再调用支付”这整套动作。但锁时长、锁粒度、异常释放都要仔细设计否则锁本身可能成为新的瓶颈。4.4 连接池、超时和容量基线修复阶段还需要把不合理的默认配置统一调整。下面是一份经过压测后可以继续微调的 YAML 示例spring: datasource: hikari: maximum-pool-size: 60 minimum-idle: 20 connection-timeout: 3000 validation-timeout: 1000 max-lifetime: 1800000 redis: timeout: 2s server: tomcat: max-threads: 300 accept-count: 200 connection-timeout: 5000要注意maximum-pool-size: 60不是越大越好。连接池大小超过数据库实际处理能力后增加连接反而会让数据库 CPU 和内存压力更大。需要先用压测确认数据库能承受的最大并发连接数再反推应用单实例连接池大小。压测命令可以用 wrkwrk -t8 -c200 -d60s --latency http://localhost:8080/order/submit参数含义8 个线程200 个并发连接持续 60 秒输出延迟分布。每次压测要记录四类数据指标学习环境生产环境建议QPS只要能跑通逻辑即可接近目标值并预留 30% 冗余P99 延迟不严格低于网关超时时间错误率0%0%允许短暂抖动但不持续连接池占用观察是否正常不超过最大值的 70%区分学习环境和生产环境很重要。本地环境验证的是逻辑正确性生产环境验证的是容量和稳定性。不要用本地 10 个并发的结果去推导生产 10000 并发下的表现。5. 可观测与告警把“社死”变成可预警5.1 四大黄金指标没有可观测性团队只能等用户投诉才知道系统出问题。高并发系统至少需要关注四个黄金指标。指标定义故障中典型表现延迟接口响应时间重点关注 P99P99 从 100ms 涨到 5s流量QPS、TPS、在线人数出现峰值但容量不足错误5xx、RPC 失败、业务异常下单错误率快速上升饱和线程池、连接池、 CPU、内存使用率连接池 100%、线程全部 BLOCKED这四个指标要同时看。只看 CPU 正常没意义因为线程可能全部阻塞在数据库连接获取上只看错误率也不行因为错误率是结果流量和饱和才能告诉我们资源是否见底。5.2 告警分级与收敛告警不能不分级别。如果所有指标超过阈值都发同一条消息值班人员的注意力会被严重稀释。建议按影响范围分级级别示例规则响应要求P0下单错误率超过 5% 持续 2 分钟立即拉群最高优先级处理P0Hikari 连接池使用率超过 80% 持续 1 分钟立即排查数据库容量P1Redis 命中率低于 90% 持续 5 分钟关注缓存策略进入处理流程P2单个实例内存使用率超过 85%评估是否扩容或优化告警收敛的核心是按“服务”和“错误类型”聚合而不是按“实例”重复发送。100 个实例同一时间上报同一个错误如果全量推送值班手机就会被打爆。合理做法是聚合后发送一条消息附带受影响实例数和代表日志。5.3 复盘文档与事件时间线模板事故恢复后 48 小时内应完成复盘。复盘文档不是用来追责而是为了让下一次同类问题更快被发现。建议固定以下结构# 故障复盘 ## 一、事件概况 - 发生时间 - 恢复时间 - 影响范围 - 用户影响 ## 二、时间线 - 00:00 告警触发 - 00:03 停止非核心流量 - 00:15 定位到缓存雪崩 - 00:30 流量恢复 ## 三、根因分析 - 直接原因 - 深层原因 - 为什么没有被监控发现 ## 四、处理动作 - 临时止血 - 代码修复 - 数据修复 ## 五、后续动作 - 指标优化 - 告警补充 - 压测计划 - 责任人时间线必须精确到分钟。很多团队复盘时说不清“为什么花了 20 分钟才恢复”就是因为故障期间没有人记录时间线。可以指定值班同学在故障群内每人发一条时间同步事后直接提取。5.4 发布前检查清单生产事故往往和新版本发布相关。以下清单可以作为发布前置门槛[ ] 变更点是否涉及核心链路下单、支付、库存。[ ] 是否进行过容量评估或压测验证。[ ] 缓存 TTL 是否随机化热点 key 是否有保护。[ ] 是否支持灰度发布和分批回滚。[ ] 数据库变更是否向前兼容。[ ] 是否补充了相关监控和告警规则。[ ] 是否有操作手册操作失败后如何回退。[ ] 是否通知了值班组、客服组和运营侧。这份清单不是走形式。每一项都应该对应具体的检查结果和负责人。无法回答的项目视为上线风险。6. 经验总结千亿赛道不怕事故怕没有事故处理机制6.1 成熟的团队如何面对 P0成熟的团队在 P0 事故中通常不是靠“个人英雄主义”解决问题而是依靠一套稳定机制。首先是先恢复再定位。不要追求在故障状态下把根因完全分析清楚先切流量、降级、回滚让系统恢复可用再冷静分析根因。故障现场的技术问题千差万别但恢复动作是可以预先准备的。其次是专人指挥。值班经理负责决策技术同学负责执行另一位同学负责记录时间线。所有人不要同时改配置、同时发命令否则很容易制造新的故障。最后是事后复盘不追责。复盘的目标是找到系统性缺口而不是让某个人承认错误。如果团队因为害怕追责而隐瞒细节下次事故仍然会以相同方式发生。6.2 最小可落地的改进动作不是每个团队都有条件立即搭建完整的混沌工程平台但可以从以下动作开始把发布前检查清单做成发布流水线中的硬性门禁。为每个核心接口至少准备一个降级开关。每次大促前执行一次小时级压测记录 QPS、P99、错误率、连接池占用四类基线。建立告警分级P0 必须能直接触达值班人手机。保留上一个稳定版本的可运行镜像至少在发布后保留两个版本确保可以快速回滚。这些动作成本不高但对高速增长的业务来说是避免下一次“社死”的核心保障。6.3 给新人的三条建议第一遇到故障不要先清理日志或重启服务。保留线程栈、保留现场、保留原始日志比立刻恢复系统更有利于后续定位。即使必须重启也要先确认是否已经抓到足够的证据。第二不要只改代码不追链路。一个接口的稳定性取决于它依赖的所有组件。要习惯画调用链接入层、应用线程、连接池、Redis、数据库、下游 RPC。哪个环节都可能成为瓶颈。第三理解默认配置而不是直接使用默认值。Spring Boot 和中间件提供的默认参数只适合开发和简单部署。高并发环境下线程池大小、连接池上限、超时时间、缓存 TTL 都需要根据压测结果显式配置。那个凌晨的“社死”时刻后来变成了团队最值钱的一次训练。真实的高并发系统不是写出一段正确代码就结束而是要从一次故障里把限流、缓存、数据库、连接池、监控、回滚这些环节全部串起来。下次再出现告警时团队的反应不再是互相问“谁改了什么”而是有人执行止损有人记录时间线有人沿入口逐步排查。能把一次“社死”复盘成机制才算真正在千亿赛道里站稳了。
返回列表