可靠系统设计与电商架构
System Design: Reliable Systems and E-Commerce Architecture
这是一本面向中高级工程师、技术负责人和架构师的系统设计实践书。全书围绕“如何设计能够长期运行的复杂系统”展开,覆盖系统设计方法论、生产可靠性、数据一致性、长流程治理,以及商品、库存、营销、计价、订单、支付和履约等电商核心领域。
本书不止讨论如何画出架构图,更关注系统在真实生产环境中如何面对流量增长、故障恢复、数据不一致、业务变更、对账补偿和资损风险,帮助读者建立从问题定义、边界划分、数据建模、架构权衡,到上线治理、故障复盘和持续演进的完整系统设计方法。
本书强调三个判断:
- 系统设计首先是业务边界、数据事实和失败语义的设计,不只是组件堆叠。
- 架构方案必须能经受生产环境的流量、故障、变更、对账和资损风险考验。
- 电商系统是理解复杂业务系统的高密度样本,商品、库存、营销、计价、订单、支付和供应商同步能覆盖大量通用架构问题。
适合读者
- 已有后端开发经验,希望系统化提升架构设计能力的工程师。
- 正在负责业务系统拆分、重构、稳定性治理或技术方案评审的技术负责人。
- 准备系统设计面试,希望把项目经验组织成清晰表达的候选人。
- 希望通过电商场景理解交易链路、一致性、对账、补偿和资损防控的读者。
本书结构
- 系统设计方法论(第 1-9 章):问题定义、方案设计、业务边界、内部结构、系统集成、架构质量与编码落地。
- 生产治理(贯穿第 3、4、7 章和第 11-14 章):覆盖可靠性、对账补偿、DLQ、故障恢复、资损防控、容量规划、限流、熔断和降级。
- 电商系统设计实战(第 10-14 章):电商全景、商品供给、库存、营销与计价,以及 C 端交易全生命周期。
- 附录面试题库(附录 D-F):附录 D 保留原有系统设计训练题库,附录 F 收录 1-10 年互联网后端工程师高频系统设计 50 题,附录 E 提供后端基础知识题单。
全书逐章速览
第一部分:系统设计方法论
| 章节 | 主题 | 内容速览 | 阅读收获 |
|---|---|---|---|
| 第 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 章 | C 端交易全生命周期 | 合并搜索导购、详情、购物车、结算、创单、订单、支付、履约和售后,围绕同一条用户交易主线展开。 | 以搜索读模型、权威事实、快照、状态机、支付渠道、退款、对账、幂等、补偿和治理设计完整 C 端交易链路。 |
附录
| 条目 | 主题 | 内容速览 | 阅读收获 |
|---|---|---|---|
| 附录 D | 系统设计题库 | 用核心案例训练设计判断,用覆盖索引和历史详细材料保留原题主题、答案、追问和模拟面试。 | 能按训练目标选择题目,同时查阅原有专题细节。 |
| 附录 E | 后端面试基础知识题单 | 面向 3–10 年开发工程师,筛选 1,000 道高频、通用的存储、缓存、消息、搜索、容器、操作系统、网络、工程工具和编程语言题目,去除具体业务题。 | 按小主题复习高频后端基础知识。 |
| 附录 F | 高频系统设计 50 题 | 面向 1–10 年互联网后端工程师,按电商交易、高并发、海量数据、一致性、中间件、可观测性和架构演进组织题目与答题提示。 | 按优先级和训练轮次进行系统设计面试复习。 |
阅读路线
- 架构主线:第 1-9 章,配合第 10-14 章电商实战。适合希望建立完整系统设计框架的读者。
- 电商实战主线:第 10-14 章。适合正在做交易系统、供给系统或支付系统的读者。
- 可靠性主线:第 3、4、7 章,结合第 14 章订单、支付和交易闭环阅读。适合做稳定性、故障恢复和资损防控治理。
- 面试主线:附录 E 用于补齐后端基础知识,附录 D 用于系统设计题训练。先按小主题复习基础题,再选择核心案例或约束变体,并使用附录 D 中的限时模拟面试进行演练。
- 基础补齐主线:附录 E。适合按小主题补齐系统设计背后的计算机基础和编码能力。
使用建议
读这本书时,不建议只记结论。更好的方式是每读完一个系统章,都回答四个问题:
- 这个系统的权威数据是什么?
- 它负责什么,又明确不负责什么?
- 它和上下游如何协作,失败后由谁补偿?
- 它的监控、对账、灰度、回滚和资损防控怎么设计?
如果能把这四个问题说清楚,就已经从“功能设计”进入了“系统设计”。
本地构建
需安装 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 ≈ 日请求量 × 峰值占比 ÷ 峰值持续秒数
日增数据量 ≈ 日写入次数 × 单次有效载荷
公式的价值在于暴露假设。例如日均一千万次请求、20% 集中在两小时内,峰值并不是“日均 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 和评审把决策沉淀下来;随后以领域边界、依赖方向、读写分离和可靠事件协作组织系统,最后通过架构视图、运行反馈和演进思维持续验证。
后续章节将把本章的概念拆开落地:
- 第 2 章:编码、重构与 Code Review 讨论架构意图如何进入日常代码与评审。
- 第 3 章:生产治理与韧性保障 展开 SLO、观测、限流、熔断、降级和故障治理。
- 第 4 章:大事务与跨系统编排 深入 Saga、Outbox、补偿、幂等与对账。
- 后续方法论章节分别处理长生命周期流程、任务处理、强一致、复杂读与高并发写;第二部分则用商品、库存、订单、支付等场景验证这些方法。
读完本章后,读者不必急于采用所有模式,但应能够拿一个真实需求回答:目标和非目标是什么?谁拥有业务事实?关键质量属性怎样排序?失败后如何收敛?为什么当前复杂度值得承担?
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]. 3rd ed. Addison-Wesley, 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 建立数据、架构、领域与表达的共同语言;正在处理跨系统一致性时,重点阅读 8,并结合本书后续的大事务章节;希望提升方案评审与团队协作时,重点阅读 4、10、12 与 13。全书范围内的资料索引见附录 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 章给出了两套完整的 Go 目录映射(三层架构版与 DDD 版),第 2.6 节会基于配套示例仓库中的 examples/product-service 做完整走读。示例工程独立于本书仓库维护,本文只保留关键片段。
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 重构的第一原则:先收口边界,再优化内部实现
很多失败的重构,不是因为方向不对,而是因为一上来就想把所有问题一起解决:顺手改命名、顺手换框架、顺手改协议、顺手统一抽象。结果系统还没变好,风险面先被扩大了。
更稳妥的路径通常是:
- 先识别最关键的腐化点,到底是编排层缺失、依赖泄漏,还是领域规则散落。
- 先把边界收口,让调用关系清楚,职责归属明确。
- 再逐步替换内部实现,而不是先大面积重写细节。
换句话说,重构优先级往往不是“把代码写得更优雅”,而是“让未来的改动先有一个正确的落点”。
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 时容易判断幂等行为是否符合预期。
失败路径至少要回答五个问题:
- 失败发生在哪个阶段,已经产生了哪些副作用?
- 调用方收到的是明确失败,还是“结果未知,需要查询”?
- 重试由谁发起,重试使用什么幂等键,最多重试几次?
- 如果无法自动恢复,系统会进入哪个可观测、可人工接管的状态?
- 这次失败是否需要补偿、对账或告警,而不是只记录一行错误日志?
例如,库存服务超时不一定等于“库存没有扣减”。如果请求已经到达下游但响应丢失,直接重试可能造成重复预留;如果订单服务把它简单映射成 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,最好按由大到小的顺序看,而不是一上来盯局部实现。
建议顺序如下:
- 需求与行为:这次改动到底解决什么问题,是否改变了用户可见行为。
- 边界与设计:代码是否放在正确层次,是否引入了不必要耦合。
- 正确性:状态迁移、并发、异常、幂等、空值、边界条件是否安全。
- 可维护性:命名、结构、复用方式、注释是否帮助未来修改。
- 验证覆盖:测试、日志、指标、灰度与回滚手段是否足够。
这个顺序很重要,因为很多真正致命的问题,根本不是“某行写错”,而是整段代码从一开始就放错了位置。
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。
- 是否覆盖了空输入、重复请求、无权限、数据不存在等边界情况。
- 是否存在状态半提交、重复执行、顺序错误的问题。
- 失败后是否有明确返回、重试策略或补偿语义。
架构与边界
- 代码是否放在正确层次,是否出现跨层泄漏。
- 领域规则是否仍由领域对象或用例层表达,而不是散落在外层。
- 是否引入了新的隐式耦合,例如直接依赖具体实现、共享可变状态、万能工具类。
- 新抽象是否真有稳定价值,还是为了“看起来高级”。
- 目录结构与分层是否符合团队约定的映射(可对照第 1 章目录映射与
examples/product-service样例)。
可读性与可维护性
- 命名是否表达业务语义,而不是技术细节。
- 函数是否过长、职责是否过多。
- 控制流是否过深,是否需要提前返回或拆分步骤。
- 注释是否解释“为什么”,而不是重复“代码在做什么”。
可靠性与可运维性
- 是否设置了合理的超时、重试、限流或幂等控制。
- 是否补充了必要的日志、指标、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 静态标杆:优秀代码应该长什么样
前面几节讲的是原则,这一节看实例。本书配套示例仓库的 examples/ 目录下有三个可编译运行的 Go 工程,它们不是玩具 Demo,而是刻意用来展示「代码结构如何承载架构意图」的对照样本。这里的聚合根、值对象、领域事件和应用服务,分别对应 Eric Evans 的领域驱动设计与 Vaughn Vernon 的实现方法;它们不是必须照搬的目录模板,而是帮助团队把业务规则放到正确位置的分析工具。[8][9]
示例代码已从书籍仓库移出,避免书稿构建项目和可运行工程相互耦合:
| 示例工程 | 展示重点 | 对应的代码问题 |
|---|---|---|
examples/order-service | 标准三层架构的自然形态 | 职责够清楚,但业务规则散落在 service,依赖具体实现 |
examples/product-service | DDD 四层架构、聚合根、值对象、领域事件、三级缓存、Outbox | 业务规则内聚到领域模型,依赖方向向内,可独立测试 |
examples/common-services | 全局 ID 等基础服务的薄封装 | 通用域不需要重抽象 |
这三个工程不应被理解成“所有项目都必须长成同一个目录”。目录只是依赖方向和职责边界的可视化结果。小型模块可能只需要一个清晰的应用服务和一个存储适配器;只有当业务规则、入口类型或基础设施变化足够复杂时,才值得引入更完整的领域层和事件机制。好的标杆不是增加层数,而是让团队能够解释每一层为什么存在、由谁拥有、如何测试,以及什么时候可以被替换。
这一节从这三个工程里挑出五段代码,分别对应 2.2-2.4 的原则。读的时候建议对照源码完整看一遍——真实工程里的取舍细节(日志、注释、错误处理)比书上任何摘录都更有信息量。
2.6.1 目录结构本身就是第一份「好代码」
product-service 的顶层结构,几乎可以直接拿来当团队模板(完整树见本节后续的分层示例):
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)。
相比之下,order-service 代表了大多数项目的现状:application/service 直接依赖 infrastructure/persistence 的具体实现,model.Order 是贫血对象。它的 README 自己也写明「这些正是后面引入 Clean Architecture、DDD 和 CQRS 的原因」。两个工程对照读,能直观看到 2.3 说的「腐化信号」长什么样、演进的终点长什么样。这里的“终点”不是永远不变的最终架构,而是当前约束下足够清晰、可替换和可测试的目标形态。
2.6.2 领域模型:业务规则内聚,而不是散落在 setter 之外
internal/domain/product.go 中的 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
}
这段代码值得走读的四个点:
- 不变量由聚合根自己保护。调用方不可能绕过「价格必须大于 0」这个规则——规则内聚在模型里,而不是靠每个调用方「记得检查」。这正是第 1 章「贫血 vs 充血」对比的完整版。
- 状态迁移表达为业务动词。
OnShelf()、OffShelf(reason)、UpdateBasePrice(price),读代码就是在读业务流程,没有任何技术噪音。 - 每次状态变更留下领域事件。事件在聚合内部积累,由应用层统一发布——「业务事实已经发生」这件事被显式建模,而不是散落在各处
kafka.Send()。 - 字段私有 + 只读 Getter。外部无法直接把商品改成上架状态,只能走
OnShelf(),非法状态在类型层面就不存在。
2.6.3 值对象:让非法输入在构造时就失败
internal/domain/value_objects.go 里金额的处理方式,是业务系统最值得抄的一个习惯:
// Price 值对象(使用分为单位,避免浮点精度问题)
type Price struct {
amount int64 // 金额(分)
currency string // 货币(CNY)
}
func NewPrice(amount int64, currency string) (Price, error) {
if amount < 0 {
return Price{}, errors.New("价格不能为负数")
}
if currency == "" {
currency = "CNY"
}
return Price{amount: amount, currency: currency}, nil
}
两个设计决策都值得在 Review 中坚持:
- 金额用「分」存 int64,不用 float64。浮点精度问题在计价、退款分摊场景是真金白银的事故源(第 13 章营销与计价系统会再次遇到这个约束)。
- 校验收敛在构造函数。
NewPrice是唯一能造出Price的地方,负数价格在系统中根本不可能存在——这比在每个使用处写if price < 0高一个量级,也正是 2.4.2「把变化点隔离」的实例。
2.6.4 应用服务:编排显式、依赖抽象、失败语义清楚
internal/application/service/product_service.go 展示了一个符合 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 说「可测试性是架构清晰的副产品」,这个工程给出了证据。product_center_service_test.go 不需要任何外部依赖就能验证核心业务行为:
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 把示例工程用起来
建议团队这样使用这些示例,而不是读完就忘:
- 新人 Onboarding:先跑通
examples/product-service(go run ./cmd),再回答「我要加一个新的商品字段,应该改哪几层」。 - Review 参照系:当 PR 里出现「这段逻辑该放哪层」的争论时,用示例工程的同类代码做参照,比抽象讨论快得多。
- 重构对照目标:迁移老代码时,把
examples/order-service(现状)和examples/product-service(目标)摆在一起,演进路径就是 2.3.4 的安全重构模式。
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 如果这是线上老代码,应该怎么重构
真实团队里更常见的情况不是“从零开始写一个更好的版本”,而是坏代码已经在线上跑了很久,周围还挂着依赖、监控、补偿脚本和历史调用。这个时候,最危险的做法往往不是不动,而是上来就想一口气彻底重写。
更现实的重构路径通常是:
- 先记录旧接口的输入、输出、错误码、日志字段和关键副作用,形成一组特征测试。
- 在不改变路由的前提下,把 Handler 中的流程编排提出来,形成单独的 Use Case。
- 再把库存、计价、订单持久化等依赖抽成清晰接口,并用适配器包住旧实现。
- 明确错误语义和幂等语义,让旧逻辑至少先变得可判断。
- 补上关键路径测试和最小观测点,再用按抽象分支、灰度或双跑继续替换内部实现。
特征测试不一定一开始就漂亮。它的任务是记录系统当前对外表现,例如重复请求是否返回同一个订单号、库存不足时返回什么错误、计价超时时订单是否会落库。对一个没有测试的遗留模块来说,先把这些行为固定下来,通常比一开始就追求理想的单元测试更现实。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 节 examples/product-service 所展示的应用层、领域层和基础设施边界保持一致。第 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 水平。更有效的做法通常包括:
- 为核心链路维护“代表团队标准”的样例实现(配套示例仓库中的三个 Go 工程就是这种样例的最小形态,见第 2.6 节);
- 为高风险场景维护 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 Practices | Change List 是否足够小,Author 和 Reviewer 是否提供了必要证据 |
| 重构查询 | Refactoring.com、Refactoring.guru | 是否选用了行为保持的重构手法,而不是扩大变更范围 |
Google 的 Code Review 指南把“持续提升代码库整体健康度”作为评审标准,同时也提醒团队不能为了追求局部完美而阻止所有交付。[6] 这意味着 CI 门禁需要有边界:编译失败、禁止依赖和核心测试失败可以直接阻止合并;复杂度趋势、技术债例外和非关键覆盖率则更适合作为可见的治理指标。
PR 模板也不应只要求 Author 勾选“已测试”。对涉及核心链路的改动,至少应要求作者回答:
- 这次改动保护了哪些业务不变量?
- 是否改变了接口、数据格式或状态迁移?
- 重试、重复请求和部分失败如何处理?
- 如何验证日志、指标和 Trace 能定位故障?
- 灰度、回滚和遗留路径删除条件是什么?
- 如果使用 AI 辅助,哪些代码由工具生成,哪些行为由测试或人工验证?
最后一项不是为了追踪工具使用,而是要求 Author 对验证证据负责。AI 可以帮助生成代码,却不能替 Author 对业务行为签字。
2.8.7 团队如何把 AI 纳入工程流程
团队不必把 AI 编程单独变成一套神秘流程,可以把它纳入既有的软件交付环:
- 先给边界,再让工具生成:在 Prompt、仓库规则或任务描述中写清楚允许修改的包、禁止依赖、错误语义和测试要求。
- 先生成局部变化,再扩大范围:优先让工具处理一个函数、一个适配器或一组测试,不要让它在没有行为基线的情况下重写整个模块。
- 先验证行为,再接受重构:运行特征测试、单元测试、Lint 和依赖检查,确认工具没有改变状态迁移和失败语义。
- 让人审查不可自动化的部分:Reviewer 重点挑战业务不变量、并发安全、兼容性、观测、灰度和回滚,而不是重复检查格式。
- 把有效约束沉淀回仓库:如果 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 本章方法论的使用顺序
遇到一段难以修改的代码时,可以按下面的顺序阅读和行动:
- 先用 Ousterhout、Evans 和 Vernon 的观点判断边界、职责和业务模型是否清楚。
- 再用 Fowler 和 Feathers 的方法寻找坏味道、接缝和可插入测试的位置。
- 如果改动跨越模块或服务,使用 Branch by Abstraction、Strangler Fig 或 Shadow Run 设计迁移路径。
- 用 Google Code Review Guide 和本章 Checklist 组织人类评审。
- 用 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。
第 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 的交付频率、变更前置时间、变更失败率和恢复时间适合观察交付系统的速度与稳定性,但它们是组织级反馈指标,不能直接替代某个业务 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 示例(假设) | 降级/失败语义 |
|---|---|---|---|
| 浏览 | 基础信息成功率、P99 | 99.9% 在 1 秒内返回 | 推荐、营销标签可为空 |
| 结算 | 价格/库存校验成功率、P99 | 99.5% 在 2 秒内完成 | 非核心权益可延后,价格不能未知 |
| 创单 | 明确成功或失败率、P99 | 99.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 组织,但交易系统必须再加业务正确性:
| 视角 | 指标 | 用途 |
|---|---|---|
| RED | Rate、Errors、Duration | 看服务对请求的外部表现;优先用分位数而非平均值 |
| USE | Utilization、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 端到端案例:支付成功但订单未更新
假设支付渠道已返回成功,订单服务因网络超时没有完成状态推进。正确目标不是“再次扣款”,而是让订单、支付账本和用户可见状态最终收敛。
- 正常链路:订单服务创建
PENDING_PAYMENT,支付服务记录支付单;支付成功与payment_succeeded事件在本地事务中写入支付账本和 Outbox;Relay 投递事件,订单消费者以payment_id幂等推进到PAID。 - 故障发生:消费者处理后返回前崩溃,Broker 重投;唯一键拒绝重复副作用,状态机把重复事件视为已处理。若支付服务提交成功但 Relay 未发出,Outbox 扫描发现未投递记录。
- 实时收敛:订单查询支付服务或接收渠道回调;若支付账本为成功而订单仍为待支付,创建差异项,重放事件或执行只推进状态的补偿命令。
- 准实时对账:按
payment_id、订单号和渠道流水号联查,校验金额、币种、商户和版本;金额不一致时禁止自动补偿,转人工冻结。 - 最终验证:订单状态、支付账本、库存动作和通知记录均有证据;用户看到“已支付/处理中”的状态,客服可查询时间线,财务可在日切对账中复核。
这里的关键不变量是:支付成功只允许订单从 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 的变更失败率和恢复时间可以作为组织层趋势指标,但它们不能替代订单、金额、库存等领域正确性指标;指标改善若伴随人工调账增加,不能判定治理成功。[3]
3.6.2 生产环境五条红线
- 不允许没有超时、幂等语义和回滚/查询路径的远程写调用进入核心链路。
- 不允许直接改权威账本、订单状态或库存余额;紧急操作也必须经过受控命令、审批和审计。
- 不允许用“接口返回成功”替代业务正确性验证;支付、库存、价格必须有独立不变量和对账。
- 不允许无限重试、无限堆积和无限自动重放;每条路径都要有预算、上限和人工接管点。
- 不允许发布、配置和补偿绕过灰度、观测和可逆性;无法回滚时必须先设计前向修复。
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] Google Cloud. Use Four Keys metrics like change failure rate to measure your DevOps performance. 2020. DORA 指标的工程化说明;不将行业分层阈值直接当作团队目标。
[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 章 大事务处理方法论:Saga、补偿与最终一致性
大事务不是把多个数据库强行变成一个数据库,而是把一个跨系统的关键业务动作拆成可提交、可重试、可补偿、可审计、可最终收敛的状态机。本章从电商创单出发,讨论大事务的难点、决策点、异常处理和治理方法。
很多工程师第一次听到“大事务”时,会自然联想到数据库事务、分布式事务,甚至 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 约束与指标:为什么创单节点天然是大事务
电商创单看起来只是用户点击一次“提交订单”,但在服务端通常至少包含以下动作:
- 校验购物车、商品、价格、税费和运费快照。
- 判断库存是否仍然可售,并预占目标数量。
- 校验优惠券、满减、积分或会员权益,并占用额度。
- 创建订单主记录与订单行,保存价格和优惠快照。
- 创建支付单或支付上下文,为后续支付做准备。
- 生成后续履约、通知、搜索和统计所需的事件。
真正的难点不在步骤数量,而在于每一步都可能拥有独立的本地事务和失败语义:
- 库存预占成功,但订单服务写库失败。
- 订单已写入,但营销服务超时,无法确定优惠是否已经占用。
- 订单和库存都成功,但支付上下文创建结果未知。
- 用户重复点击提交,两个请求同时通过库存检查。
- 第一次调用已经在下游提交,响应却在返回前丢失,客户端于是再次发起请求。
- 订单超时取消后,库存释放和优惠释放只完成了一部分。
所以创单节点天然具有四个特征:跨系统、多副作用、可失败、需收敛。可以用下面的约束表把它从“接口链路”转换成“系统设计问题”。
| 约束维度 | 创单中的具体问题 | 设计时必须回答的问题 |
|---|---|---|
| 参与方与故障域 | 订单、库存、营销、支付、风控可能独立部署 | 谁拥有每个事实?哪个故障域可以独立恢复? |
| 资源类型 | 库存和优惠可预占,通知和支付受理可能不可逆 | 资源是释放、冲正、重试还是只能记录状态? |
| 时效 | 用户希望较快得到“已创建 / 被拒绝”,外部回执可能很慢 | 哪些结果必须同步确认?哪些可以转为异步收敛? |
| 并发 | 重复点击、并发下单、取消与支付回调可能同时发生 | 如何防止重复占用和旧请求覆盖新事实? |
| 失败代价 | 超卖、重复扣款、优惠资损、孤儿占用 | 哪些失败要立即阻断?哪些允许进入待处理? |
| 可审计性 | 事后需要解释订单为何存在、库存为何释放 | 是否保存步骤、幂等键、外部请求号和操作原因? |
| 恢复目标 | 线上重试不能解决所有未知结果 | 多久内自动补偿?多久告警?何时转人工? |
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 权威事实源与不变量
每一种资源都必须有明确的权威事实源:库存是否被占用由库存系统确认,优惠是否占用由营销系统确认,订单是否存在由订单系统确认,支付是否受理由支付渠道或支付系统确认。事务协调者可以保存副本和执行记录,但不应在没有查询依据时凭自己的状态替代参与方事实。
至少需要建立以下不变量:
- 一个可能产生副作用的步骤必须拥有稳定、可重放的幂等键。
- 同一幂等键最多产生一份业务副作用,并且重复请求返回第一次执行的语义结果。
- 事务单状态只能通过受控状态迁移变化,不能由多个服务直接覆盖同一个字段。
- 补偿动作必须检查当前权威状态,不能把已经确认的资源再次释放或把已冲正的流水重复冲正。
- 结果未知不能直接跳过,也不能直接重做,必须先查询、重放或进入可运营的待处理状态。
- 每条事件都应带有业务主键、事件 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 中写一串接口调用。进入生产后,最重要的是持久化执行事实:进程重启、网络超时、消息重复和人工介入发生后,系统仍然知道这次动作走到了哪里。
可以从四类记录开始:
| 记录 | 关键字段 | 要回答的问题 |
|---|---|---|
transaction | transaction_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 释放预留资源。Oracle 的 MicroTx 文档把 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 一条实用的决策顺序
可以按下面的顺序缩小方案空间:
- 先确认是否真的存在跨系统副作用;如果只是查询聚合,不要过度引入事务协调。
- 再确认失败后是否需要释放资源、冲正流水或更新多个系统;如果没有,普通同步流程可能足够。
- 明确每个参与方是否提供幂等执行和结果查询;没有查询能力时,结果未知无法安全重试。
- 判断主控方是否明确;明确则优先考虑编排,自治则考虑事件协作,但要补全全局可见性。
- 判断资源是否能够预留;能够稳定实现
Try / Confirm / Cancel时才考虑 TCC。 - 最后才选择框架、消息系统或 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 结果未知必须先查询
“请求超时”只说明调用方没有在截止时间内得到响应,不说明下游没有执行。一个正确的未知结果处理过程是:
- 将步骤持久化为
UNKNOWN,记录请求发送时间、超时点和外部请求号。 - 调用下游的幂等查询接口,查询同一个执行键。
- 查询到成功则补写执行事实并继续;查询到未执行才允许使用同一键重试。
- 查询接口也不可用时,进入延迟查询、对账或人工队列。
如果下游没有查询接口,系统只能降低承诺:例如把该步骤设计成可补偿的预留,或要求外部系统提供对账文件。没有查询能力时,不能靠“再发一次通常没问题”来冒险。
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 评审清单
在方案评审时,至少逐项回答:
- 这个关键业务动作的最小边界是什么?哪些动作属于后续长流程?
- 哪些参与方会产生真实副作用?每个副作用的权威事实源是什么?
- 每个步骤的幂等键如何构造?重复请求返回什么结果?
- 超时后如何区分“未执行”和“结果未知”?是否存在真实查询接口?
- 哪些结果必须同步确认,哪些可以异步推进,哪些只能异步收敛?
- 事务单、步骤账本、Outbox 和消费者处理记录分别由谁维护?
- 补偿是释放资源、反向流水、状态更正还是外部异步取消?
- 补偿失败会停在哪里?告警阈值是什么?谁可以人工接管?
- 并发下单、取消、支付回调和重复消息如何避免旧状态覆盖新事实?
- 方案选择牺牲了什么能力?验证指标是什么?什么条件下重新评估?
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 模式”。
[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 还是自研状态机”,而是:
- 什么东西正在经历生命周期?
- 哪个标识可以贯穿所有阶段和外部交互?
- 哪一个领域拥有它的权威事实?
- 哪些动作改变业务事实,哪些动作只是发送通知或刷新投影?
- 流程结束的条件是什么,结束后还允许哪些售后或纠错动作?
如果这些问题没有答案,引擎只能把混乱的业务规则持久化下来。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 用五个问题判断是否进入长流程
可以先用下面五个问题做范围判断:
- 业务对象是否会跨多个阶段长期存在,而不是在一次请求内结束?
- 是否存在用户、人工或外部系统造成的真实等待?
- 是否需要回退、驳回、重提、暂停、恢复或超时关闭?
- 是否需要向用户、客服或审计人员解释“现在卡在哪里、下一步是谁处理”?
- 是否存在跨服务副作用,使得重试、补偿、查单或对账成为正常路径?
如果五个问题大部分答案是“是”,就应把它当成长生命周期流程设计。如果只有一个本地事务和一次短 RPC,先使用业务表状态和本地事务,避免因为“未来可能复杂”而过早引入流程平台。工具的重量应该由等待、治理、恢复和演进需求触发,而不是由技术潮流触发。
长流程也不等于必须微服务化。一个单体应用内的订单审核,只要有跨天等待、人工任务和规则版本,同样需要生命周期模型。反过来,一个拆成多个微服务的同步请求,如果能在一个短事务窗口内完成,也不一定需要 Workflow。服务数量是架构背景,不是流程分类标准。
5.2.3 先定义成功,再定义中间态
长流程最容易被忽略的问题是“什么算完成”。例如,商品发布的主数据已经提交,但搜索索引还没有更新;退款渠道已经受理,但资金到账仍在异步处理中;订单已经支付,但仓库尚未生成履约单。把这些都简单写成 SUCCESS 会隐藏真实风险。
建议为每一个流程定义至少四种结果:
- 业务完成:权威事实已经满足业务目标,后置投影可以继续异步收敛。
- 业务拒绝:规则明确拒绝,且不需要继续重试,例如库存不足、资质不符或支付失败。
- 处理中:事实尚未确定,系统必须等待回调、查询或人工判断。
- 待治理:自动路径无法安全决定,实例进入人工或对账队列,并有明确 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,而应写清:
- 触发者可以是用户取消请求,也可以是支付截止时间到期的定时器。
- 守卫条件是订单仍处于可取消状态、没有确认的支付成功事实、当前状态版本未冲突。
- 副作用包括关闭支付入口、释放库存预占、返还优惠占用和发送订单关闭事件。
- 如果释放资源结果未知,订单进入
CLOSING或“资源待确认”子状态,而不是立即宣称关闭完成。 - 如果此时已经收到支付成功事实,系统根据业务规则选择支付优先、取消优先或进入人工复核,不能由消息到达顺序偶然决定结果。
核心不变量应该写成可以检查的句子,例如:已完成订单不能直接回到待支付;已退款金额不能超过可退金额;同一个支付单只能确认一次;已关闭订单不能再创建新的履约任务;任何人工放行都必须有操作者、理由和审计记录。状态机的价值不在于画出所有可能箭头,而在于把禁止路径和必要条件变成程序可执行的约束。
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 章讨论的局部大事务。订单完成支付,并不意味着履约、开票、通知和推荐投影都同步完成,它只表示订单域的支付事实已经确认。
最有代表性的异常是支付成功回调与超时取消同时到达。两条命令可能来自不同消费者,也可能一个来自用户请求、一个来自调度器。处理流程可以按下面的步骤设计:
- 两个请求都带有订单 ID、命令 ID 和期望版本。
- 状态机以条件更新竞争
PENDING_PAYMENT的版本。 - 先成功的命令获得状态迁移权,另一个命令读取新的权威状态。
- 如果支付成功先落库,取消命令不能直接关闭订单,而要进入“支付后取消”的业务规则;如果取消先落库,支付回调需要查询渠道或根据支付事实决定是否触发退款。
- 任何外部结果未知,都进入
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 共同架构:事实、执行和投影分离
无论使用何种工具,长流程都可以拆成四个职责:
- 事实层:拥有业务对象和状态迁移的权威服务,负责不变量、权限和本地事务。
- 执行层:保存待执行步骤、重试、等待、定时器、人工任务和流程实例进度。
- 传播层:通过 Outbox、消息或事件日志,把已提交事实可靠地传给下游。
- 治理层:提供查询、审计、告警、对账、人工接管、版本迁移和操作保护。
四层可以部署在一个应用里,也可以由多个平台承载。关键是不要把职责混在一起:消息总线只负责传输,不拥有业务状态;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 外部事件必须经过验证和去重
重复支付回调、迟到物流事件、供应商重发通知和顺序颠倒的状态消息都是正常输入。处理外部事件时至少验证:
- 事件是否关联到正确的业务主键和流程实例。
- 来源是否可信,签名、租户和权限是否通过验证。
- 外部事件 ID 是否已经处理,事件版本是否早于当前版本。
- 金额、数量、币种、状态和时间窗口是否符合业务约束。
- 当前状态是否允许接受该事实,还是应查询权威系统或进入人工队列。
消息系统常见的是至少一次投递,消费者必须让重复处理的结果与处理一次相同。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 结果未知必须先查,不要盲目重做
结果未知是长流程最危险的中间态。调用支付、退款、库存预占或供应商下单时,客户端可能在请求到达后丢失响应。此时“没有响应”只说明调用方没有证据,不说明服务端没有执行。
处理结果未知时,恢复顺序应尽量是:
- 使用原始业务幂等键、外部请求号或资源 ID查询权威系统。
- 如果查询接口暂时不可用,记录下一次查询时间,不改变业务事实。
- 多次查询仍不能确定时,进入对账或人工复核,冻结会造成重复副作用的后续动作。
- 只有确认原动作未生效或业务规则允许安全重试时,才发起同一幂等意图的重试。
- 查询结果确认后,再推进状态机,并把证据写入流程历史。
这样,流程的重试目标不是“重新执行一遍代码”,而是“让原有业务意图获得一个可解释结果”。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 流程定义演进与存量实例迁移
流程运行数天、数月甚至更久时,代码一定会变化。流程定义应显式保存版本,新实例使用新版本,存量实例可以继续按旧版本完成,也可以在明确迁移规则和审计记录下转入新版本。迁移至少回答:
- 哪些状态在两个版本之间一一对应?
- 新版本新增的审批或校验,对存量实例是补做、豁免还是人工确认?
- 已经完成的不可逆动作是否还能被新版本重新解释?
- 迁移失败时是回到旧版本、冻结实例,还是进入人工队列?
- 新旧版本的指标和告警如何区分,何时可以停止旧版本 worker?
例如商品发布流程从“一次人工审核”升级为“质检 + 合规双审核”,最安全的做法通常是:新提交实例使用版本 2;已完成质检但尚未发布的旧实例继续按版本 1 完成,或者为它们创建一个明确的合规补审迁移任务;已发布商品不因流程定义升级而重新执行发布副作用。流程迁移是业务变更,不是普通代码重构,必须有灰度、暂停、回滚边界和审计记录。
5.8 方案选型与实施路径
5.8.1 选型顺序:先模型,再运行时
可以用下面的顺序避免“先买平台、再找场景”:
- 定义流程对象、权威事实和主状态。
- 列出命令、事件、守卫条件和不变量。
- 标记等待点、人工节点、不可逆节点和超时策略。
- 识别每个关键节点内部的大事务、Outbox、补偿和查询接口。
- 根据流程复杂度选择数据库状态机、编排器、Workflow 或事件协作。
- 设计查询、审计、告警、对账、人工接管和流程版本策略。
- 用一个正常案例、两个失败案例和一个版本升级案例验证方案。
选择工具之前先画“事实图”和“状态图”,再画“执行图”。事实图说明谁拥有订单、库存、支付和退款的权威;状态图说明允许哪些迁移;执行图说明命令如何投递、等待如何唤醒、失败如何恢复。很多 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 流程设计评审清单
在进入实现前,至少逐项回答:
- 流程对象是什么?业务主键、流程 ID、主状态、子状态和原因字段分别是什么?
- 哪个服务拥有权威事实?Workflow、消息总线和读模型分别不拥有哪类事实?
- 每一条状态迁移由什么命令或事件触发?谁有权限发起?守卫条件和不变量是什么?
- 同一对象上的支付、取消、超时、人工操作和重放如何并发?版本冲突如何处理?
- 哪些节点需要等待?等待条件、截止时间、唤醒方式、重复唤醒和超时策略是什么?
- 外部事件如何验证来源、金额、版本、时间窗口和重复处理?乱序消息如何收敛?
- 哪些节点内部存在第 4 章的大事务?失败、结果未知和补偿失败如何反馈给主流程?
- 哪些动作不可逆?不可逆节点之前有哪些校验,之后如何向前恢复或人工治理?
- 人工任务的角色、对象范围、可执行动作、升级规则、双人复核和证据要求是什么?
- 对账比较哪些事实?差异如何分类、修复、验证和关闭?谁是 owner?
- 事件重放与命令重做如何区分?哪些副作用禁止自动重放?
- 流程定义如何版本化?存量实例如何完成、迁移、冻结和回滚?
- 指标能否发现超时、停滞、未知结果、重复命令、人工积压和版本冲突?告警是否直接连接到责任人和下一动作?
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 章 任务处理方法论:从短任务、长任务到可恢复的 Workflow 与 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 本章的范围与非目标
本章聚焦以下问题:
- 如何识别任务的执行跨度、数据规模、流程确定性、认知复杂度和副作用风险。
- 如何选择同步请求、异步消息、Batch、Workflow、单 Agent、Stateful Agent Runtime 或 Multi-Agent。
- 如何设计 Task、Run、Step、Shard、Checkpoint、Lease、状态历史和结果账本。
- 如何处理重复投递、结果未知、局部失败、限流、背压、乱序、发布异常和人工接管。
本章不展开某个具体 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、输入快照、发起人、截止时间、配置版本 |
| Step | Run 内具有独立责任的阶段 | 阶段状态、输入输出引用、重试预算、门禁 |
| 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 不是互相替代的技术潮流,而是不同复杂度区间中的执行形态。
本章的核心判断如下:
- 先定义输入、输出、成功判据、截止时间和资源边界,再讨论框架。
- 只要中断后必须恢复,就需要持久化 Run、分片和可信 Checkpoint。
- 只要存在副作用,就把重复、延迟、乱序和结果未知当作默认路径。
- 任务级成功不等于对象级成功;批量任务必须能解释局部失败和跳过。
- 队列负责分发,状态存储负责事实,日志负责解释;三者不能互相冒充。
- Batch 解决大规模推进,Workflow 解决阶段依赖与治理,二者经常组合。
- Agent 可以处理不确定输入,但不能替代状态机、权限、审计、对账和人工门禁。
- Multi-Agent 只有在角色分工能带来可验证收益时才成立,并且必须有共享状态和终止条件。
- 重试、限流、背压、失败池、对账和演练共同构成可靠性,而不是事后补丁。
在进入实现前,可以用下面的评审清单快速检查一个方案:
| 评审问题 | 若回答不清,通常意味着 |
|---|---|
| 这次任务的业务输入、输出和权威事实是什么? | 任务可能把日志、队列或缓存误当成事实 |
| 失败后从哪个最小安全单元继续? | 任务可能只能全量重跑 |
| 同一动作重复两次会怎样? | 幂等、版本或结果查询能力不足 |
| 哪些错误可以重试,哪些必须隔离或人工处理? | 重试策略会放大故障或吞掉数据错误 |
| 谁能暂停、取消、批准发布和人工放行? | 控制面与权限边界不完整 |
| 如何证明已经完成,如何发现遗漏? | 缺少对象级结果和对账闭环 |
| 为什么需要 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 本章的范围与非目标
本章聚焦以下问题:
- 如何识别不可出错的业务事实和不变量。
- 如何选择本地事务、Outbox、预占、TCC、Saga 或对账补偿的组合。
- 如何处理请求重试、回调重复、状态未知、消息乱序和跨系统失败。
- 如何建立审计、对账、人工接管和恢复机制。
本章不试图展开完整的数据库实现、支付渠道接入协议或财务会计制度。数据库隔离级别、Redis 细节、Kafka 消费模型和订单系统的完整实现,应分别结合本书后续基础设施和电商实战章节阅读。
关于大事务、Saga 和 Outbox 的通用流程可参见第 4 章《大事务处理方法论》;购物车、结算、订单和支付的完整领域实现可参见第 14 章《电商用户 C 端搜索、交易、履约与售后全生命周期设计》。本章只保留跨场景的判断框架和关键取舍。
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 用可验证不变量替代口号
一个好的不变量必须可以通过数据库约束、状态条件、查询或对账验证:
- 库存不变量:
可用 + 预占 + 已售 = 总量,且每个分量不能小于零。 - 账务不变量:每笔记账必须有借方和贷方,借贷金额相等,业务流水号不可重复入账。
- 支付不变量:同一业务订单在同一支付意图下不能产生两个有效成功扣款。
- 状态不变量:支付成功不能回退为待支付,库存已确认不能被普通取消动作再次释放。
- 补偿不变量:每次补偿动作必须关联原始动作,且重复执行不能制造新的副作用。
- 版本不变量:同一聚合的状态更新必须携带版本或前置状态条件,旧请求不能覆盖新事实。
可以把不变量转成技术检查:
| 不变量 | 代码或存储约束 | 对账方式 |
|---|---|---|
| 同一支付意图只成功一次 | (order_id, payment_intent_id) 唯一约束 | 渠道成功单与本地成功单逐笔比对 |
| 库存不能负数 | 条件更新 available >= quantity、库存流水唯一 | 总量、预占、已售和订单聚合比对 |
| 状态不能逆向 | 状态转移表、版本号和前置状态条件 | 扫描非法边、检查状态事件顺序 |
| 账务借贷平衡 | 记账凭证号唯一、分录金额约束 | 按凭证、账户、币种汇总借贷差额 |
| 事件不能造成双重副作用 | 消费者已处理表或业务唯一键 | 事件、消费记录和业务流水关联检查 |
《Designing Data-Intensive Applications》把记录、派生数据、复制和一致性放在同一套数据系统权衡中讨论;对本章而言,直接含义是先确定哪份数据是事实,再决定哪些数据可以异步派生,而不是先选择一个“看起来可靠”的中间件。[5]
7.2.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、业务单号、数量、预占时间、过期时间、当前状态、幂等键和释放原因。确认和释放都必须带上版本或状态条件,避免旧请求把已经确认的资源再次释放。
它的价值不只在于防超卖,还在于把失败恢复转成明确动作:
- 预占锁定资源并创建可审计的占用记录。
- 确认把预占转为已售、已扣减或已生效。
- 释放把预占归还可用池,并记录是支付失败、用户取消、超时还是人工操作。
- 过期扫描处理没有收到确认或释放命令的中间态。
库存、额度、余额冻结和优惠券占用都可以使用这个模型,但不能机械套用。余额冻结需要同时记录账户、币种和可用余额;优惠券占用需要防止资格重新计算导致的重复发放;外部供应商库存可能只能“尝试预订”,确认和释放还会受第三方语义约束。本章推导是:资源越稀缺、失败后越难恢复,越应该把预占设计成独立的权威事实。
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]
这条链路可以拆成三个边界:
- 事实边界:关键事实在自己的权威服务内原子提交。
- 传播边界:本地提交后的事实通过 Outbox、事务消息或日志可靠传播。
- 收敛边界:跨系统的业务目标通过状态机、补偿、查单和对账最终收敛。
可以用一张责任表防止边界混乱:
| 层次 | 主要责任 | 失败时的动作 | 不应承担的责任 |
|---|---|---|---|
| 权威服务 | 提交事实、保护不变量、推进本地状态 | 回滚本地事务或进入明确中间态 | 等待所有下游同步成功 |
| 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]
对于一个“支付成功但订单未更新”的事件,系统至少要能够回答:
- 支付事实是否已经落地,金额和币种是否核对通过?
- Outbox 事件是否生成,事件版本和支付流水是否关联?
- relay 是否读取并投递,是否发生重复投递或死信?
- 订单消费者是否收到并处理,处理事务是否提交?
- 订单状态是否因为版本冲突、金额不符或状态不允许而拒绝更新?
- 是否进入补偿、查单、对账或人工队列,最老任务年龄是多少?
这类问题应能通过一个关联 ID 从用户请求追到业务事实、事件、消费者、补偿和对账结果,而不是依赖工程师在多个日志系统里猜测时间线。
7.5 方案选型:用 ADR 记录获得了什么、牺牲了什么
7.5.1 ADR 不只是写“选择了某个组件”
本章的方案选型必须回答一个问题:在当前约束下,为什么接受一种取舍,而不是另一种取舍。ADR 至少包含:
- 背景和问题。
- 决策驱动因素。
- 候选方案。
- 最终决策。
- 获得的能力。
- 主动牺牲的能力。
- 已接受的风险和缓解措施。
- 验证指标与重新评估条件。
“采用 Kafka”“使用 Redis 锁”“引入分布式事务框架”都不是决策本身。决策应描述它如何保护哪个不变量、在什么前提下成立、不能解决什么问题、失败后怎么办。
7.5.2 方案对比
| 方案 | 主要获得 | 主要牺牲 | 适用前提 | 不能自动解决的问题 |
|---|---|---|---|---|
| 单库本地事务 | 原子性强、语义简单、延迟低 | 跨服务能力弱、扩展边界受限 | 事实可以收敛在一个服务和数据库内 | 下游传播和外部渠道结果 |
| 本地事务 + Outbox | 避免业务数据与事件双写丢失 | 需要 relay、重复投递和消费者幂等 | 核心事实已在本地确定,需要可靠传播 | 下游业务动作是否成功 |
| 预占 + 确认 / 取消 | 资源生命周期清晰,失败可释放 | 状态更多,需要 TTL、扫描和补偿 | 资源可预留,确认和释放边界明确 | 外部供应商是否接受确认 |
| 事务消息 | 本地事务结果与消息提交关联 | 中间态、回查和消费端重试仍存在 | 消息平台支持生产者事务回查 | 消费者处理的全局原子性 |
| TCC | Try、Confirm、Cancel 语义显式 | 参与方改造成本高,空回滚/悬挂/幂等复杂 | 多参与方都有资源预留能力 | 第三方不可控接口和人工流程 |
| Saga | 跨服务流程可推进,参与方自治 | 没有全局瞬时原子性,补偿可能失败 | 每一步有本地事务和可执行补偿 | 补偿动作本身的自动成功 |
| 分布式锁 | 解决局部并发互斥 | 不解决事件丢失、账本和跨系统提交 | 只需要保护短时单资源临界区 | 业务状态、消息和资金正确性 |
| XA / 2PC | 统一提交语义直接 | 长事务、锁竞争、可用性和运维成本高 | 参与方受控且确实需要统一提交 | 不支持 2PC 的第三方服务 |
Seata 官方文档把 AT、TCC、Saga 和 XA 分成不同模式,并分别说明本地事务、业务 Try/Confirm/Cancel、补偿和资源协调的前提;这说明“分布式事务”不是一个单一开关,而是一组不同侵入性、隔离性和恢复语义的方案。[16][17]
7.5.3 选择路径:先问边界,再问组件
可以按以下顺序做选型:
- 关键事实能否通过领域边界调整,收敛到一个服务和一个数据库?如果能,优先本地事务。
- 如果事实已经落地,问题只是可靠传播,优先本地事务加 Outbox 或受控事务消息。
- 如果资源需要先锁定再决定最终结果,优先预占、确认、释放模型。
- 如果流程跨多个服务且允许中间态,使用 Saga 或显式编排状态机。
- 如果参与方都能提供稳定的 Try、Confirm、Cancel,并且确实需要更强的隔离,再评估 TCC。
- 只有在参与方受控、边界很窄且团队能够承担阻塞与故障恢复成本时,才考虑 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 故障演练和发布治理
发布前应演练以下场景:
- 支付回调重复、乱序、签名错误和金额不符。
- 消息积压、重复投递、消费进程在提交后崩溃。
- 数据库主从切换、锁等待升高和事务提交超时。
- 库存预占成功但订单创建失败,订单已支付但库存确认失败。
- 支付渠道不可用、查单接口超时、退款结果未知。
- 对账任务中断、补偿任务重复执行、人工操作权限错误。
演练的通过条件不能只看服务是否恢复,还要检查业务事实:有没有重复扣款、库存是否平衡、账本是否平衡、未知状态是否进入正确队列、重复执行是否保持幂等、审计记录是否完整。高准确性系统的回滚也要分成代码回滚、配置回滚和业务冲正;部署旧版本并不等于已经撤销新版本产生的资金或库存事实。
7.7.5 评审时的证据链
一次方案评审至少应沿着一条业务事实追问:从用户命令开始,事实在哪里落地,谁能改变它,事件如何传播,消费者如何去重,超时如何判断,失败如何补偿,最终如何对账。每个环节都要有表、状态、键、指标或操作手册作为证据。
如果只能画出成功时序图,却回答不了“响应丢失后怎么办”“回调和取消同时到达怎么办”“补偿执行两次怎么办”“渠道成功但本地没有记录怎么办”,说明设计还停留在接口编排层,没有进入高准确性系统真正的治理问题。
7.8 方法论总结
7.8.1 十个判断句
- 高准确性首先保护的是业务不变量,而不是某个组件的“强一致”标签。
- 强一致边界应围绕不可出错的事实划定,而不是覆盖整条用户链路。
- 线性一致性、事务隔离和跨服务业务一致性是不同语义,必须分别写清楚。[2][3]
- 本地事务能解决的问题,不要用跨系统事务解决。
- 可靠事件只能保证事实传播,不能自动保证下游业务正确;消费者仍需幂等。
- 预占、确认和释放是一套资源生命周期模型,不能只实现成功路径。
- 支付、库存和账务都必须显式建模未知状态。
- Saga 解决流程收敛,不等于全局原子提交;补偿本身也是需要治理的业务动作。[7][9]
- 账本、状态事件和审计记录要保留解释能力,不能用直接改字段掩盖差异。
- 没有对账、补偿、审计和人工接管,就没有完整的高准确性系统设计。
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 本章的范围与非目标
本章聚焦:
- 如何从查询模式设计读模型,而不是直接复用写库模型。
- 如何建立延迟预算,并把预算分配到检索、特征、排序和拼装阶段。
- 如何通过索引、缓存、预计算、物化视图和 Fanout 保护读路径。
- 如何在不一致、依赖故障和过载时提供可解释的降级结果。
本章不展开具体搜索分词算法、推荐模型训练细节或某个云厂商的产品配置。搜索引擎、Redis、Kafka、推荐模型和 Elasticsearch 的实现细节,应结合本书后续基础设施章节阅读。
索引和搜索引擎的底层面试题可参见后端面试基础知识题单中的 Elasticsearch 主题,商品发现的完整业务读路径可参见第 14 章电商用户全生命周期设计。本章重点保留复杂读场景的统一模型、延迟预算和方案取舍。
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 写入侧:构建派生读模型
写入侧可以采用以下流程:
- 权威服务在本地事务内提交业务事实。
- 通过 Outbox、CDC 或事件日志发布变更。
- 索引构建器更新搜索文档。
- 物化视图构建器更新详情和聚合结果。
- 特征计算任务更新在线特征或候选集。
- 缓存刷新器主动刷新热点数据,长尾数据按需加载。
- 记录版本号、更新时间、失败原因和重建进度。
写入传播失败不能直接覆盖权威事实,也不能让读模型无限重试。应使用重试、死信、全量重建和差异校验保证最终收敛。
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]
读链路应建立分级降级:
- 关闭高成本特征和非核心排序;
- 减少召回深度、聚合数量和 Feed 扩散范围;
- 优先返回缓存、上一版本或热门兜底结果;
- 对长尾 Query、深分页和低优先级调用限流;
- 只有在核心事实无法可靠返回时,才返回明确错误。
降级需要有状态、原因和恢复条件。不能只在日志中打印 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 十个判断句
- 低延迟不是某个缓存或搜索引擎的能力,而是端到端预算、并行边界和降级策略共同产生的结果。
- P99 比平均延迟更能暴露复杂读系统的真实体验;请求扇出越大,越需要治理最慢依赖和共享资源排队。
- 查询模式决定读模型,写模型不应该被强行复用到所有读取场景;但读模型必须保留来源、版本和重建能力。
- 能预计算的不要在线计算,能缓存的不要重复计算,但任何预计算和缓存都必须定义陈旧、失效、回源和恢复语义。
- 搜索、推荐、广告和 Feed 都应拆成召回、过滤、排序、拼装和降级阶段;拆分是为了预算和治理,不是为了增加服务数量。
- 候选数量、特征维度和排序层级都是资源预算,结果质量提升必须与额外 CPU、网络、存储和延迟成本一起评估。
- 一致性要按字段和业务动作定义窗口,而不是对整个页面做笼统承诺;价格、库存、权限和删除状态应有更严格的在线校验。
- 读系统的降级目标是保护核心结果和整体可用性,不是无条件保留所有增强功能;降级还必须带原因、等级和恢复条件。
- 方案选型必须通过 ADR 说明获得了什么、牺牲了什么、失败后如何收敛,以及什么条件下重新评估。
- 一次设计评审至少要追问:权威事实是什么、读模型如何生成、延迟预算在哪里、陈旧多久可接受、热点怎样保护、失败如何解释、结果如何重建。
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 本章的范围与非目标
本章聚焦:
- 如何识别突发流量、单 Key 热点和持续写入瓶颈。
- 如何设计准入、限流、排队、预处理、异步落库和背压。
- 如何在热点场景中保护库存、计数、顺序、公平性和用户结果语义。
- 如何处理队列积压、重复消息、快速预处理层故障、写库失败和最终对账。
上传文件本身更适合使用对象存储和异步媒体处理;本章只讨论上传任务元数据、任务状态和热点任务调度,不把文件传输和数据库写入混成一个问题。实时音视频、搜索索引和大规模数据分析也有各自的写入模型,本章只抽取它们与洪峰、热点和最终收敛相关的共同方法。
Redis 的数据结构、缓存和集群边界可参见后端面试基础知识题单中的 Redis 主题,消息分区、消费和积压治理可参见后端面试基础知识题单中的 Kafka 主题,库存权威事实则可参见库存系统实战。本章关注这些基础设施如何组合成抗洪峰的写入方法论,而不是重新介绍某一个中间件的全部 API。把消息、事务和状态迁移视为可以组合的模式,而不是把某个产品名称当成方案本身,符合企业集成模式所强调的“上下文决定模式”。[5]
9.2 约束与指标:先算清到达速率、处理速率和可接受等待
9.2.1 入口速率和服务速率
用 λ 表示请求到达速率,用 μ 表示稳定处理速率。当 λ 长时间大于 μ 时,队列一定会增长;当队列达到容量上限后,系统只能拒绝、丢弃、降级或把压力传播到更危险的下游。队列只能把时间上的波峰变得平滑,不能把长期能力不足变成能力充足。
一个用于讨论的容量示例如下,数字只是设计假设,不是通用标准:
活动峰值到达:100,000 req/s
入口准入能力:20,000 req/s
异步处理能力:5,000 req/s
队列容量:600,000 条
如果 100,000 req/s 持续五分钟,而消费者只有 5,000 req/s,即使入口只接受 20,000 req/s,队列仍会以 15,000 条每秒的速度增长,约 300 秒后新增积压达到 4,500,000 条,远超 600,000 条容量。若系统在第 30 秒发现积压并立刻拒绝新请求,队列仍然需要约 120 秒才能消化原有积压;若消费者处理速率只能提升到 10,000 req/s,恢复时间还会更长。排队设计必须同时给出最大积压、最大等待时间、满队列策略和恢复速率。
可以用近似公式帮助评审:
积压变化率 = 到达速率 - 有效处理速率
预计等待时间 ≈ 当前积压量 / 有效处理速率
清空时间 = 当前积压量 / (恢复后的处理速率 - 恢复期间的到达速率)
其中“有效处理速率”不能只看消费者线程数,还要扣除失败重试、数据库锁等待、限流、死信和下游超时的成本。如果消费者因为热点行锁把一半时间耗在等待上,表面上的并发数并不能代表真正的业务完成速率。
9.2.2 容量指标不能只有 QPS
Google SRE 对过载的总结特别强调,不同请求可能消耗完全不同的 CPU、内存、线程、网络或后端资源,用单一 QPS 作为能力指标很容易误判。[2] 本章可以把指标分成七组:
| 指标类别 | 示例指标 | 设计用途 |
|---|---|---|
| 准入 | 接受率、拒绝率、资格命中率、429 比例 | 判断入口是否按预期保护系统 |
| 热点 | Top Key、单分区写入、单资源失败率、锁等待 | 识别局部瓶颈和热点迁移 |
| 队列 | 积压量、最老消息年龄、消费速率、死信量 | 判断异步链路是否可恢复 |
| 处理 | 成功率、重试率、重复消费率、处理耗时 | 判断消费者和事实层质量 |
| 用户结果 | 已受理、排队中、成功、失败、待确认数量 | 判断结果语义是否清晰 |
| 业务事实 | 库存差异、计数差异、重复订单、回补延迟 | 判断最终正确性 |
| 资源 | CPU、连接池、锁等待、磁盘刷写、网络、GC | 判断真正耗尽的资源 |
指标还要带上维度:活动、租户、用户等级、资源 ID、分区、错误类型和版本。只看全局平均值会掩盖“全站很健康但一个热门商品已经无法下单”的情况;只看成功率又会掩盖服务端为了保护系统而拒绝了大量请求的事实。
9.2.3 用户承诺必须可计算
如果系统承诺“排队后一定有机会成功”,就必须有队列容量、资格有效期和处理时限;如果系统承诺“提交成功即占库存”,就必须让库存事实在成功响应前可验证;如果系统只承诺“已受理”,就必须提供查询接口、状态变化通知和超时处理。承诺越强,同步链路要保护的事实越多,吞吐和可降级空间通常越小。
可以把业务承诺写成一个简单的契约表:
| 用户可见结果 | 系统必须保证 | 系统可以延迟 | 超时后的动作 |
|---|---|---|---|
| 已成功 | 权威事实已提交,重复查询返回同一结果 | 通知、推荐、统计 | 只补发通知,不重复执行事实变更 |
| 已受理 | 请求已持久化或进入可恢复队列 | 订单创建、索引、通知 | 查询状态,超过期限转失败或人工 |
| 排队中 | 队列有容量,资格尚未失效 | 绝大部分业务处理 | 队列满时拒绝,不伪造成功 |
| 待确认 | 记录了未知结果和查询线索 | 无法确定 | 查询下游、对账,禁止盲目重试 |
高峰场景最危险的不是拒绝,而是给出超过系统能力的承诺。对用户明确说“当前未获得资格”,往往比先返回成功、数分钟后再解释订单不存在更容易恢复信任。
9.3 核心模型:准入、缓冲、热点、事实和收敛
9.3.1 准入控制优先于后端扩容
准入控制回答“谁能进入系统”。它至少包括用户级频控、资源级频控、活动级全局限流、令牌或资格预发放、租户与风险等级分级,以及根据后端利用率动态拒绝。入口保护的对象不只是 API 服务器,还包括连接池、缓存、消息代理、数据库和任何拥有有限并发槽位的下游。
令牌桶适合控制平均速率并允许有限突发;漏桶适合把请求平滑成较稳定的处理速率;固定窗口实现简单但窗口边界可能形成双倍突发;滑动窗口更准确但需要更多存储和计算。Sentinel 的中文文档把流量控制、排队等待、系统负载保护和热点参数限流分成不同能力,并强调应结合 QPS、并发线程、平均 RT、CPU 或 Load 等运行信号进行保护。[14] 这说明限流算法只是实现细节,关键是限流维度、信号来源和超限后的结果语义。
入口通常需要多层限流:先按 IP、设备或用户阻挡明显的异常流量,再按活动和资源控制总量,最后按下游实际容量控制进入队列的速度。层层限流不能简单相乘,否则会出现一个入口允许 10,000 req/s、活动允许 5,000 req/s、热点商品允许 1,000 req/s,但因为重试和预热同时发生,最终仍然超过消息、缓存或数据库的有效能力。
9.3.2 热点需要分类处理
| 热点类型 | 瓶颈 | 可用手段 | 主要风险 |
|---|---|---|---|
| 库存热点 | 同一资源的唯一扣减 | 资格令牌、预占、分段库存、串行队列 | 超卖、回补失败 |
| 计数热点 | 同一对象频繁递增 | 分桶计数、异步聚合、批量写 | 计数短暂不准确 |
| 评论热点 | 同一对象新增大量记录 | 分区、异步写、楼层分配 | 顺序、审核和分页复杂 |
| 房间热点 | 同一直播间大量互动 | 房间级队列、房间隔离、丢弃策略 | 热房拖垮全站 |
| 分区热点 | Hash 结果集中到单分区 | 加盐、二级分桶、动态迁移 | 查询和合并成本增加 |
| 任务热点 | 同一租户或项目集中触发任务 | 租户配额、优先级队列、批次调度 | 长尾任务饥饿 |
分片不是万能药。如果所有请求争夺同一个商品的剩余库存,简单把请求分到多个分片可能破坏库存唯一性;必须先改变资源分配模型,例如预发放资格、分段库存或按时间窗口分配令牌。相反,对于点赞计数,写入可以按内容 ID 与桶号打散,展示时再合并;这种方案牺牲的是读取和重算复杂度,而不是库存唯一性。
热点还会动态变化。活动开始前可以按历史数据预热,但真正开场后 Top Key、Top 分区和最老队列消息可能迅速改变。因此热点观测应同时支持实时 Top K 和历史趋势,隔离策略也要能动态生效。否则为了防止未知热点而给每个资源都配置独立队列,会让系统的运维成本和资源空置率先失控。
9.3.3 快速预处理层不是最终事实源
Redis、内存令牌和边缘限流适合做快速判断:是否有资格、是否重复、是否还有预分配额度、是否需要进入队列。Redis Lua 脚本在服务器端原子执行,脚本执行期间其效果具有原子性,适合把“检查额度—扣减—记录请求”这类短操作放在同一原子步骤中。[11] 但原子执行不等于业务事实已经持久化,也不等于跨数据库、消息系统和支付系统的事务已经完成。
预处理层获得低延迟和高吞吐,牺牲的是持久化语义、跨系统事务和长期可审计性。因此每次预处理都必须生成业务 ID,并把预扣、资格、版本和过期时间写入可恢复的受理记录。后续事实层应能够根据业务 ID 查询、重放、释放或补偿,而不能只有一个无法解释的缓存计数。
快速层与事实层之间还要定义失败顺序。例如“Redis 预扣成功、受理记录写入失败”时,系统不能假设这份额度自动存在;可以采用先写受理记录再预扣、短期补偿扫描预扣记录,或者使用带状态的令牌表。每种方案都有成本,但都比把 Redis 数字直接当成库存账本更容易审计。
9.3.4 异步链路必须有背压
消息队列是缓冲器,不是无限仓库。背压意味着后端处理能力下降时,入口能感知并降低接收量,而不是继续把更多请求塞进队列。队列必须定义最大长度、消息最大存活时间、消费超时、重试上限、死信策略、优先级、可丢弃事件规则和恢复速率。
不同事件要使用不同的可靠性等级:
| 事件 | 是否允许合并 | 是否允许丢弃 | 失败后的主动作 |
|---|---|---|---|
| 库存预占、订单创建 | 通常不允许 | 不允许 | 重试、补偿、人工接管 |
| 用户点赞事实 | 可以按用户和内容去重 | 视产品约束而定 | 重放事实或重新聚合 |
| 点赞总数增量 | 可以批量合并 | 可以由事实重算 | 延迟聚合、定期校准 |
| 普通弹幕 | 可以采样或合并 | 通常允许 | 丢弃并记录比例 |
| 抽奖资格、中奖结果 | 不允许 | 不允许 | 保留事件、对账和审计 |
| 搜索索引刷新 | 可以覆盖旧事件 | 可延迟,不能静默丢失 | 依据版本重建索引 |
如果队列已经接近上限,入口应快速返回“暂不可受理”或降低业务等级,而不是让请求等待一个注定超时的连接。Redis Streams 的消费者组通过待处理列表、确认和重新认领机制表达了类似的恢复边界,但确认并不等于业务副作用天然幂等,业务层仍需保存处理状态。[12] 消费者恢复后也不能立即把最大并发打满,应该逐步升速,否则积压清理本身会形成第二次洪峰。
9.3.5 异步状态机和幂等键
异步写入必须把“请求 ID”“业务对象 ID”“消息 ID”和“幂等键”区分开。请求 ID 用于追踪一次客户端请求,业务对象 ID 用于查询订单或任务,消息 ID 用于识别一次投递,幂等键用于识别同一个业务动作。四者混用会导致重试时无法判断到底是同一次动作,还是同一对象上的新动作。
受理记录可以采用如下状态:
已接收 → 已排队 → 处理中 → 成功
├→ 失败
├→ 待重试
└→ 待人工
状态迁移必须带版本或条件更新。重复消费成功状态时返回已有结果;重复消费失败状态时不能无条件重新执行;过期消息到达时,应根据业务时间和当前版本决定丢弃、补偿或人工处理。状态机还要区分“业务失败”和“技术未知”:前者可以给用户稳定的失败结论,后者不能在未查询事实前直接告诉用户“失败”。
不要把“恰好一次投递”当成异步系统的默认前提。Kafka 官方文档将至多一次、至少一次和恰好一次区分为不同的投递语义,并指出端到端的恰好一次需要生产、处理和输出链路共同满足条件。[13] 工程上更普遍的做法是接受消息可能重复,把副作用放进可提交的本地事务,用唯一约束、处理记录和状态条件保证重复执行不会重复产生业务结果。Idempotent Consumer 模式明确建议把已处理消息的 ID 与业务更新放在同一事务边界中;重复消息到达时,业务结果应与第一次处理相同,而不是依赖消息代理替应用保证业务唯一性。[7]
9.4 参考架构:把洪峰挡在最终事实存储之前
9.4.1 通用写入链路
高并发写系统可以拆成六层:
客户端 / 活动入口
→ 网关鉴权、风控、幂等和准入
→ 快速预处理层:令牌、资格、去重、预扣
→ 队列或缓冲层:按资源、租户或房间分区
→ 工作层:按可承受速率执行本地事务
→ 权威存储:订单、库存、账本、互动事实
→ 查询、通知、对账、补偿和人工接管
入口受理层只做必须快速完成的动作:校验身份、判断资格、写入受理记录、生成业务 ID、返回状态。订单创建、库存确认、评论审核和媒体处理可以异步执行,但必须能够被查询和重放。工作层要有独立的并发上限和超时,不能因为队列里消息很多就无限增加消费者。
如果业务事实和消息发送必须同时成立,可以把事件写入同一数据库事务中的 Outbox,再由独立 relay 发布。Transactional Outbox 的核心是避免“数据库提交了但消息没发出去”或“消息发出但数据库回滚”的不一致;但 relay 在发布后崩溃仍可能重复发送,因此消费者仍然需要幂等。[6] 这比把消息发送直接塞在数据库事务中更容易在故障时重试和审计。
9.4.2 分区和队列设计
队列分区键应根据业务一致性选择:库存可以按资源 ID 或活动 ID 分区,社交互动可以按对象 ID 分桶,用户任务可以按用户 ID 或租户 ID 分区。分区键决定顺序、热点和扩展性,不能只为了均匀 Hash 而忽略业务约束。
分区设计通常需要在三种能力之间取舍:同一 Key 的顺序、不同 Key 的并行度和单分区的热点上限。严格按商品 ID 分区可以简化库存顺序,但一个爆款商品会锁住一个分区;按商品 ID 加桶号可以提高并行度,却要求业务层解决同一库存的竞争。RocketMQ 的顺序消息文档也强调,顺序通常按消息组定义,同一组可以保证局部顺序,不同组则可以并行;顺序消费还受到生产串行、消费确认和有限重试等条件约束。[16]
对单个热点资源,可以采用专属队列、分段令牌或串行消费者;对长尾资源,则可以共享普通队列。热点发现后进行动态隔离,通常比预先为所有资源配置独立队列更节省成本。动态隔离必须有迁移边界:切换前后的消息版本、重复消费、旧消费者停止时间和未确认消息归属都要有明确规则。
9.4.3 最终事实和展示结果分离
点赞事实可以按“用户 + 内容”唯一写入,点赞总数可以异步聚合;评论原文需要持久化和审核,评论列表可以延迟建立索引;直播弹幕可以允许丢弃低价值事件,但抽奖资格和中奖结果不能丢。这里的关键不是“所有内容都最终一致”,而是分别定义事实、投影和可重建边界。
可以把一个业务动作拆成三类数据:
- 权威事实:决定库存、订单、资格、账本和用户行为是否发生。
- 派生投影:计数、列表、搜索索引、推荐特征和通知状态,可以由事实重建。
- 运行记录:队列偏移、重试次数、处理耗时、最后错误和人工任务,用于恢复和治理。
事实必须优先保证唯一性和可审计性;投影优先保证可重算和延迟可控;运行记录优先保证故障时有人知道“下一步该做什么”。不能因为所有请求都叫“写入”,就给它们相同的可靠性等级,也不能把一个不可重建的计数器当成事实层。
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 正常链路
- 活动服务发布商品、库存窗口、用户资格规则、队列容量和过期时间。
- 用户请求进入网关,完成鉴权、风控、频控和资格判断。
- 系统为用户生成一次业务请求 ID,并在快速层完成去重。
- 预扣层判断令牌和可用预扣额度,失败则快速返回未获得资格。
- 预扣成功后写入受理记录,并把请求放入按商品分区的队列。
- 订单消费者消费消息,在本地事务中写入订单、库存确认记录和 Outbox 事件。
- 订单进入待支付,支付成功后进入已支付和已确认状态。
- 创建订单失败、支付超时或用户取消时,触发库存释放和预扣回补。
- 查询服务从受理记录和订单事实生成用户可见状态,通知服务只负责投影,不改变库存事实。
正常链路中最容易被忽略的是第 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 八个判断句
- 高并发写系统的首要目标不是把所有请求都处理完,而是让系统在洪峰下仍然可控。
- 突发流量、总写入量和单 Key 热点是不同问题,不能用一个扩容方案覆盖。
- 先控进入量,再谈处理量;先保护热点,再谈整体吞吐。
- 入口返回“已受理”时,必须提供可查询的状态、过期规则和最终结果。
- 队列可以削峰,但不能消除处理能力不足;最大积压和最长等待时间必须可计算。
- Redis 预扣、令牌和快速计数是保护层,不是订单、库存和账本的最终事实。
- 重试、重复消息、超时和乱序是正常输入,幂等和状态机必须内建。
- ADR 必须说明方案获得了什么、牺牲了什么、接受了什么风险,以及什么条件下需要重新评估。
9.8.2 评审清单与迁移边界
评审一个高并发写方案时,可以沿着以下顺序提问:
- 峰值是瞬时尖峰、持续高流量,还是热点集中?三者的假设是否有测量或压测依据?
- 入口最多允许多少请求进入,拒绝和排队分别意味着什么?队列满时谁先被牺牲?
- 哪些数据是不可丢失的权威事实,哪些是可以合并、延迟或重建的投影?
- 热点 Key、分区、锁、连接池和下游调用是否分别有指标和保护规则?
- 客户端重试、消息重试、补偿重试是否共享预算?未知结果如何查询?
- 快速预扣与最终事实之间有哪些状态,谁负责回补,回补失败如何进入人工流程?
- 是否有一条完整的正常链路、重复链路、超时链路、乱序链路和恢复链路?
- 是否用真实热点倾斜和故障恢复验证过,而不是只测均匀流量下的平均 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 章至第 14 章将沿本章划定的边界逐域深入;第 4 章已经从方法论层面讨论了跨系统集成与一致性模式。
阅读建议:若你已熟悉 DDD 战略术语,可快速浏览 10.2 后进入 10.1 的图示;若你更习惯从接口与数据表入手,建议从 10.1.3 数据架构读起,再回到 10.1.1 校正业务语义。无论哪种路径,都请至少完成 10.4 的三条时序走读,因为后续各章的「集成小节」默认你已经知道主链路的参与者与先后次序。
本章产出物(可用于团队对齐):
- 一页 限界上下文图(10.1.1)贴在内网架构 wiki。
- 一张 服务依赖图(10.1.2)导入架构治理工具(如 Backstage / 内部 CMDB)。
- 一份 主数据清单(10.1.3 表格)作为数据 Owner 会议的输入。
- 一套 集成模式评审话术(10.3)写进 RFC 模板。
- 一张 十二系统职责表(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 章至第 14 章时「对照地图」。
本章的阅读方式:不要把全景图当成服务清单,而要把它当成后续实战章节的索引地图。商品、库存、营销、计价、搜索、购物车、订单、支付、供给治理和供应商同步都会在后文展开;本章先把它们放到同一张业务地图上,帮助读者理解每个系统的职责、上游、下游和失败边界。
下面这张图先给出 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 维持;在分布式时代,网络边界会放大设计缺陷——原本一次函数调用的地方,变成了超时、重试与部分失败。因此全景章的价值,不在于「数有多少个微服务」,而在于为每个跨边界调用预先分配 一致性语义、超时预算与观测标签。当你在第 14 章阅读订单状态机时,应能指出:某次迁移对应 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
读图要点:
- 核心域集中了「钱与承诺」相关的上下文:购物车暂存购买意图,结算把意图推进为可支付的约束集合(价格、库存、优惠),订单把承诺持久化为合同,支付完成资金侧的闭环。
- 支撑域提供可售性、可算价、可营销、可供给四类「规则与主数据」能力;它们高度影响交易,但行业模式相对可参照。
- 通用域中的搜索本书会在第 14 章深入,因其在工程上与商品、计价、库存的 Hydrate 编排强耦合;用户与消息等更常采购标准方案,在全景中保留接口位即可。
上下文映射(与第 1 章 DDD 部分衔接):上图中实线箭头多表示「客户方依赖供应方」的下游调用关系;虚线表示「通过发布语言(Published Language)或 ACL 防腐」的弱耦合。[1][2] 落地时建议显式标出:
- 供应方(Upstream):商品中心对「商品快照 ID」、计价系统对「价格快照版本」、库存对「预占凭证」拥有定义权。
- 客户方(Downstream):订单与结算消费上述契约,但不应要求供应方暴露内部表结构。
- 防腐层(ACL):对接供应商、旧单体或外采营销引擎时,把外部模型挡在边界之外,避免污染核心域通用语言。
答辩提示:业务架构图的表述模板已统一收录到附录 D 的相应核心案例。
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/>第 14 章"]
PaySvc["支付系统<br/>第 14 章"]
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
落地要点:
- 订单、支付、商品主档等强一致实体以 MySQL(或同类)为权威存储;跨聚合协作优先 Outbox + 消息(见第 1 章 1.3.9),避免「双写」在故障时无法对账。
- 搜索索引、推荐特征、报表属于派生视图,允许最终一致;延迟由业务容忍度与补偿任务共同约束。
- 库存常见「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 章至第 14 章主要在应用与数据架构层面展开;当你评估「是否需要 Service Mesh」「Kafka 分区策略」时,应回到本节检查观测性是否先于网格、消息是否已成为事实管道等平台前提。
非功能需求(NFR)与全景的对应关系:
- 可用性:网关限流与服务熔断保护核心交易路径;搜索与报表故障不得拖垮创单。
- 性能:读路径大量使用缓存与索引;写路径控制扇出深度,结算页试算可合并批量 RPC(第 14 章)。
- 安全:密钥不进仓库;支付回调验签在独立模块;内部服务 mTLS 或网络策略隔离。
- 可观测性:以
trace_id贯穿网关、结算、订单、支付;对 Saga 每一步有结构化日志与业务指标(转化率、预占失败率)。 - 合规与审计:订单与支付字段变更可追溯;营销补贴与实付金额可对账。
渐进式演进建议:早期可用「单体 + 清晰包边界」模拟上图拓扑;当团队规模与发布冲突上升时,再按限界上下文拆出独立部署单元,避免「先拆微服务、后补边界」的高成本路径。
容灾与多活(点到为止):技术架构图未展开「单元化 / 多 Region」,但在全景阶段应预留认知:订单与支付数据往往要求 Region 内强一致 + 跨 Region 异步复制;搜索索引与购物车会话更适合 就近读取。若在多活场景下仍沿用单 Region 的强同步调用链,容灾切换时容易遭遇「依赖未起、核心不可用」;因此第 4 章在谈 Saga 时也会隐含「地理边界上的超时预算」问题。
10.2 核心域与支撑域
DDD 强调:不是所有子域都值得同等投入。战略设计的产出之一,是一张「域分类表」,用于指导组织排兵布阵与技术选型(自研 / 定制 / 采购)。
10.2.1 核心域:交易与支付
核心域承载差异化与最高业务风险,典型包括:
| 限界上下文 | 业务价值 | 失败影响 | 工程特征 |
|---|---|---|---|
| 订单 | 合同与履约编排的单一事实来源 | 错单、重复下单、无法履约 | 状态机、幂等、Saga、审计 |
| 支付 | 资金收付与对账闭环 | 资损、监管与信任危机 | 幂等、渠道适配、账务分录 |
| 结算 | 把「可卖」推进为「可付」 | 转化暴跌、资源错锁 | 长事务编排、降级与超时释放 |
| 购物车 | 购买意图与会话合并 | 体验与转化问题 | 高并发读写、合并策略 |
本书将购物车、结算、订单与支付作为交易链路主轴(第 14 章),并在第 4 章从一致性模式上把它们串成可复用的集成语言。
投资与组织策略(与系统设计方法论中的技术投资判断对照):
| 维度 | 建议 |
|---|---|
| 团队配置 | 核心域配最强工程与业务分析能力;接口契约由领域 Owner 签字。 |
| 发布节奏 | 核心域应支持高频小步发布 + 特性开关;重大促销前冻结非关键变更。 |
| 质量门禁 | 核心域 PR 适用第 7 章全阶段评审;支付与订单变更默认要求双人审。 |
| 技术债 | 核心域技术债「零容忍排队」;偿债预算单独列项,不与功能挤同一队列。 |
常见误区:把「购物车」当成纯前端本地存储。实际上购物车是高并发有状态服务,涉及登录合并、库存展示与营销提示,与结算的边界必须在 API 契约上划清(第 14 章)。
10.2.2 支撑域:商品、库存、营销、定价与供给
支撑域是核心域的「地基」:没有可售商品与可算价格,订单与支付无从谈起;没有库存与营销约束,结算编排也会失去输入。
| 分组 | 限界上下文 | 与核心域的接口关系 |
|---|---|---|
| 商品与供给 | 商品中心、上架、运营、生命周期 | 提供 SPU/SKU、快照、上下架状态;不直接参与支付 |
| 规则与资源 | 库存、营销、计价 | 提供预占、券活动、试算与快照;被结算与订单编排调用 |
第 11 章至第 14 章分别深入各支撑系统;第 11 章从组织上常合并「上架 + 运营 + 生命周期」,但限界上下文仍建议在模型层分开,以避免「一个上帝服务」拖垮发布节奏。
为什么支撑域也值得深度自研:支撑域虽非「卖点」,却是故障的放大器。例如库存超卖、计价错误、营销叠加漏洞,都会在订单层集中爆发。架构评审中常问:「若该支撑域宕机 30 分钟,核心域能否优雅降级?」——答案决定缓存策略、兜底价、降级开关的设计深度。
与核心域的集成契约(摘要):
- 商品中心输出:快照 ID、类目路径、禁售标签。
- 库存输出:预占凭证、可售数量区间、渠道库存类型(第 12 章二维模型)。
- 营销输出:可叠加规则集、锁定 token、预算占用凭证。
- 计价输出:试算结果哈希或版本号,供创单时校验「结算页所见即所得」。
10.2.3 通用域:用户、搜索、消息等
通用域标准化程度高,通常采购或薄封装即可;例外是搜索与导购:虽然模式成熟,但在中大型平台中与商品、价格、库存的实时编排深度交织,本书第 14 章将其放入完整交易旅程展开。
| 能力 | 常见策略 | 与交易链关系 |
|---|---|---|
| 用户与会员 | SSO、OAuth、IdP | 提供主体身份与风控标签 |
| 消息通知 | 短信、邮件、Push | 订阅订单与支付事件 |
| 搜索 | ES + 召回排序 + Hydrate | PDP/列表需联动计价与库存态 |
反模式提醒:把「通用域 = 可以随便写」等同于降低质量要求。正确做法是:减少自研范围,但不降低 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 章给出购物车、结算、订单、支付状态机、回调和补偿;第 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 语义,避免在回调线程堆叠扇出。
与订单的边界:订单系统关心「应付金额与履约状态」;支付系统关心「渠道收单结果与资金事实」。订单不应直接保存渠道原始报文全字段,应由支付系统规范化后回写支付结果摘要。
伏笔:第 14 章展开渠道适配、对账与退款;第 4 章讨论跨系统幂等与补偿事务编排。
10.5 系统边界总览
10.5.1 各系统的职责边界(十二个核心系统)
下表给出本书采用的十二个核心可部署系统(与 10.1.2 应用架构及第 11 章至第 14 章对应)。「不负责」列用于架构评审时的负面清单,防止边界侵蚀。
| 编号 | 系统 | 核心职责 | 明确不负责 | 深入章节 |
|---|---|---|---|---|
| 1 | 商品中心 | SPU/SKU、类目属性、商品快照与主数据质量 | 库存扣减、营销计算、支付 | 第 11 章 |
| 2 | 库存系统 | 可售量、预占/确认/释放、对账与供应商同步 | 价格计算、订单状态机 | 第 12 章 |
| 3 | 营销系统 | 券/活动/补贴、圈品、预算与防刷 | 订单持久化、支付渠道 | 第 13 章 |
| 4 | 计价系统 | 多场景试算、费用、价格快照与降级 | 营销资金账、库存数量 | 第 13 章 |
| 5 | 搜索与导购 | Query、召回、排序、Hydrate 编排 | 不作为订单或支付事实来源 | 第 14 章 |
| 6 | 购物车 | 行项目暂存、合并、批量操作 | 不持有支付契约 | 第 14 章 |
| 7 | 结算编排 | 结算页 Saga、预占协调、拆单预览 | 不替代订单合同存储 | 第 14 章 |
| 8 | 订单系统 | 合同、状态机、拆单、履约协调入口 | 渠道密钥、资金划拨 | 第 14 章 |
| 9 | 支付系统 | 支付单、渠道路由、回调、对账与退款 | 商品主数据、库存数量 | 第 14 章 |
| 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 边界不清的常见问题
- 商品中心写库存流水:库存的并发语义与对账域应收敛在库存上下文,否则易出现双写不一致。
- 营销系统直接改订单金额:优惠「算出来」与订单「记下来」应分离;否则退款与审计难以追溯。
- 搜索索引当主库:索引延迟会导致「搜得到但买不了」,必须在 Hydrate 或结算侧再次以权威服务为准。
- 支付系统承载订单状态机:资金状态与履约状态相关但不同;混写会导致渠道回调与拆单场景难以治理。
- 计价系统写订单行:计价负责「算」与试算令牌;订单负责「记」与版本校验。混写会让退款金额拆分失去依据。
- 购物车持有库存预占:预占属于资源锁定,应落在结算或订单编排;购物车仅存意图与展示缓存。
- 运营后台直连生产库改价:绕过计价与审计,极易产生监管与对账风险;应走审批流 + 正式 API。
- 搜索服务在召回阶段调用支付:读路径不应触碰资金系统;价格与活动以 Hydrate 调用计价与营销只读接口为界。
10.5.3 边界划分原则(落地检查清单)
- 单一事实来源(SSOT):每个聚合只有一个权威上下文持久化。
- 编排与状态分离:编排服务可以无状态或仅存会话;合同状态由订单上下文持有。
- 读模型可替换:搜索、推荐、报表可重建;不可重建的是资金流水与订单合同。
- 跨域用契约,不用隐式共享表:表连接是反模式;用 API、事件与明确 DTO。
- 把「能不能买」的最后一次校验放在离钱最近且可审计的一步(通常是创单或支付创建)。
- 明确「编排」与「领域服务」:编排负责步骤顺序与超时;领域服务负责业务规则判定。二者勿混在同一「上帝类」中。
- 每个跨系统接口都有 SLI:例如试算 P99、索引延迟上限、支付回调处理延迟;无指标的接口等于无边界。
- 用例驱动的边界测试:为每个系统维护「本系统拒绝处理的请求样例」,在 CI 或契约测试中固定下来,防止回归侵蚀。
10.6 本章小结
本章是全书的总领章,目标不是替代后续各章的深度,而是建立三样东西:同一套词汇(限界上下文与十二个系统)、同一张依赖图(应用与数据架构)、同一套交互纪律(同步 / 异步 / 数据同步的组合拳)。[2][7]
你可以带走的关键结论:
- 四视角对齐:BA 决定投资与语义边界,AA 决定可部署单元与依赖方向,DA 决定权威数据与投影路径,TA 决定规模化运行时能力。缺任一视角,评审容易出现「各说各话」。
- 十二个核心系统:商品中心、库存、营销、计价、搜索、购物车、结算编排、订单、支付、商品上架、供给与运营、生命周期治理——分别对应第 11 章至第 14 章的主体叙事;其中第 11 章在工程上常合并多个部署单元,但在治理上仍建议按职责拆分讨论。
- 域分类指导排兵:核心域(订单、支付、结算、购物车)追求正确性与可审计性;支撑域追求稳定与可替换的集成契约;通用域追求成本与 SLO 的平衡。搜索处于「通用技术 + 核心体验」的交叉带,第 14 章会展开其 Hydrate 与降级策略。
- 交互模式不可偏科:只有同步会导致故障传播;只有异步会拉长不一致窗口;只有批式同步无法满足实时导购。实际架构是在 SLA、成本、团队成熟度 约束下的组合。
- 三条主链路是阅读地图:商品流回答「货怎么进来并被发现」;订单流回答「承诺如何形成」;支付流回答「资金如何闭环」。后续章节均可挂载到这三条链上自检:本章的哪个小节、哪张图覆盖了当前话题。
与第 4 章的衔接:第 4 章将把 10.3 的交互模式上升为 Saga、幂等、对账、事件驱动 等一致性语言,并把 CAP 折中讲透。建议在阅读实战章节时,用 10.4 的时序图遮住文字,尝试口述一遍每条箭头上的失败与补偿,检验是否已建立全景肌肉记忆。
与第 11 章至第 14 章的衔接:进入任一系统章时,建议先回答四个问题——谁是上游、谁是下游、我的 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 业务事实、不变量与非目标
任何设计开始前,都应把“必须永远成立”的不变量写出来。下面这些不变量比组件名称更重要:
- 已发布的正式商品版本具有单调递增的逻辑版本,旧版本不能覆盖新版本。
- 审核通过的对象必须有明确的内容版本,不能审核一个对象、发布另一个未被审核的对象。
- 同一个外部来源对象的同一版本重复到达,不应造成第二次业务副作用。
- 库存预占、确认和释放必须属于同一库存语义,商品发布不能假装已经完成库存扣减。
- 订单创建后必须依赖订单自己的商品快照和价格快照,不能回查商品中心当前内容作为历史事实。
- 任意异步任务都必须能回答:谁提交、处理到哪、哪一行失败、是否可以重试、重试会不会重复副作用。
- 事件消费失败不能阻塞正式事实写入,但必须进入可观测、可重试、可对账的恢复路径。
本章不讨论完整的推荐算法、营销优惠计算、支付渠道路由、订单履约编排和供应商结算。它们会在其他章节展开;本章只描述这些系统与商品供给之间的契约和边界。
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 复杂度预算
每增加一种入口,系统至少增加一类失败模式。人工入口增加并发编辑和权限问题;文件入口增加格式、行级错误和大任务恢复问题;供应商入口增加重复、乱序、来源可信度和全量删除误判问题;库存入口增加资源生命周期和扣减语义问题。复杂度不是由服务数量决定,而是由状态数量、事实源数量、异步边界和恢复动作共同决定。
因此,平台需要给每种能力设置复杂度预算:
- 只要同步保存草稿能够满足体验,就不要把草稿保存也拆成多个远程服务调用。
- 只要数据库 CAS 和短事务能够支撑任务领取,就不要为展示“分布式”而引入 Redis 锁。
- 只要单个供应商任务可以在可接受窗口内完成,就先使用 Batch + Checkpoint + DLQ,再根据测量结果引入分片。
- 只要搜索投影能够通过版本号过滤旧事件,就不要让搜索索引反向决定商品是否发布。
- 只要交易时可以重新校验,就不要为了列表页的即时准确把高频库存写入搜索引擎。
这不是保守,而是把复杂度放在真正需要它的地方。阿里巴巴的公开工程规范也把数据库、异常、日志、工程结构和安全约束放在同一套开发治理框架中,说明系统质量不只来自业务代码本身,还来自边界和习惯的长期一致性。[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、日期和入住人数作为资源条件;数字商品可能把一个兑换码批次作为库存资源。关键不在于名词是否完全一致,而在于模型能够回答:对象的稳定身份是什么,哪些字段属于它,哪个变化会创建新版本,哪个变化只更新运行时资源。
| 对象 | 稳定身份 | 典型字段 | 变化频率 | 权威来源 |
|---|---|---|---|---|
| Resource | resource_id | 资源名称、供应商、地点、资源类型 | 中 | 商品中心或资源域 |
| Item | item_id | 展示标题、状态、发布版本、类目 | 低到中 | 商品中心 |
| SPU | spu_id | 商品族共性属性、品牌、系列 | 低 | 商品中心 |
| SKU | sku_id | 规格组合、可交易单位、履约约束 | 中 | 商品中心 |
| Offer | offer_id | 渠道、售卖计划、基础价格、供应商条件 | 中到高 | 商品中心 / 计价契约 |
| Rate Plan | rate_plan_id | 价格计划、退改规则、适用窗口 | 中 | 商品中心 / 计价系统 |
| Inventory Resource | inventory_resource_id | 数量、券码、日期、时段、预占规则 | 高 | 库存系统 |
| Snapshot | snapshot_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、FAILED | Task Worker、管理员 |
| Task Item | 任务平台 | PENDING、PROCESSING、SUCCEEDED、FAILED | Item 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 | 读取失败不创建修改 |
| 保存 Draft | Draft 内容和基线 | 未发布 | 可重复保存和覆盖 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 单品创建和编辑
单品创建可以采用同步受理、异步审核和显式发布的三段式体验:
- 前端提交字段,供给服务做轻量格式校验并保存 Draft。
- 服务返回
draft_id、当前校验结果和下一步动作,不承诺正式商品已经出现。 - 用户提交审核,系统冻结一个 Staging 版本,生成 QC 任务。
- QC 通过后,用户或规则决定是否发布;发布命令带上
staging_id和base_publish_version。 - 商品中心在本地事务内合并正式数据并生成 Outbox。
- 库存、搜索、计价和营销分别处理自己的投影,交易前重新校验。
在线编辑必须记录基线。假设编辑者在版本 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、删除标记语义、拉取窗口、限流规则和对账责任。
如果供应商只提供全量列表,不能把“本次列表中没有对象”直接当成删除。可靠做法是:
- 为一次拉取生成
sync_batch_id。 - 把原始对象和来源版本写入 Raw Snapshot。
- 对每个对象计算规范化指纹和来源版本。
- 只有批次完整、来源可信、校验通过后,才把“上一批次存在、本批次缺失”的对象放入待确认删除集合。
- 经过延迟窗口、重复拉取或供应商确认后,才生成下线命令。
供应商更新还要处理来源字段和平台字段的冲突。平台可以接受供应商对房型名称、供应商价格和外部库存的主导,但平台审核状态、平台类目、展示标题和合规标签不能被供应商同步静默覆盖。一个字段是否可被外部更新,应由字段主导权表决定,而不是由同步代码里的 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 发布事务和快照
发布事务的目标是确保商品中心的本地事实具有一致解释,而不是让所有外部系统同时完成。一个合理的本地事务可以按照以下顺序执行:
- 锁定或读取 Draft / Staging 的确定版本。
- 检查当前正式商品版本是否等于基线版本。
- 校验 QC 结果、模板版本、字段主导权和必要的交易规则。
- 把差异合并到正式 Item、SPU、SKU、Offer 和规则表。
- 写入新的 Publish Version 和商品快照。
- 写入字段变更日志和操作审计。
- 写入带有聚合 ID、版本、事件 ID 和 schema 版本的 Outbox。
- 提交事务并返回正式版本。
数据库事务不应包含远程搜索、库存、计价或营销调用。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 更新。券码制库存则需要先上传原始批次、做重复码和格式校验,再激活可用券码。日期和时段库存需要按照资源格处理,不能把总量库存的加减模型直接套用。
库存协同要特别处理四种异常:商品发布成功但库存初始化失败,库存初始化成功但商品发布回滚,库存已经可售但商品被合规下架,商品版本已更新但库存配置尚未刷新。每一种异常都应有明确的可售结果、补偿动作和审计记录。
| 异常 | 商品中心状态 | 库存状态 | 用户可见结果 | 恢复动作 |
|---|---|---|---|---|
| 发布成功、库存初始化失败 | PUBLISHED | INIT_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 表,但所有有副作用的消费者都需要可证明的去重依据。
| 表组 | 关键唯一键 | 关键外键 / 引用 | 典型归档策略 |
|---|---|---|---|
| 身份表 | 租户 + 命名空间 + 外部 ID | Resource、Item 关系 | 长期保留 |
| 流程表 | Task ID、Item ID、版本 | Draft、Staging、QC | 完成后冷热分层 |
| 版本表 | Item ID + publish version | Snapshot、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 标准化、校验和风险审核
标准化不是简单字段重命名,而是把不同来源的表达转换为平台语义。例如供应商把“含早”“早餐”“早餐包含”都表示为同一类服务,但是否真的等价要由来源契约和业务规则确认;“可退”“免费取消”“入住前一天可退”则不能只做字符串归一化,因为退改时间和费用会影响订单合同。
推荐把标准化拆为三个阶段:
- 词法标准化:编码、空白、时间格式、币种、单位和枚举别名转换。
- 结构标准化:把来源对象映射到平台对象,例如把一行文件转换为 Resource、Item、SKU、Offer 和库存配置候选。
- 语义标准化:根据类目、来源、规则和历史映射判断是否存在冲突、重复或需要人工确认。
校验结果要保留规则 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 / Search | item_id + version | 索引短暂落后 | 重放事件或重建文档 |
| Item / Inventory | item_id + inventory config version | 未初始化期间不可售 | 重试初始化或人工处理 |
| Outbox / Event bus | event_id | 发送延迟 | 扫描 Outbox 重发 |
| Supplier / Platform | 外部 ID + source version | 批次窗口内延迟 | 拉取确认或生成差异 |
| Task / Item | task_id + item key | 行级处理中 | 续租、重试或人工关闭 |
| Order / Product | snapshot 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_id | hotel-supplier-17 | 外部来源主体 |
external_resource_id | H-88921 | 供应商侧酒店身份 |
external_room_id | H-88921-R-03 | 供应商侧房型身份 |
supply_trace_id | supply-20260921-001 | 贯穿一次商品生命周期的跟踪身份 |
operation_id | op-20260921-043 | 一次创建、编辑或同步动作 |
item_id | item-88421 | 平台正式商品身份 |
publish_version | 19 | 平台正式版本 |
inventory_batch_id | inv-batch-302 | 库存初始化或补货批次 |
案例的完成条件不是“所有服务都返回成功”,而是以下不变量同时成立:
- 平台能够由
item_id反查来源、草稿、审核、发布和库存任务。 - 当前正式 Item 对应一个明确的
publish_version,搜索和缓存不会接受更旧版本覆盖。 - 交易前能够依据入住日期、房型和人数检查权威库存,不能只信搜索结果。
- 订单保存自己的商品、退改和价格快照,后续商品编辑不改变历史解释。
- 任意一次失败都能定位到阶段、输入、责任主体和下一步恢复动作。
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 阶段十:对账和最终收敛
案例结束后,对账任务抽样检查:
- 商品中心的
item-88421当前版本是否等于 3。 - 搜索索引的
projected_version是否等于 3 或处于允许滞后窗口。 - 库存系统的未来日期资源是否与库存配置版本相容。
- Outbox 的
event-9001是否已发送并被关键消费者确认。 - 订单快照是否引用正确的商品版本和规则快照。
- 供应商最新来源版本是否已经映射到平台版本,还是处于冲突队列。
如果搜索版本落后,自动重放版本 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 评审清单:模型与边界
评审商品模型时,逐项回答:
- Resource、Item、SPU、SKU、Offer 和 Rate Plan 的身份是否稳定?
- 哪些字段是商品中心主导,哪些字段由供应商或运营主导?
- 同一个外部对象是否可能映射多个平台对象?发生冲突时谁处理?
- Draft、Staging、QC 和正式 Item 是否有独立语义?
- 发布版本是否能比较新旧?版本是否与时间戳混用?
- 订单快照是否包含足以解释历史交易的字段?
- 库存配置、库存余额、预占和确认是否属于不同对象?
- 商品状态和可售状态是否被错误合并?
- 规则、schema 和模板是否带版本?历史结果是否能回放?
- 是否存在一个“万能 JSON”让所有下游自行解释字段?
如果这些问题中有三项以上无法回答,说明应该先补模型和契约,暂时不要继续拆服务或增加消息队列。
11.10.4 评审清单:链路与失败
评审创建、编辑或同步链路时,检查:
- 输入是否先保存为不可变原始数据?
- 重复提交是否有业务幂等键和参数冲突判断?
- 大文件是否流式解析,是否支持行级错误?
- 任务是否可以暂停、恢复、重试和人工关闭?
- Worker 是否有租约、令牌和过期恢复?
- Checkpoint 是否足以从稳定位置继续,而不是只保存一个模糊的进度百分比?
- 失败是否区分永久错误、暂态错误、冲突和未知结果?
- 重试是否设置最大次数、总耗时和退避抖动?
- DLQ 是否有实际的处理人、回放入口和关闭理由?
- 事件消费者是否按事件 ID 和业务版本去重?
- 旧版本事件到达时,系统是否会拒绝覆盖新事实?
- 下游投影失败时,正式商品是否仍然可解释?
- 对账是否能发现静默丢失,而不仅仅是接口报错?
- 人工接管是否通过命令完成并被审计?
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 方案交付物清单
一份可以进入评审和实施的商品供给方案,至少应该交付以下内容:
- 场景边界:列出人工、批量、供应商、库存和生命周期入口,并明确不做什么。
- 约束表:记录规模、峰值、延迟、新鲜度、成本、合规和恢复目标,注明假设或测量来源。
- 领域模型:说明每个对象的身份、字段、生命周期、权威来源和消费者。
- 状态机:列出状态、允许迁移、禁止迁移、原因、操作者和异常终态。
- 数据模型:给出主表、版本表、任务表、事件表、审计表和关键唯一约束。
- 参考架构:展示控制面、事实面、投影面、同步 / 异步边界和关键失败路径。
- API 契约:区分 Command、Query 和 Event,说明幂等、版本、错误和新鲜度。
- 任务设计:说明租约、Checkpoint、批次、分片、重试、DLQ 和人工接管。
- 发布设计:说明本地事务、Outbox、事件版本、消费者去重和对账。
- 完整案例:走通一个正常链路和至少三种异常分支,说明最终收敛。
- ADR:解释关键选择、放弃的能力、接受的风险和重新评估条件。
- 验证计划:列出单测、契约测试、故障演练、容量压测和上线后指标。
如果只有架构图而没有状态机,读者无法知道异常如何推进;如果只有表结构而没有事件和版本,读者无法知道跨域如何收敛;如果只有推荐方案而没有 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
这条闭环的每一步都在限制下一步的自由度。没有业务事实,领域模型会变成字段清单;没有状态和不变量,接口会变成任意更新;没有权威来源,事件会变成无主的通知;没有本地事务边界,最终一致会变成长事务或同步链;没有失败设计,重试会产生重复副作用;没有观测和对账,系统无法证明已经恢复。
对于架构师而言,最重要的能力不是背诵某个组件的特性,而是能够在约束发生变化时重新做判断。供应商从十个增加到一千个,首先变化的是来源治理和限流;商品从百万增加到千万,首先变化的是索引、批量任务和归档;库存从数量变成座位,首先变化的是资源身份和预占语义;团队从一个变成多个,首先变化的是契约、责任和审计。
最后可以用六个问题结束评审:
- 这个对象的业务事实是什么?
- 谁拥有它,谁可以修改它?
- 哪个版本可以被审核和发布?
- 哪些动作必须在本地事务内完成?
- 失败、重复、乱序和未知结果如何恢复?
- 系统如何证明最终状态已经收敛?
如果六个问题都有明确答案,技术实现通常可以在多个候选方案中演进;如果答案依赖“组件默认会处理”或“上线后再观察”,系统即使暂时运行,也会在下一次批量导入、供应商故障或大促高峰中暴露边界缺口。
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 编排 + 逆序释放 |
这些场景看起来差异很大,但它们都必须回答同一组问题:
- 什么实体被承诺?
- 这个实体在哪个范围、时间窗口和批次内有效?
- 谁是当前事实来源?
- 预占是否需要隔离其他请求?
- 确认、释放和补偿分别意味着什么?
- 如果请求超时但操作可能已经成功,系统如何查明结果?
- 如果缓存、消息或外部供应商返回旧数据,哪些操作必须拒绝?
如果这些问题没有先被写入模型,工程实现通常会把差异堆积为 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 库存问题的五种根因
线上看到“库存不对”时,不应马上把问题归类为超卖。相同的表象可能来自五种完全不同的根因:
- 并发安全失败:两个不同 operation_id 同时满足了可售条件,导致确认量超过 available。
- 重复执行失败:同一个 operation_id 因超时被执行两次,产生两个 Reservation、两条发码记录或两次供应商预订。
- 投影漂移:事实库已经释放或确认,Redis、搜索或报表仍停留在旧版本。
- 边界语义失败:平台把“请求已发送”解释成“外部已预订”,把“支付成功”解释成“资源已确认”,或把“缓存有值”解释成“事实可售”。
- 治理失败:日志不含操作号、没有对账阈值、没有冻结门闩,故障发生后无法判断哪些订单已经改变事实。
不同根因对应不同修复:
| 根因 | 不能解决它的办法 | 应优先验证 |
|---|---|---|
| 并发安全 | 只增加缓存副本 | 条件更新、锁范围、热点分布 |
| 重复执行 | 只提高超时时间 | 唯一键、操作表、参数摘要 |
| 投影漂移 | 直接修改一个缓存数字 | 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]。
容量估算至少要拆成五个平面:
- 用户同步平面:Reserve、Confirm、Release 的延迟和成功率;
- 热路径平面:Redis 脚本执行、热 key、分片和连接池;
- 事实写入平面:数据库行锁、CAS 冲突、流水追加和 Outbox;
- 异步平面:消息积压、投影水位、补偿任务和供应商轮询;
- 运维平面:对账扫描、重建任务、审计查询和人工处理队列。
任何一个平面出现瓶颈,用户都会看到“库存不足”或“下单失败”,但根因可能完全不同。把这些错误混成一个状态,会导致产品文案、告警和排障都失去方向。
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,就不能只用全局平均值评估数据库锁竞争。
容量估算至少要拆成四条线:
- 在线读取线:Check、商品展示和搜索过滤,关注缓存命中、p99 延迟、热点 key 和回源比例。
- 在线承诺线:Reserve、Confirm、Release,关注单实体串行化、事务时间和失败重试。
- 异步变更线:Outbox、投影、供应商同步和清理任务,关注积压年龄、批次大小和重放速度。
- 治理线:对账、审计和重建,关注扫描范围、锁影响、读放大和恢复窗口。
例如,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 个预占来自哪一个活动锁定、哪一个订单和哪一个库存世代。不能只根据 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_buffer | AVAILABLE 码数量 | 最近一次确认的可售上限 | 规则计算结果 |
统一接口并不意味着统一实现。数量库存可以用 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 已生成。
可以为每个命令写出三元组:
| 命令 | 前置条件 | 后置不变量 |
|---|---|---|
| Reserve | ACTIVE、数量足够、版本匹配、操作未完成 | available + booking + locked + sold = total - invalid |
| Confirm | Reservation=RESERVED、订单归属正确、未过期或有支付例外 | booking 减少、sold/committed 增加、同一资源不能再次确认 |
| Release | Reservation=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 核心表模型
下面的表名和字段是公开示例,不对应任何内部系统。字段的价值在于表达约束。
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,
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, supplier_id
)
) ENGINE = InnoDB;
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]。
批量投影要避免长事务:
- 用状态和租约批量领取一小批 Outbox 或 Document;
- 在本地事务中更新连续版本;
- 快速提交;
- 提交后发送外部消息或更新 Redis;
- 结果未知时保留原 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->>I: Reserve(reservation_id, unit, qty)
I->>R: Lua check + move available -> booking
R-->>I: RESERVED or known duplicate
I->>D: Document + Reservation + Ledger + Outbox
D-->>I: PAYMENT_ELIGIBLE
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: TTL/Delay/Sweep releases expired reservations
真正的 Reserve 不是“检查一下 Redis 还有没有”,而是一次具备幂等性的状态迁移。应用层可以先创建 reservation_id,但不能在 Redis 失败后生成新的 ID 重新扣减;否则第一次请求可能已经成功,第二个 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 超时、取消和释放
释放是最常见也最容易被低估的库存操作。至少应有三道触发:
- Redis Reservation 的 TTL 或过期扫描,尽快发现过期;
- 延时任务,在订单进入待支付时安排一次低延迟触发;
- 数据库 Sweep,周期扫描所有 RESERVED 且 reserve_expire_at < now 的记录,作为完整性后盾。
延时任务可以丢失、重复或延迟,所以它不能作为唯一事实来源。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 脚本至少应完成以下操作:
- 检查库存单元处于 ACTIVE;
- 检查请求 epoch 与 key epoch 一致;
- 检查 Reservation 是否已存在;
- 检查可售量是否足够;
- 移动 available -> booking;
- 保存 Reservation 数量、操作 ID、epoch 和过期时间;
- 返回明确结果码。
示例:
-- KEYS[1]: inventory:qty:{unit_id}
-- KEYS[2]: inventory:reservation:{reservation_id}
-- ARGV[1]: quantity
-- ARGV[2]: expected_epoch
-- ARGV[3]: expire_seconds
-- ARGV[4]: operation_id
local stock_key = KEYS[1]
local reservation_key = KEYS[2]
local qty = tonumber(ARGV[1])
local expected_epoch = ARGV[2]
local expire_seconds = tonumber(ARGV[3])
local operation_id = ARGV[4]
local existing = redis.call('HGET', reservation_key, 'operation_id')
if existing then
if existing == operation_id then
return {1, redis.call('HGET', reservation_key, 'status') or 'RESERVED'}
end
return {-3, 'RESERVATION_CONFLICT'}
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')
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,
'status', 'RESERVED')
redis.call('EXPIRE', reservation_key, expire_seconds)
return {0, 'RESERVED'}
Redis 官方文档说明,Lua 脚本执行时会阻塞其他 Redis 活动,因而能把多步条件更新作为一个原子脚本执行[12]。但脚本越长,阻塞时间越大;脚本中的 key 必须显式传入,集群环境下多个 key 还要落在同一 hash slot。腾讯云中文命令准则也明确区分了原生命令、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 看不到就再次分配。
券码的安全规则至少包括:
- 热队列只存内部 code_id;
- 原文只在受控履约边界解密;
- code_hash 用于去重和排查,不用于恢复明文;
- 导入任务有批次、操作者、来源和校验摘要;
- SOLD、REDEEMED 和 VOIDED 不得直接转回 AVAILABLE;
- 发码接口使用订单/履约幂等键,重复请求返回相同的展示结果或已处理状态;
- 日志、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/Document | Code 状态 + 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 |
| Reserve | code_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"
}
接口实现需要检查:
- idempotency_key 是否已经处理,若已处理则返回首次结果;
- 请求参数 hash 是否与第一次一致,防止同一 key 被复用为不同操作;
- Reservation 是否属于同一订单、库存单元和 epoch;
- 数量是否为正且不超过调用方权限;
- deadline 是否已经不足以执行可靠操作;
- 调用方是否有权使用该渠道、批次和供应商。
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 系统职责矩阵
| 系统 | Owns | May call | Must 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 设计成业务操作的一等实体:
- 接收请求时,先校验 operation_id、业务主体、商品和数量等关键参数。
- 对 operation_id 建立唯一约束;若已存在,比较请求摘要。
- 摘要一致时返回原操作的当前结果;摘要不一致时返回参数冲突。
- 若操作仍处于处理中,返回处理中状态,而不是重新执行。
- 若结果未知,优先查询事实库、出站消息和下游确认,再决定是否补偿。
这套规则的前提是系统能持久化操作记录,并且操作记录的生命周期覆盖最慢的客户端重试窗口。它不能阻止恶意用户无限制造新的 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)
这里的连续版本判断适用于要求严格顺序的单实体流;如果采用分区内乱序可接受的模型,则应改用集合去重、版本向量或可交换操作,并重新证明不变量。不能因为某种事件总线“通常按顺序投递”,就把顺序当作永久合同。
对账不是每天把两个数字相减,而是先确定比较的语义。以下三种差异需要分别处理:
- 数量差异:事实库中可售为 8,缓存投影为 10。
- 范围差异:事实库的渠道范围已经下线,缓存仍然对该渠道开放。
- 时间差异:事实库已经确认释放,投影尚未收到释放事件。
对账任务应按照版本和事件时间排序,记录差异原因,区分可自动修复与必须人工确认。自动修复优先采用“从权威事实重算目标投影”,而不是对缓存执行 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],在本章中具体体现为保留释放与对账能力、削减新的不可逆承诺。
恢复顺序建议为:
- 保护现场:停止高风险新 Reserve,保留审计和状态查询。
- 确认事实:检查数据库提交、操作表、预占表和 Outbox。
- 修复传输:恢复消费者、补齐 watermark 和积压。
- 重建投影:按 epoch 从权威事实重新生成缓存与搜索数据。
- 重新开放:先开放查询,再开放 Check,最后逐步开放 Reserve。
- 复盘治理:记录触发阈值、误判、人工动作和重新评估条件。
这个顺序的关键是“先减少新承诺,再恢复派生能力”。如果先扩容缓存而不处理事实差异,系统可能以更高吞吐产生更多错误投影。
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 writes | aggregate_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 评审清单
进行库存系统设计评审时,可以逐项回答以下问题:
- 是否定义了库存系统明确负责和明确不负责的边界?
- 是否区分 Check、Reserve、Confirm、Release 的不同语义?
- 是否说明了 available、reserved、committed、pending 的不变量?
- 是否定义了每种库存类型的权威事实来源?
- 是否把数量库存、券码库存、供应商库存和无限库存拆成策略,而非复制四套服务?
- 是否为每个写操作设计了 operation_id 和请求参数摘要?
- 是否能在超时后查询结果,而不是盲目重试?
- 是否把投影版本、epoch 和 watermark 作为可治理状态?
- 是否有从事实重建缓存的流程,并能隔离旧事件?
- 是否定义了预占过期、清理、释放和 Confirm 竞争时的裁决规则?
- 是否避免在持有数据库锁时调用外部服务?
- 是否为重试设置退避、抖动、次数和总预算?
- 是否区分业务拒绝、可恢复错误、未知结果和程序错误?
- 是否保留 Outbox、死信、补偿和人工接管路径?
- 是否能从 trace_id、operation_id 追踪到事实版本和投影版本?
- 是否有超卖、漂移、积压、未知结果和恢复时间的告警?
- 是否说明了过载时保留什么、削减什么、冻结什么?
- 是否明确接受了哪些最终一致性、成本和运维复杂度?
- 是否有演练:缓存清空、消息重复、数据库切换、供应商超时、消费者积压?
- 是否设置了重新评估条件,而不是把一次性选型写成永久真理?
12.9.4 本章小结:八条可迁移判断
第一,库存不是一个数字,而是一组带有范围、状态、时间和责任主体的业务承诺。只要请求可能改变用户能否购买、兑换或履约,就应当把它纳入库存语义。
第二,先定义事实和不变量,再选择数据库、缓存和消息系统。组件只能提供局部能力,不能替代业务边界。
第三,Check 可以接受陈旧,Reserve 不能无条件相信陈旧;展示和承诺需要不同的 SLO 与一致性语义。
第四,幂等键解决重复意图,版本条件和原子操作解决并发冲突;两者都需要事实库或可审计操作记录兜底。
第五,缓存、搜索和统计是投影。投影必须可带版本、可对账、可重建,并能够隔离迟到和旧 epoch 事件。
第六,超时的正确答案通常是查询和收敛,而不是立即重试。重试必须有退避、抖动、预算和错误分类。
第七,补偿是新的业务动作,不是时光倒流。补偿可能失败,必须有状态、重试、死信、告警和人工接管。
第八,一个可上线的库存系统不仅要说明正常链路如何成功,还要说明在事实不确定、下游过载、投影漂移、供应商迟到和消息重复时,系统如何停止扩大损失并最终恢复。
12.9.5 参考资料的使用边界
本章的参考资料按“理论锚点、原始研究、官方工程文档、中文工程资料”分层使用。Sagas、分布式事务、事务处理和会话保证用于解释跨边界提交、补偿、恢复和读取语义;数据密集型系统与分布式系统模式用于连接日志、投影、幂等消费者、Outbox 和重试;MySQL、Redis、Kafka、Stripe、OpenTelemetry 与云厂商资料只用于说明它们各自公开的局部行为。来源支持的是相邻事实,不代表来源替本章的全部库存方案背书。
英文资料更适合提供原始论文、经典教材和产品官方语义。中文资料补充了国内工程语境中的超卖复盘、命令边界、幂等和大型网站架构经验。二者不能简单相互替换:论文可能讨论抽象协议,官方文档可能只覆盖某个版本,行业文章可能带有特定业务前提。正文已经尽量使用“来源直接说明什么”和“本章据此推导什么”两种句式区分它们。
引用不应被理解为把外部文章复制进书稿。具体系统的版本、部署拓扑、热度分布、合规要求和供应商合同都可能不同;读者在落地前应重新核验官方文档、团队 SLO 和实际压测结果。特别是 Redis 脚本原子性、Kafka 投递语义、数据库隔离级别和云服务限流都存在边界,不能只凭组件名称推导全局一致性。
对于本章的抽象示例,变量、数量和状态名是为了表达方法,不能被当成某个平台的事实数据。任何迁移到真实业务的方案都应完成脱敏审查、容量压测、故障演练、权限审查和数据保留评估。涉及内部系统、客户、业务名称或未公开指标时,应只保留经过抽象的领域关系,不把内部专有名词当成通用案例。
12.9.6 上线、迁移与回滚
库存系统的上线不能只发布服务代码。若已有系统把库存数字散落在订单、商品、营销和供应商适配器中,迁移首先要建立事实盘点:哪些字段是可售基线,哪些是展示缓存,哪些是预占,哪些已被订单确认;每个数字都要记录来源、时间和版本。无法确认来源的数量不能直接导入新系统成为可售,应进入 UNKNOWN 或人工盘点。
推荐采用双写校验而不是直接切流:
- 建立只读影子模型,消费旧系统事件并比较数量、状态和订单归属。
- 对新旧系统执行离线对账,修复差异并确认差异类别。
- 在低风险品类开启新系统 Reserve,但由旧系统继续提供展示,观察超卖和释放指标。
- 扩大到热点、供应商和券码品类,每一步都保留冻结开关。
- 完成读路径切换后,再切写路径;旧系统进入只读和历史查询。
回滚也要定义边界。若新系统已经向外部供应商或支付系统提交不可逆操作,不能简单把流量切回旧系统就宣称回滚完成;需要冻结新操作、查询未决状态、完成 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、规则引擎或消息队列,而是回答四个问题:
- 什么是商品事实:商品中心或供应商报价系统提供了什么可售对象、基础价、币种、有效期和销售约束?
- 什么是权益事实:用户是否拥有某张券、是否满足满减门槛、是否仍有积分、平台是否愿意承担补贴?
- 什么是价格事实:在某个场景、某个时间点、使用某个规则版本计算出的每一行金额和订单应付金额是什么?
- 什么是交易事实:订单创建后,哪些输入被锁定,哪些权益已经冻结,哪些变化必须重新确认?
如果这四类事实没有分开,系统会出现三种典型混乱。第一,营销服务把“优惠 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 = 基础价适配器输出的单行价格 × 数量
PromotionTotal = 按活动规则对 LineBase 计算的活动价或活动减免
DeductionTotal = 券、积分、红包等在合法范围内的抵扣
ChargeTotal = 运费、服务费、渠道费、税费等附加费用
Payable = LineBase - PromotionTotal - DeductionTotal + ChargeTotal
这里的 PromotionTotal 和 DeductionTotal 不是允许负数随便叠加的字段,而是组件集合的聚合结果。对每一个组件,都需要回答四个问题:计算前的基数是什么;组件是否与其他组件互斥;该组件能否覆盖费用;平台、商家和渠道分别承担多少。某些业务要把营销价格写成“活动后的新单价”,另一些业务要把它写成“对基础价的减量”,二者都可以,但必须在统一模型里选择一种语义。本章采用“组件保存增量、层负责计算顺序”的方式,因为增量更适合审计、分摊和逆向退款。
价格层的顺序是业务契约,不是实现细节。比如“满 300 减 50”应先按适用商品集合求门槛,再判断是否达到 300;平台券可能只能作用于活动后的小计;积分抵扣可能受支付金额上限约束;税费可能不能被折扣覆盖。促销研究也表明,门槛型和封顶型优惠即使最大经济收益相同,消费者对公平性和转化的感知仍可能不同,因此优惠规则既要表达经济约束,也要表达对用户展示的条件。[18]
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 存下来”,而是让未来能够重建“当时看到了什么”。因此应保存输入哈希、规则哈希、供应商报价引用、用户资格分群版本、舍入过程、组件出资方和计算引擎版本。订单创建后,新的活动可以影响下一笔订单,但不能悄悄改写已支付订单的历史快照。
本章采用以下不变量:
- 任意行的最终价不得小于零,订单应付金额不得小于零;
- 行级应付金额之和等于订单应付金额,分摊尾差必须有唯一归属行;
- 任何优惠组件都不能超过其适用基数和剩余预算;
FROZEN权益只能由创建它的业务单号确认或释放;- 同一个幂等键和相同请求指纹必须返回相同业务结果;同一个幂等键不能接受不同请求体;
- 价格快照只追加或生成新版本,不直接覆盖历史快照;
- 事件重复、回调重复和补偿重试不能使余额、库存、预算或核销次数重复变化。
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 组,并明确展示“已按当前规则选择最优组合”。不能为了追求理论最优而让结算页在所有活动上做指数级搜索。
规则顺序要固定并输出:
- 确认行和店铺边界,排除不适用商品;
- 应用只能改变单价的活动价或阶梯价;
- 计算店铺券、跨店券和平台券的合法组合;
- 应用积分、红包等支付抵扣;
- 计算可打折费用和不可打折费用;
- 进行金额下限、预算、出资方和尾差检查。
门槛优惠的“门槛基数”必须写在规则中:是原价、活动后小计、扣券前小计,还是含运费金额。否则运营说“满 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_template | template_id、rule_version、total_quota、validity | 描述可发行的券和规则 |
coupon_user | coupon_id、user_id、status、expire_at | 用户持有的具体权益 |
coupon_reservation | reservation_id、order_id、coupon_id、expires_at | 交易期间的冻结 |
coupon_ledger | event_id、action、before_status、after_status | 不可变状态流水 |
coupon_outbox | event_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 新品类接入四步法
接入一个新品类时,先不要复制一份价格服务。按四步检查:
- 定义基础事实:价格来自固定表、日历、供应商还是实时算法;是否需要报价 token;库存和价格是否同一来源。
- 选择通用层:该品类能否复用基础价、营销、抵扣、费用和快照层;哪些层要跳过,必须写入场景矩阵。
- 实现专用适配器:只在基础价、供应商预订、费用或退款语义确实不同处增加 Calculator/Adapter,不在调用方写品类 if-else。
- 验证完整链路:详情页、购物车、创单、支付、取消、部分退款、对账和故障演练全部走通。
新品类验收表应包含基础价格来源、币种精度、费用配置、营销兼容性、快照字段、供应商有效期、库存预占、退款规则、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。计价中心并行读取商品/供应商报价、用户资格、营销候选和费用配置;营销服务只在用户点击“提交订单”后执行权益冻结。推荐顺序如下:
- 校验请求指纹、用户身份、购物车版本和商品可售状态;
- 获取各行基础报价并检查报价有效期;
- 评估营销候选,过滤资格不满足和预算不足的权益;
- 选择合法优惠组合,生成组件和初步分摊;
- 冻结券、积分、营销预算和必要的商品库存;
- 在冻结结果和价格组件都成功后写入价格快照;
- 创建待支付订单,订单保存快照 ID 和所有冻结资源 ID;
- 生成支付单,支付单金额来自快照而非客户端参数。
这里存在一个重要的竞态:如果先冻结营销权益,后写价格快照失败,必须有补偿释放;如果先写快照,后冻结失败,快照只能标记为不可用,不能被订单复用。因此订单编排器需要把每一步的状态写入流程表,使用幂等键重复推进,而不是在超时后盲目从第一步重新执行。
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 对账是系统的一部分,不是运营补丁
最终一致系统必须有独立对账,否则“理论上会补偿”无法转化为“实际已经收敛”。对账不应只比较总数,而要比较维度:订单、订单行、组件、券、积分账户、预算桶、店铺、出资方、币种、事件和时间窗口。
推荐的对账任务包括:
- 订单—快照对账:订单金额、币种、组件总额与快照一致;
- 快照—营销对账:快照引用的券/积分/预算是否存在对应冻结和确认流水;
- 营销—预算对账:用户权益流水累计值与平台、商家预算桶相符;
- 订单—支付对账:支付单、渠道回执、订单状态和退款状态一致;
- 事件—消费对账:Outbox 已发布事件是否有消费者处理结果和重试记录;
- 分摊—结算对账:平台、商家、渠道的优惠承担与清算单一致。
差异处理要分级:可自动修复的悬挂冻结、重复事件和缓存计数走补偿;金额或出资方不一致先冻结结算,再生成差异单;历史订单不直接 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 缓存、多级读取与失效
缓存适合保存稳定、可重建、允许短暂过期的事实:活动元数据、规则索引、商品基础价、匿名展示价和用户资格视图。缓存不适合成为支付金额、券核销和预算账本的唯一事实来源。
可以使用三层读取:
- L1 本地缓存:小而热的规则元数据,TTL 短,发布版本变化时主动失效;
- L2 Redis:活动索引、用户资格、热点展示价和营销配额预扣;
- 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 故障、数据库死锁、供应商报价过期;
- 生产演练:预算误配置、规则回滚、补偿积压、对账差异、支付未知和人工接管。
发布前至少逐项确认:
- 每个交易场景的价格层是否由产品和财务共同签字;
- 快照是否包含输入指纹、组件、分摊、规则和报价有效期;
- 所有有副作用接口是否绑定稳定幂等键;
- 券、积分、预算和库存的冻结/确认/释放是否可重试;
- 新旧引擎空跑是否比较组件而不仅是总额;
- 预算、平台/商家出资和结算分摊是否能对账;
- 降级是否明确“不允许猜金额”的边界;
snapshot_id能否被客服、财务和研发共同查询;- 规则发布是否有仿真、审批、灰度、回滚和审计;
- 参考资料是否全部被正文引用且正文每个关键判断都有相邻引用。
13.8.8 部署拓扑与故障域
展示计价、交易计价、营销写路径和补偿任务应拥有不同的扩缩容和故障域。展示服务可以部署更多无状态实例并依赖缓存;交易计价需要与快照库保持稳定连接;营销预占需要保护关系库和 Redis 的写入;补偿和对账使用独立消费者组,避免积压时抢占交易线程池。它们可以共享代码库,但不应共享一个无法区分优先级的连接池。
多可用区部署时,活动配置和规则版本应通过可靠发布传播;热点配额的权威分片要明确所在故障域和故障转移语义。跨地域场景不能简单把同一个预算复制到两个地域再相加,否则网络分区期间可能超预算。可以选择单地域权威写入、按地域预分配预算、或接受最终对账后冲正;选择哪一种取决于预算风险和可用性目标。
部署检查还包括:数据库连接池上限、Redis 脚本最长执行时间、消息生产/消费延迟、规则缓存版本、下游超时总预算、熔断恢复时间、快照库容量、审计存储成本和备份恢复时间。大促前应做容量预热和故障注入,验证缓存预热失败时是否会把冷启动流量直接打到数据库。
13.8.9 成本与用户体验的共同优化
让所有请求都执行完整规则并不一定提高转化率。列表页只需要价格范围和公开活动标签;PDP 需要当前用户可见的最优少量权益;购物车需要用户主动选择后的精确试算;创单才需要完整冻结和分摊。按场景裁剪计算深度,可以减少下游调用和缓存空间,也能让用户更快看到可行动的结果。
营销预算不能只看折扣金额,还要把计算、缓存、消息、人工客服和退款成本纳入活动评估。某个活动的转化提高,但补偿量、支付拒绝和客服解释成本同步上升,未必是成功。活动看板应同时呈现命中率、领取率、冻结率、核销率、订单增量、退款率、平台/商家出资、异常率和每个成功订单的技术成本。
13.8.10 未来演进路线
演进顺序应由确定性和治理能力驱动,而不是直接引入 AI 定价:
- 统一事实:先把价格组件、权益流水、快照和对账做完整;
- 统一计算:将重复的优惠和费用逻辑收敛到可测试的层;
- 安全迁移:通过空跑、灰度和差异监控替换旧逻辑;
- 数据闭环:沉淀规则命中、价格变化、退款和用户反馈;
- 策略实验:在明确的预算、最低价、公平和审计约束内做 A/B 测试;
- 模型辅助:让预测模型提出价格或优惠建议,由确定性规则、安全检查和人工审批控制落地。
实时个性化、自动调价和跨境汇率都可以接入基础价或营销决策层,但不能绕过快照、最低价、出资方、用户提示和回滚。越是自动化的决策,越需要保存输入特征版本、模型版本、决策原因和重放能力。
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 可迁移的判断句
本章可以浓缩为以下判断句:
- 先定义事实,再划分服务:商品价、营销权益、价格快照和交易状态不能靠字段名称混为一谈。
- 营销决定资格,计价决定金额:营销输出可使用的权益及其限制,计价输出可审计的价格组件和应付金额。
- 展示可以近似,创单必须确定:页面报价可以短暂过期,订单和支付必须引用同一份受保护的快照。
- 有限权益就是库存:券、积分、预算和活动配额都需要冻结、确认、释放、过期和对账。
- 重试必须建立在幂等契约上:没有稳定幂等键和未知状态查询,重试只是在放大副作用。
- 最终一致性必须可观测、可补偿、可对账:Outbox、Saga 和事件总线不是一致性的终点。
- 组件比总额更接近真实业务:没有组件、分摊和出资方,优惠、退款和财务解释都只能靠猜。
- 降级的边界由业务风险决定:可以隐藏推荐标签,不能凭空生成基础价、支付金额或预算余额。
- 每个关键方案都要写获得与牺牲:统一引擎获得复用和一致性,也引入版本协作、快照存储和补偿复杂度。
- 把参考源放到论证旁边:引用不是文末装饰,而是让读者知道哪些是原理、哪些是官方语义、哪些是本章的场景推导。
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 章 电商用户 C 端搜索、交易、履约与售后全生命周期设计
本章定位:如果第 11 章回答的是“平台怎样把商品供给进来、治理好、发布出去”,这一章回答的就是“用户怎样从发现商品一路走到支付、履约、核销与售后”。它不是对搜索、购物车、订单、支付四章的简单拼接,而是站在 C 端交易旅程 视角,把弱一致读链路、强一致交易链路、履约后链路以及解释历史事实的快照体系收敛成一条完整主线。
如果前面的专题章节分别回答的是“某个系统内部怎么设计”,这一章回答的则是:
- 用户在 C 端到底会经历哪些关键交易阶段。
- 为什么搜索、详情、购物车、结算、订单、支付、履约、售后不能揉成一个系统。
- 为什么搜索和详情可以弱一致,而下单和支付必须强校验。
- 为什么订单之后不应该再依赖“最新商品真相”,而要依赖快照和履约事实。
- 为什么真正难的不是画一条交易链路,而是跨商品、库存、营销、计价、订单、支付、履约之间的一致性和资损防控。
建议配合以下章节交叉阅读:
- 本章已融合原搜索与导购章节的查询编排、索引投影、Elasticsearch、Hydrate、缓存降级和实验治理内容,统一沿用第 14 章编号。
- 本章已合并原购物车与结算、订单系统的交易链路内容,统一沿用第 14 章编号。
- 本章同时融合原支付系统章节的支付创建、渠道路由、状态机、退款、清结算、对账和渠道集成内容,统一沿用第 14 章编号。
- 第 11 章 商品中心、商品供给与生命周期治理
- 第 12 章 库存系统
- 第 13 章 营销与计价系统
14.1 核心场景、目标与边界
很多团队讲 C 端架构时,会按系统模块切开:搜索一章、购物车一章、订单一章、支付一章。这样当然清楚,但读者很容易失去真正的主线,因为用户不会感知自己“正在使用哪个中台系统”,他只会感知:
我能不能找到商品、看懂规则、拿到正确价格、顺利下单、正常支付、按承诺履约,以及出问题后能不能退款。
14.1.1 搜索与导购找商品
用户进入平台后的第一步,通常不是直接下单,而是先找到“值得交易的候选集”。
| 场景 | 用户动作 | 典型特征 | 系统重点 |
|---|---|---|---|
| 关键词搜索 | 搜品牌、搜型号、搜服务词 | 强相关性、结果多 | Query 理解、召回、排序、Hydrate |
| 类目导购 | 逛频道、逛类目、逛店铺 | 弱文本、强筛选 | 类目树、Facet、稳定排序 |
| 活动导购 | 进会场、看榜单、看运营专区 | 强陈列、强运营控制 | 搜索索引 + 运营露出 + 营销标 |
这一阶段要回答几个问题:
- 搜索结果页为什么可以弱一致。
- 为什么列表页不直接读商品中心主库。
- 为什么用户看到的“列表价”和最终下单价不一定完全相同。
14.1.2 详情页理解商品
详情页是用户第一次把“可检索商品”理解为“可交易契约”的地方。
| 用户关注点 | 背后依赖的域 |
|---|---|
| 商品标题、主图、卖点、规格 | 商品中心 |
| 当前价格、到手价、阶梯价、实时价 | 计价中心 |
| 库存是否充足、是否限购 | 库存中心 |
| 活动、优惠券、赠品、满减露出 | 营销中心 |
| 发货时效、门店核销、预约规则、退款规则 | 商品中心 / 履约规则域 |
这意味着详情页本质上不是“查一张表”,而是一个 多域聚合页。它天然带来两个工程问题:
- 详情页为什么不能直接等于下单事实。
- 商品、价格、库存、营销版本不一致时,到底以谁为准。
14.1.3 加购与购物车暂存
购物车表达的是“用户意愿”,而不是“交易事实”。
| 场景 | 典型动作 | 关键差异 |
|---|---|---|
| 未登录加购 | 用 cart_token 暂存 | 允许匿名、弱一致、可过期 |
| 登录后加购 | 绑定用户购物车 | 支持跨端同步 |
| 登录合并 | 匿名购物车合并到用户态 | 要处理数量合并、失效商品、限购截断 |
这里最重要的判断是:
- 购物车里为什么不锁库存。
- 为什么购物车允许展示价滞后,但结算必须实时试算。
14.1.4 结算、校验与提交订单
结算页是整条 C 端链路第一次真正进入 强一致交易编排 的地方。
| 结算阶段动作 | 关键系统 |
|---|---|
| 价格试算 | 计价系统 |
| 库存预占 | 库存系统 |
| 优惠校验 / 占用 | 营销系统 |
| 地址与运费计算 | 地址 / 运费系统 |
| 商品静态合规校验 | 商品中心 |
这一步要回答:
- 为什么购物车不锁库存,结算才预占库存。
- 为什么订单提交前要再次做版本校验。
- 为什么结算页的本质是一个短生命周期 Saga,而不是简单表单确认。
14.1.5 下单、支付、履约与售后
用户点击“提交订单”之后,系统就从“交易前”进入“交易中与交易后”。
| 阶段 | 用户看到的动作 | 系统主导域 |
|---|---|---|
| 下单 | 生成订单、等待支付 | 订单中心 |
| 支付 | 调起收银台、支付结果回流 | 支付中心 |
| 履约 | 发货、发码、预约、核销 | 履约 / 券码 / 供应商域 |
| 售后 | 退款、退货、取消、争议 | 订单 / 支付 / 履约协同 |
这阶段的核心问题包括:
- 订单为什么必须保存商品、价格、履约快照。
- 支付为什么不能直接决定商品状态和库存真相。
- 为什么售后必须基于订单事实,而不是去查最新商品。
14.1.6 场景到系统问题的映射
| 场景类型 | 典型问题 |
|---|---|
| 搜索导购 | 弱一致索引、动态 Hydrate、排序与活动露出 |
| 商品详情 | 多域聚合、库存与价格展示口径、规则解释 |
| 购物车 | 匿名暂存、登录合并、失效商品治理 |
| 结算 | 价格试算、库存预占、优惠校验、提交前最终校验 |
| 下单支付 | 幂等、快照、支付回调、状态机推进 |
| 履约售后 | 发货 / 发码 / 核销、退款回补、历史订单解释 |
14.2 交易域模型、职责边界与一致性语义
这一节按照“先看系统边界,再看主链路,最后看关键决策”的顺序展开。先把商品、库存、计价、营销、订单、支付和履约各自的职责划清楚,再把这些系统放进一条 C 端完整交易主链路中理解,最后集中讨论搜索弱一致、详情聚合、结算预占、订单快照和售后事实这些关键设计选择。
14.2.1 系统边界和职责
| 系统 | 负责什么 | 不负责什么 |
|---|---|---|
| 商品中心 | 正式商品契约、履约规则、退款规则、商品快照 | 购物车暂存、库存事实、支付状态 |
| 搜索与导购 | 商品召回、排序、导购投影 | 商品正式真相、订单事实 |
| 购物车与结算 | 用户意愿暂存、结算编排、试算与预占协同 | 正式创单、支付状态机 |
| 计价系统 | 实时报价、到手价解释、价格快照 | 订单状态推进、库存扣减 |
| 库存系统 | 可售库存事实、预占、确认、释放、消费 | 购物车、订单金额、营销规则 |
| 营销系统 | 优惠规则、券可用性、权益占用与释放 | 商品正式态、库存总账 |
| 订单系统 | 订单事实、订单状态机、交易快照 | 渠道支付、库存脚本实现 |
| 支付系统 | 支付单、渠道交互、支付事实 | 订单商品解释、商品真相 |
| 履约 / 核销 / 售后 | 发货、发码、核销、退款闭环 | 商品供给、搜索索引 |
最重要的三条原则是:
- 搜索和详情服务用户发现,订单和支付服务用户承诺,履约和售后服务用户交付。
- 商品中心负责交易前契约,订单中心负责交易事实,支付中心负责资金事实。
- 订单之后,任何解释都优先基于快照和履约事实,而不是基于最新主数据。
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 / 事件广播"]
这条主链路表达的是:
- 搜索、列表和详情是交易前的信息发现链路,允许适度弱一致。
- 结算、下单、支付是交易承诺链路,必须逐步收紧校验口径。
- 履约和售后是交易后事实链路,必须围绕订单快照和支付结果闭环。
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 关于过载和尾延迟的实践指出,系统应明确哪些请求可以降级,哪些请求必须被拒绝或延后,否则低价值读流量会挤压高价值写入。[6][11] 因此在本章中,搜索可以返回“价格待刷新”,结算可以返回“优惠已失效”,订单提交则宁可快速失败,也不接受未经验证的旧凭证。
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][13][25] 在本章的实现里,所有事实事件都携带 event_id、aggregate_id、event_type、occurred_at、schema_version 和 trace_id。
14.3 搜索导购、详情与交易前读链路
14.3.1 场景画像
从用户进入平台到决定”我要不要买”,通常依次经过两种读路径:
- 搜索 / 导购结果页:看的是候选集。
- 商品详情页:看的是交易前解释。
这两条链路看起来都只是”读”,但它们的职责截然不同:
- 搜索结果页服务于召回和转化,强调高吞吐和可排序。
- 详情页服务于交易前理解,强调解释性和当前性。
从系统角度看,搜索、导购、详情之间不能揉成一个系统,也不能简单地串成一条长链路,而应该按各自的职责边界和一致性要求独立设计,再通过编排层聚合。
14.3.2 关键技术点
这一节先前置所有高价值架构结论,后续场景化时序图再展开具体调用关系。
为什么搜索、导购、详情不能揉成一个系统
很多团队早期会把”商品搜索、类目导购、详情查询”统一塞进一个搜索服务里,代码复用率高,看起来也方便。但这会带来三个致命问题:
- 职责塌陷:搜索负责高吞吐召回和排序,导购负责多维度筛选和聚合卡片展示,详情负责多域事实聚合。三者面对的下游依赖链、缓存策略、降级粒度完全不同,揉在一起只会让每个场景都背上不该背的依赖。
- 一致性口子撕裂开:搜索页天然允许弱一致,详情页需要近实时一致。如果把两个场景放在同一个服务里,开发人员很难在代码层面区分”这个字段可以滞后 5 分钟”和”这个字段必须实时校验”。
- 大促放大故障面:搜索页 QPS 远高于详情页。一旦合并在同一个服务里,高并发搜索流量会直接把详情页的计价、库存、营销下游一起拖垮。
因此推荐结论很明确:
- 搜索服务解决召回、筛选、排序。
- 导购服务解决聚合卡片展示。
- 详情聚合服务解决多域事实聚合。
哪些字段来自 ES,哪些字段来自商品中心
这条链路里最关键的设计点,不是”多调了几个下游”,而是:搜索结果页到底哪些数据应该直接来自搜索中心,哪些数据应该回商品中心 / 计价 / 库存实时补齐。
决策原则只有三条:
- 是否参与召回、过滤、排序:参与这些能力的字段,优先放到 ES。
- 变动频次高不高:高频变化字段不要把绝对真相压进 ES。
- 对准确性要求高不高:一旦字段直接影响成交,就应该让实时权威域说了算。
因此,搜索结果页通常采用”ES 出骨架,商品中心与交易相关域补血肉”的模式:
| 字段类型 | 主要来源 | 为什么这么设计 |
|---|---|---|
| 标题、副标题、主图、品牌、类目、属性标签 | 搜索中心 / ES | 这些字段参与检索、过滤、聚合或高频展示,适合做索引骨架。 |
| 上下架状态、可搜状态 | 搜索中心 / ES 过滤 | 必须在搜索阶段先过滤掉不可售对象,避免返回无意义结果。 |
| 销量、评分、热度等排序因子 | 搜索中心 / ES | 直接用于粗排或综合排序,允许弱一致。 |
| 实时到手价、会员价、促销价 | 计价中心 / 商品中心 Hydrate | 高敏感、高频变化,列表允许短暂滞后,但展示时最好用实时结果覆盖。 |
| 绝对库存数、紧张库存文案 | 库存中心 Hydrate | 属于高频高并发变化字段,不能让 ES 承担绝对数写入。 |
| 活动标签、圈品命中、促销露出 | 营销中心 Hydrate | 露出逻辑变化快,且失败时可以局部降级。 |
| 冷门说明类字段 | 商品中心 | 不参与搜索与排序,没必要进 ES。 |
一个很实用的判断方法是:
- 只要字段决定”搜不搜得到、排在第几位、能不能按它筛选”,就应该优先放进 ES。
- 只要字段决定”现在到底多少钱、到底还有没有货、这个活动此刻还能不能领”,就应该优先让商品中心、计价或库存域返回实时结果。
搜索结果页为什么只认 ES 骨架,不直接等于交易事实
搜索结果页的目标是高吞吐召回、排序和转化,不承担交易真相主权。它展示的是”当前最有可能被用户点击的候选集”,不是”此刻可以直接下单成交的最终合同”。
因此,搜索页允许:
- 索引异步刷新
- 列表字段局部滞后
- 某些弱展示字段降级缺失
但它不能越界去承担:
- 实时价格真相
- 实时库存真相
- 权益资格最终裁决
这也意味着:下单前必须重新校验价格、库存、权益。搜索结果页拿到的是”可浏览卡片”,不是”可下单事实”。
搜索结果页的 Hydrate 编排为什么不是纯串行,也不是无脑全并行
Hydrate 层最容易被低估的一个问题是:下游依赖到底应该串行调用,还是并行调用。
如果完全按照”商品中心 -> 库存中心 -> 营销中心 -> 计价中心”的直觉串行走,业务理解上很顺,但性能上很危险。因为只要每个 RPC 平均耗时 20ms,四段串行下来就已经接近 80ms;任意一个下游轻微抖动,整条列表页链路就会很容易冲到 150ms 以上。
更稳妥的工程实践通常不是”全串行”,也不是”无脑全并行”,而是带依赖关系的半并行编排:
- 第一阶段并行
- 商品中心:拿商品卡片骨架、类目、品牌、商家等基础信息
- 库存中心:拿库存摘要、是否有货、紧张状态
- 第二阶段
- 营销中心:基于商品基础信息和用户上下文,先判断活动命中、优惠资格、券与满减露出
- 第三阶段
- 计价中心:在拿到营销结果之后,再基于商品基础信息、营销结果和用户上下文计算展示价、会员价、到手价
这样做的原因是:
- 库存中心通常只依赖 item_id / sku_id,并不依赖商品标题、品牌、类目这些骨架字段;
- 商品中心也不依赖价格和库存,它本身就是卡片骨架来源;
- 营销中心 往往依赖商品类目、商家、售卖属性来判断露出与资格;
- 计价中心 在很多实现里并不是独立”拍脑袋算价”,而是要把营销命中的结果、优惠叠加关系、会员折扣、活动门槛一起折进最终展示价,所以它天然位于营销之后。
这种编排的总耗时,更接近:
max(商品中心, 库存中心) + 营销中心 + 计价中心
而不是四段简单相加。所以它通常仍然明显优于”商品 -> 库存 -> 营销 -> 计价”的纯串行模式,同时又不会像”盲目全并行”那样把前置依赖关系搞乱。
如果业务继续追求更低的列表页延迟,还可以进一步演进到近似全并行:在计价中心和营销中心内部也冗余一份极简的商品基础信息,例如:
item_id -> 类目item_id -> 商家item_id -> 基础售卖属性
这样一来,Hydrate 层在收到 ES 返回的 item_id 列表后,就可以:
- 同时查商品中心
- 同时查库存中心
- 同时查营销中心
- 等营销结果返回后,再调计价中心
因为营销不再必须等待商品中心先返回类目信息,而计价至少不需要再回头额外查一次商品中心。这本质上是:
用数据冗余换实时编排延迟。
但要注意,这种”绝对并行”只适合在两个前提下使用:
- 营销和计价中心内部维护的商品轻量副本足够新鲜;
- 你们愿意承担多一层数据同步和一致性治理的复杂度。
所以默认推荐结论是:
Hydrate 层优先采用”商品 + 库存第一阶段并行,营销第二阶段,计价第三阶段”的半并行架构;只有在对延迟极度敏感、且下游已经具备足够商品副本和营销结果缓存时,才继续压缩计价前置依赖。
详情页为什么必须做动静分离
商品详情页(PDP)是交易漏斗里最核心的读页面之一。它的特点是:
- 流量极大
- 读多写少
- 高可用要求极苛刻
- 用户对页面解释能力和加载速度都极其敏感
因此,详情页不能做成”每次请求实时查所有下游”的重聚合链路,而要先做动静分离。
从工程上看,详情页的数据通常可以拆成两类:
- 静态数据:
- 标题、副标题
- 主图、详情图
- 商品参数说明
- 低频变化的文案和结构化描述
- 动态数据:
- 当前到手价
- 库存摘要
- 当前用户可见的权益和促销露出
- 配送时效、门店核销、预约规则、评价摘要
更稳妥的详情页架构通常是:
- 静态骨架优先
- 商品静态内容通过静态 JSON、静态片段或 CDN 化页面骨架快速返回。
- 用户先看到稳定的页面结构、标题和主图,而不是等待所有后端系统都准备完毕。
- 动态数据异步 Hydrate
- 前端或详情聚合层再去批量拿价格、库存、营销和履约数据。
- 动态内容可以稍晚几十毫秒填充,但不能把首屏完全卡死。
为了让这条链路在大促和高并发下仍然稳定,通常要配合多级缓存:
- CDN / 静态内容缓存
- 兜底标题、主图、详情骨架
- 聚合层本地缓存(如 Caffeine)
- 缓住极短时间内的重复请求和热点详情
- 分布式缓存(如 Redis)
- 缓住基础价、库存摘要、详情聚合片段或短期动态结果
这样做的目标不是追求”所有详情字段都绝对实时”,而是:
先保证详情页稳定打开,再保证动态关键信息足够新鲜,最后在创单前做最终强校验。
详情页里的实时计价、库存摘要与大促降级
详情页里最敏感的两个问题,永远是:
- 现在多少钱
- 现在还有没有货
这两个字段之所以难,是因为它们都不适合做成简单的全量缓存。
对于价格来说,真正的到手价往往同时受以下因素影响:
- 会员等级
- 商品基础价
- 店铺活动
- 平台券 / 品类券
- 用户专属权益
因此更常见的做法不是缓存”所有用户的最终价”,而是:
- 缓存基础价 / 会员价矩阵 / 基础促销价
- 在详情请求进来时,再结合用户上下文和营销命中做轻量实时计算
对于库存来说,详情页一般不追求展示精确库存数字,而是展示:
- 有货
- 无货
- 紧张库存
- 仅剩少量
这样可以显著降低库存读压力,同时也避免把高频变化的绝对库存数暴露到前台。
但真正的大问题出现在大促、热点商品和下游抖动场景下。此时详情页必须具备明确的降级能力:
- 计价服务超时
- 优先展示基础销售价或指导价
- 库存摘要超时
- 退化为保守文案,如”库存确认中”或”请以下单时校验为准”
- 营销露出超时
- 隐藏活动标签,不阻塞主页面
- 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 的至少一次投递和幂等生产者可以降低重复风险,但不能替代索引文档版本和业务对账。[13][14]
全量重建不能直接覆盖线上索引。更稳妥的流程是创建带 mapping_version 的新索引,执行全量快照和增量追平,抽样比较文档数量、版本和关键字段,再通过原子别名切换读流量。切换后保留旧索引一段观察窗口,以便在零结果率、召回量或排序指标异常时快速回滚。别名切换的原子性只能保证读入口的一致,不能保证新索引已经包含所有最新事件,因此切换前后的版本差异仍需由对账任务检查。
Query、Recall、Rank、Hydrate 的性能边界
四个阶段的优化目标不同,不能用“整体加机器”替代边界设计:
| 阶段 | 主要工作 | 关键指标 | 典型失败处理 |
|---|---|---|---|
| Query | 分词、纠错、同义词、意图和过滤归一 | 改写命中率、非法条件率、耗时 | 使用安全的原词或返回可解释空结果 |
| Recall | 倒排、结构化过滤、运营池、多路召回 | 候选数、零结果率、召回耗时 | 关闭高成本召回,保留主索引 |
| Rank | 相关性、业务权重、实验和稳定排序 | P99、排序版本、分桶差异 | 回退到稳定排序版本 |
| Hydrate | 商品、价格、库存、营销批量补齐 | 依赖成功率、字段缺失率、尾延迟 | 局部字段降级,不阻塞整页 |
Elasticsearch 的查询应尽量把过滤条件放在 filter 语义中,把不需要参与评分的限制从相关性计算中分离;排序字段需要评估 doc_values、内存和写入代价。from/size 深分页会随着页码增加而扩大协调节点和分片的工作量,面向用户的连续翻页应优先采用带稳定排序键的 search_after,并限制游标有效期。search_after 不是任意跳页方案,产品必须明确是否支持跳到第 N 页、结果快照是否需要稳定,以及索引切换后游标如何失效。[18]
排序必须有稳定的兜底键,例如业务排序值、更新时间和唯一 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 规定的传播字段可以帮助跨网关、查询编排和下游依赖关联同一条调用链,[21] 但 trace_id 只用于技术关联,不能替代业务上的 query_id、实验桶和结果版本。[22]
这一区分在故障排查时尤其重要:同一个 trace_id 可能因为重试产生多条尝试,而同一个 query_id 代表用户界面上的一次查询意图;页面刷新、自动补全和主搜索也应有不同的请求类型。若把这些标识混成一个字段,指标会把取消请求、重试请求和真正完成的请求混在一起,既无法计算搜索转化漏斗,也无法判断下游超时是用户主动离开还是系统确实变慢。
多路召回必须有预算、去重和质量闭环
单路关键词召回并不能覆盖所有商品表达,但增加类目召回、品牌召回、运营池、历史点击召回或向量召回后,复杂度不会只增加一个接口。每一路都要声明候选上限、超时预算、过滤条件、优先级、去重键和失败语义。查询编排器先为每一路分配预算,再以展示单元的稳定 ID 去重,最后将候选集交给粗排和精排;任何一路超时,都应允许主链路使用其余候选完成,而不是把所有结果判为失败。
| 召回来源 | 适合解决的问题 | 需要控制的风险 | 建议的质量信号 |
|---|---|---|---|
| 关键词倒排 | 用户明确表达的意图 | 同义词不足、拼写噪声 | 命中率、零结果率、点击位置 |
| 类目与属性 | 结构化浏览和筛选 | 条件互斥、类目挂错 | Facet 使用率、过滤后候选数 |
| 运营池 | 会场、榜单和活动陈列 | 过度插入、规则污染相关性 | 资源位点击、投诉率、回滚次数 |
| 向量或行为召回 | 语义近似和长尾表达 | 不可解释、召回漂移 | 独立覆盖率、离线相关性、延迟 |
多路召回的合并不应直接按来源顺序拼接。若运营池永远排在关键词结果前面,用户会认为搜索不相关;若向量候选数量没有上限,泛化商品会挤掉精确匹配;若历史行为召回带入了用户不再允许看到的商品,还可能绕过合规过滤。因此,所有候选在合并前都必须经过统一的可搜索、站点、商家、类目和合规过滤,去重后再按来源配额进入排序。来源配额、截断数量和过滤原因要作为 rank_version 的一部分记录,便于解释某次结果为什么变化。
质量闭环也要区分“没有召回”和“召回后没有展示”。前者可能是词典、索引或过滤问题,后者可能是排序、去重、资源位或合规过滤问题。日志至少记录每个候选的 recall_source、进入和离开各阶段的原因、最终位置以及是否被 Hydrate 丢弃。这样离线评估可以构造“召回覆盖—排序质量—展示可用性”的分层指标,而不是只用最终点击率反推所有问题。对含有个性化行为的召回,还要遵守最小化采集和脱敏原则,避免把搜索框中的手机号、地址或订单号写入长期训练数据。
Hydrate 的并发控制决定读链路是否会反噬交易链路
Hydrate 看似只是批量补字段,实际是一个跨域扇出器。假设一页有 50 个展示单元,同时需要价格、库存、营销和履约摘要,如果每个单元分别调用四个服务,就会把一次用户请求放大成数百次 RPC。批量接口只能降低网络次数,不能自动消除下游计算压力;查询服务还要按照依赖的容量、超时和业务重要性设置独立舱壁、并发上限和批次大小。
推荐把卡片字段分成三类:第一类是没有它就无法理解结果的核心字段,如标题、主图和商品标识;第二类是影响决策但可以显示“确认中”的动态字段,如参考价、库存摘要和配送时效;第三类是增强体验的可选字段,如活动标签、推荐理由和扩展卖点。核心字段失败时可以减少结果数量或切换到索引中的安全骨架,动态字段失败时要明确标注新鲜度,增强字段失败时直接省略。这个分层比对所有依赖设置同一个总超时更能保护用户语义。
Hydrate 的并发预算还应按照入口隔离。搜索主结果、详情页相似商品、购物车推荐、自动补全和运营后台不能共用一套无上限的线程池;suggest 的单字符请求量通常很高,但它对成交事实的价值低于结算前的价格和库存校验。每个入口应有最大候选数、最大批次数和最大下游等待时间,超出预算就返回部分结果。下游返回部分失败时,结果中应保留字段级状态,例如 price_status=stale、inventory_status=unknown,而不是将空值解释成“零元”或“无库存”。
价格和库存的降级尤其需要防止语义越界。索引中的基础价可以用于筛选和粗排,但不能在用户点击购买时继续沿用;库存摘要可以显示“有货”“库存紧张”或“确认中”,但不能把“确认中”渲染成已经锁定。进入详情、结算和创单时,系统必须重新读取权威计价和库存服务,按照当前用户、地址、优惠和时间窗口生成新的报价与预占。HTTP 的错误语义和问题详情格式可以帮助调用方区分参数错误、依赖不可用和请求过期,[23][24] 但最终能否下单仍由交易域的不变量决定。
排序、词典和索引发布要形成可回放的变更流水线
搜索质量的发布对象不只有代码。分词器、同义词、类目映射、过滤规则、排序权重、模型、索引 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, desc | text(IK / jieba 分词) | 搜索匹配 |
| 精确匹配 / 过滤 | spu_id, sku_id, brand_id, category_id, status | keyword | 精确过滤 |
| 数值 | price, sale_price, stock, sales, rating | integer / float / scaled_float | 范围查询、排序 |
| 数组 / 分面 | attrs(颜色、尺寸等规格属性), tags | nested / object | 多维度筛选 |
| 地理 | location | geo_point | 附近门店 / LBS 排序 |
| 权重 / 时间 | create_time, update_time, weight | date / 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
- 来自商品中心 / 报价引擎的实时信息:
- 指定入住日到离店日之间的真实可售房态
- 具体房型在该日期区间下的实时到手价、会员价、连住价
- 退改政策、确认时效、最晚保留时间
所以酒店搜索的标准模式通常是:
- ES 先负责按城市、星级、地理位置、设施等条件召回和粗排。
- 搜索服务拿着
hotel_ids + checkin + checkout + user_id去商品中心 / 计价 / 库存资源域批量查询。 - 用实时价格和真实房态覆盖掉 ES 的粗粒度字段,再返回给前端。
这也解释了为什么列表页不能直接把 ES 的价格和库存当成交易真相:
- ES 擅长做大规模检索和排序。
- 商品中心、计价、库存才是交易前最后的权威真相。
酒店列表页如何做价格排序
酒店搜索里,”按价格从低到高排序”是一个典型的工程取舍点。难点在于:ES 必须先拿到一个字段值才能完成全局排序,但酒店的真实价格又是动态计算出来的,它同时受到入住日期、连住天数、会员等级、售卖计划、实时促销和税费规则影响。
如果试图把”所有用户、所有日期、所有房型组合下的真实到手价”全部塞进 ES,不但索引维度会爆炸,写入频率也会把搜索集群拖垮。因此主结论固定为:
- ES 按
base_min_price粗排:ES 用基础价完成全局排序和分页。 - 当前页实时查 30 家:搜索服务拿当前页酒店 ID 去商品中心 / 计价引擎算真实价。
- 当前页内存二次微调排序:对这 30 条结果按真实价做轻量重排,确保用户看到的顺序基于真实到手价。
可以把它理解成两层排序:
- 第一层:ES 粗排
- 目标:快速筛出大体上价格更低的候选酒店。
- 使用字段:
base_min_price、地理距离、评分等低频或可容忍滞后的排序因子。
- 第二层:当前页精展 + 微调
- 目标:把当前用户、当前日期区间下的真实到手价展示给用户,并在当前页内按真实价微调顺序。
- 使用输入:
hotel_ids + checkin + checkout + user_id + member_level。
这种做法的优点是:
- ES 可以继续承担高并发检索和翻页,不会因为实时价格频繁变动而被拖垮。
- 商品中心只需要对当前页有限数量的酒店做实时算价,成本可控。
- 最终展示价足够新鲜,不至于让用户看到完全错误的成交价格。
它的代价也要诚实承认:可能存在轻微排序错位。例如某酒店在 ES 粗排时基础价更高,但在当前会员和促销条件下真实价反而更低。工程上通常接受这种小范围偏差,因为相比”绝对精确排序”,大促和高并发场景下的系统可用性更重要。
深翻页怎么处理
在 ES 里,深翻页不是看”一个城市总共有多少家酒店”,而是看 from + size 有多深。默认情况下,max_result_window = 10000,超过这个窗口 ES 会直接拒绝请求。
因此,”一个城市 1000 家酒店”本身不算问题;真正的问题是:
- 用户是否在持续向后翻页;
- 系统是否还想对越往后的结果继续做高成本的实时算价和二次精排。
这也是酒店搜索比普通商品搜索更难的地方。因为一旦用户选择”按价格排序”,后端往往不只是简单翻页,而是在做:
- ES 先按基础价召回一批候选;
- 商品中心对候选酒店批量算真实价;
- 搜索服务在内存里重新精排;
- 再截取当前页返回。
如果对所有翻页都维持这套逻辑,哪怕用户只翻到第 3 页,后端也可能已经在做”前 100 条甚至前 300 条候选的批量算价和内存重排”,这就进入了类深翻页问题:不是 ES 一定先死,而是商品中心和 Hydrate 编排先被拖垮。
酒店搜索里更稳妥的处理方式通常有三层:
-
产品侧先限深
- C 端列表采用懒加载,而不是允许无限页码跳转。
- 滚到较深位置时,优先引导用户缩小日期、商圈、价格区间、设施等筛选范围。
- 这一步往往比任何底层技术优化都更有效。
-
ES 侧用
search_after,不用无脑from + size- 对 C 端连续下拉场景,使用
search_after更合适。 - 它不支持任意跳页,但非常适合移动端”下一页、再下一页”的滚动浏览。
- 这样可以避免 ES 为了翻到更后面的页而反复丢弃前面的大量结果。
- 对 C 端连续下拉场景,使用
-
精排只保证前 N 条,后面自动降级
- 可以定义一个”最高精排桶”,例如只保证前 100 家酒店的价格排序绝对精准。
- 前 100 条以内:走”扩大召回 + 批量算价 + 内存精排”。
- 超过 100 条之后:只对当前页做实时价覆盖,不再对更大候选集做二次精排。
- 这样既保住前几屏的用户体验,也不会让后端为极低转化概率的深页结果付出无限成本。
为了进一步保护商品中心的报价引擎,通常还会加一层 批量报价缓存:
- 当用户带着同一组条件(如入住日、离店日、人数、会员等级)连续翻页时;
- 商品中心第一次批量算出来的结果可以短暂缓存;
- 后续翻页优先走缓存或 MGET,而不是每翻一页都重新全量计算。
因此,这里的推荐结论可以落成:
酒店搜索要把”深翻页问题”和”动态价格排序问题”一起看。ES 负责可扩展的游标翻页,商品中心只为前部高价值结果做精排和算价,越往后越要主动降级,而不是对所有结果做无限精确排序。
房态与价格的一致性怎么处理
酒店搜索里还有一个非常高频的决策点:列表页到底应不应该实时去查 30 家酒店的价格和房态。
这个问题没有统一答案,关键看两件事:
- 单次批量查询的真实耗时能不能稳定控制在可接受范围内。
- 高并发、大促、爬虫和跨境供应商抖动时,底层资源层能不能扛住放大的吞吐压力。
如果压测结果表明:
- 同一批次实时查询 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 在全局排序时也不可能提前知道所有酒店对当前用户、当前入住日期下的真实到手价。所以更现实的做法仍然是:
- ES 先按
base_min_price做全局粗排。 - 取出当前页 30 家酒店 ID。
- 搜索服务实时查这 30 家的真实价格与真实房态。
- 在内存中对这 30 条结果做一次轻量二次微调排序。
同时,必须在进入详情或结算时再做更强的校验,因为:
- 列表页允许轻微延迟;
- 当前页 30 家虽然可实时查,但受总超时预算约束;
- 用户点进详情或进入结算时,才是真正要收紧校验口径的节点。
因此,这里的推荐结论不是”必须缓存”或”必须实时查”,而是:
酒店列表页可以实时查当前页 30 家数据,但前提是这条路径经过限流、隔离、超时和降级保护;真正的主架构仍然是 ES 负责粗排,商品中心 / 报价引擎负责当前页实时修正;进入详情和结算时再做更强的真实性校验。
14.3.8 搜索、导购与详情链路的统一架构原则
最后,用 4 句话收口,和第 4、5、6 节保持风格一致:
- 搜索负责召回和排序,不负责给出交易最终真相。
- 详情页负责多域聚合展示,不负责生成交易事实。
- 搜索与详情允许弱一致和局部降级,但结算与订单必须强校验。
- ES 提供骨架,商品、库存、营销、计价提供动态血肉,订单只认快照与凭证。
把这 4 句话和第 3 节的技术要点、时序图、酒店特殊场景串在一起,搜索、导购与详情链路的全貌就已经足够清晰,也为后续第 4 节”购物车与结算链路”和第 5 节”下单、支付与订单编排链路”做好了准确的边界铺垫。
14.4 购物车、结算与资源预占
14.4.1 场景画像
购物车和结算虽然常常出现在同一个前端页面体系里,但它们在系统设计上承担完全不同的角色:
- 购物车是“意愿篮”,弱一致、可长期暂存。
- 结算页是“交易前总校验器”,会触发价格试算、库存预占和优惠校验。
用户从加购到结算,通常会依次跨过下面几种典型动作:
- 未登录用户把商品先放进匿名购物车。
- 登录后把匿名购物车和账号购物车合并。
- 在购物车里勾选部分商品进入结算页。
- 结算服务对商品、价格、库存、权益、运费做一次交易前汇总校验。
- 用户点击“提交订单”前,系统再次确认这些结算结果是否还有效。
因此,购物车与结算链路的本质不是“把商品列出来然后直接下单”,而是:
- 购物车负责保存用户意图;
- 结算负责把用户意图收敛成一次可提交的交易尝试;
- 订单创建必须建立在结算凭证仍然有效的前提之上。
14.4.2 关键技术点
为什么购物车不锁库存,而要等到结算阶段才预占
购物车天然是一个长生命周期容器,商品可能被用户放进去几分钟、几小时,甚至几天。如果在加购时就锁库存,会出现两个严重问题:
- 资源被大量无效占用,真实买家反而看见“无货”;
- 购物车会从“意愿篮”退化成“半订单系统”,导致系统复杂度失控。
因此更合理的边界是:
- 加购阶段只记录意愿,不锁任何资源;
- 结算阶段才做短 TTL 的库存预占和权益占用。
为什么购物车服务和结算服务要分开
购物车服务面对的是:
- 高读写、弱一致、频繁加减数量;
- 匿名态与登录态合并;
- 失效商品标记、限购截断、展示优化。
结算服务面对的是:
- 商品正式态校验;
- 价格试算与营销试算;
- 库存预占、权益占用;
- 生成一组可供订单提交消费的结算凭证。
二者虽然都服务于“买东西”,但读写模型完全不同。如果把它们揉进一个服务里,最终会变成:
- 购物车流量冲击交易校验逻辑;
- 结算逻辑污染购物车的简单读写路径;
- 系统很难分别做缓存、限流、降级和扩展。
所以更清晰的职责划分是:
- 购物车服务负责意愿存储和合并;
- 结算服务负责交易前编排和凭证生成。
结算页为什么本质上是一笔短生命周期 Saga
进入结算页时,系统需要跨多个域临时拿到“当前这笔交易是否可成立”的真相:
- 商品是否仍然可售;
- 当前价格和优惠是否仍有效;
- 库存是否足够;
- 地址、运费、履约规则是否匹配。
这些结果不是永久事实,而是一组瞬时成立的交易前提。因此结算页本质上不是订单,而是一笔短生命周期的 Saga 编排,其输出通常包括:
price_token / price_snapshot_idreserve_idscoupon_tokens / benefit_tokensfreight_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 适合对字段进行增量更新,但它本身不提供跨多个业务动作的完整交易语义,因此“修改购物车、记录版本、写事件”不能仅凭多次普通命令完成。[15] 购物车主存可以使用 Redis 提升响应速度,同时以异步备份或可重建事件保留恢复能力;恢复时应以最新有效版本合并,而不是用旧备份覆盖用户最近一次操作。
匿名购物车、登录合并与并发写
匿名购物车合并至少存在三种并发:用户在多个设备同时操作、登录合并与另一个加购请求并发、过期清理与用户读取并发。可以为每个购物车维护 cart_version,写操作携带客户端已知版本,服务端使用乐观并发校验:
- 读取购物车版本
v。 - 计算本次加购、删除或合并后的候选状态。
- 只有当前版本仍为
v时才提交为v+1。 - 版本冲突时重新读取并按业务规则重放用户操作。
合并动作要记录 merge_id,这样同一个登录重试不会将匿名数量重复并入账号购物车。对同一 sku 的数量合并可采用“相加后按限购截断”,但对不同销售主体、不同履约仓或不同促销资格的商品,不能仅凭 sku_id 合并,必须把销售主体和履约约束作为聚合键。这个判断属于本书对常见电商模型的推导,具体聚合键应由商品、库存和营销领域共同定义。
结算凭证的生命周期与安全边界
结算凭证不是把金额加密后放进前端的字符串,而是服务端可查询、可撤销、可审计的资源引用。推荐的状态机如下:
CREATED -> VALIDATING -> RESERVED -> CONSUMED
| | |
| | +--> RELEASED
| +--------------> EXPIRED
+-------------------------> INVALID
每个凭证应至少绑定用户、购物车或会话、商品版本、数量、金额摘要、资源 ID、创建时间和过期时间。提交订单时要校验:
- 凭证归属的用户与当前认证身份一致;
sku_id、数量、销售主体和履约方式没有被替换;- 价格版本和营销规则仍在允许窗口内;
- 预占资源处于可消费状态,且没有被其他订单消费;
- 凭证只被当前订单消费一次。
如果凭证放入 Redis,EXPIRE 可以限制自然过期,但过期并不等于所有外部资源都自动回补;库存和优惠仍需要显式 Release 或由延迟任务扫描。[17] 凭证消费可以使用一次性状态转移或 Lua 原子脚本,不能依赖“先查询再删除”的两个非原子步骤。Stripe 的幂等请求实践也提醒我们,幂等键应与请求参数绑定并保留足够长的窗口,防止同一键被用于不同业务请求。[32]
资源预占的补偿矩阵
结算编排不能只写成功路径,还要为每个已成功动作指定回滚动作和兜底查询:
| 已完成动作 | 后续失败 | 同步动作 | 异步兜底 | 最终状态 |
|---|---|---|---|---|
| 商品校验通过 | 价格校验失败 | 直接返回失效项 | 无 | CHECKOUT_REJECTED |
| 价格试算成功 | 库存预占失败 | 取消价格上下文 | 过期清理价格上下文 | CHECKOUT_REJECTED |
| 库存预占成功 | 营销占用失败 | 释放库存 | 预占超时扫描 | RESERVE_RELEASED |
| 营销占用成功 | 订单落库失败 | 释放库存和权益 | 按 checkout_id 反查订单 | COMPENSATING |
| 订单已落库 | 支付未完成 | 保持待支付 | 超时关闭并回补 | CLOSED / EXPIRED |
这张表的关键不是“每一步都同步回滚”,而是为每一步保留可识别的凭证和可重试的补偿入口。Transactional Outbox 与 Polling Publisher 模式把本地事务中的业务事实和待发送消息绑定,再由发布器可靠投递,适合把订单落库与补偿事件连接起来。[26][27] 但 Outbox 只解决“事件不丢”的一部分问题,消费端仍必须幂等,外部资源仍必须支持查询和撤销。
结算性能预算与降级顺序
结算是交易前同步链路,不能把所有可选权益都堆进主请求。可以按以下优先级做预算:
| 优先级 | 依赖 | 超时或不可用时的处理 |
|---|---|---|
| P0 | 商品可售、库存预占、订单约束 | 直接阻断提交 |
| P1 | 基础计价、运费、支付方式 | 返回可解释错误,允许重试或更换方式 |
| P2 | 优惠券、会员权益、推荐权益 | 在明确告知的前提下关闭复杂优惠或重新计算 |
| P3 | 推荐、埋点、个性化展示 | 静默降级,不影响创单 |
每个下游要有独立超时、并发上限和熔断状态;不能因为营销服务重试,就拖住库存连接池。Google SRE 将过载处理视为服务设计的一部分,建议通过负载削减、排队和降级保护关键路径。[6] 本章因此把“优惠计算超时”定义为可解释失败,把“推荐超时”定义为无感降级,并把这两种结果写入统一的结算诊断字段,便于后续分析转化损失。
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 创单、订单数据模型与订单状态机
14.5.1 场景画像
用户点击“提交订单”后,订单中心依据结算凭证落地商品、价格和履约快照,再由支付结果推进状态;失败、超时或风控拒绝必须释放库存与权益。
14.5.2 关键技术点
创单阶段的关键追问集中在幂等、凭证、快照、补偿和安全边界;下文用数据模型、状态机、时序和 ADR 分别展开,不在此重复问答表。
14.5.3 订单数据模型:主单、子单、订单项与快照
订单不是一个只有金额和状态的表,而是一组需要共同解释交易的事实集合。推荐按下面的关系拆分:
Order(主单)
├── OrderSubOrder(销售主体 / 履约主体 / 店铺维度)
│ ├── OrderItem(sku、数量、成交金额、资源引用)
│ ├── ProductSnapshot(标题、规格、图片、规则摘要)
│ ├── PriceSnapshot(原价、成交价、优惠分摊、税费、运费)
│ └── FulfillmentSnapshot(地址、履约方式、预约或核销规则)
├── PaymentOrder(支付单、渠道、支付状态)
├── InventoryReservationRef(预占引用与释放状态)
├── AfterSaleCase(售后单及其范围)
└── OrderEvent / Outbox(事实事件与待投递消息)
主单适合表达用户一次提交的交易聚合,子单表达销售主体或履约主体的独立推进,订单项表达可售卖的最小业务单位。支付单不能简单等同于主单,因为一个主单可以合并支付多个子单,一个子单也可能拆成多次退款。售后单也不应覆盖订单原始字段,而应引用订单项和快照,记录“针对哪一项事实提出了什么后续请求”。
快照至少要满足三个条件:第一,字段足以支持客服、售后和争议解释;第二,快照有生成时间、来源版本和摘要校验;第三,快照在订单创建后不可被普通商品更新覆盖。并不是所有商品富文本都要塞进订单库,推荐保留标题、规格、销售主体、成交价格、优惠分摊、履约规则摘要和必要的图片 / 文案版本,长描述和展示素材可以通过不可变版本存储或归档引用获取。
14.5.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。
至少要维护以下不变量:
- 已支付金额不能超过订单应付金额,退款累计不能超过已支付金额。
- 已确认的订单项数量不能大于提交时的数量,除非存在明确的拆单、换货或补发事实。
- 订单进入
PAID前不能产生“支付成功”的用户可见语义;收到重复支付通知只能返回幂等成功。 - 订单进入
CANCELED后不能再被普通支付成功事件推进为PAID,异常情况必须转人工或进入纠错流程。 - 售后完成必须引用支付、履约和退款事实,不能只凭售后申请记录关闭订单。
- 主单状态由子单状态聚合得出时,聚合规则必须固定并可测试,不能依赖消息到达顺序。
这些不变量比“最终状态有哪些枚举值”更重要。事件乱序、重复和延迟是分布式系统的常态,Kafka 的交付语义文档也明确区分了至少一次、至多一次和恰好一次处理边界;业务状态机仍需要用事件 ID 和版本检查实现自己的幂等。[13] 因此,订单状态更新应使用 order_id + version 的乐观并发条件,更新失败时重新读取最新状态并判断事件是否已经生效。
14.5.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 记录和订单事实必须在同一个本地事务提交,否则会出现“订单已经创建但没有事件”或“事件已发送但订单并不存在”的裂缝。[26] 发布器允许重复发送,因此事件消费者要以 event_id 或业务聚合版本去重;不能把“发布器没有收到确认”误判成“业务没有执行”。当发布器连续失败时,要有积压指标、重试退避、死信和人工重放入口。
MySQL InnoDB 的事务模型可以为本地订单事实提供行级锁和隔离保证,但数据库事务只覆盖同一个资源边界,不能自动把外部支付和库存纳入同一个可靠提交。[20] 本章选择短本地事务加异步补偿,牺牲跨服务瞬时强一致,换取较低锁持有时间、较高吞吐和可独立扩展的下游边界;这个牺牲必须在 ADR 中明确,而不能包装成“系统已经一致”。
14.5.6 主单、子单与合并支付
当一次提交包含多个店铺、仓库或履约类型时,主单只是用户视角的聚合,真正可独立履约的单位是子单。设计时要明确三组映射:
| 关系 | 例子 | 作用 |
|---|---|---|
| 主单到子单 | 一个主单拆为多个店铺子单 | 展示一次提交与分别履约并存 |
| 子单到支付单 | 多个子单共用一个支付单 | 支持合并支付和统一收银台 |
| 订单项到资源 | 一个订单项对应预占、券码或预约资源 | 售后和补偿定位到最小范围 |
主单不能因为某个子单发货就直接显示“已完成”,也不能因为一个子单退款就把全部子单金额退回。可以定义聚合规则:全部子单支付完成才是主单支付完成;全部子单进入终态且没有待处理售后才是主单完成;部分退款以金额和订单项范围计算。聚合规则需要覆盖空集合、部分失败、部分取消和退款中的中间态。
14.5.7 超时、重复与反查
客户端看到“提交超时”时,服务端可能已经完成创单。正确处理不是立即再写一笔订单,而是按幂等键、业务订单号或结算凭证反查:
- 按
idempotency_key查询提交记录。 - 查不到时按
checkout_id和用户身份查询订单。 - 查到待支付订单,返回原订单并允许继续支付。
- 查到已支付订单,返回支付状态而不是重新创单。
- 仍查不到且凭证已过期,提示刷新结算,不继续消费旧资源。
对外接口应使用明确的 HTTP 语义和问题详情,区分参数错误、凭证过期、资源不足、处理中和服务暂不可用;RFC 9110 定义了 HTTP 语义,RFC 9457 则提供了机器可读的错误详情表达方式。[23][24] 本章建议错误响应至少包含 type、code、title、detail、retryable、request_id 和可选的 next_action,使前端能选择“重试、刷新、换货或联系人工”,而不是把所有错误显示成“系统繁忙”。
订单应该在“提交订单”时创建,还是在“点击支付”时创建
这在电商里是一个非常经典的交易架构决策点,通常有两种主流方案:
- 方案 A:提单时创单
- 用户点击“提交订单”时,先创建一笔待支付订单,再进入收银台选择支付渠道。
- 方案 B:支付时创单
- 用户点击“立即支付”时才真正建单;支付成功后,再落最终订单事实。
二者的本质区别在于:库存锁定发生得早还是晚、订单事实生成得早还是晚。
| 方案 | 优点 | 风险 | 适用场景 |
|---|---|---|---|
| 提单时创单 | 用户一旦进入收银台,库存和交易资格通常已经预留;支持待支付、催付、继续支付 | 更容易被恶意占库存;高并发时前置写库和锁资源压力更大 | 实物电商、大多数标准电商、酒店、机酒票务、重体验场景 |
| 支付时创单 | 极大减轻前置创单压力;不容易被恶意占库存;更适合极端高并发 | 可能出现“钱付了但后置建单 / 扣库存失败”的补偿复杂度;用户体验更容易受损 | 秒杀、抢购、部分虚拟商品、部分极端高并发场景 |
这章默认采用的是 方案 A:提单时创单。原因是本章的主线更偏向:
- 购物车 -> 结算 -> 提交订单 -> 支付 -> 履约
这类标准交易旅程。它的优点是:
- 订单能够稳定承接商品、价格、履约快照;
- 用户进入收银台后,交易关系已经明确;
- 支付结果回调只需要推进订单状态,而不是同时承担“先建单、再扣库存、再补偿”的复杂责任。
但也要明确:这不是唯一正确答案。如果业务是:
- 极端高并发秒杀
- 超稀缺票券抢购
- 低客单价虚拟商品
那么“支付时创单”往往更合理,因为它能把大量无效创单和恶意占坑挡在支付前面。
因此这里更稳妥的结论是:
标准电商与重体验商品,优先采用“先创单、后支付”;极端高并发和强防刷场景,再考虑“支付时创单”。
订单提交流程应该由独立聚合服务编排,还是由订单中心自己协调下游
这也是交易架构里非常经典的一个分歧点,通常存在两种方案:
- 方案 A:单独起一个结算 / 交易聚合服务
- 由结算服务或 Trade-CO 统一去协调商品、库存、计价、营销,再把最终结果交给订单中心落单。
- 方案 B:前端直接调用订单中心,由订单中心自己协调下游
- 订单中心既管订单写库,又去调用商品、库存、计价、营销这些服务完成前置校验与编排。
这两种方案的本质区别是:交易主流程编排逻辑,是放在订单领域之外的场景聚合层,还是直接压进订单中心内部。
| 方案 | 优点 | 风险 | 适用场景 |
|---|---|---|---|
| 独立聚合服务 | 订单中心更纯净;读写压力与场景编排隔离;更适合复杂结算页和多业务协同 | 多一跳 RPC;多一个服务需要治理 | 中大型电商、业务复杂、团队分工清晰、存在独立预结算页 |
| 订单中心自己协调 | 链路短;服务更少;前期开发快 | 订单中心容易膨胀成万能大管家;读写混合;迭代风险高 | 小团队、早期系统、短期快速上线 |
从工程演进角度看,很多系统早期会采用“订单中心自己协调”的方式快速上线,但随着以下问题出现,通常都会向独立聚合服务演进:
- 结算页逻辑越来越复杂
- 商品、计价、库存、营销团队开始独立演进
- 订单中心既要承接创单写流量,又要承接大量预览与预结算读流量
- 大促时希望把“重编排逻辑”和“订单写库主链路”物理隔离
本章默认采用的是 方案 A:独立结算聚合服务编排,下游原子服务各自归位。原因是:
- 结算页本身就天然是一个跨商品、库存、计价、营销、地址的聚合场景;
- 订单中心更适合回归为“交易事实持久化 + 状态机推进”的原子领域服务;
- 支付、履约、售后后续也都更容易围绕清晰的订单事实展开。
因此这里的推荐结论是:
当系统存在独立的确认订单页 / 预结算页,且交易链路已经明显跨多个领域服务时,优先采用“结算聚合服务编排,下游原子服务落地”的架构;只有在系统非常轻量时,才让订单中心临时兼做协调者。
创单接口的幂等性应该怎么设计
创单接口的幂等性,不能只理解成“用户重复点按钮怎么办”。它真正要解决的是:
- 同一个下单意图因为网络抖动、页面重试、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 反查订单是否已存在 |
最稳妥的提交流程通常是:
- 用户进入确认订单页;
- 结算服务预生成
order_no; - Redis 写入
order:submit:{order_no},TTL 例如 15~30 分钟; - 前端提交订单时,原样带回这个
order_no; - 后端先原子消费 Redis 中的提交凭证;
- 再进入正式创单事务;
- 订单表以
order_no unique最终兜底。
这里尤其要注意一个容易答错的点:
token 过期,不等于订单一定创建失败。
所以当 Redis 里发现 order_no 已不存在时,更合理的后端处理顺序是:
- 先按
order_no去订单库查; - 如果已经有订单,直接返回这笔已有订单;
- 如果没有订单,再提示用户“页面已超时,请刷新后重新提交”。
当然,这种方案也有边界:
- 不要直接暴露纯自增订单号;
- 更适合使用 Snowflake、号段 + 随机扰动、带业务前缀的全局唯一号;
- 并且 Redis 只负责前置防重,绝不能替代数据库唯一约束。
因此这里更稳妥的结论是:
预生成订单号完全可以直接作为创单幂等 token。最佳实践是“预生成订单号 = 提交幂等键 = 订单业务主键”,再配合 Redis 一次性消费和数据库唯一索引,形成一前一后的双保险。
技术幂等和业务重复单提醒,为什么要分两层设计
创单链路里还有一个很容易被忽略的点:
- 技术幂等,解决的是“同一个请求重复到达,系统不要创建两笔订单”;
- 业务重复单提醒,解决的是“同一个用户在很短时间内,可能无意中下了两笔高度相似的订单”。
这两者看起来都在处理“重复”,但其实完全不是一回事。
技术幂等的典型触发原因通常是:
- 用户重复点击提交;
- 网络抖动导致前端自动重试;
- 网关超时后客户端再次发起请求;
- 消息重发或服务重试。
它的目标非常纯粹:
同一个下单请求,多次到达,只能被系统真正处理一次。
而业务重复单提醒更偏用户体验和业务治理,它处理的是:
- 用户已经成功下过一笔极其相似的订单;
- 但因为没注意页面状态、没看到待支付订单、或者切了支付方式,又重新下了一笔;
- 这两笔订单在技术上是两次不同请求,但在业务上很可能是用户误操作。
它的目标不是强拦截,而是:
在不破坏正常下单自由度的前提下,提示用户“你刚刚可能已经下过一笔类似订单了”。
所以一个成熟的订单系统,通常要把这两层拆开设计:
| 层次 | 目标 | 典型手段 |
|---|---|---|
| 技术幂等 | 防止同一请求被重复处理 | submit_token/order_no、Redis 一次性消费、数据库唯一索引 |
| 业务重复单提醒 | 防止用户短时间内误下两笔相似订单 | 按用户、商品、地址、金额、时间窗口做相似订单检测,并给出二次确认提示 |
业务重复单提醒一般不会像技术幂等那样做成强约束唯一键,而是更偏“软校验”。例如:
- 同一用户;
- 在最近 1~5 分钟;
- 针对相同商品 / SKU / 房型 / 行程;
- 收货地址、数量、金额高度一致;
- 且前一笔订单还处于待支付或刚支付状态。
这时系统更合理的动作通常是:
- 弹出提醒:
- “你刚刚已经提交过一笔相似订单,是否继续下单?”
- 给用户两个选择:
- 去查看已有订单;
- 继续提交当前订单。
这样做的原因是:
- 有些重复单确实是误操作;
- 但也有些重复单是用户有意为之,例如:
- 给不同人各买一份相同商品;
- 同一酒店房型连续下两间;
- 同一活动商品分开下单。
如果把业务重复单也像技术幂等一样硬挡掉,反而会伤害真实交易。
因此更稳妥的结论是:
技术幂等和业务重复单提醒必须分层设计:技术幂等前置,负责防止同一请求被系统处理两次;业务重复单提醒后置,负责识别“相似订单”并给用户二次确认。技术幂等优先,业务提醒辅助。
创单失败后,库存预占和权益占用怎么做最终一致性补偿
这也是结算编排里非常关键的一道资损防线。
在标准的提单链路里,前面往往已经发生了这些动作:
- 价格快照已经生成
- 库存已经预占,拿到了
reserve_ids - 营销权益已经占用,拿到了
coupon_tokens
这时如果最后一步:
结算服务 -> 订单中心 CreateOrder
在写库时因为数据库超时、网络断开、主从抖动等原因失败,系统就会落入一个非常危险的中间态:
- 前端看到的是“创单失败”
- 但库存和权益其实已经被前置链路冻结住了
如果不处理,就会出现:
- 僵尸库存预占
- 优惠券被卡死
- 用户无法再次下单
- 商家可售资源被无故锁住
这里不能靠 Seata / XA 这类强一致事务硬拉平,因为它们会把高并发交易主链路拖得过重。更可落地的做法是:
- 主链路快速失败
- 异步补偿兜底
- 延迟反查再兜底
推荐的补偿模型一般有两层:
第一层:消息补偿
当结算服务或订单中心感知到创单明确失败时,发布一条 OrderCreateFailedEvent:
- 库存中心订阅后释放
reserve_ids - 营销中心订阅后释放
coupon_tokens
这样可以把大部分“明确失败”的场景快速回滚掉,而不需要让前台请求同步等待所有补偿完成。
第二层:下游主动反查 + 延迟释放
为了防止消息丢失、网络抖动或“创单结果未知”这种灰色状态,库存中心和营销中心本身还应该有一层延迟自愈:
- 在预占 / 占用成功时,挂一条延迟检查任务
- 到达超时时间后,主动去订单中心反查:
- 这个
reserve_ids/coupon_tokens对应的订单到底创建成功了吗
- 这个
- 如果订单不存在,或者订单已经被关闭 / 取消,就自动释放资源
这意味着:
- 结算服务负责主流程编排
- 订单中心负责创单真相
- 库存和营销中心各自对自己的冻结资源负责自我救赎
因此这里更稳妥的结论是:
创单失败后的最终一致性,不能依赖运行时强事务,而要依赖“失败事件补偿 + 延迟反查释放”的双层自愈机制。
价格防篡改应该重新计算一遍,还是依赖价格签名 / 版本核销
创单链路里还有一个非常关键的安全问题:前端提交的价格到底能不能信。
如果黑客通过抓包改包,把前端传给 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 之类的附表按需读取。
订单中心负责交易事实编排,商品中心负责交易前静态契约
商品中心负责“商品身份与价格合规”的静态校验,订单中心负责“交易行为与流水合规”的最终编排。
订单必须保存商品快照、价格快照和履约快照
订单必须保存商品快照、价格快照和履约快照,而不是回读最新商品。
支付发起和支付结果回调是两条不同链路
支付发起和支付结果回调是两条不同链路:前者负责创建支付单,后者负责提供支付事实。
支付系统只提供支付事实,订单系统自己推进状态机
支付系统只提供支付事实,订单系统自行推进自己的状态机。
支付成功之后,订单中心再去编排库存确认、权益确认和后续履约触发
支付成功回调之后,订单中心才去编排库存确认、权益确认和后续履约触发。
支付失败、超时取消和风控拒绝后,库存和营销权益必须显式释放
支付失败、超时取消和风控拒绝后,库存和营销权益必须显式释放。
订单模型的演进:从支付单 + 订单,到主单 / 子单 / 商品维度退款
订单模型的演进,本质上是在回答一个问题:系统到底要先解决“支付聚合”,还是先解决“履约拆分、售后拆分和商品维度退款”。
在业务早期,很多系统只有三张核心表:
pay_order_taborder_tabrefund_tab
这套模型足以支撑最基础的交易闭环:
- 一个支付单对应一个或多个订单
- 一个订单下挂多个订单明细
- 退款主要按整单维度处理
它的优点是简单、上线快、链路短。但当业务开始出现下面这些诉求时,模型就会越来越吃力:
- 一个支付单下挂多个业务订单,需要合并支付
- 一个用户视角上的“大订单”需要按商家、仓库、履约方式拆成多个子单
- 售后不再只是整单退款,而是按某个商品、某个
order_item退款 - 后续还可能出现部分发货、部分签收、部分退款、部分核销
这时候,更稳妥的演进方向是把“支付域聚合”和“订单域聚合”拆开:
pay_order_tab:属于支付域,只解决一次支付要付多少钱、走哪个渠道、支付状态是什么parent_order:属于订单域,解决用户视角和业务聚合问题order_tab:承载子单,解决分商家、分仓、分履约、分售后的独立处理order_item_tab:承载商品维度事实,给商品维度退款、部分售后、部分履约提供锚点refund_tab:从“只关联合同/整单”演进到既可关联订单,也可关联order_item_id
可以用一句话概括这种演进模型:
主单解决“支付和用户视角的统一”问题,子单解决“履约、结算、售后等多维度独立处理”问题。
一个比较务实的演进顺序通常是三步走:
- 先保留现有
pay_order_tab + order_tab + refund_tab,快速支撑基础交易。 - 在订单域引入
parent_order,或者至少先在order_tab上补parent_order_id,开始支持合并支付和业务聚合。 - 在
refund_tab上增加order_item_id,让退款能力从整单退款演进到商品维度退款。
这里有一个很重要的边界要守住:
pay_order_tab不应该替代parent_order- 支付单是支付域对象,关注资金流
- 主单是订单域对象,关注用户视角、履约拆分和售后聚合
也就是说,支付单可以聚合多笔业务订单,但它不应该承接“用户看到的是一单还是多单”“履约怎么拆”“退款按哪个粒度处理”这类订单域问题。
如果系统还处在简单阶段,完全没必要一开始就把主单、子单、订单项、退款项全部做满;但设计时要预留一条清晰演进路径:
- 简单模式先跑通支付和整单退款
- 复杂模式再逐步引入主单、子单和商品维度退款
这样既能避免过度设计,也不会在后期被早期模型彻底卡死。
为什么库存 Confirm 不应该反查订单中心,而订单中心必须主动查询支付网关
这两个场景表面上都像“结果确认”,但它们的依赖方向其实完全不同。
| 维度 | 库存 Confirm | 支付结果查询 |
|---|---|---|
| 对象 | 库存中心(内部服务) | 支付网关(外部第三方) |
| 控制权 | 我们完全可控 | 我们不可控 |
| 推荐方向 | 订单中心主动调用库存 Confirm | 订单中心主动查询支付网关状态 |
| 是否希望被动反查订单中心 | 不希望 | 不适用 |
| 核心原因 | 避免内部服务双向依赖和职责污染 | 第三方回调不可靠,必须主动核实 |
库存中心之所以不应该反查订单中心,核心原因有三个:
- 避免循环依赖
- 订单中心调用库存中心做
Reserve / Confirm / Cancel是合理的单向依赖; - 如果库存中心再反过来查询订单中心,就会形成双向耦合。
- 订单中心调用库存中心做
- 保持库存中心职责纯净
- 库存中心的职责是管理库存资源和
reserve_token状态; - 它不应该理解“这个订单是不是已经支付成功”这样的订单域语义。
- 库存中心的职责是管理库存资源和
- 防止订单中心膨胀成上帝服务
- 如果库存、营销、物流都回头查订单中心,订单中心会变成整个交易系统的公共查询枢纽;
- 这会放大瓶颈,也会放大故障传播面。
因此,更合理的做法是:
- 订单中心作为协调者,在支付成功后主动调用库存中心做
Confirm; - 库存中心只根据
reserve_token做幂等状态流转:RESERVED -> CONFIRMED- 或
RESERVED -> CANCELED
这也意味着,订单中心必须能正确处理库存 Confirm 的响应结果:
SUCCESSALREADY_CONFIRMEDRESERVE_TOKEN_NOT_FOUND- 网络超时 / 调用失败
并且这条 Confirm 调用本身必须支持幂等重试。通常的推荐做法是:
- 支付成功后,订单中心在本地事务里写一条待确认库存的 Outbox / 本地消息;
- 异步 Worker 调用库存中心
ConfirmInventory(reserve_token); - 只有收到明确成功响应,才把本地消息标记为完成;
- 若响应丢失或调用失败,则按退避策略重试;
- 多次失败后,把订单打到“支付成功_库存确认异常”状态,进入自动退款或人工补偿。
和库存中心不同,支付网关必须允许订单中心主动查询。原因也非常明确:
- 它是外部第三方,我们无法完全掌控回调行为;
- 回调可能丢失、延迟、重复甚至异常;
- 所以只依赖回调不足以支撑订单状态推进。
因此支付结果的推荐模型通常是:
- 以回调为主
- 以主动查询为兜底
也就是说:
- 支付网关回调来了,订单中心先按回调推进支付事实;
- 如果回调迟迟不来,订单中心可以通过定时任务、用户主动查询或收银台轮询去调用支付网关
query接口确认状态; - 一旦确认支付成功,再继续触发库存 Confirm、权益 Confirm 和履约编排。
一句话总结就是:
库存是我们的内部资源中心,要保持干净、独立、单向依赖;支付网关是外部不可信第三方,订单中心必须主动多长一个心眼去确认它的最终状态。
Outbox 本地消息表:支付成功后如何可靠驱动库存 Confirm
如果订单中心承担了主动 ConfirmInventory 的职责,接下来最关键的问题就是:
- 支付成功后,怎么保证这条 Confirm 动作一定会被发出去;
- 如果调用库存中心时网络超时、响应丢失,怎么继续重试;
- 如果系统重启、进程崩溃,怎么保证这条确认任务不会消失。
这里最稳妥的工业级做法就是 Outbox Pattern(本地消息表模式)。
核心思想是:
把“订单状态更新”和“待确认库存消息写入”放进同一个本地事务里,先把消息落到订单库自己的
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 场景,订单中心最典型的本地事务写法就是:
- 支付回调确认成功;
- 在同一个数据库事务里:
- 更新订单状态为
PAID - 插入一条
biz_type = INVENTORY_CONFIRM的 Outbox 记录
- 更新订单状态为
- 事务提交后,异步 Worker 扫描
PENDING/FAILED且next_retry_time <= now()的消息; - Worker 调用库存中心
ConfirmInventory(reserve_token); - 若收到明确成功响应,则把 Outbox 记录标记为
SUCCESS; - 若调用失败或响应丢失,则把状态改成
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 幂等,这四层一起构成最终一致性。
混合支付的状态机应该怎么设计
当一笔订单不是“纯现金支付”,而是由 Coin + Voucher + 支付渠道 共同组成时,支付链路的难点就不再只是调起支付,而是多种资产的锁定顺序、状态流转以及部分失败时的回滚。
这里最容易出问题的点有两个:
- 多种资产同时参与,扣减顺序稍有不慎就会在高并发下形成死锁
- 渠道支付成功、券已锁定、Coin 已冻结,但后续某一步失败时,必须能可靠回滚
因此,混合支付更推荐采用三段式状态流转:
| 阶段 | 动作 | 目标 |
|---|---|---|
| 冻结阶段 | Lock Voucher + Freeze Coin | 在不真正消耗资产的前提下,先锁定用户可用权益 |
| 确认阶段 | Use Voucher + Deduct Coin | 渠道支付成功后,把冻结态转成最终消耗态 |
| 释放阶段 | Unlock Voucher + Unfreeze Coin | 用户取消、超时关闭或支付失败后,归还所有锁定资产 |
推荐的设计原则是:
- 结算服务或支付中心在拉起支付前,只做锁定 / 冻结
- 渠道支付成功回调之后,再做确认使用
- 支付失败、超时关闭、风控拦截之后,再做逆向释放
这样做的好处是:
- 用户点击支付时,平台已经知道这笔单最多能用多少券、多少 Coin
- 最终需要请求第三方支付网关的现金金额是确定的
- 资产和渠道支付被拆成“可逆阶段”和“不可逆阶段”,更适合 Saga 编排
在实现层面,推荐让营销 / 资产中心都提供三段式接口:
Lock / FreezeConfirm / Use / DeductCancel / Unlock / Unfreeze
不要让订单中心或支付中心自己去推导“这张券是不是已经用了”“Coin 到底是冻结还是已扣减”,而应该以下游返回的凭证和状态机为准。
还有一个很关键的财务边界:
用户现金实付 + 平台营销补贴 = 商户实收 + 渠道手续费。
因此,混合支付场景下,系统不仅要记录最终现金支付金额,还要记录:
marketing_discount_amountcoin_deduct_amountvoucher_discount_amountchannel_fee_amount
否则后续对账、退款分摊、商家结算都会变得非常困难。
14.5.8 场景一:提交订单时序图
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
14.5.9 场景二:提交支付时序图
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
14.5.10 场景三:支付结果回调与订单编排时序图
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)
14.5.11 场景四:支付失败与超时回滚时序图
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)
14.5.12 特殊场景:预售订单
预售订单的本质,不是“普通订单加一个活动标签”,而是延迟履约 + 分阶段支付 + 长周期资源占用。它和现货单最大的区别在于:交易事实可以先成立,但资源最终确认和履约发生在更晚的时间点。
业务生命周期
一个典型的预售订单,通常会经历下面几个阶段:
- 预热期:用户可以浏览和加购,但还不能正式下单
- 定金期:用户支付定金,锁定购买资格
- 尾款期:活动切换到尾款支付窗口,用户补齐尾款
- 履约期:尾款支付成功后,订单才进入正常发货 / 履约流程
- 异常终止:定金支付后尾款超时、用户主动取消、活动终止
因此,预售订单不是一次支付完成全部交易,而是把“购买承诺”和“最终成交”拆成了两个时间点。
核心模型
预售订单建议显式建模,而不是把逻辑散落在普通订单字段里拼凑:
order_type = PRESALEpresale_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 场景
一句话总结:
预售订单的核心,不是先收一笔钱,而是先锁定资格,再在更晚的时间点完成真正成交与履约。
14.5.13 特殊场景:0 元购订单
0 元购的本质,不是“没有支付所以更简单”,而是营销驱动的超高风险低价订单。它的目标通常是拉新、促活、带动搭售,但系统设计上反而要更谨慎,因为一旦风控、营销核销或库存确认做得不严,很容易直接形成资损。
业务本质与核心模型
0 元购订单建议也显式建模,而不是混在普通订单里只靠金额判断:
order_type = ZERO_YUANreal_pay_amount:实际支付金额,很多场景为0marketing_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 和重试机制保证最终一致性
- 关键峰值场景仍要靠 Redis + 幂等 + 唯一索引兜底
一句话总结:
0 元购不是“省掉支付就结束了”,而是把支付简化成了营销补贴问题,同时把风控和最终一致性的要求提高到了更高优先级。
14.5.14 结算服务中的“预占凭证 + 本地事务 + 异步确认”Saga 编排
在标准电商创单链路里,结算服务最核心的一点,不是自己去做最终扣减,而是把整条交易链路组织成:
- 创单前只做预占(Reserve)
- 创单成功后保存订单事实
- 支付成功后再做确认(Confirm)
- 创单失败、支付失败、超时取消后再做取消(Cancel)
这本质上是一种非常典型的 Saga 编排模式。它的核心思想是:
所有下游都采用“预占(Reserve)+ 确认(Confirm)/ 取消(Cancel)”模式;结算服务只做预占校验,不做最终扣减;订单中心创建订单成功后,作为交易事实协调点,再通过本地消息表 / Outbox 异步完成最终确认或取消。
这样设计的原因很直接:
- 结算服务面对的是商品、库存、营销、价格等多个下游域;
- 如果在提交订单时就要求所有域同步做最终扣减,主链路会非常重;
- 一旦订单落库失败,还会面临极其难看的跨域回滚问题;
- 因此更稳妥的工业级做法,是让每个下游先给出一个“可确认、可取消”的资源凭证。
这个模式通常会落成三段:
| 阶段 | 动作 | 目标 |
|---|---|---|
| 创单前 | Reserve | 预占库存、预占权益、冻结报价结果,拿到 reserve_ids / coupon_tokens / price_token |
| 支付成功后 | Confirm | 正式确认库存消耗、权益消耗,把预占资源转成成交事实 |
| 失败或取消后 | Cancel | 释放库存、释放权益、失效价格凭证,避免僵尸资源 |
因此,结算服务在创单阶段的职责不是:
- 直接扣库存;
- 直接核销券;
- 直接把营销权益变成最终消耗。
而是:
- 收集并校验这些预占凭证是否有效;
- 把它们和订单事实绑定在一起;
- 在订单创建成功后,把后续确认责任交给订单中心;
- 通过本地消息表 / Outbox,把“确认”或“取消”动作异步可靠地下发给库存中心和营销中心。
一个更贴近落地的职责分工可以概括为:
| 系统 | 在 Saga 中的职责 |
|---|---|
| 结算服务 | 组织预占、校验凭证、提交创单请求 |
| 订单中心 | 落交易事实,成为后续确认 / 取消的协调点 |
| 库存中心 | 提供 Reserve / Confirm / Cancel 三段式库存能力 |
| 营销中心 | 提供权益占用、权益确认、权益释放三段式能力 |
| 支付中心 | 提供支付事实,不直接操作库存和营销资源 |
这里尤其要强调一个经常被问到的点:
为什么创单时只做预占,支付成功后再 Confirm?
因为创单阶段的核心目标是:
- 快速、安全地把用户带到收银台;
- 让订单中心先拿到一笔清晰、可解释的交易事实;
- 把真正不可逆的资源消耗动作,推迟到“支付成功”这个更强的业务事实之后。
所以本章推荐的主流方案就是:
“预占凭证 + 本地事务落订单 + Outbox 异步确认 / 取消”的 Saga 模式。
也就是说:
- 创单时做资源预占
- 支付成功后 Confirm 库存和权益
- 创单失败、支付失败或超时后 Cancel 资源
- 通过本地消息表 / Outbox 保证最终一致性
它的优势在于:
- 主请求足够轻,不需要 XA / Seata 这种重型运行时强一致;
- 下游域边界清楚,各自只负责自己的资源预占、确认和释放;
- 即使出现网络抖动或部分失败,也可以依赖消息补偿和延迟反查完成自愈。
为什么核心交易长事务一定要做方案选型
“用户点击下单 -> 校验商品 -> 预占库存 -> 占用优惠 -> 请求支付” 这条链路,本质上是一个跨订单、库存、营销、支付多个服务的分布式长事务。单机时代的本地事务在这里已经失效,系统必须在下面几个目标之间做权衡:
- 数据一致性
- 高并发吞吐
- 外部支付网关可接入性
- 失败后的补偿成本
因此,创单链路不能只问“能不能保证一致”,而要问:
在高并发电商场景下,系统要用什么代价去换一致性。
四种典型方案的核心差异
方案一:2PC / XA
2PC 依赖全局协调器和底层数据库 XA 协议。所有参与方先进入 Prepare,锁住本地资源但不提交;只有全部成功后,协调器才下发 Commit。
它的问题非常直接:
- 底层数据库锁会跟着整个长事务一起悬挂
- 高并发下吞吐量会被严重拖死
- 外部支付网关根本不支持 XA 协议
所以在电商创单支付链路里,2PC 基本等于一条走不通的死路。
方案二:经典 Saga
经典 Saga 把长事务拆成一串本地子事务,每个服务先执行正向动作并立刻提交;如果后续失败,再逆向执行补偿。
它的优点是吞吐高、没有数据库全局锁,但缺点也很致命:
- 缺少隔离性,容易出现“先真扣库存,后又补回来”的僵尸占位
- 容易被网络乱序拖进悬挂、空补偿和重复补偿问题
- 防悬挂、防乱序的代码会越来越重
方案三:标准 TCC
TCC 把两阶段提交抬升到业务层,要求每个下游都实现:
TryConfirmCancel
库存中心在 Try 阶段不是直接扣库存,而是冻结一部分资源;支付成功后走 Confirm,失败时走 Cancel。
它的优点是隔离性很强,特别适合资金类、账户类系统;但代价也很高:
- 每个下游都要手写三套接口和状态机
- 编排器一般依赖同步 RPC,链路长时容易拖垮线程池
- 对研发规范和团队成熟度要求非常高
方案四:工业级改良 Saga
这也是本章一直在推荐的方案:把 TCC 的“预留资源思想”和 Saga 的“异步最终一致性”结合起来。
它的典型形态就是:
- 创单时只做
Reserve - 订单中心先落本地交易事实
- 支付成功后再异步
Confirm - 支付失败、超时关闭后异步
Cancel - 整个过程依赖
Outbox + Worker + 幂等状态机
这也是为什么本章一直强调:
“预占凭证 + 本地事务落订单 + Outbox 异步确认 / 取消” 才是标准电商创单支付链路的主流工业实现。
四种方案综合对比
| 对比维度 | 2PC / XA | 经典 Saga | 标准 TCC | 改良版 Saga(推荐) |
|---|---|---|---|---|
| 一致性级别 | 强一致 | 最终一致 | 业务层强一致 | 最终一致 |
| 并发吞吐 | 极低 | 高 | 中 | 高 |
| 资源隔离性 | 高,但依赖数据库锁 | 差 | 高 | 高 |
| 对外部支付友好度 | 极差 | 较好 | 一般 | 极好 |
| 开发成本 | 中 | 中 | 极高 | 中 |
| 高并发电商适配度 | 很差 | 一般 | 有限适配 | 最优 |
把它翻译成更贴近业务的话就是:
- 2PC 太重,锁不起
- 经典 Saga 太莽,容易伤库存和权益
- 标准 TCC 太贵,不适合所有链路都重做三段式
- 改良版 Saga 在复杂度、性能和一致性之间最平衡
本章的选型结论
因此,对标准的电商创单支付链路,推荐结论非常明确:
- 坚决不选 2PC / XA
- 不建议在核心交易主链路直接使用经典 Saga
- 只有金融级资管、账务划转这类场景才值得咬牙做标准 TCC
- 普通电商创单支付链路优先使用改良版 Saga
这也是为什么结算服务在本章里的角色不是“硬拉所有服务做一次全局提交”,而是:
- 组织预占
- 快速确立订单事实
- 把最终确认和取消交给订单中心异步编排
一句话总结:
在电商结算场景中,我们追求的不是运行时强一致,而是“资源先预占、订单先落地、后续异步确认、失败可靠补偿”的高吞吐最终一致。
落地建议
如果团队自己维护状态机和消息补偿能力,改良版 Saga 完全可以直接落地为:
- 下游三段式接口:
Reserve / Confirm / Cancel - 订单中心本地事务:订单事实 +
outbox - Worker 异步扫描 + 幂等重试
如果后续链路继续拉长,比如:
- 跨境履约
- 多段商旅履约
- 超长时间预约 / 改签 / 多次补偿
那么也可以再演进到更成熟的工作流 / Saga 引擎,例如:
- Seata 的 Saga 模式
- Temporal / Cadence 这一类代码化工作流引擎
但无论是否引入框架,原则都不变:
先把交易链路拆成可预占、可确认、可取消的资源状态机,再谈框架选型。
14.6 支付、交易编排与最终一致性
14.6.1 支付不是一个按钮,而是两条事实链
支付链路至少包含“发起支付”和“确认支付结果”两条不同的事实链:
- 订单中心依据订单应付金额创建或获取支付单。
- 收银台向用户展示支付方式并向渠道发起支付。
- 渠道可能同步返回成功、失败或处理中。
- 渠道还可能通过异步通知、查询接口或对账文件给出最终结果。
- 支付中心归一化渠道结果,订单中心消费支付事实并推进订单状态。
同步返回只代表一次请求的即时结果,不能自动覆盖异步通知和对账事实。尤其是移动端、网页支付和银行转账场景,用户可能在客户端超时后已经完成扣款,或者渠道通知先于前端响应到达。支付中心应维护支付单状态、渠道流水号、金额、币种、渠道和通知版本;订单中心只消费标准化的 PaymentSucceeded、PaymentFailed、PaymentPending、RefundSucceeded 等事实事件。
对外支付接口要采用幂等键。AWS Builders’ Library 将幂等 API 视为安全重试的基础:客户端可以重试同一请求,而服务端不会因为网络丢包重复创建副作用。[9] Stripe 的接口文档也将幂等键与请求参数绑定,避免同一个键被意外用于不同的请求。[32] 在本章的交易模型中,支付幂等键至少应包含订单或支付单身份,不能只使用用户 ID 或任意随机请求 ID。
支付域中的四类对象
支付系统最容易出现的模型错误,是只设计一张“支付表”,让订单号、渠道号、退款号和分账信息都堆在同一行。支付过程至少包含四类对象,它们的生命周期和事实主权不同:
| 对象 | 作用 | 关键字段 | 生命周期 | 不能替代什么 |
|---|---|---|---|---|
| 支付意图 | 表达订单希望完成的一次资金收口 | 订单、应付金额、币种、过期时间 | 从收银台创建到支付终态 | 不能直接证明渠道已扣款 |
| 支付尝试 | 表达一次具体的支付方式和渠道尝试 | 支付方式、渠道、商户号、路由版本 | 可因失败换渠道而产生多次 | 不能覆盖支付意图的累计金额 |
| 渠道交易 | 表达外部机构接收或完成的交易 | 渠道流水号、受理状态、原始报文摘要 | 受渠道查询和对账闭合 | 不能直接修改订单主状态 |
| 退款单 | 表达对已成立资金事实的逆向请求 | 原支付单、退款金额、原因、退款流水 | 从申请到渠道完成或人工终止 | 不能把原支付事实抹掉 |
因此,订单与支付之间更适合使用 payment_intent_id 关联,而不是让订单直接保存一个会不断变化的渠道流水号。一个支付意图可以有多次支付尝试,一个支付尝试可能因为渠道超时处于未知状态,渠道交易则需要由回调、主动查询和对账文件共同解释。退款也应独立建模,因为一次支付可以发生多次部分退款,退款完成时间和原支付完成时间并不相同。
这个拆分还有一个实际好处:用户点击“换一种支付方式”时,系统可以创建新的支付尝试,但仍然沿用同一个支付意图和金额快照;渠道回调到达时,只需依据渠道流水号定位尝试,再汇总到支付意图,而不会因为一次失败尝试覆盖另一条已经成功的渠道事实。支付域的金额计算、渠道状态归一和退款累计校验由支付系统负责;订单域只接收支付意图层面的标准事实。
支付核心的分层与数据边界
原支付系统可以抽象为接入层、应用编排层、支付核心、渠道适配层和清结算层。分层的价值不在于增加服务数量,而在于隔离变更原因:换微信或银行卡 SDK 不应修改退款金额规则,调整商家结算周期也不应污染回调验签逻辑。
| 层次 | 主要职责 | 可依赖 | 不应承担 |
|---|---|---|---|
| 接入层 | 鉴权、限流、防重、原始通知接收 | 网关、密钥服务 | 订单状态推进和资金记账 |
| 应用编排层 | 创建支付、发起退款、主动查单、对账任务 | 支付核心、路由、任务队列 | 渠道字段的长期透传 |
| 支付核心 | 支付意图、尝试、状态机、金额闭合、事实事件 | 本地数据库、Outbox | 直接依赖某个渠道 SDK |
| 渠道适配层 | 报文转换、验签、预下单、查询、退款 | 第三方渠道 | 解释订单履约状态 |
| 清结算层 | 分账规则、结算批次、渠道账单、差错工单 | 支付事实、财务规则 | 伪造支付成功事实 |
支付主表宜保持窄表:状态、金额、币种、订单关联、渠道标识、幂等键、版本和时间边界是高频访问字段;原始回调、渠道扩展字段和大体积报文应放入追加式通知表、扩展表或受控对象存储。这样既能减少支付状态更新时的行放大,又能保留审计需要的原始证据。敏感报文的留存时间、访问权限和脱敏方式应由合规要求决定,不应为了排障把完整卡号、验证码或渠道密钥复制到普通业务日志中。[28]
14.6.2 支付状态机、渠道通知与验签
支付单可以使用独立于订单主状态的状态机:
INIT -> REQUIRES_ACTION -> PROCESSING -> SUCCEEDED
| | | |
| | | +--> REFUNDING -> REFUNDED
| | +----------------> FAILED
| +----------------------------------> CANCELED
+---------------------------------------------> EXPIRED
渠道回调处理顺序不能只是“收到成功就改状态”,而应是:验签、校验商户号和环境、校验订单与金额、校验通知时间窗口、按渠道流水号去重、按支付单版本推进、记录原始回调摘要、发布标准事件。Adyen 的 Webhook 处理建议也强调验签、持久化事件、返回成功响应以及异步处理业务动作;本章将其抽象为适用于多渠道的回调接入规范。[33]
回调可能重复、乱序或迟到,因此状态迁移需要定义优先级。例如 SUCCEEDED 已经成立后,迟到的 PROCESSING 只能记录为过时事件,不能把支付单退回处理中;已退款的支付单收到重复退款通知,只能幂等确认。每个渠道适配器负责将渠道状态翻译为内部语义,订单中心不应感知支付宝、微信、银行卡或钱包渠道的原始枚举。
渠道路由不是简单的支付方式映射
“用户选择微信,所以调用微信接口”只是最小实现。生产路由还要考虑商户号、销售主体、币种、地区、渠道限额、费率、成功率、响应延迟、风控结果、渠道维护窗口和灰度比例。路由决策必须生成可追溯的快照,至少记录候选渠道、被选渠道、路由规则版本、商户号和选择原因;否则同一订单在重试或对账时无法解释为什么走了不同渠道。
可以把路由拆成三步:先做硬约束过滤,再对可用渠道评分,最后生成带版本的路由决策。硬约束包括支付方式是否支持、金额和币种是否在限额内、商户主体是否具备资质、渠道是否在当前地区开放;评分项才包括成功率、费率和延迟。渠道健康度只能影响未来尝试,不能把已经发生的渠道交易重新“切换”到另一渠道。
| 路由阶段 | 输入 | 输出 | 失败处理 |
|---|---|---|---|
| 资格过滤 | 用户、订单主体、币种、金额、地区 | 可用渠道集合 | 无可用渠道,返回明确不可支付 |
| 健康评分 | 渠道成功率、延迟、限额、费率 | 首选渠道与备用渠道 | 健康数据过期时使用保守规则 |
| 决策落库 | 路由规则、灰度桶、商户号 | route_snapshot | 不能仅放缓存,需可审计 |
| 尝试执行 | 支付意图、尝试号、渠道命令 | 受理结果或未知状态 | 只对幂等操作重试,未知写结果先查单 |
备用渠道并不等于可以无条件切换。若首选渠道的预下单已经返回未知,系统必须先查询首选渠道,确认没有形成资金事实,或者根据渠道明确支持的幂等语义关闭该尝试,才能创建下一次尝试;否则用户可能在两个渠道都完成扣款。这个约束也是为什么支付尝试要独立于支付意图建模。
回调入口与主动查单的双保险
回调入口应与普通收银台查询路径隔离。入口层完成 TLS、来源校验、限流和轻量验签后,先把原始通知摘要及正文指纹以追加方式保存,再向渠道返回满足协议要求的确认响应;支付状态推进、订单事件和履约触发放入异步处理。这样即使下游订单服务抖动,渠道也不会因为平台迟迟不响应而无限重试,同时平台仍保留之后重放所需的证据。
主动查单是回调的补偿路径,不是回调的替代品。对处于 PROCESSING 的支付尝试,应按渠道建议的间隔查询,并根据支付意图的过期时间、渠道最终性和重试预算决定是否继续等待。查单结果也必须经过金额、商户号、渠道流水号和订单关联校验,不能因为渠道接口返回“成功”字符串就直接写入本地。回调和查单同时到达时,使用同一状态迁移函数和数据库条件更新,确保只有一个合法版本能够产生支付成功事件。
通知接收 -> 保存原始证据 -> 验签与字段校验 -> 支付尝试状态迁移
| |
| +--> Outbox: PaymentSucceeded
+--> 失败或未知 -> 查单任务 -> 对账闭合
这条路径中,“收到通知”“本地状态已更新”“订单已消费事件”“渠道账单已对账”是四个不同的完成层级。前端只需要得到面向用户的处理中或成功提示,运营和财务则需要能看到每个层级分别是否完成。
14.6.3 支付成功后的确认、履约和库存确认
支付成功并不意味着所有下游事实已完成。订单中心收到支付成功后,通常需要依次或并行触发:库存 Confirm、营销权益确认、履约创建、发票或会员权益发放。这个阶段的关键是让每个动作都具备幂等键和可查询结果:
| 动作 | 幂等键 | 成功含义 | 失败后的动作 |
|---|---|---|---|
| 库存确认 | order_id + item_id | 预占转为已售 | 重试、查询预占、人工对账 |
| 权益确认 | order_id + benefit_id | 优惠或资产正式消费 | 取消订单项或回补权益 |
| 履约创建 | sub_order_id | 生成仓配、券码或预约任务 | 延迟重试、转履约失败 |
| 发票申请 | order_id | 形成开票任务 | 保留待处理,不影响已支付事实 |
消息发布要使用 Outbox 或等价可靠投递;消费端要保存处理记录和最后成功版本。Kafka 的幂等生产者和事务能力可以减少特定消息链路中的重复,但它不能自动保证外部库存、支付和订单数据库之间的业务恰好一次。[13][14] 所以本章采用“至少一次投递 + 业务幂等 + 对账自愈”的组合,而不是把中间件语义误当成全局事务。
14.6.4 超时关闭与补偿顺序
待支付订单的超时关闭是一个竞争问题:关闭任务可能与支付成功通知同时到达,库存释放任务可能与支付成功后的库存确认同时到达。正确做法不是依赖定时任务先后,而是用订单版本和支付事实做条件判断:
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 对重试限制的建议强调要设置重试预算和退避,避免多个层级各自重试导致重试风暴。[10] 因此支付服务、订单服务、库存服务不能各自无限重试,必须由一个明确的编排层控制总重试次数。
14.6.5 混合支付、部分支付与 0 元订单
混合支付把一个订单的应付金额拆成余额、优惠券、积分、银行卡或钱包等多个资金来源,不能用一个简单的 paid=true 字段描述。支付单应记录支付分摊明细和各渠道状态,订单只有在满足“应付金额已被合法覆盖”时才进入支付完成:
ORDER_CREATED
-> PAYMENT_PLAN_CREATED
-> [balance authorized, coupon consumed, external payment pending]
-> PAYMENT_PARTIALLY_AUTHORIZED
-> PAYMENT_SUCCEEDED
某个渠道失败时,需要决定是释放所有已占用资产重新让用户支付,还是允许更换支付方式继续支付。对于优惠券、积分等资产,最好先产生可撤销的授权或冻结记录,等支付事实成立后再确认消费;否则外部支付失败会留下难以解释的资产损失。0 元订单可以跳过外部支付渠道,但不能跳过订单、风控、库存、权益和审计:它仍然要产生可追踪的“应付为零、支付条件满足、权益正式发放”的事实。
退款不是支付成功的反向按钮
退款需要单独的退款单、退款项和退款状态机。创建退款时先读取订单快照和支付事实,计算原支付金额、已经成功退款金额、当前申请金额及剩余可退金额,并以条件更新或锁定方式保证累计值不超过可退上限。一个支付单允许多次部分退款,但每一笔退款都必须有业务原因、申请人、订单项分摊和渠道退款幂等键。
退款状态可以简化为 REQUESTED -> PROCESSING -> SUCCEEDED / FAILED。FAILED 不代表原支付失败,而只代表本次逆向动作未完成;若渠道支持安全重试,可以继续处理,若渠道返回未知,则保持处理中并进入主动查询和对账队列。退款成功后,支付域发布退款事实,订单售后域再决定是否把库存、优惠、积分、赠品或履约任务回补。支付系统不应因为退款成功就自行把订单改成“已取消”。
营销资金和平台补贴的回冲需要沿用下单时的分摊快照,而不是重新按当前规则计算。比如用户当时使用平台券、商家券和余额共同支付,部分退款时应先明确退款对象是某个订单项还是整单比例,再根据原始资金来源计算各方应回收或承担的金额。金额分摊、舍入和最小货币单位必须在退款创建时冻结,否则多次部分退款会出现累计误差或补贴被重复退回。
| 退款场景 | 支付域要记录 | 售后/履约域要处理 |
|---|---|---|
| 未履约整单退款 | 原支付单、全额退款申请、渠道退款流水 | 关闭未执行履约、释放未消费权益 |
| 单项部分退款 | 订单项、数量、分摊金额、税费和优惠归属 | 回补对应库存或取消对应履约任务 |
| 已发货退货退款 | 退货验收事实、退款原因、退款金额 | 逆向物流、库存质检、重新入库策略 |
| 已核销商品退款 | 核销时间、核销状态、可退规则 | 判断是否允许人工售后或拒绝退款 |
| 渠道退款未知 | 请求幂等键、渠道受理号、查询次数 | 不重复发起,保持退款中并告警 |
退款闭环的判断句是:原支付事实不能删除,退款只能追加新的资金事实;库存、权益和履约是否回滚,必须由各自领域依据订单快照和售后规则决定。
14.6.6 对账、审计与敏感数据边界
支付系统的最终自愈依赖对账。至少要做三种对账:支付单与订单对账、支付单与渠道流水对账、退款申请与渠道退款结果对账。对账不是单纯比金额,还要比订单身份、渠道流水号、币种、支付时间、退款累计金额和状态版本。
对账差异应分类处理:
| 差异 | 可能原因 | 处理 |
|---|---|---|
| 订单待支付、渠道已成功 | 回调丢失或消费积压 | 查询渠道并补发标准支付事件 |
| 订单已支付、渠道无流水 | 测试数据、错误环境或状态污染 | 冻结自动履约,进入人工核验 |
| 退款已申请、渠道处理中 | 渠道异步处理 | 保持退款中,按退避查询 |
| 订单退款金额大于支付金额 | 重复消费或分摊错误 | 阻断后续退款,生成高优先级告警 |
PCI DSS 资料库对支付卡数据保护、访问控制、监测和测试提出了正式要求;本章不把完整合规清单展开成实现细节,但坚持支付卡敏感数据最小化、令牌化、分权访问、审计留痕和环境隔离原则。[28] 订单快照也不应保存不必要的完整卡号、验证码或渠道密钥。
清分、结算与财务总账的边界
清算、分账和结算经常被混称为“支付成功后的钱”,但它们回答的问题不同:清分是把一笔支付按平台、商家、服务商、税费和营销补贴拆成可核算的份额;结算是按冻结期、履约完成、售后窗口和渠道到账周期把可结算份额释放给收款主体;财务总账则按照会计科目和凭证规则记录账务。支付系统可以产出资金事实和分账明细,但不应直接冒充财务总账。
建议以“结算批次 + 结算明细 + 结算凭证”组织模型:结算批次绑定销售主体、账期、币种和规则版本;明细引用订单、支付、退款和分账快照;凭证记录批次冻结、审核、出款、失败和重试结果。结算计算必须可重算但不能静默覆盖历史结果,规则变更后应创建新的版本和差异报告。对于 B2B2C 场景,平台佣金、商家应收、供应商分成和营销补贴要分别列示,不能只保存一个“商家到账金额”。
| 层级 | 权威事实 | 典型状态 | 主要消费者 |
|---|---|---|---|
| 支付 | 渠道是否受理、扣款、退款 | 处理中、成功、失败、退款中 | 订单、售后、对账 |
| 清分 | 每笔资金应归属于谁 | 待清分、已清分、待结算 | 结算、财务核算 |
| 结算 | 哪个主体何时可收到钱 | 冻结、待审核、已出款、失败 | 商家、供应商、财务 |
| 总账 | 会计科目和凭证是否平衡 | 待入账、已入账、冲正 | 财务报表、审计 |
这一区分能避免两个危险做法:一是订单支付成功后立刻把所有钱标记为商家可提现,绕过履约和售后冻结;二是为了让财务报表对上,直接修改支付单状态。正确方式是追加清分、结算或冲正事实,并通过对账任务解释每一次差异。
对账流程与差错闭环
日终或 T+N 对账应当是可重复执行的批处理,而不是一次性脚本。基本流程是:下载并校验渠道账单元数据,按渠道流水号和商户号解析,写入不可变的账单记录;读取平台支付、退款和分账事实;按币种和账单日期比对数量、金额、状态和退款累计;生成差异项;对可自动修复的差异执行查询或补发事件;其余差异进入人工工单并记录 SLA。
对账任务本身也需要幂等:同一渠道、日期、商户号和账单版本只能产生一个对账批次;账单下载成功不代表解析成功,解析成功不代表比对完成,比对完成也不代表差异已关闭。每个阶段都要有独立状态和重试入口。渠道账单可能晚到、重复下载或分片生成,因此不能以文件名作为唯一事实,应校验账单摘要、日期、渠道账号、版本和记录数。
自动修复必须有边界。仅缺失回调且渠道明确成功、金额和商户号都一致时,可以补发支付成功事件;金额不一致、币种不一致、平台不存在对应支付意图或退款累计超限时,应停止自动推进,冻结相关结算并创建高优先级工单。人工处理只能追加调整或冲正记录,不能直接删除平台账或覆盖原始渠道账单。
14.6.7 观测支付链路的完整性
支付链路需要同时具备业务指标和技术指标。业务指标包括支付发起成功率、支付成功率、支付处理中比例、回调延迟、退款成功率和对账差异率;技术指标包括接口延迟、渠道错误码分布、通知积压、重试次数、幂等命中率和死信数量。Google SRE 建议监控分布式系统时围绕延迟、流量、错误和饱和度组织指标,并使用面向用户可感知结果的告警。[7][8]
每次支付请求、渠道通知、订单状态迁移和库存确认都要通过 trace_id 关联。W3C Trace Context 规定了跨服务传播追踪上下文的标准字段,OpenTelemetry 语义约定则提供了统一的服务、HTTP、消息和数据库观测维度。[21][22] 因此,客服查询一笔“已扣款未出单”时,应能沿着订单号、支付单号、渠道流水号和 trace ID 找到完整证据,而不需要在多个系统中凭时间猜测。
14.7 履约、售后与历史事实
14.7.1 场景画像
订单支付成功只是用户交易旅程的中点,不是终点。后面的系统挑战包括:
- 实物商品怎么发货、签收、退货退款。
- 券码商品怎么发码、核销、退款。
- 酒旅、到店和预约型商品怎么预约、履约、取消和退款。
这里最重要的统一原则是:
订单之后,系统不再回头依赖最新商品真相,而是基于订单快照和履约事实继续推进。
14.7.2 关键技术点
履约、发码、核销、退款、库存回补、权益回补,看起来像很多独立动作,但真正落地时不能让订单中心自己直接编排所有下游,否则它会再次膨胀成“大管家”。
因此,第 6 节建议显式引入一个统一角色:
履约与售后服务
它的职责不是拥有订单主状态,而是承接支付之后的流程编排:
- 组织履约创建
- 接收外部履约回调
- 路由发码、核销、预约等不同履约分支
- 编排退款、库存回补、权益回补
- 把这些结果统一翻译成订单中心可消费的标准化事实
这套设计里需要先钉死 5 个原则:
-
订单中心拥有交易主事实和用户可见订单状态。 履约与售后服务只负责组织流程,不直接决定最终订单展示语义。
-
履约结果、核销结果、退款结果都只是事实输入。 外部仓配、发码、核销、退款系统只负责回传“发生了什么”,状态推进仍由订单中心完成。
-
售后必须基于订单快照、支付事实和履约事实。 不能在退款时重新查当前商品价格、当前详情页规则或当前库存展示。
-
实物、券码、预约型商品虽然路径不同,但必须复用同一套幂等、回调归一和补偿机制。
-
外部回调先进入履约与售后服务,再由订单中心消费标准化事件。 不让仓配、核销、退款系统直接改订单主状态。
可以把它和第 5 节做一个镜像理解:
- 结算服务解决“如何生成一笔订单”
- 履约与售后服务解决“订单支付之后如何被执行、被核销、被退款、被回补”
14.7.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
14.7.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
14.7.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: 推进订单 / 子单状态
14.7.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
14.7.7 特殊场景:预约 / 酒旅 / 到店履约
这几类场景看起来不像传统“发货”,但它们并不是独立订单系统,而只是履约与售后服务下的不同履约分支。
它们的共同点是:
- 都不再依赖最新商品真相
- 都依赖订单快照和支付后的履约事实推进
- 都需要把外部世界的状态翻译成订单中心可理解的标准化事件
其中最典型的事实包括:
- 预约 / 酒旅:
BookingConfirmedCheckedInCheckedOutBookingCanceled
- 到店 / 券码:
VoucherIssuedVoucherConsumedVoucherExpired
也就是说,履约与售后服务本质上承担了一个“协议翻译层”的角色:
- 下游系统说的是“已发码”“已预约”“已入住”“已核销”
- 订单中心认的是“这笔订单现在是否已履约、是否可退款、是否已完结”
14.7.8 履约与售后服务的统一架构原则
第 6 节之所以也需要一个明确的编排角色,原因和第 5 节是对称的:
- 第 5 节有结算服务 / 支付编排服务,解决“怎么生成一笔订单”
- 第 6 节有履约与售后服务,解决“订单支付之后如何被执行、核销、退款、回补”
如果没有这一层,订单中心就必须直接对接:
- 仓配
- 发码
- 核销
- 预约资源
- 退款
- 库存回补
- 权益回补
这样它会再次膨胀成“大管家”,把整个后交易阶段的复杂性都吸进去。
因此本章的统一结论是:
- 订单中心拥有订单主事实和用户可见订单状态
- 履约与售后服务拥有支付后的流程编排职责
- 下游仓配、发码、核销、退款、回补服务只提供事实,不直接拥有订单主状态
最后用一句话收口:
结算服务解决“如何生成订单”,履约与售后服务解决“订单支付之后如何被执行、被核销、被退款、被回补”。
14.8 端到端案例与特殊订单类型
14.8.1 实物电商主链路
用户搜索手机 → 看详情页 → 加购物车 → 进入结算页试算和预占 → 创建订单 → 支付成功 → 仓配发货 → 签收完成 → 可发起退货退款。
这一链路的核心风险在于:
- 列表页展示价与下单价不一致。
- 支付失败后库存未释放。
- 发货后退款和库存回补规则错乱。
14.8.2 券码 / 到店商品主链路
用户搜索餐饮券或景区票 → 详情页看使用规则 → 结算页试算与预占 → 支付成功 → 发码 → 到店核销 → 核销完成 → 按规则退款或不可退。
这一链路的核心风险在于:
- 发空码。
- 券码核销与订单状态不一致。
- 退款后券码或权益没有正确回收。
14.8.3 酒旅 / 预约型商品主链路
用户查看房型、日期、价格日历 → 结算页锁房 / 预占 → 创建订单 → 支付成功 → 预约 / 确认单生成 → 入住 / 出行 / 使用完成 → 按取消规则退款。
这一链路的核心风险在于:
- 详情页看见的日期价与下单时的真实价格不一致。
- 预约资源已变动但订单仍按旧状态继续推进。
- 售后取消没有依据订单时的规则解释。
14.9 可靠性、治理与演进
14.9.1 端到端失败矩阵
可靠性设计不能只按服务名列故障,而要按一笔交易已经走到哪一步来定义处理。下面的矩阵把“用户看到什么、系统保存什么、后台做什么”放在一起:
| 故障点 | 已发生事实 | 用户侧语义 | 后台动作 | 最终判定 |
|---|---|---|---|---|
| 搜索索引落后 | 商品中心已更新 | 允许展示旧候选,但详情和结算需刷新 | 异步重建索引 | 读投影收敛 |
| 购物车写入超时 | 未知是否写入 | 允许查询后重试 | 按请求键反查版本 | 意愿不重复 |
| 价格成功、库存失败 | 价格上下文已生成 | 提示暂不可购买 | 释放或过期价格上下文 | 结算拒绝 |
| 库存成功、创单超时 | 可能已预占 | 不立即重复提交 | 按结算凭证反查订单并补偿 | 订单或预占收敛 |
| 创单成功、支付未返回 | 订单事实已落库 | 显示待支付 / 处理中 | 查询支付,超时关闭 | 订单有明确终态 |
| 渠道已扣款、订单未支付 | 外部资金事实存在 | 显示处理中,不允许重复支付 | 查询渠道、补发支付事件 | 支付与订单对账一致 |
| 支付成功、履约创建失败 | 资金事实已成立 | 显示支付成功、履约处理中 | 重试履约并告警 | 不回滚支付事实 |
| 发货回调重复 | 履约事实已处理 | 状态不变 | 事件幂等、记录重复通知 | 不重复发货 |
| 退款处理中 | 申请已受理 | 显示退款中 | 查询渠道、限制重复申请 | 退款最终收敛 |
这里有一个重要边界:补偿不是把时间倒流,而是产生新的业务事实。例如支付成功后不能简单把订单删除来“回滚”,而应保留支付记录,依据库存、履约和退款策略生成取消、退款或人工介入事实。Saga 的核心价值就在于把长事务拆成可补偿步骤,但补偿动作本身也必须幂等并可观测。[1][2]
14.9.2 幂等、防重与乱序处理
幂等要按层设计,而不是只加一个 Redis 锁:
| 层次 | 机制 | 解决的问题 | 不能解决的问题 |
|---|---|---|---|
| 客户端 | 防抖、按钮状态、提交令牌 | 减少重复点击 | 无法防网络重试和恶意请求 |
| 网关 | 用户 / IP / 设备限流 | 削减重复流量 | 无法保证业务只执行一次 |
| 服务接口 | 幂等键、参数摘要、结果缓存 | 同一请求重复调用 | 无法处理跨接口的业务冲突 |
| 数据库 | 唯一键、版本条件更新 | 最终事实防重 | 不能替代补偿和消息投递 |
| 消费端 | event_id、聚合版本、处理表 | 重复和乱序消息 | 不能自动修复错误业务规则 |
| 对账任务 | 状态查询、差异清单 | 长时间未知状态 | 不能替代实时用户体验 |
对于事件消费者,推荐使用“事件 ID 去重 + 聚合版本检查 + 业务前置条件”三道判断:先判断是否处理过,再判断版本是否更新,最后判断当前订单状态是否允许这次迁移。版本落后但事件未处理时不能简单丢弃,应按事件类型进入重放或反查队列;因为有些事件虽然晚到,仍可能代表一个尚未被当前读模型吸收的事实。
14.9.3 重试、退避、死信与人工接管
重试策略应由错误类型驱动:
- 参数错误、签名错误、权限错误:不重试,记录并告警。
- 资源暂时不足、连接超时、服务过载:有限次数重试,使用指数退避和抖动。
- 外部渠道处理中:按渠道建议间隔查询,不盲目重复发起支付或退款。
- 状态未知且超过自动窗口:进入对账队列或人工接管。
- 业务规则冲突:冻结当前聚合,保留证据,等待修复或人工决定。
重试预算应该在整条调用链上计算。例如前端最多重试 2 次,网关重试 1 次,服务内部重试 3 次,下游消息再重试 5 次,并不代表系统最多执行 11 次;嵌套重试可能形成乘法放大。AWS 的可靠性指导把限制重试、设置退避和避免跨层重复重试作为过载防护的一部分。[10] 本章建议把 attempt、retry_budget、next_retry_at 和 last_error_class 写入任务记录。
死信不是垃圾桶。死信消息必须带有原始事件、失败次数、最后错误、首次失败时间、所属聚合和可执行动作。运维人员重放前要确认当前状态、版本和补偿风险,重放后要能看到结果。对于支付、退款和库存这类敏感动作,人工操作也必须经过权限审批并留下审计记录。
支付与退款的安全治理
支付系统的安全边界不能只靠“回调验签”四个字概括。至少要把身份、完整性、重放、权限和审计分开治理:
| 风险 | 需要证明的事实 | 控制措施 | 发现问题后的动作 |
|---|---|---|---|
| 伪造回调 | 通知确实来自渠道且未被篡改 | 渠道证书、公钥轮换、验签、时间窗口 | 拒绝处理并记录安全事件 |
| 金额篡改 | 渠道金额等于本地支付快照 | 订单号、商户号、币种、金额交叉校验 | 冻结支付尝试和相关结算 |
| 重放通知 | 同一渠道流水未重复产生副作用 | 通知指纹、流水号唯一键、版本条件更新 | 返回幂等成功但不重复发事件 |
| 越权退款 | 操作人和订单状态具备退款权限 | 角色分权、审批、订单项范围校验 | 拒绝并保留审计记录 |
| 敏感数据泄露 | 日志和快照不包含不必要的支付数据 | 令牌化、脱敏、密钥托管、最小权限 | 吊销凭证并启动安全响应 |
| 重试双扣 | 外部未知时没有重复创建扣款 | 幂等键、先查单后切换、重试预算 | 转处理中或人工核验 |
密钥轮换不能只替换配置文件。渠道适配器需要同时支持旧公钥和新公钥的过渡窗口,并记录每次验签使用的 key version;否则轮换期间的失败会被误判成渠道故障。支付通知的原文也不应永久暴露在日志系统,应该保留受访问控制保护的原始证据引用、摘要和脱敏字段。
退款和调账尤其需要职责分离:提出退款申请的人不应同时批准高风险退款,执行渠道退款的人不应拥有修改原始支付金额的权限。人工纠错不能直接改支付主表的金额或状态,而应生成带原因、审批人、前后差异和关联工单的纠错事件,再由状态机按规则应用。这样既能保留审计证据,也能避免“为了让页面显示正确”破坏对账依据。
多渠道容量隔离与降级
渠道接入的容量曲线通常不同:某个渠道可能限制每秒请求数,另一个渠道可能在高峰时尾延迟显著上升,银行转账还可能依赖批量文件而不是实时接口。因此支付网关不能使用一个共享线程池和一个全局超时。应按渠道、商户号和操作类型隔离连接池、并发配额、超时和重试预算;支付创建、查单、退款和账单下载也要分开,避免账单任务占满实时支付资源。
降级必须保护资金事实而不是只保护接口成功率:
- 渠道预下单不可用时,可以隐藏该支付方式或切换到经过确认的备用渠道,但不能对未知扣款直接重发。
- 渠道回调处理积压时,可以让收银台显示处理中,不能把超时订单直接标成失败并释放资源。
- 对账文件晚到时,可以延后结算批次,但不能静默跳过差异。
- 退款渠道异常时,应保持退款中并展示预计处理状态,不能为了降低失败率重复扣减可退余额。
因此支付服务的核心 SLO 不应只有接口可用率,还应包含成功事实不重复率、未知状态闭合时延、退款累计金额不变量、回调积压时间和对账差异关闭时延。Google SRE 关于过载处理的观点可以支撑“先保护关键事实,再降低非关键体验”的排序;本章进一步将其推导到支付场景,即宁可让用户暂时看见处理中,也不能为了即时返回而制造重复资金动作。[6]
搜索读链路的故障矩阵
搜索故障的用户影响通常先表现为“找不到、排得不对或页面变慢”,但处理方式不能只看搜索接口是否返回 200。应把索引投影、查询引擎和 Hydrate 下游分开判定:
| 故障点 | 用户语义 | 自动动作 | 不能做什么 |
|---|---|---|---|
| 索引事件积压 | 允许部分结果滞后 | 扩容消费者、按聚合版本追平、告警 | 不能把旧索引当商品正式状态 |
| 旧事件乱序到达 | 单个商品可能短暂旧 | 版本条件更新或丢弃旧写入 | 不能让旧文档覆盖新文档 |
| 重建索引失败 | 继续使用旧读别名 | 重试构建、保留旧索引、人工切换 | 不能半成品切读流量 |
| ES 慢查询或不可用 | 返回缓存、缩小召回或明确稍后重试 | 熔断高成本查询、限流、回滚排序 | 不能用无约束深分页拖垮集群 |
| Hydrate 部分超时 | 卡片显示参考价或库存确认中 | 按字段局部降级、记录依赖超时 | 不能把旧价格当实时成交价 |
| 热点 query / suggest 洪峰 | 结果可能延迟或被限流 | 独立配额、本地缓存、热点隔离 | 不能挤占结算和支付资源 |
| 零结果率异常升高 | 引导纠错或类目浏览 | 检查索引版本、词典、过滤互斥和事件链路 | 不能放宽合规和上下架过滤 |
| 实验排序异常 | 回退稳定排序版本 | 按 exp_id 停止实验、清理版本缓存 | 不能只看 CTR 忽略投诉和成交口径 |
索引对账应至少抽样比较主数据版本、文档可搜索状态、关键类目和删除事件;查询治理则要保留归一化 query、scene、rank 版本和超时分支。这样搜索的恢复动作才有证据,而不是“重启服务看看”。搜索可以在故障时降低体验,但不能越过商品合规、交易价格和库存事实边界;这与支付故障时保留资金事实的原则一致,都是先保护权威事实,再保护非关键体验。
14.9.4 可观测性:从技术指标到业务不变量
常规的 CPU、内存和接口 RT 不足以判断交易系统是否健康。至少需要四类指标:
- 用户结果:搜索成功率、结算成功率、创单成功率、支付成功率、履约及时率、退款完成时延。
- 状态流转:各状态停留时间、状态迁移失败数、重复事件数、乱序事件数、待处理聚合数。
- 资源与消息:库存预占量、预占超时量、Outbox 积压、消费者延迟、死信数、对账差异数。
- 安全与合规:签名失败、越权访问、异常支付频率、敏感字段访问、人工纠错操作。
告警要围绕“用户结果和不变量”设定,而不是为每个指标设置阈值。例如“支付成功但 10 分钟后仍无履约任务”的比例,比单独监控履约服务 CPU 更能发现真实故障;“退款累计金额大于支付金额”的不变量违反,应立即告警,即使服务总体错误率很低。Google SRE 的监控和告警原则要求告警能够驱动明确行动,并尽量减少无法行动的噪声。[7][8]
链路追踪要贯穿用户请求、结算凭证、订单、支付单、事件和下游任务。日志字段至少统一:trace_id、request_id、user_id_hash、order_id、sub_order_id、payment_id、event_id、attempt、state_version 和 error_code。个人信息与支付敏感字段要脱敏,日志不能为了方便排查而复制完整地址、身份证号、卡号或验证码。
支付治理还需要把指标组织成可验证的不变量,而不是只看各系统自己的成功率。可以每天或每小时计算以下关系:支付意图的成功金额不应大于订单应付金额;一笔支付的成功金额减去成功退款金额不应小于零;同一渠道流水号只能对应一个本地渠道交易;已发布的支付成功事件数量应能与支付状态历史中的成功迁移数量解释;已结算金额不应超过已确认支付金额扣除冻结和退款后的可结算金额。指标出现偏差时,先生成差异快照和影响范围,再决定是否暂停履约或结算,不能直接用批量更新把数字抹平。
这类不变量最好由独立的治理任务计算,而不是由支付主链路临时查询多个系统。治理任务读取不可变支付流水、退款流水、结算明细和事件投递记录,生成带时间窗口与版本的检查结果;检查结果本身也要可审计。这样既能避免高峰期把在线支付链路拖慢,也能让运营在故障复盘时回答“问题从哪个时间窗口开始、影响了多少金额、哪些订单已经自动收敛”。
搜索读链路也需要一组独立于交易写链路的观测面板:索引事件延迟和版本差异、Query 归一化失败率、各路召回贡献、零结果率、Rank P99、search_after 游标失效率、Hydrate 端到端成功率、各依赖超时比例、热点缓存命中率、实验分桶差异以及用户从搜索到详情的价格投诉率。指标必须按 scene、站点、类目、索引版本和实验版本切分,否则总体平均值会掩盖某个热门类目或某个排序版本的故障。
搜索问题的告警也应关联可行动的恢复入口:索引延迟升高触发消费者扩容或重放,版本差异触发对账队列,ES 慢查询触发高成本 DSL 限制,Hydrate 依赖超时触发字段级降级,零结果异常触发词典和过滤规则检查。把指标、用户语义和恢复动作绑定起来,才能避免“搜索接口成功率 99.9%”却已经让大批用户找不到可售商品的假健康状态。
14.9.5 灰度、回滚与数据修复
交易链路的灰度不能只按服务实例比例切流,还要考虑订单状态和数据版本。推荐按用户、设备、销售主体或订单号做稳定路由,并为每次变更记录:协议版本、状态机版本、快照 schema、事件 schema、回滚条件和观测窗口。
涉及状态机或金额计算的变更,应采用兼容优先的双读 / 双写策略:先让新版本能够读取旧快照和旧事件,再逐步写出新字段;确认新旧结果一致后,才切换主读路径。事件 schema 需要版本化,消费者要能在一段迁移窗口内处理至少两个版本。回滚应用版本不等于回滚已经产生的订单事实,数据修复应通过受控的纠错事件完成。
数据修复至少要遵循:先冻结自动动作、导出证据、计算影响范围、生成可审计的修复计划、小批量执行、复核结果、解除冻结。不能直接批量改订单状态来“修好看板”,否则会破坏支付、履约、售后和对账证据链。
14.9.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 消费和对账任务也应分别设置连接池、线程池和限流器。
容量估算最重要的不是得到一个“准确数字”,而是说明假设改变时哪个边界先失效。若支付成功率不变但结算转化率突然提升,库存预占和订单库写入可能先成为瓶颈;若支付渠道变慢,待处理支付和订单保留量会先膨胀。每个容量结论都要配套一个降级动作和一个扩容触发指标。
14.9.7 演进路线:从单体交易模块到领域协作
不建议一开始就把所有能力拆成大量微服务。可以按事实主权和变化速度分阶段演进:
- 第一阶段:单体内按商品、购物车、结算、订单、支付、履约模块分层,先建立快照、状态机和幂等契约。
- 第二阶段:将搜索读模型、购物车存储、支付渠道适配和履约回调接入独立出来,降低读写和外部不稳定性的影响。
- 第三阶段:按销售主体和履约边界拆分订单子域,引入 Outbox、事件版本和对账平台。
- 第四阶段:对高峰流量、跨境履约和复杂预约引入工作流编排,但保留订单事实和支付事实的主权边界。
拆分标准不是“服务越多越先进”,而是某个边界是否拥有独立的数据主权、容量曲线、发布节奏和故障隔离收益。Enterprise Integration Patterns 对消息通道、路由、转换、幂等接收者和事务消息等模式的总结,可作为跨域协作设计的共同语言。[3] 本章结合电商场景的推导是:只有当一个边界能够定义自己的事实、事件、补偿和运维责任时,才值得成为独立服务。
14.10 方法论总结、ADR、评审清单与参考资料
如果面试官问“C 端交易链路最难的地方是什么”,比较好的回答不是背一串系统名词,而是先给出总心智:
C 端系统的难点,不是把搜索、购物车、订单、支付这些服务拆出来,而是让用户在整条交易旅程里既感受到响应足够快,又始终不会用旧价格、旧库存、旧规则成功交易,更不会在支付后、履约后和售后阶段失去解释依据。
这章建议记住 6 句话:
- 搜索结果页是弱一致投影,详情页是交易前解释,创单时再做强校验。
- 购物车保存的是意愿,不是资源占用。
- 结算页是一次短生命周期的 Saga,负责把价格、库存、营销和地址收敛成可提交交易。
- 订单是交易事实,不是“重新查商品”的入口。
- 支付系统提供资金事实,订单系统自己推进状态。
- 订单之后,任何履约和售后都优先基于快照和履约事实解释。
当你能把这 6 句话和上面的时序图、快照、预占、幂等、回补策略串起来时,这一章就不只是一个“电商流程介绍”,而是一套真正可落地、可答辩、可治理的 C 端全生命周期设计。
14.10.1 ADR-搜索:采用“异步版本化索引 + 受控实时 Hydrate”
- 背景与问题:搜索和导购需要承受远高于交易写路径的读流量,索引字段又存在异步延迟;价格、库存、营销和履约摘要随用户和时间变化,不能全部固化在 ES,也不能每次列表请求都串行访问所有权威系统。
- 决策驱动因素:召回吞吐、尾延迟、结果可解释性、数据新鲜度、索引可重建、下游隔离和交易前事实安全。
- 候选方案:所有字段实时查询;所有字段写入 ES;异步索引承载检索骨架并对动态字段做批量 Hydrate。
- 最终决策:商品、上架和生命周期事件通过版本化管道构建可重建索引;统一 scene 查询服务执行 Query → Recall → Rank;当前页通过有预算的批量 Hydrate 补齐动态字段;详情、结算和创单再次访问权威域并进行强校验。
- 获得的能力:高吞吐召回、可控的实时字段、索引别名切换与回滚、按依赖局部降级、排序实验可复现、索引和主数据可对账。
- 主动牺牲的能力:搜索结果可能短暂滞后,列表价可能只是参考价;需要维护索引重建、版本追平、缓存失效和 Hydrate 降级机制;不能支持任意深页的强快照语义。
- 已接受的风险:索引事件乱序、ES 慢查询、热点 query 和实时依赖超时会造成局部体验下降;通过版本条件更新、别名切换、
search_after限制、舱壁隔离和用户话术控制风险。 - 验证指标:索引版本延迟、零结果率、Recall 候选量、Rank P99、Hydrate 成功率、动态字段缺失率、搜索到详情价格差异率、实验回滚时间和主数据对账差异数。
- 重新评估条件:搜索结果需要法律或业务上不可接受的实时性,或动态字段规模使 Hydrate 成本持续超过索引冗余收益时,再评估按场景拆分读模型;不能直接把 ES 升级为交易事实库。
14.10.2 ADR-00:支付采用“支付意图—支付尝试—渠道交易”三级模型
- 背景与问题:一个订单可能更换支付方式、重复打开收银台、收到重复或乱序通知,渠道同步返回也可能只是受理结果。若订单只保存一个渠道流水号,未知状态和部分退款会被迫覆盖原事实。
- 决策驱动因素:资金安全、渠道可替换、幂等重试、对账可追溯、部分退款和多币种扩展能力。
- 候选方案:订单直接绑定渠道交易;支付中心只维护一张支付表;支付意图、支付尝试和渠道交易分层建模。
- 最终决策:支付意图绑定订单应付快照,支付尝试记录一次方式和路由决策,渠道交易保存外部流水与原始证据;退款单和结算明细继续作为独立事实对象。
- 获得的能力:可以在不覆盖历史事实的前提下切换支付方式、处理未知状态和多次部分退款,并且能够按渠道流水号完成回调、查单和对账闭合。
- 主动牺牲的能力:数据模型和查询编排更复杂,前端需要理解处理中状态,财务与客服需要使用多个关联号排查问题。
- 已接受的风险:对象之间可能出现暂时未收敛的关联;通过唯一键、版本条件更新、Outbox、主动查单和对账批次控制风险。
- 验证指标:重复扣款率、支付尝试未知状态闭合时间、支付与渠道流水匹配率、部分退款超限数、渠道路由切换后的成功率和对账差异关闭时间。
- 重新评估条件:出现新的资金来源、跨境币种或监管要求,导致当前三级模型无法表达资金归属时,新增明确的事实对象,不把新字段继续堆到支付主表。
14.10.3 ADR-01:采用“结算凭证 + 订单本地事务 + 异步补偿”
- 背景与问题:结算需要同时接入商品、价格、库存、营销和履约规则;如果把所有调用放入全局强一致事务,会导致锁持有时间长、尾延迟放大,并且无法可靠覆盖外部支付渠道。
- 决策驱动因素:吞吐、尾延迟、资源隔离、补偿能力、团队可运维性和外部系统可控程度。
- 候选方案:XA / 2PC、经典 Saga、TCC、预占资源加改良 Saga。
- 最终决策:结算阶段产生带 TTL 的价格、库存和权益凭证;订单中心用短本地事务保存订单事实和快照;通过 Outbox 发布事件,由下游幂等执行 Confirm / Cancel,使用对账任务处理长期未知状态。
- 获得的能力:较短的数据库事务、较高吞吐、明确的资源释放入口、对外部支付友好、能够按域独立扩容。
- 主动牺牲的能力:不保证跨所有域的瞬时强一致;用户可能短暂看到“处理中”;补偿和对账系统必须长期运行。
- 已接受的风险:重复消息、乱序事件和补偿延迟会增加状态机复杂度;通过版本、幂等、审计和人工接管控制风险。
- 验证指标:结算成功率、凭证失效率、预占超时量、补偿成功率、Outbox 延迟、支付成功后履约创建时延和对账差异率。
- 重新评估条件:出现高比例资金级强一致需求、跨域补偿成本持续高于收益,或业务需要法律上不可拆分的原子转移时,再评估 TCC 或专用工作流。Seata 官方资料对 AT、TCC、SAGA 等模式的边界有明确说明;它们可以作为实现选项,但不能替代业务状态机设计。[29][30][31]
14.10.4 ADR-02:订单保存不可变快照,搜索与详情使用分层读模型
- 背景与问题:搜索需要吞吐和排序,详情需要解释当前商品,订单和售后需要解释历史成交事实。
- 最终决策:搜索索引只保存可容忍滞后的召回与排序字段;详情和结算通过批量 Hydrate 获取当前正式契约;订单保存商品、价格和履约快照;快照版本不可被商品更新覆盖。
- 获得的能力:读写隔离、搜索可扩展、历史订单可解释、交易前可以重新校验关键字段。
- 主动牺牲的能力:搜索列表可能短暂落后;详情聚合多一次或多次调用;快照和索引需要额外存储及重建机制。
- 验证指标:索引延迟、Hydrate 成功率、列表与结算价格差异率、快照完整率、订单争议可解释率。Elasticsearch 的乐观并发控制用于防止版本覆盖,分片请求缓存用于降低重复查询压力;这些能力只解决读模型和版本问题,不会替代库存或订单事实。[18][19]
14.10.5 ADR-03:状态机由订单域拥有,支付与履约通过标准事实事件驱动
- 背景与问题:支付、仓配、发码、预约和退款都有自己的状态,但用户需要一个统一、可解释的订单状态。
- 最终决策:订单中心拥有用户可见主状态和状态迁移规则;支付中心拥有支付事实;履约与售后服务拥有流程编排;外部系统通过验签、幂等和事件归一后输入标准事实。
- 获得的能力:状态主权清晰、渠道可替换、事件可重放、售后基于事实解释。
- 主动牺牲的能力:订单详情可能短暂显示处理中;跨域排障需要 trace、事件和对账工具;各域要维护自己的幂等与补偿。
- 重新评估条件:如果某个履约领域形成独立产品、拥有独立状态和独立 SLO,可以把它拆为更明确的子域,但不能通过让外部系统直接写订单主状态来“简化”当前链路。
14.10.6 评审清单
评审一条新的 C 端交易链路时,可以逐项追问:
- 每个关键事实的权威系统是谁?其他系统保存的是投影、快照还是引用?
- 展示数据、交易前提和交易事实分别允许多大延迟?
- 结算凭证绑定了哪些身份、版本、金额、资源和过期信息?
- 同一请求、同一事件、同一订单状态被重复处理时会发生什么?
- 超时后如何反查,消息失败后如何重试,长期未知后谁来对账?
- 订单快照是否足以解释客服、售后、退款和争议?是否误存敏感支付数据?
- 支付成功后库存确认、权益确认和履约创建的幂等键分别是什么?
- 是否有部分支付、部分退款、拆单、预售、0 元订单和预约商品的状态模型?
- 是否能从
trace_id、订单号、支付单号和事件 ID 还原一条完整证据链? - 每个降级、回滚和人工接管动作是否有明确的触发条件、权限和审计记录?
- 搜索请求是否有统一
scene、查询版本、排序版本和可回放的query_id? - 索引文档的来源版本、事件幂等、别名切换和全量重建是否可验证、可回滚?
- 深分页、热点 query、suggest、缓存 key 和实验分桶是否有边界与独立配额?
- 搜索降级时是否明确区分参考价、库存摘要和可成交事实,并在详情与结算阶段重新校验?
14.10.7 参考资料
[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] Betsy Beyer et al., “Site Reliability Engineering”, Google, 2016, https://sre.google/sre-book/table-of-contents/
[6] Google SRE, “Handling Overload”, Google SRE Book, https://sre.google/sre-book/handling-overload/
[7] Google SRE, “Monitoring Distributed Systems”, Google SRE Book, https://sre.google/sre-book/monitoring-distributed-systems/
[8] Google SRE, “Practical Alerting from Time-Series Data”, Google SRE Book, https://sre.google/sre-book/practical-alerting/
[9] Amazon Web Services, “Making Retries Safe with Idempotent APIs”, Builders’ Library, https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/
[10] Amazon Web Services, “Limit Retries”, Well-Architected Framework, https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_mitigate_interaction_failure_limit_retries.html
[11] Jeffrey Dean, Luiz André Barroso, “The Tail at Scale”, Communications of the ACM, 2013, https://research.google/pubs/the-tail-at-scale/
[12] Giuseppe DeCandia et al., “Dynamo: Amazon’s Highly Available Key-value Store”, SOSP, 2007, https://www.amazon.science/publications/dynamo-amazons-highly-available-key-value-store
[13] Apache Kafka, “Design: Message Delivery Semantics”, official documentation, https://kafka.apache.org/08/design/design/
[14] Apache Kafka, “Upgrade Notes: Idempotent and Transactional Producers”, official documentation, https://kafka.apache.org/38/getting-started/upgrade/
[15] Redis, “Hashes”, official documentation, https://redis.io/docs/latest/develop/data-types/hashes/
[16] Redis, “Transactions”, official documentation, https://redis.io/docs/latest/develop/using-commands/transactions/
[17] Redis, “EXPIRE command”, official documentation, https://redis.io/docs/latest/commands/expire/
[18] Elastic, “Optimistic Concurrency Control”, Elasticsearch Reference, https://www.elastic.co/docs/reference/elasticsearch/rest-apis/optimistic-concurrency-control
[19] Elastic, “Shard Request Cache”, Elasticsearch Reference, https://www.elastic.co/docs/reference/elasticsearch/rest-apis/shard-request-cache
[20] Oracle, “InnoDB Transaction Model”, MySQL Reference Manual, https://dev.mysql.com/doc/refman/26.7/en/innodb-transaction-model.html
[21] World Wide Web Consortium, “Trace Context”, W3C Recommendation, https://www.w3.org/TR/trace-context/
[22] OpenTelemetry, “General Semantic Conventions”, official specification, https://opentelemetry.io/docs/specs/semconv/general/
[23] IETF, “HTTP Semantics”, RFC 9110, 2022, https://datatracker.ietf.org/doc/rfc9110/
[24] IETF, “Problem Details for HTTP APIs”, RFC 9457, 2023, https://datatracker.ietf.org/doc/html/rfc9457
[25] Cloud Native Computing Foundation, “CloudEvents”, official specification, https://cloudevents.io/
[26] Chris Richardson, “Pattern: Transactional outbox”, microservices.io, https://microservices.io/patterns/data/transactional-outbox
[27] Chris Richardson, “Pattern: Polling publisher”, microservices.io, https://microservices.io/patterns/data/polling-publisher.html
[28] PCI Security Standards Council, “Document Library”, official standards library, https://www.pcisecuritystandards.org/document_library/
[29] Apache Seata, “What is Seata?”, 中文官方文档, https://seata.apache.org/zh-cn/docs/overview/what-is-seata/
[30] Apache Seata, “Saga 模式”, 中文官方文档, https://seata.apache.org/zh-cn/docs/user/mode/saga/
[31] Alibaba Cloud, “分布式事务参与者模式”, 中文官方文档, https://help.aliyun.com/zh/document_detail/132909.html
[32] Stripe, “Idempotent requests”, official API documentation, https://docs.stripe.com/api/idempotent_requests
[33] 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 | 命令查询职责分离。写模型关注一致性和状态变更,读模型关注查询效率和展示体验。 |
| ADR | Architecture Decision Record,架构决策记录,用于记录关键取舍、备选方案和后续影响。 |
数据与一致性
| 术语 | 说明 |
|---|---|
| SSOT | Single Source of Truth,权威数据源。系统设计中必须明确每类事实由谁负责。 |
| 最终一致性 | 系统允许短时间不一致,但必须有明确的收敛路径、最大漂移时间和修复机制。 |
| 幂等 | 同一业务请求重复执行多次,最终业务结果保持一致。支付、库存、消息消费和补偿任务必须重点设计。 |
| Outbox | 在本地事务内写业务数据和待发布事件,再由 Relay 异步投递,降低业务写入和消息发送之间的双写风险。 |
| Saga | 长事务拆分为多个本地事务,并为每个步骤设计补偿动作,常用于订单、库存、营销和支付协作。 |
| 对账 | 比较两个或多个系统中的事实口径,发现差异并推动补偿、人工处理或风险拦截。 |
| 补偿 | 主链路未完成或状态不一致时,通过异步任务、事件重放、人工审核等方式让系统回到正确状态。 |
| DLQ | Dead Letter Queue,死信队列。承载无法继续自动处理的失败消息,并进入可观测、可重放、可审计的恢复流程。 |
可靠性与治理
| 术语 | 说明 |
|---|---|
| SLI | Service Level Indicator,服务水平指标,例如成功率、P99 延迟、消息积压时间。 |
| SLO | Service Level Objective,服务水平目标,用来约束系统在一段时间内应达到的可靠性水平。 |
| SLA | Service Level Agreement,对外承诺的服务水平协议,通常包含可用性、响应时间和赔付条款。 |
| 错误预算 | SLO 允许的失败空间,用于平衡稳定性和发布速度。 |
| RTO | Recovery Time Objective,恢复时间目标,表示故障后多久恢复服务。 |
| RPO | Recovery Point Objective,恢复点目标,表示故障时最多可接受丢失多少数据。 |
| 限流 | 控制入口或内部调用流量,防止系统被超过承载能力的请求打穿。 |
| 熔断 | 当下游持续异常时快速失败,保护调用方资源并避免故障扩散。 |
| 降级 | 在非核心能力异常时牺牲部分体验,优先保护核心业务成功率。 |
| 舱壁隔离 | 将线程池、连接池、队列或部署单元隔离,避免一个依赖拖垮整个系统。 |
电商核心概念
| 术语 | 说明 |
|---|---|
| SPU | Standard Product Unit,标准商品单元,表达一组共同商品属性。 |
| SKU | Stock Keeping Unit,库存计量单元,通常对应可售规格、库存和价格粒度。 |
| Offer | 面向售卖的承诺,通常组合商品、价格、渠道、库存、履约和售后规则。 |
| 商品快照 | 下单时保存的商品关键事实,用于历史订单解释、纠纷处理、退款和对账。 |
| 可售量 | 当前可对外承诺售卖的数量,通常由库存事实、预占、冻结、渠道和安全库存共同决定。 |
| 预占 | 在订单或结算阶段临时锁定库存、权益或资源,后续根据支付和订单结果确认或释放。 |
| 计价 | 将基础价、营销、会员权益、运费、税费和抵扣合成为可解释、可追溯的应付金额。 |
| 资损 | 因系统设计、实现、配置、运营或外部依赖问题导致平台、商家、用户或资金方产生资产损失。 |
| 供给治理 | 围绕商品创建、导入、审核、发布、同步、补偿、审计和质量巡检建立的运营侧治理能力。 |
| 供应商同步 | 将外部供应商的资源、价格、库存、上下架和履约状态转化为平台可治理数据的链路。 |
附录 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 与工程治理
- Google SRE: Site Reliability Engineering。
- Google SRE: The Site Reliability Workbook。
- AWS Well-Architected Framework: Reliability Pillar。
- OpenTelemetry: Documentation。
- Prometheus: Documentation。
- Grafana: Documentation。
数据库、中间件与基础设施
- MySQL: Reference Manual。
- Redis: Documentation。
- Apache Kafka: Documentation。
- Elasticsearch: Documentation。
- Kubernetes: Documentation。
- Docker: Documentation。
分布式事务、消息与一致性
- 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。
电商系统与业务架构
- 商品、库存、计价、营销、订单和支付章节中的模型来自通用电商业务抽象,可结合所在公司的品类、供应商、履约和监管要求调整。
- 资金、优惠、库存和退款相关设计应同时咨询财务、法务、风控和客服团队,不能只由研发侧独立决定。
- 所有涉及支付渠道、银行卡、个人信息、发票、税务和跨境业务的落地方案,都应以当地法律法规和支付机构要求为准。
代码与工程实践
- Go: Documentation。
- Python: Documentation。
- C++: cppreference。
- Bash: GNU Bash Manual。
- Mermaid: Documentation。
维护建议
- 新增章节时,在本附录补充对应的官方文档、经典书籍或工程案例。
- 外部链接优先选择官方文档、原始论文、作者主页或长期维护的资料页。
- 对强时效内容,例如云产品能力、开源组件版本、支付渠道规范,应在正文中注明“以官方最新文档为准”。
附录 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 统一预处理。若升级版本,请同步检查这两个静态文件。
附录 D 系统设计题库
本附录集中收录系统设计题库的训练方法、质量导向的规范题目、模拟面试脚本,以及重构前逐题详细参考材料。优先使用前半部分的核心案例、约束变体和快问快答;历史材料用于查阅未被独立保留的实现细节、追问与扩展案例,不作为统一作答模板。
基础面试题与追问
以下问题保留第一章原有的基础面试题,适合快速复习;后续的核心案例、约束变体和历史详细材料用于展开训练。
系统设计基础
- 什么是系统设计?系统设计面试到底在考什么? 追问:你会如何区分“会用组件”和“有设计能力”?
- 为什么系统设计里一定要先做容量估算? 追问:如果没有精确数据,你会如何给出合理区间?
- 高并发系统为什么不能只靠加机器解决? 追问:哪些瓶颈是水平扩展也解决不了的?
- 强一致性和最终一致性分别适合哪些业务? 追问:订单、评论数、搜索索引分别怎么选,为什么?
- 为什么说可扩展性会带来复杂度? 追问:从单体走向微服务后,通常新增了哪些治理问题?
组件与分布式治理
- Redis、Kafka、MySQL 在系统设计里分别解决什么类型的问题? 追问:什么时候不该引入它们?
- 为什么分布式系统必须重视超时、重试和幂等? 追问:如果客户端重试导致重复下单,你会怎么兜底?
- 负载均衡和缓存分别解决什么问题? 追问:它们引入了哪些新的故障模式?
技术方案与架构表达
- 为什么架构师不能只会画图或列接口? 追问:一份真正可评审的 TD 至少要回答哪些关键问题?
- 技术方案里的关键设计决策应该怎么写? 追问:如果你列了 A/B 方案,怎么证明你选的那个更合理?
- 为什么很多 TD 会在 rollout 和 rollback 上被打回? 追问:你会如何把灰度、观测、回滚写成可执行方案?
- 如果让你在 5 分钟内回答一道系统设计题,你会按什么顺序组织表达? 追问:这个顺序和你写真实 TD 时有什么共通点?
使用与训练路线
题库的目标是训练设计判断,而不是完成固定格式。先按可用时间和训练目标选择题型,再按该题的关键决策复盘。
三种练习
| 题型 | 用时 | 训练目标 | 作答要求 |
|---|---|---|---|
| 核心案例 | 30 至 60 分钟 | 在冲突约束中作出完整设计决策 | 目标、约束、权威状态、主链路、失败路径和取舍 |
| 约束变体 | 10 至 20 分钟 | 修改一个约束后调整已有方案 | 先指出受影响的决策,再说明保留与修改的部分 |
| 快问快答 | 2 至 10 分钟 | 判断一个概念、边界或反模式 | 给出结论、理由和适用边界,不展开无关组件 |
核心案例不是更长的模板题。它必须有不可协商的业务约束、一个可验证的失败情境,以及至少一个需要权衡的决策。快问快答也不应被强迫补充容量、Saga 或灾备方案。
候选人训练
练习核心案例时,按下面顺序展开:
- 确认用户目标、成功指标、非目标和不可接受的业务损失。
- 写出会改变设计的规模假设、峰值集中度和外部依赖。
- 标明关键事实由谁权威保存,以及状态如何迁移。
- 描述正常链路、同步确认点和可异步的派生副作用。
- 处理题目中的故障注入:超时、重复、乱序、积压或依赖失效。
- 说明当前方案为何成立,什么阈值会触发下一阶段演进。
变体练习先复述基线方案中的一个关键决定,再只修改受新约束影响的部分。快问快答则以“结论、理由、边界”收束,避免把单点知识题回答成架构漫谈。
面试官评估
面试官不应根据候选人是否提到了缓存、消息队列或分库分表评分。应观察候选人是否:
| 观察点 | 可接受表现 | 深入表现 |
|---|---|---|
| 问题定义 | 问出影响方案的约束 | 能排序冲突目标并说明影响 |
| 关键状态 | 指出权威数据与状态前置条件 | 给出审计、幂等和恢复责任 |
| 主链路 | 区分同步承诺与异步副作用 | 解释用户可见结果与未知结果 |
| 故障处理 | 回应题目中的具体失败 | 给出监测、重试、对账和人工边界 |
| 取舍 | 说明选择的成本 | 说明何时应推翻当前选择 |
追问一次只改变一个条件,并要求候选人明确哪些结论仍成立。不要在候选人尚未给出基线方案前连续追加多个灾难场景。
按能力选择
| 训练目标 | 推荐入口 |
|---|---|
| 容量、热点与限流 | Q-SD-CORE-01、Q-SD-CORE-09 |
| 一致性、幂等与恢复 | Q-SD-CORE-02、Q-SD-CORE-03 |
| 商品、库存与供给治理 | Q-SD-CORE-04 |
| 搜索、购物车与订单 | Q-SD-CORE-05、Q-SD-CORE-06、Q-SD-CORE-07 |
| 支付、退款与对账 | Q-SD-CORE-08 |
| 平台演进与综合案例 | Q-SD-CORE-10、Q-SD-CORE-11、Q-SD-CORE-12 |
复盘记录
每次训练只记录一个可验证改进动作,例如“下次先定义支付超时后的未知状态”,或“下次说明索引版本落后时的重建入口”。下次练习选择同一核心案例的约束变体,验证这个动作是否真的改变了决策。
原有题号、题目和迁移去向见迁移覆盖索引。索引用于保证主题覆盖,不代表每个旧题都应继续作为独立的长时面试题。
原有训练方法保留与覆盖审计
本附录合并了原题库的使用说明、通用题库、电商专项题库与模拟面试。重构时不以“每题一张同格式题卡”为目标,而以是否保留可训练的设计判断、可查阅的答案细节和可追问的故障情境为准。
| 原有材料 | 保留位置 | 审计结论 |
|---|---|---|
| 使用说明、训练路线与候选人/面试官模式 | 本附录的“使用与训练路线”“三种练习”“候选人训练”“面试官评估” | 保留,并改为按训练目标选择题型 |
| 220 道通用与电商题 | “迁移覆盖索引”与“历史详细参考材料” | 每个旧题号出现一次;逐题答案、追问、代码和复盘材料保留 |
| 30、45、60 分钟模拟面试 | “模拟面试” | 保留,并改用核心案例和单变量追问组织 |
| 系统设计综合、中间件可靠性、白板与容量估算方法 | 本节后续小节 | 保留其方法论内容,避免只留下题号映射 |
系统设计面试在考什么
系统设计题考察的不是背诵某个组件,而是能否在信息不完整时定义问题、组织方案并说明取舍。重点包括:
| 维度 | 要回答的问题 |
|---|---|
| 问题理解 | 目标、范围、非目标和不可接受的业务损失是什么? |
| 结构化思考 | 入口、核心链路、权威状态、读模型和治理机制如何协作? |
| 工程判断 | 一致性、可用性、成本和复杂度为何这样取舍? |
| 风险意识 | 热点、超时、重复、积压、数据修复和降级如何闭环? |
| 沟通表达 | 结论是否有顺序,且每个选择都能说清依据与边界? |
回答与白板的展开顺序
先报结构,再按主链路展开;第一张图只画边界、同步关系、权威数据和主要依赖,局部细节只在被追问时补充。
- 确认业务目标、成功指标、非目标和风险底线。
- 给出会改变设计的规模、峰值、读写比例和外部依赖假设。
- 画入口、服务边界、权威存储、缓存、消息和下游的主链路。
- 区分同步承诺与异步副作用,说明状态前置条件和幂等边界。
- 处理超时、重试、乱序、积压和依赖失效,给出监测与恢复责任。
- 收束当前瓶颈、降级优先级、验证指标和规模扩大后的演进触发条件。
白板不需要一开始画完所有服务。它应让读者看出每条关键连线的方向、同步方式、失败去向,以及业务事实保存在哪里。
容量估算口径
容量估算以数量级正确和能够改变设计为目标。先从业务行为推导入口、读写、重试、异步和外部依赖压力,再把结论落到缓存、队列、数据库、网络与隔离预算。
峰值下单 QPS = 高峰活跃用户 x 下单转化率 / 峰值时间窗口
真实写压力 = 下单 + 库存校验 + 幂等校验 + 支付回调 + 重试
异步压力 = 业务事件 + 消费重试 + 索引同步 + 对账与补偿
大促、秒杀等场景不能按全天均匀流量估算。除入口 QPS 外,必须估算热点商品、消息积压、存储增长、带宽、第三方限额以及恢复期间的补偿流量。
存储、中间件与可靠性的答题边界
- MySQL: 用于需要事务、约束、审计和恢复的订单、支付、库存账本等权威状态;缓存和搜索不能替代其状态责任。
- Redis: 用于热点缓存、原子令牌、限流和短期状态;它提升吞吐和延迟表现,但不单独证明交易正确性。
- 消息队列: 用于削峰、异步事件传播、重试和解耦;仍需 Outbox、业务幂等、积压治理和补偿闭环。
- Elasticsearch: 用于全文检索、多字段筛选、排序和聚合;它是派生读模型,需允许索引延迟并支持重建和降级。
谈可靠性必须落到具体对象:超时限制等待,重试应对瞬时失败,幂等防止重复副作用;熔断保护调用方,降级保护核心业务。交易结果未知时,不能把调用超时直接解释为失败或成功,应通过状态查询、对账和人工边界收敛。
高频题型与复盘
秒杀、库存和红包的核心矛盾是高并发下的资源正确性;短链接、ID 和评论的核心矛盾是数据模型与读写路径;Feed、排行榜和监控的核心矛盾是扇出、实时性和成本;支付、订单和跨域事务的核心矛盾是状态一致性与恢复;搜索和推荐的核心矛盾是查询能力与派生数据的时效。
复盘时检查三层问题:结构上是否先讲约束、主链路和权威状态;内容上是否每个结论都有依据、边界和代价;表达上是否能用简短结论收束而不堆砌术语。下一次练习只选择一个能验证的改进动作。
外部题目覆盖索引
本节用于记录外部开源题库与本书题库的覆盖关系。外部仓库只作为题目发现、主题补齐、图解参考和面试训练方法来源,不直接复制整篇答案。导入任何内容前,必须重新确认仓库当前版本、许可证、原作者署名要求和题目是否已经被本附录覆盖。
索引字段
| 字段 | 含义 |
|---|---|
source_repo | 外部仓库名称和入口链接 |
source_question_or_topic | 外部题目、题型或主题;优先记录稳定标题,不记录大段原文 |
source_license | 仓库声明的许可证;未确认时标记为“待核验” |
local_capability | 对应本书的能力域、章节或训练入口 |
difficulty | 基础、进阶、高级或 Staff+ |
question_type | 核心案例、约束变体、快问快答、LLD、图解或面试流程 |
deduplicated | 未审校、候选、已合并 或 拒绝 |
推荐来源
source_repo | 主要覆盖 | source_license | 本地映射 | 处理策略 |
|---|---|---|---|---|
Hamzaa6296/system-design-interview-question | 基础概念、缓存、数据库、分片、一致性、消息队列、微服务、安全、限流、搜索、通知、可观测性、HLD、LLD 和 Staff+ 题目 | 仓库含 LICENSE,具体授权范围待逐项核验 | 第 1-9 章、第 10-22 章、规范题库和快问快答 | 作为第一轮覆盖扫描源,重点发现本书尚未覆盖的领域和题型 |
donnemartin/reliable-system-design | 系统设计主题索引、容量估算、经典设计题、样例解法、架构图、面向对象设计和 Anki 练习 | CC BY 4.0;保留署名并标明修改 | 第 1 章、第 7-9 章、第 10-14 章、规范题库和复盘材料 | 用于补经典题、标准术语和可对照的解题结构,不复制完整答案 |
karanpratapsingh/system-design | 可扩展性、分布式系统、微服务、缓存、数据库、消息和系统设计面试基础 | 仓库声明 CC BY-NC-ND 4.0;不直接改编或复制正文 | 第 1 章、第 3-9 章和第 10-14 章 | 用于补通用架构概念、取舍清单和术语索引 |
ByteByteGoHq/system-design-101 | API、HTTP、负载均衡、数据库、缓存、云架构和分布式系统的图解材料 | 许可证待核验;图片和文字分别审查 | 第 1 章、第 8 章和第 10-18 章 | 只记录图解主题和外部链接,除非确认授权,不复制图片或重绘原图 |
jguamie/system-design | Google 风格系统设计答题流程、需求澄清、规模判断、取舍表达、模拟面试和分布式系统阅读路线 | CC BY 4.0;保留署名并标明修改 | 候选人训练、面试官评估、模拟面试和第 1 章 | 用于补面试过程、评估标准和答题节奏,不把其流程当作唯一模板 |
记录模板
后续每发现一条有价值的外部题目,先按下面格式登记,再决定是否转化为本书题卡:
source_repo | source_question_or_topic | source_license | local_capability | difficulty | question_type | deduplicated |
|---|---|---|---|---|---|---|
Hamzaa6296/system-design-interview-question | Design a notification system | 待核验 | 通知、异步任务、可靠性 | 进阶 | 核心案例 | 未审校 |
判定为 已合并 前,至少完成三项检查:
- 本书现有题库中没有同一核心约束,只是换了业务名。
- 外部题目能补充一个新的业务约束、故障注入、能力域或训练形式。
- 许可证允许当前使用方式;如果只能引用标题或链接,就只保留索引,不复制正文。
规范题库
本章按可迁移的设计能力组织题目,而不是按中间件名称或业务目录堆叠题卡。每次练习先选择题型:核心案例训练完整决策,约束变体训练在条件变化后修正方案,快问快答训练一个明确的判断边界。
范围、约束与容量
Q-SD-CORE-01:热点交易的预约、限流与兑现
题型: 核心案例,45 分钟 目标: 在“活动一分钟内涌入 100 万请求、限量 1 万份、支付可延迟”的约束下,设计从资格校验到库存确认的主链路。
不可协商约束
- 不得超卖,不得因重复请求创建多个预约或订单。
- 用户必须在 300 ms 内得到“已进入队列、预约成功或已售罄”的明确结果。
- 支付成功回调可能迟到、重复或在超时关闭后到达。
关键决策
- 库存、预约、订单和支付状态分别由谁权威保存。
- 入口保护、资格校验和库存令牌分别在哪一层完成。
- 预约、创单、支付确认和释放的状态机如何收敛。
- Redis、消息队列和数据库失效时,哪些结果可延迟,哪些必须停止承诺。
故障注入
库存预扣成功后,订单服务超时;十分钟后同一用户收到支付成功回调。说明状态查询、幂等键、补偿和人工处置如何协作。
评估锚点
- 合格:区分排队结果、资源预约与最终成交,且有持久化幂等边界。
- 深入:给出预约过期、回调乱序、库存对账和熔断策略。
- 优秀:能说明何时从数据库条件更新演进到令牌预分配,并说清演进后的审计与恢复成本。
约束变体: 将活动改为一小时均匀到达;改为多仓库存;改为券码而非数量库存;改为支付前不可释放资源。
状态、一致性与恢复
Q-SD-CORE-02:跨域创单的一致性边界
题型: 核心案例,45 分钟 目标: 设计包含库存预占、优惠资格锁定、订单创建和支付确认的交易链路。
不可协商约束
- 订单、库存、营销和支付分别拥有自己的权威状态。
- 客户端超时不代表请求失败;任何命令和事件都可能重复投递。
- 搜索、消息通知和报表可以最终一致,但交易承诺不能靠缓存或消息本身证明。
关键决策
- 哪些状态迁移同步完成,哪些副作用通过 Outbox 传播。
- 每个域如何定义幂等键、状态前置条件和可重放事件。
- 补偿失败、未知支付结果和长期悬挂预约由谁发现、谁修复。
故障注入
订单已落库、库存预约超时未知、优惠券锁定成功。说明用户可见状态、后台任务和最终收敛路径。
评估锚点
- 合格:明确每个状态的权威来源与状态转移条件。
- 深入:区分 Saga、TCC 和本地事务加 Outbox 的适用边界。
- 优秀:能把对账、重放、告警和人工决策纳入同一恢复闭环。
约束变体: 库存服务不可用;优惠券必须一次性核销;订单允许拆单;支付渠道只提供异步查询。
Q-SD-CORE-03:发布后的读模型一致性
题型: 核心案例,30 分钟 目标: 商品或配置发布后,保证详情页缓存、搜索索引和计价上下文在可接受时间内收敛。
不可协商约束
- 正式主数据、版本和待发布事件必须在同一持久化边界提交。
- 搜索和缓存是派生读模型;其刷新失败不得回滚已提交的业务事实。
- 读模型必须能识别版本落后,并可从权威快照重建。
关键决策
- 发布版本、事件 ID 和投影幂等条件如何设计。
- 删除缓存、异步刷新和全量重建各自负责什么。
- 何时允许用户读旧数据,何时必须绕过读模型。
故障注入
发布已成功,但搜索消费者连续失败一小时且缓存命中旧版本。说明诊断路径、补偿策略和用户影响控制。
评估锚点
- 合格:区分权威主数据与派生读模型。
- 深入:说明 Outbox、版本单调性、重放和巡检。
- 优秀:能定义陈旧窗口、修复 SLO 和可观测指标。
领域边界与供给运营
Q-SD-CORE-04:商品供给到可售状态的全链路
题型: 核心案例,45 分钟 目标: 设计商品从供应商同步或运营录入,经审核、发布、库存初始化、营销配置到 C 端可售的链路。
不可协商约束
- Draft、审核工单、正式商品、库存与可售投影是不同对象,不能共用一个模糊状态字段。
- 运营人工修改可以覆盖部分供应商字段,但必须可审计并有明确字段主导权。
- 商品发布不等于商品可售;库存、履约、渠道和风控条件都可能阻断承诺。
关键决策
- 各域的事实、控制面和读模型分别是什么。
- 如何使用发布版本、防覆盖和冻结快照处理并发编辑。
- 库存创建、券码导入和下游刷新如何任务化、幂等化和补偿。
故障注入
审核通过后,库存券码导入发现重复码,商品已进入线上发布时间窗。说明线上展示、可售状态、错误修复与重试策略。
评估锚点
- 合格:能清楚划分商品、供给、库存、营销和可售投影的责任。
- 深入:能解释版本冲突、字段所有权和异步任务恢复。
- 优秀:能设计让运营和一线排障人员解释“为什么不可售”的诊断模型。
约束变体: 多品类动态属性;门店/日期切片库存;海量文件导入;供应商全量快照同步。
Q-SD-QUICK-01:主数据、流程对象和交易快照如何区分?
题型: 快问快答,5 分钟 预期结论: 正式商品主数据服务于当前交易契约;Draft、Staging、QC 和任务是供给流程对象;订单保存创建当时的商品、报价、履约和退款快照。 适用边界: 小型系统可以共库,但仍需在模型和状态机上明确这三类事实,避免未审核数据进入交易读取路径。
Q-SD-QUICK-02:为什么不能把 Redis 或分布式锁当作库存正确性的最终保证?
题型: 快问快答,5 分钟 预期结论: Redis 可承担限流、令牌和热点预扣;锁可减少竞争。但最终承诺仍应由持久化账本、条件更新、唯一约束或状态机决定。 适用边界: 当 Redis 只生成可消费令牌时,令牌数必须来自可审计的资源分配,并在故障恢复时与权威账本对齐。
搜索、结算、订单、支付与结算
Q-SD-CORE-05:搜索与商品交易事实如何协作
题型: 核心案例,30 分钟 目标: 设计商品检索、筛选、排序和详情展示,处理索引异步更新、价格个性化与无结果场景。
不可协商约束
- 搜索索引是面向检索体验的派生模型,不是商品、库存或最终成交价的权威来源。
- 列表允许有限陈旧,结算必须重新验证商品、价格、库存、渠道资格和履约条件。
- 排序实验不得改变用户的价格、库存或支付结果。
关键决策
- 检索文档包含哪些可异步字段,哪些字段在交易前必须回源。
- 如何用版本、重建和灰度保护索引变更。
- 如何为无结果、低相关性和多语言检索建立可验证反馈。
故障注入
商品已下架但索引仍命中;用户从搜索结果进入结算。说明每一层的行为及避免错误承诺的边界。
Q-SD-CORE-06:购物车与结算的价格、库存和实验边界
题型: 核心案例,30 分钟 目标: 设计跨端购物车、结算试算和下单确认,处理价格变化、商品失效和展示实验。
不可协商约束
- 购物车是意图记录,不自动等同于库存预约或最终报价。
- 结算必须生成可追溯的报价结果;创单必须保存与该次承诺相符的快照。
- 实验分流必须稳定、可审计,并有业务护栏和回滚条件。
故障注入
用户在两个终端同时结算;一个终端看到旧优惠,另一个终端已提交。说明合并策略、报价失效与创单幂等。
Q-SD-CORE-07:订单状态机与履约编排
题型: 核心案例,45 分钟 目标: 设计订单从创建、支付、履约到售后退款的状态模型。
不可协商约束
- 正向订单状态、支付状态、履约状态和退款状态不可混成单一枚举。
- 每次状态迁移必须有命令来源、前置状态、幂等键和审计记录。
- 延迟任务、重复事件和人工操作都可能并发抵达。
关键决策
- 状态机按聚合如何拆分,哪些状态允许互相驱动。
- 超时关单与迟到支付如何仲裁。
- 拆单、缺货、地址异常和部分退款如何保留可追溯事实。
故障注入
关单任务执行后收到支付成功回调,库存已释放且部分子单已进入履约。说明禁止什么自动动作,哪些状态需人工仲裁。
Q-SD-CORE-08:支付回调、退款与对账
题型: 核心案例,45 分钟 目标: 设计多渠道支付的支付单、回调、主动查询、退款和 T+1 对账机制。
不可协商约束
- 签名验证、渠道幂等号、平台支付单和资金状态迁移均需持久化审计。
- 网络超时只代表结果未知;不得把“调用失败”直接解释为未扣款或可安全重试。
- 回调接口必须能在重复投递时返回渠道要求的确认,但不得跳过状态校验。
关键决策
- 支付、退款和结算的业务唯一键及状态前置条件。
- 回调、主动查询和对账文件冲突时的证据优先级。
- 资金差异、金额不符和长时间处理中状态的自动与人工处理边界。
故障注入
退款渠道调用超时,渠道后来返回成功,但平台任务已重试。说明如何避免重复退款并完成账务对齐。
Q-SD-QUICK-03:Snowflake ID 是否可以同时满足订单号的全部要求?
题型: 快问快答,5 分钟 预期结论: Snowflake 可提供高吞吐、趋势有序的内部 ID,但通常不能单独满足防枚举、外部展示、路由和审计需求。 适用边界: 时钟回拨、机器 ID 分配和序列溢出需要明确运行策略;面向用户的订单号可与内部主键分离。
峰值流量、平台可靠性与演进
Q-SD-CORE-09:大促准备、容量与降级
题型: 核心案例,45 分钟 目标: 为一次大促制定容量估算、压测、预热、隔离、限流、降级和恢复方案。
不可协商约束
- 容量必须从业务行为推导,分别估算入口请求、读写比例、重试、消息积压和外部依赖。
- 必须定义核心路径和可降级能力;故障时不得伪造交易成功。
- 演练需要有可量化的触发阈值、回退动作和恢复验证。
关键决策
- 峰值假设如何变成缓存、队列、数据库和下游的容量预算。
- 哪些保护位于网关、应用、数据和运营层。
- 多机房、依赖故障与异常流量下如何保护交易事实。
故障注入
活动开始后支付渠道 P99 延迟持续升高,消息消费积压扩大,推荐服务错误率激增。说明优先级、保护动作、观测指标和恢复条件。
Q-SD-CORE-10:单体到服务化的演进决策
题型: 核心案例,45 分钟 目标: 针对快速增长的平台,决定哪些问题应先通过模块化、数据边界或平台能力解决,哪些才值得拆为独立服务。
不可协商约束
- 不得把团队规模、日订单量或“使用微服务”当成拆分理由本身。
- 每次拆分必须说明数据所有权、契约、迁移路径、回滚方式和新增运营成本。
- 架构演进应由变化频率、故障隔离、扩缩容需求和组织协作证据驱动。
关键决策
- 如何选择第一个被隔离的能力,并证明其收益大于迁移成本。
- 如何避免双写、共享数据库和跨服务同步调用成为长期耦合。
- 配置、追踪、版本兼容、技术债和可观测性如何随服务数量演进。
故障注入
拆出的库存服务与旧单体并行写入期间出现版本偏差。说明切流、对账、停写与回滚策略。
综合设计案例
Q-SD-CORE-11:多仓、多币种 B2B2C 平台
题型: 核心案例,60 分钟 目标: 设计包含商家、商品、库存、价格、税费、支付、结算和售后的跨境平台主链路。
关键决策: 领域所有权、报价快照、汇率与税费版本、库存分配、支付和结算账本、退款与汇兑差异。 故障注入: 汇率刷新滞后、仓库库存回传延迟、支付成功但商家结算暂挂。 评估锚点: 能区分交易时承诺、事后结算与用户展示;能解释法律、资金和运营约束何时改变设计。
Q-SD-CORE-12:线上数据异常的诊断与恢复
题型: 核心案例,45 分钟 目标: 用户反馈商品页显示旧数据、下单失败率升高、部分订单状态卡住时,设计从告警到恢复的处置流程。
关键决策: 关联 ID、版本、事件、状态机和读模型的诊断顺序;自动重放与人工操作的权限边界;修复后的验证证据。 故障注入: Outbox 消费延迟、缓存版本倒退和人工补偿同时发生。 评估锚点: 不直接改最终状态掩盖问题;能保留审计、重复保护和可回滚修复。
迁移覆盖索引
下表保留旧题号及其主题。核心案例表示题目被完整吸收;约束变体表示作为指定核心案例的约束变化练习;快问快答表示保留其概念判断和适用边界;参考表示保留为相应核心案例的补充材料。
| 旧题号 | 原题 | 新形式 | 规范入口 |
|---|---|---|---|
| Q-GEN-SCOPE-01 | 系统设计面试与算法面试的区别是什么? | 快问快答 | Q-SD-CORE-10 |
| Q-GEN-SCOPE-02 | 设计秒杀系统前需要澄清什么? | 约束变体 | Q-SD-CORE-01 |
| Q-GEN-CAP-01 | 如何把容量估算转化为设计依据? | 约束变体 | Q-SD-CORE-09 |
| Q-GEN-DATA-01 | 为什么核心交易状态要有权威来源? | 快问快答 | Q-SD-CORE-02 |
| Q-GEN-DATA-02 | 系统设计题中如何回答一致性问题? | 快问快答 | Q-SD-CORE-02 |
| Q-GEN-DATA-03 | 为什么核心交易状态通常放在 MySQL? | 快问快答 | Q-SD-CORE-02 |
| Q-GEN-DATA-04 | 库存扣减如何防止超卖? | 约束变体 | Q-SD-CORE-01 |
| Q-GEN-DATA-05 | 缓存和数据库不一致怎么办? | 约束变体 | Q-SD-CORE-03 |
| Q-GEN-DATA-06 | 分布式锁能解决所有并发问题吗? | 快问快答 | Q-SD-CORE-02 |
| Q-GEN-REL-01 | 如何解释高可用? | 约束变体 | Q-SD-CORE-09 |
| Q-GEN-REL-02 | 超时、重试与幂等如何协作? | 约束变体 | Q-SD-CORE-02 |
| Q-GEN-REL-03 | 什么时候用熔断,什么时候用降级? | 约束变体 | Q-SD-CORE-09 |
| Q-GEN-MW-01 | 什么场景应该引入消息队列? | 约束变体 | Q-SD-CORE-02 |
| Q-GEN-MW-02 | 检索场景为什么不总是使用 Elasticsearch? | 快问快答 | Q-SD-CORE-05 |
| Q-GEN-MW-03 | 如何降低 Kafka 消息丢失风险? | 约束变体 | Q-SD-CORE-02 |
| Q-GEN-MW-04 | 消息重复时如何保证结果正确? | 约束变体 | Q-SD-CORE-02 |
| Q-GEN-MW-05 | 何时选择 Elasticsearch 而不是 MySQL? | 快问快答 | Q-SD-CORE-05 |
| Q-GEN-EVO-01 | 高频系统设计题的底层共性是什么? | 快问快答 | Q-SD-CORE-10 |
| Q-GEN-EVO-02 | 系统设计面试后如何复盘并改进? | 快问快答 | Q-SD-CORE-10 |
| Q-ECOM-ARCH-001 | 如何设计一个中大型电商平台的服务拆分边界? | 约束变体 | Q-SD-CORE-10 |
| Q-ECOM-ARCH-002 | 如何设计电商系统的数据一致性方案? | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-003 | 如何设计服务间的调用链路追踪? | 约束变体 | Q-SD-CORE-12 |
| Q-ECOM-ARCH-004 | 如何平衡微服务拆分的粒度? | 约束变体 | Q-SD-CORE-10 |
| Q-ECOM-ARCH-005 | 电商系统的技术债治理策略 | 约束变体 | Q-SD-CORE-10 |
| Q-ECOM-ARCH-006 | 如何设计配置中心? | 约束变体 | Q-SD-CORE-10 |
| Q-ECOM-ARCH-007 | 领域驱动设计在电商系统中的应用 | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-ARCH-008 | 设计订单与库存的一致性保证方案 | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-009 | Saga模式在电商系统中的应用 | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-010 | 如何选择同步调用vs异步消息? | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-011 | 分布式事务的几种实现方案对比 | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-012 | 设计事件驱动架构 | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-013 | 如何保证消息的可靠投递? | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-014 | 幂等性设计的最佳实践 | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-015 | 设计数据最终一致性的对账补偿机制 | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-016 | 如何处理分布式系统的时钟问题? | 约束变体 | Q-SD-CORE-02 |
| Q-ECOM-ARCH-017 | 微服务间的版本兼容性设计 | 约束变体 | Q-SD-CORE-10 |
| Q-ECOM-SUPPLY-001 | 设计支持多品类的SPU/SKU数据模型 | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-002 | 商品详情页的缓存架构设计 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-SUPPLY-003 | 如何解决商品信息变更后搜索不一致问题? | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-SUPPLY-004 | 直接订阅 Binlog 同步 ES 的弊端是什么?如果不同变更之间存在依赖关系,应该怎么处理? | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-SUPPLY-005 | 设计商品类目体系和属性管理 | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-006 | 商品图片的存储和CDN方案 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-SUPPLY-007 | 虚拟商品vs实物商品的设计差异 | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-008 | 商品上架流程的工作流设计 | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-009 | 如何支持商品的多规格选择(颜色、尺码等)? | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-010 | 商品快照在订单中的应用 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-SUPPLY-011 | 设计商品推荐系统的架构 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-SUPPLY-012 | 商品搜索的倒排索引设计 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-SUPPLY-013 | 如何处理商品数据的历史版本? | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-014 | 多租户场景下的商品数据隔离 | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-015 | 商品导入的批量处理优化 | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-016 | 商品审核流程的设计 | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-017 | 库存是怎么创建出来的? | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-018 | 库存如何和商品供给运营平台、商品生命周期联动? | 约束变体 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-019 | 设计防止库存超卖的方案 | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-020 | 如何设计分布式库存系统? | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-021 | 库存预占与释放的设计 | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-022 | 如何设计库存的分级管理(前台可售vs仓库实际)? | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-023 | 库存扣减失败的补偿机制 | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-024 | 设计库存盘点系统 | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-025 | 如何处理库存的并发更新? | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-026 | 虚拟库存vs实物库存的差异 | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-027 | 多仓库场景下的库存分配策略 | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-028 | 如何设计库存安全水位和补货机制? | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-029 | 库存快照在订单中的应用 | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-030 | 库存的实时性vs一致性权衡 | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-031 | 库存回滚机制的设计 | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-032 | 跨境电商的库存管理(多国库存) | 约束变体 | Q-SD-CORE-01 |
| Q-ECOM-SUPPLY-033 | 设计支持多种促销规则的价格计算引擎 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-SUPPLY-034 | 优惠券系统的设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-SUPPLY-035 | 阶梯价和批发价的设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-SUPPLY-036 | 会员等级和积分体系的设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-SUPPLY-037 | 动态定价系统的设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-SUPPLY-038 | 跨境电商的汇率和税费计算 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-SUPPLY-039 | 组合促销的价格计算(满减+折扣+券) | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-SUPPLY-040 | 预售和定金膨胀的设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-SUPPLY-041 | 价格歧视与个性化定价的设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-SUPPLY-042 | 商品中心和商品供给平台有什么区别? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-043 | 为什么商品正式表不应该有 Draft、QC Pending、Rejected 状态? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-044 | 为什么需要 Resource,而不是只用 SPU/SKU? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-045 | Product Item、SPU、SKU、Offer 的关系是什么? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-046 | 为什么需要 publish_version? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-047 | 订单为什么不能回读最新商品表? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-048 | 商品中心是否保存实时库存和最终成交价? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-049 | 发布成功后搜索刷新失败怎么办? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-050 | 供应商同步是否直接写商品中心? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-051 | 商品中心如何支持异构品类? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-052 | 为什么发布事务里不直接刷新缓存和 ES? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-053 | 商品中心如何排查线上展示旧数据? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-054 | 为什么商品供给与运营平台不能设计成商品中心的后台 CRUD? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-055 | 商品中心和供给运营平台的边界是什么? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-056 | 供应商同步和人工上传要分成两套系统吗? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-057 | 本地运营上传和商家上传为什么审核策略不同? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-058 | 为什么 Draft、Staging、QC、正式 Item 要有不同状态机? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-059 | 新建商品在 Draft 和 QC 阶段还没有正式 item_id,怎么追踪全链路? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-060 | 商品已经在线后再次编辑,哪些 ID 会新建,哪些 ID 会复用? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-061 | 为什么商家提交 Draft 后要生成不可随意修改的 Staging Ticket? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-062 | Pending 阶段发现内容填错,还能直接编辑吗? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-063 | 为什么需要 product_qc_review 和 product_qc_review_item 两张表? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-064 | product_change_request、product_supply_operation_log、product_field_ownership 分别解决什么问题? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-065 | product_publish_record、product_publish_snapshot、product_change_log 的区别是什么? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-066 | Publish 背后的实际流程是什么? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-067 | 为什么 QC 通过不等于商品已经上线? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-068 | 为什么发布前要校验 base_publish_version? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-069 | 为什么批量导入不能同步循环写正式表? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-070 | Parser Worker 和 Item Worker 为什么要拆开? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-071 | product_supply_task 和 product_supply_task_item 的关系是什么? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-072 | 供应商同步为什么需要 Raw Snapshot、Checkpoint 和 DLQ? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-073 | 如何保证商品发布后搜索、缓存、计价上下文最终一致?营销活动如何协同? | 约束变体 | Q-SD-CORE-03 |
| Q-ECOM-SUPPLY-074 | 商品供给运营平台、商品生命周期和库存系统如何联动? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-075 | 库存运营任务为什么要单独建模? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-076 | 为什么不能把 100 万酒店同步设计成一个单进程长循环? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-077 | Task 和 Batch 有什么区别? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-078 | Checkpoint 是什么,什么时候更新? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-079 | worker 如何抢占任务? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-080 | worker_id 和 lease_token 的区别是什么? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-081 | 心跳、租约、checkpoint 分别解决什么问题? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-082 | 机器重启后怎么恢复? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-083 | 旧 worker 在长 GC 或网络抖动后恢复了怎么办? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-084 | 上一次任务还没跑完,又下发了一次任务怎么办? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-085 | 心跳正常但 checkpoint 长时间不动,说明什么? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-086 | checkpoint 更新失败怎么办? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-087 | 任务被人工取消时 worker 还在跑,怎么停? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-088 | Raw Snapshot 的价值是什么? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-089 | 为什么需要 Diff? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-090 | DLQ 为什么建议用 MySQL,而不是只用消息队列? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-091 | worker 可以从 Redis 中抢占任务吗? | 快问快答 | Q-SD-CORE-12 |
| Q-ECOM-SUPPLY-092 | 为什么商品供给不能直接写商品正式表? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-093 | 供应商同步是不是商品供给链路的一部分? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-094 | 批量导入为什么要支持部分成功? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-095 | 运营编辑如何防止覆盖供应商同步的数据? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-096 | 审核通过为什么不等于商品可售? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-097 | 如何保证商品发布和搜索缓存一致? | 约束变体 | Q-SD-CORE-03 |
| Q-ECOM-SUPPLY-098 | 为什么订单要保存商品快照? | 快问快答 | Q-SD-CORE-07 |
| Q-ECOM-SUPPLY-099 | Draft 和 Staging 有什么区别? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-100 | 为什么批量导入需要 Task 和 Task Item 两层模型? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-101 | 供给任务如何做幂等? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-102 | 大文件批量导入如何避免内存爆和长事务? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-103 | 商品供给链路中哪些校验必须前置? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-104 | 类目模板变更后,历史商品怎么处理? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-105 | 哪些运营变更需要强审核? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-106 | 为什么发布时要校验 base_publish_version? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-107 | 商品发布失败如何回滚? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-108 | 下游搜索、缓存或计价上下文刷新失败怎么办? | 约束变体 | Q-SD-CORE-03 |
| Q-ECOM-SUPPLY-109 | 错误文件应该包含哪些信息? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-110 | 如何设计商品质量分? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-111 | 运营后台最需要展示哪些链路状态? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-112 | 两个运营同时编辑同一个商品怎么办? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-113 | 供应商价格和库存很新,但运营有人工覆盖,系统听谁的? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-114 | 商品上下架和发布版本是什么关系? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-115 | 如何支持定时发布和灰度发布? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-116 | 自动下架会带来什么风险? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-117 | 供给链路如何做审计追踪? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-SUPPLY-118 | 如果面试官让你一句话概括架构,你怎么说? | 快问快答 | Q-SD-CORE-04 |
| Q-ECOM-TRADE-001 | 电商搜索引擎的架构设计 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-002 | 搜索相关性排序算法设计 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-003 | 搜索建议(Suggest)的实现 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-004 | 商品筛选和多维度过滤的设计 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-005 | 搜索结果的无结果优化 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-006 | 搜索日志分析与优化 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-007 | 跨境电商的多语言搜索 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-008 | 搜索性能优化 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-009 | 智能搜索(NLP+AI) | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-010 | 搜索结果的多样性优化 | 约束变体 | Q-SD-CORE-05 |
| Q-ECOM-TRADE-011 | 购物车的数据存储设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-012 | 购物车的价格计算 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-013 | 购物车的推荐功能 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-014 | 购物车的库存校验 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-015 | 购物车的性能优化 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-016 | 购物车商品失效的处理策略 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-017 | 购物车的跨平台同步设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-018 | 购物车推荐算法设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-019 | 购物车的结算流程设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-020 | 购物车的分享功能设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-021 | 购物车的满减凑单提示 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-022 | 购物车的批量操作设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-023 | 购物车的收藏夹联动 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-024 | 购物车的历史记录 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-025 | 购物车的AB测试设计 | 约束变体 | Q-SD-CORE-06 |
| Q-ECOM-TRADE-026 | 订单状态机的设计 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-027 | 订单号生成规则 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-028 | 订单超时自动取消 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-029 | 订单拆单与合单策略 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-030 | 订单的并发创建与幂等性 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-031 | 订单的分布式事务设计(Saga模式) | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-032 | 订单数据的分库分表设计 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-033 | 订单履约流程的编排 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-034 | 订单的退款和售后流程设计 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-035 | 订单的异常处理(缺货、地址错误) | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-036 | 订单的搜索和查询优化 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-037 | 订单的消息通知设计 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-038 | 订单数据的冷热分离 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-039 | 订单的实时数据统计 | 约束变体 | Q-SD-CORE-07 |
| Q-ECOM-TRADE-040 | 支付系统的整体架构设计 | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-TRADE-041 | 支付回调的幂等性处理 | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-TRADE-042 | 支付的对账系统设计 | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-TRADE-043 | 支付的异步回调处理 | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-TRADE-044 | 支付的分账系统设计(平台+商家) | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-TRADE-045 | 支付密码和安全设计 | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-TRADE-046 | 支付的退款处理 | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-TRADE-047 | 预授权支付的设计(酒店、租车场景) | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-PEAK-001 | 大促期间如何保证系统稳定性? | 约束变体 | Q-SD-CORE-09 |
| Q-ECOM-PEAK-002 | 设计电商系统的多机房多活架构 | 约束变体 | Q-SD-CORE-09 |
| Q-ECOM-PEAK-003 | 设计电商系统的监控告警体系 | 约束变体 | Q-SD-CORE-09 |
| Q-ECOM-PEAK-004 | 大促场景下的库存预热和削峰方案 | 约束变体 | Q-SD-CORE-09 |
| Q-ECOM-PEAK-005 | 秒杀活动的价格和库存设计 | 约束变体 | Q-SD-CORE-09 |
| Q-ECOM-PEAK-006 | 订单的限流和防刷 | 约束变体 | Q-SD-CORE-09 |
| Q-ECOM-PEAK-007 | 支付渠道的路由和降级 | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-PEAK-008 | 支付的容灾和降级 | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-PEAK-009 | 100 万酒店 10 小时如何估算吞吐? | 约束变体 | Q-SD-CORE-08 |
| Q-ECOM-CASE-001 | 设计一个百万级QPS的商品详情页系统 | 核心案例 | Q-SD-CORE-05 |
| Q-ECOM-CASE-002 | 秒杀系统的完整设计 | 核心案例 | Q-SD-CORE-09 |
| Q-ECOM-CASE-003 | 订单履约的全链路监控 | 核心案例 | Q-SD-CORE-12 |
| Q-ECOM-CASE-004 | 大促准备的全链路压测 | 核心案例 | Q-SD-CORE-09 |
| Q-ECOM-CASE-005 | 跨境电商的多币种结算 | 核心案例 | Q-SD-CORE-11 |
| Q-ECOM-CASE-006 | 电商搜索的智能排序 | 核心案例 | Q-SD-CORE-05 |
| Q-ECOM-CASE-007 | 异常流量的应急处理 | 核心案例 | Q-SD-CORE-09 |
| Q-ECOM-CASE-008 | 订单的柔性事务设计 | 核心案例 | Q-SD-CORE-02 |
| Q-ECOM-CASE-009 | 用户画像系统的设计 | 核心案例 | Q-SD-CORE-05 |
| Q-ECOM-CASE-010 | 大促后的系统复盘 | 核心案例 | Q-SD-CORE-09 |
模拟面试
本章提供基于题卡的限时模拟流程。候选人应在每个阶段主动报出假设、同步思考过程,并在结束时用自评问题复盘。
30 分钟:通用后端系统设计
本场使用 Q-SD-CORE-02,并在容量、权威状态和超时重试三个维度各加入一个约束变体。
| 阶段 | 时长 | 候选人任务 | 面试官指引 |
|---|---|---|---|
| 开场与假设 | 4 分钟 | 确认业务目标、使用者、规模口径和非目标。 | 只补充必要约束,记录候选人主动提出的假设。 |
| 容量推导 | 7 分钟 | 为创单场景给出计算口径,并把结果落到容量预算。 | 追问峰谷差异和余量来源,观察推导是否自洽。 |
| 数据边界 | 9 分钟 | 说明关键状态、读写路径与责任边界。 | 要求明确每个关键状态的归属和更新顺序。 |
| 可靠性追问 | 7 分钟 | 处理调用失败、重复与恢复情形。 | 改变一个失败条件,观察方案是否仍能闭环。 |
| 收束 | 3 分钟 | 总结方案、主要风险和下一步验证项。 | 请候选人指出一个最需要取舍的决定。 |
面试官指引:
- 按题卡顺序推进,不朗读题卡正文,也不提前给出实现选项。
- 候选人跳过假设或量化依据时,要求其补足依据后再进入下一阶段。
- 记录其是否能把容量、数据权威和失败处理连接为同一条链路。
候选人自评问题:
- 我是否在开始阶段说明了范围、数量级和关键假设?
- 我是否明确了关键状态的归属,以及读写路径的责任边界?
- 我是否对失败、重试和重复结果给出了可以验证的处理方式?
45 分钟:电商专项系统设计
本场使用 Q-SD-CORE-10、Q-SD-CORE-07 和 Q-SD-CORE-09。
| 阶段 | 时长 | 候选人任务 | 面试官指引 |
|---|---|---|---|
| 业务边界 | 7 分钟 | 确认平台角色、交易范围和最重要的业务约束。 | 用一个边界变化检查候选人的领域划分。 |
| 架构拆分 | 11 分钟 | 围绕 Q-SD-CORE-10 给出服务边界、依赖关系和核心接口。 | 追问边界两侧的数据归属与同步方式。 |
| 交易主线 | 13 分钟 | 围绕 Q-SD-CORE-07 描述状态演进、触发条件和异常回退。 | 在中途插入一个重复或乱序事件,要求候选人调整说明。 |
| 峰值追问 | 9 分钟 | 围绕 Q-SD-CORE-09 说明峰值前准备、入口控制和压力转移。 | 逐步提高流量或缩小资源,观察降级优先级。 |
| 收束与复盘 | 5 分钟 | 说明最关键的取舍、风险指标和验证计划。 | 请候选人比较一个备选方案并作出选择。 |
面试官指引:
- 先确认交易语义,再要求候选人画出架构与状态变化,避免只讨论组件名。
- 每次追问只改变一个约束,保留候选人上一轮方案作为比较基线。
- 重点观察服务边界、状态演进和峰值处理是否相互一致。
候选人自评问题:
- 我是否先确定了业务边界与数据归属,再划分服务职责?
- 我是否完整说明了交易状态的进入、退出和异常分支?
- 我是否把峰值措施与日常架构的责任边界说清楚?
60 分钟:高级系统设计答辩
主案例使用 Q-SD-CORE-01。递进追问依次引入库存正确性、创单幂等性和异常流量限制三个约束变体。
| 阶段 | 时长 | 候选人任务 | 面试官指引 |
|---|---|---|---|
| 约束协商 | 8 分钟 | 明确目标、成功标准、规模口径、资源边界和不可接受的风险。 | 至少引入一个冲突约束,要求候选人排序优先级。 |
| 白板架构 | 15 分钟 | 围绕 Q-SD-CORE-01 在白板画出入口、核心链路、状态存储与依赖关系。 | 要求图中每条关键连线都说明方向、同步方式和失败后的去向。 |
| 递进追问一 | 10 分钟 | 引入库存正确性约束,调整关键资源的并发控制与校验边界。 | 追问高并发下的结果判定和恢复责任。 |
| 递进追问二 | 10 分钟 | 引入重复创单与迟到事件,补充请求去重、状态衔接和异常后的可恢复性。 | 改变请求到达顺序,检查设计是否仍保持一致。 |
| 递进追问三 | 8 分钟 | 引入异常流量限制,说明入口保护、优先级和观测信号。 | 要求候选人给出触发保护与解除保护的判断依据。 |
| 最终取舍讨论 | 9 分钟 | 比较两个可行方案,说明可靠性、成本、复杂度和演进上的取舍。 | 质询最脆弱的假设,并要求候选人说明何时应推翻当前选择。 |
面试官指引:
- 白板阶段只要求结构、边界和关键流向;细节应在递进追问中按需展开。
- 每张追问题卡都以前一阶段方案为前提,要求候选人明确哪些结论保持不变、哪些需要修改。
- 最终讨论聚焦决策依据,避免把答辩变成对题卡正文的复述。
候选人自评问题:
- 我的白板是否让他人能看出边界、流向、状态位置和主要故障路径?
- 我是否在每个递进追问后明确了保留的假设与修改的决定?
- 我是否用业务影响、可靠性、成本和复杂度解释了最终取舍?
历史详细参考材料
以下材料保留重构前通用与电商题库中的逐题分析、方案、代码示例、追问与复盘清单。它们用于覆盖查阅和二次审校;日常训练以本附录前部的规范题库为准。
原第 38 章:通用系统设计题库
本章按通用能力组织题卡。每张题卡都要求从业务目标、规模、主路径、权威状态、失败处理和演进取舍展开,不把组件名称当作答案。
需求澄清与范围控制
Q-GEN-SCOPE-01:系统设计面试与算法面试的区别是什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-SCOPE-01 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 1 题 |
| 时长 | 10 分钟 |
| 难度 | 基础 |
| 场景 | 通用面试 |
| 题型 | 短追问 |
| 能力标签 | 问题定义、结构化表达 |
题干与约束
说明系统设计面试与算法面试考察的差异,以及为什么系统设计题应先澄清问题。
候选人作答任务
用一段完整回答说明考察目标,并给出首轮澄清问题。
推荐作答顺序
先区分确定输入下的求解与不完整约束下的设计,再说明目标、规模、状态和风险如何影响方案。
答案骨架
算法题更强调在确定条件下构造正确、高效的求解;系统设计题要求在信息不完整时定义问题、选取边界并解释取舍。先澄清可避免为错误目标堆组件,随后才能讨论容量、数据与可靠性。
评分锚点
基础回答能说出先澄清。较好回答能指出约束改变架构。优秀回答能主动给出非目标和验证指标。
递进追问
业务方只说“用户很多且必须稳定”时,下一句如何追问?
常见失分点
把系统设计说成算法题的放大版;直接罗列中间件;遗漏非目标。
关联正文
复盘清单
- 我是否先说明了目标、约束和非目标?
- 我是否把澄清结果连到后续决策?
Q-GEN-SCOPE-02:设计秒杀系统前需要澄清什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-SCOPE-02 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 2 题 |
| 时长 | 10 分钟 |
| 难度 | 基础 |
| 场景 | 秒杀 |
| 题型 | 专题设计 |
| 能力标签 | 需求澄清、热点识别 |
题干与约束
设计一个秒杀系统,但尚未给出商品规模、库存、用户资格和支付规则。
候选人作答任务
列出会改变库存、限流和异步方案的关键问题,并说明原因。
推荐作答顺序
确认活动峰值和商品热点,再确认库存事实、资格规则、下单与支付时限,最后确认可接受的排队、失败和降级体验。
答案骨架
需要确认瞬时请求量、商品库存和每人限购、是否预热、库存以何处为准、订单是否必须同步创建、未支付释放规则、风控和地域限制。极端热点决定入口限流与排队;正确性要求决定预扣、条件更新和对账方案。
评分锚点
基础回答问流量和库存。较好回答补充资格、支付超时和风控。优秀回答能把每项约束映射到对应机制。
递进追问
十万人同时抢一万件商品,和一小时内均匀产生十万订单,设计有什么不同?
常见失分点
把平均流量当峰值;只谈 Redis;没有问库存释放和重复提交。
关联正文
第 7 章 高准确性与强一致性、第 9 章 高并发写与热点、第 12 章 库存系统
复盘清单
- 我是否区分了活动瞬时峰值与平均流量?
- 我是否说明了库存和订单的权威状态?
容量估算与性能设计
Q-GEN-CAP-01:如何把容量估算转化为设计依据?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-CAP-01 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 3 题;06-whiteboard-capacity-estimation.md,容量估算口径 |
| 时长 | 20 分钟 |
| 难度 | 进阶 |
| 场景 | 通用白板 |
| 题型 | 专题设计 |
| 能力标签 | 容量估算、性能设计 |
题干与约束
说明如何估算峰值 QPS、并发、数据量、带宽和存储增长,并把结果用于设计。
候选人作答任务
选择一个下单或内容读取场景,给出口径、数量级和至少三项设计影响。
推荐作答顺序
从用户行为推导请求量,再用峰值放大和读写比例拆链路,随后估算数据、带宽、热点和异步流量。
答案骨架
先声明活跃用户、每人操作次数和峰值集中度;再计算核心读写 QPS,并加上重试、回调和异步消费。读高时设计缓存与读模型,写高时设计削峰和分片,热点集中时预热、隔离和限流,数据增长影响索引、归档与容量余量。
评分锚点
基础回答有数量级。较好回答区分读写与峰值。优秀回答能说明估算误差、外部依赖和演进阈值。
递进追问
若用户流量不变但活动压缩到一分钟,哪些估算必须重做?
常见失分点
只报一个 QPS;忽略重试和回调;估算后没有落到架构选择。
关联正文
第 1 章 系统设计方法论、第 8 章 低延迟复杂读、第 9 章 高并发写与热点
复盘清单
- 我是否明确了峰值和容量余量的口径?
- 我是否把每个数量级结论转化为设计选择?
数据建模、一致性与事务
Q-GEN-DATA-01:为什么核心交易状态要有权威来源?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-DATA-01 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 4 题 |
| 时长 | 15 分钟 |
| 难度 | 进阶 |
| 场景 | 订单、支付、库存 |
| 题型 | 短追问 |
| 能力标签 | 数据建模、一致性 |
题干与约束
订单、支付、库存热路径使用缓存时,如何解释权威状态与修复策略?
候选人作答任务
说明权威来源、缓存职责,以及不一致后的恢复流程。
推荐作答顺序
先定义不可丢失的业务事实,再划分热路径与权威路径,最后说明对账、补偿和人工处理。
答案骨架
交易状态必须有可审计、可约束、可恢复的权威来源;缓存承担低延迟、热点拦截或短期预扣,不能替代账本与状态机。发生偏差时以权威记录为准,通过事件重放、对账任务、补偿和告警追平。
评分锚点
基础回答区分缓存和数据库。较好回答说明状态机和幂等。优秀回答明确责任方、修复入口和审计证据。
递进追问
缓存预扣成功、权威写入失败时,用户和库存分别如何处理?
常见失分点
宣称缓存天然强一致;没有对账;把最终一致当作不处理失败。
关联正文
第 4 章 大事务处理、第 7 章 高准确性与强一致性、第 14 章 订单系统
复盘清单
- 我是否明确了每类状态的权威来源?
- 我是否说明了发现和修复偏差的闭环?
Q-GEN-DATA-02:系统设计题中如何回答一致性问题?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-DATA-02 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 8 题 |
| 时长 | 15 分钟 |
| 难度 | 进阶 |
| 场景 | 跨系统协作 |
| 题型 | 短追问 |
| 能力标签 | 一致性、取舍表达 |
题干与约束
如何避免把强一致和最终一致说成空泛口号?
候选人作答任务
以交易主链路和搜索投影为例,说明对象、强度、时限、失败处理和代价。
推荐作答顺序
先界定对象与错误后果,再确定同步边界和允许延迟,随后说明幂等、补偿、对账和监控。
答案骨架
订单提交、支付状态和库存确认等资损敏感事实需要强约束或同步确认;搜索、推荐、报表等派生读模型通常允许最终一致。最终一致必须说明延迟目标、可靠投递、重放幂等、对账和人工修复,而不是只说异步。
评分锚点
基础回答能分类。较好回答说出延迟和补偿。优秀回答能量化错误后果并解释性能代价。
递进追问
支付已成功但订单状态尚未更新时,什么数据对用户可见,谁负责补偿?
常见失分点
所有链路都强一致;所有异步都最终一致;没有时间边界和修复责任。
关联正文
第 4 章 大事务处理、第 7 章 高准确性与强一致性、第 14 章 支付与交易全生命周期
复盘清单
- 我是否说明了一致性的对象和可接受时限?
- 我是否给出了失败后的补偿和对账路径?
Q-GEN-DATA-03:为什么核心交易状态通常放在 MySQL?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-DATA-03 |
| 来源 | 02-middleware-reliability-interview.md,MySQL 高频追问:为什么核心交易状态通常放在 MySQL? |
| 时长 | 10 分钟 |
| 难度 | 基础 |
| 场景 | 交易存储 |
| 题型 | 中间件追问 |
| 能力标签 | 权威存储、事务 |
题干与约束
解释为什么订单、支付和库存流水通常以 MySQL 承载权威状态。
候选人作答任务
说明 MySQL 的职责、缓存和消息的职责,以及边界。
推荐作答顺序
先讲事务、约束、审计和恢复,再说明读扩展与异步传播。
答案骨架
核心交易需要原子更新、唯一约束、状态机校验、审计和恢复能力,因此以关系型事务存储承载权威事实。缓存用于加速与削峰,消息用于传播副作用;它们都不能替代最终状态的可验证来源。
评分锚点
基础回答提到事务。较好回答提到约束和账本。优秀回答能说明热点下的缓存预扣与数据库兜底。
递进追问
数据库成为瓶颈后,怎样扩展而不把权威状态交给缓存?
常见失分点
把 MySQL 说成所有数据的唯一选择;忽略读写分离、分片和归档;把消息当存储替代品。
关联正文
第 7 章 高准确性与强一致性、第 12 章 库存系统、第 14 章 订单系统
复盘清单
- 我是否说清了权威状态需要的能力?
- 我是否说明了缓存和消息不承担什么职责?
Q-GEN-DATA-04:库存扣减如何防止超卖?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-DATA-04 |
| 来源 | 02-middleware-reliability-interview.md,MySQL 高频追问:库存扣减如何防止超卖? |
| 时长 | 20 分钟 |
| 难度 | 进阶 |
| 场景 | 库存扣减 |
| 题型 | 专题设计 |
| 能力标签 | 并发控制、库存一致性 |
题干与约束
高并发下单时既要防超卖,也要处理取消、支付失败和重复请求。
候选人作答任务
设计库存检查、预占、确认、释放和对账链路。
推荐作答顺序
先确定库存事实和售卖规则,再讲原子扣减或条件更新,随后补充预占生命周期、幂等和恢复。
答案骨架
数据库使用条件更新、乐观锁或库存账本保护最终正确性;热点可先在缓存中原子预扣和限流。订单创建、支付成功、超时取消都通过唯一业务键和状态机推进预占、确认或释放;定期对账修复缓存与权威库存偏差。
评分锚点
基础回答有条件更新。较好回答有预占释放。优秀回答覆盖重复、乱序、对账和资损告警。
递进追问
支付回调重复且取消任务晚到时,如何避免已售库存被释放?
常见失分点
只用分布式锁;没有库存流水;支付失败后不释放;没有幂等键。
关联正文
第 7 章 高准确性与强一致性、第 9 章 高并发写与热点、第 12 章 库存系统
复盘清单
- 我是否覆盖了预占、确认、释放和对账?
- 我是否说明了并发与重复下的状态机约束?
Q-GEN-DATA-05:缓存和数据库不一致怎么办?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-DATA-05 |
| 来源 | 02-middleware-reliability-interview.md,Redis 高频追问:缓存和数据库不一致怎么办? |
| 时长 | 15 分钟 |
| 难度 | 进阶 |
| 场景 | 缓存 |
| 题型 | 中间件追问 |
| 能力标签 | 缓存一致性、故障恢复 |
题干与约束
核心数据更新后,缓存删除或刷新可能失败,业务允许的不一致窗口有限。
候选人作答任务
选择一致性策略,说明失败恢复和适用边界。
推荐作答顺序
先确认数据是否可容忍短暂陈旧,再确定权威读写路径,最后设计失效、重试、补偿和监控。
答案骨架
通常采用缓存旁路:写入权威存储成功后删除缓存,读取未命中时回源重建,并设置合理过期。对更敏感的数据可使用消息驱动刷新、延迟删除、版本号或直接读权威存储;删除失败必须重试、告警和补偿,不能承诺绝对一致。
评分锚点
基础回答会删除缓存。较好回答说明并发窗口。优秀回答能按业务风险选择策略并提供可观测性。
递进追问
更新成功但删除缓存失败,怎样避免陈旧数据长期存在?
常见失分点
先删缓存再写库且不处理失败;读写都强制同步更新缓存;没有过期和补偿。
关联正文
第 3 章 生产系统治理、第 8 章 低延迟复杂读、第 14 章搜索与交易全生命周期
复盘清单
- 我是否先判断陈旧数据的业务后果?
- 我是否说明了缓存刷新失败后的恢复闭环?
Q-GEN-DATA-06:分布式锁能解决所有并发问题吗?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-DATA-06 |
| 来源 | 02-middleware-reliability-interview.md,Redis 高频追问:分布式锁能解决所有并发问题吗? |
| 时长 | 10 分钟 |
| 难度 | 进阶 |
| 场景 | 并发控制 |
| 题型 | 中间件追问 |
| 能力标签 | 锁、幂等、状态机 |
题干与约束
多个节点并发修改库存或订单状态,团队希望统一使用分布式锁。
候选人作答任务
说明锁能解决什么、不能解决什么,以及核心状态的更可靠保护方式。
推荐作答顺序
先界定临界区和锁的失效条件,再说明数据库约束、条件更新和业务幂等。
答案骨架
分布式锁只能降低特定临界区的并发冲突,还会面对超时、续租、误释放、主从切换和网络分区。资金、库存和状态流转应以唯一约束、条件更新、版本号和状态机为最终保护;锁可作为优化,业务操作仍必须幂等。
评分锚点
基础回答能指出锁超时。较好回答有条件更新。优秀回答说明围栏令牌或失锁后的停止写入。
递进追问
持锁节点长时间停顿后恢复,如何防止它覆盖新持锁者的结果?
常见失分点
把锁当事务;不设置业务幂等;只讨论获取锁,不讨论失锁和释放。
关联正文
第 5 章 长生命周期业务流程、第 7 章 高准确性与强一致性、第 12 章 库存系统
复盘清单
- 我是否区分了并发优化和最终正确性保护?
- 我是否覆盖了锁失效后的业务行为?
高可用、故障恢复与可观测性
Q-GEN-REL-01:如何解释高可用?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-REL-01 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 7 题 |
| 时长 | 15 分钟 |
| 难度 | 进阶 |
| 场景 | 生产治理 |
| 题型 | 短追问 |
| 能力标签 | 高可用、可观测性 |
题干与约束
解释限流、熔断、降级、隔离、数据保护和演练如何组成可用性设计。
候选人作答任务
按流量、依赖、数据和运维四层给出具体措施。
推荐作答顺序
先说明要保护的核心路径和可用性目标,再分别覆盖流量、依赖、数据和运营恢复。
答案骨架
高可用不是增加实例数,而是让故障被限制、被发现、被恢复。流量层限流和隔离,依赖层超时、重试、熔断和快速失败,数据层复制、对账和补偿,运维层监控、告警、压测、演练和扩容预案;非核心能力在故障时让路给主交易。
评分锚点
基础回答会列举保护手段。较好回答能关联到链路。优秀回答能定义降级优先级和恢复指标。
递进追问
推荐服务异常时,商品详情和下单链路分别应该如何表现?
常见失分点
只说多机房;所有功能同等保护;没有监控和演练。
关联正文
第 3 章 生产系统治理、第 9 章 高并发写与热点、第 14 章 电商用户全生命周期
复盘清单
- 我是否先定义了必须保护的核心业务?
- 我是否覆盖了预防、发现、恢复三个阶段?
Q-GEN-REL-02:超时、重试与幂等如何协作?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-REL-02 |
| 来源 | 02-middleware-reliability-interview.md,可靠性高频追问:超时、重试和幂等是什么关系? |
| 时长 | 15 分钟 |
| 难度 | 进阶 |
| 场景 | 同步与异步调用 |
| 题型 | 中间件追问 |
| 能力标签 | 超时、重试、幂等 |
题干与约束
下游偶发超时,调用方需要提高成功率但不能重复创建订单、扣库存或支付。
候选人作答任务
说明三者的职责、重试条件和幂等落点。
推荐作答顺序
先设定超时边界,再区分可重试与不可重试错误,最后定义业务唯一键和结果查询方式。
答案骨架
超时防止资源无限等待,重试处理可恢复的瞬时失败,幂等确保重复请求产生相同业务结果。调用方使用有限次数、退避和抖动;服务端用请求键、唯一约束、状态机或去重记录收敛副作用,并在未知结果时查询最终状态而非盲目重试。
评分锚点
基础回答能定义三者。较好回答有退避和错误分类。优秀回答能处理超时后结果未知与跨服务幂等。
递进追问
支付请求超时但渠道可能已扣款,客户端能否重试?服务端应返回什么?
常见失分点
所有异常都重试;只在客户端做幂等;没有重试上限和观测。
关联正文
第 3 章 生产系统治理、第 4 章 大事务处理、第 14 章 支付与交易全生命周期
复盘清单
- 我是否区分了失败、超时和结果未知?
- 我是否说明了幂等键与最终状态查询?
Q-GEN-REL-03:什么时候用熔断,什么时候用降级?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-REL-03 |
| 来源 | 02-middleware-reliability-interview.md,可靠性高频追问:什么时候用熔断,什么时候用降级? |
| 时长 | 10 分钟 |
| 难度 | 基础 |
| 场景 | 依赖故障 |
| 题型 | 中间件追问 |
| 能力标签 | 熔断、降级、业务优先级 |
题干与约束
推荐、通知和支付渠道等依赖发生持续失败,主业务需要保持可用。
候选人作答任务
区分两种机制的目标,并给出具体业务动作。
推荐作答顺序
先确定下游故障是否拖垮调用方,再判断当前功能是否可被替代或延后。
答案骨架
熔断在错误率或延迟异常时快速拒绝调用,保护调用方资源并等待探测恢复;降级是在非核心能力不可用时提供简化结果或关闭功能,保护核心业务。例如详情页可移除推荐模块,支付渠道异常应切换渠道、保留处理中状态或转人工,不能伪造成功。
评分锚点
基础回答能区分定义。较好回答能给出恢复条件。优秀回答能按业务风险设计不同降级结果。
递进追问
通知服务熔断后,怎样保证交易状态仍可被用户查询和后续补发?
常见失分点
把熔断等同于服务下线;对支付直接返回成功;没有恢复和补偿策略。
关联正文
第 3 章 生产系统治理、第 14 章 支付与交易全生命周期
复盘清单
- 我是否按核心程度定义降级结果?
- 我是否说明熔断后的恢复探测和补偿?
中间件与基础设施
Q-GEN-MW-01:什么场景应该引入消息队列?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-MW-01 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 5 题 |
| 时长 | 15 分钟 |
| 难度 | 进阶 |
| 场景 | 异步协作 |
| 题型 | 中间件追问 |
| 能力标签 | 消息队列、异步设计 |
题干与约束
订单成功后需要通知、积分和索引更新,但主链路不能被非核心下游拖慢。
候选人作答任务
说明何时引入消息队列,以及顺序、重复、积压和失败如何处理。
推荐作答顺序
先定义必须同步确认的事实,再识别可异步的副作用,最后设计可靠投递和消费治理。
答案骨架
消息队列适合削峰、异步解耦、事件传播和失败重试,不是“解耦”万能答案。核心状态先在本地事务内落稳,通过本地消息表或可靠事件发布异步驱动下游;按业务键设计分区顺序,消费者幂等,积压时限流、扩容、回压或降级。
评分锚点
基础回答能说出异步化。较好回答覆盖幂等和积压。优秀回答能区分业务顺序、投递保证和补偿边界。
递进追问
订单创建成功但事件未发出,怎样避免下游长期遗漏?
常见失分点
事务内同步发送消息且无补偿;以为消息顺序等于业务正确;忽略积压和死信。
关联正文
第 4 章 大事务处理、第 6 章 任务处理、第 14 章 订单系统
复盘清单
- 我是否说明了消息队列不承担的职责?
- 我是否覆盖投递、消费、积压和补偿?
Q-GEN-MW-02:检索场景为什么不总是使用 Elasticsearch?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-MW-02 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 6 题 |
| 时长 | 10 分钟 |
| 难度 | 基础 |
| 场景 | 查询选型 |
| 题型 | 中间件追问 |
| 能力标签 | 搜索、存储选型 |
题干与约束
需要解释复杂搜索与按主键、简单条件查询的不同技术选择。
候选人作答任务
说明 Elasticsearch 的能力边界和引入后的代价。
推荐作答顺序
先识别查询模式,再比较事务存储和搜索读模型,最后说明索引同步与降级。
答案骨架
主键和有限条件查询通常由事务存储和索引满足;全文检索、多字段筛选、相关性排序、聚合和复杂分页更适合作为独立搜索读模型。引入 Elasticsearch 后要接受索引延迟,设计重建、异步同步、版本控制和回退查询。
评分锚点
基础回答区分查询能力。较好回答提到索引延迟。优秀回答能说明何时不引入搜索系统。
递进追问
搜索索引落后于商品主数据时,详情页和交易创建分别以什么数据为准?
常见失分点
把 Elasticsearch 当权威事务库;只谈性能;忽略运维和索引重建成本。
关联正文
第 8 章 低延迟复杂读、第 11 章 商品中心、第 14 章搜索与交易全生命周期
复盘清单
- 我是否先按查询模式判断?
- 我是否说明了搜索索引不是权威数据?
Q-GEN-MW-03:如何降低 Kafka 消息丢失风险?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-MW-03 |
| 来源 | 02-middleware-reliability-interview.md,Kafka 高频追问:Kafka 如何保证消息不丢? |
| 时长 | 15 分钟 |
| 难度 | 进阶 |
| 场景 | 消息可靠性 |
| 题型 | 中间件追问 |
| 能力标签 | 可靠投递、消息恢复 |
题干与约束
关键事件需从生产端到消费端降低丢失风险,并能被业务恢复。
候选人作答任务
分段说明生产、存储、消费和业务端的保护措施。
推荐作答顺序
先确认业务事件是否已可靠地产生,再分别说明生产确认、副本存储、消费提交和补偿。
答案骨架
生产端使用确认、重试和幂等生产者;集群端使用副本和同步副本集合;消费者在业务处理成功后再提交位点。端到端仍需要本地消息表或可重放事件、幂等消费、监控积压和补偿,因为消息系统配置不能证明业务副作用已经完成。
评分锚点
基础回答知道确认和副本。较好回答知道成功后提交位点。优秀回答能覆盖业务事件生成与补偿。
递进追问
数据库事务已提交但生产者宕机,怎样确保事件最终发出?
常见失分点
只配置确认;先提交位点;把生产者幂等误当业务幂等。
关联正文
第 4 章 大事务处理、第 6 章 任务处理、第 3 章 生产系统治理
复盘清单
- 我是否按生产、存储、消费和业务四段回答?
- 我是否说明了可靠事件与业务恢复?
Q-GEN-MW-04:消息重复时如何保证结果正确?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-MW-04 |
| 来源 | 02-middleware-reliability-interview.md,Kafka 高频追问:消息重复怎么办? |
| 时长 | 15 分钟 |
| 难度 | 进阶 |
| 场景 | 消息消费 |
| 题型 | 中间件追问 |
| 能力标签 | 幂等、状态机 |
题干与约束
消费者按至少一次投递接收事件,重复消费不能造成重复扣款、积分或状态回退。
候选人作答任务
给出幂等键、状态约束和失败重试的设计。
推荐作答顺序
先定义业务唯一事件,再选择去重记录、唯一约束或条件更新,最后处理并发消费和重放。
答案骨架
默认假设消息会重复。消费者以业务唯一键或事件标识建立去重记录,结合数据库唯一约束、状态机条件更新或幂等写入,使重复执行得到相同结果。处理与标记完成要可恢复,失败可安全重试;生产者幂等只减少投递重复,不能替代业务幂等。
评分锚点
基础回答能说去重。较好回答有唯一约束和状态机。优秀回答能处理并发重复与补偿重放。
递进追问
消费者已产生外部副作用但进程在标记完成前崩溃,如何恢复?
常见失分点
仅依赖消息 ID 的内存去重;没有持久化约束;混淆投递幂等和业务幂等。
关联正文
第 4 章 大事务处理、第 5 章 长生命周期业务流程、第 14 章 订单系统
复盘清单
- 我是否明确了幂等键对应的业务语义?
- 我是否说明了重复与崩溃后的恢复?
Q-GEN-MW-05:何时选择 Elasticsearch 而不是 MySQL?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-MW-05 |
| 来源 | 02-middleware-reliability-interview.md,Elasticsearch 高频追问:为什么搜索不用 MySQL 直接查? |
| 时长 | 10 分钟 |
| 难度 | 基础 |
| 场景 | 搜索系统 |
| 题型 | 中间件追问 |
| 能力标签 | 搜索架构、读模型 |
题干与约束
商品需要关键词搜索、筛选、排序和聚合,同时交易系统不能依赖搜索索引正确性。
候选人作答任务
说明选择 Elasticsearch 的门槛、同步方案和交易边界。
推荐作答顺序
先界定搜索体验需求,再说明索引作为派生读模型,最后说明同步失败与重建。
答案骨架
当业务需要全文检索、多维筛选、相关性排序、聚合或深分页治理时选择 Elasticsearch;简单查询继续使用 MySQL。主数据变更通过异步事件更新索引,索引按版本幂等,支持全量重建和补偿;交易创建始终以权威商品、库存和价格校验为准。
评分锚点
基础回答有功能边界。较好回答有异步同步。优秀回答有索引重建、回退和交易隔离。
递进追问
索引更新延迟时,搜索结果中的过期商品怎样处理?
常见失分点
所有查询上搜索系统;同步写主库和索引;让订单直接信任索引字段。
关联正文
第 8 章 低延迟复杂读、第 11 章 商品中心、第 14 章搜索与交易全生命周期
复盘清单
- 我是否说明了选择搜索系统的具体门槛?
- 我是否保留了交易对权威状态的校验?
架构演进、成本与技术取舍
Q-GEN-EVO-01:高频系统设计题的底层共性是什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-EVO-01 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 9 题 |
| 时长 | 15 分钟 |
| 难度 | 进阶 |
| 场景 | 综合归纳 |
| 题型 | 短追问 |
| 能力标签 | 模式识别、架构演进 |
题干与约束
秒杀、库存、支付、短链接和 Feed 流题面不同,如何归纳可迁移的设计模式?
候选人作答任务
总结三类以上核心矛盾,并说明各自的通用处理手段。
推荐作答顺序
先按热点写入、权威状态、读放大和跨系统副作用分类,再给出对应设计模式。
答案骨架
高频题通常归结为:热点与吞吐,使用削峰、排队、预热和隔离;业务事实正确性,使用权威状态、条件更新、状态机和对账;跨系统副作用,使用可靠事件、幂等、补偿与观测;复杂读,使用读模型、缓存和可接受的最终一致。题面变化时先识别主矛盾,再选择模式。
评分锚点
基础回答能归类。较好回答能联系具体题目。优秀回答能说明模式失效边界和演进条件。
递进追问
若只能优先解决一个问题,如何从资损、用户体验和系统吞吐中排序?
常见失分点
只按组件分类;把所有题归为缓存或消息队列;没有权威状态和失败恢复。
关联正文
第 1 章 系统设计方法论、第 4 章 大事务处理、第 9 章 高并发写与热点
复盘清单
- 我是否先识别题目的主矛盾?
- 我是否能为每种模式说明边界和失败处理?
Q-GEN-EVO-02:系统设计面试后如何复盘并改进?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-GEN-EVO-02 |
| 来源 | 01-system-design-interview-overview.md,本章面试题与追问第 10 题;06-whiteboard-capacity-estimation.md,收尾方式 |
| 时长 | 10 分钟 |
| 难度 | 基础 |
| 场景 | 面试复盘 |
| 题型 | 方法题 |
| 能力标签 | 复盘、表达改进 |
题干与约束
一次面试后发现自己“懂很多但讲不清”,如何建立下一轮训练计划?
候选人作答任务
给出结构、内容和表达三层复盘方法,并转化为可执行练习。
推荐作答顺序
先回放回答顺序,再核对缺失的状态与失败处理,最后把问题写成下一次的限时动作。
答案骨架
结构层检查是否先讲目标、规模、主链路、状态、失败和演进;内容层检查结论是否有依据、是否遗漏权威状态与补偿;表达层检查是否先报框架、是否术语堆叠。每个失分点转成可验证动作,下一轮在改变约束后重答并比较改进。
评分锚点
基础回答会复盘。较好回答能分层。优秀回答能设置可观察的训练目标并根据追问调整。
递进追问
如果每次都遗漏异常处理,下一次白板上应加入什么固定检查点?
常见失分点
只看答案对错;只背更多题;没有记录假设和被追问的边界。
关联正文
复盘清单
- 我是否能列出本次最影响结果的三项遗漏?
- 我是否把每项遗漏转化为下一次可验证动作?
通用答题速查
以下内容用于补足题卡中的选型表达,不替代题卡的完整作答。
缓存策略
| 策略 | 适用场景 | 主要代价 |
|---|---|---|
| 缓存旁路 | 通用读多写少 | 需要处理失效窗口和回源 |
| 读穿透 | 读密集 | 实现与排障边界更复杂 |
| 写穿透 | 对读一致性要求较高 | 写延迟与吞吐受影响 |
| 异步回写 | 写密集 | 需面对数据丢失和恢复风险 |
分布式事务
| 方案 | 适用判断 | 主要代价 |
|---|---|---|
| 两阶段提交 | 必须同步收敛且参与方可控 | 可用性与性能成本高 |
| TCC | 资源可预留、可确认和可取消 | 业务实现复杂 |
| Saga | 长流程可补偿 | 需接受中间状态和补偿设计 |
| 本地消息表 | 多数异步副作用 | 需要消费者幂等和对账 |
限流算法
| 算法 | 适用判断 | 注意点 |
|---|---|---|
| 计数器 | 简单固定窗口限制 | 边界突刺明显 |
| 滑动窗口 | 需要更平滑的统计 | 成本更高 |
| 令牌桶 | 允许有限突发 | 需设置补充速率和容量 |
| 漏桶 | 需要平滑输出 | 可能增加排队延迟 |
分库分表
分片键应服务于主要查询和写入均衡,常见选择包括用户标识、订单标识与时间。先确认跨分片查询、扩容、归档和全局唯一标识的成本;规模未到阈值前可保持单库,同时预留分片键和归档策略。
原第 39 章:电商系统设计专项题库
本章将原电商架构、商品库存营销计价、交易链路、综合案例和补充答辩材料统一为可独立使用的题卡。所有迁移题卡保留原始来源定位与详细答案材料。
专题答辩资料与技术速查
面试官题库导航
电商专项面试可按候选人的项目背景组合题卡:先用平台边界或商品建模题确认业务语义,再用库存、订单或支付题检查状态与一致性,最后用大促、同步或综合案例题观察恢复能力和长期取舍。60 至 90 分钟的面试通常覆盖一个架构题、一个交易或供给题,以及一个峰值或故障追问。
专题答辩资料:商品、库存、营销与计价综合题回答思路
如果题目要求设计“商品下单前链路”,可以按下面顺序讲:
- 商品中心提供商品、SKU、类目和上下架状态。
- 库存系统提供可售判断和预占能力。
- 营销系统完成优惠试算、活动资格校验和权益锁定。
- 计价系统汇总价格、优惠、运费、税费并生成价格明细。
- 结算或订单系统保存必要快照,避免后续价格变化影响历史订单。
这个回答顺序能自然体现边界和协作,而不是把所有逻辑堆进订单系统。
专题答辩资料:业务架构图表述模板
面试与评审中的表述模板:「业务架构图不是组织架构图;它描述的是能力边界与语义所有权。若两个团队共改一张宽表,通常意味着限界上下文识别失败或缺少明确的集成契约。」
专题答辩资料:常用 Go 库推荐
下面的库按职责覆盖常见电商后端实现;实际选型仍应结合团队维护能力、许可证和当前版本兼容性判断。
// Web框架
github.com/gin-gonic/gin
// ORM
gorm.io/gorm
// Redis
github.com/go-redis/redis/v8
// 消息队列
github.com/Shopify/sarama // Kafka
github.com/streadway/amqp // RabbitMQ
// 分布式追踪
go.opentelemetry.io/otel
// 配置管理
github.com/spf13/viper
// 限流
golang.org/x/time/rate
// 日志
go.uber.org/zap
// 数值计算
github.com/shopspring/decimal
电商平台架构与领域边界
本节覆盖平台架构、服务边界与跨系统一致性。
Q-ECOM-ARCH-001:如何设计一个中大型电商平台的服务拆分边界?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-001 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、架构演进 |
| 场景标签 | 中大型电商、平台架构 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:11 |
题干与约束
假设你接手一个日订单50万的电商平台,目前是单体应用,团队规模100人。现在需要进行微服务化改造。请设计服务拆分方案。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 假设你接手一个日订单50万的电商平台,目前是单体应用,团队规模100人。现在需要进行微服务化改造。请设计服务拆分方案。
答案:
问题分析: 服务拆分的核心挑战在于:
- 既要保证领域边界清晰(DDD视角)
- 又要考虑团队规模和协作效率
- 还要兼顾性能和一致性要求
- 需要平衡拆分粒度和运维复杂度
方案一:按业务能力垂直拆分
核心服务划分:
- 核心交易域:订单、支付、结算、购物车(4个服务)
- 商品供给域:商品中心、库存、计价、营销(4个服务)
- 用户导购域:搜索、推荐、用户中心(3个服务)
- 运营支撑域:商品上架、B端运营、数据分析(3个服务)
拆分原则:
- 每个服务对应一个限界上下文(Bounded Context)
- 服务间通过稳定的API契约通信
- 核心链路服务优先拆分,支撑域可暂时保留
优点:
- 团队自治性强,可并行开发
- 业务边界清晰,易于理解和维护
- 符合DDD最佳实践
- 故障隔离效果好
缺点:
- 需要处理分布式事务
- 服务间调用增加网络开销
- 初期实施复杂度较高
- 需要完善的基础设施支持
方案二:按技术特征水平拆分
划分:
- 高并发读服务:商品详情、搜索、列表(使用缓存和ES)
- 强一致写服务:订单、支付、库存扣减(使用分布式事务)
- 异步处理服务:消息通知、数据同步、报表生成
优点:
- 技术栈统一,便于基础设施复用
- 性能优化方向明确
- 团队技能要求更聚焦
缺点:
- 业务边界模糊,团队协作困难
- 不符合微服务自治原则
- 业务变更可能需要跨多个服务修改
方案三:绞杀者模式渐进拆分
实施步骤:
- 第一阶段:拆出搜索(读多写少,影响面小)
- 第二阶段:拆商品中心(依赖少,数据模型清晰)
- 第三阶段:拆订单链路(核心但复杂,需要Saga编排)
- 第四阶段:处理遗留单体(逐步清理剩余功能)
优点:
- 风险可控,可持续交付
- 团队学习曲线平缓
- 每个阶段都有明确产出
缺点:
- 迁移周期较长(可能需要6-12个月)
- 中间状态维护成本高
- 需要维护双写逻辑
方案对比:
| 维度 | 方案一(垂直) | 方案二(水平) | 方案三(渐进) |
|---|---|---|---|
| 团队自治 | ★★★★★ | ★★☆☆☆ | ★★★☆☆ |
| 技术复杂度 | ★★★☆☆ | ★★★★☆ | ★★☆☆☆ |
| 交付速度 | ★★★☆☆ | ★★★★☆ | ★★★★☆ |
| 长期维护 | ★★★★★ | ★★☆☆☆ | ★★★★☆ |
推荐方案: 采用方案一+方案三的结合:按领域垂直划分目标架构,采用绞杀者模式渐进实施。
实施要点:
- 前期准备:梳理系统依赖图,识别核心路径和边界
- 基础设施先行:建立服务网格、监控、链路追踪、配置中心
- 服务契约规范:定义API标准、版本管理、错误码体系
- 数据迁移策略:双写+对账+延迟删除
- 灰度发布机制:按用户维度或地域逐步切流量
延伸思考:
- 如何处理拆分过程中的数据迁移?
- 分布式事务如何保证?
- 服务间调用的超时和重试策略如何设计?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:11。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-002:如何设计电商系统的数据一致性方案?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-002 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、分布式一致性 |
| 场景标签 | 中大型电商、平台架构、跨服务协作 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:111 |
题干与约束
电商系统中,订单创建涉及库存扣减、优惠券核销、积分扣除等多个操作,这些操作分散在不同的微服务中。如何保证数据一致性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商系统中,订单创建涉及库存扣减、优惠券核销、积分扣除等多个操作,这些操作分散在不同的微服务中。如何保证数据一致性?
答案:
问题分析: 数据一致性的核心挑战:
- 多个微服务涉及写操作,无法使用传统数据库事务
- 部分操作可能失败,需要补偿机制
- 性能要求高,不能因为一致性牺牲太多性能
- 需要考虑系统可用性,不能因为一个服务故障导致整体不可用
方案一:Saga模式(编排式)
设计思路: 由订单服务作为编排器(Orchestrator),协调各个服务的操作。
流程:
- 订单服务:创建订单(状态:PENDING)
- 调用库存服务:预占库存(成功继续,失败取消订单)
- 调用营销服务:锁定优惠券(成功继续,失败释放库存)
- 调用积分服务:扣除积分(成功继续,失败释放库存+券)
- 更新订单状态:CONFIRMED
优点:
- 流程清晰,易于理解和调试
- 中心化控制,便于监控和排查问题
- 补偿逻辑集中管理
缺点:
- 订单服务成为单点,压力较大
- 流程变更需要修改编排器
- 服务间耦合度较高
方案二:Saga模式(事件编排式)
设计思路: 通过事件总线(Kafka)进行编排,各服务监听事件并发布新事件。
流程:
- 订单服务:创建订单 → 发布 OrderCreated 事件
- 库存服务:监听 OrderCreated → 预占库存 → 发布 InventoryReserved 事件
- 营销服务:监听 InventoryReserved → 锁定优惠券 → 发布 CouponLocked 事件
- 积分服务:监听 CouponLocked → 扣除积分 → 发布 PointsDeducted 事件
- 订单服务:监听 PointsDeducted → 更新订单状态为 CONFIRMED
优点:
- 服务解耦,各服务独立演进
- 无中心化瓶颈,扩展性好
- 天然支持异步,性能更好
缺点:
- 流程分散,难以全局把控
- 调试困难,需要完善的链路追踪
- 事件顺序和幂等性要求高
方案三:TCC(Try-Confirm-Cancel)
设计思路: 分为三个阶段:Try(预留)、Confirm(确认)、Cancel(取消)。
流程:
- Try阶段:预留资源(库存预占、券锁定、积分冻结)
- Confirm阶段:确认扣减(库存确认、券核销、积分扣除)
- Cancel阶段:取消操作(释放库存、释放券、解冻积分)
优点:
- 强一致性,业务语义清晰
- 资源锁定明确,不会出现超卖
- 适合对一致性要求极高的场景
缺点:
- 实现复杂,需要每个服务提供三个接口
- 性能开销大(锁定资源时间长)
- Try阶段占用资源,影响并发度
方案对比:
| 维度 | Saga-编排 | Saga-事件 | TCC |
|---|---|---|---|
| 实现复杂度 | ★★★☆☆ | ★★★★☆ | ★★★★★ |
| 性能 | ★★★☆☆ | ★★★★☆ | ★★☆☆☆ |
| 一致性强度 | 最终一致 | 最终一致 | 强一致 |
| 可观测性 | ★★★★☆ | ★★★☆☆ | ★★★★☆ |
推荐方案: 对于电商系统,推荐Saga-编排模式作为主方案,辅以事件通知。
实施要点:
- 订单服务作为编排器,维护状态机
- 每个操作携带唯一业务ID保证幂等性
- 每个步骤设置合理超时(如库存3s,优惠券2s)
- 维护补偿表记录需要补偿的操作
- 每个步骤记录日志,包含traceId
延伸思考:
- 如何处理补偿失败的情况?
- 最终一致性的“最终“是多久?
- 如何进行一致性验证和对账?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:111。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-003:如何设计服务间的调用链路追踪?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-003 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、可观测性 |
| 场景标签 | 中大型电商、平台架构、故障定位 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:430 |
题干与约束
微服务架构下,一个用户请求可能经过十几个服务。当出现问题时,如何快速定位是哪个服务出了问题?请设计分布式追踪方案。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 微服务架构下,一个用户请求可能经过十几个服务。当出现问题时,如何快速定位是哪个服务出了问题?请设计分布式追踪方案。
答案:
问题分析: 链路追踪的核心挑战:
- 如何关联一次请求涉及的所有服务调用
- 如何记录调用链路的详细信息(耗时、参数、结果)
- 如何在性能开销和可观测性之间平衡
- 如何快速检索和分析海量追踪数据
方案一:自研追踪系统
核心思路: 基于唯一TraceID串联整个调用链,每个服务记录SpanID。
设计:
- TraceID:全局唯一ID,标识一次完整请求
- SpanID:服务内部的调用单元ID
- 传递机制:通过HTTP Header或RPC Context传递
- 数据收集:每个服务将Span数据异步上报
- 存储分析:存储到Elasticsearch,Kibana可视化
实现步骤:
- 网关生成TraceID
- 服务间传递TraceID和ParentSpanID
- 每个服务记录:服务名、方法名、开始时间、结束时间、状态
- 异步上报到追踪系统
优点:
- 完全可控,可定制
- 无外部依赖
- 数据私密性好
缺点:
- 开发成本高
- 需要所有服务埋点
- 维护成本高
方案二:使用开源APM(如Skywalking)
核心思路: 使用Java Agent无侵入式采集调用链数据。
设计:
- Agent方式:通过JavaAgent字节码增强自动埋点
- OAP Server:接收、分析、存储追踪数据
- UI:可视化展示调用链拓扑、性能指标
- 告警:支持性能阈值告警
优点:
- 无侵入,不需要改代码
- 功能完善(拓扑、指标、告警)
- 社区活跃,文档丰富
- 支持多种框架(Spring、Dubbo、gRPC)
缺点:
- Agent有一定性能开销
- 不支持非Java语言
- 定制化能力有限
方案三:使用云厂商APM(如阿里云ARMS)
核心思路: 使用云厂商提供的APM服务,开箱即用。
设计:
- SDK集成:引入SDK,自动上报追踪数据
- 云端分析:云厂商负责数据存储和分析
- 控制台:提供丰富的可视化和分析能力
- AI诊断:智能分析性能瓶颈
优点:
- 开箱即用,实施快
- 功能强大(AI诊断、实时监控)
- 无需自建基础设施
- 技术支持好
缺点:
- 成本高(按量付费)
- 数据外传有安全风险
- 被云厂商锁定
方案对比:
| 维度 | 自研 | Skywalking | 云APM |
|---|---|---|---|
| 实施成本 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 功能丰富度 | ★★★☆☆ | ★★★★☆ | ★★★★★ |
| 定制能力 | ★★★★★ | ★★★☆☆ | ★★☆☆☆ |
| 运维成本 | ★★☆☆☆ | ★★★☆☆ | ★★★★★ |
推荐方案: 根据团队规模选择:
- 大团队(100+人):自研追踪系统,可定制化
- 中等团队(20-100人):使用Skywalking,开源免费
- 小团队(<20人):使用云APM,开箱即用
实施要点:
- 采样策略:不是所有请求都追踪,按比例采样(如1%)
- 性能优化:异步上报,避免阻塞主流程
- 标准化:定义统一的TraceID、SpanID规范
- 关键节点:重点追踪慢查询、外部调用、错误日志
- 告警配置:P99延迟、错误率等关键指标告警
延伸思考:
- 如何在不影响性能的前提下采集足够的追踪数据?
- 如何处理跨语言服务的追踪?
- 追踪数据如何与日志、指标关联?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:430。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-004:如何平衡微服务拆分的粒度?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-004 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、架构演进 |
| 场景标签 | 中大型电商、平台架构 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:545 |
题干与约束
在进行微服务拆分时,服务拆得太粗会失去微服务的优势,拆得太细会导致运维复杂度爆炸。如何平衡服务拆分的粒度?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 在进行微服务拆分时,服务拆得太粗会失去微服务的优势,拆得太细会导致运维复杂度爆炸。如何平衡服务拆分的粒度?
答案:
问题分析: 服务粒度的核心挑战:
- 服务太粗:失去独立部署、技术异构的优势
- 服务太细:服务数量多,运维成本高,调用链路长
- 边界不清:职责重叠,数据冗余
- 团队匹配:服务划分要与团队结构匹配
方案一:按领域模型拆分(DDD)
核心思路: 基于领域驱动设计(DDD)的限界上下文划分服务。
判断标准:
- 业务完整性:一个聚合根对应一个服务
- 独立演进:服务内部变化不影响其他服务
- 团队自治:一个团队(5-9人)负责一个服务
- 数据自治:服务拥有独立的数据库
示例:
- 订单服务:负责订单生命周期管理
- 库存服务:负责库存预占、扣减、释放
- 支付服务:负责支付路由、状态管理、对账
优点:
- 业务边界清晰
- 符合康威定律
- 易于理解和维护
缺点:
- 需要团队理解DDD
- 前期建模成本高
- 对业务专家依赖大
方案二:按变化频率拆分
核心思路: 将变化频繁的功能和稳定的功能拆开。
判断标准:
- 变化频率:营销活动(周级)vs 用户中心(月级)
- 技术栈:搜索(ES)vs 订单(MySQL)
- 性能要求:详情页(缓存)vs 下单(强一致)
示例:
- 营销服务:促销规则频繁变化,独立拆分
- 计价服务:计算逻辑复杂但相对稳定
- 用户服务:用户信息变化少,可合并
优点:
- 快速响应业务变化
- 技术选型灵活
- 易于优化性能
缺点:
- 可能打破业务边界
- 数据一致性复杂
- 难以预测变化频率
方案三:按团队规模拆分(两个披萨原则)
核心思路: 一个团队负责的服务数量,要让团队可以“用两个披萨喂饱“(5-9人)。
判断标准:
- 团队规模:一个5-9人团队负责2-3个服务
- 认知负载:团队能理解和维护的复杂度
- 沟通成本:减少跨团队协作
示例:
- 订单团队:订单服务 + 售后服务 + 物流编排服务
- 商品团队:商品服务 + 类目服务 + 品牌服务
- 库存团队:库存服务 + 仓储服务
优点:
- 团队职责清晰
- 减少沟通成本
- 符合组织架构
缺点:
- 服务粒度可能不均衡
- 团队变化时需要调整
- 可能打破业务边界
方案对比:
| 维度 | DDD拆分 | 变化频率 | 团队规模 |
|---|---|---|---|
| 业务清晰度 | ★★★★★ | ★★★☆☆ | ★★★☆☆ |
| 响应速度 | ★★★☆☆ | ★★★★★ | ★★★★☆ |
| 实施难度 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 长期维护 | ★★★★★ | ★★★☆☆ | ★★★★☆ |
推荐方案: 采用DDD为主,兼顾变化频率和团队规模的混合策略。
实施要点:
- 核心域优先DDD:订单、支付等核心域严格按DDD拆分
- 支撑域灵活拆分:搜索、推荐等可按技术特征拆分
- 团队匹配:确保每个团队能hold住负责的服务
- 逐步演进:初期粗粒度,随业务发展逐步拆细
- 防止过度拆分:服务数量控制在团队规模的2-3倍
判断粒度是否合适的指标:
- 服务代码量:1-3万行最佳
- 团队负担:一个团队2-3个服务
- 调用链路:核心链路不超过5跳
- 部署频率:每周至少部署1次
- 故障恢复:单服务故障不影响全局
延伸思考:
- 如何判断服务拆得太细了?
- 已有服务如何合并?
- 服务拆分后如何保证数据一致性?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:545。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-005:电商系统的技术债治理策略
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-005 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、架构演进 |
| 场景标签 | 中大型电商、平台架构 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:668 |
题干与约束
随着业务快速发展,系统积累了大量技术债(如代码重复、过度耦合、缺少测试)。如何在保证业务持续交付的前提下,有效治理技术债?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 随着业务快速发展,系统积累了大量技术债(如代码重复、过度耦合、缺少测试)。如何在保证业务持续交付的前提下,有效治理技术债?
答案:
问题分析: 技术债治理的核心挑战:
- 业务压力大,没有时间重构
- 技术债范围广,不知从何下手
- ROI不明确,难以说服业务
- 改造风险高,担心引入新问题
方案一:停止新功能,集中还债
核心思路: 暂停新功能开发1-2个月,团队集中精力重构优化。
实施步骤:
- 评估技术债清单(代码质量、架构问题、性能问题)
- 按影响面和风险排优先级
- 集中2个月时间重构
- 重构完成后恢复业务开发
优点:
- 集中资源,效率高
- 可以做系统性重构
- 团队专注度高
缺点:
- 业务方难以接受(2个月不交付)
- 改造风险集中爆发
- 团队压力大
方案二:绞杀式重构,逐步替换
核心思路: 不停止业务开发,在交付新功能时逐步重构。
实施策略:
- 新功能新写法:新功能用新架构实现
- 改功能顺便重构:修改老功能时顺便重构
- 热点优先:优先重构变化频繁的模块
- 设置重构配额:每个迭代20%时间用于重构
示例:
- 迭代1:新功能A(新架构) + 重构模块X
- 迭代2:新功能B(新架构) + 重构模块Y
- 迭代3:新功能C(新架构) + 重构模块Z
优点:
- 业务持续交付
- 风险分散,可控
- 团队适应性好
缺点:
- 周期长(可能需要6-12个月)
- 需要团队自律
- 新老代码共存,维护成本高
方案三:分层治理,重点突破
核心思路: 识别高价值技术债,集中资源重点治理。
分层策略:
- P0紧急债:影响线上稳定性(立即处理)
- 例:核心接口性能问题、安全漏洞
- P1重要债:影响开发效率(1个月内处理)
- 例:核心模块耦合严重、缺少测试
- P2普通债:代码质量问题(逐步优化)
- 例:代码重复、命名不规范
- P3可忽略:历史遗留问题(不处理)
- 例:废弃功能的代码
实施步骤:
- 扫描技术债(代码扫描工具 + 人工评估)
- 分级打标(P0/P1/P2/P3)
- P0立即处理,P1排入迭代,P2见缝插针
- 每月review技术债清单
优点:
- 重点突出,ROI高
- 灵活可控
- 容易说服业务
缺点:
- 需要持续跟进
- 分级标准难以统一
- P2/P3的债永远还不完
方案对比:
| 维度 | 集中还债 | 绞杀重构 | 分层治理 |
|---|---|---|---|
| 业务影响 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 治理效率 | ★★★★★ | ★★★☆☆ | ★★★★☆ |
| 风险控制 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 实施难度 | ★★★☆☆ | ★★★★☆ | ★★★★☆ |
推荐方案: 采用分层治理为主,绞杀重构为辅的组合策略。
实施要点:
- 建立技术债看板:可视化展示技术债清单和进度
- 设置重构配额:每个迭代15-20%时间用于技术债
- 重构优先级:P0立即处理,P1必须进迭代,P2机动
- 度量指标:代码覆盖率、圈复杂度、重复率、Bug密度
- 自动化检测:SonarQube扫描,PR卡点
- 技术债评审会:每月评审技术债治理进展
关键原则:
- 不积累新债:新代码必须符合质量标准
- 热点优先:优先重构变化频繁的模块
- 小步快跑:每次重构范围可控,及时验证
- 有始有终:重构必须完整,不能半途而废
- 文档同步:重构后及时更新架构文档
延伸思考:
- 如何评估技术债的严重程度和优先级?
- 重构过程中如何保证不引入新问题?
- 如何说服业务投入资源治理技术债?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:668。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-006:如何设计配置中心?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-006 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、架构演进 |
| 场景标签 | 中大型电商、平台架构 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:794 |
题干与约束
微服务架构下,配置分散在各个服务中,修改配置需要重启服务。请设计一个配置中心,支持配置的集中管理和动态更新。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 微服务架构下,配置分散在各个服务中,修改配置需要重启服务。请设计一个配置中心,支持配置的集中管理和动态更新。
答案:
问题分析: 配置中心的核心挑战:
- 配置变更如何实时推送到服务
- 如何保证配置的一致性和可靠性
- 如何支持灰度发布和快速回滚
- 如何保证配置的安全性(敏感信息加密)
方案一:基于文件+定时拉取
核心思路: 配置存储在Git,服务定时拉取配置文件。
设计:
- 存储:配置文件存储在Git仓库
- 拉取:服务每隔30秒拉取一次配置
- 生效:检测到配置变更,重新加载
- 版本:Git commit作为配置版本
优点:
- 实现简单
- 天然的版本管理
- 可以code review配置
缺点:
- 实时性差(最多30秒延迟)
- Git作为配置中心不太合适
- 无法精准推送
方案二:基于数据库+长轮询
核心思路: 配置存储在数据库,服务通过长轮询获取配置更新。
设计:
- 存储:配置存储在MySQL,包含版本号
- 推送:服务发起长轮询请求(超时60秒)
- 变更检测:配置中心检测到配置变更,立即返回
- 生效:服务收到变更通知,重新加载配置
优点:
- 实时性好(秒级)
- 实现相对简单
- 支持灰度发布
缺点:
- 长连接占用资源
- 数据库压力大(大量服务轮询)
- 扩展性有限
方案三:基于注册中心+Watch机制
核心思路: 配置存储在注册中心(如Nacos、Apollo),通过Watch机制推送。
设计:
- 存储:配置存储在Nacos
- Watch:服务订阅配置,Nacos主动推送变更
- 命名空间:按环境(dev/test/prod)隔离
- 灰度发布:支持按IP、比例灰度
- 权限控制:RBAC权限管理
实现:
1. 服务启动时注册到Nacos
2. 订阅所需的配置(dataId + group)
3. Nacos检测到配置变更,通过长连接推送
4. 服务收到通知,触发配置刷新回调
优点:
- 实时性强(毫秒级)
- 功能完善(灰度、回滚、审计)
- 高可用(Nacos集群)
- 开箱即用
缺点:
- 依赖第三方组件
- 学习成本
- 运维复杂度
方案对比:
| 维度 | 文件+拉取 | 数据库+长轮询 | 注册中心+Watch |
|---|---|---|---|
| 实时性 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 可靠性 | ★★★☆☆ | ★★★☆☆ | ★★★★★ |
| 扩展性 | ★★★☆☆ | ★★☆☆☆ | ★★★★★ |
| 实施成本 | ★★★★★ | ★★★★☆ | ★★★☆☆ |
推荐方案: 对于中大型系统,推荐使用Nacos作为配置中心。
实施要点:
-
配置分层:
- 环境配置(dev/test/prod)
- 公共配置(数据库连接池、日志级别)
- 应用配置(业务参数)
- 敏感配置(密码、密钥)加密存储
-
命名规范:
- dataId:
${应用名}-${环境}.${格式} - group:
${业务域} - 例:
order-service-prod.yaml,group:transaction
- dataId:
-
配置刷新:
- 使用
@RefreshScope注解 - 或实现
ConfigChangeListener接口 - 敏感配置(数据库连接)不支持热更新
- 使用
-
灰度发布:
- 先在灰度环境验证
- 按IP或比例逐步推送
- 监控关键指标,出问题立即回滚
-
安全控制:
- 敏感配置加密存储(AES/RSA)
- RBAC权限管理(谁能改什么配置)
- 审计日志(谁在什么时候改了什么)
- 配置变更必须经过审批流程
-
高可用:
- Nacos集群部署(至少3节点)
- 客户端本地缓存(Nacos不可用时降级)
- 配置备份(定期导出到Git)
延伸思考:
- 配置中心本身如何保证高可用?
- 敏感配置如何加密存储?
- 如何保证配置变更的安全性(防止误操作)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:794。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-007:领域驱动设计在电商系统中的应用
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-007 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、架构演进 |
| 场景标签 | 中大型电商、平台架构 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:933 |
题干与约束
你需要用DDD的思想设计电商订单系统。请描述如何识别聚合根、实体、值对象,以及如何划分限界上下文。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 你需要用DDD的思想设计电商订单系统。请描述如何识别聚合根、实体、值对象,以及如何划分限界上下文。
答案:
问题分析: DDD在电商中的核心挑战:
- 如何识别领域边界(限界上下文)
- 如何设计聚合根和实体关系
- 如何处理跨聚合的数据一致性
- 如何平衡领域纯粹性和工程实践
方案一:贫血模型+服务层
核心思路: 实体只包含数据,业务逻辑在服务层。
设计:
实体:
- Order(订单):id、userId、items、status、amount
- OrderItem(订单项):productId、quantity、price
服务层:
- OrderService.createOrder()
- OrderService.pay()
- OrderService.cancel()
优点:
- 简单直观,易于理解
- 数据库映射方便
- 团队容易上手
缺点:
- 不是真正的DDD(贫血模型)
- 业务逻辑分散在服务层
- 领域知识不内聚
方案二:充血模型+聚合根
核心思路: 实体包含数据和行为,聚合根负责维护聚合内的一致性。
设计:
聚合根:Order
- 领域对象:
- Order(聚合根):id、userId、items、status、amount
- OrderItem(实体):productId、quantity、price
- Address(值对象):province、city、street
- 领域行为:
- Order.create():创建订单
- Order.pay():支付订单
- Order.cancel():取消订单
- Order.addItem():添加商品
- 不变量:
- 订单金额 = sum(items.price * items.quantity)
- 订单只能从PENDING状态支付
优点:
- 业务逻辑内聚在领域对象中
- 符合DDD思想
- 易于测试(单元测试聚合根)
缺点:
- 学习曲线陡峭
- ORM映射复杂
- 团队需要理解DDD
方案三:CQRS+事件溯源
核心思路: 读写分离,写入用事件溯源,读取用专门的查询模型。
设计:
写模型(Command):
- 命令:CreateOrderCommand、PayOrderCommand
- 聚合根:Order(通过重放事件恢复状态)
- 事件:OrderCreated、OrderPaid、OrderCancelled
读模型(Query):
- 查询模型:OrderView(专门优化查询)
- 投影:监听事件,更新查询模型
优点:
- 读写分离,性能优化
- 完整的审计日志(事件)
- 易于扩展(新增读模型)
缺点:
- 架构复杂度高
- 事件版本管理困难
- 最终一致性
方案对比:
| 维度 | 贫血模型 | 充血模型 | CQRS+ES |
|---|---|---|---|
| 实施难度 | ★★★★★ | ★★★☆☆ | ★☆☆☆☆ |
| DDD纯粹性 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 性能 | ★★★☆☆ | ★★★☆☆ | ★★★★★ |
| 适用场景 | 简单CRUD | 复杂业务 | 高性能+审计 |
推荐方案: 对于电商订单系统,推荐充血模型+聚合根。
实施要点:
-
识别限界上下文:
- 订单上下文:订单生命周期、订单状态管理
- 库存上下文:库存预占、扣减、释放
- 支付上下文:支付路由、支付状态、对账
- 物流上下文:物流单、轨迹跟踪
-
设计聚合根:
- Order(订单聚合根)
- 实体:OrderItem
- 值对象:Address、Money
- 行为:create、pay、ship、cancel
- 不变量:订单金额一致性、状态流转规则
- Order(订单聚合根)
-
跨聚合协作:
- 强一致性:同一聚合内(订单和订单项)
- 最终一致性:跨聚合(订单和库存)
- 集成方式:领域事件 + Saga编排
-
代码组织:
order/
├── domain/ # 领域层
│ ├── Order.java # 聚合根
│ ├── OrderItem.java # 实体
│ ├── Address.java # 值对象
│ └── OrderStatus.java # 枚举
├── application/ # 应用层
│ └── OrderService.java # 应用服务(编排)
├── infrastructure/ # 基础设施层
│ ├── OrderRepository.java # 仓储接口
│ └── OrderRepositoryImpl.java # 仓储实现
└── api/ # 接口层
└── OrderController.java # REST API
- 关键设计决策:
- 聚合边界:Order包含OrderItem,不包含Product(属于商品上下文)
- ID生成:聚合根负责生成ID(UUID或雪花ID)
- 领域事件:订单状态变更发布事件(OrderPaid、OrderShipped)
- 仓储模式:通过Repository加载/保存聚合根
- 工厂模式:复杂的聚合创建用工厂
延伸思考:
- 如何处理跨聚合根的事务(如订单和库存)?
- 充血模型如何映射到数据库(JPA/MyBatis)?
- DDD在微服务中如何落地(一个聚合根一个服务?)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:933。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-008:设计订单与库存的一致性保证方案
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-008 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、分布式一致性 |
| 场景标签 | 中大型电商、平台架构、跨服务协作 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1268 |
题干与约束
在订单创建流程中,需要同时扣减库存。订单服务和库存服务是两个独立的微服务,如何保证订单创建和库存扣减的一致性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 在订单创建流程中,需要同时扣减库存。订单服务和库存服务是两个独立的微服务,如何保证订单创建和库存扣减的一致性?
答案:
问题分析: 订单库存一致性的核心挑战:
- 不能使用分布式事务(性能差、可用性低)
- 需要防止库存超卖
- 订单创建失败时库存要回滚
- 用户取消订单时库存要释放
方案一:订单服务调用库存服务(同步)
核心思路: 订单创建时同步调用库存服务扣减库存,失败则取消订单。
流程:
- 订单服务:创建订单(状态:PENDING)
- 同步调用库存服务:扣减库存
- 成功:订单状态更新为CONFIRMED
- 失败:订单状态更新为CANCELLED
- 返回结果给用户
优点:
- 实现简单直观
- 实时一致性
- 用户立即知道结果
缺点:
- 同步调用性能差
- 库存服务故障影响订单创建
- 网络超时难处理(已扣减但订单不知道)
方案二:本地消息表+最终一致性
核心思路: 订单创建后发送消息给库存服务,库存服务异步扣减。
流程:
- 订单服务:
- 开启事务
- 插入订单(状态:PENDING)
- 插入本地消息表(OrderCreated事件)
- 提交事务
- 消息发送器:定时扫描本地消息表,发送到MQ
- 库存服务:监听MQ,扣减库存
- 库存服务:发送库存扣减成功事件
- 订单服务:监听事件,更新订单状态为CONFIRMED
优点:
- 异步解耦,性能好
- 库存服务故障不影响订单创建
- 消息可靠性高(本地消息表)
缺点:
- 最终一致性(用户不能立即知道结果)
- 实现复杂
- 需要处理消息重复
方案三:库存预占+两阶段提交
核心思路: 订单创建时先预占库存,支付成功后确认扣减,取消时释放。
流程:
- 订单服务:创建订单(状态:PENDING)
- 同步调用库存服务:预占库存(库存减少,预占量增加)
- 用户支付成功后:
- 订单状态更新为PAID
- 异步通知库存服务确认扣减(预占量减少)
- 用户取消订单:
- 订单状态更新为CANCELLED
- 异步通知库存服务释放预占(库存增加,预占量减少)
优点:
- 防止超卖(预占时检查库存)
- 用户体验好(立即知道有没有库存)
- 支持取消释放
缺点:
- 库存设计复杂(实际库存、预占库存、可售库存)
- 预占超时释放机制
- 长时间预占影响其他用户
方案对比:
| 维度 | 同步调用 | 本地消息表 | 库存预占 |
|---|---|---|---|
| 一致性 | 强一致 | 最终一致 | 强一致 |
| 性能 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 用户体验 | ★★★★★ | ★★☆☆☆ | ★★★★★ |
| 实施难度 | ★★★★☆ | ★★☆☆☆ | ★★★☆☆ |
推荐方案: 对于电商系统,推荐库存预占方案。
实施要点:
- 库存表设计:
total_stock:总库存reserved_stock:预占库存available_stock= total_stock - reserved_stock
- 预占接口:先检查available_stock,再扣减
- 预占超时:定时任务扫描超时订单,释放预占
- 幂等保证:使用orderId+action作为唯一键
- 监控告警:监控预占释放率、超时订单量
延伸思考:
- 如果库存预占接口超时,订单服务如何处理?
- 预占超时时间如何设定(太短影响支付,太长占用库存)?
- 秒杀场景下如何优化库存扣减性能?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1268。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-009:Saga模式在电商系统中的应用
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-009 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、服务边界、分布式一致性 |
| 场景标签 | 中大型电商、平台架构、跨服务协作 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1384 |
题干与约束
电商订单创建涉及多个服务(库存、优惠券、积分、支付),如何使用Saga模式保证分布式事务的一致性?请详细设计Saga编排方案。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商订单创建涉及多个服务(库存、优惠券、积分、支付),如何使用Saga模式保证分布式事务的一致性?请详细设计Saga编排方案。
答案:
问题分析: Saga在电商中的核心挑战:
- 如何设计补偿逻辑
- 如何处理部分失败
- 如何保证幂等性
- 如何监控和排查问题
方案一:编排式Saga(Orchestration)
核心思路: 由订单服务作为Saga协调器,集中编排整个流程。
设计:
SagaOrchestrator(订单服务)
├── Step1: 创建订单
├── Step2: 预占库存
│ └── 补偿:释放库存
├── Step3: 锁定优惠券
│ └── 补偿:释放优惠券
├── Step4: 扣除积分
│ └── 补偿:返还积分
└── Step5: 创建支付单
└── 补偿:取消支付单
实现代码结构:
public class OrderSagaOrchestrator {
private final SagaStateMachine stateMachine;
public void createOrder(OrderRequest req) {
SagaContext ctx = new SagaContext(req);
// 执行Saga步骤
stateMachine
.step("CreateOrder", this::createOrder, this::cancelOrder)
.step("ReserveInventory", this::reserveInventory, this::releaseInventory)
.step("LockCoupon", this::lockCoupon, this::releaseCoupon)
.step("DeductPoints", this::deductPoints, this::refundPoints)
.step("CreatePayment", this::createPayment, this::cancelPayment)
.execute(ctx);
}
}
优点:
- 流程集中,易于理解
- 便于监控和调试
- 补偿逻辑清晰
缺点:
- 订单服务耦合度高
- 协调器是单点
方案二:事件编排式Saga(Choreography)
核心思路: 通过事件驱动,各服务监听事件自主决策。
设计:
OrderService → OrderCreated事件
↓
InventoryService → 监听OrderCreated → 预占库存 → InventoryReserved事件
↓
CouponService → 监听InventoryReserved → 锁定券 → CouponLocked事件
↓
PointsService → 监听CouponLocked → 扣积分 → PointsDeducted事件
↓
PaymentService → 监听PointsDeducted → 创建支付单 → PaymentCreated事件
↓
OrderService → 监听PaymentCreated → 更新订单状态CONFIRMED
失败处理:
如果某一步失败,发布失败事件:
InventoryService → InventoryReserveFailed事件
↓
OrderService → 监听失败事件 → 发布OrderCancelled事件
↓
所有服务 → 监听OrderCancelled → 执行补偿操作
优点:
- 服务解耦,独立演进
- 无中心化瓶颈
- 扩展性好
缺点:
- 流程分散,难以全局把控
- 调试困难
- 需要完善的事件溯源
方案三:混合模式
核心思路: 核心流程用编排式,非核心流程用事件式。
设计:
编排式(核心流程):
订单服务编排:库存预占 → 优惠券锁定 → 积分扣除
事件式(通知流程):
OrderPaid事件 →
- 物流服务:创建运单
- 消息服务:发送通知
- 数据服务:更新报表
优点:
- 核心流程可控
- 非核心流程解耦
- 平衡复杂度和灵活性
缺点:
- 两种模式混用,理解成本高
方案对比:
| 维度 | 编排式 | 事件式 | 混合式 |
|---|---|---|---|
| 流程可见性 | ★★★★★ | ★★☆☆☆ | ★★★★☆ |
| 服务解耦 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 调试难度 | ★★★★☆ | ★★☆☆☆ | ★★★☆☆ |
| 扩展性 | ★★★☆☆ | ★★★★★ | ★★★★☆ |
推荐方案: 对于电商订单,推荐编排式Saga。
实施要点:
-
状态机设计:
状态表:saga_execution - saga_id: UUID - current_step: 当前步骤 - status: RUNNING/SUCCESS/COMPENSATING/FAILED - context: JSON(上下文数据) - created_at, updated_at 步骤表:saga_step - saga_id - step_name: ReserveInventory - status: PENDING/SUCCESS/FAILED/COMPENSATED - retry_count: 重试次数 - error_message: 错误信息 -
幂等性保证:
- 每个步骤携带saga_id作为业务唯一键
- 服务侧记录已处理的saga_id
- 重复请求直接返回成功
-
超时处理:
- 每个步骤设置超时时间(如3秒)
- 超时后执行补偿
- 定时任务兜底扫描长时间未完成的Saga
-
补偿策略:
- 向前补偿:重试直到成功
- 向后补偿:回滚已完成的步骤
- 混合补偿:核心步骤向前,非核心向后
-
监控告警:
- Saga成功率
- 平均执行时间
- 补偿执行次数
- 长时间未完成的Saga
延伸思考:
- 如果补偿操作也失败了怎么办?
- Saga执行过程中服务重启如何恢复?
- 如何设计Saga的测试策略?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1384。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-010:如何选择同步调用vs异步消息?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-010 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、事件驱动 |
| 场景标签 | 中大型电商、平台架构、异步集成 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1569 |
题干与约束
在微服务架构中,服务间通信可以使用同步RPC或异步消息队列。在电商系统中,如何选择合适的通信方式?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 在微服务架构中,服务间通信可以使用同步RPC或异步消息队列。在电商系统中,如何选择合适的通信方式?
答案:
问题分析: 同步vs异步的核心考量:
- 业务语义:是否需要立即返回结果
- 性能要求:延迟vs吞吐量
- 可靠性:是否允许消息丢失
- 复杂度:实现和运维成本
方案一:全部使用同步RPC
适用场景:
- 查询操作(查询订单详情、商品信息)
- 强实时性要求(下单时检查库存)
- 需要立即返回结果(用户等待响应)
优点:
- 实现简单
- 调用链路清晰
- 易于调试
缺点:
- 性能瓶颈(串行调用)
- 可用性差(下游故障影响上游)
- 难以削峰
方案二:全部使用异步消息
适用场景:
- 通知类操作(发送短信、邮件)
- 可延迟处理(数据同步、报表生成)
- 需要削峰填谷(秒杀、大促)
优点:
- 解耦(服务独立)
- 削峰(消息堆积)
- 高吞吐
缺点:
- 最终一致性
- 消息丢失风险
- 调试困难
方案三:混合使用(推荐)
决策矩阵:
| 场景 | 通信方式 | 理由 |
|---|---|---|
| 查询商品详情 | 同步RPC | 需要立即返回 |
| 下单扣减库存 | 同步RPC | 需要立即知道结果 |
| 订单支付成功→通知物流 | 异步消息 | 不需要立即处理 |
| 订单支付成功→发送短信 | 异步消息 | 允许延迟 |
| 商品信息变更→更新搜索 | 异步消息 | 最终一致即可 |
| 计算订单金额 | 同步RPC | 需要立即返回金额 |
| 订单创建→更新统计报表 | 异步消息 | 非实时 |
决策原则:
- 用户在等待:使用同步(如下单、支付、查询)
- 用户不在等待:使用异步(如通知、数据同步)
- 强一致性:使用同步(如扣款、扣库存)
- 最终一致性:使用异步(如积分、优惠券)
- 高并发:优先异步(如秒杀、大促)
优点:
- 平衡性能和一致性
- 灵活应对不同场景
- 整体架构合理
缺点:
- 需要维护两套通信机制
- 团队需要理解选择原则
方案对比:
| 维度 | 全同步 | 全异步 | 混合 |
|---|---|---|---|
| 实时性 | ★★★★★ | ★★☆☆☆ | ★★★★☆ |
| 吞吐量 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 可用性 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 复杂度 | ★★★★☆ | ★★☆☆☆ | ★★★☆☆ |
推荐方案: 采用混合模式,根据场景选择合适的通信方式。
实施要点:
-
同步调用优化:
- 设置合理超时(如3秒)
- 使用断路器防止雪崩
- 重要接口设置重试机制
- 监控调用成功率和延迟
-
异步消息优化:
- 使用本地消息表保证可靠性
- 消费端幂等处理
- 死信队列处理失败消息
- 监控消息积压和消费延迟
-
场景识别技巧:
- 问:用户是否在等待结果?
- 问:失败了是否需要立即知道?
- 问:是否需要强一致性?
- 问:并发量有多大?
-
混合调用模式:
示例:订单支付成功后 同步: - 更新订单状态(用户需要立即看到) - 扣减库存(强一致性) 异步: - 通知物流(可延迟) - 发送短信(可延迟) - 更新报表(可延迟) - 赠送积分(最终一致) -
降级策略:
- 异步消息:队列满时拒绝接入
- 同步调用:超时降级(返回默认值或缓存)
延伸思考:
- 如何处理异步消息丢失的情况?
- 同步调用超时后如何判断是否成功?
- 如何在同步和异步之间切换(如异步改同步)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1569。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-011:分布式事务的几种实现方案对比
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-011 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、服务边界、分布式一致性 |
| 场景标签 | 中大型电商、平台架构、跨服务协作 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1704 |
题干与约束
请对比2PC、TCC、Saga、本地消息表这几种分布式事务方案,并说明在电商系统中各自的适用场景。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 请对比2PC、TCC、Saga、本地消息表这几种分布式事务方案,并说明在电商系统中各自的适用场景。
答案:
问题分析: 分布式事务方案的核心差异:
- 一致性强度(强一致 vs 最终一致)
- 性能开销(锁定时间、网络开销)
- 实现复杂度(接口数量、补偿逻辑)
- 适用场景(金融 vs 电商)
方案一:2PC(Two-Phase Commit)
核心思想: 分为准备阶段和提交阶段,由协调者统一协调。
流程:
准备阶段(Prepare):
协调者 → 所有参与者:准备事务
参与者 → 锁定资源,记录日志
参与者 → 协调者:返回YES/NO
提交阶段(Commit):
如果所有参与者返回YES:
协调者 → 所有参与者:提交事务
参与者 → 释放资源,返回ACK
如果任一参与者返回NO:
协调者 → 所有参与者:回滚事务
优点:
- 强一致性
- 原理简单
缺点:
- 性能差(两次网络往返)
- 阻塞(参与者锁定资源)
- 单点故障(协调者宕机)
- 不适合微服务
适用场景:
- 数据库分布式事务
- 小规模系统
- 对一致性要求极高且并发不高的场景
方案二:TCC(Try-Confirm-Cancel)
核心思想: 每个服务提供三个接口:Try、Confirm、Cancel。
流程:
Try阶段:
- 订单服务:tryCreateOrder(创建订单,状态TRYING)
- 库存服务:tryReserveInventory(预占库存)
- 支付服务:tryPreparePayment(冻结金额)
全部成功 → Confirm阶段:
- 订单服务:confirmCreateOrder(状态CONFIRMED)
- 库存服务:confirmReserveInventory(确认扣减)
- 支付服务:confirmPreparePayment(确认扣款)
任一失败 → Cancel阶段:
- 订单服务:cancelCreateOrder(取消订单)
- 库存服务:cancelReserveInventory(释放库存)
- 支付服务:cancelPreparePayment(解冻金额)
优点:
- 强一致性
- 业务语义清晰
- 资源锁定明确
缺点:
- 实现复杂(每个服务3个接口)
- Try阶段占用资源时间长
- 需要考虑幂等性
适用场景:
- 金融交易(转账、支付)
- 核心交易链路
- 对一致性要求极高的场景
方案三:Saga
核心思想: 将长事务拆分为多个本地事务,失败时执行补偿。
流程:
正向流程:
T1: 创建订单 → T2: 扣减库存 → T3: 扣除积分 → T4: 创建支付
失败补偿:
T4失败 → C3: 返还积分 → C2: 释放库存 → C1: 取消订单
优点:
- 最终一致性
- 性能好(异步)
- 实现相对简单
缺点:
- 补偿逻辑复杂
- 隔离性弱(中间状态可见)
- 需要考虑补偿失败
适用场景:
- 电商订单流程
- 长流程业务
- 可接受最终一致性的场景
方案四:本地消息表
核心思想: 利用本地事务保证消息发送,通过消息驱动下游操作。
流程:
1. 订单服务:
BEGIN TRANSACTION
INSERT INTO orders ...
INSERT INTO outbox_messages (event: OrderCreated)
COMMIT
2. 消息发送器:
定时扫描outbox_messages
发送到MQ
标记为已发送
3. 库存服务:
监听MQ OrderCreated事件
扣减库存
发送InventoryReduced事件
4. 订单服务:
监听InventoryReduced事件
更新订单状态
优点:
- 实现简单
- 消息可靠性高
- 无锁,性能好
缺点:
- 最终一致性
- 需要扫描任务
- 消息顺序性
适用场景:
- 异步通知场景
- 跨系统数据同步
- 对实时性要求不高的场景
方案对比:
| 方案 | 一致性 | 性能 | 复杂度 | 隔离性 | 适用场景 |
|---|---|---|---|---|---|
| 2PC | 强一致 | ★☆☆☆☆ | ★★☆☆☆ | ★★★★★ | 数据库事务 |
| TCC | 强一致 | ★★☆☆☆ | ★★★★★ | ★★★★☆ | 金融交易 |
| Saga | 最终一致 | ★★★★☆ | ★★★☆☆ | ★★☆☆☆ | 电商订单 |
| 本地消息表 | 最终一致 | ★★★★★ | ★★★★☆ | ★★☆☆☆ | 异步通知 |
推荐方案: 电商系统不同场景使用不同方案:
- 订单创建流程:Saga(性能和一致性平衡)
- 支付流程:TCC(强一致性)
- 数据同步:本地消息表(异步解耦)
- 库存扣减:预占机制(类似TCC)
延伸思考:
- 为什么电商系统很少用2PC?
- TCC的Try阶段如何设计才能保证性能?
- Saga的补偿操作如何保证一定成功?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1704。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-012:设计事件驱动架构
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-012 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、事件驱动 |
| 场景标签 | 中大型电商、平台架构、异步集成 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1886 |
题干与约束
电商系统需要实现事件驱动架构,当订单状态变更时,自动触发物流、消息通知、数据统计等下游操作。请设计事件驱动方案。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商系统需要实现事件驱动架构,当订单状态变更时,自动触发物流、消息通知、数据统计等下游操作。请设计事件驱动方案。
答案:
问题分析: 事件驱动架构的核心挑战:
- 如何设计领域事件
- 如何保证事件不丢失
- 如何处理事件顺序性
- 如何避免事件风暴
方案一:基于数据库Change Data Capture(CDC)
核心思想: 监听数据库变更日志(binlog),自动生成事件。
设计:
MySQL binlog → Debezium → Kafka → 下游服务
示例:
orders表INSERT → Debezium捕获 →
发送到Kafka主题: order.events →
物流服务消费 → 创建运单
优点:
- 零侵入(不需要改业务代码)
- 事件不丢失(基于binlog)
- 实时性高
缺点:
- 事件语义不清晰(只有数据变更)
- 难以表达业务意图
- 依赖数据库
适用场景:
- 数据同步
- 数据库归档
- 实时数仓
方案二:基于领域事件+Outbox模式
核心思想: 业务代码显式发布领域事件,通过Outbox模式保证可靠性。
设计:
1. 订单服务发布事件:
BEGIN TRANSACTION
UPDATE orders SET status='PAID'
INSERT INTO outbox_events (
event_id, event_type, payload, status
) VALUES (
UUID(), 'OrderPaid', '{"orderId":"123"}', 'PENDING'
)
COMMIT
2. 事件发布器(独立进程):
while (true) {
events = SELECT * FROM outbox_events WHERE status='PENDING' LIMIT 100
for (event in events) {
kafka.send(event.event_type, event.payload)
UPDATE outbox_events SET status='SENT' WHERE event_id=event.id
}
sleep(1s)
}
3. 下游服务消费:
物流服务监听order.paid主题
消息服务监听order.paid主题
报表服务监听order.paid主题
领域事件设计:
OrderCreated {
orderId, userId, items[], totalAmount, createdAt
}
OrderPaid {
orderId, paymentId, paidAmount, paidAt
}
OrderShipped {
orderId, trackingNumber, shippedAt
}
OrderCompleted {
orderId, completedAt
}
优点:
- 业务语义清晰
- 事件可靠性高(Outbox模式)
- 下游解耦
缺点:
- 需要Outbox表和发布器
- 实现复杂度中等
适用场景:
- 微服务间通信
- 业务事件通知
- 事件溯源
方案三:基于消息队列事务消息
核心思想: 使用RocketMQ的事务消息,保证事件发送和本地事务一致性。
设计:
1. 发送半消息(Half Message):
rocketMQ.sendHalfMessage(topic, "OrderPaid", payload)
2. 执行本地事务:
BEGIN TRANSACTION
UPDATE orders SET status='PAID'
COMMIT
3. 提交/回滚消息:
if (本地事务成功) {
rocketMQ.commit(messageId)
} else {
rocketMQ.rollback(messageId)
}
4. 消息回查(如果未收到commit/rollback):
rocketMQ定期回查 → 订单服务检查订单状态 → 返回commit/rollback
优点:
- 事件可靠性高
- 不需要Outbox表
- RocketMQ原生支持
缺点:
- 依赖特定MQ(RocketMQ)
- 需要实现回查接口
适用场景:
- 使用RocketMQ的系统
- 需要强可靠性的场景
方案对比:
| 维度 | CDC | Outbox | 事务消息 |
|---|---|---|---|
| 事件语义 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 可靠性 | ★★★★★ | ★★★★★ | ★★★★★ |
| 侵入性 | ★★★★★ | ★★★☆☆ | ★★★☆☆ |
| 实施难度 | ★★★★☆ | ★★★☆☆ | ★★★★☆ |
推荐方案: 对于电商系统,推荐Outbox模式。
实施要点:
-
事件设计原则:
- 事件名称:过去时(OrderPaid、OrderShipped)
- 包含完整上下文(避免下游再查询)
- 不可变(发出后不能修改)
- 幂等性(包含event_id)
-
Outbox表设计:
CREATE TABLE outbox_events ( event_id VARCHAR(64) PRIMARY KEY, aggregate_type VARCHAR(50), -- Order/Product aggregate_id VARCHAR(64), -- orderId event_type VARCHAR(50), -- OrderPaid payload JSON, -- 事件数据 status VARCHAR(20), -- PENDING/SENT/FAILED retry_count INT DEFAULT 0, created_at TIMESTAMP, sent_at TIMESTAMP ); -
事件发布器优化:
- 批量读取(每次100条)
- 批量发送(提高吞吐)
- 失败重试(指数退避)
- 定期清理已发送事件(保留7天)
-
消费端幂等:
消费端维护已处理事件表: CREATE TABLE processed_events ( event_id VARCHAR(64) PRIMARY KEY, processed_at TIMESTAMP ); 处理逻辑: 1. 检查event_id是否已处理 2. 如果已处理,直接返回 3. 执行业务逻辑 4. 记录event_id到processed_events -
监控告警:
- Outbox待发送事件数
- 事件发送失败率
- 消费延迟
延伸思考:
- 如何保证事件的顺序性(同一订单的多个事件)?
- 如何处理事件风暴(大量事件同时发布)?
- 事件版本如何管理(EventV1、EventV2)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1886。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-013:如何保证消息的可靠投递?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-013 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、分布式一致性 |
| 场景标签 | 中大型电商、平台架构、跨服务协作 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:2102 |
题干与约束
在消息队列(Kafka/RocketMQ)中,如何保证消息从生产者到消费者的可靠投递,不丢失、不重复、不乱序?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 在消息队列(Kafka/RocketMQ)中,如何保证消息从生产者到消费者的可靠投递,不丢失、不重复、不乱序?
答案:
问题分析: 消息可靠性的三个维度:
- 生产端:如何保证消息发送成功
- 存储端:如何保证消息不丢失
- 消费端:如何保证消息处理成功
方案一:At Least Once(至少一次)
核心思想: 保证消息至少被消费一次,可能重复但不会丢失。
实现:
生产端:
1. 发送消息到MQ
2. 等待MQ确认(ACK)
3. 如果超时或失败,重试发送
MQ端:
1. 消息写入磁盘后返回ACK
2. 多副本复制(至少2个副本确认)
消费端:
1. 消费消息
2. 处理业务逻辑
3. 手动提交offset
4. 如果处理失败,不提交offset,下次重新消费
优点:
- 消息不丢失
- 实现相对简单
缺点:
- 可能重复消费
- 需要业务幂等
适用场景:
- 大部分业务场景
- 可以接受重复的场景
方案二:At Most Once(至多一次)
核心思想: 消息可能丢失,但不会重复。
实现:
生产端:
1. 发送消息
2. 不等待确认,直接返回
消费端:
1. 先提交offset
2. 再处理业务逻辑
3. 如果处理失败,消息丢失
优点:
- 不会重复
- 性能高
缺点:
- 可能丢消息
适用场景:
- 日志采集
- 监控数据上报
- 允许丢失的场景
方案三:Exactly Once(精确一次)
核心思想: 消息既不丢失也不重复,精确消费一次。
实现(基于Kafka事务):
生产端:
producer.initTransactions()
try {
producer.beginTransaction()
producer.send(record1)
producer.send(record2)
// 更新本地数据库
db.update(...)
producer.commitTransaction()
} catch (Exception e) {
producer.abortTransaction()
}
消费端:
consumer.subscribe(topic)
consumer.setIsolationLevel(READ_COMMITTED) // 只读已提交
while (true) {
records = consumer.poll()
for (record in records) {
// 幂等处理
if (isProcessed(record.key)) {
continue
}
process(record)
markProcessed(record.key)
}
consumer.commitSync()
}
实现(基于业务幂等):
消息设计:
{
"messageId": "uuid", // 全局唯一ID
"orderId": "123",
"payload": {...}
}
消费端:
1. 检查messageId是否已处理(查Redis/DB)
2. 如果已处理,直接返回成功
3. 执行业务逻辑 + 记录messageId(同一事务)
4. 提交offset
优点:
- 不丢不重
- 语义最强
缺点:
- 实现复杂
- 性能开销大
- 需要事务支持
适用场景:
- 金融交易
- 支付扣款
- 对准确性要求极高的场景
方案对比:
| 语义 | 是否丢失 | 是否重复 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|---|
| At Least Once | 不丢失 | 可能重复 | ★★★★☆ | ★★★☆☆ | 大部分场景 |
| At Most Once | 可能丢失 | 不重复 | ★★★★★ | ★★★★☆ | 日志、监控 |
| Exactly Once | 不丢失 | 不重复 | ★★☆☆☆ | ★★☆☆☆ | 金融、支付 |
推荐方案: 对于电商系统,推荐At Least Once + 业务幂等。
实施要点:
-
生产端可靠性:
// 同步发送(等待确认) producer.send(record).get(3, TimeUnit.SECONDS) // 配置 acks=all // 所有副本确认 retries=3 // 重试3次 max.in.flight.requests.per.connection=1 // 保证顺序 -
MQ端可靠性:
Kafka配置: - replication.factor=3 // 3副本 - min.insync.replicas=2 // 至少2个副本确认 - unclean.leader.election.enable=false // 禁止非ISR副本成为leader RocketMQ配置: - flushDiskType=SYNC_FLUSH // 同步刷盘 -
消费端可靠性:
// 手动提交offset while (true) { records = consumer.poll() try { for (record in records) { // 幂等处理 if (!isProcessed(record.messageId)) { process(record) markProcessed(record.messageId) } } // 处理成功后提交offset consumer.commitSync() } catch (Exception e) { // 处理失败,不提交offset,下次重新消费 log.error("Process failed", e) } } -
幂等性设计:
方案1:基于唯一键 - 消息包含messageId - 消费端用messageId去重(Redis/DB) 方案2:基于业务唯一键 - 订单号、支付流水号等 - 数据库唯一索引约束 方案3:基于版本号 - 数据包含版本号 - 更新时检查版本号(乐观锁) -
顺序性保证:
发送端: - 同一订单的消息发到同一分区(按orderId hash) - max.in.flight.requests=1(保证分区内有序) 消费端: - 单线程消费同一分区 - 或使用版本号检查顺序
延伸思考:
- 如果消费失败,消息应该重试几次?
- 如何处理消息积压问题?
- 如何实现消息的延迟投递?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:2102。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-014:幂等性设计的最佳实践
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-014 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、分布式一致性 |
| 场景标签 | 中大型电商、平台架构、跨服务协作 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:2336 |
题干与约束
在分布式系统中,由于网络重试、消息重复等原因,同一个请求可能被处理多次。如何设计幂等机制,保证重复请求不会产生副作用?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 在分布式系统中,由于网络重试、消息重复等原因,同一个请求可能被处理多次。如何设计幂等机制,保证重复请求不会产生副作用?
答案:
问题分析: 幂等性的核心挑战:
- 如何识别重复请求
- 如何防止并发重复处理
- 如何设计幂等键
- 幂等状态如何存储和清理
方案一:基于唯一请求ID
核心思想: 客户端为每个请求生成唯一ID,服务端记录已处理的ID。
实现:
客户端:
POST /api/orders
Headers: {
X-Request-Id: uuid-xxx-xxx
}
Body: {...}
服务端:
public void createOrder(OrderRequest req, String requestId) {
// 1. 检查requestId是否已处理
if (redis.exists("idempotent:" + requestId)) {
return getCachedResult(requestId);
}
// 2. 加分布式锁(防止并发)
if (!redis.setNX("lock:" + requestId, 1, 10)) {
throw new ConcurrentException();
}
try {
// 3. 执行业务逻辑
Order order = orderService.create(req);
// 4. 记录已处理+结果
redis.setEx("idempotent:" + requestId,
JSON.stringify(order),
86400); // 保留24小时
return order;
} finally {
redis.del("lock:" + requestId);
}
}
优点:
- 实现简单
- 通用性强
缺点:
- 需要客户端配合生成requestId
- Redis存储成本
适用场景:
- API接口调用
- 用户操作(防止重复点击)
方案二:基于业务唯一键
核心思想: 利用业务本身的唯一性(订单号、流水号)作为幂等键。
实现:
数据库设计:
CREATE TABLE orders (
order_id VARCHAR(64) PRIMARY KEY, -- 业务唯一键
user_id VARCHAR(64),
amount DECIMAL(10,2),
status VARCHAR(20),
UNIQUE KEY uk_user_order(user_id, external_order_id) -- 组合唯一键
);
代码:
public Order createOrder(OrderRequest req) {
String orderId = generateOrderId(); // 客户端提供或服务端生成
try {
// INSERT会因为主键冲突失败(幂等)
db.insert("INSERT INTO orders VALUES (?, ?, ?, ?)",
orderId, req.getUserId(), req.getAmount(), "PENDING");
return getOrder(orderId);
} catch (DuplicateKeyException e) {
// 重复请求,返回已存在的订单
return getOrder(orderId);
}
}
优点:
- 不需要额外存储
- 性能好(数据库索引)
- 天然幂等
缺点:
- 需要设计业务唯一键
- 不适合更新操作
适用场景:
- 创建类操作(订单、支付)
- 有明确业务唯一键的场景
方案三:基于状态机+版本号
核心思想: 利用状态机和版本号,保证状态流转的幂等性。
实现:
数据库设计:
CREATE TABLE orders (
order_id VARCHAR(64) PRIMARY KEY,
status VARCHAR(20),
version INT DEFAULT 0, -- 版本号
updated_at TIMESTAMP
);
代码:
public void payOrder(String orderId) {
// 乐观锁:只有状态为PENDING且版本匹配才更新
int affected = db.update(
"UPDATE orders SET status='PAID', version=version+1, updated_at=NOW() " +
"WHERE order_id=? AND status='PENDING' AND version=?",
orderId, currentVersion
);
if (affected == 0) {
// 已经支付过了(幂等)或并发冲突
Order order = getOrder(orderId);
if (order.getStatus() == "PAID") {
return; // 幂等,直接返回
} else {
throw new ConcurrentModificationException(); // 并发冲突,重试
}
}
}
优点:
- 不需要额外存储
- 天然解决并发问题
- 适合更新操作
缺点:
- 需要理解状态机
- 并发冲突需要重试
适用场景:
- 状态流转操作(订单状态、支付状态)
- 更新操作
方案对比:
| 方案 | 适用操作 | 存储成本 | 并发安全 | 实施难度 |
|---|---|---|---|---|
| 请求ID | 所有 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 业务唯一键 | 创建 | ★★★★★ | ★★★★★ | ★★★★★ |
| 状态机+版本号 | 更新 | ★★★★★ | ★★★★☆ | ★★★☆☆ |
推荐方案: 根据场景选择合适方案:
- 创建操作:业务唯一键(如orderId)
- 更新操作:状态机+版本号
- 通用接口:请求ID
实施要点:
-
幂等键设计原则:
- 唯一性:能唯一标识一次操作
- 稳定性:多次请求幂等键相同
- 业务相关:优先用业务键(orderId)而非技术键(requestId)
-
幂等粒度:
粗粒度(操作级): - 幂等键:orderId - 含义:同一订单不能重复创建 细粒度(步骤级): - 幂等键:orderId + operationType(如 "order123:pay") - 含义:同一订单的支付操作不能重复 -
幂等状态清理:
Redis存储: - 设置过期时间(24小时) - 定期清理过期数据 数据库存储: - 不需要清理(利用业务唯一键) -
并发安全:
分布式锁: String lockKey = "lock:order:" + orderId; if (redis.setNX(lockKey, 1, 10)) { try { // 业务逻辑 } finally { redis.del(lockKey); } } 数据库乐观锁: UPDATE orders SET status=?, version=version+1 WHERE order_id=? AND version=? -
幂等测试:
测试用例: 1. 相同参数调用2次,验证第2次返回相同结果 2. 并发调用2次,验证只有1次生效 3. 延迟重试,验证中间状态不影响幂等性
延伸思考:
- 如何设计支付接口的幂等性?
- 消息队列消费端如何保证幂等性?
- 幂等状态如何跨服务共享?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:2336。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-015:设计数据最终一致性的对账补偿机制
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-015 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、分布式一致性 |
| 场景标签 | 中大型电商、平台架构、跨服务协作 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:2574 |
题干与约束
在分布式系统中,虽然采用了Saga等最终一致性方案,但仍可能因为网络、重试等原因导致数据不一致。如何设计对账补偿机制?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 在分布式系统中,虽然采用了Saga等最终一致性方案,但仍可能因为网络、重试等原因导致数据不一致。如何设计对账补偿机制?
答案:
问题分析: 对账补偿的核心挑战:
- 如何发现数据不一致
- 如何定位不一致的根本原因
- 如何自动补偿修复
- 如何避免补偿引入新问题
方案一:实时对账
核心思想: 在关键操作后立即对账,发现问题立即修复。
设计:
订单支付成功后:
1. 订单服务:更新订单状态为PAID
2. 支付服务:更新支付单状态为SUCCESS
3. 对账服务:
- 查询订单状态
- 查询支付单状态
- 对比是否一致
- 如果不一致,触发告警和补偿
补偿逻辑:
if (订单状态=PAID && 支付单状态!=SUCCESS) {
// 订单已支付但支付单未成功,可能是支付服务更新失败
重试更新支付单状态
} else if (订单状态!=PAID && 支付单状态=SUCCESS) {
// 支付成功但订单未更新,可能是订单服务更新失败
重试更新订单状态
}
优点:
- 及时发现问题
- 用户感知小
缺点:
- 增加延迟
- 对账服务成为瓶颈
适用场景:
- 核心流程(支付、扣款)
- 对一致性要求极高的场景
方案二:定时对账
核心思想: 定时(如每小时、每天)对账,批量发现和修复问题。
设计:
对账任务(每小时执行):
1. 查询最近1小时的订单:
SELECT * FROM orders
WHERE created_at >= NOW() - INTERVAL 1 HOUR
2. 对每个订单进行对账:
- 查询订单信息(order)
- 查询库存记录(inventory_log)
- 查询支付记录(payment)
- 查询物流记录(logistics)
3. 检查一致性:
订单状态 vs 支付状态
订单金额 vs 支付金额
订单库存扣减 vs 库存日志
4. 发现不一致:
- 记录到对账差异表
- 触发告警
- 尝试自动补偿
对账差异表:
CREATE TABLE reconciliation_diff (
id BIGINT PRIMARY KEY,
order_id VARCHAR(64),
diff_type VARCHAR(50), -- PAYMENT_MISMATCH/INVENTORY_MISMATCH
expected_value VARCHAR(200),
actual_value VARCHAR(200),
status VARCHAR(20), -- PENDING/COMPENSATED/MANUAL
created_at TIMESTAMP,
compensated_at TIMESTAMP
);
优点:
- 批量处理,效率高
- 不影响业务性能
- 可以发现各种不一致
缺点:
- 延迟高(小时级)
- 用户可能已感知问题
适用场景:
- 非核心流程
- 对实时性要求不高的场景
- 大批量数据对账
方案三:事件溯源+状态重建
核心思想: 记录所有事件,通过重放事件重建状态,与当前状态对比。
设计:
事件表:
CREATE TABLE domain_events (
event_id VARCHAR(64) PRIMARY KEY,
aggregate_id VARCHAR(64), -- orderId
event_type VARCHAR(50), -- OrderCreated/OrderPaid
payload JSON,
version INT,
created_at TIMESTAMP
);
对账逻辑:
1. 查询订单的所有事件:
SELECT * FROM domain_events
WHERE aggregate_id='order123'
ORDER BY version
2. 重放事件,重建订单状态:
Order order = new Order();
for (event in events) {
order.apply(event); // OrderCreated → OrderPaid → OrderShipped
}
3. 对比重建状态 vs 数据库状态:
rebuiltOrder.status vs dbOrder.status
rebuiltOrder.amount vs dbOrder.amount
4. 如果不一致,说明有事件丢失或数据被错误修改
优点:
- 可追溯完整历史
- 可精确定位问题
- 天然支持审计
缺点:
- 事件存储成本高
- 重建状态复杂
- 实施难度大
适用场景:
- 金融系统
- 审计要求高的场景
方案对比:
| 方案 | 实时性 | 准确性 | 成本 | 复杂度 | 适用场景 |
|---|---|---|---|---|---|
| 实时对账 | ★★★★★ | ★★★☆☆ | ★★★☆☆ | ★★★★☆ | 核心流程 |
| 定时对账 | ★★☆☆☆ | ★★★★★ | ★★★★☆ | ★★★☆☆ | 一般流程 |
| 事件溯源 | ★★★☆☆ | ★★★★★ | ★★☆☆☆ | ★★☆☆☆ | 金融系统 |
推荐方案: 对于电商系统,推荐定时对账为主,实时对账为辅。
实施要点:
-
对账维度设计:
维度1:订单-支付对账 - 订单状态=PAID ⇔ 存在成功的支付记录 - 订单金额 = 支付金额 维度2:订单-库存对账 - 订单商品 ⇔ 库存扣减记录 - 订单数量 = 库存扣减数量 维度3:订单-物流对账 - 订单状态=SHIPPED ⇔ 存在物流单 维度4:财务对账 - 订单收入 = 支付收入 - 退款 -
补偿策略:
自动补偿(低风险): - 订单已支付,库存未扣减 → 自动扣减库存 - 订单已取消,库存未释放 → 自动释放库存 人工介入(高风险): - 订单金额与支付金额不一致 → 人工审核 - 库存为负数 → 人工调整 - 重复支付 → 人工退款 -
对账任务调度:
实时对账: - 支付成功后:立即对账订单-支付 分钟级对账: - 每5分钟:对账最近10分钟的订单 小时级对账: - 每小时:对账最近2小时的订单 日级对账: - 每天凌晨:对账前一天所有订单 - 生成对账报表 -
补偿幂等性:
补偿操作必须幂等: - 记录补偿历史(避免重复补偿) - 使用业务唯一键 - 状态机保证(只能从错误状态补偿到正确状态) -
监控告警:
指标: - 对账差异数量 - 自动补偿成功率 - 人工处理待办数量 告警: - 差异数量超过阈值 - 自动补偿失败 - 关键差异(金额不一致)
延伸思考:
- 如果对账也失败了(如查询超时),如何处理?
- 补偿操作失败后如何处理?
- 如何设计对账结果的可视化展示?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:2574。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-016:如何处理分布式系统的时钟问题?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-016 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、架构演进 |
| 场景标签 | 中大型电商、平台架构 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:2819 |
题干与约束
在分布式系统中,不同服务器的时钟可能不同步,导致时间戳不一致、时序错误等问题。如何处理分布式系统的时钟问题?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 在分布式系统中,不同服务器的时钟可能不同步,导致时间戳不一致、时序错误等问题。如何处理分布式系统的时钟问题?
答案:
问题分析: 分布式时钟的核心挑战:
- 时钟漂移:不同机器时钟不一致
- 时序依赖:如何判断事件先后顺序
- 超时判断:如何准确计算超时
- 数据时效:如何判断数据是否过期
方案一:使用NTP时钟同步
核心思想: 通过NTP协议同步所有服务器的时钟。
实现:
1. 配置NTP服务器:
所有应用服务器配置相同的NTP源
2. 定期同步:
ntpd守护进程自动同步时钟
3. 监控时钟偏移:
监控各服务器与NTP服务器的时钟差
差异超过100ms告警
优点:
- 实施简单
- 对应用透明
- 时钟基本一致
缺点:
- 无法完全同步(仍有毫秒级误差)
- 时钟回拨问题(同步时时钟可能后退)
- 依赖网络
适用场景:
- 对时间精度要求不高的场景
- 作为基础设施
方案二:逻辑时钟(Lamport时钟)
核心思想: 不依赖物理时钟,用逻辑计数器表示事件顺序。
实现:
每个节点维护一个计数器:
1. 节点发生事件时,计数器+1
2. 节点发送消息时,附带当前计数器值
3. 节点收到消息时,更新计数器:
counter = max(local_counter, message_counter) + 1
示例:
节点A: counter=5, 发送消息(counter=5)
节点B: 收到消息, local_counter=3
更新counter = max(3, 5) + 1 = 6
优点:
- 不依赖物理时钟
- 可以判断因果关系
- 实现简单
缺点:
- 只能判断偏序(不能判断所有事件的先后)
- 不是真实时间
- 计数器可能很大
适用场景:
- 分布式日志
- 事件顺序
- 因果一致性
方案三:混合逻辑时钟(HLC)
核心思想: 结合物理时钟和逻辑时钟,既有真实时间又有顺序保证。
实现:
HLC = (physicalTime, logicalCounter)
更新规则:
1. 本地事件:
pt = 物理时钟
if (pt > hlc.pt) {
hlc = (pt, 0)
} else {
hlc = (hlc.pt, hlc.lc + 1)
}
2. 收到消息(msg_hlc):
pt = max(物理时钟, msg_hlc.pt, hlc.pt)
if (pt > hlc.pt) {
hlc = (pt, 0)
} else if (pt == msg_hlc.pt) {
hlc = (pt, max(hlc.lc, msg_hlc.lc) + 1)
} else {
hlc = (hlc.pt, hlc.lc + 1)
}
示例:
节点A: HLC=(100, 0), 发生事件
物理时钟=99 < 100
HLC=(100, 1)
节点B: 收到消息HLC=(100, 1)
物理时钟=105
HLC=(105, 0)
优点:
- 有真实时间(可展示给用户)
- 有顺序保证(逻辑计数器)
- 时钟单调递增(不会回退)
缺点:
- 实现复杂
- 需要所有节点支持
适用场景:
- 分布式数据库(CockroachDB使用)
- 需要时间戳又要顺序的场景
方案对比:
| 方案 | 时间准确性 | 顺序保证 | 实施难度 | 适用场景 |
|---|---|---|---|---|
| NTP同步 | ★★★★☆ | ★★☆☆☆ | ★★★★★ | 通用 |
| Lamport时钟 | ★☆☆☆☆ | ★★★★★ | ★★★★☆ | 事件顺序 |
| 混合逻辑时钟 | ★★★★☆ | ★★★★★ | ★★★☆☆ | 分布式DB |
推荐方案: 对于电商系统,推荐NTP同步 + 避免依赖绝对时间。
实施要点:
-
NTP基础设施:
- 部署内网NTP服务器 - 所有应用服务器同步内网NTP - 监控时钟偏移(超过100ms告警) - 禁止手动修改系统时间 -
避免依赖绝对时间:
错误示例: // 判断订单是否超时(依赖绝对时间) if (now() - order.createdAt > 30分钟) { cancelOrder() } 问题:如果时钟回拨,可能误判 正确示例: // 使用相对时间或逻辑状态 if (order.status == PENDING && order.createdAt < now() - 30分钟) { // 且使用定时任务扫描(而非实时判断) cancelOrder() } -
处理时钟回拨:
生成ID时(如雪花ID): if (currentTime < lastTime) { // 时钟回拨,等待追上 wait(lastTime - currentTime) } 或使用单调递增ID: // 不依赖时间,只保证递增 nextId = atomicIncrement() -
使用逻辑版本号:
数据表: CREATE TABLE orders ( order_id VARCHAR(64), version INT, -- 逻辑版本号(不依赖时间) updated_at TIMESTAMP ); 更新时: UPDATE orders SET version=version+1, updated_at=NOW() WHERE order_id=? AND version=? -
时间窗口设计:
对账任务: // 不对账"最近5分钟"的数据(避免时钟误差) SELECT * FROM orders WHERE created_at < NOW() - INTERVAL 5 MINUTE AND created_at >= NOW() - INTERVAL 1 HOUR
延伸思考:
- 如何设计分布式系统的全局唯一ID(考虑时钟回拨)?
- 如何在不同时区的数据中心部署系统?
- 时钟跳跃(突然快进)如何处理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:2819。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-ARCH-017:微服务间的版本兼容性设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-ARCH-017 |
| 题型 | 系统设计题 |
| 范围 | 平台级:服务边界、集成与架构演进 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、服务边界、架构演进 |
| 场景标签 | 中大型电商、平台架构 |
| 能力域 | 平台架构、服务边界与跨系统一致性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:3032 |
题干与约束
微服务架构下,服务A调用服务B的接口。当服务B升级时,如何保证向后兼容,不影响服务A?请设计API版本管理方案。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 微服务架构下,服务A调用服务B的接口。当服务B升级时,如何保证向后兼容,不影响服务A?请设计API版本管理方案。
答案:
问题分析: API版本兼容的核心挑战:
- 如何在不影响老版本的情况下升级
- 如何管理多个版本的共存
- 如何平滑下线老版本
- 如何处理数据结构变更
方案一:URL版本控制
核心思想: 在URL中包含版本号,不同版本独立部署。
实现:
版本1:GET /api/v1/orders/{id}
返回:{
"orderId": "123",
"amount": 100.00,
"status": "PAID"
}
版本2:GET /api/v2/orders/{id}
返回:{
"orderId": "123",
"totalAmount": 100.00, // 字段重命名
"discountAmount": 10.00, // 新增字段
"paymentStatus": "PAID" // 字段重命名
}
服务端:
@RestController
@RequestMapping("/api/v1")
public class OrderControllerV1 {
@GetMapping("/orders/{id}")
public OrderV1 getOrder(@PathVariable String id) {
return orderService.getOrderV1(id);
}
}
@RestController
@RequestMapping("/api/v2")
public class OrderControllerV2 {
@GetMapping("/orders/{id}")
public OrderV2 getOrder(@PathVariable String id) {
return orderService.getOrderV2(id);
}
}
优点:
- 版本隔离清晰
- 易于理解
- 支持大版本升级
缺点:
- 需要维护多份代码
- URL变化对客户端不友好
- 版本爆炸
适用场景:
- 大版本升级(API重构)
- 需要长期支持多版本
方案二:Header版本控制
核心思想: URL不变,通过HTTP Header指定版本。
实现:
请求:
GET /api/orders/123
Headers: {
Accept: application/vnd.company.order.v2+json
}
或:
GET /api/orders/123
Headers: {
API-Version: 2
}
服务端:
@GetMapping(value = "/orders/{id}",
produces = "application/vnd.company.order.v1+json")
public OrderV1 getOrderV1(@PathVariable String id) {
return orderService.getOrderV1(id);
}
@GetMapping(value = "/orders/{id}",
produces = "application/vnd.company.order.v2+json")
public OrderV2 getOrderV2(@PathVariable String id) {
return orderService.getOrderV2(id);
}
优点:
- URL不变,对客户端友好
- 符合RESTful规范
- 版本信息不污染URL
缺点:
- 调试不方便(Header不可见)
- 浏览器访问不友好
- 实现复杂
适用场景:
- RESTful API
- 对URL稳定性要求高的场景
方案三:向后兼容设计(推荐)
核心思想: 不使用显式版本号,通过向后兼容的设计避免版本问题。
兼容原则:
1. 只增不删:
✅ 新增字段(老客户端忽略)
❌ 删除字段(老客户端会报错)
2. 字段可选:
✅ 新字段设为可选
❌ 新字段设为必填
3. 默认值:
✅ 新字段提供默认值
❌ 新字段没有默认值
4. 字段弃用:
✅ 保留旧字段,标记为@Deprecated
❌ 直接删除旧字段
实现示例:
V1版本:
{
"orderId": "123",
"amount": 100.00
}
V2版本(向后兼容):
{
"orderId": "123",
"amount": 100.00, // 保留(兼容V1)
"totalAmount": 100.00, // 新增
"discountAmount": 10.00 // 新增
}
代码:
public class Order {
private String orderId;
@Deprecated // 标记弃用但保留
private BigDecimal amount;
private BigDecimal totalAmount;
private BigDecimal discountAmount;
// Getter/Setter
public BigDecimal getAmount() {
return totalAmount; // 返回新字段值(保证语义一致)
}
}
优点:
- 无需版本管理
- 客户端无需修改
- 平滑升级
缺点:
- 字段累积(历史包袱)
- 无法做破坏性变更
- 需要严格遵守兼容原则
适用场景:
- 微服务间调用
- 小版本迭代
- 高频发布的系统
方案对比:
| 方案 | URL稳定性 | 维护成本 | 兼容性 | 适用场景 |
|---|---|---|---|---|
| URL版本 | ★★☆☆☆ | ★★☆☆☆ | ★★★★★ | 大版本升级 |
| Header版本 | ★★★★★ | ★★☆☆☆ | ★★★★★ | RESTful API |
| 向后兼容 | ★★★★★ | ★★★★☆ | ★★★★☆ | 微服务 |
推荐方案: 对于微服务间调用,推荐向后兼容设计。对外API可使用URL版本控制。
实施要点:
-
API设计规范:
必须遵守: - 新增字段必须可选 - 新增字段必须有默认值 - 不删除现有字段 - 不修改字段类型 - 不修改字段语义 字段弃用流程: 1. 标记@Deprecated,文档说明 2. 观察调用量,确认无人使用 3. 至少保留3个月 4. 下个大版本时删除 -
Protobuf向后兼容:
message Order { string order_id = 1; double amount = 2; // V2新增字段 double discount_amount = 3; string payment_method = 4; // 不要重用字段编号! // reserved 5; // 如果删除字段5,标记为reserved } -
版本协商机制:
客户端声明支持的版本: Headers: { X-Client-Version: 2.0 X-Compatible-Versions: 1.0,1.5,2.0 } 服务端根据客户端版本返回合适格式: if (clientVersion >= 2.0) { return OrderV2(order); } else { return OrderV1(order); // 降级返回 } -
版本监控:
监控指标: - 各版本API调用量 - 废弃API的调用量 - 客户端版本分布 告警: - 废弃API仍有调用 - 客户端版本过低 -
契约测试:
测试用例: 1. V1客户端调用V2服务(向后兼容) 2. V2客户端调用V1服务(新字段有默认值) 3. 并发调用不同版本 4. 字段缺失时的降级处理
延伸思考:
- 如何处理数据库表结构的版本兼容?
- 如何平滑下线一个旧版本API?
- gRPC/Protobuf的版本兼容如何保证?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:3032。本章不依赖旧 Part Four 文件链接。
相关章节:电商系统全景图。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
商品、库存、营销与计价
本节覆盖商品、供给、库存、营销与计价。
专题答辩资料:商品中心专题
商品中心的核心不是“建几张商品表”,而是管理商品主数据、类目属性、SPU/SKU、上下架状态、审核流程和对外发布契约。
高频追问:
- SPU 和 SKU 如何建模?
- 类目属性如何支持不同品类差异?
- 商品草稿、审核态和线上态如何隔离?
- 商品变更如何同步到搜索、推荐、库存和营销?
- 商品中心和供应商商品数据是什么关系?
答题时要强调:商品中心是主数据系统,不应该被搜索展示、库存扣减或营销规则污染。它负责定义“商品是什么”,不负责所有使用商品的场景。
专题答辩资料:库存系统专题
库存系统的核心矛盾是“高并发可售判断”和“最终正确性”。库存不是一个简单数字,而是可售、锁定、已售、退回、供应商库存、门店库存、券码库存等多种语义的组合。
高频追问:
- 如何防止超卖?
- Redis 预扣和 MySQL 账本如何配合?
- 订单取消、支付超时后如何释放库存?
- 多仓、多门店、日期库存如何建模?
- 供应商实时库存和平台库存不一致怎么办?
好的回答通常会区分热路径和权威路径:热路径可以用 Redis 提升并发,权威路径要有库存流水、状态机、对账和补偿。
专题答辩资料:营销系统专题
营销系统的难点在于规则复杂、活动叠加、预算控制和核销一致性。优惠券、满减、折扣、秒杀、会员价看起来都是“优惠”,但约束和核销方式不同。
高频追问:
- 优惠券如何防止重复领取和重复使用?
- 活动库存和商品库存有什么区别?
- 多个优惠如何叠加和互斥?
- 营销试算和最终核销如何保持一致?
- 大促期间营销规则如何缓存和降级?
营销系统不要直接修改订单主状态。它通常通过试算、锁定、核销、释放这些动作与交易链路协作。
专题答辩资料:计价系统专题
计价系统负责把商品价格、营销优惠、会员权益、税费、运费等因素合成为用户最终看到和支付的价格。它的关键是可解释、可追溯、可复算。
高频追问:
- 计价结果如何防篡改?
- PDP、购物车、结算页和下单页价格不一致怎么办?
- 价格快照应保存在哪里?
- 计价和营销如何分工?
- 多币种、多地区、多税率如何扩展?
面试中可以用一句话概括:营销决定“能优惠多少”,计价决定“最终应付多少以及为什么”。
专题答辩资料:供应商同步重复下发答法
面试时可以强调:重复下发不是靠“大家约定别点两次”解决,而要靠任务互斥策略和数据库状态控制解决。
专题答辩资料:商品供给系统核心判断
面试时可以先强调:商品供给系统最难的不是“建商品表”,而是入口治理、暂存隔离、质量校验、风险审核、发布一致性和失败运营化。
专题答辩资料:库存语义混淆答辩提示
面试和架构评审里最容易犯的错误,是把这些语义混成一个 stock 字段。这样短期能跑,长期一定会在支付重试、订单关单、退款回补、供应商晚到回调和运营手工调整时失控。
专题答辩资料:商品供给与供应商同步关系判断
面试时可以先给出这个判断:
供应商同步是商品供给链路的一种入口,但不能把商品供给系统等同于供应商同步系统。商品供给平台要统一承接人工创建、批量导入、运营编辑、库存创建 / 修改和供应商同步;其中供应商同步是最复杂的自动化供给分支,需要独立的同步任务、Checkpoint 和数据治理链路。
专题答辩资料:人工创建链路答辩提示
面试时可以强调:人工创建链路最容易被低估。真正难点不是页面表单,而是类目差异、交易契约完整性、审核证据和发布一致性。
专题答辩资料:商品供给统一治理总结
面试总结:
我不会把商品供给设计成后台 CRUD,也不会把供应商同步、人工运营和库存运营割裂成几套互不相干的系统。我的设计是统一供给治理平台:人工创建、批量导入、运营编辑、库存创建 / 修改和供应商同步都先进入任务和暂存区,通过标准化、质量校验、Diff、差异化审核、版本化发布、Outbox、补偿和巡检后,再写入正式商品主数据和交易契约。供应商同步属于供给链路,但因为它涉及 Raw Snapshot、Checkpoint、Worker 租约、DLQ 和数据新鲜度,所以作为专项链路单独展开。这样既能保证入口统一,又能处理不同来源的复杂度差异。
专题答辩资料:供应商同步失败治理答法
面试时可以强调:供应商同步系统不是追求 100% 同步成功,而是要做到失败可定位、可隔离、可修复、可补偿。
专题答辩资料:供应商同步 DLQ 总结
面试时可以这样总结:
我会把供应商同步 DLQ 设计成 MySQL 主存储,而不是只依赖 Kafka 死信 Topic。因为同步失败往往不是单纯消息消费失败,而是字段缺失、映射失败、价格异常、发布失败这些需要人工修复、状态流转和审计的问题。Kafka 可以作为失败消息的缓冲层,但真正的死信治理要落 MySQL,记录供应商、外部编码、平台映射、错误阶段、payload 引用、重试次数、下次重试时间和处理状态。这样问题才能被查询、补偿、统计和运营化处理。
专题答辩资料:供应商同步整体总结
面试总结:
我不会把供应商同步理解成“写几个定时任务拉数据”。在 OTA、O2O 和虚拟商品平台里,供应商同步本质上是一套外部供给数据治理体系。它要解决幂等映射、版本回溯、质量校验、新鲜度控制、失败补偿和监控治理。列表页可以使用缓存提升性能,详情页要更接近实时,创单前必须实时确认。这样才能在供应商接口不稳定、商品模型不一致、价格库存频繁变化的情况下,仍然保证平台商品数据可信、交易链路安全、问题可追踪。
Q-ECOM-SUPPLY-001:设计支持多品类的SPU/SKU数据模型
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-001 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:19 |
题干与约束
电商平台需要支持实物商品(服装、3C)、虚拟商品(充值卡、会员)、服务类商品(保险、课程)。如何设计一个统一且可扩展的商品数据模型?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台需要支持实物商品(服装、3C)、虚拟商品(充值卡、会员)、服务类商品(保险、课程)。如何设计一个统一且可扩展的商品数据模型?
答案:
问题分析: 多品类商品模型的核心挑战:
- 不同品类属性差异巨大(服装有尺码颜色,充值卡有卡密)
- 需要支持灵活的属性扩展,避免频繁加字段
- 查询性能要求高(详情页、列表页高并发)
- 需要支持类目体系和属性继承
方案一:EAV(实体-属性-值)模式
核心思想: 将商品属性拆分为独立的键值对存储。
表结构:
product(商品主表)
├── product_id
├── spu_code
├── category_id
├── name
└── status
product_attribute(属性表)
├── product_id
├── attribute_key
├── attribute_value
└── attribute_type
category_template(类目模板)
├── category_id
├── attribute_definitions(JSON)
└── validation_rules
优点:
- 扩展性极强,加属性不需要改表结构
- 适合属性差异大的场景
- 灵活度高
缺点:
- 查询性能差(需要多次JOIN)
- 难以建立索引
- 类型校验在应用层
- SQL复杂
方案二:宽表+JSON扩展字段
核心思想: 核心字段固定,扩展字段用JSON存储。
表结构:
product
├── id, spu, name, category
├── common_attrs(固定字段:brand、主图等)
└── ext_attrs(JSONB:类目特有属性)
sku
├── sku_code, spu_id
├── spec_attrs(JSONB:颜色、尺码等规格)
└── ext_attrs(JSONB:其他扩展)
优点:
- 查询性能好(单表查询)
- PostgreSQL的JSONB支持索引
- 平衡灵活性和性能
缺点:
- JSON字段查询能力有限
- 需要应用层解析和校验
- 不同数据库支持程度不同
方案三:混合模式(推荐)
核心设计:
- 主表存储通用字段:product_core(id, spu, name, category, status)
- 类目模板定义属性规范:attribute_meta(属性元数据、类型、校验规则)
- 分层存储:
- product_common_attr:高频查询字段(品牌、价格区间)
- product_ext_attr:JSONB,低频字段
- product_spec:SKU规格,单独表
- 搜索侧异步构建宽表:ES文档包含所有筛选字段
数据流:
- 写入:商品创建 → 按模板校验 → 分表存储 → 事件发布 → ES同步
- 读取详情页:主表+扩展表(缓存)
- 读取列表页:直接查ES
- 后台管理:全量字段(可接受慢查询)
优点:
- 扩展性强
- 查询性能好
- 支持复杂筛选(通过ES)
- 核心字段有索引
缺点:
- 架构复杂度中等
- 需要维护ES同步
- 最终一致性
方案对比:
| 维度 | EAV | 宽表+JSON | 混合模式 |
|---|---|---|---|
| 扩展性 | ★★★★★ | ★★★★☆ | ★★★★★ |
| 查询性能 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 开发复杂度 | ★★★☆☆ | ★★★★☆ | ★★★☆☆ |
| 类型安全 | ★★☆☆☆ | ★★★☆☆ | ★★★★☆ |
推荐方案: 采用混合模式。
实施要点:
- 核心字段晋升机制:高频查询字段从JSON移到固定列
- JSONB索引:PostgreSQL建立GIN索引
- ES映射模板:自动从类目模板生成
- 缓存策略:L1进程内 + L2 Redis,TTL分层设置
- 属性校验:类目模板定义规则,运行时校验
虚拟商品特殊处理:
- 充值卡:卡密存储加密、核销记录独立表
- 会员服务:有效期、权益包用JSON存储
- 服务类:预约时间、服务人员信息扩展字段
延伸思考:
- 如何处理类目属性变更(模板升级)?
- 历史订单中的商品快照如何存储?
- 跨类目搜索时如何统一属性映射?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:19。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-002:商品详情页的缓存架构设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-002 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:157 |
题干与约束
商品详情页是电商系统访问量最大的页面,QPS可达百万级。请设计商品详情页的缓存架构,保证高性能和数据一致性。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 商品详情页是电商系统访问量最大的页面,QPS可达百万级。请设计商品详情页的缓存架构,保证高性能和数据一致性。
答案:
问题分析: 详情页缓存的核心挑战:
- 流量巨大,需要多级缓存
- 数据来源多(商品、价格、库存、营销),聚合复杂
- 数据更新频繁,缓存一致性难保证
- 热点商品流量集中
方案一:纯CDN缓存
核心思想: 详情页直接缓存在CDN,用户请求直接命中CDN。
设计:
用户 → CDN → 源站
CDN配置:
- 缓存时间:5分钟
- 缓存键:/product/{productId}
- 回源:CDN未命中时请求源站
更新策略:
- 商品信息变更 → 主动刷新CDN
- 或等待TTL过期自然更新
优点:
- 性能极高(边缘节点响应)
- 减轻源站压力
- 成本低
缺点:
- 实时性差(分钟级延迟)
- 个性化内容难处理(如用户登录状态)
- 价格库存等动态信息不适合
适用场景:
- 纯静态内容(商品图文)
- 对实时性要求不高
方案二:多级缓存(推荐)
核心思想: L1本地缓存 + L2 Redis + L3数据库。
架构:
用户 → 应用服务器
├→ L1: 本地缓存(Caffeine/Guava)
├→ L2: Redis(集中式)
└→ L3: MySQL(源数据)
缓存策略:
L1: 热点数据,容量1000条,TTL 30秒
L2: 全量数据,TTL 5分钟
L3: 源数据
查询流程:
1. 查L1,命中返回
2. L1未命中,查L2,写入L1,返回
3. L2未命中,查L3,写入L2和L1,返回
详情页数据聚合:
详情页数据:
- 商品基本信息(商品中心)→ 缓存5分钟
- 价格信息(计价系统)→ 缓存1分钟
- 库存信息(库存系统)→ 不缓存或缓存10秒
- 营销信息(营销系统)→ 缓存1分钟
- 推荐商品(推荐系统)→ 缓存30分钟
聚合策略:
// 并行调用
Future<Product> product = getProductAsync(productId);
Future<Price> price = getPriceAsync(productId);
Future<Stock> stock = getStockAsync(productId);
Future<Promotion> promo = getPromotionAsync(productId);
// 等待所有结果
ProductDetail detail = new ProductDetail(
product.get(500, MILLISECONDS),
price.get(300, MILLISECONDS),
stock.get(200, MILLISECONDS),
promo.get(300, MILLISECONDS)
);
优点:
- 性能好(多级缓存)
- 灵活度高(可针对不同数据设置不同TTL)
- 支持个性化
缺点:
- 架构复杂度中等
- 缓存一致性需要处理
- 多级缓存增加运维成本
方案三:缓存+预热+旁路
核心思想: 提前预热热点数据,冷数据旁路查询。
设计:
1. 预热:
- 大促前:提前加载热销商品
- 运营后台:手动预热重点商品
- 定时任务:每小时预热TOP 1000热门商品
2. 热点识别:
- 实时统计访问频率
- 超过阈值的商品加入热点列表
- 热点商品缓存时间更长
3. 旁路加载:
- 热点商品:L1+L2缓存
- 普通商品:L2缓存
- 长尾商品:直接查数据库
4. 缓存更新:
- 商品信息变更 → 发布事件 → 主动失效缓存
- 或使用版本号:缓存键包含版本号
优点:
- 热点商品性能极高
- 资源利用率高
- 大促效果好
缺点:
- 预热逻辑复杂
- 热点识别有延迟
- 需要实时监控
方案对比:
| 维度 | 纯CDN | 多级缓存 | 缓存+预热 |
|---|---|---|---|
| 性能 | ★★★★★ | ★★★★☆ | ★★★★★ |
| 实时性 | ★★☆☆☆ | ★★★★☆ | ★★★★☆ |
| 个性化 | ★☆☆☆☆ | ★★★★★ | ★★★★★ |
| 复杂度 | ★★★★★ | ★★★☆☆ | ★★☆☆☆ |
推荐方案: 采用多级缓存+热点预热的组合。
实施要点:
-
缓存分层:
L1(本地缓存): - 容量:1000条 - TTL:30秒 - 淘汰策略:LRU - 适用:超热门商品(TOP 100) L2(Redis): - 容量:100万条 - TTL:5分钟 - 集群部署:主从+哨兵 - 适用:热门+普通商品 L3(数据库): - 全量数据 - 读写分离 -
缓存键设计:
方案1:不带版本号 Key: product:detail:{productId} Value: JSON 更新:商品变更时主动删除key 方案2:带版本号(推荐) Key: product:detail:{productId}:{version} Value: JSON 更新:版本号+1,旧key自然过期 -
缓存更新策略:
Cache Aside模式: 1. 读取:先查缓存,未命中再查DB,写入缓存 2. 更新:先更新DB,再删除缓存 Write Through模式: 1. 更新:同时更新DB和缓存 2. 读取:直接读缓存 -
热点治理:
识别热点: - 实时统计访问频率(滑动窗口) - 超过阈值(如10000 QPS)标记为热点 热点处理: - 本地缓存延长TTL(30秒 → 5分钟) - Redis分片存储(product:123:1, product:123:2...) - 限流保护(单商品限流) -
缓存穿透/击穿/雪崩:
穿透(查询不存在的数据): - 布隆过滤器预判 - 空值缓存(TTL短,如1分钟) 击穿(热点key过期): - 互斥锁(只有一个请求回源) - 热点key永不过期(后台异步更新) 雪崩(大量key同时过期): - TTL加随机值(5分钟±30秒) - 缓存预热 - 降级方案(返回旧数据)
延伸思考:
- 缓存和数据库数据不一致如何处理?
- 如何设计缓存的监控指标?
- 大促时如何做缓存容量规划?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:157。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-003:如何解决商品信息变更后搜索不一致问题?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-003 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:391 |
题干与约束
运营修改了商品标题和价格,但搜索结果中仍然显示旧信息。这是典型的最终一致性问题。如何设计商品到搜索的数据同步方案?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 运营修改了商品标题和价格,但搜索结果中仍然显示旧信息。这是典型的最终一致性问题。如何设计商品到搜索的数据同步方案?
答案:
问题分析: 商品搜索一致性的核心挑战:
- 数据变更频繁(价格调整、库存变化)
- 搜索索引构建有延迟
- 用户期望实时看到最新信息
- 大量商品同步对ES集群压力大
方案一:实时同步(强一致性)
核心思想: 商品信息变更时,同步更新ES索引。
设计:
1. 运营后台:修改商品信息
2. 商品服务:
BEGIN TRANSACTION
UPDATE products SET title=?, price=?
// 同步更新ES
esClient.update(productId, {title, price})
COMMIT
3. 用户搜索:立即看到最新数据
优点:
- 实时一致性
- 用户体验好
缺点:
- ES更新慢(可能超时)
- 影响商品更新性能
- ES故障影响商品服务
适用场景:
- 对一致性要求极高
- 变更频率低
方案二:异步同步(最终一致性)
核心思想: 通过消息队列异步同步,保证最终一致性。
设计:
1. 商品服务:
BEGIN TRANSACTION
UPDATE products SET title=?, price=?, version=version+1
INSERT INTO outbox_events (
event_type='ProductUpdated',
payload={productId, title, price, version}
)
COMMIT
2. 事件发布器:
扫描outbox_events → 发送到Kafka
3. 搜索同步Worker:
监听Kafka ProductUpdated事件
更新ES索引
4. 幂等处理:
根据version判断是否需要更新
if (event.version > es_doc.version) {
update ES
}
优点:
- 解耦,不影响商品服务性能
- ES故障不影响商品更新
- 支持重试和补偿
缺点:
- 最终一致性(秒级延迟)
- 实现复杂度中等
适用场景:
- 大部分场景
- 可接受秒级延迟
方案三:双写+对账
核心思想: 同时写MySQL和ES,对账纠正不一致。
设计:
1. 商品服务写入:
// 双写(并行)
Future<Void> f1 = mysqlClient.update(...)
Future<Void> f2 = esClient.update(...)
// 等待两个都成功
f1.get()
f2.get()
2. 对账任务(每小时):
- 查询MySQL最近变更的商品
- 与ES中的数据对比
- 发现不一致,重新同步
3. 增量同步(每分钟):
- 基于updated_at增量同步
- 作为对账的补充
优点:
- 接近实时
- 有补偿机制
缺点:
- 双写失败处理复杂
- 两个数据源可能不一致
- 实现复杂
方案对比:
| 维度 | 实时同步 | 异步同步 | 双写+对账 |
|---|---|---|---|
| 实时性 | ★★★★★ | ★★★★☆ | ★★★★☆ |
| 系统解耦 | ★★☆☆☆ | ★★★★★ | ★★★☆☆ |
| 一致性保证 | ★★★★☆ | ★★★★★ | ★★★★★ |
| 实施难度 | ★★★★☆ | ★★★☆☆ | ★★☆☆☆ |
推荐方案: 采用异步同步+对账。
实施要点:
-
事件设计:
ProductCreated:商品创建 ProductUpdated:商品信息变更(title、desc、images) ProductPriceChanged:价格变更 ProductStatusChanged:上下架 ProductDeleted:删除 -
同步Worker设计:
消费逻辑: 1. 从Kafka消费ProductUpdated事件 2. 根据productId查询完整商品信息 3. 构建ES文档 4. 批量更新ES(bulk API,提高吞吐) 5. 提交offset 批量优化: - 攒批:100条或1秒批量提交 - 去重:同一商品多次变更只保留最新 - 合并:多个字段变更合并为一次更新 -
幂等处理:
ES文档设计: { "productId": "123", "title": "iPhone 15", "price": 5999, "version": 10, // 版本号 "updatedAt": 1679800000 } 更新逻辑: if (event.version > doc.version) { update ES } else { skip (乱序消息) } -
对账机制:
对账任务(每小时): SELECT product_id, version, updated_at FROM products WHERE updated_at >= NOW() - INTERVAL 2 HOUR 对每个商品: - 查询ES中的version - 如果MySQL.version > ES.version - 发送补偿事件到Kafka -
监控告警:
指标: - 同步延迟(消息产生到ES更新完成的时间) - 失败率(同步失败的比例) - 对账差异数(MySQL和ES不一致的商品数) 告警: - 同步延迟 > 10秒 - 失败率 > 1% - 对账差异 > 100条
延伸思考:
- 如果ES集群故障,搜索如何降级?
- 商品删除后ES索引如何处理?
- 大批量商品导入如何优化ES同步性能?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:391。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-004:直接订阅 Binlog 同步 ES 的弊端是什么?如果不同变更之间存在依赖关系,应该怎么处理?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-004 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:603 |
题干与约束
一些电商系统会通过 Binlog / CDC 捕获商品表变更,然后由 ES Synchronizer 消费消息并更新搜索索引。例如商品主表、SKU 表、Offer 表、类目映射表、供应商映射表发生变更后,同步服务根据表名和字段变化去更新 ES 文档。这种方式有什么弊端?如果一个 ES 文档依赖多张表,不同变更之间存在先后关系和依赖关系,应该如何设计?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 一些电商系统会通过 Binlog / CDC 捕获商品表变更,然后由 ES Synchronizer 消费消息并更新搜索索引。例如商品主表、SKU 表、Offer 表、类目映射表、供应商映射表发生变更后,同步服务根据表名和字段变化去更新 ES 文档。这种方式有什么弊端?如果一个 ES 文档依赖多张表,不同变更之间存在先后关系和依赖关系,应该如何设计?
答案:
问题分析:
直接订阅 Binlog 同步 ES 的本质是:
数据库表级变化
→ 触发 ES 文档更新
而商品搜索索引的本质通常是:
多张业务表
→ 聚合成一个商品搜索宽文档
两者粒度不一致。Binlog 看到的是“某张表某一行变了”,ES 需要的是“某个商品聚合视图应该变成什么样”。这会带来几个典型问题:
- 业务语义弱:Binlog 只表达
insert/update/delete,不表达ProductPublished、ProductOffline、OfferChanged、RefundRuleChanged。 - 强依赖表结构:字段新增、删除、顺序变化、JSON 结构变化,都可能影响同步逻辑。
- 跨表依赖复杂:一个 ES 商品文档可能依赖 item、spu、sku、offer、resource、category、stock config、refund rule 等多张表。
- 顺序不稳定:同一业务发布可能写多张表,Binlog 事件到达不同 consumer 时不一定按业务语义有序。
- 并发覆盖风险:两个表变更同时 patch 同一个 ES doc,可能出现后写基于旧 doc 覆盖前写结果。
- 版本语义不足:Binlog timestamp 或 position 不等价于商品业务版本,难以判断旧事件是否应该覆盖新事件。
- 失败补偿困难:失败消息只知道表和字段,不一定知道影响哪个商品、哪个发布版本、是否可以安全重建。
典型错误做法:按每条 Binlog 直接 patch ES
carrier_tab update
→ 查询旧 ES doc
→ 修改 carrier 基础字段
→ update ES
mapping_tab update
→ 查询旧 ES doc
→ 修改 support category / entrance
→ update ES
这种做法的问题是:两个 handler 都可能先读取旧 ES doc,再各自修改一部分字段,最后谁后写谁赢。如果后写的 doc 是基于旧版本读出来的,就可能把前一个变更覆盖掉。
方案一:继续直接 Binlog Patch
核心思想: 每张表的 Binlog handler 只更新 ES 文档中自己负责的字段。
优点:
- 实现直观。
- 延迟低。
- 不需要改上游业务系统。
缺点:
- 依赖关系散落在多个 handler 中。
- 跨表顺序难保证。
- 多个 handler patch 同一个 doc 时容易覆盖字段。
- 表结构变化会影响同步逻辑。
- 出问题后难以判断 ES doc 应该重建成什么样。
适用场景:
- ES 文档和 DB 表几乎一对一。
- 变更字段简单,没有跨表依赖。
- 对一致性要求不高。
方案二:Binlog 只标记 Dirty Doc,再重建完整 ES 文档
核心思想: Binlog 不直接写 ES,而是只负责发现“哪个聚合根脏了”。
Binlog Event
→ 解析影响对象
→ mark dirty(doc_type, doc_id)
→ Index Worker 从 DB 读取最新数据
→ rebuild full ES doc
→ versioned upsert ES
例如:
product_offer_tab changed
→ affected item_id = item_80001
→ mark dirty: product_doc / item_80001
refund_rule_tab changed
→ affected item_id = item_80001
→ mark dirty: product_doc / item_80001
category_mapping_tab changed
→ affected item_id list
→ mark dirty for each item
Dirty Doc 表可以这样设计:
CREATE TABLE es_sync_dirty_doc (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
doc_type VARCHAR(64) NOT NULL,
doc_id VARCHAR(128) NOT NULL,
source_table VARCHAR(128) NOT NULL,
source_event_id VARCHAR(128) DEFAULT NULL,
source_version BIGINT DEFAULT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
COMMENT 'PENDING/RUNNING/SUCCESS/FAILED/DLQ',
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME DEFAULT NULL,
last_error_message VARCHAR(1024) DEFAULT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_doc (doc_type, doc_id),
KEY idx_status_retry (status, next_retry_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ES 同步脏文档队列';
同一个 doc 在短时间内多次变化,只保留一条 dirty 记录:
item update
offer update
refund rule update
→ 合并成 item_80001 的一次 rebuild
重建逻辑:
读取 item_id
→ 查询 item 最新状态
→ 查询 spu / sku / offer
→ 查询类目、资源、库存配置、履约规则、退款规则
→ 判断是否应该被索引
是:upsert ES doc
否:delete ES doc
优点:
- 不依赖 Binlog 到达顺序。
- 不会因为局部 patch 覆盖字段。
- ES 文档构建逻辑集中。
- 可以合并多次变更,降低 ES 写入压力。
- 失败后可以按
doc_type + doc_id重试和补偿。
缺点:
- 延迟比直接 patch 略高。
- 每次重建需要回查 DB,DB 压力更大。
- 需要维护 dependency mapping。
适用场景:
- ES 文档是多表聚合宽文档。
- 商品、Offer、类目、规则之间存在依赖。
- 搜索一致性和可恢复性比毫秒级延迟更重要。
方案三:业务事件 + Outbox + 快照重建
核心思想: 不要让 ES Synchronizer 从表级 Binlog 里猜业务含义,而是让商品发布链路明确发出业务事件。
Publish Transaction
→ 写商品正式表
→ 写 publish_version
→ 写 product_snapshot
→ 写 product_outbox_event(ProductPublished)
→ ES Synchronizer 消费 ProductPublished
→ 按 item_id + publish_version 读取快照
→ rebuild ES doc
事件示例:
{
"event_id": "evt_20260428_000001",
"event_type": "ProductPublished",
"item_id": "item_80001",
"publish_version": 4,
"publish_id": "pub_20001",
"snapshot_id": "snap_90001",
"changed_fields": ["title", "offer", "refund_rule"]
}
ES 写入时带版本:
if event.publish_version < es_doc.publish_version:
ignore
else:
upsert
优点:
- 业务语义清晰。
- 下游不依赖内部表结构。
publish_version可以防乱序。- 可以基于发布快照构建 ES,结果更稳定。
- 排查问题时能回到一次发布动作,而不是一堆表变更。
缺点:
- 需要上游商品中心或供给平台改造。
- 需要设计事件契约和 Outbox。
- 对存量 Binlog 同步系统需要渐进迁移。
适用场景:
- 商品发布、上下架、封禁、回滚等核心业务链路。
- 多系统依赖商品变更通知。
- 搜索、缓存、计价上下文和营销资格消费者都需要一致理解商品版本。
方案对比:
| 维度 | 直接 Binlog Patch | Dirty Doc 重建 | 业务事件 + Outbox |
|---|---|---|---|
| 实现成本 | 低 | 中 | 中高 |
| 业务语义 | 弱 | 中 | 强 |
| 跨表依赖处理 | 差 | 好 | 很好 |
| 防并发覆盖 | 差 | 好 | 很好 |
| 防乱序能力 | 弱 | 中 | 强 |
| 对表结构耦合 | 强 | 中 | 弱 |
| 故障补偿 | 弱 | 好 | 很好 |
| 适合场景 | 简单索引 | 多表聚合索引 | 核心商品发布链路 |
推荐方案:
短期采用 Binlog → Dirty Doc Queue → Full Rebuild ES Doc,中长期演进到 业务事件 + Outbox + 商品快照重建 ES。
推荐落地路径:
-
定义 ES doc 聚合根
product index: doc_id = item_id carrier index: doc_id = carrier_id event index: doc_id = event_id -
维护依赖映射
product_item_tab → item_id product_offer_tab → item_id product_refund_rule_tab → item_id resource_tab → affected item_id list supplier_product_mapping_tab → item_id category_mapping_tab → affected item_id list -
Binlog handler 只做 mark dirty
onBinlog(table, row): doc_ids = resolveAffectedDocIds(table, row) for doc_id in doc_ids: upsert es_sync_dirty_doc(doc_type, doc_id) -
Index Worker 串行处理同一个 doc
SELECT * FROM es_sync_dirty_doc WHERE status = 'PENDING' ORDER BY updated_at ASC LIMIT 100;同一个
doc_type + doc_id通过唯一键合并,Worker 抢占后重建完整文档。 -
重建时读取 DB 最新状态
buildProductDoc(item_id): item = query item offers = query offers rules = query fulfillment / refund rules if item is not indexable: delete ES doc else: upsert full doc -
写 ES 带版本
商品类索引用
publish_version;没有业务版本的对象至少使用updated_at、rebuild_seq或source_version。 -
失败进入 DLQ 和补偿
失败时记录:
doc_type doc_id source_table error_code retry_count next_retry_at -
定期 full sync 和对账
DB latest hash != ES doc hash → mark dirty对于全量重建,建议使用新索引 + alias switch,避免重建期间影响线上查询。
面试总结:
直接订阅 Binlog 同步 ES 不是不能用,而是要清楚它的边界:
Binlog 是表级数据变化,ES index 是业务聚合视图。两者粒度不一致,直接 patch ES 会在跨表依赖、事件顺序、并发覆盖、版本防乱序和失败补偿上变复杂。
更稳的设计是:
短期:
Binlog 只负责发现哪个 doc 脏了
Dirty Queue 合并变更
Worker 从 DB 重建完整 ES doc
长期:
商品发布事务写 Outbox 业务事件
ES Synchronizer 消费 ProductPublished / ProductOffline
按 item_id + publish_version 读取商品快照
versioned upsert ES
这样 ES 同步消费的是“商品版本已发布”这个业务事实,而不是从一堆表级 Binlog 里猜商品到底发生了什么。
延伸思考:
- 如何设计
resolveAffectedDocIds,避免一张配置表变更导致全量商品都被标脏? - ES 写入使用 external version 有什么限制?
- Dirty Queue 堆积时,如何区分高优先级商品和普通商品?
- 全量重建和增量同步同时发生时,如何避免旧增量写到新索引?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:603。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-005:设计商品类目体系和属性管理
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-005 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:955 |
题干与约束
电商平台有上千个类目(如手机、服装、食品),每个类目有不同的属性(手机有内存、颜色,服装有尺码、材质)。如何设计类目体系和属性管理系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台有上千个类目(如手机、服装、食品),每个类目有不同的属性(手机有内存、颜色,服装有尺码、材质)。如何设计类目体系和属性管理系统?
答案:
问题分析: 类目属性管理的核心挑战:
- 类目层级深(最多5-6级)
- 属性类型多样(文本、数值、枚举、多选)
- 属性继承和覆盖
- 属性校验规则复杂
方案一:树形类目+固定属性
核心思想: 类目按树形组织,每个类目预定义固定属性。
设计:
category(类目表)
├── category_id
├── parent_id
├── name
├── level
├── path(/1/10/100/,便于查询祖先)
└── leaf(是否叶子节点)
category_attribute(类目属性定义)
├── category_id
├── attribute_id
├── required(是否必填)
└── display_order
attribute_definition(属性定义)
├── attribute_id
├── name
├── input_type(text/number/enum/multi_enum)
├── validation_rule(JSON)
└── options(枚举值)
优点:
- 结构清晰
- 属性定义规范
- 易于校验
缺点:
- 属性变更需要改表结构
- 不够灵活
- 类目迁移困难
方案二:动态属性模板
核心思想: 类目关联属性模板,属性模板可复用和继承。
设计:
category
├── category_id
├── parent_id
├── attribute_template_id(属性模板)
└── inherit_parent(是否继承父类目属性)
attribute_template(属性模板)
├── template_id
├── name
└── description
template_attribute(模板属性关联)
├── template_id
├── attribute_id
├── required
├── display_order
└── default_value
attribute_meta(属性元数据)
├── attribute_id
├── name
├── code(唯一标识,如"screen_size")
├── data_type(string/int/decimal/enum/boolean)
├── input_type(input/select/checkbox/radio)
├── validation_rule(JSON:min/max/regex/enum_values)
└── searchable(是否可搜索)
继承规则:
示例:手机 → 智能手机 → iPhone
手机类目(一级):
- 品牌、型号、屏幕尺寸、操作系统
智能手机(二级):
- 继承手机的所有属性
- 新增:前置摄像头、后置摄像头、电池容量
iPhone(三级):
- 继承智能手机的所有属性
- 新增:Face ID、MagSafe
- 覆盖:操作系统固定为"iOS"
优点:
- 高度灵活
- 支持继承和复用
- 属性可动态添加
缺点:
- 实现复杂
- 继承逻辑复杂
- 性能有一定影响
方案三:属性分组+扩展字段
核心思想: 将属性分为核心属性(固定字段)和扩展属性(JSON)。
设计:
product
├── 核心属性(固定字段):
│ brand_id, price, weight, status
└── 扩展属性(JSONB):
ext_attrs: {
"screen_size": "6.1英寸",
"memory": "256GB",
"color": "深空黑"
}
category_attr_group(属性分组)
├── category_id
├── group_name(基本信息/规格参数/包装清单)
└── attributes(JSON数组)
优点:
- 平衡性能和灵活性
- 核心属性有索引
- 扩展属性灵活
缺点:
- JSON查询能力有限
- 属性分组需要人工维护
方案对比:
| 维度 | 固定属性 | 动态模板 | 分组+扩展 |
|---|---|---|---|
| 灵活性 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 性能 | ★★★★★ | ★★★☆☆ | ★★★★☆ |
| 实施难度 | ★★★★★ | ★★☆☆☆ | ★★★☆☆ |
| 可维护性 | ★★★☆☆ | ★★★★☆ | ★★★★☆ |
推荐方案: 采用动态属性模板+继承。
实施要点:
-
类目层级设计:
建议:不超过4级 L1:大类(手机、服装、食品) L2:中类(智能手机、T恤、零食) L3:小类(iPhone、圆领T恤、膨化食品) L4:细分类(iPhone 15系列) -
属性校验:
public void validateProduct(Product product, Category category) { // 1. 获取类目属性模板 List<AttributeMeta> attrs = getAttributesByCategory(category); // 2. 检查必填属性 for (AttributeMeta attr : attrs) { if (attr.isRequired() && !product.hasAttribute(attr.getCode())) { throw new ValidationException("缺少必填属性: " + attr.getName()); } } // 3. 校验属性值 for (ProductAttribute attr : product.getAttributes()) { AttributeMeta meta = getAttributeMeta(attr.getCode()); meta.validate(attr.getValue()); // 类型、范围、枚举值校验 } } -
属性搜索支持:
ES映射自动生成: { "mappings": { "properties": { "productId": {"type": "keyword"}, "title": {"type": "text", "analyzer": "ik_max_word"}, "category_id": {"type": "long"}, "brand_id": {"type": "long"}, // 动态属性 "attrs": { "type": "nested", "properties": { "code": {"type": "keyword"}, "value": {"type": "keyword"} } } } } } -
属性演进:
新增属性: 1. 在attribute_meta表添加属性定义 2. 关联到类目模板 3. 存量商品渐进补齐(批量任务或人工) 弃用属性: 1. 标记为deprecated 2. 新商品不展示该属性 3. 老商品保留(不删除) -
多语言支持:
attribute_i18n(属性国际化) ├── attribute_id ├── locale(zh_CN/en_US) ├── name └── description
延伸思考:
- 如何处理类目合并和拆分?
- 属性过多时如何优化详情页加载性能?
- 跨类目搜索时属性如何映射?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:955。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-006:商品图片的存储和CDN方案
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-006 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1198 |
题干与约束
电商平台商品图片数量巨大(百万级),每天上传图片数万张。如何设计图片存储和CDN方案,保证加载速度和成本可控?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台商品图片数量巨大(百万级),每天上传图片数万张。如何设计图片存储和CDN方案,保证加载速度和成本可控?
答案:
问题分析: 图片存储的核心挑战:
- 存储成本高(TB级数据)
- 访问量大(详情页、列表页都需要图片)
- 需要支持多种尺寸(缩略图、中图、大图)
- 图片上传和审核流程
方案一:自建存储+Nginx
核心思想: 图片存储在自有服务器,通过Nginx提供静态服务。
设计:
上传流程:
1. 应用服务器接收图片
2. 保存到本地磁盘:/data/images/{年}/{月}/{日}/{uuid}.jpg
3. 返回URL:http://img.example.com/2026/04/18/xxx.jpg
访问流程:
用户 → Nginx → 本地磁盘
多尺寸处理:
- 上传时生成多个尺寸
- 或使用Nginx image_filter模块动态缩放
优点:
- 完全可控
- 无外部依赖
- 成本可控
缺点:
- 带宽成本高
- 跨地域访问慢
- 需要自己做高可用
- 缺少图片处理能力
方案二:对象存储OSS + CDN(推荐)
核心思想: 图片存储在云厂商对象存储,通过CDN加速访问。
设计:
上传流程:
1. 客户端 → 应用服务器申请上传凭证
2. 应用服务器 → OSS生成临时上传URL(STS)
3. 客户端 → 直传OSS
4. OSS → 回调应用服务器(上传成功)
5. 应用服务器 → 保存图片URL到数据库
访问流程:
用户 → CDN → OSS
图片处理:
URL参数控制:
- 缩放:?x-oss-process=image/resize,w_800
- 裁剪:?x-oss-process=image/crop,w_200,h_200
- 水印:?x-oss-process=image/watermark,text_xxx
- 格式转换:?x-oss-process=image/format,webp
优点:
- 性能好(CDN加速)
- 可靠性高(99.999999999%)
- 图片处理能力强
- 无需运维
缺点:
- 成本较高(按量付费)
- 被云厂商锁定
- 数据外传
方案三:分层存储
核心思想: 热图片存储在SSD+CDN,冷图片存储在归档存储。
设计:
热存储(最近30天):
- OSS标准存储 + CDN
- 访问速度快
- 成本高
冷存储(30天以上):
- OSS归档存储
- 访问需要解冻(分钟级)
- 成本低(1/10)
智能分层:
- 根据访问频率自动迁移
- 热点商品图片永久在热存储
优点:
- 成本优化
- 性能保证
缺点:
- 归档解冻有延迟
- 分层逻辑复杂
方案对比:
| 维度 | 自建 | OSS+CDN | 分层存储 |
|---|---|---|---|
| 性能 | ★★★☆☆ | ★★★★★ | ★★★★☆ |
| 成本 | ★★★☆☆ | ★★★☆☆ | ★★★★☆ |
| 运维成本 | ★★☆☆☆ | ★★★★★ | ★★★☆☆ |
| 功能丰富度 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
推荐方案: 采用OSS+CDN。
实施要点:
-
图片命名规范:
{bucket}/{年}/{月}/{日}/{category}/{uuid}.{ext} 示例: product-images/2026/04/18/phone/550e8400-e29b-41d4-a716-446655440000.jpg -
多尺寸策略:
方案A:上传时生成(推荐) - 上传1张原图 - 后台异步生成:缩略图(100x100)、小图(400x400)、中图(800x800) - 分别存储:{uuid}_thumb.jpg, {uuid}_small.jpg, {uuid}_medium.jpg 方案B:访问时生成 - 只存储原图 - 通过OSS图片处理参数动态生成 - URL:{url}?x-oss-process=image/resize,w_400 -
CDN配置:
缓存策略: - 原图:缓存7天 - 缩略图:缓存30天 - 回源策略:304协商缓存 防盗链: - Referer白名单 - 签名URL(临时访问) - IP黑名单 -
图片审核:
流程: 1. 上传到临时bucket 2. 触发审核(内容安全API) 3. 审核通过 → 移动到正式bucket 4. 审核不通过 → 标记为违规,删除 审核内容: - 色情识别 - 暴恐识别 - 二维码识别 - 文字OCR+敏感词 -
性能优化:
图片格式: - 优先WebP(体积小30%) - 降级JPEG/PNG(老浏览器) 懒加载: - 首屏图片优先加载 - 下方图片懒加载 - 占位图优化体验 压缩: - JPEG质量80%(肉眼无感知) - PNG使用TinyPNG压缩
延伸思考:
- 如何防止图片盗链?
- 商家上传违规图片如何处理?
- 图片存储成本如何优化?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1198。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-007:虚拟商品vs实物商品的设计差异
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-007 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1395 |
题干与约束
实物商品需要物流配送,虚拟商品(如充值卡、会员)是即时发货。两者在系统设计上有哪些差异?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 实物商品需要物流配送,虚拟商品(如充值卡、会员)是即时发货。两者在系统设计上有哪些差异?
答案:
问题分析: 虚拟商品的核心差异:
- 无需物流,履约方式不同
- 库存是卡密池,不是物理库存
- 发货是推送卡密,不是创建运单
- 支持自动发货
方案一:统一建模,类型区分
核心思想: 实物和虚拟商品共用一套模型,通过类型字段区分。
设计:
product
├── product_id
├── product_type(PHYSICAL/VIRTUAL/SERVICE)
├── fulfillment_type(LOGISTICS/INSTANT/APPOINTMENT)
└── 其他通用字段
订单履约流程:
if (product_type == PHYSICAL) {
创建运单 → 发货 → 签收
} else if (product_type == VIRTUAL) {
分配卡密 → 推送用户 → 确认收货
} else if (product_type == SERVICE) {
预约 → 服务 → 评价
}
优点:
- 模型统一,代码复用
- 易于扩展新类型
- 适合混合场景(一单既有实物又有虚拟)
缺点:
- 需要大量if/else判断
- 虚拟商品的特殊字段无法体现
方案二:拆分建模,独立系统
核心思想: 实物商品和虚拟商品拆分为两个系统。
设计:
实物商品系统:
- product, sku(标准商品模型)
- order, order_item
- logistics(物流)
虚拟商品系统:
- virtual_product(虚拟商品)
├── card_type(充值卡类型)
├── face_value(面值)
└── validity_period(有效期)
- card_pool / inventory_code_pool_XX(卡密 / 券码池)
├── card_no
├── card_pwd
├── status(AVAILABLE/BOOKING/SOLD/LOCKED/EXPIRED/INVALID)
└── order_id
- virtual_order(虚拟订单)
优点:
- 模型清晰,职责分明
- 可针对性优化
- 团队独立
缺点:
- 系统重复(订单、支付)
- 混合订单难处理
- 用户体验割裂
方案三:统一订单,差异化履约
核心思想: 订单系统统一,履约环节根据商品类型路由到不同履约系统。
设计:
订单系统(统一):
- 统一的订单模型
- 统一的下单流程
- 统一的支付流程
履约路由:
if (orderItem.productType == PHYSICAL) {
route to LogisticsService
} else if (orderItem.productType == VIRTUAL) {
route to CardDistributionService
} else if (orderItem.productType == SERVICE) {
route to AppointmentService
}
卡密分配服务:
1. 从卡密池分配未使用的卡密
2. 绑定到订单
3. 推送给用户(短信/App)
4. 标记卡密为已分配
优点:
- 订单模型统一
- 支持混合订单
- 履约解耦
缺点:
- 履约系统复杂度增加
方案对比:
| 维度 | 统一建模 | 拆分系统 | 统一订单+差异履约 |
|---|---|---|---|
| 模型清晰度 | ★★★☆☆ | ★★★★★ | ★★★★☆ |
| 混合订单 | ★★★★★ | ★★☆☆☆ | ★★★★★ |
| 实施难度 | ★★★★☆ | ★★☆☆☆ | ★★★☆☆ |
| 用户体验 | ★★★★★ | ★★★☆☆ | ★★★★★ |
推荐方案: 采用统一订单+差异化履约。
实施要点:
-
虚拟商品特殊字段:
virtual_product_ext ├── product_id ├── card_type(MOBILE_CHARGE/VIP_CARD/GAME_COIN) ├── face_value(面值) ├── validity_days(有效天数) └── auto_deliver(是否自动发货) -
卡密池设计:
card_pool ├── card_id ├── product_id ├── card_no ├── card_pwd(加密存储) ├── status(AVAILABLE/LOCKED/USED/INVALID) ├── locked_at(预占时间) ├── order_id ├── used_at └── expire_at 预占机制: 1. 下单时:status=LOCKED, locked_at=NOW() 2. 支付成功:status=USED, order_id=xxx 3. 超时未支付:定时任务释放(status=AVAILABLE) -
自动发货:
触发条件: - 支付成功事件 - 商品类型=虚拟 - auto_deliver=true 发货流程: 1. 从卡密池分配卡密 2. 更新订单状态=COMPLETED 3. 推送卡密给用户(短信/App推送) 4. 记录发货日志 -
卡密补货:
监控: - 可用卡密数量 < 1000 → 告警 补货: - 供应商批量导入 - 或系统自动生成(如游戏币) -
安全控制:
- 卡密加密存储(AES) - 卡密脱敏展示(只显示后4位) - 限制查询频率(防止爬虫) - 异常查询告警
延伸思考:
- 如何防止卡密被盗刷?
- 卡密分配失败如何处理?
- 虚拟商品是否需要支持退款?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1395。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-008:商品上架流程的工作流设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-008 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1594 |
题干与约束
商品从创建到上架需要经过多个环节(信息录入、图片上传、价格设置、审核)。请设计商品上架的工作流系统。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 商品从创建到上架需要经过多个环节(信息录入、图片上传、价格设置、审核)。请设计商品上架的工作流系统。
答案:
问题分析: 商品上架工作流的核心挑战:
- 流程长,涉及多个环节和角色
- 需要支持驳回和重新提交
- 审核规则复杂(机审+人审)
- 大批量商品上架性能
方案一:状态机模式
核心思想: 商品的状态流转按状态机管理。
状态定义:
DRAFT(草稿)
→ PENDING_REVIEW(待审核)
→ APPROVED(审核通过)
→ ONLINE(已上架)
→ OFFLINE(已下架)
→ REJECTED(审核拒绝)→ DRAFT(重新编辑)
状态表:
product
├── product_id
├── status(当前状态)
├── review_status(审核状态:PENDING/PASS/REJECT)
└── reject_reason
product_status_history(状态流水)
├── product_id
├── from_status
├── to_status
├── operator
├── reason
└── created_at
优点:
- 简单直观
- 状态清晰
缺点:
- 复杂流程表达力不足
- 难以支持并行审核
方案二:工作流引擎
核心思想: 使用工作流引擎(如Activiti、Camunda)编排流程。
流程定义(BPMN):
开始 → 填写基本信息 → 上传图片 → 设置价格
→ 提交审核 →
[机器审核] → 通过?
→ YES → [人工审核] → 通过?
→ YES → 上架成功
→ NO → 驳回
→ NO → 驳回
工作流表:
workflow_instance(流程实例)
├── instance_id
├── business_id(product_id)
├── workflow_def_id(流程定义ID)
├── current_node(当前节点)
├── status(RUNNING/COMPLETED/TERMINATED)
└── variables(流程变量,JSON)
workflow_task(任务)
├── task_id
├── instance_id
├── assignee(处理人)
├── status(PENDING/COMPLETED)
└── completed_at
优点:
- 流程可视化(BPMN图)
- 支持复杂流程(并行、分支、子流程)
- 易于调整流程
缺点:
- 引入工作流引擎,学习成本
- 重量级方案
- 调试困难
方案三:轻量级流程引擎
核心思想: 自己实现简化版工作流引擎,满足基本需求。
设计:
// 流程定义(代码配置)
WorkflowDefinition productOnboard = new WorkflowDefinition()
.addNode("FILL_INFO", new FillInfoNode())
.addNode("UPLOAD_IMAGE", new UploadImageNode())
.addNode("SET_PRICE", new SetPriceNode())
.addNode("MACHINE_REVIEW", new MachineReviewNode())
.addNode("MANUAL_REVIEW", new ManualReviewNode())
.addTransition("FILL_INFO", "UPLOAD_IMAGE")
.addTransition("UPLOAD_IMAGE", "SET_PRICE")
.addTransition("SET_PRICE", "MACHINE_REVIEW")
.addTransition("MACHINE_REVIEW", "MANUAL_REVIEW", condition="pass")
.addTransition("MACHINE_REVIEW", "FILL_INFO", condition="reject")
.addTransition("MANUAL_REVIEW", "ONLINE", condition="pass")
.addTransition("MANUAL_REVIEW", "FILL_INFO", condition="reject");
// 流程执行引擎
public class WorkflowEngine {
public void execute(String instanceId) {
WorkflowInstance instance = getInstances(instanceId);
Node currentNode = instance.getCurrentNode();
// 执行当前节点
NodeResult result = currentNode.execute(instance.getContext());
// 根据结果流转到下一节点
Node nextNode = getNextNode(currentNode, result);
instance.setCurrentNode(nextNode);
// 保存状态
saveInstance(instance);
}
}
优点:
- 轻量级,无外部依赖
- 代码即文档
- 易于调试和定制
缺点:
- 功能相对简单
- 不支持BPMN可视化
- 需要自己维护
方案对比:
| 维度 | 状态机 | 工作流引擎 | 轻量引擎 |
|---|---|---|---|
| 实施难度 | ★★★★★ | ★★☆☆☆ | ★★★★☆ |
| 流程表达力 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 维护成本 | ★★★★☆ | ★★★☆☆ | ★★★★☆ |
| 适用场景 | 简单流程 | 复杂流程 | 中等流程 |
推荐方案: 对于商品上架,推荐轻量级流程引擎。
实施要点:
-
审核规则设计:
机器审核: - 图片审核(色情、暴恐) - 标题敏感词检测 - 价格合理性检测(异常低价) - 类目属性完整性检测 人工审核: - 机器审核不通过 → 必须人审 - 高风险类目(药品、食品) → 必须人审 - 新商家首批商品 → 必须人审 - 其他商品 → 机审通过直接上架 -
批量上架优化:
单个上架: - 提交 → 立即审核 → 立即上架 批量上架: - 提交100个商品 - 异步审核(队列) - 审核完成后批量回调 - 生成审核报告 -
驳回重审:
驳回原因分类: - 图片问题(重新上传图片即可) - 价格问题(重新设置价格) - 类目错误(重新选择类目,属性重填) 重审流程: - 修改后自动重新提审 - 或需要人工重新提交 -
工作流监控:
指标: - 待审核商品数量 - 平均审核时长 - 审核通过率 - 驳回原因分布 告警: - 待审核积压 > 1000 - 审核通过率 < 80%
延伸思考:
- 如何设计商品的定时上架功能?
- 批量上架如何保证事务性?
- 审核规则如何动态配置?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1594。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-009:如何支持商品的多规格选择(颜色、尺码等)?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-009 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1816 |
题干与约束
服装类商品有多个规格(颜色、尺码),用户需要先选择规格再下单。如何设计商品规格和SKU的选择逻辑?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 服装类商品有多个规格(颜色、尺码),用户需要先选择规格再下单。如何设计商品规格和SKU的选择逻辑?
答案:
问题分析: 多规格选择的核心挑战:
- 规格组合爆炸(3个颜色×5个尺码=15个SKU)
- 无效组合处理(某颜色没有某尺码)
- 库存关联(每个SKU独立库存)
- 价格差异(不同规格价格不同)
方案一:预生成所有SKU
核心思想: 商品创建时生成所有可能的规格组合。
设计:
spu(商品)
├── spu_id
├── title
└── spec_definitions(规格定义)
{
"color": ["黑色", "白色", "蓝色"],
"size": ["S", "M", "L", "XL"]
}
sku(商品SKU)
├── sku_id
├── spu_id
├── spec_values(规格取值)
{"color": "黑色", "size": "M"}
├── price
├── stock
└── status(可售/售罄/下架)
生成逻辑:
笛卡尔积:3颜色 × 4尺码 = 12个SKU
前端逻辑:
1. 用户选择颜色"黑色"
→ 查询:黑色有哪些尺码可选
→ 禁用无货尺码
2. 用户选择尺码"M"
→ 确定SKU:{color:黑色, size:M}
→ 显示价格、库存
→ 加入购物车(记录sku_id)
优点:
- 逻辑简单
- 查询性能好(直接查SKU表)
- 库存价格独立管理
缺点:
- SKU数量多(组合爆炸)
- 无效组合浪费存储
- 规格变更需要重新生成
方案二:动态组合
核心思想: 不预生成SKU,用户选择时动态计算。
设计:
spu表:
只存储SPU和规格定义,不生成SKU
规格库存表:
spec_stock
├── spu_id
├── spec_hash(规格组合hash)
MD5("color:黑色,size:M")
├── stock
└── price
查询逻辑:
1. 用户选择规格 → 计算spec_hash
2. 查询spec_stock表获取库存价格
3. 下单时记录spec_hash
优点:
- 灵活,规格可动态调整
- 不会产生无效SKU
- 节省存储
缺点:
- 查询复杂(需要计算hash)
- 订单记录不直观(spec_hash)
- 难以支持SKU级别的运营(如促销、限购)
方案三:混合模式(主流+无效过滤)
核心思想: 预生成SKU,但只生成有效组合。
设计:
sku_constraint(无效组合)
├── spu_id
├── constraint_type(DENY/ALLOW)
├── constraint_rule(JSON)
{"color": "黑色", "size": "XL"} // 黑色没有XL
SKU生成逻辑:
1. 计算笛卡尔积
2. 过滤无效组合(根据constraint规则)
3. 生成有效SKU
前端逻辑:
1. 查询所有有效的规格组合
2. 根据用户已选规格,计算可选项
3. 禁用无货或无效的选项
优点:
- 灵活性和性能兼顾
- 支持无效组合
- SKU数量合理
缺点:
- 需要维护约束规则
- 生成逻辑复杂
方案对比:
| 维度 | 预生成所有 | 动态组合 | 混合模式 |
|---|---|---|---|
| SKU数量 | 多 | 无 | 适中 |
| 查询性能 | ★★★★★ | ★★★☆☆ | ★★★★☆ |
| 灵活性 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 运营友好 | ★★★★★ | ★★☆☆☆ | ★★★★☆ |
推荐方案: 采用混合模式(预生成+无效过滤)。
实施要点:
-
前端规格选择组件:
逻辑: 1. 加载所有有效SKU 2. 构建规格树 3. 根据已选规格,计算可选项 4. 禁用无货或无效选项 示例(用户已选"黑色"): 可选尺码 = 筛选(所有SKU, color="黑色" && stock>0) 禁用尺码 = 筛选(所有SKU, color="黑色" && stock=0) -
规格约束表达:
方案A:黑名单 "不存在黑色XL" 方案B:白名单 "只有这些组合:黑色+M, 黑色+L, 白色+S, ..." 推荐:黑名单(灵活) -
SKU图片:
商品主图:展示默认规格 规格图:每个颜色独立图片 用户选择颜色 → 切换主图 -
性能优化:
缓存: - 缓存商品的所有SKU(减少查询) - 缓存规格树(减少计算) 压缩: - 规格数据压缩传输
延伸思考:
- 如何支持规格变更(新增颜色、下架尺码)?
- 用户加购时记录SKU还是规格组合?
- 如何优化规格选择的用户体验?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1816。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-010:商品快照在订单中的应用
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-010 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2011 |
题干与约束
用户下单后,商家可能修改商品标题、价格、图片。为了避免纠纷,需要在订单中保存商品快照。请设计商品快照方案。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户下单后,商家可能修改商品标题、价格、图片。为了避免纠纷,需要在订单中保存商品快照。请设计商品快照方案。
答案:
问题分析: 商品快照的核心挑战:
- 快照内容:保存哪些字段
- 存储成本:每个订单都存快照,数据量大
- 快照时机:下单时还是支付时
- 快照更新:商品变更后订单快照是否更新
方案一:订单表冗余字段
核心思想: 在订单明细表中冗余商品关键字段。
设计:
order_item
├── order_id
├── product_id
├── sku_id
├── product_title(快照)
├── product_image(快照)
├── price(快照)
├── quantity
└── total_amount
优点:
- 查询方便
- 无需JOIN
缺点:
- 字段冗余
- 快照内容有限
- 表结构膨胀
方案二:独立快照表
核心思想: 商品快照存储在独立表,订单引用快照ID。
设计:
product_snapshot
├── snapshot_id
├── product_id
├── sku_id
├── snapshot_data(JSON)
{
"title": "iPhone 15 Pro",
"price": 7999,
"images": ["url1", "url2"],
"specs": {"color": "黑色", "storage": "256GB"},
"brand": "Apple",
"attributes": {...}
}
├── content_hash(MD5,去重)
├── version
└── created_at
order_item
├── order_id
├── snapshot_id(引用快照)
├── quantity
└── total_amount
快照生成时机:
时机1:用户下单时
- 优点:反映下单时的商品信息
- 缺点:未支付订单占用存储
时机2:用户支付时
- 优点:反映支付时的商品信息,更准确
- 缺点:支付时商品可能已下架
推荐:下单时生成,支付时校验
优点:
- 快照完整(可存储任意字段)
- 去重优化(相同快照共享)
- 订单表轻量
缺点:
- 需要JOIN查询
- 存储成本高
方案三:按需快照+延迟生成
核心思想: 下单时不生成快照,只有在需要时(如退货纠纷)才生成。
设计:
order_item
├── product_id
├── sku_id
├── snapshot_id(初始为NULL)
└── snapshot_at(快照生成时间)
生成时机:
1. 用户申请退货
2. 商家纠纷
3. 定时任务(订单完成后30天生成快照)
生成逻辑:
1. 根据product_id查询当前商品信息
2. 生成快照(尽力而为)
3. 如果商品已删除,快照为空
优点:
- 存储成本低
- 按需生成
缺点:
- 延迟生成可能获取不到准确信息
- 商品删除后无法生成
方案对比:
| 维度 | 冗余字段 | 独立快照表 | 按需快照 |
|---|---|---|---|
| 快照完整性 | ★★☆☆☆ | ★★★★★ | ★★★☆☆ |
| 存储成本 | ★★★☆☆ | ★★☆☆☆ | ★★★★★ |
| 查询性能 | ★★★★★ | ★★★★☆ | ★★★☆☆ |
| 准确性 | ★★★★★ | ★★★★★ | ★★★☆☆ |
推荐方案: 采用独立快照表+去重优化。
实施要点:
-
快照内容设计:
必须包含: - 商品标题、主图 - SKU规格、价格 - 品牌、类目 可选包含: - 商品详情图(占用空间大) - 营销信息(优惠券、满减) - 服务承诺(七天无理由退货) -
快照去重:
生成流程: 1. 计算快照内容的MD5: content_hash 2. 查询是否已存在相同hash的快照 3. 如果存在,复用snapshot_id 4. 如果不存在,创建新快照 收益: - 相同商品的订单共享快照 - 存储成本降低50%+ -
快照压缩:
JSON压缩: - 使用gzip压缩snapshot_data - 读取时解压 字段裁剪: - 只保留关键字段 - 详情图等大字段不保存 -
快照过期清理:
策略: - 订单完成后保留2年(法律要求) - 2年后匿名化处理(删除用户信息,保留快照) - 5年后归档到对象存储 -
快照版本化:
快照schema版本: V1: {title, price, image} V2: {title, price, images[], brand, specs} 读取时兼容: if (snapshot.version == 1) { return convertV1ToV2(snapshot) }
延伸思考:
- 商品快照如何支持营销信息(如“限时折扣“)?
- 快照生成失败如何处理?
- 如何设计快照的版本兼容?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2011。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-011:设计商品推荐系统的架构
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-011 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2215 |
题干与约束
电商平台需要在详情页、列表页、首页展示个性化推荐商品。请设计商品推荐系统的架构。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台需要在详情页、列表页、首页展示个性化推荐商品。请设计商品推荐系统的架构。
答案:
问题分析: 推荐系统的核心挑战:
- 推荐算法复杂(协同过滤、深度学习)
- 实时性要求(用户行为实时影响推荐)
- 冷启动问题(新用户、新商品)
- 性能要求高(毫秒级响应)
方案一:基于规则的推荐
核心思想: 使用人工配置的规则进行推荐。
规则示例:
规则1:看了还看
- 用户浏览商品A
- 推荐:浏览过A的用户还浏览了哪些商品
规则2:相似商品
- 用户浏览iPhone 15
- 推荐:同类目、相似价格的商品
规则3:热门商品
- 推荐:该类目下销量TOP 10
规则4:运营配置
- 推荐:运营手动配置的商品(大促主推)
优点:
- 实现简单
- 可控性强
- 无需算法团队
缺点:
- 推荐效果一般
- 不支持个性化
- 规则难以维护
方案二:离线推荐+在线召回
核心思想: 离线计算推荐结果,在线实时召回。
架构:
离线计算(T+1):
1. 收集用户行为数据(浏览、加购、购买)
2. 训练推荐模型(协同过滤、矩阵分解)
3. 计算用户-商品推荐矩阵
4. 存储到Redis:user:123:rec → [prod1, prod2, ...]
在线召回:
1. 用户请求推荐
2. 从Redis查询预计算结果
3. 过滤下架/无货商品
4. 返回推荐列表
实时反馈:
用户点击推荐 → 记录日志 → 下次离线计算时使用
优点:
- 支持复杂算法
- 性能好(在线只查询)
- 推荐效果好
缺点:
- 实时性差(T+1)
- 冷启动问题
- 存储成本高
方案三:实时推荐(流式计算)
核心思想: 使用流式计算(Flink)实时更新推荐结果。
架构:
用户行为 → Kafka → Flink流式计算 → 更新Redis推荐结果
Flink计算逻辑:
1. 实时聚合用户行为(滑动窗口)
2. 更新用户画像(兴趣标签)
3. 实时计算推荐(基于规则或轻量模型)
4. 更新Redis
在线服务:
查询Redis获取实时推荐结果
优点:
- 实时性好(秒级)
- 支持个性化
- 反馈快
缺点:
- 架构复杂
- 成本高
- 算法受限(不能用复杂模型)
方案对比:
| 维度 | 规则推荐 | 离线+在线 | 实时推荐 |
|---|---|---|---|
| 推荐效果 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 实时性 | ★★★★★ | ★★☆☆☆ | ★★★★★ |
| 实施难度 | ★★★★★ | ★★★☆☆ | ★★☆☆☆ |
| 成本 | ★★★★★ | ★★★☆☆ | ★★☆☆☆ |
推荐方案: 采用离线推荐+实时规则补充的混合方案。
实施要点:
-
推荐场景分类:
首页推荐: - 个性化推荐(基于用户画像) - 热门推荐(兜底) 详情页推荐: - 看了还看(基于商品相似度) - 买了还买(基于订单关联) 购物车推荐: - 凑单推荐(基于购物车商品关联) - 优惠推荐(基于满减规则) -
推荐召回链路:
第一层:个性化召回(离线计算) - 协同过滤召回 - 内容召回(基于用户兴趣标签) 第二层:规则召回(在线计算) - 热门商品 - 运营配置 第三层:排序 - 点击率预估 - 转化率预估 - 业务规则调权(如新品扶持) 第四层:过滤 - 去重 - 过滤下架/无货商品 - 多样性(不全是同一类目) -
冷启动处理:
新用户: - 展示热门商品 - 根据注册信息推断兴趣(地域、年龄) - 引导用户选择兴趣标签 新商品: - 基于类目和属性推荐给相关用户 - 运营人工推送给种子用户 - 根据早期反馈调整推荐策略 -
A/B测试:
实验: - 对照组:规则推荐 - 实验组:算法推荐 指标: - 点击率(CTR) - 转化率(CVR) - 人均订单金额 -
监控指标:
业务指标: - 推荐位点击率 - 推荐商品转化率 - 推荐覆盖度(多少用户有推荐) 技术指标: - 推荐响应时间 - 推荐服务可用性 - 离线计算任务成功率
延伸思考:
- 如何评估推荐系统的效果?
- 推荐系统如何防止马太效应(热门更热,冷门更冷)?
- 如何保护用户隐私(不过度使用用户数据)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2215。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-012:商品搜索的倒排索引设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-012 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2418 |
题干与约束
搜索引擎的核心是倒排索引。请说明电商商品搜索的倒排索引如何设计,包括分词、索引结构、查询优化等。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 搜索引擎的核心是倒排索引。请说明电商商品搜索的倒排索引如何设计,包括分词、索引结构、查询优化等。
答案:
问题分析: 倒排索引的核心要点:
- 分词策略(中文分词难点)
- 索引字段选择(哪些字段需要索引)
- 相关性打分(如何排序)
- 性能优化(索引大小、查询速度)
方案一:基于Elasticsearch标准分词
核心思想: 使用ES内置的standard分词器。
配置:
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "standard"
}
}
}
}
倒排索引示例:
商品标题:"Apple iPhone 15 Pro 256GB 黑色"
分词结果:[Apple, iPhone, 15, Pro, 256GB, 黑色]
倒排索引:
Apple → [doc1, doc3, doc8]
iPhone → [doc1, doc2, doc3]
15 → [doc1, doc5]
Pro → [doc1, doc4]
优点:
- 实现简单
- 无需额外配置
缺点:
- 中文分词效果差
- 不支持同义词
- 相关性一般
方案二:基于IK分词器(推荐)
核心思想: 使用中文分词器(IK Analyzer),支持智能分词。
配置:
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word", // 索引时:最细粒度分词
"search_analyzer": "ik_smart" // 搜索时:智能分词
},
"brand": {
"type": "keyword" // 不分词
},
"category": {
"type": "keyword"
},
"price": {
"type": "double"
},
"sales": {
"type": "long"
}
}
}
}
分词示例:
标题:"小米手机13 Ultra 5G智能手机"
ik_max_word:[小米, 米手, 手机, 小米手机, 13, Ultra, 5G, 智能, 智能手机]
ik_smart:[小米, 手机, 13, Ultra, 5G, 智能手机]
优点:
- 中文分词准确
- 支持自定义词典
- 搜索效果好
缺点:
- 需要安装插件
- 词典需要维护
方案三:多字段+权重
核心思想: 对不同字段建立索引,搜索时设置不同权重。
配置:
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word",
"boost": 3.0 // 标题权重最高
},
"brand": {
"type": "keyword",
"boost": 2.0 // 品牌权重次之
},
"category": {
"type": "keyword",
"boost": 1.5
},
"description": {
"type": "text",
"analyzer": "ik_max_word",
"boost": 1.0 // 描述权重最低
}
}
}
}
查询:
{
"query": {
"multi_match": {
"query": "小米手机",
"fields": ["title^3", "brand^2", "description"]
}
}
}
优点:
- 相关性更准确
- 可调整权重
- 支持多字段搜索
缺点:
- 查询复杂度增加
- 权重调优需要经验
方案对比:
| 维度 | 标准分词 | IK分词 | 多字段+权重 |
|---|---|---|---|
| 中文效果 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 实施难度 | ★★★★★ | ★★★★☆ | ★★★☆☆ |
| 相关性 | ★★★☆☆ | ★★★★☆ | ★★★★★ |
| 性能 | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
推荐方案: 采用IK分词+多字段权重。
实施要点:
-
自定义词典:
品牌词:小米、iPhone、华为 型号词:13Ultra、15Pro、Mate60 行业词:闪充、快充、护眼屏 维护: - 定期更新词典 - 新品牌/新词及时添加 -
同义词处理:
{ "filter": { "synonym_filter": { "type": "synonym", "synonyms": [ "手机,移动电话", "充电器,充电头", "iPhone,苹果手机" ] } } } -
拼音搜索:
支持拼音搜索: "xiaomi" → 小米 "pingguo" → 苹果 实现: - 使用pinyin分词插件 - 或维护拼音映射表 -
搜索建议(suggest):
输入"xiao" → 建议:[小米, 小天才, 小度] 输入"iphone" → 建议:[iPhone 15, iPhone 14, iPhone 13] 实现: - 使用ES的completion suggester - 基于前缀匹配 -
性能优化:
索引优化: - 只索引需要搜索的字段 - 使用doc_values减少内存占用 - 定期合并段(segment merge) 查询优化: - 结果分页(from+size < 10000) - 深度分页用scroll或search_after - 热门查询结果缓存
延伸思考:
- 如何实现搜索纠错(“小米手及” → “小米手机”)?
- 如何优化长尾查询的性能?
- 搜索结果如何排序(相关性、销量、价格)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2418。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-013:如何处理商品数据的历史版本?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-013 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2650 |
题干与约束
商品信息会不断变更(价格调整、标题修改、图片更换)。为了审计和纠纷处理,需要保留商品的历史版本。如何设计商品版本管理?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 商品信息会不断变更(价格调整、标题修改、图片更换)。为了审计和纠纷处理,需要保留商品的历史版本。如何设计商品版本管理?
答案:
问题分析: 商品版本管理的核心挑战:
- 版本数据量大(每次变更都存储)
- 查询历史版本(某个时间点的商品信息)
- 版本对比(对比两个版本的差异)
- 存储成本
方案一:全量版本存储
核心思想: 每次变更都保存完整的商品数据。
设计:
product(当前版本)
├── product_id
├── title
├── price
├── version(当前版本号)
└── updated_at
product_history(历史版本)
├── history_id
├── product_id
├── version
├── title
├── price
├── changed_fields(变更字段)
├── operator(操作人)
└── created_at
查询历史:
-- 查询商品在2024-01-15的版本
SELECT * FROM product_history
WHERE product_id='123'
AND created_at <= '2024-01-15'
ORDER BY created_at DESC
LIMIT 1
优点:
- 查询简单
- 可完整恢复任意版本
缺点:
- 存储成本高(每次变更都全量存储)
- 字段冗余
方案二:增量版本存储
核心思想: 只保存变更的字段(diff)。
设计:
product_version
├── version_id
├── product_id
├── version_no
├── changed_fields(JSON)
{
"title": {"old": "iPhone 14", "new": "iPhone 15"},
"price": {"old": 5999, "new": 7999}
}
├── operator
└── created_at
恢复历史版本:
1. 查询当前版本
2. 查询所有版本变更记录(按时间倒序)
3. 依次应用反向变更
4. 得到目标时间点的版本
优点:
- 存储成本低
- 可追踪变更内容
缺点:
- 查询复杂(需要计算)
- 版本恢复慢
方案三:混合模式(快照+增量)
核心思想: 定期保存全量快照,中间保存增量。
设计:
product_snapshot(快照,每周保存)
├── snapshot_id
├── product_id
├── snapshot_data(JSON,完整数据)
├── snapshot_version
└── created_at
product_changelog(变更日志)
├── change_id
├── product_id
├── version
├── changed_fields(JSON)
└── created_at
查询策略:
1. 找到目标时间点之前最近的快照
2. 应用快照之后的变更日志
3. 得到目标版本
优点:
- 平衡存储和查询性能
- 快照恢复快
- 增量节省空间
缺点:
- 实现复杂度中等
方案对比:
| 维度 | 全量版本 | 增量版本 | 混合模式 |
|---|---|---|---|
| 存储成本 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 查询性能 | ★★★★★ | ★★☆☆☆ | ★★★★☆ |
| 实施难度 | ★★★★★ | ★★★☆☆ | ★★★☆☆ |
| 审计能力 | ★★★★★ | ★★★★★ | ★★★★★ |
推荐方案: 对于电商系统,推荐混合模式。
实施要点:
-
快照策略:
触发快照的时机: - 商品上架时(V1) - 每周日凌晨(定期快照) - 重大变更时(价格变动>20%) -
变更日志记录:
public void updateProduct(Product product, ProductUpdate update) { Product old = getProduct(product.getId()); // 1. 更新商品 product.apply(update); product.setVersion(old.getVersion() + 1); productRepository.save(product); // 2. 记录变更日志 ChangeLog log = new ChangeLog(); log.setProductId(product.getId()); log.setVersion(product.getVersion()); log.setChangedFields(diff(old, product)); // 计算diff log.setOperator(getCurrentUser()); changeLogRepository.save(log); } -
版本查询API:
GET /api/products/{productId}/versions → 返回所有版本列表 GET /api/products/{productId}/versions/{version} → 返回指定版本数据 GET /api/products/{productId}/diff?from=10&to=12 → 返回版本差异 -
存储优化:
- 快照使用压缩存储(gzip) - 超过1年的版本归档到对象存储 - 变更日志保留2年(法律要求)
延伸思考:
- 如何支持版本回滚(恢复到历史版本)?
- 版本数据如何支持跨表查询(如关联订单)?
- 大批量商品版本查询如何优化?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2650。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-014:多租户场景下的商品数据隔离
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-014 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2846 |
题干与约束
在B2B2C平台中,多个商家共用一套系统。如何设计商品数据的租户隔离,保证数据安全和性能?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 在B2B2C平台中,多个商家共用一套系统。如何设计商品数据的租户隔离,保证数据安全和性能?
答案:
问题分析: 多租户隔离的核心挑战:
- 数据隔离:商家A看不到商家B的商品
- 性能隔离:商家A的流量不影响商家B
- 成本优化:共享基础设施降低成本
- 个性化:支持商家自定义配置
方案一:独立数据库(物理隔离)
核心思想: 每个租户独立数据库。
设计:
租户A → 数据库A → product_a, order_a
租户B → 数据库B → product_b, order_b
租户C → 数据库C → product_c, order_c
路由逻辑:
public DataSource getDataSource(String tenantId) {
return dataSourceMap.get(tenantId);
}
优点:
- 隔离性强(物理隔离)
- 性能互不影响
- 支持定制化schema
- 数据迁移方便
缺点:
- 成本高(每个租户一个数据库)
- 运维复杂(管理多个数据库)
- 跨租户查询困难
适用场景:
- 大租户(数据量大、QPS高)
- 对隔离要求极高
方案二:共享数据库+tenant_id字段(逻辑隔离)
核心思想: 所有租户共享一个数据库,通过tenant_id字段隔离。
设计:
product
├── product_id
├── tenant_id(租户ID)
├── title
├── price
└── ...
INDEX idx_tenant_product (tenant_id, product_id)
查询:
SELECT * FROM product
WHERE tenant_id='tenant_001' AND product_id='123'
Row-Level Security(PostgreSQL):
CREATE POLICY tenant_isolation ON product
USING (tenant_id = current_setting('app.current_tenant')::text);
-- 应用层设置
SET app.current_tenant = 'tenant_001';
优点:
- 成本低(共享资源)
- 运维简单(一个数据库)
- 跨租户查询方便
缺点:
- 隔离性弱(逻辑隔离)
- 性能互相影响
- 数据量大时性能下降
- 误删风险(忘记加tenant_id条件)
适用场景:
- 小租户(数据量小、QPS低)
- 成本敏感
方案三:分库分表(混合隔离)
核心思想: 大租户独立数据库,小租户共享分片。
设计:
大租户(VIP):
tenant_001 → database_001
tenant_002 → database_002
小租户(普通):
tenant_101, tenant_102, ... → database_shared_01
tenant_201, tenant_202, ... → database_shared_02
路由策略:
if (isVIPTenant(tenantId)) {
return getDedicatedDataSource(tenantId);
} else {
int shardId = hash(tenantId) % 8;
return getSharedDataSource(shardId);
}
优点:
- 成本优化(大租户独享,小租户共享)
- 性能隔离(大租户独立)
- 灵活(可动态迁移)
缺点:
- 架构复杂
- 租户迁移成本
方案对比:
| 维度 | 独立数据库 | 共享+tenant_id | 混合隔离 |
|---|---|---|---|
| 隔离性 | ★★★★★ | ★★☆☆☆ | ★★★★☆ |
| 成本 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 运维复杂度 | ★★☆☆☆ | ★★★★★ | ★★★☆☆ |
| 扩展性 | ★★★★★ | ★★★☆☆ | ★★★★☆ |
推荐方案: 采用混合隔离(分库分表)。
实施要点:
-
租户分级:
VIP租户(月GMV>1000万): - 独立数据库 - 独立Redis - 独立ES索引 普通租户: - 共享分片数据库 - 共享Redis(按tenant_id前缀隔离) - 共享ES索引(按tenant_id过滤) -
数据源路由:
@Aspect public class TenantDataSourceAspect { @Around("execution(* com.example..*Repository.*(..))") public Object route(ProceedingJoinPoint pjp) { String tenantId = TenantContext.get(); DataSource ds = getDataSource(tenantId); // 切换数据源 DynamicDataSourceHolder.set(ds); return pjp.proceed(); } } -
租户升降级:
普通→VIP(升级): 1. 创建独立数据库 2. 数据迁移(双写验证) 3. 切换路由 4. 清理旧数据 VIP→普通(降级): 1. 迁移到共享分片 2. 切换路由 3. 删除独立数据库 -
安全控制:
- 强制tenant_id过滤(ORM拦截器) - 禁止跨租户查询 - API鉴权(JWT包含tenant_id) - 审计日志(记录租户操作)
延伸思考:
- 如何防止误查询跨租户数据(ORM层面)?
- 租户数据如何备份和恢复?
- 如何支持租户级别的功能开关?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2846。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-015:商品导入的批量处理优化
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-015 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3040 |
题干与约束
商家需要批量导入商品(一次导入1000-10000个)。如何设计批量导入功能,保证性能和数据正确性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 商家需要批量导入商品(一次导入1000-10000个)。如何设计批量导入功能,保证性能和数据正确性?
答案:
问题分析: 批量导入的核心挑战:
- 数据量大,处理时间长
- 需要校验每个商品(格式、必填项、业务规则)
- 部分成功部分失败如何处理
- 导入进度如何实时反馈
方案一:同步导入
核心思想: 用户上传文件,服务端同步处理,处理完返回结果。
流程:
1. 用户上传Excel/CSV文件
2. 服务端解析文件
3. 逐行校验和插入数据库
4. 返回导入结果(成功X条,失败Y条)
优点:
- 实现简单
- 用户立即知道结果
缺点:
- 同步处理,用户等待时间长
- 大文件可能超时
- 占用服务器资源
适用场景:
- 小批量(<1000条)
- 对实时性要求高
方案二:异步导入+进度查询
核心思想: 用户上传文件后立即返回,后台异步处理。
流程:
1. 用户上传文件
2. 服务端:
- 保存文件到OSS
- 创建导入任务(状态:PENDING)
- 返回任务ID
3. 后台Worker:
- 异步处理导入任务
- 更新任务进度
- 完成后通知用户
4. 用户查询进度:
GET /api/import-tasks/{taskId}
导入任务表:
import_task
├── task_id
├── tenant_id
├── file_url(OSS地址)
├── total_count(总数)
├── success_count(成功数)
├── fail_count(失败数)
├── status(PENDING/PROCESSING/SUCCESS/FAILED)
├── error_file_url(失败记录文件)
├── progress(进度百分比)
└── created_at
import_detail(导入明细,可选)
├── task_id
├── row_no(行号)
├── product_data(JSON)
├── status(SUCCESS/FAILED)
└── error_message
优点:
- 用户体验好(不用等待)
- 支持大批量
- 不占用Web线程
缺点:
- 实现复杂
- 需要进度查询接口
方案三:流式导入+实时反馈
核心思想: 使用WebSocket实时推送导入进度。
流程:
1. 用户上传文件
2. 建立WebSocket连接
3. 服务端:
- 边解析边处理
- 每处理100条推送进度
- 实时返回失败记录
4. 用户实时看到进度和错误
优点:
- 实时反馈
- 用户体验最好
- 可随时中断
缺点:
- 需要维护WebSocket连接
- 实现最复杂
方案对比:
| 维度 | 同步导入 | 异步导入 | 流式导入 |
|---|---|---|---|
| 用户体验 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 支持规模 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 实施难度 | ★★★★★ | ★★★☆☆ | ★★☆☆☆ |
| 实时反馈 | ★★★★★ | ★★☆☆☆ | ★★★★★ |
推荐方案: 采用异步导入+进度查询。
实施要点:
-
文件解析:
支持格式: - Excel(.xlsx) - CSV - JSON 解析优化: - 流式解析(不一次加载全文件) - 分批处理(每100条一批) -
数据校验:
校验层级: L1:格式校验(必填字段、字段类型) L2:业务校验(价格合理性、类目有效性) L3:关联校验(品牌是否存在、图片URL是否有效) 快速失败: - 格式错误直接返回,不处理后续数据 -
事务处理:
方案A:全量事务 - 全部成功才提交,任一失败全部回滚 - 适合小批量、关联性强的数据 方案B:分批事务(推荐) - 每100条一个事务 - 部分失败不影响其他批次 - 生成失败报告 -
性能优化:
- 批量INSERT(100条一次) - 异步同步ES(不阻塞导入) - 限流(防止导入占用所有资源) - 分时段(凌晨处理大批量) -
失败处理:
失败记录: - 生成Excel文件,标注失败原因 - 用户下载修改后重新导入 部分成功: - 成功的商品已入库 - 失败的记录在error_file中
延伸思考:
- 如何支持导入任务的取消?
- 导入过程中商品数据变更如何处理?
- 如何设计商品导入的幂等性?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3040。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-016:商品审核流程的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-016 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3231 |
题干与约束
商家上传的商品需要经过审核才能上架(防止违规商品)。请设计商品审核系统,包括机审和人审。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 商家上传的商品需要经过审核才能上架(防止违规商品)。请设计商品审核系统,包括机审和人审。
答案:
问题分析: 商品审核的核心挑战:
- 审核效率:大量商品等待审核
- 审核准确性:机审误报,人审成本高
- 审核优先级:重点类目优先审核
- 申诉流程:商家对审核结果不满
方案一:纯人工审核
核心思想: 所有商品都由审核人员人工审核。
流程:
1. 商家提交商品
2. 进入审核队列
3. 审核员登录审核后台
4. 逐个审核(通过/拒绝)
5. 通过的商品上架
优点:
- 准确性高
- 实现简单
缺点:
- 效率低
- 人力成本高
- 审核周期长
适用场景:
- 商品量少(每天<100个)
- 高风险类目(药品)
方案二:机审+人审(推荐)
核心思想: 机器审核过滤大部分,人工审核复杂case。
流程:
商品提交
→ 机器审核
→ 通过(80%)→ 直接上架
→ 不确定(15%)→ 人工审核
→ 拒绝(5%)→ 直接拒绝
机器审核规则:
1. 图片审核:
- 调用内容安全API
- 检测色情、暴恐、二维码
- 置信度 > 0.9 → 拒绝
- 置信度 0.7-0.9 → 转人审
- 置信度 < 0.7 → 通过
2. 文本审核:
- 标题敏感词检测
- 虚假宣传检测("最好"、"第一")
- 医疗广告检测
3. 价格审核:
- 异常低价(低于市场价50%)
- 异常高价(高于市场价200%)
4. 类目审核:
- 类目与商品不匹配
- 必填属性缺失
人工审核:
审核任务分配:
- 按类目分配(服装审核员、3C审核员)
- 按优先级(大商家优先、付费商家优先)
- 负载均衡(平均分配)
审核操作:
- 通过:商品上架
- 拒绝:填写拒绝原因(类目错误、图片违规、价格虚高)
- 待定:标记问题,转高级审核员
优点:
- 效率高(机审处理80%)
- 成本可控
- 准确性较好
缺点:
- 需要维护审核规则
- 机审误报需要人工校正
方案三:智能审核(AI审核)
核心思想: 使用机器学习模型进行审核。
模型训练:
训练数据:
- 正样本:审核通过的商品
- 负样本:审核拒绝的商品
特征工程:
- 文本特征:标题、描述的词频、TF-IDF
- 图片特征:图片分类、OCR文字
- 商家特征:店铺等级、历史通过率
- 类目特征:类目风险等级
模型:
- LR、GBDT、Deep Learning
输出:
- 通过概率:0.9 → 直接通过
- 拒绝概率:0.8 → 直接拒绝
- 中间态:0.5-0.8 → 人工审核
优点:
- 准确率高(持续学习)
- 自动化程度高
- 可处理复杂case
缺点:
- 需要算法团队
- 需要大量训练数据
- 模型维护成本高
方案对比:
| 维度 | 纯人审 | 机审+人审 | AI审核 |
|---|---|---|---|
| 审核效率 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 准确率 | ★★★★★ | ★★★★☆ | ★★★★★ |
| 成本 | ★★☆☆☆ | ★★★★☆ | ★★★☆☆ |
| 实施难度 | ★★★★★ | ★★★★☆ | ★★☆☆☆ |
推荐方案: 采用机审+人审,逐步引入AI审核。
实施要点:
-
审核规则配置化:
审核规则表: review_rule ├── rule_id ├── rule_name ├── rule_type(IMAGE/TEXT/PRICE/CATEGORY) ├── rule_config(JSON) ├── severity(HIGH/MEDIUM/LOW) ├── action(REJECT/MANUAL_REVIEW/PASS) └── enabled 示例规则: { "rule_name": "敏感词检测", "keywords": ["假货", "高仿", ...], "action": "REJECT" } -
审核任务队列:
优先级队列: P0:付费商家、大商家 P1:普通商家 P2:新商家 分配策略: - P0优先分配 - 同优先级按提交时间 - 负载均衡(每个审核员任务量相当) -
审核SLA:
目标: - 机审:5秒内完成 - 人审:2小时内完成(工作时间) 超时告警: - 待审核任务积压 > 500 - 人审超时 > 50个 -
申诉流程:
商家不满审核结果: 1. 点击"申诉" 2. 填写申诉理由 3. 转高级审核员复审 4. 复审结果通知商家
延伸思考:
- 如何设计审核人员的绩效考核?
- 机审规则如何动态调整(根据审核质量)?
- 如何防止商家恶意提交违规商品?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3231。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-017:库存是怎么创建出来的?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-017 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3443 |
题干与约束
很多库存系统只讲扣减、预占和释放,但真实业务里库存首先要被创建出来。有的 SKU 只是简单数量,有的需要券码池,有的是系统自己生成券码,有的还和门店、日期、时段有关。如何设计库存创建链路?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 很多库存系统只讲扣减、预占和释放,但真实业务里库存首先要被创建出来。有的 SKU 只是简单数量,有的需要券码池,有的是系统自己生成券码,有的还和门店、日期、时段有关。如何设计库存创建链路?
答案:
库存创建不是简单 insert stock=100,而是把商品中心的销售契约物化成库存域可扣减、可对账、可恢复的实例。推荐把库存创建做成独立命令和任务:
ProductPublished / OpsImportSubmitted / SupplierSnapshotReady
→ InventoryCreateCommand
→ inventory_create_task
→ InventoryInitWorker
→ inventory_config / inventory_balance / inventory_code_pool_XX
→ Redis 热视图预热
→ InventoryReady / InventoryCreateFailed
创建命令要表达清楚:
sku_id / offer_id
management_type:平台自管 / 供应商管理 / 无限库存
unit_type:数量 / 券码 / 时间 / 座位 / 组合
scope_type / scope_id:GLOBAL / STORE / CITY / WAREHOUSE / DATE / CHANNEL
batch_id:券码批次或货品批次
calendar_date / time_slot:日期或时段
initial_quantity:初始数量
code_source:IMPORTED / SYSTEM_GENERATED / SUPPLIER_GENERATED
idempotency_key:防重复创建
不同库存类型的创建方式不同:
| 类型 | 创建方式 | 关键点 |
|---|---|---|
| 简单数量库存 | 创建 inventory_config 和一行 inventory_balance | 写 INIT/INBOUND 流水,不能绕过账本直接改 stock |
| 门店数量库存 | 按 sku_id + store_id 创建库存行 | 门店上下线要支持锁定、迁移和审计 |
| 日期 / 时段库存 | 按 sku_id + store_id + date + slot 创建切片 | 高流量品类提前物化,长尾门店懒创建 |
| 导入券码库存 | 创建 inventory_code_batch,逐行写 inventory_code_pool_XX | 加密存储、哈希去重、Redis LIST 只预热 code_id |
| 系统生成券码 | 预生成批次,或按订单幂等生成后落库 | 返回给用户前必须先有 MySQL 权威行 |
| 供应商库存 | 创建供应商映射和本地快照 | 本地快照不是最终承诺,下单前需要强刷或预订 |
面试时可以强调三个原则:
- 库存创建要任务化:商品发布事务不应该同步创建海量券码或未来 365 天日历库存,否则发布链路会被库存写放大拖垮。
- 库存创建要幂等:同一个发布版本、导入批次或供应商快照重复投递时,不能重复入库或重复生成券码。
- 库存创建要能解释来源:每一次初始化、导入、补货、系统生码都要有任务、批次和账本流水,否则后续对账只能看到“库存变了”,无法解释为什么变。
对于券码制,最容易踩坑的是把 Redis 当成码池权威。正确做法是:
导入或生成券码
→ 加密写入 inventory_code_pool_XX
→ status=AVAILABLE
→ Redis LIST 只灌入 code_id
→ 下单时弹出 code_id
→ MySQL CAS: AVAILABLE -> BOOKING
只有 MySQL 状态机更新成功,才算真正锁码成功。Redis 可以丢、可以重建,但不能成为唯一账本。
延伸思考:
- 库存创建任务部分成功时,哪些数据可以继续保留,哪些必须回滚?
- 系统生成券码如何防止被猜测和批量撞库?
- 酒店或门店预约类库存,未来多久的日历切片应该提前物化?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3443。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-018:库存如何和商品供给运营平台、商品生命周期联动?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-018 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3513 |
题干与约束
作为一个长期做电商平台的工程师,不能只讲库存扣减。商品从供给入口进入平台、经过审核发布、上线、下架、结束销售、售后核销,库存系统应该如何和商品供给运营平台以及商品生命周期联动?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 作为一个长期做电商平台的工程师,不能只讲库存扣减。商品从供给入口进入平台、经过审核发布、上线、下架、结束销售、售后核销,库存系统应该如何和商品供给运营平台以及商品生命周期联动?
答案:
核心判断是:商品发布不等于商品可售,审核通过也不等于库存 ready。
三层职责要分开:
| 层 | 负责什么 | 不能做什么 |
|---|---|---|
| 商品供给运营平台 | Draft、Staging、QC、Diff、风险审核、发布任务 | 直接写库存余额和券码池 |
| 商品生命周期 | ONLINE/OFFLINE/ENDED/BANNED/ARCHIVED、销售时间、发布版本 | 直接判断库存扣减是否成功 |
| 库存系统 | 库存配置、数量、码池、门店 / 日期切片、预占、账本 | 决定商品标题、类目、审核结果 |
| 营销系统 | 活动、券、补贴、预算、营销库存、优惠规则 | 直接改商品生命周期和库存账本 |
| 可售投影 | 合成商品、库存、价格、营销、履约、渠道、风控状态 | 不能替代库存权威账本 |
推荐链路:
供给入口 / 运营编辑 / 供应商同步
→ Draft / Staging / QC / Diff
→ Publish Transaction
写正式商品、publish_version、交易契约、Outbox
→ InventoryCreateCommand / InventoryAdjustCommand
→ 库存任务创建或调整库存实例
→ InventoryReady / InventoryChanged / InventoryFailed
→ Marketing Command / Eligibility Event
→ Availability Projector 合成可售状态
→ 搜索、缓存、详情页、运营看板刷新
生命周期和库存动作可以这样对应:
| 商品生命周期动作 | 库存系统动作 | 可售影响 |
|---|---|---|
| Draft / Staging | 只做配置校验,不创建 C 端可用库存 | 不可见、不可售 |
| QC 通过 | 可以预创建库存任务,但不开放 Reserve | 仍不可售 |
| Publish 成功 | 消费 Outbox,创建 inventory_config、数量行、码池或时间切片 | 等待 InventoryReady |
| ONLINE 生效 | 若库存 ready 且未锁定,允许 Reserve | 可售 |
| 运营补货 | 走 AdjustInventory/ImportCodeBatch/GenerateCodeBatch | 可售水位变化 |
| OFFLINE 下架 | 停止新 Reserve,保留历史预占和已售记录 | 不可下单 |
| ENDED 销售结束 | 锁定剩余库存,过期未售券码,停止供应商 booking | 不可售,只保留售后 |
| BANNED 风控封禁 | 立即冻结新预占,必要时锁定码池 | 不可售,人工处理 |
成熟平台通常会单独做可售投影:
Sellable =
product_status == ONLINE
AND now in sale_time_window
AND inventory_status in READY/AVAILABLE
AND price_status == READY
AND fulfillment_status == READY
AND channel_policy allows current channel
AND risk_status not in BLOCKED
这样运营后台可以解释商品为什么不能卖:
商品已发布,但不可售:
- 库存创建任务失败:券码文件有重复码
- 门店 1001 未配置营业时段
- 供应商 external_sku_id 映射缺失
- 搜索索引刷新失败,等待 Outbox 重试
要避免的反模式:
- 供给后台直接改
stock字段,绕过库存账本; - 商品
ONLINE后默认可卖,忽略库存、价格、履约和搜索刷新状态; - 下架时删除库存行,导致历史订单、售后和券码核销不可追溯;
- 供应商同步直接覆盖运营手工修复的库存策略;
- 库存系统直接改商品生命周期,绕过审核和发布版本。
一句话总结:
供给平台治理变更,生命周期控制线上状态,库存系统提供可承诺资源,可售投影把这些状态合成用户能否下单。它们通过命令、事件、版本和幂等键协作,而不是互相直接改库。
延伸思考:
- 商品已发布但库存初始化失败,是否允许展示“售罄”?
- 运营手工补货和供应商同步库存冲突时,字段主导权怎么判定?
- 下架后已有预占订单是否继续履约,谁来仲裁?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3513。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-019:设计防止库存超卖的方案
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-019 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3602 |
题干与约束
电商大促时,热门商品库存100件,但短时间涌入1000个订单。如何设计库存扣减方案,防止超卖?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商大促时,热门商品库存100件,但短时间涌入1000个订单。如何设计库存扣减方案,防止超卖?
答案:
问题分析: 库存超卖的核心原因:
- 并发扣减:多个请求同时扣减库存
- 分布式环境:库存分散在多个节点
- 缓存不一致:Redis和DB库存不同步
- 库存回滚:订单取消后库存未释放
方案一:数据库悲观锁
核心思想: 使用数据库行锁保证原子性。
实现:
-- 查询并锁定
SELECT stock FROM inventory
WHERE sku_id='123'
FOR UPDATE;
-- 检查库存
if (stock >= quantity) {
-- 扣减库存
UPDATE inventory
SET stock = stock - quantity
WHERE sku_id='123';
COMMIT;
} else {
ROLLBACK;
throw new OutOfStockException();
}
优点:
- 强一致性
- 不会超卖
- 实现简单
缺点:
- 性能差(锁冲突)
- 并发度低
- 可能死锁
适用场景:
- 并发不高(QPS<1000)
- 小规模系统
方案二:数据库乐观锁
核心思想: 使用版本号,更新失败时重试。
实现:
-- 查询库存和版本号
SELECT stock, version FROM inventory WHERE sku_id='123';
-- 扣减库存(带版本号)
affected = UPDATE inventory
SET stock = stock - quantity, version = version + 1
WHERE sku_id='123' AND version = {oldVersion} AND stock >= quantity;
if (affected == 0) {
// 更新失败,重试
retry();
}
优点:
- 无锁,性能好
- 不会超卖
缺点:
- 高并发时重试多
- 用户体验差(重试慢)
适用场景:
- 中等并发(QPS 1000-5000)
- 普通商品
方案三:Redis原子操作(推荐)
核心思想: 使用Redis的DECR原子操作扣减库存。
实现:
-- Lua脚本(原子执行)
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) >= tonumber(ARGV[1]) then
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
else
return 0
end
调用:
String key = "stock:sku:123";
Long result = redis.eval(luaScript,
Collections.singletonList(key),
Collections.singletonList(quantity));
if (result == 1) {
// 扣减成功,异步同步到DB
createOrder();
} else {
// 库存不足
throw new OutOfStockException();
}
异步同步DB:
定时任务(每10秒):
1. 收集Redis库存变更
2. 批量更新MySQL
3. 对账纠偏
优点:
- 性能极高(Redis内存操作)
- 支持高并发(10万+ QPS)
- 不会超卖
缺点:
- Redis和DB最终一致性
- Redis故障风险
- 需要对账
方案对比:
| 方案 | 性能 | 一致性 | 并发度 | 适用场景 |
|---|---|---|---|---|
| 悲观锁 | ★★☆☆☆ | 强一致 | ★★☆☆☆ | 低并发 |
| 乐观锁 | ★★★☆☆ | 强一致 | ★★★☆☆ | 中并发 |
| Redis原子 | ★★★★★ | 最终一致 | ★★★★★ | 高并发 |
推荐方案: 采用Redis原子操作+异步同步DB。
实施要点:
-
双层库存设计:
Redis(实时库存): - 用于扣减判断 - 高性能 - 可能丢失 MySQL(权威库存): - 定期同步Redis - 数据持久化 - 对账基准 -
库存同步:
Redis → MySQL: - 定时任务(每10秒) - 批量更新(减少DB压力) - 增量同步(只同步变更的SKU) MySQL → Redis: - 商品上架时初始化Redis - 运营调整库存时更新Redis - Redis故障恢复时从MySQL加载 -
库存预热:
大促前: 1. 识别热门商品(预测销量) 2. 提前加载到Redis 3. 设置永不过期 4. 多副本(主从) -
降级方案:
Redis故障: - 降级到MySQL悲观锁 - 限流(降低并发度) - 提示用户(商品火爆) -
监控告警:
指标: - Redis和MySQL库存差异 - 库存扣减QPS - 库存不足次数 - 超卖告警(库存为负) 告警: - 库存差异 > 100 - 超卖发生 - Redis同步延迟 > 1分钟
延伸思考:
- 秒杀场景如何进一步优化(如库存分段、令牌桶)?
- Redis故障导致库存丢失如何恢复?
- 如何处理订单取消后的库存回补?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3602。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-020:如何设计分布式库存系统?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-020 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3813 |
题干与约束
电商平台有多个仓库(北京、上海、深圳),商品在不同仓库有不同库存。如何设计分布式库存系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台有多个仓库(北京、上海、深圳),商品在不同仓库有不同库存。如何设计分布式库存系统?
答案:
问题分析: 分布式库存的核心挑战:
- 库存分布:如何在多仓库间分配库存
- 库存查询:如何快速查询总库存
- 库存分配:用户下单时选择哪个仓库发货
- 库存调拨:仓库间库存转移
方案一:集中式库存
核心思想: 所有仓库库存汇总到一个中心库存池。
设计:
inventory
├── sku_id
├── total_stock(总库存 = sum(所有仓库))
├── reserved_stock(预占库存)
└── available_stock(可售库存)
warehouse_inventory(仓库库存明细)
├── sku_id
├── warehouse_id
├── stock
└── reserved_stock
库存扣减:
1. 扣减total_stock(集中判断)
2. 分配仓库(路由算法)
3. 扣减warehouse_inventory
优点:
- 逻辑简单
- 总库存查询快
- 不会出现“有总库存但无仓库可发“
缺点:
- 集中式瓶颈
- 仓库分配逻辑复杂
方案二:分布式库存(独立核算)
核心思想: 每个仓库独立管理库存,用户下单时路由到最优仓库。
设计:
warehouse_inventory
├── sku_id
├── warehouse_id
├── stock
├── reserved_stock
└── available_stock
用户下单流程:
1. 根据用户地址选择就近仓库
2. 查询该仓库库存
3. 如果有货,扣减该仓库库存
4. 如果无货,选择次近仓库
仓库路由策略:
策略1:就近原则
- 北京用户 → 北京仓
- 上海用户 → 上海仓
策略2:库存优先
- 查询所有仓库库存
- 优先选择库存最多的仓库
策略3:成本优先
- 考虑运费、配送时效
- 选择性价比最高的仓库
优点:
- 分布式,无单点
- 性能好
- 仓库自治
缺点:
- 总库存需要聚合
- 仓库间库存不均
- 路由策略复杂
方案三:虚拟库存池(推荐)
核心思想: 前台展示虚拟总库存,后台按规则分配实际仓库。
设计:
前台层(用户可见):
inventory_view
├── sku_id
├── total_available(虚拟总库存)
= sum(warehouse_inventory.available_stock)
后台层(实际库存):
warehouse_inventory
├── sku_id
├── warehouse_id
├── physical_stock(实际库存)
├── reserved_stock(预占)
├── safety_stock(安全库存)
└── available_stock = physical_stock - reserved_stock - safety_stock
用户下单:
1. 检查虚拟总库存(快速判断)
2. 预占总库存(防止超卖)
3. 路由算法选择仓库
4. 扣减仓库库存
5. 如果仓库分配失败,尝试其他仓库
路由算法:
优先级:
1. 就近仓库(配送快)
2. 库存充足仓库(避免缺货)
3. 成本低仓库(运费低)
加权打分:
score = w1 * distance_score + w2 * stock_score + w3 * cost_score
选择score最高的仓库
优点:
- 用户体验好(总库存可见)
- 灵活分配(后台优化)
- 支持复杂路由
缺点:
- 实现复杂
- 需要智能分配算法
方案对比:
| 维度 | 集中式 | 分布式 | 虚拟池 |
|---|---|---|---|
| 用户体验 | ★★★★★ | ★★★☆☆ | ★★★★★ |
| 性能 | ★★★☆☆ | ★★★★★ | ★★★★☆ |
| 库存利用率 | ★★★★★ | ★★★☆☆ | ★★★★★ |
| 实施难度 | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
推荐方案: 采用虚拟库存池。
实施要点:
-
库存聚合:
实时聚合(Redis): total_stock:sku:123 = stock:warehouse:1:sku:123 + stock:warehouse:2:sku:123 + stock:warehouse:3:sku:123 更新触发: - 仓库库存变更 → 更新总库存 - 使用Redis Pipeline批量更新 -
仓库选择算法:
public Warehouse selectWarehouse( String userId, Address address, String skuId, int quantity ) { // 1. 筛选有货仓库 List<Warehouse> candidates = warehouses.stream() .filter(w -> w.getStock(skuId) >= quantity) .collect(Collectors.toList()); // 2. 计算每个仓库的得分 return candidates.stream() .map(w -> new ScoredWarehouse(w, calculateScore(w, address))) .max(Comparator.comparing(ScoredWarehouse::getScore)) .map(ScoredWarehouse::getWarehouse) .orElseThrow(OutOfStockException::new); } private double calculateScore(Warehouse w, Address addr) { double distanceScore = 1.0 / distance(w, addr); // 距离越近越高 double stockScore = w.getStock() / 100.0; // 库存越多越高 double costScore = 1.0 / w.getShippingCost(); // 成本越低越高 return 0.5 * distanceScore + 0.3 * stockScore + 0.2 * costScore; } -
库存预占:
预占流程: 1. 用户下单 → 预占库存(reserved_stock +quantity) 2. 用户支付 → 确认扣减(stock -quantity, reserved_stock -quantity) 3. 用户取消 → 释放库存(reserved_stock -quantity) 超时释放: - 未支付订单30分钟后自动取消 - 定时任务扫描超时预占,自动释放 -
库存调拨:
场景: - 北京仓库存100,上海仓库存0 - 上海用户下单,需要从北京调拨 调拨流程: 1. 创建调拨单 2. 北京仓库:stock -10 3. 运输中... 4. 上海仓库:stock +10 -
安全库存:
设计: available_stock = physical_stock - reserved_stock - safety_stock 作用: - 预留库存应对盘点误差 - 预留库存应对损坏、丢失 - 建议:safety_stock = physical_stock * 5%
延伸思考:
- 如何设计库存预警机制(库存不足提醒)?
- 多仓库场景下如何最优化运费成本?
- 如何处理商品跨仓拆单(一单多仓发货)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3813。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-021:库存预占与释放的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-021 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4264 |
题干与约束
用户加入购物车或进入结算页时,需要预占库存,防止其他用户抢走。但如果用户不支付,需要释放库存。如何设计库存预占机制?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户加入购物车或进入结算页时,需要预占库存,防止其他用户抢走。但如果用户不支付,需要释放库存。如何设计库存预占机制?
答案:
问题分析: 库存预占的核心挑战:
- 预占时机:什么时候预占(加购、结算、下单)
- 预占时长:预占多久(太短影响支付,太长占用库存)
- 超时释放:如何自动释放超时预占
- 并发安全:多个请求同时预占
方案一:下单时预占
核心思想: 用户下单时才预占库存,加购和结算不预占。
设计:
加购物车:不预占库存
进入结算页:不预占库存
提交订单:预占库存
→ 成功:进入支付流程
→ 失败:提示库存不足
预占超时:30分钟
支付成功:确认扣减
订单取消:释放库存
优点:
- 库存利用率高
- 实现简单
缺点:
- 用户结算时可能无货(体验差)
- 无法保证结算页的库存
适用场景:
- 普通商品
- 库存充足
方案二:结算时预占(推荐)
核心思想: 用户进入结算页时预占库存,支付成功确认,超时释放。
设计:
inventory
├── sku_id
├── total_stock(总库存)
├── reserved_stock(预占库存)
├── sold_stock(已售库存)
└── available_stock = total_stock - reserved_stock - sold_stock
预占记录表:
reservation
├── reservation_id
├── sku_id
├── order_id
├── quantity
├── status(RESERVED/CONFIRMED/RELEASED)
├── expire_at(过期时间)
└── created_at
流程:
1. 进入结算页:
BEGIN TRANSACTION
UPDATE inventory
SET reserved_stock = reserved_stock + quantity
WHERE sku_id=? AND available_stock >= quantity;
INSERT INTO reservation (sku_id, order_id, quantity, expire_at)
VALUES (?, ?, ?, NOW() + INTERVAL 15 MINUTE);
COMMIT
2. 支付成功:
UPDATE inventory
SET reserved_stock = reserved_stock - quantity,
sold_stock = sold_stock + quantity
WHERE sku_id=?;
UPDATE reservation SET status='CONFIRMED' WHERE reservation_id=?;
3. 超时释放(定时任务):
SELECT * FROM reservation
WHERE status='RESERVED' AND expire_at < NOW();
For each expired:
UPDATE inventory
SET reserved_stock = reserved_stock - quantity;
UPDATE reservation SET status='RELEASED';
优点:
- 保证结算页库存
- 用户体验好
- 防止超卖
缺点:
- 预占时间内库存被占用
- 需要定时任务释放
方案三:分级预占
核心思想: 根据用户等级和商品类型,设置不同的预占时长。
设计:
预占时长策略:
VIP用户:30分钟
普通用户:15分钟
新用户:10分钟
热门商品:10分钟(快速流转)
普通商品:15分钟
冷门商品:30分钟(不占用热门商品库存)
动态调整:
if (available_stock < 10% * total_stock) {
// 库存紧张,缩短预占时间
expire_time = 5分钟
} else {
expire_time = 15分钟
}
优点:
- 差异化服务
- 库存利用率高
- 灵活调整
缺点:
- 规则复杂
- 实现成本高
方案对比:
| 方案 | 用户体验 | 库存利用率 | 超卖风险 | 实施难度 |
|---|---|---|---|---|
| 下单预占 | ★★★☆☆ | ★★★★★ | ★★★☆☆ | ★★★★★ |
| 结算预占 | ★★★★★ | ★★★★☆ | ★★★★★ | ★★★☆☆ |
| 分级预占 | ★★★★★ | ★★★★★ | ★★★★★ | ★★☆☆☆ |
推荐方案: 采用结算时预占。
实施要点:
-
预占时长设置:
考虑因素: - 支付流程耗时(通常2-3分钟) - 用户犹豫时间(5-10分钟) - 库存周转率(紧俏商品缩短) 建议: - 默认15分钟 - 库存<10%时缩短到5分钟 - VIP用户延长到30分钟 -
预占幂等性:
使用order_id作为幂等键: INSERT INTO reservation (reservation_id, order_id, ...) ON DUPLICATE KEY UPDATE updated_at=NOW(); 防止重复预占: - 同一订单多次预占,使用相同reservation记录 - 延长expire_at即可 -
超时释放优化:
方案A:定时任务扫描 - 每分钟扫描一次 - 查询expire_at < NOW() - 批量释放 方案B:延迟队列(推荐) - 预占时发送延迟消息(延迟15分钟) - 消息到期时检查状态 - 如果未支付,释放库存 优点:精确释放,无需轮询 -
库存保护:
最大预占比例: - 允许预占库存 <= total_stock * 90% - 保留10%库存应对预占释放后的瞬时需求 预占限流: - 单用户最多预占5个订单 - 单商品最多被预占total_stock * 80%
延伸思考:
- 用户在结算页停留很久不支付,如何处理?
- 预占释放后其他用户如何得知库存恢复?
- 如何设计库存预占的监控指标?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4264。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-022:如何设计库存的分级管理(前台可售vs仓库实际)?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-022 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4477 |
题干与约束
仓库实际库存100件,但前台可售库存只有80件(预留20件应对售后、损耗)。如何设计库存的分级管理?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 仓库实际库存100件,但前台可售库存只有80件(预留20件应对售后、损耗)。如何设计库存的分级管理?
答案:
问题分析: 库存分级的核心挑战:
- 不同层级库存含义不同
- 层级间库存同步
- 安全库存设置
- 库存占用追踪
方案一:单一库存(简化版)
核心思想: 只维护一个库存字段,不区分层级。
设计:
inventory
├── sku_id
├── stock(唯一库存字段)
└── reserved_stock(预占)
优点:
- 实现简单
- 无需同步
缺点:
- 无法预留安全库存
- 无法应对损耗
方案二:多级库存(推荐)
核心思想: 区分物理库存、可售库存、预占库存、已售库存。
设计:
inventory
├── sku_id
├── physical_stock(物理库存,仓库实际数量)
├── reserved_stock(预占库存,待支付订单)
├── sold_stock(已售库存,已支付待发货)
├── safety_stock(安全库存,预留)
├── available_stock(可售库存,计算得出)
= physical_stock - reserved_stock - sold_stock - safety_stock
└── version
库存关系:
physical_stock(100)
- safety_stock(10,安全库存)
- sold_stock(20,已售待发货)
- reserved_stock(15,预占待支付)
= available_stock(55,可售)
库存流转:
用户下单:
available_stock -10, reserved_stock +10
用户支付:
reserved_stock -10, sold_stock +10
商品发货:
sold_stock -10, physical_stock -10
订单取消:
reserved_stock -10, available_stock +10
售后退货:
physical_stock +10, available_stock +10
优点:
- 库存含义清晰
- 支持安全库存
- 易于追踪
缺点:
- 字段多,维护成本高
- 同步逻辑复杂
方案三:占用日志模式
核心思想: 只维护物理库存,所有占用记录在日志表。
设计:
inventory
├── sku_id
└── physical_stock
inventory_occupation(库存占用日志)
├── occupation_id
├── sku_id
├── occupation_type(RESERVED/SOLD/SAFETY)
├── quantity
├── reference_id(order_id/warehouse_id)
├── status(ACTIVE/RELEASED)
└── expire_at
可售库存计算:
available_stock = physical_stock - sum(active_occupations)
优点:
- 灵活,支持多种占用类型
- 可追溯所有占用历史
- 易于扩展
缺点:
- 查询需要聚合计算
- 性能较差
方案对比:
| 维度 | 单一库存 | 多级库存 | 占用日志 |
|---|---|---|---|
| 清晰度 | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
| 性能 | ★★★★★ | ★★★★☆ | ★★★☆☆ |
| 灵活性 | ★★☆☆☆ | ★★★☆☆ | ★★★★★ |
| 实施难度 | ★★★★★ | ★★★☆☆ | ★★☆☆☆ |
推荐方案: 采用多级库存。
实施要点:
-
安全库存设置:
策略: - 标准:safety_stock = 5% * physical_stock - 易损商品:safety_stock = 10% * physical_stock - 高价商品:safety_stock = 2% * physical_stock 动态调整: - 根据历史损耗率调整 - 旺季增加,淡季减少 -
库存同步检查:
不变量检查: physical_stock = available_stock + reserved_stock + sold_stock + safety_stock 定期对账: 如果不等式不成立,说明库存有问题 -
库存调整接口:
运营调整物理库存: adjustPhysicalStock(skuId, delta, reason) 自动调整安全库存: adjustSafetyStock(skuId, percentage) -
库存报表:
库存健康度: - 库存周转率 = 销量 / 平均库存 - 滞销率 = 30天未售商品数 / 总商品数 - 缺货率 = 用户下单失败次数 / 总下单次数
延伸思考:
- 如何设计库存盘点功能(盘点期间库存锁定)?
- 安全库存不足时如何处理?
- 已售库存发货后如何核减?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4477。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-023:库存扣减失败的补偿机制
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-023 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4660 |
题干与约束
在订单创建流程中,扣减库存可能失败(并发冲突、网络超时、服务故障)。如何设计补偿机制,保证数据一致性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 在订单创建流程中,扣减库存可能失败(并发冲突、网络超时、服务故障)。如何设计补偿机制,保证数据一致性?
答案:
问题分析: 库存扣减失败的核心场景:
- 网络超时:不知道是否扣减成功
- 服务故障:库存服务不可用
- 并发冲突:乐观锁更新失败
- 数据不一致:订单已创建但库存未扣减
方案一:同步重试
核心思想: 扣减失败时立即重试,最多重试3次。
实现:
public void deductInventory(String skuId, int quantity) {
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
try {
inventoryService.deduct(skuId, quantity);
return; // 成功
} catch (ConcurrentModificationException e) {
if (i == maxRetries - 1) {
throw e; // 最后一次重试失败,抛出异常
}
Thread.sleep(100 * (i + 1)); // 指数退避
}
}
}
优点:
- 实现简单
- 实时性好
缺点:
- 重试占用用户等待时间
- 多次重试可能仍失败
- 影响用户体验
方案二:异步补偿
核心思想: 扣减失败时订单标记为待处理,后台异步补偿。
流程:
1. 订单创建:
if (扣减库存失败) {
订单状态 = PENDING_INVENTORY
记录补偿任务
}
2. 补偿Worker:
定时扫描PENDING_INVENTORY订单
重试扣减库存
成功 → 更新订单状态CONFIRMED
失败 → 继续重试或人工介入
3. 补偿任务表:
compensation_task
├── task_id
├── order_id
├── task_type(DEDUCT_INVENTORY)
├── payload(JSON)
├── status(PENDING/SUCCESS/FAILED)
├── retry_count
└── next_retry_at
优点:
- 不阻塞用户
- 支持多次重试
- 可人工介入
缺点:
- 最终一致性
- 用户可能看到“处理中“状态
- 实现复杂
方案三:补偿+对账(推荐)
核心思想: 结合同步重试和异步补偿,再加对账兜底。
流程:
1. 扣减库存:
try {
inventoryService.deduct(skuId, quantity);
} catch (Exception e) {
// 同步重试1次
retry once
if (still failed) {
// 记录补偿任务
compensationService.record(orderId, "DEDUCT_INVENTORY");
}
}
2. 补偿Worker(每分钟):
查询补偿任务
重试执行
成功 → 标记完成
失败 → retry_count +1
3. 对账任务(每小时):
查询已支付订单
检查库存是否已扣减
未扣减 → 创建补偿任务
4. 人工兜底:
- retry_count > 5次仍失败
- 转人工处理
- 排查根本原因
优点:
- 多层保障
- 可靠性高
- 覆盖各种异常
缺点:
- 实现最复杂
方案对比:
| 方案 | 实时性 | 可靠性 | 用户体验 | 实施难度 |
|---|---|---|---|---|
| 同步重试 | ★★★★★ | ★★★☆☆ | ★★★☆☆ | ★★★★★ |
| 异步补偿 | ★★★☆☆ | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
| 补偿+对账 | ★★★☆☆ | ★★★★★ | ★★★★☆ | ★★☆☆☆ |
推荐方案: 采用补偿+对账。
实施要点:
-
幂等性保证:
public void deductInventory(DeductRequest req) { // 使用orderId作为幂等键 if (isAlreadyDeducted(req.getOrderId())) { return; // 已扣减,直接返回 } // 执行扣减 doDeduct(req); // 记录已扣减 markDeducted(req.getOrderId()); } -
补偿任务重试策略:
指数退避: 第1次:立即重试 第2次:1分钟后 第3次:5分钟后 第4次:15分钟后 第5次:1小时后 超过5次 → 转人工 -
补偿任务优先级:
P0:已支付订单(优先处理) P1:待支付订单 P2:其他 -
对账规则:
检查项: 1. 订单状态=PAID → 库存必须已扣减 2. 订单金额 = 商品价格 × 数量 3. 库存不能为负数 差异处理: - 自动补偿(低风险) - 人工介入(高风险)
延伸思考:
- 如果补偿重试多次仍失败,如何处理?
- 补偿过程中订单状态如何展示给用户?
- 如何监控补偿任务的执行情况?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4660。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-024:设计库存盘点系统
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-024 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4857 |
题干与约束
仓库需要定期盘点库存,核对系统库存和实际库存是否一致。如何设计库存盘点系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 仓库需要定期盘点库存,核对系统库存和实际库存是否一致。如何设计库存盘点系统?
答案:
问题分析: 库存盘点的核心挑战:
- 盘点期间如何处理库存变更
- 盘点差异如何调整
- 大规模商品盘点效率
- 盘点结果审核
方案一:冻结盘点
核心思想: 盘点期间冻结库存,禁止出入库。
流程:
1. 创建盘点任务:
- 选择仓库
- 选择商品范围(全部/部分)
- 冻结库存(禁止扣减和补货)
2. 仓库人员盘点:
- 扫描商品条码
- 录入实际数量
3. 生成盘点报告:
- 系统库存 vs 实际库存
- 差异清单
4. 审核调整:
- 审核员确认差异
- 调整系统库存
- 解冻库存
优点:
- 准确性高
- 实现简单
缺点:
- 盘点期间影响业务
- 效率低
- 用户体验差
方案二:动态盘点(推荐)
核心思想: 盘点期间不冻结,记录盘点时间段的出入库,最后计算差异。
流程:
1. 开始盘点:
记录盘点开始时间T1
快照当前系统库存S1
2. 盘点期间:
正常出入库
记录所有库存变更日志
3. 结束盘点:
记录盘点结束时间T2
记录实际库存数量P
4. 计算差异:
期间出库:delta_out = sum(T1到T2的出库)
期间入库:delta_in = sum(T1到T2的入库)
理论库存:S2 = S1 - delta_out + delta_in
实际库存:P
差异:diff = P - S2
5. 调整库存:
if (diff != 0) {
inventory.physical_stock += diff
记录盘点调整日志
}
优点:
- 不影响业务
- 准确性高
- 可并行盘点
缺点:
- 计算复杂
- 需要完整的出入库日志
方案三:循环盘点
核心思想: 不是一次性盘点所有商品,而是每天盘点一部分。
流程:
将商品分为ABC类:
A类(高价值,20%):每月盘点
B类(中价值,30%):每季度盘点
C类(低价值,50%):每年盘点
每日盘点:
1. 系统自动生成今日盘点任务
2. 仓库人员按任务盘点
3. 异常差异及时调整
4. 正常差异汇总报告
优点:
- 分散盘点,效率高
- 重点商品关注度高
- 不影响业务
缺点:
- 需要分类管理
- 全盘点周期长
方案对比:
| 方案 | 对业务影响 | 准确性 | 效率 | 适用场景 |
|---|---|---|---|---|
| 冻结盘点 | ★★☆☆☆ | ★★★★★ | ★★☆☆☆ | 小仓库 |
| 动态盘点 | ★★★★★ | ★★★★★ | ★★★★☆ | 大仓库 |
| 循环盘点 | ★★★★★ | ★★★★☆ | ★★★★★ | 商品多 |
推荐方案: 采用动态盘点+循环盘点的组合。
实施要点:
-
盘点任务生成:
创建盘点单: inventory_check ├── check_id ├── warehouse_id ├── check_type(FULL/PARTIAL/CYCLE) ├── status(PENDING/CHECKING/COMPLETED) ├── start_snapshot_id(开始时库存快照) ├── start_at ├── end_at └── operator 盘点明细: check_detail ├── check_id ├── sku_id ├── system_stock(系统库存) ├── actual_stock(实际库存) ├── diff(差异) ├── reason(差异原因) └── adjusted(是否已调整) -
盘点APP设计:
功能: - 扫码盘点(扫条码自动录入) - 语音录入(解放双手) - 拍照记录(有问题的商品拍照) - 离线模式(网络不好时) 优化: - 按货架号排序(减少走动) - 实时同步(避免数据丢失) -
差异分析:
差异原因分类: - 损耗(DAMAGE):商品破损 - 丢失(LOSS):商品丢失 - 错发(WRONG_SHIP):发错货 - 漏记(MISSING_RECORD):出入库漏记 - 系统bug(SYSTEM_ERROR) 自动调整规则: - diff < 5% → 自动调整 - diff >= 5% → 需要审核 - diff > 20% → 必须复盘(可能系统bug) -
盘点报告:
报告内容: - 盘点汇总:总商品数、差异数、差异金额 - 差异TOP 10:差异最大的商品 - 差异原因分布:损耗X件、丢失Y件 - 仓库对比:各仓库差异率
延伸思考:
- 如何设计盘点的权限控制(防止作弊)?
- 盘点差异过大时如何追责?
- 如何设计移动盘点的离线模式?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4857。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-025:如何处理库存的并发更新?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-025 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5058 |
题干与约束
多个订单同时扣减同一商品库存,如何处理并发冲突,保证库存不超卖?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 多个订单同时扣减同一商品库存,如何处理并发冲突,保证库存不超卖?
答案:
问题分析: 并发更新的核心场景:
- 秒杀场景:1万人抢100件商品
- 正常场景:多个用户同时下单
- 分布式场景:多个服务器同时扣减
方案一:数据库行锁(FOR UPDATE)
实现:
BEGIN TRANSACTION;
-- 锁定行
SELECT stock FROM inventory
WHERE sku_id='123' FOR UPDATE;
-- 检查库存
if (stock >= quantity) {
UPDATE inventory SET stock = stock - quantity;
COMMIT;
} else {
ROLLBACK;
}
优点:
- 强一致性
- 不会超卖
缺点:
- 锁冲突,性能差
- 并发度低
- 长事务风险
吞吐量:约1000 TPS
方案二:乐观锁(CAS)
实现:
-- 查询当前库存
SELECT stock, version FROM inventory WHERE sku_id='123';
-- 尝试更新(CAS)
affected = UPDATE inventory
SET stock = stock - quantity, version = version + 1
WHERE sku_id='123'
AND version = oldVersion
AND stock >= quantity;
if (affected == 0) {
// 更新失败,重试
retry with exponential backoff
}
优点:
- 无锁,性能好
- 并发度高
缺点:
- 高并发时重试多,成功率低
- 可能饿死(一直重试失败)
吞吐量:约5000-10000 TPS
方案三:Redis+Lua脚本(推荐)
实现:
-- Lua脚本(Redis原子执行)
local stock_key = KEYS[1]
local quantity = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', stock_key) or "0")
if stock >= quantity then
redis.call('DECRBY', stock_key, quantity)
return 1
else
return 0
end
调用:
String key = "inventory:sku:123";
Long result = redis.eval(luaScript,
Arrays.asList(key),
Arrays.asList(String.valueOf(quantity)));
if (result == 1) {
// 扣减成功
createOrder();
// 异步同步到MySQL
asyncSyncToMySQL(skuId, -quantity);
} else {
throw new OutOfStockException();
}
优点:
- 性能极高(内存操作)
- 原子性(Lua脚本)
- 支持极高并发
缺点:
- Redis和MySQL最终一致
- Redis故障风险
- 需要对账机制
吞吐量:约10万+ TPS
方案对比:
| 方案 | TPS | 超卖风险 | 一致性 | 复杂度 |
|---|---|---|---|---|
| 行锁 | 1K | 无 | 强一致 | ★★★★☆ |
| 乐观锁 | 5-10K | 无 | 强一致 | ★★★☆☆ |
| Redis+Lua | 100K+ | 无 | 最终一致 | ★★★☆☆ |
推荐方案: 根据场景选择:
- 普通商品:乐观锁(MySQL)
- 秒杀商品:Redis+Lua
- 低并发:悲观锁
实施要点:
-
Redis高可用:
- Redis主从+哨兵 - 双机房部署 - 持久化:AOF every second -
库存同步:
Redis → MySQL: - 定时任务(每10秒) - 批量更新(减少DB压力) - 对账纠偏(每小时) -
降级方案:
Redis故障 → 降级到MySQL乐观锁 MySQL故障 → 停止扣减,返回系统繁忙 -
监控:
- Redis和MySQL库存差异 - 扣减成功率 - 扣减耗时P99 - 并发冲突次数
延伸思考:
- 如何设计秒杀的库存扣减(更极端的高并发)?
- 分库分表场景下如何扣减库存?
- Redis和MySQL数据不一致如何恢复?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5058。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-026:虚拟库存vs实物库存的差异
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-026 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5230 |
题干与约束
实物商品有物理库存限制,虚拟商品(如充值卡、游戏币)可以无限生成。两者在库存设计上有什么差异?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 实物商品有物理库存限制,虚拟商品(如充值卡、游戏币)可以无限生成。两者在库存设计上有什么差异?
答案:
问题分析: 虚拟库存的核心特点:
- 可按需生成(理论无限)
- 实际受限于供应商配额
- 卡密池管理(有卡密才能售卖)
- 即时发货(无需物流)
方案一:无限库存模式
核心思想: 虚拟商品库存设为无限大,不限制购买。
设计:
product
├── product_id
├── product_type(PHYSICAL/VIRTUAL)
└── unlimited_stock(布尔,是否无限库存)
扣减逻辑:
if (product.unlimitedStock) {
// 虚拟商品,不扣减库存
return true;
} else {
// 实物商品,正常扣减
return deductStock(skuId, quantity);
}
优点:
- 实现最简单
- 用户体验好(永不缺货)
缺点:
- 不适合卡密类商品(卡密有限)
- 无法控制销售节奏
- 可能超过供应商配额
适用场景:
- 可按需生成的虚拟商品(游戏币、积分)
方案二:卡密 / 券码池模式(推荐)
核心思想: 维护卡密 / 券码池,库存=可用卡密或券码数量。
设计:
virtual_product
├── product_id
├── supplier_id(供应商)
├── card_type(充值卡类型)
└── face_value(面值)
card_pool(卡密 / 券码池)
├── card_id
├── product_id
├── card_no(卡号)
├── card_pwd(密码,加密存储)
├── status(AVAILABLE/BOOKING/SOLD/LOCKED/EXPIRED/INVALID)
├── booked_at
├── reservation_id / order_id
├── sold_at
└── sold_order_id
库存计算:
available_stock = COUNT(*) WHERE status='AVAILABLE'
reserved_stock = COUNT(*) WHERE status='BOOKING'
生产级设计里,不建议只用一张简单 card_pool 表,更推荐把库存域的券码池收敛成 inventory_code_pool_XX 分表:
inventory_code_pool_XX
├── code_id(全局唯一,Redis LIST 只缓存这个 ID)
├── batch_id / inventory_key / sku_id(批次、库存项、SKU)
├── code_cipher(加密后的券码或卡密)
├── code_hash(去重和排查,不保存明文)
├── status(AVAILABLE/BOOKING/SOLD/LOCKED/EXPIRED/INVALID)
├── reservation_id / order_id / user_id
├── booked_at / sold_at / expire_at
└── version(CAS 与幂等控制)
面试时要特别强调:Redis LIST 不是权威库存,只是 code_id 热队列。下单时可以先从 Redis 弹出 code_id,但必须再执行 MySQL CAS:
UPDATE inventory_code_pool_XX
SET status='BOOKING',
reservation_id=?,
order_id=?,
booked_at=NOW(),
version=version+1
WHERE code_id=? AND status='AVAILABLE';
只有这条更新成功,才算真正锁码成功。支付成功后 BOOKING -> SOLD;订单取消或超时后 BOOKING -> AVAILABLE,再通过 Outbox 或补偿任务把 code_id 回填到 Redis。已经交付或核销链路可见的 SOLD 码,不应直接回到可售池,退款要走售后和履约规则。
这个设计的价值是:
- 防止 Redis 丢数据导致无法追溯;
- 避免 LIST 存明文券码造成泄漏;
- 用状态机防止重复发码和并发超卖;
- Redis 故障后可以从 MySQL
AVAILABLE状态重建热队列; - 对账时能按订单、批次、供应商和码状态逐行追踪。
库存流转:
用户下单:
1. SELECT * FROM card_pool
WHERE product_id=? AND status='AVAILABLE'
LIMIT 1 FOR UPDATE;
2. UPDATE card_pool
SET status='BOOKING', booked_at=NOW(), order_id=?
WHERE card_id=?;
用户支付:
UPDATE card_pool
SET status='SOLD', sold_at=NOW(), sold_order_id=?
WHERE card_id=? AND status='BOOKING';
订单取消:
UPDATE card_pool
SET status='AVAILABLE', order_id=NULL
WHERE card_id=? AND status='BOOKING';
优点:
- 库存真实(有卡密才能售)
- 支持卡密管理
- 防止超卖
缺点:
- 需要维护卡密池
- 卡密补货
方案三:配额模式
核心思想: 供应商给定配额,按配额售卖。
设计:
supplier_quota(供应商配额)
├── supplier_id
├── product_id
├── total_quota(总配额)
├── used_quota(已使用)
├── remaining_quota(剩余)
└── validity_period(有效期)
扣减逻辑:
1. 检查剩余配额
2. 扣减配额
3. 订单成功后,向供应商申请实际卡密
4. 发货给用户
优点:
- 无需提前准备卡密
- 按需申请
- 库存灵活
缺点:
- 实时性依赖供应商
- 供应商故障风险
方案对比:
| 方案 | 准确性 | 供应商依赖 | 实施难度 | 适用场景 |
|---|---|---|---|---|
| 无限库存 | ★★☆☆☆ | ★★★★★ | ★★★★★ | 可生成虚拟品 |
| 卡密池 | ★★★★★ | ★★★☆☆ | ★★★☆☆ | 充值卡、券码 |
| 配额模式 | ★★★★☆ | ★★☆☆☆ | ★★★☆☆ | 供应商直连 |
推荐方案: 根据虚拟商品类型选择:
- 可生成(游戏币、积分):无限库存
- 卡密类(充值卡、激活码):卡密池
- 供应商直连(机票、酒店):配额模式
实施要点:
-
卡密安全:
- 卡密加密存储(AES-256) - 卡密传输加密(HTTPS) - 卡密脱敏展示(**** **** **** 1234) - 限制查询频率(防止批量获取) -
卡密补货:
补货触发: - 可用卡密 < 安全阈值(如1000张) - 自动告警 补货方式: - 供应商API自动拉取 - 或人工Excel导入 -
卡密有效期:
过期处理: - 定时任务扫描过期卡密 - 状态更新为INVALID - 库存减少(不可售) - 向供应商申请补卡 -
虚拟发货:
自动发货: - 支付成功 → 立即分配卡密 - 推送给用户(短信/App) - 订单状态 → COMPLETED 发货耗时:< 30秒
延伸思考:
- 卡密被盗用如何防范?
- 虚拟商品是否需要支持退款?
- 供应商配额不足时如何处理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5230。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-027:多仓库场景下的库存分配策略
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-027 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5465 |
题干与约束
电商平台有5个仓库(华北、华东、华南、西南、西北),用户下单时如何选择仓库发货?请设计库存分配策略。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台有5个仓库(华北、华东、华南、西南、西北),用户下单时如何选择仓库发货?请设计库存分配策略。
答案:
问题分析: 仓库选择的核心考量:
- 配送时效:就近仓库配送快
- 运费成本:距离影响运费
- 库存充足度:优先选择库存多的仓库
- 仓库负载:避免单仓库压力过大
方案一:就近原则
核心思想: 根据用户地址,选择最近的仓库。
设计:
仓库覆盖范围:
- 北京仓:北京、天津、河北
- 上海仓:上海、江苏、浙江
- 深圳仓:广东、广西、福建
- 成都仓:四川、重庆、云南
- 西安仓:陕西、甘肃、新疆
路由逻辑:
1. 解析用户收货地址的省份
2. 查找覆盖该省份的仓库
3. 检查库存
4. 有货 → 该仓库发货
5. 无货 → 选择次近仓库
优点:
- 配送快
- 用户体验好
- 运费低
缺点:
- 库存可能不均衡
- 跨区发货增加成本
方案二:智能调度(推荐)
核心思想: 综合考虑配送时效、库存、成本,动态选择最优仓库。
设计:
评分模型:
score = w1 * distance_score +
w2 * stock_score +
w3 * cost_score +
w4 * load_score
各项得分计算:
1. distance_score(距离):
= 1.0 / (distance_km + 100)
距离越近分越高
2. stock_score(库存):
= warehouse_stock / max_stock
库存越多分越高
3. cost_score(成本):
= 1.0 / shipping_cost
运费越低分越高
4. load_score(负载):
= 1.0 - (current_orders / capacity)
当前订单越少分越高
权重设置:
- 普通商品:w1=0.5, w2=0.3, w3=0.1, w4=0.1
- 秒杀商品:w1=0.3, w2=0.5, w3=0.1, w4=0.1(库存优先)
- 大件商品:w1=0.4, w2=0.2, w3=0.3, w4=0.1(成本优先)
优点:
- 全局最优
- 灵活可配置
- 支持多种策略
缺点:
- 计算复杂
- 需要实时数据(各仓库负载)
方案三:库存均衡策略
核心思想: 主动调配库存,保持各仓库库存均衡。
设计:
库存均衡算法:
1. 计算各仓库库存偏离度
deviation = (warehouse_stock - avg_stock) / avg_stock
2. 如果偏离度 > 30%,触发调拨
从库存多的仓库调拨到库存少的仓库
3. 调拨优先级:
- 距离近优先
- 库存差距大优先
调拨执行:
1. 创建调拨单
2. 源仓库出库
3. 物流运输
4. 目标仓库入库
优点:
- 库存均衡,利用率高
- 减少缺货
- 优化全局
缺点:
- 调拨成本高
- 调拨周期长(天级)
- 需要预测算法
方案对比:
| 方案 | 配送时效 | 成本 | 库存利用率 | 复杂度 |
|---|---|---|---|---|
| 就近原则 | ★★★★★ | ★★★★☆ | ★★★☆☆ | ★★★★★ |
| 智能调度 | ★★★★☆ | ★★★★★ | ★★★★☆ | ★★★☆☆ |
| 均衡策略 | ★★★☆☆ | ★★★☆☆ | ★★★★★ | ★★☆☆☆ |
推荐方案: 采用智能调度。
实施要点:
-
仓库路由服务:
public interface WarehouseRouter { // 选择单个仓库 Warehouse route(Order order); // 多商品拆单(可能分多仓库发货) Map<Warehouse, List<OrderItem>> routeMulti(Order order); } 实现: public Warehouse route(Order order) { List<Warehouse> candidates = getCandidateWarehouses(order); return candidates.stream() .filter(w -> hasStock(w, order)) .map(w -> new ScoredWarehouse(w, calculateScore(w, order))) .max(Comparator.comparing(ScoredWarehouse::getScore)) .map(ScoredWarehouse::getWarehouse) .orElseThrow(OutOfStockException::new); } -
拆单策略:
场景:用户购买商品A、B、C - 商品A:北京仓有货 - 商品B:上海仓有货 - 商品C:两个仓库都有货 策略1:优先合单 - 查找能满足所有商品的仓库 - 减少拆单,降低运费 策略2:就近发货 - 每个商品从最近仓库发货 - 可能拆多单,但配送快 策略3:混合 - 大件商品就近发货 - 小件商品合单发货 -
库存预测:
预测模型: - 输入:历史销量、季节、促销活动 - 输出:未来7天各仓库销量预测 预分配: - 根据预测提前调拨库存 - 避免大促时调拨来不及 -
负载均衡:
仓库容量管理: - 每个仓库设置日处理能力(如1万单/天) - 接近容量时降低选择权重 - 超过容量时停止分配 动态调整: - 实时监控各仓库订单量 - 动态调整路由权重
延伸思考:
- 如何处理跨仓拆单的运费计算?
- 用户能否指定发货仓库?
- 仓库之间如何协同(库存调拨、应急支援)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5465。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-028:如何设计库存安全水位和补货机制?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-028 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5676 |
题干与约束
电商系统需要设置库存安全水位,当库存低于安全水位时自动触发补货。如何设计这套机制?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商系统需要设置库存安全水位,当库存低于安全水位时自动触发补货。如何设计这套机制?
答案:
问题分析: 库存安全水位的核心要素:
- 安全水位如何设置(太高占用资金,太低容易缺货)
- 补货时机和数量
- 补货周期(供应商交付时间)
- 多SKU的补货优先级
方案一:固定安全水位
核心思想: 为每个SKU设置固定的安全库存数量。
设计:
inventory
├── sku_id
├── stock
├── safety_stock(安全库存,人工设置)
└── reorder_point(补货点 = safety_stock + lead_time_demand)
补货触发:
if (stock <= reorder_point) {
创建补货单
补货数量 = (max_stock - current_stock)
}
优点:
- 实现简单
- 易于理解
缺点:
- 不够灵活
- 无法应对销量波动
- 需要人工调整
方案二:动态安全水位(推荐)
核心思想: 根据销量预测动态调整安全水位。
设计:
销量预测:
avg_daily_sales = sum(last_30_days_sales) / 30
前置时间:
lead_time = 供应商交付周期(如7天)
安全库存:
safety_stock = avg_daily_sales * lead_time * safety_factor
其中:
- safety_factor = 1.5(安全系数,应对波动)
- 旺季调高到2.0
- 淡季调低到1.2
补货点:
reorder_point = safety_stock + lead_time * avg_daily_sales
补货数量(EOQ经济订货批量):
order_quantity = sqrt((2 * annual_demand * order_cost) / holding_cost)
优点:
- 动态调整
- 科学合理
- 节省成本
缺点:
- 依赖销量预测准确性
- 计算复杂
方案三:ABC分类管理
核心思想: 将商品分为ABC类,采用不同的补货策略。
分类标准:
A类商品(20%商品,80%销售额):
- 高价值,严格管理
- 低安全库存(减少资金占用)
- 频繁补货(每周)
- 精准预测
B类商品(30%商品,15%销售额):
- 中等价值,常规管理
- 中等安全库存
- 定期补货(每月)
- 简单预测
C类商品(50%商品,5%销售额):
- 低价值,粗放管理
- 高安全库存(减少缺货)
- 批量补货(每季度)
- 不预测
优点:
- 差异化管理
- 资源聚焦
- 效率高
缺点:
- 需要定期重分类
- ABC边界商品难处理
方案对比:
| 方案 | 准确性 | 资金占用 | 维护成本 | 适用规模 |
|---|---|---|---|---|
| 固定水位 | ★★★☆☆ | ★★☆☆☆ | ★★★★★ | 小规模 |
| 动态水位 | ★★★★★ | ★★★★☆ | ★★★☆☆ | 大规模 |
| ABC管理 | ★★★★☆ | ★★★★★ | ★★☆☆☆ | 超大规模 |
推荐方案: 采用动态水位+ABC分类。
实施要点:
-
销量预测模型:
简单移动平均: avg_sales = sum(last_N_days) / N 加权移动平均: avg_sales = sum(sales[i] * weight[i]) 权重:最近的销量权重更高 指数平滑: forecast[t] = α * actual[t-1] + (1-α) * forecast[t-1] α = 0.3(平滑系数) 时间序列模型(高级): - ARIMA - Prophet(Facebook开源) - 考虑季节性、趋势、促销影响 -
补货决策表:
replenishment_rule ├── sku_id ├── category(ABC分类) ├── safety_stock ├── reorder_point ├── lead_time(补货周期) ├── order_quantity(建议补货量) ├── max_stock(最大库存) └── updated_at -
自动补货流程:
定时任务(每天凌晨): 1. 扫描所有SKU库存 2. 识别低于补货点的SKU 3. 生成补货建议单 4. 采购员审核 5. 自动下单给供应商(或人工) 补货单: purchase_order ├── po_id ├── supplier_id ├── sku_id ├── quantity ├── expected_delivery_date ├── status(PENDING/CONFIRMED/SHIPPED/RECEIVED) └── created_at -
补货优先级:
优先级计算: priority = w1 * shortage_ratio + w2 * sales_velocity + w3 * profit_margin shortage_ratio = (reorder_point - current_stock) / reorder_point sales_velocity = daily_sales profit_margin = (price - cost) / price 优先补货: - 严重缺货(shortage_ratio > 0.5) - 高销量 - 高利润 -
监控告警:
告警条件: - 库存 < 安全库存 → 缺货预警 - 库存 > 最大库存 * 1.5 → 积压告警 - 补货单超期未到货 → 交付延迟告警 报表: - 缺货率(SKU缺货天数 / 总天数) - 库存周转率(销量 / 平均库存) - 补货及时率(按时到货 / 总补货单)
延伸思考:
- 促销活动前如何调整补货策略?
- 供应商交付不稳定如何应对?
- 新品如何设置安全库存(无历史数据)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5676。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-029:库存快照在订单中的应用
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-029 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5893 |
题干与约束
订单下单时需要记录当时的库存状态,用于售后和数据分析。如何设计库存快照机制?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单下单时需要记录当时的库存状态,用于售后和数据分析。如何设计库存快照机制?
答案:
问题分析: 库存快照的核心目的:
- 售后分析(为何超卖、缺货)
- 数据审计(库存变更追溯)
- 报表统计(某时刻库存状态)
- 性能要求(不能影响下单)
方案一:订单表冗余库存字段
核心思想: 在订单表记录下单时的库存数量。
设计:
order_item
├── order_id
├── sku_id
├── quantity(购买数量)
├── stock_at_order(下单时库存,快照)
└── ...
优点:
- 实现最简单
- 查询方便
缺点:
- 快照信息有限
- 无法追溯详细变更
适用场景:
- 简单记录,不需要详细分析
方案二:库存变更日志
核心思想: 记录所有库存变更,按需查询历史状态。
设计:
inventory_change_log
├── log_id
├── sku_id
├── change_type(ORDER/CANCEL/REPLENISH/ADJUST)
├── quantity_delta(变更量,±)
├── stock_before(变更前库存)
├── stock_after(变更后库存)
├── reference_id(关联ID:order_id/po_id)
├── operator
└── created_at
查询某时刻库存:
1. 获取当前库存
2. 反向应用change_log(created_at > target_time)
3. 得到目标时刻库存
优点:
- 完整追溯
- 支持任意时刻查询
- 审计能力强
缺点:
- 查询需要计算
- 存储成本高
方案三:定期快照+增量日志(推荐)
核心思想: 定期保存全量快照,中间记录增量日志。
设计:
inventory_snapshot(快照,每小时)
├── snapshot_id
├── sku_id
├── stock
├── reserved_stock
├── snapshot_time
└── created_at
inventory_change_log(增量日志)
├── log_id
├── sku_id
├── change_type
├── quantity_delta
├── stock_after
├── reference_id
└── created_at
查询某时刻库存:
1. 找到目标时刻之前最近的快照
2. 应用快照之后的增量日志
3. 得到目标时刻库存
示例:
查询2024-04-18 15:30的库存
→ 找到15:00的快照(stock=100)
→ 应用15:00-15:30的日志(-5, -3, -2)
→ 结果:100 - 5 - 3 - 2 = 90
优点:
- 平衡性能和存储
- 快照恢复快
- 审计能力强
缺点:
- 实现复杂度中等
方案对比:
| 方案 | 查询性能 | 存储成本 | 审计能力 | 实施难度 |
|---|---|---|---|---|
| 冗余字段 | ★★★★★ | ★★★★★ | ★★☆☆☆ | ★★★★★ |
| 变更日志 | ★★★☆☆ | ★★☆☆☆ | ★★★★★ | ★★★☆☆ |
| 快照+日志 | ★★★★☆ | ★★★★☆ | ★★★★★ | ★★★☆☆ |
推荐方案: 采用定期快照+增量日志。
实施要点:
-
快照生成策略:
定时快照: - 每小时生成一次快照 - 或库存变更超过1000次时生成 快照内容: - SKU ID - 物理库存 - 预占库存 - 已售库存 - 可售库存 - 快照时间 -
变更日志记录:
@Aspect public class InventoryChangeLogger { @Around("execution(* InventoryService.deduct*(..))") public Object logChange(ProceedingJoinPoint pjp) { // 记录变更前库存 int stockBefore = getStock(skuId); // 执行扣减 Object result = pjp.proceed(); // 记录变更后库存 int stockAfter = getStock(skuId); // 保存日志 InventoryChangeLog log = new InventoryChangeLog(); log.setSkuId(skuId); log.setChangeType("ORDER"); log.setQuantityDelta(stockBefore - stockAfter); log.setStockBefore(stockBefore); log.setStockAfter(stockAfter); log.setReferenceId(orderId); logRepository.save(log); return result; } } -
历史库存查询API:
GET /api/inventory/{skuId}/history?time=2024-04-18T15:30:00 响应: { "skuId": "123", "stock": 90, "reserved": 10, "available": 80, "snapshotTime": "2024-04-18T15:30:00" } -
数据归档:
归档策略: - 变更日志保留90天 - 90天后归档到对象存储(OSS) - 快照保留1年 - 1年后删除(保留年度快照) -
应用场景:
场景1:售后分析 用户投诉超卖 → 查询下单时库存 → 分析扣减日志 → 定位问题 场景2:数据对账 每日对账:今日库存 = 昨日库存 + 今日入库 - 今日出库 不一致 → 查询变更日志 → 找出差异 场景3:报表统计 生成"每日库存报表" → 查询每日0点快照 → 生成报表
延伸思考:
- 如何设计库存变更的审计流程?
- 变更日志如何支持回滚操作?
- 大批量商品的快照如何优化存储?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5893。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-030:库存的实时性vs一致性权衡
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-030 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6111 |
题干与约束
库存系统中,Redis提供高性能但可能丢失数据,MySQL提供强一致但性能较低。如何在实时性和一致性之间权衡?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 库存系统中,Redis提供高性能但可能丢失数据,MySQL提供强一致但性能较低。如何在实时性和一致性之间权衡?
答案:
问题分析: 实时性vs一致性的核心矛盾:
- 用户期望实时看到库存
- 系统要保证不超卖
- 高并发下性能压力大
- 数据一致性难保证
方案一:强一致性优先(MySQL为准)
核心思想: 所有库存操作直接读写MySQL,放弃Redis。
设计:
-- 使用悲观锁
BEGIN;
SELECT stock FROM inventory WHERE sku_id=? FOR UPDATE;
UPDATE inventory SET stock = stock - ? WHERE sku_id=?;
COMMIT;
CAP理论选择:
- C(一致性):强一致性
- A(可用性):可用性一般(锁冲突)
- P(分区容错):单机MySQL,不支持分区
优点:
- 绝对一致性
- 不会超卖
- 不会丢数据
缺点:
- 性能差(1000-5000 TPS)
- 无法支持秒杀
- 并发度低
适用场景:
- 库存量少的高价商品(奢侈品)
- 对一致性要求极高的场景
方案二:最终一致性(Redis为主)
核心思想: 库存扣减在Redis,异步同步到MySQL。
设计:
扣减流程:
1. Redis DECR扣减
2. 扣减成功,创建订单
3. 异步同步到MySQL
同步策略:
- 定时任务(每10秒)批量同步
- 或消息队列异步同步
数据恢复:
- Redis故障 → 从MySQL加载
- 对账任务(每小时)纠正差异
CAP理论选择:
- C(一致性):最终一致性
- A(可用性):高可用
- P(分区容错):支持分区
优点:
- 性能极高(10万+ TPS)
- 支持高并发
- 用户体验好
缺点:
- Redis和MySQL可能不一致
- Redis故障可能丢数据
- 需要对账机制
适用场景:
- 秒杀场景
- 高并发场景
- 普通商品
方案三:分层一致性(推荐)
核心思想: 根据商品类型和场景,采用不同一致性策略。
设计:
商品分类:
1. 高价商品(>10000元):
- 使用MySQL悲观锁
- 强一致性
- 不追求性能
2. 秒杀商品:
- 使用Redis+Lua
- 最终一致性
- 极致性能
3. 普通商品:
- 使用MySQL乐观锁
- 强一致性
- 中等性能
扣减逻辑:
if (product.type == HIGH_VALUE) {
return deductWithPessimisticLock();
} else if (product.type == SECKILL) {
return deductWithRedis();
} else {
return deductWithOptimisticLock();
}
优点:
- 灵活权衡
- 性能和一致性兼顾
- 差异化服务
缺点:
- 实现复杂
- 需要商品分类
方案对比:
| 方案 | 一致性 | 性能 | 实现难度 | 适用场景 |
|---|---|---|---|---|
| 强一致 | ★★★★★ | ★★☆☆☆ | ★★★★☆ | 高价商品 |
| 最终一致 | ★★★☆☆ | ★★★★★ | ★★★☆☆ | 秒杀 |
| 分层一致 | ★★★★☆ | ★★★★☆ | ★★☆☆☆ | 综合场景 |
推荐方案: 采用分层一致性。
实施要点:
-
一致性级别定义:
强一致(Strong Consistency): - MySQL事务 - 悲观锁或串行化 - 实时一致 最终一致(Eventual Consistency): - Redis扣减 + 异步同步 - 秒级延迟 - 需要对账 因果一致(Causal Consistency): - 同一用户操作有序 - 不同用户可能看到不同状态 -
降级策略:
正常模式: - 秒杀商品:Redis(最终一致) - 普通商品:MySQL乐观锁(强一致) 降级模式(Redis故障): - 秒杀商品:暂停售卖或限流到MySQL - 普通商品:MySQL悲观锁 极端模式(MySQL故障): - 只读Redis,禁止扣减 - 提示用户稍后再试 -
一致性检查:
实时检查: - 扣减后检查Redis和MySQL差异 - 差异 > 阈值(如100)→ 告警 定期对账: - 每小时全量对账 - 自动纠正小差异(< 5) - 大差异(> 10)→ 人工介入 -
监控指标:
一致性指标: - Redis-MySQL差异数量 - 差异持续时间 - 对账修复次数 性能指标: - 扣减TPS - 扣减耗时P99 - Redis命中率
延伸思考:
- 如何设计Redis的持久化策略(AOF/RDB)?
- 分布式场景下如何保证Redis和MySQL一致性?
- CAP理论在库存系统中如何权衡?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6111。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-031:库存回滚机制的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-031 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6318 |
题干与约束
用户下单后未支付,或者订单取消,需要回滚库存。如何设计库存回滚机制,保证幂等性和正确性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户下单后未支付,或者订单取消,需要回滚库存。如何设计库存回滚机制,保证幂等性和正确性?
答案:
问题分析: 库存回滚的核心场景:
- 订单取消(用户主动取消)
- 超时未支付(30分钟自动取消)
- 支付失败(扣款失败)
- 售后退货(订单完成后退货)
核心挑战:
- 幂等性:重复回滚不能多加库存
- 并发安全:多个回滚请求同时执行
- 部分回滚:一单多商品部分退货
- 补偿机制:回滚失败如何处理
方案一:直接加库存
核心思想: 取消订单时直接增加库存。
实现:
-- 订单取消
UPDATE inventory
SET stock = stock + quantity
WHERE sku_id = ?;
-- 更新订单状态
UPDATE orders
SET status = 'CANCELLED'
WHERE order_id = ?;
优点:
- 实现简单
缺点:
- 无法保证幂等性(重复调用会多加库存)
- 并发不安全
方案二:基于订单状态回滚
核心思想: 检查订单状态,只有首次取消才回滚库存。
实现:
-- 原子更新订单状态
UPDATE orders
SET status = 'CANCELLED'
WHERE order_id = ? AND status = 'PENDING';
if (affected_rows == 1) {
// 状态更新成功,说明是首次取消
UPDATE inventory
SET reserved_stock = reserved_stock - quantity,
available_stock = available_stock + quantity
WHERE sku_id = ?;
}
优点:
- 保证幂等性
- 并发安全
缺点:
- 需要精确的状态流转
- 状态机复杂
方案三:回滚记录表(推荐)
核心思想: 维护库存回滚记录,保证幂等性和可追溯。
设计:
inventory_rollback
├── rollback_id
├── order_id
├── sku_id
├── quantity
├── rollback_type(CANCEL/REFUND/TIMEOUT)
├── status(PENDING/SUCCESS/FAILED)
├── retry_count
├── created_at
└── updated_at
回滚流程:
1. 创建回滚记录(唯一约束:order_id + sku_id)
2. 执行回滚:
UPDATE inventory
SET reserved_stock = reserved_stock - quantity
WHERE sku_id = ?;
3. 更新回滚记录状态为SUCCESS
4. 如果失败,标记为FAILED,后台重试
幂等性保证:
INSERT INTO inventory_rollback (order_id, sku_id, quantity)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE updated_at = NOW();
if (affected_rows == 1) {
// 首次插入,执行回滚
doRollback();
}
优点:
- 幂等性强
- 可追溯
- 支持重试
- 审计友好
缺点:
- 实现复杂度高
- 需要额外表
方案对比:
| 方案 | 幂等性 | 并发安全 | 可追溯 | 实施难度 |
|---|---|---|---|---|
| 直接加库存 | ★☆☆☆☆ | ★★☆☆☆ | ★☆☆☆☆ | ★★★★★ |
| 基于状态 | ★★★★☆ | ★★★★☆ | ★★★☆☆ | ★★★☆☆ |
| 回滚记录 | ★★★★★ | ★★★★★ | ★★★★★ | ★★☆☆☆ |
推荐方案: 采用回滚记录表。
实施要点:
-
回滚类型设计:
CANCEL:订单取消 - 释放预占库存 - 回补可售库存 REFUND:售后退货 - 增加物理库存 - 增加可售库存 TIMEOUT:超时未支付 - 释放预占库存 ADJUST:库存调整(人工) -
回滚执行逻辑:
@Transactional public void rollbackInventory(String orderId) { // 1. 创建回滚记录(幂等键) RollbackRecord record = new RollbackRecord(); record.setOrderId(orderId); record.setSkuId(skuId); record.setQuantity(quantity); record.setStatus("PENDING"); try { rollbackRepository.insert(record); } catch (DuplicateKeyException e) { // 已存在回滚记录,直接返回 return; } // 2. 执行库存回滚 try { inventoryService.release(skuId, quantity); record.setStatus("SUCCESS"); } catch (Exception e) { record.setStatus("FAILED"); record.setRetryCount(record.getRetryCount() + 1); throw e; } finally { rollbackRepository.update(record); } } -
部分退货处理:
场景:用户购买3件商品,退货1件 处理: 1. 创建部分回滚记录 2. 回滚数量 = 退货数量(1件) 3. 更新订单项状态(2件已发货,1件已退货) -
失败重试:
补偿Worker: 1. 定时扫描FAILED状态的回滚记录 2. 重试执行回滚 3. 最多重试5次 4. 仍失败 → 转人工处理 -
监控告警:
指标: - 回滚成功率 - 回滚延迟(下单到回滚的时间) - 失败回滚数量 告警: - 回滚成功率 < 99% - 失败回滚 > 100条
延伸思考:
- 如何防止恶意下单占用库存?
- 库存回滚失败如何人工介入?
- 大批量订单取消如何优化回滚性能?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6318。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-032:跨境电商的库存管理(多国库存)
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-032 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6540 |
题干与约束
跨境电商在中国、美国、欧洲都有仓库,同一商品在不同地区有库存。如何设计全球库存管理系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 跨境电商在中国、美国、欧洲都有仓库,同一商品在不同地区有库存。如何设计全球库存管理系统?
答案:
问题分析: 跨境库存的核心挑战:
- 时区差异(中国和美国相差12小时)
- 币种不同(人民币、美元、欧元)
- 清关周期长(跨境物流10-30天)
- 库存调拨困难
方案一:独立库存池
核心思想: 每个国家/地区独立管理库存,互不共享。
设计:
inventory
├── sku_id
├── country_code(US/CN/EU)
├── warehouse_id
├── stock
└── currency
用户购买:
1. 根据用户IP或选择的站点确定国家
2. 查询该国家的库存
3. 扣减该国家库存
4. 不跨国发货
优点:
- 实现简单
- 各国独立运营
- 无跨境调拨
缺点:
- 库存利用率低(美国有货但中国无货)
- 用户体验差(本地无货无法购买)
方案二:全球库存池(虚拟统一)
核心思想: 虚拟层展示全球总库存,实际按地区分配。
设计:
虚拟层:
global_inventory
├── sku_id
├── total_stock = sum(所有国家库存)
实际层:
regional_inventory
├── sku_id
├── region_code
├── stock
用户下单:
1. 展示全球总库存(用户可见)
2. 选择发货国家(就近优先)
3. 扣减该国库存
4. 跨境发货(如果本地无货)
优点:
- 用户体验好(看到全球库存)
- 库存利用率高
- 支持跨境发货
缺点:
- 跨境物流慢、贵
- 复杂的库存分配
方案三:混合模式(推荐)
核心思想: 优先本地发货,支持跨境应急。
设计:
库存层级:
1. 本地库存(Local Stock):
- 用户所在国家的库存
- 优先扣减
- 配送快(2-3天)
2. 区域库存(Regional Stock):
- 相邻国家的库存
- 次优选择
- 配送中等(5-7天)
3. 全球库存(Global Stock):
- 其他国家的库存
- 最后选择
- 配送慢(10-30天)
路由策略:
1. 查询本地库存
- 有货 → 本地发货
2. 查询区域库存
- 有货 → 跨境发货(用户确认)
3. 查询全球库存
- 有货 → 全球发货(用户确认)
4. 都无货 → 缺货
优点:
- 平衡速度和成本
- 灵活
- 用户可选
缺点:
- 需要智能路由
- 用户决策成本
方案对比:
| 方案 | 库存利用率 | 配送速度 | 用户体验 | 实施难度 |
|---|---|---|---|---|
| 独立池 | ★★☆☆☆ | ★★★★★ | ★★★☆☆ | ★★★★★ |
| 全球池 | ★★★★★ | ★★☆☆☆ | ★★★★★ | ★★★☆☆ |
| 混合模式 | ★★★★☆ | ★★★★☆ | ★★★★☆ | ★★☆☆☆ |
推荐方案: 采用混合模式。
实施要点:
-
库存数据结构:
global_inventory ├── sku_id ├── region_code(US/CN/EU/JP) ├── warehouse_id ├── stock ├── currency ├── local_price(本地售价) └── shipping_cost_to_other(跨境运费) -
库存分配策略:
初始分配(新品上架): - 根据各地区历史销量预测 - US: 40%, EU: 30%, CN: 20%, JP: 10% 动态调整(运营中): - 每周根据销量调整 - 滞销地区调拨到热销地区 -
跨境发货流程:
用户下单: 1. 显示配送选项: - 本地发货(2-3天,免运费) - 跨境发货(10-15天,运费$20) 2. 用户选择跨境发货 3. 扣减源国库存 4. 清关、物流 5. 配送到用户 -
币种和价格:
价格策略: - 每个地区独立定价(考虑关税、运费) - 实时汇率转换 示例: 商品成本:$100 - 美国售价:$150(含税15%,利润$35) - 中国售价:¥1200(含税13%,利润约$40) - 欧洲售价:€140(含税20%,利润约$30) -
库存同步:
同步机制: - 各地区库存独立数据库 - 聚合到全球视图(Redis缓存) - 更新延迟 < 1秒 时区处理: - 所有时间戳使用UTC - 本地展示转换为用户时区
延伸思考:
- 如何设计跨境库存调拨的审批流程?
- 清关失败如何处理库存回滚?
- 不同国家的退货政策如何影响库存管理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6540。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-033:设计支持多种促销规则的价格计算引擎
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-033 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、营销规则、权益核销 |
| 场景标签 | 商品供给、营销活动 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6748 |
题干与约束
电商平台有多种促销(满减、折扣、优惠券、满赠、阶梯价),用户下单时需要计算最终价格。如何设计灵活的价格计算引擎?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台有多种促销(满减、折扣、优惠券、满赠、阶梯价),用户下单时需要计算最终价格。如何设计灵活的价格计算引擎?
答案:
问题分析: 价格计算的核心挑战:
- 规则类型多(满减、折扣、优惠券、积分抵扣)
- 规则可组合(同时使用多种优惠)
- 优先级和互斥(有些优惠不能同时用)
- 实时计算性能
方案一:硬编码规则
核心思想: 在代码中直接编写每种促销规则的计算逻辑。
实现:
public BigDecimal calculatePrice(Order order) {
BigDecimal price = order.getOriginalPrice();
// 应用满减
if (order.getTotal() >= 200) {
price = price.subtract(new BigDecimal("30"));
}
// 应用折扣
if (order.hasDiscount()) {
price = price.multiply(new BigDecimal("0.9"));
}
// 应用优惠券
if (order.hasCoupon()) {
price = price.subtract(order.getCouponAmount());
}
return price;
}
优点:
- 实现简单
- 性能好
缺点:
- 不灵活(新增规则需要改代码)
- 难维护
- 运营无法自主配置
适用场景:
- 规则简单且固定
- 小型电商
方案二:规则引擎(推荐)
核心思想: 将促销规则配置化,使用规则引擎动态执行。
设计:
promotion_rule(促销规则表)
├── rule_id
├── rule_name
├── rule_type(DISCOUNT/FULL_REDUCE/COUPON/GIFT/TIER_PRICE)
├── rule_config(JSON)
{
"type": "FULL_REDUCE",
"threshold": 200,
"reduce": 30,
"priority": 10,
"exclusive": false
}
├── begin_time
├── end_time
├── priority(优先级,数字越小越优先)
├── exclusive(是否与其他规则互斥)
└── status
规则执行引擎:
public class PriceCalculator {
public BigDecimal calculate(Order order) {
// 1. 加载适用的规则
List<Rule> rules = ruleEngine.getApplicableRules(order);
// 2. 按优先级排序
rules.sort(Comparator.comparing(Rule::getPriority));
// 3. 依次应用规则
BigDecimal finalPrice = order.getOriginalPrice();
for (Rule rule : rules) {
if (rule.isApplicable(order)) {
finalPrice = rule.apply(finalPrice, order);
}
}
return finalPrice;
}
}
规则类型示例:
满减规则:
{
"type": "FULL_REDUCE",
"threshold": 200, // 满200
"reduce": 30 // 减30
}
折扣规则:
{
"type": "DISCOUNT",
"rate": 0.85 // 8.5折
}
阶梯价:
{
"type": "TIER_PRICE",
"tiers": [
{"quantity": 1, "price": 100},
{"quantity": 10, "price": 90},
{"quantity": 100, "price": 80}
]
}
满赠规则:
{
"type": "GIFT",
"threshold": 300,
"giftSkuId": "gift_001"
}
优点:
- 灵活可配置
- 运营自主管理
- 易于扩展新规则
- 支持复杂组合
缺点:
- 实现复杂
- 性能略低于硬编码
方案三:脚本引擎(Groovy/JavaScript)
核心思想: 将规则写成脚本,动态加载执行。
设计:
promotion_script
├── script_id
├── script_name
├── script_content(Groovy脚本)
"""
if (order.total >= 200) {
return order.total - 30
}
return order.total
"""
├── priority
└── ...
执行:
public BigDecimal calculate(Order order) {
for (Script script : scripts) {
BigDecimal price = groovyEngine.eval(script, order);
order.setPrice(price);
}
return order.getPrice();
}
优点:
- 极致灵活(可写任意逻辑)
- 无需发布代码
缺点:
- 安全风险(脚本注入)
- 调试困难
- 性能开销大
方案对比:
| 方案 | 灵活性 | 性能 | 运营友好 | 安全性 | 实施难度 |
|---|---|---|---|---|---|
| 硬编码 | ★★☆☆☆ | ★★★★★ | ★☆☆☆☆ | ★★★★★ | ★★★★★ |
| 规则引擎 | ★★★★☆ | ★★★★☆ | ★★★★★ | ★★★★☆ | ★★★☆☆ |
| 脚本引擎 | ★★★★★ | ★★★☆☆ | ★★★☆☆ | ★★☆☆☆ | ★★☆☆☆ |
推荐方案: 采用规则引擎。
实施要点:
-
规则抽象:
public interface PromotionRule { // 规则是否适用 boolean isApplicable(Order order); // 应用规则,返回新价格 BigDecimal apply(BigDecimal currentPrice, Order order); // 规则优先级 int getPriority(); // 是否与其他规则互斥 boolean isExclusive(); } // 满减规则实现 public class FullReduceRule implements PromotionRule { private BigDecimal threshold; private BigDecimal reduceAmount; public boolean isApplicable(Order order) { return order.getTotal().compareTo(threshold) >= 0; } public BigDecimal apply(BigDecimal currentPrice, Order order) { return currentPrice.subtract(reduceAmount); } } -
规则组合策略:
互斥规则: - 满减和折扣互斥(选优惠力度大的) - 用户只能使用一张优惠券 可叠加规则: - 满减 + 积分抵扣 - 会员折扣 + 优惠券 执行顺序: 1. 商品级促销(商品折扣) 2. 订单级促销(满减) 3. 用户级促销(会员折扣) 4. 优惠券 5. 积分抵扣 -
价格明细:
原价:¥500 - 商品折扣:-¥50(9折) - 满减优惠:-¥30(满200减30) - 会员折扣:-¥42(额外9折) - 优惠券:-¥20 = 实付:¥358 用户可见每项优惠的金额 -
性能优化:
规则缓存: - 缓存活跃的促销规则(Redis) - TTL 5分钟 - 规则变更时主动刷新 批量计算: - 购物车多商品批量计算 - 减少数据库查询 -
试算API:
POST /api/price/calculate { "items": [ {"skuId": "123", "quantity": 2}, {"skuId": "456", "quantity": 1} ], "couponCode": "SUMMER20", "usePoints": 100 } 响应: { "originalPrice": 500, "discounts": [ {"type": "FULL_REDUCE", "amount": 30}, {"type": "COUPON", "amount": 20} ], "finalPrice": 450 }
延伸思考:
- 如何设计促销规则的AB测试?
- 多种促销组合时如何选择最优组合?
- 促销规则变更如何保证已下单的订单价格不变?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6748。本章不依赖旧 Part Four 文件链接。
相关章节:营销与计价系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-034:优惠券系统的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-034 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、营销规则、权益核销 |
| 场景标签 | 商品供给、营销活动 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7049 |
题干与约束
电商平台需要支持优惠券(满减券、折扣券、品类券)。如何设计优惠券系统,包括发放、使用、核销?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台需要支持优惠券(满减券、折扣券、品类券)。如何设计优惠券系统,包括发放、使用、核销?
答案:
问题分析: 优惠券的核心要素:
- 发放方式(批量发放、用户领取、定向发放)
- 使用规则(满减、折扣、品类限制、商品限制)
- 并发领取(秒杀券,1万人抢100张)
- 防刷机制(防止用户重复领取)
方案一:简单优惠券
核心思想: 优惠券模板+用户优惠券实例。
设计:
coupon_template(优惠券模板)
├── template_id
├── name
├── coupon_type(FULL_REDUCE/DISCOUNT/CASH)
├── discount_amount(满减金额)
├── discount_rate(折扣率,如0.9)
├── threshold(使用门槛,如满200可用)
├── total_count(总发行量)
├── used_count(已使用数量)
├── begin_time
├── end_time
└── status
user_coupon(用户优惠券)
├── coupon_id
├── template_id
├── user_id
├── coupon_code(券码)
├── status(UNUSED/USED/EXPIRED)
├── used_order_id
├── received_at
├── used_at
└── expire_at
优点:
- 实现简单
- 易于理解
缺点:
- 功能单一
- 不支持复杂规则
方案二:规则化优惠券(推荐)
核心思想: 优惠券支持丰富的使用规则和发放规则。
设计:
coupon_template
├── template_id
├── name
├── coupon_type
├── discount_config(JSON)
{
"type": "FULL_REDUCE",
"threshold": 200,
"amount": 30
}
├── usage_rule(JSON)
{
"validCategories": [1, 2, 3], // 限定品类
"validSkus": ["sku1", "sku2"], // 限定商品
"maxDiscountPerOrder": 50, // 单笔最高优惠
"excludeBrands": [10, 20] // 排除品牌
}
├── receive_rule(JSON)
{
"maxReceivePerUser": 1, // 每人限领1张
"newUserOnly": false, // 是否新用户专享
"memberLevelRequired": "VIP" // 会员等级要求
}
├── total_count
├── received_count
├── used_count
└── ...
user_coupon
├── coupon_id
├── template_id
├── user_id
├── status
├── lock_order_id(预占:锁定到某订单)
├── locked_at
└── ...
优点:
- 规则灵活
- 支持复杂场景
- 运营可配置
缺点:
- 实现复杂度高
方案三:优惠券码模式
核心思想: 预生成优惠券码,用户输入券码兑换。
设计:
coupon_code
├── code(券码,如SUMMER2024)
├── template_id
├── status(AVAILABLE/USED/EXPIRED)
├── user_id(已兑换用户)
├── used_at
└── expire_at
使用流程:
1. 运营批量生成券码
2. 用户输入券码兑换
3. 绑定到user_coupon
4. 下单时使用
优点:
- 支持券码分享
- 灵活发放(短信、广告)
缺点:
- 券码可能被盗用
- 需要生成大量券码
方案对比:
| 方案 | 灵活性 | 并发性能 | 防刷能力 | 实施难度 |
|---|---|---|---|---|
| 简单券 | ★★☆☆☆ | ★★★★☆ | ★★★☆☆ | ★★★★★ |
| 规则券 | ★★★★★ | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
| 券码模式 | ★★★☆☆ | ★★★★★ | ★★☆☆☆ | ★★★☆☆ |
推荐方案: 采用规则化优惠券。
实施要点:
-
领券流程:
用户点击"领取": 1. 检查用户是否已领取(防重复) 2. 检查是否满足领取条件(新用户、会员等级) 3. 检查库存(received_count < total_count) 4. 扣减库存(乐观锁) 5. 创建user_coupon记录 并发控制: UPDATE coupon_template SET received_count = received_count + 1 WHERE template_id = ? AND received_count < total_count AND version = ?; if (affected_rows == 0) { throw new CouponSoldOutException(); } -
用券流程:
下单时使用优惠券: 1. 检查优惠券是否属于当前用户 2. 检查优惠券状态(UNUSED) 3. 检查是否过期 4. 检查订单是否满足使用条件(品类、金额) 5. 锁定优惠券(防止重复使用) 6. 计算优惠金额 支付成功: - 核销优惠券(status=USED) 订单取消: - 释放优惠券(status=UNUSED, lock_order_id=NULL) -
防刷策略:
策略1:用户限制 - 每人限领1张 - 同一手机号/设备ID限领 策略2:行为检测 - 短时间多次领取 → 拉黑 - 领取后不使用 → 降低权重 策略3:风控 - 新注册用户限制 - 异常IP拦截 -
券叠加规则:
规则: - 单笔订单最多使用1张优惠券 - 优惠券和满减活动可叠加 - 优惠券和积分抵扣可叠加 选券策略: - 自动选择优惠最大的券 - 或用户手动选择 -
券过期处理:
定时任务(每天凌晨): 1. 扫描即将过期的券(expire_at < NOW() + 3天) 2. 发送提醒通知(App推送、短信) 3. 扫描已过期的券 4. 状态更新为EXPIRED
延伸思考:
- 如何设计优惠券的转赠功能?
- 优惠券如何支持多次使用(如月卡券)?
- 如何设计优惠券的效果分析(发放ROI)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7049。本章不依赖旧 Part Four 文件链接。
相关章节:营销与计价系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-035:阶梯价和批发价的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-035 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7280 |
题干与约束
电商平台支持批发场景,购买数量越多价格越低(如买1件100元,买10件90元,买100件80元)。如何设计阶梯价系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台支持批发场景,购买数量越多价格越低(如买1件100元,买10件90元,买100件80元)。如何设计阶梯价系统?
答案:
问题分析: 阶梯价的核心要素:
- 阶梯定义(数量区间和对应价格)
- 混合SKU计算(多个商品如何累计数量)
- 拆单问题(阶梯内和阶梯外商品分开发货)
- 实时计算性能
方案一:SKU级阶梯价
核心思想: 每个SKU独立设置阶梯价,不同SKU不累计。
设计:
sku_tier_price
├── sku_id
├── tier_level(阶梯级别:1,2,3...)
├── min_quantity(最小数量)
├── max_quantity(最大数量,NULL表示无上限)
├── price
└── ...
示例数据:
SKU: iPhone15
tier_1: 1-9件, ¥7999
tier_2: 10-99件, ¥7500
tier_3: 100+件, ¥7000
价格计算:
if (quantity >= 100) {
return 7000 * quantity;
} else if (quantity >= 10) {
return 7500 * quantity;
} else {
return 7999 * quantity;
}
优点:
- 简单直观
- 计算快速
缺点:
- 不支持跨SKU累计
- 批发商体验差(买不同商品无法享受折扣)
方案二:品类级阶梯价
核心思想: 同一品类的商品数量累计,达到阶梯享受折扣。
设计:
category_tier_price
├── category_id
├── tier_level
├── min_quantity
├── discount_rate(折扣率)
└── ...
示例:
手机品类阶梯折扣:
tier_1: 1-9件, 无折扣
tier_2: 10-99件, 95折
tier_3: 100+件, 90折
计算:
用户购买:
- iPhone15: 5件 × ¥7999
- 小米14: 6件 × ¥3999
- 总数量:11件(属于tier_2)
- 享受95折
最终价格:
(5 × 7999 + 6 × 3999) × 0.95
优点:
- 支持跨SKU累计
- 批发商友好
缺点:
- 品类定义需要清晰
- 计算复杂
方案三:订单级阶梯价(推荐)
核心思想: 按订单总金额或总件数,应用阶梯折扣。
设计:
order_tier_price
├── tier_id
├── tier_type(BY_QUANTITY/BY_AMOUNT)
├── min_value(最小值)
├── max_value
├── discount_type(RATE/AMOUNT)
├── discount_value
└── ...
示例1:按数量
tier_1: 1-9件, 无折扣
tier_2: 10-49件, 95折
tier_3: 50+件, 90折
示例2:按金额
tier_1: <¥1000, 无折扣
tier_2: ¥1000-¥5000, 减¥100
tier_3: >¥5000, 减¥500
优点:
- 灵活
- 适用多种场景
- 计算简单
缺点:
- 需要明确阶梯规则
方案对比:
| 方案 | 灵活性 | 批发友好 | 计算复杂度 | 适用场景 |
|---|---|---|---|---|
| SKU级 | ★★☆☆☆ | ★★☆☆☆ | ★★★★★ | 零售 |
| 品类级 | ★★★★☆ | ★★★★☆ | ★★★☆☆ | 批发 |
| 订单级 | ★★★★★ | ★★★★★ | ★★★★☆ | 混合 |
推荐方案: 采用订单级阶梯价。
实施要点:
-
阶梯计算引擎:
public BigDecimal calculateTierPrice(Order order) { // 1. 计算订单总量/总额 int totalQuantity = order.getTotalQuantity(); BigDecimal totalAmount = order.getTotalAmount(); // 2. 查找匹配的阶梯 TierPrice tier = tierPriceService.findMatchingTier( totalQuantity, totalAmount ); // 3. 应用折扣 if (tier.getDiscountType() == RATE) { return totalAmount.multiply(tier.getDiscountRate()); } else { return totalAmount.subtract(tier.getDiscountAmount()); } } -
实时试算:
购物车实时显示: - 当前数量:8件 - 当前价格:¥1000 - 提示:"再买2件,享受95折,可省¥50" 动态提示: 引导用户凑单,提高客单价 -
拆单策略:
场景:用户购买120件商品 - 100件享受阶梯价(¥80/件) - 20件普通价(¥100/件) 方案A:不拆单 - 所有商品按最高阶梯价 - 用户体验好 方案B:拆单 - 100件一单,20件一单 - 复杂,不推荐 -
会员叠加:
规则: - 阶梯价和会员折扣可叠加 - 先应用阶梯价,再应用会员折扣 示例: 原价:¥10000 阶梯价(95折):¥9500 会员折扣(98折):¥9310 -
报表分析:
阶梯价效果分析: - 各阶梯成交订单数 - 平均客单价提升 - 转化率(凑单率)
延伸思考:
- 阶梯价如何与优惠券组合?
- 用户退货部分商品如何重新计算价格?
- 大促期间阶梯价如何调整?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7280。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-036:会员等级和积分体系的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-036 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7494 |
题干与约束
电商平台有会员体系(普通、银卡、金卡、钻石),不同等级享受不同权益(折扣、包邮、专属客服)。如何设计会员和积分系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台有会员体系(普通、银卡、金卡、钻石),不同等级享受不同权益(折扣、包邮、专属客服)。如何设计会员和积分系统?
答案:
问题分析: 会员体系的核心要素:
- 等级划分(如何升降级)
- 权益设计(不同等级的差异化权益)
- 积分规则(获取、消耗、过期)
- 成长值体系(区分消费积分和成长值)
方案一:简单会员(单一积分)
核心思想: 只有积分,达到一定积分自动升级。
设计:
member
├── user_id
├── member_level(NORMAL/SILVER/GOLD/DIAMOND)
├── points(积分)
├── total_points(累计积分,用于升级)
└── ...
升级规则:
- 累计积分 >= 10000 → 钻石
- 累计积分 >= 5000 → 金卡
- 累计积分 >= 1000 → 银卡
优点:
- 实现简单
- 易于理解
缺点:
- 权益单一
- 无法区分消费和成长
方案二:双轨制(积分+成长值,推荐)
核心思想: 积分用于消费抵扣,成长值用于等级提升。
设计:
member
├── user_id
├── member_level
├── points(可消费积分)
├── growth_value(成长值,只增不减)
├── upgrade_time(升级时间)
├── downgrade_time(预计降级时间)
└── ...
member_level_config
├── level
├── min_growth_value(最低成长值)
├── benefits(JSON,权益配置)
{
"discount": 0.95, // 95折
"freeShipping": true, // 包邮
"pointsRate": 1.2, // 积分倍率
"birthdayCoupon": 50, // 生日券
"exclusiveService": true // 专属客服
}
└── ...
积分规则:
point_rule
├── rule_id
├── action(ORDER/CHECKIN/SHARE/REVIEW)
├── points_reward
├── growth_reward
└── ...
示例:
- 购物:每消费1元获得1积分 + 1成长值
- 签到:每天签到获得5积分 + 0成长值
- 分享:每次分享获得10积分 + 0成长值
- 评价:每次评价获得20积分 + 5成长值
优点:
- 积分和等级分离,科学
- 防止用户消费积分后降级
- 权益丰富
缺点:
- 复杂度高
方案三:付费会员(Prime模式)
核心思想: 用户付费购买会员资格,享受权益。
设计:
member_subscription
├── user_id
├── plan_type(MONTH/YEAR)
├── status(ACTIVE/EXPIRED/CANCELLED)
├── begin_time
├── end_time
├── auto_renew(是否自动续费)
└── ...
会员权益:
- 全场95折
- 全年包邮
- 专属客服
- 优先发货
- 会员专享价
优点:
- 现金流稳定
- 用户粘性高
- 权益明确
缺点:
- 需要足够吸引力的权益
- 续费率是关键
方案对比:
| 方案 | 用户粘性 | 权益丰富度 | 实施难度 | 盈利能力 |
|---|---|---|---|---|
| 单一积分 | ★★★☆☆ | ★★☆☆☆ | ★★★★★ | ★★☆☆☆ |
| 双轨制 | ★★★★☆ | ★★★★★ | ★★★☆☆ | ★★★☆☆ |
| 付费会员 | ★★★★★ | ★★★★☆ | ★★★★☆ | ★★★★★ |
推荐方案: 采用双轨制(积分+成长值)。
实施要点:
-
积分获取规则:
消费积分: - 订单完成后发放 - 1元 = 1积分 - 会员等级倍率(金卡1.5倍) 行为积分: - 签到:5积分/天 - 分享:10积分/次 - 评价:20积分/次(带图50积分) - 首次购买:100积分 -
积分消费:
抵扣规则: - 100积分 = 1元 - 单笔订单最多抵扣订单金额的50% - 部分品类不支持积分抵扣(如iPhone) 兑换商品: - 积分商城 - 固定积分兑换商品 -
等级维护:
升级: - 成长值达到阈值立即升级 - 发送升级通知 降级: - 每年12月31日统计年度成长值 - 未达标的会员降级 - 降级前1个月提醒 - 保级活动(充值、消费保级) -
积分过期:
策略: - 积分有效期1年 - 每年12月31日清零即将过期积分 - 提前3个月、1个月、1周提醒 -
防刷策略:
- 签到积分:每天限1次 - 分享积分:每天限3次 - 评价积分:每订单限1次 - 异常行为检测(短时间大量操作)
延伸思考:
- 如何设计会员等级的有效期(年度会员)?
- 积分如何支持转赠功能?
- 会员权益如何动态调整(AB测试)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7494。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-037:动态定价系统的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-037 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、价格建模、规则计算 |
| 场景标签 | 商品供给、价格试算 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7905 |
题干与约束
电商平台希望实现动态定价(如机票、酒店根据供需实时调价)。如何设计动态定价系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台希望实现动态定价(如机票、酒店根据供需实时调价)。如何设计动态定价系统?
答案:
问题分析: 动态定价的核心要素:
- 定价因子(库存、时间、竞争对手、需求)
- 定价策略(规则还是算法)
- 价格变动频率
- 用户体验(频繁变价影响用户信任)
方案一:规则引擎定价
核心思想: 根据预设规则调整价格。
规则示例:
规则1:库存定价
- 库存 > 80% → 原价
- 库存 50%-80% → 原价 × 1.1
- 库存 20%-50% → 原价 × 1.2
- 库存 < 20% → 原价 × 1.3
规则2:时间定价
- 旺季(11-12月)→ 原价 × 1.2
- 淡季(3-4月)→ 原价 × 0.8
规则3:竞争对手定价
- 获取竞争对手价格
- 自己价格 = 竞对价格 × 0.95(低5%)
规则4:用户画像定价
- 高价值用户 → 原价
- 价格敏感用户 → 原价 × 0.9
优点:
- 可控
- 易于理解
- 运营可配置
缺点:
- 规则固定,不够灵活
- 无法自适应市场变化
方案二:算法定价(推荐)
核心思想: 使用机器学习预测最优价格。
设计:
输入特征:
- 商品属性(品牌、类目、成本)
- 库存水位
- 历史销量
- 竞争对手价格
- 用户画像(购买力、价格敏感度)
- 时间特征(星期几、节假日)
- 外部因素(天气、事件)
模型:
- 回归模型:预测最优价格
- 强化学习:实时调整价格,最大化收益
输出:
- 推荐价格
- 置信度
优点:
- 智能化
- 自适应
- 收益最大化
缺点:
- 需要算法团队
- 冷启动问题
- 黑盒,不透明
方案三:AB测试定价
核心思想: 多个价格同时测试,选择效果最好的。
流程:
1. 设定价格组:
A: ¥99
B: ¥109
C: ¥119
2. 随机分流用户
3. 统计各价格组的转化率和收益
4. 选择最优价格作为主价格
5. 持续迭代测试
优点:
- 基于实际数据
- 科学决策
缺点:
- 测试周期长
- 需要流量支持
方案对比:
| 方案 | 灵活性 | 效果 | 实施难度 | 适用场景 |
|---|---|---|---|---|
| 规则引擎 | ★★★☆☆ | ★★★☆☆ | ★★★★☆ | 标品 |
| 算法定价 | ★★★★★ | ★★★★★ | ★★☆☆☆ | 大平台 |
| AB测试 | ★★★★☆ | ★★★★☆ | ★★★★☆ | 新品 |
推荐方案: 采用规则引擎+算法定价的混合方案。
实施要点:
-
定价数据收集:
price_history(价格历史) ├── sku_id ├── price ├── stock ├── sales_quantity(该价格下的销量) ├── conversion_rate(转化率) ├── start_time └── end_time competitor_price(竞对价格) ├── sku_id ├── competitor_name ├── price ├── crawled_at └── ... -
定价决策流程:
定时任务(每小时): 1. 收集数据(库存、销量、竞对价格) 2. 输入定价模型 3. 模型输出推荐价格 4. 人工审核(可选) 5. 更新商品价格 6. 记录价格变更日志 -
价格锁定:
用户加购物车: - 锁定当前价格15分钟 - 15分钟内下单按锁定价 - 超时按最新价 或: - 不锁定价格 - 下单时实时计算(用户体验差) -
价格展示:
对用户: - 显示当前价 - 历史最低价(增加紧迫感) - 降价通知(用户订阅) 对运营: - 价格趋势图 - 竞对价格对比 - 销量-价格关系 -
价格保护:
规则: - 单次调价幅度 <= 20% - 每天最多调价3次 - 价格不低于成本价 × 1.1(保证毛利) - 价格不高于市场价 × 1.5(防止离谱)
延伸思考:
- 如何处理用户对频繁变价的不满?
- 价格歧视(同一商品不同用户不同价)的法律风险?
- 如何设计价格保护机制(买贵退差价)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7905。本章不依赖旧 Part Four 文件链接。
相关章节:营销与计价系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-038:跨境电商的汇率和税费计算
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-038 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8102 |
题干与约束
跨境电商需要处理多币种和不同国家的税费。如何设计汇率转换和税费计算系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 跨境电商需要处理多币种和不同国家的税费。如何设计汇率转换和税费计算系统?
答案:
问题分析: 跨境价格的核心要素:
- 汇率实时变动
- 不同国家税率不同(关税、增值税)
- 币种展示(用户看到本地币种)
- 结算币种(实际收款币种)
方案一:实时汇率
核心思想: 每次计算价格时查询实时汇率。
设计:
价格计算:
1. 商品基础价格(USD $100)
2. 查询实时汇率(USD/CNY = 7.2)
3. 转换为人民币(¥720)
4. 加税费(关税10%,增值税13%)
5. 最终价格(¥720 × 1.1 × 1.13 = ¥894)
汇率来源:
- 调用汇率API(如XE, OANDA)
- 每分钟更新一次
优点:
- 汇率准确
- 实时性好
缺点:
- 价格频繁变化
- 用户体验差
- API成本高
方案二:固定汇率(推荐)
核心思想: 每天固定汇率,当天内价格不变。
设计:
exchange_rate(汇率表)
├── from_currency
├── to_currency
├── rate
├── effective_date(生效日期)
└── created_at
价格计算:
1. 查询今日汇率(缓存)
2. 转换币种
3. 加税费
4. 展示价格
汇率更新:
- 每天凌晨0点更新汇率
- 或管理员手动更新
优点:
- 价格稳定
- 用户体验好
- 缓存友好
缺点:
- 汇率不是实时
- 可能有汇兑损失
方案三:汇率浮动区间
核心思想: 设置汇率波动阈值,超过阈值才更新。
设计:
固定汇率:7.2(基准)
浮动区间:±2%(7.056 - 7.344)
实时汇率:7.25
→ 在区间内,使用固定汇率7.2
实时汇率:7.40
→ 超出区间,更新固定汇率为7.4
优点:
- 平衡稳定性和准确性
- 减少价格变化频率
缺点:
- 实现复杂度高
方案对比:
| 方案 | 准确性 | 稳定性 | 用户体验 | 实施难度 |
|---|---|---|---|---|
| 实时汇率 | ★★★★★ | ★★☆☆☆ | ★★☆☆☆ | ★★★☆☆ |
| 固定汇率 | ★★★☆☆ | ★★★★★ | ★★★★★ | ★★★★☆ |
| 浮动区间 | ★★★★☆ | ★★★★☆ | ★★★★☆ | ★★☆☆☆ |
推荐方案: 采用固定汇率(每日更新)。
实施要点:
-
汇率管理:
public class ExchangeRateService { @Scheduled(cron = "0 0 0 * * ?") // 每天0点 public void updateExchangeRate() { // 1. 调用汇率API获取最新汇率 Map<String, BigDecimal> rates = fetchRatesFromAPI(); // 2. 保存到数据库 for (String pair : rates.keySet()) { ExchangeRate rate = new ExchangeRate(); rate.setPair(pair); rate.setRate(rates.get(pair)); rate.setEffectiveDate(LocalDate.now()); repository.save(rate); } // 3. 刷新缓存 cacheService.refreshRates(rates); } } -
税费计算:
tax_rule(税费规则) ├── country_code ├── category_id ├── import_duty_rate(关税率) ├── vat_rate(增值税率) ├── min_tax_free_amount(免税额) └── ... 示例: 中国: - 关税:10% - 增值税:13% - 免税额:¥5000以下免税 美国: - 关税:0% - 州税:0-10%(各州不同) -
价格展示:
商品页展示: - 商品价格:$100 - 运费:$20 - 关税:$10(预估) - 总计:$130(约¥936) 结算页: - 确认最终价格(包含税费) - 币种选择(CNY/USD) -
结算币种:
策略1:统一结算币种 - 平台统一收USD - 用户支付CNY → 银行自动换汇 策略2:多币种账户 - 平台有USD、CNY、EUR账户 - 用户付CNY → 直接入CNY账户 - 减少汇兑成本 -
汇率风险对冲:
风险: - 用户下单时汇率7.2 - 商家收款时汇率7.0 - 平台损失2% 对冲策略: - 购买外汇期货 - 设置汇率浮动保护(±1%) - 及时结汇
延伸思考:
- 如何设计多币种支付(用户用USD支付CNY订单)?
- 汇率变化导致退款金额不一致如何处理?
- 跨境税费如何合规申报?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8102。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-039:组合促销的价格计算(满减+折扣+券)
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-039 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、营销规则、权益核销 |
| 场景标签 | 商品供给、营销活动 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8304 |
题干与约束
用户下单时同时享受满减(满200减30)、商品折扣(9折)、优惠券(20元)。如何设计组合促销的价格计算逻辑?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户下单时同时享受满减(满200减30)、商品折扣(9折)、优惠券(20元)。如何设计组合促销的价格计算逻辑?
答案:
问题分析: 组合促销的核心挑战:
- 计算顺序(先满减还是先折扣影响最终价)
- 规则冲突(有些促销不能同时用)
- 最优组合(如何选择让用户优惠最大)
- 性能(实时计算)
方案一:固定计算顺序
核心思想: 规定促销的固定计算顺序。
计算顺序:
原价:¥500
顺序1:折扣 → 满减 → 优惠券
1. 商品折扣(9折):¥500 × 0.9 = ¥450
2. 满减(满200减30):¥450 - ¥30 = ¥420
3. 优惠券(20元):¥420 - ¥20 = ¥400
顺序2:满减 → 折扣 → 优惠券
1. 满减:¥500 - ¥30 = ¥470
2. 折扣:¥470 × 0.9 = ¥423
3. 优惠券:¥423 - ¥20 = ¥403
结果不同!
推荐顺序:
1. 商品级促销(商品折扣、第二件半价)
2. 订单级促销(满减、满赠)
3. 平台级促销(优惠券、积分抵扣)
4. 会员折扣
原则:
- 商品自身属性优先
- 门槛促销次之
- 通用促销最后
优点:
- 简单清晰
- 易于实现
缺点:
- 不够灵活
- 可能不是最优惠
方案二:最优组合(推荐)
核心思想: 尝试所有可能的组合,选择最优惠的。
算法:
public BigDecimal calculateBestPrice(Order order) {
// 1. 获取所有适用的促销
List<Promotion> promotions = getApplicablePromotions(order);
// 2. 生成所有可能的组合(考虑互斥规则)
List<List<Promotion>> combinations = generateCombinations(promotions);
// 3. 计算每种组合的最终价
BigDecimal minPrice = order.getOriginalPrice();
List<Promotion> bestCombination = null;
for (List<Promotion> combo : combinations) {
BigDecimal price = calculatePrice(order, combo);
if (price.compareTo(minPrice) < 0) {
minPrice = price;
bestCombination = combo;
}
}
// 4. 应用最优组合
return applyPromotions(order, bestCombination);
}
生成组合时考虑互斥:
- 满减A和满减B互斥(只能选一个)
- 优惠券互斥(只能用一张)
- 其他可叠加
优点:
- 保证最优惠
- 用户体验最好
缺点:
- 计算量大(组合爆炸)
- 性能压力
优化:
- 限制促销数量(最多5个)
- 剪枝(提前排除明显不优的组合)
- 缓存(相同商品+促销组合缓存结果)
方案三:优先级+互斥
核心思想: 促销有优先级,互斥的选优先级高的。
设计:
促销列表(按优先级排序):
1. 优惠券20元(优先级10,互斥组A)
2. 满减30元(优先级20,互斥组A)
3. 商品折扣9折(优先级30,可叠加)
4. 会员折扣95折(优先级40,可叠加)
计算逻辑:
1. 在互斥组A中选择优惠力度最大的(满减30元)
2. 应用可叠加的促销(商品折扣、会员折扣)
最终:
¥500 - ¥30(满减)× 0.9(商品折扣)× 0.95(会员折扣)= ¥401.5
优点:
- 平衡灵活性和性能
- 运营可配置
缺点:
- 可能不是全局最优
方案对比:
| 方案 | 最优性 | 性能 | 灵活性 | 实施难度 |
|---|---|---|---|---|
| 固定顺序 | ★★☆☆☆ | ★★★★★ | ★★☆☆☆ | ★★★★★ |
| 最优组合 | ★★★★★ | ★★★☆☆ | ★★★★★ | ★★☆☆☆ |
| 优先级+互斥 | ★★★★☆ | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
推荐方案: 采用优先级+互斥,必要时计算最优组合。
实施要点:
-
促销配置:
promotion ├── promotion_id ├── name ├── type(DISCOUNT/FULL_REDUCE/COUPON) ├── priority(优先级) ├── exclusive_group(互斥组,NULL表示可叠加) ├── stackable(是否可叠加) └── ... -
价格明细:
订单价格明细: { "originalPrice": 500, "appliedPromotions": [ { "name": "商品9折", "discountAmount": 50, "afterPrice": 450 }, { "name": "满200减30", "discountAmount": 30, "afterPrice": 420 }, { "name": "优惠券", "discountAmount": 20, "afterPrice": 400 } ], "finalPrice": 400 } 用户可见每一步的优惠 -
试算接口:
POST /api/price/preview { "items": [...], "promotions": [...], "coupon": "SUMMER20" } 响应: { "scenarios": [ { "name": "推荐方案", "finalPrice": 400, "savings": 100, "appliedPromotions": [...] }, { "name": "仅用优惠券", "finalPrice": 480, "savings": 20, "appliedPromotions": [...] } ] } 让用户选择方案 -
性能优化:
缓存: key: price:calculate:{商品ID}:{促销IDs哈希} value: 计算结果 TTL: 5分钟 避免重复计算
延伸思考:
- 如何向用户推荐最优促销组合?
- 促销规则变更如何保证已下单的订单价格不变?
- 如何设计促销的AB测试?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8304。本章不依赖旧 Part Four 文件链接。
相关章节:营销与计价系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-040:预售和定金膨胀的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-040 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8540 |
题干与约束
预售活动中,用户支付定金(如50元),尾款时定金可抵100元。如何设计预售和定金膨胀系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 预售活动中,用户支付定金(如50元),尾款时定金可抵100元。如何设计预售和定金膨胀系统?
答案:
问题分析: 预售定金的核心要素:
- 定金不可退(锁定用户)
- 定金膨胀(50元抵100元)
- 尾款支付期限(超时定金不退)
- 库存预占
方案一:双订单模式
核心思想: 定金订单和尾款订单分开。
设计:
presale_activity(预售活动)
├── activity_id
├── sku_id
├── deposit_amount(定金)
├── deposit_expand_amount(定金膨胀金额)
├── final_price(商品总价)
├── deposit_start_time
├── deposit_end_time
├── balance_start_time(尾款开始时间)
├── balance_end_time
└── ...
deposit_order(定金订单)
├── order_id
├── activity_id
├── user_id
├── deposit_amount
├── status(PAID/UNPAID)
└── ...
balance_order(尾款订单)
├── order_id
├── deposit_order_id(关联定金订单)
├── balance_amount(尾款金额 = 总价 - 定金膨胀)
├── status
└── ...
流程:
1. 预售期:用户支付定金 → 创建deposit_order
2. 尾款期:系统自动创建balance_order
3. 用户支付尾款
4. 发货
优点:
- 清晰分离
- 易于管理
缺点:
- 两个订单,用户理解成本高
方案二:单订单分阶段(推荐)
核心思想: 一个订单,分阶段支付。
设计:
order
├── order_id
├── order_type(PRESALE)
├── presale_activity_id
├── total_amount(商品总价)
├── deposit_amount(已付定金)
├── balance_amount(待付尾款)
├── current_stage(DEPOSIT/BALANCE/COMPLETED)
├── deposit_paid_at
├── balance_deadline
└── ...
order_payment(支付记录)
├── payment_id
├── order_id
├── payment_type(DEPOSIT/BALANCE)
├── amount
├── paid_at
└── ...
流程:
1. 预售期:用户下单,支付定金
order.current_stage = DEPOSIT
order.deposit_amount = 50
order.balance_amount = 总价 - 定金膨胀金额
2. 尾款期:订单进入尾款阶段
order.current_stage = BALANCE
发送尾款提醒
3. 用户支付尾款
order.current_stage = COMPLETED
4. 发货
优点:
- 订单统一
- 用户理解成本低
- 易于追踪
缺点:
- 订单状态复杂
方案三:虚拟商品模式
核心思想: 定金作为虚拟商品,尾款时抵扣。
设计:
1. 用户购买"定金商品"(¥50)
2. 定金支付成功后,发放"抵扣券"(可抵¥100)
3. 尾款期,用户购买商品,使用抵扣券
4. 实付 = 商品价格 - 抵扣券金额
优点:
- 复用现有优惠券系统
- 灵活
缺点:
- 定金和尾款割裂
- 用户可能不理解
方案对比:
| 方案 | 清晰度 | 实施难度 | 用户体验 | 适用场景 |
|---|---|---|---|---|
| 双订单 | ★★★☆☆ | ★★★☆☆ | ★★★☆☆ | 复杂预售 |
| 单订单分阶段 | ★★★★★ | ★★★★☆ | ★★★★★ | 通用 |
| 虚拟商品 | ★★☆☆☆ | ★★★★☆ | ★★☆☆☆ | 简单预售 |
推荐方案: 采用单订单分阶段。
实施要点:
-
定金膨胀计算:
商品总价:¥999 定金:¥50 定金膨胀:¥100(2倍膨胀) 尾款:¥999 - ¥100 = ¥899 用户总共支付:¥50 + ¥899 = ¥949(省¥50) -
库存管理:
定金支付成功: - 预占库存(reserved_stock +1) - 锁定到该订单 尾款支付成功: - 确认库存(sold_stock +1, reserved_stock -1) 超时未付尾款: - 释放库存(reserved_stock -1) - 定金不退 -
尾款提醒:
提醒策略: - 尾款开始:立即推送 - 尾款截止前3天:提醒 - 尾款截止前1天:紧急提醒 - 尾款截止前1小时:最后提醒 提醒渠道: - App推送 - 短信 - 站内信 -
超时处理:
定时任务(每小时): 1. 扫描超时未付尾款的订单 2. 订单状态 → CLOSED 3. 释放库存 4. 定金记为平台收入(不退) 5. 通知用户 -
退款规则:
规则: - 支付定金后,不可取消订单 - 定金不退 - 尾款支付后,可申请退款 - 退款金额 = 定金 + 尾款
延伸思考:
- 如何防止用户恶意付定金占用库存?
- 预售商品如何设置发货时间?
- 定金膨胀活动如何设计ROI分析?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8540。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-041:价格歧视与个性化定价的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-041 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、价格建模、规则计算 |
| 场景标签 | 商品供给、价格试算 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8751 |
题干与约束
电商平台希望根据用户画像(新老用户、购买力、价格敏感度)实现个性化定价。如何设计价格歧视系统?同时如何规避法律风险?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台希望根据用户画像(新老用户、购买力、价格敏感度)实现个性化定价。如何设计价格歧视系统?同时如何规避法律风险?
答案:
问题分析: 个性化定价的核心要素:
- 用户分层(高价值、普通、价格敏感)
- 定价策略(不同用户看到不同价格)
- 法律风险(价格歧视在某些国家违法)
- 用户信任(发现差价后的负面影响)
方案一:明面价格歧视(不推荐)
核心思想: 不同用户直接看到不同价格。
示例:
用户A(新用户):¥99
用户B(老用户):¥129
用户C(高价值用户):¥149
价格查询:
price = getPriceByUser(skuId, userId);
优点:
- 简单直接
- 收益最大化
缺点:
- 法律风险大(违反价格法)
- 用户信任崩塌(发现后口碑崩盘)
- 媒体曝光风险
方案二:差异化优惠(推荐)
核心思想: 价格统一,但不同用户获得不同优惠。
设计:
基础价格:统一¥129
新用户:
- 新人专享券:¥30
- 实付:¥99
普通用户:
- 无优惠
- 实付:¥129
高价值用户:
- 会员折扣:9折
- 实付:¥116
关键:
- 价格统一展示
- 优惠透明(标注"新人专享"、"会员专享")
优点:
- 合法合规
- 用户可接受
- 价格透明
缺点:
- 收益优化程度不如价格歧视
方案三:隐性定价(灰色地带)
核心思想: 通过算法展示不同的商品推荐和排序。
策略:
高价值用户:
- 推荐高价商品
- 搜索结果优先展示高价商品
价格敏感用户:
- 推荐促销商品
- 搜索结果优先展示低价商品
不直接改价格,但影响用户选择
优点:
- 间接影响购买
- 法律风险小
缺点:
- 效果不如直接定价
- 算法复杂
方案对比:
| 方案 | 收益 | 合规性 | 用户信任 | 风险 |
|---|---|---|---|---|
| 明面歧视 | ★★★★★ | ★☆☆☆☆ | ★☆☆☆☆ | ★★★★★ |
| 差异化优惠 | ★★★★☆ | ★★★★★ | ★★★★☆ | ★★☆☆☆ |
| 隐性定价 | ★★★☆☆ | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
推荐方案: 采用差异化优惠。
实施要点:
-
用户分层:
基于RFM模型: - R(最近一次购买) - F(购买频次) - M(购买金额) 用户分层: - 高价值用户(VIP):R<30天, F>10次, M>1万 - 活跃用户:R<90天, F>3次 - 沉睡用户:R>90天 - 新用户:注册<30天,F=0 - 价格敏感用户:经常搜索低价、使用优惠券 -
差异化优惠策略:
新用户: - 新人专享券(大额) - 首单免运费 - 新人专区(低价引流商品) 沉睡用户: - 唤醒券(定向发放) - "好久不见,给你优惠" 高价值用户: - 会员折扣 - 生日礼包 - 专属客服 价格敏感用户: - 推荐促销商品 - 凑单优惠 -
透明化展示:
商品页: - 价格:¥129(统一价格) - 您的优惠: ✓ 新人券:-¥30 ✓ 首单免运费 - 实付:¥99 标注优惠来源,避免误解 -
法律合规:
避免: - 同一商品同一时间不同价格(价格歧视) - 隐藏真实价格 - 大数据杀熟 合法: - 不同用户不同优惠(促销活动) - 会员专享价(明确标注) - 新人优惠(限定条件) -
监控与风控:
监控指标: - 用户投诉率(价格差异投诉) - 媒体舆情 - 价格离散度(同商品价格差异) 风控: - 价格差异 < 30% - 优惠透明化 - 避免同一用户看到不同价格
延伸思考:
- 如何平衡个性化定价和用户信任?
- 用户发现价格差异后如何应对?
- 如何设计价格歧视的AB测试(避免法律风险)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8751。本章不依赖旧 Part Four 文件链接。
相关章节:营销与计价系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-042:商品中心和商品供给平台有什么区别?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-042 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:173 |
题干与约束
商品中心和商品供给平台有什么区别?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:商品供给平台负责 Draft、Staging、QC、批量导入、库存创建 / 修改运营入口、供应商同步、DLQ 和发布编排;商品中心负责正式商品主数据、交易前契约、发布版本、商品快照和读模型。供给平台是“商品和供给能力如何进入平台”,商品中心是“正式商品如何被交易系统稳定使用”。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:173。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-043:为什么商品正式表不应该有 Draft、QC Pending、Rejected 状态?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-043 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:177 |
题干与约束
为什么商品正式表不应该有 Draft、QC Pending、Rejected 状态?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:这些是供给流程状态,不是正式商品生命周期状态。新建商品在发布前还没有正式 item_id;编辑商品待审时,线上旧版本仍然有效。如果把流程状态塞进正式商品表,会导致搜索、缓存、订单误读未审核数据。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:177。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-044:为什么需要 Resource,而不是只用 SPU/SKU?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-044 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:181 |
题干与约束
为什么需要 Resource,而不是只用 SPU/SKU?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:很多数字商品依附于现实或外部资源,例如酒店、门店、活动、运营商、账单机构。Resource 表达资源事实,SPU/SKU 表达商品定义,Offer 表达销售承诺。这样供应商同步可以先沉淀资源,运营再决定如何售卖。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:181。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-045:Product Item、SPU、SKU、Offer 的关系是什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-045 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:185 |
题干与约束
Product Item、SPU、SKU、Offer 的关系是什么?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:Product Item 是前台商品入口或聚合根;SPU 表达共性商品定义;SKU 表达可下单规格单元;Offer 表达销售条件、渠道、销售期、库存来源、输入、履约和退款规则。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:185。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-046:为什么需要 publish_version?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-046 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:189 |
题干与约束
为什么需要 publish_version?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:publish_version 用于防止旧编辑覆盖新版本,也用于搜索、缓存、订单按版本处理。编辑发布时要校验 base_publish_version == current_publish_version,否则进入版本冲突。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:189。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-047:订单为什么不能回读最新商品表?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-047 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:193 |
题干与约束
订单为什么不能回读最新商品表?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:商品会不断修改标题、价格计划、履约参数和退款规则。历史订单必须按照下单时的商品、报价、履约和退款契约解释,所以订单要保存或引用创单时的商品快照。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:193。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-048:商品中心是否保存实时库存和最终成交价?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-048 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、库存一致性、并发控制 |
| 场景标签 | 商品供给、库存履约 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:197 |
题干与约束
商品中心是否保存实时库存和最终成交价?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:不应该把实时库存和最终成交价作为商品中心唯一事实。商品中心保存库存配置和基础价、Offer 规则;实时库存由库存系统判断,最终成交价由计价系统试算。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:197。本章不依赖旧 Part Four 文件链接。
相关章节:库存系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-049:发布成功后搜索刷新失败怎么办?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-049 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:201 |
题干与约束
发布成功后搜索刷新失败怎么办?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:不回滚商品发布。商品正式表、版本、快照和 Outbox 同事务写入;搜索刷新通过 Outbox 异步重试,失败进入补偿任务。搜索索引按 publish_version 幂等更新,并通过巡检发现版本落后。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:201。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-050:供应商同步是否直接写商品中心?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-050 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:205 |
题干与约束
供应商同步是否直接写商品中心?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:不建议。供应商同步先保存 Raw Snapshot,然后标准化、映射、Diff,进入供给平台 Staging 和发布治理。商品中心只接收发布命令和正式结果。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:205。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-051:商品中心如何支持异构品类?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-051 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:209 |
题干与约束
商品中心如何支持异构品类?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:用类目模板定义 Resource、SPU、SKU、Offer、交易契约和展示字段;核心交易字段结构化,品类核心字段可以用扩展表,长尾展示字段用 JSON,搜索筛选字段进入搜索投影。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:209。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-052:为什么发布事务里不直接刷新缓存和 ES?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-052 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:213 |
题干与约束
为什么发布事务里不直接刷新缓存和 ES?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:刷新缓存和 ES 是下游投影动作,可能慢、可能失败,不应该放在商品发布事务里。发布事务只写正式表、版本、快照和 Outbox;下游通过事件异步刷新并可补偿。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:213。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-053:商品中心如何排查线上展示旧数据?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-053 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:217 |
题干与约束
商品中心如何排查线上展示旧数据?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:先查 product_item_tab.current_publish_version,再查 product_snapshot_tab 和 product_outbox_event,然后对比 Redis、搜索索引里的 publish_version。如果索引或缓存落后,通过 Outbox 重放或补偿任务刷新。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:217。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-054:为什么商品供给与运营平台不能设计成商品中心的后台 CRUD?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-054 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:255 |
题干与约束
为什么商品供给与运营平台不能设计成商品中心的后台 CRUD?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:商品中心负责正式主数据和查询契约,供给平台负责 Draft、Task、Staging、审核、发布、补偿和审计。后台直接 CRUD 会让未审核数据污染线上,也会造成搜索、缓存、订单快照和发布版本不可追溯。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:255。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-055:商品中心和供给运营平台的边界是什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-055 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:259 |
题干与约束
商品中心和供给运营平台的边界是什么?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:商品中心保存正式 Resource / SPU / SKU / Offer / Rule 和 publish_version;供给运营平台保存供给流程对象,例如 Draft、Staging、QC、Task、DLQ。供给平台通过发布事务写商品中心,不直接让运营后台改正式表。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:259。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-056:供应商同步和人工上传要分成两套系统吗?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-056 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:263 |
题干与约束
供应商同步和人工上传要分成两套系统吗?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:不要简单回答“分开”或“不分开”。更准确的设计是:入口层、执行层要分开,标准化之后的治理和发布层要合流。
供应商同步和人工上传的执行问题完全不同:
| 维度 | 人工上传 / 批量导入 | 供应商同步 |
|---|---|---|
| 触发方式 | 用户点击、上传 Excel、保存 Draft | 定时任务、全量同步、增量同步、Push |
| 数据来源 | 本地运营、商家 | 外部供应商 API / 消息 |
| 失败原因 | 字段填错、模板错误、图片不合规 | 超时、限流、5xx、游标失效、字段漂移 |
| 进度模型 | 文件行号、导入 item、错误文件 | city / page / cursor / checkpoint |
| 恢复机制 | 重传文件、失败行重试 | checkpoint 续跑、lease 抢占、DLQ |
| 证据保存 | 上传文件、表单 payload | Raw Snapshot、payload hash |
| 新鲜度 | 通常不要求秒级 | 酒店、票务、库存价格可能要求高新鲜度 |
所以供应商同步需要独立执行层:
supplier_sync_task
supplier_sync_batch
supplier_sync_snapshot
supplier_sync_diff_log
supplier_sync_dead_letter
但它们不能完全拆成两套商品发布系统。否则人工上传一套 QC 和发布逻辑,供应商同步另一套 QC 和发布逻辑,很容易出现审核规则重复、发布版本乱序、字段主导权混乱、搜索缓存刷新不一致、订单快照口径不统一等问题。
推荐架构是:
人工上传 / 批量导入
→ product_supply_task
→ product_supply_task_item
→ product_supply_staging
→ Validation
→ Change Request / Risk
→ QC / Auto Approve
→ Publish
→ Outbox
供应商同步
→ supplier_sync_task
→ supplier_sync_batch
→ Raw Snapshot
→ Normalize
→ Supplier Mapping
→ Diff
→ product_supply_task(task_type=SUPPLIER_SYNC_IMPORT)
→ product_supply_staging
→ Validation
→ Change Request / Risk
→ QC / Auto Approve
→ Publish
→ Outbox
能力拆分可以这样判断:
| 能力 | 是否分开 | 原因 |
|---|---|---|
| 供应商 API Adapter | 分开 | 每个供应商协议不同 |
| 全量 / 增量同步任务 | 分开 | 需要 checkpoint、lease、分页游标 |
| Raw Snapshot | 分开 | 供应商原始响应要可追溯和回放 |
| 供应商限流 / 熔断 | 分开 | 外部依赖治理 |
| 文件解析 | 人工链路独有 | Excel / CSV / 模板 |
| Draft 编辑体验 | 人工链路独有 | 用户可反复保存 |
| 标准化模型 | 合并 | 最终都要转成平台 Resource / SKU / Offer |
| 质量校验 | 合并 | 商品发布门禁应该统一 |
| Diff / Risk | 合并 | 风险判断口径要统一 |
| QC 审核 | 合并 | 审核工作台和策略要统一 |
| Publish | 合并 | 只能有一个正式发布入口 |
| Outbox | 合并 | 下游刷新口径要统一 |
| DLQ / 补偿 | 部分合并 | 供应商执行 DLQ 分开,发布治理 DLQ 合并 |
面试时可以收束成一句话:
供应商同步有自己的“采集和恢复系统”,但不应该有自己的“商品发布系统”。采集链路可以分,发布治理必须合。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:263。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-057:本地运营上传和商家上传为什么审核策略不同?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-057 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:342 |
题干与约束
本地运营上传和商家上传为什么审核策略不同?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:本地运营是内部可信来源,默认 AUTO_APPROVE,但仍要走 Validation、Staging、Publish 和 Outbox;商家是外部来源,默认 QC_REQUIRED,QC 通过后才能发布。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:342。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-058:为什么 Draft、Staging、QC、正式 Item 要有不同状态机?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-058 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:348 |
题干与约束
为什么 Draft、Staging、QC、正式 Item 要有不同状态机?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:它们描述的是不同对象。Draft 是可编辑工作区,Staging 是提交冻结快照,QC 是审核工单,正式 Item 是线上商品资产。把这些状态混在一个字段里,会出现 DRAFT/QC_PENDING/ONLINE/REJECTED 语义混乱。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:348。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-059:新建商品在 Draft 和 QC 阶段还没有正式 item_id,怎么追踪全链路?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-059 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:352 |
题干与约束
新建商品在 Draft 和 QC 阶段还没有正式 item_id,怎么追踪全链路?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:首次创建时生成 supply_trace_id 和 temporary_object_key。发布成功后生成正式 item_id,再写 product_supply_object_mapping,后续可以用 item_id 反查 supply_trace_id。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:352。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-060:商品已经在线后再次编辑,哪些 ID 会新建,哪些 ID 会复用?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-060 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:356 |
题干与约束
商品已经在线后再次编辑,哪些 ID 会新建,哪些 ID 会复用?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:item_id 和 supply_trace_id 复用;operation_id、draft_id、staging_id、可能的 review_id 都新建;发布成功后 publish_version 递增。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:356。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-061:为什么商家提交 Draft 后要生成不可随意修改的 Staging Ticket?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-061 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:360 |
题干与约束
为什么商家提交 Draft 后要生成不可随意修改的 Staging Ticket?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:Draft 是工作区,可以反复保存;Staging 是提交证据和审核发布对象。冻结 Staging 可以保证 QC 审核的内容和最终发布内容一致,避免“审核 A,发布 B”。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:360。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-062:Pending 阶段发现内容填错,还能直接编辑吗?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-062 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:364 |
题干与约束
Pending 阶段发现内容填错,还能直接编辑吗?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:不建议直接编辑待审 Staging。推荐语义是撤回当前 QC 和 Staging,再基于 Staging payload 生成新 Draft,修改后重新提交,生成新的 Staging 和 QC。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:364。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-063:为什么需要 product_qc_review 和 product_qc_review_item 两张表?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-063 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:370 |
题干与约束
为什么需要 product_qc_review 和 product_qc_review_item 两张表?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:product_qc_review 是审核工单主表,记录整体状态、审核人、结论;product_qc_review_item 是审核项明细,记录字段 Diff、风险原因、分项结论和驳回原因。批量任务和字段级驳回都需要明细表。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:370。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-064:product_change_request、product_supply_operation_log、product_field_ownership 分别解决什么问题?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-064 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:374 |
题干与约束
product_change_request、product_supply_operation_log、product_field_ownership 分别解决什么问题?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:product_change_request 记录改了什么、风险多高、是否需要 QC;product_supply_operation_log 记录从 Draft 到下线全过程发生了什么;product_field_ownership 记录字段由谁主导,防止供应商同步覆盖人工治理结果。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:374。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-065:product_publish_record、product_publish_snapshot、product_change_log 的区别是什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-065 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:378 |
题干与约束
product_publish_record、product_publish_snapshot、product_change_log 的区别是什么?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:publish_record 记录一次发布动作和结果;publish_snapshot 保存发布后的正式商品上下文;change_log 解释正式商品从旧版本到新版本变了哪些字段。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:378。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-066:Publish 背后的实际流程是什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-066 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:382 |
题干与约束
Publish 背后的实际流程是什么?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:发布前校验 Staging 状态、QC 状态、幂等、版本冲突和交易契约完整性;事务内写正式商品表、库存配置、履约退款规则、publish_version、发布快照、变更日志和 Outbox;事务后由搜索、缓存、计价上下文和数据平台消费者异步重建投影。涉及活动配置时,由供给平台发起营销系统命令或营销资格事件,但营销规则、预算和优惠计算仍归营销系统。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:382。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-067:为什么 QC 通过不等于商品已经上线?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-067 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:386 |
题干与约束
为什么 QC 通过不等于商品已经上线?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:QC 通过只表示允许发布。真正上线还需要 Publish Worker 完成正式表写入、生成发布版本、写 Outbox、刷新搜索缓存,并且商品满足销售时间、库存、渠道和风控可售条件。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:386。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-068:为什么发布前要校验 base_publish_version?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-068 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:390 |
题干与约束
为什么发布前要校验 base_publish_version?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:编辑是基于某个线上版本产生的。如果当前线上版本已经变化,旧 Staging 继续发布会覆盖别人的新修改。发布前做 CAS 版本校验,冲突时进入 VERSION_CONFLICT 并要求重新编辑。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:390。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-069:为什么批量导入不能同步循环写正式表?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-069 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:396 |
题干与约束
为什么批量导入不能同步循环写正式表?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:批量导入可能有大量数据、行级错误、长耗时和下游压力。应该先创建 product_supply_task,再流式解析文件、生成 task_item、行级校验、部分成功、错误文件和补偿任务。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:396。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-070:Parser Worker 和 Item Worker 为什么要拆开?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-070 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:400 |
题干与约束
Parser Worker 和 Item Worker 为什么要拆开?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:Parser Worker 只负责文件解析和生成行级 item;Item Worker 负责标准化、校验、Staging、Diff、QC 和发布准备。拆开后解析失败不会污染业务处理,行级处理可以限流、重试和并行。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:400。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-071:product_supply_task 和 product_supply_task_item 的关系是什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-071 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:404 |
题干与约束
product_supply_task 和 product_supply_task_item 的关系是什么?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:Task 是一次供给动作的批次状态,Task Item 是行级或对象级处理单元。Task 状态由 Item 聚合,支持部分成功、部分失败、部分等待 QC,避免一个失败项拖垮整批任务。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:404。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-072:供应商同步为什么需要 Raw Snapshot、Checkpoint 和 DLQ?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-072 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:408 |
题干与约束
供应商同步为什么需要 Raw Snapshot、Checkpoint 和 DLQ?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:供应商同步是长任务且外部不稳定。Raw Snapshot 用于追溯和回放;Checkpoint 用于断点续跑;DLQ 用于保存字段缺失、映射失败、发布失败等可运营问题单。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:408。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-073:如何保证商品发布后搜索、缓存、计价上下文最终一致?营销活动如何协同?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-073 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:414 |
题干与约束
如何保证商品发布后搜索、缓存、计价上下文最终一致?营销活动如何协同?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:正式表、发布记录、发布快照和 Outbox 在同一事务内写入;搜索、缓存、计价上下文和数据平台通过 Outbox 异步消费,按 event_id 和 publish_version 幂等处理,失败进入重试、DLQ 或 product_compensation_task。供给平台不直接写搜索索引、不直接写最终价格,也不直接改订单。营销活动是控制面协同:供给平台可以发起圈品、活动资格和活动绑定命令,营销系统负责活动规则、预算、券、补贴、营销库存和最终优惠计算。订单创建只相信当时的商品、报价、履约和退款快照,不回读最新商品解释历史订单。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:414。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-074:商品供给运营平台、商品生命周期和库存系统如何联动?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-074 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:418 |
题干与约束
商品供给运营平台、商品生命周期和库存系统如何联动?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:创建和修改库存属于供给运营平台的业务工作,但不属于供给运营平台的数据事实。供给平台是库存变更的控制面,负责表单、导入、审批、任务、错误文件、可售诊断和审计;库存系统是库存事实的数据面,负责 inventory_config、inventory_balance、券码池、预占记录、状态机和账本流水。供给平台治理变更是否可以发布,商品生命周期控制正式商品何时在线、下线、结束和封禁,库存系统控制某个范围内是否有可承诺资源,营销系统控制活动、券、补贴、预算和优惠规则。发布成功不等于可售成功,Publish 只写正式商品、发布版本、交易契约和 Outbox;库存通过 CreateInventory、AdjustInventory、ImportCodeBatch、GenerateCodeBatch 等命令异步创建或调整库存实例,再发布 InventoryReady/InventoryChanged/InventoryFailed。最终由可售投影合成 product_status + inventory_status + price_status + marketing_status + fulfillment_status + channel_policy + risk_status,告诉搜索、缓存和详情页是否可卖。供给后台不能直接改库存余额,也不能直接写营销优惠计算结果;库存系统也不能绕过审核和发布版本直接改商品生命周期。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:418。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-075:库存运营任务为什么要单独建模?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-075 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:422 |
题干与约束
库存运营任务为什么要单独建模?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考要点:库存运营任务不是库存扣减本身,而是库存变更进入平台的控制面。简单数量制库存可以随商品发布自动创建,但也应该由供给平台发起 CreateInventory 命令,库存系统幂等创建库存实例和账本;后台补货、调库存、锁库存要有权限、审批、原因和审计;手动上传券码要有文件任务、行级错误、重复码校验和错误文件;系统生码要有批次、规则、数量、有效期和幂等恢复;门店 / 日期 / 时段库存要支持批量物化和局部调整。供给平台负责 INVENTORY_CREATE / INVENTORY_ADJUST / CODE_IMPORT / CODE_GENERATE / TIME_STORE_STOCK_MATERIALIZE 等任务的入口、进度、补偿和审计,库存系统负责余额、码池状态机、预占、扣减、释放和账本。关键是每层都要有幂等键:发布创建库存用 publish_id + sku_id + scope,行级任务用 task_id + item_no,券码用 batch_id + code_hash,库存命令用 operation_id。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:422。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-076:为什么不能把 100 万酒店同步设计成一个单进程长循环?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-076 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:440 |
题干与约束
为什么不能把 100 万酒店同步设计成一个单进程长循环?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:因为任务运行时间长,中途机器重启、供应商超时、进程发布、网络抖动的概率都很高。单进程长循环的进度通常在内存里,失败后只能从头跑,排查也困难。更合理的设计是 Task + Batch + Checkpoint + DLQ:任务先落库,执行过程持续推进 checkpoint,失败数据进入 DLQ,机器重启后从 checkpoint 恢复。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:440。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-077:Task 和 Batch 有什么区别?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-077 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:444 |
题干与约束
Task 和 Batch 有什么区别?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:Task 是任务定义,描述“同步什么、怎么同步、什么时候同步”,比如某供应商酒店全量同步;Batch 是一次具体执行,描述“这一次跑到了哪里、成功多少、失败多少、当前 worker 是谁、租约什么时候过期”。一个 Task 会产生多次 Batch。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:444。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-078:Checkpoint 是什么,什么时候更新?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-078 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:448 |
题干与约束
Checkpoint 是什么,什么时候更新?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:Checkpoint 是任务进度水位,例如当前城市、页码、供应商 cursor、最后处理成功的供应商酒店 ID。它用于断点续跑。推荐先处理本页数据,再推进 checkpoint。这样即使机器在中间宕机,最多重复处理上一页,不会跳过未处理数据。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:448。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-079:worker 如何抢占任务?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-079 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:454 |
题干与约束
worker 如何抢占任务?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:通过数据库 CAS 抢占。worker 执行一条带条件的 UPDATE,只有 status=PENDING 或 status=RUNNING AND lease_until < NOW() 的 batch 才能被抢占。rows_affected=1 才说明抢占成功,其他 worker 必须退出。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:454。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-080:worker_id 和 lease_token 的区别是什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-080 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:458 |
题干与约束
worker_id 和 lease_token 的区别是什么?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:worker_id 标识执行器实例,通常由服务名、机器名或容器名、进程号、启动时间组成,方便排查和监控。lease_token 标识一次抢占行为,每次抢占都重新生成。关键写操作必须同时校验 batch_id + worker_id + lease_token,防止旧 worker 恢复后覆盖新 worker 的进度。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:458。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-081:心跳、租约、checkpoint 分别解决什么问题?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-081 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:462 |
题干与约束
心跳、租约、checkpoint 分别解决什么问题?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:心跳说明 worker 是否还活着;租约说明当前任务执行权属于谁;checkpoint 说明任务恢复时从哪里继续。心跳正常不代表任务在前进,所以还要看 last_checkpoint_at。租约过期才允许新 worker 抢占,checkpoint 用于恢复位置。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:462。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-082:机器重启后怎么恢复?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-082 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:466 |
题干与约束
机器重启后怎么恢复?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:机器重启后原 worker 不再续租,lease_until 到期。新 worker 通过 CAS 抢占过期 batch,读取 end_checkpoint,从对应城市、页码或 cursor 继续。由于可能重复处理上一页,所以落库必须基于 supplier_id + supplier_resource_code + supplier_product_code 做幂等。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:466。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-083:旧 worker 在长 GC 或网络抖动后恢复了怎么办?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-083 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:470 |
题干与约束
旧 worker 在长 GC 或网络抖动后恢复了怎么办?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:旧 worker 恢复后可能以为自己还拥有任务。所有续租、checkpoint、发布和结束任务的 SQL 都必须带 worker_id + lease_token 条件。如果更新影响行数为 0,说明租约已经丢失,旧 worker 必须停止执行,不能继续写平台表。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:470。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-084:上一次任务还没跑完,又下发了一次任务怎么办?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-084 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:476 |
题干与约束
上一次任务还没跑完,又下发了一次任务怎么办?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:要用显式互斥策略。默认 SKIP_IF_RUNNING,如果已有同 task_code 的 PENDING/RUNNING batch,新任务直接跳过;人工强制重跑可使用 CANCEL_PREVIOUS;只有数据范围不重叠时才允许 ALLOW_PARALLEL。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:476。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-085:心跳正常但 checkpoint 长时间不动,说明什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-085 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:480 |
题干与约束
心跳正常但 checkpoint 长时间不动,说明什么?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:说明 worker 还活着,但任务可能卡在某个阶段,例如供应商慢请求、对象存储写入慢、数据库锁等待、发布阻塞。此时不应立即抢占,而应告警并结合 last_heartbeat_stage 定位卡点。只有租约过期才允许新 worker 抢占。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:480。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-086:checkpoint 更新失败怎么办?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-086 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:484 |
题干与约束
checkpoint 更新失败怎么办?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:如果本页处理成功但 checkpoint 更新失败,下次恢复可能重复处理本页。因此页内落库和发布必须幂等。相反,不能先更新 checkpoint 再处理数据,否则宕机会跳过未处理页面。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:484。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-087:任务被人工取消时 worker 还在跑,怎么停?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-087 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:488 |
题干与约束
任务被人工取消时 worker 还在跑,怎么停?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:worker 不应该只在启动时读取状态,而要在每页处理前后检查 batch status。如果发现 CANCELLED,停止继续拉供应商,不再发布新数据,只做必要的清理和日志记录。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:488。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-088:Raw Snapshot 的价值是什么?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-088 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:494 |
题干与约束
Raw Snapshot 的价值是什么?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:Raw Snapshot 是供应商原始响应的证据,不是平台商品模型。它用于排查线上问题、回放同步、验证标准化规则、做 diff,也能区分是供应商数据错误还是平台映射错误。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:494。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-089:为什么需要 Diff?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-089 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:498 |
题干与约束
为什么需要 Diff?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:同步成功不等于应该发布。Diff 用来比较标准化后的数据和当前线上发布版本,识别字段变化、图片变化、坐标变化、房型变化和可售变化。低风险变化可以自动发布,高风险变化进入审核或 DLQ。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:498。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-090:DLQ 为什么建议用 MySQL,而不是只用消息队列?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-090 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:502 |
题干与约束
DLQ 为什么建议用 MySQL,而不是只用消息队列?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:供应商同步失败往往不是简单消息消费失败,而是字段缺失、城市映射失败、价格异常、发布失败等需要人工修复、状态流转和审计的问题。MySQL DLQ 可以作为权威问题单,支持查询、分派、重试、忽略、修复和报表。消息队列 DLQ 可以做短期缓冲,但不适合作为运营修复台账。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:502。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-091:worker 可以从 Redis 中抢占任务吗?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-091 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:512 |
题干与约束
worker 可以从 Redis 中抢占任务吗?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:可以,但我会把 Redis 作为加速锁或短租约,不把它作为唯一权威状态。任务状态、checkpoint、统计、DLQ 和审计仍然落 MySQL。Redis 可以用 SET lock_key value NX EX 300 抢锁,用 Lua 保证续租和释放的原子性。但真正开始执行前仍要更新 MySQL batch 的 worker_id + lease_token + lease_until,避免 Redis 主从切换、锁丢失或网络分区导致状态不可追溯。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:512。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-092:为什么商品供给不能直接写商品正式表?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-092 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:530 |
题干与约束
为什么商品供给不能直接写商品正式表?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:因为供给入口产生的是未校验、未审核、未发布的数据。直接写正式表会让半成品商品被搜索、下单或履约系统读取。正确做法是先写 Draft / Staging,通过校验、审核和发布事务后再写正式表。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:530。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-093:供应商同步是不是商品供给链路的一部分?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-093 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:534 |
题干与约束
供应商同步是不是商品供给链路的一部分?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:是。供应商同步是商品供给的一种自动化入口,但不是全部。统一供给平台要承接人工创建、批量导入、运营编辑、库存创建 / 修改和供应商同步。供应商同步因为有长任务、Checkpoint、Raw Snapshot、Worker 租约和新鲜度问题,所以需要专项链路。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:534。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-094:批量导入为什么要支持部分成功?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-094 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:538 |
题干与约束
批量导入为什么要支持部分成功?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:大批量导入中少量错误很常见。如果 10 万行因为 100 行失败全部回滚,运营效率会非常低。更合理的是行级状态,成功项继续发布,失败项生成错误文件,运营修复后重新提交。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:538。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-095:运营编辑如何防止覆盖供应商同步的数据?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-095 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:542 |
题干与约束
运营编辑如何防止覆盖供应商同步的数据?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:要定义字段主导权。平台运营主导的标题、卖点、活动标签不应被供应商自动覆盖;供应商主导的库存和价格可以按策略更新;高风险字段进入审核。运营人工覆盖供应商字段时要记录原因、责任人和保护期。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:542。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-096:审核通过为什么不等于商品可售?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-096 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:546 |
题干与约束
审核通过为什么不等于商品可售?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:审核只说明变更可以发布,真正可售还依赖商品主数据、Offer、库存、Input Schema、履约规则、退款规则、搜索索引和缓存刷新都就绪。因此发布后还要做可售校验和下游刷新补偿。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:546。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-097:如何保证商品发布和搜索缓存一致?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-097 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:550 |
题干与约束
如何保证商品发布和搜索缓存一致?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:商品正式表更新和 Outbox 事件写入同一个事务。事务提交后,由异步消费者刷新 ES、缓存、计价上下文和数据平台。涉及营销活动时,由供给平台发起活动绑定、圈品或营销资格命令,营销系统负责规则、预算、券和优惠计算。刷新或协同失败进入补偿任务,保证最终一致。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:550。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-098:为什么订单要保存商品快照?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-098 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:554 |
题干与约束
为什么订单要保存商品快照?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:商品会持续变化,包括价格、标题、履约规则和退款规则。如果历史订单回读最新商品配置,会导致售后争议和财务对不上。创单时必须保存商品快照、报价快照、履约契约和退款规则快照。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:554。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-099:Draft 和 Staging 有什么区别?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-099 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:558 |
题干与约束
Draft 和 Staging 有什么区别?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:Draft 面向编辑过程,允许运营反复保存、撤销和补充信息,不代表一次可发布变更。Staging 面向发布过程,保存已经标准化、校验过、可审核的候选数据。Draft 更像工作区,Staging 更像发布前的候选版本,两者都不能直接被 C 端读取。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:558。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-100:为什么批量导入需要 Task 和 Task Item 两层模型?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-100 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:562 |
题干与约束
为什么批量导入需要 Task 和 Task Item 两层模型?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:Task 描述一次批量动作的整体状态,例如总行数、成功数、失败数和当前阶段;Task Item 描述每一行、每个商品、每个 Offer 的处理结果。没有 Item 层,就无法定位“第几行为什么失败”,也无法支持部分成功、错误文件和行级重试。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:562。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-101:供给任务如何做幂等?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-101 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:566 |
题干与约束
供给任务如何做幂等?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:入口层要有业务幂等键,例如 task_type + trigger_id、文件 Hash、供应商批次号或变更单号。发布层要用 publish_id、base_publish_version 和唯一约束防止重复写正式表。Outbox 消费侧也要支持事件幂等,避免重复刷新缓存、索引或下游配置。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:566。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-102:大文件批量导入如何避免内存爆和长事务?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-102 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:570 |
题干与约束
大文件批量导入如何避免内存爆和长事务?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:上传后先落对象存储,异步流式解析文件,按批次写入 Task Item,不把全文件一次性加载到内存。校验和标准化由 Worker 分片处理,发布时按商品或变更单分批提交,避免一个 10 万行文件变成一个超大事务。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:570。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-103:商品供给链路中哪些校验必须前置?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-103 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:574 |
题干与约束
商品供给链路中哪些校验必须前置?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:字段格式、必填项、类目属性、SPU / SKU / Offer 关系、价格、库存来源、履约规则、退款规则、渠道和站点可售性都要前置校验。尤其是交易契约类字段不能只在下单时兜底,否则线上商品看似发布成功,实际无法购买或无法履约。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:574。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-104:类目模板变更后,历史商品怎么处理?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-104 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:578 |
题干与约束
类目模板变更后,历史商品怎么处理?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:类目模板要版本化,历史商品保留创建或上次发布时的模板版本。模板升级后可以触发质量巡检或迁移任务,对缺失新必填属性的商品标记风险、限制重新发布或要求运营补齐,不能静默把历史商品全部判为非法。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:578。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-105:哪些运营变更需要强审核?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-105 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:582 |
题干与约束
哪些运营变更需要强审核?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:高风险字段需要强审核,例如价格大幅调整、批量下架、退款规则收紧、履约方式变更、类目迁移、供应商字段覆盖、C 端展示敏感文案和活动标签。系统可以用 Diff + 风险评分决定是否自动通过、二次确认或进入人工审核。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:582。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-106:为什么发布时要校验 base_publish_version?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-106 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:586 |
题干与约束
为什么发布时要校验 base_publish_version?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:供给变更通常基于某个旧版本编辑。如果发布时正式商品已经被其他任务修改,直接覆盖会丢失别人刚发布的变更。base_publish_version 用来做乐观并发控制,发现版本不一致时进入冲突处理,让运营选择合并、放弃或重新编辑。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:586。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-107:商品发布失败如何回滚?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-107 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:590 |
题干与约束
商品发布失败如何回滚?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:发布要尽量把正式表变更和 Outbox 写入放在同一事务中。事务内失败直接回滚;事务后下游刷新失败不回滚主数据,而是进入补偿队列和 DLQ。对于已发布但业务上要撤回的变更,应通过新的反向发布版本恢复,而不是手工改库。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:590。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-108:下游搜索、缓存或计价上下文刷新失败怎么办?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-108 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、价格建模、规则计算 |
| 场景标签 | 商品供给、价格试算 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:594 |
题干与约束
下游搜索、缓存或计价上下文刷新失败怎么办?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:发布事务提交后通过 Outbox 事件驱动读侧投影刷新。消费者失败时记录重试次数、错误原因和 TraceID,超过阈值进入 DLQ,由补偿任务或人工修复重新投递。运营后台要展示“商品已发布但部分投影未同步”的状态;如果是营销活动协同失败,也要展示活动绑定失败原因和重试入口,避免误判为完全成功。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:594。本章不依赖旧 Part Four 文件链接。
相关章节:营销与计价系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-109:错误文件应该包含哪些信息?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-109 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:598 |
题干与约束
错误文件应该包含哪些信息?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:错误文件要能让运营直接修复问题,至少包含原始行号、商品标识、字段名、错误类型、错误原因、修复建议和是否可重试。对于映射类错误,还应给出候选类目、候选属性或合法枚举值,而不是只写“导入失败”。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:598。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-110:如何设计商品质量分?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-110 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:602 |
题干与约束
如何设计商品质量分?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:质量分可以从内容完整度、类目属性完整度、图片质量、价格有效性、库存可售性、履约规则、退款规则、供应商新鲜度和历史投诉率等维度计算。质量分既用于运营看板,也可以驱动自动下架、限制投放、召回补录和供应商质量考核。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:602。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-111:运营后台最需要展示哪些链路状态?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-111 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:606 |
题干与约束
运营后台最需要展示哪些链路状态?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:运营后台要展示任务进度、成功失败数量、当前处理阶段、失败明细、审核状态、发布版本、下游同步状态、错误文件下载、可重试入口和质量巡检结果。后台不是简单 CRUD,而是要把长链路的不确定性运营化。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:606。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-112:两个运营同时编辑同一个商品怎么办?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-112 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:610 |
题干与约束
两个运营同时编辑同一个商品怎么办?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:可以用草稿锁、版本号和 Diff 合并共同解决。编辑态可以提示“商品已被某人编辑”,提交时用 base_publish_version 做并发校验;若版本冲突,则展示双方 Diff,让运营选择覆盖、合并或重新基于最新版本编辑。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:610。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-113:供应商价格和库存很新,但运营有人工覆盖,系统听谁的?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-113 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:614 |
题干与约束
供应商价格和库存很新,但运营有人工覆盖,系统听谁的?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:要按字段主导权处理。价格、库存通常由供应商或库存系统主导,但平台可以允许有期限、有原因、有审批记录的人工覆盖。覆盖期内供应商同步只记录 Diff 或进入待审核,覆盖到期后再恢复自动更新,避免人工修复被下一次同步冲掉。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:614。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-114:商品上下架和发布版本是什么关系?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-114 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 基础 |
| 建议用时 | 15 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:618 |
题干与约束
商品上下架和发布版本是什么关系?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:上下架是商品生命周期状态,发布版本是商品数据变更记录。一次发布可以改变上下架状态,但两者不能混为一谈。商品可以有多个历史发布版本,当前只有一个生效版本;上下架还要受质量、库存、价格、渠道、风控和审核状态共同约束。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:618。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-115:如何支持定时发布和灰度发布?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-115 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:622 |
题干与约束
如何支持定时发布和灰度发布?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:变更单可以携带生效时间、渠道范围、城市范围、人群范围或流量比例。到达生效时间后由发布调度器执行,先写正式版本和 Outbox,再按范围刷新搜索、缓存和计价上下文;如涉及营销活动,同步发起营销活动配置或资格变更命令。灰度期间要能观察转化、投诉和履约异常,必要时快速撤回。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:622。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-116:自动下架会带来什么风险?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-116 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:626 |
题干与约束
自动下架会带来什么风险?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:自动下架能阻止缺图、缺价、无库存或无履约规则的商品继续售卖,但也可能误伤 GMV 和活动资源。设计时要区分阻断级、告警级和观察级问题;高风险商品自动下架,低风险商品先告警并给运营修复窗口,同时保留审计和恢复入口。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:626。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-117:供给链路如何做审计追踪?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-117 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、供给治理、任务编排 |
| 场景标签 | 商品供给、运营后台 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:630 |
题干与约束
供给链路如何做审计追踪?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:每次变更都要记录操作者、来源入口、TraceID、原始输入、标准化结果、Diff、审核记录、发布版本、下游事件和补偿记录。审计不是只看最终商品表,而是要能还原“谁在什么时候因为什么把商品改成了什么”。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:630。本章不依赖旧 Part Four 文件链接。
相关章节:商品供给、运营与生命周期治理。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-SUPPLY-118:如果面试官让你一句话概括架构,你怎么说?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-SUPPLY-118 |
| 题型 | 系统设计题 |
| 范围 | 领域级:商品、库存、营销、计价与供给治理 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 领域建模、商品建模、数据治理 |
| 场景标签 | 商品供给、商品主数据 |
| 能力域 | 商品、供给、库存、营销与计价 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:634 |
题干与约束
如果面试官让你一句话概括架构,你怎么说?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:我会说商品供给与运营链路的核心是“统一入口、暂存隔离、质量校验、风险审核、版本发布、事件同步、失败补偿和运营可观测”。它的目标不是让运营能改商品,而是让商品从各种入口进入平台后,稳定、可控、可追溯地变成可售供给。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:634。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
搜索、购物车、订单与支付
本节覆盖搜索、购物车、订单与支付。
专题答辩资料:搜索与导购专题
搜索系统的核心是把商品主数据转化为适合检索和排序的读模型。它不应该成为商品权威存储,也不应该承担交易状态。
高频追问:
- 商品变更如何同步到搜索索引?
- 深分页为什么慢,如何优化?
- 多条件筛选和排序如何设计索引?
- 搜索结果和商品状态不一致怎么办?
- 搜索服务故障时如何降级?
答题重点是区分主数据和读模型:商品中心负责权威事实,搜索系统负责检索体验,二者通过异步同步、补偿重建和一致性校验维持可接受的一致性。
专题答辩资料:购物车与结算专题
购物车偏用户体验,结算偏交易前校验。购物车可以允许一定程度的弱一致,结算必须重新校验商品、价格、库存、营销和地址配送。
高频追问:
- 未登录购物车和登录购物车如何合并?
- 购物车数据放 Redis 还是 MySQL?
- 结算页为什么不能完全相信购物车价格?
- 提交订单前要校验哪些内容?
- 重复提交订单如何防止?
回答时要强调:购物车不是订单。购物车里的价格、库存和优惠都只是用户侧展示,结算和下单必须重新试算和锁定。
专题答辩资料:订单系统专题
订单系统的核心是状态机和交易事实。它协调商品快照、价格快照、库存预占、营销核销、支付状态和履约状态,但不应该吞掉所有领域逻辑。
高频追问:
- 订单状态机如何设计?
- 下单链路如何保证幂等?
- 库存预占、支付成功和订单关闭如何协作?
- 订单列表越来越慢怎么办?
- 订单数据如何分库分表和归档?
订单题最忌讳只画组件图。更好的回答是先讲状态流,再讲数据模型、核心事务、异步事件和补偿任务。
专题答辩资料:支付系统专题
支付系统的核心是资金状态可信。支付回调天然会重复、乱序和延迟,因此支付单状态机、幂等处理、渠道对账和人工处理入口都很重要。
高频追问:
- 支付回调重复怎么办?
- 用户支付成功但本地订单未更新怎么办?
- 支付单和订单是什么关系?
- 退款如何设计状态机?
- 多支付渠道如何抽象?
支付题的底线是不要把“收到回调”直接等同于“订单完成”。系统必须校验支付单、订单、金额、渠道流水和状态迁移是否合法。
专题答辩资料:交易主链路串联模板
如果让你设计电商交易链路,可以这样组织:
- 搜索和导购通过 ES / 缓存提供高性能读路径。
- 购物车保存用户意图,但不作为最终交易依据。
- 结算页重新校验商品、库存、营销、价格和配送。
- 下单接口使用幂等键创建订单,并预占库存、锁定优惠、保存快照。
- 支付系统通过支付单和渠道回调推进资金状态。
- 订单根据支付事件更新状态,并通过消息驱动履约、积分、通知等下游。
- 对账、补偿和 DLQ 兜底跨系统不一致。
这条线说清楚,面试官通常就能看到你是按业务闭环理解系统,而不是按组件清单拼答案。
专题答辩资料:bool 查询语义高频点
bool 查询语义 是面试高频点:must 参与评分,适合承载关键词相关性;filter 不计分且可缓存,适合承载「硬门槛」。实践中常见错误是把「品牌=耐克」放在 must 里参与打分,导致品牌词意外影响相关性曲线;更推荐 品牌进 filter,把「品牌相关 boost」交给 function_score 或在精排阶段处理。另一个错误是把大量 低选择性 条件全部堆在 must,使 _score 退化为常数,精排阶段只能「白手起家」——这会放大后续服务压力。
专题答辩资料:搜索深分页标准答法
面试标准答法:C 端深分页用 search_after + 稳定 tie-breaker(如 spu_id);随机跳页用产品约束(最多第 N 页)或改写交互。
专题答辩资料:搜索系统边界追问
面试追问小结(边界类):「搜索是不是商品中心的一部分?」标准回答是 否:商品中心是主数据真相源;搜索维护 派生读模型 以优化检索与排序特征。「为什么不在 ES 里算最终价?」因为价格解释依赖会员、渠道、动态规则与版本,索引天然滞后;列表允许弱一致,交易必须强一致。「推荐能否直接调用搜索接口拿候选?」不建议默认耦合:推荐候选集生成逻辑与搜索意图不同,但可以在 特征与埋点层复用。
专题答辩资料:搜索导购一句话总结
若你用一句话向面试官总结本章,可以这样说:搜索索引解决「找得到与排得动」,Hydrate 解决「展示得像且不太假」;交易链路再用强一致服务解决「买得对」。 三条链路各司其职,边界清晰,才能把高并发读路径做成 可演进、可回滚、可观测 的工程系统,而不是一堆 DSL 拼接技巧。
专题答辩资料:购物车与结算域追问答法
面试映射:面试官若问「购物车要不要用分布式锁」,优先回答「行级乐观锁 + Redis 原子写足够,锁购物车会放大死锁与热点」;若问「结算页是不是微服务必须的拆分点」,可以回答「逻辑边界必须清晰,物理部署可渐进」——先把包边界与数据边界立住,比一上来拆两个集群更有性价比。
专题答辩资料:订单系统白板答辩串联
面试与答辩串联:若需要在白板前讲解订单系统,推荐顺序为:先画主状态机,再画创单 Saga 时序,再补幂等键与补偿表,最后点出「订单不直连渠道」。评委追问超卖时,回到库存 Reserve 与 CAS;追问重复支付时,回到支付幂等与订单状态机;追问消息丢失时,回到 Outbox。把四条线闭合,通常比堆技术名词更有说服力。
专题答辩资料:支付系统子系统职责对照
子系统职责对照(面试与评审常用):
| 子系统 | 核心职责 | 关键数据 | 典型反模式 |
|---|---|---|---|
| 账户系统 | 余额、冻结、流水、充值提现 | 账户余额、冻结单、账务流水 | 把渠道手续费写进用户余额宽表 |
| 支付网关 | 路由、报文、签名、重试 | 渠道请求 / 响应摘要、路由决策 | 在网关里改支付单状态机 |
| 支付核心 | 支付单、退款单、幂等、Outbox | payment、refund、outbox | Handler 直连第三方 SDK |
| 清结算引擎 | 分账、批次、结算周期 | 分账明细、结算单、提现单 | 清结算回调里直接操作支付单 |
| 风控系统 | 限额、名单、挑战 | 风控决策流水 | 风控结果不落库导致无法复盘 |
专题答辩资料:支付系统边界反模式
反模式清单(面试与评审可直用):在支付服务里写供应商下单、在支付服务里计算运费、在支付回调里直接改 SKU 库存、在支付库里维护商品税率版本。它们共同症状是:支付发布频率被迫与业务域绑定,任何小改动都触碰资金链路。
专题答辩资料:搜索导购链路总结
面试时可以这样总结:搜索导购不是单纯 ES 查询,而是“召回 + 排序 + 商品补齐 + 库存价格营销融合 + 降级”的完整读链路。
附录:常见技术方案速查
Q-ECOM-TRADE-001:电商搜索引擎的架构设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-001 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:20 |
题干与约束
电商平台每天有百万级搜索请求,需要支持全文搜索、属性筛选、排序。如何设计电商搜索引擎的整体架构?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台每天有百万级搜索请求,需要支持全文搜索、属性筛选、排序。如何设计电商搜索引擎的整体架构?
答案:
问题分析: 电商搜索的核心要素:
- 海量数据(千万级商品)
- 复杂查询(关键词+品类+价格区间+品牌)
- 实时性(商品上下架实时更新)
- 相关性排序(搜索“手机“优先展示热门手机)
- 性能要求(毫秒级响应)
方案一:基于MySQL的搜索
核心思想: 使用MySQL的LIKE查询和索引。
实现:
SELECT * FROM products
WHERE title LIKE '%手机%'
AND category_id = 10
AND price BETWEEN 1000 AND 5000
ORDER BY sales DESC
LIMIT 20;
优点:
- 实现简单
- 无需额外组件
缺点:
- LIKE ‘%keyword%’ 无法使用索引,性能差
- 不支持中文分词
- 不支持相关性排序
- 并发能力弱
适用场景:
- 小型电商(商品<10万)
- 简单搜索
方案二:Elasticsearch搜索(推荐)
核心思想: 使用专业搜索引擎ES,支持全文搜索和复杂查询。
架构:
用户搜索
→ 搜索服务(API层)
→ Elasticsearch集群
→ 返回结果
数据同步:
商品变更 → Kafka → 同步Worker → ES索引
ES索引设计:
{
"mappings": {
"properties": {
"productId": {"type": "keyword"},
"title": {
"type": "text",
"analyzer": "ik_max_word",
"fields": {
"keyword": {"type": "keyword"}
}
},
"brand": {"type": "keyword"},
"categoryId": {"type": "long"},
"price": {"type": "double"},
"sales": {"type": "long"},
"stock": {"type": "long"},
"onSale": {"type": "boolean"},
"attrs": {
"type": "nested",
"properties": {
"name": {"type": "keyword"},
"value": {"type": "keyword"}
}
},
"createdAt": {"type": "date"}
}
}
}
搜索查询:
{
"query": {
"bool": {
"must": [
{"match": {"title": "手机"}}
],
"filter": [
{"term": {"onSale": true}},
{"term": {"categoryId": 10}},
{"range": {"price": {"gte": 1000, "lte": 5000}}},
{"term": {"brand": "Apple"}}
]
}
},
"sort": [
{"sales": {"order": "desc"}},
{"_score": {"order": "desc"}}
],
"from": 0,
"size": 20
}
优点:
- 性能高(分布式搜索)
- 支持复杂查询
- 中文分词
- 相关性排序
- 实时性好
缺点:
- 运维成本高
- 数据同步复杂
方案三:混合架构
核心思想: ES负责搜索,MySQL负责详情查询。
流程:
1. 用户搜索"iPhone"
2. ES返回productId列表:[123, 456, 789]
3. 根据productId批量查询MySQL获取完整商品信息
4. 组装返回
优点:
- ES只存储搜索字段,节省空间
- MySQL保证数据完整性
- 职责分离
缺点:
- 多次查询,延迟增加
- 实现复杂
方案对比:
| 方案 | 性能 | 功能 | 运维成本 | 适用规模 |
|---|---|---|---|---|
| MySQL | ★★☆☆☆ | ★★☆☆☆ | ★★★★★ | 小型 |
| Elasticsearch | ★★★★★ | ★★★★★ | ★★★☆☆ | 大型 |
| 混合架构 | ★★★★☆ | ★★★★★ | ★★☆☆☆ | 超大型 |
推荐方案: 采用Elasticsearch。
实施要点:
-
索引设计:
索引名称:products_v1 分片数:5(根据数据量调整) 副本数:2(高可用) 字段类型选择: - keyword:不分词(品牌、类目ID) - text:分词(标题、描述) - nested:嵌套对象(属性列表) -
数据同步:
实时同步: - 商品创建/更新 → 发送Kafka消息 - 同步Worker消费消息 → 更新ES - 延迟 < 5秒 全量同步(兜底): - 每天凌晨全量同步 - 对比MySQL和ES差异 - 修复不一致数据 -
搜索优化:
查询缓存: - 热门搜索词缓存(Redis) - TTL 5分钟 搜索建议: - 输入"iph" → 建议"iPhone 15" - 使用completion suggester 拼写纠错: - 输入"ipone" → 自动纠正为"iPhone" -
性能优化:
分页优化: - 浅分页:from+size(前10页) - 深分页:search_after(10页以后) 字段裁剪: - 只返回必要字段 - _source: ["productId", "title", "price"] 路由优化: - 按类目路由到不同分片 -
监控告警:
监控指标: - 搜索QPS - 搜索延迟P99 - ES集群健康度 - 索引大小 告警: - 搜索延迟 > 500ms - ES集群RED状态 - 数据同步延迟 > 1分钟
延伸思考:
- 如何设计搜索的AB测试(不同排序策略)?
- 搜索无结果时如何处理(推荐、纠错)?
- 如何防止恶意搜索(刷流量、爬虫)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:20。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-002:搜索相关性排序算法设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-002 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:257 |
题干与约束
用户搜索“手机“,返回1000个结果,如何排序保证用户最想要的商品排在前面?请设计相关性排序算法。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户搜索“手机“,返回1000个结果,如何排序保证用户最想要的商品排在前面?请设计相关性排序算法。
答案:
问题分析: 相关性排序的核心要素:
- 文本相关性(标题匹配度)
- 商品热度(销量、点击量)
- 商品质量(评分、评价数)
- 商品新鲜度(新品)
- 个性化(用户偏好)
方案一:单一得分排序
核心思想: 只按一个维度排序(如销量)。
实现:
SELECT * FROM products
WHERE title LIKE '%手机%'
ORDER BY sales DESC
LIMIT 20;
优点:
- 简单
- 性能好
缺点:
- 忽略相关性(标题匹配度差的商品可能排前面)
- 马太效应(热门商品更热门)
方案二:多因子加权(推荐)
核心思想: 综合多个因子,加权计算总分。
算法:
总分 = w1 × 文本相关性得分 +
w2 × 销量得分 +
w3 × 评分得分 +
w4 × 新鲜度得分
各项得分计算:
1. 文本相关性(ES _score):
- 标题完全匹配:1.0
- 标题部分匹配:0.5-0.9
- 只在描述中匹配:0.1-0.4
2. 销量得分:
- 归一化:sales_score = log(sales + 1) / log(max_sales)
- 取对数避免马太效应
3. 评分得分:
- rating_score = (rating / 5.0) × log(review_count + 1)
- 考虑评分和评价数
4. 新鲜度得分:
- freshness_score = 1.0 / (days_since_published + 1)
- 新品加权
权重设置:
w1 = 0.4(文本相关性最重要)
w2 = 0.3(销量)
w3 = 0.2(评分)
w4 = 0.1(新鲜度)
ES实现:
{
"query": {
"function_score": {
"query": {"match": {"title": "手机"}},
"functions": [
{
"field_value_factor": {
"field": "sales",
"modifier": "log1p",
"factor": 0.3
}
},
{
"field_value_factor": {
"field": "rating",
"factor": 0.2
}
},
{
"gauss": {
"createdAt": {
"origin": "now",
"scale": "30d",
"decay": 0.5
}
},
"weight": 0.1
}
],
"score_mode": "sum",
"boost_mode": "sum"
}
}
}
优点:
- 综合考虑多因素
- 可调整权重
- 效果好
缺点:
- 权重调优需要经验
- 计算复杂
方案三:机器学习排序(LTR)
核心思想: 使用机器学习模型预测点击率/转化率,按预测得分排序。
流程:
1. 特征工程:
- 文本特征:TF-IDF、BM25
- 商品特征:价格、销量、评分、库存
- 用户特征:历史行为、偏好品类
- 上下文特征:时间、地域
2. 训练数据:
- 正样本:用户点击/购买的商品
- 负样本:展示但未点击的商品
3. 模型训练:
- GBDT、XGBoost
- 或深度学习模型(Wide & Deep)
4. 在线预测:
- 搜索返回候选商品
- 模型预测点击率
- 按预测得分排序
优点:
- 效果最优
- 自动学习最优权重
- 支持个性化
缺点:
- 需要算法团队
- 需要大量训练数据
- 冷启动问题
方案对比:
| 方案 | 效果 | 实施难度 | 计算成本 | 个性化 |
|---|---|---|---|---|
| 单一得分 | ★★☆☆☆ | ★★★★★ | ★★★★★ | ★☆☆☆☆ |
| 多因子加权 | ★★★★☆ | ★★★☆☆ | ★★★★☆ | ★★☆☆☆ |
| 机器学习 | ★★★★★ | ★★☆☆☆ | ★★★☆☆ | ★★★★★ |
推荐方案: 采用多因子加权,逐步引入机器学习。
实施要点:
-
初期(多因子加权):
public double calculateScore(Product product, String keyword) { // 1. 文本相关性(ES返回) double textScore = product.getElasticSearchScore(); // 2. 销量得分 double salesScore = Math.log(product.getSales() + 1) / Math.log(maxSales); // 3. 评分得分 double ratingScore = (product.getRating() / 5.0) * Math.log(product.getReviewCount() + 1); // 4. 新鲜度得分 long daysSince = ChronoUnit.DAYS.between( product.getCreatedAt(), LocalDate.now() ); double freshnessScore = 1.0 / (daysSince + 1); // 5. 加权求和 return 0.4 * textScore + 0.3 * salesScore + 0.2 * ratingScore + 0.1 * freshnessScore; } -
权重调优:
AB测试: - A组:权重方案1(w1=0.4, w2=0.3, w3=0.2, w4=0.1) - B组:权重方案2(w1=0.5, w2=0.2, w3=0.2, w4=0.1) 评估指标: - 点击率(CTR) - 转化率(CVR) - 用户停留时长 选择效果最好的权重 -
个性化因子:
用户偏好品牌: if (user.favoriteBrands.contains(product.brand)) { score *= 1.2; // 加权20% } 用户价格偏好: if (product.price in user.priceRange) { score *= 1.1; } 用户浏览历史: if (user.recentlyViewedCategories.contains(product.category)) { score *= 1.15; } -
排序规则:
规则1:置顶广告位 - 前3个位置:竞价广告 - 标注"广告" 规则2:新品扶持 - 7天内新品得分 × 1.5 规则3:库存保护 - 库存 < 10件,降权(× 0.8) - 避免缺货商品排前面 -
监控与迭代:
监控指标: - 搜索结果点击率 - 搜索转化率 - 平均点击位置 定期优化: - 每月分析数据 - 调整权重 - 新增因子
延伸思考:
- 如何处理搜索作弊(刷销量、刷好评)?
- 长尾商品如何获得曝光机会?
- 如何设计搜索排序的解释性(为何这个商品排第一)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:257。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-003:搜索建议(Suggest)的实现
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-003 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:522 |
题干与约束
用户输入“iph“,搜索框下方实时展示“iPhone 15“、“iPhone 14“等建议。如何实现搜索建议功能?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户输入“iph“,搜索框下方实时展示“iPhone 15“、“iPhone 14“等建议。如何实现搜索建议功能?
答案:
问题分析: 搜索建议的核心要素:
- 实时性(输入即显示)
- 准确性(建议与输入相关)
- 热度排序(热门建议优先)
- 性能(毫秒级响应)
方案一:数据库LIKE查询
核心思想: 从数据库查询以输入开头的关键词。
实现:
-- 假设有关键词表
SELECT keyword, search_count
FROM search_keywords
WHERE keyword LIKE 'iph%'
ORDER BY search_count DESC
LIMIT 10;
优点:
- 实现简单
缺点:
- 性能差(每次输入都查库)
- 前缀索引占用空间
- 不支持中文拼音
方案二:Trie树(字典树)
核心思想: 将热门搜索词构建为Trie树,内存查询。
数据结构:
Trie树示例(存储iPhone, iPad, iMac):
root
|
i
/ \
P M
/| \
h a a
| | |
o d c
|
n
|
e
每个节点存储:
- 字符
- 是否是词的结尾
- 热度(search_count)
查询:
public List<String> suggest(String prefix) {
TrieNode node = root;
// 1. 定位到前缀节点
for (char c : prefix.toCharArray()) {
if (!node.children.containsKey(c)) {
return Collections.emptyList();
}
node = node.children.get(c);
}
// 2. DFS收集所有以该前缀开头的词
List<String> results = new ArrayList<>();
dfs(node, prefix, results);
// 3. 按热度排序
results.sort(Comparator.comparing(this::getHotness).reversed());
return results.subList(0, Math.min(10, results.size()));
}
优点:
- 速度快(内存查询)
- 空间效率高(共享前缀)
缺点:
- 不支持中文拼音
- 内存占用大(全量词库)
方案三:Elasticsearch Completion Suggester(推荐)
核心思想: 使用ES的completion类型,支持高效前缀匹配。
索引设计:
{
"mappings": {
"properties": {
"keyword": {
"type": "completion",
"analyzer": "simple",
"search_analyzer": "simple"
},
"weight": {"type": "integer"}
}
}
}
数据导入:
{
"keyword": {
"input": ["iPhone 15", "iPhone15", "苹果15"],
"weight": 10000
}
}
查询:
{
"suggest": {
"keyword-suggest": {
"prefix": "iph",
"completion": {
"field": "keyword",
"size": 10,
"skip_duplicates": true
}
}
}
}
优点:
- 性能极高(FST结构)
- 支持拼音、同义词
- 支持热度排序(weight)
- 分布式
缺点:
- 需要ES
方案对比:
| 方案 | 性能 | 功能 | 实施难度 | 适用规模 |
|---|---|---|---|---|
| 数据库LIKE | ★★☆☆☆ | ★★☆☆☆ | ★★★★★ | 小型 |
| Trie树 | ★★★★☆ | ★★★☆☆ | ★★★☆☆ | 中型 |
| ES Completion | ★★★★★ | ★★★★★ | ★★★★☆ | 大型 |
推荐方案: 采用ES Completion Suggester。
实施要点:
-
数据准备:
建议词来源: - 热门搜索词(用户历史搜索) - 商品标题(高销量商品) - 品牌名称 - 类目名称 - 运营配置词(促销活动) 权重设置: - 用户搜索频次作为权重 - 权重 = log(search_count + 1) -
拼音支持:
安装pinyin分词器: - elasticsearch-analysis-pinyin 索引配置: { "keyword": { "type": "completion", "analyzer": "pinyin_analyzer" } } 输入"pingguo" → 建议"苹果"、"iPhone" -
个性化建议:
用户维度: - 记录用户搜索历史(Redis) - 优先展示用户历史搜索 示例: 用户输入"ip" → ES返回:["iPhone 15", "iPad Pro", "iPod"] → 叠加用户历史:["iPhone 14"(历史搜索), "iPhone 15", "iPad Pro"] → 最终展示前10个 -
缓存策略:
热门建议缓存: - 缓存TOP 1000热门前缀的建议结果 - key: suggest:iph - value: ["iPhone 15", "iPhone 14", ...] - TTL: 10分钟 减少ES压力 -
建议词更新:
实时更新: - 用户搜索 → Kafka → 统计Worker → 更新ES 定时更新(每小时): - 统计最近1小时热搜词 - 更新权重 - 新增热搜词
延伸思考:
- 如何防止建议词中的敏感词?
- 搜索建议如何支持纠错(ipone → iPhone)?
- 如何设计多语言的搜索建议?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:522。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-004:商品筛选和多维度过滤的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-004 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:760 |
题干与约束
用户搜索“手机“后,可以按品牌、价格区间、屏幕尺寸、内存等多个维度筛选。如何设计筛选系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户搜索“手机“后,可以按品牌、价格区间、屏幕尺寸、内存等多个维度筛选。如何设计筛选系统?
答案:
问题分析: 筛选系统的核心要素:
- 动态筛选项(不同类目的筛选项不同)
- 多条件组合(品牌AND价格区间AND内存)
- 筛选项计数(显示每个选项的商品数量)
- 性能(实时计算筛选结果)
方案一:前端筛选
核心思想: 一次性返回所有结果,前端JavaScript筛选。
流程:
1. 搜索"手机" → 返回1000个商品(完整数据)
2. 用户选择"Apple" → 前端过滤,显示Apple的商品
3. 用户选择"8GB内存" → 再次前端过滤
优点:
- 后端简单
- 筛选响应快(无需请求后端)
缺点:
- 数据量大(传输1000个商品)
- 不适合大规模数据
- 筛选项计数不准(只能统计当前页)
适用场景:
- 数据量小(<100条)
方案二:后端动态查询(推荐)
核心思想: 每次筛选条件变化,重新查询后端。
ES查询:
{
"query": {
"bool": {
"must": [
{"match": {"title": "手机"}}
],
"filter": [
{"term": {"brand": "Apple"}},
{"range": {"price": {"gte": 5000, "lte": 10000}}},
{"term": {"attrs.内存": "8GB"}},
{"term": {"attrs.屏幕尺寸": "6.1英寸"}}
]
}
},
"aggs": {
"brands": {
"terms": {"field": "brand", "size": 20}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{"to": 1000},
{"from": 1000, "to": 3000},
{"from": 3000, "to": 5000},
{"from": 5000}
]
}
}
},
"from": 0,
"size": 20
}
优点:
- 精确筛选
- 支持筛选项计数(aggregation)
- 适合大数据量
缺点:
- 每次筛选都请求后端
- 延迟略高
方案三:预计算筛选项
核心思想: 提前计算每个筛选项的商品数量。
设计:
filter_facet(筛选项预计算)
├── category_id
├── filter_name(品牌、价格区间、属性)
├── filter_value
├── product_count(该筛选项的商品数量)
└── updated_at
示例数据:
category_id=10(手机), filter_name="品牌", filter_value="Apple", product_count=500
category_id=10, filter_name="价格", filter_value="5000-10000", product_count=300
前端展示:
品牌:
- Apple (500)
- 小米 (300)
- 华为 (250)
价格:
- 1000以下 (100)
- 1000-3000 (200)
- 3000-5000 (150)
- 5000以上 (300)
优点:
- 展示快(直接读缓存)
- 减少ES压力
缺点:
- 数据可能不准(预计算有延迟)
- 存储成本高
方案对比:
| 方案 | 性能 | 准确性 | 实施难度 | 适用场景 |
|---|---|---|---|---|
| 前端筛选 | ★★★★★ | ★★★☆☆ | ★★★★★ | 小数据 |
| 后端查询 | ★★★★☆ | ★★★★★ | ★★★☆☆ | 通用 |
| 预计算 | ★★★★★ | ★★★☆☆ | ★★☆☆☆ | 超大规模 |
推荐方案: 采用后端动态查询(ES Aggregation)。
实施要点:
-
筛选项配置:
category_filter_config(类目筛选配置) ├── category_id ├── filter_name(品牌、价格、属性名) ├── filter_type(TERM/RANGE/NESTED) ├── display_order(展示顺序) └── ... 示例: 手机类目: - 品牌(TERM) - 价格(RANGE: 0-1000, 1000-3000, ...) - 屏幕尺寸(NESTED: attrs.屏幕尺寸) - 内存(NESTED: attrs.内存) -
ES Aggregation查询:
public SearchResponse searchWithFilters( String keyword, Map<String, List<String>> filters ) { BoolQueryBuilder query = QueryBuilders.boolQuery() .must(QueryBuilders.matchQuery("title", keyword)); // 应用筛选条件 for (Map.Entry<String, List<String>> entry : filters.entrySet()) { String filterName = entry.getKey(); List<String> values = entry.getValue(); if (filterName.equals("brand")) { query.filter(QueryBuilders.termsQuery("brand", values)); } else if (filterName.equals("price")) { // 价格区间 for (String range : values) { String[] parts = range.split("-"); query.filter(QueryBuilders.rangeQuery("price") .gte(parts[0]).lte(parts[1])); } } else { // 属性筛选 query.filter(QueryBuilders.nestedQuery( "attrs", QueryBuilders.boolQuery() .must(QueryBuilders.termQuery("attrs.name", filterName)) .must(QueryBuilders.termsQuery("attrs.value", values)), ScoreMode.None )); } } // 聚合统计 SearchSourceBuilder source = new SearchSourceBuilder() .query(query) .aggregation(AggregationBuilders.terms("brands").field("brand")) .aggregation(AggregationBuilders.range("price_ranges") .field("price") .addUnboundedTo(1000) .addRange(1000, 3000) .addRange(3000, 5000) .addUnboundedFrom(5000)); return client.search(source); } -
前端交互:
URL设计: /search?q=手机&brand=Apple,小米&price=5000-10000&memory=8GB 前端: - 用户点击筛选项 → 更新URL → 请求后端 - 后端返回筛选结果 + 筛选项计数 - 前端更新展示 已选筛选展示: - 品牌:Apple × 小米 × - 价格:5000-10000 × - 内存:8GB × 点击 × 取消该筛选 -
性能优化:
筛选缓存: key: search:q=手机&brand=Apple&price=5000-10000 value: {商品列表, 筛选项计数} TTL: 5分钟 热门筛选组合预加载 -
筛选项排序:
排序规则: 1. 按配置的display_order 2. 品牌按热度(商品数量) 3. 价格区间固定顺序(低到高) 4. 属性按字母顺序
延伸思考:
- 如何设计筛选项的动态展示(只显示有商品的筛选项)?
- 筛选条件过多时如何优化性能?
- 如何设计筛选的撤销和重置功能?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:760。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-005:搜索结果的无结果优化
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-005 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1012 |
题干与约束
用户搜索“iPhne 15“(拼写错误),没有结果。如何优化无结果页,提升用户体验?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户搜索“iPhne 15“(拼写错误),没有结果。如何优化无结果页,提升用户体验?
答案:
问题分析: 无结果场景:
- 拼写错误(iPhne → iPhone)
- 搜索词过于精确(“iPhone 15 Pro Max 256GB 深空黑色”)
- 商品确实不存在
- 分词问题
优化策略:
- 自动纠错
- 模糊搜索
- 推荐相关商品
- 引导用户
方案一:简单提示
核心思想: 直接提示“没有找到相关商品“。
优点:
- 实现简单
缺点:
- 用户体验差
- 流失率高
方案二:拼写纠错(推荐)
核心思想: 检测拼写错误,自动纠正或建议正确词。
算法:
1. 编辑距离(Levenshtein Distance):
计算输入词和词库中词的编辑距离
编辑距离 <= 2 → 认为是拼写错误
示例:
"iPhne" vs "iPhone"
编辑距离 = 2(插入o,删除e)
2. 音似匹配(Soundex):
"fone" 和 "phone" 发音相似
3. 键盘距离:
"iPhne" 中 n 和 o 在键盘上相邻,可能是误按
ES实现:
{
"suggest": {
"text": "iPhne",
"simple_suggestion": {
"term": {
"field": "title",
"suggest_mode": "popular",
"min_word_length": 3
}
}
}
}
展示:
您搜索的是:iPhne
→ 您是不是要找:iPhone?
自动按"iPhone"搜索,展示结果
方案三:模糊搜索+推荐
核心思想: 放宽搜索条件,推荐相关商品。
策略:
1. 分词后部分匹配:
"iPhone 15 Pro Max 256GB" 搜索无结果
→ 尝试搜索"iPhone 15 Pro Max"
→ 再尝试"iPhone 15 Pro"
→ 再尝试"iPhone 15"
2. 类目推荐:
用户搜索"iPhone" → 推荐"手机"类目热销商品
3. 关联推荐:
用户搜索"iPhone 充电器" → 推荐"iPhone 配件"
4. 热门推荐:
全站热销TOP 10
方案四:引导式搜索
核心思想: 引导用户重新搜索或浏览。
页面设计:
抱歉,没有找到 "iPhne 15" 的相关商品
您可以:
1. 检查拼写是否正确
2. 尝试更通用的关键词(如"手机"而不是"iPhone 15 Pro Max")
3. 浏览以下分类:
- 手机 > 智能手机
- 手机 > 苹果手机
热门搜索:
- iPhone 15
- 小米14
- 华为Mate 60
推荐商品:
[展示热销手机]
方案对比:
| 方案 | 用户体验 | 转化率 | 实施难度 |
|---|---|---|---|
| 简单提示 | ★☆☆☆☆ | ★☆☆☆☆ | ★★★★★ |
| 拼写纠错 | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
| 模糊搜索+推荐 | ★★★★★ | ★★★★★ | ★★☆☆☆ |
| 引导式 | ★★★★☆ | ★★★☆☆ | ★★★★☆ |
推荐方案: 采用拼写纠错+模糊搜索+推荐的组合。
实施要点:
-
纠错流程:
用户搜索 → ES查询 → if (结果数 == 0) { // 1. 尝试拼写纠错 corrected = spellChecker.correct(keyword); if (corrected != keyword) { results = search(corrected); if (results.size() > 0) { return showCorrectedResults(corrected, results); } } // 2. 尝试模糊搜索 results = fuzzySearch(keyword); if (results.size() > 0) { return showFuzzyResults(results); } // 3. 推荐相关商品 recommended = recommend(keyword); return showRecommended(recommended); } -
纠错词库:
来源: - 用户搜索日志(搜索A无结果,搜索B有结果) - 商品标题词库 - 品牌名称 - 常见错误(人工维护) 存储: spell_correction ├── wrong_word(错误词) ├── correct_word(正确词) ├── correction_count(纠正次数) └── ... -
模糊搜索策略:
策略1:降低匹配度要求 minimum_should_match: "75%"(原本100%) 策略2:增加同义词 "手机" = "智能手机" = "移动电话" 策略3:分词后部分匹配 "iPhone 15 Pro Max" → ["iPhone", "15", "Pro", "Max"] 匹配任意3个词即可 -
推荐策略:
推荐来源: 1. 类目热销(如果能识别类目) 2. 全站热销(兜底) 3. 相关搜索("其他用户还搜索了...") 4. 促销商品(引导转化) -
监控优化:
监控指标: - 无结果搜索率(无结果搜索数/总搜索数) - 无结果页跳出率 - 纠错成功率 目标: - 无结果搜索率 < 5% - 无结果页跳出率 < 50%
延伸思考:
- 如何处理恶意搜索(脏词、广告)?
- 无结果搜索如何用于商品补货建议?
- 如何设计多语言搜索的纠错?
(继续生成后续5题…)
由于内容较长,我将分批次完成。继续生成3.1的剩余5题:
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1012。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-006:搜索日志分析与优化
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-006 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1238 |
题干与约束
电商平台每天产生百万级搜索日志,如何分析搜索日志,发现问题并优化搜索体验?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台每天产生百万级搜索日志,如何分析搜索日志,发现问题并优化搜索体验?
答案:
问题分析: 搜索日志分析的核心目标:
- 发现热门搜索词
- 识别无结果搜索
- 分析用户搜索路径
- 优化搜索排序
推荐方案:
数据收集:
搜索日志表:
search_log
├── log_id
├── user_id
├── keyword(搜索词)
├── result_count(结果数量)
├── clicked_products(点击的商品ID列表)
├── converted(是否转化购买)
├── search_time
└── session_id
分析维度:
-
热门搜索词Top榜:
SELECT keyword, COUNT(*) as search_count FROM search_log WHERE search_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY keyword ORDER BY search_count DESC LIMIT 100; 用途: - 运营决策(备货) - 搜索建议(热词优先展示) - 广告投放 -
无结果搜索分析:
SELECT keyword, COUNT(*) as count FROM search_log WHERE result_count = 0 AND search_time >= DATE_SUB(NOW(), INTERVAL 1 DAY) GROUP BY keyword ORDER BY count DESC LIMIT 100; 优化方向: - 拼写纠错词库补充 - 商品补货建议 - 同义词扩展 -
点击率分析:
SELECT keyword, COUNT(*) as impressions, SUM(CASE WHEN clicked_products IS NOT NULL THEN 1 ELSE 0 END) as clicks, clicks / impressions as ctr FROM search_log GROUP BY keyword HAVING impressions > 100 ORDER BY ctr ASC LIMIT 100; 低CTR关键词 → 排序策略需要优化 -
转化漏斗:
搜索 → 点击 → 加购 → 下单 → 支付 分析每个环节的转化率,找到瓶颈
延伸思考:
- 如何识别恶意搜索(刷流量)?
- 搜索日志如何用于个性化推荐?
- 如何设计搜索AB测试平台?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1238。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-007:跨境电商的多语言搜索
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-007 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1330 |
题干与约束
跨境电商支持中文、英文、日文搜索。如何设计多语言搜索系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 跨境电商支持中文、英文、日文搜索。如何设计多语言搜索系统?
答案:
问题分析: 多语言搜索的核心挑战:
- 不同语言分词规则不同
- 用户可能用中文搜英文商品
- 同义词跨语言匹配
推荐方案:
-
多语言索引:
{ "mappings": { "properties": { "title": { "properties": { "zh": {"type": "text", "analyzer": "ik_max_word"}, "en": {"type": "text", "analyzer": "english"}, "ja": {"type": "text", "analyzer": "kuromoji"} } } } } } -
语言检测:
String lang = LanguageDetector.detect(keyword); // keyword="手机" → lang="zh" // keyword="phone" → lang="en" 根据语言选择搜索字段: if (lang == "zh") { query = QueryBuilders.matchQuery("title.zh", keyword); } else if (lang == "en") { query = QueryBuilders.matchQuery("title.en", keyword); } -
跨语言搜索:
用户输入中文"手机",也能搜到英文标题"phone" 方案:翻译API - 调用翻译API(Google Translate) - keyword="手机" → translate → "phone" - 搜索中文字段 OR 英文翻译
延伸思考:
- 如何处理多语言同义词?
- 不同国家的搜索习惯差异如何处理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1330。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-008:搜索性能优化
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-008 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1392 |
题干与约束
搜索响应时间P99达到2秒,用户体验差。如何优化搜索性能到100ms以内?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 搜索响应时间P99达到2秒,用户体验差。如何优化搜索性能到100ms以内?
答案:
问题分析: 搜索慢的常见原因:
- ES查询复杂(深度分页、大量聚合)
- 索引设计不合理
- 数据量大
- 网络延迟
优化方案:
-
查询优化:
避免深度分页: ❌ from=10000, size=20(跳过1万条数据) ✅ search_after(游标分页) 减少聚合计算: ❌ 聚合100个字段 ✅ 聚合最常用的10个字段 字段裁剪: ❌ 返回所有字段 ✅ _source: ["id", "title", "price"] -
缓存策略:
热门搜索缓存: key: search:q=iPhone&page=1 value: {商品列表} TTL: 5分钟 命中率:70%+ -
索引优化:
分片数量: - 单分片大小:20-50GB - 过多分片影响性能 副本数量: - 副本数=2(高可用+读负载均衡) Segment合并: - 定期force_merge减少segment数量
延伸思考:
- 如何设计搜索的降级方案(ES故障)?
- 搜索性能如何监控和告警?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1392。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-009:智能搜索(NLP+AI)
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-009 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1452 |
题干与约束
用户搜索“适合送女朋友的礼物“,如何理解用户意图,推荐合适商品?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户搜索“适合送女朋友的礼物“,如何理解用户意图,推荐合适商品?
答案:
问题分析: 传统搜索只能匹配关键词,无法理解语义。
解决方案:
-
意图识别:
NLP分析: "适合送女朋友的礼物" → 意图:礼物推荐 → 对象:女性 → 场景:送礼 映射到类目: - 珠宝首饰 - 化妆品 - 鲜花 -
语义搜索:
使用BERT等模型: - 将搜索词编码为向量 - 商品标题也编码为向量 - 计算向量相似度 - 按相似度排序
延伸思考:
- 如何训练电商领域的语义模型?
- 语义搜索如何与传统搜索结合?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1452。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-010:搜索结果的多样性优化
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-010 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 搜索架构、读模型 |
| 场景标签 | 搜索导购、高并发读 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1493 |
题干与约束
用户搜索“手机“,前10个结果都是iPhone,缺乏多样性。如何优化搜索结果的多样性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户搜索“手机“,前10个结果都是iPhone,缺乏多样性。如何优化搜索结果的多样性?
答案:
问题分析: 多样性不足的问题:
- 马太效应(热门商品更热门)
- 用户需求多样,不都想要iPhone
- 影响长尾商品曝光
优化方案:
-
品牌打散:
规则:前10个结果中,同一品牌最多出现3次 算法: 1. 按相关性排序 2. 遍历结果,统计品牌出现次数 3. 如果某品牌超过阈值,跳过该商品,选下一个 -
MMR算法(最大边际相关性):
score = λ × relevance - (1-λ) × max_similarity relevance: 与查询的相关性 max_similarity: 与已选结果的最大相似度 λ: 权衡参数(0.7) 每次选择score最高的商品,保证相关性和多样性 -
类目多样性:
前10个结果覆盖2-3个子类目 - 智能手机(5个) - 老人机(3个) - 游戏手机(2个)
延伸思考:
- 多样性和相关性如何权衡?
- 如何评估搜索结果的多样性?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1493。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-011:购物车的数据存储设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-011 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1547 |
题干与约束
用户将商品加入购物车,需要跨设备同步(手机APP、Web、小程序)。如何设计购物车的存储方案?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户将商品加入购物车,需要跨设备同步(手机APP、Web、小程序)。如何设计购物车的存储方案?
答案:
问题分析: 购物车的核心要素:
- 跨设备同步
- 用户未登录也能加购
- 数据持久化
- 高并发读写
方案一:Cookie存储
核心思想: 购物车数据存储在浏览器Cookie。
优点:
- 无需服务器存储
- 减轻服务器压力
缺点:
- 不能跨设备
- Cookie大小限制(4KB)
- 不安全(可被篡改)
适用场景:
- 简单电商
- 临时购物车
方案二:数据库存储(推荐)
核心思想: 购物车存储在MySQL/Redis。
设计:
shopping_cart
├── cart_id
├── user_id
├── sku_id
├── quantity
├── selected(是否选中,用于结算)
├── added_at
└── updated_at
索引:
- PRIMARY KEY (cart_id)
- UNIQUE KEY (user_id, sku_id)
- INDEX (user_id)
优点:
- 跨设备同步
- 数据持久化
- 支持复杂操作
缺点:
- 服务器存储成本
方案三:Redis+MySQL双写
核心思想: Redis提供高性能,MySQL保证持久化。
架构:
写操作:
1. 写Redis(立即返回)
2. 异步写MySQL
读操作:
1. 优先读Redis
2. Redis不存在,读MySQL
3. 回写Redis
优点:
- 性能高
- 数据安全
缺点:
- 数据同步复杂
推荐方案: 采用Redis+MySQL双写。
实施要点:
-
未登录用户:
未登录: - 生成临时cart_id(存Cookie) - 购物车数据存Redis - key: cart:temp:{cart_id} 登录后: - 合并临时购物车到用户购物车 - 删除临时购物车 -
购物车合并:
public void mergeCart(String tempCartId, Long userId) { List<CartItem> tempItems = getTempCart(tempCartId); List<CartItem> userItems = getUserCart(userId); for (CartItem temp : tempItems) { CartItem exist = findItem(userItems, temp.getSkuId()); if (exist != null) { // 已存在,数量相加 exist.setQuantity(exist.getQuantity() + temp.getQuantity()); } else { // 不存在,添加 userItems.add(temp); } } saveUserCart(userId, userItems); deleteTempCart(tempCartId); } -
失效商品处理:
商品失效场景: - 商品下架 - 商品删除 - 库存不足 展示: - 失效商品置灰 - 提示"商品已下架" - 提供"删除"或"移入收藏"选项 -
购物车清理:
定时任务(每天凌晨): - 删除90天未更新的购物车 - 减少存储成本 -
购物车同步:
跨设备同步: - 用户在APP加购 → 写Redis+MySQL - 用户在Web打开 → 读Redis → 显示购物车 实时同步(WebSocket): - 用户在设备A加购 - 推送到设备B - 设备B实时更新购物车数量
延伸思考:
- 购物车数量显示在导航栏,如何实时更新?
- 如何处理购物车中的促销信息过期?
- 购物车数据如何备份和恢复?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1547。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-012:购物车的价格计算
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-012 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1711 |
题干与约束
购物车中有多个商品,每个商品可能有不同促销(满减、折扣、优惠券)。如何设计购物车的实时价格计算?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 购物车中有多个商品,每个商品可能有不同促销(满减、折扣、优惠券)。如何设计购物车的实时价格计算?
答案:
问题分析: 购物车价格计算的复杂性:
- 多商品组合
- 多种促销叠加
- 实时计算(用户修改数量即刻更新)
- 价格明细展示
推荐方案:
价格计算引擎:
public CartPrice calculateCart(Cart cart) {
BigDecimal originalPrice = BigDecimal.ZERO;
BigDecimal discountAmount = BigDecimal.ZERO;
// 1. 计算商品级优惠
for (CartItem item : cart.getItems()) {
originalPrice = originalPrice.add(
item.getPrice().multiply(new BigDecimal(item.getQuantity()))
);
// 商品折扣
if (item.hasDiscount()) {
BigDecimal itemDiscount = calculateItemDiscount(item);
discountAmount = discountAmount.add(itemDiscount);
}
}
// 2. 计算订单级优惠
BigDecimal subtotal = originalPrice.subtract(discountAmount);
// 满减
BigDecimal fullReduceDiscount = calculateFullReduce(subtotal);
discountAmount = discountAmount.add(fullReduceDiscount);
// 优惠券
if (cart.hasCoupon()) {
BigDecimal couponDiscount = calculateCoupon(cart.getCoupon(), subtotal);
discountAmount = discountAmount.add(couponDiscount);
}
// 3. 最终价格
BigDecimal finalPrice = originalPrice.subtract(discountAmount);
return new CartPrice(originalPrice, discountAmount, finalPrice);
}
实时计算触发:
触发时机:
- 用户修改商品数量
- 用户选择/取消优惠券
- 用户勾选/取消商品
- 商品价格变动(后台推送)
性能优化:
- 防抖(用户停止操作500ms后计算)
- 缓存(相同购物车缓存5分钟)
延伸思考:
- 购物车价格和下单后价格不一致如何处理?
- 大促时购物车价格计算如何优化性能?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1711。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-013:购物车的推荐功能
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-013 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1785 |
题干与约束
用户购物车中有商品A,如何推荐相关商品B,提升客单价?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户购物车中有商品A,如何推荐相关商品B,提升客单价?
答案:
推荐策略:
-
关联推荐:
"买了还买": - 统计购买商品A的用户还购买了哪些商品 - 推荐高频商品 示例: 购物车有"iPhone 15" → 推荐"手机壳"、"钢化膜"、"充电器" -
凑单推荐:
购物车总价¥180 满¥200减¥30 推荐:再买¥20-30的商品,即可享受优惠 -
替代推荐:
购物车中商品缺货 → 推荐同类商品
延伸思考:
- 购物车推荐如何避免打扰用户?
- 推荐商品点击率如何提升?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1785。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-014:购物车的库存校验
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-014 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1823 |
题干与约束
用户加购物车时商品有货,结算时可能已无货。如何设计购物车的库存校验机制?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户加购物车时商品有货,结算时可能已无货。如何设计购物车的库存校验机制?
答案:
校验时机:
-
加购时校验:
用户点击"加入购物车" → 检查库存 库存充足 → 允许加购 库存不足 → 提示"库存不足" -
结算时校验:
用户点击"去结算" → 1. 批量查询购物车所有商品库存 2. 标记缺货商品 3. 展示: - 有货商品(可结算) - 缺货商品(置灰,不可结算) -
实时推送:
商品库存变化(如售罄) → WebSocket推送 前端实时更新购物车状态
延伸思考:
- 购物车中的商品是否需要预占库存?
- 库存不足时如何引导用户?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1823。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-015:购物车的性能优化
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-015 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1861 |
题干与约束
大促期间,购物车服务QPS达10万+,如何优化购物车性能?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 大促期间,购物车服务QPS达10万+,如何优化购物车性能?
答案:
优化方案:
-
读写分离:
写操作(加购、删除): - 写MySQL主库 - 异步同步到Redis 读操作(查询购物车): - 读Redis(快) - 未命中读MySQL从库 -
批量操作:
❌ 单个加购:N次请求 ✅ 批量加购:1次请求 POST /api/cart/batch-add { "items": [ {"skuId": "123", "quantity": 2}, {"skuId": "456", "quantity": 1} ] } -
本地缓存:
热点用户购物车: - 加载到应用服务器内存 - 减少Redis访问 -
限流降级:
限流: - 单用户购物车操作频率限制(10次/分钟) 降级: - Redis故障 → 降级到MySQL - MySQL故障 → 只读模式(不能加购)
延伸思考:
- 购物车数据如何分片(sharding)?
- 购物车服务如何实现高可用?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1861。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-016:购物车商品失效的处理策略
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-016 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1918 |
题干与约束
用户购物车中的商品可能因为下架、删除、库存清零而失效。如何设计失效商品的处理策略,优化用户体验?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户购物车中的商品可能因为下架、删除、库存清零而失效。如何设计失效商品的处理策略,优化用户体验?
答案:
问题分析: 商品失效场景:
- 商品下架(运营操作)
- 商品删除(商品不再销售)
- 库存售罄(暂时缺货)
- 商品涨价(价格变动)
- 促销过期(活动结束)
方案一:定时批量检测
核心思想: 定时任务扫描购物车,标记失效商品。
实现:
定时任务(每小时):
1. 查询所有购物车商品
2. 批量查询商品状态
3. 标记失效商品
4. 更新购物车
优点:
- 批量处理,效率高
- 服务器压力均匀
缺点:
- 实时性差(最长延迟1小时)
- 用户可能看到失效商品
方案二:实时校验(推荐)
核心思想: 用户打开购物车时,实时校验商品状态。
流程:
用户打开购物车 →
1. 查询购物车商品列表
2. 批量查询商品最新状态(Redis缓存)
3. 分类展示:
- 正常商品(可结算)
- 失效商品(置灰,不可结算)
4. 标注失效原因
失效商品展示:
[置灰显示]
iPhone 15 Pro 256GB
¥7999
状态:该商品已下架
操作:[删除] [移入收藏夹]
优点:
- 实时性好
- 用户体验清晰
缺点:
- 每次打开购物车都校验
- QPS增加
方案三:消息推送
核心思想: 商品状态变化时,主动推送更新购物车。
架构:
商品下架 →
发布事件(Kafka)→
购物车Worker消费 →
1. 查询包含该商品的购物车
2. 标记商品为失效
3. WebSocket推送用户(如果在线)
优点:
- 实时性最好
- 用户感知及时
缺点:
- 架构复杂
- 需要消息队列
方案对比:
| 方案 | 实时性 | 用户体验 | 实施难度 | 系统负载 |
|---|---|---|---|---|
| 定时检测 | ★★☆☆☆ | ★★★☆☆ | ★★★★★ | ★★★★☆ |
| 实时校验 | ★★★★☆ | ★★★★★ | ★★★★☆ | ★★★☆☆ |
| 消息推送 | ★★★★★ | ★★★★★ | ★★☆☆☆ | ★★★★☆ |
推荐方案: 采用实时校验+消息推送的组合。
实施要点:
-
商品状态缓存:
Redis存储商品状态: key: product:status:{skuId} value: { "onSale": true, "stock": 100, "price": 7999, "promotionId": "xxx", "updatedAt": 1679800000 } TTL: 10分钟 商品变更时主动刷新 -
批量校验优化:
public Map<String, ProductStatus> batchCheckStatus(List<String> skuIds) { // 1. 批量查询Redis List<String> keys = skuIds.stream() .map(id -> "product:status:" + id) .collect(Collectors.toList()); List<ProductStatus> cached = redis.mget(keys); // 2. 未命中的查数据库 Set<String> missingIds = findMissingIds(cached); if (!missingIds.isEmpty()) { Map<String, ProductStatus> fromDB = queryFromDB(missingIds); // 写回Redis cacheToRedis(fromDB); cached.addAll(fromDB.values()); } return toMap(cached); } -
失效商品操作:
用户操作: 1. 删除:直接从购物车删除 2. 移入收藏夹: - 加入收藏 - 从购物车删除 - 商品恢复上架时通知用户 3. 查看替代品: - 推荐同类商品 - 一键替换 -
主动通知:
通知策略: - 商品下架 → App推送 "您购物车中的【iPhone 15】已下架" - 商品降价 → App推送 "您购物车中的【iPhone 15】降价了" - 库存恢复 → 收藏夹商品有货通知 -
失效原因分类:
原因分类: - 已下架:运营下架 - 已售罄:库存为0 - 已删除:商品不存在 - 已涨价:价格变动超过10% - 活动结束:促销过期 针对性提示: - 已售罄 → "补货中,可先收藏" - 已涨价 → "当前价格¥xxx,加购时¥xxx"
延伸思考:
- 如何设计购物车的自动清理(失效商品30天后自动删除)?
- 失效商品是否计入购物车数量显示?
- 如何处理部分失效(如只有某个规格缺货)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:1918。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-017:购物车的跨平台同步设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-017 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2107 |
题干与约束
用户在手机APP加购商品,打开电脑Web也能看到。如何实现购物车的跨平台实时同步?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户在手机APP加购商品,打开电脑Web也能看到。如何实现购物车的跨平台实时同步?
答案:
问题分析: 跨平台同步的核心要素:
- 数据一致性(同一购物车)
- 实时性(秒级同步)
- 冲突处理(同时操作)
- 离线支持
方案一:轮询同步
核心思想: 客户端定时轮询服务器,获取最新购物车。
实现:
// 前端定时轮询
setInterval(() => {
fetch('/api/cart')
.then(res => res.json())
.then(cart => {
if (cart.version > localVersion) {
updateLocalCart(cart);
}
});
}, 5000); // 每5秒轮询一次
优点:
- 实现简单
- 兼容性好
缺点:
- 实时性差(5秒延迟)
- 浪费带宽(大部分请求无变化)
- 服务器压力大
方案二:WebSocket推送(推荐)
核心思想: 客户端与服务器建立长连接,服务器主动推送更新。
架构:
用户A在APP加购 →
1. APP发送请求到服务器
2. 服务器更新购物车
3. 服务器通过WebSocket推送到用户A的所有设备
4. Web端接收推送,更新购物车显示
WebSocket消息格式:
{
"type": "CART_UPDATE",
"action": "ADD_ITEM",
"data": {
"skuId": "123",
"quantity": 2
},
"version": 10,
"timestamp": 1679800000
}
实现:
// 服务端
@Service
public class CartService {
@Autowired
private WebSocketPushService pushService;
public void addToCart(Long userId, String skuId, int quantity) {
// 1. 更新购物车
Cart cart = updateCart(userId, skuId, quantity);
// 2. 推送到该用户所有在线设备
CartUpdateMessage msg = new CartUpdateMessage(
"ADD_ITEM", skuId, quantity, cart.getVersion()
);
pushService.pushToUser(userId, msg);
}
}
// 客户端
websocket.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'CART_UPDATE') {
// 更新本地购物车
if (msg.version > localCartVersion) {
applyCartUpdate(msg);
}
}
};
优点:
- 实时性好(秒级)
- 双向通信
- 节省带宽
缺点:
- 需要维护长连接
- 服务器成本高
- 需要心跳保活
方案三:长轮询
核心思想: 客户端发起请求,服务器hold住请求,有更新时返回。
实现:
function longPoll() {
fetch('/api/cart/poll?version=' + localVersion)
.then(res => res.json())
.then(cart => {
if (cart.version > localVersion) {
updateLocalCart(cart);
}
// 立即发起下一次轮询
longPoll();
})
.catch(() => {
// 失败后延迟重试
setTimeout(longPoll, 5000);
});
}
优点:
- 实时性较好
- 兼容性好(不需要WebSocket)
缺点:
- 服务器需要hold请求
- 连接可能超时
方案对比:
| 方案 | 实时性 | 服务器成本 | 兼容性 | 实施难度 |
|---|---|---|---|---|
| 轮询 | ★★☆☆☆ | ★★☆☆☆ | ★★★★★ | ★★★★★ |
| WebSocket | ★★★★★ | ★★★☆☆ | ★★★★☆ | ★★★☆☆ |
| 长轮询 | ★★★★☆ | ★★☆☆☆ | ★★★★★ | ★★★★☆ |
推荐方案: 采用WebSocket推送(支持WebSocket)+ 轮询兜底(不支持时降级)。
实施要点:
-
连接管理:
// 用户连接映射 Map<Long, Set<WebSocketSession>> userSessions = new ConcurrentHashMap<>(); // 用户连接时 public void onConnect(Long userId, WebSocketSession session) { userSessions.computeIfAbsent(userId, k -> new ConcurrentHashSet<>()) .add(session); } // 用户断开时 public void onDisconnect(Long userId, WebSocketSession session) { Set<WebSocketSession> sessions = userSessions.get(userId); if (sessions != null) { sessions.remove(session); } } // 推送消息 public void pushToUser(Long userId, Object message) { Set<WebSocketSession> sessions = userSessions.get(userId); if (sessions != null) { for (WebSocketSession session : sessions) { if (session.isOpen()) { session.sendMessage(new TextMessage(JSON.toJSONString(message))); } } } } -
版本控制:
购物车版本号: - 每次修改version+1 - 客户端记录本地version - 接收推送时检查version - 如果本地version更新,忽略旧推送 冲突解决: - 客户端操作携带version - 服务端CAS更新 - 失败则拉取最新数据重试 -
心跳保活:
// 客户端定时发送心跳 setInterval(() => { if (websocket.readyState === WebSocket.OPEN) { websocket.send(JSON.stringify({type: 'PING'})); } }, 30000); // 每30秒 // 服务端响应心跳 if (message.type === 'PING') { session.sendMessage(new TextMessage('{"type":"PONG"}')); } -
降级策略:
// 检测WebSocket支持 if ('WebSocket' in window) { connectWebSocket(); } else { // 降级到轮询 setInterval(pollCart, 10000); } // WebSocket断开时降级 websocket.onclose = () => { console.log('WebSocket断开,降级到轮询'); setInterval(pollCart, 10000); }; -
离线支持:
离线操作: 1. 用户离线时,操作保存到本地队列 2. 用户上线后,批量同步到服务器 3. 服务器合并操作,返回最终购物车 冲突处理: - 添加:合并数量 - 删除:以最新操作为准 - 修改:以最新操作为准
延伸思考:
- 如何处理网络不稳定导致的频繁重连?
- 跨平台同步如何支持多账号(家庭共享)?
- WebSocket服务如何实现横向扩展?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2107。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-018:购物车推荐算法设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-018 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2360 |
题干与约束
用户购物车有“iPhone 15“,如何推荐相关商品(配件、保险、AppleCare)提升客单价?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户购物车有“iPhone 15“,如何推荐相关商品(配件、保险、AppleCare)提升客单价?
答案:
问题分析: 购物车推荐的核心目标:
- 提升客单价(关联销售)
- 提升转化率(凑单满减)
- 提升用户体验(需要的商品)
推荐策略:
-
关联推荐(Frequently Bought Together):
-- 统计商品关联 SELECT b.sku_id, COUNT(*) as frequency FROM order_items a JOIN order_items b ON a.order_id = b.order_id WHERE a.sku_id = 'iPhone15' AND b.sku_id != 'iPhone15' GROUP BY b.sku_id ORDER BY frequency DESC LIMIT 10; 结果: - 手机壳(购买率80%) - 钢化膜(购买率70%) - 充电器(购买率60%) -
凑单推荐:
购物车总价:¥180 满减活动:满¥200减¥30 推荐策略: - 推荐价格在¥20-¥50的商品 - 优先推荐与购物车商品相关的 - 标注"再买¥20即享满减" -
类目互补推荐:
购物车有"相机" → 推荐: - 存储卡 - 相机包 - 三脚架 购物车有"婴儿奶粉" → 推荐: - 奶瓶 - 尿不湿 - 湿巾 -
个性化推荐:
基于用户历史: - 用户A经常买Apple产品 → 推荐AppleCare+、AirPods - 用户B价格敏感 → 推荐高性价比配件
实施要点:
-
关联规则挖掘:
# 使用Apriori算法 from mlxtend.frequent_patterns import apriori, association_rules # 构建购物篮矩阵 basket = orders.groupby(['order_id', 'sku_id'])['quantity'].sum().unstack().fillna(0) basket = basket.applymap(lambda x: 1 if x > 0 else 0) # 挖掘频繁项集 frequent_itemsets = apriori(basket, min_support=0.01, use_colnames=True) # 生成关联规则 rules = association_rules(frequent_itemsets, metric="confidence", min_threshold=0.5) # iPhone15 -> 手机壳 (confidence=0.8, lift=2.5) -
推荐展示位置:
位置1:购物车下方 "买了还买":展示3-5个商品 位置2:结算页 "凑单优惠":满减差额商品 位置3:加购弹窗 用户加购商品A → 弹窗推荐配件B -
推荐排序:
score = w1 × 关联度 + w2 × 利润率 + w3 × 库存充足度 + w4 × 用户个性化得分 w1=0.4, w2=0.3, w3=0.2, w4=0.1 -
AB测试:
测试维度: - A组:展示3个推荐 - B组:展示5个推荐 - C组:不展示推荐 评估指标: - 推荐点击率 - 推荐加购率 - 客单价提升
延伸思考:
- 推荐商品如何避免干扰用户(显得推销)?
- 推荐算法如何冷启动(新商品无关联数据)?
- 推荐效果如何评估和持续优化?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2360。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-019:购物车的结算流程设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-019 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2488 |
题干与约束
用户点击“去结算“,进入结算页面,需要选择地址、优惠券、支付方式。如何设计结算流程?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户点击“去结算“,进入结算页面,需要选择地址、优惠券、支付方式。如何设计结算流程?
答案:
问题分析: 结算流程的核心环节:
- 确认商品(数量、价格)
- 选择收货地址
- 选择配送方式
- 应用优惠(优惠券、积分)
- 选择支付方式
- 提交订单
方案一:单页结算
核心思想: 所有信息在一个页面完成。
页面布局:
结算页:
┌─────────────────┐
│ 1. 收货地址 │
│ [北京市朝阳区...] │
├─────────────────┤
│ 2. 商品清单 │
│ iPhone 15 × 1 │
│ ¥7999 │
├─────────────────┤
│ 3. 配送方式 │
│ ○ 标准配送(免费)│
│ ○ 次日达(¥10) │
├─────────────────┤
│ 4. 优惠 │
│ 优惠券:¥30 │
│ 积分抵扣:¥10 │
├─────────────────┤
│ 5. 支付方式 │
│ ○ 支付宝 │
│ ○ 微信支付 │
├─────────────────┤
│ 总计:¥7959 │
│ [提交订单] │
└─────────────────┘
优点:
- 流程简洁
- 一目了然
- 减少跳转
缺点:
- 页面信息多
- 移动端显示困难
方案二:分步结算(推荐)
核心思想: 分多个步骤完成结算。
流程:
步骤1:选择地址
→ 步骤2:确认商品和配送
→ 步骤3:选择优惠
→ 步骤4:支付
优点:
- 逻辑清晰
- 移动端友好
- 可保存中间状态
缺点:
- 步骤多
- 可能流失
推荐方案: PC端使用单页结算,移动端使用分步结算。
实施要点:
-
结算前校验:
public CheckoutResult preCheckout(Long userId) { // 1. 获取购物车 Cart cart = getCart(userId); // 2. 校验商品状态 List<String> invalidItems = new ArrayList<>(); for (CartItem item : cart.getItems()) { Product product = productService.getProduct(item.getSkuId()); if (!product.isOnSale()) { invalidItems.add(item.getSkuId() + ":已下架"); } else if (product.getStock() < item.getQuantity()) { invalidItems.add(item.getSkuId() + ":库存不足"); } } if (!invalidItems.isEmpty()) { return CheckoutResult.fail("部分商品无法结算", invalidItems); } // 3. 计算价格 PriceDetail price = calculatePrice(cart); // 4. 返回结算信息 return CheckoutResult.success(cart, price); } -
地址选择:
展示用户地址列表: - 默认地址(置顶) - 最近使用地址 - 其他地址 新增地址: - 省市区三级联动 - 详细地址输入 - 联系人和电话 - 设为默认地址 -
优惠券选择:
展示可用优惠券: - 按优惠力度排序 - 标注"最优"推荐 - 显示使用门槛 自动选择: - 默认选择优惠最大的券 - 用户可手动切换 不可用优惠券: - 置灰显示 - 标注不可用原因(如"不满足使用条件") -
价格实时计算:
// 监听用户操作 onChange = () => { // 防抖:用户停止操作500ms后计算 clearTimeout(this.timer); this.timer = setTimeout(() => { this.calculatePrice(); }, 500); }; calculatePrice = async () => { const params = { items: this.state.cartItems, addressId: this.state.selectedAddress, couponId: this.state.selectedCoupon, usePoints: this.state.usePoints }; const result = await API.post('/api/order/calculate-price', params); this.setState({ priceDetail: result }); }; -
订单确认信息:
最终确认页展示: - 收货人:张三 138****1234 - 收货地址:北京市朝阳区xxx - 商品清单:iPhone 15 × 1 - 配送方式:标准配送(预计3天送达) - 优惠明细: * 商品折扣:-¥100 * 满减优惠:-¥30 * 优惠券:-¥20 - 实付金额:¥7849 用户确认无误后点击"提交订单"
延伸思考:
- 如何设计结算页的防重复提交?
- 结算过程中价格变动如何处理?
- 结算流程如何优化转化率?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2488。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-020:购物车的分享功能设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-020 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2679 |
题干与约束
用户想分享购物车给朋友(如“帮我看看这些商品怎么样“),如何设计购物车分享功能?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户想分享购物车给朋友(如“帮我看看这些商品怎么样“),如何设计购物车分享功能?
答案:
问题分析: 购物车分享的核心场景:
- 征求意见(送礼选择)
- 代购(帮朋友买)
- 拼单(一起买更便宜)
方案一:生成分享链接
核心思想: 生成唯一URL,包含购物车商品信息。
实现:
生成分享:
1. 用户点击"分享购物车"
2. 服务端生成分享ID
3. 保存分享内容到数据库/Redis
4. 返回分享链接
分享链接:
https://example.com/cart/share/abc123
接收分享:
1. 朋友点击链接
2. 展示分享者的购物车商品
3. 可一键导入到自己购物车
数据设计:
cart_share
├── share_id(唯一ID)
├── user_id(分享者)
├── cart_snapshot(JSON,购物车快照)
├── expire_at(过期时间)
├── view_count(查看次数)
└── created_at
优点:
- 实现简单
- 支持任意平台
缺点:
- 链接可能泄露
- 分享内容是快照(不会实时更新)
方案二:生成二维码
核心思想: 生成二维码,扫码查看购物车。
实现:
生成二维码:
1. 生成分享链接(同方案一)
2. 将链接转为二维码
3. 展示二维码供分享
扫码查看:
1. 扫描二维码
2. 跳转到分享页面
3. 展示商品列表
优点:
- 线下分享方便
- 移动端友好
缺点:
- 仍是快照
方案三:实时共享购物车(推荐)
核心思想: 创建共享购物车,多人实时协同。
实现:
创建共享:
1. 用户创建共享购物车
2. 生成共享ID和密码(可选)
3. 邀请朋友加入
实时同步:
- 任何人添加/删除商品
- 通过WebSocket实时同步给所有成员
- 显示"张三添加了iPhone 15"
共享购物车表:
shared_cart
├── shared_cart_id
├── creator_id
├── name(如"周末采购清单")
├── password(可选)
├── members(成员列表)
├── items(商品列表)
└── created_at
优点:
- 实时协同
- 支持多人编辑
- 适合家庭、团队采购
缺点:
- 实现复杂
- 需要冲突处理
推荐方案: 采用分享链接+实时共享的组合。
实施要点:
-
分享类型:
类型1:只读分享 - 生成分享链接 - 朋友只能查看,不能修改 - 可一键导入到自己购物车 类型2:协同编辑 - 创建共享购物车 - 邀请成员 - 成员可添加/删除商品 -
分享页面设计:
分享页头部: "张三分享了购物车给你" 商品列表: [展示所有商品] 操作按钮: - [全部加入我的购物车] - [选择部分加入] - [保存为我的收藏清单] -
隐私控制:
隐私选项: - 公开:任何人都可查看 - 仅好友:需要登录且是好友 - 密码保护:需要输入密码 敏感信息隐藏: - 不显示价格(可选) - 不显示数量(可选) -
分享统计:
统计指标: - 分享次数 - 查看人数 - 转化人数(查看后购买) - 传播路径(A分享给B,B分享给C) -
场景化推荐:
场景1:送礼征询 "想送女朋友礼物,帮我选一个" → 展示多个候选商品 → 朋友投票或评论 场景2:拼单 "一起买,更便宜" → 共享购物车 → 凑满减金额 → 分摊运费
延伸思考:
- 如何设计购物车的协同冲突解决(同时删除同一商品)?
- 分享购物车如何防止恶意刷单?
- 共享购物车如何拆单结算(各付各的)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2679。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-021:购物车的满减凑单提示
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-021 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2869 |
题干与约束
购物车总价¥180,有满¥200减¥30活动。如何设计智能凑单提示,引导用户加购?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 购物车总价¥180,有满¥200减¥30活动。如何设计智能凑单提示,引导用户加购?
答案:
推荐方案:
-
差额计算:
当前金额:¥180 满减门槛:¥200 差额:¥20 提示:"再买¥20,立减¥30" -
智能商品推荐:
推荐商品筛选条件: - 价格在¥20-¥50之间(差额附近) - 与购物车商品相关(配件、同类目) - 库存充足 - 高评分 排序: - 优先推荐价格接近差额的 - 优先推荐关联度高的 -
视觉引导:
进度条展示: [████████░░] 90% (¥180/¥200) "再买¥20,立减¥30,相当于打8.5折" 推荐商品卡片: ┌───────────┐ │ 手机壳 │ │ ¥29 │ │ [加入购物车]│ └───────────┘ -
多档位满减:
满减档位: - 满¥100减¥10(已达成✓) - 满¥200减¥30(差¥20) - 满¥500减¥100(差¥320) 提示优先显示最接近的下一档
延伸思考:
- 凑单推荐如何避免过度营销(让用户反感)?
- 多个满减活动同时存在时如何提示?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2869。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-022:购物车的批量操作设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-022 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2930 |
题干与约束
用户购物车有50个商品,想批量删除、批量加入收藏。如何设计批量操作功能?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户购物车有50个商品,想批量删除、批量加入收藏。如何设计批量操作功能?
答案:
推荐方案:
-
批量选择:
界面设计: [全选] 已选0件 ☑ 商品A ¥100 ☑ 商品B ¥200 ☐ 商品C ¥300 批量操作: [删除选中] [加入收藏] [移除失效商品] -
批量接口:
POST /api/cart/batch-delete { "skuIds": ["123", "456", "789"] } POST /api/cart/batch-move-to-favorite { "skuIds": ["123", "456"] } -
事务处理:
批量操作的事务性: - 部分成功部分失败如何处理? 方案A:全量事务 - 全部成功才提交 - 任一失败全部回滚 方案B:部分成功(推荐) - 成功的操作提交 - 失败的返回错误信息 - 前端展示"成功X件,失败Y件" -
性能优化:
批量删除50个商品: ❌ for循环50次DELETE ✅ 一次DELETE WHERE sku_id IN (...) 批量更新库存: ❌ 50次UPDATE ✅ 批量UPDATE CASE WHEN
延伸思考:
- 批量操作如何支持撤销(Undo)?
- 批量操作的进度如何展示?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2930。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-023:购物车的收藏夹联动
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-023 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2997 |
题干与约束
购物车和收藏夹如何联动?商品从购物车移入收藏,或从收藏加入购物车。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 购物车和收藏夹如何联动?商品从购物车移入收藏,或从收藏加入购物车。
答案:
推荐方案:
-
数据模型:
favorite ├── favorite_id ├── user_id ├── sku_id ├── source(CART/BROWSE) ├── added_at └── ... -
互相转换:
购物车 → 收藏夹: 1. 用户点击"移入收藏" 2. 加入收藏夹 3. 从购物车删除 4. 提示"已移入收藏夹" 收藏夹 → 购物车: 1. 用户点击"加入购物车" 2. 加入购物车 3. 保留在收藏夹(不删除) -
降价提醒:
收藏商品降价: - 监控收藏商品价格 - 降价时推送通知 - 引导用户加购
延伸思考:
- 收藏夹和购物车的区别是什么?
- 如何设计收藏夹的分组功能?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:2997。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-024:购物车的历史记录
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-024 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3045 |
题干与约束
用户删除了购物车商品,想恢复。如何设计购物车的历史记录功能?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户删除了购物车商品,想恢复。如何设计购物车的历史记录功能?
答案:
推荐方案:
-
软删除:
shopping_cart ├── ... ├── deleted_at(软删除标记) └── deleted(是否删除) 查询购物车: SELECT * FROM shopping_cart WHERE user_id=? AND deleted=0 查询历史: SELECT * FROM shopping_cart WHERE user_id=? AND deleted=1 ORDER BY deleted_at DESC -
恢复功能:
历史记录页面: 最近删除: - 商品A(3天前删除)[恢复] - 商品B(7天前删除)[恢复] 恢复操作: UPDATE shopping_cart SET deleted=0, deleted_at=NULL WHERE cart_id=? -
自动清理:
定时任务: - 删除30天后的历史记录 - 减少存储成本
延伸思考:
- 购物车历史记录是否需要版本控制(记录每次修改)?
- 如何设计购物车的快照功能(保存多个购物清单)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3045。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-025:购物车的AB测试设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-025 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 结算编排、交易前校验 |
| 场景标签 | 购物车、结算页 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3097 |
题干与约束
想测试新的购物车布局对转化率的影响。如何设计购物车的AB测试?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 想测试新的购物车布局对转化率的影响。如何设计购物车的AB测试?
答案:
推荐方案:
-
分流策略:
public String getCartVersion(Long userId) { // 基于用户ID哈希分流 int hash = userId.hashCode(); if (hash % 2 == 0) { return "A"; // 对照组 } else { return "B"; // 实验组 } } -
实验设计:
对照组A(50%用户): - 旧购物车布局 实验组B(50%用户): - 新购物车布局(优化后) 评估指标: - 加购率 - 结算率 - 转化率 - 客单价 -
数据埋点:
// 购物车页面浏览 track('cart_view', { version: 'A', // 或 'B' cartItemCount: 5 }); // 点击结算 track('cart_checkout_click', { version: 'A', cartTotal: 1000 }); // 完成下单 track('order_created', { version: 'A', orderAmount: 1000 }); -
结果分析:
结果对比: | 指标 | A组 | B组 | 提升 | |------|-----|-----|------| | 结算率 | 60% | 65% | +8.3% | | 转化率 | 40% | 45% | +12.5% | | 客单价 | ¥800 | ¥850 | +6.25% | 结论:B组效果更好,全量发布
延伸思考:
- AB测试如何保证结果的统计显著性?
- 多个AB测试同时进行时如何隔离影响?
- 如何设计购物车的渐进式发布(灰度发布)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3097。本章不依赖旧 Part Four 文件链接。
相关章节:购物车与结算。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-026:订单状态机的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-026 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3178 |
题干与约束
订单从创建到完成,经历多个状态(待支付、待发货、待收货、已完成)。如何设计订单状态机,保证状态流转的正确性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单从创建到完成,经历多个状态(待支付、待发货、待收货、已完成)。如何设计订单状态机,保证状态流转的正确性?
答案:
问题分析: 订单状态流转的核心要素:
- 状态定义清晰
- 流转规则明确
- 防止非法跳转
- 支持异常流程(取消、退款)
状态定义:
正向流程:
PENDING_PAYMENT(待支付)
→ PAID(已支付/待发货)
→ SHIPPED(已发货/待收货)
→ RECEIVED(已收货/待评价)
→ COMPLETED(已完成)
逆向流程:
CANCELLED(已取消)
REFUNDING(退款中)
REFUNDED(已退款)
特殊状态:
TIMEOUT(超时关闭)
状态机实现:
方案一:If-Else判断
public void updateOrderStatus(Order order, OrderStatus newStatus) {
OrderStatus currentStatus = order.getStatus();
if (currentStatus == PENDING_PAYMENT) {
if (newStatus == PAID || newStatus == CANCELLED || newStatus == TIMEOUT) {
order.setStatus(newStatus);
} else {
throw new IllegalStateException("非法状态转换");
}
} else if (currentStatus == PAID) {
if (newStatus == SHIPPED || newStatus == REFUNDING) {
order.setStatus(newStatus);
} else {
throw new IllegalStateException("非法状态转换");
}
}
// ... 更多判断
}
缺点:
- 代码冗长
- 难以维护
- 状态多时复杂度爆炸
方案二:状态转换表(推荐)
// 定义状态转换规则
private static final Map<OrderStatus, Set<OrderStatus>> TRANSITIONS = Map.of(
PENDING_PAYMENT, Set.of(PAID, CANCELLED, TIMEOUT),
PAID, Set.of(SHIPPED, REFUNDING),
SHIPPED, Set.of(RECEIVED, REFUNDING),
RECEIVED, Set.of(COMPLETED, REFUNDING),
REFUNDING, Set.of(REFUNDED)
);
public void updateOrderStatus(Order order, OrderStatus newStatus) {
OrderStatus currentStatus = order.getStatus();
Set<OrderStatus> allowedTransitions = TRANSITIONS.get(currentStatus);
if (allowedTransitions == null || !allowedTransitions.contains(newStatus)) {
throw new IllegalStateException(
String.format("不允许从%s转换到%s", currentStatus, newStatus)
);
}
// 记录状态变更历史
OrderStatusHistory history = new OrderStatusHistory();
history.setOrderId(order.getId());
history.setFromStatus(currentStatus);
history.setToStatus(newStatus);
history.setOperator(getCurrentUser());
history.setReason(reason);
historyRepository.save(history);
// 更新订单状态
order.setStatus(newStatus);
orderRepository.save(order);
// 发布状态变更事件
eventPublisher.publish(new OrderStatusChangedEvent(order, currentStatus, newStatus));
}
优点:
- 规则清晰
- 易于维护
- 可扩展
状态流转图:
┌─> CANCELLED
│
PENDING_PAYMENT ──┬─┴─> PAID ───> SHIPPED ───> RECEIVED ───> COMPLETED
│ │ │
└─> TIMEOUT │ │
│ │
└─> REFUNDING <─┘
│
└─> REFUNDED
延伸思考:
- 如何设计订单的子状态(如待发货细分为待拣货、待打包、待出库)?
- 订单状态变更如何触发后续操作(如发货后通知物流)?
- 如何处理状态流转的并发冲突?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3178。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-027:订单号生成规则
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-027 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3303 |
题干与约束
订单号需要唯一、有序、不易被猜测。如何设计订单号生成规则?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单号需要唯一、有序、不易被猜测。如何设计订单号生成规则?
答案:
订单号设计要求:
- 全局唯一
- 趋势递增(便于分库分表)
- 信息可读(包含时间、业务类型)
- 安全性(不易被遍历)
- 长度适中(15-20位)
方案一:数据库自增ID
优点:
- 简单
- 唯一
缺点:
- 连续,易被猜测
- 分布式环境难实现
- 信息量少
方案二:UUID
优点:
- 全局唯一
- 无需中心化
缺点:
- 无序(影响索引性能)
- 长度太长(36位)
- 无业务含义
方案三:Snowflake算法(推荐)
结构:
64位Long型:
1位符号位 + 41位时间戳 + 10位机器ID + 12位序列号
示例:
0 - 00000000000000000000000000000000000000000 - 0000000000 - 000000000000
│ └─────────────41位时间戳─────────────────┘ └10位机器┘ └12位序列┘
符号位
生成的订单号:1234567890123456789(19位)
实现:
public class SnowflakeIdGenerator {
// 起始时间戳(2020-01-01)
private final long epoch = 1577836800000L;
// 机器ID(数据中心ID + 机器ID)
private final long workerId;
// 序列号
private long sequence = 0L;
// 上次生成ID的时间戳
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
// 时钟回拨检测
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨");
}
// 同一毫秒内
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & 4095; // 4095=2^12-1
if (sequence == 0) {
// 序列号用完,等待下一毫秒
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
lastTimestamp = timestamp;
// 组装ID
return ((timestamp - epoch) << 22)
| (workerId << 12)
| sequence;
}
}
优点:
- 趋势递增
- 高性能
- 分布式友好
缺点:
- 依赖机器时钟
- 机器ID需要管理
方案四:业务规则拼接
结构:
订单号格式:业务前缀 + 日期 + 随机数
示例:
OR20260418123456789
│ └────┘└───────┘
│ 日期 随机数
业务前缀(OR=Order)
生成:
String orderId = "OR"
+ LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ RandomStringUtils.randomNumeric(9);
优点:
- 可读性强
- 包含业务信息
- 可自定义
缺点:
- 需要保证随机数不重复
- 长度较长
推荐方案: 使用Snowflake算法生成基础ID,再转为业务订单号。
实现:
public String generateOrderNo() {
long snowflakeId = idGenerator.nextId();
// 转为订单号(添加业务前缀)
return "OR" + snowflakeId;
}
延伸思考:
- 如何设计订单号的校验规则(防止伪造)?
- 订单号如何支持多业务类型(普通订单、预售订单、拼团订单)?
- 分库分表场景下订单号如何设计路由键?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3303。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-028:订单超时自动取消
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-028 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3454 |
题干与约束
用户下单30分钟未支付,订单自动关闭并释放库存。如何实现订单超时自动取消?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户下单30分钟未支付,订单自动关闭并释放库存。如何实现订单超时自动取消?
答案:
方案一:定时任务扫描
核心思想: 定时任务定期扫描超时订单。
实现:
@Scheduled(fixedDelay = 60000) // 每分钟执行
public void cancelTimeoutOrders() {
// 查询超时未支付订单
List<Order> timeoutOrders = orderRepository.findByStatusAndCreateTimeBefore(
OrderStatus.PENDING_PAYMENT,
LocalDateTime.now().minus(30, ChronoUnit.MINUTES)
);
for (Order order : timeoutOrders) {
try {
// 取消订单
orderService.cancel(order.getId(), "超时未支付自动取消");
// 释放库存
inventoryService.release(order.getItems());
// 通知用户
notificationService.send(order.getUserId(), "订单已超时关闭");
} catch (Exception e) {
log.error("取消订单失败", e);
}
}
}
优点:
- 实现简单
- 可靠性高
缺点:
- 实时性差(最长延迟1分钟)
- 数据库扫描压力大
- 定时任务单点故障
方案二:延迟队列(推荐)
核心思想: 订单创建时发送延迟消息,30分钟后消费取消订单。
使用RabbitMQ延迟队列:
// 创建订单时
public void createOrder(Order order) {
// 1. 保存订单
orderRepository.save(order);
// 2. 发送延迟消息(30分钟后)
rabbitTemplate.convertAndSend(
"order.cancel.exchange",
"order.cancel.routing.key",
order.getId(),
message -> {
message.getMessageProperties().setDelay(30 * 60 * 1000); // 30分钟
return message;
}
);
}
// 消费延迟消息
@RabbitListener(queues = "order.cancel.queue")
public void handleOrderCancel(Long orderId) {
Order order = orderRepository.findById(orderId);
// 检查订单状态
if (order.getStatus() == OrderStatus.PENDING_PAYMENT) {
// 仍未支付,取消订单
orderService.cancel(orderId, "超时未支付自动取消");
inventoryService.release(order.getItems());
}
// 如果已支付,忽略
}
使用Redis实现延迟队列:
// 创建订单时
public void createOrder(Order order) {
orderRepository.save(order);
// 添加到Redis有序集合(Sorted Set)
long expireTime = System.currentTimeMillis() + 30 * 60 * 1000;
redis.zadd("order:timeout", expireTime, order.getId());
}
// 定时消费
@Scheduled(fixedDelay = 1000) // 每秒执行
public void processTimeoutOrders() {
long now = System.currentTimeMillis();
// 获取已到期的订单ID
Set<String> orderIds = redis.zrangeByScore("order:timeout", 0, now);
for (String orderId : orderIds) {
try {
// 处理超时订单
processTimeoutOrder(Long.parseLong(orderId));
// 从集合中移除
redis.zrem("order:timeout", orderId);
} catch (Exception e) {
log.error("处理超时订单失败", e);
}
}
}
优点:
- 准确到秒
- 分布式友好
- 性能好
缺点:
- 依赖消息队列
- 需要处理消息丢失
方案三:时间轮算法
核心思想: 使用时间轮数据结构管理超时任务。
实现(Netty HashedWheelTimer):
private final HashedWheelTimer timer = new HashedWheelTimer(
1, TimeUnit.SECONDS, // 每秒tick一次
60 // 60个槽位
);
public void createOrder(Order order) {
orderRepository.save(order);
// 添加超时任务
timer.newTimeout(timeout -> {
Order latestOrder = orderRepository.findById(order.getId());
if (latestOrder.getStatus() == OrderStatus.PENDING_PAYMENT) {
orderService.cancel(order.getId(), "超时未支付自动取消");
}
}, 30, TimeUnit.MINUTES);
}
优点:
- 高性能
- 精确度高
缺点:
- 内存占用(任务在内存)
- 单机方案(不支持分布式)
- 服务重启任务丢失
方案对比:
| 方案 | 实时性 | 可靠性 | 分布式 | 实施难度 |
|---|---|---|---|---|
| 定时扫描 | ★★☆☆☆ | ★★★★★ | ★★★★☆ | ★★★★★ |
| 延迟队列 | ★★★★★ | ★★★★☆ | ★★★★★ | ★★★☆☆ |
| 时间轮 | ★★★★★ | ★★★☆☆ | ★★☆☆☆ | ★★★☆☆ |
推荐方案: 采用延迟队列(RabbitMQ或Redis)。
实施要点:
-
幂等性保证:
@Transactional public void cancel(Long orderId, String reason) { Order order = orderRepository.findById(orderId); // 检查当前状态 if (order.getStatus() != OrderStatus.PENDING_PAYMENT) { log.warn("订单{}状态不是待支付,跳过取消", orderId); return; // 已被其他线程处理 } // CAS更新状态 int updated = orderRepository.updateStatus( orderId, OrderStatus.CANCELLED, OrderStatus.PENDING_PAYMENT // 期望的旧状态 ); if (updated == 0) { log.warn("订单{}取消失败,可能已被处理", orderId); return; } // 释放库存 inventoryService.release(order.getItems()); } -
异常重试:
取消失败的处理: - 消息重新入队,稍后重试 - 最多重试3次 - 仍失败则记录告警,人工处理 -
监控告警:
监控指标: - 超时订单数量 - 取消成功率 - 延迟队列堆积量 告警: - 取消失败率 > 1% - 延迟队列堆积 > 10000
延伸思考:
- 如何设计不同订单类型的不同超时时间(普通30分钟,秒杀10分钟)?
- 订单超时取消如何通知用户?
- 大促期间超时订单激增如何处理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3454。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-029:订单拆单与合单策略
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-029 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3686 |
题干与约束
用户购买多个商品,可能来自不同仓库或不同商家。如何设计订单拆单与合单策略?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户购买多个商品,可能来自不同仓库或不同商家。如何设计订单拆单与合单策略?
答案:
问题分析: 拆单场景:
- 多仓库发货(就近发货)
- 多商家发货(平台+第三方卖家)
- 预售+现货(发货时间不同)
- 自营+跨境(清关时间不同)
合单场景:
- 同一地址多笔订单(节省运费)
- 同一商家商品(方便发货)
方案一:用户下单时拆单
核心思想: 用户提交订单时,系统自动拆分为多个子订单。
流程:
用户购物车:
- 商品A(北京仓)
- 商品B(上海仓)
- 商品C(北京仓)
拆单规则:
按仓库拆分:
→ 子订单1:商品A + C(北京仓)
→ 子订单2:商品B(上海仓)
数据结构:
parent_order(父订单)
├── parent_order_id
├── user_id
├── total_amount
└── status
sub_order(子订单)
├── sub_order_id
├── parent_order_id
├── warehouse_id
├── items
└── status
用户支付:
用户支付父订单 → 分配金额到各子订单
子订单独立发货、收货
优点:
- 逻辑清晰
- 用户感知明确
缺点:
- 用户体验复杂(多个运单号)
- 退款复杂(部分退款)
方案二:后台自动拆单(推荐)
核心思想: 用户下单时是一个订单,后台根据规则自动拆分为多个发货单。
流程:
用户下单:创建订单(单个)
↓
订单支付成功
↓
订单中心分析:需要拆单
↓
创建多个发货单(shipment)
- 发货单1:商品A+C → 北京仓
- 发货单2:商品B → 上海仓
↓
各仓库独立发货
数据结构:
order(订单)
├── order_id
├── user_id
├── total_amount
└── status
shipment(发货单)
├── shipment_id
├── order_id
├── warehouse_id
├── items(发货商品)
├── tracking_number(运单号)
└── status
优点:
- 用户无感知(看到的是一个订单)
- 退款简单(按订单退)
- 灵活(可随时调整拆单规则)
缺点:
- 实现复杂
- 需要维护订单和发货单的关系
拆单规则:
-
按仓库拆分:
public List<Shipment> splitByWarehouse(Order order) { // 1. 为每个商品选择最优仓库 Map<String, Warehouse> itemWarehouse = new HashMap<>(); for (OrderItem item : order.getItems()) { Warehouse warehouse = selectWarehouse(item.getSkuId(), order.getAddress()); itemWarehouse.put(item.getSkuId(), warehouse); } // 2. 按仓库分组 Map<Warehouse, List<OrderItem>> grouped = order.getItems().stream() .collect(Collectors.groupBy(item -> itemWarehouse.get(item.getSkuId()))); // 3. 生成发货单 List<Shipment> shipments = new ArrayList<>(); for (Map.Entry<Warehouse, List<OrderItem>> entry : grouped.entrySet()) { Shipment shipment = new Shipment(); shipment.setOrderId(order.getId()); shipment.setWarehouseId(entry.getKey().getId()); shipment.setItems(entry.getValue()); shipments.add(shipment); } return shipments; } -
按商家拆分:
平台订单包含: - 自营商品(平台发货) - 第三方商品(商家发货) 拆分: - 子订单1:自营商品 - 子订单2:商家A的商品 - 子订单3:商家B的商品 -
按发货时间拆分:
订单包含: - 现货商品(立即发货) - 预售商品(15天后发货) 拆分: - 发货单1:现货(立即发) - 发货单2:预售(延迟发)
合单策略:
-
同地址合并:
用户A在1小时内下了3笔订单: - 订单1:商品A(北京仓) - 订单2:商品B(北京仓) - 订单3:商品C(上海仓) 合单: - 发货单1:订单1+订单2的商品(北京仓合并发货) - 发货单2:订单3的商品(上海仓单独发货) 好处: - 节省运费 - 减少包裹数量 -
运费优化:
规则: - 同一仓库、同一地址、24小时内的订单 - 自动合并发货 - 运费退还到用户余额
推荐方案: 采用后台自动拆单。
实施要点:
-
拆单时机:
时机选择: - 订单支付后立即拆单(推荐) - 发货前拆单(更灵活) -
用户展示:
订单详情页: 订单号:OR123456 总金额:¥1000 发货信息: - 包裹1:商品A+B(运单号:SF123) 状态:已发货 - 包裹2:商品C(运单号:SF456) 状态:待发货 -
退款处理:
部分商品退款: - 用户申请退商品A - 计算退款金额(商品价 + 分摊运费) - 只退部分金额 - 其他商品正常履约
延伸思考:
- 如何设计拆单的运费分摊规则?
- 拆单后如何保证库存一致性?
- 跨境订单的拆单有何特殊性?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3686。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-030:订单的并发创建与幂等性
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-030 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3916 |
题干与约束
用户可能重复点击“提交订单“按钮,导致创建多个订单。如何保证订单创建的幂等性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户可能重复点击“提交订单“按钮,导致创建多个订单。如何保证订单创建的幂等性?
答案:
问题分析: 重复下单的原因:
- 用户重复点击
- 网络超时重试
- 前端未防抖
- 恶意刷单
方案一:前端防抖
核心思想: 前端限制用户短时间内多次点击。
实现:
let submitting = false;
function submitOrder() {
if (submitting) {
return; // 正在提交中,忽略
}
submitting = true;
fetch('/api/order/create', {
method: 'POST',
body: JSON.stringify(orderData)
})
.then(res => {
// 处理结果
})
.finally(() => {
submitting = false; // 完成后恢复
});
}
优点:
- 简单有效
缺点:
- 仅防止前端重复
- 无法防止恶意绕过前端
方案二:唯一索引(推荐)
核心思想: 数据库层面保证唯一性。
实现:
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
idempotent_key VARCHAR(64) UNIQUE, -- 幂等键
...
);
CREATE UNIQUE INDEX uk_user_idempotent ON orders(user_id, idempotent_key);
创建订单:
@Transactional
public Order createOrder(OrderRequest request, String idempotentKey) {
try {
// 1. 构建订单
Order order = new Order();
order.setUserId(request.getUserId());
order.setIdempotentKey(idempotentKey);
order.setItems(request.getItems());
// ...
// 2. 保存订单(唯一索引保证幂等)
orderRepository.save(order);
// 3. 扣减库存
inventoryService.deduct(order.getItems());
return order;
} catch (DuplicateKeyException e) {
// 幂等键重复,说明订单已创建
return orderRepository.findByIdempotentKey(idempotentKey);
}
}
幂等键生成:
// 方案1:前端生成UUID
String idempotentKey = UUID.randomUUID().toString();
// 方案2:后端生成(基于购物车内容)
String idempotentKey = DigestUtils.md5Hex(
userId + ":" + cartItems.toString() + ":" + timestamp
);
优点:
- 数据库层面保证
- 可靠性高
缺点:
- 依赖唯一索引
- 需要生成幂等键
方案三:分布式锁
核心思想: 使用Redis分布式锁,同一用户同时只能创建一个订单。
实现:
public Order createOrder(OrderRequest request) {
String lockKey = "order:create:" + request.getUserId();
// 尝试获取锁
boolean locked = redisLock.tryLock(lockKey, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("正在创建订单,请勿重复提交");
}
try {
// 创建订单
Order order = doCreateOrder(request);
return order;
} finally {
// 释放锁
redisLock.unlock(lockKey);
}
}
优点:
- 防止并发创建
- 灵活控制
缺点:
- 依赖Redis
- 锁超时需要处理
方案四:Token机制
核心思想: 用户进入结算页时,服务端生成唯一Token,提交订单时校验Token。
流程:
1. 用户进入结算页
→ 请求服务端生成Token
→ 服务端生成Token并存Redis
→ 返回Token给前端
2. 用户提交订单
→ 携带Token
→ 服务端校验Token是否存在
→ 存在则删除Token,创建订单
→ 不存在则拒绝(重复提交)
实现:
// 生成Token
public String generateOrderToken(Long userId) {
String token = UUID.randomUUID().toString();
String key = "order:token:" + token;
redis.setex(key, 300, userId.toString()); // 5分钟有效
return token;
}
// 创建订单(校验Token)
@Transactional
public Order createOrder(OrderRequest request, String token) {
String key = "order:token:" + token;
// 检查Token是否存在
String userId = redis.get(key);
if (userId == null) {
throw new BizException("订单Token无效或已使用");
}
// 验证Token归属
if (!userId.equals(request.getUserId().toString())) {
throw new BizException("订单Token不匹配");
}
// 删除Token(保证一次性)
redis.del(key);
// 创建订单
return doCreateOrder(request);
}
优点:
- 防止重复提交
- 安全性高(Token一次性)
缺点:
- 需要多次交互
- Token过期需要重新获取
方案对比:
| 方案 | 可靠性 | 易用性 | 性能 | 适用场景 |
|---|---|---|---|---|
| 前端防抖 | ★★☆☆☆ | ★★★★★ | ★★★★★ | 辅助手段 |
| 唯一索引 | ★★★★★ | ★★★★☆ | ★★★★☆ | 通用 |
| 分布式锁 | ★★★★☆ | ★★★☆☆ | ★★★☆☆ | 高并发 |
| Token机制 | ★★★★★ | ★★★☆☆ | ★★★★☆ | 安全性要求高 |
推荐方案: 采用唯一索引+Token机制的组合。
实施要点:
-
多层防护:
L1:前端防抖(用户体验) L2:Token机制(防恶意) L3:唯一索引(最后防线) -
幂等键设计:
幂等键组成: userId + cartVersion + timestamp 例如: 123_v10_1679800000 说明: - userId:用户ID - cartVersion:购物车版本(购物车内容变化版本号+1) - timestamp:提交时间戳(精确到秒) -
异常处理:
try { return createOrder(request, token); } catch (DuplicateKeyException e) { // 唯一索引冲突,查询已存在的订单 Order existingOrder = findByIdempotentKey(idempotentKey); return existingOrder; } catch (BizException e) { // Token无效等业务异常 throw e; }
延伸思考:
- 如何设计订单创建的限流(防止刷单)?
- 订单创建失败如何回滚库存?
- 分布式事务下如何保证订单创建的一致性?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:3916。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-031:订单的分布式事务设计(Saga模式)
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-031 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4180 |
题干与约束
订单创建涉及多个服务(订单服务、库存服务、优惠券服务、积分服务)。如何使用Saga模式保证分布式事务一致性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单创建涉及多个服务(订单服务、库存服务、优惠券服务、积分服务)。如何使用Saga模式保证分布式事务一致性?
答案:
问题分析: 订单创建的分布式事务流程:
- 扣减库存(库存服务)
- 核销优惠券(营销服务)
- 扣减积分(会员服务)
- 创建订单(订单服务)
任一环节失败,已执行的操作需要回滚。
Saga模式实现(使用Go):
package saga
import (
"context"
"fmt"
)
// SagaStep 定义Saga步骤
type SagaStep struct {
Name string
Execute func(ctx context.Context, data interface{}) error
Compensate func(ctx context.Context, data interface{}) error
}
// SagaOrchestrator Saga编排器
type SagaOrchestrator struct {
steps []SagaStep
}
// Execute 执行Saga
func (s *SagaOrchestrator) Execute(ctx context.Context, data interface{}) error {
executedSteps := make([]int, 0)
// 正向执行
for i, step := range s.steps {
if err := step.Execute(ctx, data); err != nil {
// 执行失败,触发补偿
s.compensate(ctx, data, executedSteps)
return fmt.Errorf("步骤 %s 执行失败: %w", step.Name, err)
}
executedSteps = append(executedSteps, i)
}
return nil
}
// compensate 执行补偿
func (s *SagaOrchestrator) compensate(ctx context.Context, data interface{}, executedSteps []int) {
// 反向补偿
for i := len(executedSteps) - 1; i >= 0; i-- {
stepIndex := executedSteps[i]
step := s.steps[stepIndex]
if err := step.Compensate(ctx, data); err != nil {
// 补偿失败,记录日志,转人工处理
log.Errorf("步骤 %s 补偿失败: %v", step.Name, err)
}
}
}
// 订单创建Saga示例
func CreateOrderSaga(orderReq *CreateOrderRequest) error {
saga := &SagaOrchestrator{
steps: []SagaStep{
// 步骤1:扣减库存
{
Name: "DeductInventory",
Execute: func(ctx context.Context, data interface{}) error {
req := data.(*CreateOrderRequest)
return inventoryService.Deduct(ctx, req.Items)
},
Compensate: func(ctx context.Context, data interface{}) error {
req := data.(*CreateOrderRequest)
return inventoryService.Release(ctx, req.Items)
},
},
// 步骤2:核销优惠券
{
Name: "UseCoupon",
Execute: func(ctx context.Context, data interface{}) error {
req := data.(*CreateOrderRequest)
if req.CouponID == "" {
return nil // 无优惠券,跳过
}
return couponService.Use(ctx, req.UserID, req.CouponID)
},
Compensate: func(ctx context.Context, data interface{}) error {
req := data.(*CreateOrderRequest)
if req.CouponID == "" {
return nil
}
return couponService.Release(ctx, req.UserID, req.CouponID)
},
},
// 步骤3:扣减积分
{
Name: "DeductPoints",
Execute: func(ctx context.Context, data interface{}) error {
req := data.(*CreateOrderRequest)
if req.PointsToUse == 0 {
return nil
}
return pointsService.Deduct(ctx, req.UserID, req.PointsToUse)
},
Compensate: func(ctx context.Context, data interface{}) error {
req := data.(*CreateOrderRequest)
if req.PointsToUse == 0 {
return nil
}
return pointsService.Refund(ctx, req.UserID, req.PointsToUse)
},
},
// 步骤4:创建订单
{
Name: "CreateOrder",
Execute: func(ctx context.Context, data interface{}) error {
req := data.(*CreateOrderRequest)
order := &Order{
OrderID: generateOrderID(),
UserID: req.UserID,
Items: req.Items,
Status: OrderStatusPending,
}
return orderRepo.Create(ctx, order)
},
Compensate: func(ctx context.Context, data interface{}) error {
req := data.(*CreateOrderRequest)
// 订单创建失败不需要补偿(未持久化)
return nil
},
},
},
}
return saga.Execute(context.Background(), orderReq)
}
优点:
- 逻辑清晰(正向+补偿)
- 解耦各服务
- 支持长事务
缺点:
- 实现复杂
- 补偿可能失败(需要人工介入)
- 中间状态可见(不是强一致性)
延伸思考:
- Saga补偿失败如何处理?
- 如何设计Saga的可视化监控?
- Saga vs 2PC(两阶段提交)如何选择?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4180。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-032:订单数据的分库分表设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-032 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4344 |
题干与约束
订单表数据量达到亿级,单表查询性能下降。如何设计订单的分库分表方案?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单表数据量达到亿级,单表查询性能下降。如何设计订单的分库分表方案?
答案:
问题分析: 订单分库分表的核心要素:
- 分片键选择(user_id还是order_id)
- 分片数量(16、32、64、128)
- 跨片查询(如运营查询某时间段订单)
- 数据扩容
方案一:按user_id分片(推荐)
核心思想: 同一用户的订单存储在同一分片。
分片规则:
// 分片数量
const ShardCount = 64
// 计算分片
func GetShardIndex(userID int64) int {
return int(userID % ShardCount)
}
// 路由到数据源
func GetDataSource(userID int64) *sql.DB {
shardIndex := GetShardIndex(userID)
return dataSources[shardIndex]
}
表结构:
-- 64个库,每个库有orders表
database_00.orders
database_01.orders
...
database_63.orders
订单ID生成:
order_id = snowflake_id
不包含分片信息(通过user_id路由)
优点:
- 用户维度查询高效(“我的订单”)
- 单用户订单聚合容易
- 避免跨库JOIN
缺点:
- 按订单ID查询需要广播(查所有分片)
- 数据可能不均匀(大客户订单多)
方案二:按order_id分片
核心思想: 按订单ID散列分片。
分片规则:
func GetShardIndex(orderID int64) int {
return int(orderID % ShardCount)
}
订单ID生成(包含分片信息):
// 订单ID结构:分片位 + Snowflake ID
// 前6位:分片号(0-63)
// 后13位:Snowflake ID
func GenerateOrderID(userID int64) int64 {
shardIndex := GetShardIndex(userID)
snowflakeID := snowflake.Generate()
// 组装:分片号(6位) + snowflake(13位)
return int64(shardIndex)*1e13 + snowflakeID
}
// 解析分片
func ParseShard(orderID int64) int {
return int(orderID / 1e13)
}
优点:
- 按订单ID查询高效(直接定位分片)
- 数据均匀
缺点:
- 用户维度查询需要广播
- “我的订单“查询慢
方案三:复合分片
核心思想: 主表按user_id分片,建立order_id到分片的映射表。
设计:
主表(按user_id分片):
shard_00.orders
shard_01.orders
映射表(不分片,单独集群):
order_routing
├── order_id(主键)
├── shard_index(分片号)
└── user_id
查询流程:
1. 按订单ID查询:
- 查询order_routing获取分片号
- 路由到对应分片查询
2. 按用户ID查询:
- 直接路由到用户分片
优点:
- 支持多种查询方式
- 灵活
缺点:
- 映射表是单点
- 实现复杂
方案对比:
| 方案 | 用户查询 | 订单查询 | 数据均匀度 | 实施难度 |
|---|---|---|---|---|
| 按user_id | ★★★★★ | ★★☆☆☆ | ★★★☆☆ | ★★★★☆ |
| 按order_id | ★★☆☆☆ | ★★★★★ | ★★★★★ | ★★★★☆ |
| 复合分片 | ★★★★★ | ★★★★★ | ★★★★★ | ★★☆☆☆ |
推荐方案: 采用按user_id分片。
实施要点(Go实现):
-
分片路由中间件:
package sharding import ( "context" "database/sql" ) // ShardingManager 分片管理器 type ShardingManager struct { dataSources []*sql.DB shardCount int } // NewShardingManager 创建分片管理器 func NewShardingManager(dsns []string) (*ShardingManager, error) { dbs := make([]*sql.DB, len(dsns)) for i, dsn := range dsns { db, err := sql.Open("mysql", dsn) if err != nil { return nil, err } dbs[i] = db } return &ShardingManager{ dataSources: dbs, shardCount: len(dsns), }, nil } // GetDB 根据用户ID获取数据库连接 func (sm *ShardingManager) GetDB(userID int64) *sql.DB { shardIndex := userID % int64(sm.shardCount) return sm.dataSources[shardIndex] } // ExecuteOnShard 在指定分片执行查询 func (sm *ShardingManager) ExecuteOnShard(ctx context.Context, userID int64, fn func(*sql.DB) error) error { db := sm.GetDB(userID) return fn(db) } // Broadcast 广播到所有分片执行 func (sm *ShardingManager) Broadcast(ctx context.Context, fn func(*sql.DB) error) []error { errors := make([]error, 0) for _, db := range sm.dataSources { if err := fn(db); err != nil { errors = append(errors, err) } } return errors } -
订单Repository实现:
type OrderRepository struct { shardingMgr *ShardingManager } // Create 创建订单 func (r *OrderRepository) Create(ctx context.Context, order *Order) error { return r.shardingMgr.ExecuteOnShard(ctx, order.UserID, func(db *sql.DB) error { query := `INSERT INTO orders (order_id, user_id, total_amount, status, created_at) VALUES (?, ?, ?, ?, ?)` _, err := db.ExecContext(ctx, query, order.OrderID, order.UserID, order.TotalAmount, order.Status, time.Now()) return err }) } // FindByUserID 查询用户订单(单分片) func (r *OrderRepository) FindByUserID(ctx context.Context, userID int64, page, size int) ([]*Order, error) { var orders []*Order err := r.shardingMgr.ExecuteOnShard(ctx, userID, func(db *sql.DB) error { query := `SELECT * FROM orders WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?` rows, err := db.QueryContext(ctx, query, userID, size, (page-1)*size) if err != nil { return err } defer rows.Close() for rows.Next() { order := &Order{} // 扫描数据... orders = append(orders, order) } return nil }) return orders, err } // FindByOrderID 按订单ID查询(需要广播) func (r *OrderRepository) FindByOrderID(ctx context.Context, orderID int64) (*Order, error) { // 方案1:广播到所有分片查询(慢) for _, db := range r.shardingMgr.dataSources { order, err := queryFromDB(db, orderID) if err == nil && order != nil { return order, nil } } return nil, ErrOrderNotFound // 方案2:维护order_id -> user_id映射(推荐) // userID := r.getOrderUserMapping(orderID) // return r.FindByUserAndOrderID(ctx, userID, orderID) } -
订单ID包含分片信息:
// 订单ID结构:6位分片号 + 13位Snowflake func GenerateOrderIDWithShard(userID int64) int64 { shardIndex := userID % ShardCount snowflakeID := snowflake.NextID() // 组装:前6位是分片号 return shardIndex*1e13 + snowflakeID } // 解析分片号 func ParseShardFromOrderID(orderID int64) int { return int(orderID / 1e13) } // 直接定位查询 func (r *OrderRepository) FindByOrderIDFast(ctx context.Context, orderID int64) (*Order, error) { shardIndex := ParseShardFromOrderID(orderID) db := r.shardingMgr.dataSources[shardIndex] query := `SELECT * FROM orders WHERE order_id=?` row := db.QueryRowContext(ctx, query, orderID) order := &Order{} err := row.Scan(&order.OrderID, &order.UserID, ...) return order, err } -
扩容方案:
扩容策略(64 → 128分片): 方案A:双写期 1. 新建64个分片(总共128个) 2. 新订单写入新分片规则 3. 老订单保留在老分片 4. 查询时先查新分片,未命中再查老分片 方案B:一致性哈希 1. 使用一致性哈希算法 2. 扩容时只需迁移部分数据 3. 数据迁移期间双写
延伸思考:
- 如何设计分库分表的全局查询(如运营后台)?
- 订单归档如何设计(冷热数据分离)?
- 分库分表如何支持跨库JOIN?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4344。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-033:订单履约流程的编排
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-033 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4663 |
题干与约束
订单支付成功后,需要依次执行:分配仓库、创建拣货单、打包、出库、创建运单、发货。如何设计订单履约流程的编排?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单支付成功后,需要依次执行:分配仓库、创建拣货单、打包、出库、创建运单、发货。如何设计订单履约流程的编排?
答案:
推荐方案:事件驱动+状态机
架构(Go实现):
package fulfillment
import (
"context"
)
// FulfillmentEvent 履约事件
type FulfillmentEvent struct {
OrderID int64
EventType string
Data map[string]interface{}
}
// FulfillmentOrchestrator 履约编排器
type FulfillmentOrchestrator struct {
eventBus EventBus
}
// OnOrderPaid 订单支付事件处理
func (o *FulfillmentOrchestrator) OnOrderPaid(ctx context.Context, orderID int64) error {
// 1. 分配仓库
warehouse, err := o.allocateWarehouse(ctx, orderID)
if err != nil {
return err
}
// 2. 创建拣货单
pickingOrder, err := o.createPickingOrder(ctx, orderID, warehouse.ID)
if err != nil {
return err
}
// 3. 发布拣货事件
o.eventBus.Publish(&FulfillmentEvent{
OrderID: orderID,
EventType: "PickingOrderCreated",
Data: map[string]interface{}{
"pickingOrderID": pickingOrder.ID,
"warehouseID": warehouse.ID,
},
})
return nil
}
// OnPickingCompleted 拣货完成事件处理
func (o *FulfillmentOrchestrator) OnPickingCompleted(ctx context.Context, event *FulfillmentEvent) error {
orderID := event.OrderID
// 1. 打包
if err := o.pack(ctx, orderID); err != nil {
return err
}
// 2. 出库
if err := o.outbound(ctx, orderID); err != nil {
return err
}
// 3. 创建物流运单
trackingNumber, err := o.createShipment(ctx, orderID)
if err != nil {
return err
}
// 4. 发布发货事件
o.eventBus.Publish(&FulfillmentEvent{
OrderID: orderID,
EventType: "OrderShipped",
Data: map[string]interface{}{
"trackingNumber": trackingNumber,
},
})
return nil
}
// 事件监听器
func (o *FulfillmentOrchestrator) Start() {
o.eventBus.Subscribe("OrderPaid", o.OnOrderPaid)
o.eventBus.Subscribe("PickingCompleted", o.OnPickingCompleted)
o.eventBus.Subscribe("PackingCompleted", o.OnPackingCompleted)
// ...
}
履约状态机:
type FulfillmentStatus int
const (
FulfillmentPending FulfillmentStatus = 0 // 待履约
FulfillmentWarehouseAllocated FulfillmentStatus = 1 // 已分配仓库
FulfillmentPicking FulfillmentStatus = 2 // 拣货中
FulfillmentPacked FulfillmentStatus = 3 // 已打包
FulfillmentOutbound FulfillmentStatus = 4 // 已出库
FulfillmentShipped FulfillmentStatus = 5 // 已发货
FulfillmentReceived FulfillmentStatus = 6 // 已签收
)
// 状态流转规则
var fulfillmentTransitions = map[FulfillmentStatus][]FulfillmentStatus{
FulfillmentPending: {FulfillmentWarehouseAllocated},
FulfillmentWarehouseAllocated: {FulfillmentPicking},
FulfillmentPicking: {FulfillmentPacked},
FulfillmentPacked: {FulfillmentOutbound},
FulfillmentOutbound: {FulfillmentShipped},
FulfillmentShipped: {FulfillmentReceived},
}
// UpdateStatus 更新履约状态
func (o *FulfillmentOrchestrator) UpdateStatus(ctx context.Context,
orderID int64, newStatus FulfillmentStatus) error {
// 1. 查询当前状态
currentStatus, err := o.getStatus(ctx, orderID)
if err != nil {
return err
}
// 2. 检查状态流转是否合法
allowedTransitions := fulfillmentTransitions[currentStatus]
if !contains(allowedTransitions, newStatus) {
return fmt.Errorf("不允许从%v转换到%v", currentStatus, newStatus)
}
// 3. 更新状态
return o.updateStatusInDB(ctx, orderID, newStatus)
}
延伸思考:
- 履约流程如何支持异常处理(缺货、商品损坏)?
- 多个发货单如何协调履约进度?
- 履约时效如何监控和告警?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4663。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-034:订单的退款和售后流程设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-034 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 支付状态机、资金安全 |
| 场景标签 | 支付回调、资金链路 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4811 |
题干与约束
用户申请退款(仅退款、退货退款),如何设计售后流程,保证资金安全和用户体验?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户申请退款(仅退款、退货退款),如何设计售后流程,保证资金安全和用户体验?
答案:
退款场景:
- 仅退款(未发货)
- 退货退款(已发货)
- 部分退款(退部分商品)
- 售后退款(商品质量问题)
推荐方案(Go实现):
退款状态机:
type RefundStatus int
const (
RefundPending RefundStatus = 0 // 待审核
RefundApproved RefundStatus = 1 // 已同意
RefundRejected RefundStatus = 2 // 已拒绝
RefundReturning RefundStatus = 3 // 退货中
RefundReturned RefundStatus = 4 // 已退货
RefundCompleted RefundStatus = 5 // 已退款
)
// Refund 退款单
type Refund struct {
RefundID int64
OrderID int64
UserID int64
RefundType string // REFUND_ONLY, RETURN_REFUND
RefundAmount decimal.Decimal
Reason string
Status RefundStatus
CreatedAt time.Time
}
// RefundService 退款服务
type RefundService struct {
orderRepo OrderRepository
paymentSvc PaymentService
inventorySvc InventoryService
}
// CreateRefund 创建退款申请
func (s *RefundService) CreateRefund(ctx context.Context, req *RefundRequest) (*Refund, error) {
// 1. 校验订单状态
order, err := s.orderRepo.FindByID(ctx, req.OrderID)
if err != nil {
return nil, err
}
if order.Status != OrderStatusPaid && order.Status != OrderStatusShipped {
return nil, errors.New("订单状态不允许退款")
}
// 2. 校验退款金额
if req.RefundAmount.GreaterThan(order.PaidAmount) {
return nil, errors.New("退款金额超过实付金额")
}
// 3. 创建退款单
refund := &Refund{
RefundID: generateRefundID(),
OrderID: req.OrderID,
UserID: req.UserID,
RefundType: req.RefundType,
RefundAmount: req.RefundAmount,
Reason: req.Reason,
Status: RefundPending,
CreatedAt: time.Now(),
}
if err := s.refundRepo.Create(ctx, refund); err != nil {
return nil, err
}
// 4. 自动审核(部分场景)
if s.shouldAutoApprove(refund) {
return s.Approve(ctx, refund.RefundID)
}
return refund, nil
}
// Approve 审核通过退款
func (s *RefundService) Approve(ctx context.Context, refundID int64) (*Refund, error) {
refund, err := s.refundRepo.FindByID(ctx, refundID)
if err != nil {
return nil, err
}
// 1. 更新退款状态
refund.Status = RefundApproved
if err := s.refundRepo.Update(ctx, refund); err != nil {
return nil, err
}
// 2. 根据退款类型处理
if refund.RefundType == "REFUND_ONLY" {
// 仅退款:直接退款
return s.processRefund(ctx, refund)
} else {
// 退货退款:等待用户退货
refund.Status = RefundReturning
s.refundRepo.Update(ctx, refund)
// 生成退货地址和快递单号
s.generateReturnLabel(ctx, refund)
return refund, nil
}
}
// processRefund 执行退款
func (s *RefundService) processRefund(ctx context.Context, refund *Refund) (*Refund, error) {
// 1. 调用支付服务退款
if err := s.paymentSvc.Refund(ctx, refund.OrderID, refund.RefundAmount); err != nil {
return nil, fmt.Errorf("退款失败: %w", err)
}
// 2. 回补库存
order, _ := s.orderRepo.FindByID(ctx, refund.OrderID)
if err := s.inventorySvc.Return(ctx, order.Items); err != nil {
log.Errorf("回补库存失败: %v", err)
// 不阻塞退款流程,记录异常任务
s.createCompensationTask(ctx, "ReturnInventory", refund.RefundID)
}
// 3. 更新退款状态
refund.Status = RefundCompleted
if err := s.refundRepo.Update(ctx, refund); err != nil {
return nil, err
}
// 4. 更新订单状态
s.orderRepo.UpdateStatus(ctx, refund.OrderID, OrderStatusRefunded)
// 5. 发送通知
s.notifySvc.Send(ctx, refund.UserID, "退款已到账")
return refund, nil
}
自动审核规则:
func (s *RefundService) shouldAutoApprove(refund *Refund) bool {
// 自动同意条件:
// 1. 订单未发货
// 2. 退款金额 < 500元
// 3. 用户信用良好
order, _ := s.orderRepo.FindByID(context.Background(), refund.OrderID)
if order.Status == OrderStatusPaid &&
refund.RefundAmount.LessThan(decimal.NewFromInt(500)) &&
s.userSvc.IsTrusted(refund.UserID) {
return true
}
return false
}
延伸思考:
- 退款失败如何重试和补偿?
- 恶意退款如何识别和防范?
- 部分退款如何计算退款金额(商品价+运费分摊)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4811。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-035:订单的异常处理(缺货、地址错误)
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-035 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4984 |
题干与约束
订单履约过程中可能出现异常(缺货、地址无法送达、商品损坏)。如何设计异常处理流程?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单履约过程中可能出现异常(缺货、地址无法送达、商品损坏)。如何设计异常处理流程?
答案:
异常场景及处理方案:
-
库存不足(超卖):
// 发现超卖 func (s *FulfillmentService) HandleOutOfStock(ctx context.Context, orderID int64) error { // 1. 联系用户 s.notifySvc.Send(ctx, order.UserID, "商品暂时缺货,为您申请退款") // 2. 创建退款 refund := &Refund{ OrderID: orderID, RefundType: "OUT_OF_STOCK", RefundAmount: order.PaidAmount, AutoApprove: true, } return s.refundSvc.CreateRefund(ctx, refund) } -
地址无法送达:
func (s *FulfillmentService) HandleUndeliverableAddress(ctx context.Context, orderID int64) error { // 1. 通知用户修改地址 s.notifySvc.Send(ctx, order.UserID, "收货地址无法送达,请修改地址") // 2. 订单挂起 s.orderRepo.UpdateStatus(ctx, orderID, OrderStatusAddressError) // 3. 用户修改地址后重新履约 // 或超时自动退款 s.scheduleAutoRefund(ctx, orderID, 48*time.Hour) return nil } -
商品损坏:
func (s *FulfillmentService) HandleDamaged(ctx context.Context, orderID int64, itemID string) error { // 1. 记录损坏 s.logDamage(ctx, orderID, itemID) // 2. 检查是否有替代品 if hasReplace, err := s.inventorySvc.CheckStock(ctx, itemID); err == nil && hasReplace { // 有替代品,重新拣货 return s.repick(ctx, orderID, itemID) } // 3. 无替代品,部分退款 item := s.getOrderItem(ctx, orderID, itemID) return s.refundSvc.CreatePartialRefund(ctx, orderID, item.Amount) }
延伸思考:
- 异常订单如何统计和分析?
- 如何设计异常的自动化处理规则?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:4984。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-036:订单的搜索和查询优化
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-036 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5054 |
题干与约束
用户需要查询历史订单(按时间、状态、商品筛选),运营需要查询全部订单。如何设计订单查询系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户需要查询历史订单(按时间、状态、商品筛选),运营需要查询全部订单。如何设计订单查询系统?
答案:
方案一:主从分离
用户查询(读从库):
// 查询我的订单
func (r *OrderRepository) FindUserOrders(ctx context.Context,
userID int64, filter *OrderFilter) ([]*Order, error) {
// 路由到从库
db := r.shardingMgr.GetReadDB(userID)
query := `SELECT * FROM orders WHERE user_id=?`
args := []interface{}{userID}
// 添加筛选条件
if filter.Status != "" {
query += ` AND status=?`
args = append(args, filter.Status)
}
if !filter.StartTime.IsZero() {
query += ` AND created_at >= ?`
args = append(args, filter.StartTime)
}
query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
args = append(args, filter.PageSize, filter.Offset)
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanOrders(rows)
}
方案二:ES同步(推荐)
架构:
订单创建/更新 → Kafka → 同步Worker → Elasticsearch
ES索引设计:
{
"order_id": "123",
"user_id": 456,
"status": "PAID",
"total_amount": 1000,
"created_at": "2024-04-18T10:00:00Z",
"items": [
{"sku_id": "789", "title": "iPhone 15"}
]
}
查询实现:
// 复杂查询用ES
func (r *OrderRepository) SearchOrders(ctx context.Context,
query *OrderSearchQuery) (*SearchResult, error) {
esQuery := elastic.NewBoolQuery()
// 用户维度
if query.UserID > 0 {
esQuery.Must(elastic.NewTermQuery("user_id", query.UserID))
}
// 订单号
if query.OrderID != "" {
esQuery.Must(elastic.NewTermQuery("order_id", query.OrderID))
}
// 状态
if len(query.Statuses) > 0 {
esQuery.Must(elastic.NewTermsQuery("status", query.Statuses...))
}
// 时间范围
if !query.StartTime.IsZero() || !query.EndTime.IsZero() {
rangeQuery := elastic.NewRangeQuery("created_at")
if !query.StartTime.IsZero() {
rangeQuery.Gte(query.StartTime)
}
if !query.EndTime.IsZero() {
rangeQuery.Lte(query.EndTime)
}
esQuery.Must(rangeQuery)
}
// 商品筛选(嵌套查询)
if query.SkuID != "" {
esQuery.Must(elastic.NewNestedQuery("items",
elastic.NewTermQuery("items.sku_id", query.SkuID)))
}
// 执行查询
searchResult, err := r.esClient.Search().
Index("orders").
Query(esQuery).
From(query.From).
Size(query.Size).
Sort("created_at", false).
Do(ctx)
if err != nil {
return nil, err
}
return parseESResult(searchResult), nil
}
延伸思考:
- 订单数据如何归档(如1年前的订单)?
- 分库分表+ES同步如何保证一致性?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5054。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-037:订单的消息通知设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-037 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5180 |
题干与约束
订单状态变化时需要通知用户(下单成功、发货、签收)。如何设计消息通知系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单状态变化时需要通知用户(下单成功、发货、签收)。如何设计消息通知系统?
答案:
通知渠道:
- App推送
- 短信
- 微信公众号/服务号
- 站内信
- 邮件
推荐方案(Go实现):
package notification
import (
"context"
)
// NotificationService 通知服务
type NotificationService struct {
pushSvc PushService // App推送
smsSvc SMSService // 短信
wechatSvc WechatService // 微信
emailSvc EmailService // 邮件
inboxSvc InboxService // 站内信
}
// NotifyOrderStatusChanged 订单状态变更通知
func (s *NotificationService) NotifyOrderStatusChanged(ctx context.Context,
order *Order, oldStatus, newStatus OrderStatus) error {
// 根据状态确定通知内容
template := s.getTemplate(newStatus)
// 并行发送多渠道通知
errChan := make(chan error, 5)
// 1. App推送(必发)
go func() {
errChan <- s.pushSvc.Push(ctx, order.UserID, PushMessage{
Title: template.Title,
Content: template.Content,
Data: map[string]interface{}{"order_id": order.OrderID},
})
}()
// 2. 短信(重要状态才发)
if s.shouldSendSMS(newStatus) {
go func() {
phone := s.getUserPhone(ctx, order.UserID)
errChan <- s.smsSvc.Send(ctx, phone, template.SMSContent)
}()
} else {
errChan <- nil
}
// 3. 微信(用户已绑定才发)
go func() {
if openID := s.getUserWechatOpenID(ctx, order.UserID); openID != "" {
errChan <- s.wechatSvc.SendTemplateMessage(ctx, openID, template.WechatTemplate)
} else {
errChan <- nil
}
}()
// 4. 站内信(必发)
go func() {
errChan <- s.inboxSvc.Create(ctx, &InboxMessage{
UserID: order.UserID,
Title: template.Title,
Content: template.Content,
Type: "ORDER_UPDATE",
})
}()
// 5. 邮件(用户订阅才发)
go func() {
if s.userHasEmailSubscription(ctx, order.UserID) {
email := s.getUserEmail(ctx, order.UserID)
errChan <- s.emailSvc.Send(ctx, email, template.EmailContent)
} else {
errChan <- nil
}
}()
// 收集结果(至少一个渠道成功即可)
successCount := 0
for i := 0; i < 5; i++ {
if err := <-errChan; err == nil {
successCount++
}
}
if successCount == 0 {
return errors.New("所有通知渠道都失败")
}
return nil
}
// 通知模板
func (s *NotificationService) getTemplate(status OrderStatus) *NotificationTemplate {
templates := map[OrderStatus]*NotificationTemplate{
OrderStatusPaid: {
Title: "订单支付成功",
Content: "您的订单已支付成功,我们将尽快为您发货",
SMSContent: "【京东】您的订单已支付成功,预计3天内送达",
},
OrderStatusShipped: {
Title: "订单已发货",
Content: "您的订单已发货,快递单号:SF1234567890",
SMSContent: "【京东】您的订单已发货,单号SF1234567890",
},
OrderStatusReceived: {
Title: "订单已签收",
Content: "您的订单已签收,期待您的评价",
},
}
return templates[status]
}
// 是否发送短信
func (s *NotificationService) shouldSendSMS(status OrderStatus) bool {
// 只有关键状态发短信(控制成本)
importantStatuses := []OrderStatus{
OrderStatusPaid,
OrderStatusShipped,
OrderStatusRefunded,
}
for _, s := range importantStatuses {
if s == status {
return true
}
}
return false
}
延伸思考:
- 通知失败如何重试?
- 如何设计通知的用户偏好设置(关闭某些通知)?
- 大批量通知如何限流(避免骚扰)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5180。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-038:订单数据的冷热分离
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-038 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5332 |
题干与约束
订单数据90天后很少查询,但占用大量存储。如何设计订单数据的冷热分离?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单数据90天后很少查询,但占用大量存储。如何设计订单数据的冷热分离?
答案:
推荐方案:
// 冷热分离策略
type OrderArchiveService struct {
hotDB *sql.DB // 热数据库(MySQL)
coldDB *sql.DB // 冷数据库(可以是低成本存储)
ossClient OSSClient // 对象存储
}
// 归档策略
func (s *OrderArchiveService) ArchiveOrders(ctx context.Context) error {
// 1. 查询90天前已完成的订单
cutoffTime := time.Now().AddDate(0, 0, -90)
query := `SELECT * FROM orders
WHERE status IN ('COMPLETED', 'CANCELLED', 'REFUNDED')
AND updated_at < ?
LIMIT 1000`
rows, err := s.hotDB.QueryContext(ctx, query, cutoffTime)
if err != nil {
return err
}
defer rows.Close()
orders := make([]*Order, 0)
for rows.Next() {
order := &Order{}
// 扫描数据...
orders = append(orders, order)
}
// 2. 写入冷库
for _, order := range orders {
if err := s.writeToArchive(ctx, order); err != nil {
log.Errorf("归档订单%d失败: %v", order.OrderID, err)
continue
}
// 3. 删除热库数据
if err := s.deleteFromHot(ctx, order.OrderID); err != nil {
log.Errorf("删除热库订单%d失败: %v", order.OrderID, err)
}
}
return nil
}
// 查询时智能路由
func (s *OrderArchiveService) FindByID(ctx context.Context, orderID int64) (*Order, error) {
// 1. 先查热库
order, err := s.queryFromHot(ctx, orderID)
if err == nil && order != nil {
return order, nil
}
// 2. 查冷库
order, err = s.queryFromArchive(ctx, orderID)
if err == nil && order != nil {
return order, nil
}
return nil, ErrOrderNotFound
}
延伸思考:
- 归档订单如何支持查询?
- 冷数据恢复到热库的策略?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5332。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-039:订单的实时数据统计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-039 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 订单编排、状态机 |
| 场景标签 | 交易主链路、履约协作 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5520 |
题干与约束
运营大盘需要实时显示订单量、GMV、转化率。如何设计订单的实时统计系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 运营大盘需要实时显示订单量、GMV、转化率。如何设计订单的实时统计系统?
答案:
推荐方案:Flink流式计算
// 实时统计指标
type OrderMetrics struct {
Timestamp time.Time
OrderCount int64 // 订单数
GMV decimal.Decimal // 交易额
PaidOrderCount int64 // 已支付订单数
AvgOrderAmount decimal.Decimal // 客单价
}
// 指标计算Worker(消费Kafka)
func ConsumeOrderEvents(ctx context.Context) {
consumer := kafka.NewConsumer(...)
for {
msg, err := consumer.ReadMessage(ctx)
if err != nil {
continue
}
event := parseOrderEvent(msg.Value)
switch event.Type {
case "OrderCreated":
// 订单数+1
metrics.IncrOrderCount()
case "OrderPaid":
// 已支付订单数+1
metrics.IncrPaidOrderCount()
// GMV累加
metrics.AddGMV(event.Order.PaidAmount)
case "OrderCancelled":
// 订单数-1(或单独统计取消数)
metrics.IncrCancelledOrderCount()
}
// 定期刷新到Redis
if time.Now().Unix()%10 == 0 {
metrics.FlushToRedis()
}
}
}
// 实时大盘查询
func GetRealTimeMetrics(ctx context.Context) (*OrderMetrics, error) {
// 从Redis读取实时指标
rdb := redis.NewClient(...)
orderCount, _ := rdb.Get(ctx, "metrics:order:count").Int64()
gmv, _ := rdb.Get(ctx, "metrics:order:gmv").Float64()
paidCount, _ := rdb.Get(ctx, "metrics:order:paid_count").Int64()
return &OrderMetrics{
Timestamp: time.Now(),
OrderCount: orderCount,
GMV: decimal.NewFromFloat(gmv),
PaidOrderCount: paidCount,
AvgOrderAmount: decimal.NewFromFloat(gmv).Div(decimal.NewFromInt(paidCount)),
}, nil
}
延伸思考:
- 实时统计如何保证准确性(与离线对账)?
- 多维度统计(按类目、品牌)如何设计?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5520。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-040:支付系统的整体架构设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-040 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 支付状态机、资金安全 |
| 场景标签 | 支付回调、资金链路 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5603 |
题干与约束
电商平台需要支持多种支付方式(支付宝、微信、银行卡)。如何设计支付系统的整体架构?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台需要支持多种支付方式(支付宝、微信、银行卡)。如何设计支付系统的整体架构?
答案:
问题分析: 支付系统的核心要素:
- 多渠道接入(支付宝、微信、银联)
- 支付安全性
- 异步回调处理
- 对账和资金安全
架构设计(Go实现):
package payment
import (
"context"
"time"
)
// PaymentChannel 支付渠道
type PaymentChannel string
const (
ChannelAlipay PaymentChannel = "ALIPAY"
ChannelWechat PaymentChannel = "WECHAT"
ChannelUnion PaymentChannel = "UNION"
)
// PaymentService 支付服务
type PaymentService struct {
alipayAdapter PaymentAdapter
wechatAdapter PaymentAdapter
unionAdapter PaymentAdapter
paymentRepo PaymentRepository
orderSvc OrderService
}
// PaymentAdapter 支付适配器接口(适配器模式)
type PaymentAdapter interface {
// 创建支付
CreatePayment(ctx context.Context, req *PaymentRequest) (*PaymentResponse, error)
// 查询支付状态
QueryPayment(ctx context.Context, paymentID string) (*PaymentStatus, error)
// 申请退款
Refund(ctx context.Context, req *RefundRequest) error
// 验证回调签名
VerifyCallback(callback *CallbackData) error
}
// Payment 支付单
type Payment struct {
PaymentID string
OrderID int64
UserID int64
Channel PaymentChannel
Amount decimal.Decimal
Status PaymentStatus
ThirdPartyID string // 第三方支付单号
CallbackData string // 回调原始数据
CreatedAt time.Time
PaidAt *time.Time
}
// CreatePayment 创建支付
func (s *PaymentService) CreatePayment(ctx context.Context,
orderID int64, channel PaymentChannel) (*PaymentResponse, error) {
// 1. 查询订单
order, err := s.orderSvc.GetOrder(ctx, orderID)
if err != nil {
return nil, err
}
// 2. 校验订单状态
if order.Status != OrderStatusPending {
return nil, errors.New("订单状态不正确")
}
// 3. 创建支付单
payment := &Payment{
PaymentID: generatePaymentID(),
OrderID: orderID,
UserID: order.UserID,
Channel: channel,
Amount: order.TotalAmount,
Status: PaymentStatusPending,
CreatedAt: time.Now(),
}
if err := s.paymentRepo.Create(ctx, payment); err != nil {
return nil, err
}
// 4. 调用支付渠道
adapter := s.getAdapter(channel)
resp, err := adapter.CreatePayment(ctx, &PaymentRequest{
OutTradeNo: payment.PaymentID,
Amount: payment.Amount,
Subject: fmt.Sprintf("订单%d支付", orderID),
NotifyURL: "https://api.example.com/payment/callback",
ReturnURL: "https://www.example.com/order/success",
})
if err != nil {
return nil, err
}
// 5. 保存第三方支付单号
payment.ThirdPartyID = resp.TradeNo
s.paymentRepo.Update(ctx, payment)
return resp, nil
}
// 获取支付适配器
func (s *PaymentService) getAdapter(channel PaymentChannel) PaymentAdapter {
switch channel {
case ChannelAlipay:
return s.alipayAdapter
case ChannelWechat:
return s.wechatAdapter
case ChannelUnion:
return s.unionAdapter
default:
return nil
}
}
// HandleCallback 处理支付回调
func (s *PaymentService) HandleCallback(ctx context.Context,
channel PaymentChannel, callback *CallbackData) error {
// 1. 验证签名
adapter := s.getAdapter(channel)
if err := adapter.VerifyCallback(callback); err != nil {
return fmt.Errorf("签名验证失败: %w", err)
}
// 2. 查询支付单
payment, err := s.paymentRepo.FindByID(ctx, callback.OutTradeNo)
if err != nil {
return err
}
// 3. 幂等性检查
if payment.Status == PaymentStatusSuccess {
return nil // 已处理,直接返回
}
// 4. 更新支付单状态
payment.Status = PaymentStatusSuccess
payment.PaidAt = &callback.PayTime
payment.CallbackData = callback.RawData
if err := s.paymentRepo.Update(ctx, payment); err != nil {
return err
}
// 5. 更新订单状态
if err := s.orderSvc.MarkAsPaid(ctx, payment.OrderID); err != nil {
// 支付成功但订单更新失败,记录补偿任务
s.createCompensationTask(ctx, payment.PaymentID)
return err
}
// 6. 发布支付成功事件
s.eventBus.Publish(&PaymentSuccessEvent{
OrderID: payment.OrderID,
PaymentID: payment.PaymentID,
Amount: payment.Amount,
})
return nil
}
支付宝适配器示例:
type AlipayAdapter struct {
client *alipay.Client
}
func (a *AlipayAdapter) CreatePayment(ctx context.Context,
req *PaymentRequest) (*PaymentResponse, error) {
// 调用支付宝SDK
payReq := alipay.TradeAppPay{
OutTradeNo: req.OutTradeNo,
TotalAmount: req.Amount.String(),
Subject: req.Subject,
NotifyURL: req.NotifyURL,
}
orderStr, err := a.client.TradeAppPay(payReq)
if err != nil {
return nil, err
}
return &PaymentResponse{
PayData: orderStr, // APP端拉起支付宝所需的参数
}, nil
}
func (a *AlipayAdapter) VerifyCallback(callback *CallbackData) error {
// 验证支付宝回调签名
return a.client.VerifySign(callback.RawData)
}
延伸思考:
- 支付系统如何实现高可用?
- 支付渠道故障如何降级?
- 支付回调丢失如何处理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5603。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-041:支付回调的幂等性处理
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-041 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 支付状态机、资金安全 |
| 场景标签 | 支付回调、资金链路 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5824 |
题干与约束
支付回调可能重复发送(网络重试、第三方重推)。如何保证支付回调处理的幂等性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 支付回调可能重复发送(网络重试、第三方重推)。如何保证支付回调处理的幂等性?
答案:
推荐方案(Go实现):
// 幂等性处理
func (s *PaymentService) HandleCallbackIdempotent(ctx context.Context,
callback *CallbackData) error {
paymentID := callback.OutTradeNo
lockKey := fmt.Sprintf("payment:callback:lock:%s", paymentID)
// 1. 获取分布式锁
lock := redis.NewDistributedLock(s.rdb, lockKey)
acquired, err := lock.TryLock(ctx, 30*time.Second)
if err != nil {
return err
}
if !acquired {
// 其他请求正在处理,直接返回成功
return nil
}
defer lock.Unlock(ctx)
// 2. 查询支付单
payment, err := s.paymentRepo.FindByID(ctx, paymentID)
if err != nil {
return err
}
// 3. 状态检查(幂等性)
if payment.Status == PaymentStatusSuccess {
log.Infof("支付单%s已处理,跳过", paymentID)
return nil // 已成功,幂等返回
}
// 4. 使用数据库行锁+版本号
affected, err := s.paymentRepo.UpdateStatusWithVersion(ctx,
paymentID,
PaymentStatusSuccess,
payment.Version,
)
if err != nil {
return err
}
if affected == 0 {
// 版本号不匹配,说明已被其他请求处理
log.Warnf("支付单%s已被处理,版本冲突", paymentID)
return nil
}
// 5. 执行后续操作
return s.postPaymentProcess(ctx, payment)
}
// 数据库更新(带版本号)
func (r *PaymentRepository) UpdateStatusWithVersion(ctx context.Context,
paymentID string, newStatus PaymentStatus, expectedVersion int) (int64, error) {
query := `UPDATE payments
SET status=?, version=version+1, updated_at=?
WHERE payment_id=? AND version=? AND status!=?`
result, err := r.db.ExecContext(ctx, query,
newStatus, time.Now(), paymentID, expectedVersion, PaymentStatusSuccess)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
延伸思考:
- 如何设计支付回调的重试机制?
- 回调处理失败如何人工介入?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5824。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-042:支付的对账系统设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-042 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 支付状态机、资金安全 |
| 场景标签 | 支付回调、资金链路 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5910 |
题干与约束
每天需要与支付宝、微信对账,确保平台账和渠道账一致。如何设计支付对账系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 每天需要与支付宝、微信对账,确保平台账和渠道账一致。如何设计支付对账系统?
答案:
对账流程(Go实现):
package reconciliation
import (
"context"
"time"
)
// ReconciliationService 对账服务
type ReconciliationService struct {
paymentRepo PaymentRepository
alipayClient *alipay.Client
wechatClient *wechat.Client
}
// DailyReconciliation 每日对账
func (s *ReconciliationService) DailyReconciliation(ctx context.Context, date time.Time) error {
// 1. 下载渠道对账单
alipayBill, err := s.downloadAlipayBill(ctx, date)
if err != nil {
return err
}
wechatBill, err := s.downloadWechatBill(ctx, date)
if err != nil {
return err
}
// 2. 查询平台当日支付记录
platformRecords, err := s.paymentRepo.FindByDate(ctx, date)
if err != nil {
return err
}
// 3. 三方对账
diff := s.compare(platformRecords, alipayBill, wechatBill)
// 4. 处理差异
if err := s.handleDifferences(ctx, diff); err != nil {
return err
}
// 5. 生成对账报告
report := s.generateReport(diff)
s.saveReport(ctx, report)
return nil
}
// ReconciliationDiff 对账差异
type ReconciliationDiff struct {
OnlyInPlatform []*Payment // 只在平台有
OnlyInChannel []*ChannelRecord // 只在渠道有
AmountMismatch []*Mismatch // 金额不一致
StatusMismatch []*Mismatch // 状态不一致
}
// compare 比对数据
func (s *ReconciliationService) compare(platform []*Payment,
alipay, wechat []*ChannelRecord) *ReconciliationDiff {
diff := &ReconciliationDiff{}
// 构建平台数据map
platformMap := make(map[string]*Payment)
for _, p := range platform {
platformMap[p.ThirdPartyID] = p
}
// 构建渠道数据map
channelMap := make(map[string]*ChannelRecord)
for _, c := range alipay {
channelMap[c.TradeNo] = c
}
for _, c := range wechat {
channelMap[c.TransactionID] = c
}
// 比对
for tradeNo, channelRecord := range channelMap {
platformRecord, exists := platformMap[tradeNo]
if !exists {
// 只在渠道有,平台无
diff.OnlyInChannel = append(diff.OnlyInChannel, channelRecord)
} else {
// 金额比对
if !platformRecord.Amount.Equal(channelRecord.Amount) {
diff.AmountMismatch = append(diff.AmountMismatch, &Mismatch{
TradeNo: tradeNo,
PlatformAmount: platformRecord.Amount,
ChannelAmount: channelRecord.Amount,
})
}
// 状态比对
if platformRecord.Status != channelRecord.Status {
diff.StatusMismatch = append(diff.StatusMismatch, &Mismatch{
TradeNo: tradeNo,
PlatformStatus: platformRecord.Status,
ChannelStatus: channelRecord.Status,
})
}
delete(platformMap, tradeNo)
}
}
// 只在平台有的
for _, p := range platformMap {
diff.OnlyInPlatform = append(diff.OnlyInPlatform, p)
}
return diff
}
// handleDifferences 处理差异
func (s *ReconciliationService) handleDifferences(ctx context.Context,
diff *ReconciliationDiff) error {
// 1. 只在渠道有的(平台漏单)
for _, record := range diff.OnlyInChannel {
log.Warnf("平台漏单: %s", record.TradeNo)
// 补单:创建支付记录
s.createMissingPayment(ctx, record)
}
// 2. 只在平台有的(渠道无记录,可能未支付成功)
for _, payment := range diff.OnlyInPlatform {
log.Warnf("渠道无记录: %s", payment.PaymentID)
// 主动查询第三方状态
s.queryThirdPartyStatus(ctx, payment)
}
// 3. 金额不一致
for _, mismatch := range diff.AmountMismatch {
log.Errorf("金额不一致: %s, 平台=%v, 渠道=%v",
mismatch.TradeNo, mismatch.PlatformAmount, mismatch.ChannelAmount)
// 转人工处理
s.createManualTask(ctx, "AMOUNT_MISMATCH", mismatch)
}
// 4. 状态不一致
for _, mismatch := range diff.StatusMismatch {
log.Warnf("状态不一致: %s", mismatch.TradeNo)
// 以渠道状态为准,更新平台状态
s.syncStatus(ctx, mismatch)
}
return nil
}
对账报告:
type ReconciliationReport struct {
Date time.Time
TotalCount int
MatchCount int
MismatchCount int
OnlyInPlatform int
OnlyInChannel int
AmountMismatch int
TotalAmount decimal.Decimal
ChannelTotalAmount decimal.Decimal
}
延伸思考:
- 对账差异如何自动修复?
- 对账失败如何告警和处理?
- 实时对账和T+1对账如何结合?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5910。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-043:支付的异步回调处理
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-043 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 支付状态机、资金安全 |
| 场景标签 | 支付回调、资金链路 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6094 |
题干与约束
支付成功后,第三方通过回调通知平台。回调可能延迟、丢失、重复。如何设计健壮的回调处理机制?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 支付成功后,第三方通过回调通知平台。回调可能延迟、丢失、重复。如何设计健壮的回调处理机制?
答案:
推荐方案(Go实现):
// 回调处理器
type CallbackHandler struct {
paymentSvc *PaymentService
orderSvc *OrderService
lockSvc *DistributedLockService
}
// HandleCallback 处理回调
func (h *CallbackHandler) HandleCallback(ctx context.Context,
channel PaymentChannel, rawData []byte) error {
// 1. 解析回调数据
callback, err := parseCallback(channel, rawData)
if err != nil {
return fmt.Errorf("解析回调失败: %w", err)
}
// 2. 记录回调日志(用于排查问题)
h.logCallback(ctx, callback)
// 3. 验证签名
adapter := h.paymentSvc.getAdapter(channel)
if err := adapter.VerifyCallback(callback); err != nil {
log.Errorf("回调签名验证失败: %v", err)
return err
}
// 4. 幂等性处理(分布式锁)
lockKey := fmt.Sprintf("payment:callback:%s", callback.OutTradeNo)
acquired, err := h.lockSvc.TryLock(ctx, lockKey, 30*time.Second)
if err != nil {
return err
}
if !acquired {
log.Infof("回调%s正在处理中,跳过", callback.OutTradeNo)
return nil
}
defer h.lockSvc.Unlock(ctx, lockKey)
// 5. 处理支付结果
return h.paymentSvc.HandleCallback(ctx, channel, callback)
}
// 主动查询(回调超时补偿)
func (h *CallbackHandler) QueryPaymentStatus(ctx context.Context) {
// 定时任务:查询10分钟前创建但未回调的支付单
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
cutoffTime := time.Now().Add(-10 * time.Minute)
// 查询超时支付单
payments, err := h.paymentSvc.FindPendingPayments(ctx, cutoffTime)
if err != nil {
log.Errorf("查询超时支付单失败: %v", err)
continue
}
for _, payment := range payments {
// 主动查询第三方状态
go func(p *Payment) {
adapter := h.paymentSvc.getAdapter(p.Channel)
status, err := adapter.QueryPayment(ctx, p.ThirdPartyID)
if err != nil {
log.Errorf("查询支付状态失败: %v", err)
return
}
// 如果已支付,补偿处理
if status.Status == "SUCCESS" {
log.Warnf("支付单%s回调丢失,主动补偿", p.PaymentID)
h.paymentSvc.MarkAsPaid(ctx, p.PaymentID)
}
}(payment)
}
}
}
回调重试策略:
// 回调处理失败时的重试
func (h *CallbackHandler) retryCallback(ctx context.Context,
callback *CallbackData) error {
maxRetries := 5
backoff := []time.Duration{
1 * time.Second,
5 * time.Second,
30 * time.Second,
2 * time.Minute,
10 * time.Minute,
}
for i := 0; i < maxRetries; i++ {
err := h.HandleCallback(ctx, callback.Channel, callback.RawData)
if err == nil {
return nil // 成功
}
log.Warnf("回调处理失败,第%d次重试: %v", i+1, err)
if i < maxRetries-1 {
time.Sleep(backoff[i])
}
}
// 所有重试失败,记录人工任务
return h.createManualTask(ctx, "CALLBACK_FAILED", callback)
}
延伸思考:
- 回调接口如何防止伪造(恶意请求)?
- 回调处理超时如何设置?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6094。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-044:支付的分账系统设计(平台+商家)
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-044 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 支付状态机、资金安全 |
| 场景标签 | 支付回调、资金链路 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6223 |
题干与约束
B2B2C平台,用户支付100元,平台抽佣10%,商家获得90元。如何设计支付分账系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: B2B2C平台,用户支付100元,平台抽佣10%,商家获得90元。如何设计支付分账系统?
答案:
推荐方案(Go实现):
// Settlement 结算单
type Settlement struct {
SettlementID string
OrderID int64
MerchantID int64
TotalAmount decimal.Decimal // 订单总额
PlatformAmount decimal.Decimal // 平台佣金
MerchantAmount decimal.Decimal // 商家收入
Status SettlementStatus
SettledAt *time.Time
}
// SettlementService 结算服务
type SettlementService struct {
settlementRepo SettlementRepository
paymentSvc PaymentService
}
// CreateSettlement 创建结算单
func (s *SettlementService) CreateSettlement(ctx context.Context,
orderID int64) error {
// 1. 查询订单
order := s.orderSvc.GetOrder(ctx, orderID)
// 2. 计算佣金
commissionRate := s.getCommissionRate(ctx, order.MerchantID)
platformAmount := order.TotalAmount.Mul(commissionRate)
merchantAmount := order.TotalAmount.Sub(platformAmount)
// 3. 创建结算单
settlement := &Settlement{
SettlementID: generateSettlementID(),
OrderID: orderID,
MerchantID: order.MerchantID,
TotalAmount: order.TotalAmount,
PlatformAmount: platformAmount,
MerchantAmount: merchantAmount,
Status: SettlementPending,
}
return s.settlementRepo.Create(ctx, settlement)
}
// Settle 执行结算(T+N结算)
func (s *SettlementService) Settle(ctx context.Context, merchantID int64, date time.Time) error {
// 1. 查询该商家待结算的订单
settlements, err := s.settlementRepo.FindPendingByMerchant(ctx, merchantID, date)
if err != nil {
return err
}
// 2. 汇总金额
totalAmount := decimal.Zero
for _, s := range settlements {
totalAmount = totalAmount.Add(s.MerchantAmount)
}
// 3. 调用支付渠道分账/转账
if err := s.paymentSvc.Transfer(ctx, &TransferRequest{
ToAccount: s.getMerchantAccount(ctx, merchantID),
Amount: totalAmount,
Remark: fmt.Sprintf("商家%d的%s结算", merchantID, date.Format("2006-01-02")),
}); err != nil {
return err
}
// 4. 更新结算单状态
for _, settlement := range settlements {
settlement.Status = SettlementCompleted
settlement.SettledAt = timePtr(time.Now())
s.settlementRepo.Update(ctx, settlement)
}
return nil
}
// 佣金率配置
func (s *SettlementService) getCommissionRate(ctx context.Context, merchantID int64) decimal.Decimal {
// 根据商家等级、类目等确定佣金率
merchant := s.merchantSvc.GetMerchant(ctx, merchantID)
switch merchant.Level {
case "VIP":
return decimal.NewFromFloat(0.05) // 5%
case "GOLD":
return decimal.NewFromFloat(0.08) // 8%
default:
return decimal.NewFromFloat(0.10) // 10%
}
}
结算周期:
T+0:实时结算(高成本,高信用商家)
T+1:次日结算(平衡)
T+7:周结算(标准)
T+30:月结算(新商家)
延伸思考:
- 如何设计结算的对账机制?
- 商家提现如何设计?
- 结算失败如何处理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6223。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-045:支付密码和安全设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-045 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 支付状态机、资金安全 |
| 场景标签 | 支付回调、资金链路 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6341 |
题干与约束
支付环节涉及资金安全,如何设计支付密码、短信验证码等安全机制?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 支付环节涉及资金安全,如何设计支付密码、短信验证码等安全机制?
答案:
推荐方案(Go实现):
// PaymentSecurityService 支付安全服务
type PaymentSecurityService struct {
rdb *redis.Client
smsSvc SMSService
encryptSvc EncryptService
}
// VerifyPaymentPassword 验证支付密码
func (s *PaymentSecurityService) VerifyPaymentPassword(ctx context.Context,
userID int64, password string) error {
// 1. 获取用户存储的支付密码(加密)
user, err := s.userRepo.FindByID(ctx, userID)
if err != nil {
return err
}
if user.PaymentPassword == "" {
return errors.New("请先设置支付密码")
}
// 2. 验证密码
if !s.encryptSvc.VerifyPassword(password, user.PaymentPassword) {
// 记录失败次数
failCount := s.incrFailCount(ctx, userID)
// 超过5次锁定账户
if failCount >= 5 {
s.lockAccount(ctx, userID, 30*time.Minute)
return errors.New("密码错误次数过多,账户已锁定30分钟")
}
return fmt.Errorf("密码错误,还可尝试%d次", 5-failCount)
}
// 3. 清除失败计数
s.clearFailCount(ctx, userID)
return nil
}
// SendPaymentSMS 发送支付验证码
func (s *PaymentSecurityService) SendPaymentSMS(ctx context.Context,
userID int64, phone string) error {
// 1. 限流检查(防止短信轰炸)
key := fmt.Sprintf("sms:limit:%s", phone)
count, err := s.rdb.Incr(ctx, key).Result()
if err != nil {
return err
}
if count == 1 {
s.rdb.Expire(ctx, key, time.Hour)
}
if count > 5 {
return errors.New("发送次数过多,请1小时后再试")
}
// 2. 生成6位验证码
code := fmt.Sprintf("%06d", rand.Intn(1000000))
// 3. 存储验证码(5分钟有效)
codeKey := fmt.Sprintf("sms:code:%s", phone)
s.rdb.SetEX(ctx, codeKey, code, 5*time.Minute)
// 4. 发送短信
return s.smsSvc.Send(ctx, phone, fmt.Sprintf("您的支付验证码是%s,5分钟内有效", code))
}
// VerifySMSCode 验证短信验证码
func (s *PaymentSecurityService) VerifySMSCode(ctx context.Context,
phone, code string) error {
codeKey := fmt.Sprintf("sms:code:%s", phone)
// 查询验证码
storedCode, err := s.rdb.Get(ctx, codeKey).Result()
if err == redis.Nil {
return errors.New("验证码已过期")
}
if err != nil {
return err
}
// 验证
if storedCode != code {
return errors.New("验证码错误")
}
// 验证成功,删除验证码(防止重复使用)
s.rdb.Del(ctx, codeKey)
return nil
}
// 风控检查
func (s *PaymentSecurityService) RiskCheck(ctx context.Context,
userID int64, amount decimal.Decimal) error {
// 规则1:大额支付需要额外验证
if amount.GreaterThan(decimal.NewFromInt(5000)) {
// 需要短信验证码或支付密码
return errors.New("REQUIRE_SMS_OR_PASSWORD")
}
// 规则2:新用户限额
user := s.userSvc.GetUser(ctx, userID)
if user.RegisterDays() < 7 && amount.GreaterThan(decimal.NewFromInt(1000)) {
return errors.New("新用户单笔限额1000元")
}
// 规则3:异常IP检测
ip := s.getRequestIP(ctx)
if s.isBlacklistIP(ctx, ip) {
return errors.New("异常IP,禁止支付")
}
// 规则4:高频支付检测
recentPayments := s.getRecentPaymentCount(ctx, userID, 10*time.Minute)
if recentPayments > 10 {
return errors.New("支付频率异常")
}
return nil
}
延伸思考:
- 支付密码如何加密存储?
- 如何设计支付的二次确认(大额支付)?
- 支付安全如何平衡用户体验?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6341。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-046:支付的退款处理
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-046 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 支付状态机、资金安全 |
| 场景标签 | 支付回调、资金链路 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6599 |
题干与约束
用户申请退款,需要原路退回。如何设计退款流程,处理退款失败、部分退款等场景?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户申请退款,需要原路退回。如何设计退款流程,处理退款失败、部分退款等场景?
答案:
推荐方案(Go实现):
// RefundService 退款服务
type RefundService struct {
paymentRepo PaymentRepository
}
// Refund 申请退款
func (s *RefundService) Refund(ctx context.Context, req *RefundRequest) error {
// 1. 查询原支付记录
payment, err := s.paymentRepo.FindByOrderID(ctx, req.OrderID)
if err != nil {
return err
}
// 2. 校验退款金额
if req.RefundAmount.GreaterThan(payment.Amount) {
return errors.New("退款金额超过支付金额")
}
// 3. 检查是否已退款
totalRefunded, err := s.paymentRepo.GetTotalRefundedAmount(ctx, payment.PaymentID)
if err != nil {
return err
}
if totalRefunded.Add(req.RefundAmount).GreaterThan(payment.Amount) {
return errors.New("累计退款金额超过支付金额")
}
// 4. 创建退款记录
refund := &PaymentRefund{
RefundID: generateRefundID(),
PaymentID: payment.PaymentID,
OrderID: req.OrderID,
Amount: req.RefundAmount,
Reason: req.Reason,
Status: RefundStatusPending,
CreatedAt: time.Now(),
}
if err := s.refundRepo.Create(ctx, refund); err != nil {
return err
}
// 5. 调用第三方退款接口
adapter := s.getAdapter(payment.Channel)
err = adapter.Refund(ctx, &ThirdPartyRefundRequest{
OutRefundNo: refund.RefundID,
OutTradeNo: payment.ThirdPartyID,
RefundAmount: req.RefundAmount,
TotalAmount: payment.Amount,
RefundReason: req.Reason,
})
if err != nil {
refund.Status = RefundStatusFailed
refund.FailReason = err.Error()
s.refundRepo.Update(ctx, refund)
return err
}
// 6. 更新退款状态
refund.Status = RefundStatusSuccess
refund.RefundedAt = timePtr(time.Now())
s.refundRepo.Update(ctx, refund)
return nil
}
// 退款重试(定时任务)
func (s *RefundService) RetryFailedRefunds(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
// 查询失败的退款(创建时间<30分钟前)
refunds, err := s.refundRepo.FindFailed(ctx, time.Now().Add(-30*time.Minute))
if err != nil {
log.Errorf("查询失败退款失败: %v", err)
continue
}
for _, refund := range refunds {
// 重试退款
go func(r *PaymentRefund) {
if r.RetryCount >= 5 {
log.Errorf("退款%s重试次数过多,转人工处理", r.RefundID)
s.createManualTask(ctx, r.RefundID)
return
}
payment, _ := s.paymentRepo.FindByID(ctx, r.PaymentID)
adapter := s.getAdapter(payment.Channel)
err := adapter.Refund(ctx, &ThirdPartyRefundRequest{
OutRefundNo: r.RefundID,
OutTradeNo: payment.ThirdPartyID,
RefundAmount: r.Amount,
TotalAmount: payment.Amount,
})
if err == nil {
r.Status = RefundStatusSuccess
r.RefundedAt = timePtr(time.Now())
} else {
r.RetryCount++
r.FailReason = err.Error()
}
s.refundRepo.Update(ctx, r)
}(refund)
}
}
}
部分退款处理:
// 部分退款(一单多件商品,退部分)
func (s *RefundService) PartialRefund(ctx context.Context,
orderID int64, items []RefundItem) error {
// 1. 计算退款金额
var refundAmount decimal.Decimal
for _, item := range items {
itemAmount := item.Price.Mul(decimal.NewFromInt(int64(item.Quantity)))
refundAmount = refundAmount.Add(itemAmount)
}
// 2. 分摊运费
order := s.orderSvc.GetOrder(ctx, orderID)
refundItemCount := len(items)
totalItemCount := len(order.Items)
shippingRefund := order.ShippingFee.
Mul(decimal.NewFromInt(int64(refundItemCount))).
Div(decimal.NewFromInt(int64(totalItemCount)))
refundAmount = refundAmount.Add(shippingRefund)
// 3. 执行退款
return s.Refund(ctx, &RefundRequest{
OrderID: orderID,
RefundAmount: refundAmount,
RefundItems: items,
Reason: "部分退货",
})
}
延伸思考:
- 退款失败如何通知用户?
- 如何设计退款的限额控制(防止洗钱)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6599。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-TRADE-047:预授权支付的设计(酒店、租车场景)
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-TRADE-047 |
| 题型 | 系统设计题 |
| 范围 | 领域级:搜索、结算、订单与支付主链路 |
| 难度 | 中等 |
| 建议用时 | 30 分钟 |
| 能力标签 | 支付状态机、资金安全 |
| 场景标签 | 支付回调、资金链路 |
| 能力域 | 搜索、购物车、订单与支付 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6887 |
题干与约束
酒店预订需要预授权(冻结资金但不扣款),退房时根据实际消费扣款。如何设计预授权支付?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 酒店预订需要预授权(冻结资金但不扣款),退房时根据实际消费扣款。如何设计预授权支付?
答案:
推荐方案(Go实现):
// PreAuthService 预授权服务
type PreAuthService struct {
paymentAdapter PaymentAdapter
preAuthRepo PreAuthRepository
}
// PreAuthorize 预授权
func (s *PreAuthService) PreAuthorize(ctx context.Context,
req *PreAuthRequest) (*PreAuthResponse, error) {
// 1. 创建预授权记录
preAuth := &PreAuthorization{
PreAuthID: generatePreAuthID(),
OrderID: req.OrderID,
UserID: req.UserID,
Amount: req.Amount, // 冻结金额
Status: PreAuthStatusFrozen,
CreatedAt: time.Now(),
ExpireAt: time.Now().Add(30 * 24 * time.Hour), // 30天有效期
}
if err := s.preAuthRepo.Create(ctx, preAuth); err != nil {
return nil, err
}
// 2. 调用支付渠道预授权接口
resp, err := s.paymentAdapter.PreAuthorize(ctx, &ThirdPartyPreAuthRequest{
OutRequestNo: preAuth.PreAuthID,
Amount: req.Amount,
ExpireTime: preAuth.ExpireAt,
})
if err != nil {
preAuth.Status = PreAuthStatusFailed
s.preAuthRepo.Update(ctx, preAuth)
return nil, err
}
// 3. 保存第三方预授权号
preAuth.ThirdPartyID = resp.AuthNo
s.preAuthRepo.Update(ctx, preAuth)
return &PreAuthResponse{
PreAuthID: preAuth.PreAuthID,
AuthNo: resp.AuthNo,
}, nil
}
// Complete 完成预授权(实际扣款)
func (s *PreAuthService) Complete(ctx context.Context,
preAuthID string, actualAmount decimal.Decimal) error {
// 1. 查询预授权
preAuth, err := s.preAuthRepo.FindByID(ctx, preAuthID)
if err != nil {
return err
}
// 2. 校验金额
if actualAmount.GreaterThan(preAuth.Amount) {
return errors.New("实际金额超过预授权金额")
}
// 3. 调用支付渠道完成预授权
err = s.paymentAdapter.CompletePreAuth(ctx, &CompletePreAuthRequest{
AuthNo: preAuth.ThirdPartyID,
Amount: actualAmount,
})
if err != nil {
return err
}
// 4. 更新状态
preAuth.Status = PreAuthStatusCompleted
preAuth.ActualAmount = actualAmount
preAuth.CompletedAt = timePtr(time.Now())
s.preAuthRepo.Update(ctx, preAuth)
// 5. 多余金额解冻
if actualAmount.LessThan(preAuth.Amount) {
unfreezeAmount := preAuth.Amount.Sub(actualAmount)
log.Infof("解冻多余金额: %v", unfreezeAmount)
}
return nil
}
// Cancel 取消预授权
func (s *PreAuthService) Cancel(ctx context.Context, preAuthID string) error {
preAuth, err := s.preAuthRepo.FindByID(ctx, preAuthID)
if err != nil {
return err
}
// 调用支付渠道取消预授权
err = s.paymentAdapter.CancelPreAuth(ctx, preAuth.ThirdPartyID)
if err != nil {
return err
}
preAuth.Status = PreAuthStatusCancelled
s.preAuthRepo.Update(ctx, preAuth)
return nil
}
延伸思考:
- 预授权过期如何自动解冻?
- 预授权场景下的对账如何设计?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6887。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
大促、秒杀、容量与可靠性
本节覆盖大促、秒杀、容量与可靠性。
专题答辩资料:搜索导购容量估算口径
容量估算(面试与立项常问)可以按乘法拆解:峰值 QPS × 每页条数 ×(Hydrate 下游 RPC 数)≈ 下游扇出;再叠加 重试风暴 系数(建议保守取 1.3~1.8)。ES 侧则关注 分片热点(超大店、超级品牌)与 聚合查询 对 CPU 的抢占;必要时对 shop 场景做 单独索引或单独副本组,把热点从全站检索隔离出去,成本换稳定性。
Q-ECOM-PEAK-001:大促期间如何保证系统稳定性?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-PEAK-001 |
| 题型 | 系统设计题 |
| 范围 | 平台级:峰值流量、容量规划与可靠性 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 容量规划、稳定性治理、容灾恢复 |
| 场景标签 | 大促峰值、故障演练 |
| 能力域 | 大促、秒杀、容量与可靠性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:215 |
题干与约束
公司准备参加双11大促,预估流量是平时的50倍,订单量达到平时的100倍。你需要确保系统在大促期间稳定运行,请设计保障方案。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 公司准备参加双11大促,预估流量是平时的50倍,订单量达到平时的100倍。你需要确保系统在大促期间稳定运行,请设计保障方案。
答案:
问题分析: 大促稳定性的核心挑战:
- 流量突增:如何应对峰值流量(平时5000 QPS → 25万 QPS)
- 资源瓶颈:数据库、缓存、网络等基础设施能否支撑
- 热点问题:少量商品承载大部分流量
- 故障隔离:如何避免局部故障扩散为全局故障
方案一:垂直扩容+全链路压测
核心思路: 通过提升单机性能和压测验证来保障稳定性。
技术方案:
- 数据库层:升级配置(32核128G → 64核256G),增加只读从库
- 应用层:服务器扩容(50台 → 200台),JVM调优
- 缓存层:Redis扩容(3节点 → 12节点),缓存预热
- 压测验证:提前1个月进行全链路压测,发现瓶颈
优点:
- 改动小,风险可控
- 实施周期短
- 回退方便
缺点:
- 成本高(需要高配机器)
- 扩展性有限
- 单点风险依然存在
方案二:限流降级+分级保障
核心思路: 通过限流降级保护核心链路,非核心功能可降级。
技术方案:
-
多级限流:
- 网关层:总QPS限流(25万)
- 服务层:单服务限流(如订单创建10万)
- 接口层:单接口限流(如查询详情5万)
-
分级降级:
- P0核心:下单、支付、查询订单(必保)
- P1重要:搜索、详情、加购(降级返回缓存)
- P2一般:推荐、评论、收藏(直接关闭)
-
熔断机制:连续失败达阈值后自动熔断
优点:
- 保护核心链路
- 成本可控
- 局部故障不扩散
缺点:
- 用户体验下降(部分功能不可用)
- 降级逻辑需要提前准备
- 限流阈值难以精确设定
方案三:异步化+削峰填谷
核心思路: 将同步操作改为异步,通过消息队列削峰填谷。
技术方案:
- 订单异步化:下单成功后立即返回,后台异步处理
- 库存预占:Redis预扣,后台异步同步到DB
- 消息队列:Kafka承载峰值流量,消费者慢慢处理
- 任务调度:非实时任务延迟处理(如数据统计)
优点:
- 峰值流量平滑处理
- 用户体验好(快速响应)
- 系统压力平缓
缺点:
- 架构改造较大
- 数据最终一致性
- 异常处理复杂
方案对比:
| 维度 | 垂直扩容 | 限流降级 | 异步化 |
|---|---|---|---|
| 成本 | ★★☆☆☆ | ★★★★☆ | ★★★☆☆ |
| 用户体验 | ★★★★★ | ★★★☆☆ | ★★★★☆ |
| 实施难度 | ★★★★☆ | ★★★☆☆ | ★★☆☆☆ |
| 扩展性 | ★★☆☆☆ | ★★★☆☆ | ★★★★★ |
推荐方案: 采用三种方案的组合:垂直扩容作为基础,限流降级作为保护,异步化作为优化。
实施要点:
- 提前3个月准备:容量规划、全链路压测、应急演练
- 分级保障策略:明确P0/P1/P2功能,准备降级开关
- 实时监控:QPS、成功率、响应时间、错误率
- 应急预案:数据库主从切换、缓存雪崩处理、快速扩容
- 值班机制:7×24值守,关键节点实时响应
延伸思考:
- 如何评估系统需要的容量?
- 大促期间出现故障如何应急?
- 如何验证限流降级策略是否有效?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:215。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-PEAK-002:设计电商系统的多机房多活架构
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-PEAK-002 |
| 题型 | 系统设计题 |
| 范围 | 平台级:峰值流量、容量规划与可靠性 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 容量规划、稳定性治理、容灾恢复 |
| 场景标签 | 大促峰值、故障演练 |
| 能力域 | 大促、秒杀、容量与可靠性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:325 |
题干与约束
电商平台需要支持异地多活,要求在任一机房故障时,业务可以快速切换到其他机房继续提供服务。请设计多机房多活方案。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台需要支持异地多活,要求在任一机房故障时,业务可以快速切换到其他机房继续提供服务。请设计多机房多活方案。
答案:
问题分析: 多机房多活的核心挑战:
- 数据一致性:跨机房数据同步延迟和一致性保证
- 流量路由:如何将用户请求路由到合适的机房
- 故障切换:机房故障时如何快速切换
- 成本控制:多机房部署成本是单机房的2-3倍
方案一:单元化架构
核心思路: 按用户维度进行分片,每个单元独立提供服务。
设计:
- 单元划分:按用户ID hash分为N个单元(如8个)
- 单元部署:每个单元在2-3个机房部署
- 路由规则:网关根据用户ID路由到对应单元
- 数据存储:每个单元独立数据库,跨单元数据通过消息同步
优点:
- 单元内强一致性
- 扩展性好,可按单元扩容
- 故障隔离(单元故障不影响其他单元)
缺点:
- 跨单元数据访问困难
- 全局数据(如商品、库存)需要特殊处理
- 单元分配不均可能导致热点
方案二:两地三中心
核心思路: 在同城部署两个机房(主备),异地部署一个灾备机房。
设计:
- 同城双活:机房A和机房B互为主备,承载线上流量
- 异地灾备:机房C作为灾备,平时不承载流量
- 数据同步:机房A、B实时同步,机房C异步同步
- 流量分配:机房A和B各承载50%流量
优点:
- 同城延迟低(<1ms)
- 异地容灾(城市级灾难)
- 实施相对简单
缺点:
- 机房C资源闲置
- 跨城切换仍有数据丢失风险
- 仅能容忍单机房故障
方案三:三地五中心
核心思路: 在三个城市各部署机房,每个城市至少2个机房,实现真正的多活。
设计:
- 北京:机房A、机房B(承载华北流量)
- 上海:机房C、机房D(承载华东流量)
- 深圳:机房E(承载华南流量)
- 数据同步:同城实时同步,跨城异步同步
- 流量路由:根据用户地域就近路由
优点:
- 真正的异地多活
- 就近访问,延迟低
- 可容忍城市级故障
缺点:
- 成本极高(5个机房)
- 数据一致性复杂(跨城同步)
- 运维复杂度高
方案对比:
| 维度 | 单元化 | 两地三中心 | 三地五中心 |
|---|---|---|---|
| 数据一致性 | ★★★★☆ | ★★★★☆ | ★★★☆☆ |
| 成本 | ★★★☆☆ | ★★★★☆ | ★☆☆☆☆ |
| 容灾能力 | ★★★☆☆ | ★★★★☆ | ★★★★★ |
| 实施难度 | ★★★★☆ | ★★★☆☆ | ★★☆☆☆ |
推荐方案: 对于中大型电商,推荐两地三中心+单元化的组合方案。
实施要点:
- 单元划分:按用户ID分8个单元,每个单元在2个机房部署
- 同城双活:北京A、B机房互为主备
- 异地灾备:上海C机房作为灾备
- 路由策略:用户→单元→机房的三级路由
- 数据同步:同城强同步(Raft/Paxos),异地异步同步
- 故障切换:自动检测+自动切换,RTO<5分钟
延伸思考:
- 如何处理跨单元的全局数据(如商品库存)?
- 机房故障时如何保证不丢数据?
- 如何验证多活架构的有效性?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:325。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-PEAK-003:设计电商系统的监控告警体系
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-PEAK-003 |
| 题型 | 系统设计题 |
| 范围 | 平台级:峰值流量、容量规划与可靠性 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 容量规划、稳定性治理、可观测性 |
| 场景标签 | 大促峰值、故障演练 |
| 能力域 | 大促、秒杀、容量与可靠性 |
| 来源 | books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1098 |
题干与约束
电商系统涉及十几个微服务,需要建立完善的监控告警体系。请设计监控方案,确保能及时发现和处理线上问题。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商系统涉及十几个微服务,需要建立完善的监控告警体系。请设计监控方案,确保能及时发现和处理线上问题。
答案:
问题分析: 监控告警的核心挑战:
- 监控什么:需要监控哪些指标
- 如何监控:使用什么工具和技术
- 告警策略:如何避免告警疲劳
- 快速定位:如何从告警快速定位问题
方案一:基础监控(单维度)
核心思路: 监控基础指标(CPU、内存、磁盘),发现异常告警。
监控内容:
- 主机监控:CPU、内存、磁盘、网络
- 应用监控:JVM(堆内存、GC)
- 接口监控:QPS、响应时间、错误率
- 日志监控:ERROR日志、异常堆栈
工具选择:
- 指标采集:Prometheus
- 可视化:Grafana
- 告警:Prometheus Alertmanager
优点:
- 覆盖基础指标
- 开源免费
- 社区活跃
缺点:
- 单维度监控,难以关联分析
- 告警规则简单(阈值告警)
- 缺少业务视角
方案二:全链路监控(多维度)
核心思路: 结合指标、日志、链路追踪三个维度,全方位监控。
监控体系:
-
指标监控(Metrics):
- RED指标:Rate(QPS)、Error(错误率)、Duration(延迟)
- USE指标:Utilization(使用率)、Saturation(饱和度)、Error(错误)
- 业务指标:订单量、支付成功率、库存水位
-
日志监控(Logging):
- 结构化日志:JSON格式,包含traceId
- 日志聚合:ELK(Elasticsearch + Logstash + Kibana)
- 日志告警:关键错误日志触发告警
-
链路追踪(Tracing):
- APM工具:Skywalking / Jaeger
- 调用链可视化:拓扑图、火焰图
- 性能分析:慢查询、慢接口
-
关联分析:
- traceId串联三个维度
- 从告警跳转到日志和链路
- 快速定位问题根因
优点:
- 立体化监控,全面覆盖
- 可快速定位问题
- 支持根因分析
缺点:
- 成本高(存储、计算)
- 运维复杂度高
- 需要统一traceId
方案三:智能监控(AIOps)
核心思路: 基于机器学习,智能检测异常和预测故障。
核心能力:
-
异常检测:
- 基线学习:学习历史数据建立基线
- 异常识别:偏离基线自动告警
- 减少误报:智能过滤噪音
-
根因分析:
- 故障关联:自动分析告警之间的关联
- 根因定位:从众多告警中识别根因
- 建议修复:推荐修复方案
-
容量预测:
- 趋势分析:分析资源使用趋势
- 容量预警:提前预警资源不足
- 扩容建议:推荐扩容方案
优点:
- 智能化,减少人工成本
- 预测性,提前发现问题
- 自动化根因分析
缺点:
- 成本高(算法研发、算力)
- 需要大量历史数据
- 准确率受限
方案对比:
| 维度 | 基础监控 | 全链路监控 | 智能监控 |
|---|---|---|---|
| 实施成本 | ★★★★★ | ★★★☆☆ | ★☆☆☆☆ |
| 覆盖全面性 | ★★☆☆☆ | ★★★★★ | ★★★★★ |
| 定位效率 | ★★☆☆☆ | ★★★★☆ | ★★★★★ |
| 适用规模 | 小型 | 中大型 | 大型 |
推荐方案: 对于中大型电商系统,推荐全链路监控。
实施要点:
-
指标体系设计:
-
黄金指标(核心业务):
- 订单创建成功率 > 99.9%
- 支付成功率 > 99.95%
- 接口P99延迟 < 500ms
-
基础指标(资源):
- CPU使用率 < 70%
- 内存使用率 < 80%
- 磁盘使用率 < 85%
-
业务指标(自定义):
- 实时订单量
- 库存水位
- 支付渠道成功率
-
-
告警分级:
- P0(紧急):影响核心功能(下单、支付失败),5分钟响应
- P1(重要):影响部分功能(搜索慢、详情页错误),30分钟响应
- P2(一般):资源告警(CPU高、磁盘满),2小时响应
- P3(提示):趋势告警(流量增长、容量预警),工作时间处理
-
告警策略:
- 避免告警疲劳:合并同类告警、设置静默期
- 智能降噪:工作时间和非工作时间不同阈值
- 分级通知:P0电话+短信,P1钉钉,P2邮件
- 告警收敛:同一问题5分钟内只告警一次
-
监控大盘:
- 业务大盘:实时订单量、支付成功率、GMV
- 应用大盘:服务健康度、QPS、错误率、P99延迟
- 基础设施大盘:主机、数据库、缓存、消息队列
- 告警大盘:实时告警、告警趋势、MTTR
-
应急响应:
- SOP(标准操作流程):不同告警对应的处理步骤
- On-call轮值:7×24值班,保证及时响应
- 故障复盘:每次P0/P1故障必须复盘,沉淀经验
- 演练机制:定期故障演练,验证应急预案
延伸思考:
- 如何设计监控指标体系(业务指标 vs 技术指标)?
- 如何避免告警疲劳(告警太多导致麻木)?
- 监控数据如何存储和查询(时序数据库)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/07-ecommerce-basics-interview.md:1098。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-PEAK-004:大促场景下的库存预热和削峰方案
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-PEAK-004 |
| 题型 | 系统设计题 |
| 范围 | 平台级:峰值流量、容量规划与可靠性 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 容量规划、稳定性治理、削峰与预热 |
| 场景标签 | 大促峰值、故障演练、秒杀活动 |
| 能力域 | 大促、秒杀、容量与可靠性 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4055 |
题干与约束
双11大促,预计订单量是平时的100倍。如何对库存系统进行预热和削峰,保证不超卖且性能可控?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 双11大促,预计订单量是平时的100倍。如何对库存系统进行预热和削峰,保证不超卖且性能可控?
答案:
问题分析: 大促库存的核心挑战:
- 瞬时流量暴增(平时1000 QPS → 10万 QPS)
- 热点商品集中(TOP 100商品占80%流量)
- Redis/DB压力大
- 需要防止库存击穿
方案一:库存分段+令牌桶
核心思想: 将库存分为多段,每段独立扣减,最后汇总。
设计:
库存分段:
总库存10000件,分为10段:
segment_1: 1000件
segment_2: 1000件
...
segment_10: 1000件
Redis存储:
stock:sku:123:segment:1 = 1000
stock:sku:123:segment:2 = 1000
...
扣减逻辑:
1. 随机选择一个segment
2. 尝试扣减该segment库存
3. 如果成功,返回
4. 如果失败(库存不足),重试其他segment
5. 所有segment都不足,返回无货
优点:
- 降低Redis单key热点
- 提高并发度
- 不会超卖
缺点:
- 可能出现库存碎片(某段有货但其他段无货)
- 需要定期平衡segment
方案二:本地库存+定期同步
核心思想: 将库存预分配到应用服务器本地内存,减少Redis压力。
设计:
初始化(大促前):
1. 总库存10000件
2. 分配到100台服务器
3. 每台服务器本地内存:100件
扣减流程:
1. 用户请求到服务器A
2. 扣减服务器A本地库存(内存操作,极快)
3. 本地库存不足时,向Redis申请补货
4. Redis库存不足,返回无货
补货机制:
if (local_stock < 10) {
申请补货100件
Redis扣减100件
local_stock += 100
}
优点:
- 性能极高(内存操作)
- 减轻Redis压力
- 支持极高并发
缺点:
- 服务器重启库存丢失(需要归还Redis)
- 库存分散,利用率低
- 需要补货机制
方案三:队列削峰+异步扣减(推荐)
核心思想: 请求进队列,消费端限速扣减,流量削峰。
设计:
用户下单
→ 请求入队(Kafka)
→ 库存扣减Worker(限速消费)
→ 扣减成功/失败
→ 通知用户(WebSocket/轮询)
限速策略:
1. 设置消费速率:5000 TPS
2. 队列堆积:允许100万消息堆积
3. 超时处理:队列中超过5分钟的请求自动取消
用户体验:
1. 提交订单立即返回"排队中"
2. 显示排队位置(前面还有XXX人)
3. 扣减成功后通知用户
4. 扣减失败(无货)通知用户
优点:
- 削峰效果好
- 库存系统压力可控
- 用户体验可接受(秒杀场景)
缺点:
- 用户等待时间长
- 需要排队机制
- 实现复杂
方案对比:
| 方案 | 性能 | 削峰效果 | 用户体验 | 实施难度 |
|---|---|---|---|---|
| 库存分段 | ★★★★☆ | ★★★☆☆ | ★★★★★ | ★★★☆☆ |
| 本地库存 | ★★★★★ | ★★★★★ | ★★★★★ | ★★★☆☆ |
| 队列削峰 | ★★★☆☆ | ★★★★★ | ★★★☆☆ | ★★☆☆☆ |
推荐方案: 采用库存分段+本地库存的组合。
实施要点:
-
库存预热:
大促前3天: 1. 识别热销商品(TOP 1000) 2. Redis预加载: - 库存数据 - 商品信息 - 价格信息 3. 本地缓存预加载 4. 压测验证 -
分段策略:
分段数量 = max(库存数量 / 100, 服务器数量) 示例:库存10000,服务器100台 → 分段数 = max(10000/100, 100) = 100段 → 每段100件 优点: - 降低单key热度 - 并发度=分段数 -
本地库存管理:
public class LocalInventory { private final ConcurrentHashMap<String, AtomicInteger> localStock; public boolean tryDeduct(String skuId, int quantity) { AtomicInteger stock = localStock.computeIfAbsent( skuId, k -> new AtomicInteger(0) ); // 乐观尝试扣减 int current = stock.get(); if (current >= quantity) { if (stock.compareAndSet(current, current - quantity)) { return true; } } // 本地库存不足,申请补货 if (requestRecharge(skuId, 100)) { return tryDeduct(skuId, quantity); // 重试 } return false; } } -
监控大盘:
实时监控: - 总库存水位 - 扣减QPS - 成功率 - Redis热key - 本地库存分布 告警: - 库存水位 < 20% - 扣减失败率 > 5% - Redis单key QPS > 10万
延伸思考:
- 秒杀开始前如何预热(避免冷启动)?
- 大促结束后如何回收本地库存?
- 如何应对恶意刷单占用库存?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4055。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-PEAK-005:秒杀活动的价格和库存设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-PEAK-005 |
| 题型 | 系统设计题 |
| 范围 | 平台级:峰值流量、容量规划与可靠性 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 容量规划、稳定性治理、削峰与预热 |
| 场景标签 | 大促峰值、故障演练、秒杀活动 |
| 能力域 | 大促、秒杀、容量与可靠性 |
| 来源 | books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7696 |
题干与约束
秒杀活动商品价格远低于平时,流量集中,如何设计秒杀的价格和库存系统,保证不超卖且性能可控?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 秒杀活动商品价格远低于平时,流量集中,如何设计秒杀的价格和库存系统,保证不超卖且性能可控?
答案:
问题分析: 秒杀的核心挑战:
- 瞬时高并发(10万+ QPS)
- 库存精准控制(100件商品,10万人抢)
- 价格隔离(秒杀价和正常价不能混淆)
- 防黄牛(防止脚本抢购)
方案一:独立秒杀表
核心思想: 秒杀商品和库存独立存储,与正常商品隔离。
设计:
seckill_activity(秒杀活动)
├── activity_id
├── name
├── start_time
├── end_time
└── status
seckill_product(秒杀商品)
├── seckill_id
├── activity_id
├── sku_id
├── seckill_price(秒杀价)
├── normal_price(原价)
├── total_stock(秒杀库存)
├── remaining_stock(剩余库存)
├── limit_per_user(每人限购)
└── ...
用户下单:
1. 检查活动时间
2. 检查用户是否已购买(限购)
3. 扣减秒杀库存(Redis)
4. 创建订单(秒杀价)
优点:
- 隔离性好
- 不影响正常业务
- 数据清晰
缺点:
- 数据冗余
方案二:共享商品表+秒杀标记
核心思想: 秒杀商品复用商品表,通过标记区分。
设计:
product
├── sku_id
├── normal_price
├── is_seckill(是否秒杀商品)
├── seckill_price
├── seckill_stock
└── ...
价格查询:
if (product.is_seckill && isInSeckillTime()) {
return product.seckill_price;
} else {
return product.normal_price;
}
优点:
- 无冗余
- 实现简单
缺点:
- 秒杀和正常业务混在一起
- 容易出错(价格混淆)
方案三:分层架构(推荐)
核心思想: 前台秒杀系统 + 后台正常系统,数据隔离。
架构:
秒杀系统:
- 秒杀商品(独立表)
- 秒杀库存(Redis)
- 秒杀订单(独立表)
- 秒杀队列(削峰)
正常系统:
- 商品表
- 库存表
- 订单表
数据同步:
- 秒杀结束后同步到正常订单表
- 库存变更同步
优点:
- 完全隔离
- 互不影响
- 可针对性优化
缺点:
- 架构复杂
- 数据同步成本
方案对比:
| 方案 | 隔离性 | 性能 | 实施难度 | 适用场景 |
|---|---|---|---|---|
| 独立秒杀表 | ★★★★☆ | ★★★★☆ | ★★★☆☆ | 中小型 |
| 共享表 | ★★☆☆☆ | ★★★☆☆ | ★★★★★ | 小型 |
| 分层架构 | ★★★★★ | ★★★★★ | ★★☆☆☆ | 大型 |
推荐方案: 采用独立秒杀表。
实施要点:
-
秒杀库存:
Redis存储: key: seckill:stock:{seckill_id} value: 剩余库存数量 扣减(Lua脚本): local stock = redis.call('GET', KEYS[1]) if tonumber(stock) > 0 then redis.call('DECR', KEYS[1]) return 1 else return 0 end -
限购控制:
Redis Set记录已购买用户: key: seckill:bought:{seckill_id} value: Set<user_id> 检查: if (redis.sismember(key, user_id)) { return "已购买,不能重复购买"; } 记录: redis.sadd(key, user_id); -
排队机制:
流程: 1. 用户点击"立即抢购" 2. 请求进入队列(Kafka) 3. 显示排队位置 4. Worker消费队列,限速扣减库存 5. 扣减成功,通知用户 6. 扣减失败,提示已售罄 优点: - 削峰 - 用户体验可控 - 系统稳定 -
防黄牛:
策略1:验证码 - 点击抢购后弹出验证码 - 通过验证才能提交订单 策略2:实人认证 - 首次参与秒杀需要实人认证 - 人脸识别 策略3:行为分析 - 检测异常高频请求 - IP黑名单 - 设备指纹 -
价格展示:
商品详情页: - 正常价:¥999(划线价) - 秒杀价:¥199(红色突出显示) - 倒计时:距开始还剩 01:23:45 - 提醒:每人限购1件
延伸思考:
- 秒杀订单未支付如何处理(是否释放库存)?
- 秒杀活动如何预热(提前加载数据)?
- 秒杀流量如何监控和应急处理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7696。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-PEAK-006:订单的限流和防刷
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-PEAK-006 |
| 题型 | 系统设计题 |
| 范围 | 平台级:峰值流量、容量规划与可靠性 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 容量规划、稳定性治理、容灾恢复 |
| 场景标签 | 大促峰值、故障演练 |
| 能力域 | 大促、秒杀、容量与可靠性 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5412 |
题干与约束
恶意用户频繁下单不支付,占用库存和系统资源。如何设计订单的限流和防刷机制?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 恶意用户频繁下单不支付,占用库存和系统资源。如何设计订单的限流和防刷机制?
答案:
推荐方案(Go实现):
package ratelimit
import (
"context"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
// OrderRateLimiter 订单限流器
type OrderRateLimiter struct {
rdb *redis.Client
}
// CheckLimit 检查用户是否超过限流
func (l *OrderRateLimiter) CheckLimit(ctx context.Context, userID int64) error {
// 限流规则:
// 1. 每分钟最多下单5次
// 2. 每小时最多下单20次
// 3. 每天最多50个待支付订单
// 规则1:每分钟限流
key1 := fmt.Sprintf("order:limit:min:%d:%s", userID, time.Now().Format("200601021504"))
count1, err := l.rdb.Incr(ctx, key1).Result()
if err != nil {
return err
}
if count1 == 1 {
l.rdb.Expire(ctx, key1, time.Minute)
}
if count1 > 5 {
return errors.New("下单太频繁,请稍后再试")
}
// 规则2:每小时限流
key2 := fmt.Sprintf("order:limit:hour:%d:%s", userID, time.Now().Format("2006010215"))
count2, err := l.rdb.Incr(ctx, key2).Result()
if err != nil {
return err
}
if count2 == 1 {
l.rdb.Expire(ctx, key2, time.Hour)
}
if count2 > 20 {
return errors.New("您今天下单次数过多,请明天再试")
}
// 规则3:待支付订单数量限制
pendingCount, err := l.getPendingOrderCount(ctx, userID)
if err != nil {
return err
}
if pendingCount >= 50 {
return errors.New("您有过多待支付订单,请先完成支付")
}
return nil
}
// 用户信用评分
type UserCreditService struct {
repo UserCreditRepository
}
func (s *UserCreditService) CheckCredit(ctx context.Context, userID int64) error {
credit := s.repo.GetCredit(ctx, userID)
// 信用分低于60分,禁止下单
if credit.Score < 60 {
return errors.New("您的信用分过低,暂时无法下单")
}
return nil
}
// 信用分扣减规则
func (s *UserCreditService) UpdateCredit(ctx context.Context, userID int64, behavior string) {
switch behavior {
case "ORDER_TIMEOUT":
// 订单超时未支付:-5分
s.repo.DeductCredit(ctx, userID, 5, "订单超时未支付")
case "MALICIOUS_REFUND":
// 恶意退款:-10分
s.repo.DeductCredit(ctx, userID, 10, "恶意退款")
case "ORDER_COMPLETED":
// 订单完成:+1分
s.repo.AddCredit(ctx, userID, 1, "订单完成")
}
}
延伸思考:
- 如何识别黄牛和恶意用户?
- 限流策略如何针对不同用户等级差异化?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:5412。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-PEAK-007:支付渠道的路由和降级
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-PEAK-007 |
| 题型 | 系统设计题 |
| 范围 | 平台级:峰值流量、容量规划与可靠性 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 容量规划、稳定性治理、支付降级 |
| 场景标签 | 大促峰值、故障演练、支付高峰 |
| 能力域 | 大促、秒杀、容量与可靠性 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6485 |
题干与约束
支付宝渠道故障时,如何自动切换到微信支付?如何设计支付渠道的路由和降级策略?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 支付宝渠道故障时,如何自动切换到微信支付?如何设计支付渠道的路由和降级策略?
答案:
推荐方案(Go实现):
// ChannelRouter 支付渠道路由器
type ChannelRouter struct {
healthChecker *ChannelHealthChecker
config *RoutingConfig
}
// SelectChannel 选择支付渠道
func (r *ChannelRouter) SelectChannel(ctx context.Context,
preferredChannel PaymentChannel) (PaymentChannel, error) {
// 1. 检查首选渠道健康状态
if r.healthChecker.IsHealthy(preferredChannel) {
return preferredChannel, nil
}
log.Warnf("渠道%s不可用,尝试降级", preferredChannel)
// 2. 降级到备用渠道
fallbackChannels := r.config.GetFallback(preferredChannel)
for _, channel := range fallbackChannels {
if r.healthChecker.IsHealthy(channel) {
log.Infof("降级到渠道%s", channel)
return channel, nil
}
}
// 3. 所有渠道都不可用
return "", errors.New("支付渠道暂时不可用,请稍后再试")
}
// ChannelHealthChecker 渠道健康检查
type ChannelHealthChecker struct {
rdb *redis.Client
}
func (c *ChannelHealthChecker) IsHealthy(channel PaymentChannel) bool {
key := fmt.Sprintf("payment:channel:health:%s", channel)
// 从Redis读取健康状态
status, err := c.rdb.Get(context.Background(), key).Result()
if err != nil || status != "UP" {
return false
}
return true
}
// 健康检查任务(心跳)
func (c *ChannelHealthChecker) StartHealthCheck(ctx context.Context) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
// 对每个渠道执行健康检查
for _, channel := range AllChannels {
go c.checkChannel(ctx, channel)
}
}
}
func (c *ChannelHealthChecker) checkChannel(ctx context.Context,
channel PaymentChannel) {
adapter := getAdapter(channel)
// 调用渠道健康检查接口(或创建1分钱订单测试)
err := adapter.HealthCheck(ctx)
key := fmt.Sprintf("payment:channel:health:%s", channel)
if err != nil {
// 不健康
c.rdb.SetEX(ctx, key, "DOWN", 5*time.Minute)
log.Errorf("渠道%s健康检查失败: %v", channel, err)
// 告警
c.alertSvc.Send(fmt.Sprintf("支付渠道%s故障", channel))
} else {
// 健康
c.rdb.SetEX(ctx, key, "UP", 5*time.Minute)
}
}
// 路由配置
type RoutingConfig struct {
fallbacks map[PaymentChannel][]PaymentChannel
}
func NewRoutingConfig() *RoutingConfig {
return &RoutingConfig{
fallbacks: map[PaymentChannel][]PaymentChannel{
ChannelAlipay: {ChannelWechat, ChannelUnion}, // 支付宝 → 微信 → 银联
ChannelWechat: {ChannelAlipay, ChannelUnion},
ChannelUnion: {ChannelAlipay, ChannelWechat},
},
}
}
延伸思考:
- 如何设计支付渠道的成本优化(选择手续费低的)?
- 支付渠道限额如何处理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6485。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-PEAK-008:支付的容灾和降级
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-PEAK-008 |
| 题型 | 系统设计题 |
| 范围 | 平台级:峰值流量、容量规划与可靠性 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 容量规划、稳定性治理、支付降级 |
| 场景标签 | 大促峰值、故障演练、支付高峰 |
| 能力域 | 大促、秒杀、容量与可靠性 |
| 来源 | books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6764 |
题干与约束
支付是核心链路,不能中断。如何设计支付系统的容灾和降级方案?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 支付是核心链路,不能中断。如何设计支付系统的容灾和降级方案?
答案:
推荐方案(Go实现):
// PaymentFallbackService 支付降级服务
type PaymentFallbackService struct {
primarySvc *PaymentService
fallbackMode bool
}
// Pay 支付(带降级)
func (s *PaymentFallbackService) Pay(ctx context.Context,
req *PaymentRequest) (*PaymentResponse, error) {
// 1. 尝试正常支付
resp, err := s.primarySvc.CreatePayment(ctx, req)
if err == nil {
return resp, nil
}
log.Warnf("支付失败: %v,尝试降级", err)
// 2. 降级方案
if s.shouldFallback(err) {
return s.fallbackPay(ctx, req)
}
return nil, err
}
// 降级支付
func (s *PaymentFallbackService) fallbackPay(ctx context.Context,
req *PaymentRequest) (*PaymentResponse, error) {
// 降级策略1:切换支付渠道
if req.Channel == ChannelAlipay {
req.Channel = ChannelWechat
return s.primarySvc.CreatePayment(ctx, req)
}
// 降级策略2:使用货到付款
if s.isCODAvailable(req) {
return s.createCODOrder(ctx, req)
}
// 降级策略3:延迟支付(订单保留,稍后支付)
return s.createDelayedPayment(ctx, req)
}
// 熔断器
type CircuitBreaker struct {
failureThreshold int
timeout time.Duration
state CircuitState
failureCount int
lastFailTime time.Time
}
type CircuitState int
const (
StateClosed CircuitState = 0 // 闭合(正常)
StateOpen CircuitState = 1 // 开启(熔断)
StateHalfOpen CircuitState = 2 // 半开(尝试恢复)
)
func (cb *CircuitBreaker) Execute(ctx context.Context,
fn func() error) error {
// 检查熔断器状态
if cb.state == StateOpen {
// 检查是否可以尝试恢复
if time.Since(cb.lastFailTime) > cb.timeout {
cb.state = StateHalfOpen
} else {
return errors.New("熔断器开启,拒绝请求")
}
}
// 执行函数
err := fn()
if err != nil {
cb.onFailure()
} else {
cb.onSuccess()
}
return err
}
func (cb *CircuitBreaker) onFailure() {
cb.failureCount++
cb.lastFailTime = time.Now()
if cb.failureCount >= cb.failureThreshold {
cb.state = StateOpen
log.Warn("熔断器开启")
}
}
func (cb *CircuitBreaker) onSuccess() {
if cb.state == StateHalfOpen {
// 半开状态成功,恢复到闭合
cb.state = StateClosed
cb.failureCount = 0
log.Info("熔断器关闭,恢复正常")
}
}
延伸思考:
- 如何设计支付系统的多机房容灾?
- 支付降级后如何通知用户?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/09-search-cart-order-payment-questionbank.md:6764。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-PEAK-009:100 万酒店 10 小时如何估算吞吐?
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-PEAK-009 |
| 题型 | 系统设计题 |
| 范围 | 平台级:峰值流量、容量规划与可靠性 |
| 难度 | 进阶 |
| 建议用时 | 45 分钟 |
| 能力标签 | 容量规划、稳定性治理、容灾恢复 |
| 场景标签 | 大促峰值、故障演练 |
| 能力域 | 大促、秒杀、容量与可靠性 |
| 来源 | books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:506 |
题干与约束
100 万酒店 10 小时如何估算吞吐?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
参考回答:100 万 / 10 小时约等于 27.8 个酒店/秒。如果每页 100 个酒店,大约需要 10000 页,10 小时内只需要 0.28 页/秒。但如果要逐个拉详情,就是 27.8 QPS,还要受供应商限流、超时、重试和数据处理速度约束。
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/03-ecommerce-architecture-interview.md:506。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
综合案例与白板题
本节覆盖综合案例与白板设计。
Q-ECOM-CASE-001:设计一个百万级QPS的商品详情页系统
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-001 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、高并发读 |
| 场景标签 | 综合案例、白板推演、商品详情 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:13 |
题干与约束
电商平台的商品详情页是流量最大的页面,大促期间QPS可达100万。请设计一套完整的商品详情页系统,保证高性能和高可用。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 电商平台的商品详情页是流量最大的页面,大促期间QPS可达100万。请设计一套完整的商品详情页系统,保证高性能和高可用。
答案:
系统架构设计(Go实现):
package product
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
// ProductDetailService 商品详情服务
type ProductDetailService struct {
productRepo ProductRepository
l1Cache LocalCache // L1缓存:本地内存
l2Cache *redis.Client // L2缓存:Redis
cdn CDNService // CDN
mq MessageQueue // 消息队列
}
// GetProductDetail 获取商品详情(多级缓存)
func (s *ProductDetailService) GetProductDetail(ctx context.Context,
productID int64) (*ProductDetail, error) {
cacheKey := fmt.Sprintf("product:detail:%d", productID)
// L1缓存:本地内存(命中率60%)
if detail := s.l1Cache.Get(cacheKey); detail != nil {
return detail.(*ProductDetail), nil
}
// L2缓存:Redis(命中率95%)
detailJSON, err := s.l2Cache.Get(ctx, cacheKey).Result()
if err == nil {
detail := &ProductDetail{}
json.Unmarshal([]byte(detailJSON), detail)
// 回填L1
s.l1Cache.Set(cacheKey, detail, 5*time.Minute)
return detail, nil
}
// L3:数据库(命中率5%)
detail, err := s.productRepo.FindByID(ctx, productID)
if err != nil {
return nil, err
}
// 异步回填缓存
go func() {
detailJSON, _ := json.Marshal(detail)
s.l2Cache.SetEX(context.Background(), cacheKey, detailJSON, time.Hour)
s.l1Cache.Set(cacheKey, detail, 5*time.Minute)
}()
return detail, nil
}
// 缓存预热
func (s *ProductDetailService) WarmUpCache(ctx context.Context, productIDs []int64) error {
for _, pid := range productIDs {
detail, err := s.productRepo.FindByID(ctx, pid)
if err != nil {
continue
}
// 预热到Redis
cacheKey := fmt.Sprintf("product:detail:%d", pid)
detailJSON, _ := json.Marshal(detail)
s.l2Cache.SetEX(ctx, cacheKey, detailJSON, time.Hour)
}
return nil
}
// 缓存失效策略(商品更新时)
func (s *ProductDetailService) UpdateProduct(ctx context.Context,
product *Product) error {
// 1. 更新数据库
if err := s.productRepo.Update(ctx, product); err != nil {
return err
}
// 2. 发布缓存失效消息
msg := CacheInvalidateMessage{
ProductID: product.ProductID,
Timestamp: time.Now(),
}
s.mq.Publish("product.cache.invalidate", msg)
return nil
}
// 监听缓存失效消息
func (s *ProductDetailService) StartCacheInvalidateListener() {
s.mq.Subscribe("product.cache.invalidate", func(msg *CacheInvalidateMessage) {
cacheKey := fmt.Sprintf("product:detail:%d", msg.ProductID)
// 删除L1和L2缓存
s.l1Cache.Delete(cacheKey)
s.l2Cache.Del(context.Background(), cacheKey)
log.Infof("商品%d缓存已失效", msg.ProductID)
})
}
CDN静态化:
// 静态化商品详情页(HTML)
func (s *ProductDetailService) GenerateStaticHTML(ctx context.Context,
productID int64) (string, error) {
detail, err := s.GetProductDetail(ctx, productID)
if err != nil {
return "", err
}
// 渲染HTML模板
html := s.renderTemplate(detail)
// 上传到CDN
cdnURL := fmt.Sprintf("https://cdn.example.com/product/%d.html", productID)
if err := s.cdn.Upload(cdnURL, html); err != nil {
return "", err
}
return cdnURL, nil
}
性能优化点:
- 多级缓存:本地内存(1ms)→ Redis(5ms)→ MySQL(50ms)
- CDN静态化:核心商品预生成HTML,加载时间<100ms
- 缓存预热:大促前1小时预热热门商品
- 异步刷新:Cache Aside模式,回源不阻塞请求
- 降级策略:Redis故障时降级到MySQL+限流
容量规划:
QPS:100万
平均响应时间:50ms
并发连接数:100万 * 0.05 = 5万
服务器配置:
- 应用服务器:100台(每台1万QPS)
- Redis集群:50个主节点(每节点2万QPS)
- MySQL:主从+分库分表(32个分片)
延伸思考:
- 商品详情页的AB测试如何设计?
- 图片加载优化(WebP、懒加载)如何实现?
- 缓存雪崩如何防范?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:13。本章不依赖旧 Part Four 文件链接。
相关章节:商品中心。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-CASE-002:秒杀系统的完整设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-002 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、削峰与库存一致性 |
| 场景标签 | 综合案例、白板推演、秒杀活动 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:182 |
题干与约束
设计一个支持10万QPS的秒杀系统,商品库存100件,要求:防止超卖、保证公平性、抵抗恶意刷单。
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 设计一个支持10万QPS的秒杀系统,商品库存100件,要求:防止超卖、保证公平性、抵抗恶意刷单。
答案:
架构设计(Go实现):
package seckill
import (
"context"
"errors"
"fmt"
"time"
"github.com/go-redis/redis/v8"
)
// SeckillService 秒杀服务
type SeckillService struct {
rdb *redis.Client
orderSvc OrderService
inventorySvc InventoryService
mq MessageQueue
}
// CreateSeckill 创建秒杀活动
func (s *SeckillService) CreateSeckill(ctx context.Context,
seckill *Seckill) error {
// 1. 创建秒杀活动
if err := s.seckillRepo.Create(ctx, seckill); err != nil {
return err
}
// 2. 库存预热到Redis
stockKey := fmt.Sprintf("seckill:stock:%d", seckill.SeckillID)
s.rdb.Set(ctx, stockKey, seckill.Stock, 0)
// 3. 创建商品详情页缓存
s.preWarmCache(ctx, seckill.ProductID)
return nil
}
// Seckill 秒杀下单
func (s *SeckillService) Seckill(ctx context.Context,
userID int64, seckillID int64) error {
// 1. 限流(单用户限流+全局限流)
if err := s.checkRateLimit(ctx, userID, seckillID); err != nil {
return err
}
// 2. 风控检查
if err := s.riskCheck(ctx, userID); err != nil {
return err
}
// 3. 扣减Redis库存(Lua脚本保证原子性)
stock, err := s.deductStock(ctx, seckillID)
if err != nil {
return err
}
if stock < 0 {
return errors.New("商品已抢光")
}
// 4. 发送消息到MQ异步创建订单
msg := SeckillOrderMessage{
UserID: userID,
SeckillID: seckillID,
Timestamp: time.Now(),
}
if err := s.mq.Publish("seckill.order.create", msg); err != nil {
// MQ发送失败,回补库存
s.increaseStock(ctx, seckillID)
return err
}
return nil
}
// 扣减库存(Lua脚本)
func (s *SeckillService) deductStock(ctx context.Context,
seckillID int64) (int64, error) {
stockKey := fmt.Sprintf("seckill:stock:%d", seckillID)
// Lua脚本保证原子性
script := `
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) <= 0 then
return -1
end
redis.call('DECR', KEYS[1])
return stock - 1
`
result, err := s.rdb.Eval(ctx, script, []string{stockKey}).Result()
if err != nil {
return 0, err
}
return result.(int64), nil
}
// 限流(令牌桶)
func (s *SeckillService) checkRateLimit(ctx context.Context,
userID int64, seckillID int64) error {
// 单用户限流:1秒内最多1次
userKey := fmt.Sprintf("seckill:ratelimit:user:%d:%d", userID, seckillID)
exists, _ := s.rdb.Exists(ctx, userKey).Result()
if exists > 0 {
return errors.New("操作太频繁")
}
s.rdb.SetEX(ctx, userKey, 1, time.Second)
// 全局限流:令牌桶(10万QPS)
globalKey := fmt.Sprintf("seckill:ratelimit:global:%d", seckillID)
token, _ := s.rdb.Incr(ctx, globalKey).Result()
if token == 1 {
s.rdb.Expire(ctx, globalKey, time.Second)
}
if token > 100000 {
return errors.New("系统繁忙,请稍后再试")
}
return nil
}
// 异步创建订单(消费MQ)
func (s *SeckillService) CreateOrderAsync() {
s.mq.Subscribe("seckill.order.create", func(msg *SeckillOrderMessage) {
ctx := context.Background()
// 1. 防重(幂等性)
orderKey := fmt.Sprintf("seckill:order:%d:%d", msg.UserID, msg.SeckillID)
exists, _ := s.rdb.Exists(ctx, orderKey).Result()
if exists > 0 {
log.Warnf("用户%d已抢购过", msg.UserID)
return
}
// 2. 创建订单
order, err := s.orderSvc.CreateSeckillOrder(ctx, msg.UserID, msg.SeckillID)
if err != nil {
log.Errorf("创建订单失败: %v", err)
// 回补库存
s.increaseStock(ctx, msg.SeckillID)
return
}
// 3. 标记已抢购
s.rdb.SetEX(ctx, orderKey, order.OrderID, 24*time.Hour)
// 4. 通知用户
s.notifySvc.Send(ctx, msg.UserID, "秒杀成功,请尽快支付")
})
}
// 定时任务:取消未支付订单
func (s *SeckillService) CancelUnpaidOrders() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
// 查询15分钟前创建的未支付秒杀订单
ctx := context.Background()
cutoffTime := time.Now().Add(-15 * time.Minute)
orders, _ := s.orderRepo.FindUnpaidSeckillOrders(ctx, cutoffTime)
for _, order := range orders {
// 取消订单
s.orderSvc.Cancel(ctx, order.OrderID)
// 回补库存
s.increaseStock(ctx, order.SeckillID)
}
}
}
架构要点:
- 前端限流:按钮置灰、验证码、排队页
- 网关限流:Nginx限流(10万QPS)
- Redis预扣库存:Lua脚本原子操作
- 异步下单:MQ削峰,提升吞吐
- 超时取消:15分钟未支付自动取消+回补库存
延伸思考:
- 秒杀如何防止黄牛?
- 分布式锁如何选型(Redis vs Etcd)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:182。本章不依赖旧 Part Four 文件链接。
相关章节:营销与计价系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-CASE-003:订单履约的全链路监控
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-003 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、交易编排 |
| 场景标签 | 综合案例、白板推演、订单履约 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:387 |
题干与约束
订单从创建到签收,涉及多个服务(订单、库存、物流、支付)。如何设计全链路监控,快速定位问题?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单从创建到签收,涉及多个服务(订单、库存、物流、支付)。如何设计全链路监控,快速定位问题?
答案:
推荐方案:分布式追踪(OpenTelemetry + Jaeger)
package tracing
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// OrderFulfillmentService 订单履约服务
type OrderFulfillmentService struct {
tracer trace.Tracer
}
// FulfillOrder 履约订单(带追踪)
func (s *OrderFulfillmentService) FulfillOrder(ctx context.Context,
orderID int64) error {
// 创建根Span
ctx, span := s.tracer.Start(ctx, "FulfillOrder",
trace.WithAttributes(
attribute.Int64("order.id", orderID),
),
)
defer span.End()
// 步骤1:扣减库存
if err := s.deductInventory(ctx, orderID); err != nil {
span.RecordError(err)
return err
}
// 步骤2:创建拣货单
if err := s.createPickingOrder(ctx, orderID); err != nil {
span.RecordError(err)
return err
}
// 步骤3:创建物流运单
if err := s.createShipment(ctx, orderID); err != nil {
span.RecordError(err)
return err
}
span.SetAttributes(attribute.String("status", "success"))
return nil
}
// deductInventory 扣减库存(子Span)
func (s *OrderFulfillmentService) deductInventory(ctx context.Context,
orderID int64) error {
ctx, span := s.tracer.Start(ctx, "DeductInventory")
defer span.End()
// 调用库存服务
start := time.Now()
err := s.inventorySvc.Deduct(ctx, orderID)
duration := time.Since(start)
span.SetAttributes(
attribute.String("service", "inventory"),
attribute.Int64("duration_ms", duration.Milliseconds()),
)
if err != nil {
span.RecordError(err)
return err
}
return nil
}
监控指标:
// RED指标(请求速率、错误率、耗时)
type Metrics struct {
// Rate: 请求速率
OrderCreateRate *prometheus.CounterVec
// Error: 错误率
OrderCreateErrors *prometheus.CounterVec
// Duration: 耗时分布
OrderCreateDuration *prometheus.HistogramVec
}
// 记录指标
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
start := time.Now()
// 请求计数
s.metrics.OrderCreateRate.WithLabelValues("order_service").Inc()
// 执行业务逻辑
order, err := s.doCreateOrder(ctx, req)
// 记录耗时
duration := time.Since(start).Seconds()
s.metrics.OrderCreateDuration.WithLabelValues("order_service").Observe(duration)
// 记录错误
if err != nil {
s.metrics.OrderCreateErrors.WithLabelValues("order_service", "error").Inc()
} else {
s.metrics.OrderCreateErrors.WithLabelValues("order_service", "success").Inc()
}
return order, err
}
延伸思考:
- 如何设计告警规则(P95延迟>500ms告警)?
- 如何追踪跨语言调用链(Go → Java → Python)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:387。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-CASE-004:大促准备的全链路压测
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-004 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、韧性工程 |
| 场景标签 | 综合案例、白板推演、大促峰值 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:517 |
题干与约束
618大促前,需要对整个系统进行压测,验证系统能否支撑预期流量。如何设计全链路压测方案?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 618大促前,需要对整个系统进行压测,验证系统能否支撑预期流量。如何设计全链路压测方案?
答案:
压测方案(Go实现):
package loadtest
import (
"context"
"sync"
"time"
)
// LoadTester 压测工具
type LoadTester struct {
targetQPS int
duration time.Duration
workers int
}
// Run 执行压测
func (lt *LoadTester) Run(ctx context.Context,
testFunc func(context.Context) error) *LoadTestResult {
result := &LoadTestResult{
StartTime: time.Now(),
}
// 计算每个worker的QPS
qpsPerWorker := lt.targetQPS / lt.workers
interval := time.Second / time.Duration(qpsPerWorker)
var wg sync.WaitGroup
resultChan := make(chan *RequestResult, lt.targetQPS*int(lt.duration.Seconds()))
// 启动workers
for i := 0; i < lt.workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
ticker := time.NewTicker(interval)
defer ticker.Stop()
timeout := time.After(lt.duration)
for {
select {
case <-ticker.C:
// 执行请求
reqResult := lt.executeRequest(ctx, testFunc)
resultChan <- reqResult
case <-timeout:
return
}
}
}()
}
// 等待所有workers完成
go func() {
wg.Wait()
close(resultChan)
}()
// 收集结果
for reqResult := range resultChan {
result.TotalRequests++
if reqResult.Success {
result.SuccessCount++
result.TotalLatency += reqResult.Latency
} else {
result.FailureCount++
}
// 记录延迟分布
result.LatencyDistribution = append(result.LatencyDistribution, reqResult.Latency)
}
result.EndTime = time.Now()
result.Calculate()
return result
}
// executeRequest 执行单次请求
func (lt *LoadTester) executeRequest(ctx context.Context,
testFunc func(context.Context) error) *RequestResult {
result := &RequestResult{
StartTime: time.Now(),
}
err := testFunc(ctx)
result.EndTime = time.Now()
result.Latency = result.EndTime.Sub(result.StartTime)
result.Success = (err == nil)
return result
}
// LoadTestResult 压测结果
type LoadTestResult struct {
StartTime time.Time
EndTime time.Time
TotalRequests int
SuccessCount int
FailureCount int
TotalLatency time.Duration
LatencyDistribution []time.Duration
// 计算指标
QPS float64
AvgLatency time.Duration
P50Latency time.Duration
P95Latency time.Duration
P99Latency time.Duration
SuccessRate float64
}
// Calculate 计算统计指标
func (r *LoadTestResult) Calculate() {
duration := r.EndTime.Sub(r.StartTime).Seconds()
r.QPS = float64(r.TotalRequests) / duration
if r.SuccessCount > 0 {
r.AvgLatency = r.TotalLatency / time.Duration(r.SuccessCount)
}
r.SuccessRate = float64(r.SuccessCount) / float64(r.TotalRequests) * 100
// 计算P50/P95/P99
sort.Slice(r.LatencyDistribution, func(i, j int) bool {
return r.LatencyDistribution[i] < r.LatencyDistribution[j]
})
if len(r.LatencyDistribution) > 0 {
r.P50Latency = r.LatencyDistribution[len(r.LatencyDistribution)*50/100]
r.P95Latency = r.LatencyDistribution[len(r.LatencyDistribution)*95/100]
r.P99Latency = r.LatencyDistribution[len(r.LatencyDistribution)*99/100]
}
}
// 使用示例
func TestOrderCreate() {
tester := &LoadTester{
targetQPS: 10000, // 目标1万QPS
duration: 5 * time.Minute,
workers: 100,
}
result := tester.Run(context.Background(), func(ctx context.Context) error {
// 模拟下单
return orderSvc.CreateOrder(ctx, &CreateOrderRequest{
UserID: randomUserID(),
Items: randomItems(),
})
})
// 输出结果
fmt.Printf("QPS: %.2f\n", result.QPS)
fmt.Printf("成功率: %.2f%%\n", result.SuccessRate)
fmt.Printf("平均延迟: %v\n", result.AvgLatency)
fmt.Printf("P95延迟: %v\n", result.P95Latency)
fmt.Printf("P99延迟: %v\n", result.P99Latency)
}
压测环境隔离:
生产环境:不能压测
预发环境:配置与生产一致,数据隔离
压测流量标记:HTTP Header: X-Load-Test: true
延伸思考:
- 如何设计压测数据构造?
- 压测导致的脏数据如何清理?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:517。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-CASE-005:跨境电商的多币种结算
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-005 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、资金安全 |
| 场景标签 | 综合案例、白板推演、跨境支付 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:706 |
题干与约束
跨境电商平台支持美元、欧元、人民币等多币种。如何设计多币种的定价、支付、结算系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 跨境电商平台支持美元、欧元、人民币等多币种。如何设计多币种的定价、支付、结算系统?
答案:
推荐方案(Go实现):
package currency
import (
"context"
"time"
"github.com/shopspring/decimal"
)
// Currency 币种
type Currency string
const (
CNY Currency = "CNY" // 人民币
USD Currency = "USD" // 美元
EUR Currency = "EUR" // 欧元
)
// ExchangeRateService 汇率服务
type ExchangeRateService struct {
rdb *redis.Client
repo ExchangeRateRepository
}
// GetExchangeRate 获取汇率
func (s *ExchangeRateService) GetExchangeRate(ctx context.Context,
from, to Currency) (decimal.Decimal, error) {
if from == to {
return decimal.NewFromInt(1), nil
}
// 从缓存读取
cacheKey := fmt.Sprintf("exchange_rate:%s:%s", from, to)
rateStr, err := s.rdb.Get(ctx, cacheKey).Result()
if err == nil {
rate, _ := decimal.NewFromString(rateStr)
return rate, nil
}
// 从数据库读取
rate, err := s.repo.FindLatest(ctx, from, to)
if err != nil {
return decimal.Zero, err
}
// 缓存1小时
s.rdb.SetEX(ctx, cacheKey, rate.Rate.String(), time.Hour)
return rate.Rate, nil
}
// Convert 货币转换
func (s *ExchangeRateService) Convert(ctx context.Context,
amount decimal.Decimal, from, to Currency) (decimal.Decimal, error) {
rate, err := s.GetExchangeRate(ctx, from, to)
if err != nil {
return decimal.Zero, err
}
return amount.Mul(rate), nil
}
// ProductPricing 商品多币种定价
type ProductPricing struct {
ProductID int64
Prices map[Currency]decimal.Decimal
}
// GetPrice 获取指定币种的价格
func (p *ProductPricing) GetPrice(currency Currency) (decimal.Decimal, error) {
if price, exists := p.Prices[currency]; exists {
return price, nil
}
return decimal.Zero, errors.New("该币种暂不支持")
}
// OrderService 订单服务(多币种)
type OrderService struct {
exchangeRateSvc *ExchangeRateService
}
// CreateOrder 创建订单(多币种)
func (s *OrderService) CreateOrder(ctx context.Context,
req *CreateOrderRequest) (*Order, error) {
// 1. 计算订单金额(用户选择的币种)
var totalAmount decimal.Decimal
for _, item := range req.Items {
price := item.Product.GetPrice(req.Currency)
totalAmount = totalAmount.Add(price.Mul(decimal.NewFromInt(int64(item.Quantity))))
}
// 2. 转换为平台基准币种(CNY)
baseCurrencyAmount, err := s.exchangeRateSvc.Convert(ctx,
totalAmount, req.Currency, CNY)
if err != nil {
return nil, err
}
// 3. 创建订单
order := &Order{
OrderID: generateOrderID(),
UserID: req.UserID,
Currency: req.Currency, // 显示币种
TotalAmount: totalAmount, // 显示金额
BaseCurrency: CNY, // 基准币种
BaseCurrencyAmount: baseCurrencyAmount, // 基准金额
ExchangeRate: s.getExchangeRate(ctx, req.Currency, CNY),
CreatedAt: time.Now(),
}
return order, s.orderRepo.Create(ctx, order)
}
// 汇率快照(订单创建时记录汇率)
func (s *OrderService) getExchangeRate(ctx context.Context,
from, to Currency) decimal.Decimal {
rate, _ := s.exchangeRateSvc.GetExchangeRate(ctx, from, to)
return rate
}
结算处理:
// SettlementService 结算服务(多币种)
type SettlementService struct {
exchangeRateSvc *ExchangeRateService
}
// Settle 结算
func (s *SettlementService) Settle(ctx context.Context,
merchantID int64, date time.Time) error {
// 1. 查询待结算订单
orders, _ := s.orderRepo.FindPendingSettlement(ctx, merchantID, date)
// 2. 按币种分组汇总
settlementByCurrency := make(map[Currency]decimal.Decimal)
for _, order := range orders {
current := settlementByCurrency[order.Currency]
settlementByCurrency[order.Currency] = current.Add(order.MerchantAmount)
}
// 3. 转换为商家收款币种并结算
merchantCurrency := s.getMerchantCurrency(ctx, merchantID)
for currency, amount := range settlementByCurrency {
// 转换币种
settleAmount, _ := s.exchangeRateSvc.Convert(ctx,
amount, currency, merchantCurrency)
// 调用支付渠道转账
s.paymentSvc.Transfer(ctx, merchantID, settleAmount, merchantCurrency)
}
return nil
}
延伸思考:
- 汇率波动如何处理(订单创建时汇率 vs 支付时汇率)?
- 跨境支付的关税如何计算?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:706。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章支付内容。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-CASE-006:电商搜索的智能排序
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-006 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、搜索架构 |
| 场景标签 | 综合案例、白板推演、搜索导购 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:885 |
题干与约束
用户搜索“手机“,返回1000个结果。如何设计排序算法,让用户最可能购买的商品排在前面?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 用户搜索“手机“,返回1000个结果。如何设计排序算法,让用户最可能购买的商品排在前面?
答案:
推荐方案:多因子排序模型
package search
import (
"context"
"math"
"github.com/shopspring/decimal"
)
// SearchRankingService 搜索排序服务
type SearchRankingService struct {
userProfileSvc UserProfileService
}
// RankProducts 对商品排序
func (s *SearchRankingService) RankProducts(ctx context.Context,
userID int64, products []*Product) []*ScoredProduct {
scoredProducts := make([]*ScoredProduct, 0, len(products))
for _, product := range products {
score := s.calculateScore(ctx, userID, product)
scoredProducts = append(scoredProducts, &ScoredProduct{
Product: product,
Score: score,
})
}
// 按分数降序排序
sort.Slice(scoredProducts, func(i, j int) bool {
return scoredProducts[i].Score > scoredProducts[j].Score
})
return scoredProducts
}
// calculateScore 计算商品综合分数
func (s *SearchRankingService) calculateScore(ctx context.Context,
userID int64, product *Product) float64 {
// 多因子加权求和
score := 0.0
// 1. 文本相关性(权重20%)
textRelevance := s.calculateTextRelevance(product)
score += textRelevance * 0.2
// 2. 销量(权重15%)
salesScore := s.normalizeSales(product.SalesCount)
score += salesScore * 0.15
// 3. 好评率(权重10%)
ratingScore := product.Rating / 5.0
score += ratingScore * 0.1
// 4. 价格(权重10%)
priceScore := s.calculatePriceScore(product.Price)
score += priceScore * 0.1
// 5. 个性化(权重30%)
personalScore := s.calculatePersonalScore(ctx, userID, product)
score += personalScore * 0.3
// 6. 时效性(权重5%)
timeScore := s.calculateTimeScore(product.CreatedAt)
score += timeScore * 0.05
// 7. 商家质量(权重10%)
merchantScore := s.calculateMerchantScore(product.MerchantID)
score += merchantScore * 0.1
return score
}
// 个性化分数(基于用户画像)
func (s *SearchRankingService) calculatePersonalScore(ctx context.Context,
userID int64, product *Product) float64 {
profile := s.userProfileSvc.GetProfile(ctx, userID)
score := 0.0
// 1. 品牌偏好
if contains(profile.FavoriteBrands, product.Brand) {
score += 0.3
}
// 2. 类目偏好
if contains(profile.FavoriteCategories, product.CategoryID) {
score += 0.3
}
// 3. 价格区间偏好
if product.Price.GreaterThanOrEqual(profile.MinPrice) &&
product.Price.LessThanOrEqual(profile.MaxPrice) {
score += 0.2
}
// 4. 历史浏览相似度
similarity := s.calculateSimilarity(product, profile.ViewedProducts)
score += similarity * 0.2
return score
}
// 销量归一化(对数变换)
func (s *SearchRankingService) normalizeSales(salesCount int64) float64 {
if salesCount == 0 {
return 0
}
// 对数变换平滑销量差异
return math.Log10(float64(salesCount)+1) / math.Log10(1000000)
}
机器学习排序:
// LearningToRank 学习排序模型
type LearningToRank struct {
model MLModel
}
// Rank 使用模型排序
func (ltr *LearningToRank) Rank(ctx context.Context,
userID int64, products []*Product) []*ScoredProduct {
scoredProducts := make([]*ScoredProduct, 0)
for _, product := range products {
// 提取特征
features := ltr.extractFeatures(ctx, userID, product)
// 模型预测分数
score := ltr.model.Predict(features)
scoredProducts = append(scoredProducts, &ScoredProduct{
Product: product,
Score: score,
})
}
// 排序
sort.Slice(scoredProducts, func(i, j int) bool {
return scoredProducts[i].Score > scoredProducts[j].Score
})
return scoredProducts
}
// 特征提取
func (ltr *LearningToRank) extractFeatures(ctx context.Context,
userID int64, product *Product) []float64 {
return []float64{
float64(product.SalesCount),
product.Rating,
product.Price.InexactFloat64(),
float64(product.ReviewCount),
// ... 更多特征
}
}
延伸思考:
- 如何设计AB测试验证排序效果?
- 如何平衡新品曝光和热销商品?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:885。本章不依赖旧 Part Four 文件链接。
相关章节:第 14 章搜索与交易全生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-CASE-007:异常流量的应急处理
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-007 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、韧性工程 |
| 场景标签 | 综合案例、白板推演、大促峰值 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:1065 |
题干与约束
凌晨2点,监控告警:订单服务QPS突增10倍,响应时间飙升至5秒,疑似遭受攻击。如何快速定位和处理?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 凌晨2点,监控告警:订单服务QPS突增10倍,响应时间飙升至5秒,疑似遭受攻击。如何快速定位和处理?
答案:
应急响应流程(Go实现):
package emergency
import (
"context"
"time"
)
// EmergencyHandler 应急处理器
type EmergencyHandler struct {
rateLimiter *RateLimiter
ipBlacklist *IPBlacklist
alertSvc AlertService
}
// HandleAbnormalTraffic 处理异常流量
func (h *EmergencyHandler) HandleAbnormalTraffic(ctx context.Context) error {
// 第1步:分析流量特征
analysis := h.analyzeTraffic(ctx)
// 第2步:判断攻击类型
attackType := h.identifyAttackType(analysis)
// 第3步:执行防御措施
switch attackType {
case AttackTypeDDoS:
return h.handleDDoS(ctx, analysis)
case AttackTypeCrawler:
return h.handleCrawler(ctx, analysis)
case AttackTypeBrushOrder:
return h.handleBrushOrder(ctx, analysis)
default:
return h.handleUnknown(ctx, analysis)
}
}
// analyzeTraffic 分析流量
func (h *EmergencyHandler) analyzeTraffic(ctx context.Context) *TrafficAnalysis {
now := time.Now()
last5Min := now.Add(-5 * time.Minute)
// 查询最近5分钟的请求日志
logs := h.logSvc.Query(ctx, last5Min, now)
analysis := &TrafficAnalysis{
TotalRequests: len(logs),
IPDistribution: make(map[string]int),
UADistribution: make(map[string]int),
URLDistribution: make(map[string]int),
}
for _, log := range logs {
// IP分布
analysis.IPDistribution[log.IP]++
// User-Agent分布
analysis.UADistribution[log.UserAgent]++
// URL分布
analysis.URLDistribution[log.URL]++
}
// 识别异常IP(单IP请求占比>10%)
for ip, count := range analysis.IPDistribution {
ratio := float64(count) / float64(analysis.TotalRequests)
if ratio > 0.1 {
analysis.AbnormalIPs = append(analysis.AbnormalIPs, ip)
}
}
return analysis
}
// handleDDoS 处理DDoS攻击
func (h *EmergencyHandler) handleDDoS(ctx context.Context,
analysis *TrafficAnalysis) error {
// 1. 立即限流(全局QPS降低到正常值的50%)
h.rateLimiter.SetGlobalLimit(10000)
// 2. 封禁异常IP
for _, ip := range analysis.AbnormalIPs {
h.ipBlacklist.Add(ip, 1*time.Hour)
log.Warnf("封禁IP: %s", ip)
}
// 3. 启用验证码
h.enableCaptcha()
// 4. 通知运维
h.alertSvc.Send("紧急:疑似DDoS攻击,已自动防御")
return nil
}
// handleBrushOrder 处理刷单攻击
func (h *EmergencyHandler) handleBrushOrder(ctx context.Context,
analysis *TrafficAnalysis) error {
// 1. 识别刷单用户
suspiciousUsers := h.identifySuspiciousUsers(ctx)
// 2. 限制下单频率
for _, userID := range suspiciousUsers {
h.rateLimiter.SetUserLimit(userID, 1) // 1分钟1单
log.Warnf("限制用户%d下单频率", userID)
}
// 3. 启用风控策略(大额订单人工审核)
h.enableManualReview()
return nil
}
// 降级策略
func (h *EmergencyHandler) Degrade(ctx context.Context) error {
// Level 1:关闭非核心功能
h.disableRecommendation() // 关闭推荐
h.disableSearch() // 关闭搜索
// Level 2:只读模式(禁止下单)
h.enableReadOnlyMode()
// Level 3:返回静态页面
h.enableStaticMode()
return nil
}
监控告警:
// AlertRule 告警规则
type AlertRule struct {
Name string
Metric string
Threshold float64
Duration time.Duration
Severity string // P0/P1/P2/P3
}
// 告警规则示例
var alertRules = []AlertRule{
{
Name: "订单QPS异常",
Metric: "order_create_qps",
Threshold: 10000, // QPS超过1万
Duration: 1 * time.Minute,
Severity: "P0",
},
{
Name: "订单延迟异常",
Metric: "order_create_p99_latency",
Threshold: 1000, // P99超过1秒
Duration: 5 * time.Minute,
Severity: "P1",
},
}
延伸思考:
- 如何设计自动化的应急响应系统?
- 如何平衡防御和用户体验(误封正常用户)?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:1065。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-CASE-008:订单的柔性事务设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-008 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、交易编排 |
| 场景标签 | 综合案例、白板推演、订单履约 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:1240 |
题干与约束
订单创建涉及多个服务(扣库存、扣优惠券、扣积分)。如何设计柔性事务,保证最终一致性?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 订单创建涉及多个服务(扣库存、扣优惠券、扣积分)。如何设计柔性事务,保证最终一致性?
答案:
推荐方案:本地消息表 + 定时补偿
package transaction
import (
"context"
"time"
)
// LocalMessageTable 本地消息表
type LocalMessage struct {
MessageID string
BizType string // 业务类型
BizID string // 业务ID
Content string // 消息内容
Status MessageStatus // 待发送/已发送/发送失败
RetryCount int
NextRetryAt time.Time
CreatedAt time.Time
}
// OrderService 订单服务(柔性事务)
type OrderService struct {
orderRepo OrderRepository
messageSvc LocalMessageService
eventBus EventBus
}
// CreateOrder 创建订单(本地消息表)
func (s *OrderService) CreateOrder(ctx context.Context,
req *CreateOrderRequest) (*Order, error) {
// 开启数据库事务
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
// 1. 创建订单
order := &Order{
OrderID: generateOrderID(),
UserID: req.UserID,
Items: req.Items,
Status: OrderStatusPending,
CreatedAt: time.Now(),
}
if err := s.orderRepo.CreateWithTx(ctx, tx, order); err != nil {
return nil, err
}
// 2. 写入本地消息表(同一个事务)
messages := []LocalMessage{
{
MessageID: generateMessageID(),
BizType: "ORDER_CREATED",
BizID: fmt.Sprintf("%d", order.OrderID),
Content: s.serializeOrderEvent(order),
Status: MessageStatusPending,
CreatedAt: time.Now(),
},
}
for _, msg := range messages {
if err := s.messageSvc.CreateWithTx(ctx, tx, &msg); err != nil {
return nil, err
}
}
// 3. 提交事务
if err := tx.Commit(); err != nil {
return nil, err
}
// 4. 异步发送消息(事务外)
go s.publishPendingMessages(context.Background())
return order, nil
}
// publishPendingMessages 发布待发送消息
func (s *OrderService) publishPendingMessages(ctx context.Context) {
// 查询待发送消息
messages, err := s.messageSvc.FindPending(ctx, 100)
if err != nil {
return
}
for _, msg := range messages {
// 发送到消息队列
err := s.eventBus.Publish(msg.BizType, msg.Content)
if err == nil {
// 发送成功,更新状态
msg.Status = MessageStatusSent
s.messageSvc.Update(ctx, &msg)
} else {
// 发送失败,记录重试
msg.RetryCount++
msg.NextRetryAt = time.Now().Add(time.Duration(msg.RetryCount) * time.Minute)
s.messageSvc.Update(ctx, &msg)
}
}
}
// RetryFailedMessages 定时重试失败消息
func (s *OrderService) RetryFailedMessages() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
ctx := context.Background()
// 查询需要重试的消息
messages, _ := s.messageSvc.FindRetryable(ctx, time.Now())
for _, msg := range messages {
if msg.RetryCount >= 5 {
// 超过最大重试次数,转人工处理
msg.Status = MessageStatusFailed
s.messageSvc.Update(ctx, &msg)
s.createManualTask(ctx, &msg)
continue
}
// 重试发送
s.publishMessage(ctx, &msg)
}
}
}
// 下游服务消费消息
type InventoryConsumer struct {
inventorySvc InventoryService
}
func (c *InventoryConsumer) Consume(ctx context.Context, msg *OrderCreatedEvent) error {
// 幂等性检查
if c.isProcessed(ctx, msg.OrderID) {
log.Infof("订单%d已处理,跳过", msg.OrderID)
return nil
}
// 扣减库存
err := c.inventorySvc.Deduct(ctx, msg.Items)
if err != nil {
// 扣减失败,发送补偿消息
c.publishCompensation(ctx, msg.OrderID)
return err
}
// 标记已处理
c.markAsProcessed(ctx, msg.OrderID)
return nil
}
延伸思考:
- 本地消息表 vs Saga vs TCC如何选择?
- 消息发送失败如何保证最终一致性?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:1240。本章不依赖旧 Part Four 文件链接。
相关章节:订单系统。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-CASE-009:用户画像系统的设计
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-009 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、数据建模 |
| 场景标签 | 综合案例、白板推演、用户运营 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:1413 |
题干与约束
为了实现个性化推荐,需要构建用户画像(年龄、性别、消费能力、兴趣偏好)。如何设计用户画像系统?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 为了实现个性化推荐,需要构建用户画像(年龄、性别、消费能力、兴趣偏好)。如何设计用户画像系统?
答案:
推荐方案:实时+离线双层架构
package userprofile
import (
"context"
"time"
)
// UserProfile 用户画像
type UserProfile struct {
UserID int64
// 基础信息
Age int
Gender string
City string
// 消费画像
AvgOrderAmount decimal.Decimal // 客单价
TotalOrderCount int // 订单数
ConsumptionLevel string // 消费能力:高/中/低
// 兴趣画像
FavoriteCategories []int64 // 偏好类目
FavoriteBrands []string // 偏好品牌
PriceRange PriceRange // 价格区间
// 行为特征
ActiveTime []int // 活跃时段
ShoppingFrequency string // 购物频次
LastPurchaseTime time.Time
UpdatedAt time.Time
}
// UserProfileService 用户画像服务
type UserProfileService struct {
repo UserProfileRepository
rdb *redis.Client
kafkaWriter *kafka.Writer
}
// GetProfile 获取用户画像
func (s *UserProfileService) GetProfile(ctx context.Context,
userID int64) (*UserProfile, error) {
// 从缓存读取
cacheKey := fmt.Sprintf("user:profile:%d", userID)
profileJSON, err := s.rdb.Get(ctx, cacheKey).Result()
if err == nil {
profile := &UserProfile{}
json.Unmarshal([]byte(profileJSON), profile)
return profile, nil
}
// 从数据库读取
profile, err := s.repo.FindByUserID(ctx, userID)
if err != nil {
return nil, err
}
// 缓存
profileJSON, _ = json.Marshal(profile)
s.rdb.SetEX(ctx, cacheKey, profileJSON, 6*time.Hour)
return profile, nil
}
// UpdateProfileRealtime 实时更新画像
func (s *UserProfileService) UpdateProfileRealtime(ctx context.Context,
event *UserBehaviorEvent) error {
// 将行为事件写入Kafka
return s.kafkaWriter.WriteMessages(ctx, kafka.Message{
Key: []byte(fmt.Sprintf("%d", event.UserID)),
Value: s.serializeEvent(event),
})
}
// Flink实时计算(伪代码)
/*
用户行为流 → Flink → 实时画像
Flink Job:
1. 消费Kafka用户行为流
2. 计算实时指标(浏览、加购、下单)
3. 更新Redis画像缓存
4. 每小时写入HBase
*/
// 离线计算(每日凌晨执行)
func (s *UserProfileService) BatchUpdateProfiles() error {
ctx := context.Background()
yesterday := time.Now().AddDate(0, 0, -1)
// 1. 查询昨天的用户行为数据
behaviors, _ := s.behaviorRepo.FindByDate(ctx, yesterday)
// 2. 聚合计算
profileUpdates := s.aggregateBehaviors(behaviors)
// 3. 批量更新画像
for _, update := range profileUpdates {
s.repo.Update(ctx, update)
// 清除缓存
cacheKey := fmt.Sprintf("user:profile:%d", update.UserID)
s.rdb.Del(ctx, cacheKey)
}
return nil
}
// 消费能力分层
func (s *UserProfileService) calculateConsumptionLevel(
avgOrderAmount decimal.Decimal, totalOrderCount int) string {
if avgOrderAmount.GreaterThanOrEqual(decimal.NewFromInt(500)) &&
totalOrderCount >= 10 {
return "高"
} else if avgOrderAmount.GreaterThanOrEqual(decimal.NewFromInt(200)) &&
totalOrderCount >= 3 {
return "中"
} else {
return "低"
}
}
延伸思考:
- 如何保护用户隐私(GDPR合规)?
- 画像准确性如何评估?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:1413。本章不依赖旧 Part Four 文件链接。
相关章节:电商客户生命周期。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
Q-ECOM-CASE-010:大促后的系统复盘
元信息
| 项目 | 内容 |
|---|---|
| 题目编号 | Q-ECOM-CASE-010 |
| 题型 | 综合案例 / 白板题 |
| 范围 | 系统级:跨域方案设计与白板推演 |
| 难度 | 进阶 |
| 建议用时 | 60 分钟 |
| 能力标签 | 系统设计、跨域权衡、韧性工程 |
| 场景标签 | 综合案例、白板推演、大促峰值 |
| 能力域 | 综合案例与白板设计 |
| 来源 | books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:1557 |
题干与约束
618大促结束后,需要对系统表现进行复盘。如何设计复盘报告,总结经验和改进点?
候选人作答任务
基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。
推荐作答顺序
先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。
答案骨架
以下保留原题的完整分析、方案、实现细节、示例与延伸材料:
问题描述: 618大促结束后,需要对系统表现进行复盘。如何设计复盘报告,总结经验和改进点?
答案:
复盘维度:
-
业务指标:
- GMV:50亿
- 订单量:1000万
- 转化率:3.5%
- 客单价:500元
-
技术指标:
- 峰值QPS:50万
- 平均响应时间:200ms
- P99响应时间:800ms
- 可用性:99.95%
-
故障复盘:
- 23:00-23:15 订单服务QPS突增导致响应变慢
- 根因:数据库连接池不足
- 影响:15分钟内订单延迟,影响1000笔订单
- 改进:增加连接池大小,增加熔断降级
-
优化建议:
- 缓存命中率从90%提升到95%
- 数据库慢查询优化(TOP 10)
- 增加自动扩容策略
// PromotionReview 大促复盘
type PromotionReview struct {
PromotionName string
StartTime time.Time
EndTime time.Time
// 业务指标
GMV decimal.Decimal
OrderCount int64
ConversionRate float64
// 技术指标
PeakQPS int64
AvgLatency time.Duration
P99Latency time.Duration
Availability float64
// 故障列表
Incidents []*Incident
// 改进建议
Improvements []string
}
// GenerateReviewReport 生成复盘报告
func GenerateReviewReport(ctx context.Context,
promotionID int64) (*PromotionReview, error) {
// 1. 查询业务数据
orders := queryOrders(ctx, promotionID)
// 2. 查询监控数据
metrics := queryMetrics(ctx, promotionID)
// 3. 查询故障记录
incidents := queryIncidents(ctx, promotionID)
// 4. 生成报告
review := &PromotionReview{
PromotionName: "618大促",
GMV: calculateGMV(orders),
OrderCount: int64(len(orders)),
PeakQPS: metrics.PeakQPS,
Incidents: incidents,
}
return review, nil
}
延伸思考:
- 如何设计大促演练(压测、故障演练)?
- 如何量化技术优化的ROI?
评分锚点
- 能说明问题中的业务边界与权威数据,而非只罗列组件。
- 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
- 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。
递进追问
- 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
- 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
- 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?
常见失分点
- 混淆领域事实、派生读模型和流程状态。
- 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
- 把缓存、消息队列或搜索索引错误地当作交易权威来源。
关联正文
迁移来源:books/reliable-system-design/src/part03/10-ecommerce-case-studies-interview.md:1557。本章不依赖旧 Part Four 文件链接。
相关章节:生产韧性与稳定性保障。
复盘清单
- 我是否定义了数据所有权、状态机和跨域协作边界?
- 我是否解释了失败、重试、对账和补偿如何闭环?
- 我是否给出了与业务规模相匹配的性能与可靠性取舍?
附录 E 后端面试基础知识题单
MySQL
- 为什么不能让 MySQL 同时承担所有缓存、搜索、分析和异步消息职责?
- MySQL 的连接层、SQL 层、存储引擎层和文件系统层分别负责什么?
- 线上 MySQL 连接数、CPU、IO 和锁等待异常时,分别应该优先排查什么?
- InnoDB 和 MyISAM 在事务、锁、崩溃恢复和外键方面有什么区别?
- InnoDB Buffer Pool 缓存什么内容?命中率下降会带来什么影响?
- Change Buffer 解决什么问题?为什么它主要针对非唯一二级索引?
- Adaptive Hash Index 的作用是什么?它可能带来哪些副作用?
- Log Buffer、undo log、redo log 和 doublewrite buffer 分别解决什么问题?
- 为什么 InnoDB 需要同时使用 undo log 和 redo log?
- InnoDB 的 WAL 机制如何降低随机写并保证崩溃恢复?
- InnoDB 主键应该遵循哪些设计原则?
- 为什么主键过长会放大所有二级索引的存储成本?
- 金额、状态、时间、字符和大文本字段分别应该如何选择类型?
- 为什么金额通常用整数表示,而不是直接使用浮点数?
- 为什么业务表通常倾向于使用
NOT NULL和明确默认值? - 为什么字符集通常建议统一使用
utf8mb4? - 哪些字段适合做垂直拆分,垂直拆分会解决什么问题?
- 互联网业务为什么常用主键、唯一键和非空约束,而较少依赖跨服务外键?
- 数据库约束、应用状态机、幂等键和异步补偿分别应该承担什么一致性责任?
- 为什么 InnoDB 选择 B+ 树作为主要索引结构?
- B+ 树、B 树和红黑树在树高、磁盘 IO、范围查询和缓存友好性方面有什么差异?
- 如何估算一棵三层 B+ 树大致可以承载多少数据?
- B+ 树容量估算为什么不能只背一个固定数字?
- 什么是聚簇索引?InnoDB 为什么把主键索引作为聚簇索引?
- 什么是二级索引?二级索引叶子节点通常保存哪些内容?
- 什么是回表?哪些查询可以通过覆盖索引避免回表?
- 为什么主键越大,二级索引和 Buffer Pool 的压力通常越大?
- 为什么索引列顺序不能只按字段区分度机械决定?
- 为什么索引不是越多越好?索引过多会增加哪些成本?
- 哪些 SQL 写法容易导致索引失效或效果变差?
- 对索引列使用函数、隐式类型转换和前导模糊匹配分别有什么影响?
EXPLAIN中的type、key、rows、filtered和Extra分别应该关注什么?Using temporary和Using filesort一定代表严重问题吗?- 一条线上慢 SQL 应该按照什么顺序排查?
- 如何区分 SQL 执行慢和 SQL 等锁慢?
- 为什么排查慢 SQL 还需要关注实际参数分布和业务峰值?
- 延迟关联、游标分页和读模型分别适合什么场景?
- 为什么 C 端连续翻页通常更适合 keyset pagination?
- 事务的原子性、一致性、隔离性和持久性分别解决什么问题?
- 事务边界应该如何围绕业务事实和外部调用设计?
- MySQL 的读未提交、读已提交、可重复读和串行化分别有什么特点?
- 什么是快照读?什么是当前读?
- 普通
SELECT、SELECT ... FOR UPDATE、UPDATE和DELETE分别属于哪类读? - InnoDB 默认的可重复读如何处理快照读和当前读?
- MVCC 的隐藏列、undo log、版本链和 ReadView 分别起什么作用?
- RC 和 RR 隔离级别创建 ReadView 的时机有什么区别?
- 为什么 RR 下同一事务的多次普通查询可以保持一致?
- MVCC 如何让普通读和写操作并发执行?
- Record Lock、Gap Lock 和 Next-Key Lock 分别锁住什么范围?
- 为什么 InnoDB 的行锁实际上是加在索引记录上的?
- 为什么没有命中索引的
SELECT ... FOR UPDATE可能表现得像锁表? - 条件更新后为什么必须检查
affected_rows? - 为什么状态更新通常要带前置状态条件或版本号?
- MySQL 死锁通常由哪些因素引起?
- 发现死锁后应该如何定位阻塞链路并治理?
- 为什么事务中不应该长时间调用外部 RPC?
- redo log、undo log 和 binlog 的职责有什么区别?
- 为什么 redo log 和 binlog 需要通过两阶段提交保持一致?
- binlog 有哪些格式?它如何支持复制和数据订阅?
- binlog、CDC、Outbox 和业务消息之间有什么关系?
- MySQL 主从复制的大致流程是什么?
- 主从延迟通常由哪些原因造成?
- 为什么大事务、批量更新、DDL 和从库慢查询会放大复制延迟?
- 读写分离会带来哪些一致性问题?
- 写后粘主、强一致读回主和延迟摘除从库分别适合什么场景?
- MySQL 主库故障切换时需要关注哪些数据丢失和脑裂风险?
- 单主复制和多主复制分别有哪些优缺点?
- 单库单表在什么情况下应该优先通过 SQL、索引、缓存和归档治理?
- 垂直拆分和水平分片分别解决什么问题?
- 单表多少行需要分库分表?为什么不存在固定阈值?
- 分库分表后如何处理跨分片查询、排序和聚合?
- 为什么复杂后台检索通常应该迁移到 Elasticsearch 或专门读模型?
- 水平分片扩容时如何处理路由、迁移、双写、校验和灰度切流?
- MySQL 大表 DDL 为什么可能造成元数据锁、IO 放大和主从延迟?
ALGORITHM=INSTANT、INPLACE和COPY分别意味着什么?- 如何在影子库或预发环境验证大表 DDL 的风险?
- 什么是 expand-contract 发布?它如何降低字段变更风险?
- gh-ost 和 pt-online-schema-change 解决哪类线上 DDL 问题?
- 连接池大小应该根据哪些因素估算?
- 为什么盲目增大连接池可能把数据库推向雪崩?
- MySQL 监控应该覆盖哪些流量、延迟、连接、锁、复制和容量指标?
- MySQL 一条查询从连接建立到返回结果通常会经历哪些阶段?
- SQL 解析、语义检查、优化器和执行器分别承担什么职责?
- 预处理语句如何影响 SQL 解析、计划复用和 SQL 注入风险?
- 为什么数据库连接池不应该在每次请求中重复建立连接?
- MySQL 连接建立、认证和线程管理可能成为哪些线上瓶颈?
- 为什么连接数增加后吞吐不一定增加,反而可能导致上下文切换和锁竞争?
- MySQL 的 autocommit 模式会如何影响事务边界和锁释放时间?
- 长事务会如何影响锁等待、undo 保留、purge 和存储空间?
- 如何识别一个事务是业务执行慢、等待锁,还是客户端没有提交?
COMMIT和ROLLBACK分别会对数据页、日志和锁产生什么影响?- SAVEPOINT 适合解决什么局部回滚问题?
- 为什么事务中间执行外部网络调用会放大数据库资源占用?
- 如何确定一个业务操作应该放在同一个本地事务中?
- 事务隔离级别为什么不是越高越好?
- 脏读、不可重复读和幻读分别是什么?
- 可重复读为什么不等于所有查询结果都永远不变?
- 快照读和当前读混用时,为什么同一事务可能观察到不同语义?
SELECT ... FOR SHARE和SELECT ... FOR UPDATE的使用边界是什么?- 共享锁、排他锁和意向锁分别有什么作用?
- 意向锁为什么能帮助判断表级和行级锁的兼容性?
- 什么是元数据锁?为什么一条看似简单的 DDL 可能被长查询阻塞?
- AUTO_INCREMENT 锁模式会如何影响高并发插入?
- 为什么隔离级别、索引类型和查询条件会共同决定锁范围?
- 如何通过固定访问顺序和稳定排序降低多行更新的死锁概率?
- 如何通过
performance_schema或 InnoDB 锁表查看阻塞链? - 为什么一个索引覆盖了过滤条件,却仍可能产生大量回表?
- 什么是索引条件下推?它如何减少存储引擎返回给服务器层的行数?
- 什么是索引下推之外的执行计划优化,为什么不能只看
key字段? - 统计信息不准确时,优化器可能做出哪些错误选择?
- 直方图、采样统计和手动更新统计信息分别解决什么问题?
- 为什么数据分布变化后原本优秀的执行计划可能突然失效?
- SQL 使用
OR、IN、NOT IN和NULL时分别有哪些优化和语义陷阱? COUNT(*)、COUNT(1)和COUNT(column)的语义有什么区别?NULL为什么会影响比较、唯一约束、聚合和索引判断?DISTINCT、GROUP BY和窗口函数分别适合什么数据处理需求?ORDER BY在没有合适索引时为什么会产生额外排序成本?- 为什么分页排序通常需要加入唯一稳定的 tie-breaker,例如主键?
- 聚合查询、排序查询和 Join 查询分别应该如何设计索引?
- 为什么函数、表达式和隐式转换会阻止数据库直接使用索引顺序?
- 子查询、半连接和 Join 在不同数据量下可能有哪些性能差异?
- 为什么不能只根据 SQL 文本判断查询是否高效?
- 读写分离后,读请求如何根据一致性等级选择主库或从库?
- 如何实现用户写入后在一段时间内粘主,之后再恢复读从?
- 什么是半同步复制?它与异步复制相比牺牲和获得了什么?
- binlog 的 statement、row 和 mixed 格式分别有什么特点?
- 为什么生产环境通常更重视 row 格式的确定性?
- Relay Log 的作用是什么?从库 SQL 线程和 IO 线程分别做什么?
- 数据库高可用系统应该如何设计 fencing、租约和人工接管边界?
- 数据库备份中的全量、增量、binlog 和时间点恢复分别是什么?
- 如何验证备份文件不仅生成成功,而且真正可以恢复?
- 备份、归档、复制和 CDC 如何分别服务灾备、分析和业务同步?
- 在线库与归档库之间的查询边界应该如何定义?
- 为什么按时间分区或分表后仍然需要设计合适的分区键和索引?
- MySQL 分区表解决什么问题?它不能替代哪些分库分表能力?
- 分库分表后全局唯一 ID、唯一约束和自增主键分别有哪些难点?
- 分片路由失败、部分分片不可用和跨分片超时应该如何处理?
- 如何设计分库分表后的灰度迁移和双写校验?
- 如何判断一个查询应该继续留在 MySQL,还是迁移到搜索或分析系统?
- 为什么只保存当前状态而不保存状态变更历史会降低排障和审计能力?
INSERT ... ON DUPLICATE KEY UPDATE适合哪些幂等写入场景?- 条件更新、版本号和状态机在并发修改时如何配合?
- 如何设计批量更新,避免一次事务锁住过多记录?
- 批量删除历史数据时如何控制事务大小、日志量和复制压力?
- 为什么大批量
DELETE可能造成空间不释放和碎片问题? OPTIMIZE TABLE、重建表和在线变更分别有哪些风险?- Buffer Pool 命中率高是否一定代表查询性能好?
- CPU、IO、锁等待、临时表和连接数同时升高时应该如何建立排查假设?
- 为什么 P99 延迟比平均延迟更能暴露 MySQL 的长尾问题?
- 如何把慢 SQL 模板、参数分布和业务接口关联起来?
- 数据库账号、最小权限、审计和敏感字段脱敏分别如何降低风险?
- JSON 字段适合存什么类型的数据?为什么不能把核心查询都藏在 JSON 中?
- 枚举字段、字典表和状态机在可维护性上如何取舍?
- 多租户数据库设计中共享表、独立 Schema 和独立数据库分别适合什么规模?
- 为什么时间范围查询通常需要明确起止边界并避免对时间列套函数?
- 如何设计按创建时间和主键组合的稳定游标?
- 为什么分页游标不能只使用时间字段?
- 如何为报表、客服查询和核心交易链路做资源隔离?
- 线上修复数据时,为什么必须先保留原值、变更原因和操作人?
- 数据修复脚本如何实现小批量、可重入、可暂停和可校验?
- 数据库迁移脚本如何做到向前兼容和可回滚?
- 如何判断一次数据库变更是否需要应用代码、消息消费者和读模型同时升级?
Redis
- Redis 为什么快?
- Redis 的内存模型、单线程命令执行和 IO 多路复用分别带来什么收益?
- Redis 单线程模型为什么能减少锁竞争?它什么时候会成为瓶颈?
- 为什么大 Key、复杂 Lua 脚本、阻塞命令和全量扫描会拖慢整个实例?
- Redis String 的底层 SDS 解决了 C 字符串的哪些问题?
- String、Hash、List、Set 和 ZSet 分别适合哪些业务场景?
SET key value EX ttl适合什么类型的缓存?- Redis String 的
embstr、整数编码和普通动态字符串有什么区别? - Redis Hash 为什么适合局部更新的对象?什么时候应该使用 JSON String?
- Hash 的紧凑编码和哈希表编码分别适合什么规模的数据?
- 为什么大 Hash 上执行
HGETALL可能造成阻塞? - Redis List 的 quicklist 解决了什么空间和性能平衡问题?
- 如何用 List 实现简单 FIFO 队列和最近浏览列表?
- Redis List 为什么不一定适合需要可靠消费、回溯和消费组语义的场景?
- Set 适合解决哪些去重、标签和集合运算问题?
- ZSet 为什么适合排行榜、延时任务和按权重取 TopN?
- ZSet 的 score、跳表和哈希表分别承担什么作用?
- 如何根据访问模式选择 Redis 数据结构,而不是根据数据看起来像什么选择?
- RDB 和 AOF 的工作方式、恢复速度和数据丢失窗口有什么区别?
- 如果业务既关心恢复速度又不希望丢太多数据,RDB 和 AOF 应该如何组合?
- AOF 重写会带来哪些 CPU、磁盘和延迟风险?
- Redis 主从复制解决什么问题?异步复制会带来什么边界?
- Sentinel 和 Redis Cluster 分别解决什么问题?
- Redis Cluster 的 16384 个槽位如何影响路由和扩容?
- Redis Cluster 下多 Key 操作和 Lua 脚本为什么需要考虑同槽约束?
- 为什么 Cluster 解决容量扩展后仍然可能被热 Key 打爆?
- Cache Aside 模式的读路径和写路径是什么?
- 为什么很多业务选择先更新数据库再删除缓存?
- 直接同时写数据库和缓存为什么容易出现旧值覆盖新值?
- 缓存删除失败、主从延迟和并发回填分别会带来什么一致性问题?
- 延迟双删、重试、消息补偿和订阅变更分别适合解决什么问题?
- 缓存穿透、缓存击穿和缓存雪崩分别是什么?
- 如何使用参数校验、空值缓存和布隆过滤器治理缓存穿透?
- 热点 Key 过期导致缓存击穿时,互斥锁、singleflight、预热和异步刷新如何选择?
- 大量 Key 同时过期导致缓存雪崩时,TTL 抖动、多级缓存、限流和降级如何组合?
- Redis 淘汰策略中的 LRU、LFU、volatile 和 allkeys 分别有什么含义?
- 热点分布明显时,为什么 LFU 可能比 LRU 更稳定?
noeviction在没有容量保护时会带来什么风险?- Redis 容量规划应该关注命中率、内存碎片、淘汰策略和过期删除的哪些指标?
- Redis 分布式锁为什么通常使用
SET key value NX EX seconds? - 解锁时为什么必须校验唯一 value 并使用 Lua 保证比较删除原子性?
- 为什么只使用
SETNX而不设置过期时间会造成死锁? - 锁的过期时间设置过短或过长分别有什么风险?
- 为什么分布式锁不能替代数据库约束、幂等键和状态机?
- Redis Cluster 下实现分布式锁时需要注意哪些多 Key 和故障切换问题?
- 什么是 Redis 大 Key?它会影响命令延迟、复制、持久化和迁移的哪些环节?
- 什么是 Redis 热 Key?为什么增加分片数量不一定能解决它?
- 大 Key 应该如何通过拆分对象、拆分集合和限制全量命令治理?
- 热 Key 应该如何通过本地缓存、多级缓存、Key 复制和读扩散治理?
- 为什么生产环境应避免
KEYS *,而优先使用SCAN? - 如何限制大集合遍历的批次和执行时间,避免阻塞 Redis 主线程?
- Redis 线上监控应该覆盖命令延迟、慢查询、内存、碎片、大 Key、热 Key、淘汰、持久化和复制哪些指标?
- Redis 适合做缓存、计数、排行榜、延时任务和协调,但哪些场景不应该把一致性全部压在 Redis 上?
- Redis 的事件循环如何同时处理网络连接、命令执行和后台任务?
- Redis 命令执行为什么要求单次操作尽量短小?
- 一个复杂 Lua 脚本为什么可能阻塞所有客户端请求?
- Redis 的 O(1)、O(log n) 和 O(n) 命令复杂度如何影响线上风险?
- 如何审查一个 Redis 命令是否会在大数据量下造成长尾延迟?
- String、Hash、List、Set 和 ZSet 的时间复杂度分别应该关注哪些常用操作?
- Redis String 如何实现简单对象缓存、版本号和限流计数?
- Hash 局部更新为什么不代表它适合无限增大的对象?
- Hash 字段数量、字段长度和热点字段分布如何影响内存布局?
- List 的
BRPOP为什么不能自动提供消息确认和失败重试? - 使用 Redis List 实现队列时,消费者崩溃后消息可能处于什么状态?
- Set 的交集、并集和差集操作在集合规模很大时有什么风险?
- ZSet 延时队列如何扫描到期任务并避免重复领取?
- ZSet 排行榜更新和查询的热点会如何影响单分片负载?
- Redis RDB fork 过程为什么可能带来额外内存和写时复制压力?
- Redis AOF fsync 策略如何在数据安全和写延迟之间取舍?
- Redis 主从全量同步和增量同步分别在什么情况下发生?
- 主从复制断链重连时,网络带宽和磁盘 IO 可能受到什么影响?
- Sentinel 的故障检测、主观下线和客观下线分别是什么?
- Redis Cluster 扩容时槽位迁移会如何影响客户端请求和热点 Key?
- Cluster 多 Key 命令为什么需要使用 hash tag?
- Cache Aside 回源失败时,应该返回空结果、旧值、降级结果还是错误?
- 如何验证缓存一致性治理方案是否真的能覆盖删除失败和并发回填?
- Redis 作为临时状态存储时,如何防止过期、淘汰和故障恢复造成业务状态丢失?
Kafka
- Kafka 为什么更像分布式日志系统,而不只是消息转发器?
- Broker、Topic、Partition、Producer、Consumer 和 Consumer Group 分别是什么?
- Kafka 为什么采用 Partition 作为并行和扩展的基本单位?
- Kafka 为什么只能保证分区内有序,不能天然保证全局有序?
- Leader、Follower 和 ISR 分别承担什么职责?
- 为什么新 Leader 通常应该从 ISR 中选举?
unclean.leader.election开启后可能获得什么能力,又会牺牲什么?- Producer 写入 Kafka 的完整链路是什么?
acks、batch.size、linger.ms、重试和幂等配置分别影响什么?- Kafka Consumer 为什么采用主动拉取而不是 Broker 推送?
- Consumer Group 如何实现同一 Topic 的负载均衡?
- 为什么同一消费组内一个分区只能由一个消费者处理?
- 分区数少于消费者数时会发生什么?
- Rebalance 会由哪些事件触发?
- Rebalance 为什么会造成消费暂停、重复消费和延迟抖动?
session.timeout.ms和max.poll.interval.ms分别控制什么?- 如何设计消费逻辑,避免处理时间过长触发 Rebalance?
- Kafka 如何保证同一分区内的消息顺序?
- Producer 重试和多个飞行中请求为什么可能破坏消息顺序?
- At-most-once、At-least-once 和 Exactly-once 分别有什么特征?
- 如何组合
acks=all、副本数和min.insync.replicas降低消息丢失风险? - 为什么
acks=all在 ISR 退化到只剩 Leader 时仍然可能是不安全的? - Kafka Producer 幂等性解决的是哪一段链路的重复问题?
- 如何通过业务唯一键、状态机、去重表或 Redis 实现端到端幂等?
- Kafka 事务和业务幂等之间有什么区别?
- Kafka 为什么依赖顺序写日志获得高吞吐?
- Page Cache 在 Kafka 的读写路径中起什么作用?
- 为什么 Kafka JVM 堆不能无限增大?
- 零拷贝如何减少 Kafka 发送消息时的数据复制和上下文切换?
- 批量发送和压缩为什么可以降低单条消息的协议与系统调用开销?
- 增加 Partition 如何提升吞吐,又会引入哪些顺序和运维成本?
- Kafka 积压应该按照什么顺序排查?
- 为什么增加消费者实例不一定能消除积压?
- 增加 Topic 分区时需要评估哪些顺序、路由和迁移影响?
- Producer、Consumer 和下游数据库之间如何建立回压闭环?
- 哪些非核心消息可以在积压时降级、丢弃或跳过?
- Rebalance 频繁时应该检查哪些指标和消费者参数?
acks=1、副本配置和 ISR 异常为什么可能导致丢消息?- Page Cache 不足时 Kafka 的吞吐和延迟为什么会恶化?
- Kafka retention 配置不合理为什么会导致磁盘打满?
- Kafka 应该监控吞吐、请求延迟、副本健康、Lag、Rebalance、磁盘、网络和 Page Cache 哪些指标?
- 如何设计 Kafka 事件的重放、补偿和死信处理?
- Outbox、Kafka 和下游幂等如何组合,保证数据库事实能够可靠传播?
- 如果下游搜索服务不可用,Kafka 消费者应该如何限速、重试和避免积压无限增长?
- Kafka 的 offset 表示什么?它由 Broker、Consumer 还是业务处理器负责推进?
- 自动提交 offset 和手动提交 offset 分别有哪些重复与丢失风险?
- 为什么消费者应该在业务处理成功后再提交 offset?
- 消费者处理成功但提交 offset 失败时,如何通过幂等应对重复消费?
- 消费者提交 offset 成功但业务处理失败时,为什么可能造成消息丢失?
- Kafka 消息保留时间和消费完成时间之间是什么关系?
- 如何根据消息大小、峰值吞吐和保留周期估算磁盘容量?
- Kafka segment 文件、索引和日志清理分别承担什么职责?
- 时间保留、大小保留和压缩日志分别适合什么消息类型?
- 为什么 Kafka 的顺序只能在业务选择的分区范围内成立?
- 分区 key 选择不均会造成哪些热点和并行度问题?
- 增加分区后,旧消息顺序和新消息路由可能发生什么变化?
- Producer 批量大小过小或过大会分别带来什么问题?
linger.ms增大为什么可能提高吞吐却增加尾延迟?- 压缩算法如何在 CPU、网络带宽和 Broker 磁盘之间取舍?
- Producer 重试时如何避免重复写入、乱序和无限等待?
- Kafka 事务为什么不能替代下游数据库的业务幂等?
- Consumer 批量拉取和批量提交如何影响吞吐、延迟和重复范围?
- 消费线程中执行慢 SQL 或外部 RPC 为什么会造成 Rebalance?
- 如何将消费拉取、业务处理和 offset 提交拆成可控的线程模型?
- Kafka Lag 的绝对值、增长速度和分区分布分别说明什么?
- 如何区分生产速率增加、消费速率下降和单分区热点造成的积压?
- 为什么只扩 Consumer 实例不能突破分区并行度上限?
- 消费者扩容时如何避免 Rebalance 本身制造更大积压?
- 下游数据库限流时,Kafka 消费者如何调整批量、并发和提交节奏?
- 如何通过死信 Topic、重试 Topic 和人工重放处理永久失败消息?
Elasticsearch
- Elasticsearch 与 MySQL 在检索模型上有什么根本区别?
- 什么是倒排索引?它为什么适合全文检索?
- Term、Posting List、Document、Segment 和 Analyzer 分别是什么?
- Posting List 为什么通常还需要记录词频和位置?
- Elasticsearch 为什么适合搜索、过滤和聚合,却不适合作为高频事务主库?
- Index、Primary Shard、Replica Shard 和 Routing 分别是什么?
- 分片解决容量和并行问题,副本解决哪些问题?
- 为什么分片不是越多越好?
- 分片数过多会带来哪些元数据、小 Segment 和协调开销?
- 分片数过少会限制哪些扩展能力?
- 副本数增加后为什么会同时影响读吞吐、容灾、写延迟和网络成本?
- Elasticsearch 一次写入请求经过哪些协调、路由、内存缓冲和副本复制阶段?
- translog、内存 buffer、refresh 和 segment 分别起什么作用?
- 为什么 Elasticsearch 被称为 near real-time search?
- 写入成功为什么不代表文档可以立即被搜索到?
- Elasticsearch 的 Query Phase 和 Fetch Phase 分别做什么?
- 为什么没有 routing 的查询可能广播到多个分片?
- 跨分片查询为什么会增加协调、排序和网络成本?
- Mapping 如何影响搜索质量、聚合能力和资源成本?
text和keyword的区别是什么?- 为什么同一个业务字段经常需要同时保留全文字段和
.keyword子字段? - Analyzer 的字符过滤、Tokenizer 和 Token Filter 分别是什么?
- 中文分词策略如何在召回率和精确度之间取舍?
- 分词器选择不当会造成哪些召回、噪声和聚合问题?
- BM25 主要依据哪些因素计算相关性?
- 为什么业务排序经常需要和相关性评分拆开设计?
filter查询和依赖 score 的查询分别适合什么场景?from + size为什么会在深分页时消耗大量 CPU、内存和网络?scroll、search_after和from + size分别适合什么场景?- 为什么
search_after需要稳定且唯一的排序条件? - Elasticsearch 写入性能优化应该关注 bulk、refresh、副本和 Segment Merge 哪些因素?
- 查询性能优化为什么要缩小时间、租户和业务范围?
- 如何避免动态字段导致 mapping explosion?
- 为什么要控制单分片大小并减少过多小分片?
- Elasticsearch 写入延迟升高时,应该检查 bulk、refresh、translog、merge 和磁盘哪些指标?
- 查询抖动、慢查询和缓存命中率异常应该如何排查?
- 磁盘高水位通常由哪些数据生命周期和副本问题造成?
- ILM、冷热分层和索引保留周期分别解决什么问题?
- “脑裂”问题的本质是什么?现代集群如何降低这种风险?
- Elasticsearch 应该监控集群健康、未分配分片、bulk、refresh、merge、查询延迟、GC 和磁盘哪些指标?
- Elasticsearch 作为搜索读模型时,如何处理索引延迟、索引落后和索引重建?
- Lucene Segment 为什么设计成不可变结构?
- Segment Merge 会带来哪些磁盘、CPU、IO 和查询抖动?
- Segment 数量过多为什么会影响查询和资源占用?
- Elasticsearch refresh interval 如何影响写入可见性和查询性能?
- 强制 refresh 为什么会降低批量写入吞吐?
- translog 持久化和搜索可见性为什么是两个不同的时间点?
- Elasticsearch 写入确认和副本确认之间有什么关系?
- 副本未分配时,写入和读取分别可能受到什么影响?
- 主分片数量创建后为什么不容易直接调整?
- Reindex 为什么常用于 mapping、分词器和索引结构变更?
- Reindex 期间如何处理源库持续变更和新旧索引切换?
- Alias 如何帮助实现无停机索引重建和灰度切换?
- 动态 Mapping 为什么可能造成字段类型错误和字段爆炸?
- 如何为金额、状态、时间、ID 和全文字段选择 Elasticsearch 类型?
keyword字段的 doc_values 适合哪些排序和聚合场景?text字段为什么通常不能直接用于聚合和排序?- Analyzer 在索引时和查询时不一致会造成哪些召回问题?
- 同义词、停用词和自定义词典变更为什么可能需要重建索引?
- BM25 的词频、逆文档频率和字段长度因素如何影响排序?
- 多字段查询中的 boost、should 和 minimum_should_match 分别可能解决什么问题?
- 为什么过滤条件通常应该放进 filter context 而不是全部依赖 score?
- 高基数字段聚合和深分页分别有哪些资源风险?
- 写入拒绝、线程池队列积压和 bulk 超时分别说明什么?
- 删除文档为什么通常表现为删除标记而不是立即物理删除?
- 如何通过索引模板统一新索引的 mapping、settings、分片和副本配置?
- 按天、按月或按租户切索引时,分别会带来哪些查询和生命周期成本?
- 为什么索引生命周期设计应该同时考虑写入热度、查询热度和保留期限?
- 什么时候应该使用 keyword 精确过滤,什么时候应该使用 text 全文匹配?
match、term、range和bool查询分别适合什么条件?- 为什么对 keyword 字段使用错误的 match 查询可能造成意外召回?
- 高亮、聚合和排序分别会增加搜索请求的哪些资源成本?
- 为什么深分页通常应该转化为游标、导出任务或离线查询?
- 如何限制单次搜索的
from、size、聚合桶数和超时时间? - Elasticsearch 查询超时后,客户端应该如何处理部分结果和重试?
- 分片分配失败时,应该检查节点资源、分配规则、磁盘水位和索引设置哪些因素?
- 未分配副本分片为什么会降低容灾能力,即使读请求仍然成功?
- 如何通过限速和维护窗口控制分片迁移对线上流量的影响?
- Elasticsearch 集群为什么不应该把主节点、重写入节点和重查询节点完全混在一起?
- 主节点选举抖动和数据节点 GC 抖动分别应该如何排查?
- bulk 请求过大、过小或并发过高分别会造成什么问题?
- 写入端如何根据拒绝数、延迟和失败率动态调整 bulk 并发?
- 如何用一组可验证指标判断 Elasticsearch 索引设计是否满足搜索质量和性能目标?
- 搜索索引和主数据库之间出现文档缺失时,如何通过对账发现差异?
- 如何设计按时间窗口、租户或业务状态执行的增量重建任务?
- 为什么索引版本号和别名切换可以降低 mapping 变更风险?
- 查询结果为空时,如何区分没有命中、分词错误、过滤错误和索引延迟?
- 查询结果过多时,如何通过召回、过滤、分页和排序控制响应成本?
- 为什么搜索排序稳定性需要包含唯一字段作为次级排序?
- 如何在搜索质量、实时性、集群成本和可运维性之间做取舍?
Docker
- 容器和虚拟机在启动速度、资源开销和隔离边界上有什么区别?
- 容器化为什么不能自动解决状态管理、跨机网络、安全和故障治理?
- Docker Image、Container 和 Runtime 分别是什么?
- Namespace 和 cgroup 分别解决什么问题?
- PID、Network、Mount 和 UTS Namespace 分别隔离了什么视图?
- 为什么容器在宿主机上仍然是普通进程?
- 为什么镜像应该尽量精简?
- 基础镜像、依赖版本和镜像签名会影响哪些安全与可复现问题?
- 为什么容器日志通常应该输出到标准输出和标准错误?
- 容器中的临时文件和持久化数据应该如何区分?
- Volume、Bind Mount 和临时文件系统分别适合什么场景?
- 容器 CPU 和 Memory 限额为什么不能依赖默认配置?
- 容器资源限制设置过低或过高分别会带来什么问题?
- 如何判断容器化部署是否真正提升了交付效率,而不是增加了运维复杂度?
- 容器网络中端口映射、服务发现和跨主机通信分别由什么层负责?
- 容器进程收到停止信号后,为什么需要实现优雅退出?
- 如何排查一个容器“能启动但无法提供服务”的问题?
- Dockerfile 中基础镜像、工作目录、复制文件、安装依赖和启动命令分别应该如何组织?
- 为什么 Dockerfile 指令顺序会影响构建缓存命中率?
- 如何通过先复制依赖清单、后复制业务源码减少镜像重复构建?
- 为什么不应该把编译工具链和源码都保留在最终运行镜像中?
- 多阶段构建如何减小最终镜像并降低攻击面?
- 镜像层中的删除为什么不一定真正减少最终镜像大小?
- 容器镜像标签漂移为什么会破坏发布可追溯性?
- 镜像摘要和不可变版本号如何帮助实现可复现部署?
- 容器内 PID 1 为什么需要正确处理信号和子进程回收?
- 容器中使用 tini 或等价 init 进程可以解决什么问题?
- 容器停止超时过短会如何影响 HTTP 连接、消息消费和数据库事务?
- 容器健康检查与 Kubernetes 探针之间是什么关系?
- 为什么容器重启策略不能替代应用故障根因治理?
- 容器文件系统的可写层为什么不适合作为长期数据存储?
- Volume 的生命周期为什么应该独立于容器生命周期?
- 端口发布和容器内部监听地址之间有什么关系?
- 如何排查容器 DNS、网络命名空间和宿主机防火墙造成的连接失败?
- 内存 limit 触发后为什么可能直接由内核 OOM Killer 终止进程?
- CPU limit 过低为什么可能造成应用延迟升高但进程没有退出?
- 容器的 requests 和 limits 与 Kubernetes 的 requests 和 limits 有什么联系?
- 容器日志输出到标准输出后,采集系统还需要解决哪些轮转和背压问题?
- 容器镜像中的操作系统包如何进行漏洞扫描和版本治理?
- 为什么使用 root 用户运行容器会扩大安全风险?
- Linux capabilities 和 seccomp 可以限制容器的哪些内核能力?
- 为什么 privileged 容器会削弱默认隔离边界?
- 容器逃逸风险通常与哪些内核、运行时和权限配置有关?
- 容器内进程的文件描述符限制如何影响高并发网络服务?
- 如何排查容器中连接数、文件描述符和进程数达到上限的问题?
- 构建过程如何保证第三方依赖版本和软件包来源可追溯?
- 容器运行时、镜像仓库和 Kubernetes 节点之间如何协作?
- 一个容器启动后立即退出时,应该优先检查命令、环境变量、权限还是依赖服务?
- 一个容器内存持续上涨时,如何区分泄漏、缓存增长、堆外内存和页缓存?
- 如何让容器中的任务支持重复执行、失败重试和安全退出?
Kubernetes
- Kubernetes 为什么采用声明式对象和控制器,而不是只提供一个启动容器的命令?
- Pod 为什么是 Kubernetes 的最小调度和运行单元?
- Deployment、StatefulSet、DaemonSet、Job 和 CronJob 分别适合什么场景?
- 为什么数据库或有稳定身份的组件通常不直接使用 Deployment?
- Ingress 与 Service 的职责有什么区别?
- ConfigMap 和 Secret 分别适合保存什么内容?
- Secret 为什么不等于完成了密钥的全生命周期安全治理?
- API Server、Scheduler、Controller Manager 和 etcd 分别负责什么?
- kubelet 和 kube-proxy 分别负责什么?
- Kubernetes 控制器如何持续把实际状态拉回期望状态?
- Scheduler 如何综合节点资源、标签、亲和性、污点和拓扑约束选择节点?
- Pod 调度失败时,为什么集群还有空闲 CPU 也不一定能成功调度?
requests和limits分别代表什么?requests设置过低会导致什么超卖和争抢问题?limits设置过低为什么可能导致 CPU 限流或 OOMKilled?- 为什么工作负载既不设置 requests 也不设置 limits 会增加排障难度?
- Rolling Update 如何降低发布过程中的整体风险?
- Readiness Probe 和 Liveness Probe 分别解决什么问题?
- 为什么启动慢的应用不应该简单交给 Liveness Probe 处理?
preStop和优雅终止如何减少连接被硬切断?- Kubernetes 如何实现服务发现?
- Pod 网络、Service 网络和 Ingress 网络分别位于哪一层?
- 为什么应用不应该依赖 Pod IP 永久稳定?
- 如果域名能访问 Ingress 但后端返回 502,应该按什么层次排查?
- 如何设计 Kubernetes 中配置变更的灰度和回滚路径?
- 为什么发布系统必须关注连接摘流、依赖超时和跨服务版本兼容?
- Pod 长时间处于 Pending 时应该检查哪些资源、PVC、标签、污点和调度事件?
ImagePullBackOff通常由哪些镜像、鉴权和网络问题导致?CrashLoopBackOff应该如何结合容器日志、启动参数和依赖状态排查?- Kubernetes 控制面的 API Server 延迟、etcd 健康和调度失败率分别说明什么?
- 节点 CPU、内存、磁盘、inode 和网络丢包异常会如何影响 Pod?
- 如何通过多副本、跨节点分布和 PodDisruptionBudget 降低运维操作抖动?
- 为什么需要清理无效镜像和历史 ReplicaSet?
- Kubernetes 工作负载应该统一哪些探针、资源、日志和标签规范?
- 如何设计一个可以灰度、观测、暂停和回滚的 Kubernetes 发布流程?
- Kubernetes 具备高可用控制面是否意味着业务应用天然高可用?
- Kubernetes 的声明式 API 为什么有利于审计、回滚和自动收敛?
spec和status在 Kubernetes 对象中分别表达什么?- Controller 为什么需要持续监听资源变化并重复执行收敛逻辑?
- Kubernetes 控制器如何处理事件丢失、重复通知和最终一致性?
- Pod 中的多个容器为什么共享网络命名空间和部分存储卷?
- Sidecar 容器适合承担哪些代理、采集和辅助职责?
- 为什么不应该把所有业务进程都塞进同一个 Pod?
- Pod 重启、Pod 重建和容器重启有什么区别?
- Deployment 的 ReplicaSet 为什么可以帮助实现滚动发布和回滚?
- StatefulSet 的稳定网络身份、稳定存储和有序发布分别解决什么问题?
- DaemonSet 为什么适合节点级日志、监控和网络组件?
- Job 和 CronJob 如何处理一次性任务、失败重试和周期调度?
- Service 的 ClusterIP、NodePort 和 LoadBalancer 分别适合什么访问场景?
- Service selector 配置错误会造成什么现象?
- Endpoint 和 EndpointSlice 在服务发现中承担什么作用?
- Headless Service 适合哪些需要直接发现 Pod 的场景?
- NetworkPolicy 可以限制哪些 Pod 间通信?
- 为什么“Pod IP 可达”不等于“应用接口一定可用”?
- NodeSelector、Node Affinity 和 Pod Affinity 分别适合什么调度约束?
- Pod Anti-Affinity 在高可用服务中有什么价值?
- Burstable、Guaranteed 和 BestEffort 工作负载在资源争抢时有什么差异?
emptyDir、PersistentVolume、PersistentVolumeClaim 和 StorageClass 分别是什么?- PVC 长时间 Pending 时应该检查哪些存储类、访问模式和供给问题?
- Readiness、Liveness 和 Startup Probe 的职责有什么差异?
- 为什么 Startup Probe 可以避免慢启动应用被 Liveness 过早重启?
- 如何在发布过程中保证至少有足够副本继续服务?
- 灰度发布、金丝雀发布、蓝绿发布和滚动发布分别适合什么场景?
- 如何根据指标、日志和业务成功率自动暂停或回滚发布?
- PodDisruptionBudget 如何限制主动驱逐对服务可用性的影响?
- 节点 drain 时,DaemonSet、StatefulSet 和普通 Deployment 的行为有什么区别?
- 为什么节点磁盘压力会导致 Pod 驱逐和新 Pod 调度失败?
- 如何监控节点 inode、容器日志和镜像层造成的磁盘消耗?
- API Server 延迟升高时,如何区分 etcd、认证、Webhook 和控制器压力?
- etcd 数据增长、磁盘延迟和快照失败会如何影响控制面?
- Scheduler 失败率升高时,如何从事件、资源和约束链路定位原因?
- CrashLoopBackOff 的重启退避为什么不能被简单理解为应用已经恢复?
- ImagePullBackOff 时如何区分镜像不存在、鉴权失败、DNS 失败和仓库限流?
- Service 访问偶发失败时,如何检查 Endpoint 变化、连接跟踪和后端就绪状态?
- Pod 被 OOMKilled 后,为什么还要检查实际峰值、sidecar 和节点压力?
- Kubernetes 日志采集链路中的容器日志轮转、节点磁盘和下游积压如何治理?
- Kubernetes 监控应该覆盖控制面、节点、Pod、容器、网络和业务指标哪些层次?
- 多租户 Kubernetes 集群如何通过 Namespace、RBAC、ResourceQuota 和 NetworkPolicy 隔离?
- 为什么 RBAC 最小权限原则需要同时应用于人、服务账号和控制器?
- 如何设计 Kubernetes 集群的备份、灾难恢复和跨区域故障切换?
操作系统
- 进程、线程和协程分别是什么?
- 进程为什么是资源分配的基本单位,线程为什么是常见的调度单位?
- 多进程、多线程和协程在隔离性、调度成本和通信方式上如何取舍?
- 为什么线程共享内存既是性能优势也是并发风险?
- 协程为什么更适合高并发 I/O 场景?它为什么不是免费的线程替代品?
- Linux 进程的运行态、可中断睡眠、不可中断睡眠、僵尸态和停止态分别说明什么?
- 为什么僵尸进程会出现?父进程应该如何回收子进程状态?
- 孤儿进程和僵尸进程有什么区别?
- 线程数暴涨为什么会导致上下文切换、栈内存和尾延迟上升?
- 操作系统调度器如何在优先级、公平性和任务类型之间做平衡?
fork如何通过写时复制降低创建子进程的初始开销?fork后哪些操作会真正触发页面复制?- 为什么
fork后立刻exec是一种常见模式? - 虚拟内存解决了哪些地址空间隔离和资源管理问题?
- 页表和 TLB 分别是什么?TLB 缓存为什么能提高地址转换效率?
- 虚拟地址空间通常包含代码段、全局数据区、堆、栈和映射区域中的哪些部分?
- 缺页异常是如何产生和处理的?
- 内存压力过高时为什么会发生抖动?
- 如何使用
free、vmstat、top和/proc/<pid>/status判断内存问题? - RSS、VIRT 和页错误分别能帮助判断什么?
- 栈和堆在生命周期、分配速度、空间限制和风险上有什么区别?
- 为什么 GC 语言也需要理解对象逃逸和堆分配?
- FIFO、LRU 和 LFU 分别适合什么缓存访问模式?
- 执行
ls时会经过哪些用户态、系统调用、页缓存和文件系统步骤? - 阻塞 I/O、非阻塞 I/O 和 I/O 多路复用分别有什么特点?
select、poll和epoll的核心差异是什么?epoll为什么更适合监听大量网络连接?- 文件 I/O、网络 I/O、WAL 刷盘和日志写入中的性能瓶颈有什么共同点?
- 互斥锁、读写锁、条件变量、信号量和原子操作分别适合什么场景?
- 线程间同步和进程间同步有什么区别?
- 管道、共享内存、消息队列和 Unix 域套接字分别适合什么 IPC 场景?
- 通信和同步为什么不是同一个概念?
- 死锁产生的互斥、占有且等待、不可抢占和循环等待四个必要条件是什么?
- 如何通过统一加锁顺序、缩小临界区和超时回滚降低死锁风险?
- 为什么锁内调用外部服务容易造成线程长期占用和级联阻塞?
- 如何从线程栈、锁等待、
pstack、gdb、jstack或 Goroutine dump 定位死锁? - 为什么
sleep不能替代锁、条件变量或显式事件通知? - 忙等在什么极端低延迟场景可能有价值?它又会带来什么 CPU 风险?
- 为什么高并发网关常采用非阻塞 I/O 和事件循环?
- 为什么任务计算服务可能更适合多进程、线程池或受控执行队列?
- CPU 打满、内存上涨、磁盘繁忙和 load 高但吞吐低分别应该如何解释?
- 如何区分正常 Keep-Alive、半连接堆积、TIME_WAIT 和 CLOSE_WAIT 异常?
- 数据库刷盘、消息落盘和日志刷盘为什么都离不开页缓存与文件系统语义?
- 进程的独立地址空间、文件描述符表和资源上下文分别意味着什么?
- 线程共享代码、堆、文件和全局状态会带来哪些通信便利与数据竞争风险?
- 协程由用户态调度时,哪些工作仍然需要操作系统线程完成?
- 多进程模型为什么通常隔离性更好,但 IPC 和上下文切换成本更高?
- 多线程模型为什么适合共享内存服务,又为什么容易出现局部故障扩散?
- 如何根据隔离性、调度成本和通信方式选择并发模型?
- 运行态和可运行队列分别表示什么?
- 进程为什么会进入可中断睡眠或不可中断睡眠?
- 为什么处于不可中断睡眠的进程可能对普通
kill不敏感? - 父进程没有调用
wait时,子进程退出后会留下什么资源? - 大量僵尸进程如何耗尽 PID 或进程表资源?
- 孤儿进程被重新收养后,为什么通常不会像僵尸进程一样长期占用退出状态?
- 时间片、优先级和公平调度如何共同影响尾延迟?
- 线程栈大小如何影响单进程可创建的线程数量?
- 上下文切换为什么可能同时带来 CPU、缓存和调度器开销?
fork复制页表时为什么仍然可能消耗大量内存和时间?- 父进程和子进程共享文件描述符时,文件偏移和关闭行为会怎样?
exec替换进程映像时,哪些进程属性会保留,哪些会变化?- 信号、管道和等待子进程如何协同实现进程生命周期管理?
- 虚拟地址空间如何隔离不同进程并提供更灵活的内存映射?
- TLB 未命中和页错误有什么区别?
- Minor Page Fault 和需要磁盘读取的 Major Page Fault 在性能上有什么差异?
- 交换分区或 swap 频繁使用为什么会导致服务尾延迟暴涨?
- 内存抖动时为什么可能出现 CPU 不低但业务吞吐下降?
- RSS、VIRT、Shared 和匿名页分别可以帮助定位什么?
- 页缓存增长为什么不一定等于业务内存泄漏?
- 堆碎片、对象缓存和内存映射分别可能造成什么内存现象?
- 栈溢出、堆溢出和内存泄漏在现象和排查方式上有什么区别?
- 文件描述符为什么是进程资源限制中的关键指标?
ulimit -n过低会如何影响高并发网络服务?- 目录项缓存、inode 缓存和页缓存分别提高了哪些文件系统操作的速度?
fsync、fdatasync和异步写入在持久性与延迟上有什么取舍?- 为什么同步刷盘会提高可靠性,却可能造成长尾延迟?
- 顺序写和随机写对磁盘、SSD 和文件系统的压力有什么不同?
select每次调用为什么需要重新传递和遍历文件描述符集合?epoll的水平触发和边缘触发分别有什么语义?- 边缘触发模式为什么要求应用持续读取直到
EAGAIN? - Reactor、Proactor、线程池和协程模型分别如何组织 I/O 事件与业务执行?
- 原子操作适合哪些短小状态更新?它不能解决哪些复合逻辑问题?
- 读写锁在读多写少场景中的优势和饥饿风险是什么?
- 条件变量为什么通常需要放在循环条件判断中使用?
- 如何通过资源排序、锁超时和 try-lock 打破死锁条件?
- 为什么缩短临界区比单纯更换一种锁更重要?
- 如何从 load 高但 CPU 低的现象推断不可中断睡眠或 IO 阻塞?
- 为什么 CPU 高时要区分用户态计算、内核态系统调用、软中断和锁竞争?
- 网络服务的线程数、连接数、文件描述符和事件循环之间有什么容量关系?
- 如何把进程、内存、文件系统、I/O 和同步知识用于解释一次线上高延迟事故?
计算机网络
- OSI 七层模型和 TCP/IP 分层模型在工程上有什么意义?
- “输入一个 URL 后发生了什么”应该按哪些 DNS、TCP、TLS、HTTP 和服务端步骤回答?
- TCP 为什么通常需要三次握手而不是两次?
- TCP 四次挥手为什么通常不是三次?
- TIME_WAIT 是什么?为什么主动关闭连接的一方可能进入 TIME_WAIT?
- CLOSE_WAIT 是什么?大量 CLOSE_WAIT 通常说明哪类应用问题?
- 大量 TIME_WAIT 应该如何区分短连接风暴、正常流量和攻击流量?
- TCP 粘包的本质是什么?
- HTTP 方法的语义如何影响幂等性、缓存性和重试安全性?
- HTTP/1.1、HTTP/2 和 HTTP/3 在连接复用、多路复用、头部压缩和队头阻塞上有什么差异?
- HTTPS 在 HTTP 之下增加了哪些身份认证、加密和完整性能力?
- TLS 握手、证书管理和加解密会带来哪些性能与运维成本?
- DNS 如何参与就近访问、故障切换和流量调度?
ping能证明什么,不能证明什么?- 连接池复用差、TLS 握手过多和 TCP 重传如何共同放大接口延迟?
- 网络超时、重试、幂等和降级应该如何一起设计?
- 网络分层为什么能降低协议实现和故障定位的复杂度?
- SYN Flood 为什么会占用半连接队列?
- 为什么主动关闭方通常进入 TIME_WAIT,而被动关闭方可能进入 CLOSE_WAIT?
- Keep-Alive、连接池和 HTTP/2 多路复用如何降低建连成本?
- Nagle 算法和 TCP_NODELAY 会如何影响小包延迟与网络效率?
- 网络服务为什么需要同时配置连接超时、读超时、写超时和空闲超时?
- 客户端重试为什么必须结合幂等键、退避和抖动?
- HTTP GET、PUT 和 DELETE 的幂等语义如何影响自动重试?
- HTTP POST 如何通过幂等键支持安全重试?
- 代理或网关为什么可能修改 Host、X-Forwarded-For 和协议头?
- HTTP/2 多路复用为什么仍可能受到丢包影响?
- QUIC 为什么能减少 TCP 连接级队头阻塞?
- DNS 递归查询、权威查询和本地缓存之间如何协作?
- CDN 缓存穿透和回源风暴应该如何治理?
- 负载均衡如何处理健康检查、连接摘除和长连接?
- 反向代理重试为什么可能让下游流量成倍增加?
- API Gateway 通常承担哪些鉴权、限流、路由和观测职责?
- Service Mesh 的 sidecar 会引入哪些网络跳数和排障成本?
- “本机正常、跨机器失败”通常应该优先排查哪些网络层差异?
- “连接建立成功但请求无响应”可能由读取、线程、锁和下游依赖哪些问题造成?
- “请求偶发超时”如何区分 DNS、连接建立、TLS、服务端处理和响应传输阶段?
ss中的 Recv-Q 和 Send-Q 可以帮助判断什么队列压力?tcpdump如何通过 SYN、ACK、RST、重传和窗口变化辅助定位故障?curl -v能帮助观察哪些 DNS、连接、TLS 和 HTTP 细节?nc能否证明完整应用协议正常?为什么只能作为粗粒度验证工具?- 连接数很多但 QPS 不高时,可能是 Keep-Alive、慢请求还是连接泄漏?
- CLOSE_WAIT 很多时如何定位未关闭的连接、响应体或文件描述符?
- TIME_WAIT 很多时如何通过连接方向、端口范围和请求模式判断问题?
- 网络丢包、乱序和重传如何影响 TCP 延迟与吞吐?
- 子网、路由表和默认网关如何共同决定数据包路径?
- ARP 缓存错误可能造成什么局部网络故障?
- MTU 不匹配为什么可能导致小请求正常、大请求超时?
- TCP 半连接队列、全连接队列和应用 accept 速度之间有什么关系?
- 监听队列溢出会如何表现为连接超时或连接拒绝?
- 连接复用为什么通常比短连接更节省 CPU 和网络握手开销?
- 连接池最大连接数、空闲连接数和等待超时如何共同影响吞吐?
- 请求已经到达服务端但客户端超时后,服务端如何避免重复副作用?
- RPC 超时预算如何沿调用链传播?
- 级联重试会如何放大 CPU、连接、线程和队列压力?
- DNS 故障切换为什么可能受 TTL 和本地缓存影响而不立即生效?
- 代理层做 TLS 终止后,如何保留原始客户端地址和协议语义?
- 四层负载均衡为什么看不到完整 HTTP 路径?
- 七层负载均衡为什么能够按域名、路径和 Header 路由?
- 长连接和 WebSocket 在负载均衡摘流时需要额外处理什么?
- 抓包时如何判断请求没有发出、发出后被丢弃还是服务端没有响应?
- 网络设备丢包、服务端丢包和应用主动关闭如何在抓包中区分?
- Send-Q 持续增长时,应该检查对端读取、网络拥塞还是应用生产速度?
- Recv-Q 持续增长时,应该检查应用 accept/read 是否及时?
- 网络连接泄漏如何通过连接状态、线程栈和代码路径定位?
- 客户端连接池中的空闲连接为什么可能因为代理或防火墙超时而失效?
- 如何设计连接保活和空闲连接回收,避免使用已经失效的连接?
- DNS 解析结果缓存在哪里?应用、操作系统、递归 DNS 和 CDN 的缓存如何叠加?
- 反向代理如何实现限流、鉴权、灰度、请求体限制和访问日志?
- 代理层的超时、重试和连接池配置为什么必须与应用层协调?
- 负载均衡健康检查为什么可能把一个“能连通但不可用”的实例误判为健康?
- 如何设计包含依赖检查、业务探针和摘流延迟的健康检查?
- HTTP 压缩如何影响 CPU、带宽和响应延迟?
- 网络服务如何处理客户端取消请求和下游已经开始执行的情况?
- 取消传播为什么要同时终止计算、连接和后台任务?
- 多级代理链路中如何传递真实客户端地址、请求 ID 和追踪信息?
- 为什么日志和指标需要同时记录连接阶段耗时和业务处理耗时?
- 如何用请求级 trace 将 DNS、连接、TLS、代理和服务端处理耗时关联起来?
- 网络故障演练中为什么要验证恢复时间、重试放大和连接泄漏?
- 如何为网络协议升级设计客户端兼容、服务端灰度和快速回滚?
Bash
- Shell 在系统设计和线上排障中最适合承担什么角色?
- 为什么说 Shell 的核心价值是组合已有工具,而不是语法复杂度?
- 管道如何把多个命令组合成数据处理流水线?
- Shell 命令退出码如何影响自动化脚本的控制流?
sed如何用于流式替换、清洗和删除无关行?awk如何按字段切分日志并进行条件过滤、统计和计算?sort、uniq -c、head和tail如何组合统计日志热点?- 为什么管道处理大日志时通常比一次性加载到内存更稳妥?
tail -f、grep和sort | uniq -c如何组合观察实时错误?ss和netstat如何查看端口监听、连接状态和收发队列?lsof -i:port如何定位端口被哪个进程占用?ping和traceroute分别适合验证什么网络问题?ssh、scp和nc在远程排查和网络验证中分别有什么用途?- 为什么
dd和nc不能替代专业压测和基准工具? - Shell 环境变量的加载路径和继承关系为什么会影响服务运行?
- 一个可靠的 Shell 小工具应该如何处理参数、错误、输出和副作用?
- 什么情况下 Shell 脚本的复杂度已经超过了它适合处理的范围?
- Shell 中单引号、双引号和命令替换对变量展开有什么影响?
- Shell 函数、局部变量和返回码应该如何设计?
- 环境变量、导出变量和脚本局部变量的继承关系是什么?
- 定时任务、非交互 Shell 和登录 Shell 的环境差异可能来自哪里?
PATH、HOME、PWD和SHELL等环境变量如何影响脚本行为?- 相对路径、脚本所在目录和临时目录应该如何安全地处理?
- Shell 脚本如何接收必填参数、可选参数和默认值?
getopts适合解决什么命令行参数解析问题?trap可以用来实现哪些资源清理和故障记录动作?find -exec、xargs和 Shell 循环在批量文件处理上有什么差异?xargs处理空格、换行和特殊字符时有哪些边界?- 读取大文件时,为什么应优先使用流式处理而不是命令替换?
- 用
awk统计时,字段分隔符、引号和缺失字段会造成什么问题? uniq为什么通常需要先排序才能正确统计相邻重复项?- Shell 脚本调用远程主机时,如何处理超时、认证、输出和部分失败?
- 如何记录脚本版本、执行参数、操作人和结果,保证运维动作可审计?
- 如何判断一个 Shell 工具应该继续维护,还是迁移为 Python、Go 或专用平台任务?
- Shell 脚本如何避免通配符为空时误删当前目录或错误目标?
- 批量修改文件前为什么需要先输出待修改清单并支持 dry-run?
- 如何给 Shell 脚本增加锁,避免多个定时任务实例同时执行?
- 脚本执行外部命令时如何传递退出状态和错误上下文?
- 如何让脚本在网络抖动时区分可重试错误和不可重试错误?
- 如何对 SSH 批量操作设置并发上限和单机超时?
- 处理日志中的 JSON、CSV 和空格分隔字段时,为什么不能只依赖简单的
awk分隔符? - 如何在 Shell 中处理包含换行、引号和 Unicode 的文本?
- Shell 命令替换为什么可能丢失末尾换行并改变空值语义?
- 为什么
for x in $(command)不适合可靠遍历任意文件名? - 如何使用标准输入和
read流式处理大规模文本? - 如何让脚本的日志包含时间、主机、进程、步骤和返回状态?
- 运行系统变更脚本时,为什么需要检查当前状态而不是盲目重复执行?
- 如何设计一次数据库、缓存或网络配置变更脚本的前置检查和回滚动作?
- 为什么 Shell 自动化需要把不可逆操作与只读检查分开?
- 如何评估一条复杂管道的时间复杂度、内存使用和错误传播?
Python
- 为什么 Python 常用于自动化、数据处理、原型验证和跨语言编排?
- 为什么 Python 通常不被默认用于最苛刻的高性能核心路径?
- Python 的
list、dict、set和tuple分别适合什么场景? pathlib、os和shutil如何支持文件与目录自动化?json、csv和pickle分别适合什么数据编解码场景?subprocess如何与 Shell 或系统命令集成?logging和argparse如何让临时脚本演化成可复用工具?- 为什么即使是一次性脚本,也需要明确输入、输出、日志和异常处理?
- NumPy、Pandas、Requests、Click 和 Typer 分别解决哪类工程问题?
- Python 自动化相比完全依赖 Shell 的优势是什么?
- 什么情况下一个 Shell 任务应该切换为 Python 实现?
- GIL 到底限制了什么?
- 为什么 I/O 密集型任务仍然可以从 Python 多线程中受益?
- Python 多线程、多进程和异步模型分别适合什么类型的工作负载?
cProfile、pstats和火焰图分别能帮助定位什么问题?- 算法剪枝、矩阵运算、任务拆分、原生扩展和 GPU 下沉分别适合什么优化阶段?
- 为什么并发可能只是放大无意义计算,而不是解决性能问题?
- Python 和 C/C++ 混合编程常见于哪些场景?
- SWIG、ctypes 和 CFFI 等跨语言边界会带来哪些部署与依赖管理问题?
- Python 依赖打包和运行环境不一致会造成什么线上风险?
- Python 脚本从临时工具演化为长期工具时,应该补哪些工程能力?
- 为什么过早并发、单文件泥团和缺少依赖锁定会增加维护成本?
- 如何判断一个 Python 程序慢在算法、数据布局、解释器开销还是外部依赖?
- Python 自动化工具应该如何处理外部命令失败、超时和部分成功?
- 为什么“先理解算法流程,再谈并行”是 Python 优化的稳妥顺序?
- Python 的可变对象和不可变对象分别有哪些典型类型?
- 默认参数使用可变对象为什么容易产生跨调用状态污染?
- 浅拷贝和深拷贝在嵌套列表、字典和对象中的区别是什么?
- Python 的引用计数和循环垃圾回收分别解决什么问题?
- Python 对象的生命周期如何影响内存占用和资源释放?
- 生成器、迭代器和列表推导式在内存使用和执行时机上有什么区别?
- 为什么处理大文件时生成器通常比一次性读入列表更合适?
- 装饰器、上下文管理器和
with语句分别适合封装什么工程行为? - 如何使用上下文管理器保证文件、锁和数据库连接被正确释放?
- 异常层级、异常捕获范围和异常上下文应该如何设计?
- 为什么不应该用一个宽泛的
except Exception吞掉所有错误? - Python 的模块导入、包结构和循环依赖会带来哪些问题?
- 虚拟环境、依赖锁定和可重复安装为什么重要?
requirements.txt、pyproject.toml和锁文件分别承担什么职责?- Python 程序如何管理配置、密钥和不同环境的参数?
argparse如何支持子命令、默认值、类型检查和帮助信息?- Python 调用
subprocess时如何处理参数转义、输出、错误和超时? - 如何设计一个批量任务的重试、退避、断点和失败记录?
- Python HTTP 客户端应该如何设置连接、读取和总超时?
- 大型 JSON 处理为什么可能造成内存峰值?
- Python 流式解析、批量处理和分块写入如何控制内存?
- Pandas 在数据处理上方便,但为什么不应无条件加载全部数据到内存?
- NumPy 的向量化为什么通常比 Python 层循环更快?
- 多进程之间的数据传递为什么可能产生序列化和内存复制开销?
multiprocessing、线程池和进程池如何根据任务类型选择?- 异步 I/O 如何通过事件循环提高大量网络任务的并发效率?
- 异步代码中阻塞调用为什么会卡住整个事件循环?
- 生产者消费者队列如何限制 Python 任务的并发和内存?
- Python 程序如何区分 CPU、内存、磁盘、网络和外部服务瓶颈?
- 为什么微基准测试结果不能直接代表线上整体性能?
- Python 的对象分配、字符串拼接和临时列表会如何制造额外开销?
- Python 脚本出现内存上涨时,应该如何区分真实泄漏、缓存增长和延迟回收?
- Python 与 C/C++ 扩展的 ABI、平台和编译器差异会造成什么问题?
- 为什么网络、文件和子进程测试需要注入超时、错误和部分失败?
- Python 服务或脚本如何进行优雅退出和信号处理?
- 长时间运行的 Python 工具如何避免日志无限增长和临时文件泄漏?
- 可哈希对象、字典键和集合成员之间有什么关系?
- Python 的闭包如何捕获变量?循环中创建闭包时有哪些常见坑?
- 装饰器如何保留被装饰函数的名称、文档和签名信息?
- 为什么日志中应该记录异常类型、请求标识和关键参数,而不是只打印字符串?
- Python 线程安全问题如何通过锁、队列和不可变对象降低?
queue.Queue和直接共享列表在多线程通信上有什么差异?- 多进程间共享状态为什么需要进程安全的数据结构或 IPC?
- Python 的超时、取消和重试应该如何避免任务重复执行?
- 如何在 Python 中实现批量读取、批量写入和失败记录?
C++
- C++ 的显式对象生命周期和资源管理带来了哪些能力与风险?
- 栈对象和堆对象在生命周期、分配开销和空间限制上有什么区别?
new/delete和malloc/free的本质区别是什么?- 为什么 C++ 代码中不能混用
new/delete和malloc/free? - 移动语义解决了什么问题?
const、引用成员、explicit、=default和=delete分别可以表达什么约束?- C++ 多态、虚函数、虚表和动态绑定分别是什么?
- RTTI、虚函数和继承层次会带来哪些运行时和可维护性成本?
- 菱形继承和对象切片分别是什么问题?
vector、list和deque在内存布局、局部性和操作复杂度上有什么区别?map和unordered_map在排序、范围查询、平均复杂度、内存和最坏情况上有什么区别?set和unordered_set分别适合什么去重和成员判断场景?sort、lower_bound和accumulate分别适合什么算法表达?- 什么是 STL 容器的迭代器失效?
map、unordered_map和list删除元素时的失效规则有什么差异?- STL 容器为什么通常不默认提供线程安全?
- C 风格字符串和
std::string在长度管理、安全性和使用方式上有什么区别? extern "C"在跨语言接口中解决什么问题?- 跨语言字符串接口应该如何明确内存所有权和 ABI 边界?
- 网络协议解析中的字符串和缓冲区为什么必须明确消息边界与异常输入处理?
time、top、perf、gprof、valgrind和gdb分别适合定位什么问题?- 频繁动态分配、深拷贝、容器局部性差和粗粒度锁分别会造成什么性能问题?
- 悬垂指针、重复释放、越界、未初始化变量和对象切片分别如何导致不稳定?
- 析构顺序错误、链接符号缺失、ABI 不兼容和运行时库不一致分别如何排查?
undefined symbol、pkg-config、find_package和ldd在构建排障中分别有什么作用?- 为什么智能指针、明确所有权、少用裸指针和谨慎继承能降低 C++ 工程风险?
unique_ptr、shared_ptr和weak_ptr分别表达什么所有权关系?shared_ptr的引用计数会带来哪些额外成本?- 为什么不应该为了方便把所有对象都改成
shared_ptr? - 移动后对象应该满足什么可用但未指定状态?
- Rule of Three、Rule of Five 和 Rule of Zero 分别解决什么资源管理问题?
- 拷贝构造、移动构造、拷贝赋值和移动赋值的调用时机有什么区别?
- 异常安全的基本保证、强保证和不抛出保证分别是什么?
- RAII 如何帮助 C++ 代码在异常路径上释放锁、文件和内存?
- 构造函数抛出异常时,已经构造的成员和基类会如何析构?
noexcept如何影响移动操作、容器扩容和异常传播?reserve、resize和shrink_to_fit分别改变什么?vector连续内存为什么通常有更好的 CPU Cache 局部性?unordered_map的桶、哈希冲突和 rehash 分别如何影响查询?lower_bound、upper_bound和二分查找分别适合什么排序数据?- C++ 标准库容器在多读一写、多写和并行遍历时需要什么外部同步?
std::stringstream、格式化库和手工拼接在性能和可读性上有什么区别?- UTF-8、字符数量和字节数量在协议与日志处理中为什么不能混用?
- C++ ABI 不兼容通常由哪些编译器、标准库和编译选项差异造成?
- 静态链接、动态链接和运行时库版本之间有什么部署取舍?
ldd输出中缺少动态库或加载到错误版本时如何排查?- 为什么全局变量、静态初始化顺序和析构顺序会造成启动或退出问题?
- 虚函数调用在热路径中可能带来哪些间接调用和分支预测成本?
- RTTI 和异常机制在性能敏感路径中为什么需要谨慎使用?
- 对象切片为什么会丢失派生类状态和多态行为?
- 多重继承、虚继承和组合分别适合什么设计场景?
- 锁粒度、读写锁、无锁结构和线程本地存储如何影响 C++ 并发性能?
- 原子操作的内存序为什么会影响可见性和重排?
- 什么是数据竞争?为什么未定义行为比简单的错误返回更危险?
- C++ 中条件变量为什么需要在循环里重新检查条件?
- 线程池、对象池和连接池分别解决什么问题?
- 频繁分配释放如何造成碎片、锁竞争和尾延迟?
- 内存池和分配器优化什么时候值得引入?
perf如何定位 CPU 热点、锁竞争和上下文切换?valgrind、AddressSanitizer、ThreadSanitizer 和 UndefinedBehaviorSanitizer 分别适合什么问题?- core dump、调试符号和可复现构建为什么对线上崩溃定位重要?
gdb中如何结合线程栈、变量和寄存器判断崩溃原因?- 为什么优化编译、调试编译和链接时优化会影响问题复现?
- C++ 性能优化为什么应同时观察 CPU、内存带宽、Cache Miss 和系统调用?
- 如何判断一个性能问题应该通过算法、数据结构、内存布局还是并发模型解决?
- 如何建立 C++ 模块的所有权、生命周期、异常、构建和运行时边界?
std::move做了什么?为什么它本身不一定真的移动资源?- 右值引用、左值引用和完美转发分别适合什么场景?
std::forward为什么能保留参数的值类别?- 临时对象生命周期延长有哪些规则和陷阱?
- 返回局部对象的引用或指针为什么会产生悬垂引用?
const成员函数、可变成员和线程安全之间有什么关系?- 虚函数调用为什么要求对象的动态类型有效?
- 构造函数和析构函数中调用虚函数为什么可能得不到派生类行为?
std::span或视图类型如何表达不拥有内存的缓冲区?- 不拥有内存的指针、引用和视图如何避免超过被引用对象生命周期?
- 对齐、填充和结构体字段顺序如何影响内存占用?
- false sharing 是什么?它为什么会影响多线程计数器性能?
- Cache line 局部性如何影响
vector、链表和哈希表的实际性能? - 分支预测、虚函数间接调用和随机内存访问为什么会造成性能差异?
Go
- Go 相比 C++ 和 Python,在性能控制、开发复杂度、并发表达和交付方式上做了什么取舍?
- 数组、切片、
map、struct和interface分别表达什么语义? - Go 的
map为什么不能直接安全地并发读写? defer适合承担哪些资源释放职责?它在热路径中有什么成本?- Go 的显式错误返回值如何影响函数设计和失败路径?
panic和普通业务错误分别适合表达什么情况?recover在服务端入口层通常承担什么角色?- G、P、M 调度模型分别代表什么?
- 无缓冲 Channel 和有缓冲 Channel 分别表达什么同步和队列语义?
- 关闭 Channel 后继续读取、读取空 Channel 和向已关闭 Channel 写入分别会怎样?
- 为什么错误的 Channel 设计容易造成 Goroutine 泄漏和死锁?
- 生产者消费者、fan-out/fan-in、worker pool 和批处理分别如何用 Go 表达?
- 为什么 Go 强调小接口和面向消费方定义?
- 为什么过大或过早抽象的接口会降低可维护性?
- 为什么预期内业务失败不应该统一使用
panic? net、net/http、context、sync和runtime分别为网络服务提供什么能力?sync.Mutex、sync.RWMutex、sync.WaitGroup、sync.Once和原子操作分别适合什么场景?- Ticker、Timer、文件、连接和响应体不释放会带来什么资源风险?
- 如何使用 profile 判断 Go 服务慢在分配、对象存活、锁竞争还是并发模型?
- 如何设计一个具备超时、取消、连接复用和优雅退出能力的 Go 网络服务?
make和new分别适合初始化哪些类型?- 指针接收者和值接收者对方法集和接口实现有什么影响?
- 类型断言和类型 switch 分别适合什么运行时类型判断?
- 空接口和泛型分别解决什么抽象问题?
defer的执行顺序、参数求值和闭包捕获有哪些坑?panic在 Goroutine 内发生时为什么不能总被其他 Goroutine 的recover捕获?- 为什么错误信息应该携带操作上下文但避免泄露敏感数据?
- Context 的 deadline、取消信号和 request-scoped value 分别适合什么用途?
- 为什么不应该把 Context 当作普通配置容器?
- Goroutine 泄漏通常由哪些 Channel、Context、Timer 和等待逻辑造成?
- 如何通过 pprof、runtime 指标和 Goroutine dump 定位泄漏?
- GOMAXPROCS 如何影响 CPU 并行和服务吞吐?
- 过多 Goroutine 为什么可能造成调度、内存和下游连接压力?
- 无缓冲 Channel 的发送和接收为什么必须同时就绪?
- 有缓冲 Channel 的容量应该如何根据生产速率、消费速率和内存预算设置?
- 关闭 Channel 和发送结束信号分别适合哪些所有权关系?
- 多个生产者如何安全地协作关闭一个 Channel?
select的随机选择、公平性和 default 分支会带来什么行为?select中的超时分支如何避免 Goroutine 无限等待?- Worker Pool 如何限制并发、传播错误和等待所有任务结束?
- Fan-out/fan-in 如何处理结果顺序、部分失败和取消?
- Go 的 errgroup 适合解决哪些并发任务生命周期问题?
- 为什么并发任务需要明确谁负责取消、关闭和回收?
sync.Mutex和sync.RWMutex如何根据读写比例选择?sync.Once适合初始化什么资源?初始化失败时如何处理?sync.WaitGroup为什么不应该复制或在任务开始后随意修改计数?- 原子操作适合计数和状态交换,但为什么不能替代多字段事务更新?
- Go map 的并发读写应该如何通过锁、单线程拥有者或并发 Map 结构治理?
- 数据竞争检测工具如何帮助发现只在高并发下出现的问题?
sync.Pool的对象为什么可能被运行时随时回收?- Go HTTP Server 的连接、请求、Handler 和 Context 生命周期分别是什么?
- HTTP 客户端 Transport 如何影响连接池、Keep-Alive 和 DNS?
- 为什么每次创建一个默认 HTTP Client 可能导致连接复用和资源治理问题?
- 如何为 Go HTTP 客户端配置连接超时、TLS 握手超时、响应头超时和空闲连接超时?
- 为什么服务端必须设置请求体大小、Header 超时和空闲连接超时?
- 如何通过 context deadline 将一次请求的时间预算传递给下游?
- Go 服务收到 SIGTERM 后应该如何停止接收新请求并等待存量请求完成?
- 如何在服务退出时关闭 Listener、数据库连接、消息消费者和后台 Goroutine?
- Ticker 和 Timer 不停止会造成哪些资源和逻辑问题?
string与[]byte转换为什么可能产生复制和 GC 压力?- 切片复用底层数组时如何避免意外保留大数组?
- 切片扩容、预分配和批量追加如何影响内存分配次数?
- Go GC 的目标与延迟、吞吐和内存占用之间有什么取舍?
- 如何通过 pprof 的 heap、allocs、goroutine、mutex 和 block profile 定位问题?
- Go 服务出现 P99 延迟升高时,应该如何区分 GC、锁、调度、网络和下游依赖?
- cgo 为什么可能阻塞调度、增加线程数量并影响部署复杂度?
- Go 单二进制部署仍然需要关注证书、时区、动态库和 CPU 架构吗?
- Go 模块、版本选择和 vendor 如何保证依赖可复现?
- 如何设计 Go 包的边界,避免循环依赖和过大的公共接口?
- 为什么小接口、组合和依赖注入有利于 Go 服务测试?
- 如何测试 Go 并发代码中的超时、取消、竞态和部分失败?
- 如何设计 Go 消费者的重试、幂等、优雅退出和积压治理?
- Go 的值传递语义与引用类型的共享底层数据之间有什么关系?
append返回新切片为什么必须接收返回值?- 切片截取后为什么可能意外保留整个大数组?
- Go 的包初始化顺序如何影响全局变量和依赖关系?
- Context 取消后,下游 HTTP、数据库和 Goroutine 如何及时停止?
- Channel 缓冲区大小如何根据峰值、突发和处理速率估算?
- Channel 消费者退出但生产者继续发送时,如何避免发送方永久阻塞?
- 多路 Channel 合并时如何保证取消、错误和关闭语义一致?
- 什么时候使用 Channel,什么时候使用 Mutex、原子操作或直接函数调用?
- Go 的逃逸分析如何影响栈分配、堆分配和 GC 压力?
[]byte、string和bytes.Buffer在网络编解码中的取舍是什么?- Go 的 JSON 编解码为什么可能成为 CPU 和内存热点?
- HTTP Handler 中启动后台 Goroutine 时,如何保证请求取消后不会遗留任务?
- Go 数据库连接池中的最大连接、空闲连接和连接生命周期如何设置?
- 数据库连接池耗尽时,如何区分 SQL 慢、事务未提交和连接泄漏?
- Go 消息消费者如何处理批量拉取、手动确认、重试和死信?
- 消费者优雅退出时,如何停止拉取、完成安全任务并提交已处理进度?
- Go 服务如何通过限流、超时和并发信号量保护下游依赖?
- 高基数指标标签为什么可能造成监控系统和服务自身的额外压力?
- pprof 暴露接口为什么需要权限和网络隔离?
- 交叉编译时,CPU 架构、cgo、系统调用和动态库会带来哪些问题?
- Go 模块升级为什么需要关注间接依赖、语义版本和安全公告?
- 为什么并发测试需要控制时间、随机性和调度顺序,避免偶发失败?
- 如何设计 Go 服务的容量测试,分别观察 CPU、GC、连接、锁和下游压力?
- 如何让 Go 服务在依赖不可用时快速失败、有限重试并提供降级结果?
- 如何区分 Go 服务中的业务错误、客户端错误、依赖错误和程序异常?
- Go 的并发控制如何与 Kubernetes 的副本扩展和连接池容量共同规划?
- 多个 Go 实例同时处理同一任务时,如何通过租约、幂等和分片避免重复副作用?
- Go 网络服务的 P99 延迟如何拆分为排队、调度、GC、网络和下游等待?
附录 F:1-10 年互联网后端工程师高频系统设计 50 题(电商优先)
本附录独立收录博客题库原文,不修改附录 D 的原有系统设计题库。
50 题总览(电商优先)
定位: 面向 1-10 年互联网后端工程师的系统设计面试题集,电商与交易链路作为 P0 优先复习。 阅读时间: 建议分 4 次阅读,每次 30-45 分钟 | 难度: ⭐⭐⭐⭐⭐ | 面试频率: 极高
优先级说明:
- P0 电商/交易: 先掌握业务事实、状态机、对账和资损防控,再谈组件。
- P0 高并发: 电商大促场景的限流、热点、容量和可靠性。
- P1 通用能力: 分布式一致性、通用业务系统、中间件选型。
- P2 工程与架构: 可观测性、SRE、云原生、架构演进。
| # | 题目 | 优先级 | 适合年限 | 核心方案 |
|---|---|---|---|---|
| 1 | 电商系统全景与核心链路 | P0 | 5-10年 | 业务、应用、数据、技术四层拆解;交易主链路 + 支撑域 |
| 2 | 商品中心 / 商品上架 | P0 | 3-7年 | SPU/SKU、类目属性、版本发布、审核同步 |
| 3 | 库存系统 | P0 | 3-8年 | 可售/锁定/已售;Redis 预扣 + DB 权威扣减 + 对账 |
| 4 | 营销 / 优惠券 / 活动 | P0 | 3-8年 | 预算控制、资格校验、锁券、核销、幂等与对账 |
| 5 | 价格 / 计价系统 | P0 | 3-8年 | 商品价、营销价、会员权益、税费运费复算与防篡改 |
| 6 | 购物车与结算 | P0 | 3-7年 | 未登录/登录合并、结算校验、价格库存重算、重复提交防护 |
| 7 | 订单系统 | P0 | 3-8年 | 状态机、创单幂等、超时取消、履约编排、补偿 |
| 8 | 支付系统 | P0 | 5-10年 | 支付单、渠道网关、回调幂等、退款状态机、对账 |
| 9 | 退款、对账与资损防控 | P0 | 5-10年 | 退款状态机、差异账单、资损监控、人工处置闭环 |
| 10 | 秒杀系统 | P0 | 3-7年 | Redis 预热、Lua 扣减、MQ 削峰、限流防刷 |
| 11 | 分布式限流 | P0 | 3-7年 | 固定/滑动窗口、漏桶、令牌桶、Redis Lua、自适应限流 |
| 12 | 热点 Key 治理 | P0 | 3-7年 | 探测、本地缓存、Key 分散、隔离线程池 |
| 13 | 熔断、降级、限流边界 | P0 | 3-7年 | 限流防激增、熔断防雪崩、降级保核心、兜底保体验 |
| 14 | 大促容量规划与全链路压测 | P0 | 5-10年 | 容量模型、压测链路、限流降级预案、扩容与回滚 |
| 15 | AI Agent 高并发架构 | P1 | 5-10年 | 全异步、SSE、语义缓存、模型路由、工具限流 |
| 16 | 短链接服务 | P1 | 1-5年 | 发号器 + Base62 + 缓存重定向 + 布隆过滤器 |
| 17 | Feed 流 | P1 | 3-8年 | 推/拉/推拉结合、大 V 问题、缓存时间线、分页 |
| 18 | 评论系统 | P1 | 2-6年 | 楼中楼、分页、热点评论、缓存与 DB 一致性 |
| 19 | 消息通知系统 | P1 | 3-7年 | 事件订阅、渠道适配、模板、限流、重试与幂等 |
| 20 | 搜索 / 推荐系统 | P1 | 3-8年 | ES 倒排、索引同步、排序/重排、降级与一致性 |
| 21 | 分布式事务 | P1 | 3-8年 | 2PC/TCC/Saga/Outbox;区分同步承诺与异步补偿 |
| 22 | Redis / MySQL 双写一致性 | P1 | 3-7年 | Cache Aside、延迟双删、Binlog 订阅、对账补偿 |
| 23 | 分布式锁 | P1 | 3-7年 | Redis SET NX EX + Lua + Watchdog;ZooKeeper/Etcd 兜底 |
| 24 | 接口幂等性 | P1 | 2-7年 | 唯一索引、Token、幂等表、状态机前置校验 |
| 25 | 数据迁移、双写切换与最终一致性 | P1 | 5-10年 | 双写、回放、校验、灰度切流、回滚窗口 |
| 26 | 40 亿数据去重 | P1 | 1-5年 | Bitmap 512MB、Bloom Filter、HyperLogLog |
| 27 | 实时排行榜 | P1 | 2-6年 | Redis ZSet、分桶归并、快照分页 |
| 28 | 海量数据排序 | P1 | 1-5年 | 分块快排 + 多路归并 + 分布式排序 |
| 29 | 10 亿用户在线状态 | P1 | 2-6年 | Bitmap + Redis BITCOUNT + 分片 |
| 30 | 分布式 ID | P1 | 1-6年 | Snowflake、数据库号段、Redis INCR、时钟回拨处理 |
| 31 | MySQL 分库分表 | P1 | 3-8年 | 水平/垂直拆分、分片键、分布式事务与迁移 |
| 32 | Redis 核心问题 | P1 | 1-6年 | 数据结构、持久化、集群、缓存雪崩/穿透/击穿、锁 |
| 33 | 消息队列选型 | P1 | 2-7年 | Kafka/RocketMQ/RabbitMQ;顺序、可靠性、积压、幂等 |
| 34 | Elasticsearch 架构 | P1 | 2-7年 | 倒排索引、分片副本、Mapping、深分页、脑裂 |
| 35 | ClickHouse / OLAP 选型 | P1 | 3-8年 | 列存、MergeTree、实时数仓、离在线链路 |
| 36 | 线程池设计 | P2 | 1-5年 | 核心/最大线程、队列、拒绝策略、监控与告警 |
| 37 | 异步并行优化 | P2 | 2-6年 | CompletableFuture、批量、并行度、线程池隔离 |
| 38 | 认证鉴权 Session/JWT/OAuth2/SSO | P2 | 2-7年 | 会话存储、JWT 过期/吊销、SSO、权限模型 |
| 39 | 常见 Web 攻防 | P2 | 1-6年 | SQLi/XSS/CSRF/SSRF、限流、WAF、安全响应 |
| 40 | HTTPS 握手与加密 | P2 | 1-5年 | TLS 1.3、证书链、密钥交换、中间人防护 |
| 41 | 日志、指标、链路追踪三大支柱 | P2 | 2-7年 | 结构化日志、Metrics、TraceID、SLO |
| 42 | 接口突然变慢排查 | P2 | 2-7年 | 监控 → 日志 → 链路 → SQL/GC/依赖 → 回滚/扩容 |
| 43 | 线上故障排查与复盘 | P2 | 3-8年 | 止血、定位、修复、复盘、改进项闭环 |
| 44 | SLI / SLO / 错误预算 | P2 | 5-10年 | 用户可感知指标、目标、预算、告警与研发节奏 |
| 45 | 故障演练、混沌工程与降级预案 | P2 | 5-10年 | 注入故障、预案、恢复演练、复盘 |
| 46 | 分布式任务调度 | P2 | 2-6年 | 分片、重试、幂等、Leader 选举、超时治理 |
| 47 | 对象存储 / 文件上传下载 | P2 | 2-6年 | 直传/分片/断点、OSS、预签名、回调校验 |
| 48 | API 网关、注册中心、配置中心 | P2 | 3-8年 | 路由、鉴权、限流、动态配置、服务发现 |
| 49 | 单体到微服务 / 中台化拆分 | P2 | 5-10年 | 业务边界、领域模型、数据拆分、演进节奏 |
| 50 | 同城双活 / 异地多活 | P2 | 7-10年 | 单元化、数据同步、故障切换、流量调度 |
电商优先复习路线:
- 第一轮: 1-10(电商链路 + 秒杀)+ 14(大促容量)
- 第二轮: 11-13 + 21-25(高并发与一致性)
- 第三轮: 16-20 + 26-35(通用业务与中间件)
- 第四轮: 36-50(工程、SRE 与架构)
本文汇总了 1-10 年互联网后端工程师高频系统设计面试 50 题,覆盖电商交易、高并发、海量数据、分布式一致性、中间件选型、安全、可观测性和架构演进。电商与交易链路作为 P0 优先复习。
使用建议:每个小节独立成题,可直接跳转到目标章节按需查阅。
电商与交易高频题(P0)
电商题先讲业务事实:谁是权威状态、同步承诺什么、失败由谁补偿。不要一上来堆 Redis/MQ。
电商系统全景与核心链路
- 拆法:业务架构、应用架构、数据架构、技术架构。
- 主链路:搜索/导购 → 详情 → 购物车 → 结算 → 订单 → 支付 → 履约 → 售后。
- 支撑域:商品、库存、营销、价格、会员、支付、对账、履约。
- 答题重点:区分“同步承诺”和“异步副作用”,指出交易权威数据与最终一致读模型。
- 扩展:电商系统全景
商品中心与商品上架
- 模型:SPU/SKU、类目、属性、销售信息、图文详情。
- 状态机:草稿 → 审核 → 发布 → 下线/失效;发布前有版本和审批。
- 发布后同步:详情缓存、搜索索引、库存可售状态、计价上下文。
- 答题重点:线上数据不可被编辑直接覆盖,要版本化、审核、灰度发布、失败回滚。
- 扩展:商品中心
营销、优惠券与活动
- 核心对象:活动、券模板、用户券、预算、资格、核销流水。
- 关键链路:资格校验 → 预算/库存占用 → 发券/锁券 → 结算核销 → 释放/对账。
- 答题重点:预算不能靠 Redis 证明,锁券/核销要有幂等键和权威流水;大促要有降级和资损监控。
- 扩展:营销系统
价格与计价系统
- 价格组成:商品价、渠道价、会员价、营销优惠、税费、运费、平台补贴。
- 设计目标:可解释、可追溯、可复算、防篡改。
- 答题重点:试算和结算必须复用同一计价服务;缓存命中后仍要在下单前重算。
- 扩展:计价系统
购物车与结算
- 购物车:未登录本地购物车、登录合并、商品失效、库存变化。
- 结算:价格重算、库存预占、营销资格、优惠互斥、重复提交防护。
- 答题重点:购物车是暂存态,结算前必须重新校验价格、库存、营销和用户身份。
- 扩展:购物车与结算
订单系统
- 状态机:创建、待支付、已支付、已发货、已完成、已关闭、退款中。
- 关键设计:创单幂等、库存预占、支付回调、超时取消、履约编排。
- 答题重点:订单是交易事实的汇聚点,状态迁移要有前置条件、幂等和审计。
- 扩展:订单系统
退款、对账与资损防控
- 退款:退款单、原路退回、部分退款、退款状态机、重复退款防护。
- 对账:支付渠道账单 vs 本地账单,差异分类、自动修复、人工处置。
- 资损防控:金额试算/复算、幂等、红黄线告警、回滚与补偿。
- 答题重点:不能把“渠道回调成功”等同于“资金已安全到账”,要闭环对账。
- 扩展:支付系统
一、高并发与流量治理
1. 秒杀系统设计
核心挑战:瞬时流量巨大、库存超卖、恶意脚本。
架构分层:
| 层级 | 策略 |
|---|---|
| 客户端/CDN | 静态资源缓存;按钮置灰+答题验证(削峰防刷) |
| 网关层 | 令牌桶/漏桶限流;黑名单拦截;设备指纹识别 |
| 服务层 | 库存预热到 Redis;MQ 异步扣减 DB 库存;非核心服务降级 |
防超卖(核心):
- Redis Lua 脚本原子扣减:
if redis.call('get', key) > 0 then redis.call('decr', key) ... - DB 乐观锁兜底:
UPDATE stock SET num = num - 1 WHERE id = ? AND num > 0
防黄牛/脚本:
- 滑块验证 / 人机识别
- 设备指纹 + 行为分析(点击间隔、轨迹)
- 实名认证 + 限购(身份证/手机号去重)
2. 分布式限流
算法对比:
| 算法 | 优点 | 缺点 |
|---|---|---|
| 固定窗口计数器 | 实现简单 | 临界突发:窗口交界处可能 2 倍流量 |
| 滑动窗口 | 解决临界突发 | 内存开销大(需存每个请求时间戳) |
| 漏桶 | 平滑输出 | 无法应对合理突发 |
| 令牌桶 | 允许突发 | 实现稍复杂 |
分布式实现:Redis + Lua(ZSet 滑动窗口 / Token Bucket)。
动态限流:基于 CPU、RT、错误率自适应调整阈值(Sentinel / Hystrix)。
3. 热点发现与隔离
场景:秒杀商品、热搜词、突发事件导致单个 Key 流量爆炸。
方案:
- 探测:实时统计 QPS,自动识别热点 Key。
- 本地缓存:热点 Key 复制到 JVM 内存(Caffeine),直接拦截。
- 分散压力:Key 后缀加随机值(
key_1 ~ key_N),分散到多个 Redis 分片。 - 隔离:热点请求走独立线程池 + 独立缓存节点,不影响普通流量。
4. 熔断、降级、限流的区别
| 手段 | 目标 | 触发条件 |
|---|---|---|
| 限流 | 控制入口流量 | QPS 超阈值 |
| 熔断 | 切断对下游的调用 | 下游错误率/超时率过高 |
| 降级 | 关闭非核心功能 | 系统负载高、人工/自动触发 |
| 兜底 | 给用户默认响应 | 降级后的补偿策略 |
口诀:限流防激增,熔断防雪崩,降级保核心,兜底提体验。
5. AI Agent 高并发架构
挑战:LLM 推理慢(秒级)、显存/线程池易耗尽、Token 成本高。
优化策略:
- 全异步化:请求 → MQ → Agent 消费 → 结果存储 → 前端 SSE 推送。
- 流式输出 (SSE):Token 级返回,降低首屏感知延迟。
- 语义缓存 (Semantic Cache):向量相似度匹配高频问题,直接返回缓存。
- 成本优化:模型蒸馏(小模型处理简单请求);KV Cache 复用;请求批处理 (Batching)。
- 限流熔断:严格限制 Agent 调用内部工具接口的频率,防止 AI 攻击内部系统。
二、海量数据与存储
1. 40亿数据去重(1GB 内存限制)
方案对比:
| 方案 | 空间 | 精确度 | 支持删除 |
|---|---|---|---|
| Bitmap | 40亿 ≈ 512MB | 精确 | 否 |
| Bloom Filter | 极小(几十 MB) | 有误判 | 否(Counting BF 可以,但空间 ×4) |
| HyperLogLog | 12KB | 误差 0.81% | 否 |
最佳回答:
- 40亿 QQ 号(unsigned int 范围 0~2^32)→ Bitmap,约 512MB 可精确去重。
- 若内存更紧张或允许少量误判 → Bloom Filter。
- 只需统计基数(不需要知道具体哪些重复)→ HyperLogLog。
2. 1亿玩家实时排行榜
Redis ZSet 方案:
ZADD rank 5000 "player_1"
ZREVRANGE rank 0 9 -- Top 10
ZRANK rank "player_1" -- 查排名
陷阱:ZSet 元素超过千万级 → 大 Key 阻塞主线程。
解决方案(分桶 + 聚合):
- 按玩家 ID 模 N 分到 N 个 ZSet:
rank_0, rank_1 ... rank_N。 - 每个桶取 Top K。
- 应用层归并 N 个桶的 Top K,得到全局 Top K。
分页优化:
ZRANGE深分页性能差(O(logN + M))。- 游标分页:记录上一页最后的
(score, member_id),下一页从该位置继续查。 - 快照分页:定时 dump 排行到 DB,前端查快照。
3. 海量数据排序(100GB 数据,8GB 内存)
- 分块读入:每次读入 8GB → 内存快排 → 写出有序文件。
- 多路归并:用小顶堆同时从 13 个有序文件中取最小值,输出全局有序文件。
- 分布式:MapReduce / Spark 分布式排序。
4. 10亿用户在线状态
Bitmap:1 bit 表示 1 个用户的在线/离线。1亿用户仅 12MB,10 亿用户约 120MB。
SETBIT online 123456 1 -- 用户123456上线
GETBIT online 123456 -- 查询是否在线
BITCOUNT online -- 统计在线人数
三、典型业务场景设计
1. 订单超时自动取消
场景:下单 30 分钟未支付自动关闭。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 定时任务扫表 | 实现简单 | 数据量大时效率低,延迟高 |
| Redis 过期监听 | 简单 | 不可靠(不保证触发),不推荐 |
| Redis ZSet 轮询 | 精度高 | 需维护消费者 |
| RocketMQ 延迟消息 | 可靠、可扩展 | 延迟级别有限 |
| RabbitMQ TTL + DLX | 灵活 | 架构复杂 |
| 时间轮 (Time Wheel) | 高吞吐、内存高效 | 适合固定延迟场景 |
最佳回答:
- 短延迟 + 高吞吐(如 <5 min):时间轮。
- 长延迟 + 高可靠(如 30 min 关单):RocketMQ 延迟消息或 Redis ZSet。
- 千万级订单:定时任务扫表无法胜任,必须用延迟队列。
2. 分布式 ID 生成器
| 方案 | 有序性 | 性能 | 问题 |
|---|---|---|---|
| UUID | 无序 | 高 | 太长(128bit),B+ 树索引性能差 |
| 数据库号段 | 趋势递增 | 高 | 批量取号,DB 宕机有号段浪费 |
| Snowflake | 趋势递增 | 高 | 依赖时钟,回拨会重复 |
| Redis INCR | 递增 | 高 | 持久化风险,单点问题 |
Snowflake 结构:1 位符号 + 41 位时间戳(69 年)+ 10 位机器 ID + 12 位序列号(4096/ms)。
容器化环境机器 ID 唯一:
- Pod Name / IP 哈希取模。
- 启动时向 Etcd/ZooKeeper 注册获取唯一 ID。
- Redis INCR 动态分配 workerID。
3. 短链接系统
生成策略:
- 发号器 + Base62:分布式 ID → 62 进制编码(a-z, A-Z, 0-9),6 位可表示 $62^6$ ≈ 568 亿。
- Hash(MD5/Murmur)取前 N 位:简单但需处理冲突。
重定向选择:
- 301 永久重定向:浏览器缓存,无法统计点击数。
- 302 临时重定向:每次经过服务端,可统计 UA、IP、Referer 等点击来源。
点击统计:302 重定向时解析 UA/IP/渠道 → 异步写入日志 → Flink 聚合 → ClickHouse 存储。
4. Feed 流系统
| 模式 | 读性能 | 写性能 | 适用场景 |
|---|---|---|---|
| 推 (Write-fanout) | 快 | 慢(写 N 个粉丝收件箱) | 普通用户 |
| 拉 (Read-fanout) | 慢(聚合 N 个关注人) | 快 | 大 V |
| 推拉结合 | 均衡 | 均衡 | 业界主流 |
推拉结合策略:
- 活跃用户 / 普通博主:推模式。
- 大 V / 僵尸粉:拉模式。
已读去重:用户维度维护 RoaringBitmap,推送前 if (!bitmap.contains(postId)) push()。
5. 评论系统(B站/抖音盖楼)
存储模型对比:
| 模型 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 邻接表 | id, parent_id | 简单 | 查子树需递归,性能差 |
| 路径枚举 | id, path="1/2/5" | 前缀查询方便 | 路径长度受限 |
| 闭包表 | 单独表存所有祖先-后代 | 查询极快 | 写入量大 |
业界主流(两层结构):
- 一级评论:按热度/时间排序(Redis ZSet 或 DB 索引)。
- 二级回复:扁平化存储。
parent_id指向一级评论,reply_to_id指向被回复的人。不做无限嵌套。
防灌水:发言频率限制 → 敏感词过滤(AC 自动机)→ 举报+审核队列 → 新用户评论需审核(信任分体系)。
6. 红包算法
二倍均值法:amount = random(1, remain / remain_count * 2),数学上保证期望恒定。
高并发实现:
- 预分配:发红包时一次性算好所有金额,存入 Redis List。
- 抢红包:
LPOP原子弹出,天然串行化。
7. 支付系统设计
核心链路:下单 → 锁库存 → 创建支付单 → 调第三方支付 → 异步回调 → 扣库存 → 发货。
关键设计点:
- 幂等:
transaction_id唯一索引,重复回调不重复处理。 - 签名验签:防篡改请求金额。
- 对账系统:每日与第三方支付平台账单核对,发现差异报警。
- 事务消息:RocketMQ 半消息保证扣库存与支付状态一致。
8. 库存系统深度设计
Q1:如何设计一个统一库存系统,支持电商、虚拟商品、本地生活等多品类?
核心洞察:不同品类库存差异巨大,需要抽象出通用模型。
两个正交维度分类:
维度一:谁管库存?
- 自管理 (SelfManaged):平台维护(Deal、OPV)
- 供应商管理 (SupplierManaged):第三方维护(酒店、机票)
- 无限库存 (Unlimited):无需管理(话费充值)
维度二:库存形态是什么?
- 券码制 (CodeBased):每个库存是唯一券码(电子券、Giftcard)
- 数量制 (QuantityBased):库存是一个数字(虚拟服务券)
- 时间维度 (TimeBased):按日期/时段管理(酒店、票务)
- 组合型 (BundleBased):多子项联动扣减(套餐)
品类分类矩阵示例:
| 品类 | 管理类型 | 单元类型 | 扣减时机 |
|---|---|---|---|
| 电子券 | Self | Code | 下单 |
| 虚拟服务券 | Self | Quantity | 下单 |
| 酒店 | Supplier | Time | 支付 |
| 礼品卡(实时生成) | Supplier | Code | 支付 |
架构设计(策略模式):
业务层 (Order Service)
↓
库存管理器 (InventoryManager)
↓
策略路由器 (根据 inventory_config 选策略)
↓
具体策略: SelfManagedStrategy / SupplierManagedStrategy / UnlimitedStrategy
↓
存储层: Redis (Hot) + MySQL (Cold) + Kafka (Async)
核心优势:
- ✅ 新品类接入只需写配置,无需改代码。
- ✅ 每个策略独立实现,复杂度隔离。
Q2:券码制库存(如电子券)如何实现高并发扣减?
Redis 存储结构:
Key: inventory:code:pool:{itemID}:{skuID}:{batchID}
Type: LIST
Value: [codeID_1, codeID_2, ...]
Key: inventory:code:cursor:{itemID}:{skuID}:{batchID}
Value: "lastCodeID:lockCount" (补货游标)
Key: inventory:empty:{itemID}:{skuID}:{batchID}
TTL: 1h (库存空标志,避免重复查 DB)
出货流程(核心):
1. 检查库存空标志 → 命中则直接返回缺货
2. Redis LIST 原子出货 (Lua: LRANGE + LTRIM)
3. 如果库存不足 → 补货 (从 MySQL 查 3000 个可用券码 → RPUSH 到 Redis)
4. 更新 MySQL 券码状态: AVAILABLE → BOOKING
5. 同步更新 inventory 表: booking_stock += quantity
6. 发送 Kafka 事件异步记录日志
Lua 脚本(原子性保证):
local result = redis.call('LRANGE', KEYS[1], 0, ARGV[1] - 1)
redis.call('LTRIM', KEYS[1], ARGV[1], -1)
return result
关键设计:
- Lazy Loading:按需补货,避免一次性加载全量券码到 Redis(节省内存)。
- 分布式锁:补货时加锁,防止并发补货导致重复。
- 库存空标志:DB 无库存后,1小时内拦截所有请求,避免反复查 DB。
Q3:数量制库存(如虚拟服务券)如何支持营销活动动态库存?
Redis HASH 设计:
Key: inventory:qty:stock:{itemID}:{skuID}
Type: HASH
Fields:
"available" : 10000 # 普通可售库存
"booking" : 50 # 预订中
"issued" : 5000 # 已售
"{promotionID}": 500 # 营销活动独立库存(动态字段)
预订 Lua 脚本(支持营销库存):
-- 1. 获取普通库存和营销库存
local available = tonumber(redis.call('HGET', key, 'available') or 0)
local promo = tonumber(redis.call('HGET', key, promotion_id) or 0)
local total = available + promo
-- 2. 检查库存
if book_num > total then return -1 end
-- 3. 优先扣营销库存,不足时扣普通库存
if promo >= book_num then
redis.call('HINCRBY', key, promotion_id, -book_num)
else
redis.call('HSET', key, promotion_id, 0)
redis.call('HINCRBY', key, 'available', -(book_num - promo))
end
-- 4. 增加预订数
redis.call('HINCRBY', key, 'booking', book_num)
亮点:动态字段设计,无需提前建表,营销活动 ID 直接作为 HASH field。
Q4:供应商管理的库存(如酒店、机票)如何同步?
三种同步策略:
| 策略 | 适用场景 | 实时性 | 实现 |
|---|---|---|---|
| 实时查询 | 库存变化快(机票) | 高 | 每次请求调 API(30s 缓存) |
| 定时同步 | 变化中等(酒店) | 中 | 定时任务每 5 分钟拉取 |
| Webhook 推送 | 供应商主动推送 | 高 | 接收推送更新本地缓存 |
实时查询流程:
func CheckStock() {
// 1. 查 Redis 缓存(30s TTL)
if stock := redis.Get(cacheKey); stock != nil {
return stock // 命中缓存
}
// 2. 缓存未命中,调供应商 API
stock := supplierAPI.QueryStock(itemID, date)
// 3. 写入 Redis(30s)+ 异步写快照表(用于对账)
redis.Set(cacheKey, stock, 30*time.Second)
go saveSnapshot(itemID, stock, "api")
return stock
}
预订时:调供应商预订接口 → 保存供应商订单号映射 → 更新本地 booking_stock。
Q5:如何保证 Redis 与 MySQL 库存数据一致性?
双写策略:
| 操作 | Redis | MySQL | 一致性 |
|---|---|---|---|
| 预订 (Book) | 同步扣减(Lua) | Kafka 异步更新 | 最终一致 |
| 支付 (Sell) | 同步更新 | Kafka 异步更新 | 最终一致 |
| 营销锁定 (Lock) | 同步 | 同步(DB 事务) | 强一致 |
核心原则:
- Redis 是热路径:所有高频操作走 Redis(毫秒级响应)。
- MySQL 是权威数据源:故障恢复时以 MySQL 为准。
- Kafka 异步持久化:不阻塞主流程。
定时对账(每小时):
redisStock := getRedisAvailable(itemID)
mysqlStock := getMySQLAvailable(itemID)
diff := redisStock - mysqlStock
// 校验库存恒等式: total = available + booking + locked + sold
if mysqlTotal != mysqlAvailable + mysqlBooking + mysqlLocked + mysqlSold {
alert("MySQL 数据不一致")
}
// Redis vs MySQL 差异
if abs(diff) > 100 || abs(diff) > mysqlStock*0.1 {
alert("库存差异过大")
syncRedisFromMySQL(itemID) // 自动修复
}
Q6:Redis 宕机了,库存系统如何降级?
降级方案:
Redis 可用
↓
正常走 Redis(< 10ms)
Redis 不可用
↓
降级到 MySQL 直接操作(~100ms,性能下降但业务不中断)
↓
券码制: SELECT ... FOR UPDATE + UPDATE status
数量制: UPDATE available_stock = available_stock - ? WHERE available_stock >= ?
↓
记录降级日志,Redis 恢复后从 MySQL 全量同步
注意:
- 降级期间性能下降约 10 倍,需配合限流。
- MySQL 需提前规划好容量,支持降级时的流量。
Q7:Giftcard 实时生成卡密,供应商 API 超时怎么办?
问题:支付成功后调供应商 API 生成卡密,超时会导致用户等待。
解决方案(异步生成 + 重试补偿):
支付成功
↓
1. 订单状态更新为"处理中"
↓
2. 发送到 MQ 异步队列 (giftcard.generate)
↓
3. 用户先看到"卡密生成中,稍后通知"
异步消费者:
↓
调用供应商 API 生成卡密
↓
失败?→ 指数退避重试 (1s, 2s, 4s)
↓
3 次仍失败?→ 人工补发 + 告警
↓
成功:保存卡密 → 推送通知用户
卡密安全:
- 存储时 AES-256 加密。
- 管理后台脱敏显示(
XXXX-XXXX-XXXX-1234)。 - 所有访问记录审计日志。
Q8:时间维度库存(酒店/票务)与普通库存有什么不同?
差异:
| 维度 | 普通库存 | 时间维度库存 |
|---|---|---|
| 库存粒度 | SKU 级别 | SKU + 日期 |
| 存储 | 单条记录 | 每个日期一条记录 |
| 查询 | 按 item_id + sku_id | 按 item_id + sku_id + date |
| TTL | 永久 | Redis 缓存 7 天 |
Redis 设计:
Key: inventory:time:stock:{itemID}:{skuID}:{date}
Type: HASH
Fields:
"total" : 100
"available" : 80
"booking" : 15
"sold" : 5
TTL: 7天(历史日期自动过期,节省内存)
挑战:
- 酒店 1 个月有 30 条记录,查询“未来 7 天房态“需扫描 7 个 Key。
- 优化:批量
MGET+ 并行查询。
Q9:如何支持“秒杀活动锁定 1000 件库存“?
场景:运营配置秒杀活动,需从总库存中锁定 1000 件,活动结束释放。
Lua 脚本(营销锁定):
local available = tonumber(redis.call('HGET', key, 'available') or 0)
local promo_stock = tonumber(redis.call('HGET', key, promotion_id) or 0)
-- 检查库存
if lock_num > available then return -1 end
-- 从普通库存转移到营销库存
redis.call('HINCRBY', key, 'available', -lock_num)
redis.call('HSET', key, promotion_id, lock_num)
数据库同步:
UPDATE inventory
SET available_stock = available_stock - ?,
locked_stock = locked_stock + ?
WHERE item_id = ?
活动结束解锁:反向操作,营销库存 → 普通库存。
Q10:新接入一个品类“演唱会门票“,如何快速支持?
三步接入:
// 1. 评估分类
// 演唱会门票 → 供应商管理 + 时间维度(按场次) + 支付成功扣减
// 2. 写配置
INSERT INTO inventory_config (item_id, management_type, unit_type, deduct_timing, supplier_id, sync_strategy)
VALUES (900001, 2, 3, 2, 700001, 2);
// 3. 调用统一接口(无需改代码)
inventoryManager.BookStock(ctx, &BookStockReq{
ItemID: 900001,
SKUID: 0,
Quantity: 2,
OrderID: orderID,
CalendarDate: "2025-08-15", // 场次日期
})
亮点:配置驱动,零代码接入。
面试追问点(高级)
Q:为什么券码制库存不一次性加载全量到 Redis,而是按需补货?
- 内存成本:百万张券码全量加载需要几百 MB 内存,大部分可能永远用不到。
- Lazy Loading:按需补货,每次补 3000 个,节省内存。
- 补货游标:记录上次补到哪个 codeID,避免重复查询。
Q:库存对账发现 Redis 比 MySQL 多 500 个,怎么办?
- 可能原因:
- Kafka 消息积压,MySQL 异步更新延迟。
- Redis 补货后,MySQL 更新失败。
- 存在未完成的预订订单(booking 状态)。
- 处理:
- 检查 Kafka 消费 lag。
- 以 MySQL 为准,用 MySQL 数据覆盖 Redis(权威数据源原则)。
- 人工核查异常订单。
Q:多平台(Shopee、ShopeePay)如何独立统计库存?
- Redis HASH 中增加
booking_shopee、booking_shopeepay字段。 - 扣减时根据
platform参数路由到不同字段。 - DB 也冗余存储
booking_stock和spp_booking_stock。
Q:库存扣减后支付失败,如何归还库存?
- 订单超时未支付:延迟队列(30min)→ 触发 UnbookStock。
- 券码制:code status BOOKING → AVAILABLE,RPUSH 回 Redis LIST。
- 数量制:Redis
HINCRBY booking -1, HINCRBY available +1。
- 支付明确失败:立即同步释放。
四、分布式一致性与事务
1. 分布式事务
| 方案 | 一致性 | 性能 | 侵入性 | 适用场景 |
|---|---|---|---|---|
| 2PC (XA) | 强一致 | 差(阻塞) | 低 | 单体拆分初期 |
| TCC | 最终一致 | 中 | 高(需写 Try/Confirm/Cancel) | 金融转账 |
| 本地消息表 | 最终一致 | 高 | 中 | 通用场景 |
| 事务消息 (RocketMQ) | 最终一致 | 高 | 低 | 电商下单 |
| Saga | 最终一致 | 高 | 中 | 长事务(跨多个服务) |
TCC 追问:Confirm/Cancel 失败怎么办?
- 必须保证幂等 + 重试。
- 设置最大重试次数,超过后记录悬挂事务,人工补偿。
1.1 本地消息表(Outbox Pattern)深度解析
核心问题:如何保证数据库操作和消息发送的原子性?
经典场景:订单支付成功
├─ 更新订单状态(MySQL)
└─ 发送支付成功消息(Kafka)
问题:
❌ 先更新DB,再发Kafka → Kafka发送失败,下游收不到消息
❌ 先发Kafka,再更新DB → DB更新失败,下游收到错误消息
1.1.1 为什么需要本地消息表?
不使用本地消息表的问题:
// ❌ 错误方案1:先写DB,后发MQ
func ProcessPayment(orderID string) error {
// 1. 更新数据库
db.Exec("UPDATE orders SET status='PAID' WHERE id=?", orderID)
// 2. 发送消息
kafka.Send("order.paid", orderID) // 如果这里失败?
// 问题:DB已更新,但消息没发出去,下游系统不知道
}
// ❌ 错误方案2:先发MQ,后写DB
func ProcessPayment(orderID string) error {
// 1. 发送消息
kafka.Send("order.paid", orderID)
// 2. 更新数据库
db.Exec("UPDATE orders SET status='PAID' WHERE id=?", orderID) // 如果这里失败?
// 问题:消息已发出,但DB没更新,数据不一致
}
✅ 本地消息表方案:
核心思想:将"发消息"这个动作转化为"写数据库",利用数据库事务保证原子性
业务操作 + 插入消息记录 → 在同一个事务中
异步扫描消息表 → 发送到MQ → 标记已发送
1.1.2 表结构设计
-- 本地消息表(Outbox)
CREATE TABLE outbox_message_tab (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
-- 消息标识
message_id VARCHAR(64) NOT NULL UNIQUE, -- 消息唯一ID(幂等键)
event_type VARCHAR(100) NOT NULL, -- 事件类型:order.paid, inventory.deducted
-- 消息内容
event_payload JSON NOT NULL, -- 事件数据(JSON格式)
-- 发送状态
status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- pending/published/failed
retry_count INT DEFAULT 0, -- 重试次数
max_retry INT DEFAULT 3, -- 最大重试次数
-- 时间管理
next_retry_at DATETIME, -- 下次重试时间
created_at DATETIME NOT NULL, -- 创建时间
published_at DATETIME, -- 发送成功时间
-- 查询索引
INDEX idx_status_retry (status, next_retry_at),
INDEX idx_created (created_at)
);
1.1.3 完整实现流程
Step 1: 业务代码 - 在事务中写入消息
func ProcessPayment(orderID string, amount int64) error {
return db.Transaction(func(tx *gorm.DB) error {
// 1. 更新订单状态
result := tx.Exec(`
UPDATE orders
SET status = 'PAID', paid_amount = ?
WHERE id = ? AND status = 'PENDING'
`, amount, orderID)
if result.RowsAffected == 0 {
return errors.New("order not found or already paid")
}
// 2. 插入本地消息表 ⭐ 关键:在同一个事务中
message := &OutboxMessage{
MessageID: generateMessageID(orderID),
EventType: "order.paid",
EventPayload: json.Marshal(map[string]interface{}{
"order_id": orderID,
"amount": amount,
"paid_at": time.Now(),
}),
Status: "pending",
MaxRetry: 3,
CreatedAt: time.Now(),
}
if err := tx.Create(message).Error; err != nil {
return err
}
// 3. 两个操作要么都成功,要么都失败
return nil
})
}
Step 2: 后台任务 - 扫描并发送消息
type OutboxPublisher struct {
db *gorm.DB
kafka *kafka.Producer
}
// 启动定时任务(每5秒扫描一次)
func (p *OutboxPublisher) Start() {
ticker := time.NewTicker(5 * time.Second)
for range ticker.C {
p.publishPendingMessages()
}
}
func (p *OutboxPublisher) publishPendingMessages() {
// 1. 查询待发送的消息(含重试)
var messages []OutboxMessage
p.db.Where(`
status = 'pending'
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
`).Limit(100).Find(&messages)
log.Infof("Found %d pending messages", len(messages))
for _, msg := range messages {
// 2. 发送到Kafka
err := p.kafka.Send(msg.EventType, msg.EventPayload)
if err == nil {
// 2.1 发送成功 → 更新状态
p.db.Model(&OutboxMessage{}).Where("id = ?", msg.ID).
Updates(map[string]interface{}{
"status": "published",
"published_at": time.Now(),
})
log.Infof("Message published: %s", msg.MessageID)
} else {
// 2.2 发送失败 → 增加重试(指数退避)
msg.RetryCount++
if msg.RetryCount >= msg.MaxRetry {
// 超过最大重试次数 → 标记失败 → 告警
p.db.Model(&OutboxMessage{}).Where("id = ?", msg.ID).
Update("status", "failed")
sendAlert("outbox_publish_failed", msg.MessageID, err.Error())
} else {
// 指数退避:2^n 分钟后重试
nextRetry := time.Now().Add(
time.Duration(math.Pow(2, float64(msg.RetryCount))) * time.Minute,
)
p.db.Model(&OutboxMessage{}).Where("id = ?", msg.ID).
Updates(map[string]interface{}{
"retry_count": msg.RetryCount,
"next_retry_at": nextRetry,
})
log.Warnf("Message send failed, retry %d/%d at %s",
msg.RetryCount, msg.MaxRetry, nextRetry)
}
}
}
}
1.1.4 使用场景
| 场景 | 描述 | 示例 |
|---|---|---|
| 订单系统 | 订单状态变更需通知下游 | 支付成功 → 通知库存、物流 |
| 库存系统 | 库存扣减需同步缓存 | 扣减库存 → 更新Redis、发送通知 |
| 账户系统 | 余额变更需记录流水 | 充值成功 → 发送积分、优惠券 |
| 审核系统 | 审核结果需通知用户 | 商品审核通过 → 发送站内信 |
场景1:订单支付成功
// 订单服务
func HandlePaymentCallback(callback *PaymentCallback) error {
return db.Transaction(func(tx *gorm.DB) error {
// 1. 更新订单状态
tx.Model(&Order{}).Where("order_id = ?", callback.OrderID).
Update("status", "PAID")
// 2. 记录支付流水
tx.Create(&PaymentRecord{
OrderID: callback.OrderID,
TransactionID: callback.TransactionID,
Amount: callback.Amount,
})
// 3. 插入消息表(在同一事务中)⭐
tx.Create(&OutboxMessage{
MessageID: fmt.Sprintf("order:paid:%s", callback.OrderID),
EventType: "order.paid",
EventPayload: json.Marshal(callback),
Status: "pending",
})
return nil
})
}
// 下游服务消费消息
func ConsumeOrderPaid(msg *OrderPaidEvent) error {
// 库存服务:扣减库存
inventoryService.DeductStock(msg.OrderID, msg.Items)
// 积分服务:增加积分
pointService.AddPoints(msg.UserID, msg.Amount * 0.01)
// 通知服务:发送短信
notificationService.SendSMS(msg.UserID, "订单支付成功")
return nil
}
场景2:库存扣减同步缓存
func DeductStock(itemID, skuID int64, quantity int) error {
return db.Transaction(func(tx *gorm.DB) error {
// 1. 扣减数据库库存
result := tx.Exec(`
UPDATE inventory_tab
SET available_stock = available_stock - ?,
booking_stock = booking_stock + ?
WHERE item_id = ? AND sku_id = ? AND available_stock >= ?
`, quantity, quantity, itemID, skuID, quantity)
if result.RowsAffected == 0 {
return errors.New("insufficient stock")
}
// 2. 记录库存变更日志
tx.Create(&InventoryChangeLog{
ItemID: itemID,
SKUID: skuID,
ChangeQuantity: -quantity,
ChangeType: "deduct",
})
// 3. 插入消息表(同步Redis缓存)⭐
tx.Create(&OutboxMessage{
MessageID: fmt.Sprintf("inventory:changed:%d:%d:%d", itemID, skuID, time.Now().Unix()),
EventType: "inventory.changed",
EventPayload: json.Marshal(map[string]interface{}{
"item_id": itemID,
"sku_id": skuID,
"quantity": -quantity,
}),
Status: "pending",
})
return nil
})
}
// 消费者:同步Redis
func ConsumInventoryChanged(msg *InventoryChangedEvent) error {
// 更新Redis缓存
redis.HIncrBy(
fmt.Sprintf("inventory:qty:stock:%d:%d", msg.ItemID, msg.SKUID),
"available",
msg.Quantity,
)
return nil
}
1.1.5 关键设计点
1. 消息幂等性
// 消费端必须做幂等处理
func ConsumeMessage(msg *kafka.Message) error {
var event OutboxEvent
json.Unmarshal(msg.Value, &event)
// 方案1:基于message_id去重(Redis)
messageID := event.MessageID
if redis.SetNX(messageID, 1, 24*time.Hour).Val() == false {
log.Infof("Duplicate message: %s", messageID)
return nil // 已处理过
}
// 方案2:基于业务唯一性(数据库唯一索引)
// 业务逻辑自带幂等保证
processBusinessLogic(event)
return nil
}
2. 消息清理
// 定期清理已发送的消息(保留7天)
func CleanupPublishedMessages() {
db.Where("status = 'published' AND published_at < ?",
time.Now().AddDate(0, 0, -7)).
Delete(&OutboxMessage{})
}
// 失败消息人工处理
func ListFailedMessages() []OutboxMessage {
var messages []OutboxMessage
db.Where("status = 'failed'").Find(&messages)
return messages
}
3. 性能优化
// 批量发送(减少数据库交互)
func (p *OutboxPublisher) publishBatch(messages []OutboxMessage) error {
// 1. 批量发送到Kafka
batch := p.kafka.NewBatch()
for _, msg := range messages {
batch.Add(msg.EventType, msg.EventPayload)
}
batch.Send()
// 2. 批量更新状态
messageIDs := extractIDs(messages)
db.Model(&OutboxMessage{}).
Where("id IN ?", messageIDs).
Update("status", "published")
return nil
}
1.1.6 常见问题与追问
Q1:本地消息表 vs 事务消息(RocketMQ)有什么区别?
| 维度 | 本地消息表 | RocketMQ 事务消息 |
|---|---|---|
| 原理 | 数据库事务 + 异步发送 | Half消息 + 回查机制 |
| 侵入性 | 中(需建表) | 低(MQ原生支持) |
| 可靠性 | 高(数据库保证) | 高(MQ保证) |
| 复杂度 | 低 | 中(需实现回查接口) |
| 性能 | 中(依赖数据库) | 高(MQ专业) |
| 适用场景 | 通用场景 | 使用RocketMQ的系统 |
Q2:消息表会不会无限增长?
// 解决方案1:定期清理(推荐)
// 保留已发送消息7天,失败消息永久保留
DELETE FROM outbox_message_tab
WHERE status = 'published' AND published_at < DATE_SUB(NOW(), INTERVAL 7 DAY);
// 解决方案2:按月分表
CREATE TABLE outbox_message_202401 LIKE outbox_message_template;
CREATE TABLE outbox_message_202402 LIKE outbox_message_template;
// 解决方案3:归档到对象存储
// 导出旧数据 → 上传OSS → 删除数据库记录
Q3:如果OutboxPublisher挂了怎么办?
保证机制:
1. ✅ 消息已持久化到数据库,不会丢失
2. ✅ OutboxPublisher重启后继续扫描发送
3. ✅ 部署多个Publisher实例(分布式锁防重复)
4. ✅ 监控告警:pending消息超过阈值告警
Q4:如何保证消息顺序?
// 方案1:按业务KEY分区(Kafka)
func (p *OutboxPublisher) send(msg *OutboxMessage) error {
// 同一订单的消息发送到同一分区
key := extractOrderID(msg.EventPayload)
return p.kafka.SendWithKey(msg.EventType, key, msg.EventPayload)
}
// 方案2:在消息中加序列号
type OrderEvent struct {
OrderID string `json:"order_id"`
Sequence int `json:"sequence"` // 1, 2, 3...
EventType string `json:"event_type"`
}
// 消费端按sequence排序处理
func ConsumeOrderEvent(msg *OrderEvent) error {
// 检查序列号,乱序则暂存
if !isExpectedSequence(msg.OrderID, msg.Sequence) {
bufferMessage(msg)
return nil
}
processMessage(msg)
processBufferedMessages(msg.OrderID)
return nil
}
Q5:消息发送失败,但业务已执行,如何补偿?
// 解决方案:允许业务回滚 or 记录失败重新发起
// 方案1:失败消息人工补发
func RetryFailedMessage(messageID string) error {
var msg OutboxMessage
db.Where("message_id = ?", messageID).First(&msg)
// 重置状态
msg.Status = "pending"
msg.RetryCount = 0
msg.NextRetryAt = nil
db.Save(&msg)
return nil
}
// 方案2:补偿事务(如果业务支持)
func CompensateOrder(orderID string) error {
// 回滚订单状态
db.Model(&Order{}).Where("order_id = ?", orderID).
Update("status", "PENDING")
// 释放库存
inventoryService.ReleaseStock(orderID)
return nil
}
1.1.7 灵魂拷问
面试官:为什么不直接在业务代码里同步发送Kafka?
回答要点:
1. ❌ 不可靠:Kafka发送失败,但DB已提交,数据不一致
2. ❌ 性能差:同步等待Kafka响应,阻塞业务线程
3. ❌ 耦合:业务代码依赖MQ,MQ故障导致业务不可用
✅ 本地消息表:
1. 业务和消息在同一事务,保证原子性
2. 异步发送,不阻塞业务
3. 解耦,MQ临时故障不影响业务
面试官:本地消息表如何保证高可用?
1. 数据库高可用:主从复制、双主
2. Publisher多实例部署:分布式锁防重复
3. 监控告警:pending消息超过阈值告警
4. 降级策略:允许短暂延迟,保证最终一致性
面试官:你们系统哪些场景用了本地消息表?
实际案例:
1. 订单支付成功:通知库存、积分、物流
2. 商品上架成功:同步Redis、ES、发送通知
3. 库存扣减:同步缓存、记录日志
4. 用户注册:发送欢迎邮件、赠送优惠券
2. Redis 与 MySQL 双写一致性
| 方案 | 流程 | 优缺点 |
|---|---|---|
| Cache Aside(推荐) | 先更新 DB → 再删 Cache | 简单,极端并发下有短暂不一致 |
| 延迟双删 | 删 Cache → 更 DB → sleep → 再删 Cache | 减少脏读窗口,sleep 时间难定 |
| Canal 订阅 Binlog | 更 DB → Canal 监听 → 异步删/更新 Cache | 最终一致性好,架构复杂 |
追问:先删缓存再更新 DB 有什么问题?
- 删缓存后,另一个请求读到旧 DB 数据并回填缓存 → 脏数据长期存在。
- 正确顺序:先更新 DB,再删缓存。即使删失败,下次读取时缓存 Miss 会加载最新数据。
3. 分布式锁
场景:防止多个节点同时操作共享资源(库存扣减、订单创建、定时任务防重)。
方案对比:
| 方案 | 实现 | 优点 | 缺点 |
|---|---|---|---|
| Redis SET NX EX | SET lock_key uuid EX 30 NX | 简单、高性能(ms 级) | 主从切换可能丢锁 |
| RedLock | N 个独立 Redis 实例多数派加锁 | 比单节点更可靠 | 争议大(Kleppmann 批评)、部署成本高 |
| ZooKeeper | 临时有序节点 + Watch | CP 模型,锁可靠 | 性能较低(~100ms) |
| Etcd | Lease + Revision | 强一致、高可用 | 实现复杂 |
Redis 分布式锁核心实现:
// 加锁:SET NX EX + UUID 防误删
func TryLock(key string, ttl time.Duration) (string, bool) {
uuid := generateUUID()
ok := redis.SetNX(key, uuid, ttl).Val()
return uuid, ok
}
// 解锁:Lua 脚本保证原子性(只删自己的锁)
func Unlock(key, uuid string) bool {
lua := `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end`
return redis.Eval(lua, []string{key}, uuid).Val().(int64) == 1
}
高频追问:
Q:锁过期了但业务没执行完怎么办?
- Watchdog 续期(Redisson 方案):后台线程每 TTL/3 续期一次,持有锁的线程异常退出则停止续期,锁自动过期释放。
Q:Redis 主从切换导致锁丢失怎么办?
- RedLock:向 N(≥5)个独立 Redis 实例加锁,多数派(≥N/2+1)成功才算加锁成功。
- 替代方案:对强一致要求高的场景(如金融),改用 ZooKeeper 或 Etcd。
Q:分布式锁 vs 数据库行锁?
- 分布式锁:跨服务、跨数据源的资源互斥。
- 数据库行锁(
SELECT ... FOR UPDATE):单库内的行级互斥,更简单但不跨库。
4. 接口幂等性
定义:同一个请求执行多次,结果与执行一次相同。
场景:网络抖动重复提交、支付回调重复通知、MQ 消息重复消费、前端重复点击。
4.1 幂等方案对比
| 方案 | 实现 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 唯一索引 | UNIQUE KEY(order_id) | 简单、可靠 | 需提前设计字段 | 创建订单、支付 |
| Token 机制 | 获取 Token → 提交时校验+删除 | 严格防重 | 多一次请求 | 表单提交 |
| 状态机 | WHERE status='UNPAID' | 业务语义强 | 需设计状态流转 | 订单、物流状态 |
| 乐观锁 | WHERE version=? | 并发控制 | 失败需重试 | 库存扣减、余额更新 |
| 分布式锁 | Redis SET NX EX | 防并发 | 性能损耗 | 高并发抢购 |
| 幂等表 | 独立表记录处理结果 | 最严格 | 存储成本高 | 支付、退款 |
4.2 调用方与被调方职责
核心原则:调用方生成幂等键,被调方实现幂等逻辑。
| 维度 | 调用方职责 | 被调方职责 |
|---|---|---|
| 幂等键生成 | ✅ 生成全局唯一ID(业务ID/UUID) | ❌ 不生成,仅验证 |
| 幂等键传递 | ✅ HTTP Header 或请求体 | ✅ 强制要求传递 |
| 重试处理 | ✅ 保持幂等键不变 | ✅ 识别重复请求 |
| 幂等逻辑 | ❌ 不实现 | ✅ 去重+返回一致结果 |
调用方示例:
func CreateOrder(req *OrderRequest) error {
// 1. 生成幂等键(只生成一次)
idempotencyKey := fmt.Sprintf("order:%d:%d", req.UserID, time.Now().Unix())
// 2. 重试时保持幂等键不变
for i := 0; i < 3; i++ {
resp, err := client.Post("/orders", &CreateOrderReq{
IdempotencyKey: idempotencyKey, // ⭐ 关键
UserID: req.UserID,
Items: req.Items,
})
if err == nil {
return nil
}
// 仅网络错误重试
if isRetryableError(err) {
time.Sleep(time.Duration(i+1) * time.Second)
continue
}
return err
}
}
被调方示例(唯一索引方案):
func (s *OrderService) CreateOrder(req *CreateOrderRequest) (*Order, error) {
order := &Order{
OrderID: req.IdempotencyKey, // 幂等键作为业务主键
UserID: req.UserID,
Amount: req.Amount,
}
// INSERT 依赖 UNIQUE KEY(order_id) 保证幂等
err := db.Create(order).Error
if isDuplicateKeyError(err) {
// 重复请求 → 查询并返回已存在的订单
db.Where("order_id = ?", req.IdempotencyKey).First(&order)
return order, nil // 幂等返回
}
return order, err
}
4.3 高级方案:幂等表
适用场景:支付、退款等核心金融操作,需最强保证。
表结构:
CREATE TABLE idempotency_record_tab (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
idempotency_key VARCHAR(64) NOT NULL UNIQUE, -- 幂等键
request_hash VARCHAR(64) NOT NULL, -- 请求参数哈希(防篡改)
response_body TEXT, -- 首次响应结果
status VARCHAR(20) NOT NULL, -- processing/completed/failed
created_at DATETIME NOT NULL,
completed_at DATETIME,
INDEX idx_key_status (idempotency_key, status)
);
实现逻辑:
func (s *PaymentService) ProcessPayment(req *PaymentRequest) (*PaymentResult, error) {
idempotencyKey := req.IdempotencyKey
requestHash := md5(req) // 请求参数哈希
return db.Transaction(func(tx *gorm.DB) (*PaymentResult, error) {
// 1. 尝试插入幂等记录
record := &IdempotencyRecord{
IdempotencyKey: idempotencyKey,
RequestHash: requestHash,
Status: "processing",
}
err := tx.Create(record).Error
if isDuplicateKeyError(err) {
// 2. 幂等键已存在 → 查询历史结果
var existingRecord IdempotencyRecord
tx.Where("idempotency_key = ?", idempotencyKey).First(&existingRecord)
// 2.1 验证请求参数是否一致(防篡改)
if existingRecord.RequestHash != requestHash {
return nil, errors.New("request mismatch")
}
// 2.2 根据状态返回
switch existingRecord.Status {
case "completed":
// 已完成 → 返回历史结果
var result PaymentResult
json.Unmarshal([]byte(existingRecord.ResponseBody), &result)
return &result, nil
case "processing":
// 正在处理 → 返回错误,让调用方稍后重试
return nil, errors.New("processing, retry later")
}
}
// 3. 首次请求 → 执行支付逻辑
result := executePayment(req)
// 4. 保存响应结果
responseBody, _ := json.Marshal(result)
tx.Model(&record).Updates(map[string]interface{}{
"status": "completed",
"response_body": string(responseBody),
"completed_at": time.Now(),
})
return result, nil
})
}
4.4 常见问题与追问
Q1:幂等键的生命周期?
- 保留 7-30 天(覆盖业务重试窗口期)。
- 定时清理:
DELETE FROM idempotency_record WHERE created_at < NOW() - INTERVAL 30 DAY。
Q2:如何防止幂等键被篡改?
- 请求参数哈希:记录
request_hash = MD5(JSON(request))。 - 重复请求时校验:
if existingRecord.RequestHash != currentHash { return error }。
Q3:Redis 实现幂等 vs 数据库?
| 方案 | 性能 | 可靠性 | 适用 |
|---|---|---|---|
| Redis SET NX | 高(ms级) | 中(持久化风险) | 高并发、短期防重(1小时内) |
| 数据库唯一索引 | 中(10ms级) | 高 | 长期防重、金融场景 |
Q4:支付回调如何保证幂等?
// 支付平台回调(可能重复通知)
func HandlePaymentCallback(callback *PaymentCallback) error {
// 1. 验证签名(防伪造)
if !verifySign(callback.Sign) {
return errors.New("invalid sign")
}
// 2. 幂等处理(唯一索引)
record := &PaymentRecord{
TransactionID: callback.TransactionID, // 第三方交易号(唯一)
OrderID: callback.OrderID,
Amount: callback.Amount,
Status: "SUCCESS",
}
err := db.Create(record).Error
if isDuplicateKeyError(err) {
// 重复回调 → 直接返回成功(幂等)
log.Infof("Duplicate callback: %s", callback.TransactionID)
return nil
}
// 3. 首次回调 → 更新订单状态
db.Model(&Order{}).Where("order_id = ?", callback.OrderID).
Update("status", "PAID")
return nil
}
数据库表结构:
CREATE TABLE payment_record_tab (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
transaction_id VARCHAR(64) NOT NULL UNIQUE, -- ⭐ 唯一索引保证幂等
order_id VARCHAR(64) NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at DATETIME NOT NULL,
INDEX idx_order (order_id)
);
Q5:MQ 消息重复消费如何幂等?
func ConsumeOrderPaidEvent(msg *kafka.Message) error {
var event OrderPaidEvent
json.Unmarshal(msg.Value, &event)
// 方案1:基于消息ID去重(Redis)
msgID := fmt.Sprintf("msg:%s", msg.Offset)
if redis.SetNX(msgID, 1, 24*time.Hour).Val() == false {
log.Infof("Duplicate message: %s", msgID)
return nil // 已处理过
}
// 方案2:基于业务唯一性(推荐)
// 使用订单ID作为幂等键,扣库存操作基于唯一索引
err := inventoryService.DeductStock(&DeductStockReq{
OrderID: event.OrderID, // 订单ID保证唯一性
ItemID: event.ItemID,
Quantity: event.Quantity,
})
return err
}
4.5 灵魂拷问
面试官:你们系统哪些接口需要幂等?
回答要点:
- ✅ 所有写操作:创建订单、支付、退款、库存扣减。
- ✅ 外部回调:支付回调、物流回调。
- ✅ MQ 消费:所有消息消费逻辑。
- ❌ 查询接口:天然幂等,无需特殊处理。
面试官:Token 机制为什么要用 Redis Lua 而不是两次调用?
// ❌ 错误:非原子操作
if redis.Exists(token) {
redis.Del(token)
// 问题:并发情况下,两个请求可能都通过检查
}
// ✅ 正确:Lua 原子操作
lua := `
if redis.call('exists', KEYS[1]) == 1 then
redis.call('del', KEYS[1])
return 1
else
return 0
end
`
result := redis.Eval(lua, []string{token})
if result == 0 {
return errors.New("duplicate request")
}
面试官:幂等设计的最佳实践?
- 唯一标识由调用方生成:调用方最了解业务语义。
- 优先使用业务主键:订单号、交易流水号等天然唯一。
- 被调方强制校验:没有幂等键直接拒绝(400 Bad Request)。
- 幂等响应保持一致:相同请求返回相同结果(包括响应码)。
- 设置合理过期时间:既要防重复,又要避免存储爆炸。
五、并发编程
1. 线程池设计
线程数设置:
- CPU 密集型:
N + 1(N = CPU 核数)。 - IO 密集型:
N × (1 + Wait/Compute)或简化为2N。
量化估算:
核心接口 RT = 500ms,目标 1 万 QPS。 单线程 QPS = 1000/500 = 2。 单机需线程数 = 10000 / 2 = 5000 → 不现实。 → 需 多台机器:如 10 台,每台承担 1000 QPS,每台 500 线程。
共享 vs 独享:
- 独享:核心业务(支付、下单),防止被边缘业务拖垮。
- 共享:非核心业务共用 Common 线程池。
监控:暴露 activeCount, queueSize, completedTaskCount,队列 >80% 告警。
2. 异步并行优化
场景:接口串行调用 A(用户信息)、B(积分)、C(优惠券),总耗时 T = Ta + Tb + Tc。
优化:CompletableFuture (Java) / errgroup (Go) 并行调用,T = max(Ta, Tb, Tc)。
风险与应对:
- 并行度过高 → 下游瞬时压力倍增 → 配合限流和熔断。
- 部分失败 → 降级返回默认值(如积分返回 0)。
- 长尾超时 →
orTimeout(500ms)强制超时。
六、中间件选型与原理
1. 消息队列选型
| 维度 | Kafka | RocketMQ | RabbitMQ |
|---|---|---|---|
| 吞吐量 | 极高(百万级 TPS) | 高(十万级) | 中(万级) |
| 延迟 | ms 级 | ms 级 | us 级 |
| 事务消息 | 不支持 | 支持 | 不支持 |
| 延迟队列 | 不原生 | 支持 | TTL + DLX |
| 适用场景 | 日志、大数据 | 金融、电商 | 中小规模、复杂路由 |
为什么用 MQ?
- 解耦:上游不需要知道有几个下游消费者。
- 异步:主流程快速返回,耗时操作后台处理。
- 削峰:MQ 缓冲突发流量,消费者匀速消费,保护 DB。
消息不丢失(三环节保障):
- 生产者:同步发送 + 失败重试。
- Broker:同步刷盘 (
SYNC_FLUSH) + 主从同步。 - 消费者:处理成功后再手动 ACK。
消息重复:消费端做幂等(唯一索引/状态机)。
消息积压:先扩容消费者 → 排查消费阻塞原因 → 必要时跳过非关键消息。
2. Redis 核心问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 缓存穿透 | 查不存在的数据 | Bloom Filter / 缓存空值 |
| 缓存击穿 | 热点 Key 过期 | 互斥锁(Mutex) / 逻辑过期 |
| 缓存雪崩 | 大量 Key 同时过期 | 随机过期时间 / 多级缓存 |
| Big Key | 阻塞主线程 | 拆分 / UNLINK 异步删除 |
Key 过期内存释放:
- 惰性删除:访问时才检查是否过期。
- 定期删除:每秒随机抽取 20 个 Key 检查。
- 陷阱:Redis 并非过期立即释放。从库不主动删,等主库发 DEL 命令 → 可能出现“主库内存正常,从库爆满“。
3. MySQL 分库分表
拆分策略:
- 垂直拆分:按业务拆库(用户库、订单库),按字段拆表(大字段独立)。
- 水平拆分:按
Hash(UserID)或Range(Time)分散数据行。
核心难题:
- 分布式 ID:Snowflake / 号段模式。
- 跨库 Join:应用层组装,或宽表冗余。
- 非 Sharding Key 查询:按 UserID 分片后,商家查订单(MerchantID)怎么办?→ 异构索引表,另建一套按 MerchantID 分片的表(或同步到 ES)。
- 在线扩容:双写迁移 → Canal 同步增量 → 灰度切读 → 切写。
索引高频考点:最左前缀、回表与覆盖索引、索引失效(函数/隐式转换/!=/LIKE '%xx')、深分页优化(WHERE id > last_id LIMIT 10)。
4. Elasticsearch 架构
日增 1TB 场景设计:
- 冷热分离:Hot(SSD,最近 3-7 天)→ Warm/Cold(HDD,历史数据)。
- 分片:单分片 30-50GB,主分片创建后不可修改。
- Rollover:按时间/大小自动滚动创建新索引。
查询优化:
- 避免
wildcard,改用ngram分词器。 - 精确匹配用
keyword类型。 - 深分页用
search_after替代from + size。
5. ClickHouse
- 适用:日志分析、报表、OLAP 大屏、用户行为分析。
- 快的原因:列式存储 + 数据有序 + 向量化执行。
- 不适合:高并发单行查询、频繁 UPDATE。
七、安全
1. 密码存储
问题:为什么只能重置密码,不能找回原密码?
回答:密码存储的是 bcrypt(password + salt) 的不可逆哈希值。即使数据库泄露,攻击者也无法还原明文。
- Salt(盐):随机字符串,防彩虹表。即使两人密码相同,Hash 也不同。
- 为什么用 bcrypt 而非 SHA256? bcrypt 是慢哈希,故意设计得慢(可调 cost 参数),暴力破解成本极高。SHA256 太快,GPU 每秒可算数十亿次。
2. 常见攻防
| 攻击 | 防御 |
|---|---|
| XSS | 输出转义、CSP 头、HttpOnly Cookie |
| CSRF | CSRF Token、SameSite Cookie |
| SQL 注入 | 预编译(#{} 而非 ${}) |
| 重放攻击 | 签名 + 时间戳 + nonce + 设备指纹 |
3. HTTPS 握手
- 服务端下发证书(含公钥)。
- 客户端验证证书合法性。
- 客户端生成随机对称密钥,用公钥加密传给服务端。
- 后续通信使用对称加密。
一句话:非对称加密传密钥,对称加密传数据。
八、可观测性
1. 三大支柱
| 支柱 | 工具 | 核心 |
|---|---|---|
| Logging | Filebeat → Kafka → ES → Kibana | 结构化 JSON 日志,含 trace_id |
| Metrics | Prometheus + Grafana | 黄金信号:延迟、流量、错误率、饱和度 |
| Tracing | Jaeger / Zipkin | TraceID 串联全链路 |
2. “接口突然变慢“排查套路
- 看链路 (Tracing):哪一跳耗时突增?
- 看指标 (Metrics):DB CPU 飙升?MQ 积压?线程池满?
- 看日志 (Logging):是否有异常堆栈?
- 对比变更:最近是否上线/扩容/配置变更?
止血第一:先回滚或切流量,再定位根因。
九、云原生与弹性架构
1. 服务网格 (Service Mesh)
- Istio + Envoy Sidecar:实现熔断、限流、灰度发布,无代码侵入。
2. 弹性伸缩
- HPA:基于 CPU/内存/QPS 自动扩缩容。
- KEDA:基于事件驱动(如 MQ 积压量)扩缩容。
3. Serverless
- 适用:突发流量、定时任务、Webhook。
- 限制:冷启动延迟(秒级)、执行时长上限。
十、计算机基础
1. 为什么 0.1 + 0.2 != 0.3?
- IEEE 754:二进制无法精确表示 0.1 和 0.2(无限循环小数),相加后精度丢失。
- 0.1 + 0.1 == 0.2:两次相同的舍入误差在低位恰好抵消。
- 解决:金额计算必须用
Decimal类型(定点数)或转为整数(分)计算。
2. TCP 三次握手为什么不能两次?
- 两次握手无法防止历史连接初始化:旧的 SYN 包延迟到达,服务端误建连接,浪费资源。
- 三次握手确保双方都确认对方的收发能力正常。
十一、面试灵魂拷问
Q:系统瓶颈在哪?怎么优化? 先定位(DB?Redis?MQ?外部接口?)→ 再给方案(索引/分库/缓存/异步/并行/批量化)。
Q:流量突增 10 倍怎么扛? 限流(挡住超量)→ 扩容(水平加机器)→ 缓存(减少穿透)→ 异步(削峰填谷)→ 降级(保核心)。
Q:线上故障排查流程? 止血(回滚/切流)→ 看监控 → 看日志 → 看调用链 → 定位根因 → 修复 → 复盘。
Q:分布式系统最难的是什么? 网络不可靠、时钟不一致、节点随时会挂。核心矛盾是 CAP 取舍:金融选 CP(强一致),互联网选 AP(最终一致)。
Q:方案有什么副作用? 面试加分项——主动说出 trade-off。例如:“虽然异步解耦了,但增加了链路追踪的复杂度和排查成本。”
一句话速查(重点题)
| 题目 | 核心方案 | 一句话总结 |
|---|---|---|
| 电商系统全景 | 交易主链路 + 支撑域 | 先定义权威状态,再拆同步与异步 |
| 商品中心/上架 | SPU/SKU + 审核发布 | 草稿 → 审核 → 发布 → 下线 |
| 营销/优惠券 | 预算 + 资格 + 锁券 | 核销幂等 + 对账防资损 |
| 价格/计价 | 价格组成 + 复算 | 试算和结算必须复用同一计价服务 |
| 购物车/结算 | 合并购物车 + 结算重算 | 下单前重算价格、库存、营销 |
| 订单系统 | 状态机 + 幂等 + 超时 | 创单幂等,失败异步补偿 |
| 支付/退款/对账 | 回调幂等 + 退款状态机 | 渠道回调不等于资金安全 |
| 秒杀系统 | Redis Lua + MQ | 预热缓存 + 异步扣减 + 限流防刷 |
| 分布式限流 | 令牌桶 + Redis Lua | 允许突发,分布式用 Redis |
| 热点发现 | 本地缓存 + Key 分散 | 探测 → 拦截 → 分散 → 隔离 |
| 40 亿去重 | Bitmap | 512MB 精确去重 |
| 排行榜 | Redis ZSet | 分桶 + 归并解决大 Key |
| 海量排序 | 外部排序 + 多路归并 | 分块排序 → 小顶堆归并 |
| 订单超时取消 | 延迟消息 / ZSet | 长延迟用 MQ,短延迟用时间轮 |
| 分布式 ID | Snowflake | 1+41+10+12 = 64bit,4096/ms |
| 短链接 | 发号器 + Base62 | 6 位可表示 568 亿 |
| Feed 流 | 推拉结合 | 普通用户推,大 V 拉 |
| 红包算法 | 二倍均值法 | 预分配 + LPOP 原子弹出 |
| 分布式事务 | 事务消息 / TCC | 电商用事务消息,金融用 TCC |
| 分布式锁 | Redis SET NX EX | Lua 原子解锁 + Watchdog 续期 |
| 缓存一致性 | Cache Aside | 先更新 DB,再删 Cache |
| 接口幂等 | 唯一索引 / Token | 调用方生成 Key,被调方校验 |
参考
相关文章
- 系统设计完全指南:从零基础到面试高手
- {% post_link fundamentals/08-redis Redis 原理与实践 %}
- {% post_link fundamentals/09-kafka 异步和消息队列 %}
- {% post_link fundamentals/10-elasticsearch 搜索和 Elasticsearch %}
- 电商系统设计
- {% post_link system-design/07-system-reliability-engineering 系统稳定性建设:方法论与实践 %}
- 多品类统一库存系统设计
- System Design Primer 系统设计题库