Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 9 章 高并发写与热点场景系统设计方法论:秒杀、社交互动与流量洪峰处理

当系统面对秒杀、社交点赞、评论洪峰、直播互动、抢票、短时间上传等场景时,设计重点不再只是单次写入是否成功,而是如何在极端流量下保护入口、隔离热点、控制写入速率,并让用户看到的结果与最终事实安全收敛。

第 7 章保护不可出错的业务事实,第 8 章保护复杂读路径和延迟预算。本章讨论第三类问题:写入请求会在很短时间内集中爆发,且大量请求可能竞争同一个库存、同一个热门对象、同一个分区或同一个活动窗口。

这类系统不能只用“扩容”回答。扩容解决的是总资源不足,但无法自动解决单 Key 热点、库存唯一性、无上限重试、队列无限增长和用户结果不明确。真正的设计顺序应当是:

先定义用户和业务要看到的结果,再控制进入量;先隔离热点,再决定哪些动作同步完成;最后通过异步处理、幂等、补偿和对账把结果收敛到权威事实。

9.1 问题定义:高并发写不是一个单一问题

9.1.1 五个设计轴

高并发写场景至少包含五个不同的设计轴。它们相互影响,却不能混为一个“TPS 不够”的问题:

设计轴主要问题典型场景
突发性请求在几秒内集中到达,峰值远高于平时秒杀开场、抢票、直播抽奖
总写入量持续写入超过单库、单表或单分区能力行为流、评论、日志、上传元数据
热点集中度大量请求竞争同一个资源、行、锁或分区单商品库存、热门内容点赞、热门直播间
正确性是否允许丢失、重复、短暂不一致或最终回补库存、订单、资格、支付前置校验
公平性与体验谁能进入、谁先处理、如何向用户解释等待抢票、口令红包、活动资格

平均 TPS 只能描述总量,不能描述这些请求是否集中在一个 Key、一个分区或一个锁上。系统可能有足够的总 CPU,却因为一个热点行或一个队列分区先崩溃。反过来,一个写入总量很大的行为流,如果能够均匀分区、允许批量落库,可能比少量但集中竞争单行库存的请求更容易扩展。这种“总量、局部性和一致性同时约束吞吐”的视角,也是数据密集型系统设计中反复出现的基本判断。[1]

这类场景中有一个重要背景:常规系统的写路径通常很直接——请求进来、鉴权、校验、写数据库、同步返回。它在日常流量下有明显优势:链路短、语义直观、排障简单。但在洪峰场景中,数据库会同时承受连接数、行锁、索引更新、日志刷写和下游事务等待,最终事实存储被迫充当流量入口。此时即使增加应用实例,也只是让更多请求更快地撞向同一个热点资源。

9.1.2 同步成功和最终完成不是同一件事

高峰场景中,用户常常只需要快速知道请求是否被接受,而不是要求所有后端动作在一次请求内完成。系统应明确区分以下结果:

  • 已成功:核心事实已经完成,结果可以直接信任。
  • 已受理:请求已经通过准入,后续会异步处理。
  • 排队中:请求尚未获得处理资格,但仍在等待。
  • 处理中:后端已经开始执行,最终结果尚未确定。
  • 失败:系统明确拒绝或执行失败,用户可以看到原因和后续动作。
  • 待确认:请求可能已经产生副作用,但当前无法确认,需要查询、重试或人工处理。

如果接口只返回一个模糊的“成功”,用户和下游系统都会把“请求被接收”误认为“库存已扣减、订单已创建或评论已发布”。后续一旦发生超时,客户端可能再次提交,运营人员也无法判断应该补单、退款还是释放库存。可靠的异步接口通常返回业务对象 ID、当前状态、状态查询地址、重试建议和幂等键,而不是只返回一个 HTTP 200。

在 HTTP 接口中,超过准入速率时可以使用 429 Too Many Requests 表达请求被限流,并在适合的场景下通过 Retry-After 告知客户端等待时间;RFC 6585 同时指出,服务端并不必须使用同一种计数维度,可以按用户、资源或整个服务计算限额。[8] 这意味着 429 只是协议层结果,真正的设计仍然要回答“按什么限”“拒绝是否可重试”“重试是否会改变业务结果”。

9.1.3 本章的范围与非目标

本章聚焦:

  1. 如何识别突发流量、单 Key 热点和持续写入瓶颈。
  2. 如何设计准入、限流、排队、预处理、异步落库和背压。
  3. 如何在热点场景中保护库存、计数、顺序、公平性和用户结果语义。
  4. 如何处理队列积压、重复消息、快速预处理层故障、写库失败和最终对账。

上传文件本身更适合使用对象存储和异步媒体处理;本章只讨论上传任务元数据、任务状态和热点任务调度,不把文件传输和数据库写入混成一个问题。实时音视频、搜索索引和大规模数据分析也有各自的写入模型,本章只抽取它们与洪峰、热点和最终收敛相关的共同方法。

Redis 的数据结构、缓存和集群边界可参见后端面试基础知识题单中的 Redis 主题,消息分区、消费和积压治理可参见后端面试基础知识题单中的 Kafka 主题,库存权威事实则可参见库存系统实战。本章关注这些基础设施如何组合成抗洪峰的写入方法论,而不是重新介绍某一个中间件的全部 API。把消息、事务和状态迁移视为可以组合的模式,而不是把某个产品名称当成方案本身,符合企业集成模式所强调的“上下文决定模式”。[5]

9.2 约束与指标:先算清到达速率、处理速率和可接受等待

9.2.1 入口速率和服务速率

用 λ 表示请求到达速率,用 μ 表示稳定处理速率。当 λ 长时间大于 μ 时,队列一定会增长;当队列达到容量上限后,系统只能拒绝、丢弃、降级或把压力传播到更危险的下游。队列只能把时间上的波峰变得平滑,不能把长期能力不足变成能力充足。

一个用于讨论的容量示例如下,数字只是设计假设,不是通用标准:

活动峰值到达:100,000 req/s
入口准入能力:20,000 req/s
异步处理能力:5,000 req/s
队列容量:600,000 条

如果 100,000 req/s 持续五分钟,而消费者只有 5,000 req/s,即使入口只接受 20,000 req/s,队列仍会以 15,000 条每秒的速度增长,约 300 秒后新增积压达到 4,500,000 条,远超 600,000 条容量。若系统在第 30 秒发现积压并立刻拒绝新请求,队列仍然需要约 120 秒才能消化原有积压;若消费者处理速率只能提升到 10,000 req/s,恢复时间还会更长。排队设计必须同时给出最大积压、最大等待时间、满队列策略和恢复速率。

可以用近似公式帮助评审:

积压变化率 = 到达速率 - 有效处理速率
预计等待时间 ≈ 当前积压量 / 有效处理速率
清空时间 = 当前积压量 / (恢复后的处理速率 - 恢复期间的到达速率)

其中“有效处理速率”不能只看消费者线程数,还要扣除失败重试、数据库锁等待、限流、死信和下游超时的成本。如果消费者因为热点行锁把一半时间耗在等待上,表面上的并发数并不能代表真正的业务完成速率。

9.2.2 容量指标不能只有 QPS

Google SRE 对过载的总结特别强调,不同请求可能消耗完全不同的 CPU、内存、线程、网络或后端资源,用单一 QPS 作为能力指标很容易误判。[2] 本章可以把指标分成七组:

指标类别示例指标设计用途
准入接受率、拒绝率、资格命中率、429 比例判断入口是否按预期保护系统
热点Top Key、单分区写入、单资源失败率、锁等待识别局部瓶颈和热点迁移
队列积压量、最老消息年龄、消费速率、死信量判断异步链路是否可恢复
处理成功率、重试率、重复消费率、处理耗时判断消费者和事实层质量
用户结果已受理、排队中、成功、失败、待确认数量判断结果语义是否清晰
业务事实库存差异、计数差异、重复订单、回补延迟判断最终正确性
资源CPU、连接池、锁等待、磁盘刷写、网络、GC判断真正耗尽的资源

指标还要带上维度:活动、租户、用户等级、资源 ID、分区、错误类型和版本。只看全局平均值会掩盖“全站很健康但一个热门商品已经无法下单”的情况;只看成功率又会掩盖服务端为了保护系统而拒绝了大量请求的事实。

9.2.3 用户承诺必须可计算

如果系统承诺“排队后一定有机会成功”,就必须有队列容量、资格有效期和处理时限;如果系统承诺“提交成功即占库存”,就必须让库存事实在成功响应前可验证;如果系统只承诺“已受理”,就必须提供查询接口、状态变化通知和超时处理。承诺越强,同步链路要保护的事实越多,吞吐和可降级空间通常越小。

可以把业务承诺写成一个简单的契约表:

用户可见结果系统必须保证系统可以延迟超时后的动作
已成功权威事实已提交,重复查询返回同一结果通知、推荐、统计只补发通知,不重复执行事实变更
已受理请求已持久化或进入可恢复队列订单创建、索引、通知查询状态,超过期限转失败或人工
排队中队列有容量,资格尚未失效绝大部分业务处理队列满时拒绝,不伪造成功
待确认记录了未知结果和查询线索无法确定查询下游、对账,禁止盲目重试

高峰场景最危险的不是拒绝,而是给出超过系统能力的承诺。对用户明确说“当前未获得资格”,往往比先返回成功、数分钟后再解释订单不存在更容易恢复信任。

9.3 核心模型:准入、缓冲、热点、事实和收敛

9.3.1 准入控制优先于后端扩容

准入控制回答“谁能进入系统”。它至少包括用户级频控、资源级频控、活动级全局限流、令牌或资格预发放、租户与风险等级分级,以及根据后端利用率动态拒绝。入口保护的对象不只是 API 服务器,还包括连接池、缓存、消息代理、数据库和任何拥有有限并发槽位的下游。

令牌桶适合控制平均速率并允许有限突发;漏桶适合把请求平滑成较稳定的处理速率;固定窗口实现简单但窗口边界可能形成双倍突发;滑动窗口更准确但需要更多存储和计算。Sentinel 的中文文档把流量控制、排队等待、系统负载保护和热点参数限流分成不同能力,并强调应结合 QPS、并发线程、平均 RT、CPU 或 Load 等运行信号进行保护。[14] 这说明限流算法只是实现细节,关键是限流维度、信号来源和超限后的结果语义。

入口通常需要多层限流:先按 IP、设备或用户阻挡明显的异常流量,再按活动和资源控制总量,最后按下游实际容量控制进入队列的速度。层层限流不能简单相乘,否则会出现一个入口允许 10,000 req/s、活动允许 5,000 req/s、热点商品允许 1,000 req/s,但因为重试和预热同时发生,最终仍然超过消息、缓存或数据库的有效能力。

9.3.2 热点需要分类处理

热点类型瓶颈可用手段主要风险
库存热点同一资源的唯一扣减资格令牌、预占、分段库存、串行队列超卖、回补失败
计数热点同一对象频繁递增分桶计数、异步聚合、批量写计数短暂不准确
评论热点同一对象新增大量记录分区、异步写、楼层分配顺序、审核和分页复杂
房间热点同一直播间大量互动房间级队列、房间隔离、丢弃策略热房拖垮全站
分区热点Hash 结果集中到单分区加盐、二级分桶、动态迁移查询和合并成本增加
任务热点同一租户或项目集中触发任务租户配额、优先级队列、批次调度长尾任务饥饿

分片不是万能药。如果所有请求争夺同一个商品的剩余库存,简单把请求分到多个分片可能破坏库存唯一性;必须先改变资源分配模型,例如预发放资格、分段库存或按时间窗口分配令牌。相反,对于点赞计数,写入可以按内容 ID 与桶号打散,展示时再合并;这种方案牺牲的是读取和重算复杂度,而不是库存唯一性。

热点还会动态变化。活动开始前可以按历史数据预热,但真正开场后 Top Key、Top 分区和最老队列消息可能迅速改变。因此热点观测应同时支持实时 Top K 和历史趋势,隔离策略也要能动态生效。否则为了防止未知热点而给每个资源都配置独立队列,会让系统的运维成本和资源空置率先失控。

9.3.3 快速预处理层不是最终事实源

Redis、内存令牌和边缘限流适合做快速判断:是否有资格、是否重复、是否还有预分配额度、是否需要进入队列。Redis Lua 脚本在服务器端原子执行,脚本执行期间其效果具有原子性,适合把“检查额度—扣减—记录请求”这类短操作放在同一原子步骤中。[11] 但原子执行不等于业务事实已经持久化,也不等于跨数据库、消息系统和支付系统的事务已经完成。

预处理层获得低延迟和高吞吐,牺牲的是持久化语义、跨系统事务和长期可审计性。因此每次预处理都必须生成业务 ID,并把预扣、资格、版本和过期时间写入可恢复的受理记录。后续事实层应能够根据业务 ID 查询、重放、释放或补偿,而不能只有一个无法解释的缓存计数。

快速层与事实层之间还要定义失败顺序。例如“Redis 预扣成功、受理记录写入失败”时,系统不能假设这份额度自动存在;可以采用先写受理记录再预扣、短期补偿扫描预扣记录,或者使用带状态的令牌表。每种方案都有成本,但都比把 Redis 数字直接当成库存账本更容易审计。

9.3.4 异步链路必须有背压

消息队列是缓冲器,不是无限仓库。背压意味着后端处理能力下降时,入口能感知并降低接收量,而不是继续把更多请求塞进队列。队列必须定义最大长度、消息最大存活时间、消费超时、重试上限、死信策略、优先级、可丢弃事件规则和恢复速率。

不同事件要使用不同的可靠性等级:

事件是否允许合并是否允许丢弃失败后的主动作
库存预占、订单创建通常不允许不允许重试、补偿、人工接管
用户点赞事实可以按用户和内容去重视产品约束而定重放事实或重新聚合
点赞总数增量可以批量合并可以由事实重算延迟聚合、定期校准
普通弹幕可以采样或合并通常允许丢弃并记录比例
抽奖资格、中奖结果不允许不允许保留事件、对账和审计
搜索索引刷新可以覆盖旧事件可延迟,不能静默丢失依据版本重建索引

如果队列已经接近上限,入口应快速返回“暂不可受理”或降低业务等级,而不是让请求等待一个注定超时的连接。Redis Streams 的消费者组通过待处理列表、确认和重新认领机制表达了类似的恢复边界,但确认并不等于业务副作用天然幂等,业务层仍需保存处理状态。[12] 消费者恢复后也不能立即把最大并发打满,应该逐步升速,否则积压清理本身会形成第二次洪峰。

9.3.5 异步状态机和幂等键

异步写入必须把“请求 ID”“业务对象 ID”“消息 ID”和“幂等键”区分开。请求 ID 用于追踪一次客户端请求,业务对象 ID 用于查询订单或任务,消息 ID 用于识别一次投递,幂等键用于识别同一个业务动作。四者混用会导致重试时无法判断到底是同一次动作,还是同一对象上的新动作。

受理记录可以采用如下状态:

已接收 → 已排队 → 处理中 → 成功
                     ├→ 失败
                     ├→ 待重试
                     └→ 待人工

状态迁移必须带版本或条件更新。重复消费成功状态时返回已有结果;重复消费失败状态时不能无条件重新执行;过期消息到达时,应根据业务时间和当前版本决定丢弃、补偿或人工处理。状态机还要区分“业务失败”和“技术未知”:前者可以给用户稳定的失败结论,后者不能在未查询事实前直接告诉用户“失败”。

不要把“恰好一次投递”当成异步系统的默认前提。Kafka 官方文档将至多一次、至少一次和恰好一次区分为不同的投递语义,并指出端到端的恰好一次需要生产、处理和输出链路共同满足条件。[13] 工程上更普遍的做法是接受消息可能重复,把副作用放进可提交的本地事务,用唯一约束、处理记录和状态条件保证重复执行不会重复产生业务结果。Idempotent Consumer 模式明确建议把已处理消息的 ID 与业务更新放在同一事务边界中;重复消息到达时,业务结果应与第一次处理相同,而不是依赖消息代理替应用保证业务唯一性。[7]

9.4 参考架构:把洪峰挡在最终事实存储之前

9.4.1 通用写入链路

高并发写系统可以拆成六层:

客户端 / 活动入口
  → 网关鉴权、风控、幂等和准入
  → 快速预处理层:令牌、资格、去重、预扣
  → 队列或缓冲层:按资源、租户或房间分区
  → 工作层:按可承受速率执行本地事务
  → 权威存储:订单、库存、账本、互动事实
  → 查询、通知、对账、补偿和人工接管

入口受理层只做必须快速完成的动作:校验身份、判断资格、写入受理记录、生成业务 ID、返回状态。订单创建、库存确认、评论审核和媒体处理可以异步执行,但必须能够被查询和重放。工作层要有独立的并发上限和超时,不能因为队列里消息很多就无限增加消费者。

如果业务事实和消息发送必须同时成立,可以把事件写入同一数据库事务中的 Outbox,再由独立 relay 发布。Transactional Outbox 的核心是避免“数据库提交了但消息没发出去”或“消息发出但数据库回滚”的不一致;但 relay 在发布后崩溃仍可能重复发送,因此消费者仍然需要幂等。[6] 这比把消息发送直接塞在数据库事务中更容易在故障时重试和审计。

9.4.2 分区和队列设计

队列分区键应根据业务一致性选择:库存可以按资源 ID 或活动 ID 分区,社交互动可以按对象 ID 分桶,用户任务可以按用户 ID 或租户 ID 分区。分区键决定顺序、热点和扩展性,不能只为了均匀 Hash 而忽略业务约束。

分区设计通常需要在三种能力之间取舍:同一 Key 的顺序、不同 Key 的并行度和单分区的热点上限。严格按商品 ID 分区可以简化库存顺序,但一个爆款商品会锁住一个分区;按商品 ID 加桶号可以提高并行度,却要求业务层解决同一库存的竞争。RocketMQ 的顺序消息文档也强调,顺序通常按消息组定义,同一组可以保证局部顺序,不同组则可以并行;顺序消费还受到生产串行、消费确认和有限重试等条件约束。[16]

对单个热点资源,可以采用专属队列、分段令牌或串行消费者;对长尾资源,则可以共享普通队列。热点发现后进行动态隔离,通常比预先为所有资源配置独立队列更节省成本。动态隔离必须有迁移边界:切换前后的消息版本、重复消费、旧消费者停止时间和未确认消息归属都要有明确规则。

9.4.3 最终事实和展示结果分离

点赞事实可以按“用户 + 内容”唯一写入,点赞总数可以异步聚合;评论原文需要持久化和审核,评论列表可以延迟建立索引;直播弹幕可以允许丢弃低价值事件,但抽奖资格和中奖结果不能丢。这里的关键不是“所有内容都最终一致”,而是分别定义事实、投影和可重建边界。

可以把一个业务动作拆成三类数据:

  1. 权威事实:决定库存、订单、资格、账本和用户行为是否发生。
  2. 派生投影:计数、列表、搜索索引、推荐特征和通知状态,可以由事实重建。
  3. 运行记录:队列偏移、重试次数、处理耗时、最后错误和人工任务,用于恢复和治理。

事实必须优先保证唯一性和可审计性;投影优先保证可重算和延迟可控;运行记录优先保证故障时有人知道“下一步该做什么”。不能因为所有请求都叫“写入”,就给它们相同的可靠性等级,也不能把一个不可重建的计数器当成事实层。

9.5 方案选型:用 ADR 说明吞吐、正确性和体验的取舍

9.5.1 方案对比

方案适用前提获得的能力牺牲或新增风险
请求直接写 OLTP流量平稳,事实必须同步完成语义简单,结果立即落库洪峰直达数据库,热点锁和连接池先耗尽
入口限流能接受部分请求被拒绝或稍后重试保护系统边界,成本低需要公平规则、拒绝解释和客户端配合
MQ 排队结果允许延迟,消息可持久化削峰、解耦、异步重试结果延迟、积压、顺序和重复消费需要治理
Redis / Lua 预处理判断逻辑短且可原子执行低延迟原子判断,适合资格和预扣不是最终事实,故障和回补复杂
分片 / 分桶约束可以局部化或结果可以合并分散总写量和计数热点查询要合并,顺序和全局一致性变复杂
单 Key 串行化单资源的正确顺序优先语义清晰,避免并发冲突单热点吞吐受限,需要排队和超时
批量聚合写单次事件可合并或稍后写降低写放大,提高持续吞吐数据延迟,批次失败和局部丢失要处理
预分配资格供给有限且可提前筛选用户让真正有资格的请求进入窄链路资格失效、转让和公平性规则更复杂

选型时先问“哪个约束不能破坏”,再问“哪个组件最快”。如果库存唯一性不能破坏,就不能因为 Redis 延迟低而把 Redis 数字直接当成订单事实;如果互动消息可以丢弃,就没有必要为每条弹幕设计和订单相同的重试链路;如果公平性比瞬时吞吐更重要,就要把排队和抽签规则写成用户可理解的业务规则。

9.5.2 示例 ADR:秒杀采用资格准入、预扣和异步下单

背景:活动开始瞬间有大量请求竞争少量库存;库存不能超卖,但用户不要求在一次请求内完成支付和订单全部写入。

决策驱动因素:需要保护数据库,控制单商品热点,防止用户重复提交,并让失败预扣能够回补;同时要给用户一个可查询的明确结果。还要控制机器人和脚本流量,避免把网络到达时间误当成唯一公平依据。

候选方案:请求直接扣数据库、数据库内部排队、Redis 预扣后同步下单、资格令牌 + Redis 预扣 + MQ 异步下单、预分配库存到多个逻辑桶。

最终决策:入口先完成用户资格、风控和频控;通过令牌限制进入量;快速预处理层执行用户去重和库存预扣;成功请求进入按商品或活动分区的队列;消费者创建订单并在权威库存中确认;任何中间失败都通过状态机和补偿回补。受理记录与业务事实使用同一个业务请求 ID 关联,支付超时和订单创建失败分别处理。

获得的能力:

  • 入口可以拒绝明显无资格、重复或超过容量的请求。
  • 数据库不直接承受第一波洪峰,热点商品可以按队列或分段库存隔离。
  • 用户可以先获得受理结果,再通过订单查询最终状态。
  • 订单、库存和补偿都可以使用业务 ID 做幂等。
  • 失败预扣、未支付订单和消息死信都有明确的回收路径。

主动牺牲的能力:

  • 请求受理成功不等于订单已经创建成功。
  • 用户可能经历排队、处理中和待确认状态。
  • Redis 预扣、队列和数据库事实之间存在收敛窗口。
  • 系统增加了队列、回补、对账、风控和人工处理成本。

已接受风险:预扣成功但订单创建失败时需要及时回补;队列积压会延迟用户结果;Redis 或消息系统故障时需要进入受控拒绝或降级模式;如果资格规则不透明,用户可能认为排队不公平。

验证指标:超卖差异为零,重复订单为零,预扣回补延迟在目标内,队列最老消息年龄可控,入口拒绝率可解释,用户查询状态与最终订单事实一致,异常流量拦截率和不同用户群体的成功概率处于可接受范围。

重新评估条件:单商品热点仍超过单队列能力、回补和对账成本持续上升、活动公平性要求改变,或库存必须提供更强的同步确认语义。

9.5.3 选型不应把“全部接住”当成目标

在供给远小于需求的场景中,让所有请求进入系统并不代表服务质量高。系统应该尽早拒绝没有资格、重复或超过处理能力的请求,把有限资源留给能够产生有效业务结果的请求。RFC 6585 对 429 的定义也允许服务端在响应中说明限制原因或等待时间,这为“拒绝但可解释”提供了协议层基础。[8]

Google SRE 对过载和级联故障的总结强调,系统需要在过载时快速拒绝、提供降级结果并进行负载削减;如果每一层都在失败后立即重试,单个用户请求可能在下游被放大成多次调用,最终让健康实例继续减少。[3] 因此重试预算应该由一个明确的层负责,客户端、网关、服务和数据库驱动不能各自默认重试。

9.6 完整案例:从秒杀受理到最终库存事实

9.6.1 正常链路

  1. 活动服务发布商品、库存窗口、用户资格规则、队列容量和过期时间。
  2. 用户请求进入网关,完成鉴权、风控、频控和资格判断。
  3. 系统为用户生成一次业务请求 ID,并在快速层完成去重。
  4. 预扣层判断令牌和可用预扣额度,失败则快速返回未获得资格。
  5. 预扣成功后写入受理记录,并把请求放入按商品分区的队列。
  6. 订单消费者消费消息,在本地事务中写入订单、库存确认记录和 Outbox 事件。
  7. 订单进入待支付,支付成功后进入已支付和已确认状态。
  8. 创建订单失败、支付超时或用户取消时,触发库存释放和预扣回补。
  9. 查询服务从受理记录和订单事实生成用户可见状态,通知服务只负责投影,不改变库存事实。

正常链路中最容易被忽略的是第 6 步的边界:消费者确认消息前,订单事实、处理记录和需要发送的后续事件应当形成可恢复的提交点;如果业务更新提交了但消息确认失败,消息会再次投递,此时唯一约束和状态条件必须让第二次处理变成查询已有结果,而不是再次扣库存。把本地事务、事件外发和消费者幂等组合起来,是《凤凰架构》中反复讨论的分布式一致性实践之一。[19]

9.6.2 重复、超时和乱序

用户重复点击时,入口幂等键保证只获得一个受理结果;消息重复消费时,订单服务使用业务请求 ID 或唯一订单约束;预扣成功但订单响应超时时,不能重新申请一份库存,而应查询原请求状态。AWS 的幂等 API 设计也把“客户端不知道服务端是否已经产生副作用”视为重试的基本问题,建议使用客户端提供的唯一 token 让服务端返回同一结果。[9] 从客户端请求角度看,这也是 Idempotent Receiver 模式要解决的问题:请求可能已经完成但响应丢失,服务端应根据唯一请求标识返回已保存结果,而不是再次执行。[20]

支付超时与订单创建失败是不同故障:前者可能需要等待支付状态并最终关闭订单,后者需要立即释放预扣。事件乱序时,状态机必须根据版本或允许迁移表拒绝旧事件,不能简单使用“最后收到的消息覆盖当前状态”。例如已支付订单收到旧的“待支付”事件时,旧事件只能记录为过期消息,不能把订单倒退。

重试也必须分层控制。AWS 的可靠性指南建议使用指数退避、随机抖动和最大重试次数,以避免多个客户端在同一时刻重复冲击已经限流的服务。[10] 对库存和订单这样的非幂等业务,还要先判断“是否已产生事实”,再决定是否重试;对网络超时不能直接假设业务失败。

9.6.3 对账、回补与未知结果

秒杀系统至少需要两条恢复任务:一条扫描“预扣存在但受理记录缺失或未推进”的记录,另一条扫描“订单关闭但预扣未释放”的记录。扫描任务不能简单全表重试,而应根据租约、版本和最后更新时间领取任务,避免多个补偿 worker 同时操作同一业务对象。

库存对账可以用下式表达,但每一项都要有来源和时间窗口:

可解释库存 = 初始库存 - 已确认订单 - 有效预扣 + 已释放预扣 + 人工调整
库存差异 = 权威库存 - 可解释库存

当差异不为零时,先冻结自动补偿,保留原始事件、订单和操作日志,再判断是重复扣减、漏记回补、过期预扣未释放还是人工调整未入账。直接修改缓存数字只能掩盖差异,不能证明系统恢复正确。

9.6.4 社交互动变体

点赞通常需要保证“同一用户对同一内容最多一条有效点赞事实”,但展示计数可以异步聚合。可以将事实表和计数表分离:事实表使用唯一约束,计数事件进入队列,聚合任务批量更新计数,定期用事实表重算计数。取消点赞不能简单把计数减一,因为重复事件、乱序事件和事实删除可能同时发生;更稳妥的方式是让聚合器依据事实变化或版本计算净变化。

评论需要区分受理、审核、发布和删除。热门内容上的评论写入可以按对象分桶,但楼层顺序、审核状态和删除传播必须有明确规则。若产品只要求“按审核通过时间展示”,就不应承诺提交时间就是楼层顺序;若必须保持楼层连续,则需要一个可序列化的楼层分配器,并接受它成为新的热点。把所有评论直接写入同一张热点表,通常会让数据库成为第一道洪峰吸收器。

9.6.5 直播、上传与抢票变体

直播互动应按房间隔离资源,热房间使用独立队列和限额;弹幕可以丢弃或合并低价值事件,但抽奖资格、红包结果和订单不能采用同样的丢弃策略。房间内的消息顺序也要区分:弹幕展示可以近似按到达顺序,抽奖资格则必须按规则和事件版本决定结果。

短时间上传的热点不一定是数据库,而可能是签名服务、对象存储带宽、转码队列或元数据表。入口可以先发放上传凭证,把文件流量移到对象存储,再异步写入媒体任务状态;任务状态需要幂等,转码结果需要版本,失败任务需要死信和人工重试。这样恢复了“上传元数据可异步处理”的边界,也避免把文件字节流和业务事实塞进同一个事务。

抢票除了库存正确性,还要处理公平性和唯一性:用户资格、排队顺序、座位锁定 TTL、超时释放、防刷和退票回补都需要进入状态模型。它与秒杀共用削峰和预占能力,但不能简单复制“谁先到谁先成功”的实现,因为入口网络差异和重试可能改变用户顺序。

9.6.6 抢票中的公平性 ADR

如果采用严格到达顺序,系统需要可信的排队时间、单一入口和稳定的排序依据;它获得了顺序可解释性,但牺牲了跨地域网络公平,且重试和代理会影响到达时间。如果采用资格池后随机抽签,系统获得了更强的抗刷和机会公平,牺牲了用户对“先来先得”的直观预期。如果采用分批放号,系统获得了更好的热点控制和可运营性,但必须解释批次、等待和失效规则。

因此抢票的 ADR 不能只记录“采用排队系统”,而要明确公平性的定义:按请求到达、按资格、按批次、按随机抽签,还是按业务优先级。公平性指标可以包括不同用户群体的进入概率、排队等待分布、重复资格拦截率、异常流量占比、座位锁定超时率和退票回补延迟。公平性不是单独的反作弊功能,而是准入、排序、重试和结果通知共同形成的业务语义。

9.7 故障与治理:高峰是常态,失败必须可控

9.7.1 队列满和消费者变慢

队列达到上限时,系统必须按业务价值做决策:拒绝新请求、只保留高优先级、合并重复事件、丢弃可重建事件,或暂停非核心功能。不能无限增加重试,因为重试会把已过载的消费者再次打满。对于队列满的结果,要区分“用户不符合业务资格”和“系统暂时没有容量”,两者的提示、监控和后续动作不同。

消息消费者应记录处理耗时、重试次数、最后错误、业务状态和事件版本。RocketMQ 的消费重试文档把消息从就绪、处理中、待重试到死信等状态区分开,并明确不同消息类型的重试策略;这类状态机思路适合迁移到自建消费者治理中。[17] RocketMQ 的发送重试和流控文档还提醒,客户端超时后无法确定服务端是否已经处理,发送重试可能产生重复消息,因此业务方必须自行处理重复问题。[18] 处理失败后,必须判断是可重试的临时错误、不可重试的业务错误、数据损坏,还是未知结果。所有错误都重试会把死循环隐藏在队列里。

9.7.2 Redis 或快速预处理层失败

快速层失败时,不能直接绕过保护层把所有请求打到数据库。可选策略包括:受控拒绝、进入低容量保护队列、切换到预分配令牌、只允许查询已有状态,或短暂关闭活动入口。Sentinel 文档中关于系统自适应保护和热点参数限流的思路说明,入口保护应结合整体负载与局部热点,而不是只看某一个接口是否返回成功。[15]

恢复后必须核对预扣记录、订单事实和权威库存,不能因为 Redis 恢复就假设之前的预扣全部有效。预处理层的可用性目标不能高于事实层的可恢复性;如果无法证明一份令牌的来源和状态,就宁可把它标记为待核对,也不要静默再次发放。

9.7.3 重试风暴、存储故障和级联失败

重试风暴通常来自三个叠加因素:客户端没有预算、服务端超时阈值过长、多个调用层各自重试。一次用户请求可能经过网关、订单服务、库存服务和数据库驱动,每层重试三次,最坏情况下会把一次请求放大成几十次下游尝试。治理办法包括只让一个层拥有主要重试责任、为每次请求携带剩余 deadline、对不可确认结果使用查询而不是创建重试,以及把限流错误与业务失败区分开。

Google SRE 对级联故障的分析指出,服务在资源耗尽后即使把流量稍微降下来,也可能因为健康实例减少而无法恢复;负载削减、快速失败、降级和真实压测要作为同一个恢复设计来验证。[3] 因此数据库连接池耗尽时,应用不应继续等待更长时间;消息代理异常时,也不应让每个请求同步等待发送结果。失败要尽早暴露在最靠近入口、最容易恢复的边界。

9.7.4 库存和计数对账

库存对账至少比较初始库存、预扣记录、订单确认、取消释放、人工调整和最终库存。计数对账则比较事实记录与聚合结果,发现差异时优先从事实重算,不直接手工修改展示计数。对账任务也属于写入系统,必须有时间窗口、租约、幂等和审计记录,否则对账本身可能制造新的重复变更。

对于可重建投影,可以把修复过程设计成“生成新版本—校验—切换指针”,而不是在原表上边读边改。对于库存和账本这类权威事实,修复必须产生人工审批或明确的补偿事件;任何直接改数操作都应记录操作者、原因、旧值、新值和关联工单。

9.7.5 热点和过载演练

发布前要验证:热点集中在一个 Key、一个分区或一个消费者时,系统是否会隔离;队列满时用户看到什么;重试是否有上限;Redis、数据库、消息系统分别失败时是否进入安全模式。压测不能只测平均流量,还要测峰值斜坡、瞬时尖峰、热点倾斜、重复请求、慢消费者、网络超时和恢复过程。

Google SRE 建议使用真实负载测试验证系统的过载点,因为服务在接近资源上限时经常出现非线性延迟、重试放大和级联故障,而不是简单地线性变慢。[4] 演练结果要记录“在哪个资源、哪个指标、哪条保护规则先触发”,而不是只给出一个最大 QPS。

9.7.6 发布、灰度和容量复盘

高并发写功能不能只在活动当天验证。发布前应使用逐步放量、影子流量和可回退配置验证入口限流、令牌发放、队列分区和消费者扩容。核心开关应支持快速关闭活动、降低准入速率、暂停非核心消费者、切换只读查询和延长处理窗口。变化大的消费者版本要保证旧消息可以解析,或者在发布前清空并隔离不兼容的队列。

容量复盘不能只记录“最终扛住了多少 QPS”,还要记录峰值到达曲线、真实资源消耗、最热 Key、最慢队列、拒绝原因、重试放大倍数、降级时间、最终积压清空时间和对账差异。下一次活动应根据这些数据重新计算入口、队列和消费者容量,而不是继续使用过时的 TPS 经验值。

9.7.7 用户查询和运营处置

异步结果必须提供稳定的查询接口,例如根据业务对象 ID 查询当前状态、最近一次失败原因、预计下一次处理时间和是否需要用户重试。运营侧则需要看到活动级准入量、资格池消耗、队列积压、回补数量、死信数量和人工任务,而不是只看到接口成功率。

当系统进入保护模式时,用户提示、运营开关和后台指标必须使用同一套状态定义。否则前端显示“抢购成功”、后台显示“排队中”、订单系统显示“未创建”,最终会把技术不确定性转化为投诉和人工对账。未知状态要有明确的“查询中”或“待确认”界面,而不是让用户靠重复点击猜测结果。

9.8 方法论总结

9.8.1 八个判断句

  1. 高并发写系统的首要目标不是把所有请求都处理完,而是让系统在洪峰下仍然可控。
  2. 突发流量、总写入量和单 Key 热点是不同问题,不能用一个扩容方案覆盖。
  3. 先控进入量,再谈处理量;先保护热点,再谈整体吞吐。
  4. 入口返回“已受理”时,必须提供可查询的状态、过期规则和最终结果。
  5. 队列可以削峰,但不能消除处理能力不足;最大积压和最长等待时间必须可计算。
  6. Redis 预扣、令牌和快速计数是保护层,不是订单、库存和账本的最终事实。
  7. 重试、重复消息、超时和乱序是正常输入,幂等和状态机必须内建。
  8. ADR 必须说明方案获得了什么、牺牲了什么、接受了什么风险,以及什么条件下需要重新评估。

9.8.2 评审清单与迁移边界

评审一个高并发写方案时,可以沿着以下顺序提问:

  1. 峰值是瞬时尖峰、持续高流量,还是热点集中?三者的假设是否有测量或压测依据?
  2. 入口最多允许多少请求进入,拒绝和排队分别意味着什么?队列满时谁先被牺牲?
  3. 哪些数据是不可丢失的权威事实,哪些是可以合并、延迟或重建的投影?
  4. 热点 Key、分区、锁、连接池和下游调用是否分别有指标和保护规则?
  5. 客户端重试、消息重试、补偿重试是否共享预算?未知结果如何查询?
  6. 快速预扣与最终事实之间有哪些状态,谁负责回补,回补失败如何进入人工流程?
  7. 是否有一条完整的正常链路、重复链路、超时链路、乱序链路和恢复链路?
  8. 是否用真实热点倾斜和故障恢复验证过,而不是只测均匀流量下的平均 QPS?

这套方法不要求所有系统都引入 Redis、MQ、分片或复杂的分布式事务。低流量、强同步、热点有限的业务可能直接写 OLTP 更合适;如果引入异步化后用户必须等待很久、无法查询结果,或者补偿成本高于同步处理,应该重新评估边界。方法论的目标是把不可避免的取舍显式化,而不是堆叠中间件名称。

9.8.3 一句话表达

高并发写与热点场景系统设计的核心,不是把后端数据库做成无限吞吐的洪峰吸收器,而是先定义结果承诺,再用准入、限流、队列、预处理和热点隔离保护系统,最后通过异步处理、幂等、补偿和对账让最终结果回到权威事实。

9.9 参考资料

[1] Martin Kleppmann、Chris Riccomini,《Designing Data-Intensive Applications, 2nd Edition》,O’Reilly,2026。

[2] Google SRE,“Handling Overload”,Site Reliability Engineering,Google,2017。

[3] Google SRE,“Addressing Cascading Failures”,Site Reliability Engineering,Google,2017。

[4] Google SRE,“Reliable Product Launches at Scale”,Site Reliability Engineering,Google,2017。

[5] Gregor Hohpe、Bobby Woolf,Enterprise Integration Patterns,Addison-Wesley,2003。

[6] Chris Richardson,“Transactional Outbox”,Microservices.io,访问:2026-09-21。

[7] Chris Richardson,“Idempotent Consumer”,Microservices.io,访问:2026-09-21。

[8] Mark Nottingham、Robert Fielding,RFC 6585: Additional HTTP Status Codes,RFC Editor,2012。

[9] Malcolm Featonby,“Making Retries Safe with Idempotent APIs”,Amazon Builders’ Library,访问:2026-09-21。

[10] Amazon Web Services,“Control and limit retry calls”,AWS Well-Architected Framework,2022。

[11] Redis,“Scripting with Lua”,Redis Documentation,访问:2026-09-21。

[12] Redis,“Redis Streams”,Redis Documentation,访问:2026-09-21。

[13] Apache Kafka,“Message Delivery Semantics”,Apache Kafka Documentation,访问:2026-09-21。

[14] Alibaba,“Sentinel 介绍”,Sentinel 中文文档,访问:2026-09-21。

[15] Alibaba,“热点参数限流”,Sentinel 中文文档,访问:2026-09-21。

[16] Apache RocketMQ,“顺序消息”,Apache RocketMQ 中文文档,访问:2026-09-21。

[17] Apache RocketMQ,“消费重试”,Apache RocketMQ 中文文档,访问:2026-09-21。

[18] Apache RocketMQ,“消息发送重试和流控机制”,Apache RocketMQ 中文文档,访问:2026-09-21。

[19] 周志明,《凤凰架构:构建可靠的大型分布式系统》,访问:2026-09-21。

[20] Unmesh Joshi,“Idempotent Receiver”,Patterns of Distributed Systems,Martin Fowler,2023。