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

可靠系统设计与电商架构

System Design: Reliable Systems and E-Commerce Architecture

这是一本面向中高级工程师、技术负责人和架构师的系统设计实践书。全书围绕“如何设计能够长期运行的复杂系统”展开,覆盖系统设计方法论、生产可靠性、数据一致性、长流程治理,以及商品、库存、营销、计价、订单、支付和履约等电商核心领域。

本书不止讨论如何画出架构图,更关注系统在真实生产环境中如何面对流量增长、故障恢复、数据不一致、业务变更、对账补偿和资损风险,帮助读者建立从问题定义、边界划分、数据建模、架构权衡,到上线治理、故障复盘和持续演进的完整系统设计方法。

本书强调三个判断:

  • 系统设计首先是业务边界、数据事实和失败语义的设计,不只是组件堆叠。
  • 架构方案必须能经受生产环境的流量、故障、变更、对账和资损风险考验。
  • 电商系统是理解复杂业务系统的高密度样本,商品、库存、营销、计价、订单、支付和供应商同步能覆盖大量通用架构问题。

适合读者

  • 已有后端开发经验,希望系统化提升架构设计能力的工程师。
  • 正在负责业务系统拆分、重构、稳定性治理或技术方案评审的技术负责人。
  • 希望通过电商场景理解交易链路、一致性、对账、补偿和资损防控的读者。

本书结构

  1. 系统设计方法论(第 1-9 章):问题定义、方案设计、业务边界、内部结构、系统集成、架构质量与编码落地。
  2. 生产治理(贯穿第 3、4、7 章和第 11-15 章):覆盖可靠性、对账补偿、DLQ、故障恢复、资损防控、容量规划、限流、熔断和降级。
  3. 电商系统设计实战(第 10-15 章):电商全景、商品供给、库存、营销与计价,以及从商品发现到履约售后的 C 端交易链路。
  4. 附录基础资料(附录 A-C):术语表用于统一概念,参考文献与外链支持继续深入,工具与构建说明用于本地阅读和维护。

全书逐章速览

第一部分:系统设计方法论

章节主题内容速览阅读收获
第 1 章系统设计与架构从系统设计的核心问题、容量成本权衡和一致性取舍出发,把架构判断组织成可评审、可执行、可复盘的技术方案。建立从问题定义到 TD 落地的设计表达能力。
第 2 章编码、重构与 Code Review把架构意图落到代码边界、重构节奏和评审标准中,讨论如何避免 Controller、Service、DAO 与外部依赖混杂。用代码结构和 Review 持续守住系统可演进性。
第 3 章生产系统治理以治理、保障和技术债务的反馈环为主线,拆解 SLO、错误预算、韧性设计、应急保障和还债机制。能把稳定性、债务和交付速度放进同一套治理框架评估。
第 4 章大事务处理从电商创单切入,比较同步 RPC、Saga、TCC、Outbox 和补偿机制,说明跨系统副作用如何收敛。设计可恢复、可补偿、可审计的大事务链路。
第 5 章长生命周期业务流程区分大事务和长流程,围绕订单、商品、退款、入驻和审批等场景展开状态机、编排、恢复与治理。掌握业务对象跨阶段推进的状态建模和恢复设计。
第 6 章任务处理与 Agent 协作从短任务、长任务、后台任务到 Agent 协作,梳理任务生命周期、调度、幂等、重试、可观测和人机协同边界。为异步任务和长任务系统建立清晰的执行与治理模型。
第 7 章高准确性与强一致性设计聚焦支付、库存和账务等不能只靠最终一致性口号兜底的场景,强调权威事实、CP 核心段、幂等和对账。能优先保护业务事实,并为强一致链路设计吞吐边界。
第 8 章低延迟复杂读面向搜索、推荐、广告和 Feed,分析读写不对称、复杂查询、结果质量和性能耦合下的读模型与缓存策略。设计高性能读路径,并说明一致性、相关性和延迟取舍。
第 9 章高并发写入与热点治理以秒杀、社交互动和流量洪峰为代表,讨论削峰、排队、热点隔离、快反馈和后端异步收敛。能识别极端写入热点,并设计抗洪峰的写链路。

第二部分:电商系统设计实战

章节主题内容速览阅读收获
第 10 章电商系统全景从业务架构、应用架构、数据架构和技术架构拆解电商核心域与支撑域,建立交易与支付主线。用全景图识别电商系统边界、依赖和核心风险。
第 11 章商品中心、商品供给与生命周期治理合并商品主数据、供给入口、批量任务、审核、库存协同、发布一致性和生命周期治理,建立从来源输入到交易快照的端到端模型。设计可恢复、可审计、可演进的商品供给与商品中心平台。
第 12 章库存系统讨论库存的通用模型、虚拟库存、可售锁定已售、并发扣减、超卖防控、释放和对账补偿。区分热路径和权威路径,设计高并发且最终正确的库存系统。
第 13 章营销与计价系统合并优惠券、积分、活动规则、预算控制、资格校验、营销库存、价格层、会员权益、税费和运费,覆盖试算、锁定、核销、释放、复算和大促降级。设计可叠加、可核销、可解释、可复算且能控制资损的营销与计价链路。
第 14 章商品发现与交易准备从搜索、详情和动态报价进入购物车与结算,说明读模型一致性、交易前校验和资源预占。设计低延迟的商品发现链路,并把购买意愿收敛为有期限、可校验的结算凭证。
第 15 章订单与履约全生命周期从创单开始,展开订单状态机、支付事实、履约编排、售后退款和生产治理。设计可追溯、可恢复的订单、资金与交付闭环。

附录

条目主题内容速览阅读收获
附录 A术语表汇总架构、一致性、可靠性与电商概念,区分术语定义和工程目标。查阅全书共同语言与能力边界。
附录 B参考文献与外链索引经典著作、原始论文和组件官方文档。按主题继续阅读并核验版本敏感行为。
附录 C工具与构建说明说明 mdBook、Mermaid 和本地构建入口。构建与预览书稿。

阅读路线

  • 架构主线:第 1-9 章,配合第 10-15 章电商实战。适合希望建立完整系统设计框架的读者。
  • 电商实战主线:第 10-15 章;可按“商品发现与结算”(第 14 章)和“创单、支付与履约”(第 15 章)连续阅读。
  • 可靠性主线:第 3、4、7 章,结合第 15 章订单、支付和履约闭环阅读。适合做稳定性、故障恢复和资损防控治理。
  • 基础补齐主线:附录 A-B,配合各章参考资料。先统一术语,再按当前问题查阅原始文献和组件官方手册。

使用建议

读这本书时,不建议只记结论。更好的方式是每读完一个系统章,都回答四个问题:

  1. 这个系统的权威数据是什么?
  2. 它负责什么,又明确不负责什么?
  3. 它和上下游如何协作,失败后由谁补偿?
  4. 它的监控、对账、灰度、回滚和资损防控怎么设计?

如果能把这四个问题说清楚,就已经从“功能设计”进入了“系统设计”。

本地构建

需安装 mdBook。本书复用 tools/mermaid-preprocessor.py 处理 Mermaid 图表。

cd books/reliable-system-design
mdbook build
mdbook serve

也可以在仓库根目录生成到 Hexo 本地预览目录并启动服务:

npm run server:reliable-system-design

启动后访问:

http://localhost:3000/reliable-system-design/

第 1 章 系统设计与架构:从业务建模到工程落地

系统设计不是从选择组件开始,而是从理解业务、定义边界和识别约束开始。本章建立从问题定义、技术方案、架构组织到评审演进的工作链路;后续章节再分别展开编码、分布式一致性、可靠性和电商实战。

面对“做订单系统”“提升并发”“拆微服务”这类需求,最常见的误区是立刻讨论数据库、缓存、消息队列和服务数量。组件只能回答“怎么实现”,不能回答“为什么这样实现、由谁负责、失败后怎样收敛”。架构的价值在于:在业务目标、质量属性、团队能力和成本约束之间,做出可以解释、验证和演进的决策。[1][3]

本章以一条工作链路组织内容:

问题与约束 → 设计画布 → TD 与决策记录 → 业务边界 → 内部结构
→ 系统协作 → 架构表达与评审 → 运行反馈与持续演进

其中,六维画布、8 段式 TD 和四阶段评审是本书为日常工程协作归纳的工作框架;DDD、Clean Architecture、CQRS、Outbox、Saga 等模式则是应对不同问题的工具,不是默认答案。

1.1 系统设计与工程决策

从问题开始,而不是从组件开始

系统设计首先要定义问题。一个完整的问题定义至少包含四件事:谁在什么场景下遇到了什么困难;业务希望改善什么结果;哪些条件不可违反;哪些事情本次明确不做。没有这些信息,“高可用”“低延迟”“支持百万用户”都只是缺少上下文的口号。

例如,“提升下单性能”可能对应完全不同的问题:高峰期接口 P99 过高、库存热点导致大量失败、支付回调重复、运营活动成本超标,或客服无法解释订单状态。它们可能分别需要排队削峰、库存分片、幂等设计、容量治理或状态机与审计,而不是统一地“加缓存”。

维度要问的问题最低产出
业务结果成功意味着什么?哪个用户动作最重要?目标、核心场景、成功指标
范围边界本次负责什么,不负责什么?非目标、上下游责任
负载与容量峰值在哪,读写比例怎样,增长速度如何?数量级估算、容量假设
质量属性延迟、正确性、可用性、成本和可演进性如何排序?架构驱动因素
现实约束团队、预算、合规、历史系统和上线窗口限制是什么?风险与依赖清单

这张表的目的不是一次写全,而是把隐含假设公开化。设计讨论中最值得争论的往往不是“Kafka 还是 RabbitMQ”,而是“允许多长时间的最终一致”“库存扣减失败谁来补偿”“是否必须保留可追溯事实”。

用数量级建立共同现实

容量估算不追求伪精确,而是防止方案脱离现实。对核心链路,至少估算请求量、峰谷比、读写比例、单次请求的数据量、存储增长、可接受延迟和可接受错误率:

全天平均 QPS = 日请求量 ÷ 86,400
繁忙窗口平均 QPS = 该窗口请求量 ÷ 窗口持续秒数
设计秒级峰值 = 实测秒级峰值,或窗口平均 QPS × 明确的突发系数假设
日增数据量 ≈ 日写入次数 × 单次有效载荷

窗口内请求量除以窗口时长只能得到窗口平均,不能直接得到瞬时峰值。以下全部是教学假设:每天一千万次请求,20% 集中在两小时内;假设这个窗口的秒级突发系数为 4,一个热点键承接峰值流量的 30%。

口径推导结果
全天平均10,000,000 ÷ 86,400约 115.7 req/s
两小时窗口平均10,000,000 × 20% ÷ 7,200约 277.8 req/s
设计秒级峰值277.777… × 4约 1,111.1 req/s
热点键基线1,111.111… × 30%约 333.3 req/s

突发系数不是行业标准,上线前应通过秒级采样和压测校准,并说明峰值持续多久。若某类操作因重试增加 20% 尝试次数,只对这一类调用按 1.2 倍预算;不要让库存明细、批量查询和其他下游都机械套用入口 QPS。并发数还取决于处理时间,不能把请求速率、库存操作速率和连接数混为一个指标。

公式的价值在于暴露假设,而不是制造未经验证的容量上界。一条业务事件若需要保留七年,存储、索引、冷热分层和审计成本也必须在方案阶段讨论。容量应围绕实际负载、服务目标与增长预期持续校准,而不是只按当前机器余量决策。[2]

权衡不是妥协,而是设计本身

可靠、可扩展、可维护是数据密集型系统的长期目标,但它们不会自动同时达到。[1]

目标冲突需要做的判断常见处理方式
强一致性与可用性用户是否必须立即看到唯一正确结果?缩小强一致事务边界,其余采用最终一致与对账
低延迟与计算正确性能否使用预计算结果?缓存、物化视图、异步化,并定义失效与回源策略
快速交付与长期演进当前复杂度是否真的值得?先模块化单体,保留边界和迁移路径
资源成本与峰值体验峰值是常态还是偶发?弹性扩容、排队、限流、降级与容量预留

好的方案会写出取舍,而不是宣称“既高可用、又强一致、又低成本、还零复杂度”。当某项质量属性被优先保障时,也要明确它带来的代价、适用范围和重新评估的触发条件。[3]

从单机思维切换到分布式思维

单机程序中,一个函数调用通常意味着同一进程、同一事务和明确的成功或失败;分布式系统中,网络会超时、消息会重复、节点会部分失效、时钟并不可靠。因而设计需要先回答:

  • 请求超时后,调用方能否确认服务端没有执行?
  • 同一个命令被重试时,业务结果会不会重复?
  • 本地成功、远端失败时,谁拥有最终收敛责任?
  • 用户看到“处理中”时,系统如何告知最终状态?

这些问题使失败路径与正常路径同等重要。不要把分布式设计理解成服务越多越先进;服务拆分只有在业务边界、团队责任、独立扩展或故障隔离的收益足以覆盖网络复杂度时才值得采用。

用质量属性场景校准讨论

“系统要高性能”无法直接指导设计。更有效的表达是把质量要求写成可观察的场景:在什么条件下,哪个刺激作用于哪个部分,系统应如何响应,如何测量结果。例如“活动开始后的十分钟内,订单创建接口 95% 请求在 300 ms 内返回受理结果;库存依赖故障时,不得重复下单,并在一分钟内将请求转为可查询的处理中状态”。这样的表述把性能、可用性、正确性和恢复行为放进同一个可测试的边界。[3]

质量属性场景还可以防止团队在不同层次争论同一个词。产品关心的是用户何时得到明确结果,研发关心的是同步链路和资源上限,运维关心的是告警与恢复窗口;把它们写成一个场景后,才有可能设计出共同接受的指标、降级行为和验收测试。

需求澄清:把一句话需求拆成可验证的设计输入

真实项目很少以完整的技术问题开始。更常见的表达是“把订单系统做成平台”“支持多渠道库存”“让结算更快”,这些句子同时混合了业务愿望、解决方案暗示和组织目标。直接把它们翻译成服务、表和接口,容易在还没有确认问题之前就锁定实现。需求澄清的任务不是替业务方写一份更长的需求说明,而是把一句话拆成可以共同验证的设计输入。

可以先连续追问五个问题:谁遇到了问题;在什么业务事件发生时最明显;当前损失是什么;成功结果由哪个事实证明;本次明确不处理什么。以“支持多渠道库存”为例,真正需要确认的可能是渠道库存展示不一致、库存分配规则无法配置、渠道订单回传延迟,或者仓库无法解释锁定库存。它们的事实源、时效目标、责任团队和补偿路径都不相同。

需求信息需要澄清的内容常见误判
用户与角色谁发起动作,谁接收结果,谁处理例外把调用系统当成唯一用户
业务事件什么事实发生后系统必须改变把页面按钮当成业务事件
结果与证据成功由什么状态、凭证或指标证明只把接口返回 200 当作成功
时间约束哪些结果必须同步,哪些允许异步收敛把所有动作都塞进一次请求
责任与边界谁拥有事实,谁可以修改,谁负责补偿用共享表绕过责任划分
非目标当前版本不做什么,未来如何接入把所有潜在需求都纳入首版

中文工程实践中对 DDD 的总结也反复强调,模型的价值在于表达业务事实,而不是把技术分层换一套名称。[14][15] 因此,需求澄清应当产出术语表、业务事件时间线、目标与非目标、初步指标和争议问题清单,而不是先产出一张“微服务架构图”。这些产出会成为后续 TD、上下文地图和验收测试的共同输入。

常见误区:把架构问题误写成组件问题

系统设计初期有几类错误非常稳定。第一类是“先选技术”:因为团队熟悉某个数据库、消息系统或云产品,就把业务问题改写成它能解决的问题。第二类是“先拆服务”:把每个名词都做成一个服务,却没有定义数据主权、失败语义和独立运维能力。第三类是“只画正常链路”:架构图上只有成功箭头,超时、重复、乱序、回滚和人工处理被留到上线后再补。第四类是“用平均值替代尾部”:平均延迟很好看,但 P99、排队时间和依赖失败时的响应才决定用户体验。第五类是“把历史兼容当作永恒事实”:为兼容旧接口不断增加旁路,最终没有任何团队敢删除旧路径。

识别误区后,不能只给出相反口号。例如“不要微服务”并不能替代边界分析;“全部异步化”也不能解决用户必须立即知道结果的场景。更有效的做法是把每个争论改写成可验证的问题:该组件消除了哪个瓶颈?服务拆分后谁拥有数据和告警?异常路径如何收敛?新旧接口何时可以停止兼容?如果答案只能依赖“以后再补”,说明方案还没有达到可评审状态。

1.2 技术方案设计与 TD

TD 的目的:让决策可评审、可执行、可追溯

技术方案(Technical Design,TD)不是把需求换成架构图,也不是为了在评审会前制造一份长文档。它的作用是让团队在编码前对目标、边界、备选方案、风险和验证方式形成共同理解。优秀的 TD 让读者知道:要解决什么、为什么选择此方案、谁需要配合、失败后怎么办,以及上线后如何证明设计有效。

对影响边界、数据模型、接口契约、容量或发布方式的变更,TD 通常值得投入。小范围、可逆、无需跨团队协作的改动,可以在 Merge Request 中说明背景与验证即可。ADR 适合记录一项已经做出的关键决策及其后果;它应当短小、不可随意改写,并在决策被替代时链接到新的记录。[4]

设计阶段:六维画布

在写 TD 前,先完成一轮显式设计。六维画布不是标准规范,而是本书建议的思考顺序:它避免团队只讨论技术选型,却漏掉边界、数据、失败和运行现实。

维度核心问题最低产出
目标与边界解决谁的什么问题?不解决什么?目标、非目标、成功指标
约束与容量负载、时延、预算、合规和遗留约束是什么?假设、数量级、架构驱动因素
核心链路从触发到结果,状态如何变化?主流程、异常流、状态机
职责与数据谁拥有事实、谁可以修改、谁只消费?上下文、数据所有权、接口边界
方案与取舍有哪些可行方案,为什么选这一种?方案对比、ADR、迁移路径
风险与运行怎样灰度、观测、回滚和补偿?风险表、指标、发布计划

一张带假设的容量表、一条主链路时序图、一张上下文图和一份风险清单,通常比几十页组件介绍更能帮助评审。信息不完整时,应把未知项列为待验证假设,而不是用“后续再看”掩盖它。

8 段式 TD 骨架

完成画布后,可以按下列顺序组织 TD。顺序对应评审者理解方案的路径:先判断值不值得做,再判断是否做对,最后判断能否安全落地。

段落回答的问题
1. 背景与问题当前痛点、触发事件和业务影响是什么?
2. 目标与非目标成功标准和明确不覆盖的范围是什么?
3. 约束与容量负载、SLO、成本、合规、遗留依赖是什么?
4. 核心流程与边界主链路、状态、数据所有权和职责如何划分?
5. 方案与备选项选择了什么,放弃了什么,取舍依据是什么?
6. 接口、数据与一致性API、事件、存储、幂等和异常语义是什么?
7. 风险、发布与回滚依赖、灰度、监控、降级、补偿和回滚如何执行?
8. 验收与待决项如何验证效果,哪些假设仍需确认?

简单需求可以合并段落,跨系统变更则必须把接口、异常路径和发布计划写清楚。关键是让文档与决策复杂度匹配,而不是把模板当成官僚流程。

一个最小 TD 的推导示例

以“订单创建后异步通知履约”为例,需求表面上只是增加一个通知动作。若不先做设计,它很容易演化为订单服务在数据库事务里直接调用履约接口:接口慢时拖慢下单,接口异常时订单是否应回滚没有答案,重试又可能重复创建履约单。

用六维画布推导后,问题会被改写得更准确:订单创建必须先稳定落库;履约接收可以异步,但事件不得丢失;重复通知不能重复履约;用户需要能查询订单是否已进入履约流程。相应的 TD 不需要长篇代码,只需明确以下决策:

TD 段落此例的关键结论
目标与非目标保证订单事实先落库;本次不承诺履约即时完成
边界与数据订单服务拥有订单状态;履约服务拥有履约单与履约状态
方案与取舍使用 Outbox 发布订单已创建事件,接受短暂的异步延迟
一致性与异常事件按至少一次投递;履约侧以订单号或业务键幂等处理
发布与验收监控 Outbox 积压、投递失败和订单到履约单的时延;定期对账

这个例子说明,TD 的价值不在于提前规定每个类和每张表,而在于提前暴露“同步还是异步”“谁拥有事实”“失败怎样收敛”这些会影响长期成本的问题。实现细节可以在编码阶段调整,边界和验收标准则应在开发前取得一致。

决策记录、评审与交付

技术评审应围绕事实和选择展开。评审者不应只问“图画得是否完整”,还应挑战:目标是否能通过指标验证;关键假设不成立时方案会怎样失效;数据所有权和接口责任是否一致;是否存在更简单、可逆或更容易迁移的方案;灰度、回滚、补偿和人工处理是否真的可执行。

建议把有长期影响的选择抽成 ADR,例如“订单域采用读写分离”“库存预占采用异步确认”。ADR 不替代 TD:TD 解释一次完整变更,ADR 固化其中值得长期记住的关键取舍。[4] TD 的最后一页也不应停在“待开发”,而要转化为接口契约、迁移步骤、验收指标、负责人、依赖方、灰度开关和回滚预案。

当需求仍有较大不确定性时,可以把方案拆成“先验证、再扩展”的两步:先用最小实现验证业务规则、流量假设或用户行为,再决定是否投入完整的平台化能力。TD 要写清第一步能够证伪什么、达到什么阈值才进入第二步。这样既避免把探索性需求过度架构化,也避免在关键假设未验证前锁死后续选择。

1.3 架构设计核心方法

技术方案并非一次性文档:当关键假设、边界、依赖或发布策略变化时,应同步审阅相关 TD 与 ADR;过期文档比缺少文档更容易误导后续决策。

文档更新不必追求重写全文,但必须能让后来者找到仍然有效的决策、已被推翻的假设,以及下一次评审应重点检查的风险。

一套递进的组合拳

复杂业务系统不需要先选择“DDD 项目”或“Clean Architecture 项目”。更有用的顺序是:先用领域建模划清业务边界,再用依赖规则建立内部秩序;当读写特征显著不同,再考虑 CQRS;当一次业务动作跨越多个边界,再用事件、Outbox、Saga 和对账建立协作机制。每一层都应由实际复杂度触发。

业务复杂度高      → DDD 战略设计:术语、子域、限界上下文、ACL
内部依赖失控      → 分层与依赖向内:端口、用例、领域模型、适配器
读写模型冲突      → CQRS:命令模型与查询模型按需分离
跨边界事务复杂    → 事件、Outbox、Saga、幂等、补偿与对账
设计持续失真      → 架构/设计/代码/上线前四阶段评审

先划业务边界:DDD 战略设计

许多大泥球系统的根因不是类写得不够优雅,而是所有概念都被假定为同一个模型。例如商品在供给侧可能表示可编辑的资料,在交易侧表示下单快照,在搜索侧表示面向检索的文档;强行共享一个对象,只会让每个团队都被其他场景的字段和节奏牵制。

领域驱动设计强调从领域语言与模型开始处理复杂性。[5] 在战略层面,最重要的产出是:

  • 通用语言:业务、产品、研发和运营对关键术语有可追溯的一致定义。
  • 限界上下文:一个模型在哪个边界内成立;同名概念在不同上下文可以有不同含义。
  • 上下文映射:上下文之间如何协作,谁是上游,谁维护契约,哪里需要防腐层(ACL)。

以交易为例,商品、库存、订单、支付可以先作为不同上下文,而不是急于拆成四个独立服务。先明确谁拥有价格快照、谁决定库存可售、谁维护订单状态、谁确认支付事实,再决定它们以模块、进程还是独立服务的形式部署。限界上下文首先是模型和责任边界,微服务只是可能的部署结果。

事件风暴、领域故事和四色建模可以帮助发现事件、命令、角色、规则与生命周期;但它们是协作手段,不是必须照抄的图形语言。一次有效的建模工作坊至少应产出一条业务事件时间线、争议术语清单和初版上下文地图。

从模型边界到部署边界

模型边界并不自动等于进程边界。把限界上下文立即拆成微服务,往往会过早引入远程调用、分布式追踪、独立发布和跨服务一致性等成本;反过来,把所有上下文长期塞进同一模块,也会让模型和团队责任重新混杂。更稳妥的做法是把部署视为一个后续决策,并先建立可验证的模块边界。

一个上下文先以模块化单体落地,通常已经能获得不少收益:目录和依赖清晰、数据访问受控、测试可以围绕用例运行、团队能够明确模块负责人。只有在下面的信号持续出现时,才认真评估拆分:某个边界需要独立扩缩容或独立故障隔离;变化节奏与发布窗口长期不同;数据主权与安全要求必须隔离;团队已具备独立构建、部署、观测和 on-call 的能力。

判断维度适合先保持模块化单体需要评估独立部署
业务变化规则仍高度耦合,需求常跨多个上下文联动某个能力有稳定目标和独立变化节奏
团队协作同一团队能端到端负责团队边界稳定,能够独立交付与运维
数据与流量读写负载相近,仍可在同一数据域治理热点、合规或数据主权要求明显不同
运行风险进程内隔离已满足可用性目标故障、资源争抢必须被强隔离

这个判断避免把“微服务化”当成成熟度标志。架构演进的目标是降低未来变更的总成本,不是增加可展示的组件数量。即使最终决定拆分,也应先定义契约、迁移数据、保留兼容窗口,再逐步切换流量;不要把一次组织调整或技术潮流变成不可逆的大爆炸改造。

从模块化单体走向独立部署,通常可以按“封装、观测、复制、迁移、切流、删除”的顺序推进。先封装模块的入口和数据访问,阻止新增跨模块直连;再补齐调用链、业务指标和所有权;随后为候选边界复制出独立实现或影子读模型;通过双写、回放或校验任务验证结果;最后用小比例流量切换,并在兼容窗口结束后删除旧路径。这里的双写不是天然正确的方案,它会制造新旧数据漂移,因此必须配套版本号、校验任务和停止双写的退出条件。渐进式替换旧实现的思路,也可参考绞杀者模式对兼容层、切流和删除旧路径的总结。[19]

如果拆分只是为了追赶技术潮流,迁移成本往往高于收益。只有当边界能够独立变化、独立扩展、独立隔离故障或满足明确的数据治理要求时,才值得承担远程调用、部署、监控和一致性成本。AWS 的领域服务设计建议也把“围绕业务领域和能力聚焦”作为服务边界的重要依据,同时提醒团队不要让服务跨越多个业务边界。[17]

拆分后的第一项验收也不应是“服务已经启动”,而应是边界是否真的独立:调用方是否只能通过契约访问能力,是否仍有跨库读写,失败是否被限制在可接受范围,发布与回滚是否不再依赖所有团队同步操作。若这些条件尚未满足,拆分只是把进程内耦合换成了网络耦合。

再建内部秩序:依赖方向比目录名称更重要

传统三层架构擅长回答“接口、业务和数据访问代码放在哪里”,适合大多数业务系统起步。随着业务规则、外部依赖和测试要求增长,真正需要约束的是依赖方向:核心业务不应依赖 HTTP、数据库、消息队列或具体框架,而应由外层适配这些技术细节。

Clean Architecture 的价值正在于此:让依赖朝向稳定的业务规则和抽象接口,而不是让领域模型被基础设施吞没。[6]

层次应承担的职责不应承担的职责
Interface / Adapter协议转换、鉴权、参数校验、响应适配业务规则和 SQL 编排
Application / Use Case用例编排、事务边界、调用端口直接绑定数据库或消息中间件
Domain实体、值对象、聚合、不变量和领域服务HTTP、ORM、缓存和远程调用细节
Infrastructure数据库、缓存、MQ、第三方服务实现决定核心业务规则

对于复杂领域,DDD 的战术模式帮助代码承载模型:实体表达身份,值对象表达无身份的业务概念,聚合守护一致性不变量,Repository 隔离持久化细节。聚合的关键不是“把关联对象都装进来”,而是定义一次业务事务中必须共同保持正确的最小边界。跨聚合引用通常使用标识符;跨边界协作则通过明确的命令、查询或事件完成。[5]

一个订单聚合可以守护“已取消订单不能支付”“应付金额不可为负”等本地不变量;它不应直接承担调用支付渠道、更新搜索索引或通知营销系统。这些动作属于用例编排或异步协作。

CQRS:只在读写矛盾真实存在时分离

命令查询职责分离(CQRS)允许写入模型和读取模型使用不同的数据结构。它适合写侧需要严格业务规则、读侧需要多维查询或高并发展示的场景,例如订单写入要守护状态机,运营查询却需要按用户、商品、渠道、时间和状态聚合。

但 CQRS 会引入投影延迟、数据同步、回放、监控和排障成本。Martin Fowler 也特别提醒:它对少数复杂场景有价值,对大多数系统却可能增加风险性复杂度。[7] 因而选择前应回答:单一模型是否已经无法同时满足写入正确性和读取性能?读模型多久同步一次,用户是否能接受该延迟?投影失败、重复消费和重建时谁负责修复?团队是否具备观测和运营两套模型的能力?

如果答案不充分,先保持同一模型或采用轻量的读库、缓存和预计算通常更好。CQRS 不等于事件溯源;前者是职责分离,后者是把事件作为状态事实的存储方式,两者可以组合,也可以独立采用。

一个贯穿式案例:从“订单平台化”推导出可交付方案

下面用一个简化案例说明方法如何串联。假设业务提出“把订单系统平台化,支持多个销售渠道,并在大促时保持稳定”。如果直接进入技术选型,讨论很快会变成“是否上微服务、消息队列选什么、订单表是否分库”。先按本章工作链路拆解,才能知道哪些设计问题真正重要。

第一步是定义范围。订单平台负责接收下单意图、保存订单事实、维护订单生命周期,并向库存、支付、履约和营销上下文发布稳定事件;它不负责库存可售计算、支付渠道路由和仓库内部作业。第二步是写出约束:创建请求需要在 300 ms 内返回受理结果,订单事实不能因下游暂时不可用而丢失;库存和支付可以在后续阶段收敛,但用户必须能够查询处理中、成功或失败的明确状态。第三步是画出状态机:待确认 → 已创建 → 待支付 → 已支付 → 履约中 → 已完成,并列出取消、超时和人工介入等旁路状态。

设计问题可交付的判断需要验证的指标
订单事实由谁拥有订单上下文拥有订单号、订单行快照和订单状态重复请求是否返回同一订单结果
下游如何协作本地事务写订单与 Outbox,消费者按业务键幂等Outbox 延迟、重复消费和未收敛订单数
高峰如何保护入口准入、排队和优先级保护写入事实受理延迟、排队深度、拒绝率
支付未知结果怎么办保留处理中状态,通过查询和回调收敛未知结果持续时间、对账差异
新旧渠道如何迁移契约适配器隔离渠道差异,按渠道灰度新旧结果差异、回滚时间和错误率

这个案例中,微服务不是第一项决策。先确定订单事实和生命周期,才能判断订单是否需要独立部署;先确定受理结果和最终结果的区别,才能决定哪些调用同步、哪些事件异步;先定义重复请求和未知结果,才能评估幂等键、状态查询和对账。最终方案可能是模块化单体,也可能是多个服务,但它们都应能通过同一组业务事实和验收指标被验证。

架构评审时可以要求团队走完三条路径:正常下单、支付超时、库存服务不可用。每条路径都要指出状态如何推进、哪个组件写入事实、哪些消息会重试、用户看到什么、谁负责最终修复。如果只能讲清正常路径,说明方案还没有把业务状态和运行治理真正设计出来。

系统协作:把一致性设计成一条链路

跨上下文业务动作很难用一个数据库事务保证。例如创建订单后要预占库存、确认支付、发放权益;任何一步失败,都可能出现局部成功。此时最重要的不是“有没有分布式事务”,而是明确状态、消息、补偿和对账责任。

本地事务:更新业务状态 + 写入 Outbox 事件
        ↓
Relay 可靠投递事件(允许至少一次)
        ↓
消费者以幂等键处理,更新自己的本地事实
        ↓
失败时重试、补偿或转人工;定期对账发现遗漏与漂移

Transactional Outbox 将业务更新和待发布消息写入同一事务;独立 Relay 再向消息系统投递。它避免了“数据库已提交但事件尚未发出”的窗口,但仍可能重复投递,因此消费者必须具备幂等能力。[8] Saga 将跨服务长事务拆成一组本地事务;失败时执行显式补偿,而非期待全局自动回滚。[8]

设计时必须写清:幂等键来自哪里、重复请求返回什么、补偿是否真的可逆、消息乱序如何处理、对账依据哪个事实源、人工介入后的状态如何回写。对账不是失败的象征,而是最终一致系统中验证和修复业务事实的最后防线。

把容量与韧性写进设计,而不是上线后补救

一致性方案如果没有容量和故障设计,通常难以在生产环境中成立。Outbox 表会积压、消费者会落后、重试会形成流量放大、下游故障会反向拖垮上游。因此,在 TD 中至少应说明核心队列或事件流的正常积压水位、可接受恢复时间、重试上限、死信处理方式和人工介入入口。

风险设计时要明确的策略验证信号
流量突增限流、排队、背压、优先级和降级结果拒绝率、排队时长、核心链路成功率
下游不可用超时、熔断、隔离和延迟重试超时比例、熔断状态、重试放大倍数
消息积压消费扩容、限速、重放和死信处理消费延迟、积压深度、死信数量
数据漂移对账任务、审计事实源、修复流程不一致数量、修复时长、重复发生率

容量规划不是只算机器数量,而是为关键业务状态留出缓冲。降级也不是简单返回错误:对下单这类链路,可能是返回“已受理、处理中”;对查询类链路,可能是返回带时间戳的旧数据;对非关键功能,则可以暂时关闭。每一种降级都必须被产品、客服和运营理解,否则技术上成功保护了系统,业务上却制造了不可解释的用户体验。

用分阶段评审防止设计失真

只在 Pull Request 阶段做 Code Review 往往太晚:聚合边界、服务职责或一致性路线若有问题,代码写完后才发现会让返工成本急剧上升。本书建议把评审前移为四个阶段:

阶段主要问题代表检查项
架构评审方向是否正确目标、边界、质量属性、方案取舍、演进路径
设计评审模型和协作是否自洽数据所有权、聚合、接口、事件、异常路径
Code Review实现是否兑现设计依赖方向、命名、错误语义、测试、复杂度
上线前检查运行风险是否可控容量、监控、灰度、回滚、补偿与值守

代码评审仍然非常重要:Google 的公开指南将设计、功能、复杂度、测试、可读性和长期代码健康视为评审重点,而不是只检查格式。[12] 但评审者不应试图在 PR 中重新设计整个系统。若发现架构问题,应把讨论升级为设计决策,再回到实现。

四个阶段的边界并不意味着四次冗长会议。低风险改动可以把架构与设计判断浓缩在 TD 或 MR 描述中,高风险改动才需要专门评审。关键在于问题要在仍然容易改变的阶段被提出:边界问题在编码前发现,错误语义在合并前发现,容量与回滚问题在发布前发现。这样,评审不是减慢交付的关卡,而是减少返工、事故和跨团队沟通成本的反馈机制。

1.4 架构表达与评审

图不是装饰,而是不同层次的共同语言

架构图的目标不是把系统所有组件塞进一张图,而是帮助特定读者在特定问题上做判断。C4 Model 以系统上下文、容器、组件和代码等层次组织静态结构,并补充动态与部署视图;团队不必每次画齐所有层次,只画能增加决策价值的视图。[9]

受众与问题优先视图图上必须表达
业务、产品、管理者:范围与收益上下文图、能力图、主流程图用户、边界、外部依赖、业务结果
架构师、开发、测试:方案是否可行容器图、组件图、时序图职责、协议、数据流、失败与降级
运维、安全、SRE:如何运行与恢复部署图、流量图、故障演练图拓扑、容量、隔离、观测、回滚
新成员:从哪里理解系统上下文图 + 主链路时序图术语、入口、核心模块、阅读顺序

静态图负责定边界:谁拥有数据、谁依赖谁、哪些模块可替换。动态图负责拉主轴:一次请求或事件如何穿过系统、何处同步等待、何处异步交接、失败怎样处理。图中的箭头必须有语义,例如 HTTP 查询、发布领域事件、读取只读投影,而不是只画无标签连线。

让图、文档和代码相互校验

一张图不能替代设计文档,设计文档也不能替代代码;三者应形成可相互验证的关系。上下文图中的边界应能在 TD 的职责与数据所有权部分找到解释;时序图中的每一次跨边界交互应能在 API 或事件契约中找到定义;代码中的模块依赖应不违背容器图和 ADR 的关键选择。发生偏差时,应先判断是实现偏离了设计,还是新的事实证明原设计需要调整,而不是让图、文档和代码各自演化。

对长期维护的系统,图也应有生命周期。核心上下文图、主链路时序图和部署图应跟随重大边界、协议和运行变化更新;临时讨论图可以在决策沉淀后归档。这样既避免把画图变成额外负担,也避免新成员只能从过期文档或源码猜测系统意图。

让图能够经受挑战

评审中的图至少应让读者回答四个问题:这张图的范围和读者是谁?每个节点负责什么,数据由谁拥有?每条连接是同步还是异步,失败时会发生什么?图上的设计选择如何对应质量目标和监控指标?

若无法回答,通常不是图画得不够漂亮,而是边界、协议或异常语义尚未想清楚。C4 的图表检查清单也强调标题、范围、图例、元素语义和关系标签的明确性。[9]

评审是一种协作机制

高质量评审不等于找到最多问题,而是让团队以最低成本提升决策与代码质量。评审材料应在会前提供;会上优先讨论有分歧的假设、不可逆决策和高风险链路;会后记录结论、待办和责任人。对“应该用什么模式”的争论,应回到问题:它改善了哪项质量属性,新增了什么运行成本,何时需要撤销或替换?

技术方案、ADR、架构图、测试、仪表盘和复盘记录共同构成系统的可解释性。它们不是额外负担,而是让未来维护者理解“为什么这样做”的工程资产。[4][10]

评审要覆盖主链路,也要覆盖变化路径

一次技术评审至少选择一条成功路径和两类失败路径。成功路径验证职责、数据和协议是否连贯;依赖失败路径验证超时、降级、重试和补偿是否明确;变更路径则验证版本兼容、数据迁移、灰度发布与回滚是否真的可执行。对于跨团队接口,还应确认字段新增、枚举扩展、事件重放和消费者升级的兼容规则。

评审结论不必追求“所有问题都一次解决”。更重要的是把问题分类:阻塞上线的风险必须解决;可以接受但需要监控的风险要写入 ADR 或发布计划;暂不处理的债务要说明触发重访的条件。这样,评审既不会退化为形式化签字,也不会因为追求完美而阻塞合理交付。

1.5 架构师工程思维

从局部最优转向长期价值

架构师的职责不是替所有人决定技术细节,而是在不确定性下持续改善团队的决策质量。一个看似合理的局部优化,可能把复杂度转移给下游团队、运维人员或未来维护者。因此,每个重要决策都应经过至少五个滤网:业务价值、质量属性、实现与运行成本、组织协作,以及未来变化的可逆性。

滤网关键问题
业务是否直接改善用户结果、收入、风险或效率?
技术关键性能、正确性、可用性和安全目标是什么?
运维如何观察、扩缩、降级、排障与恢复?
组织哪个团队拥有它,沟通路径是否与边界匹配?
演进变化来临时能否替换、迁移或回滚?

康威定律提醒我们,系统的沟通结构往往会反映组织的沟通结构。[13] 这不是把组织图机械映射为微服务图,而是要求设计者看见责任边界、认知负荷和交付流。若一个模块必须由三个团队同时修改,或者一个服务的运行知识只掌握在个人手里,再漂亮的模块划分也难以长期健康。

简单是管理复杂度,不是否认复杂度

奥卡姆剃刀在工程中的含义不是“永远选最少组件”,而是避免引入不能证明价值的机制。系统确实需要复杂性时,应让复杂性集中在边界清晰、可观测、可测试的位置;不需要时,应保留更简单、可理解的实现。

例如,模块化单体往往比过早微服务更适合作为起点;一张明确定义的事件契约比共享数据库更容易演进;一次可逆的灰度发布比大爆炸式重构更可控。演进式架构主张把变化视为常态,并通过适应度函数、自动化验证和渐进式迁移保护关键架构特性。[11] 遗留系统的领域模型也不应被当作一次性设计;业务认知变化后,模型、边界和实现需要通过反馈持续调整。[16]

工程思维也意味着把“谁来做”纳入设计。一个方案即使技术上正确,如果没有明确的服务所有者、值班责任、接口维护者和迁移负责人,最终仍会在组织边界处失效。架构师应帮助团队把责任写入运行手册、代码所有权和发布流程,并通过复盘持续修正;这比单次输出一份完美架构图更能提高系统的长期质量。

组织边界还会影响架构演进速度。一个需要多个团队同时修改、共同值班却没有单一责任人的模块,会产生排队、反复沟通和风险转移。反过来,盲目按组织现状拆服务也会把暂时的汇报关系固化成长期的技术边界。更稳妥的做法是先识别端到端交付流,再检查团队是否拥有完成该交付流所需的能力;如果能力缺失,可以通过平台能力、清晰的契约和短期协作降低认知负荷,而不是马上复制更多服务。团队拓扑的讨论也强调,应让团队边界服务于交付流和认知负荷,而不是让系统被组织图机械切割。[20]

用质量属性场景做取舍

ATAM 等架构评估方法的核心启发是:不要抽象地讨论“系统要高可用”,而要用具体场景表达质量属性。[3]

在大促峰值期间,订单创建接口在 95% 请求中应在 300 ms 内返回受理结果;当库存服务不可用时,系统应在 1 秒内降级为“排队处理中”,不得重复创建订单,并在恢复后完成或取消处理。

这个场景同时明确了负载、响应目标、故障条件、降级行为和正确性约束。它比“高性能、高可用”更容易指导架构、测试和监控。

ATAM 的另一个重要启发是把“质量属性场景”与“敏感点、权衡点和风险”联系起来:某个缓存策略可能改善延迟,却增加数据新鲜度风险;某个同步校验可能改善正确性,却压缩高峰吞吐;某个服务拆分可能改善独立发布,却增加跨边界故障。评审不应只列出属性名称,而要指出哪项设计同时影响了多个目标,以及将用什么实验或指标验证它。SEI 对 ATAM 的正式说明也把场景、架构决策和权衡分析放在同一个评估过程中。[18]

架构师要留在反馈闭环里

架构设计不会在评审通过时结束。上线后的指标、告警、故障、用户反馈、研发效率和团队协作摩擦,都会检验原先的假设。架构师需要定期回看:哪些决策产生了预期收益,哪些复杂度没有回报,哪些边界已经不再适合业务变化。

复盘时不必把每次故障都归因于“实现不够仔细”。更值得追问的是:当时遗漏了哪项假设?边界、契约、监控或发布流程中缺少了什么反馈?将答案写回 ADR、Checklist、自动化测试或运行手册,才能让一次经验真正降低下一次决策成本。

当同一类问题反复出现时,应把它视为系统性信号:也许是模型边界不清,也许是接口契约缺少版本策略,也许是团队缺少足够的演练和所有权,而不应只靠某位工程师更谨慎来维持质量。

最有价值的架构能力,最终不是记住多少模式,而是持续把模糊问题转化为可验证假设,把复杂选择转化为清晰边界,并让团队有能力在变化中安全地改进系统。

1.6 小结与阅读导航

本章建立的是一条工程决策主线:从问题、约束与数量级开始,用六维画布形成设计,再用 TD、ADR 和评审把决策沉淀下来;随后以领域边界、依赖方向、读写分离和可靠事件协作组织系统,最后通过架构视图、运行反馈和演进思维持续验证。

后续章节将把本章的概念拆开落地:

读完本章后,读者不必急于采用所有模式,但应能够拿一个真实需求回答:目标和非目标是什么?谁拥有业务事实?关键质量属性怎样排序?失败后如何收敛?为什么当前复杂度值得承担?

1.7 参考文献与延伸阅读

本节汇总正文引用源。正文中的编号用于就近说明概念来源;完整书目按“先建立主线、再按问题深入”的方式组织。链接优先指向作者、出版方或长期维护的官方页面。

架构决策、可靠性与容量

[1] Kleppmann M. Designing Data-Intensive Applications[M]. O’Reilly Media, 2017. 建立可靠性、可扩展性、可维护性,以及数据系统权衡的主线。

[2] Beyer B, Jones C, Petoff J, Murphy N R, eds. Site Reliability Engineering[M]. O’Reilly Media, 2016;《The Site Reliability Workbook》 提供容量、服务目标和运行实践的补充。

[3] Bass L, Clements P, Kazman R. Software Architecture in Practice[M]. 4th ed. Addison-Wesley, 2021. 适合系统理解质量属性、架构视图、评估与演进。

[4] Fowler M. Architecture Decision Record[EB/OL]. 适合了解短决策记录的背景、状态与替代关系;概念由 Michael Nygard 在 2011 年的实践中推广。

领域建模、内部结构与读写分离

[5] Evans E. Domain-Driven Design: Tackling Complexity in the Heart of Software[M]. Addison-Wesley, 2003. 重点阅读通用语言、限界上下文、聚合与战略设计。

[6] Martin R C. Clean Architecture: A Craftsman’s Guide to Software Structure and Design[M]. Prentice Hall, 2017. 用于理解依赖倒置、边界与独立于框架的核心业务规则;实现时应结合项目复杂度谨慎取舍。

[7] Fowler M. CQRS[EB/OL]. 2011. 说明读写模型分离的价值与额外复杂度,适合作为 CQRS 选型的反过度设计提醒。

[8] Richardson C. Saga;Transactional Outbox[EB/OL]. Microservices Patterns. 用于理解跨服务本地事务、补偿、可靠发布和幂等消费的关系。

架构表达、评审与持续演进

[9] Brown S. The C4 Model for Visualising Software Architecture[EB/OL]. 重点参考 diagram levels 与 review checklist,按受众选择必要视图。

[10] Clements P, Bachmann F, Bass L, et al. Documenting Software Architectures: Views and Beyond[M]. 2nd ed. Addison-Wesley Professional, 2010(出版社标注版权年为 2011). 适合系统学习架构文档的视图、受众与记录方式。

[11] Ford N, Parsons R, Kua P. Building Evolutionary Architectures[M]. O’Reilly Media, 2017. 适合进一步学习演进式架构、适应度函数与渐进式变更。

[12] Google. Engineering Practices: Code Review Guide[EB/OL]. 重点参考 What to look for in a code review,将设计、正确性、测试和可维护性纳入日常评审。

[13] Conway M E. How Do Committees Invent?[J]. Datamation, 1968, 14(4): 28–31;Skelton M, Pais M. Team Topologies[M]. IT Revolution, 2019. 共同用于理解组织结构、团队认知负荷和系统边界之间的关系。

[14] 文彬, 子维. 领域驱动设计在互联网业务开发中的实践[EB/OL]. 美团技术团队, 2017. 用于补充中文互联网业务中从模型、分层到代码落地的实践观察。

[15] 阿里云开发者社区. 领域驱动设计(DDD)-基础思想[EB/OL]. 阿里云, 2020. 用于说明 DDD 与具体架构风格的关系,以及领域层、应用层和基础设施层的职责边界。

[16] Nik Silver, Mat Wall. 演进架构中的领域驱动设计[EB/OL]. InfoQ 中文, 2009. 中文译文;用于补充业务模型演进、遗留系统重构和架构适应变化的案例。

[17] AWS. Build services focused on specific business domains and functionality[EB/OL]. AWS Well-Architected Framework, 2023. 用于服务边界、业务领域聚焦和避免跨领域服务的实践说明。

[18] Carnegie Mellon Software Engineering Institute. Architecture Tradeoff Analysis Method Collection[EB/OL]. SEI. 用于质量属性场景、敏感点、权衡点和风险分析。

[19] Fowler M. Strangler Fig Application[EB/OL]. Martin Fowler’s Bliki. 用于说明通过兼容层、渐进切流和逐步删除旧实现推进架构迁移。

[20] Skelton M, Pais M. Team Topologies[EB/OL]. Team Topologies. 用于理解团队认知负荷、流对齐团队、平台团队与系统边界之间的关系。

建议阅读路径

若刚开始学习系统设计,可按 1 → 3 → 5 → 9 建立数据、架构、领域与表达的共同语言;正在处理跨系统一致性时,重点阅读第 4 章,业务对象还需要跨阶段等待和恢复时继续阅读第 5 章,关注强一致边界时再读第 7 章;希望提升方案评审与团队协作时,重点阅读第 2、3 章及电商实战部分。全书范围内的资料索引见附录 B:参考文献与延伸阅读。

第 2 章 编码、重构与 Code Review:构建可持续演进的代码

把架构意图写成可读、可测、可重构、可审查的代码,并通过高质量 Review 持续阻止系统走向腐化。

第 1 章讨论的是系统设计与技术方案写作,回答了“为什么这样设计”和“如何把设计讲清楚”。但真正决定系统长期质量的,往往不是 PPT 上的架构图,而是每天进入仓库的代码。

很多团队在方案阶段讲得很好,落地时却迅速失真:

  • 设计文档里分层清晰,代码里却出现跨层直连;
  • 领域边界在图里很优雅,提交里却把 Controller、Service、DAO 和第三方调用揉成一团;
  • 大家都知道要做幂等、超时、补偿和监控,但真正写代码时这些保障常常遗漏;
  • 团队也做 Code Review,但流于“看起来没问题”或“只是挑格式”,没有真正拦住设计偏移和线上风险。

所以这一章不再把“编码”理解成语法熟练度,也不把“重构”理解成推倒重写,更不把“Code Review”理解成合并前的礼貌动作,而是把它们一起看成架构执行力的一部分。一个成熟的工程团队,至少要在三个层面上同时达标:

  • 编码阶段:把复杂业务拆成清晰边界、稳定抽象和可验证实现。
  • 重构阶段:在不停止交付的前提下,持续修复边界失真、抽象退化和历史包袱。
  • 评审阶段:在缺陷、耦合和错误模式进入主干前,把问题尽可能提前暴露。

这种闭环在 AI 辅助编码普及后反而更重要。代码生成工具可以快速补齐 DTO、适配器和测试骨架,却不会自动拥有当前业务的隐含约束,也不会替团队承担并发、幂等、数据一致性和回滚责任。代码越容易生成,团队越需要把边界和验证写成机器能够检查的规则。

读完本章,你应该能建立四件事的共同语言:

  • 什么样的代码,才算真正支持系统长期演进。
  • 老代码开始腐化时,应该如何小步重构,而不是等到必须重写。
  • Code Review 到底在审什么,而不只是“帮忙看一眼”。
  • 架构师、开发者、Reviewer 分别应该承担什么责任。

2.1 为什么架构最终会输在代码细节上

2.1.1 架构不是画出来的,是提交出来的

系统设计常常失败,不是因为没人知道应该分层、隔离依赖、控制一致性,而是因为这些原则没有被持续写进代码。架构图表达的是目标形态,代码库体现的才是真实形态。

如果一个团队长期容忍下面这些现象,再好的技术方案也会被慢慢掏空:

  • Handler 直接拼 SQL;
  • 领域对象里混入远程调用和缓存访问;
  • “临时”分支逻辑逐步堆成主流程;
  • 为了赶需求,把异常处理、超时控制、回滚语义留给“后面补”。

时间一长,团队面对的就不再是“如何实现新需求”,而是“改一行为什么会炸三处”。这类问题本质上不是功能问题,而是代码结构已经不再承载架构意图。

2.1.2 代码质量差的真实代价

很多人把代码质量理解成审美问题,仿佛只是“优雅不优雅”。但对业务系统来说,代码质量的代价非常具体:

问题短期表现长期后果
边界混乱改动快,复制方便模块无法替换,重构成本陡增
函数过大新需求先堆进去回归风险高,没人敢动老逻辑
缺少测试点交付初期看不出来故障只能靠线上反馈暴露
Review 走形式合并速度快缺陷和坏味道持续进入主干
命名模糊写的人知道半年后团队整体认知失真

架构师尤其不能把这些问题看成“实现细节”。系统能否长期演进,往往不是取决于是否用了微服务、CQRS、Saga 这些名词,而是取决于每一次提交是否在强化还是侵蚀边界。

2.1.3 架构师为什么必须懂编码和 Review

架构师不一定要承担最多业务代码,但必须对代码落地保持足够近的距离。原因很简单:

  • 设计是否真的可实现,只有进入代码以后才会暴露真实摩擦;
  • 团队抽象是否过度、边界是否失真,Review 时最容易看出来;
  • 如果架构师长期离开编码现场,方案会越来越像“理想模型”,而不是可落地工程。

一个可操作的底线是:核心链路的关键模块,架构师至少要能定期深度 Review,并能亲自写出代表团队标准的样例代码。

flowchart LR
	A[架构设计] --> B[代码实现]
	B --> C[Code Review]
	C --> D[合并与发布]
	D --> E[线上反馈]
	E --> A

这条闭环里,编码和 Review 不是末端动作,而是把设计变成现实、再把现实反馈给设计的关键环节。

2.1.4 AI 辅助编码时代的架构防守

AI 辅助编码改变的是代码生产速度,不是业务约束本身。它很擅长生成重复性高、局部上下文充分的代码,例如请求对象、数据访问适配器、序列化逻辑和测试样板;但它很难仅凭一个函数或一个文件判断以下问题:

  • 领域规则是否必须由聚合根保护,而不是由每个调用方自行记忆;
  • 一个超时后的重试是否可能重复扣减、重复发消息或重复创建订单;
  • 新模块是否越过了依赖边界,直接访问了数据库、缓存或第三方客户端;
  • 旧接口、旧数据和旧消费者是否仍然兼容;
  • 失败后系统最终应该进入什么状态,以及由谁负责补偿。

因此,AI 时代的 Review 重点不是“代码是不是 AI 写的”,而是把人工注意力放到 AI 无法替团队决定的地方:业务不变量、状态迁移、并发语义、分布式一致性、权限边界和恢复路径。语法、格式、简单的错误检查和部分依赖约束,应尽量交给编译器、Lint、测试和 CI。

可以把 AI 生成代码的验收分为三层:

层次必须回答的问题推荐验证方式
结构是否进入了正确的包和抽象层编译、依赖图检查、架构规则测试
行为是否满足业务不变量和失败语义单元测试、特征测试、状态迁移测试
运行是否具备超时、幂等、观测和回滚抓手集成测试、灰度指标、故障演练

这也是架构师在 AI 编程时代的新职责:不是亲自审查每一行生成代码,而是把团队的设计原则转化成 Prompt 约束、代码模板、静态检查、测试夹具和发布门禁。只有这样,Review 才不会成为吞噬所有注意力的人工补丁。


2.2 好代码首先是边界清楚

2.2.1 先问职责,再写函数

很多坏代码并不是因为工程师不会写,而是因为下笔前没先回答一个问题:这段代码到底属于哪一层、承担什么职责。

以电商下单为例,至少有几类不同职责:

  • Handler:接收请求、参数绑定、鉴权、返回协议;
  • Use Case / Application:编排下单流程,协调库存、计价、订单持久化;
  • Domain:表达订单、库存、金额、不变量与业务规则;
  • Infrastructure:数据库、缓存、MQ、RPC 客户端实现。

这些职责一旦混在一起,短期会觉得“写起来更快”,长期就会出现两个典型后果:

  • 业务逻辑无法脱离接口或存储独立测试;
  • 每次改动都必须理解整条链路的全部技术细节。

关于「这些职责如何落到目录结构上」,第 1 章解释依赖方向和模型边界,本章第 2.6.1 节给出 Go 商品服务的示意目录,再用代码片段说明这些边界如何体现在实现中。

2.2.2 用例编排层要显式,不要隐式散落

业务系统最常见的腐化方式,不是类太多,而是“关键业务流程没有稳定的编排层”。于是下单的一部分逻辑藏在 Handler,另一部分藏在 Service,另一部分又埋在 Repository Hook 或异步消费者里。

更健康的做法是让“流程在哪里发生”这件事一眼可见:

type PlaceOrderUseCase struct {
	inventory InventoryService
	pricing   PricingService
	repo      OrderRepository
}

func (uc *PlaceOrderUseCase) Execute(ctx context.Context, cmd PlaceOrderCommand) (string, error) {
	if err := cmd.Validate(); err != nil {
		return "", err
	}

	quote, err := uc.pricing.Quote(ctx, cmd.UserID, cmd.Items)
	if err != nil {
		return "", fmt.Errorf("quote price: %w", err)
	}

	if err := uc.inventory.Reserve(ctx, cmd.Items); err != nil {
		return "", fmt.Errorf("reserve inventory: %w", err)
	}

	order := NewOrder(cmd.UserID, cmd.Items, quote.PayableAmount)
	if err := uc.repo.Save(ctx, order); err != nil {
		return "", fmt.Errorf("save order: %w", err)
	}

	return order.ID, nil
}

这段代码未必复杂,但它提供了两个重要价值:

  • 流程顺序显式可读;
  • 下层依赖是抽象接口,便于测试和替换。

但它仍然是教学用的最小骨架,不能被误读成完整的分布式事务方案。库存预留成功而订单保存失败时,必须有释放预留、进入待补偿状态或由后续任务重试的语义;重复请求时,Reserve 和 Save 也必须围绕同一个幂等键设计。第 4 章会系统讨论 Saga、Outbox 和补偿,这里只要求读者在 Review 时意识到这些边界不能被省略。

2.2.3 写“业务语言”,不要写“技术噪音”

很多代码难读,不是逻辑太深,而是表达层级不对。调用方真正关心的是“预留库存”“计算应付金额”“创建订单”,而不是一堆技术性细节。

反例通常长这样:

func CreateOrder(ctx context.Context, req *Request) error {
	if req == nil {
		return errors.New("nil")
	}
	// 混着校验、查库、拼对象、写缓存、打日志、改状态
	return nil
}

问题不在于函数短,而在于它没有向读者提供清晰语义。好的业务代码,读起来应该像在追踪一个真实业务动作,而不是在猜作者想做什么。

2.2.4 参数太多,通常说明抽象不对

参数列表过长,往往意味着以下几种问题之一:

  • 缺少输入模型,调用方必须自己拼装上下文;
  • 一个函数做了太多事,需要的上下文过多;
  • 中间状态没有被显式建模,只能靠参数平铺传递。

例如下面这种签名几乎注定不可维护:

func CalcPrice(userID string, city string, channel string, skuID string, qty int, couponCode string, vipLevel int, usePoints bool) (int64, error) {
	return 0, nil
}

更合理的做法是让输入拥有清晰语义:

type PriceQuoteQuery struct {
	UserID     string
	City       string
	Channel    string
	Items      []QuoteItem
	CouponCode string
	VIPLevel   int
	UsePoints  bool
}

这样做不只是“好看”,更重要的是:

  • 新字段扩展不会破坏大批调用方;
  • 测试构造输入更自然;
  • Review 时更容易判断字段语义是否合理。

2.3 重构不是推倒重来,而是持续纠偏

很多团队嘴上都知道“代码要可维护”,但一旦老逻辑开始变形,第一反应要么是继续堆条件,要么是豪情万丈地说“找时间重写”。前者会让代码越改越烂,后者通常永远没有时间真正发生。对业务系统来说,真正可执行的路径往往只有一条:在持续交付中小步纠偏。

重构的目标不是把代码变得“更漂亮”,而是降低未来每一次变更的摩擦成本,让系统重新回到可理解、可替换、可测试、可评审的状态。如果一段代码已经让团队不敢动、不愿改、每次修改都伴随高回归风险,那么它就已经不是局部实现问题,而是生产效率和系统演进能力的问题。

2.3.1 什么信号说明已经不能再继续堆逻辑

下面这些现象,通常意味着代码已经进入“继续堆功能比及时重构更危险”的阶段:

  • 一个函数同时处理校验、编排、持久化、远程调用和异常恢复。
  • 改一条业务规则要同时改多个层次,甚至多个服务。
  • 同一类逻辑在不同分支里复制粘贴,只是改了几个字段名。
  • 命名已经和真实业务语义对不上,只能靠口口相传理解。
  • Reviewer 看到代码时会说“能跑,但我不敢保证后面还好改”。

这些信号的共同点是:代码已经不再帮助团队表达业务,而是在迫使团队绕着历史包袱工作。

2.3.2 重构的第一原则:先收口边界,再优化内部实现

很多失败的重构,不是因为方向不对,而是因为一上来就想把所有问题一起解决:顺手改命名、顺手换框架、顺手改协议、顺手统一抽象。结果系统还没变好,风险面先被扩大了。

更稳妥的路径通常是:

  1. 先识别最关键的腐化点,到底是编排层缺失、依赖泄漏,还是领域规则散落。
  2. 先把边界收口,让调用关系清楚,职责归属明确。
  3. 再逐步替换内部实现,而不是先大面积重写细节。

换句话说,重构优先级往往不是“把代码写得更优雅”,而是“让未来的改动先有一个正确的落点”。

2.3.3 小步重构的常用路径

对线上系统来说,最有价值的不是理想化重构,而是能在真实交付节奏里落地的重构路径。下面几种方式最常见,也最实用:

重构路径适用场景关键动作
抽输入模型参数平铺、语义混乱先把输入收拢成明确命令或查询对象
补编排层流程散落在 Handler / Service / Hook把主流程显式收敛到 Use Case / Application 层
包装旧实现老模块不能立刻替换先加抽象隔离层,再逐步迁移调用方
拆副作用核心逻辑难测把纯业务判断与数据库 / RPC / MQ 分开
显式错误语义失败路径模糊把重复、超时、冲突、不可恢复错误区分开

这些动作看起来不“宏大”,但恰恰因为它们足够小,才更容易进入日常交付节奏,而不是永远停留在重构计划里。

Martin Fowler 将重构定义为保持外部行为不变、逐步调整内部结构的过程;重构的价值不在于一次性写出理想代码,而在于让每一步都足够小、足够可验证。[1]

2.3.4 安全重构的落地方案:适配、切换与流量验证

系统重构和架构重构有一个共同点:真正成熟的做法,往往不是停机重建,而是在线迁移。代码层面同样如此。很多时候,旧实现不能立刻删除,但可以先被包进一个更干净的抽象中,让新逻辑逐渐迁移到新边界。以下三种模式解决的是不同层次的问题,不能只因为都支持“渐进式迁移”就混为一谈:

模式适用前提切换位置必须先解决的问题
按抽象分支(Branch by Abstraction)新旧实现位于同一代码库接口或应用服务内部抽象契约、兼容行为和旧实现删除时间
绞杀者模式(Strangler Fig)新旧模块可以由路由或边界隔离请求路由、服务入口或领域边界数据所有权、流量切分、回滚和双写风险
并行双跑(Shadow Run)新实现可以抑制外部副作用流量复制和结果比较结果规范化、成本控制、隐私和副作用隔离

按抽象分支适合先在代码库内部建立稳定的端口。调用方依赖接口,旧实现和新实现分别挂在接口后面,再通过配置或 Feature Flag 切换。它比长期维护一个大 Git 分支更容易持续合并,也能让新需求优先落到新边界。切换完成后必须删除旧适配器和临时开关,否则抽象层会从迁移工具变成永久复杂度。

绞杀者模式适合边界已经足够清晰、可以按接口或流量逐步迁移的场景。它的核心不是“把旧服务复制一遍”,而是让新实现逐渐接管一个可验证的业务切片。每次迁移都应记录流量比例、错误率、延迟、数据差异和回滚条件;涉及写入时,还要先明确谁拥有权威事实,避免新旧系统长期双写却没有对账机制。Martin Fowler 将它与一次性切换重写进行对比时,强调的正是风险分散,而不是迁移本身更“时髦”。[2]

并行双跑适合验证新旧实现的结果差异,但不等于让两条路径都执行真实副作用。计价、规则计算、搜索召回等相对容易做结果比较;库存扣减、余额变更、消息发送则必须使用只读查询、沙箱适配器或副作用抑制器。比较结果时不能只比较字符串,还要定义字段归一化、允许误差和差异处置,否则“有差异”无法转化为可行动的结论。

一个常见步骤是:

识别腐化模块
  -> 用特征测试锁住既有行为
  -> 定义新接口与失败语义
  -> 用适配层包装旧实现
  -> 新需求优先走新接口
  -> 小流量切换或并行比较
  -> 用指标、测试和 Review 验证
  -> 删除旧路径与临时开关

这种做法的意义在于,它允许团队在保持业务连续交付的同时,逐步让代码库重新变得可演进,而不是在“继续忍受”和“推倒重写”之间二选一。

2.4 编码时真正应该守住的几个原则

2.4.1 可读性优先于炫技

业务系统不是算法竞赛,维护周期远长于编写周期。多数情况下,未来读代码的人并不是当前作者本人,因此代码首先要对团队友好,而不是对作者本人高效。

可读性通常来自四件事:

  • 命名和业务语义一致;
  • 控制流简单,避免多层嵌套;
  • 副作用位置明确;
  • 错误路径可见,不靠隐式约定。

2.4.2 把变化点隔离,而不是把一切都抽象

很多团队一遇到重复就急着抽象,最后得到的是“看起来可复用、实际没人敢改”的公共代码。真正应该抽象的不是相似代码本身,而是稳定的概念和可预期的变化点。

例如:

  • 不同库存来源的预留逻辑,可以抽象成同一接口下的不同实现;
  • 不同业务线都要发消息,不一定需要一个万能 SendEverythingService;
  • 只出现两次的小差异,不一定值得立即抽象成复杂模式。

好的抽象会减少决策面,坏的抽象会制造更多决策面。John Ousterhout 在《A Philosophy of Software Design》中用“深层模块”(Deep Module)解释了类似问题:好的模块用相对简单的接口隐藏较多实现复杂度;如果拆分后每个模块只有很薄的逻辑,却增加了大量接口、跳转和命名,系统的整体认知负荷反而会上升。[7]

这也说明 SOLID 不能被机械地理解成“类越小越好”或“接口越多越好”。单一职责应理解为“因同一类变化而共同修改”,依赖倒置应落到稳定的业务边界上,接口隔离则要避免把调用方强迫绑定到无关能力。原则的价值在于帮助团队识别变化方向,而不是替代具体的业务判断。

2.4.3 失败路径要和成功路径一样认真

线上事故很少发生在“主流程最理想的那条路”,而更常见于:

  • 超时后是否重试;
  • 重试后是否重复扣减;
  • 部分成功后如何补偿;
  • 下游失败时状态是否半提交;
  • 幂等键是否真的覆盖重放场景。

所以编码时必须把错误处理当成一等公民。下面的例子比“调用成功就返回”更接近生产代码:

var ErrOrderAlreadyExists = errors.New("order already exists")

func (r *OrderRepository) Save(ctx context.Context, order *Order) error {
	_, err := r.db.ExecContext(
		ctx,
		`INSERT INTO orders (id, user_id, amount_cents) VALUES (?, ?, ?)`,
		order.ID,
		order.UserID,
		order.AmountCents,
	)
	if err != nil {
		if isDuplicateKey(err) {
			return ErrOrderAlreadyExists
		}
		return fmt.Errorf("insert order: %w", err)
	}
	return nil
}

这种写法的价值在于:

  • 业务性冲突可被上层识别;
  • 基础设施故障仍能保留错误链;
  • Review 时容易判断幂等行为是否符合预期。

失败路径至少要回答五个问题:

  1. 失败发生在哪个阶段,已经产生了哪些副作用?
  2. 调用方收到的是明确失败,还是“结果未知,需要查询”?
  3. 重试由谁发起,重试使用什么幂等键,最多重试几次?
  4. 如果无法自动恢复,系统会进入哪个可观测、可人工接管的状态?
  5. 这次失败是否需要补偿、对账或告警,而不是只记录一行错误日志?

例如,库存服务超时不一定等于“库存没有扣减”。如果请求已经到达下游但响应丢失,直接重试可能造成重复预留;如果订单服务把它简单映射成 500,用户再次点击又可能创建第二笔订单。更稳妥的代码会把“明确失败”和“结果未知”区分开,给每种状态规定查询、重试或人工处理路径。这里的重点不是为每个函数塞进复杂状态机,而是不要让异常语义隐藏在一个泛化的 error 中。

2.4.4 让测试点自然存在

代码未必必须先写测试,但一定要写成“能被测试”的形状。最糟糕的情况不是没有测试,而是代码结构让你根本无法只测业务逻辑,只能起整套环境做脆弱的集成验证。

更易测的代码通常具备这些特征:

  • 输入和输出明确;
  • 外部依赖通过接口注入;
  • 核心逻辑不依赖全局状态;
  • 纯计算与副作用分离。

可测试性不是测试工程师的额外要求,而是架构是否清晰的副产品。

2.4.5 小步提交,优于大爆炸提交

许多 Review 效率低,不是 Reviewer 不负责,而是提交本身已经不可评审。一个同时包含“重命名、重构、修 bug、加新功能、顺手格式化全目录”的 PR,几乎不可能被认真看完。

高质量提交通常有三个特征:

  • 主题单一:一个 PR 只解决一类问题;
  • 变更可解释:作者能说明改动前后行为差异;
  • 回滚可控:出问题时能相对独立地撤回。

从工程协作角度看,小步提交本身就是对 Review 质量的投资。

2.4.6 将架构规则代码化:Fitness Functions 与 CI 门禁

仅靠工程师记忆和 Reviewer 的眼睛,无法长期守住大型代码库的边界。Neal Ford、Rebecca Parsons 和 Patrick Kua 在演进式架构方法中提出“架构适应度函数”(Architecture Fitness Functions):把能够反复验证的架构特征写成自动化检查,让系统在持续变化中仍然受到约束。[3]

适应度函数不应停留在“代码质量要好”这种口号,而应写成可以失败的断言,例如:

  • domain 包不能直接依赖 infrastructure;
  • Handler 不能直接导入数据库驱动或执行 SQL;
  • application 层只能依赖由自己定义的端口接口;
  • 核心写操作必须具备幂等键或明确的重复请求策略;
  • 关键状态迁移必须有行为测试;
  • 新增外部调用必须声明超时、错误映射和观测字段。

其中,依赖方向和禁止导入适合用静态检查完成;幂等、补偿和状态迁移则通常需要测试夹具或集成测试,不能幻想用一个 Lint 规则解决所有架构问题。

在 CI 中可以分三档处理:

检查类型示例失败策略
硬门禁编译失败、禁止依赖、格式错误、核心测试失败阻止合并
风险门禁关键模块缺少行为测试、API 不兼容、敏感调用无超时阻止合并或要求架构负责人确认
观察指标PR 大小、复杂度趋势、技术债例外数量记录趋势,不直接阻塞

Go 项目可以组合 golangci-lint、staticcheck、errcheck、依赖图脚本和自定义测试;Java 项目可以用 ArchUnit 对包依赖和分层规则做断言。工具不是目的,关键是把团队反复争论的边界转化成可重复执行的证据。Uber Go Style Guide 也把错误处理、包命名、并发和 Lint 配置放在同一套工程约束中,而不是将“风格”局限为格式问题。[4]


2.5 Code Review 到底在审什么

2.5.1 Review 的目标不是挑错字

Code Review 的核心目标有四个:

  • 在合并前发现正确性和稳定性问题;
  • 检查实现是否偏离架构和设计约束;
  • 传播团队编码标准和隐性知识;
  • 通过讨论提升系统长期可维护性。

所以真正有价值的 Review,应该优先关注:

  • 这段代码会不会做错;
  • 这段代码以后会不会很难改;
  • 这段代码是不是把风险藏起来了。

而格式、换行、 import 排序这些内容,应该尽量交给格式化工具和静态检查器,而不是浪费人工评审注意力。

2.5.2 Reviewer 的检查顺序

一份高质量 Review,最好按由大到小的顺序看,而不是一上来盯局部实现。

建议顺序如下:

  1. 需求与行为:这次改动到底解决什么问题,是否改变了用户可见行为。
  2. 边界与设计:代码是否放在正确层次,是否引入了不必要耦合。
  3. 正确性:状态迁移、并发、异常、幂等、空值、边界条件是否安全。
  4. 可维护性:命名、结构、复用方式、注释是否帮助未来修改。
  5. 验证覆盖:测试、日志、指标、灰度与回滚手段是否足够。

这个顺序很重要,因为很多真正致命的问题,根本不是“某行写错”,而是整段代码从一开始就放错了位置。

2.5.3 Reviewer 最容易漏掉的风险

在业务系统里,下面几类问题尤其值得重点关注:

风险类型典型问题
状态一致性是否可能部分成功、部分失败
并发语义是否会重复执行、重复扣减、脏写覆盖
超时重试重试是否幂等,是否放大下游压力
兼容性新字段、新枚举、新协议是否影响旧调用方
观测性故障发生后是否能定位到哪一步出错
回滚能力发布失败后是否能快速止损

如果 Review 长期只盯代码风格,这些风险就会直接穿透到线上。

2.5.4 Author 应该怎样配合 Review

Review 质量不只是 Reviewer 的责任,Author 同样负有很大责任。作者在发起 PR 时,至少应该主动提供这些信息:

  • 这次改动解决什么问题;
  • 改动范围和非目标范围是什么;
  • 有没有行为变化或数据兼容风险;
  • 如何验证;
  • 有没有需要 Reviewer 特别关注的点。

一个好的 PR 描述,能显著降低 Reviewer 的理解成本,也更容易把注意力放到真正重要的问题上。


2.5.5 一份可执行的 Code Review 清单

下面给出一份更适合业务系统的 Review Checklist。它不是要求每次逐条机械打勾,而是帮助团队形成稳定视角。

业务与正确性

  • 这次实现是否真的满足需求,而不是只满足 happy path。
  • 是否覆盖了空输入、重复请求、无权限、数据不存在等边界情况。
  • 是否存在状态半提交、重复执行、顺序错误的问题。
  • 失败后是否有明确返回、重试策略或补偿语义。

架构与边界

  • 代码是否放在正确层次,是否出现跨层泄漏。
  • 领域规则是否仍由领域对象或用例层表达,而不是散落在外层。
  • 是否引入了新的隐式耦合,例如直接依赖具体实现、共享可变状态、万能工具类。
  • 新抽象是否真有稳定价值,还是为了“看起来高级”。
  • 目录结构与分层是否符合团队约定的映射(可对照第 2.6.1 节的示意目录及后续代码片段)。

可读性与可维护性

  • 命名是否表达业务语义,而不是技术细节。
  • 函数是否过长、职责是否过多。
  • 控制流是否过深,是否需要提前返回或拆分步骤。
  • 注释是否解释“为什么”,而不是重复“代码在做什么”。

可靠性与可运维性

  • 是否设置了合理的超时、重试、限流或幂等控制。
  • 是否补充了必要的日志、指标、Tracing 标签。
  • 故障发生时,能否快速定位模块、输入和阶段。
  • 发布后是否具备灰度、监控和回滚抓手。

测试与验证

  • 是否新增或调整了足够说明行为的测试。
  • 测试是否覆盖关键分支,而不是只覆盖表面行数。
  • 是否区分了单元测试、集成测试和端到端验证责任。
  • 是否给出手工验证步骤或构造数据方式。

如果团队能长期围绕这五个维度讨论,Review 很快就会从“凭感觉”升级为“有共同标准”。

2.5.5.1 让 Review 意见具备可执行性

高质量意见应该描述风险,而不是表达个人偏好。一个有效的 Review 意见至少包含四部分:观察到的事实、可能造成的后果、建议的处理方向,以及是否必须在当前 PR 解决。例如:

[必须修复] 这里使用请求 ID 生成订单,但没有数据库唯一约束。
并发重试时可能创建两条订单,导致库存预留无法对应唯一订单。
建议把 idempotency_key 纳入订单唯一索引,并增加重复提交测试。

这种写法把讨论从“我不喜欢这个实现”转成“哪项业务不变量可能被破坏”。如果意见只是风格偏好,可以标记为非阻塞建议;如果涉及数据丢失、重复扣减、权限绕过或不可回滚发布,则应明确为阻塞问题。Google 的 Code Review 规范强调,Reviewer 既要保护代码库健康,也要避免用个人偏好制造不必要的交付阻力。[6]

Author 也可以主动把 PR 分成三类信息:必须理解的业务变化、可以机械验证的代码变化、希望 Reviewer 重点挑战的风险假设。这样 Reviewer 不必先猜测作者意图,再把时间花在真正需要经验判断的地方。

Review 结束后还应记录无法在当前 PR 解决的问题,例如“暂时保留旧适配器,下一阶段迁移”“先允许一处架构例外,月底前删除”。没有责任人和到期时间的 Review 结论,往往会变成无人追踪的技术债。


2.6 代码片段:设计原则如何体现在实现里

前面几节讲的是原则,这一节通过 Go 代码片段观察职责边界、领域规则和依赖方向如何体现在实现中。以下片段是用于讲解的示意代码,不属于可下载或可独立编译运行的配套工程;省略部分只用于突出设计重点,不能直接作为完整项目使用。聚合根、值对象、领域事件和应用服务等概念,可结合 Eric Evans 与 Vaughn Vernon 的领域驱动设计著作继续阅读。[8][9]

2.6.1 目录结构本身就是第一份「好代码」

下面用一个商品服务的目录结构示意职责划分;它是讲解用的结构示例,不是可直接下载运行的项目:

product-service/
├── cmd/main.go                    # 入口:只做组装,不写业务
└── internal/
    ├── domain/                    # 领域层:聚合根、值对象、领域事件、Repository 接口
    ├── application/               # 应用层:用例编排、DTO
    ├── infrastructure/            # 基础设施:Repository 实现、缓存、Kafka
    └── interfaces/                # 接口层:HTTP、gRPC、Event 三种触发方式

这个结构好在哪里,用 2.2 的视角看非常清楚:

  • 职责归属没有歧义。一个新需求来了,「这段代码该放哪」有明确答案,这是 2.2.1 的落地。
  • 依赖方向物理可见。domain 不 import 任何其他 internal 包,interfaces 和 infrastructure 都依赖内层——Review 时一条 import 就能发现跨层泄漏。
  • 三种入口(HTTP/gRPC/Event)平级,都只做协议转换后调用同一个 application service,不存在「Kafka 消费者里藏着一版业务逻辑」的隐式编排问题(2.2.2)。

可以把这种清晰的分层结构与常见的反例对照:应用服务直接依赖持久化实现、业务规则散落在贫血对象之外。对照的重点是识别 2.3 所说的腐化信号,并根据当前约束确定足够清晰、可替换和可测试的目标形态,而不是照搬某个固定目录。

2.6.2 领域模型:业务规则内聚,而不是散落在 setter 之外

下面的 Product 聚合根示意 2.2.3「写业务语言」中的上架规则:

// OnShelf 上架
func (p *Product) OnShelf() error {
	if p.status == ProductStatusOnShelf {
		return errors.New("商品已上架")
	}
	if p.basePrice.Amount() <= 0 {
		return errors.New("商品价格必须大于0")
	}
	if len(p.images) == 0 {
		return errors.New("商品必须有至少一张图片")
	}

	p.status = ProductStatusOnShelf
	p.updatedAt = time.Now()

	p.addDomainEvent(ProductOnShelfEvent{
		SKUID:     p.skuID.Value(),
		OnShelfAt: p.updatedAt,
	})

	return nil
}

这段代码值得走读的四个点:

  1. 不变量由聚合行为集中校验。通过 OnShelf() 上架时会检查价格与图片,减少调用方自行重复实现规则。这里「基础价必须大于 0」是此商品示例的业务前提,不代表第 15 章的合法零元订单不可存在;零元订单还可能由优惠或其他政策形成。
  2. 状态迁移表达为业务动词。OnShelf()、OffShelf(reason)、UpdateBasePrice(price),读代码就是在读业务流程,没有任何技术噪音。
  3. 每次状态变更留下领域事件。事件在聚合内部积累,由应用层统一发布——「业务事实已经发生」这件事被显式建模,而不是散落在各处 kafka.Send()。
  4. 未导出字段 + 受控行为方法。跨包调用者不能直接改写未导出状态,但同包代码和持久化恢复仍要遵守校验。Go 的包访问边界不是业务不变量的编译期证明,结构体零值也不会自动经过构造函数。[10]

2.6.3 值对象:让非法输入在构造时就失败

金额值对象可以用下面的方式表达:

// Price 值对象(使用分为单位,避免浮点精度问题)
type Price struct {
	amount   int64  // 金额(分)
	currency string // 货币(CNY)
}

func NewPrice(amount int64, currency string) (Price, error) {
	if currency == "" {
		currency = "CNY"
	}
	price := Price{amount: amount, currency: currency}
	if err := price.Validate(); err != nil {
		return Price{}, err
	}
	return price, nil
}

func (p Price) Validate() error {
	if p.amount < 0 {
		return errors.New("价格不能为负数")
	}
	if p.currency != "CNY" {
		return errors.New("此示例只支持币种 CNY")
	}
	return nil
}

两个设计决策都值得在 Review 中坚持:

  • 此示例的 CNY 金额用「分」存 int64,不用 float64。多币种系统需要显式管理最小货币单位、精度、舍入和溢出边界,不能把「分」推广到所有币种。第 13 章将进一步讨论计价和退款分摊。
  • 校验集中,但不假定构造入口不可绕过。NewPrice 是推荐入口;Price{}、var p Price 仍能产生币种为空的零值,同包代码也可以直接构造字段。因此零值不代表有效交易价格,Repository 恢复后和进入交易计算前需要调用相同的校验。请求中省略币种可按本示例默认 CNY,但恢复一条缺失币种的历史记录时不能静默补成 CNY。[10]
边界输入或动作本示例应如何处理
NewPrice(-1, "CNY")拒绝负价格
Price{}.Validate()拒绝未初始化币种
从存储恢复金额但币种缺失校验失败并进入数据修复,不推测币种
NewPrice(0, "CNY")允许零值金额;能否上架或成交由相应业务规则决定
优惠减免或退款分录使用各自语义与方向,不机械套用商品 Price 的非负规则

这组校验用于降低非法状态入口,不宣称所有非法输入已经在类型系统中被排除。以下仍是教学片段,不是完整的多币种金额库。

2.6.4 应用服务:编排显式、依赖抽象、失败语义清楚

下面的 ProductService 示意一个符合 2.2.2 的用例编排层:

// EventPublisher is owned by the application layer.
// Infrastructure adapters such as KafkaProducer implement it.
type EventPublisher interface {
	Publish(ctx context.Context, event domain.DomainEvent) error
	PublishBatch(ctx context.Context, events []domain.DomainEvent) error
}

type ProductService struct {
	repo           domain.ProductRepository
	eventPublisher EventPublisher
}

func (s *ProductService) CreateProduct(ctx context.Context, req *dto.CreateProductRequest) (*dto.CreateProductResponse, error) {
	// Step 1: 创建领域对象(校验和不变量在领域层完成)
	product := domain.NewProduct(skuID, spu, req.SupplierSKU, price, specs)

	// Step 2: 保存聚合根
	if err := s.repo.Save(ctx, product); err != nil {
		return nil, fmt.Errorf("保存商品失败: %w", err)
	}

	// Step 3: 发布聚合中积累的领域事件
	if err := s.publishDomainEvents(ctx, product); err != nil {
		...
	}
}

对应 2.4.3 和 2.4.4,这段代码的三个好习惯:

  • 接口在使用方定义。EventPublisher 接口声明在 application 层、由 infrastructure 的 KafkaProducer 实现——依赖方向向内,单元测试时可以注入内存假实现,不需要起 Kafka。
  • 错误用 %w 包装并带业务语义。保存商品失败: %w 既保留了错误链可供日志追溯,又给上层提供了可判断的上下文。
  • 流程步骤一眼可读。建领域对象 → 保存 → 发事件,顺序显式。编排层的职责是「编排」,不是「什么都干」。

这里还必须补充一个事务边界:如果 Save 只保存聚合,而 publishDomainEvents 直接调用 Kafka,那么数据库提交成功、消息发布失败时仍然会产生丢消息风险。生产实现应当在保存聚合的本地事务中同时写入 Outbox,再由 Worker 异步投递并更新发送状态。应用服务负责表达“保存事实并留下待发布事件”,基础设施负责可靠投递,不应把两者简化成一次普通函数调用。

2.6.5 测试:好结构让测试自然存在

2.4.4 说「可测试性是架构清晰的副产品」。下面的测试片段示意如何在不依赖外部服务的情况下验证核心业务行为:

func TestProductCenterPublishesSnapshotAndOutbox(t *testing.T) {
	repo := persistence.NewProductCenterRepository()   // 内存实现
	svc := NewProductCenterService(repo)

	result, err := svc.PublishCommand(ctx, domain.PublishProductVersionCommand{...})

	// 断言发布版本递增、快照内容正确、Outbox 事件已写入
	snapshot, _ := repo.GetSnapshot(ctx, result.ItemID, result.PublishVersion)
	events, _ := repo.ListOutbox(ctx, domain.OutboxPending)
	...
}

能写出这种测试,前提是前面所有的结构决策都做对了:依赖接口注入、核心逻辑不碰全局状态、Repository 有内存实现。反过来,2.7.1 节(下单反例)里那个把一切都揉在 Handler 里的写法,你想给它补一个「库存不足」的单元测试都无从下手——只能起数据库做脆弱的集成测试。测试写不出来,往往就是结构腐化最早的报警器。

2.6.6 把原则迁移到自己的代码

读完片段后,可以选一个已有业务改动,沿着同一组问题做 Review:入口层是否只处理协议,应用层是否显式编排,领域对象是否守住不变量,基础设施依赖是否通过边界隔离。对照本团队的目录约定和测试方式,记录需要调整的职责;不要把本节的示意目录当作必须照搬的项目模板。


2.7 动态演进:电商下单场景里的编码、重构与 Review

为了把原则落到具体场景,下面用一个简化的下单流程说明“代码怎么写”和“Review 怎么看”。

2.7.1 一个不健康的实现

func (h *OrderHandler) Create(c *gin.Context) {
	var req CreateOrderRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(400, gin.H{"error": err.Error()})
		return
	}

	user, _ := h.userRepo.FindByID(c, req.UserID)
	if user == nil {
		c.JSON(400, gin.H{"error": "user not found"})
		return
	}

	total := int64(0)
	for _, item := range req.Items {
		sku, _ := h.skuRepo.Find(c, item.SKU)
		total += sku.Price * int64(item.Qty)
		_ = h.inventoryRepo.Decrease(c, item.SKU, item.Qty)
	}

	orderID := uuid.NewString()
	_ = h.db.ExecContext(c, "insert into orders(id,user_id,total_amount) values(?,?,?)", orderID, req.UserID, total)
	c.JSON(200, gin.H{"order_id": orderID})
}

这段代码的问题非常典型:

  • Handler 同时承担协议层、编排层、领域校验和持久化职责;
  • 错误基本被吞掉;
  • 库存扣减和订单写入之间没有一致性语义;
  • 没有超时、幂等、日志和观测点;
  • 几乎无法只针对业务规则编写单元测试。

2.7.2 如果这是线上老代码,应该怎么重构

真实团队里更常见的情况不是“从零开始写一个更好的版本”,而是坏代码已经在线上跑了很久,周围还挂着依赖、监控、补偿脚本和历史调用。这个时候,最危险的做法往往不是不动,而是上来就想一口气彻底重写。

更现实的重构路径通常是:

  1. 先记录旧接口的输入、输出、错误码、日志字段和关键副作用,形成一组特征测试。
  2. 在不改变路由的前提下,把 Handler 中的流程编排提出来,形成单独的 Use Case。
  3. 再把库存、计价、订单持久化等依赖抽成清晰接口,并用适配器包住旧实现。
  4. 明确错误语义和幂等语义,让旧逻辑至少先变得可判断。
  5. 补上关键路径测试和最小观测点,再用按抽象分支、灰度或双跑继续替换内部实现。

特征测试不一定一开始就漂亮。它的任务是记录系统当前对外表现,例如重复请求是否返回同一个订单号、库存不足时返回什么错误、计价超时时订单是否会落库。对一个没有测试的遗留模块来说,先把这些行为固定下来,通常比一开始就追求理想的单元测试更现实。Michael Feathers 将这种能够插入测试、替换依赖和打破耦合的位置称为“接缝”(Seam),它是遗留代码安全演进的重要入口。[5]

每一步重构都应有停止条件:测试结果没有扩大差异,关键指标没有恶化,旧路径仍然可以回滚,且新边界已经能够承接下一次需求。没有停止条件的重构,很容易从“降低风险”变成“借重构之名扩大变更范围”。

这一阶段的目标不是“一次性得到完美代码”,而是让这段链路重新进入可控状态:可以测、可以审、可以继续演进。

2.7.3 更合理的拆分

重构前后最重要的变化不是文件数量增加,而是依赖方向和副作用位置变得可见:

graph TD
    subgraph Before[重构前:Handler 承包一切]
        A[HTTP Request] --> B[OrderHandler]
        B --> C[(Database)]
        B --> D[Inventory RPC]
        B --> E[Pricing RPC]
    end

    subgraph After[重构后:边界与编排显式]
        F[HTTP Request] --> G[OrderHandler]
        G --> H[PlaceOrderUseCase]
        H --> I[Order Domain]
        H --> J[Inventory Port]
        H --> K[Pricing Port]
        J --> L[Inventory Adapter]
        K --> M[Pricing Adapter]
        H --> N[Order Repository]
    end
type OrderHandler struct {
	uc *PlaceOrderUseCase
}

func (h *OrderHandler) Create(c *gin.Context) {
	var req CreateOrderRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	orderID, err := h.uc.Execute(c.Request.Context(), req.ToCommand())
	if err != nil {
		writeOrderError(c, err)
		return
	}

	c.JSON(http.StatusOK, gin.H{"order_id": orderID})
}

这时 Reviewer 能更容易聚焦真正重要的问题:

  • Execute 是否定义了清晰的事务边界或补偿策略;
  • ToCommand() 是否丢失关键字段;
  • writeOrderError 是否正确映射业务错误与系统错误;
  • 下层依赖是否具备幂等和超时语义。

好的结构不是为了“看起来分层”,而是为了让 Review 变得可行。

如果继续向下展开,Use Case 应该只保留流程和失败语义,领域对象负责保护不变量,Port 负责表达外部能力,Adapter 负责处理具体协议:

type PlaceOrderUseCase struct {
	inventory InventoryPort
	pricing   PricingPort
	orders    OrderRepository
}

func (uc *PlaceOrderUseCase) Execute(ctx context.Context, cmd PlaceOrderCommand) (string, error) {
	if err := cmd.Validate(); err != nil {
		return "", err
	}

	quote, err := uc.pricing.Quote(ctx, cmd.Items)
	if err != nil {
		return "", fmt.Errorf("quote order: %w", err)
	}

	reservation, err := uc.inventory.Reserve(ctx, cmd.IdempotencyKey, cmd.Items)
	if err != nil {
		return "", fmt.Errorf("reserve inventory: %w", err)
	}

	order, err := NewOrder(cmd.UserID, cmd.Items, quote.PayableAmount, reservation.ID)
	if err != nil {
		return "", fmt.Errorf("create order: %w", err)
	}
	if err := uc.orders.Save(ctx, order); err != nil {
		return "", fmt.Errorf("save order: %w", err)
	}
	return order.ID(), nil
}

这段代码仍然没有替代完整的 Saga 或本地事务设计,但它已经把几个必须讨论的问题显式化了:请求有幂等键,库存预留有业务编号,订单构造会校验不变量,外部依赖通过端口进入。若保存订单失败,系统还需要调用释放预留、进入补偿队列,或者由订单状态机记录“待确认”状态。真正的设计结论应由业务事实和失败恢复要求决定,而不是由代码层次名称决定。

经过上述拆分,下单链路已经体现了第 2.6 节所讨论的应用层、领域层和基础设施边界。第 2.6 节说明目标职责如何表达,本节说明团队如何从现状安全地演进到目标形态。

2.7.4 Reviewer 在这个场景里应该怎么提问

看到类似下单链路时,Reviewer 至少应该追问这些问题:

  • 如果前端重复提交,请求是否会创建重复订单。
  • 如果库存扣减成功、订单写库失败,后续怎么恢复。
  • 如果计价服务超时,是否会重试,重试是否安全。
  • 是否需要在日志或指标里区分哪一个阶段失败。
  • 是否有测试覆盖“库存不足”“重复请求”“下游超时”这些关键场景。

这些问题的价值远高于“变量名是不是更短一点”。

2.7.5 重构完成的判断与复盘

重构完成不等于“所有旧代码都已经删除”,而是新边界已经成为默认落点,旧路径已经不再承接新增业务,并且团队拥有删除它的条件。可以用下面的结果判断是否达到阶段目标:

  • 外部行为和关键业务不变量由特征测试或契约测试覆盖;
  • 新实现的错误、超时、重试和幂等语义能够在代码中被定位;
  • 新旧路径的延迟、错误率、数据差异和资源成本可以比较;
  • 灰度失败时能够回到旧实现,且回滚不会制造重复副作用;
  • 临时适配器、Feature Flag 和架构例外都有明确删除人和到期时间。

上线后还要复盘“哪些假设被验证、哪些假设被推翻”。如果新实现只是把复杂度从 Handler 移到了一个巨大的 Use Case,说明这次重构只改变了目录,没有改变职责;如果测试数量增加但关键失败路径仍然没有保护,说明覆盖率没有转化为安全性。重构的产出应当是更低的下一次变更成本,而不是一次漂亮的代码截图。


2.8 常见误区与团队实践建议

2.8.1 误区一:把 Review 当审批,不当协作

有些团队把 Review 理解成“有人点了 Approve 就行”,于是流程是有了,质量却没有提升。真正有效的 Review 应该是共同发现风险、澄清设计和统一标准,而不是机械签字。

2.8.2 误区二:用 Review 替代设计

如果一个 PR 到了 Review 阶段才第一次暴露“模块边界错了”“方案方向不对”,说明前面的设计沟通已经缺位。Review 可以发现架构偏移,但不应该承担完整设计工作的全部责任。

更稳妥的做法是:

  • 大改动先有 TD 或 RFC;
  • 中等改动先在 Issue、文档或评论里对齐边界;
  • PR Review 重点核对“实现是否符合设计”,而不是从零开始发明设计。

2.8.3 误区三:提交太大,导致没人真看

如果团队长期接受超大 PR,最终结果通常是:

  • Reviewer 只看自己熟悉的几处;
  • 边界性问题被“以后再说”跳过;
  • 真正复杂的改动在匆忙中直接进入主干。

团队应该把“小步可审查”当成明确要求,而不是个人习惯。

2.8.4 误区四:把风格争议交给人肉争论

缩进、换行、 import 排序、基础 lint 规则,尽量交给自动化工具。人工注意力很贵,应该用来识别架构偏移、错误语义和未来维护成本,而不是反复争论空格。

2.8.5 建议建立团队级样例、重构预算与评审文化

单靠口头要求,很难稳定提升团队编码、重构和 Review 水平。更有效的做法通常包括:

  • 为高风险场景维护 Review Checklist;
  • 为历史包袱最重的模块预留明确的重构预算,而不是永远让它排在需求之后;
  • 在复盘中把真实事故沉淀成新的评审规则;
  • 鼓励 Reviewer 提出基于风险的意见,而不是只给抽象评价。

最好的 Review 文化,不是“大家都很严格”,而是“大家知道为什么严格、严格在什么地方”。

2.8.6 推荐的自动化守护工具与规范

人肉 Checklist 适合建立共同语言,但不适合承担所有重复检查。团队可以把工具和人工 Review 按照“机器查确定性问题,人查语义和取舍”的原则分工:

目标Go 示例评审关注点
格式与基础错误gofmt、go vet、staticcheck是否存在编译器和静态分析无法表达的业务风险
错误处理errcheck、golangci-lint错误是否被正确分类、包装和恢复
架构依赖import graph 脚本、depguard 或自定义规则是否出现跨层依赖、基础设施泄漏
Go 工程规范Uber Go Style Guide包命名、并发、错误、可读性和工具配置
Code Review 流程Google Engineering PracticesChange List 是否足够小,Author 和 Reviewer 是否提供了必要证据
重构查询Refactoring.com、Refactoring.guru是否选用了行为保持的重构手法,而不是扩大变更范围

Google 的 Code Review 指南把“持续提升代码库整体健康度”作为评审标准,同时也提醒团队不能为了追求局部完美而阻止所有交付。[6] 这意味着 CI 门禁需要有边界:编译失败、禁止依赖和核心测试失败可以直接阻止合并;复杂度趋势、技术债例外和非关键覆盖率则更适合作为可见的治理指标。

PR 模板也不应只要求 Author 勾选“已测试”。对涉及核心链路的改动,至少应要求作者回答:

- 这次改动保护了哪些业务不变量?
- 是否改变了接口、数据格式或状态迁移?
- 重试、重复请求和部分失败如何处理?
- 如何验证日志、指标和 Trace 能定位故障?
- 灰度、回滚和遗留路径删除条件是什么?
- 如果使用 AI 辅助,哪些代码由工具生成,哪些行为由测试或人工验证?

最后一项不是为了追踪工具使用,而是要求 Author 对验证证据负责。AI 可以帮助生成代码,却不能替 Author 对业务行为签字。

2.8.7 团队如何把 AI 纳入工程流程

团队不必把 AI 编程单独变成一套神秘流程,可以把它纳入既有的软件交付环:

  1. 先给边界,再让工具生成:在 Prompt、仓库规则或任务描述中写清楚允许修改的包、禁止依赖、错误语义和测试要求。
  2. 先生成局部变化,再扩大范围:优先让工具处理一个函数、一个适配器或一组测试,不要让它在没有行为基线的情况下重写整个模块。
  3. 先验证行为,再接受重构:运行特征测试、单元测试、Lint 和依赖检查,确认工具没有改变状态迁移和失败语义。
  4. 让人审查不可自动化的部分:Reviewer 重点挑战业务不变量、并发安全、兼容性、观测、灰度和回滚,而不是重复检查格式。
  5. 把有效约束沉淀回仓库:如果 AI 反复生成相同的错误结构,就把问题转化成模板、示例、静态规则或测试,而不是每次重新提醒。

这里的关键不是限制工具,而是限制未经验证的变化范围。AI 越擅长生成大批代码,团队越应该要求每次提交都能说明“改变了什么、没有改变什么、如何证明”。

2.8.8 用预算和指标保护重构时间

重构预算不能只写成“有空再做”。团队可以为高风险模块设置季度预算,并把预算和可观察指标联系起来:一次需求从开始到合并需要多少时间,Reviewer 需要多少轮才能理解,关键模块的缺陷逃逸率如何变化,架构例外是否按期删除,发布后回滚和人工补偿是否减少。指标的作用是帮助团队判断“是否值得继续投入”,而不是把工程师变成追逐数字的人。

同样,测试覆盖率不能独立证明代码安全。一个没有覆盖异常分支的整洁测试套件,可能比覆盖率较低但覆盖关键业务不变量的测试更危险;一个 PR 平均很小,也不代表它没有把数据迁移、接口兼容和回滚问题藏在脚本之外。因此,指标必须和案例、故障复盘及 Review 证据一起解释。

当模块的变更耗时、线上风险和认知负担持续上升时,团队应把它视为架构信号,重新评估边界和所有权,而不是要求个人“写得更仔细”。这会把重构从临时的个人偏好,转化为可以被管理、被复盘和被持续改进的工程能力。


2.9 本章小结

编码、重构与 Code Review 不是架构之后的附属环节,而是架构能否真正落地的主战场。一个团队如果只会写设计文档,却不能持续写出边界清晰、错误可控、便于重构和审查的代码,系统质量最终仍会滑向混乱。

你可以把本章压缩成五句行动原则:

  • 编码时先守边界,再谈技巧。
  • 重构时先收口边界,再替换内部实现。
  • Review 时先看正确性与设计,再看局部实现。
  • 让提交可审查,让风险可讨论,让结论可复用。
  • 把可重复的架构判断交给自动化,把不可替代的业务取舍留给团队。

从下一章开始,我们会进一步进入生产级系统保障,讨论当代码进入真实流量、真实故障和真实资金风险环境后,还需要哪些可靠性、恢复与防资损能力。

把这些原则放回日常工作,可以形成一条足够小的行动路径。接到新需求时,先写出业务事实、变化边界和失败后的状态,再决定代码放在哪个层次;发现旧代码难以修改时,先补一条能够描述现状的测试或观测,再寻找接缝,而不是直接打开一个长期存在的重写分支;准备提交时,主动拆出机械重命名、结构调整和行为变化,让每个 PR 都能被完整理解;进入 Review 时,先检查正确性、边界和恢复能力,再讨论命名与风格;当同一条意见反复出现时,把它升级为模板、测试、Lint 或 CI 规则。

这条路径的最终目标不是制造更多流程,而是降低团队对个人记忆和英雄式谨慎的依赖。代码边界清楚,重构就能小步发生;测试和观测充分,Review 才能围绕风险讨论;架构规则可以自动检查,人工注意力才能留给业务判断;团队还要通过复盘把一次事故或一次成功迁移沉淀成下一次可以复用的证据。AI 工具会继续提高代码生成速度,但真正决定系统能否演进的,仍然是团队能否把约束、证据和责任放进工程机制。

否则每次新需求都要重新依赖少数熟悉系统的人,组织规模越大,质量越难稳定。

工程机制的价值,就是让正确做法更容易发生,让错误做法更早暴露。

2.10 延伸阅读与经典方法论索引

代码质量与演进能力不是一组可以一次性背完的规则,而是软件工程长期积累的判断框架。本章只提取与当前论点直接相关的部分,读者可以按问题继续深入。书籍中的原则存在差异,尤其是“方法应该多长”“应该先拆分还是先集中复杂度”等问题,不宜脱离上下文机械执行;阅读时应比较它们各自解决的复杂度来源,再回到业务边界和变更成本做选择。

2.10.1 设计、抽象与领域模型

主题推荐资料与本章的映射
深层模块与认知负荷John Ousterhout,《A Philosophy of Software Design》第二版,作者主页支撑 2.2 的边界、2.4.2 的变化隔离和“不要为了复用而制造浅层模块”。
代码整洁与职责Robert C. Martin,《Clean Code》用于理解命名、函数职责和可读性;应与深层模块观点结合,而不是把短函数当作绝对目标。
实用主义与重复控制Andrew Hunt、David Thomas,《The Pragmatic Programmer》20 周年版,出版社页面支撑 DRY、KISS、正交性、可逆性和 Boy Scout Rule;适合对应 2.3 与 2.4 的小步改进。
领域驱动设计Eric Evans,《Domain-Driven Design》,O’Reilly 页面支撑通用语言、聚合、值对象和领域边界,主要对应 2.2 和 2.6。
DDD 工程实现Vaughn Vernon,《Implementing Domain-Driven Design》,InformIT 样章补充聚合、应用服务、仓储和领域事件的工程落地,帮助读者理解 2.6 中示例的取舍。

2.10.2 重构、遗留系统与演进式架构

主题推荐资料与本章的映射
代码坏味道与行为保持重构Martin Fowler,《Refactoring》第二版,作者页面;在线重构目录支撑 2.3.1 的腐化信号和 2.3.3 的原子化重构。重构的关键是保持可验证的外部行为,而不是一次性重写。
无测试遗留代码Michael Feathers,《Working Effectively with Legacy Code》,Pearson 页面支撑 2.7.2 的特征测试、接缝、依赖打破和渐进式迁移。
渐进式替换Martin Fowler,Strangler Fig Application支撑 2.3.4 的绞杀者模式,重点关注风险分散、边界切片和回滚条件。
演进式架构Neal Ford、Rebecca Parsons、Patrick Kua 等,《Building Evolutionary Architectures》第二版,O’Reilly 页面支撑 2.4.6 的 Architecture Fitness Functions,以及用自动化约束保护长期演进。

2.10.3 Code Review、Go 工程与在线指南

主题推荐资料与本章的映射
Code Review 标准Google,Engineering Practices Documentation;The Standard of Code Review支撑 2.5 和 2.8:Review 的目标是持续提升代码库整体健康度,同时保持交付能力和沟通效率。
Code Review 实证研究Google,Modern Code Review: A Case Study at Google说明 Review 除了发现缺陷,还承担设计一致性、测试充分性、安全和知识传播等作用。
Go 工程规范Uber,Uber Go Style Guide配合 2.6 的 Go 示例,参考错误处理、并发、包命名、测试表和 Lint 配置;不把团队规范误写成语言标准。
重构与模式图解Refactoring.guru作为快速查询工具,用于查阅坏味道、重构手法和常见设计模式;复杂场景仍应回到原始书籍和业务约束。

2.10.4 本章方法论的使用顺序

遇到一段难以修改的代码时,可以按下面的顺序阅读和行动:

  1. 先用 Ousterhout、Evans 和 Vernon 的观点判断边界、职责和业务模型是否清楚。
  2. 再用 Fowler 和 Feathers 的方法寻找坏味道、接缝和可插入测试的位置。
  3. 如果改动跨越模块或服务,使用 Branch by Abstraction、Strangler Fig 或 Shadow Run 设计迁移路径。
  4. 用 Google Code Review Guide 和本章 Checklist 组织人类评审。
  5. 用 Fitness Functions、Lint、依赖检查和测试把已经达成的共识固定下来。

这套顺序的目的不是增加流程,而是避免把所有问题都推给最后一次 PR Review:设计问题应尽量在设计阶段暴露,结构问题应在重构阶段收口,确定性的规则应由 CI 执行,剩下的业务取舍才值得消耗专家的人工注意力。

2.10.5 参考资料

[1] Martin Fowler, Refactoring, 2nd ed., Addison-Wesley, 2018, https://www.martinfowler.com/books/refactoring.html。

[2] Martin Fowler, “Strangler Fig Application”, https://martinfowler.com/bliki/StranglerFigApplication.html。

[3] Neal Ford, Rebecca Parsons, Patrick Kua, Building Evolutionary Architectures, O’Reilly Media, 2017, https://www.oreilly.com/library/view/building-evolutionary-architectures/9781491986356/。

[4] Uber, “Uber Go Style Guide”, https://github.com/uber-go/guide/blob/master/style.md。

[5] Michael Feathers, Working Effectively with Legacy Code, Prentice Hall, 2004, https://www.pearson.com/en-us/subject-catalog/p/working-effectively-with-legacy-code/P200000000297。

[6] Google, “The Standard of Code Review”, https://google.github.io/eng-practices/review/reviewer/standard.html。

[7] John Ousterhout, A Philosophy of Software Design, 2nd ed., Yaknyam Press, 2021, https://web.stanford.edu/~ouster/cgi-bin/book.php。

[8] Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, Addison-Wesley, 2003, https://www.domainlanguage.com/ddd/。

[9] Vaughn Vernon, Implementing Domain-Driven Design, Addison-Wesley, 2013, https://www.pearson.com/en-us/subject-catalog/p/implementing-domain-driven-design/P200000009616。

[10] Go Team, “The Go Programming Language Specification”: Exported identifiers、Composite literals、The zero value, https://go.dev/ref/spec,核验日期:2026-10-09。用于说明包访问边界与零值语义,不将封装误写成业务不变量的编译期保证。

第 3 章 生产系统治理:可靠性保障、韧性演进与技术债务

生产系统不会因为上线成功就自动可靠。需求、流量、依赖、数据和组织都会变化,系统若没有持续的约束与反馈,就会逐渐变得难以解释、难以变更、难以恢复。本章把生产可靠性定义为四个结果:关键业务可完成、结果正确、失败可恢复、过程可审计。

本章不讨论“永不出错”的理想系统,也不把某个中间件当作可靠性方案。可靠性是一个控制回路:先定义业务结果和可接受的风险,再用保护机制限制故障,用遥测发现偏差,用对账和补偿收敛状态,最后把事故经验固化为治理规则。Google SRE 将 SLO 与错误预算用于平衡可靠性和变更速度,正是这种控制回路的经典表达。[1]

3.1 引言与全景框架

3.1.1 问题边界与共同模型

本章覆盖面向用户的在线服务、异步任务和带有支付、库存、优惠、供应商等外部依赖的交易系统。它关注的是生产中的失效和恢复,不展开容量规划、业务领域建模或安全纵深防御;这些主题只在与本章的故障边界相交处出现。

可靠性至少有四层:

层次判断问题不能用什么替代
可用性用户能否完成关键动作?只看 CPU、Pod 存活或接口 200
正确性金额、状态、库存是否符合不变量?只看请求成功率
可恢复性部分失败后能否回到一致状态?只写重试脚本
可解释性能否定位、审计并证明发生了什么?只保留一行错误日志

生产治理可以抽象为以下反馈环:

业务目标与风险
      │ 定义 SLI/SLO、错误预算、数据不变量
      ▼
设计约束 ──► 保护机制 ──► 运行遥测 ──► 应急止血
   ▲             │             │            │
   │             └──────► 失败记录、对账、补偿 ◄────┘
   │                                      │
   └──────────── 复盘、还债、规则固化 ◄────┘

技术债务会提高故障概率和恢复成本;保障机制可以降低爆炸半径,却可能掩盖债务;治理要把事故、演练和数据转化为长期约束。因此,判断一项工作时应同时问:它减少了哪种风险?它增加了什么复杂度?失败后谁负责收敛?

设计时可以先画一张“约束画布”,把抽象的稳定性目标翻译成可评审的工程问题:

维度必须明确的约束例子与证据
业务哪些动作必须成功,哪些结果可以延迟?支付确认不可重复,推荐结果可以延迟;由用户旅程和损失评估证明
流量平均、峰值、突发和热点如何分布?假设峰值是平时的 5 倍,热点 SKU 占请求的 20%;需要压测或生产测量
数据谁是权威源,允许多长时间的不一致?支付账本是事实,搜索是投影;允许分钟级新鲜度但不允许金额不一致
资源线程、连接、队列、磁盘和第三方配额的上限是什么?每个租户独立并发配额,批任务不能占满在线连接池
变化什么能灰度、回滚或前向修复?代码和配置可回滚,数据库字段先扩展后收缩
组织谁值班、谁批准高风险动作、谁关闭行动项?服务 owner、事故指挥者和数据 owner 分离但职责明确

容量数字如果只是方案假设,就必须标明“假设”;上线后要用实际 QPS、P99、队列长度和失败率替换它。稳定性不是把每个指标都做到最大,而是在业务约束下给资源、延迟和恢复能力分配预算。这样才能解释为什么结算链路获得独立连接池、为什么通知链路可以被降级,也能解释为什么一个看似无害的批处理会被暂停。

3.1.2 90 天治理路线图

数字是规划示例,不是通用标准。真正的目标应由业务损失、用户旅程和团队能力校准。

阶段重点验收证据
0–30 天:可见识别关键旅程、权威数据源、依赖和债务SLI/SLO 草案、服务目录、事故时间线、债务登记表
31–60 天:可控建立超时、限流、幂等、告警、回滚、DLQ 和对账最小闭环保护矩阵、燃烧率告警、一次演练、一次差异收敛
61–90 天:可演进将错误预算、变更评审、复盘行动项和还债预算纳入交付节奏ADR、灰度指标、行动项关闭率、DORA 趋势

DORA 的历史 Four Keys 已演进为五项交付指标:部署频率、变更前置时间、失败部署恢复时间、变更失败率和部署返工率。失败部署恢复时间只衡量需要立即干预的失败部署恢复,不等于全部线上事故的通用 MTTR;部署返工率关注由生产事故引发的非计划部署。应围绕同一应用或服务观察趋势,不能把不同上下文的团队指标直接混合排名,也不能替代业务 SLO。[2]

3.2 技术债务治理:管理摩擦而不是追求零负债

3.2.1 债务分类与利息

技术债务不是所有“不漂亮的代码”,而是为了当前收益接受了未来摩擦的设计或实现选择。Fowler 用“有意/无意 × 明智/鲁莽”描述债务的性质;该模型的价值不在贴标签,而在于帮助团队讨论偿还成本、收益和时机。[3] Kruchten、Nord 和 Ozkaya 则强调要识别债务项、评估影响、决定消除/降低/缓解,并把管理动作纳入发布计划。[4]

明智鲁莽
有意为验证市场先使用单库,记录边界、利息和转向条件明知没有幂等和回滚仍赶着上线,只用“以后补”掩盖风险
无意需求和流量变化后才发现原设计不合适,属于学习成本不理解约束而复制模式,造成耦合、数据污染和无人负责

债务的“本金”是重构或迁移工作量,“利息”是每次变更增加的耗时、事故概率、值班负担和恢复损失。优先级不应由代码观感决定,而应由下面的近似模型决定:

债务优先级 ≈ 影响范围 × 发生概率 × 利息增长率 × 恢复成本
             ÷ 处理成本

模型只用于排序,不是精确财务计算。一个可操作的债务登记项至少包含:影响旅程、触发条件、当前绕行、owner、利息信号、偿还方案、停止新增债务的门槛和复核日期。没有 owner 和日期的“技术债列表”只是愿望清单。

债务登记还要记录“暂不偿还”的理由。可以暂缓的债务通常满足三个条件:影响范围小、利息没有明显增长、存在可验证的运行护栏;一旦其中一个条件失效,就必须重新评审。例如,一个只读报表仍使用旧查询层,若访问量稳定且有查询超时,可以暂缓;如果它开始被在线结算复用,边界已经变化,就不能继续用原来的绿灯结论。

偿还方式也不只有重写。常见选择包括:

方式适用前提主要风险
消除组件或流程已经没有业务价值删除依赖时遗漏隐藏调用
降低边界明确但实现过于复杂重构只改善局部,没有降低系统耦合
缓解暂时无法迁移,仍有稳定性护栏护栏失效后债务利息突然暴露
接受利息稳定且处理成本高于收益业务增长使旧判断失效

每个债务项应有一个可观察的“利息指标”,比如构建耗时、变更回滚率、人工补偿量、慢查询比例、告警噪声或新成员上手时间。指标下降不等于债务消失,但能证明偿还动作确实改善了系统,而不是只改变了代码形状。

3.2.2 债务准入与偿还 ADR

允许借债,但必须让它可见、可限额、可回收。准入表可以把“现在能否上线”从口头争论变成证据判断:

决策前提必须补上的护栏重新评估条件
绿灯影响非核心路径,失败可降级且无数据损坏owner、监控、回滚开关流量、金额或依赖数达到阈值
黄灯影响核心路径,但有人工或自动补偿幂等、审计、对账、值班预案错误预算快速消耗或人工成本上升
红灯可能重复扣款、丢失权威事实或无法回滚暂停发布,先修复或隔离只有验证完成后才解除

关键重构应写成 ADR,而不是只写“推荐方案”。例如把供应商同步从“业务库直接改写搜索表”迁移为“权威库 + Outbox/CDC + 可重放投影”:

ADR 项内容
背景多个任务直接写读模型,失败后无法判断哪个版本是真实状态
候选继续共享库、双写、Outbox/CDC、全量事件溯源
决策以权威库为唯一写入源,Outbox 或 CDC 驱动投影,投影可重建
获得事实可追溯、发布可重放、读写扩展独立、差异可对账
牺牲多一套事件与版本管理;读模型存在延迟,不能宣称即时一致
已接受风险Relay 重复发布、CDC 延迟和历史脏数据迁移
验证指标投影延迟、差异率、重放成功率、人工介入量
重评条件事件吞吐、数据保留成本或一致性要求发生变化

线上重构优先采用可逆步骤:先建立抽象边界和双读比对,再影子写入或灰度投影;差异可解释后切流,保留回滚窗口,最后删除旧路径。直接停机重写通常把技术风险变成业务不可用风险。

3.3 韧性架构与保护机制

3.3.1 SLI、SLO 与错误预算

SLI 是观测,SLO 是内部目标,SLA 是对外契约。SLO 应围绕用户旅程定义,并同时覆盖速度、正确性、新鲜度和完整性;不要用组件健康替代业务结果。[1]

旅程SLI 示例SLO 示例(假设)降级/失败语义
浏览基础信息成功率、P9999.9% 在 1 秒内返回推荐、营销标签可为空
结算价格/库存校验成功率、P9999.5% 在 2 秒内完成非核心权益可延后,价格不能未知
创单明确成功或失败率、P9999.95% 在 3 秒内结束不返回半成功;超时进入查询/对账
支付回调合法回调处理延迟、重复率99.99% 在 30 秒内幂等收敛延迟回调必须可重放
数据发布权威版本到读模型的延迟99% 在 5 分钟内可见旧版本可读,但必须标记水位

错误预算是 1 - SLO,但必须先定义排除项、统计窗口和错误分类。预算充足时可以发布和实验;消耗过快时收紧灰度、优先修复高利息债务;预算耗尽时暂停高风险变更,只允许修复和安全变更。[1]

错误预算要落到决策而不是仪表盘。团队可以把预算状态分为绿、黄、红三档,并为每档预先约定动作:

预算状态观测信号发布策略治理动作
绿短、长窗口燃烧率均低于门槛正常发布,允许小规模实验优先偿还高利息债务,验证新护栏
黄短窗口明显燃烧或长窗口持续恶化缩小灰度,暂停非必要高风险变更事故指挥者复核依赖、容量和最近变更
红预算耗尽或业务正确性指标越界只允许止血、修复和安全回滚冻结发布,建立恢复计划,完成复盘后再解冻

燃烧率应同时看短窗口和长窗口。短窗口能在突发事故中快速触发止血,长窗口能识别低强度但持续的退化;只看平均值会把尖峰和局部租户的损失稀释掉。SLO 的统计口径也要写清楚:是按请求数、用户旅程、金额、订单还是时间计算;超时、客户端取消、依赖不可用和已知维护是否分别计入;一条请求跨多个服务时,不能把各服务的百分比简单相加。

错误预算不是让团队故意消耗故障额度。它把可靠性投资和交付节奏放在同一张决策表中:预算足够不代表可以跳过幂等和回滚,预算不足也不代表只能停工。若业务正确性受到威胁,即使可用性预算尚未耗尽,也应立即冻结相关动作;若某个 SLO 长期稳定且成本过高,则可以通过 ADR 重新评估目标、采集方式和投入,但要保留历史基线,避免用改变口径制造“改善”。

3.3.2 故障模型与隔离边界

先定义系统会怎样坏,再决定保护措施。常见故障与第一防线如下:

故障传播路径第一防线仍需解决的问题
慢依赖下游慢 → 线程/连接池耗尽分层超时、舱壁、熔断已发出的请求是否仍占用下游资源
重试风暴失败 → 同步重试 → 更拥塞指数退避、Full Jitter、预算操作是否幂等,是否有全链路上限
流量冲击热点/爬虫 → 队列堆积限流、排队上限、热点隔离被拒请求的用户体验和恢复顺序
部分发布新版本只在部分实例失败灰度、自动回滚、版本标记数据迁移是否向后兼容
异步不一致事务成功、消息未发或重复Outbox、幂等消费、对账权威事实和补偿 owner 是谁
人工误操作错误配置/补偿扩大影响审批、双人复核、审计、演练紧急操作能否快速撤销

故障域不能只按服务名划分,还要按流量、资源、地域和数据分片划分。一个推荐接口变慢,如果和订单接口共享线程池、连接池及缓存实例,推荐故障就会变成交易故障;一个热点商品如果和普通商品共享库存锁,也会把局部竞争扩大成全局排队。故障域的目标不是让每个模块都独立,而是让影响边界可以预测、监控和切断。

故障域典型隔离对象设计动作验证问题
业务域浏览、结算、支付、履约服务、数据、发布和权限边界非核心域变慢会不会占满核心资源?
流量域普通、活动、爬虫、后台批任务租户配额、独立队列、热点限流一个租户能否耗尽公共配额?
资源域线程池、连接池、缓存、磁盘舱壁、配额、独立实例或分片资源耗尽后核心请求还能否运行?
地域域可用区、城市、跨地域链路就近路由、区域熔断、灾备切换切流是否会把故障带到第二个区域?
数据域租户、SKU、订单分片分片键、热点拆分、读写隔离一个热点分片是否阻塞全局扫描?

依赖也要分级,否则所有错误都会被当成“服务不可用”:

依赖等级失败后的业务语义保护策略
强依赖没有它不能安全推进快速失败、冻结状态、查询或补偿
弱依赖可以用较低质量结果继续默认值、旧快照、隐藏模块、异步补齐
后置依赖不影响当前响应Outbox、队列、有限重试和 DLQ
观测依赖只影响日志或统计本地缓冲、采样,绝不阻塞主链路

例如结算时库存预占和价格快照是强依赖,推荐凑单和营销标签是弱依赖,积分发放和通知是后置依赖,埋点上传是观测依赖。依赖分级必须写入接口契约和 runbook,不能只存在于熟悉系统的工程师脑中。

高可用也不等于“多部署几个实例”。应用层需要无状态和快速替换,数据层需要复制、备份与恢复演练,消息层需要副本、积压水位和重平衡预案;每一种冗余都必须配合切换动作。没有演练过的备用链路,只能称为潜在容量,不能称为已验证的恢复能力。

3.3.3 韧性保护机制矩阵

Nygard 将稳定性模式集中在超时、熔断、舱壁、降级和可恢复变更等防御边界;AWS Reliability Pillar 也强调超时、限流、有限重试、快速失败和紧急开关。[5][6] 机制必须组合使用,单独增加重试往往只会放大故障。

机制触发条件防御目标核心参数反模式与补救
超时超出本层 deadline释放调用方资源,避免级联阻塞请求总预算向下游递减全链路固定 5 秒;改为按用户旅程分配预算
重试可恢复网络/过载错误消除瞬时抖动只重试幂等操作;次数和总时长封顶无差别重试写请求;先用幂等键和错误分类
限流速率、并发或队列超阈值保护核心资源令牌桶/并发上限/租户配额只在网关限流;在热点和下游边界也设上限
熔断依赖错误率或延迟持续超标切断故障依赖关闭、打开、半开;探测流量小熔断后无限等待;必须有明确 fallback
舱壁资源池被不同流量共享防止一个租户/依赖拖垮全局线程、连接、队列按风险隔离只隔离线程不隔离连接;需成对配置
降级非核心依赖不可用或预算不足保住核心业务结果旧数据、默认结果、异步完成把错误伪装成成功;返回状态必须可解释
幂等超时、重复投递或人工重放防止重复副作用业务键、唯一约束、状态机只在缓存去重;权威状态必须落库
变更护栏灰度指标越界限制发布爆炸半径小流量、自动暂停、可回滚只看平均值;同时看分位数和业务正确性

重试必须有全链路预算。Full Jitter 的一个最小实现如下;attempt、最大次数和总 deadline 需要由调用链共同决定,而不是每个 SDK 各自决定:

import random

def backoff_seconds(attempt, base=0.1, cap=3.0):
    limit = min(cap, base * (2 ** attempt))
    return random.uniform(0, limit)

AWS 对退避、抖动和最大重试次数的说明指出:仅有指数退避仍可能让大量客户端同时重试,因此要加入随机抖动并设置上限。[7] 对非幂等写操作,正确策略通常是返回可查询的处理中状态,由状态查询、Outbox 或人工队列收敛,而不是再次盲写。

超时必须按端到端预算分配。假设结算初始化总预算为 2 秒,不能让每个下游都使用 2 秒:商品校验可以占 150 毫秒,库存预检查 300 毫秒,计价 500 毫秒,营销试算 300 毫秒,地址运费 300 毫秒,聚合与序列化保留 200 毫秒。预算只是示例,实际值要由 P99、依赖等级和用户体验测量得到;入口 deadline 应向下游传递,子调用只能消耗剩余预算。

超时之后的语义也要区分:读请求可以返回旧快照,弱依赖可以异步补齐,强依赖写请求必须进入可查询的处理中状态。尤其要警惕“客户端超时但服务端已经提交”的不确定结果,此时直接重试可能重复扣款或重复创建订单,正确动作是查询业务状态、等待回调或进入对账。

幂等不是一个缓存键,而是一组持久化约束:

动作幂等键持久化约束参数冲突处理
创建订单user_id + request_id唯一索引、请求摘要、状态机相同键不同摘要直接拒绝
支付回调渠道流水号支付流水唯一、条件状态更新已处理返回同一结果
消息消费event_id去重表或消费流水唯一旧版本事件不覆盖新状态
释放库存reservation_id + action释放流水唯一、预占状态机已释放视为成功
人工补偿工单号或补偿批次补偿记录、审批和审计已执行只能查询,不能再次执行

中间件参数也属于保护机制。MySQL 连接池的 MaxOpenConns、ConnMaxLifetime、建连/读/写超时要和服务端 max_connections、空闲连接回收时间协调;Redis 的活跃连接上限、获取连接等待时间、读写超时和重试次数要与 maxclients、慢命令和热点键共同评估。评审至少检查三组关系:应用连接池总量小于服务端上限并留有恢复余量;应用连接生命周期不长于代理和服务端的断连时间;获取连接、建连、读、写各阶段都有上限。

现象不能直接下的结论应检查的证据
MySQL 连接池打满不一定是连接数太小慢查询、长事务、锁等待、连接泄漏和请求排队
Redis 获取连接超时不一定是 Redis 宕机慢命令、大 key、连接未归还、客户端池配置
偶发连接断开不一定是网络随机抖动wait_timeout、代理空闲超时、连接生命周期和重试放大
队列持续增长不一定是消费者数量不足单条处理时长、毒消息、下游限流和分区热点

参数优化不能只盯吞吐。把连接池放大、重试次数提高、队列上限调高,短期可能让成功率变好,长期却可能把压力转移到数据库、消息代理或外部供应商。每个参数变更都要记录目标、影响边界、回滚方式和验证指标。

3.3.4 变更与演练

一次发布就是一次故障注入机会。生产准入至少检查:依赖是否有超时、核心写是否幂等、配置是否可审计、数据库变更是否向后兼容、灰度是否能自动停止、回滚是否不会覆盖新数据。混沌演练不追求制造戏剧性事故,而是验证“发现—止血—恢复—复盘”链路和 owner 是否真实存在。

变更风险应按影响范围而不是按代码行数分级。只改文案的变更可能是低风险,只改一行限流配置也可能让全站不可用。评审可以使用以下护栏:

变更类型主要风险必须具备的护栏放量与停止条件
只读代码或展示层延迟、渲染和客户端兼容性版本标记、回滚包、基础指标小流量灰度,错误率或 P99 越界即停
核心写逻辑重复副作用、状态错迁移幂等键、状态机、影子比对、前向修复方案先内部流量,再按租户或分片放量
数据库结构锁表、兼容性和回填压力扩展后迁移、双读/双写窗口、回填限速观察锁等待、延迟和数据差异
配置与规则突发限流、计价或权限错误配置版本、审批、自动校验、快速禁用先小范围生效,业务正确性优先于吞吐
基础设施与依赖区域级不可用、连接风暴容量余量、切流预案、恢复演练分区或可用区逐步执行,失败自动暂停

每次发布至少保留四条证据:变更前基线、变更时间线、灰度期间的业务对账结果、变更后的验证结论。回滚也不是无条件安全的:如果新版本已经写入新格式数据,直接回滚旧代码可能无法读取;如果新版本已经发送新事件,回滚消费者可能重复或丢弃事件。因此数据库迁移和事件协议要优先采用向后兼容,无法兼容时设计一次性的前向修复。

混沌实验可以从低危、可恢复的故障开始。实验单应写明假设、爆炸半径、注入方式、停止开关、预期信号、恢复动作和验收证据。例如“支付查询延迟升高时,订单服务应在总预算耗尽后进入处理中,不得重复发起扣款;五分钟内限流开关生效,恢复后差异项全部进入对账状态机”。实验失败不等于系统失败,真正失败的是无法观测、无法停止或没有 owner。阿里云的混沌工程实践同样把故障注入与稳定性验证、应急预案和持续改进结合起来。[19]

3.4 可观测性与 1-5-10 应急响应

3.4.1 从图表到可回答的问题

可观测性不是把所有日志搬进平台,而是能从输出推断内部状态,并回答“谁受影响、哪条路径变慢、是否发生错误副作用、恢复是否收敛”。OpenTelemetry 将 traces、metrics、logs 和上下文传播定义为可组合的遥测信号;Dapper 论文则说明了分布式追踪在跨服务定位因果链上的价值。[8][9]

基础监控可用 RED 和 USE 组织,但交易系统必须再加业务正确性:

视角指标用途
REDRate、Errors、Duration看服务对请求的外部表现;优先用分位数而非平均值
USEUtilization、Saturation、Errors看 CPU、线程池、连接池、队列、磁盘和网络的资源压力
业务成功/失败、状态迁移拒绝、金额差异、库存差异、延迟和新鲜度识别“接口成功但业务错误”
事件发布、配置、扩容、熔断、补偿、人工操作把变化与指标异常放到同一时间线

高基数维度要有边界。trace_id 适合放在日志和 Trace,不适合直接作为长期指标标签;租户、商品、错误码、版本等维度应按查询需要分层采样、聚合或保留短期明细。统一字段至少包括 request_id、trace_id、业务单号、版本、水位、错误分类和脱敏后的操作者;禁止把支付凭证、密钥或完整个人信息写入日志。

观测体系应按“入口—服务—资源—数据—事件”分层,而不是按工具分组。入口层回答用户旅程是否成功;服务层回答哪一个接口、版本或依赖变慢;资源层回答线程、连接、队列和磁盘是否饱和;数据层回答状态、金额、库存和水位是否收敛;事件层把发布、配置、扩容、熔断、补偿和人工操作串到同一条时间线上。这样发生事故时,值班人可以从业务影响下钻到技术原因,再反向验证恢复结果。

指标命名和标签必须服务于查询。稳定维度如服务名、接口、区域、状态码和版本可以长期保留;订单号、用户号和追踪号应进入日志或短期明细索引。对高基数数据至少设置三道控制:采集端采样,存储端保留期限,查询端限制扫描范围。采样不能覆盖所有信号:错误、慢请求、状态迁移拒绝和资损差异应保留更多样本,成功请求则可以按比例采样。OpenTelemetry 的信号模型强调上下文传播和统一语义,这使得日志、指标与 Trace 能够围绕同一请求关联,但采集标准本身并不替团队决定保留多久、哪些字段可以公开。[14][20]

告警应满足四个条件:有明确影响、有责任人、有动作、有去重。CPU 高并不一定是事故,创单成功率下降、支付差异增长或队列预计超过数据新鲜度预算才是更接近用户结果的信号。每条告警都要注明当前值、基线、窗口、影响对象和建议动作;重复告警合并成事件,恢复通知必须带上验证结果。对长期无动作的告警要降级、改造或删除,告警数量本身不能作为可观测性成熟度指标。

容量与恢复验证也属于可观测性。容量估算至少区分稳定流量、峰值流量和故障转移后的流量,并把请求数、请求体大小、下游放大倍数、并发连接和数据增长一起计算。压测不能只看吞吐,还要观察 P99、错误分类、队列积压、锁等待、GC、连接池和恢复速度;压测数据必须标注是实验结果还是容量假设。备份则要按 RPO 和 RTO 设计:有备份不代表能恢复,必须定期恢复到隔离环境,校验表数量、关键账本、索引、权限和应用可读性。恢复演练中还要验证 DNS、密钥、配置、消息位点和外部依赖,避免只恢复数据库却无法启动完整业务。

3.4.2 1-5-10 应急时钟

“1-5-10”是团队约定的响应节奏,不是行业标准:1 分钟确认信号,5 分钟完成止血,10 分钟建立可协作的恢复计划。它的目的不是要求十分钟修复所有事故,而是防止团队在争论根因时继续扩大损失。

时间责任必须产出禁止动作
0–1 分钟值班人确认告警真实、标记事故级别、通知 on-call反复刷新看板却不建立事件
1–5 分钟事故指挥者停止发布、限流/降级/切流、记录影响范围未知副作用下大面积重启或改库
5–10 分钟指挥者 + 领域 owner时间线、假设、下一动作、回滚/补偿负责人多人并行执行同一不可逆操作
10 分钟后恢复小组按证据定位,持续更新影响和预算把“指标恢复”误判为“数据已收敛”

燃烧率告警要同时配置短窗口和长窗口:短窗口用于快速止血,长窗口用于发现慢性退化。告警输出必须能直接连接到动作,例如“创单 SLO 燃烧、库存补偿 backlog 增长、最近版本为 X、建议暂停发布”,而不是只发送一个红色仪表盘。

3.5 数据一致性、对账与资损防控

3.5.1 权威事实与异步传播

跨服务系统不应追求所有表同时更新,而应先回答:谁拥有业务不变量,哪条记录是权威事实,哪些是可重建投影,多久的延迟可接受。DDIA 将可靠性、可扩展性、可维护性以及数据系统的权衡放在同一框架中;中文《凤凰架构》也把分布式事务的核心问题归结为跨边界状态如何达成可接受的一致性与可恢复性。本章据此采用“权威事实 + 可重放派生状态 + 对账收敛”的模型。[10][16]

当一次本地事务既更新业务实体又发布事件时,Transactional Outbox 将事件写入同一数据库事务,再由 Relay 投递;它避免了数据库提交和消息发送之间的双写窗口,但 Relay 仍可能重复发布,因此消费者必须幂等。[11][17]

命令
  │ 本地事务:业务表 + outbox_event
  ▼
权威库 ──► Outbox/CDC ──► Broker ──► 幂等消费者 ──► 读模型/补偿队列
  │             │              │              │
  └─────────────┴──────────────┴──────► 对账与水位

Outbox 至少记录 event_id、聚合 ID、聚合版本、事件类型、payload 引用、创建时间、投递状态和最近错误。大 payload 放对象存储并保存哈希,不要把不可查询的大对象塞进主表。消费者用 event_id 或业务幂等键建立唯一约束,同时校验版本,拒绝旧事件覆盖新状态。

3.5.2 三层对账与统一状态机

对账不是简单地比较两张表,而是按时间、粒度和权威口径分层:

层次频率比较对象目标处理方式
实时流水分钟级事件、支付回调、库存动作发现单笔缺失、重复或乱序自动重试、查询状态、进入差异表
准实时汇总小时级服务汇总、渠道汇总、版本水位发现分片或批次偏差暂停发布、重放分片、生成工单
离线总账日/日切权威账本、外部账单、库存快照发现长期漏记和资损人工复核、调账、审计归档

差异项统一进入状态机,而不是由不同业务各写一套脚本:

DETECTED → CLASSIFIED → RETRYING → VERIFIED
    │           │            │          │
    │           │            └─失败────┘
    │           └─需人工──► MANUAL_REVIEW → COMPENSATED
    └──────────────────────────────► IGNORED(必须有理由)

不变量是:任何差异都有来源引用、分类、影响范围、owner、下一动作和审计记录;COMPENSATED 必须有验证证据,IGNORED 必须有业务豁免和过期时间。补偿任务要保存 attempt、next_run_at、错误分类、payload 引用和 idempotency_key。超过次数不代表成功或失败,而是转入人工复核。

DLQ 不是垃圾桶。可以进入 DLQ 的是暂时无法自动处理、但仍有明确业务语义和原始凭证的记录;格式损坏、敏感信息泄露或无法识别权威对象的记录应进入隔离区。重新投递必须支持按租户、批次、错误类型和时间范围选择,设置速率上限,禁止无限自动重放。

对账表本身也要设计成可运营的数据产品。差异记录至少包含权威来源、对端来源、比较批次、业务主键、双方状态、金额或数量、首次发现时间、最近重试时间、错误分类、影响等级和处理人。比较逻辑要区分“未到水位”“暂时不可见”“确定缺失”“重复副作用”“金额不一致”和“已人工豁免”,否则不同性质的问题会被混成一个数量。水位要有明确语义,例如事件时间、提交时间、消费位点或外部账单日切时间,不能把查询时刻随意当成业务截止时间。

自动补偿必须遵循小步、可停、可验证。先根据幂等键查询当前事实,再执行最小必要动作,动作完成后重新读取权威状态,最后将证据写回差异项。重试按错误分类处理:网络超时可以延迟重试,版本冲突需要重新读取,金额不一致只能冻结并人工审批,权限或数据格式错误应立即隔离。补偿 worker 要限制并发、租户和总金额,设置熔断与暂停开关;当补偿量突然超过基线时,优先暂停而不是扩大自动化范围。

人工接管也必须工程化。工单不能只写“请处理”,应绑定业务主键、当前状态、推荐动作、禁止动作、审批角色和验证查询。高风险动作采用双人复核,执行者不能同时批准自己的调账;每次操作记录前值、后值、原因、规则版本和关联事故。人工不是自动化失败后的黑洞,而是状态机中的一个可审计节点,处理结果仍需回到实时或日切对账中验证。

3.5.3 端到端案例:支付成功但订单未更新

假设支付渠道已返回成功,订单服务因网络超时没有完成状态推进。正确目标不是“再次扣款”,而是让订单、支付账本和用户可见状态最终收敛。

  1. 正常链路:订单服务创建 PENDING_PAYMENT,支付服务记录支付单;支付成功与 payment_succeeded 事件在本地事务中写入支付账本和 Outbox;Relay 投递事件,订单消费者以 payment_id 幂等推进到 PAID。
  2. 故障发生:消费者处理后返回前崩溃,Broker 重投;唯一键拒绝重复副作用,状态机把重复事件视为已处理。若支付服务提交成功但 Relay 未发出,Outbox 扫描发现未投递记录。
  3. 实时收敛:订单查询支付服务或接收渠道回调;若支付账本为成功而订单仍为待支付,创建差异项,重放事件或执行只推进状态的补偿命令。
  4. 准实时对账:按 payment_id、订单号和渠道流水号联查,校验金额、币种、商户和版本;金额不一致时禁止自动补偿,转人工冻结。
  5. 最终验证:订单状态、支付账本、库存动作和通知记录均有证据;用户看到“已支付/处理中”的状态,客服可查询时间线,财务可在日切对账中复核。

这里的关键不变量是:支付成功只允许订单从 PENDING_PAYMENT 向前推进,不允许回退覆盖;同一支付单只能产生一次订单支付事实;任何未知状态都必须可查询,不能用超时直接展示失败。Saga 可以协调跨服务步骤,但补偿是业务动作,不是数据库回滚;库存释放、退款和订单关闭各自需要明确前提、权限和审计。[12]

异常分支不能做什么允许的恢复动作收敛证据
支付请求超时,结果未知立即再次扣款查询渠道、等待回调、以支付单号去重渠道流水、支付账本和订单状态一致
回调重复到达重复推进订单或重复发券依据渠道流水和事件键返回已处理结果重复计数不增加业务副作用
金额或币种不一致自动标记支付成功冻结订单和支付单,进入人工复核差异原因、审批和最终处置记录
支付成功、库存失败直接取消支付且不通知用户重试预占、切换可接受库存策略或退款库存流水、退款流水和用户状态可查询
订单已关闭、晚到成功回调让旧事件覆盖关闭状态依据业务规则退款或转人工,不回退状态订单、支付和退款三方对账通过
补偿任务反复失败无限重放分类进入 DLQ,限制金额和速率后人工接管重试次数、失败分类和处理结论完整

这个案例说明,最终一致性不是“过一会儿就会好”,而是有状态、有水位、有证据和有停止条件的恢复过程。对外展示也应区分“失败”“处理中”“成功”三种语义:在支付结果未知时展示处理中,既避免用户重复操作,也避免系统把未知事实伪装成失败。美团的对账体系实践把实时、准实时和离线核对结合起来,目的正是将单笔异常、批次偏差和长期账务问题放进不同处理节奏中。[18]

3.5.4 资损三道防线

资损包括错误计价、重复扣款、重复退款、超卖、少卖、优惠预算穿透、结算错账和人工调账失控。最小治理模型是“三道防线”:

防线典型控制失败后动作
事前防错金额整数化、版本化规则、状态机、权限分级、唯一约束、影子比对阻断发布或拒绝非法命令
事中拦截金额/库存不变量、风险阈值、重复检测、限额、人工二次确认冻结高风险请求,保留凭证
事后收敛实时流水、日切总账、差异工单、补偿、调账和审计先止血,再核算范围,最后补偿

任何金额和库存操作都应能解释“前值、动作、后值、来源、规则版本、操作者”。规则服务可以拦截风险,但不能成为新的无审计黑箱;硬编码阈值必须有配置版本、生效时间和回滚路径。

不同业务的高风险不变量不同,不能只用一个“成功率”覆盖:

业务对象关键不变量常见故障优先控制
价格与优惠应用规则版本明确,成交价可由原始输入重算规则灰度不一致、优惠叠加、零元或负价版本锁定、影子计算、上下限和异常订单冻结
支付与退款同一支付或退款流水只能产生一次资金事实超时重试、重复回调、退款超过实付渠道流水唯一、金额状态机、日切对账
库存可售数、预占数、释放数满足守恒关系并发超卖、重复释放、库存回填覆盖新值条件更新、预占流水、分片对账和人工冻结
结算与供应商结算批次、费率和账期可追溯批次重复、费率版本错误、外部账单延迟批次锁、版本化费率、双方汇总和差异工单
运营与调账每次人工改变都有审批和可逆路径权限过宽、脚本误执行、理由缺失最小权限、双人复核、命令白名单和审计

资损事件的处理顺序应是“止血—定界—保全—收敛—复盘”。止血可以暂停优惠、冻结退款、关闭异常库存动作或限制某个租户,但要记录开关生效范围;定界要按时间、版本、规则和业务主键计算影响,而不是只统计报错数;保全要保留原始请求、事件、账单和配置版本;收敛要使用幂等补偿或人工调账;复盘则把缺失的不变量转成校验、告警和发布门禁。若先调账后定界,可能把问题掩盖并制造第二次差异。

3.6 组织文化与生产红线

3.6.1 无指责复盘模板

无指责不是免除责任,而是让参与者能如实描述当时看到的信号、采用的假设和采取的动作。Etsy 的实践强调从系统条件、决策过程和失败机制学习,而不是寻找一个“坏人”作为根因。[13]

复盘文档控制在一页也可以有效,但必须包含:

字段要回答的问题
影响哪些用户、订单、金额、数据和 SLO 受影响?
时间线告警、发现、止血、恢复、验证分别何时发生?
机制哪个不变量被破坏?故障如何传播?
当时判断参与者看到什么、假设什么、为什么选择该动作?
做得好的地方哪个监控、开关、预案或协作缩小了损失?
行动项owner、截止日期、验收证据、优先级和依赖是什么?
复发门槛什么信号出现时必须重新打开问题?

行动项不能只写“加强监控”。应写成“为 payment_id 建立唯一约束和重复率告警,灰度验证重复事件不产生第二次订单推进,owner 为 X,截止日期为 Y”。复盘结束不等于治理完成,只有护栏上线并验证,经验才算进入系统。

事故协作应把角色和决策分开。事故指挥者负责优先级、风险和对外节奏,不亲自执行所有命令;执行负责人负责回滚、限流、切流或补偿;通信负责人负责影响范围、用户公告和内部时间线;记录负责人保存证据、命令和假设;领域 owner 负责判断业务不变量是否恢复。小事故可以一人兼任多个角色,但必须显式记录。任何高风险动作都要先说清“预期改变什么、如果没有改变怎么办、如何撤销”,避免多人凭直觉同时改动生产。

行动项按风险排序,而不是按会议上谁发言最积极排序。立即项修复正在扩大损失的护栏缺口;短期项补齐幂等、告警、回滚和对账;长期项减少架构耦合或替换高风险流程。每一项都要有验收查询或演练证据,连续两个周期没有进展就重新升级。DORA 的变更失败率、失败部署恢复时间和部署返工率可以帮助观察同一服务的交付改进,但不能替代订单、金额、库存等领域正确性指标;指标改善若伴随人工调账增加,不能判定治理成功。[2]

3.6.2 生产环境五条红线

  1. 不允许没有超时、幂等语义和回滚/查询路径的远程写调用进入核心链路。
  2. 不允许直接改权威账本、订单状态或库存余额;紧急操作也必须经过受控命令、审批和审计。
  3. 不允许用“接口返回成功”替代业务正确性验证;支付、库存、价格必须有独立不变量和对账。
  4. 不允许无限重试、无限堆积和无限自动重放;每条路径都要有预算、上限和人工接管点。
  5. 不允许发布、配置和补偿绕过灰度、观测和可逆性;无法回滚时必须先设计前向修复。

3.7 本章小结与延伸思考

生产可靠性可以压缩成五个判断句:

  • 先定义业务结果,再定义服务指标;正确性和可恢复性不能被可用性掩盖。
  • 先划定故障域,再组合超时、限流、重试、熔断、降级和舱壁;保护机制必须说明副作用。
  • 先确认权威事实,再设计事件、读模型、对账和补偿;最终一致不等于无边界等待。
  • 先建立 1-5-10 的止血节奏,再定位根因;指标恢复后仍要验证数据收敛。
  • 先把债务、事故和行动项变成可验证的约束,再谈组织成熟度。

上线前可用以下清单做快速评审:关键旅程是否有 SLO 和错误预算?每个远程调用是否有 deadline?写操作是否有幂等键和状态机?异步事件是否可重放?权威数据是否可对账?告警是否连接到动作?发布、配置和补偿是否可审计、可暂停、可回滚?

延伸思考:当业务规模扩大十倍时,最先失效的是哪个假设?如果不能使用消息队列,如何用本地事务、扫描和对账保持收敛?如果错误预算持续耗尽,应该降低目标、增加容量,还是停止某类功能?

参考资料

[1] Beyer B, Jones C, Petoff J, Murphy N R, eds. Site Reliability Engineering:Service Level Objectives;Service Best Practices. O’Reilly Media, 2016.

[2] DORA, Nathen Harvey. DORA’s software delivery performance metrics. 更新日期:2026-01-05;核验日期:2026-10-09。采用五项交付指标的定义,区分历史 Four Keys 与当前口径,不将行业分层阈值直接当作团队目标。

[3] Fowler M. Technical Debt Quadrant. 2009.

[4] Kruchten P, Nord R, Ozkaya I. Managing Technical Debt: Reducing Friction in Software Development. Addison-Wesley Professional, 2019.

[5] Nygard M T. Release It! Second Edition: Design and Deploy Production-Ready Software. Pragmatic Bookshelf, 2018.

[6] Amazon Web Services. Reliability Pillar: Design interactions in a distributed system. AWS Well-Architected Framework.

[7] Amazon Web Services. REL05-BP03 Control and limit retry calls. AWS Well-Architected Framework.

[8] Sigelman B H, Barroso L A, Burrows M, et al. Dapper, a Large-Scale Distributed Systems Tracing Infrastructure. Google Research, 2010.

[9] OpenTelemetry. Signals;Observability primer. CNCF OpenTelemetry documentation.

[10] Kleppmann M. Designing Data-Intensive Applications. O’Reilly Media, 2017.

[11] Richardson C. Pattern: Transactional outbox. Microservices Patterns companion site.

[12] Richardson C. Pattern: Saga. Microservices Patterns companion site.

[13] Etsy Engineering. Blameless PostMortems and a Just Culture;Allspaw J. Debriefing Facilitation Guide.

[14] Majors C, Fong-Jones L, Miranda G. Observability Engineering, 2nd ed.. O’Reilly Media, 2026.

[15] Amazon Web Services. Incident lifecycle in Incident Manager. AWS Systems Manager documentation.

[16] 周志明. 《凤凰架构:构建可靠的大型分布式系统》:分布式事务. 凤凰架构,2022。

[17] Amazon Web Services. 事务性发件箱模式. AWS Prescriptive Guidance 中文文档。

[18] 美团技术团队. 美团配送资金安全治理之对账体系建设. 美团技术团队,2018。

[19] 阿里云开发者社区. 云上混沌工程:从故障注入到稳定性治理. 阿里云开发者社区,2022。

[20] OpenTelemetry 社区. 可观测性入门. OpenTelemetry 中文文档。

第 4 章 大事务处理:跨域动作的可靠执行与失败收敛

大事务不是把多个数据库强行变成一个数据库,而是把一个跨系统的关键业务动作拆成可提交、可重试、可补偿、可审计、可最终收敛的状态机。本章从电商创单出发,讨论大事务的难点、决策点、异常处理和治理方法。

很多工程师第一次听到“大事务”时,会自然联想到数据库事务、分布式事务,甚至 2PC / XA。这些概念当然重要,但如果把它们直接套到互联网业务里,往往会把问题理解偏。

互联网系统里的大事务,真正要解决的不是“如何让多个数据库像一个数据库那样一起提交”,而是:

当一个关键业务动作必须跨库存、营销、订单、支付、账务等多个系统共同完成时,系统如何在失败必然发生的现实里,仍然把结果收敛到可解释、可恢复、可补偿的状态。

这里有一个必须先划清的边界:

  • 数据库事务解决一个数据库内部的原子性、隔离性和持久性。
  • 分布式事务协议试图在多个资源之间提供更强的提交语义。
  • 业务大事务管理一个业务目标跨多个本地事务和外部副作用后的最终结果。
  • 长生命周期业务流程管理业务对象跨支付、履约、审核、售后等多个阶段的持续推进。

例如,订单从创单到支付、履约、售后,是一个长生命周期业务流程;而“创单”这个关键节点内部的锁库存、占优惠、落订单,则是一个典型的大事务。本章只聚焦“创单这一刻如何做对”,第 5 章再讨论订单或商品如何跨阶段持续运行。

4.1 问题定义:大事务到底在解决什么问题

先把三个经常混在一起的概念拆开:

概念解决的问题典型手段主要边界
数据库事务单库内多条读写是否作为一个原子单元提交本地事务、锁、隔离级别、MVCC只能直接保护同一资源管理器内的数据
分布式事务多个资源管理器如何达成统一提交或回滚决定2PC、XA、TCC依赖参与者支持协议,并可能放大锁、等待和可用性成本
业务大事务跨系统业务目标如何推进、失败、补偿和收敛Saga、状态机、Workflow、Outbox、对账结果通常是业务等价的最终一致,而非瞬时全局原子

数据库事务关心的是“同一个数据库里的几条 SQL 要不要一起成功或失败”。分布式事务关心的是“多个资源管理器能不能形成统一提交语义”。业务大事务关心的则是“一个业务流程跨多个系统推进时,如何保证结果最终正确,且过程可解释、可恢复、可审计”。

这三者不是相互替代的关系,而是不同层次的问题。大事务内部仍然需要本地事务;Saga 的每一个步骤通常都应该在参与方内部原子提交;Outbox 也必须依赖本地事务把业务事实和待发送事件一起写入。只是,跨系统的整体目标不能再假设由一次本地 commit 自动完成。

4.1.1 为什么不能把所有事情都塞进 2PC / XA

传统两阶段提交的价值在于提供较强的统一提交语义,但它要求参与者支持协议、在准备阶段保持资源状态,并共同实现故障恢复。它并非“错误方案”,而是有严格的适用边界。

真实互联网业务经常不满足这些前提:支付网关、短信平台和物流供应商不会加入你的数据库事务;用户支付和人工审批不能长期持有数据库锁;短信、支付受理和供应商订单也不一定存在真正的回滚。业务上更常见的动作是取消占用、冲正流水、标记失败和等待对账。

Helland 在讨论大规模系统时,把这类现实概括为:应用程序往往不能假设存在覆盖所有业务对象和外部系统的全局分布式事务。[2] 它提醒设计者:一致性语义必须和参与者能力、业务时限以及恢复路径一起定义。

《凤凰架构》把它归纳为多服务、多数据源的事务问题,强调按业务一致性与隔离性需求选型。[7]

因此,本章的核心问题不是“有没有一种更强的事务框架”,而是:业务动作的最小边界是什么,哪些步骤会留下副作用,每种结果和补偿分别意味着什么,请求中断后能否从持久化事实恢复,以及谁有权判断最终应为成功、已补偿、待对账还是待人工处理。

这把“大事务”从接口编排转成状态收敛。

4.2 约束与指标:为什么创单节点天然是大事务

电商创单看起来只是用户点击一次“提交订单”,但在服务端通常至少包含以下动作:

  1. 校验购物车、商品、价格、税费和运费快照。
  2. 判断库存是否仍然可售,并预占目标数量。
  3. 校验优惠券、满减、积分或会员权益,并占用额度。
  4. 创建订单主记录与订单行,保存价格和优惠快照。
  5. 创建支付单或支付上下文,为后续支付做准备。
  6. 生成后续履约、通知、搜索和统计所需的事件。

真正的难点不在步骤数量,而在于每一步都可能拥有独立的本地事务和失败语义:

  • 库存预占成功,但订单服务写库失败。
  • 订单已写入,但营销服务超时,无法确定优惠是否已经占用。
  • 订单和库存都成功,但支付上下文创建结果未知。
  • 用户重复点击提交,两个请求同时通过库存检查。
  • 第一次调用已经在下游提交,响应却在返回前丢失,客户端于是再次发起请求。
  • 订单超时取消后,库存释放和优惠释放只完成了一部分。

所以创单节点天然具有四个特征:跨系统、多副作用、可失败、需收敛。可以用下面的约束表把它从“接口链路”转换成“系统设计问题”。

约束维度创单中的具体问题设计时必须回答的问题
参与方与故障域订单、库存、营销、支付、风控可能独立部署谁拥有每个事实?哪个故障域可以独立恢复?
资源类型库存和优惠可预占,通知和支付受理可能不可逆资源是释放、冲正、重试还是只能记录状态?
时效用户希望较快得到“已创建 / 被拒绝”,外部回执可能很慢哪些结果必须同步确认?哪些可以转为异步收敛?
并发重复点击、并发下单、取消与支付回调可能同时发生如何防止重复占用和旧请求覆盖新事实?
失败代价超卖、重复扣款、优惠资损、孤儿占用哪些失败要立即阻断?哪些允许进入待处理?
可审计性事后需要解释订单为何存在、库存为何释放是否保存步骤、幂等键、外部请求号和操作原因?
恢复目标线上重试不能解决所有未知结果多久内自动补偿?多久告警?何时转人工?

4.2.1 先定义成功,不要只定义返回码

“HTTP 200”不是业务成功的充分条件。一个创单动作至少要区分五种结果:

  • 明确成功:订单已经创建,关键资源已确认,后续动作可以继续。
  • 明确拒绝:库存不足、优惠失效、风控拒绝等业务条件不满足,不应该盲目重试。
  • 技术失败且确认未执行:例如参数校验失败,或下游在受理前返回明确错误。
  • 结果未知:请求超时、连接断开、进程在提交后崩溃,无法从响应判断下游是否已经执行。
  • 进入收敛流程:在线请求无法继续,但系统已经记录事务单,后续由重试、补偿、对账或人工操作推进。

如果把最后三类全部压成“失败”,系统就会在结果未知时重复执行副作用;如果把它们全部压成“成功”,系统又会把未确认的业务事实暴露给用户。成功判据必须由业务状态和下游权威事实共同决定。

4.2.2 指标应该围绕收敛,而不是只看 RPC 成功率

大事务的关键指标不应只有平均延迟和接口错误率,还应包括:

  • 事务单从创建到终态的停留时长。
  • UNKNOWN 步骤数量及其最大存留时间。
  • 补偿任务成功率、重试次数和补偿失败积压。
  • 对账差异数量、差异金额或资源量、差异存留时间。
  • 人工处理队列长度、逾期任务数量和人工收敛耗时。
  • 重复请求被正确吸收的比例,以及重复副作用数量。

这些指标表达的是系统能否把“不确定”变成“确定”;只看在线接口成功率是不够的。

4.3 核心模型:状态机、事务单与步骤账本

大事务设计的起点不是“先选 Saga 框架”,而是先定义一个可以被持久化、查询、重试和审计的业务动作模型。

4.3.1 业务动作、事务单和步骤账本

以创单为例,可以把模型拆成三层:

对象作用典型字段
业务动作定义本次必须完成的目标action_type、业务主键、请求来源、输入快照
事务单描述整体推进状态和期限transaction_id、status、deadline、version
步骤账本记录每个副作用的执行事实step_name、幂等键、尝试次数、外部请求号、结果、补偿状态

业务动作回答“我们要完成什么”;事务单回答“这一次动作整体走到哪里”;步骤账本回答“每一个可能产生副作用的动作究竟发生过什么”。

如果只保存一个订单状态,就无法区分“订单没有创建”和“库存已经预占但订单写入失败”。如果只保存日志,又无法让恢复程序安全判断下一步。事务单与步骤账本不是为了增加表数量,而是把恢复所需要的事实从日志文本中提取成可查询数据。

4.3.2 权威事实源与不变量

每一种资源都必须有明确的权威事实源:库存是否被占用由库存系统确认,优惠是否占用由营销系统确认,订单是否存在由订单系统确认,支付是否受理由支付渠道或支付系统确认。事务协调者可以保存副本和执行记录,但不应在没有查询依据时凭自己的状态替代参与方事实。

至少需要建立以下不变量:

  1. 一个可能产生副作用的步骤必须拥有稳定、可重放的幂等键。
  2. 同一幂等键最多产生一份业务副作用,并且重复请求返回第一次执行的语义结果。
  3. 事务单状态只能通过受控状态迁移变化,不能由多个服务直接覆盖同一个字段。
  4. 补偿动作必须检查当前权威状态,不能把已经确认的资源再次释放或把已冲正的流水重复冲正。
  5. 结果未知不能直接跳过,也不能直接重做,必须先查询、重放或进入可运营的待处理状态。
  6. 每条事件都应带有业务主键、事件 ID、版本或序列信息,使消费者能够识别重复和过期消息。

4.3.3 状态不是“成功 / 失败”二选一

事务单可以使用下面的最小状态集合:

PENDING
  -> RUNNING
  -> SUCCEEDED

RUNNING
  -> UNKNOWN
  -> COMPENSATING
  -> SUCCEEDED

UNKNOWN
  -> RUNNING
  -> COMPENSATING
  -> MANUAL_REVIEW

COMPENSATING
  -> COMPENSATED
  -> MANUAL_REVIEW

这里的 UNKNOWN 不是错误日志里的临时标记,而是一个需要持久化的业务状态。COMPENSATED 表示已确认的副作用已经被业务等价地回收,不代表系统回到了完全没有发生过的历史;MANUAL_REVIEW 表示自动机制无法在安全边界内继续推进,需要带着完整上下文交给有权限的人处理。

步骤状态还应区分“未开始、执行中、已成功、结果未知、补偿中、已补偿”。只有把这些状态保存下来,进程重启后协调者才知道应该查询、继续、补偿还是停止。

4.3.4 最小数据模型

编排式 Saga 不是在一个 Service 中写一串接口调用。进入生产后,最重要的是持久化执行事实:进程重启、网络超时、消息重复和人工介入发生后,系统仍然知道这次动作走到了哪里。

可以从四类记录开始:

记录关键字段要回答的问题
transactiontransaction_id、业务主键、status、deadline、version整体是否成功、失败、补偿中或待人工处理?
transaction_step步骤名、幂等键、状态、尝试次数、外部请求号、错误每个副作用是否执行过、结果是否可查询?
outbox_event事件 ID、业务主键、版本、投递状态、重试时间本地事实提交后应该发出的事件是否可重放?
operation_audit操作者、动作、前后状态、原因、时间谁在什么依据下推进或修复了状态?

事务单和步骤账本必须有明确的所有者。协调器可以负责事务单和步骤推进,库存服务负责库存事实,营销服务负责权益事实,订单服务负责订单事实。不要让协调器为了“方便查询”直接修改库存服务的状态字段。

4.3.5 幂等键的作用域

幂等键不能只写成一个全局字符串,而要回答“谁在什么语义下去重”。推荐把它拆成:

业务意图键 = client_request_id + user_id + action_type
步骤执行键 = transaction_id + step_name + resource_id
补偿执行键 = transaction_id + compensate_step_name + resource_id
外部请求号 = service_name + transaction_id + step_attempt_scope

同一交易单对两个不同 SKU 的库存预占不能共享一个会误吞请求的键;同一 SKU 的同一业务意图也不能因为重试生成新键。幂等记录应保存第一次结果或可查询的结果引用,避免重复请求只能得到“已处理”却拿不到订单号、reservation ID 或支付单号。

4.3.6 乐观并发控制

协调器可能被 HTTP 重试、消息重投和定时器同时唤醒。事务单状态更新应带版本或状态条件:

UPDATE transaction
SET status = 'COMPENSATING', version = version + 1
WHERE transaction_id = :transaction_id
  AND status = 'RUNNING'
  AND version = :expected_version;

受影响行数为零时,不要简单报错或再次写入;应重新读取权威事务状态,判断是重复唤醒、正常竞态还是版本冲突。版本控制保护的是协调记录,不能替代库存或余额系统自己的并发控制。

4.4 参考架构:同步边界、Saga/TCC、Outbox 与消息语义

围绕创单这类关键节点,常见实现路线可以分为同步短流程、协同式 Saga、编排式 Saga 和 TCC。Outbox 通常不是独立的终局方案,而是其中某些路线的可靠事件基座。

Saga 最初就是为长时间事务提出的分解思路:把一个长事务拆成多个本地事务,并为已经完成的步骤定义补偿路径。[1]

4.4.1 同步 RPC + 本地事务

做法是由一个入口服务串行调用库存、营销和订单服务,尽量在一次请求中完成全部步骤;每个服务用本地事务保护自己的数据。

它适合以下前提:步骤有限、没有长等待、参与方响应稳定、失败后可以直接向调用方返回拒绝、补偿动作很少且容易查询。它的优点是链路直观、用户反馈快、排障入口集中。

但同步并不等于原子。假设入口服务先调用库存预占,再调用订单写入:库存成功后,入口进程在第二次调用前崩溃,数据库事务不会替你释放库存。同步只降低了流程延迟,并没有消除跨系统副作用。

因此,使用同步 RPC 时仍然需要:

  • 为每个可重试请求定义幂等键。
  • 把已完成的步骤持久化,而不是只放在调用栈里。
  • 为中间失败提供释放或补偿入口。
  • 在超时后查询下游,而不是直接假设“没有执行”。

4.4.2 协同式 Saga

协同式 Saga 由参与方通过事件彼此驱动:订单服务发出 OrderCreated,库存服务消费后预占库存并发出 InventoryReserved,营销服务再处理优惠;失败时由参与方发布补偿事件。

它的优点是领域自治强,服务之间不依赖中央协调者;当事件本来就是领域边界时,协同式设计可以自然扩展。代价是全局路径分散,补偿逻辑散落在多个服务中,参与方增加后依赖关系容易变成隐形拓扑,排障者必须从事件链拼出完整历史。AWS 的 Saga 指南也提示,编舞式方案的参与方增加后依赖追踪会变难。[3]

Seata 中文文档将 Saga 定义为先提交本地事务,失败后补偿前序参与者,并支持状态机编排正向与补偿节点。[8]

它适合多系统自治、主控方不强且事件边界存在的场景;若主链路需要判断成功或补偿,需建设事务视图或审计投影。

4.4.3 编排式 Saga

编排式 Saga 引入一个协调者,由它持久化流程状态并显式推进步骤:先预占库存,再占用优惠,再创建订单,失败时根据已完成步骤触发补偿。协调者不应把自己设计成一个持有全局锁的“超级事务管理器”,而应是一个可重入、可恢复的状态推进器。

它适合:

  • 业务动作有明确主控方。
  • 步骤顺序和补偿顺序需要集中解释。
  • 需要统一审计、重试、超时和人工接管。
  • 参与方可以提供幂等执行和结果查询接口。

它的代价是协调者本身会成为复杂系统:需要高可用、状态持久化、调度、并发控制、版本升级、补偿队列和可观测性。只有内存状态会丢失“哪些步骤已经成功”的事实,盲目重试又会把不确定性变成重复副作用。

4.4.4 TCC / 预留、确认、取消

TCC 把资源操作拆成 Try / Confirm / Cancel:Try 检查并预留资源,Confirm 确认业务操作,Cancel 释放预留资源。Apache Seata 的 TCC 文档将这三个阶段定义为由业务代码实现的资源管理操作,而不是自动套用数据库事务。基于这种预留语义,本书在库存、座位、额度和余额等资源能够稳定预留、参与方能够改造的场景中评估 TCC;它不只适用于金融系统。[6]

TCC 的能力边界比普通补偿更严格:参与方必须理解预留状态,能够处理重复的 Confirm / Cancel,还要处理空回滚、悬挂和超时。它不是把普通接口改名为三个接口:

  • Try 需要在本地事务中记录预留事实,不能只在内存中扣减。
  • Confirm 必须在 Try 成功后可重入,重复确认不能重复扣减。
  • Cancel 必须能安全释放预留,重复取消应该返回同一语义结果。
  • Try 迟到时,必须识别此前已经发生的取消,避免出现“全局已经取消、迟到的 Try 又把资源冻结”的悬挂。

因此,TCC 适合资源预留语义稳定、失败代价高且参与方能共同改造的系统,不适合把普通 CRUD 都包装成三阶段接口。

4.4.5 Outbox:可靠事件基座,不是全局事务

当服务需要在一次本地事务中同时更新业务数据并发布事件时,直接“先写库、再发消息”会产生双写断裂:数据库提交成功但进程在发消息前崩溃,或者消息已经发出但数据库最终回滚。Transactional Outbox 的做法是把业务记录和待发送事件写进同一个本地事务,再由独立投递器发送事件。[4]

Outbox 解决的是“本地事实如何可靠产生事件”,没有解决:

  • 下游如何避免重复副作用、查询未知结果并补偿资源变化。
  • 外部系统已经受理后,如何得到最终回执。

所以 Outbox 通常和状态机、Saga 或对账体系组合使用。投递器也可能在“消息已经发出、发送记录还没标记”时崩溃,导致重复投递;消费者必须幂等,不能把“可靠投递”误读成“端到端恰好一次”。

4.4.6 一张能力对比表

方案适用前提获得的能力无法保证的能力失败恢复主要成本
同步 RPC + 本地事务步骤少、短闭环、参与方稳定低延迟、链路直观跨系统原子性、自动收敛有限补偿、查询后重试早期简单,复杂后容易散落
协同式 Saga领域自治强、事件天然存在松耦合、局部自治全局可见性、集中审计事件驱动、各方补偿拓扑和排障复杂
编排式 Saga主控方明确、需要统一治理路径可见、补偿集中、易审计参与方本地语义仍需自己保证协调器重试、查询、补偿、人工接管协调器高可用与状态治理
TCC资源可预留、参与方可改造预留 / 确认 / 取消语义清晰不可逆副作用的真正回滚Confirm / Cancel 重试和超时释放侵入性、开发和测试成本高
Outbox本地写入需要可靠发事件避免数据库与消息双写丢失全局事务、下游幂等、外部回执投递重试、消费者去重额外存储、投递和清理治理

4.4.7 同步边界

有消息队列不等于应该把所有步骤都异步化;一次 HTTP 请求也不等于应该把所有步骤都同步阻塞。更务实的划分方式是:这个结果是否改变当前对用户的承诺?

类型适合放入的动作返回给调用方的语义
同步确认决定能否创建订单、是否占用核心资源、是否拒绝请求成功、明确拒绝或处理中
异步推进搜索索引、通知、画像、统计、非关键投影已记录,后续处理
异步收敛供应商回执、支付渠道最终状态、补偿和对账已受理 / 待确认,不伪造最终成功

同步边界的目标不是让所有结果立即完成,而是让调用方明确知道“现在可以依赖什么”。例如,订单主记录和库存预占已确认,可以返回一个可支付订单;支付渠道的最终扣款则可以作为后续生命周期事件,不应该被隐藏在创单请求中。

4.4.8 Outbox 的本地原子性

典型的本地事务可以是:

BEGIN
  update order status
  insert transaction_step
  insert outbox_event
COMMIT

dispatcher reads committed outbox_event
  -> publish event
  -> record delivery attempt

如果本地事务回滚,事件不应发送;如果本地事务提交,投递器最终应能读到事件。Outbox 解决的是双写断裂,不保证消息只发送一次。投递器在消息已发出、投递记录尚未更新时崩溃,下一次仍可能再次发送;消费者必须以事件 ID、业务版本和幂等键吸收重复。

4.4.9 消息顺序和版本

消息队列的分区顺序只在有限范围内成立,不能替代业务版本。事件中至少应带有:

  • 事件 ID 和事件类型。
  • 业务主键与事务 ID。
  • 聚合版本或序列号。
  • 发生时间与生产者服务。
  • 必要的价格、数量或状态快照。

消费者处理前要判断事件是否重复、是否早于当前版本、是否与当前状态允许的迁移匹配。对于不能按任意顺序处理的事件,应使用版本条件更新、重排队列或进入异常事件表,而不是让最后到达的消息覆盖当前事实。

4.5 方案选型:先写 ADR,再决定机制重量

选型不应从“团队熟悉哪个框架”开始,而应从约束和失败语义开始。下面用几个 ADR 说明常见决策。

ADR-001:不把创单设计成跨系统 2PC / XA

**背景与问题:**创单需要同时影响库存、优惠、订单和支付上下文,参与方跨多个服务,且支付或外部系统不能统一加入本地事务。

**候选方案:**跨服务 2PC / XA、同步 RPC + 补偿、编排式 Saga、TCC。

**决策:**以本地事务 + 持久化事务单 + 编排式 Saga 为主,库存和优惠等可预留资源在必要时提供 TCC 风格接口;Outbox 用于可靠传播本地事实。

**获得的能力:**可在参与方自治的前提下推进业务;每一步可以独立扩容和恢复;能够把超时、结果未知、补偿失败和人工接管建模成正式状态;对外部不可加入事务的系统保留适配空间。

**主动牺牲的能力:**不再承诺所有参与方在同一瞬间拥有全局 ACID 隔离;用户可能短暂看到“创建中”或“待确认”;补偿可能产生业务等价结果,而不是物理回到原始状态。

**已接受风险:**协调器故障、消息重复、补偿失败和并发 Saga 可能造成中间状态,需要依赖状态账本、幂等、语义锁、对账和人工收敛治理。

验证指标:UNKNOWN 步骤存留时间、补偿成功率、孤儿库存数量、订单与库存对账差异、协调器恢复时间。

**重新评估条件:**如果新增的参与方普遍支持同一事务协议,且业务要求强隔离的时间窗口很短,可以重新评估更强的提交协议;否则不能仅因为“看起来更一致”就引入全局锁和更复杂的恢复协议。

ADR-002:交易主链路选择编排式 Saga,而不是纯协同式 Saga

**背景与问题:**创单需要在一个用户请求中给出可解释结果,且库存、优惠和订单有明确顺序;失败后必须按照已执行步骤补偿。

**决策驱动因素:**主控方清晰、失败代价高、需要统一审计、参与方数量可能增长、运维人员需要从一个入口查看完整事务。

**决策:**由订单域或交易域维护协调状态,参与方只负责本地执行、幂等和结果查询;事件用于异步推进和通知,不把全局成功判断分散给任意消费者。

**获得 / 牺牲:**获得全局可见性、集中补偿和清晰的人工入口;牺牲部分领域自治,并承担协调器的高可用、版本演进和容量治理成本。

**验证指标:**单个事务从创建到终态的最大停留时间、步骤重试次数、协调器重启后的恢复成功率、人工介入率。

ADR-003:Outbox 只承担可靠事件桥接

**背景与问题:**订单状态更新后必须发布 OrderCreated 或 OrderCancelled,直接双写会有丢消息和脏消息风险。

**决策:**订单本地事务同时写订单事实、步骤账本和 Outbox;投递器至少一次发送;消费者以事件 ID、业务版本和幂等键去重。

**获得 / 牺牲:**获得本地事实与事件的原子关联;牺牲投递延迟、额外存储和清理成本,接受消息重复和消费者需要幂等。

**验证指标:**Outbox 积压、投递延迟、重复投递比例、消费者去重命中率、事件与业务事实不一致数量。

**重新评估条件:**如果事件量、顺序约束或多数据源写入方式改变,需要重新评估轮询投递、CDC 或事件存储;不能把 Outbox 表永久当作没有生命周期的垃圾桶。

4.5.1 一条实用的决策顺序

可以按下面的顺序缩小方案空间:

  1. 先确认是否真的存在跨系统副作用;如果只是查询聚合,不要过度引入事务协调。
  2. 再确认失败后是否需要释放资源、冲正流水或更新多个系统;如果没有,普通同步流程可能足够。
  3. 明确每个参与方是否提供幂等执行和结果查询;没有查询能力时,结果未知无法安全重试。
  4. 判断主控方是否明确;明确则优先考虑编排,自治则考虑事件协作,但要补全全局可见性。
  5. 判断资源是否能够预留;能够稳定实现 Try / Confirm / Cancel 时才考虑 TCC。
  6. 最后才选择框架、消息系统或 Workflow 产品,且先定义业务状态,再让技术平台承载这些状态。

4.6 完整案例:创单动作如何从请求走向收敛

下面用一个简化的创单案例贯穿正常链路和异常分支。假设参与方包括:交易服务、库存服务、营销服务、订单服务和支付上下文服务。支付本身属于后续生命周期,创单只负责建立支付入口,不等待用户完成支付。

4.6.1 先创建事务单,而不是先调用库存

客户端提交请求时携带 request_id。交易服务先以用户、购物车版本、请求 ID 和业务动作类型组成幂等作用域,检查是否已经存在同一请求的事务单:

  • 如果已经成功,返回第一次成功结果。
  • 如果仍在执行,返回处理中状态和 transaction_id。
  • 如果已明确失败,返回原失败原因。
  • 如果处于 UNKNOWN 或 MANUAL_REVIEW,返回可查询的待处理状态,不能自动创建另一笔订单。

随后,交易服务在本地事务中写入事务单、输入快照和初始步骤账本。输入快照至少包括商品、数量、价格版本、优惠版本和配送约束;它不是为了替代权威服务,而是为了让后续补偿和审计知道当时系统试图完成的目标。

4.6.2 正常链路

正常链路可以表示为:

客户端提交 request_id
  -> 创建或恢复 transaction
  -> 校验价格、库存快照和业务资格
  -> ReserveInventory(transaction_id, idempotency_key)
  -> ReservePromotion(transaction_id, idempotency_key)
  -> CreateOrder(transaction_id, idempotency_key)
  -> CreatePaymentContext(transaction_id, idempotency_key)
  -> 事务单 SUCCEEDED,发布 OrderCreated

每一步都有本地成功判据,而不是仅凭 HTTP 响应码。例如库存步骤的成功判据应包含库存服务返回的预占记录 ID、数量、过期时间和版本;营销步骤应包含优惠占用流水号;订单步骤应包含订单号和订单状态;支付上下文步骤应包含支付单号和允许支付的截止时间。

同步请求是否等待所有步骤完成,要由用户承诺决定。如果产品要求点击后立即看到可支付订单,交易服务至少要同步确认库存、优惠和订单主记录;通知、搜索索引和画像可以异步。支付渠道最终受理则属于后续流程,不应为了“返回一个完整结果”而让创单请求等待用户支付。

4.6.3 步骤账本示例

顺序步骤幂等键成功判据补偿动作失败后的去向
1校验价格与资格tx:validate快照版本有效无副作用明确拒绝
2预占库存tx:inventory:sku返回 reservation ID释放预占重试或 UNKNOWN
3占用优惠tx:promotion:id返回权益流水回补或冲正重试或补偿
4创建订单tx:order返回订单号取消订单重试查询或补偿
5创建支付上下文tx:payment-context返回支付单号关闭支付入口查询或人工处理

步骤账本应保存“最后一次尝试”和“下游可查询的外部请求号”,而不是只存一个布尔值。这样,恢复程序才可以判断:某步骤从未发出、已经发出但未知、已经成功,还是已经执行补偿。

4.6.4 五类异常分支

创单案例可以用下面的矩阵统一表达:

异常已知事实首要动作禁止动作收敛状态
库存成功、订单写入失败库存已预占,订单结果可能未知先查询订单;确认未创建后释放库存直接返回失败并忘记占用COMPENSATED 或人工
优惠占用超时库存成功,营销结果未知按幂等键查询;必要时冻结价格承诺再占用一份优惠已确认、待确认或人工
支付上下文超时支付单可能已创建查询外部请求号,未创建才重试创建第二个支付上下文成功或 UNKNOWN
重复请求可能是同一业务意图按事务单和幂等键返回第一次结果创建第二笔订单吸收重复
迟到回调当前状态可能已取消或补偿校验版本和权威状态,必要时生成退款用最后消息覆盖当前状态维持现状或人工

库存成功、订单失败时,事务单转为 COMPENSATING,先按 tx:order 查询订单;确认未创建后,以补偿派生键释放库存,释放未知则进入补偿队列。若订单其实已创建,协调者必须恢复订单事实,不能释放库存后再把订单标成失败。

优惠或支付结果未知时,都遵循“记录未知—查询—同键重试—对账 / 人工”的顺序。是否允许订单继续,取决于价格承诺和资损风险,而不是技术框架。支付上下文若只有创建接口、没有按幂等键查询接口,就不满足关键参与方的最低能力。

重复请求需要事务单唯一约束、订单接口幂等和资源服务自己的幂等记录三层保护。两个不同业务意图争抢最后一件库存,则是并发控制问题,仍需原子扣减、版本校验、语义锁或预占记录,不能误以为幂等已经解决竞争。

迟到回调不能按接收时间覆盖状态。已取消订单收到支付成功时,应查询渠道权威状态并生成退款或待退款任务,保留回调、取消和退款之间的审计关系。

4.7 故障处理与收敛治理

最危险的工程错误,是把所有失败都理解成“再试一次”。不同失败对应完全不同的动作。

失败类型识别条件首选动作禁止动作最终收敛
明确拒绝库存不足、优惠不可用、参数不合法结束正向流程,补偿已成功步骤盲目重试同一业务请求已拒绝或已补偿
瞬时失败限流、短暂网络故障、连接池耗尽有上限的退避重试无限同步阻塞调用方成功、失败或待处理
结果未知超时、连接断开、提交后进程崩溃按幂等键查询或对账直接再次执行副作用已确认、可重试或人工
重复请求同一业务意图再次提交返回第一次结果或处理中状态创建第二笔资源吸收重复
乱序 / 迟到旧版本事件晚于新状态到达校验版本、忽略或生成补偿让最后消息覆盖事实维持权威状态
部分成功前序步骤成功,后序步骤明确失败按账本反向补偿或继续收敛只回滚内存变量已补偿或待人工
补偿失败释放、冲正或取消再次失败独立补偿队列、延迟重试、告警标记失败后丢弃补偿完成或人工
长期不可用依赖超过恢复窗口仍不可达限制新增流量、转待处理、对账无限扩大重试风暴待恢复或人工
版本冲突状态条件更新影响行数为零重新读取权威状态强行覆盖新状态接受现状或业务冲突

4.7.1 明确拒绝与技术失败必须分开

库存不足是业务拒绝,重试不会让库存凭空增加;数据库连接短暂失败是技术失败,可能重试成功。错误码、异常类型和下游契约必须能让协调器区分这两类结果。

重试还要受到截止时间和预算约束。创单有自己的 deadline,各步骤只能消耗部分预算;不能让库存已经过期后,协调器仍然无限重试营销。指数退避和抖动能缓解拥塞,但不能替代幂等。AWS 对幂等 API 的说明强调,安全重试的关键是让服务识别重复请求并返回与第一次处理一致的结果。[5]

4.7.2 结果未知必须先查询

“请求超时”只说明调用方没有在截止时间内得到响应,不说明下游没有执行。一个正确的未知结果处理过程是:

  1. 将步骤持久化为 UNKNOWN,记录请求发送时间、超时点和外部请求号。
  2. 调用下游的幂等查询接口,查询同一个执行键。
  3. 查询到成功则补写执行事实并继续;查询到未执行才允许使用同一键重试。
  4. 查询接口也不可用时,进入延迟查询、对账或人工队列。

如果下游没有查询接口,系统只能降低承诺:例如把该步骤设计成可补偿的预留,或要求外部系统提供对账文件。没有查询能力时,不能靠“再发一次通常没问题”来冒险。

4.7.3 补偿不是机械地反向调用

补偿动作至少分为四类:

  • 资源释放:释放库存、座位、额度或优惠占用;要求校验占用归属和当前状态。
  • 反向流水:账务、积分和权益使用冲正或反向流水,保留原始事实,不删除历史。
  • 不可逆通知:短信、邮件、推送无法撤回,只能发送更正或记录通知结果。
  • 外部受理动作:支付退款、供应商取消可能异步完成,需要查询和等待最终回执。

补偿顺序也必须服从业务依赖。订单取消通常应先关闭支付入口或进入取消中,再释放库存和优惠;如果先释放资源,迟到的支付回调可能把一笔已经失去资源的订单推进到错误状态。补偿顺序应成为状态机的一部分,而不是开发者凭直觉在 catch 块里倒序调用。

4.7.4 并发 Saga 与语义锁

两个创单动作可能同时读取到“还有一件库存”,或者一个取消动作与支付确认同时推进。Saga 没有自动提供跨服务隔离,因此需要使用资源版本、语义锁、预占状态或可串行化的本地更新来保护关键竞争。

所谓语义锁,不一定是数据库长锁,而是把“正在被某个事务单处理”写入资源状态或独立占用记录,让其他业务动作知道应该等待、拒绝或重新读取。这样既能避免长时间持有数据库锁,也能让并发冲突成为可解释的业务状态。

4.7.5 对账、可观测与人工收敛

在线重试和自动补偿仍可能失败,因此关键链路必须有独立于在线流程的对账能力。对账不是上线后的补丁,而是大事务设计的一部分:在线链路负责尽快推进,对账链路负责发现遗漏、识别歧义并提供受控修复入口。

4.7.5.1 对账的四组事实

核对对象常见差异权威来源修复动作
订单与库存已取消订单仍占库存;已支付订单没有有效预占库存占用流水 + 订单状态查询、释放孤儿占用或冻结订单
订单与优惠订单失败但券未返还;权益已核销但没有订单权益流水 + 订单快照回补、冲正或人工审核
订单与支付 / 账务支付成功但订单未更新;退款已受理但账务未记账渠道回执 + 账务流水补记、退款、冲正或人工核验
事务单与步骤账本事务显示成功但步骤未知;步骤成功但事务卡住事务状态 + 下游查询重放状态迁移或进入人工队列

每组对账都要先定义口径:按订单号、交易单号、外部请求号还是资源流水号关联;时间窗口多长;差异是否允许短暂存在;哪些动作可以自动修复;哪些动作必须双人复核。没有口径的“对账脚本”往往只是另一种不可审计的人工改库。

4.7.5.2 可观测性要关联业务事实

日志、指标和链路追踪至少贯穿以下关联键:

transaction_id
business_id / order_id
idempotency_key
external_request_id
event_id

指标应能回答哪些事务卡住、卡在哪个参与方、是否产生副作用、补偿是否成功、谁需要处理。重点关注各步骤未知和补偿比例、UNKNOWN 存留时间、事务终态延迟、补偿队列年龄、对账差异及人工队列逾期量;单看某个 RPC 成功率无法判断大事务是否收敛。

4.7.5.3 人工接管必须是受控状态迁移

人工操作台至少应显示:事务单、业务主键、已执行步骤、外部请求号、最近错误、当前权威状态、建议动作、风险提示和截止时间。操作者应选择“重新查询”“重试补偿”“确认外部成功”“终止并冻结”等有限动作,并填写原因;系统记录操作者、时间、前后状态和审批依据。

对资金、库存和高价值权益动作,应增加权限隔离、双人复核或风险阈值。人工不能直接把订单字段改成“已支付”,而应执行一个被授权的业务命令,让系统校验渠道事实并产生相应流水。直接改库可能临时解决一笔订单,却会破坏状态机、审计链和后续对账。

4.8 演进与迁移:从简单方案到其他关键业务

大事务能力应渐进建设:

4.8.1 阶段一:同步短流程

适用于步骤少、依赖稳定、失败可以立即返回的动作;仍要建立请求幂等键和本地状态记录。

4.8.2 阶段二:状态机 + 补偿任务

出现“库存成功、订单失败”时,先建立事务单和步骤账本;补偿可以由定时任务或队列驱动,但必须使用同一幂等键并记录尝试。

4.8.3 阶段三:编排式 Saga

当步骤顺序、补偿关系、超时和审计已经跨多个模块时,把协调逻辑集中成可恢复的编排器,为复杂度提供可观察、可治理的边界。

4.8.4 阶段四:关键资源引入 TCC

当库存、余额、座位或授信额度的失败代价高,且参与方能按预留模型改造时,引入 TCC;上线前先覆盖空回滚、幂等、悬挂、超时和重复确认。

4.8.5 阶段五:平台化能力

只有多个领域反复出现状态账本、补偿队列、人工操作台、对账和审计需求时,才值得抽象共享平台。平台提供持久化、幂等、调度、观测和审计,不把所有业务补偿规则硬编码成不可扩展的通用引擎。

4.8.6 从创单迁移到其他关键业务动作

创单只是最典型的样本。其他场景仍然要先划定关键动作、权威事实和副作用,再决定补偿方式:

  • 注册与认证:账号是权威事实,权益可以补发,短信和邮件不可逆。账号已创建但权益失败时进入“权益待发放”;实名结果未知时按身份证或外部请求号查询,不能创建第二个账号。
  • 退款与售后:退款单、可退金额、支付渠道、优惠回补和库存恢复可能分属不同系统。渠道“受理成功”不等于资金到账,补偿通常是查询、重试、冲正或人工核验,而不是撤销一条退款记录。
  • 商品审核与发布:商品主数据是权威事实,索引和缓存是可重建投影。发布失败时可以保持“发布中 / 待同步”,通过事件重放收敛,不必强行回滚所有投影。

这三个场景的共同判断是:关键动作完成的标准不是所有通知都已发送,而是权威事实已经确定,必要资源已经确认或释放,不可逆副作用已经被记录,剩余投影有可追踪的异步收敛路径。

4.9 方法论总结、评审清单与常见反模式

4.9.1 方法论总结

大事务设计可以归纳为一条推理链:先定义关键业务动作和参与方约束,再明确权威事实源、事务单、步骤账本、状态和不变量;随后按同步确认、异步推进和异步收敛划分边界,依据参与方能力组合选择同步流程、Saga、TCC 和 Outbox;最后用完整案例验证明确拒绝、结果未知、部分成功、补偿失败和迟到回调,并以幂等、查询、对账、观测和人工接管把失败变成可治理状态。

最关键的判断始终不变:不要把“接口调用成功”误当成“业务已经完成”。只有所有必要副作用被确认、被补偿,或进入明确的待处理状态时,这次业务动作才真正收敛。

系统的价值不是承诺不会失败,而是让失败后的事实、责任和修复路径可见:发生了什么、哪些动作已生效、哪些结果未知、谁拥有权威事实、下一步由谁推进,以及何时回到成功、已补偿、待对账或待人工处理。

4.9.2 评审清单

在方案评审时,至少逐项回答:

  1. 这个关键业务动作的最小边界是什么?哪些动作属于后续长流程?
  2. 哪些参与方会产生真实副作用?每个副作用的权威事实源是什么?
  3. 每个步骤的幂等键如何构造?重复请求返回什么结果?
  4. 超时后如何区分“未执行”和“结果未知”?是否存在真实查询接口?
  5. 哪些结果必须同步确认,哪些可以异步推进,哪些只能异步收敛?
  6. 事务单、步骤账本、Outbox 和消费者处理记录分别由谁维护?
  7. 补偿是释放资源、反向流水、状态更正还是外部异步取消?
  8. 补偿失败会停在哪里?告警阈值是什么?谁可以人工接管?
  9. 并发下单、取消、支付回调和重复消息如何避免旧状态覆盖新事实?
  10. 方案选择牺牲了什么能力?验证指标是什么?什么条件下重新评估?

4.9.3 常见反模式

  • 把异常处理当补偿设计:只写 try/catch,没有持久化步骤状态和可重入补偿。
  • 把重试当幂等:同一请求重放时重复扣减资源,事后再靠人工修复。
  • 把超时当未执行:响应丢失后直接重做,导致重复订单、重复扣款或重复占用。
  • 把 Outbox 当全局事务:可靠发消息不能替代下游幂等、补偿、查询和对账。
  • 把 Saga 当回滚:补偿得到的是业务等价结果,不一定能恢复所有外部世界的历史状态。
  • 把人工改库当恢复机制:短期绕过流程,长期破坏权威状态、审计和版本控制。
  • 为了“绝对一致”持有长锁:把等待支付、供应商回调或人工审批塞进数据库事务,最终牺牲可用性且仍无法覆盖外部副作用。
  • 先选框架再定义状态:让框架的任务模型反过来决定业务语义,导致状态不可解释、升级难以迁移。

参考文献与延伸阅读

本节汇总正文引用源,供延伸阅读;正文编号用于就近说明,链接优先指向原始论文或官方页面。

[1] Hector Garcia-Molina, Kenneth Salem, “Sagas”, ACM SIGMOD, 1987。

[2] Pat Helland, “Life beyond Distributed Transactions: an Apostate’s Opinion”, CIDR 2007, 2007。

[3] AWS Prescriptive Guidance, “Saga patterns”。

[4] AWS Prescriptive Guidance, “Transactional outbox pattern”。

[5] Malcolm Featonby, “Making retries safe with idempotent APIs”, Amazon Builders’ Library, 2021。

[6] Apache Seata,“Seata TCC Mode”,v2.6 文档,核验日期:2026-10-09。用于 Try、Confirm、Cancel 的业务资源语义;具体实现仍需处理幂等、空回滚和悬挂。

[7] 周志明,《凤凰架构》“分布式事务”章节,在线阅读。

[8] Apache Seata,“Seata Saga 模式”。

第 5 章 长生命周期业务流程:业务对象的等待、迁移与恢复

本章讨论的不是“某个关键节点怎么一次性做对”,而是“一个业务对象如何在多个阶段中被持续推进、暂停、回退、超时、审批、恢复和审计”。读完这一章,你应该能区分:什么问题只是一个大事务,什么问题已经演化成长生命周期业务流程,以及状态机、编排器、Workflow、事件驱动和人工介入分别该放在什么位置。

上一章讲的是大事务。大事务解决的是一个关键时刻的一致性问题,例如创单时锁库存、占优惠、落订单,或者发布时更新主数据、推送索引、发送事件。它关心的是一个动作在有限时间内如何完成、失败后如何补偿,以及怎样让一次局部成功最终收敛。

这一章讨论的是另一个层次的问题:当一个业务对象要跨创单、支付、履约、售后,或者跨 Draft、审核、发布、下线等多个阶段持续存在时,系统如何管理它的完整生命周期。一个订单可能在几秒内创建,却在几天后完成履约;一个商家入驻可能经历数周的资料补充、人工复核和合同确认;一个商品发布后还可能因为合规规则变化而重新审核。它们都不是一次接口调用可以完整表达的事情。

这里要先建立三个边界:

  • 大事务关注某个关键业务动作怎样最终收敛,例如一次创单、一次退款执行或一次商品发布。
  • 长生命周期业务流程关注业务对象跨阶段的状态、等待、角色、规则和恢复,它回答“现在处于什么阶段、谁可以推动、失败后如何继续”。
  • Workflow是承载长流程的一种运行时手段,不是业务概念本身。可以没有 Workflow 引擎,但不能没有清晰的生命周期模型。

例如,订单从创单到支付、履约、售后,是长生命周期业务流程;其中创单时锁库存、占优惠、落订单,是一个可能使用 Saga 的大事务。商品从 Draft -> QC Approval -> Publish -> Unpublish,也是长生命周期业务流程;其中 Publish 节点内部更新主数据、发送事件、刷新索引,则可能需要本地事务、Outbox 和补偿组合。[1][6]

5.1 问题定义与边界:从大事务到长生命周期流程

5.1.1 先确定流程对象,而不是先确定框架

长生命周期业务流程,指的是一个业务对象不会在一次请求内闭环完成,而是要跨多个阶段、多个系统、多个角色和多个时间窗口持续推进。这里的业务对象可以是订单、商品、退款单、商家申请、账单、理赔单,也可以是一次批量导入或一次发布审批。对象的共同特征不是“使用了消息队列”,而是它需要在未来一段时间内继续被系统识别、唤醒、解释和治理。

因此,流程设计的第一个问题不是“选 Temporal、Camunda 还是自研状态机”,而是:

  1. 什么东西正在经历生命周期?
  2. 哪个标识可以贯穿所有阶段和外部交互?
  3. 哪一个领域拥有它的权威事实?
  4. 哪些动作改变业务事实,哪些动作只是发送通知或刷新投影?
  5. 流程结束的条件是什么,结束后还允许哪些售后或纠错动作?

如果这些问题没有答案,引擎只能把混乱的业务规则持久化下来。BPMN 2.0.2 这样的流程标准可以表达事件、任务、网关、消息和边界事件,但它解决的是流程表示与交换问题,并不会替系统决定订单的权威状态、退款金额或权限边界。[3] 图形化流程可以帮助团队对齐,但不能替代领域建模。

一个实用的流程对象通常至少包含四类信息:

信息层典型字段解决的问题
身份process_id、business_id、租户或商家 ID这一次流程和哪个业务对象有关
当前事实主状态、子流程状态、状态版本、原因码现在发生了什么,谁可以继续
执行上下文当前步骤、等待条件、next_action_at、外部请求号下一步要等什么、何时唤醒
治理记录命令、事件、操作者、定义版本、审计证据为什么变成这样,能否恢复和追溯

这些字段可以落在一张流程实例表、业务聚合表和事件/审计表中,也可以由 Workflow 平台分别持久化。存储形态可以变化,但语义不能消失。流程对象必须能够从持久化数据中解释出:它已经完成的动作、尚未完成的动作、正在等待的条件、最近一次失败,以及下一次允许执行的动作。

5.1.2 大事务和长流程不是同一条时间轴

大事务通常围绕一个关键节点展开。它在较短时间内协调多个参与方,并通过本地事务、Saga、TCC、Outbox、查询、补偿和对账让结果收敛。长流程则把这些关键节点串在业务对象的生命周期上,中间允许出现等待、人工审批、外部回调和跨版本运行。长流程不应该为了“看起来一致”而长时间持有数据库锁或远程事务。

维度大事务长生命周期业务流程
关注对象一个关键业务动作一个业务对象的完整生命周期
时间跨度通常秒级到分钟级分钟、小时、天甚至更长
核心问题副作用怎样最终一致阶段怎样持续推进、暂停与恢复
等待形态等待短暂 RPC 或查询等支付、审批、供应商、时间窗口
失败处理重试、补偿、查单、对账状态迁移、超时、人工接管、重放
典型实现本地事务、Saga、TCC、Outbox状态机、编排器、Workflow、事件协作
终止方式成功、失败、已补偿或待人工完成、取消、关闭、归档或转售后

如果把长流程只当成一个加长版大事务,通常会出现三种错误。第一,把所有状态塞进一个“超大 Saga”,却没有清楚区分订单主生命周期和单次创单动作。第二,为了等支付、等审核或等供应商,长期保持“事务进行中”的语义,既占用资源,又让其他业务动作不知道应该等待还是拒绝。第三,审批、驳回、重提、超时取消、人工补单等动作没有正式的状态迁移,只能通过脚本和人工改库兜底。

Hector Garcia-Molina 和 Kenneth Salem 在 Sagas 论文中讨论长生命周期事务时,核心思路就是把长期持有资源的事务拆成可交错的局部事务,并为已完成步骤设计补偿动作。[1] 这不是说每一个业务生命周期都必须实现成 Saga,而是提醒我们:跨时间的业务连续性不能依赖一次数据库事务。Pat Helland 进一步强调,面向大规模系统时,应用需要围绕业务实体和消息来组织状态,而不是假设所有跨服务变化都能被一个全局事务包住。[2] 周志明对分布式事务的整理也把本地事务、补偿、可靠消息和业务约束放在同一条推理链上,而不是把某一个协议当成通用答案。[19]

所以本章的边界是:关键节点内部的原子性和补偿继续交给第 4 章讨论;本章负责把这些节点放进一个可解释的生命周期,并处理等待、人工任务、规则变化、流程恢复和运营治理。这个边界可以避免“每章都重新发明一套分布式事务”,也能让评审者清楚知道一个问题究竟属于节点一致性,还是属于生命周期治理。

5.2 约束、指标与判断框架

5.2.1 长流程首先是约束问题

长流程的复杂度不是由节点数量单独决定的。三个节点的退款流程,如果涉及外部支付渠道、不可逆资金动作和人工复核,可能比十个内部节点的商品编辑流程更难治理。设计前应把以下约束显式写出来:

  • 时间约束:每个阶段的正常时长、最大等待时长、业务截止时间和是否允许跨日运行。
  • 角色约束:用户、系统、供应商、运营、客服、风控、财务和审核员各自能发起或完成什么动作。
  • 依赖约束:哪些系统是强依赖,哪些系统只提供后置通知;依赖是否支持查询、幂等和版本校验。
  • 可逆性约束:哪些步骤可以取消,哪些只能冲正或补发,哪些产生法律、资金或物流上的不可逆效果。
  • 并发约束:同一对象上可能同时出现用户命令、回调、定时器、人工操作和重放任务。
  • 数据约束:权威事实存在哪里,投影允许延迟多久,历史事件需要保存多久,哪些数据需要脱敏或留痕。
  • 治理约束:是否需要双人复核、租户隔离、操作审计、人工接管、数据导出和合规保留。

这些约束应该转成可验证指标,而不是只写成“高可用”“可恢复”。一个合理的指标表如下:

指标类别示例指标指标回答的问题
推进效率各阶段停留时间、P95/P99 完成时长哪个阶段正在拖慢整体流程
等待治理超时率、等待实例数、最老等待年龄是否存在无人负责的卡点
正确性重复命令吸收率、非法迁移数、乱序事件数状态机是否守住业务不变量
恢复能力未知结果存留时间、补偿成功率、重试耗时故障是否能回到可解释状态
人工治理待办逾期量、转人工比例、双人复核耗时自动化边界是否合理
运行成本每个实例事件数、调度扫描量、存储增长量长流程平台是否可持续运行
业务结果订单关闭准确率、退款差异量、发布投影滞后用户看到的结果是否符合权威事实

中国信通院《分布式系统稳定性建设指南》把稳定性建设拆成目标、架构、容量、运维和安全等维度,强调稳定性不能只靠某一个组件提供。[20] 对长生命周期流程而言,这个结论可以转化为:流程引擎的可用性不是流程正确性的全部,流程还要有状态不变量、恢复入口、指标、告警、对账和人工责任人。换言之,平台在线不等于业务流程已经收敛。

5.2.2 用五个问题判断是否进入长流程

可以先用下面五个问题做范围判断:

  1. 业务对象是否会跨多个阶段长期存在,而不是在一次请求内结束?
  2. 是否存在用户、人工或外部系统造成的真实等待?
  3. 是否需要回退、驳回、重提、暂停、恢复或超时关闭?
  4. 是否需要向用户、客服或审计人员解释“现在卡在哪里、下一步是谁处理”?
  5. 是否存在跨服务副作用,使得重试、补偿、查单或对账成为正常路径?

如果五个问题大部分答案是“是”,就应把它当成长生命周期流程设计。如果只有一个本地事务和一次短 RPC,先使用业务表状态和本地事务,避免因为“未来可能复杂”而过早引入流程平台。工具的重量应该由等待、治理、恢复和演进需求触发,而不是由技术潮流触发。

长流程也不等于必须微服务化。一个单体应用内的订单审核,只要有跨天等待、人工任务和规则版本,同样需要生命周期模型。反过来,一个拆成多个微服务的同步请求,如果能在一个短事务窗口内完成,也不一定需要 Workflow。服务数量是架构背景,不是流程分类标准。

5.2.3 先定义成功,再定义中间态

长流程最容易被忽略的问题是“什么算完成”。例如,商品发布的主数据已经提交,但搜索索引还没有更新;退款渠道已经受理,但资金到账仍在异步处理中;订单已经支付,但仓库尚未生成履约单。把这些都简单写成 SUCCESS 会隐藏真实风险。

建议为每一个流程定义至少四种结果:

  1. 业务完成:权威事实已经满足业务目标,后置投影可以继续异步收敛。
  2. 业务拒绝:规则明确拒绝,且不需要继续重试,例如库存不足、资质不符或支付失败。
  3. 处理中:事实尚未确定,系统必须等待回调、查询或人工判断。
  4. 待治理:自动路径无法安全决定,实例进入人工或对账队列,并有明确 owner 和下一动作。

在结果未知时,不能把技术超时当成业务失败,也不能把接口返回成功当成所有下游都完成。AWS 的幂等 API 指南把“响应丢失但请求可能已生效”作为需要优先解决的场景,并建议使用客户端请求标识和语义等价的重试响应。[7] 对长流程而言,这意味着流程实例需要保留外部请求号,并把“查明结果”建模成正式步骤,而不是让调用方盲目重新发起写操作。

5.3 核心模型:状态、命令、事件与版本

5.3.1 三层状态模型

状态不是一串展示给前端的枚举,而是一份业务契约。以订单为例,可以把状态拆成三层:

层次示例作用
主生命周期状态PENDING_PAYMENT、PAID、FULFILLING、COMPLETED、CLOSED表达订单对外可见的阶段
子流程状态支付处理中、库存释放中、退款审核中表达并行或局部流程的推进位置
原因与上下文关闭原因、拒绝理由、超时时间、操作者、外部单号解释为什么进入当前状态

不要把所有细节压进一个 status 字段。“订单已关闭”不能说明它是用户主动取消、支付超时、风控拒绝还是履约失败;这些原因会决定后续能否重开、是否释放资源、是否允许退款以及谁有操作权限。主状态应保持稳定而有限,原因、版本、期限和外部引用应以独立字段、事件记录或不可变审计记录保存。

同样,不要为了减少字段而把所有并行子流程压成一个全局状态。订单可能同时处于“已支付、履约单创建中、发票待开具”的状态;商品可能已经发布,但合规复核和搜索索引分别有自己的进度。主状态负责表达业务对象对外的主要阶段,子流程负责表达局部执行,聚合状态由明确规则计算或由领域服务推进。这样既能避免状态爆炸,也能防止一个字段遮住多个事实。

5.3.2 命令、状态、事件不是同一件事

状态变化应该由命令触发,由事件记录,而不是让任意服务直接更新状态。

  • 命令表达意图,例如“提交支付”“申请取消”“审核通过”“要求补件”。命令有发起者、权限、幂等键和校验条件。
  • 状态表示当前已经确认的业务事实,例如“等待支付”“履约中”“待人工复核”。状态由权威领域根据规则迁移。
  • 事件描述已经发生的变化,例如 PaymentSucceeded、OrderCancelled、ProductPublished。事件用于通知、统计、投影和下游协作。

支付服务可以发布“支付成功”的事实,但不应直接把订单表更新成“已完成”;订单域需要根据订单状态、支付单状态、金额、币种、订单版本和履约条件决定是否接受该事件。物流服务可以发布“已签收”,但售后域仍需依据签收时间、商品类型和售后规则判断是否进入可申请退款窗口。

这种区分还有一个重要作用:让外部事件成为输入,而不是拥有无限权限的写入口。事件到达时,系统必须验证业务主键、来源身份、事件版本、金额或数量、时间窗口、当前状态以及是否已处理。不能因为事件名字看起来正确,就跳过状态机和权限检查。

5.3.3 状态迁移必须有守卫条件和不变量

一张状态图只有箭头还不够,每一条箭头都要明确触发动作、守卫条件、副作用和失败去向。以“待支付订单取消”为例,规则不应只是 PENDING_PAYMENT -> CLOSED,而应写清:

  1. 触发者可以是用户取消请求,也可以是支付截止时间到期的定时器。
  2. 守卫条件是订单仍处于可取消状态、没有确认的支付成功事实、当前状态版本未冲突。
  3. 副作用包括关闭支付入口、释放库存预占、返还优惠占用和发送订单关闭事件。
  4. 如果释放资源结果未知,订单进入 CLOSING 或“资源待确认”子状态,而不是立即宣称关闭完成。
  5. 如果此时已经收到支付成功事实,系统根据业务规则选择支付优先、取消优先或进入人工复核,不能由消息到达顺序偶然决定结果。

核心不变量应该写成可以检查的句子,例如:已完成订单不能直接回到待支付;已退款金额不能超过可退金额;同一个支付单只能确认一次;已关闭订单不能再创建新的履约任务;任何人工放行都必须有操作者、理由和审计记录。状态机的价值不在于画出所有可能箭头,而在于把禁止路径和必要条件变成程序可执行的约束。

5.3.4 幂等键、版本和流程定义版本

长流程里至少有三种版本需要区分:

  • 对象状态版本:防止旧命令覆盖新状态,通常以 version 或聚合序号实现。
  • 事件版本:描述同一业务对象的事件顺序或事件格式,帮助消费者拒绝迟到事件或做兼容转换。
  • 流程定义版本:表示这一条流程按照哪套规则运行,保证在途实例不会因为代码发布而突然失去解释能力。

同一个幂等键还要明确作用域。cancel-123 是全局唯一,还是只在订单 order-123 下唯一?同一个键携带不同参数时,系统应该返回原结果还是拒绝参数冲突?AWS 的实践强调,幂等请求标识需要和请求参数绑定;同一标识代表不同意图时应报校验错误,而不是默默复用旧结果。[7]

状态更新应使用条件写入。一个简化的实现如下:

UPDATE orders
SET status = 'CLOSED',
    close_reason = :reason,
    version = version + 1,
    updated_at = CURRENT_TIMESTAMP
WHERE order_id = :order_id
  AND status = 'PENDING_PAYMENT'
  AND version = :expected_version;

受影响行数为零时,不应直接返回“系统错误”。调用方应重新读取权威状态,判断是重复请求、正常竞态、状态已被其他命令推进,还是确实发生业务冲突。版本号保护迁移顺序,状态条件保护合法路径,幂等键保护同一命令的重复执行;三者解决的问题不同,不能互相替代。

5.3.5 事件日志不自动等于事件溯源

流程通常需要保存事件、审计和执行记录,但这不等于必须把所有业务状态都实现成 Event Sourcing。事件溯源强调把应用状态的全部变化作为事件序列保存,并可以从事件重建过去状态。[10] 普通状态机也可以保存迁移日志,只把当前状态作为权威读模型。两者的选择取决于审计、重建、查询、存储和团队能力。

在大多数订单和审批场景中,推荐先采用“当前状态 + 不可变迁移记录 + 必要事件”的组合:当前状态用于快速判断,迁移记录用于审计与排障,事件用于跨边界传播。只有当历史重建、时间点查询、复杂回放已经成为核心需求时,再认真评估完整事件溯源。否则,事件流会增加版本兼容、隐私清理、重放副作用和查询模型的长期成本。

5.4 完整案例:订单、商品与退款流程

5.4.1 订单:支付回调和超时取消的竞态

订单生命周期可以简化为:

CREATED
  → PENDING_PAYMENT
  → PAYMENT_CONFIRMED
  → FULFILLMENT_PENDING
  → FULFILLING
  → DELIVERED
  → COMPLETED

另外存在取消和售后分支:

PENDING_PAYMENT ──用户取消/支付超时──► CLOSING ──资源释放确认──► CLOSED
PAYMENT_CONFIRMED ──申请售后──► AFTER_SALE_PROCESSING ──退款/换货──► COMPLETED 或 CLOSED

正常链路中,订单服务先在本地事务里创建订单和支付单,写入 PENDING_PAYMENT,并记录支付截止时间。订单服务不应该在 HTTP 请求里等待支付成功。支付服务受理请求后,以支付单号和客户端幂等键保证重复提交不会产生第二笔扣款;成功后写入支付权威事实,并通过 Outbox 或可靠消息传播 PaymentSucceeded。Outbox 解决本地订单事实与事件通知之间的双写窗口,但消息 relay 仍可能重复发送,消费者仍需要幂等。[6][8][9]

订单消费者收到 PaymentSucceeded 时,先检查支付单号、金额、币种、支付状态和订单当前版本,再执行订单状态迁移。迁移成功后,订单服务可以发起履约单创建;履约单创建本身又可能是一个由第 4 章讨论的局部大事务。订单完成支付,并不意味着履约、开票、通知和推荐投影都同步完成,它只表示订单域的支付事实已经确认。

最有代表性的异常是支付成功回调与超时取消同时到达。两条命令可能来自不同消费者,也可能一个来自用户请求、一个来自调度器。处理流程可以按下面的步骤设计:

  1. 两个请求都带有订单 ID、命令 ID 和期望版本。
  2. 状态机以条件更新竞争 PENDING_PAYMENT 的版本。
  3. 先成功的命令获得状态迁移权,另一个命令读取新的权威状态。
  4. 如果支付成功先落库,取消命令不能直接关闭订单,而要进入“支付后取消”的业务规则;如果取消先落库,支付回调需要查询渠道或根据支付事实决定是否触发退款。
  5. 任何外部结果未知,都进入 PAYMENT_UNKNOWN、CLOSING 或人工复核,而不是用超时猜测结果。

这里没有一个脱离业务的“绝对正确顺序”。电商订单可能规定“支付成功优先,取消需要退款”;票务系统可能规定“座位过期优先,迟到支付自动退款”;金融业务则可能要求进入人工复核。技术实现只能确保竞态被显式记录、只允许合法迁移,并把每种选择绑定到业务规则版本。

如果库存预占已经成功,而支付失败或订单取消,取消流程不应直接删除订单。订单进入 CLOSING,编排器向库存服务发送带 reservation_id 的释放命令;库存服务以预占记录和释放动作幂等,释放完成后产生 InventoryReleased,订单再推进到 CLOSED。如果释放结果未知,流程先查库存预占状态,查询不到可靠事实时进入对账或人工队列。Saga 的补偿不是数据库回滚,而是一个有业务语义的释放、退款、冲正或状态更正动作。[5][11][13][16]

5.4.2 商品:审核、驳回、重提和发布投影

商品生命周期更适合说明人工任务和流程定义版本:

DRAFT
  → SUBMITTED
  → UNDER_REVIEW
  → APPROVED
  → PUBLISHING
  → PUBLISHED

拒绝和下线分支如下:

UNDER_REVIEW ──资料不足/规则不符──► REJECTED ──补件重提──► UNDER_REVIEW
PUBLISHED ──违规/商家下架──► UNPUBLISHED ──重新审核──► UNDER_REVIEW

商家编辑草稿可以持续几天,系统不需要为它创建一个长期占用线程的执行任务。提交时生成一次流程实例和版本快照;审核员看到的是该版本对应的资料、规则命中原因和证据链接。审核通过后,商品主数据在本地事务中变成 APPROVED 或 PUBLISHING,搜索、推荐、详情和运营活动通过事件更新投影。主数据是权威事实,索引和缓存是可重建投影;投影延迟需要被监控,但不应该让流程引擎把所有下游都当成同一个原子事务。

审核驳回必须携带结构化原因,而不是只写“审核不通过”。原因决定商家需要补什么资料、是否允许重提、是否需要更换审核人以及是否影响历史版本。一个人工任务至少包括流程 ID、商品 ID、规则版本、候选角色、允许动作、截止时间、处理结果、理由和证据引用。人工“通过”不能直接修改数据库状态,而应提交一个受权限控制的命令,经过状态机守卫后产生 ProductApproved 事件。

流程规则会变化。例如,旧版本只要求一次质量审核,新版本增加合规双审。新建实例使用 process_definition_version = 2,旧实例可以按版本 1 完成,或者按照明确的迁移规则补建合规任务。不能把所有在途实例直接套上新代码,否则昨天提交的商品可能突然从“待发布”变成没有定义的状态。流程版本不是发布系统的内部细节,而是业务记录的一部分。

5.4.3 退款:不可逆副作用和最终收敛

退款流程一般包括申请、资格校验、金额计算、风控/人工审核、渠道受理、到账确认、权益回补和账务记账。它和订单取消的区别在于:资金渠道的受理和到账可能是两个事实,优惠回补和库存恢复也不一定与资金动作同时完成。

退款单应拥有独立的生命周期,例如 REQUESTED、UNDER_REVIEW、SUBMITTED_TO_CHANNEL、CHANNEL_UNKNOWN、REFUNDED、FAILED_NEEDS_REVIEW。SUBMITTED_TO_CHANNEL 不是 REFUNDED,因为渠道受理成功并不代表资金已入账。超时后应先查渠道,以退款单号、外部请求号和金额确认结果;只有在确认失败时才允许重新提交,重新提交仍必须使用同一业务幂等键或渠道幂等标识。

当退款成功而优惠回补失败,流程不能回滚资金,也不能把退款单标成失败。更合理的做法是保持资金事实为已退款,单独创建权益回补任务,并在对账表中记录“资金已完成、权益待收敛”。这正是长流程和大事务组合的典型边界:不可逆节点完成后,后续动作必须围绕已发生事实向前恢复或人工治理,而不是假装可以恢复世界原状。[11][12]

三个案例的共同模型是:对象有稳定 ID,状态迁移有守卫,等待有截止时间,外部副作用有请求号,人工动作有权限和审计,失败后有查询、补偿、对账或人工出口。不同之处只是权威事实、不可逆节点和允许的终态不同。方法论应抽取共同结构,不应把“订单状态”“商品状态”“退款状态”硬塞进一套没有领域含义的通用枚举。

5.5 参考架构与实现路线

5.5.1 共同架构:事实、执行和投影分离

无论使用何种工具,长流程都可以拆成四个职责:

  1. 事实层:拥有业务对象和状态迁移的权威服务,负责不变量、权限和本地事务。
  2. 执行层:保存待执行步骤、重试、等待、定时器、人工任务和流程实例进度。
  3. 传播层:通过 Outbox、消息或事件日志,把已提交事实可靠地传给下游。
  4. 治理层:提供查询、审计、告警、对账、人工接管、版本迁移和操作保护。

四层可以部署在一个应用里,也可以由多个平台承载。关键是不要把职责混在一起:消息总线只负责传输,不拥有业务状态;Workflow 引擎可以持久化等待和执行历史,但不应在没有领域校验的情况下成为订单金额、库存数量或退款结果的唯一事实源;读模型可以为了查询方便折叠多个事件,但不能反向覆盖权威事实。

5.5.2 四种实现路线

路线一:数据库状态机。 把生命周期状态、版本、原因和下一次动作放在业务表或流程表中,由应用服务按照允许迁移表推进。它简单、贴近业务、查询直接,适合阶段有限、等待少、主控方明确的流程。缺点是超时、人工任务、重放、版本迁移和全局观察需要自己建设,状态逻辑容易散落到多个服务。

路线二:应用层编排器。 引入一个专门的 Orchestrator 保存主流程和步骤账本,业务服务只负责完成节点动作并返回结果。它适合订单履约、退款执行、发布编排等主控方明确的场景,可以集中处理超时、通知、重试、补偿和查询。代价是编排器本身成为关键系统,必须解决持久化、高可用、幂等、并发、版本和人工接管,否则只是把散落的条件分支搬到一个更大的服务里。Seata 的中文文档把 Saga 描述为长事务解决方案,把 TCC 描述为需要业务方显式实现 Try、Confirm、Cancel 的侵入式方案,这正好说明“分布式事务模式”和“长生命周期流程运行时”仍是两个层次的问题。[14][15][16]

路线三:Workflow 引擎。 把流程状态持久化、长时间等待、定时器、任务队列、重试、历史和人工任务交给专门平台。Temporal 把长时间运行的 Workflow 作为 Durable Execution,强调进程、网络或基础设施故障后可以从持久化历史继续执行。[4] BPMN 类平台则更偏向流程定义、任务和组织协作。Workflow 能减少自研运行时的重复工作,但不能自动解决领域事实、权限、不变量、补偿语义和流程版本迁移。引入它意味着团队要学习新的编程模型、部署模型、可观测模型和升级纪律。

路线四:事件驱动协作。 各领域服务发布事件,由订阅者继续推进自己的局部流程。它适合多团队自治、边界清晰、中心主控方弱的系统。优点是解耦和扩展自然,缺点是全局进度、跨领域审计、人工介入和异常排查更难。事件驱动不等于没有编排,只是把编排责任分散了;当流程需要回答“整体现在是什么状态”时,仍然要建立流程视图或显式协调边界。

5.5.3 ADR:一个订单履约流程的选择

可以用下面的 ADR 方式记录选择,而不是只写“推荐上 Workflow”。

背景与问题:订单支付、库存预占、履约单创建、物流和售后分属不同服务;订单需要跨天运行,支付回调可能重复或迟到,客服需要查询和人工接管。

决策驱动因素:订单域必须保有主状态权威;支付和库存动作需要幂等;支付等待不能占用线程;超时、补偿、人工任务和审计要统一可见;历史订单不能因为流程代码发布而失去解释能力。

候选方案:继续由订单服务中的定时任务和条件分支推进;使用独立应用编排器;使用通用 Workflow 引擎;完全采用事件驱动,各服务自行推进。

最终决策:订单域保留业务状态和迁移规则,先使用持久化编排器承载支付等待、履约创建、超时和补偿;节点内仍使用本地事务、Outbox 和幂等消费者。待人工任务、流程版本、重放和跨租户治理反复出现后,再评估是否迁移到 Workflow 平台。

获得的能力:主流程可查询、等待不占线程、步骤可重试、补偿有账本、人工有入口、指标可以按流程 ID 关联。

主动牺牲的能力:不追求跨所有参与方的全局原子提交;允许投影短暂延迟;在外部结果未知时允许处理中状态;编排器需要额外的高可用和运维成本。

已接受的风险:编排器可能成为主流程瓶颈;事件和命令可能重复;补偿不一定恢复历史原状;旧流程定义需要长期保留。

验证指标:在途实例 P99 停留时长、超时比例、未知结果存留时间、补偿成功率、人工任务逾期量、重复命令正确吸收率、对账差异数量和恢复平均时长。

重新评估条件:流程定义超过团队可维护范围;多个业务都需要相同的定时器、待办、重放和审计能力;编排器发布已经成为跨团队协调瓶颈;历史实例迁移成为主要事故来源。

5.6 等待、定时器、外部事件与人工任务

5.6.1 持久化等待不是阻塞线程

长流程的本质不是让线程一直等待,而是把等待条件持久化,在条件满足时重新唤醒。每个等待点都应回答:等的是什么、由谁唤醒、最长等多久、超时后怎么办、重复唤醒是否安全。

等待类型等待对象唤醒方式超时后的默认策略
用户行为支付、确认收货、补充材料页面操作、API 或回调提醒、取消或转人工
外部系统物流、供应商、风控结果事件、查询或轮询有限重试、冻结或人工核验
人工处理审核、客服裁决、财务复核待办完成升级、转派或终止
时间窗口支付截止、售后时效、发布窗口定时器命令自动迁移并记录原因
资源条件库存、额度、配额、锁定期资源事件或再次尝试释放、排队或改走替代路径

等待记录至少保存 wait_id、流程 ID、等待类型、关联键、截止时间、唤醒版本、最近一次尝试和超时策略。调度器投递的是“到期意图”,不是直接改业务状态。例如,调度器只发送 ExpirePaymentWindow(order_id, timer_id);订单状态机收到后仍需检查订单是否还处于 PENDING_PAYMENT、定时器是否属于当前流程版本、是否已经收到支付事实。这样即使调度器重复投递、延迟投递或执行多个实例,也不会绕过状态规则。

仅靠每天扫描数据库的 Cron 容易出现扫描延迟、重复触发和范围不可控。更稳妥的做法是按截止时间建立可查询的任务索引,允许提前唤醒、批量领取和租约续期,同时保留一个低频全量扫描用于修复调度遗漏。定时器系统本身也需要幂等键、租约、重试上限和死信入口,不能因为“只是定时任务”就没有审计。

5.6.2 外部事件必须经过验证和去重

重复支付回调、迟到物流事件、供应商重发通知和顺序颠倒的状态消息都是正常输入。处理外部事件时至少验证:

  1. 事件是否关联到正确的业务主键和流程实例。
  2. 来源是否可信,签名、租户和权限是否通过验证。
  3. 外部事件 ID 是否已经处理,事件版本是否早于当前版本。
  4. 金额、数量、币种、状态和时间窗口是否符合业务约束。
  5. 当前状态是否允许接受该事实,还是应查询权威系统或进入人工队列。

消息系统常见的是至少一次投递,消费者必须让重复处理的结果与处理一次相同。Idempotent Consumer 模式建议在数据库事务中记录已处理消息 ID,利用唯一约束吸收重复消息。[9] RocketMQ 中文文档也明确提醒,消息系统不能代替业务层去重;业务对重复消费敏感时必须建立业务幂等处理。[18]

对于可能乱序的事件,不能按“最后收到的消息覆盖当前状态”。可以使用聚合版本、外部单据版本、单调序列、事件发生时间和领域规则组合判断。无法判断时,宁可保持处理中并查询权威来源,也不要用猜测把流程推进到不可逆状态。事件消费成功只表示当前消费者完成了自己的处理,不表示整个长流程完成。

5.6.3 人工任务是正式节点,不是黑箱兜底

审批、驳回、人工补单和客服裁决经常被当成“自动化失败后的兜底”。实际上,很多业务本来就需要人工判断。把人工节点正式建模,才能解决权限越权、任务丢失、责任不清和人工操作后流程停滞。

一个可治理的人工任务至少包含:所属流程实例、当前阶段、候选角色或人员、对象范围、可执行动作、截止时间、升级规则、处理结果、理由、证据链接和关联规则版本。动作必须受状态机约束。审核员可以“通过、驳回、要求补件”,但不能直接把商品从草稿改成已发布;客服可以申请人工关闭订单,但高金额订单可能还需要风控或财务二次批准。

权限不能只看角色,还要看对象范围和风险阈值:谁能处理哪个商家、哪个地区、哪种商品;金额超过阈值是否必须双人复核;发起人是否不能审批自己的申请;紧急操作是否需要事后复核。每次人工动作都记录操作者、时间、前后状态、理由、规则版本和附件哈希。审计记录不是为了事故后追责才保留,它还是后续恢复自动化的输入:系统必须知道人工做了什么例外决定,才能继续执行或避免重复执行。

人工任务完成后,流程不应依赖运营人员再去“点一下继续”。系统应把处理结果转换成正式命令或事件,按既定规则进入下一阶段。如果人工选择“确认外部已成功”,这个动作也应该触发受控的事实确认和审计,而不是直接写入“成功”字段。高风险动作可以把人工任务分成申请、审批、执行、验证四个步骤,使执行者不能同时批准自己的操作。

5.6.4 Outbox 和局部大事务放在节点内部

长流程的一个节点内部,往往还会嵌套大事务。例如订单的“支付确认后创建履约单”,商品的“发布主数据并发送索引事件”,退款的“写入退款单并通知账务”。节点内部要保证业务事实和待发送事件的本地原子性,跨服务部分由编排、消息、补偿和对账负责。

Transactional Outbox 的基本做法是把业务更新和待发送事件写在同一个本地事务中,再由 relay 投递消息;它避免数据库提交和消息发送之间的双写窗口,但 relay 可能在发送成功后、记录已发送前崩溃,因而消费者仍必须幂等。[6][8] 这也是为什么 Outbox 不是全局事务,也不是流程引擎;它只解决一个本地事实与事件传播边界。

如果业务使用 RocketMQ 事务消息,也要区分本地事务提交与下游消费完成。官方中文文档把事务消息定义为最终一致性,并说明下游消费结果不由生产端事务自动保证。[17] 因此,上游流程仍需要消费幂等、失败重试、死信、查询和对账。不同实现路线都不能把“消息发送成功”误写成“所有参与方已经完成”。

5.7 故障、恢复、重放与流程演进

5.7.1 先分类故障,再决定动作

长流程不能把所有错误都交给同一个重试器。至少应区分以下故障:

故障类型典型现象首选动作不能做什么
明确业务拒绝库存不足、资质不符、支付失败记录拒绝原因并走业务分支无限重试
暂时技术失败连接超时、限流、短暂不可用退避、抖动、有限重试无上限重试放大故障
结果未知请求超时但外部可能已生效查询、对账或人工确认直接重复写入
版本冲突旧命令更新行数为零重新读取并重新判断强行覆盖新状态
重复/乱序同一回调多次或事件迟到幂等吸收、版本校验按到达顺序覆盖
补偿失败资源释放、退款回补失败持久化补偿任务并告警直接把主流程标成功
定义不兼容新版本无法解释旧实例按版本运行或受控迁移用新代码强行重放旧数据

AWS 的 Saga 指南把失败路径分成向前恢复和向后补偿:基础设施暂时失败时可以重试并继续,业务拒绝时才根据业务语义执行补偿。[5] Microsoft 的补偿事务模式也强调,补偿动作不一定按原步骤的严格逆序执行,且不一定能把系统恢复到原始状态;补偿顺序和动作要由业务规则决定。[11][12] 因此,“失败就回滚”不是长流程的通用答案。

5.7.2 结果未知必须先查,不要盲目重做

结果未知是长流程最危险的中间态。调用支付、退款、库存预占或供应商下单时,客户端可能在请求到达后丢失响应。此时“没有响应”只说明调用方没有证据,不说明服务端没有执行。

处理结果未知时,恢复顺序应尽量是:

  1. 使用原始业务幂等键、外部请求号或资源 ID查询权威系统。
  2. 如果查询接口暂时不可用,记录下一次查询时间,不改变业务事实。
  3. 多次查询仍不能确定时,进入对账或人工复核,冻结会造成重复副作用的后续动作。
  4. 只有确认原动作未生效或业务规则允许安全重试时,才发起同一幂等意图的重试。
  5. 查询结果确认后,再推进状态机,并把证据写入流程历史。

这样,流程的重试目标不是“重新执行一遍代码”,而是“让原有业务意图获得一个可解释结果”。AWS Builders’ Library 对此给出的工程经验是:客户端提供唯一请求标识,服务端把标识与请求参数和资源创建结果绑定,重试时返回语义等价结果。[7] 这比只在客户端加一个重试循环可靠得多。

5.7.3 恢复、补偿和对账要形成闭环

在线流程负责尽快推进,但它不能承担所有修复工作。独立的对账任务应周期性比较多组事实:订单与支付、订单与库存、退款单与渠道、商品主数据与搜索投影、流程实例与步骤账本。每个差异都进入统一的差异状态机,例如 DETECTED -> CLASSIFIED -> AUTO_REPAIRING -> VERIFIED -> CLOSED,或转入 MANUAL_REVIEW。

对账记录应至少包含业务主键、差异类型、权威来源、发现时间、影响范围、当前 owner、建议动作、尝试次数、下一次处理时间、验证证据和规则版本。自动补偿必须先查询事实,再执行最小必要动作,动作完成后重新查询并写回证据。金额不一致、身份不明或不可逆副作用不应由无限自动化处理,而应暂停并要求人工审批。

可观测性应从流程 ID贯穿到命令、事件、外部请求和人工任务。日志和指标至少关联:

process_id
business_id / order_id
command_id / idempotency_key
event_id / event_version
external_request_id
process_definition_version

重点关注在途实例数量、各状态年龄、最老等待时间、重试次数、未知结果时长、补偿队列年龄、人工任务逾期量、版本冲突和对账差异,而不是只看某个 RPC 的成功率。中国信通院的稳定性建设指南强调要把稳定性目标落到评价指标和治理路径上;在长流程里,最有价值的告警应能直接回答“哪一类业务对象、卡在哪个步骤、影响什么、谁负责下一步”。[20]

5.7.4 事件重放不等于命令重做

事件重放常用于重建读模型、补发通知、修复审计视图或验证迁移结果。它要求事件有稳定 ID、顺序/版本和明确语义,消费者必须幂等。重放事件只应重新计算安全的投影,不应默认再次扣款、发货、退款或发送不可撤回通知。

如果确实需要重新执行会产生外部副作用的命令,必须建立独立的重放策略:指定批次、审批人、作用域、幂等键、速率上限、暂停开关和验证步骤。重放命令不能复用普通在线入口,否则一次修复操作可能扩大事故。对含有隐私、敏感信息或已撤回权限的数据,还要考虑历史事件的访问控制和脱敏边界。

事件溯源系统可以从事件重建状态,但仍需要处理事件版本和外部副作用;普通状态机也可以通过迁移日志提供审计。不要把“保留事件”直接等同于“可以安全重放全部业务”。重放能力的核心是可区分事实重建、投影修复、通知补发和副作用重做四种不同目标。

5.7.5 流程定义演进与存量实例迁移

流程运行数天、数月甚至更久时,代码一定会变化。流程定义应显式保存版本,新实例使用新版本,存量实例可以继续按旧版本完成,也可以在明确迁移规则和审计记录下转入新版本。迁移至少回答:

  1. 哪些状态在两个版本之间一一对应?
  2. 新版本新增的审批或校验,对存量实例是补做、豁免还是人工确认?
  3. 已经完成的不可逆动作是否还能被新版本重新解释?
  4. 迁移失败时是回到旧版本、冻结实例,还是进入人工队列?
  5. 新旧版本的指标和告警如何区分,何时可以停止旧版本 worker?

例如商品发布流程从“一次人工审核”升级为“质检 + 合规双审核”,最安全的做法通常是:新提交实例使用版本 2;已完成质检但尚未发布的旧实例继续按版本 1 完成,或者为它们创建一个明确的合规补审迁移任务;已发布商品不因流程定义升级而重新执行发布副作用。流程迁移是业务变更,不是普通代码重构,必须有灰度、暂停、回滚边界和审计记录。

5.8 方案选型与实施路径

5.8.1 选型顺序:先模型,再运行时

可以用下面的顺序避免“先买平台、再找场景”:

  1. 定义流程对象、权威事实和主状态。
  2. 列出命令、事件、守卫条件和不变量。
  3. 标记等待点、人工节点、不可逆节点和超时策略。
  4. 识别每个关键节点内部的大事务、Outbox、补偿和查询接口。
  5. 根据流程复杂度选择数据库状态机、编排器、Workflow 或事件协作。
  6. 设计查询、审计、告警、对账、人工接管和流程版本策略。
  7. 用一个正常案例、两个失败案例和一个版本升级案例验证方案。

选择工具之前先画“事实图”和“状态图”,再画“执行图”。事实图说明谁拥有订单、库存、支付和退款的权威;状态图说明允许哪些迁移;执行图说明命令如何投递、等待如何唤醒、失败如何恢复。很多 Workflow 项目失败,不是因为引擎不能调度任务,而是因为团队把执行图误当成事实图,让任务完成状态替代了业务完成状态。

5.8.2 渐进式实施路线

阶段一:本地事务 + 持久化状态。 适用于阶段少、等待少、主控方明确的流程。先建立稳定业务 ID、状态迁移表、版本号、幂等键和迁移日志;不要直接允许任意 SQL 修改状态。

阶段二:状态机 + 调度和补偿任务。 出现支付超时、外部回调、异步通知或资源释放时,增加 next_action_at、任务表、重试分类和对账入口。调度器只投递幂等命令,业务状态机负责最终判断。

阶段三:应用层编排器。 当步骤顺序、超时、补偿、人工接管和跨服务查询已经超过单个领域服务的可维护范围时,抽出独立编排器。编排器保存实例和步骤账本,参与方服务保持自己的事实和本地事务。

阶段四:Workflow 平台。 当多个业务反复需要长等待、人工任务、历史、重放、版本和统一治理时,评估引入 Workflow 引擎。迁移时先选一个低风险但真实的流程,验证持久化等待、故障恢复、worker 升级、观测和历史实例迁移,不要一开始就把所有核心流程搬过去。

阶段五:共享治理平台。 只有多个领域已经形成稳定共性,才抽象共享的任务、审计、调度、重试、人工操作台和差异队列。平台提供运行时能力,不把所有业务补偿规则硬编码成一个不可扩展的“万能流程引擎”。业务状态和权限仍由领域 owner 负责。

5.8.3 方案能力与代价对比

路线适用前提获得的能力主动牺牲/主要代价升级信号
数据库状态机阶段有限、主控清晰、等待少简单、直接、事实贴近业务定时器、待办、审计和重放自建状态逻辑散落、人工脚本增多
应用编排器主流程明确、跨服务协调变复杂统一主视角、步骤账本、补偿与查询编排器高可用、版本和幂等成本多业务重复建设相同运行时
Workflow 引擎长等待、任务多、历史和恢复要求高Durable Execution、定时器、任务、历史新编程模型、平台运维、迁移和锁定成本流程平台成为组织级基础能力
事件驱动协作多领域自治、中心主控弱松耦合、扩展性、领域边界自然全局可见性、排障、人工协同更难需要流程视图和中心治理补强

四种路线都不能自动提供业务正确性。数据库状态机可能写出非法跳转,编排器可能重复调用,Workflow 可能执行了错误的流程定义,事件驱动系统可能接受乱序消息。真正的生产能力来自“权威事实 + 可验证状态迁移 + 幂等执行 + 可恢复等待 + 受控人工 + 对账”,而不是组件名称。

5.9 评审清单与方法论总结

5.9.1 流程设计评审清单

在进入实现前,至少逐项回答:

  1. 流程对象是什么?业务主键、流程 ID、主状态、子状态和原因字段分别是什么?
  2. 哪个服务拥有权威事实?Workflow、消息总线和读模型分别不拥有哪类事实?
  3. 每一条状态迁移由什么命令或事件触发?谁有权限发起?守卫条件和不变量是什么?
  4. 同一对象上的支付、取消、超时、人工操作和重放如何并发?版本冲突如何处理?
  5. 哪些节点需要等待?等待条件、截止时间、唤醒方式、重复唤醒和超时策略是什么?
  6. 外部事件如何验证来源、金额、版本、时间窗口和重复处理?乱序消息如何收敛?
  7. 哪些节点内部存在第 4 章的大事务?失败、结果未知和补偿失败如何反馈给主流程?
  8. 哪些动作不可逆?不可逆节点之前有哪些校验,之后如何向前恢复或人工治理?
  9. 人工任务的角色、对象范围、可执行动作、升级规则、双人复核和证据要求是什么?
  10. 对账比较哪些事实?差异如何分类、修复、验证和关闭?谁是 owner?
  11. 事件重放与命令重做如何区分?哪些副作用禁止自动重放?
  12. 流程定义如何版本化?存量实例如何完成、迁移、冻结和回滚?
  13. 指标能否发现超时、停滞、未知结果、重复命令、人工积压和版本冲突?告警是否直接连接到责任人和下一动作?

5.9.2 常见反模式

  • 先选框架再定义状态:让引擎的任务模型反过来决定业务语义,最终状态不可解释、升级难迁移。
  • 把长流程写成超大事务:为了等待外部系统而持有长锁或长 RPC,既牺牲可用性,也无法覆盖外部副作用。
  • 把重试当幂等:重复请求会重复扣款、重复创建资源或重复发放权益,事后再靠人工修复。
  • 把超时当未执行:响应丢失后直接重做,没有查单、幂等键和结果未知状态。
  • 把消息发送成功当业务完成:消息只代表传播动作完成,下游消费、状态迁移和最终业务结果仍需验证。
  • 把 Outbox 当全局事务:Outbox 只解决本地数据库和事件发送之间的双写窗口,不能替代消费者幂等、补偿和对账。
  • 把人工改库当恢复机制:短期绕过一个问题,长期破坏状态机、审计链、版本控制和后续对账。
  • 把事件重放当命令重做:修复读模型时意外重复退款、发货、通知或调用不可逆外部接口。
  • 所有领域共用一个万能状态机:表面统一,实际上把订单、商品、退款的权威事实和不变量混在一起。

5.9.3 方法论总结

长生命周期业务流程可以沿八个问题展开:先定义流程对象和生命周期边界;明确时间、角色、依赖、可逆性、数据新鲜度和审计约束;建立状态、命令、事件、版本、守卫条件和不变量;把关键节点内部的大事务与流程级等待分开;选择状态机、编排器、Workflow 或事件协作时写清获得、牺牲、风险和验证指标;通过订单、商品、退款等完整案例走通正常、异常、人工和最终收敛;最后用幂等、查询、补偿、对账、重放隔离和版本迁移治理变化。

最关键的判断始终不变:不要把“接口调用成功”误当成“业务对象已经完成”。业务完成应该意味着权威事实已经满足目标,必要的副作用已经确认、补偿或进入明确的待治理状态;所有尚未完成的部分都有 owner、下一动作和截止时间。流程状态不是 UI 标签,而是可以被系统、人员和审计共同理解的业务事实。

第 4 章的大事务保证关键节点内部副作用正确,第 5 章的生命周期模型保证对象跨阶段可治理,第 6 章的任务处理保证可执行工作可以暂停、分片和恢复,第 7 章则进一步讨论高准确性和强一致性场景的边界。四者组合起来,系统才不只是“主路径能跑通”,而是能在等待、重试、乱序、规则变化和人员接管之后继续工作。

如果一个团队只能记住一句话,可以记住:

长生命周期业务流程解决的不是某个关键动作怎么一次做完,而是一个业务对象如何跨阶段被持续推进、被明确治理、在异常时被安全恢复;状态机、编排器和 Workflow 只是不同复杂度下的实现手段。

参考资料

正文中的编号用于就近说明来源支撑的事实或方法;带有“本章推导”的内容,是结合订单、商品和退款场景作出的工程判断。

[1] Hector Garcia-Molina, Kenneth Salem, “Sagas”, Proceedings of the 1987 ACM SIGMOD International Conference on Management of Data, 1987. ACM Digital Library.

[2] Pat Helland, “Life beyond Distributed Transactions: an Apostate’s Opinion”, CIDR 2007, 2007. CIDR paper.

[3] Object Management Group, Business Process Model and Notation (BPMN), Version 2.0.2, 2014. OMG specification.

[4] Temporal, “Temporal Platform Documentation”, Temporal Documentation,访问日期:2026-09-21。

[5] AWS Prescriptive Guidance, “Saga patterns”, AWS Prescriptive Guidance,访问日期:2026-09-21。

[6] AWS Prescriptive Guidance, “Transactional outbox pattern”, AWS Prescriptive Guidance,访问日期:2026-09-21。

[7] Malcolm Featonby, “Making retries safe with idempotent APIs”, Amazon Builders’ Library, 2021. Amazon Builders’ Library。

[8] Chris Richardson, “Pattern: Transactional outbox”, Microservices Patterns, microservices.io,访问日期:2026-09-21。

[9] Chris Richardson, “Pattern: Idempotent Consumer”, Microservices Patterns, microservices.io,访问日期:2026-09-21。

[10] Martin Fowler, “Event Sourcing”, 2005. martinfowler.com.

[11] Microsoft, “Compensating Transaction Pattern”, Azure Architecture Center, Microsoft Learn,访问日期:2026-09-21。

[12] Microsoft,〈补偿事务模式〉,《Azure Architecture Center》,Microsoft Learn 中文,访问日期:2026-09-21。

[13] Microsoft,〈Saga 模式〉,《Azure Architecture Center》,Microsoft Learn 中文,访问日期:2026-09-21。

[14] Apache Seata,〈Seata 是什么?〉,Apache Seata v2.6,Apache Seata 中文文档,访问日期:2026-09-21。

[15] Apache Seata,〈Seata TCC 模式〉,Apache Seata 中文文档,访问日期:2026-09-21。

[16] Apache Seata,〈Seata Saga 模式〉,Apache Seata 中文文档,访问日期:2026-09-21。

[17] Apache RocketMQ,〈事务消息〉,Apache RocketMQ 中文文档,访问日期:2026-09-21。

[18] Apache RocketMQ,〈基本最佳实践〉,Apache RocketMQ 中文文档,访问日期:2026-09-21。

[19] 周志明,〈分布式事务〉,《凤凰架构:构建可靠的大型分布式系统》,2021。凤凰架构在线章节。

[20] 中国信息通信研究院云计算与大数据研究所,《分布式系统稳定性建设指南(2022年)》,2022。中国信通院官方 PDF。

第 6 章 任务处理与 Agent 协作:分片执行、租约治理与断点恢复

任务处理解决的不是某个业务对象如何流转,而是一段工作如何被拆分、调度、执行、暂停、恢复和治理。本章以供应商数据同步为主案例,结合离线计算、流处理和 Agent 协作,建立同步请求、异步消息、Batch / 分布式 Job、Workflow、Stateful Agent Runtime 与 Multi-Agent 的统一选型框架。

前面的章节分别讨论了关键业务动作的一致性,以及业务对象跨阶段的生命周期。本章处理第三类问题:一个可执行任务如何可靠跑完,或者如何在没有一次性“跑完”的情况下持续保持正确。扫描超时订单、生成日报、同步供应商库存、重建索引、执行历史数据修复、训练模型、运行 Spark 作业,以及让代码 Agent 修改多个文件,都需要明确执行边界、进度记录和恢复机制。

从表面看,这些任务的技术栈差异很大:有的使用 HTTP,有的使用消息队列,有的运行在 Kubernetes Job 或数据计算集群上,有的由 Workflow 引擎驱动,有的还需要大模型选择工具和调整路径。但它们共享一个事实:任务一旦跨越请求、进程、时间、步骤、系统或角色边界,就不能再把调用栈当作唯一状态,也不能把“进程退出”当作完成证明。

本章不把某个框架当成答案,而是先回答五个问题:任务的输入和成功判据是什么?中断后从哪里继续?重复执行会不会产生额外副作用?谁拥有最终事实?自动路径什么时候必须暂停并交给人或另一种执行单元?这些问题回答清楚后,方案才有可能从同步请求逐步演进为异步任务、Batch、Workflow 或 Agent Runtime,而不是在故障发生后被动堆叠重试队列和运维脚本。

6.1 问题定义:任务为什么会失控

6.1.1 短任务与长任务的区别不在耗时

许多团队用“几秒以内是短任务、超过几分钟是长任务”来划分任务。这种经验可以作为容量讨论的线索,却不是架构边界。真正重要的是任务是否需要跨边界持续推进。

这里的边界包括:

  • 请求边界:调用方收到响应后,工作是否还要继续。
  • 进程边界:执行进程重启后,是否能从外部状态恢复,而不是依赖内存。
  • 时间边界:任务是否可能等待分钟、小时、天甚至更长时间。
  • 步骤边界:任务是否包含多个有不同失败语义的阶段。
  • 系统边界:任务是否调用外部 API、数据库、计算集群或人工服务。
  • 角色边界:任务是否需要审批、复核、转派或人工确认。

一个耗时两秒但会产生不可逆外部副作用的付款请求,仍然需要幂等和结果未知处理;一个运行一小时但完全可重算、无副作用的离线计算,也许只需要 Batch 和分片。相反,一个只运行十秒的代码 Agent,如果需要修改文件、执行测试、等待用户确认后继续,就已经具有长任务的状态与治理特征。

因此,本章使用如下定义:短任务是可以在一次执行上下文中完成,并且失败后可以安全地整体重试的工作;长任务是必须跨越至少一个执行边界,且需要把进度、等待、恢复或人工决策持久化的工作。这个定义比固定时长更能解释为什么同一个接口在业务规模增长后会突然需要 Run、Checkpoint 和操作台。

6.1.2 典型错误:把执行问题误认为组件问题

任务最初往往只是一个函数、一个 Cron 或一条消息消费逻辑。数据量小、依赖稳定时,这种实现能够工作;一旦执行时间变长、对象数量变多或外部依赖开始抖动,问题会以相似的方式出现:

  • 同步请求一直不返回,线程、连接和客户端重试被长期占用。
  • 一个 Consumer 同时完成拉取、转换、发布、通知和补偿,失败后无法判断应从哪里恢复。
  • 全量任务中途失败,只能从头执行,重复写入和重复调用不断累积。
  • 任务状态散落在日志、队列和开发者记忆中,值班人员无法判断它是否仍在推进。
  • 下游限流或主库繁忙时,重试反而放大流量,最后影响正常业务。Google SRE 将这种正反馈称为级联故障,并特别强调重试、队列和资源耗尽之间的相互放大关系。[2][3]

这些问题不能只靠“把消费者扩容”“换成更快的数据库”或“再加一个重试次数”解决。一个任务系统需要把意图、执行事实、执行结果、失败原因和下一步动作分开记录。消息队列可以负责把工作交给 Worker,却不能替代任务状态;日志可以解释发生了什么,却不能作为可靠的恢复点;模型输出可以帮助 Agent 做判断,却不能自动获得生产副作用的权限。

6.1.3 典型长任务场景

旧版本中被删减的场景仍然有助于建立方法的覆盖范围。它们可以归纳为五类:

场景任务为什么会变长首要治理问题
供应商同步、ETL、索引重建对象多、外部源不稳定、需要分片和质量校验Checkpoint、数据版本、发布门禁、对账
模型训练与模型交付训练耗时长,资源可能被抢占,结果还要评估和审批实验状态、产物版本、指标门槛、回滚
Spark 离线计算与 Flink 流处理前者由 Stage 和 Shuffle 推进,后者长期持有状态分区、状态快照、资源隔离、恢复
代码、调研和运维 Agent输入不确定,需要工具调用、计划调整和人工判断工具权限、上下文、预算、Checkpoint、审查
审批、发布、迁移和平台运维有等待、角色边界和不可逆动作状态机、定时器、审批、暂停和人工接管

供应商同步是本章的主案例。模型训练和 Spark 任务说明 Batch 的边界不只存在于业务中台;Flink 的有状态处理则说明“长”也可以意味着作业长期运行,而不是一次执行很久。Apache Spark 以可容错的 RDD 和血缘重算来处理节点故障,Flink 则通过状态快照和 Checkpoint 恢复有状态算子;两者都说明恢复能力必须建立在明确的中间事实之上,而不是依赖某个 Worker 恰好没有重启。[9][10]

Agent 场景又增加了一层复杂度。代码 Agent 可能要理解需求、扫描仓库、规划修改、调用编辑工具、运行测试、读取失败输出,再决定返工;调研 Agent 可能并行检索资料、去重、交叉验证并由 Reviewer 收敛结论;运维 Agent 可能需要查询日志、诊断指标、生成变更计划,等待人工批准后再执行。它们的共同点不是“用了大模型”,而是执行路径包含不确定性,同时仍然需要传统任务系统的状态、权限、审计和恢复骨架。

6.1.4 本章的范围与非目标

本章聚焦以下问题:

  1. 如何识别任务的执行跨度、数据规模、流程确定性、认知复杂度和副作用风险。
  2. 如何选择同步请求、异步消息、Batch、Workflow、单 Agent、Stateful Agent Runtime 或 Multi-Agent。
  3. 如何设计 Task、Run、Step、Shard、Checkpoint、Lease、状态历史和结果账本。
  4. 如何处理重复投递、结果未知、局部失败、限流、背压、乱序、发布异常和人工接管。

本章不展开某个具体 Workflow 或 Agent 框架的 API,也不把模型提示词写作当作任务治理。第 4 章深入 Saga、Outbox、补偿和最终一致性;第 5 章深入业务对象的长生命周期状态机;本章只在这些主题交界处保留任务执行所必需的契约,并把 Agent 当成一种可能的执行单元,而不是一种可以替代业务事实的万能架构。

6.2 约束与指标:先明确什么算“跑得对”

6.2.1 用复杂度画像代替技术名词

任务选型前,建议先为任务建立一张复杂度画像。至少记录以下九个维度:

维度评审问题设计含义
执行跨度能否在一次请求和一个进程内闭环?决定是否需要异步交接与持久状态
对象规模处理一条记录、一个批次还是数十亿事件?决定分页、分片和资源调度
流程确定性路径是否固定,还是根据中间结果选择下一步?决定 Batch / Workflow 与 Agent 的边界
状态需求是否需要等待、暂停、恢复和历史查询?决定 Run、Step、Checkpoint 与状态历史
依赖复杂度是否调用多个系统、外部机构或人工角色?决定超时、结果未知、补偿与审批
副作用风险重复执行会不会重复扣费、发布或修改生产数据?决定幂等键、版本条件和人工门禁
认知复杂度是否需要阅读非结构化输入、归纳和动态规划?决定是否引入单 Agent 或 Agent Runtime
协作复杂度一个执行者能否承担规划、执行和审查?决定是否拆为多个角色化 Agent
治理复杂度是否需要配额、审计、回滚、SLO 和合规留痕?决定控制面、操作台和权限模型

这九个维度不需要一开始都量化成精确分数,但必须写出证据和假设。例如“每天同步五千万对象”是规模假设;“供应商每分钟最多 600 次调用”是外部约束;“价格波动超过上一版本 30% 必须人工确认”是业务门禁。容量数字如果只是方案假设,要明确标记,运行后的实际吞吐、P99、队列长度和最老任务年龄再用于校正。[1]

6.2.2 成功不能只有一个布尔值

长任务至少要区分四种成功:

  • 受理成功:任务定义、输入范围和权限检查通过,Run 已创建。
  • 执行成功:Worker 已完成某个 Step 或 Shard,并把结果与状态一起落地。
  • 业务成功:输出满足业务质量门槛,正式版本已经发布或业务对象已收敛。
  • 治理成功:所有失败、跳过、人工豁免和外部差异都有记录,后续责任边界清楚。

例如供应商同步接口返回 HTTP 202,只代表受理成功,不能让调用方把它展示为“库存已更新”;一个 Shard 的 Fetch 成功,也不意味着数据已经通过校验和发布;一次 Run 标记 SUCCEEDED,还应能解释隔离了多少坏数据、是否存在容忍范围内的差异,以及这些差异由谁负责处理。

指标应围绕这些层次建立:

指标类别示例需要回答的问题
完成性输入数、成功数、失败数、跳过数是否所有对象都有结论
正确性质量错误率、版本冲突数、对账差异输出是否满足不变量
时效性Run 时长、最老 Shard 年龄、等待时间是否在截止时间和 SLO 内推进
恢复性Checkpoint 命中率、重试成功率、恢复耗时故障后是否能从安全位置继续
资源性并发、CPU、内存、连接池、外部配额任务是否挤压在线流量
治理性人工任务逾期、回滚次数、审计缺失系统是否可控、可解释、可追责

6.2.3 Task、Run、Step、Shard 与 Checkpoint

无论最终使用脚本、消息队列、Kubernetes Job 还是 Workflow 引擎,都建议先持久化执行事实。一个通用模型包含五层:

实体含义必备字段示例
Task可复用的任务定义任务类型、输入契约、规则版本、资源策略
Run某次实际执行实例Run ID、输入快照、发起人、截止时间、配置版本
StepRun 内具有独立责任的阶段阶段状态、输入输出引用、重试预算、门禁
Shard可独立调度和重试的工作切片输入范围、租约、幂等范围、Checkpoint
Checkpoint已经落地且可验证的恢复位置游标、版本、输出摘要、提交时间、校验和

Task 表达“要做什么”,Run 表达“这次究竟处理了什么”。Run 应保存输入范围、规则与配置版本、发起来源、开始时间、截止时间和最终摘要。不能用一个全局任务状态覆盖历史运行记录,否则无法区分今天正在执行的任务和昨天遗留的失败。

Step 的边界应按责任和失败语义划分,而不是按代码函数随意切分。供应商同步可以把 Fetch、Normalize、Validate、Publish 和 Reconcile 分开;Fetch 失败可以重试外部请求,Validate 失败可以隔离坏对象,Publish 失败则需要保护正式版本。阶段边界越清晰,恢复时越不需要重复已经验证过的工作。

Shard 是扩展性与故障隔离的核心。常见切法包括供应商 / 商家维度、ID 范围、时间窗口、分页游标和哈希桶。分片太大,失败恢复成本高;分片太小,调度开销、状态数量和数据库写入压力又会过大。一个好分片应能估算工作量、单独重试、追溯输入范围,并且不会因并行而破坏同一业务对象的顺序约束。

Checkpoint 必须在副作用与状态事实都落地后推进。正确顺序通常是:读取输入 → 执行幂等写入或生成暂存结果 → 保存输出摘要和版本 → 在同一事务或可验证协议中推进 Checkpoint。只把页码打印到日志,或在落库前更新游标,都不能安全恢复。

6.2.4 状态迁移与版本契约

Run 可以经历 PENDING、PLANNED、RUNNING、PAUSED、SUCCEEDED、FAILED、CANCELLED、MANUAL_REVIEW;Shard 可以经历 PENDING、RUNNING、RETRYABLE、UNKNOWN、SUCCEEDED、FAILED 和 CANCELLED。状态不是为了增加字段,而是为了避免把“已入队”误判成“已完成”,也避免把“一个分片失败”误判成“整次运行已经无价值”。

每次迁移都应携带原因、发生时间、操作者或执行器标识、关联错误、版本号和审计引用。状态历史最好追加保存,当前状态服务于调度,历史服务于审计、复盘和容量优化。任务定义、分片规则、质量阈值和发布策略发生变化时,新的 Run 必须固化配置版本;历史 Run 按创建时的版本解释,不能在重试时悄悄读取一份已经变化的全局配置。

6.3 参考架构:调度、执行、状态与观测如何协作

6.3.1 控制面与数据面分离

一个可演进的任务系统不必一开始就有独立平台,但职责需要清晰。可以把它分成控制面和数据面:

控制面:Trigger / Scheduler / Policy / Operator Console
  -> 创建 Run、生成分片、检查配额、暂停或取消
  -> 读取状态、批准发布、触发受控重试

数据面:Queue / Dispatcher / Worker Pool / External Systems
  -> 领取 Shard、调用依赖、生成结果、提交 Checkpoint
  -> 执行幂等写入、返回错误和心跳

事实层:State Store / Object Ledger / Input Snapshot / Audit Trail
  -> 保存 Run、Step、Shard、版本、错误、外部请求号和人工动作

观测层:Metrics / Logs / Traces / Alerts / Reconciliation
  -> 解释推进速度、积压、失败原因、版本差异和恢复时间

调度器只负责创建运行实例、规划分片和发出可执行意图,不应在内存里长时间循环处理全部数据。Worker 只负责领取可执行分片、在租约内完成工作、持久化结果并续租或释放,不应自行猜测全局进度。状态存储保存权威的运行事实;消息队列负责分发,不应成为唯一状态来源。Airflow 的 DAG 与 Task 模型也强调,DAG 负责依赖、重试和时间控制,Task 才是实际执行单元;这正是控制面与数据面分工的一个具体例子。[8] DolphinScheduler 的官方项目资料同样把 DAG 编排、Worker 隔离、暂停恢复和批量回填作为调度平台能力,说明调度平台负责执行治理,业务任务仍要自己定义输入、结果和幂等边界。[18]

6.3.2 可靠交接:意图、消息与状态同时可追踪

如果请求需要异步交接,建议先在本地事务中写入 Run 意图,再写入 Outbox 事件,由 Relay 投递到队列。这样可以避免“数据库已创建任务但消息没发出”或“消息先发出但任务记录不存在”的断裂。Transactional Outbox 只解决本地业务事实与事件之间的双写窗口,Relay 仍可能在发送成功、更新投递状态前崩溃,因此消费者必须按事件 ID 或业务幂等键吸收重复。[5][6] 如果一个 Step 还要协调多个业务服务,则应把每个服务的本地事实和补偿边界显式记录,不能把 Outbox 误当成跨服务全局事务;Saga 的价值正在于把长事务拆为本地事务,并为已完成步骤定义业务补偿。[4]

消息契约至少应有 event_id、task_id、run_id、step_id、shard_id、业务主键、幂等键、输入版本、发生时间和过期时间。对于大对象,消息只保存对象存储引用与校验和;不能把可能无限增长的输入和上下文直接塞进队列。消费者确认成功的条件也要写清楚:是完成一次外部调用,还是完成本地事务、写入结果和推进 Checkpoint?如果只是调用成功但状态未落地,重新投递仍是正常路径。

6.3.3 Lease、心跳与重新领取

Worker 领取 Shard 时写入 owner_id、lease_until、attempt 和 heartbeat_at。在租约内,Worker 可以续租;进程崩溃或网络隔离后,租约到期,调度器将 Shard 标记为可恢复并重新投递。租约不是锁住整个任务的长期数据库事务,而是一种“当前执行者暂时负责推进”的可过期声明。

重新领取意味着执行至少可能一次,所以必须配合幂等和版本校验。一个 Worker 可能已经完成外部副作用,却在写成功状态前失联;另一个 Worker 随后重新执行。如果下游有稳定幂等键,可以查询并吸收这次重复;如果没有查询能力,就必须把结果标为 UNKNOWN,进入对账或人工队列。把“租约过期”直接等价为“前一次一定没有执行”是危险的。

6.3.4 查询、管理和数据接口

任务系统通常需要三类接口:提交接口、查询接口和管理接口。提交接口返回 run_id、受理状态和查询地址;查询接口返回 Run、Step、Shard 的进度、最近错误、下一次动作和是否需要人工处理;管理接口支持暂停、取消、恢复、范围重试、跳过和回滚,但每个动作都要受权限、状态机和审计约束。

对外暴露的状态不能只返回 success / failed。至少要区分 ACCEPTED、RUNNING、PARTIAL_FAILURE、SUCCEEDED、FAILED、PAUSED、CANCELLED 和 MANUAL_REVIEW。调用方得到的是受理结果,还是业务完成结果,必须在接口命名、字段和文档中明确,否则任务系统会把后端的异步不确定性转化成前端的错误承诺。

6.4 方案谱系与选型框架

6.4.1 七种主流执行方案

从简单到复杂,可以把常见方案归纳为七类。它们不是互斥的产品,而是对应不同主导矛盾的执行形态。

方案解决的主导矛盾获得的能力主动牺牲的能力升级信号
同步短任务请求内快速闭环调用简单、结果直接长等待和局部恢复请求超时、连接长期占用
异步消息 / Consumer主链路不应阻塞解耦、削峰、快速接入多阶段治理和细粒度审计Consumer 出现多个阶段和补偿
Batch / 分布式 Job大量对象如何推进分片、并行、Checkpoint动态规划与人工流程需要门禁、审批或发布版本
阶段化 Workflow多阶段如何治理等待、重试、审计、人工接管部分自由度和平台成本流程定义频繁变化或需动态推理
单 Agent不确定输入如何理解语义理解、工具选择、动态计划长任务可恢复性和确定性需要跨会话恢复或高风险副作用
Stateful Agent Runtime智能任务如何持续推进状态、Checkpoint、预算、审查实现与观测成本一个 Agent 无法兼顾规划、执行、审查
Multi-Agent多职责如何分工并行探索、专业角色、交叉审查通信、协调、Token 和终止成本角色边界明确且协作收益可验证

选择顺序不应从“要不要用 Kafka、Airflow 或 Agent”开始,而应从复杂度来源开始:先看是否跨边界,再看是否要批量恢复,再看是否需要阶段治理,最后才看是否需要动态推理和角色协作。Anthropic 对 Workflow 与 Agent 的区分也采用了类似原则:预定义代码路径适合可预测流程,模型动态决定过程和工具使用时才进入 Agent 语境;即使使用 Agent,也应从最简单、可组合的结构开始。[14]

6.4.2 同步、异步与 Batch 的边界

同步请求适合校验、查询、单次规则计算和小范围写入。它的价值是及时给出确定结果,而不是把所有工作都放在请求线程里。一旦执行时长不可预测、需要大量 I/O 或调用方只需要“已受理”,就应建立异步交接边界。

异步消息适合一条消息可以独立完成的动作,例如发送通知、刷新单个商品索引或补写非关键投影。消息体需要携带事件 ID、幂等键、业务主键和输入版本,调用方得到的是 accepted,不是已完成。Consumer 如果开始拉取多个数据源、维护多个阶段、等待人工、发布正式版本,就已经超出单消息任务的边界。

Batch / 分布式 Job 适合全量同步、ETL、对账、索引重建、历史修复和训练任务。它的核心不是“后台慢慢跑”,而是把“处理全部对象”拆成可观察、可重试、可恢复的分片推进。Kubernetes Job 将一次性任务、并行完成计数和工作队列模式区分开,并允许挂起后恢复,这说明调度层的并行与业务层的对象状态仍然是两个需要分别建模的问题。[11]

6.4.3 Workflow、Agent Runtime 与 Multi-Agent 的边界

Workflow 适合阶段依赖、外部等待、审批门禁、发布保护和人工转派。它把执行路径显式写出来,让运行时负责持久化等待、超时、重试、回放和状态查询。Temporal 将 Workflow Execution 描述为可持久、可恢复的执行单位,并通过事件历史与重放恢复进度;这类能力正是长任务需要的执行骨架,但业务事实仍应由领域服务拥有。[7] 中文云工作流资料也把顺序、选择、并行、状态跟踪、错误重试和长时间执行列为工作流编排的核心能力;这些能力可以由平台提供,但不应替代业务状态和发布门禁。[17][19]

单 Agent 适合高认知密度、低到中等执行跨度的问题,例如一次性分析、轻量诊断、资料归纳和小范围代码解释。它不应直接获得无限工具权限,也不能因为输出了“完成”就被视为完成了副作用。Stateful Agent Runtime 则把 Goal、Plan、Step、Tool Call、Observation、Review、Budget 和 Checkpoint 变成正式事实,让 Agent 可以暂停、恢复、返工和等待人工。

Multi-Agent 不是“多几个模型一起聊天”,而是把规划、执行、审查、协调或领域专家角色拆开。只有当一个执行单元同时承担这些职责会产生明显冲突,并且角色分工可以用指标证明收益时,才值得引入它。AutoGen 的原始论文把多 Agent 对话描述为可组合的应用基础设施,同时也意味着需要显式定义角色、消息和交互行为,而不是把自然语言对话当作隐式协议。[15]

6.4.4 选型 ADR:记录获得与牺牲

以“供应商同步是否采用 Workflow + Batch”为例,ADR 不应只写“推荐使用某某框架”,而应记录:

ADR 项目内容
背景与问题供应商分页、限流、质量异常和发布风险使单个 Consumer 无法安全恢复
候选方案串行脚本、MQ Consumer、Batch、Workflow + Batch、Agent 主导流程
决策驱动因素局部恢复、版本发布、人工门禁、外部配额、可审计性
最终决策Workflow 管理阶段,Batch 管理 Shard,Agent 只用于低风险异常归因
获得的能力可观察状态、分片恢复、质量门禁、版本切换、人工接管
主动牺牲额外状态存储、调度运维成本、最终发布延迟和模型调用成本
已接受风险部分差异需要人工处理,Workflow 和 Worker 之间仍可能重复投递
验证指标最老任务年龄、Shard 恢复时长、发布差异率、人工队列逾期量
重新评估条件数据规模、供应商协议、质量规则或人工处理量发生明显变化

ADR 的价值在于把“为什么现在选择它”与“未来什么条件下应重新选择”分开。它也迫使团队承认获得的能力与牺牲的能力必须用同一组维度比较,不能只说“可靠性更高”而不说明成本、延迟、复杂度和人工负担。[16]

6.4.5 一条渐进式升级路径

实践中可以沿着下面的路径渐进升级:

同步短任务
  -> 异步单元任务
  -> 分片并行 Batch
  -> 阶段化 Workflow
  -> 在局部步骤引入单 Agent
  -> Stateful Agent Runtime
  -> 有明确分工和终止条件的 Multi-Agent

这不是规定所有系统必须走完的路线。能用同步短任务解决,就不要先上 Workflow;能用 Batch 解决,就不要默认上 Agent Runtime;能用单 Agent 解决,就不要为了“更像 AI 系统”直接拆成 Multi-Agent。阿里云的工程实践也把单体函数、事件触发和 Workflow 编排区分为不同适用区间:流程长、需要状态持久化和自定义重试时,才有理由承担 Workflow 的编排成本。[20] 真正的升级信号应来自新出现的事实:请求被阻塞、局部失败无法恢复、阶段需要门禁、输入需要动态理解,或者一个执行单元已经不能兼顾规划、执行和审查。

6.5 完整案例:供应商数据同步如何设计

假设平台每天从供应商拉取酒店、房型、价格和库存。供应商接口按城市分页,存在限流、游标过期和字段缺失;同步结果必须经过质量校验后才能覆盖线上可售数据。这个案例同时包含大批量推进、外部不确定性、版本发布和人工治理,适合演示完整任务模型。

6.5.1 Plan:固化输入、规则和分片

调度器首先创建一条 Run,固化供应商、同步范围、输入快照时间、规则版本、发布目标、截止时间和操作者。随后根据城市、数据量和供应商配额生成 Shard。每个 Shard 记录起始游标、预计页数、优先级、最大重试次数和资源池。

Plan 阶段要验证三个不变量:分片不重叠,所有目标范围都被覆盖;规则版本和发布目标存在,不能中途从全局配置读取;输入快照和截止时间可解释,不能把不同时间采集的数据假装成同一批次。如果配置缺失、范围异常或上一次 Run 仍占用同一资源,应在真正拉取之前拒绝本次 Run。

6.5.2 Fetch:拉取与暂存分离

Worker 领取 Shard 后,以供应商配额为上限拉取分页数据。单次请求应有超时、请求号和稳定幂等键;如果游标失效,先判断供应商是否支持按对象版本或时间窗口重建游标,不能无条件从第一页开始制造重复流量。原始响应至少保留来源请求号、拉取时间、页码或游标、规则版本和快照引用,必要时保存压缩快照与校验和。

转换结果先写入暂存区,并用 run_id + shard_id + source_object_id 作为业务幂等键。暂存的目的不是多存一份数据,而是把“供应商返回了什么”“平台如何转换”“正式发布了什么”分开。重复拉取、分片重试和 Worker 重启都不能制造额外记录,也不能覆盖其他 Run 已确认的结果。

6.5.3 Normalize、Validate:对象级与批次级门禁

对象级校验检查必填字段、日期范围、币种、引用关系、数值上下限和来源版本。错误对象进入失败池,携带输入引用、错误分类、规则版本和推荐动作;对象级错误不一定阻塞全部城市。批次级校验则检查缺失比例、数据量突变、价格波动、库存变化、关键字段覆盖率和与上一有效版本的差异。

质量门禁应明确三种结果:通过,可以进入发布;可容忍,记录差异并在授权范围内继续;阻断,保留暂存版本并进入 MANUAL_REVIEW。门禁阈值不应被写成一个无法解释的常数。例如“价格下降 50%”可能对促销商品合理,对酒店库存则可能代表供应商接口异常;阈值必须带有业务范围、数据来源、版本和豁免理由。

阶段成功判据可恢复点失败策略
Plan范围覆盖且分片不重叠分片计划已持久化拒绝运行并修正配置
Fetch原始数据可追溯、游标已确认最后一页已暂存限流退避;永久错误转失败池
Normalize字段映射完成且版本记录完整对象级暂存结果隔离坏数据并记录规则版本
Validate对象和批次质量门槛通过校验报告已保存超阈值阻断发布、转人工
Publish目标版本原子切换完成发布版本号回退有效版本或冻结范围
Reconcile输入、暂存、发布和下游可见数可核对差异报告已保存补数、重放或再次核验

6.5.4 Publish:版本切换而不是逐行污染线上数据

发布是数据任务最危险的一步。推荐将暂存结果写入候选版本,例如 supplier-A:v19,通过校验后再把城市或供应商的“当前有效版本”从 v18 原子切换到 v19。如果只能按城市发布,也要为每个城市保留上一有效版本、切换时间和回滚点。不要把暂存数据逐行更新到线上主表,否则中途失败会让用户同时看到一半新数据和一半旧数据。

发布前应执行权限和资源检查:本次 Run 是否有权影响可售数据,发布范围是否与 Plan 一致,是否有其他版本正在切换,是否超过单次影响半径。高风险发布可以按城市、租户或供应商分批灰度,并在每批之间检查错误率、查询延迟和业务指标。发布后再触发索引、缓存和通知等派生任务,避免尚未通过质量门禁的数据扩散到更多系统。

6.5.5 Reconcile:用对账结束 Run

Run 结束前需要比较输入对象数、成功转换数、隔离错误数、暂存数、发布数和下游可见数。数量相等也不代表正确,还要比较版本、业务键、缺失范围和关键字段摘要。某些差异可以在预先定义的容忍范围内结束,某些差异必须把 Run 标为 MANUAL_REVIEW;只有所有必需阶段收敛,并且差异已被解释或转入明确的后续任务,Run 才能进入 SUCCEEDED。

对账发现问题时,应创建新的、可审计的修复 Run,而不是让运营人员直接修改结果表。修复 Run 需要引用原始 Run、差异报告、修复策略和审批记录。这样可以区分“原始供应商数据有问题”“转换规则有问题”“发布过程有问题”和“下游索引没有追上”,也能避免一次人工修改破坏后续重放。

6.6 Agent 任务的状态化与协作

6.6.1 Agent 增加的是不确定性,不是免除治理

Workflow 的执行路径通常由代码预先定义;Agent 则可以根据输入和中间观察结果选择下一步。ReAct 论文将推理与行动交错,让模型在行动结果的基础上更新计划,这解释了 Agent 为什么适合检索、诊断和开放式探索;但它也意味着每一次工具调用都可能改变环境,必须被当作正式执行事实记录。[13]

可以把 Agent 看成一个具有动态规划能力的执行单元:

Goal
  -> Plan
  -> Select Tool / Ask Human
  -> Execute
  -> Observe Ground Truth
  -> Update State
  -> Review / Continue / Stop

其中 Plan 不是承诺,Tool Result 才是环境事实;模型的自然语言总结不是状态机,Checkpoint 也不能用“对话上下文里大概记得什么”替代。Agent 每次需要继续工作时,都应从持久化的 Goal、Step、工具结果、审查结论和预算中恢复。Anthropic 的工程实践也强调,Agent 在每一步都需要从环境取得真实反馈,并在检查点或阻塞点暂停等待人类判断。[14]

6.6.2 Stateful Agent Runtime 的最小模型

一个可生产化的 Agent Runtime 至少需要以下实体:

实体作用不应承担的责任
Goal用户目标、范围、成功判据不直接作为执行状态
Plan当前计划和候选步骤不保证每一步一定执行
Step一个可验证的执行单元不隐藏多个不可分的副作用
Tool Call一次工具请求、输入和结果不默认代表业务成功
Observation环境返回的事实、日志或测试结果不把模型猜测当事实
Review自动或人工审查结论不绕过权限和策略
Checkpoint可恢复的状态快照不只保存对话文本
Budget时间、Token、调用次数和副作用额度不用无限预算掩盖失控

每个 Step 至少有 PENDING、RUNNING、WAITING、SUCCEEDED、RETRYABLE、BLOCKED、REJECTED 和 CANCELLED 状态。工具调用需要保存工具版本、参数摘要、请求 ID、权限上下文、返回摘要和是否产生副作用。对代码 Agent 来说,修改文件、执行命令和提交变更的风险等级不同;对运维 Agent 来说,查询监控、生成变更计划和执行生产变更也必须有不同权限。

6.6.3 代码、调研与运维 Agent 的边界

代码 Agent 的安全链路可以是:读取需求 → 生成计划 → 扫描仓库 → 修改受限文件 → 运行测试 → 生成差异摘要 → 等待人工确认 → 提交或回滚。计划阶段可以由模型主导,修改和测试需要确定性工具,提交和发布则应由权限策略与人工门禁控制。测试失败不是“模型再想一会儿”就算恢复,而是一个带输出、环境和重试次数的失败 Step。

调研 Agent 更适合并行检索、提取证据、去重、冲突检测和摘要,但每个结论都要保留来源、访问时间和证据片段的引用关系。模型可以提出“需要继续搜索”的判断,不能把没有来源的段落自动升级为事实。对于高影响结论,可用第二个 Reviewer 检查来源覆盖和推理跳跃,并把返工次数纳入预算。

运维 Agent 必须把查询与变更分离。查询日志、指标和配置属于低风险观察动作;生成迁移计划、扩容或重启属于中风险动作;删除数据、切换流量或修改生产配置属于高风险动作。高风险动作应该要求明确授权、显示影响范围、支持 dry-run,并在执行前保存回滚点。Agent 的价值在于缩短诊断和准备时间,不等于应该把最终控制权交给模型。

6.6.4 Tool Contract、权限与成本预算

工具契约应包含名称、版本、输入 Schema、输出 Schema、超时、可重试错误、不可重试错误、幂等键、权限范围、副作用等级和审计字段。工具返回值最好分为 success、retryable_error、permanent_error、unknown,而不是只返回一段自然语言。这样 Runtime 才能决定重试、返工、等待人工还是结束。

预算至少分为四类:最大执行时间、最大模型调用次数、最大工具调用次数和最大副作用额度。预算耗尽后,任务进入 BLOCKED 或 MANUAL_REVIEW,不能通过自动循环悄悄重置。上下文也应分层保存:用户目标和权限是稳定上下文,步骤状态和工具结果是执行事实,临时推理是可丢弃上下文。把全部对话拼成一个越来越长的 Prompt,会使恢复、成本和隐私边界都变得不可控。

6.6.5 Multi-Agent 的角色、共享状态与终止条件

复杂调研或研发任务可以采用最小三角结构:Planner 负责拆解,Worker 负责执行,Reviewer 负责检查。只有在角色责任、输入输出和失败路径清晰后,才考虑增加 Router、Supervisor 或领域 Specialist。角色拆分应减少职责冲突,而不是把同一问题让多个 Agent 重复回答。

共享状态不应只是一个公共聊天房间。更稳妥的做法是维护任务账本,每个 Agent 以结构化消息写入:完成了什么、使用了哪些来源或工具、产出了什么、仍缺什么证据、推荐下一步是什么。Planner 可以读取账本生成下一轮计划,Reviewer 可以针对某个 Step 返回 ACCEPT、REWORK 或 REJECT。消息必须有版本和幂等键,避免重投后重复创建任务。

终止条件至少包括:目标判据满足、所有必需 Step 已收敛、预算耗尽、连续返工超过上限、遇到无法自动判断的冲突、人工拒绝或截止时间到达。没有终止条件的 Multi-Agent 很容易进入循环讨论;没有最终裁决者的协作系统也无法解释为什么把互相矛盾的结果交付给用户。Agent 之间的分工获得了并行和专业化能力,但牺牲了通信成本、可复现性、延迟和调试简单性,这些都应进入 ADR 和运行指标。

6.7 故障与治理:默认假设会重复、会超时、会中断

6.7.1 幂等、重复和结果未知

任何 Shard、Tool Call 或消息都可能被重复执行:消息至少一次投递、租约超时、进程在提交确认前重启,都可能造成重复。数据库写操作可以用唯一约束、条件更新或 UPSERT;外部接口应携带稳定请求号并支持按请求号查询。幂等键必须表达业务意图,而不是简单使用随机消息 ID。例如 run_id + shard_id + source_object_id 能识别同一次同步对象处理,order_id + operation_type 能识别同一次业务动作。

最危险的是“结果未知”。一次调用超时只说明调用方没有在截止时间内收到响应,并不说明下游没有执行。正确顺序是:把步骤写成 UNKNOWN,记录外部请求号;优先查询同一幂等键;确认已完成则补写执行事实;确认未执行才允许使用同一键重试;仍无法确认则进入延迟查询、对账或人工队列。直接生成一个新请求号重发,可能造成重复扣费、重复发布或两份资源占用。

6.7.2 重试、超时与失败池

重试必须分类。网络抖动、短暂限流和临时不可用通常可以指数退避并加入随机抖动;字段非法、权限拒绝、规则缺失和质量门禁失败属于永久或业务错误,应该隔离而不是重试;超过重试预算的任务应停止自动尝试。Google SRE 建议限制每次请求和客户端的重试预算,并避免多层同时重试,因为多层重试会产生组合式流量放大。[2][3]

超时至少分为单次调用、Step / Shard 执行和整次 Run 三层。单次超时保护连接与线程;Shard 超时防止死循环或异常大数据;Run 截止时间避免旧任务和下一轮长期重叠。超时触发后,先判断是否存在未知副作用,再决定取消、重试、查询或对账。失败池或 DLQ 不应是垃圾桶,每条记录至少保存原始输入引用、错误分类、尝试次数、最近错误、首次失败时间、下次动作和权限范围;运维人员可以修复输入后重放,但所有动作都要留下审计。

6.7.3 并发、限流与背压

任务系统常见事故不是完全不跑,而是跑得太快。并发度应按被保护资源设置:供应商配额、租户范围、数据库连接池、计算集群、模型服务和发布能力都可能需要独立限制。实时库存修复不应和月度索引重建共享没有隔离的资源池;紧急补数可以有受控加急通道,但不能绕过配额、权限和审计。

背压意味着下游处理不过来时,上游主动减速。需要观察队列积压、Shard 等待时间、接口 429 比例、数据库连接池、Worker 利用率、发布延迟、重试率和最老任务年龄。超过阈值后,可以降低拉取并发、延长退避时间、暂停新 Run、只保留高优先级任务或返回“稍后查询”。AWS 和 Google 的可靠性资料都把超时、退避、抖动、限流和负载削减视为同一个过载控制问题,而不是互不相关的参数调节。[2][3]

6.7.4 暂停、取消、恢复与人工接管

暂停表示保留 Run、Shard 和 Checkpoint,稍后继续;取消表示不再领取新工作,并让已运行的 Shard 在安全边界退出。Worker 应在页、批或对象边界检查控制标记,在完成当前原子单元并写入结果后退出。强行杀进程可以是故障处置手段,但不应被当作正常取消协议,因为它会把状态留在未知位置。

恢复程序应先回收过期租约,再扫描 RETRYABLE、长期 RUNNING 和 UNKNOWN Shard。对未知外部副作用先查询或对账,确认安全后再投递;对长期无进度的执行器,要结合心跳、资源指标和最后 Checkpoint 判断是卡死、慢任务还是外部依赖阻塞。人工接管不能通过直接改库绕过状态机,操作台应展示进度、最近错误、外部请求号、影响范围和建议动作,并要求操作者填写理由、证据和后续验证。

6.7.5 顺序、版本与数据一致性

任务并行化后,并不是所有对象都需要全局顺序,但同一酒店、商品或账务账户的连续更新常常需要按版本收敛。不能依赖“队列看起来有序”:扩容、重放、延迟投递和人工补发都会破坏全局接收顺序。更可靠的做法是让对象携带来源版本、更新时间或单调序号,写入时只接受不旧于当前版本的数据。

示例中的条件更新只表达版本保护思想:

UPDATE staged_inventory
SET quantity = :quantity,
    source_version = :source_version,
    updated_at = :updated_at
WHERE supplier_id = :supplier_id
  AND source_object_id = :source_object_id
  AND source_version <= :source_version;

实际比较依据必须符合来源系统语义。如果上游版本不可信,不能伪造一个“看似有序”的时间戳;应保留原始快照、运行批次和冲突记录,把无法自动判定的新旧关系转入人工核验。对 Agent 任务也一样:旧的计划或工具结果不能因为晚到就覆盖新的人工拒绝或策略版本。

6.7.6 观测、对账与发布保护

可观测性要让值班人员在几分钟内回答:Run 是否在推进,停在哪个 Step,哪些 Shard 受影响,下游是否被压垮,是否需要暂停或介入。日志、指标和追踪应携带 task_id、run_id、step_id、shard_id、幂等键、输入版本和外部请求号。OpenTelemetry 提供跨服务关联 Trace、Metric 和 Log 的统一语义,但业务字段仍需要由任务系统主动写入;没有这些字段,技术链路可见也不等于任务事实可见。[12]

层级必备事实主要告警
Run开始、截止、输入版本、总进度、最终摘要长期未结束、整体失败率超阈值
Step耗时、输入输出数、异常率、等待原因阶段耗时突增、门禁失败
Shard租约、重试数、处理速率、Checkpoint、最近错误租约长期占用、重复失败、无进度
下游队列积压、限流、连接池、发布延迟背压触发、配额耗尽、发布阻塞
Agent工具调用、Token、预算、返工次数、人工等待循环调用、权限拒绝、成本突增

对账负责发现遗漏、重复和错位,在线任务负责快速推进,二者不能互相替代。供应商同步至少核对输入、暂存、发布和下游可见版本;索引任务核对主库版本与索引版本;Agent 调研核对结论与来源引用;模型训练核对数据集、代码、参数、评估结果和产物签名。发现差异后,创建新的修复 Run 或人工任务,不要直接删除错误历史。

发布保护应有最小质量门槛、影响半径和停止条件。质量阈值突破、下游错误率持续上升、回滚次数超过上限或人工确认未完成时,系统应暂停后续发布并报警。版本化发布的回滚对象是“当前有效版本或发布开关”,不是试图删除所有已经传播的消息;如果外部副作用已经发生,回滚应被建模为新的补偿任务,并记录影响范围、进度和验证结果。

6.7.7 任务系统的测试与演练

长任务不能只测试主路径。至少要演练:Worker 在外部调用成功后崩溃、消息重复投递、租约过期、Checkpoint 写入失败、游标失效、下游限流、数据库主从切换、旧版本晚到、发布门禁阻断、人工审批超时、Agent 工具返回歧义结果,以及恢复程序重复运行。

测试结果应验证不变量,而不是只验证最终 HTTP 状态。例如同一幂等键不能产生两次副作用;旧版本不能覆盖新版本;暂停后不能新增未授权工作;取消后已运行的原子单元可以安全结束;人工拒绝后 Agent 不能自动重新提交同一高风险动作;对账差异必须能指向具体 Run、Step、Shard 和责任人。对于不可确定的模型行为,应使用固定任务集、工具桩、轨迹评估和人工抽检建立回归集,不要把一次“看起来合理”的回答当成可靠性证明。[14]

6.8 方法论总结:按问题升级,而不是按潮流堆组件

任务处理可以压缩成一条判断链:先判断是否跨执行边界,再判断是否需要大量对象推进,再判断是否需要阶段治理,最后判断是否需要动态推理与角色协作。同步调用、异步消息、Batch、Workflow、Agent Runtime 和 Multi-Agent 不是互相替代的技术潮流,而是不同复杂度区间中的执行形态。

本章的核心判断如下:

  1. 先定义输入、输出、成功判据、截止时间和资源边界,再讨论框架。
  2. 只要中断后必须恢复,就需要持久化 Run、分片和可信 Checkpoint。
  3. 只要存在副作用,就把重复、延迟、乱序和结果未知当作默认路径。
  4. 任务级成功不等于对象级成功;批量任务必须能解释局部失败和跳过。
  5. 队列负责分发,状态存储负责事实,日志负责解释;三者不能互相冒充。
  6. Batch 解决大规模推进,Workflow 解决阶段依赖与治理,二者经常组合。
  7. Agent 可以处理不确定输入,但不能替代状态机、权限、审计、对账和人工门禁。
  8. Multi-Agent 只有在角色分工能带来可验证收益时才成立,并且必须有共享状态和终止条件。
  9. 重试、限流、背压、失败池、对账和演练共同构成可靠性,而不是事后补丁。

在进入实现前,可以用下面的评审清单快速检查一个方案:

评审问题若回答不清,通常意味着
这次任务的业务输入、输出和权威事实是什么?任务可能把日志、队列或缓存误当成事实
失败后从哪个最小安全单元继续?任务可能只能全量重跑
同一动作重复两次会怎样?幂等、版本或结果查询能力不足
哪些错误可以重试,哪些必须隔离或人工处理?重试策略会放大故障或吞掉数据错误
谁能暂停、取消、批准发布和人工放行?控制面与权限边界不完整
如何证明已经完成,如何发现遗漏?缺少对象级结果和对账闭环
为什么需要 Agent,为什么还不需要 Multi-Agent?方案可能由技术潮流而非复杂度驱动

成熟的任务系统不是一组“后台脚本”,也不是让模型自由循环的对话。它是一套能够跨故障、跨时间、跨人员交接持续收敛的执行机制:有明确的事实、有安全的恢复点、有可见的失败、有受控的副作用,也有在自动化无法安全判断时及时停下来的能力。

6.9 参考资料

正文中的编号用于就近说明来源支撑的事实或方法;其中“本章推导”表示本书结合供应商同步、数据处理和 Agent 协作场景做出的工程归纳,不等同于来源原文的直接结论。链接优先指向作者、出版方或长期维护的官方页面,访问与加入日期为 2026-09-21。

[1] Kleppmann M. Designing Data-Intensive Applications[M]. O’Reilly Media, 2017. 用于数据系统的可靠性、可扩展性、可维护性和数据流处理权衡。

[2] Beyer B, Jones C, Petoff J, Murphy N R, eds. Site Reliability Engineering[M]. O’Reilly Media, 2016. 用于服务目标、过载、重试预算和生产治理。

[3] Google SRE. Addressing Cascading Failures[EB/OL]. Google, 2016. 用于解释重试、资源耗尽和级联故障的正反馈关系。

[4] Richardson C. Saga Pattern[EB/OL]. Microservices Patterns. 用于区分跨服务流程协调、补偿和全局原子提交。

[5] Richardson C. Transactional Outbox[EB/OL]. Microservices Patterns. 用于说明本地事实与事件可靠交接的双写边界。

[6] Richardson C. Idempotent Consumer[EB/OL]. Microservices Patterns. 用于说明至少一次投递下的重复消费处理。

[7] Temporal. Workflow Execution overview[EB/OL]. Temporal Documentation, 2026. 用于说明持久执行、事件历史、重放、暂停和恢复。

[8] Apache Airflow. Core Concepts[EB/OL]. Apache Software Foundation, 2026. 用于说明 DAG、Task、依赖、重试和任务执行架构。

[9] Apache Spark. RDD Programming Guide[EB/OL]. Apache Software Foundation, 2026. 用于说明 RDD、分区、血缘和节点故障恢复。

[10] Apache Flink. 有状态流处理[EB/OL]. Apache Software Foundation, 2026. 用于说明状态、Checkpoint、Savepoint 和有状态流处理容错。

[11] Kubernetes. Job 中文文档[EB/OL]. Cloud Native Computing Foundation, 2026. 中文官方资料,用于说明一次性任务、并行 Job、工作队列和挂起恢复。

[12] OpenTelemetry. Documentation[EB/OL]. OpenTelemetry, 2026. 用于说明 Trace、Metric、Log 的关联观测基础。

[13] Yao S, Zhao J, Yu D, et al. ReAct: Synergizing Reasoning and Acting in Language Models[C/OL]. ICLR, 2023. 用于说明推理与工具行动交错的 Agent 执行模式。

[14] Anthropic. Building effective agents[EB/OL]. Anthropic, 2024. 英文工程资料,用于区分 Workflow 与 Agent、工具契约、环境反馈和人类检查点。

[15] Wu Q, Bansal G, Zhang J, et al. AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation[C/OL]. arXiv, 2023. 用于说明 Multi-Agent 的角色化对话、工具和人工输入组合。

[16] 周志明. 《凤凰架构:构建可靠的大型分布式系统》[EB/OL]. 2026. 中文开源技术资料,用于补充分布式系统的可靠性、状态和架构演进视角。

[17] 阿里云. 协调多个分布式任务执行——Serverless 工作流[EB/OL]. 阿里云帮助中心. 中文官方资料,用于说明顺序、选择、并行、状态跟踪、重试和长时间流程编排。

[18] Apache DolphinScheduler. Apache DolphinScheduler[EB/OL]. Apache Software Foundation, 2026. 官方项目资料,用于补充 DAG 工作流、暂停恢复、工作组隔离和批量回填等调度能力。

[19] 阿里云. 云工作流:功能特性[EB/OL]. 阿里云帮助中心, 2024. 中文官方资料,用于说明状态节点、选择、并行、循环和标准模式的长流程持久化。

[20] 阿里云开发者社区. Serverless 工作流适用场景及最佳实践[EB/OL]. 阿里云开发者社区, 2020. 中文工程实践资料,用于补充单体函数、事件触发和 Workflow 编排之间的适用边界;具体产品行为以官方文档为准。

第 7 章 高准确性与强一致性设计:并发不变量、权威事实与账本约束

当系统面对支付、库存、账务、钱包、积分等“结果必须正确”的业务时,设计重点不再只是流程能不能跑通,而是事实能不能被严格定义、状态能不能被安全推进、错误能不能被可审计地恢复。

前面的章节讨论了大事务、长流程和任务处理。本章进一步收紧问题范围:有些业务流程即使暂时延迟几秒,最终也可以收敛;但有些业务一旦发生重复扣款、库存超卖、账务不平或金额丢失,事后再补偿也无法完全消除影响。

因此,本章不讨论“所有系统都应该强一致”,而是讨论一个更准确的问题:

在高并发、网络不可靠、依赖不确定的条件下,如何识别不可出错的业务事实,如何把这些事实保护在正确的事务边界内,又如何让跨系统流程在失败后继续可解释、可恢复、可对账。

本章采用八步方法:先定义问题,再确定约束和指标;然后建立业务不变量与状态模型;在此基础上设计参考架构,用 ADR 记录方案取舍,最后通过完整交易案例和故障治理验证设计是否真的可落地。

7.1 问题定义:高准确性不等于整条链路都强一致

7.1.1 先区分三个容易混淆的概念

高准确性关注业务结果是否正确。例如:支付金额是否正确、库存是否被重复扣减、账本借贷是否平衡、优惠券是否被重复核销。

强一致性关注多个副本或多个并发操作对外呈现的状态是否满足严格的可见性语义。线性一致性要求一个操作看起来像在某个时间点瞬间生效,并且不能违背真实时间顺序;它描述的是并发对象对外呈现的行为,不等于“数据库里有事务”这句泛化结论。[2]

可串行化关注一组并发事务的结果是否等价于某个串行执行顺序。它主要描述事务之间的隔离关系,而不是每个 API 的实时响应语义;数据库实现了某种隔离级别,也不代表跨服务的支付、库存和订单已经形成一个全局原子事务。[3]

业务一致性关注跨多个业务对象或多个服务的整体目标是否最终达成。例如,订单、库存和支付不一定由一个全局事务同时提交,但系统必须最终收敛到“已支付、库存已确认、订单可履约”,或者“支付失败、订单取消、库存已释放”。

这三个概念有交集,但不能互相替代:

  • 一个本地数据库事务可能具备很强的隔离性,但如果金额计算规则错误,业务结果仍然不准确。
  • 一个跨系统流程可能不是全局强一致,但只要每个核心事实有唯一权威源、每个状态迁移可验证、每个异常都有补偿和对账,业务仍然可以正确收敛。
  • 一个系统如果只说“最终一致”,却没有定义最终要一致到什么状态、由谁修复、多久必须收敛,那么这不是设计,只是把问题延后。

7.1.2 典型场景和核心风险

场景不可接受的结果可以接受的延迟或中间态必须保护的事实
支付重复扣款、金额错误、支付成功但无法识别渠道回调延迟,短时间处于处理中或待查单支付意图、渠道流水、支付金额和最终结果
库存超卖、重复释放、库存变负、已付款却无货库存预占后等待支付,外围库存视图短暂延迟可用量、预占量、已售量和资源版本
账务借贷不平、流水丢失、余额凭空增加或减少统计报表延迟,余额快照异步重算记账凭证、借贷分录、币种和业务流水号
钱包与额度重复扣减、冻结金额丢失、额度穿透非核心展示延迟,异步通知延迟额度变更、冻结记录、解冻记录和账本
积分与优惠券重复发放、重复核销、撤销后无法回收发放通知延迟,营销展示短暂不一致用户权益状态、领取资格和核销流水

设计的第一步不是选择数据库或消息队列,而是把“不能错”和“可以晚一点”分别写出来。如果一个字段只是用于搜索排序或营销展示,它不应因为“看起来重要”而被强行放进支付主事务;如果一个字段会直接决定扣款、发货或记账,它也不能因为“之后会同步”就被放到一个没有约束的异步队列里。

7.1.3 三个反例:为什么“最终一致”不能成为口号

第一个反例是支付成功但订单仍显示未支付。展示延迟几秒本身不一定是事故,真正危险的是订单服务把“未收到回调”解释成“支付失败”,于是允许用户再次创建支付单;或者支付回调与用户取消并发到达,两个处理器都直接覆盖状态。这里要保护的不是页面刷新速度,而是同一支付意图不能出现两个有效成功结果。

第二个反例是库存异步扣减。系统先接受订单,再把扣库存消息放入队列,消费者稍后才扣减。当消息积压或消费者暂时不可用时,多个请求都可能看到相同的可售数量并成功下单。后续把部分订单改成缺货虽然能让库存账恢复,却无法恢复用户已经收到的购买承诺。因此,稀缺资源的“允许延迟”必须发生在预占之后,而不能发生在决定是否出售之前。

第三个反例是钱包余额依赖多个异步任务拼接。如果余额不是由账本严格推导,而是通过充值、消费、退款、冻结和解冻任务慢慢修正,那么消息乱序、重复消费或部分失败都会让余额暂时失去解释能力。对于钱包一类对象,可以让余额查询延迟,但不能让每一笔资金变化没有唯一流水和可重放依据。

7.1.4 范围与边界:只保护 CP 核心段

CAP 讨论的是网络分区发生时,一组共享数据如何在一致性和可用性之间取舍;它不是“数据库分类表”,也不能直接推出“整个业务系统必须选择 CP”。Brewer 后续对 CAP 的说明强调,设计者应明确处理分区并按场景优化一致性和可用性。[1] Spanner 的研究则展示了在特定复制、时钟和基础设施假设下,系统可以提供外部一致的分布式事务,但这并不意味着普通业务可以无成本复制同样的前提。[4]

更务实的做法是把一条交易链路拆成两层:

  • 强一致核心段:价格确认、库存预占、订单事实、支付事实和账务分录。
  • 最终一致外围段:站内信、积分展示、推荐、营销统计、报表、搜索索引和用户通知。

核心段内部需要严格约束并发、状态和金额;外围段则需要可靠事件、重试、幂等和对账。这样做不是降低质量,而是把最昂贵的正确性能力集中到真正无法出错的地方。

7.1.5 本章的范围与非目标

本章聚焦以下问题:

  1. 如何识别不可出错的业务事实和不变量。
  2. 如何选择本地事务、Outbox、预占、TCC、Saga 或对账补偿的组合。
  3. 如何处理请求重试、回调重复、状态未知、消息乱序和跨系统失败。
  4. 如何建立审计、对账、人工接管和恢复机制。

本章不试图展开完整的数据库实现、支付渠道接入协议或财务会计制度。数据库隔离级别和 Redis 库存并发细节可参见第 12 章;消息投递、Outbox 与消费幂等可参见第 4 章;订单流程与支付渠道的领域实现可参见第 15 章。

关于大事务、Saga 和 Outbox 的通用流程可参见第 4 章《大事务处理》;购物车与结算见第 14 章《电商商品发现与交易准备》,订单、支付与履约见第 15 章《电商订单与履约全生命周期》。本章只保留跨场景的判断框架和关键取舍。

7.2 约束与指标:先把正确性写成可以验证的条件

高准确性系统不能只用“成功率”和“接口延迟”衡量。它必须同时有业务正确性指标、状态收敛指标和恢复能力指标。更重要的是,指标必须和约束绑定:没有时间窗口的收敛率、没有金额口径的差异数、没有资源范围的库存准确率,都不足以指导设计。

7.2.1 四类约束

规模约束包括峰值请求量、并发事务数、单资源竞争度、单账户写入频率、单分片热点和跨地域复制距离。示例数字必须标记为假设或测量结果。例如,假设一个活动库存只有 10,000 件、峰值每秒 20,000 次抢购请求,那么重点不只是总吞吐,而是同一库存键的写冲突、队列积压和失败后的释放压力。

时间约束包括支付确认时限、库存预占 TTL、回调等待时间、事件最大延迟、对账周期和人工接管时限。TTL 不是一个随意的缓存过期时间,它决定资源会被锁多久、用户等待多久以及超时任务有多大的扫描压力。

正确性约束包括金额不能错、库存不能超卖、状态不能逆向跳转、同一幂等键不能产生两个副作用、补偿不能超过原始动作的影响范围。约束应优先写成可验证的关系,而不是“尽量保证”“通常不会”。

治理约束包括每条事实必须可查询、可追踪、可对账、可重放;出现差异时必须能定位责任边界;人工修复必须有审批、审计和可撤销的补偿记录。

7.2.2 用指标描述系统是否真的收敛

指标类别示例指标设计用途需要附带的维度
业务不变量借贷差额、库存差额、重复扣款数判断事实是否被破坏业务日、币种、商品、账户、渠道
状态收敛待支付、处理中、待查单、待补偿数量判断中间态是否堆积状态年龄、来源、最老任务
幂等与重复重复请求率、重复回调率、重复消费率判断重试和客户端行为幂等键、事件 ID、调用方
恢复能力补偿成功率、补偿延迟、人工接管量判断异常闭环质量重试次数、失败原因、责任方
对账质量对账差异数、差异金额、自动修复率判断权威事实是否分叉比较双方、时间窗口、差异类型
业务链路支付成功率、库存预占成功率、履约成功率连接技术指标与业务结果商品、渠道、地域、版本
性能与容量锁等待、事务耗时、写入吞吐、事件积压判断强一致边界是否成为瓶颈P95/P99、热点键、分片

这些指标不能只看平均值。对于资金和库存,少量严重错误也可能比大量普通请求失败更重要;对于补偿任务,则要同时关注积压数量和最老任务年龄。一个“平均 5 秒收敛”的指标,如果其中一批任务已经等待 3 天,就会掩盖真实风险。

高准确性链路还应定义业务 SLO,例如“支付成功事实在 30 秒内被订单服务确认的比例”“库存预占超时后 5 分钟内释放的比例”“对账差异在下一个工作日内完成分类的比例”。这些数字不是行业通用标准,应根据渠道 SLA、资源成本、客服承诺和监管要求测量或协商确定。

7.2.3 用可验证不变量替代口号

一个好的不变量必须可以通过数据库约束、状态条件、查询或对账验证:

  • 库存不变量:可用 + 预占 + 已售 = 总量,且每个分量不能小于零。
  • 账务不变量:每笔记账必须有借方和贷方,借贷金额相等,业务流水号不可重复入账。
  • 支付不变量:同一业务订单在同一支付意图下不能产生两个有效成功扣款。
  • 状态不变量:支付成功不能回退为待支付,库存已确认不能被普通取消动作再次释放。
  • 补偿不变量:每次补偿动作必须关联原始动作,且重复执行不能制造新的副作用。
  • 版本不变量:同一聚合的状态更新必须携带版本或前置状态条件,旧请求不能覆盖新事实。

可以把不变量转成技术检查:

不变量代码或存储约束对账方式
同一支付尝试只登记一次(payment_intent_id, attempt_id) 唯一约束;同一 attempt_id 重试时复用渠道幂等键,结果未知先查单按商户请求号、渠道流水号和支付意图逐笔核对;若同一意图出现多笔渠道成功,保留资金事实并进入重复扣款退款或人工处理
库存不能负数条件更新 available >= quantity、库存流水唯一总量、预占、已售和订单聚合比对
状态不能逆向状态转移表、版本号和前置状态条件扫描非法边、检查状态事件顺序
账务借贷平衡记账凭证号唯一、分录金额约束按凭证、账户、币种汇总借贷差额
事件不能造成双重副作用消费者已处理表或业务唯一键事件、消费记录和业务流水关联检查

《Designing Data-Intensive Applications》把记录、派生数据、复制和一致性放在同一套数据系统权衡中讨论;对本章而言,直接含义是先确定哪份数据是事实,再决定哪些数据可以异步派生,而不是先选择一个“看起来可靠”的中间件。[5]

7.2.4 把一致性写成接口契约

一个可评审的接口契约至少要说明四种语义:

  1. 写入语义:请求成功表示事实已经提交,还是只表示任务已接受?
  2. 读取语义:读取主库、快照、缓存还是派生视图?读到旧数据时是否允许作出交易决策?
  3. 重复语义:相同幂等键再次到达时返回第一次结果、当前状态,还是拒绝请求?
  4. 未知语义:超时后到底是明确失败,还是进入待查询状态?谁负责查询,什么时候转人工?

如果这些语义没有写进 API、状态机和运行手册,调用方就会自行猜测。猜测在正常路径上通常不会暴露,一旦网络抖动,两个系统对“成功”的理解就会分裂。TiDB 文档也提醒,数据库对外使用的隔离级别名称可能与内部实际实现存在差异,不能只根据配置名称推断并发语义。[19] 具体数据库的锁、快照、当前读和隔离级别边界,应以所使用版本的官方手册为准。[20]

7.3 核心模型:事实、状态、不变量和权威源

7.3.1 业务事实必须有唯一权威源

系统中经常同时出现订单状态、支付状态、渠道回调状态、账户余额、账务流水和缓存状态。如果每一层都被当成“真相”,系统就会在异常时陷入争论:到底应该相信哪个状态?

建议为每类关键事实明确权威源:

事实权威源示例其他系统的角色禁止的误用
支付渠道结果支付单和渠道查单结果订单系统消费支付事实只根据一次回调覆盖本地状态
订单状态订单聚合或订单状态机履约、通知、营销读取派生状态让支付服务直接改订单任意字段
库存数量库存账或库存服务搜索和详情展示缓存视图让缓存扣减成为唯一库存事实
资金流水不可变账本余额、报表和风控是派生结果直接覆盖历史流水或余额
事件投递Outbox 或事件日志MQ 是传输通道把“消息已发送”当成业务已完成

缓存、消息和搜索索引都可以很快,但它们通常不应该成为支付金额、库存事实或账本流水的唯一权威源。一个系统可以有多个读模型,却只能有一个负责裁决冲突的写事实;否则补偿时没有基准,对账时也不知道应该修哪边。

7.3.2 命令、事实与事件不能混为一谈

命令表达意图,例如“提交支付”“预占库存”“申请退款”;它通常带有发起者、权限、幂等键和前置条件。命令可能被拒绝,也可能因为超时而处于未知状态。

事实表达已经被权威服务确认的结果,例如“支付单已成功”“库存预占已建立”“账务凭证已入账”。事实需要持久化、可查询和可审计。

事件是把已经发生的事实通知给其他系统,例如 PaymentSucceeded 或 InventoryReserved。事件传递失败不应撤销已经提交的本地事实;它应进入重试、补偿或重放流程。

这个区分可以防止一个常见错误:调用支付渠道的命令返回 HTTP 200,就立即把订单写成“已支付”。真正的支付事实可能还需要渠道确认、签名校验、金额核对和本地流水落账。只有事实被权威服务确认后,才能发布成功事件。

7.3.3 状态机比一个布尔字段更可靠

高准确性场景通常不是“成功 / 失败”两个状态,而是包含等待、未知和补偿中的中间态。

支付状态可以抽象为:

待支付 → 支付中 → 支付成功
             ├→ 支付失败
             ├→ 待查单
             └→ 已关闭

库存预占可以抽象为:

可预占 → 已预占 → 已确认
            └→ 已释放

订单状态则应把支付、库存和履约的事实组合起来,而不是简单复制某个下游字段:

草稿 → 待支付 → 支付处理中 → 已支付 → 待履约 → 已完成
          ├→ 已取消       └→ 待查单       └→ 异常履约

状态机至少应定义:

  • 允许的状态集合、终态和不可达状态。
  • 每个状态的进入条件、退出条件和责任方。
  • 哪些迁移由同步请求触发,哪些由事件或定时任务触发。
  • 重复迁移如何返回幂等结果,冲突迁移如何返回当前权威状态。
  • 发生未知结果时进入什么状态,查询任务的最大等待时间是多少。
  • 超时、补偿和人工操作能否改变状态,以及它们需要什么权限。

状态转移还要考虑并发顺序。支付成功回调、用户取消、客服补单和超时任务可能同时到达。处理器不能只按到达时间覆盖 status,而应使用版本号、前置状态条件和权威事件版本。例如,UPDATE payment SET status = 'SUCCESS', version = version + 1 WHERE payment_id = ? AND status IN ('PROCESSING', 'QUERYING') AND version = ?,受影响行数为零时再读取权威状态判断是重复、竞态还是非法请求。

7.3.4 预占模型是资源生命周期模型

“预占 + 确认 / 取消”不是简单的两次接口调用,而是资源生命周期的显式建模。预占记录至少要包含资源 ID、业务单号、数量、预占时间、过期时间、当前状态、幂等键和释放原因。确认和释放都必须带上版本或状态条件,避免旧请求把已经确认的资源再次释放。

它的价值不只在于防超卖,还在于把失败恢复转成明确动作:

  1. 预占锁定资源并创建可审计的占用记录。
  2. 确认把预占转为已售、已扣减或已生效。
  3. 释放把预占归还可用池,并记录是支付失败、用户取消、超时还是人工操作。
  4. 过期扫描处理没有收到确认或释放命令的中间态。

库存、额度、余额冻结和优惠券占用都可以使用这个模型,但不能机械套用。余额冻结需要同时记录账户、币种和可用余额;优惠券占用需要防止资格重新计算导致的重复发放;外部供应商库存可能只能“尝试预订”,确认和释放还会受第三方语义约束。本章推导是:资源越稀缺、失败后越难恢复,越应该把预占设计成独立的权威事实。

7.3.5 幂等、防重和去重必须内建

强一致场景最危险的事故,很多不是“完全失败”,而是“成功被执行了两次”。系统必须默认会遇到请求重试、回调重复、消息重复投递、任务重复执行和人工补单重复触发。

可以把幂等分成四层:

层次幂等键示例保护对象推荐实现
请求入口client_request_id重复创单、重复支付意图唯一约束 + 保存第一次结果
外部回调channel + transaction_id重复回调和重放回调流水表 + 状态条件
业务动作order_id + action重复确认、重复释放、重复退款动作记录唯一键
消息消费consumer + event_id重复消费已处理消息表或业务唯一键

Stripe 的幂等 API 设计保存第一次请求的状态码和响应体,使网络失败后的重试可以复用相同结果;它也要求同一个幂等键不能被用于不同参数的请求。[12] 这个经验说明,幂等键不只是“去重字段”,还应绑定请求语义、参数快照、保留时长和结果查询方式。

幂等不是无条件地“重复返回成功”。如果第一次动作仍在执行,第二次请求应返回处理中;如果第一次动作已经失败且失败是可重试的,应返回可查询的失败原因;如果相同键对应不同业务参数,应直接拒绝并告警。对账任务和人工补偿也必须复用原始动作的业务键,不能每次重试都生成一笔新的资金或库存动作。

7.3.6 账本优先于余额字段

余额通常是账本流水的投影,而不是唯一事实。直接修改余额字段会让系统失去解释能力:无法说明余额为什么变化,也无法在发现错误后重放和重算。

更稳妥的结构是:

业务命令 → 记账凭证 → 借贷分录 → 余额投影 → 查询缓存 / 报表

每个记账动作要有唯一业务流水号、金额、币种、借方账户、贷方账户、来源事件和时间。余额可以是事务内维护的快速投影,也可以由分录异步重算;无论采用哪种方式,账本分录都必须可追溯、不可随意覆盖。事件溯源的核心思想也是保存状态变化事件,以便重建过去状态;但它并不意味着所有系统都必须把事件日志作为唯一存储,是否采用要根据重放、查询、合规、归档和团队能力判断。[13]

7.4 参考架构:核心事实本地原子提交,外围流程可靠收敛

7.4.1 一条可落地的总体链路

高准确性系统的通用链路可以表示为:

请求入口
  → 鉴权、参数校验、幂等校验
  → 核心服务本地事务
       ├─ 写入业务事实
       ├─ 推进状态机
       ├─ 写入预占 / 账本 / 审计记录
       └─ 写入 Outbox 事件
  → 事件可靠投递
  → 下游本地事务和幂等消费
  → 超时扫描、查单、补偿、对账
  → 结果查询与人工接管

这条链路没有试图把所有系统放进一个全局事务,而是把问题拆成三个边界。企业集成模式的价值也正在于为异步消息、路由、转换、重试和请求-响应交互提供共同词汇;组件名称不应替代这些交互语义。[6]

这条链路可以拆成三个边界:

  1. 事实边界:关键事实在自己的权威服务内原子提交。
  2. 传播边界:本地提交后的事实通过 Outbox、事务消息或日志可靠传播。
  3. 收敛边界:跨系统的业务目标通过状态机、补偿、查单和对账最终收敛。

可以用一张责任表防止边界混乱:

层次主要责任失败时的动作不应承担的责任
权威服务提交事实、保护不变量、推进本地状态回滚本地事务或进入明确中间态等待所有下游同步成功
Outbox / 事务消息可靠记录并传播已提交事实重试、回查、进入死信或人工队列判断下游业务是否已完成
下游消费者幂等应用事实、维护自己的状态重复忽略、重试、补偿或拒绝乱序事件修改上游权威事实
对账与补偿发现分叉、分类差异、驱动修复自动修复确定性差异,人工处理未知差异静默覆盖历史数据

7.4.2 本地事务与 Outbox 的配合

如果服务需要同时更新业务数据并发布事件,直接先写数据库再发消息会产生双写窗口:数据库提交成功但进程在发消息前崩溃,或者消息发送成功但数据库随后回滚。传统的 2PC 可能把数据库和消息代理纳入同一提交协调,但参与方能力、锁持有时间和故障恢复成本会显著增加;Pat Helland 对大规模系统中全局分布式事务的工程代价有专门讨论。[8]

Transactional Outbox 的做法是把业务事实和待发送事件写在同一个本地事务中,再由独立 relay 投递到消息系统。该模式保证“本地事务提交后有待发送记录”,但不能让消息只发送一次:relay 可能在发送成功后、记录发送结果前崩溃,于是恢复后再次投递。消费者必须幂等,这正是 Outbox 与 Idempotent Consumer 通常配套使用的原因。[10][11]

Outbox 表不应只是一个没有治理的消息垃圾桶,至少要设计:

  • 事件 ID、聚合 ID、聚合版本、事件类型和 schema 版本。
  • 创建时间、可投递时间、尝试次数、最后错误和锁定信息。
  • 事件状态,例如待投递、投递中、已确认、重试中和死信。
  • 业务事实与事件的关联键,便于从订单追到事件,也能从事件反查事实。
  • 归档与清理策略,避免事件表增长拖慢核心事务和扫描任务。

如果同一个聚合的状态变化会产生多个事件,relay 还要确保版本顺序。消息代理可以提供分区内顺序,但业务必须决定按订单、支付单还是库存项分区;不能把“某个队列当前有序”误认为“跨所有消费者和所有业务对象全局有序”。

7.4.3 事务消息的能力边界

事务消息可以把本地事务结果与消息投递状态联系起来,但它通常保证的是“本地主分支与消息发送”的一致性,而不是消息消费者处理结果与上游事务的全局一致。RocketMQ 官方文档明确区分了半事务消息、二次确认、事务回查和消费端自行重试,并指出事务消息仍会存在下游处理完成前的中间不一致状态。[18]

因此,事务消息和 Outbox 的选择要看系统现状:已有关系库且希望减少新组件,可以优先使用本地 Outbox;已有成熟消息平台且需要生产者事务回查,可以评估事务消息;无论哪一种,消费者都必须具备幂等、乱序处理和失败重试能力。消息机制不能替代业务状态机。

7.4.4 跨系统流程的主控与补偿

当订单、库存、支付和履约分别拥有自己的数据库时,主控方需要保存流程状态和每一步的结果,而不是只保存一个“订单处理中”字段。每一步最好记录:

  • 请求和业务幂等键。
  • 发送时间、响应时间和超时时间。
  • 下游返回的业务状态与技术状态。
  • 已执行的动作及其补偿动作。
  • 当前重试次数、下一次重试时间和失败原因。
  • 对应的上游事件、下游流水和人工处理单。

Saga 把跨服务事务拆成多个本地事务,并为失败设计补偿;原始 Sagas 论文就是为长生命周期事务提出可交错执行的本地事务和补偿事务模型。[7] 微服务环境下,Saga 可以采用编排式或协同式;编排式更容易集中保存流程状态,协同式耦合更松但更难追踪。无论选择哪种方式,Saga 都不是全局原子提交,也不能自动保证补偿一定成功。[9]

补偿还必须尊重业务事实。已扣款后不能用“把支付状态改成失败”伪造退款,已发货后不能用“释放库存”掩盖履约事实,已入账后不能直接删除分录。正确的补偿通常是建立一笔新的退款、冲正、释放或调账动作,并引用原始流水。

7.4.5 可观测性必须跟随业务事实

只记录 HTTP 状态码不足以排查支付和库存问题。日志、链路和指标必须携带订单号、支付单号、库存预占号、账务流水号、幂等键、事件 ID、状态版本和责任域。OpenTelemetry 将 traces、metrics 和 logs 作为可关联的遥测信号,为跨服务定位事实传播和状态变化提供了统一的观测基础。[15]

对于一个“支付成功但订单未更新”的事件,系统至少要能够回答:

  1. 支付事实是否已经落地,金额和币种是否核对通过?
  2. Outbox 事件是否生成,事件版本和支付流水是否关联?
  3. relay 是否读取并投递,是否发生重复投递或死信?
  4. 订单消费者是否收到并处理,处理事务是否提交?
  5. 订单状态是否因为版本冲突、金额不符或状态不允许而拒绝更新?
  6. 是否进入补偿、查单、对账或人工队列,最老任务年龄是多少?

这类问题应能通过一个关联 ID 从用户请求追到业务事实、事件、消费者、补偿和对账结果,而不是依赖工程师在多个日志系统里猜测时间线。

7.5 方案选型:用 ADR 记录获得了什么、牺牲了什么

7.5.1 ADR 不只是写“选择了某个组件”

本章的方案选型必须回答一个问题:在当前约束下,为什么接受一种取舍,而不是另一种取舍。ADR 至少包含:

  1. 背景和问题。
  2. 决策驱动因素。
  3. 候选方案。
  4. 最终决策。
  5. 获得的能力。
  6. 主动牺牲的能力。
  7. 已接受的风险和缓解措施。
  8. 验证指标与重新评估条件。

“采用 Kafka”“使用 Redis 锁”“引入分布式事务框架”都不是决策本身。决策应描述它如何保护哪个不变量、在什么前提下成立、不能解决什么问题、失败后怎么办。

7.5.2 方案对比

方案主要获得主要牺牲适用前提不能自动解决的问题
单库本地事务原子性强、语义简单、延迟低跨服务能力弱、扩展边界受限事实可以收敛在一个服务和数据库内下游传播和外部渠道结果
本地事务 + Outbox避免业务数据与事件双写丢失需要 relay、重复投递和消费者幂等核心事实已在本地确定,需要可靠传播下游业务动作是否成功
预占 + 确认 / 取消资源生命周期清晰,失败可释放状态更多,需要 TTL、扫描和补偿资源可预留,确认和释放边界明确外部供应商是否接受确认
事务消息本地事务结果与消息提交关联中间态、回查和消费端重试仍存在消息平台支持生产者事务回查消费者处理的全局原子性
TCCTry、Confirm、Cancel 语义显式参与方改造成本高,空回滚/悬挂/幂等复杂多参与方都有资源预留能力第三方不可控接口和人工流程
Saga跨服务流程可推进,参与方自治没有全局瞬时原子性,补偿可能失败每一步有本地事务和可执行补偿补偿动作本身的自动成功
分布式锁解决局部并发互斥不解决事件丢失、账本和跨系统提交只需要保护短时单资源临界区业务状态、消息和资金正确性
XA / 2PC统一提交语义直接长事务、锁竞争、可用性和运维成本高参与方受控且确实需要统一提交不支持 2PC 的第三方服务

Seata 官方文档把 AT、TCC、Saga 和 XA 分成不同模式,并分别说明本地事务、业务 Try/Confirm/Cancel、补偿和资源协调的前提;这说明“分布式事务”不是一个单一开关,而是一组不同侵入性、隔离性和恢复语义的方案。[16][17]

7.5.3 选择路径:先问边界,再问组件

可以按以下顺序做选型:

  1. 关键事实能否通过领域边界调整,收敛到一个服务和一个数据库?如果能,优先本地事务。
  2. 如果事实已经落地,问题只是可靠传播,优先本地事务加 Outbox 或受控事务消息。
  3. 如果资源需要先锁定再决定最终结果,优先预占、确认、释放模型。
  4. 如果流程跨多个服务且允许中间态,使用 Saga 或显式编排状态机。
  5. 如果参与方都能提供稳定的 Try、Confirm、Cancel,并且确实需要更强的隔离,再评估 TCC。
  6. 只有在参与方受控、边界很窄且团队能够承担阻塞与故障恢复成本时,才考虑 XA / 2PC。

分布式锁只应出现在这个判断链条的局部位置:它可以保护一个短时间内的资源竞争,却不能把多个数据库写入、消息投递和第三方调用变成一个事务。把锁的租约时间、续租、失效和 fencing token 设计好,也不能替代账本、状态机和对账。

7.5.4 示例 ADR:电商创单采用局部原子 + 可靠事件

背景:订单、库存和支付属于不同服务;库存需要预占,支付渠道存在回调延迟和未知状态,外围通知和积分不影响交易事实。业务既要防止超卖和重复扣款,又要承受活动期并发。

决策驱动因素:不能超卖,不能重复扣款,支付结果必须可查;同时需要控制锁等待和第三方依赖带来的尾延迟,不能让通知或报表系统成为全局事务参与者。

候选方案:三方 XA / 2PC、TCC、全链路同步 RPC、Saga + Outbox、订单服务单体化。

最终决策:订单服务在本地事务中创建订单、支付意图和 Outbox;库存采用预占、确认、释放;支付以支付单和渠道查单作为权威事实;跨服务推进使用 Saga 或显式状态机;通知、积分、推荐和报表全部异步化。订单创建只在价格快照、库存预占和本地事实满足条件后进入待支付,不等待所有外围动作完成。

获得的能力:

  • 订单事实可以在本地事务内原子落地。
  • 库存有明确的预占和释放语义,超时可以扫描。
  • 支付未知状态可以通过查单、回调和对账继续推进。
  • 事件可以重试,消费者可以幂等,外围系统可以独立扩缩容。
  • 每个异常都有订单、支付单、库存预占单或账务流水作为人工接管对象。

主动牺牲的能力:

  • 不再追求订单、库存、支付三方瞬时原子提交。
  • 用户可能短暂看到“支付处理中”或“订单待确认”。
  • 系统需要维护更多状态、补偿任务、对账任务和人工流程。
  • 设计和运维复杂度高于单体本地事务,开发团队必须维护事件版本和兼容策略。

已接受风险与缓解措施:支付渠道或消息系统发生长时间故障时,订单会积累在处理中;通过待查单队列、超时查询、用户可见的处理中状态和人工队列限制影响范围。补偿逻辑可能因下游再次故障而延期;通过幂等动作记录、指数退避、死信和对账任务避免无限重试。库存确认长期失败时可能出现已支付但不可履约;进入异常履约和退款流程,不能静默关闭订单。

验证指标:重复扣款为零,超卖差异为零,Outbox 积压的最老事件在目标时间内处理,待查单任务在目标时间内收敛,对账差异自动修复率达到目标,人工接管任务有上限;这些目标应通过压测、故障演练和历史数据测量,而不是凭经验填充。

重新评估条件:单库事务已经成为容量瓶颈、跨地域合规要求改变、支付和库存需要独立扩展,或当前补偿复杂度已经超过引入受控分布式事务的成本。重新评估时不能只看吞吐,还要比较锁等待、异常处理人力、恢复时间和错误影响半径。

7.6 完整案例:电商创单、支付与库存如何闭环

7.6.1 先声明假设和边界

下面使用一个标准电商创单场景演示方法,不把示例数字当成通用标准。假设商品中心提供价格快照,库存中心管理可售库存和预占,订单中心是订单状态权威源,支付中心管理支付意图和渠道流水;通知、积分、推荐和报表是外围消费者。

强一致核心段是:价格快照校验、库存预占、订单创建、支付事实和账务入账。最终一致外围段是:订单通知、积分发放、购物车清空、搜索索引刷新和经营报表。库存预占 TTL、支付查询间隔和人工接管时限应由商品稀缺性、渠道 SLA 和客服承诺测量得到。

7.6.2 正常链路

用户提交订单时,结算服务生成结算快照,包含商品版本、价格、优惠资格、币种、配送信息和渠道。订单服务验证价格版本、用户资格和幂等键;库存服务以订单号和行项目号创建预占记录;支付服务创建支付意图,但此时不应把“支付意图已创建”误写为“支付已成功”。订单服务在本地事务中保存订单、支付意图引用、库存预占引用和 Outbox 事件,状态置为“待支付”。

用户完成支付后,支付服务先验证渠道回调签名、支付单号、订单号、金额和币种,再把支付事实落到自己的事务中。支付事实提交成功后发布 PaymentSucceeded 事件。订单服务消费事件时,以支付单号、事件 ID 和订单当前版本做幂等更新;库存服务收到确认指令后,将预占转为已确认;订单进入“已支付”或“待履约”。积分和通知消费订单事实,不能反过来决定订单是否支付成功。

这条链路中的关键点是:支付成功不能仅以 HTTP 回调成功为依据,库存确认不能仅以 Redis 预扣为依据,订单成功也不能仅以前端收到响应为依据。每个核心状态都必须有自己的持久化事实,并且通过业务主键可以反查上下游流水。

7.6.3 状态、键和责任矩阵

动作业务主键权威记录成功后的事件超时后的状态
创建订单order_request_id订单记录OrderCreated查询订单创建结果
预占库存order_id + line_id库存预占记录InventoryReserved待释放或重查
发起支付payment_intent_id支付单PaymentStarted支付中或待查单
接收支付成功channel + transaction_id支付事实流水PaymentSucceeded回查渠道和对账
确认库存order_id + reservation_id库存流水InventoryConfirmed异常履约或补偿
退款/冲正payment_id + refund_id退款单和账务分录RefundSucceeded待查退款

重复请求的结果不应该简单返回“重复错误”,而应返回原始业务结果。例如第一次请求已经创建订单,第二次请求应返回同一个订单号,而不是重新创建订单或让用户陷入未知状态。入口层要保存幂等键与参数摘要,避免同一个键被错误地用于另一份订单。

7.6.4 重复请求、重复回调和重复消费

客户端可能因为网络超时重复提交订单,支付渠道可能重复发送回调,消息 relay 可能重复投递事件。系统必须把这些重复行为当作正常输入。

入口幂等可以使用业务幂等键和唯一约束;支付回调幂等可以使用“渠道标识 + 渠道流水号”;消息消费幂等可以使用事件 ID、状态版本或已处理消息表。重复的 PaymentSucceeded 事件如果订单已经是已支付,应记录为已处理但不再触发新的确认、发货或积分动作;如果订单状态仍为支付处理中,则推进一次;如果订单已经关闭,则进入异常队列,等待对账与人工判断。

重复确认和重复释放也需要状态条件。例如库存已经确认后再次收到释放指令,不能把可用库存增加两次;库存已经释放后再次收到确认指令,不能直接把已释放记录改为已确认。状态机应返回“已经完成”“当前状态冲突”或“需要人工处理”,而不是让每个调用方自行决定。

7.6.5 超时和未知结果

最危险的情况不是明确失败,而是请求在服务端可能已经成功执行,但客户端没有收到响应。

例如支付请求发出后连接断开,客户端不能立即认为支付失败,也不能无限次用新支付单重试。正确处理是保留原支付单号,进入“待查单”,通过渠道查询、异步回调、支付对账或人工核验确认最终结果。查询结果需要再次核对订单号、金额、币种和渠道流水,不能只因为渠道返回“成功”就更新任意订单。

同理,库存确认超时后不能立即重复扣减;订单创建超时后不能只依据客户端响应判断。所有未知结果都需要显式状态、查询接口和后续确认任务。客户端可以通过 GET /orders/{id} 查询订单事实,服务端可以通过 GET /payments/{id} 查询支付事实;查询接口本身也要说明它返回的是权威状态还是派生视图。

7.6.6 失败与补偿

失败点已完成事实禁止的处理后续动作
预占失败订单草稿或结算快照可能存在继续开放支付入口订单置为库存不足,禁止进入支付
支付失败库存已预占只改订单状态而不释放库存关闭支付单,幂等释放库存,记录取消原因
支付成功但订单未更新支付事实已落地重新创建订单或重新扣款重投支付事件,主动查询订单消费记录
支付成功但库存确认失败资金事实和订单可能已成功静默把订单标成完成进入异常履约、替代供给或退款流程
订单创建成功但 Outbox 未投递本地订单事实已落地重新创单以“补发事件”relay 重试原事件,不能重建业务事实
预占超时资源仍被锁定无条件释放所有同商品库存按预占状态和版本安全释放
退款请求超时渠道可能已受理退款生成另一笔新退款复用原退款号查询并对账

补偿不是简单的“把状态改回去”。退款、库存释放和账务冲正都可能是新的业务动作,因此补偿动作本身也必须有幂等键、流水和审计记录。补偿应遵循原动作的业务语义和依赖顺序:先确认支付结果,再决定是否退款;先确认预占仍存在,再决定是否释放;先确认原分录,再生成冲正分录。

7.6.7 支付、库存和账务如何互相约束

支付中心不能把渠道回调直接当作订单命令,而应先建立支付事实;订单中心不能根据展示缓存判断已支付;库存中心不能根据搜索索引判断可售量;账务中心不能根据订单状态自行推断已经入账。每个系统消费上游事实,但对自己的事实负责。

一个安全的支付成功推进顺序可以是:

校验渠道结果
  → 落支付事实
  → 生成账务记账意图或记账凭证
  → 发布支付成功事件
  → 订单状态推进
  → 库存确认
  → 履约、通知、积分异步推进

不同业务可以把订单确认和账务入账的先后顺序调整,但必须在 ADR 中写清楚。如果支付事实已经成功而账务入账失败,系统不能把支付事实删除;应进入待记账或异常对账状态,最终通过重试、补账或退款处理。这个顺序保护的是“事实不可丢失”,不是追求所有系统同时返回成功。

7.7 故障与治理:把异常闭环做成一等能力

7.7.1 对账设计

对账应先明确三件事:比较双方、比较口径和允许窗口。没有这三件事,对账任务只能制造“差异数量”,却无法说明哪些差异是正常延迟,哪些差异需要修复。

对账对象比较双方核心口径常见差异自动处理
支付对账渠道成功记录与本地支付事实订单号、支付号、金额、币种、时间窗口渠道有本地无、本地成功渠道无、金额不符明确缺失可补事实,金额差异转人工
订单对账订单状态与支付/履约结果状态组合和事件版本支付成功订单仍待支付、关闭订单仍有支付重放事件或进入异常订单
库存对账库存账与预占/订单流水总量、可用、预占、已售重复释放、未释放预占、库存差额确定性释放可自动修复,未知差异冻结
账务对账业务流水、总账分录与余额快照凭证号、借贷、账户、币种缺分录、重复入账、借贷不平重试缺失凭证,金额差异人工审批
退款对账渠道退款与本地退款单/冲正原支付号、退款号、金额渠道已退款本地未更新查询并补充事实,禁止重复退款

差异至少分为数据延迟、重复记录、缺失记录、金额差异、状态冲突、超时未收敛和无法自动判断。自动修复只能覆盖确定性差异;金额不明或跨系统冲正必须进入人工队列。对账结果本身也应保存输入范围、版本、执行时间、处理人和修复动作,避免“任务跑过了但无法复盘”。

7.7.2 审计和人工接管

人工接管不是承认系统失败,而是高准确性系统在不可判定状态下的安全边界。人工操作必须:

  • 使用专门的操作类型,不能直接修改核心字段。
  • 记录操作者、原因、审批单、原始事实和操作前后的状态。
  • 经过状态机校验,不能绕过不变量。
  • 支持撤销或追加冲正,而不是覆盖历史记录。
  • 限制权限范围,资金、库存和账务操作应分离授权。
  • 具备幂等保护,重复点击同一个处理单只能产生一个动作。

后台页面不应提供“把订单改成已支付”“把库存加回来”这类直接写字段能力。更安全的操作是“确认渠道支付事实”“创建退款任务”“释放某条预占”“生成库存调账申请”,由领域服务根据当前事实执行并写入审计。这样即使人工判断错误,也能通过反向动作修正,而不是丢失原始证据。

7.7.3 监控和告警

至少应该监控:支付未知状态年龄、库存预占超时、Outbox 积压、消息重复率、消费者失败率、补偿失败率、对账差异金额、账本借贷差额、锁等待、事务回滚率和人工队列最老任务年龄。告警要区分“数量突然增加”和“单个任务超过最大允许年龄”,后者通常更能反映资金和库存风险。

过载时不能无条件扩大重试。Google SRE 对过载处理的经验是,服务要能够降级、拒绝或限制重试,避免客户端和服务端相互放大负载;重试预算、快速失败和不同请求类型的优先级都应成为容量设计的一部分。[14] 对高准确性写链路而言,降级不应表现为“少记一笔账”或“先扣款后补库存”,而应表现为暂时关闭非核心入口、限制热点资源并把未知请求转入查询队列。

7.7.4 故障演练和发布治理

发布前应演练以下场景:

  1. 支付回调重复、乱序、签名错误和金额不符。
  2. 消息积压、重复投递、消费进程在提交后崩溃。
  3. 数据库主从切换、锁等待升高和事务提交超时。
  4. 库存预占成功但订单创建失败,订单已支付但库存确认失败。
  5. 支付渠道不可用、查单接口超时、退款结果未知。
  6. 对账任务中断、补偿任务重复执行、人工操作权限错误。

演练的通过条件不能只看服务是否恢复,还要检查业务事实:有没有重复扣款、库存是否平衡、账本是否平衡、未知状态是否进入正确队列、重复执行是否保持幂等、审计记录是否完整。高准确性系统的回滚也要分成代码回滚、配置回滚和业务冲正;部署旧版本并不等于已经撤销新版本产生的资金或库存事实。

7.7.5 评审时的证据链

一次方案评审至少应沿着一条业务事实追问:从用户命令开始,事实在哪里落地,谁能改变它,事件如何传播,消费者如何去重,超时如何判断,失败如何补偿,最终如何对账。每个环节都要有表、状态、键、指标或操作手册作为证据。

如果只能画出成功时序图,却回答不了“响应丢失后怎么办”“回调和取消同时到达怎么办”“补偿执行两次怎么办”“渠道成功但本地没有记录怎么办”,说明设计还停留在接口编排层,没有进入高准确性系统真正的治理问题。

7.8 方法论总结

7.8.1 十个判断句

  1. 高准确性首先保护的是业务不变量,而不是某个组件的“强一致”标签。
  2. 强一致边界应围绕不可出错的事实划定,而不是覆盖整条用户链路。
  3. 线性一致性、事务隔离和跨服务业务一致性是不同语义,必须分别写清楚。[2][3]
  4. 本地事务能解决的问题,不要用跨系统事务解决。
  5. 可靠事件只能保证事实传播,不能自动保证下游业务正确;消费者仍需幂等。
  6. 预占、确认和释放是一套资源生命周期模型,不能只实现成功路径。
  7. 支付、库存和账务都必须显式建模未知状态。
  8. Saga 解决流程收敛,不等于全局原子提交;补偿本身也是需要治理的业务动作。[7][9]
  9. 账本、状态事件和审计记录要保留解释能力,不能用直接改字段掩盖差异。
  10. 没有对账、补偿、审计和人工接管,就没有完整的高准确性系统设计。

7.8.2 一份可执行的评审清单

维度必须回答的问题
权威事实哪个服务拥有支付、订单、库存和账务事实?缓存和消息是否被误当成事实?
不变量金额、库存、借贷、状态和幂等约束如何通过代码、数据库或对账验证?
事务边界哪些操作在本地事务中原子提交?事务内是否调用慢 RPC 或第三方接口?
状态模型是否有处理中、待查单、补偿中等未知状态?哪些状态迁移被禁止?
幂等语义请求、回调、业务动作、消息消费和人工处理分别使用什么键?
传播机制Outbox 或事务消息失败后如何重试?重复投递会造成什么副作用?
资源模型预占的 TTL、确认、释放、过期扫描和版本保护是否完整?
补偿恢复每个已执行动作的补偿动作是什么?补偿失败、重复和部分成功怎么办?
对账审计比较双方、口径、时间窗口、自动修复条件和人工审批是否明确?
运维治理告警、限流、重试预算、故障演练、回滚和人工接管是否可执行?

7.8.3 一句话表达

高准确性与强一致性系统设计的核心,不是把整条链路做成昂贵的全局原子事务,而是先识别不可错的业务事实,再用本地事务、状态机、预占模型、幂等、可靠事件、账本、补偿和对账,把核心事实保护住,把跨系统流程收敛住。

这套方法也说明了系统如何演进:早期可以用单体和单库保护核心事实;拆分后用 Outbox 和状态机维持边界;流量增长后再对热点资源分片、排队或引入受控事务协调。每一步都应以不变量和故障证据为依据,而不是以组件数量或架构图复杂度证明系统更成熟。

7.9 参考资料

[1] Eric Brewer, “CAP Twelve Years Later: How the Rules Have Changed”, Computer, 2012。

[2] Maurice Herlihy, Jeannette M. Wing, “Linearizability: A Correctness Condition for Concurrent Objects”, ACM Transactions on Programming Languages and Systems, 1990。

[3] Hal Berenson, Phil Bernstein, Jim Gray, Jim Melton, Elizabeth O’Neil, Patrick O’Neil, “A Critique of ANSI SQL Isolation Levels”, SIGMOD Record, 1995。

[4] James C. Corbett et al., “Spanner: Google’s Globally-Distributed Database”, OSDI, 2012。

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

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

[7] Hector Garcia-Molina, Kenneth Salem, “Sagas”, Proceedings of ACM SIGMOD, 1987。

[8] Pat Helland, “Life beyond Distributed Transactions: an Apostate’s Opinion”, CIDR, 2007。

[9] Chris Richardson, “Saga Pattern”, Microservices Patterns。

[10] Chris Richardson, “Transactional Outbox”, Microservices Patterns。

[11] Chris Richardson, “Idempotent Consumer”, Microservices Patterns。

[12] Stripe, “Idempotent requests”, Stripe API Reference。

[13] Martin Fowler, “Event Sourcing”, 2005。

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

[15] OpenTelemetry, “Documentation”, OpenTelemetry Project。

[16] Apache Seata, “Seata 是什么?”, Apache Seata 官方文档,2025。

[17] Apache Seata, “Seata TCC 模式”, Apache Seata 官方文档。

[18] Apache RocketMQ, “事务消息”, Apache RocketMQ 官方文档。

[19] PingCAP, “TiDB 事务隔离级别”, TiDB 文档中心。

[20] Oracle, “InnoDB Transaction Isolation Levels”, MySQL 8.4 Reference Manual。

第 8 章 低延迟复杂读:搜索、推荐、广告与 Feed 的查询设计

当系统面对搜索、推荐、广告投放、Feed 流、详情页这类“用户一打开就要马上看到结果”的场景时,设计重点不再只是数据对不对,而是如何在复杂查询、高 QPS 和持续变化的数据之间,把延迟、吞吐、结果质量和数据新鲜度一起控制住。

上一章讨论了支付、库存和账务等“事实不能错”的系统。本章讨论另一类同样典型的问题:结果不一定要求全链路瞬时强一致,但必须在严格的延迟预算内稳定产出,并且能够解释结果为什么这样生成、什么时候更新、过载时如何降级。

低延迟复杂读系统最容易被误解成“加一个缓存”或“部署一个搜索引擎”。真正的系统设计问题是:写模型、索引、候选集、特征、缓存、排序和页面拼装之间如何协作;哪些计算应该在写入时完成,哪些必须在请求时完成;当某个依赖变慢或数据暂时陈旧时,系统应该牺牲什么来保护用户体验。

8.1 问题定义:复杂读的核心是稳定地产出结果

8.1.1 复杂读不是简单地把数据查出来

简单读通常是按主键查询一个事实,复杂读则包含多个阶段:解析请求、过滤候选、全文检索、召回、排序、特征读取、聚合、权限判断、分页和结果拼装。

典型请求包括:

  • 搜索商品、内容、酒店或文档。
  • 推荐下一条内容、商品或广告。
  • 根据用户、上下文、预算和频控进行广告决策。
  • 生成按时间、关系和排序规则组织的 Feed。
  • 拼装商品详情、价格、库存、营销、评价和推荐信息。

这些请求通常具有五个共同点:查询逻辑复杂,读取远多于写入,数据会持续变化,流量具有尖峰和热点,用户对延迟与结果质量都敏感。它们的“读”并不是对某张表执行一次 SELECT,而是一次面向用户意图的结果生成过程。

从业务角度看,系统最终交付的也不是“数据库返回了多少行”,而是一个可以被用户使用的结果集合。搜索空结果、推荐列表重复、广告候选缺失、Feed 顺序抖动、详情页核心字段过期,都会被用户感知为产品质量问题,而不只是某个基础设施指标变差。

8.1.2 复杂读场景的五个设计轴

设计轴需要回答的问题
延迟用户等待多久仍然认为系统可用?P95 和 P99 是否必须稳定?
质量结果是否相关、完整、排序合理、可解释?
新鲜度新商品、价格、库存、内容和用户行为多久可见?
规模QPS、候选数量、索引规模、特征维度和结果扇出是多少?
成本哪些计算可以离线完成,哪些资源必须留给在线请求?

这五个轴会互相冲突。提高召回数量可能提高结果质量,但会增加排序延迟;缩短缓存 TTL 可以提高新鲜度,但会降低命中率并增加回源压力;增加实时特征可以提高个性化效果,但会拉长依赖链路。

可以把复杂读抽象成下面的优化问题:

在总延迟预算、资源预算和新鲜度窗口内,最大化结果质量与业务价值

这个表达式不是要求团队计算一个精确的数学最优解,而是提醒设计者:任何“更快”的方案都要说明牺牲了什么。例如,减少精排候选可以降低 P99,却可能降低相关性;延长缓存 TTL 可以提高命中率,却可能放大陈旧价格的展示风险;把 Feed 预先写入用户时间线可以降低读取成本,却会增加写放大和关系变化时的维护成本。

8.1.3 本章的范围与非目标

本章聚焦:

  1. 如何从查询模式设计读模型,而不是直接复用写库模型。
  2. 如何建立延迟预算,并把预算分配到检索、特征、排序和拼装阶段。
  3. 如何通过索引、缓存、预计算、物化视图和 Fanout 保护读路径。
  4. 如何在不一致、依赖故障和过载时提供可解释的降级结果。

本章不展开具体搜索分词算法、推荐模型训练细节或某个云厂商的产品配置。本书没有单独的基础设施篇:Redis、Kafka 和 Elasticsearch 的基础能力与配置边界应结合附录 B 的官方资料索引及实际部署版本的手册阅读。推荐模型训练细节可从本章参考资料中的 YouTube 推荐系统论文[6]及相关工程资料[14][17][19]继续阅读;商品搜索与详情的完整业务读路径见第 14 章 14.3 节。

本章重点保留复杂读场景的统一模型、延迟预算和方案取舍。

8.2 约束与指标:把“快”拆成预算,把“好”拆成质量

8.2.1 不要使用一个统一的延迟目标

“P99 必须小于 100 ms”可以作为讨论起点,但不能成为脱离场景的通用标准。搜索建议、广告决策、商品详情和离线管理后台的延迟目标不同;同一个接口在不同请求类型下的资源成本也可能完全不同。

Google 的《The Tail at Scale》说明,在大规模服务中,尾延迟会因为请求扇出、依赖抖动和资源利用率上升而被放大。因此设计时必须关注 P95、P99 和最慢依赖,而不是只看平均响应时间。[1]

延迟目标应该按场景写成契约:搜索建议要求首字响应快,详情页保证基础字段先返回,推荐模块允许独立超时,广告决策必须在极短时间内完成,运营后台则可以接受更长等待。契约还应说明超时后的行为:是返回旧结果、减少候选、跳过增强模块,还是直接拒绝请求。

8.2.2 延迟预算示例

假设商品详情页的目标预算为 180 ms,可以先建立一张可调整的预算表。数字不是通用标准,应以线上测量和容量测试为依据;它的作用是迫使团队明确每一段时间花在哪里。

网关、鉴权和请求解析:10 ms
详情物化视图读取:25 ms
价格与库存动态字段:40 ms
推荐和营销模块:35 ms
结果拼装与序列化:30 ms
网络余量与降级判断:40 ms
总预算:180 ms

更适合评审和容量测试的形式是阶段预算表:

阶段预算处理内容超预算后的动作
网关、鉴权、请求解析10 ms用户、设备、实验和权限信息直接拒绝异常请求
详情物化视图25 ms标题、图片、规格和静态展示字段读上一版本或基础快照
价格与库存动态字段40 ms价格、可售状态、活动资格返回明确的暂不可购买状态
推荐和营销模块35 ms搭配、优惠、猜你喜欢跳过非核心模块
拼装、序列化、网络余量30 ms页面响应和压缩减少字段或返回部分结果
故障判断与保护余量40 ms超时、熔断、排队和抖动进入降级策略
总预算180 ms端到端响应保护核心结果

预算不能简单地被每个服务都复制一份。如果价格服务和库存服务各自要求 100 ms,详情页就不可能稳定满足 180 ms;如果推荐不是核心结果,就不应让它拥有一个“必须完成”的预算。工程上需要区分服务自己的处理预算、调用方等待预算和端到端用户预算。

预算还要包括排队、连接池等待、序列化、跨可用区网络、重试和熔断判断。很多系统在单次调用压测中很快,在混合查询和高并发下却变慢,原因正是这些共享资源没有被计入阶段预算。真正的预算应来自分阶段 trace,而不是来自每个服务负责人对自己接口的主观估计。

8.2.3 指标体系

指标类别示例指标用途
延迟P50、P95、P99、各阶段耗时定位尾延迟和预算超支
容量QPS、并发数、CPU、内存、网络、候选数估算资源和扩容边界
新鲜度索引延迟、缓存年龄、特征更新时间判断结果是否过时
质量召回率、空结果率、点击率、转化率、去重率判断结果是否有用
稳定性降级率、超时率、回源率、错误率判断故障时是否可控
缓存命中率、热点 Key、穿透率、失效峰值治理缓存压力
成本单请求 CPU、模型推理成本、存储成本防止用无限资源换延迟

点击率和转化率只能作为业务信号,不能直接替代技术指标。一个推荐结果点击率下降,可能是排序质量问题,也可能是延迟变高导致用户根本没有看到完整结果;一个搜索空结果率上升,可能是索引构建失败,也可能是查询改写或过滤规则发生变化。指标必须能沿着请求 ID、查询版本、索引版本和策略版本回溯到具体阶段。

质量指标也不能只看一个总平均值。推荐系统需要观察候选覆盖、长尾覆盖、重复率、曝光分布和用户负反馈;搜索系统要观察无结果 Query、错误召回、首条点击和满意度;Feed 要观察新鲜内容比例、重复内容比例和游标连续性。美团公开的搜索排序实践将数据层、召回层、粗排、精排、重排和展示层拆开,并以分层架构平衡排序效果和性能,这种拆分也便于分别定义质量与延迟指标。[14][15]

8.2.4 数据新鲜度要写成业务契约

“近实时”不是指标。应针对不同字段定义窗口:商品标题允许延迟几十秒,价格可能只允许几秒,库存可能必须在下单校验时回源,推荐特征可能允许分钟级延迟,广告预算则需要更严格的扣减语义。

同一个页面也可能同时存在不同新鲜度:商品描述可以读缓存,价格和库存必须读取受控的动态服务,推荐搭配可以使用旧候选集。页面级“全局一致”往往不是必要目标,字段级新鲜度契约才更容易落地。契约至少要包含数据源、允许年龄、版本含义、超时行为、回退值和恢复动作。

可以把读结果的状态划分为:当前版本、可接受旧版本、超过窗口但仍可展示、禁止展示和未知状态。这样,服务不是简单返回一个对象,而是返回对象、来源版本、生成时间、数据年龄和状态。调用方才能决定是继续展示、标注“正在更新”、回源校验,还是直接隐藏该字段。

8.3 核心模型:读模型是派生数据,查询模式决定数据结构

8.3.1 写模型和读模型承担不同职责

写模型主要保护领域事实、写入约束和业务事务;读模型主要服务检索、过滤、排序、聚合和展示。它们可能来自同一份原始数据,但不应被迫使用同一种数据结构。

一个典型的数据流是:

权威写模型
  → 变更事件 / CDC / Outbox
  → 索引、物化视图、候选集、特征库、缓存
  → 在线复杂读服务

读模型是派生数据,因此必须记录来源版本、更新时间、构建状态和失败原因。出现异常时,系统才能知道某条结果是源数据、旧版本派生数据,还是降级数据。数据系统的设计通常要区分系统记录与派生数据:派生数据可以被重建,但必须有明确的重建输入、顺序和校验方式。[2]

8.3.2 从事实到结果的统一数据模型

复杂读可以拆成六类对象:

对象责任是否权威常见存储形态
业务事实商品状态、价格、库存、关注关系、广告预算是交易库、账本、专用事实服务
检索索引关键词、过滤字段、排序字段和向量表示否倒排索引、向量索引
物化视图页面需要的多源展示结构否宽表、文档库、键值缓存
候选集某用户或 Query 可能相关的对象否用户列表、内容池、倒排列表
特征快照排序、频控、个性化所需的输入否在线特征库、本地缓存
结果缓存已经生成的短期结果否CDN、本地缓存、Redis

权威性只属于明确承担业务约束的事实源。索引中的价格可以帮助展示和排序,但它不是结算价格;搜索结果中的库存标签可以帮助用户筛选,但不能替代下单时的库存校验;推荐缓存中的广告候选可以参与竞价,但不能替代预算扣减。把派生数据误当权威数据,是复杂读系统最危险的边界错误。

8.3.3 复杂读的统一管道

搜索、推荐、广告和 Feed 虽然业务不同,但可以用一条统一管道理解:

请求解析
  → 查询改写 / 权限过滤
  → 候选召回或索引检索
  → 规则过滤
  → 特征读取
  → 粗排 / 精排 / 重排
  → 去重、分页、拼装
  → 缓存、降级与结果解释

每个阶段都可能成为瓶颈。只优化搜索引擎而忽略排序,或者只提高缓存命中率而忽略回源风暴,都不能保证端到端延迟。

每个阶段还要说明它可以放弃什么。例如,召回可以只保留高置信度候选,粗排可以使用较低成本的模型,精排可以限制候选数量,重排可以在超时后跳过;但权限、删除、合规和核心事实校验不能因为延迟紧张而被静默跳过。分层的价值不是把一个服务拆成很多服务,而是让质量、延迟和失败语义能够分别治理。

多路召回通常需要先并行得到候选,再做去重、配额、粗排和精排。候选数量应当是可计算的资源预算,而不是越多越好:候选越多,排序特征读取、模型推理和去重成本越高;候选过少,又会造成质量天花板。腾讯公开的推荐系统资料也将召回理解为从海量物品中快速筛选小候选集,再进入排序阶段,并强调不同召回策略应根据业务和成本组合。[17][18]

8.3.4 预计算不是偷懒,而是把成本转移到可控路径

当结果可以提前准备时,应优先考虑:索引文档、物化详情、聚合统计、候选集、商品热度、用户画像和模型特征。

预计算获得的是更短的请求路径,但牺牲了部分实时性和更新链路的简单性。因此必须同时设计:更新触发、失败重试、版本传播、重建索引、旧版本回退和热点强刷。

批处理和流处理的价值不是让所有事情都异步,而是把可重复、可并行、对请求上下文不敏感的工作移出用户请求路径。Google 的 MapReduce 工作说明了大规模数据处理可以通过批量任务、分片和失败重试来完成;在复杂读系统中,这类离线能力常被用来生成索引、候选和统计特征。[3] 预计算不是“把一致性问题消失”,而是把它从用户请求中移动到可观测的传播链路。

8.3.5 缓存的语义比命中率更重要

缓存设计至少应回答:

  • 缓存的是事实、派生结果还是临时计算结果?
  • 允许陈旧多久?
  • 失效由谁触发?TTL 和主动失效冲突时谁优先?
  • 热点失效后如何防止所有请求同时回源?
  • 缓存不可用时是否有更慢但正确的路径?
  • 缓存命中时是否需要校验版本或业务状态?

Redis 官方文档将 cache-aside 定位为以 TTL 约束陈旧窗口、降低主数据库读压力的模式,同时提醒热点失效会造成缓存击穿。[12] Facebook 的 Memcache 工程实践进一步说明,大规模缓存系统会涉及一致性、失效、复制、故障转移和负载均衡;当缓存承担了过多职责时,缓存本身可能从“加速层”变成系统必须依赖的状态层。[4]

因此,缓存命中率只能回答“多少请求没有回源”,不能回答“返回的结果是否可信”。应同时记录数据年龄、命中来源、版本差距、回源原因和回源结果。对于价格、库存、权限、删除标记等高风险字段,命中缓存后仍可能需要短路校验或版本校验;对于商品描述、图片和推荐搭配,可以允许更长的陈旧窗口。

8.3.6 分页、候选和排序也属于数据模型

复杂读的分页不是界面层的小功能。offset 分页需要先跳过前面大量结果,随着页码增加,查询和排序成本可能持续上升;在数据变化时,offset 还可能导致重复或漏项。对稳定排序的搜索结果,可以使用基于排序值和唯一 ID 的游标;Elasticsearch 的 search_after 文档将游标式分页用于深分页场景,并要求调用方维护一致的排序条件和游标语义。[11]

游标必须绑定查询版本、排序版本、过滤条件摘要和过期时间,否则用户翻到下一页时,前后两次请求可能已经使用了不同的排序逻辑。Feed 还应保存时间线快照或游标生成时间,避免热点内容插入导致一条内容在不同页重复出现。推荐结果则要保存去重和曝光过滤所需的窗口,不能只缓存一个没有上下文的 ID 列表。

8.4 参考架构:从写入传播到在线读路径

8.4.1 写入侧:构建派生读模型

写入侧可以采用以下流程:

  1. 权威服务在本地事务内提交业务事实。
  2. 通过 Outbox、CDC 或事件日志发布变更。
  3. 索引构建器更新搜索文档。
  4. 物化视图构建器更新详情和聚合结果。
  5. 特征计算任务更新在线特征或候选集。
  6. 缓存刷新器主动刷新热点数据,长尾数据按需加载。
  7. 记录版本号、更新时间、失败原因和重建进度。

写入传播失败不能直接覆盖权威事实,也不能让读模型无限重试。应使用重试、死信、全量重建和差异校验保证最终收敛。

8.4.2 在线侧:围绕预算执行

在线请求可以按以下顺序处理:

接收请求
  → 识别场景和优先级
  → 命中结果缓存或本地缓存
  → 未命中时执行检索 / 召回
  → 并行读取必要特征
  → 在预算内执行排序
  → 拼装核心结果
  → 超时则跳过非核心依赖并返回降级结果

关键依赖应设置独立超时,禁止使用一个全局超时掩盖内部预算失控。依赖失败时,应该知道哪些字段可空、哪些字段可以使用旧值、哪些结果必须直接失败。

8.4.3 各类系统的主要差异

场景主要读模型主要在线步骤常见降级
搜索倒排索引、过滤字段、排序字段、向量索引查询理解、召回、过滤、排序、聚合缩小检索范围、减少聚合;交易前重新校验事实
推荐候选集、用户特征、内容特征、曝光窗口多路召回、粗排、精排、重排屏蔽下架内容,使用热门或历史候选
广告广告候选、预算、定向和频控特征过滤、竞价、排序、去重校验预算与频控,减少候选并返回保底广告
Feed用户时间线、关注关系、热点内容拉取、合并、去重、游标分页校验权限与删除,使用缓存时间线并减少扩散范围
详情页物化视图、局部缓存、动态字段拼装、权限和实时字段校验校验价格、库存和可售状态,跳过非核心模块

不同系统的共同架构不能抹平业务边界。推荐可以接受旧候选,但不能展示已下架内容;广告可以使用预计算候选,但不能无视预算和频控;详情页可以展示旧描述,但不能把超过允许窗口的库存当成可购买事实。读路径必须在“速度”和“业务安全”之间明确红线。

8.5 方案选型:用 ADR 记录延迟、质量和新鲜度的取舍

8.5.1 典型方案的获得与牺牲

方案获得的能力牺牲或新增风险
直接查询交易库事实新鲜、开发简单复杂查询争抢写资源,延迟和扩展性差
搜索索引复杂过滤、全文检索、横向扩展索引有延迟,需要重建和一致性治理
多级缓存低延迟、高 QPS、抗热点失效、陈旧、击穿和回源风暴更复杂
CQRS / 物化视图读模型可以按查询优化写入传播、版本管理和重建成本增加
Fanout-on-write读路径短,适合高频读取写放大,关系变化和超级节点难治理
Fanout-on-read写入简单,热点发布成本低读取计算复杂,延迟和合并成本高
向量召回语义相似和冷启动召回能力解释、过滤、精确约束和结果稳定性较弱

方案比较不能只写“推荐使用某组件”,而要写清楚当前约束下的优先级。直接查交易库得到的是事实新鲜和开发简单,但复杂排序、聚合和热点会争抢写资源;索引得到的是查询性能和横向扩展,但必须接受传播延迟;物化视图得到的是短读路径,但要付出重建、版本和差异校验成本。CQRS 的核心也不是增加一个读库,而是承认同一业务数据的写入组织方式和读取组织方式可能不同,并为两侧建立明确的同步边界。[7]

ADR 中的“获得 / 牺牲”应使用同一组维度比较,例如一致性、新鲜度、延迟、吞吐、质量、成本、复杂度、可运维性和恢复能力。若方案不能保证某项能力,应直接写出,而不是用“最终一致”把具体窗口隐藏起来。每个决策还应给出验证指标和重新评估条件,让 ADR 成为可被后续数据推翻的工程假设,而不是上线前的形式文档。

8.5.2 示例 ADR:商品发现采用索引 + 物化详情 + 动态事实校验

背景:商品搜索需要关键词、过滤和排序;详情页需要拼装商品、价格、库存、营销和推荐;商品信息更新频率低于读请求,但价格和库存变化更快。

决策驱动因素:搜索和详情不能持续回源交易库;价格和库存不能完全依赖陈旧缓存;读链路需要在高峰时降级,且搜索结果必须能说明索引延迟。

候选方案:所有查询直接查交易库、全量 Redis 缓存、搜索索引承担全部字段、CQRS + 物化视图、搜索索引与动态事实服务组合。

最终决策:商品主数据构建搜索索引;详情页使用物化视图减少多源拼装;价格和库存作为动态字段由受控服务校验;推荐和营销属于可降级模块;索引和物化视图通过事件传播并记录版本。

获得的能力:

  • 复杂查询不再挤压交易库。
  • 搜索、详情和推荐可以按查询模式独立扩展。
  • 页面核心字段与非核心字段可以分级处理。
  • 价格和库存不会因为页面缓存而完全失去事实约束。
  • 发生索引延迟时可以通过版本和更新时间解释。

主动牺牲的能力:

  • 商品修改不会在所有读模型中瞬时可见。
  • 需要维护事件传播、索引重建、物化视图和缓存失效。
  • 页面数据来自多个版本,短时间内可能出现字段之间的新鲜度差异。
  • 系统不能只靠数据库事务保证完整读结果一致。

已接受风险与补救:索引延迟可能导致搜索结果短暂缺少新商品,通过索引延迟监控、热点强刷和全量重建补救;价格或库存服务超时时,页面只能展示基础信息,并标注暂不可购买;缓存失效可能造成回源压力,通过单飞、随机 TTL、本地兜底和按优先级限流保护数据库。

验证指标:索引 P99 延迟、商品详情缓存命中率、价格和库存动态字段超时率、页面整体 P99、非核心模块降级率、过期数据比例和搜索空结果率。

重新评估条件:索引重建窗口超过业务新鲜度要求、动态字段请求成为页面主要瓶颈、缓存回源成本超过独立读模型成本,或业务需要读己写和强新鲜度保证。

8.5.3 Fanout 的 ADR 判断

Feed 不能只问“写扩散还是读扩散”,而应按用户关系规模、内容热点、读写比例、允许延迟、存储放大和删除语义综合判断。

  • 普通用户关注关系稳定、读取频繁时,写扩散可以换取更短的读路径。
  • 超级节点粉丝数巨大时,写扩散会造成不可接受的写放大,应转为读扩散或混合策略。
  • 混合策略获得了更好的总体平衡,但牺牲了实现简单性,需要维护两套路径和去重规则。
  • 内容删除、权限变化和用户拉黑必须能够影响已经生成的时间线,不能因为写扩散完成就认为结果永久有效。

Facebook 的 TAO 论文展示了面向固定访问模式的读优化图存储:它在持久化存储之上提供图关系访问和缓存,以低延迟服务大规模社交图;但它也明确选择了适合业务的有限一致性,而不是把所有关系读取都变成强一致事务。[5] 这个例子说明,读优化存储的价值来自清晰的访问模式和接受边界,而不是来自某个产品名称。

8.6 完整案例:商品搜索与详情页的读路径设计

8.6.1 写入到读模型

假设一个电商平台有商品主数据、价格、库存、营销、评价和推荐搭配六类信息。用户输入关键词进入搜索列表,再点击商品进入详情页。系统要满足以下假设性约束:搜索列表优先保证相关性和响应速度;静态商品信息允许几十秒内收敛;价格和库存必须在下单前重新校验;推荐和营销可以在高峰时跳过;新发布商品需要在可接受窗口内进入搜索。

商品的读状态不应只有“存在 / 不存在”,而应至少包含源版本、索引版本、详情视图版本、价格版本、库存版本和生成时间。商品主数据事件携带 product_id、source_version、event_type、occurred_at;每个派生消费者维护自己的 applied_version。当事件乱序到达时,旧版本只能被记录为过期事件,不能覆盖已经应用的新版本。

商品服务提交商品、类目、属性和展示状态后,在同一业务事务中写入变更记录。事件发布器将变更发送到索引、详情物化、推荐特征和缓存刷新任务。流程可以表示为:

商品权威库
  → 商品版本 V42
  → 变更事件 product.updated(V42)
  ├─→ 搜索索引:文档版本 V42
  ├─→ 详情物化:视图版本 V42
  ├─→ 推荐特征:候选 / 属性版本 V42
  └─→ 热点缓存:按需刷新或标记失效

每个派生结果都携带商品版本号。索引服务更新失败时,商品仍然保留在权威库;详情物化失败时,系统可以返回上一个完整视图并记录版本差距;价格或库存变化时,可以只更新动态字段,避免重建完整详情。对高热度商品,事件系统可以提升优先级,但不能无限制地让热点重建任务挤压全量恢复任务。

事件传播的可观测性至少包括:源版本到索引版本的差值、事件进入队列时间、消费者处理时间、重试次数、死信数量、单商品重建耗时和最近一次全量校验结果。版本水位比单纯的“消息消费成功率”更能说明用户是否已经看到更新。

8.6.2 搜索请求

搜索请求首先解析关键词、过滤条件、排序方式、地域和分页游标。查询理解阶段可以做同义词、实体和意图处理,但必须记录改写后的 Query,避免出现“用户搜什么”和“系统实际查什么”无法解释的问题。索引负责召回和过滤,排序字段尽量提前写入文档;聚合和高成本统计必须有独立预算。

多路召回可以包括文本匹配、类目过滤、地理距离、热门商品、个性化候选和向量召回。各路召回先并行得到候选,再经过去重、配额、粗排和精排。美团搜索公开文章将召回、粗排、多路融合、精排和重排拆成不同层次,并指出多业务场景需要在效果与性能之间做配额和截断,这正是复杂读系统需要保留的工程边界。[14]

深分页不能无限依赖 offset,因为跳过大量结果会放大查询成本。更稳定的方式是使用游标或基于排序键的连续读取,并把游标绑定到查询版本、排序规则和过期时间。

搜索结果不是商品事实本身。价格、库存和活动资格如果属于强业务约束,展示层可以使用索引中的近似值,但下单前必须回到权威服务重新校验。搜索结果还要记录结果生成时间和索引版本;当用户投诉“新商品搜不到”时,系统应能回答是商品尚未发布、事件未投递、索引未刷新,还是查询过滤规则排除了它。

8.6.3 详情页请求

详情页可以把字段分成三组:

  • 核心静态字段:商品标题、图片、规格和描述,可进入物化视图和长 TTL 缓存。
  • 动态交易字段:价格、库存、优惠资格,需要更短的新鲜度窗口或实时校验。
  • 非核心增强字段:推荐搭配、评价摘要、猜你喜欢,可以独立超时和降级。

请求到达后先读详情物化视图,再并行读取动态价格和库存;推荐和营销模块拥有独立的等待预算。若动态服务在预算内返回,页面附带当前版本;若超时,页面可以返回静态字段,但必须将商品状态标为“价格或库存正在更新”,不能把旧值渲染成仍然可下单的事实。

缓存策略也应按字段拆开。整页缓存命中快,但容易把价格、库存和推荐全部绑定在同一个 TTL 上;局部缓存更容易表达不同新鲜度,却增加拼装复杂度。商品描述和图片可以使用物化详情缓存,价格和库存使用短 TTL 快照或受控服务,用户个性化推荐则使用候选集缓存加实时过滤。

8.6.4 推荐与广告的映射

推荐系统通常采用召回、粗排、精排和重排分层。YouTube 推荐论文公开描述了候选生成模型与排序模型分离的两阶段结构,说明“把所有候选都交给一个大模型一次性排序”通常不是可扩展的在线方案。[6] 推荐候选可以来自用户历史、相似商品、内容标签、热门池和实时行为;候选层追求覆盖与速度,排序层才承担更昂贵的个性化计算。

推荐结果进入详情页时,还要执行商品下架、地域限制、已购买、曝光频控和库存状态过滤。推荐特征可以准实时,商品可售状态却不能因为旧候选而失效。推荐服务超时可以返回热门搭配或空模块,但不能让它占用价格和库存的核心预算。

广告请求还要加入预算、定向、频控、出价和合规过滤。它不是简单的推荐排序:候选相关性、商业约束和预算事实必须分层处理。推荐候选可以预计算,广告预算扣减则需要更严格的事实控制;如果预算服务超时,宁可减少广告候选或返回保底广告,也不应无界重试。

8.6.5 Feed 和热点内容的变体

Feed 场景的核心不是单条内容查询,而是把用户关系、时间顺序、内容质量和热点扩散合并成一条可分页的时间线。普通用户可以使用 Fanout-on-write,把新内容提前写入关注者的时间线;超级节点或热点内容则更适合 Fanout-on-read,在读取时合并。

混合方案需要额外处理三个问题:

  • 同一内容可能同时来自用户时间线、热点池和推荐候选,必须按内容 ID 去重。
  • 内容写入和删除传播存在延迟,必须在读取时执行最小权限和状态过滤。
  • 时间线不能只依赖 offset 分页,否则新内容插入会导致重复或漏读;应使用带版本或时间边界的游标。

因此,Feed 的 ADR 往往不是“选择写扩散”或“选择读扩散”,而是按用户类型、内容热度和资源成本选择不同路径。获得的是总体吞吐和延迟的平衡,牺牲的是两套数据路径、去重逻辑和一致性治理的复杂度。内容删除、权限变化和用户拉黑必须能够影响已经生成的时间线,不能因为写扩散完成就认为结果永久有效。速度来自预组织数据,但业务有效性仍然需要在线检查。

8.7 故障与治理:允许结果降级,不允许系统失控

8.7.1 缓存故障:击穿、穿透和雪崩要分开处理

缓存故障至少有三种形态:

  • 击穿:单个热点 Key 过期或失效,瞬间大量请求回源。
  • 穿透:请求的对象不存在或参数非法,反复绕过缓存访问后端。
  • 雪崩:大量 Key 同时失效,或缓存集群整体不可用,造成回源洪峰。

三者的保护手段不同。击穿需要 Singleflight、互斥重建、逻辑过期或热点预热;穿透需要参数校验、空值短缓存、布隆过滤器和访问限流;雪崩需要 TTL 打散、多级缓存、容量预留、故障切换和按优先级降级。中文工程资料对这三类问题的区分和治理有较多实践总结,但具体阈值必须根据业务流量和数据分布测量,不能把示例配置当成通用标准。[20]

缓存故障时,降级路径必须在正常设计中就存在。可以读取上一次成功的静态结果、切换到本地快照、只返回核心字段或限制长尾查询;不能在缓存故障后临时发明一套更复杂的“备用实时计算”,否则所谓 fallback 可能比原路径更容易失败。AWS 的优雅降级建议将部分硬依赖转为软依赖,在下游延迟或错误时返回最后一次成功值或简单静态结果,同时避免用复杂的替代机制扩大故障面。[10]

8.7.2 索引和物化视图延迟

索引延迟不是一个单一指标,应区分事件未产生、事件未投递、消费者积压、文档更新失败、刷新未完成和查询侧版本过旧。每一层都要记录水位:源版本、队列版本、构建版本、已刷新版本和查询返回版本。

出现延迟时,先保护业务,再修复派生数据。热门商品可以执行定向强刷,长尾商品等待队列自然收敛;索引构建失败进入重试或死信,持续失败进入全量重建;详情页可以使用上一完整版本,但在超过陈旧窗口后改为基础字段或不可购买状态。不要在所有请求上同步等待索引追平,否则一处传播延迟会变成全站读延迟。

全量重建也需要限速和校验。重建任务应使用独立资源,不能与在线查询抢占全部 CPU 和磁盘;新索引应先在旁路完成校验,再通过别名或版本指针切换;切换后保留旧索引一段时间,以便在字段映射或数据清洗错误时回滚。重建完成的标准不是任务结束,而是版本差距、文档数量、关键字段覆盖率和抽样比对都通过。

8.7.3 过载、重试和部分依赖失败

过载时最危险的行为通常是无条件重试。一次页面请求同时调用多个依赖,若每个依赖超时后再重试,实际请求量会迅速超过入口 QPS。Google SRE 的过载治理强调,系统应结合 CPU、队列、连接池和下游健康度进行负载削减,而不是只看请求数;级联失败往往是健康实例减少、排队增长和重试放大的结果。[8][9]

读链路应建立分级降级:

  1. 关闭高成本特征和非核心排序;
  2. 减少召回深度、聚合数量和 Feed 扩散范围;
  3. 优先返回缓存、上一版本或热门兜底结果;
  4. 对长尾 Query、深分页和低优先级调用限流;
  5. 只有在核心事实无法可靠返回时,才返回明确错误。

降级需要有状态、原因和恢复条件。不能只在日志中打印 fallback=true,还要知道是缓存故障、排序超时、索引滞后、过载保护还是策略主动降级。恢复时先小流量探测,再逐步放开候选数、特征和非核心依赖,避免流量恢复本身再次冲垮系统。

8.7.4 可观测性、质量回放与容量治理

复杂读系统应把一次请求拆成可关联的 trace:入口、查询改写、召回、候选过滤、特征读取、排序、拼装、缓存和降级。每阶段记录候选数量、耗时、版本、命中情况和错误原因;不要只记录最终总耗时,因为总耗时无法回答“是索引慢还是模型慢”。OpenTelemetry 的 trace、metric 和 log 关联能力可作为统一观测入口。[13]

质量治理需要保存 Query、召回源、过滤原因、排序版本、曝光列表和用户反馈,但要遵守隐私和数据保留边界。对搜索可以做 bad case 回放,对推荐可以做离线候选覆盖与线上 A/B 对比,对 Feed 可以检查重复、顺序和删除生效。美团公开的搜索质量实践强调把结果质量分层诊断,区分供给存在但链路未召回、召回后被过滤、排序失配和展示问题,这种诊断思路适合迁移到复杂读平台。[16][19]

容量治理不能只做峰值 QPS 压测,还要做混合请求、热点倾斜、缓存冷启动、索引重建、依赖超时、节点丢失和降级恢复测试。测试结果应记录资源曲线和拐点:在什么利用率下 P99 开始非线性上升,队列多长时需要拒绝,候选数量降低多少会影响质量,缓存恢复需要多长时间。没有这些边界,SLO 只是一个愿望数字。

8.7.5 读己写、版本和陈旧数据解释

复杂读系统不能只用“缓存是否命中”解释结果,还要能解释结果对应哪个版本。用户刚修改商品、刚发布内容或刚完成关注操作后,常常会期待读己写;如果系统无法做到全局即时可见,就应通过版本标记、请求会话版本或局部回源保证关键页面的可见性。

一种常见做法是让写请求返回变更版本,后续读请求携带该版本。读模型若尚未追上版本,可以短暂回源、等待有限时间、读取同一主分片,或者返回明确的处理中状态;不能无限阻塞,也不能静默承诺已经生效。版本等待必须受预算约束,否则“读己写”会变成一条不受控的同步链路。

对于允许陈旧的数据,应记录数据年龄和来源版本。页面可以展示旧的推荐候选,但价格、库存、权限和内容删除状态不能被普通缓存掩盖。这样“不一致”就从一个无法解释的异常,变成具有时间窗口、版本号和处理策略的业务状态。

8.8 方法论总结

8.8.1 十个判断句

  1. 低延迟不是某个缓存或搜索引擎的能力,而是端到端预算、并行边界和降级策略共同产生的结果。
  2. P99 比平均延迟更能暴露复杂读系统的真实体验;请求扇出越大,越需要治理最慢依赖和共享资源排队。
  3. 查询模式决定读模型,写模型不应该被强行复用到所有读取场景;但读模型必须保留来源、版本和重建能力。
  4. 能预计算的不要在线计算,能缓存的不要重复计算,但任何预计算和缓存都必须定义陈旧、失效、回源和恢复语义。
  5. 搜索、推荐、广告和 Feed 都应拆成召回、过滤、排序、拼装和降级阶段;拆分是为了预算和治理,不是为了增加服务数量。
  6. 候选数量、特征维度和排序层级都是资源预算,结果质量提升必须与额外 CPU、网络、存储和延迟成本一起评估。
  7. 一致性要按字段和业务动作定义窗口,而不是对整个页面做笼统承诺;价格、库存、权限和删除状态应有更严格的在线校验。
  8. 读系统的降级目标是保护核心结果和整体可用性,不是无条件保留所有增强功能;降级还必须带原因、等级和恢复条件。
  9. 方案选型必须通过 ADR 说明获得了什么、牺牲了什么、失败后如何收敛,以及什么条件下重新评估。
  10. 一次设计评审至少要追问:权威事实是什么、读模型如何生成、延迟预算在哪里、陈旧多久可接受、热点怎样保护、失败如何解释、结果如何重建。

8.8.2 读链路评审清单

评审问题合格答案应包含
结果的权威事实是什么?明确数据源、写入约束、读取红线和不可由缓存替代的校验
读模型如何生成?事件来源、版本、水位、幂等、重放、全量重建和切换方式
延迟预算如何分配?端到端目标、阶段预算、P95/P99、超时、取消和并发上限
新鲜度如何承诺?字段窗口、数据年龄、过期行为、读己写和用户提示
热点和故障如何处理?本地 / 分布式缓存、单飞、TTL 打散、限流、熔断和兜底
质量如何验证?召回覆盖、空结果、重复、排序、A/B、回放和业务结果
何时重新评估方案?阈值、成本拐点、质量下降、恢复时间和业务边界变化

8.8.3 一句话表达

低延迟与复杂读系统设计的核心,不是让所有请求实时回源并现场完成全部计算,而是围绕查询模式构建派生读模型,用索引、缓存、预计算、分层排序、延迟预算和可控降级,把复杂结果稳定地压进用户可接受的时间窗口。

8.9 参考资料

[1] Jeffrey Dean, Luiz André Barroso, “The Tail at Scale”, Communications of the ACM, 2013。

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

[3] Jeffrey Dean, Sanjay Ghemawat, “MapReduce: Simplified Data Processing on Large Clusters”, OSDI, 2004。

[4] Rajesh Nishtala et al., “Scaling Memcache at Facebook”, 10th USENIX Symposium on Networked Systems Design and Implementation, 2013。

[5] Nathan Bronson et al., “TAO: Facebook’s Distributed Data Store for the Social Graph”, 2013 USENIX Annual Technical Conference, 2013。

[6] Paul Covington, Jay Adams, Emre Sargin, “Deep Neural Networks for YouTube Recommendations”, ACM Conference on Recommender Systems, 2016。

[7] Martin Fowler, “CQRS”, Martin Fowler’s Bliki。

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

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

[10] AWS, “REL05-BP01 Implement graceful degradation to transform applicable hard dependencies into soft dependencies”, AWS Well-Architected Framework。

[11] Elastic, “Paginate search results”, Elasticsearch Reference。

[12] Redis, “Redis cache-aside”, Redis Documentation。

[13] OpenTelemetry, “Documentation”, OpenTelemetry Project。

[14] 美团技术团队,《多业务建模在美团搜索排序中的实践》,2021。

[15] 美团技术团队,《深入浅出排序学习:写给程序员的算法系统开发实践》,2018。

[16] 美团技术团队,《美团点评旅游搜索召回策略的演进》,2017。

[17] 腾讯云开发者社区,《业内推荐系统架构介绍》,2019。

[18] 腾讯云开发者社区,《推荐系统的召回》,2018。

[19] 美团技术团队,《美团综合业务推荐系统的质量模型及实践》,2022。

[20] 阿里云开发者社区,《深入解析 Redis 缓存击穿穿透雪崩的解决方案》,2024。

第 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 的数据结构、缓存和集群边界,以及 Kafka 的分区、消费和积压治理,可从附录 B 的官方资料索引按实际版本深入阅读;库存权威事实则可参见库存系统实战。本章关注这些基础设施如何组合成抗洪峰的写入方法论,而不是重新介绍某一个中间件的全部 API。把消息、事务和状态迁移视为可以组合的模式,而不是把某个产品名称当成方案本身,符合企业集成模式所强调的“上下文决定模式”。[5]

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

9.2.1 入口速率和服务速率

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

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

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

假设入口从开始就把准入速率限制在 20,000 req/s,消费者持续处理 5,000 条/秒,队列初始为空,则积压净增长率为 20,000 - 5,000 = 15,000 条/秒。容量为 600,000 条时,队列在 600,000 / 15,000 = 40 秒后达到上限。若外部峰值 100,000 req/s 持续五分钟,入口最多只能准入 20,000 req/s;若仍无视队列上限持续准入,理论净积压会达到 15,000 × 300 = 4,500,000 条,但实际队列会在第 40 秒先满,系统必须按预先定义的策略拒绝、限速或施加背压。

若运行 30 秒后停止接收新请求,此时积压为 15,000 × 30 = 450,000 条;消费者以 5,000 条/秒继续处理且没有新流量时,清空需要 450,000 / 5,000 = 90 秒。如果恢复阶段仍有流量,则清空时间为 当前积压 / (恢复处理速率 - 恢复阶段到达速率),并且只有恢复处理速率大于到达速率时队列才会下降。排队设计必须同时给出最大积压、最大等待时间、满队列策略和恢复速率。

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

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

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

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。

第 10 章 电商系统全景图

本章定位:在读完第一部分的方法论之后,进入第二部分「电商系统设计实战」之前,用一张可落地的全景图把业务能力、应用系统、数据资产与技术基础设施对齐到同一坐标系。后续第 11 章至第 15 章将沿本章划定的边界逐域深入;第 4 章已经从方法论层面讨论了跨系统集成与一致性模式。

阅读建议:若你已熟悉 DDD 战略术语,可快速浏览 10.2 后进入 10.1 的图示;若你更习惯从接口与数据表入手,建议从 10.1.3 数据架构读起,再回到 10.1.1 校正业务语义。无论哪种路径,都请至少完成 10.4 的三条时序走读,因为后续各章的「集成小节」默认你已经知道主链路的参与者与先后次序。

本章产出物(可用于团队对齐):

  1. 一页 限界上下文图(10.1.1)贴在内网架构 wiki。
  2. 一张 服务依赖图(10.1.2)导入架构治理工具(如 Backstage / 内部 CMDB)。
  3. 一份 主数据清单(10.1.3 表格)作为数据 Owner 会议的输入。
  4. 一套 集成模式评审话术(10.3)写进 RFC 模板。
  5. 一张 十二系统职责表(10.5.1)作为新人 Onboarding 必读。

10.1 系统全景架构

中大型电商平台的架构讨论,如果只停留在「微服务拆分清单」,很容易失去业务语义;如果只停留在「业务功能列表」,又难以指导工程依赖与数据落点。实践中常用 EA(企业架构)+ 4A 的多视角方法并行:

视角英文缩写回答的问题本章对应小节
业务架构BA(Business Architecture)平台提供哪些业务能力?投资优先级如何?10.1.1
应用架构AA(Application Architecture)系统如何划分?依赖方向与编排关系?10.1.2
数据架构DA(Data Architecture)主数据、索引、缓存、事件各自承担什么角色?10.1.3
技术架构TA(Technology Architecture)运行时、中间件、可观测性与安全如何承载上述系统?10.1.4

下面四节分别给出图示与解读要点。图示刻意与后续各章的术语保持一致,便于你在阅读第 11 章至第 15 章时「对照地图」。

本章的阅读方式:不要把全景图当成服务清单,而要把它当成后续实战章节的索引地图。商品、库存、营销、计价、搜索、购物车、订单、支付、供给治理和供应商同步都会在后文展开;本章先把它们放到同一张业务地图上,帮助读者理解每个系统的职责、上游、下游和失败边界。

下面这张图先给出 Part 2 的功能全景索引图。它不试图一次讲完所有实现细节,而是回答四个更基础的问题:平台有哪些能力域、这些能力域如何分工、主链路如何推进、治理闭环如何反哺供给。读完这张图,再进入后面的业务架构、应用架构、数据架构和技术架构,会更容易建立“先有地图,再看局部”的阅读节奏。

flowchart LR
    A["供应商 / 商家<br/>入驻与资质<br/>基础商品 / 资源<br/>库存或券码供给<br/>价格与履约能力"]

    subgraph P["数字电商平台 / B2B2C 平台"]
        direction LR

        B["供给平台<br/>接入与同步<br/>Draft / Staging / QC<br/>批量导入与任务编排<br/>库存运营与供应商治理"]

        C["商品中心<br/>Resource / SPU / SKU<br/>Offer / Rule / Snapshot<br/>正式商品主数据<br/>交易前契约"]

        D["运营平台<br/>活动与促销<br/>CMS / 会场 / 分类<br/>搜索与流量运营<br/>上下架与曝光编排"]

        E["用户前台<br/>搜索导购<br/>详情与列表<br/>购物车与结算<br/>下单入口"]

        F["订单 / 履约 / 售后<br/>订单创建与支付<br/>履约编排与发货<br/>退款售后与补偿<br/>清结算与对账"]
    end

    U["消费者 / 用户"]

    A --> B
    B --> C
    C --> D
    D --> E
    E --> F
    U --> E

    G["质量巡检<br/>商品质量分 / 风险识别"]
    H["生命周期治理<br/>上下架 / 冻结 / 下线 / 回滚"]
    I["供应商同步治理<br/>Raw Snapshot / Checkpoint / 质量监控"]
    J["Outbox / DLQ / 补偿<br/>异常闭环 / 人工修复"]

    G --> B
    H --> B
    I --> B
    J --> B
    F -.异常、质量与售后信号回流.-> G

    K["主链路:把货组织成资产 → 把资产组织成销售 → 把销售沉淀成交易结果"]
    B -.-> K
    C -.-> K
    D -.-> K
    E -.-> K
    F -.-> K

术语对照(避免口语歧义):

口语本书用语说明
价格中心 / 促销算价计价系统 + 营销系统「谁制定规则、谁做试算快照」应分开讨论。
交易中心订单 + 结算 + 购物车交易是链路,不是单服务。
上架后台商品上架 + 运营平台流程编排与批量工具职责不同。
搜索推荐搜索与导购(第 14 章)推荐可作为子模块,但集成模式与搜索高度相似。

从单体到分布式的认知迁移:在单体时代,模块边界靠包名与 Code Review 维持;在分布式时代,网络边界会放大设计缺陷——原本一次函数调用的地方,变成了超时、重试与部分失败。因此全景章的价值,不在于「数有多少个微服务」,而在于为每个跨边界调用预先分配 一致性语义、超时预算与观测标签。当你在第 15 章阅读订单状态机时,应能指出:某次迁移对应 10.4.2 中的哪一步、失败时由谁补偿。

10.1.1 业务架构(DDD 视角)

从 DDD 战略设计看,电商平台的业务能力应被组织为一组限界上下文(Bounded Context):每个上下文内部有独立的通用语言与生命周期;上下文之间通过显式关系(客户方 / 供应方、防腐层、发布语言等)协作。[1] 下图用「限界上下文 + 域分类」表达业务架构,颜色区分核心域、支撑域、通用域;此处沿用全书系统设计方法论中的分类,并侧重系统级映射。

flowchart TB
  subgraph Core["核心域(Core Domain)"]
    BC_Order["限界上下文:订单<br/>契约:订单号、状态机、履约编排"]
    BC_Pay["限界上下文:支付<br/>契约:支付单、渠道、清结算"]
    BC_Checkout["限界上下文:结算<br/>契约:试算、预占、拆单预览"]
    BC_Cart["限界上下文:购物车<br/>契约:行项目、会话合并"]
  end

  subgraph Support["支撑域(Supporting Domain)"]
    BC_Product["限界上下文:商品中心"]
    BC_Inv["限界上下文:库存"]
    BC_Price["限界上下文:计价"]
    BC_Mkt["限界上下文:营销"]
    BC_List["限界上下文:商品上架"]
    BC_Ops["限界上下文:B 端运营"]
    BC_Life["限界上下文:生命周期与供给治理"]
  end

  subgraph Generic["通用域(Generic Domain)"]
    BC_Search["限界上下文:搜索与导购"]
    BC_User["限界上下文:用户与会员(外采/标准能力)"]
    BC_Msg["限界上下文:消息通知(外采/标准能力)"]
  end

  BC_Cart --> BC_Checkout
  BC_Checkout --> BC_Order
  BC_Order --> BC_Pay

  BC_Checkout -.->|试算/ Hydrate| BC_Price
  BC_Checkout -.->|预占| BC_Inv
  BC_Checkout -.->|券与活动校验| BC_Mkt

  BC_Order -.->|快照与金额确认| BC_Price
  BC_Order -.->|库存确认/回退| BC_Inv
  BC_Order -.->|营销锁定与扣减| BC_Mkt
  BC_Order -.->|商品快照| BC_Product

  BC_List --> BC_Product
  BC_List --> BC_Inv
  BC_List --> BC_Price
  BC_Ops --> BC_Product
  BC_Ops --> BC_Inv
  BC_Ops --> BC_Mkt
  BC_Ops --> BC_Price
  BC_Life --> BC_Product
  BC_Life --> BC_List

  BC_Search -.->|发现与列表| BC_Product
  BC_User -.->|身份与权益| BC_Order
  BC_Msg -.->|异步触达| BC_Order

读图要点:

  1. 核心域集中了「钱与承诺」相关的上下文:购物车暂存购买意图,结算把意图推进为可支付的约束集合(价格、库存、优惠),订单把承诺持久化为合同,支付完成资金侧的闭环。
  2. 支撑域提供可售性、可算价、可营销、可供给四类「规则与主数据」能力;它们高度影响交易,但行业模式相对可参照。
  3. 通用域中的搜索本书会在第 14 章深入,因其在工程上与商品、计价、库存的 Hydrate 编排强耦合;用户与消息等更常采购标准方案,在全景中保留接口位即可。

上下文映射(与第 1 章 DDD 部分衔接):上图中实线箭头多表示「客户方依赖供应方」的下游调用关系;虚线表示「通过发布语言(Published Language)或 ACL 防腐」的弱耦合。[1][2] 落地时建议显式标出:

  • 供应方(Upstream):商品中心对「商品快照 ID」、计价系统对「价格快照版本」、库存对「预占凭证」拥有定义权。
  • 客户方(Downstream):订单与结算消费上述契约,但不应要求供应方暴露内部表结构。
  • 防腐层(ACL):对接供应商、旧单体或外采营销引擎时,把外部模型挡在边界之外,避免污染核心域通用语言。

业务架构图应解释角色、动作、权威事实与协作边界,架构表达和评审方法见第 1 章。

10.1.2 应用架构(微服务视角)

应用架构关注可部署单元之间的依赖。原则是:依赖方向自上而下、由稳定侧指向易变侧,避免出现「基础数据服务回调订单服务」这类环。[3][4] 下图在典型分层架构上,补全 C 端读路径(搜索、购物车)与结算编排,并标注后续章节编号,便于索引。

flowchart TB
  subgraph L0["接入与 BFF"]
    GW[API Gateway / BFF]
  end

  subgraph L_read["读路径与暂存"]
    SearchSvc["搜索与导购服务<br/>第 14 章"]
    CartSvc["购物车服务<br/>第 14 章"]
  end

  subgraph L_data["主数据与规则基座"]
    ProductSvc["商品中心<br/>第 11 章"]
    InvSvc["库存系统<br/>第 12 章"]
    MktSvc["营销系统<br/>第 13 章"]
    PriceSvc["计价系统<br/>第 13 章"]
  end

  subgraph L_trade["交易编排与资金"]
    CheckoutSvc["结算编排服务<br/>第 14 章"]
    OrderSvc["订单系统<br/>第 15 章"]
    PaySvc["支付系统<br/>第 15 章"]
  end

  subgraph L_supply["供给与运营"]
    ListingSvc["商品上架<br/>第 11 章"]
    OpsSvc["供给与运营管理<br/>第 11 章"]
    LifeSvc["生命周期协调<br/>第 11 章"]
  end

  GW --> SearchSvc
  GW --> CartSvc
  GW --> CheckoutSvc
  GW --> OrderSvc
  GW --> PaySvc
  GW --> ListingSvc
  GW --> OpsSvc

  SearchSvc --> ProductSvc
  SearchSvc --> InvSvc
  SearchSvc --> PriceSvc
  CartSvc --> ProductSvc

  CheckoutSvc --> PriceSvc
  CheckoutSvc --> InvSvc
  CheckoutSvc --> MktSvc
  CheckoutSvc --> OrderSvc

  OrderSvc --> ProductSvc
  OrderSvc --> InvSvc
  OrderSvc --> MktSvc
  OrderSvc --> PriceSvc
  OrderSvc --> PaySvc

  ListingSvc --> ProductSvc
  ListingSvc --> InvSvc
  ListingSvc --> PriceSvc
  OpsSvc --> ProductSvc
  OpsSvc --> InvSvc
  OpsSvc --> MktSvc
  OpsSvc --> PriceSvc
  LifeSvc --> ProductSvc
  LifeSvc --> ListingSvc

依赖解读:

  • 商品中心是多数读路径与订单快照的事实来源(System of Record for catalog);库存、计价、营销在各自上下文内维护规则,但在创单链路上被订单编排调用。
  • 结算服务常实现为独立部署的「长事务 / Saga 编排器」,在应用层与购物车解耦:购物车偏会话与展示,结算偏资源锁定与一致性门槛(详见第 14 章)。
  • 上架、运营、生命周期在应用层可能合并为一个「供给平台」团队维护的多个服务,逻辑上仍建议按限界上下文拆分数据与发布节奏(第 11 章展开)。

典型调用链(便于与 10.4 对照):

用户意图入口服务同步扇出(节选)异步副作用(节选)
搜索列表搜索与导购商品中心、计价、库存 Hydrate曝光日志、排序特征回流
加购购物车商品中心校验 SKU无或弱:会话写 Redis
打开结算页结算编排计价试算、库存预占、营销校验审计日志、风控评分
提交订单订单系统快照固化、库存确认、营销扣减OrderCreated 驱动清购物车、发券统计
去支付支付系统渠道路由、收银台创建PaymentSucceeded 驱动分账、消息触达

循环依赖治理:若发现「商品中心回调订单」一类需求,优先改为事件订阅或查询倒置(由订单侧拉取快照),而不是在数据层打开反向通道。

10.1.3 数据架构

数据架构回答三件事:主数据放哪、派生数据如何构建、事件与缓存如何对齐。下图描述一条典型的「写主库、异步投影、读多路」路径,与第 7 章 CQRS 和第 4 章 Outbox 思路衔接。

flowchart LR
  subgraph Writers["写入侧(Command Path)"]
    SVC_W[业务服务<br/>订单/商品/库存等]
    DB[(MySQL 集群<br/>事务边界内)]
    Outbox[(Outbox 表<br/>同库事务)]
  end

  subgraph Bus["集成与解耦"]
    MQ[Kafka / Pulsar<br/>领域事件总线]
    CDC[CDC 可选<br/>Binlog 流]
  end

  subgraph Readers["读取侧(Query Path)"]
    ES[(Elasticsearch<br/>搜索索引)]
    Redis[(Redis<br/>库存热点/购物车)]
    DW[(数仓 / OLAP<br/>分析投影)]
  end

  SVC_W -->|本地事务| DB
  SVC_W -->|同事务写入| Outbox
  Outbox -->|Relay| MQ
  DB -.->|可选| CDC
  MQ -->|商品变更订阅| ES
  MQ -->|库存变更| Redis
  MQ -->|订单事实| DW
  CDC --> ES

落地要点:

  1. 订单、支付、商品主档等强一致实体以 MySQL(或同类)为权威存储;跨聚合协作优先 Outbox + 消息(见第 1 章 1.3.9),避免「双写」在故障时无法对账。
  2. 搜索索引、推荐特征、报表属于派生视图,允许最终一致;延迟由业务容忍度与补偿任务共同约束。
  3. 库存常见「Redis 扛热点 + MySQL 审计」的双存储形态,必须单写者(Single Writer)与周期对账(第 12 章)。

主数据与派生数据清单(评审用):

数据类型权威存储常见派生副本一致性策略
商品主档MySQL(商品中心库)ES 文档、CDN 静态化、本地缓存Outbox / CDC → 最终一致
价格规则与快照MySQL + 计价服务缓存订单行上的快照 JSON创单时以订单持久化为准
可售库存Redis 计数 + MySQL 流水搜索侧的「是否有货」标签单写者 + 定时对账
订单合同MySQL(订单库)数仓订单事实表、客服只读库Binlog / 事件双播
支付单与账务MySQL(支付库)渠道对账文件、会计凭证T+0 / T+1 对账任务

数据所有权一句话:谁对「业务不变量」负责,谁就拥有该数据的写入 API;其余路径只能投影或引用。

离线数仓与实时数仓的边界:订单与支付事件进入数仓后,用于分析与风控建模,不得反向写回在线交易库作为业务依据;若运营需要「实时看板」,应通过专用 OLAP 或流式聚合服务读取消息总线,而不是直接查询订单主库拖垮 P99。若确需运营干预线上数据,应走带审批的正式 API 与审计日志,而不是「数仓导表回灌」。这类约束也是第 7 章上线前检查中「数据变更路径」的必审项。

10.1.4 技术架构

技术架构把应用服务映射到运行时与平台能力:流量入口、服务通信、数据存储、异步集成、可观测性与零信任边界。下图为参考拓扑,实际规模会按环境裁剪。

flowchart TB
  subgraph Edge["边缘与接入"]
    CDN[CDN / WAF]
    LB[负载均衡]
    GW2[API Gateway<br/>鉴权 限流 mTLS]
  end

  subgraph Runtime["服务运行时"]
    SVC_POD[Kubernetes Pods<br/>Go 微服务]
    Mesh[可选 Service Mesh<br/>重试 熔断 流量镜像]
  end

  subgraph Data["数据与中间件"]
    MY2[(MySQL)]
    RD2[(Redis)]
    ES2[(Elasticsearch)]
    KF2[Kafka]
  end

  subgraph Platform["平台能力"]
    REG[服务注册发现]
    CFG[配置中心]
    SEC[密钥管理]
    LOG[日志聚合]
    MET[指标与告警]
    TRACE[分布式追踪]
  end

  CDN --> LB --> GW2 --> SVC_POD
  GW2 --> Mesh
  Mesh --> SVC_POD
  SVC_POD --> MY2
  SVC_POD --> RD2
  SVC_POD --> ES2
  SVC_POD --> KF2
  SVC_POD --> REG
  SVC_POD --> CFG
  SVC_POD --> SEC
  SVC_POD --> LOG
  SVC_POD --> MET
  SVC_POD --> TRACE

与后续章节的关系:第 11 章至第 15 章主要在应用与数据架构层面展开;当你评估「是否需要 Service Mesh」「Kafka 分区策略」时,应回到本节检查观测性是否先于网格、消息是否已成为事实管道等平台前提。

非功能需求(NFR)与全景的对应关系:

  • 可用性:网关限流与服务熔断保护核心交易路径;搜索与报表故障不得拖垮创单。
  • 性能:读路径大量使用缓存与索引;写路径控制扇出深度,结算页试算可合并批量 RPC(第 14 章)。
  • 安全:密钥不进仓库;支付回调验签在独立模块;内部服务 mTLS 或网络策略隔离。
  • 可观测性:以 trace_id 贯穿网关、结算、订单、支付;对 Saga 每一步有结构化日志与业务指标(转化率、预占失败率)。
  • 合规与审计:订单与支付字段变更可追溯;营销补贴与实付金额可对账。

渐进式演进建议:早期可用「单体 + 清晰包边界」模拟上图拓扑;当团队规模与发布冲突上升时,再按限界上下文拆出独立部署单元,避免「先拆微服务、后补边界」的高成本路径。

容灾与多活(点到为止):技术架构图未展开「单元化 / 多 Region」,但在全景阶段应预留认知:订单与支付数据往往要求 Region 内强一致 + 跨 Region 异步复制;搜索索引与购物车会话更适合 就近读取。若在多活场景下仍沿用单 Region 的强同步调用链,容灾切换时容易遭遇「依赖未起、核心不可用」;因此第 4 章在谈 Saga 时也会隐含「地理边界上的超时预算」问题。


10.2 核心域与支撑域

DDD 强调:不是所有子域都值得同等投入。战略设计的产出之一,是一张「域分类表」,用于指导组织排兵布阵与技术选型(自研 / 定制 / 采购)。

10.2.1 核心域:交易与支付

核心域承载差异化与最高业务风险,典型包括:

限界上下文业务价值失败影响工程特征
订单合同与履约编排的单一事实来源错单、重复下单、无法履约状态机、幂等、Saga、审计
支付资金收付与对账闭环资损、监管与信任危机幂等、渠道适配、账务分录
结算把「可卖」推进为「可付」转化暴跌、资源错锁长事务编排、降级与超时释放
购物车购买意图与会话合并体验与转化问题高并发读写、合并策略

本书将购物车、结算、订单与支付作为交易链路主轴:第 14 章讲商品发现、购物车与结算,第 15 章讲订单、支付、履约与售后;第 4 章从一致性模式上把它们串成可复用的集成语言。

投资与组织策略(与系统设计方法论中的技术投资判断对照):

维度建议
团队配置核心域配最强工程与业务分析能力;接口契约由领域 Owner 签字。
发布节奏核心域应支持高频小步发布 + 特性开关;重大促销前冻结非关键变更。
质量门禁核心域 PR 适用第 7 章全阶段评审;支付与订单变更默认要求双人审。
技术债核心域技术债「零容忍排队」;偿债预算单独列项,不与功能挤同一队列。

常见误区:把「购物车」当成纯前端本地存储。实际上购物车是高并发有状态服务,涉及登录合并、库存展示与营销提示,与结算的边界必须在 API 契约上划清(第 14 章)。

10.2.2 支撑域:商品、库存、营销、定价与供给

支撑域是核心域的「地基」:没有可售商品与可算价格,订单与支付无从谈起;没有库存与营销约束,结算编排也会失去输入。

分组限界上下文与核心域的接口关系
商品与供给商品中心、上架、运营、生命周期提供 SPU/SKU、快照、上下架状态;不直接参与支付
规则与资源库存、营销、计价提供预占、券活动、试算与快照;被结算与订单编排调用

第 11 章至第 15 章分别深入各支撑系统;第 11 章从组织上常合并「上架 + 运营 + 生命周期」,但限界上下文仍建议在模型层分开,以避免「一个上帝服务」拖垮发布节奏。

为什么支撑域也值得深度自研:支撑域虽非「卖点」,却是故障的放大器。例如库存超卖、计价错误、营销叠加漏洞,都会在订单层集中爆发。架构评审中常问:「若该支撑域宕机 30 分钟,核心域能否优雅降级?」——答案决定缓存策略、兜底价、降级开关的设计深度。

与核心域的集成契约(摘要):

  • 商品中心输出:快照 ID、类目路径、禁售标签。
  • 库存输出:预占凭证、可售数量区间、渠道库存类型(第 12 章二维模型)。
  • 营销输出:可叠加规则集、锁定 token、预算占用凭证。
  • 计价输出:试算结果哈希或版本号,供创单时校验「结算页所见即所得」。

10.2.3 通用域:用户、搜索、消息等

通用域标准化程度高,通常采购或薄封装即可;例外是搜索与导购:虽然模式成熟,但在中大型平台中与商品、价格、库存的实时编排深度交织,本书第 14 章将其放入完整交易旅程展开。

能力常见策略与交易链关系
用户与会员SSO、OAuth、IdP提供主体身份与风控标签
消息通知短信、邮件、Push订阅订单与支付事件
搜索ES + 召回排序 + HydratePDP/列表需联动计价与库存态

反模式提醒:把「通用域 = 可以随便写」等同于降低质量要求。正确做法是:减少自研范围,但不降低 SLO 与可观测性要求。

关于搜索域的「重要性升级」:从严格 DDD 分类看,搜索常被归为通用域(技术方案成熟);但从业务入口与 GMV 贡献看,它又接近核心体验。本书采取工程折中:在域分类上保留通用属性,在章节权重上按核心链路对待(第 14 章),因其失败模式会直接影响列表价、库存态与活动标签的呈现。

用户与消息:用户域提供主体标识与会员等级,消息域消费订单与支付事件做触达。二者与交易链的耦合主要是读侧鉴权与异步通知,应避免在下单同步路径强依赖外部推送可用性。


10.3 系统间的交互模式

跨系统协作可归纳为三类:同步 RPC、异步事件、数据同步(批式或流式)。它们不是互斥的,同一链路常组合使用;选型取决于一致性语义、延迟上限与故障隔离需求。[5][6]

10.3.1 同步调用

典型场景:结算页试算、创单前库存确认、支付创建。特征是调用方阻塞等待结果,语义接近「读己之写」或强校验。

优点:实现直观、调试路径短。
风险:级联故障、线程占用、超时风暴;需配合超时、重试、熔断、舱壁与清晰的错误契约(第 4 章相关小节)。

工程要点(Go 服务常见落地):为出站 RPC 设置上下文超时与每依赖独立超时;重试仅对幂等读或带幂等键的写开放;对核心交易路径实施舱壁线程池或并发上限,避免试算扇出把进程拖死。返回错误时区分业务可预期错误(如券不可用)与基础设施错误(如超时),前者映射为 4xx 与明确 code,后者触发降级与告警。

电商实例:结算页打开时,编排服务并行调用计价、库存、营销;只要任一关键依赖超时,应整体返回「请稍后重试」或切换至缓存兜底价 + 延迟锁券策略,而不是无限等待(第 14 章详述降级矩阵)。

10.3.2 异步事件

典型场景:订单已创建、支付已成功、商品变更。特征是最终一致,通过消息中间件解耦峰值与异构消费者。

优点:吞吐与弹性好,天然适合多订阅者(搜索索引、数仓、营销统计)。
风险:重复消息、乱序、滞后;需幂等消费、版本号、可补偿流程(第 4 章 Outbox 与事件驱动)。

工程要点:事件体应携带聚合 ID、版本号、发生时间、幂等键;消费者使用「处理表」或唯一索引实现 at-least-once 下的精确一次业务效果。对支付成功类事件,建议以支付系统 Outbox 为唯一发布源,避免订单与支付双写双发导致重复记账。

电商实例:ProductChanged 发布后,搜索索引、推荐特征、运营看板可能各自消费;它们失败不应阻塞商品主事务,但需要通过死信队列与可观测面板暴露积压,防止索引长期陈旧引发客诉。

10.3.3 数据同步

典型场景:搜索索引重建、报表 T+1、跨机房冗余。实现路径包括定时批处理、CDC、双写(谨慎)。

优点:对在线路径侵入小。
风险:延迟与对账;CDC 需处理 schema 演进与回放。

工程要点:优先 CDC + 消息 或 Outbox 形成可回放管道,避免业务代码里手写双写。若必须双写,应配置对账任务比较主从差异并自动修复。搜索全量重建应走蓝绿索引别名切换,避免重建期间查询抖动。

同步 / 异步 / 数据同步对比总览:三者回答的是不同维度的问题——同步保障「此刻的正确」,异步保障「吞吐与解耦」,数据同步保障「派生视图的规模构建」。架构评审可用下图作开场白板,再落到具体接口与 SLA。[3][5]

flowchart TB
  subgraph Sync["同步 RPC(Request/Response)"]
    S1["一致性:强一致读 / 即时校验"]
    S2["延迟:毫秒级 P99 约束"]
    S3["故障:调用链扩散 → 需熔断舱壁"]
    S4["典型:试算 创单校验 支付创建"]
  end

  subgraph Async["异步事件(Message/Event)"]
    A1["一致性:最终一致 + 补偿"]
    A2["延迟:秒级可接受 / 削峰"]
    A3["故障:隔离好 → 消费者独立重试"]
    A4["典型:订单已支付 商品变更广播"]
  end

  subgraph DataSync["数据同步(Batch / CDC)"]
    D1["一致性:以快照或日志为准"]
    D2["延迟:分钟级 ~ 小时级"]
    D3["故障:可回放 / 对账修复"]
    D4["典型:搜索索引 数仓 跨库复制"]
  end

  Q["选型提问:调用方能否接受短暂不一致?失败能否补偿?是否必须占用用户请求线程?"]
  Q --> Sync
  Q --> Async
  Q --> DataSync

Go 侧抽象示例:在应用层用接口表达三种出口,避免在业务代码里散落 HTTP 客户端细节。

package integration

import "context"

// SyncPricing 同步计价试算(RPC):强一致读、可返回明确业务错误码。
type SyncPricing interface {
	QuoteCheckout(ctx context.Context, req CheckoutQuoteRequest) (*CheckoutQuoteResult, error)
}

// AsyncPublisher 异步领域事件(Outbox relay 之后投递)。
type AsyncPublisher interface {
	PublishOrderPaid(ctx context.Context, evt OrderPaidEvent) error
}

// ProductIndexProjector 数据同步投影(可由 Kafka consumer 或 CDC worker 实现)。
type ProductIndexProjector interface {
	ApplyProductChanged(ctx context.Context, change ProductChangedLog) error
}

结算编排中的幂等键(与第 13、14 章衔接):同一用户多次点击「提交订单」时,应以客户端或服务端生成的 idempotency_key 贯穿结算会话与创单请求,避免重复扣减与重复订单。下面展示在 Go 中的最小承载方式(字段名可按公司规范调整):

package checkout

import "time"

// CheckoutSession 表示结算页的一次编排会话。
type CheckoutSession struct {
	SessionID        string
	UserID           string
	IdempotencyKey   string
	QuoteVersion     int64
	ExpiresAt        time.Time
}

10.4 数据流转全景

本节用三条完整时序链把 10.1 至 10.3 的静态结构串成动态故事线。图中参与者命名与后续章节标题一致,便于对照。

三条链路的共同模式(背诵版):每条链路都同时存在 同步确认(保证局部不变量)与 异步传播(放大读模型与运营可见性)两类步骤。设计时请先标出「哪一步失败会导致资损或客诉」——这些步骤应尽量落入短事务 + 明确幂等键;其余步骤尽量推出消息总线。另一个共同点是 Hydrate:搜索与列表在 C 端读路径上,往往需要二次拉取商品、价格、库存以修补索引延迟;这与订单创单时的「快照固化」是同一思想的不同形态——用显式版本与快照对抗时间差。

与大促场景的关系:商品流在大促前表现为「批量改价、改库存、改活动」的洪峰;订单流在秒杀瞬间表现为「创单与扣减」的尖峰;支付流在峰值表现为「渠道限流与回调延迟」。全景上需要预留 降级开关与异步化边界:例如列表页短时跳过非关键 Hydrate、支付回调与订单状态更新解耦等(细节分散在第 8、12、13、15 章)。

10.4.1 商品数据流

覆盖从 B 端提交到 C 端可搜、可算、可卖的闭环。

sequenceDiagram
  autonumber
  actor Merchant as 商家/运营
  participant Listing as 商品上架
  participant Life as 生命周期管理
  participant Product as 商品中心
  participant Inv as 库存系统
  participant Price as 计价系统
  participant Bus as 消息总线
  participant ES as 搜索索引
  participant Search as 搜索与导购

  Merchant->>Listing: 提交上架申请
  Listing->>Listing: 审核/风控策略
  Listing->>Product: 创建/更新 SPU SKU
  Listing->>Inv: 初始化/同步可售库存
  Listing->>Price: 配置基础价与费用模板
  Product->>Bus: ProductChanged 事件
  Inv->>Bus: InventoryChanged 事件
  Price->>Bus: PriceSheetChanged 事件
  Bus-->>ES: 投影商品文档
  Note over ES: 异步最终一致
  Merchant->>Life: 发起下架/同步修正
  Life->>Product: 状态迁移与编辑边界控制
  Life->>Listing: 回流审核/发布任务
  Search->>ES: 列表/搜索召回
  Search->>Product: Hydrate 缺失字段(可选)
  Search->>Price: 列表价 Hydrate(可选)
  Search->>Inv: 可售状态 Hydrate(可选)

阶段解读:步骤 1~5 属于 B 端写路径,强一致要求集中在「商品主档 + 初始库存 + 基础价」三者是否同事务可见;多数平台会拆成多个本地事务 + Saga,用补偿保证最终一致。步骤 6~8 属于 异步投影,搜索可见略滞后于库表写入是预期行为,但应对运营提供「索引就绪率」指标。步骤 9~12 体现 生命周期对上架与主数据的回流:下架、供应商同步修正、违规处罚都会触发再次审核或索引失效。

伏笔:第 11 章讲清商品模型与快照;第 12 章区分预占与实物库存;第 11 章拆解上架与运营编辑的权限与状态机;第 14 章展开 Hydrate 编排与降级。

10.4.2 订单数据流

从结算页到订单持久化,强调编排、快照与回滚责任。

sequenceDiagram
  autonumber
  actor User as 用户
  participant GW as API Gateway
  participant Cart as 购物车
  participant Checkout as 结算编排
  participant Price as 计价系统
  participant Inv as 库存系统
  participant Mkt as 营销系统
  participant Product as 商品中心
  participant Order as 订单系统
  participant Bus as 消息总线

  User->>GW: 进入结算页
  GW->>Checkout: 打开结算会话
  Checkout->>Price: 试算(基础价+营销+费用)
  Checkout->>Inv: 库存预占(或预校验策略)
  Checkout->>Mkt: 券/活动可用性校验
  Price->>Product: 读取商品主数据
  User->>GW: 提交订单
  GW->>Order: 创单请求(携带试算令牌/版本)
  Order->>Product: 拉取/确认商品快照
  Order->>Price: 固化价格快照
  Order->>Inv: 确认预占或二次扣减
  Order->>Mkt: 锁定或扣减营销资源
  Order->>Order: 持久化订单与状态机
  Order->>Bus: OrderCreated 事件
  Bus-->>Cart: 提示清理已下单行(异步)

阶段解读:结算阶段(打开结算页)与创单阶段(提交订单)必须对试算结果有明确版本策略:常见做法是计价返回 quote_version 或签名摘要,订单持久化时校验,防止「页面价与实付不一致」引发纠纷。库存侧若已在结算预占,创单多为确认;若仅在结算校验、创单时才预占,则需评估高峰下的重试风暴(第 12 章)。营销锁定与扣减宜拆成「锁定 → 确认 / 释放」两阶段,与订单状态机对齐,避免券冻结长期占用。

异常路径(图中未展开但工程必备):创单任一步失败应沿 Saga 反向释放预占与券锁定;若订单已写库但后续异步失败,应依赖订单状态机驱动补偿任务,而不是人工改库。

伏笔:第 13 章定义营销资格、试算与快照边界;第 14 章给出购物车与结算设计,第 15 章展开订单、支付状态机、回调和补偿;第 4 章把这些步骤抽象为可复用的一致性模式。

10.4.3 支付数据流

聚焦支付单生命周期与订单回写、清结算衔接。

sequenceDiagram
  autonumber
  actor User as 用户
  participant Order as 订单系统
  participant Pay as 支付系统
  participant Chan as 支付渠道
  participant Ledger as 账务/清结算
  participant Bus as 消息总线

  User->>Order: 去支付
  Order->>Pay: 创建支付单(order_id 幂等键)
  Pay->>Pay: 路由渠道路由/风控标签
  Pay->>Chan: 调用渠道下单接口
  Chan-->>User: 收银台/重定向
  Chan-->>Pay: 异步支付结果通知
  Pay->>Pay: 验签 幂等 状态机推进
  Pay->>Order: 同步支付结果(或订单轮询)
  Pay->>Ledger: 记账/分账指令
  Pay->>Bus: PaymentSucceeded 事件
  Bus-->>Mkt: 实付触达营销核算(可选)
  Note over Pay,Order: 失败/关单需可补偿:关支付单、回滚营销、释放库存(与各域策略绑定)

阶段解读:支付创建应以 order_id(或业务侧支付请求号)做天然幂等键,渠道侧重复调用不产生重复扣款。回调处理必须「先记账、后通知订单」或采用可对账的两阶段状态:确保账务系统(Ledger)与支付核心状态一致。PaymentSucceeded 事件驱动营销核算、分润、积分等下游时,仍应坚持 Outbox 语义,避免在回调线程堆叠扇出。

与订单的边界:订单系统关心「应付金额与履约状态」;支付系统关心「渠道收单结果与资金事实」。订单不应直接保存渠道原始报文全字段,应由支付系统规范化后回写支付结果摘要。

伏笔:第 15 章展开渠道适配、对账与退款;第 4 章讨论跨系统幂等与补偿事务编排。


10.5 系统边界总览

10.5.1 各系统的职责边界(十二个核心系统)

下表给出本书采用的十二个核心可部署系统(与 10.1.2 应用架构及第 11 章至第 15 章对应)。「不负责」列用于架构评审时的负面清单,防止边界侵蚀。

编号系统核心职责明确不负责深入章节
1商品中心SPU/SKU、类目属性、商品快照与主数据质量库存扣减、营销计算、支付第 11 章
2库存系统可售量、预占/确认/释放、对账与供应商同步价格计算、订单状态机第 12 章
3营销系统券/活动/补贴、圈品、预算与防刷订单持久化、支付渠道第 13 章
4计价系统多场景试算、费用、价格快照与降级营销资金账、库存数量第 13 章
5搜索与导购Query、召回、排序、Hydrate 编排不作为订单或支付事实来源第 14 章
6购物车行项目暂存、合并、批量操作不持有支付契约第 14 章
7结算编排结算页 Saga、预占协调、拆单预览不替代订单合同存储第 14 章
8订单系统合同、状态机、拆单、履约协调入口渠道密钥、资金划拨第 15 章
9支付系统支付单、渠道路由、回调、对账与退款商品主数据、库存数量第 15 章
10商品上架上架审核、发布流程、供给侧状态机不复制商品中心全量模型职责第 11 章
11供给与运营管理批量任务、配置工具、权限与审计不绕过商品中心直接写「影子库」第 11 章
12生命周期与供给治理同步/编辑/下架边界、跨系统编排约束不实现全量搜索召回第 11 章

说明:第 11 章在目录上合并了上架、运营与生命周期;在工程上可拆为多服务,但在边界表中仍建议分开陈述职责,以便治理。

十二系统「一句话职责」扩展(评审口播版):

  • 商品中心:维护「卖得是什么」——结构化商品、类目约束与面向订单的快照能力;对外暴露稳定读模型与快照创建接口。
  • 库存系统:维护「还能卖多少」——把可售量、渠道库存、预占凭证与对账闭环收敛在库存库表与热点缓存中。
  • 营销系统:维护「怎么促卖」——圈品、券活动、补贴预算与叠加互斥规则;不负责把最终应付金额写入订单。
  • 计价系统:维护「收多少钱」的规则引擎与快照——对接商品基础价与营销减免,输出可校验的试算版本。
  • 搜索与导购:维护「怎么找得到」——索引与排序是手段,Hydrate 与场景识别是业务核心;索引永远晚于主库一秒是常态而非事故。
  • 购物车:维护「用户想买什么」——会话级行项目与合并逻辑,不承担资金与库存的最终承诺。
  • 结算编排:维护「现在能不能付」——把试算、预占、营销校验收敛为短窗口内的可执行计划,再交给订单持久化。
  • 订单系统:维护「合同与履约状态」——状态机、拆单、快照引用与对外协调接口;不保存渠道密钥与支付通道报文。
  • 支付系统:维护「资金事实」——支付单、渠道、回调、账务分录与对账;不反向驱动商品编辑。
  • 商品上架:维护「供给侧流程」——审核、发布、异步补偿与状态机;它是编排器而非商品主数据的越权写入者。
  • 供给与运营管理:维护「运营效率」——批量导入导出、配置工具、权限审计;所有落库应通过正式领域 API。
  • 生命周期与供给治理:维护「时间与责任的边界」——上下架、同步冲突、编辑互斥与跨系统回滚策略的协调者。

按价值链聚类(便于向业务方解释):

价值链阶段涉及系统(编号见上表)业务语言
进场与治理10、11、12、1、2、4「有货、有价、合规可售」
发现与暂存5、6、1、4、2、3「看得见、算得清、加得进」
成交与资金7、8、9「锁得住、记得准、收得到」

10.5.2 边界不清的常见问题

  1. 商品中心写库存流水:库存的并发语义与对账域应收敛在库存上下文,否则易出现双写不一致。
  2. 营销系统直接改订单金额:优惠「算出来」与订单「记下来」应分离;否则退款与审计难以追溯。
  3. 搜索索引当主库:索引延迟会导致「搜得到但买不了」,必须在 Hydrate 或结算侧再次以权威服务为准。
  4. 支付系统承载订单状态机:资金状态与履约状态相关但不同;混写会导致渠道回调与拆单场景难以治理。
  5. 计价系统写订单行:计价负责「算」与试算令牌;订单负责「记」与版本校验。混写会让退款金额拆分失去依据。
  6. 购物车持有库存预占:预占属于资源锁定,应落在结算或订单编排;购物车仅存意图与展示缓存。
  7. 运营后台直连生产库改价:绕过计价与审计,极易产生监管与对账风险;应走审批流 + 正式 API。
  8. 搜索服务在召回阶段调用支付:读路径不应触碰资金系统;价格与活动以 Hydrate 调用计价与营销只读接口为界。

10.5.3 边界划分原则(落地检查清单)

  1. 单一事实来源(SSOT):每个聚合只有一个权威上下文持久化。
  2. 编排与状态分离:编排服务可以无状态或仅存会话;合同状态由订单上下文持有。
  3. 读模型可替换:搜索、推荐、报表可重建;不可重建的是资金流水与订单合同。
  4. 跨域用契约,不用隐式共享表:表连接是反模式;用 API、事件与明确 DTO。
  5. 把「能不能买」的最后一次校验放在离钱最近且可审计的一步(通常是创单或支付创建)。
  6. 明确「编排」与「领域服务」:编排负责步骤顺序与超时;领域服务负责业务规则判定。二者勿混在同一「上帝类」中。
  7. 每个跨系统接口都有 SLI:例如试算 P99、索引延迟上限、支付回调处理延迟;无指标的接口等于无边界。
  8. 用例驱动的边界测试:为每个系统维护「本系统拒绝处理的请求样例」,在 CI 或契约测试中固定下来,防止回归侵蚀。

10.6 本章小结

本章是全书的总领章,目标不是替代后续各章的深度,而是建立三样东西:同一套词汇(限界上下文与十二个系统)、同一张依赖图(应用与数据架构)、同一套交互纪律(同步 / 异步 / 数据同步的组合拳)。[2][7]

你可以带走的关键结论:

  1. 四视角对齐:BA 决定投资与语义边界,AA 决定可部署单元与依赖方向,DA 决定权威数据与投影路径,TA 决定规模化运行时能力。缺任一视角,评审容易出现「各说各话」。
  2. 十二个核心系统:商品中心、库存、营销、计价、搜索、购物车、结算编排、订单、支付、商品上架、供给与运营、生命周期治理——分别对应第 11 章至第 15 章的主体叙事;其中第 11 章在工程上常合并多个部署单元,但在治理上仍建议按职责拆分讨论。
  3. 域分类指导排兵:核心域(订单、支付、结算、购物车)追求正确性与可审计性;支撑域追求稳定与可替换的集成契约;通用域追求成本与 SLO 的平衡。搜索处于「通用技术 + 核心体验」的交叉带,第 14 章会展开其 Hydrate 与降级策略,第 15 章继续讨论交易事实和后续履约。
  4. 交互模式不可偏科:只有同步会导致故障传播;只有异步会拉长不一致窗口;只有批式同步无法满足实时导购。实际架构是在 SLA、成本、团队成熟度 约束下的组合。
  5. 三条主链路是阅读地图:商品流回答「货怎么进来并被发现」;订单流回答「承诺如何形成」;支付流回答「资金如何闭环」。后续章节均可挂载到这三条链上自检:本章的哪个小节、哪张图覆盖了当前话题。

与第 4 章的衔接:第 4 章将把 10.3 的交互模式上升为 Saga、幂等、对账、事件驱动 等一致性语言,并把 CAP 折中讲透。建议在阅读实战章节时,用 10.4 的时序图遮住文字,尝试口述一遍每条箭头上的失败与补偿,检验是否已建立全景肌肉记忆。

与第 11 章至第 15 章的衔接:进入任一系统章时,建议先回答四个问题——谁是上游、谁是下游、我的 SSOT 是什么、我发布哪些事件。答不上来则回到 10.5.1 边界表补齐。

面向架构师的自检清单(离开本章前):能否在 10 分钟内手绘 10.1.2 依赖图并标出三条可能形成环的依赖?能否用业务语言向非技术干系人解释「为什么搜索不是订单的一部分」?能否列举支付回调失败时的三个系统状态组合及各自补偿动作?若尚不能,建议在笔记中重画一遍 10.4 时序图,再进入后续系统实战章节。

建议阅读顺序:若你更熟悉业务,可按 10.4 节三条链路走读,再回看 10.1.1 的限界上下文;若你更熟悉工程,可从 10.1.2 与 10.3 节开始,把依赖与交互模式对齐后再进入各系统章节。若你正在准备架构评审,可携带 10.1.3、10.3.3 与 10.5.3 作为一页纸附录。

10.7 参考资料

[1] Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, Addison-Wesley, 2003, https://www.domainlanguage.com/ddd/。

[2] Martin Fowler, “BoundedContext”, martinfowler.com, https://martinfowler.com/bliki/BoundedContext.html。

[3] Robert C. Martin, Clean Architecture, Pearson, 2017, https://www.pearson.com/en-us/subject-catalog/p/clean-architecture/P200000009830。

[4] AWS, “Build services focused on specific business domains and functionality”, AWS Well-Architected Framework, https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_service_architecture_business_domains.html。

[5] Gregor Hohpe, Bobby Woolf, Enterprise Integration Patterns, Addison-Wesley, 2003, https://www.enterpriseintegrationpatterns.com/。

[6] Martin Kleppmann, Designing Data-Intensive Applications, O’Reilly Media, 2017, https://dataintensive.net/。

[7] Simon Brown, The C4 Model for Visualising Software Architecture, https://c4model.com/。


第 11 章 商品中心、商品供给与生命周期治理

本章讨论一个看似普通、实际很容易失控的问题:平台如何把来自人工录入、批量文件、供应商接口和内部系统的商品供给,经过标准化、审核、版本化和发布,转化为可查询、可售、可履约、可追溯且可恢复的商品资产。

电商系统里的商品并不是一行可以随意修改的数据库记录。一个酒店房型、一个数字商品、一个门票套餐或一个实物 SKU,往往同时包含基础描述、售卖计划、价格上下文、履约约束、库存资源、审核状态、上下架窗口和面向订单的历史契约。它们的变化来源不同,变化频率不同,权威事实也不同。如果所有变化都直接写入一张“商品表”,再用一个 status 字段表示全部生命周期,系统很快就会出现状态覆盖、审核失效、库存漂移、搜索脏数据和订单无法解释等问题。

本章把商品中心、供给平台、库存控制面和下游投影放到同一个端到端模型里分析。重点不在于给出某个公司的唯一实现,而在于建立一套可迁移的判断框架:什么是业务事实,什么是流程状态;谁拥有哪个字段,哪个系统可以写入;哪些动作必须在本地事务内完成,哪些动作只能通过事件最终收敛;当任务超时、消息重复、供应商乱序、库存创建失败或审核对象被再次修改时,系统如何恢复。

11.1 问题定义与系统边界

11.1.1 先定义平台真正要交付的对象

商品平台最终交付的不是“商品编辑页面”,而是一组可以被其他业务可靠消费的事实和契约。至少要区分以下四类对象:

对象解决的问题典型事实主要消费者是否允许直接修改
供给流程对象一次创建、编辑、导入或同步动作进行到哪一步Draft、Task、QC、错误文件、重试次数运营、审核、任务执行器允许通过命令推进
商品主数据对象平台认定的正式商品是什么Resource、Item、SPU、SKU、Offer、类目属性搜索、详情、订单、计价只能通过发布契约修改
可售资源对象在给定时间、地点或资源池中还能卖多少数量库存、券码、房态、时段容量购物车、结算、订单、履约由库存域按库存语义修改
交易历史对象某次交易当时买到的是什么商品快照、价格快照、规则快照订单、客服、退款、对账创建后不可覆盖

这四类对象可以共享业务标识,但不能共享所有权。供给流程中的 draft_id 表示一次待发布变更,正式商品中的 item_id 表示稳定的线上资产,库存中的 reservation_id 表示一次资源预占,订单中的 snapshot_id 表示一份不可变的交易解释。把这些标识都简称为“商品 ID”会让接口看起来简单,却会把不同的生命周期和失败语义隐藏起来。

领域驱动设计强调,模型边界应围绕业务能力和不变量划分,而不是围绕一张数据库表或一个页面划分。[1] 企业应用架构中的分层和服务边界也不是为了增加目录,而是为了让数据访问、领域规则和流程协调有清晰的责任归属。[2] 因此,本章先把商品平台定义为多个协作域,再讨论它们如何通过命令、事件和查询模型协作。

11.1.2 典型业务场景

一个可复用的商品供给平台至少需要处理五类入口。

第一类是人工单品创建。运营人员填写标题、类目、属性、售卖计划和履约规则,系统即时返回字段校验结果。这个入口强调交互反馈和可解释错误,通常需要同步完成草稿保存,但不应在用户点击保存时直接让商品对消费者可见。

第二类是人工编辑在线商品。编辑对象已经存在正式 item_id,因此系统必须记录编辑基线,例如 base_publish_version。如果编辑期间线上版本已经发生变化,平台不能静默覆盖,而应将冲突交给差异比较、合并或重新审核流程处理。

第三类是批量导入和批量编辑。运营人员上传 Excel 或 CSV,系统需要先保存原始文件,再进行解析、行级校验、标准化、风险审核和分批发布。一个文件中允许部分行成功、部分行失败,但每一行都必须能追溯到输入行号、错误原因、目标对象和重试结果。

第四类是供应商或 ISV 推送。外部系统可能只提供全量列表,也可能提供增量事件;可能重复发送同一版本,也可能出现延迟和乱序。平台不能假设供应商具有稳定的消息语义,而要通过来源标识、外部版本、指纹、时间戳和对账窗口识别无效更新。

第五类是运营和生命周期治理。平台要支持上架、下架、封禁、重新上线、销售窗口调整、属性修正、库存补货、券码批次导入和异常修复。这类动作有的改变正式商品状态,有的改变库存事实,有的只改变流程状态,不能通过一条万能更新接口完成。

场景用户期望系统必须保证系统不应承诺
单品创建页面响应快、错误可理解草稿可恢复、提交可幂等、发布有审核边界点击保存后所有下游立即完成
在线编辑不覆盖别人的最新版本基线版本可比较、冲突可发现多人并发编辑永远自动合并
批量导入大文件可提交、错误可下载行级隔离、任务可恢复、结果可追溯一个长请求内完成全部处理
供应商同步数据最终进入平台来源可识别、版本可比较、失效可对账外部全量列表天然等价于删除命令
上下架与封禁状态变化可控、可审计正式状态有权威、下游可收敛搜索缓存和交易读取同时瞬时更新

11.1.3 系统边界:谁负责什么

建议把平台拆成四个边界,而不是把所有能力称为“商品中心”。

商品中心负责正式商品主数据和交易前契约。它维护 Resource、Item、SPU、SKU、Offer、Rate Plan、类目属性、履约规则、退款规则、发布版本和商品快照模板。商品中心可以拒绝不符合模型不变量的发布命令,但不应承担 Excel 解析、供应商租约、人工审核队列或库存流水账。

供给平台负责流程控制面。它接收多种供给入口,创建 Draft、Task、Task Item、Staging、QC 和错误文件,维护来源、操作人、任务进度、字段主导权和审核记录。供给平台可以发起发布,但不应绕过商品中心直接写正式商品表。

库存系统负责可售资源事实。数量制库存、券码库存、房态库存和日期时段库存的扣减、预占、确认、释放、过期和对账属于库存语义。供给平台可以创建库存配置或发起库存任务,但不应把库存余额当作商品属性写入商品中心。

下游读模型负责按消费场景投影数据。搜索索引、列表缓存、详情缓存、推荐特征和运营看板都可以从商品中心事件构建,但它们不是商品主数据的权威来源。订单和支付还要保留交易时的快照,因为“当前商品”不能解释过去成交时的合同。

边界权威对象允许的输入禁止的越权
供给平台Draft、Task、QC、Change Request人工、文件、API、供应商直接写正式商品和库存余额
商品中心Item、SPU、SKU、Offer、Publish Version受校验的发布命令读取所有流程细节并代替供给平台执行任务
库存系统可售数量、券码、资源格、预占记录库存命令和履约事件用商品编辑接口修改库存事实
搜索 / 缓存查询投影版本化事件、批量重建命令反向成为商品状态来源
订单系统订单合同和商品快照下单时的确认结果事后依赖商品当前版本解释历史订单

11.1.4 业务事实、不变量与非目标

任何设计开始前,都应把“必须永远成立”的不变量写出来。下面这些不变量比组件名称更重要:

  1. 已发布的正式商品版本具有单调递增的逻辑版本,旧版本不能覆盖新版本。
  2. 审核通过的对象必须有明确的内容版本,不能审核一个对象、发布另一个未被审核的对象。
  3. 同一个外部来源对象的同一版本重复到达,不应造成第二次业务副作用。
  4. 库存预占、确认和释放必须属于同一库存语义,商品发布不能假装已经完成库存扣减。
  5. 订单创建后必须依赖订单自己的商品快照和价格快照,不能回查商品中心当前内容作为历史事实。
  6. 任意异步任务都必须能回答:谁提交、处理到哪、哪一行失败、是否可以重试、重试会不会重复副作用。
  7. 事件消费失败不能阻塞正式事实写入,但必须进入可观测、可重试、可对账的恢复路径。

本章不讨论完整的推荐算法、营销优惠计算、支付渠道路由、订单履约编排和供应商结算。它们会在其他章节展开;本章只描述这些系统与商品供给之间的契约和边界。

11.1.5 读者应该带走的判断

如果一个商品系统只能回答“商品表有哪些字段”,却回答不了以下问题,它就还停留在 CRUD 层面:谁批准了这次变更?批准的是哪个版本?发布时库存是否已经准备好?搜索结果为什么还显示旧内容?供应商重复发送是否会导致重复库存?订单为什么不会随着商品编辑而改变?任务失败以后由谁恢复?

本章的主线可以压缩为一句话:供给平台治理变化,商品中心确认事实,库存系统确认资源,下游模型接受版本化投影,订单保存历史契约。

11.2 约束、指标与核心判断

11.2.1 约束先于组件

商品平台的技术选型不能从“是否使用微服务、消息队列或 Redis”开始,而应从约束开始。对于一个中大型平台,可以先建立如下假设;这些数字只是容量规划示例,不是行业通用标准,落地时必须替换为测量结果。

维度示例假设设计影响验证方法
正式商品规模1,000 万级 Item,SKU 与 Offer 数量更高主数据表需要按访问模式设计索引和归档生产数据分布、冷热比例
日常变更每天百万级草稿、编辑或供应商更新不能让每次变化都同步扇出到所有下游任务吞吐和事件积压
大促峰值读流量远高于写流量,峰值按历史压测估算详情和列表读取需要独立读模型p95 / p99 延迟、缓存命中率
批量任务单个文件可能包含十万到百万行必须流式解析、分批处理、行级隔离单行耗时、批次大小、恢复时间
供应商同步单次全量任务可能运行数小时需要 Checkpoint、租约、分片和断点续跑任务最长尾、重启恢复时长
数据新鲜度搜索允许秒级到分钟级滞后,交易校验更严格不同下游使用不同版本和新鲜度预算事件延迟分位数、版本差距
可追溯性运营需要定位到人、来源文件、输入行和发布版本需要操作日志、关联 ID 和不可变快照随机抽样回放
合规与隐私外部凭证、个人信息和供应商敏感字段受控原始文件和审计记录需要权限、脱敏和保留策略权限测试、保留策略检查

数据密集型系统的核心难点不是把数据写入某个存储,而是承认不同访问模式会导致不同的读模型、索引和一致性选择。[3] 因此,同一商品可以有商品中心正式模型、搜索投影、详情缓存、运营看板和订单快照,但它们必须有明确的生成关系,而不能互相竞争成为事实源。

11.2.2 指标体系:业务结果和系统过程分开

只监控接口 QPS 和 CPU 不足以判断供给平台是否健康。至少需要四组指标。

第一组是供给结果指标,包括创建成功率、审核通过率、发布成功率、商品可售转化率、供应商有效更新比例、重复更新比例和人工接管比例。它们回答“平台是否把输入转成了可用资产”。

第二组是过程指标,包括任务等待时间、解析耗时、标准化耗时、QC 耗时、发布耗时、事件延迟、索引刷新延迟、库存初始化延迟和对账修复时长。它们回答“慢在哪里”。

第三组是正确性指标,包括版本回退次数、乱序消息数、幂等冲突数、重复库存副作用数、快照缺失数、下游版本落后数、库存配置与事实不一致数和错误文件重试成功率。它们回答“系统是否在静默地产生错误”。

第四组是资源与韧性指标,包括队列深度、最老任务年龄、Worker 租约超时数、DLQ 增长速度、数据库锁等待、缓存击穿次数、供应商限流比例和人工修复队列。它们回答“系统距离失控还有多远”。

指标计算口径目标不是触发动作
发布成功率发布命令成功并生成正式版本的比例所有输入都必须发布区分数据错误、冲突和基础设施失败
端到端可售时间从提交到交易前可售校验通过任意下游都同步完成识别最长关键路径和可选投影
版本滞后消费者已处理版本与商品中心版本的差全部读模型零延迟超过预算进入补偿或降级
任务最老年龄队列中最早未完成任务的等待时间单纯增加 Worker检查上游洪峰、下游限流和毒性任务
对账修复时长发现差异到最终收敛的时间差异永远不出现衡量恢复能力和人工负担

11.2.3 一致性不是一个开关

商品平台至少有四种一致性语义。

商品中心本地事务需要原子性。正式 Item、Publish Version、Snapshot 和 Outbox 记录之间的关系不能出现“版本已经变更但没有任何可发布事件”或“事件说已发布但正式事实没有提交”的裂缝。

跨域发布通常只能最终一致。商品中心不能把搜索、库存、营销、计价和订单都塞入一条跨服务长事务,否则失败恢复、锁持有时间和可用性都会变差。Helland 对大规模系统的讨论指出,许多实际系统会把强一致范围收缩到单个实体或单个服务,再通过消息和补偿协调更大的业务过程。[4]

读模型允许受控陈旧。列表搜索可以接受索引延迟,但必须在进入购物车、结算或订单创建时重新校验商品状态、价格和库存。陈旧不是“无所谓”,而是必须有明确的时间预算、版本标识和用户体验降级。

历史事实需要不可变。订单快照、审核结果、发布记录和审计日志不能随着当前商品变更而被覆盖。事件日志可以帮助回放和审计,但本章不把所有表都改造成全量 Event Sourcing;是否采用事件溯源应根据重放价值、外部副作用和运维复杂度单独决策。[5] 对跨服务协作而言,事件更适合表达已经发生的事实,协作方仍需定义事件所有权、处理结果和失败边界。[6]

11.2.4 复杂度预算

每增加一种入口,系统至少增加一类失败模式。人工入口增加并发编辑和权限问题;文件入口增加格式、行级错误和大任务恢复问题;供应商入口增加重复、乱序、来源可信度和全量删除误判问题;库存入口增加资源生命周期和扣减语义问题。复杂度不是由服务数量决定,而是由状态数量、事实源数量、异步边界和恢复动作共同决定。

因此,平台需要给每种能力设置复杂度预算:

  1. 只要同步保存草稿能够满足体验,就不要把草稿保存也拆成多个远程服务调用。
  2. 只要数据库 CAS 和短事务能够支撑任务领取,就不要为展示“分布式”而引入 Redis 锁。
  3. 只要单个供应商任务可以在可接受窗口内完成,就先使用 Batch + Checkpoint + DLQ,再根据测量结果引入分片。
  4. 只要搜索投影能够通过版本号过滤旧事件,就不要让搜索索引反向决定商品是否发布。
  5. 只要交易时可以重新校验,就不要为了列表页的即时准确把高频库存写入搜索引擎。

这不是保守,而是把复杂度放在真正需要它的地方。阿里巴巴的公开工程规范也把数据库、异常、日志、工程结构和安全约束放在同一套开发治理框架中,说明系统质量不只来自业务代码本身,还来自边界和习惯的长期一致性。[20]

11.2.5 本章的默认数量模型

后文案例使用一个假设的多供应商酒店与数字商品平台。平台有约 1,000 万个正式 Item,平均每个 Item 有 4 个 SKU 或 Offer;每天接收 50 万条供应商输入,其中约 20% 经过标准化后被判定为无效更新;运营侧每天提交 2 万个批量任务,单任务平均 2,000 行,长尾任务可能达到 10 万行。搜索索引允许 60 秒内收敛,详情读模型允许 10 秒内收敛,交易前商品状态和库存校验必须在一次请求内基于权威系统返回。

这些数值只用于说明设计方法。它们不能推出“所有电商平台都应使用 60 秒新鲜度”或“所有任务都应拆成 2,000 行批次”。真正的设计输入应来自生产日志、压测、供应商 SLA、业务窗口和恢复演练。章节中所有示例数字都标记为假设,避免把单一经验包装成标准答案。

11.3 领域模型、事实源与状态机

11.3.1 商品不是一张表,而是一组有边界的事实

商品模型需要同时表达“卖什么”“如何卖”“什么时候能卖”“卖了以后如何履约”和“当时卖给用户的是什么”。这几类信息的变化速度和生命周期不同,应该拆成多个相互引用但不互相越权的对象。

Resource 表示被平台管理的可供给资源,例如酒店、景区、课程、数字权益或供应商提供的外部服务。它通常承载资源级名称、地理位置、供应商归属和基础履约能力。Product Item 表示平台面向用户展示和交易的正式商品,是一个可发布、可下架、可归档的业务资产。SPU 用来归纳同一商品族的共性,SKU 表示具体可交易的规格组合,Offer 表示在某个渠道、售卖计划或供应商条件下的销售报价,Rate Plan 表示价格、退改、库存和履约规则组合。

这套模型不是固定的行业标准。实物电商可以让 SPU 关联多个 SKU;酒店场景可能把房型作为 SKU、日期和入住人数作为资源条件;数字商品可能把一个兑换码批次作为库存资源。关键不在于名词是否完全一致,而在于模型能够回答:对象的稳定身份是什么,哪些字段属于它,哪个变化会创建新版本,哪个变化只更新运行时资源。

对象稳定身份典型字段变化频率权威来源
Resourceresource_id资源名称、供应商、地点、资源类型中商品中心或资源域
Itemitem_id展示标题、状态、发布版本、类目低到中商品中心
SPUspu_id商品族共性属性、品牌、系列低商品中心
SKUsku_id规格组合、可交易单位、履约约束中商品中心
Offeroffer_id渠道、售卖计划、基础价格、供应商条件中到高商品中心 / 计价契约
Rate Planrate_plan_id价格计划、退改规则、适用窗口中商品中心 / 计价系统
Inventory Resourceinventory_resource_id数量、券码、日期、时段、预占规则高库存系统
Snapshotsnapshot_id标题、规格、规则、展示和交易字段创建后不可变订单系统

11.3.2 供给对象和正式对象必须分开

供给平台接收的第一份数据不能直接被当作正式商品。一个草稿可能缺少图片、履约规则、库存配置或合规材料;一个供应商对象可能使用本地字段名、旧类目和不稳定的外部 ID;一个批量文件可能同时包含 10 万行,其中只有 9 万行满足最小发布条件。因此,需要用供给对象承载“正在变成商品的变化”。

Draft 表示一次可以被继续编辑的变更草稿。它应记录 draft_id、supply_trace_id、operation_id、来源、操作者、基线版本和草稿内容引用。对于新建商品,Draft 尚未拥有正式 item_id;对于编辑在线商品,Draft 需要指向已有 item_id 和 base_publish_version。

Staging 表示一份准备参与审核或发布的冻结候选版本。它的价值不在于多一张表,而在于把“仍可编辑的草稿”和“已经被审核的输入”分开。审核通过后,如果原 Draft 又被修改,系统仍然可以知道审核针对的是哪个 Staging 版本,而不是模糊地认为“这个商品审核过了”。

QC Record 表示对某个明确内容版本的质量或风险判断。它至少要带有 staging_id、规则版本、审核结果、审核人或规则引擎、时间和理由。QC 通过不能单独改变正式商品状态;它只能让发布命令具备一个必要的前置条件。

正式 Item 只承载线上资产状态,例如 PUBLISHED、ONLINE、OFFLINE、ENDED、BANNED 和 ARCHIVED。它不应该存储 DRAFTING、QC_PENDING 或 IMPORTING 之类供给流程状态。这样做的好处是,面向消费者的查询不会被后台任务的中间态污染,生命周期的所有者也清晰可见。

11.3.3 任务模型把长流程变成可恢复对象

任务是供给平台的控制面对象。Task 表示一次完整动作,例如“导入一个文件”“同步一个供应商批次”“批量下架一组商品”;Task Item 表示任务中的一行或一个对象。两者不能只用一张任务表,因为任务级成功和行级成功不是同一件事。

任务记录至少包含以下信息:

字段作用缺少后的问题
task_id任务的稳定身份无法关联日志和重试
task_type区分导入、编辑、同步、库存等动作不知道使用哪套执行器
source_type人工、文件、API、供应商或系统事件无法判断信任和限流策略
idempotency_key标识同一次业务提交重试可能创建重复任务
payload_uri指向原始文件或不可变输入无法重建解析结果
schema_version标识输入结构老文件被新解析器误读
state表达任务当前阶段无法区分排队、执行和恢复
lease_owner / lease_expire_at控制 Worker 租约机器宕机后任务永久卡住
checkpoint记录可恢复进度重启只能从头执行
success_count / failure_count汇总结果无法给运营准确反馈
error_file_uri保存行级错误失败无法修复和重提

Task Item 需要有自己的状态、输入指纹、目标对象、错误码、重试次数、最后一次处理时间和结果版本。任务可以是 PARTIAL_SUCCESS,但每一行必须落在可解释的终态,例如 SUCCEEDED、FAILED_VALIDATION、FAILED_TRANSIENT、SKIPPED_DUPLICATE 或 WAITING_MANUAL。

11.3.4 正式发布模型与订单快照

商品中心的发布不是把 Draft 的 JSON 整体复制到 Item,而是一个受约束的合并过程。发布命令需要指定草稿、Staging、基线版本、目标商品和操作者;商品中心在本地事务内读取必要数据,校验当前版本仍然匹配,写入正式表、发布版本、快照和 Outbox,然后提交。

Publish Version 是正式商品变更的逻辑时钟。它可以是单调递增整数,也可以是带来源和序号的版本结构,但必须满足两个条件:消费者可以比较新旧,发布者可以拒绝旧基线。版本不是时间戳的同义词;机器时钟回拨、不同来源的时间精度和乱序都会让时间戳单独承担版本判断变得危险。

订单快照是另一种不可变对象。下单时,订单系统应保存标题、规格、展示文案、商品版本、价格上下文、履约规则摘要和必要的资源信息。订单快照不是商品中心当前记录的缓存,而是当时合同的一部分。商品中心可以修正当前商品标题,不能因此让三个月前的订单展示内容改变。

11.3.5 状态机的所有权

一个推荐的状态分层如下:

状态机所有者状态示例允许推进的主体
Draft供给平台 / 商品写侧DRAFT、SUBMITTED、WITHDRAWN创建者、编辑者、供给服务
Staging商品写侧或发布协调域FROZEN、READY、MERGED、EXPIRED发布协调器
QC质量与风险治理域PENDING、PASSED、REJECTED、REVIEWING规则引擎、审核员
Task任务平台QUEUED、RUNNING、PAUSED、SUCCEEDED、FAILEDTask Worker、管理员
Task Item任务平台PENDING、PROCESSING、SUCCEEDED、FAILEDItem Worker
Item商品中心PUBLISHED、ONLINE、OFFLINE、ENDED、BANNED商品中心命令
Sellability商品 / 库存 / 交易协同SELLABLE、SOLD_OUT、NOT_IN_WINDOW交易前校验和库存域
Outbox业务服务NEW、SENT、RETRYING、DEAD发布器、补偿任务

状态推进必须有来源和前置条件。例如,QC 通过只能把一个确定版本标记为可发布;它不能把正式 Item 直接改成 ONLINE,因为发布事务可能尚未提交。库存变更也不能把商品从 OFFLINE 直接改成 ONLINE,因为库存域必须先确认相应资源已创建或已有可用资源。

11.3.6 状态迁移与不变量

以“新建商品”为例,合理链路是:

stateDiagram-v2
    [*] --> Draft
    Draft --> Submitted: submit
    Submitted --> Staging: freeze snapshot
    Staging --> QC_Pending: create review
    QC_Pending --> Rejected: rule or manual reject
    QC_Pending --> QC_Passed: review passed
    QC_Passed --> Publishing: publish command
    Publishing --> Published: local transaction committed
    Published --> Online: sell window and resource ready
    Published --> Offline: policy or operator command
    Online --> Offline: offline / ban / end
    Offline --> Online: approved republish

编辑在线商品时,链路稍有不同:Draft 从正式 Item 的某个 base_publish_version 派生;如果在提交前发现当前 Item 版本已经大于基线,系统应转为 CONFLICT 或重新生成差异,而不是直接合并。自动合并只适用于明确可交换的字段,例如独立的运营标签;标题、类目、售卖规则和履约约束等核心字段必须经过业务规则判断。

状态机还要定义终态和人工接管。FAILED 不等于“可以无限重试”,REJECTED 不等于“系统异常”,WAITING_MANUAL 也不应被 Worker 自动消费。状态表中要记录状态原因、发生时间、操作者、规则版本和关联事件,避免排查时只能看到一个没有上下文的枚举值。

11.3.7 Schema、字段主导权与版本兼容

不同入口不应直接共享一份没有版本的自由格式 JSON。平台需要区分三种 schema:输入 schema 描述来源格式,规范化 schema 描述平台中间模型,正式 schema 描述商品中心可以接受的发布模型。它们之间通过显式映射和校验转换。

Schema 版本升级时要回答四个问题:旧输入还能否解析,默认值是否改变语义,错误是否能定位到原始字段,已发布对象是否需要回放重建。JSON Schema 可以用来描述结构和约束,OpenAPI 可以用来描述命令和查询契约,但它们本身不能替代业务不变量。[16][17]

字段主导权表是治理的关键。例如,商品中心主导标题、类目和正式属性;供给平台主导来源、任务、审核和操作记录;供应商只主导约定的外部字段;运营人员可能主导人工标签;库存系统主导可售数量。发生冲突时,系统不是简单采用“最后写入”,而是依据字段主导权、来源可信度、版本和人工覆盖规则决策。

11.3.8 对象关系、边界上下文与契约

商品供给领域中最容易被忽略的不是对象数量,而是对象之间的关系语义。Resource 表示可被售卖或履约的资源,Item 表示平台面向用户展示和交易的商品对象,SKU 表示可区分的规格或权益单元,Offer 表示在某个渠道、时间或销售规则下的售卖条件。它们可以在数据库中有关联,但不能因为关系紧密就合并成一个对象。

例如,一个酒店房型可以对应多个销售计划;同一个销售计划可以在不同日期使用不同库存策略;一个门票商品可以有多个场次;同一个课程可以有多个班次和价格计划。如果把这些关系压缩成 product_id + price,系统短期内看似简单,长期却无法表达价格生效窗口、库存资源、退改规则和订单快照。对象模型应优先解释业务事实,再决定关系表、文档或事件中的表示方式。

领域边界可以按事实变化的节奏和不变量划分。商品中心关注“平台认定的商品是什么以及当前正式版本是什么”;供给平台关注“外部变化如何被接收、校验、审核和发布”;库存系统关注“资源是否可用、是否被预占以及如何恢复”;计价系统关注“在给定上下文下应收多少”;订单系统关注“用户已经确认了什么合同”。这些边界与组织边界可以重合,也可以暂时落在一个模块中,但每个事实只能有一个权威写入者。

对象身份来源主要变化权威写入者其他系统可做的事
Resource资源方或平台生成资源归属、类型、外部映射资源 / 商品域引用、对账、申请变更
Item平台生成生命周期、正式内容、发布版本商品中心查询、订阅事件
SKU规格组合或权益单元规格、履约限制、可交易条件商品中心查询、提交候选
Offer渠道和销售规则生成价格引用、窗口、资格商品 / 计价协同域请求试算、订阅变更
Inventory Resource仓库、场次或供应商资源余额、锁定、确认、释放库存域发起配置和命令
Order Snapshot下单确认产生合同和履约状态订单域只读追溯

边界上下文之间传递的不是内部对象,而是稳定契约。商品中心不应把内部数据库表直接暴露给库存系统;库存也不应要求商品中心提供可以随时变化的内部锁状态。契约应该说明字段含义、单位、版本、可空性、时效、错误和副作用。一个接口即使只有五个字段,也可能需要说明“空值代表未知还是无资源”“时间是本地时间还是 UTC”“价格是含税还是未税”。

契约还必须说明读写方向。查询接口允许调用方读取一个投影,但不代表调用方拥有修改权;事件允许消费者得到一个事实,但不代表消费者可以依据事件内容反向覆盖生产者的字段。对于跨域需要修改的对象,应发起命令或变更申请,并把结果通过事件返回。这样的约束能防止系统在演进后形成双主写入。

设计对象模型时,可以为每个字段制作四元组:业务含义、主导来源、版本语义、失败后处理。比如“退改规则”不是一段普通文本,而是有适用时间、费用区间、渠道范围和订单影响的交易规则。它由商品或规则域主导,发布时带规则版本;如果解析失败,商品不能进入可售状态;如果发布后变更,订单快照不应被覆盖。用四元组审查字段,比只检查字段名和数据库类型更能提前发现跨域错误。

11.4 参考架构与端到端主链路

11.4.1 控制面、事实面和投影面

合并后的架构可以分为三层。

控制面接收命令、生成任务、保存审核状态、安排执行和处理人工接管。它关心“这次变化是谁发起的、处于哪个阶段、下一步由谁执行”。供给平台是控制面最主要的承载者,但商品中心写侧也可以拥有与商品版本强相关的 Draft 和 Staging。

事实面保存平台认可的业务事实。商品中心保存正式商品模型和发布版本,库存系统保存库存余额、券码实例和资源格,订单系统保存订单合同和交易快照。事实面不应该被某个临时任务的重试状态污染。

投影面服务不同读取模式。搜索索引适合全文检索和筛选,详情缓存适合高频读取,运营看板适合聚合,推荐系统适合特征计算。投影面可以延迟、重建和丢弃,但必须能够从事实面和事件日志重新生成。

flowchart LR
    A[人工 / 文件 / API / 供应商] --> B[供给控制面]
    B --> C[Draft / Task / QC / Staging]
    C -->|Publish Command| D[商品中心写侧]
    D --> E[(正式商品事实)]
    D --> F[(Publish Version + Snapshot)]
    D --> G[(Outbox)]
    G --> H[事件总线]
    H --> I[库存投影 / 库存命令]
    H --> J[搜索索引]
    H --> K[缓存 / 计价上下文 / 营销资格]
    E --> L[详情查询]
    I --> M[交易前库存校验]
    F --> N[订单快照]
    H --> O[对账与可观测性]

这张图表达的是责任关系,不是要求所有模块必须部署为独立服务。小团队可以把控制面和商品中心写侧部署在同一个应用中,只要接口边界、数据所有权和失败恢复仍然清楚。服务拆分不能替代领域边界;反过来,单体部署也不意味着可以共享所有表。中文架构实践资料也把领域边界、数据一致性和演进过程放在同一条分析链路中,提醒设计者不要只从部署拓扑推导业务职责。[21]

11.4.2 命令、查询和事件的分工

命令表达意图,例如 SubmitDraft、ApproveStaging、PublishItem、InitializeInventory、ReplenishStock 和 OfflineItem。命令需要幂等键、操作者、目标版本和业务原因;命令成功只表示权威服务已经受理或提交了本地事实,不应随意声称所有下游都已完成。

查询表达当前可读模型,例如 GetItem、ListOffers、GetTask 和 GetPublishStatus。查询可以返回版本、更新时间和新鲜度信息,让调用方知道读到的是哪一份投影。列表查询和搜索查询不应该被要求承担交易最终校验。

事件表达已经发生的事实,例如 ItemPublished、ItemVersionChanged、InventoryInitialized、ItemOffline 和 SupplierSyncAccepted。事件的命名应使用过去时,并带有事件 ID、聚合 ID、逻辑版本、发生时间、来源、追踪 ID 和 schema 版本。事件不是远程过程调用的替代品;它更适合通知多个下游和支持重放,但不能直接表达“请立即完成另一个服务动作并返回结果”。

交互适合表达必须包含失败处理
Command API意图和状态变更幂等键、操作者、目标版本明确拒绝、冲突或受理状态
Query API当前投影和任务进度版本、更新时间、新鲜度返回陈旧标识或降级信息
Domain Event已提交事实事件 ID、聚合版本、来源重复消费、乱序过滤、DLQ
Reconciliation API期望与实际对比比较范围、采样规则、修复策略进入修复队列和人工审核

11.4.3 发布链路的本地原子边界

商品发布最重要的本地事务通常包含:检查 Draft / Staging 的可发布条件,检查基线版本,写入正式商品表,写入新的 Publish Version,生成订单所需的商品快照,写入变更日志,并写入 Outbox。只要这些记录属于同一个商品中心数据库,就可以在一个本地事务中保证“正式事实”和“待发送事件”同时存在。

Outbox 的意义是避免业务表提交成功而消息发送失败,或者消息发送成功而业务表回滚。Debezium 的 Outbox Event Router 文档将这种模式描述为:在同一数据库中记录 outbox,再由 CDC 或发布器把事件安全地送往消息系统。[13] 但 Outbox 只解决生产侧的原子性,不会自动解决消费者幂等、事件乱序、下游拒绝和最终对账。

事件发送后,下游按照自己的事实语义处理。库存服务收到商品发布事件,可以创建库存配置或等待供给平台的库存命令;搜索服务更新索引;缓存服务失效对应键;计价服务刷新可引用的商品上下文;营销服务更新圈品资格。它们不应该通过同步 RPC 链式调用把所有动作串进商品发布事务。

11.4.4 版本驱动的投影

每个下游投影都要记录最后成功处理的商品版本。收到事件时,消费者先比较 event.version 和本地 projected_version:如果事件版本更大,进行更新;如果相等,视为重复;如果更小,视为乱序或迟到并记录指标。只有在确实需要回补时,才由对账任务按照事实面重建投影。

if event.version < projection.version:
    record_stale_event(event)
    return success

if event.version == projection.version:
    record_duplicate_event(event)
    return success

if event.version > projection.version:
    apply_projection(event)
    save_projection_version(event.version)

如果一次事件包含跨实体变化,不要只使用一个模糊的全局版本。可以使用聚合版本、字段版本或发布批次版本,但必须让消费者知道比较维度。供应商外部版本也不能直接等同于商品中心版本,平台需要在来源域完成映射,并保留来源版本以便审计。

11.4.5 正常链路和失败链路

正常链路是:入口受理 → 保存原始输入 → 标准化 → 结构校验 → 风险审核 → 生成 Staging → 发布命令 → 商品中心本地事务 → Outbox → 下游投影 → 交易前校验 → 对账确认。

失败链路必须逐段定义:

失败位置可能原因首次动作最终恢复
输入受理鉴权失败、参数错误、重复提交返回可解释错误修正输入后重新提交
文件解析编码错误、列缺失、格式非法生成行级错误文件修复文件后创建新任务
标准化类目映射缺失、字段冲突标记人工处理补充规则或人工确认
QC风险命中、资料缺失拒绝或进入人工审核修改 Draft 并生成新版本
发布事务基线冲突、数据库错误、死锁回滚本地事务按幂等键重试或重新合并
Outbox发布器宕机、消息系统不可用保留未发送记录扫描重试、超限进入 DLQ
下游消费重复、乱序、依赖超时版本过滤或延迟重试对账重放和人工接管
库存初始化资源不匹配、供应商限流商品保持不可售修复库存任务后推进可售
搜索投影索引拒绝、映射冲突保留旧索引版本修复映射、批量重建

失败处理的关键不是“每一层都重试”,而是判断重试是否安全。AWS 对幂等 API 的总结是:客户端可以把重试当作安全动作,但服务端必须通过唯一请求标识和语义等价结果消除重复副作用。[7] 对无法安全重试的动作,应使用查询确认、补偿命令或人工处理,而不是盲目重复写入。

11.4.6 数据读取边界

详情页通常可以读取商品中心读模型,再在需要时补充库存和计价信息;列表页通常读取搜索索引或缓存,再根据用户、日期和库存条件做 Hydrate;购物车和结算需要重新调用库存、计价和营销系统;订单创建则必须把最终结果固化为订单快照。

这条边界意味着“页面上显示可售”不等于“订单一定可创建”。列表页的可售状态是用户体验提示,交易前校验才是事实判断。反过来,交易前校验也不能要求搜索索引实时更新后才能继续,因为这会让读流量和高频库存变化把同一个系统拖垮。

11.4.7 事件总线不是万能数据库

事件总线适合传输事实和驱动投影,但不应成为所有查询的唯一数据库。事件需要保留期限、分区、顺序、重放和 schema 演进策略;消费者也需要自己的状态、去重表和失败队列。Kafka 的幂等生产能力主要解决生产者重试造成的重复写入,不能自动保证跨服务业务动作端到端只执行一次。[12] 中文消息系统资料也反复强调生产、消费、重试和死信之间的边界,说明“消息发出”与“业务完成”必须分开度量。[19]

对于必须审计的发布和库存动作,业务数据库中的正式记录、操作日志和快照仍然是权威事实。消息只承担传播和解耦作用。对于需要全量重建的搜索或看板,可以保留事件流和批量快照;对于订单合同,则应以订单本地不可变记录为主。企业集成模式中的幂等消费者和消息重试模式也说明,消费者必须把重复消息视为正常输入,而不是异常例外。[18]

11.4.8 失败域、降级和恢复优先级

端到端链路的每一段都可能失败,但不同失败的影响范围不同。设计时可以把失败域分为入口域、控制域、事实域、传播域、投影域和交易域。入口域失败通常只影响一次提交;控制域失败可能让任务无法推进;事实域失败可能阻止正式版本产生;传播域失败会造成事件积压;投影域失败主要影响读体验;交易域失败则直接影响用户是否能够完成购买。恢复顺序应优先保障事实和已确认订单,再恢复低优先级投影。

失败域典型症状可以牺牲的能力不能牺牲的能力首选恢复动作
入口域上传失败、参数错误、供应商超时立即反馈全部细节原始输入不丢失、请求可追踪保存错误和重试指引
控制域任务不调度、审核积压新任务吞吐已受理任务的状态可解释暂停接收、恢复租约和队列
事实域发布事务失败、数据库不可用新版本发布旧版本可读、订单快照完整读旧版本、修复事务和存储
传播域Outbox 积压、消息不可达投影实时性事件可重放、事实不回滚扩容发布器、限流下游
投影域搜索落后、缓存失效筛选新鲜度交易前重新校验使用旧投影并显示陈旧
交易域库存预占失败、计价不可用非关键展示不超卖、不重复扣款拒绝或稍后重试,不猜测成功

降级不是简单返回一个默认值。默认值可能改变用户看到的合同,甚至制造可售假象。搜索可以在索引落后时返回旧版本,但必须禁止将旧索引中的库存字段直接用于下单;详情页可以返回“库存确认中”,但不能把未知库存显示成可购买;供应商同步可以暂缓缺失对象的下线,但不能把未经确认的异常数据标成正常。每一种降级都要说明用户可见结果、交易影响、恢复入口和最长容忍时间。

恢复优先级可以按照“事实、合同、资源、投影、体验”的顺序排列。事实包括正式商品版本、发布记录和库存流水;合同包括订单、价格和退改快照;资源包括库存预占、券码状态和外部确认;投影包括索引、缓存和看板;体验包括排序、推荐和非关键扩展信息。这个顺序不是说体验不重要,而是先保证不可逆或高成本的副作用不会继续扩大。

对于未知结果,系统必须保留 UNKNOWN 或 PENDING_CONFIRMATION,不能把超时当成失败,也不能把没有响应当成成功。比如调用供应商锁库存超时,平台可能已经获得了锁,也可能没有;正确动作是查询确认、等待对账或进入人工处理。若直接重试,可能产生两个外部锁;若直接释放本地资源,又可能与供应商真实锁状态冲突。未知状态要有超时、查询和最终处置规则。

恢复操作应具有范围控制。自动恢复适合单个事件重放、已知版本的索引补建和过期租约接管;大范围恢复需要先模拟影响对象数量、订单数量和下游事件数量,再按供应商、城市、类目或时间窗口分批执行。恢复命令自身要有幂等键、操作者、原因和审计。完成后必须重新跑同一条对账规则,证明差异收敛,而不是只证明恢复接口返回了成功。

容量保护也属于恢复设计。Google SRE 对过载处理的讨论强调,服务应通过削峰、排队、拒绝低优先级请求和提供成本更低的结果来保护核心能力,而不是让所有请求一起拖垮系统。[9] 在商品供给场景中,批量同步、搜索重建和运营报表可以让路于订单校验、库存确认和正式下架;任务平台需要有租户级、供应商级和优先级级别的配额,防止单个大文件抢占所有 Worker。

11.4.9 一条商品变更的时序说明

为了避免架构图只展示静态组件,可以把“运营修改一个在线房型名称并同时调整退改规则”展开为时序。运营端先读取商品版本 18,保存 Draft 时把 base_publish_version = 18 一并写入;供给服务返回 Draft 身份,不改变正式商品。运营提交审核后,系统冻结内容版本 42,执行结构校验、类目规则和风险规则,生成 QC 记录。审核通过只意味着版本 42 可以被引用,不意味着版本 42 已经进入正式商品。

发布命令到达商品中心时,服务先检查调用者权限、Staging 是否仍然有效、QC 是否针对版本 42、当前正式版本是否仍为 18,以及变更字段是否有主导权。如果同时有供应商同步把商品发布到版本 19,条件更新失败,发布命令返回冲突并指向差异;运营必须以版本 19 为基线重新合并。这个过程保护了人工编辑,也避免审核结果被悄悄套到另一份内容上。

如果当前版本仍为 18,商品中心在本地事务内写入版本 19、快照、变更日志和 Outbox。提交成功后,发布 API 返回 publish_version = 19 和事件 ID。搜索消费者收到事件后可能立即更新,也可能因为索引写入受限而等待;库存消费者可能判断退改规则变化不影响库存,但商品可售投影仍要重新计算。运营查询应看到“正式版本已发布,搜索和库存投影处理中”,而不是被同步调用链阻塞。

若发布后发现标题翻译错误,读模型可以先回退到版本 18;若发现退改规则不合法,则要冻结版本 19、阻止新订单,并生成反向版本或修复版本。已经创建的订单仍然引用自己的快照。若库存初始化在版本 19 上失败,商品可以保持 PUBLISHED + NOT_READY,库存任务修复后再变为可售;不能把库存失败伪装成商品发布失败,也不能在商品状态回滚时丢失已经生成的审计和事件。

时序节点权威事实可见状态失败后的边界
读取编辑基线商品版本 18可编辑 Draft读取失败不创建修改
保存 DraftDraft 内容和基线未发布可重复保存和覆盖 Draft
冻结 Staging内容版本 42待审核校验错误回到 Draft
QC 通过版本 42 获得审核结论可申请发布版本变化必须重新审核
发布提交正式版本 19已发布、投影待处理本地事务回滚
投影消费下游版本 19搜索 / 库存逐步收敛重试、重放、对账
交易确认订单快照和库存事实合同成立进入订单补偿,不改历史

时序中的关键不是消息数量,而是每个动作都能指出自己修改了哪一个事实、引用哪个版本、是否已经产生不可逆副作用。只要这三点清楚,系统可以把部分动作同步化或异步化;如果三点不清楚,增加队列和重试只会隐藏错误。

11.5 供给入口与任务执行框架

11.5.1 统一任务模型,而不是统一处理细节

不同入口可以共享任务生命周期,但不能强迫所有入口使用同一套解析、限流和错误策略。人工单品创建更适合短请求加 Draft 保存;Excel 导入适合文件对象加异步任务;供应商同步适合 Batch、分片和 Checkpoint;库存券码上传适合批次导入和安全扫描。

统一的是任务的外壳:任务身份、来源、状态、进度、租约、重试、错误、审计和恢复。不同的是任务的载荷、并行度、超时、幂等键和业务完成条件。

stateDiagram-v2
    [*] --> ACCEPTED
    ACCEPTED --> QUEUED: persist task
    QUEUED --> RUNNING: worker claims lease
    RUNNING --> PAUSED: rate limit or maintenance
    PAUSED --> QUEUED: resume
    RUNNING --> SUCCEEDED: all items converge
    RUNNING --> PARTIAL_SUCCESS: some items terminal failed
    RUNNING --> RETRYING: transient failure
    RETRYING --> QUEUED: backoff elapsed
    RUNNING --> WAITING_MANUAL: conflict or policy decision
    RUNNING --> FAILED: non-retryable task failure
    FAILED --> QUEUED: explicit replay
    PARTIAL_SUCCESS --> QUEUED: retry failed items
    WAITING_MANUAL --> QUEUED: operator decision

任务状态必须由状态转移规则保护。Worker 不能通过“更新 status”随意跳到成功;它要带上租约令牌、任务版本和处理批次,使用条件更新确认自己仍然拥有执行权。否则,旧 Worker 在网络分区恢复后可能继续写入,覆盖新 Worker 已经得到的结果。Kubernetes Job 对完成次数、失败重试和并行任务的建模也说明,批处理的成功条件不能只依赖一个进程是否退出,而要与任务目标和失败策略绑定。[15][22]

11.5.2 单品创建和编辑

单品创建可以采用同步受理、异步审核和显式发布的三段式体验:

  1. 前端提交字段,供给服务做轻量格式校验并保存 Draft。
  2. 服务返回 draft_id、当前校验结果和下一步动作,不承诺正式商品已经出现。
  3. 用户提交审核,系统冻结一个 Staging 版本,生成 QC 任务。
  4. QC 通过后,用户或规则决定是否发布;发布命令带上 staging_id 和 base_publish_version。
  5. 商品中心在本地事务内合并正式数据并生成 Outbox。
  6. 库存、搜索、计价和营销分别处理自己的投影,交易前重新校验。

在线编辑必须记录基线。假设编辑者在版本 8 上打开页面,另一条供应商同步先发布了版本 9,编辑者随后提交。如果系统只比较 updated_at,可能因为时间精度、时钟偏差或异步写入导致误判;更可靠的方式是使用 base_publish_version = 8 做条件更新,当前版本不是 8 就拒绝或进入差异合并。

对于互不相关的字段,可以使用字段级主导权减少冲突。例如供应商负责房型面积和供应商描述,运营负责平台展示标题和排序标签;但类目、履约规则、退改政策等会改变交易语义的字段,不能因为“来源是最新”就直接覆盖。

11.5.3 Excel 批量导入

批量导入必须把文件当作不可变输入保存。文件上传成功后,系统生成 task_id 和文件版本,计算内容摘要,把文件放入受控对象存储,随后由 Parser Worker 流式读取。不能把一个大文件完整加载进 Web 进程内存,也不应让用户请求保持到全部行处理结束。

推荐的流水线如下:

flowchart LR
    A[上传原始文件] --> B[病毒与格式检查]
    B --> C[创建 Task]
    C --> D[Parser Worker]
    D --> E[行级解析结果]
    E --> F[标准化与 Schema 校验]
    F --> G[Task Item]
    G --> H[Diff 与风险判断]
    H --> I[Staging 批次]
    I --> J[QC]
    J --> K[Publisher]
    K --> L[成功报告 / 错误文件]

Parser 只负责解析和基本格式错误,不应该直接发布商品。这样可以把“文件不合法”和“业务不允许发布”分开。每个 Task Item 保存原始行号、规范化结果、目标外部 ID、指纹、差异摘要和错误列表。错误信息应指出字段、规则、实际值和建议修复方式,而不是只返回“导入失败”。

批量更新需要区分三种结果:没有变化、可自动发布、需要人工审核。没有变化的行应该标记为 SKIPPED_NOOP,不重复触发下游刷新;可自动发布的行可以进入批量 Staging;高风险或字段主导权冲突的行进入人工队列。一个文件的任务状态可以是部分成功,不能因为 1 行失败就把 99,999 行成功结果全部回滚,也不能为了显示成功率而吞掉失败行。

11.5.4 供应商同步

供应商同步最容易发生“全量列表误删”和“旧数据覆盖新数据”。平台需要为每个供应商建立来源契约:对象外部 ID、来源版本或更新时间、全量批次 ID、删除标记语义、拉取窗口、限流规则和对账责任。

如果供应商只提供全量列表,不能把“本次列表中没有对象”直接当成删除。可靠做法是:

  1. 为一次拉取生成 sync_batch_id。
  2. 把原始对象和来源版本写入 Raw Snapshot。
  3. 对每个对象计算规范化指纹和来源版本。
  4. 只有批次完整、来源可信、校验通过后,才把“上一批次存在、本批次缺失”的对象放入待确认删除集合。
  5. 经过延迟窗口、重复拉取或供应商确认后,才生成下线命令。

供应商更新还要处理来源字段和平台字段的冲突。平台可以接受供应商对房型名称、供应商价格和外部库存的主导,但平台审核状态、平台类目、展示标题和合规标签不能被供应商同步静默覆盖。一个字段是否可被外部更新,应由字段主导权表决定,而不是由同步代码里的 UPDATE 语句决定。

11.5.5 任务抢占与租约

任务领取可以先用数据库条件更新:查询处于 QUEUED 的任务,按优先级和创建时间排序,尝试把状态改为 RUNNING,同时写入 lease_owner、lease_token 和 lease_expire_at。只有持有当前租约令牌的 Worker 才能更新进度和释放租约。

UPDATE supply_task
SET state = 'RUNNING',
    lease_owner = :worker_id,
    lease_token = :lease_token,
    lease_expire_at = :expire_at,
    version = version + 1
WHERE task_id = :task_id
  AND state IN ('QUEUED', 'RETRYING')
  AND (lease_expire_at IS NULL OR lease_expire_at < CURRENT_TIMESTAMP)
  AND version = :expected_version;

如果受影响行数为 0,说明任务已被其他 Worker 抢占、状态已变化或版本冲突,当前 Worker 不能继续执行。数据库 CAS 足以解决低到中等竞争的任务领取;只有当领取请求极高、任务分片数量巨大或跨数据库协调成为瓶颈时,才考虑 Redis 或专门的调度服务。引入 Redis 锁并不会自动解决 Worker 宕机、业务幂等和数据库最终状态问题。

租约续期必须是有限的。Worker 如果一直续期但实际没有推进 Checkpoint,说明它可能陷入死循环、依赖阻塞或毒性数据;监控应区分“心跳正常”和“业务进度正常”。当 Checkpoint 长时间不动,系统应暂停续期、转移任务或进入人工诊断,而不是让租约永远有效。

11.5.6 Checkpoint、分片与断点续跑

Checkpoint 不是简单保存一个行号。它至少需要表达输入批次、分片、游标、最后成功对象、版本、处理统计和校验信息。对供应商分页接口,Checkpoint 可能是游标和上游快照版本;对对象存储文件,Checkpoint 可能是字节偏移加行号;对数据库扫描,Checkpoint 可能是稳定排序键而不是自增 ID。

分片应建立在稳定的分片键上。按 external_id 哈希可以保证同一个对象尽量落在同一分片,降低同对象并发冲突;按文件行号分片更简单,但重试时需要确保源文件不可变;按城市或供应商分片便于限流,但可能产生热点。分片数量应由单分片处理时间、数据库连接数、下游配额和恢复目标共同决定。

供应商同步的推荐阶段是:Sharder 生成分片,Fetcher 拉取原始数据,Transformer 标准化和校验,Diff Worker 计算变化,Publisher 写入正式商品或 Staging,Reconciler 检查期望与实际。每个阶段都有自己的输入和输出,不要让一个 Worker 同时承担网络拉取、复杂转换、数据库发布和下游通知,否则任何一步变慢都会让重试边界变得模糊。

11.5.7 重试、限流与 DLQ

重试只适用于暂态失败,例如网络超时、依赖短暂不可用、数据库死锁或消息系统返回可恢复错误。参数错误、schema 不兼容、权限不足、商品冲突和风险拒绝不能通过重试解决。AWS 的工程建议强调,重试应限制在合适的一层,使用指数退避和抖动,并设置最大次数或最大耗时,避免多层重试叠加形成重试风暴。[8]

每个任务执行器都应定义重试预算:最大尝试次数、最大总耗时、单次超时、退避算法、可重试错误码和超过预算后的动作。重试间隔可以使用截断指数退避加随机抖动,但不能把随机数当作恢复策略本身。对供应商 API,还要叠加供应商级限流和熔断;对内部数据库,要避免每个行任务独立无限重试。

DLQ 不是“失败数据垃圾桶”,而是带有处置策略的待治理集合。进入 DLQ 时要记录原始事件、失败阶段、错误类型、最后一次尝试、依赖响应、可重试截止时间和建议动作。DLQ 消费可以分为自动回放、修复后回放、人工确认后回放和永久关闭四类。回放前要重新检查版本和幂等键,不能把几天前的旧事件不加判断地重新写成当前事实。

11.5.8 任务项、批次和整体完成条件

任务和任务项分别记录业务意图与最小可恢复对象;任务项保存状态、尝试次数、错误、输入摘要、目标对象、处理版本和副作用摘要,才能安全地只重跑失败项。批次大小还要结合对象关联、下游配额和租户隔离,而不能只按固定行数切分。

完成条件必须区分解析、校验、Staging、正式发布、投影收敛和可售;正式商品已发布但投影或库存待追赶时可以返回 PARTIAL_SUCCESS。规则升级或下游修复需要重新评估时,应创建关联修复任务,保留历史结果,不改写原任务状态。

完成层级判定条件用户可见结果后续动作
接收完成原始文件持久化并生成任务返回任务 ID解析和安全检查
解析完成所有输入行有解析结果可下载行级错误标准化和校验
供给完成任务项进入 Staging 或明确拒绝显示审核状态QC、人工处理
发布完成正式商品版本和快照已提交商品存在但可能不可售投影和库存协同
交易准备完成商品、价格、库存和窗口通过校验允许下单交易前仍需再校验
全链路收敛规定投影和对账差异归零状态稳定归档任务和保留审计

11.5.9 任务 API、进度语义和用户体验

任务 API 的第一原则是让客户端能安全重试和查询。创建任务接口应接收幂等键、输入对象摘要、来源、优先级和预期范围,返回任务身份、受理状态和查询地址。查询任务时应返回总体计数、各状态计数、当前阶段、最近进度时间、估计信息的置信程度和下一步动作。不要在任务还没建立吞吐模型时返回一个看似精确的“剩余 37 秒”。

进度可以分成已接收、已解析、已校验、已发布和已收敛五类。每一类的分母不同,不能只显示一个百分比。例如文件有十万行,其中两万行无需变化,八万行中有五千行进入人工审核;如果只显示“处理 100%”,用户仍然不知道为什么可售商品数量没有增加。进度接口应同时返回总数、成功数、无变化数、失败数、人工数和等待下游数。

任务查询还要暴露版本和一致性边界。客户端看到 published_count = 1000,并不等于搜索已有一千条文档;接口可以返回 projection_pending = 143、inventory_pending = 27 和最近一次对账时间。这样前端能够展示“内容已发布,库存初始化中”,运营也能区分任务执行慢和下游投影慢。

错误报告应该支持机器读取和人工修复。机器字段包括错误码、规则 ID、字段路径、对象 ID、是否可重试和关联事件;人工字段包括解释、示例、建议值和修复入口。错误码应保持稳定,文案可以迭代。对于同一规则在一万行中重复出现的错误,可以按规则聚合并提供受影响行列表,避免生成无法使用的长文本。

11.5.10 任务平台的隔离和资源治理

任务平台通常同时服务人工即时任务、供应商增量同步、供应商全量同步、运营批量修改和修复任务。它们的优先级、可接受延迟和副作用不同,不能放进一个没有隔离的队列。至少需要按照租户或供应商、任务类型、优先级和下游资源设置配额;大任务要有最大并行分片数,修复任务要有独立的安全阈值。

限流可以在入口、调度器、Worker 和下游客户端多层实施,但每层的目的不同。入口限流保护 API 和文件服务,调度限流保护队列公平,Worker 限制数据库和 CPU,并发客户端限流保护供应商和库存服务。多层限流必须避免形成“每层都认为还能加一点”的乘法放大,平台应记录实际有效并发和等待原因。

任务优先级不能只看创建时间。订单相关的库存修复、影响可售状态的合规下架、供应商全量同步和低优先级搜索重建应该有不同等级;但高优先级不能无限插队,否则低优先级会饥饿。可以使用带老化的优先级、租户配额和每类任务的保底并发,让系统在高峰时仍然能推进非紧急任务。

Worker 的扩容依据应来自瓶颈指标,而不是队列长度一个数字。需要同时观察 CPU、内存、数据库连接、锁等待、下游响应、队列年龄、Checkpoint 停滞和 DLQ 增长。队列很长但数据库锁等待很高时,继续扩容只会恶化;队列不长但最老任务年龄持续上升时,可能是某类任务被错误限流或单个毒性任务阻塞了分片。

租户隔离还包括数据和审计隔离。任务查询不能让一个供应商看到另一个供应商的原始文件、错误行和外部编码;日志和指标需要带租户维度,但敏感值要脱敏;管理员的跨租户修复应记录授权依据。隔离不是只在 API 层加一个 supplier_id 条件,后台重试、DLQ 回放、导出报告和对账脚本也必须使用同一套访问边界。

11.5.11 取消、暂停和终止任务

任务一旦进入异步执行,取消就不是删除一条任务记录。取消请求需要说明范围:取消尚未领取的任务、停止新的任务项、终止正在运行的分片,还是撤销已经发布的业务事实。前三种属于任务控制,最后一种属于业务补偿,必须使用不同的命令和权限。

暂停适合供应商限流、规则临时下线、数据库维护和人工确认。暂停时要保存原因、暂停者、影响分片和恢复条件;Worker 不能只停止心跳后等待租约自然过期,否则会把可恢复状态伪装成故障。恢复时重新检查任务版本、输入版本和规则版本,必要时把仍未处理的任务项放回队列。

正在运行的任务项不能保证立即停止。Worker 可能已经调用外部供应商或写入本地事实,因此取消协议要有安全点:读取输入前、写入正式事实前、发送外部命令前和提交本地事务前。安全点发现取消后可以结束;已经越过安全点则记录“取消待确认”,完成查询确认或补偿后再把任务项置为终止。

批量任务取消后,成功任务项不应被回滚成失败,已发布商品也不应因为任务被取消而自动删除。任务最终状态可以是 CANCELLED_WITH_PARTIAL_SUCCESS,并列出成功、失败、未处理和未知数量。运营人员需要从结果中知道哪些对象已经产生业务影响,工程人员需要知道哪些对象还应由对账或修复任务处理。

动作允许对象是否产生业务副作用结果记录
暂停未完成任务通常没有暂停原因和恢复条件
取消未领取项队列中的任务项没有取消时间和操作者
停止运行项到达安全点的 Worker可能有未知外部结果终止状态和查询任务
撤销发布已发布版本有反向发布版本和审计
关闭异常DLQ 或人工队列项取决于业务确认关闭原因和责任人

这套语义能防止“取消任务”成为一个危险的万能按钮。用户界面也应区分停止接收、暂停处理、取消未开始对象和撤销正式变更,让操作人员在看到影响范围后再选择动作。

11.6 商品中心、库存与发布一致性

11.6.1 正式商品模型的写入边界

商品中心的正式写模型不应该成为所有输入的公共暂存区。它承载的是已经满足平台不变量、可以被交易前服务依赖的正式事实。因此,商品中心写侧至少需要区分三种操作:创建正式商品、发布已有商品的新版本、改变正式商品的生命周期状态。

创建正式商品要求输入拥有合法的资源归属、类目、商品族、规格组合、售卖计划和履约规则。发布已有商品要求输入带有基线版本,并且当前版本与基线一致。生命周期操作要求说明原因、操作者和适用范围,例如人工下架、合规封禁、销售期结束、供应商终止或资源不可用。三者可以使用同一个服务入口,但不能共享相同的校验路径。

写入动作主要校验事务内写入事务外动作
创建商品外部 ID 唯一、类目完整、SKU 组合合法Item、SPU、SKU、Offer、发布版本、快照、Outbox搜索、缓存、库存初始化
发布新版本基线版本匹配、QC 通过、字段主导权有效正式表变更、版本、快照、日志、Outbox下游投影、索引、计价刷新
下架商品权限、原因、当前状态、未完成交易策略Item 状态、状态日志、Outbox搜索剔除、缓存失效、运营通知
修正属性字段归属、影响范围、是否需要重审新版本或受控字段更新读模型刷新、对账

正式模型中应尽量保存结构化字段,而不是把全部内容塞进一个无法校验的 JSON。异构品类确实需要扩展字段,但扩展字段也要有 schema、类型、索引策略、敏感级别和演进规则。对于不会参与查询、排序、交易和合规判断的长尾展示字段,可以存放在扩展属性中;一旦某个字段开始影响库存、价格、履约或风控,就应该把它提升为有明确契约的领域字段。

11.6.2 类目模板和异构品类

平台商品经常同时包含酒店、门票、课程、数字权益和实物资源。它们共享标题、图片、类目、销售状态等通用能力,却在规格、库存、履约和退改上存在巨大差异。如果用一张宽表保存所有可能字段,表结构会被空值和兼容逻辑拖垮;如果每种品类完全独立,又会让搜索、运营和订单无法复用通用能力。

可以采用“通用核心模型 + 类目模板 + 受控扩展”的方式。核心模型保存身份、归属、状态、版本和通用展示字段;类目模板描述必填属性、属性类型、枚举、校验规则和可继承关系;扩展模型保存品类特有字段,但发布前必须经过模板校验。

字段层级示例适合的存储是否可参与核心决策
通用身份item_id、spu_id、类目、品牌关系表是
通用展示标题、短描述、主图、标签关系表或受控 JSON影响展示,不直接决定库存
类目属性房型面积、票种、课程时长模板化属性表可能影响筛选和履约
交易规则退改、预约窗口、使用限制结构化规则表是
供应商扩展外部房型编码、供应商描述来源扩展表由字段主导权决定
原始输入供应商原文、文件行Raw Snapshot不直接作为交易事实

模板版本必须进入发布版本和审核记录。若类目规则从“面积必须大于零”变为“面积必须大于五平方米”,历史已发布商品是否重新校验,取决于业务规则;但新发布版本必须使用新规则,且系统需要能回答某个商品在何时依据哪一版模板通过了审核。

11.6.3 库存配置和库存事实分离

库存配置描述“这个商品需要什么样的库存能力”,库存事实描述“现在到底还能卖多少”。例如,商品中心可以保存“该门票按日期和时段管理库存”“该数字商品使用券码池”“该房型由供应商实时确认房态”;库存系统则保存具体日期格、券码实例、预占记录和可售数量。

两者分离有三个好处。第一,商品编辑不会直接改写高频库存余额。第二,库存系统可以根据不同资源类型选择不同的扣减和恢复算法。第三,商品下架、库存耗尽和销售窗口结束可以分别解释,而不必把所有原因塞进一个 ONLINE 或 OFFLINE 状态。

库存类型库存单位典型操作关键不变量
数量制库存数量或可售额度增加、扣减、预占、确认、释放可售量不能为负,预占必须有过期机制
券码制库存单张券码或码批次导入、激活、锁定、发放、回收券码只能被一个订单成功消费
日期库存日期、房型、人数查询、预占、确认、释放同一资源格不能超卖
时段库存日期、时段、容量配额分配、冻结、释放时间窗和容量规则一致
外部确认库存供应商资源标识询价、锁定、确认、取消外部状态和本地状态可对账

商品发布可以触发库存初始化,但“发布成功”和“库存可售”不必是同一瞬间。对于需要库存才能交易的商品,Item 可以先处于 PUBLISHED,交易前可售判断为 NOT_READY;库存初始化成功并完成投影后,再进入 SELLABLE。对于无库存或按预约确认的商品,商品中心可以依据库存策略决定是否允许直接展示,但仍然要把资源校验放在交易前。

11.6.4 发布事务和快照

发布事务的目标是确保商品中心的本地事实具有一致解释,而不是让所有外部系统同时完成。一个合理的本地事务可以按照以下顺序执行:

  1. 锁定或读取 Draft / Staging 的确定版本。
  2. 检查当前正式商品版本是否等于基线版本。
  3. 校验 QC 结果、模板版本、字段主导权和必要的交易规则。
  4. 把差异合并到正式 Item、SPU、SKU、Offer 和规则表。
  5. 写入新的 Publish Version 和商品快照。
  6. 写入字段变更日志和操作审计。
  7. 写入带有聚合 ID、版本、事件 ID 和 schema 版本的 Outbox。
  8. 提交事务并返回正式版本。

数据库事务不应包含远程搜索、库存、计价或营销调用。InnoDB 的锁和事务模型可以保证本地行级更新和一致性读取,但长事务会扩大锁等待、死锁和回滚成本;MySQL 官方文档也建议应用准备好在死锁后重新提交事务,并保持事务短小、操作顺序一致。[11] 中文参考手册对隔离级别、锁和死锁处理提供了同一语义的说明,可作为排查生产问题时的本地化依据。[24]

START TRANSACTION;

SELECT publish_version
FROM product_item
WHERE item_id = :item_id
FOR UPDATE;

-- 应用层确认 current_version = :base_publish_version
UPDATE product_item
SET title = :title,
    lifecycle_state = 'PUBLISHED',
    publish_version = publish_version + 1,
    updated_at = CURRENT_TIMESTAMP
WHERE item_id = :item_id
  AND publish_version = :base_publish_version;

INSERT INTO product_publish_version (
    item_id, version, snapshot_id, source_staging_id
) VALUES (:item_id, :new_version, :snapshot_id, :staging_id);

INSERT INTO product_outbox (
    event_id, aggregate_id, aggregate_version, event_type, payload
) VALUES (:event_id, :item_id, :new_version, 'ItemPublished', :payload);

COMMIT;

如果条件更新影响行数为 0,服务应返回版本冲突,而不是继续写入。死锁或瞬时数据库错误可以重新执行整个事务,但必须复用同一个业务幂等键并重新读取当前版本。事务内不能生成依赖远程服务成功才成立的本地事实,否则重试时很难判断远程副作用是否已经发生。

11.6.5 版本、快照和下游事件

发布版本至少包含:item_id、逻辑版本、来源 Staging、变更摘要、模板版本、规则版本、操作者、发布时间和快照引用。事件中不一定携带完整商品数据,可以携带事件类型、聚合身份、版本和读取地址;但如果下游必须依赖当时的内容,事件应该携带不可变快照或可长期读取的版本地址,不能指向随时变化的 Draft。

事件消费者要区分“事实事件”和“刷新命令”。ItemPublished 是事实事件,表示商品中心已经提交了版本;RefreshSearchProjection 是刷新命令,表示某个投影需要执行动作。事实事件可以被多个下游消费,刷新命令通常由编排器根据当前落后版本生成。把二者混为一谈,会导致消费者把收到消息误判成已经完成下游动作。

在消息系统中,至少一次投递是常见现实。消费者要有去重策略:可以用事件 ID 建立消费记录,可以用聚合版本做条件更新,也可以使用二者组合。Kafka 的幂等生产和事务消息可以改善生产链路,但消费者仍需处理业务数据库写入、外部 API 调用和重复事件;不能把消息系统的“恰好一次”宣传成所有业务副作用都只执行一次。[12]

11.6.6 库存创建、补货和发布协同

库存任务不能被写成商品发布事务中的同步远程调用。推荐让商品中心在本地事务内记录库存配置和发布事件,库存系统根据配置创建库存资源。库存资源创建成功后,再产生 InventoryReady 或 InventoryInitialized 事件;商品可售投影根据商品状态、销售窗口和库存状态计算。

数量制库存的补货通常是一个独立命令。运营人员提交“增加 100 件”时,系统必须记录操作原因、操作者、来源任务和幂等键,并在库存系统内用版本或 CAS 更新。券码制库存则需要先上传原始批次、做重复码和格式校验,再激活可用券码。日期和时段库存需要按照资源格处理,不能把总量库存的加减模型直接套用。

库存协同要特别处理四种异常:商品发布成功但库存初始化失败,库存初始化成功但商品发布回滚,库存已经可售但商品被合规下架,商品版本已更新但库存配置尚未刷新。每一种异常都应有明确的可售结果、补偿动作和审计记录。

异常商品中心状态库存状态用户可见结果恢复动作
发布成功、库存初始化失败PUBLISHEDINIT_FAILED不可交易或显示暂不可售修复库存任务并重试
发布回滚、库存命令已发出旧版本可能已创建资源不允许新版本交易用版本事件撤销或标记孤儿资源
商品下架、已有预占OFFLINE有预占新订单拒绝,存量订单按策略处理释放未确认预占并通知履约
配置变更、旧库存仍在售新版本旧资源交易前校验重新确认版本化迁移或人工对账

11.6.7 下游系统的协同契约

商品中心与下游的契约要写清楚数据含义,而不只是列出接口名称。

搜索系统消费商品展示、类目和可检索属性,但不承载绝对库存和最终到手价。索引允许最终一致,必须通过版本过滤旧事件,并在 Hydrate 时依据商品中心、库存或计价结果修正结果。

计价系统消费商品的售卖计划、基础价格、币种、税费和规则引用,但最终金额依赖用户、时间、渠道、优惠和库存条件。商品中心不应把“当前价格”复制成所有交易的永久事实。

营销系统消费商品标签、圈品属性和资格引用,但预算、优惠叠加和活动库存由营销系统负责。商品发布可以触发圈品刷新,不能在商品本地事务中同步计算所有优惠。

订单系统在确认商品、库存和价格后,保存订单快照。订单服务可以引用商品中心版本以便追溯,但不能只保存 item_id 并期待未来能够复原当时的标题、规则和价格。

下游商品中心提供下游自己负责允许的陈旧
搜索展示字段、类目、属性、版本索引、召回和查询秒级到分钟级,需标示版本
计价售卖计划、规则引用、基础信息试算、优惠、币种和金额交易时必须重新计算
营销商品标签和资格上下文活动、预算、优惠和风控活动资格需按活动规则校验
库存库存配置、商品状态、资源引用余额、预占、确认、释放交易前必须读权威库存
订单商品版本和快照输入订单合同和历史快照历史订单不随当前商品变化

11.6.8 API 设计

商品中心和供给平台 API 可以分为 Command、Query 和 Event 三组。Command API 采用幂等键,Query API 返回版本和新鲜度,Event API 只描述已经提交的事实。

POST /v1/supply/drafts
POST /v1/supply/drafts/{draft_id}/submit
POST /v1/supply/stagings/{staging_id}/approve
POST /v1/items/{item_id}/publish
POST /v1/items/{item_id}/offline
POST /v1/inventory-tasks

GET  /v1/supply/tasks/{task_id}
GET  /v1/items/{item_id}?version=latest
GET  /v1/items/{item_id}/publish-status

发布 API 的响应应该明确是“已提交”“已发布”还是“已受理”。如果商品中心本地事务已提交,返回正式版本和事件 ID;如果请求只是创建异步任务,则返回任务 ID 和查询地址;如果下游仍在刷新,返回 projection_status = PENDING,而不是阻塞直到搜索、缓存和库存全部完成。

命令接口的幂等键需要绑定语义。相同 idempotency_key 和相同参数应返回同一业务结果;相同键但参数不同应拒绝。Stripe 的公开 API 文档采用保存首次结果、校验重复参数和在一定时间后清理键的做法,说明幂等层不仅要记录键,还要记录请求语义和结果生命周期。[10]

11.6.9 核心表模型与数据完整性

表模型的目标不是把所有对象都画成表,而是让关键不变量可以被数据库约束和应用逻辑共同保护。建议将表分为身份表、内容表、流程表、版本表、事件表、资源表和审计表。

身份表保存 resource_tab、product_item_tab、product_spu_tab、product_sku_tab 和 product_offer_tab 的稳定关系。身份表中的外部 ID、平台 ID 和租户键需要有明确的唯一约束;如果同一个供应商可以在不同渠道重复使用外部编码,唯一键就不能只放 external_id,而应放在 supplier_id + external_id + namespace 上。

内容表保存标题、描述、类目属性和规则引用。长文本可以拆到独立表,减少列表和状态更新对主表的影响;但拆分不能让读取正式商品时需要无边界地调用十几个服务。核心读模型可以在发布时物化必要字段,保留原始内容和规范化内容的关系。

流程表保存 draft_tab、staging_tab、qc_record_tab、task_tab、task_item_tab、change_request_tab 和 compensation_task_tab。流程表的状态字段应该配合状态原因、版本和操作人,不要只依赖数据库枚举。对高频 Task Item,可以按任务或创建时间分区、归档已完成数据,但归档仍应保留可追溯索引。

版本表保存 publish_version_tab、snapshot_tab 和 schema_version_tab。版本表必须能从正式 Item 找到当前版本,也能从任意订单快照找到当时的商品版本。快照可以使用 JSON 保存完整内容,但应同时保存 schema 版本、摘要和关键索引字段,避免历史展示只能解析一份已经失效的结构。

事件表保存 outbox_tab、consumer_inbox_tab、event_delivery_tab 和 dead_letter_tab。Outbox 记录业务服务产生了什么事件,Inbox 或消费记录表示某个消费者处理到哪里,Delivery 记录便于分析投递,DLQ 记录失败处置。不是所有消费者都需要独立 Inbox 表,但所有有副作用的消费者都需要可证明的去重依据。

表组关键唯一键关键外键 / 引用典型归档策略
身份表租户 + 命名空间 + 外部 IDResource、Item 关系长期保留
流程表Task ID、Item ID、版本Draft、Staging、QC完成后冷热分层
版本表Item ID + publish versionSnapshot、Staging线上版本长期保留摘要
事件表Event ID聚合 ID、版本成功事件按保留策略归档
库存配置Item ID + config version资源类型、规则随商品版本保留
审计表operation ID + sequence请求、操作者、对象合规周期内不可变

数据库约束和应用校验各有边界。唯一索引可以保证同一命名空间内不重复,但不能判断两个商品是否业务上重复;外键可以保证引用存在,但不能保证引用对象的状态允许交易;事务可以保证本地记录原子,但不能保证远程索引已经刷新。设计评审时要明确每个不变量由哪一层保护,避免把所有正确性责任放在应用代码的一个 if 上。

11.6.10 读模型、缓存和索引重建

读模型要围绕访问模式设计,而不是复制所有正式表。详情读模型需要快速返回商品核心信息、当前版本、销售窗口和规则摘要;列表读模型需要支持类目、品牌、属性、城市和价格区间筛选;搜索索引需要分词、排序、聚合和高亮;运营看板需要按任务、供应商、类目和时间聚合。四种读模型的字段集合和更新频率可以不同。

缓存失效有三种常见方式:写入后删除旧缓存,事件到达后刷新新缓存,按版本键保存不可变缓存。选择哪种方式取决于读模型是否容易重建和用户对陈旧的容忍度。高频详情可以使用 item_id + version 作为版本键,避免旧事件删除新缓存;如果使用固定键,也必须防止旧版本的延迟失效操作覆盖新版本。

索引更新需要考虑映射和全量重建。新增字段时,先确认索引 mapping 兼容性;改变字段类型时,不能只对当前文档执行更新,而需要建立新索引、按正式版本重建、抽样对比后再切换别名。索引重建期间,商品中心继续产生事件,重建任务需要记录开始版本和切换前的增量版本,切换后再补齐这段增量。

flowchart TD
    A[读取正式商品版本 V] --> B[建立新索引 alias-v2]
    B --> C[按快照批量重建]
    C --> D[记录重建进度与最大版本]
    D --> E[消费 V 之后的增量事件]
    E --> F[抽样校验字段和版本]
    F --> G{差异是否在允许范围}
    G -- 否 --> H[停止切换并生成差异报告]
    G -- 是 --> I[切换查询别名]
    I --> J[继续消费并对账]

读模型必须携带来源版本。用户看到搜索结果时,不一定要展示版本号,但日志和调试接口应能看到 projected_version。当用户从列表进入详情时,详情接口可以发现搜索版本已经落后,选择返回最新商品、标记提示或触发异步补偿。真正的交易动作仍然不能依赖搜索文档的可售字段。

11.6.11 事务、锁和并发更新的工程规则

商品发布和任务处理经常同时更新多个表。为了降低死锁,事务内访问表和行的顺序要保持一致,例如先锁定 Item,再写 Publish Version、Snapshot 和 Outbox;不能有的代码先锁 Outbox,再锁 Item,另一段代码反过来。MySQL 官方资料建议保持事务短小、以一致顺序访问资源、及时提交,并准备在死锁后重新执行整个事务。[11]

锁的范围必须和业务不变量匹配。保护一个商品版本时锁定 Item 行即可;保护同一个 SKU 的规格唯一性需要唯一索引和重试;生成库存预占时锁定资源格或使用原子条件更新;领取任务时锁定任务状态而不是锁住整个任务表。锁得过大降低吞吐,锁得过小则无法保护业务事实。

乐观并发控制适合编辑和发布:客户端带基线版本,服务器使用条件更新。如果冲突率低,乐观控制避免长时间持锁;如果某个热点资源冲突率很高,例如同一个库存格被大量订单竞争,就应使用库存域专门的原子扣减、队列化或分区策略,而不是让商品中心承担热点。

并发设计还要考虑读到的版本。一个发布事务刚提交,搜索消费者正在处理版本 9,运营查询可能读到版本 8 的缓存;这是最终一致允许的陈旧,但查询应能返回更新时间和状态。一个订单请求读到商品版本 9,库存预占已经基于资源版本 12,则订单必须记录两者的版本,方便事后解释,而不能把所有版本压缩成一个“商品已确认”布尔值。

11.6.12 事件 Schema 和兼容演进

事件字段分为身份字段、版本字段、时间字段、来源字段和业务载荷。身份字段包括 event_id、aggregate_id 和 operation_id;版本字段包括 aggregate_version 和 schema_version;时间字段包括发生时间和发送时间;来源字段包括服务、租户、供应商和追踪 ID;载荷字段则表达本事件对下游有用的事实。

事件升级时优先采用向后兼容方式:新增可选字段,保留旧字段语义,在一段时间内同时提供新旧字段;不要直接改变字段类型、单位和枚举含义。删除字段前要确认所有消费者已经迁移。对于必须改变语义的事件,创建新事件类型或新 schema 版本,不要让消费者根据服务发布时间猜测。

演进动作风险推荐处理
新增可选字段老消费者忽略字段直接兼容,但要验证序列化
新增必填字段老生产者无法提供先使用默认或新事件版本
改变字段类型解析失败或静默转换新字段 / 新事件类型
改变枚举含义消费者行为错误保留旧含义,新增枚举并通知
删除字段老消费者缺字段观察消费版本后再删除
拆分一个事件消费顺序和幂等复杂明确聚合版本和过渡窗口

事件契约测试应覆盖生产者序列化、消费者解析、版本兼容、重复投递、乱序投递和未知字段。OpenAPI 和 JSON Schema 能帮助团队共享结构定义,但业务事件仍需要说明状态、版本和副作用边界。[16][17]

11.6.13 商品生命周期与历史版本治理

商品生命周期不止包含上线和下线。一个正式商品还可能经历草稿、待审核、已发布、可售、暂停售卖、合规冻结、供应商终止、历史归档和永久删除等阶段。不同阶段的读写权限、库存动作、订单影响和恢复方式不同,不能用一个 active 布尔值代替。生命周期状态是用户和下游能理解的业务事实;任务状态、审核状态和投影状态则应分别保存。

生命周期状态新订单已有订单允许的写入可恢复方式
DRAFT不可见或仅内部可见无编辑内容、提交审核继续编辑或放弃
PUBLISHED取决于库存和窗口不影响受控编辑、投影刷新进入可售或回退版本
ONLINE允许交易前校验按订单合同执行规则化变更下线或暂停
OFFLINE拒绝新订单按存量策略处理仅允许恢复或修复审批后重新发布
BANNED拒绝新订单触发合规和客服流程限制性修复合规解除或永久终止
ENDED拒绝新订单仅履约和售后不允许改变销售事实仅在业务允许时重新建版本
ARCHIVED不可交易保留追溯只读查询和对账由新商品替代

状态迁移要区分主动动作和派生状态。运营人员可以发起下架命令,供应商终止可以触发待确认下线,库存耗尽可以派生 SOLD_OUT,销售窗口结束可以派生 NOT_IN_WINDOW。派生状态不应覆盖生命周期事实,否则库存恢复后无法知道商品是否仍然被运营下架。可以把可售性建模为多个条件的计算结果:生命周期允许、审核通过、库存可用、销售窗口有效、渠道资格满足。

版本治理还要处理“当前版本”和“有效版本”的差异。当前版本是商品中心最近提交的正式版本;有效版本是某个渠道、日期或灰度规则下对用户生效的版本;订单版本是结算确认时固化的版本。三个版本可能暂时不同,但每个差异都必须有原因和查询路径。用户投诉时,系统应能从订单快照回到当时有效版本,再回到生成该版本的 Staging、规则和来源输入。

历史版本不可变并不意味着永远不归档。线上查询只需要当前版本和有限历史,审计、订单和对账需要更长的保留窗口;版本归档可以把完整快照转移到低成本存储,同时保留摘要、哈希、版本号和可恢复地址。归档前必须验证快照可读、引用完整和恢复演练通过,否则只是把问题从数据库搬到了对象存储。

生命周期动作需要和通知策略解耦。下架命令先写入正式状态和 Outbox,再由通知、搜索、缓存、库存和客服投影分别处理;通知失败不能阻止合规下架,但必须进入待发送队列。对已经存在的订单,通知、库存释放和履约暂停可能需要不同时间和权限,不能把它们压缩成一个同步远程调用。

11.6.14 数据迁移、回放和重建

商品模型演进通常需要迁移旧数据、重建读模型和兼容旧事件。迁移应先定义目标不变量,再设计分批、可暂停、可重入的步骤。不要先写一次性脚本,再事后猜测它是否会覆盖人工修改。迁移记录至少包含迁移版本、批次范围、开始版本、影响对象、成功数、失败数、跳过数和回滚或补偿方法。

如果新模型需要把一张宽表拆成 Item、SKU、Offer 和库存配置,可以先建立只读映射,再用影子写入验证新旧结果,最后切换读路径。切换前要比较对象数量、关键字段摘要、状态分布和版本差异;切换后保留旧读路径一段观察期,但不允许新旧两套同时成为权威写入者。

读模型重建需要一个明确的截点。先记录事实面最大版本 V0,再从快照或事件重建到 V0,随后消费 V0 之后的增量,完成抽样校验后切换查询别名。重建任务自身也可能失败,因此要记录每个分片的起止版本和校验摘要,并允许从最后一个稳定 Checkpoint 继续,而不是每次从头开始。

历史事件回放不是重复执行历史副作用。搜索和看板可以根据历史事实重建;库存和订单这类有现实副作用的系统,不应把旧事件直接当成新的扣减或确认命令。回放框架要区分事件重放、投影重建、补偿命令和业务重演,并在消息头中标示回放原因和目标投影,避免普通消费者误处理。

迁移期间要保护人工编辑和新流量。可以按租户、供应商或对象范围分批迁移,对正在编辑的 Draft 使用基线版本检测,发现冲突就暂停该对象并生成差异。迁移失败的对象不能被悄悄跳过;需要有失败报告、重试上限和人工队列。迁移结束后用对账确认旧事实与新事实的对应关系,再删除已经不需要的中间结构。

11.7 质量治理、审核与运营闭环

11.7.1 从字段校验到可售质量

商品质量不是“必填字段都不为空”。一个商品即使通过了格式校验,也可能存在重复商品、类目错配、图片不合规、退改规则缺失、库存类型不匹配、供应商来源失信和价格异常。质量治理应该分层:结构质量保证数据能被系统理解,业务质量保证内容符合品类规则,交易质量保证商品能被正确售卖,运营质量保证异常可以被发现、解释和修复。

质量层检查对象典型规则失败处理
结构质量字段类型、编码、枚举、嵌套结构必填、长度、格式、引用存在行级错误,不进入后续阶段
语义质量类目、属性、规格和履约规则房型不能使用实物重量规则进入标准化或人工审核
合规质量文案、图片、敏感字段、供应商资质禁止词、许可证、来源可信度拒绝、冻结或人工复核
交易质量价格、库存、销售窗口、退改规则可售时段有资源、价格上下文完整保持不可售或回退旧版本
运行质量投影、事件、任务、对账版本收敛、无异常积压、可追溯自动补偿或人工接管

质量规则需要有版本。规则变更后,新任务使用新规则,历史审核记录仍然保留旧规则版本。对于规则影响范围较大的变更,平台可以进行离线重检,先生成问题清单,再决定是否阻止发布。不能因为新增一条校验规则,就在生产环境中无解释地把所有存量商品立刻下线。

11.7.2 标准化、校验和风险审核

标准化不是简单字段重命名,而是把不同来源的表达转换为平台语义。例如供应商把“含早”“早餐”“早餐包含”都表示为同一类服务,但是否真的等价要由来源契约和业务规则确认;“可退”“免费取消”“入住前一天可退”则不能只做字符串归一化,因为退改时间和费用会影响订单合同。

推荐把标准化拆为三个阶段:

  1. 词法标准化:编码、空白、时间格式、币种、单位和枚举别名转换。
  2. 结构标准化:把来源对象映射到平台对象,例如把一行文件转换为 Resource、Item、SKU、Offer 和库存配置候选。
  3. 语义标准化:根据类目、来源、规则和历史映射判断是否存在冲突、重复或需要人工确认。

校验结果要保留规则 ID、规则版本、字段路径、实际值摘要、错误级别和建议动作。错误级别可以分为阻断、警告和信息,但不能让所有警告都被忽略。对于批量任务,阻断错误应停留在行级;对于会造成系统性误售的错误,例如币种缺失、时间窗口反转或库存类型不合法,应提升为任务级失败。

风险审核关注的是“这次变化是否危险”,而不是“这条记录是否完整”。高风险变化包括大范围价格变化、核心类目迁移、退改规则放宽、供应商来源切换、批量下架、库存异常增加、同一外部对象映射到多个正式商品等。风险审核可以由规则引擎先筛选,再把命中的对象交给人工;但规则引擎的结果仍要保存,不能只留下最终的通过或拒绝。

11.7.3 QC 与发布的关系

QC 是对确定版本的判断。审核页面应显示 staging_id、内容版本、基线版本、来源、差异、规则版本和风险命中项。审核人点击通过后,系统保存的是“该版本通过了该规则集合”,而不是一个永远有效的 approved = true。

QC 与发布之间可能存在时间间隔。若 Staging 在审核通过后被修改,必须生成新的版本并重新审核;若商品中心当前正式版本已经发生变化,原审核结果不能自动适用于新的合并结果。只有当发布命令引用的内容版本与 QC 记录一致,且基线没有冲突时,商品中心才可以执行发布。

QC_PASSED(staging_id=S7, content_version=42)

publish(staging_id=S7, content_version=42, base_publish_version=8)

允许发布的前提:
1. S7 仍然存在且未过期;
2. S7 的内容版本仍然是 42;
3. QC 记录针对的正是版本 42;
4. 当前正式商品版本仍然是 8;
5. 发布者拥有该商品和该动作的权限;
6. 必要的库存配置、履约和合规信息完整。

如果商品中心当前版本已经是 9,服务应返回版本冲突并给出差异地址。运营人员可以选择以版本 9 为基线重新编辑,也可以在有明确字段合并规则时生成新的 Staging。不能把“审核通过”当作绕过并发保护的凭证。

11.7.4 字段主导权和冲突处理

多来源系统的冲突,不能靠最后写入时间解决。时间只能说明消息到达或写入顺序,不能说明哪个来源拥有业务主导权。平台需要建立字段主导权矩阵。

字段族商品中心供给平台供应商运营人员冲突策略
平台 Item 身份主导只引用不可写不可改平台生成
供应商外部编码保存映射可接收主导只读按来源和唯一约束校验
平台展示标题主导提交候选可提供候选可编辑运营编辑需留痕
供应商原始描述保存快照不改写主导可标注作为来源字段展示
类目与平台属性主导候选映射提供原始值可审核规则和人工确认
价格规则引用主导契约可申请变更提供来源价可发起任务计价系统计算金额
可售数量只保存配置发起库存命令提供外部量可补货库存系统权威
审核状态接受发布前提流程协调不可决定可审核QC 域推进

当供应商更新与人工编辑冲突时,系统可以选择拒绝供应商字段、生成待审核差异、合并无冲突字段或让供应商数据只进入 Raw Snapshot。每个选择都要说明用户体验和运营成本。静默覆盖看起来吞吐最高,但会损害可追溯性和人工信任。

11.7.5 权限、审计和人工接管

权限模型不应只有“运营管理员”和“普通用户”两个角色。至少要区分查看原始供应商数据、编辑 Draft、提交审核、批准 QC、执行发布、修改库存配置、重放 DLQ、强制下架和查看敏感字段等动作。高风险动作需要双人复核或操作原因。

审计记录不是业务日志的复制品。业务日志说明服务做了什么,审计记录说明谁以什么身份、基于哪份输入、在什么规则版本下、对哪个对象做了什么改变,以及结果是什么。审计记录应尽量不可变,包含前后摘要、关联任务、请求 ID、追踪 ID、客户端来源和人工备注。

人工接管必须有明确入口和退出条件。进入 WAITING_MANUAL 的任务要有队列、优先级、责任人、超时提醒和最终动作。人工操作不能直接改数据库状态,而应产生受审计的命令。命令成功后,系统回到正常状态机;如果人工确认“关闭此异常”,也要留下关闭理由和不再重试的依据。

11.7.6 灰度、回滚和版本恢复

商品内容的灰度发布可以按供应商、类目、城市、流量桶或内部用户进行,但灰度规则不能改变权威事实。商品中心仍然保存一个明确的发布版本,读模型根据灰度策略选择展示哪个版本。若灰度版本触发错误,回滚可以选择把读模型切回旧版本,也可以生成一个反向发布版本;直接删除新版本会破坏审计链和订单解释。

发布回滚和库存回滚不是同一个动作。商品标题或图片可以回滚到旧版本;库存预占和已确认订单不能简单回滚成旧数量。规则变更可以回退,但已经生成的订单合同和已发放的券码要按业务补偿处理。因此,回滚动作需要按对象分类:读模型回退、商品版本反向发布、库存冲正、订单补偿和供应商对账。

11.7.7 对账和补偿

对账不是定期跑一条 SQL,而是比较两个有明确语义的数据集。常见对账有:商品中心正式版本与搜索索引版本、商品可售状态与库存资源状态、供应商批次与平台映射、发布版本与 Outbox、Task Item 结果与正式商品变更、订单快照与下单时商品版本。

每种对账都要定义采样范围、比较键、允许的差异、发现后的修复方式和无法自动修复时的人工流程。

对账对象比较键允许差异自动修复
Item / Searchitem_id + version索引短暂落后重放事件或重建文档
Item / Inventoryitem_id + inventory config version未初始化期间不可售重试初始化或人工处理
Outbox / Event busevent_id发送延迟扫描 Outbox 重发
Supplier / Platform外部 ID + source version批次窗口内延迟拉取确认或生成差异
Task / Itemtask_id + item key行级处理中续租、重试或人工关闭
Order / Productsnapshot ID + publish version只允许引用关系差异不修改订单快照,补充解释

补偿不是把系统恢复到某个想象中的“正确状态”,而是把当前状态推进到符合不变量的新状态。例如搜索索引落后,可以重放当前商品版本;库存初始化失败,可以重新申请资源;错误的商品发布,需要生成反向变更并重新审核;供应商误删,需要先恢复来源映射,再决定是否重新上架。

11.7.8 可观测性:从技术日志到业务链路

OpenTelemetry 将遥测信号分为 traces、metrics 和 logs,并强调上下文传播和语义约定。[14] 中文概念文档对同一套信号和上下文传播提供了本地化说明,便于团队把规范落到实际字段。[23] 对本章场景,最重要的是让同一条商品生命周期能够被关联起来:supply_trace_id 串起供给流程,operation_id 标识一次业务动作,task_id 标识异步任务,item_id 标识正式资产,publish_version 标识事实版本,event_id 标识传播事件,order_id 标识后续交易。

一次批量导入的追踪不能只在 HTTP 请求结束时停止。它应把 Parser、Normalizer、QC、Publisher、Outbox、Search Projector 和 Inventory Worker 的关键阶段串成可查询的链路。由于一个任务可能包含十万行,不能为每一行都无限制采样完整 trace;可以对任务级、批次级、异常行和随机成功行采样,并用结构化日志保存行级摘要。

trace_id: supply-trace-20260921-001
operation_id: import-operation-17
task_id: task-20260921-0008
item_id: item-88421
publish_version: 19
event_id: event-9c2

stage=parser      duration_ms=420   result=success
stage=normalize   duration_ms=87    result=success
stage=qc          duration_ms=131   result=manual_review
stage=publisher   duration_ms=0     result=not_started

指标要能支持决策。task_oldest_age 上升时,平台要知道是入口洪峰、Worker 不足、数据库锁等待、供应商限流还是某类毒性任务;publish_to_search_lag 上升时,要知道 Outbox、消息消费、索引写入还是版本冲突造成;inventory_ready_lag 上升时,要知道初始化命令未受理、资源创建失败还是可售投影没有更新。

11.7.9 运营看板和稳定性治理

一个面向运营的看板至少包含:待处理任务、最老任务、失败率、部分成功任务、人工队列、供应商健康度、发布版本滞后、搜索与缓存差异、库存初始化异常和近期强制下架。一个面向工程的看板至少包含:数据库锁等待、事务回滚、Outbox 未发送、消费延迟、DLQ 增长、Worker 租约超时、依赖错误率、重试次数和对账修复时长。

两种看板要用同一组关联 ID 互相跳转。运营看见“某批任务 20% 失败”,可以定位到错误文件和规则;工程人员看见“搜索消费延迟”,可以跳到商品版本和受影响的业务类目。只有技术指标没有业务结果,或者只有业务结果没有处理上下文,都会导致故障定位依赖人工询问。

11.7.10 失败后的人工与自动边界

自动化适合处理重复、明确、可验证的动作:超时重试、Outbox 扫描、旧版本过滤、索引重建、无副作用的格式转换。人工适合处理语义不确定、影响范围大或需要业务判断的动作:供应商对象映射冲突、大批量下架、退改规则变更、跨版本合并和合规风险解除。

一个健康的平台不是把所有异常都自动化,而是让自动化在不确定时停下来,并把足够的信息交给人工。自动化越强,审计、模拟、回滚和权限边界越重要。

11.7.11 测试和演练策略

商品供给平台的测试不能只验证“接口返回 200”。它需要覆盖模型不变量、状态迁移、异步投递、版本冲突、部分成功和恢复路径。测试对象可以分为五层。

第一层是领域规则测试。验证类目模板、SKU 组合、退改规则、库存类型、字段主导权和发布前置条件。它们不依赖数据库和消息系统,应该快速覆盖大量边界。

第二层是状态机测试。对每个状态机列出允许迁移、禁止迁移、重复迁移、并发迁移和人工关闭。状态机测试要验证状态原因、操作者、版本和事件是否同步写入,而不是只验证最终枚举值。

第三层是事务和幂等测试。重复提交相同命令应返回同一结果;相同幂等键不同参数应拒绝;数据库死锁后重试不能生成第二个版本;事务提交成功而 Outbox 发布失败时,扫描任务能够补发;Outbox 发布成功而消费者超时时,重复消息不会产生第二次库存副作用。

第四层是契约和投影测试。生产者发布新 schema 后,旧消费者仍能处理兼容事件;搜索、缓存和库存消费者收到版本 3 后再收到版本 2,不得回退;索引重建期间的增量事件能够补齐;订单快照不依赖当前商品读取。

第五层是故障演练。主动注入 Worker 宕机、租约过期、数据库死锁、消息重复、消息乱序、供应商分页缺失、对象存储不可用、搜索 mapping 冲突、库存资源创建未知结果和 DLQ 堆积。演练目标不是证明系统永远不失败,而是测量发现、隔离、恢复和人工接管分别需要多长时间。

测试类型示例输入期待结果
规则单测缺类目、重复 SKU、时间窗口反转精确错误码和字段路径
状态机测试QC_PASSED 后修改 Staging必须生成新版本或拒绝
幂等测试同命令提交三次只生成一个正式版本
乱序测试版本 3 先于版本 2 到达投影停留在版本 3
部分成功测试2,000 行中 100 行冲突其余行正常完成,报告可下载
租约测试Worker 持有租约后宕机超时后由新 Worker 接管
对账测试搜索缺一个版本、库存少一个资源格差异被发现并进入修复
回滚演练新版本导致展示错误读模型可切回,订单不受破坏

测试数据还要包含时间、时区、重复外部 ID、来源版本回退、空列表和超大列表。供应商接口的模拟不能只返回“成功 JSON”,还要模拟连接超时、部分页、重复页、限流、错误码和返回顺序变化。只有这样,任务执行器的 Checkpoint 和幂等逻辑才会被真正验证。

11.7.12 数据质量反馈闭环

质量治理不是发布前的一道闸门。发布后仍要收集用户投诉、客服工单、订单取消、库存失败、搜索零结果、供应商对账差异和人工修复结果,把它们反馈到规则、模板和来源契约中。

例如,如果某类酒店商品经常因为退改规则不清导致退款,平台不能只在订单服务增加异常分支,还应回溯到商品模板和供应商映射,要求发布前补充可解释的取消窗口。如果某供应商总是提供过期库存,平台应降低它的同步优先级、增加交易前确认或进入人工审核,而不是让搜索继续展示“看起来有房”的结果。

质量问题表可以包含问题类型、发现渠道、对象、版本、来源、影响订单数、当前状态、根因、处置动作和规则修订。问题关闭前要确认修复已投影到受影响对象,而不是只把工单标记为完成。

11.7.13 版本化运营规则

运营规则本身也需要版本化。类目模板、审核规则、字段主导权、供应商可信等级、库存初始化策略和灰度配置都可能改变商品处理结果。若系统只保存当前配置,事后无法解释为什么同样的输入在昨天通过、今天被拒绝。

规则执行记录至少保存规则版本、执行时间、输入摘要、输出结果和执行器版本。规则发布可以采用配置灰度,先在影子模式下计算结果但不阻断发布,再比较误报和漏报,确认后逐步启用。高风险规则需要能够快速回退,但回退只影响新任务,不应自动改写已经完成的审计记录。

11.7.14 质量指标、抽样和规则校准

质量平台需要同时观察通过率和错误后果。通过率高可能意味着规则过松,也可能意味着上游输入变好了;驳回率高可能意味着供应商质量差,也可能意味着模板设计不适配。不能把“通过率越高越好”作为唯一目标。至少需要按来源、类目、规则版本和处理阶段分层统计,并把发布后的订单取消、投诉和库存失败回流到质量指标中。

指标观察问题不能单独说明什么需要联动的指标
结构校验通过率输入是否满足 schema业务内容是否正确语义错误率、人工驳回率
人工审核率自动规则覆盖程度审核结果是否准确误报率、审核时长
发布成功率正式写入是否稳定下游是否已收敛Outbox 年龄、投影滞后
可售转化率商品是否具备交易条件价格和用户体验是否正确库存初始化、订单失败
投诉率用户实际感知质量根因属于哪个字段订单快照、规则版本
规则误报率风险规则是否过严漏报风险有多大线上事故、人工复核

抽样要覆盖成功和失败两侧。只抽查错误数据会高估问题严重程度,只有成功样本则无法发现规则漏报。可以按供应商和类目做分层抽样,对高风险字段提高抽样比例,对低风险展示字段降低抽样比例;样本结论要记录到规则版本,避免规则升级后仍引用旧校准结果。

规则校准还要关注变化率。一个供应商每天新增一万条房型,其中九千条标题变化可能是正常的批量重命名,也可能是接口把语言字段错映射;单看变化数量无法判断。平台可以比较字段变化的分布、历史基线、关联订单和人工确认结果,形成“变化可信度”。可信度不是最终权威,但能帮助调度器决定自动发布、延迟发布或进入人工队列。

11.7.15 数据保留、脱敏和删除语义

商品供给链路同时包含供应商合同数据、原始文件、运营备注、用户订单引用和可能的敏感信息。数据保留不能只按表设置一个统一天数。正式商品版本、订单快照、审计记录、原始供应商输入、任务日志和临时错误文件的法律责任与恢复价值不同,应分别定义保留、归档、脱敏和删除策略。

原始文件需要在任务完成后从在线处理区迁移到受控归档区,并限制下载权限;日志只保留必要的字段摘要,避免把完整供应商响应和个人信息写进高复制率的日志系统;订单快照应满足合同和售后追溯需要,不能因为当前商品被删除就失去解释能力;当供应商要求删除其来源数据时,平台要区分可删除的原始副本、必须保留的交易证据和仅保留摘要的审计记录。

删除也应是有版本和审计的业务动作。直接物理删除 Item 可能破坏订单外键、对账映射和客服查询,因此更多情况下应使用下线、封禁、归档或不可售状态。真正需要删除时,要先计算影响范围,确认没有未完成交易和待处理任务,执行级联或保留替代引用,并记录删除操作本身。数据保留策略必须在架构设计阶段确定,而不是等存储成本升高后临时清理。

11.8 完整案例:商品从创建到可售运营

11.8.1 案例边界和输入

下面用一个假设的酒店平台走通完整链路。供应商提供一个酒店资源、两个房型、三个售卖计划和未来九十天的日期库存;运营人员希望把其中一个房型改成平台统一的展示名称,并补充“入住前一天可取消”的退改规则;搜索系统需要能够按城市、入住日期和房型标签召回;订单系统需要在下单时保存当日看到的商品和规则。

这个案例同时包含三个输入:第一次由供应商同步创建酒店资源和房型,第二次由运营人员编辑展示字段,第三次由库存运营人员补充未来日期的可售数量。它们不能互相覆盖,但必须最终形成一个能被搜索、详情和交易链路共同理解的商品。

假设输入的关键标识如下:

标识示例语义
supplier_idhotel-supplier-17外部来源主体
external_resource_idH-88921供应商侧酒店身份
external_room_idH-88921-R-03供应商侧房型身份
supply_trace_idsupply-20260921-001贯穿一次商品生命周期的跟踪身份
operation_idop-20260921-043一次创建、编辑或同步动作
item_iditem-88421平台正式商品身份
publish_version19平台正式版本
inventory_batch_idinv-batch-302库存初始化或补货批次

案例的完成条件不是“所有服务都返回成功”,而是以下不变量同时成立:

  1. 平台能够由 item_id 反查来源、草稿、审核、发布和库存任务。
  2. 当前正式 Item 对应一个明确的 publish_version,搜索和缓存不会接受更旧版本覆盖。
  3. 交易前能够依据入住日期、房型和人数检查权威库存,不能只信搜索结果。
  4. 订单保存自己的商品、退改和价格快照,后续商品编辑不改变历史解释。
  5. 任意一次失败都能定位到阶段、输入、责任主体和下一步恢复动作。

11.8.2 阶段一:接收供应商原始数据

供应商任务调度器先创建 sync_task,而不是直接调用商品中心的创建接口。任务包含供应商、拉取窗口、请求凭证引用、上一次成功 Checkpoint、全量或增量模式以及本次批次 ID。调度器根据供应商配额决定是否立即执行。

Fetcher 从供应商 API 取得房型对象后,把原始响应保存为不可变 Raw Snapshot。原始数据的用途是审计和重放,不直接用于展示。保存时计算内容摘要、记录响应时间、供应商版本、分页游标和协议状态;如果供应商接口返回部分页或响应签名校验失败,任务不能进入标准化阶段。

Raw Snapshot 不能只保存“最新值”。如果供应商在凌晨把房型名称从“高级大床”改成“豪华大床”,平台需要知道两次原始输入的差异,才能判断这是不是业务变更、重复推送还是接口回退。原始数据可以按保留策略归档,但至少要保留与正式版本和订单影响相关的窗口。

11.8.3 阶段二:标准化、映射和去重

Transformer 读取 Raw Snapshot,把供应商字段映射到平台中间模型。它需要完成单位转换、时区归一、币种标识、退改规则拆解、房型属性标准化和资源关系绑定。

对于房型 H-88921-R-03,平台可能得到以下中间模型:

{
  "resource": {
    "external_id": "H-88921",
    "resource_type": "hotel",
    "city_code": "SHA"
  },
  "product": {
    "external_id": "H-88921-R-03",
    "name": "高级大床",
    "occupancy": 2,
    "area_sqm": 28
  },
  "rate_plans": [
    {
      "external_id": "RP-03-FLEX",
      "cancel_policy": "before_checkin_day_1",
      "breakfast": true
    }
  ]
}

标准化后先计算对象指纹。指纹应覆盖会影响商品语义的字段,例如名称、入住人数、面积、退改、早餐和售卖计划;不应把供应商返回时间、分页顺序或无业务意义的展示字段纳入指纹。指纹不变的输入标记为 SKIPPED_NOOP,不触发商品中心发布和下游刷新。

去重需要同时使用来源身份和平台映射。相同供应商、相同外部 ID、相同来源版本的重复消息可以被安全跳过;不同来源 ID 映射到同一个平台 Item,需要检查人工映射和唯一约束;同一个外部 ID 被供应商重新分配给不同资源,则不能直接更新,必须进入冲突队列。

11.8.4 阶段三:生成 Draft、Staging 和 QC

对于新对象,供给平台创建 Draft,保存 supplier_id、外部 ID、原始快照引用、中间模型、规范化指纹和 supply_trace_id。Draft 还会加入平台默认类目、履约规则候选和库存类型候选。

规则引擎随后执行三类校验:

  • 类目和字段校验:房型必须有入住人数、面积、资源归属和售卖计划。
  • 交易和履约校验:退改规则、入住窗口、供应商确认方式和库存类型必须相容。
  • 风险和运营校验:标题不能命中禁用词,供应商资质有效,价格和库存变化在允许范围内。

如果校验只发现字段级错误,Task Item 进入 FAILED_VALIDATION 并生成错误明细;如果发现供应商批次全部使用了未知 schema,Task 进入任务级失败;如果发现一个房型映射到两个平台商品,进入 WAITING_MANUAL,不能让后续 Worker 继续猜测。

通过结构和语义校验的 Draft 被复制或冻结为 Staging。这里的“复制”不是复制所有大字段,而是创建一个指向不可变内容版本的引用。Staging 记录内容摘要、规则版本和基线版本,QC 记录针对这个确定版本生成。

审核员在 QC 页面看到:供应商原文、平台标准化结果、与已有 Item 的差异、字段主导权冲突、库存配置、退改规则和风险命中项。如果运营人员只想修改展示标题,可以在 Draft 层编辑并生成新的 Staging;如果修改入住人数或退改政策,必须重新走相应的风险规则。

11.8.5 阶段四:发布正式商品

QC 通过后,发布协调器生成 PublishCommand:

{
  "command_id": "cmd-publish-77",
  "idempotency_key": "supplier-17:H-88921-R-03:version-5",
  "staging_id": "staging-502",
  "content_version": 5,
  "item_id": null,
  "base_publish_version": 0,
  "operator_id": "system-supplier-sync",
  "reason": "supplier_initial_publish"
}

商品中心写侧先检查 staging_id 和 QC 结果,再决定创建新的 item_id。事务内写入 Resource 引用、Product Item、SPU、SKU、Offer、Rate Plan、发布版本、商品快照、变更日志和 Outbox。事务提交后返回 item_id = item-88421、publish_version = 1 和 event_id = event-9001。

发布成功不等于商品马上可售。此时商品中心的正式事实已经存在,但库存系统可能还没有创建日期资源。商品状态可以是 PUBLISHED,可售投影是 NOT_READY。如果产品允许展示不可预订的详情页,详情可以返回商品;如果产品要求只有有库存才展示,搜索投影暂时不收录。

11.8.6 阶段五:库存初始化和可售投影

库存协调器消费 ItemPublished,读取商品中心的库存配置。对于酒店房型,库存配置说明它按入住日期、房型和人数管理资源格;协调器创建未来九十天的资源格或向供应商申请可查询的外部资源引用。

初始化任务使用 inventory_batch_id,每个日期格有自己的 Task Item。某一天供应商返回超时,不应让九十天全部失败;该日期标记为 INIT_RETRYING,其他成功日期可以进入 AVAILABLE。可售投影计算的是“商品状态 + 销售窗口 + 资源状态”的组合,而不是简单复制库存数量。

sequenceDiagram
    participant O as 运营 / 供应商
    participant S as 供给平台
    participant P as 商品中心
    participant I as 库存系统
    participant X as 搜索投影
    participant T as 交易系统

    O->>S: 提交供应商对象或编辑
    S->>S: Raw Snapshot / 标准化 / QC
    S->>P: PublishCommand
    P->>P: 本地事务写 Item、Version、Snapshot、Outbox
    P-->>S: item_id + publish_version
    P-->>I: ItemPublished(version=1)
    I->>I: 创建日期资源与库存任务
    I-->>P: InventoryReady 或 InventoryInitFailed
    P-->>X: ItemPublished + InventoryReady
    X->>X: 版本检查与索引投影
    T->>P: 交易前商品校验
    T->>I: 交易前库存预占
    T->>T: 保存商品、价格和规则快照

11.8.7 阶段六:运营编辑与版本冲突

两天后,运营人员发现平台展示名称“高级大床”不符合统一话术,希望改成“城市景观大床房”。运营页面打开的是商品版本 1,生成 Draft draft-701,基线为 1。与此同时,供应商推送了新的面积和入住人数变更,商品中心发布版本 2。

运营提交时,商品中心发现 base_publish_version = 1,当前版本为 2,于是拒绝直接发布。供给平台给出差异:供应商修改了面积和入住人数,运营修改了展示标题。平台可以依据字段主导权进行安全合并,也可以要求运营确认版本 2。若标题和面积互不冲突,系统生成基于版本 2 的新 Staging;若双方都修改了退改规则,则必须人工选择。

这个过程看起来比“最后写入覆盖”复杂,但它保护了两个重要事实:运营人员确实是在旧版本上编辑的,供应商变更没有被静默抹掉。审计记录能解释最终版本 3 是如何由版本 2 和运营 Draft 合并而来。

11.8.8 阶段七:重复、超时和乱序

供应商没有收到第一次发布请求的响应,于是用相同的幂等键重试。商品中心发现 idempotency_key 已有成功结果,返回原来的 item_id 和版本,不再创建第二个 Item。若供应商用相同键发送了不同面积,服务返回参数冲突,要求供应商生成新的业务操作键。

随后,事件 ItemPublished(version=3) 因消息系统延迟先于 ItemPublished(version=2) 到达搜索系统。搜索投影先应用版本 3 并记录 projected_version = 3;版本 2 到达时,消费者判断它更旧,记录乱序指标后确认消息,不回滚索引。对账任务仍会比较商品中心版本和索引版本,确保不是消费者误判。

如果库存初始化请求超时,库存系统不能直接返回“没有库存”。它需要查询请求 ID 或幂等键对应的执行记录,判断远端是否已经创建资源。如果查询仍然不确定,任务进入 UNKNOWN 或 WAITING_CONFIRMATION,而不是立即重试一个可能产生重复资源的创建动作。AWS 的幂等 API 讨论也强调,网络超时造成的“结果未知”需要通过语义等价重试或查询确认处理。[7]

11.8.9 阶段八:批量编辑和部分成功

运营又上传了一个文件,希望给同一城市的 2,000 个房型统一补充“含早餐”标签。Parser 生成 2,000 个 Task Item,其中 1,850 个对象版本匹配,100 个对象已经被供应商更新,30 个对象无法映射,20 个对象命中需要人工复核的风险规则。

任务不能简单返回失败。1,850 个对象可以生成一个批量 Staging 并发布;100 个版本冲突对象进入差异队列;30 个映射失败对象进入错误文件;20 个高风险对象进入人工审核。运营看到的结果应该是:成功 1,850、冲突 100、数据错误 30、待审核 20,并能下载每一类的处理明细。

批量发布可以按批次提交,但每个对象仍然使用自己的基线版本和幂等键。批次级事件可以用于进度汇总,商品级事件用于下游投影。不能因为批量操作共用了一个 HTTP 请求,就把所有商品放进一个数据库长事务。

11.8.10 阶段九:下架、库存和订单

若供应商宣布该酒店房型终止销售,平台不会直接删除 Item。同步任务先生成下线候选,检查是否存在未来订单、未完成预占和仍有效的其他供应商 Offer。合规要求或供应商明确终止可以使 Item 进入 BANNED 或 OFFLINE,但订单系统需要继续读取历史快照,履约系统需要依据订单合同处理已有订单。

对未来没有订单但存在未使用券码的数字商品,下架也不能简单删除券码库存。系统需要决定券码是否还能兑换、是否需要停止发放、是否需要通知用户。商品生命周期和资源生命周期可以不同步,但必须通过策略明确连接。

11.8.11 阶段十:对账和最终收敛

案例结束后,对账任务抽样检查:

  1. 商品中心的 item-88421 当前版本是否等于 3。
  2. 搜索索引的 projected_version 是否等于 3 或处于允许滞后窗口。
  3. 库存系统的未来日期资源是否与库存配置版本相容。
  4. Outbox 的 event-9001 是否已发送并被关键消费者确认。
  5. 订单快照是否引用正确的商品版本和规则快照。
  6. 供应商最新来源版本是否已经映射到平台版本,还是处于冲突队列。

如果搜索版本落后,自动重放版本 3 事件;如果库存某个日期格缺失,重试对应 Task Item;如果供应商来源版本和平台人工版本冲突,保留人工版本并创建待确认差异;如果订单快照缺少字段,不能通过刷新当前商品修复,而应进入订单数据修复流程。

11.8.12 案例中的失败语义

完整链路中最重要的不是每一步都成功,而是每一步失败后仍然知道系统处于什么状态。

事件已完成事实未完成事实对外状态下一步
Draft 保存成功草稿可恢复未审核、未发布处理中提交审核
QC 通过指定版本通过规则未发布待发布生成发布命令
商品中心提交成功正式 Item、版本、Outbox 已存在下游未必完成已发布、投影待收敛事件消费
库存初始化部分成功部分日期资源可售其他日期未就绪部分可售或不可售重试失败格
搜索投影失败正式商品不受影响列表仍是旧版本搜索暂时陈旧重放或重建
供应商版本冲突人工版本仍权威外部变更未合并待人工确认差异合并
订单创建成功合同和快照已保存商品后续可变化订单有效按订单事实履约

如果系统能清楚表达这些状态,运营和工程团队就可以围绕事实协作;如果只能返回一个“失败”,所有恢复都将变成数据库探查和人工猜测。

11.8.13 从案例抽象出的通用方法

这个酒店案例可以迁移到其他供给业务。实物商品把日期资源换成数量库存,数字商品把房型换成券码批次,门票把供应商确认换成场次容量,课程把资源格换成开班名额。变化的是库存语义、履约规则和风控规则,不变的是:来源输入先进入控制面,确定版本后才进入事实面,跨域动作通过事件和补偿收敛,历史交易保存不可变快照。

因此,评审一个新的商品供给需求时,可以先问:它新增了哪种对象、哪个状态机、哪个事实源、哪类输入、哪条发布边界和哪种恢复动作。如果答案只是“加几个字段、加一个接口”,通常说明复杂度还没有被显式建模。

11.8.14 数字商品和实物商品的差异

为了检验模型是否真正通用,再把案例分别映射到数字商品和实物商品。

数字商品通常没有日期库存,而是券码批次或权益实例。发布商品时,商品中心可以先发布 Offer,库存系统异步导入券码批次。券码上传必须经过格式、重复、敏感信息和批次归属检查;券码进入 AVAILABLE 后才能分配给订单。订单确认后,券码实例进入 LOCKED 或 DELIVERED,退款或取消是否释放要由权益规则决定,不能套用数量库存的简单加回。

数字商品的供应商同步还要关注“码池已经发出但平台不知道”的未知结果。如果调用供应商激活接口超时,平台不能直接把券码标记为可用或已发放,而应使用请求 ID 查询。若供应商没有查询接口,必须建立对账文件或人工确认流程,并把未知状态与可用状态分开。

实物商品的库存事实通常按仓库、批次、可售状态和配送区域管理。商品中心负责 SKU、包装、尺寸、重量和履约规则,库存系统负责仓库可用量、锁定量、在途量和冻结量。商品编辑可以改变包装描述,但改变重量和配送区域可能影响计价、仓配和订单履约,应按字段主导权进入重新审核或灰度。

场景商品中心事实库存事实主要恢复动作
数字券码商品、权益规则、使用窗口券码实例和批次查询激活结果、对账码池
实物 SKU规格、包装、履约限制仓库、批次、可售和冻结量订单级补偿、库存冲正
酒店房型房型、Rate Plan、退改规则日期房态、供应商确认重新询价、日期格对账
门票场次场次、票种、入场规则场次容量、座位或配额释放锁定、重新分配

模型的通用部分是“商品配置和资源事实分离”,差异部分是库存单位、资源状态和恢复动作。设计新业务时,不要因为几个字段名称相同就复用全部库存代码;先确认资源的身份、可售条件、预占语义和最终履约动作。

11.8.15 批量任务的端到端容量推演

假设一个批量文件有 100,000 行,Parser 每秒解析 2,000 行,标准化每秒处理 1,000 行,QC 规则每秒处理 500 行,Publisher 每秒提交 200 个对象,搜索投影每秒处理 500 个事件,库存初始化每秒处理 100 个资源格。粗略串行耗时分别约为 50 秒、100 秒、200 秒、500 秒、200 秒和 1,000 秒;但真实总时长还会受到数据库批次提交、供应商限流、锁冲突和尾部重试影响。

这个推演说明两个问题。第一,最后的库存初始化可能是关键路径,而不是文件解析;第二,简单增加 Parser Worker 不能缩短端到端时间,必须观察每个阶段的队列和最慢批次。若 Publisher 是瓶颈,可以通过批量写入、按商品分区和独立连接池优化;若库存初始化受供应商配额限制,增加本地 Worker 反而会增加失败和重试。

批次大小也需要测量。批次过小会增加数据库提交、消息和日志开销;批次过大则会放大失败重试范围,增加锁持有和单次内存占用。可以根据对象大小、事务耗时和下游接口配额动态选择批次,并记录每批耗时和失败行数。一个批次成功后才推进 Checkpoint,不能在处理到一半时提前写入“已完成”。

参数过小的代价过大的代价需要观测
解析批次调度和提交开销高内存和错误重放范围大单批耗时、内存
数据库提交批次吞吐低锁持有时间长锁等待、回滚
消息批次网络调用多单条失败影响范围大发送延迟、重试量
并行 Worker资源利用不足下游限流和数据库争用队列深度、依赖错误
Checkpoint 间隔重启丢失进度多Checkpoint 写放大恢复时间、写入量

11.8.16 供应商全量缺失和安全下线

假设供应商全量接口在一次分页请求中返回空列表。平台不能马上把全部房型下线,因为空列表可能是接口故障、权限失效、过滤条件错误或供应商真的没有资源。系统应先检查响应状态、签名、批次完整性、分页数、数据量是否低于历史阈值和供应商是否发布了明确的全量结束标记。

如果批次完整但数量骤降,可以进入“疑似大规模变更”状态,要求供应商确认或进行第二次拉取。只有在确认来源可信、批次完整且业务策略允许时,才为缺失对象生成待下线集合。待下线对象仍然保留正式商品和历史映射,先停止新的可售投影;经过延迟窗口和订单检查后再推进正式下线。

这个流程看起来增加了延迟,却避免了把供应商短暂故障放大成平台级下架。对大规模变更,系统要有熔断和人工确认阈值,例如按供应商、城市、类目和日期分组比较变化率。阈值本身也要版本化,因为旺季和淡季的正常变更范围不同。

11.8.17 订单和商品并发变化

在用户浏览和下单之间,商品可能被编辑、下架,价格可能被计价系统重新计算,库存可能被其他订单预占。系统不能试图让所有页面和服务共享一个永远不变的商品状态,而应明确下单时的重新校验和快照边界。

一个典型下单链路是:购物车携带 item_id、商品版本和用户选择;结算读取商品当前可交易字段、计价结果和库存可售;订单服务在本地事务内写订单草稿、商品快照、价格快照和预占引用;库存确认后订单进入待支付或已确认状态。如果商品版本在结算期间变化,系统可以根据字段影响判断是否重新计算:标题变化可能不影响合同,退改、价格、库存规则变化则必须重新确认。

变化是否必须重新确认原因
展示标题变更通常不需要不改变交易金额和履约
主图变更视产品策略可能影响用户预期但不一定改变合同
价格规则变更需要金额发生变化
退改规则变更需要交易合同发生变化
房型规格变更需要用户购买对象发生变化
商品下架必须阻止新订单正式状态不允许新交易
某日期库存减少需要重新校验资源可能已经不可售

订单快照应保存“确认时采用的版本”,而不是只保存当前状态。这样客服可以回答“下单当时为什么显示可退”,履约可以回答“订单使用了哪个房型规则”,对账可以把订单金额与当时的价格上下文对应起来。

11.8.18 运营修复和系统自动修复的界面

自动修复适合没有业务歧义的场景。例如搜索索引落后、Outbox 未发送、事件重复、任务租约过期、数据库死锁、可验证的供应商临时超时。运营修复适合有业务判断的场景,例如两个商品是否重复、一个字段由谁主导、供应商缺失是否意味着下线、规则变更是否影响存量订单。

修复接口要把“观察”“模拟”“执行”分开。观察接口只列出差异和影响范围;模拟接口计算如果执行会修改哪些对象、触发哪些事件;执行接口需要权限、幂等键、原因和确认。大范围修复要支持预览和分批,不允许管理员点击一次按钮就同步修改百万条正式数据。

GET  /reconciliation/diffs?scope=supplier-17&batch=302
POST /reconciliation/simulations
POST /reconciliation/actions

simulation result:
- affected_items: 1842
- affected_inventory_resources: 5901
- events_to_emit: 1842
- orders_at_risk: 17
- manual_decisions_required: 23

修复完成后,要重新运行同一对账规则确认差异是否消失;不能只检查命令返回成功。对于无法自动收敛的差异,要进入人工队列并保持可见。修复动作本身也会产生事件和审计,但事件应带有 repair_operation_id,让下游知道这是补偿而不是新的供应商输入。

11.8.19 案例中的接口、事件和数据证据

把案例进一步落到接口契约,可以检查前面的模型是否真的能够实施。创建供给任务时,接口保存原始文件摘要、供应商、批次和幂等键;任务查询返回各阶段计数和最近 Checkpoint;提交审核时引用确定的 Staging 版本;发布时携带基线版本和发布原因;库存初始化时引用商品版本和库存配置版本。每个接口都只承诺自己掌握的边界。

{
  "operation_id": "op-20260921-043",
  "item_id": "item-88421",
  "base_publish_version": 18,
  "staging_version": 42,
  "idempotency_key": "publish-item-88421-v42",
  "reason": "supplier-sync-and-operator-review",
  "requested_by": "operator-17"
}

商品中心接受发布后,返回正式版本和本地事件身份;它不等待搜索和库存完成。事件可以包含以下证据:谁发起了操作、哪个正式版本发生了变化、源 Staging 是什么、规则版本是什么、事件由哪个服务产生。下游在处理时保存自己的投影版本和处理结果,任何一方都能通过 operation_id、item_id、publish_version 和 event_id 回到同一条生命周期链路。

证据产生位置解释的问题保留方式
原始输入摘要入口 / Raw Snapshot平台收到的是什么原始对象或摘要与地址
标准化结果Transformer来源如何映射到平台中间模型和错误
QC 记录审核域为什么允许或拒绝规则版本、审核人、结论
发布版本商品中心正式事实何时改变不可变版本和快照
Outbox 事件商品中心数据库事实是否产生传播任务事件记录和发送状态
投影版本搜索、缓存、库存下游是否追上消费记录和差异
订单快照订单系统用户确认了什么合同快照和价格规则

证据链要避免两个极端。只保存完整原始响应会造成存储和隐私压力,完全不保存原始输入又无法复盘来源错误;只保存当前商品会丢失历史,保存所有中间对象又会让查询和归档复杂。可以按风险和不可逆程度分层:正式版本、订单快照和审计记录长期保留;Raw Snapshot 在可解释和对账窗口内保留;临时解析结果在任务完成后转为摘要或归档。

11.8.20 案例的上线切分和回滚顺序

完整链路不应一次性切换。可以先让供应商数据进入 Raw Snapshot 和任务看板,观察解析错误和批次完整性;再开启标准化和 Diff,但不写正式商品;然后对一小批供应商开启 Draft、QC 和 Staging;确认版本、审计和人工队列稳定后,再开启商品发布;最后逐步接入库存初始化、搜索投影和交易前校验。每一步都要有停止条件和可回退的读路径。

阶段开启能力观察重点停止条件
影子接入原始数据、解析和错误报告批次完整、字段映射、敏感信息原始输入丢失或错误率异常
受控标准化中间模型和 Diff重复率、字段冲突、规则误报正式对象被错误覆盖
受控发布少量供应商进入商品中心版本冲突、Outbox、QC 队列正式版本无法解释或回放
投影接入搜索、缓存和营销消费事件版本滞后、乱序、重建能力旧版本覆盖新版本
交易接入库存和订单使用新契约交易前失败、快照完整、对账超卖或订单无法追溯

回滚顺序应从用户可见投影到事实写入逐步处理。发现搜索文案错误时,先切换读模型或停止错误版本的投影;发现商品正式版本错误时,创建反向发布版本并重新走审核;发现库存配置错误时,冻结受影响资源、停止新订单并按库存域规则冲正;发现订单合同问题时,进入客服和履约补偿流程,不能删除商品版本掩盖历史。

灰度期间要区分“新链路产生的事实”和“新链路展示的投影”。如果新链路只在影子模式计算结果,不能把影子结果写进正式事件;如果新链路已经写正式版本,旧链路就只能读取或处理明确分工的对象,不能同时写同一个字段。灰度结束前要关闭旧写入口或建立唯一主导权,否则两个版本会继续竞争。

11.9 方案决策与演进路线

11.9.1 ADR 的写法

商品平台的方案讨论经常被简化成“推荐使用某组件”。这种写法无法解释约束,也无法告诉后来者什么情况下应该重新评估。一个可复用的 ADR 至少要包含:背景与问题、决策驱动因素、候选方案、最终决策、获得的能力、主动牺牲的能力、已接受的风险、验证指标和重新评估条件。

下面统一使用一组维度比较方案:一致性、可用性、延迟、吞吐、成本、复杂度、可运维性、数据新鲜度、恢复能力和团队负担。表格负责横向比较,正文负责解释为什么在当前约束下做出选择。

11.9.2 ADR-1:Draft 放在哪里

背景与问题。 Draft 需要支持多入口写入、持续编辑、与正式版本做 Diff,并在发布前接受 QC。它应该放在供给平台,还是商品中心写侧?

方案获得的能力牺牲的能力主要风险
Draft 全部放供给平台流程隔离、供给任务易扩展Diff 和发布需要跨服务读取正式模型跨服务读风暴、审核版本不稳定
Draft 放商品中心写侧与正式模型和版本比较简单商品中心写流量增加商品中心需要承载更多流程治理
每个入口各自保存 Draft入口局部简单无统一生命周期和审计多份事实漂移、无法统一发布

决策。 采用商品中心写侧维护与正式模型强相关的 Draft / Staging,供给平台维护 Task、来源、审核队列和执行控制。供给平台通过命令 API 写入,不直连商品中心正式表。

获得的能力。 Diff 可以在同一事实边界内完成,发布版本和 Draft 的关系清楚,审核对象不会因为跨服务读取延迟而漂移。主动牺牲的能力。 商品中心写侧需要增加草稿表、版本管理和写入容量,初期部署边界不如纯供给平台方案简单。接受的风险。 商品中心可能被批量导入写流量冲击,因此必须通过分批、限流、独立连接池和冷热隔离治理。

验证指标。 草稿写入 p95、版本冲突率、商品中心数据库锁等待、批量任务对正式查询的影响、Draft 到 Staging 的平均耗时。重新评估条件。 供给写入量使商品中心正式读写无法隔离,或者 Draft 的生命周期已经完全独立于商品模型,需要单独扩展为写侧服务。

11.9.3 ADR-2:是否需要 Staging

背景与问题。 只有 Draft 加 Operation Log 是否足够?如果 Draft 提交审核后仍然允许修改,QC 审核的版本如何冻结?

方案一致性可用性复杂度恢复能力
只有 Draft审核对象容易漂移编辑体验简单低审核回放困难
Draft + 操作日志可重建部分历史依赖日志完整性中需要重放和重建
Draft + Staging + Item审核和发布版本明确编辑与审核可以并行较高版本回滚和审计清晰

决策。 对有人工审核、批量导入和供应商同步的商品平台,采用 Draft + Staging + Item。Draft 可以继续编辑,Staging 是审核和发布的冻结候选,Item 是正式线上资产。

获得的能力。 审核结果绑定明确内容版本,发布可以重复确认基线,编辑新草稿不会覆盖已经审核的候选。主动牺牲的能力。 存储对象增多,状态解释和清理策略更复杂。接受的风险。 Staging 可能过期并积累,需要设置保留时间、清理任务和审计归档。

验证指标。 Staging 过期率、审核通过后发布冲突率、审核对象被编辑的次数、因版本不一致重新审核的比例。重新评估条件。 所有入口都变成规则自动发布,且不再需要人工审核和差异回放时,可以缩减 Staging,但仍需保留不可变发布版本。

11.9.4 ADR-3:任务抢占使用数据库还是 Redis

背景与问题。 多个 Worker 需要竞争领取任务,应该使用数据库条件更新、Redis 分布式锁,还是专门的调度系统?

方案一致性吞吐运维成本主要风险
数据库 CAS与任务事实同源中等,适合大多数任务低高竞争时锁等待
Redis 锁领取延迟低高中锁丢失、业务状态仍在数据库
调度平台分片和编排能力强高高业务语义被外部系统隐藏

决策。 第一阶段使用数据库 CAS + 租约 + Checkpoint。Redis 只在测量证明任务领取成为瓶颈,且团队已经具备 Redis 高可用、锁续期、故障切换和一致性排查能力时引入。即使使用 Redis,最终任务状态仍以数据库为权威,不能把锁的存在当作任务成功。

获得的能力。 任务领取与状态更新在同一事实源内,宕机后租约过期即可恢复,部署简单。主动牺牲的能力。 极高并发领取时数据库会有竞争,任务调度吞吐受数据库写入能力约束。接受的风险。 热点任务可能形成锁等待,需要按优先级、分片和任务类型隔离。

验证指标。 领取冲突率、锁等待、租约过期数、Checkpoint 停滞时间、每秒可领取任务数。重新评估条件。 领取操作本身占用了明显数据库写入预算,且任务事实可以安全分离成独立调度存储时,再引入 Redis 或调度服务。

11.9.5 ADR-4:发布采用本地事务加 Outbox

背景与问题。 商品正式事实、发布事件、搜索、库存、计价和营销需要协同,是否使用跨服务分布式事务?

方案一致性可用性延迟失败恢复团队负担
跨服务长事务事务范围大依赖多、可用性低高回滚复杂高
同步 RPC 链局部即时下游失败阻塞上游不稳定补偿边界模糊中高
本地事务 + Outbox + 版本事件本地强、跨域最终较高可控重试、DLQ、对账中
事件溯源全量模型审计和重放强外部交互复杂需构建投影回放和版本迁移复杂高

决策。 商品中心内部使用本地事务写正式事实、发布版本、快照、日志和 Outbox;跨域通过版本化事件、幂等消费者和对账收敛。仅在有充分重放价值的局部领域考虑 Event Sourcing,不把所有商品表改造成事件存储。

获得的能力。 商品中心写入和事件待发送原子,搜索、库存和营销可独立扩展,失败可以按事件重试。主动牺牲的能力。 跨域不会瞬时一致,用户可能短时间看到旧索引或不可售提示;运维需要维护 DLQ 和对账。接受的风险。 事件积压或消费者缺陷会造成版本滞后,必须有新鲜度预算和降级策略。

验证指标。 Outbox 未发送年龄、事件端到端延迟、消费者版本滞后、重复事件率、DLQ 处置时长、对账差异数量。重新评估条件。 业务有明确的跨域原子约束且能承担全局事务代价,或某个领域需要完整事件重放,才重新讨论更强事务模型。

11.9.6 ADR-5:供应商无效更新过滤放在哪里

背景与问题。 供应商只提供全量列表,如何识别重复、无效、乱序和应该下线的对象?过滤放供给侧还是商品中心?

决策。 来源相关的去重、指纹、批次完整性、供应商版本比较和失效候选识别放在供给侧;平台正式身份、基线版本、字段主导权和发布不变量仍由商品中心确认。供给侧可以拒绝明显无效输入,但不能越权修改正式商品状态。

获得的能力。 商品中心不被供应商重复流量打爆,来源策略可以按供应商隔离,原始数据和差异更容易审计。主动牺牲的能力。 供给侧需要维护来源适配和映射,新增供应商的接入成本上升。接受的风险。 供给侧过滤规则错误可能漏掉合法变化,因此要提供回放、抽样和对账。

11.9.7 ADR-6:订单快照在何时固化

背景与问题。 商品中心是否提前生成所有订单快照,还是订单中心在下单时生成?

决策。 商品中心负责发布版本和可供交易读取的快照来源,订单中心在确认商品、价格、库存和营销结果后,在本地订单事务内固化订单快照。订单中心不能只保存商品 ID,也不能依赖商品中心之后仍然保留原始内容。

获得的能力。 订单合同与商品后续编辑隔离,订单可独立展示、退款和客服解释。主动牺牲的能力。 同一商品内容会在商品中心和订单中心各保存一份,存储和字段演进需要治理。接受的风险。 快照字段定义变化可能导致历史订单兼容问题,因此必须有快照 schema 版本和展示降级策略。

11.9.8 ADR-7:库存配置与库存事实分离

背景与问题。 商品中心已经知道商品的库存类型和规则,是否把可售余额一起写在商品表中?

决策。 商品中心保存库存配置、库存资源引用和交易前契约;库存系统保存余额、预占、确认、释放、券码实例和资源格。商品发布可以发起初始化,但可售状态由商品、库存和销售窗口联合计算。

获得的能力。 高频库存写入不拖累商品主数据,库存算法可以按资源类型演进,预占和释放语义清晰。主动牺牲的能力。 详情读取需要跨域聚合,商品发布到可售存在延迟。接受的风险。 商品和库存可能短暂不一致,需要交易前权威校验、版本关联和对账。

11.9.9 ADR-8:同步体验和异步执行如何分层

背景与问题。 用户希望提交后立即知道结果,但批量和供应商同步无法在一个请求中完成。是否让所有接口同步等待?

决策。 同步接口只负责参数校验、幂等受理和短事务保存;异步任务负责解析、标准化、审核、发布、下游投影和恢复。查询接口返回任务状态、进度、错误文件和下一步动作。

获得的能力。 请求延迟可控,长任务可恢复,失败可以分阶段定位。主动牺牲的能力。 用户需要理解处理中、部分成功和待人工等状态,产品需要提供进度和通知体验。接受的风险。 异步系统更容易产生积压和状态不透明,因此必须提供任务看板和最老任务告警。

11.9.10 演进路线

建议采用四阶段演进,而不是一开始实现所有复杂能力。

第一阶段:单体或少量服务的可靠闭环。 先实现商品中心正式模型、Draft、版本、Outbox、基本任务表、人工创建、单品发布和库存配置。任务领取使用数据库 CAS,搜索和缓存通过简单消费者刷新,所有关键动作有审计和基本对账。

第二阶段:批量和人工治理。 增加文件对象存储、Parser Worker、Task Item、错误文件、QC 队列、人工接管、部分成功和失败重试。此时重点不是增加服务数量,而是把批量任务从 Web 请求中移出,并让运营能解释每一行结果。

第三阶段:供应商同步和多阶段流水线。 引入 Raw Snapshot、来源版本、指纹、Diff、分片、Checkpoint、租约、供应商限流和全量对账。只有当同步窗口或数据规模成为瓶颈时,再把 Fetcher、Transformer、Publisher 拆成独立 Worker。

第四阶段:大规模投影和治理平台。 依据真实指标引入事件总线、CDC、独立搜索重建、库存资源编排、数据质量平台和统一可观测性。此阶段可以考虑 Redis 抢占、复杂分片或局部事件溯源,但每一步都要有重新评估条件和回滚方式。

阶段重点能力不急于引入退出条件
一正式事实、版本、Outbox、基本任务多套调度平台、全量事件溯源单品和小批任务稳定
二批量、QC、行级错误、人工接管复杂跨供应商编排批量结果可追溯、可恢复
三来源治理、分片、Checkpoint、对账过度拆分服务同步窗口和恢复指标达到目标
四大规模事件投影、治理平台没有指标支持的复杂化规模、成本或组织边界要求

演进的判断标准不是“当前用了多少组件”,而是“当前最大的未解决不变量是什么”。如果问题是版本冲突,先做基线版本和条件更新;如果问题是任务无法恢复,先做 Checkpoint 和租约;如果问题是下游版本滞后,先做事件 ID、版本过滤和对账;如果问题是供应商流量超过数据库能力,再讨论分片、队列和独立存储。

11.9.11 数据迁移和兼容策略

把旧商品系统迁移到新的商品中心和供给平台,不能只做一次全量 INSERT SELECT。迁移首先要建立身份映射:旧商品 ID、供应商外部 ID、旧 SKU、旧库存配置和旧订单引用之间的关系必须被保存。没有映射表,后续订单、客服和供应商对账都无法追溯。

迁移可以分为四个阶段。

第一阶段是只读盘点。扫描旧表中的重复商品、缺失类目、无效库存配置、过期状态、供应商映射冲突和没有订单引用的孤儿数据。盘点结果不应直接修复数据,而应形成可审计的迁移问题清单。

第二阶段是双模型映射。为旧商品生成新模型的候选 Resource、Item、SKU、Offer 和规则对象,保留旧字段原文和转换结果。转换过程需要幂等,可以在失败后重复运行;不应把迁移脚本写成只能执行一次的长事务。

第三阶段是影子投影。新模型接收旧系统变更或历史快照,但暂时不成为交易权威。比较旧读模型和新读模型的标题、类目、可售状态、价格引用和库存配置,差异进入对账队列。对于允许差异的字段,如排序标签或搜索分词,可以建立容忍规则;对于交易字段,必须人工确认。

第四阶段是分域切换。先切换详情查询,再切换搜索投影,然后切换供给写入口,最后切换交易前校验。每次切换都要有回滚开关、双读对比、版本指标和明确的停止条件。不要把所有领域在同一个发布窗口中切换,否则出现差异时无法判断是商品、库存、搜索还是订单适配造成。

迁移对象迁移策略关键校验回滚方式
商品主数据映射表 + 影子写入身份唯一、字段完整、版本可追溯读流量切回旧模型
供给历史原始快照 + 任务结果归档来源和操作人不丢失只读旧审计记录
库存配置先迁配置、后迁事实商品与资源类型相容交易前继续读旧库存
搜索投影新模型批量重建文档数、版本和关键字段对比索引别名切回旧版本
订单快照保持订单本地权威订单展示和履约字段可读不修改已成立订单

迁移还要考虑事件兼容。旧系统事件可能没有 publish_version、schema_version 或 event_id,新消费者不能假设所有历史事件都具备完整字段。可以为历史事件分配迁移批次版本,或者只把历史数据作为快照导入,不重新触发所有外部副作用。重放历史事件时必须区分“构建读模型”和“再次执行业务动作”,例如重建搜索文档可以重放,重新扣库存和再次通知供应商则通常不可以重放。

11.9.12 多租户、供应商隔离与成本

当平台服务多个品牌、渠道或供应商时,隔离不仅是表里增加 tenant_id。身份、权限、配额、字段主导权、事件主题、文件存储、审计和数据保留都可能需要租户边界。一个供应商的异常批量任务不能消耗掉所有 Worker,也不能读取另一个供应商的原始文件。

隔离方式可以从逻辑隔离逐步演进到资源隔离。小规模时可以在所有核心表增加租户键并建立组合索引,任务调度按租户配额限制;规模增大后,可以按租户分队列、分数据库或分对象存储前缀;对于合规要求高的租户,再使用独立密钥、独立网络和独立保留策略。

隔离对象轻量方案加强方案触发条件
数据查询租户键和权限中间件独立 schema 或数据库数据量、合规或噪声隔离
任务执行租户配额、加权队列租户专属 Worker 池大客户任务影响公共队列
原始文件加密对象前缀独立桶、密钥和访问角色敏感字段或法规要求
事件传播公共主题带租户字段租户专属主题消费隔离和保密要求
审计统一审计表独立保留和查询域审计量或访问隔离

成本治理也要进入架构决策。每个商品的事件数量、搜索文档大小、原始文件保留时间、审计记录保留时间、索引重建频率和任务重试次数都会产生存储和计算成本。不能只看数据库单价,而要看“一个业务变更经过多少次序列化、消息传播、索引写入和历史保留”。

成本预算可以采用单位经济模型:每百万次商品变更的数据库写入量、事件数量、索引更新量、缓存失效量、任务执行时间和人工处理分钟数。若供应商每天重复发送 80% 未变化数据,最有效的优化可能不是扩容 Kafka,而是在来源侧计算指纹和使用批次确认;若搜索文档过大,最有效的优化可能是减少冗余字段和把高频字段转为 Hydrate,而不是继续扩展索引节点。

11.9.13 组织边界和职责交接

架构边界只有在组织责任匹配时才会稳定。商品中心团队应负责正式模型、发布事务和商品查询契约;供给平台团队负责入口、任务、审核和运营工具;库存团队负责库存事实和资源生命周期;搜索团队负责索引和查询投影;订单团队负责交易快照和订单合同。跨团队的事件 schema、版本语义和故障升级路径必须有共同维护者。

职责交接要落到具体动作:谁批准字段新增,谁维护模板版本,谁拥有事件 schema,谁确认 DLQ 的业务含义,谁决定人工修复,谁承担对账差异的最终关闭。否则系统虽然拥有多个服务,却没有任何人对端到端结果负责。

责任对象负责的决定参与的决定不应承担
商品中心正式模型、发布版本、商品查询类目模板、库存配置文件解析、库存余额
供给平台入口、任务、审核流程发布命令、字段主导权绕过商品中心写事实
库存系统余额、预占、确认、释放可售投影商品描述和订单快照
搜索系统索引、召回、查询字段是否可检索正式商品状态
订单系统订单合同、交易快照交易前读取契约修改商品历史
平台治理规则、权限、审计、指标高风险变更代替业务系统推进所有状态

11.9.14 重新评估而不是永久承诺

任何架构决策都不是永久真理。重新评估条件应该在设计完成时就写出,而不是发生故障后临时争论。可以按以下周期检查:

  • 每周查看最老任务、DLQ、版本滞后和对账差异。
  • 每月复盘供应商有效更新比例、重复输入比例、人工接管时间和任务单位成本。
  • 每次大促前验证发布、库存初始化、索引刷新和交易前校验的峰值行为。
  • 每次 schema、状态机或字段主导权变化前,检查历史版本和下游兼容性。
  • 每季度进行一次任务 Worker 宕机、消息重复、数据库死锁、供应商超时和索引重建演练。

当指标越过阈值时,团队需要重新检查的是模型和边界,而不只是扩容。队列变长可能意味着上游重复输入;发布延迟可能意味着审核对象过大;库存不一致可能意味着商品配置和库存事实边界不清;搜索差异可能意味着事件版本设计错误。只有先找到不变量被破坏的原因,组件升级才不会变成临时止痛药。

11.9.15 成本、复杂度和组织能力的约束

架构决策不只比较吞吐和延迟,还要比较长期运维成本。商品供给平台的成本来源包括数据库存储、原始文件和快照保留、消息与事件流、搜索索引、任务 Worker、供应商 API 配额、人工审核、故障演练和团队认知负担。一个功能如果把一次性人工处理减少十分钟,却引入三套需要全年值守的中间件,未必是更好的方案。

可以用单位业务对象计算成本:每成功发布一个商品需要多少数据库写入、消息数、索引更新、库存初始化调用、人工审核分钟数和归档空间。成本模型不必一开始就精确到财务结算,但要能发现数量级变化。例如把完整商品快照塞进每一条事件,会同时放大消息带宽、消费者内存、日志体积和重放成本;如果下游只需要版本和读取地址,携带完整载荷就可能是无谓负担。

选择获得的能力主动牺牲隐含成本适用前提
单体模块 + 数据库任务表简单事务、低部署成本跨模块独立扩容代码边界和锁竞争规模中小、团队少
独立任务平台异步隔离、弹性扩展调试链路变长队列、租约、观测和运维长任务多、依赖多
CDC + Outbox本地事实与事件一致依赖数据库日志和发布链路CDC 运维、schema 管理事件传播稳定、可重放
事件总线多下游解耦、重放即时同步和全局事务分区、顺序、消费治理下游较多且有事件能力
外部调度服务高并发领取和分片本地简单性额外状态和故障域任务量证明了调度瓶颈

团队能力也是约束。若团队没有事件版本治理、分布式追踪和故障演练经验,直接引入大量异步边界可能把可恢复问题变成不可解释问题。可以先采用数据库任务表、本地事务、明确的 Outbox 扫描和少量读模型,等指标证明需要独立队列、CDC 或专门调度器后再演进。这个过程不是保守,而是让复杂度与问题规模相匹配。

复杂度预算应写进 ADR。每增加一种状态机、消息主题、存储、重试层或人工入口,就要说明它解决哪个已观察到的问题、增加什么故障域、谁维护、如何测试和何时删除。若没有明确收益,优先复用已有边界。反过来,如果现有单表和同步调用已经无法表达版本、失败和历史合同,也不能为了少部署一个组件而继续堆叠补丁。

上线后要用实际指标校正假设。设计时可以假设每日一千万行输入、投影延迟一分钟或人工审核率百分之五,但这些只是模型参数;上线后需要比较真实分布、峰值、尾延迟、失败类型和单位成本。如果实际瓶颈不是当初预期的地方,团队应修改 ADR 和演进路线,而不是为了证明原方案正确而继续优化错误的组件。

11.9.16 从最小闭环到完整平台

如果团队从零开始建设商品供给能力,最小闭环不应包含所有品类和所有下游。第一阶段只选择一个来源、一个品类和一条发布链路,完成原始输入、Draft、Staging、QC、正式版本、Outbox、一个读模型和一个交易前校验。目标是验证事实边界和恢复路径,而不是通过一次发布证明平台已经通用。

第二阶段增加第二种来源和批量任务,重点验证字段主导权、重复输入、部分成功、租约接管和供应商限流。此时可以把任务项、Checkpoint、DLQ 和对账做成可观察能力。若没有这一步,平台很容易只在人工少量编辑时看起来可靠,一遇到十万行文件就暴露状态和恢复缺口。

第三阶段增加库存资源类型、订单快照和灰度发布,验证商品版本与资源事实的协同。此时要明确哪些变更影响交易合同,哪些变更只影响展示;库存初始化失败、商品下架和订单并发变化都要有可执行的补偿。不要等到接入真实大促后才第一次演练这些分支。

第四阶段才根据数量级引入独立调度器、CDC、事件分区、索引重建平台和多租户资源隔离。每一步引入新组件都要保留旧能力的可验证替代路径,至少在迁移窗口内能够比较新旧结果。平台化不是把所有功能一次性抽象出来,而是让已经被多个真实场景证明的共同不变量成为公共能力。

阶段核心目标必须验证暂不追求
单来源闭环事实和发布正确版本、事务、Outbox、快照多品类通用
批量与多来源任务可恢复分片、租约、主导权、对账极致吞吐
资源与交易不误售且可解释库存、价格、订单快照、下线全部营销能力
平台化扩展多租户和弹性配额、成本、重建、演练无边界统一抽象

每个阶段都要设“停止扩展”的条件。例如正式商品版本仍不能稳定回放时,不应继续增加更多下游;任务失败原因仍只有一段文本时,不应继续扩大批量规模;订单快照缺字段时,不应先优化搜索召回。这样的阶段门把复杂度增长和事实可靠性绑定起来,也让团队能够在需求变化时解释为什么暂缓某项能力。

11.10 方法论总结

11.10.1 五句判断句

第一句:先问谁拥有事实,再问使用什么组件。 商品中心、供给平台、库存系统和订单系统可以部署在同一个进程,也可以拆成多个服务;如果没有事实源和写入边界,服务数量不会带来架构清晰度。

第二句:把流程状态和业务事实分开。 Draft、QC、Task 和 Outbox 的状态变化说明流程走到哪里,Item、库存余额和订单快照说明业务事实是什么。一个字段不能同时表达“正在审核”和“对消费者可见”。

第三句:把版本放在所有异步边界上。 消息 ID 用于去重,聚合版本用于拒绝乱序,schema 版本用于解析兼容,任务版本用于保护租约,订单快照版本用于解释历史。没有版本的异步系统只能依赖到达时间猜测新旧。

第四句:同步接口只承诺本地边界。 一个请求可以确认参数合法、草稿已保存、任务已受理或商品中心事务已提交,但不应在没有证据时声称搜索、库存、营销和缓存都已完成。

第五句:恢复能力是设计的一部分。 超时、重复、乱序、部分成功、数据库死锁、供应商缺页、索引拒绝和人工误操作都会发生。设计中如果没有错误分类、重试预算、DLQ、对账和人工接管,所谓高可用只覆盖了正常链路。

11.10.2 选型表:问题驱动而不是组件驱动

需要解决的问题首选能力何时增加复杂度不能解决的问题
防止重复提交业务幂等键、唯一约束、结果记录多客户端语义复杂时增加幂等服务业务参数本身不确定
处理长任务Task、Task Item、Checkpoint、租约领取吞吐成为瓶颈时分片或专用调度业务规则错误
保证写入和事件一致本地事务 + Outbox事件量和 CDC 压力达到阈值时优化发布链路消费者业务幂等
防止旧数据覆盖新数据基线版本、聚合版本、条件更新多来源字段冲突时引入主导权矩阵人工无法判断的语义冲突
提升查询速度读模型、缓存、索引访问模式稳定且成本可控时物化更多字段交易时实时事实
缩短批量处理窗口流式解析、批次、并行 Worker任务规模和供应商配额证明需要分片时上游接口无法提供稳定游标
支持回放和审计不可变快照、变更日志、事件需要重建投影时保留事件或 CDC外部副作用可以安全重复
提高观测能力关联 ID、结构化日志、指标、追踪高基数和采样成本成为问题时设计分层采样没有业务语义的技术日志

选型时必须同时写出“无法保证的部分”。例如 Outbox 可以保证业务表和待发送事件在本地原子提交,但不能保证搜索索引立即刷新;幂等键可以防止同一语义的重复执行,但不能自动判断两个不同请求是否业务上等价;缓存可以降低读延迟,但不能成为库存余额的权威来源;Checkpoint 可以从中断位置继续,但不能修复已经错误写入的业务事实。

11.10.3 评审清单:模型与边界

评审商品模型时,逐项回答:

  1. Resource、Item、SPU、SKU、Offer 和 Rate Plan 的身份是否稳定?
  2. 哪些字段是商品中心主导,哪些字段由供应商或运营主导?
  3. 同一个外部对象是否可能映射多个平台对象?发生冲突时谁处理?
  4. Draft、Staging、QC 和正式 Item 是否有独立语义?
  5. 发布版本是否能比较新旧?版本是否与时间戳混用?
  6. 订单快照是否包含足以解释历史交易的字段?
  7. 库存配置、库存余额、预占和确认是否属于不同对象?
  8. 商品状态和可售状态是否被错误合并?
  9. 规则、schema 和模板是否带版本?历史结果是否能回放?
  10. 是否存在一个“万能 JSON”让所有下游自行解释字段?

如果这些问题中有三项以上无法回答,说明应该先补模型和契约,暂时不要继续拆服务或增加消息队列。

11.10.4 评审清单:链路与失败

评审创建、编辑或同步链路时,检查:

  1. 输入是否先保存为不可变原始数据?
  2. 重复提交是否有业务幂等键和参数冲突判断?
  3. 大文件是否流式解析,是否支持行级错误?
  4. 任务是否可以暂停、恢复、重试和人工关闭?
  5. Worker 是否有租约、令牌和过期恢复?
  6. Checkpoint 是否足以从稳定位置继续,而不是只保存一个模糊的进度百分比?
  7. 失败是否区分永久错误、暂态错误、冲突和未知结果?
  8. 重试是否设置最大次数、总耗时和退避抖动?
  9. DLQ 是否有实际的处理人、回放入口和关闭理由?
  10. 事件消费者是否按事件 ID 和业务版本去重?
  11. 旧版本事件到达时,系统是否会拒绝覆盖新事实?
  12. 下游投影失败时,正式商品是否仍然可解释?
  13. 对账是否能发现静默丢失,而不仅仅是接口报错?
  14. 人工接管是否通过命令完成并被审计?

11.10.5 评审清单:发布和交易

评审发布一致性时,检查:

  • 正式商品、发布版本、商品快照和 Outbox 是否在同一个本地事务中形成一致解释。
  • 发布命令是否带有基线版本,版本冲突是否可见且可恢复。
  • 发布事务是否调用了远程库存、搜索、计价或营销接口。
  • 下游事件是否包含事件 ID、聚合 ID、版本、来源和 schema 版本。
  • 消费者是否记录已处理版本,并且重复和乱序可以安全确认。
  • 搜索和缓存是否承认最终一致,是否有新鲜度预算和降级策略。
  • 交易前是否重新校验商品状态、价格、营销资格和库存。
  • 订单是否固化不可变商品快照、价格快照和规则版本。
  • 下架、封禁、库存耗尽和销售窗口结束的语义是否清晰区分。
  • 回滚是否区分商品版本回退、索引回退、库存冲正和订单补偿。

11.10.6 评审清单:治理和运维

评审治理能力时,检查:

领域必问问题证据
权限谁能发布、下架、重放、修改库存和查看原始数据?权限矩阵和审计记录
质量结构错误、业务错误、风险错误如何区分?规则版本和错误样例
供应商来源版本、限流和全量缺失如何处理?来源契约和对账报告
任务最老任务、毒性任务和部分成功如何发现?任务看板和告警
事件Outbox、消息、消费者和 DLQ 如何关联?事件 ID、追踪链路
投影搜索、缓存和运营看板如何重建?重建命令和版本指标
库存配置和事实如何对账?资源状态和库存流水
合规原始文件、个人信息和供应商数据保留多久?保留、脱敏和访问策略
演练宕机、重复、乱序、超时和回滚是否演练过?演练记录和恢复时长

11.10.7 常见反模式

反模式一:商品中心 CRUD 化。 所有入口直接 UPDATE product,审核、供应商和运营互相覆盖。症状是没有版本冲突、没有来源主导权、无法解释历史。修复方式是引入 Draft、基线版本和正式发布命令。

反模式二:用一个 status 贯穿全流程。 IMPORTING、QC_PENDING、ONLINE、SOLD_OUT 和 OFFLINE 被写入同一个字段。症状是一个动作覆盖另一个动作,调用方不知道状态的业务含义。修复方式是拆分 Task、QC、Item 和 Sellability 状态机。

反模式三:同步 RPC 链式发布。 商品发布同步调用库存、搜索、计价和营销,任意依赖超时就回滚或卡住。症状是长尾延迟高、重试产生重复副作用、故障边界不清。修复方式是本地事务 + Outbox + 版本事件 + 对账。

反模式四:用消息到达时间解决乱序。 事件没有版本,消费者把最后到达当作最新。症状是供应商延迟数据覆盖人工修正,索引偶尔回退。修复方式是聚合版本、来源版本和条件更新。

反模式五:把重试当作恢复。 所有错误都自动重试,任务无限重试,队列越来越长。症状是重试风暴和 DLQ 失控。修复方式是错误分类、重试预算、退避、熔断和人工接管。

反模式六:库存写进商品表。 商品更新同时修改可售数量,搜索、缓存和订单都依赖商品表。症状是商品写入被高频库存拖慢,预占和释放无法表达。修复方式是库存配置与库存事实分离,交易前读取权威库存。

反模式七:用当前商品解释历史订单。 订单只保存 item_id,详情页每次回查当前商品。症状是退改、标题、价格和规格随商品更新而改变。修复方式是订单本地快照和快照版本。

反模式八:用删除代替下线。 供应商全量列表缺失就直接删除商品和库存。症状是历史订单断链、客服无法解释、错误同步造成大面积下架。修复方式是来源批次、延迟确认、下线状态和可恢复映射。

11.10.8 如何把方法迁移到其他业务

迁移到实物电商时,Resource 可以是品牌或仓配商品,SKU 由颜色和尺寸组合,库存事实由仓库和批次管理;核心仍然是商品主数据、仓库库存和订单快照分离。

迁移到票务平台时,Item 是场次或票种,库存资源是座位、区域或配额,Staging 和 QC 仍然负责内容与合规审核;新的难点是座位锁定和高并发预占,但商品发布与资源事实边界不变。

迁移到课程平台时,Item 是课程,Offer 是班次或价格计划,库存资源是名额和开班时间;供应商同步可以替换为教师和机构协作,字段主导权仍然决定谁能修改课程内容和上课安排。

迁移到 SaaS 套餐时,Item 是套餐,SKU 是计费维度,Offer 是渠道和合同版本;库存资源可以变成席位或配额,订单快照仍然要保存当时的价格和权益规则。

迁移到内容平台时,Draft、审核、发布版本和读模型尤其重要;搜索、推荐和缓存都是投影,正文或媒体资产的版本化、版权状态和下架治理会替代库存成为核心问题。

这些领域的组件可以不同,状态机名称也可以不同,但评审顺序保持一致:先列业务对象和不变量,再列事实源和状态所有者,再列同步 / 异步边界,最后才选择数据库、消息系统、缓存和调度器。

11.10.9 方案交付物清单

一份可以进入评审和实施的商品供给方案,至少应该交付以下内容:

  1. 场景边界:列出人工、批量、供应商、库存和生命周期入口,并明确不做什么。
  2. 约束表:记录规模、峰值、延迟、新鲜度、成本、合规和恢复目标,注明假设或测量来源。
  3. 领域模型:说明每个对象的身份、字段、生命周期、权威来源和消费者。
  4. 状态机:列出状态、允许迁移、禁止迁移、原因、操作者和异常终态。
  5. 数据模型:给出主表、版本表、任务表、事件表、审计表和关键唯一约束。
  6. 参考架构:展示控制面、事实面、投影面、同步 / 异步边界和关键失败路径。
  7. API 契约:区分 Command、Query 和 Event,说明幂等、版本、错误和新鲜度。
  8. 任务设计:说明租约、Checkpoint、批次、分片、重试、DLQ 和人工接管。
  9. 发布设计:说明本地事务、Outbox、事件版本、消费者去重和对账。
  10. 完整案例:走通一个正常链路和至少三种异常分支,说明最终收敛。
  11. ADR:解释关键选择、放弃的能力、接受的风险和重新评估条件。
  12. 验证计划:列出单测、契约测试、故障演练、容量压测和上线后指标。

如果只有架构图而没有状态机,读者无法知道异常如何推进;如果只有表结构而没有事件和版本,读者无法知道跨域如何收敛;如果只有推荐方案而没有 ADR,读者无法判断方案是否适合自己的约束。交付物之间必须互相引用,而不是各自写一份看似完整却无法拼接的文档。

11.10.10 评审时的反事实问题

好的评审不只问“正常流程是什么”,还要问反事实:如果消息先到后到会怎样?如果输入重复会怎样?如果上游说成功但本地超时会怎样?如果人工在供应商同步同时修改同一字段会怎样?如果数据库提交成功但事件发送失败会怎样?如果事件发送成功但消费者处理到一半宕机会怎样?

可以把反事实问题整理为五类:

反事实类别示例问题需要看到的设计
时间不确定请求超时但副作用是否已完成?查询确认、幂等键、未知状态
顺序不确定版本 3 是否可能先于版本 2 到达?聚合版本、乱序过滤
参与者失败Worker 宕机时谁接管?租约、Checkpoint、重试
语义冲突人工和供应商同时改同一字段怎么办?主导权、差异、人工审核
范围扩大一条错误输入会影响多少对象?批次隔离、灰度、熔断、回滚

如果方案只能回答“服务重试一下”,通常还没有区分失败类型。重试不是万能答案:参数错误重试不会变好,业务冲突重试会放大队列,未知副作用重试可能重复扣减,永久下游故障重试会拖垮上游。评审需要看到错误分类和每类错误的终态。

11.10.11 上线前后的指标对照

上线前的压测指标和上线后的业务指标要形成映射。压测只测出系统在假设流量下能处理多少请求,不能证明供应商输入正确、审核队列可用或订单快照完整。因此,发布检查应同时看技术能力和业务结果。

设计目标上线前验证上线后指标失败时的动作
任务可恢复Worker 宕机演练租约接管时间、Checkpoint 丢失量降低并行度、修复租约
发布可靠事务 / Outbox 故障注入未发送年龄、版本滞后扫描重发、进入 DLQ
搜索收敛索引重建和乱序测试索引版本差、零结果率重放、切换别名
库存正确并发预占和释放测试负库存、对账差异冻结资源、冲正
供应商稳定限流和空列表模拟有效更新率、批次异常降级、人工确认
审核质量规则回放和误报测试驳回率、人工队列年龄调整规则、重审
订单可解释版本变更期间下单快照缺失、客服投诉数据修复、补充展示

监控阈值不能只写一个固定数字。某个供应商每天有 80% 的无效更新可能是正常的,而另一个供应商超过 20% 就可能意味着接口故障;某个品类的审核通过率低可能是规则严格,也可能是模板设计错误。阈值应按照供应商、品类、渠道和季节建立基线,并在变更后重新校准。

11.10.12 从架构判断到实现顺序

实现一个新的供给能力时,可以按照“事实先行、流程随后、投影最后”的顺序推进。

第一步定义正式事实和不变量。例如新建一个“按日期售卖的门票”,先定义票种、场次、资源格、可售容量和订单快照,而不是先设计上传页面。

第二步定义流程对象。说明草稿、审核、任务和发布命令如何关联,哪个版本可以发布,哪些错误可以重试,哪些冲突需要人工。

第三步实现本地写入。让正式事实、版本、快照、日志和 Outbox 在本地事务内可测试,先保证单域正确性。

第四步实现异步投影。搜索、缓存、营销上下文和库存初始化各自消费版本事件,加入去重、乱序过滤和失败队列。

第五步实现对账和恢复。没有对账的异步投影只能在“看起来正常”时工作,发生消息丢失或规则变更后无法证明已经收敛。

第六步再优化吞吐和成本。根据指标决定是否增加分片、Redis 抢占、CDC、独立事件主题、投影重建或租户级资源隔离。这样做可以让每一步都有可运行的闭环,也更容易定位复杂度来自哪里。

11.10.13 最终方法论

商品中心和供给平台的设计可以总结成一条闭环:

flowchart LR
    A[定义业务事实] --> B[定义状态与不变量]
    B --> C[确定权威来源]
    C --> D[划分本地事务]
    D --> E[设计命令、事件和查询]
    E --> F[设计失败、重试和补偿]
    F --> G[设计指标、对账和人工接管]
    G --> H[用案例和演练验证]
    H --> A

这条闭环的每一步都在限制下一步的自由度。没有业务事实,领域模型会变成字段清单;没有状态和不变量,接口会变成任意更新;没有权威来源,事件会变成无主的通知;没有本地事务边界,最终一致会变成长事务或同步链;没有失败设计,重试会产生重复副作用;没有观测和对账,系统无法证明已经恢复。

对于架构师而言,最重要的能力不是背诵某个组件的特性,而是能够在约束发生变化时重新做判断。供应商从十个增加到一千个,首先变化的是来源治理和限流;商品从百万增加到千万,首先变化的是索引、批量任务和归档;库存从数量变成座位,首先变化的是资源身份和预占语义;团队从一个变成多个,首先变化的是契约、责任和审计。

最后可以用六个问题结束评审:

  1. 这个对象的业务事实是什么?
  2. 谁拥有它,谁可以修改它?
  3. 哪个版本可以被审核和发布?
  4. 哪些动作必须在本地事务内完成?
  5. 失败、重复、乱序和未知结果如何恢复?
  6. 系统如何证明最终状态已经收敛?

如果六个问题都有明确答案,技术实现通常可以在多个候选方案中演进;如果答案依赖“组件默认会处理”或“上线后再观察”,系统即使暂时运行,也会在下一次批量导入、供应商故障或大促高峰中暴露边界缺口。

11.10.14 最小可行评审模板

一次商品供给方案评审可以用以下模板结束:

业务问题:
不做什么:
核心对象:
权威事实源:
状态机及所有者:
关键不变量:
输入来源:
同步边界:
异步边界:
版本和幂等键:
失败分类:
重试预算:
DLQ 与人工接管:
对账范围:
观测指标:
回滚方式:
获得的能力:
主动牺牲的能力:
接受的风险:
重新评估条件:

如果方案不能填完这张表,不代表方案一定错误,但说明它还没有达到可实施程度。尤其要警惕“后续再考虑幂等、监控和对账”这类表述:它们不是部署后才添加的外围能力,而是决定状态机和数据模型能否恢复的基础设计。

这份模板也可以作为跨团队协作的共同语言。产品团队可以从业务对象和用户可见结果开始填写,研发团队补充事务、版本和事件,测试团队补充故障注入与验收条件,运维团队补充指标、告警、回滚和人工入口,数据治理团队补充保留、脱敏和审计。评审结束后,每个空白项都应转化为待确认问题、设计任务或明确的非目标,而不是留在会议记录里等待遗忘。

真正可迁移的不是某一张表或某一个组件,而是这套从事实到恢复的推理顺序:先定义对象,再定义状态;先确定权威,再划分事务;先说明失败,再决定异步;先建立证据,再扩大规模。只要这个顺序保持稳定,具体实现就能随着流量、品类、供应商和组织变化逐步演进。

11.10.15 结语

商品供给系统的难点不在于把一个页面做成多个服务,而在于把变化、事实、资源和历史合同分开建模,再通过版本、事件和补偿把它们连接起来。商品中心保证平台认定的正式商品是什么,供给平台保证变化如何被接收和治理,库存系统保证资源是否真的可卖,搜索和缓存保证读体验,订单系统保证历史交易仍然可解释。

当一个平台能清楚回答“谁写了什么、依据哪个版本、发布到哪里、失败后怎么办、历史如何复原”时,它才真正拥有可演进的商品基础设施。技术栈可以替换,部署方式可以变化,供应商和品类也会不断增加,但权威事实、职责边界、上下游协作、补偿恢复和观测治理这五个检查框架仍然成立。

参考资料

[1] Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, Addison-Wesley, 2003, https://www.domainlanguage.com/ddd/.

[2] Martin Fowler, Patterns of Enterprise Application Architecture, Addison-Wesley, 2002, https://martinfowler.com/books/eaa.html.

[3] Martin Kleppmann, Designing Data-Intensive Applications, O’Reilly Media, 2017, https://dataintensive.net/.

[4] Pat Helland, “Life Beyond Distributed Transactions: an Apostate’s Opinion”, CIDR 2007, https://www.cidrdb.org/cidr2007/papers/cidr07p15.pdf.

[5] Martin Fowler, “Event Sourcing”, martinfowler.com, 2005, https://martinfowler.com/eaaDev/EventSourcing.html.

[6] Martin Fowler, “Event Collaboration”, martinfowler.com, 2006, https://martinfowler.com/eaaDev/EventCollaboration.html.

[7] Amazon Web Services, AWS Builders’ Library, “Making retries safe with idempotent APIs”, https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/(访问日期:2026-09-21)。

[8] Amazon Web Services, AWS Builders’ Library, “Timeouts, retries, and backoff with jitter”, https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/(访问日期:2026-09-21)。

[9] Betsy Beyer, Chris Jones, Jennifer Petoff, Niall Richard Murphy, eds., Site Reliability Engineering, Google / O’Reilly Media, 2016, “Handling Overload”, https://sre.google/sre-book/handling-overload/.

[10] Stripe, “Idempotent requests”, Stripe API Reference, https://docs.stripe.com/api/idempotent_requests(访问日期:2026-09-21)。

[11] Oracle, MySQL 8.4 Reference Manual, “How to Minimize and Handle Deadlocks”, https://dev.mysql.com/doc/refman/8.4/en/innodb-deadlocks-handling.html(访问日期:2026-09-21)。

[12] Apache Software Foundation, Apache Kafka Documentation 4.0, “Design” and “Producer Configs”, https://kafka.apache.org/40/design/design/;https://kafka.apache.org/documentation/#producerconfigs(访问日期:2026-09-21)。

[13] Debezium Community, Debezium Documentation, “Outbox Event Router”, https://debezium.io/documentation/reference/transformations/outbox-event-router.html(访问日期:2026-09-21)。

[14] OpenTelemetry Community, “Concepts”, https://opentelemetry.io/docs/concepts/(访问日期:2026-09-21)。

[15] Kubernetes Authors, “Jobs”, Kubernetes Documentation, https://kubernetes.io/docs/concepts/workloads/controllers/job/(访问日期:2026-09-21)。

[16] JSON Schema Organization, JSON Schema Specification, Draft 2020-12, https://json-schema.org/specification.

[17] OpenAPI Initiative, OpenAPI Specification 3.1.0, https://spec.openapis.org/oas/latest.html.

[18] Gregor Hohpe, Bobby Woolf, Enterprise Integration Patterns: Designing, Building, and Deploying Messaging Solutions, Addison-Wesley, 2003, https://www.enterpriseintegrationpatterns.com/.

[19] Apache RocketMQ 社区,《基本概念》,RocketMQ 中文文档,https://rocketmq.apache.org/zh/docs/introduction/02concepts/(访问日期:2026-09-21)。

[20] 阿里巴巴,《阿里巴巴 Java 开发手册》,Alibaba P3C,https://github.com/alibaba/p3c(访问日期:2026-09-21)。

[21] 周志明,《凤凰架构:构建可靠的大型分布式系统》,2021,https://icyfenix.cn/。

[22] Kubernetes 中文社区,《Job》,Kubernetes 中文文档,https://kubernetes.io/zh-cn/docs/concepts/workloads/controllers/job/(访问日期:2026-09-21)。

[23] OpenTelemetry 社区,《概念》,OpenTelemetry 中文文档,https://opentelemetry.io/zh/docs/concepts/(访问日期:2026-09-21)。

[24] Oracle,《MySQL 8.4 参考手册》,https://dev.mysql.com/doc/refman/8.4/zh-CN/(访问日期:2026-09-21)。

第 12 章 库存系统

本章定位:库存系统回答的不是“表里还有多少”,而是“在指定范围、时间窗口和履约约束下,平台现在还能向用户承诺多少”。本章从这个可承诺能力出发,建立库存单元、范围、批次、状态和事实来源模型,再落到数量制、券码制、供应商管理库存和无限库存的实现。重点不在某个中间件,而在高并发、可重试、结果未知和外部依赖不可靠时,系统如何避免重复承诺、如何审计每次变化,以及如何在发生偏差后安全收敛。

12.1 问题定义、场景边界与设计目标

12.1.1 库存不是一个数字,而是一项承诺

在最简单的例子里,库存似乎就是商品表上的一个整数:某个 SKU 有 100 件,用户买走 1 件之后变成 99 件。这种理解只在单仓库、单渠道、单时间窗口、没有预占、没有供应商、没有营销隔离的练习题里成立。真实交易系统需要回答的句子更接近下面这样:

对于 inventory_unit = 商品 + 规格 + 范围 + 时间窗口 + 批次,
在当前售卖资格、渠道、供应商和履约约束下,
平台还能够向一个新的订单承诺多少资源?

这里至少包含五个隐含条件。

第一,库存有对象。实物商品的最小对象可能是 SKU,酒店库存可能是“房型 + 日期”,服务库存可能是“门店 + 时段”,票务库存可能是“场次 + 座位”,券码库存则是一个具体的唯一字符串。对象不同,能够安全扣减的最小粒度不同。

第二,库存有范围。同一个 SKU 在不同仓库、城市、渠道、活动或供应商下,未必可以互相替代。把所有数量合并成一个全局数字,等于默认任何资源都能被任何订单消费,最后会在配送、核销或供应商确认时暴露问题。

第三,库存有时间。日期型服务的 6 月 1 日和 6 月 2 日不是同一份库存;有有效期的券码在过期后不能继续承诺;一次短时预占则把“此刻可卖”变成“某个用户在短窗口内拥有的机会”。

第四,库存有事实来源。平台自管库存由平台的账本负责;供应商管理库存只能由外部系统最终确认;无限库存虽然没有数量桶,仍然可能受供应商配额、风控规则和履约能力限制。缓存、搜索索引和前端展示都只是观察或投影,不能自动变成权威。

第五,库存有不可逆动作。实物出库、券码展示、充值提交、出票和供应商预订一旦成功,不一定能通过“加回 1”恢复原状。释放预占是状态迁移,不是任意方向的算术操作;回滚失败时需要进入补偿和人工处理,而不是把数据库中的数字改回去。

因此,本章采用一个比“扣库存”更严格的定义:

库存系统管理的是可承诺供给能力,并负责把 Check、Reserve、Confirm、Release、Refund 这些意图转换为可验证的状态迁移。

这一定义也解释了为什么库存设计经常和一致性、幂等、过载保护、对账及履约安全交织在一起。长事务不能无限期持有数据库资源,经典 Saga 工作把长事务拆成多个本地事务并用补偿连接起来[1];在服务边界扩大之后,系统更应保存业务事实、局部状态和可重试的消息,而不是假设所有参与者都能加入一个全局 ACID 事务[2]。

12.1.2 场景范围

库存的具体表现形式很多,但可以用“管理方式、库存单元、范围、扣减时机”四个维度描述。下表只列出本章要覆盖的代表性场景,不是产品分类目录。

场景最小库存单元常见范围主要不确定性典型策略
实物商品SKU 数量、仓位或批次仓库、门店、区域、渠道配送范围、锁库、出库和退货数量制 + 预占 + WMS/履约对接
到店服务名额、时段或门店配额门店、城市、日期、时段核销能力和时间冲突时间切片 + 预占/确认
券码或礼品卡唯一 code批次、面值、有效期、渠道码的唯一归属、泄露和不可逆发放码池状态机 + CAS
充值或外部权益供应商配额或请求额度账户、地区、供应商外部请求结果未知、重复充值供应商预订 + 履约状态机
酒店或票务房型/座位/舱位日期、场次、供应商实时可用性和二次确认快照 + 实时查询 + booking
无限库存商品没有数量桶用户、地区、渠道、风险等级供应商限额和履约容量资格检查 + 配额/审计
组合商品多个子库存单元子商品、批次、渠道部分成功和补偿顺序Saga 编排 + 逆序释放

这些场景看起来差异很大,但它们都必须回答同一组问题:

  1. 什么实体被承诺?
  2. 这个实体在哪个范围、时间窗口和批次内有效?
  3. 谁是当前事实来源?
  4. 预占是否需要隔离其他请求?
  5. 确认、释放和补偿分别意味着什么?
  6. 如果请求超时但操作可能已经成功,系统如何查明结果?
  7. 如果缓存、消息或外部供应商返回旧数据,哪些操作必须拒绝?

如果这些问题没有先被写入模型,工程实现通常会把差异堆积为 if category == xxx,再用不同表名、不同缓存 key 和不同定时任务掩盖概念重复。统一模型不是让所有品类共用一个表,而是让它们共用一套判断语言。

12.1.3 库存系统的职责边界

库存系统的职责可以压缩为四类:

  • 保存和解释库存能力:定义库存单元、范围、批次、有效期和可售状态;
  • 执行资源状态迁移:处理 Reserve、Confirm、Release、Lock、Unlock、Adjust 和 Refund;
  • 对外提供可审计的查询和命令:让调用方知道结果是成功、失败、处理中还是需要人工处理;
  • 让派生视图最终收敛:维护热路径、消息、供应商快照和账本之间的对账及修复。

库存系统不应该拥有以下决定:

领域归属系统库存系统的协作方式
商品名称、规格和商品发布版本商品中心使用稳定的商品/规格标识和发布版本
价格、优惠和税费计价/营销系统验证价格快照或优惠资格,不重新计算金额
订单生命周期订单系统接受带幂等键的 Reserve/Confirm/Release 命令
支付授权和扣款支付系统只有达到支付资格的订单才能进入支付
供应商履约细节供应商适配/履约系统通过 provider booking、状态查询和补偿协作
运营调账原因和审批运营/审计系统接受带操作者、原因和审批号的 Adjustment
用户展示文案前端/交易编排返回明确错误类别、状态和预计等待语义

边界不是为了把系统切得更碎,而是为了避免一个系统同时成为商品、订单、支付、营销和供应商的事实来源。跨域事务可以有编排者,但每个参与者仍然只提交自己的本地事实。Azure 的 Saga 设计资料也把每一步定义为本地事务,并强调失败后的补偿、不可逆步骤和可重试步骤必须被显式建模[7]。

12.1.4 设计目标与非目标

本章的目标不是承诺“永不超时、绝不失败”,而是让失败变得可分类、可重试、可观测和可修复。

目标可验证含义非目标
不超卖在权威库存单元内,成功确认量不违反不变量不保证外部供应商永远有货
不重复扣减相同业务操作重试只能得到同一结果或同一处理中状态不保证消息只投递一次
可释放预占最终进入 Confirm、Release 或人工处理终态不保证延时队列永不丢失
可恢复热视图丢失后能够依据事实重建不把所有异常都自动修成成功
可解释每次变化都能追溯到请求、订单、操作者或供应商事件不依赖前端展示数字作为审计
可扩展新品类通过策略和适配器接入,而不是复制服务不强迫所有品类使用 Redis
可控降级依赖故障时返回明确结果并阻止风险扩散不为了成功率静默放行未知库存

这组目标之间存在取舍。提高并发通常需要把一部分写操作放到内存热路径,降低同步等待又会扩大暂态不一致窗口;提高用户体验可能要求支付前增加一次权威确认,导致延迟和数据库热点上升。后文会把这些取舍写成条件,而不是把某个组件称为万能方案。

12.1.5 库存问题的五种根因

线上看到“库存不对”时,不应马上把问题归类为超卖。相同的表象可能来自五种完全不同的根因:

  1. 并发安全失败:两个不同 operation_id 同时满足了可售条件,导致确认量超过 available。
  2. 重复执行失败:同一个 operation_id 因超时被执行两次,产生两个 Reservation、两条发码记录或两次供应商预订。
  3. 投影漂移:事实库已经释放或确认,Redis、搜索或报表仍停留在旧版本。
  4. 边界语义失败:平台把“请求已发送”解释成“外部已预订”,把“支付成功”解释成“资源已确认”,或把“缓存有值”解释成“事实可售”。
  5. 治理失败:日志不含操作号、没有对账阈值、没有冻结门闩,故障发生后无法判断哪些订单已经改变事实。

不同根因对应不同修复:

根因不能解决它的办法应优先验证
并发安全只增加缓存副本条件更新、锁范围、热点分布
重复执行只提高超时时间唯一键、操作表、参数摘要
投影漂移直接修改一个缓存数字watermark、epoch、重建任务
边界语义继续增加重试次数状态映射、提交点、API 文案
治理失败只做事后人工统计审计字段、对账、告警和演练

因此,“库存系统是否正确”至少要拆成安全性、活性和可解释性三类问题。安全性要求不能超卖、不能重复确认;活性要求有效预占最终不会永久卡住;可解释性要求能够说明某个结果依据哪个事实、版本和操作。安全性往往需要同步条件,活性通常依赖扫描与补偿,可解释性依赖不可变记录与观测。三者缺一不可。

这也是为什么本章不会把“分布式锁”“Redis”“消息队列”当成单独答案。锁可以降低一段代码的并发,但不能自动形成业务事实;Redis 可以执行原子脚本,但不能为数据库提交作证;消息队列可以保存事件,但不能决定过期的 Reservation 是否应该释放。只有把它们放入明确的不变量和状态机,才能判断它们各自承担什么责任。

12.2 约束、容量估算与库存语义

12.2.1 先写假设,再讨论组件

库存方案的容量数字必须先标明是测量值还是设计假设。下面给出一个只用于说明方法的假设:

  • 平均每日订单量:100 万单;
  • 日内峰值是日均的 12 倍;
  • 峰值持续 10 分钟;
  • 每个订单平均包含 1.4 个库存明细;
  • 进入交易页的查询流量是创单流量的 20 倍;
  • 峰值时 70% 的请求集中在 2% 的库存单元;
  • 预占有效期为 15 分钟;
  • 账本异步投影允许的正常延迟为 5 秒。

由此可以做一个粗略的量级估算。日均创单率约为:

1,000,000 / 86,400 ≈ 11.6 order/s
峰值创单率 ≈ 11.6 × 12 ≈ 139 order/s
峰值库存明细写入 ≈ 139 × 1.4 ≈ 195 operation/s
峰值查询率 ≈ 195 × 20 ≈ 3,900 query/s

这里的 139、195 和 3,900 都是假设,不是通用阈值。更重要的是,“订单 QPS”和“库存操作 QPS”不是同一个指标;一个组合订单可能同时预占多个库存单元,一个批量查询也可能放大读请求。过载时不应只看平均 QPS,因为不同请求的资源成本可能相差很大。Google SRE 对过载处理的讨论特别强调,单纯用 QPS 作为容量代理会掩盖请求成本差异,系统应结合拒绝、降级、流量分配和资源余量管理[10]。

容量估算至少要拆成五个平面:

  1. 用户同步平面:Reserve、Confirm、Release 的延迟和成功率;
  2. 热路径平面:Redis 脚本执行、热 key、分片和连接池;
  3. 事实写入平面:数据库行锁、CAS 冲突、流水追加和 Outbox;
  4. 异步平面:消息积压、投影水位、补偿任务和供应商轮询;
  5. 运维平面:对账扫描、重建任务、审计查询和人工处理队列。

任何一个平面出现瓶颈,用户都会看到“库存不足”或“下单失败”,但根因可能完全不同。把这些错误混成一个状态,会导致产品文案、告警和排障都失去方向。

12.2.2 约束表和 SLO

约束假设设计后果不保证什么验证指标
热点集中2% 单元承接大部分请求Redis Lua、分段库存、排队或限流任意单 key 无限扩展key QPS、脚本耗时、排队长度
支付不希望事后退款支付入口前需确认库存资格主库 CAS 或授权后扣款支付之后绝不发生任何异常支付前库存失败率、退款率
供应商只支持查询本地不能取得外部锁快照 + 短时有效期 + 重新确认本地可售等于供应商最终确认过期快照命中率、二次确认失败率
消息至少一次可能重复、延迟和乱序event_id、版本、幂等消费者消息物理 exactly-once重复消费率、死信量
Redis 可丢失热视图可重建账本/Document/流水保留事实Redis 恢复后瞬间无延迟重建时长、重建差异
预占可过期用户可能不付款TTL、延时任务、Sweep延时任务单独保证最终释放过期待释放年龄、释放成功率
业务操作不可逆发码、出票、充值可能不能回滚Confirm 前后分开建模所有操作都有对称反向动作人工处理量、不可逆失败率

建议至少定义以下库存 SLO,而不是只定义一个“接口可用率”:

  • Reserve P99 延迟和业务成功率;
  • 可售查询的最大数据新鲜度窗口;
  • Confirm/Release 终态收敛时间;
  • 最老 Outbox、最老投影和最老补偿任务年龄;
  • 账本不变量破坏次数,目标必须为 0;
  • 供应商未知结果和人工处理队列的上限;
  • Redis 重建期间的冻结时间和恢复成功率。

可观测性不是在系统完成后才加的面板。OpenTelemetry 对可观测性的定义强调,系统应通过 traces、metrics 和 logs 让操作者能够从外部追问“为什么发生”,并让 SLI/SLO 连接到用户期望[18]。对库存而言,“库存接口返回 200”远远不够;还要知道它是否返回了过期快照、是否产生了预占、是否在等待投影和是否进入人工处理。

12.2.3 库存桶、操作语义与不变量

对于数量制库存,可以用几个状态桶表示生命周期:

total = available + booking + locked + sold + damaged_or_invalid
sellable = available

其中:

  • total 是当前库存基线,不等于永远可售;
  • available 是在当前范围、时间窗口和业务规则下可以被新订单预占的量;
  • booking 是已经被 Reservation 占用但还没有最终确认的量;
  • locked 是被活动、风控、运营或异常处理暂时隔离的量;
  • sold 是已经确认成交、出库、发码或进入不可逆履约的量;
  • damaged_or_invalid 是损坏、失效、作废或质量检查不通过的量,不应重新进入可售池。

所有桶不得出现不符合领域规则的负数。对于供应商库存,平台的 supplier_stock 可能只是最近一次观察值,不能在没有 provider booking 的情况下被宣称为平台已经锁定的资源。对于无限库存,total 不参与数量恒等式,但系统仍然需要记录资格、限流、配额、履约和审计状态。

操作语义必须比字段名更强:

操作它承诺什么它不承诺什么典型幂等键
Check在某一时刻观察到可能可售不为调用方保留资源查询条件或版本
Reserve以原子条件把可售变成预占不代表已经支付或履约reservation_id
Confirm把预占推进到成交/履约确认不代表下游所有副作用完成confirm_operation_id
Release使仍可释放的预占归还可售或进入终态不允许旧世代盲目加库存release_operation_id
Lock将一部分可售隔离不等于订单预占lock_operation_id
Refund按售后规则处理已确认资源不必然恢复原码、原座位或原供应商资源refund_id
Adjust由授权人员修改基线并留下理由不可绕过不变量和审批adjustment_id

Check 和 Reserve 的差异是库存系统最常被忽略的边界。两个请求都先 Check 到“还有 1”,再分别执行普通更新,便产生典型的 Check-Then-Act 竞态。中文工程资料也用库存超发案例说明,读取和扣减分离会让两个并发请求同时看到充足库存,最终把数量扣成负数[28]。所以商品详情页的库存展示可以调用 Check,但创单必须以 Reserve 的原子结果为准。

12.2.4 一致性分层

库存系统不应该笼统地说“最终一致”或“强一致”,而应按操作回答:

层级需要的保证例子允许延迟
单库存单元的并发安全不能两个请求同时成功占用最后一份资源Reserve、Code CAS同步返回
同一请求的重试一致重试不会生成第二个预占或第二条确认网络超时后重复 Reserve同一操作窗口
跨系统业务收敛订单、支付、库存最终进入可解释状态订单取消触发 Release秒到分钟
查询模型新鲜度展示数据不会超过声明的陈旧窗口商品页可售数量由 SLO 定义
账本可恢复热视图丢失后可以重建Redis 重启允许冻结一段时间

“强一致”只适用于明确的边界,例如在一个数据库事务中更新同一个 inventory_unit 的可售桶和 Reservation;它不能自动覆盖 Redis、供应商、支付和消息系统。分布式系统的正确设计不是把所有东西都宣称为强一致,而是把安全性要求放到最小的同步边界,把可恢复性和可追踪性放到异步边界。

12.2.5 容量估算:从承诺速率而不是页面流量开始

库存系统的容量估算应从业务动作开始,而不是把页面访问量直接等同于扣减量。一个商品详情页可能被反复刷新、被搜索爬虫读取或被推荐系统批量预热;真正改变库存的 Reserve、Confirm、Release 通常只是其中很小的一部分。反过来,一次批量导入或供应商同步虽然 QPS 不高,却可能一次改变数百万条券码。

可以先定义以下变量:

符号含义示例假设
V日活跃商品或库存单元数1,000,000
R每个单元日均展示读取次数100
q读取请求中需要精确可售判断的比例20%
U日均有效 Reserve 次数2,000,000
p峰值系数,峰值分钟量 / 日均分钟量20
k一次操作触及的事实与投影写入数3–8

若这些数字只是规划假设,应明确标注为假设,而不是容量承诺。读请求的日均规模可以近似为 V × R,精确判断流量为 V × R × q;Reserve 的峰值吞吐则应按 U ÷ 1440 × p 估算,再根据热点集中度放大。若 1% 的库存单元承载 50% 的 Reserve,就不能只用全局平均值评估数据库锁竞争。

容量估算至少要拆成四条线:

  1. 在线读取线:Check、商品展示和搜索过滤,关注缓存命中、p99 延迟、热点 key 和回源比例。
  2. 在线承诺线:Reserve、Confirm、Release,关注单实体串行化、事务时间和失败重试。
  3. 异步变更线:Outbox、投影、供应商同步和清理任务,关注积压年龄、批次大小和重放速度。
  4. 治理线:对账、审计和重建,关注扫描范围、锁影响、读放大和恢复窗口。

例如,Reserve 峰值为 5,000 次每秒,并不意味着数据库需要稳定承受 5,000 次每秒的随机更新。如果其中 80% 集中在 100 个热点库存单元上,每个单元的有效更新实际上接近串行队列;此时增加数据库副本只能改善读,不能消除写热点。可行的办法包括按库存单元排队、提前分桶、分散券码池、限制单用户并发或把热点商品切成多个独立的库存分区,但每一种方法都会改变分配、审计或公平性语义。

数据规模也需要估算保留周期。台账条数大致为:

daily_ledger_rows =
    daily_reserve
  + daily_confirm
  + daily_release
  + daily_adjustment
  + daily_reconciliation

如果每个操作平均追加多条事件,保留三年后,台账规模会明显大于当前库存余额表。可以采用冷热分层、按月分区和归档索引,但归档不能让审计链断裂;旧操作至少应能通过操作号、库存单元和时间范围查询到摘要、原始位置与校验和。

容量估算的结论只能在假设成立时使用。上线前需要用热点分布、批量导入、消费者积压、数据库故障和缓存重建进行压测;上线后要用真实指标校正 p、k 和集中度。阿里云关于大型网站架构的资料强调分层、缓存、异步化和可扩展性的重要性[29][30],但这些原则不能替代本系统对“哪个动作产生不可逆承诺”的专项测量。

12.2.6 SLO 分解与错误预算

库存系统不应只设置一个“接口 p99 小于多少毫秒”的总 SLO,因为 Check、Reserve、Confirm 和 Release 的业务重要性不同。可以把 SLO 分解为:

能力可用性目标延迟目标允许的失败形态
Check高最低返回有界陈旧或确认中
Reserve高但需保护热点中明确拒绝、排队或未知
Confirm高中可查询、可重试、不可重复
Release重点保证收敛可异步进入释放中或对账
Reconcile持续运行不要求在线低延迟延迟但不能静默丢失

例如,Check 即使有少量陈旧,只要明确 freshness,就可能不违反用户承诺;Reserve 如果返回成功但事实无法证明,则是安全性事故;Release 延迟几分钟可能影响可售量,但如果有明确的 RELEASE_PENDING 和补偿,通常可以通过活性指标治理。SLO 应与错误预算绑定:预算消耗过快时,优先关闭非关键展示、降低新 Reserve 或冻结高风险库存,而不是继续追求所有接口的表面成功率。

错误预算还要按商品和租户隔离。一个低价值、低热度商品的投影延迟不应消耗高价值限量资源的全部预算;一个租户的错误重试不能拖垮其他租户。多维 SLO 会增加监控和运营成本,但它更接近库存风险的实际分布。

SLO 的分母也必须定义清楚。Reserve 成功率应以收到有效命令为分母,还是以通过资格校验的命令为分母;未知结果算失败、处理中还是单独统计;供应商超时是否归平台失败。若分母含义不固定,团队可能通过改变错误分类来“改善”指标,却没有改善实际可靠性。所有业务指标都应保留原始 outcome,派生报表只负责聚合。

12.3 统一领域模型:库存单元、范围、批次与状态

12.3.1 库存单元的组合键

库存对象的真正主键通常不是单一 sku_id,而是多个维度的组合:

inventory_unit_id =
  product_id
  + sku_id
  + scope_type / scope_id
  + channel_id
  + calendar_date / time_slot
  + batch_id
  + supplier_id

不是每个品类都需要所有字段,但字段含义必须先存在于模型中。没有 scope 的数量制模型,接入仓库和渠道隔离时只能新增特判;没有 batch 的券码模型,无法解释码的有效期、来源和导入批次;没有 supplier_id 的外部库存模型,无法在多个供应商之间区分确认结果。

建议把库存单元拆成以下实体:

实体作用权威字段主要写入者
InventoryUnit定义承诺对象和策略单元 ID、范围、单位类型、管理方式库存创建/运营
InventoryBatch表达批次、有效期和来源批次状态、开始/结束时间、来源、版本批次管理/导入
StockBalance当前数量投影各状态桶、版本、epoch事务或投影消费者
Reservation一次订单占用reservation、订单、数量、过期时间、状态Reserve/Confirm/Release
StockLedger不可变变化流水operation、前后值、delta、原因、操作者每次有效迁移
InventoryDocument业务命令和意图命令类型、业务参数、聚合版本命令入口
InventoryCode唯一券码的事实归属code hash、密文、状态、订单码池/履约
SupplierBooking外部预订和确认映射provider request、状态、重试信息供应商适配器
OutboxEvent可靠事件发送意图event ID、聚合版本、payload、发送状态业务本地事务

这些实体可以物理上放在同一个数据库,也可以分库分表,但职责不能混淆。StockBalance 能快速回答“当前投影是多少”,却不能替代 StockLedger 解释“为什么变成这样”;InventoryDocument 能驱动恢复,却不应被当成已经成功扣减的证据,除非它对应的状态迁移和流水已经提交。

12.3.2 管理方式、单元类型与扣减时机

把品类差异拆成正交维度,比按品类复制服务更容易演进。

ManagementType:
  SELF_MANAGED       平台自管数量和流水
  SUPPLIER_MANAGED   平台保存观察/预订映射,供应商负责外部事实
  UNLIMITED          不维护有限数量,但维护资格、配额和审计

UnitType:
  QUANTITY           整数数量
  CODE               唯一券码或卡密
  TIME_SLOT          日期/时段名额
  SEAT               场次 + 座位/舱位
  BUNDLE             多个子单元组合

DeductTiming:
  ON_RESERVE         下单时预占
  ON_PAYMENT         支付成功后确认
  ON_FULFILLMENT     出库、发码或出票时确认
  ON_SUPPLIER_CONFIRM 外部供应商确认后确认

策略可以表示为:

InventoryStrategy =
  ManagementType
  + UnitType
  + Scope
  + DeductTiming
  + Reversibility

例如:

  • SELF_MANAGED + QUANTITY + GLOBAL + ON_RESERVE:数据库 CAS 或 Redis Lua 数量预占;
  • SELF_MANAGED + CODE + BATCH + ON_RESERVE:Redis 只取 code_id,数据库完成码状态 CAS;
  • SUPPLIER_MANAGED + TIME_SLOT + DATE + ON_SUPPLIER_CONFIRM:本地快照、实时刷新和 provider booking;
  • UNLIMITED + NONE + REGION + ON_FULFILLMENT:不扣库存数量,但检查地区、供应商额度和履约状态;
  • BUNDLE + MULTI_UNIT + CHANNEL + ON_RESERVE:按稳定顺序预占多个单元,失败后逆序补偿。

12.3.3 库存状态机

数量库存的状态应表达业务事实,而不是简单映射到某个字段。

stateDiagram-v2
    [*] --> INIT
    INIT --> READY: 创建与校验完成
    READY --> ACTIVE: 商品/批次/供应商资格满足
    ACTIVE --> FROZEN: 对账差异或运维门闩
    FROZEN --> REBUILDING: 领取重建租约
    REBUILDING --> ACTIVE: 事实与投影校验通过
    ACTIVE --> RETIRED: 下架且不再接受新预占

    state ACTIVE {
        [*] --> AVAILABLE
        AVAILABLE --> RESERVED: Reserve
        RESERVED --> CONFIRMED: Confirm
        RESERVED --> RELEASING: Cancel/Timeout
        RELEASING --> RELEASED: Release 明确成功
        RELEASING --> RECONCILE_REQUIRED: 结果未知/世代冲突
        RECONCILE_REQUIRED --> RELEASED: 对账确认已释放
        RECONCILE_REQUIRED --> CONFIRMED: 对账确认已确认
        CONFIRMED --> REFUNDED: 售后规则允许回补
    }

FROZEN 不是失败的同义词,而是系统拒绝继续扩大风险的保护状态。冻结期间可以允许查询和对账,但不应继续接受新 Reserve、普通调账或没有证据的 Release。RELEASING 也不能直接解释为“已经加回库存”;它表示释放意图已产生但最终结果尚未确认。

12.3.4 批次、有效期和券码状态

批次是多个具有共同属性的库存资源的集合,例如生产日期、供应商批次、面值、有效期规则、导入来源或加密密钥版本。批次状态可以定义为:

CREATED -> IMPORTING -> ACTIVE -> EXPIRED
                         |
                         +-> INVALID / CANCELLED

只有批次属性有效、商品售卖窗口有效、码导入完成且可售数量大于 0 时,批次才可以进入 ACTIVE。已经产生销售事实的批次不应直接篡改属性;如果有效期或供应商来源发生变化,应创建新版本或新批次,并通过新的命令留下关系。

券码的状态比数量桶更细:

stateDiagram-v2
    [*] --> AVAILABLE
    AVAILABLE --> BOOKING: 唯一预占
    BOOKING --> SOLD: Confirm/发放
    BOOKING --> AVAILABLE: 超时/取消且未展示
    AVAILABLE --> LOCKED: 质量/运营/供应商锁定
    AVAILABLE --> EXPIRED: 到期
    AVAILABLE --> INVALID: 校验失败
    SOLD --> REDEEMED: 核销
    SOLD --> VOIDED: 作废/售后

SOLD 之后不能凭退款把原码放回 AVAILABLE。数字资源一旦被用户看到或交给下游,回滚数量并不等于回滚资源;后续动作应是作废、补发、退款或人工核损。Redis List 中的元素只能是 code_id,不能把明文码放进热队列,因为内存、监控、日志和排障链路都可能成为泄露面。

12.3.5 营销库存、渠道库存和组合库存

营销库存经常被误解成另一份完全独立的商品库存。更准确的模型是:营销规则拥有“谁有资格使用”和“活动限额”,库存系统负责把活动配额、普通库存和订单占用作为多个可验证的资源协调起来。

例如一个限量优惠包含普通库存 100 个、活动配额 20 个和渠道配额 5 个。一次活动预占至少有两种实现:

  1. 先锁定活动配额,再从普通库存预占;任意一步失败就按逆序解锁;
  2. 预先把活动配额从普通可售中切出,活动期间只在一个活动库存单元上扣减。

第一种灵活但跨资源补偿复杂;第二种路径简单但需要在活动变更时重新计算可售边界。两者都必须保存来源:这 1 个预占来自哪一个活动锁定、哪一个订单和哪一个库存世代。不能只根据 SKU 和数量猜测释放对象。

组合库存也一样。例如“套餐 = 1 张券 + 1 个服务名额 + 1 个供应商配额”,应先建立统一的 reservation_id,再按确定顺序完成子预占。一个子资源失败时,释放已经成功的子资源;如果某个子资源不可逆,则 Saga 进入人工或向前恢复,而不是伪造“全局回滚成功”。Seata 的中文 Saga 文档特别强调,正向和补偿服务都需要业务开发者实现,补偿还要处理空补偿和防悬挂[21]。

12.3.6 四种库存类型的共同模型与差异

数量、券码、供应商和无限库存之所以可以放进一个领域模型,不是因为它们都能用同一个表,而是因为它们都能回答同一组问题:资源的权威事实是什么、一次 Reserve 产生哪一种承诺、Confirm 是否可逆、Release 需要哪些证据、事实如何映射到可售投影。

维度数量库存券码库存供应商库存无限库存
资源单位数量桶单个码或码段外部批次或配额资格规则
Reserve 事实数量从 available 移到 reserved码状态从 AVAILABLE 到 RESERVED外部预留号或本地待确认记录资格占用或直接通过
Confirm 事实reserved 到 committed码绑定订单并进入 USED/COMMITTED外部确认号订单或权益事实
Release 难点版本与数量不能错加原码是否可再次使用外部释放是否成功防止滥用和重复权益
主要失败并发扣减、负数重复分配、敏感信息泄露超时、漂移、供应商限流资格绕过、规则不一致
可售投影available - safety_bufferAVAILABLE 码数量最近一次确认的可售上限规则计算结果

统一接口并不意味着统一实现。数量库存可以用 CAS,券码需要唯一状态迁移,供应商库存需要外部状态查询,无限库存需要规则评估。若把券码当成数量直接扣减,可能出现数量正确但实际分配相同码;若把供应商库存当成本地余额,可能把供应商暂时未知误报为零或无限;若把无限库存完全跳过 Reservation,又可能失去防重复领取和审计能力。

统一模型还应保留策略版本。例如 inventory_unit 的 strategy_type 从 QUANTITY 改成 CODE,不应直接覆盖历史记录;应创建新的策略版本或新库存单元,并将老单元置为 RETIRED。这样,历史订单可以按当时的策略解释,重放任务也不会用今天的实现解释昨天的事实。策略版本和数据世代是两个概念:策略版本回答“用什么规则处理”,epoch 回答“这一组投影属于哪一轮事实”。

12.3.7 状态迁移的前置条件与后置不变量

状态机真正有价值的部分不是状态名称,而是每条边上的前置条件、写入事实和后置不变量。以数量库存为例,Reserve 的前置条件不仅是 available_qty >= quantity,还包括库存单元处于 ACTIVE、epoch 一致、调用方具备资格、operation_id 未被其他参数占用、预占数量不超过单次上限。后置条件则是 available 减少、booking 增加、Reservation 进入 RESERVED、版本递增、Outbox 已生成。

可以为每个命令写出三元组:

命令前置条件后置不变量
ReserveACTIVE、数量足够、版本匹配、操作未完成available + booking + locked + sold = total - invalid
ConfirmReservation=RESERVED、订单归属正确、未过期或有支付例外booking 减少、sold/committed 增加、同一资源不能再次确认
ReleaseReservation=RESERVED 或 RELEASING、释放权限正确释放数量最多回到对应 epoch 的 available
Adjust有审批、expected_version 匹配、原因完整调整前后差异有 Ledger,不能绕过负数和上限
Freeze发现漂移或人工门闩打开新 Reserve 被拒绝,查询和恢复路径仍可用

不变量还需要考虑并发快照。Confirm 读取到 RESERVED 并不等于它可以直接写 CONFIRMED;它必须用状态和版本一起作为条件。Release 也不能只看 expires_at,因为订单可能已经在另一个事务中完成 Confirm。两个命令都使用 state = RESERVED AND version = expected_version,只有一个能够推进,另一个重新读取终态。

跨库存单元的组合操作需要额外的不变量。例如套餐包含 1 个券码和 1 个服务名额,系统可能允许一段时间内只有券码已预占,但最终进入 CONFIRMED 前两者必须都绑定同一 order_id。Saga 的中间状态要能被查询;如果在超时后只看到一个子 Reservation,没有编排记录就无法知道应当释放它还是等待另一个步骤。

状态机应禁止“万能更新接口”。允许任意状态互相转换的管理脚本会让所有线上证明失效。即使为了应急,也应提供有限的人工命令,例如 ForceRelease、FreezeUnit、RebuildProjection,并在命令中保存理由、审批和前置版本。人工命令不是绕过模型,而是模型中显式定义的高权限分支。

12.4 事实来源、数据模型与可售投影

12.4.1 Document、Ledger、Balance、Reservation、Outbox

一个能恢复的库存系统通常需要把五类数据分开:

数据解决的问题能否作为最终恢复依据
InventoryDocument用户或系统发起了什么命令,当前命令处于什么状态可以作为业务意图依据,但要结合状态和流水
StockLedger某次状态迁移改变了什么,前后值是什么是不可变审计和重放依据
StockBalance当前数量桶和版本是多少是快速查询和 CAS 目标,不是孤立事实
Reservation哪个订单占用了哪个单元、数量和过期时间是释放、确认和对账的关联事实
OutboxEvent哪些已经提交的变化还需要向外发送是可靠发布依据,不是库存余额

这些数据的职责可以用一条链表示:

Command
  -> InventoryDocument
  -> local transaction
       -> Reservation / StockLedger / StockBalance
       -> OutboxEvent
  -> Outbox Relay
       -> message broker / downstream projector
  -> Redis / query view / supplier worker

这里的关键不是表的数量,而是写入边界。一次需要对外传播的本地状态变化,应该在关系型数据库本地事务中同时写入业务状态和 Outbox。AWS 对 Transactional Outbox 的定义正是为了解决数据库写入与消息发送之间的双写窗口:业务表和 Outbox 同事务提交,Relay 再至少一次发送,消费者必须幂等[6]。

12.4.2 核心表模型

下面的表名和字段是公开示例,不对应任何内部系统。此例以 MySQL 8.x 为前提,使用生成列为日期维度建立唯一键:有日期时保存 MySQL 对 DATE 的规范字符串表示,没有日期时保存 NO_DATE。MySQL DATE 的规范字符串是 YYYY-MM-DD,因此不会与 NO_DATE 冲突。

CREATE TABLE inventory_unit (
  inventory_unit_id BIGINT PRIMARY KEY,
  product_id        BIGINT NOT NULL,
  sku_id            BIGINT NOT NULL,
  scope_type        VARCHAR(32) NOT NULL,
  scope_id          VARCHAR(64) NOT NULL,
  unit_type         VARCHAR(32) NOT NULL,
  management_type   VARCHAR(32) NOT NULL,
  deduct_timing     VARCHAR(32) NOT NULL,
  supplier_id       BIGINT NOT NULL DEFAULT 0,
  batch_id          BIGINT NOT NULL DEFAULT 0,
  calendar_date     DATE NULL,
  calendar_date_key VARCHAR(10) GENERATED ALWAYS AS (
    COALESCE(CAST(calendar_date AS CHAR), 'NO_DATE')
  ) STORED,
  status            VARCHAR(32) NOT NULL,
  inventory_epoch   BIGINT NOT NULL DEFAULT 1,
  created_at        DATETIME NOT NULL,
  updated_at        DATETIME NOT NULL,
  UNIQUE KEY uk_unit_scope (
    product_id, sku_id, scope_type, scope_id,
    batch_id, calendar_date_key, supplier_id
  )
) ENGINE = InnoDB;

这样一来,同一组业务维度下的无日期库存都使用唯一键值 NO_DATE;有日期库存按规范日期字符串区分,不依赖 MySQL 唯一索引对 NULL 的行为,也不占用任何合法 DATE 值。inventory_epoch 是库存单元的世代号。活动重置、全量重建或灾备切换时递增它,任何旧预占、旧释放和旧投影都必须带上旧 epoch;写入新世代时直接拒绝。它解决的是“旧操作写入新库存”的问题,不等价于数据库行版本。

CREATE TABLE stock_balance (
  inventory_unit_id BIGINT PRIMARY KEY,
  total_qty        BIGINT NOT NULL DEFAULT 0,
  available_qty    BIGINT NOT NULL DEFAULT 0,
  booking_qty      BIGINT NOT NULL DEFAULT 0,
  locked_qty       BIGINT NOT NULL DEFAULT 0,
  sold_qty         BIGINT NOT NULL DEFAULT 0,
  invalid_qty      BIGINT NOT NULL DEFAULT 0,
  balance_version  BIGINT NOT NULL DEFAULT 0,
  inventory_epoch  BIGINT NOT NULL,
  projection_watermark BIGINT NOT NULL DEFAULT 0,
  updated_at       DATETIME NOT NULL,
  CONSTRAINT ck_non_negative
    CHECK (
      total_qty >= 0 AND available_qty >= 0
      AND booking_qty >= 0 AND locked_qty >= 0
      AND sold_qty >= 0 AND invalid_qty >= 0
    )
) ENGINE = InnoDB;

生产环境还应在应用层验证:

total_qty =
  available_qty
  + booking_qty
  + locked_qty
  + sold_qty
  + invalid_qty

数据库 CHECK 约束是最后一道保护,不能替代领域服务对状态迁移的校验。每次迁移都应记录桶的 delta,例如 Reserve 是 available=-2, booking=+2,而不是写一条没有来源的“库存减 2”。

CREATE TABLE inventory_reservation (
  reservation_id  VARCHAR(64) PRIMARY KEY,
  inventory_unit_id BIGINT NOT NULL,
  order_id        VARCHAR(64) NOT NULL,
  quantity        BIGINT NOT NULL,
  inventory_epoch BIGINT NOT NULL,
  status          VARCHAR(32) NOT NULL,
  reserve_expire_at DATETIME NULL,
  request_hash    CHAR(64) NOT NULL,
  created_at      DATETIME NOT NULL,
  updated_at      DATETIME NOT NULL,
  UNIQUE KEY uk_order_unit (order_id, inventory_unit_id),
  UNIQUE KEY uk_request_hash (request_hash),
  KEY idx_expire (status, reserve_expire_at)
) ENGINE = InnoDB;
CREATE TABLE stock_ledger (
  ledger_id       BIGINT PRIMARY KEY AUTO_INCREMENT,
  operation_id    VARCHAR(64) NOT NULL,
  inventory_unit_id BIGINT NOT NULL,
  reservation_id  VARCHAR(64) NULL,
  aggregate_version BIGINT NOT NULL,
  change_type     VARCHAR(32) NOT NULL,
  available_delta BIGINT NOT NULL DEFAULT 0,
  booking_delta   BIGINT NOT NULL DEFAULT 0,
  locked_delta    BIGINT NOT NULL DEFAULT 0,
  sold_delta      BIGINT NOT NULL DEFAULT 0,
  before_payload  JSON NOT NULL,
  after_payload   JSON NOT NULL,
  reason          VARCHAR(256) NOT NULL,
  operator_type   VARCHAR(32) NOT NULL,
  created_at      DATETIME NOT NULL,
  UNIQUE KEY uk_operation (operation_id),
  UNIQUE KEY uk_unit_version (inventory_unit_id, aggregate_version),
  KEY idx_reservation (reservation_id, created_at)
) ENGINE = InnoDB;

stock_ledger 只允许追加,不允许通过 UPDATE 把过去的错误覆盖掉。纠正错误要追加冲正或修复流水,记录原因、操作者和关联任务。这样对账才能区分原始事实、补偿事实和人工调整。

CREATE TABLE inventory_outbox (
  event_id        VARCHAR(64) PRIMARY KEY,
  aggregate_id    VARCHAR(64) NOT NULL,
  aggregate_version BIGINT NOT NULL,
  event_type      VARCHAR(64) NOT NULL,
  payload         JSON NOT NULL,
  status          VARCHAR(16) NOT NULL,
  retry_count     INT NOT NULL DEFAULT 0,
  next_retry_at   DATETIME NULL,
  lease_owner     VARCHAR(64) NULL,
  lease_expire_at DATETIME NULL,
  last_error      VARCHAR(512) NULL,
  created_at      DATETIME NOT NULL,
  sent_at         DATETIME NULL,
  UNIQUE KEY uk_aggregate_event (
    aggregate_id, aggregate_version, event_type
  ),
  KEY idx_dispatch (status, next_retry_at, lease_expire_at)
) ENGINE = InnoDB;

Outbox 的 event_id 必须稳定。Relay 发送成功但还没来得及标记 SENT 时,重启可能再次发送同一事件;这不是 Outbox 失败,而是至少一次投递下的正常窗口。消费者要按 event_id 或 aggregate_id + aggregate_version 去重。Kafka 官方文档也提醒,at-least-once 允许重复,exactly-once 需要同时说明发布和消费两端的边界,不能只看一句产品宣传[16]。

12.4.3 MySQL CAS、锁定读和批量投影

对于不需要 Redis 热路径的数量库存,可以用条件更新实现原子扣减:

UPDATE stock_balance
SET available_qty = available_qty - :qty,
    booking_qty = booking_qty + :qty,
    balance_version = balance_version + 1,
    updated_at = CURRENT_TIMESTAMP
WHERE inventory_unit_id = :unit_id
  AND inventory_epoch = :epoch
  AND available_qty >= :qty
  AND balance_version = :expected_version;

只有 affected_rows = 1 才表示本次预占成功。affected_rows = 0 需要区分库存不足、版本冲突、单元冻结和旧 epoch,不能统一返回“库存不足”。

如果一次事务需要先读取再更新相关行,可以使用 SELECT … FOR UPDATE 或 FOR SHARE 这样的锁定读。MySQL 文档明确指出,普通 SELECT 在同一事务中并不自动阻止其他事务修改刚刚读到的行,锁定读提供了额外保护[14];同时,默认隔离级别下的一致性读与锁定读使用不同语义,设计者不能把二者混成一个“读到最新值”的承诺[15]。

批量投影要避免长事务:

  1. 用状态和租约批量领取一小批 Outbox 或 Document;
  2. 在本地事务中更新连续版本;
  3. 快速提交;
  4. 提交后发送外部消息或更新 Redis;
  5. 结果未知时保留原 operation_id,不新建一条可能重复的操作。

投影消费者应记录 projection_watermark。只有当某个库存单元的 Balance 已处理到目标事件版本,才可以把它与 Redis 快照比较;拿旧 Balance 和新 Redis 直接比较,会把正常的投影延迟误判成库存泄露。

12.4.4 Redis 热视图和库存世代

Redis 适合承接高并发读写,但它的定位应是热路径执行层和查询投影。一个数量库存的示例 key 可以是:

inventory:qty:{inventory_unit_id}
inventory:reservation:{reservation_id}
inventory:guard:{inventory_unit_id}
inventory:epoch:{inventory_unit_id}

主库存 Hash 至少包含:

available
booking
locked
sold
epoch
balance_version

Reservation 记录至少包含:

reservation_id
inventory_unit_id
quantity
epoch
status
reserve_operation_id
expire_at

Redis 脚本可以把“检查可售、检查幂等、检查 epoch、移动桶、记录 Reservation”放在一次服务端执行中。Redis 官方文档保证 Lua 脚本在执行期间具有原子执行语义,但脚本会阻塞其他命令,脚本缓存也可能因重启或故障转移而丢失,因此脚本必须短小、版本化并可重新加载[12]。原子执行只保证 Redis 内部的操作不被其他 Redis 命令插入,不会自动把 Redis 和 MySQL 变成本地事务。

12.4.5 券码池的权威性

券码池必须支持从事实重建热队列。示例结构如下:

CREATE TABLE inventory_code_00 (
  code_id          BIGINT PRIMARY KEY,
  batch_id         BIGINT NOT NULL,
  inventory_unit_id BIGINT NOT NULL,
  code_cipher      VARBINARY(1024) NOT NULL,
  code_hash        CHAR(64) NOT NULL,
  status           VARCHAR(32) NOT NULL,
  reservation_id   VARCHAR(64) NULL,
  order_id         VARCHAR(64) NULL,
  inventory_epoch  BIGINT NOT NULL,
  version          BIGINT NOT NULL DEFAULT 0,
  expire_at        DATETIME NULL,
  created_at       DATETIME NOT NULL,
  updated_at       DATETIME NOT NULL,
  UNIQUE KEY uk_batch_hash (batch_id, code_hash),
  KEY idx_pool (batch_id, status, code_id),
  KEY idx_reservation (reservation_id),
  KEY idx_order (order_id)
) ENGINE = InnoDB;

Redis List 只保存 code_id,不保存明文 code_cipher。出码时先用 Lua 从 List 弹出候选 ID,再执行数据库条件更新:

UPDATE inventory_code_00
SET status = 'BOOKING',
    reservation_id = :reservation_id,
    order_id = :order_id,
    inventory_epoch = :epoch,
    version = version + 1,
    updated_at = CURRENT_TIMESTAMP
WHERE code_id = :code_id
  AND status = 'AVAILABLE'
  AND inventory_epoch = :epoch;

更新成功才算锁码成功。更新失败说明热队列里存在陈旧候选或世代不一致,应该丢弃候选并继续取码,而不是把 Redis 的弹出结果直接当作发码成功。Redis 丢失后,Worker 可以按批次和状态索引扫描 AVAILABLE 码重新填充热队列。

12.4.6 台账、余额和投影的恢复关系

Balance 表适合快速判断当前数量,但它的单行数值不一定能解释“为什么是这个数”。Ledger 适合解释变化,却不一定适合承担每次在线读。Reservation 连接了库存事实和订单意图,Outbox 连接了本地提交和外部传播。四者的恢复关系可以概括为:

initial_balance
  + sum(valid ledger deltas)
  - sum(cancelled or superseded adjustments)
  = reconstructed balance

reconstructed balance
  + active reservation allocation
  + projection watermark
  = query model at a known version

公式只是概念模型,不能直接把所有流水相加。Ledger 需要区分授权调账、预占、确认、释放、退款和无效事件;同一个 operation_id 的重复事件只计一次;被拒绝的命令不能改变余额;不同 epoch 的投影事件不能跨世代相加。对账程序应明确事件过滤规则,并输出“使用了哪些版本、忽略了哪些事件、是否存在缺口”。

调账是最容易破坏恢复能力的操作。系统不应提供“把 available 改成 100”这种没有上下文的管理接口,而应要求:

  • adjustment_id 和审批人;
  • 调整前快照及其版本;
  • 增量或目标值的业务原因;
  • 关联工单、批次、供应商或盘点证据;
  • 调整后的 Ledger 事件;
  • 对投影、订单和报表的影响范围。

如果业务确实需要设置绝对值,也应把它表达为“基于 expected_version 的校正命令”。版本不匹配时,管理员重新读取并确认,而不是静默覆盖其他人的新事实。腾讯云的 Redis 命令与版本资料提醒使用者关注命令语义、版本差异和运行环境边界[23][24][25];同样的原则也适用于库存的恢复脚本:脚本版本、数据版本和运行环境都必须可记录。

归档策略要区分在线恢复与审计恢复。在线系统可能只保留最近几个月的热台账,并把更早的记录写入低成本归档存储;但查询旧订单时仍应能通过摘要索引定位归档分片。归档前计算校验和,恢复时验证校验和,避免“文件存在”被误认为“事实完整”。对于含有券码明文、供应商凭证或用户信息的记录,归档还要执行脱敏、加密和访问控制,不能为了审计而扩大敏感数据暴露面。

这套分层的好处是故障时能选择最小恢复范围:只丢 Redis,重建投影;只丢 Outbox Relay,继续补发;只丢一个消费者分区,按 watermark 重放;只有事实库损坏时,才需要进入更高等级的备份恢复。每种恢复都要定义停止条件,防止修复任务在事实不确定时继续写入新承诺。

12.4.7 索引、分片与批量导入

库存表的索引应围绕状态迁移和治理任务设计,而不是给每个查询字段都建立索引。在线 Reserve 通常按 inventory_unit_id 读取并条件更新;过期清理按 state、expires_at 和租约字段扫描;对账按 aggregate_id、epoch、version 读取;券码导入按 batch_id、code_hash 和 status 批量处理。索引列顺序应匹配最常用的过滤条件,否则所谓“有索引”仍可能扫描大量无关记录。

热点库存的分片有三种常见语义:

分片方式优点代价
按 inventory_unit_id语义简单,单资源易串行化单一热点商品仍可能集中
按资源桶或码段分散写竞争,可并行处理需要跨桶汇总和公平分配
按租户、渠道或区域隔离流量和配额跨范围调拨与组合预占复杂

分片键一旦进入 operation_id、事件分区和缓存 key,就会影响长期演进。不能只根据当前热点选择一个随机 hash;如果未来需要按渠道冻结、按区域对账或按批次回收,分片键必须保留这些维度,或提供从聚合 ID 到分片位置的稳定目录。

批量导入也应走状态机。文件上传成功只表示原始材料已保存;解析完成、字段校验通过、重复码检查完成、写入事实库、投影预热和发布资格分别对应不同阶段。批量任务需要 batch_operation_id、行号、错误原因、成功数量、失败数量和可重试位置。重试从断点继续时,已经成功的行必须按 code_hash 或行级 operation_id 去重。

大批量写入不能长时间占用在线扣减需要的锁。可以先导入到 staging 表,再分批转入正式表;每批提交后发布版本事件;最后经过数量、校验和与抽样读取校验才把批次置为 ACTIVE。批次半成品不应进入可售投影。若导入中途取消,已写入的码进入 INVALID 或 CANCELLED,并保留导入文件摘要,不能简单删除后让审计记录看起来像从未发生。

分区和归档还会改变查询语义。跨分区的“当前可售总量”可能需要读取多个版本,不能把不同时间的分片结果直接相加;重建任务应先确定快照边界,再在同一个边界内读取各分片。容量、索引和分片选择应通过真实热点和批量恢复压测确认,不应只凭单表基准推导整个库存系统的 SLO。

12.5 参考架构与核心交易链路

12.5.1 端到端架构

库存服务可以按以下方式组织。同步路径只承担必须立即知道的结果,异步路径负责传播、投影、重试、对账和恢复。

flowchart LR
    U[用户/订单编排] --> API[Inventory API]
    API --> V[资格/版本校验]
    V --> CMD[Document Command]
    CMD --> DB[(Relational Fact Store)]
    API --> HOT[Redis Lua Hot Path]
    HOT --> RES[Reservation Record]
    DB --> OUT[Transactional Outbox]
    OUT --> RELAY[Outbox Relay]
    RELAY --> MQ[Message Broker]
    MQ --> PROJ[Balance/Query Projector]
    PROJ --> DB
    PROJ --> HOT
    API --> SUP[Supplier Adapter]
    SUP --> EXT[External Supplier]
    SWEEP[TTL/Delay/Sweep/Reconcile] --> DB
    SWEEP --> HOT
    SWEEP --> SUP
    DB --> AUDIT[Ledger and Audit]
    AUDIT --> OBS[Metrics/Trace/Alert]

图中的关系型事实库保存命令状态、预占关联、不可变流水和 Outbox;Redis 保存当前热视图和幂等执行记录;消息系统负责至少一次传播;Supplier Adapter 隔离外部协议、超时、熔断和结果查询。任何一个投影都应该能够被删除后重建,任何一个外部调用都应该有明确的未知结果状态。

12.5.2 创建库存与商品发布

库存创建不是在商品保存时随手插入一行数字,而是一条可重试的命令:

CreateInventory
  -> 校验 product/sellable 版本和库存策略
  -> 生成 inventory_unit_id、batch_id、inventory_epoch
  -> 创建 Document
  -> 初始化 Balance / code pool / supplier snapshot
  -> 写 Ledger(如果有初始数量)
  -> 写 Outbox: InventoryCreated / InventoryReady
  -> 异步预热 Redis、导入券码或启动供应商同步

创建结果应区分:

  • CREATED:命令已接受,尚未完成初始化;
  • READY:本地事实和必要投影已完成,可进入发布判断;
  • ACTIVE:商品、库存、价格、履约和供应商资格都满足;
  • PARTIAL:部分批次或码导入完成,需要异步继续;
  • FAILED_RETRYABLE:外部或基础设施错误,可以用同一命令重试;
  • FAILED_MANUAL:输入或数据冲突,需要人工处理。

商品发布成功不等于库存可以售卖。例如码批次尚未导入、供应商尚未确认、渠道库存未分配或可售时间尚未开始时,库存仍应返回不可售原因。把所有条件合成一个 sellable_projection 可以改善交易体验,但这个投影必须携带版本和失效时间,不能成为新的无来源事实。

12.5.3 数量制的正常交易链路

以一个需要支付前锁定的数量制库存为例:

sequenceDiagram
    participant C as Client
    participant O as Order Orchestrator
    participant I as Inventory
    participant R as Redis
    participant D as Fact Store
    participant P as Payment
    participant W as Sweep

    C->>O: Submit(idempotency_key)
    O->>D: persist PENDING intent + stable operation_id
    O->>I: Reserve(reservation_id, unit, qty)
    I->>R: Lua check + move available -> booking
    R-->>I: RESERVED or known duplicate
    I->>D: commit Reservation + Ledger + Outbox
    D-->>I: PAYMENT_ELIGIBLE after durable confirmation
    I-->>O: reserve success
    O->>P: Create payment after eligibility
    P-->>O: paid callback (may repeat)
    O->>I: Confirm(confirm_operation_id)
    I->>R: Lua booking -> sold
    I->>D: Confirm Ledger + Outbox
    D-->>I: CONFIRMED
    Note over W,D: Due index / Delay / DB Sweep; TTL does not release stock

真正的 Reserve 不是“检查一下 Redis 还有没有”,而是一次具备幂等性的状态迁移。应用层可以先创建 reservation_id,但不能在 Redis 失败后生成新的 ID 重新扣减;否则第一次请求可能已经成功,第二个 ID 会造成重复占用。

上面的 Redis 脚本只是同一 Redis Cluster 槽位内的热路径原子迁移,不能与 MySQL 事务原子提交。调用脚本前,服务先用稳定的 operation_id 在事实库持久化 PENDING 意图;脚本返回后,再由 MySQL 本地事务写入 Reservation、库存流水和 Outbox。只有事实库确认该操作后,订单编排器才能创建支付单。若 Redis 调用超时,使用相同的 reservation_id、operation_id、数量和 epoch 查询或重试;保留的 Redis 操作标记会返回原结果。若 Redis 状态丢失或无法证明当前 epoch 连续,则冻结该库存单元,以已提交的事实重建数量投影和 Reservation 标记,并先处理完所有 PENDING 意图,再恢复 Reserve。结果未知期间不得返回支付资格,也不得换新 ID 再扣一次。

支付回调也必须以状态机为准。第一次回调推动 PAYMENT_ELIGIBLE -> CONFIRMING -> CONFIRMED;同一回调重试返回第一次的结果;如果 Reservation 已经 RELEASED,则返回业务冲突并触发订单/支付人工策略,不能因为回调晚到而重新增加 sold。

12.5.4 支付前的最终库存资格

商品页展示的库存和支付页的 Check 都有时间窗口竞态。若业务不能接受“支付成功后才发现库存不足”,就必须把最终库存资格确认放在创建支付单之前:

提交订单
  -> 价格/营销/风控校验
  -> 创建 INIT Document 和 reservation_id
  -> Redis 快速预占(可选)
  -> 关系型主库 CAS 确认 available >= qty
  -> 同事务写 Reservation + Reserve Ledger + Outbox
  -> 提交成功后才创建支付单
  -> 支付回调只负责 Confirm

这个流程的代价是一次额外的数据库往返和热点行竞争。可以用分段库存、固定分片、批量 CAS、支付授权后 capture 或排队模式缓解,但不能通过“先让用户付款,再异步看库存”把风险转给用户。Amazon 对幂等 API 的工程经验也强调,重试只有在操作副作用可以被安全识别和去重时才有意义[19];支付和库存都属于不能依靠猜测修复的副作用。

12.5.5 超时、取消和释放

释放是最常见也最容易被低估的库存操作。至少应有三道触发:

  1. Redis 到期索引或扫描,尽快发现过期;Reservation 操作标记不得因 TTL 自动删除;
  2. 延时任务,在订单进入待支付时安排一次低延迟触发;
  3. 数据库 Sweep,周期扫描所有 RESERVED 且 reserve_expire_at < now 的记录,作为完整性后盾。

延时任务可以丢失、重复或延迟,所以它不能作为唯一事实来源。Redis 中的 reserve_expires_at 只用于到期发现,不设置会自动删除幂等标记的 TTL;操作标记至少保留到数据库已记录终态且超过约定的重试保留期,清理时仍以数据库状态为依据。超过保留期的客户端重试必须先按 operation_id 查询数据库终态,不能绕过事实库直接再次调用 Reserve。Sweep 也不能直接把所有过期行加回库存;它需要先用租约领取 Reservation,再用状态 CAS:

RESERVED
  -> RELEASING(operation_id)
  -> 查询 Redis booking 记录
  -> BOOKED 且 epoch 相同:执行幂等 Release
  -> 已 RELEASED:返回首次成功
  -> KEY_MISSING / EPOCH_MISMATCH / timeout:RECONCILE_REQUIRED

只有 Redis 明确返回释放成功,或者冻结重建已经把该 Reservation 计入权威结果,才能写 RELEASED Ledger。没有证据证明“从未扣减”时,不能直接忽略;没有证据证明“已经扣减”时,也不能再执行一次扣减。结果未知首先是状态,不是失败码。

12.5.6 降级模式

降级策略必须按风险排序:

故障严格模式有条件模式禁止行为
Redis 不可用切到数据库 CAS 或拒绝 Reserve仅冷门库存使用数据库路径先返回成功再等恢复
主库不可用不创建支付资格只提供陈旧查询使用旧快照放行新订单
消息堆积同步事实仍可处理,限制新流量降低非关键事件频率无限重试和无限扩容
供应商超时返回确认中或失败只对明确支持预订的品类重试把超时当作无货或有货
对账发现偏差冻结单元并重建无风险的查询继续直接加减差值

Google SRE 对过载的建议是,系统应在无法提供完整结果时考虑更低成本的降级结果,极端情况下快速返回错误也比继续放大负载更安全[10]。库存系统的“降级结果”不能是一个未经确认的成功;它可以是“库存确认中”“请稍后重试”或“该供应商暂时不可用”。

12.5.7 正常路径与异常路径的同一张状态表

系统设计文档如果只画正常时序图,评审者很难判断异常时的责任归属。可以把关键阶段压缩成一张状态表,要求每个状态都有进入条件、允许动作和终态证据:

阶段进入条件允许动作终态证据失败后的归宿
INIT客户端提交有效命令校验、取消Document 已记录参数错误或可重试
RESERVE_PENDING开始分配资源原子预占、超时Reservation + Ledger未知、释放或人工
RESERVED资源已隔离Confirm、Cancel、查询预占版本与过期时间Confirm 或 Release
CONFIRMING下游结果已到达验证归属、提交Confirm Ledger重试或人工
CONFIRMED承诺已完成履约、售后、审计订单与资源绑定按售后规则处理
RELEASING释放意图成立状态 CAS、恢复投影Release Ledger对账或冻结
RECONCILE_REQUIRED结果无法判断查询、重放、人工对账证据RELEASED、CONFIRMED 或拒绝

这张表还有一个用途:识别“不可达状态”。例如,如果 RESERVE_PENDING 没有超时处理,Redis 超时后它会永久占用在线操作;如果 CONFIRMING 没有查询接口,支付重复回调只能靠人工猜测;如果 RECONCILE_REQUIRED 能被普通重试直接跳过,系统就可能绕过对账保护。

每个状态的 API 返回也要与状态机一致。RESERVED 不应返回“已购买”,CONFIRMING 不应返回“已发码”,RELEASING 不应返回“库存已恢复”。如果前端为了简化只显示三种颜色,后台仍要保留完整状态,因为客服、对账和恢复任务需要更细的证据。

跨系统时序图还应标出提交点。请求发送到 Redis 不等于事实提交;数据库提交后 Outbox 生成才表示本地事实已接管异步传播;下游确认事件被事实库接收后,才表示外部副作用达到业务要求。把这些点画出来,可以在评审时直接讨论网络断开发生在哪一条箭头上,以及对应的查询和补偿是谁负责。

12.5.8 同步路径、异步路径与用户承诺

同步路径的每一个步骤都会增加请求延迟,但也会减少用户看到“未知”的概率;异步路径可以提高吞吐,却要求系统长期维护处理中状态、查询接口和补偿队列。设计时可以把动作分成三类:

动作适合同步完成的部分适合异步完成的部分
Reserve资格判断、资源隔离、操作结果搜索更新、统计、通知
Confirm预占状态推进、订单归属校验履约通知、报表、外部回执
Release释放意图和状态 CAS缓存修复、历史归档、对账
Provider booking幂等请求提交或已有结果读取轮询、超时查询、人工升级
Adjust审批和本地事实写入多级投影、报表刷新

“同步完成”也不能只按 HTTP 返回判断。一次 Reserve 返回成功,至少要说明成功的是哪一层:是本地事实已提交、Redis 已扣减、外部供应商已锁定,还是仅仅接收了一个异步命令。API 可以用 outcome、state、resource_id、version 和 next_action 字段把这些含义表达出来:

{
  "outcome": "ACCEPTED",
  "state": "RESERVE_PENDING",
  "reservation_id": "res_30001",
  "source_version": 42,
  "next_action": "POLL_STATUS",
  "status_url": "/inventory/operations/op_50001"
}

如果调用方只接受 SUCCESS/FAILURE 两个枚举,服务会被迫把 ACCEPTED、UNKNOWN、RETRYABLE_FAILURE 和 BUSINESS_REJECT 混成一个值,最终导致调用方错误重试或错误展示。将结果类型扩展为 ACCEPTED、COMMITTED、REJECTED、UNKNOWN、MANUAL_REQUIRED,可以让订单、支付和客服分别采取正确动作。

异步消费者也要有优先级。释放、取消和风险冻结通常比展示统计重要;一个消费者组长时间处理推荐刷新,不应阻塞库存状态收敛。可以为事件设置 category、priority、deadline 和 retry_class,并把不同类别放入不同队列或分区。队列拆分会增加运维成本,但它把“核心恢复”和“非关键刷新”从同一条拥塞链路中分开。

排队本身也会改变公平性。按用户排队可以防止单个用户占满线程,按商品排队可以保护热点资源,按租户排队可以隔离大客户;但队列过长时必须返回预计等待或明确拒绝,不能让请求无限悬挂。排队记录仍然要带 operation_id,消费者重启后不能因为重新入队生成新的业务意图。

对于高价值、不可逆的资源,宁愿把用户留在“确认中”,也不应在缺少事实证据时返回已确认。对于可补充、低风险的资源,可以在容量允许时使用异步履约,但仍需保留最终失败和人工补发路径。同步/异步的选择由资源可逆性、用户等待容忍度、下游协议和补偿能力共同决定,而不是由“微服务都应该异步”这种口号决定。

12.6 数量制、券码制、供应商库存与无限库存

12.6.1 数量制热点库存

数量制最容易实现,但也是最容易因为热点而失控的类型。基础 Redis Lua 脚本至少应完成以下操作:

  1. 检查库存单元处于 ACTIVE;
  2. 检查请求 epoch 与 key epoch 一致;
  3. 检查 Reservation 是否已存在;
  4. 检查可售量是否足够;
  5. 移动 available -> booking;
  6. 保存 Reservation 数量、操作 ID、epoch 和过期时间;
  7. 返回明确结果码。

下面的脚本演示 Redis 热视图中的 Reserve 原子迁移。调用方必须先在事实库持久化 PENDING 意图,且只有 MySQL 随后确认 Reservation、流水和 Outbox 后才可把结果作为支付资格。Redis 中的库存 Hash 由事实库投影初始化,status 必须是 ACTIVE。操作标记不设置自动过期;超时重试复用同一组参数,Redis 状态重建前冻结该单元并处理完所有待定意图。

-- KEYS[1]: inventory:{unit_id}:qty
-- KEYS[2]: inventory:{unit_id}:reservation:{reservation_id}
-- KEYS[3]: inventory:{unit_id}:reservation_due
-- ARGV[1]: quantity
-- ARGV[2]: expected_epoch
-- ARGV[3]: reserve_expires_at (Unix timestamp; for due scanning)
-- ARGV[4]: operation_id
local stock_key = KEYS[1]
local reservation_key = KEYS[2]
local due_key = KEYS[3]
local qty = tonumber(ARGV[1])
local expected_epoch = ARGV[2]
local reserve_expires_at = tonumber(ARGV[3])
local operation_id = ARGV[4]

if not qty or qty <= 0 or qty % 1 ~= 0 then
  return {-2, 'INVALID_QUANTITY'}
end
local max_exact_integer = 9007199254740991
if qty > max_exact_integer then
  return {-2, 'INVALID_QUANTITY'}
end
if not operation_id or operation_id == '' then
  return {-5, 'INVALID_OPERATION_ID'}
end
if not reserve_expires_at or reserve_expires_at <= 0 then
  return {-6, 'INVALID_EXPIRY'}
end
if reserve_expires_at % 1 ~= 0 then
  return {-6, 'INVALID_EXPIRY'}
end

local stock_type = redis.call('TYPE', stock_key).ok
local reservation_type = redis.call('TYPE', reservation_key).ok
local due_type = redis.call('TYPE', due_key).ok
if stock_type ~= 'hash' then
  return {-9, 'INVALID_STOCK_KEY'}
end
if reservation_type ~= 'none' and reservation_type ~= 'hash' then
  return {-7, 'CORRUPT_RESERVATION'}
end
if due_type ~= 'none' and due_type ~= 'zset' then
  return {-10, 'INVALID_DUE_INDEX'}
end

if redis.call('EXISTS', reservation_key) == 1 then
  local existing_operation_id =
    redis.call('HGET', reservation_key, 'operation_id')
  local existing_quantity =
    tonumber(redis.call('HGET', reservation_key, 'quantity') or '')
  local existing_epoch = redis.call('HGET', reservation_key, 'epoch')
  local existing_expiry =
    tonumber(redis.call('HGET', reservation_key, 'reserve_expires_at') or '')
  if not existing_operation_id then
    return {-7, 'CORRUPT_RESERVATION'}
  end
  if existing_operation_id == operation_id
    and existing_quantity == qty
    and existing_epoch == expected_epoch
    and existing_expiry == reserve_expires_at then
    return {1, redis.call('HGET', reservation_key, 'status') or 'UNKNOWN'}
  end
  return {-3, 'RESERVATION_CONFLICT'}
end

local status = redis.call('HGET', stock_key, 'status')
if status ~= 'ACTIVE' then
  return {-8, 'INVENTORY_NOT_ACTIVE'}
end

local epoch = redis.call('HGET', stock_key, 'epoch')
if not epoch or epoch ~= expected_epoch then
  return {-4, 'EPOCH_MISMATCH'}
end

local available = tonumber(redis.call('HGET', stock_key, 'available') or '0')
local booking = tonumber(redis.call('HGET', stock_key, 'booking') or '0')
if not available or available < 0 or available % 1 ~= 0
  or available > max_exact_integer
  or not booking or booking < 0 or booking % 1 ~= 0
  or booking > max_exact_integer - qty then
  return {-11, 'CORRUPT_STOCK_BUCKET'}
end
if available < qty then
  return {-1, 'INSUFFICIENT'}
end

redis.call('HINCRBY', stock_key, 'available', -qty)
redis.call('HINCRBY', stock_key, 'booking', qty)
redis.call('HSET', reservation_key,
  'operation_id', operation_id,
  'quantity', qty,
  'epoch', epoch,
  'reserve_expires_at', tostring(reserve_expires_at),
  'status', 'RESERVED')
redis.call('ZADD', due_key, reserve_expires_at, reservation_key)
return {0, 'RESERVED'}

三个 Redis Key 都使用 {unit_id} 作为 hash tag,因此在 Redis Cluster 中落到同一个 slot。Redis 官方文档说明,Lua 脚本执行时会阻塞其他 Redis 活动,因而能把多步条件更新作为一个原子脚本执行[12];但这个原子性只覆盖 Redis 内部,不能代替 MySQL 事务。reservation_due 只用于发现到期项;Confirm 或 Release 进入终态时,应在对应脚本中同时从该有序集合移除 Reservation。脚本越长,阻塞时间越大;腾讯云中文命令准则也明确区分原生命令、pipeline 和 Lua,并提醒集群脚本的 key 槽位要求[23]。因此,Lua 适合封装短小的库存状态迁移,不适合在脚本中调用外部服务、扫描大集合或实现复杂 Saga。

热点库存的扩展手段有四类:

  • 网关限流和用户维度防刷,减少不会成功的请求进入热路径;
  • 分段库存,把一个大数量拆成多个 segment,由分配器选择可用段;
  • 排队,把提交变成带结果查询的异步请求,以吞吐换用户交互;
  • 本地微缓存只用于读展示,不用于最终 Reserve,避免陈旧数据导致放行。

分段库存不是免费扩容。它会带来库存搬迁、段耗尽不均、回收和对账复杂度。如果业务必须严格按一个全局数量排序,分段会改变公平性;如果业务更重视峰值承载,分段可能是合理的取舍。ADR 中必须把这个牺牲写出来。

12.6.2 券码制库存

券码库存的扣减单位不是整数,而是一个可以被用户领取、展示、核销或发送给供应商的唯一资源。推荐链路如下:

批量导入/生成
  -> code_cipher 加密存储
  -> code_hash 唯一去重
  -> AVAILABLE 码池
  -> Redis 预热 code_id
  -> Reserve 弹出候选 code_id
  -> MySQL CAS: AVAILABLE -> BOOKING
  -> Confirm: BOOKING -> SOLD
  -> 履约: SOLD -> REDEEMED / VOIDED

Redis 弹出和数据库 CAS 之间存在一个窗口:Redis 可以已经删除候选,但数据库更新失败。这个候选不能立刻视为丢失;对账任务可以根据 AVAILABLE 状态重新回填,或者在短时租约到期后恢复。相反,如果数据库 CAS 成功而回填 Redis 失败,码仍然属于 BOOKING,不能因为 Redis 看不到就再次分配。

券码的安全规则至少包括:

  1. 热队列只存内部 code_id;
  2. 原文只在受控履约边界解密;
  3. code_hash 用于去重和排查,不用于恢复明文;
  4. 导入任务有批次、操作者、来源和校验摘要;
  5. SOLD、REDEEMED 和 VOIDED 不得直接转回 AVAILABLE;
  6. 发码接口使用订单/履约幂等键,重复请求返回相同的展示结果或已处理状态;
  7. 日志、Trace、监控和客服工具默认不打印明文。

12.6.3 供应商管理库存

平台无法把外部供应商的数据库行锁到自己的事务里,所以供应商管理库存必须区分快照、查询和预订三种语义。

模式本地保存什么可售依据主要风险失败处理
定时快照数量、时间、供应商版本未过期快照窗口内库存已被他人消费缩短 TTL、二次确认
实时查询请求结果和短缓存当前查询结果延迟、限流、结果未知超时标记 unknown,不能盲重试
供应商推送变更事件和版本最新已知版本推送丢失、乱序、重复版本校验、定期拉全量
Provider booking本地映射和外部预订号外部确认号二次确认失败、取消规则差异轮询、补偿、人工队列
混合模式快照 + 实时 + booking按场景分级状态定义复杂文案标注确认等级

如果供应商只支持查询,本地 Reserve 只能表示平台暂时接受订单意图,不能宣称外部资源已经锁定;用户界面应显示“确认中”或在支付前完成 provider booking。若供应商支持 idempotency key,应复用同一 key;若不支持,则至少保存本地 provider request、请求摘要、时间和结果,并限制重试次数。

对外依赖的重试必须区分“明确未执行”和“可能已执行”。Azure 的 Retry 模式建议针对短暂错误设置有限次数、延迟和退避,而不是把重试当作扩容手段[8];其 Retry Storm 反模式进一步指出,无限或过于密集的重试会阻止故障服务恢复,并把局部故障放大成级联故障[9]。供应商库存尤其不能在超时后简单地重复创建预订。

12.6.4 无限库存和组合库存

无限库存不是“不需要库存系统”。例如数字内容、软件授权或实时充值可能没有一个预先装入平台的数量,但仍然有:

  • 用户级领取次数;
  • 地区、渠道或活动配额;
  • 供应商并发限制;
  • 风控和反作弊策略;
  • 履约请求幂等;
  • 供应商回执和失败重试;
  • 审计、退款和人工补发。

系统可以把数量扣减替换为资格凭证:

Eligibility Check
  -> quota/risk/provider check
  -> issue fulfillment token
  -> submit provider request idempotently
  -> receive callback or query result
  -> fulfill / compensate / manual review

组合库存应把每个子库存作为独立聚合,Saga 编排只保存步骤状态和补偿顺序。不可逆步骤之后,不要用“回滚成功”描述一个实际无法撤销的动作;可以进入“履约完成但退款待处理”“部分成功待补发”等明确终态。

12.6.5 类型选择表

判断问题数量制券码制供应商管理无限库存
是否需要逐个资源唯一归属否是取决于供应商否
平台是否拥有最终数量事实通常是码池是否,通常是观察/映射不适用
热路径Lua/CAS/队列取 ID + CAS快照/查询/booking资格/配额
主要安全风险超卖和泄露重复发码和明文泄露过期和双重预订履约重复和供应商限额
释放是否可逆多数可逆展示后可能不可逆取决于供应商取决于履约
恢复依据Ledger/DocumentCode 状态 + Ledger外部确认 + 映射履约审计/供应商回执

选择策略时,先判断“资源是否能被撤回”,再判断“是否需要高并发”。一个可撤回的数量库存可以优先保证吞吐;一个不可撤回的券码和充值请求,即使流量不高,也必须优先保证唯一归属和结果可查。

12.6.6 供应商协议与本地状态的映射

供应商接口的难点不只是网络不稳定,而是双方对状态的词义可能不同。平台的 RESERVED 可能只表示“已向供应商发起请求”,供应商的 RESERVED 才表示“外部资源已锁定”;平台的 CONFIRMED 可能表示订单已支付,供应商的 CONFIRMED 可能表示履约已完成。若不建立映射表,两个系统都返回成功时仍可能出现语义错位。

建议把供应商状态映射显式记录:

本地状态供应商状态本地允许动作是否能向用户承诺
PROVIDER_PENDING未知查询、超时转人工否
PROVIDER_REQUESTED请求已接收等待确认、有限重试否
PROVIDER_RESERVED已预订创建支付或确认视协议而定
PROVIDER_CONFIRMED已确认继续履约是,按协议
PROVIDER_RELEASE_PENDING取消中查询取消结果否
PROVIDER_RELEASED已释放关闭本地预占是,已释放
PROVIDER_UNKNOWN超时或不一致冻结新请求、对账否

本地系统还要保存 provider_request_id、provider_version、provider_operation_id、请求摘要和最后一次查询时间。如果供应商没有幂等接口,本地不能通过更换 request_id 来“碰碰运气”;应该把一次未知请求放进查询队列,并在供应商规定的窗口内轮询。超过窗口仍未知时,进入人工队列或业务向前恢复。

供应商推送和主动查询可能同时到达。两者都应先比较外部版本或事件时间,再通过本地状态机推进;事件重复只返回已有结果,旧事件不能把已 CONFIRMED 的资源改回 REQUESTED。没有外部版本时,必须定义冲突裁决,例如以供应商查询的明确终态覆盖旧推送,或以人工核验为唯一入口。这个裁决要写进协议适配器,而不是分散在订单、库存和客服代码中。

合同、配额和限流也属于库存约束。供应商可能按租户、商品、时间窗或并发数限制调用;平台应为每个适配器维护本地令牌桶、超时、并发上限和熔断状态。限流时可以延迟非关键查询,但不能让 Release 永久饥饿。供应商 SLA 变化、字段版本变化和状态枚举增加,都应触发契约测试与重新评估,而不是等到线上出现大规模 UNKNOWN。

12.6.7 券码、资格凭证与敏感数据

券码库存的正确性和安全性相互关联。只要明文码被写入日志、缓存、消息或测试数据,攻击者就可能在库存状态正确的情况下提前消耗资源。券码表建议只保存加密密文和不可逆摘要,热队列只保存 code_id;读取明文时在最靠近核销或发放的边界解密,并把访问主体、原因和订单关联到审计记录。

导入、展示和发放应使用不同权限:

操作允许读取必须留下的证据
导入原始文件或流式解析内容文件摘要、批次、操作者、行数
Check数量和资格状态查询时间、版本和 freshness
Reservecode_id 及不可逆摘要reservation_id、订单、epoch
Confirm单次明文或兑换凭证发放时间、调用者、核销结果
对账摘要、状态和批次差异原因和修复工单

日志、指标和消息中不能直接包含明文码、完整支付信息或供应商密钥。测试环境应使用不可生产兑换的伪造码,并阻止生产快照直接流入开发环境。数据保留期结束后要安全删除密文、密钥引用和临时导出文件;删除操作本身保留摘要和审批记录,以便证明曾经执行过合规清理。

同一个码在状态上只能有一个有效归属,但“码被发放”与“用户已核销”可能是两个不同事实。发放失败后是否可回收、用户复制后是否可撤销、供应商是否允许重新生成,都应由领域规则决定。不要因为数据库里 status 可以改回 AVAILABLE,就默认业务上可以重新销售。

12.7 接口契约、系统边界与事件协作

12.7.1 Command API 的共同字段

写接口不应暴露“给某字段加 1”这样的底层语义,而应使用强语义命令。以 Reserve 为例:

{
  "request_id": "req_20260921_0001",
  "idempotency_key": "reserve_order_10001_unit_20001",
  "reservation_id": "res_30001",
  "order_id": "order_10001",
  "inventory_unit_id": "unit_20001",
  "quantity": 1,
  "expected_epoch": 7,
  "expire_at": "2026-09-21T12:15:00+08:00",
  "caller": "order-orchestrator",
  "deadline_ms": 500,
  "trace_id": "trace_40001"
}

接口实现需要检查:

  1. idempotency_key 是否已经处理,若已处理则返回首次结果;
  2. 请求参数 hash 是否与第一次一致,防止同一 key 被复用为不同操作;
  3. Reservation 是否属于同一订单、库存单元和 epoch;
  4. 数量是否为正且不超过调用方权限;
  5. deadline 是否已经不足以执行可靠操作;
  6. 调用方是否有权使用该渠道、批次和供应商。

Confirm 和 Release 必须引用 Reservation,而不是只带 SKU 和数量。只带数量的释放无法知道应该释放哪一个批次、哪一个活动锁定和哪一个 epoch,最终只能靠猜测平衡。

12.7.2 Query API 与错误分类

查询接口应返回状态来源和新鲜度:

{
  "inventory_unit_id": "unit_20001",
  "sellable_quantity": 12,
  "availability": "AVAILABLE",
  "source": "REDIS_PROJECTION",
  "inventory_epoch": 7,
  "projection_watermark": 18342,
  "observed_at": "2026-09-21T11:59:58+08:00",
  "expires_at": "2026-09-21T12:00:03+08:00"
}

错误应按调用方可行动性分类:

类别例子调用方动作
INSUFFICIENT已确认没有足够可售量结束本次 Reserve,展示无货
FROZEN单元正在对账/重建不重试同一请求,等待或换资源
STALE版本/epoch 已过期重新获取最新版本
SUPPLIER_UNKNOWN外部结果未知查询状态,不直接创建新预订
DUPLICATE同一幂等键已有结果返回原结果
CONFLICT同一 Reservation 参数冲突进入业务错误或人工处理
RETRYABLE短暂网络/锁冲突在预算内退避重试
MANUAL_REVIEW已发生不可逆或无法自动判断创建人工任务并停止放大

Stripe 的幂等 API 文档给出了一个有用的边界:服务可以保存某个幂等 key 的第一次状态码和响应体,重复请求得到相同结果;如果参数和第一次不同,则应报错,避免调用方误用同一 key[17]。库存接口可以采用同样原则,但保留期限应覆盖订单和补偿窗口,而不只是短暂的 HTTP 重试。

12.7.3 系统职责矩阵

系统OwnsMay callMust not decide失败交接
商品中心商品、SKU、发布版本Inventory Query/Create不决定是否成功预占返回版本冲突
库存系统可售能力、预占和流水商品、订单、支付、供应商不计算价格和优惠Reserve/Confirm/Release 结果
订单系统订单状态和用户交易意图Inventory、Payment、Marketing不直接改库存数字以 reservation_id 继续推进
支付系统授权、扣款、退款Order、Inventory 状态查询不决定库存是否充足重复回调按 key 处理
营销系统活动规则和活动配额Inventory、Pricing不绕过库存状态机补偿锁定或返回资格失败
供应商适配器外部协议、重试、回执Supplier、Inventory不把超时当成功Provider booking/unknown
履约系统出库、发码、出票、核销Inventory、Order不回写任意库存数量以履约事件确认
消息系统事件传递和保留Producers/Consumers不替代事实库依靠重投和死信
对账系统差异检测、冻结、重建建议Fact Store、Redis、Supplier不无证据修正数字人工门闩和修复流水

12.7.4 Outbox、事件和消费者幂等

一个安全的本地事务边界是:

BEGIN
  update InventoryDocument
  update Reservation/Balance
  insert StockLedger
  insert OutboxEvent
COMMIT

Relay:
  claim pending event with lease
  publish the same event_id
  mark SENT only after broker acknowledgement
  on unknown result keep event_id and retry

Outbox 解决的是数据库和消息之间的双写丢失窗口,不解决 Redis 和数据库之间的跨系统原子性,也不让消费者获得 exactly-once。消息消费者应把“已处理事件”作为自己的本地事实,或使用聚合版本条件更新。重复 CONFIRMED 事件只能得到已确认结果,不能再次增加 sold;重复 RELEASED 事件只能 ACK,不能再次增加 available。

消息顺序也不是无条件保证。对同一库存单元可以使用稳定分区键和连续 aggregate_version,但消费者仍要拒绝旧版本、缓存未来版本或进入重放队列。Kafka 官方资料对 at-least-once、exactly-once 的讨论说明,发布、日志提交和消费处理是不同问题[16];库存消费者仍然要在自身数据库里实现幂等。

12.7.5 多资源 Saga 与补偿边界

当一个订单同时需要商品库存、活动配额、供应商预订和履约 token 时,推荐用显式编排记录步骤:

Saga:
  1. reserve activity allocation
  2. reserve platform quantity
  3. request supplier booking
  4. create payment eligibility
  5. confirm each successful step

On failure:
  - stop new forward steps
  - compensate completed reversible steps in reverse order
  - mark irreversible steps as manual/forward-recovery

补偿不是数据库回滚。它是一个新的业务动作,可能失败、延迟或受到当前状态限制。Saga 理论允许局部事务先提交,但也意味着隔离性不足、补偿可能无法完全撤销。Apache Seata 的中文文档明确把“补偿服务由业务实现”和“Saga 不保证隔离性”列为核心注意点[21][22]。库存系统需要把这个事实转译为用户可理解的状态,而不是返回一个模糊的“系统异常”。

12.7.6 接口演进、兼容性与权限边界

库存 API 的字段一旦被订单、支付、客服、报表和供应商适配器使用,就会形成事实契约。新增字段通常比修改字段含义安全;把 RESERVED 改解释为 CONFIRMED,哪怕 JSON 结构没有变化,也会破坏消费者状态机。事件也应携带 schema_version、event_id、aggregate_version、occurred_at 和 producer,否则消费者无法判断一个字段缺失是旧版本兼容还是数据损坏。

写接口可以使用以下兼容策略:

变化兼容方式需要验证
新增可选字段保持旧默认值老消费者忽略未知字段
新增状态先扩展消费者,再发布生产者老消费者遇到未知状态的安全降级
字段改名并行输出一段时间新旧字段是否保持同义
改变单位新建字段或版本数量、金额、时间单位是否一致
删除字段先观测读取量,再延迟删除重放历史事件是否仍可解析
改变失败码保留旧码映射调用方是否会错误重试

Reserve、Confirm、Release 需要不同的权限。普通调用方可以发起自己的 Reservation,但不能指定任意 reservation_id 修改他人资源;订单服务可以请求 Confirm,但不能绕过支付校验;运营人员可以冻结商品,但不能直接覆盖 Balance;对账服务可以提交修复建议,真正的 Adjust 需要审批。权限检查应同时验证主体、租户、资源范围、状态和 operation_id 归属。

接口响应还要防止泄露敏感事实。对外可以返回“库存不足”,不一定返回 exact available;券码接口不能把 code_cipher、批次内部编号和供应商凭证带到普通客户端;错误信息不能暴露 SQL、锁名、内部集群地址或脚本版本。内部诊断通过 trace_id、审计查询和受控后台提供。

版本兼容不是发布流程的最后一步,而是恢复能力的一部分。重放一年前的事件时,当前消费者可能已经删除旧字段;因此要么保存可独立解析的事件快照,要么维护版本转换器。若事件只保留“当前对象指针”,对象后来被覆盖,重放就失去历史语义。对不可变 Ledger 来说,事件载荷的可读性与校验和应比极致压缩更重要。

错误码也应拥有稳定契约。可以把错误分成参数错误、资格错误、资源不足、版本冲突、重复操作、处理中、依赖未知、限流和内部故障,并为每类定义是否安全重试、是否需要查询、是否展示给用户。示例:

错误码类别调用方动作是否进入告警
INVALID_ARGUMENT修正参数后重新发起新操作否
BUSINESS_REJECTED停止自动重试按比例统计
INSUFFICIENT_RESOURCE更新展示或更换资源否
VERSION_CONFLICT重新读取并在原操作上判断观察热点
DUPLICATE_OPERATION查询并返回原结果否
PROCESSING按 status_url 查询超时才告警
DEPENDENCY_UNKNOWN保留原 operation_id,进入补偿是
RATE_LIMITED退避并遵守 Retry-After观察峰值
INTERNAL_ERROR不盲重试,联系治理流程是

错误码不能把“库存不足”伪装成“系统异常”,也不能把“系统异常”伪装成“库存不足”。前者会让用户错误地放弃仍然可用的其他资源,后者会让运营误以为需求不足。错误响应应包含 trace_id、operation_id、可安全执行的 next_action 和必要的重试窗口,但不暴露内部实现细节。

12.8 一致性、幂等、故障恢复与可观测性

库存系统最容易被误解的地方,是把“接口返回成功”当成“所有副作用都已经成功”。实际上,一次扣减可能同时涉及事实库、可售投影、预占记录、订单状态、支付资格、消息发布和供应商通知。它们拥有不同的事务边界,也可能有不同的延迟与失败概率。设计的目标不是把所有组件强行放进一个超大事务,而是让每一个边界都能说明:什么已经确定、什么还在收敛、什么可以补偿、什么必须人工处理。

12.8.1 幂等、并发控制与唯一事实

幂等解决“同一个业务操作被执行多次时,最终效果是否仍然等价”;并发控制解决“两个不同操作同时修改同一事实时,是否违反不变量”。二者经常同时出现,却不能互相替代。一个请求带有相同的操作号,可能是网络重试,也可能是客户端重复提交;一个请求带有不同的操作号,则可能是两个用户争抢最后一个库存。给每个请求加一把分布式锁,并不能自动回答第二个请求是否应该失败,更不能修复锁释放之后的重复消费。

常见的误区包括:

误区实际问题应采用的机制
把 operation_id 当作锁键只能识别相同操作,不能限制不同操作并发唯一键、CAS、行锁或原子脚本
只在缓存里去重缓存丢失后可能重复写事实库事实库唯一约束与操作记录
只在网关去重绕过网关的补偿任务仍可能重复领域服务和数据层双重幂等
只返回“成功/失败”调用方无法区分处理中与未知结果明确的状态机和查询接口
只重试网络错误业务拒绝、超卖或版本冲突会被放大按错误类别建立重试策略

Stripe 的幂等请求设计要求调用方提供幂等键,并把同一个键与第一次请求的结果关联起来[17];AWS Builders’ Library 则强调,重试安全不只是“重复执行不会报错”,还要处理同一意图的参数一致性、超时后的结果查询和跨服务传播[19]。因此,本书建议把 operation_id 设计成业务操作的一等实体:

  1. 接收请求时,先校验 operation_id、业务主体、商品和数量等关键参数。
  2. 对 operation_id 建立唯一约束;若已存在,比较请求摘要。
  3. 摘要一致时返回原操作的当前结果;摘要不一致时返回参数冲突。
  4. 若操作仍处于处理中,返回处理中状态,而不是重新执行。
  5. 若结果未知,优先查询事实库、出站消息和下游确认,再决定是否补偿。

这套规则的前提是系统能持久化操作记录,并且操作记录的生命周期覆盖最慢的客户端重试窗口。它不能阻止恶意用户无限制造新的 operation_id,也不能在所有下游都不可用时凭空判断结果。对于后者,必须依靠对账、人工接管或带版本的重建任务。

在数据库层,库存不变量需要被编码为数据库可以执行的条件,而不是只写在服务代码注释里。数量库存的最小扣减可以抽象为:

UPDATE inventory_balance
SET available = available - :quantity,
    reserved = reserved + :quantity,
    version = version + 1,
    updated_at = CURRENT_TIMESTAMP
WHERE inventory_id = :inventory_id
  AND available >= :quantity
  AND version = :expected_version;

影响行数为 1 才表示条件成立。影响行数为 0 可能是库存不足、版本冲突、记录不存在或状态不允许,服务层应通过再次读取区分这些原因,而不能统一改写为“系统繁忙”。如果使用锁定读,必须明确事务隔离级别、锁的范围和索引是否能够支持预期的锁定行为。MySQL 官方文档分别说明了 locking read 与隔离级别的边界[14][15];它们支持的是特定数据库事务语义,不应被泛化成所有存储的通用保证。

当库存事实由追加式台账表达时,扣减不是修改一行数值,而是追加一条带有原因、操作号和前置余额的记录。Martin Kleppmann 对数据系统的讨论提醒我们,日志、物化视图和派生数据之间应区分事实与读模型[3];Gray 与 Reuter 对事务处理的经典论述则将原子性、持久性、恢复和并发控制视为一组相互配合的机制[4]。因此,台账模式的核心不是“多存一份流水”,而是让每一次变化都可解释、可重放、可对账。

12.8.2 超时、未知结果与重试边界

客户端超时并不等于服务端没有执行。请求可能在服务端已经提交后,才在网络返回阶段丢失;也可能在获得锁之前被取消;还可能已经写入 Outbox,但事件消费者尚未处理。把所有超时都当成失败,会造成重复扣减;把所有超时都当成成功,会把未发生的承诺暴露给用户。

推荐将一次请求的结果划分为四类:

结果事实库下游副作用API 处理
确定成功已提交已提交或由 Outbox 接管返回成功及资源状态
确定拒绝未提交无有效副作用返回业务错误,不自动重试
确定未执行未提交可证明没有副作用可在相同 operation_id 下重试
未知无法直接判断可能已经发生返回查询地址或处理中状态

“未知”是一个合法状态,不是实现失败的借口。查询接口应至少支持按 operation_id、reservation_id、order_id 和版本查询;在必要时支持返回事实证据,例如预占记录创建时间、事实版本、Outbox 状态和最近一次下游确认时间。用户体验可以把未知状态显示为“正在确认”,但后台必须继续推进。

消息语义也不能被简化为“Kafka 是 exactly-once,所以库存不会重复”。Kafka 官方设计文档区分了生产端、消费者提交位点、事务和外部系统副作用之间的语义[16]。当消费者先写数据库、再提交 offset 时,重启可能重复写数据库;当先提交 offset、再写数据库时,崩溃可能造成消息跳过。解决方案通常是消费端幂等、业务唯一键、事务性消费,或把消费结果写入可查询的操作表。所谓 exactly-once,必须限定在具体边界和具体协议内。

因此,重试规则应按错误类别划分:

错误类别示例默认动作
参数错误数量非正、渠道不合法、版本缺失不重试,修正请求
业务拒绝库存不足、已过期、资格不符不重试或改走新的业务操作
可恢复资源错误数据库暂时不可用、连接断开有预算地退避重试
依赖超时供应商未响应、支付查询超时查询状态,避免盲目重放
限流或过载429、队列积压延迟、降级或排队
程序错误未捕获异常、契约不匹配告警、隔离,不快速重试

Microsoft 的 Retry 模式文档强调退避、抖动、重试次数与可重试错误的组合[8];Retry Storm 反模式说明多个服务同时重试会把局部故障放大为级联故障[9]。库存系统还应设置“重试预算”:单个操作最多重试多少次、一个租户每分钟最多消耗多少次、下游异常期间是否暂停新任务。重试预算耗尽后,操作进入待补偿队列,而不是继续占用在线请求线程。

12.8.3 投影漂移、对账与重建

可售库存、缓存数量和搜索结果都属于投影。它们服务于低延迟读取,却不能天然成为不可逆的业务事实。Redis 官方文档分别说明了 Lua 脚本的原子执行边界与事务命令的行为[12][13]:脚本可以把检查与修改放进一个原子执行单元,但这不等于它与关系数据库、消息系统或外部供应商构成跨系统事务。

一个可恢复的投影系统至少需要以下字段:

字段用途
source_version标识事实库已经推进到哪个版本
projection_version标识投影已经应用到哪个版本
rebuild_epoch区分重建前后的同名事件
watermark记录事件读取进度
last_reconciled_at判断对账是否停滞
checksum发现数量、范围或批次的差异

投影写入应带版本条件。例如,版本 105 的事实不能覆盖已经应用到 106 的投影;重建任务也不能把旧 epoch 的延迟消息重新写回新投影。伪代码如下:

if event.epoch != projection.rebuild_epoch:
    send_to_stale_event_queue(event)
elif event.version <= projection.source_version:
    ignore_as_duplicate(event)
elif event.version == projection.source_version + 1:
    apply(event)
    projection.source_version = event.version
else:
    pause_partition_and_repair_gap(event)

这里的连续版本判断适用于要求严格顺序的单实体流;如果采用分区内乱序可接受的模型,则应改用集合去重、版本向量或可交换操作,并重新证明不变量。不能因为某种事件总线“通常按顺序投递”,就把顺序当作永久合同。

对账不是每天把两个数字相减,而是先确定比较的语义。以下三种差异需要分别处理:

  1. 数量差异:事实库中可售为 8,缓存投影为 10。
  2. 范围差异:事实库的渠道范围已经下线,缓存仍然对该渠道开放。
  3. 时间差异:事实库已经确认释放,投影尚未收到释放事件。

对账任务应按照版本和事件时间排序,记录差异原因,区分可自动修复与必须人工确认。自动修复优先采用“从权威事实重算目标投影”,而不是对缓存执行 delta 修补。直接执行 INCRBY(-2) 可能暂时得到正确数字,却丢失版本、批次、渠道和审计信息;下一次迟到事件仍可能再次覆盖结果。

当投影差异超过阈值时,应触发冻结策略:对高风险商品停止新的 Reserve,只允许查询、释放和人工审核;对低风险商品可以继续提供受限的 Check。Google SRE 将过载与级联故障视为需要在架构中主动处理的问题[10][11]。库存冻结不是为了掩盖故障,而是把“继续制造不可逆承诺”的速度降到可控范围。

12.8.4 锁、租约、过期与清理任务

分布式锁适合保护短时间的互斥区间,不适合替代库存事实。锁可能因客户端崩溃、网络分区、时钟偏差或租约续期失败而提前失效或延迟释放。使用锁时至少要回答:

  • 锁保护的资源是什么,是否能用数据库条件更新或原子脚本替代;
  • 锁的拥有者如何识别,释放时是否校验 token;
  • 租约过期后,旧持有者是否还能继续写;
  • 业务操作超过租约时间时,是否采用 fencing token;
  • 锁服务不可用时,系统是失败、排队还是降级。

对于 Reserve,优先把有效期写入预占记录,并让状态转换验证 expires_at,而不是仅依赖缓存 key 的 TTL。TTL 只是清理提示,不能单独证明业务承诺已经释放。清理任务需要采用可重入方式:

SELECT reservation_id
FROM reservation
WHERE state = 'RESERVED'
  AND expires_at < CURRENT_TIMESTAMP
ORDER BY expires_at
LIMIT :batch_size
FOR UPDATE SKIP LOCKED;

拿到记录后,任务应在同一事务中把 RESERVED 转为 RELEASE_PENDING 或 RELEASED,并写入一条带 operation_id 的释放动作;如果释放投影或下游通知失败,则保留补偿状态。清理任务必须有批次上限、执行间隔和失败重试上限,避免过期高峰时把数据库打满。

多资源预占还需要固定锁顺序。假设一次请求同时预占商品 A 与商品 B,如果请求一按 A、B 加锁,请求二按 B、A 加锁,就可能形成环路。排序后统一按 inventory_id 加锁,可以降低死锁概率;但它不能消除事务过长、索引缺失和外部调用持锁等问题。任何外部 RPC 都不应放在持有数据库行锁的事务中。

12.8.5 过载、降级与恢复顺序

库存热点通常表现为“读很多、写集中、峰值短、失败代价高”。如果所有流量都进入同一条同步链路,数据库、缓存、消息和下游服务会按照不同速度饱和。系统应把请求拆成优先级:

优先级请求过载时策略
P0释放、对账、状态查询尽量保留,保证可恢复性
P1已有预占的 Confirm 或 Cancel保留配额,快速完成或进入待处理
P2新建 Reserve限流、排队或返回稍后重试
P3非关键展示、推荐、预热使用缓存、降级或关闭

降级也必须遵守业务边界。可以把展示数量改成“紧张”或隐藏精确值,但不能在无法验证事实时继续返回可购买;可以把供应商实时库存改成“待确认”,但不能把未知当成无限;可以暂停营销叠加规则,但不能绕过基本资格校验。Google SRE 的过载处理原则支持以较低成本的结果保护核心服务[10],在本章中具体体现为保留释放与对账能力、削减新的不可逆承诺。

恢复顺序建议为:

  1. 保护现场:停止高风险新 Reserve,保留审计和状态查询。
  2. 确认事实:检查数据库提交、操作表、预占表和 Outbox。
  3. 修复传输:恢复消费者、补齐 watermark 和积压。
  4. 重建投影:按 epoch 从权威事实重新生成缓存与搜索数据。
  5. 重新开放:先开放查询,再开放 Check,最后逐步开放 Reserve。
  6. 复盘治理:记录触发阈值、误判、人工动作和重新评估条件。

这个顺序的关键是“先减少新承诺,再恢复派生能力”。如果先扩容缓存而不处理事实差异,系统可能以更高吞吐产生更多错误投影。

12.8.6 指标、日志、链路与安全

库存系统的指标不能只有 QPS 和平均延迟。建议按“业务结果、资源状态、异步收敛、错误恢复”四组建设:

指标组代表指标关注问题
业务结果Reserve 成功率、超卖数、释放率、Confirm 转化率用户承诺是否正确
资源状态available、reserved、committed、供应商同步年龄库存是否健康
异步收敛Outbox 未发送量、消费者积压、版本缺口、投影漂移系统是否正在收敛
错误恢复重试次数、死信量、未知结果量、对账修复量故障是否可治理

日志需要包含 operation_id、reservation_id、inventory_id、order_id、source_version、projection_version、rebuild_epoch、trace_id 和 actor_type。数量和金额等敏感字段按最小需要记录,用户身份、供应商凭证和内部策略不应写入普通日志。OpenTelemetry 将 traces、metrics、logs 视为互补的可观测信号[18];库存链路尤其需要把同步请求与异步补偿通过同一个 trace 或关联 ID 串起来。

审计记录应能回答“谁在什么时间,以什么操作号,依据哪个版本,改变了什么事实”。审计数据本身通常追加写入,不允许普通业务用户覆盖。管理后台的人工释放、人工冻结、人工重建应要求二次确认和权限分级,并保留原因、工单号与审批人。安全控制不是库存正确性的附属品:如果攻击者能伪造 operation_id、修改数量或重复调用管理员接口,所有幂等设计都可能被绕开。

指标告警也要避免只看绝对数。一个商品 10 分钟内出现 100 次版本冲突,可能代表热点;全站只有 1 次但涉及高价值商品,也可能比全站平均错误率更重要。告警规则应结合商品、租户、渠道、库存类型和金额等维度,同时控制高基数标签,避免监控系统本身成为故障源。

12.8.7 一致性选择的边界与工程证据

一致性讨论需要先说明读者看到的是什么,以及这个读者是否会据此做出不可逆动作。一个用户刚刚完成 Reserve 后再次查询自己的订单,通常需要 read-your-writes;一个未登录用户浏览商品页,可以接受短暂陈旧;一个管理员查看对账结果,需要知道数据截至哪个 watermark;一个 Release worker 判断是否能释放,则必须读取权威状态或带版本条件地更新。

Session Guarantees 研究把 read-your-writes、monotonic reads、monotonic writes 和 writes-follow-reads 等保证分开讨论[5]。库存系统可以把这些保证翻译成更具体的契约:

场景最小一致性保证实现方式
用户查看自己的预占read-your-writes返回事实库结果,或携带 operation_id 等待投影追平
商品列表展示有界陈旧返回 snapshot_time 和 freshness
同一库存单元的事件monotonic writesaggregate_version 连续推进
释放已过期预占条件写入state = RESERVED 且 version 未变化
对账重建读到固定快照记录 source_version 和 rebuild_epoch
供应商回执不接受旧终态覆盖新状态provider_version 或人工冲突裁决

这比“使用最终一致性”更可测试。测试用例可以明确验证:Reserve 返回成功后,查询同一个 reservation_id 是否能在承诺时间内看到 RESERVED;版本 9 的事件到达版本 10 之后是否被拒绝;重建 epoch 3 时,epoch 2 的迟到释放是否进入隔离队列。无法写成测试或指标的“一致性保证”,通常只是描述性口号。

中文工程资料对幂等与分布式锁的讨论常指出,锁解决的是并发互斥,幂等解决的是重复请求;库存超卖复盘也常把 Check-Then-Act、缓存与数据库不一致、重试重复执行列为不同原因[26][27][28]。这些经验可以帮助发现问题,但具体结论仍要回到本系统的不变量:是同一 operation_id 重复,还是不同操作争抢同一资源;是投影落后,还是事实本身已经超卖;是供应商未知,还是本地没有保存查询证据。

Microsoft 的 Saga 模式把跨服务流程拆成局部事务和补偿动作[7]。在库存领域,选用 Saga 的前提是业务可以表达中间状态,并且每一步都有可重试、可查询或可人工处理的结果。如果某一步既不可查询、不可补偿、又不能人工确认,就不应把它藏在“最终一致”四个字后面;应先缩小交易边界,或在用户确认之前完成更强的同步验证。

12.8.8 故障演练矩阵

恢复能力必须通过故障注入验证,而不是只看代码覆盖率。最低限度可以建立以下演练矩阵:

故障注入预期保护观测证据恢复动作
Redis 在脚本返回前断开不重复扣减,操作进入未知operation_id、事实版本、漂移告警查询事实并重建投影
数据库提交后 Relay 崩溃事实不丢,事件可补发Outbox 年龄和发送次数续租并重发
消费者处理后提交位点前崩溃重复事件不重复确认event_id 命中率幂等 ACK
Confirm 与超时 Release 同时到达只有一个状态迁移成功version conflict、终态查询订单并对账
供应商超时但实际已预订不创建第二个外部预订provider request 状态轮询或人工核对
缓存全部清空禁止无证据继续高风险写入rebuild epoch、冻结数从事实库分批重建
事件乱序或旧 epoch 到达不覆盖新投影stale event queue丢弃、重放或隔离
热点商品突发过载P0/P1 仍可收敛限流、队列、拒绝率分阶段恢复新 Reserve

演练记录要包含注入时间、影响范围、触发了哪些门闩、人工做了什么、是否出现超卖、最终恢复到哪个版本。演练结果应反向更新 ADR 的验证指标和重新评估条件。若一个故障只能通过“手动改 Redis 数字”解决,说明系统还没有建立可审计的恢复路径。

故障处理还应规定人工接管的最小权限和退出条件。客服可以查询和标记异常,但不应直接调整 available;值班工程师可以冻结库存和重放投影,但不能替代业务审批确认券码;库存负责人可以批准调账,但调账后必须等待对账任务完成。每次人工动作都要有唯一 operation_id、原因、操作者、审批人和影响范围。

人工接管不是把系统交给一个“超级管理员”,而是把自动化无法证明的部分变成受控流程。接管单应明确:当前状态、已验证事实、未知事实、禁止动作、下一次查询时间和终止条件。状态从 UNKNOWN 变为 CONFIRMED 或 RELEASED 后,系统自动关闭接管单并校验相关投影;如果超过窗口仍无结论,则升级到更高责任人,而不是无限延期。

恢复结束后需要做三类检查:一是安全性检查,确认没有重复确认、负库存或跨 epoch 写入;二是活性检查,确认未决 Reservation、Outbox 和供应商请求仍会继续推进;三是解释性检查,确认每个受影响订单都能通过 operation_id、版本和审计记录复述处理经过。只有三类检查都通过,才可以撤销冻结门闩。

12.9 端到端案例、ADR、评审清单与本章小结

12.9.1 限量权益码的完整链路

下面用一个经过抽象的“限量数字权益码”说明统一模型。它不对应任何特定公司、平台或内部项目名称。权益码总量有限,每个码只能被一个有效订单确认;部分码由外部供应商分批提供;展示页需要低延迟地显示“可兑换”或“已售罄”。

创建与入库。 管理员创建 inventory_id,选择 CODE 类型,配置渠道范围、有效期、总量和供应商批次。服务写入 InventoryDocument 与 CodeItem,不直接发布到前台。每条 CodeItem 以加密或不可逆保护方式保存,状态为 AVAILABLE;原始码只允许在兑换边界读取。导入任务通过 operation_id 防止同一文件重复导入,并对批次号、码值摘要和数量做唯一检查。

发布与投影。 发布操作在事实库中把商品状态从 DRAFT 转为 PUBLISHED,同时追加一条 InventoryEvent。Outbox 在同一事务中生成,消费者收到事件后建立可售投影。由于投影可能延迟,发布接口返回“已发布,展示正在同步”比假设所有读模型已经完成更准确。若投影没有在 SLO 内更新,告警和对账任务负责推进,而不是让运营重复点击发布。

Check。 用户打开页面时调用 Check。服务根据 audience、渠道、时间和资格规则查询可售投影;投影结果包含 source_version 和 snapshot_time。若缓存显示有库存但版本已落后超过阈值,服务返回“库存确认中”或进入事实库兜底查询;如果商品处于冻结状态,不再把旧的“可兑换”状态展示为确定承诺。

Reserve。 用户提交兑换请求,客户端生成 operation_id。服务先校验商品状态和资格,再执行代码池的原子取码或带版本的状态更新。成功后写入 Reservation,状态为 RESERVED,记录 reservation_id、code_item_id、expires_at、request_hash 和 source_version。这个动作必须能在同一个事实事务内重放:相同 operation_id 返回同一 reservation_id;不同 operation_id 竞争同一 code_item 时只有一个成功。

支付或资格确认。 如果需要支付,订单系统创建订单并把 inventory_id、reservation_id 和 operation_id 写入订单元数据。支付结果通过事件或查询回传。库存服务只接受已验证的订单状态,不信任客户端直接调用 Confirm。支付成功后,Confirm 检查预占仍未过期且订单归属一致,把 Reservation 从 RESERVED 转为 CONFIRMED,把 CodeItem 从 RESERVED 转为 COMMITTED,再发出兑换准备事件。若 Confirm 重复到达,读取已确认状态并返回同一个兑换结果。

超时释放。 若支付失败或超过 expires_at,订单系统发出 Cancel 或库存清理任务扫描到期记录。释放动作带新的 operation_id,只有当 Reservation 仍是 RESERVED 时才把它改为 RELEASED,并把 CodeItem 恢复为 AVAILABLE。若 Confirm 与释放竞争,二者必须由同一事实状态机裁决:先提交 Confirm 的记录不能被后续释放覆盖;先提交 Release 的记录不能被旧的 Confirm 重新确认。

供应商延迟。 如果某个批次由供应商提供,供应商接口超时不能直接把数量记为 0,也不能把未确认的数量写入可售。系统为批次建立 SUPPLIER_PENDING 状态,保留请求和幂等键;后台查询供应商状态,收到明确结果后才转为 AVAILABLE 或 REJECTED。多次未知后,批次进入人工复核,前台只展示已确认数量。

缓存未知与重建。 如果 Redis 扣减成功后响应丢失,服务按 operation_id 查询 Reservation;若查不到,则比较事实版本和投影版本,再决定是重放相同动作还是把投影标记为待对账。不能根据 Redis 当前数量再执行一次“减一”。如果缓存被清空,重建任务按照 PUBLISHED、未过期、未被确认或有效预占的事实重算 available,生成新的 rebuild_epoch,并拒绝旧 epoch 事件。

这个案例说明,代码池、支付、供应商、缓存和消息并不需要共享一个全局事务,但每个跨边界动作都需要一个可查询的业务状态。用户看到的“成功”应对应已确认的事实;看到的“处理中”应对应可继续推进的状态;看到的“失败”应对应不会再产生有效承诺,或明确进入人工处理。

12.9.2 ADR:为什么采用事实库、可售投影与 Outbox

背景与问题。 库存既要求低延迟读,又要求扣减可解释、可恢复。单一存储很难同时满足高峰读取、复杂审计、异步通知和跨系统对账。当前设计需要支持数量、券码、供应商和无限库存四种策略,并允许投影重建。

决策驱动因素。 主要因素是超卖风险、数据可恢复性、峰值延迟、读写成本、团队运维能力、供应商不稳定、消息重复和未来策略扩展。由于库存扣减属于不可逆承诺,正确性优先级高于单纯的缓存吞吐;由于展示流量远高于扣减流量,读路径又不能强制所有请求访问事实库。

候选方案。

方案一致性延迟吞吐恢复复杂度主动牺牲
仅关系数据库 CAS强事实一致性写可接受,读成本上升热点受限于锁和索引台账与事务较好中牺牲峰值读吞吐
仅缓存原子扣减单点操作强,跨系统弱很低高重建和审计困难中牺牲事实可追溯
关系事实库 + 缓存投影事实可追溯,读模型最终一致读低、写可控可按热点扩展可对账重建高牺牲部分实时一致和运维简单度
事实库 + 投影 + Outbox在上一方案上保证事件接管事件有延迟可扩展具备补发与积压治理高牺牲架构简洁

最终决策。 选择“关系事实库或可审计台账作为权威来源,缓存和搜索作为可重建投影,Outbox 作为事实到异步事件的可靠接缝”。数量热点可以使用 Redis 原子脚本承担快速闸门,但脚本结果必须映射到 operation_id、reservation_id 和事实版本;当脚本与事实库无法在同一边界内确认时,系统进入处理中或待对账,而不是无条件返回成功。

获得的能力。 获得可审计的事实来源、明确的 Reserve 状态、缓存丢失后的重建路径、消息重复时的幂等处理、供应商延迟时的挂起状态和按策略替换库存实现的能力。Outbox 模式的目标是让事实提交与待发送事件同处一个本地事务;AWS 官方模式文档也强调了这一接缝的用途和消费者幂等要求[6]。

主动牺牲的能力。 牺牲了“所有读请求都实时看到最新结果”的保证,牺牲了单一组件即可完成的简单部署,也牺牲了跨系统全局事务的即时性。搜索、推荐和展示允许短暂陈旧;支付和订单的最终结果通过状态查询与事件收敛。

已接受的风险。 Outbox 可能积压,投影可能短暂漂移,跨系统补偿可能失败,重建期间需要冻结部分商品,缓存闸门与事实库可能出现未知结果。这些风险只有在有指标、对账、死信、人工接管和回滚/重建脚本的前提下才可接受。没有这些治理能力时,增加一个缓存只会增加故障面。

验证指标。 以 p99 Check 延迟、Reserve p99 延迟、超卖数、投影漂移时长、Outbox 积压年龄、未知结果比例、重复操作命中率、对账自动修复率和恢复到可购买所需时间作为指标。指标需要按库存类型、商品热度、渠道和供应商拆分,避免平均值掩盖热点故障。

重新评估条件。 出现不可接受的超卖、投影漂移持续超过 SLO、Outbox 积压无法在峰值后恢复、数据库成为稳定瓶颈、供应商需要更强的预留协议,或团队无法维护重建和对账工具时,应重新评估方案。可以先收窄能力,例如关闭供应商库存或暂停新的 Reserve,而不是在故障中临时改变一致性语义。

Martin Fowler 的分布式系统模式目录提供了 Outbox、幂等接收者、重试、协调器等模式的相互关系[20];本 ADR 的重点不是套用模式名称,而是把每个模式放回库存的事实、投影、补偿和运维边界中。

12.9.3 评审清单

进行库存系统设计评审时,可以逐项回答以下问题:

  1. 是否定义了库存系统明确负责和明确不负责的边界?
  2. 是否区分 Check、Reserve、Confirm、Release 的不同语义?
  3. 是否说明了 available、reserved、committed、pending 的不变量?
  4. 是否定义了每种库存类型的权威事实来源?
  5. 是否把数量库存、券码库存、供应商库存和无限库存拆成策略,而非复制四套服务?
  6. 是否为每个写操作设计了 operation_id 和请求参数摘要?
  7. 是否能在超时后查询结果,而不是盲目重试?
  8. 是否把投影版本、epoch 和 watermark 作为可治理状态?
  9. 是否有从事实重建缓存的流程,并能隔离旧事件?
  10. 是否定义了预占过期、清理、释放和 Confirm 竞争时的裁决规则?
  11. 是否避免在持有数据库锁时调用外部服务?
  12. 是否为重试设置退避、抖动、次数和总预算?
  13. 是否区分业务拒绝、可恢复错误、未知结果和程序错误?
  14. 是否保留 Outbox、死信、补偿和人工接管路径?
  15. 是否能从 trace_id、operation_id 追踪到事实版本和投影版本?
  16. 是否有超卖、漂移、积压、未知结果和恢复时间的告警?
  17. 是否说明了过载时保留什么、削减什么、冻结什么?
  18. 是否明确接受了哪些最终一致性、成本和运维复杂度?
  19. 是否有演练:缓存清空、消息重复、数据库切换、供应商超时、消费者积压?
  20. 是否设置了重新评估条件,而不是把一次性选型写成永久真理?

12.9.4 本章小结:八条可迁移判断

第一,库存不是一个数字,而是一组带有范围、状态、时间和责任主体的业务承诺。只要请求可能改变用户能否购买、兑换或履约,就应当把它纳入库存语义。

第二,先定义事实和不变量,再选择数据库、缓存和消息系统。组件只能提供局部能力,不能替代业务边界。

第三,Check 可以接受陈旧,Reserve 不能无条件相信陈旧;展示和承诺需要不同的 SLO 与一致性语义。

第四,幂等键解决重复意图,版本条件和原子操作解决并发冲突;两者都需要事实库或可审计操作记录兜底。

第五,缓存、搜索和统计是投影。投影必须可带版本、可对账、可重建,并能够隔离迟到和旧 epoch 事件。

第六,超时的正确答案通常是查询和收敛,而不是立即重试。重试必须有退避、抖动、预算和错误分类。

第七,补偿是新的业务动作,不是时光倒流。补偿可能失败,必须有状态、重试、死信、告警和人工接管。

第八,一个可上线的库存系统不仅要说明正常链路如何成功,还要说明在事实不确定、下游过载、投影漂移、供应商迟到和消息重复时,系统如何停止扩大损失并最终恢复。

12.9.5 参考资料的使用边界

本章的参考资料按“理论锚点、原始研究、官方工程文档、中文工程资料”分层使用。Sagas、分布式事务、事务处理和会话保证用于解释跨边界提交、补偿、恢复和读取语义;数据密集型系统与分布式系统模式用于连接日志、投影、幂等消费者、Outbox 和重试;MySQL、Redis、Kafka、Stripe、OpenTelemetry 与云厂商资料只用于说明它们各自公开的局部行为。来源支持的是相邻事实,不代表来源替本章的全部库存方案背书。

英文资料更适合提供原始论文、经典教材和产品官方语义。中文资料补充了国内工程语境中的超卖复盘、命令边界、幂等和大型网站架构经验。二者不能简单相互替换:论文可能讨论抽象协议,官方文档可能只覆盖某个版本,行业文章可能带有特定业务前提。正文已经尽量使用“来源直接说明什么”和“本章据此推导什么”两种句式区分它们。

引用不应被理解为把外部文章复制进书稿。具体系统的版本、部署拓扑、热度分布、合规要求和供应商合同都可能不同;读者在落地前应重新核验官方文档、团队 SLO 和实际压测结果。特别是 Redis 脚本原子性、Kafka 投递语义、数据库隔离级别和云服务限流都存在边界,不能只凭组件名称推导全局一致性。

对于本章的抽象示例,变量、数量和状态名是为了表达方法,不能被当成某个平台的事实数据。任何迁移到真实业务的方案都应完成脱敏审查、容量压测、故障演练、权限审查和数据保留评估。涉及内部系统、客户、业务名称或未公开指标时,应只保留经过抽象的领域关系,不把内部专有名词当成通用案例。

12.9.6 上线、迁移与回滚

库存系统的上线不能只发布服务代码。若已有系统把库存数字散落在订单、商品、营销和供应商适配器中,迁移首先要建立事实盘点:哪些字段是可售基线,哪些是展示缓存,哪些是预占,哪些已被订单确认;每个数字都要记录来源、时间和版本。无法确认来源的数量不能直接导入新系统成为可售,应进入 UNKNOWN 或人工盘点。

推荐采用双写校验而不是直接切流:

  1. 建立只读影子模型,消费旧系统事件并比较数量、状态和订单归属。
  2. 对新旧系统执行离线对账,修复差异并确认差异类别。
  3. 在低风险品类开启新系统 Reserve,但由旧系统继续提供展示,观察超卖和释放指标。
  4. 扩大到热点、供应商和券码品类,每一步都保留冻结开关。
  5. 完成读路径切换后,再切写路径;旧系统进入只读和历史查询。

回滚也要定义边界。若新系统已经向外部供应商或支付系统提交不可逆操作,不能简单把流量切回旧系统就宣称回滚完成;需要冻结新操作、查询未决状态、完成 Release 或人工向前恢复,并防止新旧系统同时接受同一 operation_id。数据库 schema 可以向前兼容,业务事实却可能无法向后兼容,所以发布前必须演练消息重复、旧消费者读取新事件和跨版本重放。

上线门闩应包括:投影新鲜度达标、未知结果低于阈值、对账积压可清空、Release worker 正常、Outbox 可补发、人工后台权限生效、关键告警已接收。只有运行团队能在演练中完成“冻结—确认事实—重建—逐步开放”,库存系统才真正具备生产可用性。

迁移完成后仍应保留一段观察期。观察期内按旧系统、新系统和对账结果三方比较,不只比较余额,还比较 Reservation 归属、释放时延、事件版本和供应商状态。观察期结束后再归档旧链路;归档不等于立刻删除,因为历史订单、争议处理和灾备演练仍可能需要旧系统的只读证据。

观察期的退出标准应事先写入发布计划,例如连续多个峰值窗口没有超卖、漂移和未知结果异常,所有关键指标都能追溯到操作记录,且回滚演练不再依赖临时脚本。这样迁移的完成由证据定义,而不是由“代码已经发布”定义。

如果观察期出现差异,先冻结高风险写入并保留现场,再判断是旧系统错误、新系统错误还是两套系统语义不同;不要为了让报表相等而直接覆盖一方事实。

最终报告应同时保留差异清单、修复证据和未解决风险,供后续评审重新使用。

未解决风险不能被迁移报告隐藏;它们应进入下一阶段的治理清单,并绑定责任人、验证指标和截止时间。

这份清单的关闭条件必须是可验证证据,而不是口头承诺。

只有证据闭环后,系统才算真正完成迁移。

迁移后的新事实、新投影和新治理流程必须继续接受同样的审计。

这也是库存系统长期可靠性的起点。

后续章节可以在此模型上继续展开订单、支付与履约。

读者应始终回到事实、边界和恢复证据。

这三项内容共同决定库存系统能否在压力和故障中守住承诺。

12.10 参考资料

[1] Hector Garcia-Molina, Kenneth Salem, “Sagas”, ACM SIGMOD, 1987, https://doi.org/10.1145/38713.38742.

[2] Pat Helland, “Life beyond Distributed Transactions: an Apostate’s Opinion”, CIDR, 2007, https://www.cidrdb.org/cidr2007/papers/cidr07p15.pdf.

[3] Martin Kleppmann, Designing Data-Intensive Applications, O’Reilly, 2017, https://martin.kleppmann.com/2017/03/27/designing-data-intensive-applications.html.

[4] Jim Gray, Andreas Reuter, Transaction Processing: Concepts and Techniques, Morgan Kaufmann, 1993, https://www.educate.elsevier.com/book/details/9780080519555.

[5] Richard Terry, Alan Demers, Karin Petersen, Mike Spreitzer, Marvin Theimer, Brent Welch, “Session Guarantees for Weakly Consistent Replicated Data”, PDIS, 1994, https://doi.org/10.1109/PDIS.1994.331722.

[6] AWS Prescriptive Guidance, “Transactional outbox pattern”, Amazon Web Services, https://docs.aws.amazon.com/en_en/prescriptive-guidance/latest/cloud-design-patterns/transactional-outbox.html.

[7] Microsoft, “Saga distributed transactions pattern”, Azure Architecture Center, https://learn.microsoft.com/en-us/azure/architecture/patterns/saga.

[8] Microsoft, “Retry pattern”, Azure Architecture Center, https://learn.microsoft.com/en-us/azure/architecture/patterns/retry.

[9] Microsoft, “Retry Storm antipattern”, Azure Architecture Center, https://learn.microsoft.com/en-us/azure/architecture/antipatterns/retry-storm/.

[10] Betsy Beyer, Jennifer Petoff, Chris Jones, Niall Richard Murphy, eds., “Handling Overload”, Site Reliability Engineering, Google, https://sre.google/sre-book/handling-overload/.

[11] Betsy Beyer, Jennifer Petoff, Chris Jones, Niall Richard Murphy, eds., “Addressing Cascading Failures”, Site Reliability Engineering, Google, https://sre.google/sre-book/addressing-cascading-failures/.

[12] Redis, “Introduction to Redis programmability with Lua”, Redis Documentation, https://redis.io/docs/latest/develop/programmability/eval-intro/.

[13] Redis, “Transactions”, Redis Documentation, https://redis.io/docs/latest/develop/interact/transactions/.

[14] Oracle, “InnoDB Locking Reads”, MySQL 8.4 Reference Manual, https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html.

[15] Oracle, “InnoDB Transaction Isolation Levels”, MySQL 8.4 Reference Manual, https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html.

[16] Apache Kafka, “Kafka Design: Message Delivery Guarantees”, Kafka Documentation, https://kafka.apache.org/40/design/design/.

[17] Stripe, “Idempotent requests”, Stripe API Documentation, https://docs.stripe.com/api/idempotent_requests.

[18] OpenTelemetry, “Observability primer”, OpenTelemetry Documentation, https://opentelemetry.io/docs/concepts/observability-primer/.

[19] AWS Builders’ Library, “Making retries safe with idempotent APIs”, Amazon Web Services, https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/.

[20] Martin Fowler, “Patterns of Distributed Systems”, martinfowler.com, https://martinfowler.com/articles/patterns-of-distributed-systems/.

[21] Apache Seata, “Saga 模式”, Apache Seata 中文文档, https://seata.apache.org/zh-cn/docs/user/mode/saga/.

[22] Apache Seata, “Seata 是什么”, Apache Seata 中文文档, https://seata.apache.org/zh-cn/docs/overview/what-is-seata/.

[23] 腾讯云, “Redis 命令规范”, 腾讯云文档, https://intl.cloud.tencent.com/zh/document/product/239/56004.

[24] 腾讯云, “Redis 版本差异”, 腾讯云文档, https://intl.cloud.tencent.com/zh/document/product/239/77219.

[25] 腾讯云, “Redis 命令参考”, 腾讯云文档, https://cloud.tencent.com/document/product/239/76287.

[26] 阿里云开发者社区, “幂等性与分布式锁:不要用锁解决幂等问题”, 阿里云开发者社区, https://developer.aliyun.com/article/1719978.

[27] 阿里云开发者社区, “库存超卖问题复盘与治理”, 阿里云开发者社区, https://developer.aliyun.com/article/1679252.

[28] 腾讯云开发者社区, “库存超卖问题的成因与解决思路”, 腾讯云开发者社区, https://cloud.tencent.com/developer/article/1399872.

[29] 李智慧, “大型网站技术架构:核心原理与案例分析”, 阿里云开发者社区书籍介绍, https://developer.aliyun.com/article/1215985.

[30] 李智慧, 大型网站技术架构:核心原理与案例分析, 电子工业出版社, 2013, https://book.douban.com/subject/25723064/.

第 13 章 营销与计价系统

本章定位:营销系统决定用户在什么条件下可以获得什么权益,计价系统把基础价格、营销权益、费用和支付方式编排成一份可解释、可审计、可锁定的价格事实。两者不是两个互相调用的“优惠服务”和“金额服务”,而是交易报价域中的两个限界上下文:营销提供资格与权益,计价负责金额计算,订单负责把计算结果变成交易快照,支付负责验证并完成资金交换。

电商平台最容易被低估的复杂度,往往不在“商品单价乘数量”,而在于同一件商品会同时受到用户、渠道、店铺、活动、库存、时间、币种、费用和支付方式的影响。一个用户在商品详情页看到的价格,可能只是匿名展示价;进入购物车后,会员身份和券发生变化;在结算页,跨店满减需要先按店铺分组再计算;创单时,供应商报价可能已经过期;支付时,平台还需要确认订单金额、币种、支付渠道费和营销出资方都没有被篡改。任何一个环节只保存一个 total_amount,后续客服、财务、商家和风控都无法回答“为什么是这个数”。

本章讨论一套适用于 B2C、B2B2C 和多品类虚拟商品平台的统一方法。文中的流量、延迟、预算和库存数字都属于示例假设,用于说明如何做容量规划和决策,不代表任何平台的公开事实。正文引用采用 [n] 简注,章末列出每个来源的作者或机构、标题、年份和原始 URL;引用支持相邻的理论、协议语义或工程判断,示例推导会明确标记为本章设计。

13.1 问题定义、约束与设计目标

13.1.1 先定义交易事实,再定义服务

营销和计价的第一项工作不是选 Redis、规则引擎或消息队列,而是回答四个问题:

  1. 什么是商品事实:商品中心或供应商报价系统提供了什么可售对象、基础价、币种、有效期和销售约束?
  2. 什么是权益事实:用户是否拥有某张券、是否满足满减门槛、是否仍有积分、平台是否愿意承担补贴?
  3. 什么是价格事实:在某个场景、某个时间点、使用某个规则版本计算出的每一行金额和订单应付金额是什么?
  4. 什么是交易事实:订单创建后,哪些输入被锁定,哪些权益已经冻结,哪些变化必须重新确认?

如果这四类事实没有分开,系统会出现三种典型混乱。第一,营销服务把“优惠 20 元”直接写到订单的最终金额里,计价服务无法解释优惠先后顺序和出资方。第二,页面使用缓存价而创单重新计算,用户看到的是 80 元、提交后却变成 86 元,客服只能依赖日志猜测原因。第三,订单取消时直接把券状态改回“未使用”,但另一笔重试请求已经消费了这张券,最终形成重复使用或资金损失。

因此,本章采用如下统一定义:

营销系统发布可版本化的权益规则并管理有限权益的生命周期;计价系统读取基础价格事实,选择合法权益,计算价格组件并生成快照;订单系统保存快照引用和交易状态;支付系统只接受与快照一致的应付金额。

这个定义借鉴领域驱动设计中的统一语言、限界上下文和模型边界思想。[1][2] 它也符合企业应用架构中“领域逻辑不能被数据库字段和远程调用隐式取代”的原则:分层不是把代码机械地分成 Controller、Service、DAO,而是让业务规则在能够表达业务概念的位置上保持内聚。[3]

13.1.2 业务范围与不做什么

本章覆盖以下场景:

场景典型需求必须锁定的事实
商品详情页展示会员价、活动价、匿名价和“最高可省”商品版本、用户分群、活动版本;允许短暂过期
购物车多行商品、跨店铺券、积分和运费预览行快照、数量、店铺归属、候选权益
结算与创单选择地址、配送、券和支付方式基础价、规则版本、费用、分摊结果、库存引用
支付校验防止金额被篡改、确认动态报价仍有效snapshot_id、币种、应付金额、报价有效期
取消与退款释放冻结权益、恢复可退积分、拆分退款原快照、组件 ID、退款范围、补偿状态
大促与秒杀高峰抢券、限时折扣、预算和配额热点活动版本、营销库存、限流结果和审计事件

本章不把广告竞价、复杂推荐排序、税务申报、支付清算网络和仓储履约展开为独立系统;但会定义它们与营销计价之间的接口边界。动态定价模型可以作为基础价格适配器接入,机器学习模型只负责产生候选价格或折扣建议,不直接绕过价格安全检查和快照机制。个性化定价还涉及价格公平、福利和可解释性,不能因为“算法可以提高转化”就忽略用户权益;相关研究将价格负担、群体公平、企业收益和资源可及性作为不同目标讨论。[19]

13.1.3 约束、数量级与验收指标

以下是一组用于设计演练的假设。真实项目应以压测、线上采样和业务峰值测量替换它们。

维度示例假设对设计的影响
日活用户500 万用户分群、券查询和活动列表不能每次回源全表
普通结算峰值2,000 QPS计价需要批量读取、并行依赖和稳定尾延迟
大促展示峰值50,000 QPS展示路径和交易路径必须分离,允许缓存和部分降级
创单峰值5,000 QPS快照与权益冻结必须有明确幂等语义
购物车行数P95 8 行,极端 50 行组合优惠搜索要设上限,不能无限枚举
规则数量单活动数百条,用户可见活动数十个规则需预编译、分层过滤和版本化
价格有效期固定价 30 分钟,供应商报价 1–5 分钟支付前必须校验报价过期时间
资金误差不允许出现负价、超预算、分摊不闭合金额整数化、组件审计和对账是硬约束
用户体验结算页 P99 目标由业务设定,例如 300 ms预算必须按依赖拆分,不能只给总超时

SLO 不应在系统上线后凭感觉制定。Google SRE 将 SLI、SLO 和错误预算作为服务治理的基础,强调指标必须描述用户真正关心的行为,而不是只挑容易采集的机器指标。[14] 对本章而言,至少需要分别定义:展示试算成功率、创单价格校验成功率、快照写入成功率、支付金额不一致率、权益冻结超时率、营销补偿积压、价格解释请求成功率和对账差异率。一个“计价服务 99.99% 可用”的抽象数字,不能替代“99.9% 结算请求在 300 ms 内返回,并且价格组件完整”的业务指标。

13.1.4 设计目标与 ADR-01

本章的设计目标有五个:

  • 正确:金额非负、分摊闭合、优惠不超过适用范围,平台和商家出资可追溯;
  • 一致:展示、结算、创单和支付之间有明确的口径关系,创单后的历史金额不因新规则发布而重算;
  • 可扩展:增加一个费用、优惠类型或新品类时,主要新增组件和适配器,不修改所有调用方;
  • 可恢复:网络超时、消息重复、服务重启、支付未知和部分退款都能通过重试、补偿或对账收敛;
  • 可解释:客服能够从 snapshot_id 还原输入、规则版本、价格层、优惠组件和出资方。

ADR-01:营销和计价分离,但共享报价编排契约。

背景是优惠规则变化快、商品基础价具有不同事实来源、交易链路又要求金额可审计。如果把所有逻辑放在营销服务,营销服务会变成隐含的订单服务;如果把所有规则放进计价服务,营销运营无法独立发布活动,代码会被配置分支和品类条件淹没。最终决策是:营销域输出候选权益、资格判断、资源冻结和核销结果;计价域负责把基础价、候选权益和费用组装为 PriceComponent;订单保存快照,不把两个服务的数据库表拼成一个“大促表”。

获得的能力是边界清楚、规则可审计、品类可扩展;主动牺牲的是一次请求中需要跨服务协作,且必须承担版本传递、补偿、对账和最终一致性。这个取舍不是“微服务越多越先进”,而是因为价格事实和权益事实有不同的变化速率、责任人和生命周期。ADR 应记录背景、决定和后果,并在边界条件改变时重新评估。[17]

13.1.5 四类业务压力与设计取舍

营销和计价的复杂度可以按四类压力观察。第一类是规则压力:活动越来越多,券有门槛、范围、互斥和优先级,运营希望不发版就能调整。解决办法是版本化配置、规则编译和仿真,但配置越灵活,越需要 schema、权限和回滚,否则把代码复杂度转移成运营资损。

第二类是交易压力:用户希望页面快速,订单希望金额稳定,支付希望校验严格。不能用一个“实时性”指标覆盖三者。展示可以接受短 TTL;购物车需要提示变化;创单和支付必须引用快照。快照本质上是把一个短暂的计算结果转化为交易承诺,承诺一旦产生,变化必须通过显式确认或售后流程处理。

第三类是资源压力:券、预算、积分、库存和供应商报价都可能在高峰被争抢。资源压力要求状态机、原子预占和释放,但不意味着所有状态都要同步完成。可以把展示和候选计算异步化,把真正影响资金的冻结、确认、释放做成可查询的同步命令,再用事件驱动统计和分析。

第四类是组织压力:商品、营销、订单、支付、财务和客服由不同团队负责,每个团队都可能在本地添加一个价格字段。此时最重要的不是继续建表,而是把价格组件、快照、规则版本和责任矩阵写成跨团队契约。ADR 让团队能够看到某个决策获得了什么、牺牲了什么,也能知道在什么条件下重新评估。[17]

从这四类压力可以推导出一张取舍表:

决策获得牺牲适用前提重新评估条件
规则配置化发布快、运营可控配置校验和仿真复杂规则类型有稳定抽象配置分支超过领域模型可理解范围
统一计价口径一致、扩展复用依赖编排、快照和迁移成本多场景共享价格事实只有单一固定价且无跨域资金影响
营销库存缓存预扣高峰吞吐、低延迟需要对账和补偿资源可按分片分配预算必须强一致且无法预分配
异步事件解耦、削峰、可重放最终一致、重复消费消费者可幂等用户必须在一个本地事务内立即看到结果
个性化权益精细运营、可实验公平、隐私、解释成本有清晰资格和审计策略价格差异无法解释或触发合规风险

促销研究从消费者对公平交易的感知讨论门槛和封顶优惠;本章将其转译为工程约束:规则应显式保存触发条件、展示文案和最终价影响,系统应能重放同一条件下的决策,而不是只保存一个折扣数字。[18] 个性化价格研究则提醒工程团队,收入、转化、公平和福利不是同一个目标函数;如果产品选择个性化定价,至少要保存资格版本、价格范围和审计解释。[19]

13.1.6 典型反模式

反模式一:一个 discount_amount 字段走遍所有服务。 它无法表达多张券的来源、顺序、适用行和部分退款,最终所有服务都在猜这个数字的含义。改法是组件化,并在订单中保存组件和分摊。

反模式二:把活动配置直接放进客户端。 客户端可以提前展示,但不能决定资格、门槛、库存和金额。改法是客户端传选择,服务端重新评估,返回拒绝原因和新快照。

反模式三:用分布式锁代替状态机。 锁只能减少并发冲突,不能表示支付未知、订单取消、补偿失败和人工接管。改法是锁用于保护短临界区,状态机和流水用于表达业务事实。

反模式四:把消息“恰好一次”当成资金“恰好一次”。 消息系统的语义只覆盖它的边界,外部数据库、支付渠道和供应商仍可能重复或超时。改法是外部副作用使用幂等键、状态查询和对账。

反模式五:把降级写成“营销服务失败就原价下单”。 原价可能缺少供应商费用、用户已选择的承诺或最低价保护。改法是按场景定义降级等级,交易路径在无法形成可信快照时快速失败。

13.2 统一模型:营销权益、价格层、快照与状态机

13.2.1 统一语言与核心对象

新功能进入系统前,产品、运营、客服、财务和研发应先对以下对象达成一致。美团关于互联网业务 DDD 的实践指出,复杂系统的腐化常常不是因为少了一个技术组件,而是模型与代码逐渐脱离业务语言,导致边界低内聚、高耦合。[21] 在交易场景中,最危险的词是“优惠”“价格”“库存”这类看似人人理解、实际上每个团队含义不同的词。

对象业务含义关键属性所属事实
Money带币种的不可变金额minor_units、currency、scale计价域
PriceComponent对某个行或订单总额的增量影响component_id、type、amount、source、rule_version计价域
Benefit用户或订单可获得的权益资格条件、使用限制、有效期、出资方营销域
PromotionRule描述如何产生权益的可版本化规则优先级、互斥组、门槛、适用范围营销域
Reservation对有限权益或预算的临时占用资源、数量、订单、过期时间、状态营销域
PriceSnapshot某次计算的输入和输出快照请求指纹、层级结果、组件、规则哈希、有效期计价/订单协作
Allocation把订单级优惠分配到行的结果行 ID、组件 ID、分摊金额、尾差标记计价域
LedgerEntry权益或积分的不可变流水账户、方向、数量、业务单号、幂等键营销域

PriceComponent 不能只保存一个数。至少应描述“这是基础价、活动减免、平台券、商家券、积分抵扣、运费、服务费还是税费;金额由谁承担;适用于哪一行;由哪个规则版本产生”。组件的稳定 ID 比数组下标重要,因为订单行可能在合并、拆单和退款时重排。退款、开票和财务分摊都应引用 component_id,而不是假设“第三个数组元素永远是优惠券”。

13.2.2 四层价格模型与公式

为了避免“先打折还是先减券”的口头争论,先把计算拆成语义层。一个适用于多数实物和虚拟商品的模型如下:

LineBase          = 基础价适配器输出的单行单价 × 数量
PromotionDiscount = 活动产生的减免额,不是活动后的价格
DeductionDiscount = 券、积分、红包等在合法范围内的抵扣额
Charges           = 运费、服务费、渠道费、税费等附加费用
Payable           = LineBase - PromotionDiscount - DeductionDiscount + Charges

这里的 PromotionDiscount 和 DeductionDiscount 均表示非负减免额,是对应组件集合的聚合结果。上游若提供活动后的新单价,适配器应先计算 (基础单价 - 活动后单价) × 数量,不能把活动后的应付价再次从基础小计中减去。对每一个组件,都需要回答四个问题:计算前的基数是什么;组件是否与其他组件互斥;该组件能否覆盖费用;平台、商家和渠道分别承担多少。

本章采用“组件保存增量、层负责计算顺序”的方式。内部组件若保存带符号增量,减免组件记为负向影响,在上述公式中先取对应减免额,再执行一次减法;附加费用记为正向影响。快照必须明确组件类型与符号约定,不能在同一字段中混用活动价、减免额和带符号增量。

价格层的顺序是业务契约,不是实现细节。比如“满 300 减 50”应先按适用商品集合求门槛,再判断是否达到 300;平台券可能只能作用于活动后的小计;积分抵扣可能受支付金额上限约束;税费可能不能被折扣覆盖。促销研究也表明,门槛型和封顶型优惠即使最大经济收益相同,消费者对公平性和转化的感知仍可能不同,因此优惠规则既要表达经济约束,也要表达对用户展示的条件。[18]

完整复算案例:活动、订单券与运费。 以下全部是教学假设,金额以 CNY 分保存,A、B 各一件;订单券按活动后商品小计判断门槛,运费不参与商品优惠。

组件A 商品B 商品订单合计
基础小计10,0005,00015,000
活动减免2,0005002,500
活动后商品小计8,0004,50012,500
满 12,000 减 1,000 的订单券分摊6403601,000
商品行应付7,3604,14011,500
运费(订单级组件)不分摊不分摊500
应付总额——12,000

券按活动后小计 8,000:4,500 分摊:先用整数乘除向下取整,再按余数从大到小分配剩余最小货币单位,同余数按稳定订单行 ID 决定。这个例子恰好得到 640 和 360。若在同一门槛下把券减免改成 1,001,初始分摊仍为 640 和 360,A 的余数较大,因此尾差 1 分分给 A,最终为 641 和 360;商品行应付为 7,359 和 4,140,加运费后为 11,999。分摊总和必须严格等于减免总额,计算中间乘积要检查整数溢出,不能用浮点舍入碰运气。

本案例另假设单独退 B 且无需重评券门槛,原订单运费不退,则退款商品金额读取原分摊后的 4,140,而不是按当前活动重新计算。若业务要求部分退款重新评估资格,应产生有依据的逆向调整组件,不覆盖原始快照。所有规则都要阻止优惠超过适用范围,确保行与订单金额守恒且应付不为非法负数。

13.2.3 权益状态机、价格快照与不变量

有限权益必须有明确状态机。以优惠券为例,推荐使用以下状态:

状态可执行操作不允许操作迁移触发
AVAILABLE试算、冻结重复冻结同一券用户选择并创建预占
FROZEN查询、确认、释放再次被其他订单确认订单创建成功或超时
CONSUMED查询、退款按业务规则补偿再次使用支付成功/履约确认
RELEASED审计、重新进入可用池(若策略允许)按旧订单消费取消、支付失败或超时
EXPIRED审计冻结或消费过期任务或规则判断

积分账户更适合“账户余额 + 不可变流水”,而不是只更新 available_points。流水中记录发放、冻结、消费、释放、退还和过期,每个动作带 biz_id、idempotency_key 和前序流水引用。预算也使用类似模型:预算桶有承诺额、已冻结额、已消费额和可用额,消费不应只依赖一个 Redis 计数器。

价格快照至少包含以下字段:

{
  "snapshot_id": "ps_20260921_01H...",
  "request_fingerprint": "sha256(...) ",
  "scene": "create_order",
  "currency": "CNY",
  "items": [{"line_id": "l1", "sku_id": "sku-1", "qty": 2}],
  "base_price_version": "product-v91",
  "marketing_rule_version": "campaign-v12",
  "components": [],
  "allocations": [],
  "payable_minor_units": 26800,
  "expires_at": "2026-09-21T12:00:30Z"
}

快照的关键不是“把 JSON 存下来”,而是让未来能够重建“当时看到了什么”。因此应保存输入哈希、规则哈希、供应商报价引用、用户资格分群版本、舍入过程、组件出资方和计算引擎版本。订单创建后,新的活动可以影响下一笔订单,但不能悄悄改写已支付订单的历史快照。

本章采用以下不变量:

  1. 任意行的最终价不得小于零,订单应付金额不得小于零;
  2. 行级应付金额之和等于订单应付金额,分摊尾差必须有唯一归属行;
  3. 任何优惠组件都不能超过其适用基数和剩余预算;
  4. FROZEN 权益只能由创建它的业务单号确认或释放;
  5. 同一个幂等键和相同请求指纹必须返回相同业务结果;同一个幂等键不能接受不同请求体;
  6. 价格快照只追加或生成新版本,不直接覆盖历史快照;
  7. 事件重复、回调重复和补偿重试不能使余额、库存、预算或核销次数重复变化。

13.2.4 领域边界不是数据库边界

营销域拥有“用户有没有资格、优惠是否可用、资源能否冻结、支付成功后是否核销”的规则;计价域拥有“这些合法权益如何影响金额、费用如何计算、金额如何分摊和快照如何生成”的规则;订单域拥有“何时创建、何时取消、订单状态如何推进”的规则。三者可以共用数据库集群,也可以独立部署,但不能通过直接读写对方私有表来建立契约。

一个常见错误是把 coupon_user.status 直接暴露给计价服务。计价服务看到 unused 就认为可以用券,但它不知道冻结时限、并发版本和订单归属,最终形成“计价说可用、冻结说冲突”的竞态。正确方式是营销服务提供 EvaluateBenefits 和 ReserveBenefits 接口,返回带版本和过期时间的权益结果;计价服务只消费接口契约,不猜内部状态。

DDD 并不要求所有业务对象都使用复杂聚合。简单的展示资格可以使用查询模型;需要维护不变量的券、积分账户、预算桶和快照才需要聚合边界。美团交易系统的实践强调,限界上下文是连接问题空间和解决方案空间的桥梁,模型应随着新认知迭代,而不是一次性画完后永远不变。[23]

13.2.5 请求、报价和快照的版本关系

版本不是一个字段加在表尾就完成了。一次价格计算至少同时涉及四类版本:商品版本、营销规则版本、计价引擎版本和外部报价版本。它们的职责不同:商品版本回答“这个 SKU 当时是什么”;规则版本回答“当时有哪些权益和顺序”;引擎版本回答“程序用什么算法解释规则”;报价版本回答“供应商在什么时间给了什么可售价格”。

版本变化来源是否影响展示缓存是否写入快照变更后的动作
商品版本SKU、上下架、基础价、库存策略通常失效必须重新读取商品事实
营销规则版本活动发布、券规则、门槛、互斥立即失效或按版本隔离必须重新评估资格
引擎版本代码发布、舍入或组合算法灰度隔离必须空跑 diff 后逐步切流
报价版本供应商响应、汇率、报价 token短 TTL必须过期时重新询价
用户资格版本会员变更、风控标签、渠道身份短 TTL视业务创单前二次校验

如果只保存一个 rule_version,仍然无法重建一份依赖供应商报价的历史价格。反过来,如果把所有下游响应完整复制到每一份快照,存储和隐私成本又会迅速膨胀。可以采用“快照正文 + 外部引用 + 摘要哈希”的折中:对金额、币种、组件、分摊和规则参数保存完整值;对大体积商品描述、用户画像和供应商原始响应保存版本引用与哈希;在合规允许的范围内保留重放所需的最小输入。

quote_id 和 snapshot_id 也不应混用。报价是一个可失效的计算结果,可以被新的请求替换;快照是交易事实,具有明确的创建者、有效期和状态。订单创建成功后,quote_id 可以归档,snapshot_id 继续被支付、退款、客服和财务引用。支付系统接收快照引用并验证摘要,而不是依赖客户端再次上传完整规则。

13.2.6 价格字典与领域事件

团队规模变大后,最常见的协作问题是同一个词在不同服务里含义不同。例如营销说“活动价”,商品说“销售价”,计价说“促销层”,订单说“成交价”,财务说“含税结算价”。应建立价格字典,至少定义:原价、基础价、活动后价、商品小计、订单小计、优惠、费用、支付应付、商家收入、平台补贴、退款金额和结算金额。每个术语附带金额层、币种、税费和是否可变的说明。

领域事件也要表达状态变化,而不是只表达“某某服务调用成功”:

BenefitIssued       用户获得权益
BenefitReserved     交易意图冻结权益
BenefitReleased     交易失败释放权益
BenefitConsumed     支付/履约确认核销权益
SnapshotCreated     形成价格事实
SnapshotInvalidated 报价或依赖版本失效
OrderPriceChanged   订单被允许通过显式流程改价

事件名称应以业务事实的完成为准。ReserveBenefitRequested 是命令或意图,BenefitReserved 才是消费者可以依赖的事实。事件携带 aggregate_id、aggregate_version、event_id、occurred_at 和 trace_id,这样重放、排序和对账时才能判断是旧事件、重复事件还是合法的新状态。数据密集型系统设计强调,日志和事件的价值不只是异步通知,也在于提供可重放、可审计的事实流;但重放仍必须尊重外部副作用的幂等边界。[4]

13.2.7 什么时候不需要统一计价中心

统一计价不是默认答案。以下情况可以暂时保留简单实现:商品只有一个币种和固定价;没有用户差异化;只有一种不叠加的折扣;没有平台/商家分摊;订单生命周期短且不支持部分退款;价格变化不会跨服务产生资金影响。在这种场景中,单体内的领域服务和数据库事务可能比远程计价服务更容易保证正确。

当出现下列任意两个信号时,再考虑拆分或建设统一引擎:同一价格逻辑在商品、购物车、订单和支付中复制;新增优惠要修改多个服务;客服无法解释价格;退款只能按总额估算;促销预算需要按出资方结算;供应商报价和固定价共存;大促时营销和计价分别扩容却互相放大流量。架构演进的目标是消除真实的变化耦合,而不是把每张表都变成微服务。

13.3 参考架构与职责边界

13.3.1 端到端参考架构

参考架构按“事实源、决策、编排、交易和治理”分层,而不是按技术组件名称分层。商品中心提供商品、SKU、店铺和基础价引用;供应商价格服务提供有过期时间的外部报价;用户和会员系统提供身份、等级和风控分群;营销系统提供活动、券、积分、补贴和预算;计价中心把这些输入编排为价格快照;订单、库存和支付完成交易状态推进。

flowchart LR
    C[客户端 / BFF] --> Q[报价编排层]
    Q --> P[计价中心]
    Q --> M[营销系统]
    Q --> U[用户与会员]
    P --> G[商品/供应商价格适配器]
    P --> S[价格快照库]
    M --> R[规则与活动配置]
    M --> L[营销权益账本]
    M --> B[预算与营销库存]
    Q --> O[订单系统]
    O --> I[商品库存]
    O --> Pay[支付系统]
    O --> E[(Outbox / 事件总线)]
    E --> A[对账、审计、分析]

“报价编排层”可以位于结算服务、BFF 或计价应用服务中,关键是不要让客户端自己拼价格。客户端可以提交用户选择的券和积分数量,但服务端必须重新验证资格、基数、版本和上限。展示路径可以读取缓存视图,创单路径必须经过权威接口;同一个 trace_id 贯穿两条路径,使团队能区分“页面展示旧价”和“创单错误价”。

13.3.2 职责矩阵

能力商品中心营销系统计价中心订单系统支付系统财务/对账
基础商品与 SKU权威只读只读快照引用不关心查询
基础价格自营/供应商事实不拥有适配并计算保存快照校验对账
优惠资格提供圈品维度权威调用保存选择不决定审计
优惠金额不计算提供权益规则权威计算保存组件校验分摊
券/积分/预算状态不拥有权威状态机不直接写触发冻结/释放触发确认对账
订单总额不拥有不拥有生成建议/快照交易事实验证应付额结算事实
支付金额不拥有不拥有提供快照提交支付单资金事实清算核对
规则发布商品规则活动规则引擎版本不发布不发布审批审计

边界的一个实用测试是“如果删除这个系统,哪一条事实会失去权威来源”。如果答案是“没有,另一个系统也有一份”,说明出现了双写事实;如果答案是“订单里有一个优惠金额字段”,还需要追问它是支付快照的冗余字段,还是营销规则的权威来源。订单可以保存结果,但不应成为营销规则的配置中心。

13.3.3 API 契约:评估、试算、冻结和快照

营销和计价的接口要区分只读评估与有副作用操作。一个最小契约如下:

EvaluateBenefits(request, scene) -> candidates, rejected_reasons, rule_version
CalculateQuote(request, candidates, scene) -> price_components, allocations, quote
ReserveBenefits(order_id, quote_id, selected_benefits, idempotency_key) -> reservations
CreateSnapshot(quote, reservations, idempotency_key) -> snapshot_id
ConfirmBenefits(order_id, reservation_ids, idempotency_key) -> consumed
ReleaseBenefits(order_id, reservation_ids, idempotency_key) -> released

EvaluateBenefits 不修改券和预算,适合 PDP、购物车和结算预览;ReserveBenefits 才产生冻结记录,必须拥有订单或结算会话 ID。CalculateQuote 需要把营销返回的拒绝原因结构化,例如 expired、threshold_not_met、scope_mismatch、quota_exhausted、risk_rejected,不能只返回“优惠不可用”。结构化原因可以用于页面提示、运营分析和客服解释。

计价接口的请求必须显式包含 scene。PDP、列表页、购物车、创单和支付校验不是同一个 API 换一个枚举值,而是不同的正确性契约:PDP 接受短暂过期和部分结果;创单必须有完整组件和可用的基础价;支付校验必须拒绝过期供应商报价和快照金额不一致。一个“万能价格接口”往往把所有场景都降级到最严格的成本,或者把交易路径错误地降级为展示路径。

13.3.4 ADR-02:快照传递而不是重复重算

背景:如果结算生成金额后,订单再次从商品、营销、用户和供应商系统重新计算,任何一个规则版本、用户等级、汇率或供应商报价变化都可能改变结果;但如果订单盲信客户端传来的金额,又无法防止篡改。

候选方案:

方案优点牺牲/风险
每个阶段都实时重算输入最新同一操作口径漂移,依赖放大,难以解释历史
客户端传最终金额调用少不可信,无法审计,易产生资损
结算生成快照,创单/支付校验引用并验证口径稳定,可审计,可防篡改需要快照存储、有效期和失效处理
只保存组件,不保存输入存储小规则和报价变化后无法重建当时结果

决策:采用第三种方案。快照由服务端生成并带有请求指纹;订单创建时验证签名、版本、有效期和用户/商品关键字段;支付时比较订单应付金额、快照金额和支付单金额。动态价格过期时生成新快照并要求用户重新确认,而不是偷偷替换订单金额。

该决策获得了一致口径、审计重放和安全校验,牺牲了部分实时性和存储空间;需要接受的风险是供应商报价在快照有效期内仍可能发生外部变化,因此品类策略必须明确“允许用快照成交”还是“支付前必须二次询价”。这种有背景、有候选、有牺牲、有重新评估条件的 ADR 比一句“采用快照机制”更能帮助后续团队做判断。[17]

13.3.5 配置发布与规则版本

规则配置不是直接改一行数据库记录。发布流程至少包括草稿、校验、审批、仿真、灰度、生效和下线。校验器应检查:时间窗口是否重叠;互斥组是否形成环;折扣是否超过上限;适用商品集合是否为空;预算是否有承担方;不同币种的金额是否使用正确精度;规则是否引用了已删除的券或店铺;新的活动是否会绕过最低价保护。

每次发布生成不可变的 rule_version。预览返回这个版本,快照引用这个版本,客服查询按这个版本重放。配置中心可以缓存当前版本,但数据库仍保存版本历史和发布人。回滚不是把配置改回旧值,而是把旧版本重新标记为有效并记录新的发布事件,避免审计时间线被覆盖。

规则仿真应使用历史请求样本和合成边界样本:单行恰好达到门槛、跨店恰好差一分钱、券过期一秒、预算只剩一单位、两张互斥券同时提交、退款只覆盖一个订单行。规则评估结果除了最终价格,还要输出命中的规则、被拒绝的规则及原因,以支持发布前人工检查。

13.3.6 读模型、写模型和缓存投影

营销与计价的查询量和写入语义差异很大。活动列表、可用券列表和“预计节省”属于读模型,可以按用户分群、店铺和渠道预计算;券冻结、积分消费、预算扣减和价格快照属于写模型,必须在权威存储中完成状态迁移。不要为了让查询快,把写模型中的 coupon_user.status 直接复制成多个可写缓存;读模型只能通过事件或版本刷新,不能反向修改权威事实。

一种常见的读模型是 BenefitView:它把用户当前可见的券、活动命中摘要和预计优惠聚合在一起,但不包含“已经保证可用”的承诺。视图带 generated_at、source_version 和 partial 标志,页面可以据此决定是展示、提示刷新还是隐藏。结算预览重新调用权威评估,创单则重新调用冻结接口。这样既能承受高读 QPS,又不会让缓存陈旧状态直接成为交易事实。

计价读模型可以预热匿名价、会员价和公开活动价;个性化价要把用户分群键、渠道和地区纳入 Key。缓存投影更新采用版本比较:只有事件版本大于当前版本时才覆盖,乱序事件放入延迟队列或重新读取权威数据。数据密集型系统中,复制和异步投影本来就会引入滞后,因此应用必须明确哪些读可以接受滞后、哪些读必须回到权威源。[4]

13.3.7 API 错误模型与可演进契约

金额系统的错误码要区分用户可修复错误、系统暂态错误和不可自动重试错误。推荐至少包括:INVALID_ARGUMENT、VERSION_CONFLICT、BENEFIT_NOT_ELIGIBLE、QUOTA_EXHAUSTED、QUOTE_EXPIRED、PRICE_CHANGED、IDEMPOTENCY_CONFLICT、DEPENDENCY_TIMEOUT、SAFETY_CHECK_FAILED 和 UNKNOWN_STATE。错误响应包含 retryable、next_action、reason_code、snapshot_id 或 reservation_id,让上游知道应该刷新、重新确认、查询状态还是联系客服。

接口演进必须向后兼容。新增价格组件类型时,旧客户端可以把它展示为“其他优惠/费用”,但不能忽略影响应付金额;新增字段使用可选语义,删除字段先经历弃用期;枚举未知值不能让反序列化直接崩溃;错误码新增时不改变已有错误的重试含义。创单和支付的核心字段应使用契约测试锁定,不能因为营销服务新增一个展示字段就改变交易金额。

接口还要区分命令和查询:GetQuote、GetSnapshot、GetBenefitStatus 是查询,重复调用不应产生副作用;Reserve、Confirm、Release、CreateSnapshot 是命令,必须有业务幂等键。Saga 模式通常通过编排器调用多个参与者;参与者应提供正向动作、补偿动作和状态查询三个能力,而不是只提供一个“万能更新状态”接口。[7]

13.4 营销规则、优惠组合与营销库存

13.4.1 优惠券、积分、活动和补贴不是四套孤立 CRUD

营销工具虽然在产品上表现为券、积分、活动和补贴,但它们共享三个抽象:资格、有限资源和生命周期。优惠券的有限资源是发行量和用户配额;积分的有限资源是账户余额;活动的有限资源是时间窗口、参与次数和预算;补贴的有限资源是平台或商家的资金承诺。把这些工具分别实现成“发一张券”“加积分”“改活动状态”“减预算”四个接口,会在下单、取消和退款时出现四种不同的幂等语义。

营销系统应区分配置对象和用户权益对象:

层次例子生命周期典型存储
活动配置满减、折扣、买赠、秒杀草稿→审核→生效→结束关系库 + 版本
规则对象门槛、圈品、会员、渠道、互斥随活动版本发布配置快照/规则索引
用户权益某用户领取的一张券、一次资格可用→冻结→核销/释放权益表 + 流水
营销库存券额度、活动名额、预算桶可用→冻结→消耗/回补账本 + 热点缓存
事件记录发放、冻结、确认、释放、过期不可变追加Outbox/事件表

活动配置可以被运营修改,用户权益和订单快照不能随之重写。活动命中结果也不应等同于“用户已经获得优惠”:命中只是试算,冻结才是交易承诺,确认才是消费。

13.4.2 圈品、资格与规则匹配

圈品规则常见三种表达:全部商品、集合匹配和条件匹配。集合匹配适合 SKU、类目、品牌和店铺 ID;条件匹配适合价格区间、标签、渠道和用户等级。规则引擎不应在每次请求中扫描全量商品。发布时把静态集合编译成可查询索引,运行时先做粗筛,再做用户和订单上下文判断。

资格判断要分成“稳定资格”和“瞬时资格”。会员等级、注册时间和渠道来源通常可以缓存;库存、预算、券状态和供应商报价是瞬时事实,必须在冻结或创单前重新确认。缓存资格只能减少读取,不能替代权威状态机。

规则命中结果包含:

{
  "benefit_id": "coupon-100-20",
  "eligible": true,
  "scope": ["line-1", "line-2"],
  "discount_type": "amount",
  "discount_minor_units": 2000,
  "exclusive_group": "merchant-activity",
  "priority": 80,
  "funding": [{"source": "merchant", "amount": 1500}, {"source": "platform", "amount": 500}],
  "rule_version": "campaign-v12",
  "explain": "商品小计达到 10000 分,且用户为会员"
}

美团外卖营销实践把领域对象、策略模式和领域事件结合起来处理复杂营销逻辑,说明营销代码的可维护性来自业务语义的组织,而不只是把 if-else 换成工厂类。[22] 本章的推导是:资格、组合和资金分摊分别建模,才可以在不改变券生命周期的情况下替换组合算法,也可以在不改变计价层的情况下增加新的券类型。

13.4.3 优惠叠加、互斥与最优组合

优惠组合不能简单按“折扣金额最大”求解,因为业务还要约束顺序、互斥、同组最多一个、平台券和商家券的共同使用,以及是否允许覆盖运费。可将候选优惠看作带约束的集合选择问题:

候选集合 C = {c1, c2, ... cn}
选择集合 S ⊆ C
约束:
  互斥组中最多选择一个
  每个券的适用范围必须非空
  组合后的折扣不得超过适用基数
  平台/商家预算都不能为负
目标:
  在业务优先级、用户应付金额、商家承担额和体验规则之间选择合法解

求解策略依购物车大小选择:单行和候选很少时可枚举;多行、多店铺时先按互斥组做局部最优,再用分支限界或动态规划搜索;候选过多时保留用户可见的前 K 组,并明确展示“已按当前规则选择最优组合”。不能为了追求理论最优而让结算页在所有活动上做指数级搜索。

规则顺序要固定并输出:

  1. 确认行和店铺边界,排除不适用商品;
  2. 应用只能改变单价的活动价或阶梯价;
  3. 计算店铺券、跨店券和平台券的合法组合;
  4. 应用积分、红包等支付抵扣;
  5. 计算可打折费用和不可打折费用;
  6. 进行金额下限、预算、出资方和尾差检查。

门槛优惠的“门槛基数”必须写在规则中:是原价、活动后小计、扣券前小计,还是含运费金额。否则运营说“满 300”,研发可能用不同金额层判断,用户会遇到“明明超过门槛却不可用”。研究显示,用户不仅关心经济收益,也会根据预期和公平感评价门槛或封顶促销。[18] 工程上应把这种产品语义落实为可解释的 threshold_basis,而不是让文案和代码各自猜测。

13.4.4 营销库存与原子预占

营销库存与商品库存不同。商品库存通常表示可履约数量,营销库存表示平台愿意承诺的权益数量或金额;前者可能在仓库中有物理位置,后者通常由配额、预算和用户限制构成。二者可以在同一个订单编排中协作,但不能共享一张“库存表”而不区分语义。

营销库存至少包括:

资源约束例子
活动总量全局数量或金额发放 100 万张券
时段配额时间片内上限每分钟 2 万张
用户配额单用户、设备、账户族群每人限 1 张
预算桶平台、商家、渠道或活动预算平台补贴 500 万元
并发占用短期冻结未支付订单占用 15 分钟

抢券和预占的热路径可以使用 Redis 脚本做条件检查和计数更新,但 Redis 不是营销账本。脚本只负责在一个热点分片内原子判断,成功后必须写入持久化的 reservation 和事件记录;异步消费者、定时任务和对账程序负责把缓存计数与账本收敛。Redis Lua 脚本在服务端串行执行,能把多键条件更新放在一次原子操作中,但长脚本会阻塞其他请求,且脚本缓存本身不是持久数据库。[12]

-- KEYS[1] = campaign quota
-- KEYS[2] = user quota
-- ARGV[1] = requested amount
-- ARGV[2] = user limit
-- ARGV[3] = reservation id
local total = tonumber(redis.call('GET', KEYS[1]) or '0')
local used = tonumber(redis.call('GET', KEYS[2]) or '0')
local request = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])

if total < request then return {0, 'campaign_exhausted'} end
if used + request > limit then return {0, 'user_limit'} end

redis.call('DECRBY', KEYS[1], request)
redis.call('INCRBY', KEYS[2], request)
redis.call('HSET', 'reservation:' .. ARGV[3],
  'amount', request, 'status', 'FROZEN')
return {1, 'reserved'}

脚本的业务约束是:所有涉及的 Key 必须属于同一 Redis Cluster hash slot;脚本参数不能来自客户端未经验证的价格;返回成功后,应用必须把 reservation_id 和订单幂等键持久化。若持久化失败,不能简单把脚本再执行一次,因为第二次可能重复扣减;应通过状态查询和补偿释放解决“缓存成功、账本未知”的情况。

13.4.5 券、积分与补贴的逆向流程

取消和退款不是“把正向接口反过来调用”这么简单。券可能已经过期,积分可能已经被用户继续消费,平台补贴可能已经进入商家结算。逆向流程要按组件和订单状态决定:

逆向场景券积分平台/商家补贴处理原则
创单失败释放冻结释放冻结释放预算冻结必须幂等,优先恢复可用额度
支付超时释放或过期释放冻结释放预算以支付最终状态查询为前提
订单取消未履约按策略退回退回或生成新流水冲正待结算金额不能直接删除原流水
部分退款按行/组件规则按分摊退回按出资方冲正引用 component_id 和分摊明细
已结算退款生成售后调整生成退还流水进入财务调整单不改写历史快照

平台补贴和商家补贴必须在价格组件中分拆,否则财务只能从最终优惠金额猜出资比例。多店铺订单尤其要保存店铺维度、平台维度和渠道维度的分摊结果;跨店满减的用户优惠可以集中计算,但资金承担必须可拆解。

13.4.6 优惠券发放与用户权益表

优惠券有两个不同的库存问题:模板发行量,以及用户是否已经拥有一张具体权益。模板库存可以按活动分片预扣,用户权益表则需要唯一约束避免同一用户超过领取上限。推荐的数据模型如下:

表/对象关键字段作用
coupon_templatetemplate_id、rule_version、total_quota、validity描述可发行的券和规则
coupon_usercoupon_id、user_id、status、expire_at用户持有的具体权益
coupon_reservationreservation_id、order_id、coupon_id、expires_at交易期间的冻结
coupon_ledgerevent_id、action、before_status、after_status不可变状态流水
coupon_outboxevent_id、event_type、payload可靠发布领域事件

领取接口的幂等键可以是 receive:{user_id}:{template_id}:{campaign_round},使用唯一键控制并发;发券成功后才返回券 ID。若库存预扣成功而用户权益写入失败,补偿任务应释放模板配额;若用户权益写入成功而响应超时,重试应根据幂等键返回原券,而不是再发一张。领取成功、冻结成功和核销成功分别产生不同流水,客服可以沿流水看出“券从未领取”“已领取但未使用”“订单已冻结等待支付”还是“支付成功已核销”。

券的过期不应依赖一个定时任务把所有状态批量改成 EXPIRED。读取时可以依据 expire_at 判定不可用,后台任务负责补写审计状态和释放过期预算;如果过期和冻结并发发生,状态更新必须带版本或条件,例如只允许 AVAILABLE 且 expire_at > now 的记录进入 FROZEN。过期任务重复执行应是幂等的。

13.4.7 积分账户:余额是投影,流水是证据

积分经常被实现成“余额加减”,但电商交易里至少有赚取、冻结、消费、释放、退还和过期六种动作。账户表可以保存可用、冻结、累计获得和累计消费等投影;真正的证据保存在流水表。每次变更用 entry_id 和 biz_id 唯一约束,方向、数量、过期批次和关联订单写入同一事务。

可用积分 = 历史入账 + 退还 + 释放 - 已消费 - 已过期 - 已冻结
账户不变量:available >= 0,frozen >= 0
消费前提:available >= requested
释放前提:冻结流水属于同一 order_id 和 reservation_id

积分有有效期时,消费还涉及批次选择,例如优先消费最早过期批次。这个选择必须进入冻结流水,否则退款时无法知道应该退回哪个批次。部分退款可以按订单行和组件分摊积分;如果产品不允许按行退还,要在规则中说明“整单退款后统一退还”,不能让售后服务自行决定。

13.4.8 活动类型与活动状态

活动类型不应直接决定数据库表结构。满减、折扣、阶梯价、买赠、N 元购、秒杀和拼团都可以建模为“适用范围 + 资格条件 + 计算器 + 资源约束 + 生命周期”。区别在于计算器和状态机:

活动核心输入资源约束失败/过期处理
满减适用小计、门槛、减免额活动预算、用户次数未达门槛返回差额
折扣基础价、折扣率、封顶预算、最低价超过封顶按上限
阶梯累计金额或数量每级配额选择可达到的最高级
买赠主商品、赠品、数量赠品库存赠品缺货拒绝或降级
秒杀时间、库存、用户资格高并发配额排队、限流或售罄
拼团团状态、人数、截止时间团名额、补贴团失败按策略退款

活动状态通常包括草稿、待审核、已发布、预热、进行中、暂停、已结束和已取消。运营暂停活动时,新请求不能再命中;已经冻结的订单要根据活动规则确认或释放。若活动支持延迟生效,发布事件要携带生效时间和版本,不能依赖各服务本地时钟恰好一致。

13.4.9 反作弊与营销公平

营销风控要避免两个极端:只用 IP 限制导致共享网络用户被误伤,或只靠黑名单导致黑产换设备后继续领取。可组合账户、设备、手机号、支付工具、收货地址、行为速度、历史退款率和订单完成率等信号,并把决策分为允许、挑战、排队、拒绝和人工审核。风控结果是资格输入的一部分,但不应把完整风险模型细节写进价格快照;快照只保存决策版本和审计摘要。

公平还体现在规则文案和最终金额。平台需要避免同一公开条件下出现无法解释的不同优惠,也要允许个性化权益明确说明适用对象。促销的门槛、封顶、概率和有效期应在页面展示,不能依靠用户点击后才发现限制。技术系统能够做到“每次规则一致执行”,但不自动证明规则对所有用户公平;这需要产品、法务、风控和数据团队共同定义评价指标。

13.5 计价引擎、费用模型与多品类适配

13.5.1 计价不是一段公式,而是一条可追踪的计算管线

计价引擎需要同时满足三个看似冲突的目标:在读路径足够快,在交易路径足够严格,在事后能够解释。解决方法是把计算拆成稳定的层和可插拔的适配器,每层接受结构化状态并追加组件,不把所有中间结果压缩成一个总数。

flowchart LR
    A[请求指纹] --> B[输入规范化]
    B --> C[基础价格层]
    C --> D[营销价格层]
    D --> E[抵扣层]
    E --> F[附加费用层]
    F --> G[分摊与舍入]
    G --> H[安全校验]
    H --> I[报价或快照]

每层输出 PricingState,其中至少包含行级中间金额、组件列表、已选营销权益、供应商报价引用、规则版本、舍入记录、错误/降级标志和剩余超时。层之间传递的是值对象和不可变输入;如果某层需要修改,生成新的状态或追加事件,不要在多个 goroutine 中共享可变的金额对象。

13.5.2 基础价格层:不同品类,不同事实来源

固定实物价格可以从商品中心读取;酒店和票务价格通常依赖日期、场次、座位或供应商;充值和电子钱包可能存在面额、汇率和通道费;订阅商品需要周期和续费规则。它们的共同接口不是“返回一个 int”,而是:

type BaseQuote struct {
	LineID       string
	UnitAmount   Money
	Quantity     int64
	Currency     string
	QuoteID      string
	Version      string
	ExpiresAt    time.Time
	Source       string
	Attributes   map[string]string
}

type BasePriceProvider interface {
	Quote(ctx context.Context, req BasePriceRequest) (BaseQuote, error)
}

适配器要把外部报价的有效期、请求参数摘要和供应商返回 ID 带入内部状态。一个供应商返回 100 元,另一个供应商返回 100 元,不代表它们的可售事实相同;前者可能有 10 分钟有效期,后者可能只保证 30 秒。计价中心可以缓存结果,但不能把无限期缓存价当成交易价。

价格请求先做规范化:对行项目排序、统一数量和币种、剔除不可售行、补充用户/渠道/地区上下文,然后计算 request_fingerprint。相同业务请求的字段顺序不同,不应导致不同快照;但数量、地址、配送方式、支付渠道、券选择或会员状态变化时,指纹必须变化。

13.5.3 价格层与费用模型

本章采用五个逻辑阶段:

阶段处理内容主要输出不能做什么
Base基础价、数量、币种、报价有效期行基础价不读取券并自行打折
Promotion活动价、阶梯、买赠、秒杀活动组件不直接确认券核销
Deduction券、红包、积分、会员抵扣抵扣组件不绕过资格和预算
Charge运费、服务费、渠道费、税费费用组件不隐式改变优惠顺序
Finalize下限、分摊、舍入、安全检查快照/报价不覆盖历史快照

费用建模要先回答“费用是什么”,再回答“如何计算”。建议每项费用带有 fee_code、basis、calculation_type、discountable、funding_source、taxable 和 allocation_policy。服务费可以是固定金额,平台费可以按比例,供应商手续费可能与支付方式相关,税费还可能依赖地区。费用是否可被券覆盖必须是显式配置;如果把它写成代码中的一个 if,后续加入新费用时极易出现旧券意外覆盖新费用。

ChargeComponent {
  fee_code: SERVICE_FEE
  basis: PROMOTED_SUBTOTAL
  calculation: fixed(1500) | percentage(3%)
  discountable: false
  funding_source: merchant
  allocation_policy: by_line_weight
}

多币种场景必须保留原币金额和结算币金额,汇率版本和有效期也属于快照输入。金额计算使用货币最小单位的整数或经过明确舍入规则的十进制定点数,不能使用二进制浮点数直接累加。MySQL 的事务隔离级别决定了并发读取和更新时的可见性,默认隔离级别和业务所需的锁定语义不能靠“数据库会保证一致”一笔带过。[11]

13.5.4 分摊、尾差与退款

订单级优惠必须下沉到订单行,否则商品部分退款时无法知道应退多少。设订单行的活动后小计为 b_i,订单级优惠为 D,则一种按权重分摊的初始结果为:

raw_i = D × b_i / Σb_i
allocated_i = floor(raw_i, currency_scale)
tail = D - Σallocated_i
把 tail 加到满足业务规则的最后一行或最大金额行

选择“最后一行”还是“最大金额行”必须固定并写入快照。多币种、零金额赠品、负向组件和跨店铺券都要覆盖测试。分摊结果应满足:所有行分摊之和等于订单优惠;每行分摊不超过适用金额;平台和商家出资分摊之和等于总优惠;退款按原组件和原分摊恢复,而不是重新按当前行金额计算。

常见错误包括:先四舍五入每一行导致总额多一分;把运费按商品金额分摊却没有记录配送维度;商品数量减少后重新算历史优惠;合并订单行后丢失原 component_id。这些问题看起来是金额细节,实际会扩散到发票、结算、退款、客服和财务对账。

13.5.5 领域模型与代码边界

计价领域可以使用值对象 Money 和 PriceLayer,聚合根可以是某个行级报价或订单报价。值对象负责金额加减、币种和精度;聚合负责不变量;领域服务负责跨多个组件的优惠组合和分摊;应用服务负责调用商品、营销、用户和供应商端口;基础设施负责缓存、数据库和消息。

type Money struct {
	MinorUnits int64
	Currency   string
}

func (m Money) Add(other Money) (Money, error) {
	if m.Currency != other.Currency {
		return Money{}, ErrCurrencyMismatch
	}
	return Money{MinorUnits: m.MinorUnits + other.MinorUnits,
		Currency: m.Currency}, nil
}

type PriceComponent struct {
	ID             string
	Kind           string
	Amount         Money // 减免用负数,费用用正数,或统一使用 direction 字段
	LineID         string
	RuleVersion    string
	FundingSource  string
	Discountable   bool
}

type PricingCalculator interface {
	Calculate(ctx context.Context, input PricingInput) (PricingResult, error)
}

这里的代码只是边界示意,不是可以直接上线的完整实现。生产代码还需要显式区分“减免金额为正、方向单独表示”和“减免组件用负数”的方案,不能让不同计算器各自选择。DDD 的价值是把选择固化为统一语言和领域不变量,而不是给普通 CRUD 代码增加一层抽象。[1][2]

13.5.6 场景驱动的计算器

不要让一个 CalculatePrice 接口携带几十个可选字段,然后在实现中根据 scene 走无穷分支。可以保留统一的领域管线,但为场景定义不同的输入和强约束:

场景允许缓存依赖完整度失败策略是否生成交易快照
列表页高可只读匿名/会员展示价返回基础价或隐藏营销标签否
PDP中基础价 + 可展示权益局部降级,标记不完整否
加购中行价和限购提示重新试算否
购物车低到中多行、多店铺、券预览失败行显式标记可生成短期 quote
创单低所有交易依赖和营销冻结fail-fast,不用旧营销价是
支付校验低快照、币种、报价有效期金额不一致即拒绝验证已有快照

供应商类商品还需要 quote_token 和 expires_at。支付校验发现报价过期时,不能默默使用缓存报价;可根据品类策略选择重新询价、重新生成快照、提示用户确认或失败。航空、酒店和票务常常不允许用过期报价成交,固定实物可能允许在很短窗口内使用锁定价,这应是配置化的品类策略。

13.5.7 个性化价格与安全护栏

个性化定价的输入可能来自用户等级、渠道、地区、会员权益和行为分群。系统必须区分“对所有符合资格的用户公开的会员权益”和“基于不可解释特征给每个人返回不同的基础价”。前者可以用权益规则建模,后者需要额外的公平、合规、可解释和审计约束。个性化价格研究指出,价格优化涉及消费者福利、企业收益、平等获得和分配结果等不同目标,不能用单一收入指标判断系统成功。[19]

安全检查器应在快照写入前运行:

func CheckQuote(req PricingRequest, result PricingResult) error {
	if result.Payable.MinorUnits < 0 {
		return ErrNegativePayable
	}
	if result.DiscountTotal().GreaterThan(result.EligibleSubtotal()) {
		return ErrDiscountExceedsBase
	}
	if result.AllocatedDiscount() != result.DiscountTotal() {
		return ErrAllocationMismatch
	}
	if result.BudgetDelta().IsOverLimit() {
		return ErrBudgetExceeded
	}
	if !result.CurrencyMatches(req.Currency) {
		return ErrCurrencyMismatch
	}
	return nil
}

安全检查不是风控的替代品。风控可以拒绝疑似脚本、设备族群或异常行为;计价安全检查只保证金额、范围、预算和版本的内部不变量。两者都失败时,不能为了转化率只跳过一个检查器。

13.5.8 供应商报价与动态价格适配

固定价格和动态价格的最大差异不是“一个来自数据库、一个来自接口”,而是承诺方式不同。固定价通常有较长有效期,动态报价可能绑定日期、库存、房型、座位、供应商 token 和取消政策。适配器返回报价时,需要把这些条件统一为内部可校验对象:

SupplierQuote {
  quote_id             外部报价标识
  product_ref          供应商商品/资源引用
  amount, currency     原币金额
  valid_until          报价过期时间
  reservation_token    后续预订或出票 token
  cancellation_policy  取消和退款条件
  source_version       客户端/供应商协议版本
}

计价中心不能把“查询到了价格”误认为“资源已被锁定”。酒店房间、电影座位、机票和第三方券码可能需要另一个预订动作。若业务要求在创单时锁定供应商资源,价格快照应引用预订 token;支付失败时,取消预订也成为 Saga 的一个补偿步骤。若供应商只提供报价不提供锁定,则快照只能表达“在有效期内按策略尝试成交”,不能对用户承诺绝对可得。

动态报价缓存必须遵循最短有效期和品类策略。缓存命中后仍校验 valid_until,并在支付或创单前根据风险等级二次询价。对于用户展示,可返回“价格以结算为准”;对于已经支付的订单,后续供应商涨价不能直接转嫁给用户,系统应按照订单快照和履约/退款策略处理。

13.5.9 计价字典服务与配置治理

费用和营销组件多起来后,规则配置中的字符串会产生隐形耦合,例如 service_fee 在一个服务表示平台服务费,在另一个服务表示供应商手续费。可以建立轻量的价格字典服务,维护组件编码、中文展示名、会计科目、出资方、可折扣性、可退款性、税务属性和默认分摊策略。字典只负责语义和元数据,不负责执行复杂计算。

配置治理包括:字段 schema、单位和精度校验;向后兼容;默认值禁止隐式改变金额;审批人和发布单;版本回滚;生产快照;配置变更通知。对 JSON 规则配置应做结构化验证,而不是只检查 JSON 能否解析。规则中的金额以最小货币单位表示,折扣率明确使用百分比还是小数,时间明确使用 UTC 或带时区时间。

13.5.10 新品类接入四步法

接入一个新品类时,先不要复制一份价格服务。按四步检查:

  1. 定义基础事实:价格来自固定表、日历、供应商还是实时算法;是否需要报价 token;库存和价格是否同一来源。
  2. 选择通用层:该品类能否复用基础价、营销、抵扣、费用和快照层;哪些层要跳过,必须写入场景矩阵。
  3. 实现专用适配器:只在基础价、供应商预订、费用或退款语义确实不同处增加 Calculator/Adapter,不在调用方写品类 if-else。
  4. 验证完整链路:详情页、购物车、创单、支付、取消、部分退款、对账和故障演练全部走通。

新品类验收表应包含基础价格来源、币种精度、费用配置、营销兼容性、快照字段、供应商有效期、库存预占、退款规则、SLO 和监控指标。这样“接入新品类”变成一组可验证的能力,而不是新建一套互不兼容的订单和计价逻辑。

13.5.11 计算引擎的确定性

同样的输入和版本应产生相同的价格组件顺序、金额和分摊结果。确定性要求包括:候选规则排序稳定;集合遍历不依赖哈希表随机顺序;舍入规则固定;时间由请求中的 as_of 或受控时钟提供;随机实验分组写入输入;错误和降级标志进入结果;供应商响应原始数据经过规范化后再计算。

确定性让空跑 diff、历史重放和客服解释成为可能。它不等于所有环境必须得到同一字节 JSON:字段排序可以由序列化层统一;但金额、组件 ID、规则版本和分摊结果必须语义相同。引擎输出可以同时包含人类可读的解释树和机器校验的哈希,前者服务客服,后者服务支付和对账。

13.6 从展示到支付的完整交易链路

13.6.1 示例场景与时间轴

下面用一个示例假设走通完整链路:用户在两个店铺购买三件商品。店铺 A 的商品基础小计为 220 元,店铺 B 的商品基础小计为 130 元;店铺 A 有“满 200 减 30”的活动,平台发放一张“满 300 减 20”的平台券,用户选择抵扣 50 元积分;订单有 12 元服务费,其中 5 元可被店铺承担、7 元不可折扣。平台券由平台承担,店铺活动由店铺承担。商品库存、券、积分和预算都必须在交易链路中形成对应的承诺。

这组数字不是行业标准,只用于说明“先定义基数和出资方,再算总价”。假设营销规则规定:店铺活动先于平台券,平台券基于活动后商品小计,积分只能抵扣商品应付金额,服务费不可被积分抵扣。于是:

商品基础小计             350.00
店铺 A 活动减免          -30.00
活动后商品小计           320.00
平台券                   -20.00
积分抵扣                 -50.00
可折扣服务费              +5.00
不可折扣服务费            +7.00
最终应付                  262.00

真实实现还要将 30 元和 20 元分摊到相应订单行,记录平台/店铺出资,并根据库存、地址、运费、税费和支付渠道重新校验。这个例子最重要的不是算出 262,而是每一行都能回答“它的输入、规则、责任人和回滚方式是什么”。

13.6.2 PDP 与购物车:允许变化,但要诚实表达

商品详情页请求可以使用 GetQuote(scene=pdp)。它读取基础价、活动展示信息和用户可见券,但不冻结券、不扣预算。响应中返回:

  • 当前展示金额和币种;
  • 已应用的公开活动组件;
  • “还差多少达到门槛”这类解释信息;
  • 价格有效期和是否为缓存/部分结果;
  • 进入结算后可能变化的条件,例如库存、供应商报价、地址和支付方式。

购物车请求要以服务端行项目为准。未登录购物车合并到登录账户时,不能沿用客户端缓存的券选择和价格,必须先完成行合并、店铺归属确认和权益重新评估。购物车可以保存用户选择,但选择只是意图,不是核销事实。

当某个活动服务超时时,列表页可以隐藏活动标签或返回基础价;购物车应把对应行标记为“优惠待重新计算”,而不是展示旧的优惠金额并让用户误以为已经锁定。展示降级的前提是用户能看见不确定性;交易路径不应把“缓存成功”当作“权利成功”。

13.6.3 结算、冻结与创单

结算服务收集地址、配送方式、支付渠道、券选择、积分数量和用户确认,然后调用计价中心生成 quote_id。计价中心并行读取商品/供应商报价、用户资格、营销候选和费用配置;营销服务只在用户点击“提交订单”后执行权益冻结。推荐顺序如下:

  1. 校验请求指纹、用户身份、购物车版本和商品可售状态;
  2. 获取各行基础报价并检查报价有效期;
  3. 评估营销候选,过滤资格不满足和预算不足的权益;
  4. 选择合法优惠组合,生成组件和初步分摊;
  5. 冻结券、积分、营销预算和必要的商品库存;
  6. 在冻结结果和价格组件都成功后写入价格快照;
  7. 创建待支付订单,订单保存快照 ID 和所有冻结资源 ID;
  8. 生成支付单,支付单金额来自快照而非客户端参数。

这里存在一个重要的竞态:如果先冻结营销权益,后写价格快照失败,必须有补偿释放;如果先写快照,后冻结失败,快照只能标记为不可用,不能被订单复用。因此订单编排器需要把每一步的状态写入流程表,使用幂等键重复推进,而不是在超时后盲目从第一步重新执行。

13.6.4 端到端时序图

sequenceDiagram
  participant U as 用户端
  participant C as 结算编排
  participant P as 计价中心
  participant M as 营销系统
  participant G as 商品/报价
  participant I as 商品库存
  participant O as 订单系统
  participant Pay as 支付系统

  U->>C: 提交结算(购物车版本、券、积分、幂等键)
  C->>P: CalculateQuote(scene=create_order)
  P->>G: 批量读取基础价和报价有效期
  P->>M: EvaluateBenefits(rule_version)
  M-->>P: 合法权益、组合候选、拒绝原因
  P-->>C: quote + components + request_fingerprint
  C->>M: ReserveBenefits(order_intent_id, idempotency_key)
  M-->>C: reservation_ids
  C->>I: ReserveStock(order_intent_id)
  I-->>C: stock_reservation_id
  C->>P: CreateSnapshot(quote, reservations)
  P-->>C: snapshot_id, payable
  C->>O: CreateOrder(snapshot_id, payable)
  O-->>C: order_id(PENDING_PAY)
  C->>Pay: CreatePayment(order_id, snapshot_id, payable)
  Pay-->>U: 收银台
  Pay-->>O: 支付回调(event_id)
  O->>M: ConfirmBenefits(order_id, reservation_ids)
  O->>I: ConfirmStock(order_id, stock_reservation_id)
  O-->>C: PAID

如果营销冻结成功、库存冻结失败,编排器释放营销冻结;如果库存冻结成功、快照写入失败,释放库存并把营销释放任务写入补偿表;如果支付回调重复,订单和营销确认都以 event_id、订单号和资源 ID 做幂等。支付回调到达时,订单系统不应根据回调金额直接更新订单金额,而应查询订单快照并比较金额和币种。

13.6.5 关键字段与契约检查

字段产生方消费方失败后果保留时间
request_fingerprint结算/计价快照、幂等层同一请求可能生成多个报价至少覆盖报价有效期
quote_id计价结算、订单无法把预览与创单关联交易周期
snapshot_id计价订单、支付、客服金额口径漂移订单生命周期 + 审计周期
rule_version营销计价、快照、审计无法重放活动规则永久或合规要求
reservation_id营销/库存编排器、订单、补偿无法释放或确认资源直到终态后归档
idempotency_key调用方每个有副作用服务重试造成重复冻结/核销覆盖最大重试窗口
event_id事件发布方消费者、对账重复消费改变余额永久流水/去重窗口
trace_id网关全链路客服和研发无法定位一次操作采样保留策略

幂等键应该绑定业务动作,而不是只绑定 HTTP 请求。比如 reserve_coupon:{order_intent_id}、confirm_points:{order_id}、payment_callback:{payment_event_id}。Stripe 的 API 文档把幂等键视为客户端安全重试的契约,并且要求同一个键复用时请求参数保持一致;这比在服务端“遇到重复就返回成功”更严格,也更能避免调用方误复用键。[10]

13.6.6 支付校验与用户确认

支付校验至少比较四项:订单快照应付金额、支付单创建金额、渠道回调金额、币种。对于多支付方式、部分支付和合并支付,要定义快照粒度:是整单快照,还是每个支付子单一份快照。支付成功并不自动代表所有权益都已经消费,订单状态推进和营销确认可以异步,但订单对用户必须有明确的“支付处理中”状态,避免前端重复提交。

价格变化时,用户体验策略应和品类策略绑定:价格下降可以提示并让用户确认新价;价格上涨通常需要显式二次确认;供应商不可预订可以提供替代日期或退款路径;优惠失效要展示失效原因和可选替代优惠。最终价格研究说明,用户对多商品促销可能更关注展示时的最终可支付金额,而不是单独的折扣标记,因此结算页必须展示行价、优惠、费用和最终价,而不是只展示“省了多少”。[20]

13.6.7 跨店铺满减与行级分摊

跨店铺活动最容易把“用户优惠”和“商家承担”混为一谈。以两个店铺共同参加平台满减为例,用户看到一张 40 元券,但平台可能承担 25 元,店铺 A 承担 10 元,店铺 B 承担 5 元。计价系统先在订单级判断用户是否达到门槛,再依据适用行、出资规则和尾差策略把优惠拆到行;订单系统保存订单级组件和行级分摊;结算系统按出资方取数。

行级分摊需要处理四种边界:某个店铺只有一行;某行是赠品或零价;用户删除一行后门槛不再满足;退款只覆盖某个店铺。若删除一行导致活动失效,购物车可以重新试算并要求确认;创单后不能因为其他行取消就偷偷重算已支付行的历史价格,售后应根据活动规则生成差额调整。

13.6.8 秒杀、抢券与普通下单的不同链路

秒杀不是把普通下单接口加一个 activity_id。秒杀入口需要排队、验证码或风控挑战、热点缓存、活动资格预热和独立限流;真正能够进入交易的请求数量要受营销库存、商品库存和用户配额共同控制。前置排队只解决流量削峰,不代表库存已经锁定;用户从队列拿到资格后仍需在有效时间内完成预占。

典型秒杀链路是:活动配置预热到边缘和 Redis;入口按用户/设备/活动限流;资格令牌限制进入预占接口;Redis 脚本原子扣减营销和商品配额;成功请求写入消息队列;订单消费者按幂等键创建待支付订单;支付超时由延迟任务释放资源;数据库与缓存按批次对账。消息队列只承担削峰和异步化,不能掩盖资源预占的最终事实。

秒杀商品和营销券的库存要区分:秒杀商品库存决定能否履约,秒杀券配额决定能否获得让利;商品库存成功、营销预算失败时不能只看一个结果。若业务要求二者必须同时成功,就需要同一个编排状态机和补偿;若允许“商品可买但无优惠”,则应把这种降级写入产品契约,并在页面清楚展示。

13.6.9 售后、部分退款与价格解释

售后系统收到退款请求时,应读取原订单快照,而不是调用当前计价接口。系统按订单行、组件和出资方计算退款:商品本金、可退服务费、积分退还、券是否返还、商家承担的活动金额和平台补贴冲正分别形成退款组件。对于已经履约的赠品、已经使用的服务和不可退供应商资源,退款规则可能不是简单比例。

客服解释接口可以返回一个“价格解释树”:

订单应付 262.00
├── 商品基础价 350.00
│   ├── 店铺 A 活动 -30.00(商家承担)
│   └── 平台券 -20.00(平台承担)
├── 积分抵扣 -50.00(用户积分)
├── 可折扣服务费 +5.00
└── 不可折扣服务费 +7.00

解释树不是把内部规则全部暴露给用户,而是提供客服、财务和用户都能理解的最小充分信息。每个节点引用 component_id、规则版本和适用行,支持进一步查看原始输入。价格发生人工改动时,必须走订单变更流程,生成新快照或差额单,并记录授权人和原因;禁止客服直接修改 order.total_amount。

13.6.10 价格一致性测试矩阵

一致性不仅是“同一个 API 返回同一个数”,还要覆盖跨场景。测试矩阵至少包括:

变更PDP购物车创单支付预期
规则发布新展示可见重新试算新快照使用新版本旧订单不变历史快照稳定
用户升级会员重新评估重新试算使用创单时身份校验快照不影响已创建订单
供应商报价过期显示过期提示刷新报价拒绝旧 quote拒绝过期快照不用缓存猜价
券被其他订单冻结隐藏或标记移除不可用券冻结失败已有快照可继续状态机胜出
活动预算耗尽隐藏活动重新试算不再冻结已冻结按策略确认预算不为负
订单行减少重新报价重新计算门槛售后按原快照退款按组件不重算历史

这张表也可以作为前端、结算、计价、订单和支付联合评审的契约。每一行都应该有一个故障测试和一个客服解释示例,避免只测 happy path。

13.7 一致性、幂等、补偿、对账与资损防控

13.7.1 先划分本地事务,再讨论分布式事务

订单、营销、库存、计价和支付之间通常无法共享一个数据库事务。正确的起点是列出每个服务内部必须原子提交的事实:

本地事务必须一起提交可以异步发布
营销冻结权益状态、冻结流水、幂等记录BenefitReserved
预算冻结预算账本、预占记录、版本号BudgetReserved
价格快照快照正文、输入哈希、规则版本SnapshotCreated
订单创建订单状态、快照引用、资源引用OrderCreated
支付入账支付状态、渠道回执、回调幂等记录PaymentSucceeded
对账修复修复单、差异原因、人工审批ReconciliationClosed

本地事务内先写业务事实和 Outbox 记录,提交后由消息中继发布;不要先发消息再写数据库,也不要在数据库事务里同步等待所有下游。Transactional Outbox 的核心是让数据库更新和消息待发记录在同一个本地事务中提交,消息中继可能重复发布,因此消费端仍必须幂等。[8]

13.7.2 Saga:正向步骤和补偿步骤都要有业务语义

一次下单 Saga 可以表示为:

Evaluate benefits       只读,不补偿
Reserve benefits        compensation: Release benefits
Reserve stock           compensation: Release stock
Create price snapshot   compensation: Invalidate snapshot
Create order            compensation: Cancel pending order
Create payment          compensation: Query final payment state
Confirm payment         forward recovery: Confirm benefits/stock

Saga 不是“失败后把接口按相反顺序调用”。补偿动作的语义可能与正向动作不同:冻结券的补偿是释放券,但已经核销的券不一定能简单恢复;订单取消的补偿可能生成售后单;支付未知不能直接退款,而要先查询渠道最终状态;已经结算的商家补贴需要财务调整单。原始 Sagas 论文把长事务拆成一系列本地事务和补偿步骤,这种思想适合跨多个参与者的长流程,但不提供自动的业务隔离。[5]

Seata 的中文文档也明确说明 Saga 参与者各自提交本地事务,失败后执行由业务开发定义的补偿;Saga 不保证隔离性,极端情况下前序结果可能已经被其他业务改变,导致原样回滚不可行。[24] 因此营销和计价的补偿设计应允许两种路径:能安全反向的就释放/冲正;不能安全反向的就转为向前恢复、人工接管或财务调整,并把原因记录到状态机。

13.7.3 幂等、重试和未知状态

重试的前提不是“网络错误一般可以重试”,而是操作拥有稳定的幂等语义。AWS Builders’ Library 说明,客户端令牌、语义等价响应和服务端持久化的幂等记录可以把复杂重试变成可控契约,但记录幂等令牌和执行副作用必须具有原子性,否则会出现“记住了请求却没有创建资源”或“创建了资源却忘记令牌”的分裂状态。[6]

每个有副作用的接口都要明确:

  • 幂等键来源:谁生成,是否跨重试复用,作用域是用户、订单还是资源;
  • 参数绑定:同一个键的请求体不同是返回冲突还是覆盖;推荐返回参数不一致错误;
  • 结果保存:保存成功结果、失败结果还是处理中状态;
  • 超时查询:调用方不知道服务是否执行时,使用查询接口确认状态,不直接重放;
  • 过期策略:键和去重记录保存到哪个业务终态之后;
  • 并发竞争:两个相同键同时到达时,谁创建进行中记录,其他请求等待还是返回处理中。

事件消费采用至少一次投递时,消费者用 event_id 或业务联合键去重。Kafka 文档区分了至多一次、至少一次和恰好一次语义,并指出“写入 Kafka 的恰好一次”不等于对外部数据库的端到端恰好一次;外部系统仍需要协作或幂等设计。[13] 因此本章不把“Kafka 开启 exactly-once”当作营销账本的最终保证,余额和核销仍以本地事务、唯一约束和对账为准。

13.7.4 失败矩阵

故障点已完成事实首选处理不能做什么最终校验
评估超时无副作用展示降级;创单失败使用未知的旧权益记录降级原因
券冻结成功、库存失败券 FROZEN释放券,重试释放再次冻结同一券券流水与订单意图对账
库存成功、快照失败库存预占释放库存、失效 quote把 quote 当快照复用预占记录无悬挂
订单成功、支付创建超时订单待支付查询支付;未创建则重试直接再建支付单支付单唯一键
支付回调重复已有支付成功返回幂等结果重复确认权益event_id/订单状态
支付状态未知渠道可能成功查询、延迟补偿、人工阈值立即释放所有资源渠道对账
消费事件重复业务已处理去重后确认消费再扣积分/预算消费者处理流水
部分退款部分组件已生效按组件/行分摊冲正重新按当前价计算售后单与原快照
规则误发布新版本生效熔断发布、切旧版本、冻结高风险活动修改历史订单快照发布审计和价格差异

13.7.5 Outbox、事件和消费者

Outbox 表至少包含 event_id、aggregate_type、aggregate_id、event_type、payload、version、created_at、published_at 和重试字段。事件载荷不应只带“订单号”,而应携带消费者判断幂等和更新状态所需的业务版本。消费者处理时在本地事务中完成:检查去重表、校验聚合版本、更新本地事实、写入下一跳 Outbox,再标记事件已处理。

CREATE TABLE event_consume_log (
    consumer_name VARCHAR(80) NOT NULL,
    event_id      VARCHAR(80) NOT NULL,
    status        VARCHAR(20) NOT NULL,
    processed_at  TIMESTAMP NULL,
    PRIMARY KEY (consumer_name, event_id)
);

如果事件处理和去重记录不在同一个本地事务里,服务在“业务更新成功、去重记录失败”后重启,就会重复执行。唯一键只能防止完全相同的事件重复,但不能阻止两个不同事件错误地修改同一个账户,因此账户更新还需要版本号、余额不变量和顺序约束。

幂等消费者的实现有三种常见选择。第一,在消费者数据库中建立 processed_event 唯一表,业务更新和插入去重记录放在一个本地事务;适合权益、账户和订单这类关系数据。第二,把事件版本写入聚合,只有更高版本才能更新;适合同一聚合有明确顺序的状态机。第三,让业务更新本身具备幂等性质,例如按业务键覆盖投影;适合可重建读模型。实际系统可以组合使用,但不能把“消息队列不会重复”作为前提。

microservices.io 对幂等消费者模式的定义强调,消息处理失败后的重新投递是正常情况,消费者需要记录已处理消息或设计可重复执行的更新。[9] 本章的进一步约束是:去重记录的保存期限应覆盖消息保留期、最大补偿期和人工重放期;如果删除过早,旧事件重放可能再次扣减积分。对于永久流水,最好让业务联合键和金额不变量同时防止重复,即使去重表被清理也不会产生第二笔有效消费。

事件顺序也不能被抽象成“同一 Topic 全局有序”。同一订单或同一权益账户需要按聚合键分区,消费者对版本跳跃进行检测;收到版本 8 而本地只有版本 6 时,不能直接应用,应该等待版本 7、重新读取快照或把事件放入异常队列。跨聚合没有天然的全局顺序,流程状态应由编排器或业务版本表达,而不是依赖消息到达顺序猜测。

RocketMQ 事务消息提供半事务消息、二次确认和回查机制,可以让本地事务与消息投递之间保持最终一致,但官方文档也明确指出它不保证消息消费结果与上游事务完全同步,消费端仍需重试和幂等。[25] 所以事务消息适合“支付成功后异步发放积分”“订单成功后异步清理购物车”等场景,不适合替代订单、营销和支付之间所有同步的金额校验。

13.7.6 对账是系统的一部分,不是运营补丁

最终一致系统必须有独立对账,否则“理论上会补偿”无法转化为“实际已经收敛”。对账不应只比较总数,而要比较维度:订单、订单行、组件、券、积分账户、预算桶、店铺、出资方、币种、事件和时间窗口。

推荐的对账任务包括:

  1. 订单—快照对账:订单金额、币种、组件总额与快照一致;
  2. 快照—营销对账:快照引用的券/积分/预算是否存在对应冻结和确认流水;
  3. 营销—预算对账:用户权益流水累计值与平台、商家预算桶相符;
  4. 订单—支付对账:支付单、渠道回执、订单状态和退款状态一致;
  5. 事件—消费对账:Outbox 已发布事件是否有消费者处理结果和重试记录;
  6. 分摊—结算对账:平台、商家、渠道的优惠承担与清算单一致。

差异处理要分级:可自动修复的悬挂冻结、重复事件和缓存计数走补偿;金额或出资方不一致先冻结结算,再生成差异单;历史订单不直接 UPDATE 金额字段,而是追加冲正、退款或财务调整记录。每次修复都记录“原状态、目标状态、证据、操作者、工具版本和审批人”。

13.7.7 资损防控的纵深防御

资损防控不是一个“最大折扣”判断,而是多道护栏:

护栏检查内容失败动作
配置校验门槛、互斥、折扣上限、时间和预算阻止发布
规则仿真历史样本、边界样本、新旧 diff阻止灰度
运行时安全检查非负价、范围、分摊、币种和版本拒绝快照
预算与配额用户、活动、店铺、平台上限失败或降级
风控账户族群、设备、IP、频率、异常行为拒绝权益/排队
灰度空跑新旧引擎金额、组件和延迟差异自动告警/回滚
对账订单、支付、权益、预算和结算补偿/人工接管

发布错误和运行时错误要分开处理。发布错误应尽快停止新版本、保留旧快照并禁止继续扩大损失;运行时错误可能是下游超时或数据漂移,应按影响范围降级或重试。切回旧引擎不等于重算历史订单,历史快照始终是客服和财务解释的基准。

13.7.8 Saga 编排器的状态模型

编排器本身也是一个需要持久化状态的业务对象,不能只存在于一次 HTTP 请求的内存里。每个流程实例保存 process_id、业务主键、当前步骤、整体状态、步骤输入摘要、步骤输出引用、重试次数、下一次执行时间和最后错误。步骤状态至少区分 PENDING、RUNNING、SUCCEEDED、FAILED、COMPENSATING、COMPENSATED、UNKNOWN 和 MANUAL。

PENDING -> RUNNING -> SUCCEEDED
                     └-> FAILED -> RETRYING -> RUNNING
                     └-> UNKNOWN -> QUERYING -> SUCCEEDED/FAILED/MANUAL
FAILED -> COMPENSATING -> COMPENSATED
                         └-> MANUAL

UNKNOWN 很重要。网络超时不代表下游没有执行;如果把未知直接当失败并重新冻结券,可能发生重复副作用。编排器遇到未知状态时先调用查询接口或等待回查,只有确认下游未执行后才重试。补偿也可能未知,因此补偿步骤同样需要查询、幂等和人工接管。

13.7.9 补偿任务与人工接管

补偿表可采用以下字段:task_id、process_id、step、action、business_key、payload_hash、attempts、next_retry_at、status、last_error、owner 和 manual_reason。重试采用指数退避加随机抖动,并设置最大次数和最大存活时间;金额相关任务不能因为达到最大次数就静默丢弃,而应进入人工队列。

人工接管不是“开发直接改数据库”。操作台应展示原订单、快照、权益流水、支付状态、事件时间线、重试历史和可执行动作。人工动作调用与自动补偿相同的应用服务,使用新的人工幂等键,并记录操作者、审批人和证据。高风险动作如冲正平台补贴、退回已过期券和修改订单应要求双人复核。

13.7.10 对账查询的粒度与性能

对账任务往往扫描大量历史记录,不能直接把交易主库打满。可以按日、店铺、出资方和币种切分范围,使用只读副本或离线明细表;对账结果保存摘要和差异明细,修复任务只处理差异。总额对账用于发现大方向问题,明细对账用于定位具体订单。

SELECT merchant_id, currency,
       SUM(component_amount) AS component_total,
       SUM(settlement_amount) AS settlement_total,
       COUNT(*) AS component_count
FROM settlement_component
WHERE settled_at >= :from AND settled_at < :to
GROUP BY merchant_id, currency;

金额对账要注意汇率、税费、舍入和结算时间窗口。支付渠道按自然日清算,订单按业务日统计,不能直接把两个日期字段相减就认定差异。所有汇总都能回溯到订单行和组件,无法回溯的汇总表只能作为监控指标,不能作为修复依据。

13.7.11 安全、隐私与审计保留

价格和营销日志包含用户、店铺、活动和支付关联信息。审计需要能够重建业务,但不代表可以无限保存完整个人信息。日志字段应分类:业务关联键脱敏但可关联;用户画像只记录分群版本而不记录完整特征;支付信息只保存支付单号和金额,不保存敏感卡信息;规则快照保留发布人和审批信息。数据保留期限由合规和财务要求决定,过期后删除或匿名化,同时保留不能反推个人的聚合统计。

权限也要按动作拆分:运营可以创建草稿但不能直接生效;财务可以查看出资方和结算组件但不能修改营销规则;客服可以查询解释树和触发标准补偿,但不能直接冲正金额;研发可以回放计算但不能绕过应用服务改库。审计日志本身必须防篡改,至少通过追加写、哈希链或受控存储保证“谁在什么时候看过或改变过什么”可追踪。

中文分布式系统实践常把 Saga、本地消息和最终一致性放在业务流程中共同设计,而不是认为选择了某个框架就自动获得一致性;《凤凰架构》对分布式事务、消息和可靠性边界的讨论也强调了业务补偿与技术机制的结合。[28] 阿里云对 TCC、FMT 和 Saga 参与者接入模式的说明则提醒我们,参与者能力、隔离要求和接入成本不同,不能用单一模式覆盖所有交易步骤。[27]

13.8 高并发、性能、可观测性与演进

13.8.1 容量规划要按路径拆分

计价系统至少有四种负载:展示读、权益评估、资源预占和交易写。把它们全部用一个 QPS 数字描述,会掩盖真正的瓶颈。展示路径可能是创单路径的十倍,但它可以使用缓存和较弱一致性;冻结路径 QPS 较低,却需要关系库唯一约束、Redis 原子操作和补偿;创单路径要等待多个依赖并写快照,尾延迟最重要。

容量模型可以从业务动作开始:

展示请求数 = DAU × 每用户浏览页数 × 页面并发比例
计价依赖调用数 = 请求数 × 平均行数 × 每行依赖数
冻结写入数 = 创单数 × (券/积分/预算/库存资源数)
事件数 = 成功交易数 × 领域事件类型数 × 消费者数

大促前分别压测“仅展示”“券领取”“多优惠试算”“营销预占”“创单全链路”“支付回调”和“补偿积压”。压测报告要记录请求分布、缓存命中、下游错误、连接池、线程/goroutine、数据库锁等待和消息积压,而不能只记录平均 QPS。一个平均 50 ms、P99 2 s 的结算接口对用户仍然是慢的。

13.8.2 缓存、多级读取与失效

缓存适合保存稳定、可重建、允许短暂过期的事实:活动元数据、规则索引、商品基础价、匿名展示价和用户资格视图。缓存不适合成为支付金额、券核销和预算账本的唯一事实来源。

可以使用三层读取:

  1. L1 本地缓存:小而热的规则元数据,TTL 短,发布版本变化时主动失效;
  2. L2 Redis:活动索引、用户资格、热点展示价和营销配额预扣;
  3. L3 关系库/报价源:权威配置、流水、快照和供应商报价。

缓存 Key 应包含租户/店铺、商品或活动、用户分群、渠道、币种和规则版本,避免把个性化价格错误地缓存为公共价。缓存失效不能只依赖 TTL:规则发布写入版本事件,消费者按版本删除或刷新;消费者失败后由定时任务扫描版本差异。空值缓存、布隆过滤器和入口限流用于防止缓存穿透,但不能把不存在的商品当成永久不存在。

Redis 脚本适合短小的条件更新;长脚本会阻塞单线程执行,跨槽位操作也不适合直接放进集群脚本。Redis 官方文档说明脚本执行期间服务器活动被阻塞,并建议脚本快速完成;因此“把复杂规则引擎搬进 Lua”不是性能优化,而是把 CPU 和可维护性风险转移到缓存节点。[12]

13.8.3 并发控制、热点拆分和背压

营销热点通常集中在活动 ID、券模板 ID、爆款 SKU 和大用户群。单个 Redis Key 的原子性不能无限扩展吞吐,可把活动配额拆成多个 shard:请求先依据用户或请求哈希选择分片,预扣成功后异步汇总;强一致预算需要按预算桶或账户维度保留单一权威分片,不能为了吞吐把同一预算随意复制。

计价请求要限制三件事:单次行数、单次活动评估数和单实例并发数。下游调用使用独立超时和并行上限,避免“购物车行数 × 活动数量 × 下游服务数”形成扇出爆炸。对重复的同一请求可以合并请求,但请求合并必须绑定用户、场景和输入指纹,不能让不同用户共享个性化结果。

限流策略分为入口、用户、活动、资源和下游五层。Sentinel 的设计将流量控制、熔断降级、热点参数保护和系统自适应保护视为流量治理的不同维度,并支持按 QPS、线程数、调用来源和热点参数配置规则。[26] 在本章场景中,入口限流保护服务,活动限流保护热点规则,用户限流防止单账户刷券,下游限流避免报价和用户服务被营销流量拖垮。

背压需要配合产品语义:展示请求可以丢弃非核心营销标签;发券请求可以排队并返回处理中;创单请求不能无限排队,应在预算耗尽或超过截止时间后快速失败;补偿任务使用独立队列,不能与用户交易共享线程池。Google SRE 对过载的经验是,系统应优先提供成本更低的降级结果,过载严重时还需要主动削减流量;重试必须避免把已经过载的依赖继续推向失败。[15]

13.8.4 降级边界

降级不是“出错就按原价下单”。建议定义以下降级等级:

等级可用能力适用场景交易是否允许
L0完整基础价、营销、费用和快照正常允许
L1使用已验证的基础价缓存,隐藏非核心标签PDP/列表不直接创单
L2跳过低优先级推荐券,保留已明确选择的可验证权益购物车提示重新确认
L3营销服务不可用,拒绝新冻结,允许查询已有快照订单查询/售后仅已有快照支付
L4计价不可用创单/支付拒绝,避免未知金额
L5预算或价格安全检查失败所有场景拒绝并告警

基础价、币种、库存和支付金额不能在没有事实依据时默认成零;促销标签可以隐藏,交易优惠不能“猜”。对于已冻结的订单,营销服务短暂不可用时,订单应根据已有快照继续查询和确认;对于尚未冻结的结算请求,宁可提示稍后重试,也不应把页面的估算值当成最终价。

13.8.5 可观测性与故障定位

可观测性字段要围绕业务动作建立:trace_id、span_id、request_fingerprint、quote_id、snapshot_id、order_id、reservation_id、rule_version、engine_version、event_id 和 idempotency_key。日志中不打印完整用户隐私和支付敏感信息,但需要保留可关联的脱敏键。OpenTelemetry 将 trace、metric 和 log 作为互相补充的遥测信号,强调通过上下文传播理解一次请求跨服务的完整路径。[16]

核心指标分为四类:

类型指标示例作用
用户体验PDP/P99、结算/P99、价格变化确认率判断用户能否完成操作
正确性组件缺失率、金额 diff、分摊不闭合、快照校验失败发现潜在资损
资源规则命中、券冻结成功率、预算剩余、热点分片负载判断营销供给是否健康
治理补偿积压、Outbox 延迟、对账差异、人工接管量判断最终一致性是否收敛

告警要以用户影响和 SLO 为中心。比如“缓存命中率下降”不一定是事故,如果回源仍在预算内;“创单成功但快照缺失”即使数量很少也属于高优先级;“营销补偿积压”要结合冻结金额和最老任务年龄,而不能只看任务条数。

13.8.6 灰度、空跑与迁移

从分散的旧计价逻辑迁移到统一引擎时,采用三阶段:

空跑阶段:新旧逻辑并行执行,只使用旧逻辑结果;比较最终金额、每层组件、优惠命中、出资方、分摊和延迟,差异按场景、品类、用户分群和规则版本聚合。不要只比较总金额,因为“总额相同但组件不同”会在退款时暴露。

灰度阶段:按品类、店铺、渠道、用户比例逐步切流。每一档观察价格差异、创单失败、支付拒绝、退款差异、P99 和补偿积压;配置开关要能把新流量切回旧引擎,而不删除新快照和审计证据。

清理阶段:稳定观察一个完整业务周期后再下线旧逻辑。保留历史快照的重放能力,清理的是代码路径而不是数据。迁移指标应写入 ADR 和发布单,达到阈值才推进下一档。

13.8.7 测试与发布清单

测试分为四层:

  • 领域单测:金额运算、互斥组合、门槛边界、币种精度、分摊尾差、状态机非法迁移;
  • 契约测试:商品、营销、订单、支付的请求/响应、版本和错误码;
  • 故障注入:依赖超时、重复回调、消息重复、Redis 故障、数据库死锁、供应商报价过期;
  • 生产演练:预算误配置、规则回滚、补偿积压、对账差异、支付未知和人工接管。

发布前至少逐项确认:

  1. 每个交易场景的价格层是否由产品和财务共同签字;
  2. 快照是否包含输入指纹、组件、分摊、规则和报价有效期;
  3. 所有有副作用接口是否绑定稳定幂等键;
  4. 券、积分、预算和库存的冻结/确认/释放是否可重试;
  5. 新旧引擎空跑是否比较组件而不仅是总额;
  6. 预算、平台/商家出资和结算分摊是否能对账;
  7. 降级是否明确“不允许猜金额”的边界;
  8. snapshot_id 能否被客服、财务和研发共同查询;
  9. 规则发布是否有仿真、审批、灰度、回滚和审计;
  10. 参考资料是否全部被正文引用且正文每个关键判断都有相邻引用。

13.8.8 部署拓扑与故障域

展示计价、交易计价、营销写路径和补偿任务应拥有不同的扩缩容和故障域。展示服务可以部署更多无状态实例并依赖缓存;交易计价需要与快照库保持稳定连接;营销预占需要保护关系库和 Redis 的写入;补偿和对账使用独立消费者组,避免积压时抢占交易线程池。它们可以共享代码库,但不应共享一个无法区分优先级的连接池。

多可用区部署时,活动配置和规则版本应通过可靠发布传播;热点配额的权威分片要明确所在故障域和故障转移语义。跨地域场景不能简单把同一个预算复制到两个地域再相加,否则网络分区期间可能超预算。可以选择单地域权威写入、按地域预分配预算、或接受最终对账后冲正;选择哪一种取决于预算风险和可用性目标。

部署检查还包括:数据库连接池上限、Redis 脚本最长执行时间、消息生产/消费延迟、规则缓存版本、下游超时总预算、熔断恢复时间、快照库容量、审计存储成本和备份恢复时间。大促前应做容量预热和故障注入,验证缓存预热失败时是否会把冷启动流量直接打到数据库。

13.8.9 成本与用户体验的共同优化

让所有请求都执行完整规则并不一定提高转化率。列表页只需要价格范围和公开活动标签;PDP 需要当前用户可见的最优少量权益;购物车需要用户主动选择后的精确试算;创单才需要完整冻结和分摊。按场景裁剪计算深度,可以减少下游调用和缓存空间,也能让用户更快看到可行动的结果。

营销预算不能只看折扣金额,还要把计算、缓存、消息、人工客服和退款成本纳入活动评估。某个活动的转化提高,但补偿量、支付拒绝和客服解释成本同步上升,未必是成功。活动看板应同时呈现命中率、领取率、冻结率、核销率、订单增量、退款率、平台/商家出资、异常率和每个成功订单的技术成本。

13.8.10 未来演进路线

演进顺序应由确定性和治理能力驱动,而不是直接引入 AI 定价:

  1. 统一事实:先把价格组件、权益流水、快照和对账做完整;
  2. 统一计算:将重复的优惠和费用逻辑收敛到可测试的层;
  3. 安全迁移:通过空跑、灰度和差异监控替换旧逻辑;
  4. 数据闭环:沉淀规则命中、价格变化、退款和用户反馈;
  5. 策略实验:在明确的预算、最低价、公平和审计约束内做 A/B 测试;
  6. 模型辅助:让预测模型提出价格或优惠建议,由确定性规则、安全检查和人工审批控制落地。

实时个性化、自动调价和跨境汇率都可以接入基础价或营销决策层,但不能绕过快照、最低价、出资方、用户提示和回滚。越是自动化的决策,越需要保存输入特征版本、模型版本、决策原因和重放能力。

13.8.11 从指标到容量决策

监控指标只有和决策绑定才有价值。可以为主要指标建立“现象—判断—动作”表:

现象可能原因先看什么动作
结算 P99 上升下游扇出、连接池、规则搜索膨胀span 分解、行数、规则数限制并发、缩小候选、扩容依赖
优惠命中率突然下降规则版本、缓存失效、圈品同步滞后版本、拒绝原因、商品索引暂停发布、刷新投影、回滚规则
冻结成功率下降预算耗尽、热点 Key、数据库锁等待资源维度、锁等待、剩余预算分片、排队、关闭非核心活动
支付金额校验失败快照过期、客户端篡改、重复支付snapshot、币种、请求指纹拒绝并提示,不自动改金额
补偿积压下游故障、重试风暴、任务锁最老任务、金额、错误类型隔离队列、限速、人工接管
对账差异增加事件丢失、重复消费、分摊算法变更event_id、版本、组件停止结算、生成差异单

容量规划也应以业务资源为单位。例如预算服务的容量不是“能处理多少 QPS”,而是“在最坏重试、退款和补偿情况下,能否保持预算不为负”;券服务不是“每秒写多少行”,而是“高峰期冻结、释放和过期任务是否会争用同一个用户或活动锁”。把指标绑定到资源和不变量,比单纯追求机器利用率更能发现风险。

13.8.12 回放、仿真和属性测试

历史快照回放是计价系统的核心测试能力。给定原始输入、规则版本、引擎版本和时间,回放应产生相同的金额、组件和分摊;如果因为规则或引擎升级产生差异,应以 diff 报告呈现,而不是直接覆盖历史结果。回放数据要脱敏,且对外部供应商报价使用保存的响应摘要或稳定测试替身。

除具体案例外,还应写属性测试:

  • 任何合法优惠组合的总优惠不超过适用基数;
  • 所有行的分摊总和等于订单级组件;
  • 追加一个不适用商品不会改变原有行的价格;
  • 重新执行同一冻结/确认/释放命令不会改变终态之外的余额;
  • 只改变规则版本,历史快照的结果不变;
  • 交换请求中行的排列顺序,不改变规范化后的结果;
  • 所有币种运算都不发生隐式转换;
  • 任何降级结果都带有可观察的 partial 或错误原因。

这些属性能覆盖大量边界组合,比只写几个“100 元减 20 元”的例子更能发现价格引擎的结构性错误。

13.8.13 安全发布与紧急开关

营销系统的紧急开关至少要分为规则级、活动级、品类级和全局级。活动级开关可以下线某个错误活动;品类级开关可以暂时禁用动态报价或某种费用;规则级开关可以撤销单条互斥规则;全局级开关只在确认风险扩大时使用。开关变化写入审计并传播版本,避免一个实例已经关闭活动、另一个实例仍从本地缓存命中。

紧急回滚不能只改配置中心。先阻止新的规则命中,再处理已经冻结和已经支付的订单:未支付冻结按规则释放或确认;已支付订单保持快照;超预算部分进入差异单;已经发放但未使用的权益由产品决定是否继续有效。回滚动作需要一个明确的操作顺序和验证结果,否则“回滚成功”可能只意味着页面不再展示活动,后台预算仍在消耗。

13.8.14 团队协作与文档边界

营销产品、计价研发、订单研发、支付研发、财务和客服应共同维护价格口径表,但每个领域只维护自己的事实源。公开书稿描述通用模型和可迁移方法;项目内部规则、真实预算、用户数据和供应商合同不能复制进公开文章。设计文档记录 ADR、版本、失败路径和验证指标,代码记录实现和测试,运行手册记录告警和人工操作,三者互相链接而不重复维护同一份长文。

当团队争论“优惠由谁计算”时,可以把讨论拆成三个问题:谁判断资格,谁产生金额,谁承担资金;当争论“要不要上消息队列”时,先问这个动作是否需要削峰、异步解耦、可靠发布或可重放;当争论“要不要加缓存”时,先问数据是否可重建、允许多长时间陈旧、失效后谁是权威。这样可以把组件偏好还原成业务约束和可验证的设计决策。

13.8.15 运行手册中的关键场景

运行手册不应只写“服务异常时重启 Pod”。营销和计价事故通常需要先保护资金和用户,再恢复吞吐。建议为以下场景准备可执行步骤:

规则误发布:暂停规则继续生效,确认影响的活动、商品、用户和时间窗口;阻断新冻结,保存当前版本和 diff;把旧版本切回灰度入口;扫描已经生成的快照和已支付订单;对超预算和错价订单生成差异单。不能直接删除活动记录,因为删除会让历史快照失去可解释性。

预算快速消耗:先确认是正常转化、规则配置错误还是脚本攻击;把预算桶切换为只读或快速失败;保留已冻结订单的确认路径;暂停非核心推荐流量;从平台、商家和渠道维度核对消耗斜率。预算告警应同时包含金额、订单数、用户数和每用户消耗,避免单个大额订单掩盖群体性异常。

消息积压:根据事件类型区分支付确认、释放冻结、分析埋点和营销推荐;优先处理会影响用户资金和资源占用的事件;降低自动重试频率,避免失败任务形成重试风暴;把过期活动事件丢入隔离队列并保留证据。扩容消费者前先检查数据库锁等待和下游容量,否则只会把积压压力向后推。

对账差异:停止自动修复高风险金额差异,保留原始记录和查询时间;按订单、组件、出资方、币种和事件版本分类;可确定的重复事件由幂等任务处理,无法确定的未知支付交给人工;修复后再次跑同一对账区间,直到差异从“待处理”变为“已解释/已修复/已批准保留”。

故障复盘要回答“哪一个不变量没有被挡住”:是规则发布没有校验,冻结与快照没有关联,支付未知被误判失败,事件消费者没有幂等,还是对账没有覆盖某个出资方。只写“增加监控”通常不能降低下一次事故概率;应把改进项落到状态机、契约、测试、开关和责任人,并为重新评估设定日期。

13.8.16 端到端验收案例

一个可以交给研发、QA、财务和客服共同执行的验收案例如下:用户选择两个店铺的商品,使用一张平台券和一张店铺券,抵扣积分,订单在创建后支付超时,随后收到支付成功回调;回调重复一次,消费者重启一次,订单部分退款一行商品。

验收首先检查正常路径:优惠组合符合规则,价格组件和平台/商家出资闭合,快照能重放,冻结资源与订单关联。然后在每个边界注入故障:营销冻结响应超时、库存预占失败、快照写入失败、支付创建响应丢失、支付回调重复、事件重复、积分账户暂时不可用、供应商报价过期。每个故障都要得到明确状态,而不是只验证 HTTP 返回码。

财务检查订单、组件、退款和结算单的金额闭合;客服使用 snapshot_id 查询解释树;QA 检查重试次数、幂等键冲突和补偿终态;运维检查 trace、指标、告警和人工队列。只有四类角色都能从同一份快照得到一致结论,才说明营销与计价的边界真正落地。

13.8.17 评审时的提问顺序

面对一个新的促销或费用需求,可以按固定顺序评审,而不是从“需要新增哪张表”开始。第一问是业务事实:用户获得的是价格变化、资格变化、赠品、积分还是服务承诺;第二问是权威来源:商品、营销、计价、订单、支付和财务分别谁负责;第三问是生命周期:何时创建、何时冻结、何时确认、何时释放、何时过期;第四问是金额语义:基数、顺序、币种、舍入、分摊和出资方;第五问是失败处理:超时、重复、未知、取消、退款和人工接管;第六问是指标:用户体验、正确性、资源、成本和对账如何验证。

如果一项需求无法回答“取消后怎么办”或“部分退款如何解释”,说明它还停留在页面文案层面,不能直接进入交易链路。若只能回答“正常流程很简单”,却没有提供重复回调、配置回滚和预算耗尽路径,说明设计尚未覆盖生产约束。系统设计的成熟度,往往体现为能否清楚描述失败后的状态,而不是能否画出一张漂亮的组件图。

13.8.18 与相邻章节的协作

营销与计价不是孤立章节。库存章节负责商品供给和物理/虚拟资源的可承诺数量,本章只负责营销权益和价格组件;购物车与结算章节负责用户选择、地址和提交编排,本章提供报价、冻结和快照契约;订单章节负责订单状态和售后,本章提供组件、分摊和释放/确认接口;支付章节负责支付单、渠道回调和清算,本章提供金额校验和快照引用。

跨章节阅读时,要用同一条时间轴标记四个时刻:用户看到的报价、资源被冻结、订单被创建、资金被确认。每个时刻都写下可变字段、不可变字段、权威服务和失败后的下一步。这样可以发现一些跨章节的隐患:库存只预占没有释放接口,订单保存了总额却没有快照,支付只验证金额却没有币种,营销确认没有关联订单终态,或购物车把过期 quote 当成可支付事实。

13.8.19 章节级完成定义

本章完成并不等于“文件超过指定字数”。完成定义包括:核心概念可由产品、研发、财务和客服共同复述;每个关键组件有状态机和权威来源;至少一个案例走完正常、异常、重试、补偿和退款;每个关键选择有获得、牺牲、风险和重评条件;正文引用直接支持相邻判断;参考条目全部被使用;中文和英文来源都能从原始 URL 核验;构建、链接、格式和目录均通过检查。

13.8.20 最小可行落地顺序

如果团队不能一次建设全部能力,可以按风险从高到低分阶段落地。第一阶段只支持固定价、单店铺、单券和整单退款,但必须先完成金额整数化、价格组件、快照、幂等和支付校验;第二阶段加入多店铺、平台/商家分摊、积分、预算和 Outbox;第三阶段加入复杂组合、供应商报价、部分退款、Saga 补偿和对账;第四阶段再做热点分片、灰度空跑、个性化权益和策略实验。每一阶段都要保持旧快照可解释,不能为了缩短第一阶段而把总价字段写成唯一事实。

这种顺序把最难逆转的风险——金额错误、重复扣减和历史不可解释——放在最前面,把性能和智能化放在事实模型稳定之后。对业务方而言,先得到一条可核验的交易链路;对研发方而言,后续增加活动和品类是添加规则、组件或适配器,而不是再次复制一套订单逻辑。

验收时还要区分“业务可用”和“技术可用”。业务可用要求用户能够看到条件、完成确认、收到正确权益并在售后得到解释;技术可用要求服务能在目标 SLO 内响应、错误可以重试、消息可以重放、快照可以查询。只有技术指标达标而用户被错误扣款,不能称为成功;只有页面看起来正确但后台预算和流水无法对账,也不能称为完成。

最后,所有数量级、延迟和收益数字都应标注来源或假设;所有规则、金额和状态都应能从快照或流水找到证据;所有跨服务动作都应有明确的超时、重试、补偿和对账路径。这三条是本章从方案走向生产的最低门槛。

当这些门槛被落实后,营销与计价才不再是“活动期间临时拼接的优惠逻辑”,而成为交易平台中能够演进、解释和恢复的基础能力。

它最终保护的也不只是一个金额字段,而是用户对价格的预期、商家对结算的信任、平台对预算的控制,以及团队面对故障时可以依靠的证据链。

因此,评审这类系统时不要只问“能不能算出价格”,还要问“谁能证明这个价格为何成立、谁能在失败后恢复、谁能在退款时按原规则解释、谁能在下一次活动中安全复用”。

这也是把营销和计价合并到同一章的原因:它们可以由不同团队实现,但必须在同一条交易时间轴上共同承担价格事实。

最终交付的不是一次性的活动方案,而是一套能够被复盘、重放、补偿和演进的交易基础设施。

更具体地说,系统的成熟度应体现在它能否把一次价格决策还原成一条完整证据链:请求来自哪个用户和渠道,命中了哪个规则版本,消耗了哪项权益,金额由哪些组件组成,平台与商家分别承担多少,哪个事件推进了状态,以及失败后由谁负责补偿。只有这些问题都能在不依赖个人记忆的情况下回答,营销能力才真正从运营脚本升级为可治理的基础设施。后续无论增加新商品、新渠道还是新的结算方,都应沿用同一组事实、状态和引用关系,通过回放与对账证明变化没有破坏旧交易。

13.9 方法论总结与参考资料

13.9.1 可迁移的判断句

本章可以浓缩为以下判断句:

  1. 先定义事实,再划分服务:商品价、营销权益、价格快照和交易状态不能靠字段名称混为一谈。
  2. 营销决定资格,计价决定金额:营销输出可使用的权益及其限制,计价输出可审计的价格组件和应付金额。
  3. 展示可以近似,创单必须确定:页面报价可以短暂过期,订单和支付必须引用同一份受保护的快照。
  4. 有限权益就是库存:券、积分、预算和活动配额都需要冻结、确认、释放、过期和对账。
  5. 重试必须建立在幂等契约上:没有稳定幂等键和未知状态查询,重试只是在放大副作用。
  6. 最终一致性必须可观测、可补偿、可对账:Outbox、Saga 和事件总线不是一致性的终点。
  7. 组件比总额更接近真实业务:没有组件、分摊和出资方,优惠、退款和财务解释都只能靠猜。
  8. 降级的边界由业务风险决定:可以隐藏推荐标签,不能凭空生成基础价、支付金额或预算余额。
  9. 每个关键方案都要写获得与牺牲:统一引擎获得复用和一致性,也引入版本协作、快照存储和补偿复杂度。
  10. 把参考源放到论证旁边:引用不是文末装饰,而是让读者知道哪些是原理、哪些是官方语义、哪些是本章的场景推导。

13.9.2 参考资料

[1] Eric Evans, “DDD Reference: Definitions and Pattern Summaries”, Domain Language, 2014, https://www.domainlanguage.com/ddd/reference/

[2] Vaughn Vernon, Implementing Domain-Driven Design, Addison-Wesley Professional / Pearson, 2013, https://www.pearson.com/en-us/subject-catalog/p/implementing-domain-driven-design/P200000009616/9780321834577

[3] Martin Fowler, Patterns of Enterprise Application Architecture, Addison-Wesley, 2002, https://martinfowler.com/books/eaa.html

[4] Martin Kleppmann, Designing Data-Intensive Applications, O’Reilly Media, 2017, https://martin.kleppmann.com/2017/03/27/designing-data-intensive-applications.html

[5] Hector Garcia-Molina and Kenneth Salem, “Sagas”, Proceedings of the 1987 ACM SIGMOD International Conference on Management of Data, 1987, https://doi.org/10.1145/38713.38742

[6] Malcolm Featonby, “Making retries safe with idempotent APIs”, AWS Builders’ Library, 2021, https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/

[7] Chris Richardson, “Pattern: Saga”, microservices.io, 2017, https://microservices.io/patterns/data/saga.html

[8] Chris Richardson, “Pattern: Transactional outbox”, microservices.io, 2017, https://microservices.io/patterns/data/transactional-outbox

[9] Chris Richardson, “Pattern: Idempotent Consumer”, microservices.io, 2017, https://microservices.io/patterns/communication-style/idempotent-consumer.html

[10] Stripe, “Idempotent requests”, Stripe API Reference, 2025, https://docs.stripe.com/api/idempotent_requests

[11] Oracle, “Transaction Isolation Levels”, MySQL 8.0 Reference Manual, 2024, https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html

[12] Redis Ltd., “Scripting with Lua”, Redis Documentation, 2025, https://redis.io/docs/latest/develop/programmability/eval-intro/

[13] Apache Software Foundation, “Message Delivery Semantics”, Apache Kafka Documentation, 2026, https://kafka.apache.org/40/design/design/

[14] Chris Jones, John Wilkes, Niall Murphy and Cody Smith, “Service Level Objectives”, Google SRE Book, 2016, https://sre.google/sre-book/service-level-objectives/

[15] Alejandro Forero Cuervo, “Handling Overload”, Google SRE Book, 2016, https://sre.google/sre-book/handling-overload/

[16] OpenTelemetry Authors, “Observability primer”, OpenTelemetry Documentation, 2025, https://opentelemetry.io/docs/concepts/observability-primer/

[17] Martin Fowler, “Architecture Decision Record”, martinfowler.com, 2026, https://martinfowler.com/bliki/ArchitectureDecisionRecord.html

[18] Shangwen Yi, David J. Hardisty, Dale W. Griffin and Thomas Allard, “Promotion Architecture: A Deal Fairness Model of Restricted Price Promotions”, Journal of Consumer Research, 2026, https://academic.oup.com/jcr/article/53/2/369/8362278

[19] Nathan Kallus and Angela Zhou, “Fairness, Welfare, and Equity in Personalized Pricing”, arXiv, 2020, https://arxiv.org/abs/2012.11066

[20] Ke Zhang, “Final Price Neglect in Multi-Product Promotions: How Non-Integrated Price Reductions Promote Higher-Priced Products”, Journal of Consumer Research, 2024, https://academic.oup.com/jcr/article/50/6/1097/7220485

[21] 文彬、子维,《领域驱动设计在互联网业务开发中的实践》,美团技术团队,2017,https://tech.meituan.com/2017/12/22/ddd-in-practice.html

[22] 美团外卖营销技术团队,《设计模式在外卖营销业务中的实践》,美团技术团队,2020,https://tech.meituan.com/2020/03/19/Software-design-pattern-practice-in-marketing.html

[23] lvsong,《DDD在大众点评交易系统演进中的应用》,美团技术团队,2024,https://tech.meituan.com/2024/05/09/DDD-Practice-Trading-System.html

[24] Apache Seata,《Seata Saga 模式》,Apache Seata 文档,2025,https://seata.apache.org/zh-cn/docs/user/mode/saga/

[25] Apache RocketMQ,《事务消息》,Apache RocketMQ 文档,2025,https://rocketmq.apache.org/zh/docs/featureBehavior/04transactionmessage/

[26] Alibaba,《Sentinel 介绍》,Sentinel 文档,2023,https://sentinelguard.io/zh-cn/docs/introduction.html

[27] 阿里云,《分布式事务参与者接入模式》,阿里云文档,2019,https://help.aliyun.com/zh/document_detail/132909.html

[28] 周志明,《凤凰架构:构建可靠的大型分布式系统》,2021,https://icyfenix.cn/pdf/the-fenix-project.pdf

13.9.3 适用边界

这套设计适合优惠规则复杂、交易链路跨多个服务、需要平台与商家分摊、且价格必须可审计的中大型电商或本地生活系统。对于只有一个固定价和一个简单折扣的内部工具,直接使用单体事务脚本可能更合适;不要因为本章出现了 Saga、Outbox、DDD 和多级缓存,就把它们全部套到低复杂度系统上。

当系统出现以下信号时,说明应重新审视边界:营销规则开始修改订单状态;计价服务开始直接写券表;订单只能保存总价而无法还原组件;财务无法按出资方对账;支付失败后只能人工查日志;大促期间通过无限重试掩盖容量问题。此时应回到“权威事实、职责边界、上下游协作、补偿恢复、观测治理”五个问题,重新写 ADR、补齐状态机和失败路径,而不是继续增加一个新的优惠字段。

第 14 章 电商商品发现与交易准备:搜索、详情、购物车与结算

本章定位:本章沿用户旅程讲清如何找到商品、理解商品、暂存购买意愿并形成可提交的结算凭证。重点是读链路的数据新鲜度、交易前校验和资源预占边界;订单创建后的资金、履约与售后闭环见第 15 章。

本章与第 15 章共同覆盖 C 端交易全生命周期。阅读时可把它们看成两个连续单元:第 14 章止于结算凭证准备完成,第 15 章从创单开始,继续处理订单、支付、履约和售后。

本章主要回答:

  1. 搜索与详情如何在低延迟和数据新鲜度之间取舍?
  2. 购物车保存什么事实,为什么它不锁定库存?
  3. 结算如何组合计价、库存、营销和履约约束?
  4. 怎样把展示信息和真正可成交的事实区分开?

配合阅读:第 11 章 商品中心、商品供给与生命周期治理、第 12 章 库存系统、第 13 章 营销与计价系统。


14.1 核心场景、目标与边界

很多团队讲 C 端架构时,会按系统模块切开:搜索一章、购物车一章、订单一章、支付一章。这样当然清楚,但读者很容易失去真正的主线,因为用户不会感知自己“正在使用哪个中台系统”,他只会感知:

我能不能找到商品、看懂规则、拿到正确价格、顺利下单、正常支付、按承诺履约,以及出问题后能不能退款。

14.1.1 搜索与导购找商品

用户进入平台后的第一步,通常不是直接下单,而是先找到“值得交易的候选集”。

场景用户动作典型特征系统重点
关键词搜索搜品牌、搜型号、搜服务词强相关性、结果多Query 理解、召回、排序、Hydrate
类目导购逛频道、逛类目、逛店铺弱文本、强筛选类目树、Facet、稳定排序
活动导购进会场、看榜单、看运营专区强陈列、强运营控制搜索索引 + 运营露出 + 营销标

这一阶段要回答几个问题:

  1. 搜索结果页为什么可以弱一致。
  2. 为什么列表页不直接读商品中心主库。
  3. 为什么用户看到的“列表价”和最终下单价不一定完全相同。

14.1.2 详情页理解商品

详情页是用户第一次把“可检索商品”理解为“可交易契约”的地方。

用户关注点背后依赖的域
商品标题、主图、卖点、规格商品中心
当前价格、到手价、阶梯价、实时价计价中心
库存是否充足、是否限购库存中心
活动、优惠券、赠品、满减露出营销中心
发货时效、门店核销、预约规则、退款规则商品中心 / 履约规则域

这意味着详情页本质上不是“查一张表”,而是一个 多域聚合页。它天然带来两个工程问题:

  1. 详情页为什么不能直接等于下单事实。
  2. 商品、价格、库存、营销版本不一致时,到底以谁为准。

14.1.3 加购与购物车暂存

购物车表达的是“用户意愿”,而不是“交易事实”。

场景典型动作关键差异
未登录加购用 cart_token 暂存允许匿名、弱一致、可过期
登录后加购绑定用户购物车支持跨端同步
登录合并匿名购物车合并到用户态要处理数量合并、失效商品、限购截断

这里最重要的判断是:

  • 购物车里为什么不锁库存。
  • 为什么购物车允许展示价滞后,但结算必须实时试算。

14.1.4 结算、校验与提交订单

结算页开始组织有业务约束的交易准备:各权威域在本地保护价格、库存和权益事实,跨域预占通过持久化编排、查询与补偿恢复。严格校验不等于所有服务形成一个全局强一致事务。

结算阶段动作关键系统
价格试算计价系统
库存预占库存系统
优惠校验 / 占用营销系统
地址与运费计算地址 / 运费系统
商品静态合规校验商品中心

这一步要回答:

  1. 为什么购物车不锁库存,结算才预占库存。
  2. 为什么订单提交前要再次做版本校验。
  3. 为什么结算页的本质是一个短生命周期 Saga,而不是简单表单确认。

14.1.5 下单、支付、履约与售后

用户点击“提交订单”之后,系统就从“交易前”进入“交易中与交易后”。

阶段用户看到的动作系统主导域
下单生成订单、等待支付订单中心
支付调起收银台、支付结果回流支付中心
履约发货、发码、预约、核销履约 / 券码 / 供应商域
售后退款、退货、取消、争议订单 / 支付 / 履约协同

这阶段的核心问题包括:

  1. 订单为什么必须保存商品、价格、履约快照。
  2. 支付为什么不能直接决定商品状态和库存真相。
  3. 为什么售后必须基于订单事实,而不是去查最新商品。

14.1.6 场景到系统问题的映射

场景类型典型问题
搜索导购弱一致索引、动态 Hydrate、排序与活动露出
商品详情多域聚合、库存与价格展示口径、规则解释
购物车匿名暂存、登录合并、失效商品治理
结算价格试算、库存预占、优惠校验、提交前最终校验
下单支付幂等、快照、支付回调、状态机推进
履约售后发货 / 发码 / 核销、退款回补、历史订单解释

14.2 交易域模型、职责边界与一致性语义

这一节按照“先看系统边界,再看主链路,最后看关键决策”的顺序展开。先把商品、库存、计价、营销、订单、支付和履约各自的职责划清楚,再把这些系统放进一条 C 端完整交易主链路中理解,最后集中讨论搜索弱一致、详情聚合、结算预占、订单快照和售后事实这些关键设计选择。

14.2.1 系统边界和职责

系统负责什么不负责什么
商品中心正式商品契约、履约规则、退款规则、商品快照购物车暂存、库存事实、支付状态
搜索与导购商品召回、排序、导购投影商品正式真相、订单事实
购物车与结算用户意愿暂存、结算编排、试算与预占协同正式创单、支付状态机
计价系统实时报价、到手价解释、价格快照订单状态推进、库存扣减
库存系统可售库存事实、预占、确认、释放、消费购物车、订单金额、营销规则
营销系统优惠规则、券可用性、权益占用与释放商品正式态、库存总账
订单系统订单事实、订单状态机、交易快照渠道支付、库存脚本实现
支付系统支付单、渠道交互、支付事实订单商品解释、商品真相
履约 / 核销 / 售后发货、发码、核销、退款闭环商品供给、搜索索引

最重要的三条原则是:

  1. 搜索和详情服务用户发现,订单和支付服务用户承诺,履约和售后服务用户交付。
  2. 商品中心负责交易前契约,订单中心负责交易事实,支付中心负责资金事实。
  3. 订单之后,任何解释都优先基于快照和履约事实,而不是基于最新主数据。

14.2.2 C 端交易主链路总览

flowchart LR
    A["搜索 / 导购"] --> B["商品详情页"]
    B --> C["购物车 / 直接购买"]
    C --> D["结算页试算与预占"]
    D --> E["创建订单"]
    E --> F["支付单创建与支付回调"]
    F --> G["履约 / 发货 / 发码 / 核销"]
    G --> H["退款 / 售后 / 对账闭环"]

    B -.-> P["商品中心正式契约"]
    D -.-> I["计价 / 库存 / 营销协同"]
    E -.-> S["商品 / 价格 / 履约快照"]
    F -.-> O["Outbox / 事件广播"]

这条主链路表达的是:

  1. 搜索、列表和详情是交易前的信息发现链路,允许适度弱一致。
  2. 结算、下单、支付是交易承诺链路,必须逐步收紧校验口径。
  3. 履约和售后是交易后事实链路,必须围绕订单快照和支付结果闭环。

14.2.3 决策点 1:为什么 C 端不能只用一个“大前台服务”承接全部链路

如果把搜索、详情、购物车、结算、订单、支付、履约全塞进一个“大前台交易服务”,短期当然看似简单,但长期一定会失控。

方案优点缺点 / 风险推荐结论
方案 A:大一统前台服务入口统一、联调简单搜索弱一致和交易强一致混在一起;边界塌陷;状态机膨胀不推荐
方案 B:按交易旅程拆域职责清晰;一致性口径可分层;便于扩容和治理系统间编排更复杂推荐

推荐结论:

  • 搜索 / 导购负责发现。
  • 商品详情负责解释。
  • 购物车负责意愿暂存。
  • 结算负责强校验编排。
  • 订单负责承诺落地。
  • 支付负责资金事实。
  • 履约与售后负责交易后闭环。

14.2.4 决策点 2:为什么搜索结果和详情页允许不同一致性口径

搜索结果页面对的是高 QPS 召回,详情页面对的是单商品强解释。

对象推荐一致性原因
搜索结果页弱一致索引是投影,允许短暂滞后
详情页近实时一致需要展示交易前关键信息
下单 / 支付强校验不能靠展示信息直接成交

推荐结论:

  • 搜索索引和列表投影可以异步刷新。
  • 搜索结果页不是“所有字段都由 ES 直接吐出”;只有参与召回、过滤、排序且可容忍滞后的骨架字段进索引,高频强一致字段要通过商品中心、计价和库存批量 Hydrate。
  • 详情页必须以正式商品契约为底,再补充当前价、库存和营销。
  • 下单永远不能只信列表或详情页展示结果。

14.2.5 决策点 3:为什么购物车不锁库存,结算才预占库存

购物车表达的是“想买”,不是“已经占用交易资源”。

方案风险推荐结论
购物车加购即锁库存大量僵尸占用;资源利用率极差;售罄假象不推荐
结算时再预占库存只在交易临门一脚时收紧资源推荐

推荐结论:

  • 购物车只保存用户意愿。
  • 进入结算页时,库存中心才执行 ReserveInventory。
  • 支付失败、超时取消、风控拒绝后,必须显式释放预占。

14.2.6 决策点 4:为什么订单必须保存商品 / 价格 / 履约快照

如果订单创建后还依赖“实时查最新商品”,未来只会产生解释灾难。

事实为什么要快照
商品标题、规格、履约规则商品可能后续被改名、改规格、改退款口径
价格与优惠结果价格和活动经常变化
履约参数发码、预约、核销规则可能调整

推荐结论:

  • 订单保存或引用创单时的商品快照、价格快照、履约快照。
  • 售后和客服解释历史订单时,只认快照,不认最新商品。

14.2.7 决策点 5:为什么支付不能反向驱动订单主状态机

支付是资金事实,但不是订单全业务事实。

方案风险推荐结论
支付中心直接改订单所有主状态状态主权错位;订单域被支付绑死不推荐
支付中心发支付事实事件,订单域自行推进状态支付和订单解耦;幂等更清晰推荐

推荐结论:

  • 支付系统只负责支付单与支付结果。
  • 订单系统消费支付成功 / 失败事件,自行推进自己的状态机。

14.2.8 决策点 6:为什么售后必须基于订单事实,而不是回查最新商品

退款、退货、取消和争议处理面对的是“历史承诺”,不是“当前最新售卖状态”。

推荐结论:

  • 售后判断以订单快照、支付结果、履约状态和库存回补策略为准。
  • 不允许用最新商品标题、最新价格、最新规则反向解释旧单。

14.2.9 统一业务模型:意愿、凭证、承诺与事实

把搜索、购物车、结算、订单、支付、履约和售后放在同一张图里观察,可以发现它们并不是七个互相独立的 CRUD 系统,而是在推动四类业务对象依次变形:

业务对象产生位置生命周期允许被什么覆盖不能被什么替代
意愿搜索、详情、购物车分钟到数月用户主动修改、商品失效标记不能当作库存或订单承诺
凭证结算、库存、营销、计价秒级到分钟级刷新、过期、撤销不能当作最终订单事实
承诺订单与订单项订单全生命周期合法的取消、退款、售后状态不能被最新商品配置覆盖
事实支付、履约、核销、退款永久留痕纠错事件和审计记录不能靠页面展示推断

这四类对象必须有清晰的转换条件。购物车中的意愿只有在商品可售、价格有效、库存预占、营销占用和用户约束都通过后,才能生成一组结算凭证;凭证被订单中心消费并写入本地事务后,才形成订单承诺;支付、发货、核销和退款的外部结果,再以事实事件的形式推动承诺的状态变化。

这一模型可以避免一个常见误区:把任何接口返回的数据都叫作“订单状态”。例如,搜索索引中的 available=true 只是召回条件,库存服务返回的 reserved 只是临时资源凭证,支付渠道返回的 succeeded 只是资金事实。它们都不能直接替代订单域的状态主权。分布式事务研究强调,跨资源的长期业务事务应拆成可提交、可补偿的业务步骤,而不是依赖一个持续持锁的全局事务。[1][2] 因此在本场景中,我们把每次转换的输入、输出和补偿动作都显式建模。

14.2.10 权威数据源与状态主权

每个字段都应该有且只有一个权威写入者,其他系统只保存投影、快照或引用。建议在设计评审时强制填写下面的“事实归属表”:

事实权威系统其他系统可以保存其他系统不能做
商品可售、规格、类目商品中心搜索索引、订单快照不能由搜索索引反写商品正式态
当前价格与优惠计算计价 / 营销中心结算凭证、订单价格快照不能由前端金额直接覆盖
可用库存与预占库存中心结算凭证、订单资源引用不能由订单状态猜库存余额
订单主状态订单中心读模型、消息消费者状态支付或履约系统不能直接改主状态
支付结果支付中心及渠道凭证订单支付投影、对账记录不能由前端回调参数直接认定成功
履约结果仓配、发码、预约等履约域订单履约投影、客服视图不能用商品配置推断已经履约
退款结果支付 / 售后域订单售后投影、审计记录不能只凭申请成功显示退款完成

“权威”不等于“所有请求都必须同步访问主库”。它表示发生冲突时谁拥有最终解释权。搜索可以从索引读取,订单列表可以读取投影,客服可以读宽表;但发生金额争议时,应回到订单价格快照和支付凭证,发生库存争议时,应回到库存流水和预占记录。数据密集型系统设计通常把派生数据视为可重建投影,把不可替代的业务事实与投影分离。[4] 本书进一步把这一原则应用到电商:所有可重建的数据都要带版本、来源和更新时间,所有不可重建的数据都要落本地事实或外部凭证。

14.2.11 一致性分层:展示、交易前提与交易事实

本章不把“一致性”当作一个二元开关,而是按用户动作分成三层:

层次典型请求可接受延迟失败时的用户体验设计重点
展示一致性搜索列表、推荐、普通购物车读取秒级到分钟级刷新、提示数据已变化缓存、索引、降级、版本标记
前提一致性价格试算、库存预占、优惠占用通常需要在线确认阻断提交并指导刷新凭证、TTL、版本校验、释放
事实一致性订单落库、支付入账、退款完成必须可追溯显示处理中并异步收敛本地事务、幂等、事件、对账

展示层允许短暂滞后,并不意味着交易可以信任展示结果。反过来,交易事实需要可追溯,也不意味着所有下游都必须在一个同步请求里完成。Google SRE 关于过载和尾延迟的实践指出,系统应明确哪些请求可以降级,哪些请求必须被拒绝或延后,否则低价值读流量会挤压高价值写入。[5][6] 因此在本章中,搜索可以返回“价格待刷新”,结算可以返回“优惠已失效”,订单提交则宁可快速失败,也不接受未经验证的旧凭证。

14.2.12 跨域协作的最小契约

跨域接口不应该只返回一个布尔值。一个可重试、可审计、可补偿的交易接口,至少需要包含:

契约部分示例作用
请求身份request_id、idempotency_key、user_id防止重复执行和越权
业务版本product_version、price_version、rule_version判断凭证是否过期
资源引用reserve_id、coupon_token、payment_intent_id将短期资源与订单绑定
时间边界expires_at、created_at限制重放和无限等待
结果语义accepted、confirmed、rejected、pending区分收到请求与事实已完成
纠错入口query_status、cancel、compensate超时和异常时反查或补偿

接口成功的含义必须写清楚。例如库存 Reserve 成功只代表库存中心创建了预占记录,不代表订单已经创建;支付 CreatePayment 成功只代表支付单可被继续支付,不代表资金已入账;退款申请受理也不等于渠道已经退款完成。事件驱动系统通常需要同时处理重复、乱序和延迟消息,CloudEvents 对事件元数据的规范化以及企业集成模式中的幂等消费者模式,都说明了“可识别、可追踪、可重放”应成为跨域契约的一部分。[3][7][16] 在本章的实现里,所有事实事件都携带 event_id、aggregate_id、event_type、occurred_at、schema_version 和 trace_id。


14.3 搜索导购、详情与交易前读链路

14.3.1 场景画像

从用户进入平台到决定”我要不要买”,通常依次经过两种读路径:

  1. 搜索 / 导购结果页:看的是候选集。
  2. 商品详情页:看的是交易前解释。

这两条链路看起来都只是”读”,但它们的职责截然不同:

  • 搜索结果页服务于召回和转化,强调高吞吐和可排序。
  • 详情页服务于交易前理解,强调解释性和当前性。

从系统角度看,搜索、导购、详情之间不能揉成一个系统,也不能简单地串成一条长链路,而应该按各自的职责边界和一致性要求独立设计,再通过编排层聚合。

14.3.2 关键技术点

这一节先前置所有高价值架构结论,后续场景化时序图再展开具体调用关系。

为什么搜索、导购、详情不能揉成一个系统

很多团队早期会把”商品搜索、类目导购、详情查询”统一塞进一个搜索服务里,代码复用率高,看起来也方便。但这会带来三个致命问题:

  • 职责塌陷:搜索负责高吞吐召回和排序,导购负责多维度筛选和聚合卡片展示,详情负责多域事实聚合。三者面对的下游依赖链、缓存策略、降级粒度完全不同,揉在一起只会让每个场景都背上不该背的依赖。
  • 一致性口子撕裂开:搜索页天然允许弱一致,详情页需要近实时一致。如果把两个场景放在同一个服务里,开发人员很难在代码层面区分”这个字段可以滞后 5 分钟”和”这个字段必须实时校验”。
  • 大促放大故障面:搜索页 QPS 远高于详情页。一旦合并在同一个服务里,高并发搜索流量会直接把详情页的计价、库存、营销下游一起拖垮。

因此推荐结论很明确:

  • 搜索服务解决召回、筛选、排序。
  • 导购服务解决聚合卡片展示。
  • 详情聚合服务解决多域事实聚合。

哪些字段来自 ES,哪些字段来自商品中心

这条链路里最关键的设计点,不是”多调了几个下游”,而是:搜索结果页到底哪些数据应该直接来自搜索中心,哪些数据应该回商品中心 / 计价 / 库存实时补齐。

决策原则只有三条:

  1. 是否参与召回、过滤、排序:参与这些能力的字段,优先放到 ES。
  2. 变动频次高不高:高频变化字段不要把绝对真相压进 ES。
  3. 对准确性要求高不高:一旦字段直接影响成交,就应该让实时权威域说了算。

因此,搜索结果页通常采用”ES 出骨架,商品中心与交易相关域补血肉”的模式:

字段类型主要来源为什么这么设计
标题、副标题、主图、品牌、类目、属性标签搜索中心 / ES这些字段参与检索、过滤、聚合或高频展示,适合做索引骨架。
上下架状态、可搜状态搜索中心 / ES 过滤必须在搜索阶段先过滤掉不可售对象,避免返回无意义结果。
销量、评分、热度等排序因子搜索中心 / ES直接用于粗排或综合排序,允许弱一致。
实时到手价、会员价、促销价计价中心 / 商品中心 Hydrate高敏感、高频变化,列表允许短暂滞后,但展示时最好用实时结果覆盖。
绝对库存数、紧张库存文案库存中心 Hydrate属于高频高并发变化字段,不能让 ES 承担绝对数写入。
活动标签、圈品命中、促销露出营销中心 Hydrate露出逻辑变化快,且失败时可以局部降级。
冷门说明类字段商品中心不参与搜索与排序,没必要进 ES。

一个很实用的判断方法是:

  • 只要字段决定”搜不搜得到、排在第几位、能不能按它筛选”,就应该优先放进 ES。
  • 只要字段决定”现在到底多少钱、到底还有没有货、这个活动此刻还能不能领”,就应该优先让商品中心、计价或库存域返回实时结果。

搜索结果页为什么只认 ES 骨架,不直接等于交易事实

搜索结果页的目标是高吞吐召回、排序和转化,不承担交易真相主权。它展示的是”当前最有可能被用户点击的候选集”,不是”此刻可以直接下单成交的最终合同”。

因此,搜索页允许:

  • 索引异步刷新
  • 列表字段局部滞后
  • 某些弱展示字段降级缺失

但它不能越界去承担:

  • 实时价格真相
  • 实时库存真相
  • 权益资格最终裁决

这也意味着:下单前必须重新校验价格、库存、权益。搜索结果页拿到的是”可浏览卡片”,不是”可下单事实”。

搜索结果页的 Hydrate 编排为什么不是纯串行,也不是无脑全并行

Hydrate 层最容易被低估的一个问题是:下游依赖到底应该串行调用,还是并行调用。

如果完全按照”商品中心 -> 库存中心 -> 营销中心 -> 计价中心”的直觉串行走,业务理解上很顺,但性能上很危险。因为只要每个 RPC 平均耗时 20ms,四段串行下来就已经接近 80ms;任意一个下游轻微抖动,整条列表页链路就会很容易冲到 150ms 以上。

更稳妥的工程实践通常不是”全串行”,也不是”无脑全并行”,而是带依赖关系的半并行编排:

  1. 第一阶段并行
    • 商品中心:拿商品卡片骨架、类目、品牌、商家等基础信息
    • 库存中心:拿库存摘要、是否有货、紧张状态
  2. 第二阶段
    • 营销中心:基于商品基础信息和用户上下文,先判断活动命中、优惠资格、券与满减露出
  3. 第三阶段
    • 计价中心:在拿到营销结果之后,再基于商品基础信息、营销结果和用户上下文计算展示价、会员价、到手价

这样做的原因是:

  • 库存中心通常只依赖 item_id / sku_id,并不依赖商品标题、品牌、类目这些骨架字段;
  • 商品中心也不依赖价格和库存,它本身就是卡片骨架来源;
  • 营销中心 往往依赖商品类目、商家、售卖属性来判断露出与资格;
  • 计价中心 在很多实现里并不是独立”拍脑袋算价”,而是要把营销命中的结果、优惠叠加关系、会员折扣、活动门槛一起折进最终展示价,所以它天然位于营销之后。

这种编排的总耗时,更接近:

  • max(商品中心, 库存中心) + 营销中心 + 计价中心

而不是四段简单相加。所以它通常仍然明显优于”商品 -> 库存 -> 营销 -> 计价”的纯串行模式,同时又不会像”盲目全并行”那样把前置依赖关系搞乱。

如果业务继续追求更低的列表页延迟,还可以进一步演进到近似全并行:在计价中心和营销中心内部也冗余一份极简的商品基础信息,例如:

  • item_id -> 类目
  • item_id -> 商家
  • item_id -> 基础售卖属性

这样一来,Hydrate 层在收到 ES 返回的 item_id 列表后,就可以:

  • 同时查商品中心
  • 同时查库存中心
  • 同时查营销中心
  • 等营销结果返回后,再调计价中心

因为营销不再必须等待商品中心先返回类目信息,而计价至少不需要再回头额外查一次商品中心。这本质上是:

用数据冗余换实时编排延迟。

但要注意,这种”绝对并行”只适合在两个前提下使用:

  1. 营销和计价中心内部维护的商品轻量副本足够新鲜;
  2. 你们愿意承担多一层数据同步和一致性治理的复杂度。

所以默认推荐结论是:

Hydrate 层优先采用”商品 + 库存第一阶段并行,营销第二阶段,计价第三阶段”的半并行架构;只有在对延迟极度敏感、且下游已经具备足够商品副本和营销结果缓存时,才继续压缩计价前置依赖。

详情页为什么必须做动静分离

商品详情页(PDP)是交易漏斗里最核心的读页面之一。它的特点是:

  • 流量极大
  • 读多写少
  • 高可用要求极苛刻
  • 用户对页面解释能力和加载速度都极其敏感

因此,详情页不能做成”每次请求实时查所有下游”的重聚合链路,而要先做动静分离。

从工程上看,详情页的数据通常可以拆成两类:

  • 静态数据:
    • 标题、副标题
    • 主图、详情图
    • 商品参数说明
    • 低频变化的文案和结构化描述
  • 动态数据:
    • 当前到手价
    • 库存摘要
    • 当前用户可见的权益和促销露出
    • 配送时效、门店核销、预约规则、评价摘要

更稳妥的详情页架构通常是:

  1. 静态骨架优先
    • 商品静态内容通过静态 JSON、静态片段或 CDN 化页面骨架快速返回。
    • 用户先看到稳定的页面结构、标题和主图,而不是等待所有后端系统都准备完毕。
  2. 动态数据异步 Hydrate
    • 前端或详情聚合层再去批量拿价格、库存、营销和履约数据。
    • 动态内容可以稍晚几十毫秒填充,但不能把首屏完全卡死。

为了让这条链路在大促和高并发下仍然稳定,通常要配合多级缓存:

  • CDN / 静态内容缓存
    • 兜底标题、主图、详情骨架
  • 聚合层本地缓存(如 Caffeine)
    • 缓住极短时间内的重复请求和热点详情
  • 分布式缓存(如 Redis)
    • 缓住基础价、库存摘要、详情聚合片段或短期动态结果

这样做的目标不是追求”所有详情字段都绝对实时”,而是:

先保证详情页稳定打开,再保证动态关键信息足够新鲜,最后在创单前做最终强校验。

详情页里的实时计价、库存摘要与大促降级

详情页里最敏感的两个问题,永远是:

  1. 现在多少钱
  2. 现在还有没有货

这两个字段之所以难,是因为它们都不适合做成简单的全量缓存。

对于价格来说,真正的到手价往往同时受以下因素影响:

  • 会员等级
  • 商品基础价
  • 店铺活动
  • 平台券 / 品类券
  • 用户专属权益

因此更常见的做法不是缓存”所有用户的最终价”,而是:

  • 缓存基础价 / 会员价矩阵 / 基础促销价
  • 在详情请求进来时,再结合用户上下文和营销命中做轻量实时计算

对于库存来说,详情页一般不追求展示精确库存数字,而是展示:

  • 有货
  • 无货
  • 紧张库存
  • 仅剩少量

这样可以显著降低库存读压力,同时也避免把高频变化的绝对库存数暴露到前台。

但真正的大问题出现在大促、热点商品和下游抖动场景下。此时详情页必须具备明确的降级能力:

  • 计价服务超时
    • 优先展示基础销售价或指导价
  • 库存摘要超时
    • 退化为保守文案,如”库存确认中”或”请以下单时校验为准”
  • 营销露出超时
    • 隐藏活动标签,不阻塞主页面
  • Redis 或下游依赖异常
    • 先用本地缓存或静态兜底页保证详情可打开

对热点商品,还要配合:

  • 热 Key 探测
  • 本地热点缓存
  • 限流与线程池隔离

这样即使某个爆款详情在几秒内被打到极高 QPS,也不至于把 Redis、计价、库存或营销服务一起拖崩。

所以详情页的核心不是”把所有数据都实时算到最准”,而是:

在可接受的一致性范围内,把最重要的动态字段算出来,把不重要的字段降级掉,并且永远保证详情页页面本身不因为单个下游故障而整体不可用。

搜索与详情的一致性边界怎么定

第 2 节已经给出了整体一致性分层,这里把结论聚焦到搜索和详情内部:

对象推荐一致性原因
搜索结果页弱一致索引是投影,允许短暂滞后
详情页静态骨架近实时一致商品契约信息,变更频率低,可缓存
详情页动态字段允许短暂偏差计价、库存、营销可能发生秒级变化
创单 / 结算强校验不能靠展示信息直接成交

推荐结论:

  • 搜索索引和列表投影可以异步刷新。
  • 搜索结果页不是”所有字段都由 ES 直接吐出”;只有参与召回、过滤、排序且可容忍滞后的骨架字段进索引,高频强一致字段要通过商品中心、计价和库存批量 Hydrate。
  • 详情页必须以正式商品契约为底,再补充当前价、库存和营销。
  • 下单永远不能只信列表或详情页展示结果。

为什么详情页之后还需要结算页和订单快照

详情页虽然比列表页更接近交易真相,但它仍然只是用户下单前的解释界面。它需要把:

  • 正式商品契约
  • 当前价格
  • 当前库存摘要
  • 营销露出
  • 履约规则

聚合成一个可理解的商品页面,但它仍然不能替代:

  • 结算页的价格试算
  • 预占库存
  • 优惠校验
  • 创单前最终版本校验

一句话:搜索与详情只负责”看”,结算与订单才负责”认”。订单快照则是把”认下来的事实”持久化,确保后续履约和售后不再回头依赖最新商品真相。

高并发下如何保护搜索 Hydrate 下游

搜索和详情链路的 Hydrate 阶段依赖多个下游:商品中心、库存中心、营销中心、计价中心。高并发下,这些下游很容易被放大后的请求量打穿。

工程上推荐用分层保护策略:

  • 批量接口:Hydrate 层不再逐条调用下游 RPC,而是按 item_id 列表做批量查询,减少 RPC 数量。
  • 线程池隔离 / 舱壁隔离:为商品、库存、营销、计价分别分配独立线程池,避免某一个下游慢调用占满公共线程池。
  • 超时控制:每个下游 RPC 独立设置超时时间,任一超时触发即时降级。
  • 局部降级:超时后按字段维度降级(如计价超时不阻断整个卡片,只用基础价兜底),而不是整页失败。
  • 热点缓存:热点商品在本地缓存和 Redis 里保护下游不被击穿。

这些技术和 3.6 的场景三时序图互相呼应,形成”文字说明原则 + 图说明流程”的对照。

为什么酒店要单独作为特殊场景处理

普通实物电商商品,信息变更以商品本身为主(价格、库存、活动)。但酒店天然带有 日期维度 与 用户上下文。同一家酒店,在不同入住日、房型、会员等级和连住天数下,真实价格和真实可售状态都可能完全不同。这意味着:

  • 酒店的”可检索属性”和”可交易真相”之间的鸿沟比普通商品大得多;
  • 搜索索引、详情聚合、结算校验三层之间的一致性治理复杂度远高于标品;
  • 价格排序、深翻页、实时房态一致性等问题需要专门的架构策略。

因此本章将酒店相关内容统一收口到 3.8 节作为独立特殊场景处理。以下内容从 3.3 开始按场景化时序图展开。

统一 scene 查询服务与四阶段流水线

搜索与结构化导购可以共享一条查询内核,但不能共享一套含糊的业务语义。推荐由导购查询服务对外暴露统一接口,用 scene 表达用户意图,用结构化约束表达过滤条件,再在内部依次完成 Query、Recall、Rank、Hydrate 四个阶段。这样搜索、类目浏览和店铺内浏览共享批量接口、日志和降级机制,同时保留各自的产品目标。

场景用户输入主要约束召回与排序重点结果解释
keyword关键词、类目、品牌、筛选项文本相关性与可售过滤分词、同义词、相关性、业务排序为什么匹配这个词
category类目、品牌、属性筛选类目树和 Facet 组合稳定排序、运营陈列、库存信号当前筛选条件是什么
shop店铺、关键词、店内筛选店铺边界与商家状态店内相关性、店铺运营规则商品属于哪个店铺

请求上下文至少携带 query_id、scene、用户和设备匿名标识、filter_version、rank_version、实验桶以及数据新鲜度要求。scene 不是前端随意传入的字符串,而应由网关或查询服务校验枚举、字段白名单和分页上限。若把排序逻辑散落在多个 BFF,后续会出现同一个商品在搜索、类目和店铺页的排序口径无法解释,实验也无法回滚。

四阶段的输入输出要明确:Query 阶段把用户输入转换为归一化查询和意图特征;Recall 阶段从索引、运营池或规则集合获得候选商品 ID;Rank 阶段在候选集上执行相关性、业务权重和实验配置;Hydrate 阶段批量补齐价格、库存、营销和履约摘要。每一阶段都应记录耗时、候选数量、丢弃原因和版本,避免只监控一个“搜索接口总耗时”。

索引是版本化投影,不是第二个商品主库

搜索索引的数据流应从商品中心、上架系统、生命周期治理和运营配置产生事件,经消息总线进入索引 Worker,再写入 Elasticsearch 的写别名或新版本索引。索引 Worker 只拥有“如何构建搜索投影”的职责,不拥有商品正式状态、价格真相或库存总账。派生投影可重建这一点意味着:索引文档必须保留来源版本、更新时间和构建版本,不能只保存最终字段而丢失判断依据。[4]

增量更新至少需要三道保护。第一,以 spu_id 或 sku_id 作为分区键,让同一聚合的事件尽量有序;第二,在文档中写入 source_version,消费端使用版本条件更新,拒绝旧事件覆盖新事件;第三,消费者记录 event_id 和处理结果,重复投递只重放幂等写入。Kafka 的至少一次投递和幂等生产者可以降低重复风险,但不能替代索引文档版本和业务对账。[7][8]

全量重建不能直接覆盖线上索引。更稳妥的流程是创建带 mapping_version 的新索引,执行全量快照和增量追平,抽样比较文档数量、版本和关键字段,再通过原子别名切换读流量。切换后保留旧索引一段观察窗口,以便在零结果率、召回量或排序指标异常时快速回滚。别名切换的原子性只能保证读入口的一致,不能保证新索引已经包含所有最新事件,因此切换前后的版本差异仍需由对账任务检查。

Query、Recall、Rank、Hydrate 的性能边界

四个阶段的优化目标不同,不能用“整体加机器”替代边界设计:

阶段主要工作关键指标典型失败处理
Query分词、纠错、同义词、意图和过滤归一改写命中率、非法条件率、耗时使用安全的原词或返回可解释空结果
Recall倒排、结构化过滤、运营池、多路召回候选数、零结果率、召回耗时关闭高成本召回,保留主索引
Rank相关性、业务权重、实验和稳定排序P99、排序版本、分桶差异回退到稳定排序版本
Hydrate商品、价格、库存、营销批量补齐依赖成功率、字段缺失率、尾延迟局部字段降级,不阻塞整页

Elasticsearch 的查询应尽量把过滤条件放在 filter 语义中,把不需要参与评分的限制从相关性计算中分离;排序字段需要评估 doc_values、内存和写入代价。from/size 深分页会随着页码增加而扩大协调节点和分片的工作量,面向用户的连续翻页应优先采用带稳定排序键的 search_after,并限制游标有效期。search_after 不是任意跳页方案,产品必须明确是否支持跳到第 N 页、结果快照是否需要稳定,以及索引切换后游标如何失效。[11]

排序必须有稳定的兜底键,例如业务排序值、更新时间和唯一 ID 的组合,不能只按一个会频繁变化的热度字段排序。否则同一用户连续翻页时会出现重复和漏项,Hydrate 后的局部重新排序也可能让用户感觉页面不稳定。对酒店等动态报价场景,可以先用基础价粗排,再对当前页做受控的实时修正,但不应把实时修正结果伪装成全局精确排序。

缓存、热点和实验必须绑定版本

搜索缓存应区分查询结果缓存、静态详情缓存和动态 Hydrate 缓存。查询结果缓存的 key 至少包含归一化 query、scene、过滤条件、分页游标、站点和 rank_version;Hydrate 缓存还要包含用户权益上下文的必要摘要、价格版本或库存新鲜度边界。只用关键词作为 key,会把一次实验结果、过期价格或错误降级结果污染到其他用户。

热点保护通常分三层:边缘或网关短缓存吸收重复请求,搜索服务本地缓存保护最热 query,ES 和 Hydrate 下游使用限流、舱壁和批量接口控制放大倍数。对 suggest、联想词和热门榜单要设置独立配额,因为输入框每敲一个字符都会生成请求;不能让低价值的联想流量挤占交易前详情和结算的资源。

零结果率是搜索质量和数据健康的共同信号。零结果上升时,先区分用户真的没有匹配、索引延迟、过滤条件互斥、词典改错还是查询超时;可以在不改变交易事实的前提下回退同义词、放宽非关键筛选或展示类目引导,但不能为了降低零结果率返回已经下架或不合规的商品。实验请求要把 exp_id 和配置版本贯穿日志、缓存、排序和指标,影子流量或回放结果用于验证新排序,指标异常时能够按版本快速停止。

搜索结果的可解释性与交易衔接

搜索不是只返回商品 ID。面向用户的结果应能解释命中、筛选、价格和可售状态的来源与新鲜度;面向工程和客服的结果还要带 query_id、索引版本、排序版本、Hydrate 依赖结果和降级原因。这样用户看到“参考价”“库存确认中”时,产品话术与后端事实是一致的,客服也能根据版本和 trace 还原问题。

搜索结果离开读链路进入详情、结算或订单时,必须显式丢弃“可直接成交”的假设:商品详情重新确认正式商品契约,结算重新试算价格和营销,库存重新预占,订单写入快照。读模型的版本可以作为校验输入,不能成为订单金额或库存的最终来源。这个边界把搜索的高吞吐优势与交易的资金安全连接起来,也避免把 ES 变成事实主库。

删除、下架与索引修复的语义

搜索投影对删除和下架不能只做“把文档删掉”这一种处理。商品被软删除、下架、审核拒绝、店铺冻结或生命周期过期时,事件应携带原因、来源版本和生效时间;索引消费者根据事件类型决定是从可搜索集合移除、保留不可见文档供历史回放,还是只更新过滤字段。这样客服和数据团队仍能区分“用户没有搜到”与“商品本来存在但当前不可搜”,也便于重新上架时从主数据重建完整文档。

删除事件同样可能重复、乱序或丢失。推荐把“可搜索”作为带版本的投影字段,而不是仅依赖物理删除:较新的下架版本可以把文档标记为不可召回,后台异步清理;较旧的上架事件不能重新打开文档。索引抽样对账时,应同时检查主数据中的合规状态、投影的可搜索状态、最后来源版本和删除事件是否闭合。对账任务生成修复队列后,可以按聚合重放事件或从主数据重建单个文档,不能直接批量把 ES 字段改成猜测值。

查询回放还应区分“同一请求的结果可复现”和“线上结果必须永久冻结”。搜索通常只要求前者:保留归一化 query、scene、排序配置、索引别名和 Hydrate 结果摘要,就能在脱敏测试数据上重放并解释差异;详情和结算则要把价格、库存、营销版本固化为快照。这个边界既支持相关性和实验排障,也不会把高成本的全量搜索快照误当成交易凭证。

排序治理还要把相关性目标和商业规则分开。文本相关性、类目匹配和用户输入意图决定基础排序,运营加权、广告露出、库存状态和商家策略只能在声明的约束范围内参与,且每次调整都要记录规则版本、适用 scene 和影响指标。不能用一个不可解释的综合分数同时解决相关性、毛利、库存和活动目标,否则一旦转化下降,团队既无法定位是哪条规则生效,也无法在不重建全部索引的情况下回滚。

查询契约要表达用户语义,而不是暴露索引细节

统一查询服务的接口不应让调用方直接传 Elasticsearch DSL。DSL 是当前检索实现的内部协议,一旦把它暴露给 App、运营后台或多个 BFF,任何一个调用方都可能依赖某个字段名、默认排序或脚本行为,最终导致索引 mapping 无法演进。更稳妥的接口是以业务语义表达请求:scene、关键词、类目、店铺、筛选条件、排序意图、分页游标、站点、用户上下文摘要和数据新鲜度要求。服务端负责校验字段白名单、规范化条件、补齐默认过滤,并将内部查询计划记录下来。

查询契约还要明确“没有提供”和“明确选择空值”的区别。例如用户未选择品牌,意味着不增加品牌过滤;用户选择“无品牌”或某个空属性,则可能是一个合法筛选条件。价格区间、时间区间、地理半径和库存状态也不能只用字符串传递,否则不同客户端会对边界、时区和单位产生不同解释。接口层应输出规范化后的条件摘要和拒绝原因,使日志能够回答“用户到底请求了什么”,而不是只留下一个无法复现的原始 URL。

查询取消同样属于契约的一部分。用户快速修改关键词时,前一个请求即使已经发出,也不应继续无限消耗召回、排序和 Hydrate 预算。网关可以使用请求截止时间和取消信号,服务内部在阶段边界检查剩余预算;已经进入不可取消的批量调用时,则通过小批量、连接池隔离和结果丢弃避免把过时请求写入缓存。W3C Trace Context 规定的传播字段可以帮助跨网关、查询编排和下游依赖关联同一条调用链,[12] 但 trace_id 只用于技术关联,不能替代业务上的 query_id、实验桶和结果版本。[13]

这一区分在故障排查时尤其重要:同一个 trace_id 可能因为重试产生多条尝试,而同一个 query_id 代表用户界面上的一次查询意图;页面刷新、自动补全和主搜索也应有不同的请求类型。若把这些标识混成一个字段,指标会把取消请求、重试请求和真正完成的请求混在一起,既无法计算搜索转化漏斗,也无法判断下游超时是用户主动离开还是系统确实变慢。

多路召回必须有预算、去重和质量闭环

单路关键词召回并不能覆盖所有商品表达,但增加类目召回、品牌召回、运营池、历史点击召回或向量召回后,复杂度不会只增加一个接口。每一路都要声明候选上限、超时预算、过滤条件、优先级、去重键和失败语义。查询编排器先为每一路分配预算,再以展示单元的稳定 ID 去重,最后将候选集交给粗排和精排;任何一路超时,都应允许主链路使用其余候选完成,而不是把所有结果判为失败。

召回来源适合解决的问题需要控制的风险建议的质量信号
关键词倒排用户明确表达的意图同义词不足、拼写噪声命中率、零结果率、点击位置
类目与属性结构化浏览和筛选条件互斥、类目挂错Facet 使用率、过滤后候选数
运营池会场、榜单和活动陈列过度插入、规则污染相关性资源位点击、投诉率、回滚次数
向量或行为召回语义近似和长尾表达不可解释、召回漂移独立覆盖率、离线相关性、延迟

多路召回的合并不应直接按来源顺序拼接。若运营池永远排在关键词结果前面,用户会认为搜索不相关;若向量候选数量没有上限,泛化商品会挤掉精确匹配;若历史行为召回带入了用户不再允许看到的商品,还可能绕过合规过滤。因此,所有候选在合并前都必须经过统一的可搜索、站点、商家、类目和合规过滤,去重后再按来源配额进入排序。来源配额、截断数量和过滤原因要作为 rank_version 的一部分记录,便于解释某次结果为什么变化。

质量闭环也要区分“没有召回”和“召回后没有展示”。前者可能是词典、索引或过滤问题,后者可能是排序、去重、资源位或合规过滤问题。日志至少记录每个候选的 recall_source、进入和离开各阶段的原因、最终位置以及是否被 Hydrate 丢弃。这样离线评估可以构造“召回覆盖—排序质量—展示可用性”的分层指标,而不是只用最终点击率反推所有问题。对含有个性化行为的召回,还要遵守最小化采集和脱敏原则,避免把搜索框中的手机号、地址或订单号写入长期训练数据。

Hydrate 的并发控制决定读链路是否会反噬交易链路

Hydrate 看似只是批量补字段,实际是一个跨域扇出器。假设一页有 50 个展示单元,同时需要价格、库存、营销和履约摘要,如果每个单元分别调用四个服务,就会把一次用户请求放大成数百次 RPC。批量接口只能降低网络次数,不能自动消除下游计算压力;查询服务还要按照依赖的容量、超时和业务重要性设置独立舱壁、并发上限和批次大小。

推荐把卡片字段分成三类:第一类是没有它就无法理解结果的核心字段,如标题、主图和商品标识;第二类是影响决策但可以显示“确认中”的动态字段,如参考价、库存摘要和配送时效;第三类是增强体验的可选字段,如活动标签、推荐理由和扩展卖点。核心字段失败时可以减少结果数量或切换到索引中的安全骨架,动态字段失败时要明确标注新鲜度,增强字段失败时直接省略。这个分层比对所有依赖设置同一个总超时更能保护用户语义。

Hydrate 的并发预算还应按照入口隔离。搜索主结果、详情页相似商品、购物车推荐、自动补全和运营后台不能共用一套无上限的线程池;suggest 的单字符请求量通常很高,但它对成交事实的价值低于结算前的价格和库存校验。每个入口应有最大候选数、最大批次数和最大下游等待时间,超出预算就返回部分结果。下游返回部分失败时,结果中应保留字段级状态,例如 price_status=stale、inventory_status=unknown,而不是将空值解释成“零元”或“无库存”。

价格和库存的降级尤其需要防止语义越界。索引中的基础价可以用于筛选和粗排,但不能在用户点击购买时继续沿用;库存摘要可以显示“有货”“库存紧张”或“确认中”,但不能把“确认中”渲染成已经锁定。进入详情、结算和创单时,系统必须重新读取权威计价和库存服务,按照当前用户、地址、优惠和时间窗口生成新的报价与预占。HTTP 的错误语义和问题详情格式可以帮助调用方区分参数错误、依赖不可用和请求过期,[14][15] 但最终能否下单仍由交易域的不变量决定。

排序、词典和索引发布要形成可回放的变更流水线

搜索质量的发布对象不只有代码。分词器、同义词、类目映射、过滤规则、排序权重、模型、索引 mapping、运营资源位和缓存版本都可能改变结果。每次发布应生成一个不可变配置包,至少包含配置版本、适用 scene、输入数据快照或摘要、预期影响、回滚动作和责任人。索引映射变更要通过新物理索引验证,排序和词典变更则要先用固定 query 集离线回放;不能只在开发者自己的几个关键词上确认“结果看起来正常”。

回放数据应覆盖头部 query、长尾 query、零结果 query、品牌歧义、类目筛选、店铺搜索、活动资源位以及敏感词边界。对每个样本,比较候选覆盖、首屏相关性、过滤正确性、排序稳定性、响应大小和各阶段延迟;对于个性化结果,使用脱敏后的特征摘要和固定实验桶,避免把一次线上用户画像误当成通用结论。离线指标改善也不能直接等于线上收益,线上还要观察搜索到详情、详情到结算、价格投诉、取消和退款等后续信号。

实验平台应支持至少三种操作:shadow 只计算新方案但不影响用户,灰度按稳定用户或设备分桶,紧急停止按 exp_id 和配置版本关闭。缓存 key、日志和下游 Hydrate 请求必须带上实验上下文,否则用户已经被分到新方案,缓存却返回旧方案结果,指标会出现虚假的随机波动。实验结束后要清理失效配置和缓存命名空间,保留汇总结果、样本规模和统计口径;长期不清理的实验开关会把排序系统变成无法审计的条件树。

搜索读模型还应具备最小的隐私和合规治理:原始 query 按最小必要原则保留,账号、手机号、地址、支付信息等疑似敏感片段要脱敏或丢弃;运营人员只能看到完成授权的数据范围;删除用户数据时,查询日志、实验样本和离线回放副本也要有可执行的删除策略。搜索系统不是因为只读就天然没有合规责任,尤其当它同时沉淀用户意图、价格偏好和行为反馈时,数据生命周期应与交易审计、客服排障和模型训练分别定义。

由此可以把搜索读链路的验收标准收敛成四个问题:查询是否能被准确解释,候选是否在预算内产生,动态字段是否标明真实新鲜度,结果是否能安全地衔接到交易校验。四个问题分别对应查询契约、召回治理、Hydrate 降级和权威事实边界。任何一个问题没有答案,系统即使在压测中拥有很高吞吐,也仍然可能在大促、索引切换或价格波动时把读模型问题放大成交易投诉。

14.3.3 场景一:搜索索引视图的构建

在深入搜索结果页、详情页的查询链路之前,必须先回答一个更前置的问题:搜索中心的索引数据到底是怎么从商品中心同步过来的。

电商系统中,商品中心是商品数据的“权威源“(Source of Truth),存储在关系型数据库(如 MySQL、TiDB)中。而搜索中心使用专业的搜索引擎(如 Elasticsearch、OpenSearch)构建倒排索引。搜索中心本质上不是另一套“商品数据库“,而是商品中心数据的 索引视图。因此,索引视图的构建质量直接决定了搜索、导购和详情链路上所有查询的可用性和准确性。

整体架构流程

flowchart LR
    subgraph 商品中心
        DB["商品中心 DB(MySQL / TiDB)"]
    end
    
    subgraph 数据同步层
        CDC["CDC 捕获(Canal / Flink CDC)"]
        MQ["消息队列(Kafka / RocketMQ)"]
    end
    
    subgraph 搜索中心
        Consumer["搜索索引构建服务"]
        ES["搜索索引(Elasticsearch)"]
        API["搜索服务 API"]
    end
    
    subgraph 消费端
        Frontend["前端 / APP / 推荐系统"]
    end
    
    DB -->|"Binlog(INSERT/UPDATE/DELETE)"| CDC
    DB -->|"关键事件主动发布"| MQ
    CDC -->|"实时推送"| MQ
    MQ -->|"消费变更事件"| Consumer
    Consumer -->|"index / update / delete"| ES
    ES -->|"查询"| API
    API -->|"搜索结果"| Frontend

这条链路的核心思想是:商品中心负责写入真相,搜索中心通过可靠的异步管道构建索引视图,搜索服务再基于索引视图对外提供高吞吐的读能力。

数据同步机制

搜索中心不能直接读写商品中心的库,而是通过以下方式同步数据。

(1)增量同步(推荐主路径)

  • CDC(Change Data Capture):使用 Canal、Flink CDC、Debezium 等工具监听商品中心数据库的 Binlog,捕获 INSERT / UPDATE / DELETE 操作,实时推送到 Kafka。
  • 消息队列主动发布:商品中心在商品创建、更新、上下架、价格变更等关键事件时,主动发布事件到 Kafka / RocketMQ。搜索中心消费者订阅消息,解析后更新索引。

(2)全量同步

定时任务(每天 / 每周)或手动触发,通过 DataX、Spark 等工具全量导出商品数据重建索引。用于新品类上线、索引重建、Mapping 字段调整等场景。

(3)一致性保证

  • 搜索结果是 最终一致,允许秒级延迟(电商搜索通常可接受 1~5 秒)。
  • 增量管道结合 Kafka 分区键(使用 spu_id),保证同一商品的消息有序消费。
  • 使用版本号或 seq_no 控制更新顺序,防止消息乱序导致旧数据覆盖新数据(详见 3.3.4)。

字段选择、映射与 ETL

从商品中心提取关键字段并映射到搜索索引,是索引构建中最核心的设计决策。

字段类型示例字段映射类型(Elasticsearch)用途
全文检索title, subtitle, desctext(IK / jieba 分词)搜索匹配
精确匹配 / 过滤spu_id, sku_id, brand_id, category_id, statuskeyword精确过滤
数值price, sale_price, stock, sales, ratinginteger / float / scaled_float范围查询、排序
数组 / 分面attrs(颜色、尺寸等规格属性), tagsnested / object多维度筛选
地理locationgeo_point附近门店 / LBS 排序
权重 / 时间create_time, update_time, weightdate / integer排序、boost

在写入索引之前,通常还需要经过一层轻量 ETL:

  • 清洗:去除 HTML 标签、特殊字符,统一单位。
  • 丰富:拆分 SKU 属性为独立字段或 nested 对象;生成搜索建议(completion suggester);加入同义词(“手机”→“智能手机”);计算动态权重(销量 × 0.4 + 评分 × 0.3 + 新品加分等)。
  • 去重:以 SPU 为维度聚合 SKU 信息。

索引构建方式:

  • 单索引 + alias:大多数情况用一个主索引 + alias 切换。
  • 零停机重建:新建索引 products-2026-06-10-v2 → 全量导入 → 验证通过后切换 alias → 删除旧索引。
    • 中小型项目用 products_v2 命名即可。
    • 中大型或亿级以上数据,推荐日期命名 + alias 的方式,天然支持滚动索引和按时间归档。

版本控制与更新顺序保证

搜索结果允许最终一致,但绝不能出现“旧数据覆盖新数据“。在 CDC / 消息队列场景下,网络乱序、消费者重平衡等都可能导致消息乱序到达,因此必须引入版本控制。

核心机制:

  • Elasticsearch 内置 _version:每个文档都有内部版本号。使用 version_type=external 时,Elasticsearch 直接使用你传入的版本号(如商品 update_time 的毫秒时间戳,或数据库自增 seq_no)。只有当传入版本严格大于当前版本时才会写入,否则返回 409 Conflict。
  • 消费者侧 seq_no 比对:在消费 Kafka 消息时,消费者先提取消息中携带的业务 seq_no,查询当前索引中该文档已存的 seq_no(自定义字段),仅在新 seq_no 更大时才执行更新。
  • Kafka 分区键:使用 spu_id 作为分区键,保证同一商品的消息天然有序。

这套机制的核心原则是:

用版本号做乐观锁,旧消息到了直接丢弃,新消息到了才覆盖。网络乱序不伤害数据正确性。

Nested 对象与 SKU 规格处理

Nested 是 Elasticsearch 中处理一对多关联字段的关键技术,在电商商品属性(多规格筛选)上几乎是必备。

为什么需要 Nested:普通 object 类型在数组场景下会被扁平化,导致 key-value 关联丢失。例如商品属性 [{"颜色": "红色"}, {"尺寸": "XL"}],用 object 存储后,ES 会把所有 key 和 value 拆成两个独立集合,查询 颜色=红色 AND 尺寸=XL 时可能错误匹配到其他组合。而 nested 把每个对象作为独立的内部子文档存储,查询时必须使用 nested query,才能保证字段间的关联性。

电商典型用途:

  • SKU 规格组合(颜色 + 尺码 + 版本)
  • 商品动态属性(不同类目下的属性差异大)
  • 商品标签列表、促销信息

注意事项:nested 会增加索引体积和查询开销(内部是隐藏的子文档),建议单个文档内 nested 对象不超过几百个。对于高频筛选的关键属性(如 color、size),可以同时提升为顶层 keyword 字段以加速过滤。

高并发与大促下的索引写入保护

搜索索引不仅要承受高并发查询,还要承受商品变更带来的写入压力。大促期间,库存和价格高频变更会把写入放大到峰值,读写混合压力下 Elasticsearch 容易出现 refresh 积压和 GC 抖动。

工程上的保护策略通常包括:

  • 价格与库存不压进搜索索引:只在 ES 里保留用于排序的粗粒度 base_min_price 和 has_stock 标记,真实价格和库存摘要通过 Hydrate 层从商品中心、计价、库存服务实时补齐。这是 3.5、3.6、3.7 三张时序图的共同前提。
  • 写入限流与批量提交:索引构建服务控制写入并发度,合并小请求为 _bulk 批量提交。
  • 冷热分离:热数据(在售商品)与冷数据(下架 / 历史商品)分索引存放,减少单索引体积和写入放大面。

14.3.4 场景二:搜索结果页查询时序图

这张图的主旨是:ES 负责召回、筛选、粗排,Hydrate 负责把搜索骨架补成用户能看的卡片。商品骨架 + 库存摘要并行查询,再查营销,最后请求计价(因为计价依赖营销结果)。

sequenceDiagram
    participant U as 用户
    participant FE as 前端
    participant GW as API Gateway
    participant S as 搜索与导购服务
    participant ES as 搜索索引
    participant H as Hydrate 聚合层
    participant P as 商品中心
    participant PR as 计价中心
    participant IV as 库存摘要服务
    participant MK as 营销露出服务

    U->>FE: 输入关键词 / 进入类目页
    FE->>GW: SearchRequest
    GW->>S: 统一导购查询
    S->>ES: Query + Recall + Rank
    ES-->>S: item_id 列表
    S->>H: 批量 Hydrate
    par 第一阶段并行
        H->>P: 批量取商品卡片信息
    and
        H->>IV: 批量取库存摘要
    end
    H->>MK: 基于商品信息批量取活动标签
    Note over H,MK: 营销结果返回后,Hydrate 才能继续向计价中心发起最后一个下游请求
    H->>PR: 基于商品信息 + 营销结果批量取展示价(最后请求)
    H-->>S: 合并卡片 DTO
    S-->>FE: 搜索 / 导购结果

关键点:

  • 搜索页拿到的是”可浏览卡片”,不是”可下单事实”。
  • 计价请求是最后一步,因为它依赖营销结果。

14.3.5 场景三:详情页聚合查询时序图

这张图的主旨是:详情页是一个多域聚合页——先拿商品正式态与履约规则作为骨架,再进入动态 Hydrate 补齐价格、库存、营销和履约摘要。详情页看到的动态值仍只是”当前展示真相”,后续进入结算与创单时仍需重新校验。

sequenceDiagram
    participant U as 用户
    participant FE as 前端
    participant GW as API Gateway
    participant D as 详情聚合服务
    participant P as 商品中心
    participant PR as 计价中心
    participant IV as 库存中心
    participant MK as 营销中心
    participant FL as 履约规则 / 配送服务

    U->>FE: 打开商品详情页
    FE->>GW: ItemDetailRequest(item_id)
    GW->>D: 详情聚合查询

    Note over D,FL: 第一层:静态骨架(商品正式态 + 履约规则)
    D->>P: 获取正式商品契约 / 履约规则
    P-->>D: 商品静态契约
    D->>FL: 获取配送时效 / 履约摘要
    FL-->>D: 履约摘要

    Note over D,MK: 第二层:动态 Hydrate(价格、库存、营销)
    D->>PR: 获取当前价 / 到手价
    D->>IV: 获取库存与限购摘要
    D->>MK: 获取活动、券与露出

    D-->>FE: 详情页 DTO(静态骨架 + 动态数据)

关键点:

  • 详情页看到的动态值仍然只是”当前展示真相”,不是最终交易事实。
  • 后续进入结算与创单时仍需重新校验价格、库存和权益。

14.3.6 场景四:详情页动态 Hydrate 与降级时序图

这张图承接 3.2.5、3.2.6 和 3.2.9 中关于详情页动静分离、大促降级和高并发保护的内容,用一条时序图把”正常路径 + 降级路径”同时表达清楚。

核心思想是:详情静态骨架优先返回,动态字段并行 / 半并行补齐;任一下游超时后,详情聚合服务按字段维度降级,而不是整页失败。

sequenceDiagram
    participant FE as 前端
    participant D as 详情聚合服务
    participant CDN as CDN / 静态缓存
    participant Redis as Redis 缓存
    participant P as 商品中心
    participant PR as 计价中心
    participant IV as 库存服务
    participant MK as 营销服务

    FE->>D: 请求详情页(item_id, user_id)

    Note over D,CDN: 第一步:静态骨架优先返回
    D->>CDN: 读静态骨架(标题、主图、详情片段)
    CDN-->>D: 静态骨架
    D-->>FE: 静态骨架流式返回 / 首屏 SSR

    Note over D,MK: 第二步:动态字段并行 Hydrate
    par 并行动态补齐
        D->>Redis: MGET 价格缓存
        alt 缓存命中
            Redis-->>D: 基础价 / 会员价矩阵
        else 缓存未命中
            D->>PR: 实时计价
            alt 计价正常
                PR-->>D: 到手价
            else 计价超时
                Note over D: 降级:展示基础销售价 / 指导价
            end
        end
    and
        D->>IV: 批量查库存摘要
        alt 库存查询正常
            IV-->>D: 库存摘要
        else 库存超时
            Note over D: 降级:展示”请以下单时校验为准”
        end
    and
        D->>MK: 批量查营销露出
        alt 营销查询正常
            MK-->>D: 活动标签 / 券信息
        else 营销超时
            Note over D: 降级:隐藏活动标签,不阻塞主页面
        end
    end

    Note over D: 第三步:聚合结果,按字段维度局部降级
    D-->>FE: 完整详情页 DTO(部分字段可见降级兜底值)

降级分支总结:

异常场景降级策略对用户体验的影响
计价超时展示基础销售价 / 指导价到手价可能不够精确,但不影响浏览决策
库存摘要超时展示”请以下单时校验为准”用户无法在详情页看到库存余量
营销露出超时隐藏活动标签用户看不到当前活动权益,但不阻塞页面
Redis 缓存异常走本地缓存 / CDN 兜底动态数据可能不够新鲜,但不影响页面打开

14.3.7 特殊场景:酒店搜索与动态报价

酒店搜索比普通实物电商更复杂,因为它的库存和价格天然带有 日期维度 与 用户上下文。同一家酒店,在不同入住日、房型、会员等级和连住天数下,真实价格和真实可售状态都可能完全不同。本节统一收口前面积累的酒店案例,用酒店证明:当商品是强时空属性资源时,搜索和详情链路必须做专门的动态报价与库存治理。

酒店哪些信息放 ES,哪些信息回商品中心 / 报价引擎

酒店搜索的字段边界比标品电商更严格:

  • 来自搜索中心 / ES 的信息:
    • 酒店名称、别名、地址、经纬度、星级、品牌、设施标签、商圈、地标、基础房型名
    • 粗粒度的 has_room
    • 用于价格粗排的 base_min_price
  • 来自商品中心 / 报价引擎的实时信息:
    • 指定入住日到离店日之间的真实可售房态
    • 具体房型在该日期区间下的实时到手价、会员价、连住价
    • 退改政策、确认时效、最晚保留时间

所以酒店搜索的标准模式通常是:

  1. ES 先负责按城市、星级、地理位置、设施等条件召回和粗排。
  2. 搜索服务拿着 hotel_ids + checkin + checkout + user_id 去商品中心 / 计价 / 库存资源域批量查询。
  3. 用实时价格和真实房态覆盖掉 ES 的粗粒度字段,再返回给前端。

这也解释了为什么列表页不能直接把 ES 的价格和库存当成交易真相:

  • ES 擅长做大规模检索和排序。
  • 商品中心、计价、库存才是交易前最后的权威真相。

酒店列表页如何做价格排序

酒店搜索里,”按价格从低到高排序”是一个典型的工程取舍点。难点在于:ES 必须先拿到一个字段值才能完成全局排序,但酒店的真实价格又是动态计算出来的,它同时受到入住日期、连住天数、会员等级、售卖计划、实时促销和税费规则影响。

如果试图把”所有用户、所有日期、所有房型组合下的真实到手价”全部塞进 ES,不但索引维度会爆炸,写入频率也会把搜索集群拖垮。因此主结论固定为:

  1. ES 按 base_min_price 粗排:ES 用基础价完成全局排序和分页。
  2. 当前页实时查 30 家:搜索服务拿当前页酒店 ID 去商品中心 / 计价引擎算真实价。
  3. 当前页内存二次微调排序:对这 30 条结果按真实价做轻量重排,确保用户看到的顺序基于真实到手价。

可以把它理解成两层排序:

  • 第一层:ES 粗排
    • 目标:快速筛出大体上价格更低的候选酒店。
    • 使用字段:base_min_price、地理距离、评分等低频或可容忍滞后的排序因子。
  • 第二层:当前页精展 + 微调
    • 目标:把当前用户、当前日期区间下的真实到手价展示给用户,并在当前页内按真实价微调顺序。
    • 使用输入:hotel_ids + checkin + checkout + user_id + member_level。

这种做法的优点是:

  • ES 可以继续承担高并发检索和翻页,不会因为实时价格频繁变动而被拖垮。
  • 商品中心只需要对当前页有限数量的酒店做实时算价,成本可控。
  • 最终展示价足够新鲜,不至于让用户看到完全错误的成交价格。

它的代价也要诚实承认:可能存在轻微排序错位。例如某酒店在 ES 粗排时基础价更高,但在当前会员和促销条件下真实价反而更低。工程上通常接受这种小范围偏差,因为相比”绝对精确排序”,大促和高并发场景下的系统可用性更重要。

深翻页怎么处理

在 ES 里,深翻页不是看”一个城市总共有多少家酒店”,而是看 from + size 有多深。默认情况下,max_result_window = 10000,超过这个窗口 ES 会直接拒绝请求。

因此,”一个城市 1000 家酒店”本身不算问题;真正的问题是:

  • 用户是否在持续向后翻页;
  • 系统是否还想对越往后的结果继续做高成本的实时算价和二次精排。

这也是酒店搜索比普通商品搜索更难的地方。因为一旦用户选择”按价格排序”,后端往往不只是简单翻页,而是在做:

  1. ES 先按基础价召回一批候选;
  2. 商品中心对候选酒店批量算真实价;
  3. 搜索服务在内存里重新精排;
  4. 再截取当前页返回。

如果对所有翻页都维持这套逻辑,哪怕用户只翻到第 3 页,后端也可能已经在做”前 100 条甚至前 300 条候选的批量算价和内存重排”,这就进入了类深翻页问题:不是 ES 一定先死,而是商品中心和 Hydrate 编排先被拖垮。

酒店搜索里更稳妥的处理方式通常有三层:

  1. 产品侧先限深

    • C 端列表采用懒加载,而不是允许无限页码跳转。
    • 滚到较深位置时,优先引导用户缩小日期、商圈、价格区间、设施等筛选范围。
    • 这一步往往比任何底层技术优化都更有效。
  2. ES 侧用 search_after,不用无脑 from + size

    • 对 C 端连续下拉场景,使用 search_after 更合适。
    • 它不支持任意跳页,但非常适合移动端”下一页、再下一页”的滚动浏览。
    • 这样可以避免 ES 为了翻到更后面的页而反复丢弃前面的大量结果。
  3. 精排只保证前 N 条,后面自动降级

    • 可以定义一个”最高精排桶”,例如只保证前 100 家酒店的价格排序绝对精准。
    • 前 100 条以内:走”扩大召回 + 批量算价 + 内存精排”。
    • 超过 100 条之后:只对当前页做实时价覆盖,不再对更大候选集做二次精排。
    • 这样既保住前几屏的用户体验,也不会让后端为极低转化概率的深页结果付出无限成本。

为了进一步保护商品中心的报价引擎,通常还会加一层 批量报价缓存:

  • 当用户带着同一组条件(如入住日、离店日、人数、会员等级)连续翻页时;
  • 商品中心第一次批量算出来的结果可以短暂缓存;
  • 后续翻页优先走缓存或 MGET,而不是每翻一页都重新全量计算。

因此,这里的推荐结论可以落成:

酒店搜索要把”深翻页问题”和”动态价格排序问题”一起看。ES 负责可扩展的游标翻页,商品中心只为前部高价值结果做精排和算价,越往后越要主动降级,而不是对所有结果做无限精确排序。

房态与价格的一致性怎么处理

酒店搜索里还有一个非常高频的决策点:列表页到底应不应该实时去查 30 家酒店的价格和房态。

这个问题没有统一答案,关键看两件事:

  1. 单次批量查询的真实耗时能不能稳定控制在可接受范围内。
  2. 高并发、大促、爬虫和跨境供应商抖动时,底层资源层能不能扛住放大的吞吐压力。

如果压测结果表明:

  • 同一批次实时查询 30 家酒店的价格与房态只需大约 100ms;
  • 且这条链路经过了限流、隔离、超时和降级设计;

那么列表页采用”ES 粗排 + 当前页 30 家实时查询”是完全合理的,并且会比死缓存带来更好的用户体验。因为它能显著减少”列表页看到 300 元有房,点进详情页变成 500 元或满房”的落差。

但这里真正的难点,不是”单次 100ms 能不能做到”,而是以下三个工程问题。

第一,怎么扛住高并发下的整体吞吐量。

单次查 30 家只要 100ms,不代表在大促、暑期、国庆或被外部爬虫高频抓取时依然成立。因为一旦搜索 QPS 被放大,请求总量会迅速变成:

  • 搜索 QPS × 每次查询酒店数

这时候真正需要保护的不是搜索服务本身,而是后面的:

  • 商品中心 / 报价引擎
  • 库存 / 房态资源层
  • 海外供应商接口

因此更稳妥的做法是:

  • 在搜索服务到资源层之间做 线程池隔离 / 舱壁隔离
  • 对供应商或报价 RPC 做 超时控制与限流
  • 对异常来源流量做 防刷与防爬
  • 超过保护阈值时,自动退化为”ES 基础价 + 粗房态”模式

也就是说,实时查 30 家可以作为主路径,但必须有明确的降级开关。

第二,怎么避免慢供应商拖垮整个列表页。

酒店列表经常会混入海外供应商或跨境资源方,它们的接口 RT 波动可能远大于本地酒店资源系统。如果 30 家里有 2~3 家供应商超时,不能让整页结果跟着被拖慢。

因此列表页更推荐采用:

  • 批量并行查询
  • 严格的总超时预算
  • 单酒店或单供应商分支的独立超时中断

在这种模式下:

  • 100ms 内成功返回的酒店正常展示实时价
  • 超时或失败的酒店退化成 ES 基础价、基础房态或”参考价”文案
  • 绝不允许少数慢分支把整页响应时间拉穿

第三,实时查和价格排序怎么同时成立。

这正是为什么 3.7.2 里强调”ES 粗排,当前页实时价覆盖 + 局部内存微调”。因为即使当前页 30 家的实时查询只要 100ms,ES 在全局排序时也不可能提前知道所有酒店对当前用户、当前入住日期下的真实到手价。所以更现实的做法仍然是:

  1. ES 先按 base_min_price 做全局粗排。
  2. 取出当前页 30 家酒店 ID。
  3. 搜索服务实时查这 30 家的真实价格与真实房态。
  4. 在内存中对这 30 条结果做一次轻量二次微调排序。

同时,必须在进入详情或结算时再做更强的校验,因为:

  • 列表页允许轻微延迟;
  • 当前页 30 家虽然可实时查,但受总超时预算约束;
  • 用户点进详情或进入结算时,才是真正要收紧校验口径的节点。

因此,这里的推荐结论不是”必须缓存”或”必须实时查”,而是:

酒店列表页可以实时查当前页 30 家数据,但前提是这条路径经过限流、隔离、超时和降级保护;真正的主架构仍然是 ES 负责粗排,商品中心 / 报价引擎负责当前页实时修正;进入详情和结算时再做更强的真实性校验。

14.3.8 搜索、导购与详情链路的统一架构原则

最后,用 4 句话收口,和第 4、5、6 节保持风格一致:

  1. 搜索负责召回和排序,不负责给出交易最终真相。
  2. 详情页负责多域聚合展示,不负责生成交易事实。
  3. 搜索与详情允许弱一致和局部降级,但结算与订单必须强校验。
  4. ES 提供骨架,商品、库存、营销、计价提供动态血肉,订单只认快照与凭证。

把这 4 句话和第 3 节的技术要点、时序图、酒店特殊场景串在一起,搜索、导购与详情链路的全貌就已经足够清晰,也为后续第 4 节”购物车与结算链路”和第 5 节”下单、支付与订单编排链路”做好了准确的边界铺垫。


14.4 购物车、结算与资源预占

14.4.1 场景画像

购物车和结算虽然常常出现在同一个前端页面体系里,但它们在系统设计上承担完全不同的角色:

  • 购物车是“意愿篮”,弱一致、可长期暂存。
  • 结算页是“交易前总校验器”,会触发价格试算、库存预占和优惠校验。

用户从加购到结算,通常会依次跨过下面几种典型动作:

  1. 未登录用户把商品先放进匿名购物车。
  2. 登录后把匿名购物车和账号购物车合并。
  3. 在购物车里勾选部分商品进入结算页。
  4. 结算服务对商品、价格、库存、权益、运费做一次交易前汇总校验。
  5. 用户点击“提交订单”前,系统再次确认这些结算结果是否还有效。

因此,购物车与结算链路的本质不是“把商品列出来然后直接下单”,而是:

  • 购物车负责保存用户意图;
  • 结算负责把用户意图收敛成一次可提交的交易尝试;
  • 订单创建必须建立在结算凭证仍然有效的前提之上。

14.4.2 关键技术点

为什么购物车不锁库存,而要等到结算阶段才预占

购物车天然是一个长生命周期容器,商品可能被用户放进去几分钟、几小时,甚至几天。如果在加购时就锁库存,会出现两个严重问题:

  • 资源被大量无效占用,真实买家反而看见“无货”;
  • 购物车会从“意愿篮”退化成“半订单系统”,导致系统复杂度失控。

因此更合理的边界是:

  • 加购阶段只记录意愿,不锁任何资源;
  • 结算阶段才做短 TTL 的库存预占和权益占用。

为什么购物车服务和结算服务要分开

购物车服务面对的是:

  • 高读写、弱一致、频繁加减数量;
  • 匿名态与登录态合并;
  • 失效商品标记、限购截断、展示优化。

结算服务面对的是:

  • 商品正式态校验;
  • 价格试算与营销试算;
  • 库存预占、权益占用;
  • 生成一组可供订单提交消费的结算凭证。

二者虽然都服务于“买东西”,但读写模型完全不同。如果把它们揉进一个服务里,最终会变成:

  • 购物车流量冲击交易校验逻辑;
  • 结算逻辑污染购物车的简单读写路径;
  • 系统很难分别做缓存、限流、降级和扩展。

所以更清晰的职责划分是:

  • 购物车服务负责意愿存储和合并;
  • 结算服务负责交易前编排和凭证生成。

结算页为什么本质上是一笔短生命周期 Saga

进入结算页时,系统需要跨多个域临时拿到“当前这笔交易是否可成立”的真相:

  • 商品是否仍然可售;
  • 当前价格和优惠是否仍有效;
  • 库存是否足够;
  • 地址、运费、履约规则是否匹配。

这些结果不是永久事实,而是一组瞬时成立的交易前提。因此结算页本质上不是订单,而是一笔短生命周期的 Saga 编排,其输出通常包括:

  • price_token / price_snapshot_id
  • reserve_ids
  • coupon_tokens / benefit_tokens
  • freight_snapshot

这些凭证只在短时间内有效,供后续提交订单消费。

结算页为什么必须返回凭证,而不是只返回一个总价

如果结算页只把“总价 299 元”返回给前端,而不携带任何可验证的结算凭证,那么提交订单时系统无法判断:

  • 这个价格是不是旧价格;
  • 这个优惠是不是已经失效;
  • 这批库存是不是已经被别人买走;
  • 用户有没有抓包篡改前端金额。

因此结算页必须返回可验证的凭证集合,而不是一个纯展示结果。到了提交订单阶段,结算服务或订单中心才能拿这些凭证再次校验,确认这次提交是否仍然合法。

购物车合并为什么不是简单的“两个列表拼起来”

匿名购物车和登录购物车合并时,至少要处理下面几类现实问题:

  • 同一 sku 在两个桶里都存在,需要合并数量;
  • 合并后可能触发限购上限,需要截断;
  • 某些商品已经下架、缺货或不可售,需要打失效标记;
  • 某些商品规格变了、价格变了,需要提示刷新;
  • 不同端(H5、App、小程序)带来的本地购物车格式可能不同。

所以购物车合并的正确语义不是“简单拼数组”,而是:

以用户账号购物车为权威桶,对匿名购物车做一次规则化并入。

结算确认为什么必须支持“失效与刷新”

用户进入结算页到真正提交订单,中间可能经过几十秒甚至几分钟。期间世界已经变了:

  • 商品被下架;
  • 价格发生变化;
  • 优惠券被别的订单占用了;
  • 库存被抢空;
  • 配送范围或运费模板发生变化。

所以结算确认一定要支持:

  • 显式校验失效;
  • 告知前端“哪些条件失效了”;
  • 允许用户一键刷新结算页重新生成凭证。

这也是为什么订单系统只认校验通过后的结算凭证,而不认用户页面上肉眼看到的旧数据。

购物车数据模型:把意愿和展示状态分开

购物车最容易被低估的地方,是它同时承载了用户意愿、商品展示和跨端同步三个问题。建议将购物车项拆成“用户选择字段”和“系统观察字段”:

CartItem
├── user_id / cart_id
├── sku_id / quantity / selected
├── added_at / updated_at
├── client_source / merge_version
├── last_seen_product_version
├── display_price_snapshot(仅展示)
├── invalid_reason(缺货、下架、超限、规格失效)
└── extension(端侧可扩展字段)

其中 quantity、selected 和 sku_id 是用户意愿,display_price_snapshot 和 invalid_reason 是系统观察结果,不能让后者覆盖前者。用户打开购物车时看到“价格已变动”,仍然可以保留该项并让用户决定是否刷新;系统也不能因为一次价格查询失败就静默删除购物车项。

在存储上,热点购物车适合使用按用户或购物车分桶的 KV 结构,必要时使用 Hash 保存商品项。Redis Hash 适合对字段进行增量更新,但它本身不提供跨多个业务动作的完整交易语义,因此“修改购物车、记录版本、写事件”不能仅凭多次普通命令完成。[9] 购物车主存可以使用 Redis 提升响应速度,同时以异步备份或可重建事件保留恢复能力;恢复时应以最新有效版本合并,而不是用旧备份覆盖用户最近一次操作。

匿名购物车、登录合并与并发写

匿名购物车合并至少存在三种并发:用户在多个设备同时操作、登录合并与另一个加购请求并发、过期清理与用户读取并发。可以为每个购物车维护 cart_version,写操作携带客户端已知版本,服务端使用乐观并发校验:

  1. 读取购物车版本 v。
  2. 计算本次加购、删除或合并后的候选状态。
  3. 只有当前版本仍为 v 时才提交为 v+1。
  4. 版本冲突时重新读取并按业务规则重放用户操作。

合并动作要记录 merge_id,这样同一个登录重试不会将匿名数量重复并入账号购物车。对同一 sku 的数量合并可采用“相加后按限购截断”,但对不同销售主体、不同履约仓或不同促销资格的商品,不能仅凭 sku_id 合并,必须把销售主体和履约约束作为聚合键。这个判断属于本书对常见电商模型的推导,具体聚合键应由商品、库存和营销领域共同定义。

结算凭证的生命周期与安全边界

结算凭证不是把金额加密后放进前端的字符串,而是服务端可查询、可撤销、可审计的资源引用。推荐的状态机如下:

CREATED -> VALIDATING -> RESERVED -> CONSUMED
    |          |           |
    |          |           +--> RELEASED
    |          +--------------> EXPIRED
    +-------------------------> INVALID

每个凭证应至少绑定用户、购物车或会话、商品版本、数量、金额摘要、资源 ID、创建时间和过期时间。提交订单时要校验:

  • 凭证归属的用户与当前认证身份一致;
  • sku_id、数量、销售主体和履约方式没有被替换;
  • 价格版本和营销规则仍在允许窗口内;
  • 预占资源处于可消费状态,且没有被其他订单消费;
  • 凭证只被当前订单消费一次。

如果凭证放入 Redis,EXPIRE 可以限制自然过期,但过期并不等于所有外部资源都自动回补;库存和优惠仍需要显式 Release 或由延迟任务扫描。[10] 凭证消费可以使用一次性状态转移或 Lua 原子脚本,不能依赖“先查询再删除”的两个非原子步骤。Stripe 的幂等请求实践也提醒我们,幂等键应与请求参数绑定并保留足够长的窗口,防止同一键被用于不同业务请求。[19]

资源预占的补偿矩阵

结算编排不能只写成功路径,还要为每个已成功动作指定回滚动作和兜底查询:

已完成动作后续失败同步动作异步兜底最终状态
商品校验通过价格校验失败直接返回失效项无CHECKOUT_REJECTED
价格试算成功库存预占失败取消价格上下文过期清理价格上下文CHECKOUT_REJECTED
库存预占成功营销占用失败释放库存预占超时扫描RESERVE_RELEASED
营销占用成功订单落库失败释放库存和权益按 checkout_id 反查订单COMPENSATING
订单已落库支付未完成保持待支付超时关闭并回补CLOSED / EXPIRED

这张表的关键不是“每一步都同步回滚”,而是为每一步保留可识别的凭证和可重试的补偿入口。Transactional Outbox 与 Polling Publisher 模式把本地事务中的业务事实和待发送消息绑定,再由发布器可靠投递,适合把订单落库与补偿事件连接起来。[17][18] 但 Outbox 只解决“事件不丢”的一部分问题,消费端仍必须幂等,外部资源仍必须支持查询和撤销。

结算性能预算与降级顺序

结算是交易前同步链路,不能把所有可选权益都堆进主请求。可以按以下优先级做预算:

优先级依赖超时或不可用时的处理
P0商品可售、库存预占、订单约束直接阻断提交
P1基础计价、运费、支付方式返回可解释错误,允许重试或更换方式
P2优惠券、会员权益、推荐权益在明确告知的前提下关闭复杂优惠或重新计算
P3推荐、埋点、个性化展示静默降级,不影响创单

每个下游要有独立超时、并发上限和熔断状态;不能因为营销服务重试,就拖住库存连接池。Google SRE 将过载处理视为服务设计的一部分,建议通过负载削减、排队和降级保护关键路径。[5] 本章因此把“优惠计算超时”定义为可解释失败,把“推荐超时”定义为无感降级,并把这两种结果写入统一的结算诊断字段,便于后续分析转化损失。

14.4.3 场景一:加购与购物车合并时序图

sequenceDiagram
    participant U as 用户
    participant FE as 前端
    participant C as 购物车服务
    participant R as Redis 购物车主存
    participant DB as 购物车备份表

    U->>FE: 点击加购
    FE->>C: AddToCart(item_id, sku_id, qty)
    C->>R: HSET / HINCRBY
    C-->>FE: 加购成功
    C->>DB: 异步落库备份

    U->>FE: 登录
    FE->>C: MergeCart(cart_token, user_id)
    C->>R: 读取匿名桶和用户桶
    C->>R: 执行合并、限购截断、失效标记
    C-->>FE: 返回合并后的购物车

14.4.4 场景二:进入结算页的 Saga 编排时序图

sequenceDiagram
    participant U as 用户
    participant FE as 前端
    participant CO as 结算服务
    participant P as 商品中心
    participant PR as 计价系统
    participant IV as 库存中心
    participant MK as 营销中心
    participant AD as 地址 / 运费服务

    U->>FE: 进入结算页
    FE->>CO: CheckoutPreview(selected_items)
    CO->>P: 批量校验商品正式态与履约规则
    CO->>PR: 价格试算
    CO->>IV: 预占库存
    CO->>MK: 权益校验 / 占用
    CO->>AD: 地址与运费计算
    CO-->>FE: 结算确认页(price_token, reserve_ids, coupon_tokens, freight_snapshot)

14.4.5 场景三:结算确认页失效与刷新时序图

sequenceDiagram
    participant FE as 前端
    participant CO as 结算服务
    participant P as 商品中心
    participant PR as 计价系统
    participant IV as 库存中心
    participant MK as 营销中心

    FE->>CO: SubmitCheckout(price_token, reserve_ids, coupon_tokens)
    CO->>P: 校验商品版本
    CO->>PR: 校验价格凭证是否仍有效
    CO->>IV: 校验预占是否仍有效
    CO->>MK: 校验权益占用是否仍有效
    alt 任一凭证失效
        CO-->>FE: CHECKOUT_EXPIRED / BENEFIT_INVALID,需要刷新
    else 校验全部通过
        CO-->>FE: ALLOW_CREATE_ORDER
    end

14.4.6 购物车与结算链路的统一架构原则

购物车保存意愿,结算生成凭证;提交前校验后才能落订单事实。


14.5 搜索读链路的可靠性与治理

搜索读链路需要一组独立于交易写链路的观测面板:索引事件延迟和版本差异、Query 归一化失败率、各路召回贡献、零结果率、Rank P99、search_after 游标失效率、Hydrate 端到端成功率、各依赖超时比例、热点缓存命中率、实验分桶差异以及用户从搜索到详情的价格投诉率。指标必须按 scene、站点、类目、索引版本和实验版本切分,否则总体平均值会掩盖某个热门类目或某个排序版本的故障。分布式系统监控应同时关注技术信号和用户可感知结果,并让告警对应明确的恢复动作。[20]

搜索问题的告警也应关联可行动的恢复入口:索引延迟升高触发消费者扩容或重放,版本差异触发对账队列,ES 慢查询触发高成本 DSL 限制,Hydrate 依赖超时触发字段级降级,零结果异常触发词典和过滤规则检查。把指标、用户语义和恢复动作绑定起来,才能避免“搜索接口成功率 99.9%”却已经让大批用户找不到可售商品的假健康状态。

搜索读链路的故障矩阵

搜索故障通常表现为找不到商品、排序异常或页面变慢。下面是本章根据版本化投影、隔离和可观测性原则设计的恢复矩阵,不是 Elasticsearch 自动提供的全套业务保证。[5][11][20]

故障点用户语义恢复动作不可越过的边界
索引事件积压结果可能滞后在下游预算内扩容或重放,按聚合版本追平并告警不把旧索引当正式商品状态
旧事件乱序单个商品短暂陈旧对状态投影做版本条件更新;缺失事实另行反查不让旧文档覆盖新版本
重建索引失败保留可用旧索引重试构建、校验水位,保留旧别名并受控切换不让半成品索引接管读流量
ES 慢查询或不可用受控缓存、缩小召回或稍后重试限制高成本查询、熔断、回退排序缓存结果仍受合规、权限与下架约束
Hydrate 部分超时参考价或库存确认中按字段局部降级、标识来源和时间,记录依赖超时旧价格不是实时成交价
热点 query / suggest 洪峰延迟或限流独立配额、本地缓存、热点资源隔离不挤占结算与支付资源
零结果率异常纠错或类目浏览检查索引版本、词典、过滤条件及事件管道不为扩大召回放宽合规约束
实验排序异常回退稳定排序按实验版本停止流量并处理对应缓存不只看 CTR,忽略成交、投诉与权限

索引对账至少抽样比较主数据版本、可搜索状态、类目和删除事实;查询治理保留归一化 query、scene、rank 版本和超时分支。详情与结算要重新校验成交所需事实,索引修复不能替代价格确认、库存预占或订单状态推进。以上动作应有速率预算、可暂停开关和恢复后验证,避免重放流量再次拖垮交易链路。

14.6 本章总结、搜索 ADR 与评审清单

本章从商品发现一直走到结算凭证形成,建议记住三个判断:

  1. 搜索结果页是弱一致投影,详情页解释当前商品,结算和创单再校验交易事实。
  2. 购物车保存的是购买意愿,不代表库存或营销资源已被占用。
  3. 结算将价格、库存、营销和履约约束收敛为有期限、可校验的提交凭证。

14.6.1 ADR-搜索:采用“异步版本化索引 + 受控实时 Hydrate”

  • 背景与问题:搜索和导购需要承受远高于交易写路径的读流量,索引字段又存在异步延迟;价格、库存、营销和履约摘要随用户和时间变化,不能全部固化在 ES,也不能每次列表请求都串行访问所有权威系统。
  • 决策驱动因素:召回吞吐、尾延迟、结果可解释性、数据新鲜度、索引可重建、下游隔离和交易前事实安全。
  • 候选方案:所有字段实时查询;所有字段写入 ES;异步索引承载检索骨架并对动态字段做批量 Hydrate。
  • 最终决策:商品、上架和生命周期事件通过版本化管道构建可重建索引;统一 scene 查询服务执行 Query → Recall → Rank;当前页通过有预算的批量 Hydrate 补齐动态字段;详情、结算和创单再次访问权威域并进行强校验。
  • 获得的能力:高吞吐召回、可控的实时字段、索引别名切换与回滚、按依赖局部降级、排序实验可复现、索引和主数据可对账。
  • 主动牺牲的能力:搜索结果可能短暂滞后,列表价可能只是参考价;需要维护索引重建、版本追平、缓存失效和 Hydrate 降级机制;不能支持任意深页的强快照语义。
  • 已接受的风险:索引事件乱序、ES 慢查询、热点 query 和实时依赖超时会造成局部体验下降;通过版本条件更新、别名切换、search_after 限制、舱壁隔离和用户话术控制风险。
  • 验证指标:索引版本延迟、零结果率、Recall 候选量、Rank P99、Hydrate 成功率、动态字段缺失率、搜索到详情价格差异率、实验回滚时间和主数据对账差异数。
  • 重新评估条件:搜索结果需要法律或业务上不可接受的实时性,或动态字段规模使 Hydrate 成本持续超过索引冗余收益时,再评估按场景拆分读模型;不能直接把 ES 升级为交易事实库。

14.6.2 搜索与详情评审清单

评审搜索与详情读链路时,重点确认:

  1. 搜索请求是否有统一 scene、查询版本、排序版本和可回放的 query_id?
  2. 索引文档的来源版本、事件幂等、别名切换和全量重建是否可验证、可回滚?
  3. 深分页、热点 query、suggest、缓存 key 和实验分桶是否有边界与独立配额?
  4. 搜索降级时是否明确区分参考价、库存摘要和可成交事实,并在详情与结算阶段重新校验?

14.6.3 参考资料

[1] Hector Garcia-Molina, Kenneth Salem, “Sagas”, ACM SIGMOD Record, 1987, https://doi.org/10.1145/38713.38742

[2] Pat Helland, “Life Beyond Distributed Transactions: An Apostate’s Opinion”, CIDR, 2007, https://www.cidrdb.org/cidr2007/papers/cidr07p15.pdf

[3] Gregor Hohpe, Bobby Woolf, “Enterprise Integration Patterns”, official pattern catalog, https://www.enterpriseintegrationpatterns.com/

[4] Martin Kleppmann, “Designing Data-Intensive Applications”, author resources, 2017, https://martin.kleppmann.com/2017/03/27/designing-data-intensive-applications.html

[5] Google SRE, “Handling Overload”, Google SRE Book, https://sre.google/sre-book/handling-overload/

[6] Jeffrey Dean, Luiz André Barroso, “The Tail at Scale”, Communications of the ACM, 2013, https://research.google/pubs/the-tail-at-scale/

[7] Apache Kafka, “Design: Message Delivery Semantics / Using Transactions”, 4.1 版本文档, https://kafka.apache.org/41/design/design/,核验日期:2026-10-09。

[8] Apache Kafka, “Upgrade Notes: Idempotent and Transactional Producers”, official documentation, https://kafka.apache.org/38/getting-started/upgrade/

[9] Redis, “Hashes”, official documentation, https://redis.io/docs/latest/develop/data-types/hashes/

[10] Redis, “EXPIRE command”, official documentation, https://redis.io/docs/latest/commands/expire/

[11] Elastic, “Optimistic Concurrency Control”, Elasticsearch Reference, https://www.elastic.co/docs/reference/elasticsearch/rest-apis/optimistic-concurrency-control

[12] World Wide Web Consortium, “Trace Context”, W3C Recommendation, https://www.w3.org/TR/trace-context/

[13] OpenTelemetry, “General Semantic Conventions”, official specification, https://opentelemetry.io/docs/specs/semconv/general/

[14] IETF, “HTTP Semantics”, RFC 9110, 2022, https://datatracker.ietf.org/doc/rfc9110/

[15] IETF, “Problem Details for HTTP APIs”, RFC 9457, 2023, https://datatracker.ietf.org/doc/html/rfc9457

[16] Cloud Native Computing Foundation, “CloudEvents”, official specification, https://cloudevents.io/

[17] Chris Richardson, “Pattern: Transactional outbox”, microservices.io, https://microservices.io/patterns/data/transactional-outbox

[18] Chris Richardson, “Pattern: Polling publisher”, microservices.io, https://microservices.io/patterns/data/polling-publisher.html

[19] Stripe, “Idempotent requests”, official API documentation, https://docs.stripe.com/api/idempotent_requests

[20] Google SRE, “Monitoring Distributed Systems”, Google SRE Book, https://sre.google/sre-book/monitoring-distributed-systems/

第 15 章 电商订单与履约全生命周期:创单、支付、履约与售后

本章定位:本章承接第 14 章形成的结算凭证,从创单开始建立订单事实,继续讲解支付确认、履约推进、售后退款和长期治理。订单、支付与履约各自拥有明确的状态主权,并通过可重试、可对账的事实事件协作。

建议先阅读第 14 章《电商商品发现与交易准备》,了解搜索、购物车和结算如何形成交易输入;本章重点回答这些输入怎样转化为可恢复、可审计的订单与资金事实。

配合阅读:第 11 章 商品中心、商品供给与生命周期治理、第 12 章 库存系统、第 13 章 营销与计价系统。


15.1 创单、订单数据模型与订单状态机

15.1.1 场景画像

用户点击“提交订单”后,订单中心依据结算凭证落地商品、价格和履约快照,再由支付结果推进状态;失败、超时或风控拒绝必须释放库存与权益。

15.1.2 关键技术点

创单阶段的关键追问集中在幂等、凭证、快照、补偿和安全边界;下文用数据模型、状态机、时序和 ADR 分别展开,不在此重复问答表。

15.1.3 订单数据模型:主单、子单、订单项与快照

订单不是一个只有金额和状态的表,而是一组需要共同解释交易的事实集合。推荐按下面的关系拆分:

Order(主单)
├── OrderSubOrder(销售主体 / 履约主体 / 店铺维度)
│   ├── OrderItem(sku、数量、成交金额、资源引用)
│   ├── ProductSnapshot(标题、规格、图片、规则摘要)
│   ├── PriceSnapshot(原价、成交价、优惠分摊、税费、运费)
│   └── FulfillmentSnapshot(地址、履约方式、预约或核销规则)
├── PaymentOrder(支付单、渠道、支付状态)
├── InventoryReservationRef(预占引用与释放状态)
├── AfterSaleCase(售后单及其范围)
└── OrderEvent / Outbox(事实事件与待投递消息)

主单适合表达用户一次提交的交易聚合,子单表达销售主体或履约主体的独立推进,订单项表达可售卖的最小业务单位。支付单不能简单等同于主单,因为一个主单可以合并支付多个子单,一个子单也可能拆成多次退款。售后单也不应覆盖订单原始字段,而应引用订单项和快照,记录“针对哪一项事实提出了什么后续请求”。

快照至少要满足三个条件:第一,字段足以支持客服、售后和争议解释;第二,快照有生成时间、来源版本和摘要校验;第三,快照在订单创建后不可被普通商品更新覆盖。并不是所有商品富文本都要塞进订单库,推荐保留标题、规格、销售主体、成交价格、优惠分摊、履约规则摘要和必要的图片 / 文案版本,长描述和展示素材可以通过不可变版本存储或归档引用获取。

15.1.4 订单状态机与不变量

订单状态机要先区分“用户可见主状态”和“内部协作状态”。例如库存预占失败、支付渠道处理中、物流回调重复等情况,未必都需要新增一个用户可见状态,但必须在内部事件和诊断字段中留下痕迹。

标准实物订单可以抽象为:

CREATED -> PENDING_PAYMENT -> PAID -> FULFILLING -> SHIPPED
    |            |             |          |             |
    |            |             |          |             +--> DELIVERED
    |            |             |          +----------------> FULFILLMENT_FAILED
    |            |             +---------------------------> REFUNDING
    |            +------------------------------------------> CLOSED
    +-------------------------------------------------------> CANCELED

不同商品类型可以复用 CREATED、PENDING_PAYMENT、PAID、REFUNDING 等公共状态,再由履约子状态表达“待发码、已发码、已核销、已预约、已入住”等差异。状态迁移必须由事件和前置条件驱动,而不是由任意接口直接写字符串。建议为每条迁移记录:from_state、event_id、event_type、actor、occurred_at、reason_code 和 to_state。

至少要维护以下不变量:

  1. 已支付金额不能超过订单应付金额,退款累计不能超过已支付金额。
  2. 已确认的订单项数量不能大于提交时的数量,除非存在明确的拆单、换货或补发事实。
  3. 订单进入 PAID 前不能产生“支付成功”的用户可见语义;收到重复支付通知只能返回幂等成功。
  4. 订单进入 CANCELED 后不能再被普通支付成功事件推进为 PAID,异常情况必须转人工或进入纠错流程。
  5. 售后完成必须引用支付、履约和退款事实,不能只凭售后申请记录关闭订单。
  6. 主单状态由子单状态聚合得出时,聚合规则必须固定并可测试,不能依赖消息到达顺序。

这些不变量比“最终状态有哪些枚举值”更重要。事件乱序、重复和延迟是分布式系统的常态,Kafka 的交付语义文档也明确区分了至少一次、至多一次和恰好一次处理边界;业务状态机仍需要用事件 ID 和版本检查实现自己的幂等。[9] 因此,订单状态更新应使用 order_id + version 的乐观并发条件,更新失败时重新读取最新状态并判断事件是否已经生效。

15.1.5 创单本地事务与消息发布

提交订单的本地事务只做本域必须同时成立的事情:验证凭证归属、写入订单主单与子单、写入订单项和快照、创建支付单引用、生成初始状态事件或 Outbox 记录。它不应该在数据库事务中同步等待支付渠道、物流系统或所有回补动作。

事务边界可以表达为:

BEGIN
  consume checkout token
  insert order / sub_order / item
  insert snapshots
  insert payment_order
  insert outbox(OrderCreated)
COMMIT

publisher -> payment / inventory / marketing / fulfillment
consumer  -> idempotent state transition
reconciler -> query status and compensate

Outbox 记录和订单事实必须在同一个本地事务提交,否则会出现“订单已经创建但没有事件”或“事件已发送但订单并不存在”的裂缝。[18] 发布器允许重复发送,因此事件消费者要以 event_id 或业务聚合版本去重;不能把“发布器没有收到确认”误判成“业务没有执行”。当发布器连续失败时,要有积压指标、重试退避、死信和人工重放入口。

MySQL InnoDB 的事务模型可以为本地订单事实提供行级锁和隔离保证,但数据库事务只覆盖同一个资源边界,不能自动把外部支付和库存纳入同一个可靠提交。[13] 本章选择短本地事务加异步恢复,主动放弃跨服务瞬时原子可见,缩短本地锁持有窗口并减少跨域同步等待;实际吞吐仍需压测。这个牺牲必须在 ADR 中明确,不能包装成“系统已经一致”。

15.1.6 主单、子单与合并支付

当一次提交包含多个店铺、仓库或履约类型时,主单只是用户视角的聚合,真正可独立履约的单位是子单。设计时要明确三组映射:

关系例子作用
主单到子单一个主单拆为多个店铺子单展示一次提交与分别履约并存
子单到支付单多个子单共用一个支付单支持合并支付和统一收银台
订单项到资源一个订单项对应预占、券码或预约资源售后和补偿定位到最小范围

主单不能因为某个子单发货就直接显示“已完成”,也不能因为一个子单退款就把全部子单金额退回。可以定义聚合规则:全部子单支付完成才是主单支付完成;全部子单进入终态且没有待处理售后才是主单完成;部分退款以金额和订单项范围计算。聚合规则需要覆盖空集合、部分失败、部分取消和退款中的中间态。

15.1.7 创单时机、编排归属与提交流程

订单应该在“提交订单”时创建,还是在“点击支付”时创建

讨论创单时机时,先区分支付意图与正式订单。无论采用哪种方案,都应在请求渠道之前持久化可查询的交易意图、金额快照和幂等标识;“延后正式订单”不等于不记录用户已经发起的交易。还要区分支付授权与实际扣款:授权通常只是资金冻结,capture/扣款才形成实际资金变动,具体语义取决于支付渠道。

阶段方案 A:提交订单时创建待支付单方案 B:延后创建正式订单
用户提交/点击支付本地事务创建待支付订单、价格快照和支付意图先持久化 TradeIntent、金额快照和稳定幂等键;暂不创建正式订单
库存资格创建支付单前预占库存,记录过期时间与订单归属在请求支付授权或扣款前取得有效库存预占/购买资格;若业务无法保证资源,必须明确告知结果可能退款
渠道调用建立支付尝试后请求授权或扣款优先使用“先授权、后 capture”:授权后提交正式订单,再 capture;若渠道只支持即时扣款,则扣款前仍须持有有效库存资格
权威事实支付渠道成功结果经回调、主动查询或对账核验后记为支付事实;订单记录早已存在TradeIntent 是请求已受理的本地事实;渠道确认的授权/扣款是资金事实;正式订单须在本地提交后才算订单事实
失败收敛待支付超时后条件取消并幂等释放预占;未知支付结果先查单,不按超时直接关单授权后订单提交失败时撤销授权;即时扣款后订单暂不可写时保留 PAYMENT_SUCCEEDED_ORDER_PENDING 并重试落单;库存或业务资格最终不成立时按渠道能力幂等退款/冲正,并通知用户,未知结果先查单,不能直接发起新扣款

方案 B 的关键是把“减少正式订单写入”与“先扣款再争库存”分开。只要先扣款、后竞争库存,就可能产生已扣款但无资源的结果;后续退款可以收敛资金,却会增加用户等待、渠道费用和客服处置成本。即使 TradeIntent 已持久化,也不能把它当成正式订单或库存承诺。

决策约束方案 A 更合适的条件方案 B 可评估的条件
资源承诺需要进入收银台前确定订单、价格和库存有独立的短时资格/库存令牌,或商品本身不受稀缺库存限制
支付渠道普通支付即可,支付成功后更新已有订单支持授权与 capture/撤销,或即时扣款后的退款流程和对账能力成熟
高峰治理可用限流、排队、分段库存和异步下游降低压力已测量正式订单写入确为瓶颈,且 TradeIntent、幂等、查单、退款和客服链路具备容量
用户与资金风险需要稳定的待支付订单、催付和继续支付体验可以接受短暂“支付确认中”,并能对退款时限和用户告知负责

本章主线采用 方案 A:提交订单时创建待支付单:购物车 -> 结算 -> 提交订单 -> 支付 -> 履约。订单先承接商品、价格和履约快照;支付结果只推进既有订单状态。方案 B 只有在测量证明正式订单写入构成瓶颈,且库存资格、渠道授权/扣款、未知结果查单、退款与人工治理都已设计并演练时才值得采用。秒杀或高并发本身不足以构成采用方案 B 的理由。

订单提交流程应该由独立聚合服务编排,还是由订单中心自己协调下游

这也是交易架构里非常经典的一个分歧点,通常存在两种方案:

  • 方案 A:单独起一个结算 / 交易聚合服务
    • 由结算服务或 Trade-CO 统一去协调商品、库存、计价、营销,再把最终结果交给订单中心落单。
  • 方案 B:前端直接调用订单中心,由订单中心自己协调下游
    • 订单中心既管订单写库,又去调用商品、库存、计价、营销这些服务完成前置校验与编排。

这两种方案的本质区别是:交易主流程编排逻辑,是放在订单领域之外的场景聚合层,还是直接压进订单中心内部。

方案优点风险适用场景
独立聚合服务订单中心更纯净;读写压力与场景编排隔离;更适合复杂结算页和多业务协同多一跳 RPC;多一个服务需要治理中大型电商、业务复杂、团队分工清晰、存在独立预结算页
订单中心自己协调链路短;服务更少;前期开发快订单中心容易膨胀成万能大管家;读写混合;迭代风险高小团队、早期系统、短期快速上线

从工程演进角度看,很多系统早期会采用“订单中心自己协调”的方式快速上线,但随着以下问题出现,通常都会向独立聚合服务演进:

  • 结算页逻辑越来越复杂
  • 商品、计价、库存、营销团队开始独立演进
  • 订单中心既要承接创单写流量,又要承接大量预览与预结算读流量
  • 大促时希望把“重编排逻辑”和“订单写库主链路”物理隔离

本章默认采用的是 方案 A:独立结算聚合服务编排,下游原子服务各自归位。原因是:

  • 结算页本身就天然是一个跨商品、库存、计价、营销、地址的聚合场景;
  • 订单中心更适合回归为“交易事实持久化 + 状态机推进”的原子领域服务;
  • 支付、履约、售后后续也都更容易围绕清晰的订单事实展开。

因此这里的推荐结论是:

当系统存在独立的确认订单页 / 预结算页,且交易链路已经明显跨多个领域服务时,优先采用“结算聚合服务编排,下游原子服务落地”的架构;只有在系统非常轻量时,才让订单中心临时兼做协调者。

15.1.8 创单幂等、防重与超时反查

客户端看到“提交超时”时,服务端可能已经完成创单。正确处理不是立即再写一笔订单,而是按幂等键、业务订单号或结算凭证反查:

  1. 按 idempotency_key 查询提交记录。
  2. 查不到时按 checkout_id 和用户身份查询订单。
  3. 查到待支付订单,返回原订单并允许继续支付。
  4. 查到已支付订单,返回支付状态而不是重新创单。
  5. 仍查不到且凭证已过期,提示刷新结算,不继续消费旧资源。

对外接口应使用明确的 HTTP 语义和问题详情,区分参数错误、凭证过期、资源不足、处理中和服务暂不可用;RFC 9110 定义了 HTTP 语义,RFC 9457 则提供了机器可读的错误详情表达方式。[16][17] 本章建议错误响应至少包含 type、code、title、detail、retryable、request_id 和可选的 next_action,使前端能选择“重试、刷新、换货或联系人工”,而不是把所有错误显示成“系统繁忙”。

创单接口的幂等性应该怎么设计

创单接口的幂等性,不能只理解成“用户重复点按钮怎么办”。它真正要解决的是:

  • 同一个下单意图因为网络抖动、页面重试、MQ 重放或用户多次点击而重复到达时;
  • 系统最多只能创建一笔订单;
  • 如果第一次其实已经成功了,后续重试还应该尽量返回第一次成功的结果,而不是简单报错。

这类设计在工程上通常不是靠单点手段,而是靠一套分层防御模型完成:

  • 第一层:前端轻量防抖
    • 用户点击“提交订单”后,按钮立刻置灰
    • 页面进入 loading 状态,减少肉眼可见的重复点击
  • 第二层:结算服务前置防重
    • 用户进入确认订单页时,后端生成一个全局唯一的 submit_token
    • 提交订单时,前端必须把这个 submit_token 原样透传回来
    • 结算服务先在 Redis 上做一次幂等拦截,例如:
    • SET submit_lock:{submit_token} 1 NX EX 30
    • 如果没有拿到锁,说明相同提交意图正在处理中,或者已经处理过
  • 第三层:订单中心存储级最终兜底
    • Redis 分布式锁并不是绝对强一致的
    • 极端情况下,主从切换、锁过期或网络抖动仍然可能让重复请求穿透
    • 所以订单中心在落库时,仍然必须对 submit_token 做唯一约束
    • 即使两个请求同时穿透了 Redis,MySQL 也只能允许一笔订单真正写成功
  • 第四层:结果幂等与顺水推舟
    • 如果第一次创单已经成功,但响应前端时网络丢包
    • 第二次相同请求进来,不应该只返回“重复提交”
    • 更好的做法是缓存 submit_token -> {status, order_id}
    • 后续重试命中后,直接返回第一次成功的 order_id,把用户平滑带到收银台

这几层的职责并不相同:

防线核心目标典型实现
前端防抖减少普通重复点击按钮置灰、loading、防重复提交
Redis 防重前置消峰,保护下游资源submit_token + 分布式锁 / SETNX
DB 唯一约束存储层最终绝对幂等订单表或防重表上的唯一索引
结果缓存平滑处理“成功但回包丢失”submit_token -> order_id 结果映射

面试里经常会被继续追问两个极端场景。

场景一:业务还没执行完,Redis 锁先过期怎么办?

如果创单链路比较长,固定的 EX 30 很容易在高峰期被打穿。更稳妥的工程实现通常是:

  • 使用具备看门狗续期能力的分布式锁实现;
  • 业务未结束时自动续期;
  • 业务结束后显式解锁。

这样可以避免“创单事务还没跑完,锁却先失效”的幂等击穿问题。

场景二:Redis 锁挡住了大部分流量,但极端情况下仍然漏了怎么办?

这里真正的底线是:

Redis 负责前置消峰,MySQL 唯一键负责最终闭环。

也就是说,Redis 锁不是为了替代数据库唯一约束,而是为了减少无意义的重复流量冲击商品、库存、营销和订单中心。

因此这里更稳妥的结论是:

创单幂等要做成“前端防抖 + 结算服务 submit_token 防重 + 订单中心唯一索引兜底 + 结果缓存顺水推舟”的四层模型。Redis 负责前置消峰,数据库负责最终绝对幂等。

预生成的订单号能不能直接作为创单幂等 token

在创单幂等设计里,还有一个非常实用的工程化问题:

  • 结算页是不是一定要单独发一个随机 submit_token;
  • 还是说,预生成的订单号本身就可以兼做创单幂等键。

答案是:可以,而且在很多系统里这是更推荐的做法。

这里的关键不是“先写一条待支付订单”,而是:

  • 用户进入确认订单页时,系统先生成一个全局唯一的 order_no;
  • 这个 order_no 先不落订单主表;
  • 而是先作为一次性的创单提交凭证,写入 Redis,设置合理的过期时间;
  • 真正提交订单时,前端带着这个 order_no 回来,由后端原子消费它,再执行正式创单。

这意味着,一个预生成订单号同时承担了两层身份:

  • 业务主键:后续支付、履约、售后都围绕这个订单号推进;
  • 提交幂等键:用于防止同一个结算确认页被重复提交多次。

与“纯随机 token”相比,它的优势很明显:

  • 不需要再维护一套独立 token 体系;
  • 幂等键和订单主键天然合一,链路更清晰;
  • 如果提交时 Redis token 已经过期,后端可以直接按 order_no 去订单库反查是否已经成功创单;
  • 即使前端回包丢失,用户重试时也更容易平滑返回已有订单。

但要想把这个方案用稳,必须满足几个前提:

设计点要求
订单号生成时机必须在确认订单页阶段就预生成,而不是创单事务最后才生成
Redis 侧order_no 要作为一次性可消费凭证写入缓存,并设置 TTL
MySQL 侧订单表必须对 order_no 做唯一索引,承担最终兜底
提交失败处理Redis token 失效后,不能直接报错,要先按 order_no 反查订单是否已存在

本案例采用以下提交流程:

  1. 用户进入确认订单页;
  2. 结算服务预生成 order_no;
  3. Redis 写入 order:submit:{order_no},TTL 例如 15~30 分钟;
  4. 前端提交订单时,原样带回这个 order_no;
  5. 后端先原子消费 Redis 中的提交凭证;
  6. 再进入正式创单事务;
  7. 订单表以 order_no unique 最终兜底。

这里尤其要注意一个容易答错的点:

token 过期,不等于订单一定创建失败。

所以当 Redis 里发现 order_no 已不存在时,更合理的后端处理顺序是:

  • 先按 order_no 去订单库查;
  • 如果已经有订单,直接返回这笔已有订单;
  • 如果没有订单,再提示用户“页面已超时,请刷新后重新提交”。

当然,这种方案也有边界:

  • 不要直接暴露纯自增订单号;
  • 更适合使用 Snowflake、号段 + 随机扰动、带业务前缀的全局唯一号;
  • 并且 Redis 只负责前置防重,绝不能替代数据库唯一约束。

因此这里更稳妥的结论是:

预生成订单号完全可以直接作为创单幂等 token。最佳实践是“预生成订单号 = 提交幂等键 = 订单业务主键”,再配合 Redis 一次性消费和数据库唯一索引,形成一前一后的双保险。

技术幂等和业务重复单提醒,为什么要分两层设计

创单链路里还有一个很容易被忽略的点:

  • 技术幂等,解决的是“同一个请求重复到达,系统不要创建两笔订单”;
  • 业务重复单提醒,解决的是“同一个用户在很短时间内,可能无意中下了两笔高度相似的订单”。

这两者看起来都在处理“重复”,但其实完全不是一回事。

技术幂等的典型触发原因通常是:

  • 用户重复点击提交;
  • 网络抖动导致前端自动重试;
  • 网关超时后客户端再次发起请求;
  • 消息重发或服务重试。

它的目标非常纯粹:

同一个下单请求,多次到达,只能被系统真正处理一次。

而业务重复单提醒更偏用户体验和业务治理,它处理的是:

  • 用户已经成功下过一笔极其相似的订单;
  • 但因为没注意页面状态、没看到待支付订单、或者切了支付方式,又重新下了一笔;
  • 这两笔订单在技术上是两次不同请求,但在业务上很可能是用户误操作。

它的目标不是强拦截,而是:

在不破坏正常下单自由度的前提下,提示用户“你刚刚可能已经下过一笔类似订单了”。

所以一个成熟的订单系统,通常要把这两层拆开设计:

层次目标典型手段
技术幂等防止同一请求被重复处理submit_token/order_no、Redis 一次性消费、数据库唯一索引
业务重复单提醒防止用户短时间内误下两笔相似订单按用户、商品、地址、金额、时间窗口做相似订单检测,并给出二次确认提示

业务重复单提醒一般不会像技术幂等那样做成强约束唯一键,而是更偏“软校验”。例如:

  • 同一用户;
  • 在最近 1~5 分钟;
  • 针对相同商品 / SKU / 房型 / 行程;
  • 收货地址、数量、金额高度一致;
  • 且前一笔订单还处于待支付或刚支付状态。

这时系统更合理的动作通常是:

  • 弹出提醒:
    • “你刚刚已经提交过一笔相似订单,是否继续下单?”
  • 给用户两个选择:
    • 去查看已有订单;
    • 继续提交当前订单。

这样做的原因是:

  • 有些重复单确实是误操作;
  • 但也有些重复单是用户有意为之,例如:
    • 给不同人各买一份相同商品;
    • 同一酒店房型连续下两间;
    • 同一活动商品分开下单。

如果把业务重复单也像技术幂等一样硬挡掉,反而会伤害真实交易。

因此更稳妥的结论是:

技术幂等和业务重复单提醒必须分层设计:技术幂等前置,负责防止同一请求被系统处理两次;业务重复单提醒后置,负责识别“相似订单”并给用户二次确认。技术幂等优先,业务提醒辅助。

15.1.9 创单失败后的资源补偿

创单失败后,库存预占和权益占用怎么做最终一致性补偿

这也是结算编排里非常关键的一道资损防线。

在标准的提单链路里,前面往往已经发生了这些动作:

  • 价格快照已经生成
  • 库存已经预占,拿到了 reserve_ids
  • 营销权益已经占用,拿到了 coupon_tokens

这时如果最后一步:

  • 结算服务 -> 订单中心 CreateOrder

在写库时因为数据库超时、网络断开、主从抖动等原因失败,系统就会落入一个非常危险的中间态:

  • 前端看到的是“创单失败”
  • 但库存和权益其实已经被前置链路冻结住了

如果不处理,就会出现:

  • 僵尸库存预占
  • 优惠券被卡死
  • 用户无法再次下单
  • 商家可售资源被无故锁住

本案例的外部支付不能加入 XA,且创单、支付与资源释放跨越不同时间窗口,因此不选择跨全链路的 XA 提交。Seata 支持不同事务模式,不能把整个框架等同于 XA;具体选择取决于参与方协议、资源预留能力和恢复成本。本章采用的做法是:

  • 主链路快速失败
  • 异步补偿兜底
  • 延迟反查再兜底

推荐的补偿模型一般有两层:

第一层:消息补偿

当结算服务或订单中心感知到创单明确失败时,发布一条 OrderCreateFailedEvent:

  • 库存中心订阅后释放 reserve_ids
  • 营销中心订阅后释放 coupon_tokens

这样可以把大部分“明确失败”的场景快速回滚掉,而不需要让前台请求同步等待所有补偿完成。

第二层:下游主动反查 + 延迟释放

为了防止消息丢失、网络抖动或“创单结果未知”这种灰色状态,库存中心和营销中心本身还应该有一层延迟自愈:

  • 在预占 / 占用成功时,挂一条延迟检查任务
  • 到达超时时间后,主动去订单中心反查:
    • 这个 reserve_ids / coupon_tokens 对应的订单到底创建成功了吗
  • 如果订单不存在,或者订单已经被关闭 / 取消,就自动释放资源

这意味着:

  • 结算服务负责主流程编排
  • 订单中心负责创单真相
  • 库存和营销中心各自对自己的冻结资源负责自我救赎

因此这里更稳妥的结论是:

创单失败后的最终一致性,不能依赖运行时强事务,而要依赖“失败事件补偿 + 延迟反查释放”的双层自愈机制。

15.1.10 下单价格与订单快照校验

价格防篡改应该重新计算一遍,还是依赖价格签名 / 版本核销

创单链路里还有一个非常关键的安全问题:前端提交的价格到底能不能信。

如果黑客通过抓包改包,把前端传给 SubmitOrder 的金额从 5999 改成 0.01,而后端又没有做价格防篡改校验,就会直接造成巨大资损。

本例比较两种防护方案:

  • 方案 A:无状态签名校验
    • 预结算时由计价系统生成一个 price_token
    • 它通常由 item_id + user_id + price + timestamp + secret 等因子签名得到
    • 创单时前端把价格和 price_token 一起透传回来
    • 结算服务或计价服务重新验签,确认价格未被改包
  • 方案 B:有状态版本核销
    • 预结算时由计价系统生成一个 price_version_id
    • 并把对应价格结果写到 Redis 或计价缓存中
    • 创单时前端只透传版本号
    • 后端按版本号回查真实价格,再以缓存中的真相落单

这两种方案的本质区别是:

  • 方案 A 更像“数学签名防篡改”
  • 方案 B 更像“后端状态核销防篡改”
方案优点风险适用场景
无状态签名校验不需要高频读 Redis;延迟低;更适合高并发如果只做简单签名,天然不防重放;仍需额外处理超时窗口和 nonce高并发场景、性能优先场景
有状态版本核销安全边界清晰;天然适合做一次性核销;方便过期控制对 Redis / 状态存储依赖更强;预结算和创单时会增加缓存 IO 压力安全要求高、流程较长、可接受缓存成本的场景

在价格结果能够固化为有期限凭证的本案例中,默认采用:

  • 以 无状态签名 作为主防线,防止前端改价
  • 再加上 时间窗口 + nonce 防重放
  • 如有必要,再用轻量 Redis 记录短期 nonce 或一次性 token

这样既能保持高并发下的低延迟,又能防止用户把一个合法的价格签名反复提交多次。

因此这里更稳妥的结论是:

高并发电商链路里,优先采用“价格签名校验 + 时间窗口 / nonce 防重放”的轻量方案;只有在业务对一次性核销、价格版本冻结要求特别强时,再演进到有状态版本核销。

订单快照应该由谁构建:商品中心、订单中心,还是结算服务

订单快照的职责划分也是一个非常容易设计错的点。这里真正的问题不是“谁能拿到商品标题”,而是:

  • 谁手里拥有最完整的下单上下文;
  • 谁最适合在创单前把商品、价格、履约三类事实揉成一份订单解释材料;
  • 谁应该只负责持久化,而不要再次退化成交易大管家。

这类职责一般会出现三种候选方案:

  • 方案 A:由商品中心构建快照
    • 看起来商品中心最懂商品,但它只拥有“当前商品真相”
    • 它并不知道这次交易用了什么券、最终成交价是多少、履约承诺是什么
    • 如果让商品中心在提单时参与组装订单快照,会把交易上下文反向污染到商品域
  • 方案 B:由订单中心构建快照
    • 订单中心在收到创单请求时,理论上可以再去查商品、计价、营销、履约
    • 但这会让订单中心重新变成“大管家”
    • 创单链路的耗时、依赖数和失败面都会急剧上升
  • 方案 C:由结算服务构建快照,订单中心只负责落库
    • 结算服务在提交订单前,本来就已经拿到了:
    • 商品静态信息
    • 价格明细和优惠分摊结果
    • 履约承诺与交付上下文
    • 它是最适合在内存里把这些信息揉成 SnapshotDTO 的那一层
    • 订单中心收到后,只需要把订单事实和快照 JSON 一起持久化

三种方案的关键差异如下:

方案优点风险推荐度
商品中心构建商品标题、主图、类目天然可得职责越界;不拥有价格与履约上下文;容易把交易逻辑污染回商品域不推荐
订单中心构建创单和落库在一个服务里闭环订单中心重新退化成大管家;创单链路变重;依赖面暴涨谨慎使用
结算服务构建最接近完整交易上下文;适合在创单前聚合和揉快照需要明确快照字段边界,避免把无意义大字段塞进快照推荐

因此这里更稳妥的结论是:

订单快照应由结算服务在提交订单前完成组装,订单中心只负责把订单事实与快照结果持久化;商品中心提供静态契约,但不直接参与快照构建。

进一步说,快照也不能无限膨胀。真正应该进入订单快照的,通常是:

  • 商品 ID、SKU ID、标题、下单时主图 URL、规格属性;
  • 成交单价、原价、优惠分摊、券抵扣、运费、税费;
  • 履约类型、承诺送达时间、退改规则摘要。

而像商品详情图文、长文本介绍、视频等大字段,不应该进入订单快照。订单列表查询也不应该把大 JSON 混在主表里,而应该通过独立的 order_snapshot 之类的附表按需读取。

订单中心负责交易事实编排,商品中心负责交易前静态契约

商品中心负责“商品身份与价格合规”的静态校验,订单中心负责“交易行为与流水合规”的最终编排。

订单必须保存商品快照、价格快照和履约快照

订单必须保存商品快照、价格快照和履约快照,而不是回读最新商品。

15.1.11 订单数据模型演进与主权边界

订单模型的演进:从支付单 + 订单,到主单 / 子单 / 商品维度退款

订单模型的演进,本质上是在回答一个问题:系统到底要先解决“支付聚合”,还是先解决“履约拆分、售后拆分和商品维度退款”。

在业务早期,很多系统只有三张核心表:

  • pay_order_tab
  • order_tab
  • refund_tab

这套模型足以支撑最基础的交易闭环:

  • 一个支付单对应一个或多个订单
  • 一个订单下挂多个订单明细
  • 退款主要按整单维度处理

它的优点是简单、上线快、链路短。但当业务开始出现下面这些诉求时,模型就会越来越吃力:

  • 一个支付单下挂多个业务订单,需要合并支付
  • 一个用户视角上的“大订单”需要按商家、仓库、履约方式拆成多个子单
  • 售后不再只是整单退款,而是按某个商品、某个 order_item 退款
  • 后续还可能出现部分发货、部分签收、部分退款、部分核销

这时候,更稳妥的演进方向是把“支付域聚合”和“订单域聚合”拆开:

  • pay_order_tab:属于支付域,只解决一次支付要付多少钱、走哪个渠道、支付状态是什么
  • parent_order:属于订单域,解决用户视角和业务聚合问题
  • order_tab:承载子单,解决分商家、分仓、分履约、分售后的独立处理
  • order_item_tab:承载商品维度事实,给商品维度退款、部分售后、部分履约提供锚点
  • refund_tab:从“只关联合同/整单”演进到既可关联订单,也可关联 order_item_id

可以用一句话概括这种演进模型:

主单解决“支付和用户视角的统一”问题,子单解决“履约、结算、售后等多维度独立处理”问题。

一个比较务实的演进顺序通常是三步走:

  1. 先保留现有 pay_order_tab + order_tab + refund_tab,快速支撑基础交易。
  2. 在订单域引入 parent_order,或者至少先在 order_tab 上补 parent_order_id,开始支持合并支付和业务聚合。
  3. 在 refund_tab 上增加 order_item_id,让退款能力从整单退款演进到商品维度退款。

这里有一个很重要的边界要守住:

  • pay_order_tab 不应该替代 parent_order
  • 支付单是支付域对象,关注资金流
  • 主单是订单域对象,关注用户视角、履约拆分和售后聚合

也就是说,支付单可以聚合多笔业务订单,但它不应该承接“用户看到的是一单还是多单”“履约怎么拆”“退款按哪个粒度处理”这类订单域问题。

如果系统还处在简单阶段,完全没必要一开始就把主单、子单、订单项、退款项全部做满;但设计时要预留一条清晰演进路径:

  • 简单模式先跑通支付和整单退款
  • 复杂模式再逐步引入主单、子单和商品维度退款

这样既能避免过度设计,也不会在后期被早期模型彻底卡死。

15.1.12 场景一:提交订单时序图

sequenceDiagram
    participant FE as 前端
    participant CO as 结算服务
    participant R as Redis 幂等凭证
    participant O as 订单中心
    participant P as 商品中心
    participant PR as 计价系统
    participant IV as 库存中心
    participant MK as 营销中心

    Note over FE,CO: 用户进入确认页 → 预生成 order_no(存 Redis,有效期 15~30min)

    FE->>CO: SubmitOrder(order_no, price_token, reserve_ids, coupon_tokens, ...)

    CO->>R: Lua原子消费 order_no 凭证

    alt 凭证不存在或已过期
        CO->>O: queryOrderByOrderNo(order_no)
        O-->>CO: 已有订单 / 未找到
        CO-->>FE: 返回已有订单 或 "页面已超时,请刷新重试"
    else 第一次有效提交
        CO->>P: 商品静态合规校验
        CO->>PR: 价格签名 + 版本校验(price_token)
        CO->>IV: 库存预占凭证校验(reserve_ids)
        CO->>MK: 营销权益校验(coupon_tokens)

        alt 任意校验失败
            CO-->>FE: 返回具体错误(不删 Redis 凭证,允许重试)
        else 全部校验通过
            CO->>CO: 组装订单快照 SnapshotDTO
            CO->>O: CreateOrder(order_no, orderDTO, snapshotDTO)

            Note over O: 本地事务
            O->>O: 1. 插入 orders(order_no 唯一索引)<br>2. 插入 order_snapshot<br>3. 记录操作日志
            O-->>CO: 成功

            CO->>R: 删除 order_no 凭证(仅成功后删除)
            CO-->>FE: 创建成功 + order_id
        end
    end

15.1.13 特殊场景:预售订单

预售订单的本质,不是“普通订单加一个活动标签”,而是延迟履约 + 分阶段支付 + 长周期资源占用。它和现货单最大的区别在于:交易事实可以先成立,但资源最终确认和履约发生在更晚的时间点。

业务生命周期

一个典型的预售订单,通常会经历下面几个阶段:

  • 预热期:用户可以浏览和加购,但还不能正式下单
  • 定金期:用户支付定金,锁定购买资格
  • 尾款期:活动切换到尾款支付窗口,用户补齐尾款
  • 履约期:尾款支付成功后,订单才进入正常发货 / 履约流程
  • 异常终止:定金支付后尾款超时、用户主动取消、活动终止

因此,预售订单不是一次支付完成全部交易,而是把“购买承诺”和“最终成交”拆成了两个时间点。

核心模型

预售订单建议显式建模,而不是把逻辑散落在普通订单字段里拼凑:

  • order_type = PRESALE
  • presale_end_time:尾款支付截止时间
  • delivery_time:预计发货时间
  • lock_deadline:库存锁定截止时间

其中最关键的是“分阶段支付”模型。更推荐单独抽一层 pay_stage 或等价支付阶段表,而不是在 pay_order_tab 上硬塞一组定金 / 尾款字段。原因很简单:

  • 每个阶段都可能有独立的支付流水 trade_no
  • 退款时可能只退定金,或者只退尾款
  • 财务对账更容易按阶段落账
  • 后续即使出现三阶段支付,也不需要推翻原模型

与其他服务的交互边界

预售单会把多个中心的责任拉得更长,因此边界必须先钉死:

  • 商品中心:提供预售商品快照,尤其是定金金额、尾款金额、预计发货时间、活动承诺文案
  • 营销中心:提供预售活动规则,如定金比例、尾款优惠、限购数量
  • 库存中心:在定金支付成功后做长时间预占;在尾款支付成功后再转成最终 Confirm
  • 消息中心:在尾款截止前做多轮提醒,例如提前 24 小时、3 小时、30 分钟

这里的设计重点是:

结算服务在定金阶段解决“资格锁定”,订单中心在尾款支付完成后才把这笔交易推进到真正可履约状态。

预售链路的关键挑战

预售最大的风险,不在于普通创单,而在于它把资源和资金拉成了一个长周期博弈。

挑战一:用户付了定金,但长时间不付尾款

  • 风险:库存被长时间占用,影响后续售卖
  • 处理:库存中心使用长 TTL 预占;尾款超时后由订单中心通过 Outbox 异步 Cancel;必要时按规则扣除部分定金作为违约成本

挑战二:尾款支付窗口集中爆发

  • 风险:大量用户在最后几小时同时补尾款,触发支付、库存 Confirm、营销结算的洪峰
  • 处理:尾款支付仍然走普通支付主链路的幂等、防重、Outbox、Confirm 编排,不因为它是“第二阶段支付”就绕开主流程

挑战三:退款复杂度更高

  • 风险:预售退款不再是简单整单退款,而可能区分定金退款、尾款退款、履约前退款
  • 处理:退款模型增加 refund_stage = DEPOSIT / FINAL 一类字段,显式标记退款属于哪个支付阶段

推荐的落地原则

预售场景最容易犯的错误,是把它当成“普通订单 + 两次付款”来实现。更稳妥的思路是:

  • 把预售当成一种独立 order_type
  • 把定金和尾款视为两个支付阶段
  • 把库存看成“长预占 + 最终确认”的两段式资源状态
  • 把尾款超时和活动终止视为标准 Saga Cancel 场景

一句话总结:

预售订单的核心,不是先收一笔钱,而是先锁定资格,再在更晚的时间点完成真正成交与履约。

15.1.14 特殊场景:0 元购订单

0 元购的本质,不是“没有支付所以更简单”,而是营销驱动的超高风险低价订单。它的目标通常是拉新、促活、带动搭售,但系统设计上反而要更谨慎,因为一旦风控、营销核销或库存确认做得不严,很容易直接形成资损。

业务本质与核心模型

0 元购订单建议也显式建模,而不是混在普通订单里只靠金额判断:

  • order_type = ZERO_YUAN
  • real_pay_amount:实际支付金额,很多场景为 0
  • marketing_discount_amount:营销补贴金额,给财务和活动归因使用
  • campaign_token:营销中心返回的强凭证,后续用于核销和反查
  • risk_score / risk_level:风控打分结果

这里要特别强调一个设计边界:

0 元购不是“没有成本”,只是用户不付钱,平台通过营销补贴替用户付款。

因此,订单里必须保留补贴金额和活动凭证,否则后续对账、活动归因、反作弊和财务核销都会变得很被动。

是否一定要走支付网关

0 元购最常见的设计争议,是要不要经过支付网关。

推荐结论是:

  • 如果用户仍需支付运费或其他附加费用,就必须走正常支付链路
  • 如果商品、运费、附加费用全部为 0,可以跳过第三方支付网关

但即使跳过真实支付,也建议保留一笔 pay_order_tab 记录,原因包括:

  • 用户订单列表和交易流水仍需要一条完整支付事实
  • 财务和活动分析需要知道这笔单是“0 元成交”,不是“没有支付过程”
  • 后续退款、活动冲正、对账也更容易统一模型

也就是说,0 元购可以跳过外部支付渠道,但不应该跳过系统内部的支付事实建模。

库存、营销和风控的处理原则

0 元购最容易犯的错误,是把它当成“福利单”,然后在库存和营销链路上放松规则。更稳妥的做法是:

  • 库存仍然走正常的 Reserve -> Confirm / Cancel
  • 营销权益建议在订单创建成功后异步核销,而不是在主请求里同步硬卡死
  • 风控必须前置,并且允许在订单创建后继续异步二次审查

为什么营销核销更推荐异步?

  • 同步核销会把营销中心稳定性直接传染给下单主链路
  • 0 元购经常是活动洪峰场景,营销系统更容易成为热点瓶颈
  • 订单事实先落下,再通过 Outbox 异步核销,更符合本章的最终一致性设计

风控是 0 元购的第一优先级

0 元购的最大风险,不是支付失败,而是被羊毛党刷穿。推荐采用三层风控:

事前风控

  • 用户画像
  • 设备指纹
  • 历史 0 元购记录
  • IP / 设备 / 账号限频

事中风控

  • 营销中心限购规则
  • 实时风险打分
  • 对高风险请求直接拒绝创单

事后风控

  • 订单创建成功后做异步复审
  • 对异常订单做人工审核、活动回收或后续履约拦截

典型规则包括:

  • 单用户 / 单设备每日限购 N 单
  • 新用户 + 老设备组合高风险
  • 短时间内同一商品出现大量 0 元单,直接熔断活动入口

0 元购的共性架构要求

虽然预售和 0 元购看起来很不一样,但它们都在提醒我们一件事:

不要把所有业务特性都塞进一个大状态机,而要通过 order_type + 扩展字段 + 异步编排 来承接复杂场景。

因此,这类特殊订单更适合统一遵守下面几条原则:

  • 基础交易状态仍保持简单:PENDING -> PAID -> FULFILLING -> COMPLETED
  • 业务差异通过 order_type 和专属扩展字段表达
  • 营销核销、库存确认、风控补偿通过 Outbox 交接已提交意图,再依靠幂等状态机、查询、有限重试和对账推进终态;Outbox 本身不保证每项动作最终成功
  • 关键峰值场景仍要靠 Redis + 幂等 + 唯一索引兜底

一句话总结:

0 元购不是“省掉支付就结束了”,而是把支付简化成了营销补贴问题,同时把风控和最终一致性的要求提高到了更高优先级。

15.1.15 结算服务中的“预占凭证 + 本地事务 + 异步确认”Saga 编排

在本章默认的“支付前可预占、支付后确认、失败可释放”交易场景中,结算服务不是自己执行所有最终扣减,而是把交易链路组织成:

  • 创单前只做预占(Reserve)
  • 创单成功后保存订单事实
  • 支付成功后再做确认(Confirm)
  • 创单失败、支付失败、超时取消后再做取消(Cancel)

本例将这些步骤组织为持久化的 Saga 编排。它的核心思想是:

库存和有限权益提供可查询的预占、确认与释放能力;结算服务组织预占并校验报价凭证;订单中心保存交易事实与后续动作意图,由持久化编排通过 Outbox 交接确认或取消。支付渠道不被假定为可参与同一资源预占协议。

这样设计的原因很直接:

  • 结算服务面对的是商品、库存、营销、价格等多个下游域;
  • 如果在提交订单时就要求所有域同步做最终扣减,主链路会非常重;
  • 订单落库失败时,需要明确查询与释放已占资源的跨域恢复责任;
  • 在这些资源确实可预留的前提下,先建立可查询、可确认、可释放的资源凭证,可以把临时占用与最终消耗分开。

这个模式通常会落成三段:

阶段动作目标
创单前Reserve预占库存、预占权益、冻结报价结果,拿到 reserve_ids / coupon_tokens / price_token
支付成功后Confirm正式确认库存消耗、权益消耗,把预占资源转成成交事实
失败或取消后Cancel释放库存、释放权益、失效价格凭证,避免僵尸资源

因此,结算服务在创单阶段的职责不是:

  • 直接扣库存;
  • 直接核销券;
  • 直接把营销权益变成最终消耗。

而是:

  • 收集并校验这些预占凭证是否有效;
  • 把它们和订单事实绑定在一起;
  • 在订单创建成功后,把后续确认责任交给订单中心;
  • 通过本地消息表 / Outbox,把“确认”或“取消”动作异步可靠地下发给库存中心和营销中心。

一个更贴近落地的职责分工可以概括为:

系统在 Saga 中的职责
结算服务组织预占、校验凭证、提交创单请求
订单中心落交易事实,成为后续确认 / 取消的协调点
库存中心提供 Reserve / Confirm / Cancel 三段式库存能力
营销中心提供权益占用、权益确认、权益释放三段式能力
支付中心提供支付事实,不直接操作库存和营销资源

这里尤其要强调一个经常被问到的点:

为什么创单时只做预占,支付成功后再 Confirm?

因为创单阶段的核心目标是:

  • 快速、安全地把用户带到收银台;
  • 让订单中心先拿到一笔清晰、可解释的交易事实;
  • 把真正不可逆的资源消耗动作,推迟到“支付成功”这个更强的业务事实之后。

所以本案例的选择是:

预占资源的持久化编排式 Saga + 订单本地事务 + Outbox。

也就是说:

  • 创单时做资源预占
  • 支付成功后 Confirm 库存和权益
  • 创单失败、支付失败或超时后 Cancel 资源
  • 通过 Outbox 可靠交接本地已提交事实,后续状态由幂等、查询、补偿与对账收敛

它的优势在于:

  • 不要求所有资源加入一次跨整个支付生命周期的 XA 提交;
  • 下游域边界清楚,各自只负责自己的资源预占、确认和释放;
  • 网络抖动或部分失败时有可查询的恢复入口,但补偿也可能失败,需要冻结或人工接管。

按参与方能力比较,而不是按方案名称排名

第 4 章定义提交协议、流程编排、资源预留和可靠事件的不同边界。本节只比较它们在本案例中的作用,详细机制见第 4 章资源预留模型;完整决策保留在 15.6.2 ADR-01。

路线参与方前提保证范围与隔离手段失败恢复和主要成本本案例的判断
XA 资源上的 2PC纳入提交的资源支持协议;事务能在短窗口结束协议范围内的统一提交决定;隔离级别另由资源管理器实现in-doubt 恢复、协调器与资源占用;不能仅凭协议名断言线性一致外部支付不加入该协议,因此不覆盖整条生命周期;不排除独立短内部事务使用
编排式或协同式 Saga各步骤有本地事务、可查询结果、业务恢复路径不自动提供跨域隔离;可结合预占、版本和语义锁保护竞争持久化步骤、重试与补偿,处理不可逆动作与未知结果使用编排式,便于集中解释订单状态与恢复责任
TCC参与方能实现可靠 Try/Confirm/Cancel 及共同决策恢复预留保护指定资源;不由三段式接口名自动获得全局原子可见幂等、空回滚、悬挂和确认/取消恢复;资源占用及参与方改造对需要共同确认决定的可预留资源另行评估,不限于金融场景
Outbox业务事实与事件意图能在同一数据库事务写入本地事实与待发事件原子提交;不决定下游是否成功Relay 重投、消费幂等、积压与清理作为所选编排的事件基座,不与 Saga/TCC 当作互斥事务路线

Saga 的正向步骤可以是“预占”,不必先执行不可逆的真实消耗;TCC 的确认也不必被解释成一条永不重试的同步 RPC 链。Seata 的不同模式或工作流运行时是实现选择,不是额外一致性级别。[20][21][22] 本书把“预占 + 编排 + Outbox”作为本案例组合,不另造“工业级改良 Saga”标准。

资源预占必须兑现哪些条件

  • Reserve 在权威库通过条件更新建立唯一预占,返回资源范围、数量、版本、有效期和幂等结果;缓存令牌不自动代替这个事实。
  • Confirm 和 Cancel 使用同一预占身份及合法前置状态,终态只能被合法迁移推进;重复命令不产生第二次消耗或释放。
  • 取消先于迟到 Reserve 到达时,应有取消凭证或等价版本门禁拒绝旧意图,防止资源重新悬挂。
  • TTL 到期不等于渠道一定没扣款。支付结果未知时先查单并冻结后续危险动作,按业务协议续占或终止;资源已释放但随后查明成功时保留资金事实,按规则退款或人工处理,不抢回已承诺给他人的资源。
  • Outbox 只证明本地意图已提交。确认迟迟无法完成时,要能查询、重试、隔离、对账和人工接管;成功、取消或有证据的人工处置都是恢复终态,不把消息最终送达当成业务一定成功。

本案例的选择与重评

在外部支付不能加入 XA、库存和有限权益可形成有效预占、业务允许后续状态异步收敛的前提下,选择持久化编排式 Saga。订单本地事务保存订单、快照和待执行意图;Worker 交接与反查,库存和营销各自提交 Confirm/Cancel。获得短本地事务与明确恢复边界,主动接受跨域可见性延迟、预占占用成本及长期补偿治理。

如果可预留资源不能兑现上述条件,先调整承诺时机或拒绝风险交易,不能因使用 Saga 名称就放行。若共同确认决定、补偿成本或等待跨度改变,再评估 TCC、局部 XA 或持久化工作流,依据资源协议和测量结果重做 ADR,不用“金融级”“最优”代替约束分析。


15.2 支付、交易编排与最终一致性

15.2.1 支付不是一个按钮,而是两条事实链

支付链路至少包含“发起支付”和“确认支付结果”两条不同的事实链:

  1. 订单中心依据订单应付金额创建或获取支付单。
  2. 收银台向用户展示支付方式并向渠道发起支付。
  3. 渠道可能同步返回成功、失败或处理中。
  4. 渠道还可能通过异步通知、查询接口或对账文件给出最终结果。
  5. 支付中心归一化渠道结果,订单中心消费支付事实并推进订单状态。

同步返回只代表一次请求的即时结果,不能自动覆盖异步通知和对账事实。尤其是移动端、网页支付和银行转账场景,用户可能在客户端超时后已经完成扣款,或者渠道通知先于前端响应到达。支付中心应维护支付单状态、渠道流水号、金额、币种、渠道和通知版本;订单中心只消费标准化的 PaymentSucceeded、PaymentFailed、PaymentPending、RefundSucceeded 等事实事件。

对外支付接口要采用幂等键。AWS Builders’ Library 将幂等 API 视为安全重试的基础:客户端可以重试同一请求,而服务端不会因为网络丢包重复创建副作用。[7] Stripe 的接口文档也将幂等键与请求参数绑定,避免同一个键被意外用于不同的请求。[23] 在本章的交易模型中,支付幂等键至少应包含订单或支付单身份,不能只使用用户 ID 或任意随机请求 ID。

支付域中的四类对象

支付系统最容易出现的模型错误,是只设计一张“支付表”,让订单号、渠道号、退款号和分账信息都堆在同一行。支付过程至少包含四类对象,它们的生命周期和事实主权不同:

对象作用关键字段生命周期不能替代什么
支付意图表达订单希望完成的一次资金收口订单、应付金额、币种、过期时间从收银台创建到支付终态不能直接证明渠道已扣款
支付尝试表达一次具体的支付方式和渠道尝试支付方式、渠道、商户号、路由版本可因失败换渠道而产生多次不能覆盖支付意图的累计金额
渠道交易表达外部机构接收或完成的交易渠道流水号、受理状态、原始报文摘要受渠道查询和对账闭合不能直接修改订单主状态
退款单表达对已成立资金事实的逆向请求原支付单、退款金额、原因、退款流水从申请到渠道完成或人工终止不能把原支付事实抹掉

因此,订单与支付之间更适合使用 payment_intent_id 关联,而不是让订单直接保存一个会不断变化的渠道流水号。一个支付意图可以有多次支付尝试,一个支付尝试可能因为渠道超时处于未知状态,渠道交易则需要由回调、主动查询和对账文件共同解释。退款也应独立建模,因为一次支付可以发生多次部分退款,退款完成时间和原支付完成时间并不相同。

这个拆分还有一个实际好处:用户点击“换一种支付方式”时,系统可以创建新的支付尝试,但仍然沿用同一个支付意图和金额快照;渠道回调到达时,只需依据渠道流水号定位尝试,再汇总到支付意图,而不会因为一次失败尝试覆盖另一条已经成功的渠道事实。支付域的金额计算、渠道状态归一和退款累计校验由支付系统负责;订单域只接收支付意图层面的标准事实。

支付核心的分层与数据边界

原支付系统可以抽象为接入层、应用编排层、支付核心、渠道适配层和清结算层。分层的价值不在于增加服务数量,而在于隔离变更原因:换微信或银行卡 SDK 不应修改退款金额规则,调整商家结算周期也不应污染回调验签逻辑。

层次主要职责可依赖不应承担
接入层鉴权、限流、防重、原始通知接收网关、密钥服务订单状态推进和资金记账
应用编排层创建支付、发起退款、主动查单、对账任务支付核心、路由、任务队列渠道字段的长期透传
支付核心支付意图、尝试、状态机、金额闭合、事实事件本地数据库、Outbox直接依赖某个渠道 SDK
渠道适配层报文转换、验签、预下单、查询、退款第三方渠道解释订单履约状态
清结算层分账规则、结算批次、渠道账单、差错工单支付事实、财务规则伪造支付成功事实

支付主表宜保持窄表:状态、金额、币种、订单关联、渠道标识、幂等键、版本和时间边界是高频访问字段;原始回调、渠道扩展字段和大体积报文应放入追加式通知表、扩展表或受控对象存储。这样既能减少支付状态更新时的行放大,又能保留审计需要的原始证据。敏感报文的留存时间、访问权限和脱敏方式应由合规要求决定,不应为了排障把完整卡号、验证码或渠道密钥复制到普通业务日志中。[19]

15.2.2 支付状态机、渠道通知与验签

支付单可以使用独立于订单主状态的状态机:

INIT -> REQUIRES_ACTION -> PROCESSING -> SUCCEEDED
  |          |                 |             |
  |          |                 |             +--> REFUNDING -> REFUNDED
  |          |                 +----------------> FAILED
  |          +----------------------------------> CANCELED
  +---------------------------------------------> EXPIRED

渠道回调处理顺序不能只是“收到成功就改状态”,而应是:验签、校验商户号和环境、校验订单与金额、校验通知时间窗口、按渠道流水号去重、按支付单版本推进、记录原始回调摘要、发布标准事件。Adyen 的 Webhook 处理建议也强调验签、持久化事件、返回成功响应以及异步处理业务动作;本章将其抽象为适用于多渠道的回调接入规范。[24]

回调可能重复、乱序或迟到,因此状态迁移需要定义优先级。例如 SUCCEEDED 已经成立后,迟到的 PROCESSING 只能记录为过时事件,不能把支付单退回处理中;已退款的支付单收到重复退款通知,只能幂等确认。每个渠道适配器负责将渠道状态翻译为内部语义,订单中心不应感知支付宝、微信、银行卡或钱包渠道的原始枚举。

渠道路由不是简单的支付方式映射

“用户选择微信,所以调用微信接口”只是最小实现。生产路由还要考虑商户号、销售主体、币种、地区、渠道限额、费率、成功率、响应延迟、风控结果、渠道维护窗口和灰度比例。路由决策必须生成可追溯的快照,至少记录候选渠道、被选渠道、路由规则版本、商户号和选择原因;否则同一订单在重试或对账时无法解释为什么走了不同渠道。

可以把路由拆成三步:先做硬约束过滤,再对可用渠道评分,最后生成带版本的路由决策。硬约束包括支付方式是否支持、金额和币种是否在限额内、商户主体是否具备资质、渠道是否在当前地区开放;评分项才包括成功率、费率和延迟。渠道健康度只能影响未来尝试,不能把已经发生的渠道交易重新“切换”到另一渠道。

路由阶段输入输出失败处理
资格过滤用户、订单主体、币种、金额、地区可用渠道集合无可用渠道,返回明确不可支付
健康评分渠道成功率、延迟、限额、费率首选渠道与备用渠道健康数据过期时使用保守规则
决策落库路由规则、灰度桶、商户号route_snapshot不能仅放缓存,需可审计
尝试执行支付意图、尝试号、渠道命令受理结果或未知状态只对幂等操作重试,未知写结果先查单

备用渠道并不等于可以无条件切换。若首选渠道的预下单已经返回未知,系统必须先查询首选渠道,确认没有形成资金事实,或者根据渠道明确支持的幂等语义关闭该尝试,才能创建下一次尝试;否则用户可能在两个渠道都完成扣款。这个约束也是为什么支付尝试要独立于支付意图建模。

回调入口与主动查单的双保险

回调入口应与普通收银台查询路径隔离。入口层完成 TLS、来源校验、限流和轻量验签后,先把原始通知摘要及正文指纹以追加方式保存,再向渠道返回满足协议要求的确认响应;支付状态推进、订单事件和履约触发放入异步处理。这样即使下游订单服务抖动,渠道也不会因为平台迟迟不响应而无限重试,同时平台仍保留之后重放所需的证据。

主动查单是回调的补偿路径,不是回调的替代品。对处于 PROCESSING 的支付尝试,应按渠道建议的间隔查询,并根据支付意图的过期时间、渠道最终性和重试预算决定是否继续等待。查单结果也必须经过金额、商户号、渠道流水号和订单关联校验,不能因为渠道接口返回“成功”字符串就直接写入本地。回调和查单同时到达时,使用同一状态迁移函数和数据库条件更新,确保只有一个合法版本能够产生支付成功事件。

通知接收 -> 保存原始证据 -> 验签与字段校验 -> 支付尝试状态迁移
                                      |                 |
                                      |                 +--> Outbox: PaymentSucceeded
                                      +--> 失败或未知 -> 查单任务 -> 对账闭合

这条路径中,“收到通知”“本地状态已更新”“订单已消费事件”“渠道账单已对账”是四个不同的完成层级。前端只需要得到面向用户的处理中或成功提示,运营和财务则需要能看到每个层级分别是否完成。

15.2.3 场景二:提交支付时序图

sequenceDiagram
    participant FE as 前端
    participant PAY as 收银/支付编排中心
    participant M as 营销/资产中心
    participant O as 订单中心
    participant CH as 外部支付网关

    FE->>PAY: CreatePayment(order_id, voucher_id, use_coin_count)
    PAY->>O: 校验订单状态、应付金额、可支付状态
    O-->>PAY: 返回订单应付基线
    PAY->>M: 锁定 Voucher + 冻结 Coin
    M-->>PAY: 返回 campaign_token / lock_token / freeze_token
    PAY->>PAY: 计算现金金额、券抵扣、Coin 抵扣、渠道费

    alt 最终现金支付金额 > 0
        PAY->>CH: 请求外部支付网关预下单(最终现金金额 + pay_order_no)
        CH-->>PAY: 返回 pay_token / 渠道唤起参数
        PAY-->>FE: 返回收银台参数 + 费用明细
    else 最终现金支付金额 = 0
        PAY->>PAY: 标记为零现金支付单
        PAY-->>FE: 返回“无需拉起渠道,等待系统完成支付确认”
    end

15.2.4 场景三:支付结果回调与订单编排时序图

sequenceDiagram
    participant CH as 外部支付网关
    participant PAY as 收银/支付编排中心
    participant O as 订单中心
    participant IV as 库存中心
    participant MK as 营销中心
    participant AS as 资产中心

    CH-->>PAY: 支付成功回调 / 主动查询返回 SUCCESS
    PAY->>PAY: 验签 + 幂等 + 支付状态推进
    PAY->>O: 发布 PaymentSucceeded 事件
    O->>O: 推进订单为 PAID
    O->>IV: ConfirmInventory(reserve_ids)
    O->>MK: ConfirmPromotionHold(coupon_tokens)
    O->>AS: DeductCoin / UseVoucher(freeze_token, lock_token)

15.2.5 场景四:支付失败与超时回滚时序图

sequenceDiagram
    participant CH as 外部支付网关
    participant PAY as 收银/支付编排中心
    participant O as 订单中心
    participant IV as 库存中心
    participant MK as 营销中心
    participant AS as 资产中心

    CH-->>PAY: 支付失败回调 / 主动查询返回 FAIL
    PAY->>O: PaymentFailed / PaymentExpired
    O->>O: 推进订单为 CLOSED / PAYMENT_FAILED
    O->>IV: ReleaseInventory(reserve_ids)
    O->>MK: ReleasePromotionHold(coupon_tokens)
    O->>AS: UnfreezeCoin / UnlockVoucher(freeze_token, lock_token)

15.2.6 支付成功后的确认、履约和库存确认

支付成功并不意味着所有下游事实已完成。订单中心收到支付成功后,通常需要依次或并行触发:库存 Confirm、营销权益确认、履约创建、发票或会员权益发放。这个阶段的关键是让每个动作都具备幂等键和可查询结果:

动作幂等键成功含义失败后的动作
库存确认order_id + item_id预占转为已售重试、查询预占、人工对账
权益确认order_id + benefit_id优惠或资产正式消费取消订单项或回补权益
履约创建sub_order_id生成仓配、券码或预约任务延迟重试、转履约失败
发票申请order_id形成开票任务保留待处理,不影响已支付事实

库存确认需要可靠投递、业务幂等和可查询结果。支付成功与库存 Confirm 的本地事务边界、Outbox 表和 Worker 重试方式见 15.2.13;Kafka 的幂等生产者和事务能力只能减少特定消息链路中的重复,不能保证外部库存、支付和订单数据库之间的业务恰好一次。[9][10]

15.2.7 超时关闭与补偿顺序

待支付订单的超时关闭是一个竞争问题:关闭任务可能与支付成功通知同时到达,库存释放任务可能与支付成功后的库存确认同时到达。正确做法不是依赖定时任务先后,而是用订单版本和支付事实做条件判断:

close_worker(order)
  -> lock or CAS order version
  -> query payment status
  -> if payment is SUCCEEDED: do not cancel
  -> if payment is PROCESSING: enter PAYMENT_PENDING and retry query
  -> if payment is FAILED or EXPIRED: move to CANCELED
  -> publish resource-release command

释放动作也要遵循“先确认事实、再释放资源”。不能仅因支付查询超时就释放库存;应先把订单置为处理中,经过重试窗口和渠道查询仍无结果后,才按照业务策略关闭。AWS Well-Architected 对重试限制的建议强调要设置重试预算和退避,避免多个层级各自重试导致重试风暴。[8] 因此支付服务、订单服务、库存服务不能各自无限重试,必须由一个明确的编排层控制总重试次数。

15.2.8 混合支付、部分支付与 0 元订单

混合支付把一个订单的应付金额拆成余额、优惠券、积分、银行卡或钱包等多个资金来源,不能用一个简单的 paid=true 字段描述。支付单应记录支付分摊明细和各渠道状态,订单只有在满足“应付金额已被合法覆盖”时才进入支付完成:

ORDER_CREATED
  -> PAYMENT_PLAN_CREATED
  -> [balance authorized, coupon consumed, external payment pending]
  -> PAYMENT_PARTIALLY_AUTHORIZED
  -> PAYMENT_SUCCEEDED

某个渠道失败时,需要决定是释放所有已占用资产重新让用户支付,还是允许更换支付方式继续支付。对于优惠券、积分等资产,最好先产生可撤销的授权或冻结记录,等支付事实成立后再确认消费;否则外部支付失败会留下难以解释的资产损失。0 元订单可以跳过外部支付渠道,但不能跳过订单、风控、库存、权益和审计:它仍然要产生可追踪的“应付为零、支付条件满足、权益正式发放”的事实。

多种资产参与时,应把资金和权益的变化拆成三个阶段:

阶段动作目标
冻结Lock Voucher、Freeze Coin先锁定可用权益,不直接确认消耗
确认Use Voucher、Deduct Coin收到支付成功事实后再正式消费
释放Unlock Voucher、Unfreeze Coin取消、超时或支付失败时归还资源

营销和资产服务应提供 Lock / Freeze、Confirm / Use / Deduct、Cancel / Unlock / Unfreeze 等显式接口;订单和支付服务根据下游凭证推进流程,不自行推导权益状态。这样能把多资产链路分成可逆与不可逆阶段,降低并发死锁和部分失败后的回滚难度。账务侧还要保留现金实付、平台补贴、优惠抵扣、积分扣减和渠道手续费等分摊事实,确保后续部分退款和结算沿用下单时的原始口径。

15.2.9 退款状态与逆向交易

退款需要单独的退款单、退款项和退款状态机。创建退款时先读取订单快照和支付事实,计算原支付金额、已经成功退款金额、当前申请金额及剩余可退金额,并以条件更新或锁定方式保证累计值不超过可退上限。一个支付单允许多次部分退款,但每一笔退款都必须有业务原因、申请人、订单项分摊和渠道退款幂等键。

退款状态可以简化为 REQUESTED -> PROCESSING -> SUCCEEDED / FAILED。FAILED 不代表原支付失败,而只代表本次逆向动作未完成;若渠道支持安全重试,可以继续处理,若渠道返回未知,则保持处理中并进入主动查询和对账队列。退款成功后,支付域发布退款事实,订单售后域再决定是否把库存、优惠、积分、赠品或履约任务回补。支付系统不应因为退款成功就自行把订单改成“已取消”。

营销资金和平台补贴的回冲需要沿用下单时的分摊快照,而不是重新按当前规则计算。比如用户当时使用平台券、商家券和余额共同支付,部分退款时应先明确退款对象是某个订单项还是整单比例,再根据原始资金来源计算各方应回收或承担的金额。金额分摊、舍入和最小货币单位必须在退款创建时冻结,否则多次部分退款会出现累计误差或补贴被重复退回。

退款场景支付域要记录售后/履约域要处理
未履约整单退款原支付单、全额退款申请、渠道退款流水关闭未执行履约、释放未消费权益
单项部分退款订单项、数量、分摊金额、税费和优惠归属回补对应库存或取消对应履约任务
已发货退货退款退货验收事实、退款原因、退款金额逆向物流、库存质检、重新入库策略
已核销商品退款核销时间、核销状态、可退规则判断是否允许人工售后或拒绝退款
渠道退款未知请求幂等键、渠道受理号、查询次数不重复发起,保持退款中并告警

退款闭环的判断句是:原支付事实不能删除,退款只能追加新的资金事实;库存、权益和履约是否回滚,必须由各自领域依据订单快照和售后规则决定。

15.2.10 对账、审计与敏感数据边界

支付系统的最终自愈依赖对账。至少要做三种对账:支付单与订单对账、支付单与渠道流水对账、退款申请与渠道退款结果对账。对账不是单纯比金额,还要比订单身份、渠道流水号、币种、支付时间、退款累计金额和状态版本。

对账差异应分类处理:

差异可能原因处理
订单待支付、渠道已成功回调丢失或消费积压查询渠道并补发标准支付事件
订单已支付、渠道无流水测试数据、错误环境或状态污染冻结自动履约,进入人工核验
退款已申请、渠道处理中渠道异步处理保持退款中,按退避查询
订单退款金额大于支付金额重复消费或分摊错误阻断后续退款,生成高优先级告警

PCI DSS 资料库对支付卡数据保护、访问控制、监测和测试提出了正式要求;本章不把完整合规清单展开成实现细节,但坚持支付卡敏感数据最小化、令牌化、分权访问、审计留痕和环境隔离原则。[19] 订单快照也不应保存不必要的完整卡号、验证码或渠道密钥。

清分、结算与财务总账的边界

清算、分账和结算经常被混称为“支付成功后的钱”,但它们回答的问题不同:清分是把一笔支付按平台、商家、服务商、税费和营销补贴拆成可核算的份额;结算是按冻结期、履约完成、售后窗口和渠道到账周期把可结算份额释放给收款主体;财务总账则按照会计科目和凭证规则记录账务。支付系统可以产出资金事实和分账明细,但不应直接冒充财务总账。

建议以“结算批次 + 结算明细 + 结算凭证”组织模型:结算批次绑定销售主体、账期、币种和规则版本;明细引用订单、支付、退款和分账快照;凭证记录批次冻结、审核、出款、失败和重试结果。结算计算必须可重算但不能静默覆盖历史结果,规则变更后应创建新的版本和差异报告。对于 B2B2C 场景,平台佣金、商家应收、供应商分成和营销补贴要分别列示,不能只保存一个“商家到账金额”。

层级权威事实典型状态主要消费者
支付渠道是否受理、扣款、退款处理中、成功、失败、退款中订单、售后、对账
清分每笔资金应归属于谁待清分、已清分、待结算结算、财务核算
结算哪个主体何时可收到钱冻结、待审核、已出款、失败商家、供应商、财务
总账会计科目和凭证是否平衡待入账、已入账、冲正财务报表、审计

这一区分能避免两个危险做法:一是订单支付成功后立刻把所有钱标记为商家可提现,绕过履约和售后冻结;二是为了让财务报表对上,直接修改支付单状态。正确方式是追加清分、结算或冲正事实,并通过对账任务解释每一次差异。

对账流程与差错闭环

日终或 T+N 对账应当是可重复执行的批处理,而不是一次性脚本。基本流程是:下载并校验渠道账单元数据,按渠道流水号和商户号解析,写入不可变的账单记录;读取平台支付、退款和分账事实;按币种和账单日期比对数量、金额、状态和退款累计;生成差异项;对可自动修复的差异执行查询或补发事件;其余差异进入人工工单并记录 SLA。

对账任务本身也需要幂等:同一渠道、日期、商户号和账单版本只能产生一个对账批次;账单下载成功不代表解析成功,解析成功不代表比对完成,比对完成也不代表差异已关闭。每个阶段都要有独立状态和重试入口。渠道账单可能晚到、重复下载或分片生成,因此不能以文件名作为唯一事实,应校验账单摘要、日期、渠道账号、版本和记录数。

自动修复必须有边界。仅缺失回调且渠道明确成功、金额和商户号都一致时,可以补发支付成功事件;金额不一致、币种不一致、平台不存在对应支付意图或退款累计超限时,应停止自动推进,冻结相关结算并创建高优先级工单。人工处理只能追加调整或冲正记录,不能直接删除平台账或覆盖原始渠道账单。

15.2.11 观测支付链路的完整性

支付链路需要同时具备业务指标和技术指标。业务指标包括支付发起成功率、支付成功率、支付处理中比例、回调延迟、退款成功率和对账差异率;技术指标包括接口延迟、渠道错误码分布、通知积压、重试次数、幂等命中率和死信数量。Google SRE 建议监控分布式系统时围绕延迟、流量、错误和饱和度组织指标,并使用面向用户可感知结果的告警。[5][6]

每次支付请求、渠道通知、订单状态迁移和库存确认都要通过 trace_id 关联。W3C Trace Context 规定了跨服务传播追踪上下文的标准字段,OpenTelemetry 语义约定则提供了统一的服务、HTTP、消息和数据库观测维度。[14][15] 因此,客服查询一笔“已扣款未出单”时,应能沿着订单号、支付单号、渠道流水号和 trace ID 找到完整证据,而不需要在多个系统中凭时间猜测。

15.2.12 库存确认与支付结果查单的依赖方向

为什么库存 Confirm 不应该反查订单中心,而订单中心必须主动查询支付网关

这两个场景表面上都像“结果确认”,但它们的依赖方向其实完全不同。

维度库存 Confirm支付结果查询
对象库存中心(内部服务)支付网关(外部第三方)
控制权我们完全可控我们不可控
推荐方向订单中心主动调用库存 Confirm订单中心主动查询支付网关状态
是否希望被动反查订单中心不希望不适用
核心原因避免内部服务双向依赖和职责污染第三方回调不可靠,必须主动核实

库存中心之所以不应该反查订单中心,核心原因有三个:

  • 避免循环依赖
    • 订单中心调用库存中心做 Reserve / Confirm / Cancel 是合理的单向依赖;
    • 如果库存中心再反过来查询订单中心,就会形成双向耦合。
  • 保持库存中心职责纯净
    • 库存中心的职责是管理库存资源和 reserve_token 状态;
    • 它不应该理解“这个订单是不是已经支付成功”这样的订单域语义。
  • 防止订单中心膨胀成上帝服务
    • 如果库存、营销、物流都回头查订单中心,订单中心会变成整个交易系统的公共查询枢纽;
    • 这会放大瓶颈,也会放大故障传播面。

因此,更合理的做法是:

  • 订单中心作为协调者,在支付成功后主动调用库存中心做 Confirm;
  • 库存中心只根据 reserve_token 做幂等状态流转:
    • RESERVED -> CONFIRMED
    • 或 RESERVED -> CANCELED

这也意味着,订单中心必须能正确处理库存 Confirm 的响应结果:

  • SUCCESS
  • ALREADY_CONFIRMED
  • RESERVE_TOKEN_NOT_FOUND
  • 网络超时 / 调用失败

并且这条 Confirm 调用本身必须支持幂等重试。通常的推荐做法是:

  • 支付成功后,订单中心在本地事务里写一条待确认库存的 Outbox / 本地消息;
  • 异步 Worker 调用库存中心 ConfirmInventory(reserve_token);
  • 只有收到明确成功响应,才把本地消息标记为完成;
  • 若响应丢失或调用失败,则按退避策略重试;
  • 多次失败后,把订单打到“支付成功_库存确认异常”状态,进入自动退款或人工补偿。

和库存中心不同,支付网关必须允许订单中心主动查询。原因也非常明确:

  • 它是外部第三方,我们无法完全掌控回调行为;
  • 回调可能丢失、延迟、重复甚至异常;
  • 所以只依赖回调不足以支撑订单状态推进。

因此支付结果的推荐模型通常是:

  • 以回调为主
  • 以主动查询为兜底

也就是说:

  • 支付网关回调来了,订单中心先按回调推进支付事实;
  • 如果回调迟迟不来,订单中心可以通过定时任务、用户主动查询或收银台轮询去调用支付网关 query 接口确认状态;
  • 一旦确认支付成功,再继续触发库存 Confirm、权益 Confirm 和履约编排。

一句话总结就是:

库存是我们的内部资源中心,要保持干净、独立、单向依赖;支付网关是外部不可信第三方,订单中心必须主动多长一个心眼去确认它的最终状态。

15.2.13 Outbox 如何可靠驱动库存 Confirm

Outbox 本地消息表:支付成功后如何可靠驱动库存 Confirm

如果订单中心承担了主动 ConfirmInventory 的职责,接下来最关键的问题就是:

  • 支付成功后,怎么保证这条 Confirm 动作一定会被发出去;
  • 如果调用库存中心时网络超时、响应丢失,怎么继续重试;
  • 如果系统重启、进程崩溃,怎么保证这条确认任务不会消失。

当订单事实与待执行意图能够在同一个数据库事务中提交时,本案例采用 Outbox Pattern(本地消息表模式);它可靠交接本地意图,下游确认仍依赖幂等、查询与恢复。[18]

核心思想是:

把“订单状态更新”和“待确认库存消息写入”放进同一个本地事务里,先把消息落到订单库自己的 outbox 表,再由异步 Worker 扫描并可靠投递。

这里推荐表名直接使用 outbox,而不是 local_message,原因有三点:

  • outbox 更符合行业标准语义;
  • 在面试和评审里,一说就能让人联想到 Outbox Pattern;
  • 后续如果再引入 inbox、事件总线或双向事件处理,命名也更自然。

推荐的表结构大致如下:

CREATE TABLE `outbox` (
    `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
    `message_id` VARCHAR(64) NOT NULL COMMENT '全局唯一消息ID',
    `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型:INVENTORY_CONFIRM、MARKETING_CONFIRM 等',
    `biz_key` VARCHAR(64) NOT NULL COMMENT '业务唯一键,如 order_no',
    `payload` JSON NOT NULL COMMENT '消息体',
    `status` VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PROCESSING/SUCCESS/FAILED/DEAD',
    `retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数',
    `next_retry_time` DATETIME NOT NULL COMMENT '下次重试时间',
    `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_message_id` (`message_id`),
    UNIQUE KEY `uk_biz` (`biz_type`, `biz_key`),
    KEY `idx_status_retry` (`status`, `next_retry_time`),
    KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Outbox 表(本地消息表)';

几个关键字段的职责分别是:

字段作用
message_id全局唯一消息标识,方便排障和防重
biz_type区分库存确认、营销确认、退款等不同任务
biz_key业务唯一键,通常用 order_no 或 reserve_token
payload真正的调用参数,如 reserve_token / order_no / sku_list
status当前处理状态,支持重试和死信管理
retry_count已重试次数
next_retry_time下一次允许被扫描和重试的时间点

对应到库存 Confirm 场景,订单中心最典型的本地事务写法就是:

  1. 支付回调确认成功;
  2. 在同一个数据库事务里:
    • 更新订单状态为 PAID
    • 插入一条 biz_type = INVENTORY_CONFIRM 的 Outbox 记录
  3. 事务提交后,异步 Worker 扫描 PENDING/FAILED 且 next_retry_time <= now() 的消息;
  4. Worker 调用库存中心 ConfirmInventory(reserve_token);
  5. 若收到明确成功响应,则把 Outbox 记录标记为 SUCCESS;
  6. 若调用失败或响应丢失,则把状态改成 FAILED,并按指数退避推进 next_retry_time。

这里尤其要注意两层幂等:

  • 订单中心侧幂等
    • 同一笔 biz_type + biz_key 的 Outbox 记录只能插一次;
    • Worker 重复扫描时,也不能重复推进本地状态。
  • 库存中心侧幂等
    • 以 reserve_token 为主键推进状态机:
    • RESERVED -> CONFIRMED
    • 重复 Confirm 返回成功或 ALREADY_CONFIRMED

因此,订单中心处理库存 Confirm 的推荐口径可以总结成:

  • 同步调用可以有,但不能只依赖同步 response;
  • 最终必须以 Outbox + Worker 重试为准;
  • 只有收到明确成功响应,才把本地消息标记完成;
  • 多次重试仍失败,就把订单打到“支付成功_库存确认异常”状态,进入自动退款或人工补偿。

如果用 Go 落地,这个 Worker 的职责通常就是:

  • 周期性扫描 outbox
  • 反序列化 payload
  • 调库存中心 Confirm 接口
  • 根据返回结果更新 status / retry_count / next_retry_time

也就是说,Go 代码真正需要保证的不是“调一次 RPC 就好”,而是:

订单状态推进、Outbox 持久化、异步 Worker 重试、库存 Confirm 幂等,这四层一起构成最终一致性。

15.3 履约、售后与历史事实

15.3.1 场景画像

订单支付成功只是用户交易旅程的中点,不是终点。后面的系统挑战包括:

  • 实物商品怎么发货、签收、退货退款。
  • 券码商品怎么发码、核销、退款。
  • 酒旅、到店和预约型商品怎么预约、履约、取消和退款。

这里最重要的统一原则是:

订单之后,系统不再回头依赖最新商品真相,而是基于订单快照和履约事实继续推进。

15.3.2 关键技术点

履约、发码、核销、退款、库存回补、权益回补,看起来像很多独立动作,但真正落地时不能让订单中心自己直接编排所有下游,否则它会再次膨胀成“大管家”。

因此,第 6 节建议显式引入一个统一角色:

履约与售后服务

它的职责不是拥有订单主状态,而是承接支付之后的流程编排:

  • 组织履约创建
  • 接收外部履约回调
  • 路由发码、核销、预约等不同履约分支
  • 编排退款、库存回补、权益回补
  • 把这些结果统一翻译成订单中心可消费的标准化事实

这套设计里需要先钉死 5 个原则:

  1. 订单中心拥有交易主事实和用户可见订单状态。 履约与售后服务只负责组织流程,不直接决定最终订单展示语义。

  2. 履约结果、核销结果、退款结果都只是事实输入。 外部仓配、发码、核销、退款系统只负责回传“发生了什么”,状态推进仍由订单中心完成。

  3. 售后必须基于订单快照、支付事实和履约事实。 不能在退款时重新查当前商品价格、当前详情页规则或当前库存展示。

  4. 实物、券码、预约型商品虽然路径不同,但必须复用同一套幂等、回调归一和补偿机制。

  5. 外部回调先进入履约与售后服务,再由订单中心消费标准化事件。 不让仓配、核销、退款系统直接改订单主状态。

可以把它和第 5 节做一个镜像理解:

  • 结算服务解决“如何生成一笔订单”
  • 履约与售后服务解决“订单支付之后如何被执行、被核销、被退款、被回补”

15.3.3 场景一:履约发起时序图

sequenceDiagram
    participant O as 订单中心
    participant FA as 履约与售后服务
    participant W as 仓配服务 / 发码服务 / 预约资源服务

    O->>F: OrderPaid
    O->>FA: OrderPaid(sub_order_id, order_type, snapshot)
    FA->>FA: 按商品类型路由履约
    FA->>W: CreateFulfillment / IssueVoucher / CreateBooking
    W-->>FA: 返回 fulfillment_id / issue_task_id / booking_token
    FA->>O: 发布 FulfillmentCreated 事件
    O->>O: 推进订单为 TO_FULFILL / TO_SHIP / TO_ISSUE / TO_BOOK

15.3.4 场景二:履约结果回调与订单编排时序图

sequenceDiagram
    participant W as 仓配 / 供应商 / 履约外部系统
    participant FA as 履约与售后服务
    participant O as 订单中心
    participant IV as 库存服务

    W-->>FA: 回调发货成功 / 签收 / 履约完成 / 履约失败
    FA->>FA: 验签 + 幂等 + 状态归一
    FA->>O: 发布 FulfillmentUpdated 事件
    alt 发货成功 / 履约完成
        O->>O: 推进订单为 SHIPPED / DELIVERED / FULFILLED
        O->>IV: ConsumeFulfillmentResource(如需要)
    else 履约失败 / 无法履约
        O->>O: 推进订单为 FULFILLMENT_FAILED
    end

15.3.5 场景三:券码发码与核销时序图

sequenceDiagram
    participant O as 订单中心
    participant FA as 履约与售后服务
    participant IV as 库存服务 / 券码池服务
    participant V as 发码服务 / 核销服务

    O->>FA: OrderPaid(voucher_order)
    FA->>IV: AllocateCode / ConfirmInventory
    IV-->>FA: 返回券码或资源实例
    FA->>V: 发码 / 生成核销凭证
    FA->>O: 发布 VoucherIssued 事件
    V->>V: 用户到店核销
    V->>FA: 回调 VoucherConsumed / VoucherExpired
    FA->>O: 发布 VoucherConsumed / VoucherExpired 事件
    O->>O: 推进订单 / 子单状态

15.3.6 场景四:售后退款与库存 / 权益回补时序图

sequenceDiagram
    participant U as 用户
    participant FA as 履约与售后服务
    participant O as 订单中心
    participant PAY as 支付服务
    participant IV as 库存服务
    participant MK as 营销 / 资产服务

    U->>FA: 发起退款 / 取消 / 退货
    FA->>O: 校验订单快照、支付状态、履约状态
    O-->>FA: 返回可退条件
    FA->>PAY: 发起退款
    PAY-->>FA: 退款成功 / 退款失败
    FA->>IV: 按规则回补库存
    FA->>MK: 回补或关闭权益
    FA->>O: 发布 RefundSucceeded / AftersaleClosed 事件
    O->>O: 推进订单为 REFUNDING / REFUNDED / PARTIAL_REFUNDED / AFTERSALE_CLOSED

15.3.7 特殊场景:预约 / 酒旅 / 到店履约

这几类场景看起来不像传统“发货”,但它们并不是独立订单系统,而只是履约与售后服务下的不同履约分支。

它们的共同点是:

  • 都不再依赖最新商品真相
  • 都依赖订单快照和支付后的履约事实推进
  • 都需要把外部世界的状态翻译成订单中心可理解的标准化事件

其中最典型的事实包括:

  • 预约 / 酒旅:
    • BookingConfirmed
    • CheckedIn
    • CheckedOut
    • BookingCanceled
  • 到店 / 券码:
    • VoucherIssued
    • VoucherConsumed
    • VoucherExpired

也就是说,履约与售后服务本质上承担了一个“协议翻译层”的角色:

  • 下游系统说的是“已发码”“已预约”“已入住”“已核销”
  • 订单中心认的是“这笔订单现在是否已履约、是否可退款、是否已完结”

15.3.8 履约与售后服务的统一架构原则

第 6 节之所以也需要一个明确的编排角色,原因和第 5 节是对称的:

  • 第 5 节有结算服务 / 支付编排服务,解决“怎么生成一笔订单”
  • 第 6 节有履约与售后服务,解决“订单支付之后如何被执行、核销、退款、回补”

如果没有这一层,订单中心就必须直接对接:

  • 仓配
  • 发码
  • 核销
  • 预约资源
  • 退款
  • 库存回补
  • 权益回补

这样它会再次膨胀成“大管家”,把整个后交易阶段的复杂性都吸进去。

因此本章的统一结论是:

  • 订单中心拥有订单主事实和用户可见订单状态
  • 履约与售后服务拥有支付后的流程编排职责
  • 下游仓配、发码、核销、退款、回补服务只提供事实,不直接拥有订单主状态

最后用一句话收口:

结算服务解决“如何生成订单”,履约与售后服务解决“订单支付之后如何被执行、被核销、被退款、被回补”。


15.4 端到端案例与特殊订单类型

15.4.1 实物电商主链路

用户搜索手机 → 看详情页 → 加购物车 → 进入结算页试算和预占 → 创建订单 → 支付成功 → 仓配发货 → 签收完成 → 可发起退货退款。

这一链路的核心风险在于:

  • 列表页展示价与下单价不一致。
  • 支付失败后库存未释放。
  • 发货后退款和库存回补规则错乱。

15.4.2 券码 / 到店商品主链路

用户搜索餐饮券或景区票 → 详情页看使用规则 → 结算页试算与预占 → 支付成功 → 发码 → 到店核销 → 核销完成 → 按规则退款或不可退。

这一链路的核心风险在于:

  • 发空码。
  • 券码核销与订单状态不一致。
  • 退款后券码或权益没有正确回收。

15.4.3 酒旅 / 预约型商品主链路

用户查看房型、日期、价格日历 → 结算页锁房 / 预占 → 创建订单 → 支付成功 → 预约 / 确认单生成 → 入住 / 出行 / 使用完成 → 按取消规则退款。

这一链路的核心风险在于:

  • 详情页看见的日期价与下单时的真实价格不一致。
  • 预约资源已变动但订单仍按旧状态继续推进。
  • 售后取消没有依据订单时的规则解释。

15.5 订单、支付与履约的可靠性治理和演进

15.5.1 端到端失败矩阵

可靠性设计不能只按服务名列故障,而要按一笔交易已经走到哪一步来定义处理。下面的矩阵把“用户看到什么、系统保存什么、后台做什么”放在一起:

故障点已发生事实用户侧语义后台动作最终判定
搜索索引落后商品中心已更新允许展示旧候选,但详情和结算需刷新异步重建索引读投影收敛
购物车写入超时未知是否写入允许查询后重试按请求键反查版本意愿不重复
价格成功、库存失败价格上下文已生成提示暂不可购买释放或过期价格上下文结算拒绝
库存成功、创单超时可能已预占不立即重复提交按结算凭证反查订单并补偿订单或预占收敛
创单成功、支付未返回订单事实已落库显示待支付 / 处理中查询支付,超时关闭订单有明确终态
渠道已扣款、订单未支付外部资金事实存在显示处理中,不允许重复支付查询渠道、补发支付事件支付与订单对账一致
支付成功、履约创建失败资金事实已成立显示支付成功、履约处理中重试履约并告警不回滚支付事实
发货回调重复履约事实已处理状态不变事件幂等、记录重复通知不重复发货
退款处理中申请已受理显示退款中查询渠道、限制重复申请退款最终收敛

这里有一个重要边界:补偿不是把时间倒流,而是产生新的业务事实。例如支付成功后不能简单把订单删除来“回滚”,而应保留支付记录,依据库存、履约和退款策略生成取消、退款或人工介入事实。Saga 的核心价值就在于把长事务拆成可补偿步骤,但补偿动作本身也必须幂等并可观测。[1][2]

15.5.2 幂等、防重与乱序处理

幂等要按层设计,而不是只加一个 Redis 锁:

层次机制解决的问题不能解决的问题
客户端防抖、按钮状态、提交令牌减少重复点击无法防网络重试和恶意请求
网关用户 / IP / 设备限流削减重复流量无法保证业务只执行一次
服务接口幂等键、参数摘要、结果缓存同一请求重复调用无法处理跨接口的业务冲突
数据库唯一键、版本条件更新最终事实防重不能替代补偿和消息投递
消费端event_id、聚合版本、处理表重复和乱序消息不能自动修复错误业务规则
对账任务状态查询、差异清单长时间未知状态不能替代实时用户体验

对于事件消费者,推荐使用“事件 ID 去重 + 聚合版本检查 + 业务前置条件”三道判断:先判断是否处理过,再判断版本是否更新,最后判断当前订单状态是否允许这次迁移。版本落后但事件未处理时不能简单丢弃,应按事件类型进入重放或反查队列;因为有些事件虽然晚到,仍可能代表一个尚未被当前读模型吸收的事实。

15.5.3 重试、退避、死信与人工接管

重试策略应由错误类型驱动:

  • 参数错误、签名错误、权限错误:不重试,记录并告警。
  • 资源暂时不足、连接超时、服务过载:有限次数重试,使用指数退避和抖动。
  • 外部渠道处理中:按渠道建议间隔查询,不盲目重复发起支付或退款。
  • 状态未知且超过自动窗口:进入对账队列或人工接管。
  • 业务规则冲突:冻结当前聚合,保留证据,等待修复或人工决定。

重试预算应该在整条调用链上计算。例如前端最多重试 2 次,网关重试 1 次,服务内部重试 3 次,下游消息再重试 5 次,并不代表系统最多执行 11 次;嵌套重试可能形成乘法放大。AWS 的可靠性指导把限制重试、设置退避和避免跨层重复重试作为过载防护的一部分。[8] 本章建议把 attempt、retry_budget、next_retry_at 和 last_error_class 写入任务记录。

死信不是垃圾桶。死信消息必须带有原始事件、失败次数、最后错误、首次失败时间、所属聚合和可执行动作。运维人员重放前要确认当前状态、版本和补偿风险,重放后要能看到结果。对于支付、退款和库存这类敏感动作,人工操作也必须经过权限审批并留下审计记录。

支付与退款的安全治理

支付系统的安全边界不能只靠“回调验签”四个字概括。至少要把身份、完整性、重放、权限和审计分开治理:

风险需要证明的事实控制措施发现问题后的动作
伪造回调通知确实来自渠道且未被篡改渠道证书、公钥轮换、验签、时间窗口拒绝处理并记录安全事件
金额篡改渠道金额等于本地支付快照订单号、商户号、币种、金额交叉校验冻结支付尝试和相关结算
重放通知同一渠道流水未重复产生副作用通知指纹、流水号唯一键、版本条件更新返回幂等成功但不重复发事件
越权退款操作人和订单状态具备退款权限角色分权、审批、订单项范围校验拒绝并保留审计记录
敏感数据泄露日志和快照不包含不必要的支付数据令牌化、脱敏、密钥托管、最小权限吊销凭证并启动安全响应
重试双扣外部未知时没有重复创建扣款幂等键、先查单后切换、重试预算转处理中或人工核验

密钥轮换不能只替换配置文件。渠道适配器需要同时支持旧公钥和新公钥的过渡窗口,并记录每次验签使用的 key version;否则轮换期间的失败会被误判成渠道故障。支付通知的原文也不应永久暴露在日志系统,应该保留受访问控制保护的原始证据引用、摘要和脱敏字段。

退款和调账尤其需要职责分离:提出退款申请的人不应同时批准高风险退款,执行渠道退款的人不应拥有修改原始支付金额的权限。人工纠错不能直接改支付主表的金额或状态,而应生成带原因、审批人、前后差异和关联工单的纠错事件,再由状态机按规则应用。这样既能保留审计证据,也能避免“为了让页面显示正确”破坏对账依据。

多渠道容量隔离与降级

渠道接入的容量曲线通常不同:某个渠道可能限制每秒请求数,另一个渠道可能在高峰时尾延迟显著上升,银行转账还可能依赖批量文件而不是实时接口。因此支付网关不能使用一个共享线程池和一个全局超时。应按渠道、商户号和操作类型隔离连接池、并发配额、超时和重试预算;支付创建、查单、退款和账单下载也要分开,避免账单任务占满实时支付资源。

降级必须保护资金事实而不是只保护接口成功率:

  • 渠道预下单不可用时,可以隐藏该支付方式或切换到经过确认的备用渠道,但不能对未知扣款直接重发。
  • 渠道回调处理积压时,可以让收银台显示处理中,不能把超时订单直接标成失败并释放资源。
  • 对账文件晚到时,可以延后结算批次,但不能静默跳过差异。
  • 退款渠道异常时,应保持退款中并展示预计处理状态,不能为了降低失败率重复扣减可退余额。

因此支付服务的核心 SLO 不应只有接口可用率,还应包含成功事实不重复率、未知状态闭合时延、退款累计金额不变量、回调积压时间和对账差异关闭时延。Google SRE 关于过载处理的观点可以支撑“先保护关键事实,再降低非关键体验”的排序;本章进一步将其推导到支付场景,即宁可让用户暂时看见处理中,也不能为了即时返回而制造重复资金动作。[4]

商品发现的索引、Query/Rank、Hydrate 和搜索实验故障由第 14 章搜索治理矩阵统一展开。本章只保留其交易影响:旧列表价或库存摘要不能成为成交依据,交易准备必须重新核验权威事实;无法校验时返回明确拒绝或可查询的处理中状态,不静默放行。

15.5.4 可观测性:从技术指标到业务不变量

常规的 CPU、内存和接口 RT 不足以判断交易系统是否健康。至少需要四类指标:

  1. 用户结果:搜索成功率、结算成功率、创单成功率、支付成功率、履约及时率、退款完成时延。
  2. 状态流转:各状态停留时间、状态迁移失败数、重复事件数、乱序事件数、待处理聚合数。
  3. 资源与消息:库存预占量、预占超时量、Outbox 积压、消费者延迟、死信数、对账差异数。
  4. 安全与合规:签名失败、越权访问、异常支付频率、敏感字段访问、人工纠错操作。

告警要围绕“用户结果和不变量”设定,而不是为每个指标设置阈值。例如“支付成功但 10 分钟后仍无履约任务”的比例,比单独监控履约服务 CPU 更能发现真实故障;“退款累计金额大于支付金额”的不变量违反,应立即告警,即使服务总体错误率很低。Google SRE 的监控和告警原则要求告警能够驱动明确行动,并尽量减少无法行动的噪声。[5][6]

链路追踪要贯穿用户请求、结算凭证、订单、支付单、事件和下游任务。日志字段至少统一:trace_id、request_id、user_id_hash、order_id、sub_order_id、payment_id、event_id、attempt、state_version 和 error_code。个人信息与支付敏感字段要脱敏,日志不能为了方便排查而复制完整地址、身份证号、卡号或验证码。

支付治理还需要把指标组织成可验证的不变量,而不是只看各系统自己的成功率。可以每天或每小时计算以下关系:支付意图的成功金额不应大于订单应付金额;一笔支付的成功金额减去成功退款金额不应小于零;同一渠道流水号只能对应一个本地渠道交易;已发布的支付成功事件数量应能与支付状态历史中的成功迁移数量解释;已结算金额不应超过已确认支付金额扣除冻结和退款后的可结算金额。指标出现偏差时,先生成差异快照和影响范围,再决定是否暂停履约或结算,不能直接用批量更新把数字抹平。

这类不变量最好由独立的治理任务计算,而不是由支付主链路临时查询多个系统。治理任务读取不可变支付流水、退款流水、结算明细和事件投递记录,生成带时间窗口与版本的检查结果;检查结果本身也要可审计。这样既能避免高峰期把在线支付链路拖慢,也能让运营在故障复盘时回答“问题从哪个时间窗口开始、影响了多少金额、哪些订单已经自动收敛”。

15.5.5 灰度、回滚与数据修复

交易链路的灰度不能只按服务实例比例切流,还要考虑订单状态和数据版本。推荐按用户、设备、销售主体或订单号做稳定路由,并为每次变更记录:协议版本、状态机版本、快照 schema、事件 schema、回滚条件和观测窗口。

涉及状态机或金额计算的变更,应采用兼容优先的双读 / 双写策略:先让新版本能够读取旧快照和旧事件,再逐步写出新字段;确认新旧结果一致后,才切换主读路径。事件 schema 需要版本化,消费者要能在一段迁移窗口内处理至少两个版本。回滚应用版本不等于回滚已经产生的订单事实,数据修复应通过受控的纠错事件完成。

数据修复至少要遵循:先冻结自动动作、导出证据、计算影响范围、生成可审计的修复计划、小批量执行、复核结果、解除冻结。不能直接批量改订单状态来“修好看板”,否则会破坏支付、履约、售后和对账证据链。

15.5.6 容量估算与隔离

容量设计要按交易阶段拆分,而不是拿站点总访问量平均分配。设每日访问用户为 (U),商品详情转化率为 (c_d),加购率为 (c_a),结算率为 (c_c),支付率为 (c_p),则可以用下列假设估算日级业务量:

detail_views  = U × c_d
cart_actions  = detail_views × c_a
checkouts     = cart_actions × c_c
orders        = checkouts × c_p

峰值不能简单等于日均除以 86400,应另外给出峰值系数、活动集中系数和热点 SKU 系数。搜索和详情读流量通常数量级更大,应与结算、创单和支付写流量隔离;热点库存、支付回调、Outbox 消费和对账任务也应分别设置连接池、线程池和限流器。

容量估算最重要的不是得到一个“准确数字”,而是说明假设改变时哪个边界先失效。若支付成功率不变但结算转化率突然提升,库存预占和订单库写入可能先成为瓶颈;若支付渠道变慢,待处理支付和订单保留量会先膨胀。每个容量结论都要配套一个降级动作和一个扩容触发指标。

15.5.7 演进路线:从单体交易模块到领域协作

不建议一开始就把所有能力拆成大量微服务。可以按事实主权和变化速度分阶段演进:

  1. 第一阶段:单体内按商品、购物车、结算、订单、支付、履约模块分层,先建立快照、状态机和幂等契约。
  2. 第二阶段:将搜索读模型、购物车存储、支付渠道适配和履约回调接入独立出来,降低读写和外部不稳定性的影响。
  3. 第三阶段:按销售主体和履约边界拆分订单子域,引入 Outbox、事件版本和对账平台。
  4. 第四阶段:对高峰流量、跨境履约和复杂预约引入工作流编排,但保留订单事实和支付事实的主权边界。

拆分标准不是“服务越多越先进”,而是某个边界是否拥有独立的数据主权、容量曲线、发布节奏和故障隔离收益。Enterprise Integration Patterns 对消息通道、路由、转换、幂等接收者和事务消息等模式的总结,可作为跨域协作设计的共同语言。[3] 本章结合电商场景的推导是:只有当一个边界能够定义自己的事实、事件、补偿和运维责任时,才值得成为独立服务。

15.6 本章总结、ADR、评审清单与参考资料

订单创建之后,关键是让资金、订单和履约事实逐步收敛,同时保留可解释、可恢复的证据:

  1. 订单是交易事实,支付事实由支付域拥有,订单域根据标准事实推进自己的状态机。
  2. 支付成功后,库存、权益和履约通过幂等事件确认;未知状态由查单、对账和补偿闭合。
  3. 售后基于订单快照和履约事实处理,不能依赖商品当前配置重建历史交易。

15.6.1 ADR-00:支付采用“支付意图—支付尝试—渠道交易”三级模型

  • 背景与问题:一个订单可能更换支付方式、重复打开收银台、收到重复或乱序通知,渠道同步返回也可能只是受理结果。若订单只保存一个渠道流水号,未知状态和部分退款会被迫覆盖原事实。
  • 决策驱动因素:资金安全、渠道可替换、幂等重试、对账可追溯、部分退款和多币种扩展能力。
  • 候选方案:订单直接绑定渠道交易;支付中心只维护一张支付表;支付意图、支付尝试和渠道交易分层建模。
  • 最终决策:支付意图绑定订单应付快照,支付尝试记录一次方式和路由决策,渠道交易保存外部流水与原始证据;退款单和结算明细继续作为独立事实对象。
  • 获得的能力:可以在不覆盖历史事实的前提下切换支付方式、处理未知状态和多次部分退款,并且能够按渠道流水号完成回调、查单和对账闭合。
  • 主动牺牲的能力:数据模型和查询编排更复杂,前端需要理解处理中状态,财务与客服需要使用多个关联号排查问题。
  • 已接受的风险:对象之间可能出现暂时未收敛的关联;通过唯一键、版本条件更新、Outbox、主动查单和对账批次控制风险。
  • 验证指标:重复扣款率、支付尝试未知状态闭合时间、支付与渠道流水匹配率、部分退款超限数、渠道路由切换后的成功率和对账差异关闭时间。
  • 重新评估条件:出现新的资金来源、跨境币种或监管要求,导致当前三级模型无法表达资金归属时,新增明确的事实对象,不把新字段继续堆到支付主表。

15.6.2 ADR-01:采用“结算凭证 + 订单本地事务 + 异步补偿”

  • 背景与问题:结算需要接入商品、价格、库存、营销和履约规则。若让支付等待窗口也占用跨资源 prepare 状态,会扩大资源占用与恢复成本;本案例的外部支付又不能加入 XA,不能把整条生命周期包装成一个全局提交。
  • 决策驱动因素:吞吐、尾延迟、资源隔离、补偿能力、团队可运维性和外部系统可控程度。
  • 候选方案:在协议兼容的短资源边界使用 XA/2PC;同步调用加恢复;持久化编排式 Saga;资源参与者采用 TCC。Outbox 是可靠事件基座,不是另一种互斥提交协议。
  • 最终决策:在外部支付不能加入 XA、库存和有限权益可预留、后续状态允许异步收敛的本案例中,采用预占资源的持久化编排式 Saga。结算产生带身份、版本和 TTL 的报价及资源凭证;订单本地事务保存订单、快照和待发意图;下游按权威预占状态幂等 Confirm/Cancel,长期未知结果由查单、对账和人工处理推进。
  • 获得的能力:短本地事务、较少的跨域同步等待、明确的资源释放入口,以及按域治理容量与恢复的边界;不承诺未经压测的固定吞吐。
  • 主动牺牲的能力:不保证跨所有域的瞬时强一致;用户可能短暂看到“处理中”;补偿和对账系统必须长期运行。
  • 已接受的风险:重复消息、乱序事件、预占过期和迟到支付事实增加状态机复杂度;Confirm/Cancel 需要条件更新,未知支付不能直接释放或再次扣款,补偿失败可能转为人工处置。
  • 验证指标:结算成功率、凭证失效率、预占超时量、补偿成功率、Outbox 延迟、支付成功后履约创建时延和对账差异率。
  • 重新评估条件:参与资源需要共同确认决定、当前预占无法保护业务不变量、补偿成本持续高于收益或等待跨度明显增长时,重新评估 TCC、协议兼容的局部 XA 与持久化工作流。Seata 官方资料区分 AT、TCC、Saga 等模式;框架选择不能替代业务保证,也不由“金融级”标签决定。[20][21][22]

15.6.3 ADR-02:订单保存不可变交易快照

  • 背景与问题:订单和售后需要解释当时的成交事实,不能依赖不断变化的商品、价格或搜索投影。
  • 决策驱动因素:快照完整性、版本身份、退款复算、客服解释、审计与存储成本。
  • 候选方案:仅保存当前商品引用;只保存外部快照引用;保存交易必需的本地不可变快照并关联来源版本。
  • 最终决策:订单本地事务保存商品、价格组件、分摊、资源凭证与履约规则的必要快照及来源版本。后续商品或规则更新不覆盖它;纠错产生受控新事实,不改写原成交解释。
  • 获得的能力:历史订单可解释,部分退款可按原分摊复算,跨域故障时仍能查询当时承诺。
  • 主动牺牲的能力:增加快照存储、版本治理和数据保留成本;交易前必须校验,快照不能证明外部资源当前仍可用。
  • 已接受的风险:提交时来源变化、字段遗漏或保存过多敏感数据;通过凭证有效期、必填字段、访问控制与审计约束处理。
  • 验证指标:快照完整率、版本绑定错误数、退款复算差异和订单争议可解释率。
  • 重新评估条件:新品类承诺、售后规则或保留要求变化时复核快照字段。搜索与详情读模型选型由第 14 章搜索 ADR负责,Elasticsearch 并发控制和缓存只承担读模型职责,不能证明交易事实。[11][12]

15.6.4 ADR-03:状态机由订单域拥有,支付与履约通过标准事实事件驱动

  • 背景与问题:支付、仓配、发码、预约和退款都有自己的状态,但用户需要一个统一、可解释的订单状态。
  • 最终决策:订单中心拥有用户可见主状态和状态迁移规则;支付中心拥有支付事实;履约与售后服务拥有流程编排;外部系统通过验签、幂等和事件归一后输入标准事实。
  • 获得的能力:状态主权清晰、渠道可替换、事件可重放、售后基于事实解释。
  • 主动牺牲的能力:订单详情可能短暂显示处理中;跨域排障需要 trace、事件和对账工具;各域要维护自己的幂等与补偿。
  • 重新评估条件:如果某个履约领域形成独立产品、拥有独立状态和独立 SLO,可以把它拆为更明确的子域,但不能通过让外部系统直接写订单主状态来“简化”当前链路。

15.6.5 订单、支付与履约评审清单

评审订单之后的交易链路时,重点确认:

  1. 每个关键事实的权威系统是谁?其他系统保存的是投影、快照还是引用?
  2. 展示数据、交易前提和交易事实分别允许多大延迟?
  3. 结算凭证绑定了哪些身份、版本、金额、资源和过期信息?
  4. 同一请求、同一事件、同一订单状态被重复处理时会发生什么?
  5. 超时后如何反查,消息失败后如何重试,长期未知后谁来对账?
  6. 订单快照是否足以解释客服、售后、退款和争议?是否误存敏感支付数据?
  7. 支付成功后库存确认、权益确认和履约创建的幂等键分别是什么?
  8. 是否有部分支付、部分退款、拆单、预售、0 元订单和预约商品的状态模型?
  9. 是否能从 trace_id、订单号、支付单号和事件 ID 还原一条完整证据链?
  10. 每个降级、回滚和人工接管动作是否有明确的触发条件、权限和审计记录?

15.6.6 参考资料

[1] Hector Garcia-Molina, Kenneth Salem, “Sagas”, ACM SIGMOD Record, 1987, https://doi.org/10.1145/38713.38742

[2] Pat Helland, “Life Beyond Distributed Transactions: An Apostate’s Opinion”, CIDR, 2007, https://www.cidrdb.org/cidr2007/papers/cidr07p15.pdf

[3] Gregor Hohpe, Bobby Woolf, “Enterprise Integration Patterns”, official pattern catalog, https://www.enterpriseintegrationpatterns.com/

[4] Google SRE, “Handling Overload”, Google SRE Book, https://sre.google/sre-book/handling-overload/

[5] Google SRE, “Monitoring Distributed Systems”, Google SRE Book, https://sre.google/sre-book/monitoring-distributed-systems/

[6] Google SRE, “Practical Alerting from Time-Series Data”, Google SRE Book, https://sre.google/sre-book/practical-alerting/

[7] Amazon Web Services, “Making Retries Safe with Idempotent APIs”, Builders’ Library, https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/

[8] Amazon Web Services, “Limit Retries”, Well-Architected Framework, https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_mitigate_interaction_failure_limit_retries.html

[9] Apache Kafka, “Design: Message Delivery Semantics / Using Transactions”, 4.1 版本文档, https://kafka.apache.org/41/design/design/,核验日期:2026-10-09。

[10] Apache Kafka, “Upgrade Notes: Idempotent and Transactional Producers”, official documentation, https://kafka.apache.org/38/getting-started/upgrade/

[11] Elastic, “Optimistic Concurrency Control”, Elasticsearch Reference, https://www.elastic.co/docs/reference/elasticsearch/rest-apis/optimistic-concurrency-control

[12] Elastic, “Shard Request Cache”, Elasticsearch Reference, https://www.elastic.co/docs/reference/elasticsearch/rest-apis/shard-request-cache

[13] Oracle, “InnoDB Transaction Model”, MySQL 8.4 Reference Manual, https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-model.html,核验日期:2026-10-09。

[14] World Wide Web Consortium, “Trace Context”, W3C Recommendation, https://www.w3.org/TR/trace-context/

[15] OpenTelemetry, “General Semantic Conventions”, official specification, https://opentelemetry.io/docs/specs/semconv/general/

[16] IETF, “HTTP Semantics”, RFC 9110, 2022, https://datatracker.ietf.org/doc/rfc9110/

[17] IETF, “Problem Details for HTTP APIs”, RFC 9457, 2023, https://datatracker.ietf.org/doc/html/rfc9457

[18] Chris Richardson, “Pattern: Transactional outbox”, microservices.io, https://microservices.io/patterns/data/transactional-outbox

[19] PCI Security Standards Council, “Document Library”, official standards library, https://www.pcisecuritystandards.org/document_library/

[20] Apache Seata, “What is Seata?”, 中文官方文档, https://seata.apache.org/zh-cn/docs/overview/what-is-seata/

[21] Apache Seata, “Saga 模式”, 中文官方文档, https://seata.apache.org/zh-cn/docs/user/mode/saga/

[22] Apache Seata, “Seata TCC Mode”, v2.6 官方文档, https://seata.apache.org/docs/user/mode/tcc/,核验日期:2026-10-09。

[23] Stripe, “Idempotent requests”, official API documentation, https://docs.stripe.com/api/idempotent_requests

[24] Adyen, “Handle webhook events”, official documentation, https://docs.adyen.com/development-resources/webhooks/handle-webhook-events

附录 A:术语表

本附录汇总全书高频术语,便于读者在阅读方法论、可靠性和电商实战章节时统一语义。

架构与建模

术语说明
系统设计围绕业务目标、容量、数据、边界、依赖、失败处理和演进路径做出的整体工程设计。
架构系统中最难回退的一组关键决策,包括职责边界、数据事实、集成方式、部署形态和治理机制。
限界上下文(Bounded Context)DDD 中承载统一语言和模型边界的业务语义范围。上下文之间通过显式契约协作。
通用语言(Ubiquitous Language)业务、产品、研发、测试和运营共同使用的一组稳定术语,用来减少沟通偏差。
聚合(Aggregate)DDD 战术设计中的一致性边界,聚合内部维护不变量,聚合之间通过 ID、事件或服务协作。
防腐层(ACL)隔离外部系统、旧系统或第三方模型的转换层,避免外部语义污染核心领域模型。
CQRS命令查询职责分离。写模型关注一致性和状态变更,读模型关注查询效率和展示体验。
ADRArchitecture Decision Record,架构决策记录,用于记录关键取舍、备选方案和后续影响。

数据与一致性

术语说明
SSOTSingle Source of Truth,权威数据源。系统设计中必须明确每类事实由谁负责。
最终一致性允许暂时差异;在更新停止、传播和恢复条件满足后,相关副本或派生视图最终收敛。该语义本身不承诺固定时间内收敛。[1]
有界陈旧读在额外机制和前提下,将读取结果相对权威事实的滞后限制在约定范围内;不能由最终一致性这一名称直接推出。
数据新鲜度 SLO衡量数据在目标窗口内更新到位的比例。超过目标需要告警和恢复升级,不表示系统已自动一致。
幂等同一业务请求重复执行多次,最终业务结果保持一致。支付、库存、消息消费和补偿任务必须重点设计。
Outbox在本地事务内写业务数据和待发布事件,再由 Relay 异步投递,降低业务写入和消息发送之间的双写风险。
Saga长事务拆分为多个本地事务,并为每个步骤设计补偿动作,常用于订单、库存、营销和支付协作。
对账比较两个或多个系统中的事实口径,发现差异并推动补偿、人工处理或风险拦截。
补偿主链路未完成或状态不一致时,通过异步任务、事件重放、人工审核等方式让系统回到正确状态。
DLQDead Letter Queue,死信队列。承载无法继续自动处理的失败消息,并进入可观测、可重放、可审计的恢复流程。

可靠性与治理

术语说明
SLIService Level Indicator,服务水平指标,例如成功率、P99 延迟、消息积压时间。
SLOService Level Objective,服务水平目标,用来约束系统在一段时间内应达到的可靠性水平。
SLAService Level Agreement,对外承诺的服务水平协议,通常包含可用性、响应时间和赔付条款。
错误预算SLO 允许的失败空间,用于平衡稳定性和发布速度。
RTORecovery Time Objective,恢复时间目标,表示故障后多久恢复服务。
RPORecovery Point Objective,恢复点目标,表示故障时最多可接受丢失多少数据。
限流控制入口或内部调用流量,防止系统被超过承载能力的请求打穿。
熔断当下游持续异常时快速失败,保护调用方资源并避免故障扩散。
降级在非核心能力异常时牺牲部分体验,优先保护核心业务成功率。
舱壁隔离将线程池、连接池、队列或部署单元隔离,避免一个依赖拖垮整个系统。

电商核心概念

术语说明
SPUStandard Product Unit,标准商品单元,表达一组共同商品属性。
SKUStock Keeping Unit,库存计量单元,通常对应可售规格、库存和价格粒度。
Offer面向售卖的承诺,通常组合商品、价格、渠道、库存、履约和售后规则。
商品快照下单时保存的商品关键事实,用于历史订单解释、纠纷处理、退款和对账。
可售量当前可对外承诺售卖的数量,通常由库存事实、预占、冻结、渠道和安全库存共同决定。
预占在订单或结算阶段临时锁定库存、权益或资源,后续根据支付和订单结果确认或释放。
计价将基础价、营销、会员权益、运费、税费和抵扣合成为可解释、可追溯的应付金额。
资损因系统设计、实现、配置、运营或外部依赖问题导致平台、商家、用户或资金方产生资产损失。
供给治理围绕商品创建、导入、审核、发布、同步、补偿、审计和质量巡检建立的运营侧治理能力。
供应商同步将外部供应商的资源、价格、库存、上下架和履约状态转化为平台可治理数据的链路。

本书要求最终一致的业务实现额外定义收敛目标、测量窗口、滞后告警、查询和修复责任,以及超时后的人工接管。业务流程可以收敛到成功、失败、取消或人工处理终态,不能把“最终一致”解释成每笔交易最终一定成功。具体时限属于业务目标或额外保证,不属于该术语无条件承诺的能力。

参考资料

[1] Werner Vogels,Eventually Consistent - Revisited,2008。用于最终一致性与不一致窗口的概念边界;本书的告警、修复和人工接管要求是工程推导。

附录 B:参考文献与延伸阅读

本附录收录全书写作中反复涉及的基础资料和延伸阅读。正文以工程判断和实践模型为主,本附录用于帮助读者继续深入。

系统设计与架构方法

  • Martin Kleppmann, Designing Data-Intensive Applications。
  • Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software。
  • Vaughn Vernon, Implementing Domain-Driven Design。
  • Sam Newman, Building Microservices。
  • Martin Fowler: Patterns of Enterprise Application Architecture。
  • Martin Fowler: Microservices。
  • Martin Fowler: CQRS。

可靠性、SRE 与工程治理

数据库、中间件与基础设施

分布式事务、消息与一致性

  • Chris Richardson: Microservices Patterns。
  • Chris Richardson: Saga Pattern。
  • Chris Richardson: Transactional Outbox。
  • Pat Helland, Life Beyond Distributed Transactions。
  • Nancy Lynch, Seth Gilbert: Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services。

电商系统与业务架构

  • 商品、库存、计价、营销、订单和支付章节中的模型来自通用电商业务抽象,可结合所在公司的品类、供应商、履约和监管要求调整。
  • 资金、优惠、库存和退款相关设计应同时咨询财务、法务、风控和客服团队,不能只由研发侧独立决定。
  • 所有涉及支付渠道、银行卡、个人信息、发票、税务和跨境业务的落地方案,都应以当地法律法规和支付机构要求为准。

代码与工程实践

维护建议

  • 新增章节时,在本附录补充对应的官方文档、经典书籍或工程案例。
  • 外部链接优先选择官方文档、原始论文、作者主页或长期维护的资料页。
  • 对强时效内容,例如云产品能力、开源组件版本、支付渠道规范,应在正文中注明“以官方最新文档为准”。

附录 C:工具与构建说明

mdBook

与仓库内其他 mdBook 相同:安装 mdBook,可选安装 mdbook-mermaid 以渲染 Mermaid。

cd books/reliable-system-design
mdbook build
mdbook serve

Mermaid

book.toml 中 additional-js 指向与 book.toml 同目录下的 mermaid.min.js 与 mermaid-init.js;Mermaid 代码块由 tools/mermaid-preprocessor.py 统一预处理。若升级版本,请同步检查这两个静态文件。