资讯详情

资讯详情

秒杀系统架构设计:高并发、防超卖与数据一致性实践

秒杀这个场景我在几个模拟项目里反复折腾过很多轮从最早的数据库扛一切到后来的Redis 扣减 MQ 落库中间踩的坑比写过的代码还多。秒杀系统架构设计这件事说穿了就是三件事的平衡高并发下系统不能塌、防超卖上库存不能错、数据一致性上账不能乱。三者互相拉扯——为了抗高并发把库存全放内存一致性就变难为了强一致每次都落库并发又扛不住。这篇不聊虚的就把这条链路上每一层为什么这么设计、参数怎么定、代码怎么写、哪里容易翻车一次讲透。适合已经写过 CRUD、想搞清楚流量洪峰到底怎么接住的后端同学也适合做技术方案评审前想快速补齐知识面的朋友。1. 秒杀的真正难点不在高并发三个字而在流量形态很多人一听秒杀就条件反射地想到QPS 高然后开始堆机器、加缓存、上消息队列。方向没错但只盯着 QPS 数字往往会做出一个平时压测很漂亮、大促当天第一秒就崩的系统。问题出在秒杀流量的形态和普通业务流量完全不是一回事用处理平峰流量的思路去接洪峰等于用桶去接瀑布。1.1 瞬时洪峰与平峰请求的本质差异普通电商的请求是均匀铺开的一天几百万 PV摊到每秒也就几十上百系统有充足的缓冲时间做重试、做降级、做扩缩容。秒杀不一样它把几乎全部流量压缩到开抢后的 1 到 3 秒内。一个 1 万件库存的活动可能有 50 万人同时点立即抢购这 50 万请求里 99% 注定失败但它们依然会真实地打到你的系统上。这个差别带来的直接后果是系统不是持续过载而是瞬间被打穿。持续过载你可以靠弹性扩容慢慢消化瞬间打穿没有给你反应时间——连接池在第 200 毫秒就满了线程池队列在第 500 毫秒就堆积到上限数据库连接数在第 1 秒就爆了然后所有请求开始超时超时又触发重试重试把流量再放大一倍。这就是典型的雪崩而且是在你监控告警还没响起来的时候就已经发生了。所以秒杀架构的第一个设计原则不是提升处理能力而是减少进入核心链路的请求数量。这个思路必须先立住后面所有的限流、缓存、异步本质都是在做减法。1.2 普通 CRUD 架构在秒杀场景下的三处断裂点我把常见的断裂点归成三类做过压测的同学应该都眼熟。第一处是数据库行锁争用。update stock set count count - 1 where id ? and count 0这条语句本身没问题逻辑上也防了超卖但当几千个事务同时争抢同一行的写锁时InnoDB 的行锁会迅速演变成排队后面的请求全部阻塞在锁等待上innodb_lock_wait_timeout一到就报错。行锁争用不是慢是卡死。第二处是应用层连接与线程耗尽。Tomcat 默认 200 个工作线程数据库连接池常见配置 20 到 50 个。当请求进来的速度远超处理速度线程全被占住新请求在 accept 队列里排队队满了直接拒绝。这时候你去看 CPU可能才 30%但服务已经不可用了——瓶颈不在计算在等待。第三处是热点数据击穿缓存。活动开始前缓存被清空或者过期第一个请求打到数据库后面几千个请求同时发现缓存没有一起打到数据库这就是缓存击穿。秒杀场景下因为所有请求都集中在同一个 key 上击穿的概率被无限放大。下面这张表是我自己整理的三处断裂点与对应策略的对照评审方案时可以直接拿去用断裂点具体表现根因对应策略数据库行锁争用锁等待超时、事务堆积单行热点写入库存前置到 Redis异步落库连接与线程耗尽CPU 不高但服务无响应请求速度超处理速度网关限流、请求排队、快速失败缓存击穿瞬时大量请求直达 DB热点 key 失效预热、逻辑过期、互斥重建1.3 先把指标算清楚QPS、库存量、超卖容忍度动手设计前有几个数必须先落到纸面上否则后面所有的容量规划都是拍脑袋。峰值 QPS 估算用预计参与人数 × 峰值集中系数 ÷ 峰值持续秒数来估。假设某模拟活动预计 30 万人参与其中 80% 会在开抢后 2 秒内点击那就是 24 万请求挤在 2 秒内平均 12 万 QPS考虑到点击本身有抖动实际瞬时峰值要按 1.5 倍预留也就是 18 万 QPS 的容量目标。单机承载能力一台 4 核 8G 的应用节点纯转发型接口读缓存、做判断大概能扛 3000 到 8000 QPS具体看序列化方式和日志量。带 Redis 交互的接口要打对折。这些数字必须自己压出来别信任何参考值。超卖容忍度这个词听起来奇怪但它决定架构复杂度。绝大多数商品业务要求零超卖那就必须有一道强一致的兜底但对某些虚拟商品或者可候补的场景少卖比超卖更可怕这时候策略要反过来。先明确业务能接受哪种错误再谈技术方案这是我做过最多次返工的地方。还有一个容易被忽略的指标是库存量级。100 件库存和 10 万件库存架构选择完全不同。库存很少的时候热点极度集中几乎所有请求都会失败此时更该做的是提前拦截,比如用令牌或者抽签的方式先筛一遍库存很多的时候反而是成功请求多落库压力大要重点考虑异步写和批量提交。2. 流量漏斗把九成请求挡在核心链路之外把秒杀链路想象成一个漏斗最上面是用户点击最下面是数据库落单。每一层都要尽可能多地漏掉请求而且是越靠前漏掉越好——在 CDN 漏掉一个请求成本几乎为零在数据库层漏掉一个请求可能已经消耗了几十个连接和锁。这一章讲的就是漏斗上面几层怎么设计。2.1 页面静态化与 CDN 前置秒杀页面的特点是访问量极大、内容几乎不变、只有是否开始剩余数量这类字段会变。这种页面天生适合静态化。把商品详情、活动规则这些不变的部分做成静态 HTML 推到 CDN用户请求直接在边缘节点返回根本不进你的机房。真正要回源的只有两个接口活动状态查询和下单。活动状态查询可以做成一个极轻量的接口返回结构就几个字段甚至可以在静态页面里内嵌一个倒计时前端本地算时间到点了才发起请求这样连状态查询都能省掉一大半。这里有个细节很多人会漏按钮的禁用状态必须由服务端的时间为准但前端要提前预判。如果完全等前端倒计时到点才允许点击那么各端时钟偏差会导致请求提前或延后几秒集中打进来反而形成二次洪峰。比较稳的做法是服务端返回一个可点击时间戳前端用本地时间差做补偿同时服务端在开抢前 100 毫秒内收到的请求统一做过早请求快速拒绝不做任何业务处理。CDN 配置上还有两个实际会踩的点。一是缓存 key 要包含活动 ID 和版本号活动配置变更时通过版本号刷新缓存否则改了价格用户看到的还是旧的。二是回源带宽要单独评估静态资源被缓存后回源量很小但下单接口是不缓存的这部分流量会百分之百回源评估机房入口带宽时必须把它算进去。2.2 网关层的限流与排队请求进到机房后第一道闸门是网关。网关层要做的事情很明确按用户维度限流、按接口维度限流、超出的请求快速失败。按用户维度限流是为了防刷。同一个用户 ID 在 1 秒内发起超过 5 次下单请求后面的一律拒绝并打标记正常用户不会这么点。这里要注意别用 IP 做维度移动网络下大量用户共享出口 IP按 IP 限流会误伤一大片。按接口维度限流是为了保护后端。给下单接口配一个略高于预估峰值的阈值比如预估 12 万 QPS阈值配到 15 万超过的直接返回当前人数过多请稍后再试。这个阈值不能配得太保守否则正常用户也被拦也不能配太高否则限流形同虚设。关于排队还是拒绝这是个需要想清楚的选择。排队看起来体验更好用户看到正在排队第 3821 位但实际上排队会带来两个问题一是排队本身要占用连接和内存队列越长网关本身的资源消耗越大二是队列里的请求最终还是要在后端处理只是把洪峰往后平移了几秒并没有真正削峰。我的经验是只在活动刚开始的前两秒做短队列排队队列长度限制在几千超过就立即拒绝。绝大多数用户能接受没抢到但不能接受页面卡住不动三十秒。网关限流的实现用令牌桶或者滑动窗口都可以。滑动窗口更精确但内存占用大令牌桶实现简单允许一定的突发。秒杀场景我倾向于用滑动窗口因为突发正是我们要抑制的东西。伪代码大概是这样// 基于 Redis 的滑动窗口限流窗口 1 秒阈值 150000 public boolean tryAcquire(String key, int limit, long windowMs) { long now System.currentTimeMillis(); String member now - ThreadLocalRandom.current().nextInt(1000000); // 移除窗口外的记录 redis.zremrangeByScore(key, 0, now - windowMs); Long count redis.zcard(key); if (count ! null count limit) { return false; } redis.zadd(key, now, member); redis.expire(key, windowMs / 1000 1); return true; }这段代码在生产环境要注意两个问题一是zcard和zadd不是原子的高并发下会有少量超额放行可以接受二是每次请求都要操作 Redis网关到 Redis 的延迟会直接加到用户请求上所以这个 key 要放在本地缓存加定期同步或者用 Redis 的 Lua 脚本合并成一次调用。2.3 缓存预热与热点隔离过了网关请求进入应用层。这一层做的第一件事是查库存够不够这个查询必须走缓存而且要保证缓存里一定有数据。预热的时间点很关键。太早了可能被其他操作覆盖或者过期太晚了第一个请求就是冷启动。我的做法是活动开始前 10 分钟做一次全量预热前 1 分钟再做一次校验性预热两次都通过一个独立的管理接口触发接口返回预热成功的 key 数量和值人工确认后再放开流量。预热的内容不只是库存数量还包括活动配置限购数、开始结束时间、商品基础信息、用户资格标记如果有会员门槛。把这些都塞进一个 Hash 结构一次hgetall全部拿到能省掉好几次网络往返。热点隔离是另一个容易被忽略的点。如果同一台机器上既有秒杀活动的缓存又有普通商品的缓存秒杀把 Redis 连接打满普通业务也跟着遭殃。解决方案是给秒杀单独分配一组 Redis 实例物理隔离成本会增加但对于大促这种不能出事的场景这笔钱值得花。如果没有条件做物理隔离至少要做连接池隔离——秒杀用的连接池和普通业务用的连接池分开配置互不影响。3. 防超卖的关键库存扣减放在哪一层这是整个秒杀系统里讨论最多、争议最大的一块。方案大致三种纯数据库扣减、Redis 扣减 异步落库、Redis 扣减 同步落库。三种方案的差别不在能不能防超卖而在扛不扛得住和错了怎么补。3.1 数据库直接扣减为什么扛不住先把最朴素的方案摆出来它的正确性没有问题UPDATE seckill_stock SET available available - 1, version version 1 WHERE sku_id #{skuId} AND available 0 AND version #{version};available 0这个条件保证了不会扣成负数影响行数为 0 就代表扣减失败。这条语句在单机、低并发下完美无缺问题出在并发上。InnoDB 对同一行的更新是要加排他锁的事务提交前其他事务全部阻塞。假设单次更新加事务提交耗时 2 毫秒那么单行每秒最多串行处理 500 次。要扛 12 万 QPS理论上需要 240 个并发事务在跑——但锁是互斥的并发度再高也是排队实际吞吐就是 500 TPS 左右。这还没算网络往返、连接池等待、GC 停顿。所以数据库扣减不是不能用而是只能用在库存很少、参与人数不多的普通活动上。库存 100 件、几千人参与数据库直接扣完全够用别为了秒杀而秒杀过度设计同样是一种浪费。3.2 Redis 原子扣减与 Lua 脚本的实现细节当库存和参与量都上去了就必须把扣减动作前置到 Redis。原因很简单Redis 单线程执行命令天然串行单实例能扛 10 万级 QPS而且内存操作没有锁等待。核心逻辑用 Lua 脚本实现保证判断库存 扣减 记录用户这三步的原子性-- KEYS[1] 库存 key, KEYS[2] 用户购买记录 key -- ARGV[1] 用户ID, ARGV[2] 购买数量, ARGV[3] 限购数 local stock tonumber(redis.call(HGET, KEYS[1], available)) if stock nil then return -1 -- 库存未初始化 end local bought tonumber(redis.call(HGET, KEYS[2], ARGV[1]) or 0) if bought tonumber(ARGV[2]) tonumber(ARGV[3]) then return -2 -- 超过限购 end if stock tonumber(ARGV[2]) then return 0 -- 库存不足 end redis.call(HINCRBY, KEYS[1], available, -tonumber(ARGV[2])) redis.call(HINCRBY, KEYS[2], ARGV[1], tonumber(ARGV[2])) return 1 -- 扣减成功这段脚本有几处必须注意的地方我在不同项目里都遇到过。第一返回值语义要设计清楚。上面用了 -1、-2、0、1 四个返回值分别对应库存未初始化超限购库存不足成功。如果只返回成功失败日志里就没法区分是库存没了还是用户超买了排查问题时两眼一抹黑。返回值语义定好之后应用层要对每种情况给不同的用户提示超限购和库存不足的文案是不一样的。第二Lua 脚本要尽量短。Redis 执行 Lua 是阻塞的脚本越长阻塞越久。这个脚本里只有几次哈希操作执行时间是微秒级没问题。但如果有人在里面加了循环、加了大量 key 遍历就会把整个 Redis 实例卡住。我见过一个脚本里遍历了整个用户列表做校验结果单次执行 20 毫秒整个实例的 QPS 直接掉到 50。第三key 要用 Hash 而不是 String。库存和已售数量放在同一个 Hash 里一次读取就能拿到全部信息比两个 String 少一次往返。而且 Hash 在字段不多的时候用的是紧凑编码内存效率也更好。第四别忘了设置过期时间。活动结束后的这些 key 如果不清会一直占内存。但过期时间不能设得太短至少要比活动结束时间晚几天留出对账和补数据的窗口。3.3 数据库兜底乐观锁、唯一索引与库存流水Redis 扣减解决了性能问题但它引入了一个新问题Redis 是内存可能丢数据也可能因为网络分区和数据库不一致。所以数据库这一层必须保留兜底而且兜底逻辑要能独立保证正确性。兜底有两道防线。第一道是唯一索引防重复下单。建一张订单表在(user_id, activity_id)上建唯一索引。用户重复下单时数据库直接报唯一键冲突应用层捕获这个异常把 Redis 里多扣的库存加回去。这一道防的是同一个用户下了多单和库存扣减的正确性无关但同样重要。第二道是库存流水的最终校验。每次成功下单都往stock_log表插一条记录字段包括 sku_id、数量、订单号、时间。活动结束后跑一个校验任务sum(stock_log.quantity)必须等于初始库存 - 剩余库存。对不上的话说明中间有丢单或者重复扣减需要人工介入。-- 校验 SQL流水总和应等于已售数量 SELECT s.sku_id, s.total - s.available AS sold_in_db, COALESCE(SUM(l.quantity), 0) AS sold_in_log FROM seckill_stock s LEFT JOIN stock_log l ON s.sku_id l.sku_id GROUP BY s.sku_id, s.total, s.available HAVING sold_in_db sold_in_log;这张表最好在活动进行中就定期跑比如每 5 分钟一次而不是等活动结束。一旦发现偏差能及时止损等结束再发现用户该抢的都抢完了处理起来非常被动。注意库存流水表的写入量在秒杀场景下非常大不要给它建太多索引主键加一个 sku_id 的普通索引就够。我见过给流水表建了五个索引的写入 TPS 直接掉了一半。3.4 三种扣减方案的适用边界对比把三种方案摆在一起看选择逻辑其实很清楚方案单机吞吐一致性保证实现复杂度适用场景数据库直接扣减约 500 TPS/行强一致低库存少、参与人数少Redis 扣减 异步落库10 万 QPS 以上最终一致高大促、高并发活动Redis 扣减 同步落库取决于数据库较强一致中中等并发、不能容忍数据延迟中间那种Redis 扣减 同步落库的方案实际上是让 Redis 承担并发控制、数据库承担持久化两者在同一个请求里串行完成。它的吞吐受限于数据库但比纯数据库方案高因为 Redis 那层把无效流量挡掉了——大部分请求在 Redis 就返回失败根本走不到数据库。这个方案在很多中等规模的活动里其实是最优解复杂度可控一致性也好。4. 数据一致性异步落库之后的账怎么对上一章说 Redis 扣减成功之后要异步落库那异步就意味着有延迟、有失败、有可能顺序错乱。这一章讲的是怎么把异步造成的不一致控制在可接受范围内以及在真的出问题时怎么把它查出来、补回去。4.1 为什么强一致在秒杀里是伪需求先说个可能不太中听的观点秒杀场景下追求Redis 和数据库强一致是给自己找麻烦。原因在于两者的角色不同。Redis 是并发控制层它的职责是在毫秒级判断这个请求该不该成功并且保证不超卖数据库是持久化层它的职责是把成功的结果可靠地记下来。这两件事的时间尺度根本不同硬要它们实时一致就等于要求持久化层也要扛并发控制的量前面所有的优化全部白费。用户真正在乎的一致性是什么是他抢到了订单里能看到是他没抢到别给他生成订单。至于 Redis 里的库存数字和数据库里的库存数字差了几个用户看不见也不关心。业务上的一致性要求是订单和库存对得上不是两个存储系统的每个字段实时相等。想清楚这一点架构就顺了Redis 保证不超卖数据库保证订单不丢、不重。中间的短暂不一致是设计的一部分不是 bug。4.2 消息队列削峰与本地消息表异步落库的常见做法是Redis 扣减成功后往消息队列发一条扣减成功的消息消费端收到后写数据库。这个链路有两个风险点必须处理。风险一是消息发送失败。Redis 扣减成功了但发消息的时候网络抖了一下消息没发出去数据库里就永远不会扣。这种情况必须有个补偿机制。最简单可靠的是本地消息表在扣减成功后先在本地数据库和应用同一个库但不同表插一条待发送的消息记录然后再发 MQ发送成功后再把这条记录标记为已发送。如果应用在发消息前挂了重启时扫表把未发送的补发出去。CREATE TABLE local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_key VARCHAR(64) NOT NULL COMMENT 业务唯一键如订单号, payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送, retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz (biz_key) );扫描任务的间隔不要太短10 秒一次就够扫描时只捞status 0 AND next_retry_at now()的记录限制每次最多取 500 条避免一次拉太多把数据库打满。重试次数超过 10 次的单独捞出来告警人工处理。风险二是消息重复消费。MQ 大多保证 at-least-once重复投递是常态。消费端必须做幂等靠业务唯一键来判断——消费之前先查一下这个订单号是不是已经落库了落了就直接 ack。这里用唯一索引比用select判断更可靠因为select和insert之间有竞态窗口唯一索引是数据库层面的原子保障。4.3 对账与补偿定时任务该盯哪些数异步系统一定要有对账而且对账的粒度要设计好。我一般分三级第一级是实时校验在消费端落库的时候顺手做。落库前查一次数据库当前库存如果发现数据库库存已经小于等于 0 但消息说要扣说明有问题直接把消息打进死信队列不落库。第二级是分钟级对账每 5 分钟跑一次比对 Redis 里各活动的剩余库存和数据库里的剩余库存差值超过阈值比如超过总库存的 0.1%就告警。这一级主要用来发现消息积压或者消费端卡住因为正常情况下的延迟只有秒级。第三级是日终对账活动结束后跑全量校验用前面提到的stock_log汇总来对。这一级是最终的数据修复依据。发现不一致之后怎么补分两种情况。如果是 Redis 扣多了Redis 剩余比数据库少说明有扣减没落库这时候要把 Redis 的库存加回去同时把对应的用户购买记录清掉让用户重新抢——这一步要谨慎因为用户可能已经看到抢到了的提示所以更稳妥的做法是保留 Redis 的扣减结果把数据库补上宁可少卖也不让用户体验受损。如果是 Redis 扣少了Redis 剩余比数据库多说明有重复扣减或者回滚没执行这种情况相对安全把 Redis 修正到数据库的值即可多出来的库存可以再放出去或者留到下一轮。这套对账逻辑不复杂但一定要写成脚本、写进 runbook不能靠人临时发挥。大促当天谁都不想现场写 SQL。4.4 用户侧的一致性一人一单与幂等设计前面讲的都是库存一致性但用户感知最强的一致性其实是我有没有抢到。这里有几个坑。第一个坑是重复提交。用户手抖点两次或者网络超时后客户端自动重试都可能产生两个请求。解决方案是前端在点击后立刻禁用按钮同时服务端用(userId, activityId)做幂等——第一次请求进来先往 Redis 写一个SETNX的标记key 是seckill:order:{activityId}:{userId}有效期覆盖整个活动周期。写成功才继续处理写失败直接返回您已参与过本次抢购。这个标记不能只依赖库存扣减时的用户购买记录因为那是按数量的幂等标记是按是否有过成功请求的。第二个坑是超时但实际成功。用户请求发出去了服务端处理成功了但响应在返回路上丢了用户看到的是超时。这时候用户会重试而重试会被幂等标记拦住返回已参与。但用户不知道自己是成功了还是失败了。所以幂等拦截返回的结果必须是查询订单状态而不是简单的重复提交。具体做法是幂等校验失败时去查一下这个用户的订单查到了就返回订单信息查不到就返回处理中请稍后刷新。第三个坑是 Redis 扣减成功但落库失败。用户看到抢到了但订单表里没有记录用户去订单列表里找不到客服就会被投诉。这个问题的根治办法是让用户看到的抢到只是排队成功真正的成功以订单落库为准前端轮询订单状态落库了才显示抢购成功。多了一次轮询但避免了一堆客诉。这个取舍我认为非常值。5. 压测与线上踩坑设计图上看不见的那些问题方案画得再漂亮不上压测都是纸上谈兵。而且秒杀系统的压测特别容易做成自欺欺人——压测环境跑出 10 万 QPS生产环境一上就崩。这一章把我遇到过的几类典型问题和排查思路摊开讲。5.1 压测数据造假的三种典型第一种是压测机自己先撑不住。用几台 8 核机器发压每台能发出几千 QPS 就到顶了实际压出来的数字是压测机的极限不是被测系统的极限。判断方法很简单看压测机的 CPU如果已经 90% 以上那测出来的数字就是假的。要么加压测机要么用云上的压测服务要么干脆用一台高配机器多开进程。第二种是数据不真实。压测时用 100 个用户 ID 循环发请求Redis 里的用户购买记录 key 只有 100 个内存命中率接近 100%生产环境是 30 万个不同的用户 ID内存占用和 key 分布完全不同。压测一定要用接近真实基数的用户 ID至少要几十万个而且分布要打散。第三种是链路不完整。压测只压了下单接口没压上游的网关、没压下游的 MQ 消费。结果下单接口的 QPS 很好看但 MQ 积压了几百万条消息消费端根本追不上。压测必须全链路压包括异步消费部分并且要观察消息的堆积量随时间的变化。5.2 热点 Key 与连接池耗尽Redis 虽然单实例能扛十万 QPS但那是分散在不同 key 上的情况。秒杀只有一个热点 key所有请求都打在这一个 key 上Redis 的分片如果有的话在这里完全失效因为同一个 key 只能落在一个节点上。这个问题没有银弹只能缓解。方案一是库存分桶把 1 万件库存拆成 10 个桶每个桶 1000 件用户请求按用户 ID 哈希到某个桶上扣减。这样热点被分散到 10 个 key 上单 key 压力降到十分之一。代价是库存可能分配不均——某个桶扣完了但其他桶还有货用户明明看到还有库存却抢不到。所以分桶要把桶做得足够小、数量足够多同时库存总量要留一点余量接受一定的少卖。方案二是本地缓存加令牌。应用启动时从 Redis 一次性领一批库存到本地比如每台机器领 100 个用户在本地内存扣减扣完了再向 Redis 领。这样 Redis 的压力降低到原来的百分之一。但这个方案的一致性很难保证机器宕机时本地未使用的库存就丢了而且不同机器的库存分布不均。除非是对少卖极度宽容的场景否则不建议用。连接池耗尽的问题更隐蔽。表现是应用 CPU 不高、Redis 监控看 QPS 也不高但接口响应时间突然飙到几秒。抓个线程栈就会发现一堆线程卡在JedisPool.getResource上。原因是连接池配小了或者有的代码路径拿不到连接没释放。排查这类问题第一步永远是 jstack 看线程卡在哪比看任何监控都直接。# 抓三次线程栈间隔 5 秒看哪些线程反复出现在同一位置 for i in 1 2 3; do jstack pid /tmp/stack_$i.txt sleep 5 done grep -A 5 JedisPool /tmp/stack_1.txt | head -505.3 超时设置引发的连锁雪崩超时配置是秒杀系统里最容易配错、又最致命的东西。默认值往往是无限等待或者几十秒这在秒杀场景下等于自杀。整条链路要分层设置超时而且越靠上游超时越短链路环节建议超时说明客户端 → 网关3 秒用户可接受的等待上限网关 → 应用1 秒超过基本没救快速失败应用 → Redis200 毫秒内网调用超过说明有问题应用 → 数据库500 毫秒写操作超过就回滚应用 → MQ300 毫秒发送失败走本地消息表补偿这张表的关键是层层递减。如果下游超时比上游还长上游已经放弃了、下游还在执行就会造成资源浪费和状态不一致。比如网关 1 秒就返回失败了但应用还在等数据库 5 秒这 5 秒里线程被占着用户已经重试了三次于是同一个用户在系统里同时有四个请求在跑雪崩就这么来的。还有一个细节重试必须加退避和次数上限。数据库超时后立刻重试在过载情况下只会加重负担。正确做法是重试前 sleep 一个随机时间比如 50 到 200 毫秒并且最多重试 2 次。超过就认为失败走补偿流程。5.4 大促期间真正该盯的指标监控大盘上几十个指标真出事的时候看不过来。我的经验是只盯这几个其他的用告警兜底一是下单接口的成功率不是 QPS。QPS 再高成功率掉到 10% 也是灾难。成功率的基线要在压测时确定下来大促期间跌破基线的 80% 就告警。二是 Redis 的慢查询和连接数。慢查询数量突然增加说明有长脚本或者大 key连接数逼近上限说明有泄漏或者请求堆积。这两个指标能在问题爆发前几分钟给出信号。三是 MQ 的堆积量。堆积量持续上涨说明消费能力不足这时候要么加消费者要么先把部分非核心消息降级。堆积量是一个趋势指标看绝对值没意义要看增速。四是数据库的活跃连接数和慢 SQL 数。秒杀期间数据库不是主角但它是最后的兜底一旦它也扛不住整个系统就彻底没救了。五是 GC 时间和频率。秒杀期间对象创建速度极快如果 GC 配置不当一次 Full GC 停顿几秒就等于这几秒内所有请求都超时。这五个指标配到一个大盘上一屏能看完值班的人扫一眼就知道系统健康不健康。6. 一些不在设计文档里的经验写到这里方案层面的东西基本讲完了最后分享几个不太会写进设计文档、但实际很管用的经验。第一个是降级开关必须提前做好。活动开始前把关闭限购校验关闭用户资格校验直接返回活动已结束这几个开关做进配置中心并且验证过可以实时生效。真的出事的时候你不是去改代码发版而是点一下开关。我见过太多团队大促时现场改代码改完还没发上去活动已经结束了。第二个是库存数字不要一次放完。如果活动是分批放库存的比如每小时放一批第一批只放总量的 30%观察系统的实际表现再决定后面怎么放。这样即使架构有隐患损失也是局部的。第三个是日志要能定位到单个用户。秒杀的日志量巨大不可能全打。但每个请求必须带一个 traceId用户投诉的时候能顺着 traceId 把整条链路的日志串起来。日志里至少要有用户 ID、活动 ID、Redis 扣减结果、MQ 发送结果、数据库落库结果。这五个字段齐了百分之九十的客诉都能在五分钟内定位。第四个是活动结束后的清理比活动本身更重要。活动结束后要把 Redis 里的临时 key 清掉、把预热数据删掉、把限流规则撤掉。我有一次忘了撤限流第二天的常规活动一直被限排查了半天才发现是上一场活动的配置还留着。所以清理也要做成一个 checklist逐项打勾。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →