Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

系统设计与架构实战:原理、工程与电商案例

这是一本面向中高级工程师、技术负责人和架构师的系统设计实践书。全书以系统设计方法论为主线,结合中间件、可靠性工程、工程治理和电商业务案例,帮助读者把“会画架构图”推进到“能落地、能治理、能复盘”的工程能力。

本书强调三个判断:

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

适合读者

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

本书结构

  1. 系统设计方法论(第 1-9 章):问题定义、方案设计、业务边界、内部结构、系统集成、架构质量与编码落地,以及可靠性与工程治理(对账补偿、DLQ、故障恢复、资损防控、容量规划、限流、熔断和降级)。
  2. 基础设施与计算机基础(第 10-22 章):MySQL、Redis、Kafka、Elasticsearch、Kubernetes、全局 ID 与技术选型,以及操作系统、网络、Shell、Python、C++、Go。
  3. 电商系统设计实战(第 24-36 章):电商全景、商品、库存、营销、计价、搜索、购物车、订单、支付、供应商同步、供给治理和 B2B2C 综合案例。
  4. 系统设计题库(第 37-40 章):面向候选人的结构化练习与面向面试官的评估,覆盖通用系统设计问题、电商专项题库,以及限时模拟面试。

全书逐章速览

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

章节主题内容速览阅读收获
第 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 章MySQL 存储与数据库介绍 MySQL 在电商系统中的定位,覆盖 InnoDB、表设计、索引、事务、分库分表和线上治理。从访问模式和一致性要求倒推数据库建模与索引设计。
第 11 章Redis 缓存实践梳理 Redis 数据结构、持久化、复制、集群、缓存一致性、分布式锁和热点治理。设计缓存、锁和热点保护时能避开常见误区。
第 12 章Kafka 消息队列与异步讲解 Topic、分区、消费组、顺序性、可靠性、幂等、高吞吐、积压和回压处理。能用消息队列拆解异步链路,并治理重复、乱序和积压。
第 13 章Elasticsearch 搜索与索引覆盖倒排索引、分片副本、写入查询链路、Mapping、分词、相关性、深分页和集群治理。建立搜索读模型、索引设计和性能优化的基础判断。
第 14 章Kubernetes 与 Docker从容器化价值与边界讲到 Docker 模型、Kubernetes 对象、调度发布、服务治理、网络配置和资源管理。能理解容器平台如何支撑发布、伸缩、隔离和故障排查。
第 15 章全局 ID 与基础服务说明电商 ID 分类、全场景 ID 清单和 DB、Redis、雪花算法等发号方案的能力边界。为订单、商品、库存等核心对象选择可扩展的 ID 体系。
第 16 章技术栈选型按选型顺序、组件职责边界、电商示例和评审清单,讨论技术方案为什么选、何时不选。能把技术选型转化为可解释、可复盘的架构决策。
第 17 章操作系统基础复习进程线程、调度、虚拟内存、文件系统、I/O、并发同步和死锁等工程常用知识。用操作系统视角解释性能、资源和并发问题。
第 18 章计算机网络实践覆盖网络分层、TCP、UDP、HTTP、HTTPS、DNS、CDN、代理和常见网络问题排查。能把网络协议知识应用到链路设计、延迟分析和故障定位。
第 19 章Bash 与 Shell 实用讲解 Shell 基础、命令组织、文本处理、管道、grep、sed、awk、日志排查和自动化脚本。提升命令行排查、批处理和轻量自动化能力。
第 20 章Python 实践介绍 Python 的工程用途、常用语法、标准库、自动化、数据处理、调试、性能和常见坑。用 Python 快速完成工具脚本、数据处理和验证任务。
第 21 章C++ 实践聚焦内存模型、对象生命周期、STL 容器算法、字符串处理、性能优化和常见陷阱。理解 C++ 性能和内存风险,支撑底层问题分析。
第 22 章Go 语言实践梳理 Go 的核心设计、goroutine、channel、接口、组合、错误处理、网络服务和并发实践。用 Go 设计清晰的并发服务和工程化后端模块。

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

章节主题内容速览阅读收获
第 24 章电商系统全景从业务架构、应用架构、数据架构和技术架构拆解电商核心域与支撑域,建立交易与支付主线。用全景图识别电商系统边界、依赖和核心风险。
第 25 章商品中心系统说明商品中心的定位、边界、架构分层、读写分离、SPU/SKU、类目属性和对外发布契约。设计稳定的商品主数据系统,并避免被展示和交易逻辑污染。
第 26 章商品供给与生命周期治理围绕供给入口、商品创建编辑、运营审核、生命周期治理和专项链路,分析商品不是简单 CRUD 的原因。设计覆盖创建、编辑、审核、发布和治理的商品供给平台。
第 27 章库存系统讨论库存的通用模型、虚拟库存、可售锁定已售、并发扣减、超卖防控、释放和对账补偿。区分热路径和权威路径,设计高并发且最终正确的库存系统。
第 28 章营销系统覆盖优惠券、积分、活动规则、预算控制、资格校验、试算、锁定、核销、释放和大促降级。设计可叠加、可核销、可控资损的营销能力。
第 29 章计价系统分析商品价格、营销优惠、会员权益、税费和运费如何合成为可解释、可追溯、可复算的价格结果。设计可防篡改、可复算、能跨页面保持一致的计价链路。
第 30 章搜索与导购区分搜索和导购,围绕统一查询服务、场景识别、查询编排、索引同步、筛选排序和降级展开。构建商品发现读路径,并治理搜索体验与主数据一致性。
第 31 章购物车与结算说明购物车和结算域的边界,覆盖未登录加购、登录合并、结算校验、价格库存营销重算和重复提交防护。把用户意图暂存和交易前强校验拆成清晰职责。
第 32 章订单系统从业务场景、状态机、订单数据模型、创单幂等、库存营销支付协作和异步补偿展开订单核心设计。设计以状态和交易事实为中心的订单系统。
第 33 章支付系统讲解支付单、渠道网关、回调幂等、重复乱序处理、退款状态机、对账和资金安全。设计可信资金状态,避免把渠道回调等同于本地交易完成。
第 34 章B2B2C 平台架构以机票、酒店等品类业务模型为背景,串联供应商、平台、用户、交易、履约和结算的完整架构约束。从多角色、多品类、多链路角度设计平台型电商架构。
第 35 章供给、库存、审核与运营全生命周期把商品创建、编辑、库存运营、生命周期治理和供应商协同放入一条端到端供给治理链路。设计供给侧全生命周期系统,并处理状态、审核、同步和恢复。
第 36 章C 端交易全生命周期从搜索导购、详情页、加购、结算、下单、支付、履约到售后,梳理 C 端用户完整交易动作。按用户旅程设计交易主链路,并识别每阶段的系统风险。

第四部分:系统设计题库

章节主题内容速览阅读收获
第 37 章系统设计题库:使用说明、双索引与训练路线说明候选人与面试官模式、统一作答框架、能力与场景双索引,以及训练路线。能选择题目并开展结构化练习或评估。
第 38 章通用系统设计题库通过规范题卡覆盖需求、容量、数据一致性、可靠性、中间件与架构演进。能练习通用设计判断,并以一致标准评估作答。
第 39 章电商系统设计专项题库将全部遗留电商问题、追问、作答辅助与案例整合为五个专题。能练习电商领域建模、交易、峰值流量与故障恢复。
第 40 章系统设计模拟面试提供关联规范题卡的 30、45 和 60 分钟模拟面试。能演练时间控制、递进追问与自我复盘。

阅读路线

  • 架构主线:第 1-9 章,配合第 10-16 章中间件。适合希望建立完整系统设计框架的读者。
  • 电商实战主线:第 24-36 章。适合正在做交易系统、供给系统、支付系统或平台型业务的读者。
  • 可靠性主线:第 3、4、7 章,结合第 32-33 章订单和支付阅读。适合做稳定性、故障恢复和资损防控治理。
  • 面试主线:第 37-40 章。先从第 37 章了解题库使用方式,再选择通用或电商专项题库,最后通过第 40 章进行限时演练。适合把工程经验整理为系统设计表达。
  • 基础补齐主线:第 17-22 章。适合查漏补缺,补齐系统设计背后的计算机基础和编码能力。

使用建议

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

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

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

本地构建

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

cd books/system-design-architecture-book
mdbook build
mdbook serve

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

npm run server:system-design-architecture-book

启动后访问:

http://localhost:3000/system-design-architecture-book/

第 1 章 系统设计与方案写作:从问题定义到工程落地的决策方法

从系统设计总论到技术方案落地,建立架构师的问题判断、方案表达与评审推进能力。

很多人把系统设计理解成“高并发组件大全”,或者把技术方案理解成“把接口、表结构和流程图拼在一起的文档”。这两种理解都抓住了一部分现实,但都不够完整。

系统设计真正关心的是:在业务目标、资源预算和风险约束下,如何做出一条可落地、可演进、可运维的系统路径。技术方案真正关心的是:如何把这条路径写成一份可评审、可执行、可复盘的设计文档,让不同角色都能基于同一份材料做判断和协作。

所以这一章不再把“系统设计通识”和“技术方案设计方法论”割裂开来,而是把它们放在同一个认知骨架里:

  • 前半部分回答“系统设计到底在权衡什么”
  • 后半部分回答“架构师如何把这些权衡写成一份真实可执行的 TD”

读完这一章,你应该至少能回答三件事:

  • 系统设计不是背组件,而是做边界清晰的工程决策。
  • 架构师写 TD 不是堆图和堆接口,而是把关键取舍显式化。
  • 一份好的技术方案,既要让评审者看懂,也要让实现者、测试和运维真正能落地。

1. 系统设计总论

1.1 系统设计的核心问题

系统设计并不是“把几个中间件背熟”,而是在给定业务目标、资源预算和风险约束下,设计一条可落地、可演进、可运维的系统路径。无论是做业务系统还是准备面试,真正反复出现的核心问题其实都很稳定:

  • 系统要解决什么问题,成功指标是什么。
  • 请求链路会经过哪些模块,哪里最容易成为瓶颈。
  • 数据放在哪里,以什么方式读写、复制、缓存和恢复。
  • 当流量上涨、依赖抖动、机器故障时,系统如何维持可接受的服务质量。
  • 当成本、人力和交付时间有限时,哪些复杂度值得引入,哪些应该延后。

很多人把系统设计理解成“画一张架构图”。架构图当然重要,但它只是表达形式,不是设计本身。真正的设计工作包括问题定义、容量估算、模块划分、数据模型、一致性策略、容灾方案、可观测性,以及对各种取舍的显式说明。

如果把系统设计抽象成一句话,可以理解为:围绕性能、容量、可靠性与成本,持续做出边界清晰的工程决策。

1.2 性能、容量与成本的基本权衡

系统几乎不可能同时在所有维度上最优。多数设计问题,最后都落到性能、容量与成本三者之间的平衡。

性能不是只看响应时间

性能通常至少包含两个维度:

  • 延迟:单个请求从发起到完成需要多久。
  • 吞吐:单位时间内系统能处理多少请求或任务。

低延迟并不等于高吞吐。一个同步串行、逻辑简单的服务可以把单次请求做得很快,但在高并发下很容易被线程、连接数或数据库写入能力卡住。反过来,批处理、异步削峰、顺序写磁盘等策略可能提升整体吞吐,却拉高了单次请求完成时间。

容量估算决定方案上限

很多架构失误不是技术不会,而是没有先做数量级判断。至少要回答这些问题:

  • 峰值 QPS 和平均 QPS 分别是多少。
  • 读写比是多少,热点是否集中。
  • 单条数据多大,按日、按月的增长量如何。
  • 可接受的响应时间、失败率、恢复时间分别是多少。

例如,一个读多写少的商品详情系统,常见手段是缓存、读写分离、CDN 和多级副本;而一个写多且顺序性要求高的订单流水系统,就更关注数据库写入能力、消息队列削峰和分库分表策略。同样的“高并发”标签,背后对应的设计完全不同。

成本不仅是机器费用

设计里最容易被低估的,是复杂度带来的隐性成本:

  • 开发成本:系统越分散,联调、测试和发布成本越高。
  • 运维成本:组件越多,监控、告警、升级、容灾演练越重。
  • 认知成本:团队越难理解系统,越容易出现误用和故障。
  • 迁移成本:未来替换存储、重构链路、扩容架构时的代价。

所以系统设计不是“配置越豪华越好”,而是“当前阶段最合适”。对小流量业务,上来就做多机房多活通常是不划算的;对资金交易、库存扣减等核心链路,过度节省又会把风险留到线上。

1.3 一致性、可用性与扩展性的取舍

分布式系统设计之所以难,是因为很多需求天然彼此拉扯。

一致性与可用性

一旦进入副本复制、跨机房部署和异步链路,一致性就出现层次差异:

  • 强一致性:适合金融交易、余额扣减、强约束库存。
  • 最终一致性:适合评论计数、消息通知、搜索索引同步。
  • 会话一致性或读己之写:适合用户中心、个人设置等体验敏感场景。

一致性要求越强,往往意味着写入路径更重、故障场景更复杂、可用性成本更高。相反,如果系统接受短暂不一致,就能利用缓存、异步复制、消息队列和多副本读扩展获得更好的可用性与吞吐。

可扩展性不是简单“加机器”

扩展性至少分三类:

  • 垂直扩展:升级单机 CPU、内存、磁盘。
  • 水平扩展:增加实例、副本、分片。
  • 功能扩展:在不推翻既有系统的前提下增加新能力。

水平扩展是互联网系统的主路线,但代价是系统从“本地调用”变成“远程协作”:需要负载均衡、服务发现、分布式缓存、消息重试、链路追踪、配置管理,以及对网络分区和局部失败的处理。

常见权衡例子

场景优先目标常见手段代价
商品详情、资讯流可用性与读性能CDN、缓存、读写分离可能读到旧数据
订单创建、支付扣减一致性与正确性事务、幂等、串行化关键写路径吞吐更低,链路更重
日志采集、行为埋点吞吐与扩展性批量、异步、消息队列端到端延迟更高
搜索与推荐可扩展性与演进速度索引异步构建、近实时更新数据收敛有延迟

成熟的答案不是“既强一致又高可用还能无限扩容”,而是先确认业务优先级,再说明愿意牺牲什么、换来什么。

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

系统设计能力真正的分水岭,在于能否完成从单机程序思维到分布式系统思维的转变。

在单机开发中,我们默认很多事情天然成立:

  • 函数调用几乎总能立即返回。
  • 内存共享简单直接。
  • 时钟、状态和数据都集中在本地。
  • 故障通常表现为进程退出,而不是链路局部异常。

这些默认前提一旦搬到分布式环境,大部分都失效了。进入分布式系统后,你必须默认以下事实:

  • 网络不可靠,调用可能超时、重试、乱序、重复到达。
  • 带宽有限,跨机房通信代价高于机房内调用。
  • 节点会宕机,副本会延迟,拓扑会变化。
  • 远程调用比本地调用慢几个数量级。
  • 局部故障很常见,系统要学会降级而不是等全局恢复。

也正因为如此,系统设计会反复出现这些基础设施组件:DNS、负载均衡、反向代理、缓存、消息队列、服务发现、监控告警、链路追踪。它们本质上都是为了应对“单机默认成立、分布式默认不成立”的现实。

1.5 常见系统设计误区

误区一:只背组件,不理解问题

把 MySQL、Redis、Kafka、Elasticsearch、Kubernetes 分别背得很熟,并不自动等于会做系统设计。组件只是手段,关键是知道为什么引入、什么时候不用、出现问题如何兜底。

误区二:先讲方案,后补需求

很多设计讨论一开始就说“这里上 Redis、那里上 MQ”。如果需求边界、读写模型、容量目标都没澄清,方案几乎注定会跑偏。

误区三:忽略数量级

“数据量很大”“流量很高”这种说法几乎没有工程价值。没有 QPS、峰值、存储增长、延迟目标,设计就是悬空的。

误区四:过度追求完美架构

新业务起步阶段最需要的往往不是“终局架构”,而是足够稳定且便于迭代的起点。过早引入微服务、多活、复杂编排,常常会先把团队拖垮。

误区五:只考虑正常流程,不考虑失败流程

一个设计如果没有回答超时、重试、幂等、限流、熔断、降级、回滚、监控这些问题,那它往往只能在 PPT 上运行。

误区六:把面试答案讲成组件清单

好的系统设计表达不是罗列名词,而是沿着“需求 -> 估算 -> 方案 -> 取舍 -> 风险”的顺序推进,让听者知道你是在做工程决策,而不是在背模板。

1.6 系统设计总论小结

系统设计的核心不在于记住多少术语,而在于能否围绕业务目标做出清晰、可解释、可落地的工程选择。到这里先建立三个基本视角:

  • 用性能、容量、成本一起看问题,而不是只盯单点优化。
  • 用一致性、可用性、扩展性的冲突理解分布式设计的难点。
  • 用分布式现实校正单机直觉,接受网络、节点和依赖都会失败。

但对架构师来说,这还不够。真正的挑战不是“想清楚”,而是“写清楚、讲清楚、推动落地”。这也是为什么懂系统设计,最后会自然过渡到会写技术方案。


2. 技术方案设计方法论

2.1 为什么架构师必须会写 TD

很多技术方案质量不高,不是因为实现能力不足,而是因为问题定义模糊、边界不清、关键取舍没有显式化。一个成熟的架构师,不能只会想方案,还必须会把方案写成一份不同角色都能消费的 TD(Technical Design)。

一份好的 TD 至少承担五个作用:

  • 对齐理解:让业务、研发、测试、运维对目标和边界达成一致。
  • 显式决策:把关键取舍写出来,而不是藏在作者脑子里。
  • 组织协作:让跨团队依赖、前置条件、owner、上线顺序变得可讨论。
  • 降低返工:在实现前暴露问题,比上线后补锅便宜得多。
  • 沉淀知识:后续 review、复盘、交接、新人 onboarding 都能复用。

很多人把 TD 写成三种危险形态:

  • 方案写成实施手册:命令、SQL、接口、页面路径很多,但核心设计决策不清楚。
  • 方案写成大纲汇总:背景、目标、接口、排期都列了,但没有一条主链路和关键判断。
  • 方案写成记录本:会议纪要、争议点、草图、截图都堆在一起,读者无法快速定位“这份方案最终到底怎么定”。

架构师写 TD 的价值,不是文档漂亮,而是让评审者能在有限时间内看清楚:

  • 这件事为什么值得做
  • 这次到底怎么做
  • 为什么这么做
  • 哪些地方有风险
  • 如果做坏了怎么退

2.2 什么时候需要写技术方案与 TD

不是每个需求都值得写一份完整 TD,但也绝不能把所有设计工作都压缩成几句口头说明。一个简单判断标准是:当一件事已经不是“直接写代码就能安全落地”,而是需要提前澄清边界、做关键取舍、控制风险、对齐多人协作时,就应该写 TD。

从实践看,下面几类场景最适合进入 TD:

  • 涉及新业务能力:例如支付、推荐、实时协作、新履约链路、新审批机制。这类需求往往要同时定义模型、接口、状态机和扩展点。
  • 涉及核心链路变更:如下单、支付、库存、账户、计价、发布、配置生效等主流程。主链路一旦改动,正确性和回滚成本都会显著上升。
  • 涉及核心数据变更:新表设计、字段语义调整、数据迁移、双写回切、索引重建、统计口径变更。这类改动最难回滚,也最容易留下长期隐患。
  • 涉及架构演进或重构:单体拆分、服务边界重划、存储替换、链路异步化、读写分离、缓存前置。此时重点不只是“新方案是什么”,还包括“旧系统怎么安全过渡”。
  • 涉及外部依赖或跨团队协作:第三方接入、遗留系统对接、多团队联合交付、多服务契约调整。只靠口头同步很容易出现理解偏差。
  • 涉及高风险上线:容量提升、稳定性治理、容灾切换、灰度放量、合规安全改造。越是“出一次问题就代价很大”的需求,越需要把方案显式写出来。

反过来说,常规 bugfix、边界清楚的小型优化、明确职责范围内的简单 CRUD 改动,通常不需要单独写完整 TD,在工单、设计备注或 Merge Request 里写清实现思路即可。

真正成熟的团队,不是“所有事都写 TD”,而是知道什么时候必须写、写到什么深度、谁需要参与评审。这一点决定了后续方法论能否真正高效落地。

2.3 技术方案 / TD 的常见类型

判断“要不要写”之后,下一个问题不是立刻套模板,而是先判断:这次到底是哪一类方案。 不同类型的 TD,关注点、交付物和评审方式都不一样。为了便于团队使用,我更推荐把常见 TD 收敛成四大类型。

业务特性开发型 TD(New Feature TD)

这是最常见的一类,服务于新功能、新流程、新业务能力的落地。

典型场景:新增支付方式、推荐能力、营销玩法、审批流、履约节点、实时协作特性。

设计重点

  • 领域模型是否清晰,核心概念是否稳定。
  • 接口契约、前后端交互和服务间协议是否明确。
  • 状态流和异常路径是否被显式建模。
  • 未来如果扩展新渠道、新规则、新租户,是否容易演进。

架构演进与重构型 TD(Architecture Evolution / Refactoring TD)

当旧架构已经不适配新阶段业务时,就进入这一类。

典型场景:单体拆分、服务边界重划、数据库拆分、核心链路改造、老模块重写、跨代架构替换。

设计重点

  • 当前系统的痛点是什么,是否有足够事实支撑。
  • 目标形态是什么,新旧架构的关键差异在哪里。
  • 迁移路径如何设计,包括灰度、双写、对账、回切和回滚。
  • 如何做到“在高速公路上换轮胎”,尽量不影响线上存量用户。

性能优化与容量规划型 TD(Performance / Capacity TD)

这类 TD 通常不是由产品需求驱动,而是由技术指标或大促目标驱动。

典型场景:QPS 提升、RT 降低、热点优化、缓存改造、异步削峰、数据库扩容、双十一备战。

设计重点

  • 先用压测、APM、火焰图、SQL 分析等手段证明瓶颈在哪里。
  • 明确优化手段与收益预期,是缓存、异步、分片、索引还是架构调整。
  • 给出容量模型、资源预算和稳定性兜底策略。
  • 说明优化后的新风险,例如缓存一致性、雪崩、回源放大、限流误伤。

技术选型与基础设施型 TD(Tech Selection / Infra TD)

当团队要引入新技术、替换基础组件或升级工程平台时,通常属于这一类。

典型场景:数据库替换、消息中间件迁移、引入 AI 能力、CI/CD 重构、日志与监控平台升级。

设计重点

  • 为什么选 A,不选 B 和 C。
  • PoC 是否覆盖了最关键的真实业务场景。
  • 学习成本、运维成本、组织适配成本是否可接受。
  • 如果未来发现不合适,退出策略和迁移路径是什么。

为什么先分类,再写文档

因为分类本质上是在回答三件事:

  • 这篇文档的核心读者是谁。
  • 这次评审最应该挑战什么。
  • 文档该详写模型和接口,还是详写迁移与回滚,还是详写压测和容量。

如果类型判断错了,作者就很容易出现两种偏差:要么对一个简单功能写成“大而全”的架构长文,要么对一次高风险重构只写了几张接口图。前者浪费团队时间,后者直接把风险带进线上。

2.4 需求澄清与问题定义

在进入方案设计前,至少要回答以下问题:

  • 这个系统服务谁,核心使用场景是什么。
  • 当前方案或旧系统的痛点是什么。
  • 哪些指标是必须达成的,例如延迟、吞吐、稳定性、正确性。
  • 哪些内容属于非目标,本期明确不做。
  • 外部依赖有哪些,是否受别的团队、第三方服务或基础设施约束。

一个常见错误是把“背景”写成“解决方案摘要”。背景部分应该聚焦现状、问题和目标,而不是提前给出实现细节。否则评审者很难区分哪些是事实,哪些是方案假设。

目标与非目标必须显式写

设计不是追求完美,而是追求平衡。把目标和非目标显式写出来,有两个好处:

  • 帮助评审者理解你的取舍依据。
  • 防止项目在推进过程中不断被隐性扩 scope。

例如,一个面向内部运营的报表系统,本期目标可能是“支持 T+1 数据更新、百人级并发查询、操作留痕”,非目标则可能包括“实时分析”“跨地域多活”“统一对外开放 API”。这些边界会直接影响存储、链路和投入方式。

2.5 容量估算与真实约束

好的方案不是从组件清单开始,而是从数字开始。数字不需要百分之百精确,但必须足够支撑决策。

为什么容量估算是设计入口

容量估算回答的是两个问题:

  • 方案大概要承受多大规模。
  • 哪些约束会成为架构边界。

缺少数量级,很多讨论都会变成空话。比如“是否需要分库分表”“要不要上消息队列”“缓存层值不值得做”,都依赖数据规模与流量模型。

至少要估哪些数字

常见估算维度包括:

  • 请求量:平均 QPS、峰值 QPS、突发流量倍数。
  • 数据量:单条大小、日增量、冷热分布、保留周期。
  • 读写模型:读多写少、写多读少、是否存在热点。
  • 时延目标:接口 RT、异步任务完成时间、批处理窗口。
  • 可用性目标:SLA、可接受失败率、恢复时间目标。
  • 外部约束:预算、人力、上线时间、合规、安全审计。

识别真实约束,而不是理想约束

工程里最重要的约束往往不是技术本身,而是现实条件:

  • 项目只有两周交付,意味着不能引入过多新基础设施。
  • 团队没人维护分布式存储,意味着方案要优先复用成熟组件。
  • 依赖下游 SLA 很弱,意味着要提前设计降级、重试和隔离。
  • 数据涉及权限和审计,意味着安全、追踪和权限模型必须前置。

容量估算的意义,不只是为了“算多大”,更是为了知道系统最先会被什么限制住。

2.6 核心链路与职责拆解

当问题和约束清楚之后,才进入架构拆解。拆解的目标不是把图画复杂,而是把职责边界、调用方向和关键路径讲明白。

一个合格的高层设计,通常应该先回答:

  • 系统由哪些核心模块组成。
  • 请求从入口到结果返回,会经过哪些关键环节。
  • 哪些模块在主链路上,哪些是异步旁路。
  • 哪些依赖来自外部系统,失败时如何影响主流程。

核心链路比全景图更重要

很多设计评审失败,不是因为没有覆盖面,而是没有突出核心链路。所谓核心链路,就是最能决定系统价值和风险的那条主路径。例如:

  • 下单系统中的“校验库存 -> 扣减库存 -> 生成订单 -> 支付确认”
  • 内容平台中的“写入内容 -> 审核 -> 建索引 -> 用户检索”
  • 配置发布系统中的“冻结输入 -> 构建产物 -> 切换版本 -> 验证可读 -> 收敛状态”

如果核心链路没有讲清楚,后面的高可用、扩展性和风险管理就很难讨论扎实。

职责拆解的最低要求

架构师至少要回答:

  • 谁是入口方,谁是编排方,谁是状态拥有者。
  • 谁负责写入真相源,谁负责投影、索引、缓存或通知。
  • 哪些步骤必须同步完成,哪些允许异步收敛。
  • 哪些模块承担主链路责任,哪些模块只是辅助能力。

2.7 数据流、状态流与边界划分

系统设计不只是模块设计,更是数据与状态的设计。模块边界一旦模糊,最终往往会在一致性、权限和故障恢复上出问题。

数据流回答“信息怎么走”

设计数据流时,至少要说明:

  • 数据从哪里来,由谁写入。
  • 哪些服务读主库,哪些走缓存、副本或索引。
  • 哪些数据需要同步返回,哪些允许异步收敛。
  • 数据在不同存储之间如何复制、投递、校验和修复。

状态流回答“系统怎么演进”

很多业务的复杂度不在 CRUD,而在状态机。订单、审批、履约、任务调度都属于典型状态驱动场景。设计时要明确:

  • 系统有哪些核心状态。
  • 状态转移由谁触发,是否允许回滚或补偿。
  • 并发更新时如何防止状态错乱。
  • 失败重试会不会造成重复执行。

边界划分回答“谁负责什么”

边界划分的目标是高内聚、低耦合、隔离变化。可以借助这些原则检查设计:

  • 一个模块的职责是否足够单一。
  • 一个接口是否暴露了过多实现细节。
  • 一个服务是否承担了本不属于它的状态管理责任。
  • 未来变化最频繁的部分,是否被隔离到了独立边界内。

SOLID、正交性、高内聚、低耦合,本质上都在强调同一件事:系统应该把容易变化的部分隔离开,让稳定部分尽量少受影响。

2.8 设计阶段(Design Phase):用六维画布把方案想清楚

在进入 TD 骨架之前,还有一个很容易被跳过的阶段:把方案真正想清楚。很多方案写到一半写不下去,不是因为不会画图或不会写接口,而是因为设计阶段的几个关键维度没有覆盖全,导致写到后面才发现矛盾、遗漏或逻辑不自洽。

我把这个阶段称为 Design Phase——它不是画草图的阶段,也不是写文档的阶段,而是用一套结构化的维度把模糊的直觉变成可执行的方案。它的核心输出不是一个文档模板,而是一组经过推敲的设计决策。

为什么需要一个显式的设计阶段

现实中最常见的翻车路径是:拿到需求 → 画几条链路 → 开始写接口 → 写到 rollout 时才发现“这个风险之前没想过“。原因是设计过程中缺少一套稳定的检查维度,导致作者被主流程牵着走,忽略了系统的横切面。

显式地把设计阶段拆成六个维度,不是为了增加工作量,而是为了让设计者在动手写文档之前,就已经把最关键的问题过了一遍。这六维的作用不是“写满“,而是“别漏“。

六维设计画布

我把设计阶段的关键关注点收敛为六个维度:Model(模型)、Process(流程)、Capability(能力)、Governance(治理)、Measurement(度量)、Evolution(演进)。这六维不是六个章节标题,而是六种审视方案的视角——一个方案如果在这六个维度上都经得起追问,基本就站稳了。

1. Model(模型)——数据与领域核心

关注什么:业务概念在系统中如何被建模,包括实体、关系、状态机、聚合边界、事实源(Source of Truth)。

为什么重要:模型是方案的地基。如果核心实体的职责边界不清晰、事实源不唯一、状态转移规则有歧义,往上的流程和接口写得再详细也是沙上建塔。

至少产出

  • 核心实体与关系的明确描述(不一定是完整 ER 图,但关键实体及其事实源必须说清楚)
  • 关键业务对象的状态机或生命周期(哪怕只有一个核心对象)
  • 事实源声明:每个核心数据的主写入方是谁,谁只读副本或投影

事实源是最容易被忽略的设计决策:一个常见错误是,同一份数据在多个服务中被修改,但没人说清楚谁是“真相“。到对账的时候才发现,A 服务改了一个字段、B 服务改了另一个字段、C 服务只读了缓存,三方对不上一笔数。所以设计阶段的第一件事不是画架构图,而是声明每条核心数据的 Source of Truth——哪个模块拥有写入权,其他模块只能通过它获取最新状态或持有最终一致的投影。

2. Process(流程)——请求链路与事务边界

关注什么:一次请求或一个业务流程如何跨越多个模块,经过哪些步骤,每一步是同步还是异步,事务边界在哪里,失败时如何回滚或补偿。

为什么重要:主链路是评审者理解方案的第一入口。如果核心链路画不清楚,后面的容量估算、容灾设计、灰度发布都缺乏锚点。

至少产出

  • 核心业务场景的端到端时序或流程图(标注同步/异步边界)
  • 每个跨模块操作的事务范围声明(一个事务最多跨越哪些操作)
  • 异常路径处理:超时、重试、幂等、补偿的明确策略

先写最坏情况,再画正常流程:一个很实用的设计习惯是,先写清楚最坏情况下系统应该怎么保护核心事实。比如“如果支付回调丢失““如果库存扣减成功但订单创建失败”“如果 MQ 消息重复投递”——把这些失败场景的处理策略写出来之后,正常流程自然就清楚了。反之,如果只画正常流程,这些边界条件往往要到上线后才会被触发。

3. Capability(能力)——模块职责与技术选型

关注什么:系统需要哪些核心能力,这些能力如何分配到模块或服务,每个模块的关键技术选型是什么,为什么选这个而不是别的。

为什么重要:能力划分决定了团队的开发边界和运维边界。选型决策是最容易被挑战的地方——不是因为评审者觉得你的选型不对,而是因为你没有解释选型依据。

至少产出

  • 核心模块列表及其职责的一句话声明
  • 关键选型的决策记录:选项、选择、依据、代价、fallback
  • 模块间的依赖方向(谁依赖谁,是否合理)
4. Governance(治理)——风险、安全与合规

关注什么:方案在运行时会面临哪些风险,如何通过限流、熔断、降级、幂等、对账、权限控制等手段把风险控制在可接受范围内。

为什么重要:治理层是区分“PPT 架构“和“可上线架构“的分水岭。一个方案如果没有回答超时怎么办、重复怎么办、数据不一致怎么发现、敏感数据怎么保护,它就只能活在理想环境里。

至少产出

  • 风险清单:每个风险的影响范围、触发条件、缓解措施
  • 关键链路的限流、熔断、降级策略声明
  • 数据一致性保障方案:幂等、对账、补偿的层次设计
  • 敏感数据的安全边界(加密、脱敏、访问控制)

对账与补偿不是“锦上添花“,而是核心设计:涉及资金、库存、积分等场景时,对账和补偿不是运维阶段才考虑的事,而是设计阶段就必须定义的。对账回答“我怎么知道数据不一致了“——包括实时对账、准实时核对、离线全量对账;补偿回答“不一致了怎么修“——包括自动补偿(如 Saga)和人工介入的修复工作台。设计阶段至少要说清楚:对账的粒度和频率是什么,补偿的方向和幂等保障是什么。

5. Measurement(度量)——可观测性与 SLA

关注什么:方案上线后如何确认它在正常运行,哪些指标是关键健康信号,出问题时如何快速定位。

为什么重要:一个没有度量方案的设计,上线后基本等于“盲飞“。可观测性不是运维的事后补丁,而是设计阶段就必须嵌入方案的一部分。

至少产出

  • 关键业务指标和 SLI/SLO 声明
  • 核心链路的监控埋点位置和告警触发条件
  • 故障排查路径:哪个日志、哪个大盘、哪个 trace
6. Evolution(演进)——兼容性与迁移路径

关注什么:方案不是一次建成的,后续迭代时如何保证兼容、如何迁移旧数据和旧逻辑、如何让团队逐步接手。

为什么重要:很多方案第一期设计得很完美,但第二期需求一来就推倒重来,因为第一期没有预留演进空间。演进能力不是“过度设计未来“,而是“今天的设计不堵死明天的路“。

至少产出

  • 本期明确不做但可能影响未来扩展的假设说明
  • 数据模型和 API 的兼容策略(向前兼容、版本管理)
  • 如果未来需要替换核心组件,迁移路径大概是什么方向

六维设计画布速查表

维度一句话最关键的三个问题最少交付物
Model数据与事实事实源是谁?状态怎么流转?核心实体边界在哪?核心实体描述、状态机、事实源声明
Process链路与事务主链路怎么走?事务边界在哪?失败了怎么补偿?核心时序图、事务范围、异常处理策略
Capability能力与选型模块怎么划分?为什么选这个组件?依赖方向是否合理?模块职责声明、选型决策记录
Governance风险与防护最坏情况是什么?怎么兜底?对账和补偿怎么做?风险清单、限流/熔断/降级策略、对账方案
Measurement度量与观测怎样算正常?出了事怎么发现?怎么快速定位?SLI/SLO、监控埋点、告警规则
Evolution兼容与迁移今天的设计会不会堵死明天的路?旧数据怎么迁移?兼容策略、非目标假设、迁移路径草图

设计阶段的轻量交付模板

经过六维画布的审视之后,设计阶段至少应产出一组可交付的产物。这不是 TD 模板本身,而是进入 TD 写作之前就已经就绪的“弹药“

  1. 架构图(1 张):表达核心模块、职责边界和依赖方向,不追求细节,但追求边界清晰。
  2. 核心链路时序图(1-2 张):展示最关键业务场景的端到端调用链,必须标注同步/异步、重试、降级。
  3. 状态迁移表(1 张):至少包含核心业务对象的状态、事件、守卫条件和转移动作。
  4. 风险清单(1 份):每一条风险都包含触发条件、影响范围、缓解措施和回滚方式。
  5. 选型决策记录(1 份):关键的技术选型必须有选项、选择、依据、代价和 fallback。
  6. 容量预估(1 份):峰值 QPS、存储量级、带宽、核心接口 RT 目标的估算与推导过程。

这六项产物不需要写得很长,但它们覆盖了六维画布的核心输出。如果这六项在动手写 TD 之前已经就绪,写 TD 的过程就变成了“把这些决策按评审顺序组织和表达“,而不是“边写边想“。

从设计阶段到 TD 骨架

六维画布是“想清楚“的工具,TD 8 段式骨架是“写清楚“的框架。两者天然衔接:

  • Model + Process 的输出 → TD 的 End-to-End Flow / Responsibility Split
  • Capability 的输出 → TD 的 Key Decisions
  • Governance 的输出 → TD 的 Dependency / Rollout / Risk / Rollback
  • Measurement + Evolution 的输出 → 分散在 TD 的 Assumptions、Rollout 和 Work Breakdown 中
  • 轻量模板的六项产物 → TD 各段的核心素材

换句话说,六维画布保证了方案的完备性——六个维度都想到了,不容易有重大遗漏;TD 骨架保证了表达的可评审性——关键信息按评审者关心的顺序组织,不容易被打回来。两者合在一起,就是“想清楚 → 写清楚 → 评清楚“的完整闭环。

2.9 一份好 TD 的 8 段式骨架

真实项目里,评审者很少有耐心跟着作者的思路一点点猜。你需要把最关键的信息按一种稳定顺序摆出来。实践中,我更推荐以下 8 段式骨架:

  1. Background / Objective / Scope
  2. Current State / Problem Statement
  3. Key Decisions
  4. End-to-End Flow / Responsibility Split
  5. API / Data Contract
  6. Dependency / Rollout / Risk / Rollback
  7. Open Questions / Assumptions
  8. Work Breakdown / Owner / Timeline

为什么这 8 段是评审最关心的顺序

  • 先看 为什么做,再看 做什么
  • 先看 问题是否成立,再看 方案是否合理
  • 先看 关键取舍,再看 细节接口
  • 先看 风险与 rollout,再看 排期和 owner

这个顺序不是为了“写得规范”,而是为了降低评审摩擦。评审会上最常见的问题,本质上都在追问这 8 段里的某一段。

每一段分别回答什么

1. Background / Objective / Scope

  • 为什么现在要做这件事
  • 成功标准是什么
  • 本期明确不做什么

2. Current State / Problem Statement

  • 当前系统或流程是什么样
  • 痛点是实际存在的,还是假设出来的
  • 如果不改会怎样

3. Key Decisions

  • 这次最关键的 2-5 个设计决定是什么
  • 候选方案有哪些
  • 为什么选这个,不选那个
  • 代价和 fallback 是什么

4. End-to-End Flow / Responsibility Split

  • 从输入到输出,链路怎么走
  • 哪个系统在什么步骤负责什么
  • 同步 / 异步边界在哪里

5. API / Data Contract

  • 请求、响应、事件、状态字段分别是什么意思
  • 哪些字段是兼容新增,哪些字段会改变行为
  • 幂等、顺序、版本、精度是否说清楚

6. Dependency / Rollout / Risk / Rollback

  • 依赖谁
  • blocker 是什么
  • 上线顺序是什么
  • 出问题怎么降级、怎么退

7. Open Questions / Assumptions

  • 哪些问题还没定
  • 哪些地方是在基于默认假设推进
  • 哪些结论要等外部条件成立才有效

8. Work Breakdown / Owner / Timeline

  • 工作怎么拆
  • 谁负责
  • 哪些步骤有先后依赖
  • 如何判断“设计准备完成,可以进入实现”

2.10 关键设计决策怎么写

很多方案不是没列选项,而是没有把“决策标准”写清楚。结果读者看完知道有 A、B、C 三个方案,却不知道为什么最后选了 B。

一个合格的决策段落,至少要包含:

  • 候选方案
  • 选择结果
  • 选择依据
  • 明确代价
  • fallback 或后续迁移路径

推荐写法:

决策:发布链路采用“先冻结输入,再构建不可变产物,再切换版本指针”的状态机模型。

候选方案:
- 方案 A:直接同步覆盖多处存储
- 方案 B:引入 snapshot + task + pointer

选择依据:
- A 实现简单,但状态难以收敛,回滚困难
- B 增加模型复杂度,但能显式表达发布状态并支持补偿与回滚

代价:
- 需要新增状态表、补偿任务和观测指标

Fallback:
- 第一阶段只在试点配置上启用新状态机,其他配置维持旧路径

2.11 风险、依赖、灰度与回滚

很多文档前面分析得很好,到了 rollout 和 rollback 就开始写模板套话,这是最容易被架构评审打回来的地方。

风险不是“列几个大词”

真正有价值的风险描述要回答:

  • 风险是什么
  • 何时会发生
  • 影响范围是什么
  • 如何发现
  • 如何缓解
  • 如果缓解失败,怎么回滚

依赖必须集中表达

依赖和 blocker 不应散落在各章节里。建议统一收口:

  • 依赖的系统 / 团队 / 第三方
  • 当前状态
  • 若依赖不满足,本方案会卡在哪
  • 临时兜底方案是什么

rollout 必须能执行

上线方案至少要说清:

  • 是一次性切换还是灰度放量
  • 灰度维度是什么:用户、流量、市场、类目、开关
  • 观测指标是什么
  • 失败阈值是什么
  • 谁负责拍板扩大或停止

rollback 必须能落地

回滚不是“有问题就回滚”这一句话,而是:

  • 回滚对象是什么:版本、快照、开关、配置、数据批次
  • 回滚动作由谁执行
  • 回滚前需要检查什么
  • 回滚后如何确认系统回到稳定态

2.12 方案评审与推进

一份技术方案写完并不代表工作结束。评审和风险管理,决定了方案能否真正落地。

评审到底在评什么

评审不是挑文档格式,而是检查这几个层面:

  • 问题定义是否准确,目标和非目标是否清晰。
  • 方案是否能支撑容量、性能和稳定性目标。
  • 关键取舍是否解释充分,是否讨论过替代方案。
  • 风险是否被识别,是否有缓解和回滚措施。
  • 任务拆分、排期和依赖是否现实。

评审者视角不同,问题也不同

  • 业务 / 产品:目标是否清楚,边界是否合理
  • 架构师:关键决策是否站得住,tradeoff 是否充分
  • 实现者:职责、链路、接口、状态是否足够明确
  • 运维 / 稳定性角色:灰度、监控、回滚、人工介入是否准备好

如果作者自己没有带着这四种视角审一遍,评审会上通常会被打得很散。

2.13 高质量 TD 的常见反模式

结合真实项目 review,最常见的高频失败模式有这些:

  • 方案写成实施手册:命令、表结构、接口细节很多,但缺少主设计决策。
  • 选项列了但没有决策标准:A/B 方案并列展示,却没有说明为什么选这个。
  • 字段语义不统一:金额、状态、快照、版本等核心字段在不同章节含义漂移。
  • 依赖和 blocker 散落在正文里:读者要靠自己拼图。
  • rollout 和 rollback 只有模板占位:看起来写了,实际上不可执行。
  • 开放问题没有显式出口:真正没定的内容被埋在自然语言里。
  • 设计与运行手册混写:评审想看系统行为,却被后台操作步骤淹没。

评审时,真正值得优先指出的,永远是这些结构性问题,而不是措辞或排版。

2.14 TD 模板、AI Skill 与方法论沉淀

方法论最终不能只停留在脑子里。一个成熟团队通常会把它沉淀成三类工件:

  • TD 模板:统一结构,让作者少走弯路
  • Review Checklist:统一审查口径,让评审少遗漏关键风险
  • AI Skill / 写作助手:把结构化 author/review 工作流自动化,降低表达成本

例如一个公开版的 td-writing-review skill,可以服务三类输入:

  • 技术方案草稿
  • 需求摘要 / 会议纪要
  • 已完成但结构混乱的设计文档

它的价值不在于“帮你润色”,而在于:

  • author 模式先帮助作者收敛 Top Decisions
  • review 模式先暴露 决策缺口、风险缺口、责任缺口
  • 统一作者、评审者和实现者对一份 TD 的阅读顺序

从这个角度看,书里的方法论回答“为什么这样写”,skill 回答“如何把它操作化”。两者不是替代关系,而是同一套认知的不同形态。

2.15 技术方案方法论小结

技术方案设计的本质,是把一个模糊问题逐步收敛为一套可评审、可实施、可演进的工程决策。本部分的方法论可以压缩为一条主线:

  1. 先澄清问题与边界,再谈方案。
  2. 先用数据识别容量与约束,再谈组件选型。
  3. 先讲核心链路、数据流和状态流,再补充横切能力。
  4. 再用六维画布(Model / Process / Capability / Governance / Measurement / Evolution)把方案从六个维度推敲一遍,确保没有重大遗漏。
  5. 再用 8 段式骨架把关键信息按评审顺序摆出来。
  6. 最后用评审、风险、灰度和回滚把方案从”想法”变成”工程计划”。

3. 架构师的武器库

从边界设计到内部结构、系统协作、质量保障与容量韧性,建立架构师的通用作战能力。


3.1 组合拳总论

软件架构的本质挑战

对一个做了 8 年左右后端或系统设计的工程师来说,软件架构真正难的地方,通常不是“有没有听过某个名词”,而是你会一遍又一遍遇到同一类问题:业务越来越复杂,团队越来越大,系统之间的调用越来越多,查询越来越重,规则越来越碎,而每一次新需求都在把这些问题继续放大。

很多人第一次认真接触架构,往往是从技术概念开始的:分层、DDD、CQRS、事件驱动、六边形架构、Clean Architecture。但真正在项目里工作几年之后,你会慢慢发现,架构设计并不是在概念之间“站队”,而是在不断回答几个非常现实的问题:

  • 业务边界到底应该怎么划,才能避免概念混乱?
  • 服务内部应该怎么组织,才能扛住持续变化?
  • 系统拆开之后,彼此之间怎么协作才不会越来越乱?
  • 代码怎么写、评审怎么做、上线怎么控,才能让设计不在落地时变形?

也正因为如此,本章并不打算把几个流行概念逐一解释一遍,而是想先做一件更重要的事:从工程实践的角度,沉淀软件架构面对的本质挑战,再引出架构师通常会如何打出一套“组合拳”来应对这些挑战。

为什么软件架构问题总会反复出现

软件架构之所以让人反复感到“似曾相识”,是因为很多系统最终都会落进相同的复杂性轨道。

项目刚开始时,大家通常只觉得“先把功能做出来”最重要。于是一个订单服务里,先有了下单接口,再加上支付回调、取消订单、超时关单、库存预占、营销校验、事件通知。前几个月看起来一切都还可控,但只要系统开始承接更多业务,问题就会陆续出现:

  • 原本只有几条状态流转,后来变成十几种特殊流程
  • 原本只有一个团队维护,后来变成多个团队协作
  • 原本只需要简单查询,后来页面需要展示越来越多聚合信息
  • 原本只改一处代码就够,后来一个需求要改接口、应用层、数据库、缓存和消息逻辑

这些问题反复出现,不是因为团队不努力,也不是因为某个框架选错了,而是因为复杂性会自然增长。如果没有一套稳定的边界、结构和协作方式,系统最终都会从“能跑”走向“难改”,再从“难改”走向“没人敢动”。

从这个角度看,架构设计并不是锦上添花,它更像是在系统复杂性不断上升时,为团队建立一套能够持续控住复杂性的秩序。

复杂系统的四类核心矛盾

如果把这些年在项目里反复遇到的问题抽象一下,大多数复杂系统最终都会面对四类矛盾。

第一类矛盾:业务复杂性不断上升

  • 商品、订单、库存、支付、营销之间的边界到底怎么划?
  • 同一个“订单”“商品”“价格”概念,在不同团队和不同系统里是否还是同一个意思?
  • 规则越来越多之后,它们到底应该落在流程里、模型里,还是散落在各个 Service 里?

第二类矛盾:系统必须持续应对变化

  • 业务规则会变,促销玩法会变,履约流程会变
  • 技术栈也会变,数据库、框架、消息中间件都可能调整
  • 如何让这些变化被限制在局部,而不是每次都牵动整条链路?

第三类矛盾:多人协作需要统一认知

  • 50+ 开发者同时开发,如何降低彼此干扰?
  • 产品、运营、业务专家和开发是否真的在使用同一套语言?
  • 新成员加入时,是否能快速理解系统边界和核心规则?

第四类矛盾:性能、一致性与可维护性彼此拉扯

  • 查询希望越快越好,往往需要宽表、缓存、搜索和反范式化
  • 写入希望越稳越好,往往需要事务、一致性和严格规则保护
  • 如果为了性能不断破坏模型,系统会变脏;如果为了模型纯净牺牲响应能力,业务体验又会变差

这些矛盾单独看都不新鲜,但真正困难的地方在于:它们几乎总是一起出现。这也是为什么软件架构很少有“一招鲜”,而更像是一套围绕不同问题分层出招的组合动作。

电商系统为什么是观察架构问题的最佳样本

电商系统之所以适合拿来讨论架构,不是因为它更“高级”,而是因为它几乎天然把前面四类矛盾全部放大了。

从业务上看,电商系统同时涉及商品、库存、计价、营销、订单、支付、履约、售后,业务规则彼此交叉,非常容易出现边界模糊和语义冲突。

从流量上看,商品详情、搜索、列表页有很高的读压力;下单、支付、库存扣减等链路又对一致性极其敏感。这意味着系统既要面对高并发,又不能把正确性让位给性能。

从组织上看,电商项目通常不是一个小团队就能长期覆盖的。商品团队、营销团队、交易团队、支付团队、履约团队会围绕同一个平台长期协作。边界一旦划不清,协作成本会非常快地吞掉研发效率。

也正因为如此,电商系统非常适合拿来回答这样一个问题:面对真实复杂系统,架构师到底需要哪些方法论,它们分别在什么阶段解决什么问题?


架构师如何应对这些挑战

如果说上一节回答的是“问题到底是什么”,那么这一节要回答的是另一个更重要的问题:架构师通常会按什么顺序来应对这些问题?

在真实项目中,成熟的架构设计很少是“先选一个流行概念,再把所有问题往里塞”。更常见的做法是按问题的层次逐步处理:先确定边界,再稳定内部结构,再设计系统之间的协作方式,最后用代码规范和质量机制把这套设计守住。

这也是本书第一部分的主线。

先做业务边界

复杂系统最先要解决的,通常不是“选哪种分层”,而是“系统到底应该怎么切”。

如果业务边界没有先划清楚,那么后面几乎所有设计都会失焦:

  • 团队不知道哪个规则属于哪个系统
  • 同一个概念在不同服务里被重复定义
  • 上下游关系长期靠口头约定维持
  • 一个变更会反复穿透多个模块和多个团队

因此,架构师首先要做的,往往是识别核心域、支撑域、通用域,划分限界上下文,建立通用语言,并明确上下文之间的关系模式。换句话说,先画地图,再开始谈地图里的建筑。

这一步对应本章的 3.2 业务边界与战略设计 部分。

再建系统内部秩序

边界画清楚之后,问题会自然转向单个系统内部:这个服务应该如何分层?依赖方向怎么控制?业务规则落在哪?读写路径是否应该继续共用同一套模型?

这一步其实是在解决“单个系统内部如何建立秩序”的问题。也是大多数团队最熟悉、但最容易混在一起的一步。

在工程实践里,这一层通常不是靠单一方法论完成的,而是几种方法一起配合:

  • 用三层架构先建立基本职责分层
  • 用 Clean Architecture 约束依赖方向
  • 用 DDD 战术设计承载复杂业务规则
  • 在读写矛盾明显的地方引入 CQRS

这一步对应本章的 3.3 系统内部结构设计 部分。

再处理系统之间的协作

系统一旦拆分成多个上下文、多个服务,新的问题就出现了:调用如何解耦?跨服务流程怎么编排?消息如何可靠投递?事务边界跨不过去时,一致性又怎么保证?

也就是说,边界和内部结构解决的是“单个系统怎么设计”,而系统间协作解决的是“多个系统如何一起工作”。

这一层关注的不再只是类、包和目录,而是:

  • 事件驱动与同步调用如何搭配
  • 防腐层和集成契约如何守住边界
  • Outbox 如何处理双写问题
  • Saga 和补偿事务如何处理长流程一致性

这一步对应本章的 3.4 系统集成与一致性设计 部分。

最后落实代码与质量保障

很多设计图看起来都很漂亮,但真正决定系统能否长期维持下去的,往往不是图画得多完整,而是代码怎么写、评审怎么做、上线怎么控。

如果没有编码原则,系统内部结构很快会在日常开发中被打穿;如果没有质量保障机制,再好的边界和架构也会在交付压力下被不断开洞。

因此,架构的最后一公里,通常落在两个层面:

  • 代码层面:如何写出可读、可改、可测试的实现
  • 质量层面:如何通过评审、检查清单和上线前验证守住设计

这两部分分别对应本书的:

  • 第 2 章《编码与 Code Review》
  • 本章的 3.5《架构质量保障》部分

如果把这一整套顺序收成一句话,就是:

先划清业务边界,再建立系统内部秩序,再处理系统之间的协作,最后通过代码原则与质量机制把架构真正落地。


架构设计的方法论地图

理解了问题层次和应对顺序之后,我们就可以把常见的方法论重新放回同一张地图中。

在实际落地中,架构师通常不会只选一种方法论,而是把它们嵌套使用,形成一个完整闭环。这也是“组合拳”这个说法最核心的含义:不是每种方法论都解决所有问题,而是每一种方法论都在处理复杂系统中的一个关键维度。

DDD:解决业务边界与领域建模

DDD 在这套组合拳中有两层作用。

第一层是战略设计:帮助架构师识别核心域、划分限界上下文、建立通用语言和上下文映射。它回答的是“系统应该怎么切”的问题。

第二层是战术设计:帮助团队在单个上下文内部用聚合根、实体、值对象、领域事件表达复杂业务规则。它回答的是“切出来的系统内部,规则应该怎么承载”的问题。

因此,DDD 既属于“先划边界”,也会延伸到“内部结构设计”。这也是为什么本书会把它拆成不同层次分别展开。

三层架构:建立工程分层的默认起点

三层架构不是最“先进”的方法论,但它仍然是很多项目最实用的起点。

它先解决的是一个非常现实的问题:代码应该按什么职责落位,团队才能顺利协作。表现层、业务逻辑层、数据访问层的分工,让系统至少先具备了基本秩序。

从架构师视角看,三层架构的价值不在于它能一次解决所有问题,而在于它能以很低的成本让项目快速从“无结构”进入“有分层”的状态。因此,它经常成为后续引入更强结构的起点。

Clean Architecture:约束依赖方向

如果说三层架构先回答“代码放在哪”,那么 Clean Architecture 进一步回答“核心业务应该依赖谁”。

它最核心的要求是依赖方向向内:外层技术可以依赖内层业务,但内层业务不应该知道数据库、框架和中间件的具体存在。这个约束看起来很抽象,但它直接影响系统是否可测试、可替换、可长期演进。

很多项目在目录上看起来仍然是三层,但一旦在依赖方向上开始服从这个规则,它其实就已经在朝 Clean Architecture 靠拢了。

CQRS:解决读写目标冲突

CQRS 解决的是另一个常见矛盾:同一套模型能否同时服务好读和写。

在简单系统里,共用统一模型通常更经济;但在复杂系统里,写侧关注一致性和规则保护,读侧关注查询性能和展示效率,二者的目标很容易发生冲突。这个时候,继续强行共用一条路径,往往会让双方互相拖累。

CQRS 的价值,正在于允许我们承认这种冲突,并把命令侧和查询侧分别优化。它经常出现在系统内部结构演进的后期,同时又会自然和事件驱动、投影、最终一致性衔接起来。

事件驱动与一致性设计:解决系统间协作

当系统之间开始协作时,单个服务内部的分层已经不够了。新的问题变成了:事件怎么发、消息怎么投、双写怎么解、长流程怎么补偿、一致性怎么守。

这一组方法论通常包括:

  • 事件驱动
  • 集成模式
  • Outbox Pattern
  • Saga / 补偿事务
  • 最终一致性设计

它们不再主要解决“单个系统内部怎么组织”,而是解决“多个系统之间如何可靠协作”。这也是为什么这里会把它们放到本章的 3.4 部分集中讨论。

编码原则与质量保障:守住架构落地质量

一套架构设计如果不能稳定落到代码和交付流程里,最终就会退化成 PPT 上的结构。

因此,架构师的组合拳里还缺最后两类能力:

  • 用编码原则和设计模式保证实现层不把架构意图写坏
  • 用评审、检查清单、测试与上线前验证保证架构不在交付时变形

这两类能力看起来不像 DDD 或 CQRS 那样“有名词感”,但它们恰恰决定了一套架构能不能在团队里真正活下来。

如果把整套方法论再压缩成一句话,就是:

DDD 帮助我们认清业务边界,三层架构和 Clean Architecture 帮助我们建立系统内部秩序,CQRS 帮助我们在读写冲突中做结构优化,事件驱动与一致性设计帮助我们处理系统间协作,而编码原则与质量保障负责把这一切真正守住。



3.2 业务边界与战略设计

为什么需要战略设计

第 1 章从 Clean ArchitectureDDDCQRS 的协作关系出发,已经用「三位一体」搭好了工程骨架,并简要触及了限界上下文、上下文映射与通用语言。本章不再重复分层目录、Outbox 或读写分离的细节,而是把镜头拉近到 DDD 的战略设计:它回答的是「边界在哪里、团队如何协作、概念如何对齐」——这些问题若未澄清,战术层的聚合与仓储很容易变成「漂亮的样板代码」,却无法降低沟通与演进成本。

战术先行常见症状

许多团队第一次接触 DDD 时,会直接从「实体 / 聚合 / 仓储」入手,短期内代码结构变整齐,但很快遇到以下矛盾:

  • 同名不同义:产品口中的「商品」指前台可售的 SKU;库存同学口中的「商品」是可售量与仓位的组合;订单里的「商品」又是下单快照。没有上下文边界时,一个 Product 结构体会被迫承载三套语义。典型症状是代码中出现 Product.InventoryQty(库存关注)和 Product.DisplayTitle(商品关注)混杂在同一个类型中,任何修改都需要跨团队协调,变更成本高昂。

  • 跨团队改同一张表:订单服务为了赶需求直接更新库存表,或营销脚本回写订单金额字段。短期省事,长期让「谁拥有这条数据」变得模糊。某次故障中,订单团队修改了库存表的索引,导致库存域的查询性能暴跌;两边互相推诿责任,最终花了一周才定位问题。这种「绕过契约直连数据库」的路径,是边界模糊的最大隐患。

  • 集成靠私下约定:RPC 参数、Kafka Topic、回调字段在口口相传中演进,缺少显式的上下游关系与防腐策略。某次营销域修改了优惠券事件的字段名(从 coupon_id 改为 couponCode),订单域的消费者直接报错,才发现双方没有契约测试与版本策略。线上故障持续了 2 小时,影响了数万订单。

真实案例:某团队在引入 DDD 后,代码中充满了精美的聚合、仓储、领域服务,但每次跨服务需求都需要「拉群对齐」,因为没有明确的上下文地图。产品经理提需求时说「改一下商品价格显示逻辑」,三个团队(商品、订单、营销)都认为这是自己的职责,最终在会议室吵了一下午才确定归属。

战略设计的目标,是把上述隐性知识变成可评审的工件:上下文地图、术语表、子域投资优先级与集成模式(客户-供应商、防腐层等)。战术设计再在这些边界之内展开。有了清晰的上下文映射,「改商品价格显示」的需求可以在 10 分钟内定位到正确的上下文(计价域),而不是三方扯皮。

关键认知:战略设计不是「画图玩」,而是团队协作的操作系统。当产品提需求、技术做设计、代码做评审时,都参考同一张上下文地图、同一份术语表,沟通成本会显著降低。没有战略设计的团队,每个需求都要重新「对齐理解」;有战略设计的团队,大部分对齐工作已经提前完成。

战略设计与第 1 章的分工
主题第 1 章侧重本章侧重
限界上下文与分层、CQRS 并列介绍概念识别方法、划分原则、电商多域对照
上下文映射模式列表与示意关系选型、Go 侧接口与适配器落地
通用语言命名对照示例工作坊流程、术语治理与演进
子域分类核心 / 支撑 / 通用与评分投资策略、资源配比与常见误判

读完本章,你应能用一页纸向团队说明:我们有哪些限界上下文、各自语言是什么、之间用哪种映射集成、哪几块值得重仓投入

实践建议:战略设计不必一次性做到完美。可以先用一次 2 小时的工作坊识别出 3-5 个核心上下文,画出简单的映射图,建立初版术语表(20-30 个核心术语)。在后续迭代中,根据实际协作痛点逐步细化边界、补充术语、调整映射关系。重要的是让战略设计的产物(上下文地图、术语表、集成契约)成为团队评审与决策的依据,而不是藏在某个架构师的脑子里。


限界上下文(Bounded Context)

限界上下文是模型的显式边界:在边界之内,术语含义稳定、规则可推敲;跨边界则允许同名不同义,但必须通过契约(API、事件、发布语言)连接。

识别限界上下文

识别不是一次性「微服务切分」,而是对业务能力与协作现实的建模。可组合使用以下线索:

  1. 业务能力:下单、收款、发货、圈品投放通常是不同能力,各自有独立生命周期。例如「订单履约」是一个完整的业务能力,包含订单创建、支付确认、发货、收货等完整流程,这些步骤紧密耦合,应该归属同一个上下文。而「商品展示」则是另一个独立能力,包含商品上架、搜索、详情页等,两者可以独立演进。

  2. 语言边界:当同一个词在两处讨论时含义开始分叉,往往意味着边界临近。例如「锁库」在订单侧可能是预留,在库存侧可能是可售量扣减。再比如「价格」,在商品域是「标价」,在计价域是「试算结果」,在订单域是「合同金额」,在支付域是「实付金额」——四个上下文中的「价格」含义完全不同,需要明确划分。

  3. 一致性边界:需要同事务维护的不变量,通常落在同一上下文内;可接受最终一致的协作,适合跨上下文用事件衔接。例如订单总价必须等于各明细之和(强一致),适合在订单上下文内用聚合保证;而库存扣减后通知搜索索引更新(可接受秒级延迟),适合跨上下文用事件。

  4. 团队与发布节奏(康威定律):若两个模块永远由同一小队同节奏发布,拆成两个部署单元的紧迫性要重新评估;反之则倾向清晰上下文与契约。例如订单域和支付域虽然业务相关,但由不同团队维护、发布节奏独立、技术栈不同(订单用 Go,支付用 Java),应该拆分为独立上下文,通过清晰的 API 与事件集成。

电商示例(订单链路)

graph TB
    subgraph 订单上下文
        O[Order 聚合<br/>生命周期 / 金额快照]
    end
    subgraph 商品上下文
        P[Catalog / SKU<br/>上架与展示属性]
    end
    subgraph 库存上下文
        I[Stock / Reservation<br/>可售 / 预留 / 扣减]
    end
    subgraph 营销上下文
        M[Promotion / Coupon<br/>规则与资格]
    end
    O -->|下单前询价 / 快照| P
    O -->|预留 / 释放| I
    O -->|试算 / 核销| M

识别要点:每个上下文有清晰的核心职责与生命周期——订单管理订单状态流转,商品管理可售商品信息,库存管理可售量与预占,营销管理优惠规则。它们通过定义明确的接口与事件协作,而不是共享同一个「大而全」的模型。

上下文边界的划分原则
  1. 优先保护不变量:订单总价与明细一致、库存不为负等,各自应在所属上下文的聚合内守护,而不是靠分布式事务「一把梭」。例如订单聚合在 AddLine 方法中同步更新 TotalAmount,保证总价与明细一致性;而库存扣减与订单创建分属两个上下文,通过事件最终一致。

  2. 拒绝共享大模型:不要把「全局统一 Product」当作目标;不同上下文各自建模,用 ID 与快照连接。订单上下文的 OrderLine 包含商品快照(标题、单价),商品上下文的 Product 包含展示信息(详情、图片),两者模型不同但通过 ProductID 关联。

  3. 数据所有权清晰:每个业务表有唯一写入方;其他上下文只通过 API 或事件消费。库存表只能由库存服务写入,订单服务需要库存数据时通过 GetStock API 查询,而不是直接读库存表。

  4. 映射显式化:同步调用、异步事件、批量对账的选择应写进架构说明,而不是隐含在代码路径里。例如在上下文映射图中明确标注「订单 → 库存:同步预占 API + 超时释放事件」。

拆分决策树(可与团队工作坊共用):

flowchart TD
    A[是否存在稳定业务边界?]
    A -->|是| B[候选独立上下文]
    A -->|否| C{一致性要求是否冲突?}
    C -->|是| D[倾向拆分并定义集成]
    C -->|否| E{是否不同团队 / 发布节奏?}
    E -->|是| F[拆分 + 强化契约与监控]
    E -->|否| G[模块化单体中先划清包边界]

使用建议:这个决策树适合在「是否拆分」的争议中使用。当团队对某个功能模块是否应该独立成服务有分歧时,逐个回答上述问题,多数情况下能达成共识。关键是不要为了拆而拆——模块化单体同样可以有清晰的限界上下文,只是物理部署在同一个进程中。等到团队规模、发布节奏确实需要独立时,再升级为独立服务。

电商案例:订单域、商品域、营销域

以下表格用于对齐职责与对外能力(示例命名可按团队语言替换):

限界上下文核心关注点典型聚合(示例)对其他上下文承诺的能力
订单域订单生命周期、应付金额、状态机OrderOrderLinePlaceOrderCancelOrder、领域事件 OrderPlaced / OrderPaid
商品域可售商品、类目属性、媒体素材ProductSKU批量查询基础信息、按 SKU 返回标题与规格
营销域券、活动、互斥叠加规则CouponCampaignDiscountRulePreviewPromotionCommitPromotionHold
实战案例:边界划分的决策过程

某电商平台早期把计价能力分散在订单、营销、商品三个域:订单域计算小计,营销域计算优惠,商品域返回基础价。随着业务复杂度上升,出现多处问题:

症状

  • 购物车、订单创建、支付确认三处的价格计算逻辑不一致
  • 营销规则变更需要同步修改订单与商品的计算代码
  • 无法支持「PDP 加购试算」场景,因为没有统一的计价入口

重构决策

  1. 识别核心能力:「给定商品清单、营销规则、用户身份,计算出各层级价格」是一个完整的业务能力
  2. 独立上下文:新建计价上下文,职责是提供统一的试算接口,收敛所有价格计算逻辑
  3. 定义边界
    • 计价上下文不拥有商品基础价、营销规则、订单状态——它是编排者
    • 对外提供 Calculate(items, promotions, context) -> PriceBreakdown
    • 各场景(PDP / 购物车 / 订单)通过统一接口获取价格

收益

  • 价格计算的一致性得到保证(同一套代码服务所有场景)
  • 营销规则变更只需在营销域发布事件,计价域订阅后自动生效
  • 支持了试算、价格预览、价格审计等新需求

经验:识别边界不是一次性切分,而是在「职责不清、协作成本高、重复逻辑多」的信号出现时,主动重构出清晰边界。

Go 建模提示:在订单上下文中,不要直接引用商品聚合的类型,而是使用 ID + 快照 表达跨边界依赖。

package order

type ProductID string

// Money 在订单上下文中表示「合同金额」;实现细节可复用共享包,但语义归属订单。
type Money struct {
	Cents    int64
	Currency string
}

// OrderLine 属于订单上下文:保存下单时刻解释合同所需的快照。
type OrderLine struct {
	ProductID   ProductID
	ProductName string // 快照:避免商品改标题影响历史订单
	UnitPrice   Money  // 快照:避免改价影响已生成应付金额
	Qty         int
}
package catalog

type ProductID string

// Product 属于商品上下文:关注展示与销售属性,而非订单合同解释。
type Product struct {
	ID          ProductID
	Title       string
	Description string
	OnShelf     bool
}

要点:两个包里的「商品信息」形状不同不是重复,而是上下文各有权威——合同解释以订单快照为准,陈列以商品上下文为准。

实践建议:在代码评审时,如果发现两个上下文共享同一个 Product 类型,应该追问:「这两处对商品的关注点是否相同?」如果答案是「不同」(一个关注展示,一个关注合同),那么应该拆分为两个独立的类型。宁可有一些字段重复,也不要为了「消除重复」而强行共享模型——这种重复是有意义的重复,体现了不同上下文的自治性。


通用语言(Ubiquitous Language)

通用语言是业务方与研发共同维护的精确词汇系统,贯穿需求、设计与代码。战略阶段的价值在于:先对齐语言,再讨论服务拆分与表结构。

建立通用语言

推荐从一次轻量工作坊开始:

  1. 列出动词与名词:下单、支付、发货、锁库、核销、退款……标记同义词(「关闭订单」vs「取消订单」)。
  2. 为每个词写一句业务定义:谁触发、前置状态、成功后的世界有何不同。
  3. 映射到代码锚点:包名、类型名、公开方法名尽量使用一致词汇(如 PlaceOrder 而非 CreateOrderRecord)。
  4. 记录禁用词:例如团队约定不用「更新状态 2」这类技术黑话对外沟通。
工作坊实践流程(2 小时示例)

参与者:产品经理、领域专家、架构师、核心开发各 1-2 人。

第一阶段(30 分钟):业务流程梳理

  • 在白板上画出核心业务流程(如「用户下单到收货」)
  • 标记出关键状态节点与触发动作
  • 识别出现频率最高的业务名词(订单、商品、库存、优惠券)

第二阶段(45 分钟):术语对齐

  • 逐个讨论每个名词的精确定义
    • 示例:「库存」在商品上架时指初始可售量,在订单创建时指预占后的剩余量,在发货后指实际扣减
    • 决策:用「可售库存(Available Stock)」、「预占库存(Reserved Stock)」、「已扣库存(Deducted Stock)」三个明确术语替代模糊的「库存」
  • 识别同义词并统一
    • 示例:技术侧说「关单」,业务侧说「取消订单」→ 统一为 CancelOrder
    • 示例:「锁库」、「预占库存」、「冻结库存」→ 统一为 ReserveStock

第三阶段(30 分钟):映射到代码

  • 为每个术语分配英文命名(供代码使用)
  • 明确哪些术语属于哪个限界上下文
  • 示例输出:
| 中文术语 | 英文命名 | 所属上下文 | 定义 |
|---------|---------|-----------|------|
| 下单 | PlaceOrder | 订单域 | 用户提交购买意图,生成待支付订单 |
| 预占库存 | ReserveStock | 库存域 | 为订单预留库存,防止超卖;超时后自动释放 |
| 核销优惠券 | RedeemCoupon | 营销域 | 将优惠券从可用状态变更为已使用 |

第四阶段(15 分钟):归档与宣导

  • 将术语表提交到代码仓库(docs/glossary.md
  • 在下次需求评审时强制对齐:新需求必须使用术语表中的词汇
  • 代码评审时检查:新增 API / 事件命名是否符合术语表

工作坊成果示例

#### 订单上下文术语(v1.0)

- **下单(PlaceOrder)**:用户提交购买意图,生成待支付订单;不等于支付成功。
  - 前置条件:商品可售、库存充足、优惠券可用
  - 后置状态:订单状态为 `PendingPayment`,库存为 `Reserved`
  
- **锁库 / 预占库存(ReserveStock)**:为指定订单行预留可售库存,防止超卖;不等同于「扣减库存」。
  - 触发方:订单域在创单时调用库存域接口
  - 超时策略:30 分钟未支付自动释放
  
- **订单已支付(OrderPaid)**:支付渠道确认成功后的领域事实;会触发履约与扣减等后续流程。
  - 事件订阅者:库存域(扣减库存)、物流域(创建配送单)、营销域(核销优惠券)

实战技巧

  • 不要追求第一版术语表的完美——先建立 60% 共识,剩余在迭代中补充
  • 争议术语标记「待定」,给出 2-3 个候选,在实际编码中验证哪个更顺
  • 每季度 Review 一次术语表,淘汰不再使用的术语,补充新增的核心概念

Go:让类型系统承载语言

package order

type OrderID string
type UserID string
type ProductID string

// PlaceOrderCommand 用业务动词命名命令,而非数据库操作。
type PlaceOrderCommand struct {
	BuyerID UserID
	Lines   []OrderLineDraft
}

type OrderLineDraft struct {
	ProductID ProductID
	Qty       int
}
语言的演进与维护

语言会随业务演进,需要低成本维护机制:

  • ADR / RFC:当术语含义变化(例如「预售」从全款改为定金),用简短架构记录说明新旧语义与兼容期。
  • 版本化 API:对外契约(REST / gRPC / 事件 Schema)与术语表联动更新,避免「文档是新的、代码是旧的」。
  • 定期 Review:每个迭代挑一个争议需求,反问「我们用的是哪一个上下文里的定义?」

演进示例:当业务引入「先用后付」,需明确它属于支付上下文的授信产品,还是订单上下文的支付子状态——结论应写回术语表,并调整 OrderStatus 与集成事件名,而不是仅在 if 分支加 flag。

版本化策略:术语表应该有版本号(如 v1.0、v1.1),每次重大变更(如删除术语、修改定义)都升级版本并记录变更日志。这样新人可以追溯「为什么当初选择这个词」,避免重复讨论已解决的问题。同时,对外API的命名也应该与术语表版本对应,例如PlaceOrder v1使用术语表v1.0的定义,PlaceOrder v2使用v1.1的定义,保证向后兼容。

跨团队同步:术语表变更应该通知所有相关团队。可以在 Git 仓库中设置术语表文件的 CODEOWNERS,任何修改都需要相关团队的 Approver 确认。这样可以避免「术语表改了但代码没改」或「不同团队理解不一致」的问题。

反模式:技术术语污染业务讨论

典型反模式包括:在评审中使用 OrderDTO / OrderVO、把数据库动词当业务语言(InsertOrder)、用魔法状态码沟通。它们会阻断业务专家的参与。

package badexample

// ❌ 技术噪声:业务方无法从命名理解用例意图
type OrderService struct{}

func (s *OrderService) HandleSubmit(data map[string]any) error { return nil }
// ✅ 使用业务动词与强类型参数,评审可对读
package order

import "context"

type OrderService interface {
	PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error)
}
更多语言污染案例与纠正

反模式 1:用数据库字段名代替业务概念

// BAD: 数据库思维泄漏到业务层
type Order struct {
    OrderNo    string
    UserId     int64
    TotalAmt   int64
    StatusCode int    // 0=待支付 1=已支付 2=已取消 3=已关闭
}

func (s *OrderService) UpdateStatusCode(orderNo string, code int) error {
    // 业务规则隐藏在魔法数字背后
}
// GOOD: 业务语言驱动设计
type Order struct {
    ID         OrderID
    CustomerID CustomerID
    Total      Money
    Status     OrderStatus // 枚举类型
}

type OrderStatus int
const (
    StatusPendingPayment OrderStatus = iota
    StatusPaid
    StatusCanceled
    StatusFulfilled
)

// 业务动词显式化
func (o *Order) MarkAsPaid(paidAt time.Time) error {
    if o.Status != StatusPendingPayment {
        return ErrInvalidTransition
    }
    o.Status = StatusPaid
    o.PaidAt = paidAt
    return nil
}

收益:代码评审时,业务方能直接参与讨论状态转换规则,而不是盯着 SQL 猜测 status = 1 的含义。


反模式 2:接口命名只有 CRUD,没有业务意图

// BAD: 贫血模型 + CRUD
type OrderRepository interface {
    Insert(ctx context.Context, order *Order) error
    Update(ctx context.Context, order *Order) error
    Delete(ctx context.Context, id string) error
    Select(ctx context.Context, id string) (*Order, error)
}

问题

  • Update 可以改任意字段,无法表达业务约束
  • 新人看不出「订单支付」应该调用哪个方法
  • 业务规则散落在 Service 层的 if-else 中
// GOOD: 用例驱动的接口
type OrderUseCase interface {
    PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error)
    MarkAsPaid(ctx context.Context, orderID OrderID, paidAt time.Time) error
    CancelOrder(ctx context.Context, orderID OrderID, reason string) error
}

收益:接口即文档——每个方法对应一个明确的业务用例。


反模式 3:在需求评审中使用技术黑话

真实案例:产品提需求「用户支付后,订单状态改为 2」。

问题

  • 产品被迫记忆「2」的含义(下次可能记错)
  • 新人无法从文档理解业务流程
  • 数据库状态码变更时,文档需要全局替换

纠正方案

  • 需求文档使用业务语言:「用户支付后,订单状态改为已支付
  • 代码中使用枚举常量:StatusPaid
  • 数据库存储可以是数字,但对外接口和文档必须是业务术语

落地检查

  • 代码评审时,发现「魔法数字」或「技术缩写」,要求作者用业务术语重命名
  • API 文档生成时,枚举值自动展示为业务含义(如 "status": "paid"
  • 新人培训时,术语表是必读文档

上下文映射(Context Mapping)

上下文映射描述谁依赖谁、如何集成。它把组织关系与架构关系对齐,避免「谁都能改」的隐式耦合。

上下游关系模式

常见关系(节选):

模式关系典型集成电商提示
客户-供应商(Customer-Supplier)下游依赖上游,上游需考虑下游诉求版本化查询 API、批量接口订单(客户)依赖商品(供应商)
遵奉者(Conformist)下游无力改变上游模型直接采用对方模型税务、监管、强势渠道
防腐层(ACL)下游翻译上游模型,保护自身核心适配器封装第三方 SDK对接微信 / 支付宝支付
开放主机服务(OHS)上游提供稳定多租户接口标准 REST / gRPC + 兼容策略商品中心对搜索、推荐、活动统一供数
发布语言(Published Language)双方约定中立交换格式JSON Schema、Avro、开放事件规范OrderPaid 事件字段集
graph LR
    subgraph 上游
        Catalog[商品上下文]
        Promo[营销上下文]
    end
    subgraph 下游
        Checkout[结算上下文]
        Order[订单上下文]
    end
    Catalog -->|Customer-Supplier| Order
    Promo -->|Customer-Supplier| Checkout
    Checkout -->|同步编排| Order
各模式的实战应用

客户-供应商(Customer-Supplier)实践

场景:订单域(客户)依赖商品域(供应商)获取商品信息。

关键点

  • 上游(商品域)提供版本化 API,保证向后兼容
  • 下游(订单域)通过契约测试验证依赖稳定性
  • 定期召开「契约评审会」,下游提需求,上游评估可行性

实现示例

// 商品域对外提供的稳定接口(v1版本)
package catalogapi

type GetProductRequest struct {
    ProductID string `json:"product_id"`
}

type GetProductResponse struct {
    ID          string  `json:"id"`
    Title       string  `json:"title"`
    Price       float64 `json:"price"`
    Available   bool    `json:"available"`
}

// 订单域依赖商品域的接口
package order

type ProductAPI interface {
    GetProduct(ctx context.Context, productID string) (*catalogapi.GetProductResponse, error)
}

契约测试

func TestProductAPI_Contract(t *testing.T) {
    // 验证商品域的响应格式是否符合订单域的预期
    resp := &catalogapi.GetProductResponse{
        ID:    "SKU123",
        Title: "iPhone 15",
        Price: 5999.00,
        Available: true,
    }
    // 断言必需字段存在
    assert.NotEmpty(t, resp.ID)
    assert.NotEmpty(t, resp.Title)
}

遵奉者(Conformist)实践

场景:对接税务系统、支付渠道等强势上游,无力改变对方模型。

策略

  • 直接使用对方的数据结构(避免无谓的翻译层)
  • 在上游变更时快速跟进(监听对方发布公告)
  • 内部文档记录「为什么使用对方模型」(避免后人困惑)

示例

// 直接使用支付宝 SDK 的类型
import "github.com/alipay/alipay-sdk-go"

type AlipayAdapter struct {
    client *alipay.Client
}

func (a *AlipayAdapter) CreatePayment(orderID string, amount float64) error {
    // 直接使用 Alipay SDK 的请求结构
    req := alipay.TradeCreateRequest{
        OutTradeNo:  orderID,
        TotalAmount: fmt.Sprintf("%.2f", amount),
        Subject:     "订单支付",
    }
    _, err := a.client.TradeCreate(&req)
    return err
}

适用场景:上游是成熟的外部系统,模型变更频率低,翻译成本高于收益。


开放主机服务(OHS)+ 发布语言实践

场景:商品中心需要服务多个下游(搜索、推荐、营销、订单),避免为每个下游定制接口。

策略

  • 设计通用查询接口,支持灵活的筛选与投影
  • 发布标准事件(JSON Schema / Protobuf),所有下游订阅相同事件
  • 使用 API Gateway 管理多租户访问(限流、鉴权、版本路由)

示例

// 商品域发布统一的查询接口
type ProductQueryAPI interface {
    ListProducts(ctx context.Context, req ListProductsRequest) (*ListProductsResponse, error)
}

type ListProductsRequest struct {
    CategoryID string   `json:"category_id,omitempty"`
    Tags       []string `json:"tags,omitempty"`
    OnShelf    *bool    `json:"on_shelf,omitempty"`
    Limit      int      `json:"limit"`
    Offset     int      `json:"offset"`
}

发布语言(事件)

{
  "event_type": "ProductOnShelf",
  "version": "1.0",
  "product_id": "SKU123",
  "title": "iPhone 15",
  "price": 5999.00,
  "occurred_at": "2026-04-17T10:00:00Z"
}

收益

  • 下游(搜索、推荐)可以独立订阅事件,无需与商品域强耦合
  • 商品域只需维护一套接口,降低维护成本
  • 通过 Schema Registry 管理事件版本,保证向后兼容

选型决策树

flowchart TD
    start([识别上下游关系]) --> q1{上游是否可控?}
    q1 -->|是| q2{下游数量?}
    q2 -->|1-2个| customer[Customer-Supplier]
    q2 -->|3个以上| ohs[OHS + Published Language]
    q1 -->|否| q3{模型是否冲突?}
    q3 -->|冲突| acl[ACL 防腐层]
    q3 -->|不冲突| conformist[Conformist 遵奉者]
共享内核

共享内核是两方共同维护的一小块模型或库。它减少重复,但会牺牲自治,需要强治理。

电商谨慎场景:订单与库存若共享「SKU ID 类型 + 基础校验函数」这类极小内核尚可;一旦共享「库存数量字段」或「订单状态枚举」,边界会迅速模糊。

// sharedkernel/sku.go — 保持极小、稳定、少变更
package sharedkernel

type SKUCode string

func (c SKUCode) IsWellFormed() bool {
	return len(string(c)) >= 6 // 示例规则:长度下限
}

实践建议:共享内核应能通过双人评审 + 语义化版本演进;否则优先改为 Published Language(如清晰的事件字段)而非代码级共享。

何时使用共享内核

  • 两个上下文由同一团队维护,且模型变更成本低
  • 共享的是极其稳定的基础类型(如 ID、Money、Email 等值对象)
  • 双方都同意「修改共享内核需要通知并等待对方确认」

何时避免共享内核

  • 两个上下文由不同团队维护(会严重拖慢发布节奏)
  • 共享的是经常变化的业务规则(如订单状态、库存策略)
  • 无法保证「修改前通知」的纪律(会导致隐式破坏性变更)

真实案例:某团队在订单与库存之间共享了 ProductStatus 枚举。营销需求要求增加「预售」状态,订单团队快速修改了枚举并发布,但忘记通知库存团队。库存服务在处理「预售商品」时因为没有对应的分支处理逻辑,导致库存同步失败。最终双方约定:将共享内核降级为 Published Language(事件 Schema),每次变更必须走 RFC 流程并双方确认。

防腐层

防腐层把外部不稳定协议挡在边界之外,领域层只依赖自己的端口接口。

package order

import (
	"context"
	"fmt"
)

type OrderID string

// Money 表示订单上下文的应付金额(示例:用分存储,避免 float)。
type Money struct {
	cents    int64
	currency string
}

func NewMoneyFromCents(cents int64, currency string) Money {
	return Money{cents: cents, currency: currency}
}

func (m Money) DecimalYuan() string {
	if m.cents < 0 {
		return "0.00"
	}
	yuan := m.cents / 100
	fen := m.cents % 100
	return fmt.Sprintf("%d.%02d", yuan, fen)
}

// PaymentSession 是领域侧对「可跳转支付」的最小抽象,不暴露渠道字段。
type PaymentSession struct {
	CheckoutURL string
}

type StartPaymentCommand struct {
	OrderID OrderID
	Payable Money
}

// PaymentGateway 由订单领域定义:表达「我需要的支付能力」,由基础设施实现。
type PaymentGateway interface {
	StartPayment(ctx context.Context, cmd StartPaymentCommand) (*PaymentSession, error)
}
package alipayacl

import (
	"context"
	"fmt"

	"example/order"
)

// 仅示意第三方 SDK 的能力边界,避免示例依赖真实包名。
type alipayPrecreateClient interface {
	Precreate(ctx context.Context, body map[string]any) (*alipayPrecreateResult, error)
}

type alipayPrecreateResult struct {
	QRCodeURL string
}

// AlipayACL 位于基础设施侧:翻译领域命令 ↔ 支付宝请求 / 响应。
type AlipayACL struct {
	client alipayPrecreateClient
}

func NewAlipayACL(client alipayPrecreateClient) *AlipayACL {
	return &AlipayACL{client: client}
}

func (a *AlipayACL) StartPayment(ctx context.Context, cmd order.StartPaymentCommand) (*order.PaymentSession, error) {
	req := map[string]any{
		"out_trade_no": string(cmd.OrderID),
		"total_amount": cmd.Payable.DecimalYuan(),
		"subject":      fmt.Sprintf("订单支付 %s", string(cmd.OrderID)),
	}
	resp, err := a.client.Precreate(ctx, req)
	if err != nil {
		return nil, err
	}
	return &order.PaymentSession{CheckoutURL: resp.QRCodeURL}, nil
}

收益

  • 当渠道字段变更时,修改集中在 ACL;订单聚合与用例不被第三方类型污染
  • 可以为同一个端口提供多个实现(支付宝 ACL、微信 ACL、PayPal ACL),通过工厂模式或配置切换
  • 测试时可以使用 Fake 实现替代真实支付渠道,提升测试速度和可靠性
  • 防腐层承担了「翻译」职责,领域层保持纯粹,不受外部依赖污染

反模式警示:有些团队会在防腐层之外再包一层「防防腐层」,过度封装导致代码层级过深。原则是一次翻译足矣——从第三方模型翻译到领域模型,中间不需要再多一层「通用模型」。

电商系统的上下文映射实例

综合一版可挂在 Wiki 首页的示意(箭头表示依赖方向):

graph LR
    OrderBC[订单上下文]
    CatalogBC[商品上下文]
    InventoryBC[库存上下文]
    PaymentBC[支付上下文]
    LogisticsBC[物流上下文]
    PromoBC[营销上下文]

    OrderBC -->|Customer-Supplier| CatalogBC
    OrderBC -->|Customer-Supplier| InventoryBC
    OrderBC -->|ACL| PaymentBC
    OrderBC -->|Customer-Supplier| LogisticsBC
    OrderBC -->|Customer-Supplier| PromoBC
    InventoryBC -.->|极小共享内核| CatalogBC

落地检查清单

  • 每个箭头是否有明确契约(OpenAPI / Proto / 事件表)?
  • 失败模式(超时、重试、幂等)是否写清归属上下文?
  • 是否存在「绕过契约直连数据库」的路径?若有,计划消除。
集成失败案例与解决方案

案例 1:订单域直接读取库存表(违反边界)

背景:订单服务为了展示「剩余库存」,在查询订单详情时直接 JOIN 库存表。

问题

  • 库存表结构变更时,订单服务也要修改(耦合)
  • 库存域无法独立演进(加缓存、分库、切换存储)
  • 破坏了「库存域拥有库存数据」的所有权原则

解决方案

// 订单域定义端口
type StockQueryPort interface {
    GetAvailableQty(ctx context.Context, sku string) (int, error)
}

// 基础设施层实现(调用库存域 API)
type RemoteStockAdapter struct {
    client *stockservice.Client
}

func (a *RemoteStockAdapter) GetAvailableQty(ctx context.Context, sku string) (int, error) {
    resp, err := a.client.GetStock(ctx, &stockpb.GetStockRequest{Sku: sku})
    if err != nil {
        return 0, fmt.Errorf("query stock: %w", err)
    }
    return int(resp.AvailableQty), nil
}

收益:库存域可以独立优化存储、加缓存、切换数据库,订单域只依赖接口契约。


案例 2:营销规则变更导致订单金额计算不一致

背景:营销域上线新规则后,订单域的价格计算逻辑未同步更新,导致订单金额与用户预览不一致。

根因:没有统一的计价入口,订单域、购物车、PDP 各自实现计算逻辑。

解决方案

  1. 新建计价上下文作为编排者
  2. 所有场景通过计价域的 Calculate 接口获取价格
  3. 营销规则变更时,只需更新营销域;计价域自动订阅规则变更事件
graph LR
    PDP[商品详情页] -->|试算请求| Pricing[计价上下文]
    Cart[购物车] -->|试算请求| Pricing
    Order[订单域] -->|确认金额| Pricing
    Pricing -->|查基础价| Catalog[商品域]
    Pricing -->|查规则| Promo[营销域]

案例 3:支付回调丢失导致订单状态不同步

背景:支付域通过 HTTP 回调通知订单域支付成功,但网络抖动导致回调丢失,订单长时间停留在「待支付」状态。

问题

  • 同步回调不可靠(网络、超时、重启)
  • 缺少补偿机制

解决方案

  1. 异步事件 + 重试:支付域发布 PaymentCaptured 事件到 Kafka,订单域订阅并幂等处理
  2. 对账任务:每小时扫描「待支付」订单,调用支付域查询实际状态,发现不一致则补偿
  3. 状态机保护:订单状态机禁止「已支付 → 待支付」的逆向转换,防止数据损坏
// 订单域订阅支付事件
func (s *OrderService) HandlePaymentCaptured(ctx context.Context, event PaymentCapturedEvent) error {
    order, err := s.repo.FindByID(ctx, event.OrderID)
    if err != nil {
        return err
    }
    // 幂等检查
    if order.Status == StatusPaid {
        return nil // 已处理
    }
    return order.MarkAsPaid(event.PaidAt)
}

经验:跨上下文集成必须考虑失败场景——同步调用加超时与重试,异步事件加幂等与对账。


领域的分类

战略精炼的重要产出,是把公司能力地图分为 核心域、支撑域、通用域,以指导人力与风险的投放。

核心域、支撑域、通用域
  • 核心域:差异化竞争力所在,复杂且多变,应重仓自研与深度建模。例如亚马逊的推荐算法、阿里的交易风控,这些是竞争壁垒,必须投入顶尖人才与架构资源。
  • 支撑域:业务必需但非胜负手,可适度定制,避免过度设计。例如商品管理、库存管理,参考业界成熟模型即可,不必追求极致创新。
  • 通用域:行业共性,成熟外包或 SaaS 更划算。例如短信通知、对象存储、日志监控,云服务比自研更经济。
投资策略

可用「四维度」快速评分:业务价值、复杂度、变化频率、差异化。不必追求精确分数,关键是相对比较与资源承诺。

子域示例倾向投资建议(示意)
订单履约核心域架构师 + 高可用工程化,明确 SLA 与演练
库存准确性核心域并发控制、对账与仿真压测
商品管理支撑域参考业界模型,控制自定义范围
消息通知通用域使用云短信 / 邮件 SaaS,内部仅封装网关
投资决策案例

案例 1:搜索推荐从支撑域升级为核心域

背景:某平台早期将搜索视为支撑域,采用开源 Elasticsearch + 简单配置。随着 GMV 增长,发现 70% 流量来自搜索,转化率直接影响营收

决策过程

  • 重新评估:搜索从「辅助发现」变为「核心转化入口」
  • 投资升级
    • 组建搜索算法团队(相关性、个性化排序)
    • 自研召回引擎与排序服务(而非依赖 ES 默认评分)
    • 建设 ABTest 平台与实时指标体系
  • 资源配比:从 1 人维护 → 8 人团队(算法 + 工程)

收益:搜索点击率提升 40%,GMV 贡献占比从 70% 提升到 85%。


案例 2:自研消息队列的代价

背景:某团队因「担心 Kafka 运维复杂」,自研了轻量级消息队列。

问题暴露

  • 维护成本:集群故障、数据丢失、性能瓶颈需要专人处理
  • 功能缺失:不支持事务、延迟消息、死信队列等企业级特性
  • 团队分心:核心业务开发被「救火中间件」打断

纠正方案

  • 迁移到云服务商托管的 Kafka(或 RocketMQ)
  • 内部仅封装薄的 SDK 层(日志、监控、错误处理)
  • 释放的人力投入到核心业务优化

经验:通用域的自研往往是「过早优化」——先用成熟方案,待瓶颈确认后再评估自研价值。


常见误判

  • 把所有模块都标成核心域:资源分散,真正的差异化无人深耕。
  • 把支撑域做成「无设计」:质量太差会反向拖垮核心域(数据错误、不可用)。
  • 在通用域自研中间件:短期有掌控感,长期维护成本侵蚀核心业务投入。
  • 忽视支撑域的稳定性:商品数据错误、库存不准确会直接影响订单转化——支撑域不是「二等公民」。
投资复盘机制

建议每半年召开一次「子域投资复盘会」:

  1. 回顾当前分类:哪些域的重要性发生了变化?
  2. 评估资源配比:核心域是否得到了足够的架构师与工程资源?
  3. 识别欠投资域:哪些支撑域因质量问题拖累了核心域?
  4. 调整策略:将资源从「过度投资的通用域」转移到「欠投资的核心域」

输出示例

子域上次分类本次分类资源变化理由
搜索推荐支撑域核心域+5 人转化率提升是 Q1 关键目标
消息队列通用域通用域-2 人迁移到云服务,释放人力
库存准确性核心域核心域+3 人大促准备,加强对账与监控
电商平台的领域分类

结合国内中大型平台的常见划分(需按公司战略微调):

核心域候选:交易订单、库存准确性、交易风控、推荐与搜索体验(若差异化来自发现与转化)。

支撑域候选:商品中心、营销规则、计价、物流对接、客服工单。

通用域候选:登录注册、对象存储、日志监控、基础消息通道。

详细领域分类与资源配比
子域分类业务价值复杂度变化频率差异化资源配比示例
订单履约核心域极高10人团队,架构师+高工
库存准确性核心域极高8人团队,专项优化
交易风控核心域极高极高极高算法团队+工程团队
搜索推荐核心域极高极高算法+工程双轨
商品中心支撑域5人团队,参考业界
营销系统支撑域6人团队,规则引擎
计价系统支撑域3人团队,统一接口
物流对接支撑域2人维护,适配器模式
登录注册通用域使用 OAuth2 服务
对象存储通用域云服务(OSS/S3)
消息通知通用域云服务+薄封装
分类决策的实战案例

案例 1:库存系统从支撑域升级为核心域

初始阶段

  • 分类:支撑域(「库存只是个数字,没啥复杂的」)
  • 投资:2 人维护,简单 Redis + MySQL

问题暴露

  • 大促期间频繁超卖,投诉量激增
  • 供应商库存同步延迟,导致用户下单后被取消
  • 库存准确性成为用户信任的核心指标

重新评估

  • 业务价值:库存不准 → 超卖 → 投诉 → 品牌损失(极高)
  • 复杂度:多仓、多供应商、实时同步、预占释放(高)
  • 差异化:准确率 99.9% vs 竞品 95%(高)
  • 结论:升级为核心域

投资升级

  • 团队扩充到 8 人(架构师 + 高工 + 算法)
  • 建设预占释放机制、对账系统、实时监控
  • 引入分布式锁与 Lua 脚本保证并发正确性
  • 建立库存准确性 SLA(99.95%)

收益:超卖率从 5% 降到 0.1%,用户满意度显著提升。


案例 2:消息通知从自研降级为云服务(通用域)

初始阶段

  • 分类:支撑域
  • 投资:4 人团队自研短信 / 邮件 / 推送网关

问题暴露

  • 维护成本高(渠道对接、失败重试、速率控制)
  • 功能落后(不支持模板管理、A/B 测试)
  • 团队被「救火」占用,无法投入核心业务

重新评估

  • 业务价值:中(通知到达率影响体验,但非核心竞争力)
  • 差异化:无(所有平台都需要通知,无差异化空间)
  • 结论:降级为通用域,使用云服务

调整方案

  • 迁移到云服务商(阿里云 / 腾讯云 / AWS SNS)
  • 内部仅保留薄封装层(日志、监控、降级)
  • 释放的 4 人转投到核心域(搜索推荐优化)

收益:维护成本降低 80%,功能更丰富(模板管理、多渠道支持),团队聚焦核心业务。


包结构与投资映射
// 用包结构反映投资重心(示意):核心域包内更完整领域模型,外围保持薄封装。

// 核心域:完整的 DDD 分层
// core/order/
//   ├── domain/       (聚合、实体、值对象、领域服务)
//   ├── application/  (用例、命令处理器)
//   ├── adapter/      (HTTP、gRPC、事件订阅)
//   └── infra/        (仓储实现、外部集成)

// 支撑域:适度建模,控制复杂度
// supporting/catalog/
//   ├── service/      (业务逻辑)
//   ├── repository/   (数据访问)
//   └── api/          (对外接口)

// 通用域:薄封装,优先使用外部服务
// generic/notification/
//   ├── client/       (云服务 SDK 封装)
//   └── config/       (配置与降级)

说明:包划分只是辅助沟通的手段,真正重要的是团队边界、发布边界与数据所有权是否与之一致。核心域投入更多架构设计与代码质量保障,支撑域保持适度复杂度,通用域优先复用成熟方案。


本章小结

核心要点回顾
  • 战略设计解决边界、协作与语言问题,是战术建模的前置条件;与第 1 章的架构骨架互补而非重复。
  • 限界上下文按业务能力、语言分叉、一致性与团队现实划分;订单、商品、营销应各自维护模型,以 ID 与快照连接。
  • 通用语言需要术语表、命令动词与演进机制,避免技术黑话污染评审。
  • 上下文映射把依赖关系产品化:客户-供应商、防腐层、共享内核(克制使用)、开放主机服务与发布语言各得其所。
  • 子域分类驱动投资:核心域求精,支撑域求稳,通用域求省,并定期复盘调整。
落地检查清单

在项目启动或重构时,可用以下清单自查:

限界上下文识别

  • 是否识别出 3-8 个主要限界上下文?(过少说明边界模糊,过多说明过度拆分)
  • 每个上下文是否有清晰的职责描述(一句话能说清楚)?
  • 是否避免了「大而全的领域模型」(如一个 Product 类服务所有上下文)?
  • 跨上下文的数据引用是否通过 ID 与快照,而非直接依赖对方的聚合类型?

通用语言建立

  • 是否建立了术语表(docs/glossary.md)?
  • 代码中的核心类型、方法名是否与术语表一致?
  • 是否消除了魔法数字与技术黑话(如 status=2UpdateData)?
  • 新需求评审时,是否强制使用术语表中的词汇?

上下文映射

  • 是否有明确的上下文映射图(类似 4.4.4 的 Mermaid 图)?
  • 每个依赖关系是否标注了集成模式(Customer-Supplier / ACL / OHS)?
  • 是否为关键集成定义了契约(API 文档 / Proto / 事件 Schema)?
  • 是否考虑了失败场景(超时、重试、降级、对账)?

子域投资

  • 是否明确标记了核心域、支撑域、通用域?
  • 核心域是否得到了架构师与高级工程师的投入?
  • 通用域是否优先使用成熟的开源 / 云服务,而非自研?
  • 是否有定期复盘机制(每半年 Review 一次子域分类)?
常见陷阱总结
陷阱症状纠正方向
战术先行代码结构优美,但跨团队协作仍然混乱补充战略设计:画出上下文地图,建立术语表
边界过度拆分10+ 个微服务,每个只有几百行代码合并职责相近的上下文,先模块化后服务化
共享大模型一个 Product 类被所有服务依赖每个上下文独立建模,用 ACL 翻译
术语不一致同一概念有 3 种命名(Order / Purchase / Transaction)通过工作坊对齐,强制使用术语表
忽视集成失败只考虑 Happy Path,线上频繁出现数据不一致为每个集成点设计失败处理(重试、降级、对账)
投资不均衡核心域缺人,通用域自研中间件占用大量资源重新评估子域分类,调整资源配比
实践建议
  1. 战略设计不是一次性工作——建议每季度召开一次「边界复盘会」,回顾上下文划分是否合理,术语表是否需要更新。

  2. 工作坊轻量化——不必追求「完美的限界上下文图」,先建立 60% 共识,剩余在实际编码中验证和调整。

  3. 术语表即文档——将术语表提交到代码仓库,作为新人培训与需求评审的必读材料。

  4. 从小处着手——若团队尚未实践 DDD,可以从「建立一个术语表」或「重命名一个核心 API」开始,而非一次性重构整个系统。

  5. 与第 1 章方法论结合——战略设计画出边界 → Clean Architecture 确保依赖方向 → DDD 战术设计守护不变量 → CQRS 优化读写路径,四者协同发力。

与下一节的衔接:接下来将把镜头转向单个系统内部,讨论三层架构、Clean Architecture、DDD 战术与 CQRS 如何共同建立稳定的内部秩序。战略设计先回答「边界是什么、边界在哪里」,系统内部结构再回答「边界之内如何组织代码与数据流」。


3.3 系统内部结构设计

为什么要单独讨论系统内部结构

如果说本章解决的是「系统应该被划分成哪些业务边界」,那么这一章要回答的是另一个同样关键的问题:在边界已经划定之后,单个系统内部到底应该怎么组织?

很多团队在架构演进时,会把两个层面混在一起讨论:

  • 一边在谈限界上下文怎么划
  • 一边又在争论 Handler 能不能直接调 Repository
  • 同时还在讨论聚合根、值对象、读写分离和查询性能

这样做的问题是,边界问题、依赖问题、建模问题和数据流问题被混成一团,最后往往谁都没有真正讲清楚。

对架构师来说,系统内部结构设计至少要同时回答四个问题:

  1. 代码先按什么方式分层,团队才容易协作?
  2. 依赖应该按什么方向流动,外部技术才不会污染核心业务?
  3. 复杂业务规则应该由什么模型承载,而不是散落在 Service 里?
  4. 当读和写的目标已经出现冲突时,数据流又该如何拆开?

这也正对应了本章的四个关键抓手:

  • 三层架构:先建立最基本的职责秩序
  • Clean Architecture:再约束依赖方向
  • DDD 战术设计:让复杂规则回到模型内部
  • CQRS:当读写目标冲突时,再拆分读写路径

从这个角度看,这四者并不是彼此替代,而是逐层叠加。

从业务边界到系统内部结构

本章讲的战略设计,解决的是「地图」问题:哪些能力属于订单,哪些属于库存,哪些属于营销,团队之间怎样通过上下文地图协作。

而本章解决的是「房间内部装修」问题:当我们已经确定了这里是订单上下文、那里是库存上下文之后,订单系统内部又该如何分层、如何建模、如何组织读写路径。

如果没有本章的边界,系统内部结构设计会失去落点;
如果只有边界、没有内部结构,系统很快又会退化成新的大泥球。

因此,这一章在全书里承担的是承上启下的角色:

  • 承接本章:边界已经划清
  • 通向本章的 3.4 部分:单个系统内部有了秩序之后,才有条件进一步讨论系统之间如何协作
架构师在这一层真正关注什么

在系统内部结构设计这一层,架构师最需要关注的,通常不是“某个类放在哪个目录”,而是以下几类长期成本:

  • 变更成本:一个新需求会不会牵一发而动全身?
  • 理解成本:新人能不能在有限时间内看懂业务主线?
  • 测试成本:核心逻辑能否脱离数据库和消息中间件被验证?
  • 扩展成本:当新促销规则、新支付方式、新查询场景出现时,系统能否局部演进?

这也是为什么系统内部结构设计,不能只靠“项目目录看起来很整齐”来判断。真正重要的是:职责有没有分清、依赖有没有收束、规则有没有回到模型内部、读写目标有没有被正确拆分。


标准三层架构:多数项目的默认起点

为什么三层架构会成为默认起点

在系统设计的早期阶段,最重要的往往不是“架构是否优雅”,而是“职责是否基本清楚”。对于一个刚开始建设的 order-service 来说,下单、查询订单、支付后更新状态、超时关单,这些需求虽然已经涉及接口、业务逻辑和数据存储,但复杂度通常还不足以支撑更重的架构方法论。此时,标准三层架构往往是一个合适的起点。

它之所以在很多项目里成为默认选择,不是因为它“最先进”,而是因为它在低复杂度阶段最务实。对多数团队而言,先让代码按职责分区、让调用链基本清晰,往往比一开始就引入大量抽象更重要。

三层架构的核心分工

三层架构的核心思想很简单:把系统按职责拆成表现层、业务逻辑层和数据访问层。如果用更统一的工程命名来表达,这三类职责通常会分别落在 interfacesapplicationinfrastructure 中。这样做的目的不是追求抽象,而是让不同类型的代码各归其位:

  • 表现层(Interfaces):接收 HTTP、RPC 或消息请求,完成参数解析和响应组装
  • 业务逻辑层(Application):承载核心业务流程,例如创建订单、计算总价、更新状态
  • 数据访问层(Infrastructure 中的 persistence):负责和数据库交互,执行查询、插入和更新操作

这里要特别注意,三层架构首先强调的是职责分层,而不是接口隔离。在这个阶段,层与层之间通常可以直接调用,不需要一开始就为每一层都设计 Port、Adapter 和复杂的依赖注入。

Go 项目中的典型目录映射

对于一个订单服务,这种分层通常可以组织成如下结构:

order-service/
├── cmd/
│   ├── order-server/               # 同步服务入口:HTTP / RPC
│   │   └── main.go
│   ├── order-job/                  # 定时任务入口
│   │   └── main.go
│   └── order-consumer/             # 消息消费入口
│       └── main.go
├── internal/
│   ├── interfaces/                 # 表现层:接收外部请求
│   │   ├── http/
│   │   │   └── order.go
│   │   ├── rpc/
│   │   │   └── order.go
│   │   ├── job/
│   │   │   ├── close_timeout_order.go
│   │   │   └── retry_publish.go
│   │   └── event/
│   │       ├── payment_paid.go
│   │       ├── stock_reserved.go
│   │       └── stock_failed.go
│   ├── application/                # 业务逻辑层:创单、支付、取消、关单
│   │   ├── service/
│   │   │   ├── order.go
│   │   │   ├── job.go
│   │   │   ├── consumer.go
│   │   │   └── event.go
│   │   └── dto/
│   │       ├── request.go
│   │       └── response.go
│   ├── model/                      # 早期共享模型,承载数据结构
│   │   ├── order.go
│   │   ├── request.go
│   │   └── event.go
│   ├── infrastructure/             # 基础设施:MySQL、日志、事件总线
│   │   ├── persistence/
│   │   │   ├── order.go
│   │   │   └── transaction.go
│   │   ├── event/
│   │   │   └── event_bus.go
│   │   ├── logger/
│   │   │   └── logger.go
│   │   └── mysql/
│   │       └── mysql_db.go
│   └── bootstrap/
│       └── app.go                  # 程序启动与依赖组装
└── go.mod

这里虽然目录名已经统一成了 interfaces / application / infrastructure 这套风格,但它的本质仍然是三层架构。interfaces 对应表现层,application 对应业务逻辑层,infrastructure/persistence 对应数据访问层。额外出现的 model 只是为了集中承载共享数据结构,例如 OrderCreateOrderRequest,它还不是后续 DDD 阶段那种真正承担业务规则的 domain

一个订单服务的调用链

以“创建订单”接口为例,这条调用链通常是这样的:

HTTP / RPC 请求
    -> interfaces.CreateOrder
    -> application.CreateOrder
    -> infrastructure.persistence.Save
    -> MySQL

在这条链路中,每一层都只做自己该做的事情。

**表现层(Interfaces)**负责与外部世界打交道。它知道请求从哪里来,也知道响应该怎么返回,但它不应该承担核心业务判断。一个典型的创单 Handler 通常会做下面几件事:

  • 接收 HTTP 或 RPC 请求
  • 解析 customer_id、商品列表等参数
  • 做最基础的参数合法性校验
  • 调用 OrderService.CreateOrder
  • 把结果组装成 API 响应返回

也就是说,表现层回答的是“外部如何调用系统”,而不是“系统内部如何完成下单”。

**业务逻辑层(Application)**是三层架构的中心。它负责把“创建订单”这个业务动作真正组织起来,例如校验商品列表是否为空、计算订单总价、生成订单号、设置订单初始状态、调用 Repository 持久化订单,以及在需要时发布订单创建事件。

示例代码可以写成这样:

// application/service/order.go — 标准三层架构中的业务逻辑层
type OrderService struct {
    repo *persistence.OrderRepository  // 直接依赖具体实现
    db   *sql.DB
}

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) (*Order, error) {
    order := &Order{
        CustomerID: req.CustomerID,
        Items:      req.Items,
    }
    order.Total = s.calculateTotal(order.Items)
    return s.repo.Save(ctx, order)
}

这段代码很好地体现了三层架构在早期的特点:简单、直接、上手快Application Service 直接依赖持久化实现,不需要先设计接口、适配器、领域对象等抽象。对于一个业务规则还不复杂的系统来说,这种直接性反而能提高开发效率。

**数据访问层(Infrastructure/Persistence)**的职责是把业务层提出的“保存订单”“查询订单”这些需求,翻译成具体的数据库操作。例如,OrderRepository 里可能包含下面这些方法:

  • Save(order):插入或更新订单
  • FindByID(orderID):根据订单 ID 查询订单
  • ListPendingPaymentBefore(t):查询超时未支付订单

它的价值在于:把 SQL、表结构、连接池、事务细节集中到一个位置。这样,业务层不需要到处散落 SQL 语句,表现层也不必知道数据库长什么样。

为什么这种结构在项目初期很好用

在标准三层架构中,层与层之间通常不需要一开始就为每一层专门定义接口和适配器。更常见的做法是:

  • interfaces 直接调用 application
  • application 直接调用 infrastructure/persistence
  • 各层之间通过普通的函数签名和结构体协作

也就是说,三层架构首先解决的是“代码应该放在哪一层”,而不是“核心业务到底应该依赖谁”。这也是它和后续 Clean Architecture 的关键区别。

这种结构在项目初期很好用,通常有三个原因。

第一,它容易理解。即使团队成员没有 DDD 或 Clean Architecture 背景,也能快速知道一个需求应该落在哪一层。

第二,它开发成本低。不需要在一开始就设计大量接口和抽象,代码路径短,调试方便,适合快速迭代。

第三,它足够支撑早期业务。对于订单创建、订单查询、支付回调、超时关单这类相对直接的业务流程,三层架构通常已经可以胜任。

换句话说,三层架构不是“过时的做法”,而是在业务复杂度还低时,一种成本最低的秩序化手段

三层架构的边界与典型问题

三层架构的问题,通常不是一开始就出现,而是在业务逐渐变复杂时慢慢显现。最常见的几个信号是:

  • Application 层越来越大,开始同时处理业务规则、事务、SQL 细节和外部依赖
  • Infrastructure/Persistence 和数据库实现耦合过深,换存储成本高
  • Model 逐渐退化成纯数据结构,真正的业务规则散落在多个 Application Service 方法里
  • 单元测试越来越困难,因为业务逻辑强依赖数据库和基础设施
  • 同一个“订单”概念,在不同模块里开始出现不一致的含义

例如,当你想把 MySQL 更换为 PostgreSQL 时,可能会发现 Application 层虽然没有直接操作数据库连接,但它已经对持久化实现、事务组织方式,甚至某些 MySQL 特有能力形成了隐式依赖。此时,问题就不再只是“换个数据库驱动”这么简单,而是说明:业务逻辑与基础设施实现已经纠缠在一起了

这正是三层架构的边界所在。它非常适合作为系统的起点,但当复杂度继续增长时,仅靠“Interfaces / Application / Persistence”这条主线,已经不足以持续控制系统复杂性。接下来,就需要进一步引入更明确的依赖边界、更强的业务模型表达,以及更清晰的读写分离策略。这也是后续要引入 Clean Architecture、DDD 和 CQRS 的原因。


Clean Architecture(整洁架构)

核心思想:依赖规则

Clean Architecture 由 Robert C. Martin(Uncle Bob)在 2012 年提出,其核心思想非常简单:

业务逻辑应该独立于 UI、数据库、框架或任何外部代理。

这句话的含义是:当你决定从 MySQL 换到 PostgreSQL,或者把 Web 框架从 Gin 换到 Echo 时,核心的业务逻辑(Use Cases 和 Entities)不需要改动一行代码

依赖规则:源代码的依赖方向只能向内。外层(如数据库、Web 框架)可以依赖内层,但内层绝不能知道外层的存在

┌──────────────────────────────────────────────────────────────┐
│  Frameworks & Drivers  (Web, DB, External APIs)              │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  Interface Adapters  (Controllers, Gateways, Repos)  │    │
│  │  ┌──────────────────────────────────────────────┐    │    │
│  │  │  Application Business Rules  (Use Cases)     │    │    │
│  │  │  ┌──────────────────────────────────────┐    │    │    │
│  │  │  │  Enterprise Business Rules (Entities) │    │    │    │
│  │  │  └──────────────────────────────────────┘    │    │    │
│  │  └──────────────────────────────────────────────┘    │    │
│  └──────────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────────┘

                   依赖方向 ──────→ 向内
四层模型

Clean Architecture 将系统划分为四层,每层有清晰的职责:

层级职责示例
Entity(实体)最核心的业务规则,与应用无关Order, Product 的领域模型
Use Cases(用例)特定于应用的业务逻辑“处理订单”、“计算运费”
Interface Adapters(接口适配器)数据格式转换,连接内外层Controller, Presenter, Repository 接口实现
Frameworks & Drivers(框架和驱动)具体技术实现MySQL, Redis, Gin, gRPC

关键理解

  • Entity 层是纯业务逻辑,不知道 HTTP、数据库、消息队列的存在
  • Use Case 层编排业务流程,依赖 Entity 层的接口,不依赖具体实现
  • Adapter 层连接内外,实现 Entity/Use Case 层定义的接口
  • Framework 层是具体的技术选型,可以随时替换
Go 项目中的典型目录映射

在 Go 项目中,Clean Architecture 通常可以映射成和前面三层架构相近、但依赖方向更清晰的目录结构。例如,一个 order-service 可以演进成这样:

order-service/
├── cmd/
│   ├── order-server/
│   │   └── main.go                     # HTTP / RPC 服务入口
│   ├── order-job/
│   │   └── main.go                     # 定时任务入口
│   └── order-consumer/
│       └── main.go                     # 消息消费入口
├── internal/
│   ├── interfaces/                     # 接口层:所有进入系统的请求入口
│   │   ├── http/
│   │   │   ├── order.go
│   │   │   └── health.go
│   │   ├── rpc/
│   │   │   └── order.go
│   │   ├── event/
│   │   │   ├── payment_paid.go
│   │   │   ├── stock_reserved.go
│   │   │   └── stock_failed.go
│   │   └── job/
│   │       ├── close_timeout_order.go
│   │       └── retry_publish.go
│   ├── application/                    # 应用层:编排业务用例
│   │   ├── service/
│   │   │   ├── create_order.go
│   │   │   ├── cancel_order.go
│   │   │   ├── mark_order_paid.go
│   │   │   └── close_timeout_order.go
│   │   ├── dto/
│   │   │   ├── request.go
│   │   │   ├── response.go
│   │   │   └── event.go
│   │   └── transaction.go             # 应用层依赖的事务抽象(可选)
│   ├── domain/                         # 最内层:业务概念与规则
│   │   ├── order.go                    # Order 实体 / 核心业务规则
│   │   ├── order_item.go               # OrderItem 实体 / 值对象
│   │   ├── repository.go               # Repository Port
│   │   ├── event_publisher.go          # EventPublisher Port
│   │   ├── event.go                    # 领域事件定义
│   │   └── errors.go                   # 领域错误
│   ├── infrastructure/                 # 基础设施层:技术实现
│   │   ├── persistence/
│   │   │   ├── order_repo.go           # Repository Port 的 MySQL 实现
│   │   │   ├── order_po.go             # 持久化对象 / 表映射
│   │   │   └── transaction.go          # 事务实现
│   │   ├── event/
│   │   │   └── publisher.go            # EventPublisher Port 的实现
│   │   ├── cache/
│   │   │   └── order_cache.go
│   │   ├── mysql/
│   │   │   └── db.go
│   │   ├── mq/
│   │   │   └── producer.go
│   │   ├── logger/
│   │   │   └── logger.go
│   │   └── config/
│   │       └── config.go
│   └── bootstrap/
│       ├── app.go                      # 依赖注入
│       ├── router.go                   # HTTP / RPC 路由注册
│       └── wiring.go                   # application / interfaces / infrastructure 组装
├── api/
│   ├── http/
│   │   └── order.md
│   └── proto/
│       └── order.proto
└── go.mod

关键设计原则

  1. 依赖方向向内interfaces 依赖 applicationapplication 依赖 domaininfrastructure 负责从外层实现内层需要的能力
  2. 接口在内层定义:例如 internal/domain/repository.go 定义 Port,internal/infrastructure/persistence/order_repo.go 提供实现
  3. 框架在外层:Gin、MySQL、Kafka、Redis 等技术细节都停留在 interfacesinfrastructure,不会侵入核心业务
三层架构与 Clean Architecture 的区别

这一点非常容易被误解。很多团队以为自己已经做了三层分层,就等于已经做了 Clean Architecture。其实两者并不等价:

  • 三层架构关注的是分层职责:表现层、业务层、数据访问层分别负责什么
  • Clean Architecture 关注的是依赖方向:核心业务是否只依赖抽象,而不是依赖具体实现

所以,三层架构回答的是“代码应该放在哪一层”,而 Clean Architecture 回答的是“核心业务应该依赖谁”。

这也是为什么一个项目可以“已经分层”,但仍然没有真正获得可替换性和可测试性。只要 application/service 仍然依赖某个具体的 MySQL repository、具体的消息发布器、具体的框架对象,业务逻辑就还没有真正从基础设施中解耦出来。

改造要点:引入 Port,把实现推到外层

阶段 1 的关键动作通常有两个:

  • 在内层定义接口,例如 OrderRepository
  • 让外层提供具体实现,例如 MySQLOrderRepository

代码通常会变成这样:

// domain/repository.go — 内层定义接口
package domain

type OrderRepository interface {
    Save(ctx context.Context, order *Order) error
}

// application/service/create_order.go — 应用层依赖接口而非实现
type CreateOrderService struct {
    repo domain.OrderRepository
}

func (svc *CreateOrderService) Execute(ctx context.Context, req CreateOrderRequest) error {
    order := domain.NewOrder(req.CustomerID)
    // ... 应用逻辑 ...
    return svc.repo.Save(ctx, order)
}

在这段代码里,CreateOrderService 并不知道底层是不是 MySQL,也不知道外面是不是 HTTP Handler 调用它。它只关心“创建订单”这件业务本身。这种内层稳定、外层可替换的结构,就是 Clean Architecture 的核心价值。

以一个订单服务的调用链为例

如果把 Clean Architecture 放到一个具体的 order-service 中,它的调用链通常会比三层架构多出一层清晰的“依赖反转”:

HTTP 请求
    -> adapter/inbound/http.OrderHandler
    -> usecase.PlaceOrderUseCase
    -> domain.Order / domain.Repository
    -> adapter/outbound/persistence.MySQLOrderRepo
    -> MySQL

这条链路的关键,不只是“多了几层目录”,而是每一层的依赖关系开始变得可控:

  • HTTP Handler 只负责接收请求、解析参数、返回响应
  • Use Case 只负责组织业务流程,不直接操作数据库
  • Domain 负责承载核心业务规则和抽象接口
  • Outbound Adapter 负责把领域层定义的接口落到 MySQL、Redis 或 MQ 上

从外面看,它和三层架构一样,仍然是在处理“下单请求”;但从里面看,业务逻辑和基础设施已经不再直接耦合。也正因为如此,后面不管你是替换存储、补单元测试,还是进一步引入 DDD,都有了更清晰的落脚点。

核心价值:技术无关的业务逻辑

让我们通过一个具体的代码示例来理解 Clean Architecture 的价值。

反例:依赖具体实现
// ❌ 反例:OrderService 直接依赖具体的 MySQL 实现
package service

import (
    "database/sql"
    _ "github.com/go-sql-driver/mysql"
)

type OrderService struct {
    db *sql.DB  // 直接依赖 MySQL
}

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) (*Order, error) {
    // 业务逻辑和数据库操作混在一起
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil {
        return nil, err
    }
    defer tx.Rollback()
    
    order := &Order{
        CustomerID: req.CustomerID,
        Items:      req.Items,
        Total:      calculateTotal(req.Items),
    }
    
    // SQL 语句直接写在业务逻辑中
    _, err = tx.ExecContext(ctx, 
        "INSERT INTO orders (customer_id, total, status) VALUES (?, ?, ?)",
        order.CustomerID, order.Total, "pending")
    if err != nil {
        return nil, err
    }
    
    return order, tx.Commit()
}

问题

  1. 业务逻辑和数据库操作混在一起,难以测试
  2. 换数据库(如 PostgreSQL)需要改动业务逻辑代码
  3. 无法编写不依赖数据库的单元测试
  4. OrderService 直接依赖 database/sql 和 MySQL 驱动
正例:依赖抽象
// ✅ 正例:Clean Architecture 方式

// domain/order/repository.go — 内层只定义接口
package order

type Repository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id string) (*Order, error)
}

// domain/order/order.go — 领域模型
package order

type Order struct {
    id         string
    customerID string
    items      []OrderItem
    status     Status
    totalPrice Money
}

func NewOrder(customerID string) *Order {
    return &Order{
        id:         generateID(),
        customerID: customerID,
        items:      make([]OrderItem, 0),
        status:     StatusDraft,
    }
}

func (o *Order) AddItem(product Product, qty int) error {
    if o.status != StatusDraft {
        return ErrOrderNotEditable
    }
    if qty <= 0 {
        return ErrInvalidQuantity
    }
    item := NewOrderItem(product, qty)
    o.items = append(o.items, item)
    o.recalculateTotal()
    return nil
}

func (o *Order) Place() error {
    if len(o.items) == 0 {
        return ErrEmptyOrder
    }
    o.status = StatusPlaced
    return nil
}
// usecase/place_order.go — Use Case 依赖接口而非实现
package usecase

import "myapp/domain/order"

type PlaceOrderUseCase struct {
    orderRepo order.Repository  // 依赖抽象接口
}

func (uc *PlaceOrderUseCase) Execute(ctx context.Context, req PlaceOrderRequest) (*PlaceOrderResponse, error) {
    // 创建订单聚合
    o := order.NewOrder(req.CustomerID)
    
    // 添加商品
    for _, item := range req.Items {
        product := order.Product{ID: item.ProductID, Price: item.Price}
        if err := o.AddItem(product, item.Quantity); err != nil {
            return nil, err
        }
    }
    
    // 下单
    if err := o.Place(); err != nil {
        return nil, err
    }
    
    // 持久化(通过接口)
    if err := uc.orderRepo.Save(ctx, o); err != nil {
        return nil, err
    }
    
    return &PlaceOrderResponse{OrderID: o.ID()}, nil
}
// adapter/persistence/mysql_order_repo.go — 外层实现接口
package persistence

import (
    "database/sql"
    "myapp/domain/order"
)

type MySQLOrderRepo struct {
    db *sql.DB
}

func NewMySQLOrderRepo(db *sql.DB) order.Repository {
    return &MySQLOrderRepo{db: db}
}

func (r *MySQLOrderRepo) Save(ctx context.Context, o *order.Order) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO orders (id, customer_id, total, status) VALUES (?, ?, ?, ?)",
        o.ID(), o.CustomerID(), o.Total().Amount, o.Status().String())
    return err
}

func (r *MySQLOrderRepo) FindByID(ctx context.Context, id string) (*order.Order, error) {
    // 实现查询逻辑
    // ...
    return nil, nil
}
// adapter/persistence/mongo_order_repo.go — 换存储只需新增实现
package persistence

import (
    "go.mongodb.org/mongo-driver/mongo"
    "myapp/domain/order"
)

type MongoOrderRepo struct {
    collection *mongo.Collection
}

func NewMongoOrderRepo(col *mongo.Collection) order.Repository {
    return &MongoOrderRepo{collection: col}
}

func (r *MongoOrderRepo) Save(ctx context.Context, o *order.Order) error {
    _, err := r.collection.InsertOne(ctx, bson.M{
        "_id":         o.ID(),
        "customer_id": o.CustomerID(),
        "total":       o.Total().Amount,
        "status":      o.Status().String(),
    })
    return err
}

对比收益

维度反例(依赖具体实现)正例(依赖抽象)
测试必须启动 MySQL 才能测试用 Mock 实现接口即可测试
换存储改动业务逻辑代码只需新增一个 Adapter
理解成本业务逻辑和技术细节混在一起业务逻辑清晰独立
并行开发数据库schema确定后才能开发定义好接口就可以并行开发
依赖注入的 Go 实现

在 Clean Architecture 中,组装(将接口与实现绑定)发生在最外层——通常是 main.gocmd/server/main.go

方式一:手动注入(推荐,适合中小项目)
// cmd/server/main.go
package main

import (
    "database/sql"
    "log"
    "myapp/adapter/inbound/http"
    "myapp/adapter/outbound/persistence"
    "myapp/infra/mysql"
    "myapp/usecase"
    
    _ "github.com/go-sql-driver/mysql"
    "github.com/gin-gonic/gin"
)

func main() {
    // 1. Infrastructure 层:初始化基础设施
    db, err := sql.Open("mysql", "user:pass@tcp(localhost:3306)/mydb")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 2. Adapter 层:创建实现(实现 domain 接口)
    orderRepo := persistence.NewMySQLOrderRepo(db)
    productRepo := persistence.NewMySQLProductRepo(db)

    // 3. Use Case 层:注入依赖
    placeOrderUC := usecase.NewPlaceOrderUseCase(orderRepo, productRepo)
    cancelOrderUC := usecase.NewCancelOrderUseCase(orderRepo)

    // 4. Adapter 层(Inbound):创建 HTTP Handler
    orderHandler := http.NewOrderHandler(placeOrderUC, cancelOrderUC)

    // 5. Framework 层:启动 Web 服务器
    router := gin.Default()
    orderHandler.RegisterRoutes(router)
    router.Run(":8080")
}

优点

  • 零依赖,不需要引入任何 DI 框架
  • 编译时检查,类型安全
  • 调试直观,依赖关系一目了然

缺点

  • 当依赖超过 20 个时,main.go 变得冗长
  • 手动管理依赖顺序,容易出错
方式二:Wire(适合大型项目)

Google 的 Wire 通过代码生成实现依赖注入:

// cmd/server/wire.go
//go:build wireinject

package main

import (
    "myapp/adapter/inbound/http"
    "myapp/adapter/outbound/persistence"
    "myapp/infra/mysql"
    "myapp/usecase"
    
    "github.com/google/wire"
)

func InitializeOrderHandler() (*http.OrderHandler, error) {
    wire.Build(
        // Infrastructure
        mysql.NewConnection,
        
        // Adapters (Outbound)
        persistence.NewMySQLOrderRepo,
        persistence.NewMySQLProductRepo,
        
        // Use Cases
        usecase.NewPlaceOrderUseCase,
        usecase.NewCancelOrderUseCase,
        
        // Adapters (Inbound)
        http.NewOrderHandler,
    )
    return nil, nil
}

运行 wire ./cmd/server 后,Wire 会自动生成 wire_gen.go

// Code generated by Wire. DO NOT EDIT.

func InitializeOrderHandler() (*http.OrderHandler, error) {
    db, err := mysql.NewConnection()
    if err != nil {
        return nil, err
    }
    orderRepo := persistence.NewMySQLOrderRepo(db)
    productRepo := persistence.NewMySQLProductRepo(db)
    placeOrderUC := usecase.NewPlaceOrderUseCase(orderRepo, productRepo)
    cancelOrderUC := usecase.NewCancelOrderUseCase(orderRepo)
    handler := http.NewOrderHandler(placeOrderUC, cancelOrderUC)
    return handler, nil
}

优点

  • 自动处理依赖顺序
  • 编译时检查,类型安全
  • 适合大型项目(100+ 依赖)

缺点

  • 需要学习 Wire 的 API
  • 代码生成可能影响调试体验
方式三:Uber Fx(运行时注入)
// cmd/server/main.go
package main

import (
    "go.uber.org/fx"
    "myapp/adapter/inbound/http"
    "myapp/adapter/outbound/persistence"
    "myapp/infra/mysql"
    "myapp/usecase"
)

func main() {
    fx.New(
        // Infrastructure
        fx.Provide(mysql.NewConnection),
        
        // Adapters
        fx.Provide(persistence.NewMySQLOrderRepo),
        fx.Provide(persistence.NewMySQLProductRepo),
        
        // Use Cases
        fx.Provide(usecase.NewPlaceOrderUseCase),
        fx.Provide(usecase.NewCancelOrderUseCase),
        
        // HTTP Handler
        fx.Provide(http.NewOrderHandler),
        
        // Start server
        fx.Invoke(func(h *http.OrderHandler) {
            router := gin.Default()
            h.RegisterRoutes(router)
            router.Run(":8080")
        }),
    ).Run()
}

优点

  • 支持生命周期管理(启动/关闭钩子)
  • 支持依赖图可视化
  • 适合微服务框架

缺点

  • 运行时注入,类型错误要到运行时才能发现
  • 学习曲线较陡

推荐选择

  • 小型项目(<50 个依赖):手动注入
  • 中型项目(50-200 个依赖):Wire
  • 大型项目(200+ 个依赖,微服务):Fx
为什么这一步通常早于 DDD

很多读者会问:既然后面还要引入 DDD,为什么不一步到位?原因是 Clean Architecture 先解决的是一个更基础的问题:先把业务逻辑和技术实现拆开

在这一步之前,系统最大的问题往往不是“领域模型不够优雅”,而是“业务代码和基础设施绑得太紧”。如果这个问题不先处理,后面即使想引入聚合根、值对象、领域事件,也很容易被外层技术细节拖住,导致模型表达和工程实现互相污染。

所以,从演进顺序上看,先引入 Clean Architecture,往往比直接上 DDD 更稳妥。它为后续的领域建模先清出了一块相对干净的内层空间。

收益:可替换、可测试、可演进

引入 Clean Architecture 之后,最直接的收益通常有三个。

第一,更容易替换基础设施。MySQL、PostgreSQL、Redis、消息中间件都变成外层实现细节,而不是业务层的一部分。

第二,更容易做单元测试CreateOrderService 可以直接注入 Mock Repository 进行测试,不需要启动数据库。

第三,为后续演进留出空间。当业务复杂度进一步上升时,你可以在内层继续引入更强的领域模型表达,而不必同时和框架、数据库实现纠缠。

当然,代价也很明显:目录更多、抽象更多、依赖注入也更复杂。对于业务非常简单的项目,这一步可能会显得“有点重”。但一旦系统已经出现“换数据库很痛”“单元测试写不动”“业务层越来越黏住基础设施”这些信号,引入 Clean Architecture 往往是值得的。

架构风格对比:Clean vs 六边形 vs 洋葱

在学习 Clean Architecture 时,你可能还会遇到另外两个相似的概念:六边形架构(Hexagonal Architecture)洋葱架构(Onion Architecture)。它们经常被混用,但实际上有细微差别:

维度Clean Architecture六边形架构 (Hexagonal)洋葱架构 (Onion)
提出者Robert C. Martin (2012)Alistair Cockburn (2005)Jeffrey Palermo (2008)
核心隐喻同心圆,层层向内六边形,端口与适配器洋葱,层层剥开
关键概念Entity, Use Case, AdapterPort(接口), Adapter(实现)Domain Model, Domain Service, App Service
外部交互方式通过 Interface Adapter 层通过 Port + Adapter 对通过 Infrastructure 层
核心共识依赖方向向内,业务逻辑不依赖外部技术同左同左
graph TB
    subgraph "Clean Architecture"
        direction TB
        CA_E[Entity] 
        CA_U[Use Case] --> CA_E
        CA_A[Adapter] --> CA_U
        CA_F[Framework] --> CA_A
    end
    
    subgraph "Hexagonal"
        direction TB
        H_D[Domain Core]
        H_PI[Inbound Port] --> H_D
        H_PO[Outbound Port] --> H_D
        H_AI[Driving Adapter] --> H_PI
        H_AO[Driven Adapter] --> H_PO
    end

    subgraph "Onion"
        direction TB
        O_DM[Domain Model]
        O_DS[Domain Service] --> O_DM
        O_AS[App Service] --> O_DS
        O_IF[Infrastructure] --> O_AS
    end

实际差异很小,三者在 Go 项目中的落地几乎一样——关键是守住一条线:内层定义接口,外层实现接口

Port & Adapter 模式的 Go 实现

六边形架构中,**Port(端口)**是接口,**Adapter(适配器)**是实现。在 Go 中天然契合:

// domain/port.go — Outbound Port(领域层定义接口)
package domain

type PaymentGateway interface {
    Charge(ctx context.Context, orderID string, amount Money) (*PaymentResult, error)
}

// adapter/payment/stripe_adapter.go — Driven Adapter(基础设施层实现接口)
package payment

import (
    "myapp/domain"
    "github.com/stripe/stripe-go/v72"
)

type StripeAdapter struct {
    client *stripe.Client
}

func NewStripeAdapter(apiKey string) domain.PaymentGateway {
    return &StripeAdapter{
        client: stripe.NewClient(apiKey),
    }
}

func (a *StripeAdapter) Charge(ctx context.Context, orderID string, amount domain.Money) (*domain.PaymentResult, error) {
    params := &stripe.ChargeParams{
        Amount:   stripe.Int64(amount.Amount),
        Currency: stripe.String(amount.Currency),
    }
    resp, err := a.client.Charges.New(params)
    if err != nil {
        return nil, fmt.Errorf("stripe charge failed: %w", err)
    }
    return &domain.PaymentResult{
        TransactionID: resp.ID, 
        Status:        "success",
    }, nil
}
// adapter/payment/mock_adapter.go — 测试时可替换为 Mock
package payment

type MockPaymentAdapter struct {
    ShouldFail bool
}

func NewMockPaymentAdapter() domain.PaymentGateway {
    return &MockPaymentAdapter{ShouldFail: false}
}

func (a *MockPaymentAdapter) Charge(ctx context.Context, orderID string, amount domain.Money) (*domain.PaymentResult, error) {
    if a.ShouldFail {
        return nil, errors.New("mock payment failure")
    }
    return &domain.PaymentResult{
        TransactionID: "mock-txn-001", 
        Status:        "success",
    }, nil
}

关键理解

  • Port(接口)在领域层定义,表达“我需要什么能力“
  • Adapter(实现)在基础设施层提供,表达“我如何提供这个能力“
  • 测试时,可以用 Mock Adapter 替换真实的 Stripe Adapter
  • 换支付渠道(如从 Stripe 换到支付宝),只需新增一个 Adapter
反模式:常见违规案例

在实际项目中,Clean Architecture 的违规往往不是故意的,而是在时间压力下“顺手“写下的。以下是三个最常见的反模式:

Anti-pattern 1:跨层调用
// ❌ 反例:Handler 直接引用了 MySQL 包(跳过了 domain 和 usecase 层)
package handler

import (
    "database/sql"
    "net/http"
)

func GetOrder(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        row := db.QueryRow("SELECT * FROM orders WHERE id = ?", r.URL.Query().Get("id"))
        // 直接在 handler 里写 SQL...
    }
}

问题

  • Handler 直接依赖数据库,无法测试
  • 业务逻辑散落在各个 Handler 中,无法复用
  • 换数据库需要改动所有 Handler
// ✅ 正例:Handler 只依赖 Use Case 接口
package handler

type OrderQuerier interface {
    GetOrderDetail(ctx context.Context, id string) (*OrderDetailDTO, error)
}

type OrderHandler struct {
    querier OrderQuerier
}

func NewOrderHandler(q OrderQuerier) *OrderHandler {
    return &OrderHandler{querier: q}
}

func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
    dto, err := h.querier.GetOrderDetail(r.Context(), r.URL.Query().Get("id"))
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(dto)
}
Anti-pattern 2:基础设施泄漏到领域层
// ❌ 反例:领域实体中使用了 sql.NullString(基础设施类型侵入领域)
package domain

import "database/sql"

type Order struct {
    ID       string
    Remark   sql.NullString  // ← 领域层不应该知道 SQL 的存在
    Status   int             // ← 用魔数表示状态
}

问题

  • sql.NullStringdatabase/sql 包的类型,领域层不应该依赖基础设施包
  • 领域模型变得“贫血“,只是数据容器,没有行为
// ✅ 正例:领域层使用纯 Go 类型,转换在 adapter 层完成
package domain

type Order struct {
    id     OrderID       // 强类型 ID
    remark string        // 空字符串表示无备注
    status OrderStatus   // 枚举类型,不是魔数
    items  []OrderItem
}

func (o *Order) UpdateRemark(remark string) error {
    if len(remark) > 500 {
        return ErrRemarkTooLong
    }
    o.remark = remark
    return nil
}

// adapter/persistence/converter.go — 在 Adapter 层做类型转换
func toDomain(po *OrderPO) *domain.Order {
    remark := ""
    if po.Remark.Valid {
        remark = po.Remark.String
    }
    status := domain.StatusFromInt(po.Status)
    return domain.ReconstructOrder(
        domain.OrderID(po.ID),
        remark,
        status,
        toItemList(po.Items),
    )
}

func toPO(o *domain.Order) *OrderPO {
    return &OrderPO{
        ID:     string(o.ID()),
        Remark: sql.NullString{String: o.Remark(), Valid: o.Remark() != ""},
        Status: o.Status().ToInt(),
    }
}
Anti-pattern 3:循环依赖
❌ domain/order.go imports adapter/notification
   adapter/notification imports domain/order
   → 编译失败:import cycle

问题

  • Go 不允许循环依赖,编译直接报错
  • 即使在允许循环依赖的语言(如 C#),也会导致模块耦合

解法:在 domain 层定义 Notifier 接口,adapter 层实现它。方向始终向内

// domain/notifier.go — 领域层定义接口
package domain

type Notifier interface {
    NotifyOrderPlaced(ctx context.Context, order *Order) error
}

// usecase/place_order.go — Use Case 依赖接口
type PlaceOrderUseCase struct {
    orderRepo order.Repository
    notifier  domain.Notifier  // 依赖抽象
}

func (uc *PlaceOrderUseCase) Execute(ctx context.Context, req PlaceOrderRequest) error {
    // ... 创建订单 ...
    if err := uc.orderRepo.Save(ctx, o); err != nil {
        return err
    }
    // 通过接口调用通知
    return uc.notifier.NotifyOrderPlaced(ctx, o)
}

// adapter/notification/sms_notifier.go — Adapter 层实现接口
package notification

type SMSNotifier struct {
    smsClient *SMSClient
}

func (n *SMSNotifier) NotifyOrderPlaced(ctx context.Context, order *domain.Order) error {
    message := fmt.Sprintf("您的订单 %s 已创建", order.ID())
    return n.smsClient.Send(ctx, order.CustomerPhone(), message)
}

依赖方向

domain (定义 Notifier 接口)
   ↑
usecase (依赖 Notifier 接口)
   ↑
adapter/notification (实现 Notifier 接口)

DDD(领域驱动设计)

战略设计:架构层面

DDD 不是一种架构,而是一套方法论。它认为软件的灵魂在于其解决的业务问题(即“领域“)。

DDD 分为两个层面:

  • 战略设计:架构层面,关注如何划分领域、如何确定投资策略、如何划分上下文边界
  • 战术设计:代码层面,关注如何用聚合、实体、值对象等战术模式编写高质量的领域模型

我们先讲战略设计。

领域分层与投资策略
为什么需要领域分层?

一个中大型系统往往包含十几个甚至几十个子系统。假设你是一家电商平台的 CTO,面对以下子系统:

  • 订单系统、支付系统、商品管理、库存管理
  • 用户系统、搜索系统、推荐系统、评价系统
  • 消息通知、物流跟踪、风控系统、数据报表

核心问题:资源有限(人力、预算、时间),不可能对所有子系统投入同等精力。如何决定:

  • 哪些系统必须自研,投入最好的团队?
  • 哪些系统可以定制开发,用常规团队?
  • 哪些系统直接买现成方案或用开源?

如果投资决策错误:

  • ❌ 把资源浪费在通用能力上(如自研消息队列),错失核心业务创新
  • ❌ 在核心竞争力上妥协(如用低质量的订单系统),导致业务受限

DDD 的答案:按照业务价值对领域分层,实施差异化投资策略。这就是核心域(Core Domain)、支撑域(Supporting Domain)、通用域(Generic Domain)的由来。

三种领域的定义与特征
域类型定义业务价值竞争差异化投资策略组织形式技术选型
核心域
Core Domain
平台的核心竞争力,创造差异化价值最高,决定平台成败高度差异化,竞品难模仿重点投入,自研最优秀团队,独立编制自主可控,完全掌握
支撑域
Supporting Domain
支撑核心业务的必要能力中等,必须有但不差异化有一定特色但可被超越适度投入,可定制常规团队,共享资源定制开发,参考业界
通用域
Generic Domain
通用基础能力,行业共性低,无差异化行业标准,无竞争优势最小投入,采购外包/工具团队开源/SaaS/采购

核心域(Core Domain)

  • 什么是“核心竞争力“? 直接影响营收、用户体验、留存率的能力,是公司在市场中胜出的关键
  • 特点:频繁变化(紧跟业务创新)、技术复杂、需要领域专家
  • 识别标志:如果这个域做不好,公司会输;如果做得特别好,会赢
  • 案例:电商的订单系统、金融的交易系统、SaaS 的租户管理

支撑域(Supporting Domain)

  • 为什么“必须有但不差异化“? 业务依赖但不产生竞争优势,做到 80 分和 95 分对业务影响不大
  • 特点:相对稳定、有一定复杂度、需要理解业务
  • 识别标志:缺了不行,但不是赢的关键
  • 案例:电商的商品管理、金融的账户系统、SaaS 的权限系统

通用域(Generic Domain)

  • 为什么可以采购? 行业已有成熟方案,无需重复造轮子,自研的投入产出比很低
  • 特点:标准化、变化少、技术成熟
  • 识别标志:市面上有多个成熟产品可选
  • 风险:过度依赖外部服务,但可通过多供应商策略缓解
  • 案例:用户认证(Auth0/Keycloak)、消息推送(Twilio)、存储(AWS S3)
领域划分方法论

如何判断一个子域属于哪一类? 下面提供一套可操作的评分框架。

判断维度与评分模型
判断维度核心域(8-10分)支撑域(4-7分)通用域(1-3分)评分问题
业务价值直接影响收入/利润/核心指标间接影响业务,必需但不关键不影响业务差异化这个域对营收/留存的影响有多大?
竞争差异化独特能力,竞品难以模仿有特色但可被超越行业标准,无差异竞品能轻易复制这个能力吗?
变化频率频繁变化,紧跟业务创新定期调整优化稳定,很少大改多久需要大改一次?
技术复杂度高度复杂,需要领域专家中等复杂,需要业务理解成熟方案可解决普通团队能否 hold 住?

评分方法

  • 每个维度打分 1-10 分
  • 总分 = 四个维度分数相加(满分 40 分)

总分判断标准

  • 32-40 分 → 核心域(Core Domain)
  • 16-31 分 → 支撑域(Supporting Domain)
  • 4-15 分 → 通用域(Generic Domain)

注意事项

  • 边界分数(如 31-32 分)需要结合公司战略、团队能力综合判断
  • 初创公司可以适当放宽核心域标准(28 分以上即可),聚焦资源
  • 成熟公司标准更严格,避免核心域过多导致资源分散
方法论应用:电商系统实战分析

下面选择电商系统的 3 个典型域,应用评分模型进行深度分析。

案例 1:订单域(核心域)
维度评分详细分析
业务价值10订单流程直接影响 GMV(成交总额),每提升 1% 转化率就是百万级营收
竞争差异化9拼团、秒杀、预售、分期等玩法是核心竞争力,竞品难以完全模仿
变化频率9每个大促(618、双11)都会调整订单流程,支持新的营销玩法
技术复杂度9分布式事务(Saga)、状态机、高并发、幂等性、最终一致性
总分37核心域

为什么是核心域?

  • 订单流程的流畅度直接影响用户下单转化率
  • 支持的营销玩法越丰富,平台竞争力越强
  • 每个促销活动都可能需要调整订单逻辑
  • 技术上涉及多个复杂的分布式系统问题

投资建议

  • 团队配置:最优秀的架构师 + 3-5 名资深后端开发,独立团队
  • 技术选型:自研,完全掌控,不依赖外部服务
  • 质量要求:99.99% 可用性,全链路监控,灰度发布
  • 迭代策略:快速响应业务需求,2 周一个迭代
  • 文档要求:完整的设计文档、接口文档、故障预案
案例 2:商品域(支撑域)
维度评分详细分析
业务价值7商品管理是必需的,但 SPU/SKU 模型本身不产生差异化
竞争差异化5各家电商的商品模型大同小异,主要差异在类目和属性配置
变化频率6新品类上线时需要调整,但不频繁(季度级别)
技术复杂度6有一定复杂度(EAV 模型、搜索索引),但方案成熟
总分24支撑域

为什么是支撑域?

  • 商品管理做到 80 分和 95 分,对用户体验影响不大
  • SPU/SKU 模型是行业通用方案,没有太多创新空间
  • 但又不能没有(缺了商品管理,电商就玩不转)

投资建议

  • 团队配置:常规开发团队 2-3 人,可以与其他支撑域共享资源
  • 技术选型:参考业界成熟方案(如有赞、Shopify 的商品模型),适度定制
  • 质量要求:99.9% 可用性,降级策略
  • 迭代策略:稳定为主,谨慎迭代,充分测试后再上线
  • 文档要求:基础设计文档和接口文档
案例 3:用户域(通用域)
维度评分详细分析
业务价值3用户注册登录是基础能力,但不产生差异化(用户不会因为注册流程选择平台)
竞争差异化2注册登录是行业标准(手机号、邮箱、第三方登录),无差异
变化频率2很少变化,除非监管要求(如实名认证)
技术复杂度3SSO、OAuth 2.0 都有成熟方案(Auth0、Keycloak)
总分10通用域

为什么是通用域?

  • 注册登录不会成为平台的竞争优势
  • 市面上有大量成熟的身份认证服务
  • 自研的投入产出比很低

投资建议

  • 团队配置:外包或使用 SaaS 服务,内部只需 1 人对接
  • 技术选型:采购(Auth0、Keycloak、AWS Cognito)
  • 质量要求:依赖服务商 SLA(通常 99.95%+)
  • 迭代策略:按需对接新的认证方式(如生物识别),最小投入
  • 文档要求:对接文档即可
跨行业对比:方法论的通用性

同样的方法论在不同行业如何应用?下表展示三个典型行业的域划分:

行业核心域(差异化竞争力)支撑域(业务必需)通用域(行业标准)
电商• 订单系统(交易流程)
• 支付系统(资金安全)
• 商品管理
• 库存管理
• 计价引擎
• 营销系统
• 用户认证
• 搜索
• 消息推送
• 物流跟踪
• 风控
金融• 交易系统(买卖撮合)
• 风控系统(反欺诈)
• 账户系统
• 清结算
• 合规报送
• 用户认证
• 消息通知
• 报表系统
• 存储
SaaS• 租户管理(多租户隔离)
• 计费系统(订阅模式)
• 权限系统(RBAC)
• 审计日志
• 集成中心(API)
• 用户认证
• 消息
• 存储
• 监控告警

关键洞察

  1. 核心域因行业而异

    • 电商的核心是「交易流程」和「资金安全」
    • 金融的核心是「买卖撮合」和「风控合规」
    • SaaS 的核心是「多租户」和「订阅计费」
    • → 核心域反映了行业的本质和竞争焦点
  2. 通用域高度相似

    • 用户、消息、存储在各行业都是通用域
    • 这些能力已经高度标准化,有大量成熟方案
    • → 通用域是「不需要重新发明轮子」的领域
  3. 支撑域体现业务特点

    • 电商的商品、库存、计价有一定特色,但不是核心竞争力
    • 金融的账户、清结算是必需的,但各家差异不大
    • SaaS 的权限、审计是基础能力,但实现相对标准
    • → 支撑域是「需要理解业务,但可以参考业界实践」的领域
限界上下文(Bounded Context)

同一个“商品“在不同的上下文中有完全不同的含义:

 ┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
 │   商品上下文      │     │   订单上下文      │     │   物流上下文      │
 │                  │     │                  │     │                  │
 │  商品 = SKU +    │     │  商品 = 商品快照 + │     │  商品 = 包裹 +    │
 │  价格 + 库存     │     │  购买数量 + 金额   │     │  重量 + 体积      │
 └─────────────────┘     └─────────────────┘     └─────────────────┘

为什么需要限界上下文?

在一个大型系统中,如果所有模块都对“商品“有统一的定义,会导致:

  • ❌ 商品模型越来越臃肿(既要支持展示,又要支持下单,还要支持物流)
  • ❌ 一个模块的需求变化影响所有其他模块
  • ❌ 团队之间沟通成本巨大(每次讨论都要对齐“商品“的定义)

Bounded Context 的解决方案

  • 每个上下文内,“商品“有自己的定义和模型
  • 上下文之间通过明确的接口领域事件通信
  • 上下文内部的变化,不会影响其他上下文

电商系统的 Bounded Context 示例

graph TB
    subgraph 商品上下文
        P1[Product<br/>SKU ID, Name, Price<br/>Stock, Images]
    end
    
    subgraph 订单上下文
        O1[OrderItem<br/>Product Snapshot<br/>Quantity, Price at Order Time]
    end
    
    subgraph 搜索上下文
        S1[SearchDocument<br/>SKU ID, Title, Price<br/>Category, Sales Count]
    end
    
    商品上下文 -->|商品变更事件| 订单上下文
    商品上下文 -->|索引同步| 搜索上下文

不同上下文之间通过防腐层(Anti-Corruption Layer)领域事件通信,避免概念混淆。我们会在 5.4.4 详细讲解。

上下文映射(Context Map)

限界上下文划分好之后,它们之间如何协作?Context Map(上下文映射)定义了上下文之间的关系模式

常见的上下文关系模式
模式定义适用场景电商案例
共享内核
Shared Kernel
两个上下文共享部分模型代码紧密协作的两个团队商品上下文和库存上下文共享 SKU 定义
客户-供应商
Customer-Supplier
下游依赖上游,上游需要考虑下游需求明确的上下游关系订单上下文依赖商品上下文
遵奉者
Conformist
下游完全遵循上游模型,无话语权接入第三方系统接入微信支付API
防腐层
Anti-Corruption Layer
下游通过翻译层隔离上游变化上游模型不稳定或不可控接入供应商API时的适配层
开放主机服务
Open Host Service
上游提供标准化接口供多方使用上游服务多个下游商品中心提供统一的商品查询API
发布语言
Published Language
定义标准的数据交换格式跨团队/跨公司协作订单事件的JSON Schema定义
电商系统的 Context Map 实例
graph LR
    A[商品上下文] -->|发布领域事件| B[订单上下文]
    B -->|调用防腐层| C[支付上下文]
    B -->|发布领域事件| D[物流上下文]
    A -->|共享内核| E[库存上下文]
    F[供应商系统] -.->|遵奉者模式| B
    A -->|开放主机服务| G[搜索上下文]
    A -->|开放主机服务| H[营销上下文]

关系说明

  1. 商品 → 订单:发布领域事件(ProductPriceChanged),订单上下文异步消费
  2. 订单 → 支付:通过防腐层调用支付API,隔离支付系统的变化
  3. 订单 → 物流:发布领域事件(OrderShipped),物流上下文异步消费
  4. 商品 ↔ 库存:共享内核(共享 SKU 的定义)
  5. 供应商 → 订单:遵奉者模式(完全遵循供应商的履约接口)
  6. 商品 → 搜索/营销:开放主机服务(提供标准化的商品查询API)
Go 项目中的典型目录映射

当 DDD 落到 Go 项目时,目录结构通常会围绕“限界上下文”和“聚合”展开,而不是围绕数据库表或接口协议展开。以一个订单服务为例,比较常见的组织方式如下:

order-service/
├── cmd/
│   └── server/
│       └── main.go
│
├── domain/
│   ├── order/                      # 订单上下文 / 订单聚合
│   │   ├── aggregate.go
│   │   ├── entity.go
│   │   ├── value_object.go
│   │   ├── repository.go
│   │   ├── event.go
│   │   └── service.go
│   ├── inventory/                  # 库存上下文
│   │   ├── stock.go
│   │   └── repository.go
│   └── payment/                    # 支付上下文
│       ├── payment.go
│       └── gateway.go
│
├── application/
│   ├── service/
│   │   ├── place_order.go
│   │   ├── cancel_order.go
│   │   └── mark_order_paid.go
│   └── dto/
│       ├── request.go
│       └── response.go
│
├── interfaces/
│   ├── http/
│   │   └── order.go
│   ├── rpc/
│   │   └── order.go
│   └── event/
│       └── payment_paid.go
│
└── infrastructure/
    ├── persistence/
    │   ├── order_repo.go
    │   ├── inventory_repo.go
    │   └── payment_repo.go
    ├── mq/
    └── mysql/

和阶段 1 相比,这里的核心变化不是目录变得更深,而是 domain 不再只是“放接口和几个结构体的地方”,而是开始真正承载业务语义。换句话说,阶段 1 的 domain 更像“业务核心的边界”;阶段 2 的 domain 才开始成为“业务规则真正居住的地方”。

从贫血模型到充血模型

阶段 1 中,很多项目虽然已经把 Order 放进了 domain,但这个 Order 仍然只是一个贫血模型:它主要承担数据承载作用,真正的规则依旧写在 application/service 里。例如:

// 阶段 1 的 "贫血模型"
type Order struct {
    ID     string
    Status int     // 用魔数表示状态
    Total  float64 // 用 float 表示金额
}

这类模型的问题在于:它看起来像“领域对象”,但实际上并没有把业务语义和不变量收进去。订单是否允许下单、是否允许取消、金额是否有效、状态能否迁移,仍然需要调用者在外面自己判断。

而在阶段 2 中,Order 会逐渐演进成一个真正的聚合根:

// 阶段 2 的 "充血模型"
type Order struct {
    id     OrderID
    status OrderStatus   // 值对象,枚举约束
    total  Money         // 值对象,精度安全
    items  []OrderItem
}

func (o *Order) Place() ([]DomainEvent, error) {
    if len(o.items) == 0 {
        return nil, ErrEmptyOrder  // 聚合根保护不变量
    }
    o.status = OrderStatusPlaced
    return []DomainEvent{OrderPlacedEvent{...}}, nil
}

这里的关键不只是把字段从公有变成私有,也不只是把 int 改成 OrderStatus。真正重要的是:业务规则开始内聚到模型本身。订单能否下单、状态如何流转、哪些约束必须被保护,不再由外部调用者“记得去做”,而是由聚合根主动维护。

DDD 在这一阶段具体引入什么

阶段 2 最常引入的几个战术设计元素包括:

  • 聚合根(Aggregate Root):例如 Order,它负责维护订单聚合内部的一致性边界
  • 实体(Entity):例如 OrderItem,有身份或生命周期,属于某个聚合的一部分
  • 值对象(Value Object):例如 OrderIDMoneyOrderStatus,用来表达语义并约束非法状态
  • 领域事件(Domain Event):例如 OrderPlacedEventOrderCancelledEvent,表达“业务事实已经发生”
  • 领域服务(Domain Service):当某条业务规则不自然属于某个单一聚合时,再用领域服务承载

需要强调的是,DDD 不是要求你“把所有东西都做成值对象和事件”。它真正的目的,是让代码结构尽可能贴近业务语言,而不是贴近数据库表结构。

应用层在这一阶段如何变化

引入 DDD 之后,application/service 并不会消失,但它的职责会变得更清晰:它不再自己堆满业务规则,而是更像一个应用服务,负责调度聚合根、协调事务和调用 Port。

也就是说:

  • 在阶段 1 中,CreateOrderService 往往自己承担很多规则判断
  • 在阶段 2 中,PlaceOrderService 更像一个 orchestrator,它负责加载聚合、调用 order.Place()、保存聚合、发布领域事件

这也是为什么 DDD 不是对 Clean Architecture 的替代,而是对其内层表达能力的增强。

以一个订单服务的调用链为例

如果把 DDD 放到下单场景里,调用链通常会长这样:

HTTP 请求
    -> interfaces/http.OrderHandler
    -> application/service.PlaceOrderService
    -> domain/order.Order 聚合根
    -> domain/order.Repository
    -> infrastructure/persistence.OrderRepo
    -> 发布 OrderPlacedEvent

和三层架构相比,这里最重要的变化不是“步骤变多了”,而是业务规则开始收拢到聚合根内部

例如:

  • Handler 不再自己判断订单状态是否合法
  • Application Service 不再堆满状态迁移和金额校验
  • Order.Place()Order.Cancel()Order.MarkPaid() 这类方法开始成为真正的业务入口

这意味着,当我们讨论“订单能不能取消”“订单支付后能不能再次修改”时,答案主要应该在 domain/order 里找到,而不是散落在多个应用服务和数据库更新逻辑中。

为什么这一步值得做

当业务规则还很少时,把逻辑写在 application/service 里看起来并没有问题;但一旦规则数量和状态迁移开始增多,继续让应用层承担所有判断,很快会出现几个典型症状:

  • 一个 application/service 文件动辄数百行,充满 if/else
  • 状态迁移规则散落在多个命令处理器里
  • 同样的金额校验、状态校验、约束判断在多个地方重复出现
  • 新成员很难回答“订单到底有哪些业务规则”

这时,引入 DDD 的最大收益,就是把这些规则收拢回业务概念本身。新成员阅读 Order.Place()Order.Cancel()Order.MarkPaid(),就能理解订单生命周期中的核心约束,而不是在多个应用服务文件之间来回跳转。

收益:模型更贴近业务,规则更容易维护

阶段 2 最直接的收益通常有三个。

第一,业务规则开始真正内聚。规则不再散落在各个 application/service 中,而是集中在聚合根和相关值对象里。

第二,模型表达能力更强Money 不再只是 float64OrderStatus 不再只是魔数,代码本身开始带有业务语义。

第三,系统更适合继续演进。当后续要引入领域事件驱动、跨上下文协作,或者进一步拆分读写模型时,一个表达清晰的领域模型会成为非常稳固的基础。

当然,代价也很现实:DDD 的学习门槛比前两阶段更高,过度使用聚合、值对象和事件,也很容易把简单业务写得过于复杂。因此,阶段 2 的关键不是“为了 DDD 而 DDD”,而是在业务规则确实已经复杂到值得建模时,再让模型承担它本来就该承担的职责。

通用语言(Ubiquitous Language)

DDD 的战术设计关注的是代码层面的实现:如何用聚合、实体、值对象等战术模式编写高质量的领域模型。

战术设计概述
概念定义示例
Aggregate(聚合)一组相关对象的集合,确保数据的一致性边界Order 聚合包含 OrderItem 列表
Aggregate Root(聚合根)聚合的入口对象,外部只能通过它访问聚合Order 是聚合根,OrderItem 不能被单独访问
Entity(实体)有唯一标识的对象,按 ID 区分User(不同 ID = 不同用户)
Value Object(值对象)没有唯一标识,仅由属性定义Money(100, "USD")Address
Domain Event(领域事件)领域中发生的有意义的事实OrderPlacedPaymentCompleted
Domain Service(领域服务)不属于任何实体的业务逻辑跨聚合的转账操作
Go 代码示例:Order 聚合
// domain/order/order.go
package order

import (
    "errors"
    "time"
)

type OrderID string

type Order struct {
    id         OrderID
    customerID string
    items      []OrderItem
    status     OrderStatus
    totalPrice Money
    createdAt  time.Time
}

// 聚合根通过方法保护业务不变量
func (o *Order) AddItem(product Product, qty int) error {
    // 业务规则 1:只有草稿状态的订单才能添加商品
    if o.status != OrderStatusDraft {
        return ErrOrderNotEditable
    }
    // 业务规则 2:数量必须大于 0
    if qty <= 0 {
        return ErrInvalidQuantity
    }
    // 业务规则 3:同一商品不能重复添加(或合并数量)
    for i, item := range o.items {
        if item.ProductID == product.ID {
            o.items[i].Quantity += qty
            o.recalculateTotal()
            return nil
        }
    }
    
    item := NewOrderItem(product, qty)
    o.items = append(o.items, item)
    o.recalculateTotal()
    return nil
}

func (o *Order) Place() ([]DomainEvent, error) {
    // 业务规则 4:订单必须至少有一个商品
    if len(o.items) == 0 {
        return nil, ErrEmptyOrder
    }
    // 业务规则 5:只有草稿状态才能下单
    if o.status != OrderStatusDraft {
        return nil, ErrInvalidOrderStatus
    }
    
    o.status = OrderStatusPlaced
    
    // 发布领域事件
    events := []DomainEvent{
        OrderPlacedEvent{
            OrderID: o.id,
            Total:   o.totalPrice,
            At:      time.Now(),
        },
    }
    return events, nil
}

func (o *Order) recalculateTotal() {
    total := Money{Amount: 0, Currency: "USD"}
    for _, item := range o.items {
        itemTotal, _ := item.Price.Multiply(item.Quantity)
        total, _ = total.Add(itemTotal)
    }
    o.totalPrice = total
}

// Getters(聚合根控制外部访问)
func (o *Order) ID() OrderID { return o.id }
func (o *Order) CustomerID() string { return o.customerID }
func (o *Order) Status() OrderStatus { return o.status }
func (o *Order) Total() Money { return o.totalPrice }
// domain/order/money.go — Value Object(值对象)
package order

import "errors"

type Money struct {
    Amount   int64  // 使用分为单位,避免浮点精度问题
    Currency string
}

func (m Money) Add(other Money) (Money, error) {
    if m.Currency != other.Currency {
        return Money{}, ErrCurrencyMismatch
    }
    return Money{
        Amount:   m.Amount + other.Amount,
        Currency: m.Currency,
    }, nil
}

func (m Money) Multiply(factor int) (Money, error) {
    if factor < 0 {
        return Money{}, errors.New("factor must be positive")
    }
    return Money{
        Amount:   m.Amount * int64(factor),
        Currency: m.Currency,
    }, nil
}

func (m Money) String() string {
    return fmt.Sprintf("%d.%02d %s", m.Amount/100, m.Amount%100, m.Currency)
}

关键设计要点

  1. 业务规则内聚:所有的业务规则都在聚合根的方法中,而不是散落在 Service 层
  2. 不变量保护:聚合根保证内部数据的一致性(如 totalPrice 始终是 items 的总和)
  3. 领域事件:聚合根发生重要状态变化时,返回领域事件
  4. 值对象Money 是值对象,保证金额计算的精度和货币一致性

开发者和业务专家用同一套词汇交流,代码里的变量名就是业务里的术语:

业务术语代码命名反面教材
下单Order.Place()Order.SetStatus(1)
加入购物车Cart.AddItem()Cart.Insert()
发起退款Refund.Initiate()Refund.Create()
库存扣减Stock.Deduct()Stock.Update()

为什么重要?

在传统的开发模式中,业务专家和开发者之间存在“翻译“过程:

  • 业务专家说“用户下单后锁定库存“
  • 产品经理翻译成“创建订单后更新库存状态“
  • 开发者实现成 updateInventoryStatus(orderId, status=2)

问题

  • 业务术语(锁定库存)→ 技术术语(更新状态 = 2),中间丢失了业务含义
  • 代码中的 status=2 没人知道是什么意思
  • 业务规则隐藏在魔数和 SQL 中,无法追溯

通用语言的解决方案

// ✅ 代码直接使用业务术语
func (s *InventoryService) LockStock(ctx context.Context, skuID string, qty int) error {
    stock, err := s.stockRepo.FindBySKU(ctx, skuID)
    if err != nil {
        return err
    }
    
    // 业务术语:锁定库存
    if err := stock.Lock(qty); err != nil {
        return err
    }
    
    return s.stockRepo.Save(ctx, stock)
}

// domain/inventory/stock.go
func (s *Stock) Lock(qty int) error {
    if s.available < qty {
        return ErrInsufficientStock
    }
    s.available -= qty
    s.locked += qty
    return nil
}

对比

  • updateInventoryStatus(orderId, status=2) — 技术术语,无业务含义
  • stock.Lock(qty) — 业务术语,直接表达意图
聚合设计原则

聚合设计是 DDD 战术层面最难的部分。三条核心原则:

原则一:一个事务只修改一个聚合
// ❌ 反例:一个事务同时修改 Order 和 Inventory 两个聚合
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCmd) error {
    return s.txManager.RunInTx(ctx, func(tx *sql.Tx) error {
        order := domain.NewOrder(cmd.CustomerID)
        order.AddItem(cmd.ProductID, cmd.Qty)
        order.Place()
        s.orderRepo.SaveTx(tx, order)      // 修改 Order 聚合
        s.inventoryRepo.DeductTx(tx, cmd.ProductID, cmd.Qty) // ← 同时修改 Inventory 聚合
        return nil
    })
}

问题

  • 两个聚合在同一个事务中修改,导致锁粒度过大
  • Order 和 Inventory 强耦合,无法独立演进
  • 高并发场景下容易死锁
// ✅ 正例:通过领域事件实现跨聚合协作
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCmd) error {
    order := domain.NewOrder(cmd.CustomerID)
    order.AddItem(cmd.ProductID, cmd.Qty)
    events, err := order.Place()
    if err != nil {
        return err
    }
    
    // 只保存 Order 聚合
    if err := s.orderRepo.Save(ctx, order); err != nil {
        return err
    }
    
    // 发布事件,由 Inventory 服务异步消费
    s.eventBus.Publish(ctx, events...)
    return nil
}

// inventory 服务的事件处理器
func (h *InventoryEventHandler) OnOrderPlaced(ctx context.Context, e OrderPlacedEvent) error {
    return h.stock.Deduct(ctx, e.ProductID, e.Qty)
}

收益

  • Order 和 Inventory 解耦,可以独立扩展
  • 事务范围缩小,提高并发性能
  • 通过事件实现最终一致性
原则二:小聚合优于大聚合
维度小聚合大聚合
并发冲突低(锁粒度小)高(整个大聚合被锁)
内存占用小(按需加载)大(整棵树一次加载)
一致性范围单个核心不变量多个不变量混在一起
适用场景高并发写入强一致性要求的小规模数据

判断标准:如果两个实体之间没有需要在同一个事务中保护的业务不变量,就应该拆成两个聚合。

示例

// ❌ 大聚合:Order 聚合包含 Customer 的完整信息
type Order struct {
    id       OrderID
    customer *Customer  // 包含 Customer 的所有信息
    items    []OrderItem
}

// 问题:加载 Order 时被迫加载 Customer 的所有信息(地址、订单历史等)
// ✅ 小聚合:Order 只保存 CustomerID
type Order struct {
    id         OrderID
    customerID CustomerID  // 只存 ID,需要时按需查询
    items      []OrderItem
}

// 需要 Customer 信息时,通过 Repository 查询
func (uc *OrderDetailUseCase) GetOrderDetail(ctx context.Context, orderID string) (*OrderDetailDTO, error) {
    order, err := uc.orderRepo.FindByID(ctx, orderID)
    if err != nil {
        return nil, err
    }
    customer, err := uc.customerRepo.FindByID(ctx, order.CustomerID())
    if err != nil {
        return nil, err
    }
    return &OrderDetailDTO{
        OrderID:      string(order.ID()),
        CustomerName: customer.Name(),
        Items:        order.Items(),
    }, nil
}
原则三:通过 ID 引用其他聚合
// ❌ 聚合内直接持有另一个聚合的引用
type Order struct {
    customer *Customer  // 直接引用 → 加载 Order 时被迫加载 Customer
}

// ✅ 通过 ID 引用
type Order struct {
    customerID CustomerID  // 只存 ID,需要时按需查询
}

收益

  • 聚合边界清晰,加载 Order 不会加载 Customer
  • 聚合之间松耦合,可以独立演进
  • 减少内存占用和数据库 JOIN
Repository 与 Unit of Work 模式

Repository 是领域模型和持久化之间的桥梁,它提供了类似集合的接口来访问聚合。

标准 Repository 模式
// domain/order/repository.go — 领域层定义接口
package order

type Repository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id OrderID) (*Order, error)
    FindByCustomerID(ctx context.Context, customerID string, limit int) ([]*Order, error)
}

Repository 的职责

  • 提供类似集合的接口(Save, FindByID, Remove)
  • 隐藏底层存储细节(MySQL/MongoDB/Redis)
  • 保证聚合的完整加载和保存
Unit of Work 模式

标准 Repository 每个操作独立,但有时需要在一个事务中协调多个 Repository(例如保存聚合根 + 写 Outbox 表)。Unit of Work 模式解决这个问题:

// domain/uow.go — 领域层定义接口
package domain

type UnitOfWork interface {
    OrderRepo() order.Repository
    OutboxRepo() OutboxRepository
    Commit(ctx context.Context) error
    Rollback(ctx context.Context) error
}

// infrastructure/uow_impl.go — 基础设施层实现
package infrastructure

type mysqlUnitOfWork struct {
    tx         *sql.Tx
    orderRepo  *MySQLOrderRepo
    outboxRepo *MySQLOutboxRepo
}

func NewUnitOfWork(db *sql.DB) (domain.UnitOfWork, error) {
    tx, err := db.Begin()
    if err != nil {
        return nil, err
    }
    return &mysqlUnitOfWork{
        tx:         tx,
        orderRepo:  &MySQLOrderRepo{tx: tx},
        outboxRepo: &MySQLOutboxRepo{tx: tx},
    }, nil
}

func (u *mysqlUnitOfWork) OrderRepo() order.Repository  { return u.orderRepo }
func (u *mysqlUnitOfWork) OutboxRepo() OutboxRepository { return u.outboxRepo }
func (u *mysqlUnitOfWork) Commit(ctx context.Context) error   { return u.tx.Commit() }
func (u *mysqlUnitOfWork) Rollback(ctx context.Context) error { return u.tx.Rollback() }
// application/command/place_order.go — Use Case 使用 UoW
func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCmd) error {
    uow, err := h.uowFactory(ctx)
    if err != nil {
        return err
    }
    defer uow.Rollback(ctx)

    order := domain.NewOrder(cmd.CustomerID)
    events, err := order.Place()
    if err != nil {
        return err
    }

    // 同一个事务中保存聚合和 Outbox
    if err := uow.OrderRepo().Save(ctx, order); err != nil {
        return err
    }
    for _, e := range events {
        if err := uow.OutboxRepo().Save(ctx, toOutboxEntry(e)); err != nil {
            return err
        }
    }
    
    return uow.Commit(ctx)
}
领域事件异步化:Outbox Pattern

问题:保存聚合到数据库后,还要发送事件到 Kafka。这两个操作无法在一个事务中完成(双写问题)。如果先写 DB 再发 Kafka,发送失败则事件丢失;如果先发 Kafka 再写 DB,写 DB 失败则产生幽灵事件。

解法:Outbox Pattern——将事件写入本地数据库的 Outbox 表(与业务数据同一事务),再由独立的 Relay 进程异步发送到 Kafka。

sequenceDiagram
    participant App as Application
    participant DB as MySQL
    participant Relay as Outbox Relay
    participant MQ as Kafka

    App->>DB: BEGIN TX
    App->>DB: INSERT orders (聚合数据)
    App->>DB: INSERT outbox (领域事件)
    App->>DB: COMMIT TX
    
    loop 定期轮询
        Relay->>DB: SELECT * FROM outbox WHERE status='pending'
        Relay->>MQ: Publish(event)
        MQ-->>Relay: ACK
        Relay->>DB: UPDATE outbox SET status='sent'
    end
Outbox 表设计
CREATE TABLE outbox (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    event_type  VARCHAR(128) NOT NULL,
    event_key   VARCHAR(128) NOT NULL,
    payload     JSON NOT NULL,
    status      ENUM('pending', 'sent', 'failed') DEFAULT 'pending',
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    sent_at     TIMESTAMP NULL,
    retry_count INT DEFAULT 0,
    INDEX idx_status_created (status, created_at)
);
Relay 实现
package infrastructure

import (
    "context"
    "log/slog"
    "time"
)

type OutboxRelay struct {
    outboxRepo OutboxRepository
    producer   MessageProducer
}

func (r *OutboxRelay) Run(ctx context.Context) {
    ticker := time.NewTicker(500 * time.Millisecond)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            entries, err := r.outboxRepo.FetchPending(ctx, 100)
            if err != nil {
                slog.Error("fetch outbox failed", "error", err)
                continue
            }
            for _, entry := range entries {
                if err := r.producer.Publish(ctx, entry.EventType, entry.EventKey, entry.Payload); err != nil {
                    slog.Error("publish event failed", "id", entry.ID, "error", err)
                    r.outboxRepo.MarkFailed(ctx, entry.ID)
                    continue
                }
                r.outboxRepo.MarkSent(ctx, entry.ID)
            }
        }
    }
}

关键保证

  • At-least-once delivery:Relay 崩溃后重启会重新发送 pending 的事件,消费者必须做幂等处理
  • 顺序保证:按 created_at 顺序拉取,同一 event_key 的事件保持顺序
  • 死信处理retry_count > 5 的事件转入死信表,人工介入

CQRS(命令查询职责分离)

为什么要读写分离

CQRS 的逻辑非常直白:处理“改变数据“(Command)的逻辑和处理“读取数据“(Query)的逻辑应该完全分开

在复杂系统中,写的逻辑和读的需求往往是矛盾的

维度写(Command)读(Query)
关注点业务规则、校验、权限、事务跨表关联、全文搜索、分页排序
数据模型范式化(3NF),保证一致性反范式化(宽表),优化查询速度
性能目标保证正确性 > 速度保证速度 > 实时性
扩展方式垂直扩展(事务安全)水平扩展(读副本、缓存)
典型存储MySQL, PostgreSQLElasticsearch, Redis, ClickHouse

电商订单详情页的矛盾

用户打开订单详情页,需要展示:

  • 订单基本信息(订单号、状态、总价)
  • 商品信息(名称、图片、规格)
  • 价格明细(商品价、运费、优惠券)
  • 物流信息(快递公司、运单号、物流轨迹)
  • 售后信息(退款状态、退货进度)

如果用写模型(范式化)来查询:

-- ❌ 需要 JOIN 5-6 张表,性能差
SELECT o.*, oi.*, p.*, l.*, r.*
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
JOIN logistics l ON o.id = l.order_id
LEFT JOIN refunds r ON o.id = r.order_id
WHERE o.id = ?

如果用读模型(反范式化):

// ✅ 一个宽表或一个 ES 文档,性能极佳
{
  "order_id": "123",
  "status": "delivered",
  "total_price": 299.00,
  "items": [
    {"product_name": "iPhone", "image": "...", "price": 299.00}
  ],
  "logistics": {"company": "SF Express", "tracking_no": "SF123"},
  "refund": null
}
DDD 与 CQRS 的关系

如果说阶段 2 的 DDD 解决的是“业务逻辑本身应该如何表达”,那么阶段 3 的 CQRS 解决的则是另一个更现实的问题:这些业务逻辑表达清楚之后,读和写是否还应该继续走同一条路径

到了这一阶段,系统的主要矛盾往往已经不再是“模型贫血”或“规则散落”,而是“写模型为了保证一致性必须保持克制,但读场景又越来越追求宽表、搜索、排序和高并发”。也就是说,DDD 帮你把写侧建模建清楚了,但读侧未必适合继续共享同一套模型。

这正是 CQRS 和 DDD 的关系。DDD 让命令侧的模型更有表达力,CQRS 则进一步承认:命令侧和查询侧本来就不是一回事。写侧继续走领域模型,读侧则按展示需求单独设计,这样两者才能各自优化。

Go 项目中的典型目录映射

当 CQRS 落到 Go 项目中,最常见的变化不是整个项目推倒重来,而是在原有的应用层和适配层中,把读写路径显式拆开。以订单服务为例,比较常见的组织方式如下:

order-service/
├── cmd/
│   └── server/
│       └── main.go
│
├── domain/
│   └── order/
│       ├── aggregate.go
│       ├── value_object.go
│       ├── repository.go
│       └── event.go
│
├── application/
│   ├── command/                    # 写路径
│   │   ├── place_order.go
│   │   ├── cancel_order.go
│   │   └── mark_order_paid.go
│   └── query/                      # 读路径
│       ├── order_detail.go
│       └── order_list.go
│
├── interfaces/
│   ├── http/
│   │   └── order.go
│   └── rpc/
│       └── order.go
│
├── infrastructure/
│   ├── persistence/                # Write DB
│   │   └── order_repo.go
│   ├── readmodel/                  # Read DB / 宽表 / ES
│   │   ├── order_detail_repo.go
│   │   └── order_list_repo.go
│   ├── projection/                 # Event -> Read Model
│   │   └── order_projector.go
│   ├── mq/
│   └── mysql/

和前面的 DDD 相比,这里的核心变化不是 domain 再次升级,而是 applicationinfrastructure 开始明确区分“命令处理”和“查询处理”。从这个时候开始,大家讨论问题时,不再只是说“订单接口怎么写”,而是会进一步区分“这是一条命令链路,还是一条查询链路”。

以一个订单服务的调用链为例

如果把 CQRS 放到下单和查单场景中,最直观的变化就是系统里开始并存两条调用链。

写路径通常是这样的:

HTTP 请求
    -> interfaces/http.OrderHandler
    -> application/command.PlaceOrderHandler
    -> domain/order.Order 聚合根
    -> infrastructure/persistence.OrderRepo
    -> 发布领域事件

读路径则通常是这样的:

HTTP 请求
    -> interfaces/http.OrderHandler
    -> application/query.OrderDetailHandler
    -> infrastructure/readmodel.OrderDetailRepo
    -> Read DB / 宽表 / ES

这两条链路最重要的区别不只是“目录不同”,而是它们的目标根本不同:

  • 写路径 关注的是业务规则、一致性和状态变更
  • 读路径 关注的是展示效率、关联结果和查询性能

也正因为如此,CQRS 并不是“把原来的 Service 拆成两个文件”那么简单,而是承认读写两类需求的优化方向从一开始就不一样。

架构全景
flowchart LR
    subgraph 写路径 Command Side
        A[Client] -->|Command| B[Command Handler]
        B --> C[Domain Model / Aggregate]
        C --> D[(Write DB - MySQL)]
        C -->|Domain Event| E[Event Bus]
    end

    subgraph 读路径 Query Side
        E -->|同步/异步投影| F[Read Model Builder]
        F --> G[(Read DB - ES/Redis)]
        H[Client] -->|Query| I[Query Handler]
        I --> G
    end

关键理解

  • Command Side(写路径):走领域模型,保证业务规则,数据存储在 MySQL
  • Query Side(读路径):绕过领域模型,直接从优化的读库(ES/Redis)返回 DTO
  • 同步机制:通过领域事件 + 投影器(Projector)将写模型同步到读模型
Command 与 Query 的设计
Command — 表达意图,不返回业务数据
// application/command/place_order.go
package command

type PlaceOrderCommand struct {
    CustomerID string
    Items      []OrderItemDTO
}

type CommandResult struct {
    Success bool
    ID      string
    Error   error
}

type PlaceOrderHandler struct {
    orderRepo order.Repository
    eventBus  EventBus
}

func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) CommandResult {
    order := domain.NewOrder(cmd.CustomerID)
    for _, item := range cmd.Items {
        if err := order.AddItem(item.ProductID, item.Qty); err != nil {
            return CommandResult{Error: err}
        }
    }
    
    events, err := order.Place()
    if err != nil {
        return CommandResult{Error: err}
    }
    
    if err := h.orderRepo.Save(ctx, order); err != nil {
        return CommandResult{Error: err}
    }
    
    h.eventBus.Publish(ctx, events...)
    return CommandResult{Success: true, ID: string(order.ID())}
}

Command 的特点

  • 表达用户的意图(下单、取消、退款)
  • 走领域模型,执行业务规则
  • 只返回操作结果(成功/失败 + ID),不返回完整的业务数据
Query — 直接返回展示层需要的 DTO
// application/query/order_detail.go
package query

type OrderDetailQuery struct {
    OrderID string
}

type OrderDetailDTO struct {
    OrderID      string    `json:"order_id"`
    CustomerName string    `json:"customer_name"`
    Items        []ItemDTO `json:"items"`
    TotalPrice   string    `json:"total_price"`
    Status       string    `json:"status"`
    CreatedAt    string    `json:"created_at"`
}

type OrderDetailHandler struct {
    readDB ReadModelRepository
}

func (h *OrderDetailHandler) Handle(ctx context.Context, q OrderDetailQuery) (*OrderDetailDTO, error) {
    // 绕过领域模型,直接从读库获取
    return h.readDB.FindOrderDetail(ctx, q.OrderID)
}

Query 的特点

  • 绕过领域模型,不触发任何业务逻辑
  • 直接从优化的读库(ES/Redis/宽表)返回 DTO
  • 数据结构完全匹配前端需求,减少转换
为什么这一步值得做

当系统还比较简单时,把读和写放在同一套模型里看起来完全没有问题;但一旦查询开始变宽、流量开始倾斜、读写优化目标开始冲突,继续强行共用同一套模型,通常会出现几个典型症状:

  • 写模型为了保证一致性保持范式化,查询却不得不频繁做多表关联
  • 一个订单详情页需要拼接商品、价格、物流、售后等多个维度,查询越来越重
  • 读流量远高于写流量,但系统却只能按同一套方式扩展
  • 业务层为了兼顾读写两端,逐渐长成“既不像领域模型,也不像查询模型”的折中结构

这时,引入 CQRS 的最大收益,就是允许“写侧继续为正确性服务,读侧单独为性能服务”。你不再需要让一套模型同时满足两种天然冲突的目标。

核心价值

极致的性能优化。你可以针对写操作使用关系型数据库(保证强一致性),针对读操作使用 Elasticsearch 或 Redis(保证高并发)。读写模型可以独立扩展、独立优化

电商系统的实际案例

场景写模型(MySQL)读模型(Elasticsearch)
下单保证事务一致性,写入订单表不涉及
订单详情页不涉及从 ES 读取宽表(包含商品、物流、售后)
订单列表不涉及从 ES 搜索(支持筛选、排序、分页)
数据分析不涉及从 ClickHouse 读取(OLAP)
收益:写侧更稳,读侧更快

引入 CQRS 之后,最直接的收益通常有三个。

第一,写侧模型可以继续保持克制。命令处理依旧围绕聚合根和一致性边界展开,不必为了适配复杂查询去牺牲建模质量。

第二,读侧可以针对场景单独优化。订单详情页、订单列表页、搜索页、报表页,可以分别使用适合自己的读模型,而不必共享同一套查询结构。

第三,读写可以独立扩展。当读 QPS 远高于写 QPS 时,你可以优先扩读模型、缓存和搜索引擎,而不是被迫整体扩容。

当然,代价也很现实:读写分离之后,系统复杂度会上升,读模型同步、最终一致性、幂等处理和投影器维护都需要额外关注。因此,CQRS 不是“默认就该上”的方案,而是在读写矛盾已经明显出现时,才值得付出的复杂性成本。

Event Sourcing:事件溯源

Event Sourcing 经常和 CQRS 一起被提及,但它们是独立的概念,可以单独使用,也可以组合使用。

核心思想

传统方式存储的是当前状态(state),Event Sourcing 存储的是导致状态变化的事件序列(events)。当前状态通过重放事件计算得出。

传统方式:
  orders 表: {id: 1, status: "paid", total: 200, updated_at: "2026-04-07"}

Event Sourcing:
  events 表:
    {seq: 1, type: "OrderCreated",  data: {id: 1, customer: "alice"}}
    {seq: 2, type: "ItemAdded",     data: {product: "shoe", price: 100, qty: 2}}
    {seq: 3, type: "OrderPlaced",   data: {total: 200}}
    {seq: 4, type: "PaymentReceived", data: {amount: 200, method: "credit_card"}}
与 CQRS 的关系
graph LR
    A[CQRS] --- B[可以独立使用]
    C[Event Sourcing] --- B
    A --- D[组合使用效果最佳]
    C --- D
    D --> E[写侧用事件存储<br/>读侧用物化视图]
  • 只用 CQRS 不用 ES:写侧用普通数据库,读侧用独立的读模型。最常见的方式。
  • 只用 ES 不用 CQRS:事件存储 + 重放计算状态,读写用同一个模型。适合审计场景。
  • CQRS + ES:写侧用事件存储,读侧通过投影事件构建物化视图。适合金融、交易系统。
适用与不适用场景
适用不适用
需要完整审计追踪(金融、合规)简单 CRUD 应用
需要时间旅行/回放(调试、分析)高频更新的状态(计数器、在线人数)
事件本身有业务价值数据模型频繁变更
需要撤销/补偿操作团队对 ES 没有经验且交期紧

本书立场:电商系统大部分场景只用 CQRS 不用 Event Sourcing。Event Sourcing 适合金融、合规等场景,但对于电商的商品、订单等模块,增加的复杂度超过了收益。

最终一致性处理策略

引入 CQRS 后,写模型和读模型之间存在延迟(通常毫秒到秒级)。这需要在架构层面和用户体验层面同时处理。

架构层面

策略一:幂等消费

投影器可能收到重复事件(at-least-once delivery),必须做幂等处理:

func (p *OrderProjector) Project(ctx context.Context, event DomainEvent) error {
    // 幂等性检查:事件是否已处理
    exists, err := p.readDB.EventProcessed(ctx, event.ID())
    if err != nil {
        return err
    }
    if exists {
        return nil // 已处理过,跳过
    }

    // 处理事件
    switch e := event.(type) {
    case OrderPlacedEvent:
        dto := OrderDetailDTO{
            OrderID:    string(e.OrderID),
            Status:     "placed",
            TotalPrice: e.Total.String(),
            CreatedAt:  e.At.Format(time.RFC3339),
        }
        if err := p.readDB.Upsert(ctx, dto); err != nil {
            return err
        }
    }
    
    // 标记事件已处理
    return p.readDB.MarkEventProcessed(ctx, event.ID())
}

策略二:补偿事务(Saga)

当跨服务操作中某一步失败,通过发布补偿事件回滚前面的步骤:

正向流程:CreateOrder → ReserveStock → ChargePayment
补偿流程:                ReleaseStock ← RefundPayment ← PaymentFailed

我们会在本章的 3.4 部分详细讲解 Saga 模式。

用户体验层面

Optimistic UI(乐观更新):前端在发送 Command 后立即更新 UI,不等待读模型同步。

用户点击"下单" 
  → 前端立即显示"订单已创建"(乐观更新)
  → 后端 Command 异步处理
  → 读模型延迟 200ms 后更新
  → 用户下次刷新时看到真实状态

Read-your-writes:Command 成功后返回版本号,Query 时带上版本号,确保读到的是自己写入之后的数据。

// Command 返回版本号
type CommandResult struct {
    Success bool
    ID      string
    Version int64  // 版本号
}

// Query 时带上版本号
type OrderDetailQuery struct {
    OrderID         string
    MinVersion      int64  // 期望读到的最小版本
}

// Query Handler 检查版本
func (h *OrderDetailHandler) Handle(ctx context.Context, q OrderDetailQuery) (*OrderDetailDTO, error) {
    dto, err := h.readDB.FindOrderDetail(ctx, q.OrderID)
    if err != nil {
        return nil, err
    }
    // 如果读模型版本低于期望版本,返回错误或等待
    if dto.Version < q.MinVersion {
        return nil, ErrReadModelNotReady
    }
    return dto, nil
}
投影器(Projector)实现模式

投影器是 CQRS 架构中将领域事件转化为读模型的组件。

flowchart LR
    A[Event Store / MQ] --> B[Projector]
    B --> C[(Read DB)]
    
    B --> D{事件类型路由}
    D -->|OrderPlaced| E[创建订单读模型]
    D -->|ItemAdded| F[更新商品明细]
    D -->|OrderCancelled| G[标记订单取消]
完整实现
// adapter/projection/projector.go
package projection

type Projector interface {
    Handles() []string // 返回该 Projector 关心的事件类型列表
    Project(ctx context.Context, event DomainEvent) error
}

type OrderReadModelProjector struct {
    readDB ReadModelRepository
}

func (p *OrderReadModelProjector) Handles() []string {
    return []string{"OrderPlaced", "OrderCancelled", "ItemAdded", "PaymentCompleted"}
}

func (p *OrderReadModelProjector) Project(ctx context.Context, event DomainEvent) error {
    switch e := event.(type) {
    case OrderPlacedEvent:
        return p.readDB.Upsert(ctx, OrderReadModel{
            OrderID:   string(e.OrderID),
            Status:    "placed",
            Total:     e.Total.Amount,
            Currency:  e.Total.Currency,
            CreatedAt: e.At,
        })
    case OrderCancelledEvent:
        return p.readDB.UpdateStatus(ctx, string(e.OrderID), "cancelled")
    case PaymentCompletedEvent:
        return p.readDB.UpdateStatus(ctx, string(e.OrderID), "paid")
    default:
        return nil
    }
}
投影器的运行模式
模式机制延迟适用场景
同步投影Command Handler 执行完后同步调用 Projector零延迟读写在同一进程、低吞吐
异步投影事件通过 MQ 传递,Projector 独立消费毫秒~秒级高吞吐、读写分离部署
Catch-up 投影Projector 从事件存储按序号拉取事件可控重建读模型、新增投影视图

电商系统推荐异步投影,理由:

  • 读写可以独立扩展(写用MySQL,读用ES)
  • 故障隔离(读模型崩溃不影响写操作)
  • 性能最优(异步处理不阻塞写操作)

本章小结

本章的核心不是再介绍几个新概念,而是把系统内部结构设计这件事重新排成一条清晰主线:

  • 先用三层架构建立最基本的职责秩序
  • 再用 Clean Architecture 收紧依赖方向
  • 再用 DDD 战术设计把复杂规则收回到模型内部
  • 最后在必要时用 CQRS 拆分读写路径

换句话说,系统内部结构设计不是在问“该选哪一种架构”,而是在问:面对职责、依赖、建模和数据流这四类问题时,架构师应该按什么顺序出招。

下一节将继续沿着这条主线往外走:当单个系统内部已经有了秩序之后,多个系统之间又该如何集成,怎样在分布式环境中处理协作与一致性问题。


3.4 系统集成与一致性设计

分布式事务挑战

跨服务、跨库、跨供应商的电商链路,本质上是在没有全局锁的前提下完成协作。讨论「分布式事务」之前,先要统一语言:我们追求的往往不是银行核心那种「强一致实时可见」,而是用户可理解的一致性财务可审计的一致性的组合。

把一致性目标写成 SLO:方法论章节最容易停留在概念层,落地时建议把「一致性」翻译成可监控指标。例如:「创单成功后 99.9% 用户在 1 秒内读到订单详情」「支付成功后 5 分钟内订单状态同步完成」「对账差异在 T+1 日内闭环率」。没有指标,团队就会在事故后争论「这算不算一致」。SLO 也会反向约束集成模式:如果你承诺秒级读己之所写,就不能把关键读路径绑在慢消费者之后。

可观测性是最小一致性保障:跨系统集成里,最大风险不是「短暂不一致」,而是「不知道不一致发生在哪」。因此链路追踪(Trace)、关联 ID(order_id / payment_id / idempotency_key)、以及跨系统日志检索规范,应被视为一致性基础设施的一部分,而不是「运维锦上添花」。当你能在 5 分钟内从用户投诉定位到具体参与方与具体请求,你已经把大量 P1 事故降级为 P3。

CAP 理论在电商中的应用

CAP 指出:在**网络分区(P)**客观存在时,系统只能在 C(线性化一致性)A(可用性:非故障节点可继续响应) 之间做权衡。电商里更务实的表述是:

  • P 不可逃避:机架故障、运营商割接、云厂商区域抖动都会制造分区;你的系统要么承认它,要么假装它不存在(后者通常以事故收场)。
  • C 与 A 不是 0/1:多数业务选择的是 延迟可接受的一致性(例如订单创建立即可见、搜索索引秒级滞后)与 降级后的可用性(例如暂停个性化推荐但不阻断下单)。

下面的三角图用三个顶点表达「三者不可同时取满」的直觉(示意,非形式化证明)。

flowchart LR
  subgraph CAP[CAP 在分区下的取舍三角]
    C((C\n强一致 / 线性化))
    A((A\n高可用 / 持续服务))
    P((P\n分区容错))
  end
  C ---|分区下难与 A 兼得| A
  A ---|要一致则需限制写入| P
  P ---|承认分区| C

  style C fill:#ffebee
  style A fill:#e8f5e9
  style P fill:#e3f2fd

电商映射示例

场景更偏向原因
支付记账、余额扣减CP 倾向差错成本高,宁可失败重试也不要错账
商品详情、推荐列表AP 倾向短暂旧数据可接受,可用性影响转化
创单 + 库存预占工程上常选 BASE + 补偿同步强一致跨多服务代价大,Saga / TCC 更常见

在 CAP 之外,工程讨论里还常用 PACELC:在**正常(Latency)分区(Partition)**两种状态下,分别在 C 与 A、**C 与 L(延迟)**之间权衡。电商系统的真实矛盾往往不在「选 CP 还是 AP」这种标签,而在「把哪一类不一致暴露给用户」:用户能容忍搜索列表晚 1 秒,但很难容忍「支付成功却订单仍待付款」这种语义断裂。于是产品与技术要共同定义 RPO/RTO 的业务翻译——例如「支付回调最长可延迟多久仍可被用户理解」「库存预占展示与真实可售不一致的上限是多少」。

另一个常见误区是把 「强一致」当成银弹。跨服务的两阶段提交(2PC)与其变体在微服务规模下会放大故障域:协调者不可用、参与者长时间持锁、尾延迟抖动都会被交易洪峰放大。更稳妥的工程路径通常是:在单个聚合 / 单个服务内用数据库事务守住硬约束,跨边界用 Saga / TCC / 可靠消息组合,再用 对账兜住不可避免的外部异步。

一致性分类

从工程师落地角度,建议把「一致性」从口号拆成可见性容错模型经济后果三层,再映射到技术策略。

flowchart TB
  subgraph L1[用户可见一致性]
    U1[读己之所写 Read-your-writes]
    U2[单调读 Monotonic reads]
    U3[因果一致 Causal]
  end
  subgraph L2[跨系统事实来源]
    S1[单主库权威 Single source of truth]
    S2[多副本异步复制]
    S3[事件驱动物化视图]
  end
  subgraph L3[财务 / 履约后果]
    F1[强一致账务]
    F2[最终一致业务态]
    F3[对账闭环]
  end
  L1 --> L2
  L2 --> L3

谱系速查

  • 强一致(线性化 / 串行化):单个分片内靠数据库事务;跨分片靠分布式协调(代价高、故障域大)。
  • 会话一致 / 单调读:常见于「用户刚下的单,列表里立刻能看到」——可用主从路由策略与 sticky 读实现。
  • 最终一致:允许短暂漂移,但必须有版本、水位、对账与补偿兜住边界。
  • 因果一致:适合「评论回复依赖发帖可见」等场景;电商里多用于协作类附属功能,主交易链路仍以订单状态机为准。

为了把「一致性」从论文语言落到排障语言,建议团队在架构评审里强制区分三类问题,并把它们映射到不同手段:

  1. 读可见性问题:用户「刚操作完却看不到」——多数是读写路由、缓存、投影延迟;优先查主从延迟、Outbox 堆积、消费者 lag。
  2. 跨系统事实冲突:订单说已支付、支付说未成功——多数是回调丢失、幂等键不一致、状态机非法迁移;优先查渠道流水与平台流水对齐。
  3. 跨时间窗口的统计不一致:GMV 看板与财务口径对不上——多数是异步汇总、时区、退款冲正口径;优先查离线任务水位与口径文档。

下表给出「用户感知」与「系统手段」的对照,用于和需求方对齐预期(避免把技术极限包装成业务承诺)。

用户感知目标典型手段需要额外付出的代价
下单后列表立刻出现读主库 / 读己之所写路由主库压力、热点风险
全站搜索秒级更新Outbox + 近实时索引运维复杂度、契约治理
支付成功即刻可履约同步确认 + 强校验 + 幂等尾延迟、渠道配额
大促高峰仍可下单异步化、削峰、降级读短暂不一致、对账压力
典型场景分析
  1. 创单链路(订单、库存、营销、计价):跨多个限界上下文,同步点越多尾延迟越大;典型解法是「结算页强校验 + 创单 Saga + 异步投影」。
  2. 搜索与商品主数据:索引滞后是常态,关键是可观测的延迟降级展示(与本章的 3.6 部分呼应)。
  3. 支付回调与渠道对账:渠道侧状态与平台侧状态异步收敛;必须幂等、必须可对账(与支付相关章节及 6.5 节呼应)。
  4. 供应商库存:供应商为权威时,平台侧是快照 + 同步策略;分区时以可解释的拒单 / 延迟确认交换「永不超卖」承诺。
  5. 退款与售后:涉及支付渠道、营销回退、库存回冲、供应商拦截等多参与方,不可逆节点(已打款、已出票)会把「补偿」切换为「工单 / 人工」。这类链路更需要 Saga 日志 + 对账批次号 + 幂等退款单号 三件套,避免重复退款与部分成功。
  6. 秒杀与抢购:热点 SKU 的约束是「库存单调递减」与「请求风暴」叠加。工程上通常是 Redis 原子预减 + 异步落库对账,而不是跨服务同步强一致;否则会把尾延迟与失败面扩散到整个站点入口。
  7. 清结算与分账:平台、商家、渠道、营销补贴多方账本需要在 T+N 周期收敛。这里的「一致性」更像会计恒等式:借贷平衡、可追溯、可审计,而不是用户请求路径上的毫秒级一致。
  8. 跨境与多币种:汇率快照、税费、支付路由使得「金额一致」必须显式引入 snapshot_id / rate_version,否则对账会把产品问题误判为技术故障。

这些场景的共同点,是都要求你在架构文档里写清楚一句话:一致性的责任边界在哪里结束。例如库存预占由库存服务负责语义,订单只保存 reserve_id 凭证;支付金额以支付核心的记账为准,订单侧只保存 pay_amount 快照与引用号。边界写清楚,集成才不会退化成「谁都能改一笔」。


Saga 编排模式

Saga 把长事务拆为本地事务序列,用补偿事务撤销已提交步骤的可逆效果。它不要求全局锁表,也不要求所有参与方实现预留接口(对比 TCC 的 Try/Confirm/Cancel),因此在跨团队集成中最常见。

很多团队会把「分布式事务」理解成「找一个中间件把多个数据库一次性提交」。在电商微服务里,这种理解往往会把问题推向两个极端:要么 过度依赖全局协调(可用性与尾延迟受损),要么 完全回避一致性话题(只能靠人肉修数)。Saga 的价值在于承认现实:跨服务没有免费的全局原子性,但可以把不确定性收敛到 可测试的本地事务 + 可审计的补偿 + 可对账的凭证。当你能清楚说出「哪一步失败会留下什么外部痕迹」,你就已经比大多数项目更接近可控。

落地时还要区分 业务失败系统失败:库存不足是业务失败,通常不应触发重试;渠道超时是系统失败,需要退避重试与查询对齐。把两者混在一个 if err != nil 里,Saga 会表现为「无意义重试放大雪崩」或「该补偿却不补偿」。建议在错误模型里显式引入 BusinessErrorTransientError(或等价错误码),编排器据此分支。

Saga 基础概念
  • 子事务(Local Transaction):在一个服务 / 一个库边界内可原子提交。
  • 补偿(Compensation):语义上撤销前一步业务效果;必须幂等,且要能处理「原操作其实失败」的空补偿。
  • Saga 日志:记录每一步状态,支撑断点续跑、人工介入与审计。

TCC(Try-Confirm-Cancel) 相比,Saga 对参与方的接口要求更低,但业务侧要承担更多「补偿语义」的设计成本。可以用下表做模式选型(不是非此即彼,很多系统会在支付子域用更严格的协议,在营销子域用 Saga)。

维度SagaTCC
参与方改造低:正向 + 补偿即可高:三阶段接口与资源预留语义
一致性强度依赖补偿正确性与对账Try 成功后可更强约束提交
失败处理补偿链 + 人工兜底Cancel 路径必须可靠
典型适用创单、结算编排支付、余额、库存强约束场景(团队成熟度高)
编排 vs 协同
flowchart LR
  subgraph Orch[编排 Orchestration]
    O[OrderOrchestrator\n集中状态机]
    O --> I[InventorySvc]
    O --> M[MarketingSvc]
    O --> P[PricingSvc]
  end

  subgraph Cho[协同 Choreography]
    E1[OrderCreated 事件]
    E2[InventoryReserved 事件]
    E3[MarketingLocked 事件]
    OrdSvc[订单服务]
    InvSvc[库存服务]
    MktSvc[营销服务]
    OrdSvc --> E1 --> InvSvc
    InvSvc --> E2 --> MktSvc
    MktSvc --> E3
  end

选型经验

  • 编排:调试路径清晰,适合强流程(创单、退款、结算);缺点是编排器可能成为热点与变更集中点。
  • 协同:解耦参与者,适合弱流程扩展;缺点是全局可观测性与顺序约束更难,需要严格的事件契约与版本治理

电商创单、退款等有严格顺序与对账要求的链路,业界更常见的是编排为主、事件为辅

混合形态:编排器完成「强一致点的凭证收集」(试算快照、预占号、营销锁),聚合根提交后再通过 Outbox 广播 OrderCreated 给搜索、推荐、风控等读模型或旁路系统。这样既保留集中调试与审计的主线,又避免把读侧耦合进同步链路。

补偿机制设计

补偿不是「数据库回滚」的同义词,而是业务语义撤销。设计要点:

  1. 可逆性分级:库存释放、券解锁可逆;已发货、已出票常不可逆,需要转入人工 / 工单 / 财务流程。
  2. 补偿的幂等:网络重试会导致补偿重复执行;应用层用业务幂等键状态检查保证安全。
  3. 失败面分类:业务拒绝(库存不足)与基础设施故障(超时)要分流,后者才触发重试与退避。
  4. 超时与悬挂:子调用超时后,编排器要通过查询接口把「未知」落成「成功 / 失败」之一,再决定前进或回滚。

Saga 持久化与恢复:请求线程崩溃、实例重启、发布滚动都会导致「执行到一半」的外部视图。生产系统应至少落一张 saga_instance 表(或等价事件日志),字段建议包含:saga_idbiz_key(幂等键)、current_stepstatus(running/compensating/succeeded/failed)、payload_jsonstarted_atupdated_atversion(乐观锁)。恢复线程按 status=running 扫描,结合每步的 query 结果把流程推向下一个合法状态。没有这张表,你只能依赖日志拼凑现场,事故复盘成本会指数级上升。

并发与重入:同一个 biz_key 的重复请求不应启动第二个 Saga 实例。常见做法是「数据库唯一约束 + 返回进行中的 saga_id」或「Redis 分布式锁(短 TTL + 续期谨慎)」。锁方案要警惕:锁超时后另一个实例进入,会造成双轨执行;因此最终仍应以 幂等键与 Saga 状态机 作为真相来源。

sequenceDiagram
  participant O as Orchestrator
  participant S as StepService
  O->>S: forward()
  alt 成功
    S-->>O: OK
    O->>O: persist saga step done
  else 明确失败
    S-->>O: FAIL business
    O->>S: compensate previous (idempotent)
    S-->>O: OK
  else 超时 / 未知
    S-->>O: TIMEOUT
    O->>S: query()
    S-->>O: committed? yes/no
    O->>O: reconcile then forward or compensate
  end
订单创建的 Saga 实例

下面给出一个教学裁剪版结构完整的 Go 示例:三步正向(计价快照绑定、库存预占、营销锁定)与两步补偿(释放库存、解锁营销)。示例省略了真实 RPC、链路追踪与部分错误包装,但保留编排骨架、幂等键透传、补偿逆序等关键结构。

Saga 流程图

flowchart TD
  Start([开始 CreateOrderSaga]) --> T1[Step1: AttachPriceSnapshot]
  T1 -->|失败| X1([结束: 失败无补偿])
  T1 -->|成功| T2[Step2: ReserveInventory]
  T2 -->|失败| C1[Comp1: 无需库存补偿]
  T2 -->|成功| T3[Step3: LockMarketing]
  T3 -->|失败| C2[Comp2: ReleaseInventory]
  T3 -->|成功| Done([持久化订单\n提交本地事务])
  C2 --> X2([结束: 已回滚库存])

  style Done fill:#e8f5e9
  style X1 fill:#ffebee
  style X2 fill:#fff3e0

Go 编排骨架(单文件可 go run,将下游调用替换为真实 gRPC / HTTP SDK 即可落地):

Walk-through(对照流程图读代码)

  1. AttachSnapshot 失败时,系统尚未占用库存与营销资源,因此直接返回错误即可,对应流程图左侧「失败无补偿」分支。
  2. Reserve 成功后,reserve_id 成为库存侧的外部凭证;后续任何释放都必须携带它,并在库存服务侧以幂等方式处理。
  3. LockPromo 失败进入补偿:只释放库存,不解锁营销(因为锁未成功)。若未来某版本 LockPromo 变成「部分成功」(例如锁定成功但写审计失败),必须把补偿扩展为「先查锁是否存在再解锁」,否则会出现补偿空转或补偿遗漏。
  4. Run 成功后,订单服务应在单一本地事务中写入订单主表、明细、价格快照引用、外部凭证集合,并写入 Outbox 触发下游投影。不要把「订单持久化」拆到多个没有事务边界的 RPC 里,否则你会重新发明分布式事务。
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"time"
)

// ---- 端口:生产环境应使用 gRPC / 生成代码 ----

type PricingClient interface {
	AttachSnapshot(ctx context.Context, orderID, snapshotID string) error
}

type InventoryClient interface {
	Reserve(ctx context.Context, idempotencyKey, orderID, sku string, qty int) (reserveID string, err error)
	Release(ctx context.Context, idempotencyKey, reserveID string) error
}

type MarketingClient interface {
	LockPromo(ctx context.Context, idempotencyKey, orderID, promoRef string) (lockID string, err error)
	UnlockPromo(ctx context.Context, idempotencyKey, lockID string) error
}

// ---- 编排器 ----

type CreateOrderCommand struct {
	OrderID        string
	IdempotencyKey string
	PriceSnapshot  string
	SKU            string
	Qty            int
	PromoRef       string
}

type CreateOrderSaga struct {
	Pricing   PricingClient
	Inventory InventoryClient
	Marketing MarketingClient
}

func (s *CreateOrderSaga) Run(ctx context.Context, cmd CreateOrderCommand) error {
	if cmd.OrderID == "" || cmd.IdempotencyKey == "" {
		return errors.New("invalid command")
	}

	// Step 1: 绑定计价快照(失败则无资源占用)
	if err := s.Pricing.AttachSnapshot(ctx, cmd.OrderID, cmd.PriceSnapshot); err != nil {
		return fmt.Errorf("attach snapshot: %w", err)
	}

	// Step 2: 库存预占
	reserveID, err := s.Inventory.Reserve(ctx, cmd.IdempotencyKey+":inv", cmd.OrderID, cmd.SKU, cmd.Qty)
	if err != nil {
		return fmt.Errorf("reserve inventory: %w", err)
	}

	// Step 3: 营销锁定
	lockID, err := s.Marketing.LockPromo(ctx, cmd.IdempotencyKey+":mkt", cmd.OrderID, cmd.PromoRef)
	if err != nil {
		// 补偿:释放库存(逆序)
		if relErr := s.Inventory.Release(ctx, cmd.IdempotencyKey+":inv:rel", reserveID); relErr != nil {
			return fmt.Errorf("lock promo failed: %v; release inventory failed: %w", err, relErr)
		}
		return fmt.Errorf("lock promo: %w", err)
	}

	log.Printf("saga ok order=%s reserve=%s lock=%s", cmd.OrderID, reserveID, lockID)
	// Step 4(示意):在同一服务的数据库事务里插入订单主表与明细
	return nil
}

// ---- 内存桩:演示幂等 + 成功路径 ----

type memPricing struct{}

func (memPricing) AttachSnapshot(ctx context.Context, orderID, snapshotID string) error {
	return nil
}

type memInventory struct {
	released bool
}

func (m *memInventory) Reserve(ctx context.Context, idempotencyKey, orderID, sku string, qty int) (string, error) {
	return "resv_123", nil
}

func (m *memInventory) Release(ctx context.Context, idempotencyKey, reserveID string) error {
	m.released = true
	return nil
}

type memMarketing struct{}

func (memMarketing) LockPromo(ctx context.Context, idempotencyKey, orderID, promoRef string) (string, error) {
	return "lock_456", nil
}

func (memMarketing) UnlockPromo(ctx context.Context, idempotencyKey, lockID string) error {
	return nil
}

// 将 HTTP 客户端映射为 InventoryClient 的示例(真实项目用专用 SDK)
type httpInventory struct {
	client *http.Client
	url    string
}

func (httpInventory) Reserve(ctx context.Context, idempotencyKey, orderID, sku string, qty int) (string, error) {
	// 伪代码:构造 POST /v1/reservations,Header 携带 Idempotency-Key
	return "", errors.New("not implemented in demo")
}

func (httpInventory) Release(ctx context.Context, idempotencyKey, reserveID string) error {
	return errors.New("not implemented in demo")
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	saga := &CreateOrderSaga{
		Pricing:   memPricing{},
		Inventory: &memInventory{},
		Marketing: memMarketing{},
	}
	err := saga.Run(ctx, CreateOrderCommand{
		OrderID:        "ord_1",
		IdempotencyKey: "idem_user_click_001",
		PriceSnapshot:  "pshot_9f3c",
		SKU:            "sku_a",
		Qty:            1,
		PromoRef:       "promo_x",
	})
	if err != nil {
		log.Fatal(err)
	}
}

落地清单(从示例走向生产)

  • 为每一步引入持久化 Saga 表saga_idstepstatuspayloaderror_code)。
  • 所有 outbound 调用携带关联 IDorder_idtrace_ididempotency_key)。
  • 对「超时未知」统一走 query + reconcile 状态机,而不是立刻补偿。

Saga 表结构示例(MySQL,示意):落地时按你们公司的审计规范补全操作者与 trace 字段。

CREATE TABLE saga_instance (
  saga_id       BIGINT PRIMARY KEY AUTO_INCREMENT,
  biz_key       VARCHAR(128) NOT NULL,
  name          VARCHAR(64)  NOT NULL,
  status        VARCHAR(32)  NOT NULL,
  current_step  INT            NOT NULL,
  payload       JSON           NOT NULL,
  version       INT            NOT NULL DEFAULT 0,
  created_at    DATETIME       NOT NULL,
  updated_at    DATETIME       NOT NULL,
  UNIQUE KEY uk_saga_biz (biz_key, name)
);

CREATE TABLE saga_step (
  id         BIGINT PRIMARY KEY AUTO_INCREMENT,
  saga_id    BIGINT NOT NULL,
  step_no    INT    NOT NULL,
  step_name  VARCHAR(64) NOT NULL,
  status     VARCHAR(32) NOT NULL,
  req        JSON,
  resp       JSON,
  err        TEXT,
  created_at DATETIME NOT NULL,
  KEY idx_saga (saga_id, step_no)
);

事件驱动架构

事件驱动架构(EDA)把系统间的耦合从「知道对方的表结构」变为「订阅对方愿意公布的事实」。它与 DDD 的**领域事件(Domain Event)**天然契合:事件名应是业务过去式(OrderPlaced),而不是命令式(PlaceOrder)。

EDA 并不自动带来解耦:如果事件载荷里塞满下游私有字段,或消费者之间隐式依赖顺序却缺乏分区策略,你只会得到「异步耦合的大泥球」。因此本章强调三件事:契约顺序边界可观测的投递语义。与第 38 章的事件发布实践结合时,请把「事件平台能力」与「领域建模能力」分开评估:Kafka 再强也替代不了你对聚合边界的判断。

领域事件

领域事件用于表达聚合内已发生且不可变的事实。好的事件:

  • 自描述:携带必要标识与版本(schema_version)。
  • 可演进:兼容字段新增,慎改语义。
  • 与命令分离:命令可丢弃重试;事件一旦发布,消费者会据此做副作用。

命名与版本:事件名建议稳定且可检索(order.placed.v1),避免把促销规则编码进 topic 名称。载荷里携带 occurred_atproducerschema_version,消费者才能做 向后兼容 解析。另一个实践是把「业务关键字段」与「展示字段」分层:关键字段用于幂等与投影,展示字段允许缺失并由读模型降级。

聚合边界:领域事件应从聚合根的不变量中自然产生,而不是为了通知某个下游临时「造事件」。后者会导致事件泛滥、顺序难以推理、回放成本失控。若你发现自己需要 SomethingMaybeChanged 这类含糊事件,通常意味着限界上下文边界需要重塑。

事件的发布与订阅
flowchart LR
  subgraph OrderBC[订单限界上下文]
    AR[Order Aggregate]
    AR -->|产生| DE[Domain Events]
    DE --> OB[Outbox Table]
  end
  subgraph Infra[基础设施]
    REL[Outbox Relay / Poller]
    BUS[(Message Bus)]
  end
  subgraph Consumers[订阅方]
    InvProj[库存读模型投影]
    Srch[搜索索引增量]
    Risk[风控评分任务]
  end
  OB --> REL --> BUS
  BUS --> InvProj
  BUS --> Srch
  BUS --> Risk

发布订阅的工程细节

  • Topic 分区键:与顺序性强相关的字段(同一 order_id)应映射到同一分区,避免乱序消费;但分区键过粗会造成热点分区,需要业务侧权衡。
  • 消费者组:一组消费者共享进度,实现水平扩展;重平衡(rebalance)会带来短暂停顿,要评估是否影响实时性 SLA。
  • 至少一次投递:因此消费者必须 幂等;常见实现是 UNIQUE(consumer, event_id) 或业务唯一键。
  • 顺序与并行:能并行就并行(不同聚合互不相关),不能并行就必须把「会改变含义的顺序」收敛到单分区或单线程处理器。
  • 背压:投影任务落后时,应有 lag 告警与降级读策略,避免把读路径拖死。

与第 38 章「事件发布」对齐时,把「谁允许发什么事件」纳入治理:事件不是自由文本广播,而是受版本管理的契约。建议在仓库中维护 events/ 目录(或 Buf Schema Registry),把破坏性变更当作发布流程的一部分。

事件溯源(Event Sourcing)

事件溯源(ES)把状态还原为事件流的折叠state = fold(events)。它带来强大审计与回放能力,但也引入:

  • 模型复杂度:投影、快照、版本迁移成本高。
  • 查询压力:多数业务仍需要物化读模型(CQRS)。

电商建议:账务、支付指令、库存流水等强审计子域可评估 ES;一般商品展示、搜索索引用物化视图 + Outbox 性价比更高(与第 1 章 CQRS 小节呼应)。

何时值得上 ES:当你明确需要「按时间回放任意业务态」且愿意投入 投影重建、快照策略、事件迁移工具链;否则先用 审计日志表 + 不可变对象存储归档 + 定期校验 往往更划算。ES 不是银弹,它是把复杂度从数据库迁移到了事件存储与投影运维。

快照(Snapshot):长生命周期聚合(例如会员账户、长期预售订单)如果每次都从头折叠事件,读路径会不可接受。快照本质是「在某版本截断事件流」,需要定义 快照写入频率快照与事件的版本对齐规则

实践要点与 Outbox 完整实现

Outbox 模式解决的核心矛盾是:数据库事务提交消息发布难以跨资源原子化。做法是:在同一本地事务中写入业务表与 outbox 表;由独立进程异步投递到消息总线,实现 at-least-once 发布且不丢单(消费者仍需幂等)。

Outbox 时序图

sequenceDiagram
  participant API as Order API
  participant DB as MySQL
  participant R as Outbox Relay
  participant K as Kafka

  API->>DB: BEGIN
  API->>DB: INSERT orders ...
  API->>DB: INSERT outbox(event_type,payload,status)
  API->>DB: COMMIT
  loop poll
    R->>DB: SELECT ... FOR UPDATE SKIP LOCKED
    R->>K: produce message(idempotent key)
    K-->>R: ack
    R->>DB: UPDATE outbox SET status=published
  end

Go 实现骨架(使用 database/sql;生产环境可替换为 sqlx / ORM,但保持「同事务写两张表」不变):

package outboxdemo

import (
	"context"
	"database/sql"
	"encoding/json"
	"time"
)

type OutboxEvent struct {
	ID        int64
	Aggregate string
	EventType string
	Payload   json.RawMessage
	Status    string // pending / published / dead
	CreatedAt time.Time
}

type OrderRepository struct {
	DB *sql.DB
}

// CreateOrderWithOutbox 演示:订单写入与 outbox 同事务
func (r *OrderRepository) CreateOrderWithOutbox(ctx context.Context, orderID string, evtType string, payload any) error {
	bytes, err := json.Marshal(payload)
	if err != nil {
		return err
	}

	tx, err := r.DB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()

	if _, err := tx.ExecContext(ctx, `INSERT INTO orders(id, status) VALUES(?, 'CREATED')`, orderID); err != nil {
		return err
	}
	if _, err := tx.ExecContext(ctx,
		`INSERT INTO outbox(aggregate_id, event_type, payload, status, created_at)
		 VALUES(?,?,?,?,?)`,
		orderID, evtType, bytes, "pending", time.Now().UTC(),
	); err != nil {
		return err
	}
	return tx.Commit()
}

// Relay 轮询投递:演示 SKIP LOCKED 多实例安全
type Relay struct {
	DB *sql.DB
}

func (relay *Relay) PollOnce(ctx context.Context, publish func(ctx context.Context, ev OutboxEvent) error) (int, error) {
	tx, err := relay.DB.BeginTx(ctx, nil)
	if err != nil {
		return 0, err
	}
	defer func() { _ = tx.Rollback() }()

	rows, err := tx.QueryContext(ctx, `
		SELECT id, aggregate_id, event_type, payload, status, created_at
		FROM outbox
		WHERE status='pending'
		ORDER BY id ASC
		LIMIT 50
		FOR UPDATE SKIP LOCKED`)
	if err != nil {
		return 0, err
	}
	defer rows.Close()

	var batch []OutboxEvent
	for rows.Next() {
		var ev OutboxEvent
		var agg string
		if err := rows.Scan(&ev.ID, &agg, &ev.EventType, &ev.Payload, &ev.Status, &ev.CreatedAt); err != nil {
			return 0, err
		}
		ev.Aggregate = agg
		batch = append(batch, ev)
	}
	if len(batch) == 0 {
		return 0, tx.Commit()
	}

	for _, ev := range batch {
		if err := publish(ctx, ev); err != nil {
			return 0, err
		}
		if _, err := tx.ExecContext(ctx, `UPDATE outbox SET status='published' WHERE id=?`, ev.ID); err != nil {
			return 0, err
		}
	}
	return len(batch), tx.Commit()
}

func DemoDDL() string {
	return `
CREATE TABLE IF NOT EXISTS orders (
  id VARCHAR(64) PRIMARY KEY,
  status VARCHAR(32) NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  aggregate_id VARCHAR(64) NOT NULL,
  event_type VARCHAR(128) NOT NULL,
  payload JSON NOT NULL,
  status VARCHAR(16) NOT NULL,
  created_at DATETIME NOT NULL,
  KEY idx_outbox_pending (status, id)
);`
}

工程清单

  • Relay 进程要独立扩容,与 API 进程分离;发布失败应退避重试并将多次失败送入死信队列人工处理。
  • 消息体应携带 event_id / aggregate_id / causation_id,与消费者表上的唯一约束联合实现端到端幂等。
  • 与第 38 章「事件发布」衔接时,把事件契约(JSON Schema / Protobuf)纳入 CI,避免「字段悄悄改名」造成投影脏写。

Outbox 运维要点pending 堆积通常不是 Kafka 坏了,而是 Relay 吞吐不足、DB 锁竞争、或下游拒绝消息。建议把以下指标做成仪表盘:outbox_pending_countrelay_lag_secondspublish_fail_ratedead_letter_count。出现持续堆积时,优先扩容 Relay 与检查热点 aggregate_id(大单事件风暴)。

顺序投递 vs 批量投递:某些支付相关事件需要严格顺序,Relay 可以按 aggregate_id 分区串行投递;而搜索增量可批量合并,降低总线开销。关键是不要把「所有事件都塞进一个全局顺序」里,否则系统吞吐会被最慢的消费者绑架。

与第 1 章 Outbox 小节的关系:第 1 章强调「领域事件异步化」的动机与边界;本章补齐 实现骨架、时序与运维指标,便于你在后续中间件与实战章节落地到具体服务时直接对照检查清单。


幂等性设计通用方案

幂等性的本质

幂等性回答的问题是:同一个业务意图被执行多次,是否与只执行一次等价。分布式系统里重复来源包括:用户双击、网关重试、消息重复投递、回调重放。

从接口语义上,幂等还应区分两类返回:

  • 语义幂等:第二次调用返回与第一次业务等价的结果(可能 HTTP 状态码不同,但业务码一致),典型是支付创建。
  • 严格幂等:第二次调用应尽可能返回同一响应体(含错误),以便客户端无需分支处理;这通常依赖网关或应用侧的 响应缓存

另一个关键维度是 时间窗口:创单幂等键可能只需 24 小时;支付幂等键可能要跨结算周期。窗口外的重复请求应被明确拒绝还是进入人工?这属于产品策略,但必须在技术方案里写死,否则会出现「以为幂等永远有效」的误用。

实现策略
策略适用注意
天然幂等SET status='CANCELLED' WHERE id=? AND status='PAID'仍需防止错误状态迁移
业务幂等键支付、创单、退款需要落库索引与 TTL 治理
令牌桶 / 去重表高并发写定期归档,冷热分离
唯一约束DB 层最终防线冲突即视为重复成功需返回同一结果

组合策略才是常态:接口层挡住「明显重复」;服务层用状态机挡住「非法重放」;数据库用唯一约束挡住「并发双插」。任何单层都可能被绕过(例如内部任务不经过网关),因此不要迷信「只加 Header 就安全」。

测试清单:至少覆盖「并发双请求同一幂等键」「第一次超时后重试」「第一次失败第二次成功」「消息重复投递」四类用例;支付与退款还要覆盖「渠道侧已成功但平台超时」的对称场景(与支付相关章节联动)。

各层的幂等性保证
flowchart TB
  subgraph Edge[接入层]
    GW[API Gateway\nIdempotency-Key 透传 / 快速去重]
  end
  subgraph App[应用服务层]
    SVC[Service\n幂等表 / 状态机守卫]
  end
  subgraph Data[数据层]
    DB[(MySQL UNIQUE\n业务键 / 请求键)]
    MQ[消息消费者\n消费位点 + 业务唯一键]
  end
  Client[Client] --> GW --> SVC --> DB
  BUS[(Kafka)] --> MQ --> SVC

接口层示例:幂等键落库

type IdempotencyStore interface {
	// TryBegin 返回 true 表示首次;false 表示重复,应返回缓存响应
	TryBegin(ctx context.Context, key, route string) (bool, error)
	SaveResponse(ctx context.Context, key string, code int, body []byte) error
	GetResponse(ctx context.Context, key string) (code int, body []byte, found bool, err error)
}

func WithCreateOrderIdempotency(store IdempotencyStore, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		key := r.Header.Get("Idempotency-Key")
		if key == "" {
			http.Error(w, "missing Idempotency-Key", http.StatusBadRequest)
			return
		}
		first, err := store.TryBegin(r.Context(), key, r.URL.Path)
		if err != nil {
			http.Error(w, "idempotency error", http.StatusInternalServerError)
			return
		}
		if !first {
			code, body, found, err := store.GetResponse(r.Context(), key)
			if err != nil || !found {
				http.Error(w, "duplicate without cached response", http.StatusConflict)
				return
			}
			w.WriteHeader(code)
			_, _ = w.Write(body)
			return
		}
		// 实现时需要包装 ResponseWriter 捕获状态码与 body,成功后 SaveResponse
		next.ServeHTTP(w, r)
	})
}

服务层:把幂等键与业务键(order_idpayment_id)建立映射;对「处理中」状态设置合理超时,避免永久悬挂。

数据层:对 merchant_order_nochannel_trade_no 等建立 UNIQUE 索引;冲突时读取已有行并返回与首次一致的结果体(支付场景极其关键,见支付相关章节)。

消息消费者层:除了业务唯一键,还要注意 at-least-once 带来的「处理成功但 ack 前崩溃」重复。常见做法是:业务写入与「消费记录」同事务,或采用 幂等表 + 业务状态机 组合。Kafka 的幂等 producer 只能保证生产端不重复,不能保证消费端。

与第 13–15 章的衔接:购物车提交、结算创单、支付创建与回调,是幂等设计最密集的区域。请把这些链路里的 Idempotency-Key 来源、TTL、冲突返回码统一成一份「交易接口规范」,否则每个团队会发明一种错误语义,客户端与对账都会被拖垮。


数据一致性保证

最终一致性

最终一致性不是「暂时不一致然后祈祷」,而是满足三个条件:

  1. 收敛性:在无新写入时,系统应到达稳定态。
  2. 可观测性:能度量漂移(延迟、差异条数)。
  3. 可修复性:通过对账与补偿把漂移拉回业务可接受范围。

最终一致的工程含义:允许短暂不一致,但不允许「永远不一致」。因此必须定义 最大允许漂移时间(MTTD)修复时限(MTTR) 的业务含义。例如「支付回调最多延迟 5 分钟,对账必须在 T+1 日内闭环」,这类指标比对程序员说「我们最终一致」更有约束力。

与缓存的关系:读路径缓存(商品详情、列表价)引入的是另一类一致性。原则是:写路径更新权威,再异步失效 / 刷新缓存;不要反过来用缓存驱动写。库存热路径若使用 Redis,必须把 对账与回补 当作一等能力,而不是事后补丁。

对账机制

对账回答的问题是:两份账本是否在说同一件事。电商常见对账维度:

  • 平台订单 vs 支付渠道:金额、手续费、状态、退款。
  • 库存流水 vs 实物出库:WMS / OMS 对齐。
  • 营销预算 vs 实际核销:防止薅羊毛与预算透支。

设计要点(与库存、支付相关章节呼应):

  1. 对账文件与解析:渠道侧日终文件 + 平台侧流水导出;解析必须版本化。
  2. 三层匹配:长款(渠道有平台无)、短款(平台有渠道无)、金额不一致。
  3. 差错工单:自动修复仅限白名单场景;其余进入人工复核。
  4. 幂等与回放:同一对账批次重复跑不产生重复账务分录。

对账批次生命周期(建议)INITPARSEDMATCHEDDIFF_GENERATEDFIXUP_APPLIEDCLOSED。每一步落审计日志,支持监管问询与内部复盘。短款与长款不要混在一个工单模板里:短款更像「钱可能丢了」,长款更像「重复记账风险」,处理 SLA 与审批链往往不同。

平台侧数据准备:对账不只读「业务库」,还要聚合 渠道回调日志、网关请求日志、消息投递记录。否则你会出现「业务状态对,但财务凭证缺角」的尴尬。实践中常用 不可变事件流水 作为对账输入之一,因为它比业务表更抗「事后改字段」。

与库存对账的衔接:库存侧常见是 Redis 计数与 MySQL 流水、供应商快照三方对齐。支付侧则是平台支付单与渠道清算文件对齐。两者共享同一套工程套路:差异分类 → 自动白名单修复 → 人工复核 → 复盘入库

package recon

import (
	"context"
	"database/sql"
	"time"
)

type ChannelRow struct {
	TradeNo     string
	AmountCents int64
	Status      string
	OccurredAt  time.Time
}

type PlatformRow struct {
	PaymentID   string
	ChannelRef  string
	AmountCents int64
	Status      string
}

// ReconcileBatch 演示:以 channel_ref 对齐(生产需处理多币种、多清算周期)
func ReconcileBatch(ctx context.Context, db *sql.DB, ch []ChannelRow, pf []PlatformRow) error {
	pidx := map[string]PlatformRow{}
	for _, p := range pf {
		pidx[p.ChannelRef] = p
	}
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	defer func() { _ = tx.Rollback() }()

	for _, c := range ch {
		p, ok := pidx[c.TradeNo]
		if !ok {
			_, _ = tx.ExecContext(ctx, `INSERT INTO recon_diff(batch_at, kind, ref, detail) VALUES(?,?,?,?)`,
				time.Now().UTC(), "SHORT_PLATFORM", c.TradeNo, "channel has, platform missing")
			continue
		}
		if p.AmountCents != c.AmountCents || p.Status != c.Status {
			_, _ = tx.ExecContext(ctx, `INSERT INTO recon_diff(batch_at, kind, ref, detail) VALUES(?,?,?,?)`,
				time.Now().UTC(), "MISMATCH", c.TradeNo, "amount or status")
		}
	}
	return tx.Commit()
}

差错分类与处理策略(支付对账视角的抽象):无论渠道是微信、支付宝还是银行卡收单,差异最终都会落到有限几类。

  • 状态不一致但金额一致:常见于回调丢失或延迟。处理上优先以渠道终态为准触发状态迁移,并确保迁移动作幂等。
  • 金额不一致:高风险,通常需要冻结相关支付单与关联订单,禁止自动发货,进入财务复核;同时要回溯是否存在重复退款、重复记账或币种转换错误。
  • 手续费 / 分账字段不一致:多见于规则变更窗口与历史数据混跑。处理上应引入 规则版本号生效时间,避免用新规则解释旧交易。
  • 时间窗口不一致:渠道文件是「清算日」,平台流水是「交易发生日」。对账前要先把口径写到 SOP:以哪个时区、哪个切分点为准。

自动化修复的边界:只有满足「可逆、可证明、可回放」三条件的动作才适合自动化。例如「补写缺失的支付回调记录」可以自动化;「直接给用户退款」通常需要更高等级审批与多重校验。自动化的目标是减少人工 机械劳动,不是替代 风险判断

补偿任务

补偿任务与 Saga 补偿不同:后者在请求生命周期内;前者是异步修复器,用于:

  • 回调迟到导致的悬挂单;
  • 消息堆积造成的投影落后;
  • 对账发现的轻微差异批量冲正。

设计清单:可重入批大小上限死信隔离人工止血的开关

任务编排建议:补偿任务尽量 幂等、可观测、可暂停。出现大面积渠道故障时,最危险的是「自动修复脚本跑得比人还快」,把差错扩散成二次事故。要有 全局开关 + 分渠道开关 + 最大自动修复笔数阈值

与支付补偿的差异:Saga 补偿发生在用户请求上下文内,强调快速失败与回滚;异步补偿任务发生在分钟到小时级窗口,强调批处理、限流与审计。两者不要混用同一套重试策略。

案例化走读:支付回调迟到(与支付相关章节呼应):用户支付成功,渠道回调因网络抖动晚到 10 分钟。期间订单可能停留在「待支付」,客服系统可能提示用户重复支付。补偿任务不应「直接改状态为成功」了事,而应执行一条可审计的状态迁移:WAIT_PAYPAID,并触发 履约消息、发票消息、积分入账 等下游;每一步仍要带幂等键,避免回调重复造成重复履约。

案例化走读:库存 Redis 与 MySQL 漂移(与库存侧实践呼应):热路径扣减在 Redis,权威在 MySQL。对账发现 Redis 小于 MySQL 的可用量,可能意味着回补丢失;反过来可能意味着 Redis 多扣。修复策略应区分「业务可自动纠正」与「需要冻结 SKU 人工介入」。自动纠正必须带 上限来源证据(流水号、操作者、任务批次),否则会把数据修复变成新的数据破坏源。


集成模式总结

同步调用模式
  • 适用:强实时、需要立即失败反馈(试算、库存预占校验)。
  • 要点:超时、重试、熔断、幂等键向后兼容的 API 版本

常见反模式:把十几个同步调用串成「上帝编排」,任何一个下游抖动都会放大尾延迟;没有 bulkhead(舱壁) 时,还会出现「支付抖动拖垮创单」的级联故障。治理手段包括:并发化可并行步骤硬超时 + 部分降级把非关键校验挪到异步

接口演进:同步集成最怕破坏性变更。建议强制 version 字段或 URL 版本,并在网关层做 灰度路由;同时给客户端明确的 错误码字典(业务拒绝 vs 基础设施失败),否则重试风暴不可避免。

异步消息模式
  • 适用:解耦峰值、跨团队广播事实、最终一致投影。
  • 要点:Outbox、消费者幂等严格有序 vs 并行的权衡、死信队列。

典型反模式:业务先写库再「顺手发 Kafka」,崩溃窗口会导致消息丢失;或消费者不做幂等,靠「应该不会重复」的侥幸心理。另一个反模式是 把异步当同步用:通过轮询消息结果阻塞用户请求,这会把消息系统的延迟特性原封不动搬进关键路径。

观测性:异步链路必须能回答三个问题:消息发出去了吗、消息被处理了吗、处理正确吗。分别对应 Outbox 状态、消费者 lag、对账差异。

数据同步模式
  • CDC / Binlog 订阅:近实时同步到数仓或搜索;关注 schema 变更治理。
  • 定时批量:对账、报表、冷数据归档。
  • 双写:高风险,仅在迁移窗口短期使用,需校验任务护航。

CDC 的边界:它擅长复制「事实行变更」,但不自动复制「业务含义」。例如拆表、改主键、把枚举从字符串改成数字,都会让下游投影误读。需要 契约变更流程双读双写过渡期

定时批量的价值:很多一致性不是实时问题,而是「日终必须平」。批量任务的关键是 可重跑、可分段、可限流,并在大促日提前做 容量演练

选型决策树
flowchart TD
  Q1{需要立即知道\n下游成功与否?}
  Q1 -->|是| Q2{失败是否必须\n阻断用户?}
  Q1 -->|否| M[异步消息 +\nOutbox / 消费者幂等]

  Q2 -->|是| S[同步 RPC\n+ 超时 / 熔断 / 幂等键]
  Q2 -->|否| Q3{是否可以接受\n秒级最终一致?}
  Q3 -->|是| M
  Q3 -->|否| S

  S --> Q4{是否广播给\n多个订阅方?}
  Q4 -->|是| HY[同步拿到关键凭证\n+ 异步事件分发读模型]
  Q4 -->|否| S

  M --> Q5{是否需要强审计\n可回放?}
  Q5 -->|是| ES[评估事件溯源 /\n不可变日志]
  Q5 -->|否| P[物化视图 +\n对账修复]

  style S fill:#e3f2fd
  style M fill:#e8f5e9
  style HY fill:#fff3e0
  style ES fill:#f3e5f5
  style P fill:#eceff1

如何使用决策树(避免误用):决策树的每个叶子都不是「唯一正确答案」,而是默认起点。真实系统往往处在叶子之间的灰区:例如创单需要同步拿到 price_snapshot_id,但搜索索引更新可以异步。灰区的处理原则是:把「用户当下要看到的结果」留在同步路径,把「世界最终会知道的结果」放到异步路径,并用对账兜底。

与实时性相关的常见误判:团队容易把「运营后台要立即看到」误认为「用户主链路必须同步」。后台可采用 近实时 CDC + 物化视图,而用户侧主链路仍应保持最小同步半径。把后台需求塞进核心交易链路,是尾延迟与大促故障的高频来源。

与后续章节的关系(阅读地图)

  • 商品、搜索、推荐:AP + 异步投影为主,强调延迟可观测(第 7、12 章)。
  • 库存、营销:同步预占 / 锁定 + 异步对账(第 8、9 章)。
  • 计价、购物车:读路径可弱一致;写路径谨慎用缓存(第 11、13 章)。
  • 订单、支付:幂等 + 对账 + 补偿任务三位一体的资金安全网(第 14、15 章)。

本章小结

本章建立了系统集成的方法论「四件套」:

  1. CAP 与一致性谱系帮助你在分区现实下做可解释的取舍,而不是用「都要」掩盖矛盾。
  2. Saga(编排优先)给出跨服务长流程的工程主路径,补偿必须可逆且幂等
  3. 事件驱动 + Outbox 把「写库再发消息」变成可验证的本地事务,为 CQRS 投影与搜索增量提供底座。
  4. 幂等与对账 是分布式世界的安全带:前者防重复,后者治漂移;补偿任务负责把系统从边角态拉回主航道。

落地检查清单(建议你复制到评审模板)

  • 每个跨服务写链路是否写明 一致性级别(用户可见 / 财务 / 读模型)?
  • 是否存在「先外部成功、后本地提交」的窗口?若有,是否有 query/reconcile
  • 关键接口是否具备 幂等键冲突返回语义
  • 是否避免「双写」作为长期方案?若必须双写,是否有 校验任务
  • 事件是否走 Outbox?Relay 是否有 堆积告警
  • 是否定义 对账批次差错分级?自动修复是否有 阈值与开关
  • Saga / 异步任务是否 可重入?是否能在发布滚动中恢复?

给团队负责人的一句话建议:把「集成复杂度」当作与「业务复杂度」并列的成本项。没有 Outbox、没有对账、没有幂等键的系统也能上线,但它会把成本推迟到 大促夜、监管审计、渠道切流 这些最难的时刻一次性兑现。本章的目的,是把这部分成本前移为 可评审、可测试、可监控 的工程资产。

给一线开发者的一句话建议:写跨服务调用时,默认网络会超时、消息会重复、回调会迟到;把这三条写进单元测试与集成测试的假设里,比写一百行防御性注释更有用。

带着这套语言进入后续可靠性章节和电商实战篇,你会更容易判断:此处该同步还是异步、事件应不应该广播、失败该当场回滚还是记账异步修。


3.5 架构质量保障

为什么需要分阶段评审

软件工程里有一句常被引用的话:好的代码是重构出来的,不是一次写出来的。初稿几乎必然欠打磨,真正可靠的质量来自持续、有纪律的迭代。Code Review 把这种迭代前移到合并之前——它把个人习惯拉平到团队标准,把隐性知识显性化,把缺陷拦截在扩散之前。

然而,「随便看看」式的评审往往流于表面:有人只看风格,有人只看有没有明显 bug,有人被 diff 的噪声淹没。结果是:架构层面的失误晚到无法廉价修正,设计层面的模糊在代码里被放大成技术债,上线前才发现性能或可观测性缺口。

单次 PR 评审的认知陷阱
陷阱典型表现后果
问题域混杂在讨论 SQL 索引时顺便「拍板」限界上下文决策缺少干系人与记录,后续反复
噪声淹没信号2000 行 MR 里找架构问题高风险项被 style nitpick 挤出注意力
缺少外部脚手架依赖评审者当天状态遗漏与团队经验强相关,不可复制

Checklist 的价值在于降低认知负荷:在疲劳、时间压力或上下文切换时,仍有一个外部脚手架防止遗漏。它并不替代经验与判断力——遇到清单未覆盖的灰区,恰恰说明团队应该把新教训反哺进清单或 ADR(Architecture Decision Record)。

四阶段评审:在正确时机问正确问题

本书建议按四个阶段组织评审,而不是在单次 PR 里眉毛胡子一把抓:

  1. 架构评审:新项目、新服务、新子域或大规模模块拆分——确认分层、边界、读写路径与技术选型。
  2. 设计评审:接口与模型冻结前——核对聚合、命令 / 查询、领域事件与模式选型是否与领域一致。
  3. 代码评审:日常 MR——用 SOLID、函数质量、命名、错误处理与依赖方向守住实现细节。
  4. 上线前检查:发布窗口——补齐性能、并发、可观测性、测试、回滚与文档。
flowchart LR
  A[架构评审<br/>设计期] --> B[设计评审<br/>详设期]
  B --> C[代码评审<br/>MR 期]
  C --> D[上线前检查<br/>合并期]
  D --> E[发布 / 观测 / 复盘]
运作建议:让清单「活」起来
  • 责任人明确:架构项由 Tech Lead / 架构负责人主评;设计项由领域 Owner 主评;PR 项由作者与至少一名熟悉该域的审阅者共担;上线前项与 SRE / On-call 对齐。
  • 粒度分层:巨型 MR 可先要求作者附「自审清单」勾选说明,再在评论里对争议点逐条引用章节编号,避免无结构的「感觉不对」。
  • 与工具链结合:复杂度、静态检查、依赖图、覆盖率门槛作为门禁;清单作为人工语义层补充(例如:覆盖率够了但测的是 happy path,仍需人眼过业务不变量)。
  • 可追溯结论:架构与设计阶段的结论落在 ADR、RFC 或设计文档;Code Review 只核对「实现是否背离结论」。PR 中发现架构级问题应上升到设计讨论,而不是在局部 hack 里修掉症状。
团队实践案例

案例 1:架构评审会的标准流程(某电商团队实践)

时机:新服务立项、重大重构(影响 3+ 服务)、技术选型变更

参与者

  • 必需:Tech Lead、系统负责人、相关团队代表
  • 可选:SRE(高可用关注)、DBA(存储关注)、安全(合规关注)

流程(60 分钟):

  1. 背景介绍(5分钟):系统负责人讲解业务背景、问题域、核心挑战
  2. 架构方案宣讲(15分钟):分层、限界上下文、读写路径、技术选型
  3. 质疑与讨论(30分钟):按 7.2 清单逐项检查,重点追问:
    • 依赖方向是否违反?
    • 边界划分是否合理?(参考本章战略设计)
    • 读写比假设是否量化?
    • YAGNI 检查:是否过度设计?
  4. 决策与记录(10分钟)
    • 通过 / 有条件通过 / 回炉重做
    • 记录到 ADR(Architecture Decision Record)
    • 指定 Follow-up 责任人

输出示例(ADR-015):

#### ADR-015: 订单域引入 CQRS

##### 状态
已批准(2026-04-15)

##### 背景
订单查询(订单列表、详情、搜索)QPS 是写入的 50 倍;
当前写模型(含 JOIN)拖慢查询性能。

##### 决策
引入 CQRS,读模型使用物化视图(MySQL)+ ES 索引。

##### 方案
- 写模型:订单聚合 + Outbox 事件
- 读模型:订阅 OrderPlaced / OrderPaid 事件,更新 order_view 表与 ES
- 一致性:最终一致(可容忍 1-2 秒延迟)

##### 风险与对策
- 风险:读写数据不一致
- 对策:对账任务(每小时),差异告警

##### 评审结论
通过,需在 Q2 上线前完成性能压测。

案例 2:设计评审中发现聚合边界过大

背景:订单团队在设计评审时提交了一个 Order 聚合,包含订单基础信息、明细、支付记录、履约记录、售后记录。

评审意见

  • 问题:聚合过大,任何字段变更都需要加载整个对象,性能差
  • 追问:「支付记录」是否需要和订单在同一事务中修改?
  • 结论:不需要——支付成功是外部事件触发,可以通过事件异步更新

重构方案

  • Order 聚合:订单基础信息 + 明细(需要强一致性)
  • Payment 聚合:支付记录(独立生命周期)
  • Fulfillment 聚合:履约记录(独立生命周期)
  • 集成:通过领域事件(OrderPlacedOrderPaid)衔接

收益:订单聚合从平均 2KB 缩小到 500 字节,查询性能提升 4 倍。


案例 3:PR 评审中拦截的架构违规

背景:开发者在 HTTP Handler 中直接写 SQL,绕过了应用层。

评审意见

// ❌ 违反依赖方向
func HandleCreateOrder(w http.ResponseWriter, r *http.Request) {
    db := mysql.Default() // Handler 直接依赖基础设施
    _, _ = db.ExecContext(r.Context(), "INSERT INTO orders ...")
}

处理流程

  1. 识别问题:违反 7.2.1 依赖方向检查
  2. 上升讨论:在 PR 中标记 needs-architecture-review
  3. 解决方案
    • 定义应用层用例:PlaceOrderUseCase
    • 定义领域层端口:OrderRepository
    • Handler 只依赖应用层
  4. 后续:将此类问题补充到团队的「评审反模式」文档

经验:当 PR 中出现架构级违规时,不要在代码层面修修补补,而是叫停并重新设计。短期看延迟了交付,长期避免了技术债累积。

何时进入哪一阶段:决策树
flowchart TD
  start([变更进入评审]) --> q1{是否改变系统边界<br/>或核心数据流?}
  q1 -->|是| arch[必须先过架构评审<br/>必要时更新 ADR]
  q1 -->|否| q2{是否改变聚合 / 事件契约<br/>或对外 API 语义?}
  q2 -->|是| design[设计评审 + 契约评审]
  q2 -->|否| code[代码评审 MR]
  arch --> design
  design --> code
  code --> q3{是否进入发布窗口<br/>或影响关键路径 SLO?}
  q3 -->|是| ship[上线前检查]
  q3 -->|否| merge[合并后持续观测]
  ship --> merge

架构评审阶段

适用时机:立项、新服务、新子域或大规模模块拆分。目标是在写大量代码之前,把分层、边界、一致性、读写特征与技术选型对齐。

分层结构检查

标准:是否明确定义 Domain / Application / Adapter / Infrastructure(或等价四层)?源代码依赖是否一律指向内层(Domain 为最内),外层通过接口向内依赖?

反例(违反依赖方向):HTTP Handler 直接 import 具体 MySQL 驱动或 ORM 包,绕过应用服务与领域端口。

// BAD: handler depends on concrete DB package
import "github.com/org/repo/infra/mysql"

func HandlePlaceOrder(w http.ResponseWriter, r *http.Request) {
    db := mysql.Default()
    _, _ = db.ExecContext(r.Context(), "INSERT INTO orders ...")
}

合规方向:Handler 只依赖应用层用例;持久化通过 Repository 接口在领域或应用边界声明,由 Infra 实现。

// GOOD: handler -> application port -> domain; infra implements port
type PlaceOrderHandler struct {
    App *application.OrderService
}

func (h *PlaceOrderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    cmd, err := decodePlaceOrder(r)
    if err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
    }
    if err := h.App.PlaceOrder(r.Context(), cmd); err != nil {
        http.Error(w, err.Error(), http.StatusConflict)
        return
    }
    w.WriteHeader(http.StatusCreated)
}
检查点通过标准常见反模式
依赖方向domain 不引用 adapter / infraHandler 内写 SQL
端口归属Repository 接口由内层拥有接口定义在 infradomain 引用
组装根main / cmd 完成绑定在领域 New* 里创建具体 DB

评审追问:若团队暂时未引入完整四层,是否至少在包级约定 adapter 不得被 domain import,并在 CI 用 grep / 自定义 linter 守护?

限界上下文验证

标准:是否识别 核心域、支撑域、通用域?每个 BC 是否有清晰的 Ubiquitous Language 与对外契约(API / 事件),避免「一个大而全的领域模型」?

反例:订单子域与库存子域共用同一个 Product 结构体,字段含义在两边互相拉扯。

// BAD: one struct serves two contexts with conflicting meanings
type Product struct {
    ID           string
    Title        string
    PriceCent    int64 // pricing in order context
    WarehouseQty int   // stock in inventory context — coupling contexts
}

合规 sketch:不同 BC 使用不同模型与防腐层翻译;集成通过 API、消息或显式 ACL。

// GOOD: separate models + explicit mapping at boundary
type catalog.ProductView struct{ ID, Title string }

type ordering.OrderLine struct {
    ProductID     string
    UnitPrice     Money
    SnapshotTitle string
}

type inventory.StockUnit struct {
    SKU    string
    OnHand int
}
检查点通过标准评审问题
模型隔离各 BC 有独立类型与映射层是否共享「富模型」而非仅 ID?
契约稳定对外 API / 事件有版本与兼容性策略破坏性变更如何灰度?
语言一致docs/glossary.md 或等价物CustomerUser 是否混用?
读写路径分析

标准:是否量化 读写比、延迟与一致性要求?读路径若存在重 JOIN、宽表、复杂筛选,是否考虑 独立读模型 / 投影,而不是全部堆在写模型上?

反例:在命令路径(下单)同步执行多表 JOIN 报表查询,拖慢写入尾延迟。

// BAD: command handler does heavy read for side UI
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) error {
    _ = s.db.QueryRowContext(ctx, `SELECT ... heavy join for dashboard ...`)
    return s.persistOrder(ctx, cmd)
}
// GOOD: split; async projection or query DB
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) error {
    if err := s.orders.Save(ctx, newOrderFrom(cmd)); err != nil {
        return err
    }
    return s.outbox.Publish(ctx, OrderPlaced{OrderID: cmd.IdempotencyKey})
}
检查点通过标准
写路径只持久化命令所需最小一致性数据
读路径物化视图、搜索索引或专用查询服务
指标是否测量 p99 写延迟读 QPS

评审追问:若读是写的两个数量级以上,独立读模型往往是经济解(与第 1 章 CQRS 呼应)。

技术选型审查

标准:存储与中间件是否与 访问模式 匹配(点查、范围扫、全文检索、图关系、流处理)?是否记录选型假设与回退方案?

维度评审问题
数据量与热点预估行数、分区键、热点键
一致性强一致 / 最终一致是否与业务容忍度一致
运维成本备份、多 AZ、升级窗口
合规留存周期、脱敏、跨地域

反例:全文搜索需求用 MySQL LIKE '%keyword%' 扛流量,缺少倒排索引与相关性能力。

过度设计与 YAGNI(纳入技术选型同一关口)

标准:是否仅为已确认的变更点引入抽象?能否用更简单的模型先交付,再演化?

反例:典型 CRUD 后台强行上 DDD + CQRS + Event Sourcing 全家桶,团队无力维护投影与版本化事件。

评审追问:若去掉 Event Sourcing,业务是否仍成立?若答案是肯定的,则 ES 很可能是可选优化而非当前必需。CQRS 是否由观测到的读写不对称驱动,而不是由「流行架构标签」驱动?


设计评审阶段

适用时机:接口评审、领域模型评审、用例与事件清单冻结前。目标是让 战术设计(聚合、Repo、Command / Query、事件)与战略分层一致。

聚合边界检查

标准一致性边界是否以聚合为单位设计?是否避免在单个事务中强行修改多个聚合根,除非有显式的领域规则与补偿策略?

反例:一个数据库事务内同时更新 OrderInventory 聚合,绕过领域事件与最终一致性。

// BAD: one transaction mutates two aggregates directly
func SaveOrderAndDeductStock(ctx context.Context, tx *sql.Tx, o *Order, inv *Inventory) error {
    if err := persistOrder(tx, o); err != nil {
        return err
    }
    inv.Quantity -= o.LineItems[0].Qty
    return persistInventory(tx, inv)
}
检查点通过标准
聚合根入口外部只能通过根修改状态
一事务一根跨聚合协作走事件 + 最终一致(或已文档化的 Saga)
暴露集合不返回可变内部 slice 引用

聚合根识别补充:外部代码禁止绕过根直接改内部实体(如导出 []*OrderLine 被外部改 Qty)。若根方法数量爆炸,区分是聚合过大还是缺少领域服务

实体与值对象(本小节一并核对):实体有稳定标识、状态变更走受控方法;值对象不可变、按值相等;Money 等禁止提供可变 setter,对外构造函数保证合法组合。

命令查询分离验证
Command 设计

标准:命令是否表达 业务意图PlaceOrderCancelSubscription),而不是贫血 CRUD(UpdateOrder + 任意 map)?

// BAD: command is just a data bag
type UpdateOrderCommand struct {
    OrderID string
    Patch   map[string]any
}
// GOOD: explicit intent
type PlaceOrderCommand struct {
    CustomerID     string
    Items          []OrderItemDTO
    IdempotencyKey string
}
检查点通过标准
语义动词 + 业务名词,可映射到用例
幂等携带幂等键 / 乐观锁(如需要)
失败语义可映射为明确业务结果,而非一律 500

Repository(与命令 / 查询配套检查):接口定义在领域层;方法名表达业务需要(FindActiveByCustomer)而非表驱动;复杂筛选优先归入 Query 侧,避免 Repository 万能方法膨胀。

Query 设计

标准:查询是否直接返回 DTO / 读模型不强行加载完整领域图?是否避免在查询路径上触发写模型副作用?

// BAD: query returns rich aggregate for read-only UI
func (s *QueryService) OrderForUI(ctx context.Context, id string) (*domain.Order, error) {
    return s.orders.LoadFullGraph(ctx, id)
}
// GOOD: dedicated read DTO
type OrderSummaryDTO struct {
    OrderID   string
    Status    string
    TotalCent int64
    PlacedAt  time.Time
}
领域事件设计

标准:关键业务状态变更是否发布 领域事件?命名是否使用 过去式OrderPlacedPaymentCaptured)并携带必要上下文(版本、发生时间)?

// BAD: imperative name
type PlaceOrder struct{ OrderID string }

// GOOD: past tense, domain vocabulary
type OrderPlaced struct {
    OrderID    string
    OccurredAt time.Time
    Version    int
}
检查点通过标准
命名过去式 + 领域词汇
载荷消费者演进所需字段(版本、关联 ID)
投递Outbox / 至少一次 + 消费者幂等
模式选型审查

详设阶段可快速对照下表,避免「每个地方都 if-else」或「每个地方都上框架」。

场景特征推荐模式说明
多步骤顺序流程Pipeline(管道)与第 2 章 Pipeline 呼应
同一接口多种实现策略模式扩展点清晰
频繁变化的业务规则规则引擎 / 规则表驱动需版本化与评审
跨聚合协作领域事件 + Outbox与第 1 章 Outbox 呼应

反例:全系统统一 RuleEngine.Execute(ctx, ruleSetID, facts),但规则集无人版本化与评审,线上等于「可执行的配置漂移」。

合规:规则变更走 PR + 审计 + 影子流量;核心不变量仍保留在代码与单测中,引擎只编排可变的参数化策略


代码评审阶段

适用时机:每次合并请求。把设计约束落到 Go 代码的可观察性质上。

SOLID 原则检查

对每一项,用「一句检查问句」把握核心;争议点再用第 1 章分层与端口对齐。

原则检查问句典型反例合规方向
S该类型是否只有一个变化理由?OrderService 又发邮件又导 CSV按职责拆服务
O扩展新行为是否无需改稳定路径?switch payment 无限增长PaymentGateway 接口 + 多实现
L实现是否可替换且不 surprise?Charge 静默成功显式 Fake / 诚实错误
I客户端是否不被迫依赖不需要的方法?Storage 胖接口Reader / Writer 隔离
D高层是否依赖抽象?NewAppsql.Open构造注入 Repository

DIP 延伸——包级依赖方向domain 不 import adapter / infraapplication 不直接引用 HTTP、ORM、消息 SDK;无循环依赖(必要时提取 domain/sharedkernel 最小类型)。go list -deps 或 IDE 依赖图可抽查。

LSP 反例 sketch

// BAD: implementation surprises caller
type NoOpPaymentGateway struct{}
func (NoOpPaymentGateway) Charge(ctx context.Context, amount int64) error {
    return nil // silently skips payment
}
函数质量审查
维度阈值 / 标准工具或手段
长度单函数宜 < 80 行拆私有步骤或 Pipeline 阶段
圈复杂度< 10(团队可校准)golangci-lint / gocyclo
嵌套深度< 3Guard clause 早返回
参数个数< 5Options 结构体或 functional options
gocyclo -over 10 ./...
// GOOD: named steps keep orchestration readable
func (h *PlaceOrderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    if err := h.ensureAuth(ctx, r); err != nil {
        h.writeErr(w, err)
        return
    }
    cmd, err := h.decode(r)
    if err != nil {
        h.writeErr(w, err)
        return
    }
    if err := h.app.PlaceOrder(ctx, cmd); err != nil {
        h.writeErr(w, err)
        return
    }
    w.WriteHeader(http.StatusCreated)
}

评审追问context.Context 是否作为 第一个参数 传递 I/O 边界函数,而不是塞进结构体字段隐式携带?

命名与可读性
  1. 变量 / 函数名反映业务术语:名称来自 Ubiquitous Language,而非数据库列名机械翻译。
  2. 团队内一致:同一概念只有一个词(Customer vs User 要治理)。
  3. 避免技术术语代替业务术语:不用 SetStatus(1),而用 MarkShipped()
// BAD: magic status
func (o *Order) SetStatus(s int) { o.status = s }

// GOOD: business verb
func (o *Order) MarkShipped(at time.Time) error {
    if o.status != StatusPaid {
        return ErrInvalidStateTransition
    }
    o.status = StatusShipped
    o.shippedAt = at
    return nil
}
错误处理验证
  1. 禁止静默忽略错误:是否存在 _ = xxx 或空白 if err != nil { }
  2. 错误 wrap 携带上下文:跨层 fmt.Errorf("place order: %w", err)
  3. 区分业务错误与系统错误:调用方能否区分「库存不足」与「应重试的基础设施错误」?
var ErrOutOfStock = errors.New("out of stock")

func (s *InventoryService) Reserve(ctx context.Context, sku string, qty int) error {
    if qty > available(sku) {
        return fmt.Errorf("reserve %s: %w", sku, ErrOutOfStock)
    }
    return nil
}
DDD 战术与聚合不变量(与错误语义一并核对)

聚合不变量 sketch

func (o *Order) AddLine(sku string, qty int, unitCent int64) error {
    if qty <= 0 {
        return ErrInvalidQty
    }
    if o.status != StatusDraft {
        return ErrOrderNotEditable
    }
    lineTotal := unitCent * int64(qty)
    if lineTotal < 0 {
        return ErrOverflow
    }
    o.lines = append(o.lines, OrderLine{SKU: sku, Qty: qty, UnitCent: unitCent})
    o.totalCent += lineTotal
    return nil
}

上线前检查

适用时机:发布分支、灰度前、重大重构合并前。与功能完成度无关的「生产就绪」项在此收敛。

性能与并发

性能

  • 关键路径是否有 benchmark 或压测基线?
  • 是否关注 alloc/op、GC 停顿、锁竞争(mutex profile)?
  • 异步路径是否避免无界队列导致内存膨胀?
func BenchmarkPlaceOrder(b *testing.B) {
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        // exercise hot path
    }
}

并发安全

// BAD: unsynchronized map writes
var cache = map[string]int{}
func Set(k string, v int) { go func() { cache[k] = v }() }

// GOOD: mutex or single-owner goroutine
type SafeCache struct {
    mu sync.RWMutex
    m  map[string]int
}
检查点通过标准
数据竞争go test -race 纳入 CI 或发布前门禁
泄漏长测采样 NumGoroutine;channel 不阻塞在默认分支
锁内 I/O避免在持锁时调用慢外部依赖
可观测性

标准metrics(RED / USE)、trace(关键 span)、结构化日志request_idorder_id 等关联字段)。

logger.Info("order_placed",
    "order_id", orderID,
    "customer_id", customerID,
    "duration_ms", elapsed.Milliseconds(),
)
检查点通过标准
日志键值字段可查询,而非仅拼接长句
链路跨服务传播 trace 上下文
SLO新路径有指标与告警阈值
测试覆盖

标准:核心业务规则覆盖率按团队约定(例如 > 80%);集成测试覆盖仓储、消息、外部 HTTP 的 fake / 容器。

检查点通过标准
边界表格驱动覆盖错误路径
Flaky修复或隔离,避免 t.Skip 永久化
语义覆盖不变量,而非仅「能跑通」
func TestPlaceOrder_OutOfStock(t *testing.T) {
    t.Parallel()
    // arrange: 0 stock -> expect ErrOutOfStock
}
回滚方案

标准feature flag 或配置开关;数据库迁移可回滚或具备向前兼容的双写 / 双读;事件 schema 向后兼容或双写新字段。

回滚方案检查清单
维度检查项通过标准
代码回滚Feature Flag关键功能可通过配置开关禁用,无需重新发布
数据库迁移双向脚本UP/DOWN 脚本齐全,测试过回滚流程
事件 Schema向后兼容新增字段可选,旧消费者不受影响
API 兼容性版本策略新版本 API 与旧版本共存,客户端可选升级
配置变更灰度发布配置分批推送,每批观察指标后再继续
依赖服务降级预案下游服务故障时,上游可降级(返回默认值/缓存)
Feature Flag 实践
// 使用 Feature Flag 控制新功能
package order

import "context"

type FeatureFlags interface {
    IsEnabled(ctx context.Context, feature string) bool
}

func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) error {
    // 旧逻辑
    if err := s.validateBasic(cmd); err != nil {
        return err
    }
    
    // 新功能:风控检查(可通过 Feature Flag 关闭)
    if s.flags.IsEnabled(ctx, "order.fraud_detection") {
        if err := s.fraudDetector.Check(ctx, cmd); err != nil {
            return err
        }
    }
    
    return s.repo.Save(ctx, newOrderFrom(cmd))
}

收益

  • 新功能上线后发现问题,可立即关闭 Feature Flag,无需回滚代码
  • 灰度发布:先对 5% 用户开启,观察指标后再逐步放量
  • A/B 测试:对不同用户群开启不同策略,对比效果

文档与运维:架构变更(新 BC、事件契约、SLA)同步到 README / ADR / 运维手册;On-call 知道降级、重放消息、解读关键告警;新人能仅凭文档拉起本地依赖(docker-compose / make 目标)。

运维文档模板

#### 服务运维手册

##### 关键告警
- `order_create_latency_p99 > 500ms`:订单创建延迟过高
  - **可能原因**:数据库慢查询、库存服务超时
  - **处理步骤**:
    1. 查看 Grafana 面板确认瓶颈(DB/库存/计价)
    2. 若库存服务超时,执行降级:`kubectl set env deployment/order INVENTORY_FALLBACK=true`
    3. 通知库存团队排查

##### 降级开关
- `INVENTORY_FALLBACK=true`:库存查询降级,使用本地缓存
- `FRAUD_DETECTION=false`:关闭风控检查(紧急情况)
- `PROMOTION_ENABLED=false`:关闭营销试算(性能问题)

##### 回滚流程
1. 确认回滚目标版本:`kubectl rollout history deployment/order`
2. 执行回滚:`kubectl rollout undo deployment/order --to-revision=N`
3. 观察监控:关注错误率、延迟、上下游调用
4. 数据库回滚(如需要):执行 DOWN 脚本

本章小结

全阶段总览表(评审清单)
阶段必查项(高杠杆)
架构评审依赖向内、BC 划分、聚合边界、读写评估、YAGNI
设计评审聚合根入口、值对象不可变、Repo 在领域层、Command 意图、领域事件
代码评审SRP、函数规模与复杂度、业务命名、错误 wrap、依赖方向
上线前Benchmark / 压测证据、并发与 race、可观测性、测试与集成、回滚与文档
MR 描述区模板(可复制)
#### Self review (author)
- [ ] 7.4 SOLID: 新类型职责与扩展点合理
- [ ] 7.4 函数长度 / 复杂度 / 嵌套 / 参数个数
- [ ] 7.4 命名与 glossary 一致
- [ ] 7.4 错误 wrap,无静默 `_ = err`
- [ ] 7.4 依赖方向与 DDD 战术(不变量、VO)

#### Release readiness (if applicable)
- [ ] 7.5 Benchmark 或压测链接
- [ ] 7.5 并发 / race 检查
- [ ] 7.5 Metrics + logs + traces
- [ ] 7.5 核心规则测试与集成测试
- [ ] 7.5 回滚 / 迁移 / 双写方案
- [ ] 7.5 文档 / ADR 更新

#### Design links
- ADR / RFC: ...
实战案例与反模式
案例 A:库存预占接口「顺手」改了聚合边界(设计评审失效)

背景:结算服务在「创单前预占」需求中,直接在订单聚合的事务内更新库存行,图省事。

症状:大促锁竞争升高;库存与订单发布节奏耦合,回滚困难。

处理:设计评审阶段强制改为 OrderPlaced / ReserveStockRequested 事件驱动或显式 Saga;代码评审拦截「双聚合同一事务」。

案例 B:营销规则 JSON 线上漂移(模式选型 + 运维失守)

背景:规则引擎读取未版本化的 JSON,运营后台可直接保存到生产。

症状:线上行为与测试环境不一致,难以复盘。

处理:规则集 版本号 + PR 审核 + 审计日志;核心不变量仍在单测与代码中;影子流量验证。

案例 C:Handler 直连 DB(架构评审后置到 PR)

背景:原型代码直接进入主干,后续 MR 只在 SQL 层修修补补。

症状:领域规则散落在 SQL;单测必须起库。

处理:上升架构评审,引入 端口 + 用例;本 MR 仅允许「垂直切片」式重构到合规结构,不接受继续堆 SQL。


案例 D:缺少性能测试导致的线上故障

背景:订单服务上线了「批量取消」功能,代码评审通过,但未做性能测试。

线上故障

  • 运营同学一次性取消 5000 个订单
  • 服务在循环中逐个发送取消事件到 Kafka,耗时 30 秒
  • 期间所有订单查询请求超时(共享同一个 goroutine 池)
  • 用户投诉量激增

根因分析

  • 代码评审通过:功能逻辑正确,无明显bug
  • 缺失上线前检查:没有性能测试,没有评估「批量场景下的资源占用」

改进方案

  1. 补充 7.5.1 性能检查:批量操作必须有 Benchmark
  2. 异步化:批量取消改为后台任务,分批处理(每批 100 个)
  3. 限流:批量接口加频控,防止运营误操作

经验:代码评审通过≠生产就绪。上线前检查(7.5)是最后一道防线,必须覆盖性能、并发、可观测性。


案例 E:聚合不变量在 PR 中被破坏

背景:订单聚合有不变量「总价 = 各明细之和」,某次 PR 为了修复 bug,直接修改了 TotalAmount 字段。

代码变更

// BAD: 直接修改总价,破坏不变量
func (o *Order) ApplyDiscount(amount int64) {
    o.TotalAmount -= amount // ❌ 绕过了明细,破坏一致性
}

后果

  • 订单详情页显示的小计与总价不一致
  • 财务对账时发现差异,追溯到这次变更

评审反思

  • 设计评审阶段应明确聚合不变量(7.3.1)
  • 代码评审阶段应检查是否有直接修改聚合字段的行为
  • 测试应覆盖不变量(如 assert(order.Total == sum(order.Lines))

正确方案

// GOOD: 通过明细修改,自动更新总价
func (o *Order) ApplyDiscountToLine(lineIndex int, discountAmount int64) error {
    if lineIndex >= len(o.Lines) {
        return ErrInvalidLineIndex
    }
    o.Lines[lineIndex].UnitPrice -= discountAmount
    o.recalculateTotal() // 重新计算总价,保证不变量
    return nil
}

func (o *Order) recalculateTotal() {
    total := int64(0)
    for _, line := range o.Lines {
        total += line.UnitPrice * int64(line.Qty)
    }
    o.TotalAmount = total
}

案例 F:缺少回滚方案的数据库迁移

背景:库存服务需要新增字段 reserved_qty,开发者提交了 PR 包含数据库迁移脚本。

问题

  • 只有 UP 脚本,没有 DOWN 脚本(无法回滚)
  • 没有双写策略:新代码直接依赖新字段,回滚时会报错

线上故障

  • 新版本上线后发现性能问题,需要回滚
  • 回滚代码后,服务启动失败(读取不存在的字段)
  • 被迫紧急修复:手动删除字段、重新上线旧版本

改进方案(7.5.4 回滚方案):

  1. 三阶段迁移
    • 阶段 1:加字段,代码双写(写新旧两个字段),读旧字段
    • 阶段 2:代码切换为读新字段
    • 阶段 3:删除旧字段
  2. 每阶段可独立回滚:任何一步出问题都能回到上一阶段
  3. UP/DOWN 脚本齐全:迁移工具(如 migrate)强制要求两个方向

评审清单补充

  • 数据库迁移是否有 DOWN 脚本?
  • 新字段是否通过双写 / 双读策略引入?
  • 回滚后服务是否仍能正常启动?
按角色的最小阅读路径
角色建议优先阅读
作者(提 MR)7.4 全文 + 7.6.2 模板
审阅者(同域)7.4.3–7.4.5 + 与 7.3 冲突点
Tech Lead(新模块)7.2、7.3 + 7.5
SRE / On-call7.5 + 事件与迁移说明
核心要点

系统化的 Code Review 不是挑剔,而是把重构前移到成本最低的阶段。按 架构 → 设计 → 代码 → 上线前 四段清单推进,并与前文的方法论、战略设计、内部结构设计交叉引用,团队可以在一致语言下讨论分层、边界与实现细节。建议将 7.6.1 嵌入 MR 模板,并在复盘时根据失效案例增补第 21 条——最好的 Checklist 永远是活文档。


3.6 容量规划、压测、限流、熔断与降级

容量规划从业务量开始

容量估算不要从机器配置开始,而要从业务量开始。以电商系统为例,至少要估算:

  • 日活用户、峰值活跃用户和高峰时间分布。
  • 商品浏览、搜索、加购、结算、下单、支付的转化漏斗。
  • 核心表日增量、历史数据保留周期和归档策略。
  • 秒杀、促销、批量导入、供应商同步等非均匀流量。
  • 失败重试、消息积压、补偿任务带来的额外流量。

一个常见误区是只估算正常请求 QPS,却忽略重试流量。下游服务抖动时,如果上游没有超时、退避和熔断,请求量会被重试放大,最终把原本局部故障扩散成全链路故障。

压测验证什么

压测不是为了得到一个漂亮的 QPS 数字,而是为了发现系统的边界。一次有价值的压测至少要回答:

问题观察指标
单服务瓶颈在哪里?CPU、内存、GC、连接池、线程池
数据库是否扛得住?慢 SQL、锁等待、连接数、主从延迟
缓存是否稳定?命中率、热点 Key、内存淘汰、网络延迟
消息系统是否积压?生产速率、消费速率、消费延迟、分区倾斜
降级策略是否生效?错误率、超时率、降级命中率、核心链路成功率

压测结果要沉淀成容量水位线,例如“订单创建链路在当前配置下安全水位为峰值 3000 QPS,超过后库存预占 P99 延迟明显上升”。没有水位线的压测,很难指导后续扩容和限流配置。

限流、熔断与降级

限流、熔断和降级经常被放在一起,但职责不同。

手段触发条件目标
限流入口流量超过系统承载能力防止系统被打穿
熔断下游错误率或延迟持续恶化防止故障扩散
降级非核心能力影响核心链路保住关键业务成功率

在电商场景中,商品详情页可以降级推荐模块,结算页可以降级非关键营销提示,但订单创建、库存预占和支付状态更新不能随意降级为“不处理”。降级设计必须区分核心链路和非核心链路。

容量保护的工程落点

容量保护不能只靠网关。成熟系统通常会在多个层次放保护:

  • 入口层:按用户、IP、商家、活动、API 维度限流。
  • 应用层:线程池隔离、连接池上限、超时和快速失败。
  • 缓存层:热点 Key 保护、本地缓存、请求合并。
  • 数据库层:慢 SQL 治理、读写分离、归档、分库分表。
  • 消息层:消费者限速、批量消费、重试退避、DLQ。
  • 运维层:容量水位告警、自动扩容、压测基线和演练。

这些措施的共同目标不是让系统永远不失败,而是让失败以可预期的方式发生,并优先保护最重要的业务结果。

面试中的表达方式

系统设计面试里谈容量和韧性,建议按“估算、瓶颈、保护、验证”四步表达:

  1. 先估算峰值流量和数据规模。
  2. 再指出最可能的瓶颈,例如数据库写入、库存热点、搜索查询或消息积压。
  3. 然后说明保护措施,包括缓存、异步、限流、熔断、降级和补偿。
  4. 最后补充如何压测、监控、告警和演练。

这样回答比直接说“加 Redis、上 MQ、做限流”更有说服力,因为它说明了每个手段对应的压力来源和失败后果。


3.7 本章小结

核心观点回顾

本章想传达的核心观点其实很简单:

  • 软件架构面对的不是单一问题,而是一组会长期反复出现的复杂性挑战
  • 架构师真正做的,不是“选择某一个流行概念”,而是按问题层次打一套组合拳
  • 这套组合拳通常遵循固定顺序:先做业务边界,再建系统内部秩序,再处理系统间协作,最后通过代码原则和质量机制把设计守住

也就是说,架构设计的本质不是追逐术语,而是控制复杂性如何在系统中传播。

与后续章节的关系

这一章只是全书的总序,负责建立方法论地图。后续章节会沿着这条主线逐步展开:

  • 本章的 3.2《业务边界与战略设计》部分:先讨论系统应该如何划清边界
  • 本章的 3.3《系统内部结构设计》部分:讨论单个系统内部如何建立秩序
  • 本章的 3.4《系统集成与一致性设计》部分:讨论多个系统之间如何协作
  • 本章的 3.5《架构质量保障》部分:讨论如何用评审和验证机制守住架构质量
  • 第 2 章《编码与 Code Review》:讨论架构如何真正落到代码与团队评审

读完这一章之后,你不必已经掌握所有细节,但应该先建立一个判断框架:当系统变复杂时,架构师会从哪些层次出手,又该如何把这些方法论组合起来。

延伸阅读

  • Robert C. Martin, Clean Architecture: A Craftsman’s Guide to Software Structure and Design, 2017
  • Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, 2003
  • Vaughn Vernon, Implementing Domain-Driven Design, 2013
  • Martin Fowler, CQRS Pattern

4. 软件工程绘图完全指南

从系统设计与 TD 方法论继续往前走,架构师还必须掌握另一项核心表达能力:把抽象的边界、链路、状态与决策画成不同角色都能快速理解的图。下面这部分内容整体保留原有写法,只调整标题层级,把它作为第 1 章的第三部分。

从图种基础到架构心法,建立软件工程师的绘图全栈能力。

3.1 软件工程绘图全景

3.1.1 画图是工程师的“第二语言“

写代码是对机器的表达,画图是对人的表达。一个只会写代码但不擅长画图的工程师,面对跨团队协作、技术评审、向上汇报时,沟通成本会指数级上升。

反过来说,一张好图能在 3 分钟内讲清 30 分钟白板都讲不清的事。这不是天赋,是技能——可以学,也必须学。

3.1.2 图的四象限分类体系

软件工程中需要画的图,可以归入四个维度。先建立这个分类框架,后面每学一种图种,都能找到它在框架中的位置:

维度核心问题典型图种
结构图系统“有什么“?模块怎么划分?类图、ER 图、包图、组件图、部署图
行为图系统“怎么动“?数据怎么流转?时序图、活动图、状态图、流程图
交互图“谁调了谁”?依赖方向是什么?组件图、集成图、数据流图
物理图“跑在哪里”?机器怎么连?部署拓扑图、网络拓扑图

简单记忆:结构图看盒子,行为图看时间线,交互图看箭头方向,物理图看机器。

3.1.3 图种误用的三种典型反模式

在进入具体画法之前,先看看最常见的三种“画错图“:

反模式一:用技术架构图向老板汇报。 框图里塞满 Kafka、Redis、Saga、DLQ,老板只想知道“你的系统能支撑什么业务,边界在哪“。结果:老板迷失,方案被否。

反模式二:画时序图不标注异常路径。 大多数时序图只画“正常流程“——A 调 B,B 调 C,返回 OK。真实系统里,超时怎么办?重试几次?降级到哪个兜底逻辑?不标注这些的时序图,等于没画。

反模式三:用一张图解释一切。 试图在一张图里塞进业务架构、技术架构、部署架构、数据流。结果所有人都不满意。一张图只解决一个视角的问题。

3.1.4 场景 → 图种速查表

遇到以下场景,查这张表就知道该画什么:

场景首选图种配合图种
需求澄清 & 用户故事用例图活动图(描述主流程)
业务流程梳理流程图 / 活动图状态图(关键对象)
接口设计 & 调用链时序图
数据模型设计ER 图类图(面向对象项目)
领域建模(DDD)限界上下文图时序图(验证依赖方向)
架构评审(技术)技术治理图 + 核心时序图部署拓扑图
架构评审(业务)全景功能架构图
故障复盘故障传播时序图恢复流程图
新人 Onboarding系统全景图 + 部署拓扑图核心链路时序图
技术方案文档4+1 视图套件

3.2 图种实战手册

每种图按统一模板讲解:一句话定义 → 何时用 → 核心要素 → 画法步骤 → 常见错误 → Mermaid 模板

3.2.1 用例图(Use Case Diagram)

一句话定义:描述“谁“(Actor)能用系统“做什么“(Use Case)。

何时用

  • 项目启动阶段,与产品/业务方对齐功能范围
  • 梳理用户角色及其可操作的功能边界
  • 新人快速理解系统对外提供的能力清单

核心要素

  • Actor(参与者):人形图标,代表用户角色或外部系统
  • Use Case(用例):椭圆,代表一个功能目标
  • 系统边界:矩形框,框住所有用例,表示系统范围
  • 关联线:连接 Actor 和 Use Case

画法步骤

  1. 确定系统边界——画一个矩形框,写上系统名称
  2. 列出所有 Actor(用户角色、外部系统)——画在框外
  3. 对每个 Actor,列出它触发的用例(动词+名词,如“下单““查看订单”)——画在框内
  4. 连线:Actor 到用例

常见错误

  • 把用例拆得太细(“输入用户名”“输入密码”“点击登录“不是用例,应以用户目标为单位)
  • 用例之间用箭头连线形成“流程图“(用例图只表达功能包含关系,不表达先后顺序)
  • 在用例图上标注技术细节(如“调用 Redis 缓存“)

Mermaid 模板

graph LR
  subgraph 电商系统
    UC1((浏览商品))
    UC2((加入购物车))
    UC3((下单))
    UC4((支付))
    UC5((查看订单))
  end
  买家 --- UC1
  买家 --- UC2
  买家 --- UC3
  买家 --- UC4
  买家 --- UC5

3.2.2 流程图 & 活动图(Flowchart & Activity Diagram)

一句话定义:描述一个业务流程或算法中“先做什么、判断什么、再做什么“的执行顺序。

何时用

  • 梳理业务流程(如订单状态流转、退款审批)
  • 描述算法逻辑(分治、回溯、状态机)
  • 对账、补偿、异常处理流程

核心要素

  • 开始/结束节点:圆角矩形或椭圆
  • 处理步骤:矩形
  • 判断节点:菱形(一个入口,至少两个出口,标注判定条件)
  • 流向箭头:连接各节点

画法步骤

  1. 确定起点和终点
  2. 梳理“主执行轴“——正常流程走完的 5-8 个核心节点
  3. 在每个判断节点处,画出分支(是/否,成功/失败)
  4. 补充分支的汇合点(分支最终要去哪)
  5. 对异常分支特别标注(超时、重试、降级)

常见错误

  • 主流程和异常流程混在一起,分不清楚“正常路径“和“兜底路径“
  • 判断节点只画出口不标条件(菱形两条线出去,没写“是/否“)
  • 流程线交叉缠绕(必要时应拆分或使用子流程)

Mermaid 模板

graph TD
  Start([用户提交订单]) --> LockStock[预占库存]
  LockStock --> CheckStock{库存充足?}
  CheckStock -->|是| CreateOrder[创建订单]
  CheckStock -->|否| Fail([返回库存不足])
  CreateOrder --> Payment{支付成功?}
  Payment -->|是| Confirm[确认订单]
  Payment -->|否| TimeoutCheck{超时?}
  TimeoutCheck -->|是| ReleaseStock[释放库存]
  TimeoutCheck -->|否| Payment
  ReleaseStock --> CancelOrder([订单取消])
  Confirm --> End([完成])

3.2.3 时序图(Sequence Diagram)

一句话定义:描述一次请求中,多个参与者(服务、模块、组件)之间按时间顺序发生的消息交互。

何时用

  • 接口设计:描述一次 API 调用涉及哪些服务及调用顺序
  • 技术评审:展示核心链路的数据流转
  • 故障排查:还原故障发生时的调用链
  • 方案对比:展示重构前后调用链的变化

核心要素

  • 生命线(Lifeline):每个参与者一条垂直虚线,从上到下表示时间推进
  • 激活条(Activation Bar):生命线上的矩形,表示该参与者正在处理请求
  • 消息(Message):水平箭头,表示调用/返回
  • 自调用(Self Message):指向自己的箭头

画法步骤

  1. 列出参与交互的所有角色(服务、模块、数据库、消息队列等)
  2. 从左到右排列参与者(按调用顺序:调用方在左,被调用方在右)
  3. 沿时间线,逐条画出消息交互(每一条线就是一个 RPC/HTTP/MQ 消息)
  4. 标注关键参数和返回值
  5. 重点:标注异常分支——超时、重试、降级、熔断

常见错误

  • 只画正常流程,不画异常路径(超时怎么办?依赖挂了怎么办?)
  • 参与者太多(超过 6-7 个时应考虑抽象或拆分为多张图)
  • 忘记标注同步/异步(实线箭头=同步,虚线箭头=异步返回)
  • 激活条缺失或长度不对(应精确表示处理开始和结束的时间)

Mermaid 模板

sequenceDiagram
    participant C as Client
    participant GW as API Gateway
    participant Order as 订单服务
    participant Stock as 库存服务
    participant Pay as 支付服务
    participant MQ as 消息队列

    C->>GW: POST /orders
    GW->>Order: 创建订单请求
    Order->>Stock: 预占库存(itemId, qty)
    Stock-->>Order: 预占成功
    Order->>Pay: 发起支付(orderId, amount)
    Pay-->>Order: 支付受理中
    Order->>MQ: 发送"订单创建"事件
    Order-->>GW: 返回订单号
    GW-->>C: 201 Created

    Note over Order,Stock: 库存不足时 Order 直接返回错误
    Note over Order,Pay: 支付超时 3s 则降级为"待支付"状态

3.2.4 类图 & ER 图(Class Diagram & ER Diagram)

一句话定义:类图描述代码层面的类、接口及其关系;ER 图描述数据库层面的实体、属性和关系。两者都是“结构建模“的核心工具,但面向不同层级。

何时用

  • 类图:面向对象设计、代码评审、设计模式沟通
  • ER 图:数据库建模、表结构设计、数据治理

核心要素(类图)

  • :三层矩形(类名 / 属性 / 方法)
  • 关系:继承(空心三角实线)、实现(空心三角虚线)、关联(实线箭头)、聚合(空心菱形)、组合(实心菱形)、依赖(虚线箭头)
  • 多重性:1、0..1、1..*、*

核心要素(ER 图)

  • 实体:矩形(表名)
  • 属性:椭圆或列表(列名 + 类型)
  • 关系:菱形或连线(1:1、1:N、N:M)
  • 主键/外键:下划线或 PK/FK 标注

画法步骤

  1. 识别核心实体/类(先列出来,不连线)
  2. 标注每个实体/类的关键属性(3-5 个,不是全部)
  3. 确定关系类型(“一个订单包含多个订单项”→ 1:N)
  4. 连线并标注多重性
  5. 检查:能否用这张图回答“某个查询要 join 哪些表“?

常见错误

  • 把类图画成“所有属性和方法的大全“(类图应高亮关键结构和关系,不是代码镜像)
  • ER 图中所有关系都用 N:M(实际大部分可以化简为 1:N)
  • 遗漏外键或关联条件标注
  • 类图和 ER 图画成同一张图(混用会导致抽象层次混乱)

Mermaid 模板(ER 图)

erDiagram
    Customer ||--o{ Order : places
    Order ||--|{ OrderItem : contains
    OrderItem }|--|| Product : references
    Order ||--o{ Payment : "paid by"

    Customer {
        int id PK
        string name
        string email
    }
    Order {
        int id PK
        int customer_id FK
        string status
        decimal total
        datetime created_at
    }
    OrderItem {
        int id PK
        int order_id FK
        int product_id FK
        int quantity
        decimal unit_price
    }
    Product {
        int id PK
        string name
        decimal price
        int stock
    }
    Payment {
        int id PK
        int order_id FK
        string method
        decimal amount
        string status
    }

3.2.5 部署图 & 拓扑图(Deployment Diagram & Topology Diagram)

一句话定义:描述系统的物理部署形态——哪些服务跑在哪些节点上,节点之间通过什么网络协议通信。

何时用

  • 技术评审:说明系统如何部署、是否高可用
  • 运维交接:让运维团队理解服务分布和依赖
  • 容量规划:展示各节点的资源规格和扩缩容策略
  • 安全审计:标明网络区域、防火墙、加密边界

核心要素

  • 节点(Node):物理/虚拟机器、容器、Pod
  • 组件(Component):跑在节点上的服务/进程
  • 通信路径:节点间的连线(标注协议和端口,如 HTTPS:443gRPC:9090
  • 网络区域:用不同颜色或边界线区分(公网/DMZ/内网/数据库子网)

画法步骤

  1. 划分网络区域(公有云区域、VPC、子网)
  2. 画出各区域的节点(标注机型、核心数、内存)
  3. 在节点上放置服务组件
  4. 连线并标注协议、端口、是否加密
  5. 标注高可用特性(多 AZ、主从、集群)

常见错误

  • 物理节点和逻辑服务混在一张图(应将部署图和组件图分开)
  • 忽略网络区域划分(所有节点放在一起,没有安全边界意识)
  • 不标注端口和协议(运维无法据此配置防火墙规则)

Mermaid 模板

graph TB
  subgraph Public[公网]
    CDN[CDN]
    User[用户]
  end
  subgraph DMZ[DMZ]
    LB[负载均衡 :443]
    GW[API Gateway]
  end
  subgraph AppSubnet[应用子网]
    OrderSvc[订单服务 x3]
    StockSvc[库存服务 x2]
    PaySvc[支付服务 x2]
  end
  subgraph DataSubnet[数据子网]
    MySQL_M[MySQL 主]
    MySQL_S[MySQL 从]
    Redis[Redis Cluster]
    Kafka[Kafka Broker x3]
  end

  User --> CDN
  CDN --> LB
  LB --> GW
  GW --> OrderSvc
  GW --> StockSvc
  OrderSvc --> PaySvc
  OrderSvc --> MySQL_M
  StockSvc --> Redis
  OrderSvc --> Kafka
  MySQL_M -.->|主从复制| MySQL_S

3.2.6 状态图(State Diagram)

一句话定义:描述一个对象或系统从创建到消亡的完整生命周期,以及在什么事件触发下发生状态转移。

何时用

  • 订单、支付、审批等有明确状态机的业务对象
  • 设计状态机实现前,先画状态图与产品确认
  • 排查“为什么订单卡在待支付状态不动了“之类的问题

核心要素

  • 状态节点:圆角矩形,表示对象在某一时刻的状态
  • 转移箭头:标注 事件 [守卫条件] / 动作
  • 初始状态:实心圆
  • 终态:实心圆 + 外圈

画法步骤

  1. 画出初始状态(对象被创建时的状态)
  2. 逐个添加“可能到达的状态“节点
  3. 在每个转移箭头上标注“什么事件触发“+“什么条件”+“执行什么动作”
  4. 检查每个状态是否都有“出边“(除了终态)——确保没有“死状态“
  5. 与产品/业务方逐条确认状态流转逻辑

常见错误

  • 只画主流程状态,遗漏异常状态(退款中、已取消、已超时)
  • 转移条件不写清楚(“支付成功” vs “支付超时” vs “支付失败“是不同的转移)
  • 一个状态图塞进多个对象的生命周期

Mermaid 模板

stateDiagram-v2
    [*] --> 待支付
    待支付 --> 已支付: 支付成功
    待支付 --> 已取消: 超时30min / 自动取消
    待支付 --> 已取消: 用户主动取消
    已支付 --> 履约中: 商家接单
    履约中 --> 已发货: 出库扫描
    已发货 --> 已完成: 用户签收
    已发货 --> 退款中: 用户申请退货
    退款中 --> 已退款: 退货审核通过
    退款中 --> 已发货: 退货审核拒绝
    已完成 --> [*]
    已取消 --> [*]
    已退款 --> [*]

3.3 架构图的黄金法则

掌握了基础图种之后,进入架构师层面的进阶话题:如何用图推动决策、争取资源、对齐团队

3.3.1 铁律一:视图分离——看人下菜,明确沟通对象

核心原则:没有一张架构图能讲清楚所有事情。面向不同的听众,必须切换不同的视觉语言。

第一类听众:高层 / 产品 / 业务(战略宣讲)

关注点:系统能支撑什么?业务资产怎么划分?团队边界在哪?

对应图种:全景功能架构图。

画法

  • 横向分层(用户层、能力层、底层核心),纵向分域(如将大盘划分为“稳健供给“与“炽烈运营“)
  • 方框里只写业务能力名称,不出现任何技术术语
  • 用颜色区分自研(核心域)、外采(支撑域)、外包(通用域)

禁忌:绝对不要出现 Kafka、Redis、Saga、ACL 等底层技术名词。听众会在 10 秒内失去兴趣。

第二类听众:架构师 / 核心开发 / 测试(技术评审)

关注点:数据怎么进?怎么保证不丢?挂了怎么兜底?

对应图种:流式技术治理图 + 核心链路时序图。

画法

  • 强烈的 Pipeline 推进感(分阶段流水线),粗线条高亮“黄金主执行轴“
  • 必须挂载横向的治理层(分布式事务、防资损)、设施层与 SRE 保障层(熔断限流、DLQ 死信队列)
  • 时序图必须标注异常路径:超时时间、重试次数、降级策略

禁忌:不要只画“理想状态下的正常流程“——评审的核心价值恰在于挑战你的异常处理。

第三类听众:组织架构 / 核心开发(DDD 落地)

关注点:谁听谁的?依赖方向是什么?防腐层在哪?

对应图种:限界上下文图(Context Map)。

画法

  • 只画限界上下文的“圈子“和显式连线
  • 连线标注关系类型:ACL(防腐层)、Hydrate(灌注)、Domain Event(领域事件)、Upstream/Downstream
  • 圈的大小可以暗示职责轻重(核心域画大一点)

禁忌:不要在 DDD 图上画技术实现细节——这是战略设计图,不是战术设计图。

3.3.2 铁律二:动态与静态结合,不要画“死图“

很多架构图画完就像一张安静的“功能方块阵列“,看图的人完全不知道系统在真实运转时会发生什么。

静态定边界(方块):方块的作用仅仅是定义“职责与高内聚的限界“。例如商品中心、计价中心,它们是一个静态的盒子——盒子内部你可以再画一张图去展开。

动态拉主轴(连线/流水线):必须引入“时间线/生命周期“来激活静态图。

做法一:像流式治理图一样,顶部拉出 1 → 2 → 3 → 4 → 5 的数据流向阶段。

做法二:在静态图的底部,加一条带箭头的横向长条,写明核心交易生命周期(如:预览 → 试算 → 下单 → 履约 → 对账),并用虚线与上方的功能方块关联,表明“在下单阶段,交易中心会调用计价中心的哪个网关“。

核心技巧:静态盒子定义“谁是谁“,动态时间线回答“然后呢“。两者都画清楚,读者才能真正理解系统。

3.3.3 铁律三:在图上明确表达架构决策

好的架构图不只是画出方块,它是在画出你的架构决策

表达“收敛态势“

如果你在推动某个核心模块的收敛(如统一计价中心、统一用户中心),不要把它缩在角落。把它放在图的核心中枢或公共腰带位置,让上游的各个模块形成“向心力“向它收敛,而它向下游发起调用。可视化本身就是说服力。

表达“防腐与隔离(ACL)“

在对接外部系统或脏数据源时,图上必须显式画出一个隔离带(防腐层)。例如:供给中心接进来的三方脏数据,必须经过清洗、标准化映射(Normalize)后才能进入纯净的主数据中心。这在图上要用颜色或虚线框明确标出——“这是脏数据暂存区,这是纯净的标准域”。

表达“容错与补偿(降级兜底)“

一个合格的战术架构图,必须在底层标出 Saga 逆向补偿、DLQ 死信队列、人工修复工作台和降级策略。例如在图注或连线旁写上一行小字:“当营销中心发生熔断时,计价中心降级为仅计算商品原价”。这能证明你的方案不是空中楼阁,而是具备线上生产力保障的。

3.3.4 架构图的演进:从概念到落地

一张好图会在项目的不同阶段“长“出不同的形态:

阶段图的形态特征
POC/预研概念草图只有大块服务和大致数据流,方块少连线粗,表达“这个方向可行“
详细设计战术行军图细化每个节点的内部结构,标注接口、协议、异常路径
上线后实况图补上实际部署后的拓扑、监控埋点、容灾切换路径

关键实践:将图源文件(如 .drawio.excalidraw)纳入 Git 管理,与设计文档放在同一目录。每次架构变更时,同时更新图和文档。图文不一致的架构文档,比没有文档更危险。

3.3.5 在架构图中表达非功能性需求

非功能性需求(性能、可用性、安全、成本)往往是评审的核心关注点,但大部分架构图中它们完全缺席。以下是在图中表达它们的技巧:

  • 性能:在关键调用连线上标注 P99 < 50msQPS 峰值 5000
  • 可用性:用虚线画出故障切换路径(主库 → 从库的箭头),标注 RTO < 5min
  • 安全性:用锁图标或颜色标记加密通信(mTLS)、敏感数据存储(加密列)
  • 容量:在服务节点上标注资源规格(4C8G × 3,峰值 QPS 2000
  • 成本:在技术栈旁边标注大致的成本估算(如“Redis Cluster 32G 约 ¥3000/月“)

3.4 按场景组合出招

技能学了,图种会了,心法懂了。但面对真实工作场景,真正的问题变成:这次我到底该画哪几张图?

以下六种常见场景的组合策略,直接可用。

3.4.1 向上汇报:让老板一眼看懂

场景:向 CTO/VP/业务负责人汇报新系统方案或重构计划。

核心目标:让对方在 1 分钟内理解“这个系统能支撑什么业务、边界在哪、为什么值得做“。

图件组合

  • 1 张全景功能架构图:横向分层(用户层→能力层→核心层),纵向分域(按业务能力划分)。只写业务名词,不出现技术组件。
  • 可选:1 张“改造前后对比“简化图:左边画当前痛点(箭头散乱、重复建设),右边画改造后(统一收敛、边界清晰)。

注意:不要画时序图、不要画部署图。这些是技术评审用的,在上层汇报中只会分散注意力。

3.4.2 技术评审:经得起挑战的方案

场景:向架构师委员会/核心开发团队/测试团队做方案评审。

核心目标:证明方案在正常流程、异常流程、高并发、故障恢复四个维度都经得起推敲。

图件组合

  • 1 张技术治理图:Pipeline 式的主执行轴 + 横向治理层(事务、防资损、SRE 保障)
  • 1-2 张核心链路时序图:展示最关键的 1-2 条链路的完整调用链,标注异常路径
  • 1 张对账与补偿流程图:如果涉及资金/库存,必须画出对账流程和异常补偿流程
  • 1 张部署拓扑图:展示多 AZ 部署、主从架构、容灾切换

3.4.3 领域建模:让 DDD 落地

场景:团队进行领域驱动设计(DDD)的战略设计阶段,需要对齐领域边界和依赖关系。

核心目标:让所有人对“哪个上下文负责什么“和“上下文之间怎么协作“达成共识。

图件组合

  • 1 张限界上下文图(Context Map):圈出每个限界上下文,标注关系(ACL / 领域事件 / 上下游)
  • 2-3 张关键场景的时序图:选最复杂的 2-3 个跨上下文协作场景画时序图,验证依赖方向是否合理(核心域不应该依赖通用域)
  • 可选:每个核心上下文的内部类图:只在代码实现前画

3.4.4 故障复盘:讲清楚“发生了什么“

场景:线上故障后的复盘会议,需要还原故障过程、分析根因、提出改进措施。

核心目标:让所有参与者(开发/运维/产品/管理者)对故障全貌有一致理解。

图件组合

  • 1 张故障传播时序图:关键——用红色标记故障起点和传播路径,标出每个节点响应时间的变化(正常 50ms → 超时 3000ms),标出熔断和降级的触发点
  • 1 张恢复流程图:描述故障发生后人工介入的完整过程(谁发现了→怎么定位→什么操作→怎么确认恢复)
  • 1 张改进措施对照表:左列问题根因,右列改进措施及负责人,附上预计完成时间

3.4.5 新人 Onboarding:最快的上手路径

场景:新同事入职,需要在最短时间内理解系统全貌和核心工作流。

核心目标:先给全景,再给抓手,让新人知道“出了问题去哪看、找谁“。

图件组合

  • 1 张系统全景图:展示所有服务/模块及其职责的一句话描述,不展开内部细节
  • 1 张核心链路时序图:展示“一次用户请求从进入到返回“的完整路径,这是新人理解系统运行方式的最佳入口
  • 1 张部署拓扑图:标注各个环境的访问地址、日志查看方式、监控大盘链接
  • 可选:团队与系统对照表:每个系统/模块对应的 Owner 团队,让新人知道找谁

3.4.6 写设计文档:标准图件套件

场景:正式的技术方案文档,需要覆盖多个视角。

核心目标:让阅读者从逻辑、进程、物理、开发四个视角完整理解方案。这里借鉴“4+1 视图“的思想:

视角对应图种回答的问题
逻辑视图类图 / 包图 / 模块图代码怎么组织的?模块怎么划分?
进程视图时序图 / 活动图运行时怎么协作?数据怎么流转?
物理视图部署图 / 拓扑图服务跑在哪?怎么容灾?
开发视图组件图 / 分层图代码仓库怎么拆分?模块间依赖?
场景视图用例图 + 核心时序图关键业务场景怎么贯穿各层?

注意:不是每次都要画满 4+1。小需求 2-3 张图足够,大型项目按需补齐。


3.5 工具与视觉规范

3.5.1 工具矩阵对比

没有“最好的绘图工具“,只有“最适合你场景的工具“。以下是按六个维度对主流工具的对比:

工具架构图UML 标准时序图ER 图团队协作Git 友好适用人群
Draw.io⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐(XML 文件)全能型,免费
Excalidraw⭐⭐⭐⭐(无标准 UML)⭐⭐⭐⭐⭐⭐⭐(JSON 文件)手绘风格,适合快速表达想法
PlantUML⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐(纯文本协作)⭐⭐⭐(文本 diff)代码驱动,CI 友好
Mermaid⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐(Markdown 原生)⭐⭐⭐(内嵌 Markdown)GitHub/GitLab 原生渲染
Figma/FigJam⭐⭐⭐⭐⭐⭐(二进制文件)设计师协作
ProcessOn⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐(在线协作)国内团队,快速上手
Lucidchart⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐企业级,付费

选购建议

  • 个人学习 / 博客配图:Mermaid(直接写在 Markdown 里)或 Excalidraw(快速画草图)
  • 团队协作 / 设计文档:Draw.io + Git 管理源文件
  • CI/CD 集成 / 自动化文档:PlantUML 或 Mermaid
  • 正式架构评审:Draw.io 或 ProcessOn(视觉效果更好)

3.5.2 大厂视觉规范:让你的图具备技术高级感

3.5.2.1 三色原则

全图的主色调控制在 3 种以内。大厂常用配色:

  • 浅蓝:通用域 / 基础能力(消息队列、网关、监控)
  • 浅绿:支撑域 / 适度定制(运营后台、配置中心)
  • 浅橙 / 浅红:核心域 / 重点自研(业务核心链路,如计价、交易)

不要把图画成彩虹——颜色越多,视觉权重越分散,信息的层级感越弱。

3.5.2.2 粗细线条区分主次
  • 普通服务间调用:最细的灰色线条(#999, 1px)
  • 核心主执行轴:粗彩色线条(3-4px 明蓝或橙色),横向贯穿全图
  • 异常/降级路径:虚线
  • 在主轴下方配一行小字手写体注脚,例如:supplier_sync → mapping_normalize → product_sku
3.5.2.3 图例说明(Legend)是强制项

图的右上角或右下角,必须放一个干净的图例说明框。至少标出:

  • 每种颜色的含义(核心域 / 支撑域 / 通用域)
  • 每种线型的含义(同步调用 / 异步消息 / 降级路径)
  • 特殊符号的含义(锁=加密、盾牌=安全边界)
3.5.2.4 标注让图从“好看“变成“有用“
  • 在关键连线旁标注接口名或事件名(如 OrderCreated 而不是“发消息“)
  • 在服务节点标注 QPS 峰值或资源规格
  • 在数据库旁标注容量(如 500GB, QPS 3000

3.5.3 代码即图:Mermaid & PlantUML 的工程优势

传统拖拽式绘图的问题是:改一张图要重新打开工具、拖拽、对齐、导出、替换。而代码驱动的绘图方案有三大工程优势:

优势一:Git Diff 友好。 图的变更是纯文本,Code Review 时能精确看到“哪条连线被改了“。

优势二:CI 可检查。 可以在 CI 流水线中渲染验证,图挂了构建就报错。

优势三:复用与模板化。 团队的公共组件可以抽象成模板,新人复制模板改节点名即可,不需要从头学习工具操作。

核心原则:代码驱动画“规范型“的图(时序图、ER 图、状态图,这些有严格语法),拖拽工具画“表达型“的图(架构图、全景图,这些需要自由排布视觉权重)。

3.5.4 版本管理与团队协作

图源文件管理策略

docs/
├── design-doc.md
├── diagrams/
│   ├── architecture.drawio          # 架构图源文件
│   ├── order-sequence.md            # Mermaid 源码(嵌入文档或独立存放)
│   └── er-diagram.puml              # PlantUML 源文件

图文一致性检查清单

  1. 代码接口变了 → 时序图更新了吗?
  2. 新加了服务 → 架构图更新了吗?
  3. 改了数据库 Schema → ER 图更新了吗?
  4. 调整了依赖方向 → 限界上下文图更新了吗?

团队图库建设:建立团队的“图模板库“(Excalidraw 的 Library 功能),包含:标准服务节点样式、标准连线样式、标准网络区域模板、标准图例框。所有人从模板出发,视觉语言自然统一。


3.6 总结

3.6.1 软件工程绘图的三个层次

        ┌───────────────────────────┐
        │  层次三:架构决策的表达    │  ← 第三、四章
        │  图是你的说服力和领导力    │
        ├───────────────────────────┤
        │  层次二:图种的选择与组合  │  ← 第二章
        │  知道什么场景画什么图      │
        ├───────────────────────────┤
        │  层次一:图种的画法基本功  │  ← 第二章
        │  掌握每类图的核心要素      │
        └───────────────────────────┘

最核心的一句话:画图之前先想听众,定义好边界再连线,用动态主轴激活静态方块,用底层底座展现高可用防线,用异常路径证明你的方案经过深思熟虑。

最后的建议:把画图当成和写代码同等重要的技能来刻意练习。每次写设计文档时,问自己:如果只能放三张图,我选哪三张?每张图要回答的那一个问题是什么?图上的每一根线、每一个字,都应该是答案的一部分。

5. 架构师的自我修养:从“会 Coding“到“会 Solving“

前面的三个部分分别讲了系统设计的判断框架、技术方案的表达方法,以及软件工程绘图的全部基本功。这三样东西合在一起,构成了架构师工具箱里的“硬技能“。但真正决定一个架构师能走多远的,往往不是工具熟练度,而是一套更深层的素质——思考方式、决策原则、落地习惯,以及持续成长的自我要求。

这一部分把这些内容收拢在一起,称为“架构师的自我修养“。

从高级工程师到架构师的跨越,不是从“关注如何 Coding“到“关注如何 Design“,而是从“关注如何 Coding“到“关注如何 Solving“。

4.1 架构师的思考模型:多维滤网

普通工程师面对需求时,第一反应通常是“这个功能怎么写代码“。架构师面对同样的需求,脑海里启动的是一套多维滤网——需求在经过多层过滤之后,才变成方案。这四层滤网,是架构师思考问题的基本框架。

长期视角 vs 短期视角

架构师必须同时持有两套时间尺度:

  • 短期视角:当前业务量是多少,两周内能不能交付,团队现在的能力能不能接住。
  • 长期视角:半年后数据量会涨到什么级别,一年后业务模型是否会变,今天的选型会不会成为明天的瓶颈。

这两个视角天然冲突:短期要快,长期要稳。普通工程师可以选择只关注其中一个,架构师不能。架构师的工作就是在“快速交付“和“长远规划“之间,找到当前阶段最合适的平衡点

一个很实用的判断标准是:这个决策未来要改的成本有多高? 如果只是换一个缓存策略、调整一个超时参数,成本很低,那就不值得现在过度设计;如果要换存储引擎、改数据模型、迁移协议,成本很高,那就值得在早期多花时间把方向选对。

全局视角 vs 局部视角

普通开发盯着自己的模块,架构师看的是整个生态:

  • 这个改动对上游调用方有什么影响?
  • 对下游依赖的吞吐会有什么压力?
  • 网络拓扑上,调用链路会经过哪些区域?
  • 安全边界在哪里,敏感数据会不会因为这次改动被暴露到不该出现的链路上?

全局视角不是“什么都知道“,而是习惯性地追问“这个决策的外溢效应是什么“。一个模块内部做得很漂亮,但如果它的接口设计导致上游被迫改 10 个调用方,那它就不是一个好方案。

非功能性需求永远是第一反应

业务方说“我要一个购物功能“,普通工程师想的是“商品表、购物车接口、下单流程“。架构师脑子里弹出来的第一屏是:

  • 这条链路的峰值 QPS 会到多少?
  • 库存扣减的一致性要求是什么级别?
  • 支付回调丢了怎么办?
  • 如果依赖的第三方挂了,用户还能不能看到购物车?
  • 这个功能的监控指标是什么?
  • 做这一套要花多少钱?

非功能性需求不是写完功能之后再来“补“的,而是方案设计的骨架。架构师的训练,本质上就是把对这些维度的敏感度从“事后想起“变成“事前条件反射“。

商业与技术结合

技术是为商业服务的。架构师面对一个技术方案时,至少要想三个问题:

  • ROI:这个技术投入(开发时间、机器成本、维护人力)换回来的商业收益是什么?是用户体验提升,还是客诉下降,还是成本节省?
  • 技术债:今天这个选择,会在未来产生什么隐性代价?如果两年后要换掉它,迁移成本多大?
  • 战略对齐:这个方案是否符合公司的技术战略?如果公司正在推进“去 Oracle“,那就不要再引入 Oracle 依赖。

技术深度是架构师的底气,但商业判断力才是决定一个架构师能否被业务方和老板信任的关键。一个只会说“这个方案技术更先进“的架构师,和一个能说“这个方案虽然技术更保守,但交付快两周、运维成本低一半、而且团队已经熟练“的架构师,影响力是完全不同的。

4.2 核心原则与方法论

架构师不是靠直觉做决策,而是靠一套经过千锤百炼的原则和方法论。这些原则不会直接告诉你“这里用 Redis 还是 Kafka“,但它们会给你一个判断框架,让你在面对任何新技术、新场景时都能做出结构化的决策。

4.2.1 三条必须内化的设计原则

康威定律(Conway’s Law)

系统架构复制了组织的沟通结构。

这不是一句鸡汤,而是一个经过反复验证的组织工程规律。如果你的团队分了 3 个组,各自独立汇报、独立考核,那你的系统大概率会变成 3 个耦合度很低的主模块。反过来,如果你想得到一条高内聚的 Bounded Context,你就需要一支专属的、跨职能的团队来守护它。

康威定律给架构师的两条行动指引:

  • 设计架构时,先看看团队怎么组织的。如果组织和架构不匹配,要么调整架构去适应组织现实,要么推动组织调整去支持目标架构。
  • 推动组织变革,本质上也是架构师工作的一部分。如果你想要一个收敛的、统一的核心域,却让 3 个团队各自维护一块,那架构图再漂亮也落不了地。

奥卡姆剃刀原则(Occam’s Razor)

如无必要,勿增实体。

能用简单架构解决的问题,绝不要上复杂架构。能用单库解决的就不要分库分表,能用单体解决的就不要拆微服务,能用 MySQL 解决的就不要引入分布式 NoSQL。

这不是“反对新技术“,而是复杂度的每一层增加,都会在开发、测试、运维、排障、新人上手等环节持续产生利息。架构师的职责,就是在引入任何一种复杂度之前,问自己:“这个复杂度,是业务真的需要,还是我觉得它酷?”

一个实用的自检标准:如果两年后你要离开这个团队,你会愿意把这个系统交接给一个中级工程师吗? 如果答案是否定的,那这个架构可能过重了。

演进式架构(Evolutionary Architecture)

不要试图一步到位设计出 10 年不落后的系统。

架构是长出来的。第一版的目标不是“完美“,而是“足够稳定且便于迭代“。好的架构不追求预判未来所有需求,而是追求具备“易于在未来发生变革“的能力——当你发现某个模块的边界划错了、某个存储选型顶不住了、某个抽象不再适用了,你能不能在不大动干戈的情况下把它换掉。

演进式架构的核心不是预测未来,而是为变化留好接口、隔离好边界、控制好变更半径。

4.2.2 三套核心方法论

领域驱动设计(DDD)

DDD 是微服务拆分的理论基石。它的核心思想是通过划分“限界上下文(Bounded Context)“,把复杂的业务拆解为边界清晰的领域模型。每个限界上下文内部拥有自己的领域语言(Ubiquitous Language),上下文之间通过明确的依赖关系和防腐层(ACL)进行协作。

DDD 最重要的贡献,不是那些战术模式(聚合、实体、值对象),而是把“业务理解“前置为设计的第一步。在动手画架构图之前,先跟业务方把领域概念对齐,把限界上下文划清楚,把依赖方向定下来——这些工作决定了系统架构的骨架是否正。

本书不会展开 DDD 的完整体系,但在第 2.8 节“六维设计画布“中,Model 维度和 Capability 维度的核心思路与 DDD 一脉相承。

ATAM(Architecture Tradeoff Analysis Method)

ATAM 是一套架构评估方法,核心思想可以浓缩为一句话:世界上没有完美的架构,只有最适合当前场景的架构。

架构师的工作,本质上就是在做权衡。常见的权衡对包括:

  • 用空间换时间(缓存、索引、预计算)
  • 用一致性换可用性(最终一致性、异步收敛)
  • 用吞吐换延迟(批量处理、异步削峰)
  • 用复杂度换扩展性(分库分表、微服务拆分)

ATAM 的价值不在于告诉你“选哪个“,而在于让你养成一个习惯:在做每一个决策时,能说清楚你选择了什么、放弃了什么、为什么这个取舍在当前场景下是合理的。

4+1 视图

4+1 视图是从不同视角描述架构的一套模型:

视图关注点典型受众
逻辑视图功能需求和模块划分产品、业务方
开发视图代码组织、模块依赖、构建方式开发者
进程视图运行时行为、并发、通信架构师、SRE
物理视图部署拓扑、网络、机器运维、SRE
场景视图关键用例如何贯穿各层(+1)所有角色

4+1 视图的核心洞见是:没有人能用一张图理解整个系统。 架构师的职责不是画出一张“万能图“,而是为不同的受众选择正确的视图,用正确的语言表达。这一点与本书第三部分“软件工程绘图完全指南“的核心理念完全一致。

4.3 六维画布:高准确率场景的实战应用

在第 2.8 节中,我们已经系统介绍了六维设计画布(Model / Process / Capability / Governance / Measurement / Evolution)作为通用设计阶段的方法论。这里不再重复展开,而是针对支付、库存、账务、交易等“不能错“的高风险场景,补充几个实战要点。

这类场景的共同特征很简单:错了就是资损,错了就要赔钱,错了监管就要来。 所以在六维画布的应用上,每一层都会被提到更高的标准:

【 核心层(底座)】   -->  1. Model(模型):      定义事实,数据长什么样
【 运行层(骨干)】   -->  2. Process(流程):    驱动状态,业务怎么跑起来
【 扩展层(翅膀)】   -->  3. Capability(能力): 提炼复用,怎么支持更多业务
【 防御层(免疫)】   -->  4. Governance(治理): 容错补偿,出错了怎么兜底
【 观测层(眼睛)】   -->  5. Measurement(度量):白盒监控,运行得怎么样
【 未来层(生命力)】 -->  6. Evolution(演进):  平滑升级,怎么应对未来的变化
  • Model:明确唯一事实源。余额不能只有 balance 字段,应当采用不可变流水账本模型——“余额是流水计算出的结果,而不是一个可被直接覆盖的字段”。这在高准确率场景下不是锦上添花,而是最低要求。
  • Process:缩小强一致边界。广泛采用“预占 + 确认/取消“模式(如 TCC),内建幂等与防重契约。核心原则是:让强一致区域越小越好,让补偿链路越清楚越好。
  • Capability:策略与核心分离。新业务、新渠道来临时,优先通过配置驱动、规则引擎或 SPI 插件化来支持,而不是频繁修改核心代码。核心代码是经过去重考验的,每一次修改都是风险。
  • Governance:设计异常闭环。主动探测未知状态(如支付结果未返回),遇到超时或失败时通过 Saga 逆向补偿或自动对账来修复。对账不是运维的事,是方案设计的一部分。
  • Measurement:白盒化可观测性。除了 QPS、RT、错误率这些技术指标,还必须度量业务健康指标——如一致性滞后时间、对账不平率、未处理异常单量。
  • Evolution:规划平滑迁移。涉及数据迁移的场景,至少要考虑“双写双发、历史离线比对、切读写“的完整流程,并预留降级和回退空间。

六维画布的核心价值,在于把一句“这个场景很重要,要认真设计“,变成了六个可以逐项检查的维度。在高准确率场景下,这六个维度少看一个,都有可能在线上变成真金白银的损失。

4.4 日常工作中的落地法则

架构师不仅要想得清楚、讲得明白,还要能落地。很多方案在设计阶段看起来很完美,一到执行就变形——不是因为方案有问题,而是因为架构师没有把落地这件事本身当成设计的一部分。

别做“象牙塔“里的架构师

必须写代码。 不写代码的架构师会逐渐丧失对技术的敏感度。当你不再清楚一个 PR 从提交到上线要走多少步、一个分布式事务在真实网络里会遇到多少种超时模式、一个配置变更在凌晨三点会不会把值班同事叫醒的时候,你设计的方案就会越来越像“理论上成立,实际上悬空“。

一个可操作的底线是:每年至少参与一个核心模块的编码或定期的深度 Code Review。 不是为了证明“我还能写“,而是为了保持对系统真实状态的体感。

在一线听炮火。 系统重构、线上大故障、性能瓶颈发生时,架构师必须在现场。不是“事后看报告“,而是跟着值班同事一起排查、一起决策、一起复盘。只有知道痛点长什么样、排查链路有多绕、当时的判断有多难做,下一次方案评审时你才能问出真正关键的问题。

控制技术陷阱,避免过度设计

警惕技术自嗨。 技术社区每天都在推新东西,架构师最容易犯的错误之一,就是为了“简历好看“而引入最新、最复杂的开源框架。盲目引入服务网格、分布式事务框架、图数据库、事件溯源等技术,在业务体量还没有到那个级别的时候,往往不是“为未来准备“,而是“给今天添乱“。

一个很直白的判断标准:你引入这个技术,是因为不引入就做不了,还是因为引入了会让你觉得更有成就感? 如果是后者,那大概率是过度设计。

管理技术债。 架构师要定期盘点系统中的“坏味道“和存量技术债,并有计划地推动重构。技术债不是不存在的债,而是暂时没有还的债。架构师的职责不是把债藏起来,而是:

  • 把债显式化:记录在哪里、影响范围、严重程度
  • 把债排优先级:哪些债已经快还不上了(比如每次发版都要祈祷不出事),哪些还可以再缓一缓
  • 把还债计划嵌入业务迭代:争取在每次业务需求中,顺带清理一部分关联的技术债

沟通与消灭模糊性

把复杂问题说简单。 架构师一半以上的时间在沟通,而沟通最核心的能力不是“把简单问题说复杂“,而是“把复杂问题说简单“。

面对不同角色,切换不同的语言:

  • 对老板和业务方:用商业语言和 ROI——“这个方案可以减少 30% 的客诉”“这个重构可以让新功能上线快两周”
  • 对开发团队:用清晰的 UML 图、接口定义、技术规范——把模糊的“这里要做个接口“变成精确的“这个接口的请求体长这样、幂等键是这个字段、超时是 3 秒“
  • 对测试:用状态机、边界条件、异常路径——不要只给正常流程,把最坏情况也列出来

定义边界,达成共识。 在项目初期,最值钱的工作不是画漂亮的架构图,而是明确划分“谁该做什么、谁不该做什么“。很多项目后期冲突,根源都在前期边界没有对齐。架构师要做的是在利益相关者之间建立统一的术语表(Ubiquitous Language),确保不同角色在说同一个词的时候,指的是同一件东西。

关注团队梯队与规范

沉淀规范。 定义团队的代码规范、CI/CD 流程、日志规范、异常处理规范。好的架构不只是你自己画的图,而是你留下的框架和规范——让“哪怕资质平平的开发,在你的框架下写代码,也不会把系统搞塌“。

这才是架构师最大的杠杆:你不是通过自己多写代码来影响系统质量,而是通过你设计的框架、规范和约束,让整个团队产出的代码质量都上一个台阶。

技术传帮带。 通过架构评审、技术分享、结对编程,把自己的思考方式赋能给高级工程师。架构师的重要输出不是只有文档和代码,还有“下一代架构师“。一个只会自己做决策的架构师,迟早会成为团队的瓶颈;一个能培养出更多能做决策的人的人,才是真正意义上的技术 Leader。

4.5 架构师自我修养小结

从高级工程师到架构师的跨越,不是一夜之间的突变,而是一层一层积累的结果。把本部分的要点浓缩为几条:

  • 技术深度是底气:不写代码、不懂底层、不看故障现场,其他能力都是空中楼阁。
  • 全局视野与权衡艺术是核心:架构师不是在找“正确答案“,而是在找“当前场景下代价最小、收益最大的方案“。
  • 沟通能力是放大镜:能把复杂问题讲简单、能把不同角色拉到同一张桌子上达成共识,决定了你的方案能不能从纸面上走到线上。
  • 团队杠杆是上限:你一个人做得好是高级工程师,你能让一个团队都做得好才是架构师。沉淀规范、培养梯队、建立标准,这些工作的影响力远超过你亲自写的任何一行代码。

最后,用一句话来总结“架构师的自我修养“:

技术深度是你的底气,全局视野与权衡艺术是你的核心能力,沟通与推动落地是你从“想清楚“到“做成事“的桥梁。而这一切加起来,最终的目标不是让你自己更强大,而是让你所在的系统、你的团队、你的组织,因为你而变得更强大。


6. 本章小结

这一章分五个部分:第一部分讲系统设计的判断框架,第二部分讲技术方案的表达与推进方法,第三部分讲架构师的武器库——从边界设计到系统内部结构、系统集成与一致性、质量保障与容量韧性(合并原第 2 章内容),第四部分讲软件工程绘图的全部基本功,第五部分补充架构师的自我修养——从思考模型、方法论到落地习惯。

四部分看似不同领域,本质上是在回答同一个问题:

架构师如何在现实约束下,把复杂问题做成一套可落地、可协作、可复盘的工程决策。

如果只懂前半部分,你可能能想清楚问题,却很难推动团队按同一套理解落地;如果只会后半部分,你可能能写出格式完整的 TD,却缺少真正的系统设计判断力。如果只停留在技术和工具层面,你可能能画出漂亮的图、写出严谨的文档,却无法在组织约束下长期推动落地和演进。

所以真正成熟的能力不是”会画图””会写接口””会背组件”,而是:

  • 能定义问题
  • 能识别约束
  • 能做关键取舍
  • 能把取舍写清楚
  • 能带着评审视角推进方案落地
  • 能把复杂问题讲简单,把模糊需求变成清晰边界
  • 能通过规范、框架和传帮带,让整个团队的质量一起提升

后续章节会继续展开:

  • 第二部分 技术方案设计方法论
  • 第三部分 架构师的武器库(业务边界、系统内部结构、集成与一致性、质量保障)
  • 第四部分 软件工程绘图完全指南
  • 第五部分 架构师的自我修养
  • 第 2 章:编码与 Code Review

7. 本章面试题与追问

  1. 什么是系统设计?系统设计面试到底在考什么? 追问:你会如何区分“会用组件”和“有设计能力”?
  2. 为什么系统设计里一定要先做容量估算? 追问:如果没有精确数据,你会如何给出合理区间?
  3. 高并发系统为什么不能只靠加机器解决? 追问:哪些瓶颈是水平扩展也解决不了的?
  4. 强一致性和最终一致性分别适合哪些业务? 追问:订单、评论数、搜索索引分别怎么选,为什么?
  5. 为什么说可扩展性会带来复杂度? 追问:从单体走向微服务后,通常新增了哪些治理问题?
  6. Redis、Kafka、MySQL 在系统设计里分别解决什么类型的问题? 追问:什么时候不该引入它们?
  7. 为什么分布式系统必须重视超时、重试和幂等? 追问:如果客户端重试导致重复下单,你会怎么兜底?
  8. 负载均衡和缓存分别解决什么问题? 追问:它们引入了哪些新的故障模式?
  9. 为什么架构师不能只会画图或列接口? 追问:一份真正可评审的 TD 至少要回答哪些关键问题?
  10. 技术方案里的关键设计决策应该怎么写? 追问:如果你列了 A/B 方案,怎么证明你选的那个更合理?
  11. 为什么很多 TD 会在 rollout 和 rollback 上被打回? 追问:你会如何把灰度、观测、回滚写成可执行方案?
  12. 如果让你在 5 分钟内回答一道系统设计题,你会按什么顺序组织表达? 追问:这个顺序和你写真实 TD 时有什么共通点?

第 2 章 编码、重构与 Code Review:构建可演进代码的实践方法

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

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

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

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

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

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

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

  • 什么样的代码,才算真正支持系统长期演进。
  • 老代码开始腐化时,应该如何小步重构,而不是等到必须重写。
  • 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.2 好代码首先是边界清楚

2.2.1 先问职责,再写函数

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

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

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

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

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

关于「这些职责在真实工程里如何落到目录结构上」,第 1 章给出了两套完整的 Go 目录映射(三层架构版与 DDD 版),2.7 节会基于可运行的 ~/Projects/system-design-architecture-examples/ 示例工程做完整走读。示例工程独立于本书仓库维护。

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
}

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

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

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

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

反例通常长这样:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

更稳妥的路径通常是:

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

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

2.3.3 小步重构的常用路径

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

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

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

2.3.4 把“空中换引擎”用在代码重构上

系统重构和架构重构有一个共同点:真正成熟的做法,往往不是停机重建,而是在线迁移。代码层面同样如此。很多时候,旧实现不能立刻删除,但可以先被包进一个更干净的抽象中,让新逻辑逐渐迁移到新边界。

一个常见步骤是:

识别腐化模块
  -> 定义新接口
  -> 用适配层包装旧实现
  -> 新需求优先走新接口
  -> 老调用逐步迁移
  -> 用测试和 Review 锁住行为
  -> 删除旧路径

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

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

2.4.1 可读性优先于炫技

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

可读性通常来自四件事:

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

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

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

例如:

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

好的抽象会减少决策面,坏的抽象会制造更多决策面。

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 时容易判断幂等行为是否符合预期。

2.4.4 让测试点自然存在

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

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

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

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

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

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

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

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

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


2.5 Code Review 到底在审什么

2.5.1 Review 的目标不是挑错字

Code Review 的核心目标有四个:

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

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

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

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

2.5.2 Reviewer 的检查顺序

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

建议顺序如下:

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

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

2.5.3 Reviewer 最容易漏掉的风险

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

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

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

2.5.4 Author 应该怎样配合 Review

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

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

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


2.6 一份可执行的 Code Review 清单

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

2.6.1 业务与正确性

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

2.6.2 架构与边界

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

2.6.3 可读性与可维护性

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

2.6.4 可靠性与可运维性

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

2.6.5 测试与验证

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

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


2.7 优秀代码实践走读:从可运行示例看好代码的形状

前面几节讲的是原则,这一节看实例。本书配套的 ~/Projects/system-design-architecture-examples/ 目录下有三个可编译运行的 Go 工程,它们不是玩具 Demo,而是刻意用来展示「代码结构如何承载架构意图」的对照样本。示例代码已从书籍仓库移出,避免书稿构建项目和可运行工程相互耦合:

示例工程展示重点对应的代码问题
~/Projects/system-design-architecture-examples/order-service标准三层架构的自然形态职责够清楚,但业务规则散落在 service,依赖具体实现
~/Projects/system-design-architecture-examples/product-serviceDDD 四层架构、聚合根、值对象、领域事件、三级缓存、Outbox业务规则内聚到领域模型,依赖方向向内,可独立测试
~/Projects/system-design-architecture-examples/common-services全局 ID 等基础服务的薄封装通用域不需要重抽象

这一节从这三个工程里挑出五段代码,分别对应 2.2-2.4 的原则。读的时候建议对照源码完整看一遍——真实工程里的取舍细节(日志、注释、错误处理)比书上任何摘录都更有信息量。

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

product-service 的顶层结构,几乎可以直接拿来当团队模板(完整树见第 34 章 34.6 节):

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 包,interfacesinfrastructure 都依赖内层——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.7.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
}

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

  1. 不变量由聚合根自己保护。调用方不可能绕过「价格必须大于 0」这个规则——规则内聚在模型里,而不是靠每个调用方「记得检查」。这正是第 1 章「贫血 vs 充血」对比的完整版。
  2. 状态迁移表达为业务动词OnShelf()OffShelf(reason)UpdateBasePrice(price),读代码就是在读业务流程,没有任何技术噪音。
  3. 每次状态变更留下领域事件。事件在聚合内部积累,由应用层统一发布——「业务事实已经发生」这件事被显式建模,而不是散落在各处 kafka.Send()
  4. 字段私有 + 只读 Getter。外部无法直接把商品改成上架状态,只能走 OnShelf(),非法状态在类型层面就不存在。

2.7.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。浮点精度问题在计价、退款分摊场景是真金白银的事故源(第 29 章计价系统会再次遇到这个约束)。
  • 校验收敛在构造函数NewPrice 是唯一能造出 Price 的地方,负数价格在系统中根本不可能存在——这比在每个使用处写 if price < 0 高一个量级,也正是 2.4.2「把变化点隔离」的实例。

2.7.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 既保留了错误链可供日志追溯,又给上层提供了可判断的上下文。
  • 流程步骤一眼可读。建领域对象 → 保存 → 发事件,顺序显式。编排层的职责是「编排」,不是「什么都干」。

2.7.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.8.1 节(下单反例)里那个把一切都揉在 Handler 里的写法,你想给它补一个「库存不足」的单元测试都无从下手——只能起数据库做脆弱的集成测试。测试写不出来,往往就是结构腐化最早的报警器。

2.7.6 把示例工程用起来

建议团队这样使用这些示例,而不是读完就忘:

  • 新人 Onboarding:先跑通 product-servicego run ./cmd),再回答「我要加一个新的商品字段,应该改哪几层」。
  • Review 参照系:当 PR 里出现「这段逻辑该放哪层」的争论时,用示例工程的同类代码做参照,比抽象讨论快得多。
  • 重构对照目标:迁移老代码时,把 order-service(现状)和 product-service(目标)摆在一起,演进路径就是 2.3.4 的「空中换引擎」。

2.8 一个电商下单场景里的编码、重构与 Review

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

2.8.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.8.2 如果这是线上老代码,应该怎么重构

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

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

  1. 先把 Handler 中的流程编排提出来,形成单独的 Use Case。
  2. 再把库存、计价、订单持久化等依赖抽成清晰接口。
  3. 明确错误语义和幂等语义,让旧逻辑至少先变得可判断。
  4. 补上关键路径测试和最小观测点,再继续拆分内部实现。

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

2.8.3 更合理的拆分

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 变得可行。

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

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

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

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


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

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

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

2.9.2 误区二:用 Review 替代设计

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

更稳妥的做法是:

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

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

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

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

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

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

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

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

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

  • 为核心链路维护“代表团队标准”的样例实现(本书的 ~/Projects/system-design-architecture-examples/ 三个 Go 工程就是这种样例的最小形态,见 2.7 节);
  • 为高风险场景维护 Review Checklist;
  • 为历史包袱最重的模块预留明确的重构预算,而不是永远让它排在需求之后;
  • 在复盘中把真实事故沉淀成新的评审规则;
  • 鼓励 Reviewer 提出基于风险的意见,而不是只给抽象评价。

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


2.10 本章小结

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

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

  • 编码时先守边界,再谈技巧。
  • 重构时先收口边界,再替换内部实现。
  • Review 时先看正确性与设计,再看局部实现。
  • 让提交可审查,让风险可讨论,让结论可复用。

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

第 3 章 生产系统治理、保障与技术债务:韧性架构的动态平衡

生产系统最大的错觉,是把一次次线上成功误认为系统天然可靠。事实上,只要系统仍在承载业务、不断接入新需求、持续经历变更,它就一定会沿着更复杂、更脆弱、更难解释的方向滑动。技术债会累积,保障措施会老化,组织纪律会松动,最终在某个看似偶然的流量峰值、配置变更或下游抖动中集中爆发。

这就是我理解的软件系统“熵增定律”:孤立的生产系统,总是倾向于增加混乱度。工程师的工作,本质上不是追求一个永远完美的架构,而是不断向系统注入“负熵”,在业务速度、技术债务和生产保障之间寻找动态平衡。

3.1 引言:软件系统的熵增定律与“救火队长”陷阱

很多团队在发展初期都经历过同一条路径:先用最短时间把业务跑起来,再靠人盯、人补、人扛去渡过每一次线上波动。短期看,这种方式似乎很高效;长期看,它往往把团队拖进“越忙越救火,越救火越没有时间治理”的恶性循环。

真正难的,从来不是把系统做上线,而是让它在十倍流量、百次变更、多人协作和多系统耦合之下,仍然保持可预测、可恢复、可审计和可演进。生产事故很少是纯偶发事件,更常见的情况是:技术债长期积累,在某一次变更、流量冲击或依赖抖动下突然开始收利息。

所以,本章要讨论的不是狭义上的“稳定性技巧”,而是一套更完整的生产系统方法论:治理负责治本,保障负责治标,技术债是必须正视的现实约束。三者不是并列话题,而是一个持续运转的反馈系统。

3.2 全景框架:治理、保障与技术债务的动态反馈环

如果把生产系统放到更长的时间维度里看,技术债、治理和保障之间大致构成一个闭环:

【生产治理(治本)】 ─── 指导 / 注入机制 ───► 【技术债务(利息)】
        ▲                                           │
        │ 动态演练 / 事故复盘 / 规则固化            │ 隐形挤占产能 / 诱发事故
        │                                           ▼
【应急保障(治标)】 ◄──── 承载压力 / 爆发故障 ─── 【生产运行(现实)】

这个闭环里有四个关键判断:

  1. 技术债是因,生产故障往往是果。
  2. 保障是防线,但保障不能代替治理。
  3. 治理的价值,不是“多立规矩”,而是持续把偶发经验沉淀成系统能力。
  4. 事故、演练、复盘和数据,会反过来暴露新的技术债,并推动下一轮治理。

因此,一个成熟团队不会把治理、保障和债务拆开看,而是会不断追问:今天的故障,背后暴露的是哪类债务?今天加的一道保障,究竟是临时止血,还是已经沉淀成长期机制?

3.3 技术债务篇:如何与系统的“慢性癌症”和平共处

技术债务最危险的地方,不是代码丑,不是注释少,也不是架构图不好看;真正危险的是,它会悄悄吞掉团队的变更速度、恢复能力和判断空间。很多系统不是死于一次大故障,而是死于长期“小病不断”,最后任何小改动都可能引发连锁反应。

但老系统治理的现实从来不是“零负债”,而是“让债务处于可控带宽内”。因此,技术债务管理的重点不是道德审判,而是分级、量化和准入。

从实践上看,技术债至少可以分成五类:

  • 架构债:边界混乱、依赖蔓延、共享资源池过多。
  • 代码债:复杂度失控、测试缺口、变更影响面难以评估。
  • 数据债:口径不一致、状态无法追溯、历史快照缺失。
  • 运维债:回滚困难、告警失真、恢复工具薄弱。
  • 组织债:没有 owner、没有还债预算、没有变更纪律。

面对这些债务,我更建议建立三套机制:

  1. 红绿灯准入机制。不是所有债务都不能借。业务抢跑阶段允许存在“绿灯债”,例如先靠人工值守兜底,但要明确转红阈值,例如用户量、调用量、商家数或金额规模达到什么水平后必须重构。
  2. 还债预算制。把技术债纳入季度和版本计划,固定拿出一部分产能处理高利息债务,而不是永远等“有空了再说”。
  3. 线上重构方法论。真正成熟的重构,不是停机重写,而是“空中换引擎”:先封装抽象隔离层,再做影子系统和灰度比对,最后小流量切换、可逆迁移。

带着这个视角再看可靠性,就会发现:保障体系解决的是“今天别死”,治理体系解决的是“下次别再这么死”,而技术债务决定了系统到底有多容易在关键时刻出事。

3.4 生产治理篇:面向失效设计的韧性架构

生产治理和应急保障最大的区别,在于前者讨论的是“系统为什么总会在相似场景下反复出问题”,后者讨论的是“问题已经发生时怎么把损失控制住”。如果说保障是在事故当天保护业务,治理就是在事故之间的每一天,持续削弱下一次事故发生的概率和爆炸半径。

很多团队在稳定性建设上最容易犯的错,是把治理理解成一组零散的技术动作:补一个监控、加一个限流、写一个预案、做一次演练。这样做当然有帮助,但还不够。真正的治理,必须把这些动作变成一套持续生效的约束机制,让系统在需求增长、人员流动和架构演进中,仍然保持边界、纪律和反馈回路。

从这个角度看,生产治理至少要回答五个问题:

  1. 我们究竟在为哪些业务结果负责,而不是只为哪些机器指标负责?
  2. 什么样的故障是可以接受的,什么样的故障一旦发生就必须立刻止损?
  3. 哪些依赖可以降级,哪些依赖绝不能带病放行?
  4. 哪些技术债当前可以接受,哪些已经开始透支系统未来?
  5. 事故、演练和变更产生的经验,是否真的被沉淀成了下一轮治理规则?

下面这些能力看起来像是“稳定性工程细节”,但它们真正的价值,在于把经验变成规则,把规则变成默认约束,把默认约束变成系统长期可演进的基础。

业务系统的可靠性至少包含四层含义:

层次关注问题典型例子
可用性用户能不能完成关键动作能否搜索商品、进入结算、提交订单、完成支付
正确性系统结果是否符合业务规则不能超卖、不能重复扣款、不能错误计价
可恢复性出错后能否回到一致状态支付回调失败后可对账,库存预占失败后可释放
可解释性问题能否被定位、审计和复盘一笔订单为什么卡住,哪一步失败,谁处理过

很多团队把可靠性理解成“服务不挂”。这只是最低层目标。对电商、支付、交易、供应链这类业务系统来说,更重要的是关键业务结果不出错

例如:

  • 商品详情页推荐模块失败,可以降级为空。
  • 搜索排序模型失败,可以切到默认排序。
  • 购物车展示价失败,可以提示“以结算页为准”。
  • 订单创建失败,不能生成半张订单。
  • 支付回调重复,不能重复推进订单状态。
  • 库存扣减失败,不能假装扣减成功。

可靠性工程的第一原则是:按业务重要性分层保护,而不是按技术组件平均用力。

很多业务系统的真实发展路径,并不是一开始就有完整的平台化和治理体系,而是先为了验证业务价值快速上线,再逐步补齐可靠性能力。这种路径本身没有问题,问题在于团队常常把“已经能跑”误当成“已经可生产”。

以供应商 Feed 同步系统为例,一个典型的演进节奏通常如下:

阶段目标常见范围
PoC先验证链路能不能跑通申请 Partner、下载样例 Feed、实现单城市同步和 DB 入库
MVP先支撑业务启动打通全 Pipeline,引入 Airflow 调度,补上基础监控
生产化让系统在增长中保持稳定分片、限流、告警、测试大规模数据和高频调度
优化迭代用真实数据反向优化成本和质量监控实际变更率,动态调整同步频率和资源水位

如果从时间上看,这个过程也很常见:

  • PoC(1-2 周):申请 Partner,下载样例 Feed,实现单城市同步和 DB 入库,重点是确认字段能不能解析、业务流程能不能闭环。
  • MVP(4-6 周):扩成完整 Pipeline,引入 Airflow,补上基础成功率、延迟、失败量监控,重点是让业务能开始用,而不是先把所有平台能力做满。
  • 生产化(2-4 周):补齐分片、限流、告警、容量验证和大规模测试,重点是防止数据量、城市数、供应商数一上来就把早期实现拖垮。
  • 优化迭代:上线后持续监控实际变更率,再调整抓取和同步频率,避免既没有必要地高频拉取,也不要因为频率过低让数据失去业务价值。

这里最容易被忽略的是:PoCMVP 阶段为了抢时间,通常会暂时跳过很多生产治理动作,例如分片策略、重试预算、告警分级、回放工具、压测、审计、容量模型。这在阶段性上是合理的,但前提是团队明确知道这些是技术债,而不是“以后再说的细节”。

一个成熟团队会把这种分阶段建设显式写进方案里:

  1. 第一阶段定义“验证什么能不做”。
  2. 第二阶段定义“哪些能力缺失仍可控”。
  3. 第三阶段明确“达到什么标准才算可生产”。
  4. 上线后用真实指标反向推动优化,而不是一直凭经验拍频率、拍容量、拍阈值。

所以,可靠性治理并不要求团队一开始就过度设计;它真正要求的是:在快速上线之后,能系统性地把生产化和治理工作补回来,并且让这些工作进入版本计划,而不是永远排在需求之后。

3.4.1 SLI、SLO、SLA 与错误预算:把稳定性变成治理语言

可靠性建设必须先量化目标。没有目标,监控只是图表;没有目标,告警只是噪声;没有目标,稳定性投入永远会输给短期需求。

最常用的概念是 SLI、SLO 和 SLA。

指标含义谁使用示例
SLIService Level Indicator,服务表现指标工程团队订单创建成功率、P99 延迟、支付回调处理延迟
SLOService Level Objective,内部稳定性目标工程与业务团队订单创建 99.95% 在 2s 内完成
SLAService Level Agreement,对外协议承诺客户、法务、商业团队可用性低于承诺时补偿客户

SLI 是观测,SLO 是目标,SLA 是契约。三者不要混用。很多团队只有 SLA,却没有可执行的 SLO;最后稳定性问题就只能靠事故后吵架,而不是事故前治理。

3.4.1.1 业务系统常见 SLI

SLI 不能只看接口成功率。业务系统至少要覆盖下面几类指标:

类型指标说明
成功率下单成功率、支付成功率、库存预占成功率用户是否完成关键动作
延迟PDP P99、结算 Init P99、创单 P99、支付回调处理耗时用户等待多久,链路是否退化
正确性价格差异率、超卖次数、重复扣款次数、非法状态迁移次数是否出现资损或业务错误
新鲜度商品索引同步延迟、库存同步延迟、供应商数据延迟读模型和权威数据相差多久
完整性消息堆积、DLQ 数量、补偿任务 backlog异步链路是否持续收敛
饱和度线程池、连接池、队列、CPU、内存、磁盘、网络系统距离崩溃还有多远

面向用户体验的 SLI 更适合做告警;面向资源和组件的 SLI 更适合做排障。不要把两者颠倒。CPU 高不一定是事故,但下单成功率跌了通常就是事故。

3.4.1.2 按用户旅程定义 SLO

业务系统的 SLO 最好围绕用户旅程定义,而不是围绕单个接口定义。

用户旅程推荐 SLO说明
商品浏览99.9% 商品详情页在 1s 内返回基础信息推荐、营销标签可降级
搜索导购99.5% 搜索请求在 800ms 内返回结果个性化排序失败可回退默认排序
结算初始化99.5% 结算页在 2s 内完成商品、库存、价格校验非关键权益可降级
提交订单99.95% 创单请求在 3s 内明确成功或失败不允许返回未知半成功
支付回调99.99% 合法回调在 30s 内完成幂等处理延迟处理必须进入对账
商品发布99% 商品发布后 5 分钟内同步搜索和缓存允许短暂最终一致

这里有两个关键点:

第一,SLO 要区分核心与非核心能力。推荐模块和支付回调不能使用同一套目标。第二,SLO 要明确时间窗口。比如“商品发布后 5 分钟内同步搜索”,比“搜索要及时”更可执行。

3.4.1.3 错误预算

错误预算是 SLO 的另一面。

如果订单创建成功率 SLO 是 99.95%,那么一个月内允许的失败预算是 0.05%。预算消耗过快,说明系统稳定性正在透支。此时团队应该收紧发布、暂停高风险变更、优先修复稳定性问题。

错误预算的价值不是“允许失败”,而是把稳定性和研发速度放进同一个决策框架:

  • 预算充足:可以更积极发布、实验和重构。
  • 预算快速消耗:需要减少变更,治理错误率最高的链路。
  • 预算耗尽:进入稳定性冻结期,只允许修复和低风险变更。

没有错误预算时,稳定性讨论容易变成抽象争论;有了错误预算,讨论会变成数据驱动。

3.4.2 故障模型:先知道系统会怎样坏

可靠性工程不是等故障发生后再补救,而是在设计阶段预判故障类型。

业务系统常见故障可以分为九类:

故障类型典型表现常见根因
流量冲击QPS 突增、队列堆积、接口超时大促、热点商品、爬虫、重试风暴
慢依赖P99 上升、线程池耗尽下游 DB 慢查询、RPC 抖动、外部供应商变慢
部分失败只有某地区、某品类、某渠道异常分片热点、灰度版本、地域网络、配置差异
数据不一致支付成功订单未更新、搜索仍展示下架商品异步消息丢失、补偿失败、对账缺失
资源耗尽连接池满、磁盘满、内存 OOM、Pod 重启容量低估、泄漏、突发批任务
发布回归新版本错误率升高兼容性不足、灰度缺失、回滚困难
配置事故开关误开、阈值错误、路由错误配置无审计、无灰度、无校验
外部依赖故障支付渠道、短信、供应商接口异常第三方不可控、超时和降级不足
人为操作事故误删数据、错误补偿、误切流权限过大、缺少审批和演练

做可靠性设计时,要针对每类故障问三个问题:

  1. 如何提前发现?
  2. 如何限制影响范围?
  3. 如何恢复业务和数据?

只回答“加监控”是不够的。监控只能发现问题,不能自动阻止级联故障。要让故障可控,还需要隔离、限流、熔断、降级、补偿、对账、回滚和应急流程。

3.4.3 故障域与依赖分级

大型业务系统真正危险的不是单点故障,而是故障传播。一个推荐接口变慢,拖慢商品详情;商品详情线程池被打满,网关堆积;网关重试放大流量,最终核心交易接口也被拖死。这就是故障域没有隔离。

3.4.3.1 故障域

故障域可以从多个维度划分:

维度示例隔离手段
业务域商品、交易、支付、履约服务边界、数据边界、独立发布
流量域普通流量、大促流量、爬虫流量网关限流、活动专用集群、风控拦截
用户域C 端用户、商家、运营后台租户限流、后台任务隔离
地域域华东、华南、海外多地域部署、就近路由、区域熔断
资源域线程池、连接池、队列、缓存舱壁隔离、配额、独立集群
数据域分库分表、冷热数据、租户分片分片键设计、归档、读写隔离

故障域设计的目标是:一个局部问题不应该拖垮全站,一个非核心链路不应该拖垮核心链路,一个租户或热点商品不应该耗尽公共资源。

3.4.3.2 依赖分级

依赖必须分级。否则所有失败都会被当作同等严重,系统就很难降级。

依赖等级定义失败策略
强依赖没有它不能继续业务快速失败、阻断、进入补偿或人工处理
弱依赖失败后可提供降级体验超时后返回默认值、隐藏模块、异步补齐
后置依赖不影响当前用户响应写 Outbox、异步重试、DLQ
观测依赖只影响日志、埋点、统计本地缓冲、采样、失败不阻断主链路

以结算页为例:

  • 商品上下架和库存预占是强依赖。
  • 营销标签、推荐凑单提示是弱依赖。
  • 积分发放、通知、推荐特征回流是后置依赖。
  • 埋点和日志上传是观测依赖。

只有把依赖分级写进设计,超时、重试、熔断、降级才有依据。

3.4.4 高可用架构原则

高可用不是“多部署几个实例”。它是一组贯穿应用、数据、基础设施和组织流程的设计原则。

3.4.4.1 冗余

冗余用于消除单点。

层次常见做法注意事项
应用层多实例、多 Pod、多可用区实例无状态,健康检查真实反映业务可用性
网关层多入口、多 LB、DNS 容灾避免单一入口和错误路由配置
数据层主从、半同步、多副本、备份复制延迟、故障切换、恢复演练
缓存层Redis Cluster、多副本、热点隔离主从切换期间的一致性和热点保护
消息层多 Broker、多分区、多副本ISR、磁盘、消费积压和重平衡
跨地域同城多活、异地灾备数据冲突、流量切换、成本与复杂度

冗余必须配合切换能力。没有演练过的备用链路,不能算可靠性能力。

3.4.4.2 无状态与快速恢复

应用服务尽量无状态。状态外置到数据库、缓存、对象存储或消息系统。这样实例可以快速扩缩容和替换。

但“无状态”不等于“没有状态”。业务状态仍然存在,只是不能绑死在某个进程内。常见问题包括:

  • 本地内存保存用户会话,实例重启后登录态丢失。
  • 本地队列保存待处理任务,进程崩溃后任务丢失。
  • 本地缓存没有版本控制,发布后读到旧规则。

可靠的做法是明确状态归属:什么状态可以本地缓存,什么状态必须持久化,什么状态需要幂等恢复。

3.4.4.3 舱壁隔离

舱壁隔离来自船舱设计:一个舱进水,不能让整条船沉没。

业务系统里的舱壁隔离包括:

  • 不同下游依赖使用不同线程池和连接池。
  • 核心接口和非核心接口使用不同资源池。
  • 后台批任务和在线请求隔离。
  • 大客户、活动、渠道流量隔离。
  • 支付、订单、库存等强一致链路与推荐、画像、报表隔离。

典型反模式是一个应用只有一个全局线程池。推荐服务变慢时,线程池被推荐请求占满,订单请求也无法执行。这种故障不是推荐系统故障,而是资源隔离设计失败。

3.4.4.4 优雅退化

优雅退化不是“随便返回默认值”,而是在业务允许的边界内选择更低质量但可接受的服务。

场景可接受降级不可接受降级
商品详情隐藏推荐、展示基础信息展示已下架商品为可购买
搜索导购默认排序、隐藏个性化标签返回过期价格导致误导购买
购物车部分商品状态显示“稍后刷新”静默丢失用户加购
结算非关键优惠提示缺失跳过库存校验继续下单
支付渠道切换、挂起等待对账未确认支付却标记成功

每个降级策略都应该写清楚:触发条件、用户文案、数据后果、恢复方式、审计记录和负责人。

3.4.5 保护机制:超时、重试、幂等、限流、熔断、降级

保护机制的目标是阻止小故障变成大事故。

3.4.5.1 超时预算

超时不能拍脑袋设置。一个请求的端到端超时应该拆成多个依赖的预算。

例如结算 Init 总超时 2s:

依赖建议预算说明
商品校验150ms批量查询,强依赖
库存预检查300ms强依赖,失败阻断
计价试算500ms强依赖,展示链路可降级,提交链路不可降级
营销试算300ms视业务可部分降级
地址运费300ms可缓存,可部分降级
聚合与序列化200ms应用自身预算

如果每个下游都默认 3s 超时,一个聚合接口就会在故障时被拖成几十秒。超时预算必须从入口向下传递,不能每一层各自为政。

3.4.5.2 重试

重试只适合瞬时失败,不适合所有失败。

错误类型是否重试原因
网络抖动、连接重置可以可能是瞬时问题
读请求超时可以,但要限制次数注意不要放大流量
写请求超时谨慎必须有幂等键或事务状态
参数错误、权限错误不重试重试没有意义
下游容量不足不应立即重试会加剧故障

重试要配合退避和抖动:

第一次失败:等待 50ms
第二次失败:等待 100ms + 随机抖动
第三次失败:等待 200ms + 随机抖动
超过总预算:快速失败

没有退避的重试会形成重试风暴。没有幂等的重试会形成重复副作用。

3.4.5.3 幂等

业务系统里,“只执行一次”通常做不到;“执行多次结果一致”必须做到。

场景幂等手段
创建订单idempotency_key + 唯一索引 + 请求摘要
支付回调渠道流水号唯一约束 + 支付单状态机
消息消费event_id 去重表 + 业务状态条件更新
库存释放预占单状态机 + 释放流水唯一约束
优惠券核销券实例状态机 + 核销单唯一约束

幂等设计要回答四个问题:

  1. 幂等键由谁生成?
  2. 幂等键的作用范围是什么?
  3. 相同幂等键但请求参数不同怎么办?
  4. 幂等记录保存多久,如何清理?

仅仅“前端按钮防抖”不是幂等。真正的幂等必须在服务端和存储层兜底。

3.4.5.4 限流

限流不是为了拒绝用户,而是为了保护系统在极端情况下仍然服务核心请求。

常见限流维度:

  • API 维度:保护单个接口。
  • 用户维度:防止单用户刷爆资源。
  • 商家或租户维度:防止大租户影响小租户。
  • 商品维度:保护热点商品。
  • 活动维度:隔离大促流量。
  • 下游依赖维度:保护数据库、缓存、供应商接口。

限流结果也要分层:

限流对象返回策略
非登录爬虫直接拒绝
普通用户读请求排队或提示稍后重试
下单请求尽量排队,明确失败语义
支付回调不建议简单限流,应快速落表后异步处理
后台批任务降速、暂停、转离线队列

支付回调、库存补偿、对账任务这类恢复链路不能和普通流量共用同一个限流策略,否则事故恢复时反而被限住。

3.4.5.5 熔断

熔断解决的是“下游已经异常,继续调用只会浪费资源”的问题。

熔断通常有三种状态:

状态行为
关闭正常调用下游
打开快速失败或降级,不再打到下游
半开放少量探测流量,成功后恢复,失败后继续打开

熔断触发条件不宜只看错误率,还要看延迟、并发数、超时率和业务错误类型。库存不足不是系统故障,不应该触发熔断;库存服务超时率升高才可能触发熔断。

3.4.5.6 降级

降级要提前设计,而不是事故中临时写代码。

一份合格的降级方案至少包含:

  • 哪些功能可以降级。
  • 什么条件触发降级。
  • 降级后的用户体验是什么。
  • 降级是否会造成数据不一致。
  • 如何恢复。
  • 谁有权限开启。
  • 开启和关闭是否有审计。
  • 是否演练过。

降级开关必须纳入配置治理。不要让任何人可以在没有审批、没有审计、没有回滚方案的情况下打开“允许使用缓存价创单”这类高风险开关。

3.4.6 变更治理:多数生产事故,根子都在变更

生产系统不是运行时自己突然变坏的,更多时候,是某次代码发布、配置修改、数据回填、规则切换或外部依赖接入,把原本潜伏的脆弱性显露出来。很多团队把稳定性问题归咎于“流量太大”或“依赖太差”,但回头复盘会发现,真正点燃事故的火种,往往还是变更。

所以,成熟的生产治理一定把变更当成最高风险动作之一,而不是默认“上线成功就代表变更安全”。一套可执行的变更治理,至少应该包含下面几层:

  • 风险分级:代码发布、配置变更、数据变更、权限变更不能用同一套审批标准。
  • 小步灰度:任何高风险变更都应先在小流量、小地域、小租户范围内验证。
  • 可逆设计:上线前先确认怎么回滚,而不是出事后再讨论能不能回。
  • 冻结机制:错误预算快速消耗、大促期间、关键结算窗口应主动收紧变更。
  • 证据闭环:每一次变更都能追溯到版本、负责人、影响范围和回滚动作。

从实践经验看,下面几类变更尤其危险:

变更类型常见误区推荐治理方式
代码发布以为单测过了就安全灰度、回滚预案、关键路径 watch
配置变更以为“只是改个参数”风险很低schema 校验、双人审批、灰度生效
数据变更以为脚本只跑一次不会出事限速、分批、影子校验、回填回滚
规则变更以为规则平台天然安全版本化、命中监控、误杀恢复
外部接入以为先接上再慢慢治理适配层、隔离池、降级与熔断预案

治理变更的核心不是拖慢研发,而是让团队始终知道:哪些变更在消耗系统信用,哪些变更在透支未来恢复空间。

3.4.7 混沌工程:用有计划的破坏逼出真实问题

很多技术债平时看不见,不是因为它不存在,而是因为系统还没有被真正打到边界。压测能回答“流量大了会怎样”,但不一定能回答“依赖慢了会怎样”“配置错了会怎样”“半数实例异常时系统是否还能自我保护”。这正是混沌工程的价值。

混沌工程不是为了制造戏剧化故障,更不是为了证明团队多勇敢,而是为了在可控范围内提前暴露脆弱点,逼着系统把“理论上的保护机制”变成“现实里真的有效的保护机制”。

一次像样的混沌实验,至少要说清楚四件事:

  1. 这次要验证什么假设。
  2. 影响范围控制在哪里。
  3. 触发后预期的保护行为是什么。
  4. 失败后要沉淀成什么治理动作。

例如:

  • 注入库存服务 30% 超时,验证结算链路是否正确触发限流、降级和熔断。
  • 关闭某个支付渠道回调消费,验证对账和补偿是否能在时间窗口内接管。
  • 模拟推荐服务高延迟,验证是否会挤占订单线程池。
  • 对配置中心推送错误规则到灰度环境,验证审批、审计和回滚是否顺畅。

真正有价值的混沌工程,不是最后写一句“演练完成,系统稳定”,而是逼出具体治理结果,例如:

  • 某个共享线程池必须拆分。
  • 某个补偿任务必须增加幂等键。
  • 某个降级开关必须增加审计和审批。
  • 某条告警必须改成基于业务症状而不是机器指标。

混沌工程的终点,不是“我们敢破坏系统”,而是“我们终于知道系统会怎么坏,并且愿意在真实事故前把这些问题修掉”。

3.5 应急保障篇:现代可观测性与 1-5-10 应急时钟

应急保障的价值,不是证明系统已经足够完美,而是在问题必然发生时,让团队能在最短时间看见问题、切断扩散、恢复核心业务,并为后续治理留下足够的上下文和证据链。

3.5.1 监控系统:从“有图表”到“能救命”

监控系统是可靠性工程里最容易被低估、也最容易做成表面工程的一部分。很多团队有成百上千张图,但事故来临时仍然不知道哪里坏了;有几百条告警,但真正 P0 被淹没在噪声里。

一个成熟的监控系统必须同时满足四个目标:

  1. 发现问题:用户感知异常前后,系统能尽快发现。
  2. 定位问题:能从业务指标追到服务、依赖、资源和代码路径。
  3. 解释问题:能说明影响范围、开始时间、变化点和根因候选。
  4. 推动恢复:告警附带负责人、预案、看板和止血动作。

监控不是运维团队单独负责的事情。业务系统的监控指标必须由业务研发设计,由服务 owner 负责维护。

3.5.1.1 监控分层模型

建议按五层建设监控。

层次目标典型指标
业务监控判断用户和业务是否受影响下单成功率、支付成功率、GMV、转化漏斗、资损金额
链路监控判断关键路径哪一步异常结算 Init、库存预占、计价、创单、支付回调各步耗时和成功率
应用监控判断服务自身是否健康QPS、错误率、P99、线程池、连接池、队列、依赖耗时
中间件监控判断基础组件是否退化MySQL、Redis、Kafka、Elasticsearch、对象存储
基础设施监控判断运行环境是否饱和CPU、内存、磁盘、网络、容器重启、节点压力

这五层要能上下联动。支付成功率下降时,应该能继续下钻到支付回调延迟、支付渠道错误码、数据库锁等待、消息积压和具体版本变更。

3.5.1.2 业务监控

业务监控是最接近用户体验的一层,也是告警优先级最高的一层。

电商系统建议至少建设下面这些业务指标:

业务域核心指标异常含义
商品商品发布成功率、上下架延迟、商品索引同步延迟商品无法发布或前台不可见
搜索搜索成功率、零结果率、召回数量分布、搜索转化率搜索体验异常或索引错误
购物车加购成功率、购物车读取成功率、合并失败率用户意图丢失或体验下降
结算结算 Init 成功率、库存预占失败率、价格试算失败率交易前置链路异常
订单创单成功率、订单状态推进延迟、非法状态迁移数交易主链路异常
支付支付发起成功率、渠道回调成功率、支付单挂起数资金链路异常
库存超卖次数、预占释放延迟、库存对账差异资损或履约风险
供应商同步成功率、数据新鲜度、解析失败率、DLQ 数量外部供给质量异常

业务监控要特别注意分桶。只看全站平均值会掩盖局部故障。常见分桶包括:

  • 地域:国家、城市、机房、可用区。
  • 端:iOS、Android、Web、小程序、开放 API。
  • 品类:实物、酒店、机票、券码、充值。
  • 渠道:自然流量、活动流量、推荐流量、商家后台。
  • 用户类型:新客、老客、会员、大客户、商家。
  • 支付渠道:微信、支付宝、信用卡、余额、第三方渠道。

例如全站支付成功率只下降 0.2%,可能看起来不严重;但如果某个支付渠道在某个国家下降 20%,就是明确事故。

3.5.1.3 应用监控:RED 与 USE

在线服务常用 RED 方法:

指标含义例子
Rate请求速率QPS、每分钟请求数
Errors错误5xx、业务错误码、依赖错误
Duration延迟P50、P95、P99、最大值

资源类组件常用 USE 方法:

指标含义例子
Utilization使用率CPU、内存、磁盘、连接池使用率
Saturation饱和度队列长度、线程池排队、Kafka lag
Errors错误网络错误、磁盘错误、连接失败

对业务服务来说,应用监控至少应包含:

  • 每个 API 的 QPS、成功率、错误码分布。
  • P50、P90、P95、P99、P999 延迟。
  • 入口请求大小、响应大小、批量大小分布。
  • 下游依赖耗时、成功率、超时率、熔断次数。
  • 线程池活跃数、队列长度、拒绝数。
  • 数据库连接池使用率、等待时间、获取失败数。
  • 本地缓存命中率、缓存大小、淘汰数。
  • 消息生产成功率、消费延迟、重试次数。
  • GC、goroutine/thread 数、堆内存、FD 数。

P99 比平均值重要。平均延迟稳定,不代表用户体验稳定。很多事故都是 P99 先升高,然后队列堆积,最后错误率爆炸。

3.5.1.4 中间件监控

中间件监控要关注“是否还能承载业务语义”,而不仅是“组件是否活着”。

MySQL

指标关注点
QPS / TPS流量是否异常
慢 SQL 数量访问模式是否退化
连接数 / 连接池等待应用是否被 DB 卡住
行锁等待 / 死锁事务设计是否有问题
主从延迟读写分离是否会读旧数据
Buffer Pool 命中率内存是否足够
磁盘 IOPS / fsync 延迟存储是否饱和
binlog 延迟与大小数据订阅、恢复能力是否受影响

Redis

指标关注点
QPS / 延迟是否出现热点或网络抖动
命中率缓存是否有效
内存使用 / evicted keys是否正在淘汰关键数据
hot key / big key是否存在局部热点
blocked clients是否有慢命令阻塞
replication lag主从切换是否有数据风险
连接数客户端是否泄漏连接

Kafka

指标关注点
Produce 成功率和耗时上游是否能写入
Consumer lag下游是否跟得上
分区倾斜key 设计是否导致热点
ISR 收缩副本可靠性是否下降
Broker 磁盘水位是否接近不可写
Rebalance 次数消费组是否不稳定
DLQ 数量是否存在无法自动处理的数据

Elasticsearch

指标关注点
查询延迟和错误率搜索体验是否退化
rejected / thread pool queue查询或写入是否过载
JVM heap / GC集群是否接近不稳定
segment 数量merge 压力是否过大
refresh / indexing 延迟索引新鲜度
shard 分布是否存在热点分片
circuit breaker查询是否触发内存保护

中间件指标要和业务指标联动。例如搜索零结果率升高,可能不是 ES 宕机,而是商品索引同步延迟或某个类目 mapping 错误。

3.5.1.5 指标设计规范

指标设计不规范,监控平台很快会变成垃圾场。

命名

建议统一命名:

<domain>_<component>_<metric>_<unit>

示例:

order_create_requests_total
order_create_latency_seconds
payment_callback_failures_total
inventory_reservation_conflicts_total
checkout_init_dependency_latency_seconds

标签

常用标签:

标签示例
serviceorder-service
envprod、staging
regionsg、hk、sh
clusterprimary、canary
endpointCreateOrder
methodGET、POST
resultsuccess、failure
error_codeINVENTORY_TIMEOUT
dependencyinventory-service
biz_typephysical、hotel、voucher

不要把高基数字段放进指标标签,例如:

  • user_id
  • order_id
  • sku_id
  • trace_id
  • request_id

这些字段应该进入日志或追踪,不应该进入 metrics 标签。否则监控系统会被高基数打爆。

直方图桶

延迟指标建议使用 histogram,而不是只记录平均值。桶要贴近业务 SLO。

例如创单延迟 SLO 是 3s,可以设置:

50ms, 100ms, 200ms, 500ms, 1s, 2s, 3s, 5s, 10s

如果桶全是默认值,P99 可能无法准确表达业务目标。

3.5.1.6 日志系统

日志用于回答“这一次具体发生了什么”。

成熟的业务日志应该是结构化的,而不是只打印自然语言。

{
  "timestamp": "2026-05-07T10:30:00+08:00",
  "level": "ERROR",
  "service": "order-service",
  "trace_id": "4f8c9f...",
  "span_id": "a81d...",
  "operation": "CreateOrder",
  "user_id_hash": "u_9d21",
  "order_id": "o_123456",
  "idempotency_key": "idem_abc",
  "error_code": "INVENTORY_RESERVE_TIMEOUT",
  "dependency": "inventory-service",
  "latency_ms": 850,
  "message": "reserve inventory timeout"
}

日志设计要注意:

  • 每条关键链路日志必须包含 trace_id
  • 写操作日志必须包含业务 ID 和幂等键。
  • 错误日志必须包含标准错误码,而不是只打印异常堆栈。
  • 敏感信息要脱敏,例如手机号、证件号、银行卡号。
  • 日志级别要有规范,不能把正常业务失败都打成 ERROR。
  • 采样要谨慎,关键交易失败日志不能被采样丢掉。

日志不是越多越好。无结构、无字段、无错误码的日志越多,排障越慢。

3.5.1.7 链路追踪

链路追踪用于回答“慢在哪里、卡在哪一跳”。

一次完整交易链路可能是:

Gateway
  -> Checkout Service
    -> Product Service
    -> Pricing Service
    -> Inventory Service
    -> Marketing Service
  -> Order Service
    -> MySQL
    -> Outbox
  -> Kafka
    -> Payment Service

追踪系统需要做到:

  • 网关生成或透传 trace_id
  • RPC、HTTP、MQ 都要传递上下文。
  • 每个 Span 标记服务名、方法、依赖、错误码、耗时。
  • 异步消息要把生产端和消费端通过 trace 或 event id 关联。
  • 对慢请求和错误请求提高采样率。

关键 Span 建议包含:

字段示例
biz.operationcheckout_init、create_order、payment_callback
biz.idorder_id、payment_id、checkout_id
dependencyinventory-service
error_codeINVENTORY_TIMEOUT
retry_count2
degradedtrue
idempotency_hitfalse

追踪不要只服务研发排障,也要服务事故复盘。事故时间线中每个关键业务动作都应该能找到对应 trace。

3.5.1.8 告警体系

告警不是“指标超过阈值发消息”。告警的目标是把正确的人,在正确时间,带到正确现场。

告警优先级建议如下:

等级定义示例响应
P0核心业务大面积不可用或有资损风险下单成功率大跌、重复扣款、支付状态错乱立即电话,事故群,负责人到场
P1核心链路局部异常或持续退化某地区支付失败率升高、库存预占超时10 分钟内响应
P2非核心功能异常或有恶化趋势推荐不可用、某批任务失败工作时间处理或值班处理
P3观察项或容量预警磁盘水位增长、连接池接近阈值工单跟进

告警要优先基于症状,而不是原因。

好的 P0 告警:

订单创建成功率 5 分钟内低于 98%,影响全部用户

较差的 P0 告警:

order-service CPU > 80%

CPU 高可能只是正常流量,也可能已经自动扩容;订单成功率下降才是真正用户影响。

3.5.1.9 SLO 燃烧率告警

固定阈值容易误报或漏报。更成熟的方式是错误预算燃烧率告警。

例如某接口 30 天 SLO 是 99.9%,错误预算是 0.1%。如果 5 分钟内错误率达到 5%,虽然只持续很短时间,但它燃烧预算的速度非常快,应立即告警。

常见做法是多窗口组合:

窗口作用
5 分钟快速发现突发事故
30 分钟过滤短暂抖动
2 小时发现持续退化
24 小时发现慢性问题

示例规则:

groups:
  - name: order-slo
    rules:
      - alert: OrderCreateHighBurnRate
        expr: |
          (
            sum(rate(order_create_requests_total{result="failure"}[5m]))
            /
            sum(rate(order_create_requests_total[5m]))
          ) > 0.02
        for: 5m
        labels:
          severity: P0
          service: order-service
        annotations:
          summary: "Order create failure rate is burning error budget fast"
          runbook: "https://runbook.example.com/order-create"

真实生产中还要叠加 30 分钟窗口,避免短暂毛刺直接打爆电话。

3.5.1.10 告警治理

告警治理和代码治理一样重要。没有治理,告警会腐烂。

每条告警都应该有:

  • 明确 owner。
  • 明确严重级别。
  • 明确触发条件。
  • 明确恢复条件。
  • 明确 runbook。
  • 明确看板链接。
  • 明确是否需要夜间唤醒。
  • 明确抑制和去重策略。

告警噪声常见来源:

噪声来源治理方式
阈值过低使用历史基线或分位数
同根因多告警告警聚合和抑制
非业务影响告警夜间唤醒调整严重级别
发布期间短暂波动结合变更事件和灰度窗口
无 owner 告警直接下线或补 ownership
长期无人处理每周告警复盘

一个很实用的指标是:每周每个 on-call 被夜间唤醒次数。如果次数持续过高,团队会进入告警疲劳,真正事故反而没人敏感。

3.5.1.11 看板设计

看板要按排障路径设计,而不是按组件堆图。

推荐四类看板:

业务驾驶舱

  • GMV、订单量、支付成功率。
  • 搜索、加购、结算、创单、支付漏斗。
  • 各端、各地区、各品类分桶。
  • 当前事故和变更事件。

核心链路看板

  • 结算 Init 每一步成功率和耗时。
  • 创单 Saga 每一步成功率和耗时。
  • 支付回调处理延迟。
  • 补偿任务 backlog。

服务看板

  • API QPS、错误率、P99。
  • 下游依赖耗时。
  • 线程池、连接池、队列。
  • 当前版本、实例数、重启次数。

中间件看板

  • DB 慢 SQL、锁等待、主从延迟。
  • Redis 命中率、热点 key、内存淘汰。
  • Kafka lag、DLQ、分区倾斜。
  • ES 查询延迟、rejected、heap、shard 状态。

看板第一屏应该回答三个问题:

  1. 用户是否受影响?
  2. 影响范围多大?
  3. 当前最可疑的故障域在哪里?

不要让 on-call 在事故中从几十张图里猜。

3.5.1.12 监控覆盖检查清单

上线前可以用下面的清单检查监控是否完整:

检查项必须回答
业务成功率核心动作是否有成功率指标
业务延迟用户关键等待路径是否有 P99
错误码是否区分业务失败和系统失败
依赖指标每个下游是否有成功率、耗时、超时
资源饱和线程池、连接池、队列是否可见
数据一致性是否有对账差异、补偿 backlog、DLQ
分桶维度是否能按地区、端、品类、渠道下钻
变更关联看板是否能看到版本、配置、开关变化
告警 owner每条 P0/P1 是否有人负责
Runbook告警是否附带处理步骤

监控系统做到这个程度,才真正有资格说“线上可观测”。

3.5.2 应急响应:先止血,再定位,再恢复

事故处理中最常见的错误,是一开始就追根因。真正的事故响应顺序应该是:

  1. 判断影响范围。
  2. 保护核心链路。
  3. 止血和恢复用户体验。
  4. 保留证据。
  5. 定位根因。
  6. 修复和复盘。

3.5.2.1 事故角色

P0/P1 事故建议明确角色:

角色职责
Incident Commander统一指挥,决定止血动作
Tech Lead技术定位和修复方案
Operator执行切流、回滚、开关、扩容
Communicator同步业务、客服、管理层
Scribe记录时间线和关键决策

事故中最怕多人同时操作、没人记录、没人统一决策。角色清晰比技术高超更重要。

3.5.2.2 常见止血动作

场景止血动作
新版本错误率升高立即回滚或停止灰度
单地域异常摘除地域流量或切到备用区域
下游依赖慢熔断、降级、限制并发
数据库打满限流写入、暂停批任务、扩容只读
Kafka 积压扩消费者、暂停低优先级生产、隔离热点 topic
支付渠道异常切支付渠道、挂起订单、启动渠道对账
商品错误上架下架、冻结库存、停止投放
价格错误关闭活动、冻结订单、通知客服和法务

止血动作要提前演练。事故中临时决定“能不能关这个开关”,通常已经晚了。

3.5.2.3 事故时间线

事故复盘依赖时间线。建议记录:

  • 第一次异常指标出现时间。
  • 第一次告警时间。
  • 人工响应时间。
  • 影响范围变化。
  • 每次操作时间和操作人。
  • 关键判断依据。
  • 恢复时间。
  • 根因确认时间。

没有时间线,复盘会变成各说各话。

3.5.3 容量规划、压测与故障演练

容量规划不是“机器够不够”,而是“业务增长和流量模型变化时,核心链路是否还能维持 SLO”。

3.5.3.1 容量估算

容量估算从业务漏斗开始。

日活用户 -> 高峰活跃 -> 商品浏览 -> 加购 -> 结算 -> 下单 -> 支付

需要估算:

  • 峰值 QPS。
  • 并发请求数。
  • 数据写入量。
  • 热点 key 和热点商品。
  • 异步消息量。
  • 重试和补偿流量。
  • 外部依赖容量。

大促系统尤其要关注流量形态。日常流量通常平滑,大促流量可能在秒级集中爆发。平均 QPS 对大促没有意义。

3.5.3.2 压测

压测要验证三件事:

  1. 系统上限在哪里。
  2. 退化方式是否符合预期。
  3. 保护机制是否生效。

压测类型:

类型目标
单服务压测找服务自身瓶颈
全链路压测验证端到端能力
影子流量用真实流量分布验证读链路
限流压测验证限流阈值和用户体验
降级压测验证弱依赖失败时主链路是否稳定
恢复压测验证故障恢复后队列和补偿是否追平

压测报告不要只写“峰值 QPS”。至少要包含:

  • 安全水位。
  • 瓶颈资源。
  • P99 曲线。
  • 错误率拐点。
  • 下游依赖压力。
  • 扩容建议。
  • 降级开关验证结果。

3.5.3.3 故障演练

故障演练比压测更接近真实事故。

常见演练:

  • 关闭一个应用实例。
  • 让某个下游延迟升高。
  • 让 Redis 热 key 失效。
  • 让 Kafka 消费者停止。
  • 让支付渠道回调延迟。
  • 让数据库只读延迟升高。
  • 灰度版本返回错误码。
  • 配置中心下发错误配置并回滚。

演练后要复盘:

  • 告警是否及时。
  • 值班是否知道怎么处理。
  • 开关是否可用。
  • 回滚是否顺畅。
  • 数据是否恢复一致。
  • 文档是否准确。

未演练的预案,不应被视为可靠性能力。

3.5.4 数据恢复的底线能力:对账、补偿、备份与恢复

业务系统的可靠性最终会落到数据上。服务恢复但数据错了,事故并没有结束。

数据可靠性包括:

能力目标
幂等防止重复副作用
状态机防止非法状态推进
Outbox保证本地事务和消息发送可恢复
对账发现跨系统不一致
补偿自动或人工修复不一致
DLQ隔离无法自动处理的数据
备份防止数据不可逆丢失
恢复演练确认备份真的能恢复

备份不是可靠性终点,恢复才是。很多团队有备份,但从未演练恢复;真正事故发生时才发现备份缺字段、权限不可用、恢复耗时不可接受。

关键业务数据至少要定期演练:

  • 单表误删恢复。
  • 单订单修复。
  • 按时间点恢复。
  • 跨系统对账修正。
  • 消息重放。
  • 补偿任务重跑。

数据修复必须有审计。任何人工修复都应该记录操作人、原因、SQL 或工具参数、影响行数、审批单和验证结果。

3.5.5 发布可靠性:多数事故来自变更

线上事故很大比例来自变更:代码发布、配置变更、数据迁移、活动配置、扩容缩容、依赖升级。

发布可靠性要控制三个问题:

  1. 变更前能否发现风险。
  2. 变更中能否限制影响。
  3. 变更后能否快速回滚。

3.5.5.1 灰度发布

灰度发布建议按层推进:

开发环境 -> 测试环境 -> 预发环境 -> 单实例灰度 -> 小流量灰度 -> 分区域灰度 -> 全量

灰度期间必须观察:

  • 错误率。
  • P99 延迟。
  • 业务成功率。
  • 下游依赖耗时。
  • 新旧版本差异。
  • 日志错误码。

如果灰度只看进程是否启动成功,等于没有灰度。

3.5.5.2 配置变更

配置变更和代码发布一样危险,甚至更危险,因为它通常绕过测试。

高风险配置包括:

  • 限流阈值。
  • 降级开关。
  • 价格规则。
  • 营销规则。
  • 路由规则。
  • 支付渠道权重。
  • 分库分表配置。
  • 库存策略。

配置中心至少应支持:

  • 变更审批。
  • 灰度下发。
  • 版本记录。
  • 一键回滚。
  • 生效范围展示。
  • 变更事件写入监控和日志。

3.5.5.3 数据库变更

数据库变更尤其要谨慎。

常见原则:

  • 先兼容旧代码,再发布新代码,最后清理旧字段。
  • 大表 DDL 使用在线变更工具。
  • 回填任务限速,避免打爆主库。
  • 回填有断点续跑和校验。
  • 删除字段前至少观察一个版本周期。
  • 变更前准备回滚和恢复方案。

很多事故不是 SQL 写错,而是回填任务没有限速,导致主库延迟和线上接口超时。

3.6 数据与资金安全篇:恢复、对账、补偿与资损防控

业务系统真正困难的地方,不是让每一次调用都成功,而是在调用失败、消息丢失、回调乱序、数据延迟、人工误操作和外部系统不可控时,仍然能把业务状态拉回正确轨道。

电商系统尤其如此。一次下单会穿过商品、库存、营销、计价、订单、支付、履约、供应商、搜索、财务等多个系统。任何一个系统都只能保证自己局部正确,却很难在同一个事务里保证全链路同时成功。于是,系统必须接受一个事实:短时间不一致不可避免,长期不收敛不可接受

对账、补偿、DLQ 和故障恢复,就是让系统从“偶发错误靠人肉排查”走向“差异可发现、失败可隔离、修复可执行、过程可审计”的工程体系。

本章会以电商系统为主线,回答几个关键问题:

  1. 什么情况下必须做对账,谁是权威数据源?
  2. 对账发现差异后,如何生成安全的补偿任务?
  3. 什么错误应该自动重试,什么错误应该进入 DLQ?
  4. DLQ 为什么不能只是 Kafka 的死信 Topic?
  5. 支付成功但订单未更新、库存扣了但订单失败、供应商同步一半失败,应该如何恢复?
  6. 如何设计后台、监控、审计和人工介入流程,让故障恢复真正可运营?

3.6.1 先建立一个判断:恢复能力是主链路的一部分

很多团队把对账和补偿当作“上线后再补”的兜底脚本。这是危险的。业务系统上线第一天,就应该具备基本恢复能力。

因为故障不是例外,而是分布式协作的常态:

故障形态电商例子如果没有恢复能力
请求超时库存预占接口超时,但下游实际预占成功订单失败,库存长期占用
回调丢失支付渠道扣款成功,回调没有送达用户已扣款,订单仍待支付
消息失败商品已发布,搜索索引事件消费失败前台搜索不到商品或仍展示下架商品
消费乱序先收到订单取消,再收到支付成功事件状态机被错误推进
部分成功订单创建成功,优惠券锁定失败用户看到订单异常,券状态不一致
外部延迟供应商库存更新延迟 10 分钟平台继续售卖已售罄商品
人工误操作运营错误调整价格或库存需要回滚、冻结和审计

可靠的系统不是没有这些问题,而是问题出现后能够自动识别、自动收敛或可控地交给人工处理。

这一章讨论的对象不是单个技术组件,而是一条恢复闭环:

业务事实产生
  -> 事件投递 / 状态推进 / 外部交互
  -> 差异出现
  -> 对账发现差异
  -> 差异分类
  -> 自动补偿或进入 DLQ
  -> 人工修复或重新投递
  -> 再次对账确认收敛
  -> 形成审计记录和复盘材料

如果这个闭环缺任何一环,系统都会变成“线上看起来能跑,但出了问题谁也说不清”。

3.6.2 四个核心概念

3.6.2.1 对账

对账是用一个或多个权威数据源,比对另一个系统中的派生状态、投影状态或外部状态,发现差异并形成可处理的差异项。

对账不是财务系统专属概念。只要存在下面任意一种情况,就需要对账:

  • 跨系统异步同步。
  • 事件驱动的最终一致。
  • 本地系统与外部渠道协作。
  • 缓存、索引、读模型与主数据分离。
  • 主链路为了性能接受短窗口不一致。
  • 需要审计、追溯和证明业务结果。

例如:

对账场景权威数据源被对账对象常见差异
支付对账支付渠道账单、银行流水本地支付单、订单状态渠道成功,本地未支付
库存对账库存流水账本库存余额快照、Redis 库存流水扣减成功,余额未刷新
订单对账订单状态日志、支付事实订单主状态、履约状态支付成功但订单未推进
商品对账商品正式表、发布版本搜索索引、缓存、推荐特征商品下架但搜索仍可见
营销对账券实例状态、核销流水订单优惠快照、用户券包订单取消但券未释放
供应商对账供应商接口或文件平台商品、库存、价格供应商下架但平台仍售卖
消息对账Outbox 表Kafka 消费进度、下游处理表事件已生成但未被消费

对账的本质是:用更可信的事实校准更容易漂移的状态

3.6.2.2 补偿

补偿是针对已发现的不一致,执行一个可幂等、可重试、可审计的修复动作,让系统重新收敛。

补偿不是“反向操作”这么简单。真实业务里,补偿动作要遵守状态机、资金规则、库存规则和用户体验边界。

例如:

  • 库存预占成功但订单创建失败,可以释放库存。
  • 支付成功但订单仍待支付,应推进订单到已支付,而不是退款。
  • 订单已取消后又收到支付成功,可能要进入退款或人工确认,而不是直接把订单改回已支付。
  • 商品已下架但搜索仍展示,应删除搜索文档或标记不可售。
  • 供应商酒店价格异常,可能不能自动覆盖,要进入人工审核。

补偿的核心原则是:修复业务结果,而不是机械回滚技术动作

3.6.2.3 DLQ

DLQ 是 Dead Letter Queue,死信队列。但在业务系统里,DLQ 不应该只是 MQ 里的失败 Topic。

对于电商系统,失败数据往往需要查询、筛选、分派、修复、重新投递、忽略、审计和统计。Kafka DLQ 可以保留失败消息,但它不适合承担运营治理主存储。

更成熟的做法是:

Kafka DLQ:短期失败消息缓冲,可选
MySQL DLQ:权威问题单、处理状态、修复入口和审计台账
对象存储:保存大 payload、原始文件、渠道账单、供应商原始响应
运营后台:提供筛选、修复、重放、忽略、导出、审批

所以本章提到 DLQ 时,重点指“可运营的死信治理体系”,不只是消息中间件里的一个 Topic。

3.6.2.4 故障恢复

故障恢复是比对账、补偿、DLQ 更大的概念。它覆盖请求失败、任务失败、数据不一致、系统宕机、灾备切换和人工修复。

可以分为四层:

层次目标常见手段
请求级恢复单次请求失败后快速返回或安全重试超时、重试、幂等键、状态查询
任务级恢复异步任务失败后继续推进重试队列、补偿任务、Checkpoint、Worker Lease
数据级恢复多系统数据重新收敛对账、重放、差异修复、人工工单
系统级恢复服务或机房故障后恢复业务能力回滚、切流、降级、灾备、数据恢复

成熟系统不会把恢复责任压在某一层。只靠请求重试会放大故障,只靠夜间对账会延长用户影响,只靠人工工单会拖垮团队。

3.6.3 设计对账前,先定义“事实”和“口径”

对账最容易失败的原因,不是 SQL 写错,而是团队没有统一“谁说了算”。

3.6.3.1 权威数据源

不同业务域的权威事实不同:

业务对象权威事实不应作为权威的对象
支付是否成功渠道交易流水、本地支付单状态机前端支付页结果、同步预下单返回
订单是否有效订单主状态 + 状态变更日志搜索索引、客服备注
库存是否扣减库存流水账本、预占记录、确认记录Redis 当前值、前台展示库存
券是否核销券实例状态机、核销流水订单优惠展示字段
商品是否可售商品发布版本、可售规则、库存状态ES 文档、缓存详情页
供应商是否有库存供应商实时查询或最近一次有效同步平台缓存库存

权威数据源也不是永远固定的。资金事实通常以渠道和银行为准;业务状态以本地状态机为准;履约状态可能以供应商确认为准;财务入账以总账系统为准。对账系统要明确每类差异的裁决规则。

3.6.3.2 业务口径

同一个字段在不同系统里可能含义不同。

以支付为例:

字段可能的口径差异
paid_amount用户实付、渠道扣款、平台到账、扣除手续费后金额
paid_time用户确认时间、渠道扣款时间、回调到达时间、本地入库时间
success渠道受理成功、扣款成功、清算成功、可结算成功
refund_amount申请退款金额、渠道退款金额、实际到账金额

如果这些口径不统一,对账会不断制造“假差异”。因此重要字段必须有数据字典,并在系统边界上写清楚。

3.6.3.3 差异容忍窗口

最终一致系统允许短时间不一致。对账不能把所有延迟都当事故。

场景合理容忍窗口说明
支付回调到订单推进秒级到分钟级回调、Outbox、消费者可能有短暂延迟
商品发布到搜索可见分钟级索引刷新和缓存刷新需要时间
库存 Redis 与 MySQL 快照秒级到分钟级热路径与账本路径可能异步
供应商库存同步分钟级到小时级取决于供应商接口能力和品类风险
财务清结算T+1 或 T+N取决于渠道账单周期

对账规则必须写清“多长时间内算正常延迟,多长时间后算差异”。否则系统会在高峰期产生大量误报。

3.6.4 对账系统的分层设计

一套通用对账系统通常包含五层。

层次职责电商例子
数据采集层拉取权威数据和被对账数据渠道账单、订单表、库存流水、供应商文件
标准化层把异构数据转成统一模型金额单位、币种、状态枚举、时间格式
比对层执行 Join、聚合、恒等式校验支付单和渠道流水外连接
差异层生成差异项并分类长款、短款、状态不一致、缺失记录
处置层自动补偿、人工工单、忽略、复核推进订单、退款、释放库存、重建索引

不要把对账写成一个巨大定时脚本。脚本可以作为第一版实现,但架构上仍应具备批次、明细、差异状态、处理动作和审计。

3.6.4.1 对账批次

每次对账都应该有批次记录。

CREATE TABLE recon_batch (
    id BIGINT PRIMARY KEY,
    recon_type VARCHAR(64) NOT NULL,
    biz_date DATE NOT NULL,
    source_system VARCHAR(64) NOT NULL,
    target_system VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL,
    total_count BIGINT NOT NULL DEFAULT 0,
    matched_count BIGINT NOT NULL DEFAULT 0,
    diff_count BIGINT NOT NULL DEFAULT 0,
    auto_fixed_count BIGINT NOT NULL DEFAULT 0,
    manual_required_count BIGINT NOT NULL DEFAULT 0,
    started_at DATETIME NOT NULL,
    finished_at DATETIME NULL,
    error_code VARCHAR(64) NULL,
    error_message VARCHAR(512) NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_recon_batch (recon_type, biz_date, source_system, target_system)
);

批次的价值是可追溯:今天对了什么,是否完成,发现多少差异,自动修复多少,还剩多少需要人工处理。

3.6.4.2 对账明细和差异项

差异项应单独建表,而不是只打日志。

CREATE TABLE recon_diff (
    id BIGINT PRIMARY KEY,
    batch_id BIGINT NOT NULL,
    recon_type VARCHAR(64) NOT NULL,
    biz_id VARCHAR(128) NOT NULL,
    biz_sub_id VARCHAR(128) NULL,
    diff_type VARCHAR(64) NOT NULL,
    severity VARCHAR(32) NOT NULL,
    source_snapshot JSON NOT NULL,
    target_snapshot JSON NOT NULL,
    expected_action VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL,
    compensation_task_id BIGINT NULL,
    owner VARCHAR(64) NULL,
    first_seen_at DATETIME NOT NULL,
    last_seen_at DATETIME NOT NULL,
    resolved_at DATETIME NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_diff_dedup (recon_type, biz_id, biz_sub_id, diff_type)
);

几个关键点:

  • source_snapshottarget_snapshot 保存比对时的证据。
  • diff_type 用于分类,不要只写自然语言。
  • expected_action 说明建议处理动作。
  • status 管理差异生命周期,不能靠“有没有日志”判断是否处理。
  • 唯一键用于差异去重,避免同一问题每天生成一堆新差异。

3.6.4.3 差异分类

差异分类决定能否自动修复。

差异类型示例推荐动作
缺本地记录渠道有成功支付,本地无支付单人工确认或补建支付单,风险高
缺外部记录本地支付成功,渠道账单无记录查单复核,可能短款
状态不一致渠道成功,本地支付中自动查单后推进状态
金额不一致本地 100 元,渠道 99 元人工复核,禁止自动改账
时间延迟订单已支付,履约未开始超窗口后生成补偿
投影缺失商品已发布,ES 无文档自动重建索引
重复记录同一渠道流水命中多笔本地单P0 资损风险,人工冻结

原则上,资金金额差异、重复扣款、重复退款、跨用户归属错误,都不应该直接自动修复。可以自动生成工单、冻结风险对象、通知负责人,但不能让补偿程序“自作主张”。

3.6.5 电商核心场景对账

3.6.5.1 支付对账

支付对账是最典型、也最不能偷懒的对账场景。

核心数据源包括:

数据源作用
渠道账单外部扣款和退款事实
本地支付单平台支付状态机
支付渠道交互日志请求、回调、查单证据
订单表用户侧交易状态
财务分录入账和清结算口径

支付对账常见差异:

差异含义处理
渠道成功,本地支付中回调丢失或处理失败主动查单,推进支付单和订单
渠道成功,本地失败本地状态错误或晚到成功进入人工复核,可能需要补发事件
本地成功,渠道无成功记录本地误判成功或账单延迟查单复核,超窗口转人工
渠道退款成功,本地退款中退款回调丢失推进退款单,通知订单售后
金额不一致资损风险冻结差异项,人工复核

支付对账的推荐流程:

T+1 拉取渠道账单
  -> 校验文件完整性和签名
  -> 标准化为 ChannelTradeRow
  -> 与 payment_channel_log / payment_order 外连接
  -> 生成 recon_diff
  -> 对低风险状态差异自动查单
  -> 查单确认后生成 compensation_task
  -> 补偿推进支付单、订单、Outbox
  -> 高风险金额差异进入人工复核

注意:支付对账的自动补偿应该优先“推进状态”,而不是直接“改金额”。金额差异必须谨慎,因为它会影响财务、税务、商家结算和用户权益。

3.6.5.2 订单对账

订单是业务承诺的载体。订单对账重点不是“表里有没有数据”,而是订单状态是否和周边事实一致。

订单状态需要对齐的事实
待支付支付单应未成功,库存和营销资源仍在锁定窗口内
已支付支付成功事实存在,库存确认或履约创建应已触发
已取消支付未成功或已退款,库存和券应释放
已发货 / 履约中履约单或供应商订单应存在
已完成履约完成、售后窗口和财务确认满足规则

典型对账规则:

  • 支付单成功超过 1 分钟,订单仍待支付,生成订单推进补偿。
  • 订单取消超过 5 分钟,库存预占仍未释放,生成库存释放补偿。
  • 订单已支付超过 10 分钟,履约单未创建,生成履约补偿。
  • 订单已退款,营销券仍显示已核销,生成营销释放或人工修复。
  • 主单完成,但子单仍处于中间态,生成状态修复任务。

订单对账必须尊重状态机。不能因为“支付成功”就无条件把订单改成“已支付”。如果订单已经取消、退款、关闭或进入风控冻结,补偿动作应该进入人工确认或走专门的反向流程。

3.6.5.3 库存对账

库存对账的目标是保证“可售、预占、已售、释放、作废”这些状态最终符合账本。

一个常见库存恒等式:

total = available + reserved + sold + locked + damaged

如果使用 Redis 做热路径扣减,还需要额外比较:

MySQL 可售快照
  vs Redis 可售值
  vs 库存流水聚合
  vs 订单预占状态

典型差异:

差异可能原因处理
Redis 少于 MySQL扣减成功但落库失败、回填失败以账本为准重建 Redis
Redis 多于 MySQL释放重复、缓存覆盖错误冻结商品,按账本修复
预占超时未释放关单任务失败补偿释放预占
已支付订单库存仍 reserved支付确认事件消费失败补偿确认 sold
流水聚合不等于余额人工改库或程序缺陷停止自动修复,人工排查

库存对账最重要的原则是:Redis 不是账本。故障恢复时应以 MySQL 账本、预占记录、订单状态和审计日志为准,Redis 可以重建。

3.6.5.4 商品与搜索对账

商品系统经常采用“主数据 + 读模型”架构。正式商品表是权威数据,搜索索引、商品详情缓存、推荐特征都是投影。

常见对账规则:

  • 商品已下架,ES 仍可搜索到,删除或标记不可售。
  • 商品已发布,ES 缺文档,重建索引。
  • 商品价格版本已更新,详情页缓存仍是旧版本,刷新缓存。
  • 商品可售状态依赖库存和履约规则,搜索文档的可售标记过期,重新投影。
  • 供应商商品被标记风险,前台仍展示,强制下架并告警。

商品投影对账通常可以自动修复,因为它不改变主数据,只重建派生视图。但如果发现主数据本身质量异常,例如类目映射错误、酒店坐标漂移、价格异常波动,则应进入质量问题单或 DLQ,而不是自动发布。

3.6.5.5 供应商同步对账

供应商同步是电商系统里最容易产生长尾差异的地方。供应商接口可能限流、超时、字段缺失、分页游标异常、同一商品编码复用、价格库存延迟更新。

供应商对账要区分三类事实:

事实说明
Raw Snapshot供应商原始响应,便于追溯和重放
Standardized Model平台标准化后的中间模型
Published Version已发布到交易链路的正式版本

同步成功不等于可以发布。一个酒店从供应商拉取成功,但城市映射失败、经纬度漂移、价格低于风险阈值、房型缺关键字段,都不应该直接进入正式商品。

典型处理:

  • 网络超时:指数退避重试。
  • 供应商 5xx:限流、熔断、延迟重试。
  • 字段缺失:进入 MySQL DLQ,等待供应商或运营修复。
  • 城市映射失败:进入映射工单。
  • 价格异常:进入审核,不自动发布。
  • 下游索引刷新失败:生成投影补偿。

供应商同步的恢复能力通常依赖 Task + Checkpoint + Worker Lease + Raw Snapshot + DLQ。没有这些能力,长任务一旦中断,就只能从头跑,或者靠人工猜进度。

3.6.6 补偿任务设计

补偿任务不是简单的定时重试。它是一个小型工作流系统。

3.6.6.1 补偿任务模型

CREATE TABLE compensation_task (
    id BIGINT PRIMARY KEY,
    task_type VARCHAR(64) NOT NULL,
    biz_type VARCHAR(64) NOT NULL,
    biz_id VARCHAR(128) NOT NULL,
    biz_sub_id VARCHAR(128) NULL,
    priority INT NOT NULL DEFAULT 0,
    status VARCHAR(32) NOT NULL,
    idempotency_key VARCHAR(128) NOT NULL,
    request_payload JSON NOT NULL,
    context_snapshot JSON NOT NULL,
    retry_count INT NOT NULL DEFAULT 0,
    max_retry_count INT NOT NULL DEFAULT 10,
    next_retry_at DATETIME NOT NULL,
    last_error_code VARCHAR(64) NULL,
    last_error_message VARCHAR(512) NULL,
    locked_by VARCHAR(128) NULL,
    locked_until DATETIME NULL,
    source_type VARCHAR(64) NOT NULL,
    source_id BIGINT NULL,
    manual_required TINYINT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_comp_idem (task_type, idempotency_key),
    KEY idx_comp_schedule (status, next_retry_at, priority),
    KEY idx_comp_biz (biz_type, biz_id)
);

字段说明:

字段设计目的
task_type区分支付查单、订单推进、库存释放、索引重建等动作
biz_type / biz_id关联业务对象,便于客服和运营检索
idempotency_key防止同一差异生成多个重复补偿
context_snapshot保存补偿创建时的上下文证据
source_type / source_id关联来源,例如 recon_diff、dlq、manual
locked_by / locked_until支持多 Worker 抢占和故障恢复
manual_required标记是否需要人工审批或处理

补偿任务必须能回答:为什么生成、要修什么、修复前证据是什么、谁执行过、执行结果如何。

3.6.6.2 补偿状态机

建议状态机保持简单:

PENDING
  -> RUNNING
  -> SUCCESS
  -> RETRY_WAIT
  -> MANUAL_REQUIRED
  -> DLQ
  -> CANCELLED

状态含义:

状态含义
PENDING等待执行
RUNNINGWorker 正在执行
RETRY_WAIT可重试失败,等待下一次执行
SUCCESS补偿完成,并通过必要校验
MANUAL_REQUIRED需要人工判断或审批
DLQ自动补偿已无法继续,转入死信治理
CANCELLED差异已消失或任务被人工取消

不要让任务在 RUNNING 状态永久停留。Worker 必须使用租约,超时后允许其他 Worker 接管。

3.6.6.3 重试策略

补偿重试要区分错误类型。

错误类型示例策略
瞬时错误网络抖动、连接重置指数退避重试
下游限流渠道 QPS 超限、供应商 429降低并发,延后重试
数据未就绪订单刚创建,支付单尚未落库短延迟重试
业务冲突状态机不允许推进转人工或重新对账
参数错误商品 ID 不存在、字段格式错误进入 DLQ
资损风险金额不一致、重复扣款冻结并人工复核

一个常见退避策略:

第 1 次:30 秒后重试
第 2 次:2 分钟后重试
第 3 次:10 分钟后重试
第 4 次:30 分钟后重试
第 5 次:2 小时后重试
超过阈值:MANUAL_REQUIRED 或 DLQ

不要无上限重试。无上限重试会掩盖真实问题,打爆下游,也会让补偿队列永远无法清空。

3.6.6.4 Worker 执行原则

补偿 Worker 应该遵守这些原则:

  1. 先抢租约,再执行任务。
  2. 执行前重新读取业务对象最新状态。
  3. 判断差异是否仍然存在。
  4. 调用幂等接口或执行条件更新。
  5. 执行后验证结果。
  6. 写补偿日志和审计记录。
  7. 失败时按错误分类更新下一次动作。

伪流程:

扫描 PENDING / RETRY_WAIT 且 next_retry_at <= now 的任务
  -> CAS 抢占 locked_by / locked_until
  -> 读取任务和业务对象
  -> 如果差异已消失,标记 SUCCESS 或 CANCELLED
  -> 如果状态机不允许自动修复,标记 MANUAL_REQUIRED
  -> 执行幂等补偿动作
  -> 校验修复结果
  -> 成功:标记 SUCCESS
  -> 可重试失败:计算 next_retry_at,标记 RETRY_WAIT
  -> 不可重试失败:写入 DLQ

3.6.6.5 补偿接口要幂等

补偿任务本身会重试,也可能被人工重放。因此所有补偿动作都必须幂等。

补偿动作幂等设计
推进订单已支付order_id + paid_event_id 唯一约束,状态条件更新
释放库存预占reservation_id + release_reason 唯一流水,状态机从 reserved 到 released
确认库存已售reservation_id 只能确认一次
释放优惠券coupon_lock_id 状态机,重复释放直接返回成功
重建搜索索引item_id + publish_version 覆盖式写入
支付查单payment_id 查询不产生副作用,结果推进用状态机
供应商重投递task_item_id + retry_round 去重,保留原始 payload 引用

判断幂等是否合格,可以问一句:同一补偿任务执行 10 次,业务结果是否仍然正确?

3.6.6.6 补偿不是万能的

有些差异不应该自动补偿。

场景原因
金额不一致可能涉及资损、税务、分账
重复支付需要确认是否退款、是否合并订单
跨用户归属错误可能是严重数据串号
商品价格异常可能需要法务和运营判断
供应商确认失败但用户已支付需要客服、退款或替代履约策略
人工 SQL 导致状态错乱需要冻结现场,防止越修越错

自动化的边界必须清楚。好的补偿系统不是“什么都自动修”,而是低风险自动收敛,高风险快速冻结、分派和审计。

3.6.7 DLQ 设计:从失败消息到可运营问题单

3.6.7.1 什么应该进入 DLQ

进入 DLQ 的前提是:系统判断当前数据无法继续正常自动处理,或者继续重试会产生更高风险。

常见进入 DLQ 的原因:

类型例子是否可自动恢复
格式错误消息 JSON 无法解析、字段类型不兼容通常不可
业务对象缺失订单不存在、商品不存在可能需要等待或人工
状态冲突订单已取消但收到支付成功事件需要业务判断
下游持续失败重试超过阈值可能稍后恢复
数据质量问题供应商城市映射失败、价格异常通常需要人工
幂等冲突同一幂等键参数不同高风险,人工
安全风险验签失败、来源不可信不应重放

不要把所有失败都立即丢进 DLQ。瞬时网络失败应该先重试;明确不可恢复的数据问题才应进入 DLQ。

3.6.7.2 MySQL DLQ 表

业务 DLQ 推荐落 MySQL 或其他可查询事务存储,作为权威问题单。

CREATE TABLE dead_letter_item (
    id BIGINT PRIMARY KEY,
    dlq_type VARCHAR(64) NOT NULL,
    biz_type VARCHAR(64) NOT NULL,
    biz_id VARCHAR(128) NOT NULL,
    stage VARCHAR(64) NOT NULL,
    error_code VARCHAR(64) NOT NULL,
    error_message VARCHAR(512) NOT NULL,
    retryable TINYINT NOT NULL DEFAULT 0,
    severity VARCHAR(32) NOT NULL,
    status VARCHAR(32) NOT NULL,
    payload_ref VARCHAR(512) NULL,
    payload_digest VARCHAR(128) NOT NULL,
    context_snapshot JSON NOT NULL,
    retry_count INT NOT NULL DEFAULT 0,
    next_retry_at DATETIME NULL,
    owner VARCHAR(64) NULL,
    resolved_by VARCHAR(64) NULL,
    resolved_at DATETIME NULL,
    resolution VARCHAR(64) NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_dlq_dedup (dlq_type, biz_type, biz_id, stage, error_code, payload_digest),
    KEY idx_dlq_status (status, severity, created_at),
    KEY idx_dlq_biz (biz_type, biz_id)
);

设计要点:

  • 大 payload 不要直接塞主表,保存对象存储引用和摘要。
  • payload_digest 用于去重和防篡改。
  • stage 表示失败阶段,例如 PARSEVALIDATEPUBLISHCONSUMECALLBACK
  • resolution 表示处理结果,例如 REPLAYEDIGNOREDMANUAL_FIXEDREFUNDED
  • 所有人工动作必须写审计日志。

3.6.7.3 DLQ 状态机

OPEN
  -> ASSIGNED
  -> RETRYING
  -> RESOLVED
  -> IGNORED
  -> ESCALATED
状态含义
OPEN新进入,等待处理
ASSIGNED已分派给 owner
RETRYING正在自动或人工重放
RESOLVED已修复并验证
IGNORED确认为无需处理,必须填写原因
ESCALATED升级到事故、客服、财务或供应商

DLQ 的“忽略”能力很重要,但必须有权限和审计。没有忽略能力,队列会永远堆积;忽略没有审计,又会变成掩盖问题的洞。

3.6.7.4 Kafka DLQ 与 MySQL DLQ 的边界

能力Kafka DLQMySQL DLQ
保留失败消息
顺序回放
运营查询
分派处理
人工修复
审计报表
大规模吞吐
问题单生命周期

推荐组合:

消息消费失败
  -> 可重试错误:延迟重试 Topic
  -> 超过阈值:写 Kafka DLQ 保留原消息
  -> 同时写 MySQL DLQ 作为问题单
  -> 运营或系统修复后,从 MySQL 触发重新投递
  -> 需要原始消息时,从 Kafka / 对象存储读取 payload

3.6.7.5 重新投递要有护栏

DLQ 重新投递不能只是一个按钮。

重放前必须检查:

  • 原始 payload 是否完整。
  • 消费代码是否已经修复。
  • 业务对象当前状态是否允许重放。
  • 是否会重复扣款、重复发券、重复扣库存。
  • 是否需要灰度重放。
  • 是否需要限速。
  • 是否需要审批。

重放策略:

策略使用场景
单条重放高风险订单、支付、退款
批量限速重放商品索引、缓存刷新
按错误码重放已修复某类解析错误
按时间窗口重放某次发布造成的失败
影子重放先验证新代码能否处理,不写正式状态

高风险 DLQ 应该要求双人审批。尤其是支付、退款、库存和营销权益,不能让一个人随手批量重放。

3.6.8 Outbox、事件重放与对账的关系

Outbox 解决的是“本地事务成功后,事件可靠发出去”的问题。对账解决的是“事件发出去之后,下游最终有没有处理成功”的问题。

两者不能互相替代。

能力解决问题不能解决
Outbox本地数据和事件生成同事务下游消费失败、业务处理失败
MQ 重试临时消费失败不可重试数据错误
DLQ隔离无法处理的数据自动判断业务真相
对账发现最终状态差异实时阻止所有错误
补偿修复已发现差异替代主链路正确性

一个电商订单支付成功事件的可靠链路应该是:

支付回调落库
  -> 支付单状态机推进为 SUCCESS
  -> 同事务写 payment_outbox_event
  -> Outbox Relay 投递 PaymentSucceeded
  -> 订单服务消费事件,幂等推进订单
  -> 履约、库存、营销继续消费订单事件
  -> 对账任务扫描支付成功但订单未支付
  -> 发现差异后生成订单推进补偿

即使 Outbox 做得很好,对账仍然必要。因为下游可能消费失败、业务状态冲突、代码 Bug、数据污染或人工误操作。

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

这是电商系统最典型的恢复案例。

3.6.9.1 故障过程

用户提交支付
  -> 支付渠道扣款成功
  -> 渠道回调支付服务
  -> 支付服务验签成功,更新支付单 SUCCESS
  -> 写 Outbox 事件 PaymentSucceeded
  -> 订单服务消费事件时数据库超时
  -> MQ 重试多次失败
  -> 消息进入 DLQ
  -> 用户订单仍显示待支付

用户看到的是“钱扣了,订单没变”。这类问题必须优先处理,因为它直接影响信任。

3.6.9.2 发现方式

至少应该有三条发现路径:

  1. 实时链路告警:支付成功事件消费失败率升高。
  2. 近实时对账:支付成功超过 1 分钟,订单仍待支付。
  3. T+1 渠道对账:渠道成功,本地订单未完成。

只靠用户投诉是不合格的。

3.6.9.3 自动补偿策略

低风险情况下,可以自动推进订单:

前置条件判断
支付单为 SUCCESS
渠道流水验签和金额正确
订单仍处于 PendingPay
订单未取消、未退款、未风控冻结
支付金额等于订单应付金额
幂等事件未处理成功

补偿动作:

创建 compensation_task: ADVANCE_ORDER_PAID
  -> 读取订单和支付单最新状态
  -> 校验金额、币种、支付单归属
  -> CAS 更新订单 PendingPay -> Paid
  -> 写订单状态日志
  -> 写 OrderPaid Outbox
  -> 标记补偿成功
  -> 对账差异改为 RESOLVED

如果订单已取消,就不能简单推进。需要判断取消原因和支付时间:

情况处理
支付发生在取消前可能是状态处理延迟,人工确认
支付发生在取消后通常发起退款或人工确认
订单已风控冻结进入风控工单
金额不一致进入财务差异工单

3.6.9.4 用户体验

恢复系统还要考虑用户展示:

  • 支付成功但订单未推进时,不要长期显示“待支付”。
  • 可以展示“支付结果确认中”,并提供刷新或客服入口。
  • 如果自动补偿完成,应同步推送订单状态。
  • 如果需要退款,应明确退款处理中和预计到账时间。

很多系统后台补偿做得不错,但前台状态表达很差,用户仍然会投诉。故障恢复不只是数据修复,也包括用户可解释性。

3.6.10 端到端案例二:库存预占成功但订单创建失败

3.6.10.1 故障过程

用户提交订单
  -> 订单服务调用库存预占
  -> 库存系统写 reservation SUCCESS
  -> 订单服务写订单主表时失败
  -> 用户收到下单失败
  -> 库存仍被占用

如果不处理,库存会被幽灵订单占住,造成少卖。

3.6.10.2 恢复设计

库存预占必须有过期时间和业务关联。

关键字段:

字段说明
reservation_id预占 ID
request_id创单请求幂等键
order_id如果订单创建成功则绑定
sku_id库存对象
quantity预占数量
statusRESERVED、CONFIRMED、RELEASED、EXPIRED
expire_at预占过期时间

恢复路径:

  1. 创单失败后,订单服务同步调用库存释放。
  2. 如果同步释放失败,写补偿任务 RELEASE_INVENTORY_RESERVATION
  3. 库存系统定时扫描超时 RESERVED 记录。
  4. 对账任务扫描“无订单绑定的有效预占”。
  5. 补偿释放库存并写释放流水。

注意:释放库存必须幂等。重复释放不能让库存加两次。

3.6.10.3 什么时候不能自动释放

如果订单服务写主表超时,可能订单实际已经写成功,只是响应失败。因此释放前要查订单:

查询结果处理
订单不存在可以释放
订单存在且待支付不释放,绑定 reservation
订单存在且已支付确认库存 sold
订单存在但状态异常转人工或重新对账

这就是为什么补偿执行前必须重新读取最新状态。补偿不能只相信任务创建时的上下文。

3.6.11 端到端案例三:供应商同步失败进入 DLQ

3.6.11.1 场景

一个酒店供应商每天同步 100 万个酒店和房型。某次同步中,供应商把城市编码从 BKK 改成新编码,但没有提前通知平台。平台城市映射失败,导致部分酒店无法发布。

如果系统只是打印日志,运营不会知道哪些酒店失败,也无法修复。

3.6.11.2 正确链路

Supplier Sync Task
  -> 拉取 Raw Snapshot
  -> 标准化字段
  -> 城市 / 商户 / 品牌映射
  -> 质量校验
  -> Diff
  -> 低风险自动发布
  -> 高风险审核
  -> 失败进入 MySQL DLQ

DLQ 记录应包含:

字段示例
dlq_typeSUPPLIER_SYNC
biz_typeHOTEL
biz_idsupplier_hotel_id
stageMAPPING
error_codeCITY_MAPPING_NOT_FOUND
payload_refraw snapshot 地址
context_snapshotsupplier_id、city_code、hotel_name、task_id
ownersupply-ops
retryablefalse

运营后台应该能按供应商、城市、错误码筛选,批量建立映射规则,然后重放受影响 DLQ。

3.6.11.3 为什么不能直接自动发布

城市映射失败不是技术瞬时错误,而是业务语义缺失。自动发布可能导致:

  • 酒店出现在错误城市。
  • 用户搜索结果错误。
  • 税费和履约规则错误。
  • 供应商结算主体错误。

所以这类 DLQ 的正确处理不是重试,而是修复映射数据后重新投递。

3.6.11.4 重放后的验证

重放成功不等于结束。还要验证:

  • 商品正式版本是否更新。
  • 搜索索引是否刷新。
  • 详情页缓存是否刷新。
  • 可售诊断是否通过。
  • 质量报表中的失败数是否下降。
  • 原 DLQ 是否标记 RESOLVED 并写审计。

这才是闭环。

3.6.12 故障恢复中的人工介入

业务系统不能完全排斥人工介入。关键是人工介入必须产品化、权限化、审计化。

3.6.12.1 人工介入入口

建议后台至少提供:

能力说明
差异查询按订单号、支付单、SKU、供应商、错误码查找
证据展示展示对账快照、原始 payload、trace、日志链接
操作建议系统给出推荐动作和风险提示
单条修复对单个差异执行推进、释放、重建、退款等动作
批量处理对低风险同类问题批量重放
审批流程高风险动作需要双人审批
操作审计记录操作人、原因、参数、前后状态
结果验证操作后自动触发对账或状态检查

禁止直接让运营或研发用 SQL 改核心状态。手工 SQL 最大的问题不是慢,而是不可审计、不可复用、不可防错。

3.6.12.2 人工动作也要幂等

人工点击“重新投递”也可能点两次,后台超时后也可能重复提交。所以人工操作不能绕过幂等。

每次人工动作都应该生成 manual_operation 记录:

CREATE TABLE manual_operation (
    id BIGINT PRIMARY KEY,
    operation_type VARCHAR(64) NOT NULL,
    biz_type VARCHAR(64) NOT NULL,
    biz_id VARCHAR(128) NOT NULL,
    idempotency_key VARCHAR(128) NOT NULL,
    operator VARCHAR(64) NOT NULL,
    reason VARCHAR(512) NOT NULL,
    request_payload JSON NOT NULL,
    before_snapshot JSON NOT NULL,
    after_snapshot JSON NULL,
    status VARCHAR(32) NOT NULL,
    approved_by VARCHAR(64) NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_manual_idem (operation_type, idempotency_key)
);

人工修复不是“绕过系统”,而是通过系统提供的安全入口完成修复。

3.6.13 监控与指标

第 3 章已经系统讨论监控体系,本章只列对账、补偿和 DLQ 必须具备的专项指标。

3.6.13.1 对账指标

指标含义
recon_batch_success_rate对账批次成功率
recon_batch_duration_seconds对账耗时
recon_diff_total差异数量
recon_diff_age_seconds差异存活时间
recon_auto_fix_success_rate自动修复成功率
recon_manual_required_total需要人工处理的差异

关键告警:

  • 支付对账批次失败。
  • P0 差异数量大于 0,例如重复扣款、金额不一致。
  • 差异超过 SLA 未处理。
  • 同一错误码差异突然上升。

3.6.13.2 补偿指标

指标含义
compensation_pending_total待执行任务数
compensation_retry_total重试任务数
compensation_success_rate补偿成功率
compensation_oldest_age_seconds最老任务年龄
compensation_manual_required_total需要人工介入数量
compensation_execution_duration_seconds单次执行耗时

比“失败数量”更重要的是“最老任务年龄”。如果一条支付补偿卡了 6 小时,哪怕总数只有 1,也可能是严重用户问题。

3.6.13.3 DLQ 指标

指标含义
dlq_open_total未处理 DLQ 数
dlq_new_total新增 DLQ 数
dlq_resolved_total已解决数
dlq_replay_success_rate重放成功率
dlq_oldest_age_seconds最老 DLQ 年龄
dlq_by_error_code按错误码分布
dlq_by_owner按负责人分布

DLQ 要有运营 SLA。例如:

  • 支付和退款 DLQ:15 分钟内响应。
  • 订单状态 DLQ:30 分钟内响应。
  • 商品投影 DLQ:2 小时内处理。
  • 供应商数据质量 DLQ:工作日内处理或升级供应商。

3.6.14 上线前检查清单

上线一个跨系统链路前,可以用下面清单检查恢复能力。

检查项必须回答
权威数据源出现差异时谁说了算
幂等键重试、回调、补偿是否共用稳定幂等语义
状态机哪些状态允许自动推进,哪些必须人工
Outbox本地事务成功后事件是否可靠投递
对账规则是否有差异发现机制和容忍窗口
补偿任务是否有任务表、状态机、租约、重试和审计
DLQ不可自动处理的数据是否可查询、可分派、可重放
人工入口是否禁止直接 SQL 修核心状态
监控告警是否能看到差异数量、积压时间、重放成功率
Runbook值班同学是否知道如何止血和恢复

如果一个链路没有对账和补偿,它不是“设计简单”,而是把复杂度转嫁给事故当天的 on-call。

3.6.15 常见反模式

反模式问题
只写日志不落差异表问题不可运营,无法统计和追踪
对账只告警不修复告警越来越多,团队逐渐麻木
补偿没有幂等重试本身制造二次事故
所有失败都无限重试打爆下游,掩盖数据问题
DLQ 只是 Kafka Topic运营无法查询、分派、修复、审计
人工直接改库无权限边界、无审计、无防错
金额差异自动修极易扩大资损
不保存原始 payload无法复盘、无法重放、无法举证
不区分错误类型可重试和不可重试混在一起
补偿执行前不读最新状态修复旧问题,制造新问题

3.6.16 小结:恢复体系不是兜底脚本,而是业务可信度的一部分

对账、补偿、DLQ 与故障恢复,是业务系统长期稳定运行的底座。

对电商系统来说:

  • 支付对账保护资金事实。
  • 库存对账保护可售承诺。
  • 订单对账保护用户状态。
  • 商品与搜索对账保护前台体验。
  • 供应商对账保护供给质量。
  • 补偿任务负责把低风险差异自动拉回正确状态。
  • DLQ 负责把不可自动处理的问题变成可运营、可追踪、可审计的问题单。
  • 人工介入不是失败,而是高风险业务恢复的一部分,但必须通过系统化入口完成。

真正成熟的恢复体系,不是“出了问题有脚本”,而是从主链路设计时就把失败纳入模型:每个动作都有幂等语义,每个状态都有推进规则,每个事件都有重放路径,每个差异都有处理责任,每次人工操作都有证据链。

系统会失败,但业务结果必须最终可信。这就是对账、补偿、DLQ 和故障恢复的工程价值。

3.6.17 延伸思考:恢复能力为什么总在团队最忙时才显得昂贵

恢复体系的建设往往最容易被延后,因为它很少直接带来新用户、新 GMV 或新功能。但从长期看,恢复能力不足带来的代价,通常会以更昂贵的方式回到团队面前:一次支付差异、一次库存冻结、一次批量修复、一次通宵对账、一次无法解释的用户投诉。

真正成熟的团队,不会把对账、补偿、DLQ 和人工修复看成附属能力,而是会把它们视为主链路可信度的组成部分。因为系统规模越大,越不可能靠“所有调用都成功”维持正确,只能靠“失败后仍能收敛”维持业务可信。

3.6.18 资损防控:资金、库存、优惠与账务安全

资损防控是业务系统可靠性里最“硬”的部分。接口慢一点,用户可能刷新;推荐失败,用户可能少点一个商品;但一旦出现重复扣款、错误退款、超卖、优惠券被薅、商家错结算、价格算错,系统损失的就不只是可用性,而是现金、库存、商誉和合规风险。

很多团队把资损防控理解成支付系统或财务系统的事情,这是不完整的。真正的资损往往发生在业务链路交界处:计价给了错误金额,订单保存了错误快照,库存释放重复,营销券重复核销,支付回调乱序,退款绕过原路退回,供应商履约失败但订单仍完成,运营后台误改了高风险配置。

本章从业务系统工程视角讨论资损防控:如何识别资损场景,如何设计账本与状态机,如何在主链路中防错,如何在高风险操作前审批和拦截,如何通过对账、补偿、冻结和审计把损失控制在最小范围。

3.6.18.1 什么是资损

资损不只是“钱少了”。只要系统错误导致平台、商家、用户、供应商之间的权益分配偏离真实业务承诺,都可以视为资损风险。

在电商系统中,常见资损对象包括:

对象资损表现例子
现金多扣、少扣、漏扣、错退用户支付 100 元,订单只入账 90 元
库存超卖、少卖、重复释放、错误锁定库存只有 1 件,卖出 2 件
优惠重复用券、超预算补贴、错误叠加满减和新人券错误叠加导致 0 元购
积分 / 余额重复发放、重复扣减、扣减失败订单取消后积分未退回
商家结算错分账、错费率、错周期商家应结 1000 元,只结 900 元
供应商结算平台订单与供应商单不一致供应商已确认,平台未生成结算
发票 / 税务金额口径错误含税价和不含税价混用
履约权益虚拟码重复发、酒店错订同一券码发给两个用户

资损防控的目标不是让所有风险变成零。成本、体验和效率决定了系统只能对不同风险分级治理。核心目标是:

  1. 高风险错误尽量在发生前拦截。
  2. 已发生的差异尽快发现。
  3. 发现后能冻结影响范围。
  4. 修复动作可幂等、可审批、可审计。
  5. 损失金额、影响用户和责任边界可解释。

3.6.18.2 资损风险的来源

资损通常不是单点 Bug,而是业务规则、分布式一致性、人工操作和外部依赖共同作用的结果。

3.6.18.2.1 计价错误

计价错误是资损高发区。

场景风险
优惠叠加顺序错误折扣、满减、券、积分重复优惠
金额精度错误浮点计算、舍入规则不一致
币种转换错误汇率、币种精度、税费口径错误
价格缓存过期下架价、活动价、供应商价未刷新
运费 / 税费漏算用户少付或商家少收
会员价与活动价冲突高优先级规则未生效

计价资损最危险的地方在于:错误金额一旦进入订单快照,后续支付、退款、结算都会沿着错误口径继续执行。

3.6.18.2.2 状态机错误

状态机错误会让系统在不该推进时推进,或者在该回滚时没有回滚。

场景风险
支付成功晚于订单取消订单状态和资金事实冲突
退款成功但订单仍完成商家结算多结
发货后重复退款用户拿到商品又拿到钱
订单取消后库存未释放少卖
订单取消后券未退回用户权益损失
售后关闭后退款回调到达重复状态推进

状态机必须用代码和数据库约束共同保护,不能只靠业务同学“按流程操作”。

3.6.18.2.3 幂等缺失

高并发系统中,重复请求、重复回调、重复消息、重复人工提交都很常见。

幂等缺失会直接造成:

  • 重复创建订单。
  • 重复扣库存。
  • 重复核销优惠券。
  • 重复发放积分。
  • 重复退款。
  • 重复给供应商下单。

只在前端按钮上做防抖没有意义。资损防控要求服务端、数据库唯一键、状态机和流水账本共同保证幂等。

3.6.18.2.4 异步链路不一致

为了性能,电商系统大量使用异步事件。

支付成功
  -> 订单已支付
  -> 库存确认已售
  -> 营销确认核销
  -> 履约创建
  -> 商家结算计提
  -> 数据仓库入账

任何一步事件丢失、消费失败、乱序或重复,都可能造成资损。因此异步链路必须具备 Outbox、消费幂等、DLQ、对账和补偿。

3.6.18.2.5 人工操作

很多严重资损来自人工操作,而不是代码 Bug。

操作风险
批量改价大量商品低价售卖
修改活动规则优惠门槛错误、预算失控
手工补券重复发券或发错用户
手工退款超额退款或错退
直接 SQL 修数据绕过状态机和审计
修改支付渠道权重走错费率或不可用渠道
修改供应商映射商品履约和结算主体错误

资损防控必须把后台、配置中心、批量任务和人工修复工具纳入治理,而不是只保护 C 端接口。

3.6.18.2.6 外部系统不确定性

支付渠道、供应商、物流、银行、清结算文件都可能不稳定。

典型风险:

  • 渠道同步返回成功,但最终扣款失败。
  • 渠道回调成功,但对账文件缺记录。
  • 供应商确认成功,但后续取消。
  • 供应商价格和平台价格不同步。
  • 银行清算金额与本地账务不同。

外部依赖不能被当作本地事务的一部分。必须用适配层、状态机、查单、对账和人工复核治理。

3.6.18.3 资损防控的三道防线

成熟的资损防控不是某一个风控规则,而是三道防线。

防线目标手段
事前防错不让高风险动作发生规则校验、状态机、唯一键、限额、审批、灰度
事中拦截异常发生时立即阻断扩散风险规则、冻结、熔断、预算水位、异常检测
事后收敛已发生差异可发现、可修复对账、补偿、DLQ、审计、复盘

很多团队只做事后对账,等财务发现差异时,用户已经拿到错误权益,商家已经结算,供应商已经履约,修复成本极高。真正有效的资损防控必须前移。

3.6.18.3.1 事前防错

事前防错的核心是把业务不变量写进系统。

例如:

  • 退款总额不能超过支付成功金额。
  • 同一支付渠道流水只能绑定一笔支付单。
  • 同一张券只能被一个订单核销一次。
  • 库存释放必须关联原始预占流水。
  • 订单金额必须等于商品金额、优惠、运费、税费的可解释组合。
  • 活动预算消耗不能超过预算上限。
  • 高风险价格变更必须审批和灰度。

这些规则不能只存在于 PRD 或口头约定中,必须落到数据库约束、领域服务校验、审批流和监控规则。

3.6.18.3.2 事中拦截

事中拦截关注“异常趋势刚出现时,能否快速止血”。

典型拦截:

  • 某活动优惠核销速度超过预算曲线,自动暂停活动。
  • 某 SKU 下单价低于成本价阈值,自动下架或进入审核。
  • 某支付渠道短时间失败率异常,自动降权或切流。
  • 某商家退款率异常升高,冻结自动退款。
  • 某接口重复退款请求异常,触发用户或订单级限流。
  • 库存超卖风险出现,冻结商品可售。

拦截动作必须区分用户体验和资损风险。推荐失败可以降级,但金额异常应该宁可阻断,也不能带病放行。

3.6.18.3.3 事后收敛

事后收敛依赖第 3 章的对账、补偿、DLQ 和故障恢复能力。

关键要求:

  • 差异能被批次化发现。
  • 差异能按风险等级分类。
  • 低风险差异能自动修复。
  • 高风险差异能冻结并进入人工复核。
  • 所有修复动作都有审计证据。
  • 修复后再次对账确认收敛。

事后收敛不是失败的借口,而是承认分布式系统不可能永远同步成功。

3.6.18.4 资损防控的核心模型:账本、状态机、快照

资损防控有三个底层模型:账本、状态机、快照。

3.6.18.4.1 账本

账本记录每一次权益变化的事实流水。余额、库存、券、积分、商家结算都应该有账本。

账本的特点:

  1. 追加写,不随意物理删除。
  2. 每条流水有业务来源和幂等键。
  3. 借贷或增减方向明确。
  4. 能从流水重放出余额。
  5. 能与快照和外部账单对账。

以资金账本为例:

CREATE TABLE fund_ledger (
    id BIGINT PRIMARY KEY,
    account_id VARCHAR(128) NOT NULL,
    biz_type VARCHAR(64) NOT NULL,
    biz_id VARCHAR(128) NOT NULL,
    direction VARCHAR(16) NOT NULL,
    amount BIGINT NOT NULL,
    currency VARCHAR(16) NOT NULL,
    ledger_type VARCHAR(64) NOT NULL,
    idempotency_key VARCHAR(128) NOT NULL,
    before_balance BIGINT NULL,
    after_balance BIGINT NULL,
    status VARCHAR(32) NOT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_fund_ledger_idem (ledger_type, idempotency_key),
    KEY idx_fund_ledger_account (account_id, created_at),
    KEY idx_fund_ledger_biz (biz_type, biz_id)
);

金额建议使用最小货币单位的整数,例如分、厘、cent,避免浮点误差。

3.6.18.4.2 状态机

状态机定义业务对象能从哪里到哪里。

支付单状态机示例:

INIT
  -> PAYING
  -> SUCCESS
  -> FAILED
  -> CLOSED
  -> REFUNDING
  -> REFUNDED

状态机要做三件事:

  1. 限制非法迁移。
  2. 处理重复事件。
  3. 为补偿和对账提供判断依据。

例如支付单从 SUCCESS 不能回到 PAYING;退款成功回调重复到达时应该幂等返回;订单已取消后收到支付成功事件时不能无条件推进订单,需要进入特殊处理。

3.6.18.4.3 快照

快照用于固定业务承诺。

订单创建时应保存:

  • 商品快照。
  • SKU 快照。
  • 价格快照。
  • 优惠快照。
  • 运费和税费快照。
  • 履约规则快照。
  • 供应商报价和确认快照。

如果订单只保存商品 ID,后续商品改价、活动下线、供应商规则变化,就无法解释当时为什么收这个价,也无法正确退款、结算和审计。

资损防控里有一个重要原则:交易发生后,用交易快照解释历史,不用当前配置解释历史。

3.6.18.5 计价资损防控

计价是资损防控第一现场。

3.6.18.5.1 金额公式必须可解释

订单应付金额应该能被公式解释:

payable_amount
  = item_amount
  + shipping_fee
  + tax_amount
  + service_fee
  - platform_discount
  - merchant_discount
  - coupon_discount
  - points_discount

每一项都必须有来源、版本和上限。

金额项防控要求
商品金额绑定商品价格版本和币种
运费保存运费模板版本和计算输入
税费保存税率、税区、含税口径
平台优惠绑定活动 ID、预算、规则版本
商家优惠区分商家承担和平台承担
券优惠绑定券实例和核销流水
积分抵扣保存积分扣减流水

如果金额只保存一个总价,后续退款、结算、财务入账都会变成猜谜。

3.6.18.5.2 金额不变量

计价系统必须校验不变量:

不变量说明
应付金额不能小于 0除非明确支持 0 元单
优惠总额不能超过可优惠金额防止倒贴
平台补贴不能超过活动预算防止预算穿透
商家承担金额不能超过商家授权防止错扣商家
退款金额不能超过原支付成功金额防止超退
分账总额不能超过实收金额防止商家多结
不同币种不能直接加减必须先明确汇率和结算币种

这些校验应该存在于计价服务、订单创建、支付创建、退款创建和结算生成多个关键点。

3.6.18.5.3 价格保护

价格变更要有风险保护:

  • 价格低于成本价或历史均价一定比例,进入审核。
  • 批量改价影响 SKU 数超过阈值,需要双人审批。
  • 活动价生效前灰度到小流量或内部用户。
  • 新规则上线后监控订单平均折扣率、毛利率和 0 元单数量。
  • 价格配置变更写审计,并能快速回滚。

价格错误通常扩散非常快。一个错误活动配置可能几分钟内产生几万笔订单,所以价格保护必须具备自动暂停能力。

3.6.18.6 订单、支付与退款资损防控

3.6.18.6.1 创建支付单

创建支付单前必须校验:

校验目的
订单仍可支付防止取消后支付
支付金额等于订单应付金额防止篡改金额
支付币种一致防止币种错配
支付单幂等防止重复支付单
订单未进入退款或风控冻结防止状态冲突

支付金额不能信任前端传入。前端只能提交订单号和支付方式,金额必须由服务端根据订单快照读取。

3.6.18.6.2 支付回调

支付回调是资损高风险入口。

处理顺序建议:

接收回调
  -> 验签
  -> 校验渠道商户号
  -> 幂等检查渠道流水
  -> 查询本地支付单
  -> 校验金额、币种、订单归属
  -> 状态机推进
  -> 写支付流水和 Outbox
  -> 返回渠道成功

几个关键原则:

  • 验签失败不能进入业务逻辑。
  • 渠道流水号必须唯一。
  • 回调金额必须等于支付单金额。
  • 更新支付状态和写 Outbox 应在同一事务内。
  • 成功响应渠道应该晚于本地事务提交。
  • 重复回调必须幂等返回成功。
3.6.18.6.3 退款防控

退款比支付更容易出资损,因为它常常由售后、客服、运营或自动规则触发。

退款前必须校验:

校验说明
原支付成功未支付不能退款
可退金额累计退款不能超过实付
退款原因是否符合售后规则
订单状态已发货、已核销、已完成的退款规则不同
权益回收券、积分、库存、发票是否需要反向处理
风险审批大额退款、批量退款、跨账户退款必须审批

退款建议使用退款单状态机:

INIT
  -> REFUNDING
  -> SUCCESS
  -> FAILED
  -> CLOSED

原路退回是默认原则。任何非原路退款、线下退款、人工补偿,都应提高风险等级并要求审批。

3.6.18.6.4 重复退款防控

重复退款常见来源:

  • 用户重复提交售后。
  • 客服重复点击。
  • 渠道回调重复。
  • 补偿任务重复执行。
  • 多个售后单同时关联同一支付单。

防控方式:

  • payment_id + refund_request_id 唯一。
  • 支付单累计退款金额使用条件更新。
  • 退款成功流水唯一。
  • 退款补偿幂等。
  • 售后单和退款单一对一或明确多对一规则。

金额更新建议使用条件约束:

UPDATE payment_order
SET refunded_amount = refunded_amount + :refund_amount
WHERE payment_id = :payment_id
  AND paid_amount - refunded_amount >= :refund_amount;

更新影响行数为 0 时,不能继续发起渠道退款。

3.6.18.7 库存与履约资损防控

库存资损既可能是超卖,也可能是少卖。

3.6.18.7.1 库存账本

库存系统不应该只有一个 available_stock 字段。至少需要库存流水:

流水类型说明
INIT初始化库存
RESERVE下单预占
CONFIRM支付成功确认已售
RELEASE取消或超时释放
ADJUST人工调整
FREEZE风险冻结
UNFREEZE解除冻结

库存余额可以是快照,但库存真相应来自流水和状态机。

3.6.18.7.2 超卖防控

超卖防控要覆盖并发、缓存和供应商三类风险。

风险防控
并发扣减DB 条件更新、Redis Lua、库存分片
重复扣减预占流水幂等键
重复释放释放流水唯一键
Redis 与 DB 不一致定期对账,以账本修复缓存
热点 SKU限流、排队、库存分桶
供应商库存延迟可售水位、安全库存、实时确认

典型扣减条件:

UPDATE inventory_balance
SET available = available - :qty,
    reserved = reserved + :qty
WHERE inventory_key = :inventory_key
  AND available >= :qty;

影响行数为 0 表示库存不足,不能继续创建可支付订单。

3.6.18.7.3 少卖防控

很多团队只关注超卖,忽略少卖。少卖也是资损,因为平台损失交易机会。

少卖来源:

  • 订单失败后库存未释放。
  • Redis 扣减成功但 DB 释放失败。
  • 预占过期扫描任务挂掉。
  • 供应商库存恢复后平台未同步。
  • 风险冻结后没有自动解冻。

少卖防控依赖:

  • 预占过期释放。
  • 库存对账。
  • 冻结库存监控。
  • 供应商库存新鲜度监控。
  • 手工冻结工单 SLA。
3.6.18.7.4 虚拟商品和券码

券码、充值、酒店、机票这类库存更复杂。

品类风险防控
券码同一码发给多个用户code_id 状态机、发码幂等
充值供应商扣款但用户未到账供应商请求幂等、回调对账
酒店平台收款但供应商未确认支付前二次确认或支付后快速取消退款
机票价格和舱位实时变化锁价、验舱、支付时效

虚拟履约必须有供应商请求流水,不能只记录“履约成功 / 失败”。否则供应商争议时无法举证。

3.6.18.8 营销与优惠资损防控

营销系统是“有意让利”的系统,也是资损高发区。

3.6.18.8.1 优惠预算

每个活动都应该有预算控制:

预算类型说明
总预算活动最多补贴金额
日预算单日最多补贴金额
用户预算单用户最多领取或核销
商家预算单商家承担上限
商品预算单 SKU 补贴上限
渠道预算不同渠道独立预算

预算扣减要使用原子操作,并有预算流水。

预算冻结
  -> 订单支付成功确认消耗
  -> 订单取消释放预算
  -> 退款时按规则返还或不返还

不要只在活动开始前校验预算,活动运行中必须实时监控预算消耗速度。

3.6.18.8.2 优惠叠加规则

优惠叠加要明确:

  • 哪些优惠互斥。
  • 哪些优惠可叠加。
  • 叠加顺序是什么。
  • 每类优惠作用范围是什么。
  • 优惠是否参与退款。
  • 优惠由平台还是商家承担。

常见事故是优惠规则从“单券”扩展到“多券 + 满减 + 积分 + 会员价”后,没有重新设计规则引擎,只是在代码里堆条件。

3.6.18.8.3 薅羊毛防控

营销资损不一定来自 Bug,也可能来自规则被利用。

常见手段:

  • 新用户补贴被批量注册领取。
  • 邀请奖励被刷。
  • 低价商品叠加满减套利。
  • 退款后优惠权益未回收。
  • 组合订单拆单后优惠分摊错误。

防控方式:

  • 用户、设备、支付工具、收货地址、IP、账号关系图谱。
  • 优惠领取和核销频率限制。
  • 低价订单和 0 元订单专项监控。
  • 退款后优惠回收规则。
  • 活动预算和毛利率水位告警。

营销系统不能只看转化率,也要看补贴效率和异常套利。

3.6.18.9 供应商与结算资损防控

B2B2C 和多供应商平台里,资损常发生在平台、用户、商家、供应商之间的边界。

3.6.18.9.1 供应商履约一致性

平台订单和供应商订单必须建立映射:

字段说明
order_id平台订单
supplier_order_id供应商订单
supplier_id供应商
confirm_status供应商确认状态
supplier_amount供应商结算金额
currency币种
confirm_time确认时间
raw_response_ref原始响应

如果用户已支付但供应商未确认,系统必须明确处理策略:

  • 自动重试确认。
  • 切换供应商。
  • 退款。
  • 客服介入。
  • 平台承担差价履约。

不能让订单无限停在“处理中”。

3.6.18.9.2 商家结算

商家结算至少要对齐:

用户实付
  - 平台优惠承担
  - 商家优惠承担
  - 佣金
  - 手续费
  - 退款
  - 售后扣减
  = 商家应结算金额

结算资损常见问题:

问题风险
优惠承担方错平台和商家分摊错误
退款未冲减结算商家多结
费率配置错误佣金少收或多收
结算周期错误提前或延后结算
跨币种汇率错误汇兑损失
订单状态口径错误未完成订单被结算

结算系统必须使用不可变结算批次和结算明细。已出账批次不能随意修改,应通过调账单修正。

3.6.18.9.3 调账

调账是必要能力,但也是高风险能力。

调账必须具备:

  • 调账原因。
  • 调账对象。
  • 调账金额。
  • 关联订单或结算单。
  • 审批人。
  • 操作人。
  • 前后余额。
  • 财务凭证。

调账不能直接修改原始订单金额或支付金额。正确方式是追加调账流水。

3.6.18.10 权限、配置与后台操作防控

后台是资损防控的重点区域。

3.6.18.10.1 高风险操作分级
风险等级操作控制
P0批量退款、批量改价、批量发券、结算调账双人审批、白名单、限额、审计
P1活动规则上线、支付渠道切换、供应商映射修改审批、灰度、回滚
P2单商品改价、单订单补偿、单用户补券权限校验、理由必填、审计
P3查询、导出、低风险配置权限和水印

高风险操作要做到“能不能做、谁能做、做多少、怎么回滚、谁审批、如何审计”都有系统约束。

3.6.18.10.2 配置变更防控

配置变更必须和代码发布一样治理。

要求:

  • 配置有 schema 校验。
  • 配置有版本号。
  • 配置支持灰度。
  • 配置支持一键回滚。
  • 高风险配置需要审批。
  • 配置变更事件写入监控。
  • 配置生效范围可见。

例如支付渠道权重、活动预算、费率、退款规则、库存策略,都不应允许无审批直接全量生效。

3.6.18.10.3 禁止直接改库成为流程

事故处理中偶尔可能需要 DBA 级别的紧急修复,但不能把直接 SQL 变成日常流程。

直接改库的风险:

  • 绕过状态机。
  • 绕过幂等。
  • 绕过 Outbox。
  • 绕过审计。
  • 无法触发下游同步。
  • 无法被对账系统正确解释。

正确方式是建设修复工具:通过领域服务执行修复,自动写状态日志、Outbox、审计和对账标记。

3.6.18.11 风险规则与拦截系统

资损防控需要规则系统,但规则系统不能变成没人懂的黑盒。

3.6.18.11.1 规则类型
规则类型示例
阈值规则单笔退款超过 5000 元需要审批
比例规则商品价格低于近 7 日均价 50% 拦截
频率规则同用户 10 分钟内退款超过 3 次
预算规则活动预算消耗超过 90% 自动降级
状态规则已发货订单不能自动全额退款
关系规则同设备批量领取新人券
对账规则支付成功但订单未推进超过 1 分钟

规则结果不一定只有通过和拒绝,还可以是:

  • 放行。
  • 二次确认。
  • 人工审批。
  • 冻结对象。
  • 降级权益。
  • 触发对账。
  • 触发告警。
3.6.18.11.2 规则执行点

规则要放在关键业务动作前:

动作规则
计价价格、优惠、预算、毛利率
创单库存、优惠、风控、金额
支付金额一致、订单状态、渠道风险
退款可退金额、售后状态、风险审批
发券用户资格、频率、预算
改价成本价、影响范围、审批
结算订单完成、退款冲减、费率
人工修复权限、审批、幂等、审计

越靠近源头拦截,损失越小。等到财务 T+1 对账才发现,通常已经很难无痛修复。

3.6.18.11.3 规则治理

规则系统本身也会造成事故。

治理要求:

  • 规则有 owner。
  • 规则有版本。
  • 规则有命中日志。
  • 规则支持灰度。
  • 规则支持回滚。
  • 规则变更有审批。
  • 规则效果可监控。
  • 规则误杀可申诉和恢复。

没有治理的规则系统,最后会变成谁也不敢改、谁也解释不了的风险源。

3.6.18.12 监控与告警

资损监控要比普通可用性监控更贴近业务结果。

3.6.18.12.1 核心指标
指标
计价0 元单数量、负毛利订单数、折扣率分布、价格异常拦截数
支付支付金额不一致、重复渠道流水、支付成功订单未推进
退款超额退款拦截数、重复退款尝试、人工退款金额
库存超卖次数、预占超时未释放、库存对账差异
营销预算消耗速度、券重复核销、异常领取量
结算长短款、调账金额、结算差异
供应商平台支付成功但供应商未确认、供应商金额差异
后台高风险操作次数、审批拒绝数、批量操作影响量
3.6.18.12.2 告警分级
等级场景响应
P0重复扣款、重复退款、大面积错价、超卖、金额不一致立即止血,事故群,冻结风险对象
P1活动预算异常消耗、供应商确认失败激增、结算差异升高10 分钟内响应
P2单类商品价格异常、补偿积压、DLQ 增长值班处理
P3审计报表、趋势异常工单跟进

资损告警必须带上影响金额、影响订单数、影响用户数、开始时间、建议止血动作。只告警“规则命中异常”没有行动价值。

3.6.18.12.3 风险看板

资损看板建议分为四屏:

  1. 实时风险屏:P0/P1 风险、影响金额、冻结对象、当前止血状态。
  2. 交易链路屏:计价、创单、支付、退款、履约各环节异常。
  3. 权益与库存屏:库存、券、积分、预算、活动风险。
  4. 运营与审计屏:高风险操作、审批、调账、人工修复。

看板第一屏必须让值班同学知道:现在是否正在亏钱,亏在哪,是否已经止血。

3.6.18.13 资损事故处理

资损事故处理顺序和普通可用性事故不同。普通事故优先恢复服务,资损事故优先止血和保护证据。

推荐顺序:

  1. 确认是否仍在产生损失。
  2. 冻结风险入口,例如活动、商品、退款、支付渠道、供应商。
  3. 保护现场,保存日志、配置版本、订单快照、渠道流水。
  4. 估算影响范围和金额。
  5. 分类处理用户、商家、供应商和财务影响。
  6. 执行修复或补偿。
  7. 复核账务和数据。
  8. 输出复盘和长期改进项。
3.6.18.13.1 常见止血动作
事故止血动作
错价下架商品、暂停活动、冻结未履约订单
重复退款暂停自动退款、冻结售后入口
优惠被薅暂停活动、冻结异常券、限制用户
超卖停售 SKU、冻结库存、启动客服预案
支付状态错乱暂停相关渠道、启动查单和对账
供应商确认失败暂停售卖或切供应商
商家结算错误暂停出账批次、冻结打款

止血动作要提前做成开关,并有权限和审计。事故中临时找人写 SQL 关活动,通常已经晚了。

3.6.18.13.2 影响范围评估

资损事故必须回答:

  • 影响开始和结束时间。
  • 影响订单数。
  • 影响用户数。
  • 影响商家或供应商。
  • 最大可能损失金额。
  • 已实际发生损失金额。
  • 可追回金额。
  • 需要赔付金额。
  • 是否涉及合规和法务。

影响范围评估依赖快照、账本、审计和对账。如果平时没有这些数据,事故当天无法凭空生成。

3.6.18.14 典型案例

3.6.18.14.1 错价 0 元购

故障过程:

运营配置满 100 减 100
  -> 规则未限制适用品类
  -> 低价商品叠加新人券
  -> 大量订单应付金额变成 0
  -> 支付绕过
  -> 履约开始发货

防控点:

  • 计价校验应付金额不能低于最低支付金额,除非活动明确允许。
  • 活动规则必须绑定适用品类、商品、用户和预算。
  • 0 元单数量异常告警。
  • 负毛利订单实时拦截。
  • 履约前二次校验订单风险状态。
3.6.18.14.2 重复退款

故障过程:

用户申请退款
  -> 售后系统创建退款单
  -> 支付渠道退款超时
  -> 补偿任务重试
  -> 客服又手工发起退款
  -> 两条路径使用不同幂等键
  -> 用户收到两笔退款

防控点:

  • 退款以支付单累计可退金额为最终约束。
  • 售后单、退款单、支付单建立唯一关系。
  • 所有退款入口共用统一退款服务。
  • 人工退款也走同一幂等和审批流程。
  • 渠道超时先查单,不盲目再次退款。
3.6.18.14.3 库存重复释放

故障过程:

订单超时取消
  -> 关单任务释放库存
  -> 支付失败回调也释放库存
  -> 补偿任务再次释放库存
  -> 可售库存被加回三次
  -> 后续超卖

防控点:

  • 库存释放必须基于 reservation 状态机。
  • reservation_id + release_type 或 release ledger 唯一。
  • 库存余额不能由多个系统直接修改。
  • 库存对账发现 available + reserved + sold 不平立即冻结。

3.6.18.15 上线前资损检查清单

检查项必须回答
金额口径订单、支付、退款、结算金额是否有统一定义
快照订单是否保存商品、价格、优惠、税费和履约快照
幂等支付、退款、发券、扣库存是否有服务端幂等
状态机是否限制非法状态迁移和晚到事件
账本资金、库存、券、积分、预算是否有流水
预算活动和补贴是否有实时预算控制
可退金额是否防止累计退款超过实付
可售库存是否防止重复释放和超卖
人工操作高风险后台操作是否有审批、限额、审计
配置变更活动、费率、渠道、库存策略是否支持灰度和回滚
对账支付、库存、营销、结算、供应商是否有对账
冻结发现异常时能否冻结商品、订单、退款、活动、结算
监控是否有影响金额、订单数和异常趋势告警
Runbook值班同学是否知道如何止血和评估影响

3.6.18.16 小结:资损防控最终比拼的是系统约束力

资损防控是业务系统工程能力的综合考试。它同时考验领域建模、金额口径、状态机、幂等、账本、审批、对账、补偿、监控和事故处理。

对电商系统来说,资损防控不能只放在支付系统里,也不能只依赖财务 T+1 对账。真正可靠的做法是:

  1. 在计价阶段保证金额可解释。
  2. 在订单阶段固化交易快照。
  3. 在库存和营销阶段用账本保护权益变化。
  4. 在支付和退款阶段用幂等、状态机和渠道对账保护资金事实。
  5. 在供应商和结算阶段明确外部事实和结算口径。
  6. 在后台和配置中心治理人工高风险操作。
  7. 在事故中优先止血、冻结、保留证据和评估影响。

资损防控的最高标准不是“出了问题能赔”,而是系统能在大多数错误发生前拦住,在少数错误发生后快速发现,并在修复过程中保留完整证据链。

3.6.18.17 延伸思考:为什么资损防控最后会变成组织成熟度问题

资损防控表面上是技术能力,底层其实是在考验一整个组织是否真正接受了“高风险业务必须被系统约束”这件事。一个团队如果仍然习惯凭经验改价、靠口头约定补券、用 SQL 修订单、出了问题再让财务和客服兜底,那么再多的规则引擎和告警平台都只是表面繁荣。

真正有效的资损防控,最终会落实到几条朴素但难坚持的原则上:金额必须可解释,状态必须可追溯,人工操作必须可审批,修复动作必须可审计,异常趋势必须可止血,技术债务必须被持续偿还。做到这些,系统才不仅仅是“能交易”,而是“值得信任”。

3.7 组织文化篇:不惩罚文化与不可逾越的红线

系统治理走到最后,拼的从来不只是架构图和工具链,而是团队面对复杂性时的判断、纪律和克制。技术问题到最后往往会回到组织问题:需求压力如何与稳定性约束平衡,事故之后如何不甩锅但又不放松红线,谁来为长期治理负责,谁来捍卫对生产环境的敬畏。

3.7.1 复盘文化:从事故里赚回经验

事故复盘不是追责会。复盘的目标是让系统和团队变得更可靠。

一份高质量复盘至少包含:

  • 事故摘要。
  • 影响范围。
  • 时间线。
  • 根因。
  • 为什么监控没有更早发现。
  • 为什么保护机制没有阻止扩散。
  • 为什么恢复耗时这么长。
  • 已完成修复。
  • 后续行动项。
  • 行动项 owner 和截止时间。

复盘中最有价值的问题通常不是“谁犯了错”,而是:

  • 为什么这个错误能上线?
  • 为什么灰度没有发现?
  • 为什么告警没有及时响?
  • 为什么回滚不够快?
  • 为什么影响范围没有被隔离?
  • 为什么同类问题以前没有被系统性治理?

复盘行动项要避免空话。比如“加强测试”“提升稳定性意识”不是有效行动项。有效行动项应该是:

  • 为订单创建接口新增创单成功率 SLO 告警。
  • 将营销依赖从订单线程池拆到独立 bulkhead。
  • 支付回调增加渠道流水唯一约束。
  • 配置中心对支付渠道权重开启双人审批。
  • 每月演练一次库存服务超时降级。

3.8 本章小结:生产系统不是被设计稳定的,而是被治理稳定的

生产系统的长期稳定,从来不是靠某一个高可用组件、某一套报警平台,或者某几位经验丰富的值班同学单独撑起来的。它真正依赖的是一整套动态平衡机制:在业务增长时控制技术债,在事故发生时快速止血,在恢复之后把经验沉淀成治理规则,在组织层面守住对生产环境的敬畏。

如果只做保障,不做治理,团队会不断重复同一类事故;如果只谈治理,不建设恢复能力,系统会在第一次真实冲击中暴露脆弱;如果长期回避技术债,任何表面上的稳定都会在未来某个节点以更高利息偿还。

所以,本章真正想强调的不是某一项技巧,而是一种面向长期演进的工程观:治理负责治本,保障负责治标,技术债务决定系统的隐形成本,而这三者必须在真实生产运行中不断被重新校准。一个能够活二十年的系统,不是因为它从不出错,而是因为它能在每一次出错后变得更可控、更可解释,也更值得继续承载业务。

3.9 延伸思考:当系统继续增长,下一阶段的治理挑战是什么

走到这里,可以回头问三个更难、也更长期的问题:

  1. 当系统规模继续扩大时,我们是在增加能力,还是在增加复杂度?
  2. 当业务继续追求速度时,哪些技术债仍然可以接受,哪些已经越过了红线?
  3. 当平台和工具越来越多时,团队是否真的形成了自驱治理能力,还是仍然靠少数“救火队长”维持运转?

很多系统的分水岭,不在于某一次大促有没有扛住,而在于团队能不能从“出了问题会处理”,走向“复杂性上升时仍然知道该如何治理”。

第 4 章 大事务处理方法论:Saga、补偿与最终一致性

从电商创单出发,理解为什么关键业务动作会天然演化为大事务问题,以及如何在同步 RPC、Saga、TCC、Outbox 和补偿机制之间做出可恢复、可审计、可收敛的设计选择。

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

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

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

这也是为什么本章会从电商创单切入。创单不是唯一的大事务场景,但它最能把大事务设计里的关键矛盾一次性暴露出来:多系统、副作用、补偿、超时、幂等、状态收敛。

这里先提前建立一个边界:

  • 大事务方法论,关注的是一个关键节点的最终一致性。
  • 长生命周期业务流程方法论,关注的是一个业务对象在多个阶段中的推进与治理。

例如,订单从创单到支付、履约、售后,是一个长生命周期业务流程;而“创单”这个关键节点内部的锁库存、占优惠、落订单,则是一个典型大事务。下一章会专门展开长生命周期业务流程,本章只聚焦“大事务”本身。

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

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

概念解决的问题典型手段
数据库事务单库内的原子性和一致性本地事务、锁、隔离级别
分布式事务多资源之间的提交一致性2PCXATCC
业务大事务跨系统业务目标如何推进和收敛Saga、状态机、Workflow、补偿

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

这三者并不是谁替代谁的关系,而是处理层次不同的问题。

在互联网系统里,很多流程天生就不适合直接上 2PC / XA

  • 支付网关、短信平台、物流系统、供应商接口都不可能加入你的本地事务。
  • 用户支付不是毫秒级动作,可能会等待几十秒、几分钟,甚至跨天。
  • 高并发下长时间持锁会迅速击穿吞吐和可用性。
  • 很多步骤天然只能做“确认 / 取消 / 补偿”,而不是“全局原子提交 / 回滚”。

所以大事务设计的重点很少是“全局一次提交”,更常见的是:

  1. 找到权威状态源。
  2. 把业务流程拆成多个可提交、可恢复、可补偿的阶段。
  3. 为每个阶段定义状态、超时、补偿和审计规则。
  4. 确保流程在局部失败后仍然能够最终收敛。

这也意味着,大事务系统的设计对象不是“接口调用顺序”,而是“状态推进机制”。

4.2 为什么创单节点天然是大事务

电商创单看起来只是用户点了一次“提交订单”,但“创单”这个动作本身通常至少涉及下面这些子动作:

  1. 校验商品是否仍然可售。
  2. 校验价格、优惠、税费、运费是否仍然有效。
  3. 预占库存或其他资源。
  4. 创建订单主记录和订单行。
  5. 生成支付单或支付上下文。
  6. 为后续支付准备支付上下文。

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

  • 库存预占成功,但订单写库失败。
  • 订单创建成功,但支付发起失败。
  • 营销额度占用成功,但库存预占失败。
  • 支付上下文创建成功,但订单落库失败。
  • 订单超时取消后,库存释放和优惠释放只完成了一部分。

所以创单节点天然带有四个大事务特征:

  • 跨系统:库存、价格、订单、支付、履约通常不是同一个服务。
  • 多副作用:库存、营销、订单、支付上下文都可能留下真实副作用。
  • 可失败:任何一步都可能独立失败,而且失败之后未必能简单回滚。
  • 需收敛:失败之后必须知道哪些动作要撤销、哪些状态要保留。

用一句话概括:创单节点不是“一个接口调多个下游”,而是“一次业务动作里包含多个必须一起收敛的副作用”。

订单生命周期当然还会继续进入支付、履约、售后,但那是下一章要讨论的长流程问题。本章只把“创单这一刻怎么做对”拆开讲透。

4.3 如何判断一个业务动作是不是大事务

不是所有跨系统调用都值得上 Saga、TCC 或补偿编排。真正需要按大事务来设计的业务动作,通常至少满足下面几个判断维度:

判断维度典型问题
跨系统副作用是否会同时修改库存、营销、订单、账务等多个系统
失败代价某一步部分成功后,是否会出现超卖、重复扣减、资损
补偿需求任一步失败后是否需要显式回收已有副作用
幂等要求重试时是否可能重复扣库存、重复占券、重复落单
状态收敛是否必须把结果收敛成“成功 / 失败 / 待补偿 / 待人工处理”
可审计性事后是否必须解释“哪些动作已执行、哪些动作已回滚”

可以先记一个很实用的经验判断:

  • 普通同步调用:步骤少、依赖少、失败后整体返回即可,没有明显跨系统副作用。
  • 大事务设计:需要显式补偿、幂等和状态收敛,但问题仍集中在一个关键业务动作内部。
  • 长流程编排:存在长等待、审批、人工介入和多阶段推进,已经超出单个业务动作的边界。

对创单来说,如果只是“查库存 -> 算价 -> 落单”三步确认,短流程可能够用;但只要开始有库存预占、优惠占用、订单创建、失败补偿这些跨系统副作用,它就已经进入大事务问题域了。

4.4 四种主流方案的能力边界

围绕创单这类关键节点,最常见的四种实现路线是:

4.4.1 同步 RPC + 本地事务

做法是由一个入口服务串行调用库存、营销、订单等下游,所有动作尽量在一次同步请求里完成,本地状态通过单库事务保护。

优点:

  • 实现简单,链路直观。
  • 对短流程最友好。
  • 不需要额外引入 Saga Coordinator。

缺点:

  • 一旦步骤变多,超时、重试、局部失败会迅速复杂化。
  • 中间状态不清晰,补偿难以标准化。
  • 对“部分成功后如何回收副作用”支持很弱。

它适合副作用较少的短闭环动作,不适合复杂创单节点。

4.4.2 协同式 Saga

做法是各系统通过事件彼此驱动:订单服务发事件,库存系统消费后再发事件,营销系统再消费,最终把整个业务动作收敛完成。

优点:

  • 松耦合,系统自治性强。
  • 对异步链路友好。
  • 能顺着领域边界自然扩展。

缺点:

  • 全局副作用不容易一眼看清。
  • 补偿逻辑分散在多个系统里。
  • 审计和排障成本偏高。

它适合多系统自治较强的场景,但对交易主链路来说,可见性往往不够。

4.4.3 编排式 Saga

做法是引入一个协调者,由它显式推进业务动作:先预占库存,再占用优惠,再创建订单,失败时按策略触发补偿。

优点:

  • 流程可见性强。
  • 补偿逻辑集中,便于治理。
  • 很适合创单、退款、发布这类有明确主控方的关键节点。

缺点:

  • 协调者本身会成为复杂系统。
  • 需要认真设计状态持久化、重试、幂等和高可用。

它是很多互联网主交易链路里最常见的大事务折中方案。

4.4.4 TCC / 预留确认取消模型

当副作用系统本身支持显式的 Try / Confirm / Cancel 语义时,可以进一步使用 TCC

优点:

  • 业务语义清晰,预留和确认边界明确。
  • 对库存、额度、余额这类可预占资源尤其自然。
  • 成功路径和取消路径都更可控。

缺点:

  • 要求参与方都暴露 Try / Confirm / Cancel 接口。
  • 对已有遗留系统改造成本高。
  • 不适合所有副作用场景。

它更适合高价值资源的严谨控制,而不是所有普通业务动作。

4.4.5 一张对比表

方案实现复杂度状态可见性补偿能力审计能力适用场景
同步 RPC + 本地事务副作用少、同步闭环
协同式 Saga中偏低中偏低多系统异步协作
编排式 Saga中偏高创单、退款、发布等主控节点
TCC库存、余额、额度等可预留资源

4.5 大事务系统的核心不是“调接口”,而是“状态收敛”

很多失败的大事务系统,都犯了同一个错误:把大事务理解成“把多个下游接口按顺序调完”。这会导致系统看似有链路,实则没有治理能力。

真正可生产的大事务系统,设计对象应该是“副作用之后如何收敛”,而不是“调用顺序”。

以创单为例,比“库存接口调没调成功”更重要的问题是:

  • 当前创单动作处在“待执行”“部分成功”“待补偿”“已完成”还是“已失败”?
  • 当前库存预占是“未预占”“已预占”“待释放”还是“已确认”?
  • 当前营销额度是“未占用”“已占用”“待回补”还是“已回补”?

一旦把系统设计成状态收敛问题,很多治理动作才有落点:

  • 超时:不是简单重试接口,而是把状态推进到“待补偿 / 待人工确认”。
  • 补偿:不是“反向再调一遍接口”,而是把已经产生的副作用有控制地回收。
  • 幂等:不是只看请求 ID,而是同一状态迁移不能重复制造副作用。
  • 人工接管:不是 DBA 直接改库,而是通过受控动作收敛状态。

所以大事务系统设计时,必须优先回答:

  1. 哪些副作用属于这个大事务边界?
  2. 每个副作用的成功、失败和补偿语义是什么?
  3. 超时如何转移?
  4. 失败后由谁补偿?
  5. 什么状态允许人工接管?

这也是为什么很多成熟系统最后都会自然走向“显式状态机 + Saga / TCC / 补偿协调器”的设计。

4.6 状态机、Saga、TCC 与 Outbox 怎么选

这里不要从“喜欢哪个框架”开始,而要从复杂度来源开始。

4.6.1 数据库状态机足够的情况

如果流程满足下面这些特点,数据库状态机通常就够:

  • 步骤有限。
  • 主控服务明确。
  • 失败补偿比较简单。
  • 编排逻辑仍然能被一个服务清晰维护。

典型例子:简单创单、简单退款、简单额度占用。

4.6.2 应用层 Saga 更合适的情况

当业务动作已经明显跨系统、需要显式补偿和统一审计时,可以选择编排式 Saga。

典型例子:交易主链路创单、售后退款、商品发布确认。

4.6.3 TCC 值得引入的情况

如果参与方都能暴露明确的预留、确认、取消语义,那么引入 TCC 会更合理:

  • 资源本身可以被预占。
  • ConfirmCancel 具有明确业务语义。
  • 失败代价高,不能依赖事后修账来兜底。
  • 参与方改造能力足够强。

典型例子:库存冻结确认、钱包余额预扣、额度占用。

4.6.4 Outbox 适合解决什么问题

很多团队会把 Outbox Pattern 误认为一种完整大事务方案。更准确地说,它解决的是:

  • 本地事务提交之后,事件如何可靠发出。
  • 避免“数据库写成功了,但消息没发出去”。
  • 为后续 Saga 或补偿链路提供稳定起点。

所以 Outbox 通常不是独立的大事务终局方案,而是大事务体系里的可靠事件基座。

4.6.5 一个务实的升级路径

建议遵循下面的演进顺序:

  1. 先用同步短流程解决最简单问题。
  2. 补偿和状态边界开始复杂时,升级到状态机 / Saga。
  3. 关键资源需要明确预留确认时,再考虑 TCC
  4. 需要稳定事件桥接时,把 Outbox 作为基础设施补上。

这条路径通常比一开始就追求“全局最强方案”更符合互联网系统的实际演进。

4.7 从创单扩展到其他关键业务动作

创单并不是例外,它只是最典型的大事务样本。用同样的视角看,很多关键节点都属于同一类问题。

4.7.1 用户注册 / 认证流程

注册往往不只是“写一条用户记录”,还会涉及账号创建、风控校验、实名状态更新、欢迎权益发放。只要这些动作跨多个系统共同完成,它就已经具备大事务特征。

4.7.2 退款 / 售后流程

退款这个关键动作内部,同样可能涉及退款单创建、支付网关退款、权益回补、账务回冲等多个副作用,而且强依赖补偿和审计。

4.7.3 审核 / 发布流程

商品发布里的 Publish 动作,虽然不是资金交易,但同样具备更新主数据、推送索引、清缓存、发事件等多个副作用,往往很适合编排式 Saga。

4.7.4 一个统一视角

这些流程表面业务不同,但底层共性高度一致:

  • 都不是单库事务问题,而是跨系统副作用收敛问题。
  • 都需要显式状态。
  • 都需要超时、补偿和审计。
  • 都需要在简单方案和严格方案之间做选型。

4.8 方法论沉淀:如何在真实系统中做出选择

到这里可以把整章压缩成一组可复用判断。

4.8.1 五个判断句

  1. 只要一个关键业务动作跨多个系统和多个本地事务共同完成,它就可能是大事务问题。
  2. 只要失败后不能简单整体回滚,就必须设计补偿和状态收敛。
  3. 只要副作用真实存在,就必须先定义幂等和补偿,再谈重试。
  4. 真正决定系统能否生产可用的,不是接口链路,而是状态收敛模型。
  5. 选型顺序永远应该是:先解决一致性问题,再决定是否值得引入更重的协调机制。

4.8.2 一张务实选型表

场景特征推荐方案
步骤少、同步闭环、失败整体返回即可同步 RPC + 本地事务
跨系统、多步骤、需要补偿应用层状态机 / 编排式 Saga
多系统自治、事件天然存在、主控方不强协同式 Saga
资源可预占、确认取消边界清晰TCC

4.8.3 面试与评审中的一句话表达

如果要把这一章压缩成一句可直接复用的话,可以这样讲:

互联网系统里的大事务,本质上不是把多个数据库事务绑成一个全局事务,而是把一个关键业务动作建模成可补偿、可幂等、可审计、可收敛的状态机;同步 RPC、Saga、TCC 和 Outbox 只是不同复杂度下的实现手段。

第 5 章 长生命周期业务流程方法论:状态机、编排、审批与恢复

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

上一章讲的是大事务。大事务解决的是一个关键时刻的一致性问题,例如创单时锁库存、占优惠、落订单,或者发布时更新主数据、推送索引、发送事件。

这一章讨论的是另一个层次的问题:

当一个业务对象要跨创单、支付、履约、售后,或者跨 Draft、审核、发布、下线等多个阶段持续推进时,系统该如何管理它的生命周期。

这里要明确一个边界:

  • 大事务,关注的是某个关键业务动作怎么最终收敛。
  • 长生命周期业务流程,关注的是业务对象如何跨阶段推进、治理和恢复。
  • Workflow,只是长生命周期业务流程的一种实现手段,而不是总概念本身。

例如:

  • 订单从创单到支付、履约、售后,是长生命周期业务流程。
  • 其中“创单”这一步里的锁库存、占优惠、落订单,是大事务。
  • 商品从 Draft -> QC Approval -> Publish -> Unpublish,也是长生命周期业务流程。
  • 其中 Publish 节点内部的数据更新与下游同步,则可能用 Saga 处理。

5.1 什么叫长生命周期业务流程

长生命周期业务流程,指的是一个业务对象不会在一次请求内闭环完成,而是要跨多个阶段、多个系统、多个角色、多个时间窗口持续推进。

它通常具有几个共同特征:

  1. 有明确阶段:例如待创建、待支付、待履约、已完成。
  2. 有等待点:要等用户支付、等供应商响应、等人工审批、等外部回调。
  3. 有角色切换:用户、系统、运营、客服、风控、审核员都可能参与。
  4. 有暂停与恢复:流程可能挂起几分钟、几小时,甚至几天。
  5. 有治理要求:必须知道现在走到哪一步、为什么卡住、谁能继续推进。

所以长生命周期业务流程的核心问题不是“接口怎么调”,而是:

如何让业务对象在跨时间、跨角色、跨系统的现实里,仍然保持可解释、可推进、可恢复。

5.2 为什么不能用大事务思维替代长流程思维

很多团队在系统早期,会把所有复杂问题都理解成“大事务再加几个状态”。这在短期内看起来省事,但一旦流程拉长,就会开始失控。

原因在于,大事务和长流程关注的是两个不同的问题:

维度大事务长生命周期业务流程
关注对象一个关键业务动作一个业务对象的完整生命周期
时间跨度通常秒级到分钟级分钟、小时、天甚至更长
核心问题如何最终一致如何持续推进与治理
典型能力补偿、幂等、回滚、对账状态流转、审批、超时、人工介入、恢复
实现手段Saga、TCC、Outbox状态机、Workflow、编排器、事件驱动

如果把长流程只当成一个加长版大事务,通常会出现几类问题:

  • 把所有状态都塞进一个“超大 Saga”里,最后没人能解释流程边界。
  • 为了等支付、等审核、等供应商,长期持有“事务进行中”的语义,系统状态越来越脆弱。
  • 审批、驳回、重提、超时取消、人工补单这些动作没有被建模成正式流转,只能靠脚本和人工改库兜底。

所以必须建立一个判断:

当问题的核心从“这一步怎么做对”变成“整个生命周期怎么治理”时,就应该切换到长流程方法论。

5.3 哪些场景天然属于长生命周期业务流程

5.3.1 订单生命周期管理

订单并不是“创单成功”就结束,而是会继续进入:

  • 创单
  • 支付
  • 风控审核
  • 履约
  • 签收
  • 售后
  • 关闭

这里的复杂度主要来自:

  • 每个阶段都可能由不同系统主导。
  • 支付和履约之间有明显等待点。
  • 售后、退款、逆向履约会把流程重新拉回中间阶段。
  • 客服、运营、仓库都可能参与人工处理。

所以订单本质上是一个长生命周期业务对象。

5.3.2 商品生命周期管理

商品系统里也经常有类似链路:

  • Draft
  • Editing
  • Submitted
  • QC Approval
  • Published
  • ArchivedUnpublished

它的复杂度来自:

  • 商家编辑可能持续几天。
  • 审核节点通常需要人工或半自动处理。
  • 审核不通过会退回前一个阶段。
  • 发布后还会涉及搜索、推荐、详情、运营活动等下游同步。

这显然不是一个大事务,而是一个典型的生命周期流程。

5.3.3 退款、入驻、审批与结算流程

还有一些场景天然带有流程属性:

  • 退款申请 -> 审核 -> 退款执行 -> 回写结果
  • 商家入驻 -> 资料提交 -> 风控校验 -> 合同审批 -> 开店
  • 账单生成 -> 对账 -> 差异确认 -> 结算付款

这些流程的共同点是:

  • 阶段边界清晰。
  • 等待和审批很多。
  • 经常需要人工接管。
  • 需要正式审计记录。

5.4 长流程系统真正要设计的是什么

很多人说“我们上个 Workflow 就好了”,但这句话跳过了最关键的设计层。

长流程系统真正要设计的,不是某个引擎,而是下面这几件事。

5.4.1 流程阶段

先定义清楚业务对象一生要经历哪些阶段。

例如订单可以是:

  • PENDING_CREATE
  • PENDING_PAYMENT
  • PAYMENT_PROCESSING
  • PENDING_FULFILLMENT
  • IN_FULFILLMENT
  • COMPLETED
  • CANCELLED

例如商品可以是:

  • DRAFT
  • SUBMITTED
  • UNDER_REVIEW
  • REJECTED
  • PUBLISHED
  • UNPUBLISHED

没有阶段定义,就不会有后面的治理能力。

5.4.2 流转规则

不是所有状态都能互相跳转。系统必须显式定义:

  • 哪些状态可以进入下一个状态
  • 哪些状态允许回退
  • 哪些状态只能人工推进
  • 哪些状态在超时后自动转移

这一步本质上是在设计生命周期状态机。

5.4.3 等待点与唤醒条件

长流程一定会遇到等待:

  • 等支付回调
  • 等审核员处理
  • 等物流回传
  • 等外部系统补数

每个等待点都必须回答:

  1. 等的是什么事件?
  2. 最长等多久?
  3. 超时后转到什么状态?
  4. 谁有权限继续推进?

5.4.4 人工介入与审计

长流程一旦进生产,人工介入几乎不可避免。真正成熟的设计不会回避它,而是会把它纳入正式模型:

  • 谁可以审批、驳回、跳过、重试、关闭
  • 每次人工动作是否有理由、操作者和时间
  • 哪些动作需要双人复核
  • 人工介入后如何恢复自动推进

也就是说,人工介入不是“系统失败后的非正式补丁”,而是流程治理的一部分。

5.5 四种主流实现路线

围绕长生命周期业务流程,常见实现路线大致有四种。

5.5.1 纯数据库状态机

做法是把生命周期状态放在业务主表里,由应用服务按规则推进。

优点:

  • 简单直接。
  • 与业务模型贴合。
  • 适合阶段有限、主控方明确的流程。

缺点:

  • 流程一复杂,状态转移逻辑会散落在多个服务里。
  • 超时、审批、回放、观察能力需要自己补齐。
  • 长期容易演化成一堆难维护的条件分支。

它适合较短但明显分阶段的流程。

5.5.2 应用层编排器

做法是引入一个专门的 Orchestrator,由它负责推进主流程,业务服务负责完成各节点动作。

优点:

  • 主流程视角更清晰。
  • 超时、重试、补偿、通知更容易集中治理。
  • 很适合订单履约、退款审批、发布编排这类主控方明确的流程。

缺点:

  • 编排器本身会成为一个复杂系统。
  • 状态、权限、审计、重试策略都要认真设计。

它适合流程复杂度已经超出单个业务服务承载能力的场景。

5.5.3 Workflow 引擎

做法是把流程状态持久化、等待、超时、回放、人工任务等能力,进一步交给专门的工作流系统承载,例如 TemporalCamundaZeebe

优点:

  • 对长期运行流程很友好。
  • 更容易提供可视化、审计、人工任务、超时和恢复能力。
  • 能更系统地处理“等待外部事件再唤醒”的模式。

缺点:

  • 引入成本更高。
  • 团队需要建立新的建模和运维能力。
  • 如果流程并不复杂,可能过度设计。

这里要再次强调:Workflow 只是方法,不是总概念。

5.5.4 事件驱动流程

做法是多个系统通过事件彼此驱动,不一定有一个强中心编排器。

优点:

  • 系统耦合低。
  • 能沿着领域边界自然扩展。
  • 对多团队自治环境友好。

缺点:

  • 全局可见性较弱。
  • 审计、排障、人工介入更难。
  • 没有强约束时,流程边界容易失控。

它更适合主控方不强、但领域事件天然存在的场景。

5.6 长流程里的关键能力

无论采用哪种实现路线,真正决定系统是否可生产的,往往是下面这些能力。

5.6.1 状态持久化

系统必须记住:

  • 当前处在哪个阶段
  • 已完成哪些动作
  • 正在等待什么
  • 最近一次失败原因是什么

5.6.2 超时治理

每个阶段都要考虑:

  • 正常多久完成
  • 超时后自动取消、重试还是转人工
  • 超时是否影响上下游协同

5.6.3 恢复与重入

流程中断后,系统必须能回答:

  • 能不能从当前状态继续
  • 能不能安全重试
  • 哪些动作已经产生副作用,不能重复执行

5.6.4 观察与审计

要能快速看到:

  • 一个流程实例当前卡在哪
  • 上一步是谁推进的
  • 为什么失败
  • 最近一次人工介入做了什么

5.6.5 子节点内的大事务

这也是和上一章最重要的连接点:

长流程的每个关键节点内部,往往还会嵌套一个大事务。

例如:

  • 订单生命周期里的“创单”节点,内部可能用 Saga 处理库存、营销、订单创建。
  • 商品生命周期里的“发布”节点,内部可能用 Saga 处理主数据、索引、缓存、事件。
  • 退款生命周期里的“执行退款”节点,内部可能用补偿事务处理支付、权益、账务回冲。

所以实际系统中常见的组合不是二选一,而是:

  • 长流程方法论 管生命周期
  • 大事务方法论 管关键节点一致性

5.7 一个务实的选型框架

可以用下面几个问题来判断是否应该升级为长流程系统:

  1. 这个业务对象是否要跨多个阶段存在,而不是一次请求内结束?
  2. 是否存在等待点,例如支付回调、审核、外部响应?
  3. 是否需要回退、驳回、重提、暂停、恢复?
  4. 是否经常要人工介入?
  5. 是否需要对外解释“它现在卡在哪、下一步是谁处理”?

如果上面大部分答案都是“是”,那它已经不再只是一个大事务,而是长生命周期业务流程。

进一步选型时,可以参考:

场景特征推荐方案
阶段有限、等待少、主控服务明确数据库状态机
阶段较多、超时和通知复杂、需要统一主视角应用层编排器
长等待、审批多、恢复和审计要求高Workflow 引擎
多系统自治、领域事件天然存在、中心编排意愿弱事件驱动流程

5.8 方法论沉淀:如何在真实系统中做出选择

到这里可以把整章压缩成一组可复用判断。

5.8.1 五个判断句

  1. 只要一个业务对象要跨多个阶段长期存在,它就不是单次请求问题,而是生命周期问题。
  2. 只要流程里有等待、审批、驳回和人工接管,就不要再用“大事务延长版”去硬扛。
  3. 生命周期系统的核心不是“接口调用顺序”,而是“阶段、流转规则、等待点和恢复点”。
  4. Workflow 只是长流程的一种实现方式,不是总概念;先定义流程治理模型,再决定是否引入引擎。
  5. 真实系统里最常见的组合是:用长流程管理生命周期,用大事务保证关键节点一致性。

5.8.2 面试与评审中的一句话表达

如果要把这一章压缩成一句可直接复用的话,可以这样讲:

长生命周期业务流程方法论,解决的不是某个关键动作怎么一次做完,而是一个业务对象如何跨阶段被持续推进、被明确治理、在异常时被安全恢复;状态机、编排器和 Workflow 只是不同复杂度下的实现手段。

第 6 章 任务处理方法论:从短任务、长任务到 Agent 协作

本章讨论的不是某个具体中间件,也不是某个特定框架的使用说明,而是一套跨传统后端系统、数据系统与 Agent 系统都成立的任务处理方法论。读完这一章,你应该能判断:一个任务到底该做成同步请求、异步消费、批处理、阶段化 Workflow、单 Agent、Stateful Agent Runtime,还是 Multi-Agent 协作系统。

很多工程问题表面上长得完全不同。

  • 供应商酒店数据同步,要跑十个小时,中途还会遇到接口限流和分页游标失效。
  • Spark 离线任务每天凌晨跑数仓加工,失败后要从某个 Stage 恢复,而不是全部重刷。
  • Flink 实时作业一直在跑,单条事件处理很快,但整个作业要长期维护状态、Checkpoint 和 Exactly-once 语义。
  • 代码 Agent 需要理解需求、扫描代码、修改多个文件、跑测试、等待人工确认,再继续下一轮执行。
  • 多 Agent 调研系统要让 Planner 拆任务、Worker 并行查资料、Reviewer 审核结论,最后再统一收敛。

如果只从技术栈看,这些系统横跨电商中台、大数据平台、流计算、机器学习和 AI Agent;如果只从执行方式看,它们却都属于同一类问题:任务无法在一次请求内闭环完成,必须跨时间、跨步骤、跨系统甚至跨角色持续推进。

这正是“任务处理方法论”要回答的问题。

很多团队在系统早期都能把东西做出来,但一旦任务变长、链路变深、依赖变多,问题就开始集中爆发:

  • 原本同步函数能跑通的逻辑,放大后开始超时、阻塞、重试风暴。
  • 原本塞进一个 MQ Consumer 的任务,后来长成“大 Consumer 泥球”,没人敢动。
  • 原本只靠对话上下文驱动的 Agent,任务稍微复杂就忘记做到哪一步、哪些动作已经执行过、哪些结果需要人工确认。
  • 原本只想“加一个自动化”,最后却变成没有状态边界、没有审计、没有回退路径的危险系统。

所以这一章不会从“工具怎么用”开始,而是从一个更根本的问题开始:任务应该如何被拆分、推进、治理和演进。


6.1 背景、目标与范围

6.1.1 为什么现在要建立任务处理方法论

系统设计讨论里,大家很容易把注意力放在“架构图长什么样”“数据库怎么拆”“缓存怎么配”“QPS 有多高”。这些当然重要,但在很多真实系统中,决定复杂度的往往不是单点组件,而是任务本身的执行形态

一个任务如果可以在单次请求里完成,工程重点通常是性能、正确性和接口契约;一个任务如果要持续十分钟、十小时甚至长期运行,工程重点立刻变成:

  • 怎么知道现在做到哪里了
  • 中间失败后从哪里恢复
  • 哪些步骤允许重试,哪些步骤不能重复执行
  • 哪些依赖失败可以降级,哪些失败必须阻断
  • 哪些结果必须经过审查、审批或人工确认
  • 当任务复杂到单个执行者已经扛不住时,是否需要拆成多个协作角色

传统后端系统早就被这些问题教育过,所以有了同步请求、异步任务、批处理、状态机、Workflow、补偿、对账、审批、回滚这些工程实践。现在 Agent 系统之所以越来越像“长任务系统”,不是因为它们变得传统了,而是因为只要任务一旦跨边界推进,就一定会重新遇到同样的状态、恢复与治理问题。

这就是现在必须做这件事的原因:我们不需要再把“传统系统”和“Agent 系统”当成两套割裂的方法论,而应该用一套统一框架理解它们。

6.1.2 本章目标

本章希望帮助读者建立三层能力。

第一层能力,是判断能力。你需要能快速判断一个问题到底更像短任务还是长任务,它的复杂度主要来自执行跨度、认知复杂度,还是协作复杂度。

第二层能力,是选型能力。面对同步任务、异步任务、Batch、Workflow、单 Agent、Stateful Agent Runtime、Multi-Agent 这些方案,能够知道它们分别适合什么场景、代价是什么、升级路径是什么。

第三层能力,是治理意识。也就是理解:真正让任务系统进入生产可用状态的,不是“能跑起来”,而是“能恢复、能审计、能降级、能回退、能演进”。

6.1.3 成功标准

如果这一章写得足够好,读者至少应该能回答以下问题:

  1. 短任务和长任务到底怎么区分,为什么不能只按耗时判断。
  2. 同步请求、异步任务、Batch、Workflow、Agent Runtime 和 Multi-Agent 分别是什么。
  3. 当前真实工程里有哪些典型长任务场景,它们为什么天然是长任务。
  4. 为什么很多 Agent 系统走着走着,会重新走到状态机、Checkpoint、审批和治理层上来。
  5. 当一个系统从简单走向复杂时,应该如何渐进演进,而不是一开始就过度设计。

6.1.4 本章明确不做什么

本章不尝试替代每一个具体领域的完整实现指南,例如:

  • 不展开讲 Spark 的算子执行机制和 Shuffle 内部细节。
  • 不展开讲 Flink 的状态后端、Barrier 对齐和 Watermark 算法实现。
  • 不展开讲某个特定 Agent 框架的 API 用法。
  • 不给出某个具体业务系统的全部表结构和部署脚本。

本章要做的,是在这些具体技术之上,抽象出一套更稳定的任务处理方法论。


6.2 现状、问题定义与典型长任务场景

6.2.1 当前系统通常怎么处理任务

大多数系统的任务处理方式,都经历过一个相似演进:

  1. 最开始是同步函数或同步接口,逻辑简单、调用直接、状态靠内存和调用栈保存。
  2. 接着为了削峰和解耦,开始把任务扔到 MQ 或后台 Worker。
  3. 任务再复杂一点,就出现批处理、定时 Job、分片执行、重试队列和补偿任务。
  4. 当任务带有明显阶段边界、审批节点和发布风险时,团队会走向显式 Workflow。
  5. 当任务不再只是“执行规则”,而是要理解目标、动态规划步骤、根据中间结果调整策略时,Agent Runtime 开始出现。
  6. 当规划、执行、审查三类职责冲突加剧,才会进一步考虑 Multi-Agent 协作。

问题在于,很多团队虽然现实中已经走到了第 3、第 4 甚至第 5 步,但脑子里仍然停留在第 1 步:用短任务思维去理解长任务问题。

6.2.2 典型错误:用短任务思维处理长任务

短任务思维的核心默认前提是:

  • 请求会很快结束
  • 执行现场不会丢
  • 出错后整体重试就行
  • 状态不需要被别人接手
  • 成功或失败很容易定义

这些前提一旦放到长任务里,几乎全部失效。

典型的错误做法包括:

把复杂长任务塞进一个同步接口

最初看起来实现很快:前端发请求,后端一口气做完抓取、转换、写库、发消息和索引更新。但只要数据量一大、依赖一抖、步骤一多,就会变成超时、线程阻塞、调用链炸裂、失败后无法恢复的系统。

把复杂流程直接塞进一个 MQ Consumer

这类系统刚开始往往运行正常,因为“反正异步了”。但异步只是把主链路压力移走,并没有解决状态治理。随着条件分支增加,Consumer 会长成一个没人敢改的大泥球:

  • 一个消息里同时处理抓取、校验、发布、通知
  • 出错时不知道该重试消息还是人工补数据
  • 顺序、幂等、状态边界全靠注释和经验维护

把 Agent 的对话历史当成正式状态系统

这是当前很多 Agent 系统最容易犯的错误。对话上下文当然能保存一部分信息,但它并不等于状态机,也不等于 Checkpoint,更不等于审计日志。只要任务跨会话、跨时间、跨角色,单靠上下文窗口就会立刻暴露问题:

  • 模型忘记之前做过什么
  • 中断后不能安全恢复
  • 某个工具调用到底有没有落地副作用说不清
  • 某个结果到底有没有经过 reviewer 审核说不清

6.2.3 如果不改,会发生什么

如果继续用错误的任务处理形态去承接更高复杂度的问题,后果通常不是“偶尔不优雅”,而是系统性失控:

  • 故障恢复时间持续拉长,因为没有恢复点,只能整批重跑。
  • 线上风险上升,因为危险副作用没有被阶段边界和审查机制隔离。
  • 团队维护成本上升,因为真正的任务状态散落在日志、数据库、队列和开发者脑子里。
  • Agent 系统幻觉和误动作放大,因为缺少显式 reviewer、policy 和人工接管点。

也就是说,问题并不是“有没有任务处理方法论都能做”,而是“没有方法论时,系统复杂度会以最坏的方式自己长出来”。

6.2.4 典型长任务场景

下面这些场景,今天在工程实践中都天然属于长任务。

场景一:供应商数据同步与主数据接入

例如酒店、机票、商品、库存、价格等供应商供给同步,或者 ERP / WMS / OMS / PIM 数据接入。

这类任务通常具备这些特征:

  • 数据量大,需要分页、分片或游标推进
  • 外部系统慢且不稳定
  • 中途失败后不能从头重跑
  • 结果会影响正式业务数据,必须有治理和发布保护

这正是 Batch + Checkpoint + Workflow 最典型的场景。

场景二:机器学习模型训练与模型交付

模型训练不是一次普通函数调用,而是一条很长的流水线:

  • 数据准备
  • 特征工程
  • 训练
  • 评估
  • 产物导出
  • 模型注册
  • 灰度发布

它天然需要:

  • 记录实验参数和版本
  • 保存中间产物
  • 按阶段恢复
  • 做上线前评估和审查

所以它本质上是一个长任务系统,只是执行者不是传统业务服务,而是训练集群和实验平台。

场景三:Spark 大数据离线任务

Spark 作业通常由多个 Stage、Shuffle、依赖表和资源调度共同构成。一个报表任务、画像计算任务或离线指标加工任务,虽然表面上是“跑个 ETL”,但实际上关注的是:

  • 数据分批和 DAG 拓扑
  • 上游依赖是否 ready
  • 某个 Stage 失败后如何恢复
  • 结果如何继续进入数仓、报表、特征平台

这类任务更接近 Batch / DAG Workflow

Flink 的特别之处在于:单条事件处理可能非常快,但整个作业是一个持续运行、持续维护状态、持续做 Checkpoint 和恢复的系统。

它的“长”,不是一次任务执行十小时,而是作业生命周期本身长期存在。换句话说,它不是“长时间才结束”,而是“长期运行并持续维护状态”的长任务。

场景五:Agent 长任务处理

例如:

  • 代码 Agent 理解需求、改多个文件、运行测试、失败后调整方案
  • 调研 Agent 分阶段检索、阅读、总结、交叉验证
  • 运维 Agent 查日志、查监控、生成诊断报告、等待人工确认后再继续

这些任务之所以是长任务,不是因为模型思考得久,而是因为:

  • 任务有多个步骤
  • 步骤之间要传递中间状态
  • 经常要等待人或系统反馈
  • 工具调用带副作用,不能随便重试

场景六:Multi-Agent 协作任务

例如复杂研发任务、深度研究任务、规划-执行-审查闭环系统。

这类任务之所以更复杂,是因为它不仅要管理执行状态,还要管理:

  • 角色边界
  • 共享状态
  • 通信协议
  • 终止条件
  • 审查和仲裁机制

它本质上是“协作型长任务”。

场景七:复杂业务流程与审批链路

例如:

  • 订单履约编排
  • 售后退款流程
  • 资金结算和对账
  • 内容审核发布
  • 风控审批和人工复核

这些任务显然不是单请求问题,而是带有组织角色、审批边界和正式业务副作用的长期推进问题。

场景八:基础设施与平台运维任务

例如:

  • 大规模数据迁移
  • 集群升级
  • 灰度发布
  • 索引重建
  • 批量回刷缓存

这类任务说明,长任务不仅存在于业务中台,也深度存在于基础设施层。

6.2.5 一个统一归纳

从当前主流工程现实来看,长任务大致可以归纳为五大类:

  1. 大批量数据处理类:供应商同步、Spark ETL、索引重建。
  2. 持续运行状态类:Flink 实时任务、实时风控、持续流处理。
  3. 高认知多步骤类:代码 Agent、调研 Agent、运维 Agent。
  4. 多角色协作类:Multi-Agent 规划-执行-审查协作。
  5. 高风险业务流程类:审批、发布、结算、履约编排。

这五类场景虽然技术栈不同,但都共享同一组约束:

  • 需要状态
  • 需要恢复
  • 需要治理
  • 需要审计
  • 往往需要分阶段推进

6.3 核心判断与关键设计决策

在正式讲各种方案之前,先把这章最关键的几个判断讲清楚。

6.3.0 先建立一个统一分类框架

很多任务讨论之所以容易跑偏,是因为团队一上来就直接争论“要不要上 Kafka”“要不要上 Airflow”“要不要上 Agent”,但没有先把任务本身分类。

更稳的做法,是先按下面五个维度给任务画像:

维度关注的问题
执行时长是不是能在一次请求里闭环,还是会持续分钟到天级
确定性是固定步骤,还是需要动态判断、规划和路径调整
状态需求是否需要记录进度、中间结果、恢复点
依赖复杂度是单系统问题,还是跨系统、跨团队、跨角色问题
失败影响失败后是简单重试,还是需要补偿、跳过、人工介入

按这五个维度看,很多常见任务就能快速归类:

  • 短任务:一次 HTTP 请求、简单 API 调用、单条消息处理,通常快速失败、快速重试。
  • 长任务:无法在单次请求或单个进程内完成,必须拆分、持久化状态并持续推进。
  • Agent 类任务:除了执行跨度,还带有推理、规划、工具调用和路径调整能力。

尤其在数据处理任务里,这五个维度几乎总会同时出现:数据量大、来源不稳、依赖多、恢复要求高、幂等要求强。所以很多数据平台问题,表面像“跑个任务”,本质上却是长任务治理问题。

6.3.1 判断一:短任务与长任务的区别,不在耗时,而在执行跨度

很多人以为“几秒内完成的是短任务,几小时完成的是长任务”。这是一种很常见但不准确的区分方式。

更准确的判断标准是:任务是否需要跨边界持续推进。

这里的边界包括:

  • 跨请求边界
  • 跨进程边界
  • 跨时间边界
  • 跨步骤边界
  • 跨系统边界
  • 跨角色边界

如果一个任务必须跨越这些边界中的多个,并且中途还需要持久化状态、支持恢复和接受治理,那么它本质上就是长任务。

6.3.2 判断二:Agent 不是另一套宇宙,它只是更智能的执行单元

把“传统系统”和“Agent 系统”完全对立起来,是近几年很常见的一种认知误区。

传统系统关注的是:

  • 任务定义
  • 状态推进
  • 幂等与补偿
  • 调度和治理

Agent 系统在这些事情之外,多出来的不是“抛弃这些约束”,而是:

  • 更强的语义理解能力
  • 更强的动态规划能力
  • 更强的工具选择和路径调整能力

所以更合理的理解是:Agent 给执行系统加了大脑,但并没有消灭任务系统的骨架。

6.3.3 判断三:不要按“技术潮流”选方案,要按复杂度来源选方案

一个方案是否合适,取决于复杂度主要来自哪里。

如果复杂度来自执行跨度,就要先解决状态、恢复和阶段推进问题。
如果复杂度来自认知判断,就要考虑单 Agent 或 Runtime。
如果复杂度来自职责冲突和并行协作,才考虑 Multi-Agent。

换句话说,合理的判断顺序应该是:

先看执行跨度
  → 再看认知复杂度
  → 最后看协作复杂度

6.3.4 判断四:默认从最小可行执行单元开始

很多系统真正的问题不是“起点太简单”,而是“起点太复杂”。

一个成熟架构师要克制地做决策:

  • 能用同步短任务解决,就不要先上 Workflow
  • 能用 Batch 解决,就不要默认上 Agent Runtime
  • 能用单 Agent 解决,就不要为了“更像 AI 系统”直接拆成多 Agent

这不是保守,而是尊重复杂度成本。

6.3.5 判断五:治理层不是附属品,而是长任务系统的核心竞争力

真正把系统带进生产环境的,不是“会执行”,而是“能在失败、异常和风险面前稳住”。

所以一旦进入长任务语境,设计必须天然包含:

  • 状态边界
  • 审查边界
  • 恢复路径
  • 回退路径
  • 权限和预算约束

6.4 执行方案谱系总览与选型框架

讲方法论,不能只讲抽象。我们还需要一个完整的“方案谱系”,把主流执行方案摆在同一张图上看。

6.4.1 七种主流执行方案

从简单到复杂,可以把主流方案归纳成七类:

  1. 同步短任务
  2. 异步任务 / MQ Consumer
  3. Batch / 分布式 Job
  4. 阶段化 Workflow
  5. 单 Agent
  6. Stateful Agent Runtime
  7. Multi-Agent

这七类方案不是互斥关系,也不意味着系统必须逐一经历。它们更像是一条复杂度逐级上升的执行谱系。

如果只记名字,很容易把它们误解成“七个平级工具箱”。更准确的理解是:它们分别对应七种不同的主导矛盾。

方案主导矛盾最适合解决的问题最容易失控的点
同步短任务请求内快速闭环简单查询、简单写入、轻量工具调用一旦跨时间就会开始阻塞和超时
异步任务 / MQ Consumer主链路不该被阻塞通知、缓存刷新、轻量后台处理逻辑越长越容易长成大 Consumer
Batch / 分布式 Job大批量对象如何推进ETL、全量同步、索引重建、修数没有 Checkpoint 时只能全量重跑
阶段化 Workflow多阶段流程如何治理审批、发布、复杂同步、长链路编排建模不足时流程边界会模糊
单 Agent不确定输入如何理解轻量分析、诊断、一次性辅助任务容易把推理能力误当治理能力
Stateful Agent Runtime多步智能任务如何恢复代码 Agent、调研 Agent、运维 Agent状态、审查、权限设计不到位会很危险
Multi-Agent单执行者已无法兼顾多职责规划-执行-审查闭环、复杂协作通信和共享状态成本急剧上升

所以这一节最重要的不是记住七个名词,而是建立一个感觉:

  • 前四类更偏传统执行系统。
  • 后三类更偏智能执行系统。
  • 真正决定是否升级,不是“想不想用新技术”,而是主导矛盾有没有变化。

6.4.2 一个统一的选型维度

判断该用哪一类方案时,我建议统一看九个维度:

维度关注的问题
执行跨度是否需要跨请求、跨时间、跨步骤推进
状态持久化中途失败后是否要从中间状态恢复
流程灵活性执行路径是预定义的还是动态规划的
治理要求是否需要审批、审查、审计、回退
认知复杂度是否需要推理、归纳、策略调整
协作复杂度是否需要多角色协作
风险等级任务副作用是否高风险
工程成本方案搭建、维护、观测成本有多高
可演进性后续是否容易平滑升级

这九个维度看起来很多,但它们的重要性并不相同。实际评审时,我建议先抓三层优先级:

第一层,先看“任务会不会跨边界持续推进”:

  • 执行跨度
  • 状态持久化
  • 风险等级

如果这三项判断错了,后面的所有选型基本都会跑偏。因为你可能会拿一个同步接口去硬扛本该有 Checkpoint 的长任务,或者拿一个单 Agent 去承接本该有人工审查的高风险动作。

第二层,再看“执行路径是固定的还是动态的”:

  • 流程灵活性
  • 认知复杂度

这一步决定你是在传统 Workflow 体系里继续前进,还是要引入 Agent。

第三层,最后再看“是不是已经复杂到需要多人分工”:

  • 协作复杂度
  • 治理要求
  • 工程成本
  • 可演进性

也就是说,不要一上来就讨论 Multi-Agent。大多数系统根本不是卡在“协作不够”,而是卡在“状态没建好、恢复点没定义、风险边界没隔离”。

6.4.3 一个很实用的决策顺序

可以把选型过程压缩成下面这个顺序:

  1. 如果不需要状态持久化,优先考虑 同步短任务单 Agent
  2. 如果只是主链路不适合同步阻塞,但流程简单,优先考虑 异步任务 / MQ Consumer
  3. 如果要处理大批量对象并支持恢复,优先考虑 Batch / 分布式 Job
  4. 如果存在显式阶段边界和强治理要求,优先考虑 阶段化 Workflow
  5. 如果长任务同时需要动态规划和策略调整,升级到 Stateful Agent Runtime
  6. 只有当单执行单元无法同时承担规划、执行、审查职责时,才进入 Multi-Agent

如果把这套顺序翻译成更像评审会里的提问方式,大概会是下面这样:

第一问:这个任务能不能在一次请求里闭环?
  能
    → 同步短任务,必要时用单 Agent 做轻量认知增强
  不能
    → 进入长任务语境

第二问:它只是要异步化,还是要显式恢复?
  只是异步化
    → 异步任务 / MQ Consumer
  需要显式恢复
    → Batch / Workflow

第三问:它的复杂度主要来自“海量对象推进”还是“多阶段治理”?
  海量对象推进
    → Batch / 分布式 Job
  多阶段治理
    → 阶段化 Workflow

第四问:固定流程已经不够,是否还需要动态推理和策略调整?
  不需要
    → 继续留在传统执行系统
  需要
    → Stateful Agent Runtime

第五问:单个智能执行者是否已经无法同时承担规划、执行、审查?
  还可以
    → 单 Agent / Runtime
  不可以
    → Multi-Agent

这棵判断路径背后的核心思想其实很朴素:

  • 先判断有没有“长任务骨架”问题。
  • 再判断有没有“智能决策”问题。
  • 最后才判断有没有“协作分工”问题。

顺序一旦反过来,团队就很容易在基础治理能力没建好的时候,过早引入复杂智能架构。

6.4.4 很容易混淆的三个方案:异步任务、Batch 与 Workflow

很多团队在长任务建设初期,最容易混淆的不是 Agent,而是下面三个传统方案:

  • 异步任务 / MQ Consumer
  • Batch / 分布式 Job
  • 阶段化 Workflow

它们都能“异步干活”,但解决的问题层次完全不同。

方案核心目标典型边界最强能力最常见问题
异步任务 / MQ Consumer把动作从主链路剥离一个消息处理一个相对单一的后台动作解耦、削峰、快速接入容易长成大 Consumer 泥球
Batch / 分布式 Job大批量对象推进与恢复批次、分片、页、游标Checkpoint、分片并行、持续跑完阶段治理和审计能力偏弱
阶段化 Workflow多阶段流程推进与治理Stage、审批、发布、人工介入显式状态、强治理、可审计建模成本更高

可以把它们的差别记成三句话:

  • 异步任务解决的是“不要阻塞主链路”。
  • Batch解决的是“海量对象怎么分批推进并从中间恢复”。
  • Workflow解决的是“多阶段流程怎么治理、审计和人工接管”。

对供应商酒店同步这类问题来说:

  • 如果只是“收到变更后异步刷新一个缓存”,MQ Consumer 就够。
  • 如果是“每天全量拉几百万酒店并支持失败续跑”,更像 Batch
  • 如果还要再加上清洗、校验、质检、发布、人工审核,那就已经是 Workflow

接下来,我们按方案逐个展开。


6.5 方案一:同步短任务

6.5.1 定义与特点

同步短任务是最基础、最常见的执行形态。它通常在单次请求、单次 RPC 或单次函数调用里闭环完成。

它的典型特点是:

  • 请求内完成
  • 执行路径固定
  • 状态主要存在调用栈和内存里
  • 失败通常整体报错或整体重试

6.5.2 适用场景

同步短任务适合以下类型的问题:

  • 查询接口
  • 单次规则计算
  • 单次写操作
  • 简单聚合接口
  • 轻量工具调用
  • 一次性问答型 Agent

例如,一个商品详情查询接口、一个优惠券可用性校验接口、一个简单的 Seatalk 机器人问答,都更像短任务。

6.5.3 端到端链路与职责边界

同步短任务的端到端链路通常很直接:

Input
  → Validation
  → Business Logic
  → Optional Dependency Calls
  → Response

在这种模式下,职责边界比较清晰:

  • 网关负责接入、鉴权、限流
  • 应用负责执行业务逻辑
  • 下游服务或数据库负责提供依赖能力

同步 / 异步边界几乎不存在,所有动作都发生在一个请求上下文里。

6.5.4 契约、幂等与失败处理

同步短任务虽然简单,但并不意味着可以没有契约意识。

至少要明确:

  • 请求字段的必填、可选和默认值
  • 响应字段的语义和兼容性
  • 写操作是否需要幂等键
  • 下游超时后是失败、重试还是默认降级

短任务的失败处理通常比较直接:

  • 读请求:可整体重试或快速失败
  • 写请求:如果有副作用,必须明确幂等语义

6.5.5 优缺点与边界

优点:

  • 实现成本低
  • 调试简单
  • 调用链直观
  • 对团队认知负担小

缺点:

  • 不擅长跨步骤恢复
  • 不适合长时间阻塞
  • 不适合复杂状态推进
  • 危险副作用治理能力弱

边界很明确:一旦任务开始跨时间、跨步骤、跨系统持续推进,同步短任务就不应再继续硬扛。


6.6 方案二:异步任务与 MQ Consumer

6.6.1 定义与特点

异步任务的核心价值,是把“不适合同步阻塞的动作”从主链路中剥离出来。

常见模式是:

主请求
  → 写 DB / 发 MQ
  → 立即返回

后台 Consumer / Worker
  → 处理后续动作

6.6.2 适用场景

常见适用场景包括:

  • 发通知
  • 刷新缓存
  • 生成报表
  • 回写非核心下游
  • 执行轻量后台任务

这类任务的共同点是:异步化能改善主链路体验,但任务本身通常仍然比较单一。

6.6.3 端到端链路与同步 / 异步边界

异步任务的关键边界,在“主链路何时结束,后台任务何时接手”。

常见链路是:

Request
  → Persist Intent / Publish Event
  → Return

Consumer
  → Consume Message
  → Execute Side Effect
  → Ack / Retry / DLQ

这类系统里最重要的设计点不是“怎么发 MQ”,而是:

  • 消息什么时候被认为已可发布
  • 失败后由谁负责重试
  • 主链路和异步链路的状态怎么对齐

6.6.4 消息契约、顺序与重试

异步任务至少要显式定义:

  • 消息体字段语义
  • 幂等键
  • 顺序要求
  • 消费成功的判定条件
  • 最大重试次数
  • 进入 DLQ 的条件

如果这些没有定义清楚,异步化只会把问题从接口层转移到消息层。

6.6.5 常见反模式:大 Consumer 泥球

这是非常值得警惕的反模式。

当团队把“复杂长任务”都扔给一个 Consumer 处理时,系统会慢慢演化成:

  • 一个消息触发十几个副作用
  • 顺序逻辑、补偿逻辑、回退逻辑全部塞在一起
  • 状态只存在日志里,没有正式状态契约

这说明系统已经超出了“异步任务”的适用边界,应该升级到 Batch 或 Workflow。

6.6.6 优缺点与边界

优点:

  • 解耦主链路
  • 削峰填谷
  • 改善用户响应时间

缺点:

  • 状态通常较粗
  • 调试比同步任务复杂
  • 一旦流程变深,极易失控

边界结论:异步任务解决的是“异步化”,不是“复杂长任务治理”。


6.7 方案三:Batch 与分布式 Job

6.7.1 定义与特点

Batch 与分布式 Job 是传统工程里最典型的长任务方案之一。它们面向的是大批量对象处理和较长时间窗口推进。

它们的典型特点是:

  • 按批次组织任务
  • 按分片、分页、游标或时间窗口推进
  • 支持 Checkpoint
  • 支持续跑、重试和补偿

6.7.2 适用场景

最常见的适用场景包括:

  • 供应商全量同步
  • Spark 离线计算任务
  • 数仓 ETL
  • 索引重建
  • 大规模历史数据修复
  • 账务和库存对账

6.7.3 任务切分、分片与 Checkpoint

Batch 的核心不是“后台慢慢跑”,而是可推进、可追踪、可恢复

所以它必须回答:

  • 任务如何切分成批次
  • 批次如何切分成分片或页
  • 每个分片完成到哪里算一个安全恢复点

典型 Checkpoint 可以是:

  • 最后一页页码
  • 最后一个游标
  • 最后处理 ID
  • 已完成 shard 列表

没有 Checkpoint 的 Batch,本质上只是一个长循环脚本,不是成熟的长任务方案。

6.7.4 状态契约与执行推进

Batch 系统通常至少需要三层状态:

  • 任务级状态:这个批次是否开始、是否结束
  • 分片级状态:哪个 shard 完成了,哪个仍在跑
  • 对象级状态:哪条记录成功、失败、跳过或待补偿

很多系统失败就失败在只有任务级状态,没有对象级状态。最终看起来“批次成功了”,但具体错了哪些对象,根本追不出来。

6.7.5 依赖、风险与治理

Batch 方案的治理重点通常包括:

  • 并发度控制
  • 资源调度
  • 重试与 DLQ
  • checkpoint 恢复
  • 人工补数据
  • 结果审计

对于 Spark 这类任务,治理重点偏资源调度与 Stage 恢复;对于供应商同步这类任务,治理重点偏对象级状态、数据质量与发布保护。

6.7.6 优缺点与边界

优点:

  • 非常适合大批量对象处理
  • 适合显式恢复
  • 工程实践成熟

缺点:

  • 交互性较弱
  • 对动态规划支持弱
  • 阶段治理能力取决于额外建模

边界结论:当任务主要难点是“批量推进与恢复”,Batch 是主力方案;当难点进一步变成“阶段治理和强审计”,就该升级到 Workflow。

6.7.7 数据处理长任务的六步法

如果把供应商同步、ETL、数仓加工、索引重建这类任务抽象成一套统一方法,我更推荐下面这套六步法:

第一步:任务拆分

先不要急着写 Worker,而是先定义任务单元怎么拆。

常见切分方式包括:

  • 按国家、城市、商家、仓库等业务维度分片
  • 按时间窗口分片
  • 按页、游标、ID 范围分片
  • 按阶段拆成 Extract -> Transform -> Load -> Validate -> Publish

拆分的目标不是“并行看起来很高级”,而是让任务天然具备可推进、可恢复、可隔离失败的结构。

第二步:状态管理与 Checkpoint

数据任务必须有正式恢复点。至少要能回答:

  • 已处理到哪个游标或页码
  • 已完成哪些 shard
  • 哪些对象成功、失败、跳过
  • 当前任务版本和输入快照是什么

成熟做法通常会组合使用:

  • 进度元数据表
  • 对象级状态表
  • 中间结果存储
  • 输入版本号或哈希校验

第三步:幂等性设计

所有写操作默认都应该假设会被重复执行。

这意味着:

  • 写库优先使用 UPSERT、唯一键约束或版本覆盖
  • 对外副作用要有幂等键
  • 同一批次、同一对象重复处理不能制造额外副作用

一个很实用的经验是:把幂等键设计成“任务 ID + 批次 / 分片 ID + 对象 ID”。

第四步:错误处理与重试策略

不要把所有失败都当成一种失败。

更合理的分层是:

  • 瞬时错误:限流、超时、短暂网络抖动,适合指数退避重试
  • 部分失败:允许子批次或单 shard 重试,不必全量重跑
  • 永久失败:进入 DLQ、失败池或人工处理队列

真正决定工程质量的,往往不是“会不会重试”,而是“能不能只重试该重试的那一小部分”。

第五步:编排与治理

当数据任务进入正式生产后,光“跑完”是不够的,还必须具备治理能力:

  • 依赖管理
  • 并发度控制
  • 结果发布保护
  • 审批和人工介入点
  • 成功率、耗时、数据漂移、资源消耗监控

这也是为什么很多 ETL / 同步任务最后会从简单脚本升级到 Airflow、Temporal 或内部 Workflow 平台。

第六步:演进与优化

长任务系统几乎都是逐步演进出来的。

典型路径往往是:

串行脚本
  → 分片并行 Batch
  → 带 Checkpoint 的 Pipeline
  → 带治理能力的 Workflow
  → 在局部阶段引入 Agent 辅助判断

比如地址标准化、类目映射、异常记录归因这类“规则难穷尽但风险可控”的环节,就很适合在稳定 Workflow 骨架里嵌入 Agent,而不是反过来让 Agent 主导整个数据链路。


6.8 方案四:阶段化 Workflow

6.8.1 定义与特点

阶段化 Workflow 是在 Batch 之上进一步显式建模“阶段边界、职责边界和治理边界”的方案。

它的核心思路不是把任务拆得越细越好,而是把天然不同性质的步骤拆开。

6.8.2 适用场景

典型适用场景包括:

  • 供应商数据同步
  • 审批流
  • 复杂业务流程
  • 跨系统数据发布
  • 内容审核与正式发布

6.8.3 端到端链路与职责拆分

以供应商同步为例,一个成熟的 Workflow 往往天然会拆成下面这类职责:

  • Sharder:定义边界和调度单位
  • Fetcher:负责忠实拉取外部数据
  • Transformer:负责标准化和语义映射
  • Publisher:负责正式发布和写前保护

关键不在“分四步”,而在于:

  • 把慢源不稳定性和正式发布风险隔离开
  • 把原始数据、标准数据和正式数据隔离开
  • 让每个阶段都能独立观察、恢复和治理

6.8.4 状态、事件与幂等

Workflow 的核心资产通常包括:

  • 阶段状态
  • 阶段间事件
  • 对象级状态 Ledger
  • 幂等约束
  • 版本保护

这类系统特别适合引入显式状态机,因为“当前能不能进入下一阶段”本身就是一种正式业务规则。

6.8.5 降级、熔断、人工接管与回滚

Workflow 系统之所以适合高风险场景,是因为它很容易内建治理能力:

  • 某一阶段异常时可以熔断,不继续放大影响
  • 某些对象异常时可以进入人工审查或补偿池
  • 发布前可以做 review gate
  • 高风险操作可以要求审批

6.8.6 优缺点与工程价值

优点:

  • 职责边界清晰
  • 治理能力强
  • 审计和补偿更自然
  • 适合高风险长任务

缺点:

  • 建模成本高
  • 对前期设计要求高
  • 不适合边界尚不稳定的小任务

边界结论:Workflow 不是为了让系统显得高级,而是为了解决强治理长任务的结构化问题。


6.9 方案五:单 Agent

6.9.1 定义与特点

单 Agent 的核心不是“有一个聊天机器人”,而是让一个执行单元同时具备:

  • 目标理解
  • 步骤规划
  • 工具选择
  • 结果整合

在很多轻量任务里,它比规则系统灵活得多。

6.9.2 适用场景

典型适用场景:

  • 问答
  • 查询分析
  • 轻量诊断
  • 一次性辅助任务
  • 小范围代码解释或修改建议

6.9.3 输入、推理、工具调用、输出链路

单 Agent 的典型链路是:

Input
  → Reasoning
  → Tool Selection
  → Tool Execution
  → Output

它非常适合“高认知密度、低执行跨度”的问题。

6.9.4 上下文、结果和工具契约

单 Agent 虽然灵活,但仍然需要约束:

  • 输入边界要清楚
  • 工具权限要受控
  • 输出结构最好可解析
  • 高风险结果不能直接信任

6.9.5 失败、审查与边界控制

单 Agent 最大的误区,是把“会推理”误当成“会治理”。

它的天然边界包括:

  • 上下文窗口有限
  • 状态持久化能力弱
  • 恢复能力弱
  • 工具副作用控制弱

所以单 Agent 更像“认知增强的短任务执行器”,而不是完整长任务系统。

6.9.6 优缺点与边界

优点:

  • 灵活
  • 接入成本低
  • 对不确定输入友好

缺点:

  • 不擅长长链路恢复
  • 容易出现上下文膨胀
  • 治理能力取决于额外框架

边界结论:单 Agent 适合做起点,但不能天然承担所有长任务系统职责。


6.10 方案六:Stateful Agent Runtime

6.10.1 定义与特点

Stateful Agent Runtime 的核心价值,在于把 Agent 从一次模型调用,提升为一个被 Runtime 托管的长任务执行单元。

它典型会包含:

  • Goal
  • Planner
  • Step Queue
  • Executor
  • State Store
  • Reviewer
  • Policy Layer
  • Resume Loop

6.10.2 适用场景

最典型的适用场景包括:

  • 代码 Agent
  • 调研 Agent
  • 运维 Agent
  • 多步业务助手
  • 需要暂停、恢复、审查的智能执行系统

6.10.3 在 Agent 中如何落地 Task / Step / State / Checkpoint

这是 Runtime 和单 Agent 的根本区别。

在 Runtime 中:

  • Task 不再只是 prompt,而是正式任务定义
  • Step 不再只是隐式思考过程,而是显式执行单元
  • State 不是聊天历史,而是正式状态存储
  • Checkpoint 是恢复边界,而不是“模型大概记得做到哪了”

6.10.4 状态、事件、审查与策略契约

一个成熟的 Agent Runtime 至少要定义:

  • 任务状态
  • 步骤状态
  • 等待状态
  • 工具调用结果状态
  • reviewer 通过 / 拒绝 / 返工状态
  • 预算和风险策略

这说明它本质上已经重新走到长任务系统的核心结构上来了。

6.10.5 暂停恢复、人工确认、回退与治理

这类系统天然要支持:

  • 中途暂停
  • 等待人工确认
  • 等待外部事件
  • 基于 checkpoint 恢复
  • 危险动作回退或阻断

这些能力不是可选增强,而是 Agent 长任务要进入生产环境的基本要求。

6.10.6 优缺点与升级条件

优点:

  • 保留 Agent 的认知灵活性
  • 具备长任务治理能力
  • 适合复杂多步任务

缺点:

  • 系统复杂度明显上升
  • 对观测和审计要求高
  • 设计失误时更容易出现隐蔽问题

升级条件也很明确:当单 Agent 已经无法稳定承载长链路执行时,Runtime 才值得引入。


6.11 方案七:Multi-Agent 协作

6.11.1 定义与特点

Multi-Agent 不是“多几个模型一起聊”,而是把规划、执行、审查、协调等不同职责拆给不同角色化执行单元。

6.11.2 适用场景

典型适用场景:

  • 复杂研发任务
  • 并行调研
  • 规划-执行-审查闭环
  • 跨领域分析任务

6.11.3 角色划分与协作链路

最小可行结构通常是三角模型:

  • Planner
  • Worker
  • Reviewer

在此基础上可以继续扩展:

  • Supervisor
  • Router
  • Specialist Agent
  • Memory Manager

6.11.4 共享状态、消息契约与终止条件

Multi-Agent 最难的地方,不是“再多接几个模型”,而是:

  • 状态谁持有
  • 消息如何交接
  • 什么时候返工
  • 什么时候结束
  • 谁拥有最终裁决权

如果这些问题不显式定义,系统很快就会进入循环讨论、重复劳动和责任稀释。

6.11.5 审查、仲裁、风险控制与人工接管

Multi-Agent 一旦落地,治理比单 Agent 更重要。

需要重点控制:

  • 角色权限
  • 通信协议
  • 终止条件
  • 共享状态
  • 风险升级路径
  • 人工接管入口

6.11.6 优缺点与过度设计边界

优点:

  • 适合复杂任务分治
  • 适合隔离职责冲突
  • 便于引入 reviewer 和 specialist

缺点:

  • 通信成本高
  • 责任边界复杂
  • 调试和治理难度高
  • Token 和时间成本显著上升

边界结论:Multi-Agent 不是默认架构,而是当单执行单元和单 Runtime 已经无法良好承载复杂职责时的一种受控分工机制。


6.12 跨方案对比:如何选择合适的任务处理方案

讲完七类方案后,我们需要把它们拉回同一张表里比较。

6.12.1 一张统一对比表

方案状态持久化恢复能力流程灵活性治理能力工程成本典型场景
同步短任务查询、单次写入、轻量问答
异步任务 / MQ Consumer弱到中低到中低到中通知、缓存刷新、轻量后台处理
Batch / 分布式 Job中到强数据同步、Spark 任务、索引重建
阶段化 Workflow中到高供应商同步、审批流、复杂发布
单 Agent弱到中低到中问答、分析、轻量诊断
Stateful Agent Runtime中到强代码 Agent、调研 Agent、运维 Agent
Multi-Agent中到强很强中到强很高复杂协作、规划执行审查闭环

6.12.2 一条渐进式升级路径

实践里最常用的升级路径通常是:

同步短任务
  → 异步任务
  → Batch / Workflow
  → 单 Agent
  → Stateful Agent Runtime
  → Multi-Agent

当然,系统不一定严格按这个顺序走,但背后的方法论是稳定的:

  • 先解决执行跨度
  • 再解决认知问题
  • 最后解决协作问题

6.12.3 三个常用的选型建议

建议一:默认从最小可行方案起步

不要用 Multi-Agent 去解决一个同步接口就能解决的问题,也不要为一个短生命周期脚本搭一整套 Workflow 平台。

建议二:一旦出现状态恢复需求,就停止使用短任务思维

这是很多系统的分水岭。只要你开始认真讨论 checkpoint、人工恢复、局部重试,就说明任务已经进入长任务语境。

建议三:治理问题不能靠“多加一点 prompt”解决

很多 Agent 系统的问题,本质上不是模型不够聪明,而是状态、审查、权限和回退机制没建出来。


6.13 本章小结

这一章想建立的,不是一套新名词,而是一种更稳定的系统设计视角。

6.13.1 先分清复杂度来自哪里

系统设计里最容易犯的错误,是在错误的维度上用力。
有的任务难在执行跨度,有的难在认知判断,有的难在协作治理。方案选型必须先识别复杂度来源。

6.13.2 短任务、长任务、Agent 和 Multi-Agent 不是割裂概念

它们本质上都是统一执行模型在不同复杂度区间下的不同实现。区别只是:

  • 执行跨度有多大
  • 状态是否需要外置持久化
  • 是否需要动态规划
  • 是否需要多角色协作

6.13.3 真正成熟的任务系统,拼的不是“会不会执行”,而是“会不会治理”

无论你最后选择的是 Batch、Workflow、Agent Runtime 还是 Multi-Agent,只要系统进入长任务区间,就绕不开以下核心能力:

  • 状态
  • 恢复
  • 幂等
  • 补偿
  • 审计
  • 风险控制
  • 人工接管

6.13.4 最后压缩成三条原则

如果要把本章压缩成三句话,我建议记住这三条:

  1. 先看执行跨度:是否需要跨边界持久推进。
  2. 再看认知复杂度:是否需要动态规划和工具选择。
  3. 最后看协作复杂度:是否需要角色分工与审查闭环。

只有把这三个问题分开看,任务处理方案的选型才会真正清楚。

第 7 章 高准确性与强一致性系统设计方法论:支付、库存与账务场景

当系统面对支付、库存、账务、钱包、积分等“结果必须正确”的业务时,设计重点不再只是流程能不能跑通,而是事实能不能被严格定义、状态能不能被安全推进、错误能不能被可审计地恢复。

在上一章里,我们讨论了大事务如何通过状态机、Saga 和工作流来处理跨系统流程推进问题。但不是所有大事务都处在同一个风险级别上。

对于营销推送、审批流、供应商同步这类流程,允许一定程度的最终一致性,通常问题不大。
但对于支付、订单金额、库存扣减、账务记账、钱包余额、积分发放这类系统,如果出现重复执行、状态错乱、金额丢失、库存超卖,后果往往不是“用户体验差一点”,而是直接导致资损、合规风险和人工对账成本失控。

所以这一章讨论的不是“所有大事务”,而是其中要求最高的一类:

高准确性与强一致性场景,本质上是在高并发和分布式条件下,想办法让业务事实始终正确、状态始终可解释、异常始终可恢复。

7.1 什么叫高准确性与强一致性场景

先把“高准确性”和“强一致性”拆开理解。

  • 高准确性:强调业务结果不能算错、扣错、记错、发错。
  • 强一致性:强调关键状态在某个时刻必须具备唯一、明确、不可歧义的事实定义。

这两个概念通常一起出现,但关注点并不完全相同。

例如:

  • 支付成功后,账务金额必须一分不差,这是高准确性。
  • 同一笔订单不能同时处于“已支付”和“未支付”,这是强一致性。
  • 同一件库存不能被两个请求同时成功扣减,这是强一致性。
  • 钱包余额更新后不能凭空多出或少掉金额,这是高准确性。

这类系统通常有四个共同特征:

  1. 错误代价高:一旦出错,常常直接变成资损、投诉或审计问题。
  2. 状态必须唯一:系统不能长期容忍“到底成功没成功说不清”的状态。
  3. 重复执行危险:重复扣款、重复扣库存、重复发券都会制造真实副作用。
  4. 事后必须可追溯:要能回答“谁在什么时间对哪笔事实做了什么动作”。

典型案例包括:

  • 支付、退款、清结算、钱包、积分、优惠核销
  • 订单金额确认、应收应付计算、税费和手续费入账
  • 库存预占、库存确认、库存回补、防超卖
  • 财务对账、账本修复、补单补账
  • 合约执行、链上写入、不可变日志相关系统

7.2 这类问题为什么不能只靠“最终一致性”口号解决

很多团队在业务初期会说一句话:“先异步化,最终一致就行。”
这句话对很多普通业务是成立的,但对支付、库存、账务这类场景,常常是不够的。

原因是这里存在一个关键差异:

  • 普通流程更关心“任务最终有没有完成”
  • 强一致流程更关心“中间任何时刻的业务事实能不能被信任”

举几个典型反例:

7.2.1 支付成功但订单仍显示未支付

如果只是延迟几秒展示,问题不大;
但如果系统因此再次发起扣款、重复触发履约,问题就从“延迟一致”变成了“事实错误”。

7.2.2 库存异步扣减导致超卖

如果下单成功只是先记一条消息,稍后再去扣库存,那么在高并发情况下,同一个库存窗口可能已经被多次卖出。
这时后续补偿虽然能把一部分订单取消,但超卖本身已经发生,用户体验和业务承诺都被破坏了。

7.2.3 钱包余额更新依赖多次异步拼接

如果余额不是由账本严格推导,而是通过多个异步任务“慢慢修正”,那么一旦消息乱序、重复消费、部分失败,系统就很难保证余额始终正确。

所以一个很重要的判断是:

在高准确性场景里,不能把所有问题都外包给“最终一致性”;必须先划清哪些环节必须同步确认、哪些环节可以异步收敛。

7.3 设计原则:先保护事实,再优化吞吐

这类系统的设计原则和普通高吞吐系统不同。这里不是先追求“快”,而是先追求“对”。

7.3.1 在 CAP 里优先保护 CP 核心段

不要教条地说“整个系统必须 CP”,因为互联网交易链路通常仍然包含通知、积分、营销等 AP / 最终一致部分。
更务实的做法是:

  • 识别真正的强一致核心段
  • 在核心段内优先保证正确性和排他性
  • 把外围可延迟的步骤拆出去异步化

例如创单流程里,下面这些更接近强一致核心段:

  • 订单金额确认
  • 库存预占
  • 订单创建
  • 支付状态落账

而这些可以作为外围异步段:

  • 站内信通知
  • 积分发放
  • 推荐系统更新
  • 营销埋点

7.3.2 缩小强一致边界

很多系统设计失败,不是因为没有一致性方案,而是因为试图把整条链路都做成全局强一致。

正确思路通常是:

  1. 找出真正不可错的业务事实。
  2. 只对这些事实建立强约束。
  3. 其他动作通过事件、补偿和对账来收敛。

这也是为什么成熟系统很少对整条交易链路直接上 XA / 2PC,而是把问题拆成:

  • 核心事实在本地事务内原子提交
  • 跨系统传播通过 Outbox 或事件驱动保证不丢
  • 外围动作用 Saga / 补偿收敛

7.3.3 广泛使用“预占 + 确认 / 取消”模式

这是支付、库存、额度、券核销里最常见也最有效的模型。

它的本质是把一次危险的“直接扣减”拆成两个阶段:

  1. 先预占资源,防止并发冲突
  2. 再根据后续结果确认或取消

库存如此,支付授权如此,履约额度如此,优惠占用也如此。

这种模式的优势在于:

  • 能缩短真正需要强锁定的时间
  • 能把失败恢复变成显式取消动作
  • 更容易支持超时释放和审计

7.3.4 幂等、防重、去重必须内建,而不是补丁

强一致场景里最危险的事故,很多不是“完全失败”,而是“成功被执行了两次”。

所以必须默认系统会遇到:

  • 请求重试
  • 回调重复
  • 消息重复投递
  • 任务重复执行
  • 人工补单重复触发

应对方式不是事后排查,而是把幂等性设计成系统契约:

  • 外部请求要有业务幂等键
  • 状态迁移要有唯一约束
  • 账务流水要有不可重复入账语义
  • 补偿动作也必须幂等

7.3.5 审计能力和对账能力不是附属品

只要资金、库存、账本参与进来,系统就必须默认未来一定会出现:

  • 下游超时
  • 第三方状态不确定
  • 多系统状态分叉
  • 人工修复需求

所以从第一天起就要准备:

  • 权威账本或权威状态源
  • 完整操作流水
  • 可追踪的状态迁移记录
  • 周期性或实时对账机制

没有这些,所谓“强一致”通常只是运行时看起来没问题,出事时却无法解释。

7.4 方案选型:从本地事务到 Saga、TCC 与账本系统

强一致不是只有一种实现方式。不同场景,关键矛盾不同,方案也不同。

7.4.1 本地事务:最强、最简单,也最值得优先保留

如果关键事实能够收敛在一个数据库、一个服务、一个聚合边界里,本地事务永远是第一选择。

适用场景:

  • 单服务内订单金额确认
  • 单库内库存扣减
  • 单账本内余额更新与流水落库

优点:

  • 语义清晰
  • 成本最低
  • 最容易保证 ACID

限制:

  • 很难直接跨服务、跨库、跨第三方系统扩展

经验上,只要能通过领域边界调整把关键事实压缩进本地事务,就不要轻易放弃这个机会。

7.4.2 本地事务 + Outbox:跨系统传播的务实主力方案

很多场景真正要解决的问题不是“跨系统一起提交”,而是:

核心事实已经在本地事务里确定了,如何把这个事实可靠传播给其他系统,而且不能丢、不能重复制造错误副作用。

这时很适合使用 Outbox Pattern

  1. 在本地事务里同时写入业务事实和待发送事件
  2. 后台可靠投递事件
  3. 下游按幂等语义消费

适用场景:

  • 支付成功后通知订单系统
  • 订单创建后触发履约、通知、积分
  • 账务记账后通知报表和风控系统

优点:

  • 保留本地事务强语义
  • 避免双写不一致
  • 更符合互联网系统演进路径

限制:

  • 只能保证“本地事实 + 可靠发布”
  • 不能替代跨系统同步锁定

7.4.3 预占 + 确认 / 取消:库存、额度、券核销的默认模型

当资源需要先锁住、再根据后续结果决定是否真正消耗时,这种模式通常比直接扣减更稳。

适用场景:

  • 库存预占 -> 支付成功后确认 -> 超时后释放
  • 钱包冻结 -> 交易成功后扣减 -> 失败后解冻
  • 优惠券占用 -> 下单完成后核销 -> 取消后返还

优点:

  • 天然适合高并发竞争场景
  • 失败恢复路径清晰
  • 方便处理超时和人工介入

限制:

  • 需要额外状态设计
  • 需要后台释放和清理机制

7.4.4 Saga:适合强主控但允许阶段收敛的跨系统流程

如果一条链路必须跨多个系统推进,但又不值得上全局分布式事务,那么 Saga 是很常见的选择。

适用前提:

  • 每一步都可以有独立本地事务
  • 失败后存在明确补偿动作
  • 系统能容忍阶段性中间态

这在创单、退款、售后、履约链路里都很常见。

但要注意:

  • Saga 不是强原子提交
  • 它依赖状态推进与补偿收敛
  • 它更适合“流程一致性”,不适合直接承担“账本绝对原子一致性”

7.4.5 TCC:适合资源竞争强、预留语义清晰的场景

Try-Confirm-Cancel 可以理解成更严格、更结构化的“预占 + 确认 / 取消”。

适用场景:

  • 库存预留
  • 余额冻结
  • 授信额度占用
  • 稀缺资源抢占

优点:

  • 语义清晰
  • 能显式表达资源占用生命周期
  • 对高冲突资源友好

限制:

  • 对参与方接口设计要求高
  • 业务改造成本大
  • 并不适合所有外部系统

如果系统本身没有清晰的资源预留语义,硬上 TCC 往往会让实现比问题更复杂。

7.4.6 分布式锁:只解决并发互斥,不解决业务全局一致

很多团队一遇到库存或金额问题,第一反应是“加锁”。这只说对了一小部分。

分布式锁能解决的是:

  • 同一资源被并发修改时的互斥问题

但它解决不了:

  • 跨系统提交原子性
  • 回调重复导致的副作用
  • 事件丢失
  • 账本对账问题

所以像 Redlock 这样的方案,最多只是强一致系统里的一个局部组件,而不是总方案。

7.4.7 XA / 2PC:理论完整,但互联网主链路要慎用

XA / 2PC 的优点很诱人:多资源统一提交,失败统一回滚。
但现实里的问题也非常明显:

  • 长事务影响吞吐
  • 锁持有时间长
  • 参与方约束强
  • 对第三方系统无能为力
  • 故障恢复和运维复杂

因此它更适合:

  • 受控环境内的少量核心资源
  • 内部强约束系统
  • 明确能接受性能代价的场景

而不适合作为互联网交易主链路的默认方案。

7.4.8 一张务实选型表

场景特征推荐方案说明
关键事实可收敛在单库单服务本地事务优先级最高
本地事实必须可靠传播给下游本地事务 + Outbox解决双写一致性
稀缺资源需要先锁住后确认预占 + Confirm/Cancel 或 TCC典型如库存、额度、冻结余额
跨多个系统推进且允许阶段中间态Saga依赖补偿和状态收敛
只需要解决单资源并发冲突分布式锁只是局部互斥能力
少量受控资源必须统一提交XA / 2PC(慎用)用在很窄的边界里

7.5 以电商创单为例:哪些必须强一致,哪些可以最终一致

电商创单非常适合用来练这个判断能力。

7.5.1 创单链路拆分

我们把它拆成两层:

  • 强一致核心段:价格确认、库存预占、订单创建、支付事实落账
  • 最终一致外围段:通知、积分、推荐、报表、营销统计

这一步很关键,因为它决定了系统后面是“能跑”,还是“能长期稳定地跑”。

7.5.2 一个推荐的落地组合

对大多数电商交易系统,比较务实的组合通常是:

  1. 订单服务内部用本地事务完成订单主记录和状态初始化
  2. 库存使用预占 + 确认 / 释放模型
  3. 支付结果以支付系统或账务系统作为权威事实源
  4. 订单与支付之间通过幂等回调 + Outbox / 可靠事件传播
  5. 外围通知、积分、营销动作全部异步化
  6. 异常状态通过查单、补偿、对账和人工接管收敛

这套组合的核心思想是:

  • 关键事实不追求“跨所有系统原子提交”
  • 而是先保证每个核心事实在自己的边界内绝对正确
  • 再通过可靠事件和补偿把全链路收敛起来

7.5.3 为什么不建议“订单、库存、支付三方一起全局事务提交”

因为这样做通常会同时踩中三类问题:

  • 性能差:高并发下锁竞争和超时都会放大
  • 可用性差:任一方抖动都可能拖垮整条链路
  • 现实不成立:第三方支付、回调、跨天操作根本不受你控制

所以成熟交易系统的思路更像:

关键资源先预留,关键事实各自原子提交,跨系统靠事件、查单、补偿和对账闭环。

7.6 支付、库存与账务三类系统的设计重点

7.6.1 支付系统:事实源必须唯一

支付最怕的不是慢,而是不知道到底付没付成功。

所以支付系统设计时,必须明确:

  • 谁是支付成功的权威事实源
  • 回调、主动查单、本地状态冲突时谁优先
  • 同一支付单如何防止重复扣款

推荐做法通常包括:

  • 支付单号全局唯一
  • 请求幂等键和回调幂等键分开设计
  • 支付结果进入账务或订单前先落支付事实流水
  • 对“处理中 / 未知”状态建立查单机制

7.6.2 库存系统:重点是防超卖和防重复释放

库存系统的第一原则不是“扣减快”,而是“不能卖出不存在的货”。

因此要优先设计:

  • 预占记录
  • 并发扣减约束
  • 超时释放机制
  • 重复确认 / 重复释放幂等

如果库存很稀缺,还要进一步考虑:

  • 热点行竞争
  • 分片库存
  • 扣减排队
  • 库存账与业务账对账

7.6.3 账务系统:重点是账本模型,而不是余额字段

账务系统最容易犯的错误,是直接把“余额”当成唯一真相。

更稳的做法是:

  • 以账本流水作为权威事实
  • 余额是账本汇总结果或受控快照
  • 每一笔变更都可追溯、可解释、可重放

这也是为什么高准确性系统里经常强调:

没有流水就没有事实,没有事实就谈不上强一致。

7.7 常见误区

7.7.1 把分布式锁当成一致性总方案

锁只能防并发冲突,不能代替状态机、幂等、账本和补偿。

7.7.2 把 Saga 当成原子提交

Saga 解决的是流程收敛,不是全局瞬时强一致。

7.7.3 把最终一致性当作“不需要定义中间事实”

真正成熟的最终一致系统,恰恰更依赖严格的中间状态定义和对账机制。

7.7.4 只看接口成功,不看业务事实是否落账

第三方返回成功不等于你的业务已经正确落账。
系统必须有自己的事实落地和可验证依据。

7.7.5 只做主链路,不做异常闭环

没有查单、补偿、对账、人工介入入口的强一致系统,通常只是在“理想路径上看起来完整”。

7.8 方法论沉淀:如何做出务实选型

最后把本章压缩成几条可以复用的判断。

7.8.1 五个判断句

  1. 只要业务结果一旦出错就会形成资损或合规风险,就要优先按高准确性场景来设计。
  2. 真正需要强一致的,通常不是整条链路,而是链路里的核心事实段。
  3. 本地事务永远优先于跨系统强一致,能缩边界就不要扩边界。
  4. 预占 + 确认 / 取消,是支付、库存、额度、券核销中最通用的资源控制模型。
  5. 真正能支撑生产的,不只是“写时一致”,还包括幂等、防重、查单、补偿、对账和审计。

7.8.2 一句话表达

如果要把这一章压缩成一句可直接复用的话,可以这样讲:

高准确性与强一致性系统设计的核心,不是把整条链路做成昂贵的全局原子事务,而是先识别不可错的业务事实,再用本地事务、预占模型、幂等、防重、账本和可靠事件,把核心事实保护住、把外围流程收敛住。

第 8 章 低延迟与复杂读场景系统设计方法论:搜索、推荐、广告与 Feed

当系统面对搜索、推荐、广告投放、Feed 流、详情页这类“用户一打开就要马上看到结果”的场景时,设计重点不再只是数据对不对,而是如何在复杂查询、高 QPS 和持续变化的数据之间,把延迟、吞吐和结果质量一起控制住。

在上一章里,我们讨论了高准确性与强一致性系统的设计方法。那一类系统最关心的是“事实不能错”。
这一章讨论的是另一类非常典型、而且同样重要的系统问题:

结果可以不是全链路瞬时强一致,但必须足够快、足够稳、足够能支撑复杂查询与排序。

搜索、推荐、广告和 Feed 往往不会要求“每一条结果都和写库瞬时完全一致”,但它们对用户体验极其敏感:

  • 首屏慢 100 ms,点击率和转化率就可能下降
  • 排序抖动、召回不足、缓存失效,会直接影响 GMV 或广告收入
  • 高峰流量下,读链路一旦抖动,用户看到的就是白屏、空列表或结果乱序

因此这类系统的核心问题不是“如何做全局强一致”,而是:

如何围绕低延迟、高吞吐、复杂查询、多维排序和近实时更新,构建一条可扩展、可治理、可解释的读路径。

8.1 什么叫低延迟与复杂读场景

先看这类系统的共同特征。

8.1.1 用户对响应时间极其敏感

这类场景往往直接处在用户主路径上:

  • 搜索框输入后的结果页
  • 商品详情页
  • 首页 Feed 流
  • 广告召回与投放决策
  • 推荐卡片刷新

它们的共同目标通常是:

  • P99 延迟控制在 50-200 ms
  • 高峰下仍然保持稳定吞吐
  • 结果不能经常空、慢、乱、抖

8.1.2 查询模式比 OLTP 复杂得多

这类系统的读取通常不是“按主键查一条记录”,而是:

  • 多条件过滤
  • 多字段排序
  • 打分与召回
  • 聚合统计
  • 分页与游标
  • 个性化特征拼接

也就是说,它们面对的是复杂读,不是简单读。

8.1.3 读写压力不对称

很多这类系统都有一个鲜明特征:

  • 写入频率未必极高
  • 但读取频率极高,而且带有尖峰流量

例如:

  • 商品信息更新一天几次,但详情页每秒可能数十万次读取
  • 广告素材更新较慢,但广告检索和排序每秒大量请求
  • 酒店库存价格持续变化,但搜索请求量远远高于写请求

这决定了它们必须围绕读路径做专门设计。

8.1.4 结果质量和系统性能是耦合在一起的

搜索、推荐、广告和 Feed 的特别之处在于,性能问题不是纯技术问题,它直接影响业务结果:

  • 延迟过高会减少曝光和点击
  • 召回不足会降低交易转化
  • 排序不稳定会损害用户信任
  • 缓存策略不合理会带来陈旧结果或成本爆炸

所以这类系统不是“把数据读出来”就够了,而是要在延迟、吞吐、相关性、时效性、成本之间做平衡。

8.2 为什么不能套用强一致系统的设计思路

很多团队在做搜索、推荐或 Feed 时,会下意识沿用交易系统思路:所有读都回源数据库、写完立刻强一致可见、每次都现场实时计算。
这在低延迟复杂读场景里通常会迅速失效。

原因很简单:这类系统的主要矛盾不是“写入事实是否瞬时统一”,而是“复杂结果能不能在极短时间内稳定产出”。

8.2.1 直接回源数据库通常扛不住复杂读

如果把搜索、推荐、广告检索都直接落在交易数据库上,很快就会遇到:

  • 多条件查询慢
  • 排序成本高
  • 聚合和过滤打满数据库
  • 读流量挤压写流量

所以这类系统通常必须接受一个事实:

面向复杂读的系统,往往需要专门的数据组织方式,而不是直接复用交易库模型。

8.2.2 “每次实时算”通常扛不住延迟目标

如果每个请求都现算召回、特征、排序、拼接和过滤,结果往往是:

  • 请求路径过长
  • 下游依赖过多
  • P99 被拖高
  • 峰值时放大级联故障

所以成熟系统普遍会把一部分计算前移:

  • 预计算
  • 预聚合
  • 物化视图
  • 离线或准实时索引构建

8.2.3 读系统需要接受“近实时一致”而不是“瞬时全局一致”

搜索索引、缓存层、推荐候选池、广告特征库通常不会和源数据库绝对同时更新。
真正工程化的目标不是“完全零延迟一致”,而是:

  • 延迟窗口可控
  • 不一致范围可解释
  • 热门数据能优先收敛
  • 出错时能快速降级

这和上一章的强一致系统,是完全不同的设计哲学。

8.3 设计原则:先保护读路径,再治理一致性窗口

8.3.1 读写分离是起点,不是终点

在这类系统里,第一步通常都是把读模型从写模型里拆出来。

写模型负责:

  • 交易事实
  • 后台配置
  • 权威数据写入

读模型负责:

  • 检索
  • 聚合
  • 排序
  • 展示
  • 个性化查询

但读写分离只是开始。真正关键的是,读模型必须为读场景本身优化,而不是简单复制一份表。

8.3.2 为查询设计数据,而不是为存储设计数据

交易系统的建模往往围绕领域写入展开;
复杂读系统的建模则必须围绕查询模式展开。

例如:

  • 搜索需要倒排索引、过滤字段、排序字段
  • Feed 需要按用户、时间、权重组织候选集
  • 推荐需要召回池、特征视图、排序结果缓存
  • 详情页需要拼接好的展示视图和热点字段缓存

也就是说,低延迟系统里的数据结构,很多时候不是“最规范”,而是“最适合读”。

8.3.3 尽量把昂贵计算前移到写时或异步路径

这是复杂读系统最重要的原则之一。

如果一个结果可以提前准备,就不要在请求现场临时拼装。

典型做法包括:

  • 提前生成索引文档
  • 提前汇总统计结果
  • 提前构建用户候选集
  • 提前计算商品热度、质量分、CTR 预估特征
  • 提前准备详情页物化视图

这样做的本质是:

  • 把请求时延换成异步计算成本
  • 把复杂链路换成可治理的离线 / 准实时链路

8.3.4 多级缓存不是优化项,而是主设计对象

对搜索、推荐、广告、详情页来说,缓存通常不是“最后才加的加速器”,而是系统架构的一部分。

常见缓存层包括:

  • CDN / Edge Cache
  • 本地缓存
  • Redis 分布式缓存
  • 结果缓存
  • 特征缓存
  • 候选集缓存

设计重点不只是命中率,还包括:

  • 热点是否可控
  • 失效是否平滑
  • 是否会雪崩
  • 是否能防穿透
  • 陈旧数据是否在业务容忍范围内

8.3.5 延迟预算必须像资金预算一样被管理

一条低延迟读链路,不应该靠“感觉上挺快”来治理,而要把延迟拆解到各个阶段:

  • 网关预算
  • 检索预算
  • 特征读取预算
  • 排序预算
  • 拼装预算
  • 回退预算

只有这样,团队才能知道:

  • 慢在哪里
  • 哪一步最容易拖垮 P99
  • 哪些依赖必须预取、并行化或裁剪

8.4 方案选型:从搜索引擎、缓存到 CQRS 与向量检索

8.4.1 Elasticsearch / Meilisearch:复杂检索的主力

当场景涉及:

  • 关键词检索
  • 多维过滤
  • 相关性排序
  • 聚合统计
  • 近实时索引更新

这时专门的搜索引擎通常比直接查数据库更合适。

Elasticsearch 更适合复杂检索和大规模扩展;
Meilisearch 更轻量,适合中小型场景和更简单的搜索体验。

适用场景:

  • 商品搜索
  • 酒店搜索
  • 内容检索
  • 条件筛选页

限制:

  • 索引一致性不是瞬时强一致
  • 映射、分片、重建索引都有治理成本

8.4.2 Redis 多级缓存:高 QPS 热点读的基础设施

缓存最适合处理:

  • 高频重复读取
  • 热点详情
  • 热门查询结果
  • 会话级个性化上下文

常见组合是:

  • 本地缓存抗瞬时热点
  • Redis 抗跨实例热点
  • CDN 抗静态或半静态资源请求

适用场景:

  • 商品详情页
  • 首页配置
  • 热门榜单
  • 短时间窗口的搜索结果

限制:

  • 更新一致性难度高于读性能本身
  • 热点 key 和失效风暴需要专门治理

8.4.3 CQRS:把写模型和读模型彻底分开

当一个系统同时存在:

  • 复杂写入约束
  • 复杂读路径诉求

这时 CQRS 很有价值。

它的重点不是“时髦架构名词”,而是承认:

同一份业务数据,写入最优组织方式和读取最优组织方式往往不同。

适用场景:

  • 商品中心写入复杂,但搜索和详情读取压力大
  • 广告配置后台写入不频繁,但投放查询极高频
  • 推荐系统后台训练与线上读取完全不同节奏

8.4.4 Materialized View:详情页和聚合页的务实方案

如果页面最终展示逻辑相对稳定,但原始数据来源很多,那么物化视图通常是非常务实的解法。

适用场景:

  • 商品详情页
  • 酒店详情页
  • 店铺首页
  • 聚合展示页

优点:

  • 减少请求时拼装成本
  • 读路径更短
  • 更利于缓存

限制:

  • 视图更新链路需要治理
  • 变更传播延迟需要被业务接受

8.4.5 Fanout:Feed 与消息分发常见模式

在 Feed 场景里,常见问题是:

  • 是写时推送给所有用户
  • 还是读时按需拉取并排序

这就是经典的 Fanout-on-writeFanout-on-read 选择。

适用判断:

  • 关注关系稳定、读多写少,可偏向写扩散
  • 超大 V、热点内容多、关系图复杂,可偏向读扩散

成熟系统往往不是二选一,而是混合策略:

  • 普通用户走写扩散
  • 超级节点走读扩散

8.4.6 向量搜索:适合语义检索与召回,不适合作为唯一读模型

Milvus 及同类向量检索系统适合:

  • 语义搜索
  • 多模态召回
  • 推荐候选生成

但它通常不单独承担完整线上读链路,而是作为召回层的一部分,再和:

  • 规则过滤
  • 倒排检索
  • 精排模型
  • 缓存和特征层

一起组成完整方案。

8.4.7 一张务实选型表

场景特征推荐方案说明
关键词检索、多维过滤、相关性排序Elasticsearch / Meilisearch适合搜索主路径
热点高频读取、结果短期复用强Redis 多级缓存适合详情页、榜单、热门结果
读写模型差异大CQRS读写分离彻底化
展示数据需要多源拼装Materialized View缩短请求路径
Feed 大规模内容分发Fanout 混合策略平衡写放大和读放大
语义检索、候选召回向量搜索更适合作为召回层

8.5 以搜索、广告、推荐、Feed 和详情页为例

8.5.1 搜索:核心矛盾是召回、排序与时效性

搜索系统最常见的问题不是“查不到数据”,而是:

  • 查得慢
  • 排得不准
  • 新数据生效太慢
  • 过滤条件组合一多性能就崩

搜索的核心设计通常包括:

  • 检索索引单独维护
  • 热门过滤和聚合结果缓存
  • 索引近实时更新
  • 排序字段提前准备

8.5.2 广告:核心矛盾是时延预算极小,但决策逻辑很复杂

广告投放通常需要在极短时间内完成:

  • 候选召回
  • 预算过滤
  • 定向过滤
  • 出价与排序
  • 去重和频控

因此广告系统很依赖:

  • 特征预加载
  • 候选池缓存
  • 多层过滤前移
  • 精排计算预算控制

8.5.3 推荐:核心矛盾是个性化效果与在线成本的平衡

推荐系统常见的链路是:

  • 召回
  • 粗排
  • 精排
  • 重排

如果所有步骤都在线做,成本会非常高。
所以工程上往往会把大量工作前移到:

  • 离线训练
  • 准实时特征生成
  • 候选集预计算

线上只保留必须实时决策的部分。

8.5.4 Feed:核心矛盾是分发模型和热点控制

Feed 流的问题通常不是单次查询复杂,而是:

  • 用户规模大
  • 内容更新快
  • 热点扩散强
  • 顺序和去重要求高

它的架构重点往往在:

  • 写扩散还是读扩散
  • 游标分页和增量拉取
  • 热点内容缓存
  • 异步预聚合时间线

8.5.5 详情页:核心矛盾是多源拼装与高命中率

详情页常见痛点是数据来源太多:

  • 商品基础信息
  • 价格
  • 库存
  • 营销
  • 评价
  • 推荐搭配

如果全部请求时拼装,延迟和失败率都会显著上升。
所以成熟详情页通常依赖:

  • 物化视图
  • 局部字段独立缓存
  • 热点页预热
  • 动静分离

8.6 一致性与延迟如何取舍

这类系统不是不需要一致性,而是需要一种不同于交易系统的一致性治理方式。

8.6.1 先定义一致性窗口

不要笼统地说“允许一点延迟”,而要明确:

  • 搜索索引允许延迟多少秒
  • 推荐候选允许延迟多久
  • 缓存允许陈旧多久
  • 广告预算与投放状态允许多大偏差窗口

只有把窗口数字化,系统才能治理。

8.6.2 热数据优先收敛,冷数据延后收敛

不是所有数据都需要同样快地更新。

例如:

  • 热门商品价格变更要优先刷新
  • 长尾内容索引延迟几十秒可能完全可接受
  • 活动期广告预算状态要更高频同步

这类分层治理比“全量一视同仁”更现实。

8.6.3 读降级要比写降级更早准备

在复杂读系统里,系统最常见的救命手段不是暂停写入,而是读路径降级:

  • 关闭部分排序特征
  • 降低召回深度
  • 只展示缓存结果
  • 跳过部分非核心拼装字段
  • 返回默认推荐或兜底列表

读降级设计得越早,系统在高峰时越稳。

8.7 常见误区

8.7.1 直接拿交易库硬扛搜索和复杂筛选

短期可用,长期一定会遇到查询性能和资源争抢问题。

8.7.2 过度迷信缓存命中率,而忽视失效治理

高命中率不等于系统稳定。
如果热点失效后全量回源,系统一样会雪崩。

8.7.3 把所有计算都放在线上实时完成

这样最容易把链路做长、把 P99 做坏、把下游依赖拖死。

8.7.4 只谈检索,不谈排序和特征预算

很多低延迟系统真正慢的不是“查出来”,而是“排出来”。

8.7.5 没有为不一致设计解释机制

如果价格更新后搜索结果短时间未刷新,系统至少要知道:

  • 哪一层没更新
  • 延迟窗口是否超标
  • 是否需要热点强刷

否则“不一致”就只是不可定位的黑盒现象。

8.8 方法论沉淀:如何做出务实选型

8.8.1 五个判断句

  1. 只要读链路处在主路径、对时延高度敏感,就要优先围绕读模型而不是写模型来设计。
  2. 复杂读系统的关键不是“把数据查出来”,而是“把复杂结果在延迟预算内稳定产出”。
  3. 能预计算的不要现场算,能缓存的不要重复算,能分层的不要一层做完。
  4. 读系统的一致性治理重点不是瞬时全局一致,而是可控的一致性窗口、热点优先收敛和降级能力。
  5. 真正成熟的低延迟系统,拼的不只是搜索引擎或缓存,而是索引、缓存、读模型、排序链路和治理能力的整体配合。

8.8.2 一句话表达

如果把这一章压缩成一句可以直接复用的话,可以这样讲:

低延迟与复杂读系统设计的核心,不是让所有数据实时强一致地回源读取,而是围绕查询模式重建读模型,用索引、缓存、预计算、物化视图和近实时更新,把复杂结果稳定地压进延迟预算里。

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

当系统面对秒杀、社交点赞、评论洪峰、直播互动、抢票、短视频上传这类“短时间内大量写入同时打向少数热点资源”的场景时,设计重点不再只是数据模型是否优雅,而是如何在极端流量下保护系统、保护资源、保护用户体验,并让写入结果最终可收敛。

前两章里,我们分别讨论了两类常见方法论:

  • 高准确性与强一致性系统,重点是保护业务事实
  • 低延迟与复杂读系统,重点是保护读路径和延迟预算

这一章讨论的是第三类非常典型的问题域:

写入压力不是均匀分布的,而是会在极短时间内集中爆发,并且经常打在同一个资源、同一个分区、同一个库存窗口、同一个热门对象上。

这类系统的核心矛盾通常不是“单次写入怎么做”,而是:

  • 流量洪峰能不能被拦住
  • 热点资源会不会被打穿
  • 后端写链路会不会雪崩
  • 结果会不会因为重复、乱序、超卖、回压而失控

所以高并发写场景的关键,不是盲目“提 TPS”,而是:

先把洪峰削平,把热点隔离,把写链路分层,再决定哪些结果必须立即确认,哪些结果可以异步收敛。

9.1 什么叫高并发写与热点场景

先看这类系统的几个共同特征。

9.1.1 写流量会在短时间内陡增

很多常规写系统的压力是相对平滑的,但高并发写场景往往不是。

典型情况包括:

  • 秒杀活动开始的前几秒
  • 演唱会抢票开票瞬间
  • 直播间大促口令生效的几十秒
  • 热门内容发布后的评论、点赞洪峰
  • 短视频平台热点事件带来的上传或互动峰值

这里最大的风险不是平均流量,而是瞬时峰值。

9.1.2 写入集中打向少数热点资源

普通写流量的问题主要是总量大;
热点写流量的问题是大量请求同时写同一个对象。

例如:

  • 同一个秒杀库存
  • 同一场演唱会同一票档
  • 同一个直播商品
  • 同一条爆款内容的点赞计数
  • 同一个明星帖子的评论楼层

这意味着即使系统总吞吐看起来够,单个热点 key、单个分区、单行记录、单个队列仍然可能先被打爆。

9.1.3 业务常常要求“快反馈”,但后端不一定能同步处理

用户在这些场景下通常希望立即得到反馈:

  • 抢到了还是没抢到
  • 点赞是否成功
  • 评论是否提交
  • 直播下单是否进入受理

但后端真正的业务处理往往比前端反馈复杂得多:

  • 需要扣库存
  • 需要写订单
  • 需要生成异步任务
  • 需要做审核或风控
  • 需要做内容处理或媒体转码

所以系统必须学会把“用户反馈时点”和“后端真正完成时点”分开设计。

9.1.4 问题常常不是平均处理能力,而是极端不均匀

这类系统最容易误判的一点是:

平均 TPS 够,不等于热点场景能扛住。

因为真正的崩溃往往来自:

  • 单 key 热点
  • 单分区热点
  • 单机热点
  • 单库热点
  • 某个下游依赖在高峰时先超时

因此高并发写系统设计,本质上要解决的是“局部极端不均匀”。

9.2 为什么不能用普通 OLTP 写路径硬扛

很多系统在业务初期的写路径都很直接:

  1. 请求进来
  2. 鉴权校验
  3. 直接写数据库
  4. 同步返回成功

这套方式在日常流量下很有效,但到了秒杀、抢票、社交热点等场景,很快就会暴露问题。

9.2.1 数据库不是洪峰吸收器

如果海量请求直接落到数据库层,会立刻遇到:

  • 连接数打满
  • 行锁竞争
  • 热点索引竞争
  • 磁盘刷写压力暴涨
  • 主从延迟放大

也就是说,数据库适合承担“最终事实存储”,但不应该成为第一道承压面。

9.2.2 同步全链路处理会把高峰直接扩散到所有下游

如果请求必须同步走完:

  • 库存
  • 订单
  • 支付上下文
  • 风控
  • 内容审核
  • 推送通知

那么高峰会被一层层传递下去,最终变成级联超时和雪崩。

9.2.3 热点冲突会让吞吐在局部迅速塌陷

例如 10 万请求并发抢 100 件库存,问题不只是“请求多”,而是这些请求都争夺同一个库存事实。
这时单纯横向扩容应用实例,收益通常有限,因为瓶颈不在应用层总算力,而在共享热点资源。

9.2.4 用户体验不一定要求“同步完成全部动作”

很多场景里,用户真正需要的是:

  • 请求被接受
  • 请求结果有明确状态
  • 最终结果能查到

而不是必须在 50 ms 内把所有后续业务全部做完。

所以工程上常见的关键转变是:

不要把所有业务步骤都塞进同步写路径,而要把同步确认、异步处理和最终收敛拆开。

9.3 设计原则:先削峰,再控速,再异步,再落库

9.3.1 第一原则:不要让洪峰直接打到最终存储

高并发写系统最重要的一件事,就是在真正写库之前先建立缓冲层和保护层。

这层保护可以是:

  • 接口限流
  • 队列排队
  • 缓冲池
  • Redis 预处理
  • 本地内存令牌

核心思想是一样的:

  • 前面吸收波峰
  • 后面按系统可承受速率处理

9.3.2 先控制进入量,再讨论处理量

很多团队一上来就想“怎么把后端撑大”,但真正成熟的系统会先问:

  • 多少请求值得进入系统
  • 多少请求应该直接拒绝
  • 多少请求应该排队等待
  • 多少请求应该只保留一次有效写入

因为在极端高峰下,“全部接住”往往不是能力,而是灾难。

9.3.3 把同步写和异步写分层

这类系统通常要把写路径分成三层:

  1. 入口受理层:校验、限流、幂等、快速返回
  2. 削峰缓冲层:队列、缓存、热点隔离、令牌分发
  3. 最终处理层:数据库写入、补偿、对账、状态推进

这样做的好处是:

  • 同步链路更短
  • 异步链路更可控
  • 各层可以独立扩缩容和降级

9.3.4 热点资源要被单独设计,而不是顺带处理

热点不是边角问题,而是这类系统的中心问题。

要优先回答:

  • 热点 key 怎么识别
  • 热点数据放在哪一层先拦截
  • 单热点是否要独立分片
  • 热点读写是否需要专门队列
  • 超热资源是否要隔离出单独集群

9.3.5 幂等、防重、补偿必须默认开启

高峰场景下,重试、超时、重复点击、客户端重发、消息重复投递都是常态。
所以系统必须默认:

  • 同一个请求可能来两次
  • 同一个消息可能消费两次
  • 同一个用户可能短时间连点十几次

这意味着:

  • 入口需要幂等键
  • 核心状态变更要有唯一约束
  • 异步消费必须幂等
  • 补偿逻辑也必须幂等

9.4 方案选型:从限流、排队到 Redis、MQ 与分片

9.4.1 限流:先决定谁能进来

限流是高并发写系统的第一道门槛。

常见算法包括:

  • 令牌桶
  • 漏桶
  • 固定窗口
  • 滑动窗口

适用场景:

  • API 接口保护
  • 用户级频控
  • 活动入口控流
  • 下游保护

重点不是“有没有限流”,而是:

  • 按用户限还是按资源限
  • 本地限还是全局限
  • 超限直接拒绝还是进入等待

9.4.2 排队与异步化:把尖峰变成平峰

消息队列最适合吸收瞬时洪峰,把写压力变成后端可消费的平滑流量。

适用场景:

  • 秒杀下单受理
  • 评论异步入库
  • 点赞异步聚合
  • 上传后异步转码

优点:

  • 削峰填谷
  • 降低同步链路时长
  • 便于失败重试

限制:

  • 不能无限排队
  • 排队时长需要可观测
  • 结果确认模式需要重新设计

9.4.3 Redis + Lua:热点资源的快速原子预处理层

对于库存预减、热点计数、资格判断、去重写入等场景,Redis + Lua 是非常常见的方案。

它适合做:

  • 秒杀库存预扣
  • 单用户去重购买校验
  • 点赞防重
  • 热门计数原子更新

优点:

  • 单线程原子执行
  • 延迟低
  • 能在真正写库前先完成快速判定

限制:

  • Redis 不是最终事实账本
  • 结果仍需和后端状态收敛
  • 热点 key 本身仍需治理

9.4.4 分片与一致性哈希:把热点拆散,但不是万能药

分片的价值在于把总量拆散,把负载摊平。
一致性哈希的价值在于减少扩缩容时的数据迁移成本。

适用场景:

  • 评论分区
  • 点赞计数分桶
  • 上传任务路由
  • 用户写流量打散

但要注意:

  • 如果所有请求都打同一个商品库存,单纯分片不一定解决根问题
  • 真正单热点资源,往往还需要资格预发放、分段库存、活动分仓等专门设计

9.4.5 数据库写缓冲与批量落库:适合“能稍后再写”的场景

对于评论、点赞、日志、行为流、媒体元数据这类不要求每次强同步落库的场景,可以考虑:

  • 批量聚合写
  • 写缓冲
  • 日志式追加
  • 周期性刷盘

优点:

  • 显著降低数据库写放大
  • 提高吞吐

限制:

  • 不适合所有强一致业务
  • 刷盘失败、缓冲丢失、顺序要求都要治理

9.4.6 一张务实选型表

场景特征推荐方案说明
流量瞬时暴涨,需要先挡住入口限流 / 频控先保护系统边界
洪峰大但允许异步处理MQ / 排队典型削峰填谷
热点资源需要快速原子判断Redis + Lua适合预扣、去重、计数
总写量大,负载可打散分片 / 一致性哈希适合评论、上传、用户流量
写后可延迟入库写缓冲 / 批量落库适合行为流和互动数据
需要同时解决库存与状态推进Redis 预处理 + 异步订单链路秒杀场景常见组合

9.5 以秒杀、社交互动、直播与抢票为例

9.5.1 秒杀:核心矛盾是少量库存对抗海量请求

秒杀的关键不是“把所有请求都处理掉”,而是:

  • 谁有资格进入下一轮
  • 库存在哪一层先被拦住
  • 结果如何最终收敛成真实订单

比较务实的路径通常是:

  1. 接口限流和资格校验
  2. Redis 预扣库存或发放资格令牌
  3. 成功者进入异步下单链路
  4. 订单写库后最终确认
  5. 失败订单触发补偿回补

9.5.2 社交点赞 / 评论:核心矛盾是热点对象与低成本反馈

点赞和评论看似简单,但热点内容上可能同时出现:

  • 海量并发写
  • 重复点击
  • 计数放大
  • 排序和楼层竞争

工程上常见做法是:

  • 点赞先做防重和异步聚合
  • 评论先受理再异步落库
  • 热门计数独立缓存
  • 长尾数据按普通路径处理

9.5.3 直播互动:核心矛盾是洪峰极短、反馈要快

直播弹幕、抽奖、口令红包、互动投票等场景通常要求:

  • 几乎实时反馈
  • 能扛瞬时爆发
  • 不因单次活动拖垮全站

因此会特别依赖:

  • 活动隔离
  • 房间级限流
  • 房间级队列
  • 热房间单独扩容

9.5.4 抢票:核心矛盾是公平性、库存一致性与热点保护

抢票和秒杀很像,但通常更强调:

  • 座位或票档资源的唯一性
  • 顺序和公平性
  • 超时释放
  • 防刷和黄牛控制

这类场景除了高并发写治理,还常常叠加强一致要求,因此经常会结合:

  • 限流
  • 排队
  • 预占库存
  • 状态机推进
  • 取消回补

9.6 热点治理、库存一致性、幂等与补偿

9.6.1 热点治理首先是识别热点

很多系统做不好热点治理,不是不会分片,而是根本不知道热点在哪里。

要重点观测:

  • 热点 key 分布
  • 单分区写入量
  • 热门活动资源命中率
  • 单对象写失败率
  • 单下游超时率

9.6.2 库存一致性不能只靠前置预扣

Redis 预扣很快,但它不是订单事实本身。
真正成熟的库存方案通常是:

  • 前面快速预扣或资格判断
  • 后面真实订单和库存事实落库
  • 最后通过补偿和对账收敛

也就是说:

预扣解决的是峰值入口,最终事实仍然要靠后端状态闭环。

9.6.3 幂等既要挡住入口,也要挡住异步链路

入口幂等防的是重复提交;
异步幂等防的是重复消费和重复执行。

两者都缺一不可。

9.6.4 补偿不是失败兜底,而是主设计的一部分

在高并发写场景里,失败不是偶发,而是常态之一。
所以必须提前设计:

  • 订单创建失败如何回补
  • 消费超时如何重试
  • 热点状态不一致如何对账
  • 预扣成功但最终失败如何释放

9.7 常见误区

9.7.1 只想着扩容,不想着控流

扩容能提升总量,但拦不住无上限洪峰。

9.7.2 让数据库承担第一波冲击

这样最容易把最终存储层变成第一批牺牲者。

9.7.3 只做异步化,不做结果确认设计

异步不是把问题藏起来。
用户成功、受理中、失败、待确认这些状态都必须被明确定义。

9.7.4 只做 Redis 预扣,不做后端事实闭环

这样系统会很快陷入“缓存看起来对,数据库事实说不清”的状态。

9.7.5 把所有热点都当成同一种问题

单库存热点、热门评论热点、直播房间热点、上传带宽热点,其实是不同层次的问题,方案不能一刀切。

9.8 方法论沉淀:如何做出务实选型

9.8.1 五个判断句

  1. 高并发写系统的首要目标不是把所有请求都处理完,而是让系统在洪峰下仍然可控。
  2. 先控进入量,再谈处理量;先保护热点,再谈整体吞吐。
  3. 能异步的不要强同步,能削峰的不要直接落库,能分层的不要单链路硬扛。
  4. Redis、MQ、分片、批量写都只是局部手段,真正关键的是入口保护、状态设计和最终收敛。
  5. 秒杀、抢票、社交互动看起来不同,但底层都在解决洪峰、热点、幂等、补偿和资源保护问题。

9.8.2 一句话表达

如果把这一章压缩成一句可直接复用的话,可以这样讲:

高并发写与热点场景系统设计的核心,不是把后端数据库做得足够抗打,而是先用限流、排队、预处理和热点隔离把洪峰削平,再用异步链路、幂等、补偿和状态闭环把最终结果收敛出来。

第 10 章 MySQL:存储与数据库

MySQL 是多数业务系统的核心事务存储。对于 3 到 10 年工程师来说,面试官通常不会只问“索引是什么”“事务四大特性是什么”,而是会把问题放进真实业务里:订单列表为什么慢、库存为什么超卖、支付回调为什么重复、主从延迟为什么导致用户看不到刚下的订单、单表过大后为什么不能只靠加机器解决。

因此,本章的目标不是堆 MySQL 命令,而是建立一套面试和工程都能复用的决策框架:

  • 从业务建模出发,判断哪些数据应该进入 MySQL,哪些不该进入 MySQL。
  • 从表结构、索引和执行计划出发,解释查询性能问题。
  • 从事务、锁、MVCC 和状态机出发,解释一致性问题。
  • 从 redo、undo、binlog、复制和高可用出发,解释可靠性问题。
  • 从容量、归档、分库分表和读模型出发,解释系统如何演进。

如果用一句话概括:MySQL 面试的核心不是“背概念”,而是能把业务约束、数据模型、索引设计、事务边界和扩展路径串成一条完整的工程链路。

MySQL 在电商系统中的定位

电商系统里,MySQL 通常承载的是“必须可信、可追溯、可恢复”的核心业务事实,而不是所有读请求的最终形态。

业务域典型数据MySQL 的角色常见扩展组件
用户用户账号、实名信息、地址权威存储Redis 缓存、风控系统
商品SPU、SKU、类目、属性主数据与后台管理Elasticsearch、Redis
库存可售库存、锁定库存、库存流水强一致扣减与审计Redis 预扣、MQ
订单订单主表、订单明细、状态流转核心交易事实MQ、搜索、归档库
支付支付单、退款单、对账记录资金链路事实支付渠道、账务系统
营销优惠券、活动、使用记录权益与核销事实Redis、规则引擎

一个成熟系统通常不会让 MySQL 同时承担所有职责。常见分工是:

  • MySQL 保存核心事实,例如订单、支付、库存流水。
  • Redis 承担热点缓存、限流、活动预热和临时状态。
  • Elasticsearch 承担商品搜索、订单后台检索等多条件查询。
  • Kafka 或其他 MQ 承担异步通知、数据同步、削峰和最终一致性。
  • HBase、ClickHouse、Hive 等承担历史归档和分析查询。

面试时如果被问“订单系统怎么设计存储”,好的回答一般不是一句“订单表分库分表”,而是先拆读写场景:

  1. 买家下单、支付、取消、退款属于强一致写链路,核心状态落 MySQL。
  2. 买家订单列表属于高频读,可以走 MySQL 索引、缓存或读模型。
  3. 商家后台按多条件检索订单,适合同步到 Elasticsearch。
  4. 超过一定时间的历史订单可以归档,在线库只保留热数据。
  5. 财务对账与审计要保留不可篡改流水,不应只依赖订单当前状态。

这个拆法体现的是系统设计能力:先识别数据的权威来源,再为不同访问模式构建合适的读模型。

MySQL 架构与 InnoDB 基础

MySQL 可以粗略分成四层:

层次作用排障关注点
连接层连接、认证、线程管理连接数、连接池、认证耗时
SQL 层解析、优化、执行计划慢 SQL、执行计划、临时表
存储引擎层数据组织、索引、事务、锁InnoDB 锁、Buffer Pool、redo
文件系统层数据文件、日志文件、刷盘IO、磁盘水位、fsync 抖动

线上问题定位时,先判断瓶颈在什么层,比直接改 SQL 更有效:

  • 连接打满,可能是应用连接池、慢查询堆积或数据库连接配置问题。
  • CPU 高,可能是大量排序、函数计算、低选择性索引或并发过高。
  • IO 高,可能是 Buffer Pool 命中率低、刷脏页、临时表落盘或大查询扫表。
  • 锁等待高,可能是事务过长、索引未命中、加锁顺序混乱或热点行竞争。

InnoDB 是大多数业务库默认选择,因为它同时提供事务、行锁、崩溃恢复和 MVCC。

能力InnoDBMyISAM
事务支持 ACID不支持
锁粒度行级锁为主表级锁
崩溃恢复支持 redo 恢复较弱
外键支持不支持
典型场景订单、库存、支付历史遗留读多写少场景

InnoDB 的几个核心组件要能和线上现象对应起来:

组件作用典型现象
Buffer Pool缓存数据页和索引页命中率低会导致读 IO 放大
Change Buffer缓冲非唯一二级索引变更写入后异步合并,降低随机 IO
Adaptive Hash Index热点页上的自适应哈希某些读热点能加速,极端场景也可能带来争用
Log Buffer缓冲 redo 日志提交延迟和刷盘策略相关
undo log保存旧版本支撑回滚和 MVCC
redo log记录页修改支撑崩溃恢复
doublewrite buffer防止页写一半损坏提升恢复可靠性,带来额外写入

面试中不要孤立背这些名词。更好的回答方式是:InnoDB 把数据按页组织在 B+ 树里,读写优先经过 Buffer Pool;事务修改先写 redo,旧版本写 undo;提交时通过 WAL 和刷盘策略保证持久性;崩溃后用 redo 重放、undo 回滚未完成事务。

表设计:把治理成本前置

表设计不是把字段放进去就结束。对电商系统来说,表结构决定了后续索引、事务、归档、分库分表和数据同步的成本。

主键与业务 ID

InnoDB 是聚簇索引组织表,主键会直接影响数据页组织和所有二级索引大小。主键设计通常遵循:

  • 尽量短:二级索引叶子节点会保存主键值,主键越长,二级索引越大。
  • 尽量稳定:主键不应跟随业务状态变化。
  • 尽量单调:随机主键会增加页分裂和写放大。
  • 避免把可变业务含义塞进主键:业务规则变更会非常痛苦。

电商里经常同时存在两类 ID:

ID 类型用途设计关注点
数据库主键 idInnoDB 聚簇索引短小、单调、便于存储
业务单号 order_no对外展示、幂等、路由全局唯一、可追踪、避免泄露规模

订单表可以用自增或趋势递增的 BIGINT 作为内部主键,同时用 order_no 做唯一业务单号。分库分表后,order_no 往往还要编码时间、机房、分片或随机位,便于路由和排障。

字段类型与约束

字段类型决定存储成本和索引效率。常见建议如下:

  • 金额用整数存分,避免浮点误差。
  • 状态用 TINYINTSMALLINT,并在代码中维护枚举含义。
  • 时间字段统一使用明确语义,例如 created_atupdated_atpaid_atdeleted_at
  • 字符集默认 utf8mb4,避免后续表级字符集迁移。
  • 除少数确实需要三值逻辑的字段外,尽量 NOT NULL 并给默认值。
  • 大文本、JSON、图片地址列表等低频大字段,优先考虑垂直拆分。

示例订单表结构可以这样思考,而不是照抄字段:

CREATE TABLE order_main (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_no VARCHAR(32) NOT NULL,
  buyer_id BIGINT UNSIGNED NOT NULL,
  seller_id BIGINT UNSIGNED NOT NULL,
  status TINYINT NOT NULL,
  total_amount BIGINT NOT NULL,
  pay_amount BIGINT NOT NULL,
  paid_at DATETIME NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_buyer_status_created (buyer_id, status, created_at, id),
  KEY idx_seller_created (seller_id, created_at, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里的重点不是字段是否完整,而是几个设计意图:

  • order_no 用唯一索引保障业务幂等。
  • 买家订单列表按 buyer_id + status + created_at 建索引。
  • 商家后台如果只是按商家和时间查,可以用 seller_id + created_at
  • 如果商家后台要按手机号、商品名、收货人、状态组合查询,就不应该强行全靠 MySQL 单表索引,通常要同步搜索读模型。

外键、约束与应用层一致性

很多互联网业务不会大量使用数据库外键,不是因为外键没价值,而是因为高并发、分库分表、异步解耦和跨服务调用会让外键约束变得很难扩展。

更常见的做法是:

  • 数据库用主键、唯一键、非空约束兜住底线。
  • 应用层用状态机、幂等键和事务边界保证业务一致性。
  • 异步链路用消息、补偿任务和对账任务修复最终一致性问题。

比如订单和支付单不一定用外键绑定,但支付单必须有唯一支付流水号,订单状态更新必须带前置状态条件,支付成功消息必须可重放且幂等。

索引设计:从访问模式倒推

索引本质上是用写入成本和存储成本换查询效率。真正的索引设计不是“字段越多越好”,而是从高频访问模式倒推。

为什么 InnoDB 使用 B+ 树

InnoDB 主流索引结构是 B+ 树。和红黑树、B 树相比,B+ 树更适合磁盘和页缓存模型:

维度B+ 树红黑树B 树
树高低,扇出大高,节点分散较低
磁盘 IO适合按页读取随机访问多较适合
范围查询叶子节点链表天然有序不擅长一般
缓存友好性

面试里常见追问是“一棵三层 B+ 树能存多少数据”。不要死背数字,应该说明估算逻辑:InnoDB 页默认常见大小是 16KB,非叶子节点存索引键和子节点指针,叶子节点存整行或二级索引条目。每层扇出越大,树高越低。实际容量受主键大小、行宽、页填充率影响,所以“几层树支撑百万到千万级数据”是估算,不是固定结论。

聚簇索引、二级索引与回表

InnoDB 的聚簇索引把主键和整行数据放在同一棵 B+ 树上。二级索引叶子节点保存的是二级索引列加主键值。

这带来几个重要结论:

  • 通过主键查询通常最快,因为一次命中聚簇索引即可。
  • 通过二级索引查询整行,通常要先命中二级索引,再用主键回表。
  • 覆盖索引能避免回表,因为查询所需列都在二级索引里。
  • 主键越大,所有二级索引都会变大。

例如:

SELECT order_no, status, pay_amount
FROM order_main
WHERE buyer_id = 1001 AND status = 20
ORDER BY created_at DESC
LIMIT 20;

如果索引是:

KEY idx_buyer_status_created (buyer_id, status, created_at, id)

查询可以较好地按买家、状态、时间定位。但如果 SELECT *,仍可能回表读取其他列。面试时提到覆盖索引,最好同时补一句:覆盖索引不是为了炫技,而是为了减少随机回表;但索引列过多会增加写入成本和存储成本,要围绕高频查询设计。

联合索引设计原则

联合索引遵循最左前缀原则,但工程上不能只背这句话。更实用的判断顺序是:

  1. 等值条件优先放在前面,例如 buyer_idstatus
  2. 范围条件之后的列通常不能继续用于精确定位,只能部分用于过滤或排序。
  3. 排序字段要和过滤条件组合考虑,避免额外 filesort。
  4. 覆盖索引只覆盖高频轻量查询,不要把大字段塞进索引。
  5. 区分度高不等于一定放最前,字段顺序要服务具体查询模式。

以订单列表为例,用户最常见查询是:

SELECT id, order_no, status, pay_amount, created_at
FROM order_main
WHERE buyer_id = ?
  AND status = ?
ORDER BY created_at DESC, id DESC
LIMIT 20;

可以考虑:

KEY idx_buyer_status_created_id (buyer_id, status, created_at, id)

如果业务更常见的是“查询某个用户全部订单,不一定按状态筛选”,还需要评估是否补充:

KEY idx_buyer_created_id (buyer_id, created_at, id)

但不能机械地为每种组合建索引。索引过多会带来:

  • 写入、更新和删除变慢。
  • Buffer Pool 被更多索引页占用。
  • DDL 成本增加。
  • 优化器选择成本上升。

一个好习惯是把索引和 SQL 场景写在一起评审,而不是只评审表结构。

索引失效与执行计划

常见导致索引效果变差的原因包括:

  • 对索引列做函数运算,例如 DATE(created_at) = '2026-04-01'
  • 隐式类型转换,例如字符串列用数字条件比较。
  • 前导模糊匹配,例如 LIKE '%phone'
  • 低选择性字段单独建索引,例如只有几个状态值的 status
  • OR 条件跨多个字段,导致优化器难以使用单个索引。
  • 范围条件过宽,优化器认为扫表更便宜。
  • 统计信息不准,执行计划选错。

排查慢 SQL 时,EXPLAIN 至少关注:

字段关注点
type是否退化为 ALL、大范围 range
key是否命中预期索引
rows预估扫描行数是否过大
filtered过滤比例是否合理
Extra是否出现 Using temporaryUsing filesort

EXPLAIN 不是终点。更严谨的排查还要结合慢日志、实际执行耗时、扫描行数、返回行数、索引统计信息和业务流量峰值。

查询优化:从慢 SQL 到访问模式重构

SQL 优化的第一原则是:先确认瓶颈,再决定手段。不要看到慢 SQL 就立刻加索引。

慢 SQL 排查路径

一条线上 SQL 很慢,可以按下面顺序排查:

  1. 看慢日志,确认 SQL 模板、耗时、扫描行数和返回行数。
  2. EXPLAIN,确认索引、Join 顺序、排序和临时表。
  3. 看业务参数分布,确认是不是少数大客户、爆款商品或异常时间范围。
  4. 看锁等待,确认慢是执行慢还是等待慢。
  5. 看资源指标,确认 CPU、IO、Buffer Pool、连接数是否异常。
  6. 再决定是改 SQL、补索引、拆查询、加缓存、建读模型还是做归档。

这个顺序很适合面试回答,因为它体现你不是“头痛医头”,而是在定位瓶颈。

深分页

深分页是电商后台和订单列表常见问题。下面这个 SQL 看似只取 20 条:

SELECT id, order_no, status, created_at
FROM order_main
WHERE buyer_id = ?
ORDER BY created_at DESC
LIMIT 100000, 20;

数据库通常仍要扫描、排序或跳过大量记录。常见优化有三类。

第一类是延迟关联,先用索引取主键,再回表:

SELECT o.id, o.order_no, o.status, o.created_at
FROM order_main o
JOIN (
  SELECT id
  FROM order_main
  WHERE buyer_id = ?
  ORDER BY created_at DESC
  LIMIT 100000, 20
) t ON o.id = t.id;

它能减少回表数据量,但不能从根上消除大偏移扫描。

第二类是游标分页,也叫 keyset pagination:

SELECT id, order_no, status, created_at
FROM order_main
WHERE buyer_id = ?
  AND (created_at < ? OR (created_at = ? AND id < ?))
ORDER BY created_at DESC, id DESC
LIMIT 20;

它适合 App 和 C 端列表,因为用户通常只是连续向下翻页,不需要精确跳到第 5000 页。

第三类是读模型重构。比如商家后台要求按多个条件筛选历史订单,并支持任意页跳转,这类需求不一定适合直接压在订单主表上,可以同步到 Elasticsearch 或专门的后台查询表。

Join 与反范式

面试里经常有人把“禁止 Join”当成经验。更准确的说法是:核心高并发链路要谨慎 Join,大查询和跨业务域 Join 要尽量避免。

订单详情页如果每次都 Join 用户、商品、优惠、支付、履约多张表,稳定性会很差。常见做法是:

  • 写入时适度冗余订单快照,例如商品标题、购买价、店铺名。
  • 核心链路按主键或唯一键查询,避免复杂 Join。
  • 后台分析和报表走离线或近实时数仓。
  • 搜索和复杂筛选走专门读模型。

反范式不是不讲一致性,而是把“交易事实”和“展示快照”分开。订单里的商品标题是下单时快照,即使商品后来改名,历史订单也不应该跟着变。

事务、MVCC 与隔离级别

事务的价值是把多条操作封装成一个具备原子性、一致性、隔离性和持久性的整体。对工程师来说,更重要的是知道什么时候需要事务、事务边界怎么划、长事务有什么风险。

隔离级别

MySQL InnoDB 默认隔离级别通常是可重复读。

隔离级别可能问题工程特点
读未提交脏读基本不用
读已提交不可重复读很多系统可接受,锁范围相对小
可重复读快照读一致,当前读配合锁控制幻读InnoDB 默认常见选择
串行化并发最低极少用于高并发业务

要区分快照读和当前读:

  • 普通 SELECT 通常是快照读,读的是 ReadView 可见版本。
  • SELECT ... FOR UPDATEUPDATEDELETE 是当前读,要读取最新版本并加锁。

很多幻读问题的争议都来自没有区分这两类读。InnoDB 在可重复读下,快照读通过 MVCC 保持一致视图;当前读则通过 Next-Key Lock 等机制控制并发插入。

MVCC

MVCC 的核心由三部分组成:

  • 隐藏列:记录事务 ID 和回滚指针。
  • undo log:保存旧版本,形成版本链。
  • ReadView:记录当前活跃事务范围,判断哪个版本可见。

RC 和 RR 的关键差异在 ReadView 创建时机:

  • RC:每次快照读都创建新的 ReadView。
  • RR:事务内第一次快照读创建 ReadView,后续复用。

因此 RR 下同一事务里两次普通查询结果可以保持一致,而 RC 下第二次查询可能看到其他事务已经提交的数据。

面试回答 MVCC 时,可以这样组织:

  1. InnoDB 不直接覆盖旧数据,而是通过 undo log 保存旧版本。
  2. 每行有事务 ID 和回滚指针,可以沿版本链找到历史版本。
  3. 事务读取时根据 ReadView 判断哪些版本可见。
  4. 这样读写可以并发,普通读不必阻塞写,写也不必阻塞普通读。
  5. 但当前读和写冲突仍要靠锁解决。

锁、库存扣减与订单状态机

锁是 MySQL 面试最容易从概念走向工程的部分。不要只背行锁、间隙锁、Next-Key Lock,要能说清楚它们在库存、订单和支付里的作用。

行锁、Gap Lock 与 Next-Key Lock

InnoDB 行锁是加在索引记录上的。这个细节非常重要:

  • 条件命中唯一索引,锁范围通常很小。
  • 条件命中普通索引,可能锁住多条索引记录。
  • 条件没有命中索引,可能扫描并锁住大量记录。

几类锁可以这样理解:

锁类型作用典型场景
Record Lock锁住已有索引记录更新某个订单
Gap Lock锁住索引记录之间的间隙防止范围内插入
Next-Key LockRecord Lock + Gap Lock当前读下控制幻读

如果面试官问“为什么 SELECT ... FOR UPDATE 变成锁表”,好的回答是:它不是字面意义一定锁全表,而是如果条件没有走索引,InnoDB 需要扫描大量记录并加锁,效果上接近大范围锁,导致并发更新被阻塞。

库存扣减:不要先查再扣

库存扣减是电商最经典的一致性场景。错误写法通常是:

SELECT available_stock
FROM sku_stock
WHERE sku_id = ?;

UPDATE sku_stock
SET available_stock = available_stock - ?
WHERE sku_id = ?;

如果没有事务和锁保护,并发下很容易超卖。更常见的安全写法是条件更新:

UPDATE sku_stock
SET available_stock = available_stock - ?,
    locked_stock = locked_stock + ?,
    updated_at = NOW()
WHERE sku_id = ?
  AND available_stock >= ?;

然后检查影响行数:

  • affected_rows = 1:扣减成功。
  • affected_rows = 0:库存不足或记录不存在。

这类写法利用 MySQL 单行更新的原子性,避免“先查再改”的竞态。对于多 SKU 订单,还要注意:

  • sku_id 排序后统一扣减,降低死锁概率。
  • 每个扣减动作写库存流水,便于回滚、审计和补偿。
  • 下单失败、支付超时、订单取消时释放锁定库存。
  • 秒杀等极高并发场景可以前置 Redis 预扣,但最终仍要落 MySQL 事实和流水。

乐观锁与订单状态机

订单状态流转更适合用状态机和乐观更新控制。例如支付成功回调只允许把 UNPAID 改成 PAID

UPDATE order_main
SET status = 20,
    paid_at = NOW(),
    updated_at = NOW()
WHERE order_no = ?
  AND status = 10;

这条 SQL 的关键是 status = 10。它保证重复回调、乱序回调或人工操作不会随便覆盖状态。更新后必须检查影响行数:

  • affected_rows = 1:状态流转成功。
  • affected_rows = 0:订单不存在,或已经处理过,或状态不允许流转。

如果表里使用 version 字段,写法类似:

UPDATE order_main
SET status = ?,
    version = version + 1,
    updated_at = NOW()
WHERE order_no = ?
  AND version = ?;

乐观锁的重点不是有 version 字段,而是更新后必须判断影响行数,否则代码会把“更新失败”误认为“更新成功”。

悲观锁适合什么场景

悲观锁适合冲突概率高、必须串行处理的关键资源。例如账户余额、同一张优惠券核销、同一笔售后单处理。典型写法是:

SELECT id, balance
FROM account
WHERE user_id = ?
FOR UPDATE;

使用悲观锁要注意:

  • 查询条件必须命中索引。
  • 事务内不要调用慢外部接口。
  • 锁住资源后尽快更新并提交。
  • 多资源加锁要统一顺序,减少死锁。

一个常见事故是:在事务里先锁订单,再调用支付渠道或库存服务,外部调用慢导致数据库锁长期持有,最终拖垮整个订单库。正确做法通常是把外部调用放到事务外,事务内只做必要的状态校验和落库。

死锁治理

死锁不是 MySQL 异常,而是高并发系统的常见现象。重点不是追求永不死锁,而是降低概率并做好重试。

常见治理手段:

  • 多资源操作统一顺序,例如多个 SKU 按 sku_id 升序扣减。
  • 缩短事务时间,事务中不做 RPC、不做大批量循环。
  • 确保更新条件命中索引,缩小锁范围。
  • 拆分大事务,避免一次锁太多行。
  • 对可重试事务做有限次数重试,并保证幂等。

面试里如果被问“线上死锁怎么排查”,可以回答:

  1. 查看死锁日志或 InnoDB status,找到两边 SQL 和锁等待关系。
  2. 确认 SQL 是否命中索引,是否扫描了过大范围。
  3. 确认事务代码路径,是否存在相反顺序加锁。
  4. 缩小事务范围,统一加锁顺序。
  5. 对业务允许的场景增加重试和幂等。

日志、崩溃恢复与复制

MySQL 的可靠性离不开 undo log、redo log 和 binlog。它们回答的是不同问题。

日志所属作用典型用途
undo logInnoDB保存旧版本回滚、MVCC
redo logInnoDB记录物理页修改崩溃恢复
binlogMySQL Server记录逻辑变更复制、审计、数据订阅

WAL 与崩溃恢复

InnoDB 使用 WAL 思想:事务修改数据时,不必立刻把脏页刷到磁盘,而是先保证 redo log 持久化。崩溃后根据 redo log 重放已提交修改,再配合 undo 回滚未提交事务。

innodb_flush_log_at_trx_commit 常见取值:

取值行为风险
1每次提交写 redo 并 fsync最安全,生产常见
2每次提交写 OS Buffer,约每秒 fsync系统崩溃可能丢秒级数据
0写入和 fsync 都按周期风险最高

电商交易链路通常更偏向安全,而不是为了少量性能牺牲提交持久性。非核心日志类数据可以根据业务损失窗口做不同选择。

redo log 与 binlog 的一致性

redo log 用于 InnoDB 崩溃恢复,binlog 用于复制和数据订阅。两者必须保持一致,否则可能出现主库恢复后数据存在,但从库和下游没有对应 binlog,或反过来。

MySQL 通过两阶段提交协调 redo 和 binlog,大致过程是:

  1. InnoDB 写 redo prepare。
  2. MySQL Server 写 binlog。
  3. InnoDB 写 redo commit。

面试中如果被问“为什么需要两阶段提交”,可以回答:因为 redo 和 binlog 属于不同日志体系,如果不协调,崩溃时可能出现主库事务状态和复制日志不一致,影响主从复制和数据恢复。

binlog 与数据订阅

电商系统经常通过 binlog 同步数据到其他系统:

  • 订单数据同步到 Elasticsearch,支撑商家后台检索。
  • 商品变更同步到缓存和搜索索引。
  • 支付状态同步到账务、履约和通知系统。
  • 用户行为或订单事实同步到数仓。

这类 CDC 链路要注意:

  • 下游消费必须幂等。
  • 乱序和重复要能处理。
  • 大事务会阻塞同步延迟。
  • 表结构变更要和订阅方兼容。
  • 删除操作要考虑软删和审计要求。

主从复制、读写分离与高可用

主从复制是 MySQL 高可用和读扩展的基础形态。主库写入 binlog,从库拉取并重放。

主从延迟为什么发生

主从延迟常见原因包括:

  • 主库写入流量过大,从库重放追不上。
  • 大事务或大批量 DDL 阻塞复制。
  • 从库机器规格弱于主库。
  • 从库同时承担大量慢查询。
  • 网络抖动或复制线程异常。

读写分离后,延迟会变成业务一致性问题。比如用户刚支付成功,订单详情页如果读从库,可能仍然显示“待支付”。

常见兜底策略:

  • 写后短时间内读主库,保证 read-your-writes。
  • 对订单详情、支付结果、库存确认等强一致读走主库。
  • 监控复制延迟,超过阈值时熔断从库读。
  • 根据 GTID 或位点等待从库追上,但要设置超时。
  • 对后台列表、历史查询、非关键统计允许读从库。

面试回答时要把“哪些读必须回主”说清楚:

场景是否可读从库原因
支付成功后的结果页通常读主用户刚完成写操作
订单详情页立即刷新通常读主或粘主避免状态倒退
历史订单列表可读从库短暂延迟可接受
后台报表可读从库或数仓实时性要求较低
库存扣减判断不能读从库后决策会导致超卖风险

故障切换

主库故障后,需要从从库中选新主,并让应用切换写入。这里的难点不是“能不能切”,而是:

  • 新主是否拥有最完整数据。
  • 老主恢复后如何避免双主写入。
  • 应用连接如何发现新主。
  • 复制拓扑如何重建。
  • 切换过程中业务是否允许短暂不可用。

常见方案包括 MHA、Orchestrator、云数据库高可用方案和自研管控平台。面试不一定要深入某个工具,但要说明高可用不是只有数据库内部问题,还包括应用路由、连接池刷新、数据一致性和故障演练。

多主与单主

多主写入听起来能提高可用性,但会引入冲突解决、全局唯一 ID、跨地域延迟和一致性问题。大多数交易系统更常见的是:

  • 单主写入,主从高可用。
  • 按业务域拆库,各自单主。
  • 分库分表后,每个分片单主。
  • 异地多活场景按业务规则拆写入归属,而不是任意多点同时写同一份数据。

一句实用判断:交易事实尽量避免多点同时写同一行或同一业务对象,除非你已经有非常明确的冲突解决模型。

分库分表、归档与容量演进

分库分表是 MySQL 面试大题,但它不是第一选择。好的回答要先说明什么时候不分,什么时候必须分,分了之后会引入什么新成本。

单库单表先做到合理

在讨论分库分表前,先做这些事:

  1. 表结构和索引是否合理。
  2. 慢 SQL 是否已经治理。
  3. 是否有缓存或读模型承接高频读。
  4. 历史数据是否可以归档。
  5. 大字段是否可以垂直拆分。
  6. 是否可以按业务域垂直拆库。

“单表多少行要分表”没有固定答案。500 万、2000 万只是经验区间,真正要看:

  • 行宽和索引大小。
  • 热数据比例和 Buffer Pool 命中率。
  • 高频查询是否稳定命中索引。
  • 写入 QPS 和更新热点。
  • DDL、备份、恢复和归档耗时。
  • 业务是否已经遇到容量或性能瓶颈。

面试里不要只说“超过 2000 万就分表”。更好的回答是:我会先看访问模式和增长趋势,如果索引高度、Buffer Pool、慢查询、备份恢复和 DDL 成本都开始不可控,再考虑分表,并且会优先通过归档和读模型降低在线库压力。

垂直拆分

垂直拆分有两类。

垂直分表:把低频大字段拆出去。例如订单主表保留状态、金额、用户、时间等核心字段,把扩展信息、备注、发票、地址快照放到扩展表。

垂直分库:按业务域拆库。例如用户库、商品库、订单库、库存库、支付库、营销库。它的价值是:

  • 降低单库复杂度。
  • 隔离故障域。
  • 让不同业务域独立扩容。
  • 减少跨团队变更冲突。

垂直拆分的代价是跨库 Join 消失,应用层要通过服务接口、冗余快照、异步同步和读模型解决查询需求。

水平分片

水平分片要先选分片键。订单系统常见候选有:

分片键优点问题
buyer_id买家订单列表容易查商家维度查询困难
order_no单订单查询和路由清晰买家列表需要额外索引或映射
seller_id商家后台友好买家查询困难
时间归档容易热点集中,近期分片压力大

实际系统里经常组合使用:

  • 订单主写入按 buyer_idorder_no 分片。
  • 单号里编码分片信息,便于直接路由。
  • 商家后台查询走搜索读模型。
  • 财务和运营分析走数仓。
  • 历史订单按时间归档。

分片后要解决的问题包括:

  • 路由:请求如何定位到库表。
  • 唯一 ID:如何生成全局唯一业务单号。
  • 跨分片查询:如何查多个分片并合并排序。
  • 分布式事务:如何避免跨分片强事务。
  • 扩容再平衡:如何从 128 张表扩到 256 张表。
  • 运维治理:备份、恢复、DDL、监控如何批量执行。

跨分片查询与读模型

很多分库分表失败,不是因为写入分散不了,而是因为查询需求没想清楚。

例如订单分片按 buyer_id,买家列表很好查;但商家后台要按 seller_id + status + created_at + phone 查订单,就会变成跨分片扫描。常见解法不是硬查所有分片,而是:

  • 建商家订单读模型。
  • 同步到 Elasticsearch。
  • 后台查询限制时间范围和条件。
  • 把复杂报表放到数仓。

这也是电商系统常见 CQRS 思路:写模型围绕交易一致性设计,读模型围绕查询效率设计。

历史归档

订单、支付、库存流水都天然随时间增长。在线库不应该无限承载所有历史数据。

归档设计要回答:

  • 在线库保留多久热数据,例如 3 个月、6 个月、1 年。
  • 归档库是否支持用户查询历史订单。
  • 归档过程如何避免影响主库。
  • 归档后订单详情、售后、发票、客服如何查询。
  • 归档数据是否需要参与审计和对账。

常见做法是:

  • 在线库保留近期订单。
  • 历史库或冷存储保留全量订单。
  • 用户历史订单查询通过归档服务或异步查询。
  • 财务和审计走独立账务或数仓链路。

归档不是简单 DELETE。大批量删除会产生大量 undo、redo、binlog,还可能造成复制延迟。工程上通常要小批量、限速、可恢复,并配合监控。

DDL、连接池与线上治理

很多 MySQL 事故不是来自 SQL 写错,而是来自变更和治理不到位。

DDL 风险

加字段、改字段、加索引、删索引都可能带来:

  • 元数据锁阻塞。
  • 表重建。
  • 大量 IO。
  • 主从延迟。
  • binlog 暴涨。
  • 应用兼容问题。

MySQL 8 对部分 DDL 支持更好的在线能力,例如某些场景可以 INSTANT 加列,但不能假设所有 DDL 都无成本。不同版本、字段位置、索引类型和表结构都会影响 DDL 算法。面试里说“先确认 MySQL 版本和 DDL 算法”,最好能落到可执行动作。

第一步是确认数据库版本、发行版和表结构特征:

SELECT VERSION();
SHOW VARIABLES LIKE 'version%';
SHOW CREATE TABLE order_main\G
SHOW TABLE STATUS LIKE 'order_main'\G

要重点看:

  • 是 MySQL 5.7、MySQL 8.0、MySQL 8.4,还是云厂商兼容版本;不同小版本的 Online DDL 能力差异很大。
  • 表是否为 InnoDB,是否有 ROW_FORMAT=COMPRESSED、全文索引、函数索引、分区、外键、生成列等特殊结构。
  • 本次 DDL 是加列、删列、改类型、改默认值、加索引、删索引,还是调整列顺序;不同操作对应的算法完全不同。
  • 是否会修改行格式或重建表,例如 VARCHAR(255) 改到 VARCHAR(256)、字段类型变更、列顺序调整,这类操作经常比“加一个字段”危险得多。

第二步是明确 DDL 算法。常见算法可以这样理解:

算法含义风险
INSTANT主要改数据字典元数据,不改已有行数据最轻,但仍需要短暂元数据锁
INPLACE尽量原地执行,可能重建表或索引通常允许并发 DML,但可能有 IO 和主从延迟
COPY建临时表并复制全量数据最重,耗时长,空间放大,通常不适合在线大表

判断方法不是靠猜,而是主动约束算法和锁级别。比如你希望这是元数据级变更,就显式指定:

ALTER TABLE order_main
  ADD COLUMN ext_info JSON NULL,
  ALGORITHM=INSTANT,
  LOCK=NONE;

如果当前版本或表结构不支持该算法,MySQL 会直接报错,而不是悄悄退化成更重的方式。对于加索引这类常见操作,也应该尽量显式指定期望:

ALTER TABLE order_main
  ADD INDEX idx_buyer_created (buyer_id, created_at),
  ALGORITHM=INPLACE,
  LOCK=NONE;

上线前还要在影子库或预发库用同等量级数据验证:真实耗时、是否重建表、临时空间增长、binlog 增长、主从延迟、业务 SQL 是否被元数据锁阻塞。不要把开发库的几万行测试结果,直接外推到生产几亿行大表。

大表 DDL 的稳妥流程:

  1. 查清 MySQL 版本、表结构特征和本次 DDL 类型。
  2. 查官方文档或云厂商文档,确认目标版本对该操作支持 INSTANTINPLACE 还是只能 COPY
  3. 在 SQL 里显式指定 ALGORITHMLOCK,让不符合预期的 DDL 直接失败。
  4. 影子库或预发库用接近生产的数据量验证耗时、空间放大和主从延迟。
  5. 选择低峰执行,必要时使用 gh-ost 或 pt-online-schema-change。
  6. 监控主库负载、元数据锁、复制延迟、临时空间、binlog 增长和错误日志。
  7. 应用发布采用 expand-contract 策略:先加字段兼容,再切流量,最后清理旧字段。
  8. 准备回滚方案,并明确回滚是不是另一个更重的 DDL。

连接池治理

连接数不是越大越好。连接池过大可能把数据库从“排队”打成“雪崩”。

估算连接池要考虑:

  • 应用实例数。
  • 每个实例最大连接数。
  • 数据库 max_connections
  • 平均 SQL 耗时和峰值 QPS。
  • 是否有慢查询占住连接。

例如 50 个应用实例,每个实例连接池 100,总连接上限就是 5000。即使数据库允许这么多连接,真正同时执行 SQL 时,CPU、IO 和锁也未必扛得住。

常见治理手段:

  • 按服务重要性设置不同连接池大小。
  • 对慢接口限流,避免占满连接。
  • 设置合理超时,避免连接长时间挂起。
  • 监控活跃连接、等待连接和连接获取耗时。
  • 对后台任务和在线链路使用不同账号或资源隔离。

监控指标

MySQL 监控至少覆盖这些维度:

类别指标
流量QPS、TPS、读写比例
延迟平均延迟、P95、P99、慢查询数量
连接活跃连接、线程数、连接池等待
InnoDBBuffer Pool 命中率、脏页、行锁等待
日志redo 写入、fsync 耗时、binlog 大小
复制主从延迟、复制中断、Relay Log 堆积
容量数据文件、索引大小、磁盘水位
变更DDL 耗时、元数据锁、主从延迟波动

面试里说监控时,不要只列指标。最好能说明指标和动作的关系:慢查询上升要看执行计划和流量参数;主从延迟上升要看大事务和从库慢查询;连接池等待上升要看数据库延迟和应用并发;磁盘水位上升要看归档和 binlog 清理。

电商典型场景设计

这一节把前面的概念放到几个真实面试题里。

场景一:下单扣库存如何防止超卖

目标:不能卖出超过真实库存,同时要支撑较高并发。

基础方案:

  1. 订单请求先做幂等校验,例如 request_id 或购物车提交 token。
  2. 库存表用条件更新扣减可售库存。
  3. 扣减成功后写库存流水。
  4. 创建订单并记录订单明细。
  5. 支付超时或取消订单时释放锁定库存。

核心 SQL:

UPDATE sku_stock
SET available_stock = available_stock - ?,
    locked_stock = locked_stock + ?,
    updated_at = NOW()
WHERE sku_id = ?
  AND available_stock >= ?;

高并发秒杀场景可以进一步:

  • Redis 预扣减少 MySQL 压力。
  • MQ 削峰,异步创建订单。
  • 用户限购和风控前置。
  • MySQL 保留最终库存事实和流水。
  • 对账任务校验 Redis、订单和库存流水一致性。

优秀回答要点:不要只说“加锁”,要说清楚条件更新、影响行数、库存流水、幂等、失败补偿和热点削峰。

场景二:支付回调重复怎么办

支付渠道可能重复通知,网络也可能重试。系统必须保证重复回调不会重复发货、重复加积分或重复记账。

常见设计:

  • 支付流水号建立唯一索引。
  • 回调原始报文落库,便于审计。
  • 更新订单状态时带前置状态条件。
  • 后续履约、积分、通知通过消息异步触发,并且消费端幂等。

示例:

INSERT INTO payment_callback_log (
  channel_trade_no,
  order_no,
  payload,
  created_at
) VALUES (?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE updated_at = NOW();
UPDATE order_main
SET status = 20,
    paid_at = NOW(),
    updated_at = NOW()
WHERE order_no = ?
  AND status = 10;

如果更新影响行数为 0,不一定是错误,可能是重复回调或状态已经流转。业务要查询当前状态后做幂等返回。

场景三:订单列表为什么越来越慢

常见原因:

  • 单表数据持续膨胀,索引和热数据无法很好留在内存。
  • 查询没有命中合适联合索引。
  • 使用深分页。
  • SELECT * 导致大量回表。
  • 历史订单和近期订单混在同一张在线表。
  • 商家后台复杂筛选压在交易库上。

治理路径:

  1. 先看慢日志和执行计划。
  2. 针对买家列表建立合适联合索引。
  3. App 端改为游标分页。
  4. 垂直拆分低频字段,减少行宽。
  5. 历史订单归档。
  6. 商家后台和客服检索走搜索读模型。
  7. 数据继续增长后再考虑水平分片。

这个回答比“加索引”更完整,因为它覆盖了访问模式、数据生命周期和读模型分离。

场景四:主从延迟导致用户看到旧订单状态

现象:用户支付成功后跳转订单页,页面仍显示待支付。

原因:支付成功写主库,但订单详情读从库;从库复制有延迟。

解决:

  • 支付成功后的订单详情读主库。
  • 用户写操作后一段时间粘主。
  • 延迟超过阈值时从库摘流。
  • 对关键状态变更使用消息通知前端刷新时,也要保证查询源一致。
  • 从根因上治理大事务、慢查询和从库资源不足。

面试追问通常是“那所有读都读主不就好了?”回答应该是:可以但会牺牲读扩展能力。更合理的是按一致性要求分级,强一致读回主,弱一致列表读从库。

常见线上问题排查手册

慢查询

排查路径:

  1. 慢日志定位 SQL 模板。
  2. EXPLAIN 看索引、扫描行数、排序和临时表。
  3. 对比实际参数,确认是否有大商家、大用户或异常时间范围。
  4. 看是否锁等待,而不是执行慢。
  5. 优化 SQL、索引或读模型。

常见动作:

  • 避免 SELECT *
  • 增加合适联合索引。
  • 改写深分页。
  • 拆分大查询。
  • 把复杂检索迁到搜索系统。

锁等待

排查路径:

  1. 查看当前执行 SQL 和阻塞链路。
  2. 找出先持锁事务。
  3. 确认事务是否长时间未提交。
  4. 检查更新条件是否命中索引。
  5. 检查代码里是否事务内调用外部服务。

常见动作:

  • 缩短事务。
  • 补充索引。
  • 统一加锁顺序。
  • 拆分批量任务。
  • 对可重试事务增加幂等重试。

主从延迟

排查路径:

  1. 看延迟从什么时候开始。
  2. 查主库是否有大事务、大 DDL 或批量更新。
  3. 查从库是否有慢查询占用资源。
  4. 看网络和复制线程状态。
  5. 判断是否需要临时摘除从库读流量。

常见动作:

  • 大任务小批量提交。
  • DDL 低峰执行。
  • 从库避免跑重查询。
  • 延迟过高时强一致读回主。
  • 对归档、报表类任务限速。

连接耗尽

排查路径:

  1. 看数据库连接数是否打满。
  2. 看应用连接池等待是否上升。
  3. 查是否有慢 SQL 占住连接。
  4. 查是否有连接泄漏或事务未关闭。
  5. 查发布或流量突增是否导致实例数变化。

常见动作:

  • 限制非核心接口。
  • 降低单实例连接池上限。
  • 优化慢 SQL。
  • 增加超时和熔断。
  • 后台任务与在线流量隔离。

面试答题框架

MySQL 问题可以按四层回答:

  1. 业务语义:这是订单、库存、支付还是后台查询?一致性要求是什么?
  2. 数据模型:表怎么设计,主键、唯一键、状态、流水怎么设计?
  3. 执行机制:索引、事务、锁、日志、复制如何支撑它?
  4. 工程治理:慢 SQL、归档、分库分表、监控、补偿怎么做?

例如被问“如何设计订单表”,不要直接列字段。可以这样答:

  1. 订单是交易事实,主表记录订单核心状态、金额、买卖双方、时间,明细表记录商品快照。
  2. 主键用内部递增 ID,外部用全局唯一 order_no,并建唯一索引保证幂等。
  3. 买家列表按 buyer_id + status + created_at 建联合索引,单订单按 order_no 查。
  4. 状态流转用前置状态条件或版本号保证幂等。
  5. 支付、履约、积分等通过 MQ 异步解耦,但消费端必须幂等。
  6. 后台复杂检索走搜索读模型,历史订单做归档,数据量继续增长再分库分表。

这类回答体现的是工程经验,而不是背字段。

本章小结

MySQL 的工程主线可以总结为五句话:

  • MySQL 保存核心业务事实,不负责承接所有查询形态。
  • 表设计要提前考虑主键、唯一键、状态机、索引、归档和分片。
  • 索引设计必须从访问模式倒推,而不是为每个字段机械建索引。
  • 事务和锁要服务业务一致性,库存、订单、支付都要靠幂等和状态条件兜底。
  • 复制、归档、读模型和分库分表是容量演进手段,但每一步都会引入新的治理成本。

对于 3 到 10 年工程师,面试官真正想听的是:你是否知道一个概念在真实业务里会带来什么收益、什么风险、如何排查、如何取舍。

本章面试题与优秀回答要点

9. 为什么 InnoDB 选择 B+ 树,而不是红黑树或 B 树?

优秀回答要点:

  • 数据库存储按页读取,B+ 树扇出大、树高低,能减少磁盘 IO。
  • B+ 树叶子节点有序链表,适合范围查询和排序。
  • 红黑树适合内存结构,但树高大、节点分散,不适合磁盘页模型。
  • B 树非叶子节点也存数据,范围扫描和页利用率通常不如 B+ 树适合 InnoDB 场景。

追问:三层 B+ 树大致能存多少数据?

答题方向:说明估算方法,而不是死背数字。页大小、主键大小、行宽、页填充率都会影响容量。

9. 聚簇索引、二级索引、覆盖索引和回表是什么关系?

优秀回答要点:

  • 聚簇索引叶子节点保存整行数据。
  • 二级索引叶子节点保存索引列和主键值。
  • 二级索引查整行通常要通过主键回表。
  • 覆盖索引是查询所需列都在索引里,可以避免回表。
  • 主键越大,二级索引越大。

追问:为什么不把所有查询字段都放进覆盖索引?

答题方向:索引会增加写入成本、存储成本和 Buffer Pool 压力,只覆盖高频轻量查询。

9. 如何为订单列表设计索引?

优秀回答要点:

  • 先明确查询模式:买家列表、商家列表、后台检索不是同一个问题。
  • 买家按状态和时间翻页,可以设计 buyer_id + status + created_at + id
  • App 端优先游标分页,避免深分页。
  • 商家后台复杂多条件查询不一定适合压在订单主表,可能需要 Elasticsearch。
  • 索引设计要结合写入成本,不能为所有条件组合建索引。

追问:如果查询不带 status,原索引还能用吗?

答题方向:要看联合索引最左前缀,buyer_id 仍可用,但排序和过滤效果可能不同,必要时补充适配高频查询的索引。

9. MVCC 是如何实现可重复读的?

优秀回答要点:

  • InnoDB 通过 undo log 保存旧版本。
  • 行记录有事务 ID 和回滚指针,形成版本链。
  • ReadView 判断哪些事务版本对当前事务可见。
  • RR 下第一次快照读创建 ReadView,事务内复用,所以多次普通查询一致。
  • 当前读仍然要靠锁处理并发写入。

追问:RC 和 RR 的 ReadView 有什么区别?

答题方向:RC 每次快照读创建新 ReadView,RR 事务内第一次快照读创建并复用。

9. SELECT ... FOR UPDATE 一定是行锁吗?

优秀回答要点:

  • InnoDB 锁加在索引记录上。
  • 条件命中唯一索引时,锁范围较小。
  • 条件未命中索引时,可能扫描并锁住大量记录,效果接近锁表。
  • 事务内持锁时间越长,阻塞越严重。

追问:如何降低锁范围?

答题方向:命中合适索引,缩短事务,避免事务内 RPC,拆分批量操作。

9. 库存扣减如何防止超卖?

优秀回答要点:

  • 不要先查库存再扣减。
  • 使用条件更新:available_stock >= quantity
  • 更新后检查 affected_rows
  • 写库存流水,支持审计、回滚和对账。
  • 多 SKU 按固定顺序扣减,降低死锁。
  • 秒杀场景可以 Redis 预扣和 MQ 削峰,但 MySQL 仍保存最终事实。

追问:Redis 扣成功但 MySQL 扣失败怎么办?

答题方向:需要补偿释放 Redis 预扣、返回失败或重试,最终以 MySQL 库存事实和流水对账。

9. 支付回调重复如何保证幂等?

优秀回答要点:

  • 支付渠道流水号唯一索引。
  • 回调日志落库。
  • 订单状态更新带前置状态条件。
  • 更新影响行数为 0 时,查询当前状态做幂等返回。
  • 后续履约、积分、通知消费端也要幂等。

追问:为什么只靠分布式锁不够?

答题方向:锁只能降低并发,不保存业务事实;重复请求、重放消息、进程崩溃后仍要靠唯一键和状态机保证幂等。

9. redo log、undo log、binlog 分别解决什么问题?

优秀回答要点:

  • undo log 保存旧版本,用于回滚和 MVCC。
  • redo log 记录物理页修改,用于崩溃恢复。
  • binlog 记录逻辑变更,用于复制、审计和数据订阅。
  • redo 和 binlog 通过两阶段提交保持一致。

追问:为什么需要两阶段提交?

答题方向:避免崩溃时 redo 和 binlog 不一致,导致主库恢复状态和从库复制状态不一致。

9. 主从延迟会带来什么业务问题?

优秀回答要点:

  • 写主读从可能读到旧数据。
  • 支付成功页、订单详情、库存决策等强一致场景不能随便读从。
  • 可以写后粘主、强一致读回主、监控延迟并摘流。
  • 大事务、DDL、从库慢查询都可能造成延迟。

追问:哪些读可以读从库?

答题方向:历史列表、后台报表、弱一致统计可以读从或数仓;刚写后的关键状态读主。

9. 单表多少行需要分库分表?

优秀回答要点:

  • 没有绝对阈值。
  • 要看行宽、索引大小、热数据比例、QPS、慢查询、Buffer Pool、备份恢复和 DDL 成本。
  • 先治理索引、SQL、缓存、垂直拆分和历史归档。
  • 分库分表会引入路由、跨分片查询、扩容、分布式事务和运维成本。

追问:订单表按什么分片键?

答题方向:买家查询适合 buyer_id,单号查询适合 order_no,商家后台适合读模型;通常要结合业务主路径和辅助索引设计。

9. 分库分表后如何处理跨分片查询?

优秀回答要点:

  • 尽量避免在线跨分片扫全量。
  • 核心查询通过分片键路由。
  • 商家后台、多条件检索走 Elasticsearch 或查询读模型。
  • 报表分析走数仓。
  • 必须跨分片时限制时间范围、并发查询、合并排序,并做好降级。

追问:扩容从 128 张表到 256 张表怎么做?

答题方向:提前设计可扩展路由;迁移要双写、校验、灰度切流、回滚;也可用逻辑分片避免频繁物理迁移。

9. 一条 SQL 很慢,你如何系统排查?

优秀回答要点:

  • 先看慢日志和 SQL 模板。
  • EXPLAIN 看索引、扫描行数、排序和临时表。
  • 看参数分布,确认是否热点用户或大时间范围。
  • 判断是执行慢还是锁等待。
  • 看数据库资源:CPU、IO、Buffer Pool、连接。
  • 再决定补索引、改 SQL、拆查询、缓存、读模型或归档。

追问:Using filesort 一定有问题吗?

答题方向:不一定。小结果集 filesort 可接受;大结果集频繁 filesort 才需要通过索引、限制范围或读模型优化。

9. 大表 DDL 如何降低风险?

优秀回答要点:

  • 先用 SELECT VERSION()SHOW VARIABLES LIKE 'version%'SHOW CREATE TABLE 确认数据库版本、发行版、表引擎、行格式、索引、分区、外键和生成列等信息。
  • 再判断 DDL 类型:加列、删列、改类型、加索引、改默认值、调整列顺序,对应的 INSTANTINPLACECOPY 能力不同。
  • 显式指定 ALGORITHMLOCK,例如期望元数据变更就写 ALGORITHM=INSTANT, LOCK=NONE;如果不支持,让它在预发或低峰前失败,而不是线上静默退化。
  • 影子环境用接近生产的数据量验证耗时、临时空间、binlog 增长、主从延迟和元数据锁影响。
  • 大表高风险变更选择低峰执行,必要时用 gh-ost 或 pt-online-schema-change。
  • 执行时监控元数据锁、主从延迟、IO、CPU、磁盘水位、错误日志和业务慢 SQL。
  • 应用采用 expand-contract 兼容发布:先扩展字段和代码兼容,再切流量,最后收缩旧字段。
  • 准备回滚方案,并确认回滚是否也需要重 DDL。

追问:为什么删字段比加字段更危险?

答题方向:删字段同时有兼容性风险和 DDL 风险。兼容性上,可能还有旧版本应用、定时任务、报表、数据同步、客服后台或风控规则在读写该字段;一旦删除,问题会立刻变成运行时错误或数据缺失。DDL 上,不同 MySQL 版本对 DROP COLUMN 的算法支持不同,可能是元数据级,也可能触发表重建、binlog 暴涨和主从延迟。稳妥做法是先让代码停止写,再停止读,保留一段观察期,通过 SQL 审计、代码搜索、binlog/CDC 订阅方确认无依赖,最后低峰删除。

9. 如何设计 MySQL 监控和告警?

优秀回答要点:

  • 监控 QPS、延迟、慢查询、连接、锁等待、Buffer Pool、复制延迟、磁盘。
  • 告警要能映射动作,例如延迟高摘从库、磁盘高触发归档或扩容。
  • 区分主库、从库、在线链路、后台任务。
  • 监控要覆盖应用连接池,不只看数据库本身。

追问:连接数打满时先扩连接可以吗?

答题方向:不一定。先确认是不是慢 SQL、锁等待或连接泄漏。盲目扩连接可能把数据库打得更慢。

第 11 章 Redis:缓存原理与实践

Redis 在系统设计中的定位不只是“快一点的 KV”。它既可以做缓存,也可以承担计数、排行榜、延时任务、分布式协调等角色。要用好 Redis,关键在于先理解它为什么快、擅长什么,再明确哪些问题不该交给它解决。

Redis 的核心模型与高性能来源

Redis 的核心特征可以概括为三点:内存存储、单线程命令执行模型、面向场景设计的数据结构。绝大多数请求直接命中内存,避免了磁盘寻址;单线程把命令串行化,减少锁竞争;而 String、Hash、List、Set、ZSet 又让很多业务需求能以接近 O(1) 或 O(log n) 的方式完成。

特性价值
内存存储微秒到毫秒级访问延迟
单线程命令执行避免复杂锁竞争与上下文切换
多路复用 IO用一个线程处理大量连接
场景化数据结构减少业务层重复造轮子

“Redis 为什么快”通常可以从四个角度回答:

  1. 数据主要在内存中,避免磁盘 IO。
  2. 数据结构专门为高频操作设计。
  3. 命令执行串行化,减少锁开销。
  4. 网络层使用 IO 多路复用支撑高并发连接。

但这套高性能模型也天然带来边界:大 Key、复杂脚本、阻塞命令、全量扫描都会拖慢整个实例。Redis 的快,建立在“单次操作足够短小”这个前提之上。

数据结构与典型使用模式

String。 Redis String 的底层是 SDS,而不是 C 风格字符串。SDS 通过记录长度与剩余空间,避免了频繁遍历和越界问题,适合计数器、分布式 ID、简单对象缓存、限流键等场景。

常见模式:

  • INCR 做访问计数、库存扣减、限流计数。
  • SET key value EX ttl 做简单对象缓存。
  • 短字符串走 embstr 编码,整数值可走 int 编码,节省内存和分配成本。

Hash。 Hash 适合存储字段较少、需要局部更新的对象,例如商品基础信息、Session、用户状态。相比把整个 JSON 放进 String,Hash 的优势是可以只更新一个字段,不必整对象反序列化再回写。

Redis 会在“小对象”与“大对象”之间切换编码:

  • 小 Hash 倾向使用紧凑编码,节省内存。
  • 字段数或字段长度超过阈值后切到哈希表,实现更快查找。

工程上要警惕“大 Hash”:

  • HGETALL 可能阻塞。
  • 集群中容易形成热点。
  • 持久化和复制的代价会明显上升。

List。 List 适合先进先出队列、最近浏览、时间线等场景。现代 Redis 使用 quicklist,把多个小块组织起来,在插入效率和内存占用之间做折中。

典型模式:

  • LPUSH + RPOP 做简单队列。
  • BRPOP 做阻塞消费。
  • LPUSH + LTRIM 保留“最近 10 条浏览记录”。

如果业务需要可靠消费、回溯和消费组语义,List 往往只是过渡方案,最终还是会演进到专业 MQ。

Set。 Set 适合标签、去重、共同好友、点赞用户集合等场景。它擅长的是“存在性”和“集合运算”,例如交集、并集、差集。

ZSet。 ZSet 通过 score 排序,典型用于排行榜、延时队列、推荐权重排序。它在小数据量时可以走紧凑编码,规模变大后通常用跳表加哈希表组合,同时兼顾按成员查询和按分值范围查询。

一个实用判断是:如果需求里同时出现“排名”“TopN”“定时到期”“按权重取前几名”,优先想到 ZSet。

持久化、复制与集群

Redis 的数据虽然主要在内存里,但线上系统通常不会接受“进程一挂数据全没”,因此要根据业务要求配置持久化与高可用。

持久化。

方案特点适用场景
RDB周期快照,文件紧凑,恢复快可接受少量数据回退
AOF追加写命令,恢复更细粒度更关注数据完整性

RDB 的优势是生成文件紧凑、恢复速度快,对冷备和全量恢复友好;缺点是两次快照之间的数据可能丢失。AOF 的优势是更接近操作日志,数据损失窗口更小;代价是文件更大、重写和恢复时间更长。很多生产环境会结合两者使用,在恢复速度和数据完整性之间做平衡。

主从复制。 主从模式主要解决读扩展和基础灾备:

  • 主节点负责写入。
  • 从节点异步追赶主节点。
  • 可以承接只读流量。

它的问题也很明确:存在复制延迟,且主节点故障后还需要额外机制完成自动切换。

Sentinel 与 Cluster。 Sentinel 在主从之上补上监控、故障检测和自动切换能力,适合单分片但希望具备自动主备切换的场景。它解决的是“主挂了怎么办”,不解决“容量不够怎么办”。

Cluster 则进一步提供数据分片能力,把 key 映射到 16384 个槽位,实现横向扩容。它更适合缓存容量和吞吐都持续增长的业务,但同时引入了新的限制:

  • 多 key 操作必须考虑槽位分布。
  • 跨槽事务与 Lua 脚本受到限制。
  • 热点 key 依然可能把单节点打爆。

因此,Redis Cluster 不是“开了就自动水平扩展一切问题”,它只是把容量问题显式搬到了分片治理层。

缓存设计与一致性问题

缓存设计的核心不是“把什么放进去”,而是先想清楚:失效后能不能接受短暂不一致,回源流量能不能扛住,以及命中率下降时怎么保护数据库。

常见缓存模式。

模式做法特点
Cache Aside + TTL读缓存未命中回源 DB,再回填缓存最常见,简单但有不一致窗口
定时刷新后台任务周期性刷新读路径稳定,但实现复杂
写 DB 同时写缓存一次请求更新两处并发下顺序问题多
写 DB 后删缓存工程上最常见需处理删失败与并发覆盖

大多数业务会落在“先更新 DB,再删除缓存”这一范式上,因为直接写缓存更容易在并发场景里出现旧值覆盖新值的问题。但它也不是银弹,还要考虑:

  • 删除缓存失败怎么办
  • 主从延迟导致回源读到旧值怎么办
  • 热点 key 失效瞬间如何避免打穿 DB

因此工程上常会叠加重试、延迟双删或 MQ 补偿,但要注意复杂度不能超过问题本身。

三类典型异常。

  1. 缓存穿透:大量请求访问根本不存在的数据。 常见治理是布隆过滤器、空值缓存和参数校验。

  2. 缓存击穿:单个热点 key 过期瞬间,大量流量回源。 常见治理是互斥锁、singleflight、热点预热和永不过期加异步刷新。

  3. 缓存雪崩:一批 key 同时失效,导致回源洪峰。 常见治理是 TTL 加随机抖动、多级缓存、限流降级和熔断保护。

容量与淘汰。 内存规划至少要回答三个问题:

  • 目标命中率是多少
  • 能接受什么淘汰策略
  • 过期删除对 CPU 与内存的平衡如何取舍

Redis 常见淘汰策略包括 allkeys-lruallkeys-lfuvolatile-lru 等。业务热点分布明显时,LFU 往往比 LRU 更稳;而“永不过期 + noeviction”如果没有容量保护,通常只是在把风险后移。

分布式锁与常见误区

Redis 可以实现分布式锁,但它适合的是“高性能互斥协调”,而不是“绝对强一致事务锁”。

最基础的正确姿势是:

  1. 使用 SET key value NX EX seconds 原子加锁。
  2. value 要写唯一请求标识,解锁时校验“是不是自己加的锁”。
  3. 解锁要通过 Lua 脚本保证比较与删除原子执行。

只做 SETNX 而不带过期时间,是最典型的线上事故源头之一,因为进程崩溃后锁会永久残留。另一类常见误区是:

  • 锁超时时间拍脑袋设置,业务执行时间稍长就提前过期。
  • 误把锁当作幂等,实际上锁只能限并发,不能替代状态校验。
  • 在 Cluster 下对多 key 脚本与锁语义理解不清。

对于库存扣减、支付状态流转这类关键链路,更稳妥的做法通常是“数据库约束/乐观锁 + Redis 锁或限流”组合,而不是把一致性全压在 Redis 锁上。

热点问题与线上治理

Redis 的线上治理重点,不是单纯“调大机器”,而是尽量把问题前移到模型和 key 设计阶段。

大 Key。 大 Key 典型定义可以粗略理解为:

  • String 值很大,例如超过 10KB 甚至更高。
  • Hash/List/Set/ZSet 元素数过多,例如上万级。

风险包括:

  • 单次命令耗时长,阻塞主线程。
  • 复制、持久化和迁移成本高。
  • 集群迁移时容易形成长尾。

治理手段包括拆 key、拆对象、避免 HGETALL/LRANGE 0 -1 之类全量命令,以及用 redis-cli --bigkeys 做巡检。

热 Key。 热 Key 问题的本质是流量集中到单个 key 所在实例。即便集群分片足够多,只要热点集中,单点依然会被打满。治理思路通常包括:

  • 本地缓存或多级缓存分担读流量。
  • 热点 key 主动预热、延长 TTL。
  • 在极端场景下做 key 复制或业务层读扩散。

阻塞型命令与扫描。 生产环境应避免直接使用 KEYS * 这类 O(N) 命令。全量扫描优先用 SCAN,大集合遍历要限制批次,避免在单线程模型里把维护脚本跑成线上事故。

监控维度。

类别指标
延迟命令耗时、慢查询日志
内存used_memory、碎片率、淘汰次数
键分布大 Key、热 Key、过期键数量
持久化RDB/AOF 执行时长、失败次数
复制主从延迟、断链、全量同步次数

本章小结

Redis 的工程价值在于用合适的数据结构快速解决高频访问问题,但它的所有优势都建立在“数据模型简洁、单次命令短小、容量边界清楚”之上。

这一章可以提炼为四个判断:

  • 把 Redis 当缓存时,先想一致性与回源保护。
  • 把 Redis 当数据结构服务时,先按访问模式选 String、Hash、List、Set、ZSet。
  • 把 Redis 当高可用系统时,先分清持久化、主从、Sentinel、Cluster 各自解决什么问题。
  • 把 Redis 当分布式协调组件时,先明确它只能提供有限互斥,不替代数据库事务语义。

本章面试题与追问

  1. Redis 为什么快? 追问:单线程是不是性能瓶颈,什么时候它反而是优势?

  2. String、Hash、List、Set、ZSet 分别适合哪些业务场景? 追问:商品详情缓存为什么很多场景更适合 Hash 而不是整段 JSON String?

  3. Redis Hash 的紧凑编码和哈希表编码分别适合什么场景? 追问:为什么大 Hash 会成为线上风险?

  4. RDB 和 AOF 怎么选? 追问:如果业务既想恢复快,又不想丢太多数据,通常怎么组合?

  5. Sentinel 和 Cluster 分别解决什么问题? 追问:为什么 Cluster 解决了容量扩展,却没有自动解决热 Key 问题?

  6. 你会如何设计缓存和 DB 的一致性方案? 追问:为什么很多团队选择“先更新 DB,再删除缓存”,而不是直接更新缓存?

  7. 缓存穿透、击穿、雪崩分别是什么? 追问:如果是明星商品详情页热点 key 过期,你会优先怎么保护 DB?

  8. Redis 分布式锁的正确实现方式是什么? 追问:为什么解锁必须校验 value,并且通常要用 Lua 脚本?

  9. 什么是大 Key 和热 Key,分别有什么危害? 追问:如果你发现某个 key 读 QPS 极高,但又不能简单加机器,你会从哪几种方向治理?

  10. 什么时候不应该继续用 Redis,而应该引入专业 MQ 或数据库能力? 追问:如果需要可靠消费、消息回溯和消费组,你为什么不会优先选择 Redis List?

第 12 章 Kafka:消息队列与异步

Kafka 在系统设计里的核心价值,是把同步强耦合链路改造成可缓冲、可回放、可扩展的异步数据流。它并不只是“消息中转站”,而是围绕顺序写、分区并行和副本复制构建出来的一整套高吞吐日志系统。

Kafka 基础模型

Kafka 的基本对象包括 Broker、Topic、Partition、Producer、Consumer 和 Consumer Group。理解它们之间的关系,是看懂顺序性、吞吐与高可用的前提。

组件作用
Broker存储和转发消息的服务节点
Topic业务主题,逻辑上的消息分类
PartitionTopic 的物理分片,承载并行与扩展
Producer生产消息
Consumer消费消息
Consumer Group多消费者协同消费的逻辑组

Kafka 的一个关键设计是“分区内有序、全局无序”。只要消息落在同一个 Partition,就会按 offset 单调递增排列;但跨 Partition 不承诺全局顺序。因此,业务若要求“同一订单内的状态变更严格有序”,通常做法不是把 Topic 只设成一个分区,而是按订单 ID 作为 key 路由到固定分区。

副本模型则决定了 Kafka 的高可用边界:

  • Leader:对外承担读写。
  • Follower:从 Leader 复制数据。
  • ISR:与 Leader 保持同步的副本集合。

Leader 故障时,只从 ISR 中选新 Leader,才能尽量避免数据倒退。如果允许落后副本直接上位,就会提升可用性但增加丢数据风险。

写入、消费与消费组

写入路径。 Producer 发送消息时,通常会经历序列化、分区选择、批量打包、网络发送、Broker 追加写日志、Follower 同步、返回确认这一流程。影响写入体验的几个关键参数包括:

  • acks:控制等待多少副本确认。
  • batch.sizelinger.ms:控制批量发送效果。
  • 重试与幂等配置:决定失败重发后的可靠性和顺序风险。

消费路径。 Consumer 不是被推送消息,而是主动拉取。拉取模型让消费者能按自己的节奏处理,也方便批量读取与偏移量管理。消费完成后是否提交 offset,直接决定可靠性语义。

Consumer Group。 Consumer Group 用于实现负载均衡。一个分区在同一消费组内只能被一个消费者消费,所以:

  • 分区数少于消费者数时,多余消费者会空闲。
  • 要提升组内并行度,分区数必须足够。

消费组最大的治理点在于 Rebalance。以下情况会触发重新分配:

  1. 消费者加入或退出。
  2. Topic 分区数变化。
  3. 心跳超时或消费线程长时间卡住。

Rebalance 的代价并不小,它会导致一段时间消费暂停,还可能造成重复消费。因此要特别关注超时参数、消费逻辑时长和实例重启策略。

顺序性、可靠性与幂等

顺序性。 Kafka 的顺序保证是分层次的:

  • 同一 Partition 内天然有序。
  • 多 Partition 并行时,全局无序。
  • Producer 开启重试且允许过多飞行中的请求时,可能破坏同分区重试顺序。

所以对顺序敏感的业务,一般要同时做三件事:

  1. 用业务 key 固定分区。
  2. 控制生产端重试配置,避免乱序重发。
  3. 让消费端按分区串行处理关键链路。

可靠性语义。

语义特点常见做法
At-most-once可能丢,不重复自动提交 offset,弱确认
At-least-once不丢,但可能重复acks=all + 手动提交 offset
Exactly-once端到端成本最高Producer 幂等/事务 + 消费端幂等

常见的“不丢消息”配置是:

  • Producer 用 acks=all
  • Broker 设置合理副本数和 min.insync.replicas
  • 关闭 unclean.leader.election
  • Consumer 业务处理成功后再提交 offset

但要明确,Kafka 的可靠性是链路整体属性,不是单个参数就能保证。比如 acks=all 如果此时 ISR 已经只剩 Leader,本质上依然是退化状态。

幂等。 Kafka 的 Producer 幂等可以减少重试导致的重复写入,但它主要解决“Producer 到 Broker”这一段。真正的端到端幂等,仍然要靠业务侧保证,例如:

  • 用订单号做数据库唯一键。
  • 用状态机限制非法重复更新。
  • 用去重表或 Redis 记录已处理消息 ID。

因此,“Kafka 开了幂等就万事大吉”是一个常见误解。

高吞吐设计原理

Kafka 的高吞吐并不是因为它“把数据放进内存队列”,而是因为它把磁盘和网络用到了极致。

顺序写磁盘。 Kafka 把消息追加到日志尾部,避免了随机写的磁盘寻道开销。顺序写对磁盘极其友好,这是它在持久化前提下仍能做出高吞吐的基础。

Page Cache。 Kafka 尽量利用操作系统页缓存,而不是自己在 JVM 堆中维护大块缓存。好处是:

  • 热数据读写往往直接命中内存。
  • JVM 堆不必过大,GC 压力更小。
  • 文件缓存由 OS 统一管理,更适合顺序日志场景。

这也是为什么 Kafka 机器的内存不能被 JVM 堆吃光,否则 Page Cache 不足会让性能明显下滑。

零拷贝。 Kafka 在发送消息给消费者时会尽量利用 sendfile 等能力,让数据从 Page Cache 直接进入网卡,减少用户态与内核态之间的多次拷贝和上下文切换。

批量与压缩。 Producer 会把多条消息打成 batch,再统一发送;Broker 和网络层又能对 batch 做压缩。这意味着单条消息的协议开销和系统调用次数被均摊,从而进一步提升吞吐。

分区并行。 Partition 把单线程日志扩展成了“多条顺序日志并行处理”的模型。它是 Kafka 横向扩容的核心手段,但也是顺序性与运维复杂度的来源。

积压、延迟与回压处理

消费积压不是一个单一问题,它可能来自生产端突增、消费逻辑变慢、下游依赖超时、分区数不足,或者 Rebalance 频繁引发的停顿。

排查顺序。

  1. 先看 Lag,确认是哪些 Topic、哪些分区积压。
  2. 再看消费端处理时长,确认是不是业务逻辑慢。
  3. 检查消费者实例数与分区数的关系,避免“加了实例却没有并行收益”。
  4. 检查是否频繁 Rebalance、GC、网络抖动或下游超时。

常见应对手段。

  • 临时扩消费者实例,但不超过分区数。
  • 增加分区数以提升并行度,但要评估顺序和迁移影响。
  • 把重逻辑异步化,缩短消费线程阻塞时间。
  • 对非核心消息允许降级、丢弃或跳过。
  • 调整 max.poll.interval.ms、批量参数和消费线程模型。

回压思路。 Kafka 自身能缓冲流量,但它不能无限吞掉下游故障。真正稳定的系统需要在 Producer、Consumer、下游服务之间形成回压闭环,例如:

  • Producer 端限速或降级。
  • Consumer 端控制并发和批量。
  • 下游数据库或搜索服务承压时主动减速。

否则 Kafka 只是把问题从“实时失败”变成“延迟爆炸”。

常见线上问题与排查

Rebalance 频繁,导致重复消费。 当消费逻辑过慢、心跳超时或实例频繁重启时,消费组会不断重平衡。排查时重点看:

  • session.timeout.ms
  • max.poll.interval.ms
  • 消费业务是否阻塞
  • 是否缺少静态成员配置

分区数量不足,扩容无效。 很多“加机器不生效”的根因,不是消费者不够,而是 Topic 分区不够。一个分区只能被组内一个消费者处理,多加出来的实例根本拿不到任务。

acks=1 或副本配置不当导致丢消息。 只等 Leader 确认时,如果 Leader 写完但 Follower 还没同步就宕机,消息就可能丢失。此类问题通常要结合 acksmin.insync.replicas 和 ISR 监控一起看。

Page Cache 不足,吞吐骤降。 如果把 JVM 堆设得过大,OS 没有足够内存做页缓存,Kafka 会频繁读盘,吞吐和延迟都会恶化。

retention 配置不合理,磁盘打满。 Kafka 的本质是日志系统,不设置合理保留时间和容量上限,就等于默认“消息永久堆积”。日志类 Topic 特别容易在这件事上出事故。

重点监控项。

类别指标
吞吐MessagesInPerSecBytesInPerSec
延迟请求平均延迟、批量发送耗时
副本UnderReplicatedPartitions、ISR 收缩次数
消费Consumer Lag、Rebalance 次数
资源磁盘使用率、Page Cache 压力、网络带宽

本章小结

Kafka 的工程价值可以总结为三层:

  • 用 Topic 和 Partition 把同步调用改造成可扩展的异步日志流。
  • 用副本、ISR、offset 与消费组支撑可靠性和高可用。
  • 用顺序写、Page Cache、零拷贝、批量压缩支撑高吞吐。

真正落地时,最难的往往不是“会不会发消息”,而是能否在顺序、可靠性、吞吐、积压治理之间做清晰取舍。

本章面试题与追问

  1. Kafka 的核心组件有哪些,它和普通消息队列最大的差异是什么? 追问:为什么说 Kafka 更像分布式日志系统,而不只是消息转发器?

  2. 为什么 Kafka 只能保证分区内有序,不能天然保证全局有序? 追问:如果订单状态要求严格有序,你会怎么设计分区策略?

  3. Consumer Group 是如何实现负载均衡的? 追问:为什么消费者数量超过分区数量后,再扩容也没有收益?

  4. Rebalance 是什么,为什么它会影响线上稳定性? 追问:如果线上频繁重复消费,你会优先检查哪几个超时和消费参数?

  5. Kafka 如何保证消息不丢失? 追问:为什么 acks=all 也不能脱离 ISR 状态单独谈可靠性?

  6. Producer 幂等和业务幂等有什么区别? 追问:为什么 Kafka 自带幂等仍然不能替代数据库唯一键或状态机设计?

  7. Kafka 为什么这么快? 追问:顺序写、Page Cache、零拷贝、批量压缩分别解决了什么瓶颈?

  8. 什么是 HW 和 LEO? 追问:为什么消费者通常只能读到 HW 之前的数据?

  9. Kafka 出现消费积压时,你会如何分层排查? 追问:如果 Lag 很高,但消费者 CPU 并不高,可能说明什么问题?

  10. Topic 的 retention 应该如何设置? 追问:如果日志类 Topic 把磁盘打满,除了临时清理,你会如何从治理角度避免再次发生?

第 13 章 Elasticsearch:搜索与索引

Elasticsearch 的价值不在于“能查文本”,而在于它把全文检索、过滤、聚合和分布式扩展组合成了一套可落地的搜索系统。面试和工程实践里,真正拉开差距的不是会不会写 Query DSL,而是能否解释清楚倒排索引、分片副本、写入刷新、相关性排序和线上治理之间的关系。

倒排索引与搜索基础

关系型数据库更擅长精确查找和事务处理,而搜索系统更擅长从大量文本中找出“最相关”的结果。支撑这一点的核心结构就是倒排索引。

倒排索引会把“词项到文档”的关系提前建好。以酒店搜索为例,文档里出现过“Jakarta”“airport”“hotel”这些词,索引中就会记录这些词分别出现在哪些文档、出现次数多少、位置在哪。查询时不再遍历全部文档,而是直接定位词项对应的 posting list,再做交并集、过滤和打分。

结构作用
Document一条被索引的业务记录
Term分词后得到的词项
Posting List某个词项命中的文档列表
SegmentLucene 的底层不可变索引段
Analyzer文本分析链路,负责分词与归一化

这种模型决定了 Elasticsearch 适合搜索、筛选、统计三类操作的组合,但不适合高频事务更新、强一致约束和复杂联表。工程上一个常见误区是把它当主数据库使用,结果在更新频繁、字段约束严格的场景里付出很高代价。

索引、分片与副本

索引可以理解成一类文档的逻辑集合,而真正承载数据的是分片。主分片负责承接写入与查询,副本分片用于容灾和读扩展。

概念作用设计关注点
Index逻辑数据集一般按业务域或生命周期拆分
Primary Shard主分片决定水平扩展上限,创建后不易直接调整
Replica Shard副本分片提升可用性和查询并发
Routing路由规则影响写入均衡、查询范围和热点风险

分片不是越多越好。分片过多会带来更多元数据、更多小段文件和更重的协调开销;分片过少则会限制并行度和扩容空间。经验上更重要的是让单分片大小、文档量、查询模式和节点资源处于平衡状态。

副本也不是单纯“多一份更安全”。副本数增加后,读吞吐和容灾能力会上升,但写入链路需要等待更多复制动作,磁盘和网络成本也会增加。面试里经常会追问“分片和副本分别解决什么问题”,要回答清楚:分片解决容量和并行,副本解决可用性和读扩展。

写入链路与查询链路

Elasticsearch 的写入不是“直接落成可查的数据结构”,而是先经过协调、路由、写入内存缓冲与 translog,再在 refresh 后对搜索可见。

一条文档写入链路通常可以拆成以下阶段:

  1. 客户端请求先到协调节点。
  2. 协调节点根据 _id 或自定义 routing 计算目标主分片。
  3. 主分片先写内存 buffer 和 translog,再并行复制给副本。
  4. 达到确认条件后向客户端返回成功。
  5. 后台 refresh 把内存中的数据转换成新的 segment,使其对搜索可见。

这也是为什么 Elasticsearch 常被称为 near real-time search。写成功不代表立刻能被检索到,中间存在一个 refresh 窗口。

查询链路同样分为两段:

  1. Query Phase:协调节点把查询分发到相关分片,各分片本地完成匹配、过滤和打分。
  2. Fetch Phase:协调节点汇总 Top N 命中文档,再向对应分片拉取 _source 等完整内容。

如果没有 routing 限定,查询通常会广播到多个分片。分片数越多,跨分片协调成本越高,因此“查询慢”不一定是单机算力不足,也可能是索引设计把简单请求放大成了全局扫描。

Mapping、分词与相关性

Mapping 决定字段如何被索引和查询,是搜索质量与资源成本的分水岭。最常见的字段选择是 textkeyword

字段类型适用场景特点
text标题、描述、评论等全文字段会分词,适合 match 查询
keywordID、状态、国家码、精确标签不分词,适合过滤、聚合、排序

一个常见实践是为同一业务字段同时保留全文与精确子字段,例如名称字段既支持中文分词搜索,也支持 .keyword 做精确匹配和聚合。

Analyzer 一般由字符过滤、Tokenizer、Token Filter 三部分组成。中文场景常见的分词策略是索引时更细、查询时稍粗,用来兼顾召回和精确度。若分词器选择不当,常见问题包括:

  • 召回过低:用户输入的词被切得太碎或根本切不出来。
  • 噪声过高:停用词、同义词配置不合理,结果相关性下降。
  • 聚合错误:把本该用 keyword 的字段误建成 text

相关性评分常以 BM25 为基础。它综合考虑词频、逆文档频率和字段长度,因此高频词、长文本和多字段查询都会影响最终排序。线上搜索体验差,往往不是“ES 算法不行”,而是字段权重、分词规则、过滤条件和业务排序混在一起没有分层治理。

深分页与性能优化

深分页是 Elasticsearch 的经典问题。from + size 在页码很深时,协调节点仍需要让各分片先取回更多候选,再做全局排序和截断,因此页数越深,CPU、内存和网络成本越高。

方案适用场景特点
from + size浅分页、后台列表简单直接,但深页成本高
Scroll批量导出、离线扫描基于快照,不适合实时翻页
search_after面向用户的连续翻页依赖稳定排序,性能更可控

除了分页,性能优化还要同时看写入、查询和索引模型三层。

写入侧重点:

  • 批量写入而不是单条刷入。
  • 控制 refresh 和副本策略,避免高峰期频繁生成小 segment。
  • 让路由更均匀,避免热点主分片。

查询侧重点:

  • 能 filter 就不要全靠 score。
  • 缩小搜索范围,例如按时间、租户、业务域切索引。
  • 对高频聚合与排序字段提前建好合适的数据类型。

索引侧重点:

  • 避免过宽 mapping 和无意义的动态字段爆炸。
  • 控制单分片大小,减少过多小分片。
  • 对冷热数据分层,降低热节点负担。

集群治理与常见问题

搜索集群的事故很多不是出在“查不出来”,而是出在治理:脑裂风险、分片失衡、写入堆积、段合并抖动、磁盘水位过高、mapping 失控。

常见问题与排查重点可以归纳如下:

问题常见原因处理方向
写入延迟升高bulk 太小、refresh 太频繁、磁盘吃紧调整批量、观察 translog 和 merge
查询抖动深分页、跨太多分片、缓存命中差收缩查询范围,优化排序和过滤
分片不均衡routing 偏斜、热点租户集中重设路由策略或拆索引
磁盘高水位副本过多、冷热分层缺失、保留周期过长清理旧索引,做 ILM 或冷热迁移
集群不稳定主节点选举异常、节点抖动、GC 压力大先稳控制面,再排查 JVM 和硬件

旧版本 Elasticsearch 里常被问到“脑裂”问题,本质是网络分区下多个节点都认为自己可以成为主节点。现代集群通过更严格的选主与法定人数机制降低了这类问题,但工程原则没有变:控制面节点要稳定,选主规则要清楚,不能把主节点和重查询节点混成一团。

日常治理最值得监控的维度包括:

  • 集群健康:节点数、主分片/副本分片分配、未分配分片。
  • 写入链路:bulk 延迟、拒绝数、translog、refresh 和 merge 时间。
  • 查询链路:QPS、P95/P99 延迟、慢查询、缓存命中率。
  • 资源层:CPU、堆内存、GC、磁盘水位、网络带宽。

本章小结

Elasticsearch 的核心不是某个 API,而是“倒排索引 + 分片副本 + 近实时刷新 + 相关性排序”这套整体模型。

可以把本章总结为四个判断:

  • 做搜索系统时,先想倒排索引和分词,而不是 SQL 心智。
  • 做集群规划时,先分清分片解决扩展,副本解决高可用。
  • 做性能优化时,先找出是写入、查询还是索引模型出了问题。
  • 做线上治理时,先稳住控制面、分片布局和磁盘水位,再谈复杂调优。

本章面试题与追问

  1. Elasticsearch 和 MySQL 在检索模型上有什么根本区别? 追问:为什么全文检索不适合用普通 B+ 树硬扛?

  2. 什么是倒排索引?它的核心结构包括哪些部分? 追问:posting list 里为什么不仅要记文档 ID,还常常要记位置和词频?

  3. 分片和副本分别解决什么问题? 追问:为什么分片数不是越多越好?

  4. Elasticsearch 的写入为什么是 near real-time,而不是绝对实时? 追问:translog、refresh、segment 各自起什么作用?

  5. 一次搜索请求大致会经过哪些阶段? 追问:为什么跨很多分片的查询更容易变慢?

  6. textkeyword 有什么区别? 追问:如果把聚合字段错误地建成 text,线上会有什么后果?

  7. Analyzer 的作用是什么? 追问:为什么搜索质量问题很多时候本质上是分词和字段设计问题?

  8. BM25 主要在衡量什么? 追问:为什么业务排序经常需要和相关性评分拆开设计?

  9. 深分页为什么慢?scrollsearch_after 分别适合什么场景? 追问:如果用户要持续翻页看搜索结果,你会优先推荐哪种方案?

  10. Elasticsearch 集群线上最常见的治理问题有哪些? 追问:如果磁盘水位持续升高且分片未分配,你会先检查什么?

第 14 章 Kubernetes 与 Docker

容器和 Kubernetes 已经成为现代应用交付的默认底座,但它们的价值并不只是“把程序打包起来”。真正重要的是:用统一镜像交付环境,用声明式控制面管理运行状态,用调度、网络和发布机制把单机部署问题变成集群工程问题。

容器化的价值与边界

容器化解决的第一件事是环境一致性。开发、测试、生产使用同一镜像,能够显著降低“我这里能跑”这类问题。第二件事是交付效率,应用启动、回滚、扩缩容都被标准化。第三件事是资源隔离,不同服务在同一台机器上也能有较清晰的 CPU、内存和文件系统边界。

维度容器虚拟机
启动速度秒级分钟级
资源开销更轻更重
隔离粒度进程级,共享内核OS 级,隔离更强
适用场景微服务、CI/CD、弹性部署强隔离、多操作系统环境

但容器化并不是免费午餐。它不会自动解决状态管理、跨机网络、安全基线和故障治理。一个常见误解是“上了 Kubernetes 就天然高可用”,实际上如果应用本身没有做好无状态、健康检查、优雅退出和资源约束,集群只会更快地把问题放大。

Docker 基础模型

Docker 的基础模型可以概括为“镜像 + 容器 + 运行时隔离”。

镜像是分层构建出来的只读模板,容器是在镜像最上层叠加一个可写层后的运行实例。镜像分层的价值在于复用、缓存和分发:基础层不变时,只需要重建变更层;不同应用也可以共享公共基础镜像。

机制作用
Image Layer复用构建结果,提升发布效率
Namespace隔离进程、网络、挂载点等资源视图
Cgroup限制 CPU、内存等资源使用
Union FS把多层镜像叠加成统一文件系统视图

Namespace 负责“看见什么”,例如 PID、Network、Mount、UTS;cgroup 负责“最多能用多少”,例如 CPU 和内存限额。两者结合后,容器在宿主机上仍然是普通进程,只是拥有更独立的资源视图和限制。

工程上几个非常重要的实践是:

  • 镜像尽量精简,减少无关依赖和攻击面。
  • 一个容器优先承载一个主进程,避免职责混乱。
  • 明确设置 CPU/Memory 限额,不要依赖默认无限制。
  • 应用日志输出到标准输出,避免把容器当长期文件服务器。

Kubernetes 核心对象

Kubernetes 的核心不是“帮你起容器”,而是通过声明式资源对象持续逼近期望状态。你提交 YAML 描述目标,控制器不断把实际状态拉回目标状态。

对象作用典型场景
Pod最小调度与运行单元运行一个或多个紧耦合容器
Deployment管理无状态 Pod 副本和发布Web 服务、API 服务
StatefulSet管理有稳定身份的实例数据库、队列、存储节点
DaemonSet每个节点运行一个 Pod日志采集、Node 监控
Job/CronJob一次性或周期性任务数据修复、定时清理
Service为 Pod 提供稳定访问入口集群内服务发现和负载均衡
Ingress七层路由入口域名、路径转发与 TLS
ConfigMap/Secret注入配置与密钥环境差异配置、凭证管理

控制平面里,API Server 是统一入口,Scheduler 决定 Pod 放到哪台 Node,Controller Manager 负责副本收敛,etcd 保存集群期望状态。Node 上的 kubelet 负责本机 Pod 生命周期,kube-proxy 或其他网络组件负责服务转发。

理解这些对象的关键,不是记概念,而是记住它们各自解决的层次:Pod 解决运行单元,Deployment/StatefulSet 解决编排和生命周期,Service/Ingress 解决访问路径。

调度、发布与服务治理

调度本质上是在资源、约束和可用性之间找平衡。Scheduler 会综合节点资源、亲和性、污点容忍、拓扑分布和调度策略来选择 Node。调度失败时最常见的原因不是“集群坏了”,而是请求的资源太大、节点标签不匹配,或者被污点挡住了。

发布层面,Deployment 最常见的能力是滚动更新与回滚。一个稳定的发布链路通常要具备以下元素:

能力作用
Rolling Update分批替换实例,降低整体风险
Readiness Probe只有真正可服务后才接流量
Liveness Probe进程卡死时触发重建
preStop + 优雅终止减少连接被硬切断

服务治理则落在 Service、Ingress 和上层网关策略上。Service 通过虚拟 IP 或 DNS 名把后端 Pod 集合暴露出来,解决 Pod IP 会变化的问题;Ingress 则把域名、路径和 TLS 策略统一收口。面试里如果问“Kubernetes 如何实现服务发现”,核心回答是:Pod 不稳定,Service 提供稳定入口,DNS 再把服务名解析到这个入口。

当业务规模上来后,仅靠“起更多 Pod”还不够,还要关注发布窗口、灰度节奏、连接摘流、依赖超时和跨服务版本兼容,否则发布本身就会成为主要故障源。

网络、配置与资源管理

Kubernetes 网络的基本假设是:每个 Pod 都有独立 IP,Pod 之间默认可互通。不同网络插件会用不同实现方式,但对应用开发者暴露出来的语义尽量统一。

层次关注点
Pod 网络Pod 到 Pod 的直接通信
Service 网络稳定虚拟入口与四层转发
Ingress 网络七层路由、TLS、外部暴露

这套模型带来了两个直接要求。第一,应用不要依赖 Pod IP 稳定不变;第二,网络排障必须区分是容器内监听、Pod 间连通、Service 转发还是 Ingress 配置出了问题。

配置管理上,ConfigMap 适合普通配置,Secret 适合敏感信息,但 Secret 也不是“天然绝对安全”,它解决的是分发与引用,不等于完成密钥全生命周期治理。实践里更重要的是:

  • 配置和镜像分离,避免每次改配置都重做镜像。
  • 敏感信息不要硬编码进镜像和仓库。
  • 关键配置变更要有灰度和回滚路径。

资源管理上,要区分 requestslimitsrequests 决定调度时为 Pod 预留多少资源,limits 决定运行时的上限。如果没有合理设置:

  • requests 太低,会导致节点过度超卖,运行时争抢严重。
  • limits 太低,容易触发 CPU 限流或 OOMKilled。
  • 两者都不设,排障和容量评估都会失真。

集群运维与常见故障

Kubernetes 运维的核心不是背命令,而是建立一套从“控制面健康”到“业务 Pod 状态”的排障路径。最常见的故障状态包括:

状态/现象常见原因优先排查点
Pending调度失败、资源不足、PVC 未绑定describe pod、节点资源、调度事件
ImagePullBackOff镜像地址错误、仓库鉴权失败、网络不通镜像名、Secret、拉取事件
CrashLoopBackOff应用启动即退出、配置错误、依赖未就绪容器日志、启动参数、配置挂载
OOMKilled内存 limit 过低或程序泄漏容器内存曲线、limit 设置、GC/缓存行为
Service 不通selector 错误、端口不匹配、探针未就绪Endpoint、端口映射、readiness 状态

除了业务 Pod,控制平面和节点本身也要监控:

  • 控制面:API Server 延迟、etcd 健康、调度失败率。
  • 节点层:CPU、内存、磁盘、inode、网络丢包。
  • 工作负载层:Pod 重启次数、探针失败、发布耗时。

集群治理中非常高频的几个工程动作是:

  • 清理无效镜像和历史 ReplicaSet,避免节点磁盘被打满。
  • 统一探针、资源、日志格式和标签规范,降低维护成本。
  • 对核心服务做多副本、跨节点分布和 PodDisruptionBudget,减少运维操作带来的抖动。

本章小结

Docker 解决的是标准化打包与隔离运行,Kubernetes 解决的是在集群里持续管理这些运行实例。两者串起来,才构成现代应用交付与运维的基础设施底座。

可以把本章收束为四点:

  • 容器化优先解决环境一致性和交付效率,但不会自动修复应用设计问题。
  • Docker 的关键模型是镜像分层、namespace 和 cgroup。
  • Kubernetes 的关键模型是声明式对象、调度控制和稳定服务入口。
  • 线上稳定性很大程度上取决于探针、资源、发布和排障体系是否健全。

本章面试题与追问

  1. Docker 和虚拟机最大的差异是什么? 追问:为什么说容器更轻,但隔离边界通常弱于虚拟机?

  2. Docker 镜像为什么要做分层? 追问:分层机制如何同时影响构建速度和镜像分发效率?

  3. namespace 和 cgroup 分别解决什么问题? 追问:为什么只做隔离不做资源限制,线上仍然会互相拖垮?

  4. Kubernetes 为什么引入 Pod,而不是直接调度单个容器? 追问:什么场景下一个 Pod 里会放多个容器?

  5. Deployment、StatefulSet、DaemonSet 各自适合什么场景? 追问:为什么数据库类组件通常不建议直接用 Deployment?

  6. Service 和 Ingress 的职责分别是什么? 追问:如果域名能访问到 Ingress,但后端始终 502,你会先查哪一层?

  7. Readiness Probe 和 Liveness Probe 有什么区别? 追问:为什么把启动慢的问题交给 liveness 处理,反而容易造成反复重启?

  8. requestslimits 分别代表什么? 追问:为什么很多 OOMKilled 不是简单“加内存”就能真正解决?

  9. Pod 长时间处于 Pending,通常从哪些方向排查? 追问:如果集群 CPU 看起来还有空闲,但 Pod 仍然调度失败,可能是什么原因?

  10. Kubernetes 线上最常见的故障模式有哪些? 追问:CrashLoopBackOff、ImagePullBackOff、OOMKilled 这三类故障的排查路径有什么不同?

第 15 章 全局 ID 体系与基础服务设计

15.1 为什么电商系统需要全局 ID 体系

在示例代码中,供给链路为了演示流程,使用了类似下面的写法:

s.repo.NextID(ctx, "draft")

仓储内部再用时间戳、前缀和内存序列拼出一个 ID。这种写法适合教学 Demo,因为它能让读者把注意力放在 Draft、Staging、QC、Publish 的业务流程上;但在生产系统中,这类发号逻辑很快会失控。

问题不在于这行代码短,而在于它跳过了一条完整的 ID 设计决策链。

第一步先判断业务语义draft_id 到底是供给流程里的临时单据 ID,还是正式商品 ID?如果它只是草稿单据,就不应该和 item_idsku_id 复用同一套语义;如果其他服务也要引用它,就必须进入统一 namespace 管理,而不能由某个 repository 临时定义 "draft" 前缀。

第二步判断唯一性边界:如果系统只有单进程,内存序列可能暂时可用;一旦扩展到多个实例,实例之间就必须通过 worker_id、号段分配、数据库唯一约束或中心化 ID 服务避免撞号。多实例解决的是“同一个集群内多个进程是否会生成相同 ID”。

第三步判断部署边界:从单机房迁到多机房后,问题会升级为 region / datacenter 之间是否会撞号。此时要考虑 region bits、独立号段、灾备切换、网络分区和数据汇合,而不仅仅是实例内的自增序列。

第四步判断时间依赖:如果 ID 里拼了时间戳,机器时钟回拨、NTP 抖动、容器迁移都会影响唯一性和排序语义。使用 Snowflake 一类方案时,必须明确时钟回拨时是等待、熔断、切换 worker,还是降级到备用策略。

第五步判断暴露范围:这个 ID 是只在内部日志和数据库中使用,还是会出现在 URL、开放 API、订单详情或客服系统中?如果对外暴露,连续递增或可预测格式可能泄露业务量,也可能带来枚举风险。

第六步判断失败语义:发号失败时业务能不能感知?是直接返回错误、重试、回滚,还是使用本地缓存号段继续服务?如果 ID 已经发出但业务事务失败,这个 ID 是否允许浪费?大多数生产系统的答案是:允许跳号,不允许复用。

第七步判断治理能力:namespace 谁审批?容量谁规划?号段快耗尽谁告警?重复冲突谁发现?发号 QPS、失败率、时钟回拨、worker 租约、审计日志在哪里看?如果这些问题没有归属,ID 生成就还只是工具函数,不是基础设施能力。

电商系统里的 ID 远不止一个 draft_id。商品有 item_idspu_idsku_id,交易有 checkout_idorder_idpayment_id,供给有 draft_idstaging_idqc_review_id,库存有 inventory_keyreservation_id,事件有 event_idoutbox_event_id,链路上还有 trace_idoperation_id。这些 ID 的业务语义、性能要求、暴露范围和容灾策略都不同。

把这条链路走完后,全局 ID 体系要回答的就不再是“如何拼一个字符串”,而是建立一套可治理的规则:

什么业务对象使用什么 ID 类型;
什么 ID 由哪个 namespace 管理;
什么 ID 可以对外暴露;
什么 ID 需要趋势递增;
什么 ID 需要可读业务单号;
什么 ID 只是幂等键或链路追踪键;
发号失败、重复、耗尽、时钟回拨时如何处理。

一句话概括:ID 体系是电商系统的基础设施治理问题,不只是一个工具函数问题。

15.2 电商 ID 分类

设计 ID 之前,先不要问“用不用 Snowflake”,而要问“这个 ID 表达什么业务语义”。下表给出电商系统中最常见的 ID 分类。

类型典型字段设计重点不适合的做法
实体 IDitem_idspu_idsku_id长期稳定、索引友好、跨系统引用每个服务各自自增
业务单号order_nopayment_norefund_no对外展示、客服查询、对账、不可枚举直接暴露连续自增
流程单据 IDdraft_idstaging_idqc_review_id流程追踪、审计、低耦合与正式商品 ID 混用
事件 IDevent_idoutbox_event_id幂等消费、重放、排障用时间戳字符串拼接
幂等键idempotency_keyrequest_id表达同一次业务请求当作普通随机 ID
链路 IDtrace_idoperation_id跨服务追踪和审计每层重新生成

这张表背后的关键判断是:ID 的生成方式要服从它的业务用途。

例如,sku_id 通常是商品主数据的稳定实体 ID,适合使用 BIGINT,方便数据库索引、缓存 Key、消息体和下游系统引用;order_no 是对外业务单号,除了唯一之外,还要考虑客服查询、对账、不可枚举和格式兼容;idempotency_key 则不是普通 ID,它表达“同一次业务请求”,必须配合唯一索引和状态机来防止重复下单、重复扣款或重复退票。

15.3 全场景 ID 清单

下面的矩阵不是要求所有公司都照抄,而是给出一个可评审的默认选择。实际落地时,可以根据规模、团队能力、数据库类型、是否多机房和是否对外开放 API 做取舍。

业务域关键 ID样例值推荐类型推荐生成方式设计说明
商品中心item_idspu_idsku_iditem_id=800000123456spu_id=700000123456sku_id=600000123456BIGINTSegment 号段或 Snowflake高频查询和跨系统引用,优先索引友好
商品组合offer_idrate_plan_idoffer_id=500000123456rate_plan_id=510000123456BIGINT 或字符串Segment,外部映射可用字符串本地 Offer 用平台 ID,供应商编码单独保存
供给流程draft_idstaging_idqc_review_iddraft_01HZY7K8J7W6S9B2Q5R4T3M1N0staging_01HZY7N4K9P8D7C6B5A4M3T2Q1qc_01HZY7R9S8T7V6W5X4Y3Z2A1B0字符串ULID/UUIDv7 + 受控 prefix流程单据不应与正式商品 ID 混用
供给任务task_idbatch_idsync_batch_idbatch_20260429_hotel_full_0001字符串ULID/UUIDv7 或业务时间分区编码长任务、批处理和补偿需要可追踪
库存事实stock_ledger_idreservation_idstock_ledger_id=920000123456rsrv_01HZY85D2K9M7N6P5Q4R3S2T1VBIGINT 或字符串Segment、Snowflake 或 ULID账本可用 BIGINT,预占凭证可用字符串
库存业务键inventory_keyinv:sku:600000123456:globalinv:sku:600000123456:date:2026-05-01:channel:app字符串业务组合键表达 SKU、范围、日期、渠道、供应商等维度
购物车cart_idcart_01HZY86K8V7T6S5R4Q3P2N1M0字符串或 BIGINT登录态绑定 user_id,游客车用 ULID登录购物车可弱化独立 ID,游客车需要会话标识
结算checkout_idchk_01HZY88P6Q5R4S3T2V1W0X9Y8Z字符串ULID/UUIDv7 + 幂等键一次结算会话要能重试、恢复和防重复
订单order_idorder_noorder_id=1928475629384753152order_no=ORD20260429CN7K3F9Q2X内部 BIGINT + 外部字符串Snowflake 派生业务单号内部主键和对外单号解耦
支付payment_idpayment_nochannel_trade_nopayment_no=PAY20260429F8K2M6Q9channel_trade_no=202604292200149876543210内部 BIGINT + 外部字符串Snowflake 或渠道请求号平台支付单和渠道单号都要保存
售后refund_idafter_sale_idrefund_no=RF20260429P7Q6R5S4after_sale_id=AS20260429Q8R7S6T5字符串或 BIGINTSnowflake 派生单号便于客服、对账和售后流转
营销campaign_idcoupon_idpromotion_idcampaign_id=300000123456coupon_id=310000123456BIGINTSegment 或 Snowflake营销对象数量大,需稳定引用
搜索index_task_iddoc_idindex_task_id=idx_01HZY8A7B6C5D4E3F2G1H0J9K8doc_id=sku_600000123456字符串业务 ID 或 ULID搜索文档通常以业务实体 ID 为主键
履约fulfillment_iddelivery_order_nofulfillment_id=FUL20260429M8N7P6Q5字符串Snowflake 派生单号或外部单号履约单经常要与供应商、物流系统对接
财务ledger_idsettlement_idreconciliation_idledger_id=930000123456settlement_id=SET202604290001BIGINT 或字符串Segment、Snowflake、批次号账务更重视可追溯、不可重复和对账批次
事件event_idoutbox_event_idevt_01HZY8B8C7D6E5F4G3H2J1K0M9evt_product_published_800000123456_12字符串ULID/UUIDv7 或确定性事件 ID用于幂等消费、重放和排障
链路追踪trace_idoperation_idtrace_id=4bf92f3577b34da6a3ce929d0e0e4736op_01HZY8D9E8F7G6H5J4K3M2N1P0字符串Trace 标准或 ULID跨服务传递,不在每一层重新生成
幂等idempotency_keyu_10001:cart_9f2a:req_8c7d字符串客户端请求 ID 或业务语义组合键依赖唯一约束和状态机,不等同于随机 ID

这里有几个容易混淆的点:

  1. order_idorder_no 可以不是同一个字段。前者可以是内部主键,后者是对外业务单号。
  2. inventory_key 通常不是随机 ID,而是业务维度组合,例如 inv:sku:30001:globalinv:sku:40001:date:2026-05-01:channel:app
  3. checkout_id 不是订单号。结算会话可能失败、过期或被重试,只有创单成功后才产生订单。
  4. idempotency_key 的核心不是“看起来唯一”,而是业务上能判断“这是不是同一次请求”。

15.4 常见发号方案对比

15.4.1 DB 自增

DB 自增是最简单的方案:表主键使用 AUTO_INCREMENT 或数据库原生 identity。它适合单库单表、小规模后台配置、内部字典表和教学示例。

优点是简单、强一致、无需额外服务。缺点也明显:强依赖单库,跨库分表困难;连续递增容易暴露业务量;高并发交易链路可能把数据库打成瓶颈。

在电商系统中,DB 自增可以用于后台低频配置表,但不建议直接作为对外订单号、支付单号或全局 SKU ID。

15.4.2 DB Sequence 表

Sequence 表通过插入一张专门的序列表获取 LastInsertId,示例中的订单服务就有类似思路:

CREATE TABLE order_id_seq (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    created_at DATETIME(6) NOT NULL
);

每生成一个订单号,就插入一行序列表,再把自增值格式化成 ORD-123。这个方案比直接使用业务表自增稍微解耦,但本质仍是数据库中心化发号。

它适合早期系统、低中并发内部单据和容易理解的教学实现。不适合高并发交易核心,也不适合直接对外暴露连续序列。

15.4.3 Redis INCR

Redis INCR 可以按 key 递增,例如:

INCR id:order:20260429

再格式化为:

ORD2026042900012345

它的优点是性能高、实现简单、天然适合按天流水号。缺点是强依赖 Redis 高可用和持久化策略;主从切换、数据回滚、双活部署时要谨慎;同时,按天连续递增仍可能暴露业务量。

Redis INCR 适合活动流水、短期批次、低风险业务编号。核心订单和支付单如果使用 Redis INCR,必须设计持久化、主从切换和重复保护。

15.4.4 Snowflake

Snowflake 是经典的分布式趋势递增 ID 方案。常见实现把一个 64 位整数拆成:

时间戳 + 机器 / 机房标识 + 毫秒内序列

例如常见切分是 41 位毫秒时间戳、10 位机器标识、12 位序列。它的优点是本地生成、低延迟、高吞吐、趋势递增、适合 BIGINT 主键。缺点是依赖时钟,必须治理 worker_id,还要处理时钟回拨。

Snowflake 适合订单内部 ID、支付内部 ID、库存账本 ID、营销 ID,以及需要高并发写入的实体 ID。对外单号可以基于 Snowflake 再格式化,而不是直接暴露原始数字。

15.4.5 Segment 号段

Segment 号段,也叫 Hi-Lo 模式。核心思想是数据库只负责分配一段 ID,服务实例拿到号段后在本地内存中发号:

product.sku 申请到 1000000 - 1009999
product.sku 申请到 1010000 - 1019999

数据库中通常维护:

namespace、max_id、step、version

服务用乐观锁推进 max_id,一次拿一段。这样既保留数据库的强一致分配,又避免每个 ID 都访问数据库。

优点是不强依赖时钟,容量可控,namespace 独立,适合主数据 ID。缺点是服务重启会浪费一段号;号段耗尽前要预取;如果数据库不可用,新的号段无法分配。

Segment 非常适合 item_idspu_idsku_idcampaign_idcoupon_id 等电商主数据 ID。

15.4.6 UUIDv7、ULID 与 KSUID

UUID、ULID、KSUID 都属于更偏字符串或 128 位标识的方案。相较传统 UUIDv4,UUIDv7、ULID 和 KSUID 更强调时间有序或近似时间有序,适合日志、事件、流程单据和跨服务追踪。

UUIDv7 已在 RFC 9562 中定义,它把 Unix 毫秒时间放在高位,并用随机位提供唯一性。ULID 也采用时间 + 随机的思路,字符串更短、更适合人类阅读和按字典序排序。

这类 ID 的优点是无需中心服务,跨服务生成方便,天然适合字符串前缀。缺点是比 BIGINT 长,索引和存储成本更高,不适合所有高频实体都使用。

推荐用于 draft_idstaging_idqc_review_idoperation_idevent_idoutbox_event_idcheckout_id 等场景。

方案是否中心化是否趋势递增主要优点主要风险推荐场景
DB 自增简单、强一致单点瓶颈、暴露业务量小规模后台表
DB Sequence与业务表解耦、易理解高并发瓶颈、跨库困难早期单据号、教学示例
Redis INCR高性能、适合按天流水持久化和主从切换风险活动流水、短期批次
Snowflake大体是低延迟、高吞吐、BIGINT 友好时钟回拨、worker 分配订单、支付、库存账本
Segment 号段半中心化不依赖时钟、容量可控号段浪费、依赖号段预取商品、营销、主数据
UUIDv7 / ULID无需协调、跨服务方便字段较长、索引成本高流程单据、事件、链路

15.5 推荐混合架构

生产电商系统更常见的不是“全站只用一个算法”,而是混合架构:

业务服务
  -> ID SDK
  -> ID Registry
  -> Generator Router
      -> Segment Generator
      -> Snowflake Generator
      -> ULID / UUIDv7 Generator
      -> Business Number Formatter
  -> Observability / Audit / Admin

推荐默认规则如下:

商品和库存主数据:Segment 或 Snowflake 的 BIGINT
交易单号:Snowflake 派生业务单号
供给流程、事件和链路:ULID/UUIDv7 + 受控 prefix
幂等:业务语义唯一约束,不等同于普通 ID

也就是说:

  1. item_idspu_idsku_id 这类主数据 ID 优先使用 BIGINT,便于索引和跨系统传递。
  2. order_nopayment_norefund_no 这类对外单号可以在底层 Snowflake ID 上增加日期、渠道、校验位或编码。
  3. draft_idstaging_idqc_review_id 这类流程单据 ID 使用字符串,更适合审计、日志和跨系统排障。
  4. idempotency_key 不由 ID 服务随便生成,而要和用户、购物车快照、请求来源或业务动作绑定。

这个混合架构可以同时满足性能、可读性、治理和扩展性。

15.6 ID 服务架构

15.6.1 ID Registry

ID Registry 是 ID 体系的控制面,负责登记所有 namespace,例如:

product.item
product.spu
product.sku
supply.draft
supply.staging
trade.order
trade.payment
event.outbox

每个 namespace 至少要记录:

字段含义
namespace全局唯一的业务命名空间
biz_domain所属业务域
id_typeINT64STRINGBUSINESS_NOIDEMPOTENCY_KEY
generator_typeSEGMENTSNOWFLAKEULIDUUIDV7BUSINESS
prefix字符串 ID 或业务单号前缀
expose_scopeINTERNALEXTERNALMIXED
owner_team负责人团队
statusENABLEDDISABLEDDEPRECATED

不要让业务代码直接传 "draft""order" 这种裸字符串。裸字符串无法治理,也无法做容量规划和审计。

15.6.2 ID SDK

业务服务应该依赖 SDK,而不是直接访问 ID 表或自己拼接字符串。SDK 至少提供:

type Generator interface {
    NextInt64(ctx context.Context, ns Namespace) (int64, error)
    NextString(ctx context.Context, ns Namespace) (string, error)
    NextBatchInt64(ctx context.Context, ns Namespace, size int) ([]int64, error)
}

SDK 可以封装本地缓存、号段预取、熔断降级、指标上报和错误转换。业务服务只关心“我要哪个 namespace 的 ID”。

15.6.3 Generator Router

Generator Router 根据 namespace 配置路由到不同发号器:

product.sku       -> Segment Generator
trade.order       -> Snowflake Generator + Business Number Formatter
supply.draft      -> ULID Generator
event.outbox      -> UUIDv7 Generator
checkout.session  -> ULID Generator

这样可以把“业务 ID 规则”从业务代码中拿出来,避免仓储层、应用层、HTTP 层各自发明一套规则。

15.6.4 Segment Generator

Segment Generator 从数据库申请号段,然后在本地内存中发号。为了避免号段耗尽造成请求抖动,应该支持双 Buffer:

当前号段使用到 70% 时,后台预取下一段;
当前号段耗尽时,如果下一段已就绪,立即切换;
预取失败时,继续使用当前号段并告警;
当前号段完全耗尽且无法预取时,返回明确错误。

15.6.5 Snowflake Generator

Snowflake Generator 的关键不是位运算,而是 worker 治理:

  1. worker_id 不能靠配置文件随手写,应该由租约表、注册中心或部署平台分配。
  2. 实例启动时申请 worker,定期心跳,退出或过期后释放。
  3. 发现时钟回拨时,要短暂等待、切换 worker 或熔断,而不是继续发号。
  4. 多机房部署时,要预留 region 或 datacenter 位。

15.6.6 ULID / UUIDv7 Generator

这类生成器适合本地生成,但仍然要受 namespace 约束。推荐格式:

draft_01JABCD...
staging_01JABCE...
qc_01JABCF...
evt_01JABCG...
op_01JABCH...

prefix 不是随意字符串,而是 Registry 中登记过的前缀。这样日志、排障和数据治理可以快速识别 ID 类型。

15.6.7 Business Number Formatter

业务单号通常不直接等于底层 ID。订单号可以设计为:

ORD + yyyyMMdd + base36(snowflake_id) + check_digit

例如:

ORD20260429CN7K3F9Q2X

这种格式便于客服和对账按日期定位,同时不直接暴露连续自增值。校验位可以降低人工录入错误。

15.6.8 Observability / Audit / Admin

ID 服务必须可观测:

指标说明
idgen_qps各 namespace 发号 QPS
idgen_error_rate发号失败率
segment_remaining当前号段剩余比例
segment_alloc_latency申请号段耗时
clock_rollback_count时钟回拨次数
worker_lease_expired_countworker 租约过期次数
duplicate_key_error_count下游唯一键冲突次数

高频 ID 不应把每次发号都同步写审计表,否则 ID 服务会被审计拖垮。更合理的方式是:常规路径打指标,异常路径写审计。

15.7 关键业务 ID 设计

15.7.1 sku_idspu_iditem_id

item_id 是前台商品入口,spu_id 是商品定义层的标准品,sku_id 是具体销售规格。它们都属于长期稳定的主数据 ID,推荐使用 BIGINT

默认选择:

product.item -> Segment
product.spu  -> Segment
product.sku  -> Segment

如果系统写入并发特别高,也可以改成 Snowflake,但要统一 worker 管理。无论使用哪种方案,ID 一旦发出就不应复用。草稿废弃、商品下架、SKU 删除都不应该回收 ID。

供给链路中是否提前生成 sku_id,取决于业务:

  1. 如果外部供应商、图片、库存、审核都需要提前引用 SKU,可以在 Draft 阶段占号,状态为 RESERVED
  2. 如果希望未审核数据完全不污染正式商品空间,可以在 Publish 成功时生成正式 sku_id

两种方案都可行,但必须在附录和代码中讲清楚边界。

15.7.2 order_idorder_no

订单建议内部主键和对外单号解耦:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    order_no VARCHAR(64) NOT NULL,
    user_id BIGINT NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_order_no (order_no)
);

其中:

id       -> 内部主键,Snowflake 或 Segment
order_no -> 对外业务单号,Snowflake 派生格式

不要直接暴露 ORD-1ORD-2 这类连续单号。它会暴露业务量,也容易被枚举。

15.7.3 checkout_ididempotency_key

checkout_id 表达一次结算会话,idempotency_key 表达一次业务请求。它们可以相关,但不能混为一谈。

典型设计:

checkout_id = ULID
idempotency_key = user_id + cart_snapshot_hash + client_request_id

创单时,订单系统需要唯一约束:

UNIQUE KEY uk_order_idempotency (user_id, idempotency_key)

这样用户重复点击“提交订单”时,系统返回同一笔订单,而不是生成多笔订单。

15.7.4 payment_id、渠道单号与对账

支付系统至少要区分三类编号:

字段说明
payment_id平台内部支付主键
payment_no平台对外支付单号
channel_trade_no支付渠道返回的交易号

平台调用渠道时,还需要一个稳定的渠道请求号,例如 out_trade_no。这个请求号通常应该由平台生成,并作为调用渠道的幂等键。不要用渠道返回单号作为平台支付单的唯一依据,因为渠道单号只有调用成功后才出现。

15.7.5 draft_idstaging_id 与供给审核单

供给流程 ID 推荐使用字符串:

draft_01J...
staging_01J...
qc_01J...

原因是它们不是正式商品资产,不需要像 sku_id 一样参与高频交易查询。字符串 prefix 能快速表达流程类型,便于运营后台、日志检索和问题排查。

关键边界是:Draft、Staging、QC 阶段的 ID 不应替代正式 item_idspu_idsku_id。只有发布事务成功后,商品中心才持有正式商品主数据 ID。

15.7.6 event_id 与 Outbox 去重

事件 ID 需要支持幂等消费和重放。常见方案有两种:

  1. 随机或时间有序 ID,例如 evt_01J...
  2. 确定性事件 ID,例如 evt_product_published_{item_id}_{version}

对于 Outbox,确定性事件 ID 很有价值,因为同一个聚合版本只应该发布一次事件。消费者侧仍然要有处理表或唯一索引:

CREATE TABLE event_consume_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    consumer_group VARCHAR(64) NOT NULL,
    event_id VARCHAR(128) NOT NULL,
    consumed_at DATETIME NOT NULL,
    UNIQUE KEY uk_consumer_event (consumer_group, event_id)
);

这样即使消息系统 at-least-once 投递,也能实现业务上的精确一次效果。

15.8 容灾、风险与治理

风险表现缓解策略
时钟回拨Snowflake 生成重复或乱序 ID使用 NTP 单调配置;检测回拨;短暂等待;超过阈值熔断或切换 worker
号段浪费Segment 服务重启后未用完的 ID 丢失接受不连续;容量规划;合理设置 step;禁止回收已发号段
重复发号多实例使用同一 worker 或并发申请同一号段worker 租约;DB 乐观锁;唯一索引;重复冲突告警
ID 枚举外部用户通过连续 ID 猜测订单量或访问资源内外 ID 解耦;业务单号编码;权限校验;必要时加校验位
跨地域冲突多机房各自发号后 ID 冲突预留 region bits;按 region 分段;中心化 namespace 规划
字段类型失控同一个 ID 在不同系统里一会儿是字符串,一会儿是数字统一契约;IDL / OpenAPI 固化类型;迁移期双字段兼容
把幂等键当 ID重试请求仍然生成多笔订单或多次扣款唯一约束;请求状态表;幂等返回;业务状态机保护

电商系统还要特别注意“唯一性不是只靠 ID 服务保证”。最终写入业务表时仍然要有唯一索引。ID 服务负责降低冲突概率和统一规则,业务数据库负责最后一道硬约束。

15.9 数据库与接口设计

15.9.1 Namespace 注册表

CREATE TABLE id_namespace (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    namespace VARCHAR(64) NOT NULL COMMENT '业务命名空间,例如 product.sku、trade.order',
    biz_domain VARCHAR(64) NOT NULL COMMENT '业务域,例如 product、trade、supply',
    id_type VARCHAR(32) NOT NULL COMMENT 'INT64/STRING/BUSINESS_NO/IDEMPOTENCY_KEY',
    generator_type VARCHAR(32) NOT NULL COMMENT 'SEGMENT/SNOWFLAKE/ULID/UUIDV7/BUSINESS',
    prefix VARCHAR(32) DEFAULT NULL COMMENT '字符串 ID 或业务单号前缀',
    expose_scope VARCHAR(32) NOT NULL COMMENT 'INTERNAL/EXTERNAL/MIXED',
    step INT NOT NULL DEFAULT 1000 COMMENT 'Segment 号段步长',
    max_capacity BIGINT DEFAULT NULL COMMENT '容量规划上限',
    owner_team VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ENABLED/DISABLED/DEPRECATED',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_namespace (namespace),
    KEY idx_domain_status (biz_domain, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ID 命名空间注册表';

15.9.2 Segment 号段表

CREATE TABLE id_segment (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    namespace VARCHAR(64) NOT NULL,
    max_id BIGINT NOT NULL COMMENT '当前已经分配到的最大 ID',
    step INT NOT NULL COMMENT '每次申请的号段大小',
    version BIGINT NOT NULL COMMENT '乐观锁版本',
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_namespace (namespace)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Segment 号段表';

申请号段时使用乐观锁:

UPDATE id_segment
SET max_id = max_id + step,
    version = version + 1,
    updated_at = NOW()
WHERE namespace = ?
  AND version = ?;

15.9.3 Snowflake Worker 租约表

CREATE TABLE id_worker (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    worker_id INT NOT NULL,
    region_code VARCHAR(32) NOT NULL,
    datacenter_code VARCHAR(32) NOT NULL,
    instance_id VARCHAR(128) NOT NULL,
    lease_token VARCHAR(64) NOT NULL,
    lease_until DATETIME NOT NULL,
    heartbeat_at DATETIME NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/EXPIRED/DISABLED',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_worker_region_dc (worker_id, region_code, datacenter_code),
    UNIQUE KEY uk_instance (instance_id),
    KEY idx_status_lease (status, lease_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Snowflake worker 租约表';

这张表不是普通配置表,而是 Snowflake 实例之间争抢 worker_id 的租约表。每个正在发号的实例必须先在自己的 region_codedatacenter_code 下拿到一个未被占用的 worker_id,并且在租约有效期内持续续租。UNIQUE KEY uk_worker_region_dc 保证同一个地域和机房内,同一个 worker_id 同一时间只能被一个实例持有;UNIQUE KEY uk_instance 保证同一个实例不会同时占用多个 worker。

核心字段的使用方式如下:

字段用法
worker_id写入 Snowflake 的 worker bits,取值范围要受位数限制,例如 0 到 31
region_codedatacenter_code约束 worker 的部署边界,避免不同地域或机房混用同一组 worker
instance_id当前持有租约的实例标识,通常由部署平台注入,例如 Pod UID 或进程实例 ID
lease_token每次成功获取或抢占租约时生成的新 token,用于续租和释放时做 fencing 校验
lease_until租约过期时间,过期后其他实例才允许抢占
heartbeat_at最近一次心跳时间,用于排查实例卡顿、网络抖动和租约丢失
statusACTIVE 表示可用租约,EXPIRED 表示已释放或过期,DISABLED 表示该 worker 位被运维禁用

实例启动时的流程是:

  1. 生成或读取 instance_id
  2. 先按 instance_id 查询自己是否已有租约行;如果有有效租约,则续租并复用原来的 worker_id
  3. 如果自己的租约行已经过期但没有被禁用,则优先在原行上重新生成 lease_token 并续租,避免触发 uk_instance 唯一索引冲突。
  4. 如果没有自己的租约行,则在当前 region_codedatacenter_code 下扫描可用 worker_id
  5. 对空闲 worker 插入新行;对已经过期的 worker,使用条件更新抢占。
  6. 只有插入成功或条件更新影响 1 行时,实例才算拿到租约,Snowflake Generator 才能进入 ready 状态。

这里的“扫描可用 worker_id”不是说实例提前知道要抢哪一个,而是由 Snowflake 位数推导出候选集合。当前设计里 worker_id 是 5 bit,所以候选范围是 0..31。实例可以用 hash(instance_id) % 32 作为扫描起点,然后按环形顺序尝试 32 个候选,避免所有实例都从 worker_id = 0 开始竞争。

对每个候选 worker_id,实例按下面顺序处理:

  1. 如果表里没有这一行,尝试插入新租约;插入成功就获得该 worker。
  2. 如果这一行存在且 status = 'DISABLED',跳过。
  3. 如果这一行存在且 lease_until >= NOW(),说明仍被其他实例持有,跳过。
  4. 如果这一行存在且 lease_until < NOW(),再执行下面的条件更新抢占。
  5. 如果 32 个候选都不可用,实例保持 not ready,后台退避重试。

空闲 worker 可以直接插入:

INSERT INTO id_worker (
    worker_id, region_code, datacenter_code,
    instance_id, lease_token, lease_until,
    heartbeat_at, status, created_at, updated_at
) VALUES (
    ?, ?, ?,
    ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND),
    NOW(), 'ACTIVE', NOW(), NOW()
);

并发启动时,两个实例可能同时插入同一个候选 worker。此时唯一索引会让其中一个插入失败;失败方不要报错退出,而是读取最新行状态,继续尝试下一个候选或尝试抢占已经过期的候选。

抢占过期 worker 时必须带上过期条件,避免两个实例同时抢到同一个 worker:

UPDATE id_worker
SET instance_id = ?,
    lease_token = ?,
    lease_until = DATE_ADD(NOW(), INTERVAL ? SECOND),
    heartbeat_at = NOW(),
    status = 'ACTIVE',
    updated_at = NOW()
WHERE worker_id = ?
  AND region_code = ?
  AND datacenter_code = ?
  AND status <> 'DISABLED'
  AND lease_until < NOW();

续租时必须同时校验 instance_idlease_token。如果更新影响行数不是 1,说明租约已经过期、被抢占或被禁用,当前实例必须立刻停止发号,进入 not ready 状态,并记录 WORKER_LEASE_LOST

UPDATE id_worker
SET lease_until = DATE_ADD(NOW(), INTERVAL ? SECOND),
    heartbeat_at = NOW(),
    updated_at = NOW()
WHERE instance_id = ?
  AND lease_token = ?
  AND status = 'ACTIVE';

正常退出时不要删除租约行,而是把租约置为过期,便于审计和后续排查:

UPDATE id_worker
SET lease_until = NOW(),
    status = 'EXPIRED',
    updated_at = NOW()
WHERE instance_id = ?
  AND lease_token = ?;

实现时要把数据库时间作为租约判断的基准,减少不同机器本地时钟不一致带来的误判。实例本地还应维护一个租约看门狗:如果距离上次成功心跳已经超过安全阈值,即使数据库还没返回失败,也要先把 Snowflake Generator 标记为 not ready,避免长时间 GC、网络卡顿或容器暂停后继续使用已经可能被别人抢占的 worker_id

15.9.4 发号审计与异常记录

CREATE TABLE id_issue_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    request_id VARCHAR(64) NOT NULL,
    namespace VARCHAR(64) NOT NULL,
    caller VARCHAR(128) NOT NULL,
    issue_type VARCHAR(32) NOT NULL COMMENT 'SUCCESS/FAILED/ROLLBACK/SEGMENT_ALLOCATED',
    issued_value VARCHAR(128) DEFAULT NULL,
    error_message VARCHAR(512) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_request_id (request_id),
    KEY idx_namespace_time (namespace, created_at),
    KEY idx_issue_type_time (issue_type, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键 ID 发号审计与异常记录';

审计表不应该记录所有高频发号请求。建议只记录关键 namespace、号段申请、异常、回拨和人工操作。

15.9.5 Go SDK 接口

type Namespace string

const (
    NamespaceProductItem  Namespace = "product.item"
    NamespaceProductSPU   Namespace = "product.spu"
    NamespaceProductSKU   Namespace = "product.sku"
    NamespaceSupplyDraft  Namespace = "supply.draft"
    NamespaceSupplyStage  Namespace = "supply.staging"
    NamespaceTradeOrder   Namespace = "trade.order"
    NamespaceTradePayment Namespace = "trade.payment"
)

type Generator interface {
    NextInt64(ctx context.Context, ns Namespace) (int64, error)
    NextString(ctx context.Context, ns Namespace) (string, error)
    NextBatchInt64(ctx context.Context, ns Namespace, size int) ([]int64, error)
}

应用服务依赖这个接口,仓储只负责保存实体:

type SupplyOpsService struct {
    repo  SupplyRepository
    idgen id.Generator
}

15.10 示例代码改造建议

当前示例中的写法是:

DraftID: s.repo.NextID(ctx, "draft"),

生产级演进方向是:

draftID, err := s.idgen.NextString(ctx, id.NamespaceSupplyDraft)
if err != nil {
    return nil, err
}

再构造领域对象:

draft := &domain.ProductSupplyDraft{
    DraftID:     draftID,
    OperationID: operationID,
    Status:      domain.DraftStatusDraft,
    CreatedAt:   now,
    UpdatedAt:   now,
}

ProductCenterRepository.NextItemID(ctx) 也可以演进为:

itemID, err := s.idgen.NextInt64(ctx, id.NamespaceProductItem)
if err != nil {
    return nil, err
}

订单服务中的 NextOrderID(ctx) 可以拆成两层:

internalID, err := s.idgen.NextInt64(ctx, id.NamespaceTradeOrder)
if err != nil {
    return nil, err
}

orderNo := s.orderNoFormatter.Format(internalID, time.Now())

这样,仓储不再定义 ID 规则,业务服务也不再传裸 prefix。所有 namespace、生成策略和对外格式都由 ID 体系统一治理。

本附录只给出改造方向,不要求立刻重构示例代码。教学代码可以保留简化实现,但正文要让读者知道生产系统应该如何演进。

15.11 面试和架构评审要点

设计全局 ID 体系时,可以用下面的问题自查:

  1. 这个 ID 是内部实体 ID、对外业务单号、流程单据 ID、事件 ID,还是幂等键?
  2. 这个 ID 是否会出现在 URL、订单详情、客服系统或开放 API 中?
  3. 如果对外暴露,是否会泄露业务量或被枚举?
  4. 这个 ID 是否需要趋势递增?是否真的需要严格递增?
  5. 数据库主键是 BIGINT 还是字符串?索引成本是否可接受?
  6. 多实例部署时,worker 或号段如何分配?
  7. 机器时钟回拨时,系统等待、降级还是熔断?
  8. 多机房部署时,是否预留 region 或 datacenter 位?
  9. ID 服务不可用时,业务是失败、降级还是使用本地缓存号段?
  10. 最终业务表是否有唯一索引兜底?
  11. 幂等键是否有业务语义,还是只是随机字符串?
  12. 老系统 ID 如何迁移?是否需要双写、双查或映射表?

如果这些问题没有答案,就说明 ID 体系还停留在工具函数层面,没有进入基础设施治理层面。

15.12 小结

统一 ID 体系的重点不是某个算法,而是按业务语义治理 namespace、生成策略、暴露形式和失败处理。

电商系统中,sku_idorder_nodraft_idevent_ididempotency_key 看起来都叫 ID,但它们解决的问题完全不同。生产级设计应该把它们分开建模:

实体 ID:稳定引用,索引友好
业务单号:对外展示,防枚举,便于对账
流程单据:追踪流程,支持审计
事件 ID:幂等消费,支持重放
幂等键:表达同一次业务请求
链路 ID:贯穿调用链和操作链

推荐的默认架构是:Segment 号段 + Snowflake + ULID/UUIDv7 + 幂等键治理。这套组合不是最炫的方案,但足够贴近真实电商系统:它尊重不同业务场景的差异,也给后续多实例、多机房、开放平台和长期演进留下空间。

第 16 章 技术栈选型指南

技术栈选型不是列一个“流行组件清单”,而是把业务目标、团队能力、系统约束和长期治理成本放到同一张表里比较。一个组件能不能用,不只取决于性能指标,还取决于团队是否会运维、故障时是否能降级、数据是否能迁移、监控是否完整、未来是否会被业务规模反噬。

选型的基本顺序

技术选型建议按下面的顺序展开:

  1. 先明确业务目标:低延迟、高吞吐、强一致、快速迭代、成本可控,哪个是第一优先级。
  2. 再判断数据特征:事务数据、缓存数据、搜索数据、事件数据、分析数据不能混用一套存储思路。
  3. 再识别访问模式:读多写少、写多读少、热点集中、时间序列、复杂检索、批量导入各自对应不同设计。
  4. 再评估团队能力:是否有足够经验处理扩容、备份、回滚、监控、容量规划和故障演练。
  5. 最后决定演进路径:先用简单方案跑通业务,再在瓶颈明确后引入更重的基础设施。

这个顺序的核心是:不要为了展示技术复杂度而引入组件,要为了控制业务复杂度而选择工具。

常见组件的职责边界

组件适合解决的问题不适合承担的职责
MySQL权威数据、事务、状态机、审计高维全文检索、无限历史明细在线查询
Redis热点缓存、短期状态、原子扣减、限流计数长期权威存储、复杂事务关系
Kafka异步解耦、削峰、事件传播、可回放日志用户实时同步响应、强事务提交
Elasticsearch全文搜索、多条件筛选、聚合检索核心交易状态、强一致写路径
Kubernetes标准化部署、弹性、发布治理替代应用自身的降级、幂等、补偿设计

在系统设计面试和真实项目里,好的回答不是“这里用 Redis”,而是“这里用 Redis 承担短期热点读写,权威状态仍在 MySQL,并通过过期、回源和对账处理不一致”。组件本身只是方案的一部分,组件之间的边界和失败处理才是架构设计。

电商场景中的选型示例

电商系统天然会把多种组件放到同一条链路里。商品中心的主数据适合落 MySQL;商品搜索和筛选适合构建 Elasticsearch 索引;库存可售量可以用 Redis 承担高并发预扣,但库存流水和最终状态必须回到 MySQL;订单创建后的通知、积分、营销核销适合通过 Kafka 异步扩散。

这类组合的重点不是“组件越多越高级”,而是每个组件只承担自己擅长的职责,并在边界上设计好失败语义:

  • Redis 扣减成功但订单创建失败,要有释放或补偿。
  • Kafka 消息可能重复投递,消费者要幂等。
  • Elasticsearch 索引可能延迟,前台体验要接受短暂不一致。
  • MySQL 主从可能延迟,关键读路径要能读主或使用一致性读策略。

选型评审清单

做技术方案评审时,可以用下面的问题快速过滤风险:

问题判断点
这个组件解决的核心矛盾是什么?性能、可靠性、一致性、研发效率,不能混在一起说
不引入它会怎样?如果没有明确痛点,先保持简单
它失败时业务如何降级?超时、重试、降级、补偿必须能讲清
数据迁移和回滚怎么做?新旧链路并行、双写校验、灰度切换
谁负责运维和容量规划?没有人负责的组件就是未来事故源
它会不会改变团队开发方式?CQRS、事件驱动、微服务都会增加协作成本

技术栈选型的结论最好写进 ADR 或技术方案文档。这样后续团队复盘时,不只知道“当时选了什么”,还知道“为什么选、放弃了什么、风险如何接受”。

第 17 章 操作系统基础

系统设计并不要求你像内核开发者那样实现调度器和页表,但要求你在分析线上瓶颈、评估并发模型和解释性能问题时,能够把操作系统概念落到工程现实里。本章保留和系统设计最相关的操作系统知识:任务调度、虚拟内存、文件系统、I/O 模型,以及这些概念在服务端工程中的实际含义。

进程、线程与调度

进程是资源分配的基本单位,线程是操作系统调度的基本单位。一个进程拥有独立地址空间、文件描述符表和资源上下文;同一进程内的多个线程共享代码段、堆、打开的文件和大部分全局状态。协程则更轻量,通常由语言运行时或用户态调度器管理,例如 Go 的 Goroutine。系统设计里常见的并发方案,本质上是在这三类执行单元之间做取舍。

判断并发模型时,至少要回答三件事:隔离性、调度成本、通信方式。多进程隔离性好,单个进程崩溃不容易直接拖垮其他进程,但进程间通信和上下文切换成本更高。多线程共享内存更方便,适合高吞吐服务端程序,但锁竞争、共享状态失控和局部故障扩散也更明显。协程进一步降低了切换成本,更适合高并发 I/O 场景,但它不是免费的线程替代品,底层依旧依赖线程和操作系统调度。

Linux 进程常见状态包括运行态、可中断睡眠、不可中断睡眠、僵尸态和停止态。面试中问这些状态,不是为了背枚举值,而是为了判断你能否解释诸如“为什么这个进程看起来卡死”“为什么 kill 不掉”“为什么机器 load 很高但 CPU 不高”这类问题。比如不可中断睡眠往往意味着线程在等磁盘或某类内核资源;僵尸进程说明子进程已退出,但父进程没有调用 waitwaitpid 回收状态。

调度层面要理解两个点。第一,时间片和公平性并不意味着所有任务“绝对平均”,调度器会综合优先级、可运行队列和任务类型决定谁先执行。第二,系统设计里的“并发能力”不只取决于语言层 API,也取决于底层调度代价。线程数暴涨会带来更重的内核切换和更大的栈内存开销,最终体现为吞吐下降、尾延迟升高和机器负载异常。

进程创建也经常出现在面试追问里。fork 利用写时复制创建子进程,真正昂贵的不是“马上复制整片内存”,而是复制页表并在后续写入时再触发页面复制。因此,fork 之后立刻 exec 是典型优化路径。如果父进程退出而子进程仍在运行,子进程会变成孤儿进程并被 init 收养;如果子进程退出而父进程不回收,就会形成僵尸进程。线上服务出现大量僵尸进程,往往反映的是进程管理逻辑失控,而不是单纯的资源不足。

内存管理与虚拟内存

系统设计读者最该掌握的不是页表实现细节,而是“虚拟内存如何塑造程序行为”。每个进程看到的都是自己的虚拟地址空间,通常包含代码段、全局数据区、堆、栈以及映射区域。CPU 访问虚拟地址时,操作系统通过页表将它翻译为物理地址,TLB 则缓存热点映射,减少频繁查表成本。

这种设计带来三个直接收益。第一,隔离性更强,不同进程默认不能随意访问彼此内存。第二,地址空间比物理内存更灵活,程序可以按需分配和映射。第三,结合分页、换页和共享页机制,系统能够更高效地使用有限内存。工程里一旦出现内存抖动、缺页异常过多、交换分区频繁使用,服务性能通常会明显恶化。

理解页错误非常重要。程序访问的页不在物理内存时会触发缺页异常,操作系统可能从磁盘装入页面、更新页表并恢复执行。如果内存压力过高,页面频繁在内存和磁盘之间来回交换,就会出现抖动,表现为 CPU 忙于内核态和 I/O,业务吞吐反而下降。这也是为什么线上高延迟问题不应只看 CPU,还要结合 freevmstattop/proc/<pid>/status 以及 RSS、VIRT、页错误等指标一起判断。

栈和堆的区别在工程里也很实用。栈上对象通常生命周期短、分配释放快,但空间有限;堆更灵活,适合动态对象,却也更容易带来碎片、泄漏和分配开销。很多语言运行时都会在堆管理上做大量优化,例如缓存小对象、批量申请内存、延迟释放等。即使使用了 GC 语言,理解“对象为什么会逃逸到堆上”仍然对性能分析有帮助。

缓存置换算法是另一个常见追问。FIFO、LRU、LFU 不是让你机械背结论,而是要求你理解不同访问模式下的淘汰策略。系统设计里的缓存层也是同样的问题:热点数据是否稳定、扫描型流量是否会污染缓存、淘汰成本是否可接受。如果你明白操作系统和缓存系统都在解决“有限存储下保留高价值数据”的问题,很多设计取舍就容易串起来。

文件系统与 I/O 模型

文件系统是把磁盘组织成目录、文件、inode 和数据块的规则体系。对服务端工程来说,关键不是把 ext4 的每个结构都记住,而是理解“文件名”和“文件内容”并不是一回事:目录项负责名字到 inode 的映射,inode 负责元数据和数据块位置。于是一个文件的读取往往包含路径解析、权限校验、inode 查找、页缓存命中或磁盘读取等多个步骤。

“执行 ls 时系统里发生了什么”是很典型的综合题。用户态程序触发系统调用,内核读取目录项和 inode,可能命中页缓存,也可能发起磁盘 I/O,然后把结果返回给用户态格式化输出。这个过程提醒我们:很多看似简单的命令都跨越了用户态、内核态、页缓存和文件系统层。系统设计里讨论日志系统、对象存储网关、搜索索引持久化时,理解这条链路会更扎实。

I/O 模型则直接影响服务并发能力。阻塞 I/O 易于编写,但一个线程在读写期间可能长期等待;非阻塞 I/O 能减少无效等待,但需要事件通知机制;I/O 多路复用通过 selectpollepoll 等机制让一个线程监听多个描述符,是高并发网络服务常见基础。它的核心价值不是“复用某个神秘对象”,而是让线程只在事件就绪时处理真正有数据的连接。

select 的问题在于需要在用户态和内核态之间反复拷贝描述符集合、遍历所有待监听句柄,而且可监听数量有限。epoll 则把关注的文件描述符注册到内核中,等待阶段只返回就绪事件,避免每轮全量扫描。这也是为什么现代 Linux 网络服务器大量采用 epoll,再在用户态配合线程池、协程池或 reactor 模型组织业务执行。

文件 I/O 与网络 I/O 在工程上常常联系在一起。日志写盘、WAL 刷新、快照生成、消息落盘、连接读写都属于 I/O 密集场景。判断瓶颈时,除了看“磁盘快不快”“网卡带宽够不够”,还要看同步写还是异步写、是否命中页缓存、系统调用频率是否过高、是否存在短小随机 I/O 放大问题。

并发同步与死锁

共享状态一旦进入多线程或多进程环境,同步就不可回避。互斥锁保证同一时刻只有一个执行单元进入临界区;读写锁适合读多写少;条件变量负责等待某个条件成立;信号量可用于限制并发度;原子操作适合短小、无阻塞的共享状态更新。系统设计里选择同步原语,不应只按“谁快”,而应看共享数据范围、竞争频率、临界区长度和故障恢复方式。

进程间同步与线程间同步也要区分。线程共享地址空间,天然能通过锁保护共享内存;进程则需要借助共享内存、管道、消息队列、Unix 域套接字等 IPC 机制,再额外配合同步手段。很多工程师在线上排查问题时会混淆“通信”和“同步”两个概念:能把数据送过去,不代表并发访问就是安全的。

死锁的四个必要条件是互斥、占有且等待、不可抢占和循环等待。理解这四个条件的意义在于,你可以从中推导治理思路:统一加锁顺序、缩小临界区、避免锁内阻塞调用、在必要时引入超时与回滚机制。系统设计中谈分布式锁时,这套思路同样适用。只不过范围从单机内存对象扩展成跨节点资源,代价和失败模式更重。

线上死锁通常不只表现为“程序卡住”。更常见的信号是请求大量堆积、少数线程长期占锁、CPU 不高但延迟飙升。此时需要结合线程栈、锁等待、pstackgdbjstack 或语言运行时导出的 goroutine dump 去定位。理解同步原语,是为了更快地把“线程很多”“连接很多”还原成真正的阻塞因果链。

还要警惕“sleep 不是同步”。在线程里加 sleep 常常只是把竞争窗口随机化,不能替代锁、条件变量和显式事件通知。类似地,忙等虽然能降低某些极端低延迟场景中的唤醒成本,但如果无节制使用,会把 CPU 烧在空转上。工程实践里,正确的同步目标从来不是“代码看起来能跑”,而是“语义明确、竞争可控、故障可恢复”。

工程场景中的操作系统知识

操作系统知识在系统设计中最常见的价值,是帮助你解释现象和做容量边界判断。比如一个网关服务为什么更适合事件驱动模型?因为它面对的是大量连接和较长 I/O 等待,线程一对一会把资源浪费在切换和栈空间上。再比如一个任务计算服务为什么可以接受多进程或多线程池?因为它更关注 CPU 利用率、故障隔离和执行队列控制。

当线上服务出现“CPU 打满、延迟升高、内存上涨、磁盘繁忙、load 高但吞吐低”等问题时,操作系统概念就是第一层解释框架。CPU 高要区分是用户态计算、锁竞争还是系统调用放大;内存高要区分是业务对象变多、缓存积压、页缓存增长还是泄漏;磁盘忙要区分是顺序写、随机读、同步刷盘还是日志风暴;连接多则要进一步判断是正常 keep-alive、半连接堆积还是 TIME_WAIT、CLOSE_WAIT 异常。

系统设计面试也喜欢把这些概念放进开放题里追问。例如:

  • 为什么高并发服务器普遍采用非阻塞 I/O 和事件循环?
  • 为什么 fork 后立刻 exec 成本不高?
  • 为什么僵尸进程会耗尽 PID 资源而孤儿进程通常不是问题?
  • 为什么数据库刷盘、消息落盘和日志刷盘都离不开页缓存、同步策略和文件系统语义?

如果你能把这些问题讲成“操作系统如何影响服务行为”,而不是背一堆术语,本章的目标就达到了。

本章小结

操作系统知识在系统设计中的作用,不是让你写内核,而是让你更准确地理解程序如何被调度、内存如何被管理、I/O 为什么会成为瓶颈,以及并发问题为什么会以某种形式暴露出来。

这一章最值得保留的判断框架有四个:

  • 并发模型是隔离性、切换成本和通信方式的平衡。
  • 内存问题不仅是“占用高”,更是页错误、回收、换页和对象生命周期的问题。
  • 文件系统和 I/O 模型决定了服务端程序在高并发下的扩展边界。
  • 同步原语和死锁治理是工程可靠性的基础,而不是语法细节。

把这些概念和后续的网络、语言运行时、缓存与消息系统结合起来,很多线上现象都会更容易解释。

本章面试题与追问

  1. 进程、线程和协程的本质区别是什么? 追问:如果要做高并发网络服务,你会优先考虑哪种并发模型,为什么?

  2. 为什么说线程共享内存既是优势也是风险? 追问:如果一个服务改成多进程模型,哪些通信和同步问题会随之变化?

  3. fork 为什么通常比直觉中更快? 追问:写时复制解决了什么问题,又会在什么场景下真正触发拷贝成本?

  4. 僵尸进程和孤儿进程分别是什么? 追问:如果线上出现大量僵尸进程,你首先怀疑哪类代码路径?

  5. 虚拟内存的价值是什么? 追问:缺页异常、TLB 和交换分区分别会怎样影响服务性能?

  6. 栈和堆的区别在工程上意味着什么? 追问:为什么有些程序内存看起来很多,但真正的业务对象不一定很多?

  7. selectepoll 的核心区别是什么? 追问:为什么事件驱动模型在高连接数场景下更有优势?

  8. 什么是 I/O 多路复用? 追问:它解决的是“并发数”问题、“吞吐量”问题,还是“线程利用率”问题?

  9. 死锁产生的四个必要条件是什么? 追问:在线程很多、CPU 不高但请求卡住的场景里,你会如何判断是否存在死锁?

  10. 为什么系统设计工程师也需要理解文件系统和页缓存? 追问:数据库刷盘、日志写入和消息持久化为什么都离不开这些基础知识?

第 18 章 计算机网络实践

系统设计的绝大多数系统都建立在网络通信之上。请求为什么会超时、连接为什么会堆积、重试为什么会放大故障、为什么有的场景选 TCP 有的场景选 UDP,本质上都离不开网络基础。本章不追求把协议细节讲成教材,而是聚焦系统设计工程师最常用的网络判断框架。

网络分层与通信基础

网络分层的意义在于把复杂通信拆成职责明确的层次。无论是常见的 OSI 七层模型,还是工程上更常用的 TCP/IP 分层,真正重要的都不是把每一层背下来,而是理解“链路层负责本地传输、网络层负责寻址与转发、传输层负责端到端通信、应用层负责具体语义”。只要这个边界清楚,排查问题时就能更快判断故障到底出在路由、连接、协议还是业务本身。

系统设计里常见的应用层协议很多,如 HTTP、HTTPS、RPC、DNS、SMTP、SSH、WebSocket 等。它们并不是孤立存在的,而是建立在传输层和网络层之上。因此,面试里问“浏览器输入一个 URL 后发生了什么”,本质上是在考你能否把域名解析、TCP 建连、TLS 握手、HTTP 请求、负载均衡、缓存命中和页面渲染串成一条链路。

理解请求路径的第一步,是把“名字”和“地址”区分开。用户输入的是域名,真正通信需要的是 IP 和端口;应用关心的是 URL、Header、Body 和状态码,内核和网卡关心的是报文、连接状态、队列和重传。系统设计中的很多中间层,例如代理、网关、CDN 和 Service Mesh,正是通过在不同层插入能力来实现路由、缓存、安全和治理。

还要注意,一个应用请求通常会穿过多级网络设备和软件组件:本机协议栈、出口 NAT、四层或七层负载均衡、反向代理、应用网关、服务实例。延迟和故障可能出现在其中任何一段。所以讨论网络性能时,不能只盯着“服务端代码是不是慢”,还要考虑解析、建连、加密、拥塞、重试和中间层转发开销。

TCP、UDP 与连接管理

TCP 是面向连接、可靠、按序的字节流协议;UDP 是无连接、尽力而为的数据报协议。这个区别几乎所有人都会背,但系统设计更关心“为什么这里必须要连接语义”“为什么这里宁可接受丢包也不愿接受高延迟”。例如交易请求、数据库连接、控制平面通信更适合 TCP;语音视频、实时游戏、部分监控或探测流量则更可能偏向 UDP。

TCP 的可靠性来自一整套机制:三次握手建立连接、序列号保证顺序、确认与重传保证交付、滑动窗口实现流量控制、拥塞控制约束发送速率。面试中经常被问到“为什么是三次握手不是两次”“为什么挥手通常是四次”“TIME_WAIT 有什么作用”。这些问题的核心都在于:连接状态不是一句“建好了”就结束,而是双方都要对序列空间和关闭状态达成一致,避免旧报文污染新连接。

TIME_WAIT 经常被误解为“系统有问题”。实际上它是主动关闭一方的正常状态,用于等待旧报文自然消失并确保最后一个 ACK 有重传机会。真正值得警惕的是 CLOSE_WAIT 过多,因为这通常意味着对端已经关闭连接,而本地应用迟迟没有执行关闭逻辑。线上排查时,TIME_WAIT 大量堆积先看连接模型和短连接风暴,CLOSE_WAIT 大量堆积则优先排查代码是否存在连接泄漏。

TCP 还有几个系统设计里特别常见的概念。粘包不是 TCP “把消息粘住了”,而是因为 TCP 只提供字节流,不保留应用消息边界,所以应用层必须靠长度字段、分隔符或固定协议头自行拆包。KeepAlive 负责探测连接是否存活,但它不等于应用层心跳,因为内核看到连接活着,不代表业务线程、依赖服务或上游逻辑一定可用。真正严肃的分布式系统通常会同时使用 TCP 连接保活和业务语义心跳。

UDP 的价值则在于低开销和更少的连接约束。它适合广播、DNS 查询、实时媒体和部分自定义协议。如果业务既想要 UDP 的低时延,又想要可靠性,就只能在应用层自己补上序号、确认、重传、流控甚至拥塞控制,这也是 QUIC 这类协议的重要思路:保留 UDP 的部署灵活性,同时在更高层重新实现可靠连接语义。

HTTP、HTTPS 与应用层协议

HTTP 是最常见的应用层协议,本质是请求-响应语义在网络中的标准表达。理解 HTTP,关键不是死记各种 Header,而是理解三个层面:方法语义、连接复用、缓存与状态。GET、POST、PUT、DELETE 等方法不仅仅是语法差异,更关系到幂等性、可缓存性以及重试时是否安全。系统设计里谈 API 网关、重试策略和幂等保障时,这些语义非常重要。

HTTP/1.1 引入持久连接后,不再要求每个请求都重新建立 TCP 连接,明显减少了握手成本。HTTP/2 则进一步提供多路复用、头部压缩和更高效的连接利用率。HTTP/3 基于 QUIC,把传输层的部分可靠性和拥塞控制能力上移,减少队头阻塞问题。理解这些演进,能帮助你解释为什么同样的服务在协议升级后会表现出不同的延迟特征。

HTTPS 则是在 HTTP 之下增加 TLS,加密数据并提供服务端身份认证。对系统设计来说,HTTPS 不只是“更安全”,它还意味着额外的握手、证书管理、加解密开销和终止点选择。很多系统会在 CDN、负载均衡或 API Gateway 层终止 TLS,再用内网协议转发;也有更严格的场景会选择端到端加密。这背后是安全性、性能和运维复杂度之间的平衡。

HTTP keep-alive 和 TCP keepalive 也经常被混淆。前者强调连接复用,希望多个请求共享同一条连接,减少重复建连和握手开销;后者强调存活探测,希望确认底层连接没有悄悄失效。把这两个概念分清,你才能解释为什么客户端连接池、服务端空闲连接回收、代理层超时和心跳策略必须协同设计。

除了 HTTP,系统内部还大量使用 RPC 协议。RPC 更强调方法调用和服务契约,通常配合二进制序列化获得更紧凑的编码和更高的吞吐;REST 更强调资源表达和通用语义,更适合对外开放接口。系统设计中选 RPC 还是 REST,关键看调用对象、演进方式、调试便利性和生态要求,而不是简单争论“哪种更高级”。

DNS、CDN 与代理

DNS 负责把域名解析成 IP 地址,是用户请求链路里最早的关键环节之一。系统设计里讨论 DNS,不应只停留在“把域名变成 IP”。更重要的是理解它对可用性和流量调度的影响:本地缓存、递归解析、权威 DNS、TTL、就近解析、故障切换都会影响最终用户实际访问到哪里。

CDN 本质上是把静态资源、部分动态内容甚至边缘计算能力尽量前移,缩短用户与内容之间的物理和网络距离。它能降低源站带宽压力、减少跨地域时延、吸收突发流量,并承担缓存、TLS 终止、访问控制等职责。系统设计里如果面对图片、视频、下载包、热点页面或公共 API 分发,CDN 几乎都是必须考虑的层。

代理分为正向代理和反向代理。正向代理更偏向客户端侧的出口控制与访问转发;反向代理则位于服务端入口,承担负载均衡、缓存、TLS 终止、限流、鉴权、灰度发布等职责。面试里问代理,很多时候其实是在问网关和流量治理。你需要说明代理并不是“多了一跳而已”,而是通过这层插入统一控制面和可观测能力。

DNS、CDN 和代理组合起来,构成了用户访问大规模互联网服务的第一道基础设施。一次请求可能先经过本地 DNS 缓存,命中 CDN 边缘节点;未命中时再回源到反向代理;代理再把流量分配到后端服务集群。理解这条路径,有助于分析“为什么用户明明访问同一个域名,却落到不同机房”“为什么回源带宽飙升”“为什么某些地区访问特别慢”等问题。

常见网络问题排查

网络排查最怕一上来就猜。更可靠的方法是沿请求路径逐层缩小范围:名字是否能解析、路由是否可达、端口是否监听、连接是否建立、TLS 是否成功、应用是否返回、返回是否被代理或缓存改写。把问题拆开后,工具才有意义。

最常用的基础工具包括:

  • ping:快速验证 ICMP 可达性,但不能证明某个业务端口一定正常。
  • traceroute:定位路径中的跳点和潜在路由异常。
  • netstatss:查看监听端口、连接状态和收发队列。
  • lsof -i:确认端口被哪个进程占用。
  • tcpdump:抓包确认请求是否真的发出、是否有重传、RST、握手失败等问题。
  • curlwgetnc:从应用层或传输层主动构造请求,缩短定位路径。

几类高频现象特别值得掌握。第一,连接超时不一定是服务没启动,也可能是 DNS、路由、防火墙、安全组、监听地址或 SYN 队列问题。第二,请求慢不一定是网络慢,可能是 TCP 重传、TLS 握手过多、连接池复用差,或者代理层重试放大。第三,服务端连接数高不一定危险,但如果伴随大量 SYN_RECV、TIME_WAIT、CLOSE_WAIT,就要进一步区分是攻击、流量模型还是代码资源泄漏。

系统设计里,网络问题经常和容量、超时、重试、幂等耦合在一起。一次小规模丢包如果触发客户端无节制重试,就可能从局部波动演变成系统性故障。理解网络,不只是为了抓包,更是为了设计更稳健的重试策略、超时预算、连接池策略和服务降级机制。

本章小结

网络基础不是系统设计的附属知识,而是所有分布式系统的共同地基。只要系统跨机器运行,就一定会面对寻址、连接、超时、重传、缓存、代理和流量治理问题。

这一章最重要的结论是:

  • 分层模型帮助你把复杂问题定位到正确层次,而不是笼统地说“网络有问题”。
  • TCP 与 UDP 的选择,本质上是可靠性、时延、状态管理和复杂度的取舍。
  • HTTP、HTTPS 与 RPC 的差异,直接影响 API 设计、连接复用和系统开销。
  • DNS、CDN 与代理决定了请求如何被分发、缓存和治理。
  • 排查网络问题时,要沿链路逐层验证,不要把所有异常都归因于应用代码。

本章面试题与追问

  1. OSI 七层模型和 TCP/IP 分层模型在工程上最大的意义是什么? 追问:排查一次接口超时时,你会优先从哪几层入手?

  2. TCP 和 UDP 的本质区别是什么? 追问:为什么实时音视频常用 UDP,而交易请求通常更适合 TCP?

  3. 为什么 TCP 建连通常是三次握手? 追问:如果只有两次,会在哪些场景下带来歧义或风险?

  4. TIME_WAIT 和 CLOSE_WAIT 分别说明了什么? 追问:如果机器上 CLOSE_WAIT 特别多,你会优先排查哪里?

  5. 什么是 TCP 粘包? 追问:应用层一般通过哪些方式恢复消息边界?

  6. HTTP keep-alive 和 TCP keepalive 有什么区别? 追问:为什么应用层往往还要额外设计心跳机制?

  7. HTTPS 相比 HTTP 多了哪些核心能力和代价? 追问:TLS 终止放在 CDN、负载均衡还是应用侧,各自有什么权衡?

  8. RPC 和 REST 的差异是什么? 追问:内部高频服务调用和对外开放 API,为什么常常会选不同风格?

  9. DNS、CDN 和反向代理分别解决什么问题? 追问:如果某地区用户访问明显变慢,你会如何沿这三层排查?

  10. 遇到“能 ping 通但接口连不上”的问题,你会怎么定位? 追问:哪些工具能帮助你区分是端口监听、握手失败还是应用层超时?

第 19 章 Bash 与 Shell 实用

Shell 不是系统设计主线的一部分,但它是工程师处理机器、日志和批量任务时最常用的放大器。真正有价值的 Shell 能力,并不是背命令大全,而是把零散命令组织成可靠的排查链路和自动化片段。本章只保留系统设计读者最常用的命令组织、文本处理和日志排查能力。

Shell 基础与命令组织

Shell 的价值在于把命令、文件、环境变量和退出码组织成一条可重复执行的流程。相较于图形界面,Shell 更适合线上故障排查、批量处理和轻量自动化,因为它天然能够组合已有工具,而不必为每个小任务都写完整程序。

系统设计工程师最常用的不是“所有命令”,而是少数高频操作:查帮助、找路径、看目录、数文件、比较差异、跟踪日志、查找目标文件、打包压缩和远程复制。比如 maninfo 负责看手册,whichwhereis 帮助确认可执行文件位置,find 负责按条件搜索文件,tail -f 适合实时跟日志,diff 用于比较配置差异,tar 负责打包和解包。

命令组织里最实用的是退出码语义。cmd1 && cmd2 表示只有前一个命令成功才继续;cmd1 || cmd2 表示前一个命令失败才执行后者。这种基于退出状态的组织方式,比把命令机械串起来更安全。很多一次性排查脚本看似简单,真正避免误操作的关键反而是正确使用条件执行和显式失败。

环境变量也是 Shell 工作流的一部分。系统级环境通常由 /etc/profile/etc/profile.d 管理,用户级环境由 ~/.bash_profile~/.profile~/.bashrc 等文件控制。理解这些加载路径,能帮助你解释“为什么在终端里能运行,服务里却找不到命令”“为什么某个库路径在当前会话生效、重开后又失效”。

对系统设计读者来说,Shell 最重要的心智模型是:命令不是孤立按钮,而是可以通过参数、退出码、重定向和环境变量编排的小组件。只要这个模型建立起来,后面的文本处理和自动化就顺了。

文本处理与管道

管道是 Shell 最强大的抽象之一。它把前一个命令的标准输出直接作为后一个命令的标准输入,让你可以把过滤、切分、统计、排序等动作串成数据流处理链。工程上处理日志、CSV、配置文件和监控输出时,管道往往比临时写脚本更快。

文本处理的第一原则是先缩小数据范围,再做昂贵分析。比如先用 grep 过滤出某类请求,再用 awk 取出关心的列,接着用 sortuniq -c 做聚合统计,最后再排序找出热点。一类典型命令是:

cat access.csv | grep "keyword" | awk -F ',' '{print $2,$5,$6}' | sort | uniq -c | sort -rk 1

它的意义不在于语法花哨,而在于体现了常见排查顺序:筛选样本、抽取字段、聚合去重、按频次排序。线上日志分析时,这种“逐步缩小”的方式比一次性写很复杂的正则更稳妥。

还要注意标准输入、标准输出和标准错误的分工。很多排查命令之所以可以无缝组合,就是因为它们默认遵守这一约定。理解这一点后,你会更容易写出能被其他命令接住的输出,而不是只能在终端上肉眼阅读的杂乱信息。

文本处理并不意味着所有事都该交给 Shell。数据量过大、逻辑过于复杂或者需要稳定复用时,应及时切换到 Python、Go 等更适合编排的语言。优秀的工程习惯不是“全都用 Shell”,而是知道它在轻量链式处理场景下最有性价比。

grep、sed、awk 实战

grepsedawk 是 Shell 文本处理的三件套。grep 负责过滤匹配行,sed 负责流式替换和简单编辑,awk 负责按字段切分、计算和重组输出。系统设计工程师不需要追求花式写法,但需要熟练掌握它们在排查链路里的典型角色。

grep 最适合做快速收敛。比如查看某关键词在日志中出现次数、找出某个错误码相关请求、排除噪声样本。配合 -v 可以反向过滤,配合 -n 可以带行号,配合 -c 可以快速计数。排查大日志时,先 grep 再做后续处理,通常能显著减少认知负担。

sed 更适合做结构化替换和批量清洗,例如把某类前缀去掉、统一分隔符、抽取固定模式、删除无关行。它的优势在于保持流式处理,不必把整个文件读入内存。虽然现代工程里很多人更习惯用 Python 处理文本,但在一次性修正、批量替换和流水线里,sed 仍然很高效。

awk 则是日志分析中的利器。它天然按分隔符切字段,能做条件判断、聚合和简单计算。例如根据逗号分割提取第 2、第 5、第 6 列,再配合排序统计错误来源;或者从 Nginx 日志里抽取状态码、耗时和 URI,快速定位最慢接口。你可以把它理解成“适合小型结构化文本的即时查询语言”。

实战中常见的模式是:

  • grep 做第一层过滤。
  • awk 抽字段、做统计。
  • sed 做格式清洗或替换。
  • sortuniqhead 输出最终结论。

这类链式处理和系统设计题中的“数据流分阶段处理”其实很像。先做粗筛,再做聚合,最后输出热点。掌握它们,能让你在面对海量日志时更有节奏感。

日志排查与自动化脚本

线上排查时,Shell 的最大价值是把“人肉重复动作”变成稳定脚本。日志查看是最典型场景。tail -f 适合追实时新增日志,grep 适合筛错误关键词,lsofpstopfreedfiostatsar 则帮助你把应用日志和系统资源状态关联起来。

常见的系统信息命令包括:uname -a 查看内核版本,cat /proc/cpuinfocat /proc/meminfo 查看硬件与内存信息,ulimit -a 查看进程资源限制,ipcs 查看 IPC 资源,nvidia-smi 查看 GPU 情况。系统设计里讨论容量和故障时,很多判断必须落到这些基础信息上。

Shell 对网络排查同样有效。netstatss 可以看端口与连接状态,lsof -i:port 可以定位谁占用了端口,pingtraceroute 用于连通性验证,scpssh 则是远程运维的基本工具。nc 既能充当简单客户端,也能临时监听端口,非常适合验证某条网络路径是否真的能通。

这里还需要保留两个很实用的基准思路。第一,用 dd 配合 /dev/zero/dev/null 粗测磁盘读写性能,帮助区分磁盘写慢、读慢还是读写竞争。第二,用 nc 配合 time 临时构造网络数据流,快速估算两台机器之间的吞吐上限。它们都不替代专业压测工具,但足够帮助你在故障现场先判断瓶颈大致落在哪一层。

自动化脚本的核心不是“写得复杂”,而是让结果稳定可复现。好的小脚本通常会包含清晰的输入参数、显式的失败处理、必要的输出说明,以及尽量少的副作用。只要能把重复检查、日志采样、批量拉取和环境初始化收敛成几个可靠脚本,排障效率就会明显提升。

本章小结

Shell 对系统设计读者来说,是工程执行力的补足。它不负责设计架构,但负责让你更快地看见系统状态、清洗数据、定位热点并把重复动作自动化。

这一章最值得带走的要点是:

  • Shell 的核心能力是组合,不是命令背诵。
  • 管道让多个小工具可以像数据流处理系统一样协同工作。
  • grepsedawk 是日志排查和文本处理的高性价比工具。
  • 系统信息、网络状态和简单基准测试都可以通过 Shell 快速完成第一轮判断。

本章面试题与追问

  1. 为什么说 Shell 最大的价值是“组合已有工具”? 追问:相比单独敲命令,管道和退出码语义解决了什么工程问题?

  2. cmd1 && cmd2cmd1 || cmd2 分别适合什么场景? 追问:为什么自动化脚本里显式处理失败比“继续执行”更重要?

  3. 日志排查时为什么通常先过滤、再聚合、最后排序? 追问:如果原始日志很大,这样的顺序会带来什么收益?

  4. grepsedawk 三者在职责上有什么区别? 追问:如果你要从日志中抽取某几列并按频次统计,为什么 awk 往往更合适?

  5. 管道为什么适合处理日志和命令输出? 追问:标准输出和标准错误分离,对脚本编排有什么帮助?

  6. 你会用哪些命令快速查看系统 CPU、内存、磁盘和打开文件状态? 追问:这些信息和应用日志结合后,能帮助你判断哪些类型的问题?

  7. tail -fgrepsort | uniq -c 常常怎样配合使用? 追问:这种命令链为什么很像一个简化版的数据处理流水线?

  8. ddnc 为什么能帮助做粗粒度性能判断? 追问:它们适合拿来验证什么,不适合替代什么?

  9. 环境变量加载路径为什么值得工程师理解? 追问:为什么“终端里能执行、服务里不能执行”常常和环境加载顺序有关?

  10. 什么样的 Shell 脚本算是工程上可靠的小工具? 追问:如果脚本会被多人反复执行,你最先补上的保护措施是什么?

第 20 章 Python 实践

Python 在系统设计语境中的价值,通常不在核心高性能路径,而在于快速验证、自动化编排、数据处理和工程胶水。它开发效率高、生态丰富,适合把复杂系统周围的重复劳动和分析任务快速固化下来。本章不展开 Python 语言全貌,而是聚焦系统设计读者最常见的工程用途和性能边界。

Python 的工程用途

Python 最大的优势是“写得快、改得快、库多”。这使它非常适合做离线处理、运维脚本、数据清洗、实验原型、模型推理封装、测试工具以及跨语言系统之间的胶水层。在真实工程里,很多服务并不会把 Python 放在最核心的性能敏感模块,却会大量依赖它完成周边自动化和流程打通。

一个很典型的场景,是把 C++ 实现的视觉算法抽取成更易使用的 Python 接口。这个例子说明了 Python 的实际定位:不是替代底层高性能实现,而是提供更友好的调度层、实验层和业务集成层。当项目既想保留 C/C++ 性能,又希望让上层调用更灵活时,Python 往往是折中方案。

跨语言集成时要特别关注依赖和部署复杂度。比如通过 SWIG 为 C++ 库生成 Python 调用接口,如果第三方依赖默认是动态链接,最终部署就可能非常脆弱。这个案例强调将 OpenCV、ViSP 等依赖按静态方式编译,本质上是在提醒系统设计读者:工程交付不只是“代码能跑”,还包括依赖边界是否可控、部署环境是否稳定。

因此,评价 Python 是否适合某项任务,不应只看运行速度,还要综合考虑交付周期、生态支持、接口灵活性和可维护性。对很多自动化和分析类任务来说,Python 的高开发效率本身就是系统整体效率的一部分。

常用语法与标准库

系统设计读者使用 Python,通常不需要深入语言冷门特性,但需要掌握一套足够稳定的工程工具箱。最常见的就是基础数据结构、文件与路径处理、序列化、子进程调用、时间处理、日志、异常处理和并发标准库。

Python 的常见数据结构如 listdictsettuple 足以覆盖大量自动化脚本需求。pathlibosshutil 适合处理文件和目录;jsoncsvpickle 负责常见数据编解码;subprocess 负责和 Shell、系统命令集成;logging 提供结构清晰的日志输出;argparse 则让一次性脚本更容易演化成可复用工具。

掌握这些标准库的意义,在于减少“为了一个简单任务引入重型框架”的冲动。很多工程脚本之所以后期难维护,不是因为 Python 不够强,而是因为脚本从一开始就没有遵守基本的输入输出、异常处理和模块划分习惯。即使是临时工具,也应尽量做到参数明确、日志可读、错误可解释。

如果工作内容涉及算法验证或数据分析,NumPy、Pandas、Requests、Click、Typer 等生态也很常用。但在系统设计场景下,更重要的是理解它们解决的问题类型:是为了高效处理数组和表格,还是为了快速构造 HTTP 客户端与命令行工具。这样你在选型时会更有边界感。

自动化与数据处理

自动化是 Python 最稳定的主场之一。批量改文件、调用外部程序、解析日志、汇总指标、生成报告、封装内部平台接口,这些任务用 Python 实现往往既清晰又足够快。相比 Shell,Python 更适合处理稍复杂的控制流、异常分支和结构化数据;相比 Go 或 C++,它启动门槛更低,迭代速度更快。

数据处理也是 Python 的强项。常见优化思路很有代表性:先理解整体流程,再用 cProfile 找热点,然后考虑算法剪枝、矩阵运算替代循环、任务拆分并行、必要时下沉到 C/C++ 或 GPU。这个顺序非常重要,因为很多性能问题并不是语言本身造成的,而是算法路径、数据布局和无意义工作过多。

如果你需要快速处理一批日志或实验数据,Python 的典型工作流通常是:

  • 用标准库或 Pandas 读入结构化数据。
  • 先做过滤和字段抽取。
  • 再做聚合、统计和可视化。
  • 最后将结果输出成报告、文件或接口请求。

这种流程和系统设计里的离线链路很相似。它强调数据先被组织起来,再进入分析与动作,而不是把所有逻辑写成难以复用的临时循环。

跨语言自动化也是 Python 非常常见的角色。通过 C 扩展、SWIG、ctypes 或 CFFI,可以把高性能组件暴露给 Python 调用。这样做的前提是边界要清楚:性能敏感逻辑放在底层,编排和实验放在 Python 层。只要边界混乱,性能问题和部署问题就会一起放大。

调试、性能与常见坑

Python 性能慢,首先不是因为“它解释执行”这么简单,而是因为动态类型、对象模型和运行时调度带来了额外开销。需要关注三个关键原因:动态类型需要在运行期做更多判断,CPython 的 GIL 会限制 CPU 密集型多线程并行,默认解释器没有通用 JIT。理解这三个因素,有助于你判断一个任务慢在语言本身、任务模型,还是算法路径。

调试性能时,最有价值的习惯不是凭感觉改代码,而是先做 profile。cProfilepstats、火焰图和调用图能帮助你确定热点函数;然后再考虑是否通过减少循环、改用向量化、预分配数据结构、批处理请求、减少对象创建等方式优化。如果热点仍然集中在少数重计算路径,再考虑多进程、Numba、Cython 或 C/C++ 重写。

GIL 是系统设计读者必须理解的经典追问。它意味着在 CPython 中,CPU 密集型任务即使开多个线程,也不能真正并行执行 Python 字节码;但 I/O 密集型任务在阻塞时会释放 GIL,因此多线程仍然有价值。换句话说,面对 CPU 密集任务,更应优先考虑多进程、原生扩展或把计算下沉到底层库;面对 I/O 密集任务,线程和异步模型则仍然有效。

多进程测试和 multiprocessing 示例,提醒的是一个工程边界:不要因为 Python 有线程 API,就默认它适合所有并发场景。正确做法是先判断瓶颈类型,再选并发模型。除此之外,Python 常见坑还包括:

  • 误把临时脚本写成无法维护的单文件泥团。
  • 忽视依赖打包,导致环境一换就不能运行。
  • 在大数据量上频繁做 Python 层循环而不做批处理。
  • 过早并发,却没有先完成单线程 profile 和算法剪枝。

本章小结

Python 在系统设计中的定位非常清晰:它是高效率的工程工具语言,适合自动化、数据处理、原型验证和跨语言整合,但不应被默认放到最苛刻的高性能核心路径里。

这一章最重要的结论有:

  • Python 的最大优势是开发效率和生态,不是原始性能。
  • 自动化和数据处理是 Python 最稳定、最有性价比的战场。
  • 性能优化要先 profile,再判断是算法、运行时还是语言边界问题。
  • GIL 决定了 CPU 密集和 I/O 密集任务在并发模型上的不同选择。

本章面试题与追问

  1. Python 在系统设计工程里最适合承担哪些角色? 追问:为什么很多团队会用 Python 做自动化和数据处理,却不用它承载最核心的高性能路径?

  2. 动态类型为什么会影响 Python 性能? 追问:这种成本换来了哪些开发效率上的收益?

  3. GIL 到底限制了什么? 追问:为什么 I/O 密集型任务仍然可以从 Python 多线程中受益?

  4. 面对 CPU 密集型 Python 程序,你会优先怎么优化? 追问:为什么通常应先做 profile,而不是直接上多进程或重写语言?

  5. cProfile 这类工具在性能优化中的作用是什么? 追问:如果 profile 显示热点在少数几个循环里,你会有哪些典型手段?

  6. Python 和 C/C++ 混合编程常见于哪些场景? 追问:如果要通过 SWIG 或其他方式暴露底层能力,为什么部署依赖管理很关键?

  7. 自动化脚本为什么常用 Python 而不是完全依赖 Shell? 追问:什么情况下你会认为任务复杂度已经超过了 Shell 的舒适区?

  8. 多进程和多线程在 Python 里该如何取舍? 追问:如果任务同时包含网络 I/O 和部分 CPU 计算,你会如何拆分?

  9. 为什么“先理解算法流程再谈并行”是更稳妥的优化顺序? 追问:如果大量时间花在无意义计算上,并发为什么可能只是放大浪费?

  10. Python 工程里最常见的非性能类坑有哪些? 追问:如果一个脚本会长期存在并被多人使用,你会优先补哪些工程化能力?

第 21 章 C++ 实践

C++ 在系统设计语境中的价值,主要体现在高性能基础组件、底层库、客户端 SDK、算法与系统软件等领域。系统设计工程师不一定每天写 C++,但理解它的对象模型、内存语义、STL 和常见性能陷阱,能帮助你更好地阅读底层组件实现、分析性能问题并和基础设施团队高效协作。

内存模型与对象生命周期

C++ 的核心工程价值之一,是把对象生命周期和资源管理显式暴露给开发者。与自动垃圾回收语言不同,C++ 要求你明确知道对象在哪分配、何时构造、何时析构、谁负责释放,以及拷贝和移动各自意味着什么。很多性能问题和稳定性问题,都来自这些边界没有被讲清楚。

对象可以分配在栈上,也可以分配在堆上。栈对象生命周期随作用域结束自动结束,开销小、行为明确;堆对象更灵活,但需要显式释放或依赖智能指针托管。new/deletemalloc/free 的差异也必须分清:前者是 C++ 语义,负责调用构造和析构;后者只是分配和释放原始内存。把两者混用,往往会埋下未定义行为。

深拷贝、浅拷贝、移动语义和资源所有权是理解 C++ 生命周期的关键。带有堆内存、文件句柄、锁或网络连接的对象,如果只做默认浅拷贝,就可能导致双重释放或悬垂引用。C++11 引入移动语义,本质上是让资源所有权可以转移而不是重复复制,从而减少不必要的分配与释放成本。系统设计里凡是高性能对象池、缓冲区管理、连接对象管理,都离不开这一层语义。

初始化列表、const、引用成员、explicit=default=delete 等语言特性,也是工程代码里常见的边界声明工具。它们的重要性不在于语法细节,而在于明确表达“这个对象是否可拷贝”“是否允许隐式转换”“哪些成员必须在构造时就初始化”。好的 C++ 代码会主动用这些机制降低误用空间。

多态与对象模型同样要理解到工程层。虚函数带来运行期动态绑定和更灵活的扩展点,但也引入虚表、额外间接调用和更复杂的继承布局。虚析构函数为什么重要、RTTI 依赖什么、菱形继承为什么麻烦,这些问题看似偏语言,其实都与大型代码库的可维护性和二进制兼容有关。

STL 容器与算法

STL 的价值不是“提供了很多容器”,而是为常见数据访问模式提供了成熟的数据结构和算法抽象。系统设计工程师使用 STL 时,重点不是背出 17 种容器,而是知道在不同读写模型下该如何选型,以及这些容器在时间复杂度、内存布局和迭代器稳定性上的差异。

vector 连续存储、缓存友好,适合高频遍历和尾部追加,是最常见的默认选择。list 插入删除灵活,但局部性差,现代工程里使用场景远少于初学者想象。deque 适合双端操作。map 基于有序树结构,适合需要范围查询和有序遍历的场景;unordered_map 基于哈希,均摊查找更快,但内存开销、最坏情况和迭代顺序都不同。

优先队列、集合、字符串容器和算法库同样很常用。priority_queue 适合调度、TopK、定时任务等场景;setunordered_set 适合去重与成员判断;sortlower_boundaccumulate 这类算法则帮助你把“数据结构 + 操作”写得更明确。系统设计里的很多基础模块,例如定时器、缓存淘汰、路由表、倒排索引局部结构,都能映射到这些容器选择上。

容器失效和线程安全是面试高频坑。向 vector 追加元素可能触发扩容,导致原有指针和迭代器失效;删除 mapunordered_maplist 中元素时,各容器失效规则也不同。STL 容器通常不默认提供并发安全保障,因此在多线程环境下需要外部同步。理解这些边界,比单纯记住复杂度表更重要,因为线上 bug 很多正出在“以为还能继续用那个指针”。

STL 的内存管理也值得关注。容器增长、缩容、释放内存和分配器策略会直接影响 RSS 和性能抖动。很多人误以为容器清空后内存一定回到操作系统,实际上并不总是如此。系统设计里如果某个模块频繁创建大对象容器、突发扩容又不及时回收,内存峰值问题会很明显。

字符串与字符处理

字符串在系统里无处不在:协议解析、日志格式化、配置读取、序列化、路径处理、键名拼接。C++ 中处理字符串时,性能问题和安全问题都很容易出现,因此这是一个很值得单独关注的主题。

首先要区分 C 风格字符串和 std::string。前者以空字符结尾,容易产生越界、未终止和缓冲区覆盖问题;后者封装了长度和存储,更适合现代工程。即便如此,std::string 也不是完全没有成本:频繁拷贝、反复拼接、隐式临时对象构造都会带来分配放大。对性能敏感路径,应尽量减少无意义复制,优先使用引用、视图或预分配策略。

字符处理里常见的工程细节包括编码、长度与容量、格式化和解析。日志和协议处理时,经常要面对 UTF-8 与字节长度不一致、路径或分隔符清洗、大小写转换、子串提取等问题。现代 C++ 代码更倾向于使用标准库提供的安全接口,而不是大量手写指针运算。

字符串问题还经常和接口边界绑在一起。例如 extern "C" 处理跨语言接口时,需要格外注意 ABI 和字符串内存所有权;网络协议解析时则必须明确消息边界和异常输入处理。很多看似“只是个字符串”的 bug,最终都演变成崩溃、乱码、日志污染或安全风险。

性能优化与常见陷阱

C++ 的性能优势并不是自动获得的。它给了开发者更多控制权,也意味着更多犯错空间。真正的优化应该从测量开始。常用的 timetopperfgprofvalgrindgdb 等工具,构成了性能与稳定性分析的基础工具箱。只有先知道时间和内存消耗在哪,优化才不会变成拍脑袋。

常见性能问题通常集中在几类:

  • 频繁动态分配与释放导致碎片和锁竞争。
  • 不必要的深拷贝和字符串拼接导致额外内存流量。
  • 容器选型不当,导致复杂度或局部性很差。
  • 虚函数、异常、RTTI 等高级特性在热路径被滥用。
  • 锁粒度过粗、共享状态过多,导致并发扩展性差。

与此同时,C++ 也有一批经典稳定性陷阱:悬垂指针、重复释放、越界访问、未初始化变量、对象切片、析构顺序错误、链接符号缺失、ABI 不兼容等。编译、链接、core dump、undefined symbolpkg-configfind_packageldd 这些内容提醒我们,很多 C++ 问题并不出在业务逻辑,而是出在构建、链接和运行时环境边界。

对系统设计读者来说,更重要的是建立一个现实判断:C++ 很适合写性能敏感、资源受控、生命周期明确的模块,但它要求更严格的工程纪律。RAII、智能指针、明确所有权、少用原始裸指针、谨慎设计继承层次、避免过度模板炫技,这些习惯比零散记忆语法更有价值。

本章小结

C++ 在系统设计体系中的角色,是为高性能与底层控制力提供支撑。理解它的对象生命周期、资源管理和容器行为,能帮助你更稳地处理基础组件、性能优化和疑难问题定位。

这一章最值得保留的结论是:

  • C++ 的强大建立在显式生命周期和资源所有权管理之上。
  • STL 选型应围绕访问模式、复杂度、内存布局和失效规则展开。
  • 字符串和字符处理既是性能问题,也是安全问题。
  • 性能优化必须依赖测量工具,很多问题最终落在内存、链接和对象边界上。

本章面试题与追问

  1. new/deletemalloc/free 的本质区别是什么? 追问:为什么在 C++ 对象语义里混用它们会很危险?

  2. 什么是浅拷贝,什么是深拷贝? 追问:如果一个对象内部持有堆内存或文件句柄,默认拷贝会带来哪些风险?

  3. 移动语义解决了什么问题? 追问:在什么场景下它比传统拷贝更能体现工程价值?

  4. 为什么初始化列表在 C++ 中很重要? 追问:哪些成员必须通过初始化列表初始化,为什么?

  5. vectorlistmapunordered_map 应该如何取舍? 追问:如果既关注性能又关注遍历顺序或范围查询,你会怎么选?

  6. 什么是迭代器失效? 追问:为什么这类问题在线上经常表现成偶发崩溃而不是稳定复现?

  7. 为什么虚析构函数在多态基类中通常必不可少? 追问:如果没有虚析构,删除派生类对象时会出现什么问题?

  8. C++ 字符串处理最常见的性能坑有哪些? 追问:为什么频繁拼接和隐式拷贝会在热路径上带来明显代价?

  9. 你会如何定位一个 C++ 程序的性能热点或内存问题? 追问:perfvalgrindgdb 这类工具各自更适合哪类问题?

  10. 为什么很多 C++ 问题最终落在构建、链接和运行环境边界上? 追问:如果出现 undefined symbol,你会从哪些方向排查?

第 22 章 Go 语言实践

Go 在系统设计领域很常见,因为它在工程复杂度、并发表达和部署体验之间取得了不错平衡。很多网关、基础服务、平台工具和云原生组件都选择 Go,不是因为它在所有维度都最强,而是因为它提供了足够高的性能、清晰的工程约束和相对低的团队协作成本。本章聚焦系统设计读者最值得掌握的 Go 语言核心设计与并发实践。

Go 的核心设计

Go 的设计目标很明确:减少语言复杂度,鼓励组合优于继承,让并发成为一等公民,同时保持单个二进制可部署、工具链统一和工程风格收敛。与 C++ 相比,Go 把很多底层控制权交给运行时和垃圾回收器;与 Python 相比,它提供了更直接的并发原语和更稳定的交付体验。这也是它在服务端基础设施中非常受欢迎的原因。

Go 的常用类型体系并不复杂,但每一类都有明确的工程含义。数组是值类型,切片是对底层数组的轻量视图,map 负责哈希访问,struct 负责组合数据和行为,interface 负责抽象能力而不是强制继承层次。理解这些概念的重点不在语法,而在于“哪些赋值是深拷贝、哪些只是共享底层数据”“哪些对象可比较、哪些不能直接比较”。

切片和 map 是高频坑位。切片共享底层数组,传递和赋值虽然轻量,但可能引发底层数据联动变化;追加元素时如果触发扩容,又会让底层存储发生迁移。map 需要显式初始化,遍历顺序不稳定,适合表达集合和索引,但在并发读写下不能直接安全使用。很多线上问题都不是语言难点,而是没有把这些共享语义想清楚。

Go 还通过 defer、错误返回值和 panic/recover 建立了一套偏工程化的控制流风格。defer 适合资源回收,但在极端热路径里也有成本;错误值强调显式处理,适合分层传播;panic 则更适合表达真正不该发生的异常状态,而不是把普通业务失败全都当异常抛出。系统设计语境下,这种风格的价值在于让失败路径更容易被阅读和治理。

Goroutine 与 Channel

Goroutine 是 Go 最鲜明的工程特征之一。它是由 Go 运行时调度的轻量执行单元,相比操作系统线程拥有更小的初始栈和更低的切换成本,因此可以在单个进程中承载大量并发任务。理解 Goroutine 时,不应只停留在“它很轻”,而要进一步理解 G、P、M 调度模型、阻塞点和运行时如何降低线程级调度成本。

Go 的并发调度大体可理解为:Goroutine 是待执行任务,P 代表可运行上下文,M 对应内核线程。运行时通过本地运行队列、全局队列、工作窃取和网络轮询器,把大量 Goroutine 高效映射到少量线程上。这也是 Go 在高并发 I/O 场景下表现良好的基础。只不过它不是魔法,如果 Goroutine 在系统调用中长期阻塞、发生大量锁竞争或无节制创建,也依然会把系统拖慢。

Channel 则是 Go 对 CSP 模型的工程化实现,用于在 Goroutine 之间传递数据和同步事件。无缓冲 Channel 强调发送与接收同步配对;有缓冲 Channel 更像受控队列,可在一定程度上解耦生产者和消费者。使用 Channel 时,最重要的是先想清楚它表达的是数据流、完成信号、限流令牌,还是取消通知,而不是把它当成“任何并发都能解决的万能结构”。

关闭 Channel、读取已关闭 Channel、向已关闭 Channel 写数据等语义也必须搞清楚。关闭后仍可继续读取缓冲区中剩余数据,读取空的已关闭 Channel 会得到零值和 ok=false;向已关闭 Channel 写入则会 panic。很多 Goroutine 泄漏和死锁,都是因为生产者、消费者和关闭方的职责没有设计清楚。

系统设计里常见的 Go 并发模式包括:生产者-消费者、fan-out/fan-in、并发受限的 worker pool、超时控制、取消传播和批处理。它们背后依旧是经典并发问题,只是 Go 把表达方式做得更直接了。

接口、组合与错误处理

Go 的接口是隐式实现的,这一点非常契合大型系统中的依赖解耦。一个类型只要实现了接口要求的方法,就自动满足这个接口;不需要像传统面向对象语言那样显式声明“我实现了某接口”。这种设计降低了耦合,但也要求工程师更加谨慎地控制接口大小和抽象层次。

接口的最佳实践通常是“小接口、面向消费方定义”。如果接口过大、过早抽象,很容易演变成难以维护的伪架构。Go 倡导组合优于继承,struct 嵌套和接口拼接比复杂继承层级更常见。系统设计里把依赖能力拆成小接口,再通过组合拼出服务对象,往往更利于测试和替换实现。

错误处理是 Go 工程风格的另一核心。多数函数通过返回 error 显式暴露失败,不鼓励用异常机制隐藏控制流。这样做的好处是:失败路径就在函数签名里,调用者必须面对它。代价是样板代码会多一些,因此在大型代码库中,如何包装错误、附加上下文、区分系统错误和业务错误就很重要。

panic/recover 在服务端程序中的正确定位也值得强调。它适合捕捉程序不变量被破坏、数组越界、空指针这类真正异常的情况;而对预期内业务错误,应优先走普通错误返回。服务程序通常会在入口层用 recover 兜底,记录栈信息,避免单个请求直接把整个进程打挂。记录 panic 栈的示例,正体现了这一点。

网络服务与并发实践

Go 非常适合写网络服务,因为标准库提供了比较完善的 netnet/httpcontextsyncruntime 和测试分析工具。一个典型 Go 服务的工程价值,通常来自下面几方面:连接处理足够直接,并发模型清晰,单二进制部署简单,配套性能分析和调试工具完整。

写 Go 网络服务时,要特别注意几个工程细节。第一,HTTP 客户端默认支持连接复用,但前提是正确读取并关闭 Response.Body。这不是小细节,而是关系到连接池能否复用、文件描述符是否泄漏和整体吞吐是否稳定。第二,所有 I/O 操作都应合理设置超时,避免连接和 Goroutine 被无限挂起。第三,要警惕由于 Channel 阻塞、未消费结果、忘记取消 Context 等问题导致的 Goroutine 泄漏。

Go 的同步原语很多时候比 Channel 更直接。sync.Mutexsync.RWMutexsync.WaitGroupsync.Oncesync.Pool、原子操作等,都有各自清晰边界。比如 sync.Pool 更适合做短生命周期对象复用、减轻 GC 压力,而不适合拿来做真正语义上的连接池;连接池需要对象状态管理、容量约束和失效检测,这些都不是 sync.Pool 的职责。

Go 的运行时和内存管理也会影响网络服务表现。Goroutine 泄漏、string[]byte 频繁转换、切片扩容、未关闭资源、Ticker 不释放、cgo 调用过多,都会增加内存压力和 GC 负担。优化思路应先从 profile 入手,再判断是分配过多、对象存活太久,还是并发模型本身出了问题。

本章小结

Go 在系统设计领域的优势,不是单一指标领先,而是语言复杂度、并发表达、运行时能力和交付体验的综合平衡。理解它的核心类型语义、Goroutine/Channel 模型、接口与错误处理风格,基本就掌握了阅读和构建 Go 服务的主干能力。

这一章最重要的结论是:

  • Go 倡导简单、组合和工程约束清晰的设计风格。
  • Goroutine 轻量但不是免费,并发模型仍要考虑阻塞、取消和泄漏。
  • Channel 适合表达数据流和同步事件,但不是锁和队列的万能替代。
  • 接口和错误处理决定了 Go 代码库的可维护性与故障表达方式。
  • 网络服务优化应同时关注连接复用、超时控制、资源回收和 GC 压力。

本章面试题与追问

  1. Go 为什么在服务端系统里如此常见? 追问:相比 C++ 和 Python,它在工程交付和并发表达上分别做了哪些取舍?

  2. 数组、切片和 map 在语义上有什么关键区别? 追问:为什么切片和 map 的共享底层数据特性容易引出隐蔽 bug?

  3. Goroutine 为什么比线程更轻量? 追问:轻量主要体现在哪些方面,又为什么说它并不是“零成本并发”?

  4. G、P、M 调度模型分别代表什么? 追问:网络轮询器和工作窃取在高并发服务里起到了什么作用?

  5. 无缓冲 Channel 和有缓冲 Channel 分别适合表达什么? 追问:如果 Channel 设计不当,为什么很容易出现 Goroutine 泄漏或死锁?

  6. 为什么说 Channel 不是锁的万能替代? 追问:在什么场景下 sync.Mutex 反而比 Channel 更直接、更合适?

  7. Go 的接口为什么强调“小而面向消费方”? 追问:如果接口过早、过大抽象,会带来哪些维护问题?

  8. Go 为什么鼓励显式返回 error? 追问:panic/recover 在服务端程序里更适合承担什么角色?

  9. 为什么 HTTP 客户端里“读取并关闭 Response.Body”很重要? 追问:如果不这么做,会对连接复用和资源使用产生什么影响?

  10. Go 服务常见的内存和并发坑有哪些? 追问:如果你怀疑存在 Goroutine 泄漏或 GC 压力异常,会优先看哪些指标或工具?

第 24 章 电商系统全景图

本章定位:在读完第一部分的方法论之后,进入第三部分「电商系统设计实战」之前,用一张可落地的全景图把业务能力、应用系统、数据资产与技术基础设施对齐到同一坐标系。后续第 25 章至第 36 章将沿本章划定的边界逐域深入;第 4 章已经从方法论层面讨论了跨系统集成与一致性模式。

阅读建议:若你已熟悉 DDD 战略术语,可快速浏览 24.2 后进入 24.1 的图示;若你更习惯从接口与数据表入手,建议从 24.1.3 数据架构读起,再回到 24.1.1 校正业务语义。无论哪种路径,都请至少完成 24.4 的三条时序走读,因为后续各章的「集成小节」默认你已经知道主链路的参与者与先后次序

本章产出物(可用于团队对齐)

  1. 一页 限界上下文图(24.1.1)贴在内网架构 wiki。
  2. 一张 服务依赖图(24.1.2)导入架构治理工具(如 Backstage / 内部 CMDB)。
  3. 一份 主数据清单(24.1.3 表格)作为数据 Owner 会议的输入。
  4. 一套 集成模式评审话术(24.3)写进 RFC 模板。
  5. 一张 十二系统职责表(24.5.1)作为新人 Onboarding 必读。

24.1 系统全景架构

中大型电商平台的架构讨论,如果只停留在「微服务拆分清单」,很容易失去业务语义;如果只停留在「业务功能列表」,又难以指导工程依赖与数据落点。实践中常用 EA(企业架构)+ 4A 的多视角方法并行:

视角英文缩写回答的问题本章对应小节
业务架构BA(Business Architecture)平台提供哪些业务能力?投资优先级如何?24.1.1
应用架构AA(Application Architecture)系统如何划分?依赖方向与编排关系?24.1.2
数据架构DA(Data Architecture)主数据、索引、缓存、事件各自承担什么角色?24.1.3
技术架构TA(Technology Architecture)运行时、中间件、可观测性与安全如何承载上述系统?24.1.4

下面四节分别给出图示与解读要点。图示刻意与后续各章的术语保持一致,便于你在阅读第 25 章至第 33 章时「对照地图」。

本章的阅读方式:不要把全景图当成服务清单,而要把它当成后续实战章节的索引地图。商品、库存、营销、计价、搜索、购物车、订单、支付、供给治理和供应商同步都会在后文展开;本章先把它们放到同一张业务地图上,帮助读者理解每个系统的职责、上游、下游和失败边界。

下面这张图先给出 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

术语对照(避免口语歧义)

口语本书用语说明
价格中心 / 促销算价计价系统 + 营销系统「谁制定规则、谁做试算快照」应分开讨论。
交易中心订单 + 结算 + 购物车交易是链路,不是单服务。
上架后台商品上架 + 运营平台流程编排与批量工具职责不同。
搜索推荐搜索与导购(第 30 章)推荐可作为子模块,但集成模式与搜索高度相似。

从单体到分布式的认知迁移:在单体时代,模块边界靠包名与 Code Review 维持;在分布式时代,网络边界会放大设计缺陷——原本一次函数调用的地方,变成了超时、重试与部分失败。因此全景章的价值,不在于「数有多少个微服务」,而在于为每个跨边界调用预先分配 一致性语义、超时预算与观测标签。当你在第 32 章阅读订单状态机时,应能指出:某次迁移对应 24.4.2 中的哪一步、失败时由谁补偿。

24.1.1 业务架构(DDD 视角)

从 DDD 战略设计看,电商平台的业务能力应被组织为一组限界上下文(Bounded Context):每个上下文内部有独立的通用语言与生命周期;上下文之间通过显式关系(客户方 / 供应方、防腐层、发布语言等)协作。下图用「限界上下文 + 域分类」表达业务架构,颜色区分核心域、支撑域、通用域(分类标准与第 16 章一致,此处侧重系统级映射)。

flowchart TB
  subgraph Core["核心域(Core Domain)"]
    BC_Order["限界上下文:订单<br/>契约:订单号、状态机、履约编排"]
    BC_Pay["限界上下文:支付<br/>契约:支付单、渠道、清结算"]
    BC_Checkout["限界上下文:结算<br/>契约:试算、预占、拆单预览"]
    BC_Cart["限界上下文:购物车<br/>契约:行项目、会话合并"]
  end

  subgraph Support["支撑域(Supporting Domain)"]
    BC_Product["限界上下文:商品中心"]
    BC_Inv["限界上下文:库存"]
    BC_Price["限界上下文:计价"]
    BC_Mkt["限界上下文:营销"]
    BC_List["限界上下文:商品上架"]
    BC_Ops["限界上下文:B 端运营"]
    BC_Life["限界上下文:生命周期与供给治理"]
  end

  subgraph Generic["通用域(Generic Domain)"]
    BC_Search["限界上下文:搜索与导购"]
    BC_User["限界上下文:用户与会员(外采/标准能力)"]
    BC_Msg["限界上下文:消息通知(外采/标准能力)"]
  end

  BC_Cart --> BC_Checkout
  BC_Checkout --> BC_Order
  BC_Order --> BC_Pay

  BC_Checkout -.->|试算/ Hydrate| BC_Price
  BC_Checkout -.->|预占| BC_Inv
  BC_Checkout -.->|券与活动校验| BC_Mkt

  BC_Order -.->|快照与金额确认| BC_Price
  BC_Order -.->|库存确认/回退| BC_Inv
  BC_Order -.->|营销锁定与扣减| BC_Mkt
  BC_Order -.->|商品快照| BC_Product

  BC_List --> BC_Product
  BC_List --> BC_Inv
  BC_List --> BC_Price
  BC_Ops --> BC_Product
  BC_Ops --> BC_Inv
  BC_Ops --> BC_Mkt
  BC_Ops --> BC_Price
  BC_Life --> BC_Product
  BC_Life --> BC_List

  BC_Search -.->|发现与列表| BC_Product
  BC_User -.->|身份与权益| BC_Order
  BC_Msg -.->|异步触达| BC_Order

读图要点

  1. 核心域集中了「钱与承诺」相关的上下文:购物车暂存购买意图,结算把意图推进为可支付的约束集合(价格、库存、优惠),订单把承诺持久化为合同,支付完成资金侧的闭环。
  2. 支撑域提供可售性、可算价、可营销、可供给四类「规则与主数据」能力;它们高度影响交易,但行业模式相对可参照。
  3. 通用域中的搜索本书会深入(第 30 章),因其在工程上与商品、计价、库存的 Hydrate 编排强耦合;用户与消息等更常采购标准方案,在全景中保留接口位即可。

上下文映射(与第 1 章 DDD 部分衔接):上图中实线箭头多表示「客户方依赖供应方」的下游调用关系;虚线表示「通过发布语言(Published Language)或 ACL 防腐」的弱耦合。落地时建议显式标出:

  • 供应方(Upstream):商品中心对「商品快照 ID」、计价系统对「价格快照版本」、库存对「预占凭证」拥有定义权。
  • 客户方(Downstream):订单与结算消费上述契约,但不应要求供应方暴露内部表结构。
  • 防腐层(ACL):对接供应商、旧单体或外采营销引擎时,把外部模型挡在边界之外,避免污染核心域通用语言。

答辩提示:业务架构图的表述模板已统一收录到第 39 章的“专题答辩资料:业务架构图表述模板”

24.1.2 应用架构(微服务视角)

应用架构关注可部署单元之间的依赖。原则是:依赖方向自上而下、由稳定侧指向易变侧,避免出现「基础数据服务回调订单服务」这类环。下图在典型分层架构上,补全 C 端读路径(搜索、购物车)与结算编排,并标注后续章节编号,便于索引。

flowchart TB
  subgraph L0["接入与 BFF"]
    GW[API Gateway / BFF]
  end

  subgraph L_read["读路径与暂存"]
    SearchSvc["搜索与导购服务<br/>第 30 章"]
    CartSvc["购物车服务<br/>第 31 章"]
  end

  subgraph L_data["主数据与规则基座"]
    ProductSvc["商品中心<br/>第 25 章"]
    InvSvc["库存系统<br/>第 27 章"]
    MktSvc["营销系统<br/>第 28 章"]
    PriceSvc["计价系统<br/>第 29 章"]
  end

  subgraph L_trade["交易编排与资金"]
    CheckoutSvc["结算编排服务<br/>第 31 章"]
    OrderSvc["订单系统<br/>第 32 章"]
    PaySvc["支付系统<br/>第 33 章"]
  end

  subgraph L_supply["供给与运营"]
    ListingSvc["商品上架<br/>第 26 章"]
    OpsSvc["供给与运营管理<br/>第 26 章"]
    LifeSvc["生命周期协调<br/>第 26 章"]
  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 编排器」,在应用层与购物车解耦:购物车偏会话与展示,结算偏资源锁定与一致性门槛(详见第 31 章)。
  • 上架、运营、生命周期在应用层可能合并为一个「供给平台」团队维护的多个服务,逻辑上仍建议按限界上下文拆分数据与发布节奏(第 26 章展开)。

典型调用链(便于与 24.4 对照)

用户意图入口服务同步扇出(节选)异步副作用(节选)
搜索列表搜索与导购商品中心、计价、库存 Hydrate曝光日志、排序特征回流
加购购物车商品中心校验 SKU无或弱:会话写 Redis
打开结算页结算编排计价试算、库存预占、营销校验审计日志、风控评分
提交订单订单系统快照固化、库存确认、营销扣减OrderCreated 驱动清购物车、发券统计
去支付支付系统渠道路由、收银台创建PaymentSucceeded 驱动分账、消息触达

循环依赖治理:若发现「商品中心回调订单」一类需求,优先改为事件订阅查询倒置(由订单侧拉取快照),而不是在数据层打开反向通道。

24.1.3 数据架构

数据架构回答三件事:主数据放哪、派生数据如何构建、事件与缓存如何对齐。下图描述一条典型的「写主库、异步投影、读多路」路径,与第 7 章 CQRS 和第 4 章 Outbox 思路衔接。

flowchart LR
  subgraph Writers["写入侧(Command Path)"]
    SVC_W[业务服务<br/>订单/商品/库存等]
    DB[(MySQL 集群<br/>事务边界内)]
    Outbox[(Outbox 表<br/>同库事务)]
  end

  subgraph Bus["集成与解耦"]
    MQ[Kafka / Pulsar<br/>领域事件总线]
    CDC[CDC 可选<br/>Binlog 流]
  end

  subgraph Readers["读取侧(Query Path)"]
    ES[(Elasticsearch<br/>搜索索引)]
    Redis[(Redis<br/>库存热点/购物车)]
    DW[(数仓 / OLAP<br/>分析投影)]
  end

  SVC_W -->|本地事务| DB
  SVC_W -->|同事务写入| Outbox
  Outbox -->|Relay| MQ
  DB -.->|可选| CDC
  MQ -->|商品变更订阅| ES
  MQ -->|库存变更| Redis
  MQ -->|订单事实| DW
  CDC --> ES

落地要点

  1. 订单、支付、商品主档等强一致实体以 MySQL(或同类)为权威存储;跨聚合协作优先 Outbox + 消息(见第 1 章 1.3.9),避免「双写」在故障时无法对账。
  2. 搜索索引、推荐特征、报表属于派生视图,允许最终一致;延迟由业务容忍度与补偿任务共同约束。
  3. 库存常见「Redis 扛热点 + MySQL 审计」的双存储形态,必须单写者(Single Writer)与周期对账(第 27 章)。

主数据与派生数据清单(评审用)

数据类型权威存储常见派生副本一致性策略
商品主档MySQL(商品中心库)ES 文档、CDN 静态化、本地缓存Outbox / CDC → 最终一致
价格规则与快照MySQL + 计价服务缓存订单行上的快照 JSON创单时以订单持久化为准
可售库存Redis 计数 + MySQL 流水搜索侧的「是否有货」标签单写者 + 定时对账
订单合同MySQL(订单库)数仓订单事实表、客服只读库Binlog / 事件双播
支付单与账务MySQL(支付库)渠道对账文件、会计凭证T+0 / T+1 对账任务

数据所有权一句话:谁对「业务不变量」负责,谁就拥有该数据的写入 API;其余路径只能投影或引用。

离线数仓与实时数仓的边界:订单与支付事件进入数仓后,用于分析与风控建模,不得反向写回在线交易库作为业务依据;若运营需要「实时看板」,应通过专用 OLAP 或流式聚合服务读取消息总线,而不是直接查询订单主库拖垮 P99。若确需运营干预线上数据,应走带审批的正式 API 与审计日志,而不是「数仓导表回灌」。这类约束也是第 7 章上线前检查中「数据变更路径」的必审项。

24.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

与后续章节的关系:第 25 章至第 33 章主要在应用与数据架构层面展开;当你评估「是否需要 Service Mesh」「Kafka 分区策略」时,应回到本节检查观测性是否先于网格消息是否已成为事实管道等平台前提。

非功能需求(NFR)与全景的对应关系

  • 可用性:网关限流与服务熔断保护核心交易路径;搜索与报表故障不得拖垮创单。
  • 性能:读路径大量使用缓存与索引;写路径控制扇出深度,结算页试算可合并批量 RPC(第 31 章)。
  • 安全:密钥不进仓库;支付回调验签在独立模块;内部服务 mTLS 或网络策略隔离。
  • 可观测性:以 trace_id 贯穿网关、结算、订单、支付;对 Saga 每一步有结构化日志与业务指标(转化率、预占失败率)。
  • 合规与审计:订单与支付字段变更可追溯;营销补贴与实付金额可对账。

渐进式演进建议:早期可用「单体 + 清晰包边界」模拟上图拓扑;当团队规模与发布冲突上升时,再按限界上下文拆出独立部署单元,避免「先拆微服务、后补边界」的高成本路径。

容灾与多活(点到为止):技术架构图未展开「单元化 / 多 Region」,但在全景阶段应预留认知:订单与支付数据往往要求 Region 内强一致 + 跨 Region 异步复制;搜索索引与购物车会话更适合 就近读取。若在多活场景下仍沿用单 Region 的强同步调用链,容灾切换时容易遭遇「依赖未起、核心不可用」;因此第 4 章在谈 Saga 时也会隐含「地理边界上的超时预算」问题。


24.2 核心域与支撑域

DDD 强调:不是所有子域都值得同等投入。战略设计的产出之一,是一张「域分类表」,用于指导组织排兵布阵与技术选型(自研 / 定制 / 采购)。

24.2.1 核心域:交易与支付

核心域承载差异化与最高业务风险,典型包括:

限界上下文业务价值失败影响工程特征
订单合同与履约编排的单一事实来源错单、重复下单、无法履约状态机、幂等、Saga、审计
支付资金收付与对账闭环资损、监管与信任危机幂等、渠道适配、账务分录
结算把「可卖」推进为「可付」转化暴跌、资源错锁长事务编排、降级与超时释放
购物车购买意图与会话合并体验与转化问题高并发读写、合并策略

本书将购物车、结算、订单与支付作为交易链路主轴(第 31 章至第 33 章),并在第 4 章从一致性模式上把它们串成可复用的集成语言。

投资与组织策略(与第 16 章对照)

维度建议
团队配置核心域配最强工程与业务分析能力;接口契约由领域 Owner 签字。
发布节奏核心域应支持高频小步发布 + 特性开关;重大促销前冻结非关键变更。
质量门禁核心域 PR 适用第 7 章全阶段评审;支付与订单变更默认要求双人审。
技术债核心域技术债「零容忍排队」;偿债预算单独列项,不与功能挤同一队列。

常见误区:把「购物车」当成纯前端本地存储。实际上购物车是高并发有状态服务,涉及登录合并、库存展示与营销提示,与结算的边界必须在 API 契约上划清(第 31 章)。

24.2.2 支撑域:商品、库存、营销、定价与供给

支撑域是核心域的「地基」:没有可售商品与可算价格,订单与支付无从谈起;没有库存与营销约束,结算编排也会失去输入。

分组限界上下文与核心域的接口关系
商品与供给商品中心、上架、运营、生命周期提供 SPU/SKU、快照、上下架状态;不直接参与支付
规则与资源库存、营销、计价提供预占、券活动、试算与快照;被结算与订单编排调用

第 25 章至第 29 章分别深入各支撑系统;第 26 章从组织上常合并「上架 + 运营 + 生命周期」,但限界上下文仍建议在模型层分开,以避免「一个上帝服务」拖垮发布节奏。

为什么支撑域也值得深度自研:支撑域虽非「卖点」,却是故障的放大器。例如库存超卖、计价错误、营销叠加漏洞,都会在订单层集中爆发。架构评审中常问:「若该支撑域宕机 30 分钟,核心域能否优雅降级?」——答案决定缓存策略、兜底价、降级开关的设计深度。

与核心域的集成契约(摘要)

  • 商品中心输出:快照 ID、类目路径、禁售标签
  • 库存输出:预占凭证、可售数量区间、渠道库存类型(第 27 章二维模型)。
  • 营销输出:可叠加规则集、锁定 token、预算占用凭证
  • 计价输出:试算结果哈希或版本号,供创单时校验「结算页所见即所得」。

24.2.3 通用域:用户、搜索、消息等

通用域标准化程度高,通常采购或薄封装即可;例外是搜索与导购:虽然模式成熟,但在中大型平台中与商品、价格、库存的实时编排深度交织,本书第 30 章单独成章。

能力常见策略与交易链关系
用户与会员SSO、OAuth、IdP提供主体身份与风控标签
消息通知短信、邮件、Push订阅订单与支付事件
搜索ES + 召回排序 + HydratePDP/列表需联动计价与库存态

反模式提醒:把「通用域 = 可以随便写」等同于降低质量要求。正确做法是:减少自研范围,但不降低 SLO 与可观测性要求

关于搜索域的「重要性升级」:从严格 DDD 分类看,搜索常被归为通用域(技术方案成熟);但从业务入口与 GMV 贡献看,它又接近核心体验。本书采取工程折中:在域分类上保留通用属性,在章节权重上按核心链路对待(第 30 章),因其失败模式会直接影响列表价、库存态与活动标签的呈现。

用户与消息:用户域提供主体标识与会员等级,消息域消费订单与支付事件做触达。二者与交易链的耦合主要是读侧鉴权异步通知,应避免在下单同步路径强依赖外部推送可用性。


24.3 系统间的交互模式

跨系统协作可归纳为三类:同步 RPC异步事件数据同步(批式或流式)。它们不是互斥的,同一链路常组合使用;选型取决于一致性语义、延迟上限与故障隔离需求。

24.3.1 同步调用

典型场景:结算页试算、创单前库存确认、支付创建。特征是调用方阻塞等待结果,语义接近「读己之写」或强校验。

优点:实现直观、调试路径短。
风险:级联故障、线程占用、超时风暴;需配合超时、重试、熔断、舱壁与清晰的错误契约(第 4 章相关小节)。

工程要点(Go 服务常见落地):为出站 RPC 设置上下文超时每依赖独立超时;重试仅对幂等读或带幂等键的写开放;对核心交易路径实施舱壁线程池并发上限,避免试算扇出把进程拖死。返回错误时区分业务可预期错误(如券不可用)与基础设施错误(如超时),前者映射为 4xx 与明确 code,后者触发降级与告警。

电商实例:结算页打开时,编排服务并行调用计价、库存、营销;只要任一关键依赖超时,应整体返回「请稍后重试」或切换至缓存兜底价 + 延迟锁券策略,而不是无限等待(第 31 章详述降级矩阵)。

24.3.2 异步事件

典型场景:订单已创建、支付已成功、商品变更。特征是最终一致,通过消息中间件解耦峰值与异构消费者。

优点:吞吐与弹性好,天然适合多订阅者(搜索索引、数仓、营销统计)。
风险:重复消息、乱序、滞后;需幂等消费、版本号、可补偿流程(第 4 章 Outbox 与事件驱动)。

工程要点:事件体应携带聚合 ID、版本号、发生时间、幂等键;消费者使用「处理表」或唯一索引实现 at-least-once 下的精确一次业务效果。对支付成功类事件,建议以支付系统 Outbox 为唯一发布源,避免订单与支付双写双发导致重复记账。

电商实例ProductChanged 发布后,搜索索引、推荐特征、运营看板可能各自消费;它们失败不应阻塞商品主事务,但需要通过死信队列与可观测面板暴露积压,防止索引长期陈旧引发客诉。

24.3.3 数据同步

典型场景:搜索索引重建、报表 T+1、跨机房冗余。实现路径包括定时批处理、CDC、双写(谨慎)。

优点:对在线路径侵入小。
风险:延迟与对账;CDC 需处理 schema 演进与回放。

工程要点:优先 CDC + 消息Outbox 形成可回放管道,避免业务代码里手写双写。若必须双写,应配置对账任务比较主从差异并自动修复。搜索全量重建应走蓝绿索引别名切换,避免重建期间查询抖动。

同步 / 异步 / 数据同步对比总览:三者回答的是不同维度的问题——同步保障「此刻的正确」,异步保障「吞吐与解耦」,数据同步保障「派生视图的规模构建」。架构评审可用下图作开场白板,再落到具体接口与 SLA。

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
}

24.4 数据流转全景

本节用三条完整时序链把 24.1 至 24.3 的静态结构串成动态故事线。图中参与者命名与后续章节标题一致,便于对照。

三条链路的共同模式(背诵版):每条链路都同时存在 同步确认(保证局部不变量)与 异步传播(放大读模型与运营可见性)两类步骤。设计时请先标出「哪一步失败会导致资损或客诉」——这些步骤应尽量落入短事务 + 明确幂等键;其余步骤尽量推出消息总线。另一个共同点是 Hydrate:搜索与列表在 C 端读路径上,往往需要二次拉取商品、价格、库存以修补索引延迟;这与订单创单时的「快照固化」是同一思想的不同形态——用显式版本与快照对抗时间差

与大促场景的关系:商品流在大促前表现为「批量改价、改库存、改活动」的洪峰;订单流在秒杀瞬间表现为「创单与扣减」的尖峰;支付流在峰值表现为「渠道限流与回调延迟」。全景上需要预留 降级开关与异步化边界:例如列表页短时跳过非关键 Hydrate、支付回调与订单状态更新解耦等(细节分散在第 8、12、13、15 章)。

24.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 体现 生命周期对上架与主数据的回流:下架、供应商同步修正、违规处罚都会触发再次审核或索引失效。

伏笔:第 25 章讲清商品模型与快照;第 27 章区分预占与实物库存;第 26 章拆解上架与运营编辑的权限与状态机;第 30 章展开 Hydrate 编排与降级。

24.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 或签名摘要,订单持久化时校验,防止「页面价与实付不一致」引发纠纷。库存侧若已在结算预占,创单多为确认;若仅在结算校验、创单时才预占,则需评估高峰下的重试风暴(第 27 章)。营销锁定与扣减宜拆成「锁定 → 确认 / 释放」两阶段,与订单状态机对齐,避免券冻结长期占用。

异常路径(图中未展开但工程必备):创单任一步失败应沿 Saga 反向释放预占与券锁定;若订单已写库但后续异步失败,应依赖订单状态机驱动补偿任务,而不是人工改库。

伏笔:第 29 章定义试算与快照边界;第 32 章给出订单状态机与创单流程;第 33 章深入支付状态、回调和补偿;第 4 章把这些步骤抽象为可复用的一致性模式。

24.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 语义,避免在回调线程堆叠扇出。

与订单的边界:订单系统关心「应付金额与履约状态」;支付系统关心「渠道收单结果与资金事实」。订单不应直接保存渠道原始报文全字段,应由支付系统规范化后回写支付结果摘要

伏笔:第 33 章展开渠道适配、对账与退款;第 4 章讨论跨系统幂等与补偿事务编排。


24.5 系统边界总览

24.5.1 各系统的职责边界(十二个核心系统)

下表给出本书采用的十二个核心可部署系统(与 24.1.2 应用架构及第 25 章至第 33 章对应)。「不负责」列用于架构评审时的负面清单,防止边界侵蚀。

编号系统核心职责明确不负责深入章节
1商品中心SPU/SKU、类目属性、商品快照与主数据质量库存扣减、营销计算、支付第 25 章
2库存系统可售量、预占/确认/释放、对账与供应商同步价格计算、订单状态机第 27 章
3营销系统券/活动/补贴、圈品、预算与防刷订单持久化、支付渠道第 28 章
4计价系统多场景试算、费用、价格快照与降级营销资金账、库存数量第 29 章
5搜索与导购Query、召回、排序、Hydrate 编排不作为订单或支付事实来源第 30 章
6购物车行项目暂存、合并、批量操作不持有支付契约第 31 章
7结算编排结算页 Saga、预占协调、拆单预览不替代订单合同存储第 31 章
8订单系统合同、状态机、拆单、履约协调入口渠道密钥、资金划拨第 32 章
9支付系统支付单、渠道路由、回调、对账与退款商品主数据、库存数量第 33 章
10商品上架上架审核、发布流程、供给侧状态机不复制商品中心全量模型职责第 26 章
11供给与运营管理批量任务、配置工具、权限与审计不绕过商品中心直接写「影子库」第 26 章
12生命周期与供给治理同步/编辑/下架边界、跨系统编排约束不实现全量搜索召回第 26 章

说明:第 26 章在目录上合并了上架、运营与生命周期;在工程上可拆为多服务,但在边界表中仍建议分开陈述职责,以便治理。

十二系统「一句话职责」扩展(评审口播版)

  • 商品中心:维护「卖得是什么」——结构化商品、类目约束与面向订单的快照能力;对外暴露稳定读模型与快照创建接口。
  • 库存系统:维护「还能卖多少」——把可售量、渠道库存、预占凭证与对账闭环收敛在库存库表与热点缓存中。
  • 营销系统:维护「怎么促卖」——圈品、券活动、补贴预算与叠加互斥规则;不负责把最终应付金额写入订单。
  • 计价系统:维护「收多少钱」的规则引擎与快照——对接商品基础价与营销减免,输出可校验的试算版本。
  • 搜索与导购:维护「怎么找得到」——索引与排序是手段,Hydrate 与场景识别是业务核心;索引永远晚于主库一秒是常态而非事故。
  • 购物车:维护「用户想买什么」——会话级行项目与合并逻辑,不承担资金与库存的最终承诺。
  • 结算编排:维护「现在能不能付」——把试算、预占、营销校验收敛为短窗口内的可执行计划,再交给订单持久化。
  • 订单系统:维护「合同与履约状态」——状态机、拆单、快照引用与对外协调接口;不保存渠道密钥与支付通道报文。
  • 支付系统:维护「资金事实」——支付单、渠道、回调、账务分录与对账;不反向驱动商品编辑。
  • 商品上架:维护「供给侧流程」——审核、发布、异步补偿与状态机;它是编排器而非商品主数据的越权写入者。
  • 供给与运营管理:维护「运营效率」——批量导入导出、配置工具、权限审计;所有落库应通过正式领域 API。
  • 生命周期与供给治理:维护「时间与责任的边界」——上下架、同步冲突、编辑互斥与跨系统回滚策略的协调者。

按价值链聚类(便于向业务方解释)

价值链阶段涉及系统(编号见上表)业务语言
进场与治理10、11、12、1、2、4「有货、有价、合规可售」
发现与暂存5、6、1、4、2、3「看得见、算得清、加得进」
成交与资金7、8、9「锁得住、记得准、收得到」

24.24.2 边界不清的常见问题

  1. 商品中心写库存流水:库存的并发语义与对账域应收敛在库存上下文,否则易出现双写不一致。
  2. 营销系统直接改订单金额:优惠「算出来」与订单「记下来」应分离;否则退款与审计难以追溯。
  3. 搜索索引当主库:索引延迟会导致「搜得到但买不了」,必须在 Hydrate 或结算侧再次以权威服务为准。
  4. 支付系统承载订单状态机:资金状态与履约状态相关但不同;混写会导致渠道回调与拆单场景难以治理。
  5. 计价系统写订单行:计价负责「算」与试算令牌;订单负责「记」与版本校验。混写会让退款金额拆分失去依据。
  6. 购物车持有库存预占:预占属于资源锁定,应落在结算或订单编排;购物车仅存意图与展示缓存。
  7. 运营后台直连生产库改价:绕过计价与审计,极易产生监管与对账风险;应走审批流 + 正式 API。
  8. 搜索服务在召回阶段调用支付:读路径不应触碰资金系统;价格与活动以 Hydrate 调用计价与营销只读接口为界。

24.24.3 边界划分原则(落地检查清单)

  1. 单一事实来源(SSOT):每个聚合只有一个权威上下文持久化。
  2. 编排与状态分离:编排服务可以无状态或仅存会话;合同状态由订单上下文持有。
  3. 读模型可替换:搜索、推荐、报表可重建;不可重建的是资金流水与订单合同
  4. 跨域用契约,不用隐式共享表:表连接是反模式;用 API、事件与明确 DTO。
  5. 把「能不能买」的最后一次校验放在离钱最近且可审计的一步(通常是创单或支付创建)。
  6. 明确「编排」与「领域服务」:编排负责步骤顺序与超时;领域服务负责业务规则判定。二者勿混在同一「上帝类」中。
  7. 每个跨系统接口都有 SLI:例如试算 P99、索引延迟上限、支付回调处理延迟;无指标的接口等于无边界。
  8. 用例驱动的边界测试:为每个系统维护「本系统拒绝处理的请求样例」,在 CI 或契约测试中固定下来,防止回归侵蚀。

24.6 本章小结

本章是全书的总领章,目标不是替代后续各章的深度,而是建立三样东西:同一套词汇(限界上下文与十二个系统)、同一张依赖图(应用与数据架构)、同一套交互纪律(同步 / 异步 / 数据同步的组合拳)。

你可以带走的关键结论

  1. 四视角对齐:BA 决定投资与语义边界,AA 决定可部署单元与依赖方向,DA 决定权威数据与投影路径,TA 决定规模化运行时能力。缺任一视角,评审容易出现「各说各话」。
  2. 十二个核心系统:商品中心、库存、营销、计价、搜索、购物车、结算编排、订单、支付、商品上架、供给与运营、生命周期治理——分别对应第 25 章至第 33 章的主体叙事;其中第 26 章在工程上常合并多个部署单元,但在治理上仍建议按职责拆分讨论。
  3. 域分类指导排兵:核心域(订单、支付、结算、购物车)追求正确性与可审计性;支撑域追求稳定与可替换的集成契约;通用域追求成本与 SLO 的平衡。搜索处于「通用技术 + 核心体验」的交叉带,第 30 章会展开其 Hydrate 与降级策略。
  4. 交互模式不可偏科:只有同步会导致故障传播;只有异步会拉长不一致窗口;只有批式同步无法满足实时导购。实际架构是在 SLA、成本、团队成熟度 约束下的组合。
  5. 三条主链路是阅读地图:商品流回答「货怎么进来并被发现」;订单流回答「承诺如何形成」;支付流回答「资金如何闭环」。后续章节均可挂载到这三条链上自检:本章的哪个小节、哪张图覆盖了当前话题。

与第 4 章的衔接:第 4 章将把 24.3 的交互模式上升为 Saga、幂等、对账、事件驱动 等一致性语言,并把 CAP 折中讲透。建议在阅读实战章节时,用 24.4 的时序图遮住文字,尝试口述一遍每条箭头上的失败与补偿,检验是否已建立全景肌肉记忆。

与第 25 章至第 33 章的衔接:进入任一系统章时,建议先回答四个问题——谁是上游、谁是下游、我的 SSOT 是什么、我发布哪些事件。答不上来则回到 24.5.1 边界表补齐。

面向架构师的自检清单(离开本章前):能否在 10 分钟内手绘 24.1.2 依赖图并标出三条可能形成环的依赖?能否用业务语言向非技术干系人解释「为什么搜索不是订单的一部分」?能否列举支付回调失败时的三个系统状态组合及各自补偿动作?若尚不能,建议在笔记中重画一遍 24.4 时序图,再进入后续系统实战章节。

建议阅读顺序:若你更熟悉业务,可按 24.4 节三条链路走读,再回看 24.1.1 的限界上下文;若你更熟悉工程,可从 24.1.2 与 24.3 节开始,把依赖与交互模式对齐后再进入各系统章节。若你正在准备架构评审,可携带 24.1.3、24.3.3 与 24.24.3 作为一页纸附录。


第 25 章 商品中心系统

本章定位:商品中心不是供给后台,也不是商家上传系统。商品中心是平台的正式商品主数据中心和交易前契约中心,负责沉淀已经发布生效的 Resource、SPU/SKU、Offer、库存配置、履约规则、退款规则、发布版本和商品快照。第 26 章讨论商品如何从 Draft、Staging、QC 进入平台;本章讨论商品一旦发布后,平台如何稳定、可追溯、可交易地表达“卖什么、怎么卖、如何履约、历史订单如何解释”。

商品中心最容易被设计成一个“大后台 CRUD”。早期这样做问题不大,但当平台开始支持本地生活、酒店、票务、充值、礼品卡、账单缴费、供应商同步和商家自助上传后,商品中心如果继续直接承接草稿、审核、导入、供应商拉取、搜索刷新、订单快照,就会迅速变成不可维护的大泥球。

更合理的边界是:

供给与运营平台
  → Draft / Staging / QC / Task / DLQ
  → Publish Command
  → 商品中心正式主数据
  → 商品快照 / Outbox / 读模型
  → 搜索、缓存、库存、计价、订单、履约

本章回答五个问题:

  1. 商品中心到底负责什么? 正式商品主数据、交易前契约、发布版本和稳定读模型。
  2. 为什么不能把 Draft、QC、Rejected 放到商品正式表? 因为这些是供给流程状态,不是正式商品生命周期状态。
  3. Resource、SPU/SKU、Offer 如何建模? 用资源层、商品定义层、销售承诺层拆清楚异构品类。
  4. 发布版本和商品快照解决什么问题? 解决回滚、对账、搜索一致性和历史订单解释。
  5. 商品中心如何和库存、计价、搜索、订单集成? 通过交易前契约、Outbox 事件和版本化读模型形成最终一致。

完整的供给治理链路见:


25.1 系统定位与边界

25.1.1 商品中心是什么

商品中心是平台对“商品事实”的正式表达。它不关心商家正在编辑哪份草稿,也不关心某条 Excel 第几行是否解析失败;它关心的是已经发布生效的商品版本。

商品中心至少要回答:

问题商品中心的回答
卖的是什么资源Resource,例如酒店、门店、机场、活动、充值运营商
商品如何定义SPU/SKU,例如套餐、面额、房型、服务规格
用什么销售承诺卖Offer / Rate Plan,例如价格计划、渠道、销售期、可售规则
下单前需要什么信息Input Schema,例如手机号、账单号、入住人、证件
如何履约Fulfillment Rule,例如充值、发券、出票、供应商确认
如何退款售后Refund Rule,例如随时退、过期退、不可退、取消政策
哪个版本当前生效publish_version
历史订单如何解释商品快照、报价快照、履约契约快照、退款规则快照

一句话:

商品中心负责正式商品和交易前契约,不负责供给过程。

25.1.2 商品中心不是什么

商品中心不应该承接所有 B 端流程状态。下面这些内容应该属于第 26 章的供给与运营平台:

内容应该归属原因
草稿保存供给平台 Draft草稿允许反复编辑,不影响线上
商家上传审核供给平台 QC审核是发布准入工单,不是正式商品
Excel 导入进度供给平台 Task / Task Item属于任务执行状态
供应商分页同步供应商同步 Batch / Checkpoint属于外部拉取和恢复链路
错误文件供给平台 File / Validation / DLQ属于运营修复闭环
待审、驳回、撤回Staging / QC Review属于提交快照和审核工单

商品正式表不应该出现这些状态:

DRAFT
QC_PENDING
QC_REVIEWING
REJECTED
WITHDRAWN
PARSING
VALIDATING

正式商品表应该出现的是线上资产状态:

PUBLISHED
ONLINE
OFFLINE
ENDED
BANNED
ARCHIVED

这个边界非常重要。新建商品在 Draft、Staging、QC 阶段还没有正式 item_id,只有发布成功后才进入商品中心。如果商品中心提前生成正式商品并把状态设为 DRAFT,后面会出现两个问题:

  1. 未审核商品容易被搜索、缓存或内部查询误读为正式商品。
  2. Draft、QC、正式商品的生命周期混在一起,状态机很快失控。

25.1.3 与供给平台的关系

第 26 章的供给平台负责“如何把商品送进来”,商品中心负责“已经发布的商品如何被交易系统稳定使用”。

供给平台:
  Draft
  Staging Ticket
  QC Review
  Change Request
  Publish Record

商品中心:
  Resource
  Product Item
  SPU / SKU
  Offer / Rate Plan
  Stock Config
  Input Schema
  Fulfillment Rule
  Refund Rule
  Publish Version
  Product Snapshot

两者通过发布命令连接:

PublishProductVersionCommand
  → 商品中心校验 base_publish_version
  → 写正式商品主数据
  → 生成 publish_version
  → 生成商品快照
  → 写 Outbox 事件

商品中心可以保存 last_publish_idlast_operation_idsource_type 等审计引用,但不应该反向保存供给侧的完整流程状态。


25.2 总体架构

25.2.1 架构分层

商品中心建议拆成五层:

Command API
  → Domain Model
  → Versioned Write Model
  → Snapshot / Outbox
  → Read Model / Cache / Search Projection

更完整的架构如下:

flowchart LR
    subgraph Supply["供给与运营平台"]
        Draft["Draft / Staging / QC"]
        PublishCmd["Publish Command"]
    end

    subgraph ProductCenter["商品中心"]
        Command["Command API"]
        Domain["Resource / Item / SPU / SKU / Offer"]
        Contract["Stock Config / Input Schema / Fulfillment / Refund"]
        Version["Publish Version"]
        Snapshot["Product Snapshot"]
        Outbox["Outbox Event"]
        Query["Query API / Read Model"]
    end

    subgraph Downstream["下游系统"]
        Search["搜索"]
        Cache["缓存"]
        Pricing["计价"]
        Inventory["库存"]
        Order["订单"]
        Fulfillment["履约"]
        Data["数据平台"]
    end

    Draft --> PublishCmd
    PublishCmd --> Command
    Command --> Domain
    Command --> Contract
    Domain --> Version
    Contract --> Version
    Version --> Snapshot
    Version --> Outbox
    Snapshot --> Query
    Outbox --> Search
    Outbox --> Cache
    Outbox --> Pricing
    Outbox --> Inventory
    Outbox --> Data
    Query --> Order
    Query --> Fulfillment

这张图的关键点是:商品中心的写入口不是运营后台的表单提交,而是供给平台发布出来的命令。这样可以保证所有商品变更都经过 Draft、Staging、Validation、QC 或自动准入策略,再进入正式商品。

25.2.2 读写分离

商品中心的写入链路强调一致性和可追溯:

Publish Command
  → 版本校验
  → 正式表事务写入
  → 快照生成
  → Outbox

商品中心的读取链路强调低延迟和稳定:

详情页
  → Redis / Local Cache
  → 商品中心 Query API
  → DB 回源

列表页
  → Search
  → 商品中心批量 Hydrate

创单页
  → 商品中心版本化快照
  → 库存确认
  → 计价试算

不要让搜索列表直接扫商品中心 MySQL,也不要让商品中心在详情接口里临时计算复杂优惠价和实时库存。商品中心提供交易前契约,计价和库存由各自领域负责。

25.2.3 核心原则

商品中心设计要坚持几个原则:

原则说明
正式表只保存发布数据Draft、QC、Rejected 不进入商品正式表
写入必须版本化每次发布递增 publish_version
订单只信快照历史订单不回读最新商品解释
读模型可最终一致搜索、缓存通过 Outbox 异步刷新
下游按版本幂等消费旧事件不能覆盖新版本
高变化数据不强塞商品表实时库存、最终成交价、供应商实时房态不属于商品中心唯一事实

25.3 核心领域模型

25.3.1 三层商品模型

传统电商常用 SPU/SKU 就够了,但数字商品平台会遇到酒店、票务、账单、充值、本地服务、礼品卡等异构场景。只靠 SPU/SKU 容易把资源、销售单元、交易契约混在一起。

更稳的模型是三层:

资源层 Resource
  → 商品定义层 SPU / SKU / Product Item
  → 销售承诺层 Offer / Rate Plan / Contract
层级解决的问题示例
Resource这个商品背后依附什么现实或虚拟资源酒店、门店、机场、运营商、影院、活动
SPU/SKU平台上售卖的商品定义和规格豪华房、50 元券、10GB 流量包、电影票套餐
Offer / Rate Plan以什么条件卖给用户价格计划、销售期、渠道、取消政策、库存来源

这个拆法的好处是:

  1. 同一个 Resource 可以挂多个商品。
  2. 同一个 SKU 可以有多个 Offer。
  3. 同一个 Offer 可以切换库存、履约、退款策略。
  4. 供应商同步可以优先沉淀 Resource,运营再决定如何售卖。

25.3.2 Resource

Resource 表达“现实或外部世界里的资源对象”。它不是一定可售的商品,而是商品的承载对象。

品类Resource 示例商品示例
酒店酒店、房间资源、地理位置房型套餐、Rate Plan
本地生活门店、服务地点双人餐券、洗车券、按摩套餐
票务演出、场馆、场次门票档位、座位区
充值运营商、国家、产品线话费面额、流量包
账单账单机构、缴费类型电费、水费、宽带账单

Resource 的状态不等于商品状态。酒店资源可以是有效的,但某个房型 Offer 暂时不可售;门店可以营业,但某张券可以下架。

25.3.3 Product Item

Product Item 是商品中心对 C 端“一个可展示商品”的正式聚合。它通常对应前台一个详情页或一张商品卡。

它和 SPU 的关系取决于品类:

场景建议
标准零售一个 item_id 通常对应一个 SPU
本地生活券一个 item_id 可以对应一个套餐 SPU 和默认 SKU
酒店一个 item_id 可以对应酒店资源详情,Offer 表达房型和 Rate Plan
充值缴费一个 item_id 可以对应运营商或缴费入口,SKU 表达面额或套餐

为什么需要 item_id?因为 C 端、商家端、订单、搜索通常需要一个统一的商品入口 ID。SPU/SKU 偏商品定义,Resource 偏资源对象,Offer 偏销售承诺;item_id 是把这些内容组合成“前台商品”的聚合根。

25.3.4 SPU / SKU

SPU 表达商品共性定义,SKU 表达可下单的规格单元。

对象作用示例
SPU商品共性信息iPhone 16、KFC 套餐、酒店豪华房
SKU可售规格单元黑色 256G、双人套餐、含早可取消

不是所有数字商品都需要复杂多 SKU,但建议保留默认 SKU。这样订单、库存、计价、履约都能使用统一结构。

25.3.5 Offer / Rate Plan

Offer 表达销售承诺。它不是简单价格字段,而是一组交易前条件:

Offer =
  销售对象
  + 渠道
  + 销售期
  + 价格/价格引用
  + 库存来源
  + 输入要求
  + 履约规则
  + 退款规则

酒店和票务等品类还需要 Rate Plan:

对象含义
Offer平台销售配置
Rate Plan供应商或业务侧的价格计划、取消政策、入住条件

例如酒店:

Resource: Bangkok Hotel A
SPU: Deluxe Room
SKU: Deluxe Room + 2 Breakfast
Offer: Shopee App Channel + TH Site
Rate Plan: refundable before T-1, pay now, supplier plan RP_001

25.4 类目模板与异构品类

25.4.1 为什么需要类目模板

商品中心不能为每个品类硬编码一套表单和校验。类目模板要定义:

  1. 需要哪些 Resource。
  2. 是否需要 SPU/SKU。
  3. 必填属性和枚举。
  4. 是否需要库存配置。
  5. 是否需要 Input Schema。
  6. 支持哪些履约方式。
  7. 支持哪些退款规则。
  8. 哪些字段可索引、可筛选、可展示。
category_template
  → resource_schema
  → spu_schema
  → sku_schema
  → offer_schema
  → contract_schema

类目模板既服务供给平台,也服务商品中心。供给平台用它做表单、校验、QC 风险识别;商品中心用它约束正式数据和生成读模型。

25.4.2 属性分类

商品属性至少分四类:

属性类型示例是否影响交易
基础展示属性标题、图片、卖点、描述间接影响
关键决策属性品牌、城市、门店、酒店星级影响搜索和转化
销售规格属性颜色、尺码、面额、房型影响 SKU
交易契约属性退款规则、履约方式、输入字段直接影响下单和售后

不要把所有属性都塞进一个 ext_info。适合搜索、筛选、风控、计价、履约使用的字段,要结构化存储或生成专门投影;纯展示和低频字段可以放 JSON 扩展。

25.4.3 扩展字段策略

扩展字段有三种方式:

方式优点缺点适用
JSON 扩展上线快,适合异构字段查询和约束弱低频展示字段
垂直扩展表类型清晰,查询友好表多,建模成本高酒店、门店、票务核心属性
属性表 EAV灵活查询复杂,容易失控通用筛选属性

推荐组合:

核心交易字段
  → 结构化列

品类核心字段
  → 垂直扩展表

长尾展示字段
  → JSON

搜索筛选字段
  → 搜索投影

25.5 正式商品状态与版本

25.5.1 Item 状态

正式商品的生命周期状态建议放在 product_item_tab.item_status

状态含义
PUBLISHED已有正式发布版本,但未必已经开始售卖
ONLINEC 端可见,满足上线条件
OFFLINE手动下线或暂不售卖
ENDED销售期结束
BANNED平台封禁
ARCHIVED归档,仅保留历史查询和审计

它不应该包含 DRAFTQC_PENDINGREJECTED。这些状态属于供给侧对象。

25.5.2 Sellable 状态

item_status 表达商品资产状态,sellable_status 表达当前是否可交易:

状态含义
SELLABLE当前允许下单
UNSELLABLE不允许下单
NOT_STARTED销售期未开始
SOLD_OUT库存或券码池不足
EXPIRED销售期或有效期结束
RISK_BLOCKED风控或平台规则阻断

这两个状态可以不同。例如商品 ONLINE,但库存为 0 时 sellable_status=SOLD_OUT。列表页可以展示该商品,但下单按钮不可用。

25.5.3 Publish Version

每次发布都要生成新的 publish_version

item_id = item_80001
publish_version: 3 → 4

版本用于:

  1. 防止旧编辑覆盖新版本。
  2. 支持回滚和审计。
  3. 让搜索、缓存、订单按版本幂等处理。
  4. 让历史订单能解释当时看到的商品。

编辑发布前必须校验:

staging.base_publish_version == product_item.current_publish_version

如果不相等,说明线上商品已经变化,当前提交应进入版本冲突,不能静默覆盖。

25.5.4 商品状态机

stateDiagram-v2
  [*] --> PUBLISHED: Publish Version Created
  PUBLISHED --> ONLINE: Hit Sale Rule
  PUBLISHED --> OFFLINE: Not Saleable Yet
  ONLINE --> OFFLINE: Merchant/Ops Offline
  ONLINE --> ENDED: Sale Ended
  ONLINE --> BANNED: Platform Ban
  OFFLINE --> ONLINE: Reactivate
  ENDED --> ONLINE: Extend Sale Period and Publish
  BANNED --> OFFLINE: Unban
  OFFLINE --> ARCHIVED: Archive
  ENDED --> ARCHIVED: Archive
  ARCHIVED --> [*]

注意:这个状态机只描述正式商品。Draft、Staging、QC 的状态机在第 26 章。


25.6 核心表模型

商品中心表模型围绕“正式主数据 + 交易前契约 + 发布版本 + 快照”设计。

25.6.1 Resource 表

CREATE TABLE resource_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    resource_id VARCHAR(64) NOT NULL,
    resource_type VARCHAR(32) NOT NULL COMMENT 'HOTEL/STORE/EVENT/CARRIER/BILLER',
    category_code VARCHAR(32) NOT NULL,
    resource_name VARCHAR(256) NOT NULL,
    country_code VARCHAR(32) DEFAULT NULL,
    city_code VARCHAR(64) DEFAULT NULL,
    address VARCHAR(512) DEFAULT NULL,
    geo_lat DECIMAL(10, 7) DEFAULT NULL,
    geo_lng DECIMAL(10, 7) DEFAULT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/INACTIVE/ARCHIVED',
    ext_info JSON DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_resource_id (resource_id),
    KEY idx_type_city (resource_type, city_code),
    KEY idx_category_status (category_code, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台资源主表';

Resource 是平台沉淀资源的主表。供应商同步通常先映射到 Resource,再由供给平台决定是否生成商品和 Offer。

25.6.2 Product Item 表

CREATE TABLE product_item_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    item_id VARCHAR(64) NOT NULL,
    category_code VARCHAR(32) NOT NULL,
    item_type VARCHAR(32) NOT NULL COMMENT 'STANDARD/VIRTUAL/LOCAL_SERVICE/HOTEL/TICKET/BILL',
    resource_id VARCHAR(64) DEFAULT NULL,
    title VARCHAR(256) NOT NULL,
    subtitle VARCHAR(512) DEFAULT NULL,
    main_image VARCHAR(512) DEFAULT NULL,
    item_status VARCHAR(32) NOT NULL COMMENT 'PUBLISHED/ONLINE/OFFLINE/ENDED/BANNED/ARCHIVED',
    sellable_status VARCHAR(32) NOT NULL COMMENT 'SELLABLE/UNSELLABLE/NOT_STARTED/SOLD_OUT/EXPIRED/RISK_BLOCKED',
    current_publish_version BIGINT NOT NULL DEFAULT 1,
    sale_start_at DATETIME DEFAULT NULL,
    sale_end_at DATETIME DEFAULT NULL,
    source_type VARCHAR(32) DEFAULT NULL COMMENT 'LOCAL_OPS/MERCHANT/SUPPLIER/SYSTEM',
    last_publish_id VARCHAR(64) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_item_id (item_id),
    KEY idx_resource (resource_id),
    KEY idx_category_status (category_code, item_status),
    KEY idx_sellable (sellable_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='正式商品聚合根';

product_item_tab 是 C 端商品入口。它不保存 Draft 和 QC 状态,只保存正式商品的线上状态和当前发布版本。

25.6.3 SPU 表

CREATE TABLE product_spu_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    spu_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    category_code VARCHAR(32) NOT NULL,
    spu_name VARCHAR(256) NOT NULL,
    brand_id BIGINT DEFAULT NULL,
    spec_schema JSON DEFAULT NULL,
    attr_payload JSON DEFAULT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/INACTIVE/ARCHIVED',
    publish_version BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_spu_id (spu_id),
    KEY idx_item (item_id),
    KEY idx_category_status (category_code, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品 SPU 定义';

SPU 承载共性信息和规格定义。对于单规格商品,可以只有一个默认 SKU。

25.6.4 SKU 表

CREATE TABLE product_sku_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    sku_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    spu_id VARCHAR(64) NOT NULL,
    sku_name VARCHAR(256) DEFAULT NULL,
    spec_values JSON DEFAULT NULL,
    barcode VARCHAR(128) DEFAULT NULL,
    sku_status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/INACTIVE/ARCHIVED',
    publish_version BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_sku_id (sku_id),
    KEY idx_item (item_id),
    KEY idx_spu (spu_id),
    KEY idx_status (sku_status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品 SKU 定义';

SKU 是订单行、库存配置和计价试算常用的锚点。即使是虚拟品,也建议保留默认 SKU 来统一交易链路。

25.6.5 Offer 表

CREATE TABLE product_offer_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    offer_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    spu_id VARCHAR(64) DEFAULT NULL,
    sku_id VARCHAR(64) DEFAULT NULL,
    resource_id VARCHAR(64) DEFAULT NULL,
    offer_type VARCHAR(32) NOT NULL COMMENT 'NORMAL/PACKAGE/RATE_PLAN/PRESELL',
    site_code VARCHAR(32) NOT NULL,
    channel_code VARCHAR(32) DEFAULT NULL,
    currency VARCHAR(16) NOT NULL,
    list_price DECIMAL(18, 4) DEFAULT NULL COMMENT '标价或基础价,不等同最终成交价',
    offer_status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/INACTIVE/ARCHIVED',
    sale_start_at DATETIME DEFAULT NULL,
    sale_end_at DATETIME DEFAULT NULL,
    publish_version BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_offer_id (offer_id),
    KEY idx_item_status (item_id, offer_status),
    KEY idx_sku (sku_id),
    KEY idx_site_channel (site_code, channel_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品销售 Offer';

Offer 里的价格字段只能表达基础价或标价。最终成交价应该由计价系统根据活动、优惠、会员、渠道和风控规则试算。

25.6.6 Rate Plan 表

CREATE TABLE product_rate_plan_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    rate_plan_id VARCHAR(64) NOT NULL,
    offer_id VARCHAR(64) NOT NULL,
    supplier_id BIGINT DEFAULT NULL,
    supplier_rate_plan_code VARCHAR(128) DEFAULT NULL,
    plan_name VARCHAR(256) DEFAULT NULL,
    plan_type VARCHAR(32) DEFAULT NULL COMMENT 'REFUNDABLE/NON_REFUNDABLE/PAY_NOW/PAY_LATER',
    cancellation_policy_ref VARCHAR(128) DEFAULT NULL,
    meal_plan VARCHAR(64) DEFAULT NULL,
    ext_info JSON DEFAULT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/INACTIVE/ARCHIVED',
    publish_version BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_rate_plan_id (rate_plan_id),
    KEY idx_offer (offer_id),
    KEY idx_supplier_plan (supplier_id, supplier_rate_plan_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='价格计划或供应商 Rate Plan';

Rate Plan 适合酒店、票务、活动等复杂供给。简单商品可以不建独立 Rate Plan,只使用 Offer。

25.6.7 库存配置表

CREATE TABLE product_stock_config_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    stock_config_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    sku_id VARCHAR(64) DEFAULT NULL,
    offer_id VARCHAR(64) DEFAULT NULL,
    stock_type VARCHAR(32) NOT NULL COMMENT 'PLATFORM_STOCK/CODE_POOL/SUPPLIER_REALTIME/NO_STOCK',
    inventory_ref VARCHAR(128) DEFAULT NULL,
    deduct_timing VARCHAR(32) DEFAULT NULL COMMENT 'CREATE_ORDER/PAY_SUCCESS/FULFILL_SUCCESS',
    oversell_policy VARCHAR(32) DEFAULT NULL COMMENT 'FORBID/ALLOW_WITH_LIMIT/SUPPLIER_CONFIRM',
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/INACTIVE',
    publish_version BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_stock_config_id (stock_config_id),
    KEY idx_item (item_id),
    KEY idx_offer (offer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品库存配置';

这张表保存库存“如何接入”和“扣减时机”,不保存实时库存事实。实时库存属于库存系统或供应商实时确认。

25.6.8 输入 Schema 表

CREATE TABLE product_input_schema_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    input_schema_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    offer_id VARCHAR(64) DEFAULT NULL,
    schema_version BIGINT NOT NULL,
    schema_payload JSON NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/INACTIVE/ARCHIVED',
    publish_version BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_input_schema_id (input_schema_id),
    KEY idx_item_offer (item_id, offer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='下单输入 Schema';

充值需要手机号,账单缴费需要账单号,酒店需要入住人,机票需要乘机人证件。Input Schema 必须发布为正式契约,否则订单系统无法稳定校验用户输入。

25.6.9 履约规则表

CREATE TABLE product_fulfillment_rule_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    fulfillment_rule_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    offer_id VARCHAR(64) DEFAULT NULL,
    fulfillment_type VARCHAR(32) NOT NULL COMMENT 'TOPUP/ISSUE_CODE/BOOKING/SUPPLIER_ORDER/MANUAL',
    supplier_id BIGINT DEFAULT NULL,
    fulfillment_params JSON DEFAULT NULL,
    timeout_seconds INT DEFAULT NULL,
    retry_policy JSON DEFAULT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/INACTIVE/ARCHIVED',
    publish_version BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_fulfillment_rule_id (fulfillment_rule_id),
    KEY idx_item_offer (item_id, offer_id),
    KEY idx_supplier (supplier_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品履约规则';

履约规则是交易前契约的一部分。订单创建时要保存对应快照,避免商品后续修改供应商参数后影响历史订单。

25.6.10 退款规则表

CREATE TABLE product_refund_rule_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    refund_rule_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    offer_id VARCHAR(64) DEFAULT NULL,
    refund_type VARCHAR(32) NOT NULL COMMENT 'REFUNDABLE/NON_REFUNDABLE/PARTIAL/CONDITIONAL',
    rule_payload JSON NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/INACTIVE/ARCHIVED',
    publish_version BIGINT NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_refund_rule_id (refund_rule_id),
    KEY idx_item_offer (item_id, offer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品退款规则';

退款规则变更属于高风险变更。它应在第 26 章供给平台中经过 Diff、风险识别和 QC 或强权限确认,再发布到商品中心。

25.6.11 发布版本表

CREATE TABLE product_publish_version_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    item_id VARCHAR(64) NOT NULL,
    publish_version BIGINT NOT NULL,
    publish_id VARCHAR(64) NOT NULL,
    publish_type VARCHAR(32) NOT NULL COMMENT 'CREATE/EDIT/OFFLINE/ONLINE/BAN/UNBAN/ROLLBACK',
    source_type VARCHAR(32) DEFAULT NULL COMMENT 'LOCAL_OPS/MERCHANT/SUPPLIER/SYSTEM',
    source_ref_id VARCHAR(64) DEFAULT NULL COMMENT '供给侧 publish_id、operation_id 或 sync batch',
    payload_hash VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/ROLLED_BACK/ARCHIVED',
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_item_version (item_id, publish_version),
    UNIQUE KEY uk_publish_id (publish_id),
    KEY idx_item_time (item_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品发布版本';

发布版本是商品中心的核心版本轴。供给侧的 product_publish_record 记录发布动作,商品中心的 product_publish_version_tab 记录正式版本事实。

25.6.12 商品快照表

CREATE TABLE product_snapshot_tab (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    snapshot_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    publish_version BIGINT NOT NULL,
    snapshot_type VARCHAR(32) NOT NULL
        COMMENT 'FULL/PRODUCT/OFFER/STOCK_CONFIG/INPUT_SCHEMA/FULFILLMENT/REFUND',
    snapshot_payload JSON NOT NULL,
    payload_hash VARCHAR(64) NOT NULL,
    payload_ref VARCHAR(512) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_snapshot_id (snapshot_id),
    UNIQUE KEY uk_item_version_type (item_id, publish_version, snapshot_type),
    KEY idx_item_version (item_id, publish_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品发布快照';

快照是订单系统解释历史订单的基础。订单不应该回读最新商品表,而应该保存或引用创单时的快照。

25.6.13 Outbox 表

CREATE TABLE product_outbox_event (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    event_id VARCHAR(64) NOT NULL,
    aggregate_type VARCHAR(64) NOT NULL COMMENT 'PRODUCT_ITEM/PUBLISH_VERSION',
    aggregate_id VARCHAR(64) NOT NULL,
    event_type VARCHAR(128) NOT NULL
        COMMENT 'ProductPublished/ProductOnline/ProductOffline/ProductSnapshotCreated',
    item_id VARCHAR(64) DEFAULT NULL,
    publish_version BIGINT DEFAULT NULL,
    payload JSON NOT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
        COMMENT 'PENDING/SENDING/SENT/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,
    sent_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_event_id (event_id),
    KEY idx_status_retry (status, next_retry_at),
    KEY idx_item_version (item_id, publish_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品中心 Outbox 事件';

正式表写入、版本表、快照表、Outbox 必须在同一个事务里完成。搜索和缓存刷新失败时,不回滚商品发布,而是通过 Outbox 重试和补偿。


25.7 发布写入链路

25.7.1 只接受发布命令

商品中心写接口不应该暴露成通用 CRUD:

POST /product/updateTitle
POST /sku/updatePrice
POST /offer/updateRefundRule

更合理的是命令式接口:

PublishProductVersion
OfflineProduct
ReactivateProduct
BanProduct
UnbanProduct
ArchiveProduct
RollbackProductVersion

原因很简单:商品变更不是字段修改,而是交易前契约变更。每次变更都需要版本、快照、事件和审计。

25.7.2 Publish Command

发布命令需要包含完整的发布上下文:

{
  "publish_id": "pub_20260427_0001",
  "operation_id": "op_20001",
  "source_type": "MERCHANT",
  "publish_type": "EDIT",
  "item_id": "item_80001",
  "base_publish_version": 3,
  "category_code": "LOCAL_SERVICE",
  "resource": {},
  "spu_list": [],
  "sku_list": [],
  "offer_list": [],
  "stock_config_list": [],
  "input_schema_list": [],
  "fulfillment_rule_list": [],
  "refund_rule_list": []
}

新建商品时 item_id 可以为空,由商品中心在发布事务中生成正式 item_id。编辑商品时必须带 item_idbase_publish_version

25.7.3 发布前校验

商品中心发布前要做最后一道强校验:

  1. publish_id 是否幂等。
  2. 新建商品是否没有重复外部对象映射。
  3. 编辑商品的 base_publish_version 是否匹配当前版本。
  4. Resource、SPU、SKU、Offer 关系是否完整。
  5. 库存配置、输入 Schema、履约规则、退款规则是否满足类目模板。
  6. 商品是否被封禁、归档或锁定。
  7. 发布 payload hash 是否已经发布过。

如果校验失败,商品中心应该返回结构化错误给供给平台,由供给平台记录 DLQ 或展示给运营。

25.7.4 发布事务

发布事务的典型流程:

BEGIN
  → 幂等检查 publish_id
  → 新建商品生成 item_id,编辑商品锁定 item_id 当前版本
  → 写 resource_tab
  → 写 product_item_tab
  → 写 product_spu_tab
  → 写 product_sku_tab
  → 写 product_offer_tab
  → 写 product_stock_config_tab
  → 写 product_input_schema_tab
  → 写 product_fulfillment_rule_tab
  → 写 product_refund_rule_tab
  → 生成 new_publish_version
  → 写 product_publish_version_tab
  → 写 product_snapshot_tab
  → 写 product_outbox_event
COMMIT

不要在发布事务里刷新 ES、删除 Redis、通知营销系统。那些动作通过 Outbox 异步执行。

25.7.5 幂等与并发

发布幂等建议使用:

场景幂等 Key
发布请求publish_id
商品版本item_id + publish_version
payload 去重item_id + base_publish_version + payload_hash
Outbox 事件event_id

编辑并发使用版本 CAS:

UPDATE product_item_tab
SET current_publish_version = ?,
    updated_at = NOW()
WHERE item_id = ?
  AND current_publish_version = ?;

rows_affected = 0 说明线上版本已经变化,本次发布必须失败并返回版本冲突。


25.8 读取模型与查询接口

25.8.1 详情页读模型

详情页需要的信息通常横跨多张表:

item
  + resource
  + spu / sku
  + offer
  + stock config
  + input schema
  + fulfillment rule
  + refund rule

在线上高 QPS 场景,不建议每次详情请求都做多表实时 Join。可以生成详情读模型:

ProductDetailView
  item_id
  publish_version
  title
  images
  resource_info
  sku_options
  offer_summary
  input_schema
  fulfillment_summary
  refund_summary
  sellable_status

读模型可以存 Redis、文档库或宽表。它是正式表的投影,不是新的事实源。

25.8.2 列表页与搜索

列表页通常从搜索系统召回商品,再批量补充商品中心的轻量信息:

Search Query
  → item_id list
  → batch get product summary
  → merge price / inventory / campaign hint

搜索索引建议保存:

item_id
publish_version
title
category
resource_location
tags
display_price
sellable_status
updated_at

搜索索引不是商品事实源。索引里的 publish_version 必须能和商品中心对账。

25.8.3 创单前读取

创单前读取和详情页读取不同。详情页可以为了体验返回缓存,创单前必须重新确认交易安全:

Create Order
  → 读取商品当前 publish_version
  → 读取商品快照
  → 库存确认
  → 计价试算
  → 供应商实时确认(如果需要)
  → 保存订单快照

对酒店、机票、电影票这类动态品类:

列表页可以缓存
详情页尽量刷新
创单前必须实时确认

25.8.4 批量查询

商品中心必须提供批量查询接口,否则搜索、购物车、订单、营销会在高峰期打出 N+1 查询。

推荐接口:

BatchGetProductSummary(item_ids)
BatchGetProductSnapshot(item_id, publish_version)
BatchGetOfferContract(offer_ids)
BatchGetInputSchema(item_ids)

批量接口要支持部分失败和降级:

{
  "success_items": [],
  "missing_items": ["item_001"],
  "stale_items": ["item_002"]
}

25.9 商品快照与订单契约

25.9.1 为什么订单只信快照

商品会持续变化:标题、图片、价格、退款规则、履约参数、供应商映射都可能调整。如果历史订单回读最新商品表,就会出现:

  1. 用户下单时看到可退款,售后时变成不可退款。
  2. 下单时是供应商 A,履约时商品映射变成供应商 B。
  3. 下单时价格计划为 RP_001,后续改成 RP_002,订单对账无法解释。
  4. 商品下架后历史订单详情无法展示。

所以订单创建时必须保存商品上下文快照。

25.9.2 快照内容

订单至少需要保存或引用:

快照内容
商品快照标题、图片、类目、Resource、SPU/SKU
Offer 快照销售期、渠道、基础价、Rate Plan
价格快照计价系统试算结果、优惠、实付金额
输入 Schema 快照用户下单时需要提供什么
履约规则快照供应商、履约参数、超时策略
退款规则快照取消政策、退款条件、售后路径
供应商映射快照外部商品、资源、Rate Plan 对应关系

商品中心负责商品、Offer、输入、履约、退款等交易前契约快照;计价系统负责价格快照;库存系统负责库存预占或扣减记录。

25.9.3 快照生成策略

快照可以在发布时生成,也可以在创单时按版本读取。

推荐:

发布时生成版本化商品快照
  → 创单时引用 item_id + publish_version + snapshot_id
  → 订单保存必要字段冗余

这样既能减少创单时组装成本,也能保证历史解释稳定。


25.10 与库存、计价、搜索、订单的边界

25.10.1 与库存系统

商品中心保存库存配置,不保存实时库存事实。

商品中心库存系统
库存类型实时库存数量
库存来源引用预占、扣减、释放
券码池引用券码分配
扣减时机并发控制
是否需要供应商确认供应商库存查询

例如:

product_stock_config.stock_type = CODE_POOL
inventory_ref = code_pool_10001

商品中心只告诉订单系统这个商品使用券码池,真正券码是否可用由库存或券码系统判断。

25.10.2 与计价系统

商品中心可以保存基础价、标价、Offer 规则引用,但最终成交价属于计价系统。

商品中心:
  list_price
  currency
  offer_id
  rate_plan_id

计价系统:
  channel price
  campaign price
  coupon
  service fee
  settlement amount
  final payable amount

不要把活动价、券后价、会员价直接写死在商品表里,否则退款、对账和营销成本会失去来源。

25.10.3 与搜索系统

搜索系统是商品中心的读投影。商品中心通过 Outbox 发送:

ProductPublished
ProductContentChanged
ProductOnline
ProductOffline
ProductSellableChanged

搜索消费事件后更新索引。索引要保存 publish_version,方便巡检:

product_item.current_publish_version
  vs
search_index.publish_version

如果不一致,生成补偿任务重建索引。

25.10.4 与订单系统

订单系统使用商品中心的方式是:

  1. 创单前读取商品当前版本和快照。
  2. 校验商品状态和基础交易契约。
  3. 调库存系统确认可售。
  4. 调计价系统确认金额。
  5. 必要时调供应商实时确认。
  6. 保存订单快照。

订单不应该直接依赖商品中心最新表解释历史订单。

25.10.5 与履约系统

履约系统需要的不是最新商品详情,而是订单创建时的履约规则快照。

fulfillment_type
supplier_id
supplier_product_code
rate_plan_code
timeout_seconds
retry_policy

商品发布后的履约规则变更只影响新订单,不影响旧订单。


25.11 缓存、索引与一致性

25.11.1 缓存策略

商品详情适合多级缓存:

Local Cache
  → Redis
  → DB / Read Model

缓存 key 要带版本:

product:detail:{item_id}:{publish_version}

这样新版本发布后可以直接读新 key,旧版本仍可供历史订单或短期页面使用。

25.11.2 缓存失效

发布成功后,不建议同步删除所有缓存。更稳的方式:

写新版本缓存
  → Outbox 通知缓存失效
  → 旧版本自然过期

如果使用不带版本的热 key:

product:detail:{item_id}

则必须通过 Outbox 事件失效或刷新,并让消费者按 publish_version 幂等,避免旧事件覆盖新缓存。

25.11.3 搜索最终一致

商品中心 DB 和搜索索引天然是最终一致。关键不是强行同步刷新,而是:

  1. 事件不丢。
  2. 消费可重试。
  3. 消费按版本幂等。
  4. 有巡检发现索引落后。
  5. 有补偿任务重建索引。

巡检公式:

索引落后 = product_item.current_publish_version > search_index.publish_version

25.11.4 Outbox 补偿

Outbox 失败不应影响商品发布成功。失败事件进入重试:

PENDING
  → SENDING
  → SENT

SENDING / FAILED
  → retry
  → DLQ

常见补偿任务:

  1. 重建搜索索引。
  2. 刷新商品详情缓存。
  3. 重放商品发布事件。
  4. 校验营销圈品是否同步。
  5. 校验订单创单快照是否可读取。

25.12 供应商同步与商品中心

供应商同步属于供给链路,不属于商品中心内部执行链路。商品中心不应该知道某个供应商全量同步跑到哪个城市、哪个 page、哪个 cursor。

正确关系:

Supplier Sync
  → Raw Snapshot
  → Normalize
  → Mapping
  → Diff
  → Product Supply Staging
  → Publish Command
  → Product Center

商品中心只接收发布后的正式结果:

Resource 更新
SPU/SKU 更新
Offer 更新
Rate Plan 更新
履约/退款契约更新

但商品中心要支持供应商同步带来的特殊要求:

要求商品中心怎么支持
供应商资源映射Resource、Offer、Rate Plan 保留外部映射引用
字段主导权商品中心保留字段来源,供给平台判断是否可覆盖
大批量更新发布命令幂等、限流、分批
版本追溯source_typesource_ref_idpublish_version
下游刷新Outbox 事件按版本投递

25.13 商品中心 API 设计

25.13.1 Command API

Command API 面向供给平台和内部治理系统。

API作用
PublishProductVersion发布新商品或编辑版本
OfflineProduct下线商品
ReactivateProduct重新上线
BanProduct平台封禁
UnbanProduct解封
ArchiveProduct归档
RollbackProductVersion回滚到指定版本

Command API 要求:

  1. 强幂等。
  2. operatorsource_ref
  3. base_publish_version
  4. 返回结构化错误。
  5. 写操作必须产生版本、快照和事件。

25.13.2 Query API

Query API 面向前台、订单、搜索 Hydrate 和内部系统。

API作用
GetProductDetail商品详情
BatchGetProductSummary批量商品摘要
GetProductSnapshot按版本读取商品快照
BatchGetOfferContract批量读取 Offer 契约
GetInputSchema读取下单输入 Schema
GetFulfillmentRuleSnapshot读取履约规则快照
GetRefundRuleSnapshot读取退款规则快照

Query API 要区分:

C 端展示读取
  → 可以走缓存和读模型

创单交易读取
  → 必须读取版本化快照并做状态校验

25.13.3 事件 API

商品中心对外发布事件:

ProductPublished
ProductContentChanged
ProductOfferChanged
ProductContractChanged
ProductOnline
ProductOffline
ProductBanned
ProductArchived
ProductSnapshotCreated

事件 payload 至少包含:

{
  "event_id": "evt_001",
  "event_type": "ProductPublished",
  "item_id": "item_80001",
  "publish_version": 4,
  "changed_fields": ["title", "refund_rule"],
  "occurred_at": "2026-04-27T10:00:00Z"
}

下游必须按 event_id 幂等,按 publish_version 防乱序。


25.14 质量治理与可观测性

25.14.1 商品质量维度

商品中心要参与发布后质量巡检,但质量问题的修复流程仍然回到供给平台。

常见质量问题:

问题影响
缺图C 端转化下降,搜索降权
缺价或价格异常详情页不可展示,计价失败
无库存配置创单不可售
无履约规则支付后无法履约
无退款规则售后不可解释
Resource 映射缺失供应商履约失败
搜索索引版本落后前台搜不到或展示旧数据
缓存版本落后详情页展示旧内容

25.14.2 监控指标

指标说明
商品详情 P99Query API 延迟
缓存命中率详情缓存、摘要缓存命中
发布成功率发布命令成功 / 总命令
发布版本冲突率版本 CAS 失败比例
快照生成失败率发布后快照失败比例
Outbox 堆积量待投递商品事件数
索引版本落后数搜索索引落后商品数
商品契约缺失率缺库存、履约、退款规则的商品占比

25.14.3 排查链路

线上商品问题建议从 item_idpublish_version 开始排查:

item_id
  → product_item_tab
  → product_publish_version_tab
  → product_snapshot_tab
  → product_outbox_event
  → search_index publish_version
  → order snapshot

如果要追溯商品是怎么进入平台的,再通过 source_ref_id 回到第 26 章供给平台:

publish_id / operation_id
  → product_supply_operation_log
  → draft / staging / qc / publish_record

25.15 答辩材料

本章相关问题、总结话术和追问要点已统一收录到第 39 章的“商品供给与运营治理”题卡

延伸阅读建议


第 26 章 商品供给、编辑、运营与生命周期治理

在大型电商系统中,供给平台(Supply Platform)和运营平台(Operation Platform)通常是两个独立但又紧密交织的系统平台。供给平台负责“把货搞进来并管好”,运营平台负责“把货卖出去并卖得好”,而商品生命周期负责把供给态、运营态和交易态串成一套可治理、可发布、可追溯的状态演进机制。

因此,本章不再把商品供给管理、统一治理平台、供应商同步拆成三篇平行章节,而是把它们收敛到同一条主线上理解:

供给入口
  -> 治理控制面
  -> 商品生命周期
  -> 库存 / 营销 / 搜索 / 订单协同
  -> 供应商同步与持续运维

为了方便阅读,本章将从商品如何进入平台、如何被治理与发布、如何与库存和运营协同、以及如何通过供应商同步持续更新四个视角展开。

本章定位:承接第 25 章「商品中心」的 Resource、SPU/SKU、Offer、库存可售、搜索索引和订单快照模型,讨论商品如何进入平台、如何被审核发布、如何创建和修改库存、上线后如何持续运营,以及商品生命周期如何与供给任务、供应商同步、库存控制面、营销协同和下游投影保持一致。

商品供给管理不是后台 CRUD。它是一条长期运行的供给治理流水线:

供给入口
  → Draft / Staging
  → Task / Item
  → 标准化与校验
  → Diff 与风险识别
  → 来源准入策略:商家 QC,本地运营自动准入
  → 版本化发布
  → 库存控制面 / 营销协同 / 交易契约生效
  → Outbox 下游投影刷新
  → DLQ / 补偿 / 质量巡检

本章要回答五个问题:

  1. 商品生命周期如何设计? Draft、Staging、QC、正式 Item、Task 状态不能混成一个字段。
  2. 供给入口如何统一? 人工创建、批量导入、运营编辑、库存创建 / 修改、供应商同步都进入统一治理框架,但执行策略不同。
  3. 库存创建和修改归谁管? 供给平台承接库存配置、补货、券码导入、生码和锁库存的运营工作流,库存系统维护库存事实和账本。
  4. 同步与异步如何取舍? 单商品创建和编辑需要同步体验,批量导入、批量编辑、库存批量导入和供应商同步必须异步任务化。
  5. 发布如何保证一致? 商品主数据、库存控制面、营销协同、交易契约、搜索缓存、计价上下文和订单快照要通过版本、命令和 Outbox 形成最终一致。

本章后半部分会继续展开统一治理平台控制面与供应商同步专项链路,不再拆成独立平行章节。

本章建议配合三张图阅读:

  1. 主图用泳道流程图回答“谁在什么时候做什么”。

商品创建到发布上线泳道流程

  1. 辅助图用状态机回答“商品状态怎么变”。

商品生命周期状态机

  1. 辅助图用 Data Flow Diagram 回答“数据在哪些表之间流转”。

商品供给发布 Data Flow Diagram

图源文件:

  • books/system-design-architecture-book/images/product-create-publish-swimlane.svg
  • books/system-design-architecture-book/images/product-lifecycle-state-machine.svg
  • books/system-design-architecture-book/images/product-supply-data-flow.svg

26.1 系统定位与边界

26.1.1 为什么不是商品中心 CRUD

商品中心负责主数据模型和查询契约;供给与运营平台负责商品进入平台和持续维护的流程治理。

系统负责什么不负责什么
商品中心Resource、SPU、SKU、Offer、类目、属性、正式发布版本文件导入进度、审核队列、错误文件、运营任务
供给与运营平台入口、草稿、任务、暂存、校验、QC 准入、发布编排、库存创建 / 修改的运营入口、营销活动配置入口、补偿、审计C 端高 QPS 查询、库存扣减、库存账本事实、计价试算、搜索索引直写、订单状态维护、营销优惠计算
库存系统库存事实、库存创建命令执行、库存预占、扣减、释放、券码池、库存账本商品标题、图片、类目治理、运营审核流
计价系统基础价、渠道价、试算、优惠叠加、结算价商品上架流程和审核流
营销系统活动、券、补贴、预算、营销库存、圈品规则、优惠计算规则商品供给流程、商品生命周期和库存账本
搜索系统索引、召回、排序、可检索投影商品发布事务
订单系统商品快照、报价快照、履约契约快照最新商品配置维护

供给平台与搜索、计价、订单的关系不是“后台同步调用并写入对方系统”。供给平台完成发布后写 Outbox,搜索索引、缓存、计价上下文、数据平台等由各自消费者按版本重建投影;订单系统不接收供给平台的直接写入,而是在创单时读取当时可交易上下文并保存商品、报价、履约和退款快照。

供给平台与营销系统的关系更近,但仍然是控制面协同:供给平台可以承接“这个商品参加什么活动、圈选哪些 SKU、活动资格何时生效”的运营入口,并向营销系统提交活动配置或圈品命令;营销系统负责活动规则、预算、券、补贴、营销库存和最终优惠计算。不要把活动价、优惠叠加结果或券核销状态写回商品供给表。

如果运营后台直接修改商品正式表,会快速产生几个问题:

  1. 导入半成品污染线上。
  2. 审核和变更原因不可追溯。
  3. 搜索、缓存、计价上下文刷新不一致,营销活动协同状态不可见。
  4. 历史订单被最新商品配置影响。
  5. 供应商同步和人工编辑互相覆盖。

因此,供给与运营平台的核心不是“把商品写进数据库”,而是:

让一个商品从供给入口到可被搜索、可被下单、可被履约、可被追溯。

这里要特别区分 运营入口归属事实归属:创建库存、补货、导入券码、系统生码、锁库存、门店库存调整、日期库存调整,都应该在供给与运营平台里有工作台、审批、任务进度、错误文件和审计记录;但最终的库存余额、券码状态机、预占记录和账本流水,必须由库存系统维护。供给平台发起 CreateInventory / AdjustInventory / ImportCodeBatch / GenerateCodeBatch / LockInventory 命令,库存系统幂等执行并返回 InventoryReady / InventoryChanged / InventoryFailed

26.1.2 五类供给入口

商品供给来源通常有五类:

入口典型场景入口特点执行方式
本地运营创建平台运营创建本地生活券、礼品卡、充值套餐、账单缴费入口低量、强交互、可信操作源同步体验 + 自动准入 + 发布治理
商家上传商家自助上传门店、套餐、服务商品、素材外部操作源,质量不稳定同步提交 + 默认 QC
批量导入大促前批量创建商品、门店、套餐、价格计划、券码池大量、行级失败、需要错误文件异步任务
运营编辑修改标题、图片、类目、价格、库存、上下架、退款规则基于线上版本变更,风险差异大同步提交 + 审核/发布
供应商同步酒店、影院、票务、活动等外部数据全量/增量/Push/刷新长任务、外部不稳定、需要断点续跑专项同步链路

这五类入口不能完全拆成五套系统。更合理的设计是:

入口层分开
  → 执行策略分开
  → 标准化后进入统一 Staging
  → 统一 Validation / Diff / Review / Publish / Outbox

26.1.3 主链路与专项链路

供应商同步属于商品供给链路,但它不是商品供给链路的全部。

商品供给与运营治理平台
  ├─ 人工创建 / 商家上传
  ├─ 批量导入
  ├─ 运营编辑
  ├─ 库存创建 / 补货 / 券码导入
  └─ 供应商同步

供应商同步因为涉及 Raw Snapshot、Checkpoint、Worker Lease、Sync Batch Version、Supplier Mapping、新鲜度和供应商质量治理,所以执行层需要单独设计。

但发布治理层应该合流:

supplier_sync_batch
  → Normalize
  → product_supply_task(task_type=SUPPLIER_SYNC_IMPORT)
  → product_supply_task_item
  → product_supply_staging
  → product_validation_result
  → product_change_request
  → Publish

一句话总结:

供应商同步执行层独立,商品发布治理层复用。

26.1.4 典型场景地图

为了让读者先建立整体感,再进入后面的状态机、任务模型和发布细节,可以先用三张“场景地图”理解本章。

商品创建场景:

场景典型来源流量特征用户时效预期推荐处理模式
表单手动创建商家后台、运营后台低频、离散、低并发强同步,秒级回执直接写 Draft
Excel 批量导入商家批量上新、运营代建高吞吐、突发、文件流异步,返回任务 ID流式解析 + MQ
API / ISV 推送ERP、开放平台、KA 商家中高频、小批次、可重试半同步,回执后异步完成receipt_id + MQ + 回调
主动拉取同步某酒店供应商 / 某外部供应商等海量、长周期、离线批处理纯离线分片任务 + 指纹过滤

商品编辑场景:

场景典型来源是否进草稿是否需要审核生效时效
单品表单编辑商家后台、运营后台通常需要保存后待审
Excel 批量编辑商家批量调标题 / 属性通常需要异步待审
供应商全量对齐定时 Pull视字段与来源策略而定离线批量更新
高频价格 / 库存同步ERP、自动控价系统否或局部旁路通常免审秒级生效
平台风控强制下架法务、风控、合规平台内部裁决立即生效

库存模型差异:

维度数字库存券码库存
数据模型单行数量模型一码一实例模型
B 端加库存直接调数量或 Delta必须导入新增券码
B 端减库存直接扣减数量必须选择具体券码作废
C 端扣减Redis 计数器Redis 队列化发号
核心风险超卖、写放大一券多卖、券码泄露

这三张地图和后文的关系是:

  • 26.3 解释“状态怎么流转”。
  • 26.4、26.7、26.8、26.9 解释“不同入口怎么执行”。
  • 26.6 解释“库存任务和券码任务如何治理”。

26.1.5 商品创建场景摘要

从执行方式看,商品创建并不是一条链路,而是四类不同入口的组合。它们共享统一的发布治理框架,但同步 / 异步策略完全不同。

表单手动创建

  • 适用对象:新商家、小 B 商家、平台运营。
  • 特点:低频、离散、低并发。
  • 体验要求:同步保存,秒级回执。
  • 推荐方式:直接写 Draft,前端和网关都做强校验,不合规数据就地拦截。

Excel 批量导入

  • 适用对象:批量上新、批量初始化商品库。
  • 特点:高吞吐、突发、文件流。
  • 体验要求:快速返回 task_id,后台异步处理。
  • 推荐方式:流式解析、行级错误隔离、按批投递 MQ,由商品中心异步消费。

API / ISV 推送

  • 适用对象:ERP、开放平台、KA 商家系统接入。
  • 特点:小批次、中高频、重试风险高。
  • 体验要求:先返回“已接收”,再异步完成。
  • 推荐方式:返回 receipt_id,后台异步建品,成功后回调或供对方查询。

主动拉取同步

  • 适用对象:供应商全量或大批量静态资源接入。
  • 特点:海量、长周期、纯离线。
  • 体验要求:稳定、可分片、可断点续跑。
  • 推荐方式:Master-Worker 分片、分页抓取、内容指纹过滤、只让变更数据进入发布链路。

26.1.6 商品编辑场景摘要

商品编辑比创建多了一层“线上版本覆盖”的复杂度,因此要先区分普通编辑路径和高频旁路路径。

单品表单编辑

  • 编辑页必须读取当前线上版本。
  • 保存时必须带版本号,例如 biz_version
  • 如果线上版本已变化,应立即拒绝并提示刷新。
  • 保存结果进入 Draft,再走 Diff、审核和发布。

Excel 批量编辑

  • 上传后立即返回 task_id
  • 行级校验失败写错误文件,成功行按批进入 SUPPLY_EDIT_TOPIC
  • 商品中心消费后写草稿并统一进入待审状态。

供应商全量对齐

  • 指纹未变的数据不应反复进入商品中心。
  • 指纹变更的数据进入影子行和送审链路。
  • 批次结束后可通过 batch_no 做清尾下架。

高频价格 / 库存同步

  • 价格和库存不适合每次都写草稿。
  • 这类字段更适合走库存域或价格域专用接口。
  • 目标是秒级生效,而不是进入人审队列。

平台风控强制下架

  • 这是一条逆向控制流,不回到供给平台。
  • 商品中心直接切换正式商品状态。
  • 同时强制刷新缓存和搜索索引,确保全站不可见。

26.2 核心难点与设计策略:从供给治理到可售闭环

商品供给管理真正难的不是“建几张商品表”,而是把不同入口、不同状态、不同事实源和不同交易风险收敛成一条可治理、可回放、可补偿的供给链路。

核心矛盾典型表现设计策略
多入口人工创建、批量导入、运营编辑、库存创建 / 修改、供应商同步都会改变供给能力入口分开,标准化后统一进入 Supply Task、Staging、Validation、Publish
多状态Draft、Staging、QC、正式商品、库存任务、Outbox 都有自己的状态谁拥有生命周期,谁拥有状态字段,避免一个 status 表达所有语义
多事实源商品、库存、计价、营销、搜索、订单都关心商品变化,但事实归属不同供给平台做控制面,事实数据留在各自系统,通过命令和事件协作
多交易风险缺图、缺价、无库存、无履约规则、活动配置失败都会导致不可售发布版本、交易契约、库存任务、营销协同和可售投影分层推进
多失败形态导入失败、审核失败、发布失败、下游投影失败、库存创建失败DLQ、错误文件、补偿任务和运营看板把失败运营化

这一章后续所有设计都围绕六个目标展开:

  1. 入口统一:所有供给动作都有任务、来源、操作者和 TraceID。
  2. 线上隔离:草稿、导入中数据、未审核变更不进入正式表。
  3. 质量可控:标准化、类目模板、主数据校验、交易契约校验和风险规则形成发布门禁。
  4. 状态分离:发布、上线、可售、库存 ready、营销 ready 不能混成一个状态。
  5. 最终一致:正式表、快照、Outbox 同事务,读侧投影和营销协同异步完成。
  6. 失败可运营:任务、Item、DLQ、错误文件、补偿任务和可售诊断形成闭环。

26.2.1 供给链路的核心矛盾:多入口、多状态、多事实源

商品供给平台看上去像一个后台,但它本质上是供给控制面。控制面不直接承诺“库存一定够”“价格一定正确”“活动一定可用”“订单一定能履约”,它承诺的是:任何供给变更都必须有入口、有证据、有校验、有发布版本、有审计和可补偿路径。

一个商品从进入平台到被用户购买,至少会经过三类对象:

对象类型例子设计重点
流程对象Draft、Task、Task Item、Staging、QC Review记录供给变更如何被提交、校验、审核和发布
正式对象Resource、SPU、SKU、Offer、Rate Plan、交易前契约支撑 C 端查询、交易校验和订单快照
派生对象搜索索引、商品缓存、计价上下文、营销资格、可售投影面向读性能、导购体验和交易前判断,可以异步重建

如果把这三类对象混在一张宽表里,短期会觉得简单,长期一定会遇到几个问题:未审核数据污染线上,供应商同步覆盖人工修复,库存补货绕过账本,搜索索引和商品版本对不上,历史订单无法解释当时为什么能买、为什么这个价。

26.2.2 发布、上线与可售三态分离

电商系统里最容易被混淆的三个词是:发布、上线、可售。

状态含义典型判断
PUBLISHED正式商品版本已经生成,交易契约和发布快照已经落库publish_version 递增,Outbox 已写入
ONLINE商品生命周期允许 C 端展示和进入交易前校验商品未下架、未封禁、未结束销售,当前时间在销售窗口内
SELLABLE当前渠道、当前时间、当前范围内可以承诺给用户商品在线,库存 ready,价格 ready,营销资格 ready,履约和风控通过

因此,审核通过不等于发布成功,发布成功不等于商品上线,商品上线也不等于可售。更稳的链路应该是:

QC Approved
  → Publish Transaction
  → ProductPublished
  → InventoryReady / PricingContextReady / MarketingEligibilityReady
  → AvailabilityProjected
  → Search / Cache / Detail Page refresh

这样做的好处是,运营后台可以清楚解释“商品为什么不能卖”:

商品已发布,但不可售:
- 库存创建任务失败:券码文件存在重复码
- 计价上下文未刷新:基础价版本落后
- 营销活动绑定失败:活动预算已关闭
- 搜索索引落后:等待 Outbox 补偿重放

26.2.3 供给控制面与事实数据面的边界

供给平台负责让变更安全进入平台,但不能替代各个事实系统。边界可以这样理解:

系统在供给链路里的角色权威事实
供给运营平台入口、任务、暂存、校验、审核、发布编排、补偿和审计供给流程事实
商品中心正式商品主数据、交易前契约、发布版本和快照商品定义事实
库存系统库存实例、券码池、预占、扣减、释放和账本库存事实
计价系统基础价、渠道价、优惠叠加、试算和结算价价格事实
营销系统活动、券、补贴、预算、营销库存和优惠规则营销事实
搜索系统可检索投影、召回、排序和索引版本搜索读模型
订单系统商品快照、报价快照、履约和退款契约快照订单交易事实

这里的关键不是“供给平台能不能调用别的系统”,而是“调用表达什么语义”。供给平台可以发起 CreateInventoryBindProductToCampaignPublishProductVersion 这类业务命令;但不能直接更新库存余额、直接写 ES、直接写最终成交价,也不能修改订单状态。

26.2.4 库存创建 / 修改的运营归属

库存创建和修改属于供给运营平台的业务工作,但不属于供给运营平台的数据事实。原因很简单:库存动作往往带有强运营属性。

场景为什么需要供给运营平台承接
简单数量库存随商品发布创建需要和商品类目、Offer、销售范围、扣减时机一起校验
后台补货 / 调库存 / 锁库存需要权限、审批、操作原因、风险提示和审计
手动上传券码需要文件上传、行级错误、重复码提示、错误文件和任务进度
系统生成券码需要生码规则、数量、有效期、审批和批次追踪
门店 / 日期 / 时段库存需要门店范围、营业时间、节假日、批量复制和局部调整
批量编辑库存需要异步任务、部分成功、失败重试和运营可见进度

所以更准确的说法是:

供给运营平台:负责库存任务的入口、审批、编排、进度、错误文件和审计
库存系统:负责库存实例、余额、券码状态机、预占、扣减、释放和账本

库存任务会在 26.6 单独展开。这里先建立一个原则:供给平台发起库存命令,库存系统幂等执行库存事实变更。

26.2.5 发布后的最终一致与可售投影

26.2.6 三个关键架构决策

这一章里最容易反复争论的,其实不是字段怎么命名,而是三个结构性问题:草稿放哪里、QC 放哪里、QC 状态和草稿状态是否合一。

决策点一:草稿应放在哪里

推荐把 Draft 放在商品中心,而不是供给平台。

原因有三点:

  1. Draft 最终要 Merge 到正式商品,放在同域内更容易做本地事务和版本控制。
  2. Diff 强依赖草稿和线上正式版本的对比,放在商品中心可以避免跨服务高频读取。
  3. 发布成功后的缓存刷新、索引更新和交易契约生效,都更适合由商品中心在同一条发布链路中完成。

代价是商品中心写流量会上升,但这个问题可以通过草稿表和正式表分表、读写分离、冷热隔离缓解。

决策点二:为什么不能设计成“供给 -> QC -> 商品”

这个流程看起来顺手,但有两个致命问题:

  1. Diff 无法本地完成
    如果 QC 位于供给平台后面,就必须频繁回查商品中心线上数据;批量场景下会制造跨服务读风暴。

  2. 审核结果可能被并发污染
    QC 审通过的是供给侧某个瞬间的版本;但在结果写回商品中心前,供给侧数据可能又被改写,导致“审核的是旧版本,生效的是新版本”。

更稳的链路应该是:

供给平台
  → 商品中心写草稿并锁定快照
  → 发送送审事件
  → QC 旁路审核
  → QC 返回 PASS / REJECT
  → 商品中心本地事务合流

决策点三:QC 状态与草稿状态是否要分开

结论是要分开。

商品中心草稿表维护“货品视角”的粗粒度状态,例如:

字段含义示例
draft_id草稿 ID10001
goods_id对应正式商品 ID88888
audit_status草稿审核状态DRAFT / PENDING / PASS / REJECT
biz_version乐观锁版本5

QC 中心工单表维护“审批流视角”的细粒度状态,例如:

字段含义示例
task_id工单 ID90001
source_ref_id关联草稿 ID10001
task_status审批流状态MACHINE_REVIEW / HUMAN_QUEUE / HUMAN_REVIEW / DONE
reject_reason驳回原因标题包含敏感词

这样做的好处是:

  • 商品中心只关心“这个草稿能不能合流到正式商品”。
  • QC 中心可以自由演进机审、人审、挂起、申诉等复杂流程。
  • 避免 QC 的高频状态变更持续写爆商品中心数据库。

供给发布事务内只做商品中心必须强一致的事情:写正式商品主数据、交易前契约、发布版本、发布快照、变更日志和 Outbox。事务外再由不同系统异步完成读模型和可售能力刷新。

Publish Transaction
  → ProductPublished Outbox
  → Inventory Command / Inventory Event
  → Pricing Context Consumer
  → Marketing Command / Eligibility Event
  → Search Indexer / Cache Invalidator
  → Availability Projector

可售投影不替代任何事实系统。它只回答一个面向交易入口的问题:当前这个商品,在这个渠道、这个城市、这个门店、这个时间点,能不能展示、能不能下单、为什么不能下单。

Sellable =
  product_status == ONLINE
  AND now in sale_time_window
  AND inventory_status in READY/AVAILABLE
  AND price_status == READY
  AND marketing_status in READY/NONE_REQUIRED
  AND fulfillment_status == READY
  AND channel_policy allows current channel
  AND risk_status not in BLOCKED

一个成熟平台最需要避免的反模式是:

  1. 供给后台直接改库存余额,绕过库存账本。
  2. 库存系统直接决定商品上下架,绕过发布版本和审核。
  3. 商品发布事务同步调用 ES、计价、营销和订单,导致发布链路被下游拖垮。
  4. 把活动价、最终优惠金额写回商品表,导致计价口径和营销成本无法解释。
  5. 历史订单回读最新商品配置,导致售后和财务无法复盘。

26.3 商品生命周期管理

26.3.1 状态归属原则

商品供给系统最容易犯的错误,是把 Draft、Staging、QC、正式商品状态都塞进一个 status 字段。这样一来,状态很快会变成“大杂烩”:DRAFTQC_PENDINGONLINEREJECTEDPUBLISHING 同时出现在同一张表里,最后没人说得清这个状态到底是在描述“编辑工作区”“提交快照”“审核工单”,还是“线上商品”。

更稳的建模方式是:谁拥有生命周期,谁拥有状态字段

对象状态回答的问题典型状态
Draftproduct_supply_draft这份草稿是否还能编辑DRAFT/SUBMITTED/DISCARDED/ARCHIVED
Stagingproduct_supply_staging这份提交快照走到校验、审核、发布的哪一步VALIDATED/QC_PENDING/APPROVED/PUBLISH_PENDING/PUBLISHED/REJECTED/WITHDRAWN/CANCELLED/VERSION_CONFLICT
QC Reviewproduct_qc_review这张审核单是否被批准、驳回或撤销PENDING/REVIEWING/APPROVED/REJECTED/CANCELLED/PUBLISHED
Product Itemproduct_item_tab 或商品中心正式表这个正式商品在线上是否可见、可售、可归档PUBLISHED/ONLINE/OFFLINE/ENDED/BANNED/ARCHIVED
Task / Task Itemproduct_supply_taskproduct_supply_task_item一次同步、导入、编辑任务执行到哪里RUNNING/VALIDATING/QC_REVIEWING/PUBLISHING/PARTIAL_FAILED/SUCCESS

一个商品可以同时有多套状态,但它们属于不同对象:

正式商品:
  item_id = item_80001
  item_status = ONLINE
  publish_version = 3

编辑草稿:
  draft_id = draft_20001
  draft_status = DRAFT

待审提交:
  staging_id = stg_20001
  staging_status = QC_PENDING

审核单:
  review_id = qc_20001
  qc_status = PENDING

这不是重复设计,而是避免“一个字段表达四种语义”。正式 item_tab 不应该出现 DRAFTQC_PENDINGREJECTED 这类供给流程状态;新建商品在发布前甚至还没有正式 item_id

26.3.2 六套核心状态机

26.3.2.1 Draft 状态机

Draft 是编辑工作区,允许反复保存。它不进入审核,也不代表线上商品。

stateDiagram-v2
  [*] --> DRAFT: 创建草稿
  DRAFT --> DRAFT: 保存修改
  DRAFT --> SUBMITTED: 提交生成 Staging
  DRAFT --> DISCARDED: 放弃草稿
  SUBMITTED --> ARCHIVED: 发布成功或历史归档
  DISCARDED --> [*]
  ARCHIVED --> [*]

Draft 状态说明:

状态含义是否可编辑
DRAFT未提交草稿
SUBMITTED已提交并生成 Staging
DISCARDED用户主动丢弃
ARCHIVED发布成功或历史归档

如果 Pending 后撤回或 Rejected 后修改,推荐基于原 Staging 生成新的 Draft,而不是直接修改已提交 Draft。

26.3.2.2 Staging 状态机

Staging 是提交快照,进入校验、风险评估、审核和发布。它的业务 payload 应该冻结,流程字段可以变化。

stateDiagram-v2
  [*] --> VALIDATED: 后端强校验通过
  VALIDATED --> QC_PENDING: 需要 QC
  VALIDATED --> APPROVED: 自动准入
  QC_PENDING --> QC_REVIEWING: 审核员领取
  QC_REVIEWING --> QC_APPROVED: QC 通过
  QC_REVIEWING --> REJECTED: QC 驳回
  QC_PENDING --> WITHDRAWN: Merchant 撤回
  QC_REVIEWING --> CANCELLED: QC/系统撤销
  QC_APPROVED --> PUBLISH_PENDING: 等待发布
  APPROVED --> PUBLISH_PENDING: 等待发布
  PUBLISH_PENDING --> PUBLISHED: 发布成功
  PUBLISH_PENDING --> VERSION_CONFLICT: 线上版本已变化
  REJECTED --> [*]
  WITHDRAWN --> [*]
  CANCELLED --> [*]
  VERSION_CONFLICT --> [*]
  PUBLISHED --> [*]

Staging 状态说明:

状态含义
VALIDATED提交快照已通过后端强校验
QC_PENDING等待 QC 审核
QC_REVIEWINGQC 审核中
QC_APPROVEDQC 通过,但还未进入发布等待区
APPROVED自动准入通过
PUBLISH_PENDING允许发布,等待自动、手动或定时发布
PUBLISHED已发布为正式商品版本
REJECTEDQC 驳回
WITHDRAWNMerchant 主动撤回
CANCELLEDQC、系统或任务主动撤销
VERSION_CONFLICT编辑基于的 base_publish_version 已过期

26.3.2.3 QC 状态机

QC Review 是审核工单,不保存完整商品正文,只保存审核对象、风险原因、审核结论、审核人和驳回原因。

stateDiagram-v2
  [*] --> PENDING: 创建审核单
  PENDING --> REVIEWING: 审核员领取
  REVIEWING --> APPROVED: 审核通过
  REVIEWING --> REJECTED: 审核驳回
  PENDING --> CANCELLED: Merchant/QC/System 撤销
  REVIEWING --> CANCELLED: Merchant/QC/System 撤销
  APPROVED --> PUBLISHED: 对应 Staging 发布成功
  REJECTED --> [*]
  CANCELLED --> [*]
  PUBLISHED --> [*]

QC 状态说明:

状态含义
PENDING等待审核
REVIEWING审核中
APPROVED审核通过,允许进入发布
REJECTED审核驳回,展示给商家或运营修复
CANCELLED审核单被撤销,不计入驳回率
PUBLISHED对应提交已发布成功

REJECTEDCANCELLED 要严格区分:前者代表内容不合规,后者代表审核单不应该继续处理,例如重复单、任务取消、版本冲突或审核路由错误。

26.3.2.4 正式 Item 状态机

正式 Item 是商品中心里的线上资产。它只关心商品是否可见、可售、下架、封禁或归档,不关心草稿是否提交、QC 是否驳回。

stateDiagram-v2
  [*] --> PUBLISHED: 发布正式版本
  PUBLISHED --> ONLINE: 满足销售开始时间和可售规则
  PUBLISHED --> OFFLINE: 发布但暂不售卖
  ONLINE --> OFFLINE: 商家下线或运营下线
  ONLINE --> ENDED: 销售期结束
  ONLINE --> BANNED: 平台封禁
  OFFLINE --> ONLINE: 重新上线
  ENDED --> ONLINE: 修改销售期并重新发布
  BANNED --> OFFLINE: 解封但不可售
  OFFLINE --> ARCHIVED: 归档
  ENDED --> ARCHIVED: 归档
  ARCHIVED --> [*]

正式 Item 状态说明:

状态含义
PUBLISHED已有正式版本,但还未满足上线条件
ONLINEC 端可见且可下单
OFFLINE人工下线或暂不售卖
ENDED销售期结束
BANNED平台封禁,不允许商家直接上线
ARCHIVED归档,只保留历史查询和审计

正式商品表建议把“商品生命周期状态”和“交易可售状态”拆开:

product_item_tab.item_status:
  PUBLISHED / ONLINE / OFFLINE / ENDED / BANNED / ARCHIVED

product_item_tab.sellable_status:
  SELLABLE / UNSALEABLE / NOT_STARTED / SOLD_OUT / EXPIRED / RISK_BLOCKED

product_item_tab.publish_version:
  当前正式发布版本

item_status 描述商品资产是否上线、下线、封禁、归档;sellable_status 描述当前是否允许交易。比如商品可以是 ONLINE,但因为库存为 0 而 sellable_status=SOLD_OUT

对于编辑已上线商品,正式 Item 通常保持 ONLINE,新的 Draft、Staging、QC 在供给侧流转。只有发布事务成功后,正式 Item 的 publish_version 才递增。

26.3.2.5 Task 状态机

批量任务除了商品对象本身的状态机,还应该有独立的 task 状态机。task 负责表达整批任务当前所处阶段,并为后台展示、超时回收、重试补偿、报告生成提供统一锚点。

批量 Task 是执行编排对象。它不描述“商品是否在线”,而描述“一整批导入、编辑、审核、发布任务现在走到了哪一步”。

stateDiagram-v2
  [*] --> PENDING: 创建任务
  PENDING --> PARSING: Parser 抢占
  PARSING --> RUNNING: 切分 task_item 完成
  RUNNING --> QC_REVIEWING: 存在高风险 item
  RUNNING --> PUBLISHING: 已有 item 进入发布
  QC_REVIEWING --> PUBLISHING: 审核通过进入发布
  PUBLISHING --> SUCCESS: 全部发布成功
  PUBLISHING --> PARTIAL_FAILED: 部分发布失败
  RUNNING --> FAILED: 全部失败或审核整体驳回
  QC_REVIEWING --> FAILED: 审核整体驳回
  PUBLISHING --> FAILED: 发布整体失败
  PENDING --> CANCELLED: 人工取消
  PARSING --> CANCELLED: 人工取消或超时回收
  RUNNING --> CANCELLED: 人工取消或超时回收
  QC_REVIEWING --> CANCELLED: 人工取消或超时回收
  PUBLISHING --> CANCELLED: 人工取消或超时回收
  SUCCESS --> [*]
  PARTIAL_FAILED --> [*]
  FAILED --> [*]
  CANCELLED --> [*]

Task 状态说明:

状态含义
PENDING任务已创建,等待 Parser 抢占
PARSING正在解析源文件、生成 task_item
RUNNINGitem 已开始异步处理
QC_REVIEWING存在高风险 item 等待审核
PUBLISHING已有通过准入的 item 进入发布
SUCCESS全部 item 成功发布
PARTIAL_FAILED部分成功,部分失败 / 驳回 / DLQ
FAILED整批失败、审核整体驳回,或最终没有可用结果
CANCELLED人工取消或超时回收结束

Task 状态通常由 item 聚合而来,但不等于简单计数求和。一个实用的聚合规则如下:

Item 汇总结果Task 状态
全部 SUCCESSSUCCESS
部分 SUCCESS,部分 FAILED/DLQ/REJECTEDPARTIAL_FAILED
全部失败或全部被驳回FAILED
存在 QC_PENDING/QC_REVIEWINGQC_REVIEWING
存在 PUBLISHINGPUBLISHING
尚未开始处理,仅完成建 taskPENDING
Parser 已抢占但未完成切 itemPARSING
item 已启动执行但未进入 QC / 发布RUNNING

26.3.2.6 Task Item 状态机

task_item 是批量任务里的行级执行单元,它描述“这一行数据在标准化、校验、审核、发布中的推进情况”,不应和正式商品状态混在一起。

Task Item 关注的是“这一行数据现在处在处理链路的哪一环”,因此状态机会比 Task 更细,但仍应保持单向推进为主。

stateDiagram-v2
  [*] --> PENDING: 生成行级任务
  PENDING --> NORMALIZING: Worker 抢占
  NORMALIZING --> VALIDATING: 标准化完成
  VALIDATING --> STAGING: 校验通过
  STAGING --> DIFFING: 写入 staging
  DIFFING --> QC_PENDING: 命中高风险规则
  DIFFING --> PUBLISHING: 自动准入
  QC_PENDING --> QC_REVIEWING: 审核员领取
  QC_REVIEWING --> QC_APPROVED: 审核通过
  QC_APPROVED --> PUBLISHING: 进入发布
  PUBLISHING --> SUCCESS: 发布成功
  NORMALIZING --> FAILED: 标准化失败
  VALIDATING --> FAILED: 校验失败
  STAGING --> FAILED: staging 写入失败
  DIFFING --> FAILED: diff 失败
  QC_REVIEWING --> FAILED: 审核驳回
  PUBLISHING --> FAILED: 发布失败
  FAILED --> DLQ: 超过重试阈值
  PENDING --> SKIPPED: 幂等命中 / 任务取消
  FAILED --> SKIPPED: 冲突策略跳过
  SUCCESS --> [*]
  DLQ --> [*]
  SKIPPED --> [*]

Task Item 状态说明:

状态含义
PENDING已生成行级任务,等待消费
NORMALIZING正在按模板做标准化
VALIDATING正在执行结构、主数据、交易契约校验
STAGING已写入 staging,准备做 diff
DIFFING正在和线上版本比较差异
QC_PENDING命中高风险规则,等待审核
QC_REVIEWING审核中
QC_APPROVED审核通过,等待进入发布
PUBLISHING正在发布正式版本
SUCCESS已成功完成发布
FAILED执行失败,可重试或导出错误文件
DLQ多次失败后进入死信队列
SKIPPED被幂等、撤销、冲突策略跳过

26.3.3 状态联动规则

六套状态机不是互相复制,而是通过明确动作联动。

动作DraftStagingQC Review正式 Item
新建草稿DRAFT
提交草稿SUBMITTEDVALIDATED/QC_PENDING/APPROVED按策略创建 PENDING 或不创建新建商品无 item_id;编辑商品不变
QC 领取不变QC_REVIEWINGREVIEWING不变
QC 通过不变QC_APPROVEDPUBLISH_PENDINGAPPROVED不变
QC 驳回不变REJECTEDREJECTED新建商品仍无 item_id;编辑商品旧版本继续在线
Merchant 撤回新建或恢复可编辑 DraftWITHDRAWNCANCELLED不变
QC/系统撤销cancel_action 决定CANCELLED/VERSION_CONFLICTCANCELLED不变
发布成功ARCHIVEDPUBLISHEDPUBLISHED 或无 QC创建或更新正式 Item,递增 publish_version
下线无关无关无关ONLINE → OFFLINE/ENDED/BANNED

发布前要做 CAS 校验:

Staging.base_publish_version == Item.current_publish_version

如果不相等,说明有人已经发布了更新版本,当前 Staging 不能继续发布,应进入 VERSION_CONFLICT,并要求基于最新版本重新编辑。

26.3.4 状态日志与生命周期事件

每个对象都要记录自己的状态变化,但落库可以收敛到通用操作流水和正式变更日志,避免为 Draft、Staging、QC 各建一套高度相似的日志表。

日志记录什么
product_supply_operation_logDraft 创建、保存、提交、丢弃、Staging 校验、进入 QC、撤回、驳回、QC 领取、撤销、发布完成
product_publish_record发布批次、发布版本、发布结果
product_change_log正式商品上线、下线、封禁、过期、归档、回滚等发布后变更

日志至少包含:

object_type
object_id
old_status
new_status
operator_type
operator_id
reason
rule_code
supply_trace_id
operation_id
publish_version
created_at

生命周期事件也要分层:

事件触发时机典型消费者
ProductDraftCreatedDraft 创建运营后台
ProductSupplySubmittedDraft 提交并生成 Staging审核系统、通知系统
ProductQcApprovedQC 通过发布 Worker、通知系统
ProductQcRejectedQC 驳回商家 Portal、运营后台
ProductPublished正式版本发布成功搜索索引、缓存、计价上下文、数据平台、营销资格消费者
ProductMarketingEligibilityChanged商品活动资格、圈品范围或活动标签变化营销系统
ProductOnline正式商品上线搜索、推荐、营销资格消费者
ProductOffline正式商品下架搜索、订单前校验、运营看板
ProductArchived正式商品归档数据平台、审计系统

对搜索、缓存、计价上下文这类读侧投影来说,真正有交易意义的是 ProductPublished/ProductOnline/ProductOffline。营销系统既可以消费商品发布事件更新活动资格,也可以接收供给平台发起的活动配置命令,但供给平台不直接写营销规则和优惠计算结果。Draft、Staging、QC 事件主要服务于 B 端运营、审核、通知和审计。

事件发布建议走 Outbox:

更新商品状态 / 写发布版本
  → 同事务写 product_outbox_event
  → Dispatcher 投递 Kafka
  → 消费者按 event_id 幂等处理

消费者侧要使用 publish_version 或事件版本防止旧事件覆盖新状态。

26.3.5 从 Draft 到下线的端到端流程

商品生命周期可以按“供给侧对象”和“商品中心正式对象”两条线理解:

供给侧对象:
Draft
  → Staging Ticket
  → QC Ticket
  → Publish Record
  → Operation Log

商品中心正式对象:
item_id
  → publish_version
  → item_status / sellable_status

新建商品在 Draft、Staging、QC 阶段没有正式 item_id;编辑已有商品时,Draft 和 Staging 会指向已有 item_idbase_publish_version。无论创建还是编辑,supply_trace_id 都用于串起同一个商品生命周期,operation_id 用于标识一次创建、一次编辑、一次下线或一次重新上线操作。正式 item_tab 只保存正式商品资产状态,不保存 Draft、QC Pending、Rejected 这些供给流程状态。

26.3.5.1 新建商品:Create Draft

Merchant 或 Local Ops 创建商品时,供给平台先创建 Draft,而不是直接创建商品中心正式商品。

点击 Create
  → 后端生成 supply_trace_id
  → 后端生成 operation_id
  → 后端生成 draft_id
  → 保存 draft_payload
  → Draft.status = DRAFT

新建 Draft 示例:

{
  "draft_id": "draft_10001",
  "draft_type": "CREATE",
  "supply_trace_id": "pst_90001",
  "operation_id": "op_10001",
  "item_id": null,
  "base_publish_version": null,
  "temporary_object_key": "tmp_item_10001",
  "source_type": "MERCHANT",
  "merchant_id": "merchant_001",
  "operator_id": "user_001",
  "category_code": "LOCAL_SERVICE",
  "draft_payload": {
    "item_name": "KFC Voucher 50K",
    "market_price": 70000,
    "discount_price": 50000,
    "stock": 1000,
    "redeem_methods": ["BSC", "CSB"]
  },
  "status": "DRAFT"
}

这里最重要的是:

字段新建 Draft 的含义
supply_trace_id商品生命周期追踪 ID,首次创建时生成,后续编辑复用
operation_id本次创建操作 ID,每次操作新生成
draft_id草稿 ID,每份草稿新生成
item_id为空,因为还没有正式商品
temporary_object_key创建前临时对象键,用于 Staging、QC 和后续映射

26.3.5.2 商家提交:Draft 到 Staging / QC

商家提交 Draft 后,系统不直接审核 Draft,而是生成一份不可随意修改的 Staging Ticket。QC Ticket 指向 Staging Ticket。

Draft 是工作区,允许商家反复保存、修改、预览;Staging Ticket 是提交快照,用来承载本次审核和发布。提交之后不能直接修改 Staging 的业务 payload,否则会出现“QC 审核的是 A,最终发布的是 B”的问题。

Merchant Submit Draft
  → 后端强校验
  → 标准化 payload
  → 生成 staging_ticket_id
  → 生成 change_id
  → 判断 qc_policy
  → 创建 qc_ticket_id
  → Draft.status = SUBMITTED
  → Staging.status = QC_PENDING
  → QC.status = PENDING

新建商品提交后:

staging_ticket_id = stg_10001
qc_ticket_id = qc_10001
supply_trace_id = pst_90001
operation_id = op_10001
item_id = NULL
temporary_object_key = tmp_item_10001
qc_policy = QC_REQUIRED

商家创建商品默认进入 QC。Local Ops 创建商品默认自动准入,但也必须经过 Staging、Validation、Publish,不允许绕过发布事务直接写正式表。

MERCHANT:
  Validation Passed
    → QC_REQUIRED
    → QC Ticket

LOCAL_OPS:
  Validation Passed
    → AUTO_APPROVE
    → Publish

如果同一次编辑里既有“不需要 QC”的字段,又有“需要 QC”的字段,整份 Staging 应该一起等 QC 通过后发布,不能先发布一部分字段。否则同一次操作会拆成多个线上版本,审计和用户体验都会变复杂。

Staging 可以更新的是流程字段:

status
qc_status
publish_status
reviewer_id
reject_reason
published_at

Staging 不应该直接更新的是商品业务字段:

item_name
image_list
price
stock_rule
available_store_ids
fulfillment_rule
refund_rule

如果商家在 Pending 阶段发现内容填错,不能直接编辑这份待审 Staging,而应该走“撤回后编辑”的流程。

26.3.5.3 QC 通过后:自动发布或等待手动 Publish

QC 通过只代表“允许发布”,不一定代表“已经发布”。是否立即发布由 publish_policy 决定。

QC APPROVED
  → publish_policy = AUTO_PUBLISH
      → Publish Worker 自动发布

QC APPROVED
  → publish_policy = MANUAL_PUBLISH
      → Staging.status = PUBLISH_PENDING
      → 等商家或运营点击 Publish

推荐发布策略:

策略含义适用场景
AUTO_PUBLISHQC 通过后自动进入发布事务普通商家商品、低风险运营商品
MANUAL_PUBLISHQC 通过后等待点击 Publish活动商品、需要商家确认上线窗口
SCHEDULED_PUBLISH到指定时间自动发布大促、预售、定时上新

26.3.5.4 Publish 背后的实际流程

Publish 是供给链路到交易链路的边界动作。它把 Staging 数据转换成商品中心正式模型,并生成版本、快照和下游刷新事件。

发布前必须重新校验:

1. Staging.status 是否允许发布。
2. QC.status 是否 APPROVED,或 qc_policy 是否 AUTO_APPROVE。
3. operation_id / staging_ticket_id 是否已经发布过。
4. 编辑场景下 base_publish_version 是否等于线上当前版本。
5. 商品是否被删除、冻结、封禁。
6. 库存、券码池、门店、履约、退款、结算信息是否完整。

发布事务:

BEGIN
  → 新建商品:生成 item_id
  → 编辑商品:锁定 item_id 当前版本
  → 写 product_item
  → 写价格、库存配置、门店映射
  → 写履约规则、退款规则、输入 Schema
  → 生成 new_publish_version
  → 写 product_publish_snapshot
  → 写 product_change_log
  → 写 product_outbox_event
  → 写 product_publish_record
COMMIT

新建商品发布成功后:

temporary_object_key = tmp_item_10001
  → item_id = item_80001
  → publish_version = 1

编辑商品发布成功后:

item_id = item_80001
publish_version: 3 → 4

正式 item_id 不变,只递增 publish_version。发布成功后,供给平台要把 Staging、QC、Task、Draft 状态推进到完成态:

Staging.status = PUBLISHED
QC.status = PUBLISHED
TaskItem.status = SUCCESS
Task.status = PUBLISHED 或 PARTIAL_FAILED
Draft.status = ARCHIVED

26.3.5.5 编辑在线商品:Edit Active Item

商品已经在线后,商家或运营再次编辑,必须基于正式 item_id 和当前 publish_version 创建新的编辑 Draft。

打开 Active 商品
  → 读取 item_id
  → 读取 current_publish_version
  → 反查 supply_trace_id
  → 新建 operation_id
  → 新建 edit_draft_id
  → 预填当前线上版本

编辑 Draft 示例:

{
  "draft_id": "draft_20001",
  "draft_type": "EDIT",
  "supply_trace_id": "pst_90001",
  "operation_id": "op_20001",
  "item_id": "item_80001",
  "base_publish_version": 3,
  "source_type": "MERCHANT",
  "draft_payload": {
    "item_name": "KFC Voucher 50K - Weekend Special",
    "discount_price": 48000,
    "add_stock": 200
  },
  "changed_fields": [
    {
      "field": "item_name",
      "old": "KFC Voucher 50K",
      "new": "KFC Voucher 50K - Weekend Special",
      "need_qc": true
    },
    {
      "field": "discount_price",
      "old": 50000,
      "new": 48000,
      "need_qc": true
    },
    {
      "field": "add_stock",
      "old": null,
      "new": 200,
      "need_qc": false
    }
  ],
  "qc_policy": "QC_REQUIRED",
  "status": "DRAFT"
}

编辑 Active 商品时的 ID 规则:

ID是否新建说明
supply_trace_id复用原商品生命周期 ID
item_id正式商品 ID 不变
operation_id一次编辑一个新操作
draft_id一份编辑草稿
staging_ticket_id一份待发布快照
qc_ticket_id按策略商家编辑默认创建,本地运营默认不创建
publish_version发布后递增3 → 4

26.3.5.6 QC 驳回、撤回和重新提交

QC 驳回后,不修改正式商品。对于新建商品,因为还没有 item_id,只影响 Staging 和 Draft;对于编辑商品,线上旧版本继续售卖。

这里要区分三种容易混淆的动作:

动作发起方业务含义QC 状态Staging 状态Merchant 端展示后续动作
Merchant 撤回商家商家主动终止本次待审提交CANCELLEDWITHDRAWN回到 Draft 或从 Pending 消失修改后重新提交
QC 驳回审核员本次提交内容不符合平台要求REJECTEDREJECTEDRejected Tab 展示驳回原因点击 Revise 生成新 Draft
QC 主动撤销审核员/系统这张审核单不应该继续审核CANCELLEDCANCELLED通常不进 Rejected Tab按撤销原因返回 Draft、关闭或转风险单

QC 驳回用于表达“内容不通过”,例如图片违规、标题敏感、资质缺失、退款规则不符合平台要求。驳回必须带结构化原因,最好能落到字段级别:

QC REJECTED
  → QC.status = REJECTED
  → Staging.status = REJECTED
  → 写 product_qc_review_item.reject_reason
  → 写 product_supply_operation_log(QC_REJECTED)
  → 通知 Merchant
  → Merchant 在 Rejected Tab 看到 Staging Ticket
  → 点击 Revise
  → 基于 rejected staging 生成新的 Draft 或恢复到 Draft
  → 修改后重新提交

QC 驳回不应该自动生成新 Draft。原因是驳回只是审核结论,是否修改、怎么修改,应该由商家或运营确认后再创建新草稿。这样可以避免系统自动生成大量无人处理的 Draft。

QC 主动撤销不是驳回。它适用于“审核单本身不应该继续走下去”的场景:

场景为什么不是驳回推荐处理
商家已发起撤回,但 QC 页面还未刷新商家主动终止,不是内容不合规cancel_source=MERCHANT,Staging WITHDRAWN
重复提交了两张相同审核单不是商品内容问题保留最新单,旧单 CANCELLED
商家账号、门店或类目权限失效审核对象前置条件已失效CANCELLED,必要时创建风险单
任务被运营取消批量任务不再执行关联 TaskItem 标记 CANCELLED
审核策略配置错误,需要重新路由原审核队列不正确CANCELLED 后重新生成 QC Ticket
线上版本已变化,当前 Staging 过期base_publish_version 不再匹配CANCELLEDVERSION_CONFLICT,要求重新编辑

QC 主动撤销流程:

QC Cancel
  → 校验 QC.status IN (PENDING, REVIEWING)
  → 填写 cancel_reason
  → QC.status = CANCELLED
  → QC.cancel_source = QC 或 SYSTEM
  → QC.cancel_reason = ...
  → Staging.status = CANCELLED
  → 写 product_supply_operation_log(QC_CANCELLED)
  → 根据 cancel_action 决定后续动作

cancel_action 可以设计成:

cancel_action含义适用场景
RETURN_TO_DRAFT回到草稿,允许修改后重新提交审核策略错误、资料需补充
CLOSE_ONLY只关闭审核单,不生成草稿重复单、任务取消
CREATE_RISK_CASE转成风险/合规问题单商家资质失效、疑似违规
RECREATE_QC重新生成审核单并路由到正确队列审核队列配置错误

Merchant 也可以在 Pending 阶段撤回:

Withdraw
  → QC.status = CANCELLED
  → QC.cancel_source = MERCHANT
  → QC.cancel_reason = merchant withdraw
  → Staging.status = WITHDRAWN
  → 基于 Staging 生成新的 Draft,或恢复原 Draft
  → OperationLog 记录 WITHDRAWN

Pending 阶段的编辑规则建议设计成:

当前状态是否直接编辑 Staging推荐动作
DRAFT不涉及直接编辑 Draft,保存或提交
QC_PENDING不允许查看详情、撤回、基于 Staging 生成新 Draft
REJECTED不允许改原 Staging点击 Revise,生成新 Draft 后重新提交
APPROVED 但未发布不建议改原 StagingPublish、Withdraw,或创建新 Revision
PUBLISHED不允许改历史 Staging基于正式 item_id 创建编辑 Draft

如果产品希望 Pending 页面也展示 Edit 按钮,底层语义也应该是:

Edit Pending
  = Withdraw 当前 QC Ticket
  + Staging.status = WITHDRAWN
  + 基于当前 Staging payload 生成 draft_new
  + 用户编辑 draft_new
  + Submit 后生成 stg_new 和 qc_new

示例:

draft_10001
  → submit
  → stg_10001
  → qc_10001(PENDING)

用户发现内容有误
  → withdraw qc_10001
  → stg_10001 = WITHDRAWN
  → draft_10002 基于 stg_10001 生成
  → submit draft_10002
  → stg_10002
  → qc_10002(PENDING)

撤回和驳回都不影响正式商品表。对于 Active 商品编辑,Active Tab 仍然展示当前线上版本;Pending / Rejected Tab 展示 Staging Ticket 中的待审或驳回版本。

26.3.5.7 商品下线:Offline / Ended / Ban

下线不是删除商品。下线只是让商品不再对 C 端可见或不可下单,历史订单、核销、退款、结算仍然要能查到商品快照。

下线触发来源:

触发来源示例处理方式
Merchant 主动下线商家点击 Deactivate校验权限,更新商品状态为 OFFLINE
Ops Ban平台审核发现违规更新状态为 BANNED/OFFLINE,记录 ban reason
系统自动过期销售结束时间已过系统任务更新为 ENDED/OFFLINE
库存不可售库存为 0 或券码池为空可进入 SOLD_OUT 或保持在线但不可下单
风控拦截敏感内容、资质问题强制下线并通知商家修复

Merchant 主动下线流程:

点击 Deactivate
  → 校验商品属于该商家
  → 校验商品未被锁定发布中
  → 生成 operation_id
  → 写状态变更记录
  → BEGIN
      → item.status = OFFLINE
      → 写 product_status_log
      → 写 product_outbox_event(ProductOffline)
    COMMIT
  → 搜索下架 / 缓存失效 / 订单前校验不可下单

Ops Ban 流程:

Ops Ban
  → 选择 ban_reason
  → item.status = BANNED
  → sellable_status = UNSALEABLE
  → 写 product_status_log
  → 写 ProductOffline / ProductBanned Outbox
  → Merchant 端展示 Ban Reason

自动过期流程:

定时任务扫描 end_selling_at < now
  → item.status = ENDED
  → sellable_status = UNSALEABLE
  → 写 ProductOffline Outbox

下线后是否能重新上线,要看下线原因:

当前状态是否可重新上线条件
OFFLINE可以商家手动下线且商品未过期
ENDED可以修改销售时间并重新发布
BANNED不可直接上线必须修复后提交 QC 或 Ops 解封
ARCHIVED通常不可只保留历史和审计

26.3.5.8 列表读模型

Merchant Portal 不能只读正式商品表。不同 Tab 的数据源不同:

Tab数据源展示内容
Active正式商品表当前线上版本
Ended / Offline正式商品表已下线、过期、手动停用商品
Draftproduct_supply_draft未提交草稿
Pendingproduct_supply_staging + product_qc_review已提交、待审核、审核中、审核通过待发布版本
Rejectedproduct_supply_staging + product_qc_review被驳回的提交版本和驳回原因

Draft Tab 只读 Draft,不直接读 Staging。Rejected 或 Withdrawn 的 Staging 只有在用户点击 Revise 或 Withdraw 后,才会派生出新的 Draft,进入 Draft Tab。

推荐过滤条件:

Draft Tab:
  product_supply_draft.status = DRAFT

Pending Tab:
  product_supply_staging.status IN (
    QC_PENDING,
    QC_REVIEWING,
    QC_APPROVED,
    APPROVED,
    PUBLISH_PENDING
  )

Rejected Tab:
  product_supply_staging.status = REJECTED
  AND product_qc_review.status = REJECTED

同一个商品可以同时出现在 Active 和 Pending:

Active Tab:
  item_id = item_80001
  展示 publish_version = 3

Pending Tab:
  staging_ticket_id = stg_20001
  展示待审编辑版本
  base_publish_version = 3

这样线上用户继续看到稳定版本,商家也能看到自己提交中的新版本。

26.3.5.9 全链路日志

查看一个商品从 Draft 到下线的完整日志,靠 supply_trace_id 串联:

DRAFT_CREATED
DRAFT_SUBMITTED
VALIDATION_PASSED
QC_CREATED
QC_APPROVED
PUBLISH_STARTED
PUBLISH_SUCCEEDED
PRODUCT_ONLINE
EDIT_DRAFT_CREATED
EDIT_SUBMITTED
QC_REJECTED
EDIT_RESUBMITTED
PUBLISH_SUCCEEDED
PRODUCT_OFFLINE
PRODUCT_REACTIVATED
PRODUCT_ARCHIVED

查询方式:

SELECT *
FROM product_supply_operation_log
WHERE supply_trace_id = ?
ORDER BY created_at ASC;

如果 Merchant 传入的是正式 item_id,后端先查映射表:

SELECT supply_trace_id
FROM product_supply_object_mapping
WHERE item_id = ?;

一句话总结:

Draft / Staging / QC 是供给侧流程对象,item_id / publish_version 是商品中心正式对象。创建商品时先没有 item_id,QC 通过并 Publish 后才生成;编辑商品时复用 item_idsupply_trace_id,新建本次操作的 Draft、Staging、QC;下线只改变正式商品可售状态,不删除历史版本和订单快照。

26.3.6 用 Git 理解供给生命周期

商品供给生命周期和 Git 的版本化协作很像。它们本质上都在解决同一个问题:如何把一次变更变成可审核、可发布、可回滚、可追溯的版本

商品供给链路Git 类比含义
DraftWorking Tree本地正在编辑的工作区
Draft 保存保存文件只是保存工作进度,还没有进入正式历史
Staging TicketCommit Candidate / PR Candidate准备提交给系统审核和发布的一份确定内容
QC ReviewCode Review / PR Review审核这次提交是否允许进入正式版本
PublishMerge / Release正式进入线上商品版本
publish_versionRelease Tag / Commit Version线上版本号
product_publish_snapshotCommit Snapshot某个发布版本的完整内容快照
product_change_logCommit Diff这次版本相对上个版本改了什么
product_supply_operation_logGit Log / Reflog谁在什么时候做了什么
base_publish_versionBase Commit本次编辑基于哪个线上版本
VERSION_CONFLICTRebase Conflict / Merge Conflict编辑基于旧版本,但线上版本已经变化
WithdrawClose PR不继续审核这次提交
RejectedRequest Changes审核没过,需要修改后重新提交

最关键的类比是:

Draft
  ≈ Working Tree

Staging Ticket
  ≈ Commit / PR Candidate

QC
  ≈ Code Review

Publish
  ≈ Merge to main / Release

例如,一个线上商品当前是 publish_version=3,可以类比成主分支当前 commit 是 C3

线上商品 publish_version = 3
  ≈ main 当前 commit = C3

商家编辑 Draft
  ≈ 修改 working tree

提交 Draft 生成 Staging
  ≈ create commit / create PR,base = C3

QC 审核
  ≈ code review

发布成功
  ≈ merge 到 main,生成 C4

如果审核期间线上商品已经被另一次操作发布到了 publish_version=4,当前 Staging 仍然基于 base_publish_version=3,就应该进入 VERSION_CONFLICT

Staging.base_publish_version = 3
Item.current_publish_version = 4
  → VERSION_CONFLICT
  → 要求基于最新版本重新编辑

这和 Git 里的 rebase conflict 或 merge conflict 很像:不是简单拒绝变更,而是要求操作者基于最新版本重新生成 Diff。

不过商品供给比 Git 更复杂。Git 主要管理代码文件;商品供给还会影响价格、库存、履约、退款、搜索缓存、订单快照和供应商映射。因此发布时不能只“合并内容”,还要生成交易前契约、发布快照和 Outbox 事件,确保 C 端可搜、可买、可履约、可售后。


26.4 供给入口与执行方式

26.4.1 同步与异步的取舍

供给平台不能所有动作都异步,也不能所有动作都同步。

场景推荐方式原因
单商品草稿保存同步运营需要立即看到保存结果
单商品提交校验同步为主基础错误要即时反馈
单商品发布可同步也可异步简单品类可同步,复杂品类进入发布任务
批量导入商品异步文件解析、行级错误、部分成功、错误文件
批量编辑价格 / 上下架异步风险高、影响面大,需要进度和审核
供应商全量同步异步长任务,需要 checkpoint、lease、DLQ
供应商 Push 单条变更异步优先需要幂等、削峰、失败补偿

统一抽象:

product_supply_task.execution_mode = SYNC / ASYNC

单商品创建也可以生成 product_supply_task(total_count=1),这样审计、审核、发布记录统一。

26.4.2 来源与 QC 准入策略

商品上传是否需要 QC,不能只看字段风险,还要看来源和操作者信任等级。一个简单但实用的默认策略是:

本地运营上传
  → Validation 通过
  → 自动准入
  → 发布事务

商家上传
  → Validation 通过
  → 默认进入 QC
  → QC 通过后发布

也就是说,本地运营是平台内部可信操作源,默认不需要 QC;商家是外部操作源,默认需要 QC。二者都不能绕过 Validation、Staging、发布版本和 Outbox。

来源示例默认 QC 策略仍然必须做什么
LOCAL_OPS平台本地运营创建商品、上传素材、配置套餐AUTO_APPROVE,不创建 QC 审核单强校验、审计、发布版本、Outbox
MERCHANT商家自助上传门店、套餐、服务商品、图片QC_REQUIRED,默认创建 QC 审核单强校验、字段 Diff、QC 通过后发布
SUPPLIER供应商同步酒店、票务、活动商品按风险分流,低风险自动准入,高风险 QCRaw Snapshot、Diff、字段主导权、补偿
SYSTEM补偿任务、质量修复任务、系统迁移继承原任务策略或按规则准入幂等、审计、可回放

本地运营“不需要 QC”不等于“可以直接写正式表”。它只是跳过人工审核工单,仍然要走:

Draft / Task
  → Staging
  → Validation
  → Diff / Risk
  → AUTO_APPROVE
  → Publish

对于本地运营的超高风险动作,例如大批量改价、退款规则大范围变更、类目迁移,可以不走普通 QC,但要通过更合适的控制手段:

  1. 高权限校验。
  2. 二次确认。
  3. 变更窗口。
  4. 发布后巡检。
  5. 快速回滚。

26.4.3 人工创建

人工创建是“从 0 到 1”生成商品,核心是完整性。这里要区分本地运营创建和商家自助创建:本地运营创建默认自动准入,商家创建默认进入 QC。

选择类目
  → 加载类目模板
  → 填写 Resource / SPU / SKU / Offer / Rule
  → 前端实时校验
  → 后端同步强校验
  → 保存 Draft
  → 提交生成 Staging
  → 质量校验和风险判断
  → 来源准入策略:LOCAL_OPS 自动准入,MERCHANT 进入 QC
  → 发布正式表

人工创建必须一次性收齐交易前契约:

契约示例
商品模型Resource、SPU、SKU、Offer、Rate Plan
库存契约库存来源、券码池、供应商实时库存能力
输入契约手机号、账单号、入住人、乘客证件
履约契约充值、发券、出票、预订确认
售后契约退款规则、取消政策、过期处理

如果这些契约不完整,商品即使写入主表,也不能认为创建成功。

26.4.4 批量导入

批量导入适合大促、类目迁移、商家批量上新、套餐批量配置。

下载模板
  → 上传文件
  → 文件格式预检
  → 创建 product_supply_task(status=PENDING, execution_mode=ASYNC)
  → Parser Worker 流式解析
  → 每行生成 product_supply_task_item
  → Item Worker 分批标准化和校验
  → 按来源生成准入策略
  → LOCAL_OPS 成功项进入发布
  → MERCHANT 成功项进入 QC
  → 失败项生成错误文件
  → 汇总任务状态

批量导入的重点不是“快”,而是:

  1. 可恢复。
  2. 可解释。
  3. 可部分成功。
  4. 可生成错误文件。
  5. 可控制下游压力。

26.4.5 运营编辑

运营编辑是“基于线上版本的变更”,核心是 Diff、风险和主导权。

读取 current_publish_version
  → 创建编辑 Draft
  → 修改字段
  → 提交生成 Staging
  → 与线上版本做 Diff
  → 判断字段主导权
  → 计算风险等级
  → 来源准入策略 / QC 审核 / 阻断
  → 发布新 publish_version

常见风险:

变更风险策略
标题、描述、小图修正自动准入,记录变更日志
普通图片变更低/中图片质量校验后发布
库存水位调整自动校验,通过后发布,异常告警
价格或 Offer 规则变更中高超阈值进入 QC
类目变更强制 QC
履约类型或退款规则变更强制 QC
Resource / Supplier Mapping 变更强制 QC 并触发巡检

26.4.6 供应商同步

供应商同步是自动化程度最高、数据治理要求最强的入口。

它需要独立执行层:

supplier_sync_task
  → supplier_sync_batch
  → Page / Cursor Fetch
  → Raw Snapshot
  → Normalize
  → Supplier Mapping
  → Diff
  → product_supply_staging
  → Publish

供应商同步不应该直接写正式商品表。它应该先保存 Raw Snapshot,再标准化、校验、映射、Diff,然后进入统一发布治理链路。


26.5 核心表模型

供给与运营链路的表设计要围绕十类能力组织:草稿、任务、行级处理、暂存、校验、QC 审核、Diff / Change、发布快照、下游一致性、补偿审计。

重新 review 表模型时,要先确认每张表回答的问题:

问题应该由谁回答
用户正在编辑哪份内容Draft
提交给审核和发布的是哪份冻结快照Staging
这次变更为什么需要审核Change Request / Risk
审核员审核了什么、结论是什么QC Review
为什么不能发布Validation / DLQ
已经发布了哪个正式版本Publish Record / Publish Snapshot
搜索、缓存、计价上下文是否收到变更,营销活动协同是否完成Outbox / Compensation
从 Draft 到下线的全链路日志怎么查Operation Log / Object Mapping

26.5.1 表分组

表组典型表作用
Draft 草稿表product_supply_draftproduct_supply_draft_version保存单商品创建和编辑过程中的草稿
Task 任务表product_supply_task记录一次供给动作
Task Item 明细表product_supply_task_item记录每一行、每个商品、每个 Offer 或每条规则的处理状态
Staging 暂存表product_supply_stagingproduct_supply_staging_snapshot保存已提交、已标准化、但未发布的数据
Validation 校验表product_validation_result保存字段、类目、主数据、交易契约、风险规则的校验结果
QC Review 审核表product_qc_reviewproduct_qc_review_item保存发布前 QC 审核单、审核项、审核结论和驳回原因
Change / Audit 表product_change_requestproduct_supply_operation_logproduct_field_ownership保存 Diff、风险等级、审核策略、字段主导权和操作流水
Publish / Snapshot 表product_publish_recordproduct_publish_snapshotproduct_change_log保存发布批次、商品快照和变更日志
Mapping 表product_supply_object_mapping串联 supply_trace_id、临时对象键和正式 item_id
Outbox / DLQ / Compensation 表product_outbox_eventproduct_supply_dead_letterproduct_compensation_taskproduct_quality_issue保证下游一致性和失败补偿

第一期最小闭环建议:

product_supply_draft
product_supply_task
product_supply_task_item
product_supply_staging
product_validation_result
product_qc_review
product_qc_review_item
product_change_request
product_field_ownership
product_supply_operation_log
product_supply_object_mapping
product_publish_record
product_publish_snapshot
product_change_log
product_outbox_event
product_supply_dead_letter
product_compensation_task
product_quality_issue

二期再补强:

product_supply_draft_version
product_supply_staging_snapshot

26.5.2 Draft 表

Draft 偏编辑态,允许反复保存,不进入审核,不影响线上。

CREATE TABLE product_supply_draft (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    draft_id VARCHAR(64) NOT NULL,
    draft_type VARCHAR(32) NOT NULL COMMENT 'CREATE/EDIT',
    supply_trace_id VARCHAR(64) NOT NULL COMMENT '同一商品供给生命周期追踪 ID',
    operation_id VARCHAR(64) NOT NULL COMMENT '本次创建、编辑、撤回或重新提交操作 ID',
    category_code VARCHAR(32) NOT NULL,
    source_type VARCHAR(32) NOT NULL COMMENT 'LOCAL_OPS/MERCHANT/SUPPLIER/SYSTEM',
    merchant_id VARCHAR(64) DEFAULT NULL,
    operator_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) DEFAULT NULL COMMENT '正式商品 ID,新建发布前为空',
    temporary_object_key VARCHAR(128) DEFAULT NULL COMMENT '新建商品发布前的临时对象键',
    platform_resource_id BIGINT DEFAULT NULL,
    spu_id BIGINT DEFAULT NULL,
    sku_id BIGINT DEFAULT NULL,
    offer_id BIGINT DEFAULT NULL,
    source_staging_id VARCHAR(64) DEFAULT NULL COMMENT '从 Rejected/Withdrawn Staging 派生草稿时记录来源',
    parent_draft_id VARCHAR(64) DEFAULT NULL,
    draft_version INT NOT NULL DEFAULT 1,
    base_publish_version BIGINT DEFAULT NULL,
    draft_payload JSON NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'DRAFT/SUBMITTED/DISCARDED/ARCHIVED',
    created_at DATETIME NOT NULL,
    submitted_at DATETIME DEFAULT NULL,
    archived_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_draft_id (draft_id),
    KEY idx_trace (supply_trace_id),
    KEY idx_item_status (item_id, status),
    KEY idx_operator_status (operator_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给草稿';

26.5.3 Task 表

Task 管一次供给动作的整体状态。

CREATE TABLE product_supply_task (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_id VARCHAR(64) NOT NULL,
    task_type VARCHAR(32) NOT NULL
        COMMENT 'MANUAL_CREATE/MANUAL_EDIT/BATCH_IMPORT/BATCH_EDIT/SUPPLIER_SYNC_IMPORT',
    execution_mode VARCHAR(16) NOT NULL COMMENT 'SYNC/ASYNC',
    source_type VARCHAR(32) NOT NULL COMMENT 'LOCAL_OPS/MERCHANT/SUPPLIER/SYSTEM',
    source_id VARCHAR(64) DEFAULT NULL,
    category_code VARCHAR(32) NOT NULL,
    operator_id VARCHAR(64) DEFAULT NULL,
    supply_trace_id VARCHAR(64) DEFAULT NULL COMMENT '单商品任务可直接关联,多商品任务为空',
    operation_id VARCHAR(64) DEFAULT NULL COMMENT '单商品创建、编辑、上下线操作 ID',
    draft_id VARCHAR(64) DEFAULT NULL,
    operator_trust_level VARCHAR(32) DEFAULT NULL COMMENT 'INTERNAL/TRUSTED/EXTERNAL',
    qc_policy VARCHAR(32) DEFAULT NULL COMMENT 'AUTO_APPROVE/QC_REQUIRED/BLOCK',
    trigger_id VARCHAR(64) DEFAULT NULL,
    template_version VARCHAR(64) DEFAULT NULL,
    status VARCHAR(32) NOT NULL
        COMMENT 'DRAFT/PENDING/PARSING/RUNNING/VALIDATING/QC_PENDING/QC_REVIEWING/QC_APPROVED/APPROVED/PUBLISHING/PUBLISHED/PARTIAL_FAILED/FAILED/CANCELLED',
    total_count INT NOT NULL DEFAULT 0,
    parsed_count INT NOT NULL DEFAULT 0,
    success_count INT NOT NULL DEFAULT 0,
    failed_count INT NOT NULL DEFAULT 0,
    skipped_count INT NOT NULL DEFAULT 0,
    current_stage VARCHAR(64) DEFAULT NULL,
    input_file_ref VARCHAR(512) DEFAULT NULL,
    input_file_name VARCHAR(256) DEFAULT NULL,
    input_file_hash VARCHAR(64) DEFAULT NULL,
    input_file_size BIGINT DEFAULT NULL,
    input_file_content_type VARCHAR(128) DEFAULT NULL,
    parse_checkpoint VARCHAR(1024) DEFAULT NULL,
    error_file_ref VARCHAR(512) DEFAULT NULL,
    error_file_name VARCHAR(256) DEFAULT NULL,
    report_file_ref VARCHAR(512) DEFAULT NULL,
    report_file_name VARCHAR(256) DEFAULT NULL,
    publish_version BIGINT DEFAULT NULL,
    worker_id VARCHAR(64) DEFAULT NULL,
    lease_token VARCHAR(64) DEFAULT NULL,
    lease_until DATETIME DEFAULT NULL,
    heartbeat_at DATETIME DEFAULT NULL,
    created_at DATETIME NOT NULL,
    started_at DATETIME DEFAULT NULL,
    finished_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_task_id (task_id),
    UNIQUE KEY uk_task_trigger (task_type, trigger_id),
    KEY idx_trace (supply_trace_id),
    KEY idx_status (status),
    KEY idx_category_status (category_code, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给任务';

批量导入相关文件元数据直接放在 product_supply_task 中:源文件、错误文件和质量报告都通过 *_file_ref*_file_nameinput_file_hash 这类字段管理。这样第一期模型更简单,运营后台也可以通过 task 直接拿到文件地址和校验信息。

26.5.4 Task Item 表

Task Item 是批量任务的核心表,也是失败定位单元。

CREATE TABLE product_supply_task_item (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_id VARCHAR(64) NOT NULL,
    item_no VARCHAR(64) NOT NULL COMMENT '文件行号、对象序号或外部对象序号',
    item_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE/SPU/SKU/OFFER/RATE_PLAN/STOCK/RULE',
    idempotency_key VARCHAR(128) NOT NULL,
    supply_trace_id VARCHAR(64) DEFAULT NULL,
    operation_id VARCHAR(64) DEFAULT NULL,
    draft_id VARCHAR(64) DEFAULT NULL,
    item_id VARCHAR(64) DEFAULT NULL,
    platform_resource_id BIGINT DEFAULT NULL,
    spu_id BIGINT DEFAULT NULL,
    sku_id BIGINT DEFAULT NULL,
    offer_id BIGINT DEFAULT NULL,
    status VARCHAR(32) NOT NULL
        COMMENT 'PENDING/NORMALIZING/VALIDATING/STAGING/DIFFING/QC_PENDING/QC_REVIEWING/QC_APPROVED/PUBLISHING/SUCCESS/FAILED/DLQ/SKIPPED',
    risk_level VARCHAR(32) DEFAULT NULL COMMENT 'LOW/MEDIUM/HIGH',
    qc_policy VARCHAR(32) DEFAULT NULL COMMENT 'AUTO_APPROVE/QC_REQUIRED/BLOCK',
    error_code VARCHAR(128) DEFAULT NULL,
    error_message VARCHAR(1024) DEFAULT NULL,
    raw_row_ref VARCHAR(512) DEFAULT NULL,
    normalized_ref VARCHAR(512) DEFAULT NULL,
    staging_id VARCHAR(64) DEFAULT NULL,
    change_id VARCHAR(64) DEFAULT NULL,
    retry_count INT NOT NULL DEFAULT 0,
    next_retry_at DATETIME DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_task_item (task_id, item_no),
    UNIQUE KEY uk_task_idempotency (task_id, idempotency_key),
    KEY idx_trace (supply_trace_id),
    KEY idx_item_status (item_id, status),
    KEY idx_task_status (task_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给任务明细';

26.5.5 Staging 表

Staging 是正式表前的隔离层。

CREATE TABLE product_supply_staging (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    staging_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) NOT NULL,
    item_no VARCHAR(64) NOT NULL,
    draft_id VARCHAR(64) DEFAULT NULL,
    supply_trace_id VARCHAR(64) NOT NULL,
    operation_id VARCHAR(64) NOT NULL,
    object_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE/SPU/SKU/OFFER/RATE_PLAN/STOCK/RULE',
    object_key VARCHAR(128) NOT NULL,
    item_id VARCHAR(64) DEFAULT NULL COMMENT '正式商品 ID,新建发布前为空',
    temporary_object_key VARCHAR(128) DEFAULT NULL COMMENT '新建商品发布前的临时对象键',
    source_type VARCHAR(32) NOT NULL,
    change_id VARCHAR(64) DEFAULT NULL,
    qc_policy VARCHAR(32) DEFAULT NULL COMMENT 'AUTO_APPROVE/QC_REQUIRED/BLOCK',
    risk_level VARCHAR(32) DEFAULT NULL COMMENT 'LOW/MEDIUM/HIGH',
    publish_policy VARCHAR(32) DEFAULT NULL COMMENT 'AUTO_PUBLISH/MANUAL_PUBLISH/SCHEDULED_PUBLISH',
    publish_after DATETIME DEFAULT NULL,
    raw_payload_ref VARCHAR(512) DEFAULT NULL,
    normalized_payload JSON NOT NULL,
    payload_hash VARCHAR(64) NOT NULL,
    base_publish_version BIGINT DEFAULT NULL,
    status VARCHAR(32) NOT NULL
        COMMENT 'VALIDATED/QC_PENDING/QC_REVIEWING/QC_APPROVED/APPROVED/PUBLISH_PENDING/PUBLISHED/REJECTED/WITHDRAWN/CANCELLED/VERSION_CONFLICT',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_staging_id (staging_id),
    UNIQUE KEY uk_task_object (task_id, object_type, object_key),
    KEY idx_trace (supply_trace_id),
    KEY idx_item_status (item_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给暂存数据';

base_publish_version 很重要。运营编辑或批量导入可能基于旧版本,如果发布时线上版本已经变化,必须识别冲突,不能静默覆盖。

26.5.6 QC 审核表

QC 审核表位于“标准化校验之后、正式发布之前”。它不是商品正式数据表,而是发布准入工单。商品数据仍然保存在 Draft、Staging、Snapshot 或正式商品表中;QC 表只记录谁审核、审核什么、为什么需要审核、审核结论是什么。

Staging
  → Validation
  → Diff / Risk
  → QC Review
  → Publish

QC 审核要同时支持单商品和批量任务:

场景QC 粒度说明
单商品创建一个审核单对应一个商品上下文审核 Resource、SPU/SKU、Offer、交易契约是否完整
单商品编辑一个审核单对应一次字段变更审核字段 Diff、风险原因和历史版本
批量导入一个任务下多个审核项低风险项自动准入,高风险行进入 QC
批量编辑按商品、SKU、Offer 或规则生成审核项避免整批等待一个高风险项
供应商同步按 Diff 风险生成审核项坐标漂移、类目变化、映射变化、退款规则变化进入 QC
质量巡检按问题单生成审核项缺图、缺价、不可履约等问题修复后再发布

审核主表:

CREATE TABLE product_qc_review (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    review_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) DEFAULT NULL,
    source_type VARCHAR(32) NOT NULL COMMENT 'LOCAL_OPS/MERCHANT/SUPPLIER_SYNC/QUALITY_INSPECTION',
    review_type VARCHAR(32) NOT NULL COMMENT 'CREATE/EDIT/DIFF/RISK/QUALITY_FIX',
    category_code VARCHAR(32) NOT NULL,
    supply_trace_id VARCHAR(64) NOT NULL,
    operation_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) DEFAULT NULL,
    platform_resource_id BIGINT DEFAULT NULL,
    spu_id BIGINT DEFAULT NULL,
    sku_id BIGINT DEFAULT NULL,
    offer_id BIGINT DEFAULT NULL,
    staging_id VARCHAR(64) DEFAULT NULL,
    change_id VARCHAR(64) DEFAULT NULL,
    base_publish_version BIGINT DEFAULT NULL,
    risk_level VARCHAR(32) NOT NULL COMMENT 'LOW/MEDIUM/HIGH',
    review_policy VARCHAR(32) NOT NULL COMMENT 'AUTO_APPROVE/QC_REQUIRED/BLOCK',
    status VARCHAR(32) NOT NULL
        COMMENT 'PENDING/REVIEWING/APPROVED/REJECTED/CANCELLED/PUBLISHED',
    cancel_source VARCHAR(32) DEFAULT NULL COMMENT 'MERCHANT/QC/SYSTEM/TASK',
    cancel_reason VARCHAR(1024) DEFAULT NULL,
    cancel_action VARCHAR(32) DEFAULT NULL COMMENT 'RETURN_TO_DRAFT/CLOSE_ONLY/CREATE_RISK_CASE/RECREATE_QC',
    submitter_id VARCHAR(64) DEFAULT NULL,
    reviewer_id VARCHAR(64) DEFAULT NULL,
    review_note VARCHAR(1024) DEFAULT NULL,
    reject_reason VARCHAR(1024) DEFAULT NULL,
    submitted_at DATETIME DEFAULT NULL,
    reviewed_at DATETIME DEFAULT NULL,
    cancelled_at DATETIME DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_review_id (review_id),
    KEY idx_trace (supply_trace_id),
    KEY idx_item_status (item_id, status),
    KEY idx_task_status (task_id, status),
    KEY idx_status_risk (status, risk_level),
    KEY idx_object (platform_resource_id, spu_id, sku_id, offer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给 QC 审核单';

审核明细表:

CREATE TABLE product_qc_review_item (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    review_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) DEFAULT NULL,
    item_no VARCHAR(64) DEFAULT NULL,
    object_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE/SPU/SKU/OFFER/RATE_PLAN/STOCK/RULE',
    object_key VARCHAR(128) NOT NULL,
    staging_id VARCHAR(64) DEFAULT NULL,
    change_id VARCHAR(64) DEFAULT NULL,
    changed_fields JSON NOT NULL,
    risk_reasons JSON NOT NULL,
    evidence_ref VARCHAR(512) DEFAULT NULL COMMENT '原始文件、供应商快照、图片质检或巡检证据',
    status VARCHAR(32) NOT NULL COMMENT 'PENDING/APPROVED/REJECTED/CANCELLED/SKIPPED',
    reviewer_id VARCHAR(64) DEFAULT NULL,
    review_note VARCHAR(1024) DEFAULT NULL,
    reject_reason VARCHAR(1024) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_review_item (review_id, object_type, object_key),
    KEY idx_task_item (task_id, item_no),
    KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给 QC 审核明细';

product_qc_review 管一次审核工单,product_qc_review_item 管工单下的审核项。对于单商品,通常一张审核单只有一个或少量 item;对于批量导入,可能一个任务生成多个 QC item,QC 通过的 item 可以继续发布,驳回的 item 回到草稿、错误文件或 DLQ。

QC 状态和任务状态要分开。一个 product_supply_task 可以处于 QC_REVIEWINGPARTIAL_FAILED,其中部分 product_qc_review_item 已经 APPROVED 并发布,另一些仍然 PENDINGREJECTED。不要把整批任务强行卡成一个大审核单。

REJECTEDCANCELLED 也要分开统计。REJECTED 表示审核员认为本次提交内容不符合平台要求,应计入 QC 驳回率;CANCELLED 表示审核单被商家、QC、系统或任务主动终止,不应计入驳回率,而应单独看撤销率和撤销原因分布。

26.5.7 Validation 校验结果表

Validation 负责保存“为什么不能进入 Staging、QC 或 Publish”。错误文件、表单错误提示、DLQ 修复建议都应该从这里或 Task Item 中生成,而不是从日志里拼。

CREATE TABLE product_validation_result (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    validation_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) DEFAULT NULL,
    item_no VARCHAR(64) DEFAULT NULL,
    draft_id VARCHAR(64) DEFAULT NULL,
    staging_id VARCHAR(64) DEFAULT NULL,
    supply_trace_id VARCHAR(64) DEFAULT NULL,
    operation_id VARCHAR(64) DEFAULT NULL,
    item_id VARCHAR(64) DEFAULT NULL,
    validation_layer VARCHAR(64) NOT NULL
        COMMENT 'SCHEMA/MASTER_DATA/MODEL/TRADE_CONTRACT/SELLABLE/RISK',
    field_path VARCHAR(256) DEFAULT NULL,
    severity VARCHAR(32) NOT NULL COMMENT 'INFO/WARN/BLOCK',
    error_code VARCHAR(128) NOT NULL,
    error_message VARCHAR(1024) NOT NULL,
    suggested_action VARCHAR(512) DEFAULT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'OPEN/RESOLVED/IGNORED',
    created_at DATETIME NOT NULL,
    resolved_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_validation_id (validation_id),
    KEY idx_task_item (task_id, item_no),
    KEY idx_staging (staging_id),
    KEY idx_trace (supply_trace_id),
    KEY idx_status_error (status, error_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给校验结果';

26.5.8 Change Request 表

Change Request 保存字段 Diff、风险等级和准入策略。QC 审核的是 Change Request 对应的 Staging,而不是直接审核 Draft。

CREATE TABLE product_change_request (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    change_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) DEFAULT NULL,
    item_no VARCHAR(64) DEFAULT NULL,
    draft_id VARCHAR(64) DEFAULT NULL,
    staging_id VARCHAR(64) NOT NULL,
    supply_trace_id VARCHAR(64) NOT NULL,
    operation_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) DEFAULT NULL,
    object_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE/SPU/SKU/OFFER/RATE_PLAN/STOCK/RULE',
    object_key VARCHAR(128) NOT NULL,
    change_type VARCHAR(32) NOT NULL COMMENT 'CREATE/EDIT/OFFLINE/ONLINE/ARCHIVE/SUPPLIER_DIFF',
    base_publish_version BIGINT DEFAULT NULL,
    changed_fields JSON NOT NULL,
    risk_level VARCHAR(32) NOT NULL COMMENT 'LOW/MEDIUM/HIGH',
    risk_reasons JSON DEFAULT NULL,
    qc_policy VARCHAR(32) NOT NULL COMMENT 'AUTO_APPROVE/QC_REQUIRED/BLOCK',
    publish_policy VARCHAR(32) DEFAULT NULL COMMENT 'AUTO_PUBLISH/MANUAL_PUBLISH/SCHEDULED_PUBLISH',
    status VARCHAR(32) NOT NULL
        COMMENT 'CREATED/VALIDATED/QC_PENDING/APPROVED/REJECTED/PUBLISHING/PUBLISHED/CANCELLED',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_change_id (change_id),
    KEY idx_task_item (task_id, item_no),
    KEY idx_staging (staging_id),
    KEY idx_trace (supply_trace_id),
    KEY idx_item_status (item_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给变更请求';

26.5.9 发布记录与发布快照表

Publish Record 记录一次发布动作,Publish Snapshot 保存发布后的正式商品上下文。订单快照、回滚、对账、问题排查都依赖发布快照。

CREATE TABLE product_publish_record (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    publish_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) DEFAULT NULL,
    item_no VARCHAR(64) DEFAULT NULL,
    staging_id VARCHAR(64) NOT NULL,
    change_id VARCHAR(64) DEFAULT NULL,
    review_id VARCHAR(64) DEFAULT NULL,
    supply_trace_id VARCHAR(64) NOT NULL,
    operation_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    old_publish_version BIGINT DEFAULT NULL,
    new_publish_version BIGINT NOT NULL,
    publish_type VARCHAR(32) NOT NULL COMMENT 'CREATE/EDIT/OFFLINE/ONLINE/ARCHIVE/ROLLBACK',
    status VARCHAR(32) NOT NULL COMMENT 'PENDING/PUBLISHING/SUCCESS/FAILED/CANCELLED',
    error_code VARCHAR(128) DEFAULT NULL,
    error_message VARCHAR(1024) DEFAULT NULL,
    operator_id VARCHAR(64) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    published_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_publish_id (publish_id),
    UNIQUE KEY uk_item_version (item_id, new_publish_version),
    KEY idx_staging (staging_id),
    KEY idx_trace (supply_trace_id),
    KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给发布记录';
CREATE TABLE product_publish_snapshot (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    snapshot_id VARCHAR(64) NOT NULL,
    publish_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    publish_version BIGINT NOT NULL,
    category_code VARCHAR(32) NOT NULL,
    snapshot_type VARCHAR(32) NOT NULL COMMENT 'FULL/RESOURCE/SPU/SKU/OFFER/RULE',
    snapshot_payload JSON NOT NULL,
    payload_hash VARCHAR(64) NOT NULL,
    payload_ref VARCHAR(512) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_snapshot_id (snapshot_id),
    UNIQUE KEY uk_item_version_type (item_id, publish_version, snapshot_type),
    KEY idx_publish (publish_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品发布快照';

26.5.10 商品变更日志表

product_change_log 是正式发布后的变更流水,用于后台展示、审计、回滚和数据平台消费。

CREATE TABLE product_change_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    change_log_id VARCHAR(64) NOT NULL,
    publish_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    old_publish_version BIGINT DEFAULT NULL,
    new_publish_version BIGINT NOT NULL,
    change_type VARCHAR(32) NOT NULL COMMENT 'CREATE/EDIT/OFFLINE/ONLINE/BAN/UNBAN/ARCHIVE/ROLLBACK',
    changed_fields JSON DEFAULT NULL,
    operator_type VARCHAR(32) NOT NULL COMMENT 'LOCAL_OPS/MERCHANT/SYSTEM/SUPPLIER',
    operator_id VARCHAR(64) DEFAULT NULL,
    reason VARCHAR(1024) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_change_log_id (change_log_id),
    KEY idx_item_version (item_id, new_publish_version),
    KEY idx_publish (publish_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品正式发布变更日志';

26.5.11 Outbox 事件表

Outbox 解决“商品正式表已变更,但搜索、缓存、计价上下文或营销资格消费者没收到事件”的问题。商品正式写入、发布记录、Outbox 必须在同一个事务里完成。

CREATE TABLE product_outbox_event (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    event_id VARCHAR(64) NOT NULL,
    aggregate_type VARCHAR(64) NOT NULL COMMENT 'PRODUCT_ITEM/SUPPLY_TASK/PUBLISH_RECORD',
    aggregate_id VARCHAR(64) NOT NULL,
    event_type VARCHAR(128) NOT NULL COMMENT 'ProductPublished/ProductOnline/ProductOffline/ProductQcRejected',
    item_id VARCHAR(64) DEFAULT NULL,
    publish_version BIGINT DEFAULT NULL,
    payload JSON NOT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
        COMMENT 'PENDING/SENDING/SENT/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,
    sent_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_event_id (event_id),
    KEY idx_status_retry (status, next_retry_at),
    KEY idx_item_version (item_id, publish_version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给 Outbox 事件';

26.5.12 DLQ 表

DLQ 是运营可处理的问题单,不是简单日志。它要能支持自动重试、人工分派、修复备注和重新投递。

CREATE TABLE product_supply_dead_letter (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    dead_letter_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) DEFAULT NULL,
    item_no VARCHAR(64) DEFAULT NULL,
    draft_id VARCHAR(64) DEFAULT NULL,
    staging_id VARCHAR(64) DEFAULT NULL,
    review_id VARCHAR(64) DEFAULT NULL,
    publish_id VARCHAR(64) DEFAULT NULL,
    supply_trace_id VARCHAR(64) DEFAULT NULL,
    operation_id VARCHAR(64) DEFAULT NULL,
    item_id VARCHAR(64) DEFAULT NULL,
    error_stage VARCHAR(64) NOT NULL COMMENT 'PARSE/VALIDATION/STAGING/QC/PUBLISH/OUTBOX/INDEX/CACHE',
    error_type VARCHAR(64) NOT NULL COMMENT 'RETRYABLE/NON_RETRYABLE/MANUAL_FIX/RISK_BLOCKED',
    error_code VARCHAR(128) NOT NULL,
    error_message VARCHAR(1024) NOT NULL,
    payload_ref VARCHAR(512) DEFAULT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
        COMMENT 'PENDING/RETRYING/MANUAL_FIX/RESOLVED/IGNORED/FAILED',
    retry_count INT NOT NULL DEFAULT 0,
    max_retry_count INT NOT NULL DEFAULT 5,
    next_retry_at DATETIME DEFAULT NULL,
    assignee VARCHAR(64) DEFAULT NULL,
    fix_note VARCHAR(1024) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    resolved_at DATETIME DEFAULT NULL,
    UNIQUE KEY uk_dead_letter_id (dead_letter_id),
    KEY idx_status_retry (status, next_retry_at),
    KEY idx_task_item (task_id, item_no),
    KEY idx_trace (supply_trace_id),
    KEY idx_item_status (item_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给死信问题单';

26.5.13 对象映射表

product_supply_object_mapping 用来解决“创建前没有 item_id,发布后如何从 item_id 反查整个供给生命周期”的问题。

CREATE TABLE product_supply_object_mapping (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    mapping_id VARCHAR(64) NOT NULL,
    supply_trace_id VARCHAR(64) NOT NULL,
    temporary_object_key VARCHAR(128) DEFAULT NULL,
    item_id VARCHAR(64) NOT NULL,
    first_draft_id VARCHAR(64) DEFAULT NULL,
    first_staging_id VARCHAR(64) DEFAULT NULL,
    first_publish_id VARCHAR(64) DEFAULT NULL,
    source_type VARCHAR(32) NOT NULL COMMENT 'LOCAL_OPS/MERCHANT/SUPPLIER/SYSTEM',
    category_code VARCHAR(32) NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/ARCHIVED',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_mapping_id (mapping_id),
    UNIQUE KEY uk_trace (supply_trace_id),
    UNIQUE KEY uk_item_id (item_id),
    KEY idx_temp_key (temporary_object_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供给对象与正式商品映射';

26.5.14 操作流水表

product_supply_operation_log 串联 Draft、Staging、QC、Publish、Item Status,是 B 端“View Log”的主要数据源。

CREATE TABLE product_supply_operation_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    log_id VARCHAR(64) NOT NULL,
    supply_trace_id VARCHAR(64) NOT NULL,
    operation_id VARCHAR(64) DEFAULT NULL,
    object_type VARCHAR(64) NOT NULL COMMENT 'DRAFT/STAGING/QC/PUBLISH/ITEM/TASK/DLQ',
    object_id VARCHAR(64) NOT NULL,
    action VARCHAR(64) NOT NULL COMMENT 'DRAFT_CREATED/SUBMITTED/QC_APPROVED/PUBLISHED/OFFLINE',
    old_status VARCHAR(64) DEFAULT NULL,
    new_status VARCHAR(64) DEFAULT NULL,
    operator_type VARCHAR(32) NOT NULL COMMENT 'LOCAL_OPS/MERCHANT/SYSTEM/SUPPLIER/QC',
    operator_id VARCHAR(64) DEFAULT NULL,
    reason VARCHAR(1024) DEFAULT NULL,
    ext_info JSON DEFAULT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_log_id (log_id),
    KEY idx_trace_time (supply_trace_id, created_at),
    KEY idx_object (object_type, object_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给操作流水';

26.5.15 字段主导权表

字段主导权用于解决供应商同步和人工运营编辑互相覆盖的问题。没有这张表,供应商同步很容易把运营刚修复的标题、坐标、退款规则覆盖掉。

CREATE TABLE product_field_ownership (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    ownership_id VARCHAR(64) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    field_path VARCHAR(256) NOT NULL,
    owner_type VARCHAR(32) NOT NULL COMMENT 'SUPPLIER/LOCAL_OPS/MERCHANT/PLATFORM_RULE',
    owner_id VARCHAR(64) DEFAULT NULL,
    override_until DATETIME DEFAULT NULL,
    override_reason VARCHAR(1024) DEFAULT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ACTIVE/EXPIRED/CANCELLED',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_ownership_id (ownership_id),
    UNIQUE KEY uk_item_field (item_id, field_path),
    KEY idx_status_until (status, override_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品字段主导权';

26.5.16 补偿任务表

Outbox 自身可以重试事件投递,但商品供给还需要更宽的补偿任务:重建搜索索引、刷新缓存、修复发布版本和索引不一致、重新生成错误文件、重新投递质量修复结果等。这类任务不要混在业务 Task 里,否则运营看板会分不清“供给任务失败”和“下游补偿任务失败”。

CREATE TABLE product_compensation_task (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    compensation_id VARCHAR(64) NOT NULL,
    source_type VARCHAR(64) NOT NULL COMMENT 'OUTBOX/DLQ/QUALITY_CHECK/MANUAL/SYSTEM',
    source_id VARCHAR(64) DEFAULT NULL,
    compensation_type VARCHAR(64) NOT NULL
        COMMENT 'REBUILD_INDEX/INVALIDATE_CACHE/REPLAY_OUTBOX/RETRY_PUBLISH/REGENERATE_ERROR_FILE/QUALITY_FIX',
    item_id VARCHAR(64) DEFAULT NULL,
    publish_version BIGINT DEFAULT NULL,
    task_id VARCHAR(64) DEFAULT NULL,
    dead_letter_id VARCHAR(64) DEFAULT NULL,
    payload JSON DEFAULT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
        COMMENT 'PENDING/RUNNING/SUCCESS/FAILED/CANCELLED',
    retry_count INT NOT NULL DEFAULT 0,
    max_retry_count INT NOT NULL DEFAULT 5,
    next_retry_at DATETIME DEFAULT NULL,
    worker_id VARCHAR(64) DEFAULT NULL,
    lease_token VARCHAR(64) DEFAULT NULL,
    lease_until DATETIME DEFAULT NULL,
    error_code VARCHAR(128) DEFAULT NULL,
    error_message VARCHAR(1024) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    started_at DATETIME DEFAULT NULL,
    finished_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_compensation_id (compensation_id),
    KEY idx_status_retry (status, next_retry_at),
    KEY idx_item_version (item_id, publish_version),
    KEY idx_source (source_type, source_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给补偿任务';

26.5.17 质量问题表

质量巡检发现的问题要变成可分派、可跟进、可统计的问题单。它和 DLQ 的区别是:DLQ 通常来自链路执行失败,质量问题表来自发布后巡检、数据对账和运营治理。

CREATE TABLE product_quality_issue (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    issue_id VARCHAR(64) NOT NULL,
    issue_type VARCHAR(64) NOT NULL
        COMMENT 'MISSING_IMAGE/MISSING_PRICE/NO_STOCK/MAPPING_MISSING/RULE_MISSING/INDEX_INCONSISTENT/FIELD_OWNERSHIP_EXPIRED',
    category_code VARCHAR(32) NOT NULL,
    item_id VARCHAR(64) NOT NULL,
    publish_version BIGINT DEFAULT NULL,
    object_type VARCHAR(32) DEFAULT NULL COMMENT 'RESOURCE/SPU/SKU/OFFER/RULE/INDEX',
    object_key VARCHAR(128) DEFAULT NULL,
    severity VARCHAR(32) NOT NULL COMMENT 'LOW/MEDIUM/HIGH/CRITICAL',
    issue_payload JSON DEFAULT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'OPEN'
        COMMENT 'OPEN/ASSIGNED/FIXING/FIX_SUBMITTED/RESOLVED/IGNORED',
    owner_team VARCHAR(64) DEFAULT NULL,
    assignee VARCHAR(64) DEFAULT NULL,
    source_task_id VARCHAR(64) DEFAULT NULL,
    related_dead_letter_id VARCHAR(64) DEFAULT NULL,
    fix_staging_id VARCHAR(64) DEFAULT NULL,
    fix_publish_id VARCHAR(64) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    resolved_at DATETIME DEFAULT NULL,
    UNIQUE KEY uk_issue_id (issue_id),
    KEY idx_item_status (item_id, status),
    KEY idx_type_status (issue_type, status),
    KEY idx_severity (severity)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品质量问题单';

26.5.18 表模型 review 结论

这一组表的边界可以这样记:

一句话定位
product_supply_draft可编辑工作区
product_supply_task一次供给动作的执行批次
product_supply_task_item批量任务的行级处理单元
product_supply_staging已提交冻结快照
product_validation_result为什么不能继续流转
product_change_request改了什么、风险多高、需不需要 QC
product_qc_review审核工单
product_qc_review_item审核工单里的字段或对象级审核项
product_publish_record发布动作
product_publish_snapshot发布后的正式商品上下文
product_change_log正式商品发布后的变更流水
product_outbox_event通知下游
product_supply_dead_letter可运营的问题单
product_supply_object_mapping从临时对象追到正式 item_id
product_supply_operation_log从 Draft 到下线的完整日志
product_field_ownership防止同步覆盖人工治理字段
product_compensation_task下游刷新、重放和一致性修复任务
product_quality_issue发布后质量巡检问题单

正式商品主数据表,例如 resource_tabproduct_spu_tabproduct_sku_tabproduct_offer_tabstock_config_tabfulfillment_rule_tabrefund_rule_tab,仍然属于商品中心和交易前契约,不属于供给任务表。供给平台只通过发布事务写入它们。


26.6 库存运营任务:从库存创建、补货到券码池管理

库存系统负责库存事实,但库存任务的运营入口应该在供给平台。原因是库存创建、补货、券码导入、系统生码、门店日期库存调整,往往不是一个纯技术接口调用,而是一组带有类目约束、审批、批量进度、错误文件、风险提示和审计要求的运营工作。

本节讨论的是 库存任务如何被供给平台发起和治理,不是库存系统内部如何扣减。库存系统内部的余额、预占、扣减、释放、券码状态机和账本,仍然属于第 27 章库存系统。

26.6.1 为什么库存任务属于供给运营平台

有些库存很简单,商品发布时顺手创建一行数量库存即可;有些库存很复杂,需要运营手动上传券码、系统生成券码、按门店和日期批量物化库存切片,还要支持后续补货、锁库存、局部调整和错误修复。如果这些入口散落在库存后台、商品后台、商家后台和供应商同步任务里,后续一定会出现三类问题:

  1. 入口不可控:同一个 SKU 的库存可以从多个后台修改,谁改的、为什么改、基于哪个商品版本改,很难追踪。
  2. 风险不可审:大额补货、批量锁库存、券码导入失败、活动前临时调库存,没有统一的风险评分和审批。
  3. 失败不可运营:文件第几行错了、哪批券码重复、哪些门店日期库存创建失败,如果只在库存系统日志里,运营无法闭环。

因此更合理的分工是:

供给运营平台:
  负责库存任务的入口、表单、审批、任务编排、进度、错误文件、补偿和审计

库存系统:
  负责库存配置、余额、券码池、预占、扣减、释放、账本和对账

这个边界和商品发布一致:供给平台不直接改正式商品表,也不直接改库存余额。它发起表达业务意图的命令,由库存系统幂等执行。

26.6.2 库存任务的通用模型

库存运营任务可以复用供给平台的 Task / Task Item / DLQ 模型,文件元数据直接挂在 Task 上,但要有清晰的 task_type 和执行阶段。

常见 task_type

任务类型说明典型触发
INVENTORY_CREATE创建初始库存实例商品发布、供应商商品首次映射
INVENTORY_ADJUST数量补货、盘点调整、扣减修正运营补货、库存盘点、售后修正
INVENTORY_LOCK锁定或解锁库存范围风控、质检、活动预留、门店停业
CODE_IMPORT手动上传券码或卡密文件运营导入、供应商批量交付
CODE_GENERATE系统生成券码平台自营券、礼品卡、充值码
TIME_STORE_STOCK_MATERIALIZE门店、日期、时段库存物化本地生活、预约、票务、酒店库存日历
INVENTORY_BULK_EDIT批量编辑库存配置或库存水位大促前批量调整、门店批量上下线

任务状态建议和普通供给任务保持一致,但要额外表达库存系统执行结果:

DRAFT
  → SUBMITTED
  → VALIDATING
  → REVIEWING
  → APPROVED
  → DISPATCHING
  → EXECUTING
  → SUCCESS

EXECUTING
  → PARTIAL_FAILED
  → DLQ

任意非终态
  → CANCELLED

供给平台里的任务状态回答“运营任务走到哪一步”;库存系统里的命令结果回答“库存事实是否已经变更”。两者不要合成一个字段,否则会出现“任务已审批但库存未 ready”“库存部分成功但任务显示成功”这类语义混乱。

库存命令至少要携带:

operation_id
task_id
item_no
source_type
source_id
item_id / sku_id / offer_id
inventory_key
inventory_type
scope_type / scope_id
quantity_delta / initial_quantity
code_batch_id
effective_time
operator_id
reason
idempotency_key
publish_version

其中 idempotency_key 很关键。它通常来自:

商品发布创建库存:publish_id + sku_id + inventory_scope
运营补货:task_id + item_no
券码导入:batch_id + code_hash
系统生码:task_id + generation_seq
门店日期库存:task_id + store_id + date + time_slot

26.6.3 数量制库存:随商品发布自动创建

简单数量制库存通常和商品发布强相关。例如平台自营的充值套餐、通用券包、虚拟权益包,商品创建时就知道库存类型、初始数量、销售范围和扣减时机。此时可以让库存创建随商品发布自动触发,但仍然不要把库存创建放进商品发布事务里同步执行。

推荐链路:

Publish Transaction
  → 写商品正式表、库存配置、交易契约、Outbox
  → ProductPublished
  → InventoryCreateCoordinator 消费事件
  → 生成 CreateInventory 命令
  → 库存系统创建 inventory_config / inventory_balance
  → 写 INIT / INBOUND 账本
  → InventoryReady / InventoryCreateFailed
  → 可售投影刷新

这种设计有几个好处:

  1. 商品发布事务不会被库存系统写放大拖慢。
  2. 同一个 publish_id + inventory_key 可以幂等创建,Outbox 重放不会重复加库存。
  3. 库存创建失败可以展示为“商品已发布但库存未 ready”,由运营重试或修复。
  4. 库存系统仍然通过账本解释初始库存来源,而不是凭空出现一行余额。

数量制库存也要区分三个概念:

概念含义是否可以直接改
initial_quantity初始化或本次入库数量只能通过创建 / 入库命令产生
available_quantity当前可承诺数量由库存系统根据预占、扣减、释放计算
locked_quantity被运营、风控或活动锁住的数量通过锁定 / 解锁命令变化

供给平台可以展示这些数值,但不能执行:

UPDATE inventory_balance SET available_quantity = ? WHERE inventory_key = ?;

26.6.4 数量制库存:后台补货、调库存与锁库存

商品上线后,运营仍然会做补货、盘点调整、锁库存和解锁。它们都属于库存任务,但业务语义不同,不能都叫“改库存”。

操作业务语义典型风险库存系统动作
补货增加可售供给补错 SKU、补错门店、活动前超卖写入库流水,增加可用或总量
盘点调整修正账实差异人工改错导致账本不可解释写调整流水,保留原因和审批单
锁库存暂停某部分库存继续售卖误锁导致大面积售罄增加锁定量或锁定范围
解锁库存恢复被锁库存可售解锁已售或异常库存校验状态后释放锁定
活动预留给营销活动预留一部分库存与普通售卖池冲突生成独立 reservation 或 lock reason

补货任务建议至少经过:

创建补货草稿
  → 选择商品 / SKU / 门店 / 日期范围
  → 输入补货数量和原因
  → 风险校验:数量阈值、活动中商品、历史投诉、供应商主导权
  → 自动通过 / 人工审批
  → 发 AdjustInventory 命令
  → 库存系统 CAS 执行并写 inventory_ledger
  → 返回 InventoryChanged

锁库存任务要更加谨慎。它经常来自风控、质检、履约异常、供应商停供或门店停业。锁定后不能删除库存行,也不能影响历史订单和已预占记录;它只应该阻止新的 Reserve。

26.6.5 券码制库存:手动上传券码批次

券码制库存不能只用一个数量字段表达。每个券码都是一份可交付资源,必须一码一行、有状态机、有加密存储、有去重能力、有订单关联和审计记录。

手动上传券码推荐链路:

上传券码文件
  → 创建 CODE_IMPORT task
  → 在 task 中写入 input_file_ref / input_file_hash
  → Parser Worker 流式解析
  → 生成 task_item(row_no, code_hash, raw_ref)
  → 校验格式、重复码、有效期、批次、SKU 归属
  → 低风险自动通过 / 高风险进入审批
  → 发 ImportCodeBatch 命令
  → 库存系统加密写 inventory_code_batch
  → 分表写 inventory_code_pool_XX
  → Redis LIST 预热 code_id
  → 返回 CodeBatchReady / CodeBatchPartialFailed

inventory_code_pool_XX 的核心原则是:一码一行,MySQL 状态机是权威,Redis LIST 只保存 code_id 热数据。

inventory_code_pool_XX
  code_id
  batch_id
  inventory_key
  sku_id / offer_id
  code_cipher
  code_hash
  status
  reservation_id
  order_id
  user_id
  booked_at
  sold_at
  expire_at
  version

券码导入要特别注意:

  1. 明文码不进入日志、错误文件和 Redis。
  2. code_hash 用于去重和问题排查,code_cipher 用于加密交付。
  3. 重复码、格式错误、过期码要行级失败,不能拖垮整批。
  4. MySQL CAS 成功后才算锁码成功,Redis 弹出 code_id 只是候选。
  5. SOLD 码不要因为退款直接回到可售池,退款要走售后和履约规则。

26.6.6 券码制库存:系统生码与码池初始化

系统生码和手动上传券码相似,但多了生码规则和安全要求。供给平台负责生码任务配置,库存系统负责真正生成、落库和维护状态机。

生码任务通常包含:

sku_id / offer_id
generate_count
code_length
code_prefix
validity_start / validity_end
redeem_channel
merchant_id / supplier_id
batch_reason
approval_id
idempotency_key

系统生码的关键不是“生成随机字符串”,而是保证不可猜测、不可重复、可审计、可恢复:

  1. 使用足够熵的随机源,避免连续号或可推导规则。
  2. 生成前先创建 inventory_code_batch,所有码归属同一批次。
  3. 每个 code_hash 有唯一约束,重复生成要丢弃并补足数量。
  4. 生成成功后先写 MySQL,再按需预热 Redis code_id
  5. 任务失败可以按 task_id + generation_seq 恢复,不重复生成已成功的码。

对于大批量生码,建议分片执行:

CODE_GENERATE task
  → task_item 1: generate 1 - 10000
  → task_item 2: generate 10001 - 20000
  → ...

这样可以支持进度展示、部分失败重试和批次级对账。

26.6.7 门店 / 日期 / 时段库存:批量物化与局部调整

本地生活、预约、票务、酒店、活动类商品经常不是一行全局库存,而是按门店、日期、时段、房型、场次等维度切片。供给平台要提供适合运营使用的批量工具,库存系统负责物化和扣减。

典型库存范围:

GLOBAL
STORE
CITY
DATE
STORE_DATE
STORE_DATE_TIME_SLOT
SUPPLIER_SKU
CHANNEL

门店日期库存任务要支持三种创建方式:

创建方式适用场景设计要点
提前物化热门门店、热门日期、活动库存发布后批量创建未来 N 天切片
懒创建长尾门店、低频日期首次查询或首次编辑时创建库存行
模板复制多门店同规则、多日期同库存从营业模板、节假日模板或门店分组复制

例如运营要给 100 家门店创建未来 30 天、每天 6 个时段的库存,任务会展开成 18000 个库存切片。这个操作不能同步阻塞在页面提交里,必须任务化、分批执行、展示进度、支持部分失败。

局部调整也很重要。例如某门店临时停业,只应该锁定该门店未来几天的库存切片,不应该下架整个商品;某个时段履约能力不足,也只应该调整这个时段的可售能力。

26.6.8 库存批量编辑任务:行级处理、部分成功与错误文件

库存批量编辑和商品批量导入一样,必须按任务和行级明细设计。常见文件行可能长这样:

row_no
sku_id
store_id
date
time_slot
operation_type
quantity_delta
lock_reason
effective_time
idempotency_key

处理流程:

上传库存批量文件
  → 校验模板版本和列结构
  → 流式解析为 task_item
  → 行级校验:SKU 是否存在、门店是否有效、日期是否合法、数量是否越界
  → 风险评分:大额调整、活动商品、供应商主导字段、临近开售
  → 自动通过 / 人工审批
  → 分批发送库存命令
  → 聚合成功、失败、跳过、DLQ
  → 生成错误文件

部分成功是必须能力。10000 行库存调整里 100 行门店不存在,不应该让 9900 行全部失败。错误文件要能让运营直接修复,至少包含:

row_no
sku_id
store_id
date
operation_type
error_code
error_message
suggestion
retryable

26.6.9 库存任务状态机与幂等设计

库存任务最怕重复执行。重复导入券码会造成重复码,重复补货会造成库存虚增,重复锁库存会导致可售水位异常。因此每一层都要有幂等键。

层级幂等 Key作用
任务层task_type + trigger_id防止同一次发布或同一个文件重复创建任务
行级层task_id + item_no 或业务 idempotency_key防止重复处理同一行
库存命令层operation_id防止命令重复投递
券码层batch_id + code_hash防止重复码入库
库存切片层inventory_key + scope + date + slot防止重复物化切片
账本层operation_id + ledger_type防止重复写入入库、调整或锁定流水

任务状态推进建议用 CAS:

UPDATE product_supply_task
SET status = 'EXECUTING',
    updated_at = NOW()
WHERE task_id = ?
  AND status = 'APPROVED';

库存系统执行命令也要返回明确结果:

SUCCESS
DUPLICATE_IGNORED
PARTIAL_FAILED
VALIDATION_FAILED
CONFLICT
RETRYABLE_FAILED

DUPLICATE_IGNORED 不是失败,而是说明幂等命中;CONFLICT 通常需要运营重新基于最新库存状态编辑。

26.6.10 供给平台与库存系统的命令边界

供给平台和库存系统之间建议使用业务命令,而不是让供给平台直连库存表。

命令语义返回事件
CreateInventory创建库存配置和初始库存实例InventoryReady / InventoryCreateFailed
AdjustInventory补货、盘点调整、修正库存InventoryChanged / InventoryAdjustFailed
LockInventory锁定库存范围或数量InventoryLocked / InventoryLockFailed
UnlockInventory解锁库存范围或数量InventoryUnlocked / InventoryUnlockFailed
ImportCodeBatch导入券码批次CodeBatchReady / CodeBatchPartialFailed
GenerateCodeBatch系统生成券码批次CodeBatchReady / CodeBatchFailed
MaterializeTimeStoreStock物化门店、日期、时段库存StockSliceReady / StockSlicePartialFailed
RebuildAvailability触发可售投影重建AvailabilityProjected / AvailabilityProjectFailed

命令请求里要带 operator_idreasonsource_typetask_idoperation_idpublish_version。库存系统写账本时也要保存这些字段,后续才能从一条库存流水反查到对应的供给任务、审批单和发布版本。

26.6.11 库存任务的审计、补偿与可售诊断

库存任务完成后,运营后台不能只显示“成功 / 失败”。更好的展示是:

任务状态:PARTIAL_FAILED
总行数:10000
成功:9850
失败:120
跳过:30
库存系统命令成功率:98.5%
错误文件:可下载
影响商品:128 个
影响门店:46 个
可售投影:等待 12 个商品刷新

补偿要区分三类:

失败类型示例处理方式
供给侧失败文件格式错误、字段缺失、门店不存在生成错误文件,运营修复后重新提交
库存侧失败库存系统超时、CAS 冲突、重复码自动重试、DLQ、人工确认
投影侧失败可售投影刷新失败、缓存未失效、搜索状态落后Outbox 重放、补偿任务重建

最终运营要能看到一条完整链路:

谁在什么时间
基于哪个商品版本
因为什么原因
创建了哪个库存任务
影响了哪些库存实例或券码批次
库存系统是否执行成功
可售投影是否刷新成功
如果失败,下一步该谁处理

这就是把库存运营任务放在供给平台里的价值:库存事实不被供给平台篡改,但库存变更过程被供给平台治理起来。

26.6.12 Deal 商品与券码接入检查清单

对于 Deal 类型商品(囤券、团购套餐、电子凭证),商品创建和券码资产初始化往往是同一条业务链路。为了便于评审和落地,可以在本节补一张检查清单。

先区分两类模式:

模式适用场景推荐策略
平台自发码平台自己定义券码规则交易成功后动态生成
商家 / 三方自带码电影票、外部卡密、外部凭证创建时前置校验并落库

如果是商家 / 三方自带码,推荐使用“两阶段激活”:

  1. 提单阶段:商品中心写 draft_tab,库存中心接收券码并写入实例表,状态标记为 PENDING_REVIEW
  2. 发布阶段:QC 通过后,商品中心 Merge 正式商品并发出 ItemPublishedEvent,库存中心再把券码批量激活为 AVAILABLE

这样可以避免“商品成功了,券码没准备好”或“券码入库了,商品回滚了”的孤儿资产问题。

券码安全也要单独强调:

  • 数据库不要明文存储券码。
  • SHA-256(code + salt) 一类哈希值做唯一索引。
  • 真正券码内容使用对称加密存储。
  • 服务间传输默认脱敏,只有在用户真正查看券码时才解密展示。

高并发发号则应避免直接撞数据库:

  • 商品发布成功后,把可用券码预热到 Redis 队列。
  • 交易时使用 LPOP 或 Lua 脚本原子取码。
  • 取码成功后立即与 order_id 绑定,再异步回写数据库实例状态。

建议在设计文档里固定检查以下五项:

检查维度红线
券码归属必须先明确是平台自发码还是商家自带码
事务边界审核通过前券码不得对 C 端可见
唯一性底层必须能阻断一券多卖
安全严禁明文存储和明文扩散
性能发号链路不要直接撞 MySQL

一句话总结:Deal 商品不是“商品 + 库存”这么简单,而是“商品外壳 + 券码资产 + 激活状态机 + 高并发发号器”的组合系统。


26.7 单商品创建和编辑链路

26.7.1 单商品创建

单商品创建要保证运营体验,所以保存和基础校验走同步;正式发布仍然走治理链路。

选择类目
  → 加载类目模板
  → 填写商品信息
  → 前端实时校验
  → 保存 Draft
  → 提交
  → 后端同步强校验
  → 生成 Staging
  → 生成 product_supply_task(total_count=1, execution_mode=SYNC)
  → 生成 product_supply_task_item
  → Validation
  → Diff / Risk
  → 自动准入 / QC 审核 / 阻断
  → Publish
  → Outbox

同步接口可以返回:

{
  "task_id": "task_001",
  "draft_id": "draft_001",
  "staging_id": "stg_001",
  "status": "VALIDATED",
  "validation_errors": []
}

如果校验失败,必须返回字段级错误,而不是只说“提交失败”:

{
  "status": "FAILED",
  "errors": [
    {
      "field": "refund_rule",
      "error_code": "REFUND_RULE_MISSING",
      "message": "hotel offer requires refund rule"
    }
  ]
}

26.7.2 单商品编辑

编辑比创建更复杂,因为它基于线上版本修改。

打开商品详情
  → 读取 current_publish_version
  → 创建编辑 Draft
  → 修改字段
  → 保存草稿
  → 提交编辑
  → 同步校验
  → 生成 Staging
  → 与 current_publish_version 做 Diff
  → 判断字段主导权
  → 计算风险等级
  → 自动准入 / 创建 QC 审核单 / 阻断
  → 发布新 publish_version

单商品编辑必须具备三种能力:

能力说明
版本锁编辑基于 base_publish_version,发布时如果线上版本已变化,要提示冲突
字段 Diff审核员看到字段级变化,而不是整段 JSON
字段主导权判断运营编辑是否可以覆盖供应商同步字段

编辑示例:

操作策略
改标题低风险,自动准入
改主图图片质量校验,通过后自动准入或进入 QC
改价格超过阈值进入 QC
改退款规则高风险,强制 QC
改供应商映射高风险,强制 QC 并触发巡检

26.7.3 Draft 与 Staging 的区别

作用是否影响线上
Draft编辑中的草稿,允许反复保存
Staging提交后的待发布快照,进入校验、审核、发布
正式表C 端、搜索、订单真正读取的数据
Draft
  → 用户可反复修改

Staging
  → 系统校验、审核、发布使用

正式表
  → 只有发布事务能写入

26.8 批量导入和批量编辑链路

批量链路要按“任务 + 行级明细 + 暂存快照 + 错误文件 + 补偿”设计,不能把整个 Excel 读进内存后循环写正式表。

26.8.1 异步执行总流程

上传文件 / 批量提交
  → 创建 product_supply_task(status=PENDING, execution_mode=ASYNC)
  → 在 task 中写入 input_file_ref / input_file_name / input_file_hash
  → Parser Worker 抢占任务并流式解析文件
  → 批量写入 product_supply_task_item
  → Item Worker 分批处理 item
  → 标准化 / 校验 / Staging / Diff
  → 低风险自动准入,高风险生成 QC item
  → QC 通过项进入发布,驳回项进入错误文件或 DLQ
  → Publish Worker 发布正式表并写 Outbox
  → 生成错误文件 / DLQ / 质量报告

这个拆法有三个好处:

  1. 解析失败不会污染正式商品表。
  2. 行级失败不会拖垮整批任务。
  3. 发布和下游刷新可以限速、重试和补偿。

26.8.2 提交任务

“提交任务”阶段只负责保存源文件和任务元数据,不直接解析 Excel,也不直接写正式商品表。

sequenceDiagram
  title Excel 批量导入:提交任务
  actor Merchant
  participant API as Upload API
  participant FileStore as OSS / USS
  participant TaskDB as product_supply_task

  Merchant->>API: 1. 上传 Excel / 批量提交
  activate API
  API->>FileStore: 2. 上传源文件到 OSS / USS
  activate FileStore
  FileStore-->>API: input_file_ref / file_name / hash
  deactivate FileStore

  API->>TaskDB: 3. 创建 product_supply_task<br/>status=PENDING, execution_mode=ASYNC,<br/>input_file_ref / file_name / input_file_hash
  activate TaskDB
  TaskDB-->>API: task_id
  deactivate TaskDB

  API-->>Merchant: 4. 返回 task_id
  deactivate API

  Note right of API: 提交阶段只负责保存源文件和任务元数据,<br/>不直接解析 Excel,也不直接写正式商品表。

26.8.3 Parser Worker

Parser Worker 只负责解析,不负责发布。

sequenceDiagram
  title Excel 批量导入:Parser Worker 解析文件
  actor Scheduler as Parser Scheduler
  participant Parser as Parser Worker
  participant FileStore as OSS / USS
  participant TaskDB as product_supply_task
  participant ItemDB as product_supply_task_item
  participant Outbox
  participant ItemMQ as Task Item MQ

  Scheduler->>Parser: 1. 触发解析任务
  activate Parser
  Parser->>TaskDB: 2. CAS 抢占任务<br/>PENDING -> PARSING
  activate TaskDB
  TaskDB-->>Parser: claim ok
  deactivate TaskDB

  Parser->>TaskDB: 3. 读取文件元数据<br/>校验 input_file_ref / hash / 模板版本 / 列结构
  activate TaskDB
  TaskDB-->>Parser: file metadata
  deactivate TaskDB

  Parser->>FileStore: 4. 按 input_file_ref 读取源文件
  activate FileStore
  FileStore-->>Parser: file stream
  deactivate FileStore

  loop 5. 流式解析文件,每 N 行一批
    Parser->>Parser: 5.1 stream read<br/>不能一次性加载到内存
    Parser->>ItemDB: 5.2 同事务写 task items<br/>status=PENDING
    activate ItemDB
    ItemDB-->>Parser: inserted
    deactivate ItemDB

    Parser->>Outbox: 5.3 同事务写 Outbox<br/>TASK_ITEM_CREATED, task_item_id
    activate Outbox
    Outbox-->>Parser: queued
    deactivate Outbox

    Parser->>ItemMQ: 5.4 提交后立即发布 task_item_id
    activate ItemMQ
    ItemMQ-->>Parser: ack
    deactivate ItemMQ

    Parser->>Outbox: 5.5 更新 outbox.status=SENT
    activate Outbox
    Outbox-->>Parser: updated
    deactivate Outbox

    Parser->>TaskDB: 5.6 update parsed_count<br/>parse_checkpoint / heartbeat_at
    activate TaskDB
    TaskDB-->>Parser: updated
    deactivate TaskDB
  end

  Parser->>TaskDB: 6. 写 total_count<br/>PARSING -> RUNNING
  activate TaskDB
  TaskDB-->>Parser: updated
  deactivate TaskDB
  deactivate Parser

  Note right of Parser: parse_checkpoint 示例:<br/>{<br/>  "sheet": "Sheet1",<br/>  "row_no": 12000,<br/>  "byte_offset": 8842211<br/>}<br/><br/>这一阶段结束时,<br/>TASK_ITEM_CREATED 已经投递到 MQ。
  Note right of ItemDB: 幂等兜底:<br/>UNIQUE(task_id, item_no)<br/>UNIQUE(task_id, idempotency_key)
1. CAS 抢占 product_supply_task
2. 读取 product_supply_task 中的文件元数据,校验 input_file_ref、文件 hash、模板版本和列结构
3. 流式读取文件,不能一次性加载到内存
4. 每 N 行批量插入 product_supply_task_item
5. 更新 parsed_count、parse_checkpoint、heartbeat_at
6. 解析完成后写 total_count
7. task.status 从 PARSING 推进到 RUNNING

parse_checkpoint 示例:

{
  "sheet": "Sheet1",
  "row_no": 12000,
  "byte_offset": 8842211
}

Excel 不一定天然支持稳定的 byte_offset 恢复。工程上可以先把上传文件转换成规范化 CSV 或行级 JSONL,再按 offset 恢复;也可以按 row_no 从头快速跳过。

重复解析靠唯一键兜住:

UNIQUE(task_id, item_no)
UNIQUE(task_id, idempotency_key)

26.8.4 Item Worker

Item Worker 的主链路应改为消费 Task Item MQ,而不是高频扫表抢小批量 item。扫表只保留给补偿、超时回收和死信重放。

sequenceDiagram
  title Excel 批量导入:Item 执行与分流
  participant ItemMQ as Task Item MQ
  participant ItemWorker as Item Worker
  participant ItemDB as product_supply_task_item
  participant StagingDB as product_supply_staging
  participant Risk as Risk Engine / QC Builder
  participant QCDB as product_qc_review
  participant QCItemDB as product_qc_review_item

  loop 1. 消费 task item 事件
    ItemWorker->>ItemMQ: 1.1 消费 TASK_ITEM_CREATED
    activate ItemWorker
    activate ItemMQ
    ItemMQ-->>ItemWorker: task_item_id
    deactivate ItemMQ

    rect rgb(245, 245, 245)
      Note over ItemWorker,ItemDB: 每个 item 独立事务
      ItemWorker->>ItemDB: 1.2 CAS item -> NORMALIZING
      activate ItemDB
      ItemDB-->>ItemWorker: claim ok / duplicated
      deactivate ItemDB

      ItemWorker->>ItemWorker: 1.3 读取 raw_row_ref<br/>按类目模板标准化
      ItemWorker->>ItemDB: 1.4 写 normalized_ref
      activate ItemDB
      ItemDB-->>ItemWorker: saved
      deactivate ItemDB

      ItemWorker->>ItemWorker: 1.5 Schema / 主数据 /<br/>商品模型 / 交易契约校验

      alt 校验通过
        ItemWorker->>StagingDB: 1.6 写 product_supply_staging
        activate StagingDB
        StagingDB-->>ItemWorker: staging_ref
        deactivate StagingDB

        ItemWorker->>StagingDB: 1.7 与线上 publish_version 做 Diff
        activate StagingDB
        StagingDB-->>ItemWorker: diff result
        deactivate StagingDB

        ItemWorker->>Risk: 1.8 生成 product_change_request
        activate Risk

        alt 低风险自动准入
          Risk->>StagingDB: 1.9 staging.status = APPROVED
          activate StagingDB
          StagingDB-->>Risk: updated
          deactivate StagingDB
          Risk->>ItemDB: 1.10 item.status = APPROVED
          activate ItemDB
          ItemDB-->>Risk: updated
          deactivate ItemDB
        else 高风险进入 QC
          Risk->>QCDB: 1.9 创建 product_qc_review
          activate QCDB
          QCDB-->>Risk: review_id
          deactivate QCDB
          Risk->>QCItemDB: 1.10 创建 product_qc_review_item
          activate QCItemDB
          QCItemDB-->>Risk: created
          deactivate QCItemDB
          Risk->>StagingDB: 1.11 staging.status = QC_PENDING
          activate StagingDB
          StagingDB-->>Risk: updated
          deactivate StagingDB
          Risk->>ItemDB: 1.12 item.status = QC_PENDING
          activate ItemDB
          ItemDB-->>Risk: updated
          deactivate ItemDB
        end

        deactivate Risk
      else 校验失败
        ItemWorker->>ItemDB: 1.6 item.status = FAILED<br/>error_code / error_message
        activate ItemDB
        ItemDB-->>ItemWorker: updated
        deactivate ItemDB
      end
    end

    deactivate ItemWorker
  end

  Note right of ItemWorker: 主链路由 MQ 实时驱动,<br/>不要依赖高频扫表抢任务。
  Note over ItemWorker,Risk: 这一阶段结束时:<br/>低风险进入 APPROVED,<br/>高风险进入 QC_PENDING。

每个 item 或小批次独立事务:

读取 raw_row_ref
  → CAS 将 item 推进到 NORMALIZING
  → 按类目模板标准化
  → 写 normalized_ref
  → 执行 Schema / 主数据 / 商品模型 / 交易契约校验
  → 校验通过后写 product_supply_staging
  → 与线上 publish_version 做 Diff
  → 生成 product_change_request
  → 根据 risk_level 将 staging / item 分流到 APPROVED 或 QC_PENDING
  → 更新 item.status

不要用一个大事务包住 500 行。否则一行失败会拖垮整批,也会造成长事务和锁等待。补偿任务如果需要扫表,也应该只扫超时未完成、需要重试或进入 DLQ 回放的少量 item,而不是作为主执行路径。

26.8.5 QC 流程

QC 流程应从 Item 主链路里拆开。Item Worker 只负责把高风险变更送进 QC_PENDING,后续由审核工作台和 QC Worker 推进。

sequenceDiagram
  title Excel 批量导入:QC 审核流
  actor Ops as Ops / QC Reviewer
  participant QCAPI as QC API
  participant QCDB as product_qc_review
  participant QCItemDB as product_qc_review_item
  participant StagingDB as product_supply_staging
  participant ItemDB as product_supply_task_item
  participant Publish as Publish Worker

  Ops->>QCAPI: 1. 打开审核单 / 查看风险命中和 Diff
  activate QCAPI
  QCAPI->>QCDB: 1.1 查询 product_qc_review
  activate QCDB
  QCDB-->>QCAPI: review header
  deactivate QCDB
  QCAPI->>QCItemDB: 1.2 查询 product_qc_review_item
  activate QCItemDB
  QCItemDB-->>QCAPI: review items
  deactivate QCItemDB
  QCAPI->>StagingDB: 1.3 查询 staging snapshot / diff
  activate StagingDB
  StagingDB-->>QCAPI: snapshot
  deactivate StagingDB
  QCAPI-->>Ops: review context
  deactivate QCAPI

  alt Ops 审核通过
    Ops->>QCAPI: 2. 审核通过
    activate QCAPI
    QCAPI->>QCDB: 2.1 review.status = APPROVED<br/>reviewer_id / reviewed_at
    activate QCDB
    QCDB-->>QCAPI: updated
    deactivate QCDB
    QCAPI->>QCItemDB: 2.2 review_item.status = APPROVED
    activate QCItemDB
    QCItemDB-->>QCAPI: updated
    deactivate QCItemDB
    QCAPI->>StagingDB: 2.3 staging.status = APPROVED
    activate StagingDB
    StagingDB-->>QCAPI: updated
    deactivate StagingDB
    QCAPI->>ItemDB: 2.4 item.status = APPROVED
    activate ItemDB
    ItemDB-->>QCAPI: updated
    deactivate ItemDB
    QCAPI->>Publish: 2.5 enqueue publish candidate
    activate Publish
    Publish-->>QCAPI: queued
    deactivate Publish
    deactivate QCAPI
  else Ops 驳回
    Ops->>QCAPI: 2. 审核驳回 / 填写原因
    activate QCAPI
    QCAPI->>QCDB: 2.1 review.status = REJECTED<br/>reject_reason / reviewer_id
    activate QCDB
    QCDB-->>QCAPI: updated
    deactivate QCDB
    QCAPI->>QCItemDB: 2.2 review_item.status = REJECTED
    activate QCItemDB
    QCItemDB-->>QCAPI: updated
    deactivate QCItemDB
    QCAPI->>StagingDB: 2.3 staging.status = REJECTED
    activate StagingDB
    StagingDB-->>QCAPI: updated
    deactivate StagingDB
    QCAPI->>ItemDB: 2.4 item.status = REJECTED
    activate ItemDB
    ItemDB-->>QCAPI: updated
    deactivate ItemDB
    deactivate QCAPI
  end

  Note over Ops,Publish: 审核通过后进入发布队列,<br/>驳回则停留在供给治理链路内等待修复。
item.status = QC_PENDING
  → 创建 product_qc_review / product_qc_review_item
  → 审核员领取 review
  → 逐条查看 diff / 风险原因 / 规则命中
  → 审核通过: item.status = QC_APPROVED, staging.status = APPROVED
  → 审核驳回: item.status = FAILED 或 QC_REJECTED, staging.status = REJECTED

工程上要注意两点:

  1. product_qc_review 适合作为审核单头,product_qc_review_item 作为行级审核项。
  2. 审核动作要记录 reviewer、decision、reason、reviewed_at,避免只改状态不留审计。

26.8.6 Publisher 流程

Publisher 也应从 Item Worker 中拆开。它只消费已经进入 APPROVED / QC_APPROVED 的 staging 或 publish candidate,不再重复做标准化和风险判断。

sequenceDiagram
  title Excel 批量导入:发布流程
  participant Trigger as Auto Approve / QC API
  participant Publish as Publish Worker
  participant FormalTable as Formal Item Table
  participant Outbox
  participant ItemDB as product_supply_task_item
  participant TaskDB as product_supply_task

  Trigger->>Publish: 1. enqueue publish candidate<br/>来源: 自动准入 / QC 审核通过

  alt 发布成功
    Publish->>FormalTable: 2. 发布正式商品表<br/>写 payload / publish_version
    activate Publish
    activate FormalTable
    FormalTable-->>Publish: published
    deactivate FormalTable

    Publish->>Outbox: 3. 写 Outbox 事件
    activate Outbox
    Outbox-->>Publish: queued
    deactivate Outbox

    Publish->>ItemDB: 4. 回写 item.status=PUBLISHED
    activate ItemDB
    ItemDB-->>Publish: updated
    deactivate ItemDB

    Publish->>TaskDB: 5. 聚合 task progress / success_count
    activate TaskDB
    TaskDB-->>Publish: updated
    deactivate TaskDB
    deactivate Publish
  else 发布失败
    Publish->>Outbox: 2. 写失败事件
    activate Publish
    activate Outbox
    Outbox-->>Publish: queued
    deactivate Outbox

    Publish->>ItemDB: 3. 回写 item.status=FAILED
    activate ItemDB
    ItemDB-->>Publish: updated
    deactivate ItemDB

    Publish->>TaskDB: 4. 聚合 failed_count
    activate TaskDB
    TaskDB-->>Publish: updated
    deactivate TaskDB
    deactivate Publish
  end

  Note right of Publish: 发布阶段只承接已经通过准入的候选项,<br/>负责正式表写入、状态回写和出箱事件。
Publish Worker
  → 领取 APPROVED / QC_APPROVED 的 staging
  → CAS staging -> PUBLISHING
  → 写正式商品表 / 索引刷新 / 缓存失效
  → 同事务写 Outbox
  → 更新 item.status = SUCCESS
  → 更新 staging.status = PUBLISHED

如果发布失败:

PUBLISHING
  → RETRY_WAITING / FAILED / DLQ

这样拆开以后,职责边界更清晰:

组件主要职责
Item Worker标准化、校验、写 staging、风险分流
QC Worker / 审核台人工审核高风险变更
Publish Worker只处理已准入的发布动作
Report Generator聚合终态结果,生成错误文件与质量报告

26.8.7 Report / 错误文件生成流程

Report Generator 可以独立于 Publish Worker。它订阅任务终态事件,或扫描已经进入终态的 task,聚合行级错误、QC 结果和发布结果,生成运营可读的错误文件与质量报告。

sequenceDiagram
  title Excel 批量导入:错误文件与质量报告生成
  participant Trigger as Task Terminal Event / Scheduler
  participant Report as Error File / Report Generator
  participant ItemDB as product_supply_task_item
  participant QCDB as product_qc_review_item
  participant TaskDB as product_supply_task
  participant FileStore as OSS / USS

  Trigger->>Report: 1. 触发生成错误文件 / 质量报告
  activate Report
  Report->>ItemDB: 2. 读取 FAILED / REJECTED / DLQ item
  activate ItemDB
  ItemDB-->>Report: item errors
  deactivate ItemDB

  Report->>QCDB: 3. 读取 QC 审核结果 / 驳回原因
  activate QCDB
  QCDB-->>Report: review results
  deactivate QCDB

  Report->>Report: 4. 聚合错误明细 / 统计指标 / 质量摘要
  Report->>FileStore: 5. 上传 error file / report
  activate FileStore
  FileStore-->>Report: error_file_ref / report_ref
  deactivate FileStore

  Report->>TaskDB: 6. 回写 error_file_ref / report_ref / error_file_name
  activate TaskDB
  TaskDB-->>Report: updated
  deactivate TaskDB
  deactivate Report

  Note right of Report: 报告生成失败不应阻塞发布主链路,<br/>可以异步重试或人工补生成。
Report Generator
  → 监听 task 终态事件或定时扫描终态 task
  → 聚合 FAILED / REJECTED / DLQ item
  → 拼装错误文件和质量报告
  → 上传文件存储
  → 回写 product_supply_task.error_file_ref / report_ref

26.8.8 状态机引用

批量导入链路中的 tasktask_item 状态机,建议统一收敛到 26.3 商品生命周期管理 中维护,避免在执行链路章节重复定义后逐渐漂移。

在本节里可以只记住两点:

  1. task 负责表达整批任务的阶段推进,如 PENDING → PARSING → RUNNING → QC_REVIEWING → PUBLISHING → SUCCESS/PARTIAL_FAILED/FAILED
  2. task_item 负责表达行级执行状态,如标准化、校验、进入 QC、发布成功、失败重试或进入 DLQ。

具体状态定义、状态说明和聚合规则,见 26.3.2.5 Task 状态机26.3.2.6 Task Item 状态机

26.8.9 错误文件

错误文件要能指导运营修复,而不是只写“导入失败”。

row_no, object_key, field, error_code, error_message, suggestion
12, SKU_001, price, PRICE_TOO_LOW, price lower than floor price, adjust price >= 100
25, OFFER_014, refund_rule, REFUND_RULE_MISSING, refund rule is required, choose a refund template
31, HOTEL_020, city_code, CITY_NOT_FOUND, city cannot map to platform city, add city mapping first

错误文件应该从 product_supply_task_itemproduct_validation_result 生成,而不是从日志里拼。

生成错误文件后,直接回写 product_supply_task.error_file_referror_file_name,这样运营后台可以通过 task 直接找到源文件和错误文件。


26.9 供应商商品同步链路

26.9.1 为什么单独设计

供应商同步和批量导入有相似之处:都是外部或非正式数据进入平台商品模型,都需要任务、明细、标准化、校验、Diff、发布和补偿。

但供应商同步还有额外复杂度:

维度批量导入 / 批量编辑供应商同步
来源运营、商家、内部系统外部供应商
触发方式人工上传、运营操作定时、全量、增量、Push、刷新
数据形态Excel、CSV、表单API、消息、分页、游标
失败原因格式错误、字段缺失、人工误操作超时、限流、5xx、游标失效、字段漂移
恢复重点错误文件、失败行重提Checkpoint、Worker Lease、Raw Snapshot、DLQ
新鲜度通常不是秒级很多品类强依赖新鲜度
交易前确认多数依赖平台配置Hotel / Flight / Movie 必须实时确认

26.9.2 推荐架构

Supplier Adapter
  → Sync Task / Batch
  → Page / Cursor Fetch
  → Raw Snapshot
  → Normalize
  → Quality Check
  → Supplier Mapping
  → Diff
  → product_supply_staging
  → Auto Approve / QC Review
  → Publish
  → Search / Cache / Downstream Event
  → Metrics / DLQ / Compensation

同步执行层使用专项表:

supplier_sync_task
supplier_sync_batch
supplier_sync_snapshot
supplier_sync_diff_log
supplier_sync_dead_letter

发布治理层复用供给平台:

product_supply_task
product_supply_task_item
product_supply_staging
product_validation_result
product_change_request
product_qc_review
product_qc_review_item
product_publish_snapshot
product_outbox_event

26.9.3 新鲜度分层

不同数据的刷新策略不同。

数据类型示例新鲜度要求策略
静态资源酒店名称、地址、设施、机场、车站小时级或天级全量 + 增量同步
半动态数据酒店最低价、可售状态、热门库存水位分钟级定时刷新 + 热门加频
强动态数据机票报价、座位图、下单前房态房价秒级或实时搜索缓存,详情刷新,下单实时确认
交易契约退款规则、履约参数、供应商映射强一致倾向发布版本控制,不随意覆盖

原则是:

列表页可以快,详情页要准,创单必须安全。


26.10 标准化、质量校验与风险审核

26.10.1 标准化

不同入口的数据格式不同,但必须统一到平台商品模型:

入口数据
  → Resource
  → SPU / SKU
  → Offer / Rate Plan
  → Stock Config / Sellable Rule
  → Input Schema
  → Fulfillment Rule
  → Refund Rule

标准化阶段要记录字段来源和 payload hash:

field_source:
  title: OPS
  hotel_address: SUPPLIER
  refund_rule: PLATFORM

这对字段主导权、供应商覆盖、事故追溯非常重要。

26.10.2 质量校验

质量校验不能只做字段必填。

校验层校验内容失败处理
Schema 校验类型、必填、枚举、长度、格式行级失败
类目模板校验类目要求的对象和字段是否完整阻断提交
主数据校验城市、商户、品牌、Resource 是否存在进入人工映射
商品模型校验SPU、SKU、Offer、Rate Plan 关系是否成立阻断发布
交易契约校验库存来源、Input Schema、履约规则、退款规则阻断发布
可售校验商品状态、库存、价格、渠道、站点是否允许售卖阻断上线或告警
风险校验价格、类目、履约、退款、映射是否高风险自动准入、进入 QC 或阻断

26.10.3 来源准入与风险审核

审核策略应该差异化,而不是所有变更都人工 QC。这里的“审核”落库到 product_qc_reviewproduct_qc_review_itemproduct_change_request 负责记录字段 Diff 和风险结论,QC 表负责记录审核工单和审核结论。

准入策略先看来源,再看风险:

qc_policy =
  source_policy
  + operator_trust_level
  + risk_level
  + category_policy
  + field_policy

默认推荐:

来源默认策略说明
LOCAL_OPSAUTO_APPROVE本地运营是内部可信操作源,校验通过后自动准入,不创建 QC 审核单
MERCHANTQC_REQUIRED商家是外部操作源,上传创建和编辑默认进入 QC
SUPPLIER风险分流静态低风险字段可自动准入,高风险 Diff 进入 QC
SYSTEM继承策略补偿、回放、迁移任务继承原始任务或质量问题单策略

这个策略能避免两个极端:一是把本地运营所有动作都堆进 QC,导致运营效率很低;二是让商家自助上传绕过 QC,导致低质量商品污染线上。

risk_score =
  field_weight
  + change_ratio_weight
  + category_weight
  + operator_risk_weight
  + product_heat_weight
  + supplier_quality_weight
变更类型风险等级策略
本地运营创建商品低/中校验通过后自动准入,不创建 QC
商家上传商品低/中默认进入 QC,审核素材、类目、交易契约
标题、描述、小图修正本地运营自动准入,商家进入 QC
普通图片变更低/中图片质量校验通过后,本地运营自动准入,商家进入 QC
库存水位调整自动校验,通过后发布,异常告警
价格或 Offer 规则变更中高超阈值进入 QC
类目变更强制 QC
履约类型或退款规则变更强制 QC
Resource / Supplier Mapping 变更强制 QC 并触发巡检

QC 审核单要保存:

review_id
task_id
source_type
staging_id
change_id
changed_fields
risk_level
review_policy
reviewer_id
review_note
reject_reason
status

审核员看到的不是一段 JSON,而是字段级 Diff、风险原因、历史版本、供应商原始数据或运营输入证据。

26.10.4 QC 阶段位置

QC 是发布前质量闸口,不是录入阶段,也不是最终商品主表。但并不是所有来源都需要 QC:商家上传默认需要,本地运营上传默认不需要。

供给入口
  → Draft / Task / Item
  → Staging
  → Validation
  → Diff / Risk
  → Source Policy
  → Auto Approve / QC Pending / Block
  → QC Review
  → Publish

不同入口的 QC 处理方式不同:

入口QC 触发点处理方式
本地运营单商品创建后端强校验通过后自动准入,不创建 QC 审核单
商家单商品创建后端强校验通过后创建 QC 审核单,QC 通过后发布
本地运营编辑字段 Diff 和风险评分后默认自动准入,高危动作走权限和二次确认
商家编辑字段 Diff 和风险评分后默认进入 QC,驳回后回到 Draft
本地运营批量导入每个 product_supply_task_item 校验完成后成功项自动准入,失败项生成错误文件
商家批量导入每个 product_supply_task_item 校验完成后成功项生成 QC item,不阻塞失败项错误文件
批量编辑每个商品、SKU、Offer 或规则 Diff 后按来源和风险生成准入策略,支持部分通过、部分驳回
供应商同步Normalize + Diff 后高风险差异进入 QC,低风险差异自动发布
质量巡检缺陷修复提交后修复结果进入 QC,避免修复动作二次污染线上

QC 的关键原则是:QC 通过才允许进入发布事务,QC 驳回不能修改正式商品表。驳回项应该回到 Draft、错误文件、DLQ 或质量问题单,由运营修复后重新提交。


26.11 发布一致性设计

QC 通过或自动准入不代表商品已经可售。发布要保证商品主数据、资源映射、交易契约、库存可售、搜索缓存和下游系统最终一致。

26.11.1 发布事务

QC 通过 / 自动准入
  → 开启发布事务
  → 写 Resource / SPU / SKU / Offer / Rate Plan
  → 写 Stock Config / Sellable Rule
  → 写 Input Schema / Fulfillment Rule / Refund Rule
  → 生成 publish_version
  → 写 product_publish_snapshot
  → 写 product_change_log
  → 写 product_outbox_event
  → 提交事务
  → 异步刷新搜索、缓存、计价上下文、数据平台
  → 如涉及活动配置,异步调用营销系统命令

发布事务内只做商品中心必须强一致的事情。ES 刷新、缓存失效、计价上下文刷新都通过 Outbox 异步执行;营销活动配置走营销系统命令或营销资格事件,不放进商品发布事务同步调用。

发布前必须二次确认 QC 状态:

SELECT status
FROM product_qc_review
WHERE review_id = ?
  AND status = 'APPROVED';

如果高风险变更没有对应的 APPROVED QC 审核单,Publish Worker 必须拒绝发布。这样可以防止绕过审核接口直接调用发布接口。

26.11.2 发布版本和快照

每次发布生成 publish_version

product_id = 10001
old_publish_version = 21
new_publish_version = 22

发布快照用于:

  1. 订单创单保存商品上下文。
  2. 事故回滚。
  3. 对账和排查。
  4. 审核复盘。
  5. 搜索索引一致性校验。

订单系统不能回读最新商品解释历史订单,必须保存:

商品快照
报价快照
履约契约快照
退款规则快照
供应商映射快照

26.11.3 Outbox

Outbox 事件示例:

ProductPublished
ProductContentChanged
OfferChanged
SellableRuleChanged
FulfillmentRuleChanged
SearchIndexRefreshRequired
ProductCacheInvalidationRequired

product_outbox_event 至少包含:

event_id
event_type
aggregate_type
aggregate_id
publish_version
payload
status
retry_count
next_retry_at

如果搜索刷新失败,不回滚商品发布,而是进入 Outbox 补偿。


26.12 运营管理能力

26.12.1 字段主导权

字段主导权解决的是“供应商同步和人工运营谁覆盖谁”。

字段主导方供应商同步能否覆盖运营策略
标题、卖点、活动标签平台运营运营编辑为准
酒店名称、地址、设施供应商/平台治理低风险可覆盖,高风险审核可人工修正并设置保护期
展示图片平台运营/供应商取决于来源质量图片变更需要质量校验
基础价、Rate Plan供应商/计价取决于品类超阈值审核
库存水位、可售状态库存域/供应商人工覆盖必须有有效期
退款规则、履约规则平台/供应商契约高风险覆盖强制 QC
类目、Resource 映射平台治理强制 QC 和数据巡检

当运营覆盖供应商字段时,建议记录:

field_path
owner_type
override_until
override_reason
operator_id

供应商同步遇到保护字段时,不自动覆盖,只记录 Diff 和冲突日志。

26.12.2 权限与审计

权限要拆成两层:

  1. 功能权限:是否能创建、编辑、导入、审核、发布。
  2. 数据范围权限:能操作哪些类目、商家、供应商、站点、渠道。

审计日志至少记录:

who
when
what
before
after
reason
trace_id
task_id
publish_version

高风险操作必须强制备注,例如:

  1. 批量改价。
  2. 类目迁移。
  3. 退款规则变更。
  4. 供应商映射变更。
  5. 热门商品下架。

26.12.3 回滚与灰度

回滚不是简单把字段改回去。需要区分:

回滚对象处理
商品主数据回滚到指定 publish_version
搜索索引根据快照重建索引
缓存失效或刷新旧版本
营销圈品重新向营销系统提交活动配置命令,或重新投递商品营销资格事件
订单不回滚历史订单快照

灰度发布可以按:

  1. 站点。
  2. 渠道。
  3. 城市。
  4. 白名单用户。
  5. 商品热度。

26.13 DLQ、补偿与质量巡检

26.13.1 失败分类

失败类型示例处理
输入失败Excel 字段非法、必填缺失生成错误文件,运营修复后重新提交
映射失败城市、商户、品牌、Resource 找不到进入人工映射队列
审核失败高风险变更被驳回回到草稿,保留驳回原因
发布失败DB 冲突、版本过期重试或要求基于最新版本重新编辑
下游失败ES 刷新失败、缓存失效失败Outbox 补偿
质量失败缺图、缺价、无库存、不可履约质量巡检下架或告警

26.13.2 DLQ 表

product_supply_dead_letter 是可运营问题单,不只是消息队列里的失败消息。

dead_letter_id
task_id
item_no
error_stage
error_type
error_code
error_message
raw_payload_ref
status
retry_count
next_retry_at
assignee
fix_note

DLQ 状态机:

PENDING
  → RETRYING
  → RESOLVED

PENDING
  → MANUAL_FIX
  → RETRYING
  → RESOLVED

PENDING
  → IGNORED

RETRYING
  → FAILED

26.13.3 质量巡检

质量巡检要覆盖:

  1. 缺图商品。
  2. 缺价商品。
  3. 无库存商品。
  4. 无履约规则商品。
  5. 退款规则缺失商品。
  6. 供应商映射缺失商品。
  7. 发布版本与搜索索引不一致。
  8. 运营覆盖字段过期。

质量指标:

商品质量缺陷率 = 缺陷商品数 / 在线商品数
发布失败率 = 发布失败任务 / 发布任务
索引刷新成功率 = ES 刷新成功 / 总刷新
DLQ 修复率 = RESOLVED DLQ / TOTAL DLQ

26.14 可观测性与稳定性

26.14.1 任务看板

运营后台至少要能看到:

任务进度:总数、成功、失败、跳过、当前阶段
失败原因:错误码、错误字段、建议修复方式、错误文件
审核队列:风险等级、命中规则、Diff、责任人
发布结果:publish_version、Outbox 状态、索引/缓存刷新状态
质量看板:缺图、缺价、无库存、无履约规则、映射缺失

核心指标:

指标说明
任务成功率成功任务 / 总任务
行级成功率成功 item / 总 item
任务完成耗时从创建到发布完成
自动准入占比自动准入 / 总提交
QC 驳回率驳回 / QC 提交
发布失败率发布失败 / 发布任务
索引刷新成功率ES 刷新成功 / 总刷新
商品质量缺陷率缺图、缺价、无库存、映射缺失商品占比

26.14.2 隔离与限流

批量任务不能拖垮交易链路。

建议隔离:

  1. 批量导入队列。
  2. 批量发布队列。
  3. 供应商同步队列。
  4. Outbox 刷新队列。
  5. 质量巡检队列。

大促前夜,运营批量改价、供应商全量同步、搜索索引重建如果共用同一组 Worker 和数据库连接池,很容易互相放大故障。默认策略应该是:

交易读链路优先
单商品编辑优先
小批量发布优先
大批量任务限速
供应商异常熔断

26.14.3 幂等与并发

幂等分层:

层级幂等 Key
任务触发task_type + trigger_id
批量行级task_id + item_notask_id + idempotency_key
暂存对象task_id + object_type + object_key
发布object_id + payload_hash + base_publish_version
Outboxevent_id

并发控制:

  1. 编辑基于 base_publish_version
  2. 发布时 CAS 校验版本。
  3. 失败后要求重新基于最新版本生成 Diff。
  4. 供应商同步遇到运营保护字段时不覆盖。

26.15 与其他系统的集成

26.15.1 商品中心

供给平台通过命令 API 写商品中心,不直连商品正式表。

命令应该表达业务意图:

CreateProductDefinition
ChangeProductContent
ChangeOfferRule
ChangeRefundRule
PublishProductVersion
OfflineProduct

不要让运营后台执行:

UPDATE product_sku SET price = ? WHERE sku_id = ?;

26.15.2 库存系统

库存创建和修改的运营入口在供给平台,但库存事实必须留在库存系统。26.6 已经展开库存运营任务的设计,这里只强调系统集成边界:供给平台发起库存命令,库存系统幂等执行事实变更并返回事件。

供给平台可以发起的库存命令包括:

  1. CreateInventory:初始化库存来源、范围、扣减时机和初始库存实例。
  2. AdjustInventory:数量补货、库存调整、锁定和解锁。
  3. ImportCodeBatch / GenerateCodeBatch:导入或生成券码批次,由库存系统加密落库和维护状态机。
  4. MaterializeTimeStoreStock:创建门店、日期、时段等库存切片。
  5. RebuildAvailability:发布可售规则并刷新可售投影。
  6. 创单时仍由交易链路调用库存系统 Reserve / Confirm / Release。

这里的底线是:供给平台负责表单、审批、任务、错误文件、进度和审计;库存系统负责幂等执行、CAS、预占、账本和对账。不要让运营后台执行:

UPDATE inventory_balance SET available_stock = ? WHERE inventory_key = ?;

26.15.3 营销系统

供给平台可以和营销系统集成,但集成的是活动配置、圈品和营销资格,不是优惠计算。常见动作包括:

  1. BindProductToCampaign:把商品、SKU 或门店范围加入活动。
  2. UpdatePromotionEligibility:同步商品是否具备参加活动的资格。
  3. SyncProductMarketingTags:同步活动标签、频道标签或运营分组。
  4. UnbindProductFromCampaign:商品下架、售罄、禁售或活动结束时解除圈品。

边界要清楚:供给平台负责表单、审批、Diff、发布版本和审计;营销系统负责活动规则、预算、券、补贴、营销库存、优惠叠加和成本归因。供给平台不要保存最终活动价,也不要直接核销券或锁定营销预算。

活动配置不能放进商品发布事务里同步调用。更稳妥的链路是:

商品发布 / 活动资格变更
  → 写 product_outbox_event
  → Marketing Coordinator 消费事件
  → 调用营销系统命令
  → 营销系统返回 CampaignBindingReady / Failed
  → 供给运营后台展示协同状态和补偿入口

26.15.4 计价系统

商品供给发布的是基础价格事实和 Offer 规则,计价系统根据商品版本、渠道、会员、营销活动和结算规则做试算。供给平台不要直接调用计价系统写最终成交价,发布后只通过 Outbox 让计价上下文消费者重建价格投影。

不要把活动价手填到商品表里,否则会造成:

  1. 计价口径漂移。
  2. 营销成本无法对账。
  3. 退款时无法解释优惠来源。

26.15.5 搜索系统

发布后通过 Outbox 刷新搜索索引。供给平台不直接写 ES,也不把 ES 写入成功作为商品发布事务的一部分。

搜索索引要保存:

product_id
publish_version
title
category
tags
display_price
sellable_status
updated_at

索引版本落后时,巡检任务要能发现:

product_publish_version != search_index_publish_version

26.15.6 订单系统

供给平台不直接调用订单系统修改订单,也不向订单系统推送“最新商品配置”。订单系统只在创单时读取当时可交易上下文,并保存商品、报价、履约和退款规则快照。

订单创建时不能只保存 sku_id,还要保存商品上下文快照。

product_snapshot
offer_snapshot
price_snapshot
input_schema_snapshot
fulfillment_rule_snapshot
refund_rule_snapshot
supplier_mapping_snapshot

这样后续商品改价、下架、退款规则调整,不影响历史订单解释。


26.16 答辩材料

本章相关总结、常见问题和参考要点已统一收录到第 39 章的“商品供给与运营治理”题卡

延伸阅读建议

  • 第 25 章:商品中心模型(Resource、SPU/SKU、Offer、类目属性)
  • 第 28 章:营销系统
  • 第 29 章:计价系统设计与实现
  • 本章后半部分:统一供给与运营治理平台
  • 本章后半部分:供应商数据同步链路

26.17 统一供给与运营治理平台

26.17.1 背景

商品供给与运营治理平台解决的是“商品如何进入平台、如何被审核发布、如何创建和修改库存、上线后如何持续维护”的问题。它不是运营后台的 CRUD,也不是供应商同步的一组定时任务,而是一条长期运行的供给治理流水线。

在数字商品平台中,商品供给来源通常有五类:

人工创建/上传
  → 运营或商家从 0 到 1 创建商品

批量导入
  → 通过模板、Excel、CSV 或文件批量创建和修改商品

运营编辑
  → 对线上商品做标题、图片、类目、价格、库存、上下架、履约和退款规则变更

库存创建 / 修改
  → 初始化库存、补货、导入券码、系统生码、锁库存、门店和日期库存调整

供应商同步
  → 从外部供应商全量、增量、Push 或主动刷新供给数据

供应商同步属于商品供给链路,但它不是商品供给链路的全部。更合理的设计是:用一套统一的供给治理控制面承接五类入口,共享任务模型、暂存区、校验、审核、发布版本、Outbox、DLQ、补偿和质量监控;其中供应商同步因为有长任务、Checkpoint、Raw Snapshot、Worker 租约和数据新鲜度问题,单独作为专项链路展开,详见本章后续的 26.18 小节。

本附录聚焦统一供给治理平台,尤其补足人工上传、批量导入、运营编辑和库存运营四条控制面链路。

26.17.2 整体方案介绍

这一节先不进入表设计和执行细节,而是先用一条主链路把统一供给治理平台串起来。核心目标是让读者先看到“整体怎么转”,再理解为什么后面需要 DraftStaging、任务化执行、Outbox、DLQ 和补偿机制。

26.17.2.1 全生命周期主链路总览

flowchart LR
    A["供给入口"] --> B["Draft / Staging"]
    B --> C["标准化与校验"]
    C --> D["Diff / 风险识别"]
    D --> E["QC / 自动准入"]
    E --> F["发布到商品中心"]
    F --> G["库存 / 营销 / 搜索 / 计价协同"]
    G --> H["订单使用快照交易"]
    F --> I["Outbox 下游投影"]
    I --> J["巡检 / 对账 / 补偿"]

这条主链路表达的是统一供给治理平台的核心职责:

  1. 所有供给动作先进入 Draft / Staging,而不是直接污染正式商品。
  2. 标准化、校验、Diff、风险识别和审核,构成正式发布前的门禁。
  3. 发布之后不是“流程结束”,而是进入库存、营销、搜索、计价、缓存和订单快照的协同阶段。
  4. 通过 Outbox、巡检、对账和补偿保证最终一致,而不是要求一次同步调用把所有下游都写成功。

26.17.2.2 决策点:是否需要 Staging

很多系统只有 DraftPublished 两层,觉得“提交审核前是草稿,审核通过后是正式”就够了。但一旦涉及批量导入、供应商同步、差异审核和回滚,Draft 往往不够表达“已提交但未正式发布的快照”。

方案优点缺点 / 风险适用场景推荐结论
方案 A:无 StagingDraft 直接审核发布状态简单已提交版本和正在编辑版本难区分;批量治理困难简单后台有条件可用
方案 B:引入 Staging 作为提交快照更适合审核、Diff、回滚、批处理模型稍复杂中大型供给平台推荐

推荐方案是方案 B。Draft 表示“正在编辑的工作副本”,Staging 表示“已提交、待审核或待发布的静态快照”,两者语义不同,不建议合并。

26.17.2.3 决策点:同步体验和异步任务如何分层

方案优点缺点 / 风险适用场景推荐结论
方案 A:全部同步处理用户感知简单批量导入、供应商同步会拖垮接口小流量、低复杂度不推荐
方案 B:全部异步任务化统一执行模型单品创建体验差,交互成本高内部工具型系统不推荐
方案 C:单品同步体验,批量与长链路异步任务化体验与治理平衡需要双模式编排平台型业务推荐

推荐方案是方案 C:表单手工创建、低风险单品编辑保留同步交互;批量导入、批量编辑、券码导入、供应商同步全部任务化。

26.17.2.4 设计目标

  1. 入口统一:人工创建、批量导入、运营编辑、库存创建 / 修改、供应商同步进入统一任务和发布框架。
  2. 线上隔离:所有未校验、未审核、未发布的数据只进入 Draft / Staging,不污染正式商品表。
  3. 质量可控:通过类目模板、主数据校验、交易契约校验、风险规则和审核流控制发布质量。
  4. 发布一致:商品主数据、资源映射、Offer、库存控制面、履约规则、退款规则、营销协同、搜索索引、缓存和计价上下文最终一致。
  5. 失败可恢复:任务、行级明细、错误文件、DLQ、Outbox 和补偿任务形成闭环。
  6. 变更可追溯:每次发布都有 Diff、审核记录、操作者、TraceID、发布版本和商品快照。
  7. 运营可用:运营能看到任务进度、失败原因、错误文件、审核状态、发布结果和质量报表。

26.17.3 核心难点与解决方法

难点典型表现风险解决方法
入口多且语义不同人工创建、批量导入、运营编辑、库存创建 / 修改、供应商同步都在改变供给能力流程混乱、重复逻辑、审计缺失统一为 Supply Task,但按 task_type 路由不同策略
未发布数据污染线上表单保存、导入半成品直接写商品正式表前台展示脏数据,订单拿到半成品契约Draft / Staging 与正式表分离,只有发布事务写正式表
类目差异大酒店、话费、账单、券码、电影票字段完全不同表单和校验 if-else 爆炸类目模板 + 能力矩阵 + Schema 驱动表单和校验
批量导入规模大大促前一次导入 10 万行商品或价格内存爆、长事务、失败难定位流式解析、行级任务、分批处理、部分成功、错误文件
运营误操作批量改价、类目迁移、退款规则变更资损、投诉、履约失败Diff、风险评分、二次确认、人工审核、灰度发布
供应商与运营冲突供应商同步覆盖运营修正字段运营修复失效,线上数据反复抖动字段主导权、保护期、版本锁、冲突日志
审核策略粗糙所有变更都人工审核或全部自动通过效率低或风险失控风险分级:低风险自动,中风险规则校验,高风险强审
发布不一致DB 成功,ES / 缓存 / 计价上下文没刷新,或营销活动协同失败搜不到、价格错、活动不可用、下单失败发布事务 + Outbox + 营销命令 + 异步投影 + 补偿重试
历史订单受影响商品改价、改退款规则后影响旧订单售后争议、财务对不上创单保存商品快照、报价快照、履约和退款规则快照
失败不可运营只在日志里记录导入失败运营不知道怎么修MySQL DLQ + 错误文件 + 修复建议 + 重新投递
质量缺陷长期存在缺图、缺价、无库存、无履约规则转化差、履约失败商品质量巡检、质量分、自动下架或告警

核心判断已统一收录到第 39 章的“商品供给与运营治理”题卡

26.17.4 总体架构

架构图如下:

商品供给与运营治理平台总体架构

图源文件:

  • books/system-design-architecture-book/images/product-supply-ops-architecture.png
  • books/system-design-architecture-book/images/product-supply-ops-architecture.svg
  • source/diagrams/Excalidraw/product-supply-ops-architecture.excalidraw
Supply Entry
  → Draft / Staging
  → Supply Task
  → Standardization
  → Quality Validation
  → Diff & Risk Scoring
  → Review / Auto Approval
  → Publish Transaction
  → Outbox Event
  → Search / Cache / Pricing Context / Data Platform
  → Marketing Command / Eligibility Event
  → DLQ / Compensation / Quality Inspection

分层职责如下:

层级职责关键产物
供给入口层接收表单、文件、API、供应商同步数据原始输入、来源、操作者、TraceID
暂存层保存未发布数据Draft、Staging Snapshot、payload hash
任务层编排一次供给动作Task、Task Item、进度、错误文件
标准化层转成平台统一模型Resource、SPU、SKU、Offer、Rule
校验层判断是否完整、合法、可售校验结果、错误码、质量分
风险审核层判断是否自动通过或人工审核Diff、风险等级、审核单
发布层写正式表、生成版本publish version、product snapshot
集成层通过 Outbox 通知搜索、缓存、计价上下文和数据平台,通过营销命令协同活动配置Outbox、索引任务、缓存失效任务、营销协同任务
治理层失败补偿、质量巡检、报表DLQ、补偿任务、质量日报

31.1 核心表分组

商品供给与运营链路的表设计要覆盖草稿、任务、行级处理、暂存、校验、变更审核、发布、补偿审计八类能力。

表组典型表作用
Draft 草稿表product_supply_draftproduct_supply_draft_version保存单商品创建、单商品编辑过程中的草稿,草稿可反复保存,不进入发布
Task 任务表product_supply_task记录一次供给动作:单商品创建、单商品编辑、批量导入、批量编辑、供应商同步后的商品变更
Task Item 明细表product_supply_task_item记录任务中每一行、每个商品、每个 Offer 或每条规则的处理状态
Staging 暂存表product_supply_stagingproduct_supply_staging_snapshot保存已经提交、已经标准化、但还没有发布到正式表的数据
Validation 校验表product_validation_result保存字段、类目、主数据、商品模型、交易契约、风险规则的校验结果
Change / Audit 表product_change_requestproduct_audit_log保存字段 Diff、风险等级、审核策略、审核人、审核结论和驳回原因
Publish / Snapshot 表product_publish_recordproduct_publish_snapshotproduct_change_log保存发布批次、商品完整快照和正式发布后的变更日志
Outbox / DLQ / Compensation 表product_outbox_eventproduct_supply_dead_letterproduct_compensation_taskproduct_quality_issue保证下游一致性,承接失败问题单、补偿任务和质量巡检

这些表不是为了把商品中心再复制一遍。供给平台负责流程治理和发布编排,正式商品数据仍然写入商品中心主数据表,例如:

resource_tab
product_spu_tab
product_sku_tab
product_offer_tab
rate_plan_tab
stock_config_tab
sellable_rule_tab
fulfillment_rule_tab
refund_rule_tab

第一期建议保留最小闭环:

product_supply_draft
product_supply_task
product_supply_task_item
product_supply_staging
product_validation_result
product_change_request
product_audit_log
product_publish_snapshot
product_change_log
product_outbox_event
product_supply_dead_letter

供应商同步执行层独立维护 supplier_sync_tasksupplier_sync_batchsupplier_sync_snapshotsupplier_sync_dead_letter,但标准化后的商品变更要进入供给平台:

supplier_sync_batch
  → Normalize
  → product_supply_task(task_type=SUPPLIER_SYNC_IMPORT)
  → product_supply_task_item
  → product_supply_staging
  → product_validation_result
  → product_change_request
  → Publish

26.17.5 领域边界

商品供给与运营平台不应该替代商品中心、库存系统、计价系统、搜索系统、订单系统或营销系统。它的职责是“供给流程和发布治理”,不是所有商品数据的唯一存储,也不是到处同步写下游的超级后台。

系统负责什么不负责什么
供给与运营平台入口、任务、暂存、校验、审核、发布编排、库存创建 / 修改运营入口、营销活动配置入口、补偿、审计C 端高 QPS 商品查询、库存扣减、库存账本事实、计价试算、搜索索引直写、订单状态维护、营销优惠计算
商品中心Resource、SPU、SKU、Offer、Rate Plan、类目、属性正式模型运营任务进度和错误文件
库存系统库存事实、库存创建命令执行、库存扣减、券码池、实时可售、库存账本商品标题、图片、类目、运营审核流
计价系统价格规则、试算、应付金额、优惠叠加商品生命周期审核
营销系统活动、券、补贴、预算、营销库存、圈品规则、优惠计算规则商品供给流程、商品生命周期和库存账本
搜索系统可检索字段、召回、排序、索引刷新商品发布事务
订单系统商品快照、报价快照、履约契约快照商品最新主数据维护

设计原则:

  1. 供给平台负责流程,商品中心负责正式模型。
  2. 库存创建 / 修改的运营入口在供给平台,库存事实和扣减账本在库存系统。
  3. 搜索、缓存、计价上下文和数据平台通过 Outbox 事件感知变更,不由运营后台直接写入。
  4. 营销系统通过活动配置命令或营销资格事件协同,但活动规则、预算、券、补贴、营销库存和优惠计算仍归营销系统。
  5. 订单只相信创单时保存的快照,不回读最新商品配置解释历史订单,也不由供给平台直接修改订单。

26.17.6 任务模型

31.1 Task:一次供给动作

product_supply_task 记录一次人工创建、批量导入、运营编辑或供应商同步动作。

CREATE TABLE product_supply_task (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_id VARCHAR(64) NOT NULL,
    task_type VARCHAR(32) NOT NULL
        COMMENT 'MANUAL_CREATE/BATCH_IMPORT/OPS_EDIT/SUPPLIER_SYNC',
    execution_mode VARCHAR(16) NOT NULL DEFAULT 'SYNC'
        COMMENT 'SYNC/ASYNC',
    source_type VARCHAR(32) NOT NULL COMMENT 'OPS/MERCHANT/SUPPLIER/SYSTEM',
    source_id VARCHAR(64) DEFAULT NULL,
    category_code VARCHAR(32) NOT NULL,
    operator_id VARCHAR(64) DEFAULT NULL,
    trigger_id VARCHAR(64) DEFAULT NULL COMMENT '外部幂等 ID',
    template_version VARCHAR(64) DEFAULT NULL,
    status VARCHAR(32) NOT NULL
        COMMENT 'DRAFT/PENDING/PARSING/RUNNING/VALIDATING/REVIEWING/APPROVED/PUBLISHING/PUBLISHED/PARTIAL_FAILED/REJECTED/FAILED/CANCELLED',
    total_count INT NOT NULL DEFAULT 0,
    parsed_count INT NOT NULL DEFAULT 0,
    success_count INT NOT NULL DEFAULT 0,
    failed_count INT NOT NULL DEFAULT 0,
    skipped_count INT NOT NULL DEFAULT 0,
    current_stage VARCHAR(64) DEFAULT NULL,
    input_file_ref VARCHAR(512) DEFAULT NULL,
    parse_checkpoint VARCHAR(1024) DEFAULT NULL,
    error_file_ref VARCHAR(512) DEFAULT NULL,
    publish_version BIGINT DEFAULT NULL,
    worker_id VARCHAR(64) DEFAULT NULL,
    lease_token VARCHAR(64) DEFAULT NULL,
    lease_until DATETIME DEFAULT NULL,
    heartbeat_at DATETIME DEFAULT NULL,
    created_at DATETIME NOT NULL,
    started_at DATETIME DEFAULT NULL,
    finished_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_task_id (task_id),
    UNIQUE KEY uk_task_trigger (task_type, trigger_id),
    KEY idx_status (status),
    KEY idx_category_status (category_code, status),
    KEY idx_operator_time (operator_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给任务';

31.2 Task Item:行级或对象级明细

批量导入和供应商同步必须支持部分成功,因此任务要拆到 item 维度。

CREATE TABLE product_supply_task_item (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_id VARCHAR(64) NOT NULL,
    item_no VARCHAR(64) NOT NULL COMMENT '文件行号、表单对象序号或外部对象序号',
    item_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE/SPU/SKU/OFFER/RATE_PLAN/STOCK/RULE',
    idempotency_key VARCHAR(128) NOT NULL,
    platform_resource_id BIGINT DEFAULT NULL,
    spu_id BIGINT DEFAULT NULL,
    sku_id BIGINT DEFAULT NULL,
    offer_id BIGINT DEFAULT NULL,
    status VARCHAR(32) NOT NULL
        COMMENT 'PENDING/NORMALIZING/VALIDATING/STAGING/DIFFING/REVIEWING/PUBLISHING/SUCCESS/FAILED/DLQ/SKIPPED',
    risk_level VARCHAR(32) DEFAULT NULL COMMENT 'LOW/MEDIUM/HIGH',
    error_code VARCHAR(128) DEFAULT NULL,
    error_message VARCHAR(1024) DEFAULT NULL,
    raw_row_ref VARCHAR(512) DEFAULT NULL,
    staging_id VARCHAR(64) DEFAULT NULL,
    change_id VARCHAR(64) DEFAULT NULL,
    normalized_ref VARCHAR(512) DEFAULT NULL,
    normalized_payload_hash VARCHAR(64) DEFAULT NULL,
    retry_count INT NOT NULL DEFAULT 0,
    max_retry_count INT NOT NULL DEFAULT 5,
    next_retry_at DATETIME DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_task_item (task_id, item_no),
    UNIQUE KEY uk_task_idempotency (task_id, idempotency_key),
    KEY idx_task_status (task_id, status),
    KEY idx_platform_object (platform_resource_id, spu_id, sku_id, offer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给任务明细';

31.3 状态机

DRAFT
  → PENDING
  → PARSING
  → RUNNING
  → VALIDATING
  → REVIEWING
  → APPROVED
  → PUBLISHING
  → PUBLISHED

PARSING / RUNNING / VALIDATING / REVIEWING / PUBLISHING
  → PARTIAL_FAILED / FAILED / REJECTED

PENDING / PARSING / RUNNING / VALIDATING / REVIEWING
  → CANCELLED

状态说明:

状态含义
DRAFT表单草稿或导入任务草稿
PENDING已提交,等待执行
PARSING批量任务正在解析文件并生成 item
RUNNING批量任务正在分批处理 item
VALIDATING正在标准化和质量校验
REVIEWING有高风险项进入审核
APPROVED审核通过,等待发布
PUBLISHING正在写正式表和 Outbox
PUBLISHED全部发布成功
PARTIAL_FAILED部分 item 成功、部分失败
REJECTED审核驳回
FAILED整体失败
CANCELLED人工取消

26.17.7 暂存区与快照

所有入口都必须先写暂存区,不能直接写商品正式表。

CREATE TABLE product_supply_staging (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    staging_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) NOT NULL,
    item_no VARCHAR(64) NOT NULL,
    object_type VARCHAR(32) NOT NULL
        COMMENT 'RESOURCE/SPU/SKU/OFFER/RATE_PLAN/STOCK_CONFIG/INPUT_SCHEMA/FULFILLMENT_RULE/REFUND_RULE',
    object_key VARCHAR(128) NOT NULL,
    source_type VARCHAR(32) NOT NULL,
    source_ref VARCHAR(512) DEFAULT NULL,
    raw_payload_ref VARCHAR(512) DEFAULT NULL,
    normalized_payload JSON NOT NULL,
    payload_hash VARCHAR(64) NOT NULL,
    base_publish_version BIGINT DEFAULT NULL,
    status VARCHAR(32) NOT NULL
        COMMENT 'DRAFT/VALIDATED/REVIEWING/APPROVED/PUBLISHED/REJECTED',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_staging_id (staging_id),
    UNIQUE KEY uk_task_object (task_id, object_type, object_key),
    KEY idx_status (status),
    KEY idx_object_key (object_type, object_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给暂存数据';

暂存区的作用:

  1. 保护线上正式表,不让半成品商品被搜索或下单。
  2. 支持审核员查看发布前快照。
  3. 支持 Diff、风险评分、回放和问题排查。
  4. 支持失败后修复并重新发布。

26.17.8 人工创建链路

人工创建适合运营或商家少量创建商品,例如本地生活券、礼品卡、账单缴费入口、活动套餐。

选择类目
  → 加载类目模板
  → 填写 Resource / SPU / SKU / Offer / Rule
  → 前端实时校验
  → 保存 Draft
  → 提交 Supply Task
  → 后端强校验
  → 生成 Staging Snapshot
  → 审核
  → 发布

关键难点:

难点解决方法
不同品类字段差异巨大类目模板驱动表单,模板定义字段、类型、是否必填、校验规则
运营只填商品标题和价格,遗漏交易契约提交时强校验 Offer、库存来源、履约规则、退款规则、Input Schema
新商品审核缺少上下文审核页展示标准化快照、类目模板、风险命中、历史相似商品
草稿反复修改Draft 与 Staging 分离,草稿不生成发布版本
创建成功但无法下单发布后做可售校验:库存、价格、履约、退款、搜索索引状态

类目模板示例:

{
  "category_code": "HOTEL",
  "required_objects": ["RESOURCE", "SPU", "OFFER", "RATE_PLAN", "REFUND_RULE"],
  "fields": [
    {"name": "hotel_name", "type": "string", "required": true},
    {"name": "city_code", "type": "string", "required": true},
    {"name": "geo.lat", "type": "decimal", "required": true},
    {"name": "geo.lng", "type": "decimal", "required": true}
  ]
}

26.17.9 批量导入链路

批量导入适合大促、类目迁移、商家批量上新、套餐批量配置。

下载模板
  → 上传文件
  → 文件格式预检
  → 创建 product_supply_task
  → 流式解析
  → 每行生成 product_supply_task_item
  → 分批标准化
  → 行级校验
  → 成功项发布或审核
  → 失败项生成错误文件
  → 汇总任务状态

核心难点与解决方法:

难点解决方法
文件过大流式解析,不一次性读入内存
导入耗时长分批提交,后台异步执行,前台轮询进度
局部失败行级状态,成功项继续,失败项生成错误文件
重复上传task_type + trigger_id 幂等,行级 idempotency_key 去重
模板演进文件记录 template_version,旧模板兼容或拒绝
批量事故高风险字段批量变更进入抽样审核或二次确认
下游被打爆发布和索引刷新限速,使用 Outbox 背压

31.1 异步执行总流程

批量导入和批量编辑不能只有一个后台线程从头跑到尾。更稳妥的方式是拆成解析、行级处理、审核发布和结果归档几个阶段。

上传文件 / 批量提交
  → 创建 product_supply_task(status=PENDING, execution_mode=ASYNC)
  → Parser Worker 流式解析文件
  → 批量写入 product_supply_task_item
  → Item Worker 分批处理 item
  → 标准化 / 校验 / Staging / Diff
  → 低风险自动发布,高风险进入审核
  → Publish Worker 发布正式表并写 Outbox
  → 生成错误文件 / DLQ / 质量报告

这个拆法有三个好处:

  1. 解析失败不会污染正式商品表。
  2. 行级失败不会拖垮整批任务。
  3. 发布和下游刷新可以限速、重试和补偿。

31.2 Parser Worker:只解析,不发布

Parser Worker 的职责边界要非常窄:只负责把文件拆成 product_supply_task_item,不做正式发布。

1. CAS 抢占 product_supply_task
2. 校验 input_file_ref、文件 hash、模板版本和列结构
3. 流式读取文件,不能一次性加载到内存
4. 每 N 行批量插入 product_supply_task_item
5. 更新 parsed_count、parse_checkpoint、heartbeat_at
6. 解析完成后写 total_count
7. task.status 从 PARSING 推进到 RUNNING

parse_checkpoint 用来恢复解析进度:

{
  "sheet": "Sheet1",
  "row_no": 12000,
  "byte_offset": 8842211
}

如果 Parser Worker 在第 12000 行宕机,下次恢复时允许重复解析上一小批。重复数据由两个唯一键兜住:

UNIQUE(task_id, item_no)
UNIQUE(task_id, idempotency_key)

注意:Excel 这类格式不一定天然支持稳定的 byte_offset 恢复。工程上可以先把上传文件转换成规范化 CSV 或行级 JSONL,再按 offset 恢复;也可以按 row_no 从头快速跳过。核心原则是 checkpoint 控制重跑范围,幂等保证重复处理不写错。

31.3 Task Item:行级事实表

product_supply_task_item 是批量链路里最重要的表。Task 只说明“这次批量任务怎么样”,Item 才能回答“第几行、哪个商品、哪个 Offer 为什么失败”。

Item 状态机建议设计为:

PENDING
  → NORMALIZING
  → VALIDATING
  → STAGING
  → DIFFING
  → REVIEWING
  → PUBLISHING
  → SUCCESS

失败分支:
NORMALIZING / VALIDATING / STAGING / DIFFING / PUBLISHING
  → FAILED / DLQ / SKIPPED

关键字段含义:

字段作用
item_no文件行号或批量对象序号
idempotency_key行级业务幂等键,防止重复导入
raw_row_ref原始行数据引用,方便生成错误文件和回放
normalized_ref标准化后 payload 引用
staging_id通过校验后的暂存数据
change_idDiff 后生成的变更单
retry_count / next_retry_at自动重试控制

31.4 Item Worker:分批处理行级任务

Item Worker 不按“整个文件”处理,而是扫描一小批待处理 item。

SELECT *
FROM product_supply_task_item
WHERE task_id = ?
  AND status IN ('PENDING', 'FAILED')
  AND next_retry_at <= NOW()
ORDER BY item_no ASC
LIMIT 500;

每个 item 或小批次使用独立事务:

读取 raw_row_ref
  → CAS 将 item 推进到 NORMALIZING
  → 按类目模板标准化成 Resource / SPU / SKU / Offer / Rule
  → 写 normalized_ref
  → 执行 Schema / 主数据 / 商品模型 / 交易契约校验
  → 校验通过后写 product_supply_staging
  → 与线上 publish_version 做 Diff
  → 生成 product_change_request
  → 根据 risk_level 自动发布或进入 REVIEWING
  → 更新 item.status

不要用一个大事务包住 500 行。正确做法是行级或小批次事务,否则一行失败会拖垮整批,也会造成长事务、锁等待和回滚成本过高。

31.5 Staging、Diff 与发布合流

Item Worker 校验通过后,只能写 product_supply_staging,不能直接写正式商品表。

product_supply_task_item
  → product_supply_staging
  → product_change_request
  → product_publish_snapshot
  → product_outbox_event

base_publish_version 很重要。批量导入或批量编辑可能基于旧版本生成,如果发布时线上商品已经被别人改过,必须识别版本冲突,不能静默覆盖。

风险分流建议如下:

风险等级处理
LOW自动准入,进入发布
MEDIUM规则校验通过后发布,异常进入审核
HIGH强制进入人工审核

Publish Worker 只处理已经 APPROVEDAUTO_APPROVE 的变更:

读取 approved change
  → 开启发布事务
  → 写 Resource / SPU / SKU / Offer / Rule
  → 写 publish_snapshot
  → 写 product_change_log
  → 写 product_outbox_event
  → 提交事务
  → item.status = SUCCESS

ES、缓存和计价上下文不要放在发布事务里同步调用,统一由 Outbox 消费者异步刷新;营销活动配置走营销系统命令或营销资格事件,不在发布事务内同步写营销规则。

31.6 Task 状态汇总

Task 状态不要靠 Worker 主观判断,而要从 item 状态聚合。

Item 汇总结果Task 状态
全部 SUCCESSPUBLISHED
部分 SUCCESS,部分 FAILED/DLQPARTIAL_FAILED
全部失败FAILED
存在 REVIEWINGREVIEWING
存在 PUBLISHINGPUBLISHING
任务被人工取消CANCELLED

统计可以每批 item 处理完成后增量更新,也可以由定时聚合 Job 修正。运营后台看到的进度来自 task 计数,但失败定位必须下钻到 item。

31.7 失败处理

批量异步链路的失败要按阶段处理:

失败阶段示例处理
文件级失败文件损坏、模板版本不支持、列结构缺失task 直接 FAILED,不生成大量 item
行级格式失败价格非法、字段缺失、枚举非法item FAILED,写错误文件
主数据失败城市、商户、品牌不存在item DLQMANUAL_FIX
风险失败改价过大、退款规则变化change_request REVIEWING
发布失败版本冲突、DB 冲突、唯一键冲突item 延迟重试,超过次数进 DLQ
下游失败ES、缓存刷新失败Outbox 补偿,不回滚发布事务

错误文件应该从 product_supply_task_itemproduct_validation_result 生成,而不是从日志拼出来。

31.8 设计原则

  1. Parser Worker 只解析,不发布。
  2. Item Worker 按行级状态推进,支持部分成功。
  3. 所有 item 处理必须幂等。
  4. Staging 是正式表前的隔离层。
  5. 发布必须版本化,不能覆盖未知的新版本。
  6. 下游刷新走 Outbox,不阻塞发布事务。
  7. Task 管整体进度,Item 才是真正的问题定位单元。

错误文件要能指导运营修复,而不是只写“导入失败”:

row_no, object_key, field, error_code, error_message, suggestion
12, SKU_001, price, PRICE_TOO_LOW, price lower than floor price, adjust price >= 100
25, OFFER_014, refund_rule, REFUND_RULE_MISSING, refund rule is required, choose a refund template
31, HOTEL_020, city_code, CITY_NOT_FOUND, city cannot map to platform city, add city mapping first

26.17.10 运营编辑链路

运营编辑针对线上商品,需要解决“谁能改、改什么、是否覆盖供应商数据、什么时候生效、如何回滚”的问题。

读取当前 publish_version
  → 创建编辑草稿
  → 修改字段
  → 生成 Diff
  → 字段主导权判断
  → 风险评分
  → 自动通过 / 人工审核 / 阻断
  → 发布新 publish_version
  → Outbox 通知读侧投影
  → 营销活动配置异步协同

31.1 字段主导权

字段主导方供应商同步能否覆盖运营策略
标题、卖点、活动标签平台运营运营编辑为准
酒店名称、地址、设施供应商/平台治理低风险可覆盖,高风险审核可人工修正并设置保护期
展示图片平台运营/供应商取决于来源质量图片变更需要质量校验
基础价、Rate Plan供应商/计价取决于品类超阈值审核
库存水位、可售状态库存域/供应商人工覆盖必须有有效期
退款规则、履约规则平台/供应商契约高风险覆盖强制审核
类目、Resource 映射平台治理强制审核和数据巡检

31.2 冲突处理

常见冲突:

运营改了酒店名称
  → 供应商增量同步又推回旧名称

运营批量下架一批商品
  → 供应商同步推送可售状态为可售

运营修复城市映射
  → 供应商全量同步发现城市字段不同

解决方法:

  1. 对每个字段定义 owner_type:OPS、SUPPLIER、SYSTEM。
  2. 运营覆盖供应商字段时记录 override_untiloverride_reason
  3. 供应商同步遇到运营保护字段时只记录 Diff,不自动覆盖。
  4. 高风险冲突进入审核队列。
  5. 保护期到期后由巡检任务决定是否恢复供应商主导。

26.17.11 标准化与质量校验

质量校验要分层,不要只做字段必填。

校验层校验内容失败处理
Schema 校验类型、必填、枚举、长度、格式行级失败
类目模板校验类目要求的对象和字段是否完整阻断提交
主数据校验城市、商户、品牌、Resource 是否存在进入人工映射
商品模型校验SPU、SKU、Offer、Rate Plan 关系是否成立阻断发布
交易契约校验库存来源、Input Schema、履约规则、退款规则阻断发布
可售校验商品状态、库存、价格、渠道、站点是否允许售卖阻断上线或告警
风险校验价格、类目、履约、退款、映射是否高风险进入审核

质量分可以作为运营看板:

quality_score =
  content_score
  + model_score
  + sellability_score
  + fulfillment_score
  + risk_score

如果商品缺图、缺价、无库存、无履约规则,即使主表写入成功,也不能认为供给成功。

26.17.12 Diff 与风险审核

审核不是所有变更都走人工。系统应该根据 Diff 和风险规则决定处理方式。

CREATE TABLE product_change_request (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    change_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) NOT NULL,
    object_type VARCHAR(32) NOT NULL,
    object_id BIGINT DEFAULT NULL,
    old_publish_version BIGINT DEFAULT NULL,
    new_staging_id VARCHAR(64) NOT NULL,
    changed_fields JSON NOT NULL,
    risk_level VARCHAR(32) NOT NULL COMMENT 'LOW/MEDIUM/HIGH',
    review_policy VARCHAR(32) NOT NULL COMMENT 'AUTO_APPROVE/MANUAL_REVIEW/BLOCK',
    status VARCHAR(32) NOT NULL COMMENT 'PENDING/APPROVED/REJECTED/PUBLISHED',
    reviewer_id VARCHAR(64) DEFAULT NULL,
    review_note VARCHAR(1024) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_change_id (change_id),
    KEY idx_task (task_id),
    KEY idx_status_risk (status, risk_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给变更单';

风险策略:

变更类型风险等级策略
标题、描述、小图修正自动通过,记录日志
普通图片变更低/中图片质量校验通过后发布
库存水位调整自动校验,通过后发布,异常告警
价格或 Offer 规则变更中高超阈值人工审核
类目变更强制审核
履约类型或退款规则变更强制审核
Resource / Supplier Mapping 变更强制审核并触发巡检

风险评分示例:

risk_score =
  field_weight
  + change_ratio_weight
  + category_weight
  + product_heat_weight
  + operator_history_weight
  + source_trust_weight

26.17.13 发布一致性设计

审核通过不等于商品可售。发布阶段要把商品主数据和交易前契约一次性落到可追溯版本上。

开始发布事务
  → 校验 base_publish_version
  → 写 Resource / SPU / SKU / Offer / Rate Plan
  → 写 Stock Config / Sellable Rule
  → 写 Input Schema / Fulfillment Rule / Refund Rule
  → 写 Supplier Mapping 或 Merchant Mapping
  → 生成 publish_version
  → 生成 product_snapshot
  → 写 product_change_log
  → 写 outbox_event
提交事务
  → 异步刷新搜索、缓存、计价上下文、数据平台
  → 如涉及活动配置,异步调用营销系统命令

关键设计:

设计点解决的问题
base_publish_version 乐观锁防止基于旧版本覆盖新版本
publish_version支持回滚、审计、对账
product_snapshot支持订单快照、问题排查
outbox_event防止商品已变更但下游没收到事件
异步刷新避免发布事务被 ES、缓存、计价上下文和营销协同拖慢
补偿任务下游刷新失败后可重试

Outbox 事件:

ProductPublished
ProductContentChanged
OfferChanged
RatePlanChanged
SellableRuleChanged
FulfillmentRuleChanged
RefundRuleChanged
SearchIndexRefreshRequired
ProductCacheInvalidationRequired

26.17.14 DLQ 与补偿

人工供给和运营编辑也需要 DLQ。它们的失败通常不是供应商接口失败,而是输入错误、映射错误、审核驳回、版本冲突、发布失败和下游刷新失败。

CREATE TABLE product_supply_dead_letter (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    dead_letter_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) NOT NULL,
    task_type VARCHAR(32) NOT NULL,
    item_no VARCHAR(64) DEFAULT NULL,
    object_type VARCHAR(32) DEFAULT NULL,
    object_key VARCHAR(128) DEFAULT NULL,
    platform_resource_id BIGINT DEFAULT NULL,
    spu_id BIGINT DEFAULT NULL,
    sku_id BIGINT DEFAULT NULL,
    offer_id BIGINT DEFAULT NULL,
    error_stage VARCHAR(64) NOT NULL COMMENT 'PARSE/VALIDATION/MAPPING/REVIEW/PUBLISH/OUTBOX/INDEX/CACHE',
    error_type VARCHAR(64) NOT NULL COMMENT 'RETRYABLE/NON_RETRYABLE/MAPPING_REQUIRED/RISK_BLOCKED/VERSION_CONFLICT',
    error_code VARCHAR(128) NOT NULL,
    error_message VARCHAR(1024) NOT NULL,
    payload_ref VARCHAR(512) DEFAULT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
        COMMENT 'PENDING/RETRYING/MANUAL_FIX/RESOLVED/IGNORED/FAILED',
    retry_count INT NOT NULL DEFAULT 0,
    max_retry_count INT NOT NULL DEFAULT 5,
    next_retry_at DATETIME DEFAULT NULL,
    owner_team VARCHAR(64) DEFAULT NULL,
    assignee VARCHAR(64) DEFAULT NULL,
    fix_note VARCHAR(1024) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    resolved_at DATETIME DEFAULT NULL,
    UNIQUE KEY uk_dead_letter_id (dead_letter_id),
    KEY idx_status_next_retry (status, next_retry_at),
    KEY idx_task (task_id),
    KEY idx_error_code (error_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给死信队列';

补偿策略:

失败类型示例处理方式
可重试失败DB 短暂失败、Outbox 发送失败指数退避重试
输入失败文件字段非法、必填缺失生成错误文件,运营修复后重新提交
映射失败城市、商户、品牌找不到人工补映射后重新投递
风险阻断价格异常、退款规则风险人工审核或驳回
版本冲突基于旧版本编辑重新拉取最新版本再编辑
下游失败ES、缓存刷新失败Outbox 补偿重试

26.17.15 可观测性

运营后台和监控系统要能回答五个问题:

  1. 任务跑到哪里了?
  2. 为什么失败?
  3. 谁需要处理?
  4. 修复后如何重新投递?
  5. 发布后前台是否真的可见、可买、可履约?

核心指标:

指标类型指标
任务进度总数、成功数、失败数、跳过数、当前阶段
效率指标任务完成耗时、P95 / P99、排队时间
质量指标字段缺失率、映射失败率、缺图率、缺价率、无库存率
审核指标自动审核占比、人工审核耗时、驳回率
发布指标发布成功率、版本冲突数、回滚次数
下游指标ES 刷新失败数、缓存失效失败数、Outbox 堆积
运营指标错误文件下载次数、人工修复耗时、DLQ 修复率

质量巡检任务:

  1. 缺图商品巡检。
  2. 缺价商品巡检。
  3. 无库存商品巡检。
  4. 无履约规则商品巡检。
  5. 退款规则缺失巡检。
  6. 搜索索引与发布版本一致性巡检。
  7. 缓存版本与发布版本一致性巡检。
  8. 运营覆盖字段到期巡检。

26.17.16 典型异常场景

异常风险处理
运营重复点击提交重复创建任务task_type + trigger_id 幂等
导入文件 10 万行中 500 行失败整批回滚影响效率部分成功,失败行生成错误文件
发布时发现版本冲突覆盖别人刚发布的变更base_publish_version 乐观锁,要求重新编辑
审核通过但 ES 刷新失败前台搜不到Outbox 补偿刷新
商品发布成功但库存未初始化前台可见不可买可售校验不通过,不进入 ONLINE
运营改标题后被供应商覆盖人工修复失效字段主导权和保护期
大批量改价低于底价资损风险规则阻断,人工审核
退款规则变更影响历史订单售后争议订单保存退款规则快照
质量巡检发现缺履约规则下单后无法履约自动下架或阻断可售,进入 DLQ

26.17.17 与供应商同步的关系

统一供给治理平台和供应商同步专项链路的关系如下:

统一供给治理平台
  → 统一任务模型
  → 统一暂存与发布模型
  → 统一校验、Diff、审核、Outbox、补偿

供应商同步专项链路
  → Raw Snapshot
  → Sync Batch
  → Checkpoint
  → Worker Lease
  → Supplier Mapping
  → 数据新鲜度

供应商同步产生的标准化数据最终也应该进入供给治理平台的校验、Diff、审核和发布机制。不同点在于,供应商同步多了拉取、分页、断点续跑、租约抢占、原始快照和供应商质量监控。

26.17.18 答辩材料

本专题相关总结、常见问题和参考回答已统一收录到第 39 章的“供应商同步与人工上传”题卡

26.18 供应商数据同步链路

26.18.1 背景

数字商品平台需要从外部供应商同步供给数据。本方案讨论的是一条通用的供应商数据同步链路,并以酒店供给全量同步为例展开。酒店数据规模大、结构复杂、变化频率不一致:酒店名称、地址、设施、图片等静态信息变化较慢;房型、套餐、最低价、可售状态等半动态信息需要更高频刷新;下单前房态房价必须实时确认。

本设计聚焦一个典型任务:

通过遍历所有城市,从供应商拉取酒店信息
酒店规模约 100 万
任务预计运行 10 小时
需要支持断点续跑、失败补偿、数据追溯和质量监控

这类任务不能只依赖进程内状态做一个长循环。第一阶段更推荐设计成 Batch + Checkpoint + DLQ 的可恢复流水线:任务可以按城市和分页顺序遍历,进度持久化在数据库里,失败后从 checkpoint 继续。任务分片和分布式 Worker 抢占可以作为后续优化项目,而不是一开始就进入主链路。

26.18.2 设计目标

  1. 可恢复:任务中断后可以从 checkpoint 继续,不从头重跑。
  2. 可追溯:保存供应商原始数据 Raw Snapshot,支持问题排查和回放。
  3. 可治理:通过标准化、质量校验、Diff、版本控制,避免错误数据污染平台模型。
  4. 可补偿:失败数据进入 DLQ,支持自动重试、人工修复和重新投递。
  5. 可观测:实时查看任务进度、失败原因、供应商质量和业务影响指标。
  6. 不影响交易安全:列表页可缓存,详情页更接近实时,创单前必须实时确认。

26.18.3 核心难点

难点说明设计策略
任务时间长100 万酒店跑 10 小时,中途失败概率高Batch + Page/Cursor Checkpoint
数据量大全量同步可能包含酒店、房型、图片、设施等大 payloadRaw Snapshot 存引用,主表保持轻量
供应商不稳定超时、限流、5xx、分页游标失效限流、熔断、指数退避、DLQ
模型不一致供应商酒店/房型/套餐与平台 Resource/SPU/SKU/Offer 不一致标准化映射 + supplier mapping
数据质量不稳定字段缺失、城市映射失败、价格异常、坐标漂移分层质量校验 + 部分成功
发布风险同步成功不代表可以发布sync version、snapshot version、publish version 分离
下游一致性DB 更新成功但 ES、缓存、事件可能失败Outbox + 索引补偿

26.18.4 总体架构

Full Sync Task
  → Sync Batch
  → Page Fetch
  → Raw Snapshot
  → Normalize
  → Quality Check
  → Resource Mapping
  → Diff
  → Publish
  → Search / Cache / Downstream Event
  → Metrics / DLQ / Compensation

架构图见:

供应商数据同步链路架构图

Data Flow Diagram 见:

供应商数据同步 Data Flow Diagram

图文件:

  • books/system-design-architecture-book/images/supplier-sync-architecture.png
  • books/system-design-architecture-book/images/supplier-sync-architecture.svg
  • books/system-design-architecture-book/images/supplier-sync-data-flow.png
  • books/system-design-architecture-book/images/supplier-sync-data-flow.svg

26.18.5 任务模型

30.1 Task:同步任务定义

supplier_sync_task 描述“要同步什么、怎么同步、多久同步一次”。

CREATE TABLE supplier_sync_task (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_code VARCHAR(64) NOT NULL,
    supplier_id BIGINT NOT NULL,
    category_code VARCHAR(32) NOT NULL,
    sync_mode VARCHAR(32) NOT NULL COMMENT 'FULL/INCREMENTAL/PUSH/REFRESH',
    data_scope VARCHAR(64) NOT NULL COMMENT 'RESOURCE/PRODUCT/OFFER/STOCK_PRICE',
    schedule_type VARCHAR(32) NOT NULL COMMENT 'CRON/MANUAL/PUSH',
    cron_expr VARCHAR(64) DEFAULT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'ENABLED/DISABLED',
    concurrency_policy VARCHAR(32) NOT NULL DEFAULT 'SKIP_IF_RUNNING'
        COMMENT 'SKIP_IF_RUNNING/CANCEL_PREVIOUS/ALLOW_PARALLEL',
    last_batch_id VARCHAR(64) DEFAULT NULL,
    owner_team VARCHAR(64) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_task_code (task_code),
    KEY idx_supplier_category (supplier_id, category_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步任务定义';

样例:

task_code: hotel_supplier_full_resource
supplier_id: 1001
category_code: HOTEL
sync_mode: FULL
data_scope: RESOURCE
schedule_type: MANUAL
status: ENABLED
concurrency_policy: SKIP_IF_RUNNING
owner_team: product-sync

30.2 Batch:一次任务执行批次

supplier_sync_batch 记录一次任务执行的状态、水位、统计和版本。

CREATE TABLE supplier_sync_batch (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    batch_id VARCHAR(64) NOT NULL,
    task_code VARCHAR(64) NOT NULL,
    trigger_source VARCHAR(32) NOT NULL COMMENT 'CRON/MANUAL/COMPENSATION',
    trigger_id VARCHAR(64) DEFAULT NULL COMMENT '外部触发幂等 ID',
    supplier_id BIGINT NOT NULL,
    category_code VARCHAR(32) NOT NULL,
    sync_mode VARCHAR(32) NOT NULL,
    data_scope VARCHAR(64) NOT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'PENDING/RUNNING/SUCCESS/PARTIAL_FAILED/FAILED/CANCELLED',
    sync_batch_version BIGINT NOT NULL,
    start_checkpoint VARCHAR(512) DEFAULT NULL,
    end_checkpoint VARCHAR(512) DEFAULT NULL,
    total_count INT NOT NULL DEFAULT 0,
    success_count INT NOT NULL DEFAULT 0,
    failed_count INT NOT NULL DEFAULT 0,
    skipped_count INT NOT NULL DEFAULT 0,
    current_city_code VARCHAR(64) DEFAULT NULL,
    current_page INT DEFAULT NULL,
    progress_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
    worker_id VARCHAR(64) DEFAULT NULL,
    lease_token VARCHAR(64) DEFAULT NULL,
    lease_until DATETIME DEFAULT NULL,
    heartbeat_at DATETIME DEFAULT NULL,
    last_heartbeat_stage VARCHAR(64) DEFAULT NULL,
    last_heartbeat_message VARCHAR(512) DEFAULT NULL,
    last_checkpoint_at DATETIME DEFAULT NULL,
    created_at DATETIME NOT NULL,
    started_at DATETIME DEFAULT NULL,
    finished_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    error_message VARCHAR(1024) DEFAULT NULL,
    UNIQUE KEY uk_batch_id (batch_id),
    UNIQUE KEY uk_task_trigger (task_code, trigger_id),
    KEY idx_task_status (task_code, status),
    KEY idx_status_lease (status, lease_until),
    KEY idx_supplier_time (supplier_id, started_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步批次';

样例:

batch_id: batch_20260427_hotel_full_001
task_code: hotel_supplier_full_resource
trigger_source: MANUAL
trigger_id: req_20260427_0001
supplier_id: 1001
category_code: HOTEL
sync_mode: FULL
data_scope: RESOURCE
status: RUNNING
sync_batch_version: 202604270001
total_count: 1000000
success_count: 688200
failed_count: 320
skipped_count: 12000
current_city_code: BKK
current_page: 120
progress_percent: 68.82
worker_id: hotel-sync-worker-pod-a1b2c3-12345-20260427T103000Z
lease_token: 7f2d4c77-5d5b-4f1f-aeb0-74f7f21c6e2a
lease_until: 2026-04-27 10:35:00
heartbeat_at: 2026-04-27 10:30:00
last_heartbeat_stage: FETCHING
last_heartbeat_message: fetching city=BKK page=120
last_checkpoint_at: 2026-04-27 10:29:50

26.18.6 任务创建、互斥与执行恢复

30.1 任务创建流程

一次同步任务通常由定时调度、运营手动触发或系统补偿触发。无论来源是什么,都不应该直接启动一个进程开始跑,而是先创建 batch,再由执行器领取 batch。

触发同步
  → 查询 supplier_sync_task
  → 检查任务是否 ENABLED
  → 检查 trigger_id 幂等
  → 检查互斥策略
  → 创建 supplier_sync_batch(status=PENDING)
  → 执行器抢占 batch
  → 执行同步

创建 batch 时要初始化:

字段说明
batch_id本次执行唯一 ID
trigger_source / trigger_id触发来源和外部请求幂等 ID
sync_batch_version本次同步批次版本
status初始为 PENDING
start_checkpoint本次任务起点,通常为空或上次成功水位
end_checkpoint当前进度,任务执行过程中不断推进
total_count预计处理数量,可先为空或估算
worker_id / lease_token执行器抢占后写入

任务创建也要做幂等。运营后台重复点击、调度器重试、网络超时后重发,都可能重复触发同一个任务。推荐由调用方传入 trigger_id,例如运营后台的 manual_request_id 或调度系统的 fire_id

同一个 task_code + trigger_id
  → 只允许创建一个 batch
  → 重复请求直接返回已存在 batch

如果是定时任务,可以用计划触发时间生成 trigger_id

trigger_id = hotel_supplier_full_resource:2026-04-27T02:00:00Z

30.2 上一次任务还没执行完怎么办

同一个供应商、同一个品类、同一个数据范围的全量任务,通常不应该同时跑多个,否则会造成供应商限流、重复写入、发布版本乱序和进度混乱。这里需要显式定义互斥策略。

策略含义适用场景
SKIP_IF_RUNNING如果已有运行中的 batch,新触发直接跳过定时全量同步、普通刷新
CANCEL_PREVIOUS取消旧 batch,启动新 batch人工修复后需要重新跑全量
ALLOW_PARALLEL允许并行,但必须保证数据范围不重叠不同城市、不同供应商、不同数据 scope

默认建议使用 SKIP_IF_RUNNING。创建 batch 前先检查:

SELECT batch_id, status, heartbeat_at, lease_until
FROM supplier_sync_batch
WHERE task_code = ?
  AND status IN ('PENDING', 'RUNNING')
ORDER BY created_at DESC
LIMIT 1;

如果存在未完成 batch:

concurrency_policy = SKIP_IF_RUNNING
  → 不创建新 batch,记录 SKIPPED 日志

concurrency_policy = CANCEL_PREVIOUS
  → 将旧 batch 标记 CANCELLED
  → 创建新 batch

concurrency_policy = ALLOW_PARALLEL
  → 检查数据范围是否重叠
  → 不重叠才允许创建

相关答辩提示已统一收录到第 39 章的“供应商任务互斥”题卡

30.3 Batch 抢占

即使第一阶段不做任务分片,也建议 batch 由执行器通过 CAS 抢占,避免多个进程同时执行同一个 batch。抢占不是“查出来再更新”,而是用一条带条件的 UPDATE 完成。

UPDATE supplier_sync_batch
SET status = 'RUNNING',
    worker_id = ?,
    lease_token = ?,
    lease_until = DATE_ADD(NOW(), INTERVAL 5 MINUTE),
    heartbeat_at = NOW(),
    last_heartbeat_stage = 'CLAIMED',
    last_heartbeat_message = 'batch claimed',
    started_at = IFNULL(started_at, NOW()),
    updated_at = NOW()
WHERE batch_id = ?
  AND status = 'PENDING';

rows_affected = 1 表示抢占成功;rows_affected = 0 表示已经被其他执行器抢走,当前 worker 必须放弃执行。

对于机器重启、进程 OOM、发布中断后遗留的 RUNNING batch,可以允许抢占 lease 已经过期的 batch:

UPDATE supplier_sync_batch
SET worker_id = ?,
    lease_token = ?,
    lease_until = DATE_ADD(NOW(), INTERVAL 5 MINUTE),
    heartbeat_at = NOW(),
    last_heartbeat_stage = 'RECLAIMED',
    last_heartbeat_message = 'expired batch reclaimed',
    updated_at = NOW()
WHERE batch_id = ?
  AND status = 'RUNNING'
  AND lease_until < NOW();

注意,这里只抢占“租约过期”的任务,不抢占“心跳正常”的任务。否则一个慢请求、一次 GC 或一次网络抖动都可能导致双 worker 写同一个 batch。

30.4 worker_idlease_token

worker_id 用来标识“哪个执行器实例在跑任务”,lease_token 用来标识“本次抢占的所有权”。两者要同时使用。

字段作用是否稳定
worker_id标识执行器实例,方便排查、日志关联和监控展示进程生命周期内稳定
lease_token标识一次抢占行为,防止旧 worker 恢复后覆盖新 worker每次抢占重新生成

worker_id 可以用“服务名 + 机器/容器名 + 进程号 + 启动时间”生成:

func GenerateWorkerID(serviceName string) string {
    host := os.Getenv("POD_NAME")
    if host == "" {
        host = os.Getenv("HOSTNAME")
    }
    if host == "" {
        host, _ = os.Hostname()
    }

    pid := os.Getpid()
    startedAt := time.Now().UTC().Format("20060102T150405Z")
    return fmt.Sprintf("%s-%s-%d-%s", serviceName, host, pid, startedAt)
}

示例:

worker_id   = hotel-sync-worker-pod-a1b2c3-12345-20260427T103000Z
lease_token = 7f2d4c77-5d5b-4f1f-aeb0-74f7f21c6e2a

为什么还需要 lease_token?因为容器名或机器名可能复用,旧进程在长 GC 后也可能恢复。只有 worker_id 不够严格;lease_token 能保证“只有当前这次抢占的持有者”才能续租、推进 checkpoint 和结束任务。

所有关键更新都必须带上三个条件:

WHERE batch_id = ?
  AND worker_id = ?
  AND lease_token = ?

如果更新影响行数为 0,要立即停止当前任务,并记录 LEASE_LOST 日志。

30.5 心跳与租约

长任务不能只依赖 status=RUNNING 判断是否还活着。机器重启、进程 OOM、发布重启都可能导致状态永远卡在 RUNNING。因此 batch 要同时有“租约”和“心跳”。

概念解决的问题典型字段
心跳 Heartbeatworker 是否还活着heartbeat_atlast_heartbeat_stage
租约 Lease当前谁拥有任务执行权worker_idlease_tokenlease_until
Checkpoint任务恢复时从哪里继续end_checkpointlast_checkpoint_at

执行器每 15 到 30 秒续租一次,租约建议设置为 2 到 5 分钟。心跳间隔要远小于租约时长,给短暂网络抖动留下余量。

UPDATE supplier_sync_batch
SET heartbeat_at = NOW(),
    lease_until = DATE_ADD(NOW(), INTERVAL 5 MINUTE),
    last_heartbeat_stage = ?,
    last_heartbeat_message = ?,
    updated_at = NOW()
WHERE batch_id = ?
  AND worker_id = ?
  AND lease_token = ?
  AND status = 'RUNNING';

心跳建议上报的不只是“我还活着”,还要包含当前阶段:

阶段含义示例 message
FETCHING正在请求供应商接口fetching city=BKK page=120
SNAPSHOT_SAVING正在保存 Raw Snapshotsaving raw snapshot page=120
NORMALIZING正在做字段标准化normalizing 100 hotels
VALIDATING正在做质量校验validating schema and city mapping
PUBLISHING正在发布平台模型publishing resource changes
CHECKPOINTING正在推进 checkpointcheckpoint to page=121

如果心跳更新失败:

rows_affected = 0
  → 当前 worker 不再拥有任务
  → 停止拉取供应商
  → 停止写平台表
  → 打印 LEASE_LOST 日志
  → 退出执行

这一步非常关键。不能因为“当前进程还活着”就继续跑,因为数据库里的执行权可能已经被新 worker 抢走。

30.6 心跳正常但 Checkpoint 不动怎么办

心跳和 checkpoint 是两个维度。心跳正常只能说明 worker 还活着,不代表任务在前进。可能出现:

  1. 供应商接口一直卡在慢请求。
  2. 某个城市数据量异常大。
  3. Raw Snapshot 存储变慢。
  4. 发布阶段被数据库锁阻塞。
  5. worker 进入了内部死循环,但心跳线程仍然正常。

因此需要同时监控:

heartbeat_lag = now - heartbeat_at
checkpoint_lag = now - last_checkpoint_at

处理策略:

现象判断动作
heartbeat_lag 超过租约worker 失联允许新 worker 抢占
heartbeat_lag 正常,checkpoint_lag 过大worker 活着但进度卡住告警,不立即抢占
heartbeat_lag 正常,阶段长期不变某阶段阻塞根据阶段定位供应商、存储或发布问题

不要在心跳正常时强行抢占。否则可能造成两个 worker 同时处理同一页,只是其中一个更慢。

30.7 机器重启后如何恢复

机器重启后,原 worker 不再续租。调度器或新 worker 会发现:

SELECT batch_id
FROM supplier_sync_batch
WHERE status = 'RUNNING'
  AND lease_until < NOW();

恢复流程:

worker-01 执行 batch
  → 机器重启,心跳停止
  → lease_until 过期
  → worker-02 生成新的 worker_id 和 lease_token
  → worker-02 抢占过期 batch
  → 读取 end_checkpoint
  → 从 city/page/cursor 继续

这时可能重复处理上一页,所以处理逻辑必须幂等:

supplier_id + supplier_resource_code + supplier_product_code

Checkpoint 负责减少重跑范围,幂等负责保证重复处理也不会写错。

30.8 进度上报

进度不要只写日志,要落到 batch 表,便于运营后台、告警系统和排查工具读取。

每处理完一页,更新 checkpoint、统计、进度和心跳:

UPDATE supplier_sync_batch
SET end_checkpoint = ?,
    current_city_code = ?,
    current_page = ?,
    success_count = success_count + ?,
    failed_count = failed_count + ?,
    skipped_count = skipped_count + ?,
    progress_percent = ?,
    heartbeat_at = NOW(),
    lease_until = DATE_ADD(NOW(), INTERVAL 5 MINUTE),
    last_heartbeat_stage = 'CHECKPOINTING',
    last_heartbeat_message = ?,
    last_checkpoint_at = NOW(),
    updated_at = NOW()
WHERE batch_id = ?
  AND worker_id = ?
  AND lease_token = ?
  AND status = 'RUNNING';

上报频率建议按“页”或“固定时间窗口”控制:

上报方式优点缺点
每条酒店上报精确DB 写入过多
每页上报性能和准确性平衡失败时最多重复一页
每 30 秒上报写入少进度略滞后

推荐:每页处理完成后推进 checkpoint,同时每 15 到 30 秒续租心跳。如果一页处理时间可能超过心跳间隔,则需要独立心跳协程,不能等整页处理完成才心跳。

30.9 边界场景处理

场景风险处理
定时任务重复触发同一任务多个 batch 并发concurrency_policy=SKIP_IF_RUNNING
人工重复点击执行重复创建全量任务task_code + status 互斥
机器重启batch 卡在 RUNNINGlease 过期后新 worker 抢占
旧 worker 恢复覆盖新 worker checkpoint更新时校验 worker_id + lease_token
心跳正常但 checkpoint 不动worker 活着但卡住告警定位,不立即抢占
checkpoint 更新失败下次重复处理上一页页内写入必须幂等
checkpoint 先更新后处理失败数据被跳过必须先处理成功再推进 checkpoint
供应商短暂失败任务频繁失败指数退避、限流、熔断
任务被取消仍有 worker 在跑worker 每页检查 batch status
发布新版本进程退出checkpoint + lease 恢复

26.18.7 Checkpoint 与断点续跑

30.1 为什么需要 Checkpoint

100 万酒店、10 小时任务,如果只把进度放在内存里,会有三个问题:

  1. 任务中断后恢复困难。
  2. 机器重启后只能从头开始。
  3. 进度不可观测,不知道当前卡在哪里。

因此,第一阶段主设计不引入任务分片,而是在 supplier_sync_batch 上保存 checkpoint。任务仍然可以按城市和分页遍历,但每处理完一页就推进一次 checkpoint。

batch_001
  → city = BKK, page = 1
  → city = BKK, page = 2
  → ...
  → city = JKT, page = 1
  → ...

30.2 Checkpoint 存储

Checkpoint 可以先复用 supplier_sync_batch.start_checkpointsupplier_sync_batch.end_checkpoint,也可以在后续演进中拆出独立 checkpoint 表。

主链路里的 checkpoint 建议记录:

字段含义
city_code当前遍历到哪个城市
page当前处理到第几页
cursor供应商返回的下一页游标
last_supplier_hotel_id上一次成功处理的供应商酒店 ID
success_count当前批次已成功处理数量
failed_count当前批次失败数量
updated_atcheckpoint 更新时间

30.3 Checkpoint 是什么

Checkpoint 是同步任务“跑到哪里了”的进度记录。它用于断点续跑。

示例:

{
  "city_code": "BKK",
  "page": 120,
  "cursor": "abc123",
  "last_supplier_hotel_id": "H998877"
}

如果 Bangkok 第 120 页失败,下次可以从 page 120 或 cursor abc123 继续,而不是从第一页重跑。

30.4 Checkpoint 怎么使用

推荐顺序是:先处理本页数据,再推进 checkpoint

拉取 BKK page=120
  → 保存 Raw Snapshot
  → 标准化
  → 质量校验
  → 平台模型映射
  → Diff / Publish
  → 本页处理成功
  → checkpoint = BKK page=121

不要先推进 checkpoint 再处理数据,否则机器在中间宕机会跳过未处理页面。

机器重启时的恢复流程:

机器重启 / 进程退出
  → 调度器重新启动 batch
  → 读取 batch.end_checkpoint
  → 从 city/page/cursor 继续拉取
  → 已处理过的一页允许重复处理
  → 通过 supplier_id + supplier_resource_code 幂等去重

Checkpoint 只能保证“不大范围重跑”,不能保证“绝不重复处理”。因此它必须和幂等设计配合使用。

26.18.8 拉取与限流

同步任务按城市和分页拉取:

city = BKK
page_size = 100
page = 1..N

容量估算:

1000000 hotels / 10 hours = 27.8 hotels/s

如果每页 100 个酒店:

1000000 / 100 = 10000 pages
10000 pages / 10 hours = 0.28 page/s

如果需要逐个拉酒店详情:

1000000 detail calls / 10 hours = 27.8 QPS

拉取并发度要受供应商限流约束:

fetch_concurrency = min(供应商限流 QPS / 单请求 QPS, 系统处理能力)

必须支持:

  1. 每供应商限流。
  2. 每城市请求节流。
  3. 超时控制。
  4. 失败指数退避。
  5. 供应商异常时熔断。

26.18.9 Raw Snapshot 与标准化

30.1 Raw Snapshot

Raw Snapshot 是供应商原始响应数据的快照。它不是平台商品模型,也不是最终发布数据,而是证据和可回放数据。

作用:

  1. 排查问题:线上价格或酒店信息异常时,可以还原供应商当时返回了什么。
  2. 支持回放:修复映射规则后,可以用原始数据重新跑同步。
  3. 支持 Diff:比较本次和上次数据变化。
  4. 明确责任:区分供应商数据错误和平台清洗映射错误。

30.2 Snapshot 表

CREATE TABLE supplier_sync_snapshot (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    snapshot_id VARCHAR(64) NOT NULL,
    batch_id VARCHAR(64) NOT NULL,
    supplier_id BIGINT NOT NULL,
    category_code VARCHAR(32) NOT NULL,
    supplier_resource_code VARCHAR(128) DEFAULT NULL,
    supplier_product_code VARCHAR(128) DEFAULT NULL,
    snapshot_type VARCHAR(32) NOT NULL COMMENT 'RAW/NORMALIZED',
    snapshot_version BIGINT NOT NULL,
    payload_ref VARCHAR(512) DEFAULT NULL,
    payload_hash VARCHAR(64) NOT NULL,
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_snapshot_id (snapshot_id),
    KEY idx_batch (batch_id),
    KEY idx_supplier_object (supplier_id, supplier_resource_code, supplier_product_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步快照';

样例:

snapshot_id: rs_20260427_000001
batch_id: batch_20260427_hotel_full_001
supplier_id: 1001
category_code: HOTEL
supplier_resource_code: hotel_8848
supplier_product_code: room_deluxe
snapshot_type: RAW
snapshot_version: 8
payload_ref: s3://hotel-sync/raw/2026/04/27/batch001/BKK/page120.json
payload_hash: 9a0f...e31c

30.3 标准化

供应商字段需要转换成平台标准模型:

供应商字段平台字段
supplier_hotel_idsupplier_resource_code
hotel_nameresource_name
city_codeplatform_city_id
addressaddress
latitudegeo.lat
longitudegeo.lng
facilitiesext_info.facilities

标准化后生成 NORMALIZED snapshot。

26.18.10 质量校验

质量校验分为五层:

校验层校验内容失败处理
Schema 校验必填字段、类型、枚举、时间格式、货币单位进入失败明细
主数据校验城市、国家、商圈、品牌是否存在进入人工映射
模型校验是否能映射 Resource / SPU / SKU / Offer阻断发布
交易校验价格异常、库存异常、可售状态矛盾高风险拦截
业务规则校验站点、渠道、品类是否允许售卖审核或灰度

质量校验要支持部分成功。100 万酒店同步中,不能因为 100 条失败就整批失败。

成功数据:继续发布
失败数据:写入 DLQ
高风险数据:进入审核或人工修复

26.18.11 平台模型映射

酒店通常作为 Resource 沉淀:

supplier_hotel_id
  → supplier_product_mapping
  → platform_resource_id

如果 mapping 存在:

更新 resource / ext_info / room 信息

如果 mapping 不存在:

创建 resource
创建 supplier mapping
必要时创建 SPU / SKU / Offer

酒店同步的核心落库模型:

平台模型说明
resource_tab酒店资源
resource_ext_hotel_tab酒店扩展信息,如地址、设施、坐标、评分
supplier_product_mapping_tab供应商酒店 ID 与平台酒店 ID 的映射
product_spu_tab需要平台售卖承接时创建
product_sku_tab固定售卖单元,部分酒店业务可不沉淀完整 SKU
product_offer_tab套餐、房型、房价计划等销售配置

26.18.12 版本与 Diff

版本分为三类:

版本含义用途
sync_batch_version本次同步任务版本排查哪次同步带来了变化
data_snapshot_version原始/标准化数据快照版本支持回放、diff、回滚
publish_version平台正式发布版本控制搜索、缓存、下游事件一致性

Diff 是标准化后的数据与当前线上发布版本之间的变化。

Normalized Snapshot
  vs
Current Published Resource

Diff 类型:

Diff 类型示例动作
NO_CHANGE无变化跳过
CONTENT_CHANGED酒店名称、地址变化更新详情缓存
IMAGE_CHANGED图片变化更新图片和缓存
GEO_CHANGED城市、坐标变化高风险,进入审核
ROOM_CHANGED房型变化更新房型或 Offer
SELLABILITY_CHANGED可售状态变化刷新可售状态

Diff 表:

CREATE TABLE supplier_sync_diff_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    diff_id VARCHAR(64) NOT NULL,
    batch_id VARCHAR(64) NOT NULL,
    supplier_id BIGINT NOT NULL,
    category_code VARCHAR(32) NOT NULL,
    supplier_resource_code VARCHAR(128) DEFAULT NULL,
    supplier_product_code VARCHAR(128) DEFAULT NULL,
    platform_resource_id BIGINT DEFAULT NULL,
    spu_id BIGINT DEFAULT NULL,
    sku_id BIGINT DEFAULT NULL,
    offer_id BIGINT DEFAULT NULL,
    old_publish_version BIGINT DEFAULT NULL,
    new_snapshot_version BIGINT NOT NULL,
    diff_type VARCHAR(64) NOT NULL COMMENT 'NO_CHANGE/CONTENT_CHANGED/PRICE_CHANGED/STOCK_CHANGED/RULE_CHANGED',
    changed_fields JSON NOT NULL,
    risk_level VARCHAR(32) NOT NULL COMMENT 'LOW/MEDIUM/HIGH',
    action VARCHAR(64) NOT NULL COMMENT 'IGNORE/AUTO_PUBLISH/REVIEW/DLQ',
    created_at DATETIME NOT NULL,
    UNIQUE KEY uk_diff_id (diff_id),
    KEY idx_batch (batch_id),
    KEY idx_action (action)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步差异日志';

样例:

diff_id: diff_20260427_000001
batch_id: batch_20260427_hotel_full_001
supplier_id: 1001
category_code: HOTEL
supplier_resource_code: hotel_8848
platform_resource_id: 50001
old_publish_version: 22
new_snapshot_version: 8
diff_type: CONTENT_CHANGED
changed_fields:
[
  {"field": "address", "old": "Old Road", "new": "New Road"},
  {"field": "facilities", "old": ["wifi"], "new": ["wifi", "pool"]}
]
risk_level: LOW
action: AUTO_PUBLISH

26.18.13 发布与下游刷新

发布时生成新的 publish_version

resource_id = 50001
old_publish_version = 21
new_publish_version = 22

发布后通过 Outbox 发事件:

HotelResourceUpdated
HotelMappingCreated
HotelContentChanged
HotelSearchIndexRefreshRequired

下游动作:

  1. 搜索索引刷新。
  2. 详情缓存失效。
  3. 商品质量报表更新。
  4. 数据平台 CDC。
  5. 营销、计价、订单读取新版本商品上下文。

26.18.14 DLQ 与补偿

30.1 为什么用 MySQL DLQ

酒店同步失败通常不是单纯消息失败,而是字段缺失、映射失败、价格异常、发布失败、索引失败等需要人工修复、状态流转和审计的问题。因此推荐:

Kafka DLQ:短期失败消息缓冲,可选
MySQL DLQ:权威问题单和补偿状态

30.2 DLQ 表

CREATE TABLE supplier_sync_dead_letter (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    dead_letter_id VARCHAR(64) NOT NULL,
    batch_id VARCHAR(64) NOT NULL,
    task_code VARCHAR(64) NOT NULL,
    sync_mode VARCHAR(32) NOT NULL,
    category_code VARCHAR(32) NOT NULL,
    supplier_id BIGINT NOT NULL,
    supplier_resource_code VARCHAR(128) DEFAULT NULL,
    supplier_product_code VARCHAR(128) DEFAULT NULL,
    platform_resource_id BIGINT DEFAULT NULL,
    spu_id BIGINT DEFAULT NULL,
    sku_id BIGINT DEFAULT NULL,
    offer_id BIGINT DEFAULT NULL,
    error_stage VARCHAR(64) NOT NULL COMMENT 'ADAPTER/VALIDATION/MAPPING/PUBLISH/INDEX',
    error_type VARCHAR(64) NOT NULL COMMENT 'RETRYABLE/NON_RETRYABLE/MAPPING_REQUIRED/RISK_BLOCKED',
    error_code VARCHAR(128) NOT NULL,
    error_message VARCHAR(1024) NOT NULL,
    raw_payload_ref VARCHAR(512) DEFAULT NULL,
    raw_payload_hash VARCHAR(64) DEFAULT NULL,
    normalized_payload_ref VARCHAR(512) DEFAULT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
        COMMENT 'PENDING/RETRYING/MANUAL_FIX/RESOLVED/IGNORED/FAILED',
    retry_count INT NOT NULL DEFAULT 0,
    max_retry_count INT NOT NULL DEFAULT 5,
    next_retry_at DATETIME DEFAULT NULL,
    last_retry_at DATETIME DEFAULT NULL,
    owner_team VARCHAR(64) DEFAULT NULL,
    assignee VARCHAR(64) DEFAULT NULL,
    fix_note VARCHAR(1024) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    resolved_at DATETIME DEFAULT NULL,
    UNIQUE KEY uk_dead_letter_id (dead_letter_id),
    UNIQUE KEY uk_dedup (
        batch_id,
        supplier_id,
        supplier_resource_code,
        supplier_product_code,
        error_stage,
        raw_payload_hash
    ),
    KEY idx_status_next_retry (status, next_retry_at),
    KEY idx_supplier_status (supplier_id, status),
    KEY idx_category_status (category_code, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步死信队列';

样例:

dead_letter_id: dlq_20260427_000001
batch_id: batch_20260427_hotel_full_001
task_code: hotel_supplier_full_resource
sync_mode: FULL
category_code: HOTEL
supplier_id: 1001
supplier_resource_code: hotel_8848
error_stage: MAPPING
error_type: MAPPING_REQUIRED
error_code: CITY_NOT_FOUND
error_message: supplier city code BKK-OLD cannot map to platform city
raw_payload_ref: s3://hotel-sync/raw/2026/04/27/batch001/BKK/page120.json
status: MANUAL_FIX
owner_team: product-sync
assignee: ops_user_01

30.3 状态机

PENDING
  → RETRYING
  → RESOLVED

PENDING
  → MANUAL_FIX
  → RETRYING
  → RESOLVED

PENDING
  → IGNORED

RETRYING
  → FAILED

30.4 补偿 Job

SELECT *
FROM supplier_sync_dead_letter
WHERE status IN ('PENDING', 'FAILED')
  AND next_retry_at <= NOW()
  AND retry_count < max_retry_count
ORDER BY next_retry_at ASC
LIMIT 100;

重试时间使用指数退避:

next_retry_at = now + min(2^retry_count minutes, 1 hour)

26.18.15 监控指标

指标类型指标
任务进度总城市数、已完成城市数、当前城市、当前 page/cursor
处理统计酒店总数、成功数、失败数、跳过数
性能指标任务耗时、供应商 QPS、平均耗时、P99 耗时
质量指标字段缺失率、映射失败率、重复数据率、异常价格率
新鲜度指标数据延迟、过期数据比例、热门酒店刷新延迟
补偿指标DLQ 数量、重试成功率、人工修复数量
下游指标ES 刷新失败数、缓存刷新失败数、事件发布失败数

核心指标公式:

同步成功率 = 成功处理酒店数 / 总酒店数
映射失败率 = 映射失败酒店数 / 总酒店数
字段缺失率 = 缺失关键字段酒店数 / 总酒店数
数据新鲜度延迟 = now - last_success_sync_time
DLQ 修复率 = resolved_dlq_count / total_dlq_count

26.18.16 异常场景

异常处理
某城市同步失败从该城市对应 checkpoint 继续
某页接口超时从 page checkpoint 重试
单个酒店字段缺失写入 DLQ,不阻塞整批
供应商限流降低 worker 数,指数退避
城市映射失败进入人工映射,修复后重新投递
ES 刷新失败Outbox 补偿重试
发布版本异常保留旧版本,新版本不生效

26.18.17 答辩材料

本专题相关总结、常见问题和参考回答已统一收录到第 39 章的“供应商同步恢复与 DLQ”题卡

26.18.18 后续优化项目

30.1 任务分片

当单批次同步时间继续变长,或者需要多个 Worker 并行提升吞吐时,可以把任务从“Batch + Checkpoint”演进为“Batch + Shard + Checkpoint”。

典型分片方式:

batch_001
  ├─ city_shard_BKK
  ├─ city_shard_JKT
  ├─ city_shard_SIN
  └─ ...

Shard 表可以这样设计:

CREATE TABLE supplier_sync_shard (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    batch_id VARCHAR(64) NOT NULL,
    shard_type VARCHAR(32) NOT NULL COMMENT 'CITY',
    shard_key VARCHAR(128) NOT NULL COMMENT 'city_code or city_id',
    status VARCHAR(32) NOT NULL COMMENT 'PENDING/RUNNING/SUCCESS/FAILED',
    checkpoint VARCHAR(1024) DEFAULT NULL,
    total_count INT DEFAULT 0,
    success_count INT DEFAULT 0,
    failed_count INT DEFAULT 0,
    skipped_count INT DEFAULT 0,
    worker_id VARCHAR(64) DEFAULT NULL,
    lease_token VARCHAR(64) DEFAULT NULL,
    lease_until DATETIME DEFAULT NULL,
    heartbeat_at DATETIME DEFAULT NULL,
    started_at DATETIME DEFAULT NULL,
    finished_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_batch_shard (batch_id, shard_key),
    KEY idx_status (status),
    KEY idx_lease (status, lease_until),
    KEY idx_updated_at (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步分片';

30.2 分布式 Worker 抢占

多个 Worker 可以通过数据库 CAS 抢占 PENDING shard:

UPDATE supplier_sync_shard
SET status = 'RUNNING',
    worker_id = 'worker-01',
    lease_token = 'token-abc',
    lease_until = DATE_ADD(NOW(), INTERVAL 5 MINUTE),
    heartbeat_at = NOW(),
    updated_at = NOW()
WHERE id = 123
  AND status = 'PENDING';

rows_affected = 1 表示抢占成功,rows_affected = 0 表示已经被其他 Worker 抢走。

执行过程中 Worker 定期续租:

UPDATE supplier_sync_shard
SET heartbeat_at = NOW(),
    lease_until = DATE_ADD(NOW(), INTERVAL 5 MINUTE)
WHERE id = ?
  AND worker_id = ?
  AND lease_token = ?
  AND status = 'RUNNING';

如果 Worker 宕机,租约过期后,调度器把 shard 释放回 PENDING,其他 Worker 读取 shard checkpoint 继续执行。

30.3 Redis 抢占与数据库权威状态

当 batch 或 shard 数量非常多,多个 worker 高频抢占数据库导致压力上升时,可以引入 Redis 作为抢占加速层。

基本做法:

worker 抢 Redis 锁
  → SET lock:sync:batch:{batch_id} value NX EX 300
  → 抢到 Redis 锁后,再 CAS 更新 MySQL batch
  → MySQL 更新成功,才真正执行任务
  → 执行期间同时续 Redis 锁和 MySQL lease

Redis 抢锁示例:

SET lock:sync:batch:batch_001 worker_id:lease_token NX EX 300

续租和释放必须用 Lua 校验 value,不能直接 DEL

if redis.call("GET", key) == value then
    return redis.call("EXPIRE", key, ttl)
else
    return 0
end

释放锁同理:

if redis.call("GET", key) == value then
    return redis.call("DEL", key)
else
    return 0
end

Redis 抢占的关键原则:

  1. Redis 只做短期锁,不做任务事实表。
  2. MySQL 仍然是 batch 状态、checkpoint、统计和审计的权威存储。
  3. worker 只有同时持有 Redis 锁和 MySQL lease,才允许继续执行。
  4. 如果 Redis 锁续租失败,但 MySQL lease 还在,可以选择停止任务并释放 MySQL lease,避免双写风险。
  5. 如果 MySQL lease 更新失败,即使 Redis 锁还在,也必须停止任务。

是否使用 Redis,要看瓶颈在哪里。对于“一个 10 小时酒店全量任务”的第一阶段,MySQL CAS 足够简单可靠;对于“上万个 shard、大量 worker 高频抢占”的阶段,Redis 才更有价值。

30.4 为什么放在后续优化

任务分片和分布式 Worker 会引入额外复杂度:

  1. Shard 状态机。
  2. Worker 租约和心跳。
  3. 旧 Worker 恢复后的并发写保护。
  4. 跨 shard 的批次统计聚合。
  5. 热点城市和长尾城市的任务倾斜。

如果第一阶段的 10 小时任务可以接受,优先实现 Batch + Checkpoint + DLQ 的简单闭环。等同步窗口、供应商限流、数据规模或恢复时间成为瓶颈,再引入 shard 和分布式 Worker。

26.19 本章小结

商品供给不是单一后台功能,而是一套围绕供给入口、治理控制面、商品生命周期、库存与营销协同、以及供应商持续同步形成的系统化能力。把这几部分放在同一章里,能更清楚地看到:供给负责把货组织成平台资产,运营负责把资产组织成销售结果,而生命周期与治理机制负责确保整个过程可控、可追溯、可恢复。

第 27 章 库存系统

本章定位:库存是交易链路的硬约束之一。本章先从通用库存系统出发,解释库存为什么不是一个简单数字,而是一种可承诺供给能力;再用库存对象、库存范围、事实来源、单元形态和扣减时机抽象多品类差异,最后落到虚拟商品库存的券码、充值、供应商实时生成、Redis 原子扣减、账本对账与补偿实践。


27.1 背景与挑战

27.1.1 库存系统的本质

库存系统管理的不是一个简单数字,而是平台对用户的一种可承诺供给能力

某个售卖对象
  在某个库存范围
  某个时间窗口
  某种业务约束下
  还能承诺给用户多少

这句话比“SKU 还有多少件”更接近真实电商。实物电商要考虑仓库、门店、调拨、发货;本地生活要考虑门店、券码、活动名额;酒店要考虑日期、房型、供应商确认;票务要考虑场次、座位、出票;虚拟商品要考虑卡密、充值、供应商实时生成和不可逆履约。

因此,库存系统的第一原则不是“扣一个数”,而是:

在高并发、可重试、可能失败的交易链路里,稳定地回答“能不能卖”,并把每一次承诺、占用、确认、释放、补偿都记录清楚。

从通用库存视角看,库存至少有五种状态语义:

语义含义常见字段
总库存平台或供应商声明的库存规模total_stock
可售库存当前还能承诺给用户的数量available_stock
预占库存已被订单占用但未最终成交booking_stock/reserved_stock
锁定库存被风控、运营、活动或异常处理临时锁住locked_stock
已售库存已经确认成交或已经出库 / 出码 / 出票sold_stock/issued_stock

库存语义混淆的答辩提示已统一收录到第 39 章的“防止库存超卖”题卡

27.1.2 从通用库存到虚拟商品库存

通用库存系统先解决“可售承诺”的建模问题,再针对不同品类选择不同策略。虚拟商品没有传统仓库和物流,但它不是更简单,而是把仓储复杂度换成了履约和一致性复杂度:券码不能重复发,充值提交后不能随便撤回,供应商实时生成可能超时,卡密泄露会变成资损。

场景库存形态核心难点技术手段
实物电商仓库 / 门店 / 批次数量仓配范围、锁库、出库、退货回补库存范围建模、账本流水、WMS 对接、预占释放
本地生活SKU 数量、门店配额、券码门店可用、券码唯一、营销叠加Scope 维度、券码池、商品库存 + 营销库存双预占
酒店房型 + 日期库存连住多晚、供应商同步延迟、下单二次确认日期切片、快照 + 实时刷新、供应商 booking
票务 / 机票场次、座位、舱位强动态库存、出票不可逆短缓存、实时查询、异步出票状态机
数字券 / 礼品卡卡密 / code防重复出码、卡密安全、空池补货码池状态机、Redis LIST、加密存储、出码幂等
话费 / 充值无限或供应商实时额度平台无实物库存但履约可能失败无限库存策略、供应商错误分级、补偿工单

所以这一章的叙事顺序应该是:先建立通用库存系统,再过渡到虚拟商品库存类型。后文的 Redis Lua、供应商同步、对账补偿,本质上都是服务于这些难点,而不是为了展示某个技术组件。

27.1.3 核心难点与解决手段

库存系统的技术含量集中在“高并发下把承诺做对,失败后能修回来”。

难点典型表现为什么难解决手段
并发超卖多个用户同时抢同一 SKU,库存被扣成负数Check 和扣减分离会产生竞态Redis Lua、DB CAS、行锁、库存分片
重复扣减支付回调重放、接口超时重试、消息重复消费分布式系统默认至少一次order_id/event_id 幂等键、唯一约束、状态机
预占泄漏下单占库存但用户不支付订单、支付、库存异步推进TTL、延时队列、定时扫描、幂等 Release
热点库存秒杀单 SKU 形成 Redis 热 Key单 Key QPS 打满单线程限流、库存分片、令牌桶、队列削峰
多库存联动商品库存成功,营销库存失败多资源无法本地事务提交Saga、逆序补偿、补偿任务、人工门闩
供应商不确定查询超时、库存过期、异步确认晚到外部系统不可控快照、实时查询、熔断、供应商 booking 表
库存漂移Redis、MySQL、订单预占记录不一致热路径和账本路径分离库存流水、聚合对账、Outbox、修复任务
虚拟履约不可逆卡密已发、充值已提交后失败回滚不等于把数字加回来履约状态机、发货幂等、人工核损

这张表也决定了本章的技术主线:账本模型保证可追溯,原子扣减保证并发安全,幂等状态机保证可重试,对账补偿保证最终修复,供应商适配保证外部不确定性可控

27.1.4 设计目标

目标说明优先级
统一模型用库存对象、库存范围、事实来源、扣减时机抽象多品类P0
强语义 API对外只暴露 Check / Reserve / Confirm / Release / Refund,不暴露表字段P0
高并发安全热路径采用 Redis Lua、DB CAS、分片库存或队列削峰P0
幂等可重试所有写操作都有业务幂等键和状态机终态P0
账本可追溯每次入库、预占、确认、释放、调整都有流水P0
最终一致Redis、MySQL、订单、供应商视图通过对账补偿收敛P0
边界清晰库存只负责可售承诺,不负责价格、优惠、履约规则和订单生命周期P0

容量与并发视角的补充:库存系统往往是交易洪峰的第一扇闸门。当创单 QPS 在短时间内抬升一个数量级时,最先暴露的通常不是 CPU,而是热 Key、连接池、消息堆积与下游供应商配额。因此在需求阶段就要区分两类指标:对用户承诺的创单成功率,以及对内部承诺的库存服务自身 SLO,例如 Reserve P99、对账修复时延、补偿积压量。两者混谈会导致“系统看起来没挂,但用户体验已经崩了”。


27.2 通用库存模型与分类体系

27.2.1 四个建模问题

设计库存系统时,不应该先问“用 Redis 还是 MySQL”,而应该先问四个建模问题:

问题含义示例
库存对象是什么被承诺和扣减的最小业务对象sku_idoffer_id、房型、场次、券批次
库存范围是什么这份库存在哪个范围内可用仓库、门店、城市、渠道、日期、批次、供应商
库存事实来源是谁谁对库存真实性负责平台自管、供应商管理、无限库存
扣减时机是什么什么时候把可售变成占用或已售下单、支付、发货、供应商确认

这四个问题共同决定库存 Key、数据表唯一键、缓存 Key、扣减策略和对账维度。缺少“库存范围”时,系统很容易只支持虚拟商品数量制,一旦接入仓库、门店、酒店日期或活动独占库存,就会开始堆 if category == xxx

27.2.2 库存范围:从 SKU 到可售承诺

通用库存的核心 Key 不是单纯 sku_id,而是:

inventory_key =
  sku_id
  + scope_type / scope_id
  + calendar_date / time_slot
  + batch_id
  + channel_id
  + supplier_id

不同业务可以裁剪维度,但不能把范围概念抹掉。

范围维度解决什么问题典型场景
仓库 / 门店用户从哪里发货或核销实物电商、到店券
城市 / 站点哪些区域可售本地生活、跨境站点
日期 / 时段哪一天或哪一场可售酒店、票务、预约服务
批次哪批货、哪批券码、哪批卡密礼品卡、预采购券码
渠道App、Web、直播、B 端渠道是否共享库存渠道独占、大促限量
活动活动库存是否从商品库存切出秒杀、限时抢购
供应商外部库存和预订接口归属酒店、票务、充值

工程上建议把库存范围抽象成 scope_type + scope_id,再把日期、批次、渠道作为显式字段。这样既能保持查询可控,也能让业务表达足够清晰。

27.2.3 账本、聚合与热视图

一个可恢复的库存系统通常至少有四类数据:

数据一句话定位技术重点
inventory_config这份库存采用什么策略策略路由、供应商、扣减时机、是否允许超卖
inventory_balance当前聚合库存视图唯一键、CAS 更新、恒等式校验
inventory_reservation某个订单占了什么库存幂等、过期时间、终态状态机
inventory_ledger每一次库存变化的事实流水可追溯、可重放、对账依据
Redis 热视图高并发读写投影Lua 原子性、TTL、可重建

最重要的设计原则是:

Redis 是热路径投影,不是最终账本。库存事故恢复时,应以 MySQL 账本、预占记录、订单状态和供应商最终态为准。

通用模型可以这样组织:

CREATE TABLE inventory_config (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  inventory_key VARCHAR(128) NOT NULL,
  item_id BIGINT NOT NULL,
  sku_id BIGINT NOT NULL DEFAULT 0,
  scope_type VARCHAR(32) NOT NULL DEFAULT 'GLOBAL'
    COMMENT 'GLOBAL/WAREHOUSE/STORE/CITY/CHANNEL/DATE/SUPPLIER',
  scope_id VARCHAR(64) NOT NULL DEFAULT '0',
  management_type INT NOT NULL COMMENT '1=自管理,2=供应商,3=无限',
  unit_type INT NOT NULL COMMENT '1=券码,2=数量,3=时间,4=组合',
  deduct_timing INT NOT NULL DEFAULT 1 COMMENT '1=下单,2=支付,3=发货,4=供应商确认',
  supplier_id BIGINT NOT NULL DEFAULT 0,
  sync_strategy INT NOT NULL DEFAULT 0 COMMENT '1=定时,2=实时,3=推送',
  oversell_allowed TINYINT NOT NULL DEFAULT 0,
  low_stock_threshold INT NOT NULL DEFAULT 100,
  status INT NOT NULL DEFAULT 1,
  UNIQUE KEY uk_inventory_key (inventory_key),
  KEY idx_item_sku (item_id, sku_id)
);

CREATE TABLE inventory_balance (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  inventory_key VARCHAR(128) NOT NULL,
  item_id BIGINT NOT NULL,
  sku_id BIGINT NOT NULL,
  scope_type VARCHAR(32) NOT NULL,
  scope_id VARCHAR(64) NOT NULL,
  batch_id BIGINT NOT NULL DEFAULT 0,
  calendar_date DATE DEFAULT NULL,
  total_stock INT NOT NULL DEFAULT 0,
  available_stock INT NOT NULL DEFAULT 0,
  booking_stock INT NOT NULL DEFAULT 0,
  locked_stock INT NOT NULL DEFAULT 0,
  sold_stock INT NOT NULL DEFAULT 0,
  supplier_stock INT NOT NULL DEFAULT 0,
  supplier_sync_time BIGINT NOT NULL DEFAULT 0,
  version BIGINT NOT NULL DEFAULT 0,
  updated_at DATETIME NOT NULL,
  UNIQUE KEY uk_inventory_key (inventory_key),
  KEY idx_sku_scope (sku_id, scope_type, scope_id),
  KEY idx_date (calendar_date)
);

CREATE TABLE inventory_reservation (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  reservation_id VARCHAR(64) NOT NULL,
  inventory_key VARCHAR(128) NOT NULL,
  order_id VARCHAR(64) NOT NULL,
  qty INT NOT NULL,
  status VARCHAR(32) NOT NULL
    COMMENT 'RESERVED/CONFIRMED/RELEASED/EXPIRED/CANCELLED',
  expire_at DATETIME DEFAULT NULL,
  idempotency_key VARCHAR(128) NOT NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  UNIQUE KEY uk_reservation_id (reservation_id),
  UNIQUE KEY uk_order_inventory (order_id, inventory_key),
  UNIQUE KEY uk_idempotency (idempotency_key)
);

CREATE TABLE inventory_ledger (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  ledger_id VARCHAR(64) NOT NULL,
  inventory_key VARCHAR(128) NOT NULL,
  order_id VARCHAR(64) DEFAULT NULL,
  event_id VARCHAR(64) DEFAULT NULL,
  change_type VARCHAR(32) NOT NULL
    COMMENT 'INBOUND/RESERVE/CONFIRM/RELEASE/REFUND/LOCK/UNLOCK/ADJUST',
  qty_delta INT NOT NULL,
  before_payload JSON DEFAULT NULL,
  after_payload JSON DEFAULT NULL,
  reason VARCHAR(256) DEFAULT NULL,
  operator_type VARCHAR(32) NOT NULL COMMENT 'SYSTEM/ORDER/OPS/SUPPLIER/RECONCILE',
  created_at DATETIME NOT NULL,
  UNIQUE KEY uk_ledger_id (ledger_id),
  UNIQUE KEY uk_event_id (event_id),
  KEY idx_inventory_time (inventory_key, created_at),
  KEY idx_order (order_id)
);

对于券码制,还要单独增加 inventory_code_pool_XX 分表。这个表不是普通库存表的附属字段,而是虚拟商品、卡密、兑换券、权益码等「唯一资源」的权威账本:一码一行、状态机驱动,Redis LIST 只保存 code_id 热数据,权威仍在 MySQL

券码池分表:inventory_code_pool_XX

数量制库存扣减的是 available_stock,券码制库存扣减的是某一条真实存在的码。只要业务需要向用户交付一个不可重复的串码,就不能把 Redis LIST 里的字符串当成事实来源,否则会遇到三类严重问题:

  • Redis 宕机、回滚或误删后,无法解释哪些码已经分配给哪个订单;
  • LIST 里直接存明文券码,内存、日志、监控和排障链路都可能泄漏敏感资源;
  • 取消、支付确认、发码失败、售后和过期处理没有统一状态机,极易重复发码或把已交付的码放回可售池。

推荐模型如下:

CREATE TABLE inventory_code_pool_00 (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  code_id BIGINT NOT NULL,
  batch_id VARCHAR(64) NOT NULL,
  inventory_key VARCHAR(128) NOT NULL,
  sku_id BIGINT NOT NULL,
  code_cipher VARBINARY(1024) NOT NULL COMMENT '加密后的券码或卡密',
  code_hash VARCHAR(64) NOT NULL COMMENT '去重与排查使用,不保存明文',
  status VARCHAR(32) NOT NULL
    COMMENT 'AVAILABLE/BOOKING/SOLD/LOCKED/EXPIRED/INVALID',
  reservation_id VARCHAR(64) DEFAULT NULL,
  order_id VARCHAR(64) DEFAULT NULL,
  user_id BIGINT DEFAULT NULL,
  booked_at DATETIME DEFAULT NULL,
  sold_at DATETIME DEFAULT NULL,
  expire_at DATETIME DEFAULT NULL,
  version BIGINT NOT NULL DEFAULT 0,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  UNIQUE KEY uk_code_id (code_id),
  UNIQUE KEY uk_batch_hash (batch_id, code_hash),
  KEY idx_batch_status_id (batch_id, status, id),
  KEY idx_order (order_id),
  KEY idx_reservation (reservation_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存券码池分表00';

分表路由通常按 batch_idcode_id 做一致性哈希,也可以按 inventory_key + batch_id 固定到某个分片。关键原则是:活动开始后不要改变路由规则;补货、锁码、支付确认、取消释放、对账修复都必须命中同一个权威分片。批次维度可以配合 inventory_code_batch 保存供应商、面值、有效期、导入批次、加密密钥版本、总码量和当前水位。

券码池的状态机建议收敛为:

AVAILABLE --reserve--> BOOKING --confirm/pay--> SOLD
BOOKING --cancel/timeout--> AVAILABLE
AVAILABLE --ops/supplier--> LOCKED
AVAILABLE --expire--> EXPIRED
AVAILABLE --quality_check--> INVALID

这里有一个重要约束:已发给用户或已核销链路可见的 SOLD 码,不能简单回到 AVAILABLE。退款、补发、作废应走售后和履约状态,而不是把同一串码再次投入可售池。这个约束能显著降低重复发码、用户投诉和供应商对账争议。

高并发出码链路可以这样设计:

1. 大促前按批次把可售 code_id 预热到 Redis LIST:
   inventory:code:pool:{batch_id}:{shard}

2. 下单预占时从 Redis LIST 弹出一批 code_id。

3. 应用逐个执行 MySQL CAS:
   UPDATE inventory_code_pool_XX
   SET status='BOOKING',
       reservation_id=?,
       order_id=?,
       user_id=?,
       booked_at=NOW(),
       version=version+1,
       updated_at=NOW()
   WHERE code_id=? AND status='AVAILABLE';

4. 更新成功才算锁码成功,同时写 inventory_reservation 与 inventory_ledger。

5. 更新失败说明 Redis 中是陈旧 code_id,丢弃后继续取下一个,不把 Redis 结果视为成功。

6. 支付成功后 BOOKING -> SOLD;订单取消或超时后 BOOKING -> AVAILABLE,并通过 Outbox 或补偿任务把 code_id 回填到 Redis LIST。

因此 Redis 的角色只是「热队列」:只放 code_id,不放明文码;只提升吞吐,不承担库存权威职责。Redis LIST 为空时,补货 Worker 按 idx_batch_status_idid 游标分页扫描 MySQL 可用码并回填;Redis 故障后,也可以从 MySQL 的 AVAILABLE 状态全量重建热队列。监控上要同时看 MySQL 可用码数量、Redis LIST 长度、锁码 CAS 失败率、BOOKING 超时释放量和 SOLD 重复告警。

27.2.4 多维分类模型

统一库存的关键,是把品类差异拆成几个正交维度,而不是让每个品类复制一套服务。

// ManagementType:谁拥有库存事实来源
const (
	SelfManaged      = 1 // 平台自管:平台维护可用量与流水
	SupplierManaged  = 2 // 供应商管理:平台保存快照 + 同步策略
	Unlimited        = 3 // 无限库存:不维护可用量,但保留审计与风控
)

// UnitType:库存如何被扣减与表达
const (
	CodeBased     = 1 // 券码制:最小粒度为唯一 code
	QuantityBased = 2 // 数量制:最小粒度为整数数量
	TimeBased     = 3 // 时间维度:按日期 / 时段切片
	BundleBased   = 4 // 组合型:多子项联动扣减
	SeatBased     = 5 // 座位制:场次 + 座位 / 舱位
)

// DeductTiming:什么时候改变库存承诺
const (
	DeductOnOrder           = 1 // 下单预占
	DeductOnPay             = 2 // 支付后确认
	DeductOnFulfillment     = 3 // 发货 / 发码 / 出票时确认
	DeductOnSupplierConfirm = 4 // 供应商确认后确认
)

四个维度的组合决定策略路由:

InventoryStrategy =
  ManagementType
  + UnitType
  + ScopeType
  + DeductTiming

例如:

自管理 + 数量制 + GLOBAL + 下单预占
  → Redis Lua 数量扣减策略

自管理 + 券码制 + BATCH + 下单预占
  → Redis LIST 取码 + MySQL 码池状态机

供应商管理 + 时间维度 + DATE + 供应商确认
  → 本地快照 + 下单实时刷新 + supplier_booking 轮询

无限库存 + 无范围 + 支付后履约
  → 不扣库存,但写履约审计和风控流水

27.2.5 品类分类矩阵

下表将常见品类映射到通用模型。它不是为了枚举业务,而是为了帮助团队识别“这类库存难在哪里,应该选哪组技术手段”。

品类管理类型单元类型库存范围推荐扣减时机技术重点
实物电商普通 SKUSelfQuantity仓库 / 门店下单预占或支付确认仓配范围、锁库、出库回补
秒杀商品SelfQuantity活动 / 渠道下单预占热点 Key、限流、库存分片
本地生活服务券SelfQuantity门店 / 城市下单预占门店可用、营销双预占
电子券 DealSelfCode券码批次下单预占码池、锁码、出码幂等
酒店SupplierTime日期 / 供应商支付或供应商确认日历库存、实时确认、供应商 booking
机票 / 票务SupplierSeat / Time航班 / 场次 / 座位支付前后组合短缓存、强刷新、异步出票
话费 TopUpUnlimited / SupplierQuantity供应商支付后履约无平台库存、履约失败补偿
礼品卡预采购SelfCode批次下单预占卡密安全、空池补货
礼品卡实时生成SupplierCode供应商支付后生成供应商超时、生成幂等
组合套餐Self / SupplierBundle子 SKU 各自范围下单预占固定扣减顺序、Saga 补偿

下面的矩阵图用“事实来源 × 单元形态”表达组合空间,具体落地时再叠加库存范围和扣减时机。

flowchart TB
  subgraph D1[事实来源 ManagementType]
    M1[SelfManaged 平台自管]
    M2[SupplierManaged 供应商管理]
    M3[Unlimited 弱库存约束]
  end

  subgraph D2[单元形态 UnitType]
    U1[Quantity 数量]
    U2[Code 券码 / 卡密]
    U3[Time 日期 / 时段]
    U4[Seat 座位 / 场次]
    U5[Bundle 组合]
  end

  M1 --> C1[实物 SKU / 本地服务 / 秒杀]
  M1 --> C2[预采购券码 / 礼品卡]
  M2 --> C3[酒店 / 票务 / 充值供应商]
  M2 --> C4[实时生成卡密]
  M3 --> C5[话费等弱库存商品]

  U1 --> C1
  U2 --> C2
  U3 --> C3
  U4 --> C3
  U5 --> C6[套餐:子项各自路由]

  style M1 fill:#e8f5e9
  style M2 fill:#e3f2fd
  style M3 fill:#fff3e0

与营销库存的关系:商品库存回答“有没有货”,营销库存回答“活动名额 / 补贴预算够不够”。秒杀等场景往往需要双扣减(商品 + 营销),本章在 27.7 节说明集成边界;营销细节见第 28 章。

27.2.6 库存创建:从商品发布到库存实例

库存系统不能只设计扣减,还要设计 库存从哪里来、什么时候创建、创建到什么粒度、失败后如何重放。商品发布只说明“这个 SKU 可以售卖”,不一定说明“库存实例已经准备好”。库存创建的目标,是把商品中心的销售契约转成库存域可扣减、可对账、可恢复的实例。

库存创建通常来自五类入口:

创建入口典型触发创建内容
商品发布SKU / Offer 生效事件inventory_config、默认库存范围、初始 inventory_balance
运营导入后台填数量、上传券码、批量配置门店数量库存、券码批次、门店库存、日期库存
供应商同步外部商品 / 房态 / 配额同步供应商映射、本地快照、可售时间切片
系统生码平台自营券、礼品卡、活动码生成inventory_code_batchinventory_code_pool_XX
日历物化酒店、预约、场次滚动开放日期 / 时段 / 门店维度的库存行

工程上建议把库存创建做成命令和任务,而不是在商品发布事务里直接写完所有库存行:

ProductPublished / OpsImportSubmitted / SupplierSnapshotReady
  → InventoryCreateCommand
  → inventory_create_task
  → InventoryInitWorker
  → inventory_config / inventory_balance / inventory_code_pool_XX
  → Redis 热视图预热
  → InventoryReady / InventoryCreateFailed

创建命令至少包含这些字段:

CreateInventoryCommand
├── source_type:PRODUCT_PUBLISH / OPS_IMPORT / SUPPLIER_SYNC / CODE_GENERATION
├── source_id:发布版本、导入任务、供应商批次或生码任务
├── item_id / 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:防重复创建

任务表可以这样设计:

CREATE TABLE inventory_create_task (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  task_id VARCHAR(64) NOT NULL,
  source_type VARCHAR(32) NOT NULL
    COMMENT 'PRODUCT_PUBLISH/OPS_IMPORT/SUPPLIER_SYNC/CODE_GENERATION/CALENDAR_MATERIALIZE',
  source_id VARCHAR(128) NOT NULL,
  item_id BIGINT NOT NULL,
  sku_id BIGINT NOT NULL,
  inventory_key VARCHAR(128) NOT NULL,
  create_mode VARCHAR(32) NOT NULL
    COMMENT 'QUANTITY/CODE_IMPORT/CODE_GENERATE/TIME_STORE/SUPPLIER_SNAPSHOT',
  payload JSON NOT NULL,
  status VARCHAR(32) NOT NULL
    COMMENT 'PENDING/RUNNING/SUCCESS/FAILED/PARTIAL_SUCCESS/CANCELLED',
  retry_count INT NOT NULL DEFAULT 0,
  error_code VARCHAR(64) DEFAULT NULL,
  error_message VARCHAR(1024) DEFAULT NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  UNIQUE KEY uk_task_id (task_id),
  UNIQUE KEY uk_source_inventory (source_type, source_id, inventory_key),
  KEY idx_status_updated (status, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存创建任务';

不同库存形态的创建方式不同:

类型创建粒度关键动作风险控制
简单数量库存inventory_key 一行创建 inventory_config,写 inventory_balance(total/available),写 INBOUND/INIT 流水幂等键防重复入库,调整库存不能绕过账本
门店数量库存sku_id + store_id每个门店一行 inventory_balancescope_type=STORE门店上下线要锁定或迁移库存
日期 / 时段库存sku_id + store_id + date + slot按滚动窗口物化未来 N 天,或首次查询懒创建不要一次性创建无限日历;跨日、节假日、最小提前预约要校验
外部供应商库存supplier_id + external_sku + date创建配置和映射,写本地快照,扣减前强刷或预订本地快照不是最终承诺,必须保留新鲜度时间
导入券码库存batch_id + code_id创建 inventory_code_batch,逐行写 inventory_code_pool_XX,预热 Redis code_id明文只在导入和加密环节短暂存在,唯一哈希防重复
系统生成券码batch_id + code_idorder_id + code_id预生成 N 个码进入码池,或支付后按订单幂等生成并立即落库生成算法要防猜测,返回给用户前必须先有 MySQL 权威行

数量库存最简单,但也不应该直接改一个 stock 字段。推荐流程是:

1. 校验 SKU / Offer 是否已经发布并可售。
2. 根据 scope 生成 inventory_key。
3. 幂等创建 inventory_config。
4. Upsert inventory_balance:
   total_stock += initial_quantity
   available_stock += initial_quantity
5. 写 inventory_ledger(change_type=INBOUND 或 INIT)。
6. 刷新 Redis 热视图和搜索可售标签。

券码库存要多一个批次对象:

inventory_code_batch
├── batch_id
├── inventory_key / sku_id
├── code_source:IMPORTED / SYSTEM_GENERATED / SUPPLIER_GENERATED
├── generation_mode:PRE_GENERATED / ON_DEMAND
├── total_count / available_count
├── expire_at
├── encrypt_key_version
├── route_shard
└── status:CREATING/READY/LOCKED/EXHAUSTED/FAILED

如果是运营或供应商导入券码,Worker 要逐行做格式校验、去重、加密、哈希、分表落库,再把 code_id 批量灌入 Redis LIST。如果是系统自己生成券码,有两种模式:

  • 预生成:活动开始前生成一批随机不可猜测的券码,全部进入 inventory_code_pool_XX,适合大促高并发发码。
  • 按需生成:支付或履约阶段按 order_id + sku_id 幂等生成,先写入码池权威行,再展示给用户,适合低峰值或强个性化券码。

无论哪种模式,都不能只把生成出来的字符串返回给用户而不落库。正确顺序是:生成 / 导入 → 加密落 MySQL → 状态机进入 AVAILABLE 或 BOOKING → 必要时预热 Redis code_id → 发码时再解密展示

门店、日期和时段库存则要关注“物化范围”。例如本地生活门店券可以是:

inventory_key =
  sku_id
  + scope_type=STORE
  + scope_id=store_1001
  + calendar_date=2026-05-01
  + time_slot=DINNER

平台可以提前物化未来 30 天或 90 天的库存行,也可以在首次查询 / 首次预约时懒创建。前者查询快但写放大明显;后者节省存储但需要处理并发首次创建。推荐对高流量品类提前物化,对长尾门店懒创建,并用唯一键保证同一门店同一天同一时段只创建一行。

库存创建的成功标准不是“任务跑完”,而是这些对象都进入可解释状态:

  1. inventory_config 能解释策略路由;
  2. inventory_balanceinventory_code_pool_XX 能解释可售资源;
  3. inventory_ledger 能解释库存从哪里来;
  4. Redis 热视图可从 MySQL 重建;
  5. 搜索 / 商品聚合读模型能感知可售状态;
  6. 创建失败可以按 inventory_create_task 重试、部分成功回滚或人工修复。

27.2.7 虚拟商品库存的特化

虚拟商品库存可以复用通用库存模型,但需要额外突出四个风险:

  1. 发货不可逆:充值、出票、发码一旦提交供应商或展示给用户,不能简单回滚。
  2. 唯一资源泄露:卡密、券码属于敏感资产,不能在日志、消息、搜索索引里明文扩散。
  3. 供应商最终态晚到:平台支付成功不代表供应商履约成功,必须有 pending、confirmed、failed、manual 状态。
  4. 空池与补货:预采购券码可能卖空,补货既要高效又不能重复装载同一码。
虚拟库存类型通用模型映射特殊难点关键技术点
数量制虚拟商品Self + Quantity高并发超卖Redis Lua、幂等预占、异步落库
券码 / 卡密制Self + Code一码一货、防重复出码码池状态机、Redis LIST、加密存储
无限库存Unlimited无库存但有履约失败审计流水、风控阈值、供应商错误分级
供应商实时生成Supplier + Code钱已收但生成失败supplier_request 幂等、补偿轮询、人工核损
时间 / 场次类Supplier + Time / Seat快照过期、下单二次确认短 TTL、实时刷新、供应商 booking
组合虚拟套餐Bundle子项部分成功Saga、固定顺序、逆序补偿

一句话总结:

虚拟商品不是“没有库存”,而是库存从仓库货架变成了数量承诺、唯一凭证、供应商额度和不可逆履约状态。

27.2.8 可售库存计算

库存系统对外暴露的不是 total_stock,而是可售判断。可售库存的计算要分管理类型:

SelfManaged:
  sellable = total_stock - booking_stock - locked_stock - sold_stock

SupplierManaged:
  sellable = min(platform_snapshot, supplier_latest_confirmation)

Unlimited:
  sellable = business_limit_or_sentinel

对于自管理库存,要维护恒等式:

total_stock = available_stock + booking_stock + locked_stock + sold_stock

对于供应商库存,平台本地的 supplier_stock 只是最后一次可见快照,不一定代表下单瞬间真实库存。因此供应商管理品类通常采用两段式:

列表页 / 详情页:
  读本地快照 + 短 TTL 缓存

创单 / 支付前:
  实时刷新或创建 supplier_booking

这样可以把“展示性能”和“交易安全”分开,避免为了列表页性能牺牲创单正确性。


27.3 库存扣减策略

27.3.1 扣减时机

扣减时机是交易体验与资损风险的权衡轴:

  • 下单预占(Reserve / Book):用户体验好(下单即锁货),但占用时长内库存不可用,需要可靠的超时释放。
  • 支付后扣减(Sell on pay):减少无效占用,更适合供应商成本高或确认链路长的品类。
  • 发货扣减:实物电商更常见;数字商品平台多用前两者的组合。

工程上建议把时机写入 inventory_config.deduct_timing,由订单 / 结算编排读取,而不是散落在订单代码的 switch

配置值与交易编排的契约deduct_timing 只是标签,真正决定行为的是订单状态机与库存 API 的组合。推荐在内部文档中固定一张「状态 × 库存动作」表,例如:PENDING_PAYMENT → ReleasePAID → ConfirmCLOSED → Release(幂等)。当同一品类在不同国家 / 不同供应商合同中扣减时机不同,用配置驱动可以避免为每个市场复制一套订单服务。

27.3.2 预占与确认

预占(Reserve) 的本质:把「可售」迁移到「已占用(booking)」状态,并保证操作原子、可幂等、可追踪。

确认(Confirm / Sell) 的本质:把「占用」迁移到「已售(sold / issued)」,并与支付成功事件对齐。

自管理数量制的状态迁移(Redis HASH 字段视角):

available --(reserve)--> booking --(confirm)--> issued
available <---(release)--- booking

券码制则是 AVAILABLE → BOOKING → SOLD 的状态机,失败路径需要可逆。

策略模式落地(路由与编排解耦):业务层只依赖统一的 InventoryManager(或应用服务),由它读取 inventory_config 后选择策略实现。这样「新品类接入」优先体现为 配置 + 策略类,而不是修改订单核心代码。

// InventoryStrategy 抽象了库存生命周期中可被统一编排的动作集合。
type InventoryStrategy interface {
	CheckStock(ctx context.Context, req *CheckStockReq) (*CheckStockResp, error)
	BookStock(ctx context.Context, req *BookStockReq) (*BookStockResp, error)
	UnbookStock(ctx context.Context, req *UnbookStockReq) error
	SellStock(ctx context.Context, req *SellStockReq) error
	RefundStock(ctx context.Context, req *RefundStockReq) error
}

type StrategyRouter struct{}

func (StrategyRouter) MustStrategy(cfg *InventoryConfig) (InventoryStrategy, error) {
	switch cfg.ManagementType {
	case SelfManaged:
		return NewSelfManagedStrategy(cfg), nil
	case SupplierManaged:
		return NewSupplierManagedStrategy(cfg), nil
	case Unlimited:
		return NewUnlimitedStrategy(), nil
	default:
		return nil, fmt.Errorf("unknown management_type=%d", cfg.ManagementType)
	}
}

与「营销锁定」的关系:数量制 Redis HASH 常会增加 locked 以及按 promotion_id 维度的动态字段,用于表达「活动独占库存」。商品详情页展示的可售量,与下单强校验使用的可售量,可能不是同一个聚合口径——务必在接口契约里写清楚,避免运营配置误解导致客诉。

27.3.3 超时释放

超时释放至少要回答三个问题:谁来触发?以什么为准?失败如何兜底?

常见实现组合:

  1. Redis TTL / 预占记录过期:快速回收「短期锁」。
  2. 延时队列:在创单时投递 delay=15m 的任务,到点检查订单是否已支付。
  3. 定时扫描:扫描 PENDING_PAYMENT 且超时的订单,幂等调用库存释放接口。

下面的时序图展示「下单预占 → 支付确认 / 超时释放」的主路径(商品库存服务视角)。

sequenceDiagram
  autonumber
  participant O as 订单系统
  participant I as 库存服务
  participant R as Redis
  participant Q as 延时队列
  participant P as 支付系统

  O->>I: ReserveStock(order_id, sku, qty, ttl=15m)
  I->>R: EVAL Lua 原子扣减 available 并增加 booking
  R-->>I: OK
  I-->>O: reserved
  O->>Q: schedule ReleaseStock(order_id) @T+15m

  alt 用户在 TTL 内完成支付
    P-->>O: PaymentSuccess
    O->>I: ConfirmStock(order_id)
    I->>R: booking -= qty; issued += qty
    I-->>O: confirmed
    Note over Q: 可选:取消延时任务(若支持精确去重)
  else 超时未支付
    Q-->>I: ReleaseStock(order_id) 幂等
    I->>R: booking -= qty; available += qty
    I-->>O: released
    O-->>O: CloseOrder(timeout)
  end

与上时序图互补,建议再用 状态机 固化「预占记录」本身的生命周期(尤其是 Redis 侧 reservation:{order_id} 与 DB 影子行并存时)。下图把「可重复进入的幂等终态」标出,避免研发在「重复回调 / 重复释放」上各写一套语义。

stateDiagram-v2
  [*] --> NONE: 未创单 / 未预占
  NONE --> RESERVED: Reserve 成功\n(available↓ booking↑)
  RESERVED --> CONFIRMED: Confirm\n(booking↓ issued↑)
  RESERVED --> RELEASED: Release / 超时\n(booking↓ available↑)
  CONFIRMED --> [*]: 终态(可审计重复 Confirm)
  RELEASED --> [*]: 终态(可审计重复 Release)
  note right of RESERVED
    幂等键:order_id
    并发护栏:Lua / 版本号 / 行锁择一
  end note

关键细节

  • 幂等键order_id 贯穿 Reserve / Confirm / Release,重复调用必须安全。
  • 顺序依赖:若营销与商品双预占,失败回滚顺序应与成功顺序相反(Saga 补偿语义)。

27.3.4 超卖防护

超卖防护应分层:

  1. 热路径原子性:Redis Lua 或单分片事务,保证「检查 + 扣减」不可分割。
  2. 业务幂等:同一 order_id 重复确认只生效一次。
  3. 冷路径校验:支付回调后,在确认库存前读取 MySQL 侧汇总做二次校验(容忍更高延迟)。
  4. 对账兜底:周期任务发现 available + booking + sold 恒等式破坏或 Redis / MySQL 偏差过大,自动冻结商品并告警(见 22.5.2)。

CheckStock 与 ReserveStock 为什么要拆开? 只读 Check 适合列表页、加购前的快速失败;但它不能保证并发下的正确性。正确做法是:创单路径必须以 Reserve 这种「读改写原子操作」为准,Check 只是辅助。否则会出现「校验时还有货,下单时被抢走」的经典竞态。

秒杀场景的 Facade(可选优化):当商品库存与营销库存必须同事务化编排时,常规做法是订单 Saga 两步调用;在极端 QPS 下可以引入 FlashSaleInventoryFacade.CheckAndReserve 聚合接口,把限流、热点治理、重复请求拦截收敛到库存域的专用入口。注意:Facade 是性能与风控的「窄接口」,不要让它反向吞噬订单领域的编排职责。


27.4 供应商集成

供应商集成本质是 把「外部库存事实」映射为平台可售视图,并在预订 / 取消时调用供应商 API 对齐状态。

27.4.1 实时查询

适用:变化快、对超卖极度敏感(机票、部分热门票务)。

模式

  • 读路径:短 TTL 缓存 + 超时控制 + 熔断降级。
  • 写路径:同步预订或异步预订(供应商返回 pending 时需轮询,并通过异步 booking 状态机收敛结果)。

读路径的 Go 骨架(与第 34 章风格一致:先缓存、后供应商、再回写、可观测)

// CheckSupplierStock 演示:实时查询 + 短缓存 + 异步快照(示意代码)
func (s *SupplierManagedStrategy) CheckStock(ctx context.Context, req *CheckStockReq) (*CheckStockResp, error) {
	cacheKey := fmt.Sprintf("inventory:supplier:%d:%d:%s", req.ItemID, req.SKUID, req.Date)

	// 1) 先读 Redis 缓存(例如 30s TTL:机票可更短,酒店可更长)
	if v, err := s.rdb.Get(ctx, cacheKey).Int(); err == nil {
		return &CheckStockResp{Available: int32(v), FromCache: true}, nil
	}

	// 2) 供应商调用必须带超时;失败要映射为可重试/不可重试
	ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	resp, err := s.supplier.QueryStock(ctx, &SupplierQuery{
		SupplierID: req.SupplierID,
		ProductID:  req.ExternalProductID,
		Date:       req.Date,
	})
	if err != nil {
		return nil, MapSupplierErr(err) // Retryable / Fatal / Unknown
	}

	// 3) 回写缓存 + 异步落快照(快照用于运营后台、对账与熔断时的最后成功视图)
	_ = s.rdb.Set(ctx, cacheKey, resp.Stock, 30*time.Second).Err()
	go func() {
		bg, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer cancel()
		_ = s.snapshot.Save(bg, req.ItemID, req.SKUID, req.Date, resp.Stock, "api")
	}()

	return &CheckStockResp{Available: resp.Stock, FromCache: false}, nil
}

工程要点(把「实时」变成可运营能力)

  • 缓存击穿:热点航线/场次在缓存过期瞬间会把供应商 QPS 顶满;需要单飞(singleflight)、随机抖动 TTL、以及网关层按 supplier_id 配额限流。
  • 错误语义Unknown 不要当作「0 库存」返回,否则会把用户引导到错误决策;应显式返回「暂不可校验」并由前端降级展示。
  • 观测:必须记录 from_cachesupplier_latency_mssupplier_error_class,否则线上只能看到「库存服务慢」,无法判断是供应商还是自研逻辑。

27.4.2 定时同步

适用:变化中等、可接受分钟级延迟(部分酒店库存)。

模式

  • 定时任务拉取供应商库存,写入本地 inventory 快照字段(如 supplier_stocksupplier_sync_time)。
  • 读路径优先读本地快照,必要时触发「刷新任务」。

27.4.3 推送模式

适用:供应商能力较强,主动推送房态 / 价格变更。

要点

  • Webhook 入口必须鉴权、幂等、重放安全。
  • 推送与定时拉取可并存:推送负责快变字段,拉取负责兜底对齐。

27.4.4 降级策略

触发条件平台行为用户侧体验
供应商超时返回可重试 / 排队;读缓存则明确标注「仅供参考」可能看到「库存紧张」
连续失败超阈值熔断一段时间,仅允许读取上次成功快照可能暂停售卖
异步预订 pending 过久进入人工处理队列,避免盲目关单造成纠纷「处理中」

下面的架构图对比 实时查询定时同步 在读路径上的差异(简化)。

flowchart LR
  subgraph RT[实时查询路径]
    A1[用户请求] --> B1[库存服务]
    B1 --> C1{Redis 缓存命中?}
    C1 -->|是| Z1[返回缓存库存]
    C1 -->|否| D1[供应商 API]
    D1 --> E1[回写 Redis 短 TTL]
    E1 --> Z1
  end

  subgraph SCH[定时同步路径]
    J1[定时任务] --> K1[供应商批量接口]
    K1 --> L1[更新 MySQL 快照字段]
    L1 --> M1[可选:刷新 Redis 视图]
    A2[用户请求] --> B2[库存服务]
    B2 --> Z2[读本地快照 / 缓存]
  end

实践建议:同一家供应商也可能混用(例如酒店:列表页用快照,下单页强刷一次实时),关键是把策略写进配置中心而非写死在代码分支。

礼品卡横跨多种模式的启示:预采购卡密(Self + Code)、实时生成卡密(Supplier + Code)、无限库存(Unlimited)往往并存于同一业务线。统一模型的价值在于:团队可以用同一张「策略决策表」讨论边界,而不是在三个服务里分别口述规则。

异步预订(pending → confirmed)的工程清单:当供应商只能异步确认时,至少补齐以下构件:supplier_booking 映射表、可重入的轮询 worker、超时与人工介入队列、订单侧状态机联动、对账任务对「平台已占 / 供应商未确认」的专项扫描。否则极易出现「钱扣了但供应商没单」或「供应商有单但平台没单」的双向不一致。


27.5 数据一致性保证

27.5.1 Redis 与 MySQL 同步

典型路径是 「Redis 同步执行,MySQL 异步落库」

  • 同步:Lua 脚本更新 Redis 中的 available/booking/issued 或券码池。
  • 异步:发送 InventoryEvent 到 Kafka,消费者批量写 inventory_balanceinventory_ledger
操作RedisMySQL一致性语义
预占同步 Lua异步事件最终一致
确认售出同步 Lua异步事件最终一致
运营强锁 / 黑名单视场景:可同步双写 DB强一致需求更高

原则

  • Redis 不是账本:故障恢复应以 MySQL + 日志为准,Redis 可重建。
  • Outbox(可选):若要求「绝不丢事件」,在订单或库存事务内写 outbox 表,再异步投递。

双写与消息丢失的权衡:纯「先 Redis 后发 Kafka」在进程崩溃时可能丢消息。工程上常见三种增强手段(按成本从低到高):

  1. 同步写库存账本表(简化版 outbox):Redis 成功后同步插入 inventory_ledger(或写 binlog),再由后台任务投递 MQ;代价是热路径多一次 DB 写。
  2. 事务消息 / Outbox:与业务状态同事务提交,确保「状态变更」与「事件」原子一致。
  3. 对账修复为主、消息为辅:接受短窗口不一致,用对账把差异拉回(适合容忍度稍高、但吞吐极大的场景)。

选型没有银弹:机票酒店类强一致诉求更高,虚拟券码大促类更偏向吞吐与事后修复。

27.5.2 对账机制

对账目标不是「每时每刻 Redis == MySQL」,而是 尽快发现破坏恒等式与异常漂移,并可控修复

建议对账维度:

  1. 单行恒等式total = available + booking + locked + sold(字段含义以你的表结构为准)。
  2. 跨存储视图:Redis available vs MySQL available_stock 差值。
  3. 订单侧一致性PENDING_PAYMENT 订单是否仍存在预占记录;是否出现「仅商品预占成功、营销失败」等半截状态。
flowchart TD
  A[定时对账任务启动] --> B[拉取自管理 SKU 配置]
  B --> C[读取 Redis 聚合视图]
  C --> D[读取 MySQL 权威行]
  D --> E{恒等式成立?}
  E -->|否| X[告警 + 冻结售卖 + 记缺陷单]
  E -->|是| F{Redis vs MySQL 差值超阈?}
  F -->|否| Z[记录健康指标]
  F -->|是| G{允许自动修复?}
  G -->|是| H[以 MySQL 为准重写 Redis]
  G -->|否| Y[仅告警 + 人工确认]
  H --> Z

修复策略要谨慎:自动以 MySQL 覆盖 Redis 适合「Redis 丢数据」类问题;若根因是重复消费导致 MySQL 多减,则应阻断自动修复,先定位消息幂等缺陷。

对账任务的伪代码骨架(Go):对账不仅是数值 diff,更是「缺陷驱动」的运营工具。下面示例强调阈值、恒等式与人工门闩(auto_reconcile)。为便于阅读,abs / max 等函数省略实现。

// 伪代码骨架:abs/max/alert/rewrite 需按项目工具库实现
func ReconcileItem(ctx context.Context, cfg InventoryConfig) error {
	redisAvail := readRedisAvailable(ctx, cfg.ItemID, cfg.SKUID)
	mysqlRow, err := loadInventoryRow(ctx, cfg.ItemID, cfg.SKUID)
	if err != nil {
		return err
	}

	if !mysqlRow.identityOK() {
		return fmt.Errorf("mysql identity broken: item=%d sku=%d", cfg.ItemID, cfg.SKUID)
	}

	diff := redisAvail - mysqlRow.AvailableStock
	if abs(diff) > max(100, mysqlRow.AvailableStock/10) {
		alert(ctx, "large inventory diff", cfg.ItemID, cfg.SKUID, diff)
	}

	if cfg.AutoReconcile {
		return rewriteRedisFromMySQL(ctx, cfg.ItemID, cfg.SKUID, mysqlRow)
	}
	return nil
}

27.5.3 补偿任务

补偿任务用于处理:

  • Kafka 消费失败导致日志未落库。
  • 供应商异步预订最终态与本地订单状态不一致。
  • Saga 补偿某一步失败后的「人 + 程序」协同修复。

建议补偿任务具备:可观测进度、可重入、可限流、可人工跳过,并在执行前获取分布式锁或基于 order_id 的行级互斥,避免双写打架。

补偿与对账的分工:对账偏「批量、周期性、发现漂移」;补偿偏「单点、事件触发、把状态推进到合法终态」。两者叠加才能覆盖「消息乱序」「重复投递」「供应商晚到回调」等真实世界的粗糙边缘。

Kafka 消费者的吞吐与顺序:库存事件消费端建议「按 item_id 分区有序 + 批量落库」:item_id 分区可以保证同一商品变更串行应用,批量 INSERT 日志与合并更新可以降低 MySQL TPS。需要警惕的是:重试会导致重复消息,因此 MySQL 写入必须基于 event_id 或业务幂等键去重;否则对账会看到「日志重复 / 库存多减」。

跨库存类型一致性(商品 + 营销):秒杀场景下商品预占成功但营销失败时,必须回滚商品预占。回滚失败不要把系统留在「半占用」状态:应记录缺陷单并阻塞该 order_id 的继续支付,直到补偿成功或人工判定。该话题与第 4 章 Saga、第 28 章营销库存紧密相关,本章强调 库存侧 API 必须可单独幂等重放,以便编排器反复补偿。


27.6 系统边界与职责

27.6.1 库存系统的职责边界

库存系统应该负责

  • SKU 维度的可售数量 / 券码 / 日历切片视图的维护。
  • 预占、确认、释放、退款相关的原子操作与审计日志。
  • 供应商库存同步策略的执行与降级。

库存系统不应该负责

  • 订单优惠分摊、支付路由、用户风控评分(可读取必要参数,但不拥有规则)。
  • 商品详情文案、主图、类目属性(属于商品中心)。

27.6.2 库存 vs 商品:边界划分

维度商品中心库存系统
核心聚合SPU/SKU、属性、类目SKU(或批次 / 日期)库存数量与码池
上架生成可售商品视图根据模板初始化 inventory_config / 初始库存
快照商品快照用于订单展示可选择是否在快照中冗余「库存展示字段」

建议:商品详情页展示库存「有 / 无」可以来自搜索 / 商品聚合读模型;下单路径的强校验必须调用库存服务。

27.6.3 平台库存 vs 供应商库存

  • 平台自管:平台能强约束不超卖(在自有数据正确前提下)。
  • 供应商管理:平台只能「尽力而为」,必须定义 同步延迟下 的用户协议与技术降级(例如显示「库存紧张」、下单后异步确认)。

把「可售」定义成合同:供应商管理并不等于「平台不承担责任」。产品条款、详情页提示、客服话术需要与技术策略一致:例如列表页展示的是「上次同步快照」,下单页展示的是「下单瞬间强刷结果」,支付页又可能进入「供应商二次确认」。这些差异如果只靠前端临时拼接字段,极易引发纠纷;建议由商品 / 库存领域共同产出 可售声明(availability disclaimer) 的配置,并在关键触点统一渲染。

时间维度下的边界:酒店类库存往往以「入住日」为切片,查询与扣减都携带日期参数。库存服务应提供明确的日期合法性校验(不可售日期、最小连住、跨日边界),但不要吞掉「价格日历」职责——价格仍归计价系统,库存只回答「这一天还有没有房 / 席位」。

27.6.4 库存预占的归属

推荐由 库存服务提供 Reserve / Confirm / Release API,订单系统编排调用。避免订单服务直接写 Redis,否则:

  • 权限边界模糊,排障困难;
  • 原子脚本难以复用;
  • 监控指标分散。

进一步建议:把「预占记录」视为库存域内的聚合片段(可用 Redis HASH、也可用独立表存储影子状态),对外只暴露语义化 API。订单系统持有 order_id 与支付超时策略;库存系统持有「这单占了多少、占在哪一批次 / 哪一天」。当两边都要保存时,必须明确 主键映射与幂等回放 规则:支付回调重复到达时,Confirm 只能执行一次;超时释放与支付成功并发时,必须以「订单最终状态」为仲裁者。

组合型(Bundle)扣减的边界:套餐类商品是「一个售卖单元,多个库存单元」。库存系统可以提供 BundleReserve 事务式 API,内部仍以子 SKU 为单位调用原子脚本,但整体成功准则由库存域定义(全成或全败)。不建议把子项拆解交给订单服务循环调用——否则补偿顺序、部分失败、日志关联都会变得脆弱。


27.7 与其他系统的集成

27.7.1 与商品中心集成(商品上架时初始化库存)

商品中心在 SKU 生效时发出领域事件(或消息)是最佳挂钩点:库存服务消费事件后生成 InventoryCreateCommand,再由创建任务异步初始化 inventory_configinventory_balance、券码批次或日期 / 门店切片。完整创建模型见 22.2.6,本节只强调系统边界。

这里的关键是 幂等:同一 SKU 的重复发布 / 回滚发布不得生成重复库存行;建议使用 source_type + source_id + inventory_keyitem_id + sku_id + scope + publish_version 做唯一约束,并在消费端用「版本号 / 生效时间窗」判定是否应用变更。

对「供应商管理」品类,初始化阶段就要写入 supplier_idsync_strategy,并创建供应商适配器所需的 外部商品编码映射(否则库存同步与预订调用会在上线后才发现无法对齐)。对于券码制,还要初始化 batch_id 维度与分表路由规则,避免大促时临时改路由。

27.7.2 与商品供给运营平台和生命周期联动

从长期运营视角看,库存不是商品表里的一个字段,而是商品生命周期能否进入“真实可售”的硬闸门。商品供给运营平台负责“这次变更是否可以发布”,商品生命周期负责“正式商品处于什么线上状态”,库存系统负责“这个商品在某个范围内是否有可承诺资源”。三者必须通过命令、事件和可售投影联动,而不是互相直接改库。

职责可以这样划分:

拥有什么不应该做什么
供给运营平台Draft、Staging、QC、Diff、风险审核、发布任务直接写 inventory_balance 或券码池分表
商品生命周期PUBLISHED/ONLINE/OFFLINE/ENDED/BANNED/ARCHIVEDpublish_version、生效时间判断具体库存扣减是否成功
库存系统inventory_configinventory_balance、码池、预占、账本、供应商库存快照决定商品标题、类目、价格和审核结果
可售投影商品状态、库存状态、价格状态、履约状态的合成结果不能取代各域权威事实

更稳的联动方式是:

供给入口 / 运营编辑 / 供应商同步
  → Draft / Staging / QC / Diff
  → Publish Transaction
      写正式商品、发布版本、交易契约、Outbox
  → InventoryCreateCommand / InventoryAdjustCommand
  → 库存任务创建或调整库存实例
  → InventoryReady / InventoryChanged / InventoryFailed
  → Availability Projector 合成可售状态
  → 搜索、缓存、详情页、运营看板刷新

这里的关键判断是:发布成功不等于可售成功。发布只说明商品主数据和交易契约已经进入正式版本;真正能不能卖,还要看库存、价格、履约、渠道、风控和搜索投影是否都就绪。

生命周期与库存动作可以用一张表固定下来:

商品生命周期动作供给运营动作库存系统动作对外可售影响
新建 Draft运营填写商品、库存来源、券码模式、门店 / 日期范围不创建正式库存,只做表单校验和模拟校验不可见、不可售
Staging 提交冻结候选版本,做交易契约校验校验库存配置是否完整,例如有码池批次、供应商映射、日历范围仍不可售
QC / 风险通过允许进入发布可以预创建低风险库存任务,但不能对 C 端开放仍不可售
Publish 成功写正式商品、publish_version、Outbox消费事件创建 inventory_config、数量行、码池批次或时间切片等待 InventoryReady
ONLINE 生效生命周期调度器尝试上线若库存 ready 且未锁定,允许 Reserve;否则返回不可售原因可售投影变为 true
运营调库存创建库存变更单,走权限、Diff、审批执行 AdjustInventory/ImportCodeBatch/GenerateCodeBatch,写账本可售水位变化
OFFLINE / 下架生命周期变为下架停止新 Reserve;已有预占按订单状态释放或继续履约搜索下架,详情不可下单
ENDED / 过期销售期结束或活动结束锁定剩余库存、过期未售码、停止供应商 booking不可售,只保留售后
BANNED / 风控封禁风险系统或运营封禁立即冻结新预占,必要时锁定码池和供应商调用不可售,进入人工处理

供给运营平台和库存系统之间应通过语义化命令交互:

CreateInventory       创建库存配置和初始实例
AdjustInventory       数量调整、补货、扣减修复
ImportCodeBatch       导入供应商或运营上传的券码
GenerateCodeBatch     系统生成券码批次
LockInventory         风控、盘点、质量问题冻结库存
UnlockInventory       审核通过后解锁
EndSale               销售结束,锁定剩余资源
RebuildAvailability   重建可售投影

这些命令都要有 operation_idsource_typesource_idoperator_idreasonbase_publish_version。原因很简单:库存调整往往是资损敏感操作,不能让运营后台绕过库存账本直接改数量;也不能让供应商同步悄悄覆盖人工修正过的库存策略。

可售投影建议单独建模,而不是让前端临时拼状态:

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

这样运营后台可以明确展示“商品为什么还不能卖”:

商品已发布,但不可售:
- 库存创建任务失败:券码文件第 183 行重复
- 搜索索引未刷新:等待 Outbox 重试
- 门店 1001 未配置营业时段
- 供应商映射缺失 external_sku_id

成熟平台通常会把这个结果沉淀为 product_availability_projection 或搜索宽表字段。它不是权威库存,只是读模型;当商品生命周期、库存、价格、履约任一侧变化,都通过事件重建它。

几个容易踩坑的点:

  1. 把库存当商品字段:运营编辑商品时直接改 stock,绕过库存账本,后续对账无法解释。
  2. 把发布当上线:商品表 ONLINE 了,但码池没导完、日期切片没物化、供应商映射缺失,C 端下单失败。
  3. 把下架当删除库存:下架只是不再接收新交易,历史预占、已售、售后和券码核销仍要保留。
  4. 供应商同步直接覆盖人工库存策略:应通过字段主导权、保护期和变更单处理冲突。
  5. 生命周期和库存状态互相递归调用:建议事件驱动 + 可售投影,避免同步调用链变成大事务。

27.7.3 与订单系统集成(预占 / 扣减 / 释放)

创单路径建议以 Reserve 作为硬闸门:订单系统先拿到库存服务的成功回执,再写入订单主表为 PENDING_PAYMENT。如果顺序反过来,会出现「订单已创建但库存未占」的不可恢复窗口,除非再引入复杂补偿。

支付成功后的 Confirm 应与支付回调幂等键绑定(支付单号 / 回调事件 id)。实践中常见错误是:支付重放导致库存二次加 issued,或支付失败却误触发 Confirm。关单 / 超时释放 应与订单状态机严格对齐:只有从可取消状态进入释放,才调用 Release;对于已进入履约的订单,释放必须转为退款域的逆向流程(可能涉及供应商取消接口)。

27.7.4 与供应商系统集成(实时查询 / 定时同步)

供应商集成建议落在 供应商网关库存适配器层,由库存服务调用,而不是让订单服务直连供应商:订单系统只需要知道「库存服务承诺的结果」,不需要理解每家供应商的 OAuth、签名算法与重试语义。

适配器层应统一:超时、重试(仅对幂等读 / 明确幂等写)、熔断、隔离舱(bulkhead)、以及 错误码映射。强烈建议把供应商错误抽象为三类:Retryable(可重试)、Fatal(明确失败)、Unknown(需要人工核对)。Unknown 类错误不要自动重试写入路径,否则极易造成重复预订。

27.7.5 库存变更事件发布

事件字段建议包含:event_idevent_typeitem_idsku_idorder_idquantitybefore/after 快照、时间戳。消费者可以是:搜索引擎刷新可售标签、报表、风控。

事件设计要兼顾 可排序可去重event_id 建议全局唯一;event_type 建议稳定枚举;before/after 用于审计与对账回放。对于券码制,还应携带 code_ids 或哈希摘要(避免明文扩散到不该出现的下游)。如果下游是搜索索引,通常只需要「可售阈值变化」而非每一次微抖动,可增加 聚合投影(projector)把高频事件折叠为低频索引更新。

27.7.6 集成模式与降级策略

  • 同步编排 + 异步对账 是默认主路径;
  • 秒杀聚合接口(一次网络往返完成商品 + 营销预占)属于性能优化特例,应被清晰标记为「窄场景专用」,避免成为全局耦合点。

集成时序(常规创单:订单编排库存):下图强调「库存服务不创建订单」,只提供原子操作;订单系统承担 Saga 与超时任务。

sequenceDiagram
  autonumber
  participant U as 用户
  participant O as 订单系统
  participant PI as 商品库存
  participant CI as 营销库存
  participant DB as MySQL

  U->>O: 提交订单
  O->>PI: ReserveStock(order_id)
  PI-->>O: OK
  O->>CI: ReserveQuota(order_id)
  CI-->>O: OK
  O->>DB: InsertOrder(PENDING_PAYMENT)
  O-->>U: 创单成功

  Note over O,PI: 支付成功回调路径省略;失败时按逆序补偿 Release

降级策略(库存不可用):严格模式直接失败;宽松模式允许「先创单后补扣」(极易超卖,仅适合内部试单或供应商兜底能力极强且可取消的场景)。若启用宽松模式,必须同步启用 更频繁对账 + 更强支付确认校验 + 明确法务条款


27.8 工程实践

27.8.1 Lua 脚本原子性

Redis 单线程执行 Lua,可保证脚本内多条命令原子执行,非常适合「读-判断-写」库存扣减。

数量制预占脚本(示例):从 available 扣减并增加 booking,不足返回 -1

-- KEYS[1]: inventory:qty:stock:{itemID}:{skuID}
-- ARGV[1]: qty
local key = KEYS[1]
local qty = tonumber(ARGV[1])

local available = tonumber(redis.call('HGET', key, 'available') or '0')
if available < qty then
  return -1
end

redis.call('HINCRBY', key, 'available', -qty)
redis.call('HINCRBY', key, 'booking', qty)
return available - qty

带幂等门的预占(强烈建议):仅靠业务层判断「是否已预占」仍可能出现并发双调。更稳妥做法是把幂等状态写进同一个 HASH(或独立 key),让 Lua 一次完成「首次预占 / 重复预占返回成功」。

-- KEYS[1]: inventory:qty:stock:{itemID}:{skuID}
-- KEYS[2]: inventory:qty:reservation:{orderID}
-- ARGV[1]: qty
-- ARGV[2]: ttlSeconds
local stockKey = KEYS[1]
local resKey = KEYS[2]
local qty = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])

if redis.call('EXISTS', resKey) == 1 then
  return 1
end

local available = tonumber(redis.call('HGET', stockKey, 'available') or '0')
if available < qty then
  return -1
end

redis.call('HINCRBY', stockKey, 'available', -qty)
redis.call('HINCRBY', stockKey, 'booking', qty)

redis.call('HSET', resKey,
  'qty', qty,
  'status', 'RESERVED'
)
redis.call('EXPIRE', resKey, ttl)
return 0

确认与释放(与预占配对):确认时将 booking 转为 issued;释放时退回 available。下面脚本演示「仅当 reservation key 仍存在且状态为 RESERVED 才确认」,用于防止重复支付回调导致二次加 issued

-- KEYS[1]: inventory:qty:stock:{itemID}:{skuID}
-- KEYS[2]: inventory:qty:reservation:{orderID}
-- ARGV[1]: op -- CONFIRM or RELEASE
local stockKey = KEYS[1]
local resKey = KEYS[2]
local op = ARGV[1]

if redis.call('EXISTS', resKey) == 0 then
  return 2
end

local qty = tonumber(redis.call('HGET', resKey, 'qty') or '0')
local st = redis.call('HGET', resKey, 'status')

if st ~= 'RESERVED' then
  return 3
end

if op == 'CONFIRM' then
  local booking = tonumber(redis.call('HGET', stockKey, 'booking') or '0')
  if booking < qty then return -1 end
  redis.call('HINCRBY', stockKey, 'booking', -qty)
  redis.call('HINCRBY', stockKey, 'issued', qty)
  redis.call('HSET', resKey, 'status', 'CONFIRMED')
  redis.call('PERSIST', resKey)
  return 0
end

if op == 'RELEASE' then
  local booking = tonumber(redis.call('HGET', stockKey, 'booking') or '0')
  if booking < qty then return -1 end
  redis.call('HINCRBY', stockKey, 'booking', -qty)
  redis.call('HINCRBY', stockKey, 'available', qty)
  redis.call('DEL', resKey)
  return 0
end

return 4

Go 侧调用(go-redis v9 示例)

package inventory

import (
	"context"
	"fmt"

	"github.com/redis/go-redis/v9"
)

const reserveQtyLua = `
local key = KEYS[1]
local qty = tonumber(ARGV[1])
local available = tonumber(redis.call('HGET', key, 'available') or '0')
if available < qty then return -1 end
redis.call('HINCRBY', key, 'available', -qty)
redis.call('HINCRBY', key, 'booking', qty)
return available - qty
`

type RedisInventory struct {
	rdb redis.UniversalClient
}

func (s *RedisInventory) ReserveQuantity(ctx context.Context, itemID, skuID int64, qty int) (int64, error) {
	key := fmt.Sprintf("inventory:qty:stock:%d:%d", itemID, skuID)
	res, err := s.rdb.Eval(ctx, reserveQtyLua, []string{key}, qty).Int64()
	if err != nil {
		return 0, err
	}
	if res < 0 {
		return 0, ErrNotEnoughStock
	}
	return res, nil
}

券码池取 code_id 脚本(示例)LRANGE + LTRIM 同事务化,避免读到数据却在截断前被并发修改。脚本只从 Redis 热队列取 code_id,不能视为出码成功;真正成功以 MySQL AVAILABLE -> BOOKING 的 CAS 更新为准。

-- KEYS[1]: inventory:code:pool:{batch}:{shard}
-- ARGV[1]: n
local n = tonumber(ARGV[1])
local codeIds = redis.call('LRANGE', KEYS[1], 0, n - 1)
redis.call('LTRIM', KEYS[1], n, -1)
return codeIds

补货并发与空池短路:券码制常见问题是 Redis LIST 空时频繁穿透数据库。应组合使用「空池标记(短 TTL)」「补货分布式锁」「MySQL 侧复合索引 + id 游标分页」,避免补货慢事务拖垮热路径。

脚本版本管理:生产环境建议把 SHA 载入或显式 SCRIPT LOAD,并对脚本变更做版本号控制,避免滚动发布期间混用旧脚本。

27.8.2 性能优化

  • 热点 Key:本地缓存、随机副本读、网关限流、拆分活动维度字段。
  • 批量落库:Kafka consumer 批量 INSERT 操作日志,减少 MySQL roundtrip。
  • 预热:大促前把可售 code_id 批量灌入 Redis LIST,避免冷启动补货抖动;明文券码仍只在 MySQL 加密存储。

容量与峰值的经验法则(中型平台量级):当峰值下单 QPS 相对日均放大两个数量级以上时,瓶颈往往不在「业务 if-else」,而在 Redis 热 Key、Kafka 消费滞后、MySQL 批量写入窗口。因此性能优化应优先围绕:热点分散、消息攒批、限流前置、以及「允许短暂最终一致」的产品与风控共识。

Redis 故障降级:Redis 不可用时,可短期切到 MySQL 行级锁扣减(UPDATE ... WHERE available_stock >= ?SELECT ... FOR UPDATE),并把实例标记为 degraded,待恢复后做一次 以 MySQL 为准的全量回填。降级期间的延迟与锁竞争上升是预期成本,需要在监控面板明确标注「降级模式」,避免误读 SLO。

27.8.3 监控告警

建议至少监控:

  • reserve/confirm/release 成功率与 P99 延迟;
  • Redis / MySQL 差异直方图;
  • 供应商调用错误率与熔断状态;
  • 对账修复次数与人工介入队列长度。

告警分级示例(可与 Prometheus 规则结合)

级别触发条件响应目标
P0任意 SKU 出现「已售大于总量」或恒等式破坏立即停售 + 紧急修复
P1Redis / MySQL 可用量长期分叉且持续扩大1 小时内定位根因
P2供应商同步延迟超阈值但未破坏交易降级展示 + 供应商工单

27.9 本章小结

本章围绕「通用库存模型 + 多维分类 + 策略实现 + 清晰系统边界」展开:

  • 用库存对象、库存范围、事实来源、单元形态和扣减时机收敛多品类差异,并以矩阵图帮助团队建立共同语言。
  • inventory_balanceinventory_reservationinventory_ledger 区分聚合视图、预占记录和事实流水,避免把 Redis 当作最终账本。
  • InventoryCreateCommandinventory_create_task 把库存创建独立建模,覆盖数量、券码、系统生码、门店和日期切片等不同初始化方式。
  • 用命令、事件和可售投影串联商品供给运营平台、商品生命周期和库存状态,避免把发布成功误读为可售成功。
  • 预占 / 确认 / 超时释放 为主轴设计扣减策略,并用时序图明确订单、库存、延时队列与支付的协作关系。
  • 在供应商集成上区分 实时查询与定时同步 的读路径差异,并强调降级与产品文案的一致性。
  • 在一致性上采用 Redis 同步 + MySQL 异步 + 对账修复 的组合拳,避免把 Redis 当作唯一账本。
  • 在工程层用 Go + Lua 落实热路径原子性,配合监控与补偿任务形成闭环。

落地检查清单(团队可用)

  1. 每个 SKU 是否都能解释其 inventory_key、库存范围、事实来源、单元形态和扣减时机?
  2. 库存创建是否有独立任务、幂等键、失败重试和部分成功处理?
  3. 商品发布、生命周期、库存 ready、价格 ready、履约 ready 是否能合成明确的可售投影?
  4. 创单路径是否以 Reserve 原子接口 为准,而不是仅 Check?
  5. order_id 是否在 Reserve / Confirm / Release 全链路幂等?
  6. 是否同时具备 TTL、延时队列、定时扫描 至少两道释放防线?
  7. 是否具备 Redis / inventory_balance / inventory_reservation / 订单状态 多方对账与人工门闩?
  8. 供应商异步确认是否有 映射表 + worker + 人工队列

阅读建议:若读者刚完成第 25 章商品中心与第 4 章一致性章节,可按「商品发布 → 库存创建 → 创单预占 → 支付确认 → 对账修复」的顺序对照本章示意图走读一遍;再把自家品类的库存对象、库存范围、事实来源、单元形态和扣减时机填入矩阵,通常能在工作坊中快速对齐产品与工程预期。建议同时准备 1~2 个真实故障案例作为讨论锚点,避免停留在抽象原则层面。

下一章预告:第 28 章将深入营销系统,重点讨论优惠计算与营销库存(券、活动、补贴)如何与商品库存协同,避免「算得便宜却卖超了」这类跨域问题。


第 28 章 营销系统

交易链路关键域:营销系统负责「增长与让利」的可编排表达——优惠券、积分、活动、补贴等工具在成本可控前提下提升转化;同时必须与计价、订单、库存、支付等系统严格分工,避免「算价口径漂移」与「资源扣减双写」。


28.1 系统概览

28.1.1 营销系统的定位

一句话定位:营销系统是电商平台的增长引擎让利规则中心,它不替代「商品价格事实来源」(商品中心 / 计价中心),而是对可售商品集合施加条件化权益(券、活动价、积分抵扣、平台补贴),并在交易链路中完成可审计的占用与核销

与相邻系统的关系(职责视角):

系统营销系统依赖它什么营销系统不替它承担什么
商品中心类目、SPU/SKU、可售状态、圈品范围商品主数据维护、上下架编排
计价中心统一试算编排、价格分层模型、快照口径基础价/渠道价等「标价事实」的唯一来源(按组织边界而定)
库存系统可售库存、预占结果实物库存扣减与释放
订单系统创单编排、状态机、补偿入口订单主单据生命周期(营销只参与其中资源步骤)
用户系统画像标签、等级、风控信号账号体系与鉴权主责
支付系统实付金额、分账、补贴清算渠道对接与支付状态机

价值闭环:投放 → 领取/参与 → 试算曝光 → 下单锁定 → 支付核销 → 结算对账 → 报表 ROI。缺任何一环都会出现「看得见优惠、对不上账」的工程事故。

28.1.2 核心业务场景

典型场景可按用户生命周期与平台目标拆分:

拉新:新人券、首单立减、渠道专属券批次、注册礼包(券 + 积分组合)。

促活与留存:签到积分、任务体系、会员等级权益、生日礼、沉默召回券。

成交提升(GMV / AOV):满减满折、跨店凑单、限时折扣、N 元购、买赠。

热点营销(高并发):秒杀、抢券、限量补贴;对系统提出与普通促销完全不同的容量与一致性要求。

平台型业务(B2B2C):商家自营销 + 平台统一规则 + 审核流;成本承担方可能是商家、平台或按比例共担,必须在支付与清结算链路可解释、可分摊。

B2C 与 B2B2C 的营销差异(决定你是否要引入「审核流、分账、跨店叠加」三件套):

维度B2C(自营为主)B2B2C(平台 + 商家)
营销主体平台单一主体平台与多商家并存
成本承担平台预算闭环即可需定义商家承担、平台补贴、联合出资比例
活动审核通常内部运营闭环商家活动常需平台审核与风控评分
优惠叠加平台统一互斥组即可需处理跨店、跨卖家券、店铺券与平台券的优先级
结算复杂度订单金额 ≈ 平台收入口径需分账、分润、逆向退款时的营销成本回冲

非功能需求(NFR)速查:营销系统既要「算得对」,也要「扛得住、赔得起、查得到」。建议在架构评审材料中显式写出下列指标,并与监控看板一一映射:

  • 一致性:券与积分的状态迁移与订单支付状态单调一致;允许的最终一致边界写清楚(例如报表延迟分钟级)。
  • 幂等性:领取、冻结、核销、回滚接口全部带业务幂等键;消息消费以 event_id(biz_type,biz_id,action) 去重。
  • 可用性:试算路径可降级;写路径失败可补偿;热点活动具备独立熔断域,避免拖垮全站下单。
  • 可观测性:每一次试算输出 trace_id;每一次核销写审计流水;预算与库存类 Redis Key 有容量与过期策略。
  • 安全与合规:防刷、频控、隐私最小化;补贴与券的发放记录满足审计留存周期。

28.1.3 系统架构

工程上通常采用「接入编排 + 工具域服务自治 + 计算引擎集中」的形态:网关负责鉴权、路由、限流与实验分桶;券/积分/活动各自拥有独立数据库边界(逻辑或物理分库);营销计算引擎聚合多源输入并调用规则引擎;异步事件通过消息总线广播给通知、风控、数据仓库与对账任务。

flowchart LR
  U[用户终端] --> G[营销网关 / BFF]
  G --> CS[优惠券服务]
  G --> PS[积分服务]
  G --> AS[活动服务]
  G --> CE[营销计算引擎]

  CS --> CDB[(券库 MySQL)]
  PS --> PDB[(积分库 MySQL)]
  AS --> ADB[(活动库 MySQL)]
  AS --> ES[(Elasticsearch)]

  CE --> RE[[规则引擎]]
  CE --> RC[(规则配置 Redis)]

  subgraph Cache[高性能层]
    R[(Redis: 库存/频控/锁)]
  end

  CS --> R
  PS --> R
  AS --> R

  subgraph Bus[异步总线]
    K[Kafka]
  end

  CS --> K
  PS --> K
  AS --> K
  CE --> K

  G --> PR[商品中心]
  G --> PC[计价中心]
  G --> OR[订单服务]

协作要点

  1. 读路径:试算以「低耦合聚合」为目标,尽量通过计价中心统一编排(见 23.6、28.7),营销服务提供「可用工具集合 + 规则解释」。
  2. 写路径:领取、冻结、核销、回滚必须可幂等、可补偿;与订单 Saga 步骤一一对应(见 23.7.3)。
  3. 观测路径:任何金额差异必须能定位到「规则版本 + 输入快照 + 引擎输出」。

典型技术选型(可按团队资产替换,但角色分工建议保留)

组件类型常见选型在营销系统中的职责
关系型数据库MySQL / PostgreSQL券批次、用户券、积分流水、活动配置、审计表;强一致事实源
缓存与计数Redis热点库存、用户领券次数、预算桶、分布式锁、滑动窗口限流
消息队列Kafka / Pulsar异步通知、对账、数据仓库同步、延迟核销补偿
搜索与分析Elasticsearch活动检索、运营圈人、券批次检索;与交易主路径解耦
流量治理Sentinel / Envoy / 自研网关热点接口限流、熔断、排队策略入口

容量与体验的经验区间(用于评审对齐,不是 SLA 承诺):日常试算 QPS 与购物车刷新强相关;大促峰值往往来自「领券 + 秒杀下单」叠加。实践中常把「试算」与「领券写路径」在网关层拆分集群,避免读放大拖慢写。秒杀接口的目标不是无限吞吐,而是失败要快、成功要稳:失败请求在边缘以毫秒级返回,成功请求进入受控队列,尾部延迟可接受。


28.2 营销工具体系

营销工具体系的本质是权益载体不同:券是「凭证类权益」,积分是「账户类权益」,活动是「时段/集合类权益」,补贴是「清算类权益」。统一抽象有利于计算引擎与对账。

从领域建模角度,建议抽一层极薄的**营销权益(PromotionEntitlement)**通用语言:任何工具最终都落到「是否可用、可用多少、如何占用、如何确认、如何冲正」五问。这样订单编排层不必理解「满减与折扣的数学差异」,只理解统一的 Try/Confirm/Cancel 契约即可。

反模式提醒

  • 把「活动价」直接写进商品中心主价格字段,导致历史订单与供应商结算口径被破坏。
  • 在订单服务内复制一份券规则计算逻辑,短期最快,长期必然与营销引擎漂移。
  • 补贴只记在营销表、不落订单行快照,导致支付成功后的财务还原无法对齐。
flowchart TB
  subgraph Tools[营销工具域]
    COUP[优惠券子域]
    PT[积分子域]
    ACT[活动子域]
    SUB[补贴子域]
  end

  subgraph Shared[共享能力]
    ID[权益实例 ID]
    SM[状态机与审计日志]
    POL[互斥/叠加策略引用]
  end

  COUP --> Shared
  PT --> Shared
  ACT --> Shared
  SUB --> Shared

  CE2[营销计算引擎] --> POL
  CE2 --> COUP
  CE2 --> PT
  CE2 --> ACT

三大工具与数据平面的关系(落地视图):优惠券与积分强依赖「用户维度」一致性与账务流水;活动强依赖「商品维度」圈品与时段索引。下图从读写路径拆开,便于与容量规划对齐:写路径(领取、冻结、核销)走高一致通道;读路径(列表、试算)可走缓存与只读副本,但创单前必须有一次穿透校验。

flowchart TB
  subgraph CouponPlane[优惠券平面]
    CB[(券批次表)]
    CU[(用户券实例)]
    CL[(券审计流水)]
    CRS[[券库存 Redis]]
  end

  subgraph PointsPlane[积分平面]
    PA[(积分账户)]
    PL[(积分流水)]
    PE[(过期索引 / 批次桶)]
  end

  subgraph ActivityPlane[活动平面]
    AC[(活动主数据)]
    AP[(圈品映射 / 规则表达式)]
    ES[(活动检索 ES)]
    ARS[[活动配额 Redis]]
  end

  GW2[营销网关] -->|写: 领取 / 冻结| CouponPlane
  GW2 -->|写: 发放 / 扣减| PointsPlane
  GW2 -->|读: 命中检索| ActivityPlane

  CE3[营销计算引擎] -->|读模型| CouponPlane
  CE3 -->|读余额| PointsPlane
  CE3 -->|读命中| ActivityPlane
  CE3 -->|不写账务| GW2

28.2.1 优惠券系统

模型拆分

  • Coupon(券批次):描述面额/折扣、门槛、总库存、每用户限领、适用范围(全场/类目/SKU)、承担方(平台/商家)。
  • CouponUser(用户券实例):领取时间、过期时间、状态(未使用/冻结/已使用/作废)。
  • CouponLog(审计流水):谁在何时以何因做了何动作;是对账与客服判责依据。

关键实现约束

  1. 超发控制:热点券批次用 Redis 原子扣减「可领库存」,DB 落库作为最终事实;二者通过异步对账修正(见 23.8.2)。
  2. 核销一致性:下单冻结、支付成功确认核销、关单回滚;状态迁移必须落在单用户券粒度锁或等价乐观锁上,避免并发双花。

券批次生命周期与运营协同:除技术状态外,建议为运营提供「紧急下线」与「仅禁止新领取、已领取仍可用」两种模式。前者用于舆情与合规风险,后者用于预算将尽时的平滑收口。两种模式在网关与试算引擎侧都要有显式开关,避免只改数据库导致缓存层继续发券。

import (
	"context"
	"strconv"
	"time"
)

// CouponUserStatus 描述用户券生命周期(示意)
type CouponUserStatus string

const (
	CouponUserUnused  CouponUserStatus = "UNUSED"
	CouponUserFrozen  CouponUserStatus = "FROZEN"
	CouponUserUsed    CouponUserStatus = "USED"
	CouponUserExpired CouponUserStatus = "EXPIRED"
)

type FreezeCouponCommand struct {
	UserID       int64
	CouponUserID int64
	OrderID      int64
	Idempotency  string
}

type CouponAppService struct {
	repo   CouponRepository
	locker DistributedLock
	bus    EventBus
}

func (s *CouponAppService) Freeze(ctx context.Context, cmd FreezeCouponCommand) error {
	unlock, err := s.locker.Lock(ctx, "coupon_user:"+strconv.FormatInt(cmd.CouponUserID, 10), 3*time.Second)
	if err != nil {
		return err
	}
	defer unlock()

	cu, err := s.repo.GetCouponUserForUpdate(ctx, cmd.CouponUserID)
	if err != nil {
		return err
	}
	if cu.UserID != cmd.UserID {
		return ErrNotOwner
	}

	// 幂等:同一订单重复冻结直接成功
	if cu.Status == CouponUserFrozen && cu.FrozenOrderID != nil && *cu.FrozenOrderID == cmd.OrderID {
		return nil
	}
	if cu.Status != CouponUserUnused {
		return ErrInvalidState
	}

	return s.repo.Transition(ctx, cmd.CouponUserID, Transition{
		From: CouponUserUnused,
		To:   CouponUserFrozen,
		OrderID: cmd.OrderID,
		Reason:  "freeze_for_order",
		IdemKey: cmd.Idempotency,
	})
}

28.2.2 积分系统

账户模型available / frozen 双桶;流水追加不可变;过期建议「批次/桶」或「到期索引表」驱动,避免全表扫描。

并发更新:高冲突账户使用 version 乐观重试;低冲突可用单行 CAS。对外接口必须支持业务幂等键(例如 biz_type + biz_id)防止重复发放。

import (
	"context"
	"strconv"
	"time"
)

type SpendPointsCommand struct {
	UserID      int64
	Points      int64
	OrderID     int64
	Idempotency string
}

func (s *PointsAppService) Spend(ctx context.Context, cmd SpendPointsCommand) error {
	if ok, err := s.repo.InsertIdempotency(ctx, "points_spend", cmd.Idempotency); err != nil {
		return err
	} else if !ok {
		return nil
	}

	for i := 0; i < 5; i++ {
		acct, err := s.repo.GetAccount(ctx, cmd.UserID)
		if err != nil {
			return err
		}
		if acct.Available < cmd.Points {
			return ErrInsufficientPoints
		}

		affected, err := s.repo.UpdateAvailableCAS(ctx, cmd.UserID, acct.Version, acct.Available-cmd.Points, acct.TotalSpent+cmd.Points)
		if err != nil {
			return err
		}
		if affected == 1 {
			_ = s.repo.AppendLog(ctx, PointsLog{
				UserID: cmd.UserID, Type: "SPEND", Delta: -cmd.Points,
				BizType: "ORDER", BizID: strconv.FormatInt(cmd.OrderID, 10),
			})
			return nil
		}
		time.Sleep(time.Duration(10*(i+1)) * time.Millisecond)
	}
	return ErrWriteConflict
}

28.2.3 活动系统

活动系统负责规则配置 + 圈品 + 生命周期治理。活动类型差异很大,但工程上可收敛为:

  1. 活动元数据:时间窗、状态机(草稿/待审/生效/结束/作废)。
  2. 参与单元:SKU 级活动价、店铺级满减、平台级跨店活动。
  3. 执行策略:由计算引擎解释 rule_config(JSON / DSL),活动服务自身避免堆叠 switch 地狱(与 23.3 联动)。

圈品(与商品中心集成详见 23.7.1):活动侧存 activity_product 映射或存规则表达式;运行时以 product_id/sku_id/category_id 多路判定,注意索引与缓存击穿。

活动运营与工程协作:活动系统往往是运营配置最高频的子系统。建议把配置错误分为三类分别治理:语法错误(JSON Schema 校验拒绝保存)、语义风险(例如折扣低于成本阈值触发风控审核)、容量风险(圈品过大导致试算超时,需异步预计算 + 结果缓存)。Engineering 侧提供「沙箱试算」与「灰度发布」能力,比单纯堆人审核更有效。

常见活动形态与工程关注点(节选):

活动形态业务目标工程关注点
满减满折提升客单价跨店分摊、尾差、与券叠加顺序
限时直降清库存 / 打爆款与基础价、渠道价冲突检测
秒杀抢购引流热点库存、风控、异步下单、超卖校准
买赠关联销售赠品行生成、赠品库存、履约拆单

28.2.4 补贴系统

补贴与「券/活动」不同之处在于:它往往不直接以用户可见凭证表达,而是以平台/商家承担比例进入清结算。典型场景:

  • 平台秒杀补贴:活动价低于供货价差额由平台承担。
  • 联合营销:商家出资 70%,平台出资 30%。
  • 支付立减:渠道补贴 + 平台补贴叠加(需风控与预算)。

数据落点:订单行级记录「营销成本分摊字段」;支付成功后由营销结算服务生成结算事实表,推送给财务/对账系统(与 23.7.5 呼应)。

type SubsidySplit struct {
	OrderID        int64
	LineID         int64
	PlatformCent   int64
	MerchantCent   int64
	ThirdPartyCent int64
	Currency       string
}

func mulDiv64(a, b, denom int64) int64 {
	if denom == 0 {
		return 0
	}
	return (a * b) / denom
}

func BuildSubsidySplit(line LinePriceSnapshot, policy CostSharePolicy) SubsidySplit {
	discount := line.ListCent - line.PayableCent
	platform := mulDiv64(discount, policy.PlatformBP, 10_000)
	third := mulDiv64(discount, policy.ChannelBP, 10_000)
	merchant := discount - platform - third
	if merchant < 0 {
		merchant = 0
	}
	return SubsidySplit{OrderID: line.OrderID, LineID: line.LineID, PlatformCent: platform, MerchantCent: merchant, ThirdPartyCent: third, Currency: line.Currency}
}

28.3 营销计算引擎

营销计算引擎是「把业务上含糊的便宜」翻译成「可执行、可分摊、可回滚」的工程模块。它输入购物车行、用户工具实例、活动集合、规则版本;输出每个 SKU 行的优惠拆分与订单级汇总。

为什么必须单独建设「引擎」而不是散落在各接口里? 因为营销规则的变化频率远高于交易主流程:运营每周都可能调整叠加策略、临时插入互斥组、或对某渠道单独放量。若把规则散落在购物车、结算、创单多个服务,最终一定出现「页面能买、结算不能买」或「结算能买、支付少减」的漂移。引擎化的核心价值是把规则解释收敛到单一模块,并把输入输出契约化,让其他系统以「黑盒服务」方式依赖它。

输入输出的工程契约(建议写进接口文档的第一页)

  • 输入必须可序列化快照化:不仅是商品 ID 列表,还应包含价格快照引用、店铺维度、会员等级、渠道、时区与活动版本。任何无法快照的输入都不应进入创单强一致路径。
  • 输出必须可分摊:除了订单级优惠总额,还要给出「行级拆分」与「税/运费处理建议字段」(若业务需要),否则财务与发票域会再次各自实现一套拆分。
  • 输出必须可回放trace 不是日志炫技,而是客服判责与线上排障的最低成本工具;建议以结构化 JSON 存储关键决策点(命中、未命中原因、互斥裁决)。

28.3.1 规则引擎设计

规则引擎的目标不是追求通用 AI,而是追求:可版本化、可灰度、可解释、可单测。推荐分层:

  1. 事实层(Facts):用户、店铺、渠道、会员等级、商品标签、时间窗。
  2. 约束层(Constraints):互斥组、优先级、每单上限、每用户上限、黑白名单。
  3. 策略层(Policies):叠加顺序(先活动后券 / 先券后活动)、分摊策略(按比例/按剩余价)、取整模式。
  4. 执行层(Actions):生成 AppliedPromotion 列表与金额。
flowchart TB
  IN[试算请求\n购物车行 + 用户选择] --> NORM[规范化 Facts\n类目/店铺/渠道/等级]
  NORM --> MATCH[规则匹配\n索引 + 过滤]
  MATCH --> CONS[约束求解\n互斥/上限/黑名单]
  CONS --> ORD[策略排序\n优先级 + tie-break]
  ORD --> APPLY[动作执行器\n生成应用明细]
  APPLY --> ALLOC[分摊器\n尾差修正]
  ALLOC --> OUT[试算结果\n明细 + 汇总 + trace]

  CFG[(规则配置版本)] --> MATCH
  CFG --> CONS
  CFG --> ORD

  subgraph Exec[执行器插件]
    A1[满减]
    A2[折扣封顶]
    A3[积分抵扣]
    A4[活动价覆盖]
  end

  APPLY --> Exec

落地建议:规则配置存版本号;试算响应携带 rule_versionengine_trace_id;创单快照必须引用同一版本,避免「页面价 ≠ 创单价」纠纷。

规则引擎实现梯度(从简到繁)

  1. 配置驱动 + 少量代码:适合多数电商平台;规则以结构化 JSON 存储,由固定管线解释;上线规则走版本表 + 灰度。
  2. DSL + 安全沙箱:适合玩法极多、运营希望「自写表达式」的团队;需限制可调函数集合、CPU 时间、内存与外部 I/O。
  3. 外置规则引擎(Rete 系):适合金融级复杂规则或强审计行业;引入成本高,需评估团队运维能力。

无论哪一梯度,都不要把「外部 I/O」藏在规则匹配的热路径里:事实应在进入引擎前由编排层并行拉齐并做超时兜底,引擎内部尽量纯函数化,便于单测与回放。

type RuleEngine interface {
	Evaluate(ctx context.Context, in BasketInput, cfg RuleSetVersion) (Evaluation, error)
}

type Evaluation struct {
	Applied []AppliedPromotion
	Trace   []TraceStep
}

type DefaultRuleEngine struct {
	matcher   Matcher
	solver    ConstraintSolver
	applier   ApplierChain
	allocator LineAllocator
}

func (e *DefaultRuleEngine) Evaluate(ctx context.Context, in BasketInput, cfg RuleSetVersion) (Evaluation, error) {
	candidates, err := e.matcher.Match(ctx, in, cfg)
	if err != nil {
		return Evaluation{}, err
	}
	filtered, err := e.solver.ApplyConstraints(ctx, in, candidates)
	if err != nil {
		return Evaluation{}, err
	}
	applied, trace, err := e.applier.Apply(ctx, in, filtered, cfg.StackingPolicy)
	if err != nil {
		return Evaluation{}, err
	}
	if err := e.allocator.AllocateToLines(ctx, in.Lines, &applied); err != nil {
		return Evaluation{}, err
	}
	return Evaluation{Applied: applied, Trace: trace}, nil
}

28.3.2 优惠叠加与互斥

叠加规则是事故高发区。建议产品口径与实现口径合一:用「互斥组 ID + 优先级 + 可叠加白名单」三要素表达一切

flowchart TD
  S([开始叠加编排]) --> P1[步骤1: 活动价 / 秒杀价\n命中后刷新行内基准价]
  P1 --> P2[步骤2: 店铺级促销\n满减 / 满折 / 店铺券池]
  P2 --> G{互斥组校验\n同组择优}
  G -->|存在冲突| R[按优先级 / 用户选择\n保留唯一胜出项]
  G -->|无冲突| P3[步骤3: 平台级促销\n跨店满减 / 平台券]
  P3 --> P4[步骤4: 积分抵扣\n上限、比例、最低应付]
  P4 --> P5[步骤5: 支付渠道优惠\n由支付域承接可选]
  P5 --> E([输出最终应付\n含 trace 与分摊])

互斥典型:同一互斥组内多张券二选一;活动价与部分券互斥;渠道支付券与平台券互斥。实现上不要在多个服务各写一段 if,而应由引擎读取同一份配置

type StackingPolicy struct {
	Steps []StackStep
}

type StackStep struct {
	Name        string
	MutexGroups []string // promotions in same group are mutually exclusive within this step
}

type MutexGuard struct{}

func (MutexGuard) PickAtMostOne(ps []Candidate) ([]Candidate, error) {
	seen := map[string]Candidate{}
	out := make([]Candidate, 0, len(ps))
	for _, c := range ps {
		if c.MutexGroup == "" {
			out = append(out, c)
			continue
		}
		old, ok := seen[c.MutexGroup]
		if !ok || c.Priority > old.Priority {
			seen[c.MutexGroup] = c
		}
	}
	for _, v := range seen {
		out = append(out, v)
	}
	return out, nil
}

28.3.3 最优解求解

「最优」必须业务定义:常见是用户应付最小平台补贴最小GMV 最大。工程上可用:

  • 小规模:券张数 ≤ 3 且活动组合有限时,有界枚举最可靠。
  • 中等规模:动态规划(若可分解为线性结构);或贪心 + 校验(先取门槛最高券,再修正)。
  • 大规模:启发式 + 约束剪枝;必须输出可解释 trace,避免黑盒。
type Plan struct {
	ChosenCoupons []int64
	DiscountCent  int64
}

func BestCouponBruteForce(cents int64, coupons []CouponView) Plan {
	best := Plan{DiscountCent: -1}
	n := len(coupons)
	for mask := 0; mask < (1 << n); mask++ {
		var sum int64
		var ids []int64
		for i := 0; i < n; i++ {
			if mask&(1<<i) == 0 {
				continue
			}
			c := coupons[i]
			if cents < c.MinSpendCent {
				sum = -1
				break
			}
			sum += c.DiscountCent
			ids = append(ids, c.CouponUserID)
		}
		if sum < 0 {
			continue
		}
		if sum > best.DiscountCent {
			best = Plan{ChosenCoupons: append([]int64(nil), ids...), DiscountCent: sum}
		}
	}
	return best
}

复杂度与工程边界:有界枚举在「券实例候选数」与「活动组合数」上是指数级,评审时要写清楚上限。实践中常通过产品约束「一单最多使用 N 张券」「同一互斥组仅允许一张」把搜索空间压到可接受范围。若业务坚持「多券最优」,建议把求解器做成独立服务并设置硬超时与降级策略(返回用户已选方案或启发式方案),避免阻塞创单主链路。

从枚举到「可证明正确」的贪心:当互斥组把候选压成「每张券至多一张、每组至多一张」时,常见目标函数(应付最小)往往可通过「按门槛分层 + 组内按优惠额排序」的贪心得到最优,前提是产品承认规则满足 拟阵(matroid) 或近似结构。工程上不必引入过重数学证明,但应在设计文档写清 贪心成立的前提(例如:折扣不随剩余金额非单调变化、不存在「用券 A 才解锁券 B」这类交叉依赖)。一旦出现交叉依赖,应显式退回枚举或 MILP 小模型求解,并在超时后降级为「用户已选方案」。

动态规划适用的一种典型子结构:若订单可拆为若干「独立店铺子篮」,且店铺间仅存在「平台跨店满减」一条耦合边,可先按店求局部最优,再在平台层做一次低维 DP(阶梯满减档位通常 ≤10)。这与「全购物车暴力 bitmask」相比,复杂度从指数降到近似多项式,是大厂 B2B2C 场景常用的工程折中。

与「用户主观选择」的冲突处理:最优解未必等于用户勾选。常见策略是:结算页提供「系统推荐组合」与「用户手动选择」两种模式;手动模式以校验为主(不重新最优),并在 UI 明确提示损失金额或不可用原因,减少客诉。

28.3.4 试算与预览

试算接口必须无副作用;预览与创单必须使用同一套输入契约(行价格快照 ID、券实例 ID、活动版本、用户地址/会员状态)。

建议字段

  • pricing_snapshot_id:来自计价中心的基准价快照。
  • marketing_rule_version:规则集版本。
  • client_scenePDP / CART / CHECKOUT(不同场景可用不同策略,但要显式)。
type PreviewMarketingRequest struct {
	UserID              int64
	PricingSnapshotID   string
	SelectedCouponIDs   []int64
	UsePoints           int64
	Lines               []LineInput
	Scene               string
	IdempotencyKey      string
}

type PreviewMarketingResponse struct {
	RuleVersion     string
	PayableCent     int64
	DiscountCent    int64
	LineAllocations []LineAllocation
	Warnings        []string
}

缓存与一致性策略:试算读多写少,可对「活动命中结果」做短 TTL 缓存,但务必以 pricing_snapshot_id 作为缓存键的一部分,避免基准价变化后命中脏数据。对于「用户已领券列表」类数据,强一致诉求更高,建议短 TTL + 用户维度本地缓存谨慎使用,或在关键操作(创单)前做一次穿透校验。

与创单的衔接:预览返回的 LineAllocations 应可被订单原样持久化为「营销快照」子文档;创单重放时不得再次调用可能变化的试算逻辑去「修正」历史订单,除非走明确的改价流程(通常需要客服授权与审计)。


28.4 高并发场景设计

28.4.1 秒杀与抢券

秒杀本质是:把绝大多数失败请求挡在极便宜的路径上,把极少数成功请求放进可串行化的扣减与下单管道。它与普通促销的差异在于:热点 SKU 的竞争半径远大于库存规模。

flowchart TB
  U[用户请求] --> CDN[CDN/静态页]
  U --> WAF[WAF/风控前置]
  WAF --> GW[API 网关\n鉴权 + 签名]
  GW --> RL[限流\n用户/设备/IP]
  RL --> CAP[验证码/挑战]
  CAP --> SS[秒杀服务\n无状态副本]
  SS --> HOT[(Redis 集群\n库存 + 令牌)]
  SS -->|成功令牌| MQ[Kafka 下单队列]
  MQ --> WK[下单 Worker\n幂等消费]
  WK --> ORD[(订单库)]
  WK --> INV[库存服务\n确认扣减]
  WK --> MKT[营销服务\n营销库存消耗]

  SS -. 异步校准 .-> DB[(活动/券 DB)]

关键设计点

  1. 库存拆分:商品库存与营销库存(见 23.5)分别扣减,避免「营销卖爆但仓库没货」或反向超卖。
  2. 令牌化:网关层发放有限令牌,后端只验证令牌,避免打穿 DB。
  3. 排队与等待:返回「排队中」优于同步拖垮线程池(取决于体验要求)。

抢券与秒杀的共性差异:抢券失败通常是「库存耗尽」;秒杀失败还可能是「商品库存不足但营销库存仍显示可买」这类双库存不一致。务必在架构层定义哪一个是用户可见的剩余量,以及异步校准任务的 SLA(例如 1 秒内把 DB 回灌到 Redis)。

28.4.2 限流与降级

限流维度:用户 ID、设备指纹、IP 段、活动 ID、接口名。降级策略(需产品确认):

  • 试算失败:按原价或可延迟重试。
  • 领券失败:明确「已抢光」与「系统繁忙」文案,避免重复猛刷。
  • 引擎超时:熔断返回保守结果 + 记录补偿任务。

限流实现分层(从外到内)

层级手段说明
边缘CDN、静态化、验证码降低无效流量与脚本命中率
网关全局限流、活动级配额保护下游不被突发打满
服务实例并发槽、队列长度避免 goroutine/线程池堆积导致雪崩
数据层Redis 单 Key 分片、Lua 原子脚本热点写入串行化且保持正确性

降级与用户体验的契约:降级不是「悄悄少优惠」,而是「明确告知当前无法应用优惠」。若业务允许静默降级,必须在法务与客服层面评估投诉风险;技术上建议至少记录 degraded=true 与原因码,便于事后补偿。

import "github.com/sony/gobreaker"

func NewMarketingBreaker() *gobreaker.CircuitBreaker {
	return gobreaker.NewCircuitBreaker(gobreaker.Settings{
		Name:        "marketing_preview",
		MaxRequests: 5,
		Interval:    time.Second * 10,
		Timeout:     time.Second * 30,
		ReadyToTrip: func(c gobreaker.Counts) bool {
			if c.Requests < 20 {
				return false
			}
			failRatio := float64(c.TotalFailures) / float64(c.Requests)
			return failRatio >= 0.4
		},
	})
}

28.4.3 防刷与风控

防刷是「业务风控 + 工程限流」的组合:设备指纹、代理 IP 聚类、异常领取节奏、黑名单、券码猜测防护。工程上务必:

  • 热点 Key 分片;避免单 Key 成为 Redis 热点。
  • 异步写审计,主链路只做最小校验。
  • 与风控系统通过评分结果而不是全量明细耦合,降低 RT。

黑产对抗的分层策略:第一层是「明显的工程滥用」(高频请求、批量注册、同设备多号),用限流与验证码解决;第二层是「业务规则套利」(拆单、凑单、退款薅券),需要规则与订单域联合治理;第三层是「支付侧套利」(拒付、chargeback),已超出营销系统边界,但必须把营销核销数据完整输出给风控与财务。

策略落地建议:不要把所有风控判断都改成同步 RPC。典型做法是:领券接口同步只做硬规则(黑名单、频控),复杂模型异步回扫;一旦发现异常,可下发「冻结券使用资格」事件,让用户在结算页看到需要人脸核验或客服介入。这样可以在不大幅增加主链路 RT 的前提下提升对抗能力。


28.5 营销库存系统

营销库存是活动参与配额,与商品可售库存解耦。秒杀中「500 件活动库存 + 10000 件商品库存」意味着两路都要成功才能成交。

营销库存 vs 商品库存(概念对齐表,避免团队各说各话):

维度商品库存营销库存
本质可售实物或履约能力活动参与名额 / 补贴预算的数字化表达
典型驱动采购、仓储、供应商可用量营销预算、活动目标、风控阈值
管理维度SKU、仓、批次活动、SKU、用户、时段
扣减含义少一件货少一次优惠资格或一分预算
失败体验缺货活动结束 / 已抢光 / 超出限购
一致性策略强一致预占 + 补偿常采用 Redis 原子脚本 + 异步校准

工程结论:下单链路里若同时存在两类库存,编排顺序必须写死(先营销后商品或相反)并配套一致的回滚顺序;任何「只扣一类」的实现都会在极端并发下出现难复现的幽灵订单。

28.5.1 券库存管理

券批次 total / used / reserved 三界清晰:reserved 对应创单未支付阶段的冻结量;支付成功由 reserved → used;关单释放 reserved。

批次库存与 Redis 热计数的一致性策略:公开领券场景下,常见做法是「Redis 原子扣减可领余量 + 异步刷新 DB 已领量」,主链路避免对券批次行高频 UPDATE。需要接受的前提是:极端情况下 Redis 与 DB 存在短暂偏差,因此必须配套 日终对账(按 coupon_id 聚合 coupon_user 与 Redis 计数)与 紧急熔断(运营一键停领后,网关与脚本两侧同时生效)。若业务要求「绝不能超发一张」,则要么将扣减下沉到单批次行的强一致事务(牺牲峰值),要么引入分桶库存(把 100 万张券拆成 N 个 sub-batch,各自 Redis 计数,DB 汇总)。

用户券实例与批次维度的联动:用户侧 CouponUser 状态迁移(未使用 → 冻结 → 已使用)应与批次维度的 reserved/used 单调一致;实现上可在 Confirm 阶段用 单笔订单幂等键 保证「批次已用 +1」只执行一次。冻结阶段是否同步增加批次 reserved,取决于财务口径——若冻结即占用预算,则批次层也应体现 reserved,便于运营实时看到「被锁住的成本」。

28.5.2 预算控制

活动预算与补贴池建议 Redis 原子扣减 + 日终对账;预算耗尽应快速失败并联动运营告警(短信/IM)。

预算模型拆分:至少区分「活动总预算」「单 SKU 子预算」「单用户补贴上限」三层。总预算用于财务控制;子预算用于防止单一 SKU 把活动打穿;用户上限用于防止单用户套利。上线前要与财务确认:预占是否计入消耗(通常创单即占用预算,关单释放),否则会出现「未支付订单占用预算导致活动提前结束」的体验问题。

Redis 与 DB 的职责:Redis 承担热点路径的原子判断与扣减;DB 承担审计与汇总;二者不一致时以 DB 为准修复 Redis 是常见策略,但要评估修复延迟期间的用户影响(短暂超发或短暂不可领)。若业务零容忍超发,需要把关键扣减下沉到 DB 或使用更强一致方案,代价是峰值容量下降。

const decrBudgetLua = `
local v = redis.call("GET", KEYS[1])
if not v then return -1 end
local n = tonumber(v)
local d = tonumber(ARGV[1])
if n < d then return 0 end
redis.call("DECRBY", KEYS[1], d)
return 1
`

// budgetRedis 抽象 go-redis 的 Eval,便于单测注入 mock
type budgetRedis interface {
	Eval(ctx context.Context, script string, keys []string, args ...interface{}) interface {
		Int() (int64, error)
	}
}

func DecrBudgetAtomically(ctx context.Context, r budgetRedis, key string, delta int64) (bool, error) {
	res, err := r.Eval(ctx, decrBudgetLua, []string{key}, delta).Int()
	if err != nil {
		return false, err
	}
	if res == -1 {
		return false, ErrBudgetNotInitialized
	}
	return res == 1, nil
}

28.5.3 实时监控

核心指标:

  • 领取成功率 / 拒绝原因分布(库存不足 vs 风控 vs 限流)。
  • 冻结/核销/回滚计数与订单状态对齐曲线。
  • 预算消耗速率(每分钟消耗,预测耗尽时间)。
  • 引擎 RT 分位与规则版本维度下钻。

从指标到告警的落地方法:不要只对「错误率」告警,要对「结构变化」告警。例如:领取失败率不变,但「风控拒绝占比」突然上升,往往意味着活动被黑产盯上;又如:冻结成功但确认核销失败升高,通常是支付回调或订单状态机异常的前兆。营销监控看板建议固定三类视图:活动运营视图(转化、消耗、ROI)、稳定性视图(RT、限流、熔断)、资金风险视图(预算、异常大额订单、补贴分账失败队列)。


28.6 系统边界与职责

28.6.1 营销系统的职责边界

营销系统应负责:工具发放与状态机、活动配置与圈品、营销库存、补贴分摊事实生成、试算解释与审计日志、与订单冻结/回滚对应的资源操作。

营销系统不应负责:商品主数据、基础标价、支付渠道、物流、发票税务口径的唯一裁定。

边界不清的典型症状(出现任一条都值得开专项治理):

  • 订单表出现大量「手写促销字段」,营销服务却不知道这些字段如何产生。
  • 同一个满减规则在详情页、购物车、结算页算出三种金额。
  • 支付回调后才发现优惠无法分账,只能人工补单。
  • 风控拦截发券,但试算仍展示可用,用户完成下单后失败。

28.6.2 营销 vs 计价:谁算什么

这是最容易跨团队扯皮的边界。推荐清晰分工

计算内容建议负责方说明
商品基础价、渠道价、会员价计价中心(或商品+计价组合域)作为「价格事实」与快照源头
券/活动/积分是否可用与优惠额营销计算引擎输出结构化应用明细
购物车/结算页统一应付计价中心编排调用营销用户看到单一「应付」
创单价格快照订单域落库 + 引用计价/营销版本售后按快照解释

反模式:订单服务里手写一段「满 100 减 20」与营销服务另一套重复逻辑——必然漂移。

推荐协作模式(一句话):计价中心负责「把钱算清楚并快照」,营销系统负责「把规则讲清楚并证明合规」。当两者接口契约稳定后,前端与订单域都应对营销细节保持「无知」,只消费结构化结果。

28.6.3 平台营销 vs 商家营销

平台券与商家券在成本承担、审核、叠加策略、结算上不同;系统上建议「券实例维度绑定承担方」,订单行维度记录分摊,支付后生成清算明细。

28.6.4 营销规则 vs 营销执行

规则:可配置、可版本、可读多。执行:冻结、扣减、回滚、消息投递,必须可幂等与可补偿。不要在规则脚本里直接写数据库副作用;执行器与规则解释器分离(28.3.1 的分层)。


28.7 与其他系统的集成

集成章节的目标是:把「同步调用边界」与「异步补偿边界」画清楚。营销系统处于交易链路中段,最容易出现长事务重试风暴,因此接口设计要比普通 CRUD 更严格:超时、幂等、可观测三者缺一不可。

跨系统调用的最小契约(建议作为内部 OpenAPI 规范附件):下列字段在多团队扯皮时最有用——X-Idempotency-Key(写路径必填)、X-Rule-Version / pricing_snapshot_id(试算与创单对齐)、X-Biz-SceneCHECKOUT / ORDER_CREATE)、X-Trace-Id(全链路透传)。补偿任务消费侧应至少支持 (biz_type, biz_id, action) 唯一约束,避免 Kafka 重投导致二次核销。

调用方 → 被调方典型接口一致性语义失败退避策略
计价 → 营销PreviewPromotions只读,可缓存超时 → 保守不可用券
订单 → 营销TryFreeze / Confirm / Cancel可补偿幂等重试 + 逆序 Cancel
订单 → 营销ConsumeCampaignQuota与支付回调对齐补偿表重放
支付 → 营销OnPaymentSucceeded(事件)至少一次幂等 + 对账
营销 → 商品BatchGetProductTags只读短超时 + 部分失败降级

28.7.1 与商品中心集成(圈品规则)

商品中心提供类目、标签、上下架状态;营销读取时应缓存 + 兜底超时降级(降级策略需业务拍板:宁可不可用券,不可错误可用券)。

失败模式:商品中心超时 → 试算无法判断圈品 → 建议默认「该活动对此 SKU 不适用」而不是「适用」,避免错误让利。对于已加购用户,可提示稍后重试或刷新。

28.7.2 与用户系统集成(画像与风控)

画像用于定向投放;注意隐私合规与最小必要原则。风控评分作为硬门槛时,应有明确失败原因码供前端展示(避免「神秘失败」)。

失败模式:画像服务延迟 → 定向券领取接口可异步化处理(先返回受理中),但创单路径若依赖画像,必须设置硬超时并走保守策略(按非定向规则校验)。

28.7.3 与订单系统集成(锁定 / 扣减 / 回退)

订单创建立即涉及「资源锁定」;营销侧需提供 Try/Confirm/Cancel 语义或等价 Saga 接口:freeze_couponconfirm_couponrollback_coupon,积分同理。

失败模式:订单 Try 成功但网络超时导致订单重试 → 营销接口必须幂等,重复 Try 不得重复扣减。Confirm 晚到必须先识别「已确认 / 已回滚」状态,避免二次核销。

28.7.4 与计价系统集成(试算接口)

计价中心调用营销预览接口时,传入价格快照而不是实时价字符串,避免时间差;返回应用明细后由计价做最终取整与应付。

失败模式:营销试算成功但创单延迟十分钟 → 必须以快照版本为准;若规则版本在此期间变更,创单应拒绝或提示用户重新结算,而不能静默改价。

28.7.5 与支付系统集成(补贴分账)

支付成功事件触发:营销确认核销、生成补贴分账数据、推送给清算系统。必须处理重复回调幂等

失败模式:支付回调重复 → Confirm 幂等;支付成功但营销 Confirm 失败 → 必须有补偿任务把订单推到一致状态,并阻断发货或数字履约直到营销侧确认完成(视业务风险阈值而定)。

28.7.6 集成时序图与补偿机制

sequenceDiagram
  participant U as 用户
  participant O as 订单服务
  participant P as 计价中心
  participant M as 营销服务
  participant I as 库存服务
  participant Pay as 支付

  U->>O: 创单请求
  O->>P: 试算/确认价\n(pricing_snapshot)
  P->>M: 预览可用优惠\n(rule_version)
  M-->>P: 应用明细
  P-->>O: 应付金额 + 快照

  O->>M: Try 冻结券/扣减积分
  M-->>O: OK
  O->>I: Try 预占库存
  I-->>O: OK
  O->>O: 持久化订单(PENDING_PAY)

  U->>Pay: 发起支付
  Pay-->>O: 支付成功回调(幂等)
  O->>M: Confirm 核销券/确认积分
  O->>I: Confirm 扣减库存
  O->>O: 更新订单(PAID)

  Note over O,M: 任一步失败进入 Saga 逆序补偿\n并写入补偿任务表重试

补偿表字段建议:biz_idactionpayloadnext_retry_atstatus;超过阈值人工介入。对账任务按日核对营销核销与订单快照。


28.8 工程实践

28.8.1 性能优化

  • 多级缓存:券批次元数据本地缓存 + Redis;注意失效传播。
  • 并行 I/O:预览时券列表、活动命中、用户等级查询可 errgroup 并行(注意超时串联)。
  • 热点分片:秒杀库存键按 activity_id + shard 拆分;避免单 Key QPS 顶满单线程。

压测与容量规划建议:至少拆三条压测曲线——「仅试算」「领券写路径」「秒杀下单全链路」。把下游依赖(商品、计价、库存)分别做故障注入,观察营销服务是否会出现重试放大。热点活动前执行 Redis 预热与连接池参数复核,避免冷启动把连接打满。

连接池与 goroutine 背压:试算接口最容易在大促被放大为「购物车行数 × 活动命中次数」次下游调用。除缓存外,应在网关或服务入口配置 最大并发试算协程数单请求活动匹配上限(例如每 SKU 最多评估 K 个活动),超出部分直接标记为「未评估,用户可手动领券」。否则会出现「CPU 不高但延迟爆炸」的典型症状——根因是无限并行导致的协调开销与下游排队。

import (
	"context"
	"golang.org/x/sync/errgroup"
	"time"
)

// PreviewParallel 演示:试算阶段并行拉取多源事实,统一超时兜底。
func PreviewParallel(ctx context.Context, userID int64) (coupons int, points int64, err error) {
	g, ctx := errgroup.WithContext(ctx)
	ctx, cancel := context.WithTimeout(ctx, 120*time.Millisecond)
	defer cancel()

	g.Go(func() error {
		// 伪代码:查询用户可用券数量
		coupons = 3
		return nil
	})
	g.Go(func() error {
		points = 1200
		return nil
	})

	if err := g.Wait(); err != nil {
		return 0, 0, err
	}
	return coupons, points, nil
}

28.8.2 数据一致性

主路径用 Saga;异步用 Outbox 发 Kafka;消费者幂等键用 event_id 或业务联合键;日终对账修数据。

对账维度清单(营销侧最小集)

对账项对比双方发现差异后的处理
券核销营销核销流水 vs 订单快照以订单快照为准回补或冲正
积分变动积分流水 vs 订单支付事件重放补偿任务
活动消耗Redis 计数 vs DB 汇总以 DB 为准回灌或人工修正
补贴分账订单行分摊 vs 支付清算单冻结差异单,财务介入

28.8.3 成本控制

预算桶、单用户上限、异常消耗报警、活动 ROI 看板;技术上防止「无限重试放大写压力」。

成本与体验平衡:预算耗尽应「快速失败」而不是「排队重试吞吞吐」。对于平台补贴型活动,建议设置分钟级消耗速率告警:一旦斜率异常(脚本薅羊毛),可自动触发熔断与黑名单联动。


28.9 本章小结

本章从工具体系(券、积分、活动、补贴)出发,拆解了营销系统的职责边界与架构分层;深入讲解了营销计算引擎的规则分层、叠加互斥与最优求解;针对秒杀抢券给出了高并发架构与限流降级策略;阐述了营销库存与预算独立于商品库存的原因与原子扣减模式;重点厘清了营销 vs 计价的算价边界,并通过集成时序图说明订单、计价、营销、库存、支付在全链路的 Try/Confirm 与补偿关系。

落地检查清单

  1. 试算与创单是否引用同一 pricing_snapshot_idmarketing_rule_version
  2. 券/积分状态机是否覆盖冻结、过期、回滚全路径?
  3. 秒杀链路是否分离商品库存与营销库存,并有异步校准?
  4. 补贴分账是否能在财务对账中还原到订单行?

与全书其他章节的阅读顺序建议:若你正在实现交易链路,建议将本章与第 29 章(计价系统)、第 31 章(购物车与结算)、第 32 章(订单系统)交叉阅读:把「试算 → 锁定 → 支付确认 → 清算」同一条时间轴画在白板上,再把每个系统的接口填进去,你会很快发现团队里哪些职责被重复实现、哪些补偿路径尚未覆盖。


延伸阅读:建议结合第 4 章(一致性)、第 3 章(资损防控)、第 29 章(计价系统)、第 32 章(订单系统)和第 33 章(支付系统)一起阅读,重点关注优惠锁定、核销、释放、补贴分账与对账闭环。

第 29 章 计价系统设计与实现

本章聚焦交易链路中的统一计价能力:四层价格模型、场景化计算、DDD 战术建模与系统边界。

阅读提示:若你更熟悉「订单里直接存一个 total_amount」的朴素模型,可以先带着两个问题读完全章——第一,券与积分为何常常不进创单快照;第二,为什么支付阶段坚持校验而不是重算。搞清这两个问题,就能理解计价中心存在的必然性,而不是把它简单看成「又一个中台服务」。文中 Go 示例为教学裁剪版,省略了错误包装、观测字段与部分依赖注入,落地时请按项目规范补全。


29.1 背景与挑战

29.1.1 价格计算的复杂性

在电商系统中,用户看到的「价格」并非单一标量,而是多因子分层叠加的结果:基础售价、营销活动、订单级抵扣、运费与增值服务、支付渠道手续费等,共同决定订单应付最终支付。若各系统各自实现一套加减逻辑,极易出现「商详 99 元、下单 105 元」的体验问题,甚至引发重复优惠、二次扣券等资损。

典型分解可概括为:

  • 基础价格:市场价、日常折扣价、渠道价等;
  • 营销价格:秒杀、新人价、满减、Bundle 等;
  • 抵扣:优惠券、积分、支付立减等;
  • 费用与附加:运费、碎屏险等平台服务费、跨境或信用卡手续费等。

同一 SKU 在 PDP(商品详情页)购物车创单收银台支付 各阶段,对计算深度、一致性与性能的要求并不相同,这要求计价中心以场景驱动的方式暴露能力,而不是「一刀切」的全量重算。

在传统拆分里,商品服务算「标价」、营销服务算「活动价」、订单服务在创单时再算一遍总价、支付服务为渠道优惠再算一遍——表面看各团队各管一段,实际上同一业务概念被多处隐式定义,边界一模糊就会出现「券用了两次」「满减门槛按行算还是按单算各执一词」等问题。更隐蔽的是:浮点金额四舍五入顺序外币汇率取整点不一致,会在大规模订单下累积成对账差异。计价中心的意义,就是把「一次购物旅程中所有与钱有关的加减」收敛到同一套编排与同一套舍入规则,让其他系统变成数据供给方或执行方,而不是第二个计算器。

29.1.2 核心挑战

  1. 一致性:展示价、订单快照、支付金额必须可追溯、可校验;规则版本与快照版本应对齐。
  2. 准确性:金额以为单位的整数运算;订单级优惠需按比例分摊到行,否则退款无法闭合。
  3. 性能:PDP / 列表页高 QPS,需多级缓存与轻量路径;创单 / 收银台可接受更高延迟但不容错算
  4. 异构品类:Topup、酒店、机票等定价因子差异大,需要策略插件而非巨石 if-else
  5. 供应商品类实时价:报价可能在浏览至支付间变化,需要 BookingToken、支付前反查等机制。

此外有两类「软挑战」往往在事故后才被写入复盘:组织边界产品口径。前者表现为多个团队各维护一段计算逻辑,接口文档写「参考订单域」,实际上订单域又在调另一套历史脚本;后者表现为 PRD 写「到手价」,但未定义是否含运费、是否含可叠加券的上限——技术再完美的引擎也无法收敛未定义的业务。计价项目启动时,建议把统一语言表(见 25.5 与系列第六篇)作为需求评审门禁,与 SkipLayers 表双签,再进入排期。

从工程视角,上述挑战可以压成三条硬约束:算得对(正确性)、大家认(一致性)、扛得住(性能与可用性)。其中正确性又依赖两条底座:一是整数分与确定的舍入;二是快照——把某一刻的规则解释结果固化为事实,后续链路只解释事实,不再在暗处重算。一致性则依赖版本化:规则集、活动表、券批次、汇率表都带有业务版本或生效区间,计价请求必须携带「我按哪一版解释」的线索,否则 PDP 与创单永远可能对不齐。

29.1.3 设计目标

目标说明
准确性计价结果可审计,关键路径可空跑比对
一致性统一入口计算,订单 / 支付以快照为准
高性能前台场景高缓存命中;交易路径少 IO、可并发
可扩展新营销类型、新费用项以策略 / 配置扩展
可观测分层耗时、缓存命中、差异告警

上述目标之间存在天然张力:例如「极致缓存」与「强一致快照」方向相反,需要通过场景分流化解,而不是用一套参数打天下。团队 OKR 里若只写 P99 延迟而不写差异率 / 资损事件数,很容易把系统优化成「快但不准」。建议在质量看板上同时跟踪:快照校验失败次数空跑 diff 超标次数客服价格类工单占比,与延迟指标并列。

29.1.4 计价系统在交易链路中的位置

计价中心位于商品、营销、库存、订单、支付之间:向上读取商品基础价与营销规则,向下为购物车、结算、创单、支付提供试算快照服务。它是交易链路的横切基础模块,不宜承担订单状态机或支付渠道路由等非本域职责。

flowchart LR
  subgraph upstream[上游依赖]
    Item[商品中心]
    Promo[营销系统]
    User[用户 / 画像]
    Supplier[供应商报价]
  end
  Pricing[计价中心]
  subgraph downstream[下游消费方]
    PDP[商详 / 导购]
    Cart[购物车]
    Checkout[结算收银台]
    Order[订单系统]
    Pay[支付系统]
  end
  Item --> Pricing
  Promo --> Pricing
  User --> Pricing
  Supplier --> Pricing
  Pricing --> PDP
  Pricing --> Cart
  Pricing --> Checkout
  Pricing --> Order
  Pricing --> Pay

把计价中心画在枢纽位置,并不是鼓励它成为「上帝服务」,而是强调其 I/O 边界:对外是少量稳定的试算与快照 API,对内通过防腐层消化外部世界的变化。实践中常见反模式有两种:其一是计价服务直接读营销库的宽表,把对方存储模型当自己领域模型;其二是把订单状态推进、支付路由塞进计价——二者都会让团队在排障时无法回答「这一分钱到底是谁改的」。本章后续用 DDD 的聚合与 ACL 约束,正是为了避免这两种腐化。


29.2 计价引擎架构

29.2.1 分层架构

计价中心通常分为:场景入口层(API / Handler)编排与快照层核心计算引擎品类策略防腐适配层缓存与持久化。入口按 PricingScene 路由到不同 Handler,核心引擎以责任链顺序执行各 Layer,并结合 Calculator 做品类扩展。

flowchart TB
  subgraph api[统一入口层]
    GW[Pricing API / Gateway]
    Router[SceneRouter]
    Snap[SnapshotManager]
    GW --> Router
    Router --> Snap
  end
  subgraph engine[核心计算层]
    L1[BasePriceLayer]
    L2[PromotionLayer]
    L3[DeductionLayer]
    L4[ChargeLayer]
    L5[FinalAssemblyLayer]
    L1 --> L2 --> L3 --> L4 --> L5
  end
  subgraph strategy[品类策略]
    C1[DealCalculator]
    C2[TopupCalculator]
    C3[HotelCalculator]
  end
  subgraph infra[基础设施]
    ACL[防腐层适配器]
    Cache[(L1/L2 缓存)]
    DB[(快照 / 审计)]
  end
  Router --> engine
  engine --> strategy
  engine --> ACL
  engine --> Cache
  Snap --> DB

与五层实现的关系:实现上常把「最终汇总、尾差修正、安全校验」独立为 FinalAssemblyLayer,于是代码里会看到五段责任链。本书在业务模型上仍称四层,是因为前四层对应「可被业务方单独讨论的价格语义」,而 Final 层是技术组装层(把四层结果折叠成响应 DTO 与快照 schema),不参与对外营销话术。团队在评审架构图时,应对业务讲四层,对研发可展开五层,避免无谓争论。

SceneRouter 的职责不仅是转发:它要注入 PricingScene、解析租户 / 地区 / 渠道、挂载灰度与空跑开关,并在入口完成参数校验(例如购物车行是否含失效 SKU、供应商品类是否带预订 token)。SnapshotManager 则与订单域协作:创单成功后写入订单快照,收银台基于订单行再生成支付快照;二者生命周期不同,不可混用一张表、一个过期策略

29.2.2 四层价格模型

为与业务语言对齐,本书将可叠加的价格语义归纳为四层(不含最终的汇总展示层):基础层、营销层、抵扣层、费用层。引擎内部可再拆「最终汇总」为独立步骤,用于生成明细与快照版本。

层级名称典型内容出资方 / 备注
Layer 1基础价格市场价、折扣价、渠道价、供应商报价商家 / 平台标价
Layer 2营销价格秒杀、新人价、满减、活动价商家或平台营销预算
Layer 3抵扣优惠券、积分、部分支付立减用户权益
Layer 4费用与附加运费、增值服务费、平台服务费、支付手续费用户或平台规则
flowchart TB
  subgraph L1[Layer 1 基础价格]
    M[市场价]
    D[折扣价]
    M --> D
  end
  subgraph L2[Layer 2 营销价格]
    P[活动 / 秒杀 / 新人 / 满减]
  end
  subgraph L3[Layer 3 抵扣]
    V[券 / 积分 / 立减]
  end
  subgraph L4[Layer 4 费用与附加]
    F[运费 / 增值服务费 / 手续费]
  end
  L1 --> L2
  L2 --> L3
  L3 --> L4
  L4 --> Out[应付 / 实付口径由场景定义]

端到端走数示例(整数分):设某 SKU 日常折扣价 98000 分,限时抢购再减 8000 分(Layer 2),创单时加运费 1000 分、碎屏险 5000 分(Layer 4 中与履约相关部分),则订单应付为 98000 − 8000 + 1000 + 5000 = 96000 分。用户进入收银台选择满 500 减 100 的券(此处为 10000 分)与积分抵 10000 分(Layer 3),再选择会产生 2% 信用卡手续费的渠道,手续费基数若约定为「券与积分后的金额」,则应付变为 96000 − 10000 − 10000 = 76000 分,手续费 1520 分,实付 77520 分——具体基数以公司业务规则为准,关键是 Layer 顺序与基数必须在规则文档与代码注释中一致,并在快照里记录「手续费按哪一版基数计算」。

口径说明

  • 创单(CreateOrder):常见做法是 Layer 1 + Layer 2 + Layer 4 中与订单履约相关的费用(如运费、增值服务费),不包含 Layer 3 的券与积分,也不包含支付渠道手续费(手续费依赖用户所选渠道,放在收银台)。
  • 收银台(Checkout):完整执行 Layer 1–4,生成支付快照
  • 支付(Payment):以快照为准做校验,避免再次「全量重算」引入漂移。

Layer 的顺序不可随意调换:必须先有「可减的基准」,再谈营销减免,再谈用户权益抵扣,最后才叠加履约与支付相关费用。若把券提前到营销之前,会出现「用券改变满减门槛」这类循环依赖,规则引擎与测试用例都会爆炸。Layer 4 内部也建议再分子阶段:先算与履约相关的运费与增值服务费,再在收银台根据用户所选支付渠道计算手续费,这样创单快照不会错误地绑定某一渠道费率。

29.2.3 计算流程

计算流程可抽象为:构建 PricingContext → 按场景得到 skipLayers → 责任链逐层改写 PricingState → 品类 Calculator 参与行级计算 → Final 汇总明细 →(交易路径)持久化快照

flowchart LR
  A[请求 + Scene] --> B[加载商品 / 规则版本]
  B --> C[初始化 PricingState]
  C --> D{遍历 Layer}
  D -->|未跳过| E[更新行金额 / 明细]
  D -->|跳过| F[保持上层结果]
  E --> G{还有 Layer?}
  F --> G
  G -->|是| D
  G -->|否| H[分摊 / 取整 / 保护校验]
  H --> I[响应 + 可选快照]

PricingState 建议携带的内容包括:行级中间价、已选营销命中列表、已锁定券批次、供应商报价引用 ID、舍入审计数组、以及每层产生的结构化 PriceComponent(类型、金额、出资方、关联业务单号)。Final 之前的各层应尽量避免「只写一个整数总价」——客服与财务追问时,只有明细才能解释为什么少了一分钱。供应商品类还要在 state 中携带 报价过期时刻预订 token,以便支付校验阶段做二次确认或优雅失败。


29.3 核心实现

29.3.1 价格计算器设计

引擎对外暴露稳定接口,对内使用 Layer 责任链 + Calculator 策略

package pricing

import "context"

// Engine 计价引擎对外接口。
type Engine interface {
	CalculatePrice(ctx context.Context, req *PricingRequest) (*PricingResponse, error)
	CalculateWithDryRun(ctx context.Context, req *PricingRequest) (*PricingResponse, *DryRunResult, error)
	BatchCalculate(ctx context.Context, reqs []*PricingRequest) ([]*PricingResponse, error)
}

// Layer 单层计算:可读写 PricingState。
type Layer interface {
	Name() string
	Order() int
	Process(ctx context.Context, req *PricingRequest, st *PricingState) error
}

// Calculator 品类策略:在单层或多层之间参与行级公式。
type Calculator interface {
	Support(categoryID int64) bool
	Priority() int
	Calculate(ctx context.Context, req *PricingRequest, st *PricingState) error
}

责任链与策略的协作方式可以概括为:Layer 负责「这一类变价因子在何时进入总式」,Calculator 负责「这一品类如何解释基础输入」。例如酒店品类在 Layer 1 需要把「间夜 × 日历价 × 税费」折叠成一行基准 Money;Topup 在 Layer 1 只需要「面额 × 折扣率」。若把品类差异全写进 Layer 1 的 switch,Layer 将迅速膨胀;若把 Layer 2 的营销叠加规则写进 Calculator,又会导致营销变更需要改多个品类文件。推荐做法是:Layer 保持与品类无关的通用语义,Calculator 只处理「如何得到 Layer 1 接受的基准结构」以及少数「品类特有附加费」钩子。

错误语义:引擎对外错误应分层——参数非法(4xx)、依赖不可用(5xx 可重试)、规则冲突(4xx 业务码)、资损风险(4xx 拒绝 + 告警)。不要把「营销返回空列表」与「内部 panic」混用同一码,否则 SLO 统计会被污染。对创单路径,任何未分类错误都应默认 fail-close,避免生成半张快照。

initLayers 中按 Order() 排序注册:BasePricePromotionDeductionChargeFinal,与 25.2.2 的四层语义一致,Final 负责尾差、分摊与明细输出。

场景到层的映射在代码里常表为「跳过列表」,与业务文档交叉对照便于测试覆盖:

func SkipLayersForScene(scene PricingScene) []string {
	switch scene {
	case ScenePDP, SceneAddToCart:
		return []string{"deduction", "charge"}
	case SceneCart:
		return []string{"charge"} // 购物车可预估券;运费常缺省或按默认地址估算
	case SceneCreateOrder:
		// 创单:基础 + 营销 + 与订单绑定的附加费;不含券积分与支付手续费
		return []string{"deduction", "payment_handling_fee"}
	case SceneCheckout:
		return nil
	case ScenePayment:
		return []string{"base_price", "promotion", "deduction", "charge", "final"}
	default:
		return nil
	}
}

注:payment_handling_fee 是否从 Layer 4 拆出,取决于实现里是否将「订单附加费」与「支付渠道费」分为两个子处理器;关键是创单口径不包含随渠道变化的费率

29.3.2 快照生成

快照是防资损的关键:创单生成订单价格快照(含行明细、规则版本、供应商 BookingToken 等),收银台生成支付快照(含券积分与手续费)。快照应包含:

  • snapshot_idversioncalculated_atexpire_at
  • 各层贡献的结构化明细(便于对账与客服解释);
  • 可选:rule_bundle_hash 用于比对「当时用的是什么规则集」。

快照与订单数据的关系:订单表应保存 snapshot_id 或内嵌只读 JSON,但不建议在订单域再实现一套价格公式去「验算」——验算应回调计价或读快照服务,否则双实现又会分叉。快照表建议支持只追加:修正价格走新快照版本(v2),旧版本保留审计;支付失败回滚不应删除历史快照记录。TTL:订单快照常对齐库存锁定时间(如 30 分钟);支付快照对齐收银台支付超时(如 15 分钟),二者解耦。

29.3.3 试算接口

试算与正式计算共用同一套 Layer,通过 Scene 控制深度:PDP 试算只读展示;购物车允许预估券(标注 estimated=true);创单 / 收银台必须明确用户已选权益(券码、积分数量、渠道)。

试算响应里应显式区分三类字段:事实(已锁定、写入快照)、建议(系统推荐最优券但用户未确认)、估算(缺地址导致运费按默认规则猜)。前端展示时必须用不同标签,避免用户把「估算运费」当成承诺。对于 DryRun(空跑比对):上线新引擎时,生产流量旁路调用新旧两套,只在差异超阈值时采样上报,可在 PricingResponse 中附加 diff_summary 而不影响主路径延迟。

29.3.4 幂等性保证

计价接口常被上游重试。建议:

  • 请求携带 Idempotency-Key 或业务侧 request_id
  • 服务端以「用户 + 场景 + 关键购物车指纹」为维度短 TTL 缓存响应副本
  • 生成快照类写操作与订单号 / 结算单号绑定,防止重复生成两套有效快照。
type SnapshotRepository interface {
	Save(ctx context.Context, s *PriceSnapshot) error
	GetByOrderID(ctx context.Context, orderID string) (*PriceSnapshot, error)
}

func (s *PricingAppService) CreateOrderSnapshot(ctx context.Context, cmd CreateOrderSnapshotCmd) (*PriceSnapshot, error) {
	if snap, err := s.repo.GetByOrderID(ctx, cmd.OrderID); err == nil && snap != nil {
		return snap, nil
	}
	// ... 首次计算后落库
	return s.repo.SaveAndReturn(ctx, cmd)
}

安全校验器(Safety Checker) 常与幂等一起出现在创单 / 收银台路径:在返回快照前检查「总价不为负」「折扣不超过品类阈值」「优惠不超过商品应付之和」等。校验失败应拒绝生成快照而不是静默裁剪,否则会把业务错误伪装成成功交易。对于前端上送金额与后端计算金额的比对,建议以后端为准,前端金额仅作 UX 提示;若必须比对,应使用宽松阈值防浮点,或统一为整数分。


29.4 多级缓存与降级

29.4.1 缓存策略

场景是否缓存TTL 思路
PDP / 列表批量L1 短 TTL + L2 较长;命中要求高于展示 SLA
购物车部分自营可中等 TTL;供应商品类报价短 TTL
创单 / 收银台 / 支付否(结果可落快照表)以强一致计算为主

缓存 key 设计要同时防击穿脏读:key 中应包含 item_idsku_idregionchanneluser_segment(若价随人群变化)、以及规则版本摘要。大促时热门商品可采用**单飞(singleflight)**合并回源。对购物车这类高 churn 场景,可缓存「行哈希 → 计价结果」短 TTL,而不是整购物车超长缓存,避免用户改数量后长期读到旧价。

29.4.2 降级方案

  • 依赖超时:返回上一版本缓存并打标 stale=true(仅允许非交易路径);
  • 营销服务不可用:PDP 可降级为仅 Layer 1;创单路径应失败快速而非静默吞错;
  • 供应商报价失败:使用 DB 缓存价并限制最大陈旧度,超阈值则拦截创单。

降级策略要与法务与用户协议对齐:若页面上承诺了「展示价即购买价」,则任何返回陈旧价的降级路径都必须附带明确提示或干脆失败;否则可能构成虚假宣传风险。技术团队常忽略这一点,把「能卖出去」置于「合规展示」之上。开关治理上,降级与熔断配置应纳入配置中心审计,谁在什么时间打开「允许陈旧价」,需要可追溯。

29.4.3 性能优化要点

  • 批量场景用 errgroup 并发拉取多 SKU 基础价与活动;
  • 热点 SKU 预热
  • 对 Layer 内 RPC 设置独立超时与熔断,避免一层拖垮整条链。
package pricing

import (
	"context"
	"sync"
	"time"
)

type CacheManager struct {
	mu   sync.Mutex
	l1   map[string]cacheEntry
	l1TTL time.Duration
}

type cacheEntry struct {
	val       *PricingResponse
	expiresAt time.Time
}

func NewCacheManager(l1TTL time.Duration) *CacheManager {
	return &CacheManager{l1: make(map[string]cacheEntry), l1TTL: l1TTL}
}

func (c *CacheManager) GetOrCompute(ctx context.Context, key string, fn func(context.Context) (*PricingResponse, error)) (*PricingResponse, error) {
	now := time.Now()
	c.mu.Lock()
	if e, ok := c.l1[key]; ok && now.Before(e.expiresAt) {
		c.mu.Unlock()
		return e.val, nil
	}
	c.mu.Unlock()

	val, err := fn(ctx)
	if err != nil {
		return nil, err
	}
	c.mu.Lock()
	c.l1[key] = cacheEntry{val: val, expiresAt: now.Add(c.l1TTL)}
	c.mu.Unlock()
	return val, nil
}

生产环境可在 L1 之上再接 Redis、并接入 singleflight;此处展示「先读内存、未命中再计算回写」的最小闭环。


29.5 DDD 建模实践(重点)

DDD 在计价系统中的价值,在于用统一语言消除 originalPrice / salePrice / actualPay 混用,并用聚合边界保证「基础价 + 选中营销 + 费用 − 抵扣」在同一事务语义内一致。

29.5.1 领域模型设计

限界上下文:计价上下文(Pricing Context)与营销、商品、用户、支付等上下文通过 ACL(防腐层) 交互。计价上下文中,核心概念包括:Price(一次可报价单元)、PriceLayer(单层结果)、Money(金额值对象)、PriceSnapshot(不可变结果事实)、PricingPolicy(来自外部的规则投影)。

classDiagram
  class PriceAggregate {
    +string LineID
    +ReconstructFromSnapshot()
    +ApplyLayers()
    +ToSnapshot()
  }
  class Money {
    +int64 cents
    +string currency
    +Add(Money) Money
    +Sub(Money) Money
  }
  class PriceLayer {
    +LayerKind kind
    +Money delta
    +map meta
  }
  class PriceSnapshot {
    +string SnapshotID
    +[]LineBreakdown lines
    +string RuleVersion
  }
  class PricingContextVO {
    +int64 UserID
    +string Region
    +PricingScene Scene
  }
  PriceAggregate --> Money
  PriceAggregate --> PriceLayer : layers
  PriceAggregate --> PricingContextVO
  PriceSnapshot --> Money

限界上下文关系(战略视图):计价上下文处于下游消费位,对商品、营销、用户、支付等上下文均通过 ACL 取数;这些上下文互不直接依赖计价模型,避免「改一个 proto 全仓库编译失败」的耦合。下图省略防腐层实现类,只保留协作方向,便于与架构评审中的上下文地图对照。

flowchart LR
  subgraph peers[相邻上下文]
    IC[商品 Item Catalog]
    MC[营销 Marketing]
    UC[用户 User]
    PCtx[支付 Payment]
  end
  subgraph pricing_ctx[计价 Pricing]
    AR[Price 聚合 / Layer 编排]
    SN[PriceSnapshot]
  end
  IC -->|基础价 DTO| pricing_ctx
  MC -->|活动命中 / 券面额| pricing_ctx
  UC -->|人群 / 新客标记| pricing_ctx
  PCtx -->|渠道费率投影| pricing_ctx
  pricing_ctx -->|试算结果 / 快照 ID| PCtx

防腐层(ACL) 在计价落地中几乎与引擎同等重要:营销侧可能叫 activity_price,商品侧叫 sale_price,支付侧叫 payable_amount——计价域只接受自己的 MoneyPriceLayer。下面是一个最小对照:应用服务只依赖计价域接口 PromotionPort,基础设施里实现适配器,把 RPC DTO 转成值对象。

// domain/ports.go — 由定价上下文定义,由基础设施实现。
type PromotionPort interface {
	ActivePromotions(ctx context.Context, q PromotionQuery) ([]PromotionOffer, error)
}

// domain/promotion_offer.go — 定价上下文内的只读投影。
type PromotionOffer struct {
	ActivityID int64
	Kind       string
	Price      Money
}

// infra/promotion_acl.go
type promotionACL struct{ /* rpc client */ }

func (a *promotionACL) ActivePromotions(ctx context.Context, q PromotionQuery) ([]PromotionOffer, error) {
	// resp := a.client.Query(...)
	// return toOffers(resp),字段映射、枚举归一、金额转分,全部在此完成
	return nil, nil
}

29.5.2 聚合根:Price(行级报价聚合)

订单行 / 购物车行为粒度定义聚合根 Price(本书与实现中可与 PricingAggregate 等价命名),保证:

  1. 同一行上互斥营销的选择规则在一个聚合内完成;
  2. 行小计层明细同步更新;
  3. 对外只暴露已完成校验的结果。
package domain

import "errors"

type LayerKind int

const (
	LayerBase LayerKind = iota
	LayerPromotion
	LayerDeduction
	LayerCharge
)

// Price 聚合根:表示「一行 SKU 在一次请求下」的可报价过程。
type Price struct {
	lineID   string
	skuID    int64
	quantity int64
	layers   []PriceLayer
	ctx      PricingContext
	version  int64
}

func NewPrice(lineID string, skuID, qty int64, ctx PricingContext) (*Price, error) {
	if qty <= 0 {
		return nil, errors.New("quantity must be positive")
	}
	return &Price{lineID: lineID, skuID: skuID, quantity: qty, ctx: ctx}, nil
}

func (p *Price) ReplaceLayer(kind LayerKind, delta Money, meta map[string]string) error {
	if delta.IsNegative() && kind == LayerBase {
		return errors.New("base layer cannot go negative")
	}
	// 同类层覆盖或追加策略由领域规则决定,此处示意「按 kind 幂等替换」
	p.layers = upsertLayer(p.layers, kind, delta, meta)
	return nil
}

func upsertLayer(existing []PriceLayer, kind LayerKind, delta Money, meta map[string]string) []PriceLayer {
	nl := make([]PriceLayer, 0, len(existing)+1)
	replaced := false
	for _, l := range existing {
		if l.Kind == kind {
			nl = append(nl, PriceLayer{Kind: kind, Delta: delta, Meta: cloneMeta(meta)})
			replaced = true
			continue
		}
		nl = append(nl, l)
	}
	if !replaced {
		nl = append(nl, PriceLayer{Kind: kind, Delta: delta, Meta: cloneMeta(meta)})
	}
	return nl
}

func cloneMeta(m map[string]string) map[string]string {
	if m == nil {
		return nil
	}
	out := make(map[string]string, len(m))
	for k, v := range m {
		out[k] = v
	}
	return out
}

func (p *Price) Subtotal() (Money, error) {
	var sum Money
	for _, l := range p.layers {
		var err error
		sum, err = sum.Add(l.Delta)
		if err != nil {
			return Money{}, err
		}
	}
	return sum, nil
}

聚合边界Price 内不直接修改「券库存」「活动预算」——这些属于营销聚合,由应用服务先预留 / 锁定后再传入 Price 已选结果。

若团队纠结「一行 SKU 是否太小」:可以从一致性边界反推——任何「必须在同一事务里决定且一起成功或失败」的价格要素,应处于同一聚合;若某些营销是平台级自动领取、失败可静默降级,则不必纳入 Price 聚合,而可作为 Layer 2 的只读输入。购物车多行场景下,行级 Price 聚合 + 订单级领域服务是常见组合:行内互斥活动放在行聚合,跨行满减分摊放在服务。

29.5.3 值对象:MoneyPriceLayer

Money:用 int64 分与 currency 表达,不可变,所有运算返回新值,避免浮点误差。跨境时可在值对象内同时保存「展示币种金额」与「清算币种金额」,但比较与快照持久化必须指定其中一种为权威口径,另一种仅作参考字段。舍入规则(银行家舍入 vs 向上取整)应配置化,并在快照中记录 rounding_mode,否则三年后审计很难解释「为什么当年这样舍」。

PriceLayer:描述单层对金额的增量贡献(可为负表示减免),并携带 meta(活动 ID、费用类型、出资方 source=platform|merchant|channel)供对账。一个实用技巧是为每个 PriceLayer 分配稳定 component_id(UUID 或雪花),在退款回收、部分开票时直接引用,而不是靠数组下标——订单行重排或合并时,下标并不可靠。

// 与上文 Price 同属 domain 包。

type Money struct {
	cents    int64
	currency string
}

func (m Money) Add(o Money) (Money, error) {
	if m.currency != o.currency {
		return Money{}, errors.New("currency mismatch")
	}
	return Money{cents: m.cents + o.cents, currency: m.currency}, nil
}

// Multiply 单价 × 数量;若数量非法应由调用方先校验。
func (m Money) Multiply(qty int64) (Money, error) {
	if qty <= 0 {
		return Money{}, errors.New("quantity must be positive")
	}
	return Money{cents: m.cents * qty, currency: m.currency}, nil
}

func (m Money) IsNegative() bool { return m.cents < 0 }

type PriceLayer struct {
	Kind  LayerKind
	Delta Money
	Meta  map[string]string
}

29.5.4 领域服务

当逻辑跨多行不适合放入单一 Price 时,使用领域服务,例如:

  • 订单级满减分摊:余额递减法处理尾差;
  • 互斥活动择优:跨多个候选活动比较用户实付;
  • Bundle 计价:买 N 享 M 折等。
package domain

import "errors"

// LineAmount 表示一行在分摊前的可参与金额(通常为 Layer1+2+4 之后的行小计,单位:分)。
type LineAmount struct {
	LineID string
	Cents  int64
}

// ApportionmentService:订单级优惠按行权重分摊(余额递减 + 尾差落末行)。
type ApportionmentService struct{}

func (ApportionmentService) Allocate(orderDiscountCents int64, lines []LineAmount) ([]int64, error) {
	if orderDiscountCents < 0 {
		return nil, errors.New("discount must be non-negative")
	}
	if len(lines) == 0 {
		return nil, errors.New("no lines")
	}
	var total int64
	for _, l := range lines {
		if l.Cents < 0 {
			return nil, errors.New("line amount cannot be negative")
		}
		total += l.Cents
	}
	if total == 0 {
		return nil, errors.New("total weight is zero")
	}
	out := make([]int64, len(lines))
	var allocated int64
	for i := 0; i < len(lines)-1; i++ {
		// 按比例向下取整到分
		part := orderDiscountCents * lines[i].Cents / total
		out[i] = part
		allocated += part
	}
	out[len(lines)-1] = orderDiscountCents - allocated
	return out, nil
}

领域服务无状态,入参出参均为领域对象或值对象。

29.5.5 仓储与工厂

  • 工厂:从商品 / 营销 DTO 通过 ACL 组装 Price 初始状态;
  • 仓储PriceSnapshotRepository 持久化快照;不写聚合根运行态,避免贫血往返;
  • 应用服务:开启事务、调用营销锁定、调用引擎、保存快照、发布「快照已生成」领域事件。

工厂的职责是把「外部世界的行项目」翻译成领域可计算的初始不变式:数量为正、币种一致、基础层已填入「未乘数量的单价」或「已乘数量的行基准」——二者只能选一种约定,并在团队 wiki 中写死。工厂内不做营销择优,只做数据完备性与 ACL 映射;择优属于领域服务或 Layer 2 策略,避免工厂膨胀成第二个引擎。

package domain

// ItemPort 由商品上下文经 ACL 实现。
type ItemPort interface {
	BaseUnitPrice(ctx context.Context, skuID int64) (Money, error)
}

// PriceFactory 从商品行构造聚合根(示意:仅 Layer1 基准)。
type PriceFactory struct {
	items ItemPort
}

type CartLineInput struct {
	LineID string
	SkuID  int64
	Qty    int64
}

func (f *PriceFactory) NewPriceFromLine(ctx context.Context, in CartLineInput, pc PricingContext) (*Price, error) {
	unit, err := f.items.BaseUnitPrice(ctx, in.SkuID)
	if err != nil {
		return nil, err
	}
	p, err := NewPrice(in.LineID, in.SkuID, in.Qty, pc)
	if err != nil {
		return nil, err
	}
	lineBase, err := unit.Multiply(in.Qty)
	if err != nil {
		return nil, err
	}
	if err := p.ReplaceLayer(LayerBase, lineBase, map[string]string{"source": "item_catalog"}); err != nil {
		return nil, err
	}
	return p, nil
}

上例中 Multiply 可作为 Money 上的方法,与「单价 × 数量」语义绑定;若品类要求按「件数阶梯」重算基准,则在工厂之后交给对应 Calculator,而不是在工厂里写 switch category

应用服务与领域层的调用顺序(创单示例):校验入参 → 通过工厂构建每行 Price 聚合(仅含基础层)→ 调用领域服务选出互斥活动 → 各 Layer 在应用层编排下逐步调用 ReplaceLayerApportionmentService 处理订单级减免 → Price 聚合生成行视图 → 组装 PriceSnapshot 持久化。注意:券锁定属于应用层编排步骤,领域层只接收「锁定成功后的面额」作为事实输入,这样聚合不变式才不会依赖远程 RPC 的副作用。

充血 / 贫血混合策略:行级 Price 与分摊服务采用充血模型承载规则;快照 PO、HTTP DTO 保持贫血,避免把序列化细节泄漏进领域。测试金字塔上,领域单测覆盖互斥、尾差、货币错误;契约测试覆盖 ACL 与外部服务的字段映射;端到端只保留少量黄金用例,防止全链路测试过慢导致无人运行。


29.6 不同场景的价格计算

29.6.1 PDP 场景(商品详情页 / 加购试算)

PDP 的首要 KPI 是转化,技术侧对应的是极低延迟与稳定展示。计算上通常停留在 Layer 1 与 Layer 2:用户需要知道「日常卖多少、活动卖多少、我是否命中新人/秒杀」。券与积分如果在 PDP 就做全量最优解,RPC 扇出会爆炸,因此常见做法是:主路径同步返回展示价,券预估走异步任务或边缘计算,并在 UI 上用弱提示展示「领券最高可再减 X 元」。

加购(AddToCart) 与 PDP 类似,往往不锁任何资源;若要做「凑满减」提示,可在服务端维护轻量规则缓存,仍以 Layer 1–2 为主。PDP 与创单的价格差异若不可避免,必须在交互上降级为「以结算页为准」,同时在日志里记录 rule_bundle_hash,便于客诉时复盘。

29.6.2 购物车场景

购物车是多品聚合用户频繁编辑的交集:行增删、数量变化、地址切换都会触发重算。技术上通常批量拉取基础价与活动,再对共享的订单级优惠做编排;Layer 3 在购物车阶段多为试算而非锁定,返回体应用 estimated 标记。运费若无默认地址,可返回区间或按城市模板估算,并在进入结算页时用真实地址覆盖。

供应商品类在购物车仍需注意外部报价抖动:可短时缓存供应商返回,但 TTL 要显著短于自营;用户停留过久时,结算页应主动提示「价格已更新」。

29.6.3 创单场景(订单金额与快照)

创单是价格从「展示」走向「事实」的分水岭:此时应完成与履约相关的费用(运费、服务费等),并生成订单快照。不包含券与积分并非技术偷懒,而是业务上常把「用户尚未进入收银台选择的支付权益」排除在订单应付之外,避免订单应付随用户换券剧烈波动;若业务要求订单应付即含券,应在需求层显式调整 Layer 映射,而不是在代码里硬塞。

创单路径还要与库存预占、营销库存锁定同事务或同 Saga 编排:计价不负责预占,但要在预占成功之后再冻结快照,否则会出现「快照有了库存没了」的僵尸数据。供应商品类在创单常同步拉取供应商报价并生成 BookingToken,写入快照供支付确认。

29.6.4 支付场景

支付侧理想状态是 O(1) 查表校验:读取 snapshot_id 对应金额、币种、过期时间,与支付请求比对;供应商品类增加「预订确认」RPC。任何在支付路径重新跑全量 Layer 的做法,都应视为技术债:渠道回调重复、用户重复点击支付,都会让重算路径产生非确定性。若必须重算(极少数风控场景),应产生新快照版本并阻断旧支付单。

29.6.5 场景间的价格一致性保证

  1. 规则版本对齐:请求携带 rule_version / activity_bundle_id
  2. 快照链:创单快照 → 收银台在快照之上仅计算「增量」(券、渠道费)或全量重算后对比差异;
  3. 强提醒:当收银台结果与创单快照差异超过业务阈值,阻断或用户确认。

下图从同一用户旅程抽象各场景「算到哪一层、是否落快照」:箭头表示时间顺序,方框内为与本章 SkipLayersForScene 相呼应的语义(具体跳过列表以实现为准)。把它挂在团队 wiki 上,可减少「购物车为什么和创单差一块运费」的重复解释成本。

flowchart LR
  PDP[PDP / 加购] -->|Layer1+2| A[展示价]
  Cart[购物车] -->|Layer1+2 + 预估3| B[预估小计]
  CO[创单] -->|Layer1+2+4履约段| C[订单快照]
  CH[收银台] -->|Layer1-4 全量| D[支付快照]
  Pay[支付] -->|读快照校验| E[渠道扣款]
  A -.->|规则版本对齐| B
  B -.->|强一致重算| C
  C -.->|增量或 diff 门禁| D
  D -.->|禁止全量暗算| E

时间维度的一致性常被忽略:活动配置可能在用户浏览与创单之间切换生效状态,因此仅有「价格」数值不够,还要记录解释价格的规则时间戳。另一个角度是货币与税费:跨境场景下 PDP 可能只展示本币参考,创单必须锁定报关与税费口径,避免支付阶段因汇率刷新产生合规争议。

测试策略:应为每条主路径维护「黄金 JSON」——给定固定输入(商品、活动版本、用户身份、地址),期望输出快照哈希固定;任何引擎重构先跑黄金用例再灰度。对购物车预估与创单事实的差异,产品需定义可接受区间(如绝对值 ≤ 1 元或 ≤0.5%),超出即前端强提示,避免客诉升级。

场景主要 Layer是否生成快照典型 SLA 心态
PDP1 + 2极快、可缓存
购物车1 + 2(+3 预估)快、可部分预估
创单1 + 2 + 4(部分)订单快照强一致
收银台1 + 2 + 3 + 4支付快照强一致
支付校验快照强一致、少 IO

收银台与创单的时序:常见用户路径是先创单再进收银台选券,因此支付快照往往晚于订单快照生成;若业务允许「未创单先预览收银台」,则要定义预览快照不落库或落短 TTL 缓存,避免用户反复刷新产生大量孤儿快照占满存储。另一个易错点是部分失败:创单成功但写快照失败时,必须有补偿任务阻断支付或自动关单,否则会出现「订单存在却无快照」的不可恢复状态。


29.7 系统边界与职责

29.7.1 计价系统的职责边界

计价中心负责

  • 统一编排各层价格;
  • 输出明细与快照版本;
  • 金额校验、尾差、分摊与安全阈值(如最大折扣率)。

不负责

  • 营销活动配置与圈品 CRUD;
  • 券的发放与库存扣减(由营销执行),计价仅消费「已锁定 / 已选中」结果;
  • 支付路由与渠道签约。

29.7.2 计价 vs 营销:谁算什么

维度营销系统计价系统
规则定义✅ 活动、券模板、互斥叠加
最优券搜索(可选)✅ 或协同推荐服务可消费候选集
金额编排提供命中规则与减免额✅ 汇总为价格事实
执行扣减✅ 锁定 / 核销

边界口诀:营销回答「能不能用、用哪条」;计价回答「用了以后多少钱」

进一步细化:**「最优券推荐」**可以放在营销、推荐或独立优惠参谋服务里,但「用户已勾选某张券后的应付」必须由计价统一给出,避免前端本地算法与后端不一致。支付渠道立减有时由渠道 SDK 返回,计价需约定是「事前写入快照」还是「支付回调后补记账」——两种模式都能做,但不能混用两种口径于同一报表周期。

29.7.3 试算 vs 订单价格快照

  • 试算:可重复、可缓存、允许短暂不一致(需标注);
  • 快照:一次创单事实,不可变(修正走补差单、客服单等流程)。

29.7.4 基础价 vs 促销价 vs 支付价

  • 基础价:商品域维护的标价体系经 ACL 投影;
  • 促销价:营销规则作用后的价格带;
  • 支付价:在订单应付基础上叠加用户支付相关抵扣与手续费后的渠道实扣口径。

争议场景举例:若平台补贴在营销侧记账,但支付渠道又有「立减」,需要明确支付价是否含渠道补贴、财务对账时GMV 与实收各扣哪一段——这属于清结算域的规则,但计价必须在 PriceComponent 上打好 sourceledger_account 类标签,否则报表会对不齐。再如「部分退款是否回收满减」:这是营销与订单策略,计价提供按行分摊的实付结构即可支撑多种回收算法。


29.8 与交易链路各系统的集成

29.8.1 与商品系统集成(基础价读取)

商品中心提供 SPU/SKU 主数据、规格价、渠道价;计价通过 ACL 转为 Money 与可选的「划线价」展示字段。约定:商品系统不实现营销价,避免双源;若商品侧已有「日常售价」字段,应在数据字典中与计价的 Layer 1 对齐命名。批量接口应支持按 sku_id IN (...) 拉取,减少 N+1。

29.8.2 与营销系统集成(营销规则应用)

营销系统输出「命中了哪些活动、互斥关系、是否可叠加、券批次剩余」等;计价把这些投影为 PromotionOffer 再进入 Layer 2。锁定 / 核销仍由营销执行:创单前调用营销「预占」,失败则整单创单失败;计价只消费预占成功后的面额事实。若营销 RPC 慢,优先考虑异步刷新购物车缓存而非缩短创单超时。

29.8.3 与 PDP 集成(加购试算)

PDP 网关调用 GetItemPrice 类接口,应带齐 regionplatform、用户分群键;CDN 上只能缓存匿名价时,登录态价需回源或边缘二次请求。对 SEO 落地页,注意缓存穿透:热门失效 SKU 要有布隆过滤或空值短缓存。

29.8.4 与购物车集成(实时试算)

购物车服务维护行表,计价侧接收「行快照 + 指纹」;指纹变化(数量、选中券)即缓存失效。购物车合并(登录前后)要以服务端合并结果为准重新试算,避免客户端本地算价。

29.8.5 与结算系统集成(确认价格)

结算编排地址、配送方式、可用券列表,调用计价生成支付快照;结算页展示的每一项优惠,都应在快照明细中有对应 component_id,方便客服追溯。

29.8.6 与订单系统集成(创单金额计算)

订单系统保存 order_snapshot_id 与行级分摊明细;后续改价(客服改运费)应走订单变更流程并生成新快照或差值单,而不是直接 UPDATE 金额字段。订单取消释放营销锁时,计价一般不参与,但要保证幂等释放

29.8.7 与支付系统集成(支付金额校验)

支付创建时上传 snapshot_id 与应付总额;支付核心对比快照与渠道金额(含外币换算规则)。重复支付回调通过支付单号幂等;部分支付、合并支付等高级场景要在协议层约定快照粒度(整单 vs 子单)。

29.8.8 集成调用链路与时序

下图刻意省略了库存、地址、风控等横向调用,只保留价格相关主干,便于新人建立心智模型;真实链路可用同一 trace_id 把多次计价调用(结算预览、创单、支付校验)串成一棵树,观察是否出现「同一次用户操作重复计算三次」的浪费——若有,应通过快照传递减少重复扇出。

以下以「用户从结算提交支付」为例展示典型同步调用(简化):

sequenceDiagram
  participant U as 用户端
  participant Ch as 结算系统
  participant P as 计价中心
  participant I as 商品中心
  participant M as 营销系统
  participant O as 订单系统
  participant Pay as 支付系统

  U->>Ch: 确认结算页
  Ch->>P: GetCheckoutPrice(行项目, 券, 渠道)
  P->>I: 批量基础价
  P->>M: 活动命中 + 券试算
  P-->>Ch: 支付快照 snapshot_id
  Ch->>O: 创单(带 snapshot_id)
  O->>P: 可选:校验 / 冻结快照
  O-->>Ch: order_id
  Ch->>Pay: 发起支付(order_id, 金额, snapshot_id)
  Pay->>P: GetPaymentPrice 校验
  P-->>Pay: OK / 差额拒绝
  Pay-->>U: 收银台支付

29.8.9 降级与容错策略

  • 计价依赖故障时,交易路径默认 fail-fast;展示路径可降级;
  • 重试需配合幂等键避免双快照;
  • 全链路 trace id 贯通,便于按 snapshot_id 定位规则版本与下游返回。

超时配置建议:PDP 调用链应「短超时 + 部分降级」,创单链可「较长超时 + 严格失败」。熔断打开时,要有人工开关把流量切到备用集群或旧版本引擎,而不是无限重试。对供应商报价,超时后是否允许用缓存价创单属于业务决策:机票酒店类往往不允许,实物自营类可能允许——决策应写在品类策略配置里而不是写死在代码分支。

审计与合规:计价日志应能重建「当时为什么是这个价」,包括各层输入输出哈希;日志中避免打印完整用户 PII,但需保留 user_idorder_id 关联键。对外部监管或商家对账,常导出快照明细而非实时重算结果。


29.9 工程实践

29.9.1 性能优化

  • 分层埋点:每层耗时、RPC 次数、跳过率;
  • 批量接口上限(如 100 SKU)与背压;
  • 大促前预热与限流按 scene + category 维度配置。

除指标外,建议在引擎内建自适应批大小:当单次购物车行数超过阈值时自动拆批并发,再合并结果,防止单次请求拖垮 GC。对 Go 服务,注意 context 超时传递:上游取消时应中断未完成的供应商调用。内存方面,PricingState 可能持有大切片,必要时在返回后显式重置对象池复用缓冲区,降低大促分配压力。

29.9.2 监控告警

  • 空跑 diff 金额 / 比例阈值告警;
  • 快照校验失败率;
  • 供应商报价失败与降级占比。

告警应区分用户可感知失败(创单失败率)与后台差异(空跑 diff)。对后者可采用采样 + 自动建 JIRA/工单。另建议监控 「创单成功但快照写入失败」 这类罕见组合——往往来自数据库半成功状态,需要补偿任务修复。

29.9.3 故障处理

  • 回滚:灰度开关切回旧引擎;快照已落库则不以新逻辑改写历史
  • 数据修复:通过补差、退款重算由财务域流程驱动,而非直接改库内金额。

演练层面,每季度做一次**「营销配置误发」桌面推演**:若运营错误配置了叠加券,计价能否通过安全校验器拦截?若不能,规则引擎侧也要有发布前仿真。事故后复盘要输出「哪一层本应挡住」的改进行项,而不是只修数据。


29.10 本章小结

本章从交易链路视角定义了计价中心的定位:以四层价格模型统一基础、营销、抵扣与费用语义,用场景驱动的责任链控制计算深度与性能,并以 DDDPrice 聚合、Money / PriceLayer 值对象与领域服务结合,保障边界清晰快照一致。与营销系统的分工上,应坚持「营销定义规则与执行权益,计价产出可审计的价格事实」。落地时务必配套幂等、分摊、空跑比对与可观测性,才能在复杂促销与高并发下同时满足体验与资损防控。

延伸阅读建议:读完本章可对照第 28 章营销系统边界与第 32 章订单价格快照设计,把「试算 → 创单 → 收银台 → 支付」四个时间点的口径表画在团队 wiki 上,作为跨团队评审的检查清单。实现上新加一层价格或一类费用时,先更新该表,再写代码,能显著降低联调返工。

落地检查清单(摘录):① 各场景 skipLayers 与产品 PRD 是否逐条签字;② 快照表是否支持版本与只追加;③ 金额是否全链路整数分;④ 分摊单测是否覆盖「末行尾差」与「单行边界」;⑤ ACL 是否禁止领域层引用外部 proto;⑥ 空跑比对是否在灰度期全量开启;⑦ 支付校验失败是否有客服可查的 snapshot_id 与规则哈希。团队可在发版前用此清单做十分钟走查,把「架构上正确」落实为「发布时可控」。

术语说明:文中 PDP 指商品详情与导购详情类页面;创单指订单创建请求在服务端落库的关键步骤;收银台泛指用户确认支付前选择券、积分与支付方式的交互阶段。不同公司团队命名可能为 Checkout、Cashier 或 Payment Preview,本书统一以「收银台」称呼,重在语义阶段而非具体页面 URL。

落地延伸:读者若在落地中遇到本章未覆盖的品类,可沿用「先补 Calculator、再补 Layer 子处理器」的扩展顺序,避免破坏四层语义的一致性。批量计价接口建议对请求体大小与 SKU 个数双限流,并在响应头返回 X-Pricing-Partial: true 以标记降级后的不完整结果,便于上游决定是否重试或裁剪展示。此做法在搜索 Hydrate 与推荐列表页尤为实用,可作为默认工程约定。


第 30 章 搜索与导购

本章定位:搜索与结构化导购(类目列表、店铺内浏览)是电商平台最主要的 读流量入口 之一,直接影响转化与 GMV。本章在「统一导购查询服务」主叙事下,串起 Query → Recall → Rank → Hydrate 全链路,并以 Elasticsearch 作为召回与粗排主引擎,厘清与商品中心、计价、库存、营销、推荐等系统的 边界与契约,面向中大型团队的工程落地。

阅读提示:若你习惯把「列表页」简单等同于「查 ES 返回 JSON」,建议带着三个问题读完全章——第一,搜索与导购在产品目标上与推荐有何不同;第二,哪些字段必须进索引、哪些必须 Hydrate;第三,索引滞后与 Hydrate 超时时,列表弱一致如何不与交易强一致冲突。搞清这三点,就能把读路径工程化讲清楚,而不是停留在「调个 DSL」的层面。文中 Go 示例为教学裁剪版,落地时请补全观测、注入与错误包装。


30.1 系统定位

30.1.1 搜索与导购的区别

在日常口语里,「搜索」「列表」「导购」常被混用;在架构文档里,建议用 用户意图约束形态 区分:

维度关键词搜索(Search)结构化导购(Browse / Merchandising)
用户输入显式 query,可能含糊、多义通常无文本 query,或 query 为辅助
主约束文本相关性 + 硬 filter类目 / 品牌 / 店铺 / 多维筛选项
失败体验零结果、纠错、同义词空列表、Facet 互斥错误
典型 scenekeywordcategoryshop
引擎侧重分析链、改写、BM25 / 向量(可选)聚合导航、稳定排序、强 filter

二者在工程上应 共享同一套流水线内核(召回 → 排序 → Hydrate),否则极易出现「同一批商品在搜索与类目列表排序不一致」的线上事故。产品层面,搜索偏意图检索:用户带着问题来,系统要回答「最相关的候选集」;导购偏可控陈列:运营希望用户在既定类目树与筛选体系内高效浏览。推荐系统(Feed)则偏 个性化发现,目标函数、特征 freshness、在线学习与搜索不同,本章在 12.5.2 单独划界。

30.1.2 核心挑战

挑战根因设计方向
相关性同义词多、类目错挂、拼写噪声可控词典与改写 + 埋点闭环
列表价与索引不一致促销、会员、渠道价变化快于索引Hydrate + 产品话术;结算强一致
高并发读大促与热搜集中ES 扩展、缓存、限流、降级
深分页from/size 成本随页数上升search_after + 产品限制
跨系统编排Hydrate 依赖多、尾延迟叠加并发上限、独立超时、部分降级
索引与主数据漂移异步链路、至少一次消费幂等 version、对账与补偿任务

与订单、支付等 写路径 不同,导购链路往往 QPS 高、容忍短暂最终一致,但必须处理好 相关性、价格与库存展示口径、营销露出、以及索引滞后 带来的用户预期落差。详情页应以 商品中心读模型 为准做强一致或近实时;列表页承认 弱一致,并在创单 / 结算阶段由库存、计价、营销再次校验。

组织协作 视角看,导购链路往往是「商品、搜索、推荐、营销、前端、数据」多条职能线的交汇点:任何一方在接口里多塞一点排序逻辑,短期能加快需求交付,长期会把 归因、回滚、实验 变成不可能任务。因此本章反复强调 统一查询内核 + 版本化 rank,并不是架构洁癖,而是把 变更半径 收敛到可治理的边界内。另一个常被低估的协作点是 口径对齐:运营口中的「到手价」、产品文档里的「列表价」、计价服务返回的字段名,若不能在数据字典层统一,Hydrate 再快也只能放大混乱。

风险 视角看,导购事故通常不是「ES 挂了」这种单点,而是 组合型:索引滞后叠加 Hydrate 超时,再叠加前端把「展示价」当成「下单价」渲染,最终在社交媒体被放大成「平台偷偷涨价」。工程上要用 字段语义 + UI 文案 + 快照校验 三道闸兜底;单纯优化 ES 延迟并不能消灭这类问题。

30.1.3 系统架构

下图给出 搜索与导购 在全局中的位置:写入侧 不拥有商品主数据,仅消费事件维护 派生索引读取侧 负责召回与排序,卡片动态字段由 Hydrate 编排 多系统补齐。

graph TB
    subgraph UserLayer["用户层"]
        User[商城用户 Web/App]
    end

    subgraph Gateway["接入层"]
        APIGateway[API Gateway<br/>鉴权/限流/路由]
    end

    subgraph SearchDiscovery["搜索与导购域"]
        MQS[导购查询服务<br/>Query/Recall/Rank/Hydrate]
        IndexWorker[索引构建 Worker<br/>消费事件/幂等更新]
    end

    subgraph CoreStorage["核心存储"]
        ES[(Elasticsearch<br/>商品索引)]
    end

    subgraph WriteServices["写入侧数据来源"]
        ProductCenter[商品中心]
        ListingService[上架系统]
        LifecycleService[生命周期/审核]
        BOpsPlatform[B 端运营]
    end

    subgraph ReadServices["Hydrate 依赖"]
        PricingRead[计价只读]
        InventoryRead[库存摘要]
        MarketingRead[营销标签/圈品]
        OpConfig[运营配置/加权]
    end

    subgraph MessageBus["消息总线"]
        Kafka[Kafka / MQ]
    end

    User --> APIGateway --> MQS
    MQS --> ES
    MQS -.-> PricingRead
    MQS -.-> InventoryRead
    MQS -.-> MarketingRead
    MQS -.-> OpConfig

    ProductCenter --> Kafka
    ListingService --> Kafka
    LifecycleService --> Kafka
    BOpsPlatform --> Kafka
    Kafka --> IndexWorker --> ES

关键边界:搜索索引存放 相对静态或可容忍滞后 的字段(标题、类目、上架状态、部分排序特征);易变字段(展示价、库存紧张度、活动标)优先 Hydrate,或在索引中以「粗粒度信号 + 版本」形式存在并与 Hydrate 对齐。

非功能需求 上,建议把导购链路的 SLO 拆成「可分别报警」的三段:ES 召回 P99应用内排序 P99Hydrate 端到端成功率。很多团队只监控入口延迟,结果线上表现为「整体还不慢」,但 Hydrate 超时率缓慢爬升,直到大促才被计价或库存的连接池打爆一次性暴露。更稳妥的做法是把 Hydrate 每个依赖的 超时次数、空返回比例、批量大小分布 都做成 TopN 维度,并在压测脚本里显式模拟「半数依赖降级」。

容量估算:导购链路的容量估算答辩口径已统一收录到第 39 章的“搜索性能优化”题卡


30.2 统一导购查询服务

30.2.1 场景识别(scene 设计)

对外推荐 单一主叙事导购查询服务(Merchandising Query Service) 暴露统一查询接口,用 scene 区分业务语义;内部共享 Query → Recall → Rank → Hydrate 流水线。网关可做鉴权、限流与字段裁剪,但 不要把排序规则散落在多个 BFF 中。

scene用户输入典型 filter召回主索引
keyword关键词 + 可选类目 / 品牌上架可售、合规、店铺黑名单全站商品索引(或按站点分片)
category无关键词或空 query固定 category_id + 同上同上
shop可选关键词固定 shop_id + 同上店铺子索引或单索引强 filter

店铺维度实现二选一:独立索引别名(写入侧按 shop 路由,查询简单)或 单索引 + 强 filter(运维简单,超大店需关注分片热点)。scene 应进入 日志、追踪与实验分桶,与 query_idrank_version 一并贯穿。

// Scene 为导购域的稳定枚举,避免魔法字符串散落。
type Scene string

const (
    SceneKeyword  Scene = "keyword"
    SceneCategory Scene = "category"
    SceneShop     Scene = "shop"
)

type UnifiedQuery struct {
    Scene         Scene
    SiteID        string
    UserID        string // 可选,用于会员价 Hydrate
    RawQuery      string // keyword 场景必填;category 可空
    CategoryID    string
    ShopID        string
    Filters       []Filter // 品牌、价格带、属性等
    Page          PageCursor
    ExpID         string
    RankVersion   string
}

func RouteScene(req UnifiedQuery) (Scene, error) {
    if req.Scene != "" {
        return req.Scene, nil
    }
    switch {
    case req.ShopID != "":
        return SceneShop, nil
    case req.CategoryID != "" && strings.TrimSpace(req.RawQuery) == "":
        return SceneCategory, nil
    case strings.TrimSpace(req.RawQuery) != "":
        return SceneKeyword, nil
    default:
        return "", errors.New("unable to route scene: missing query/category/shop")
    }
}

30.2.2 查询编排

编排(Orchestration) 负责:scene 路由 → Query 理解 → 构建 ES DSL → 执行召回 → 粗精重排 → 触发 Hydrate → 合并 DTO。编排层应保持 无业务状态的纯函数倾向:依赖通过接口注入,核心流水线可单测。

type MerchandisingQueryService struct {
    QU   QueryUnderstanding
    ES   SearchClient
    Rank Ranker
    Hydr Hydrator
    CFG  OpConfigClient
}

func (s *MerchandisingQueryService) Search(ctx context.Context, req UnifiedQuery) (*SearchResult, error) {
    scene, err := RouteScene(req)
    if err != nil {
        return nil, err
    }
    qctx, err := s.QU.Normalize(ctx, scene, req)
    if err != nil {
        return nil, err
    }
    recall, err := s.ES.Recall(ctx, qctx)
    if err != nil {
        return nil, err
    }
    ranked := s.Rank.Score(ctx, req, recall)
    reranked := s.Rank.Rerank(ctx, req, ranked, s.CFG) // 运营配置、打散、强插
    cards, err := s.Hydr.Hydrate(ctx, HydrateRequest{
        Scene:       scene,
        UserID:      req.UserID,
        DocIDs:      reranked.IDs(),
        RankVersion: req.RankVersion,
    })
    if err != nil {
        // Hydrate 全局失败应极少:通常部分降级
        return nil, err
    }
    return AssembleResult(reranked, cards), nil
}

演进注记:何时拆 BFF。当「列表卡片组装」与「搜索实验」发布节奏被不同团队强绑定时,常见折中是把 Hydrate 后的视图组装 下沉到 BFF,但 排序分数、实验桶、rank_version 仍应由导购查询内核产出并透传。否则会出现「实验只在 App 搜索生效、H5 列表不生效」的割裂,排查时日志还对不齐。另一个反模式是把 ES DSL 拼接散落在多个网关插件里:短期看似减少了一次 RPC,长期 DSL 变更无法回归测试,零结果率 波动也无法定位是改写问题还是索引问题。

编排层还应内置 最小可观测上下文query_id 应在进入 QU.Normalize 之前生成,并注入到 ES 查询注解(如 preference / custom header)与下游 RPC metadata 中,保证一次用户请求能在日志系统里 串起全链路。若你们使用 OpenTelemetry,建议把 scenerank_versionexp_id 作为 span attributes,而不是塞进自由文本日志。

30.2.3 结果聚合

结果聚合 关注三类合并:

  1. 多路召回合并(如关键词 BM25 + 可选向量):需 quota、去重、延迟预算;MVP 常单路 ES。
  2. 排序分与业务字段合并:ES _score、销量、上新等与 Hydrate 返回的展示价、库存标合并为统一 DTO。
  3. Facet 与列表一致性:侧边栏聚合必须与当前 filter 同一 query 范围,否则出现「互斥筛选仍显示有货计数」的体验问题;大流量下可 异步加载 facet近似聚合

对外响应建议显式携带 partialindex_versionprice_as_of**(时间戳),便于客诉定位与前端提示「价格以结算为准」。

多路召回合并(例如 BM25 + 向量)在工程上要提前写清 配额策略:两路各取多少、按什么键去重、合并后是否二次截断。没有配额时,最常见事故是「向量路召回大量泛化商品」把关键词路的相关性稀释掉,表现为 CTR 下降但延迟上升。若团队尚未建立向量索引运维与回放体系,MVP 阶段更建议 单路 ES + 强词典,把复杂度留给数据运营而不是平台第一天的 midnight。

Facet 与列表一致性 的实现细节是:用户每点击一次筛选,服务端应以 同一套 UnifiedQuery 生成 ES 请求体,其中 post_filteraggs 的嵌套关系必须遵循「先算子集再聚合」的语义。很多初版实现为了省事,把 facet 请求拆成第二次查询,若不在客户端做强一致串行,会出现 列表已空但 facet 仍显示有货 的短暂撕裂。工程上更推荐 单次 ES 往返(列表 + facet)或在产品层声明「facet 异步刷新」并做骨架屏。

DTO 稳定性:导购接口是前台最高频契约之一,字段增删应走 版本化 JSON schema 或 protobuf 的向后兼容规则。特别是 partial 语义一旦上线,就不应在无迁移的情况下改变含义(例如从「仅价格缺失」扩展成「任意字段缺失」),否则前端埋点与客服话术会同时失效。


30.3 主链路:Query → Recall → Rank → Hydrate

主链路是本章的「脊柱」。下图给出 端到端数据流(含实验与运营配置注入位点):

flowchart TB
    subgraph In["输入"]
        REQ[UnifiedQuery<br/>scene/filters/page/exp]
    end

    subgraph Q["Query 理解"]
        NORM[归一化/词典]
        RW[改写与同义词]
        TAG[intent_tags]
    end

    subgraph R["Recall 召回"]
        DSL[ES DSL 组装]
        ES[(Elasticsearch)]
    end

    subgraph P["Rank 排序"]
        COARSE[粗排截断 M]
        FINE[精排到 Top K]
        RERANK[重排:打散/合规/强插]
        CFG[运营配置 OpConfig]
    end

    subgraph H["Hydrate"]
        BATCH[批量并行获取]
        PRICE[计价只读]
        INV[库存摘要]
        MKT[营销标签]
        PC[商品读/主图标题补全]
    end

    subgraph Out["输出"]
        DTO[列表 DTO + partial 标记]
    end

    REQ --> NORM --> RW --> TAG --> DSL --> ES --> COARSE --> FINE
    CFG --> RERANK
    FINE --> RERANK --> BATCH
    BATCH --> PRICE
    BATCH --> INV
    BATCH --> MKT
    BATCH --> PC --> DTO

30.3.1 Query 理解

目标不是通用 NLP 搜索引擎,而是 可控、可解释、可回归

  • 归一化:全半角、大小写、去噪字符、重复空格。
  • 同义词 / 类目词典:运营可配表驱动;变更走 版本号,与排序实验解耦。
  • 拼写纠错:可选;需 限流 + 白名单,避免引入合规或品牌风险。

输出物建议固定为:normalized_queryintent_tagsrewrites[](有限条数),供 DSL 组装与埋点。

工程落地建议:把 Query 理解的输出定义成 不可变结构体(或值对象),并在日志里同时打印 raw_querynormalized_query,但注意隐私合规(手机号、地址片段误入搜索框并不少见)。改写表(同义词、类目映射)应支持 灰度发布:先 shadow 记录「若启用改写将变成什么」,再按桶启用,避免运营配置错误导致大面积零结果。

与「大模型改写」的边界:生成式改写很诱人,但在电商场景要先回答 责任归属:改写后的 query 若召回违规商品,谁承担合规责任?更稳妥的路径通常是 受控词典 + 小模型 / 规则纠错,把 LLM 放在离线挖掘与运营辅助,而不是在线默认链路的第一跳。

30.3.2 召回策略

召回阶段输出 候选 doc 列表(通常为 SPU 或展示单元 ID)及 ES 内已可用的排序分量。不要在召回阶段做重 CPU 的跨系统调用。硬条件(上架状态、类目、店铺、站点)应优先放在 filter 上下文以利用缓存与免评分。

bool 查询语义 是关键语义点:must 参与评分,适合承载关键词相关性;filter 不计分且可缓存,适合承载「硬门槛」。实践中常见错误是把「品牌=耐克」放在 must 里参与打分,导致品牌词意外影响相关性曲线;更推荐 品牌进 filter,把「品牌相关 boost」交给 function_score 或在精排阶段处理。另一个错误是把大量 低选择性 条件全部堆在 must,使 _score 退化为常数,精排阶段只能「白手起家」——这会放大后续服务压力。

召回截断 需要与后续粗排预算对齐:若 ES size 直接取 2000 返回全字段,网络与反序列化会先拖垮应用。更常见做法是 ES 侧 只回 id 与排序必要字段docvalue_fields / _source: false),把重字段留给 Hydrate 或商品读服务。对于「店铺内搜索」这类可能触发热点店铺的场景,可在 DSL 增加 routing 或独立索引,把查询分散到更小分片集合上。

可选扩展:向量召回。若引入向量,需要同步建设 向量更新延迟、ANN 参数、召回评测集 三件事;否则极易出现「文本搜得到、向量搜不到」的双轨撕裂。多数业务在规模化前,同义词 + 类目意图 + 运营纠错 的投入产出比更高。

30.3.3 粗精排序

阶段典型输入典型输出说明
粗排ES 召回前 N(如 500~2000)截断到 M(如 200)_score + function_score(销量、上新衰减等)
精排M 条 doc idTop K(如 50)转化率预估、价格带、店铺分;LTR 可替换此阶段
重排K 条页大小 P多样性、类目打散、疲劳度、合规过滤、运营强插

合规默认值明确违法禁售 应在索引写入侧即不可召回;审核「灰区」更适合 召回 filter;最后一道 重排后、返回前 再过滤,避免已排序商品在末尾被剔除导致 空洞位

AB 与配置版本(最小集)exp_id 贯穿日志;rank_version 绑定权重 / 规则 / 模型版本可快速回滚;query_id 关联 ES 与 Hydrate 子调用。发布建议 shadow traffic 双写日志对比,再按桶放量;与计价、营销大促窗口 错峰改排序,避免归因困难。

粗排放在 ES 还是应用内 没有银弹:ES 内 function_score 的好处是 少一次数据搬运;坏处是调试困难、权重爆炸、且与「精排模型特征」割裂。常见折中是:ES 负责 硬过滤 + 文本相关 + 少量可解释加权;应用内精排负责 复杂特征交叉业务规则解释。无论选哪条路径,都要保证 同一套 rank_version 能在离线回放数据集上复现,否则线上调参只能靠运气。

重排的业务含义 需要写清:多样性(同店铺打散、同品牌打散)、疲劳度(用户反复看到同一 SPU)、运营强插(置顶资源位)都属于「非相关性目标」,若不与相关性分层,就会出现「搜牙刷全是运营想卖的电器」。工程上建议把重排规则 配置化 + 可视化回归,并在报表里同时看 CTR 与 投诉率 / 零结果率

30.3.4 Hydrate 编排

列表卡片常需:展示价、原价划线、库存状态、营销标、店铺名。变化快于索引刷新时,必须由 Hydrate 补齐。契约建议:入参 doc_ids[](上限如 50)、user_id(可选)、scenerank_version;出参为 map[id]CardEnrichment,缺失键表示单卡失败。

Hydrate 编排 强调:批量、限时、可降级、可观测。下图描述 并行依赖超时隔离(示意):

flowchart LR
    subgraph Req["HydrateRequest"]
        IDS[doc_ids 上限 N]
    end

    subgraph Orch["编排器 Hydrator"]
        SPL[拆分批次/限流]
        EG[errgroup 并发池]
        MERGE[合并 map 结果]
        DEF[缺省策略/占位]
    end

    subgraph Deps["下游只读依赖"]
        A[计价批量]
        B[库存摘要批量]
        C[营销标签批量]
        D[商品读批量]
    end

    IDS --> SPL --> EG
    EG --> A
    EG --> B
    EG --> C
    EG --> D
    A --> MERGE
    B --> MERGE
    C --> MERGE
    D --> MERGE
    MERGE --> DEF
type CardEnrichment struct {
    ListPriceCents   *int64 // 展示价(分);nil 表示 Hydrate 未取到
    StrikePriceCents *int64 // 划线价(分)
    StockLevel       string // 如 IN_STOCK / LOW / UNKNOWN
    PromoTags     []string
    Title         string
    MainImageURL  string
}

type HydrateRequest struct {
    Scene       Scene
    UserID      string
    SiteID      string
    DocIDs      []int64
    RankVersion string
}

type Hydrator struct {
    Pricing  PricingBatchClient
    Inv      InventoryBatchClient
    Mkt      MarketingBatchClient
    Product  ProductBatchClient
    Parallel int
    PerDep   time.Duration
}

func (h *Hydrator) Hydrate(ctx context.Context, req HydrateRequest) (map[int64]CardEnrichment, error) {
    g, ctx := errgroup.WithContext(ctx)
    if h.Parallel > 0 {
        g.SetLimit(h.Parallel)
    }

    out := sync.Map{} // map[int64]CardEnrichment

    run := func(fn func(context.Context) map[int64]CardEnrichment) {
        g.Go(func() error {
            cctx, cancel := context.WithTimeout(ctx, h.PerDep)
            defer cancel()
            partial := fn(cctx)
            for id, card := range partial {
                v, _ := out.LoadOrStore(id, CardEnrichment{})
                base := v.(CardEnrichment)
                out.Store(id, mergeCard(base, card))
            }
            return nil // 单依赖失败不失败整页:在 fn 内部吞错
        })
    }

    run(func(c context.Context) map[int64]CardEnrichment {
        m, _ := h.Pricing.BatchListPrices(c, req.SiteID, req.UserID, req.DocIDs)
        return m
    })
    run(func(c context.Context) map[int64]CardEnrichment {
        m, _ := h.Inv.BatchStockSummary(c, req.SiteID, req.DocIDs)
        return m
    })
    run(func(c context.Context) map[int64]CardEnrichment {
        m, _ := h.Mkt.BatchPromoTags(c, req.SiteID, req.UserID, req.DocIDs)
        return m
    })
    run(func(c context.Context) map[int64]CardEnrichment {
        m, _ := h.Product.BatchCardFields(c, req.SiteID, req.DocIDs)
        return m
    })

    _ = g.Wait()

    merged := make(map[int64]CardEnrichment, len(req.DocIDs))
    for _, id := range req.DocIDs {
        if v, ok := out.Load(id); ok {
            merged[id] = v.(CardEnrichment)
        }
    }
    return merged, nil
}

func mergeCard(a, b CardEnrichment) CardEnrichment {
    // 教学示例:按字段非空合并
    if b.ListPriceCents != nil {
        a.ListPriceCents = b.ListPriceCents
    }
    if b.StrikePriceCents != nil {
        a.StrikePriceCents = b.StrikePriceCents
    }
    if b.StockLevel != "" {
        a.StockLevel = b.StockLevel
    }
    if len(b.PromoTags) > 0 {
        a.PromoTags = append(a.PromoTags, b.PromoTags...)
    }
    if b.Title != "" {
        a.Title = b.Title
    }
    if b.MainImageURL != "" {
        a.MainImageURL = b.MainImageURL
    }
    return a
}

Hydrate 与「列表一致性」:当某个 SPU 在 ES 中仍存在,但商品中心已下架或不可售时,应以 商品读返回的状态 为准做最终过滤,并在必要时 剔除该位显示不可用(取决于产品策略)。这意味着 Hydrate 不只是「加字段」,也可能反向改变 可展示集合;若发生剔除,需要在前端处理 页大小不足 的补位逻辑(例如自动补拉一条),否则会出现末尾空洞。

连接池与超时:Hydrate 依赖往往共享连接池,若列表 QPS 高且每页 50 条,极易把下游 最大并发 顶满。除了限制 Parallel 与批量大小,还应对 同一用户 做轻量节流(例如滑动窗口),防止脚本或异常客户端发起「并发多页请求」放大扇出。


30.4 Elasticsearch 专题

与商品中心的分工:索引字段清单、nested 取舍、商品变更如何进索引,以商品中心相关章节为权威叙述。本节聚焦 查询侧契约、典型 DSL、深分页与性能调优,并给出 索引生命周期与 mapping 视角 的架构图。

30.4.1 索引设计

索引设计 要在「召回质量」「写入吞吐」「运维成本」三者间折中。下图从 写入投影查询路径 两侧展示(与 IndexWorker 呼应):

flowchart LR
    subgraph Sources["领域事件源"]
        P[商品中心]
        L[上架状态]
        C[生命周期/审核]
        O[运营批量]
    end

    subgraph Pipe["索引管道"]
        MQ[Kafka]
        W[IndexWorker<br/>幂等/version]
        BULK[Bulk Processor]
    end

    subgraph Index["ES 索引族"]
        ALIAS[read_alias 指向物理索引]
        IDX[(product_search_vN)]
    end

    subgraph Query["查询侧"]
        DSL[bool + filter + sort]
        FACET[aggs 导航]
    end

    P --> MQ
    L --> MQ
    C --> MQ
    O --> MQ
    MQ --> W --> BULK --> IDX
    ALIAS --> IDX
    DSL --> ALIAS
    FACET --> ALIAS

文档建模的两种典型切分:一是以 SPU 为展示单元(服装、标品多规格常如此),SKU 维度属性用 nested 或扁平化字段表达;二是以 SKU 为展示单元(强价格/库存差异的品类),索引文档更细但写入放大。切分没有绝对正确,关键是 列表页用户心智下单单元 一致:若用户认为自己在买「一款多规格商品」,却以 SKU 文档展示,容易出现 重复占位排序抖动(同一 SPU 多个 SKU 同时出现在列表)。切分确定后,Hydrate 的 doc_ids 语义也要固定,否则计价批量接口的入参会频繁返工。

nested 的决策树(落地版):当查询必须表达「父文档条件 ∧ 子文档条件」且无法通过扁平化字段无损表达时,才引入 nested;否则优先 写入侧展开(例如把可检索属性汇总到父级 attrs.searchable)以降低查询成本。nested 还会让 聚合 更复杂:facet 若需要 SKU 级分布,必须清楚产品是否真的需要,很多类目列表只需要 SPU 级导航。

mapping 要点(查询视角)

实践说明
筛选 / 聚合 / 排序字段优先 keyword 或数值类型,保证 doc_values
全文检索text + 子字段 keyword 谨慎用于排序
nested仅当 SKU 级属性必须父子联合约束时使用;滥用会放大成本
反模式对大文本无意义排序;高基数深度聚合默认全开

30.4.2 查询 DSL 模式

分析链与中文分词:索引与查询使用 同一分析链(或查询链为索引链的有意子集)。filter 不参与评分且可缓存,适合 站点、上架状态、类目、店铺、价格区间 等硬条件。

{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "无线耳机",
            "fields": ["title^3", "brand^2", "attrs.searchable"],
            "type": "best_fields"
          }
        }
      ],
      "filter": [
        { "term": { "site_id": "SG" } },
        { "term": { "listing_status": "ONLINE" } },
        { "term": { "category_id": "cat-3c-audio" } },
        { "range": { "list_price": { "gte": 50, "lte": 500 } } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "sales_30d": "desc" },
    { "spu_id": "asc" }
  ],
  "_source": false,
  "docvalue_fields": ["spu_id", "list_price", "shop_id"]
}

列表页应 裁剪 _source,避免返回大段正文。生产可用 docvalue_fields_source 组合权衡包大小。

高亮与摘要:高亮字段应控制在 title 等短字段;对大段描述开启高亮会显著增加响应体与序列化成本。若产品需要「摘要片段」,更推荐由商品中心提供 预生成摘要 或在索引中维护 short_description 的受控长度字段,而不是在查询时从正文动态截取。

聚合导航(facets)与筛选互斥:当用户选择 brand=A 后,其它 facet 的桶计数应基于「已选条件下的子集」重算,否则会出现互斥筛选仍显示「有库存计数」的错觉。实现上可以用 filter aggs、post_filter 组合,或在单次请求中拆成「列表 query」与「facet query」两段但由同一 UnifiedQuery 生成,避免前后端各自拼装 filter 造成漂移。

// Go 侧可用结构体拼装 DSL(示意字段,非完整客户端)
type BoolQuery struct {
    Must   []any `json:"must,omitempty"`
    Filter []any `json:"filter,omitempty"`
}

type SearchBody struct {
    Query  map[string]any `json:"query"`
    Sort   []any          `json:"sort,omitempty"`
    Size   int            `json:"size"`
    Source any            `json:"_source,omitempty"`
}

func BuildKeywordDSL(site, cat, q string, from, size int) SearchBody {
    return SearchBody{
        Query: map[string]any{
            "bool": BoolQuery{
                Must: []any{
                    map[string]any{
                        "multi_match": map[string]any{
                            "query":  q,
                            "fields": []string{"title^3", "brand^2", "attrs.searchable"},
                            "type":   "best_fields",
                        },
                    },
                },
                Filter: []any{
                    map[string]any{"term": map[string]any{"site_id": site}},
                    map[string]any{"term": map[string]any{"listing_status": "ONLINE"}},
                    map[string]any{"term": map[string]any{"category_id": cat}},
                },
            },
        },
        Sort: []any{
            map[string]any{"_score": "desc"},
            map[string]any{"sales_30d": "desc"},
            map[string]any{"spu_id": "asc"},
        },
        Size: size,
        Source: false,
    }
}

30.4.3 深分页问题

方式适用风险
from + size前若干页from 过大时全局排序,内存与延迟陡增
search_after深度翻页 / 连续浏览需稳定 sort key;不适合随机跳页
scroll离线导出、对账不适合 C 端高并发

答辩提示:深分页问题的标准答法已统一收录到第 39 章的“搜索性能优化”题卡

search_after 的工程细节:sort 数组必须 全链路稳定,任何「仅用于展示」的字段都不应参与 tie-break,否则会出现翻页跳变。常见做法是 _score + 业务排序字段 + 主键升序。客户端需要缓存上一页最后一条的 sort 值;若中间发生 索引刷新 导致顺序变化,产品上要接受「轻微抖动」或通过 会话级快照(成本更高)解决。

随机跳页的产品替代:电商 C 端常见替代是「跳到第 N 页」改为「继续浏览 / 相似推荐 / 细化筛选」,把深分页需求转化为 更强的约束更相关的子集,既保护集群也提升转化。

30.4.4 性能调优

  • Profile:区分评分、聚合、function_score 热点。
  • 分片与副本:分片数与数据量、查询并发匹配;副本换读吞吐但写入放大。
  • 段合并与冷热:写入高峰观察 merge;冷热索引降副本或迁移。
  • function_score 粗排:销量 log1p、上新高斯衰减等可进 ES,注意权重爆炸与可调试性。

Facet 性能:限制桶数、min_doc_count;首屏列表优先,facet 可异步。Suggest(completion / search_as_you_type)应 单独限流,并与 Query 纠错 二选一主路径 以防延迟放大。

慢查询清单(发布前自检)

症状可能原因处理方向
P99 随页数线性变差from/size 深分页search_after + 产品限制
CPU 尖刺大聚合 / 高基数 terms降桶、采样、异步 facet
写入延迟升高分片过大 / merge 压力分片再规划、冷热分层
命中不稳定分析链不一致 / synonym 热更统一分析链 + 版本化词表
结果「看起来对但排序怪」function_score 权重叠加归一化与离线回放

索引别名与零停机切换:大版本 mapping 变更往往需要 reindex。生产上应使用 双写 / 回填 + read_alias 原子切换,并在切换后保留旧索引一段时间用于回滚与对账。切换窗口内还要特别注意 缓存层(CDN、应用本地缓存)是否仍指向旧索引版本,否则会出现「列表已新、详情仍旧」的短暂错觉。

运行时字段与脚本:能用 mapping 解决的不建议长期依赖脚本字段;脚本会把成本从索引时挪到查询时,且更难做 成本预算。若必须使用脚本,务必加 采样 profile熔断(例如限制每节点脚本编译频率)。


30.5 系统边界与职责

30.5.1 搜索系统的职责边界

搜索与导购系统应负责:

  • 派生索引的构建与查询(含增量、幂等、版本对齐)。
  • 召回与(可配置)排序,以及 列表读模型的编排(Hydrate)
  • 观测与实验 位点(query_idrank_versionexp_id)。

不应负责:

  • 商品主数据真相源、订单事务、营销算价与券扣减、库存预占。

30.5.2 搜索 vs 推荐:边界划分

维度搜索 / 导购推荐(Feed)
主信号query + 筛选意图用户行为序列与画像
目标相关性 + 平台规则下的转化发现与时长 / GMV 组合目标
失败模式零结果、相关性差信息茧房、疲劳
架构检索引擎 + 规则/LTR特征平台 + 在线排序 + 重排

二者可 共享埋点、特征与实验平台,但服务边界建议解耦,避免「搜索里偷偷塞推荐」导致可解释性与合规审计困难。

落地协作模式:推荐团队常希望复用搜索召回做「候选池」,这在技术上是可行的,但要把契约写清:候选池的版本、过滤条件、以及责任边界(例如禁售过滤由谁兜底)。更推荐的方式是推荐系统维护 自己的候选生成链路,在特征层复用搜索的 类目、品牌、文本 embedding 等中间产物,而不是在运行时强耦合调用搜索 HTTP 接口——否则搜索一旦降级,推荐会被连带拖死,故障半径不可控。

30.5.3 索引数据 vs 实时数据

数据类型放索引Hydrate / 实时读
标题、主图、类目、品牌可选补全
上架状态、站点强一致场景再二次校验
展示价、会员价粗粒度 / 索引价是(列表口径)
可售 / 紧张粗信号是(更准确)
活动标、圈品命中可缓存只读是(失败可降级)

30.5.4 召回 vs 排序 vs Hydrate 的职责

  • 召回:在高召回率前提下控延迟;避免跨系统调用。
  • 排序:可解释、可版本化、可回滚;重排承载运营与合规。
  • Hydrate:补齐易变展示字段;单卡失败不拖死整页

30.5.5 决策点:搜索服务哪些字段应该直接来自 ES,哪些应该回商品中心实时拉取

这个问题的本质,不是“字段能不能塞进 ES”,而是:这个字段是否参与检索、过滤、排序,以及它的变动频次和准确性要求是否允许它停留在搜索投影里

建议用一个简单的四象限模型来判断:

  • 纯 ES 模式:字段低频变化、弱一致可接受、且直接参与召回 / 过滤 / 排序。
  • 纯商品中心模式:字段不参与搜索,仅在展示时偶尔需要,直接走商品中心批量读或缓存。
  • 混合模式:字段既参与筛选 / 排序,又高频变化、强一致要求高;ES 只存粗信号或排序骨架,最终展示由商品中心、计价或库存系统批量覆盖。
  • 忽略模式:既不影响搜索,也不需要在列表展示,就不应进 ES。

落地时可以直接按下表执行:

字段类型是否进 ES权威来源列表展示从哪拿决策理由
商品标题 / 副标题商品中心ES 直接输出必须分词检索,且变动频率低。
商品主图 URL商品中心ES 直接输出不参与搜索,但列表高频展示,若不冗余会放大 RPC 扇出。
类目 / 品牌 / 属性商品中心ES 直接输出直接参与筛选、聚合与排序解释。
销量 / 好评率 / 综合分数据中心 / 搜索中心ES 直接输出用于排序与导购权重,允许分钟级刷新。
上下架状态商品中心ES 过滤必须在 ES 层先过滤掉不可售商品。
绝对库存数库存中心库存中心 / 商品中心批量 Hydrate高频、高并发更新,ES 不适合承接绝对数写入。
实时到手价 / 促销价混合计价中心 / 商品中心Hydrate 实时覆盖ES 可存基础价用于粗排,最终展示价必须以实时价为准。
冷门展示字段(如保质期说明、包装材质)商品中心商品中心批量读不参与搜索,没必要浪费 ES 存储和内存。

可以把这条规则概括为一句话:

ES 出骨架,商品中心、计价和库存补血肉。

酒店搜索示例:哪些信息存 ES,哪些信息走商品中心 / 资源域

酒旅搜索比实体电商更复杂,因为它的价格和库存天然带有 日期维度用户上下文。同一家酒店,在不同入住日、房型、会员等级和连住天数下,价格与可售状态都可能完全不同。

因此建议这样拆:

信息类型示例字段放在哪里原因
基础静态信息酒店名称、别名、地址、经纬度、星级、品牌、设施标签、商圈、地标、基础房型名称ES参与文本检索、Geo 检索、筛选与排序,且更新频率低。
粗粒度交易信号has_roombase_min_price、整体可售布尔状态ES支撑“有房过滤”和“价格粗排”,允许短暂滞后。
实时库存某入住日到离店日之间具体房型剩余间数酒店商品中心 / 库存资源域高并发、按日期格子变化,不能让 ES 承担这种写入压力。
实时到手价会员价、连住优惠价、含税到手价、早餐方案价计价 / 商品中心强依赖用户、日期、售卖计划,必须实时计算。
售卖规则退改政策、最晚保留时间、确认时效商品中心 / 履约规则域交易解释事实,详情和下单必须以权威域为准。

酒店搜索的推荐链路通常是:

  1. 搜索中心先用 ES 过滤出满足城市、星级、地理范围和设施条件的酒店骨架。
  2. ES 负责按 base_min_price、距离、评分等字段做粗排,返回酒店 ID、标题、主图、经纬度等静态字段。
  3. 搜索服务拿着 hotel_ids + checkin + checkout + user_id,批量调用商品中心 / 计价 / 库存资源域,获取这次查询窗口下的真实价格和真实可售状态。
  4. 用实时数据覆盖 ES 粗粒度结果,再返回给前端。

这意味着:

  • 价格排序 可以依赖 ES 中的基础低价做粗排;
  • 最终展示价 必须以实时返回结果为准;
  • 绝对房态库存 不应该写入 ES;
  • “仅剩 2 间” 这类文案也必须来自实时资源域,而不是搜索索引。

一个简化的搜索编排伪代码如下:

func SearchHotels(ctx context.Context, req SearchRequest) (SearchResult, error) {
    esResult := esClient.SearchHotelIndex(ctx, req)

    hotelIDs := esResult.HotelIDs()
    realtimeMap, err := productCenter.BatchGetHotelRealtimeInfo(ctx, hotelIDs, req.CheckIn, req.CheckOut, req.UserID)
    if err != nil {
        return esResult.ToFallbackResult(), nil
    }

    return MergeHotelSkeletonAndRealtime(esResult, realtimeMap), nil
}

这套设计的关键不是追求“所有数据都实时”,而是把 搜索的吞吐压力交易字段的强一致压力 分层处理:ES 承担召回和粗排,商品中心 / 计价 / 库存承担交易前的最终真相。

答辩提示:搜索边界类追问已统一收录到第 39 章的“电商搜索引擎架构”题卡


30.6 与上下游系统集成

30.6.1 与商品中心集成(索引数据来源)

商品中心提供 主数据与读模型版本;索引 Worker 消费 product.changed 等事件,比较 version / updated_at 后 bulk upsert。删除语义需显式:HARD_DELETE vs UNSEARCHABLE(保留文档但 filter 掉)。

批量读接口的契约:商品中心面向 Hydrate 的批量接口应返回 卡片级最小字段集(标题、主图、类目路径、店铺名等),并携带 content_version 便于与索引对齐。切忌让导购服务在列表场景调用「详情级大对象」接口,否则会把商品中心的 详情缓存击穿 间接变成搜索事故。

图片与多媒体:主图 URL 是否进索引取决于列表是否必须在 ES 故障时仍能展示基本内容;更常见是把图片放在 Hydrate,索引只存 image_id 或稳定 CDN key,避免 URL 频繁变更触发无意义 reindex。

func ApplyProductEvent(doc ProductDoc, evt ProductEvent) (bool, error) {
    if evt.Version < doc.Version {
        return false, nil
    }
    return true, UpsertES(doc.Merge(evt))
}

30.6.2 与计价系统集成(列表价 Hydrate)

计价只读接口建议 批量 + 站点 + 会员等级 维度;字段命名与 PDP / 结算 严格区分「列表价」与「应付价」,避免客户端误用。超时策略见 12.7.2。

会员价与未登录态:未登录用户可能只能看到「起售价」或「公开价」,此时 Hydrate 请求不应隐式携带会员身份;登录态切换时要小心 前端缓存 造成「登录后列表仍显示旧价」,可通过 price_as_of 或短 TTL 缓存失效解决。对「登录看价」类降级,建议同时返回 可解释的降级原因码(如 PRICING_TIMEOUT),便于埋点区分转化率下降根因。

30.6.3 与库存系统集成(可售状态)

列表展示 弱一致 库存摘要即可;下单前以库存服务 强校验 为准(与订单、库存章节衔接)。降级策略需业务拍板:偏保守利于防客诉,偏乐观利于转化。

30.6.4 与营销系统集成(活动标签)

营销在列表侧 只读展示:活动标、圈品是否命中;不算价、不锁券。资格与叠加仍以结算与创单为准。

活动标与合规:列表展示「满减」「券」等文案时,要避免暗示用户已领取或已满足门槛。活动标更像 广告露出,不是 权益状态;否则容易与营销执行域产生口径冲突并引发投诉。对敏感类目(医疗、金融类比商品),活动文案可能需要额外 合规审核字段 控制展示。

30.6.5 Hydrate 编排与降级策略

失败建议降级
计价超时展示索引价或「登录看价」
库存超时保守文案或 UNKNOWN,不断言有货
营销标签失败隐藏活动标,不影响下单资格判定
ES 集群故障短时返回缓存快照 / 简化查询 / 明确提示

批量契约建议:doc_ids 上限 20~60;并行度 4~16;单依赖超时 30~120ms;响应带 partial=true

列表请求时序(典型)

sequenceDiagram
    participant U as 用户
    participant G as Gateway
    participant M as 导购查询服务
    participant ES as Elasticsearch
    participant H as 计价/库存/营销只读

    U->>G: 搜索/列表请求 + query_id
    G->>M: 鉴权、限流、透传实验桶
    M->>M: Query 理解
    M->>ES: DSL 召回 + 粗排字段
    ES-->>M: hits + sort keys
    M->>M: 精排 / 重排
    M->>H: batch hydrate(限时)
    H-->>M: 部分成功 / 超时降级
    M-->>G: 列表 DTO + rank_version + partial
    G-->>U: 响应

索引更新时序(典型)

sequenceDiagram
    participant L as 上架/商品领域服务
    participant MQ as 消息总线
    participant W as 索引 Worker
    participant ES as Elasticsearch

    L->>MQ: 商品或上架状态变更事件
    MQ->>W: 至少一次投递
    W->>W: 幂等:比较 version
    W->>ES: bulk upsert/delete
    ES-->>W: ack

30.6.6 集成性能优化

  • 批量 RPC 合并网络往返;连接池按 QPS × 每页条数 估算。
  • 热门 SPU 短 TTL 缓存 + singleflight 防击穿;注意 个性化价 与缓存 key 冲突。
  • 重试 指数退避 + 用户维度熔断,避免拖垮计价 / 库存。

依赖拓扑与故障隔离:Hydrate 依赖建议按 关键路径分级:标题主图属于「强展示」;活动标属于「弱展示」;价格属于「强体验但可降级」。分级后可以定义 不同的超时与重试策略,避免弱依赖拖长尾。若使用服务网格,可为营销只读设置更小的超时与更激进的熔断,把尾延迟从全链路中剥离出去。


30.7 一致性与降级

30.7.1 索引延迟处理

现象:上架后短暂搜不到;改价后列表旧价。组合手段:详情强一致读商品中心;列表展示 数据时间戳 或「价格以结算为准」;大促关键池可走 强制刷新队列(与商品中心刷新策略对齐)。

运营活动窗口的同步策略:大促「清单商品」往往要求更高新鲜度,技术上可采用 活动商品白名单 + 更高优先级消费队列,甚至短时 双写直刷(写入路径旁路触发索引更新)。但要警惕:旁路越多,幂等与对账越复杂;因此白名单规模必须可控,并在活动结束后及时回收,避免把临时机制固化成永久债务。

30.7.2 降级策略

与 12.6.5 呼应:Hydrate 独立超时;ES 故障 缓存 / 简化查询;suggest 与主搜 配额隔离

30.7.3 缓存策略

  • 查询结果缓存:key 需含站点、筛选 hash、排序版本;个性化价场景慎用或细分桶。
  • Facet 缓存:更短 TTL 或异步;注意筛选变更失效。
  • 索引别名切换:蓝绿 reindex 后一次性切 read_alias,缩短双读不一致窗口。

索引延迟的「产品 + 技术」组合拳:除了技术手段(强制刷新队列、提高消费并行、热点分片治理),还需要 产品话术与 UI 引导:例如「刚刚上架,正在全网同步」或提供 直达详情 的链接入口。否则用户会把「搜不到」理解为「平台没货」,对转化伤害更大。对价格类客诉,客服工具应能输入 spu_id 查到 index_versionprice_as_of,否则只能复读「以结算为准」。

Hydrate 风暴的防护:当 ES 变慢时,应用层往往会 放大重试拉长等待,进而把计价与库存拖入雪崩。防护要点是:入口限流先于下游扩容;超时短于下游默认;失败快速返回 partial;并对同一 query_id 的重复提交做 去重(幂等键 + 短窗缓存)。


30.8 工程实践

30.8.1 性能优化

  • 网关按 用户 / IP / 设备 限流;异常流量对接风控。
  • 压测覆盖:大 filter + 多排序键 + search_afterHydrate 半数超时
  • 零结果率P99 端到端hydrate 超时率 建立 SLO。

热点治理:除店铺维度外,还要关注 超级品牌、超级类目、大促会场 的查询模式是否会把 ES 查询打成「同一 filter 反复出现」的形状。此类热点更适合 边缘缓存查询结果短缓存,并把缓存 key 与 rank_version 绑定,避免实验回滚后缓存污染。对 suggest 接口要单独做 更低配额,否则 App 输入框的每个字符都会放大成 ES 压力。

30.8.2 可观测性

日志与追踪携带 query_idsceneexp_idrank_version;ES 查询记录 归一化 query(注意隐私脱敏)。对慢查询 profile 采样

「可回放」是搜索排障的生命线:建议在测试环境保存 DSL 生成器版本fixture query 集,线上问题能一键回放同一 DSL(脱敏后)到预发集群对比 hits。没有回放能力时,团队只能依赖工程师记忆改写了什么,排障周期会从小时级变成天级。

30.8.3 AB 实验

实验桶进请求上下文;指标按桶对比 CTR / CVR;与排序配置 版本绑定,支持快速回滚与 shadow diff。

发布前自检清单(摘录)

  • 索引别名切换:reindex 完成后一次性切 read_alias,并验证读写两侧别名一致。
  • mapping 变更评审:是否需要全量重建;是否影响排序字段 doc_values
  • 压测:覆盖「大 filter + 多排序键 + search_after」与「Hydrate 半数超时」。
  • 降级开关:ES 故障、hydrate 超时、实验回滚在配置中心可一键切换,并有演练记录。
  • 对账任务:抽样对比 ES 文档版本与商品中心版本,差异进入修复队列。

指标面板建议(与稳定性并列):零结果率、Top query 延迟、hydrate 成功率分依赖、ES 慢查询计数、实验分桶 CTR/CVR、以及 客服价格类工单占比。最后一项能把「体验问题」翻译成管理层听得懂的损失函数。


30.9 本章小结

搜索与导购是电商 读模型工程化 的主战场:统一 scene 与编排 降低系统熵;Elasticsearch 承担召回与部分粗排,但必须与商品、上架、生命周期、计价、库存、营销的 契约 清晰划分;Query → Recall → Rank → Hydrate 主链路上,一致性与体验通过 索引版本化 + Hydrate 限时降级 + 产品话术 组合兜底。与推荐系统保持 目标与架构边界 上的解耦,在特征与实验平台上 复用能力,是多数中大型平台的务实演进路径。

本章答辩总结已统一收录到第 39 章的“电商搜索引擎架构”题卡

演进路线 看,多数团队会经历「ES 直出 → 引入 Hydrate → 引入统一 scene 与 rank 版本 → 引入完整观测与对账」四阶段;每一阶段都能单独带来收益,但不要把四阶段压缩成一次「大爆炸重构」,否则会在大促窗口付出惨痛代价。最稳妥的切分是:先统一排序内核与日志字段,再逐步把易变字段迁出索引。


参考资料

  1. Elasticsearch 官方文档 — 查询 DSL、分页、profile、聚合。
  2. 本书相关章节:商品中心(索引与缓存)、库存系统、营销系统、计价系统、商品供给与运营管理。
  3. 本书相关章节:第 13 章 Elasticsearch、第 25 章商品中心、第 27 章库存系统、第 28 章营销系统、第 29 章计价系统。

第 31 章 购物车与结算

本章聚焦转化漏斗中的意愿暂存交易前置校验两段能力:购物车弱一致、不锁资源;结算页强一致、编排计价 / 库存 / 营销 / 地址,并通过 Saga 补偿幂等键 保证可重入与可回滚。文中 Go 示例为教学裁剪版,落地时请补全超时、观测、注入与错误语义。

阅读提示:若你习惯把「购物车、结算、创单」写在同一个服务里,可以带着三个问题读完全章——第一,预占库存为何不应出现在购物车;第二,试算与扣券为何必须拆开两个系统时刻;第三,拆单预览与真正拆单的边界应落在哪。把这三件事想清楚,就能把本章与第 27 章(库存)、第 29 章(计价)、第 32 章(订单)自然衔接起来。

答辩提示:购物车与结算域的追问答法已统一收录到第 39 章的“购物车结算流程”题卡


31.1 系统定位

31.1.1 购物车与结算域

在典型电商链路 浏览 → 加购 → 结算 → 下单 → 支付 中,购物车域承担「意愿篮」:长期暂存 SKU 与数量、支持跨端查看、允许展示价与可售状态弱一致滞后结算域(Checkout)承担「交易前的最后一次强校验」:价格试算拿到 price_snapshot_id、库存预占拿到 reserve_ids、营销只做可用性校验、地址与运费可短时缓存;用户点击提交后,把上述凭证交给订单系统创单,自身不推进订单状态机、不执行支付。

二者哲学差异可概括为:

维度购物车结算页
一致性弱一致可接受价格 / 库存 / 优惠需实时
资源锁定不锁定预占库存(如 15 分钟)
生命周期可长期保留用完即焚或极短会话
失败策略标记失效、不阻断浏览关键依赖失败应阻断或明确降级

31.1.2 核心职责

购物车服务应内聚的职责包括:匿名 cart_token 发放与校验、登录后合并、Redis 主存储与 DB 异步备份、批量选择与数量修改(乐观锁)、列表 Hydrate(批量读商品、可选读展示价与库存状态)、失效商品标记。不应承担:计价规则、库存预占、券扣减、拆单履约路由。

结算服务应内聚:进入结算时的 Saga 编排(并发试算 / 预占 / 校验 / 地址运费)、提交订单前的 幂等去重、订单创建失败时的 显式释放预占、拆单与运费的轻量预览不应承担:订单持久化与状态机、营销扣券事务、支付渠道路由。

限界上下文(Bounded Context) 视角看,购物车与结算可以部署为两个服务,也可以先合在一个进程里用包级边界隔离,但语言层边界要先立住:购物车领域的聚合通常是「购物车行集合」;结算领域的聚合更接近「一次结算尝试(CheckoutAttempt)」——它甚至不一定要落库,可以以请求上下文 + 外部系统返回的凭证组合存在。把这两个聚合混在一个 Order 聚合根里,是单体时代最常见的腐化起点:你会看到订单服务里长出「顺便改下购物车」的私有 API,最后谁也不敢删。

团队分工建议:购物车更接近 增长与体验团队(关注转化、列表性能、推荐插卡);结算更接近 交易与资金安全团队(关注幂等、补偿、风控)。若组织上同属一个小组,也应在代码评审里用不同的 OWNERS 文件与 SLO 分栏,避免用购物车的发布节奏去承载结算的严谨性,反之亦然。

31.1.3 系统架构

下图给出购物车与结算在全局中的位置,以及读写依赖分层(只读展示 vs 强一致编排)。部署上,购物车服务与结算服务可共享网关与部分中间件,但建议 独立扩容曲线:大促往往是「加购 QPS」先于「结算 QPS」暴涨,混布会让结算的尾延迟拖慢加购。数据库侧购物车备份表与订单库也应物理隔离,避免创单洪峰影响购物车异步刷盘。

flowchart TB
  subgraph user[用户层]
    Web[Web / App]
  end
  subgraph gw[接入层]
    API[API Gateway]
  end
  subgraph domain[购物车与结算域]
    CartS[购物车服务]
    Chk[结算编排服务]
    Wkr[购物车清理 Worker]
  end
  subgraph store[本域存储]
    Redis[(Redis 购物车主存)]
    DB[(MySQL 备份 / 会话可选)]
  end
  subgraph weak[弱一致只读依赖]
    Prod[商品读服务]
    Price[计价展示价 可选]
    InvS[库存状态 可选]
  end
  subgraph strong[强一致依赖]
    Trial[计价试算]
    Resv[库存预占]
    Mkt[营销校验]
    Addr[地址 / 运费]
  end
  subgraph down[下游]
    Ord[订单系统]
    Pay[支付系统]
    Bus[消息总线]
  end
  Web --> API
  API --> CartS
  API --> Chk
  CartS --> Redis
  CartS -.-> DB
  CartS -.-> Prod
  CartS -.-> Price
  CartS -.-> InvS
  Chk --> Trial
  Chk --> Resv
  Chk --> Mkt
  Chk --> Addr
  Chk --> Ord
  Ord --> Pay
  Ord --> Bus
  Bus --> Wkr
  Wkr --> CartS

架构要点:购物车路径以 Redis HASH 为主键模型(cart:{user_id}cart:token:{token}),结算路径以 编排器 为中心。默认推荐 无状态结算:每次进入结算重新试算与预占,前端仅持有上一次的 snapshot_id / reserve_ids 直到提交或超时,这样可以把复杂度压到可接受范围。若产品强需求「刷新页面仍保留勾选与券选择」,可在 27.8 引入轻量 checkout_session 并严格对齐预占 TTL 与快照过期时间,否则极易出现「页面看到的是 A 价、提交时已是 B 价」的认知冲突。

本章显式非目标:不把支付路由、支付渠道对账、订单履约全状态机纳入结算服务;不展开秒杀极端优化(仅在后文工程小节点到为止);不把计价规则引擎、库存 Lua 细节、营销券批次台账重写一遍——这些分别归属第 29、27、28 章及订单第 32 章。

与第 4 章(Saga 总论)的关系:第 4 章给出编排 / 协同、补偿幂等与事件驱动的一般模式;本章把它落到「购物车弱一致 + 结算强编排」这一条具体链路上。你在评审架构时可以用一句话自检:购物车里永远不该出现 Saga,因为那里没有跨系统资源需要一致回滚;结算页几乎必然出现 Saga,因为试算、预占、创单分布在不同限界上下文。


31.2 购物车设计

除「能加购、能合并」外,购物车还需要回答四个体验问题:加购后价格变了怎么办商品下架了怎么办跨端是否一致风控与刷单边界在哪。下面分小节把模型与工程一次说透。

31.2.1 未登录加购

未登录加购的本质是在没有稳定用户主键的前提下,为浏览器会话分配一个可验证、可过期、可合并的购物车标识。推荐由后端签发 cart_token(UUID),前端写入 HttpOnly Cookie 或受控存储,并与 Redis TTL(常见 7~30 天)对齐。

流程要点:首次加购若本地无 token,则调用匿名创建接口,服务端生成 token 并 HSET;后续请求携带 token 走 HINCRBY 或覆盖写入。

sequenceDiagram
  participant U as 用户
  participant F as 前端
  participant C as 购物车服务
  participant R as Redis
  U->>F: 加入购物车
  F->>F: 读取 cart_token
  alt 无 token
    F->>C: POST /cart/anonymous/init
    C->>C: 生成 UUID
    C->>R: HSET cart:token:{token} sku qty
    C-->>F: Set-Cookie cart_token
  else 有 token
    F->>C: POST /cart/add token + sku + qty
    C->>R: HINCRBY cart:token:{token} sku delta
  end
  C-->>U: 成功

服务端应对 cart_token签名校验或存储侧校验,避免伪造 token 横向遍历他人购物车(常见做法:token 即随机高熵 ID,Redis 中不存在则拒绝;或对 token 做 HMAC 绑定设备指纹,视安全等级取舍)。

// AddAnonymousCart 首次匿名加购:创建 token 并写入 Redis
func (s *CartService) AddAnonymousCart(ctx context.Context, skuID int64, qty int) (token string, err error) {
	token = uuid.NewString()
	key := "cart:token:" + token
	if err = s.rdb.HSet(ctx, key, strconv.FormatInt(skuID, 10), qty).Err(); err != nil {
		return "", err
	}
	_ = s.rdb.Expire(ctx, key, 30*24*time.Hour).Err()
	return token, nil
}

安全与滥用面:匿名桶没有账号体系背书,必须配合 频控(同 IP / 同设备加购 QPS)、购物车行数上限(例如单桶 120~200 个 SKU)、以及异常 token 批量探测的风控策略。否则黑产可以用海量 token 刷 Redis 与下游 Hydrate,把商品读服务拖成「另一个 DDoS 入口」。

商品失效在购物车层的语义:购物车不保证「可结算」,只保证「用户曾表达的意愿可追溯」。典型变化与展示策略如下(结算页会再次强校验):

变化购物车展示是否允许去结算
价格上涨 / 下降展示最新参考价 + 轻提示允许尝试进入结算
下架 / 禁售行置灰 + 标签不允许勾选结算
售罄置灰 +「到货提醒」可选不允许勾选结算
SKU 被删除 / 查无此品「商品失效」占位不允许勾选结算

31.2.2 登录后合并

登录合并要解决三类冲突:同 SKU 数量合并不同 SKU 追加业务约束(限购、下架、售罄标记)。合并完成后应失效匿名桶(或保留短 TTL 供排障),并把前端 Cookie 清理或覆盖为用户态。

flowchart TD
  A[登录成功] --> B[读取匿名 cart:token]
  B --> C[读取用户 cart:user]
  C --> D{遍历匿名行}
  D --> E{用户侧是否已有 SKU}
  E -->|是| F[数量相加并限购截断]
  E -->|否| G[追加新行 selected 默认 true]
  F --> H[Upsert Redis + 异步刷 DB]
  G --> H
  H --> I[删除匿名 key 或缩短 TTL]
  I --> J[返回合并结果摘要]
// MergeCart 登录后合并:相同 SKU 数量相加,尊重限购上限
func (s *CartService) MergeCart(ctx context.Context, userID int64, cartToken string) error {
	anonKey := "cart:token:" + cartToken
	userKey := "cart:user:" + strconv.FormatInt(userID, 10)

	pipe := s.rdb.TxPipeline()
	anon, err := s.rdb.HGetAll(ctx, anonKey).Result()
	if err != nil {
		return err
	}
	for skuStr, qtyStr := range anon {
		skuID, _ := strconv.ParseInt(skuStr, 10, 64)
		addQty, _ := strconv.Atoi(qtyStr)
		cur, _ := s.rdb.HGet(ctx, userKey, skuStr).Int()
		newQty := cur + addQty
		if lim := s.limits.MaxQty(ctx, skuID); lim > 0 && newQty > lim {
			newQty = lim
		}
		pipe.HSet(ctx, userKey, skuStr, newQty)
	}
	pipe.Del(ctx, anonKey)
	_, err = pipe.Exec(ctx)
	return err
}

合并冲突的决策表(实现与产品需一致):

场景处理备注
同 SKU数量相加合并后再跑限购
仅匿名有下架 SKU保留并标记让用户手动删
限购截断调到上限并 toast记录审计日志
选中态默认选中新并入 SKU也可继承匿名侧选中态

跨端一致:Web 与 App 只要最终都映射到 user_id 或同一 cart_token,Redis 即单一事实来源;DB 异步略滞后通常可接受。若业务强诉求「一端改数量另一端秒开即见」,可在用户维度加可选的 cart.updated 推送,但不要反向把推送当成库存真相。

31.2.3 Redis + DB 双写

关系库备份层推荐保留「行模型」而非把购物车 JSON blob 一塞了之,便于对账、客服查询与审计。匿名与用户共用一张表时,用 user_id = 0 + cart_token 组合唯一索引:

CREATE TABLE shopping_cart (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL DEFAULT 0 COMMENT '0 表示匿名',
    cart_token VARCHAR(64) DEFAULT NULL,
    spu_id BIGINT NOT NULL,
    sku_id BIGINT NOT NULL,
    quantity INT NOT NULL DEFAULT 1,
    selected TINYINT NOT NULL DEFAULT 1,
    version INT NOT NULL DEFAULT 1,
    added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_user_sku (user_id, sku_id),
    UNIQUE KEY uk_token_sku (cart_token, sku_id),
    INDEX idx_user (user_id),
    INDEX idx_token (cart_token)
) COMMENT='购物车备份表';

不存成交价:购物车行只存 sku_id、数量、选中态等「意愿」,不在行上持久化价格。展示价来自商品标价或计价展示接口;否则一旦促销回溯,你会在库里同时存两种真相,客服与技术将无法争论哪一种才是「用户当时看到的意思」。

推荐主路径:写 Redis 同步成功即对用户返回成功;DB 通过 异步队列延迟批量刷盘 落库,并配 周期对账(例如每 5 分钟扫描变更桶)以防 Redis 丢数据。读路径:优先 HGETALL Redis;miss 时读 DB 回填 Redis。

双写要避免「先 DB 后 Redis」导致的高延迟写路径;也要避免「只写 Redis 永不落库」带来的容灾空洞。工程上常采用 Outbox变更版本号:每次写携带 updated_at / cart_version,Worker 按版本增量同步。

故障切换剧本(建议在运维手册一页纸写清):当 Redis 集群大面积不可用时,购物车服务应能降级到 只读 DB 或只接受写队列暂存 两种模式之一——前者读慢但可用,后者写入排队、返回「稍后在购物车查看」类文案。无论哪种,都要避免「写请求默默丢失」。恢复后应有 回填工具 把 DB 最新版本同步到 Redis,并记录一次对账报告。

对账视角:定期抽样比对 Redis 与 DB 的行数与数量合计,差异超过阈值触发告警。差异来源通常是异步延迟、Outbox 堆积或历史 bug;不要用手工改 Redis「修数据」作为常规手段,除非同时修 DB 并留审计。

// PersistCartItemAsync 异步落库示例:写 Redis 成功后投递 Outbox
func (s *CartService) PersistCartItemAsync(ctx context.Context, userID, skuID int64, qty int) error {
	key := "cart:user:" + strconv.FormatInt(userID, 10)
	if err := s.rdb.HSet(ctx, key, strconv.FormatInt(skuID, 10), qty).Err(); err != nil {
		return err
	}
	return s.outbox.Enqueue(ctx, CartChangedEvent{UserID: userID, SKUID: skuID, Qty: qty, TS: time.Now().UnixMilli()})
}

31.2.4 批量操作

批量全选 / 取消、批量删除、批量改数量,建议提供 单次 RPC 批量接口,减少往返。并发修改数量时使用 乐观锁(DB 表 version 字段)或 Redis Lua 脚本保证「读改写」原子性。

UPDATE shopping_cart
SET quantity = ?, version = version + 1, updated_at = NOW()
WHERE user_id = ? AND sku_id = ? AND version = ?;

RowsAffected = 0,返回冲突码让前端重试或刷新列表。批量接口内部仍可按 SKU 分片并行,但要对总耗时设上限,避免长尾拖垮网关。

购物车列表 Hydrate(只读聚合):从 Redis 取出 sku_id -> qty 后,批量查询商品中心;展示价与库存状态为可选增强。部分失败应 降级为占位文案 而不是整页 500,否则转化率会被技术细节直接打掉。

func (s *CartService) ListVO(ctx context.Context, userID int64) ([]LineVO, error) {
	key := "cart:user:" + strconv.FormatInt(userID, 10)
	raw, err := s.rdb.HGetAll(ctx, key).Result()
	if err != nil {
		return nil, err
	}
	ids := make([]int64, 0, len(raw))
	for k := range raw {
		id, _ := strconv.ParseInt(k, 10, 64)
		ids = append(ids, id)
	}
	prod, _ := s.product.BatchGet(ctx, ids)
	out := make([]LineVO, 0, len(raw))
	for skuStr, qtyStr := range raw {
		skuID, _ := strconv.ParseInt(skuStr, 10, 64)
		qty, _ := strconv.Atoi(qtyStr)
		p := prod[skuID]
		out = append(out, LineVO{SKUID: skuID, Qty: qty, Title: p.Title, Image: p.Image, Shelf: p.Status})
	}
	return out, nil
}

31.3 结算页设计

31.3.1 Saga 编排

结算页是典型的 编排型 Saga(Orchestrated Saga):结算服务作为编排器逐步调用子系统,并在失败时执行逆向补偿(如释放预占)。它不追求 2PC 的强一致提交,而追求 可观测、可补偿、幂等 的业务闭环。

协同式 Saga(Choreography) 相比:结算链路强依赖「用户此刻在结算页」这一交互闭环,需要集中式的超时、降级与错误文案,编排器模式更利于排障与 SLA 治理;协同式更适合订单创建之后、履约与供应商之间那种长链路、多参与方且希望减少中心耦合的场景(第 4 章对比过二者,这里只强调落地选择)。

进入结算 vs 提交订单是两段 Saga:前者可以失败重试、可以部分降级;后者必须短、幂等、尽量少分支。实践中常见反模式是把两段逻辑写进同一个「上帝函数」,导致 Init 阶段的并发优化污染了 Submit 的可证明性。建议代码层拆 CheckoutInitSagaCheckoutSubmitSaga 两个入口,共用领域服务但不同超时与指标。

stateDiagram-v2
  [*] --> Init: 进入结算
  Init --> PricingOK: 试算成功
  Init --> Fail: 试算失败
  PricingOK --> Reserved: 预占成功
  PricingOK --> Fail: 预占失败 / 释放快照无关资源
  Reserved --> Validated: 营销校验完成(可降级跳过)
  Reserved --> Compensate: 致命失败
  Validated --> Ready: 地址运费就绪(可降级默认)
  Ready --> Submitted: 提交订单成功
  Ready --> Compensate: 提交失败
  Submitted --> [*]
  Compensate --> Released: 释放预占(幂等)
  Released --> Fail
  Fail --> [*]

编排顺序的工程权衡:试算与预占可否并行?若营销结果影响可售组合,可能需要串行;默认实践中常见做法是 试算与预占并行以换取时延,失败时按依赖关系补偿:若试算失败但预占已成功,应释放预占;若试算成功预占失败,一般无需回滚试算(快照由计价系统管理生命周期)。下图给出进入结算阶段的并发扇出。

sequenceDiagram
  participant U as 用户
  participant O as 结算编排器
  participant P as 计价试算
  participant I as 库存预占
  participant M as 营销校验
  participant A as 地址运费
  U->>O: InitCheckout(cart, address, coupons)
  par 扇出
    O->>P: Trial(scene=checkout)
    O->>I: Reserve(TTL=900s)
    O->>M: ValidateCoupons
    O->>A: ListAddress + Freight
  end
  P-->>O: snapshot_id + 明细
  I-->>O: reserve_ids
  M-->>O: 可用券列表(可空)
  A-->>O: 运费(可默认)
  O-->>U: 结算页聚合结果

31.3.2 价格试算

结算页必须调用计价中心的 试算接口scene=checkout),拿到 应付总额、分项明细、快照 ID 与过期时间。购物车列表上的价格只能是「参考价」,产品话术需统一为 「以结算页为准」,否则客服与舆情成本极高。

试算失败属于 P0 阻断:不允许进入可提交状态。可选优化是快照过期后由订单系统二次校验或拒绝创单,但不应在结算页静默使用陈旧价。

触发重新试算的事件(与前端埋点一一对应,便于解释「为什么总价跳了」):

事件是否必须重算说明
首次进入结算建立基准快照
切换收货地址通常要运费与可达店铺集合可能变化
切换 / 取消优惠券影响分层抵扣
修改数量(仍在结算页)行金额与门槛类活动联动
仅切换发票抬头视税制可能不影响含税价

快照过期的产品策略:常见做法是快照 30~60 分钟内有效,过期提示用户刷新;订单系统在创单时再做一次 硬校验,防止「结算页停留过久」绕过。不要试图在结算服务内「续命」快照,那会把计价系统的版本语义搅浑。

31.3.3 库存预占

预占解决的是「从结算到支付窗口内库存被抢走」的体验与超卖风险。预占时长常用 900 秒,由库存服务维护 TTL 与释放任务;结算服务在 订单创建失败 时显式调用 release-reserve,避免等待 TTL 造成的资源浪费。

预占与试算的失败组合处理见 27.6 节补偿表。核心原则:结算页不实现扣减,只持有 reserve_ids 凭证。

用户在结算页改数量:应走「释放旧预占 → 按新数量重新预占」的两段调用,中间态要对前端屏蔽或短锁按钮,避免双份预占。若释放成功而重新预占失败,应整体回退到「请返回购物车重选」的确定语义,而不是半提交。

stateDiagram-v2
  [*] --> 可售
  可售 --> 预占中: Reserve
  预占中 --> 已扣减: ConfirmReserve
  预占中 --> 可售: TTL 到期或 Release
  已扣减 --> [*]: 关单回补等由订单域处理

31.3.4 营销校验

结算页调用营销 只读校验:判断券是否可用、圈品是否命中、互斥规则是否满足。不扣券。扣券放在订单创建事务路径(或订单 Saga 的下一步),避免「结算扣券成功、创单失败」带来的复杂回滚与客诉。

营销超时可 降级:隐藏优惠入口,以原价试算结果继续(需产品同意);若业务不允许无券结算,则应阻断。

券在结算与订单之间的「两段式」价值:结算阶段输出的是 可解释性(为什么这张券灰掉),订单阶段输出的是 事实(券批次余额少了一次)。中间没有第三段「半锁定券」,除非你单独引入锁券服务——那会把领域模型再劈一叉,一般不值得。

可选:有状态结算会话(复杂度权衡):默认仍建议无状态;若产品要求「刷新保留勾选与券」,需要额外持久化会话,并与预占 TTL、快照过期严格对齐,否则会出现「页面展示与提交凭证不一致」。表结构示例见 27.8.3。


31.4 拆单与地址运费

31.4.1 拆单预览

拆单维度通常包括:跨店铺跨仓自营 / POP不同履约 SLA。结算页只做 split-preview:返回预计子单分组、每组 SKU、预估运费与送达时间;不生成子订单 ID,不调重度履约路由。

预览要回答的用户问题是「我会收到几个包裹、各自多少钱」,而不是「仓库拣货路径怎么走」。因此预览计算应使用 与创单一致的拆分规则版本号(例如 split_ruleset=2026Q2),在响应里透传;当订单系统发现规则升级导致结果变化时,可以返回可读错误码,让用户刷新结算页,而不是静默改单。

对于 同一店铺多仓可发 的场景,预览可能给出「可能拆」的灰色提示:真正选仓在订单或履约系统完成,预览只基于默认策略做估计。产品文案上建议用「预计」二字,技术文档里要写清楚 估计误差允许的边界,避免法务与客服在「预览两包裹实发合一」场景下无解。

性能:拆单预览输入是购物车选中行的结构化列表,复杂度通常在 O(n)O(n log n)(按店铺、类目排序);不要在预览里调用供应商实时询价类接口,否则结算页会被第三方 SLA 绑架。需要供应商参与的场景,应折叠为「下单后再确认」的异步路径,并在结算页显著提示。

flowchart LR
  subgraph cart[购物车选中行]
    s1[SKU1 shopA]
    s2[SKU2 shopA]
    s3[SKU3 shopB]
  end
  subgraph pv[拆单预览]
    o1[预览单1 shopA 运费 f1]
    o2[预览单2 shopB 运费 f2]
  end
  s1 --> o1
  s2 --> o1
  s3 --> o2

预览接口建议由 订单域 提供只读计算(与真正拆单共享规则内核),避免结算域复制一套拆单逻辑。

31.4.2 地址选择

地址列表由用户域或履约子域提供。结算页缓存默认地址 ID,切换地址时触发 运费重算 与可选的 试算重算(运费是否进快照取决于计价模型)。需防止用户用「切换地址」刷爆运费服务:对 (user_id, address_id, cart_hash) 做频控与短 TTL 缓存。

跨境与身份证 / 通关信息:若地址切换会触发额外字段(实名、税号),不要把敏感信息长期缓存在结算会话里;遵循最小留存原则,提交创单时一次性写入订单快照或合规存储。地址校验失败(不可达、风控拦截)应区分「硬失败」与「软提示」:硬失败直接阻断;软提示允许用户继续但要在支付前再次确认。

默认地址漂移:用户可能在结算过程中于「地址管理页」修改默认地址。结算服务应以 进入结算时锁定 address_id 为主策略;若产品要求实时联动,需要 WebSocket 或轮询刷新,并重新跑试算与预占,复杂度会迅速上升——这是有状态会话最容易踩的坑之一。

31.4.3 运费计算

运费计算输入至少包含:地址结构化信息店铺维度SKU 体积重量模板促销包邮规则。缓存 Key 示例:freight:{address_id}:{cart_hash},TTL 20~60 秒。购物车变更或地址变更必须使 cart_hash 失效。

运费与试算的关系要在一开始就写进契约:如果运费进入计价快照,则切换地址必须同时触发试算 + 运费;如果运费独立,则订单系统创单时也要携带运费版本号,否则会出现「结算看到 10 元运费、订单变成 12 元」的纠纷。B2B2C 下常见是 计价统一收口的应付金额 已含运费,这时地址服务只作为试算的输入因子,而不是第二套计算器。

拆单与运费的耦合:跨店场景下,预览接口宜返回 按店铺分组的运费数组,前端展示「每店一笔运费」;不要在前端把多段运费硬加成单一标量,否则与后续子订单对账困难。冷链、大件、送货上门加价等,可作为 运费模板扩展字段 由地址 / 履约服务解释,结算域只展示结果不做规则。


31.5 系统边界与职责

31.5.1 购物车域与结算域

能力购物车域结算域
暂存 SKU / 数量否(用购物车快照或请求体)
展示 Hydrate仅必要时复用
试算 / 快照否(可选展示价)
预占
营销扣减否(仅校验)

31.5.2 结算与订单

结算服务在提交阶段只做三件事:幂等闸门、组装创单请求、调用订单 Create。订单系统负责:真正拆单、写订单与明细、确认预占转扣减、扣券、发布 order.created 事件。结算服务不应写订单表,也不应持有订单状态机。

为何不能把「创单」继续留在结算服务里:短期看少一次 RPC,长期看你会得到「结算发布 order.created、订单服务也发布 order.created」的双头龙,消费者不知道以谁为准;更糟的是版本升级时,两个团队对「部分失败是否算创单成功」理解不一致,线上会出现只有结算库有记录、订单库没有的幽灵交易。边界一旦划给订单,就要让订单成为 订单事实的唯一写入者

BFF(Backend for Frontend)与结算编排器的分工:移动端 BFF 可以做字段裁剪、聚合多个读接口、甚至缓存用户地址列表;但不要把试算与预占藏在 BFF 里「顺便算一下」,否则 Web 与 App 会各自实现半套结算逻辑。推荐做法是 BFF 薄、结算编排厚、领域服务更厚。

31.5.3 资源锁定的归属

资源锁定发生地释放 / 确认
库存预占结算进入时TTL 自动释放;创单确认;失败显式释放
价格快照计价系统生成订单校验快照有效性
营销券未锁定订单创建时扣减

31.5.4 谁负责拆单预览

建议归属 订单域只读 API(或拆单内核库被订单服务托管)。结算域仅编排调用。若预览放在结算服务内,极易与履约变更耦合,出现「预览两单、创单变三单」的舆情风险——需版本化规则与免责声明。

反模式速查(评审清单可直接复用):

反模式为何糟糕正确方向
购物车预占库存长期占用,利用率差只在结算预占
购物车存成交价与促销回溯冲突行上不存价,展示时拉价
结算页扣券创单失败要回滚券订单扣券
结算页内嵌拆单履约变更面爆炸预览与真正拆单分离
结算服务写订单表双写一致性与职责越界只调订单 API

31.6 与其他系统集成

31.6.1 与订单系统衔接(提交订单)

创单请求应携带:idempotency_keyuser_idcart_itemsprice_snapshot_idreserve_idscoupon_idsaddress_idshipping_method。订单系统内部再驱动库存确认与营销扣减(详见第 32 章)。

边界:结算服务 不得 根据创单结果去修改订单状态;支付 URL 的拼装可以放在 BFF,但支付单创建仍应由支付域根据订单事实驱动。结算返回给前端的应是 订单 ID + 下一步跳转参数,而不是「假装自己是订单库」。

func (s *CheckoutService) Submit(ctx context.Context, r SubmitRequest) (*SubmitResult, error) {
	orderID, err := s.submitOnce(ctx, r.UserID, r.IdempotencyKey, func(c context.Context) (string, error) {
		return s.orders.Create(c, CreateOrderDTO{
			UserID: r.UserID, Items: r.Items, SnapshotID: r.SnapshotID,
			ReserveIDs: r.ReserveIDs, Coupons: r.Coupons, AddressID: r.AddressID,
		})
	})
	if err != nil {
		_ = s.inv.Release(context.Background(), r.ReserveIDs)
		return nil, err
	}
	return &SubmitResult{OrderID: orderID}, nil
}

31.6.2 与计价系统集成(价格试算)

结算只认计价返回的 price_snapshot_id 与过期时间;不在本地拼接促销表达式。

契约要点:试算请求应携带 场景枚举用户身份地址因子已选券列表购物车行;响应必须包含 可审计明细快照过期时间。结算服务侧禁止缓存「最终应付」超过秒级,否则与风控频控冲突。

31.6.3 与库存系统集成(库存预占)

调用 POST /inventory/reserve,设置 expire_seconds;保存返回的 reserve_ids[] 直至创单成功或失败释放。

幂等与重试:预占接口在超时重试场景下必须由库存侧保证 同一业务重放键 不产生双倍占用(常见做法是基于 user_id + checkout_tracerequest_token 去重)。结算侧则要把 reserve_ids 当作 opaque handle,不在本地推断库存数量。

31.6.4 与营销系统集成(优惠校验)

调用 validate-coupons;返回不可用原因用于前端提示。扣减走订单。

购物车域为什么不调用营销:购物车阶段引入营销,会把「意愿篮」变成「半个交易」,用户未表达购买意图就要承担券解释成本;更麻烦的是券规则与圈品频繁变更,购物车 Hydrate 会变成 O(N×规则) 的热点路径。

31.6.5 集成调用链路与补偿

下图从「进入结算」到「提交订单」画出主路径与补偿关注点(虚线为异步或失败回退)。

flowchart TB
  subgraph client[客户端]
    FE[结算前端]
  end
  subgraph checkout[结算域]
    CH[Checkout Orchestrator]
    IDM[幂等闸门 Redis]
  end
  subgraph deps[依赖系统]
    PR[Pricing Trial]
    IV[Inventory Reserve]
    MK[Marketing Validate]
    AD[Address Freight]
    OR[Order Create]
  end
  FE -->|Init| CH
  CH --> PR
  CH --> IV
  CH --> MK
  CH --> AD
  FE -->|Submit| CH
  CH --> IDM
  IDM -->|首次| OR
  OR -.->|失败释放| IV
  CH -.->|补偿调用| IV

补偿表(节选)

失败点已完成补偿
试算失败可能已预占释放预占
预占失败试算成功无需释放价快照
创单失败预占在释放预占;营销未扣券则无需回券
支付超时订单进入关单流由订单 / 库存回补(不在本章展开)

购物车域边界表(只读展示)

下游调用购物车不做
商品中心POST /product/batch-get不缓存详情、不判定可售真相
计价(可选)batch-display-price不锁价、不算复杂规则
库存(可选)batch-status不预占、不扣减
营销不调用不算券

结算域边界表(强一致编排)

下游调用结算不做
计价trial-calculate不实现规则引擎
库存reserve / 触发 release不确认扣减
营销validate-coupons不扣券
地址list + freight/calculate不持久化地址
订单create不拆单、不推进状态机

31.6.6 Saga 编排实现

用 Go 的 errgroup 控制并发与 context.WithTimeout 控制尾延迟,再在汇聚点做决策:

func (s *CheckoutService) InitCheckout(ctx context.Context, req InitRequest) (*InitResult, error) {
	g, ctx := errgroup.WithContext(ctx)
	var trial *TrialResult
	var resv *ReserveResult

	g.Go(func() error {
		c, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
		defer cancel()
		r, err := s.pricing.Trial(c, TrialInput{UserID: req.UserID, Items: req.Items, Scene: "checkout"})
		if err != nil {
			return err
		}
		trial = r
		return nil
	})
	g.Go(func() error {
		c, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
		defer cancel()
		r, err := s.inv.Reserve(c, ReserveInput{UserID: req.UserID, Items: req.Items, TTL: 900 * time.Second})
		if err != nil {
			return err
		}
		resv = r
		return nil
	})
	if err := g.Wait(); err != nil {
		if resv != nil {
			_, _ = s.inv.Release(context.Background(), resv.IDs)
		}
		return nil, err
	}
	return &InitResult{SnapshotID: trial.SnapshotID, Payable: trial.Payable, ReserveIDs: resv.IDs}, nil
}

提交阶段保持 单线程顺序:幂等 → 创单 → 返回 order_id

可观测性补充:为每一次 InitCheckout 生成 checkout_trace_id,贯穿所有下游 RPC 的 baggage;在日志中打印各依赖耗时直方图标签(pricing_msreserve_ms 等)。Submit 路径额外打印 idempotency_key 与返回 order_id。这样当「只有某个地区的用户预占失败率升高」时,你可以快速判断是库存分片热点还是地址服务区域路由问题。

集成测试建议:至少三类用例要在 CI 里跑通:Init 成功 + Submit 成功Init 成功后订单返回冲突(模拟幂等)Init 成功后订单失败触发释放。第四类 Init 部分依赖超时 可以放在 nightly,以免拖慢 PR 流水线,但不能没有。


31.7 幂等性与去重

31.7.1 idempotency_key 设计

推荐由前端生成 UUIDv4 作为 Idempotency-Key 请求头或 JSON 字段,并在用户点击「提交订单」的第一次交互即固定,重试与自动重连复用同一键。服务端在结算网关或结算服务使用 Redis:

SET idempotency:{user_id}:{key} -> processing NX EX 120

成功后写入 order_id 作为值;重复请求直接返回缓存结果。订单表保留唯一索引 (user_id, idempotency_key) 作为最终兜底。

键空间建议包含 user_id,避免跨用户碰撞;TTL 覆盖「用户犹豫 + 网络抖动」窗口即可。

键的生命周期与返回语义:第一次提交进行中时,Redis 里可以是 processing 占位;成功后写入 order_id 并延长 TTL,重复请求应返回 同一 order_id同一支付跳转参数,HTTP 层可用 200409+业务体,但务必前后端约定一致。若创单失败删除了 Redis 键,客户端重试会生成新 UUID——这是允许的,但要评估「用户连点导致多笔预占」的极端情况;更好的 UX 是在失败提示里保留「重试同一单」入口,由前端复用旧键。

与订单系统幂等的叠床架屋是否有必要:有必要。网关 Redis 去重解决 极短时间窗内的风暴重放;数据库唯一索引解决 跨进程、跨机房、Redis 丢失 的慢变量问题。二者不是重复建设,而是不同时间尺度的防线。评审时如果有人问「只留 DB 行不行」,答案是行,但你会在高峰期看到大量创单请求把订单库打满冲突重试;「只留 Redis 行不行」,答案是也行,直到某次故障切换丢键。

func (s *CheckoutService) submitOnce(ctx context.Context, user int64, key string, fn func(context.Context) (string, error)) (string, error) {
	rk := fmt.Sprintf("idem:%d:%s", user, key)
	ok, err := s.rdb.SetNX(ctx, rk, "processing", 2*time.Minute).Result()
	if err != nil {
		return "", err
	}
	if !ok {
		return s.rdb.Get(ctx, rk).Result()
	}
	orderID, err := fn(ctx)
	if err != nil {
		_ = s.rdb.Del(ctx, rk).Err()
		return "", err
	}
	_ = s.rdb.Set(ctx, rk, orderID, 24*time.Hour).Err()
	return orderID, nil
}

31.7.2 重复提交防护

三层组合:前端按钮禁用 + 请求级幂等键 + 订单唯一索引。仅依赖前端不可靠;仅依赖 Redis 可能因过期导致双单,因此 DB 唯一约束不可或缺。

移动端弱网:重试库(例如自动重放 POST)必须与业务幂等键协同,否则会在用户无感知的情况下放大写压力。建议移动端网络层对 写操作 默认关闭盲重试,或仅在收到明确可重试错误码时重放,并始终携带同一 Idempotency-Key

网关层去重与业务层去重的边界:API Gateway 可以做粗粒度 IP + path 频控,但不要把「业务幂等」全部交给网关规则引擎;网关不知道 reserve_ids 是否已被使用,也不知道订单是否已支付。网关负责 削峰,结算与订单负责 正确性

31.7.3 补偿机制

补偿分 自动显式:库存 TTL 属于自动;创单失败触发结算服务显式 release。所有释放接口必须 幂等,重复调用不产生副作用。补偿任务应记录 结构化日志 + metric,便于统计「创单失败率 × 预占释放成功率」。

补偿与重试的观测字段:建议在日志与 Trace 中固定携带 checkout_trace_id(一次 Init 生成)、idempotency_keyreserve_ids 哈希、snapshot_id。当客服工单进来时,可以分钟级还原「当时为什么失败」,而不是靠 grep 多台机器。

订单创建后的购物车清理:推荐消费 order.created 事件异步删除已购 SKU,且以 order_id 做消费幂等。清理非强一致:即使延迟,用户最多看到「购物车还多一件已买商品」,用 UI 提示即可,不应阻塞支付跳转。

func (w *CartCleaner) OnOrderCreated(ctx context.Context, e OrderCreated) error {
	if ok, _ := w.idem.Seen(ctx, "cart_clean", e.OrderID); ok {
		return nil
	}
	for _, it := range e.Lines {
		_ = w.rdb.HDel(ctx, "cart:user:"+strconv.FormatInt(e.UserID, 10), strconv.FormatInt(it.SKUID, 10)).Err()
	}
	return w.idem.Mark(ctx, "cart_clean", e.OrderID, 7*24*time.Hour)
}

31.8 工程实践

31.8.1 性能优化

  1. 购物车列表 Hydrate 使用 批量接口,商品中心一次拉全 SKU;可选并行拉取展示价与库存状态。
  2. 结算 Init 使用 errgroup + 独立超时;对非关键依赖(营销、地址)允许降级。
  3. 热点用户桶考虑 Hash Tag(Redis Cluster 场景)与本地微缓存(谨慎,防击穿)。

购物车写放大HINCRBY 是 O(1),但每一次加购若都同步触发 DB Outbox,会在大促预热期形成写放大。常见做法是 合并窗口(200ms 内多次变更合并为一条 Outbox)或按用户维度微批刷盘。读放大主要来自 Hydrate:务必限制 sku_ids 批量大小(例如每页 50),并对商品中心失败做部分成功返回,避免整页超时。

结算页 P99 与转化率:经验上,结算 Init 超过 1.5s 会显著伤害「进入结算 → 提交」转化。除并发扇出外,还应检查 是否无意中串行化了可并行步骤(例如把拆单预览放在试算之前且强依赖网络);更隐蔽的是 大 JSON 响应体前端重复渲染 造成的体感慢,这要靠前端性能与网关压缩共治。

31.8.2 转化漏斗监控

建议埋点维度:scene(搜索 / 活动 / 推荐)、deviceregion。核心比率:加购率、进入结算率、结算 Init 成功率、提交成功率、支付成功率。对 幂等拦截率 单独监控:异常升高可能意味着前端重复提交或网络重试策略错误。

分层漏斗与告警:除全站均值外,建议对 新客 / 沉默唤醒 / 高客单 分桶,否则会被大盘平均掩盖。告警上至少拆三条:结算 Init 错误率、预占失败占比、创单失败占比——三者根因不同,混在一个「下单失败率」里会排障困难。

与业务运营协同:漏斗面板应能下钻到 错误码分布(库存不足、券不可用、地址不可达、快照过期),否则运营只会看到「转化率掉了」,技术只会说「系统没挂」。把错误码映射到「可行动项」(补货、调整券门槛、修正运费模板)是平台化团队的工作方式。

31.8.3 降级策略

依赖故障策略风险
营销隐藏优惠客单价下降
地址使用默认地址错发风险需产品接受
计价 / 库存不建议静默继续体验与资损

大促期间可启用 排队结算削峰队列,把 Submit 变异步(需改变产品交互,谨慎)。

结算依赖超时与重试(落地参考)

依赖超时重试说明
计价试算700~900ms0~1 次失败即阻断
库存预占400~600ms1 次注意幂等键
营销校验250~350ms0 次可降级
地址运费150~250ms0 次可默认地址

有状态结算会话表(可选)

CREATE TABLE checkout_session (
  session_id VARCHAR(64) PRIMARY KEY,
  user_id BIGINT NOT NULL,
  cart_snapshot JSON,
  price_snapshot_id VARCHAR(64),
  reserve_ids JSON,
  address_id BIGINT,
  expires_at TIMESTAMP,
  INDEX idx_user (user_id),
  INDEX idx_expires (expires_at)
);

启用会话时,要在 expires_at 到达后 主动释放预占 或依赖库存 TTL,并在前端显著提示「剩余有效时间」。

Worker 清单:Redis → DB 购物车增量同步;匿名桶过期清理;order.created 购物车清理;预占释放巡检(备份补偿)。每一项都要有 可观测执行次数与失败率


31.9 本章小结

购物车与结算域分别回答 「想买什么」「现在能不能买」 两个问题:前者弱一致、不锁资源,以 Redis + DB 双写与匿名合并保障体验;后者以 Saga 编排把计价试算、库存预占、营销校验、地址运费组合为可提交凭证,并通过幂等键与补偿释放保证韧性。清晰划分 拆单预览 vs 真正拆单试算 vs 扣券结算 vs 订单状态机,是避免边界腐化的关键。

如果把全章压成三条工程戒律,它们分别是:购物车 never lock结算 always orchestrate with timeoutssubmit always idempotent end-to-end。前两条保证体验与资源利用率,最后一条保证「用户只点一次,系统只落一单」这一最低限度的交易正义。

与全书其他章节的衔接:库存预占细节见第 27 章;计价与快照见第 29 章;订单创建、拆单与状态推进见第 32 章;支付见后续支付章节。

最后一页检查清单(发布前自问):是否在 PRD 里写清了「价格以结算为准」;是否在接口契约里禁止购物车预占;是否在订单创单接口上强制 idempotency_key;是否为 release-reserve 写了幂等测试;是否在监控里拆分 Init 与 Submit 的成功率;是否为大促准备了预占 TTL 与线程池隔离参数。六项都打勾,这一章才算真正「从文章走进了系统」。若还能补充一页 故障演练剧本(依赖逐个超时、Redis 丢键、订单重复返回),团队在真实大促里会少很多「第一次见」的慌乱。


延伸阅读与引用

  • 本书第 4 章:Saga 与幂等通用模式。
  • 本书第 27 章:库存预占、确认与释放。
  • 本书第 29 章:试算场景与快照校验。
  • 本书第 32 章:订单创建与分布式事务实践。
  • 外部参考:Microsoft Azure Architecture Center — Saga pattern;Redis Hashes 文档。

落地阅读顺序建议:先读第 29 章理解「快照从哪来」,再读第 27 章理解「预占与确认的语言」,最后读第 32 章看「订单如何把券与库存变成事实」。本章处在三者的交汇处:最容易写成「什么都能调一点的脚本服务」,也最考验你是否坚持用 编排 + 凭证 + 幂等 把复杂度关在门内。读完若只能记住一句话,建议记住:购物车是缓存意志,结算是换取凭证,订单是写下事实——三者顺序不可倒置;任何把「事实」前移到购物车或结算持久层的 shortcut,都会在客诉与对账里连本带息还回来,务必警惕为好。

第 32 章 订单系统

本章定位:订单系统是交易链路的编排中枢。本章覆盖数据模型、状态机、创单 Saga、分布式事务(TCC / Saga)、幂等、特殊单类型、履约与系统边界;Go 示例为教学裁剪版,落地时请补全观测、鉴权与错误包装。

阅读提示:若你已读完第 29 章计价与第 31 章结算,可带着三个问题阅读——第一,订单快照与计价快照如何对齐版本;第二,库存预占发生在结算还是创单,失败时谁补偿;第三,支付域与订单域的状态机如何解耦又不丢一致性。搞清边界后,订单服务才不会长成「上帝对象」。

与第 34 章案例的衔接:在 B2B2C 聚合平台中,订单往往还要承载 供应商订单号、供应商错误码、重试策略版本 等跨域信息;本章给出的是「平台自营电商」的主干模型,落地到机票、酒店等品类时,应把供应商差异收敛到 履约网关与扩展表,避免主表字段爆炸。第 34 章从全景角度回顾订单在微服务拓扑中的位置,可与本章边界小节交叉阅读。


第一步:订单系统向商品中心发起“静态合规强校验”(RPC 同步) 这个校验是商品中心做的,还是 订单中心做的

这是一个在微服务架构设计中非常经典的“业务主权”与“职责边界”辩题。

一句话给出硬核结论:校验的“规则逻辑”是由商品中心研发并提供的,但校验的“执行物理动作”是由订单系统发起,商品中心配合完成的。这是一个典型的“分布式协同强校验”。

我们不能孤立地说是哪一个中心“独立”做的。在工业级设计中,它们各自握有一部分主权,通过 RPC 接口契约 紧密协作。

以下是它们在下单黄金链路上的具体职责分工与底层交互逻辑:

🤝 职责切分:谁提供规则?谁执行判断? 校验维度 🛍️ 商品中心的职责(主权方/规则提供者) 🛒 订单系统的职责(发起方/执行驱动者)

  1. 状态校验

(上架/下架/封禁)

拥有主权:商品中心定义了商品的生命周期状态机。它在本地维护并实时知道商品到底是 ON_SHELF 还是 BANNED。 无感知:订单系统不认识商品的内部状态,它只负责在下单的瞬间,把 Goods_ID 传给商品中心,问一句:“这个商品现在能卖吗?” 2. 价格验签

(防黑产恶意改价)

拥有主权:商品中心是商品售卖价(Master Price)的唯一权威数据源。 防腐拦截:用户在前端看到的可能是缓存价格,订单系统在创建订单快照时,必须拿前端传过来的价格去撞商品中心的真实价,一旦不一致,立刻阻断。 3. 限购校验

(单人限购2件)

规则定义:商品中心负责存储该 Deal 商品的限购配置(如:max_buy_limit = 2)。 流水判定:商品中心只知道“规则是限购2件”,但不知道当前用户之前买没买过。订单系统需要拿着这个规则,去反查自己的历史订单流水表,计算出该用户已购数量,最终判定是否超限。 ⚙️ 工业级 RPC 交互时序图(看清谁在动) 为了彻底看清这个强校验是怎么完成的,我们还原下单瞬间的 毫秒级 RPC 交互流:

C端用户 点击【提交订单】,带上参数:{user_id: 999, goods_id: 888, buy_count: 3, front_price: 99.00}。

订单系统 接收到请求,开启分布式事务,作为核心驱动者,它第一步必须向商品中心发起 RPC 强校验请求:

productReadService.validateForOrder(ValidateDTO);

商品中心 收到 RPC 请求,在自己的高可用缓存/库中执行它的主权校验逻辑:

查库发现:goods_id: 888 的状态是 ON_SHELF(通过 ✅)。

查库发现:真实系统价格是 99.00,与前端传来的 front_price 一致(通过 ✅)。

查库发现:该商品配置了限购规则:max_buy_limit = 2。

返回响应:商品中心将状态、价格校验结果以及限购规则包装成 ValidateResult 吐回给订单系统(告知:状态价格OK,但限购规则是2)。

订单系统 接过接力棒,继续在本地执行后续校验:

订单系统发现用户想买 3 件,但商品中心返回的规则是限购 2 件(3>2)。

订单系统就地熔断,抛出异常 OrderException(“超出商品限购次数”),直接回滚事务,拦截下单。

🎙️ 评审会一锤定音的架构共识: 在技术评审时,如果团队为了图省事,想把逻辑搞混,你可以用以下两个微服务架构铁律来定调:

  1. 为什么订单系统不能自己查商品库来校验?(禁止跨库查询) 绝对禁止订单系统直接去 SELECT * FROM product_item_tab。因为商品中心的类目、状态模型极其复杂且频繁重构。一旦订单系统直接读商品中心的表,两个微服务就变成了“数据库级别的强耦合”。商品中心改一个字段,订单系统就会直接挂掉。订单系统必须通过商品中心提供的 RPC 接口来获取结论。

  2. 为什么商品中心不能直接帮订单系统把限购也校验了?(禁止职责越界) 商品中心无法独立完成限购校验。因为校验限购需要知道“该用户过去下过多少个成功的订单”。订单流水数据属于订单中心的专属资产。如果让商品中心去校验,商品中心就必须反向调用订单系统的库,这会导致微服务之间发生可怕的“循环依赖(Circular Dependency)”,系统会直接陷入死锁和架构蠕变。

💡 最终结论: 商品中心做“商品身份与价格合规”的校验,订单中心做“交易行为与流水合规”的校验。 订单系统是整条链路的总指挥(Saga编排者),它调用商品中心的 RPC 接口获取商品的主权结论和规则,再结合自己的订单资产,最终共同完成了这记精妙的“静态合规强校验”

32.1 系统概览

32.1.1 业务场景

订单系统连接用户、商品、库存、计价、营销、支付、履约与售后,典型职责包括:

  • 创单编排:校验、拆单、落库、驱动库存 / 营销等资源侧执行;
  • 状态管理:主单状态机与子域(支付、履约、售后)协同;
  • 事件发布OrderCreatedOrderPaid 等驱动异步履约与数据分析;
  • 可追溯:行级快照、金额快照、状态流水满足审计与对账。

订单系统负责流程编排与订单事实的持久化;不负责库存原子扣减实现、支付渠道路由、物流轨迹计算——这些属于各自限界上下文。

从用户旅程看,订单是「一次购买意图」在系统中的物化载体:购物车表达意图,结算页收敛约束,订单把约束写成不可抵赖的事实(谁、在什么时间、以什么价格、买了什么、用了哪些权益)。因此订单域的 API 设计应偏向命令式与幂等CreateOrderCancelOrder),而不是把计价规则或库存脚本再暴露一遍。否则网关层会出现大量「为了创单临时拼出来的 DTO」,后续每次改营销口径都要联动发版。

在多租户或 B2B2C 场景下,订单还要携带租户、店铺、销售渠道等维度,这些字段会直接影响分库键、对账口径与发票主体。建议在模型早期就固定「主单维度集合」,避免后期把店铺 ID 塞进备注字段。对客服与风控而言,订单号是串联各系统的公共关联键,应在日志规范中强制全链路透传。

32.1.2 核心挑战

挑战表现设计抓手
高并发大促创单峰值、热点用户分库分表、异步削峰、限流熔断
一致性跨库存 / 营销 / 计价多写Saga + 补偿表 + 对账
状态复杂主状态 + 子流程并行子状态机 + 显式事件 + 审计日志
类型多样实物、虚拟、O2O、预售策略模式 + 扩展点
幂等重试、回调、用户连点幂等表 + 状态机 + 业务唯一键
可追溯客诉、风控、财务快照版本、Outbox、状态历史

组织层面的补充:订单团队常被拉去「顺便」做营销试算或支付路由,短期能救火,长期会造成循环依赖与发布耦合。建议在架构评审中把「编排」与「计算 / 执行」拆开画依赖图:订单只依赖稳定的 RPC 契约,不依赖对方存储模型。

容量视角:订单创建往往是洪峰「汇聚点」——上游购物车、结算已经做过一次编排,创单仍需在短时间内完成多次 RPC。此时瓶颈常在下游库存热键、营销锁券、DB 事务持锁时间。压测时应区分「创单接口自身 QPS」与「端到端成功率」,并单独观测 Saga 每步 P99补偿队列深度,否则容易出现「接口没超时但用户看到一直转圈」的体验问题。

一致性视角:订单域最容易犯的错误,是把「用户看到的状态」与「内部资源状态」混在一张表里频繁互转。更稳妥的做法是:主单状态只表达对用户承诺的阶段;资源是否释放、券是否退回,由子域与补偿任务保证,主单只记录结果事件。这样客服解释成本更低,报表口径也更稳定。

32.1.3 系统架构

订单域内部可拆为:Order Core(创单与查询)Order State(状态机服务)Fulfillment Orchestrator(履约编排,可与核心同进程或独立部署)Projection Worker(读模型 / 搜索同步)。存储上 MySQL 承载权威订单数据,Redis 做详情缓存与短时防重,Kafka 承载领域事件;搜索侧可由 Elasticsearch 异步投影。

安全与合规:订单表包含地址、电话等个人信息,需按最小权限原则控制导出接口;对客服脱敏展示与完整审计应分角色授权。对外部合作伙伴(如供应商、物流)同步订单数据时,应通过 网关字段级裁剪 而非直接暴露内部宽表。日志中禁止打印完整支付卡号与证件号,必要时使用 tokenization 后的引用 ID。

多活与容灾:订单写路径强依赖主库可用性,跨机房多活通常采用 单元化 + 用户分片路由主从切换 策略;事件总线需配置跨集群复制与 消费者位点备份。演练时要验证:主库故障切换后 Outbox 是否重复投递、消费者是否幂等。

版本化 API:订单查询接口应对外标注 响应 schema 版本,并在字段弃用时保留兼容期;创单请求亦应携带 客户端版本号,便于回溯「哪一版 App 产生了异常参数」。在契约测试中,把 错误码表幂等语义 一并纳入回归范围,可显著降低联调返工率。

成本与存储治理:订单明细与快照会随时间线性增长,需制定 冷热分层(热数据 SSD、冷数据归档到对象存储)与 压缩策略(对大 JSON 快照启用压缩列或拆表)。同时评估 法务保留周期,避免无限期囤积个人地址数据带来合规风险;到期匿名化应与状态机终态联动触发。

开发者体验:订单域建议提供 本地夹具(fixtures)一键造单脚本,让前端与测试可在秒级生成处于各主状态的样例单;并在 Swagger / Buf 文档中写清每个错误码对应的用户提示与重试建议,减少「联调靠吼」的沟通成本。

本章与第 4 章关系:第 4 章从全局角度归纳 Saga、事件驱动与幂等模式;本章把它们落到订单这一 busiest 的编排点上,可作为第 4 章的案例化深读材料。

阅读顺序建议:若时间有限,可优先精读 28.3、32.4、32.5、32.6、32.9、32.10,其余小节作为查阅索引。

术语提示:文中英文术语如 Saga、Outbox、Process Manager 等在团队内首次落地时,请在词汇表写明中文对照与缩写规则,避免口头沟通歧义。

flowchart TB
  subgraph clients[接入层]
    GW[API Gateway]
    App[移动端 / Web]
  end
  subgraph order_bc[订单限界上下文]
    OC[Order Core 创单与查询]
    SM[State Machine 状态推进]
    FO[Fulfillment Orchestrator]
    OB[Outbox Relay]
  end
  subgraph deps[下游依赖]
    Inv[库存]
    Pr[计价]
    Mk[营销]
    Pay[支付]
    Log[物流 / 供应商履约]
  end
  subgraph data[数据面]
    DB[(MySQL)]
    R[(Redis)]
    K[Kafka]
    ES[(Elasticsearch)]
  end
  App --> GW --> OC
  OC --> Inv
  OC --> Pr
  OC --> Mk
  SM --> Pay
  FO --> Log
  OC --> DB
  OC --> R
  OB --> K
  K --> ES

读路径与写路径分流:创单、支付回调、履约回传属于写模型;「我的订单列表」高 QPS 查询可走投影表或 ES,以 eventual lag 换取扩展性,但订单详情页强一致读仍建议回源主库或带版本号的缓存。

部署与弹性:订单核心通常按「有状态尽量避免」原则设计——会话态放在 Redis 或客户端 request_id,服务端保持无状态水平扩展。Outbox Relay 与履约 Worker 可独立扩缩容,避免与大盘创单抢 CPU。跨机房时,优先保证写主就近Kafka 多副本,读副本延迟通过版本号或「刷新」按钮产品化,而不是在接口层偷偷强读从库。


32.2 订单数据模型

数据模型是订单域与周边系统对话的「共同语言」。表结构一旦轻率扩展,会在数年后以 报表口径漂移、对账困难、索引失效 的方式反噬团队。设计时建议坚持三条约束:第一,主表保持瘦——只放跨行、跨流程的汇总与阶段字段;行级细节、履约扩展、风控标签下沉子表。第二,金额字段语义单一——每个金额列在数据字典中绑定唯一业务定义(含税 / 不含税、含运费 / 不含运费),禁止复用同一列表达多种口径。第三,时间字段成体系——created_atpaid_atclosed_at 与状态机一一对应,避免用 updated_at 推断业务时刻。

在国际化与多店铺场景,模型还要提前容纳 税号、发票类型、买家身份(C 端 / B 端) 等字段,即使首期不上线,也应预留扩展位或使用 JSON 扩展表,以免后续 ALTER 大表锁死业务。与数据仓库的衔接上,建议把订单变更以 CDC 或领域事件 方式同步到 ODS,避免数仓直接扫主库大表。

32.2.1 订单表设计

主表保存订单级事实:用户、类型、主状态、金额汇总、版本号、时间戳。金额一律用**分(int64)**存储,避免浮点误差。

// Order 主表 ORM 示意
type Order struct {
	OrderID         string    `json:"order_id"`
	UserID          int64     `json:"user_id"`
	OrderType       int8      `json:"order_type"` // 1 实物 2 虚拟 3 O2O 4 预售
	Status          int16     `json:"status"`
	TotalAmount     int64     `json:"total_amount"`     // 商品应付合计(含运费等按口径)
	PaymentAmount   int64     `json:"payment_amount"`   // 用户实付
	DiscountAmount  int64     `json:"discount_amount"`  // 优惠合计
	FreightAmount   int64     `json:"freight_amount"`
	Currency        string    `json:"currency"`
	PricingSnapshot string    `json:"pricing_snapshot"` // JSON:计价中心快照 ID / 版本
	CASVersion      int64     `json:"cas_version"`
	CreatedAt       time.Time `json:"created_at"`
	UpdatedAt       time.Time `json:"updated_at"`
}

索引建议

  • 主键:order_id(Snowflake 或分段号段);
  • 查询:(user_id, created_at DESC) 支撑列表;
  • 运营:(status, updated_at) 支撑关单扫描、履约队列。

分库分表注意:若以 user_id 为分片键,需保证 订单号到分片的路由可逆(例如 order_id 内嵌分片号,或维护全局路由表)。否则仅凭 order_id 查询会退化为全分片广播。另一种常见做法是按 order_id 哈希分片、列表查询走 ES 投影表,主库只服务按单号点查与写路径。

软删除 vs 状态终态:交易订单通常不物理删除,以 CancelledClosed 等终态表达关闭;合规与审计要求下,敏感字段脱敏也应保留主键与流水。若业务误用 is_deleted 与状态机双轨,会出现「状态已支付但记录被软删」的灾难组合。

32.2.2 订单明细

明细表一行对应一个 SKU 实例,保存数量、行小计、关联商品快照 ID,可选存 分摊后的优惠 以便部分退款闭合。

type OrderLine struct {
	LineID       int64  `json:"line_id"`
	OrderID      string `json:"order_id"`
	ProductID    int64  `json:"product_id"`
	SkuID        int64  `json:"sku_id"`
	Quantity     int32  `json:"quantity"`
	SalePrice    int64  `json:"sale_price"`    // 行单价(已含当时营销结果视口径)
	LineAmount   int64  `json:"line_amount"`   // sale_price * quantity - line_discount
	LineDiscount int64  `json:"line_discount"`
	SnapshotID   string `json:"snapshot_id"`   // 商品展示快照
}

拆单:若业务要求「不同仓 / 不同商家」拆子单,可引入 shipment_group_id 或子订单表;主单与子单的支付关系需在模型层一次性说清,避免支付回调时无法定位行。

行级扩展字段:跨境、大宗或企业采购常在行上携带 税率、HS 编码、采购合同号 等。建议用 line_extra JSON 或独立扩展表,并在写入时做 schema 版本号,避免无约束 JSON 变成「第二个代码仓库」。部分退款时,需能根据行级快照与分摊规则还原「可退金额」,这与第 29 章的分摊口径强相关。

赠品与换购:赠品行可标记 line_type=gift,金额为 0 但仍占用库存与履约单元;换购行则绑定主行 parent_line_id。建模时务必让履约与库存识别「最小履约单元」,否则会出现赠品未发但主单已完成的口径争议。

32.2.3 价格快照

价格快照解决事后解释问题:用户下单后商品改价,应以快照为准。实践中常并存两层:

  1. 计价中心快照(第 29 章):规则解释结果、分层明细、舍入版本;
  2. 商品展示快照:标题、主图、规格文案,用于客服与纠纷处理。
type OrderPricingSnapshot struct {
	SnapshotID   string `json:"snapshot_id"`
	PricingJSON  []byte `json:"pricing_json"`  // 来自计价中心
	RuleVersion  string `json:"rule_version"`  // 活动 / 券批次版本
	CreatedAt    time.Time `json:"created_at"`
}

创单事务内写入 order.pricing_snapshot 外键或内嵌 JSON,支付校验阶段只校验不重新全量试算(与第 29 章口径一致),避免渠道变化导致「可支付金额漂移」。

快照与客服工单:客服系统应能按 snapshot_id 拉取当时的展示文案与分层价格,而不是实时读商品中心。否则用户截图与后台看到的不一致,纠纷成本极高。若快照体积过大,可采用 对象存储 + 哈希引用,MySQL 仅存指针与版本。

多币种Currency 与汇率快照需同事务写入;支付若支持用户切换币种,应生成新的支付上下文而不是覆盖原订单金额字段,除非业务流程明确允许改价。

32.2.4 状态历史

order_state_log 记录 from / to / operator / reason / event_name,可选挂 payment_idfulfillment_idrefund_id。与 Outbox 同事务写入,可投影 OrderStateChanged 供风控与 BI。

type OrderStateLog struct {
	LogID      int64     `json:"log_id"`
	OrderID    string    `json:"order_id"`
	FromStatus int16     `json:"from_status"`
	ToStatus   int16     `json:"to_status"`
	Event      string    `json:"event"` // PaySuccess, Ship, Delivered...
	Operator   string    `json:"operator"`
	Reason     string    `json:"reason"`
	CreatedAt  time.Time `json:"created_at"`
}

幂等辅助表(可与通用幂等表合并):idempotent_record(idempotent_key UNIQUE, biz_type, biz_id, status, expire_at),支撑创单、回调、补偿。

事件字段规范event 建议使用 过去式英文枚举(如 PaySucceeded)并在网关层统一大小写,避免日志检索分裂。operator 区分 systemuser:{id}admin:{id}payment_channel,便于审计。若与 GDPR / 个人信息保护法合规,展示层再脱敏,但底层链路保留可追责 ID。


32.3 订单状态机

32.3.1 全局状态机

线上常用「主状态 + 子状态机」:主状态面向用户与报表;支付、履约、售后各自维护子状态,通过领域事件驱动主状态迁移。

stateDiagram-v2
  direction LR
  [*] --> PendingPay: 创单成功
  PendingPay --> Paid: 支付成功
  PendingPay --> Cancelled: 超时 / 用户取消
  Paid --> Fulfilling: 进入履约
  Fulfilling --> Completed: 履约完结
  Paid --> AfterSale: 发起售后
  Fulfilling --> AfterSale: 履约中售后
  AfterSale --> Fulfilling: 售后关闭继续履约
  AfterSale --> Cancelled: 全额退款关单
  Cancelled --> [*]
  Completed --> [*]

子状态机(支付侧示意):主单进入 PendingPay 后,支付域独立维护尝试次数、渠道、风控结果。主单不应细化为「微信处理中 / 支付宝处理中」,否则订单表会被渠道维度污染;必要时用 payment_sub_status 扩展列或独立 order_payment 表承载。

stateDiagram-v2
  direction LR
  [*] --> PayInit: 创建支付单
  PayInit --> PayTrying: Try 成功
  PayTrying --> PayConfirmed: Confirm 成功
  PayTrying --> PayCancelled: Cancel / 超时
  PayConfirmed --> [*]
  PayCancelled --> [*]

PayConfirmed 事件到达订单应用服务时,才触发主单 PendingPay → Paid,并写入状态日志 event=PaySucceeded。若只有主单状态没有子单记录,排障时很难解释「用户看到支付成功但订单仍待支付」的短暂不一致窗口。

32.3.2 状态转换规则

显式白名单 表达合法迁移,禁止散落 if 隐式跳转。下表为示意矩阵(行:from,列:to, 合法)。

from \ toPendingPayPaidFulfillingCompletedCancelledAfterSale
PendingPay
Paid
Fulfilling
Completed视业务
Cancelled
AfterSale

回退约束:默认不允许 Paid → PendingPay;若支付风控冲正,应走 支付域事件 PaymentReversed,由订单编排显式迁移到 Cancelled 或特殊 PaymentFailed 终态,并触发资源回补 Saga。

并发与乱序:支付回调可能晚于用户主动取消;履约回调可能早于支付成功(脏数据)。统一策略是:任何迁移前读取当前主状态 + CAS;非法迁移记录审计并打指标,而不是「静默成功」。对疑似乱序消息可进入 延迟队列 二次投递,但要有上限避免永远悬挂。

超时关单PendingPay 超时迁移到 Cancelled 时,应同时触发 库存释放与营销解锁;若关单任务与支付回调并发,必须以数据库行锁或 CAS 决定只有一个赢家,失败方进入补偿或幂等返回。

32.3.3 状态机实现

推荐 表驱动 + CAS 更新:引擎根据 (from, event)to,再执行带 WHERE status = ? AND cas_version = ? 的更新,失败则视为并发抢占或重复事件。

Guard 条件:除 (from, event) 外,真实系统常需要额外守卫,例如「仅当 pay_expire_at 未到期才允许 PendingPay → Paid」「仅当所有子单已发货才允许 Fulfilling → Completed」。守卫逻辑建议写成 纯函数 func Guard(order *Order, ev Event) error,便于单测与可视化文档同步维护。

批量状态推进:运营后台「批量关闭异常单」属于高危操作,应走 审批流 + 异步任务,每条订单仍执行单条 CAS 与日志,避免一条 SQL 批量 UPDATE 丢失审计信息。

type Event string

const (
	EvtPayOK   Event = "PaySuccess"
	EvtTimeout Event = "PayTimeout"
	EvtShip    Event = "Shipped"
)

var transitionTable = map[int16]map[Event]int16{
	StatusPendingPay: {EvtPayOK: StatusPaid, EvtTimeout: StatusCancelled},
	StatusPaid:       {EvtShip: StatusFulfilling},
}

func ApplyEvent(ctx context.Context, repo OrderRepo, orderID string, from int16, ev Event, actor, reason string) error {
	to, ok := transitionTable[from][ev]
	if !ok {
		return ErrIllegalTransition
	}
	if err := repo.UpdateStatusCAS(ctx, orderID, from, to); err != nil {
		return err
	}
	return repo.AppendStateLog(ctx, orderID, from, to, string(ev), actor, reason)
}

32.3.4 状态变更历史

历史表与主表 CAS 同事务提交;若需 对外通知,使用 Outbox 保证「日志落库 ⇒ 消息必达」。监控上应对 illegal_transition_totalfrom,to 聚合,异常飙升多为回调乱序或重复投放。

回放与对账:状态日志是订单域最重要的法务与财务证据链之一。导出报表时,应能按时间序重放 from → toevent,并与支付流水、物流轨迹交叉验证。若仅保存终态而无过程日志,出现「用户声称未收到货」时将难以举证。

测试策略:为 transitionTable 维护单元测试矩阵,覆盖所有 (from, event);对并发场景增加 模糊测试(随机交叉回调与关单任务)。生产灰度时,可短暂打开「非法迁移采样日志」定位历史脏规则。


32.4 订单创建流程

创单可拆四步:参数校验 → 资源侧扣减 / 锁定 → 订单持久化 → 异步事件

32.4.1 参数校验

  • 用户风控、地址、发票抬头;
  • SKU 可售、限购、黑白名单;
  • 计价快照版本与当前试算差异阈值(超限则拒绝创单提示刷新);
  • 营销资格(券批次、人群标签)。

校验分层:建议拆为 语法校验(网关)权限校验(用户会话)业务不变式校验(订单域)。不变式包括:行数上限、单用户并发创单上限、敏感 SKU 需二次验证等。不要把所有校验堆在单个「上帝函数」里,可按 Pipeline 组织,便于单测与观测每步耗时。

与结算一致性:若用户从结算页提交,创单请求应携带 结算会话 ID 或 booking_token(第 31 章),订单域校验其未过期且未被消费。否则会出现「结算页显示可买,创单失败」的合理但体验差场景——需用明确错误码驱动前端刷新。

拆单预览:若创单前已展示拆单结果,创单请求应携带 拆单版本号;服务端重新计算拆单,不一致则拒绝以保护用户预期。

32.4.2 库存扣减

与第 27 章对齐:创单常用 Reserve(预占)Deduct(直接扣),取决于品类 deduct_timing。失败快速返回,不必进入订单落库。

失败语义:库存返回 INSUFFICIENT_STOCKHOTKEY_THROTTLED 应对前端不同提示;后者可触发排队或重试策略。订单域不应把供应商超时简单映射为库存不足,以免误导用户。

跨仓:多仓 reserve 应按「子单顺序」或「固定 SKU 字典序」调用,降低死锁概率;任一子仓失败应整体失败并释放已成功部分。

32.4.3 营销扣减

营销侧提供 Lock / Deduct + Compensate 语义;订单携带 promotion_trace_id,保证券与积分操作可回滚。

券与积分顺序:若同时用券与积分,营销接口应保证 原子锁;订单侧避免先发两次 RPC 再本地补偿。常见实现是营销提供 BatchLockBenefits 单接口,内部保证事务。

风控联动:营销返回「疑似黄牛」时,订单应快速失败并记录 risk_trace_id,避免进入复杂 Saga 后再被风控拦截,浪费库存预占窗口。

32.4.4 订单持久化

主单 + 明细 + 快照同事务写入;PendingPay 之前可存在短暂 Draft 态用于灰度,但对外接口应合并为一次成功语义。

Saga 编排总览

flowchart TD
  A[开始 CreateOrder] --> B[校验与组装上下文]
  B --> C[库存 Reserve / Deduct]
  C -->|失败| Z[结束失败]
  C --> D[营销 Lock / Deduct]
  D -->|失败| R1[补偿: 库存释放]
  D --> E[写订单 Draft/PendingPay]
  E -->|失败| R2[补偿: 营销回滚]
  E -->|失败| R3[补偿: 库存释放]
  E --> F[提交事务 + Outbox]
  F --> G[发布 OrderCreated]
  G --> H[结束成功]
  R1 --> Z
  R2 --> Z
  R3 --> Z

与结算(第 31 章)边界:若结算页已预占库存,创单应携带 reservation_token幂等转正式占,避免二次扣减。

完整 Saga 编排(Go 示意):下列代码将库存、营销、落库串为编排型 Saga;InsertOrder 与 Outbox 应在同一数据库事务内,保证事件与订单一致。

type CreateOrderSaga struct {
	Inv InventoryClient
	Mk  MarketingClient
	Repo OrderRepo
	Req  *CreateRequest
}

func (s *CreateOrderSaga) Run(ctx context.Context) error {
	steps := []struct {
		name string
		do   func(context.Context) error
		undo func(context.Context) error
	}{
		{"reserve_stock", func(ctx context.Context) error {
			return s.Inv.Reserve(ctx, &ReserveInput{Token: s.Req.ReservationToken, Lines: s.Req.Lines})
		}, func(ctx context.Context) error {
			return s.Inv.Release(ctx, s.Req.ReservationToken)
		}},
		{"lock_promo", func(ctx context.Context) error {
			return s.Mk.Lock(ctx, &LockInput{OrderDraftID: s.Req.DraftID, UserID: s.Req.UserID})
		}, func(ctx context.Context) error {
			return s.Mk.Unlock(ctx, s.Req.DraftID)
		}},
		{"persist_order", func(ctx context.Context) error {
			return s.Repo.InsertOrderWithOutbox(ctx, buildOrder(s.Req))
		}, func(ctx context.Context) error {
			return s.Repo.DeleteDraft(ctx, s.Req.DraftID)
		}},
	}
	var done int
	for i, st := range steps {
		if err := st.do(ctx); err != nil {
			for j := done - 1; j >= 0; j-- {
				_ = steps[j].undo(ctx)
			}
			return fmt.Errorf("step %s: %w", st.name, err)
		}
		done = i + 1
	}
	return nil
}

Outbox 同事务InsertOrderWithOutbox 内应写入 outbox 表,relay 进程异步投递 OrderCreated。若先写 Kafka 再写 DB,崩溃窗口会导致「消息已发但单未落库」的幽灵订单。

典型故障复盘(示意):某次大促中,创单接口错误率飙升,监控显示库存 RPC P99 正常,但营销锁券出现大量超时。根因是订单线程池被 同步调用营销 + 同步写大事务 占满,健康检查仍返回 OK。改进包括:为营销调用单独 bulkhead 线程池;将非关键校验异步化;把 InsertOrder 事务拆小,先写主键占位再补全明细(需配合幂等与补偿)。该案例说明:编排层的背压与隔离 和下游性能同样重要。

灰度与压测:新营销规则上线前,应在预发环境用 影子流量 回放生产采样请求,对比新旧路径差异;创单接口要暴露 feature flag 控制是否启用新 Saga 步骤,避免一次性全量切换。

订单草稿(Draft)模式:高客单价或复杂 Bundling 场景,用户可能多次编辑地址与优惠券。可引入 草稿单 存储中间态,正式创单时把草稿 ID 作为幂等维度的一部分;草稿 TTL 与购物车 TTL 应协调,避免用户以为「已下单」实际草稿过期。草稿转正时仍需重新校验库存与价格,不可盲信草稿内缓存

拆单与父子单支付:一次支付覆盖多子单时,支付回调需携带 平台支付单号 → 子订单列表 的映射;若映射缺失,会出现一笔支付成功但部分子单仍处于待支付。建议在支付创建阶段由支付服务持久化该映射,订单服务只消费标准化结构。


32.5 分布式事务

32.5.1 TCC 模式

Try / Confirm / Cancel 三阶段,适合强一致资源预留,典型在支付(见第 33 章)。订单创单一般不强行 TCC 全链路,以免参与方过多拖垮可用性。

type PaymentTCC interface {
	Try(ctx context.Context, req *PaymentRequest) (*PaymentResource, error)
	Confirm(ctx context.Context, res *PaymentResource) error
	Cancel(ctx context.Context, res *PaymentResource) error
}

空回滚与幂等:Cancel 必须容忍 Try 未落地;Confirm / Cancel 重试需基于支付单状态短路。

悬挂与对账:Try 超时后业务可能已判定失败并走 Cancel,但晚到的 Try 成功仍可能落库,造成资源悬挂。需要 Try 超时撤销任务渠道对账 双向收敛;订单域记录 try_expires_at 与渠道流水号,便于夜间批处理纠偏。

func (p *PaymentCancel) Cancel(ctx context.Context, rid string) error {
	rec, err := p.store.GetReserve(ctx, rid)
	if errors.Is(err, ErrNotFound) {
		return nil
	}
	if rec.Phase == PhaseCanceled {
		return nil
	}
	if rec.Phase != PhaseTried {
		return ErrInvalidPhase
	}
	return p.store.MarkCanceled(ctx, rid)
}

32.5.2 Saga 模式

Saga 将长事务拆为可补偿的本地事务序列。订单创单、超时关单、售后退款均适合 Orchestration(编排):由订单服务顺序调用下游,失败则逆序补偿。

type Step struct {
	Name       string
	Try        func(ctx context.Context) error
	Compensate func(ctx context.Context) error
}

type Saga struct{ steps []Step }

func (s *Saga) Run(ctx context.Context) error {
	var done []Step
	for _, st := range s.steps {
		if err := st.Try(ctx); err != nil {
			for i := len(done) - 1; i >= 0; i-- {
				if cerr := done[i].Compensate(ctx); cerr != nil {
					// 记录补偿失败任务,进入异步重试
					_ = cerr
				}
			}
			return err
		}
		done = append(done, st)
	}
	return nil
}

编排 vs 协同:编排型 Saga 便于集中超时、重试、指标;协同型通过事件总线解耦,但排查「谁该补偿」困难。订单创建推荐 编排为主、事件为辅:同步阶段用编排保证用户得到明确成功 / 失败;异步阶段用事件驱动履约。协同式在售后长尾流程中更常见,但仍建议有 流程实例表 记录当前步骤,避免纯粹靠消息隐式状态。

隔离与雪崩:Saga 每步应设置 独立超时与熔断,避免单个下游拖垮整个创单线程池。失败返回时携带 标准化错误码(库存不足、券不可用、风控拒绝),网关映射为 HTTP 4xx,避免一律 500。

32.5.3 选型对比

维度TCCSaga
一致性更强,资源预留明确最终一致
改造参与方需三接口正向 + 补偿即可
适用支付、金融类创单、售后、长流程
复杂度中,高在补偿治理
flowchart TD
  Q1{资金 / 支付渠道是否核心参与方?}
  Q1 -->|是| TCC[TCC 或渠道原生两阶段]
  Q1 -->|否| Q2{步骤>3 且可接受中间态?}
  Q2 -->|是| SG[Saga 编排 + 补偿表]
  Q2 -->|否| LOCAL[单库事务 + Outbox 单步事件]

32.5.4 补偿机制

补偿分 同步逆序异步重试队列 两级:同步阶段只处理可快速失败回滚;库存 / 支付等慢 IO 失败写入 compensation_task,由 Worker 指数退避重试,超过阈值人工工单。

优先级:资金相关 > 库存 > 营销权益,降低「钱已退券未退」类客诉风险。

func EnqueueCompensation(ctx context.Context, store TaskStore, t *CompensationTask) error {
	switch t.BizType {
	case "payment":
		t.Priority = 1
	case "inventory":
		t.Priority = 2
	default:
		t.Priority = 3
	}
	return store.Insert(ctx, t)
}

人工介入:补偿失败超过阈值应生成工单,附带 Saga 上下文 JSON(每步请求 / 响应摘要、trace_id)。不要在告警里只写「补偿失败」,否则 on-call 无法快速判断是库存还是营销。

与消息一致性:若补偿需要发「回滚券」消息,仍建议走 Outbox,避免补偿 RPC 成功但消息丢失导致用户仍看到券被锁。

两阶段提交(2PC)在订单域的位置:强一致 2PC 在互联网大规模订单核心路径已较少采用,但在 与财务总账同一数据库集群 的小范围场景仍可能出现。若团队评估引入全局事务协调器,应充分评估 阻塞窗口与单点风险;多数电商场景仍以 Saga + 对账替代。

事务边界与领域事件:本地事务内应只包含「本聚合必须同事务成功」的最小集合。把「写订单 + 写审计 + 写 Outbox」放在同事务是合理的;把「写订单 + 调库存远程提交」放在同一本地事务则不可行。事件命名建议采用过去式领域语言(OrderCreated),订阅方据此触发投影或履约,而不是用命令式 CreateShipment 事件污染语义。


32.6 幂等性设计

32.6.1 幂等性原则

  • 技术幂等:HTTP 重试、唯一约束防双插;
  • 业务幂等:同一业务键多次提交得到同一业务结果(返回首单)。

32.6.2 实现方案

  1. 数据库唯一键idempotent_key
  2. Redis SETNX:短 TTL 防抖;
  3. 状态机短路:非法迁移直接返回成功或明确错误码;
  4. 业务外键order_id + coupon_id 唯一扣减记录。

处理中状态:幂等表应有 processing 态,防止客户端疯狂重试导致风暴;可配合 429 + Retry-Afterprocessing 超时由后台任务回收或允许用户重放(需业务决策)。

时钟与 TTLexpire_at 应略大于业务 SLA(如 24h),并定期清理历史幂等记录,避免表无限膨胀。清理前需确认该键不再可能被渠道重放。

32.6.3 各场景实现

场景幂等键说明
创单user_id + client_request_id插入抢占,成功返回已有订单
支付回调channel_order_idpayment_id + event防渠道重放
物流回调shipment_id + logistics_status防状态重复推进
售后退款refund_id 分步幂等每步独立去重表
func CreateOrderIdempotent(ctx context.Context, repo OrderRepo, req *CreateRequest) (*Order, error) {
	key := fmt.Sprintf("order:create:%d:%s", req.UserID, req.ClientToken)
	rec := &IdempotentRecord{Key: key, BizType: "order_create", Status: StatusProcessing}
	if err := repo.InsertIdempotent(ctx, rec); err != nil {
		if existing, e := repo.GetIdempotent(ctx, key); e == nil && existing.Status == StatusSuccess {
			return repo.GetOrder(ctx, existing.BizID)
		}
		return nil, ErrDuplicateInProgress
	}
	order, err := runCreateSaga(ctx, repo, req)
	if err != nil {
		_ = repo.MarkIdempotent(ctx, key, StatusFailed)
		return nil, err
	}
	_ = repo.MarkIdempotentSuccess(ctx, key, order.OrderID)
	return order, nil
}

支付回调三重防重(与第 33 章衔接):幂等表 + 主状态机 + Confirm 内部状态短路,缺一不可。只依赖状态机会在「重复回调但状态已前进」时误报失败;只依赖幂等表会在表损坏时放大风险。

监控:按 biz_type 统计幂等命中、冲突、处理中滞留;对 idem_conflict_rate 设定基线告警,异常上升通常意味着客户端重试策略变更或渠道重放策略调整。

客户端协作:移动端在弱网下会放大重试;应在 SDK 层统一生成 client_request_id 并持久化到本地,直到收到明确成功响应才清理。Web 端则可用 sessionStorage 暂存,刷新页面不丢失。服务端返回 409 + 已有订单号 优于静默成功,便于客户端跳转「订单详情」。

安全视角:幂等键不应可被枚举猜测;对匿名下单场景,应绑定 设备指纹或会话令牌,防止撞库式刷接口。对公开回调接口必须 验签 + IP 白名单 + Replay window


32.7 特殊订单类型

32.7.1 虚拟订单

无物流,支付后 即时履约(发券、开通权益)。状态机在 Paid 后可直接进入 Granting → Completed,失败重试必须 发放流水唯一约束

stateDiagram-v2
  [*] --> PendingPay
  PendingPay --> Paid: 支付成功
  Paid --> Granting: 触发履约
  Granting --> Completed: 权益到账
  Granting --> GrantFailed: 下游失败
  GrantFailed --> Granting: 重试 / 人工回放
  PendingPay --> Cancelled: 超时

资损防控:虚拟履约接口必须具备 可查询结果 能力(QueryGrantResult),以便在超时后判断是「已成功未回包」还是「确实失败」。否则重试可能造成重复发放,不重试又可能用户付款无权益。

与会员体系:若权益挂在账号维度,订单应记录 目标账号(本人 / 赠送人),避免客服手工改绑带来的审计缺口。

32.7.2 O2O 订单

强调 门店 / 骑手 / 超时。支付后进入 PendingAccept,商家超时未接单触发关单与补偿。

func CreateO2OOrder(ctx context.Context, lbs LBS, req *CreateRequest) error {
	storeID, err := lbs.ResolveStore(ctx, req.Lat, req.Lng, req.SKUs)
	if err != nil {
		return err
	}
	req.StoreID = storeID
	return runCreateSaga(ctx, orderRepo, req)
}

运力与派单:订单应保存 store_idexpected_arrival 等业务字段;配送系统回写骑手位置不属于订单核心表,可进入履约扩展表或实时查询接口。

取消与部分退款:O2O 常出现「商家缺货部分退款」,需要行级部分退与状态机协同;主单可能仍处于履约中,财务上已发生部分结算,报表需单独建模。

32.7.3 预售订单

定金 + 尾款 拆两张支付单;库存使用 Reserve 锁定到尾款结束。尾款超时释放预订并关单。

stateDiagram-v2
  [*] --> PendingDeposit
  PendingDeposit --> DepositPaid: 定金成功
  PendingDeposit --> Cancelled: 未付超时
  DepositPaid --> PendingBalance: 开启尾款期
  PendingBalance --> BalancePaid: 尾款成功
  PendingBalance --> Cancelled: 尾款超时
  BalancePaid --> Fulfilling: 进入发货流程

财务与税务:定金可能不计入收入确认节点,尾款成功后才触发 ERP 凭证;订单模型应能区分 两笔支付子单一笔主单 的映射,避免对账系统把定金当全款。

库存语义:定金阶段常用 软预留(不占可售库存但占名额)或 硬预留(直接减可售),需与供应链共识;尾款失败释放策略必须可观测,否则大促会出现「幽灵占用」。


32.8 订单履约

32.8.1 履约流程

支付成功发布 OrderPaid,履约 Worker 消费后创建物流单或调用供应商 API;主单状态从 Paid 进入 Fulfilling,再随物流事件细化。

stateDiagram-v2
  [*] --> PendingShipment: 支付成功
  PendingShipment --> Shipped: 仓库发货
  Shipped --> InTransit: 揽收
  InTransit --> Delivered: 签收
  Delivered --> Completed: 确认收货 / 超时自动确认

异步 Worker 设计:消费 OrderPaid 时应 幂等检查主状态,避免重复创建物流单;创建失败应区分可重试与不可重试。DLQ 消息要包含 order_idattempt 计数,支持人工重放。

拆包裹与多包裹:一单多包裹时,用子表 order_shipment 记录多个运单;主状态聚合规则需定义(例如全部签收才 Delivered,或第一件发货即 PartialShipped 子状态)。

32.8.2 供应商对接

对机票、酒店等供应商品类,履约即 供应商下单 / 出票 / 确认;需映射供应商返回码到统一 FulfillmentErrorCode,并支持 重试与人工工单

幂等与供应商:供应商接口常「同一请求重试返回同一确认号」,订单侧应保存 supplier_request_id 与响应指纹,避免重复下单造成双份资源。

降级:供应商大面积故障时,可暂停自动履约、进入 人工队列,并在订单详情展示「处理中」与预计时间,减少进线量。

32.8.3 状态同步

物流回调与供应商 Webhook 必须 签名验证 + 幂等表;异步链路用 Kafka 削峰,失败进 DLQ 并保留原始消息体。

func OnLogisticsEvent(ctx context.Context, repo OrderRepo, ev *LogisticsEvent) error {
	key := fmt.Sprintf("logistics:%s:%s", ev.ShipmentID, ev.Status)
	if err := repo.InsertIdempotent(ctx, key); err != nil {
		return nil
	}
	return repo.ApplyLogisticsMapping(ctx, ev)
}

时间语义:物流状态常带时间戳,订单应保存 供应商时间戳 + 接收时间,便于跨时区纠纷。自动确认收货的计时一般从 Delivered 事件时间起算,而非支付时间。

履约 SLA 与用户体验:对虚拟与 O2O,用户更敏感于「多久到账 / 多久送达」。应在订单详情展示 预计完成时间区间,该区间由履约服务根据历史分位数计算,而不是订单域拍脑袋写死常量。超时未履约应自动触发 补偿或客服工单,并给用户明确状态而非无限「处理中」。

仓配一体与自提:自提点、门店自提等模式会改变履约状态机,新增 ReadyForPickupPickedUp 等节点;主单聚合规则需重新定义「完成」含义(取货扫码 vs 离店)。这些差异最好通过 履约子状态机配置 注入,而不是复制一套订单主表。


32.9 系统边界与职责

32.9.1 订单系统的职责边界

负责:创单编排、主状态机、订单事实存储、领域事件、补偿编排入口。
不负责:支付渠道协议、库存原子脚本、营销规则解释、物流路由算法。

反模式清单:在订单库里维护「渠道费率表」、在订单服务里直连 Elasticsearch 做商品搜索、在订单进程里跑供应商长轮询——这些都会让订单成为最难部署的巨石。应通过 防腐层 + 专门服务 吸收。

32.9.2 订单 vs 支付:边界划分

订单提供 应付金额事实与支付上下文;支付服务生成 支付单 并对接渠道。回调默认进入 支付域,由其校验签名后调用订单 Application Service 推进状态,避免订单服务直接解析多渠道报文导致膨胀。

金额变更:除部分业务(邮费后补、税费调整)外,支付金额应以订单快照为准;若必须改价,应走 订单变更单 或关闭旧单重建,而不是在支付回调里悄悄改 payment_amount

32.9.3 订单 vs 物流:边界划分

订单保存 运单号快照 与履约状态;轨迹查询走物流查询服务。订单不应存储全量轨迹点。

异常协同:丢件、拒收、改址属于物流域流程,结果以事件通知订单;订单负责更新主状态与触发售后入口,而不是在订单服务内嵌物流公司客服规则。

32.9.4 订单作为编排者的角色

订单是 Process Manager:定义 Saga 顺序、超时、补偿策略;各子域提供幂等 API。编排者不缓存子域权威数据副本(除快照外)。

可观测性:编排者应输出 结构化 Saga 日志saga_idsteplatencyresult),并在 Trace 中把下游 Span 串为子节点,否则大促排障只能看「创单慢」而无法定位瓶颈环节。

32.9.5 订单 vs 履约:职责划分

订单给出「应履约什么」;履约服务决定「如何履约」:拆包裹、选承运商、对接供应商 API。虚拟品可将履约内嵌 Worker,但仍建议独立模块便于扩缩容。

售后交错:履约中发起售后时,应冻结部分发运或拦截未发商品;订单主状态进入 AfterSale 后,履约 Worker 需识别 继续履约 / 中止 的策略位,避免「已退款仍发货」的严重事故。


32.10 与其他系统的集成

32.10.1 与商品中心集成(快照读取)

创单只读 商品只读视图 + 快照构建器,禁止直接 join 商品运营库。快照哈希可复用第 25 章策略。

变更传播:商品标题、类目变更不应反写历史订单;若发生合规下架,应通过 风控事件 拦截新创单,而不是修改已落库快照。对 SEO 友好的长描述可不入库,仅保留客服需要的最小字段集以控存储。

32.10.2 与库存系统集成(扣减与回退)

统一使用库存暴露的 Reserve / Commit / ReleaseDeduct / Restore 语义;订单保存 reservation_id 便于超时释放。

支付后确认:若品类要求支付后才向供应商下单,创单阶段可能是 软占,支付成功后再 Commit;订单需记录两阶段 token,关单时只释放对应阶段。

32.10.3 与计价系统集成(价格快照)

创单请求携带 pricing_token,计价服务返回 不可变快照;订单落库字段与支付校验字段保持一致。

舍入与分摊:快照应包含 行级分摊结果尾差处理规则版本(第 29 章),否则部分退款时营销与财务会对不上。支付校验只验证「渠道应付 == 快照应付」在允许误差内。

32.10.4 与营销系统集成(锁定与扣减)

先锁后付:创单锁券,支付成功转实扣;关单 / 超时统一走营销回滚接口,携带 order_id 幂等。

平台与商家券:若一单混合出资,营销回滚需支持 按比例撤销;订单应保存 subsidy_split 片段,避免退款时无法拆分平台补贴与商家让利。

32.10.5 与支付系统集成(支付触发)

订单在 PendingPay 调用支付 创建支付单 API;之后状态迁移以支付回调为准,前端轮询仅作体验辅助。

前端轮询:轮询间隔应指数退避并设上限;服务端对同一用户并发轮询限流,防止把订单库打挂。更优方案是 SSE / WebSocket 推送支付结果,但仍要以回调为准。

32.10.6 与供应商系统集成(履约)

供应商网关隔离协议差异;订单只面向网关请求 CreateSupplierOrder,不感知对方 SOAP / REST 细节。

契约版本:网关应携带 供应商契约版本号,订单落库保存该版本,便于供应商升级后追溯「老单走老协议」。

32.10.7 集成编排:Saga 模式实践

下图展示创单阶段与外部系统的 时序编排(示意,省略签名与重试)。

sequenceDiagram
  participant U as 用户
  participant O as 订单服务
  participant P as 计价
  participant I as 库存
  participant M as 营销
  participant D as DB
  participant K as Kafka
  U->>O: CreateOrder(req)
  O->>P: ValidatePricingToken
  P-->>O: PricingSnapshotOK
  O->>I: ReserveStock
  I-->>O: reservation_id
  O->>M: LockPromotions
  M-->>O: lock_token
  O->>D: Tx: Insert Order + Lines + Outbox
  D-->>O: OK
  O->>K: OrderCreated(event)
  O-->>U: 201 + order_id
  Note over O,I: 任一步失败则逆序 Release / Unlock 并返回错误码

32.10.8 失败补偿与重试策略

  • 可重试错误(网络抖动):有限次重试 + 指数退避;
  • 业务拒绝(库存不足):立即失败,不做无意义重试;
  • 补偿失败:入补偿表,定时拉升 + 人工兜底;
  • 监控compensation_backlogsaga_step_latencyduplicate_callback_total

跨系统对账:每日按 订单支付成功总额 与支付系统、营销补贴账、供应商结算单进行三方抽样对账;出现差异时,以支付渠道流水为资金真相,以订单行快照为业务真相,营销与库存走补偿闭环。

集成测试建议:在 CI 中维护 合约测试(Pact 类)对库存 / 计价 / 营销的关键响应做快照校验;订单编排层的集成测试应覆盖「任一步失败」与「补偿失败入队」两条主线。


32.11 工程实践

32.11.1 订单 ID 生成

Snowflake 为主流:趋势递增、无 DB 往返。需处理 时钟回拨(拒绝生成并告警)与 workerId 分配(etcd / DB 租约)。

type Snowflake struct {
	mu        sync.Mutex
	epochMs   int64
	workerID  int64
	sequence  int64
	lastMs    int64
}

func (s *Snowflake) Next() (int64, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	now := time.Now().UnixMilli()
	if now < s.lastMs {
		return 0, ErrClockMovedBackwards
	}
	if now == s.lastMs {
		s.sequence = (s.sequence + 1) & 0xFFF
		if s.sequence == 0 {
			for now <= s.lastMs {
				now = time.Now().UnixMilli()
			}
		}
	} else {
		s.sequence = 0
	}
	s.lastMs = now
	id := ((now - s.epochMs) << 22) | (s.workerID << 12) | s.sequence
	return id, nil
}

号段模式:部分银行或票据场景要求数字更短,可采用 DB / Redis 号段批量领取;代价是需要容灾切换时的 跳号容忍 与对账。

32.11.2 性能优化

  • 分库分表键:优先 user_idorder_id 哈希;
  • 热点用户:队列合并写、缓存扇出;
  • 读多写少:详情 Redis + 短 TTL,写后删缓存;
  • 异步:Outbox relay 批量发送。

批量写与合并:秒杀场景可用 请求合并(coalesce)将短时间窗口内的创单请求排队合并调用库存;需评估公平性与尾延迟,并设最大等待时间。

func PublishOutbox(ctx context.Context, tx Tx, topic string, payload []byte) error {
	msg := OutboxMessage{Topic: topic, Payload: payload, Status: StatusPending}
	return tx.InsertOutbox(ctx, &msg)
}

32.11.3 监控告警

指标示例:order_create_success_ratepending_pay_timeout_countpay_callback_latencyfulfillment_retry_countidem_conflict_rate。日志必须带 order_idtrace_ididempotent_key

告警分级compensation_backlog 连续升高为 P0;illegal_transition_total 小量抖动可为 P2 观察。夜间告警需合并同根因,避免「短信轰炸」导致真正 P0 被忽略。

容量演练:定期做 限流阈值演练Kafka 分区迁移演练,验证订单写路径在极端情况下的降级开关是否生效(例如暂停非核心消息投影)。


32.12 本章小结

本章从数据模型出发,强调订单主表、明细、快照与状态历史四位一体:主表表达用户可见阶段,明细绑定商品快照与分摊信息,计价快照锁定金额解释,状态日志提供审计证据链。在状态机层面,主单与子域(支付、履约、售后)协同,所有迁移走白名单 + CAS,乱序与并发通过幂等与行级竞争消解。

创单流程中,我们以 Saga 编排库存、营销与本地落库,以 Outbox 保证事件可靠投递;分布式事务层面区分 TCC(支付资金)与 Saga(长流程资源),并配套补偿优先级与人工工单。幂等贯穿创单、回调与履约,三重防重降低资损概率。特殊订单类型通过差异化状态机与策略扩展点接入,避免污染核心路径。履约侧则强调异步、幂等与供应商契约版本。

系统边界看,订单是交易编排者而非执行者:计价解释、库存原子、支付渠道、物流轨迹各有其主;订单负责把它们的结果事实串成可审计的业务故事。掌握这些原则后,阅读第 33 章支付系统时,可重点关注 支付单状态机如何回调驱动订单、以及 清结算如何与订单快照对齐

答辩提示:订单系统白板讲解顺序和追问应对已统一收录到第 39 章的“订单状态机”题卡

演进建议:早期团队可用「单服务 + 清晰包边界」模拟限界上下文,待调用链路过长再拆 Order / Payment / Fulfillment。拆分时优先把 回调入口定时任务 迁出,因为它们最容易与核心创单抢资源。无论是否微服务,本章强调的 快照、状态机、Saga、幂等 四件套都仍然成立。

下一章将进入支付系统,重点讨论 渠道回调、清结算与资金安全,请保持「订单事实不变,支付驱动状态」的心智模型继续阅读。


第 33 章 支付系统

本章定位:支付系统是交易链路的资金收口外部渠道适配中心。本章展开分层架构、主链路时序、状态机、退款与营销核算、清结算与对账、幂等与一致性、系统边界与集成。文中 Go 示例为教学裁剪版,落地时请补全观测、鉴权、错误包装与依赖注入。

阅读提示:读本章时建议始终带着三个问题——谁拥有支付单的真相状态回调与主动查询以谁为准闭合差错入账如何可审计回滚。把这三个问题回答清楚,就能把「渠道差异」与「分布式一致性」从口号落到可执行的工程清单。

与全书脉络的关系:第 29 章计价给出「应付金额的合理解释」,第 31 章结算完成「资源锁定」,第 32 章订单沉淀「业务承诺」,本章则把承诺兑现为外部世界的扣款事实;第 34 章会把支付放回 B2B2C 聚合场景,观察供应商履约与渠道结算如何共同挤压支付边界。


33.1 业务背景

33.1.1 支付系统的定位

在电商交易链路中,订单系统负责「承诺与履约编排」,计价系统负责「金额解释与快照」,而支付系统负责把应付金额转化为真实资金转移(或渠道侧支付承诺),并把结果以可审计、可幂等、可对账的方式回写给订单、营销、财务等协作方。

支付系统通常同时服务三类角色:

  • 消费者(C 端):收银台展示、组合支付、支付结果通知、退款进度查询;
  • 商家与平台(B 端):结算、提现、分账视图、对账差错工单;
  • 内部中台:风控、财务核算、客服调账、数据仓库离线口径。

因此,支付系统既不是「订单的子模块」,也不是「财务系统的渠道壳」。更合理的边界是:支付域管理支付单、渠道交互、资金事实;订单域管理履约状态机;财务域管理会计科目与总账口径。三者通过事件与对账任务最终对齐。

交易形态看,实物电商更强调「支付成功 → 库存扣减 / 发货」的串联;虚拟商品与 B2B2C 聚合平台则更强调「支付成功 → 供应商下单 / 出票」的外部 API 依赖。支付系统不应把供应商履约细节写进支付单,但必须在扩展字段中保留可追溯的外联单号(例如渠道子商户号、分账接收方),否则一旦出现拒付或 chargeback,运营与风控很难在分钟级定位问题根因。

组织协作看,支付团队往往与财务、风控、客服、数据治理交叉。接口契约里最容易被忽略的不是「字段类型」,而是语义口径:例如「成功」到底指渠道受理成功、银行扣款成功,还是可结算到账。建议在支付域建立统一语言表(状态枚举、事件名、对账字段含义),并在评审门禁中与订单、财务双签,避免线上出现「订单已支付但财务未入账」的真空地带。

33.1.2 核心业务场景

场景说明工程要点
标准支付微信 / 支付宝 / 银行卡 / 余额等渠道路由、渠道限额、组合支付顺序
退款全额 / 多次部分退款可退金额闭合、营销资金回冲、渠道退款单号
清结算平台佣金、商家应收、营销补贴分账模型版本、结算周期、冻结与解冻
对账交易对账、资金对账、差错闭环文件解析幂等、长短款分类、人工复核 SLA
风控与合规限额、黑白名单、反洗钱报送(视地区)规则引擎、审计日志、敏感字段脱敏
争议与拒付chargeback、调单、凭证上传证据链留存、订单快照关联、工单闭环
企业采购对公转账、账期支付、开票衔接支付单与应收单映射、核销流程

场景串联示例:用户在结算页确认应付 199 元 → 订单系统生成待支付订单并携带计价快照 → 支付系统创建支付单并把金额与快照版本绑定 → 用户选择微信 → 网关路由到指定商户号与费率套餐 → 回调成功后 Outbox 投递 ORDER_PAID → 库存与营销在各自消费者内幂等确认。任何一步回滚都必须明确补偿顺序(先释放营销锁定还是先关支付单),否则会出现「钱没付但券没了」的资损路径。

33.1.3 核心挑战

维度具体问题典型技术回应
资金安全重复支付、伪造回调、金额篡改验签、幂等键、乐观锁、不可变流水
高并发大促峰值、热点商户限流、异步化、渠道 QPS 配额与降级
最终一致性支付成功与订单已支付不同步Outbox、本地消息表、补偿扫描
渠道异构字段、状态、回调时序不一致网关适配器、统一渠道模型、对账闭合
可运营性差错处理、调账、追溯工单系统、操作审计、双人复核
观测与排障渠道抖动、跨系统扯皮TraceID 贯通、支付会话回放、原始报文留存策略
合规与隐私PCI、卡号、证件信息最小采集、字段加密、密钥托管、脱敏展示

挑战之间的耦合需要提前说清:例如「极致性能」往往鼓励异步化与缓存,但「资金安全」又要求强审计与落库完整性;「渠道快速接入」若缺少网关隔离,会把各渠道 if-else 泄漏到支付核心,最终形成不可测试的巨石。折中办法通常是:热路径短事务 + 冷数据异步落盘 + 严格分层单测与对账兜底——性能靠结构与容量规划解决,而不是靠跳过幂等校验解决。


33.2 整体架构

33.2.1 分层架构

支付平台常见分层如下:接入层(多端统一鉴权与防重)、应用服务层(支付编排、退款编排、运营查询)、领域核心层(支付单聚合、账户、清结算规则)、渠道适配层(网关与 SPI)、基础设施层(数据库、缓存、消息、任务调度)。分层的目标不是画框,而是让变更原因单一:换渠道不应驱动清结算规则重写,改分账比例不应污染回调验签逻辑。

flowchart TB
  subgraph access[接入层]
    A1[商城 App / H5]
    A2[商家后台]
    A3[Open API / Webhook 入口]
  end

  subgraph app[应用服务层]
    B1[支付编排服务]
    B2[退款编排服务]
    B3[对账与差错服务]
    B4[运营查询 / 工单]
  end

  subgraph core[领域核心层]
    C1[支付核心:支付单 / 状态机]
    C2[账户:余额 / 冻结 / 流水]
    C3[清结算引擎:分账 / 批次]
    C4[风控:规则 / 名单 / 限额]
  end

  subgraph channel[渠道适配层]
    G1[支付网关]
    G2[微信适配器]
    G3[支付宝适配器]
    G4[银行 / 其他]
  end

  subgraph infra[基础设施层]
    D1[(MySQL)]
    D2[(Redis)]
    D3[Kafka]
    D4[定时调度 / 队列 Worker]
  end

  subgraph third[第三方]
    E1[微信支付]
    E2[支付宝]
    E3[网联 / 银联 / 其他]
  end

  A1 --> B1
  A2 --> B2
  A3 --> B1
  B1 --> C1
  B1 --> C4
  B1 --> G1
  B2 --> C1
  B2 --> G1
  B3 --> D1
  C1 --> D1
  C2 --> D1
  C2 --> D2
  C3 --> D4
  G1 --> D3
  G1 --> G2
  G1 --> G3
  G1 --> G4
  G2 --> E1
  G3 --> E2
  G4 --> E3

33.2.2 支付网关

支付网关对外暴露统一的 CreatePaymentQueryPaymentRefundParseNotify 等接口,对内以策略 + 适配器组合消化渠道差异。网关层应内聚以下能力:

  • 渠道路由:按支付方式、费率、成功率、灰度比例、商户号维度选择具体适配器;
  • 报文转换:统一内部 ChannelCommand 与外部 API 字段映射;
  • 签名与加密:各渠道证书、公钥轮换、时钟偏移容错;
  • 超时与重试策略:仅对可安全重试的读操作与幂等写操作重试;
  • 观测:按 channelmerchant_noscene 打点,便于大促排障。

33.2.3 支付核心

支付核心维护支付单聚合根、状态机、流水与幂等索引。它不应直接调用「原始 HTTP 渠道 SDK」,而应依赖网关接口,从而保证领域模型不被渠道 JSON 污染。支付核心还应产出领域事件(如 PaymentSucceeded),通过 Outbox 保证与数据库状态同事务。

33.2.4 支付渠道

渠道层实现的是「如何把统一命令变成第三方可理解请求」。实践中建议每个渠道独立模块(Go package),统一实现小接口集合,例如:

// ChannelClient 描述支付网关对单个渠道的抽象。
// 真实系统还应包含上下文、超时控制、指标与可观测字段。
type ChannelClient interface {
	PreCreate(ctx context.Context, in PreCreateInput) (PreCreateOutput, error)
	QueryTrade(ctx context.Context, in QueryTradeInput) (TradeStatus, error)
	Refund(ctx context.Context, in RefundInput) (RefundOutput, error)
	ParseNotify(ctx context.Context, httpHeader map[string][]string, body []byte) (NotifyEvent, error)
}

渠道差异集中在:同步返回是否可信(多数场景仅表示受理)、回调到达顺序退款是否同步到账对账文件粒度。网关要把这些差异折叠为有限的内部枚举(如 ACCEPTEDSUCCEEDEDFAILEDUNKNOWN),避免把渠道字符串透传到订单域。

子系统职责对照:支付系统职责对照和答辩口径已统一收录到第 39 章的“支付系统整体架构”题卡

数据落库建议

数据落库建议:支付单主表保持「窄」——只放状态、金额、币种、渠道标识、幂等键、版本号;大报文、扩展参数、渠道原始回调进入扩展表或对象存储,查询走异步索引。这样可以在大促时显著降低行更新放大效应,同时满足合规留存。


33.3 支付流程

主链路可以概括为:创建支付单(幂等)→ 渠道路由与预下单 → 用户交互 → 异步回调 / 主动查单 → 发布成功事实 → 下游投影。其中「回调」与「查单」是双保险:回调丢包时必须靠主动查询 + 补偿任务闭合。

33.3.1 支付创建

创建支付单的关键是稳定幂等键金额校验。幂等键建议至少覆盖:order_id + payer_id + pay_scene(如收银台二次发起)。数据库层以唯一索引兜底,缓存锁仅作为热点优化而非唯一手段。

type CreatePaymentCmd struct {
	OrderID         int64
	PayerID         int64
	PayScene        string
	PayableAmount   int64 // 分
	IdempotencyKey  string
	ExpireAt        time.Time
}

func (s *PaymentService) CreatePayment(ctx context.Context, cmd CreatePaymentCmd) (*Payment, error) {
	if cmd.IdempotencyKey == "" {
		return nil, fmt.Errorf("missing idempotency key")
	}
	// 1) 先查:快速幂等返回
	if p, err := s.repo.FindByIdempotencyKey(ctx, cmd.IdempotencyKey); err == nil && p != nil {
		return p, nil
	}
	// 2) 插入:唯一约束冲突则回查
	p := NewPayment(cmd)
	if err := s.repo.Insert(ctx, p); err != nil {
		if IsDuplicateKey(err) {
			return s.repo.FindByIdempotencyKey(ctx, cmd.IdempotencyKey)
		}
		return nil, err
	}
	return p, nil
}

金额校验应读取订单侧「应付快照」或「支付试算结果」,在支付核心内做二次比对(容忍 0 还是容忍营销舍入误差需产品口径明确)。禁止仅依赖前端传参。

创单后常见并发展示:用户双击支付、客户端重试、网关超时重放。除了数据库唯一索引,仍建议在应用层返回明确可理解的幂等响应(HTTP 409 或业务码 PAYMENT_ALREADY_CREATED),让前端可以稳定切换到「轮询支付结果」而不是再次创单。

33.3.2 渠道路由

路由策略常见维度:用户支付方式偏好余额是否充足渠道费率渠道健康度商户号维度黑白名单。路由输出应写入支付单扩展字段,便于事后追溯「为何走了该渠道」。

路由在工程上可抽象为「评分函数」:对每个候选渠道计算加权分,选择最高分。下面示例演示余额优先 + 渠道兜底的简化策略(真实系统还需接入风控否决与渠道健康度面板):

type RouteInput struct {
	BalanceFen      int64
	PayableFen      int64
	PreferredMethod string // WECHAT / ALIPAY / BALANCE
}

type RouteDecision struct {
	UseBalance int64
	UseChannel string
}

func Route(in RouteInput) RouteDecision {
	if in.PreferredMethod == "BALANCE" && in.BalanceFen >= in.PayableFen {
		return RouteDecision{UseBalance: in.PayableFen, UseChannel: ""}
	}
	if in.BalanceFen > 0 && in.BalanceFen < in.PayableFen {
		// 组合支付:余额抵扣一部分,剩余金额走渠道侧收银台。
		return RouteDecision{UseBalance: in.BalanceFen, UseChannel: in.PreferredMethod}
	}
	return RouteDecision{UseBalance: 0, UseChannel: in.PreferredMethod}
}

灰度与容灾:路由层应能按百分比切流到新渠道、在新渠道错误率超阈值时自动回滚到旧渠道。此类「动态路由」必须有审计记录,否则财务对账会发现同一商户号在不同日期走了不同费率套餐却无法解释。

33.3.3 支付回调

回调处理必须「先验签、再幂等、再状态机推进、再副作用」。副作用包括:写流水、记渠道交易号、插入 Outbox 事件。任何一步失败都要有可重试明确失败码,避免渠道端无限重试雪崩。

回调入口建议独立部署(甚至独立集群),与创单读多路径隔离,避免大促时查询流量挤占回调写路径。入口层完成 TLS、限流、IP 白名单后,应尽快把报文写入原始回调表(append-only),再异步处理——这样即使后续逻辑发布回滚,也不会丢凭证。

func firstHeader(headers map[string][]string, key string) string {
	v := headers[key]
	if len(v) == 0 {
		return ""
	}
	return v[0]
}

func VerifyNotify(channel string, headers map[string][]string, body []byte, pubKey string) error {
	// 伪代码:不同 channel 选择不同验签算法与字段拼接顺序。
	sig := firstHeader(headers, "X-Signature")
	if sig == "" {
		return fmt.Errorf("missing signature")
	}
	ok, err := verifyChannelSignature(channel, body, sig, pubKey)
	if err != nil {
		return err
	}
	if !ok {
		return fmt.Errorf("bad signature")
	}
	return nil
}

func verifyChannelSignature(channel string, body []byte, sig string, pubKey string) (bool, error) {
	// 落地时在此处分发到各渠道验签实现。
	return true, nil
}

33.3.4 状态同步

支付成功后,订单系统需要进入「已支付 / 待发货」等状态。推荐用 Transactional Outbox:支付成功与 order_paid 事件同事务提交,再由 Dispatcher 投递到消息系统,订单消费者重试直至成功或进入死信人工处理。

为什么不推荐支付直接 RPC 订单同步更新:回调线程会被订单可用性绑架;一旦订单服务抖动,容易出现「支付已成功但本地事务回滚」的灾难组合。Outbox 把跨系统写入变成同库同事务,失败面显著收敛。

同步查询路径:用户在收银台返回 App 后,前端会高频轮询支付结果。轮询应读取本地支付单状态缓存(短 TTL),命中失败再穿透数据库;穿透时要防止缓存击穿打爆主库。

sequenceDiagram
    autonumber
    participant U as 用户终端
    participant O as 订单服务
    participant P as 支付核心
    participant G as 支付网关
    participant C as 第三方支付

    U->>O: 提交订单 / 请求收银台
    O->>P: CreatePayment(含应付金额、幂等键)
    P->>P: 校验金额与订单状态
    P->>P: 持久化支付单(PENDING)
    P->>G: PreCreate(路由后调用具体渠道)
    G->>C: 统一下单 / 预支付
    C-->>G: 返回支付参数 / 跳转信息
    G-->>P: 标准化受理结果
    P-->>U: 返回收银台渲染数据
    U->>C: 用户完成鉴权与支付
    C-->>G: 异步回调(notify)
    G->>P: ParseNotify + 验签
    P->>P: 状态机:PAYING -> SUCCESS(幂等)
    P->>P: 同事务写入 Outbox(ORDER_PAID)
    P-->>G: 响应 SUCCESS(按渠道要求)
    Note over P,O: Dispatcher 读取 Outbox
    P-->>O: 投递 ORDER_PAID 事件
    O->>O: 订单状态 -> PAID(幂等)

33.4 支付状态机

33.4.1 支付状态

建议将支付单状态控制在可理解且可枚举的集合内。示例(可按业务增删):

  • PENDING:已创单,尚未唤起渠道;
  • PAYING:已唤起渠道,等待最终结果;
  • SUCCESS:支付成功;
  • FAILED:明确失败,可重新发起(是否允许换渠道由产品决定);
  • CLOSED:超时关单或业务关闭;
  • PARTIAL_REFUNDED:仍存在可退余额;
  • REFUNDED:已无可退余额(含全额退款累计闭合)。

33.4.2 状态转换

状态转换必须集中校验,禁止在 Handler 内随手 UPDATE status='SUCCESS'。下面给出集中规则表思路(节选):

var allowed = map[string][]string{
	"PENDING":  {"PAYING", "CLOSED"},
	"PAYING":   {"SUCCESS", "FAILED", "CLOSED"},
	"SUCCESS":  {"PARTIAL_REFUNDED", "REFUNDED"},
	"PARTIAL_REFUNDED": {"PARTIAL_REFUNDED", "REFUNDED"},
}

func CanTransit(from, to string) bool {
	nexts, ok := allowed[from]
	if !ok {
		return false
	}
	for _, n := range nexts {
		if n == to {
			return true
		}
	}
	return false
}

33.4.3 超时处理

PAYING 状态建议配置支付超时时间(与渠道侧 TTL 对齐),由定时任务扫描:

  1. QueryTrade 主动确认,防止「用户已付但回调丢失」;
  2. 若渠道仍返回处理中,推迟下次扫描(指数退避);
  3. 若超过最大等待仍不明,标记 UNKNOWN 或保持 PAYING 并提升告警,禁止直接 SUCCESS

状态历史表强烈建议与业务表解耦:每次迁移插入 payment_status_history(old,new,actor,reason)。客服在工单系统里追问「谁把支付单改成 SUCCESS」时,历史表比 grep 日志可靠得多。若还需满足合规审计,可对历史表做只追加与定期归档。

退款单状态机(与支付单联动,字段命名示例):

stateDiagram-v2
    [*] --> RF_PENDING: CreateRefund
    RF_PENDING --> RF_PROCESSING: 调用渠道退款
    RF_PROCESSING --> RF_SUCCESS: 渠道确认成功
    RF_PROCESSING --> RF_FAILED: 明确失败
    RF_FAILED --> RF_PROCESSING: 人工重试 / 改派渠道
    RF_SUCCESS --> [*]
type RefundStatus string

const (
	RefundPending    RefundStatus = "PENDING"
	RefundProcessing RefundStatus = "PROCESSING"
	RefundSuccess    RefundStatus = "SUCCESS"
	RefundFailed     RefundStatus = "FAILED"
)

func RefundCanTransit(from, to RefundStatus) bool {
	switch from {
	case RefundPending:
		return to == RefundProcessing
	case RefundProcessing:
		return to == RefundSuccess || to == RefundFailed
	case RefundFailed:
		return to == RefundProcessing
	default:
		return false
	}
}
stateDiagram-v2
    [*] --> PENDING: CreatePayment
    PENDING --> PAYING: PreCreate 成功
    PENDING --> CLOSED: 主动关单 / 订单取消
    PAYING --> SUCCESS: 回调或查单确认成功
    PAYING --> FAILED: 明确失败
    PAYING --> CLOSED: 超时 + 查单无成功
    SUCCESS --> PARTIAL_REFUNDED: 部分退款成功累计
    PARTIAL_REFUNDED --> PARTIAL_REFUNDED: 继续部分退款
    PARTIAL_REFUNDED --> REFUNDED: 剩余可退为 0
    SUCCESS --> REFUNDED: 全额退款完成

33.5 退款流程

33.5.1 退款创建

退款单应独立建模,关联 payment_idorder_id,并具备自己的幂等键(如 order_id + refund_batch_no)。创建退款单时需要:

  • 校验支付单处于可退状态;
  • 校验退款权限(售后窗口、履约状态由订单域返回或事件驱动);
  • 锁定「可退余额」计算,防止并发双退。

并发双退的典型漏洞是「两次请求同时读到相同已退金额」。工程上可用数据库行锁SELECT ... FOR UPDATE 锁支付单)或原子 SQLUPDATE payment SET refunded = refunded + ? WHERE id=? AND paid-refunded>=?)保证上限。若退款跨多个支付单(组合支付),要么在订单域生成退款编排单一次性下发,要么在支付域引入分布式锁 / 事务消息串行化。

// 以下为退款创建事务骨架:CreateRefundCmd / Refund / lockPaymentForRefund 由项目定义。
func (s *RefundService) CreateRefund(ctx context.Context, cmd CreateRefundCmd) (*Refund, error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()

	p, err := lockPaymentForRefund(ctx, tx, cmd.PaymentID)
	if err != nil {
		return nil, err
	}
	if p.Status != StatusSuccess && p.Status != StatusPartialRefunded {
		return nil, fmt.Errorf("payment not refundable")
	}
	refundable := p.PaidFen - p.RefundedFen
	if cmd.AmountFen <= 0 || cmd.AmountFen > refundable {
		return nil, fmt.Errorf("invalid refund amount")
	}
	r := NewRefund(cmd)
	if err := insertRefund(ctx, tx, r); err != nil {
		return nil, err
	}
	return r, tx.Commit()
}

33.5.2 可退金额计算

可退金额应以支付成功时的实付为上限,扣减已成功退款单金额,并处理营销侧「平台承担 / 商家承担 / 用户让渡」的拆分。教学示例:

type Money = int64 // 分

func Refundable(paid Money, refunded Money) (Money, error) {
	if paid < 0 || refunded < 0 {
		return 0, fmt.Errorf("invalid money")
	}
	if refunded > paid {
		return 0, fmt.Errorf("refunded overflow")
	}
	return paid - refunded, nil
}

真实系统还要处理:运费是否可退行级分摊是否已闭合跨境税额等,这些规则应读取订单退款域算好的结构化结果,而不是在支付服务里拍脑袋重算。

当订单存在「平台券抵扣」时,常见业务口径是:按本次退款占实付比例回冲营销账。下面给出与博客一致的比例思路(教学版,舍入策略需统一):

type RefundBreakdown struct {
	RefundCashFen      int64
	RefundPromotionFen int64
}

// 假设 promotion 由平台承担,需要单独记账回冲;现金部分走渠道退款。
func AllocatePromotionRefund(paidFen, promotionFen, refundCashFen int64) RefundBreakdown {
	if paidFen <= 0 || refundCashFen <= 0 {
		return RefundBreakdown{RefundCashFen: refundCashFen}
	}
	if promotionFen <= 0 {
		return RefundBreakdown{RefundCashFen: refundCashFen}
	}
	// 按比例拆分营销回冲;生产请使用 decimal 或整数比避免累积误差。
	promo := (promotionFen * refundCashFen) / paidFen
	cash := refundCashFen - promo
	return RefundBreakdown{RefundCashFen: cash, RefundPromotionFen: promo}
}

33.5.3 部分退款

每一次部分退款生成独立退款单,记录渠道退款单号。支付单维度的 refunded_amount 单调递增,直到等于 paid_amount 才进入 REFUNDED。若业务需要展示「第 N 次退款」,应对退款单列表做分页查询。

33.5.4 营销退款

当订单存在平台券、满减、积分抵现时,退款往往不仅是「把钱退回支付渠道」,还包括:

  • 营销资产回冲:券是否退回、积分是否返还;
  • 补贴冲销:平台补贴在清结算层的冲减分录。

支付系统应消费订单域提供的退款分解单(RefundBreakdown),将其映射为支付退款 + 财务应收应付调整。不要在支付回调里直接调用营销扣减接口的长链路同步调用,避免放大故障半径。

sequenceDiagram
    participant U as 用户
    participant O as 订单服务
    participant P as 支付核心
    participant G as 支付网关
    participant C as 第三方支付
    participant M as 营销系统

    U->>O: 申请退款
    O->>O: 售后校验 / 生成退款分解
    O->>P: CreateRefund(幂等键 + 分解单)
    P->>P: 计算可退并落退款单
    P->>G: 渠道退款
    G->>C: Refund API
    C-->>G: 受理 / 同步结果
    G-->>P: 标准化结果
    P->>P: 更新退款单与支付单累计
    P-->>O: RefundSucceeded 事件
    O->>M: 异步冲销补贴 / 退券(可编排)

33.6 清结算与对账

33.6.1 分账模型

清结算层把单笔支付成功事实拆成多方应收应付:平台佣金商家货款渠道手续费营销补贴等。模型要点:

  • 分账版本:规则应版本化,支付单引用 split_rule_version
  • 最小粒度:通常到「子订单 / 明细行」级别,避免汇总误差;
  • 冻结与解冻:未到结算日期的资金先记入「待结算余额」,防止重复提现。

示例(简化,不含税与渠道费):用户实付 100 元,平台佣金 5%,商家货款 95%,营销补贴由平台另行记账,不重复从商家侧扣减。

角色口径金额(元)说明
用户实付100支付单记录
平台佣金5清结算生成应收
商家货款95进入待结算余额
渠道手续费按渠道账单往往单独维度对账

分账计算输入应来自支付成功事件 + 订单行快照,而不是实时去读商品中心促销价,否则会出现「支付按 A 规则、结算按 B 规则」的结构性差错。

33.6.2 T+N结算

T+N 表示在交易发生日 T 之后第 N 个工作日完成可提现或完成渠道结算。工程上要区分:

  • 渠道结算周期(微信支付宝对平台);
  • 平台对商家账期(业务合同)。

两者不一致时,现金流收入确认可能不同步,财务口径由会计政策决定,技术侧提供可追溯批次与明细即可。

提现限额属于清结算风控交叉域:既要满足合规,又要避免误伤正常商家。可参考如下校验骨架:

func ValidateWithdraw(ctx context.Context, merchantID int64, amountFen int64, sumToday int64) error {
	const singleLimit = 50_000_00 // 50 万(分)
	const dailyLimit = 200_000_00
	if amountFen > singleLimit {
		return fmt.Errorf("single withdraw limit exceeded")
	}
	if sumToday+amountFen > dailyLimit {
		return fmt.Errorf("daily withdraw limit exceeded")
	}
	return nil
}

sumToday 由仓储层查询当日已提现金额后传入,避免示例函数隐式依赖未定义符号。

33.6.3 对账流程

对账的本质是:用第三方权威数据校准本地事实。本地事实应至少包括支付单、渠道流水号、金额、手续费、清算日期。对账任务必须幂等:同一日的文件重复拉取不应产生重复分录。

实现要点:先把第三方文件标准化为 ReconRow{trade_no, amount, fee, currency, trade_time},再与本地 payment_channel_log 做外连接。Join 键应优先使用渠道交易号,其次才是商户订单号(部分渠道存在换单号)。对账批任务写入 recon_batch 表,明细写入 recon_diff,避免直接在支付单上打补丁丢失审计链。

func DiffOne(local, remote ReconRow) string {
	switch {
	case local.TradeNo == "":
		return "LONG"
	case remote.TradeNo == "":
		return "SHORT"
	case local.AmountFen != remote.AmountFen:
		return "AMOUNT_MISMATCH"
	default:
		return "OK"
	}
}
flowchart TD
  A[定时触发 T 日对账] --> B[拉取渠道对账文件 / API]
  B --> C[解析入库 staging]
  C --> D[按 channel_trade_no join 本地流水]
  D --> E{差异检测}
  E -->|无差异| F[生成对账成功批次]
  E -->|长款| G[第三方有本地无]
  E -->|短款| H[本地有第三方无]
  E -->|金额不一致| I[金额 / 币种 / 手续费差异]
  G --> J[差错工单 + 自动修复策略评审]
  H --> J
  I --> J
  J --> K[人工复核 / 调账 / 补单]
  K --> F

33.6.4 差错处理

差错应分类闭环:数据修复类(补记支付成功)、重复记账类(幂等破坏,需冻结)、金额差异类(舍入、币种转换、部分退款叠加)。下面示例演示「将差错写入不可变表 + 状态机」的思路:

type ReconIssueType string

const (
	IssueLong  ReconIssueType = "LONG"  // 渠道多
	IssueShort ReconIssueType = "SHORT" // 本地多
	IssueAmt   ReconIssueType = "AMOUNT_MISMATCH"
)

type ReconIssue struct {
	ID               int64
	Type             ReconIssueType
	ChannelTradeNo   string
	LocalPaymentID   int64
	ExpectedAmountFen int64
	ActualAmountFen   int64
	Status           string // OPEN / APPROVED / FIXED
	CreatedAt        time.Time
}

func (s *ReconService) OpenIssue(ctx context.Context, issue ReconIssue) error {
	// 插入唯一键:(channel, channel_trade_no, type) 防止重复开单
	return s.repo.InsertIssue(ctx, issue)
}

自动修复只应对极少数确定性场景开放(例如「回调晚到导致短款」且查单已证实成功),且必须双人复核或二次审批,避免自动化把资金风险放大。

人工复核材料包应一键生成:本地流水、渠道流水、原始回调、查单响应、订单快照、客服沟通记录链接。没有材料包的差错工单往往会在团队之间空转数日,最后靠「某个老员工记得当时切了灰度」来收场——这是可复用的技术债。

长短款的业务含义也要培训到位:长款不等于立刻给用户加余额,短款也不等于立刻从商家扣回;它们首先是对账系统的待确认差异项,必须经过规则引擎与人工阈值判断,避免把运营操作变成新的资金风险源。


33.7 幂等性与一致性

33.7.1 支付幂等

幂等键分层建议:

场景幂等键实现要点
创建支付单业务方传入 Idempotency-KeyDB 唯一索引 + 冲突回查
渠道预下单payment_id 映射 out_trade_no渠道侧 out_trade_no 唯一
回调处理channel_trade_no + 支付单 version验签后乐观锁更新
退款创建refund_idempotency_key与订单退款单绑定
消息消费event_id / 业务唯一键consumer 侧去重表或状态条件更新

幂等与「恰好一次」:分布式系统里更现实的目标是效果幂等——重复执行不会产生额外副作用。消息系统通常是至少一次投递,因此消费者必须能扛重复。

与第 4 章的衔接:支付是幂等设计「压力最大的考场」,因为它同时承受用户重试、渠道重放、内部补偿三路冲击。建议把幂等键规范写成跨团队接口标准(HTTP Header 命名、长度、字符集、过期策略),否则订单、支付、营销各自发明一套键,联调阶段会指数级爆炸。

33.7.2 重试机制

重试划分为三类:

  1. 用户端重试:按钮防抖 + 服务端幂等;
  2. 同步调用重试:仅对幂等读、幂等写(带键)执行有限次退避;
  3. 异步补偿重试:Outbox、消息队列、定时查单,需要最大重试次数 + 死信队列
func Retry(ctx context.Context, attempts int, base time.Duration, fn func() error) error {
	var err error
	for i := 0; i < attempts; i++ {
		if err = fn(); err == nil {
			return nil
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(base * time.Duration(1<<i)):
		}
	}
	return err
}

退避与抖动:对渠道主动查询类任务,应加随机抖动,避免整点对渠道形成查询尖峰。对内部消息重试,应区分可重试错误(网络)与业务错误(余额不足),后者重试只会放大噪音。

33.7.3 补偿任务

补偿任务清单建议包括:

  • 支付结果补偿PAYING 超时查单;
  • 通知订单补偿:Outbox 未投递;
  • 退款结果补偿:退款受理中查单;
  • 对账补偿:文件拉取失败重试。

补偿任务要有全局锁或分区调度,避免多实例重复打满渠道 QPS。

Saga + 本地消息表(Outbox 的等价实现):当团队尚未引入独立 Outbox 组件时,可用「支付成功 + 本地消息」同事务,定时任务扫描投递。

import (
	"database/sql"
	"encoding/json"
)

type LocalMessage struct {
	ID         int64
	Topic      string
	Payload    []byte
	Status     string // PENDING/SENT/FAILED
	RetryCount int
	CreatedAt  time.Time
}

func marshalOrderPaid(orderID, paymentID int64) ([]byte, error) {
	return json.Marshal(map[string]any{
		"order_id":   orderID,
		"payment_id": paymentID,
	})
}

func OnPaymentSuccessTx(tx *sql.Tx, paymentID, orderID int64) error {
	if _, err := tx.Exec(`UPDATE payment SET status='SUCCESS' WHERE id=?`, paymentID); err != nil {
		return err
	}
	payload, err := marshalOrderPaid(orderID, paymentID)
	if err != nil {
		return err
	}
	_, err = tx.Exec(`
		INSERT INTO local_message(topic,payload,status,retry_count,created_at)
		VALUES('ORDER_PAID', ?, 'PENDING', 0, NOW())
	`, payload)
	return err
}
sequenceDiagram
    participant P as 支付核心
    participant DB as 数据库
    participant JOB as 投递任务
    participant MQ as 消息队列
    participant O as 订单服务

    P->>DB: BEGIN
    P->>DB: UPDATE payment SUCCESS
    P->>DB: INSERT local_message PENDING
    P->>DB: COMMIT
    JOB->>DB: SELECT PENDING LIMIT N
    JOB->>MQ: Publish ORDER_PAID
    MQ-->>O: deliver
    O->>O: 幂等更新订单 PAID
    JOB->>DB: UPDATE local_message SENT

TCC 何时值得:当余额类扣减与渠道退款需要短窗口内强一致,且参与者可控(内部服务)时,TCC 仍有一席之地。但其运维成本、悬挂事务处理、监控接入都显著高于 Saga。多数电商支付主链路仍以 Saga + 对账 为主,TCC 用于账户冻结 / 营销锁定等局部。

一致性小结:支付与订单之间优先接受最终一致,用「事务边界内的状态 + Outbox」保证至少一次投递;消费者侧必须幂等。强一致场景(如余额 + 渠道同时扣减)谨慎使用 TCC,成本高且难维护。

账户余额与缓存(常见追问):若余额读走 Redis,必须定义回源与修复策略(定时对账或以 MySQL 为准覆盖)。支付扣减建议「数据库为权威 + Redis 仅作加速」,否则容易出现 Redis 与 DB 长时间分叉不自知。


33.8 系统边界与职责

边界章节的判据很简单:如果某个需求改动会让支付团队与订单团队同时大改表结构,通常说明边界画错了。好的边界让「最常变」的渠道差异停在网关,让「最不该变」的资金状态机停在支付核心。

33.8.1 支付系统的职责边界

支付系统应拥有:支付单与退款单渠道交互与回调验签支付侧流水与幂等索引触发清结算批次的事实渠道对账原始凭证关联。不应拥有:订单履约、物流、商品库存数量真相(除非余额支付与账户强绑定)。

反模式清单已统一收录到第 39 章的“支付系统整体架构”题卡

33.8.2 支付 vs 订单:谁负责什么

主题订单域支付域
应付金额解释引用计价快照校验快照与支付单金额
支付状态PAID 等业务状态SUCCESS 等资金状态
关单 / 取消驱动是否允许继续支付执行关单并同步渠道撤销(若支持)
售后退款策略是否允许退、退多少(业务规则)执行资金退回与累计已退
发票与税务展示订单展示口径提供支付流水号、渠道单号

关单竞态:用户支付最后一秒订单被取消,或支付成功回调晚于关单。必须在订单状态机定义终态优先级(例如「已支付优先于待支付关闭」),支付侧也要能识别「订单已关但支付已成功」并进入异常工单而不是静默吞掉。

33.8.3 支付 vs 财务清结算

支付系统产出资金事实与分账明细,财务系统将其映射为会计凭证税务口径。不要在支付库直接记总账。

技术团队常低估的点:财务需要期间币种折算信息,而支付系统常只存「展示币种」。若平台做多币种,应在支付成功事件中固化清算币种与汇率来源,否则月末调账会演变成跨团队扯皮。

33.8.4 平台支付 vs 第三方支付渠道

平台支付(余额、礼品卡)往往走账户系统闭环,仍需流水与对账;第三方支付走渠道。组合支付要定义失败回滚顺序(例如先渠道后余额或相反),并在状态机里显式建模。

部分成功是组合支付的最大坑:渠道成功、余额扣减失败如何处理?常见策略是:先扣内部可控资源,再调渠道(降低外部不可控失败面),或在产品层直接禁止某些组合。无论哪种,都要写进用户可见的错误文案与客服话术。

33.8.5 支付 vs 钱包 / 余额

余额属于预付价值,涉及充值、提现、冻结、监管要求(视地区)。建议独立「账户子域」,支付核心通过账户服务完成扣减,避免把账户表与支付单表强耦合在同一张宽表。

钱包若支持「零钱 + 银行卡」混合,仍建议把零钱视作内部渠道走同一套路由与对账框架,这样运营监控可以统一看「渠道成功率」,而不是另起炉灶一套报表。


33.9 与其他系统的集成

集成章节的共同目标是:把支付系统变成可替换、可观测、可回滚的协作节点,而不是「所有系统都要在支付回调里串一圈」的上帝节点。

33.9.1 与订单系统集成(状态同步)

订单系统应订阅 ORDER_PAID 事件或通过同步 API(弱不推荐)更新状态。无论哪种,订单更新接口必须幂等:重复 payment_id 不应推进到非法状态。

推荐事件载荷至少包含:order_idpayment_idpaid_amount_fencurrencypricing_snapshot_versionpaid_at。订单侧据此做二次校验(金额是否与创单快照一致),不一致进入人工工单而不是静默成功。

33.9.2 与营销系统集成(补贴核算)

营销补贴如果是支付时分账,需要明确分账参与方与失败重试;如果是事后结算,支付成功事件应携带可被清结算消费的补贴分解标识。

若营销侧需要「支付成功后才真正扣减预算」,必须定义失败回滚语义:支付关单时发送 PAYMENT_CLOSED,营销消费者释放锁定;若营销扣减失败但支付已成功,应进入异步补扣或人工处理,绝不能反向把支付单改成失败。

33.9.3 与用户系统集成(余额 / 积分)

余额支付应走 Deduct -> ConfirmTry -> Confirm/Cancel 的可补偿路径,并与支付单状态机关联。积分抵现建议由订单 / 计价域先行锁定,支付成功后再确认扣减,失败则释放。

余额账户建议提供可查询的冻结单号与支付单关联,客服排障时可以直接回答「这笔钱对应哪笔冻结」。积分系统若延迟较高,应避免在支付回调线程同步等待。

33.9.4 与第三方支付渠道集成

渠道集成要点:证书轮换时钟同步回调 IP 白名单沙箱与生产隔离配置。网关层提供模拟器(mock)支撑联调。

生产环境还需准备:渠道公告订阅(费率、维护窗口)、密钥到期提醒多商户号容灾(主商户异常时切备用)。渠道 SDK 升级应走灰度,并用回放样本验证验签与解析路径。

33.9.5 与财务系统集成(分账与对账)

向财务导出结算批次明细行,并保证金额字段为整数分、附带币种与汇率快照。任何手工调账必须留下审计记录。

财务更关心会计期间科目映射:技术侧输出应携带 biz_datesettlement_batch_idmerchant_idfee_item。避免让财务同学从 JSON 大字段里手工抠数。

33.9.6 集成异常处理与重试

对下游失败应区分:可重试(网络抖动)不可重试(业务拒绝)需要人工(数据不一致)。消息消费者应使用幂等处理表或业务唯一键防重复消费。

典型异常:订单服务短暂不可用 → Outbox 堆积;解决思路是扩容消费者、限流非核心订阅、并对核心 ORDER_PAID 单独 topic 保障 SLA。另一类异常是订单返回成功但内部逻辑部分失败(例如库存服务超时)——这属于订单域自己的 Saga,支付侧不应「自作主张退款」,除非产品明确配置自动拒单策略。

33.9.7 回调幂等性保证

回调幂等的工程清单:

  1. 验签失败直接拒绝,记录原始报文哈希;
  2. 以渠道交易号为天然幂等键,数据库唯一索引;
  3. 状态推进使用乐观锁versionstatus + updated_at 条件更新);
  4. 成功响应只在本地事务提交后返回,避免「渠道认为成功、本地实际失败」;
  5. 对重复回调返回与首次一致的业务成功响应,避免渠道无限重试。
func (s *NotifyHandler) Handle(ctx context.Context, ev NotifyEvent) error {
	return s.tx.Run(ctx, func(tx Tx) error {
		p, err := tx.LockPayment(ctx, ev.OutTradeNo)
		if err != nil {
			return err
		}
		if p.Status == StatusSuccess {
			return nil
		}
		if !CanTransit(string(p.Status), string(StatusSuccess)) {
			return fmt.Errorf("invalid transit: %s -> SUCCESS", p.Status)
		}
		if err := tx.UpdatePaymentSuccess(ctx, p.ID, p.Version, ev); err != nil {
			return err
		}
		return tx.EnqueueOutbox(ctx, OutboxOrderPaid{OrderID: p.OrderID, PaymentID: p.ID})
	})
}

33.10 工程实践

33.10.1 多渠道接入

新渠道接入建议清单:沙箱对齐用例集、字段映射表、对账文件样本、异常码枚举、回调重放工具、灰度开关(按商户 / 百分比)。

建议为每个渠道维护兼容性矩阵:API 版本、最低 SDK 版本、已知缺陷列表(例如某版本退款接口延迟)。上线前用「同一批黄金用例」在沙箱回放,避免只在 happy path 自测。

33.10.2 性能优化

热点路径:创单读多写少可用缓存;回调写路径应短事务,只更新必要列;大字段(原始报文)异步落对象存储。避免在回调线程同步调用多个下游。

数据库层可对 payment(status,updated_at)refund(payment_id,status) 建立合适组合索引;对 channel_trade_no 建立唯一索引支撑幂等。大促前做容量评估:预估回调峰值、写入 QPS、消息投递延迟,并准备只读副本承载客服查询。

33.10.3 监控告警

最低限度指标:notify_latencynotify_fail_ratepaying_timeout_countoutbox_backlogrecon_open_issuesrefund_unknown_count。每条指标应能下钻到 payment_id

告警阈值建议分层:页面级(影响用户支付成功率)、资金级(对账差异、短款)、运维级(证书到期、磁盘满)。资金级告警必须带跳转链接到工单或 Runbook,减少 On-call 临场检索成本。

33.10.4 资金安全

原则:最小权限密钥双人复核调账不可变审计日志敏感信息脱敏展示关键操作二次验证。技术方案之外,运营流程同样是系统的一部分。

建议每年至少进行一次红队演练或渗透测试,覆盖伪造回调、重放报文、越权查询他人支付单等路径;密钥使用 KMS / HSM 托管,开发人员默认不应接触生产明文私钥。


33.11 本章小结

本章从业务背景出发,给出了支付平台的分层架构图,并以时序图贯穿「创单 → 路由 → 回调 → Outbox 同步订单」的主链路;用状态机图约束支付单生命周期,并补充退款单状态联动;在清结算与对账部分给出分账示例、T+N 口径区分、对账 join 思路与差错闭环流程图;在幂等与一致性部分拆分创建、回调、退款、消息消费等幂等键,并给出指数退避重试、补偿任务调度、Saga + 本地消息表与 TCC 选型边界;最后通过系统边界对外集成回扣订单、营销、用户、渠道、财务协作中的异常分层与回调幂等清单。

若把本章压缩成上线前检查表,可以只保留八条:唯一幂等键验签先于业务状态机集中校验成功响应晚于提交Outbox 同事务消费者幂等对账可回放密钥与权限最小化。这八条都做到,未必能保证「永不故障」,但能保证故障可定位、可止血、可复盘

把支付系统做好,本质上是在持续回答一句话:在不可靠的网络与不可控的第三方之上,如何让用户与平台都相信「这笔钱的状态是真的」。下一章将进入全书综合案例(第 34 章),从平台视角回看支付在整体架构中的位置与演进路径。


第 34 章 B2B2C 平台完整架构

综合案例:一个中大型B2B2C电商平台的完整架构设计,从品类分析到技术选型,从系统设计到团队协作,覆盖200+人团队、日订单200万级的实战经验与架构决策。


34.1 项目背景与业务约束(Business Context)

本章讨论的是一个中大型 B2B2C 聚合电商平台。它不是传统实物电商,也不是单一供应商商城,而是连接多类外部供应商与自营虚拟商品的平台型系统。平台侧负责商品组织、搜索导购、价格试算、营销、下单、支付和履约编排;供应商侧负责实际资源确认与数字履约。

这个背景很重要。因为系统的核心复杂度不来自物流仓配,而来自多品类、多供应商、强实时交易和不一致外部接口的叠加:机票和酒店要求零超卖,充值和礼品卡允许失败后补偿,电子券又依赖本地券码池发放。不同品类背后的库存、价格、履约模型完全不同,直接决定后续的领域划分、服务边界和一致性策略。

34.1.1 业务定位

平台采用“聚合供应商 + 自营虚拟商品”的 B2B2C 模式,连接航司/GDS、酒店 OTA/PMS、运营商、院线、券码供应商等外部系统,同时保留部分自营业务能力。

业务范围

业务类型典型品类履约方式关键特征
供应商聚合机票、酒店、充值、电影票调用供应商 API 完成出票、确认、充值、锁座接口差异大,实时性和可用性依赖外部系统
平台自营优惠券、线下券、礼品卡平台本地发券码或调用内部发码系统可控性更强,但需要券码池、核销和过期管理
数字履约所有品类API 调用、异步确认、电子凭证发放无物流链路,但交易状态和补偿链路更复杂

业务全景图

B2B2C 数字商品平台业务全景

这张图展示了平台业务链路的四个关键视角:供应商供给侧负责资源供给与数字履约;平台核心能力负责商品、库存、价格、营销、订单、支付、履约和售后编排;本地商家与平台运营负责商品录入、审核、维护、促销和上下架;C 端用户则围绕搜索导购、结算下单、支付、履约结果和退款售后形成完整交易闭环。

本平台的一个关键前提是无物流场景。所有商品都是虚拟数字商品,履约不经过仓库、分拣、配送,而是通过 API 调用、电子票、确认单、充值结果或券码完成。因此,本章不会讨论仓配、物流轨迹、签收等实物电商问题,而是聚焦数字商品平台中更核心的四类问题:

  1. 供应商差异:50+ 外部供应商接口形态不一致,可能同时存在实时查询、定时同步和事件推送。
  2. 库存差异:机票/酒店依赖供应商实时库存,电子券依赖本地券码池,充值类商品近似无限库存。
  3. 价格差异:机票动态定价、酒店日历价、充值固定面额、优惠券固定折扣价并存。
  4. 履约差异:有的品类同步返回结果,有的品类需要异步确认,有的品类需要本地分配唯一券码。

34.1.2 业务与技术目标

平台的核心目标可以概括为以下目标:

  1. 交易链路要稳:订单创建、支付、履约不能丢数据,核心链路故障要快速恢复。
  2. 导购链路要快:搜索、详情、结算要在高并发下保持低延迟,并允许适度降级。
  3. 供给运营要可控:商品上架、运营编辑、供应商同步、促销配置和上下架要有明确流程、审核机制和可追溯记录。
  4. 品类接入要快:新增品类和供应商不能反复改造主流程。
  5. 团队协作要顺:服务边界、API 契约和事件契约必须足够清晰,支撑多人多团队并行开发。

性能目标

指标正常值大促峰值设计含义
日订单量200 万1000 万交易链路需要支持 5 倍峰值弹性
搜索 QPS300015000搜索与聚合层需要缓存、批量查询和降级能力
详情页 QPS500025000商品、库存、计价、营销服务需要并发编排
下单 QPS10005000库存预占、价格校验、订单写入必须控制事务边界
P99 延迟200ms500ms大促期间允许部分非核心能力降级

可用性目标

目标要求说明
核心链路 SLA99.95%覆盖订单创建、支付、履约等交易动作
搜索/详情 SLA99.9%可通过缓存、兜底价格、隐藏营销信息等方式降级
RTO< 5 分钟故障后需要在 5 分钟内恢复核心能力
RPO0核心交易数据不允许丢失

扩展性目标

扩展场景目标关键依赖
新品类接入< 2 周品类策略、库存策略、履约策略可插拔
新供应商接入< 1 周供应商适配器、防腐层、统一错误模型
新营销玩法< 3 天规则引擎、计价输入标准化、活动配置平台

34.1.3 本章的核心架构命题

在上述业务背景下,架构设计的难点不是“要不要拆微服务”,而是如何在品类差异、供应商不确定性和交易一致性之间找到可演进的边界

本章后续会围绕四个问题展开:

  1. 如何理解品类差异:机票、酒店、充值、电子券为什么不能用同一套库存和履约模型。
  2. 如何划分系统边界:订单、商品、库存、计价、营销、支付、供应商网关各自拥有怎样的数据和职责。
  3. 如何组织交易链路:搜索、详情、结算、下单、支付如何在性能与准确性之间取舍。
  4. 如何沉淀架构决策:通过 ADR 记录关键取舍,避免团队在同一问题上反复争论。

34.2 品类业务模型分析(Business Architecture)

不同品类的业务模型存在显著差异,直接影响架构设计决策。理解这些差异是系统设计的基础。

34.2.1 机票业务模型

业务特点

• 库存模型:实时库存(供应商侧),强依赖供应商实时查询
• 价格模型:动态定价,实时波动(可能秒级变化)
• SKU复杂度:极高(航司+航班号+舱位+日期+...组合)
• 库存单位:座位数量(不可超卖)
• 扣减时机:创单前向供应商实时确认并占座 → 创单待支付 → 支付确认 → 出票
• 履约流程:查询报价/库存 → 占座/预订 → 创建订单 → 支付 → 出票(调用GDS/供应商API)→ 发送电子票

架构影响

  • ✓ 必须支持实时库存查询(高频调用供应商API)
  • ✓ 价格快照必须精确到秒级,防止价格变动纠纷
  • ✓ 超卖零容忍 → 创建订单前必须完成供应商侧占座/预订
  • ✓ 供应商故障需快速切换到备用供应商
  • ✓ 订单状态复杂(待出票、出票中、出票失败、已出票)

技术要点

// 机票库存查询策略
type FlightStockStrategy struct {
    supplierClient rpc.SupplierClient
    redis          redis.Client
    config         *FlightConfig
}

func (s *FlightStockStrategy) CheckStock(ctx context.Context, req *StockRequest) (*StockResponse, error) {
    // Step 1: 尝试从Redis获取缓存(TTL=5分钟)
    cacheKey := fmt.Sprintf("flight:stock:%s:%s", req.FlightNo, req.Date)
    cached, err := s.redis.Get(ctx, cacheKey).Result()
    if err == nil {
        return parseStockFromCache(cached), nil
    }
    
    // Step 2: 缓存未命中,调用供应商实时查询
    ctx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)  // 800ms超时
    defer cancel()
    
    stock, err := s.supplierClient.QueryStock(ctx, req)
    if err != nil {
        // 供应商故障,切换备用供应商
        return s.fallbackToSecondarySupplier(ctx, req)
    }
    
    // Step 3: 缓存结果(短TTL,机票价格变化快)
    s.redis.Set(ctx, cacheKey, marshal(stock), 5*time.Minute)
    
    return stock, nil
}

监控指标

  • 供应商调用超时率:< 1%
  • 缓存命中率:> 70%
  • 出票成功率:> 99.5%
  • 出票平均时长:< 30秒

34.2.2 酒店业务模型

业务特点

• 库存模型:房间数量(按日期维度管理)
• 价格模型:日历房价(每个日期不同价格)
• SKU复杂度:高(酒店ID+房型+日期范围+早餐+...)
• 库存单位:房间数/间夜数
• 扣减时机:下单预占 → 支付确认 → 供应商确认
• 履约流程:下单 → 支付 → 提交供应商 → 确认单 → 入住凭证

架构影响

  • ✓ 支持日期范围查询(check-in到check-out)
  • ✓ 日历价格存储(每个日期一条记录)
  • ✓ 库存按日期维度管理(某天无房不影响其他日期)
  • ✓ 支持“担保“模式(先占房,入住时结算)
  • ✓ 需处理“确认单延迟“(供应商异步确认)

数据模型

// 酒店日历价格表(宽表存储)
type HotelCalendarPrice struct {
    HotelID      int64     `gorm:"primaryKey"`
    RoomTypeID   int64     `gorm:"primaryKey"`
    Date         time.Time `gorm:"primaryKey;index"`  // 日期维度
    BasePrice    int64     // 基础价格(分)
    WeekendPrice int64     // 周末价格
    Stock        int       // 当日库存
    Status       string    // 可售状态(AVAILABLE/SOLD_OUT/CLOSED)
}

// 查询日期范围内的价格与库存
func (r *HotelRepo) GetCalendarPrice(hotelID, roomTypeID int64, checkIn, checkOut time.Time) ([]*HotelCalendarPrice, error) {
    var prices []*HotelCalendarPrice
    err := r.db.Where("hotel_id = ? AND room_type_id = ? AND date >= ? AND date < ?",
        hotelID, roomTypeID, checkIn, checkOut).
        Order("date ASC").
        Find(&prices).Error
    return prices, err
}

缓存策略

  • 热门酒店:30分钟缓存
  • 长尾酒店:1小时缓存
  • 价格变更:主动失效缓存

34.2.3 充值业务模型

业务特点

• 库存模型:无限库存(供应商侧无限制)
• 价格模型:固定面额(10元、50元、100元)
• SKU复杂度:低(运营商+面额)
• 库存单位:无限
• 扣减时机:支付后
• 履约流程:下单 → 支付 → 调用供应商API → 充值成功/失败

架构影响

  • ✓ 无需库存管理(库存类型=无限)
  • ✓ 价格简单(基础价+平台服务费)
  • ✓ 超卖可接受(事后补偿)
  • ✓ 供应商调用简单(同步API,3秒内返回)
  • ✓ 失败重试友好(幂等性强)

技术要点

// 充值库存策略(无限库存)
type RechargeStockStrategy struct{}

func (s *RechargeStockStrategy) CheckStock(ctx context.Context, req *StockRequest) (*StockResponse, error) {
    // 充值类商品无需检查库存,直接返回"可售"
    return &StockResponse{
        Available: true,
        Quantity:  999999,  // 虚拟无限库存
        Message:   "充值类商品,库存充足",
    }, nil
}

func (s *RechargeStockStrategy) Reserve(ctx context.Context, req *ReserveRequest) (*ReserveResponse, error) {
    // 充值类商品无需预占,直接返回成功
    return &ReserveResponse{
        ReserveID: "",  // 无预占ID
        Success:   true,
    }, nil
}

34.2.4 电子券业务模型

业务特点

• 库存模型:固定库存(券码池)
• 价格模型:固定折扣价
• SKU复杂度:中(商户+门店+商品+...)
• 库存单位:券码(一券一码)
• 扣减时机:支付后
• 履约流程:下单 → 支付 → 发券码 → 到店核销

架构影响

  • ✓ 券码池管理(预生成10万个券码)
  • ✓ 券码发放(支付后随机分配)
  • ✓ 核销系统(商户扫码核销)
  • ✓ 过期管理(券有效期7天-180天)
  • ✓ 退款逻辑(未核销可退,已核销不可退)

技术要点

// 券码池管理:MySQL 是权威,Redis LIST 只缓存 code_id
type VoucherCodePool struct {
    redis redis.Client
    repo  CodePoolRepository
}

func (p *VoucherCodePool) ReserveCode(ctx context.Context, batchID string, orderID int64) (int64, error) {
    for attempt := 0; attempt < 3; attempt++ {
        // Step 1: Redis LIST 只取 code_id,不取明文券码
        poolKey := fmt.Sprintf("inventory:code:pool:%s:%d", batchID, shard(batchID))
        codeID, err := p.redis.LPop(ctx, poolKey).Int64()
        if err == redis.Nil {
            return 0, errors.New("券码池为空")
        }
        if err != nil {
            return 0, err
        }

        // Step 2: MySQL CAS 才是锁码成功的判定
        // UPDATE inventory_code_pool_XX
        // SET status='BOOKING', order_id=?, booked_at=NOW()
        // WHERE code_id=? AND status='AVAILABLE'
        ok, err := p.repo.BookCode(ctx, codeID, orderID)
        if err != nil {
            return 0, err
        }
        if ok {
            return codeID, nil
        }
        // Redis 中的陈旧 code_id,丢弃后继续取下一个。
    }

    return 0, errors.New("券码池热队列需要回填")
}

34.2.5 差异化设计策略

通过上述品类分析,我们提炼出三个核心设计维度:

维度1:库存管理类型

类型典型品类库存来源预占策略
实时库存机票、酒店、电影票供应商实时查询创单前确认资源,订单超时释放
池化库存优惠券、礼品卡平台自有(券码池)支付后扣减
无限库存充值、SaaS服务无库存概念无需预占

维度2:价格模型

类型典型品类缓存策略快照策略
动态定价机票5分钟TTL秒级快照
日历定价酒店30分钟TTL日期维度快照
固定定价充值、礼品卡1小时TTL简单快照

维度3:履约模式

类型典型品类调用方式失败处理
同步履约充值同步API(3秒超时)立即重试3次
异步履约机票、酒店异步轮询(30秒/次)补偿任务
券码发放优惠券本地分配(无外部调用)券码池补充

统一抽象

// 品类策略接口(策略模式)
type CategoryStrategy interface {
    // 库存检查
    CheckStock(ctx context.Context, req *StockRequest) (*StockResponse, error)
    // 库存预占
    ReserveStock(ctx context.Context, req *ReserveRequest) (*ReserveResponse, error)
    // 价格计算
    CalculatePrice(ctx context.Context, req *PriceRequest) (*PriceResponse, error)
    // 订单履约
    Fulfill(ctx context.Context, order *Order) (*FulfillResult, error)
}

// 策略工厂(根据品类选择策略)
type CategoryStrategyFactory struct {
    strategies map[CategoryType]CategoryStrategy
}

func (f *CategoryStrategyFactory) GetStrategy(categoryType CategoryType) CategoryStrategy {
    return f.strategies[categoryType]
}

设计原则

  1. 策略模式:每个品类一个策略实现,避免 if-else 地狱
  2. 适配器模式:统一供应商接口差异,降低耦合
  3. 模板方法:下单流程统一,具体步骤由策略实现
  4. 可扩展性:新增品类只需新增策略,不影响主流程

34.3 DDD战略设计与系统边界(Application Architecture - 设计过程)

基于34.2的品类业务分析,本节展示如何运用DDD战略设计方法,从业务领域识别限界上下文、划分系统边界、设计服务间集成方式,最终形成34.4的整体架构全貌。

34.3.1 限界上下文识别

限界上下文是DDD战略设计的核心概念,它定义了一个模型的适用边界。本系统通过事件风暴识别出12个核心限界上下文。

识别过程(事件风暴Workshop):

第1步:领域事件识别(橙色便签)
• OrderCreated(订单创建)
• ProductOnShelf(商品上架)
• StockReserved(库存预占)
• PaymentPaid(支付成功)
• PromotionApplied(促销应用)
...

第2步:聚合命令(蓝色便签)
• CreateOrder(创建订单)
• ReserveStock(预占库存)
• CalculatePrice(计算价格)
• ApplyPromotion(应用促销)
...

第3步:聚合实体(黄色便签)
• Order(订单)
• Product(商品)
• Stock(库存)
• Payment(支付)
• Promotion(促销)
...

第4步:限界上下文识别(用绳子圈起相关的实体/命令/事件)
• 订单上下文:Order + CreateOrder + OrderCreated
• 商品上下文:Product + OnShelfProduct + ProductOnShelf
• 库存上下文:Stock + ReserveStock + StockReserved
...

识别出的12个限界上下文

限界上下文核心聚合根核心职责数据所有权
订单上下文Order订单创建、状态机、履约orders、order_items
商品上下文Product商品信息、类目、属性products、categories
库存上下文Stock库存管理、预占、扣减stocks、stock_logs
计价上下文Price价格计算、试算、快照price_snapshots
营销上下文Promotion营销规则、优惠券、活动promotions、coupons
支付上下文Payment支付、退款、对账payments、refunds
搜索上下文ProductIndex商品搜索、筛选、排序ES索引
用户上下文User用户信息、登录、权限users、roles
供应商上下文Supplier供应商对接、适配、熔断suppliers、supplier_products
购物车上下文Cart购物车管理、合并carts
评价上下文Review用户评价、晒单reviews
消息上下文Notification消息通知、推送notifications

为什么这样划分?

  1. 订单与商品分离

    • 订单关注“交易流程“(下单、支付、履约)
    • 商品关注“商品信息“(SPU/SKU、类目、属性)
    • 分离原因:变化速度不同(订单频繁变更,商品相对稳定)
  2. 库存独立

    • 库存是“资源“,订单/商品都依赖它
    • 库存有独立的生命周期(预占 → 扣减 → 释放)
    • 独立原因:单一职责,避免库存逻辑分散
  3. 计价独立

    • 价格计算涉及多个维度(基础价、营销、优惠券、Coin)
    • 多个场景需要试算(详情页、购物车、结算页)
    • 独立原因:统一计价逻辑,避免不一致
  4. 营销独立

    • 营销规则复杂(满减、折扣、买赠、限时秒杀)
    • 营销活动变化频繁
    • 独立原因:灵活支持新玩法,不影响主流程

上下文大小原则

过小:每个实体一个上下文 ❌
• 导致上下文过多,通信成本高
• 事务边界不清晰

合适:一个聚合根(或紧密相关的聚合根)一个上下文 ✅
• 订单上下文:Order + OrderItem
• 商品上下文:Product + Category

过大:多个不相关的聚合根在一个上下文 ❌
• 导致上下文职责不清晰
• 团队协作困难

34.3.2 上下文映射关系

上下文映射是限界上下文之间的关系,定义了它们如何协作、如何通信、谁主导谁跟随。

本系统的上下文映射图

graph LR
    Order[订单上下文<br/>Order Context] 
    Product[商品上下文<br/>Product Context]
    Inventory[库存上下文<br/>Inventory Context]
    Pricing[计价上下文<br/>Pricing Context]
    Marketing[营销上下文<br/>Marketing Context]
    Payment[支付上下文<br/>Payment Context]
    Search[搜索上下文<br/>Search Context]
    Supplier[供应商上下文<br/>Supplier Context]
    
    Order -->|Customer-Supplier| Product
    Order -->|Customer-Supplier| Inventory
    Order -->|Customer-Supplier| Pricing
    Order -->|Customer-Supplier| Marketing
    Order -->|Customer-Supplier| Payment
    
    Search -->|Conformist| Product
    Search -->|Open Host Service| Product
    
    Inventory -->|Anti-Corruption Layer| Supplier
    Product -->|Anti-Corruption Layer| Supplier
    
    Pricing -->|Shared Kernel| Marketing

映射关系类型

关系类型说明本系统示例实现方式
Customer-Supplier下游(客户)依赖上游(供应商)订单 → 商品
订单 → 库存
同步RPC调用
Conformist下游完全遵循上游模型搜索 → 商品搜索直接使用商品模型
Anti-Corruption Layer下游用防腐层保护自己库存 → 供应商适配器翻译外部模型
Open Host Service上游提供公开服务商品 → 搜索RESTful API + Events
Shared Kernel两个上下文共享部分模型计价 ⇄ 营销共享折扣计算规则
Published Language上游定义标准数据格式订单事件(Kafka)Protobuf/JSON Schema

关键决策解析

决策1:订单 → 商品(Customer-Supplier)

为什么不是Conformist(遵奉者)?
• 订单需要保存商品快照(商品模型可能变化)
• 订单不应该被商品模型变更影响
• 订单有自己的领域模型(OrderItem vs Product)

为什么是Customer-Supplier?
• 订单依赖商品(下游依赖上游)
• 商品提供稳定的API(上游为下游服务)
• 变更需要协商(商品API变更需通知订单团队)

决策2:库存 → 供应商(Anti-Corruption Layer)

为什么需要防腐层?
• 供应商模型不稳定(50+供应商,接口各不相同)
• 防止供应商模型污染库存域
• 便于切换供应商(ACL隔离变化)

防腐层职责:
• 翻译外部模型 → 内部模型
• 统一异常处理
• 适配器模式(每个供应商一个适配器)

决策3:计价 ⇄ 营销(Shared Kernel)

为什么是Shared Kernel?
• 折扣计算规则在两个上下文都需要
• 规则变更需要两个上下文同步
• 共享折扣计算代码(避免重复)

Shared Kernel范围:
• DiscountRule(折扣规则接口)
• PriceBreakdown(价格明细结构)
• 仅共享"计算规则",不共享"数据存储"

上下文通信机制

场景通信方式协议示例
同步查询RPCgRPC + Protobuf订单查询商品信息
同步操作RPCgRPC + Protobuf订单预占库存
异步事件消息队列Kafka + Protobuf订单创建 → 搜索更新销量
批量查询RPCgRPC + Stream批量查询商品价格

34.3.3 边界划分实践案例

┌──────────────────────────────────────────────────────┐
│              接入层(API Gateway)                    │
│  • 鉴权、限流、路由、协议转换                         │
│  • Web/App/小程序统一接入                            │
└──────────────────────────────────────────────────────┘
                          ↓
┌──────────────────────────────────────────────────────┐
│             聚合层(Aggregation Service)             │
│  • 数据编排:并发调用多个微服务                       │
│  • 降级策略:服务故障时的降级处理                     │
│  • 缓存优化:聚合结果缓存                            │
└──────────────────────────────────────────────────────┘
                          ↓
┌─────────────────────────────────────────────────────────────┐
│                   业务服务层(Microservices)                │
│  ┌────────┬────────┬────────┬────────┬────────┬────────┐   │
│  │ Product│Inventory│ Pricing│Marketing│ Order │ Payment│   │
│  │  商品  │  库存  │  计价  │  营销  │  订单 │  支付  │   │
│  └────────┴────────┴────────┴────────┴────────┴────────┘   │
└─────────────────────────────────────────────────────────────┘
                          ↓
┌──────────────────────────────────────────────────────┐
│           基础设施层(Infrastructure)                │
│  • MySQL、Redis、Elasticsearch、Kafka               │
│  • 服务发现(Consul)、服务网格(Envoy)             │
│  • 监控告警(Prometheus、Grafana、Jaeger)          │
└──────────────────────────────────────────────────────┘

分层职责

层级服务职责不负责
接入层API Gateway鉴权、限流、路由业务逻辑、数据编排
聚合层Aggregation数据获取、编排、降级具体业务计算
业务层Microservices单一业务领域逻辑跨域数据获取
基础层Infra存储、消息、监控业务规则

34.3.4 微服务拆分实践

拆分原则

  1. 按业务能力拆分(而非技术层次)
  2. 单一职责:每个服务只负责一个限界上下文
  3. 数据所有权:每个服务拥有自己的数据库
  4. API优先:服务间只通过API或事件通信

核心服务清单

服务名称职责数据库QPS(峰值)团队规模
Product Center商品信息、类目、属性MySQL(4分库)2000012人
Inventory Service库存管理、预占、扣减MySQL+Redis800010人
Pricing Service价格计算、试算、快照MySQL150008人
Marketing Service营销规则、优惠券、活动MySQL+Redis1000012人
Order Service订单创建、状态机、履约MySQL(8分库64表)500015人
Payment Service支付、退款、对账MySQL600010人
Search Service商品搜索、筛选、排序Elasticsearch150008人
User Service用户信息、登录、权限MySQL80006人
Supplier Gateway供应商对接、适配、熔断MySQL+Redis1200015人

聚合服务

服务职责依赖服务
Search Aggregation搜索结果聚合Search + Product + Inventory + Pricing
Detail Aggregation详情页聚合Product + Inventory + Pricing + Marketing
Checkout Aggregation结算页聚合Product + Inventory + Pricing + Marketing

34.3.5 服务依赖关系示例

graph TB
    subgraph 接入层
        Gateway[API Gateway]
    end
    
    subgraph 聚合层
        SearchAgg[搜索聚合]
        DetailAgg[详情聚合]
        CheckoutAgg[结算聚合]
    end
    
    subgraph 业务服务层
        Product[商品中心]
        Inventory[库存服务]
        Pricing[计价服务]
        Marketing[营销服务]
        Order[订单服务]
        Payment[支付服务]
        Search[搜索服务]
    end
    
    subgraph 基础服务
        Supplier[供应商网关]
        User[用户服务]
    end
    
    Gateway --> SearchAgg
    Gateway --> DetailAgg
    Gateway --> CheckoutAgg
    Gateway --> Order
    
    SearchAgg --> Search
    SearchAgg --> Product
    SearchAgg --> Inventory
    SearchAgg --> Pricing
    
    DetailAgg --> Product
    DetailAgg --> Inventory
    DetailAgg --> Pricing
    DetailAgg --> Marketing
    
    CheckoutAgg --> Product
    CheckoutAgg --> Inventory
    CheckoutAgg --> Pricing
    CheckoutAgg --> Marketing
    
    Order --> Inventory
    Order --> Payment
    Order --> Supplier
    
    Inventory --> Supplier
    Product --> Supplier

依赖原则

  1. 上游 → 下游:聚合层调用业务层,不反向依赖
  2. 避免循环依赖:严格禁止服务间循环调用
  3. 异步解耦:非核心路径使用Kafka事件异步
  4. 降级友好:下游故障不影响上游核心功能

34.3.6 数据流转示例

同步数据流(关键路径)

用户搜索商品:
API Gateway → Search Aggregation 
            → Search Service(ES查询)
            → Product Service(批量获取基础信息)
            → Inventory Service(批量查库存)
            → Pricing Service(批量计算价格)
            ← 返回聚合结果

响应时间:< 200ms(P99)

异步数据流(非关键路径)

订单创建成功 → Kafka Event:OrderCreated
            → 订阅者1:Inventory Service(确认扣减)
            → 订阅者2:Search Service(更新销量)
            → 订阅者3:User Service(积分增加)
            → 订阅者4:Data Team(数据分析)

最终一致性:< 5秒

34.3小结

以上展示了系统的整体架构全貌:四层架构、12个核心微服务、服务依赖关系、数据流转模式。这些是34.3战略设计的具体落地——12个限界上下文对应12个微服务,上下文映射关系决定了服务间的集成方式。

接下来34.5节将讨论技术选型决策,34.6节将深入各个系统的详细设计。


34.4 整体架构设计(Application Architecture - 设计结果)

基于34.3节识别的12个限界上下文和上下文映射关系,本节展示如何将它们落地为具体的架构设计:四层架构、微服务拆分、服务依赖关系、数据流转模式。

34.3 → 34.4的映射关系

34.3 限界上下文           →    34.4 微服务
├─ 订单上下文             →    Order Service
├─ 商品上下文             →    Product Center
├─ 库存上下文             →    Inventory Service
├─ 计价上下文             →    Pricing Service
├─ 营销上下文             →    Marketing Service
├─ 支付上下文             →    Payment Service
├─ 搜索上下文             →    Search Service
└─ 供应商上下文           →    Supplier Gateway

34.3 上下文映射           →    34.4 服务集成
├─ Customer-Supplier      →    同步RPC调用
├─ Anti-Corruption Layer  →    适配器模式
└─ Published Language     →    Kafka事件

34.4.1 分层架构

采用经典的四层架构,确保职责清晰、易于维护。

基于前面识别的限界上下文和映射关系,本节通过实际案例展示如何划分边界、重构边界。

案例1:计价系统的边界重构

初始问题

  • 价格计算逻辑分散在订单、营销、商品三个域
  • 购物车、订单创建、支付确认三处价格计算不一致
  • 无法支持“PDP加购试算“场景

重构方案

  1. 新建计价上下文:职责是提供统一的试算接口
  2. 定义边界
    • 计价上下文不拥有商品基础价、营销规则、订单状态
    • 对外提供 Calculate(items, promotions, context) -> PriceBreakdown
    • 各场景通过统一接口获取价格
  3. 收益
    • 价格一致性得到保证
    • 营销规则变更只需在营销域发布事件
    • 支持了试算、价格预览、价格审计等新需求

案例2:库存预占的归属

争议:库存预占应该放在订单域还是库存域?

决策:放在库存域

理由

  • 库存域拥有库存数据所有权
  • 预占是库存的一种状态(可售 → 预占 → 扣减)
  • 订单域只需调用库存域的 Reserve 接口
  • 降低耦合:订单域不需要了解库存的存储结构

34.4.4 集成模式选择

集成场景模式理由
订单 → 商品同步RPC需要实时获取商品信息,延迟<100ms
订单 → 库存同步RPC库存预占是核心路径,必须同步
订单 → 支付同步RPC支付创建需要同步返回支付URL
订单成功 → 搜索异步事件销量更新非核心路径,可最终一致
订单成功 → 积分异步事件积分增加非核心路径

事件驱动示例

// 订单域发布事件
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    // 创建订单...
    order := &Order{...}
    s.repo.Save(ctx, order)
    
    // 发布事件(Outbox模式)
    event := &OrderCreatedEvent{
        OrderID:    order.ID,
        UserID:     order.UserID,
        TotalPrice: order.TotalPrice,
        Items:      order.Items,
    }
    s.outbox.Publish(ctx, "order-events", event)
    
    return order, nil
}

// 搜索域订阅事件
func (s *SearchService) HandleOrderCreated(ctx context.Context, event *OrderCreatedEvent) error {
    // 更新商品销量(用于排序)
    for _, item := range event.Items {
        s.incrementSales(ctx, item.SkuID, item.Quantity)
    }
    return nil
}

34.4.5 跨系统事务处理

Saga模式(编排)

// 订单创建Saga
type CreateOrderSaga struct {
    inventoryClient rpc.InventoryClient
    marketingClient rpc.MarketingClient
    orderRepo       *OrderRepo
}

func (s *CreateOrderSaga) Execute(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    var reserveID string
    var couponLockID string
    
    // Step 1: 库存预占
    reserve, err := s.inventoryClient.ReserveStock(ctx, req.Items)
    if err != nil {
        return nil, fmt.Errorf("库存预占失败: %w", err)
    }
    reserveID = reserve.ReserveID
    defer func() {
        if err != nil {
            // 补偿:释放库存
            s.inventoryClient.ReleaseStock(ctx, reserveID)
        }
    }()
    
    // Step 2: 优惠券锁定
    couponLock, err := s.marketingClient.LockCoupon(ctx, req.CouponCode, req.UserID)
    if err != nil {
        return nil, fmt.Errorf("优惠券锁定失败: %w", err)
    }
    couponLockID = couponLock.LockID
    defer func() {
        if err != nil {
            // 补偿:释放优惠券
            s.marketingClient.UnlockCoupon(ctx, couponLockID)
        }
    }()
    
    // Step 3: 创建订单
    order := &Order{
        ID:           generateOrderID(),
        UserID:       req.UserID,
        Items:        req.Items,
        ReserveID:    reserveID,
        CouponLockID: couponLockID,
        Status:       StatusPendingPayment,
    }
    err = s.orderRepo.Save(ctx, order)
    if err != nil {
        return nil, fmt.Errorf("订单创建失败: %w", err)
    }
    
    return order, nil
}

34.4.6 防腐层设计

防腐层(Anti-Corruption Layer)

// 供应商响应模型(外部)
type SupplierFlightResponse struct {
    Code    string  `json:"code"`
    Message string  `json:"message"`
    Data    struct {
        FlightNo  string  `json:"flight_no"`
        Available int     `json:"available"`
        Price     float64 `json:"price"`
    } `json:"data"`
}

// 平台库存模型(内部)
type StockResponse struct {
    Available bool
    Quantity  int
    Message   string
}

// 防腐层:翻译外部模型 → 内部模型
func (a *FlightSupplierACL) TranslateStock(supplierResp *SupplierFlightResponse) *StockResponse {
    return &StockResponse{
        Available: supplierResp.Code == "SUCCESS" && supplierResp.Data.Available > 0,
        Quantity:  supplierResp.Data.Available,
        Message:   supplierResp.Message,
    }
}

收益

  • 领域层不被供应商模型污染
  • 供应商接口变更时,修改集中在ACL
  • 测试时可以使用Fake实现替代真实供应商

34.5 技术选型决策(Technology Architecture)

34.5.1 选型原则

原则1:成熟度优先

  • 优先选择生产级成熟技术(避免踩坑)
  • 社区活跃、文档完善、案例丰富
  • 避免使用 alpha/beta 版本

原则2:团队能力匹配

  • 技术栈与团队技能对齐
  • 学习曲线可控(新技术培训 < 1个月)
  • 有内部专家支持

原则3:生态完整性

  • 工具链完善(测试、监控、部署)
  • 第三方库丰富
  • 云服务支持(AWS/GCP/阿里云)

原则4:成本可控

  • 开源优先(降低License成本)
  • 云服务按需使用(避免自建中间件)
  • 运维成本可接受

34.5.2 Go生态选型

语言选择:Go

维度GoJava理由
性能⭐⭐⭐⭐⭐⭐⭐⭐⭐协程模型,高并发性能优异
开发效率⭐⭐⭐⭐⭐⭐⭐编译快,部署简单(单一二进制)
学习曲线⭐⭐⭐⭐⭐⭐⭐⭐语法简洁,容易上手
生态⭐⭐⭐⭐⭐⭐⭐⭐⭐微服务生态完善(gRPC/Consul/Envoy)
团队能力⭐⭐⭐⭐⭐⭐⭐⭐团队有Go经验

Web框架:Gin

// 理由:
// 1. 性能优异(httprouter,零内存分配)
// 2. 中间件丰富(鉴权、限流、日志)
// 3. 社区活跃(GitHub 70k+ stars)

router := gin.Default()
router.Use(middleware.Auth())
router.Use(middleware.RateLimit(1000))
router.GET("/products/:id", handler.GetProduct)

ORM:GORM

// 理由:
// 1. 支持MySQL、PostgreSQL、SQLite
// 2. 关联查询、预加载、Hook机制完善
// 3. 自动迁移(开发环境)

type Product struct {
    ID       int64  `gorm:"primaryKey"`
    Title    string `gorm:"size:255;not null"`
    Price    int64  `gorm:"not null"`
}

RPC:gRPC + Protobuf

// 理由:
// 1. 二进制序列化(性能优于JSON)
// 2. 强类型(编译期检查)
// 3. 支持流式调用(双向流)

service ProductService {
    rpc GetProduct(GetProductRequest) returns (GetProductResponse);
    rpc BatchGetProduct(BatchGetProductRequest) returns (stream Product);
}

依赖注入:Google Wire

// 理由:
// 1. 编译时生成(无反射,性能高)
// 2. 类型安全(编译期检查依赖)
// 3. 官方支持(Google开源)

//go:generate wire
func InitializeApp() (*App, error) {
    wire.Build(
        NewDB,
        NewRedis,
        NewProductRepo,
        NewProductService,
        NewApp,
    )
    return nil, nil
}

34.5.4 数据库选型

MySQL(主库)

场景选择理由配置
订单表ACID保证、事务支持InnoDB,8分库64表
商品表关联查询、JOIN支持InnoDB,4分库
支付表强一致性、金融级可靠性InnoDB,双主互备

Redis(缓存 + 库存)

场景数据结构TTL
商品详情Hash30分钟
库存数量String(Lua原子扣减)永久
券码池热队列List(只存 code_id,MySQL CAS 后才算锁码成功)可从 MySQL 重建
用户SessionString2小时

Elasticsearch(搜索 + 日志)

场景索引设计刷新间隔
商品搜索product_index(标题、类目、属性)30秒
订单查询order_index(订单号、用户ID、状态)1分钟
日志搜索log-{date}(按日分索引)5秒

34.5.5 中间件选型

Kafka(消息队列)

场景TopicPartitionReplication
订单事件order-events163
库存事件inventory-events83
日志采集logs322

Consul(服务发现)

  • 健康检查:HTTP/TCP/gRPC
  • 配置中心:动态配置热更新
  • KV存储:Feature Flag

Envoy(Service Mesh)

  • 流量管理:灰度发布、A/B测试
  • 可观测性:自动生成Trace
  • 安全:mTLS加密

34.6 核心系统设计

34.6 核心系统设计(Application + Data Architecture详细设计)

基于34.4的整体架构,本节深入每个核心系统的详细设计,包括应用层的业务逻辑设计和数据层的模型设计。

34.6.1 商品中心设计

34.6.1.1 服务定位与职责

一句话概括,商品中心 = 商品主数据 + 供给运营 + 库存管理 + 搜索导购中心

商品中心处在供应商供给、平台运营、C 端导购和交易系统之间。它不是简单的商品表 CRUD,也不是只维护标题、图片、类目和上下架状态的 PIM 系统;在数字商品平台里,商品中心还要承接商品如何进入平台、如何被运营维护、如何保持供应商数据新鲜、如何判断可售、如何支撑搜索列表和详情页,以及如何在下单前给订单系统提供稳定的商品快照和库存校验结果。

由于团队规模和系统演进阶段限制,本平台没有独立拆分库存中心和搜索中心,库存能力与搜索导购能力都由商品中心内部模块承接。这里需要特别说明:库存和搜索归商品中心,不代表商品主数据、库存状态、搜索索引混在一起。商品中心内部仍然按六个域拆分,分别管理不同的数据模型、生命周期和对外契约,避免商品定义、库存状态、搜索索引、供应商模型和交易状态互相污染。

从业务链路看,商品中心主要承接三类问题:

  1. 供给侧问题:商品从哪里来,如何上传、审核、同步、修正和下架。
  2. 导购侧问题:用户如何在首页、列表页、详情页看到正确、可搜索、可筛选、可展示的商品。
  3. 交易前问题:商品是否存在、是否上架、是否可售、库存是否满足、是否需要供应商实时确认。

因此,商品中心内部可以拆成六个稳定的职责域:

职责域解决的问题关键输出
商品主数据域定义商品是什么,包括类目、SPU/SKU、属性、素材、业务实体和商品状态标准商品模型、类目属性、商品详情、商品快照
商品供给与运营域管理商品如何进入平台、如何审核、如何编辑、如何上下架Listing Task、审核结果、发布事件、变更日志
供应商商品同步域管理外部供应商商品如何映射、同步、刷新和补偿供应商映射、同步任务、数据完整性报告
库存与可售域判断商品是否能卖,统一无限库存、池化库存和实时库存差异库存查询结果、预占结果、可售状态、券码发放结果
搜索与导购域支撑首页、列表页、详情页的召回、筛选、排序、Hydrate 和缓存ES 索引、搜索结果、详情页聚合数据、降级结果
系统集成与事件域向营销、计价、订单、履约、供应商网关和数据平台输出稳定契约查询 API、领域事件、CDC、质量监控数据

商品中心内部模块划分

商品中心 Product Center
├─ 1. 商品主数据域(Product Master Data)
│  ├─ 类目:前台类目、后台类目、类目层级、类目属性模板
│  ├─ SPU/SKU:标准商品、销售单元、组合 SKU
│  ├─ 商品属性:基础属性、业务属性、动态属性、扩展属性
│  ├─ 业务实体:运营商、银行、航司、酒店、影院、商户、门店
│  ├─ 商品素材:标题、描述、图片、Icon、展示标签
│  └─ 商品状态:草稿、待审核、已上架、已下架、已归档
│
├─ 2. 商品供给与运营域(Supply & Operation)
│  ├─ 商品供给:人工上传、批量上传、模板下载、供应商导入
│  ├─ 数据校验:字段校验、类目校验、属性校验、价格/库存预检
│  ├─ 审核发布:新商品审核、编辑审核、高风险变更审核
│  ├─ 商品运营:编辑、批量编辑、上下架、排序、入口配置
│  ├─ 质量治理:缺字段检查、异常价格检查、库存异常检查
│  └─ 操作追踪:Listing Task、审核日志、变更日志、状态流水
│
├─ 3. 供应商商品同步域(Supplier Sync)
│  ├─ 商品映射:平台 SKU 与供应商 SKU、外部资源 ID、业务实体 ID 映射
│  ├─ 静态同步:酒店基础信息、影院信息、商户门店、票务基础数据
│  ├─ 动态同步:可缓存价格、库存水位、上下架状态
│  ├─ 同步任务:全量同步、增量同步、供应商 Push、接入层 Push
│  └─ 同步治理:重试、补偿、告警、数据完整性巡检
│
├─ 4. 库存与可售域(Stock & Sellable)
│  ├─ 库存模型:无限库存、池化库存、实时库存
│  ├─ 库存来源:本地 DB、券码池、供应商接入层 API、供应商 API
│  ├─ 交易动作:查询、预占、释放、扣减、回补
│  ├─ 券码管理:券码池、发码、核销状态、过期管理
│  └─ 可售判断:商品状态、库存状态、供应商可用性、业务规则
│
├─ 5. 搜索与导购域(Search & Discovery)
│  ├─ 搜索索引:ES 索引构建、索引刷新、索引回滚
│  ├─ 召回筛选:关键词、类目、品牌/Carrier、城市、商户、标签
│  ├─ 排序展示:运营排序、销量、价格、活动标签、库存状态
│  ├─ Hydrate:补齐商品详情、库存状态、展示价、营销标签
│  ├─ 页面能力:首页入口、列表页、详情页、商品缓存
│  └─ 降级策略:缓存兜底、隐藏营销标签、库存弱展示
│
└─ 6. 系统集成与事件域(Integration & Event)
   ├─ 对营销:类目、Tag、商品范围、圈品能力、可营销状态
   ├─ 对计价:基础价、类目、属性、库存上下文、能力配置
   ├─ 对订单:商品快照、上下架状态、可售校验、库存预占结果
   ├─ 对履约:履约类型、供应商映射、发货/出票/充值能力配置
   ├─ 对供应商网关:查价、查库存、同步任务、履约参数映射
   ├─ 对数据平台:CDC、商品变更日志、质量监控、经营分析
   └─ 事件机制:商品创建、商品更新、上下架、库存变化、同步失败

与其他系统的边界

系统商品中心提供对方负责
营销系统类目、Tag、业务实体、商品范围、可营销状态活动配置、圈品、优惠券、满减/折扣规则
计价中心基础价、类目、属性、库存上下文、能力配置PDP 价格、结算页试算价、下单价、支付价、结算价
订单系统商品详情、商品快照、上下架状态、库存可售性、预占结果订单创建、订单状态机、支付前后流转
履约系统履约类型、供应商映射、商品能力配置出票、预订确认、充值、发券、销账、履约补偿
供应商网关/供应商接入层平台 SKU、供应商映射、同步任务、商品能力配置外部 API 适配、供应商查价查库存、供应商履约调用

因此,商品中心的定位不是“商品表 CRUD 服务”,而是数字商品平台交易前链路的核心系统。它统一商品定义、库存能力和搜索导购能力,屏蔽供应商商品模型差异,对外稳定输出商品、库存、搜索结果和能力配置,并通过事件机制驱动营销、计价、订单和履约系统协同。


34.6.1.2 核心设计挑战:异构商品模型

数字商品平台的商品中心,最大的难点不是“字段很多”,而是不同品类对交易对象的定义并不相同。实物电商的交易对象通常比较稳定:用户买的是一个 SKU,平台围绕 SKU 管库存、价格、物流和售后即可。但 OTA、O2O 和虚拟商品不是这样。它们卖的可能是一次账户余额变更、一个数字凭证、一项到店服务权益、一个特定时间窗口内的资源确认权,或者一次供应商实时返回的临时报价。

所以,这里的“商品”不能简单理解为 Product + SKU。更准确的说法是:商品中心要统一的是交易前的经营表达,而不是所有品类的实时交易状态

1. 不同品类卖的不是同一种东西

品类类型用户实际购买的是什么典型品类核心复杂度
账户变更型给外部账户充值、销账或开通权益Topup、账单缴费、流量包下单前要校验账户,支付后要确认外部账户状态变化
数字凭证型一个可兑换、可消费或可核销的凭证Gift Card、E-Voucher、Payment Voucher商品定义与券码库存必须隔离,发码后状态不可随意回滚
到店服务型某商户或门店的一次服务权益Local Service、Deal Voucher商户、门店、核销、过期、退款规则比 SKU 字段更重要
资源确认型某个时间窗口下的稀缺资源确认权Flight、Hotel、Movie、Train、Bus价格和库存高度实时,通常需要供应商确认或锁定
组合套餐型多个权益或资源的组合电影 + 小食、酒店 + 活动券需要处理组合价、组合库存、部分履约和部分退款

如果用实物电商的思路强行套这些品类,会遇到五类问题:

  1. SKU 爆炸:把机票、酒店、电影票的每次报价都沉淀成 SKU,会产生海量临时 SKU,而且很快过期。
  2. 字段污染:把所有品类字段都放进一张商品宽表,最后会变成大量空字段、重复字段和语义不清的扩展字段。
  3. 实时性误判:把动态价格和实时库存当成商品主数据保存,会导致列表页、详情页和下单价频繁不一致。
  4. 流程耦合:把账号校验、账单查询、锁座、房态确认、券码发放都写进商品 CRUD,会让商品中心变成交易系统和履约系统的混合体。
  5. 售后规则丢失:OTA/O2O 的退改签、取消政策、核销后不可退等规则不是普通展示字段,而是影响订单状态机和资损风险的交易规则。

2. 三种常见解决方案

面对异构商品,业界通常会经历三种建模方案。它们不是简单的“谁对谁错”,而是适用于不同阶段、不同品类复杂度。

方案A:标准 SPU/SKU + EAV/ExtInfo 扩展

这是最接近传统电商商品中心的方案。核心模型是:

Category
  → SPU
  → SKU
  → Attribute / EAV
  → ExtInfo JSON

所有商品尽量表达为 SPU/SKU。固定字段放主表,可搜索、可筛选字段放属性表,品类专属展示字段放 ExtInfo JSON

维度评价
优点简单直观,运营后台容易实现,适合 Topup、Gift Card、E-Voucher、Local Service 等固定面额或固定券模板商品
缺点难以表达 Flight、Hotel、Movie 这类实时供给;如果把日期、舱位、房态、座位都 SKU 化,会造成 SKU 爆炸
适用阶段平台早期、品类较少、以固定数字商品为主

这套方案的问题在于:它容易让团队误以为“所有东西都应该变成 SKU”。一旦把实时报价、房态、座位图、账单金额都塞进 SKU,商品中心就会从主数据系统滑向交易结果缓存系统。

方案B:资源中心化模型

OTA 和 O2O 平台常见的另一种做法,是先把业务资源标准化,再在资源上包装可售商品。

Resource
  → Product Package
  → Offer / Rate Plan
  → Availability

这里的 Resource 可以是酒店、房型、城市、机场、车站、影院、影厅、影片、商户、门店、账单机构等。SPU/SKU 不再是唯一核心,而是资源上的销售包装。

维度评价
优点适合酒店、电影、本地服务、交通票务;避免把所有资源组合都沉淀成 SKU;供应商资源映射更清晰
缺点模型理解成本更高;只解决资源建模,还不能完整表达用户输入、预订锁定、履约和售后
适用阶段平台开始接入 OTA/O2O 品类,资源、门店、城市、场次、房型等成为核心数据

资源中心化模型能解决“商品背后是什么资源”的问题,但还没有完整回答“这个资源在交易链路里怎么报价、怎么锁定、怎么履约、怎么退款”。

方案C:商品交易契约模型(推荐)

更完整的做法是把商品中心从“商品字段存储系统”升级为“交易前契约系统”。它不是推翻 SPU/SKU,也不是单纯资源化,而是把两者组合起来:

SPU/SKU               表达平台商品定义
Resource              表达商品背后的业务资源
Offer / Rate Plan     表达售卖条件和报价规则
Capability Matrix     表达类目能力差异
Runtime Context       表达交易前运行时上下文

这套方案的核心思想是:

商品中心统一的是经营表达和交易前契约,不是所有品类的实时资源状态。

维度评价
优点解释力强,能同时覆盖固定 SKU、资源型商品和实时供给商品;边界清晰,适合书籍总结和答辩表达
缺点初期理解成本较高,需要治理“哪些数据稳定、哪些数据实时、哪些数据只进快照”
适用阶段多品类平台,尤其是同时覆盖 Topup、Bill、Voucher、Hotel、Flight、Movie、Local Service 的平台

因此,本章采用方案 C 作为推荐方案。它吸收方案 A 的 SPU/SKU 基础能力,也吸收方案 B 的 Resource 建模能力,再通过能力矩阵和运行时上下文把不同品类的交易差异显式表达出来。

3. 八层商品交易模型

对 OTA、O2O 和虚拟商品来说,一个可交易商品通常可以拆成八层。不是每个品类都完整使用八层,但这个分层能帮助我们判断“什么应该进商品中心,什么应该留给库存、计价、订单和履约系统”。

层次解决的问题商品中心是否负责示例
Product Definition平台如何运营和展示这个商品负责类目、标题、图片、品牌/实体、基础描述、上下架状态
Resource商品背后的资源是什么负责稳定部分酒店、房型、影院、场次、商户、门店、账单机构、城市站点
Offer / Rate Plan在某个上下文下如何报价负责配置,不负责所有实时结果面额、套餐、日历价规则、供应商报价计划、活动价输入
Availability当前是否可买负责统一入口和可售判断券码池库存、供应商实时库存、房态、座位、通道可用性
Input Schema下单前需要用户提供什么负责配置手机号、账单号、乘客证件、入住人、座位选择、邮箱
Booking / Lock支付前是否需要锁定资源只负责能力配置和结果引用占座、锁房、锁券码、锁账单金额、锁场次座位
Fulfillment Contract支付后如何交付负责履约能力配置充值、销账、发码、出票、预订确认、到店核销
Refund / After-sale Rule失败或退款时如何处理负责规则配置和快照输入未核销可退、已出票退改签、取消政策、失败自动退款

这八层之间的关系可以理解为:

Product Definition  定义平台卖什么
  → Resource        指向背后的资源
  → Offer           生成可展示或可购买的报价
  → Availability    判断当前是否可买
  → Input Schema    收集交易所需信息
  → Booking / Lock  锁定稀缺资源或金额
  → Fulfillment     支付后完成数字交付
  → Refund Rule     失败或售后时决定如何回滚

这套分层的价值在于:它不要求所有品类长得一样,但要求所有品类在交易链路里说清楚自己处在哪一层、依赖哪些层、哪些数据需要实时确认

4. 典型品类的八层映射

品类Product DefinitionResourceOffer / Rate PlanAvailabilityInput SchemaBooking / LockFulfillmentRefund / After-sale
Topup运营商、面额、套餐说明手机号账户、区域固定面额/套餐价供应商通道可用性手机号、区域通常无需锁定充值到账失败退款,成功后通常不可退
Bill账单机构、账单类型用户账单账户查账后生成金额账单是否可缴账单号、用户标识可锁定账单金额或查询流水代缴销账已销账通常不可退
Gift Card品牌、面额、有效期、使用说明券码池固定面额/折扣价未分配券码数量收件账号/邮箱可在支付后分配,也可提前锁码发码未发码可退,已发码受限
E-Voucher / Local Service商户、门店、券模板、核销规则门店服务能力、券码池券售价/活动价本地库存/券码数量门店、购买数量可锁库存或支付后扣减发券 + 到店核销未核销可退,已核销不可退
Flight / Train / Bus城市、站点、承运方、基础运营配置班次、舱位/座位供应商实时报价实时座位/占座结果乘客、证件、行李等创单前占座/预订出票退改签规则复杂
Hotel酒店、房型、设施、地理位置、政策房型 + 日期范围Rate Plan / 日历价 / 动态价房态确认入住人、日期、人数预订确认或担保锁房预订确认受取消政策约束
Movie影片、影院、影厅、场次基础信息场次 + 座位场次价/套餐价座位图和锁座状态座位、手机号锁座出票/取票码通常不可退或限时退

这张表说明了一个关键事实:同样叫商品,但不同品类的“可售单元”可能位于不同层次。Gift Card 的可售单元很接近 SKU;Hotel 的可售单元是“房型 + 日期范围 + Rate Plan”;Flight 的可售单元是一次实时报价和占座结果;Bill 的可售单元甚至要在用户输入账单号之后才形成。

5. 商品中心的职责边界

基于八层模型,商品中心应该重点负责交易前可复用、可运营、可搜索、可配置的部分:

商品中心负责:
  Product Definition:类目、SPU/SKU、标题、图片、状态、Tag
  Resource 稳定部分:酒店、房型、影院、商户、门店、城市、站点、账单机构
  Offer 配置:基础价、面额、套餐、价格规则输入、供应商报价映射
  Availability 入口:库存类型、库存来源、可售规则、查询/预占能力
  Input Schema:手机号、账单号、乘客、入住人、座位等表单配置
  Contract 配置:履约类型、退款规则、供应商映射、能力开关

商品中心不负责:
  实时航班报价、实时房态房价、座位锁定状态
  用户账单金额、支付结果、订单履约状态、售后处理结果

这样划分之后,商品中心不会因为接入一个新品类就不断膨胀。新增品类时,优先回答八个问题:

  1. 它的稳定商品定义是什么?
  2. 它依赖哪些资源?
  3. 报价是平台配置还是供应商实时返回?
  4. 可用性由谁确认?
  5. 用户下单前需要输入什么?
  6. 是否需要预订、锁定或占用资源?
  7. 支付后如何履约?
  8. 失败、取消、退款时遵循什么规则?

这八个问题回答清楚,商品中心、计价、库存、订单、履约和售后之间的边界也就清楚了。

6. 建模原则

最终的建模原则可以总结为六句话:

  1. 不要用一个 SKU 表硬套所有品类:SKU 是稳定可售单元,不是所有实时报价和资源组合的容器。
  2. 静态资源和动态资源分离:酒店资料、影院资料可以同步;房态、座位、报价必须按时效处理。
  3. 商品定义和交易结果分离:商品中心保存能力和规则,订单/履约系统保存每笔交易的状态。
  4. 用户输入配置化:不同品类的表单和校验规则要通过 Input Schema 表达,避免写死在交易代码里。
  5. 履约和售后契约前置:商品中心要告诉订单系统“这个商品怎么履约、怎么退”,但不处理具体订单的履约状态。
  6. 供应商差异通过映射和防腐层隔离:商品中心只保留平台模型和供应商映射,不让供应商字段污染主模型。

这一节的核心结论是:商品中心真正统一的是经营表达和交易前契约,而不是统一所有品类的实时资源状态。这是数字商品平台避免商品模型失控的关键。


34.6.1.3 统一商品模型设计

商品中心的统一模型目标不是把所有品类强行压成同一种 SKU,而是提供一个稳定的“商品表达框架”,让不同品类都能被运营、搜索、计价、下单和履约系统理解。

核心模型分层

模型作用示例
Category统一品类层级和能力开关40102 机票、10102 话费充值、70101 Deal Voucher
Resource表达商品背后的稳定业务资源酒店、房型、城市、机场、影院、商户、门店、账单机构
Carrier / Brand统一业务实体、品牌、运营商、机构某运营商、某礼品卡品牌、某酒店品牌、某银行、某影院
SPU表达平台商品或商品族某品牌礼品卡、某酒店商品页、某商户套餐
SKU表达稳定可售单元或销售模板100 元礼品卡、某券模板、某充值面额、某房型 + Rate Plan
Offer / Rate Plan表达报价和售卖条件固定面额、套餐价、含早/无早、可取消/不可取消
Attribute / EAV支持可搜索、可筛选、可分析属性面额、有效期、城市、商户类型、是否支持退款
ExtInfo JSON承接低频、展示型、品类专属字段酒店设施、券使用说明、账单字段配置
Supplier Mapping连接平台商品与外部供应商资源平台 SKU ↔ 供应商 SKU / 外部资源 ID / 业务实体 ID
Category Capability表达类目在交易链路中的能力差异是否实时查价、是否需要输入账号、是否需要锁座/锁房
Runtime Context为列表、详情、结算、创单组装交易前上下文商品定义 + 资源 + 报价 + 可售 + 输入 + 履约 + 售后

这套模型可以理解为三层:

第一层:稳定主数据
  Category + Resource + SPU + SKU + Attribute

第二层:交易前契约
  Offer / Rate Plan + Capability + Input Schema + Fulfillment Rule + Refund Rule

第三层:运行时上下文
  Runtime Context = 稳定主数据 + 交易前契约 + 实时查询结果

其中第一层主要持久化在商品中心;第二层也是商品中心负责维护,但会被计价、订单、履约和售后系统消费;第三层通常不是永久主数据,而是在搜索、详情、结算、创单等场景下按需组装,并在订单创建时形成订单快照。

设计原则

  1. 类目表达业务类型:不要再额外引入 product_typecategory_id 互相重叠,类目编码本身可以表达一级、二级、三级业务含义。
  2. Resource 表达稳定业务资源:酒店、房型、影院、商户、门店、城市、机场、车站等不是普通 SKU 字段,而是可以被多个商品、多个供应商和多个场景复用的资源。
  3. Carrier / Brand 表达业务实体:运营商、银行、航司、影院、酒店品牌、商户都可以归入业务实体模型,再通过实体类型区分。
  4. SPU/SKU 表达可运营商品:Gift Card、Voucher、Topup 面额适合沉淀 SKU;Flight 搜索结果不适合沉淀完整 SKU,只沉淀城市、站点、航司等基础资源。
  5. Offer / Rate Plan 表达售卖条件:酒店的含早/无早、可取消/不可取消,电影套餐,礼品卡面额,都应该从“商品是什么”里拆出来,作为“如何售卖”的配置。
  6. EAV 只放可检索属性:需要筛选、搜索、分析的字段进入属性表;仅用于展示的复杂结构进入 ExtInfo
  7. 动态价格和实时库存不进商品主表:商品主表保存稳定定义,动态报价、房态、座位库存通过缓存、供应商查询和订单快照处理。

34.6.1.4 商品中心核心表设计

商品中心的表设计要支撑三件事:稳定主数据、灵活品类差异、可追溯运营链路。下面是核心表的定位,不要求所有字段一次性设计到位,但边界要清晰。

表名定位关键内容
category_tab类目树类目编码、父类目、层级、名称、排序、状态、能力开关
carrier_brand_tab业务实体/品牌/运营商实体类型、名称、Logo、国家/地区、状态、扩展配置
resource_tab统一业务资源资源类型、资源编码、名称、父资源、国家/城市、状态、通用属性
resource_ext_*_tab高频资源扩展表酒店、房型、门店、影院、航线等高频字段,避免全部塞进 JSON
product_spu_tab标准商品或商品族SPU Code、类目、品牌/实体、标题、状态、素材
product_sku_tab可售单元SKU Code、SPU ID、基础价、销售状态、库存类型、履约类型
product_resource_mapping_tab商品与资源关系SKU/SPU 与酒店、房型、门店、影院、城市等资源的关系
product_offer_tab商品报价配置固定价、套餐价、展示价、报价来源、价格生效范围
rate_plan_tab售卖条件计划早餐、取消政策、支付方式、入住人数、供应商报价计划
product_attr_definition_tab属性定义属性 Code、属性类型、适用类目、是否可搜索/筛选
product_attr_value_tab商品属性值SKU/SPU 与属性值绑定,用于筛选、搜索、分析
product_ext_info_tab品类扩展信息JSON 结构,保存低频展示型、品类专属字段
supplier_product_mapping_tab供应商商品映射平台 SKU/SPU 与供应商 SKU、外部资源 ID、业务实体 ID 的映射
category_capability_tab类目能力矩阵商品模型类型、报价类型、库存类型、输入 Schema、锁定模式、履约类型、售后规则
input_schema_tab用户输入表单配置手机号、账单号、乘客、入住人、邮箱、座位选择等输入字段
fulfillment_rule_tab履约契约配置充值、销账、发码、出票、预订确认、核销等履约模式
refund_rule_tab售后规则配置是否可退、是否人工审核、取消政策、核销后限制、供应商退改规则
product_stock_tab库存与可售状态库存类型、库存来源、可售状态、库存数量、更新时间
inventory_create_task库存创建任务数量初始化、券码导入 / 生成、门店日期切片、创建状态和错误信息
inventory_code_batch_tab券码批次批次来源、生成模式、总码量、有效期、密钥版本、分表路由
inventory_code_pool_XX券码池分表一码一行、加密券码、状态机、分配订单、核销状态;Redis 只缓存 code_id
product_supply_task商品供给任务导入批次、任务状态、操作人、成功/失败数量、错误文件
product_audit_log_tab审核日志审核对象、变更内容、审核结论、审核人、驳回原因
product_change_log_tab变更日志商品字段变更前后值、操作来源、TraceID、操作人
product_search_index_task_tab搜索索引任务索引动作、目标 SKU、任务状态、重试次数、失败原因

方案 3 的核心 ER 关系

erDiagram
    CATEGORY_TAB ||--o{ PRODUCT_SPU_TAB : contains
    PRODUCT_SPU_TAB ||--o{ PRODUCT_SKU_TAB : contains

    CATEGORY_TAB ||--|| CATEGORY_CAPABILITY_TAB : configures
    CATEGORY_TAB ||--o{ RATE_PLAN_TAB : defines
    CATEGORY_TAB ||--o{ INPUT_SCHEMA_TAB : defines
    CATEGORY_TAB ||--o{ FULFILLMENT_RULE_TAB : defines
    CATEGORY_TAB ||--o{ REFUND_RULE_TAB : defines

    PRODUCT_SKU_TAB ||--o{ PRODUCT_OFFER_TAB : has
    RATE_PLAN_TAB ||--o{ PRODUCT_OFFER_TAB : applies_to

    PRODUCT_SKU_TAB ||--|| PRODUCT_STOCK_TAB : has
    PRODUCT_SKU_TAB ||--o{ VOUCHER_CODE_POOL_TAB : allocates

    PRODUCT_SPU_TAB ||--o{ PRODUCT_RESOURCE_MAPPING_TAB : maps
    PRODUCT_SKU_TAB ||--o{ PRODUCT_RESOURCE_MAPPING_TAB : maps
    RESOURCE_TAB ||--o{ PRODUCT_RESOURCE_MAPPING_TAB : referenced_by

    RESOURCE_TAB ||--o{ RESOURCE_RELATION_TAB : from_resource
    RESOURCE_TAB ||--o{ RESOURCE_RELATION_TAB : to_resource

    RESOURCE_TAB ||--o{ SUPPLIER_RESOURCE_MAPPING_TAB : maps_to_supplier
    PRODUCT_SKU_TAB ||--o{ SUPPLIER_PRODUCT_MAPPING_TAB : maps_to_supplier
    PRODUCT_OFFER_TAB ||--o{ SUPPLIER_PRODUCT_MAPPING_TAB : maps_to_supplier

    CATEGORY_TAB {
        bigint category_id PK
        varchar category_code
        bigint parent_id
        int level
        varchar name
        varchar status
    }

    CATEGORY_CAPABILITY_TAB {
        bigint capability_id PK
        bigint category_id FK
        varchar product_model_type
        varchar offer_type
        varchar availability_type
        varchar booking_mode
        varchar fulfillment_type
        varchar refund_rule_id
        varchar supplier_dependency
    }

    PRODUCT_SPU_TAB {
        bigint spu_id PK
        bigint category_id FK
        varchar spu_code
        varchar title
        bigint brand_id
        varchar status
        json ext_info
    }

    PRODUCT_SKU_TAB {
        bigint sku_id PK
        bigint spu_id FK
        varchar sku_code
        varchar sku_name
        bigint base_price
        varchar inventory_type
        varchar fulfillment_type
        varchar status
        json ext_info
    }

    RESOURCE_TAB {
        bigint resource_id PK
        varchar resource_type
        varchar resource_code
        varchar name
        bigint parent_resource_id
        varchar country_code
        varchar city_code
        varchar status
        json attributes
    }

    PRODUCT_RESOURCE_MAPPING_TAB {
        bigint id PK
        bigint spu_id FK
        bigint sku_id FK
        bigint resource_id FK
        varchar relation_type
        int priority
        varchar status
    }

    RESOURCE_RELATION_TAB {
        bigint id PK
        bigint from_resource_id FK
        bigint to_resource_id FK
        varchar relation_type
        varchar status
    }

    PRODUCT_OFFER_TAB {
        bigint offer_id PK
        bigint sku_id FK
        bigint rate_plan_id FK
        varchar offer_type
        bigint price
        varchar currency
        varchar price_rule
        datetime valid_from
        datetime valid_to
        varchar status
    }

    RATE_PLAN_TAB {
        bigint rate_plan_id PK
        bigint category_id FK
        varchar plan_code
        varchar meal_type
        varchar cancel_policy
        varchar payment_type
        json constraints
        varchar status
    }

    PRODUCT_STOCK_TAB {
        bigint stock_id PK
        bigint sku_id FK
        varchar stock_type
        varchar source_type
        int quantity
        boolean sellable
        datetime updated_at
    }

    INVENTORY_CODE_POOL_XX {
        bigint code_id PK
        varchar batch_id
        bigint sku_id FK
        varbinary code_cipher
        varchar code_hash
        varchar status
        varchar reservation_id
        bigint assigned_order_id
        datetime expire_at
        varchar redeem_status
    }

    INPUT_SCHEMA_TAB {
        bigint schema_id PK
        bigint category_id FK
        varchar scene
        json fields
        json validation_rules
        varchar status
    }

    FULFILLMENT_RULE_TAB {
        bigint rule_id PK
        bigint category_id FK
        varchar fulfillment_type
        varchar mode
        int timeout_sec
        json params
        varchar status
    }

    REFUND_RULE_TAB {
        bigint refund_rule_id PK
        bigint category_id FK
        boolean refundable
        boolean need_review
        json policy
        varchar status
    }

    SUPPLIER_RESOURCE_MAPPING_TAB {
        bigint id PK
        bigint resource_id FK
        bigint supplier_id
        varchar supplier_resource_code
        varchar supplier_resource_type
        varchar status
    }

    SUPPLIER_PRODUCT_MAPPING_TAB {
        bigint id PK
        bigint sku_id FK
        bigint offer_id FK
        bigint supplier_id
        varchar supplier_product_code
        varchar mapping_status
        json ext_ref
    }

一个重要经验是:表结构要承认异构,而不是掩盖异构。主表保持稳定,资源表承接业务资源,Offer/Rate Plan 表承接售卖条件,能力矩阵承接流程差异,映射表连接供应商,日志表保证可追溯。这样既不会让主表无限膨胀,也不会每新增一个品类就新建一整套孤立模型。

资源表建议采用“统一资源表 + 高频扩展表”的方式:

resource_tab
  保存资源身份、资源类型、名称、父子关系、状态、国家/城市等通用字段

resource_ext_hotel_tab / resource_ext_room_type_tab
  保存酒店星级、地址、经纬度、设施、房型面积、床型等高频字段

resource_ext_merchant_tab / resource_ext_outlet_tab
  保存商户、门店、营业时间、地理位置、核销能力等高频字段

resource_ext_cinema_tab / resource_ext_route_tab
  保存影院、影厅、城市站点、航线/车线等高频字段

这样设计的原因是:不是所有资源都值得单独建完整模型,但高频检索、高频展示、高频排序的资源字段不能长期躲在 JSON 里。统一 resource_tab 负责身份和关系,扩展表负责高频业务字段。


34.6.1.5 不同品类的数据存储样例

不同品类进入商品中心时,关键是判断“哪些信息稳定,哪些信息动态,哪些信息不应该沉淀”。下面按典型品类说明。

品类SPU/SKU 存什么Resource 存什么Offer / Rate Plan 存什么动态数据在哪里
Topup运营商面额 SKU、套餐说明、基础价、可售状态运营商、国家/地区、号码归属规则固定面额、套餐价、手续费规则手机号校验、供应商通道状态实时查询或短缓存
Bill账单机构商品、账单类型、缴费入口账单机构、账单地区、账单账号类型手续费规则、是否支持部分支付、滞纳金规则用户账单金额、欠费明细、账单可缴状态实时查询
Gift Card品牌 + 面额 SKU、有效期、使用说明礼品卡品牌、券码池资源固定面额、折扣价、发码规则券码分配结果、用户核销状态进入履约/核销链路
E-Voucher / Local Service券模板 SKU、购买限制、可用时间、核销规则摘要商户、门店、服务项目、券码池券售价、活动价、门店适用范围发券、核销、过期、退款状态进入履约/售后链路
Flight / Train / Bus通常不沉淀完整行程 SKU,只存基础运营配置城市、机场/车站、航司/车司、线路供应商报价计划、服务费、加价规则航班/班次报价、座位、舱位、占座结果实时查询
Hotel酒店 SPU、房型销售模板 SKU、上下架、展示素材酒店、房型、品牌、城市、商圈、设施Rate Plan:含早/无早、可取消/不可取消、支付方式某日期房态房价、税费、供应商确认结果走缓存或实时查询
Movie影片/影院/套餐商品、基础场次配置影片、影院、影厅、座位区域场次价、套餐价、服务费规则实时座位图、锁座状态、出票结果走供应商查询

这种划分的核心标准是:稳定内容进商品中心,动态资源走缓存/供应商,交易结果进订单/履约/售后快照

以酒店为例,酒店本身是 Resource,平台上售卖的不是“酒店这一条记录”,而是围绕酒店资源包装出来的商品和售卖条件:

resource_tab
  HOTEL: Bangkok Central Hotel
  ROOM_TYPE: Deluxe King Room

product_spu_tab
  SPU-HOTEL-90001: 平台上的 Bangkok Central Hotel 商品页

product_sku_tab
  SKU-HOTEL-90001-ROOM90002-BF-RF:
    Deluxe King Room + 含早 + 可取消

rate_plan_tab
  BF_RF:
    breakfast = included
    cancel_policy = free_cancel_before_deadline
    payment_type = prepay

实时查询 / 报价缓存
  check_in = 2026-05-01
  nights = 2
  adult = 2
  price = 实时返回
  availability = 实时确认

这个例子说明:resource_tab 回答“资源是什么”,product_spu_tab 回答“平台是否运营这个资源”,product_sku_tab 回答“卖哪个稳定销售模板”,rate_plan_tab 回答“用什么售卖条件”,实时查询回答“这个日期和人数下是否真的可以买”。

再以账单缴费为例,账单机构和缴费入口可以沉淀在商品中心,但用户账单金额不能沉淀成商品主数据:

resource_tab
  BILLER: 某电力公司

product_spu_tab
  SPU-BILL-ELECTRICITY: 电费缴费

product_sku_tab
  SKU-BILL-ELECTRICITY-REGION-A: A 地区电费缴费入口

input_schema_tab
  account_no: 必填,数字,长度 10-16

实时查账
  account_no = 用户输入
  bill_amount = 实时返回
  due_date = 实时返回

这类商品的可售单元不是固定面额,而是“用户输入账号后形成的一次账单支付上下文”。因此商品中心只保存账单机构、输入规则、手续费规则和履约契约,具体账单金额进入计价上下文和订单快照。


34.6.1.6 商品供给与运营链路

商品供给与运营链路解决的是“商品如何进入平台,以及上线后如何被持续维护”的问题。供应商同步本质上属于供给链路,但它不是唯一入口。更准确的划分是:

商品供给与运营治理平台
  ├─ 人工创建/上传:运营/商家从 0 到 1 创建商品
  ├─ 批量导入:文件、模板、批量任务导入商品和配置
  ├─ 运营编辑:标题、图片、类目、价格、库存、上下架、退款规则变更
  └─ 供应商同步:外部供给数据全量/增量/Push/刷新

这四类入口应该进入同一个“供给治理控制面”,共享任务模型、校验、审核、发布版本、Outbox、补偿和可观测性;但供应商同步因为存在长任务、Checkpoint、Raw Snapshot、Worker 租约、DLQ、数据新鲜度等复杂问题,可以单独展开成 34.6.1.7 和附录案例。

相关答辩判断已统一收录到第 39 章的“供应商同步与人工上传”题卡

如果这条链路设计不好

如果这条链路设计不好,问题会很快暴露到 C 端交易链路:列表页搜不到、详情页价格错误、下单时库存不可用、券码发放失败、供应商映射缺失、退款规则不完整。商品供给链路的核心不是“把商品写进数据库”,而是“让一个商品从供给入口到可被搜索、可被下单、可被履约、可被追溯”。

这条链路的系统难点和解决方法可以先这样收敛:

难点典型表现解决方法
入口多且语义不同人工创建、批量导入、运营编辑、供应商同步都在改商品统一进入 Supply Task 和 Staging,但按 task_type 路由不同策略
半成品污染线上草稿、导入半成品、同步脏数据直接写正式表Draft / Staging 与正式表隔离,只有发布事务能写线上版本
品类差异大酒店、话费、账单、礼品卡、电影票字段和交易规则完全不同类目模板 + 能力矩阵 + Schema 驱动表单、校验和发布规则
运营误操作风险高批量改价、退款规则变更、类目迁移导致资损或投诉字段级 Diff、风险评分、强审核、版本回滚和灰度发布
供应商和运营冲突供应商同步覆盖运营修正字段,线上数据反复抖动字段主导权、人工覆盖保护期、冲突日志和巡检
发布不一致商品库成功,ES、缓存、营销、计价没有刷新发布事务 + Outbox + 异步刷新 + 补偿重试
失败不可运营错误只在日志里,运营不知道哪一行失败、怎么修行级明细、错误文件、MySQL DLQ、修复建议和重新投递
历史订单被新配置影响商品改价或退款规则变更后影响旧订单解释创单保存商品快照、报价快照、履约契约和退款规则快照

1. 三种方案对比

方案核心思路优点缺点适用阶段
方案A:后台 CRUD + 简单审核运营直接编辑商品正式表,审核通过后上架实现简单,适合固定 SKU、低规模自营商品无法支撑批量导入、错误隔离、版本回滚、下游一致性和事故追溯早期平台
方案B:任务化上架系统用 Listing Task 承接人工上传、批量导入和运营编辑支持异步处理、进度追踪、错误文件、失败重试只解决“任务怎么跑”,没有完整的质量治理、风险分级和发布一致性中期平台
方案C:供给治理平台在任务化基础上加入暂存区、标准化、质量校验、Diff、差异化审核、发布快照、Outbox、补偿和巡检能支撑 OTA、O2O、虚拟商品、多供应商、多运营角色长期演进设计复杂度更高,需要明确模型边界和流程状态机多品类、多来源、强运营平台

本系统选择 方案C:供给治理平台。它不是把所有入口混成一条大流程,而是提供统一控制面,让不同入口走不同策略、共享同一套发布和治理能力。

2. 推荐架构:供给治理控制面

供给入口层
  → Draft / Staging 暂存区
  → Listing Task / Batch / Item
  → 标准化与类目模板适配
  → 多层质量校验
  → Diff 与风险识别
  → 审核流 / 自动准入
  → 发布事务:主数据 + 交易契约 + Outbox
  → 搜索索引 / 缓存 / 营销 / 计价 / 订单上下文刷新
  → DLQ / 补偿 / 巡检 / 质量报表

各层职责如下:

层级职责关键产物
供给入口层接收运营表单、批量文件、供应商同步、商家 API原始输入、操作者、来源、TraceID
暂存层保存未发布数据,避免污染线上正式表Draft、Staging Snapshot、Import Row
任务编排层把一次供给动作变成可恢复任务product_supply_taskproduct_supply_task_item、进度和失败明细
标准化层把入口数据转换成平台 Resource/SPU/SKU/Offer/Rule 模型标准化模型、字段来源、数据 hash
校验层检查字段、主数据、交易契约、可售规则校验结果、错误码、质量分
风险审核层识别高风险变更并路由审核Diff、风险等级、审核单
发布层写正式表、生成版本、写 Outboxpublish_version、商品快照、事件
下游刷新层刷新搜索、缓存、营销、计价、数据平台索引任务、缓存失效任务、补偿任务
治理层巡检、补偿、报表、审计DLQ、质量报告、操作日志

这里最重要的边界是:所有入口都不要直接写商品正式表。人工表单、Excel 导入、供应商同步和运营批量编辑都先写暂存区和任务表,经过校验、审核和发布后,再写入正式主数据和交易契约表。

3. 四类入口的差异化设计

入口典型场景主流程关键风险处理策略
人工创建运营创建本地生活券、礼品卡、话费套餐、账单缴费入口表单草稿 → 实时校验 → 提交审核 → 发布字段漏填、类目选错、履约规则不完整表单配置化、类目模板、强校验、完整审核
批量导入大促前批量创建套餐、门店、价格计划、券码池上传文件 → 预校验 → 异步解析 → 分批处理 → 错误文件大批量错误、重复导入、局部失败任务化、行级状态、部分成功、失败文件、幂等 key
运营编辑改标题、图片、价格、库存、退款规则、上下架读取线上版本 → 创建变更单 → Diff → 风险审核 → 发布误操作、批量事故、覆盖供应商数据字段主导权、版本锁、风险阈值、回滚
供应商同步酒店、影院、活动、票务等外部数据同步同步任务 → Raw Snapshot → 标准化 → 映射 → Diff → 发布接口不稳定、模型不一致、新鲜度、长任务失败独立同步链路,见 34.6.1.7

这四类入口共享最终发布模型,但入口策略不同。人工创建强调“完整性和可解释”;批量导入强调“吞吐和错误隔离”;运营编辑强调“Diff、权限和风险”;供应商同步强调“可恢复、可追溯和自动化治理”。

4. 核心数据模型

供给治理平台的表设计不要从“一张商品表”出发,而要围绕“未发布隔离、任务可恢复、行级可定位、校验可解释、变更可审核、发布可追溯、失败可补偿”来组织。核心可以分成八组:

表组典型表作用
Draft 草稿表product_supply_draftproduct_supply_draft_version保存单商品创建和编辑过程中的草稿,允许反复保存,不影响线上
Task 任务表product_supply_task记录一次供给动作,如人工创建、批量导入、运营编辑、供应商同步后的商品变更接入
Task Item 明细表product_supply_task_item记录每一行、每个商品、每个 Offer 或每条规则的处理状态,是失败定位单元
Staging 暂存表product_supply_stagingproduct_supply_staging_snapshot保存已经提交、已经标准化、但未发布到正式表的数据
Validation 校验表product_validation_result保存 Schema、类目模板、主数据、商品模型、交易契约、风险规则的校验结果
Change / Audit 表product_change_requestproduct_audit_logproduct_field_ownership保存字段 Diff、风险等级、审核策略、审核动作和字段主导权
Publish / Snapshot 表product_publish_recordproduct_publish_snapshotproduct_change_log保存发布批次、线上版本快照和正式变更日志,支持追溯和回滚
Outbox / DLQ / Compensation 表product_outbox_eventproduct_supply_dead_letterproduct_compensation_taskproduct_quality_issue保证下游最终一致,承接失败补偿、人工修复和质量巡检

第一期不一定把所有可选表都建齐,最小闭环建议包括:

product_supply_draft
product_supply_task
product_supply_task_item
product_supply_staging
product_validation_result
product_change_request
product_audit_log
product_publish_snapshot
product_change_log
product_outbox_event
product_supply_dead_letter

供应商同步执行层可以独立使用 supplier_sync_tasksupplier_sync_batchsupplier_sync_snapshotsupplier_sync_dead_letter,但标准化之后要进入供给平台的 product_supply_stagingproduct_validation_resultproduct_change_request 和统一发布链路。

product_supply_task 记录一次供给动作:

CREATE TABLE product_supply_task (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_id VARCHAR(64) NOT NULL,
    task_type VARCHAR(32) NOT NULL COMMENT 'MANUAL_CREATE/BATCH_IMPORT/OPS_EDIT/SUPPLIER_SYNC',
    source_type VARCHAR(32) NOT NULL COMMENT 'OPS/MERCHANT/SUPPLIER/SYSTEM',
    source_id VARCHAR(64) DEFAULT NULL,
    category_code VARCHAR(32) NOT NULL,
    operator_id VARCHAR(64) DEFAULT NULL,
    trigger_id VARCHAR(64) DEFAULT NULL COMMENT '外部幂等 ID',
    status VARCHAR(32) NOT NULL COMMENT 'DRAFT/VALIDATING/REVIEWING/APPROVED/PUBLISHING/PUBLISHED/PARTIAL_FAILED/REJECTED/FAILED/CANCELLED',
    total_count INT NOT NULL DEFAULT 0,
    success_count INT NOT NULL DEFAULT 0,
    failed_count INT NOT NULL DEFAULT 0,
    skipped_count INT NOT NULL DEFAULT 0,
    current_stage VARCHAR(64) DEFAULT NULL,
    error_file_ref VARCHAR(512) DEFAULT NULL,
    publish_version BIGINT DEFAULT NULL,
    created_at DATETIME NOT NULL,
    started_at DATETIME DEFAULT NULL,
    finished_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_task_id (task_id),
    UNIQUE KEY uk_trigger (task_type, trigger_id),
    KEY idx_status (status),
    KEY idx_category_status (category_code, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给任务';

product_supply_task_item 记录每个商品、资源或 Offer 的处理结果:

CREATE TABLE product_supply_task_item (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    task_id VARCHAR(64) NOT NULL,
    item_no VARCHAR(64) NOT NULL COMMENT '文件行号或外部对象序号',
    item_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE/SPU/SKU/OFFER/STOCK/RULE',
    idempotency_key VARCHAR(128) NOT NULL,
    platform_resource_id BIGINT DEFAULT NULL,
    spu_id BIGINT DEFAULT NULL,
    sku_id BIGINT DEFAULT NULL,
    offer_id BIGINT DEFAULT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'PENDING/VALIDATING/REVIEWING/PUBLISHING/SUCCESS/FAILED/SKIPPED',
    risk_level VARCHAR(32) DEFAULT NULL COMMENT 'LOW/MEDIUM/HIGH',
    error_code VARCHAR(128) DEFAULT NULL,
    error_message VARCHAR(1024) DEFAULT NULL,
    draft_ref VARCHAR(512) DEFAULT NULL,
    normalized_ref VARCHAR(512) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_task_item (task_id, item_no),
    UNIQUE KEY uk_task_idempotency (task_id, idempotency_key),
    KEY idx_task_status (task_id, status),
    KEY idx_platform_object (platform_resource_id, spu_id, sku_id, offer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给任务明细';

暂存区保存未发布的数据,不直接影响线上:

CREATE TABLE product_supply_staging (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    staging_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) NOT NULL,
    item_no VARCHAR(64) NOT NULL,
    object_type VARCHAR(32) NOT NULL COMMENT 'RESOURCE/SPU/SKU/OFFER/RATE_PLAN/STOCK/RULE',
    object_key VARCHAR(128) NOT NULL,
    source_type VARCHAR(32) NOT NULL,
    raw_payload_ref VARCHAR(512) DEFAULT NULL,
    normalized_payload JSON NOT NULL,
    payload_hash VARCHAR(64) NOT NULL,
    base_publish_version BIGINT DEFAULT NULL,
    status VARCHAR(32) NOT NULL COMMENT 'DRAFT/VALIDATED/REVIEWING/APPROVED/PUBLISHED/REJECTED',
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_staging_id (staging_id),
    UNIQUE KEY uk_task_object (task_id, object_type, object_key),
    KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给暂存数据';

变更日志保存 Diff、风险和审核依据:

CREATE TABLE product_change_request (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    change_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) NOT NULL,
    object_type VARCHAR(32) NOT NULL,
    object_id BIGINT DEFAULT NULL,
    old_publish_version BIGINT DEFAULT NULL,
    new_staging_id VARCHAR(64) NOT NULL,
    changed_fields JSON NOT NULL,
    risk_level VARCHAR(32) NOT NULL,
    review_policy VARCHAR(32) NOT NULL COMMENT 'AUTO_APPROVE/MANUAL_REVIEW/BLOCK',
    status VARCHAR(32) NOT NULL COMMENT 'PENDING/APPROVED/REJECTED/PUBLISHED',
    reviewer_id VARCHAR(64) DEFAULT NULL,
    review_note VARCHAR(1024) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_change_id (change_id),
    KEY idx_task (task_id),
    KEY idx_status_risk (status, risk_level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给变更单';

5. 人工创建链路

人工创建不是简单表单提交。它要把“类目模板、交易契约、履约契约、退款规则”一次性收齐,否则商品看似创建成功,交易时会失败。

选择类目
  → 加载类目模板和能力矩阵
  → 填写 Resource / SPU / SKU / Offer / Rule
  → 前端实时校验 + 后端强校验
  → 保存 Draft
  → 提交 Listing Task
  → 生成 Staging Snapshot
  → 质量校验
  → 新商品审核
  → 发布正式表

人工创建的关键设计:

设计点说明
类目模板驱动表单不同品类展示不同字段,例如酒店要地址和坐标,充值要号码规则,账单缴费要 Input Schema
Draft 与正式表隔离草稿允许反复保存,不影响线上商品
交易契约强校验Offer、库存来源、履约规则、退款规则、Input Schema 不完整时不能提交
审核证据完整审核员看到的是标准化后的商品快照、字段来源、风险命中和历史版本
创建后不等于上线发布成功后还要等待库存初始化、索引刷新、可售校验通过

人工创建链路的答辩提示已统一收录到第 39 章的“人工上传审核策略”题卡

6. 批量导入链路

批量导入要按“任务 + 行级明细 + 暂存快照 + 错误文件”设计,不能把整个 Excel 读进内存后循环写正式表。

下载模板
  → 上传文件
  → 文件格式预检
  → 创建 product_supply_task(status=PENDING)
  → 流式解析文件
  → 每行生成 product_supply_task_item
  → 分批标准化和校验
  → 成功项进入发布/审核
  → 失败项生成错误文件
  → 任务状态汇总为 PUBLISHED / PARTIAL_FAILED / FAILED

批量导入的关键设计:

设计点说明
模板版本化模板字段随类目演进,导入文件必须记录 template_version
行级幂等task_id + row_no 和业务幂等 key 防止重复导入
部分成功10000 行中 9800 行成功、200 行失败时,不应该整批回滚
错误文件下载失败行要带 error_codeerror_message、原始值和建议修复方式
背压与限流大文件分片处理,避免压垮商品库、库存系统和搜索刷新
批量事故防护高风险批量变更必须二次确认或抽样审核

批量异步链路要拆成多个 Worker 阶段,而不是一个 Worker 从解析文件一路写到正式表:

上传文件 / 批量提交
  → 创建 product_supply_task(status=PENDING)
  → Parser Worker 抢占任务并流式解析文件
  → 批量写入 product_supply_task_item
  → Item Worker 分批处理 item
  → 标准化 / 校验 / Staging / Diff
  → 低风险自动发布,高风险进入审核
  → Publish Worker 发布正式表并写 Outbox
  → 生成错误文件 / DLQ / 质量报告

Parser Worker 只负责解析,不负责发布。它校验文件 hash、模板版本和列结构,按行流式读取文件,每 N 行批量插入 product_supply_task_item,并持续更新 parsed_countparse_checkpoint。如果 Worker 中途退出,下次从 checkpoint 继续;如果重复解析上一小批数据,通过 task_id + item_notask_id + idempotency_key 唯一键去重。

{
  "sheet": "Sheet1",
  "row_no": 12000,
  "byte_offset": 8842211
}

product_supply_task_item 是真正的问题定位单元。Task 只表示一次批量任务,Item 表示每一行、每一个商品对象或每一个 Offer 的处理状态。

PENDING
  → NORMALIZING
  → VALIDATING
  → STAGING
  → DIFFING
  → REVIEWING
  → PUBLISHING
  → SUCCESS

失败分支:
NORMALIZING / VALIDATING / STAGING / DIFFING / PUBLISHING
  → FAILED / DLQ / SKIPPED

Item Worker 不按文件整批处理,而是扫描小批量 item:

SELECT *
FROM product_supply_task_item
WHERE task_id = ?
  AND status IN ('PENDING', 'FAILED')
  AND next_retry_at <= NOW()
ORDER BY item_no ASC
LIMIT 500;

每个 item 或小批次独立事务,流程是:读取原始行 → 按类目模板标准化 → 写 normalized_ref → 执行 Schema、主数据、交易契约校验 → 写 product_supply_staging → 与线上 publish_version 做 Diff → 生成 product_change_request → 按风险等级进入自动发布或人工审核。

Publish Worker 只处理已经通过审核或自动准入的变更:

读取 APPROVED change
  → 开启发布事务
  → 写 Resource / SPU / SKU / Offer / Rule
  → 写 publish_snapshot 和 change_log
  → 写 outbox_event
  → 提交事务
  → item.status = SUCCESS

Task 状态由 item 统计汇总,而不是 Worker 主观判断:

Item 汇总结果Task 状态
全部 SUCCESSPUBLISHED
部分 SUCCESS,部分 FAILED/DLQPARTIAL_FAILED
全部失败FAILED
存在 REVIEWINGREVIEWING
存在 PUBLISHINGPUBLISHING

这里的关键原则是:Parser Worker 只解析,Item Worker 推进行级状态,Publish Worker 只做已审核发布;Task 管整体进度,Item 管失败定位,Staging 管线上隔离,Outbox 管下游一致性

错误文件示例:

row_no, sku_code, field, error_code, error_message
12, SKU_001, price, PRICE_TOO_LOW, price is lower than category floor price
25, SKU_014, refund_rule, REFUND_RULE_MISSING, refund rule is required for hotel offer
31, SKU_020, city_code, CITY_NOT_FOUND, city code cannot map to platform city

7. 运营编辑链路

运营编辑不是“打开商品详情页直接保存”。一个线上商品可能同时被供应商同步、运营编辑、库存系统、风控系统影响。运营编辑必须明确字段主导权、版本锁和风险审核。

读取当前 publish_version
  → 创建编辑草稿
  → 修改字段
  → 与线上版本做 Diff
  → 判断字段主导权和风险等级
  → 自动通过 / 人工审核 / 阻断
  → 发布新 publish_version
  → Outbox 通知搜索、缓存、营销、计价、订单

字段主导权可以这样定义:

字段主导方供应商同步能否覆盖运营编辑策略
酒店名称、地址、设施供应商/平台治理低风险可覆盖,高风险审核可人工修正并加保护期
标题、卖点、活动标签平台运营不能直接覆盖运营编辑为准
基础价格、Rate Plan供应商/计价取决于品类超阈值审核
库存水位、可售状态库存域/供应商可覆盖异常告警,不建议人工长期覆盖
退款规则、履约规则平台/供应商契约高风险覆盖强制审核
类目、Resource 映射平台治理不能自动覆盖强制审核和巡检

高风险运营编辑必须具备三个能力:

  1. Diff 可读:审核员看到字段级变化,而不是整段 JSON。
  2. 版本可回滚:发布新版本后出现事故,可以回滚到上一个 publish_version
  3. 覆盖可解释:如果运营字段覆盖了供应商字段,要记录覆盖原因、有效期和责任人。

8. 标准化校验与风险审核

数字商品供给不能只做字段必填校验,还要校验交易前契约是否完整。

校验层检查内容示例失败处理
Schema 校验字段类型、必填、枚举、长度、格式图片 URL、手机号规则、账单号长度行级失败
类目模板校验类目要求的属性、能力、扩展字段是否完整Gift Card 必须有面额和有效期阻断提交
主数据校验Resource、Brand、Carrier、城市、商户是否存在酒店必须有关联城市进入人工映射
商品模型校验SPU/SKU/Offer/Rate Plan 关系是否成立SKU 不能缺 Offer阻断发布
交易契约校验库存来源、Input Schema、履约规则、退款规则是否完整Voucher 券码池为空不能发布阻断发布或告警
风险校验价格、类目、履约、退款、映射是否高风险价格大幅变化、退款规则变严人工审核

审核策略应该差异化,而不是所有变更都人工审核:

变更类型风险等级策略
标题、描述、普通图片修改自动通过,记录变更日志
库存水位、供应商可售状态自动校验,通过后发布,异常告警
展示价、Offer 规则、活动标签中高超过阈值进入人工审核
类目、履约类型、退款规则强制人工审核
供应商映射、Resource ID、SPU/SKU 结构强制审核,并触发巡检

风险规则要配置化:

risk_score =
  field_weight
  + change_ratio_weight
  + category_weight
  + operator_risk_weight
  + product_heat_weight

例如同样是改价,长尾商品小幅调价可以自动通过,热门酒店或高销量礼品卡大幅降价必须人工复核。

9. 发布一致性设计

审核通过不代表商品已经可售。真正发布时,要保证商品主数据、资源映射、交易契约、库存可售、搜索缓存和下游系统最终一致。

审核通过
  → 开启发布事务
  → 写入 Resource / SPU / SKU / Offer / Rate Plan
  → 写入 Stock Config / Sellable Rule
  → 写入 Input Schema / Fulfillment Rule / Refund Rule
  → 生成 publish_version 和 product_snapshot
  → 写入 product_change_log
  → 写入 Outbox 事件
  → 提交事务
  → 异步刷新搜索、缓存、营销、计价、数据平台

发布事务里只做商品中心必须强一致的事情;ES 刷新、缓存失效、营销圈品、计价上下文刷新都通过 Outbox 异步执行。

设计点说明
正式表与暂存表分离任务处理中的半成品不能污染线上
发布版本化每次发布生成 publish_version,支持回滚、对账和排查
Outbox 同事务商品变更与事件写入同事务,避免“商品变了但下游不知道”
下游刷新可重试ES、缓存、营销、计价刷新失败进入补偿任务
订单只信快照创单保存商品快照、报价快照、履约契约和退款规则快照

Outbox 事件示例:

ProductPublished
ProductContentChanged
OfferChanged
SellableRuleChanged
FulfillmentRuleChanged
SearchIndexRefreshRequired
ProductCacheInvalidationRequired

10. DLQ、补偿与质量巡检

人工供给和运营编辑也需要 DLQ。它们的失败不一定来自供应商接口,更多来自文件格式、字段错误、审核驳回、发布失败和下游刷新失败。

失败类型示例处理
输入失败Excel 字段非法、必填缺失生成错误文件,运营修复后重新提交
映射失败城市、商户、品牌、Resource 找不到进入人工映射队列
审核失败高风险变更被驳回回到草稿,保留驳回原因
发布失败DB 写入冲突、版本过期重试或要求基于最新版本重新编辑
下游失败ES 刷新失败、缓存失效失败Outbox 补偿
质量失败缺图、缺价、无库存、不可履约质量巡检下架或告警

补偿任务包括:

  1. 失败行重新投递。
  2. 审核通过但发布失败重试。
  3. 搜索索引重建。
  4. 商品缓存失效重试。
  5. 发布版本与 ES 索引一致性校验。
  6. 商品质量日报。
  7. 运营覆盖字段到期巡检。
  8. 无库存、无价格、无履约规则商品巡检。

11. 可观测性指标

供给运营链路需要可观测,否则运营会遇到“上传了但不知道失败在哪里”“审核通过但前台搜不到”“商品发布了但不能下单”等问题。

指标说明目标
任务成功率成功任务 / 总任务按入口拆分统计
行级成功率成功 item / 总 item批量导入核心指标
任务完成耗时从创建到发布完成P95 可控
自动审核占比自动通过 / 总审核持续提升,但高风险不追求自动化
审核驳回率驳回 / 审核提交反映输入质量和规则合理性
发布失败率发布失败 / 发布任务< 1%
索引刷新成功率ES 刷新成功 / 总刷新> 99%
缓存失效成功率缓存失效成功 / 总失效> 99%
商品质量缺陷率缺图、缺价、无库存、映射缺失商品占比持续下降
人工修复耗时从失败到修复完成按错误类型统计

运营后台至少要能看到:

任务进度:总数、成功、失败、跳过、当前阶段
失败原因:错误码、错误字段、建议修复方式、错误文件
审核队列:风险等级、命中规则、Diff、责任人
发布结果:publish_version、Outbox 状态、索引/缓存刷新状态
质量看板:缺图、缺价、无库存、无履约规则、映射缺失

12. 与供应商同步链路的关系

供应商同步不是被排除在供给链路之外,而是供给链路中自动化程度最高、数据治理要求最强的入口。

统一供给治理平台
  → 统一发布模型:Resource / SPU / SKU / Offer / Rule
  → 统一治理能力:校验 / Diff / 审核 / 发布 / Outbox / 补偿
  → 统一观测能力:任务进度 / 失败明细 / 质量指标

供应商同步专项链路
  → Raw Snapshot
  → Checkpoint
  → Worker Lease
  → Sync Batch Version
  → Supplier Mapping
  → 数据新鲜度

所以本章采用“主链路 + 专项链路”的写法:34.6.1.6 讲统一商品供给、运营与生命周期治理,完整设计见第 26 章:商品供给、运营与生命周期治理34.6.1.7 专门讲供应商同步,因为它有长任务恢复、外部数据追溯和供应商质量治理等额外复杂度。

本节答辩总结已统一收录到第 39 章的“供应商同步与人工上传”题卡

34.6.1.7 供应商商品同步链路

供应商同步链路解决的是”外部供给数据如何进入平台,并持续保持可用、可信、足够新鲜”的问题。它不是简单的定时任务,也不是把供应商字段原样搬进商品表,而是一个完整的数据治理链路:接入外部数据、适配不同协议、完成平台模型映射、校验数据质量、生成发布版本、刷新搜索缓存、通知下游系统,并在失败时可追踪、可补偿、可人工修复。

从系统边界上看,它属于 34.6.1.6 里的供应商供给入口,但执行层要单独设计。统一供给平台负责发布模型、审核、Outbox 和质量治理;供应商同步专项链路负责外部协议适配、Raw Snapshot、Checkpoint、租约、批次版本、新鲜度和供应商质量治理。

在数字商品平台中,供应商同步的复杂度来自四个方面:

  1. 接口不稳定:供应商可能超时、限流、重复推送、乱序推送,也可能临时修改字段含义。
  2. 模型不一致:供应商有自己的酒店、房型、套餐、面额、场次、票种模型,平台则使用 Resource、SPU、SKU、Offer、Rate Plan 等统一抽象。
  3. 新鲜度不同:酒店地址和设施可以小时级更新,酒店最低价需要分钟级刷新,机票报价和下单前房态房价必须实时确认。
  4. 交易风险高:列表页展示可以允许轻微过期,但创单前如果使用过期价格或库存,就会带来资损、投诉和履约失败。

因此,供应商同步链路的核心目标不是“同步成功”,而是:正确映射、变化可追溯、错误可隔离、数据可验证、过期可感知、失败可补偿

核心难点和解决方法如下:

难点典型表现解决方法
长任务易中断100 万酒店全量同步跑 10 小时,发布、重启、OOM 都可能中断Batch + Page/Cursor Checkpoint + Worker Lease
外部数据不可控字段缺失、枚举变化、分页游标失效、重复 PushAdapter 防腐层、Schema 校验、幂等 key、指数退避和熔断
模型映射复杂供应商酒店/房型/套餐无法直接对应平台 Resource/SPU/SKU/Offersupplier mapping 表、标准化快照、映射失败进入人工修复
同步成功不等于可发布拉到了数据但城市映射失败、价格异常、坐标漂移质量校验、Diff、风险分级、低风险自动发布,高风险审核或 DLQ
数据新鲜度不一致静态信息小时级即可,房态房价下单前必须实时按数据类型和交易阶段分层 TTL,列表缓存、详情刷新、创单确认
失败需要可追溯线上价格异常时不知道供应商当时返回什么Raw Snapshot / Normalized Snapshot / Diff / publish_version 分离
下游最终一致DB 更新成功但 ES、缓存、营销、计价没有同步Outbox 同事务写入,索引和缓存刷新失败进入补偿

供应商同步架构图与 Data Flow Diagram

供应商数据同步链路架构图

供应商数据同步 Data Flow Diagram

完整的任务模型、Checkpoint、Worker 租约、DLQ 和监控指标,见第 26 章:商品供给、运营与生命周期治理

图中可以看到,供应商数据进入平台后会经过五个阶段:

供应商数据源
  → 接入适配与同步任务
  → 标准化、质量校验、平台模型映射
  → Resource / SPU / SKU / Offer / Mapping / Stock Snapshot 落库
  → 搜索索引、缓存、营销、计价、订单、数据平台刷新
  → 失败补偿、监控告警、数据巡检

1. 同步对象分层

供应商同步首先要分清楚“同步的到底是什么”。不同数据的生命周期、新鲜度和交易风险完全不同,不能放在同一张表、使用同一个刷新策略。

数据层示例平台承接模型同步特点
资源数据城市、机场、车站、酒店、影院、商户、门店resource_tab相对稳定,适合全量 + 增量同步
商品主数据标题、图片、类目、属性、可售范围product_spu_tabproduct_sku_tab变化频率中等,需要审核与发布版本
销售配置面额、套餐、房价计划、票种、售卖规则product_offer_tabrate_plan_tab直接影响展示价和可售性
动态交易数据价格、库存、座位图、房态、可售状态product_stock_tab、缓存、实时查询变化快,需要 TTL 和交易前确认
供应商映射供应商酒店 ID、房型 ID、套餐 ID、票种 IDsupplier_product_mapping_tab是履约、查价、查库存的关键桥梁

这个分层决定了同步策略:静态资源可以沉淀,动态报价可以缓存,强交易数据必须实时确认。商品中心不能为了统一而把所有数据都持久化成 SKU,也不能为了灵活而完全不沉淀基础资源。

2. 同步模式设计

供应商同步通常要同时支持五种模式:

同步模式适用场景设计重点
全量同步新供应商接入、数据修复、周期校准分片、断点续跑、批次版本、失败明细
增量同步日常商品、资源、状态变化游标、更新时间、水位记录、乱序处理
供应商 Push供应商主动推送价格、库存、上下架变化幂等、签名校验、重复消息去重
平台主动刷新热门酒店、热门影片、热门面额、活动商品根据曝光、点击、转化、变价率动态调频
交易前实时确认Flight、Hotel、Movie 等强实时品类下单前查价、查库存、锁资源或确认可售

实际系统中这五种模式会同时存在。比如酒店静态信息来自全量和增量同步,列表页最低价来自定时刷新,详情页房态房价来自短 TTL 缓存,下单前必须实时向供应商确认。

3. 幂等设计

供应商同步的幂等要覆盖三层:

层级幂等对象幂等 Key目的
接入层一次供应商 Push 或同步消息supplier_id + event_id 或 payload hash防止重复消费
映射层一个外部资源或商品supplier_id + supplier_resource_code + supplier_product_code防止重复创建 Resource/SPU/SKU
发布层一次平台商品变更sync_batch_id + platform_product_id + data_hash防止重复发布、重复刷新索引

其中 supplier_resource_code 表示供应商侧稳定资源,例如酒店 ID、影院 ID、商户 ID、机场/车站代码;supplier_product_code 表示供应商侧可售对象,例如房型、套餐、面额、场次、票种。

供应商原始数据
  → 生成 source_hash
  → 查询 supplier_mapping
  → 已存在:比较 data_hash,变化才更新
  → 不存在:创建平台 Resource / SPU / SKU / Offer
  → 写入 mapping,保证后续同步可定位

这里最容易踩坑的是供应商编码不稳定。有些供应商会复用商品编码、合并资源、拆分资源,甚至换供应商后编码体系完全变化。因此平台不能直接把供应商编码当成平台主键,而要维护独立的 platform_resource_idspu_idsku_id,供应商编码只作为映射关系存在。

4. 版本设计

版本要分清楚三类,不能混在一起:

版本含义用途
sync_batch_version本次同步任务版本排查“哪次同步带来了变化”
data_snapshot_version原始数据和标准化数据快照版本支持回放、diff、回滚
publish_version平台正式发布版本控制搜索、缓存、下游事件一致性

推荐链路如下:

Sync Batch v102
  → Raw Snapshot v102.1
  → Normalized Snapshot v102.1
  → Diff: price changed / room name changed / offer disabled
  → Publish Version p5688
  → ProductUpdated / OfferChanged / StockChanged Event

版本设计的关键是:同步版本不等于发布版本。供应商同步可能只是拉到了数据,但经过校验后发现字段缺失,不应该发布;也可能一次同步中有 10 万条数据,只有 300 条真正变化。平台需要把“同步到了什么”和“发布了什么”分开记录。

5. 质量校验设计

质量校验不能只做字段非空,而要分成五层:

校验层校验内容失败处理
Schema 校验必填字段、类型、枚举、时间格式、货币单位直接拦截,进入失败明细
主数据校验城市、机场、酒店、商户、品牌、类目是否存在进入待映射或人工修复
模型校验是否能映射到 Resource/SPU/SKU/Offer/Rate Plan阻断发布
交易校验价格是否异常、库存是否为负、可售状态是否矛盾高风险拦截或降级
业务规则校验是否允许该站点、渠道、品类售卖,是否需要审核进入审核或灰度发布

质量校验要支持“部分成功”。例如酒店全量同步 100 万条房型数据,不能因为 100 条数据失败就整批失败。更合理的处理方式是:可处理数据继续写入,失败明细单独记录 error_codeerror_messageraw_payload_ref,高风险数据不发布,进入人工修复或补偿队列。

供应商同步失败治理的答辩提示已统一收录到第 39 章的“供应商同步恢复与 DLQ”题卡

6. 新鲜度设计

不同数据的 TTL 不一样,不能使用统一缓存时间。

数据类型示例新鲜度要求策略
静态资源酒店名称、地址、设施、机场、车站小时级或天级全量 + 增量同步
半动态数据酒店最低价、可售状态、热门库存水位分钟级定时刷新 + 热门加频
强动态数据机票报价、座位图、下单前房态房价秒级或实时搜索缓存,详情刷新,下单实时确认
交易契约退款规则、履约参数、供应商映射强一致倾向发布版本控制,不随意覆盖

新鲜度可以按三个维度决策:

TTL = f(category, popularity, transaction_stage)

示例:

场景TTL 策略
Hotel 列表页最低价热门酒店 10 分钟刷新,长尾酒店 1-6 小时
Hotel 详情页房态房价用户进入详情页时刷新或短 TTL 缓存
Hotel 下单前确认必须实时查供应商
Flight 搜索报价实时查或极短 TTL
Topup 面额配置小时级缓存即可
Bill 账单金额用户输入账单号后实时查询

这里的原则是:L 页可以快,D 页要准,创单必须安全。列表页价格允许作为导购参考,详情页价格要尽量接近实时,创单价格必须基于最新供应商状态确认。

7. 补偿设计

补偿不是“失败后重试三次”这么简单,而要按失败类型分类处理。

失败类型示例处理方式
临时失败网络超时、供应商 5xx、限流指数退避重试
数据失败字段缺失、枚举非法、价格异常不盲目重试,进入修复队列
映射失败找不到城市、酒店、影院、商户映射进入人工映射或规则匹配
发布失败DB 成功但 ES 刷新失败Outbox 重试,索引补偿
一致性失败平台数据与供应商数据长期不一致对账任务 + 差异修复

推荐处理链路:

Sync Failed
  → 判断错误类型
  → Retryable:延迟重试
  → NonRetryable:进入失败明细
  → MappingRequired:进入人工修复队列
  → PublishFailed:Outbox 补偿
  → StaleData:巡检任务重新拉取

死信队列中不要只存错误信息,要存完整上下文:

supplier_id
sync_batch_id
supplier_resource_code
supplier_product_code
error_code
error_message
raw_payload_ref
retry_count
next_retry_time
owner_team

否则线上排查时只能看到“同步失败”,不知道失败的是哪个供应商、哪个资源、哪条商品。

8. 死信队列落地设计

死信队列(Dead Letter Queue, DLQ)不要只理解成“失败消息丢到一个 MQ Topic”。在供应商商品同步场景里,失败往往不是单纯的消息消费失败,而是字段缺失、映射失败、价格异常、发布失败、索引刷新失败等需要人工修复、状态流转和审计的问题。因此,推荐设计成 MySQL 为主的可运营 DLQ + MQ/Redis 做调度辅助

组件职责
MySQL DLQ 表权威问题单,支持查询、筛选、人工修复、状态流转、审计和报表
Kafka / MQ可选,用于失败事件的短期缓冲和异步投递
Redis ZSet可选,用于延迟重试调度,按 next_retry_at 排序
Raw Snapshot / 对象存储保存大体积原始 payload,DLQ 表只保存引用

判断一条失败是否进入 DLQ,需要先做错误分类:

失败类型是否进入 DLQ处理方式
网络超时、供应商 5xx不一定先自动重试,超过次数后进入 DLQ
供应商限流不一定延迟重试、降速、熔断
字段缺失、枚举非法需要人工或规则修复
城市、酒店、影院、商户映射失败需要补映射
价格异常、库存异常高风险数据拦截
DB 写入成功但 ES 刷新失败索引补偿
同步成功但发布失败发布补偿
重复消息幂等丢弃即可

推荐处理架构如下:

同步任务失败
  → 错误分类
  → Retryable:进入延迟重试队列
  → 达到最大重试次数:写入 MySQL DLQ
  → NonRetryable:直接写入 MySQL DLQ
  → 人工修复 / 规则修复 / 定时补偿
  → 重新投递同步任务
  → 成功后标记 RESOLVED

DLQ 主表可以这样设计:

CREATE TABLE supplier_sync_dead_letter (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,

    -- 定位同步批次
    sync_batch_id VARCHAR(64) NOT NULL,
    sync_task_id VARCHAR(64) NOT NULL,
    sync_mode VARCHAR(32) NOT NULL COMMENT 'FULL/INCREMENTAL/PUSH/REFRESH',
    category_code VARCHAR(32) NOT NULL,

    -- 定位供应商和外部对象
    supplier_id BIGINT NOT NULL,
    supplier_resource_code VARCHAR(128) DEFAULT NULL,
    supplier_product_code VARCHAR(128) DEFAULT NULL,

    -- 平台侧映射,可为空,因为很多失败发生在映射前
    platform_resource_id BIGINT DEFAULT NULL,
    spu_id BIGINT DEFAULT NULL,
    sku_id BIGINT DEFAULT NULL,
    offer_id BIGINT DEFAULT NULL,

    -- 错误分类
    error_stage VARCHAR(64) NOT NULL COMMENT 'ADAPTER/VALIDATION/MAPPING/PUBLISH/INDEX',
    error_type VARCHAR(64) NOT NULL COMMENT 'RETRYABLE/NON_RETRYABLE/MAPPING_REQUIRED/RISK_BLOCKED',
    error_code VARCHAR(128) NOT NULL,
    error_message VARCHAR(1024) NOT NULL,

    -- Payload 不建议大字段直接塞满主表
    raw_payload_ref VARCHAR(512) DEFAULT NULL,
    raw_payload_hash VARCHAR(64) DEFAULT NULL,
    normalized_payload_ref VARCHAR(512) DEFAULT NULL,

    -- 重试与状态
    status VARCHAR(32) NOT NULL DEFAULT 'PENDING'
        COMMENT 'PENDING/RETRYING/MANUAL_FIX/RESOLVED/IGNORED/FAILED',
    retry_count INT NOT NULL DEFAULT 0,
    max_retry_count INT NOT NULL DEFAULT 5,
    next_retry_at DATETIME DEFAULT NULL,
    last_retry_at DATETIME DEFAULT NULL,

    -- 人工处理
    owner_team VARCHAR(64) DEFAULT NULL,
    assignee VARCHAR(64) DEFAULT NULL,
    fix_note VARCHAR(1024) DEFAULT NULL,

    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    resolved_at DATETIME DEFAULT NULL,

    UNIQUE KEY uk_dedup (
        sync_batch_id,
        supplier_id,
        supplier_resource_code,
        supplier_product_code,
        error_stage,
        raw_payload_hash
    ),
    KEY idx_status_next_retry (status, next_retry_at),
    KEY idx_supplier_status (supplier_id, status),
    KEY idx_category_status (category_code, status),
    KEY idx_task (sync_task_id),
    KEY idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步死信队列';

这个表有几个关键设计点:

  1. 定位外部对象supplier_resource_codesupplier_product_code 用来定位供应商侧资源和可售对象。
  2. 平台映射允许为空:很多失败发生在映射前,所以 platform_resource_idsku_idoffer_id 都不能强制非空。
  3. Payload 存引用:供应商原始数据可能很大,尤其是酒店图片、设施、电影座位图,不建议全部放在 DLQ 主表。
  4. 唯一键去重uk_dedup 防止同一条错误反复写入 DLQ。
  5. 补偿扫描索引idx_status_next_retry 支持补偿 Job 按状态和下次重试时间扫描。

如果不想引入对象存储,也可以把 payload 放到单独快照表:

CREATE TABLE supplier_sync_payload_snapshot (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    payload_ref VARCHAR(128) NOT NULL,
    payload_type VARCHAR(32) NOT NULL COMMENT 'RAW/NORMALIZED',
    payload_json JSON NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_payload_ref (payload_ref)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步载荷快照';

DLQ 状态机建议保持简单:

PENDING
  → RETRYING
  → RESOLVED

PENDING
  → MANUAL_FIX
  → RETRYING
  → RESOLVED

PENDING
  → IGNORED

RETRYING
  → FAILED
状态含义
PENDING等待系统或人工处理
RETRYING正在补偿重试
MANUAL_FIX需要人工补映射、修字段、确认风险
RESOLVED已修复成功
IGNORED确认无需处理,例如供应商下架或数据已废弃
FAILED多次补偿仍失败,需要升级

补偿 Job 可以按 statusnext_retry_at 扫描:

SELECT *
FROM supplier_sync_dead_letter
WHERE status IN ('PENDING', 'FAILED')
  AND next_retry_at <= NOW()
  AND retry_count < max_retry_count
ORDER BY next_retry_at ASC
LIMIT 100;

处理时要按错误类型走不同分支:

func ProcessDeadLetter(ctx context.Context, dlq *DeadLetter) error {
    if !tryLock(dlq.ID) {
        return nil
    }

    switch dlq.ErrorType {
    case "RETRYABLE":
        return retryOriginalSync(ctx, dlq)
    case "MAPPING_REQUIRED":
        if !mappingFixed(dlq) {
            markManualFix(dlq)
            return nil
        }
        return retryOriginalSync(ctx, dlq)
    case "RISK_BLOCKED":
        return waitManualApproval(ctx, dlq)
    case "PUBLISH_FAILED":
        return retryPublish(ctx, dlq)
    default:
        markManualFix(dlq)
        return nil
    }
}

重试时间建议使用指数退避,避免供应商故障时补偿任务反复打爆外部接口:

next_retry_at = now + min(2^retry_count minutes, 1 hour)

为什么不只用 Kafka DLQ?因为 Kafka 更适合保留失败消息,不适合作为运营治理主存储。

能力Kafka DLQMySQL DLQ
保留失败消息
按供应商、品类、错误码查询
人工修复状态流转
审计和报表
定时补偿扫描一般
高吞吐消息暂存一般

所以更推荐采用:

Kafka DLQ:短期消息缓冲,可选
MySQL DLQ:权威问题单和补偿状态

供应商同步 DLQ 的答辩总结已统一收录到第 39 章的“MySQL DLQ”题卡

9. 监控设计

9. 监控设计

供应商同步监控要分成技术指标、数据质量指标和业务影响指标。

指标类型指标说明
技术指标同步成功率、失败率、平均耗时、P99 耗时、重试次数看任务是否健康
数据质量字段缺失率、映射失败率、重复数据率、异常价格率看数据是否可信
新鲜度数据延迟、过期数据比例、热门商品刷新延迟看数据是否足够新
交易影响L-D 变价率、D-B 不可售率、下单前确认失败率看同步对转化和交易的影响
供应商维度每个供应商成功率、超时率、字段错误率支持供应商治理
品类维度每个品类同步量、失败率、变价率支持品类策略优化

核心指标可以这样定义:

同步成功率 = 成功处理 item 数 / 总 item 数
映射失败率 = 映射失败 item 数 / 总 item 数
字段缺失率 = 缺失关键字段 item 数 / 总 item 数
数据新鲜度延迟 = now - last_success_sync_time
L-D 变价率 = 详情页价格 != 列表页价格 的访问占比
D-B 不可售率 = 下单前确认不可售 / 详情页可售点击

这些指标不仅用于技术告警,也应该反馈到运营和供应商治理。比如某个供应商字段缺失率长期高,说明不是偶发故障,而是供应商数据质量问题;某个品类 L-D 变价率长期高,说明列表页缓存刷新策略需要调整;某个热门酒店 D-B 不可售率高,说明详情页房态刷新或下单前确认策略存在问题。

10. 不同品类的同步策略

品类平台沉淀什么实时获取什么同步重点
Flight / Train / Bus城市、机场、车站、航司/车司、基础线路报价、余票、座位、退改规则确认少沉淀 SKU,搜索和下单前强实时
Hotel酒店、房型、设施、地理位置、图片、品牌房态、房价、取消规则最终确认静态资源沉淀,动态价格库存按热度刷新
Topup运营商、国家/地区、面额、套餐、号码规则账号可用性、供应商可用性商品配置稳定,重点是账号校验和供应商状态
Bill账单机构、账单类型、输入字段、支付规则账单金额、欠费状态、是否可缴低代码表单 + 实时查账单
Movie / Event影片、影院、活动、场次、票种、套餐座位图、最终票态、锁座结果半同步半实时,座位相关必须实时确认
Voucher / Gift Card商户、品牌、面额、有效期、核销规则本地券码池库存或供应商券码状态更偏平台自营库存,重点是券码池和核销状态

供应商同步整体答辩总结已统一收录到第 39 章的“供应商同步恢复与 DLQ”题卡

34.6.1.8 库存与可售设计

在本平台中库存没有独立拆服务,而是由商品中心内部的库存与可售域承接。这里的关键不是把库存字段放进商品主表,而是建立统一的库存抽象,屏蔽不同品类的库存来源差异。

库存类型典型品类库存来源处理方式
无限库存Topup、Bill无明确库存或供应商容量足够只做可售规则与供应商可用性校验
池化库存Voucher、Gift Card平台券码池或本地库存支付后分配券码,库存不足时停止售卖
实时库存Flight、Hotel、Movie供应商接入层 / 外部供应商实时查询搜索展示可缓存,下单前必须实时确认

统一可售判断

可售 = 商品状态可售
    + 类目/站点/渠道可售
    + 库存满足
    + 供应商可用
    + 风控/业务规则允许

库存相关动作包括查询、预占、释放、扣减、回补和对账。对于 Flight/Hotel/Movie 这类实时库存,商品中心更多是统一入口和状态判断,真实资源确认仍然要通过供应商网关或供应商接入层完成。


34.6.1.9 搜索与导购设计

由于搜索也由商品中心负责,商品中心不仅要管理商品数据,还要负责把商品组织成用户可浏览、可搜索、可筛选、可点击的前台体验。

搜索导购链路

首页入口/类目导航
  → 列表页搜索与筛选
  → ES 召回
  → 排序与过滤
  → Hydrate 商品信息、库存、展示价、营销标签
  → 返回列表页
  → 详情页聚合

关键设计点

能力设计重点
首页入口入口配置、类目分组、排序、发布快照、CDN/Redis 缓存
ES 索引商品主数据、类目、实体、Tag、可售状态、运营排序字段
Hydrate搜索只返回候选 ID,详情信息、库存、价格、营销标签统一补齐
缓存热门商品、本地缓存、Redis、索引快照结合使用
降级价格不可用时展示基础价,营销不可用时隐藏标签,库存不可用时弱提示
新鲜度L 页允许缓存,D 页更接近实时,创单必须实时校验

搜索导购的答辩总结已统一收录到第 39 章的“电商搜索引擎架构”题卡


34.6.1.10 跨系统集成与事件设计

商品中心位于交易前链路,需要向多个系统输出稳定契约。

下游系统商品中心输出对方使用方式
营销系统类目、Tag、商品范围、业务实体、可营销状态圈品、活动配置、券可用范围
计价中心基础价、类目、属性、库存上下文、能力配置PDP 价格、结算试算价、下单价、结算价
订单系统商品快照、上下架状态、可售校验、库存预占结果创建订单前校验与订单快照落库
履约系统履约类型、供应商映射、履约参数出票、充值、发券、预订确认
供应商网关/供应商接入层平台 SKU、供应商映射、同步任务上下文查价、查库存、同步、履约调用
数据平台CDC、商品变更日志、质量监控数据经营分析、质量报表、异常发现

核心事件

事件触发时机典型消费者
ProductCreated商品创建成功搜索索引、营销、数据平台
ProductUpdated商品字段变化搜索索引、缓存刷新、质量监控
ProductOnShelf商品上架搜索、营销、推荐
ProductOffShelf商品下架搜索、订单前校验、运营看板
StockChanged库存变化搜索导购、告警、数据平台
SupplierSyncFailed供应商同步失败告警、运营后台、任务补偿

事件发布建议使用 Outbox 或可靠消息机制,避免“商品已更新但事件丢失”导致搜索、营销、计价数据不一致。


34.6.1.11 架构取舍与经验总结

1. 为什么没有独立拆库存中心和搜索中心?

这是团队规模和演进阶段下的取舍。库存和搜索都与商品强相关,早期拆成独立服务会增加团队协作、接口维护和数据一致性成本。把它们放在商品中心内部,可以减少跨服务调用,提高迭代效率。但内部必须按域隔离,避免主数据、库存状态和搜索索引混成一团。

2. 为什么不用一张大宽表?

大宽表短期开发快,但会很快变成 80+ 字段、15+ 品类混杂、字段语义不清的“大泥球”。更好的做法是:稳定字段进主表,可检索字段进属性表,展示型差异进 ExtInfo,高频品类能力进入扩展表。

3. ExtInfo JSON、EAV、扩展表怎么取舍?

方案适合场景不适合场景
ExtInfo JSON低频展示字段、品类专属配置高频查询、筛选、排序
EAV 属性表可搜索、可筛选、可分析属性强事务、高频更新字段
扩展表高频访问的品类核心字段低频字段和一次性配置

4. 为什么 Flight 不沉淀完整 SKU?

机票报价由日期、航线、航班、舱位、乘客类型、供应商策略共同决定,价格和库存变化太快。如果把每次报价都沉淀成 SKU,会产生巨量临时 SKU,且数据很快过期。更合理的是商品中心维护城市、机场、航司等基础资源,搜索和创单实时请求供应商。

5. 为什么 Hotel 要静态信息和动态房态房价分离?

酒店名称、地址、设施、图片相对稳定,适合同步到商品中心并进入搜索索引;房态和房价按日期、间夜、人数实时变化,适合缓存刷新和下单前实时确认。两者分离可以兼顾搜索性能和交易准确性。

6. 如何避免商品中心变成“大泥球”?

关键是三条边界:内部按六个域拆分,数据库按主表/属性/扩展/映射/日志拆分,对外用 API 和事件契约隔离。即便物理上是一个商品中心,逻辑上也要保持清晰边界,为未来拆分成商品中台、库存中心、搜索中心留下演进空间。


34.6.1.12 实现落地参考:DDD 分层与代码组织

前面 34.6.1.2 到 34.6.1.11 讨论的是商品中心的业务架构和数据架构。本节保留一个简化版 DDD 代码落地参考,用于说明 Product 聚合根、Repository、接口层、事件订阅和缓存如何组织。它不是上述完整商品中心的全量实现,而是帮助读者理解“架构如何落到代码”的最小示例。

34.6.1.12.1 八层模型的工程落地方式

八层商品交易模型不建议直接落成八个服务或八组强耦合表。更合理的落地方式是:用八层模型识别品类差异,用品类能力矩阵沉淀差异,用 Runtime Context 输出交易前上下文,用 Category Strategy 执行动态差异,用供应商适配器隔离外部接口差异

八层商品交易模型
  → Category Capability Matrix  品类能力矩阵
  → Product Master Model        商品主模型
  → ProductRuntimeContext       交易前运行时上下文
  → CategoryStrategy            品类策略
  → Supplier Adapter / ACL      供应商适配器/防腐层

1. Category Capability Matrix:把八层模型变成品类配置

能力矩阵描述每个类目在八层模型上的行为。新增品类时,先补齐能力矩阵,再判断是否需要新增策略代码。

category_capability
├─ category_id
├─ product_model_type       // SINGLE_SKU / RESOURCE_BASED / REALTIME_OFFER / ACCOUNT_BASED
├─ resource_type            // NONE / HOTEL / FLIGHT / MOVIE / MERCHANT / BILLER
├─ offer_type               // FIXED_PRICE / RATE_PLAN / REALTIME_QUOTE / BILL_QUERY
├─ availability_type        // UNLIMITED / LOCAL_POOL / REALTIME_SUPPLIER / SEATMAP
├─ input_schema_id
├─ booking_mode             // NONE / PRE_LOCK / PAY_THEN_LOCK / CONFIRM_AFTER_PAY
├─ fulfillment_type         // TOPUP / BILL_PAY / ISSUE_CODE / TICKET / BOOKING_CONFIRM
├─ refund_rule_id
└─ supplier_dependency      // LOW / MEDIUM / HIGH
品类商品模型报价可用性输入锁定履约
TopupSINGLE_SKUFIXED_PRICESUPPLIER_CHANNEL手机号NONE充值
BillACCOUNT_BASEDBILL_QUERYBILL_PAYABLE账单号LOCK_AMOUNT销账
Gift CardSINGLE_SKUFIXED_PRICELOCAL_POOL邮箱/账号LOCK_CODE发码
HotelRESOURCE_BASEDRATE_PLANREALTIME_SUPPLIER入住人/日期CONFIRM_BOOKING预订确认
FlightREALTIME_OFFERREALTIME_QUOTEREALTIME_SUPPLIER乘客证件PRE_LOCK出票

2. ProductRuntimeContext:给交易前链路统一输出

搜索、详情、结算、创单都需要商品信息,但需要的深度不同。因此商品中心可以输出统一的运行时上下文,再由调用方按场景读取需要的部分。

type ProductRuntimeContext struct {
    ProductDefinition   ProductDefinition
    ResourceContext     ResourceContext
    OfferContext        OfferContext
    Availability        AvailabilityContext
    InputSchema         InputSchema
    BookingRequirement  BookingRequirement
    FulfillmentContract FulfillmentContract
    RefundRule          RefundRule
}
场景需要的上下文
首页ProductDefinition
列表页ProductDefinition + Offer 展示价 + Availability 弱状态
详情页Product + Resource + Offer + Availability + RefundRule
结算页Product + Offer + Availability + InputSchema
创单Product + 实时 Offer + 实时 Availability + Booking + Fulfillment + RefundRule

3. CategoryStrategy:让主流程不感知品类差异

主流程不应该写大量 if category == flightif category == hotel。品类差异应该进入策略接口。

type CategoryStrategy interface {
    BuildProductContext(ctx context.Context, req *RuntimeRequest) (*ProductDefinition, error)
    ResolveOffer(ctx context.Context, req *RuntimeRequest) (*OfferContext, error)
    CheckAvailability(ctx context.Context, req *RuntimeRequest) (*AvailabilityContext, error)
    ValidateInput(ctx context.Context, req *RuntimeRequest) error
    PrepareBooking(ctx context.Context, req *RuntimeRequest) (*BookingRequirement, error)
    BuildFulfillmentContract(ctx context.Context, req *RuntimeRequest) (*FulfillmentContract, error)
    BuildRefundRule(ctx context.Context, req *RuntimeRequest) (*RefundRule, error)
}

不同品类实现不同策略:

TopupStrategy
BillStrategy
GiftCardStrategy
LocalServiceStrategy
HotelStrategy
FlightStrategy
MovieStrategy

4. Supplier Adapter / ACL:隔离供应商接口差异

供应商请求参数、响应字段、错误码和超时策略都不一致,不能让这些差异污染商品中心主模型。供应商适配器负责请求转换、响应转换、错误码统一、超时重试、熔断、幂等键和 TraceID 透传。

平台统一请求
  → Supplier Adapter
  → 供应商 A/B/C 私有协议
  → Supplier Adapter 归一化响应
  → OfferContext / AvailabilityContext

5. 创单场景下的完整运行流程

1. 订单系统请求商品中心构造 RuntimeContext
2. 商品中心读取 category_capability
3. 根据 category_id 找到对应 CategoryStrategy
4. Strategy 读取商品主数据、资源、输入配置、履约配置
5. Strategy 调供应商适配器获取实时 Offer / Availability
6. Strategy 校验用户输入
7. 如果需要 Booking,则执行预订、占座或锁定
8. 商品中心返回 ProductRuntimeContext
9. 订单系统保存商品快照、报价快照、履约契约、售后规则快照
10. 支付成功后,履约系统按 FulfillmentContract 执行交付

这套落地方式的关键是:商品中心保存能力与规则,RuntimeContext 输出交易前上下文,订单保存交易快照,履约系统执行交付,售后系统执行退款规则

领域模型设计思想:商品域的特点是“树形结构+读多写少“,与订单域的“复杂状态机+高并发写“完全不同。

34.6.1.12.2 Product 聚合根示例
// Product聚合根(SKU维度)
type Product struct {
    // 聚合根ID
    skuID SKU_ID  // 值对象
    
    // SPU信息(实体引用)
    spu *SPU
    
    // SKU规格(值对象)
    specs Specifications
    
    // 基础价格(值对象)
    basePrice Price
    
    // 状态(值对象)
    status ProductStatus
    
    // 多媒体素材
    images []ImageURL
    
    // 时间戳
    createdAt time.Time
    updatedAt time.Time
    
    // 领域事件(未提交)
    domainEvents []DomainEvent
}

// 值对象:SKU_ID
type SKU_ID struct {
    value int64
}

func NewSKU_ID(id int64) SKU_ID {
    return SKU_ID{value: id}
}

func (id SKU_ID) Int64() int64 {
    return id.value
}

// 值对象:Price(基础价格,单位:分)
type Price struct {
    amount int64  // 分为单位
}

func NewPrice(amount int64) (Price, error) {
    if amount < 0 {
        return Price{}, errors.New("价格不能为负数")
    }
    if amount > 100000000 { // 100万元上限
        return Price{}, errors.New("价格超过上限")
    }
    return Price{amount: amount}, nil
}

func (p Price) Amount() int64 {
    return p.amount
}

func (p Price) Yuan() float64 {
    return float64(p.amount) / 100.0
}

// 值对象:Specifications(SKU规格)
type Specifications struct {
    attributes map[string]string  // {"颜色":"红色","尺寸":"L"}
}

func NewSpecifications(attrs map[string]string) Specifications {
    return Specifications{attributes: attrs}
}

func (s Specifications) Get(key string) string {
    return s.attributes[key]
}

func (s Specifications) ToJSON() string {
    data, _ := json.Marshal(s.attributes)
    return string(data)
}

// 值对象:ProductStatus
type ProductStatus string

const (
    ProductDraft     ProductStatus = "DRAFT"      // 草稿
    ProductOnShelf   ProductStatus = "ON_SHELF"   // 在架
    ProductOffShelf  ProductStatus = "OFF_SHELF"  // 下架
)

// 实体:SPU(标准产品单元)
type SPU struct {
    id         SPU_ID
    title      string
    categoryID int64
    brandID    int64
    attributes map[string][]string  // 属性模板{"颜色":["红","蓝"],"尺寸":["S","M","L"]}
    description string
    
    // SPU下的所有SKU(聚合内实体集合)
    skus []*Product
}

func (spu *SPU) ID() SPU_ID {
    return spu.id
}

func (spu *SPU) Title() string {
    return spu.title
}

func (spu *SPU) AddSKU(sku *Product) error {
    // 不变量检查:SKU规格必须符合SPU属性模板
    if !spu.isValidSpecs(sku.specs) {
        return errors.New("SKU规格不符合SPU属性模板")
    }
    spu.skus = append(spu.skus, sku)
    return nil
}

func (spu *SPU) isValidSpecs(specs Specifications) bool {
    // 检查SKU的规格是否都在SPU的属性模板中
    for key, value := range specs.attributes {
        allowedValues, exists := spu.attributes[key]
        if !exists {
            return false
        }
        if !contains(allowedValues, value) {
            return false
        }
    }
    return true
}
34.6.1.12.3 聚合根方法
// 上架(状态转换)
func (p *Product) OnShelf() error {
    if p.status == ProductOnShelf {
        return errors.New("商品已在架")
    }
    
    // 不变量检查:必须有基础价格
    if p.basePrice.Amount() == 0 {
        return errors.New("商品未设置价格,不能上架")
    }
    
    // 不变量检查:必须有商品图片
    if len(p.images) == 0 {
        return errors.New("商品未上传图片,不能上架")
    }
    
    oldStatus := p.status
    p.status = ProductOnShelf
    p.updatedAt = time.Now()
    
    // 发布领域事件
    p.addDomainEvent(&ProductOnShelfEvent{
        SKUID:      p.skuID,
        SPUID:      p.spu.id,
        OnShelfTime: p.updatedAt,
    })
    
    return nil
}

// 下架
func (p *Product) OffShelf(reason string) error {
    if p.status == ProductOffShelf {
        return errors.New("商品已下架")
    }
    
    oldStatus := p.status
    p.status = ProductOffShelf
    p.updatedAt = time.Now()
    
    // 发布领域事件
    p.addDomainEvent(&ProductOffShelfEvent{
        SKUID:       p.skuID,
        Reason:      reason,
        OffShelfTime: p.updatedAt,
    })
    
    return nil
}

// 更新基础价格
func (p *Product) UpdateBasePrice(newPrice Price) error {
    if newPrice.Amount() == p.basePrice.Amount() {
        return nil  // 价格未变化
    }
    
    oldPrice := p.basePrice
    p.basePrice = newPrice
    p.updatedAt = time.Now()
    
    // 发布领域事件
    p.addDomainEvent(&PriceChangedEvent{
        SKUID:    p.skuID,
        OldPrice: oldPrice.Amount(),
        NewPrice: newPrice.Amount(),
        ChangedAt: p.updatedAt,
    })
    
    return nil
}

// 领域事件管理
func (p *Product) addDomainEvent(event DomainEvent) {
    p.domainEvents = append(p.domainEvents, event)
}

func (p *Product) DomainEvents() []DomainEvent {
    return p.domainEvents
}

func (p *Product) ClearDomainEvents() {
    p.domainEvents = nil
}

// 查询方法
func (p *Product) IsOnShelf() bool {
    return p.status == ProductOnShelf
}

func (p *Product) BasePrice() Price {
    return p.basePrice
}

func (p *Product) Specs() Specifications {
    return p.specs
}
34.6.1.12.4 Repository 模式(防腐层)
// ProductRepository接口(领域层定义)
type ProductRepository interface {
    // 查询
    FindBySKUID(ctx context.Context, skuID SKU_ID) (*Product, error)
    FindBySPUID(ctx context.Context, spuID SPU_ID) ([]*Product, error)
    BatchFindBySKUIDs(ctx context.Context, skuIDs []SKU_ID) ([]*Product, error)
    
    // 保存
    Save(ctx context.Context, product *Product) error
    Update(ctx context.Context, product *Product) error
    
    // 删除
    Delete(ctx context.Context, skuID SKU_ID) error
}

// ProductRepositoryImpl实现(基础设施层)
type ProductRepositoryImpl struct {
    db             *gorm.DB
    cache          cache.Cache
    eventPublisher EventPublisher
    sharding       ShardingStrategy
}

func (r *ProductRepositoryImpl) FindBySKUID(ctx context.Context, skuID SKU_ID) (*Product, error) {
    // Step 1: 查询L1本地缓存
    cacheKey := fmt.Sprintf("product:%d", skuID.Int64())
    if cached, found := r.cache.GetLocal(cacheKey); found {
        return cached.(*Product), nil
    }
    
    // Step 2: 查询L2 Redis缓存
    if cached, err := r.cache.Get(ctx, cacheKey); err == nil {
        product := r.unmarshalProduct(cached)
        r.cache.SetLocal(cacheKey, product, 1*time.Minute)
        return product, nil
    }
    
    // Step 3: 查询MySQL
    productDO, err := r.queryFromDB(ctx, skuID)
    if err != nil {
        return nil, err
    }
    
    // Step 4: 转换DO → Domain Model
    product := r.toDomain(productDO)
    
    // Step 5: 回写缓存
    r.cache.Set(ctx, cacheKey, r.marshalProduct(product), 30*time.Minute)
    r.cache.SetLocal(cacheKey, product, 1*time.Minute)
    
    return product, nil
}

func (r *ProductRepositoryImpl) Save(ctx context.Context, product *Product) error {
    // Step 1: 转换Domain Model → DO
    productDO := r.toDataObject(product)
    
    // Step 2: 分库路由
    db := r.sharding.Route(product.spu.categoryID)
    
    // Step 3: 保存到数据库
    if err := db.WithContext(ctx).Create(productDO).Error; err != nil {
        return fmt.Errorf("save product failed: %w", err)
    }
    
    // Step 4: 发布领域事件(事务提交后)
    for _, event := range product.DomainEvents() {
        if err := r.eventPublisher.Publish(ctx, event); err != nil {
            log.Errorf("publish event failed: %v", err)
        }
    }
    product.ClearDomainEvents()
    
    // Step 5: 清除缓存
    cacheKey := fmt.Sprintf("product:%d", product.skuID.Int64())
    r.cache.Delete(ctx, cacheKey)
    
    return nil
}

func (r *ProductRepositoryImpl) BatchFindBySKUIDs(ctx context.Context, skuIDs []SKU_ID) ([]*Product, error) {
    products := make([]*Product, 0, len(skuIDs))
    
    // 批量查询优化:分离缓存命中和未命中
    var missedIDs []SKU_ID
    
    for _, skuID := range skuIDs {
        cacheKey := fmt.Sprintf("product:%d", skuID.Int64())
        if cached, err := r.cache.Get(ctx, cacheKey); err == nil {
            products = append(products, r.unmarshalProduct(cached))
        } else {
            missedIDs = append(missedIDs, skuID)
        }
    }
    
    // 批量查询数据库(未命中的)
    if len(missedIDs) > 0 {
        missedProducts, err := r.batchQueryFromDB(ctx, missedIDs)
        if err != nil {
            return nil, err
        }
        
        // 回写缓存
        for _, product := range missedProducts {
            cacheKey := fmt.Sprintf("product:%d", product.skuID.Int64())
            r.cache.Set(ctx, cacheKey, r.marshalProduct(product), 30*time.Minute)
        }
        
        products = append(products, missedProducts...)
    }
    
    return products, nil
}

34.6.1.12.5 基础设施层(Infrastructure Layer)

####### 34.6.1.12.5.1 核心存储设计

表结构设计

-- SPU表(标准产品单元)
CREATE TABLE product_spu (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(255) NOT NULL COMMENT '商品标题',
    category_id BIGINT NOT NULL COMMENT '类目ID',
    brand_id BIGINT COMMENT '品牌ID',
    attributes JSON COMMENT '属性模板',
    description TEXT COMMENT '商品描述',
    status VARCHAR(20) DEFAULT 'DRAFT' COMMENT '状态',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_category (category_id),
    INDEX idx_brand (brand_id),
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SPU表';

-- SKU表(库存保持单元)
CREATE TABLE product_sku (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    spu_id BIGINT NOT NULL COMMENT 'SPU ID',
    sku_code VARCHAR(100) UNIQUE NOT NULL COMMENT 'SKU编码',
    specs JSON COMMENT '规格值',
    base_price BIGINT NOT NULL COMMENT '基础价格(分)',
    images JSON COMMENT '商品图片',
    status VARCHAR(20) DEFAULT 'DRAFT' COMMENT '状态',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_spu (spu_id),
    INDEX idx_code (sku_code),
    INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='SKU表';

-- 类目表
CREATE TABLE product_category (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    parent_id BIGINT DEFAULT 0 COMMENT '父类目ID',
    level INT DEFAULT 1 COMMENT '层级',
    sort_order INT DEFAULT 0 COMMENT '排序',
    status VARCHAR(20) DEFAULT 'ACTIVE',
    INDEX idx_parent (parent_id),
    INDEX idx_level (level)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品类目表';

分库分表策略

-- 按 category_id 分4库
-- 理由:同品类商品通常一起查询(搜索、推荐)
db_index = category_id % 4

-- 单表不分表
-- 理由:单品类商品数量可控(< 100万),查询模式简单

索引策略

索引名字段类型用途
PRIMARYid主键主键查询
idx_categorycategory_id普通类目查询
idx_brandbrand_id普通品牌查询
idx_statusstatus普通状态筛选
idx_spuspu_id普通SPU查SKU
idx_codesku_code唯一SKU编码查询

####### 34.6.1.12.5.2 缓存策略

详见下文“34.6.1.12.8 三级缓存实现“。


####### 34.6.1.12.5.3 消息中间件(Messaging)⭐️

职责划分

组件层级职责示例
Kafka ProducerInfrastructure事件发布(技术实现)发送消息到Kafka Topic
Kafka ConsumerInfrastructure事件消费(技术实现)订阅Topic、接收消息、路由
Event HandlerInterface协议适配Kafka消息 → DTO

Kafka Producer(事件发布)

// internal/infrastructure/messaging/kafka_producer.go

type KafkaProducer struct {
    producer *kafka.Producer
}

func (p *KafkaProducer) Publish(ctx context.Context, event domain.DomainEvent) error {
    topic := p.getTopicByEventType(event.EventType())
    
    // 序列化事件
    data, _ := json.Marshal(event)
    
    // 发送到Kafka
    return p.producer.Produce(&kafka.Message{
        TopicPartition: kafka.TopicPartition{
            Topic:     &topic,
            Partition: kafka.PartitionAny,
        },
        Key:   []byte(event.EventType()),
        Value: data,
        Headers: []kafka.Header{
            {Key: "event_type", Value: []byte(event.EventType())},
            {Key: "timestamp", Value: []byte(fmt.Sprint(event.OccurredAt().Unix()))},
        },
    }, nil)
}

Kafka Consumer(事件消费)

// internal/infrastructure/messaging/kafka_consumer.go

type KafkaConsumer struct {
    consumer     *kafka.Consumer
    eventHandler *event.ProductEventHandler  // 注入Interface Layer的Handler
}

func (c *KafkaConsumer) Start(ctx context.Context) error {
    // 订阅Topic
    c.consumer.SubscribeTopics([]string{
        "supplier-product-events",
        "pricing-events",
    }, nil)
    
    // 消费循环
    for {
        msg, err := c.consumer.ReadMessage(100 * time.Millisecond)
        if err != nil {
            continue
        }
        
        // ⭐️ 路由到Interface Layer的Event Handler
        messageType := string(msg.Key)
        if err := c.eventHandler.HandleMessage(ctx, messageType, msg.Value); err != nil {
            log.Errorf("Handle message failed: %v", err)
        } else {
            c.consumer.CommitMessage(msg)  // 手动提交offset
        }
    }
}

Topic设计

Topic生产者消费者用途
product-domain-eventsProduct ServiceSearch Service, Marketing Service商品领域事件(商品创建、上架、价格变更)
supplier-product-eventsSupplier ServiceProduct Service供应商商品事件(供应商创建商品)
pricing-eventsPricing ServiceProduct Service定价事件(价格计算完成)

详见 34.6.1.12.7.4 事件订阅者的分层设计


34.6.1.12.6 接口层(Interface Layer)

####### 34.6.1.12.6.1 gRPC 接口定义

核心接口(product.proto):

// ProductService商品服务
service ProductService {
    // 查询单个商品
    rpc GetProduct(GetProductRequest) returns (GetProductResponse);
    
    // 批量查询商品
    rpc BatchGetProducts(BatchGetProductsRequest) returns (BatchGetProductsResponse);
    
    // 创建商品
    rpc CreateProduct(CreateProductRequest) returns (CreateProductResponse);
    
    // 更新基础价格
    rpc UpdateBasePrice(UpdateBasePriceRequest) returns (UpdateBasePriceResponse);
    
    // 上架
    rpc OnShelf(OnShelfRequest) returns (OnShelfResponse);
    
    // 下架
    rpc OffShelf(OffShelfRequest) returns (OffShelfResponse);
}

message GetProductRequest {
    int64 sku_id = 1;
}

message GetProductResponse {
    ProductInfo product = 1;
}

message BatchGetProductsRequest {
    repeated int64 sku_ids = 1;  // 最多100个
}

message BatchGetProductsResponse {
    repeated ProductInfo products = 1;
}

message ProductInfo {
    int64 sku_id = 1;
    int64 spu_id = 2;
    string sku_code = 3;
    string sku_name = 4;
    Price base_price = 5;
    Specifications specs = 6;
    ProductStatus status = 7;
}

message Price {
    int64 amount = 1;  // 金额(分)
    string currency = 2;  // 货币(CNY)
}

message Specifications {
    string color = 1;
    string size = 2;
    map<string, string> attrs = 3;  // 其他属性
}

enum ProductStatus {
    DRAFT = 0;      // 草稿
    ON_SHELF = 1;   // 上架
    OFF_SHELF = 2;  // 下架
}

####### 34.6.1.12.6.2 HTTP 接口(可选)

// HTTP接口(供运营后台使用)
GET    /api/v1/products/:sku_id           # 查询商品
POST   /api/v1/products                    # 创建商品
PUT    /api/v1/products/:sku_id           # 更新商品
POST   /api/v1/products/:sku_id/on-shelf  # 上架
POST   /api/v1/products/:sku_id/off-shelf # 下架

####### 34.6.1.12.6.3 Event 接口(异步)⭐️

事件订阅接口(接收外部服务事件):

// ProductEventHandler 商品事件处理器(接口层)
// 职责:适配外部事件消息 → 调用Application Service
type ProductEventHandler struct {
    productService *service.ProductService
}

// 处理消息入口
func (h *ProductEventHandler) HandleMessage(ctx context.Context, messageType string, data []byte) error

// 订阅的事件类型
const (
    SupplierProductCreated = "supplier.product.created"  // 供应商商品创建
    PricingPriceChanged    = "pricing.price_changed"     // 定价变更
)

与HTTP/gRPC的区别

  • 同步接口(HTTP/gRPC):客户端等待响应
  • 异步接口(Event):消息队列异步触发,无响应

职责

  • ✅ 协议适配(Kafka消息 → DTO)
  • ✅ 调用Application Service
  • ❌ 不负责Kafka连接(由Infrastructure Layer的Kafka Consumer负责)

详见 34.6.1.12.7.4 事件订阅者的分层设计


34.6.1.12.7 应用服务层(Application Layer)

####### 34.6.1.12.7.1 核心代码结构

product-service/
├── cmd/
│   └── main.go                          # 服务入口
├── internal/
│   ├── domain/                          # 领域模型层
│   │   ├── product.go                   # Product聚合根
│   │   ├── spu.go                       # SPU实体
│   │   ├── value_objects.go             # 值对象(SKU_ID, Price, Specifications)
│   │   ├── events.go                    # 领域事件
│   │   └── repository.go                # Repository接口
│   ├── application/                     # 应用服务层
│   │   ├── dto/
│   │   │   ├── product_request.go       # 请求DTO
│   │   │   └── product_response.go      # 响应DTO
│   │   └── service/
│   │       ├── product_service.go       # 商品应用服务
│   │       └── product_query_service.go # 查询服务(CQRS)
│   ├── infrastructure/                  # 基础设施层
│   │   ├── persistence/
│   │   │   ├── product_repository.go    # Repository实现
│   │   │   ├── data_object.go           # 数据对象(DO)
│   │   │   └── sharding.go              # 分库路由
│   │   ├── cache/
│   │   │   ├── redis_cache.go           # Redis缓存
│   │   │   └── local_cache.go           # 本地缓存
│   │   └── messaging/                   # 消息中间件 ⭐️
│   │       ├── kafka_producer.go        # Kafka生产者(事件发布)
│   │       └── kafka_consumer.go        # Kafka消费者(技术实现)
│   └── interfaces/                      # 接口层
│       ├── grpc/
│       │   ├── product_handler.go       # gRPC处理器
│       │   └── proto/
│       │       └── product.proto        # Protobuf定义
│       ├── http/
│       │   └── product_handler.go       # HTTP处理器(可选)
│       └── event/ ⭐️                     # 事件接口(异步)
│           └── product_event_handler.go # Event Handler
├── config/
│   └── config.yaml                      # 配置文件
├── migrations/                          # 数据库迁移
│   └── 001_create_product_tables.sql
└── go.mod

####### 34.6.1.12.7.2 核心应用服务实现

应用服务层(product_service.go):

type ProductService struct {
    repo           domain.ProductRepository
    eventPublisher EventPublisher
}

// GetProduct 查询商品(三级缓存)
func (s *ProductService) GetProduct(ctx context.Context, skuID int64) (*dto.ProductResponse, error) {
    // Step 1: 通过Repository查询(Repository内部实现三级缓存)
    product, err := s.repo.FindBySKUID(ctx, domain.NewSKU_ID(skuID))
    if err != nil {
        return nil, fmt.Errorf("product not found: %w", err)
    }
    
    // Step 2: Domain Model → DTO
    return s.toDTO(product), nil
}

// BatchGetProducts 批量查询商品
func (s *ProductService) BatchGetProducts(ctx context.Context, skuIDs []int64) ([]*dto.ProductResponse, error) {
    // 参数校验:限制批量大小
    if len(skuIDs) > 100 {
        return nil, errors.New("批量查询最多100个")
    }
    
    // 转换为值对象
    domainIDs := make([]domain.SKU_ID, len(skuIDs))
    for i, id := range skuIDs {
        domainIDs[i] = domain.NewSKU_ID(id)
    }
    
    // 批量查询
    products, err := s.repo.BatchFindBySKUIDs(ctx, domainIDs)
    if err != nil {
        return nil, err
    }
    
    // 转换为DTO
    dtos := make([]*dto.ProductResponse, len(products))
    for i, p := range products {
        dtos[i] = s.toDTO(p)
    }
    
    return dtos, nil
}

// CreateProduct 创建商品
func (s *ProductService) CreateProduct(ctx context.Context, req *dto.CreateProductRequest) (*dto.ProductResponse, error) {
    // Step 1: DTO → Domain Model
    product, err := s.buildProduct(req)
    if err != nil {
        return nil, fmt.Errorf("build product failed: %w", err)
    }
    
    // Step 2: 保存(Repository内部发布领域事件)
    if err := s.repo.Save(ctx, product); err != nil {
        return nil, fmt.Errorf("save product failed: %w", err)
    }
    
    return s.toDTO(product), nil
}

// OnShelf 商品上架
func (s *ProductService) OnShelf(ctx context.Context, skuID int64) error {
    // Step 1: 查询聚合根
    product, err := s.repo.FindBySKUID(ctx, domain.NewSKU_ID(skuID))
    if err != nil {
        return err
    }
    
    // Step 2: 执行领域逻辑(状态转换)
    if err := product.OnShelf(); err != nil {
        return err
    }
    
    // Step 3: 保存聚合根(自动发布领域事件)
    return s.repo.Update(ctx, product)
}

// UpdateBasePrice 更新基础价格
func (s *ProductService) UpdateBasePrice(ctx context.Context, skuID int64, newPrice int64) error {
    // Step 1: 查询聚合根
    product, err := s.repo.FindBySKUID(ctx, domain.NewSKU_ID(skuID))
    if err != nil {
        return err
    }
    
    // Step 2: 创建价格值对象(带校验)
    price, err := domain.NewPrice(newPrice)
    if err != nil {
        return fmt.Errorf("invalid price: %w", err)
    }
    
    // Step 3: 执行领域逻辑
    if err := product.UpdateBasePrice(price); err != nil {
        return err
    }
    
    // Step 4: 保存聚合根(自动发布PriceChangedEvent)
    return s.repo.Update(ctx, product)
}

####### 34.6.1.12.7.3 领域事件

事件名触发时机事件数据消费方Topic用途
ProductCreated商品创建成功sku_id, spu_id, title, category_id, base_priceSearch Service, Recommendationproduct-events同步到ES索引
ProductUpdated商品信息更新sku_id, changed_fieldsSearch Service, Cache Invalidationproduct-events更新ES、清缓存
ProductOnShelf商品上架sku_id, spu_id, on_shelf_timeSearch Service, Marketingproduct-events上架通知、活动关联
ProductOffShelf商品下架sku_id, reason, off_shelf_timeSearch Service, Order Serviceproduct-events从ES移除、停止接单
PriceChanged基础价格变更sku_id, old_price, new_pricePricing Service, Analyticsproduct-events重新计算售价、价格分析

事件结构定义

// ProductCreatedEvent 商品创建事件
type ProductCreatedEvent struct {
    SKUID      int64     `json:"sku_id"`
    SPUID      int64     `json:"spu_id"`
    Title      string    `json:"title"`
    CategoryID int64     `json:"category_id"`
    BasePrice  int64     `json:"base_price"`
    CreatedAt  time.Time `json:"created_at"`
}

func (e *ProductCreatedEvent) Type() string {
    return "product.created"
}

// ProductOnShelfEvent 商品上架事件
type ProductOnShelfEvent struct {
    SKUID       int64     `json:"sku_id"`
    SPUID       int64     `json:"spu_id"`
    OnShelfTime time.Time `json:"on_shelf_time"`
}

func (e *ProductOnShelfEvent) Type() string {
    return "product.on_shelf"
}

// PriceChangedEvent 价格变更事件
type PriceChangedEvent struct {
    SKUID     int64     `json:"sku_id"`
    OldPrice  int64     `json:"old_price"`
    NewPrice  int64     `json:"new_price"`
    ChangedAt time.Time `json:"changed_at"`
}

func (e *PriceChangedEvent) Type() string {
    return "product.price_changed"
}

####### 34.6.1.12.7.4 事件订阅者的分层设计 ⭐️

核心问题:DDD架构中,事件订阅者(Event Subscriber)应该放在哪一层?

这是微服务架构中的经典设计问题。不同的分层方案会影响代码的复用性、可测试性和职责清晰度。

####### 方案对比

方案A:Interface Layer(推荐)⭐️

事件订阅者是“异步接口“,与HTTP/gRPC同级。

┌─────────────────────────────────────────────────────────────┐
│              Interface Layer (接口层)                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐       │
│  │ HTTP Handler │  │ gRPC Handler │  │Event Subscriber│     │
│  │  (同步接口)   │  │  (同步接口)   │  │  (异步接口) ⭐️│     │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘       │
│         │                  │                  │               │
└─────────┼──────────────────┼──────────────────┼─────────────┘
          ↓                  ↓                  ↓
┌─────────────────────────────────────────────────────────────┐
│    同一个 Application Service (ProductService)                │
│    - GetProduct()     (查询)                                 │
│    - CreateProduct()  (命令)                                 │
│    - OnShelf()        (命令)                                 │
│    - UpdatePrice()    (命令)                                 │
└─────────────────────────────────────────────────────────────┘

方案B:Infrastructure Layer(不推荐)

Kafka Consumer直接调用Application Service。

问题:

  • ❌ 违反依赖倒置原则(Infrastructure依赖Application)
  • ❌ 职责不清晰(Infrastructure既是实现层又是入口层)
  • ❌ 难以替换消息队列(从Kafka切换到RabbitMQ需要大量修改)

推荐方案A的原因

  1. 对称性:HTTP、gRPC、Event都是外部触发源,应该同级
  2. 复用性:Application Service被所有接口复用,业务逻辑只写一次
  3. 职责清晰:Interface负责协议适配,Infrastructure负责技术实现
  4. 易于测试:可以直接测试Application Service,不依赖Kafka
  5. 易于替换:更换消息队列只需修改Infrastructure Layer

####### 完整调用链路

HTTP同步调用

Client
  ↓ HTTP Request
┌─────────────────────────────────┐
│ Interface Layer - HTTP Handler  │ ← 解析HTTP请求
│ product_handler.go              │
└──────────────┬──────────────────┘
               ↓ DTO
┌─────────────────────────────────┐
│ Application Layer               │ ← 业务编排
│ product_service.go              │
└──────────────┬──────────────────┘
               ↓ Domain Model
┌─────────────────────────────────┐
│ Domain Layer                    │ ← 业务规则
│ product.go                      │
└──────────────┬──────────────────┘
               ↓ Repository
┌─────────────────────────────────┐
│ Infrastructure Layer            │ ← 数据持久化
│ product_repository.go           │
└─────────────────────────────────┘

Kafka异步调用

Kafka Topic (supplier-product-events)
  ↓ 异步消息
┌─────────────────────────────────┐
│ Infrastructure Layer            │ ← 技术实现(Kafka连接、消息接收)
│ kafka_consumer.go               │
└──────────────┬──────────────────┘
               ↓ 消息路由
┌─────────────────────────────────┐
│ Interface Layer - Event Handler │ ← 协议适配(Kafka消息 → DTO)
│ product_event_handler.go        │
└──────────────┬──────────────────┘
               ↓ DTO
┌─────────────────────────────────┐
│ Application Layer               │ ← 业务编排(复用同一个Service)
│ product_service.go              │
└──────────────┬──────────────────┘
               ↓ Domain Model
┌─────────────────────────────────┐
│ Domain Layer                    │ ← 业务规则
│ product.go                      │
└──────────────┬──────────────────┘
               ↓ Repository
┌─────────────────────────────────┐
│ Infrastructure Layer            │ ← 数据持久化
│ product_repository.go           │
└─────────────────────────────────┘

关键差异

  • HTTP: Interface → Application
  • Event: Infrastructure → Interface → Application(多一层技术实现)

####### 目录结构调整

product-service/
├── internal/
│   ├── interfaces/                      # 接口层
│   │   ├── http/
│   │   │   └── product_handler.go       # HTTP Handler(同步)
│   │   ├── grpc/
│   │   │   ├── proto/product.proto      
│   │   │   └── product_handler.go       # gRPC Handler(同步)
+│   │   └── event/ ⭐️                    # 新增:事件接口
+│   │       └── product_event_handler.go # Event Handler(异步接口)
│   │
│   ├── application/                     
│   │   └── service/
│   │       └── product_service.go       # 应用服务(被所有接口复用)
│   │
│   ├── infrastructure/                  
│   │   ├── persistence/
│   │   │   └── product_repository.go    
-│   │   └── event/
-│   │       └── kafka_publisher.go      # 事件发布
+│   │   └── messaging/ ⭐️                # 重命名:消息中间件
+│   │       ├── kafka_producer.go       # Kafka生产者(事件发布)
+│   │       └── kafka_consumer.go       # Kafka消费者(技术实现)

####### Interface Layer - Event Handler实现

职责:协议适配(Kafka消息 → DTO → 调用Application Service)

// internal/interfaces/event/product_event_handler.go

package event

import (
    "context"
    "encoding/json"
    "fmt"

    "product-service/internal/application/dto"
    "product-service/internal/application/service"
)

// ProductEventHandler 商品事件处理器(接口层)
// 职责:适配外部事件消息 → 调用Application Service
// 与HTTP/gRPC Handler同级,是"异步接口"
type ProductEventHandler struct {
    productService *service.ProductService
}

func NewProductEventHandler(productService *service.ProductService) *ProductEventHandler {
    return &ProductEventHandler{
        productService: productService,
    }
}

// HandleMessage 统一的消息处理入口
// 由Infrastructure Layer的Kafka Consumer调用
func (h *ProductEventHandler) HandleMessage(ctx context.Context, messageType string, data []byte) error {
    fmt.Printf("🔔 [Interface Layer - Event] Received message: %s\n", messageType)

    switch messageType {
    case "supplier.product.created":
        return h.handleSupplierProductCreated(ctx, data)

    case "pricing.price_changed":
        return h.handlePriceChanged(ctx, data)

    default:
        fmt.Printf("⚠️  Unknown message type: %s\n", messageType)
        return nil
    }
}

// handleSupplierProductCreated 处理供应商商品创建事件
// 场景:供应商服务创建新商品后,通过Kafka通知商品服务同步
func (h *ProductEventHandler) handleSupplierProductCreated(ctx context.Context, data []byte) error {
    // Step 1: 反序列化Kafka消息
    var kafkaEvent struct {
        SupplierID  int64  `json:"supplier_id"`
        SupplierSKU string `json:"supplier_sku"`
        Title       string `json:"title"`
        BasePrice   int64  `json:"base_price"`
        CategoryID  int64  `json:"category_id"`
    }
    if err := json.Unmarshal(data, &kafkaEvent); err != nil {
        return fmt.Errorf("反序列化失败: %w", err)
    }

    // Step 2: Kafka消息 → DTO(协议适配)
    req := &dto.CreateProductRequest{
        SupplierID:  kafkaEvent.SupplierID,
        SupplierSKU: kafkaEvent.SupplierSKU,
        Title:       kafkaEvent.Title,
        BasePrice:   kafkaEvent.BasePrice,
        CategoryID:  kafkaEvent.CategoryID,
    }

    // Step 3: 调用应用服务(与HTTP/gRPC调用同一个方法!)
    resp, err := h.productService.CreateProduct(ctx, req)
    if err != nil {
        return fmt.Errorf("创建商品失败: %w", err)
    }

    fmt.Printf("✅ [Interface Layer - Event] Product created, SKUID=%d\n", resp.SKUID)
    return nil
}

// handlePriceChanged 处理价格变更事件
// 场景:定价服务计算出新价格后,通知商品服务更新基础价格
func (h *ProductEventHandler) handlePriceChanged(ctx context.Context, data []byte) error {
    var kafkaEvent struct {
        SKUID    int64 `json:"sku_id"`
        NewPrice int64 `json:"new_price"`
    }
    if err := json.Unmarshal(data, &kafkaEvent); err != nil {
        return fmt.Errorf("反序列化失败: %w", err)
    }

    // Kafka消息 → DTO
    req := &dto.UpdatePriceRequest{
        SKUID:    kafkaEvent.SKUID,
        NewPrice: kafkaEvent.NewPrice,
    }

    // 调用应用服务(复用业务逻辑)
    _, err := h.productService.UpdateBasePrice(ctx, req)
    if err != nil {
        return fmt.Errorf("更新价格失败: %w", err)
    }

    fmt.Printf("✅ [Interface Layer - Event] Price updated, SKUID=%d\n", kafkaEvent.SKUID)
    return nil
}

关键设计点

  1. 协议适配:将Kafka特有的消息格式转换为通用的DTO
  2. 复用Service:调用 productService.CreateProduct()(与HTTP/gRPC同一个方法)
  3. 错误处理:返回错误给Kafka Consumer,由Consumer决定重试或DLQ
  4. 日志跟踪:打印日志便于调试异步流程

####### Infrastructure Layer - Kafka Consumer实现

职责:技术实现(Kafka连接、订阅Topic、接收消息、路由到Interface Layer)

// internal/infrastructure/messaging/kafka_consumer.go

package messaging

import (
    "context"
    "fmt"
    "time"

    eventHandler "product-service/internal/interfaces/event"
    "github.com/confluentinc/confluent-kafka-go/kafka"
)

// KafkaConsumer Kafka消费者(基础设施层)
// 职责:管理Kafka连接、订阅Topic、接收消息、路由到Interface Layer
type KafkaConsumer struct {
    consumer     *kafka.Consumer
    eventHandler *eventHandler.ProductEventHandler
    topics       []string
}

func NewKafkaConsumer(eventHandler *eventHandler.ProductEventHandler) *KafkaConsumer {
    return &KafkaConsumer{
        eventHandler: eventHandler,
        topics: []string{
            "supplier-product-events",  // 供应商事件
            "pricing-events",           // 定价事件
        },
    }
}

// Start 启动消费者(阻塞)
func (c *KafkaConsumer) Start(ctx context.Context) error {
    fmt.Printf("📡 [Infrastructure - Kafka Consumer] Starting...\n")
    
    // 初始化Kafka Consumer
    consumer, err := kafka.NewConsumer(&kafka.ConfigMap{
        "bootstrap.servers": "localhost:9092",
        "group.id":          "product-service-group",
        "auto.offset.reset": "earliest",
        "enable.auto.commit": false,  // 手动提交offset(保证at-least-once)
    })
    if err != nil {
        return fmt.Errorf("创建Kafka Consumer失败: %w", err)
    }
    c.consumer = consumer

    // 订阅Topic
    if err := c.consumer.SubscribeTopics(c.topics, nil); err != nil {
        return fmt.Errorf("订阅Topic失败: %w", err)
    }
    
    fmt.Printf("📡 [Infrastructure - Kafka Consumer] Subscribed to: %v\n", c.topics)

    // 消费循环
    for {
        select {
        case <-ctx.Done():
            fmt.Println("📡 [Infrastructure - Kafka Consumer] Stopping...")
            return ctx.Err()
            
        default:
            // 读取消息(100ms超时)
            msg, err := c.consumer.ReadMessage(100 * time.Millisecond)
            if err != nil {
                continue  // 超时或临时错误,继续循环
            }

            // ⭐️ 路由消息到Interface Layer的Handler
            messageType := string(msg.Key)
            if err := c.routeMessage(ctx, messageType, msg.Value); err != nil {
                fmt.Printf("❌ [Infrastructure - Kafka Consumer] Handle error: %v\n", err)
                // 错误处理:重试或发送到DLQ (Dead Letter Queue)
            } else {
                // ⭐️ 手动提交offset(保证at-least-once)
                c.consumer.CommitMessage(msg)
            }
        }
    }
}

// routeMessage 路由消息到Interface Layer的Handler
func (c *KafkaConsumer) routeMessage(ctx context.Context, messageType string, data []byte) error {
    fmt.Printf("📬 [Infrastructure - Kafka Consumer] Routing: %s\n", messageType)

    // ⭐️ 调用Interface Layer的Handler
    if err := c.eventHandler.HandleMessage(ctx, messageType, data); err != nil {
        return fmt.Errorf("处理消息失败: %w", err)
    }

    return nil
}

// Stop 停止消费者
func (c *KafkaConsumer) Stop() error {
    if c.consumer != nil {
        return c.consumer.Close()
    }
    return nil
}

关键设计点

  1. 技术实现:负责Kafka连接、消息接收等技术细节
  2. 消息路由:根据消息类型路由到Interface Layer的Handler
  3. 手动提交Offset:保证at-least-once语义(消息处理成功后才提交)
  4. 错误处理:失败消息可以重试或发送到DLQ
  5. 优雅停机:通过Context取消消费循环

####### 依赖注入与启动

main.go

package main

import (
    "context"
    "product-service/internal/application/service"
    "product-service/internal/infrastructure/messaging"
    "product-service/internal/infrastructure/persistence"
    eventHandler "product-service/internal/interfaces/event"
    httpHandler "product-service/internal/interfaces/http"
)

func main() {
    // Infrastructure Layer - 持久化
    repo := persistence.NewProductRepository(...)

    // Infrastructure Layer - 消息发布
    eventPublisher := messaging.NewKafkaProducer()

    // Application Layer
    productService := service.NewProductService(repo, eventPublisher)

    // Interface Layer - HTTP
    handler := httpHandler.NewProductHandler(productService)

    // ⭐️ Interface Layer - Event (异步接口)
    evtHandler := eventHandler.NewProductEventHandler(productService)

    // ⭐️ Infrastructure Layer - Kafka Consumer (技术实现)
    kafkaConsumer := messaging.NewKafkaConsumer(evtHandler)

    // 启动HTTP服务器
    go startHTTPServer(handler)

    // ⭐️ 启动Kafka Consumer
    ctx := context.Background()
    if err := kafkaConsumer.Start(ctx); err != nil {
        log.Fatalf("Kafka Consumer error: %v", err)
    }
}

依赖关系

Infrastructure (KafkaConsumer)
    ↓ 调用
Interface (ProductEventHandler)
    ↓ 调用
Application (ProductService)
    ↓ 调用
Domain (Product)

✅ 依赖方向:外层 → 内层(符合依赖倒置原则)


####### 实际项目优化

1. 服务分离部署

product-service-api (处理HTTP/gRPC)
├── Deployment: 10副本
├── 职责:对外接口
└── 扩容依据:QPS

product-service-consumer (处理Kafka事件)
├── Deployment: 3副本
├── Consumer Group: product-service-group
├── 职责:消费事件
└── 扩容依据:消息堆积

共享:
├── Application Service
├── Domain Model
└── Repository

好处

  • API服务可以独立扩容(根据QPS)
  • Consumer服务可以独立扩容(根据消息堆积)
  • Consumer故障不影响API可用性

2. Outbox Pattern(保证一致性)

-- 事件表(保证事务一致性)
CREATE TABLE domain_event_outbox (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    aggregate_type VARCHAR(50) NOT NULL COMMENT '聚合类型',
    aggregate_id BIGINT NOT NULL COMMENT '聚合ID',
    event_type VARCHAR(100) NOT NULL COMMENT '事件类型',
    event_data JSON NOT NULL COMMENT '事件数据',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    published_at TIMESTAMP NULL COMMENT '发布时间',
    status ENUM('PENDING', 'PUBLISHED', 'FAILED') DEFAULT 'PENDING',
    retry_count INT DEFAULT 0,
    INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB COMMENT='领域事件发件箱';

Outbox发送器

// 定时扫描未发布的事件
func (s *OutboxSender) SendPendingEvents(ctx context.Context) error {
    events, err := s.repo.FindPendingEvents(ctx, limit)
    if err != nil {
        return err
    }

    for _, event := range events {
        // 发布到Kafka
        if err := s.kafkaProducer.Publish(ctx, event); err != nil {
            s.repo.MarkFailed(ctx, event.ID)
            continue
        }

        // 标记为已发布
        s.repo.MarkPublished(ctx, event.ID)
    }

    return nil
}

3. 事件幂等性处理

// Event Handler中的幂等性检查
func (h *ProductEventHandler) handlePriceChanged(ctx context.Context, data []byte) error {
    var event PriceChangedEvent
    json.Unmarshal(data, &event)

    // ⭐️ 检查是否已处理(根据event_id或业务维度)
    if h.isEventProcessed(ctx, event.EventID) {
        fmt.Println("⏭️  Event already processed, skipping...")
        return nil  // 幂等性:已处理,直接返回成功
    }

    // 处理业务逻辑
    if err := h.productService.UpdateBasePrice(ctx, ...); err != nil {
        return err
    }

    // 记录已处理
    h.markEventProcessed(ctx, event.EventID)

    return nil
}

// 使用Redis或DB记录已处理的事件
func (h *ProductEventHandler) isEventProcessed(ctx context.Context, eventID string) bool {
    exists, _ := h.redis.Exists(ctx, "processed:event:"+eventID).Result()
    return exists > 0
}

func (h *ProductEventHandler) markEventProcessed(ctx context.Context, eventID string) {
    // 保存24小时(TTL根据业务重试窗口决定)
    h.redis.SetEX(ctx, "processed:event:"+eventID, "1", 24*time.Hour)
}

####### 总结

事件订阅者应该放在Interface Layer,原因:

  1. 对称性:HTTP、gRPC、Event都是外部触发源,应该同级
  2. 复用性:Application Service被所有接口复用,业务逻辑只写一次
  3. 职责清晰:Interface负责协议适配,Infrastructure负责技术实现
  4. 易于测试:可以直接测试Application Service,不依赖Kafka
  5. 易于替换:更换消息队列只需修改Infrastructure Layer

完整调用链路

Kafka Topic 
  → Infrastructure (接收消息)
  → Interface (协议适配) 
  → Application (业务编排)
  → Domain (业务规则)
  → Infrastructure (持久化)

示例代码:参见 ~/Projects/system-design-architecture-examples/product-service/ 完整 Demo


34.6.1.12.8 三级缓存实现(Infrastructure Layer 详细实现)

三级缓存架构

// L1: 本地缓存(1分钟)
// 优点:延迟最低(<1ms),适合热点商品
// 缺点:容量有限,多实例不一致
localCache.Set("product:"+skuID, product, 1*time.Minute)

// L2: Redis缓存(30分钟)
// 优点:容量大,多实例共享
// 缺点:网络开销(1-5ms)
redis.Set("product:"+skuID, marshal(product), 30*time.Minute)

// L3: MySQL(源数据)
// 优点:数据权威、一致
// 缺点:延迟最高(10-50ms)
db.QueryOne("SELECT * FROM product_sku WHERE id = ?", skuID)

缓存更新策略

  1. 商品更新时:主动删除缓存(Cache Aside模式)
  2. 上下架时:删除L1+L2缓存,强制下次查询走DB
  3. 价格变更时:删除缓存 + 发布PriceChangedEvent通知Pricing Service

缓存Key设计

product:{sku_id}                    # 单个商品
product:spu:{spu_id}                # SPU下所有SKU(Hash结构)
product:category:{category_id}      # 类目商品列表(Set结构)

34.6.1.12.9 完整示例代码 ⭐️

本章节的完整代码实现(包含 DDD 四层架构、事件发布订阅、三级缓存等)详见:

示例代码路径~/Projects/system-design-architecture-examples/product-service/

这部分示例代码不是完整生产系统,而是为了帮助读者把前面的架构设计映射到工程结构中。它重点覆盖商品中心的 DDD 分层、聚合根、Repository、缓存、事件发布订阅和接口适配。前面讨论的供给运营、供应商同步、库存可售、搜索导购等能力,在真实项目中会继续扩展为更多应用服务和任务模块;示例工程先保留最小可读骨架,避免把主线淹没在实现细节里。

目录结构

~/Projects/system-design-architecture-examples/product-service/
├── README.md                               # 项目说明
├── QUICKSTART.md                           # 快速开始指南
├── EIGHT_LAYER_MODEL.md                    # 八层商品交易模型说明
├── EVENT_PATTERN.md                        # 事件模式说明
├── EVENT_SUBSCRIBER_LAYER.md               # 事件订阅者分层设计
├── RESTORE_GUIDE.md                        # 示例恢复与排查指南
├── go.mod
├── cmd/
│   └── main.go                             # 程序入口
└── internal/
    ├── domain/                             # 领域层
    │   ├── product.go                      # Product 聚合根
    │   ├── spu.go                          # SPU 实体
    │   ├── value_objects.go                # 值对象
    │   ├── events.go                       # 领域事件
    │   ├── repository.go                   # Repository 接口
    │   ├── category_capability.go          # 品类能力矩阵
    │   ├── runtime_context.go              # 八层运行时上下文
    │   ├── category_strategy.go            # 品类策略接口
    │   └── strategy/                       # Topup/GiftCard/Flight/Hotel策略
    ├── application/                        # 应用层
    │   ├── dto/
    │   │   ├── product_dto.go              # 商品 DTO
    │   │   ├── runtime_context_dto.go      # 八层上下文 DTO
    │   │   └── category_action_dto.go      # 品类动作/垂直搜索 DTO
    │   └── service/
    │       ├── product_service.go          # 商品应用服务
    │       ├── runtime_context_service.go  # 八层上下文应用服务
    │       └── category_action_service.go  # Topup校验/Flight搜索应用服务
    ├── infrastructure/                     # 基础设施层
    │   ├── cache/cache.go                  # 三级缓存
    │   ├── event/
    │   │   ├── event_handlers.go           # 领域事件处理
    │   │   └── event_publisher.go          # 事件发布抽象
    │   ├── messaging/
    │   │   ├── kafka_producer.go           # Kafka 生产者
    │   │   └── kafka_consumer.go           # Kafka 消费者
    │   └── persistence/
    │       ├── product_repository.go       # Repository 实现
    │       ├── data_object.go              # 数据对象
    │       └── capability_repository.go    # 品类能力与示例数据
    └── interfaces/                         # 接口层
        ├── http/product_handler.go         # HTTP 接口
        ├── http/runtime_context_handler.go # 八层上下文 HTTP 接口
        ├── http/category_action_handler.go # 品类动作/垂直搜索 HTTP 接口
        ├── grpc/product_handler.go         # gRPC 接口
        └── event/product_event_handler.go  # Event 接口

章节内容与示例代码的对应关系

本节设计点示例代码位置说明
DDD 分层结构internal/domaininternal/applicationinternal/infrastructureinternal/interfaces展示领域层、应用层、基础设施层、接口层的依赖方向
Product 聚合根internal/domain/product.go承载商品状态、基础价格、上下架行为和领域事件
SPU 与值对象internal/domain/spu.gointernal/domain/value_objects.go展示 SPU/SKU、价格、规格等核心模型
八层商品交易模型internal/domain/runtime_context.gointernal/domain/category_capability.go展示 Product Definition、Resource、Offer、Availability、Input Schema、Booking、Fulfillment、Refund Rule
品类差异策略internal/domain/strategy展示 Topup、Gift Card、Flight、Hotel 如何分别构建八层上下文
交易前上下文编排internal/application/service/runtime_context_service.go根据 SKU、类目和场景选择策略并组装 ProductRuntimeContext
品类动作与垂直搜索internal/application/service/category_action_service.gointernal/interfaces/http/category_action_handler.go展示 Topup 账号校验和 Flight 实时搜索如何复用品类能力,但保留独立场景接口
Repository 抽象internal/domain/repository.go在领域层定义仓储接口,避免领域模型依赖数据库实现
Repository 实现internal/infrastructure/persistence/product_repository.go负责 DO 与 Domain Model 转换、缓存回写和持久化
品类能力示例数据internal/infrastructure/persistence/capability_repository.go用内存数据模拟品类能力矩阵和商品运行时数据
缓存策略internal/infrastructure/cache/cache.go演示本地缓存、Redis 缓存和源数据查询的组合方式
HTTP/gRPC 接口internal/interfaces/httpinternal/interfaces/grpc同步接口入口,负责请求协议到应用服务 DTO 的转换
Event 接口internal/interfaces/event/product_event_handler.go异步接口入口,把外部事件转换为应用服务调用
Kafka 技术实现internal/infrastructure/messaging负责消息队列连接、消费、生产等基础设施细节
事件模式说明EVENT_PATTERN.mdEVENT_SUBSCRIBER_LAYER.md解释事件发布、订阅者分层和接口层定位

运行 Demo

cd ~/Projects/system-design-architecture-examples/product-service
go run cmd/main.go

学习要点

  1. DDD分层架构:Domain、Application、Infrastructure、Interface四层
  2. 事件订阅者分层:Interface Layer作为异步接口(推荐阅读 EVENT_SUBSCRIBER_LAYER.md
  3. 三级缓存实现:L1本地缓存 → L2 Redis → L3 MySQL
  4. 领域事件发布订阅:Domain产生事件 → Application发布 → Infrastructure投递 → Interface消费
  5. 八层商品交易模型:通过 ProductRuntimeContext 展示 Topup、Gift Card、Flight、Hotel 的商品模型差异

34.6.2 库存系统设计

二维库存模型(参考34.2.5):

// 库存策略接口
type StockStrategy interface {
    CheckStock(ctx context.Context, req *StockRequest) (*StockResponse, error)
    Reserve(ctx context.Context, req *ReserveRequest) (*ReserveResponse, error)
    Deduct(ctx context.Context, req *DeductRequest) error
    Release(ctx context.Context, reserveID string) error
}

// 策略工厂
func NewStockStrategy(managementType ManagementType) StockStrategy {
    switch managementType {
    case Realtime:
        return &RealtimeStockStrategy{}  // 机票、酒店
    case Pooled:
        return &PooledStockStrategy{}    // 优惠券
    case Unlimited:
        return &UnlimitedStockStrategy{} // 充值
    }
}

预占机制

// Redis Lua脚本(原子预占)
const reserveScript = `
local stock_key = KEYS[1]
local reserve_key = KEYS[2]
local qty = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])

local stock = tonumber(redis.call('GET', stock_key) or 0)
if stock >= qty then
    redis.call('DECRBY', stock_key, qty)
    redis.call('SET', reserve_key, qty, 'EX', ttl)
    return 1
else
    return 0
end
`

func (r *StockRepo) Reserve(ctx context.Context, skuID int64, qty int, ttl time.Duration) (string, error) {
    reserveID := generateReserveID()
    stockKey := fmt.Sprintf("stock:%d", skuID)
    reserveKey := fmt.Sprintf("reserve:%s", reserveID)
    
    result, err := r.redis.Eval(ctx, reserveScript, 
        []string{stockKey, reserveKey}, 
        qty, int(ttl.Seconds())).Result()
    
    if result == int64(1) {
        return reserveID, nil
    }
    return "", errors.New("库存不足")
}

34.6.3 订单系统设计

状态机

type OrderStatus string

const (
    StatusCreated          OrderStatus = "CREATED"           // 已创建
    StatusPendingPayment   OrderStatus = "PENDING_PAYMENT"   // 待支付
    StatusPaid             OrderStatus = "PAID"              // 已支付
    StatusFulfilling       OrderStatus = "FULFILLING"        // 履约中
    StatusFulfilled        OrderStatus = "FULFILLED"         // 已履约
    StatusCanceled         OrderStatus = "CANCELED"          // 已取消
    StatusRefunded         OrderStatus = "REFUNDED"          // 已退款
)

// 状态转换规则
var transitions = map[OrderStatus][]OrderStatus{
    StatusCreated:        {StatusPendingPayment, StatusCanceled},
    StatusPendingPayment: {StatusPaid, StatusCanceled},
    StatusPaid:           {StatusFulfilling, StatusRefunded},
    StatusFulfilling:     {StatusFulfilled, StatusRefunded},
    StatusFulfilled:      {StatusRefunded},  // 已履约可申请退款
}

func (o *Order) TransitionTo(newStatus OrderStatus) error {
    allowed, ok := transitions[o.Status]
    if !ok || !contains(allowed, newStatus) {
        return fmt.Errorf("不允许从 %s 转换到 %s", o.Status, newStatus)
    }
    o.Status = newStatus
    return nil
}

分库分表(参考ADR-007):

• 分库:按 user_id % 8(用户维度查询最频繁)
• 分表:按 create_time 分表(按月归档,64表)
• 路由表:order_route(order_id → db_index, table_index)

34.6.4 支付系统设计

支付流程

// Step 1: 创建支付单
func (s *PaymentService) CreatePayment(ctx context.Context, orderID int64, amount int64) (*Payment, error) {
    payment := &Payment{
        ID:      generatePaymentID(),
        OrderID: orderID,
        Amount:  amount,
        Status:  PaymentStatusCreated,
    }
    s.repo.Save(ctx, payment)
    return payment, nil
}

// Step 2: 调用支付渠道(支付宝/微信)
func (s *PaymentService) Pay(ctx context.Context, paymentID int64, channel string) (*PayURL, error) {
    gateway := s.gatewayFactory.Get(channel)
    payURL, err := gateway.CreateOrder(ctx, payment)
    return payURL, err
}

// Step 3: 接收支付回调(幂等处理)
func (s *PaymentService) HandleCallback(ctx context.Context, callbackData *CallbackData) error {
    // 幂等性检查
    payment, err := s.repo.GetByPaymentID(ctx, callbackData.PaymentID)
    if payment.Status == PaymentStatusPaid {
        return nil  // 已处理,幂等返回
    }
    
    // 验签
    if !s.verifySign(callbackData) {
        return errors.New("签名验证失败")
    }
    
    // 更新支付状态(乐观锁)
    affected, err := s.repo.UpdateStatus(ctx, callbackData.PaymentID, 
        PaymentStatusCreated, PaymentStatusPaid)
    if affected == 0 {
        return errors.New("支付单状态已变更")
    }
    
    // 发布支付成功事件
    s.eventPublisher.Publish(ctx, &PaymentPaidEvent{
        OrderID:   payment.OrderID,
        PaymentID: payment.ID,
        Amount:    payment.Amount,
    })
    
    return nil
}

对账流程

// 每小时对账任务
func (s *PaymentService) ReconcileHourly(ctx context.Context, hour time.Time) error {
    // Step 1: 获取本地支付记录
    localPayments, _ := s.repo.GetByHour(ctx, hour)
    
    // Step 2: 获取支付渠道对账单
    remotePayments, _ := s.gatewayClient.DownloadBill(ctx, hour)
    
    // Step 3: 比对差异
    diff := s.compare(localPayments, remotePayments)
    
    // Step 4: 处理差异
    for _, d := range diff {
        if d.Type == Missing {
            // 本地有,渠道无 → 可能是渠道延迟
            s.alertService.Alert("支付对账差异", d)
        } else if d.Type == Extra {
            // 本地无,渠道有 → 可能是回调丢失
            s.补单处理(d)
        }
    }
    
    return nil
}

34.6.5 供应商集成设计

适配器模式

// 供应商接口(统一抽象)
type SupplierAdapter interface {
    QueryStock(ctx context.Context, req *StockQueryRequest) (*StockQueryResponse, error)
    ReserveStock(ctx context.Context, req *ReserveRequest) (*ReserveResponse, error)
    CreateOrder(ctx context.Context, req *CreateOrderRequest) (*CreateOrderResponse, error)
    QueryOrderStatus(ctx context.Context, orderID string) (*OrderStatus, error)
}

// 机票供应商适配器
type FlightSupplierAdapter struct {
    client *FlightSupplierClient
    config *Config
}

func (a *FlightSupplierAdapter) QueryStock(ctx context.Context, req *StockQueryRequest) (*StockQueryResponse, error) {
    // Step 1: 参数转换(平台模型 → 供应商模型)
    supplierReq := a.transformRequest(req)
    
    // Step 2: 调用供应商API(熔断保护)
    supplierResp, err := a.client.QueryAvailability(ctx, supplierReq)
    if err != nil {
        return nil, fmt.Errorf("供应商调用失败: %w", err)
    }
    
    // Step 3: 响应转换(供应商模型 → 平台模型)
    resp := a.transformResponse(supplierResp)
    return resp, nil
}

熔断机制

import "github.com/sony/gobreaker"

func NewSupplierClientWithCircuitBreaker(client *http.Client) *SupplierClient {
    cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
        Name:        "SupplierAPI",
        MaxRequests: 3,
        Interval:    10 * time.Second,
        Timeout:     30 * time.Second,
        ReadyToTrip: func(counts gobreaker.Counts) bool {
            failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
            return counts.Requests >= 3 && failureRatio >= 0.5
        },
        OnStateChange: func(name string, from, to gobreaker.State) {
            log.Printf("熔断器 %s 状态变更: %s -> %s", name, from, to)
        },
    })
    
    return &SupplierClient{
        client: client,
        cb:     cb,
    }
}

func (c *SupplierClient) QueryStock(ctx context.Context, req *Request) (*Response, error) {
    result, err := c.cb.Execute(func() (interface{}, error) {
        return c.client.Do(buildHTTPRequest(req))
    })
    if err != nil {
        return nil, err
    }
    return parseResponse(result), nil
}

34.7 完整业务链路(系统集成与数据流)

从子系统到完整链路:前面章节展示了各个子系统的内部设计(商品中心、库存、订单、支付、供应商集成),本章展示这些子系统如何协作,形成端到端的业务链路。

两条关键链路

  • B端链路(供应商 → 运营 → 平台):商品生命周期管理,决定“商品如何进入、如何管理、如何退出“
  • C端链路(用户 → 交易 → 履约):交易流完整路径,决定“用户如何发现、如何下单、如何完成支付“

集成视角的关键点

  • 数据流转:跨系统的数据传递(事件驱动、同步调用、异步任务)
  • 状态同步:多系统间的状态一致性(商品状态、库存状态、订单状态)
  • 异常处理:跨系统的容错与补偿(Saga、重试、降级)

34.7.1 B端商品生命周期完整链路

B端商品生命周期是平台运营的核心能力,决定了“商品如何进入、如何管理、如何退出“。本节展示从商品录入到下架归档的完整链路,涵盖供应商、运营、系统三方协作。

完整生命周期(7个阶段)

阶段1:商品录入(手动/批量/API)
   ↓ 录入成功率 > 95%
阶段2:审核发布(人工/自动)
   ↓ 审核通过率 > 85%
阶段3:供应商同步(实时/定时)
   ↓ 同步成功率 > 98%
阶段4:库存管理(同步/监控/对账)
   ↓ 库存准确率 > 99.5%
阶段5:日常维护(单品/批量编辑)
   ↓ 编辑成功率 > 99%
阶段6:促销配置(活动关联/价格设置)
   ↓ 生效准时率 > 99.9%
阶段7:下架归档(临时/永久)
   ↓ 归档成功率 > 99%

与C端链路的对比

维度B端链路C端链路
阶段数量7个阶段5个阶段
时间跨度数天到数月(商品生命周期)数分钟(单次购物流程)
参与角色供应商、运营、系统用户、系统
核心关注数据准确性、流程合规性用户体验、转化率
关键技术幂等性、状态机、异步任务聚合编排、快照、Saga

阶段1:商品录入

业务场景

  • 手动录入:运营人员通过后台表单录入新商品(小批量、高质量)
  • 批量导入:商家/运营通过Excel批量导入(大促前、品类扩展)
  • API推送:供应商通过OpenAPI实时推送新品(自动化、规模化)

技术难点

  • 幂等性保证:防止重复提交(网络超时、用户重试)
  • 异步解耦:批量导入通过异步任务处理,避免阻塞用户
  • 数据校验:必填字段、格式校验、业务规则校验

核心设计

// 上架任务状态机
type ListingStatus string

const (
    ListingDraft      ListingStatus = "DRAFT"       // 草稿
    ListingPending    ListingStatus = "PENDING"     // 待审核
    ListingApproved   ListingStatus = "APPROVED"    // 审核通过
    ListingRejected   ListingStatus = "REJECTED"    // 审核驳回
    ListingPublished  ListingStatus = "PUBLISHED"   // 已发布
)

// 上架任务
type ListingTask struct {
    TaskCode    string        // 幂等性标识
    ItemInfo    ItemInfo      // 商品信息
    SupplierID  int64         // 供应商ID
    Status      ListingStatus
    ReviewerID  int64         // 审核人
    RejectReason string       // 驳回原因
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

// 创建上架任务(幂等性保证)
func (s *ListingService) CreateListingTask(ctx context.Context, req *ListingRequest) (*ListingTask, error) {
    // Step 1: 生成幂等性标识符
    taskCode := s.generateTaskCode(req)
    
    // Step 2: FirstOrCreate(幂等性)
    task := &ListingTask{
        TaskCode:   taskCode,
        ItemInfo:   req.ItemInfo,
        SupplierID: req.SupplierID,
        Status:     ListingDraft,
    }
    
    result := s.db.Where("task_code = ?", taskCode).FirstOrCreate(task)
    if result.RowsAffected > 0 {
        // 首次创建,发布事件
        s.eventPublisher.Publish(ctx, &ListingTaskCreatedEvent{
            TaskCode: taskCode,
            ItemInfo: req.ItemInfo,
        })
    }
    
    return task, nil
}

// 提交审核
func (s *ListingService) SubmitForReview(ctx context.Context, taskCode string) error {
    // 状态转换:DRAFT → PENDING
    return s.updateStatus(ctx, taskCode, ListingDraft, ListingPending)
}

// 审核通过
func (s *ListingService) Approve(ctx context.Context, taskCode string, reviewerID int64) error {
    // Step 1: 状态转换:PENDING → APPROVED
    if err := s.updateStatus(ctx, taskCode, ListingPending, ListingApproved); err != nil {
        return err
    }
    
    // Step 2: 创建商品记录(写入商品中心)
    task, _ := s.getTask(ctx, taskCode)
    itemID, err := s.productCenter.CreateProduct(ctx, &CreateProductRequest{
        ItemInfo:   task.ItemInfo,
        SupplierID: task.SupplierID,
    })
    if err != nil {
        return fmt.Errorf("create product failed: %w", err)
    }
    
    // Step 3: 初始化库存(调用库存服务)
    s.inventoryClient.InitStock(ctx, itemID, task.ItemInfo.InitStock)
    
    // Step 4: 初始化价格(调用计价服务)
    s.pricingClient.InitPrice(ctx, itemID, task.ItemInfo.BasePrice)
    
    // Step 5: 更新搜索索引(异步)
    s.eventPublisher.Publish(ctx, &ProductCreatedEvent{
        ItemID:     itemID,
        ItemInfo:   task.ItemInfo,
        SupplierID: task.SupplierID,
    })
    
    return nil
}

批量导入

// 批量导入服务
type BatchImportService struct {
    taskRepo     TaskRepository
    taskQueue    TaskQueue
    validator    ItemValidator
    fileParser   FileParser
}

// 批量导入(异步)
func (s *BatchImportService) BatchImport(ctx context.Context, file io.Reader, operatorID int64) (*BatchImportTask, error) {
    // Step 1: 解析文件(支持Excel/CSV)
    items, parseErr := s.fileParser.Parse(file)
    if parseErr != nil {
        return nil, fmt.Errorf("文件解析失败: %w", parseErr)
    }
    
    // Step 2: 数据校验(预检)
    validItems := make([]*ItemInfo, 0, len(items))
    invalidItems := make([]*ValidationError, 0)
    
    for _, item := range items {
        if err := s.validator.Validate(item); err != nil {
            invalidItems = append(invalidItems, &ValidationError{
                Item:  item,
                Error: err.Error(),
            })
        } else {
            validItems = append(validItems, item)
        }
    }
    
    // Step 3: 创建批量任务
    batchTask := &BatchImportTask{
        TaskID:       generateTaskID(),
        TotalCount:   len(items),
        ValidCount:   len(validItems),
        InvalidCount: len(invalidItems),
        Status:       "PENDING",
        OperatorID:   operatorID,
        CreatedAt:    time.Now(),
    }
    
    if err := s.taskRepo.Save(ctx, batchTask); err != nil {
        return nil, err
    }
    
    // Step 4: 发送到任务队列(异步处理)
    s.taskQueue.Publish(ctx, &BatchImportEvent{
        TaskID:       batchTask.TaskID,
        Items:        validItems,
        InvalidItems: invalidItems,
    })
    
    return batchTask, nil
}

// 批量任务处理(Consumer)
func (s *BatchImportService) ProcessBatchTask(ctx context.Context, event *BatchImportEvent) error {
    successCount := 0
    failedItems := make([]*ImportFailure, 0)
    
    // 逐条处理(控制并发度)
    semaphore := make(chan struct{}, 10) // 限制10并发
    var wg sync.WaitGroup
    var mu sync.Mutex
    
    for _, item := range event.Items {
        wg.Add(1)
        semaphore <- struct{}{}
        
        go func(item *ItemInfo) {
            defer wg.Done()
            defer func() { <-semaphore }()
            
            // 创建上架任务
            if _, err := s.listingService.CreateListingTask(ctx, &ListingRequest{
                ItemInfo:   item,
                SupplierID: item.SupplierID,
            }); err != nil {
                mu.Lock()
                failedItems = append(failedItems, &ImportFailure{
                    Item:  item,
                    Error: err.Error(),
                })
                mu.Unlock()
            } else {
                mu.Lock()
                successCount++
                mu.Unlock()
            }
        }(item)
    }
    
    wg.Wait()
    
    // 更新任务状态
    s.taskRepo.Update(ctx, event.TaskID, &BatchImportResult{
        Status:       "COMPLETED",
        SuccessCount: successCount,
        FailedCount:  len(failedItems),
        FailedItems:  failedItems,
        CompletedAt:  time.Now(),
    })
    
    return nil
}

API推送

// OpenAPI Service(供应商接口)
type OpenAPIService struct {
    listingService *ListingService
    rateLimiter    RateLimiter      // 限流器
    authService    AuthService      // 鉴权
}

// API推送商品
func (s *OpenAPIService) PushProduct(ctx context.Context, req *PushProductRequest) (*PushProductResponse, error) {
    // Step 1: 鉴权(API Key + Signature)
    supplierID, err := s.authService.Authenticate(ctx, req.APIKey, req.Signature)
    if err != nil {
        return nil, fmt.Errorf("鉴权失败: %w", err)
    }
    
    // Step 2: 限流(防止刷接口)
    if !s.rateLimiter.Allow(supplierID) {
        return nil, fmt.Errorf("请求过于频繁,请稍后重试")
    }
    
    // Step 3: 参数校验
    if err := s.validatePushRequest(req); err != nil {
        return nil, fmt.Errorf("参数校验失败: %w", err)
    }
    
    // Step 4: 创建上架任务(幂等性)
    task, err := s.listingService.CreateListingTask(ctx, &ListingRequest{
        ItemInfo:   req.ItemInfo,
        SupplierID: supplierID,
    })
    if err != nil {
        return nil, fmt.Errorf("创建任务失败: %w", err)
    }
    
    // Step 5: 自动提交审核(API推送默认进入审核)
    if err := s.listingService.SubmitForReview(ctx, task.TaskCode); err != nil {
        return nil, err
    }
    
    return &PushProductResponse{
        TaskCode: task.TaskCode,
        Status:   string(task.Status),
        Message:  "商品已提交审核,预计1-3小时内完成",
    }, nil
}

监控指标

指标目标值监控维度
录入成功率> 95%按录入方式(手动/批量/API)
批量导入耗时< 10s/千条按文件大小
API响应时间P99 < 500ms按供应商
幂等性命中率< 5%按操作类型

阶段2:审核发布

业务场景

  • 人工审核:高风险商品(特定类目、新供应商)需人工审核
  • 自动审核:低风险商品通过规则引擎自动审核通过
  • 审核驳回:不合规商品驳回并通知修改

技术难点

  • 规则引擎:多维度规则组合(合规性、完整性、准确性)
  • 审核SLA:自动审核秒级响应,人工审核小时级
  • 审核日志:完整记录审核决策,支持溯源

自动审核规则引擎

// 审核引擎
type ReviewEngine struct {
    rules []ReviewRule
}

// 审核规则接口
type ReviewRule interface {
    Check(ctx context.Context, task *ListingTask) *ReviewResult
}

// 自动审核
func (e *ReviewEngine) AutoReview(ctx context.Context, task *ListingTask) (*ReviewResult, error) {
    results := make([]*ReviewResult, 0, len(e.rules))
    
    // 执行所有规则
    for _, rule := range e.rules {
        result := rule.Check(ctx, task)
        results = append(results, result)
        
        // 任何规则拒绝则直接返回
        if result.Decision == ReviewReject {
            return result, nil
        }
    }
    
    // 所有规则通过
    return &ReviewResult{
        Decision: ReviewApprove,
        Score:    e.calculateScore(results),
    }, nil
}

// 规则1: 合规性检查
type ComplianceRule struct {
    sensitiveWords []string
    bannedCategories []int64
}

func (r *ComplianceRule) Check(ctx context.Context, task *ListingTask) *ReviewResult {
    // 违禁词检测
    for _, word := range r.sensitiveWords {
        if strings.Contains(task.ItemInfo.Title, word) || 
           strings.Contains(task.ItemInfo.Description, word) {
            return &ReviewResult{
                Decision: ReviewReject,
                Reason:   fmt.Sprintf("包含违禁词: %s", word),
            }
        }
    }
    
    // 禁售类目检测
    for _, category := range r.bannedCategories {
        if task.ItemInfo.CategoryID == category {
            return &ReviewResult{
                Decision: ReviewReject,
                Reason:   "该类目禁止销售",
            }
        }
    }
    
    return &ReviewResult{Decision: ReviewApprove}
}

// 规则2: 完整性检查
type CompletenessRule struct{}

func (r *CompletenessRule) Check(ctx context.Context, task *ListingTask) *ReviewResult {
    item := task.ItemInfo
    
    // 必填字段检查
    if item.Title == "" || item.Description == "" || item.BasePrice <= 0 {
        return &ReviewResult{
            Decision: ReviewReject,
            Reason:   "缺少必填字段",
        }
    }
    
    // 图片数量检查
    if len(item.Images) < 3 {
        return &ReviewResult{
            Decision: ReviewReject,
            Reason:   "商品图片不足3张",
        }
    }
    
    return &ReviewResult{Decision: ReviewApprove}
}

// 规则3: 准确性检查
type AccuracyRule struct{}

func (r *AccuracyRule) Check(ctx context.Context, task *ListingTask) *ReviewResult {
    item := task.ItemInfo
    
    // 价格合理性检查
    if item.BasePrice < 1 || item.BasePrice > 1000000 {
        return &ReviewResult{
            Decision: ReviewManual, // 转人工审核
            Reason:   "价格异常,需人工确认",
        }
    }
    
    // 类目匹配检查(示例:通过标题关键词)
    if !r.isCategoryMatched(item.Title, item.CategoryID) {
        return &ReviewResult{
            Decision: ReviewManual,
            Reason:   "类目与标题不匹配,需人工确认",
        }
    }
    
    return &ReviewResult{Decision: ReviewApprove}
}

审核策略

审核维度检查项风险等级处理策略
合规性违禁词检测、敏感内容自动拒绝
完整性必填字段、图片数量自动拒绝
准确性价格合理性、类目匹配转人工审核
一致性SPU/SKU关系、属性匹配自动通过

审核流程

// 审核服务
func (s *ListingService) ProcessReview(ctx context.Context, taskCode string) error {
    task, _ := s.getTask(ctx, taskCode)
    
    // Step 1: 自动审核
    autoResult, err := s.reviewEngine.AutoReview(ctx, task)
    if err != nil {
        return err
    }
    
    switch autoResult.Decision {
    case ReviewApprove:
        // 自动通过 → 直接发布
        return s.Approve(ctx, taskCode, SystemReviewerID)
        
    case ReviewReject:
        // 自动拒绝 → 驳回
        return s.Reject(ctx, taskCode, autoResult.Reason)
        
    case ReviewManual:
        // 转人工审核 → 进入审核队列
        return s.assignToReviewer(ctx, taskCode)
    }
    
    return nil
}

监控指标

指标目标值监控维度
审核通过率> 85%按供应商、按类目
自动审核占比> 70%按审核决策
人工审核SLA< 4hP99耗时
审核驳回率< 15%按驳回原因

阶段3:供应商同步

业务场景

  • 供应商定时推送商品数据(每小时/每天)
  • 供应商实时推送价格/库存变更
  • 供应商商品可能已存在,也可能不存在

核心挑战:Upsert语义

如果商品存在 → 更新
如果商品不存在 → 创建

实现方案

// 供应商同步任务
type SyncTask struct {
    SyncID       string    // 同步批次ID
    SupplierID   int64     // 供应商ID
    SupplierSkuID string   // 供应商SKU ID
    SyncData     SyncData  // 同步数据
    SyncType     string    // FULL/INCREMENTAL
    Status       string    // PENDING/SUCCESS/FAILED
}

// Upsert处理(幂等性保证)
func (s *SyncService) UpsertProduct(ctx context.Context, req *SyncRequest) error {
    // Step 1: 根据供应商SKU ID查询平台商品ID
    mapping, err := s.repo.GetMapping(ctx, req.SupplierID, req.SupplierSkuID)
    
    if err == ErrNotFound {
        // 场景1:商品不存在 → 创建(走上架流程)
        return s.createNewProduct(ctx, req)
    } else {
        // 场景2:商品存在 → 更新(走同步流程)
        return s.updateExistingProduct(ctx, mapping.ItemID, req)
    }
}

// 创建新商品(供应商同步触发的上架)
func (s *SyncService) createNewProduct(ctx context.Context, req *SyncRequest) error {
    // Step 1: 创建上架任务
    task, err := s.listingService.CreateListingTask(ctx, &ListingRequest{
        ItemInfo:   transformToItemInfo(req.SyncData),
        SupplierID: req.SupplierID,
        Source:     "SUPPLIER_SYNC",  // 标记来源
    })
    
    // Step 2: 根据供应商信用等级,决定是否需要审核
    if s.needReview(req.SupplierID) {
        // 低信用供应商:需要人工审核
        task.Status = ListingPending
    } else {
        // 高信用供应商:自动通过
        task.Status = ListingApproved
        s.listingService.Approve(ctx, task.TaskCode, SYSTEM_REVIEWER_ID)
    }
    
    return nil
}

// 更新现有商品(供应商同步)
func (s *SyncService) updateExistingProduct(ctx context.Context, itemID int64, req *SyncRequest) error {
    // Step 1: 对比差异
    existing, _ := s.productCenter.GetProduct(ctx, itemID)
    diff := s.compareDiff(existing, req.SyncData)
    
    // Step 2: 根据差异类型决定是否需要审核
    if diff.HasHighRiskChange() {
        // 高风险变更(价格变化>50%、类目变更)→ 需要审核
        return s.createReviewTask(ctx, itemID, diff)
    } else {
        // 低风险变更(库存、图片)→ 直接更新
        return s.productCenter.UpdateProduct(ctx, itemID, diff)
    }
}

// 判断供应商是否需要审核
func (s *SyncService) needReview(supplierID int64) bool {
    supplier, _ := s.supplierRepo.Get(ctx, supplierID)
    
    // 根据供应商信用等级和历史表现决定
    return supplier.CreditLevel < 3 || supplier.RejectRate > 0.1
}

差异化审核策略

变更类型变更范围审核策略理由
价格变更< 10%自动通过正常波动
价格变更10-50%需要审核防止错误
价格变更> 50%必须审核 + 告警高风险
库存变更任意自动通过实时性要求高
标题变更轻微修改自动通过低风险
类目变更任意必须审核影响搜索
图片变更任意自动通过低风险

监控指标

指标目标值监控维度
同步成功率> 98%按供应商、按同步类型
同步耗时P99 < 5s按数据大小
差异化审核命中率10-20%按变更类型
供应商数据质量错误率 < 5%按供应商

阶段4:库存管理

业务场景

  • 实时同步:热卖商品库存通过WebHook实时推送(减库存事件)
  • 定时同步:长尾商品库存通过定时任务批量拉取(每小时/每天)
  • 水位监控:库存低于阈值时触发告警,通知供应商补货
  • 日终对账:每日对账供应商库存与平台库存,发现差异自动修正

技术难点

  • 同步策略:实时 vs 定时的平衡(成本 vs 准确性)
  • 库存准确性:多方数据源(供应商、订单系统、售后退款)一致性
  • 并发控制:高并发扣减库存时的原子性保证
  • 对账修正:发现差异后的自动修正 vs 人工介入

核心设计

// 库存同步策略(策略模式)
type StockSyncStrategy interface {
    Sync(ctx context.Context, skuID int64) (*SyncResult, error)
}

// 实时同步策略(高价值商品)
type RealtimeStockSyncStrategy struct {
    supplierClient SupplierClient
    inventoryRepo  InventoryRepository
    cache          *redis.Client
}

func (s *RealtimeStockSyncStrategy) Sync(ctx context.Context, skuID int64) (*SyncResult, error) {
    // Step 1: 调用供应商API实时查询库存
    supplierStock, err := s.supplierClient.GetStock(ctx, skuID)
    if err != nil {
        return nil, fmt.Errorf("供应商库存查询失败: %w", err)
    }
    
    // Step 2: 更新库存(数据库 + 缓存)
    if err := s.inventoryRepo.Update(ctx, skuID, supplierStock); err != nil {
        return nil, err
    }
    
    // Step 3: 更新缓存(防止穿透)
    s.cache.Set(ctx, fmt.Sprintf("stock:%d", skuID), supplierStock, 5*time.Minute)
    
    return &SyncResult{
        SKUID:         skuID,
        OldStock:      0, // 旧库存
        NewStock:      supplierStock,
        SyncTime:      time.Now(),
        SyncType:      "REALTIME",
    }, nil
}

// 定时同步策略(长尾商品)
type ScheduledStockSyncStrategy struct {
    supplierClient SupplierClient
    inventoryRepo  InventoryRepository
}

func (s *ScheduledStockSyncStrategy) Sync(ctx context.Context, skuID int64) (*SyncResult, error) {
    // Step 1: 批量拉取供应商库存(减少API调用)
    supplierStocks, err := s.supplierClient.BatchGetStock(ctx, []int64{skuID})
    if err != nil {
        return nil, err
    }
    
    // Step 2: 批量更新数据库
    updates := make(map[int64]int32)
    for _, stock := range supplierStocks {
        updates[stock.SKUID] = stock.Quantity
    }
    
    if err := s.inventoryRepo.BatchUpdate(ctx, updates); err != nil {
        return nil, err
    }
    
    return &SyncResult{
        SKUID:    skuID,
        NewStock: supplierStocks[skuID].Quantity,
        SyncTime: time.Now(),
        SyncType: "SCHEDULED",
    }, nil
}

// 库存同步服务(根据商品分级选择策略)
type StockSyncService struct {
    realtimeStrategy  *RealtimeStockSyncStrategy
    scheduledStrategy *ScheduledStockSyncStrategy
    productRepo       ProductRepository
}

func (s *StockSyncService) SyncStock(ctx context.Context, skuID int64) error {
    // 根据商品热度选择同步策略
    product, _ := s.productRepo.Get(ctx, skuID)
    
    var strategy StockSyncStrategy
    if product.Hotness > 80 { // 热卖商品
        strategy = s.realtimeStrategy
    } else {
        strategy = s.scheduledStrategy
    }
    
    _, err := strategy.Sync(ctx, skuID)
    return err
}

库存水位监控

// 库存水位监控器
type StockWatermarkMonitor struct {
    inventoryRepo InventoryRepository
    alertService  AlertService
    supplierClient SupplierClient
}

// 检查库存水位(定时任务,每5分钟执行)
func (m *StockWatermarkMonitor) CheckWatermark(ctx context.Context, skuID int64) error {
    // Step 1: 查询当前库存
    stock, err := m.inventoryRepo.GetStock(ctx, skuID)
    if err != nil {
        return err
    }
    
    // Step 2: 计算水位线(根据历史销量)
    watermark := m.calculateWatermark(ctx, skuID)
    
    // Step 3: 库存低于水位线 → 告警
    if stock.Available < watermark {
        m.alertService.Send(ctx, &Alert{
            Level:   "WARNING",
            Type:    "LOW_STOCK",
            SKUID:   skuID,
            Message: fmt.Sprintf("库存低于水位线(当前: %d, 水位: %d)", stock.Available, watermark),
        })
        
        // Step 4: 通知供应商补货
        m.supplierClient.RequestReplenishment(ctx, &ReplenishmentRequest{
            SKUID:        skuID,
            RequestQty:   watermark * 2, // 建议补货量
            UrgencyLevel: "NORMAL",
        })
    }
    
    return nil
}

// 计算水位线(基于历史销量)
func (m *StockWatermarkMonitor) calculateWatermark(ctx context.Context, skuID int64) int32 {
    // 查询最近7天日均销量
    avgDailySales := m.inventoryRepo.GetAvgDailySales(ctx, skuID, 7)
    
    // 水位线 = 3天销量(安全库存)
    return avgDailySales * 3
}

库存对账

// 库存对账任务(每日凌晨执行)
type StockReconciliationJob struct {
    inventoryRepo  InventoryRepository
    supplierClient SupplierClient
    diffRepo       DiffRepository
}

func (j *StockReconciliationJob) Run(ctx context.Context, date time.Time) error {
    // Step 1: 批量拉取所有SKU的供应商库存
    supplierStocks, err := j.supplierClient.GetAllStocks(ctx)
    if err != nil {
        return err
    }
    
    // Step 2: 批量查询平台库存
    platformStocks, err := j.inventoryRepo.GetAllStocks(ctx)
    if err != nil {
        return err
    }
    
    // Step 3: 对比差异
    diffs := make([]*StockDiff, 0)
    for skuID, supplierQty := range supplierStocks {
        platformQty := platformStocks[skuID]
        
        if supplierQty != platformQty {
            diff := &StockDiff{
                SKUID:        skuID,
                SupplierQty:  supplierQty,
                PlatformQty:  platformQty,
                Difference:   supplierQty - platformQty,
                ReconcileDate: date,
            }
            diffs = append(diffs, diff)
        }
    }
    
    // Step 4: 记录差异
    if err := j.diffRepo.BatchSave(ctx, diffs); err != nil {
        return err
    }
    
    // Step 5: 自动修正(差异 < 10% 自动修正,> 10% 人工介入)
    for _, diff := range diffs {
        if math.Abs(float64(diff.Difference)/float64(diff.PlatformQty)) < 0.1 {
            // 小差异:自动修正
            j.inventoryRepo.Update(ctx, diff.SKUID, diff.SupplierQty)
        } else {
            // 大差异:告警 + 人工介入
            j.alertService.Send(ctx, &Alert{
                Level:   "ERROR",
                Type:    "STOCK_MISMATCH",
                SKUID:   diff.SKUID,
                Message: fmt.Sprintf("库存差异过大(供应商: %d, 平台: %d)", diff.SupplierQty, diff.PlatformQty),
            })
        }
    }
    
    return nil
}

监控指标

指标目标值监控维度
库存准确率> 99.5%按SKU、按供应商
实时同步成功率> 98%按供应商API可用性
定时同步耗时< 10min/批次按SKU数量
水位告警响应时间< 5minP99
日终对账差异率< 2%按供应商
自动修正覆盖率> 80%按差异范围

阶段5:日常维护

业务场景

  • 单品编辑(修改标题、描述、图片)
  • 批量编辑(批量调价、批量上下架)
  • 批量导入导出(Excel操作)

核心设计

// 运营编辑任务
type EditTask struct {
    TaskID      string       // 任务ID
    ItemIDs     []int64      // 商品ID列表(支持批量)
    EditType    string       // SINGLE/BATCH
    Changes     []Change     // 变更内容
    Status      string       // PENDING/EXECUTING/SUCCESS/FAILED
    Progress    int          // 进度(0-100)
    TotalCount  int          // 总数
    SuccessCount int         // 成功数
    FailedCount int          // 失败数
}

// 批量编辑(异步任务)
func (s *EditService) BatchEdit(ctx context.Context, req *BatchEditRequest) (*EditTask, error) {
    // Step 1: 创建批量编辑任务
    task := &EditTask{
        TaskID:     generateTaskID(),
        ItemIDs:    req.ItemIDs,
        EditType:   "BATCH",
        Changes:    req.Changes,
        Status:     "PENDING",
        TotalCount: len(req.ItemIDs),
    }
    s.taskRepo.Save(ctx, task)
    
    // Step 2: 发布异步任务
    s.taskQueue.Publish(ctx, &BatchEditTaskEvent{
        TaskID: task.TaskID,
    })
    
    return task, nil
}

// 批量编辑执行器(异步)
func (w *BatchEditWorker) Execute(ctx context.Context, taskID string) error {
    task, _ := w.taskRepo.Get(ctx, taskID)
    
    // 逐个处理商品
    for i, itemID := range task.ItemIDs {
        err := w.editSingleItem(ctx, itemID, task.Changes)
        
        if err == nil {
            task.SuccessCount++
        } else {
            task.FailedCount++
            log.Errorf("edit item %d failed: %v", itemID, err)
        }
        
        // 更新进度
        task.Progress = (i + 1) * 100 / task.TotalCount
        w.taskRepo.Update(ctx, task)
    }
    
    // 更新任务状态
    if task.FailedCount == 0 {
        task.Status = "SUCCESS"
    } else if task.SuccessCount == 0 {
        task.Status = "FAILED"
    } else {
        task.Status = "PARTIAL_SUCCESS"
    }
    
    return nil
}

进度追踪

// 查询任务进度
func (s *EditService) GetTaskProgress(ctx context.Context, taskID string) (*TaskProgress, error) {
    task, _ := s.taskRepo.Get(ctx, taskID)
    
    return &TaskProgress{
        TaskID:       task.TaskID,
        Status:       task.Status,
        Progress:     task.Progress,
        TotalCount:   task.TotalCount,
        SuccessCount: task.SuccessCount,
        FailedCount:  task.FailedCount,
        EstimateLeft: s.estimateTimeLeft(task),
    }, nil
}

监控指标

指标目标值监控维度
编辑成功率> 99%按编辑类型(单品/批量)
批量编辑耗时< 5s/千条按数据大小
进度更新频率每秒任务执行期间
部分成功占比< 10%按失败原因

阶段6:促销配置

业务场景

  • 活动关联:将商品关联到大促活动(618、双11)
  • 价格设置:配置活动价、满减、折扣券
  • 定时生效:活动开始时自动生效,结束时自动失效
  • 价格验证:确保活动价 < 原价,防止虚假促销

技术难点

  • 定时生效:活动开始/结束时间精确到秒,需要定时任务支持
  • 价格一致性:促销价变更后需同步到商品中心、搜索、缓存
  • 并发控制:大促开始时大量商品同时生效,避免雪崩

核心设计

// 促销配置服务
type PromotionConfigService struct {
    productRepo    ProductRepository
    pricingClient  PricingClient
    promotionRepo  PromotionRepository
    cache          *redis.Client
}

// 配置促销(运营人员)
func (s *PromotionConfigService) ConfigPromotion(ctx context.Context, req *ConfigPromotionRequest) error {
    // Step 1: 参数校验
    if err := s.validatePromotionConfig(req); err != nil {
        return fmt.Errorf("配置校验失败: %w", err)
    }
    
    // Step 2: 价格验证(活动价 < 原价)
    product, _ := s.productRepo.Get(ctx, req.SKUID)
    if req.PromotionPrice >= product.BasePrice {
        return fmt.Errorf("促销价必须低于原价")
    }
    
    // Step 3: 创建促销配置
    promotionConfig := &PromotionConfig{
        ConfigID:       generateConfigID(),
        SKUID:          req.SKUID,
        ActivityID:     req.ActivityID,
        PromotionPrice: req.PromotionPrice,
        PromotionType:  req.PromotionType, // DISCOUNT/COUPON/FULL_REDUCTION
        StartTime:      req.StartTime,
        EndTime:        req.EndTime,
        Status:         "PENDING", // 待生效
        CreatedBy:      req.OperatorID,
    }
    
    if err := s.promotionRepo.Save(ctx, promotionConfig); err != nil {
        return err
    }
    
    // Step 4: 注册定时任务(生效/失效)
    s.scheduleActivation(ctx, promotionConfig)
    s.scheduleDeactivation(ctx, promotionConfig)
    
    return nil
}

// 批量配置促销(大促场景)
func (s *PromotionConfigService) BatchConfigPromotion(ctx context.Context, req *BatchConfigRequest) (*BatchConfigTask, error) {
    // Step 1: 创建批量任务
    task := &BatchConfigTask{
        TaskID:     generateTaskID(),
        ActivityID: req.ActivityID,
        TotalCount: len(req.Configs),
        Status:     "PENDING",
    }
    
    s.taskRepo.Save(ctx, task)
    
    // Step 2: 异步处理
    s.taskQueue.Publish(ctx, &BatchConfigEvent{
        TaskID:  task.TaskID,
        Configs: req.Configs,
    })
    
    return task, nil
}

// 定时任务:促销生效
type PromotionActivationJob struct {
    promotionRepo  PromotionRepository
    pricingClient  PricingClient
    searchClient   SearchClient
    cache          *redis.Client
}

func (j *PromotionActivationJob) Run(ctx context.Context) {
    // Step 1: 查询即将生效的促销(未来5分钟)
    now := time.Now()
    upcoming := j.promotionRepo.FindUpcoming(ctx, now, now.Add(5*time.Minute))
    
    // Step 2: 逐个生效
    for _, config := range upcoming {
        if time.Now().After(config.StartTime) {
            j.activatePromotion(ctx, config)
        }
    }
}

func (j *PromotionActivationJob) activatePromotion(ctx context.Context, config *PromotionConfig) error {
    // Step 1: 更新价格服务(促销价生效)
    if err := j.pricingClient.UpdatePromotionPrice(ctx, &UpdatePriceRequest{
        SKUID:          config.SKUID,
        PromotionPrice: config.PromotionPrice,
        ValidUntil:     config.EndTime,
    }); err != nil {
        return err
    }
    
    // Step 2: 更新搜索索引(展示促销标签)
    j.searchClient.UpdatePromotionTag(ctx, config.SKUID, config.ActivityID)
    
    // Step 3: 清理缓存(强制刷新)
    j.cache.Del(ctx, fmt.Sprintf("product:%d", config.SKUID))
    j.cache.Del(ctx, fmt.Sprintf("price:%d", config.SKUID))
    
    // Step 4: 更新促销配置状态
    config.Status = "ACTIVE"
    j.promotionRepo.Update(ctx, config)
    
    // Step 5: 发布促销生效事件
    j.eventPublisher.Publish(ctx, &PromotionActivatedEvent{
        SKUID:      config.SKUID,
        ActivityID: config.ActivityID,
        ActiveTime: time.Now(),
    })
    
    return nil
}

// 定时任务:促销失效
type PromotionDeactivationJob struct {
    promotionRepo  PromotionRepository
    pricingClient  PricingClient
    searchClient   SearchClient
    cache          *redis.Client
}

func (j *PromotionDeactivationJob) Run(ctx context.Context) {
    // 查询已过期的促销
    expired := j.promotionRepo.FindExpired(ctx, time.Now())
    
    for _, config := range expired {
        j.deactivatePromotion(ctx, config)
    }
}

func (j *PromotionDeactivationJob) deactivatePromotion(ctx context.Context, config *PromotionConfig) error {
    // Step 1: 恢复原价
    j.pricingClient.RestoreOriginalPrice(ctx, config.SKUID)
    
    // Step 2: 移除促销标签
    j.searchClient.RemovePromotionTag(ctx, config.SKUID)
    
    // Step 3: 清理缓存
    j.cache.Del(ctx, fmt.Sprintf("product:%d", config.SKUID))
    j.cache.Del(ctx, fmt.Sprintf("price:%d", config.SKUID))
    
    // Step 4: 更新状态
    config.Status = "EXPIRED"
    j.promotionRepo.Update(ctx, config)
    
    return nil
}

价格验证策略

// 价格验证器
type PriceValidator struct {
    productRepo ProductRepository
}

func (v *PriceValidator) Validate(ctx context.Context, req *ConfigPromotionRequest) error {
    product, _ := v.productRepo.Get(ctx, req.SKUID)
    
    // 规则1: 促销价 < 原价
    if req.PromotionPrice >= product.BasePrice {
        return fmt.Errorf("促销价必须低于原价")
    }
    
    // 规则2: 折扣不能过低(防止价格战)
    discount := float64(product.BasePrice-req.PromotionPrice) / float64(product.BasePrice)
    if discount > 0.7 {
        return fmt.Errorf("折扣过大(> 70%%),需审批")
    }
    
    // 规则3: 价格必须为整数(避免定价错误)
    if req.PromotionPrice%100 != 0 {
        return fmt.Errorf("价格必须为整数(单位:分)")
    }
    
    return nil
}

监控指标

指标目标值监控维度
生效准时率> 99.9%按活动、按SKU
失效准时率> 99.9%按活动、按SKU
价格一致性100%商品中心、搜索、缓存
配置错误率< 1%按配置类型

阶段7:下架归档

业务场景

  • 临时下架:商品缺货、质量问题临时下架,后续可恢复
  • 永久下架:商品停产、违规下架,不可恢复
  • 订单检查:下架前检查是否有进行中的订单,避免影响用户
  • 历史归档:永久下架商品归档到历史库,释放主库空间

技术难点

  • 订单安全:下架前必须检查订单状态,防止影响履约
  • 数据一致性:下架需同步到商品中心、搜索、库存、价格
  • 归档策略:历史数据归档到冷存储,降低成本

核心设计

// 下架服务
type OffShelfService struct {
    productRepo   ProductRepository
    orderRepo     OrderRepository
    searchClient  SearchClient
    inventoryClient InventoryClient
    pricingClient PricingClient
}

// 下架商品
func (s *OffShelfService) OffShelf(ctx context.Context, req *OffShelfRequest) error {
    // Step 1: 检查进行中的订单
    activeOrders, err := s.orderRepo.FindActiveOrdersBySKU(ctx, req.SKUID)
    if err != nil {
        return err
    }
    
    if len(activeOrders) > 0 && req.OffShelfType == "PERMANENT" {
        return fmt.Errorf("存在进行中的订单(%d个),暂不能永久下架", len(activeOrders))
    }
    
    // Step 2: 更新商品状态
    product, _ := s.productRepo.Get(ctx, req.SKUID)
    
    if req.OffShelfType == "TEMPORARY" {
        // 临时下架 → OFF_SHELF
        product.Status = "OFF_SHELF"
        product.OffShelfReason = req.Reason
        product.OffShelfTime = time.Now()
    } else {
        // 永久下架 → ARCHIVED
        product.Status = "ARCHIVED"
        product.ArchivedReason = req.Reason
        product.ArchivedTime = time.Now()
    }
    
    s.productRepo.Update(ctx, product)
    
    // Step 3: 从搜索索引中移除
    s.searchClient.RemoveProduct(ctx, req.SKUID)
    
    // Step 4: 冻结库存(防止误售)
    s.inventoryClient.FreezeStock(ctx, req.SKUID)
    
    // Step 5: 移除促销配置
    s.pricingClient.RemovePromotions(ctx, req.SKUID)
    
    // Step 6: 发布下架事件
    s.eventPublisher.Publish(ctx, &ProductOffShelfEvent{
        SKUID:         req.SKUID,
        OffShelfType:  req.OffShelfType,
        Reason:        req.Reason,
        OffShelfTime:  time.Now(),
    })
    
    return nil
}

// 恢复上架(仅临时下架可恢复)
func (s *OffShelfService) RestoreOnShelf(ctx context.Context, skuID int64) error {
    product, _ := s.productRepo.Get(ctx, skuID)
    
    // 只有临时下架可恢复
    if product.Status != "OFF_SHELF" {
        return fmt.Errorf("商品状态不支持恢复(当前状态: %s)", product.Status)
    }
    
    // Step 1: 恢复商品状态
    product.Status = "ON_SHELF"
    s.productRepo.Update(ctx, product)
    
    // Step 2: 恢复搜索索引
    s.searchClient.AddProduct(ctx, product)
    
    // Step 3: 解冻库存
    s.inventoryClient.UnfreezeStock(ctx, skuID)
    
    return nil
}

// 归档服务(永久下架商品)
type ArchiveService struct {
    productRepo     ProductRepository
    archiveRepo     ArchiveRepository
    orderRepo       OrderRepository
    inventoryClient InventoryClient
}

// 归档商品(异步任务,每日凌晨执行)
func (s *ArchiveService) ArchiveProduct(ctx context.Context, skuID int64) error {
    product, _ := s.productRepo.Get(ctx, skuID)
    
    // 只归档永久下架的商品
    if product.Status != "ARCHIVED" {
        return nil
    }
    
    // Step 1: 检查是否有未完成的订单(防御性检查)
    activeOrders, _ := s.orderRepo.FindActiveOrdersBySKU(ctx, skuID)
    if len(activeOrders) > 0 {
        return fmt.Errorf("仍有进行中的订单,暂不归档")
    }
    
    // Step 2: 归档商品数据(写入历史库)
    archiveData := &ArchivedProduct{
        SKUID:        skuID,
        ProductData:  product.ToJSON(),
        ArchivedTime: time.Now(),
    }
    s.archiveRepo.Save(ctx, archiveData)
    
    // Step 3: 归档订单数据
    historicalOrders, _ := s.orderRepo.FindAllOrdersBySKU(ctx, skuID)
    for _, order := range historicalOrders {
        s.archiveRepo.SaveOrder(ctx, &ArchivedOrder{
            OrderID:      order.OrderID,
            SKUID:        skuID,
            OrderData:    order.ToJSON(),
            ArchivedTime: time.Now(),
        })
    }
    
    // Step 4: 删除主库数据(释放空间)
    s.productRepo.Delete(ctx, skuID)
    s.inventoryClient.DeleteStock(ctx, skuID)
    
    return nil
}

监控指标

指标目标值监控维度
下架成功率> 99%按下架类型(临时/永久)
订单冲突率< 1%永久下架前的订单检查
恢复成功率100%临时下架恢复
归档耗时< 5s/商品按数据大小
归档完整性100%商品数据、订单数据

34.7.2 C端交易流完整链路

交易流是电商的核心价值链,从用户搜索到完成支付的完整路径。本节展示五个阶段的设计与集成。

与B端链路的对比

维度B端链路(34.7.1)C端链路(34.7.2)
参与方供应商、运营、系统用户、系统
时间跨度数天到数月(商品生命周期)数分钟(单次购物流程)
关键技术幂等性、状态机、异步任务聚合编排、快照、Saga
核心关注数据准确性、流程合规性用户体验、转化率

阶段1:搜索与导购

业务场景:用户搜索“iPhone 15“

系统架构

用户输入关键词
    ↓
API Gateway → Search Aggregation
    ↓
Query理解(分词、纠错、意图识别)
    ↓
Elasticsearch召回(相关性排序)
    ↓
Hydrate编排(并发调用多个服务)
    ├─ Product Service(商品信息)
    ├─ Inventory Service(库存状态)
    ├─ Pricing Service(价格计算)
    └─ Marketing Service(活动标签)
    ↓
返回搜索结果

核心代码

// 搜索聚合服务
type SearchAggregation struct {
    esClient        *elasticsearch.Client
    productClient   rpc.ProductClient
    inventoryClient rpc.InventoryClient
    pricingClient   rpc.PricingClient
    marketingClient rpc.MarketingClient
}

func (a *SearchAggregation) Search(ctx context.Context, req *SearchRequest) (*SearchResponse, error) {
    // Step 1: Query理解(分词、意图识别)
    query := a.parseQuery(req.Keyword)
    
    // Step 2: ES召回(按相关性排序)
    hits, err := a.esClient.Search(ctx, query)
    if err != nil {
        return nil, err
    }
    
    skuIDs := extractSkuIDs(hits)
    
    // Step 3: Hydrate编排(并发调用)
    var products map[int64]*Product
    var stocks map[int64]*Stock
    var prices map[int64]*Price
    var promos map[int64]*PromoInfo
    
    g, ctx := errgroup.WithContext(ctx)
    
    // 并发调用4个服务
    g.Go(func() error {
        products, _ = a.productClient.BatchGet(ctx, skuIDs)
        return nil
    })
    g.Go(func() error {
        stocks, _ = a.inventoryClient.BatchCheck(ctx, skuIDs)
        return nil
    })
    g.Go(func() error {
        priceItems := buildPriceItems(skuIDs)
        prices, _ = a.pricingClient.BatchCalculate(ctx, priceItems)
        return nil
    })
    g.Go(func() error {
        promos, _ = a.marketingClient.BatchGet(ctx, skuIDs, req.UserID)
        // 降级:Marketing故障时使用空促销
        if promos == nil {
            promos = make(map[int64]*PromoInfo)
        }
        return nil
    })
    
    g.Wait()
    
    // Step 4: 聚合结果
    return a.buildSearchResponse(hits, products, stocks, prices, promos), nil
}

性能优化

  • ES查询:P99 < 50ms
  • Hydrate并发:4个服务并发调用,总耗时 < 200ms
  • 缓存策略:热门搜索词缓存5分钟

阶段2:商品详情页(PDP)

业务场景:用户点击商品进入详情页

核心设计

// 详情页聚合服务
func (a *DetailAggregation) GetDetail(ctx context.Context, skuID int64, userID int64) (*DetailResponse, error) {
    // 并发调用5个服务
    var product *Product
    var stock *Stock
    var price *Price
    var promos []*Promotion
    var reviews []*Review
    
    g, ctx := errgroup.WithContext(ctx)
    
    g.Go(func() error {
        product, _ = a.productClient.Get(ctx, skuID)
        return nil
    })
    g.Go(func() error {
        stock, _ = a.inventoryClient.Check(ctx, skuID)
        return nil
    })
    g.Go(func() error {
        price, _ = a.pricingClient.Calculate(ctx, skuID, userID)
        return nil
    })
    g.Go(func() error {
        promos, _ = a.marketingClient.GetPromotions(ctx, skuID, userID)
        return nil
    })
    g.Go(func() error {
        reviews, _ = a.reviewClient.GetTopReviews(ctx, skuID, 5)
        return nil
    })
    
    g.Wait()
    
    // 生成快照(用于后续试算)
    snapshot := a.generateSnapshot(product, price, promos)
    
    return &DetailResponse{
        Product:   product,
        Stock:     stock,
        Price:     price,
        Promos:    promos,
        Reviews:   reviews,
        Snapshot:  snapshot,  // 快照ID,5分钟有效
    }, nil
}

阶段3:购物车

业务场景:用户加购商品

未登录加购

// 未登录用户(Cookie存储)
func (c *CartService) AddToCartAnonymous(ctx context.Context, req *AddCartRequest) error {
    // Step 1: 获取匿名cartID(存储在Cookie)
    cartID := req.AnonymousCartID
    if cartID == "" {
        cartID = generateCartID()
    }
    
    // Step 2: 存储到Redis(TTL=7天)
    cartKey := fmt.Sprintf("cart:anon:%s", cartID)
    cartData, _ := c.redis.Get(ctx, cartKey).Result()
    
    cart := parseCart(cartData)
    cart.AddItem(req.SkuID, req.Quantity)
    
    c.redis.Set(ctx, cartKey, marshal(cart), 7*24*time.Hour)
    
    return nil
}

登录后合并

// 用户登录后合并购物车
func (c *CartService) MergeCartOnLogin(ctx context.Context, userID int64, anonymousCartID string) error {
    // Step 1: 获取匿名购物车
    anonCartKey := fmt.Sprintf("cart:anon:%s", anonymousCartID)
    anonCart, _ := c.redis.Get(ctx, anonCartKey).Result()
    
    // Step 2: 获取用户购物车
    userCartKey := fmt.Sprintf("cart:user:%d", userID)
    userCart, _ := c.redis.Get(ctx, userCartKey).Result()
    
    // Step 3: 合并(相同商品累加数量)
    merged := mergeCarts(parseCart(anonCart), parseCart(userCart))
    
    // Step 4: 保存到用户购物车
    c.redis.Set(ctx, userCartKey, marshal(merged), 0)  // 永久存储
    
    // Step 5: 删除匿名购物车
    c.redis.Del(ctx, anonCartKey)
    
    // Step 6: 异步持久化到MySQL(防止Redis丢失)
    c.eventPublisher.Publish(ctx, &CartMergedEvent{
        UserID: userID,
        Items:  merged.Items,
    })
    
    return nil
}

阶段4:结算页试算

业务场景:用户点击“去结算“

核心设计

// 结算页聚合服务
func (a *CheckoutAggregation) Calculate(ctx context.Context, req *CalculateRequest) (*CalculateResponse, error) {
    // Step 1: 判断是否使用快照(ADR-008)
    var products []*Product
    var promos []*Promotion
    
    if req.Snapshot != nil && !req.Snapshot.IsExpired() {
        // 快照未过期,使用快照数据(性能优先)
        products = req.Snapshot.Products
        promos = req.Snapshot.Promos
    } else {
        // 快照过期,实时查询
        products, _ = a.productClient.BatchGet(ctx, req.SkuIDs)
        promos, _ = a.marketingClient.GetPromotions(ctx, req.SkuIDs, req.UserID)
    }
    
    // Step 2: 实时查询库存(不能用快照)
    stocks, _ := a.inventoryClient.BatchCheck(ctx, req.SkuIDs)
    
    // Step 3: 计算价格
    prices, _ := a.pricingClient.BatchCalculate(ctx, products, promos)
    
    // Step 4: 检查可下单性
    canCheckout := a.checkCanCheckout(stocks, req.Items)
    
    return &CalculateResponse{
        Items:       buildItems(products, stocks, prices),
        TotalPrice:  calculateTotal(prices),
        CanCheckout: canCheckout,
        Warnings:    a.generateWarnings(stocks, promos),
    }, nil
}

阶段5:下单与支付

完整下单流程(Saga模式):

// 订单创建Saga(编排多个服务调用)
type CreateOrderSaga struct {
    productClient   rpc.ProductClient
    inventoryClient rpc.InventoryClient
    pricingClient   rpc.PricingClient
    marketingClient rpc.MarketingClient
    orderRepo       *OrderRepo
}

func (s *CreateOrderSaga) Execute(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    var err error
    var reserved *ReserveResult
    var couponLock *CouponLock
    
    // Step 1: 实时查询商品信息(ADR-009:不使用快照)
    products, err := s.productClient.BatchGet(ctx, req.SkuIDs)
    if err != nil {
        return nil, fmt.Errorf("query products failed: %w", err)
    }
    
    // Step 2: 实时查询营销信息
    promos, err := s.marketingClient.GetPromotions(ctx, req.SkuIDs, req.UserID)
    if err != nil {
        return nil, fmt.Errorf("query promotions failed: %w", err)
    }
    
    // Step 3: 校验营销活动有效性
    for _, promo := range promos {
        if !s.validatePromotion(promo) {
            return nil, fmt.Errorf("promotion %s expired", promo.ID)
        }
    }
    
    // Step 4: 库存预占(CAS操作)
    reserved, err = s.inventoryClient.Reserve(ctx, req.Items)
    if err != nil {
        return nil, fmt.Errorf("库存不足: %w", err)
    }
    defer func() {
        if err != nil {
            // 补偿:释放库存
            s.inventoryClient.Release(ctx, reserved.ReserveID)
        }
    }()
    
    // Step 5: 优惠券锁定
    if req.CouponCode != "" {
        couponLock, err = s.marketingClient.LockCoupon(ctx, req.CouponCode, req.UserID)
        if err != nil {
            return nil, fmt.Errorf("优惠券锁定失败: %w", err)
        }
        defer func() {
            if err != nil {
                // 补偿:释放优惠券
                s.marketingClient.UnlockCoupon(ctx, couponLock.LockID)
            }
        }()
    }
    
    // Step 6: 实时计算价格
    price, err := s.pricingClient.Calculate(ctx, products, promos)
    if err != nil {
        return nil, fmt.Errorf("价格计算失败: %w", err)
    }
    
    // Step 7: 价格校验(ADR-011)
    if req.ExpectedPrice > 0 {
        if err := s.validatePriceChange(req.ExpectedPrice, price.FinalPrice); err != nil {
            return nil, err
        }
    }
    
    // Step 8: 生成商品快照
    snapshot := s.generateProductSnapshot(products, promos, price)
    
    // Step 9: 创建订单
    order := &Order{
        OrderID:         s.generateOrderID(),
        UserID:          req.UserID,
        Items:           req.Items,
        TotalPrice:      price.FinalPrice,
        ProductSnapshot: marshal(snapshot),
        ReserveID:       reserved.ReserveID,
        CouponLockID:    couponLock.LockID,
        Status:          StatusPendingPayment,
        ExpireTime:      time.Now().Add(15 * time.Minute),
    }
    
    err = s.orderRepo.Create(ctx, order)
    if err != nil {
        return nil, fmt.Errorf("订单创建失败: %w", err)
    }
    
    // Step 10: 发布订单创建事件(异步)
    s.eventPublisher.Publish(ctx, &OrderCreatedEvent{
        OrderID: order.OrderID,
        UserID:  order.UserID,
        Items:   order.Items,
    })
    
    return order, nil
}

交易流监控

阶段关键指标目标值
搜索搜索→点击转化率> 15%
详情页详情→加购转化率> 8%
购物车加购→结算转化率> 30%
结算页结算→下单转化率> 60%
支付下单→支付转化率> 85%
整体搜索→支付转化率> 2%

34.8 DDD战术设计实践

领域模型是系统设计的核心。本节展示如何在订单域应用DDD战术模式。

聚合设计:Order聚合根

// Order聚合根
type Order struct {
    // 聚合根ID
    orderID OrderID  // 值对象
    
    // 基本信息
    userID    int64
    shopID    int64
    
    // 订单明细(实体集合)
    items []*OrderItem
    
    // 价格信息(值对象)
    pricing *OrderPricing
    
    // 状态(值对象)
    status OrderStatus
    
    // 时间戳
    createdAt time.Time
    updatedAt time.Time
    
    // 领域事件(未提交)
    domainEvents []DomainEvent
}

// 值对象:OrderID
type OrderID struct {
    value string
}

func NewOrderID() OrderID {
    return OrderID{value: generateSnowflakeID()}
}

func (id OrderID) String() string {
    return id.value
}

// 值对象:OrderPricing
type OrderPricing struct {
    subtotal       int64  // 商品总价
    discount       int64  // 折扣金额
    couponDiscount int64  // 优惠券
    payableAmount  int64  // 应付金额
}

func (p *OrderPricing) Calculate() int64 {
    return p.subtotal - p.discount - p.couponDiscount
}

// 实体:OrderItem
type OrderItem struct {
    itemID    int64
    skuID     int64
    quantity  int
    unitPrice int64
    
    // 快照
    snapshot *ItemSnapshot
}

// 聚合根方法:状态转换
func (o *Order) TransitionTo(newStatus OrderStatus) error {
    // 检查状态转换是否合法
    if !o.status.CanTransitionTo(newStatus) {
        return fmt.Errorf("不允许从 %s 转换到 %s", o.status, newStatus)
    }
    
    oldStatus := o.status
    o.status = newStatus
    o.updatedAt = time.Now()
    
    // 发布领域事件
    o.addDomainEvent(&OrderStatusChangedEvent{
        OrderID:   o.orderID,
        OldStatus: oldStatus,
        NewStatus: newStatus,
        ChangedAt: o.updatedAt,
    })
    
    return nil
}

// 聚合根方法:添加商品项
func (o *Order) AddItem(item *OrderItem) error {
    // 不变量检查:订单金额不能超过限额
    if o.calculateTotal()+item.Total() > MAX_ORDER_AMOUNT {
        return errors.New("订单金额超过限额")
    }
    
    o.items = append(o.items, item)
    
    // 发布领域事件
    o.addDomainEvent(&OrderItemAddedEvent{
        OrderID: o.orderID,
        Item:    item,
    })
    
    return nil
}

// 不变量:订单金额 = 所有商品项之和
func (o *Order) calculateTotal() int64 {
    total := int64(0)
    for _, item := range o.items {
        total += item.Total()
    }
    return total
}

// 领域事件管理
func (o *Order) addDomainEvent(event DomainEvent) {
    o.domainEvents = append(o.domainEvents, event)
}

func (o *Order) DomainEvents() []DomainEvent {
    return o.domainEvents
}

func (o *Order) ClearDomainEvents() {
    o.domainEvents = nil
}

Repository模式

// OrderRepository接口(领域层定义)
type OrderRepository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, orderID OrderID) (*Order, error)
    FindByUserID(ctx context.Context, userID int64, limit int) ([]*Order, error)
}

// OrderRepositoryImpl实现(基础设施层)
type OrderRepositoryImpl struct {
    db            *gorm.DB
    eventPublisher EventPublisher
}

func (r *OrderRepositoryImpl) Save(ctx context.Context, order *Order) error {
    // Step 1: 转换聚合根 → 数据模型
    orderDO := r.toDataObject(order)
    
    // Step 2: 保存到数据库
    err := r.db.Transaction(func(tx *gorm.DB) error {
        // 保存订单主表
        if err := tx.Create(orderDO).Error; err != nil {
            return err
        }
        
        // 保存订单明细表
        for _, item := range order.Items() {
            itemDO := r.toItemDataObject(item, orderDO.ID)
            if err := tx.Create(itemDO).Error; err != nil {
                return err
            }
        }
        
        return nil
    })
    
    if err != nil {
        return err
    }
    
    // Step 3: 发布领域事件(事务提交后)
    for _, event := range order.DomainEvents() {
        r.eventPublisher.Publish(ctx, event)
    }
    order.ClearDomainEvents()
    
    return nil
}

领域事件与Outbox模式

// Outbox表(确保事件必达)
type Outbox struct {
    ID          int64
    EventType   string
    EventData   string  // JSON
    Status      string  // PENDING/PUBLISHED/FAILED
    RetryCount  int
    CreatedAt   time.Time
}

// 发布领域事件(Outbox模式)
func (p *EventPublisher) Publish(ctx context.Context, event DomainEvent) error {
    // Step 1: 序列化事件
    eventData, _ := json.Marshal(event)
    
    // Step 2: 写入Outbox表(与业务在同一事务)
    outbox := &Outbox{
        EventType: event.Type(),
        EventData: string(eventData),
        Status:    "PENDING",
        CreatedAt: time.Now(),
    }
    
    return p.db.Create(outbox).Error
}

// Outbox轮询器(定时扫描未发布的事件)
func (w *OutboxWorker) Run() {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    
    for range ticker.C {
        // Step 1: 查询待发布事件(PENDING状态)
        var outboxes []*Outbox
        w.db.Where("status = ? AND retry_count < ?", "PENDING", 3).
            Limit(100).
            Find(&outboxes)
        
        // Step 2: 发布到Kafka
        for _, outbox := range outboxes {
            err := w.kafkaProducer.Send(outbox.EventType, outbox.EventData)
            
            if err == nil {
                // 发布成功,标记为PUBLISHED
                w.db.Model(outbox).Update("status", "PUBLISHED")
            } else {
                // 发布失败,重试计数+1
                w.db.Model(outbox).Updates(map[string]interface{}{
                    "retry_count": gorm.Expr("retry_count + 1"),
                    "status":      "FAILED",
                })
            }
        }
    }
}

34.9 架构决策记录(ADR)

本节记录系统设计过程中的关键架构决策,包括决策背景、备选方案、最终决策及理由。ADR是架构演进的重要资产,帮助团队理解「为什么这样设计」,避免重复讨论。

ADR-001: 计价中心数据输入方式

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:计价中心需要营销信息(促销规则、优惠券等)来计算最终价格,有两种方案:

  • 方案1:计价中心自己调用Marketing Service获取营销信息
  • 方案2:聚合服务获取营销信息后传递给计价中心

决策:采用方案2,由聚合服务获取营销信息后传递给计价中心。

理由

  1. 单一职责原则(SRP)

    • Pricing Service专注于价格计算逻辑(纯函数)
    • Aggregation Service负责数据编排和获取
    • 职责边界清晰,符合微服务设计原则
  2. 依赖解耦

    方案1依赖链:Aggregation → Pricing → Marketing(传递性依赖)
    方案2依赖链:Aggregation → Pricing | Marketing(平行依赖)✓
    
  3. 性能优化空间更大

    • 聚合层可以并发调用Marketing和其他服务(Product、Inventory)
    • Pricing变成纯计算,无IO等待
    • 减少网络调用层级(2层 vs 3层)
  4. 易于测试

    // 方案2:Pricing是纯函数,测试简单
    func TestCalculatePrice(t *testing.T) {
        priceItem := &PriceCalculateItem{
            SkuID:     1001,
            BasePrice: 2399.00,
            PromoInfo: &PromoInfo{DiscountRate: 0.9},  // Mock数据
        }
        result := pricingService.Calculate(priceItem)
        assert.Equal(t, 2159.10, result.FinalPrice)
    }
    
  5. 统一降级处理

    • 聚合层统一处理各服务失败(Marketing、Product、Inventory)
    • Pricing Service无感知,始终收到完整输入数据
    • 降级逻辑不混入业务计算

代码示例

// SearchOrchestrator(聚合服务)
func (o *SearchOrchestrator) Search(ctx context.Context, req *SearchRequest) (*SearchResponse, error) {
    // Step 1: 获取sku_ids(从ES)
    skuIDs, _ := o.searchClient.QuerySkuIDs(ctx, req.Keyword)
    
    // Step 2: 并发调用Product + Inventory + Marketing
    var products []*Product
    var stocks []*Stock
    var promos map[int64]*PromoInfo
    
    g, ctx := errgroup.WithContext(ctx)
    g.Go(func() error {
        products, _ = o.productClient.BatchGet(ctx, skuIDs)
        return nil
    })
    g.Go(func() error {
        stocks, _ = o.inventoryClient.BatchCheck(ctx, skuIDs)
        return nil
    })
    g.Go(func() error {
        promos, _ = o.marketingClient.BatchGet(ctx, skuIDs, req.UserID)
        // 降级:Marketing故障时使用空促销
        if promos == nil {
            promos = make(map[int64]*PromoInfo)
        }
        return nil
    })
    g.Wait()
    
    // Step 3: 调用Pricing计算价格(传入营销信息)
    priceItems := buildPriceItems(products, promos)
    prices, _ := o.pricingClient.BatchCalculate(ctx, priceItems)
    
    return buildSearchResponse(products, stocks, prices), nil
}

// PricingService(计价中心)- 纯函数,只负责计算
func (s *PricingService) Calculate(item *PriceItem) *PriceResult {
    finalPrice := item.BasePrice
    
    // 应用促销折扣(数据来自聚合层)
    if item.PromoInfo != nil {
        finalPrice = finalPrice * item.PromoInfo.DiscountRate
    }
    
    return &PriceResult{
        OriginalPrice: item.BasePrice,
        FinalPrice:    finalPrice,
        Discount:      item.BasePrice - finalPrice,
    }
}

影响范围

  • Aggregation Service:增加Marketing Service调用
  • Pricing Service:接收PromoInfo作为输入参数
  • Marketing Service:无影响

ADR-002: 库存预占时机

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:在下单流程中,库存预占的时机有两种选择:

  • 方案1:结算试算时预占(早期锁定)
  • 方案2:确认下单时预占(延迟锁定)

决策:采用方案2,在确认下单时预占库存。

理由

  1. 减少无效预占

    • 用户在试算阶段可能多次修改商品、数量、优惠券
    • 早期预占会导致大量无效锁定(用户未真正下单)
    • 试算到下单的转化率通常只有20-30%
  2. 提升库存利用率

    • 避免库存被长时间预占(用户可能犹豫、放弃)
    • 预占时长控制在15分钟内(支付超时自动释放)
  3. 降低系统压力

    • 试算接口QPS高(用户多次试算),预占会导致Redis压力大
    • 确认下单QPS相对较低,预占操作更可控
  4. 用户体验

    • 试算快速返回(不需要等待预占操作)
    • 确认下单时再预占,用户心理准备更充分

权衡

  • ✓ 优点:提升库存利用率、减少无效预占、降低系统压力
  • ✗ 缺点:确认下单时可能库存不足(需要前端提示)

降低缺点的措施

  • 试算时展示实时库存状态(“仅剩N件”)
  • 确认下单时二次校验库存,失败友好提示
  • 热门商品提前告知“库存紧张,请尽快下单“

ADR-003: 聚合服务 vs BFF

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:在API Gateway和微服务之间,是使用BFF(Backend For Frontend)还是Aggregation Service?

决策:采用Aggregation Service,而不是传统BFF。

理由

  1. 业务导向 vs 端导向

    • BFF按端划分(Web BFF、App BFF、小程序 BFF)
    • Aggregation按业务场景划分(搜索聚合、详情聚合、结算聚合)✓
    • 本系统多个端(Web、App)的业务逻辑高度一致,按端拆分会导致重复代码
  2. 代码复用

    BFF模式:
    ├─ Web BFF(搜索逻辑)
    ├─ App BFF(搜索逻辑)    ← 重复代码
    └─ 小程序 BFF(搜索逻辑) ← 重复代码
    
    Aggregation模式:✓
    ├─ Search Aggregation(Web/App/小程序共用)
    └─ Detail Aggregation(Web/App/小程序共用)
    
  3. 维护成本

    • BFF需要维护多个端的代码一致性
    • Aggregation只需维护一套业务逻辑
  4. 适配端差异的方式

    • API Gateway层处理端协议差异(HTTP、WebSocket、gRPC)
    • Aggregation返回标准数据格式,前端各端按需裁剪

适用场景

  • ✓ 多端业务逻辑高度一致(如本系统)
  • ✗ 不适用:各端业务逻辑差异大(如社交产品,Feed流算法不同)

ADR-004: 虚拟商品库存模型

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:虚拟商品(机票、充值卡、优惠券)的库存模型和实物商品差异大,应该如何设计?

决策:采用二维库存模型(ManagementType + UnitType)。

库存管理类型(ManagementType)

类型说明典型品类库存来源
实时库存强依赖供应商实时查询机票、酒店供应商API
池化库存自有库存,可超卖后补偿充值卡、优惠券平台采购
无限库存虚拟商品,无库存限制SaaS服务、数字内容

库存单位类型(UnitType)

类型说明典型品类
SKU级别每个规格独立库存充电器(颜色、规格)
批次级别按批次管理(有效期)优惠券、礼品卡
座位级别唯一标识(座位号)机票、电影票

理由

  1. 不同品类的库存特性差异极大,无法用统一模型
  2. 二维模型提供灵活性,支持策略模式动态选择
  3. 便于扩展新品类(只需添加新策略)

ADR-005: 同步 vs 异步数据流

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:下单流程中,哪些操作应该同步执行,哪些应该异步执行?

决策:采用同步+异步混合模式

同步操作(用户等待)

  1. 库存预占(必须成功,否则无法下单)
  2. 优惠券扣减(避免超发)
  3. 订单创建(生成order_id)

异步操作(Kafka事件)

  1. 库存确认扣减(预占成功后,异步确认)
  2. 搜索索引更新(销量、热度)
  3. 购物车清理
  4. 用户行为分析
  5. 消息通知(订单确认、物流更新)

理由

  1. 用户体验

    • 同步操作<500ms,用户可接受
    • 非核心操作异步化,不阻塞下单
  2. 系统解耦

    • 异步事件降低服务间强依赖
    • 消费者故障不影响下单流程
  3. 性能优化

    • 减少下单接口响应时间
    • 异步操作可批量处理(提升吞吐)
  4. 容错能力

    • 异步操作支持重试(Kafka消费者重试机制)
    • 同步操作失败可立即回滚(Saga模式)

ADR-009: 创单时是否使用快照数据(核心安全决策)

决策日期:2026-04-15
状态:已采纳 ✓

问题描述:用户从详情页到提交订单期间,前端已经缓存了商品信息、价格、活动等快照数据。在用户点击“提交订单“创建订单时,后端是否可以使用这些快照数据来提升性能,避免重复查询?

备选方案

方案描述优点缺点
方案A:使用快照创单时直接使用前端传递的快照数据✅ 性能好(无需查询)
✅ 响应快(200ms → 50ms)
❌ 安全风险高(快照可能被篡改)
❌ 资损风险
方案B:强制实时查询创单时强制调用商品服务、营销服务查询最新数据✅ 数据绝对准确
✅ 安全性高(防篡改)
✅ 无资损风险
❌ 性能稍差(多次RPC调用)
❌ RT增加100-200ms
方案C:混合模式普通商品用快照,营销商品强制查询⚠️ 复杂度高
⚠️ 容易出错
❌ 维护成本高
❌ 边界不清晰

决策:采用方案B(强制实时查询)

决策理由

  1. 安全性优先于性能

    风险分析:
    - 如果用快照,活动结束但快照未更新 → 用户用秒杀价下单 → 资损
    - 如果用快照,用户篡改价格 → 恶意低价下单 → 资损
    - 性能损失:100-200ms
    - 资损风险:每单可能损失数百至数千元
    
    结论:100ms的性能代价 << 资损风险
    
  2. 涉及资金的操作必须实时校验

    创单 = 锁定库存 + 锁定价格 + 准备扣款
    → 必须基于最新、最准确的数据
    → 不能因为性能优化而妥协安全性
    
  3. 防止恶意篡改

    场景:黑产抓包修改快照数据
    快照:{"expected_payable": 799900}  // 原价 ¥7,999
    篡改:{"expected_payable": 1}       // 改成 ¥0.01
    
    如果后端使用快照:
    → 按 ¥0.01 创单 → 公司巨额损失!
    
    强制实时查询:
    → 后端查到实际价格 ¥7,999
    → 对比快照 ¥0.01 vs 实际 ¥7,999
    → 差异巨大,拒绝创单!
    
  4. 活动可能随时变化

    10:00  秒杀价 ¥7,999,生成快照
    10:04  秒杀活动提前结束(库存售罄)
    10:05  用户提交订单
    
    如果用快照:
    → 按 ¥7,999 创单(活动已结束!)
    → 资损
    
    强制查询:
    → 查到活动已结束,价格 ¥8,999
    → 提示用户价格变化
    → 避免资损
    

实现方案

// OrderService.CreateOrder - 确认下单接口(准确性优先)
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    // ⚠️ 关键:创单时不使用任何前端传递的快照数据,全部实时查询
    
    // Step 1: 实时查询商品信息(不使用前端快照)
    products, err := s.productClient.BatchGetProducts(ctx, req.SkuIDs)
    if err != nil {
        return nil, fmt.Errorf("query products failed: %w", err)
    }
    
    // Step 2: 实时查询营销活动(强制最新数据)
    promos, err := s.marketingClient.BatchGetPromotions(ctx, req.SkuIDs, req.UserID)
    if err != nil {
        return nil, fmt.Errorf("query promotions failed: %w", err)
    }
    
    // Step 3: 校验营销活动有效性(关键:防止使用过期活动)
    for _, promo := range promos {
        if !s.validatePromotion(promo) {
            return nil, fmt.Errorf("promotion %s is invalid or expired", promo.ID)
        }
    }
    
    // Step 4: 实时计算价格(基于最新营销数据)
    price, err := s.pricingClient.CalculateFinalPrice(ctx, products, promos)
    if err != nil {
        return nil, fmt.Errorf("calculate price failed: %w", err)
    }
    
    // Step 5: 价格校验(对比前端传递的期望价格)
    if req.ExpectedPrice > 0 {
        if err := s.validatePriceChange(req.ExpectedPrice, price.FinalPrice); err != nil {
            return nil, err  // 价格变化过大,拒绝创单
        }
    }
    
    // Step 6: 预占库存
    reserved, err := s.inventoryClient.ReserveStock(ctx, req.Items)
    if err != nil {
        return nil, fmt.Errorf("reserve stock failed: %w", err)
    }
    
    // Step 7: 生成商品快照(基于实时查询的数据)
    snapshot := s.generateProductSnapshot(products, promos, price)
    
    // Step 8: 创建订单(保存快照)
    order := &Order{
        OrderID:         s.generateOrderID(),
        UserID:          req.UserID,
        Items:           req.Items,
        TotalPrice:      price.FinalPrice,
        ProductSnapshot: marshal(snapshot),  // 💾 保存商品快照
        Status:          OrderStatusPendingPayment,
        ExpireTime:      time.Now().Add(15 * time.Minute),
        ReserveIDs:      reserved,
    }
    
    return s.orderRepo.Create(ctx, order)
}

// 价格校验逻辑(防止用户感知差)
func (s *OrderService) validatePriceChange(expected, actual int64) error {
    diff := actual - expected
    diffPercent := float64(diff) / float64(expected) * 100
    
    // 场景1: 价格降低 → 允许(对用户有利)
    if diff < 0 {
        return nil
    }
    
    // 场景2: 价格上涨 < 1元 → 允许(误差容忍)
    if diff <= 100 { // 100分 = 1元
        return nil
    }
    
    // 场景3: 价格上涨 >= 1元 且 < 5% → 允许但记录日志
    if diffPercent < 5.0 {
        log.Warnf("price increased: expected=%d, actual=%d", expected, actual)
        return nil
    }
    
    // 场景4: 价格上涨 >= 5% → 拒绝,要求用户重新确认
    return &PriceChangedError{
        Expected: expected,
        Actual:   actual,
        Message:  fmt.Sprintf("价格已变化,请重新确认"),
    }
}

核心原则

┌────────────────────────────────────────────────────────┐
│ 试算阶段:性能优先 → 可用快照(5分钟缓存)              │
│ 创单阶段:准确性优先 → 强制实时查询                     │
│ 历史查询:可追溯性 → 保存快照到订单表                   │
└────────────────────────────────────────────────────────┘

ADR-010: 创单与支付的时序关系

决策日期:2026-04-14
状态:已采纳 ✓

问题描述:在订单流程中,“创建订单“和“支付“这两个动作的时序关系有两种模式:

  1. 创单即支付:用户点击“立即购买“后,先支付,支付成功后再创建订单
  2. 先创单后支付:用户点击“提交订单“后,先创建订单(资源扣减),然后再支付

决策:采用“先创单后支付“模式

理由

1. 防止超卖(关键)

【创单即支付模式的问题】:
1. 用户A看到库存=1
2. 用户B也看到库存=1
3. 用户A点击支付(此时库存未扣减)
4. 用户B也点击支付(库存仍未扣减)
5. 两人同时支付成功 → 超卖!

【先创单后支付模式的解决方案】:
1. 用户A点击"提交订单" → 库存预占:1 → 0(剩余可用)
2. 用户B点击"提交订单" → 库存不足,下单失败
3. 用户A有15分钟支付窗口
4. 如果用户A超时未支付 → 释放库存:0 → 1(其他人可下单)

2. 用户体验更好

  • ✅ 用户点击“提交订单“后,订单立即生成,库存被锁定
  • ✅ 用户可以慢慢选择支付方式(支付宝、微信、银行卡)
  • ✅ 用户可以在支付环节选择优惠券、支付渠道优惠
  • ✅ 用户可以先下单占位,稍后再支付(适合机票、酒店)

3. 价格计算灵活性

  • 创单时计算:商品基础价格 + 营销优惠(折扣、满减)
  • 支付时计算:支付渠道费(信用卡手续费、花呗分期费)+ 支付渠道优惠

权衡

维度优势劣势
用户体验✅ 先锁定库存,再支付
✅ 支付环节更灵活
⚠️ 15分钟内库存被占用
防止超卖✅ 创单时锁定库存(零超卖)⚠️ 需要处理超时释放逻辑
库存利用率⚠️ 预占库存可能被浪费(10-20%未支付率)✅ 可通过缩短支付窗口优化
系统复杂度⚠️ 需要库存预占机制
⚠️ 需要超时释放定时任务
⚠️ 状态机更复杂

超时未支付处理

// OrderTimeoutJob - 定时扫描超时未支付订单
func (j *OrderTimeoutJob) Run() {
    // 查询超时订单(创建时间 > 15分钟,状态=PENDING_PAYMENT)
    expiredOrders := j.orderRepo.FindExpiredPendingPayment(15 * time.Minute)
    
    for _, order := range expiredOrders {
        // 1. 更新订单状态:PENDING_PAYMENT → CANCELLED
        order.Status = OrderStatusCancelled
        order.CancelReason = "超时未支付"
        j.orderRepo.Update(ctx, order)
        
        // 2. 释放库存
        j.inventoryClient.ReleaseStock(ctx, order.ReserveIDs)
        
        // 3. 回退优惠券
        if order.CouponID != "" {
            j.marketingClient.ReleaseCoupon(ctx, order.CouponID, order.UserID)
        }
        
        // 4. 发布订单取消事件
        j.eventPublisher.Publish(ctx, &OrderCancelledEvent{
            OrderID: order.OrderID,
            Reason:  "超时未支付",
        })
    }
}

ADR-011: 创单时前后端价格校验策略

决策日期:2026-04-15
状态:已采纳 ✓

问题描述:创单时后端实时查询得到的价格,可能和前端展示的价格不一致(活动变化、价格调整)。应该如何处理这种差异?

决策:采用差异容忍 + 提示机制

价格对比规则

场景差异情况处理策略理由
场景1价格降低✅ 直接通过对用户有利
场景2价格上涨 < 1元✅ 允许(容忍误差)微小差异,可接受
场景3价格上涨 >= 1元 且 < 5%✅ 允许但记录日志合理波动范围
场景4价格上涨 >= 5%❌ 拒绝,要求重新确认差异过大,影响用户决策

实现代码

func (s *OrderService) validatePriceChange(expected, actual int64) error {
    diff := actual - expected
    diffPercent := float64(diff) / float64(expected) * 100
    
    // 场景1: 价格降低 → 允许(对用户有利)
    if diff < 0 {
        return nil
    }
    
    // 场景2: 价格上涨 < 1元 → 允许
    if diff <= 100 {
        return nil
    }
    
    // 场景3: 价格上涨 < 5% → 允许但记录
    if diffPercent < 5.0 {
        log.Warnf("price increased: expected=%d, actual=%d, diff=%d", 
            expected, actual, diff)
        return nil
    }
    
    // 场景4: 价格上涨 >= 5% → 拒绝
    return &PriceChangedError{
        Expected: expected,
        Actual:   actual,
        Message:  fmt.Sprintf("价格已变化:原价%.2f元,现价%.2f元", 
            float64(expected)/100, float64(actual)/100),
    }
}

前端交互

// 前端处理价格变化错误
try {
    const order = await api.createOrder(orderData);
} catch (error) {
    if (error.code === 'PRICE_CHANGED') {
        // 弹窗提示用户
        showConfirmDialog({
            title: '价格已变化',
            message: error.message,
            confirm: '接受新价格并下单',
            cancel: '返回重新选择'
        }).then((confirmed) => {
            if (confirmed) {
                // 用户接受新价格,使用新价格重新下单
                api.createOrder({
                    ...orderData,
                    acceptNewPrice: true,
                    expectedPrice: error.actualPrice
                });
            }
        });
    }
}

ADR-012: 试算价格计算与创单价格计算的统一与差异

决策日期:2026-04-15
状态:已采纳 ✓

问题描述:试算接口(/checkout/calculate)和创单接口(/order/create)都需要计算价格,两者的价格计算逻辑应该如何设计?

决策统一计价服务 + 差异化数据输入

核心设计

接口数据输入计算逻辑快照策略
试算接口可使用快照(5分钟)调用统一计价服务允许快照数据
创单接口强制实时查询调用统一计价服务禁止快照数据

理由

  1. 计价逻辑统一

    • 试算和创单使用同一个 PricingService.Calculate
    • 避免“试算价格“与“订单价格“不一致
    • 营销规则变更只需更新一处
  2. 数据输入差异化

    • 试算:允许使用缓存/快照数据(性能优先)
    • 创单:强制实时查询(准确性优先)
  3. 最终一致性保证

    • 试算阶段可能使用过期快照
    • 创单阶段的实时查询是最后防线
    • 价格差异会被拦截并提示用户

架构图

graph TB
    subgraph 试算接口
        A1[Checkout.Calculate]
        A2[使用快照数据<br/>性能优先]
    end
    
    subgraph 创单接口
        B1[Order.Create]
        B2[强制实时查询<br/>准确性优先]
    end
    
    subgraph 计价服务
        C[PricingService.Calculate<br/>统一计算逻辑]
    end
    
    A1 --> A2
    A2 --> C
    B1 --> B2
    B2 --> C

ADR-013: 价格在整个交易链路中的流转与计算策略

决策日期:2026-04-15
状态:已采纳 ✓

问题描述:从用户搜索商品到最终支付,价格会经历多个阶段(搜索列表 → 商品详情 → 加购试算 → 创单 → 支付)。每个阶段的价格计算范围、数据来源、系统交互都不同。需要一个全局视角来理解价格是如何流转的,以及各阶段的相同点和不同点。

核心挑战

业务困惑:
• 为什么搜索列表的价格和详情页不一样?
• 详情页显示的价格和试算价格能保证一致吗?
• 试算价格和最终支付价格可能不同吗?
• 每个阶段都要调用Pricing Service吗?
• 基础价格、营销折扣、优惠券、Coin、支付渠道费分别在哪个阶段计算?

决策:采用**“分阶段计算 + 逐步扩展价格维度 + 最终强制校验”**策略


价格流转全局图

用户旅程:搜索 → 详情 → 试算 → 创单 → 支付
           ↓      ↓      ↓      ↓      ↓
价格计算: 基础价  +营销  +营销  +营销  +Coin+Voucher+渠道费
           ↓      ↓      ↓      ↓      ↓
数据来源: ES缓存  实时   快照   强制   强制实时
                         (可选) 实时
           ↓      ↓      ↓      ↓      ↓
性能目标: 30ms   150ms  230ms  500ms  200ms

五个阶段对比

阶段价格维度数据来源性能目标计算复杂度资损风险
搜索列表基础价(最低价)ES缓存(延迟1-5分钟)P95 < 30ms低(只查ES)
商品详情基础价 + 营销折扣实时查询 + 生成快照P95 < 150ms中(3个服务)
结算试算基础价 + 营销 + 数量快照 OR 实时查询P95 < 230ms中(可能3个服务)
确认下单基础价 + 营销 + 数量 + 券强制实时查询P95 < 500ms高(4个服务 + 预占)
支付确认上述 + Coin + Voucher + 渠道费强制实时查询P95 < 200ms高(多维度计算)极高

核心设计原则

  1. 逐步扩展价格维度

    搜索:最低价(吸引用户)
    详情:折扣价(展示营销)
    试算:总价(含数量、券)
    创单:锁定价(预占资源)
    支付:最终价(含所有优惠与费用)
    
  2. 数据来源分级

    搜索/详情:允许缓存(性能优先)
    试算:允许快照(性能与准确性平衡)
    创单/支付:强制实时(安全优先)
    
  3. 多道防线保证准确性

    详情页:生成快照(用于试算)
    试算:对比快照与实时(发现变化)
    创单:强制实时 + 价格校验(最后防线)
    支付:二次校验 + Coin/Voucher锁定(终极防线)
    

监控指标

  • 各阶段P95响应时间
  • 快照命中率(目标 > 80%)
  • 价格差异率(试算vs创单,目标 < 5%)
  • 价格变化拦截率(创单价格校验触发频率)

34.10 高可用与性能优化(Infrastructure & Operations)

34.10.1 高可用设计

服务多副本部署

服务正常副本大促副本扩容策略
Product Center618CPU > 70% 自动扩容
Inventory618QPS > 5000 扩容
Order824QPS > 3000 扩容
Payment412QPS > 2000 扩容

数据库高可用

MySQL:
• 主从复制(1主2从)
• 双主互备(支付库)
• 自动故障转移(MHA)

Redis:
• Sentinel模式(1主2从3哨兵)
• 自动故障转移

Kafka:
• 3副本
• ISR机制

熔断与降级

// 熔断配置
type CircuitBreakerConfig struct {
    MaxRequests       uint32        // 半开状态最大请求数
    Interval          time.Duration // 统计窗口
    Timeout           time.Duration // 熔断超时时间
    FailureThreshold  float64       // 失败率阈值(0-1)
}

// 降级策略
func (s *SearchAggregation) Search(ctx context.Context, req *SearchRequest) (*SearchResponse, error) {
    // 尝试调用Marketing Service
    promos, err := s.marketingClient.GetPromotions(ctx, req.SkuIDs)
    if err != nil {
        // 降级:使用基础价格(不展示营销信息)
        log.Warn("Marketing Service故障,降级为基础价格")
        promos = make(map[int64]*PromoInfo)  // 空促销
    }
    
    // 继续后续流程...
    return s.buildResponse(products, promos)
}

34.10.2 性能优化

缓存策略(多级缓存):

// L1: 本地缓存(进程内)
type LocalCache struct {
    cache *bigcache.BigCache
}

func (c *LocalCache) Get(key string) (interface{}, error) {
    data, err := c.cache.Get(key)
    if err == nil {
        return unmarshal(data), nil
    }
    return nil, err
}

// L2: Redis缓存
// L3: MySQL数据库

func (s *ProductService) GetProduct(ctx context.Context, skuID int64) (*Product, error) {
    // L1: 本地缓存
    if product, err := s.localCache.Get(skuID); err == nil {
        return product, nil
    }
    
    // L2: Redis缓存
    if product, err := s.redis.Get(ctx, fmt.Sprintf("product:%d", skuID)); err == nil {
        s.localCache.Set(skuID, product)  // 回填L1
        return product, nil
    }
    
    // L3: MySQL数据库
    product, err := s.repo.GetByID(ctx, skuID)
    if err != nil {
        return nil, err
    }
    
    // 回填缓存
    s.redis.Set(ctx, fmt.Sprintf("product:%d", skuID), product, 30*time.Minute)
    s.localCache.Set(skuID, product)
    
    return product, nil
}

批量查询优化

// 批量获取商品信息(减少RPC调用)
func (s *ProductService) BatchGetProducts(ctx context.Context, skuIDs []int64) (map[int64]*Product, error) {
    // Step 1: 尝试从缓存批量获取
    cached := s.redis.MGet(ctx, toCacheKeys(skuIDs))
    
    // Step 2: 找出缺失的ID
    missingIDs := findMissing(skuIDs, cached)
    
    // Step 3: 批量查询数据库(IN查询)
    if len(missingIDs) > 0 {
        missing, _ := s.repo.GetByIDs(ctx, missingIDs)
        // 回填缓存
        s.redis.MSet(ctx, missing, 30*time.Minute)
        cached = merge(cached, missing)
    }
    
    return cached, nil
}

数据库优化

-- 索引优化
CREATE INDEX idx_order_user_create ON `order` (user_id, create_time DESC);
CREATE INDEX idx_order_status ON `order` (status, create_time DESC);

-- 避免SELECT *(只查询需要的字段)
SELECT order_id, status, total_price FROM `order` WHERE user_id = ?;

-- 分页优化(使用索引覆盖)
SELECT order_id FROM `order` 
WHERE user_id = ? AND create_time > ?
ORDER BY create_time DESC
LIMIT 20;

34.10.3 容灾与降级

多机房部署

Region A(主):
• 写流量:100%
• 读流量:70%

Region B(备):
• 写流量:0%(只读副本)
• 读流量:30%

灾难切换:
• 自动故障检测(3秒)
• 流量切换到Region B(30秒)
• RTO:< 2分钟

降级开关

// Feature Flag控制降级
func (s *CheckoutService) Calculate(ctx context.Context, req *CalculateRequest) (*CalculateResponse, error) {
    // 检查Feature Flag
    if s.featureFlag.IsEnabled(ctx, "marketing.enabled") {
        // 正常逻辑:调用Marketing Service
        promos, _ := s.marketingClient.GetPromotions(ctx, req)
        return s.calculateWithPromos(req, promos)
    } else {
        // 降级逻辑:不使用营销信息
        return s.calculateBasic(req)
    }
}

34.11 团队组织与协作(Organization & Governance)

34.11.1 团队结构

康威定律实践:系统架构反映组织沟通结构。

订单团队(15人)
├─ 订单核心(5人):订单创建、状态机
├─ 订单查询(3人):我的订单、订单详情
├─ 履约对接(4人):供应商履约、异常处理
└─ 测试(3人)

商品团队(12人)
├─ 商品中心(6人):SPU/SKU管理
├─ 类目属性(3人):类目树、属性模板
└─ 测试(3人)

库存团队(10人)
├─ 库存核心(5人):预占、扣减、释放
├─ 供应商同步(3人):实时查询、定时同步
└─ 测试(2人)

跨团队协作

场景协作方式工具
API契约OpenAPI/Proto定义Swagger、Buf
事件契约Schema RegistryConfluent Schema Registry
联调测试契约测试Pact
故障处理On-call轮值PagerDuty

34.11.2 协作流程

需求评审流程

1. 产品提需求(PRD)
   ↓
2. 技术评审(架构师+各团队Lead)
   • 是否需要新增服务?
   • 是否需要修改API契约?
   • 是否需要数据库迁移?
   ↓
3. API契约评审(上下游团队)
   • 定义Request/Response
   • 明确超时、重试策略
   • 确认降级方案
   ↓
4. 开发排期
   • 各团队独立开发
   • 契约测试通过后联调
   ↓
5. 集成测试
   • 端到端测试
   • 性能测试
   ↓
6. 灰度发布
   • 5% → 20% → 50% → 100%

实际案例:新增“拼团“功能的完整协作流程

第1周:需求评审与技术方案

【产品需求】
- 用户发起拼团(3人成团,24小时有效)
- 拼团价格比正常价格低20%
- 成团后统一发货,不成团退款

【技术评审会议】(2小时,架构师+6个团队Lead)
问题1:拼团功能是否需要新增服务?
  → 决策:新增"拼团服务"(GroupBuy Service)
  → 理由:拼团逻辑复杂(成团判断、超时处理),独立服务便于维护

问题2:拼团价格如何计算?
  → 决策:在Pricing Service中新增"拼团价格策略"
  → 理由:价格计算逻辑应该统一管理

问题3:拼团成功后如何扣减库存?
  → 决策:拼团成功时批量预占库存(3人份)
  → 理由:避免成团后库存不足

【输出物】
- 技术方案文档(15页)
- 服务依赖图(Mermaid图)
- 数据库设计(ER图)
- 时序图(成团流程、超时处理)

第2周:API契约评审

【API契约】
// 创建拼团
POST /groupbuy/create
Request:
{
  "sku_id": 1001,
  "original_price": 299.00,
  "groupbuy_price": 239.00,  // 8折
  "required_count": 3,        // 3人成团
  "expire_hours": 24          // 24小时有效
}
Response:
{
  "groupbuy_id": "GB20260501123456",
  "status": "waiting",        // 等待中
  "current_count": 1,         // 当前人数
  "required_count": 3,
  "expires_at": 1744633200
}

// 参与拼团
POST /groupbuy/join
Request:
{
  "groupbuy_id": "GB20260501123456",
  "user_id": 67890
}
Response:
{
  "status": "success",        // 成功 or 团满
  "order_id": "ORD123456",    // 如果成团,返回订单号
  "current_count": 3
}

【契约测试】
- 上游:前端团队(Web、App)
- 下游:Pricing Service、Inventory Service、Order Service
- 测试工具:Pact
- 测试覆盖:100%(所有API)

第3-4周:并行开发

【团队分工】
拼团团队(5人):
  - 拼团服务核心逻辑
  - 超时任务(15分钟扫描一次)
  - 数据库表设计(groupbuy、groupbuy_participant)

计价团队(2人):
  - 新增拼团价格策略
  - 拼团价格校验

库存团队(2人):
  - 批量预占库存接口

订单团队(3人):
  - 拼团成团后批量创建订单
  - 拼团失败后退款

前端团队(4人):
  - 拼团页面(发起、参与、分享)
  - 倒计时组件

【每日站会】(15分钟)
- 各团队汇报进度
- 识别阻塞点
- 协调资源

【契约测试通过率】
- 第3周末:70%
- 第4周末:100% ✅

第5周:集成测试

【测试场景】
场景1:正常成团
  1. 用户A发起拼团(3人成团)
  2. 用户B、C参与拼团
  3. 成团 → 创建3个订单 → 预占库存(3份)
  4. 用户A、B、C支付 → 确认扣减库存

场景2:超时未成团
  1. 用户A发起拼团(3人成团)
  2. 只有用户B参与(2人)
  3. 24小时后超时 → 标记拼团失败 → 退款

场景3:库存不足
  1. 用户A发起拼团(3人成团)
  2. 用户B、C参与拼团
  3. 成团时库存不足(只剩2个)→ 拼团失败 → 退款

【性能测试】
- 并发创建拼团:1000 TPS
- 并发参与拼团:5000 TPS
- 超时扫描任务:1000个拼团/秒
- P99延迟:< 300ms ✅

第6周:灰度发布

【灰度策略】
阶段1(5%):内部员工 + 白名单用户(1000人)
  → 观察1天:成团率、退款率、投诉数

阶段2(20%):北京、上海用户
  → 观察3天:性能指标、业务指标

阶段3(50%):全国用户
  → 观察1周

阶段4(100%):全量发布
  → 持续监控1个月

【关键指标】
- 成团率:65%(目标 > 60%)✅
- 退款率:5%(目标 < 10%)✅
- 用户投诉:3起/天(目标 < 10起)✅
- P99延迟:280ms(目标 < 300ms)✅

协作关键点

阶段关键协作点工具/机制
需求评审架构师+各团队Lead对齐技术方案会议+文档
API契约上下游团队明确接口定义OpenAPI + Pact
并行开发各团队独立开发,通过契约测试联调Pact + Mock Server
集成测试端到端测试,验证完整流程自动化测试平台
灰度发布分阶段发布,持续监控Feature Flag + Grafana

变更管理

// ADR(Architecture Decision Record)
// 记录重大架构决策

#### ADR-014: 拼团功能是否复用订单服务

**决策日期**:2026-05-01
**状态**:已采纳 ✓

**问题描述**:
拼团功能需要创建订单,是在订单服务中新增拼团逻辑,还是新建拼团服务?

**备选方案**:
A. 在订单服务中新增拼团逻辑
   ✓ 复用订单创建逻辑
   ✗ 订单服务变得臃肿
   ✗ 拼团逻辑与订单逻辑耦合

B. 新建拼团服务
   ✓ 拼团逻辑独立,便于维护
   ✓ 订单服务保持单一职责
   ✗ 需要新建服务(增加运维成本)

**决策**:采用方案B,新建拼团服务

**理由**:
1. 拼团逻辑复杂(成团判断、超时处理、退款逻辑)
2. 拼团是营销活动,不是订单核心流程
3. 未来可能有"砍价""秒杀"等类似活动,独立服务便于扩展

**影响范围**:
- 新增服务:GroupBuy Service
- QPS估算:2000(正常)/ 10000(大促)
- 部署规模:4副本(正常)/ 12副本(大促)

**后续行动**:
- ✓ 已完成:GroupBuy Service开发
- ✓ 已完成:与订单服务集成
- ✓ 已完成:灰度上线

34.11.3 技术治理

代码评审清单

  • 是否符合分层架构(依赖方向正确)
  • 是否有单元测试(覆盖率 > 80%)
  • 是否有集成测试(核心路径)
  • 是否有性能测试(Benchmark)
  • 是否有监控指标(Prometheus Metrics)
  • 是否有日志(结构化日志)
  • 是否有文档(API文档、设计文档)
  • 是否考虑降级方案

技术债管理

#### 技术债清单

| 优先级 | 类型 | 描述 | 负责人 | 预计工作量 |
|-------|------|------|--------|-----------|
| P0 | 性能 | 订单查询慢查询优化 | @张三 | 2天 |
| P1 | 安全 | 支付回调签名验证 | @李四 | 1天 |
| P2 | 代码 | 商品中心重复代码重构 | @王五 | 3天 |

34.12 上线与演进(Deployment & Evolution)

34.12.1 上线策略

分阶段上线

阶段1:基础功能(2周)
• 商品中心、库存服务、订单服务
• 支持机票、酒店两个品类
• 单机房部署

阶段2:营销功能(2周)
• 营销服务、计价服务
• 支持优惠券、活动

阶段3:新品类(每周1个)
• 充值、电影票、优惠券、礼品卡

阶段4:多机房(4周)
• 双机房部署
• 流量灰度切换

34.12.2 灰度发布

灰度策略

// 灰度规则
type GrayReleaseRule struct {
    Version    string   // 新版本号
    Percentage int      // 流量比例(0-100)
    Whitelist  []int64  // 白名单用户ID
    Regions    []string // 灰度地区
}

func (r *GrayRouter) Route(userID int64, region string) string {
    // 白名单用户直接路由到新版本
    if contains(r.rule.Whitelist, userID) {
        return r.rule.Version
    }
    
    // 按地区灰度
    if !contains(r.rule.Regions, region) {
        return "stable"  // 老版本
    }
    
    // 按百分比灰度
    if hash(userID) % 100 < r.rule.Percentage {
        return r.rule.Version  // 新版本
    }
    
    return "stable"  // 老版本
}

灰度步骤

1. 5%流量(白名单用户 + 内部员工)
   观察1小时:错误率、延迟、业务指标

2. 20%流量(特定地区)
   观察2小时

3. 50%流量
   观察4小时

4. 100%流量(全量发布)
   观察24小时

5. 下线老版本

34.12.3 监控告警

三级监控体系

层级监控对象工具告警阈值
业务监控订单量、GMV、转化率Grafana + ClickHouse同比下降20%
应用监控QPS、延迟、错误率Prometheus + GrafanaP99延迟>500ms
基础设施监控CPU、内存、磁盘、网络Prometheus + Node ExporterCPU>80%

核心指标

// Prometheus Metrics
package metrics

import "github.com/prometheus/client_golang/prometheus"

var (
    // 业务指标
    orderCreatedTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "order_created_total",
            Help: "订单创建总数",
        },
        []string{"category", "status"},  // 标签:品类、状态
    )
    
    orderCreatedLatency = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "order_created_latency_seconds",
            Help:    "订单创建延迟",
            Buckets: []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
        },
        []string{"category"},
    )
    
    orderGMV = prometheus.NewGaugeVec(
        prometheus.GaugeOpts{
            Name: "order_gmv_total",
            Help: "订单GMV(元)",
        },
        []string{"date"},
    )
    
    // 系统指标
    httpRequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP请求延迟",
            Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10},
        },
        []string{"method", "endpoint", "status"},
    )
    
    httpRequestTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_request_total",
            Help: "HTTP请求总数",
        },
        []string{"method", "endpoint", "status"},
    )
    
    // 依赖服务指标
    rpcCallDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "rpc_call_duration_seconds",
            Help:    "RPC调用延迟",
            Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5},
        },
        []string{"service", "method", "status"},
    )
    
    rpcCallTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "rpc_call_total",
            Help: "RPC调用总数",
        },
        []string{"service", "method", "status"},
    )
    
    // 数据库指标
    dbQueryDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "db_query_duration_seconds",
            Help:    "数据库查询延迟",
            Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1},
        },
        []string{"query_type", "table"},
    )
    
    // Redis指标
    redisCommandDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "redis_command_duration_seconds",
            Help:    "Redis命令延迟",
            Buckets: []float64{.0001, .0005, .001, .005, .01, .025, .05, .1},
        },
        []string{"command"},
    )
)

// 使用示例
func CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
    startTime := time.Now()
    
    // 业务逻辑...
    order, err := createOrderInternal(ctx, req)
    
    // 记录指标
    duration := time.Since(startTime).Seconds()
    category := req.Category
    status := "success"
    if err != nil {
        status = "failed"
    }
    
    // 记录订单创建总数
    orderCreatedTotal.WithLabelValues(category, status).Inc()
    
    // 记录订单创建延迟
    orderCreatedLatency.WithLabelValues(category).Observe(duration)
    
    // 记录GMV
    if err == nil {
        orderGMV.WithLabelValues(time.Now().Format("2006-01-02")).Add(float64(order.TotalPrice))
    }
    
    return order, err
}

告警规则

    # Prometheus AlertManager 规则
groups:
  - name: order-service-alerts
    rules:
      # P99延迟告警
      - alert: OrderCreateLatencyHigh
        expr: histogram_quantile(0.99, order_created_latency_seconds) > 1
        for: 5m
        labels:
          severity: warning
          service: order-service
        annotations:
          summary: "订单创建延迟过高"
          description: "P99延迟 {{ $value }}s > 1s(持续5分钟)"
          dashboard: "https://grafana.example.com/d/order-service"
      
      # 错误率告警
      - alert: OrderCreateErrorRateHigh
        expr: |
          sum(rate(order_created_total{status="failed"}[5m])) 
          / sum(rate(order_created_total[5m])) > 0.01
        for: 5m
        labels:
          severity: critical
          service: order-service
        annotations:
          summary: "订单创建失败率过高"
          description: "失败率 {{ $value | humanizePercentage }} > 1%"
          runbook: "https://wiki.example.com/runbook/order-create-error"
      
      # QPS下降告警(业务异常)
      - alert: OrderCreateQPSDrop
        expr: |
          (sum(rate(order_created_total[5m])) 
          / sum(rate(order_created_total[5m] offset 1h))) < 0.5
        for: 10m
        labels:
          severity: warning
          service: order-service
        annotations:
          summary: "订单创建QPS骤降"
          description: "当前QPS {{ $value }},比1小时前下降50%以上"
      
      # GMV下降告警(业务异常)
      - alert: OrderGMVDrop
        expr: |
          (sum(rate(order_gmv_total[1h])) 
          / sum(rate(order_gmv_total[1h] offset 24h))) < 0.8
        for: 30m
        labels:
          severity: critical
          service: order-service
        annotations:
          summary: "订单GMV大幅下降"
          description: "当前GMV {{ $value }},比昨天同期下降20%以上"
      
      # RPC调用失败率告警
      - alert: RPCCallErrorRateHigh
        expr: |
          sum(rate(rpc_call_total{status!="success"}[5m])) by (service) 
          / sum(rate(rpc_call_total[5m])) by (service) > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "RPC调用失败率过高:{{ $labels.service }}"
          description: "失败率 {{ $value | humanizePercentage }} > 5%"
      
      # 数据库慢查询告警
      - alert: DBSlowQuery
        expr: histogram_quantile(0.99, db_query_duration_seconds) > 0.1
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "数据库慢查询"
          description: "P99延迟 {{ $value }}s > 100ms"
      
      # Redis延迟告警
      - alert: RedisLatencyHigh
        expr: histogram_quantile(0.99, redis_command_duration_seconds) > 0.01
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Redis延迟过高"
          description: "P99延迟 {{ $value }}s > 10ms"
      
      # 服务实例Down告警
      - alert: ServiceInstanceDown
        expr: up{job="order-service"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "服务实例宕机"
          description: "实例 {{ $labels.instance }} 已宕机超过1分钟"

告警分级与处理

级别触发条件通知方式响应时间处理人
P0(紧急)GMV下降>20%、服务全部宕机电话+短信+企业微信< 5分钟On-call工程师+经理
P1(严重)错误率>1%、P99延迟>1s企业微信+短信< 15分钟On-call工程师
P2(警告)QPS下降>50%、数据库慢查询企业微信< 30分钟值班工程师
P3(提示)磁盘使用>80%、内存使用>80%邮件< 2小时运维团队

监控大屏

┌─────────────────────────────────────────────────────────┐
│                   订单服务实时监控大屏                    │
├─────────────────────────────────────────────────────────┤
│  今日订单量: 1,234,567  ↑ 12.3%   今日GMV: ¥456,789,012  │
│  当前QPS: 2,345         P99延迟: 234ms    错误率: 0.12%  │
├─────────────────────────────────────────────────────────┤
│  订单创建趋势(24小时)             QPS & P99延迟         │
│  ███████████████████████████████   ███████████████████   │
│  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓   ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒     │
│  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░     │
├─────────────────────────────────────────────────────────┤
│  品类分布               服务依赖健康度                   │
│  机票: 45%  ████████   Product Service:   ✅ 正常        │
│  酒店: 30%  ██████     Inventory Service: ✅ 正常        │
│  充值: 15%  ███        Pricing Service:   ⚠️  延迟高     │
│  其他: 10%  ██         Marketing Service: ✅ 正常        │
├─────────────────────────────────────────────────────────┤
│  活跃告警(3条)                                         │
│  ⚠️  P1 Pricing Service P99延迟>500ms(持续10分钟)      │
│  📊 P2 订单QPS比昨天同期下降15%                          │
│  💾 P3 MySQL主库连接数>80%                              │
└─────────────────────────────────────────────────────────┘

On-call值班机制

【值班表】(7x24小时)
周一:张三(订单团队)
周二:李四(商品团队)
周三:王五(库存团队)
...

【值班职责】
1. 响应P0/P1告警(5分钟内)
2. 排查问题根因(15分钟内定位)
3. 协调资源修复(30分钟内恢复)
4. 事后复盘(24小时内)

【升级机制】
On-call工程师无法处理 → 升级到Team Lead
Team Lead无法处理 → 升级到架构师
架构师无法处理 → 升级到CTO

34.12.4 系统演进路径

已完成

  • ✅ 基础架构搭建(微服务、服务发现、监控)
  • ✅ 核心品类上线(机票、酒店、充值)
  • ✅ 营销系统(优惠券、活动)
  • ✅ 双机房部署

进行中

  • 🚧 性能优化(P99延迟 < 200ms)
  • 🚧 新品类接入(电影票、礼品卡)
  • 🚧 供应商扩展(50+ → 100+)

规划中

  • 📅 国际化(多语言、多币种)
  • 📅 推荐系统(AI推荐)
  • 📅 智能客服(NLP)
  • 📅 区块链溯源(高端商品)

34.13 经验总结(Lessons Learned)

34.13.1 成功经验

1. 架构决策记录(ADR)制度

价值:

  • 重大决策留痕,新人可快速了解背景
  • 避免重复讨论已解决的问题
  • 架构演进有据可查

建议:

  • 每个ADR包含:问题、决策、理由、权衡、影响范围
  • 定期Review(每季度)
  • 与代码一起版本管理

2. 品类差异化设计

价值:

  • 避免“一刀切“架构(机票与充值差异大)
  • 策略模式让新品类接入成本降低80%
  • 适配器模式让供应商集成周期从4周缩短到1周

建议:

  • 先分析业务模型差异,再设计技术方案
  • 抽象共性,策略处理差异
  • 避免过度抽象(YAGNI原则)

3. 聚合层编排模式

价值:

  • API Gateway职责单一(鉴权、限流、路由)
  • 业务编排集中在聚合层,易于优化
  • 降级策略统一管理

建议:

  • 聚合层只做数据获取与编排,不做业务计算
  • 支持并发调用(提升性能)
  • 统一降级策略(Marketing故障降级为基础价)

4. 多级缓存策略

价值:

  • P99延迟从500ms降低到200ms
  • Redis QPS降低60%(本地缓存命中率30%)
  • 大促期间扛住5倍流量

建议:

  • L1(本地):热点数据,1分钟TTL
  • L2(Redis):通用数据,30分钟TTL
  • L3(MySQL):源数据
  • 缓存失效策略:主动失效 + TTL兜底

5. 契约测试

价值:

  • 上下游团队并行开发(不等联调)
  • API变更影响提前发现
  • 集成测试成本降低70%

建议:

  • 使用Pact等契约测试工具
  • API契约与代码一起版本管理
  • CI自动运行契约测试

34.13.2 踩过的坑

坑1:过早引入Event Sourcing

问题

  • 初期为了“追求架构完美“引入Event Sourcing
  • 团队对ES理解不足,查询复杂,运维困难
  • 投影重建耗时长(大促后修复bug需要重建投影,耗时4小时)

教训

  • Event Sourcing不是银弹,适用于审计要求极高的场景
  • 对于大部分电商场景,CQRS(不带ES)足够
  • 先用简单方案(CRUD),待确认瓶颈后再演进

坑2:供应商接口未做熔断

问题

  • 某供应商故障,接口超时(30秒)
  • 大量请求堆积,线程池耗尽
  • 整个订单服务不可用(影响其他供应商)

教训

  • 所有外部调用必须熔断(gobreaker)
  • 超时时间合理设置(不超过1秒)
  • 故障隔离(某个供应商故障不影响其他)

坑3:分库分表过早

问题

  • 订单量100万时就分库分表(8库64表)
  • 运维复杂度激增(扩容、迁移、对账)
  • 跨库查询需要路由表,增加延迟

教训

  • 单表500万以下不分表(MySQL性能足够)
  • 单库3000万以下不分库
  • 分库分表需要充分评估成本收益

坑4:忽视数据一致性对账

问题

  • 库存预占后未释放(代码bug)
  • 累积1个月后,库存数据严重不准确
  • 影响用户体验(明明有库存却提示“已售罄“)

教训

  • 异步操作必须有对账机制(每小时/每天)
  • 对账发现差异要有自动补偿
  • 监控库存准确率(定期抽查)

坑5:缓存穿透导致雪崩

问题

  • 恶意请求查询不存在的商品(skuID=0)
  • 缓存未命中,直接打到数据库
  • 数据库连接池耗尽,服务雪崩

教训

  • 布隆过滤器(Bloom Filter)拦截不存在的Key
  • 缓存空值(TTL=1分钟)
  • 请求参数校验(前置拦截非法请求)

34.13.3 改进方向

短期改进(3个月内)

  1. 性能优化

    • 目标:P99延迟从200ms降低到150ms
    • 措施
      • 热点数据预加载:大促前提前加载10万+热门商品到Redis
      • 数据库慢查询优化:全部慢查询(<50ms),添加复合索引
      • 连接池优化:MySQL连接池从100提升到500
      • 批量查询优化:单次查询支持100+商品(原50个)
    • 预期收益:QPS提升30%,响应时间降低25%
  2. 稳定性提升

    • 混沌工程实践
      • 每周定期故障演练(随机Kill Pod、网络延迟、数据库主从切换)
      • 自动化故障注入工具(Chaos Mesh)
      • 故障恢复时间目标:< 3分钟
    • 降级开关完善
      • 所有非核心功能支持降级(营销、推荐、评论)
      • Feature Flag平台(实时开关,无需重启)
      • 降级决策自动化(根据错误率自动降级)
    • 容量规划
      • 提前3个月预估资源需求(基于历史数据+增长率)
      • 大促前1个月进行压测(验证容量)
      • 弹性扩容策略(CPU > 70%自动扩容)
  3. 开发效率

    • 统一脚手架
      • 一键创建新服务(包含标准目录结构、配置文件、CI/CD)
      • 内置最佳实践(监控、日志、链路追踪)
      • 代码生成工具(Proto → Go代码自动生成)
    • 自动化测试
      • 单元测试覆盖率 > 90%(核心业务逻辑100%覆盖)
      • 集成测试自动化(每次提交自动运行)
      • 性能测试定期执行(每周一次,P99延迟不能退化)
    • CI/CD优化
      • 构建时间 < 5分钟(并行构建、增量构建、缓存优化)
      • 自动化部署(合并到main分支自动部署到生产)
      • 灰度发布流程标准化(5% → 20% → 50% → 100%)

中期改进(6-12个月)

  1. 智能化

    • 推荐系统

      • 协同过滤(基于用户行为相似度)
      • 深度学习模型(基于用户画像+商品属性)
      • 实时推荐(用户浏览行为实时调整推荐结果)
      • A/B测试(对比推荐效果,持续优化)
      • 预期提升:点击率+15%,转化率+10%
    • 动态定价

      • 根据供需关系自动调价(库存少+需求高 → 涨价)
      • 竞品价格监控(爬虫+算法,自动调整价格)
      • 用户画像定价(VIP用户优惠力度更大)
      • 时段定价(早上价格高,晚上价格低)
      • 预期提升:毛利率+8%,订单量+12%
    • 智能客服

      • FAQ自动回复(NLP模型识别用户问题)
      • 订单查询自动化(用户输入订单号,自动查询状态)
      • 售后自动化(退款、换货流程自动化)
      • 人工客服辅助(AI推荐回复话术)
      • 预期收益:客服成本降低40%,响应速度提升50%
  2. 国际化

    • 多语言支持(i18n)

      • 支持英语、中文、日语、韩语、泰语
      • 翻译管理平台(统一管理翻译资源)
      • 动态语言切换(用户可随时切换语言)
      • 本地化适配(日期格式、货币符号、文化差异)
    • 多币种支持

      • 支持USD、EUR、JPY、CNY等10+币种
      • 汇率实时转换(接入外汇API,每分钟更新)
      • 价格展示优化(根据用户地区自动选择币种)
      • 结算币种选择(支持多币种支付)
    • 跨境支付

      • 接入PayPal、Stripe(国际信用卡)
      • 本地化支付(日本:Pay-easy,韩国:KakaoPay)
      • 外汇结算(自动结汇,降低汇率风险)
  3. 数据驱动

    • 实时数据大屏

      • GMV实时展示(今日/本周/本月)
      • 订单量、转化率、客单价实时监控
      • 品类TOP10、商品TOP100
      • 地域分布、用户画像
      • 技术栈:Flink + ClickHouse + Grafana
    • A/B测试平台

      • 灰度实验(新功能A/B测试)
      • 流量分配(按用户ID哈希,保证一致性)
      • 效果评估(点击率、转化率、收入对比)
      • 自动化决策(效果好的方案自动全量)
    • 用户画像

      • 行为标签(浏览、加购、下单、复购)
      • 偏好标签(品类偏好、价格敏感度、优惠敏感度)
      • 生命周期标签(新用户、活跃用户、流失用户)
      • 精准营销(根据画像推送个性化优惠)

长期愿景(1-3年)

  1. 平台化

    • 开放API

      • 商品API(第三方接入商品数据)
      • 订单API(第三方接入订单流程)
      • 支付API(第三方接入支付能力)
      • API网关(统一鉴权、限流、监控)
      • 预期收益:生态规模扩大3倍
    • SaaS化

      • 中小企业独立部署(提供SaaS服务)
      • 多租户隔离(数据隔离、资源隔离)
      • 按需付费(按订单量或GMV收费)
      • 自助配置(商家自助配置商品、营销)
    • 生态建设

      • 开发者社区(技术文档、SDK、Demo)
      • 第三方插件市场(营销插件、支付插件)
      • 合作伙伴计划(供应商、物流商、支付商)
  2. 技术创新

    • Serverless架构

      • 函数计算(FaaS)替代部分微服务
      • 按需计费(降低运维成本50%)
      • 自动扩容(无需手动扩容)
      • 适用场景:短信通知、数据清洗、报表生成
    • Edge Computing

      • CDN边缘计算(静态资源、动态渲染)
      • 边缘缓存(用户就近访问,降低延迟)
      • 边缘函数(简单业务逻辑在边缘执行)
      • 预期收益:首屏加载时间降低60%
    • 区块链溯源

      • 高端商品防伪(奢侈品、珠宝)
      • 全链路追溯(生产、流通、销售)
      • 不可篡改(区块链存证)
      • 增强用户信任

改进路线图

gantt
    title 系统改进路线图
    dateFormat YYYY-MM
    section 短期(3个月)
    性能优化           :2026-05, 3M
    稳定性提升         :2026-05, 3M
    开发效率           :2026-05, 3M
    
    section 中期(6-12个月)
    智能化             :2026-08, 12M
    国际化             :2026-08, 12M
    数据驱动           :2026-08, 12M
    
    section 长期(1-3年)
    平台化             :2027-08, 24M
    技术创新           :2027-08, 24M

关键里程碑

时间里程碑成功标准
2026-08性能优化完成P99延迟 < 150ms,QPS提升30%
2026-11稳定性提升完成故障恢复时间 < 3分钟,可用性 > 99.99%
2027-02智能化上线推荐点击率+15%,动态定价毛利率+8%
2027-05国际化完成支持5种语言,10种币种,海外订单占比20%
2027-08数据驱动成熟A/B测试平台日活10万+,用户画像覆盖率100%
2028-08平台化初步完成开放API日调用100万+,接入第三方100+
2029-08技术创新落地Serverless占比30%,边缘计算覆盖80%流量

34.14 本章小结(Chapter Summary)

本章通过一个中大型B2B2C电商平台的完整案例,展示了从业务分析到技术落地的全过程,是全书知识点的综合实践验证。本章不仅覆盖了架构方法论(第 1-9 章),还深入展示了**供给运营系统(第 26 章)C端核心交易流(第 30-33 章)**的完整实现,真正做到了“理论→实践→落地“的闭环。


核心要点回顾

1. 品类差异化设计是关键

不同品类的业务模型存在本质差异,这是架构设计的基础:

品类库存模型价格模型履约模式超卖容忍度
机票实时库存(供应商)动态定价异步出票零容忍
酒店日历库存日历定价异步确认零容忍
充值无限库存固定面额同步充值可补偿
优惠券券码池固定折扣即时发放可补偿

设计启示

  • ✅ 使用策略模式处理品类差异(避免 if-else 地狱)
  • ✅ 使用适配器模式统一供应商接口(降低耦合)
  • ✅ 模板方法定义统一流程(具体步骤由策略实现)
  • ❌ 避免“一刀切“架构(机票与充值差异巨大,不能用同一套逻辑)

2. 聚合层解决跨服务编排问题

API Gateway(职责单一)
   ↓ 鉴权、限流、路由
Aggregation Service(编排层)
   ↓ 并发调用、数据聚合、降级处理
Business Services(业务层)
   ↓ 单一职责、独立部署
Infrastructure(基础设施层)

为什么需要聚合层?

  • ✅ API Gateway保持职责单一(鉴权、限流、路由)
  • ✅ 复杂编排逻辑集中管理(搜索场景:ES → Product → Inventory → Marketing → Pricing)
  • ✅ 统一降级策略(Marketing故障降级为基础价)
  • ✅ 性能优化空间大(并发调用、批量查询、缓存聚合结果)

3. 架构决策记录(ADR)是宝贵资产

本章记录了13个关键ADR决策:

ADR编号决策主题核心价值
ADR-001计价中心数据输入方式聚合层传入 vs 计价层自己调用
ADR-002库存预占时机试算 vs 创单
ADR-003聚合服务 vs BFF按业务场景 vs 按端
ADR-004虚拟商品库存模型二维模型(ManagementType + UnitType)
ADR-005同步 vs 异步数据流核心路径同步,非核心异步
ADR-009创单时是否使用快照强制实时查询(安全优先)
ADR-010创单与支付的时序先创单后支付(防止超卖)
ADR-011前后端价格校验策略差异容忍 + 提示机制
ADR-012试算与创单价格计算统一引擎 + 差异化数据来源
ADR-013价格流转全局策略分阶段计算 + 逐步扩展维度

ADR的价值

  • ✅ 记录决策背景(新人快速了解“为什么这样设计“)
  • ✅ 避免重复讨论(已解决的问题有文档可查)
  • ✅ 架构演进有据可查(回顾历史决策,持续优化)
  • ✅ 与代码一起版本管理(决策与实现同步演进)

4. 系统边界清晰至关重要

案例1:计价系统的边界重构

  • 问题:价格计算逻辑分散在订单、营销、商品三个域
  • 重构:新建计价上下文,提供统一试算接口
  • 收益:价格一致性得到保证,营销规则变更只需在营销域发布事件

案例2:库存预占的归属

  • 争议:库存预占应该放在订单域还是库存域?
  • 决策:放在库存域
  • 理由:库存域拥有库存数据所有权,预占是库存的一种状态,订单域只需调用库存域的 Reserve 接口

案例3:防腐层保护领域模型

// 供应商响应模型(外部)
type SupplierFlightResponse struct {
    Code    string
    Message string
    Data    struct {...}
}

// 平台库存模型(内部)
type StockResponse struct {
    Available bool
    Quantity  int
    Message   string
}

// 防腐层:翻译外部模型 → 内部模型
func (a *FlightSupplierACL) TranslateStock(supplierResp) *StockResponse {
    // 领域层不被供应商模型污染
}

5. 高可用需要多层防护

层级措施工具/技术
应用层服务多副本、自动扩容Kubernetes HPA
接口层熔断、降级、限流gobreaker、Feature Flag
缓存层多级缓存(本地+Redis+DB)BigCache + Redis
数据层主从复制、读写分离MySQL Replication
机房层多机房部署、灰度发布Multi-Region + Canary

稳定性三板斧

  • 熔断:供应商调用失败率>50%,熔断10秒
  • 降级:Marketing Service故障,降级为基础价
  • 限流:令牌桶算法,QPS=500

6. 供给运营是平台的核心能力(新增34.5.6)

三种核心场景

场景业务语义处理逻辑审核策略
商品上架新商品首次进入平台Create完整审核流程
供应商同步供应商数据变更Upsert差异化审核
运营编辑已上线商品维护Update差异化审核

设计要点

  • 幂等性保证:task_code唯一索引(上架)、sync_id唯一索引(同步)
  • 差异化审核:高风险变更(价格变化>50%、类目变更)必须审核
  • 批量操作:异步任务 + 进度追踪(100+ SKU批量编辑)
  • 状态机:DRAFT → PENDING → APPROVED → PUBLISHED
  • 与商品中心集成:审核通过后写入商品中心、初始化库存/价格

7. C端交易流贯穿整个业务链路(新增34.5.7)

五个阶段完整设计

搜索(Query理解+ES召回+Hydrate)
   ↓ 转化率 > 15%
详情页(多服务聚合+快照生成)
   ↓ 转化率 > 8%
购物车(未登录加购+登录合并+双写)
   ↓ 转化率 > 30%
结算页(价格试算+库存检查+优惠校验)
   ↓ 转化率 > 60%
下单支付(Saga编排+实时查询+价格校验)
   ↓ 转化率 > 85%

关键技术

  • Hydrate编排:并发调用4-5个服务(Product、Inventory、Pricing、Marketing)
  • 快照机制:详情页生成快照(5分钟TTL),结算页可选使用(性能优先)
  • 购物车合并:未登录Redis存储,登录后合并到用户购物车
  • Saga编排:下单时依次执行库存预占、优惠券锁定、价格计算、订单创建
  • 强制实时查询:创单时不使用任何快照(ADR-009,安全优先)

8. DDD战术设计落地实践(新增34.5.8)

Order聚合根设计

// 聚合根
type Order struct {
    orderID OrderID          // 值对象(聚合根ID)
    items   []*OrderItem     // 实体集合
    pricing *OrderPricing    // 值对象
    status  OrderStatus      // 值对象
    domainEvents []DomainEvent // 领域事件
}

// 值对象:OrderID(不可变)
// 值对象:OrderPricing(无ID,通过属性比较相等性)
// 实体:OrderItem(有ID,可变)

Repository + Outbox模式

  • Repository接口在领域层定义(不依赖基础设施)
  • 领域事件与业务在同一事务(Outbox表)
  • Outbox轮询器:定时扫描未发布事件,发布到Kafka

领域事件

// OrderStatusChangedEvent(订单状态变更)
// OrderItemAddedEvent(商品项添加)
// OrderCreatedEvent(订单创建)

9. 团队协作与技术治理同等重要

康威定律实践

订单团队(15人)→ 订单服务
商品团队(12人)→ 商品中心
库存团队(10人)→ 库存服务
...

契约测试加速并行开发

  • ✅ 上下游团队定义API契约(OpenAPI/Proto)
  • ✅ 消费者编写契约测试(Pact)
  • ✅ 提供者验证契约(契约测试通过后联调)
  • ✅ 契约变更影响提前发现(CI自动运行)

技术治理机制

  • ✅ ADR记录重大决策
  • ✅ 代码评审清单(架构、设计、代码、测试)
  • ✅ 技术债管理(优先级、负责人、工作量)
  • ✅ 定期架构Review(每季度)

实战价值

本章不是空洞的理论,而是200+人团队、日订单200万级的真实实践总结:

成功经验(值得借鉴):

  1. ADR制度:让架构演进有据可查,新人快速上手
  2. 品类差异化:策略模式让新品类接入成本降低80%
  3. 聚合编排:API Gateway职责单一,性能优化空间大
  4. 多级缓存:P99延迟从500ms降低到200ms
  5. 契约测试:团队并行开发,集成测试成本降低70%

踩过的坑(避坑指南):

  1. 过早引入Event Sourcing:团队理解不足,查询复杂,运维困难
  2. 供应商接口未做熔断:某供应商故障,整个订单服务不可用
  3. 分库分表过早:订单量100万就分库分表,运维复杂度激增
  4. 忽视数据一致性对账:库存预占后未释放,累积1个月后严重不准确
  5. 缓存穿透导致雪崩:恶意请求查询不存在的商品,数据库连接池耗尽

改进方向(持续演进):

  • 短期(3个月):性能优化、稳定性提升、开发效率
  • 中期(6-12个月):智能化、国际化、数据驱动
  • 长期(1-3年):平台化、技术创新

与其他章节的关系

本章是全书知识点的综合应用与实践验证:

前置章节在本章的应用
第 1 章(架构方法论)Clean Architecture分层、DDD战略设计(34.5.8)、CQRS读写分离
第 1 章(领域驱动设计)12个限界上下文划分、上下文映射、防腐层(34.6.4)
第 2 章(代码整洁)策略模式(品类策略)、适配器模式(供应商集成)、SOLID原则
第 7 章(架构质量保障)ADR、代码评审清单、测试策略
第 25 章(商品中心)SPU/SKU模型、类目属性、商品快照
第 27 章(库存系统)二维库存模型、预占机制、超时释放(34.5.2)
第 28 章(营销系统)营销规则引擎、优惠券锁定、最优解求解
第 26 章(供给运营)商品上架、供应商同步、运营编辑(34.5.6 新增)
第 29 章(计价系统)四层价格模型、试算接口、快照生成
第 30 章(搜索导购)Query→Recall→Rank→Hydrate链路(34.5.7 新增)
第 31 章(购物车结算)未登录加购、登录合并、Saga编排(34.5.7 新增)
第 32 章(订单系统)状态机、Saga模式、幂等性(34.5.7、34.5.8 新增)
第 33 章(支付系统)支付创建、回调处理、对账流程

后续演进提示:如果后续继续扩展本书,可以在本章基础上继续展开系统演进与重构、团队协作与工程实践等主题。


给读者的建议

  1. 不要盲目照搬架构

    • 根据团队规模调整(10人团队不需要12个微服务)
    • 根据业务特点优化(B2C和B2B2C差异大)
    • 根据发展阶段选择(初创期先单体,成熟期再拆分)
  2. 架构是演进出来的

    • 先简单方案(单体应用、MySQL单表)
    • 再根据瓶颈优化(QPS瓶颈→缓存,数据量瓶颈→分库分表)
    • 避免过度设计(YAGNI原则:You Aren’t Gonna Need It)
  3. ADR是宝贵财富

    • 记录决策过程(不只是结果)
    • 记录备选方案(为什么不选A而选B)
    • 记录权衡取舍(有什么优点和缺点)
    • 定期Review(每季度回顾,持续优化)
  4. 从错误中学习

    • 本章的“踩过的坑“是避坑指南
    • 建立错误知识库(每个错误都是学习机会)
    • 持续改进(错误 → 规则 → 自动化检查)
  5. 关注业务价值

    • 技术服务于业务(不是为了炫技)
    • 优先解决业务痛点(性能瓶颈、稳定性问题)
    • 量化技术收益(P99延迟降低、QPS提升、成本节省)

关键数据回顾

指标数值说明
团队规模200+人前台60、中台80、基础设施30、数据20、测试10
日订单量200万(正常)/ 1000万(大促)大促5倍流量
服务数量12个核心服务 + 3个聚合服务按业务能力拆分,单一职责
ADR数量13个记录重大架构决策
响应时间P99 < 200ms(正常)/ 500ms(大促)多级缓存优化
可用性99.95%(核心链路)多层防护
代码覆盖率> 80%单元测试 + 集成测试

第 35 章 电商商品供给、库存、审核与运营全生命周期设计

本章定位:这不是对“商品中心”“供给平台”“库存系统”三篇文章的简单拼接,而是站在平台生命周期视角,把商品如何进入平台、如何被治理、如何形成正式交易契约、如何建立可售库存、如何持续编辑和运营、以及如何与搜索、营销、订单、履约保持一致,收敛成一条完整主线。

如果前面的专题章节分别回答的是“某个系统内部怎么设计”,这一章回答的则是:

  1. 平台到底在处理哪些高频使用场景。
  2. 这些场景为什么不能用一个后台 CRUD 搞定。
  3. DraftStaging、正式商品、库存事实、审核状态、发布版本应该分别放在哪里。
  4. 为什么库存运营入口和库存事实归属必须分离。
  5. 为什么真正难的不是建几张表,而是跨系统一致性、任务化、幂等、补偿和资损防控。

建议配合以下章节交叉阅读:


1. 核心使用场景:系统到底在处理什么问题

很多团队一开始会从系统模块讲起:商品中心一章、库存系统一章、审核后台一章、供应商同步一章。这样讲虽然清楚,但读者很容易失去真正的主线,因为业务里遇到的从来不是“我现在只在操作商品中心”,而是:

一个商品从创建、审核、发布、补库存、改标题、供应商同步、搜索刷新到订单履约,会跨越多少系统边界,以及这些边界怎么保证不打架。

1.1 商品创建场景

商品创建并不是单一操作,而是至少五类不同来源的组合:

场景典型触发方特征体验要求系统重点
本地运营手工创建平台运营低频、强交互、可信来源同步保存、秒级回执Draft、强校验、自动准入
商家后台单品上传商家数据质量波动大同步提交、异步审核默认 QC、素材校验、风控
Excel 批量导入运营 / 商家高吞吐、行级失败快速返回任务 ID任务化、错误文件、重试
API / ISV 推送建品ERP / 开放平台中高频、天然重试先确认接收,再异步完成幂等键、回调、批次治理
供应商首次同步建品第三方供应商海量、离线、外部不稳定纯异步Checkpoint、Mapping、归一化

这些场景共同逼着系统回答几个问题:

  1. 手工创建和批量导入能否走同一条治理主线。
  2. 供应商同步是“创建”还是“Upsert”。
  3. 外部来源的数据先放哪里,什么时候才算正式商品。
  4. 审核和发布失败后如何定位到具体行、具体字段、具体来源。

但真正落到业务现场时,“创建商品” 往往并不只是在建一条商品记录,而是在同时决定这个商品如何建立可售库存能力。尤其在酒旅、到店餐饮、景区票务和本地生活券场景里,商品创建常常天然伴随着库存创建。

典型地会出现 3 类组合场景:

场景商品创建时的库存动作库存真相来源系统重点
纯数字库存商品手工填写初始库存数字数量创建商品时同步初始化库存
系统动态发券商品手工填写初始库存数字,并声明发券规则数量 + 券码生成规则商品创建时同步建立库存与发券策略
外部死码池商品先声明 EXT_POOL 模式,后续导入券码 Excel券码池商品创建与券码导入分阶段完成

这意味着系统在创建商品时,至少还要回答两个额外问题:

  1. 创建商品时,是否必须同步声明 inventory_mode
  2. 商品创建和库存初始化,是不是要被收敛到同一个供给任务里。

也正因为如此,后面第 3.7 节讨论的“库存入口设计”,并不是独立于商品创建存在的附属能力,而是商品供给主链路的一部分。

1.2 商品编辑场景

商品一旦上线,编辑链路往往比创建链路更复杂,因为它直接影响线上交易和历史一致性。

场景典型触发方是否影响线上存量商品常见风险推荐模式
运营手工改单品运营后台标题、类目、价格误改变更请求 + 审核发布
商家批量编辑标题 / 属性商家批量污染、类目错挂批量任务 + 行级校验
供应商增量同步更新供应商覆盖人工改动、脏数据反写差异识别 + 策略路由
高频价格 / 库存变更ERP / 自动化系统生效滞后、重复覆盖局部旁路 + 幂等更新
风控 / 合规强制下架法务 / 风控即时停止可售平台裁决直达正式态

这里最关键的分歧不是“能不能改”,而是:

  • 哪些字段必须先进 Staging 再发布。
  • 哪些字段可以走快速旁路。
  • 哪些变更必须保留审批与审计链路。
  • 供应商同步和人工编辑冲突时谁优先。

1.3 库存运营场景

库存不是一个孤立的“数字服务”,B 端运营视角里它至少包含以下场景:

场景典型对象目标风险归属建议
初始化库存新商品 / 新 SKU建立首批可售能力初始配置错误供给平台发起,库存系统落账
补货 / 扣减 / 调整数量库存改变可售量超卖 / 错减运营工作台 + 库存命令
券码导入卡密 / 礼品卡 / 券类增加唯一资源池泄露 / 重复导入批量任务 + 码池账本
系统生码平台生成券码自动补足供给生成失败 / 碰撞异步批任务
锁库存 / 解锁库存大促 / 风控 / 活动临时冻结可售漏解锁显式命令 + 审计
门店 / 日期 / 批次调整O2O / 酒店 / 票务精细化承诺维度错配范围化建模

库存场景的难点不是表单操作,而是:

  • 库存运营入口要不要直接放在库存系统。
  • 数量库存和券码库存能否共用一个模型。
  • Redis 里的热数据是不是事实来源。
  • 订单预占、支付确认、超时释放怎么和库存账本对齐。

1.4 生命周期治理场景

治理场景决定了这个系统是不是“平台”:

场景目标为什么重要
提交审核把编辑结果送入准入链路防止半成品直接污染线上
自动准入让低风险变更快速生效降低人工成本,提升运营效率
人工 QC人工兜底高风险变更处理类目、素材、合规风险
驳回 / 撤回 / 重提形成运营闭环让失败可修复、可追踪
发布 / 下架 / 归档管理线上资产状态区分流程态与正式态

这部分要解决的核心问题是:流程状态、审核状态和正式商品状态能不能混在一起。答案通常是否定的。

1.5 供应商协同场景

供应商同步既是供给入口,也是持续运营的一部分:

场景特征关键能力
全量拉取海量、慢、成本高分片、快照、断点续跑
增量同步高频、可重复指纹、幂等、对齐
Push 回调外部主动通知签名校验、重放防护
人工刷新定向修复单资源重跑、人工兜底

供应商协同最大的工程难点不是“能不能接上接口”,而是:

  • 供应商数据是不是可以直接覆盖正式商品。
  • 同步失败后如何知道哪一批、哪一页、哪个资源出问题。
  • 供应商变更和人工编辑冲突时谁说了算。

1.6 场景到系统问题的映射

场景类型典型问题
商品创建入口统一、同步/异步分层、草稿归属、批量错误隔离、创建商品时是否同步创建库存
商品编辑差异化审核、线上版本保护、供应商冲突治理
库存运营入口与事实分离、账本可追溯、预占释放一致性、库存模式路由
生命周期治理状态分层、审计留痕、重提与补偿闭环
供应商协同幂等、断点续跑、数据质量评估、Mapping 管理
发布协同版本化发布、Outbox、搜索/缓存/订单最终一致

2. 整体方案设计

这一节按照“先看系统边界,再看主链路,最后看关键决策”的顺序展开。先把商品主域、供给平台、库存系统、营销、搜索和订单各自的职责划清楚,再把这些系统放进一条完整的生命周期主链路中理解,最后集中讨论 Draft / Staging、同步 / 异步、资产归属和库存边界这些关键设计选择。

2.1 系统的边界和职责

系统负责什么不负责什么
商品主域(写侧)可审核、可发布的 Draft / Staging、正式商品主数据、交易前契约、发布版本、商品快照商家接入、文件导入界面、错误文件分发、供应商抓取编排
供给与运营平台入口、标准化、Task、Validation、QC 工作台、发布编排、运营工作台库存最终事实、搜索索引直写、订单状态维护、正式商品资产主权
库存系统库存事实、预占、确认、释放、账本、券码池商品标题、类目、审核流、营销规则
营销系统活动、圈品、预算、优惠规则、营销库存商品正式发布事务、库存总账
搜索系统索引、召回、排序、可检索投影商品发布事务、库存事实
订单系统订单状态、商品/报价/履约快照引用最新商品配置维护

边界划分里最重要的三条原则是:

  1. 供给平台负责接脏数据、洗数据、做任务和审核编排,但不持有正式商品资产主权。
  2. 商品主域写侧负责可审核、可发布的草稿资产、版本冻结和正式落库闭环。
  3. 库存系统负责库存事实和账本,不负责 B 端商品编辑与审核流程。

2.2 全生命周期主链路总览

flowchart LR
    A["供给入口"] --> B["Draft / Staging"]
    B --> C["标准化与校验"]
    C --> D["Diff / 风险识别"]
    D --> E["QC / 自动准入"]
    E --> F["发布到商品主域正式态"]
    F --> G["库存 / 营销 / 搜索 / 计价协同"]
    G --> H["订单使用快照交易"]
    F --> I["Outbox 下游投影"]
    I --> J["巡检 / 对账 / 补偿"]

这条主链路表达的是统一供给治理平台的基本职责:

  1. 所有供给动作先进入 Draft / Staging,而不是直接污染正式商品。
  2. 标准化、校验、Diff、风险识别和审核,构成正式发布前的质量门禁。
  3. 发布之后不是“流程结束”,而是进入库存、营销、搜索、计价和订单快照的协同阶段。
  4. 最终通过 Outbox、巡检、对账和补偿保证一致性,而不是要求一次同步调用把所有下游都写成功。

2.3 决策点 1:为什么不能继续在原商品系统上打补丁,而是需要新增 Draft

场景与问题

很多团队在系统演进早期都会问一个问题:既然原来已经有商品系统,为什么不继续在原表上加几个字段、补几个状态、加几段审核逻辑,而是要显式引入 Draft 表甚至新的写模型?

短期当然可以继续打补丁,但问题是业务模型已经变了。早期很多商品系统主要承载的是自运营的相对简单的配置型商品,商品本身更像一个后台配置对象;后面商品开始承载外部供给接入、审核流程、库存策略、履约规则、退款口径、供应商同步和版本化发布之后,它已经不再只是“静态配置”,而是在向“可治理的交易资产”演进。

如果继续把这些新能力都硬塞进原商品系统的单一正式表或单一状态字段里,短期看似节省改造成本,长期却会持续累积三类问题:

  1. 边界继续塌陷

    原商品系统原本只负责正式商品表达,如果继续往里塞草稿、审核、导入状态、供应商同步中间态,它就会同时承担接入层、流程层和资产层职责,后面每接一个新品类或一个新供给来源,复杂度都会继续上升。

  2. 正式资产被流程态污染

    一旦把“待审、驳回、文件解析中、待补图、供应商刷新中”这些状态混进正式商品表,商品主域的语义就不再稳定,搜索、缓存、计价、订单也更容易误读这些半成品或中间态。

  3. 演进成本越来越高

    继续打补丁的本质是拿局部 if/else 和临时字段去扛新的业务模型。前期似乎更快,但后期每增加一种商品类型、每新增一种审核策略、每接一种库存模式,都要在旧结构上继续叠复杂度,风险和维护成本只会越来越高。

所以这次改造的本质不是简单重构代码,而是边界重划:把正式商品、可发布草稿、流程任务、审核工作台、库存事实这些不同层次的数据重新分层。也正因为如此,系统才需要显式引入 DraftStaging 这类新写模型,而不是继续在原商品表上追加字段和状态。

方案对比

方案优点缺点 / 风险适用场景推荐结论
方案 A:继续在原商品系统上打补丁短期改造快;不需要新增模型和表边界塌陷;流程态污染正式态;复杂度滚雪球增长早期、简单、低变化系统过渡方案
方案 B:新增 Draft 表和独立写模型语义分层清晰;便于审核、发布、版本冻结和演进初期改造成本更高中长期演进、复杂商品平台推荐

推荐方案

推荐方案 B。判断标准不是“新增表是不是更优雅”,而是业务模型是否已经发生质变。

一旦商品开始承载供给接入、审核治理、库存协同、履约规则和版本化发布,原商品系统就不再适合只靠补字段和补状态来演进。此时新增 Draft 表,本质上是在承认“正式商品”和“待治理草稿”是两种不同层次的数据,应该被显式建模。

2.4 决策点 2:Draft 属于供给平台还是商品主域写侧,QC 挂在哪层快照上

场景与问题

这是整个商品生命周期架构里最容易引发争论的一个决策点。按业务流程直觉看,商家和运营是在供给平台改商品,所以草稿似乎天然应该留在供给侧;但从资产主权、事务边界、版本冻结和长期演进看,Draft 又更像商品主域内部的未生效版本。

这个问题不能只看“草稿表放哪里”,还必须连着 QC 链路一起看。因为真正的架构问题不是:

Draft 在供给还是在商品中心?

而是:

可审核、可发布的草稿资产归谁持有,QC 又应该挂在哪一层冻结快照上做裁判?

如果 Draft 留在供给侧,QC 往往也只能围绕供给侧草稿运行,链路就会自然变成:

供给(draft)
  → QC
  → 商品正式态

如果 Draft 已进入商品主域写侧,QC 更合理的形态则是:

供给接入
  → 商品主域 Draft / Staging
  → QC
  → 本地 Merge 到正式 Item

这两条链路看起来只差了一个步骤,底层却是两种完全不同的资产主权和版本控制模型。

这里先给出结论,再展开论证:

如果 Draft 只是浏览器表单保存、文件上传中的临时缓存,它可以短暂停留在供给侧;但一旦 Draft 进入待审核、待发布、可版本冻结、可与正式商品合流的阶段,它本质上已经属于商品主域的写模型,而不应该长期留在供给接入层。

换句话说,这一节讨论的不是“前端临时草稿存哪里”,而是“可审核、可发布的商品草稿资产归谁持有”。

方案 A:Draft 留在供给平台

这种方案的出发点非常自然:供给平台负责对接商家、运营、供应商和 ERP,商品变更从这里进入,审核工作台、批量任务、错误文件也在这里,于是团队很容易顺着流程心智把 Draft 一起留在供给层。

在这种设计下,QC 也通常会变成“前置流式审核”,即先在供给侧审核,再把通过后的 DTO 推给商品主域落正式态。

优点
  • 业务流程看起来更顺,商家上传、审核流、任务进度都集中在一个平台。
  • 供给平台天然适合承接脏数据、错误文件、批量导入和人工修复。
  • 对早期团队来说,上线速度快,不需要商品主域一开始就承接完整的草稿写模型。
缺点 / 风险
  1. 资产主权分裂

    同一个商品会被拆成两份核心表达:供给平台里的草稿资产,以及商品中心里的正式资产。草稿和正式商品其实只是同一资产的不同阶段,却被两个服务分别持有,后续版本控制、冲突治理和审计解释都会变复杂。

  2. 发布合流链路跨服务,放大批量窗口风险

    当 QC 判定通过时,系统需要把草稿 merge 到正式商品。如果草稿在供给、正式表在商品中心,发布就必须走跨服务 RPC 或异步命令。单次发布未必是灾难,但一旦进入凌晨供应商大批量更新、大促前批量调价、酒店政策全量同步等场景,发布链路时延、锁竞争、失败点和重试成本都会明显放大,也更容易在批量窗口冲击商品主域写链路。

  3. 类目属性契约和校验逻辑容易进入“双写地狱”

    商品草稿模型与正式模型往往高度同构:类目、属性、多规格 SKU、阶梯价、履约规则、退款规则都要表达。商品主域一旦新增类目契约、修改必填属性或调整校验规则,供给平台就很容易被迫同步修改草稿表结构和复制一套校验逻辑。久而久之,两个服务会演变成披着接口外衣的伪单体。

  4. 版本冻结与防篡改更难做

    如果审核看的数据在供给侧,而最终发布动作发生在商品主域,审核中途还要面对商家、运营、供应商同步并发修改同一商品的问题。理论上可以通过版本号、快照和锁机制解决,但复杂度明显高于把可发布草稿直接内聚在商品主域本地。

  5. QC 更难锁定真正的裁判快照

    这是这条链路最微妙、也是最危险的问题。QC 在供给侧看到的是版本 A,但在审核通过到跨服务发布的窗口里,供给侧草稿可能已经被另一个并发修改线程推进到版本 B。最终就可能出现“审的是 A,发的是 B”的时空撕裂问题。

  6. 多版本乱序容易导致线上数据回滚

    商家短时间连续提交多个版本时,机审秒过的版本 B 可能先发布,而人工审核较慢的版本 A 后通过。如果发布动作只是“审核通过后把 DTO 再推给商品主域”,旧版本 A 就可能在更晚的时间反向覆盖新版本 B,形成线上数据时间倒流。

适用场景
  • 早期系统
  • 类目简单、审核弱、供应商同步不重
  • 商品主域还没有成熟的写模型和发布版本体系

方案 B:Draft 收拢到商品主域写侧

这里的“放在商品中心”需要说得更精确一些:不是把 Draft 直接放进 C 端线上 product_item 表,而是放进商品主域自己的 draft / staging / version / publish merge 写模型中。商品主域对这份草稿资产拥有版本冻结、发布合流和正式落库的主权。

在这种设计下,QC 不再审核供给平台里那份可变草稿,而是基于商品主域已经落库、已经冻结版本号的 Draft / Staging 快照做裁判。QC 回传的也不再是完整商品 DTO,而只是轻量的判决结果,例如:

{
  "draft_id": 555,
  "publish_version": 102,
  "result": "PASS"
}
优点
  1. 资产主权统一

    无论是线上售卖商品,还是待审核的未生效版本,本质上都是同一份商品资产的不同形态。把它们收在商品主域写侧,领域边界最稳定。

  2. 合流可以本地事务闭环

    product_draft_tabproduct_staging_tabproduct_item_tabpublish_version 都在商品主域本地时,QC 通过后的 merge 可以在本地事务内完成。这样发布路径更短,也更容易做版本校验、幂等发布和失败回滚。

  3. 版本冻结与快照锁定更自然

    供给平台把数据清洗成标准 DTO 后推给商品主域,商品主域将其落成 DraftStaging 并生成版本号。QC 审核的是这份冻结后的本地快照,而不是仍然暴露在接入层并发修改风险中的动态数据。

  4. 契约和校验逻辑只维护一份

    类目、属性、SKU、Offer、履约、退款等商品主契约只需在一个主域里演进,避免供给和商品两边长期维护两套同构模型。

  5. 发布事件与读模型刷新更容易统一

    正式商品落库、快照生成、Outbox 写入、搜索/缓存/计价/营销投影刷新都可以由商品主域统一发起,链路更可解释。

  6. QC 能基于冻结快照做真正的版本裁判

    数据一旦进入商品主域落成 DraftStaging,系统就可以为它分配明确的业务版本号,并在送审期间锁定这份快照。商家若继续修改,必须生成新的版本而不是覆写旧版本。这样 QC 审核的是确定版本,发布的也是同一个确定版本。

  7. QC 回传 verdict,商品主域本地事务完成 merge

    QC 审核通过后,不需要再跨服务传一份巨大的商品 DTO 回商品主域,只需要回传“哪一个草稿版本审核通过”的轻量判决。商品主域随后在本地事务里将该版本 merge 到正式 Item,并统一完成快照、Outbox、缓存刷新等后续动作。

缺点 / 风险
  1. B 端写压力会向商品主域集中

    零散保存、批量导入、供应商同步、错误重提等写流量都会压到商品主域写侧。

  2. 商品主域会承接更多流程复杂度

    如果团队边界没划清,商品主域很容易被误用成“供给后台本体”,把本该属于供给平台的任务、审核工作台、文件处理逻辑全堆进来。

化解施策

方案 B 的关键不是“把所有东西都塞进一个商品服务”,而是:

  • 供给平台继续负责入口、清洗、任务、审核工作台、供应商同步编排。
  • 商品主域只负责可审核、可发布的草稿资产,以及正式落库闭环。
  • product_draft_tabproduct_item_tab 物理隔离,避免草稿写洪峰污染线上正式资产。
  • 条件成熟时,可以拆成 product-writerproduct-reader:前者承接草稿、审核、发布;后者只服务 C 端读流量和缓存。
适用场景
  • 中大型平台
  • 类目复杂、供应商同步重、审核和版本治理要求高
  • 希望长期保持商品主权统一的系统

方案对比

方案优点缺点 / 风险适用场景推荐结论
方案 A:Draft 留在供给平台,QC 前置审核流程直觉强;任务与审核工作台集中;早期交付快资产主权分裂;发布跨服务;契约双写;QC 难锁定冻结快照;易发生版本乱序覆盖早期系统、低复杂度业务过渡方案
方案 B:Draft 收拢到商品主域写侧,QC 基于本地快照裁判主权统一;本地事务 merge;版本冻结自然;契约单一演进;QC verdict 轻量回传写压力上移;需要 writer/reader 与冷热隔离中大型平台、长期治理推荐

推荐方案

推荐方案 B,但需要准确理解为:

Draft 不应该长期留在供给接入层,也不应该直接混进 C 端线上商品表,而应该归商品主域写侧管理。

供给平台的定位更像“流量入口、数据加工厂与流程编排层”:

  • 负责接外部脏数据
  • 负责把数据清洗成标准 DTO
  • 负责任务、审核工作台、错误文件和供应商同步流程

而商品主域才真正负责:

  • Draft / Staging / Version
  • 版本冻结与防篡改
  • QC 裁判快照锚定
  • 本地事务 merge
  • 正式商品落库
  • Outbox 与下游投影事件

因此,更完整的架构判断不是“Draft 属于供给平台还是商品中心”二选一,而是:

  • 入口态临时数据 可以短暂停在供给侧
  • 可审核、可发布的草稿资产 应归商品主域写侧
  • QC 应该裁判商品主域中已经冻结版本的快照

这也是为什么在平台化电商架构里,真正成熟的设计通常都不会让供给平台长期持有商品草稿的最终主权。

2.5 决策点 3:是否需要 Staging,单 Draft + Operation Log 是否可行

场景与问题

当团队决定引入 Draft 之后,下一步一定会遇到两个高度关联的问题:

  1. 只有 Draft 和正式 Item 两层够不够,是否还需要单独的 Staging
  2. 如果想尽量轻量化,单 Draft + Operation Log 能不能替代 Draft + Staging + Item 三层模型。

这两个问题最好放在一起讨论,因为它们本质上都在回答同一件事:

商品写模型到底需不需要三层隔离,以及单草稿方案的边界在哪里。

这里先给出一个压缩结论:

  • Draft 解决的是“脏数据吸收、审核沙盒、并行编辑”。
  • Staging 解决的是“已通过 QC 的干净版本如何等待生效、切渠道、做回滚”。
  • Operation Log 适合作为单草稿方案的差分增强,但它不能完全替代 Staging 在时间轴、渠道隔离和回滚上的作用。

DraftStaging 的职责差异

维度DraftStaging
核心语义正在编辑、待治理的工作副本已通过 QC、待激活的静态快照
数据纯净度可能仍在被修改,存在未审核内容100% 已过审,版本已冻结
解决的问题审核沙盒、并行编辑、脏写隔离定时生效、多渠道隔离、快速回滚
是否面向 C 端当前不可见,但可视为“下一任合法继承人”

如果没有 Staging,系统就只能在“审核通过后立刻发布”和“继续停留在 Draft 里等待后续动作”之间二选一,这会让定时生效、多渠道未来版本和回滚变得很别扭。

方案 A:只有 Draft,无 Staging

优点
  • 模型简单,表和状态少。
  • 审核通过后直接发布,链路短。
  • 对小系统或低变更频率系统来说,心智负担低。
缺点 / 风险
  1. 已提交版本和正在编辑版本容易混淆

    如果没有 Staging,系统很难清晰表达“这份数据已经通过 QC,但暂时还不应该生效”的状态。

  2. 定时生效和渠道隔离能力弱

    大促零点变价、不同渠道未来版本、供应商已过审但待外部确认的场景,都需要一个“已过审但未激活”的物理缓冲区。

  3. 回滚确定性弱

    没有 Staging 的物理快照,回滚通常要依赖历史版本重算、日志回放或脚本修复。

适用场景
  • 小团队
  • 商品结构简单
  • 几乎没有定时生效、多渠道隔离和秒级回滚要求

方案 B:单 Draft + Operation Log

这是很多中型系统会选择的折中方案:一个商品只保留一个 Draft,再配套 Operation Log / Diff Map 记录价格、标题、库存等字段差分。

优点
  • 比三层模型更轻量,只需要 Item + Draft + Operation Log
  • 某些低风险变更可以直接 patch 当前草稿,避免“一个商品只能改一次”的业务阻塞。
  • 存储成本、索引成本和模型心智都比较低。
缺点 / 风险
  1. 多任务审核容易撕裂

    标题改动需要人工审核 3 小时,价格改动 100ms 秒过,如果它们都 patch 到同一个 Draft 上,价格就无法安全独立发布。

  2. 激活时需要动态归并日志

    没有 Staging 的完整待发布快照,系统只能在生效瞬间做 Operation Log 的 Reduce 和字段级 patch,这会把计算压力带到高峰时刻。

  3. 多渠道隔离与一键回滚能力弱

    Draft 很难同时表达多个未来渠道版本;一旦线上数据洗脏,回滚通常要依赖逆向回放 Operation Log,确定性弱于直接切回上一个 Staging 版本。

适用场景
  • 中等复杂度系统
  • 审核策略比较一致
  • 定时生效和多渠道未来版本需求不强

方案 C:Draft + Staging + Item

优点
  • Draft 负责吸收脏写流量和并行编辑,Staging 负责沉淀通过 QC 的完整待发布版本。
  • 多个 Draft 可以并行加工,但最终只在 Staging 中形成有序的未来版本时间轴。
  • 到激活时不再做复杂归并,而是把已经组装好的快照本地 merge 到 Item
  • 支持定时发布、多渠道隔离、灰度切换和秒级回滚。
缺点 / 风险
  • 物理数据副本更多,模型更重。
  • 需要额外的激活调度、版本治理和清理策略。
  • 对团队的数据建模要求更高。
适用场景
  • 中大型平台
  • 审核存在快慢混合链路
  • 大促定时变价、跨渠道供给隔离较多
  • 需要高确定性的回滚和版本切换

方案对比

方案优点缺点 / 风险适用场景推荐结论
方案 A:只有 Draft,无 Staging简单、状态少、上手快难表达“已过审待生效”;定时发布和渠道隔离弱小系统有条件可用
方案 B:单 Draft + Operation Log轻量、节省存储、开发快多任务审核撕裂;激活时要动态归并;回滚能力弱中等复杂度系统可行但有天花板
方案 C:Draft + Staging + Item状态分层清晰;激活轻量;支持多版本、多渠道、回滚模型更重,存储更多中大型供给中台推荐

推荐方案

推荐方案 C 作为复杂供给中台的目标形态,但不是所有系统第一天就必须上三层模型。

更务实的判断标准是:

  • 如果系统当前仍以单渠道、弱审核、低频定时发布为主,方案 B 完全可以作为高性价比方案。
  • 一旦系统开始出现多路并行修改、价格和图文审核时效分裂、大促零点定时生效、跨渠道未来版本和秒级回滚要求,就应从单 Draft 进化到 Draft + Staging + Item

换句话说,Staging 不是“为了优雅而优雅”的抽象,而是当系统需要时间轴、渠道隔离和高确定性回滚时,最自然的一层写模型。

2.6 决策点 4:同步体验和异步任务如何分层

方案优点缺点 / 风险适用场景推荐结论
方案 A:全部同步处理用户感知简单批量导入、供应商同步会拖垮接口小流量、低复杂度不推荐
方案 B:全部异步任务化统一执行模型单品创建体验差,交互成本高内部工具型系统不推荐
方案 C:单品同步体验,批量与长链路异步任务化体验与治理平衡需要双模式编排平台型业务推荐

推荐方案是方案 C:表单手工创建、低风险单品编辑保留同步交互;批量导入、批量编辑、券码导入、供应商同步全部任务化。

2.7 决策点 5:单商品创建也需要写入任务表吗

场景与问题

这个问题在很多团队里都会被直觉性地处理成“单品同步接口就直接写数据库,不要引入任务表”,因为从前端交互看,运营或商家只是在页面上点一次提交,系统秒级返回,看起来不像一个需要任务化治理的场景。

但如果把链路往后多看几步,就会发现单商品创建后面仍然会经过:

  • 标准化与校验
  • 交易契约检查
  • 风险识别
  • 送商品主域写侧冻结快照
  • 审核或自动准入
  • 发布编排
  • 下游刷新
  • 失败补偿与审计追踪

如果单品场景完全不落 task,它就会和 Excel、API/ISV 推送、批量编辑形成两套完全不同的排障、审计和补偿口径。

方案对比

方案优点缺点 / 风险适用场景推荐结论
方案 A:单商品创建不写任务表实现最轻;同步链路最短审计不统一;无法统一补偿;发布追踪割裂非常早期、极简后台不推荐
方案 B:单商品创建只写 operation_id,不写 task比方案 A 多一层链路锚点仍然和批量链路分叉;任务视图不统一过渡期系统可过渡
方案 C:单商品创建写轻量 task(total_count=1, execution_mode=SYNC)审计、发布、补偿、状态跟踪统一需要多维护一张轻量任务记录平台型系统推荐

推荐方案

推荐方案 C。单商品创建虽然是同步交互,但后端仍建议写入一条轻量 product_supply_task,并在需要时配一条 task_item。这样可以统一:

  • 入口受理号 receipt_id
  • 操作链路 operation_id
  • 发布记录 publish_record
  • 失败补偿入口
  • 运营侧任务查询与审计口径

这条任务记录不是为了把单品交互“强行异步化”,而是为了让同步体验和长链路治理共用同一套后端锚点。

一句话说,就是:

单商品创建可以同步执行,但不应该脱离任务模型。

2.8 决策点 6:库存配置与库存事实是否放在同一系统

场景与问题

商品上线前常常需要配置库存策略,例如是数量库存、券码库存还是供应商库存;但上线后真正的库存余额、预占和账本又属于交易硬约束。

方案对比

方案优点缺点 / 风险适用场景推荐结论
方案 A:库存配置和库存事实全放商品/供给侧简化初期集成后期库存服务难独立;交易与运营耦合过深早期单体系统不推荐
方案 B:库存配置可在商品/供给链路表达,库存事实由库存系统维护符合职责边界;账本和交易链路更稳需要命令与事件协同平台化和多品类系统推荐

推荐方案

推荐方案 B:库存配置是交易契约的一部分,可以在商品发布时一并声明;库存余额、预占、确认、释放、券码状态机必须由库存系统维护。

2.9 决策点 7:供应商同步的无效流量过滤,应该放在供给侧还是商品中心

场景与问题

供应商同步场景里,经常会遇到一个非常诱人的优化点:既然 100w 级对象里可能有 70%~80% 根本没有实质变化,那么是不是应该在供给侧先基于业务指纹做一轮粗过滤,把无效数据直接蒸发在上游?

这个思路在算力和带宽上很有吸引力,但它会立即引出一个更危险的问题:

供给侧是否真的拥有足够权威的“真相”,来判断一条供应商数据应该被跳过?

如果供给侧维护了一套自己的指纹或状态镜像,而商品中心又是正式商品的唯一真相源,那么一旦出现:

  • 上一轮同步失败
  • 消息在 MQ 中长时间排队
  • 运营在商品中心人工改价或锁定字段
  • 多条链路同时修改同一商品

供给侧就很容易基于一份“过期状态”做出错误过滤,最终把本应进入商品中心重试或更新的数据静默跳过,形成最危险的“丢单式不一致”。

方案对比

方案优点缺点 / 风险适用场景推荐结论
方案 A:供给侧基于业务指纹做过滤上游 MQ、Consumer、RPC 压力更小;看起来更省算力供给侧需要维护商品状态镜像;失败状态和线上真相容易不一致;最容易产生静默跳过和幽灵覆盖真相源就在上游、链路极短、失败模型简单的系统不推荐
方案 B:供给侧忠实搬运,商品中心负责幂等去重与 Diff 裁剪真相源单一;最终判断权收敛到商品主域;不容易出现跨系统状态分裂下游商品中心需要承担更强的幂等和 Diff 压力平台化商品主域、多链路并发更新场景推荐

很多团队在这里还会想到一个“折中优化”:

  • supplier_sync_state_ledger 中增加 hash_sign
  • Consumer 先把本次供应商对象的核心字段做 MD5
  • 如果和 state_ledger.hash_sign 完全一致,就直接在供给侧把对象切成 SKIPPED
  • 不再请求商品中心

这个方案看上去能进一步削减商品中心压力,但它本质上仍属于“由供给侧模拟商品真相”的思路,因此需要单独评估:

方案优点缺点 / 风险适用场景推荐结论
方案 C:在 supplier_sync_state_ledger 中维护 hash_sign,由供给侧先做 hash 过滤能在供应商链路最前面蒸发一部分无变化流量;减少 MQ 后续处理和商品中心反查压力hash_sign 代表的是供给侧曾经看到的状态,不一定等于商品中心当前真相;一旦上次失败、超时未知、运营手工改价、字段锁定或版本演进,就可能在上游形成错误 SKIPPED,导致静默丢单极短链路、真相源几乎就在上游、且下游没有复杂并发来源的系统不推荐作为主方案

因此,对于 hash_sign 这类设计,书里建议明确区分 “观测字段”“裁决字段”

  • 可以把 hash_sign 作为一种辅助观测字段,用来统计“本批次疑似无变化对象占比”、辅助排障和大盘分析
  • 但不建议由供给侧据此直接裁决 SKIPPED

核心原因很简单:

  1. 供给侧的 hash_sign 不代表正式商品当前真相
    它最多只代表“上一次供给链路认为自己处理过什么”。一旦上次写商品失败、超时未知、被运营锁定、被别的链路改过,这个 hash 就可能已经过期。

  2. 供应商视角的“没变”,不等于商品中心视角的“可跳过”
    商品中心还要看:

    • 当前 publish_version
    • 字段主导权
    • 运营锁定状态
    • 是否存在其他来源并发修改
      这些信息都不是供应商同步链路自己能最终裁决的。
  3. 上游错误过滤比多打一条消息更危险
    多打一条 MQ、让商品中心多做一次只读反查,最多是多一点算力开销;但如果上游错误地把一条本该重试或更新的数据切成 SKIPPED,它就会变成最难排查的静默丢单。

不过,这并不意味着供给侧就只能做“纯搬运”,一条无效流量都不能拦。更准确的说法是:

供给侧不能做最终业务去重裁决,但可以做安全过滤。

这里的“安全过滤”有一个很严格的边界:它只能基于 供给链路自身的确定性状态,去切掉那些数学上或流程上绝对无效的流量,不能去猜测“商品中心现在是不是没变”。

推荐保留的安全过滤包括:

  1. 同一批次内的重复页 / 游标漂移过滤

    • 如果同一个 outer_goods_id 在同一 current_batch_id 下已经进入 PROCESSING / SUCCESS
    • 且本次报文 payload_hash 与刚处理过的内容一致
    • 可以安全短路,避免因为供应商翻页重叠导致重复投递
  2. 无映射死链过滤

    • 如果对象状态已经明确是业务阻断态,比如 BLOCKED / UNMAPPED
    • 且错误码是 MAPPING_MISSING / MAPPING_REMOVED
    • 则可以在供给侧直接抑制,不再重复把这类对象送入商品中心
    • 当运营补齐映射时,再通过事件把状态重新拨回 INIT
  3. 并发重投 / 幽灵消息过滤

    • 如果对象已经被另一个 Consumer 成功 CAS 强占到 PROCESSING
    • 当前这条消息就是明显的重投或短暂并发踩脚
    • 可以直接丢弃或延迟重试,而不必重复读 HBase 和反查商品中心
  4. 已明确成功投递过商品中心的数据的重复过滤

    • 如果在 同一批次 内,该对象已经明确完成过一次成功投递,并且当前重入消息与上一次处理内容一致
    • 则可以在供给侧短路,避免把同一条成功消息重复送到商品中心
    • 这里的关键前提是“已经明确成功投递过商品中心”,而不是仅仅因为本地 hash 看起来没变

所以最终边界应该这样理解:

  • 供给侧允许做拓扑级、流程级、安全型过滤
  • 商品中心负责最终业务级、版本级、真相级幂等去重

推荐方案

推荐方案 B:供给侧不承担最终过滤主权,由商品中心自己基于正式真相做幂等去重和 Diff 裁剪;供给侧仅保留安全过滤。

这背后的核心判断是:

  1. 商品中心才是正式商品的唯一真相源

    供应商同步、运营手工编辑、商家后台修改、营销锁价都可能同时作用于同一商品。只有商品中心知道当前线上正式版本、字段锁定状态和最终主导权,因此也只有它最适合做“是否真要写入”的最终裁决。

  2. 上游过滤最怕静默丢单

    一旦供给侧的指纹、状态或失败记录与商品中心不一致,就会出现“上游判定没变,实际下游没成功”的黑洞。这类错误比多发几条 MQ 更危险,因为它既难发现,又直接导致数据永远不同步。

  3. 幂等和去重本来就是商品主域必须具备的底线能力

    即使没有供应商 Pull,商品中心也必须面对:

    • 同一消息重投
    • 多链路并发更新
    • 旧版本晚到
    • 运营人工修改和自动同步打架

    所以把去重主权收拢到商品中心,不是额外负担,而是让商品主域回到它本来就该承担的职责。

设计收束

这条决策最终会把供应商同步链路压缩成一种更稳定的非对称结构:

  • 供给侧:负责串行拉取、快照落盘、状态台账、MQ 投递、任务与批次管理,并只做安全过滤
  • 商品中心:负责基于正式 DTO、publish_version、字段主导权和乐观锁做最终幂等拦截

一句话说:

上游允许做拓扑级安全过滤,但最终业务去重不能离开商品中心。
对供应商同步来说,供给侧只能切掉“绝对无效”的流量,最终是否真要跳过,必须在商品主域的最终写入前裁决。

2.10 决策点 8:订单商品快照应该由订单中心在下单时拍,还是由商品中心提前固化

场景与问题

订单要解决的不是“此刻商品线上长什么样”,而是“用户下单那一刻,平台承诺交易的到底是什么”。

这就自然引出一个高频架构问题:

既然订单最终要把商品快照写进 order_item,为什么不让订单中心在创单时自己实时抓商品、自己组装并保存快照?

这个问题表面上看是“少一张表、少一层对象”的简化,但它背后其实是在问:

  • 商品结构与交易结构的主权应该落在哪个领域
  • 创单链路是否应该承担商品快照组装成本
  • 商品快照到底是“交易临时拼装物”,还是“商品主域提前固化的解释事实”

方案对比

方案优点缺点 / 风险适用场景推荐结论
方案 A:订单中心在下单时实时读取商品并自己拍快照表面上少一层商品中心快照对象;订单侧实现直观商品领域结构泄露到交易域;订单中心必须理解规格、属性、多媒体、类目等复杂字段;创单高峰会把商品中心读压力推到交易链路;订单和商品 DTO 强耦合商品结构极简单、交易量低、无高并发要求的小系统不推荐
方案 B:商品中心在正式发布时提前固化 item_snapshot,订单中心只关联 item_snapshot_id商品主权清晰;交易链路只传结构化标量;创单时不再实时组装复杂商品 DTO;订单解释事实稳定商品中心需要多维护一层快照对象平台型商品中心、复杂商品结构和高并发交易场景推荐

推荐方案

推荐方案 B:商品快照由商品中心在发布时提前固化,订单中心在下单时只引用 item_snapshot_id,不自己拼装快照。

核心原因有 3 个:

  1. 商品结构主权在商品中心,不在订单中心

    商品的结构远不只是标题和价格,往往还包括:

    • 多规格与规格组合
    • 扩展属性
    • 类目树与类目口径
    • 图文与多媒体资产
    • 履约、交易、营销可见字段

    这些结构的业务语义都属于商品域。如果让订单中心自己去“抓商品再拍快照”,商品领域代码就会反向泄露到交易域,最终形成 DTO 强耦合和边界污染。

  2. 交易链路应该传引用,不应该现场组装复杂商品对象

    在高并发下单、秒杀或大促场景里,如果订单中心每次都要实时请求商品中心、拉取完整商品对象、再本地拼成订单商品快照,会把商品中心的读性能直接暴露给交易洪峰。

    相反,如果商品中心在发布时就把 item_snapshot 预先固化好,那么订单中心创单时只需要拿到:

    • item_id
    • item_snapshot_id
    • 当时有效的价格、库存、履约上下文引用

    这样交易链路会退化成结构化标量与引用传递,吞吐量和稳定性都会更好。

  3. 订单解释事实必须依赖已冻结的商品快照

    订单一旦成立,之后无论商品标题、图文、规格文案甚至履约说明怎样变化,订单都必须能回到“下单时平台承诺给用户的那一版商品事实”。

    这个“解释事实”应该是商品中心提前冻结好的 item_snapshot,而不是订单中心事后临时拼出来的一份影子 DTO。

设计收束

因此,这个决策点的最终边界可以这样钉死:

  • 商品中心负责生成和维护 item_snapshot
  • 订单中心只在创单时绑定 item_snapshot_id
  • 订单解释基于快照,不基于“当前线上商品”

一句话说:

商品中心负责定义“当时卖的是什么”,订单中心负责记录“用户买的是哪一版”。
快照应该由商品主域提前固化,而不是由交易域在下单瞬间临时拼装。

2.11 决策点 9:供给、商品、库存、搜索、营销、订单的发布一致性应该如何设计

场景与问题

当商家或运营在 B 端点击“发布”时,真正被改变的往往不只是商品中心一张表,而是整条交易主链路上的多个领域:

  • 供给平台要推进任务和发布状态
  • 商品中心要把 Draft merge 成正式 Item
  • 库存中心可能要更新库存配置或资源路由
  • 搜索域要刷新索引与检索快照
  • 营销域要刷新价格、券适用性或促销上下文
  • 订单域要基于最新版本判断确认页快照是否失效

这里最大的风险不是“某一个服务没更新”,而是 多个服务更新节奏不同

最典型的资损场景是:

  • 商品价格已经上调
  • 营销优惠先发布生效
  • 搜索或商品详情还停留在旧价格
  • 用户用旧价叠加大额券瞬间下单

因此,这个决策点要回答的不是抽象的“是否一致”,而是:

供给、商品、库存、搜索、营销、订单等多域之间,应该采用什么样的发布一致性模型,既保证 B 端发布体验,又死守 C 端资金安全。

方案对比

方案优点缺点 / 风险推荐结论
方案 A:运行时强一致分布式事务(XA / AT / Seata)表面上“一次发布,多域同时成功或失败”发布 RT 极高;跨域长事务锁住多个库;任何一个下游慢都会拖垮整条发布链路;高峰期极易放大为全站雪崩不推荐
方案 B:领域事件驱动的最终一致性发布主链路短;各域解耦;更适合大规模微服务体系必须处理消息至少一次、乱序、延迟和旁路对账推荐

为什么不能用运行时强一致

很多同学在面对“商品、库存、搜索、营销一起发布”时,第一反应是:

用分布式事务把这些服务串起来。

但在真实生产环境里,这几乎一定会出问题。

原因有 3 个:

  1. B 端发布体验会被拖死
    • 发布动作会同步等待商品、库存、搜索、营销全部返回
    • 只要其中一个服务网络抖动或处理变慢,整个运营后台就会卡死
  2. 跨域长事务会把数据库行锁拖进交易高峰
    • 库存、营销、商品等库都可能被长事务锁住
    • C 端下单、查价、扣库存会被 B 端发布操作反向拖垮
  3. 失败点越多,回滚越脆弱
    • 搜索是 ES
    • 营销有缓存和规则引擎
    • 订单有确认页快照
    • 这些本来就不是天然适合 XA 的事务参与者

因此,这里的行业共识应该被明确钉死:

放弃运行时强一致,采用“本地事务 + Outbox 事件广播 + 下游幂等消费 + 版本乱序拦截 + 延迟对账兜底”的发布模型。

推荐方案:基于逻辑版本号驱动的最终一致发布

整套工业级发布一致性链路可以压缩成 4 个动作:

  1. 源头状态机推进
    • 供给平台或商品中心只在本地事务内推进自己的发布状态
    • 同时写入 Outbox 事件
  2. 事务消息广播
    • 通过 MQ / Outbox 扫描器把商品变更事件 fan-out 给库存、搜索、营销等域
  3. 消费端版本校验
    • 下游各域只接受比自己当前 last_processed_version 更新的消息
    • 旧消息、乱序消息直接丢弃
  4. 旁路延迟对账
    • 基于 Binlog / Canal / Debezium 或定时审计任务,对多域最终状态做迟到核对与冲正

核心设计点 1:源头必须先状态机化,再发事件

发布动作不能直接“改完数据库顺手发一条 MQ”,而必须先在源头服务中把发布建模成状态机,例如:

  • EDITING
  • PUBLISHING
  • PUBLISHED
  • FAILED

以供给平台或商品中心为源头时,本地事务至少做两件事:

  1. 推进本地状态到 PUBLISHING
  2. 在同一个事务里写一条 ItemChangedEvent 到 Outbox

这一步的关键是:

  • 先把本地真相拉平
  • 再把“应该通知外界的事实”可靠广播出去

而不是反过来先发消息再祈祷本地和下游都成功。

核心设计点 2:多域广播靠事件,不靠同步 RPC 链式调用

一条商品变更事件发出去后,库存、搜索、营销、数据平台等域应该独立异步消费,而不应该挂成一条“商品调库存、库存调搜索、搜索再调营销”的同步调用链。

这样做的原因是:

  • fan-out 广播更容易横向扩展
  • 每个域都可以按自己的节奏消费
  • 某一个域短时失败不会拖垮整个发布入口

这也意味着:

  • 商品中心负责商品正式真相
  • 库存域负责库存配置或库存路由真相
  • 搜索域负责检索投影
  • 营销域负责促销上下文

它们彼此独立,但都围绕同一个 发布版本号逻辑版本号 收敛。

核心设计点 3:下游必须用 version 做乱序拦截

这是整个发布一致性设计里最关键的一道防线。

运营可能在 1 秒内连续改两次价格:

  • 第一次把价格改成 100
  • 第二次立刻改成 150

如果“150”的消息先到、而“100”的旧消息后到,下游如果无脑覆盖,就会把正确价格覆盖回旧值,直接造成资损。

因此,源头发出的事件必须至少携带:

  • item_id / sku_id
  • version
  • op_time
  • 本次变化的核心字段

典型事件体可以抽象成:

{
  "item_id": "ITEM_8888",
  "event_type": "ITEM_UPDATE",
  "price": 15000,
  "version": 1002,
  "op_time": 1780900000
}

下游服务必须在自己的余额表、配置表或投影表里冗余:

  • last_processed_version

并在更新时做原子谓词检查:

UPDATE projection_xxx
SET item_price = :price,
    last_processed_version = :version
WHERE item_id = :item_id
  AND :version > last_processed_version;

如果 affected_rows == 0,说明这是一条迟到的老消息,应直接丢弃。

一句话说:

发布一致性的核心不是“消息有没有到”,而是“老消息永远不能覆盖新版本”。

核心设计点 4:订单域必须用确认页快照隔离版本漂移

订单域是最不能“等最终一致慢慢修”的地方,因为用户是在交易链路里直接下单和付钱。

这里不能简单地说“商品最终会一致”,而要明确:

  • 用户点击“去结算”时,必须生成一份确认页快照
  • 这份快照里锁定当时的 item_id / price / version

后续在 CreateOrder 时,订单服务要拿着这份版本去做最终校验:

  • 如果商品仍是同一版本,允许下单
  • 如果商品已经推进到更高版本,例如改价或下架,直接提示用户刷新确认页

也就是说:

  • 搜索和营销可以接受短暂的投影延迟
  • 订单不能接受“旧快照直接落单”

各业务域的协同策略

  1. 商品域 → 搜索域

    • 搜索收到变更后批量写 ES
    • 考虑 ES 近实时刷新延迟,可同时刷新 Redis 商品检索快照
    • C 端详情直达优先读 Redis 快照,缓冲 ES 延迟
  2. 商品域 → 营销域

    • 营销收到价格、上下架、品类变化后刷新自身投影
    • 但营销在最终核价时,仍应回查或读取高性能缓存里的当前商品正式价格版本
    • 一旦发现版本不一致,应宁可降级不用优惠,也不要放行“低价高券”
  3. 商品域 → 库存域

    • 如果商品改动涉及库存配置、履约方式、资源路由或扣减时机,库存域消费事件后应更新自己的配置投影
    • 同样要使用 last_processed_version 防止旧配置覆盖新配置
  4. 商品域 → 订单域

    • 订单域不实时追商品所有细节
    • 只在确认页和创单时拿着 version 做关键校验
    • 商品快照与版本对不上时,直接拒绝旧确认页继续下单

终极兜底:旁路延迟对账与自动冲正

即使前面的 Outbox、MQ、版本拦截都做对了,真实生产环境里依然可能发生:

  • 消息漏投
  • 消费者卡死
  • 搜索 / 营销某个域长时间没消费成功

所以还必须有一条不依赖主业务链路的旁路审计机制

推荐做法是:

  1. 在商品发布成功后,基于 Binlog 订阅(Canal / Debezium)或审计任务记录发布事实
  2. 延迟几分钟再触发跨域核对,给异步消费预留静默窗口
  3. 对库存、搜索、营销、商品详情投影、价格快照等关键域做版本和关键字段核对
  4. 一旦发现某个域还停留在旧版本:
    • 触发自动冲正
    • 或调用受控后台接口强制回刷
    • 同时报警

这条链路的定位非常重要:

  • 它不是主发布路径
  • 它是最终一致性的独立审计者

推荐方案

推荐采用:

本地事务状态机 + Outbox 广播 + 下游版本拦截 + 订单快照隔离 + 延迟对账兜底

这套方案的核心判断是:

  1. 发布链路的第一目标不是“所有域瞬时同时成功”,而是 源头状态确定、事件可靠发出
  2. 下游一致性的第一目标不是“每条消息都严格按网络顺序到达”,而是 旧版本永远不能覆盖新版本
  3. 交易安全的第一目标不是“营销和搜索马上变对”,而是 订单不能基于过期价格版本落单
  4. 最终一致性的最后一层,不是业务代码里的 if/else,而是 旁路审计和自动冲正

一句话说:

在超大规模电商里,多域发布一致性不是靠运行时强一致来硬扛,而是靠“版本化事件广播 + 消费端乱序拦截 + 订单快照隔离 + 旁路延迟对账”这套组合拳,把发布体验、系统吞吐和资金安全同时守住。

2.12 状态机与任务模型设计

2.12.1 生命周期不能只用一个大状态字段表达

整套系统里最容易做错的一件事,就是试图用一个大状态字段表达所有生命周期。

但实际上,下面这些状态并不处于同一层:

  • DRAFT / QC_PENDING / PUBLISHED
  • ONLINE / OFFLINE / BANNED / ARCHIVED
  • AVAILABLE / RESERVED / CONFIRMED / RELEASED / CONSUMED
  • CREATED / RUNNING / PARTIAL_SUCCESS / FAILED / DONE

如果把它们强行塞进一个统一状态字段里,短期看起来省表、省字段,长期一定会出现:

  • 状态爆炸
  • 语义互相污染
  • 不同系统职责无法切开
  • 补偿、审计和恢复策略无从落地

因此这里必须先统一一条系统级原则:

生命周期不是一条线,而是多层状态机的组合。
任务状态、审核状态、正式商品状态、库存状态必须显式分层,不能混成一个大状态字段。

2.12.2 决策点 10:是否只用一个大状态字段表达全部生命周期

方案优点缺点 / 风险推荐结论
方案 A:单一大状态字段表面简单;早期实现快状态爆炸;语义冲突;难扩展;补偿和审计口径混乱不推荐
方案 B:拆分任务状态、审核状态、正式商品状态、库存状态语义清晰;职责边界明确;便于治理、审计和补偿模型设计要求更高推荐

推荐方案是方案 B。

原因不在于“拆得越多越优雅”,而在于不同状态层在回答不同问题:

  • 任务状态:这次任务有没有执行完
  • 审核状态:这次变更有没有被裁判通过
  • 正式商品状态:这个商品现在能不能被平台售卖
  • 库存状态:这份库存或资源当前处在什么履约阶段

如果把这些问题混在一起,最终就会得到一套谁也解释不清、谁也不敢改的状态系统。

2.12.3 任务状态、审核状态、正式商品状态、库存状态的分层设计

建议至少拆成 4 层:

状态层示例回答的问题
任务状态CREATED / RUNNING / PARTIAL_SUCCESS / FAILED / DONE这次任务处理完了没有
审核状态PENDING / APPROVED / REJECTED / WITHDRAWN这次变更是否被审核通过
正式商品状态ONLINE / OFFLINE / ENDED / BANNED / ARCHIVED这个商品现在是否允许售卖
库存状态AVAILABLE / RESERVED / CONFIRMED / RELEASED / LOCKED / CONSUMED这份库存是否可售、已占、已消费

进一步讲,它们之间的关系应该是:

  • 任务状态 驱动“有没有处理完”
  • 审核状态 驱动“能不能进入正式发布”
  • 正式商品状态 驱动“平台货架能不能卖”
  • 库存状态 驱动“用户当前能不能占、能不能扣、能不能核销”

它们彼此关联,但不互相替代。

例如:

  • 一个任务可以 FAILED,但商品正式状态仍然是 ONLINE
  • 一个商品可以 OFFLINE,但历史库存资源仍可能处于 CONFIRMEDCONSUMED
  • 一个审核可以 REJECTED,但这并不等于库存要发生任何状态变化

所以这部分最终要落成的系统心智是:

不同状态层服务于不同的治理目标。
任务解决执行闭环,审核解决裁判闭环,商品状态解决售卖闭环,库存状态解决履约闭环。


3. 详细设计 - 供给平台

这一节不再讨论“商品资产最终长什么样”,而是专门回答供给平台如何承接多入口流量、如何隔离不同入口的资源和执行策略、如何把脏输入治理成标准对象,并如何把结果交给商品主域写侧与库存系统。

这里先钉死三个边界:

  1. 供给平台负责接入、标准化、任务编排、发布编排和失败运营化。
  2. 供给平台不持有 Draft / Staging / QC 主权,这些状态与快照属于商品主域写侧。
  3. 供给平台承接库存运营入口,但不持有库存事实、预占、账本和券码池主权。

3.1 平台定位与职责边界

供给平台的定位更接近一个 B 端接入与治理控制面,而不是商品资产或库存事实的权威持有者。

能力供给平台负责什么不负责什么
入口接入商家上传、运营创建、Excel 导入、API/ISV 推送、供应商 Pull/Push正式商品读模型
输入治理标准化、格式校验、映射补齐、错误文件、幂等受理Draft / Staging / QC 主权
任务编排task / task_item、执行模式、进度跟踪、部分成功商品正式态本地 merge
发布协同触发发布命令、跟踪发布结果、记录 publish record正式商品版本落库
库存入口创建库存变更单、券码导入、生码、锁库存命令入口库存事实、账本、预占
供应商同步Batch、Checkpoint、Snapshot、映射、DLQ供应商数据最终是否成为正式商品版本的裁决权

一句话总结:

供给平台负责把外部和 B 端的复杂输入组织成可治理、可编排、可恢复的标准化变更;商品主域写侧负责把这些变更冻结、审核、发布为正式商品;库存系统负责把库存命令落成事实。

3.2 决策点

这一节后面会进入大量实现细节。为了避免决策点散落在各个小节里,供给平台相关的关键取舍统一先收口在 3.2。后续如果继续新增决策点,也优先继续挂到这里。

3.2.1 决策点 1:任务抢占使用 DB 还是 Redis 分布式锁

Parser Worker 抢占 PENDING 任务时,行业里最主流的两套方案就是:

  • 基于 MySQL 行锁 / 轻量 CAS 的 DB 抢占
  • 基于 Redis SETNX / Redisson 的分布式锁抢占

这两套方案都能做,但它们解决问题的侧重点完全不同。

对比维度方案 A:基于 DB 的轻量 CAS / 行锁抢占方案 B:基于 Redis 分布式锁抢占推荐结论
底层原理依赖 MySQL 单行条件更新和 MVCC,在数据库里一步完成状态推进与租约占有先在 Redis 抢锁,再去 DB 改状态,锁与状态分属两个组件对 B 端批量任务更推荐方案 A
架构复杂度低,无额外中间件依赖中,需要 Redis 集群和分布式锁客户端批量任务优先简单可靠
抢占吞吐中等,但对 B 端批处理完全够用极高,适合超高频短平快抢占Parser 抢占通常不需要 Redis 级吞吐
状态一致性强。状态、租约、进度都在 DB 权威方闭环弱一些。加锁和改状态跨组件,天然存在双写不一致风险B 端供应链更看重一致性
续租机制需要自己维护 heartbeat_at / lease_until可借助 Redisson Watchdog 自动续租自动续租是优点,但不是决定性优势
脑裂 / 猝死恢复强。旧 Worker 恢复后因为 worker_id + lease_token 不匹配而自然失效弱一些。Redis 主从异步复制、锁提前过期、假死恢复都可能带来双 Worker 风险批量主链路不应把风险转移到锁组件
运维与排障简单,直接查 product_supply_task 就能看到状态、租约、进度更复杂,需要同时查 DB 和 Redis 锁状态批量链路更适合单主权排障模型

推荐结论是:

对 Excel 批量导入这类 长生命周期、低 TPS、强一致、强审计 的 B 端任务,优先使用 DB CAS 抢占
对秒杀、轻量定时任务、超高频短平快加锁场景,才优先考虑 Redis 分布式锁。

原因不在于 Redis 不够快,而在于这类批处理任务真正敏感的不是“抢占吞吐”,而是:

  • 状态是否原子闭环
  • 容灾恢复是否确定
  • 排障路径是否单一
  • 是否会因为锁和状态分离而产生脑裂

因此在当前这条链路里,更稳的选择是:

  • 任务状态、租约、进度全部沉淀在 product_supply_task
  • 通过一条带条件的 UPDATE ... WHERE status='PENDING'lease_until < NOW() 完成抢占
  • 由旁路恢复逻辑接管过期租约任务

一句话讲清楚就是:

这条链路要优先追求 状态强一致和容灾确定性,而不是为了追求更高的锁吞吐,把抢占权拆到 Redis 去制造新的双写不一致面。

3.2.2 决策点 2:多入口是否需要按交互频率与数据吞吐量做物理隔离

供给平台的入口很多,但真正困难的地方从来不是“入口多”,而是不同入口的时效预期、吞吐规模、失败容忍度和用户心理完全不同。如果把单品同步提交、Excel 导入、ERP Push、Supplier Pull 全塞进一套线程池、一套队列、一套 Worker,短期看架构很简单,长期一定会在高峰窗口自相残杀。

这里要回答的不是抽象地“要不要隔离”,而是一个更具体的工程问题:

供给平台应该“一套执行池跑所有入口”,还是应该按交互频率和数据吞吐量做物理隔离。

先给结论:

应该按 交互频率 × 数据吞吐量 做物理隔离。
多入口可以复用治理模型,但不应该共用执行资源池、队列和容错策略。

方案优点缺点 / 风险适用场景推荐结论
方案 A:所有入口共用一套线程池 / 队列 / Worker架构简单;早期开发快;运维对象少高峰时互相拖垮;交互型链路被系统型流量吞掉;长任务与短任务争抢资源;容错策略互相污染早期、小流量、单一入口系统过渡方案
方案 B:按四象限做物理隔离,治理层复用时效和吞吐边界清晰;本地运营体验更稳;便于限流、背压、分级容错资源池更多;执行面稍复杂;入口分层要求更高中大型供给平台、多入口系统推荐

为什么这件事必须作为独立决策点先钉死:

  1. 用户心智不同

    单品创建/编辑是秒级心智;Excel 导入是分钟级、盯着进度条的交互心智;ERP Push 只要求快速 ACK 和高可用;Supplier Pull 更偏向长任务、可恢复优先。把这些链路混在一起,本质上是在让最脆弱的交互型链路去为最大吞吐的系统型流量垫背。

  2. 资源模型不同

    单品提交主要吃 Web 线程和 RPC RT;Excel 导入吃文件 IO、流式解析和批量落盘;ERP Push 吃 MQ 堆积和 Consumer 匀速消费;Supplier Pull 吃 Batch Worker、Checkpoint、Lease、Snapshot 和外部限流。它们根本不是同一种执行问题。

  3. 容错策略不同

    Excel 需要部分成功、错题本和行级修复;ERP/API 需要在签名失败、模板错配、批次结构畸形时整批阻断;Supplier Pull 还要区分批次级、页面级、对象级失败。执行层不隔离,最后连错误模型都会互相污染。

  4. 高峰窗口最容易出现跨链路拖垮

    凌晨供应商同步百万级对象、ERP 疯狂重试、Supplier Pull 长时间占用 Worker 时,如果 Excel 导入和单品创建还共用这套执行资源,前台商家可能连一个 20 行的小 Excel 都要排很久,甚至单品保存都会被拖慢。

落地结构:四象限入口设计

整个接入层不应该只按“来源”分类,更应该按 交互频率 × 数据吞吐量 做资源隔离。

象限典型入口交互/吞吐特征执行方式核心目标
Local 单品运营后台、商家后台单品表单强交互、低吞吐、秒级预期同步提交、同步返回不卡前台,不排队
Local Excel商家导表、运营批量建品/编辑高交互异步任务、中等吞吐上传后返回 task_id,专属 Excel Worker 异步解析和处理分钟级反馈、错题本、部分成功
Supplier Push三方 ERP、ISV、供应商开放接口 Push无交互、低时效、超大吞吐接口快速 ACK,写专属 MQ,Consumer 匀速消费抗洪峰,不拖垮本地后台
Supplier Pull酒店、票务、供应商平台全量/增量拉取长任务、海量吞吐、可恢复优先Batch + Checkpoint + Lease + Raw Snapshot可恢复、可补偿、可追溯

落地结构:资源池 / 队列 / Worker 对应关系

隔离不是概念隔离,而是要落到具体执行介质上:

链路资源池 / 介质设计原则
Local 单品创建/编辑独立 Web 线程池或独立同步请求配额不排队,秒级返回
Local Excel 导入独立 Excel Worker Pool保证分钟级反馈,不被 ERP 洪峰拖垮
Supplier Push独立 MQ + Consumer接口快速 ACK,后端慢消费
Supplier Pull独立 Batch Worker支持长任务、Checkpoint、Lease

这里要钉死两个底线:

  1. Excel 导入与 Supplier ERP/API 推送不共用线程池、队列和容错模型。
  2. Supplier Push 和 Supplier Pull 也不应共用同一执行池,因为一个偏实时受理,一个偏长任务恢复。

推荐方案

推荐的落地原则是:

  • 治理层复用
    • 统一 task / task_item / publish_record / operation_log
    • 统一校验结果、Diff、发布编排和错误运营化
  • 执行层隔离
    • Local 单品:独立同步链路
    • Local Excel:独立 Excel Worker Pool
    • Supplier Push:独立 MQ + Consumer
    • Supplier Pull:独立 Batch Worker + Checkpoint / Lease

3.2.3 决策点 3:Excel 与 ERP/API 是否可以共用同一套容错机制

这是供给平台里最容易被低估的一个设计点。两者都叫“批量数据”,但它们面对的是两种完全不同的用户心智和恢复语义:

  • Excel 导入是 交互型导入
  • ERP/API 推送是 系统型同步

真正的问题不是“是不是都可能失败”,而是:

供给平台应该给所有批量入口一套统一错误模型,还是应该按交互型导入和系统型同步拆分容错机制。

先给结论:

不应该共用同一套容错机制。
Excel 导入应以部分成功、行级失败、错题本、人工修复为核心;
ERP/API 推送应以关键错误整批阻断、标准错误码、幂等重试、批次隔离为核心。

方案优点缺点 / 风险适用场景推荐结论
方案 A:统一容错模型规则少;实现看起来简单;错误处理路径统一用户心智混乱;交互型导入和系统型同步互相迁就;结果输出和重试语义容易错位早期、小流量、单一入口系统过渡方案
方案 B:按交互型导入 vs 系统型同步拆分容错模型用户体感更合理;错误粒度更贴近场景;便于部分成功和批次阻断分别治理需要维护两套错误语义和输出模型中大型供给平台、多入口系统推荐

为什么必须拆:

  1. 用户心智不同

    Excel 导入的操作者坐在页面前等待结果,接受“99 行成功、1 行失败,给我错题本”;ERP/API 推送没有人在等进度条,更在意接口高可用、标准错误码和是否能稳定重试。

  2. 错误粒度不同

    Excel 更适合行级错误隔离;ERP/API 更适合批次级阻断。把两者混成一种模型,要么 Excel 体验过于僵硬,要么 ERP 批次语义被稀释。

  3. 恢复方式不同

    Excel 的恢复动作往往是“下载错题本、修复后重传”;ERP/API 的恢复动作通常是“幂等重试、重新推送整批、按错误码修复上游数据”。

  4. 结果输出不同

    Excel 需要错误文件、错题本、行级提示;ERP/API 需要标准响应码、批次状态、重试与死信策略。结果载体本身就不是一回事。

推荐方案

推荐把两类入口的错误模型明确拆开:

  • Excel 导入
    • 支持部分成功
    • 允许行级失败继续向下处理
    • 生成错题本和错误文件
    • 支持运营人工修复后再提交
  • ERP/API 推送
    • 关键错误整批阻断
    • 返回标准错误码
    • 依赖幂等重试和批次隔离
    • 超过阈值进入死信或问题单

错误分级规则

第 3 节应该明确区分:

  • 可行级跳过的错误
    • 字段格式错
    • 个别映射缺失
    • 单行类目异常
  • 必须整批失败的错误
    • 签名失败
    • 密钥过期
    • 核心模板不匹配
    • 批次结构畸形

3.2.4 决策点 4:Excel 批量链路为什么不能继续用传统串行流程

很多团队在批量导入场景的第一版实现里,都会自然地选择“一把梭”串行流程:

  • 上传文件
  • 接口线程直接开始解析
  • 逐行调用下游
  • 最后在接口或单机后台线程里给出结果

这种做法在数据量小、链路短的时候能跑通,但一旦进入真正的供给平台场景,问题会迅速暴露出来。Excel 批量创建和更新并不是“把单品流程循环 N 次”那么简单,它面对的是:

  • 文件 IO 和业务处理耦合
  • 大文件导致的内存和线程占用
  • 单行失败拖垮整批
  • 系统重启后任务蒸发
  • 下游抖动时无法削峰和背压
  • 结果文件、错题本、部分成功难以可靠生成

所以这里真正要回答的问题不是“要不要异步”,而是:

Excel 批量链路应该继续沿用传统串行处理,还是升级为任务化、分阶段、可恢复的异步流水线。

方案优点缺点 / 风险适用场景推荐结论
方案 A:传统串行流程实现快;组件少;早期容易跑通文件解析和业务处理耦合;接口长时间占用;一行失败拖垮整批;系统重启难恢复;难做错题本和部分成功低流量、临时工具型系统过渡方案
方案 B:任务化、分阶段、可恢复的异步流水线可削峰、可恢复、可审计、支持部分成功和错题本需要 task / task_item / parser worker / MQ / 结果归档 等配套平台型批量供给链路推荐
维度传统串行流程当前新流程
提交阶段上传后直接开始处理,接口可能长时间占用只收单,创建 task,秒级返回 task_id
解析方式一个线程从头读到尾,解析和处理耦合Parser Worker 负责流式解析,行级明细先落盘
执行模型单机线程池直接逐行调用下游解析后投递 MQ,Consumer 集群匀速消费
状态表达只有“成功/失败”或粗糙进度tasktask_item 分层状态机
失败处理一行失败容易拖垮整批行级沙盒隔离,允许 PARTIAL_SUCCESS
恢复能力进程重启往往只能从头重跑parse_checkpoint + lease + MQ retry 支持恢复
结果输出靠日志排查自动生成错题本和错误文件

推荐把 Excel 批量链路升级成:

  • 提交任务
  • 解析 Excel
  • 处理任务
  • 产生结果文件

这不是为了“架构更复杂”,而是为了把批量链路从单机脚本思维升级成:

任务化、分阶段、可恢复、可审计、可部分成功 的工业级流水线。

3.2.5 决策点 5:供应商定时同步链路为什么要单独拆分出来

供应商定时同步链路通常来自三方 ERP 的定时批量同步、货期库存批量回传、接口增量推送等场景。它在后端履约阶段,确实可以复用 Excel 批量链路的部分底座,比如:

  • 行级 task_item 落盘
  • MQ 行级解耦消费
  • Diff / 字段主导权 / base_publish_version 校验
  • 结果文件或问题单回填

但它在接入层、吞吐模型、容错语义和监控容灾心智上,必须单独拆出来。原因不是“供应商同步更高级”,而是它和 Excel 批量链路面对的是两种完全不同的世界。

先给结论:

供应商定时同步链路应该独立成一条接入链路和执行链路,但可以复用批量治理底座。
也就是“接入层分流,履约层收口”。

方案优点缺点 / 风险适用场景推荐结论
方案 A:供应商同步和 Excel 批量共用一套接入池、解析器和容错模型实现看起来统一;入口少;早期开发快运营导表和 ERP 洪峰互相拖垮;文件流和 JSON/RPC 混在一起;错误语义错位;凌晨同步会把前台 Excel 链路堵死低流量、单入口、临时工具型系统过渡方案
方案 B:供应商同步单独拆分接入链路,履约层复用批量治理底座接入隔离清晰;监控和背压策略可独立;Excel 体验不被洪峰拖垮;错误模型更契合 ERP 协议入口更多;执行面要分层;治理底座需要共用接口中大型供给平台、多入口系统推荐

为什么要单独拆分,核心是四组对立面:

  1. 流量特征不同

    • Excel 是商家手动上传,流量散落、低频、文件大对象。
    • 供应商同步是 ERP 定时洪峰,流量集中、周期性强、瞬时并发高。
    • 如果共用接入池,ERP 洪峰会把 Excel 导入和单品保存一起拖慢。
  2. 数据载体不同

    • Excel 的输入是文件流,需要 Parser Worker、流式解析、Checkpoint 和断点续跑。
    • 供应商同步的输入通常是 JSON/RPC 报文或消息,不需要文件解析器。
    • 如果混用,接入层会被迫同时支持文件解析和报文拆分,代码会快速面条化。
  3. 容错语义不同

    • Excel 面向人,追求错题本、部分成功、行级修复。
    • 供应商同步面向机器,追求标准错误码、批次阻断、幂等重试。
    • 把两者的错误模型揉成一套,会让人和机器都不好用。
  4. 优先级与背压不同

    • Excel 导表通常是高优先级交互任务,要求尽快反馈。
    • 供应商同步更像低优先级高吞吐的后台洪峰,需要更严格的背压和限流。
    • 如果不拆,凌晨同步会抢占商家白天操作的宝贵资源。

所以,供应商同步链路在架构上应该做成:

  • 接入层独立
    • Excel 走文件流接入
    • ERP/ISV 走 API / MQ 接入
  • 执行层可复用
    • 最终都沉淀为 task_item
    • 后续共用校验、Diff、发布治理和补偿框架
  • 监控容灾独立
    • Excel 盯错题本、部分成功和任务进度
    • 供应商同步盯批次水位、checkpoint、死信和重试

一句话收束就是:

Excel 批量链路和供应商定时同步链路可以共用“履约底座”,但不能共用“接入心智”和“容错模型”。
供应商同步必须单独拆分出来,才能保证 ERP 洪峰不会拖垮运营导表体验。

3.2.6 决策点 6:一次同步任务需要几个小时,是否需要任务分片

当一次供应商同步任务可能持续几个小时,甚至覆盖 100w 级资源时,真正要回答的问题不是“能不能把任务切得更碎”,而是:

面对慢源、强频控和长时间排队,最稳的长任务模型应该怎么选。

对某酒店供应商这类慢源供应商,推荐答案不是“任务分片并发拉取”,而是:

  • 上游接入层保持 单 Worker 串行 Pull
  • 每次只拉一页或一个 cursor
  • 每页成功后立即写快照、写状态、丢 MQ
  • 下游 Consumer 集群再慢慢做 Diff / publish_version / 商品 RPC upsert

这里的核心不是分片,而是下面 5 个关键点:

  1. 统一时刻单例运行与断点续传
    同步窗口可能横跨数小时,昨天的批次没跑完,今天的调度又来了。
    所以 supplier_sync_batch 必须具备 status=RUNNING + lease_until 的租约机制:

    • 如果存在未过期的运行中批次,本次调度直接阻断
    • 如果租约已过期,新 Worker 通过 CAS 接管批次,并从 current_checkpoint 继续跑
  2. 非对称双库物化分流
    100w 酒店的巨型异构 JSON 不适合直接压到 MySQL。

    • HBase / Object Store 负责吞 RAW / NORMALIZED 快照
    • MySQL 负责批次、状态、映射和治理锚点 这不是为了“上大数据组件”,而是为了把“存大对象”和“管状态”拆开。
  3. 接入层页级物化收拢
    串行拉回一页后,不能做 500 次单条 Upsert。
    更稳的做法是:

    • HBase 侧按页批量刷写原始快照
    • MySQL 侧按页批量 Upsert 状态台账 / 行级锚点
    • 再按页批量投递 MQ 接入层要尽量消灭单条循环写。
  4. 时间差下的版本保护
    这类同步里,上游拉取和下游消费之间可能相隔数小时。

    • 供给侧负责忠实搬运,不承担最终过滤主权
    • Consumer 在写商品主域前必须实时反查版本和锁定状态
    • 商品中心在最终写入前用版本、主导权和幂等能力完成最后裁决 目标不是在供给侧裁掉数据,而是防止旧消息在长时间排队后覆盖新版本。
  5. 动态多阶计数器与终结标记
    串行 Pull 在拉到最后一页前并不知道总对象数,所以不能沿用“固定总数计数器”的套路。
    更适合的是:

    • Worker 每投一页就 INCRBY
    • 拉到尾页时写入 end_of_stream=true
    • 下游 Consumer 每处理一条就 DECR
    • 计数归零且终结标记为真时,再触发收尾
方案优点缺点 / 风险适用场景推荐结论
方案 A:任务分片并发拉取理论吞吐更高;单次窗口可能更短接入复杂;容易把慢源打挂;恢复复杂;需要额外分片管理供应商接口很快、允许并发拉取不推荐
方案 B:单 Worker 串行拉取,逐页 checkpoint,拉完即投 MQ接入最稳;对供应商友好;断点续传简单;容易做单例控制和动态计数接入吞吐不高,但通常足够某酒店供应商这类慢源、频控严的同步推荐

推荐的长任务结构是:

  • 定时任务先创建或接管 supplier_sync_batch
  • 一个独占 Worker 串行拉取供应商页面
  • 每页成功后立即做页级物化:快照、状态台账、MQ 投递
  • current_checkpoint 只记录“已经安全拉回并送入 MQ 的最高水位线”
  • RAW / NORMALIZED 快照落 HBase / 对象存储
  • MySQL 只保留批次、状态台账、映射、治理锚点
  • 下游 Consumer 再慢慢做 Diff / publish_version / 商品 RPC upsert

一句话收束就是:

对于性能一般、频控严格、同步窗口很长的供应商,关键不是任务分片,而是 单例运行、串行拉取、页级物化、下游强幂等与版本拦截
最合适的结构是“单 Worker 串行 Pull + checkpoint + MQ 异步消化”的线性流水线。

3.2.7 决策点 7:供应商只提供全量 List,如何识别应该下线的对象

供应商同步还有一个非常难啃的问题:很多供应商只会告诉你“我现在还有什么”,不会告诉你“我刚刚删除了什么”。

这会形成典型的“沉默的消失”:

  • 创建 / 更新很好处理:List 里有的对象,系统里没有就创建,有了就更新
  • 下线最难处理:如果供应商在自己的系统里下掉了一个对象,它不会发 DELETE 事件,而只是让这个对象从下一次 List 里消失

这里真正的难点不是“怎么比一次”,而是:

在供应商只给全量 List 的前提下,如何把“缺失对象识别”设计成可恢复、可审计、可熔断的批次收尾动作。

推荐先给结论:

  • 默认采用 时间戳游标对账法
  • 对误下线极其敏感的场景,再升级到 影子快照表对账法
方案做法优点风险 / 成本适用场景推荐结论
方案 A:时间戳游标对账法以本次同步启动时间 T_start 为锚点;本批次命中的对象完成“存活打卡”;批次结束后,把仍在线但最后打卡时间早于 T_start 的对象判为候选下线集轻量、无锁、无需额外影子表;容易并入批次收尾如果过早执行,或供应商本次文件明显残缺,可能误杀大多数供应商全量同步默认推荐
方案 B:影子快照表对账法先把本次全量 List 的对象主键完整写入影子快照表,再与当前在线集合做差集,找出本次缺失者更安全,适合高敏感业务多一张影子表,多一步批次清理,实现更重对误下线极度敏感、供应商稳定性差高风险场景使用

为什么默认推荐时间戳游标法?因为它不要求每次都建立完整影子表,也不需要做大范围 NOT IN 式对账。更稳的思路是:

  1. 批次启动时记录 T_start
  2. 本次 List 中出现并成功进入处理链路的对象,在状态台账里完成“存活打卡”
  3. 当且仅当本批次已经 拉取结束 + 下游消费完成收敛,再统一识别那些“本次没有打卡”的在线对象
  4. 这些对象进入“候选下线集”
  5. 先做比例熔断,再决定是否批量下线

这里有两条必须锁死的安全红线:

  1. 绝不能在消费尚未完成时提前下线
    必须等 end_of_stream=true 且对象级处理已收敛,才能开始识别候选下线对象。否则会把还在 MQ 排队的正常对象误杀。

  2. 必须有数量安全阈值熔断
    如果本次候选下线对象比例异常高,比如一次要下线 80% 甚至 95%,默认应该认为是供应商漏传、文件不完整或接口故障,而不是“真的大规模下架”。此时必须阻断自动下线,转入告警和人工介入。

所以,这个问题的最终答案不是“做一条大 SQL 找差集”,而是:

批次时间锚点 + 对象打卡 + 收尾时统一盘点 + 下线比例熔断,把“沉默的消失”收敛成一个可控、可补偿、可审计的批次收尾步骤。

3.3 统一任务模型与执行框架

入口隔离和执行资源划分已经在 3.2.2 定义完成。这一节开始只讨论统一任务锚点、状态机和抢占模型,而不再重复解释为什么要做四象限隔离。

这里的“统一任务模型”不是指所有入口都共用一套执行线程池,而是指 Local 单品、Excel、API/ISV 推送、运营批量编辑 这些供给治理型入口共用一套治理任务锚点:

  • product_supply_task
  • product_supply_task_item
  • receipt_id
  • trigger_id
  • operation_id
  • execution_mode

共用范围

入口是否共用 product_supply_task推荐模式
Local 单品创建/编辑轻量 task(total_count=1, execution_mode=SYNC)
Excel 批量导入task + task_item,异步执行
API / ISV 推送受理后落治理任务,异步处理
运营批量编辑task + task_item,支持部分成功
Supplier 同步执行层独立 supplier_sync_* 模型

因此需要在正文中明确一句:

Local 任务与 Supplier 任务在治理层复用发布编排锚点,但执行层不共用线程池、队列和 Worker 模型。

任务状态机

统一任务模型除了统一表结构,还要统一状态机。否则单品、批量、API 推送虽然都写进了 product_supply_task,但每条链路各自发明一套状态语义,最后还是会退化成多套排障和审计口径。

推荐把状态拆成两层:

  • product_supply_task 负责表达“这一批整体走到哪里了”
  • product_supply_task_item 负责表达“这一行 / 这一对象具体成没成功”
stateDiagram-v2
    [*] --> CREATED
    CREATED --> RUNNING: sync execution starts / worker accepted
    CREATED --> CANCELED: withdrawn before execution
    RUNNING --> DONE: all items succeeded
    RUNNING --> PARTIAL_SUCCESS: some items failed
    RUNNING --> FAILED: batch-level failure or all items failed
    RUNNING --> CANCELED: manually terminated
    PARTIAL_SUCCESS --> DONE: retry succeeded
    PARTIAL_SUCCESS --> FAILED: retry exhausted / manual close
    FAILED --> RUNNING: retry or compensation retry
stateDiagram-v2
    [*] --> PENDING
    PENDING --> VALIDATING
    VALIDATING --> FAILED: validation error
    VALIDATING --> READY: normalized and routable
    READY --> PUBLISHED: publish succeeded
    READY --> FAILED: rejected / publish failed
    READY --> SKIPPED: no-op / ownership deny
    FAILED --> PENDING: item retry

推荐语义如下:

层级状态含义
taskCREATED已受理,还未开始执行
taskRUNNING已进入同步执行或异步 Worker 执行
taskPARTIAL_SUCCESS部分对象成功、部分失败,通常需要错误文件或重试
taskDONE全部对象成功结束
taskFAILED整体失败,或全部对象失败
taskCANCELED执行前撤回,或人工终止
task_itemPENDING已入队,尚未处理
task_itemVALIDATING正在标准化、校验、计算 Diff
task_itemREADY已通过供给治理,可提交商品主域写侧
task_itemPUBLISHED已完成发布
task_itemFAILED行级校验失败、审核驳回或发布失败
task_itemSKIPPED无有效变更、字段主导权不允许覆盖等

这套状态机要强调两个边界:

  • task 状态不等于商品状态。DONE 只表示供给任务处理完了,不代表商品一定已经 ONLINE
  • task_item.READY 也不等于正式发布成功,它只表示该对象已经完成供给平台治理,可以进入商品主域写侧的 Draft / Staging / QC 链路。

3.3.1 任务抢占设计

异步任务模型一旦进入多 Worker 部署,就不能默认“只有一个执行器会来处理这条任务”。如果没有显式抢占机制,最常见的后果就是:

  • 两个 Worker 同时处理同一条任务
  • 旧进程恢复后覆盖新进程进度
  • 任务卡在 RUNNING / PARSING 无法恢复
  • 解析进度和执行进度被重复推进

因此,product_supply_task 建议内建一套 数据库权威的任务抢占模型,至少包括:

  • worker_id
  • lease_token
  • lease_until
  • heartbeat_at
  • parse_checkpoint 或等价进度字段

推荐原则是:

  • 任务事实以 MySQL 为权威
  • 抢占通过数据库 CAS 完成
  • 只有租约持有者才能续租、推进 checkpoint、结束任务
  • 只允许抢占租约过期任务,不强抢心跳正常任务

这套设计最好拆成“首次抢占、周期续租、租约过期接管、旧进程失效”四个阶段来看,避免把它误解成一次简单的 UPDATE ... WHERE status='PENDING'

sequenceDiagram
    autonumber
    participant SCH as "调度器 / 轮询器"
    participant W1 as "Parser Worker-A"
    participant T as "product_supply_task"

    SCH->>T: 创建 task(status=PENDING, parse_checkpoint=0)
    SCH-->>W1: 下发待抢占 task_id
    W1->>T: CAS 抢占任务<br/>PENDING -> PARSING<br/>写 worker_id / lease_token / lease_until / heartbeat_at
    alt rows_affected = 1
        T-->>W1: 抢占成功
        W1->>T: 写首个 parse_checkpoint
        loop 解析期间周期续租
            W1->>T: 更新 heartbeat_at / lease_until / parse_checkpoint
        end
        W1->>T: 解析完成后切换为 PROCESSING
    else rows_affected = 0
        T-->>W1: 抢占失败,任务已被其他 Worker 持有
    end

这一小节最重要的不是“如何生成一个 worker_id”,而是把所有权边界钉死:

设计点说明
DB 权威抢占抢占不是“先查再改”,而是带条件的 UPDATE CAS。rows_affected = 1 才表示当前 Worker 真正拿到了执行权。
双因子所有权worker_id 标识是哪台执行器,lease_token 标识本次抢占行为。只有两者同时匹配,才允许续租、推进 checkpoint 和结束任务。
续租与心跳heartbeat_at 说明 Worker 还活着,lease_until 说明执行权何时失效。续租要周期执行,不能等整批解析完再更新。
只抢过期任务只允许抢占 lease_until < now() 的任务,不抢心跳正常任务,避免双 Worker 同时处理一条任务。
checkpoint 恢复抢占恢复时不从头重跑,而是从 parse_checkpoint 或等价进度字段继续,允许小范围重复处理,真正防重依赖幂等键。
旧进程失效旧 Worker 即使恢复,也因为 worker_id + lease_token 不再匹配,无法继续推进任务状态,避免脏回写。

这张图只保留任务抢占的最小闭环,重点是:

  • 第一次抢占必须由数据库 CAS 原子完成,不能先查再改。
  • 续租必须同时更新心跳、租约和 checkpoint。
  • 旧 Worker 失去租约后就不能再写回状态。

3.3.2 供给治理任务数据库权威抢占模型

根据 3.2.1 决策点 1:任务抢占使用 DB 还是 Redis 分布式锁,供给平台统一采用 MySQL CAS + Lease 作为任务抢占模型,不再引入 Redis 分布式锁。原因很直接:对于 Excel 批量、运营批量编辑这类 B 端长任务,最重要的不是极限抢占吞吐,而是状态、租约、进度、恢复都必须沉淀在同一权威主表中。

这个模型依赖两个核心前提:

  • 数据库是唯一状态权威
  • 双因子所有权校验:worker_id + lease_token

worker_id 用来表达“哪台物理执行器正在持有任务”,lease_token 用来表达“这一次抢占行为的唯一租约实例”。只有两者同时匹配,当前 Worker 才有资格续租、推进 checkpoint、回写状态。这样即使旧 Worker 假死后恢复,也无法越权回写。

flowchart TD
    DB["MySQL 任务权威状态台账"] --> CAS["CAS 抢占<br/>status=PENDING 或 lease_until 已过期"]
    CAS --> WA["Parser Worker A<br/>抢占成功"]
    CAS --> WB["Parser Worker B<br/>rows_affected=0 退避"]
    WA --> HB["周期续租 + 推进 checkpoint"]
    HB --> FAIL["Worker OOM / 假死 / 断电"]
    FAIL --> EXPIRE["lease_until 到期"]
    EXPIRE --> TAKEOVER["新 Worker CAS 接管"]
    TAKEOVER --> BLOCK["旧 Worker 恢复后<br/>token 不匹配,DB 物理拦截回写"]

建议 product_supply_task 至少具备下面这些字段,来支撑数据库权威抢占:

ALTER TABLE `product_supply_task`
ADD COLUMN `status` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务级状态机',
ADD COLUMN `worker_id` VARCHAR(64) NULL COMMENT '当前持有任务的物理节点标识',
ADD COLUMN `lease_token` VARCHAR(64) NULL COMMENT '每次抢占生成的唯一租约令牌(UUID)',
ADD COLUMN `lease_until` DATETIME(3) NULL COMMENT '租约失效绝对截止时间',
ADD COLUMN `heartbeat_at` DATETIME(3) NULL COMMENT '最后一次心跳时间',
ADD COLUMN `parse_checkpoint` VARCHAR(512) NULL COMMENT '解析进度锚点 JSON',
ADD COLUMN `last_heartbeat_stage` VARCHAR(64) NULL COMMENT '最后心跳所处阶段(CLAIMED/PARSING/DISPATCHING)',
ADD INDEX `idx_status_lease` (`status`, `lease_until`);

首次抢占与僵尸接管可以统一为一条 CAS SQL:

public Optional<TaskLease> tryClaimTask(Long taskId) {
    String workerId = localHost() + "_" + threadId();
    String leaseToken = UUID.randomUUID().toString();
    Date now = new Date();
    Date leaseUntil = new Date(now.getTime() + 30_000);

    int rows = db.executeUpdate(
        "UPDATE product_supply_task SET " +
        " status = 'PARSING', worker_id = ?, lease_token = ?, " +
        " lease_until = ?, heartbeat_at = ?, last_heartbeat_stage = 'CLAIMED' " +
        "WHERE id = ? AND (" +
        " status = 'PENDING' OR " +
        " (status IN ('PARSING','PROCESSING') AND lease_until < ?)" +
        ")",
        workerId, leaseToken, leaseUntil, now, taskId, now
    );

    if (rows == 1) {
        return Optional.of(new TaskLease(taskId, workerId, leaseToken));
    }
    return Optional.empty();
}

周期续租和 checkpoint 推进必须绑定双因子所有权:

public boolean renewLease(TaskLease lease, String checkpointJson, int parsedCount) {
    Date now = new Date();
    Date leaseUntil = new Date(now.getTime() + 30_000);

    int rows = db.executeUpdate(
        "UPDATE product_supply_task SET " +
        " lease_until = ?, heartbeat_at = ?, parse_checkpoint = ?, parsed_count = ?, " +
        " last_heartbeat_stage = 'PARSING' " +
        "WHERE id = ? AND worker_id = ? AND lease_token = ? AND status = 'PARSING'",
        leaseUntil, now, checkpointJson, parsedCount,
        lease.getTaskId(), lease.getWorkerId(), lease.getLeaseToken()
    );

    return rows == 1;
}

这套模型最重要的收益不是“谁先抢到了任务”,而是把下面四件事锁死在同一条数据库事实线上:

  • 抢占主权
  • 续租主权
  • 进度推进主权
  • 旧进程失效主权

3.3.3 全局发布协同与状态机转移矩阵

根据 3.1 平台定位与职责边界,供给平台不持有正式态商品落库主权,也不持有库存账本主权。它真正持有的是 任务编排主权、状态表达主权、结果归档主权。因此,这里的状态机不能只写成“跑起来了 / 失败了”,而要能明确表达不同阶段的物理意义。

product_supply_task 解决的是“这一批整体走到哪里了”,product_supply_task_item 解决的是“这一行具体成没成功、失败在哪一层”。

任务级状态转移矩阵

原始状态触发事件变更条件 / 校验卡点目标状态后置动作 / 物理回写
PENDINGWorkerClaimEventtryClaimTask() 成功,rows_affected=1PARSING开启续租线程,启动流式解析器
PENDINGUserCancelEvent解析尚未开始,且用户手工撤回CANCELED释放文件句柄,写终态
PARSINGParseSuccessEvent文件解析完毕,行级记录全部成功物化落盘PROCESSING批量投递 task_item_id 到 MQ
PARSINGParseFatalErrorEvent文件损坏、模板不匹配、本地 OOM 等不可恢复错误FAILED记录系统异常日志,不再分发消息
PROCESSINGItemCountZeroEventRedis 计数器归零,或 DB 扫描确认所有行已完成GENERATING_RESULT唤醒结果归档 Worker
PROCESSINGBatchFatalErrorEvent批次级系统异常,且不适合继续消费FAILED回填批次错误码,终止后续分发
GENERATING_RESULTResultArchiveEvent无错误行,或错题本已成功上传 OSSDONE / PARTIAL_SUCCESS回填 error_file_url 与统计计数

行级明细状态转移矩阵

原始状态触发事件核心过滤与判断内核目标状态后置动作 / 物理回写
INITConsumerAcceptEventConsumer 从队列拉取明细并成功占有PROCESSING进入标准化、校验与 Diff 组件
PROCESSINGValidateFailEvent必填项缺失、类目映射失败、模板错误FAILED回填结构化错误码与友好提示
PROCESSINGDiffNoOpEvent仅更新场景触发:字段裁剪后无有效变更SKIPPED不调用商品主域写侧,直接 DECR
PROCESSINGRpcSubmitEvent通过供给治理防线,商品主域已受理SUBMITTED回填 draft_id / goods_id,触发 DECR
PROCESSINGRpcTimeoutEvent调商品主域写侧超时或 UNKNOWNPROCESSING暂不 DECR,交给旁路自愈任务
FAILEDRetryEvent人工修复或补偿触发INIT重新进入队列等待处理

这套矩阵的设计重点有两个:

  1. 任务级状态和行级状态必须分层表达。否则你只能知道“这批差不多失败了”,却永远回答不了“哪一行失败、是校验失败还是写入超时”。
  2. 超时未知必须留在中间态RpcTimeoutEvent 不能简单写成 FAILED,否则旁路自愈和假失败拨正都会失去抓手。

3.4 核心功能:单个创建和编辑

3.4.1 场景画像

单个创建和单个编辑都属于强交互、低吞吐、秒级反馈的入口,但它们的难点并不相同:

  • 单个创建关注“从无到有”的受理、校验、建草稿和返回受理结果
  • 单个编辑关注“基于哪个正式版本改”的 Diffbase_publish_version 和字段主导权

两者的共同点是:

  • 前端体验是同步的
  • 后端仍然要写轻量 task(total_count=1, execution_mode=SYNC)
  • 供给平台负责受理、标准化、校验和 RPC 编排
  • Draft / Staging / QC 主权仍在商品主域写侧

3.4.2 单个创建完整时序图

sequenceDiagram
    participant U as "运营/商家"
    participant W as "供给平台 Web"
    participant T as "product_supply_task"
    participant I as "product_supply_task_item"
    participant V as "标准化/校验层"
    participant A as "Supply Application"
    participant P as "商品中心 RPC"
    participant R as "product_supply_request_log"
    participant H as "旁路自愈任务"

    U->>W: 提交单个商品创建表单
    Note over W,T: Tx1:请求受理 + 先落盘
    W->>T: 创建 task(total_count=1, execution_mode=SYNC)
    W->>I: 创建 task_item(object_type=PRODUCT, business_fingerprint)
    W->>V: 标准化输入、模板校验、交易契约校验
    alt 校验失败
        V-->>W: 返回字段级错误
        W->>I: 更新 item=FAILED
        W->>T: 更新 task=FAILED
        W-->>U: 同步返回失败原因
    else 校验通过
        Note over V,A: Tx2:组装创建命令
        V->>A: 组装 CreateDraftCommand
        A->>R: 记录请求日志(request_id, payload_hash)
        A->>P: CreateProductDraft RPC
        alt 明确成功
            P-->>A: 返回 draft_id / goods_id(可选) / audit_route
            Note over A,T: Tx3:回填 RPC 结果
            A->>R: 记录 response_code=SUCCESS
            A->>I: 更新 item=SUBMITTED,记录 draft_id
            A->>T: 更新 task=SUBMITTED,记录 draft_id
            A-->>W: 返回受理结果
            W-->>U: 返回 draft_id / task_id
        else 超时未知 / 假失败
            P--xA: timeout / unknown
            A->>R: 记录 response_code=TIMEOUT_UNKNOWN
            A->>I: 更新 item=PROCESSING
            A->>T: 更新 task=PROCESSING
            A-->>W: 返回处理中 / 请稍后刷新
            H->>P: 依据 request_id / business_fingerprint 只读反查
            alt 商品中心已成功受理
                P-->>H: 返回 draft_id
                H->>I: 回填 item=SUBMITTED, draft_id
                H->>T: 回填 task=SUBMITTED
            else 商品中心未成功创建
                P-->>H: not found / failed
                H->>I: 回填 item=FAILED
                H->>T: 回填 task=FAILED
            end
        end
    end

3.4.3 单个编辑完整时序图

sequenceDiagram
    participant U as "运营/商家"
    participant W as "供给平台 Web"
    participant T as "product_supply_task"
    participant I as "product_supply_task_item"
    participant V as "标准化/校验层"
    participant D as "Diff/字段主导权"
    participant A as "Supply Application"
    participant P as "商品中心 RPC"
    participant R as "product_supply_request_log"
    participant H as "旁路自愈任务"

    U->>W: 提交单个商品编辑
    W->>T: 创建 task(total_count=1, execution_mode=SYNC)
    W->>I: 创建 task_item(object_type=PRODUCT, base_publish_version, business_fingerprint)
    W->>V: 标准化输入并做格式/契约校验
    V->>D: 计算 Diff,判断字段主导权和风险级别
    alt 无有效变更
        D-->>W: 返回 no-op
        W->>I: 更新 item=SKIPPED
        W->>T: 更新 task=DONE
        W-->>U: 返回无变更
    else 有效变更
        D->>A: 组装 UpdateDraftCommand(base_publish_version)
        A->>R: 记录请求日志(request_id, base_publish_version)
        A->>P: UpdateProductDraft RPC
        alt 明确成功
            P-->>A: 返回 draft_id / audit_route / version_status
            A->>R: 记录 response_code=SUCCESS
            A->>I: 更新 item=SUBMITTED
            A->>T: 更新 task=SUBMITTED
            A-->>W: 返回受理结果
            W-->>U: 返回 draft_id / task_id
        else 版本冲突
            P-->>A: VERSION_CONFLICT
            A->>R: 记录 response_code=VERSION_CONFLICT
            A->>I: 更新 item=FAILED
            A->>T: 更新 task=FAILED
            A-->>W: 返回版本已变化,请刷新后重试
        else 超时未知 / 假失败
            P--xA: timeout / unknown
            A->>R: 记录 response_code=TIMEOUT_UNKNOWN
            A->>I: 更新 item=PROCESSING
            A->>T: 更新 task=PROCESSING
            A-->>W: 返回处理中 / 请稍后刷新
            H->>P: 依据 request_id / business_fingerprint 只读反查
            alt 商品中心已成功受理且版本未冲突
                P-->>H: 返回 draft_id / current_publish_version
                H->>I: 回填 item=SUBMITTED, draft_id
                H->>T: 回填 task=SUBMITTED
            else 商品中心版本已演进
                P-->>H: VERSION_CONFLICT
                H->>I: 回填 item=FAILED
                H->>T: 回填 task=FAILED
            else 商品中心未成功处理
                P-->>H: not found / failed
                H->>I: 回填 item=FAILED
                H->>T: 回填 task=FAILED
            end
        end
    end

3.4.4 关键设计点

设计点说明
轻量任务锚点单品同步链路也要写轻量 tasktask_item,否则单品与批量的审计、补偿、发布追踪会分裂成两套口径。
先落盘再透传前端同步链路看似可以直接把 DTO 透传到商品中心,但工业级中台更稳的做法是“无条件先落盘”。这样单品、批量、API、ERP 同步都能百川归海到同一套操作日志、发布追踪和审计模型;同时还能在网络超时、进程假死、商品中心假失败时保留本地操作沙盒,不至于让商家输入在链路中蒸发。
受理优先于 RPC单个创建的关键是“请求先受理,再校验,再调用商品中心 RPC”,不要把本地受理和跨服务 RPC 强行做成分布式大事务。
业务指纹幂等幂等 Key 不能直接对整段 JSON 做 MD5,因为请求里经常带时间戳、随机数、操作人等噪音字段。更合理的方式是抽取决定业务唯一性的核心特征,生成稳定的业务指纹,并作为 task_item 或等价明细记录的唯一键,从数据库层物理拦截狂点和重复重试。
假失败自愈RPC 发生超时未知时,不能简单把超时当失败返回给前端。否则商家重试时会撞上本地幂等锁,形成“提示失败、实则成功、再次提交又冲突”的死局。更稳的做法是把任务定格在 PROCESSING/UNKNOWN,由旁路自愈任务拿业务指纹或请求键去商品中心只读反查,再把本地状态拨正为 SUCCESSFAILED
编辑的版本约束单个编辑比单个创建多两件事:一是基于当前正式版本做 Diff;二是带上 base_publish_version 防止旧版本覆盖新版本。
base_publish_version 的双向保护base_publish_version 不只是同步链路上的乐观锁,也是异步补偿链路的污染防线。同步提交时它能识别并发编辑冲突并柔性提示前端刷新;异步自愈或重放时,如果商品中心版本已经演进,乐观锁会在物理层阻断旧命令,确保补偿动作不会把新版本再覆盖脏。
字段主导权字段主导权判断必须出现在编辑链路里,避免运营人工修复字段被供应商来源字段反向覆盖。
RPC 边界供给平台调用的是语义化 RPC,如 CreateProductDraftUpdateProductDraft,而不是直接写商品中心内部表。

3.4.5 表和版本映射

对象作用关键字段
product_supply_task单次同步交互的任务锚点task_type / execution_mode / source_type / operator_id / status
product_supply_task_item单对象明细object_type / object_key / normalized_snapshot / status
product_supply_request_log记录供给平台到商品中心 RPC 请求结果request_id / api_name / request_payload_hash / response_code / latency_ms

3.5 核心功能:Excel 批量创建和更新

3.5.1 场景画像

Excel 批量创建和更新属于高交互异步任务:用户上传文件后会盯着进度条等结果,期望分钟级反馈,并且接受“部分成功、部分失败、最后给我错题本”。

这条链路真正的工业级做法,不是“上传文件后一个线程从头跑到尾”,而是拆成四个阶段:

  1. 提交任务:只收单,不处理,秒级返回 task_id
  2. 解析 Excel:流式解析,行级明细落盘
  3. 处理任务:通过 MQ 行级解耦,Consumer 匀速顶下游
  4. 产生结果文件:汇总成功/失败/跳过,生成错题本

这条链路的核心不是“批量更大”,而是:

  • 任务级状态和行级状态必须分离
  • 文件 IO、行级处理、商品中心 RPC、结果归档必须分阶段解耦
  • 创建和更新会混在同一批文件里
  • 更新行要带上 base_publish_version 或当前正式对象引用
  • 处理阶段要靠 MQ 削峰和背压,不能靠单机线程池硬顶
  • 收尾阶段要能在分布式消费结束后可靠触发,而不是人工扫表碰运气
  • 创建和更新虽然共享同一批量底座,但在阶段三“处理任务”的治理内核必须分流

这里不再重复讨论为什么 Excel 批量链路必须任务化、分阶段和异步化,这个架构取舍已经在 3.2.4 决策点 4:Excel 批量链路为什么不能继续用传统串行流程 中统一说明。

3.5.2 批量任务状态机

Excel 批量任务必须显式区分 任务级状态机行级状态机。否则系统只能告诉运营“这批任务大概失败了”,却回答不了“哪一行失败、失败在哪个阶段、是否还能继续重试”。

推荐把这一节的状态机写成更贴近 Excel 批量场景的两层:

stateDiagram-v2
    [*] --> PENDING
    PENDING --> PARSING: parser worker claimed
    PENDING --> CANCELED: withdrawn before parse
    PARSING --> PROCESSING: rows persisted and MQ dispatched
    PARSING --> FAILED: parse fatal error
    PROCESSING --> GENERATING_RESULT: all items finished and result file ready to aggregate
    PROCESSING --> FAILED: batch-level fatal error or all items failed
    PROCESSING --> CANCELED: manually terminated
    GENERATING_RESULT --> DONE: all items succeeded / no error file required
    GENERATING_RESULT --> PARTIAL_SUCCESS: some items failed or skipped, error file generated
    PARTIAL_SUCCESS --> DONE: manual repair or retry succeeded
    PARTIAL_SUCCESS --> FAILED: retry exhausted / force close
    FAILED --> PROCESSING: compensation retry / replay
stateDiagram-v2
    [*] --> INIT
    INIT --> PROCESSING: consumer accepted
    PROCESSING --> SUBMITTED: draft command accepted
    PROCESSING --> SKIPPED: no-op / ownership deny
    PROCESSING --> FAILED: validation error / publish error
    FAILED --> INIT: row retry

这两层状态分别解决不同问题:

层级状态含义
taskPENDING任务已受理,文件还未被 Parser Worker 抢占
taskPARSING正在流式解析 Excel,并持续推进 parse_checkpoint
taskPROCESSING行级明细已落盘,正在 MQ 消费和调用商品主域
taskGENERATING_RESULT所有行已完成处理,正在汇总结果并生成错题本
taskPARTIAL_SUCCESS至少一部分行成功,但仍有失败或跳过项,需要错题本或重试
taskDONE全部有效行已成功处理,结果文件已生成或无需生成
taskFAILED解析致命失败、整批失败,或全部行失败
taskCANCELED执行前撤回或人工终止
task_itemINIT行已落盘,尚未进入消费处理
task_itemPROCESSING正在校验、Diff 或调用商品中心 RPC
task_itemSUBMITTED行级命令已被商品主域受理
task_itemSKIPPED无有效变更、字段主导权不允许覆盖等
task_itemFAILED行级校验失败、版本冲突、RPC 失败等

这套状态机有 3 个关键点:

  1. PARSING 是 Excel 批量独有的重要中间态,单个同步任务通常不会经历这个阶段。
  2. PARTIAL_SUCCESS 是批量任务里最关键的任务级状态之一,单个任务几乎不会真正用到它。
  3. task_item 的状态必须能独立重试,否则错题本修复和局部补偿就无从谈起。
  4. 创建和更新可以共用这套状态机外壳,但更新链路通常会多一个“实时补齐版本 + Diff 裁剪”的隐含处理层。
  5. GENERATING_RESULT 是批量任务专有的收尾态,它把“行级消费完成”和“任务最终归档”拆开,避免把结果文件生成逻辑硬塞回 PROCESSING

3.5.3 批量创建与批量更新的核心差异

批量创建和批量更新虽然共用同一套四阶段异步流水线外壳,但在阶段三“处理任务”的内核策略完全不同。两者最本质的差别,不是谁调用了不同的 RPC,而是它们对商品主数据做的是两种相反的物理动作:

  • 批量创建追求 完备性与防冗余
  • 批量更新追求 增量安全与抗并发

也正因为如此,同一套 Excel 跑批外壳下,创建和更新必须走不同的卡点设计。

差异 1:对象存在性的前置判断不同

  • 批量创建:
    • 逻辑是“对象绝对不该存在”
    • Consumer 拿到一行后,先基于 business_fingerprint 或等价唯一特征反查
    • 如果系统里已经存在同指纹商品,该行直接失败并进入错题本,防止重复创建
  • 批量更新:
    • 逻辑是“对象必须存在”
    • Consumer 拿着 goods_id / item_id / spu_id 等正式对象标识反查
    • 如果根本找不到对应正式商品,该行直接失败,因为失去了更新主体

差异 2:版本号的防御姿态不同

  • 批量创建:
    • 不依赖 base_publish_version
    • 属于从 0 到 1 的初始化过程
    • 创建命令只要满足完备性和防重要求即可
  • 批量更新:
    • 必须在消费阶段实时反查并补齐 base_publish_version
    • 更新命令必须显式带上版本约束
    • 这是更新链路最关键的抗并发底线,用来阻断旧版本覆盖新版本

差异 3:数据剪裁与组装机制不同

  • 批量创建:
    • 更像胖报文
    • 关注“合规的最小完备集”
    • 少一个必填项就应直接失败
  • 批量更新:
    • 更像瘦报文
    • 先做 Diff
    • 再做字段主导权裁剪
    • 再做缺失字段补全
    • 绝不能用 Excel 里的空字段覆盖线上已有值

差异 4:并发与锁竞争退避策略不同

  • 批量创建:
    • 风险点是重复创建和指纹碰撞
    • 更依赖本地 task_item 唯一指纹索引
    • 同批内还可以先做内存去重
  • 批量更新:
    • 风险点是下游已存在热点行的锁竞争
    • 更需要 merchant_id / item_id 局部 Hash 顺序路由
    • 把无序并发写驯化成顺序消费,给下游主库卸压
维度批量创建批量更新
业务前置条件对象绝对不该存在对象必须存在
版本号心智无需关注 base_publish_version必须实时补齐 base_publish_version
报文完整度胖报文,强调最小完备集瘦报文,强调增量字段
核心底层组件标准化校验组件Diff 引擎 + 字段主导权矩阵
防重 / 幂等主战场本地唯一指纹索引中台版本乐观锁 + 本地任务幂等
下游主库风险重复创建、索引分裂、资产冗余热点行锁竞争、锁等待、版本回滚覆盖
主要退避策略指纹去重、同批内存 Group By局部 Hash 顺序路由、版本补齐、Diff 裁剪

一句话收束就是:

批量创建和批量更新共享的是同一种跑批外壳,但在处理内核上必须做“非对称卡点”:创建防冗余,更新防并发。

3.5.4 任务抢占与批量处理时序图

3.5.4.1 任务抢占时序图
sequenceDiagram
    autonumber
    participant SCH as "调度器 / 轮询器"
    participant W1 as "Parser Worker-A"
    participant W2 as "Parser Worker-B"
    participant MON as "任务巡检器"
    participant T as "product_supply_task"

    SCH->>T: 创建 task(status=PENDING, parse_checkpoint=0)
    SCH->>T: 周期扫描候选任务<br/>status = 'PENDING' AND task_type = 'IMPORT'
    SCH-->>W1: 下发待抢占 task_id

    W1->>T: CAS 抢占任务<br/>UPDATE ... SET status='PARSING', worker_id, lease_token,<br/>lease_until, heartbeat_at, parse_checkpoint<br/>WHERE id=? AND status='PENDING'
    alt rows_affected = 1
        T-->>W1: 抢占成功,任务所有权归属 W1
        W1->>T: 进入解析前初始化<br/>last_heartbeat_stage='CLAIMED'
        W1->>T: 写首个 parse_checkpoint<br/>sheet=1,row_no=1,byte_offset=0

        loop 解析期间周期续租
            W1->>T: 更新 heartbeat_at / lease_until
            W1->>T: 推进 parse_checkpoint / parsed_count
            T-->>W1: rows_affected = 1
        end

        alt 解析完成
            W1->>T: 提交解析结果<br/>parsed_count / success_count / failed_count
            W1->>T: 切换 task 状态<br/>PARSING -> PROCESSING
            W1->>T: 保留 worker_id / lease_token<br/>供后续处理阶段复用
        else 解析异常 / OOM / 进程退出
            W1--x T: 心跳停止,lease_until 逐渐到期
        end
    else rows_affected = 0
        T-->>W1: 抢占失败,任务已被其他 Worker 持有
        W1-->>SCH: 放弃执行,等待下次调度
    end

    MON->>T: 轮询僵尸任务<br/>status in ('PARSING','RUNNING') AND lease_until < now()
    alt 发现租约已过期
        MON-->>W2: 通知可接管任务
        W2->>T: CAS 抢占过期任务<br/>WHERE id=? AND status IN ('PARSING','RUNNING')<br/>AND lease_until < now()
        alt 接管成功
            T-->>W2: rows_affected = 1
            W2->>T: 覆盖 worker_id / lease_token / lease_until / heartbeat_at
            W2->>T: 从 parse_checkpoint 继续解析<br/>允许局部重跑一小段
        else 任务已被别的 Worker 接管
            T-->>W2: rows_affected = 0
            W2-->>MON: 放弃接管
        end
    else 心跳仍正常
        MON-->>T: 记录任务健康,无需接管
    end

    W1->>T: 旧进程恢复后尝试续租或回写
    T-->>W1: worker_id + lease_token 不匹配,更新失败
    W1-->>SCH: 旧进程自动失效,不可写回状态
3.5.4.2 批量创建完整时序图
sequenceDiagram
    autonumber
    participant U as "运营/商家"
    participant W as "供给平台 Web"
    participant OSS as "OSS / S3"
    participant T as "product_supply_task"
    participant I as "product_supply_task_item"
    participant PX as "Parser Worker"
    participant MQ as "Task Item MQ"
    participant C as "Item Consumer"
    participant V as "标准化/校验层"
    participant P as "商品中心 RPC"
    participant RC as "Redis Counter"
    participant F as "Result File Worker"

    U->>OSS: 上传 Excel
    OSS-->>U: 返回 file_url
    U->>W: 提交 file_url / 发起批量创建
    W->>T: 创建 task(status=PENDING, execution_mode=ASYNC)
    W-->>U: 返回 task_id / receipt_id
    W->>PX: 投递解析任务
    PX->>T: CAS 抢占任务(PENDING -> PARSING, worker_id, lease_token)
    PX->>T: 更新 heartbeat_at / parse_checkpoint
    loop 每一行
        PX->>I: 批量写入 task_item(status=INIT, raw_row, normalized_snapshot)
        PX->>T: 批量更新 parsed_count / parse_checkpoint / heartbeat_at
    end
    PX->>T: 更新 task=PROCESSING, total_count
    PX->>RC: SET task:count:{task_id}=total_count
    PX->>MQ: 投递每个 task_item_id
    loop 每个 task_item 消费
        C->>MQ: 消费 task_item_id
        C->>I: 读取 task_item,更新 item=PROCESSING
        C->>V: 标准化校验 / 模板校验 / 契约校验
        alt 行级校验失败
            V-->>I: 更新 item=FAILED,记录 error_code / error_message
        else 创建行
            V->>V: 基于 business_fingerprint 反查是否已存在
            V->>P: CreateProductDraft RPC
            P-->>I: 回填 draft_id / audit_route,更新 item=SUBMITTED
        else 临时性网络失败
            C-->>MQ: RECONSUME_LATER
        end
        C->>RC: DECR task:count:{task_id}
        alt 计数归零
            RC-->>C: 0
            C->>F: 触发结果归档
        end
    end
    F->>I: 汇总 success / failed / skipped
    F->>OSS: 生成并上传错题本 Excel
    F->>T: 回填 success_count / failed_count / skipped_count / error_file_url / status=DONE
    T-->>U: 前端轮询得到部分成功或全部完成
3.5.4.3 批量编辑完整时序图
sequenceDiagram
    autonumber
    participant U as "运营/商家"
    participant W as "供给平台 Web"
    participant OSS as "OSS / S3"
    participant T as "product_supply_task"
    participant I as "product_supply_task_item"
    participant PX as "Parser Worker"
    participant MQ as "Task Item MQ"
    participant C as "Item Consumer"
    participant V as "标准化/校验层"
    participant D as "Diff/字段主导权"
    participant P as "商品中心 RPC"
    participant RC as "Redis Counter"
    participant F as "Result File Worker"

    U->>OSS: 上传 Excel
    OSS-->>U: 返回 file_url
    U->>W: 提交 file_url / 发起批量编辑
    W->>T: 创建 task(status=PENDING, execution_mode=ASYNC)
    W-->>U: 返回 task_id / receipt_id
    W->>PX: 投递解析任务
    PX->>T: CAS 抢占任务(PENDING -> PARSING, worker_id, lease_token)
    PX->>T: 更新 heartbeat_at / parse_checkpoint
    loop 每一行
        PX->>I: 批量写入 task_item(status=INIT, raw_row, normalized_snapshot)
        PX->>T: 批量更新 parsed_count / parse_checkpoint / heartbeat_at
    end
    PX->>T: 更新 task=PROCESSING, total_count
    PX->>RC: SET task:count:{task_id}=total_count
    PX->>MQ: 投递每个 task_item_id
    loop 每个 task_item 消费
        C->>MQ: 消费 task_item_id
        C->>I: 读取 task_item,更新 item=PROCESSING
        C->>V: 标准化校验 / 模板校验 / 契约校验
        alt 行级校验失败
            V-->>I: 更新 item=FAILED,记录 error_code / error_message
        else 更新行
            V->>P: 反查正式对象 / 当前 publish_version
            V->>D: 补齐 base_publish_version,计算 Diff / 字段主导权
            alt 无有效变更
                D-->>I: 更新 item=SKIPPED
            else 有效变更
                D->>P: UpdateProductDraft RPC
                P-->>I: 回填 draft_id / audit_route,更新 item=SUBMITTED
            end
        else 临时性网络失败
            C-->>MQ: RECONSUME_LATER
        end
        C->>RC: DECR task:count:{task_id}
        alt 计数归零
            RC-->>C: 0
            C->>F: 触发结果归档
        end
    end
    F->>I: 汇总 success / failed / skipped
    F->>OSS: 生成并上传错题本 Excel
    F->>T: 回填 success_count / failed_count / skipped_count / error_file_url / status=DONE
    T-->>U: 前端轮询得到部分成功或全部完成

3.5.5 关键设计点

3.5.5.1 提交阶段的设计点
设计点说明
四阶段生命周期批量任务要明确拆成“提交任务 → 解析 Excel → 处理任务 → 产生结果文件”四个阶段,而不是让一个线程从头跑到尾。
新旧流程差异新流程不是简单把旧流程异步化,而是显式拆出提交、解析、处理、收尾四阶段,并引入 task/task_item 分层状态、MQ 解耦和结果归档。
提交阶段只收单提交阶段只做文件合规校验、创建 product_supply_task(status=PENDING)、返回 task_id,不直接解析 Excel,也不直接写正式商品表。
任务级中间态Excel 批量任务要显式经历 PENDING -> PARSING -> PROCESSING,这和单任务常见的 CREATED -> RUNNING 有明显差异。
任务与行级分层tasktask_item 必须拆开,Task 表达整批阶段,Item 表达第几行为什么失败。
创建 vs 更新分流创建和更新共享同一跑批外壳,但阶段三必须做非对称内核分流:创建追求完备性与防冗余,更新追求增量安全与抗并发。
3.5.5.2 Parser Worker 解析阶段的设计点
设计点说明
Parser Worker 只解析不发布Parser Worker 的职责边界要非常窄:只负责抢占任务、流式解析文件、生成 task_item 和投递 MQ,不直接调用商品中心,也不做正式发布。
DB 任务抢占Parser Worker 建议通过数据库 CAS 抢占 product_supply_taskPENDING -> PARSING,并同时写入 worker_id / lease_token / lease_until / heartbeat_atrows_affected=1 才表示抢占成功,rows_affected=0 说明任务已被其他执行器抢走。
续租与恢复Parser 抢占后要持续更新 heartbeat_at / lease_until / parse_checkpoint。如果进程 OOM 或机器重启,新 Worker 只能抢占 lease_until 已过期的任务;如果心跳正常,就不允许强抢,避免双 Parser 同时切同一个文件。
流式解析落盘解析阶段必须流式读取 Excel,并把每一行转成 product_supply_task_item 持久化下来,避免一次性读大文件导致 OOM,也避免系统重启后整批数据蒸发。
解析进度 checkpoint解析阶段要把 parse_checkpoint 作为权威恢复点持久化,至少记录 sheet / row_no / byte_offset(或等价位置)。恢复时允许从最近 checkpoint 附近重跑一小段,真正防重依赖幂等键和唯一索引。
3.5.5.3 处理与收尾阶段的设计点
设计点说明
MQ 行级解耦解析阶段结束后,不是单机线程池直接处理所有行,而是把 task_item_id 投递到 MQ,让 Consumer 集群按安全 QPS 匀速消费,实现削峰、背压和横向扩展。
同批混合动作创建行和更新行在同一个 Excel 里可以共存,但消费时要分流成 CREATE / UPDATE / SKIP 不同动作。
创建的完备性校验创建行必须满足最小完备集,并优先做业务指纹反查和唯一索引拦截,防止重复建品。
更新的版本约束更新行不能直接覆盖正式版本,仍然要经过 Diffbase_publish_version 和字段主导权判断。
更新的版本补齐Excel 本身没有版本概念,更新行必须在消费第一秒实时补齐 base_publish_version,再进入更新命令组装。
更新的顺序路由对同商家、同商品或同热点对象的大量更新,建议做局部 Hash 顺序路由,降低下游行锁竞争和锁等待。
行级沙盒隔离某一行因为网络失败、版本冲突、校验失败而报错,只更新该 task_item 状态,绝不因为一行异常回滚整批任务。
MQ 重试与 DLQ临时网络失败、商品中心短暂抖动等可交给 MQ 自动重试;超过阈值的硬失败再回填 task_item=FAILED 或进入问题单。
3.5.5.4 结果文件与收尾阶段的设计点

这一阶段的核心目标不是“再处理一批数据”,而是把分布式消费后的行级结果重新收口成一个商家可感知的最终产物。也就是:由谁来判断批次已经全部完成,谁来汇总成功/失败/跳过,谁来生成可下载的错题本 Excel,并把结果回填到主任务表。

推荐做法是把“完成判定”和“结果文件生成”拆成两步:

  1. 在解析阶段结束后,由 Parser Worker 先把 Redis 原子计数器初始化为本批次总行数,例如 SET task:count:{task_id} = total_count
  2. 所有 task_item 被 MQ Consumer 处理完后,无论成功、失败还是跳过,都在本地事务回写行级状态,然后执行一次 DECR task:count:{task_id}
  3. 当某个 Consumer 执行 DECR 后拿到的结果正好等于 0,它就成为本批次的“终结者线程”,并先把主任务切换为 GENERATING_RESULT
  4. 终结者线程异步汇总 product_supply_task_item 的行级状态,计算 success_count / failed_count / skipped_count
  5. 如果存在失败行,就按照原始行数据 + error_code / error_message 流式生成错题本 Excel,上传到 OSS/S3,得到 error_file_url
  6. 最后由终结者线程在一个轻量本地事务里回填 product_supply_taskstatus=DONEstatus=PARTIAL_SUCCESSsuccess_countfailed_countskipped_counterror_file_url

如果 Redis 计数器因为漏计、超时或者 Consumer 猝死没有自然归零,系统还需要一个旁路兜底定时器定期扫描 PROCESSING / GENERATING_RESULT 的任务和 task_item 状态:

  • 如果 DB 聚合结果已经显示所有行都处理完成,但 Redis 计数器没归零,则由定时器补触发一次 GENERATING_RESULT 和结果文件生成。
  • 如果 DB 仍然有未完成行,则任务继续保持 PROCESSING,等待后续 Consumer 或恢复 Worker 继续完成。

这也是为什么“Redis 闪电收尾 + DB 旁路盘点”必须同时存在:

  • Redis 负责高性能、低成本地找出最后一个完成者。
  • DB 负责在异常场景下给出最终真相,避免计数器漏计把结果文件永远卡住。
  • 结果文件生成不是一个纯内存事件,而是一个必须能被数据库复核、补触发、补回填的任务收尾动作。
设计点说明
Redis 终结者收尾分布式消费后,不能靠扫表猜测是否结束。更稳的做法是用 Redis 原子计数器 DECR task:count:{task_id},最后一个归零的 Consumer 触发结果文件生成和主任务收尾。
DB 旁路盘点如果 Redis 漏计或 Consumer 猝死导致计数器悬空,旁路定时器需要扫描 PROCESSING / GENERATING_RESULT 的任务和 task_item,一旦 DB 已确认全量完成,就补触发结果文件生成和主任务回填。
部分成功模型批量任务允许部分成功,不应因为 1 行失败拖垮整批。
错题本生成错题本不能从日志拼,要从 task_item.error_code / error_message 和原始行数据动态生成,并回填 error_file_url

3.5.6 批量链路交付级核心表结构

对于 Excel 批量链路,真正的底座就是两张表:

  • product_supply_task:承载任务级状态机、双因子租约、错题本与统计计数
  • product_supply_task_item:承载行级明细沙盒、幂等、防冗余、错误归因

下面这组 DDL 不要求你逐字段 1:1 照搬,但字段职责最好不要偏离。

CREATE TABLE `product_supply_task` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `receipt_id` VARCHAR(64) NOT NULL COMMENT '对外受理凭证号',
  `task_type` VARCHAR(32) NOT NULL DEFAULT 'IMPORT' COMMENT 'IMPORT / SYNC / BATCH_EDIT',
  `execution_mode` VARCHAR(32) NOT NULL DEFAULT 'ASYNC' COMMENT 'SYNC / ASYNC',
  `source_type` VARCHAR(32) NOT NULL COMMENT 'MERCHANT_BACKEND / OPERATOR_ADMIN / ERP_API / SUPPLIER_PULL',
  `operator_id` VARCHAR(64) NOT NULL COMMENT '操作人/系统标识',
  `merchant_id` BIGINT NOT NULL COMMENT '商家/供应商ID',
  `status` VARCHAR(32) NOT NULL DEFAULT 'PENDING' COMMENT '任务级状态机',
  `worker_id` VARCHAR(64) DEFAULT NULL COMMENT '当前持有任务的物理节点',
  `lease_token` VARCHAR(64) DEFAULT NULL COMMENT '当前租约令牌',
  `lease_until` DATETIME(3) DEFAULT NULL COMMENT '租约到期时间',
  `heartbeat_at` DATETIME(3) DEFAULT NULL COMMENT '最后心跳时间',
  `last_heartbeat_stage` VARCHAR(64) DEFAULT NULL COMMENT '最后心跳阶段',
  `parse_checkpoint` VARCHAR(512) DEFAULT NULL COMMENT '解析断点 JSON',
  `file_url` VARCHAR(512) DEFAULT NULL COMMENT '原始 Excel 文件路径',
  `error_file_url` VARCHAR(512) DEFAULT NULL COMMENT '错题本路径',
  `total_count` INT NOT NULL DEFAULT 0 COMMENT '总有效行数',
  `parsed_count` INT NOT NULL DEFAULT 0 COMMENT '已解析行数',
  `success_count` INT NOT NULL DEFAULT 0 COMMENT '成功行数',
  `failed_count` INT NOT NULL DEFAULT 0 COMMENT '失败行数',
  `skipped_count` INT NOT NULL DEFAULT 0 COMMENT '跳过行数',
  `gmt_create` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_receipt_id` (`receipt_id`),
  KEY `idx_status_lease` (`status`, `lease_until`),
  KEY `idx_merchant_create` (`merchant_id`, `gmt_create`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供给平台统一任务控制主表';
CREATE TABLE `product_supply_task_item` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '明细主键ID',
  `task_id` BIGINT UNSIGNED NOT NULL COMMENT '关联任务ID',
  `row_number` INT NOT NULL COMMENT 'Excel 绝对行号',
  `object_type` VARCHAR(32) NOT NULL DEFAULT 'PRODUCT' COMMENT 'PRODUCT / COMBINED_PRODUCT / INVENTORY_COMMAND',
  `goods_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '正式商品ID',
  `business_fingerprint` VARCHAR(64) NOT NULL COMMENT '业务指纹',
  `base_publish_version` INT UNSIGNED DEFAULT NULL COMMENT '更新链路版本锚点',
  `status` VARCHAR(32) NOT NULL DEFAULT 'INIT' COMMENT '行级状态机',
  `raw_row_data` LONGTEXT DEFAULT NULL COMMENT '原始行数据快照',
  `normalized_snapshot` LONGTEXT DEFAULT NULL COMMENT '标准化后 DTO 快照',
  `error_code` VARCHAR(64) DEFAULT NULL COMMENT '结构化错误码',
  `error_message` VARCHAR(1024) DEFAULT NULL COMMENT '友好错误提示',
  `gmt_create` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `gmt_modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_task_fingerprint` (`task_id`, `business_fingerprint`),
  KEY `idx_task_status` (`task_id`, `status`),
  KEY `idx_goods_id` (`goods_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供给平台任务行级明细沙盒表';

这两张表最重要的设计收益不是“字段很全”,而是:

  • 抢占、续租、解析、处理、收尾全都能在数据库里找到权威锚点
  • 单行失败、局部重试、错题本回放都不需要回头翻日志
  • tasktask_item 的边界天然支持部分成功和补偿闭环

3.5.7 四阶段流水线的全生命周期战术推演

这一节把前面零散出现的“提交、解析、处理、收尾”收成一条完整流水线,方便直接指导实现。

阶段 1:提交任务(收单解耦面)

  • 商家点击上传并提交
  • 供给平台 Web 只校验扩展名、文件大小、模板基础合法性
  • 创建唯一 receipt_id
  • product_supply_task 中插入一条 status=PENDING 的任务
  • 立即返回 receipt_id + task_id

这一阶段的目标不是处理业务,而是把“用户提交”可靠地转换成一个可追踪、可抢占、可恢复的任务锚点。

阶段 2:解析 Excel(流式物化面)

  • Parser Worker 通过 3.3.2 的 CAS + Lease 模型抢占任务
  • 抢占成功后,把任务推进到 PARSING
  • 使用流式解析组件读取 Excel,而不是一次性全量读入内存
  • 每解析一批行,就批量插入 product_supply_task_item
  • 同时推进 parse_checkpoint
  • 解析完成后,把 total_count 写入 Redis 计数器,并把任务切到 PROCESSING
  • 随后批量投递 task_item_id 到 MQ

这里的关键不是“能读 Excel”,而是把文件 IO 风险安全地转化成数据库里的原子行记录。

阶段 3:处理任务(异步治理分流面)

  • Item Consumer 集群根据下游商品主域写侧的承载能力匀速消费
  • 创建行走“完备性 + 防冗余”路径
  • 更新行走“版本补齐 + Diff + 字段主导权裁剪”路径
  • 行处理结束后,立刻回写 task_item.status / error_code / error_message
  • 然后执行一次 DECR task:count:{task_id}

这一步最重要的是 行级沙盒隔离

  • 一行失败,只影响这一行
  • 不会把整批任务拖回滚
  • 也不会阻断其他行继续向前推进

阶段 4:产生结果文件(分布式收尾归档面)

  • 某个 Consumer 执行 DECR 后返回值正好等于 0
  • 它成为“终结者线程”
  • 先把主任务从 PROCESSING 切到 GENERATING_RESULT
  • 然后聚合 task_item 状态
  • 若存在失败行,则用 raw_row_data + error_message 流式生成错题本 Excel
  • 上传 OSS / S3,得到 error_file_url
  • 最后回填主任务终态:
UPDATE product_supply_task
SET status = IF(failed_count > 0, 'PARTIAL_SUCCESS', 'DONE'),
    success_count = ?,
    failed_count = ?,
    skipped_count = ?,
    error_file_url = ?
WHERE id = ?;

如果 Redis 计数器漏计、Consumer 猝死或最后一个 DECR 没有自然归零,则由旁路定时器兜底:

  • 扫描 PROCESSING / GENERATING_RESULT
  • 聚合 task_item
  • 如果 DB 已确认没有未完成行,就补触发结果文件生成与主任务回填

因此这一阶段不是“顺手做个文件导出”,而是整条异步流水线从分布式并发重新收敛为可交付结果的总收官。

3.5.8 表和版本映射

对象作用关键字段
product_supply_task一次 Excel 批次任务task_type=IMPORT / execution_mode=ASYNC / worker_id / lease_token / parse_checkpoint / error_file_url / success_count / failed_count
product_supply_task_item每一行的对象级状态item_no / item_action / object_key / business_fingerprint / status / error_code / publish_record_id
normalized_snapshot每行标准化后的对象快照category_id / product_payload / ext_json
diff_summary更新行差分摘要changed_fields / risk_level / ownership_result
draft_id商品主域草稿引用draft_id / audit_route
base_publish_version更新行的版本锚点item_id / base_publish_version
routing_key更新行顺序路由键hash(merchant_id,item_id)

3.6 核心功能:供应商同步

3.6.1 场景画像

供应商同步是整个供给平台里最复杂的一类自动化链路。它不只是 “Pull 一批 JSON 回来”,而是同时覆盖:

  • Supplier Push
  • Supplier Pull
  • Full Sync
  • Incremental Sync

这条链路为什么要单独拆出来,在 3.2.5 已经做过统一决策:它可以复用批量治理底座,但不能和 Excel 批量共用接入层、线程池和错误模型。这里开始只讲供应商同步链路本身的实现。

其中,某酒店供应商这类慢源 Pull 长任务最有代表性。它和单商品创建、Excel 导入最大的不同,不是“批量更大”,而是同时叠加了这些现实约束:

  • 供应商接口性能一般,QPS 严格受限
  • 同步窗口可能长达数小时,甚至 10 小时以上
  • 外部供应商接口不稳定,可能限流、超时、5xx、cursor 失效
  • 上游拉取与下游消费天然存在时间差,旧数据覆盖新数据是核心资损风险
  • 任务中断后必须能从 current_checkpoint 恢复,而不是从头重跑
  • 供应商原始返回值必须保留为证据和回放素材

为了把这类长任务讲清楚,这里把供应商同步统一抽象成 4 个阶段:

  1. Phase 0: Sharder,决定本次同步边界和分片颗粒度。
  2. Phase 1: Fetcher,按照分片上下文去外部供应商拉原始数据。
  3. Phase 2: Transformer,把原始 JSON / XML 映射成平台标准对象。
  4. Phase 3: Publisher,把标准对象持久化、投递、发布到商品主域和治理链路。

这 4 个阶段不是要求所有供应商都做成高并发切片框架,而是提供一个统一的执行心智。对某酒店供应商这类慢源 Pull,Phase 0 仍然可以只产出一个或少量串行分片,Phase 1 保持单 Worker 慢拉,后面的 Transformer / Publisher 再通过 MQ 并发消峰。

这里直接定调:

对某酒店供应商这类慢源,正确答案不是“Fetcher 阶段盲目并发拉”,而是“Sharder 控边界,Fetcher 串行慢拉,Publisher 之后再用 MQ 蓄水和下游并发消峰”。

所以供应商同步这类链路的本质不是“收一批外部数据”,而是:

如何在超长任务、不稳定外部系统和正式发布治理之间,实现 可恢复、可追溯、可治理、可补偿

3.6.2 整体设计:关键决策点

在进入具体执行模型之前,这一节先不急着铺所有细节,而是先把最重要的判断收口成一组“执行摘要”。这一节主要回答两件事:

  • 整体设计上,这条同步链路为什么要拆成四阶段,而不是继续停留在单 Worker 黑盒。
  • 关键决策上,为什么治理粒度要做到主任务、批次、分片、对象四层,而不是只做粗粒度状态。

目标只有一个:让读者先看懂这条同步链路为什么要这样分层、为什么治理粒度要做到这里,然后再进入后面的任务治理、分层展开、时序图和表结构。

3.6.2.1 四阶段核心职责边界
Phase组件核心职责禁止事项关键输出
0Sharder回答“这次同步边界是什么?切成多大单元?”不做业务映射、不做最终裁决ShardContext 列表,至少包含 batch_id / shard_id / payload / checkpoint
1Fetcher忠实拉取 + checkpoint 推进 + RAW snapshot不做语义转换、不做最终发布判断RAW 数据 + 推进 current_checkpoint
2Transformer外部 -> 平台语义映射 + 标准化 + 基础风险标注不做最终上线 / 下线裁决Normalized 标准模型 + normalized_ref
3Publisher写前保护 + Upsert 主域 + 状态机驱动不直接改正式商品表SUCCESS / SKIPPED / FAILED + 投递结果

这个拆分的最大价值在于:把“拉数据的不稳定”“翻译的复杂性”“发布的治理”彻底解耦。对于某酒店供应商这类慢源、大体量、强治理场景,这种分层方式的长期维护性会明显高于“大 Consumer 泥球”。

后文所有设计点,本质上都是围绕这四个职责边界展开,而不是额外发明新的执行层。

3.6.2.2 总体架构选择:为什么不是纯串行黑盒

在真正展开四阶段执行模型之前,需要先回答一个更上层的问题:

供应商同步到底应该继续采用“一个 Worker 从头拉到尾”的纯串行模式,还是应该升级成“分阶段 + 分片 + 中间状态可观测”的流水线模式。

这是一个非常典型的架构分水岭。推荐先把两个方案明确摆出来:

方案设计思路优点缺点适用场景
方案 A:纯串行 + Checkpoint只有一个 Worker 执行整个任务;每处理完一小批数据就持久化 checkpoint;失败或重启后从最新 checkpoint 继续实现最简单;状态一致性最好;调试容易;故障定位直接无法利用并行能力;速度慢;单 Worker 风险高;很难做局部重试数据源本身不支持并发、总量可控、强顺序依赖明显的场景
方案 B:分阶段 + 分片流水线拆成 Sharder -> Fetcher -> Transformer -> Publisher 四阶段,通过 MQ、状态表、快照存储解耦职责边界清晰;可并行;支持任务级、批次级、分片级、对象级治理;支持局部重试、精准补偿、熔断和人工介入;可插拔扩展性强实现复杂度更高;需要处理最终一致性、幽灵消息、旧数据覆盖;运维和监控成本更高长任务、大体量、强治理、多供应商、多阶段协同的工业级同步场景

如果只从“最容易上线”看,方案 A 当然更轻;但一旦同步对象规模进入 100w 级、需要长期稳定运行、需要失败治理和人工介入,方案 A 很快就会暴露两个根本问题:

  1. 它只能回答“任务有没有跑完”,很难回答“卡在哪、为什么卡、能不能局部恢复”。
  2. 它把拉取、转换、发布和治理揉成一个执行体,后续每加一层能力,复杂度都会继续堆在同一个 Worker 里。

也正因为如此,这一节最终选择方案 B 作为主方案。但这里也要讲清楚一个非常重要的现实判断:

选择方案 B,不等于每个供应商都必须在每个阶段都高并发运行。

对某酒店供应商这种慢源、QPS 严格受限、上游接口不稳定的场景,更合理的落地方式其实是:

  • 在架构层选择方案 B,保留分阶段、可治理、可补偿的能力。
  • 在执行层让 Fetcher 继续采用偏串行的慢拉模式。
  • 把并发能力主要释放到 Transformer / Publisher 和错误处理链路,而不是上游盲目并发拉取。

所以更准确的工程判断应该是:

不是“方案 A 或方案 B 二选一”,而是“整体架构采用方案 B,但对慢源供应商在局部执行策略上保留方案 A 的串行心智”。

这样做的结果是:

  • 既保住了某酒店供应商慢源场景下最关键的稳定性和 checkpoint 恢复能力;
  • 又不会把整个系统锁死在“单 Worker 黑盒长任务”的演进路径上。
3.6.2.3 任务治理粒度:主任务、批次、分片、对象是否都要建模

如果说上一小节回答的是“执行模型为什么不能继续停留在单 Worker 黑盒”,那么这一小节回答的就是另一个关键问题:

既然已经进入长任务流水线,那状态和治理到底要做到多细?

这一点建议直接用“四层治理粒度”来理解:

粒度层回答的问题典型载体
主任务级这类同步任务如何触发、是否启停、整体运行是否健康supplier_sync_task
批次级这一次执行由谁在跑、跑到哪、租约是否有效supplier_sync_batch
分片级哪个 shard 卡住了、失败了、是否可局部重试supplier_sync_shard_task
对象级哪个对象成功、失败、跳过、是否待补偿supplier_sync_state_ledger

换句话说:

  • 主任务级负责“任务定义和总控”
  • 批次级负责“租约和执行总览”
  • 分片级负责“局部调度和局部恢复”
  • 对象级负责“最终状态机和精准补偿”

只有这四层同时存在,系统才能真正从“黑盒长循环”升级成“可治理流水线”。

这里最需要强调的是:supplier_sync_state_ledger 不是可选项,而是工业级流水线的指挥中心。没有它,100w+ 对象同步很容易退化成黑盒。原因非常直接:

  • 并发主权:Consumer 通过状态机和 CAS 抢占对象级执行权,避免 MQ 重投后的并发踩脚。
  • 对账主权:大盘进度、成功/失败/跳过统计不能去扫 HBase,只能依赖 MySQL 轻量聚合。
  • 补偿主权:失败对象必须能被精准捞出做二次重试,而不是把 100w 对象整批重跑。
  • 时间差防线:消息在 MQ 里排队数小时后,Consumer 仍可结合状态台账和实时版本做写前保护,阻断旧数据覆盖新数据。

在这套四层模型里,还要继续追问一步:

如果已经接受“分阶段 + 分片流水线”的总体架构,那么主任务表和分片任务表是否可以继续简化,甚至完全去掉?

答案是:可以简化,但不建议完全去掉。

先把三个层级的设计摆出来:

方案是否保留主任务表是否保留分片任务表适用场景治理能力
完整版(推荐生产)保留(supplier_sync_task保留(supplier_sync_shard_task大规模、强治理、多供应商、需要人工介入★★★★★
简化版(可接受)保留(轻量版)去掉中等规模、团队小、治理要求中等★★★☆
极简版完全去掉完全去掉小项目、原型、单任务★★

为什么说“可以简化”,是因为不是所有团队都需要一开始就把任务治理做满:

  • 如果供应商数量有限、批次规模中等、人工介入极少,确实可以只保留一个轻量 supplier_sync_task 主任务表,把 shard 直接作为内存对象或消息体投到 MQ。
  • 如果只是原型验证或单一供应商接入,甚至可以连主任务表都不做,只保留最基本的批次水位和对象级状态。

但为什么又说“不建议完全去掉”,核心原因在于:分片任务表的价值,不只是为了多一张表,而是为了让每个 shard 成为可独立调度、可重试、可监控的单元。

一旦完全去掉 supplier_sync_shard_task,系统通常会退化成下面这种模式:

  • Sharder 把 shard 直接打进 MQ
  • Fetcher / Publisher 靠对象级 state_ledger 兜底
  • 失败后主要依赖日志和对象级状态做恢复

这套模式在小规模下能跑,但会损失几个非常关键的治理能力:

  1. 无法方便地对某个分片整体暂停、重跑或摘除

    你可以重试单个对象,但很难回答“第 42 片整体是不是应该停下来人工排查”。

  2. 分片级进度、失败率和耗时统计会明显变难

    主任务级只能看到大盘,对象级能看到细粒度结果,但中间缺失了最有价值的“分片级执行视角”。

  3. Sharder 失败后的恢复逻辑会更绕

    如果没有持久化的分片任务表,很多恢复动作都会退化成“重新算一遍 shard,再试图和现有 MQ / ledger 对齐”,实现和排障复杂度都会上升。

所以更稳的结论应该是:

  • 生产级默认推荐完整版:主任务表 + 分片任务表都保留。
  • 中等规模可以接受简化版:保留轻量主任务表,去掉分片任务表,但要明确接受分片级治理能力下降。
  • 极简版只适合原型或非常小的单任务系统:它能跑通链路,但不应该作为长期演进目标。

如果把这一判断和上面的方案 A / B 放在一起看,会更清楚:

  • 方案 A 主要是在执行策略上更接近“单 Worker 串行”。
  • 方案 B 是在架构分层上进入“分阶段 + 分片流水线”。
  • 而“主任务表 / 分片任务表是否完整保留”,是在方案 B 内部进一步决定治理能力要做到什么层级。

也就是说,执行模型治理建模粒度 是两个相关但不同的决策,不应该混成一件事。

3.6.2.4 四阶段的关键设计摘要
模块当前设计为什么重要
Sharder支持 Full / Delta / Cursor 可插拔策略,并保留 shard_strategy_version让同一条同步主链路能适配不同供应商和不同同步模式,且便于老批次恢复与复盘
Fetcher慢源采用“串行 + 页级 checkpoint + 页级批量物化”在不稳定上游前优先保证恢复能力和接入层稳定性,而不是盲目追求并发吞吐
Transformer外部语义映射、标准化和风险标注从 Fetcher 剥离降低执行层复杂度,并为字段主导权判断、规则复算和对象回放做前置准备
PublisherCAS + 实时版本反查 + 写前保护阻断旧数据覆盖新数据,把最终幂等、版本保护和正式落地收口到治理边界最清晰的位置
收尾下线批次收尾 + 时间戳打卡 + 候选集 + 比例熔断避免把“全量缺席判断”做成行级黑逻辑,并降低大面积误下线风险

这张表的作用不是替代后文细节,而是先把最关键的五个工程判断压缩成“高价值摘要”。

3.6.2.5 Phase 0 ~ 3 的一句话设计判断

如果把四阶段模型再压缩一层,每个 Phase 最重要的判断其实只有下面几句:

  • Sharder:决定同步边界,支持策略可插拔,并允许慢源退化成单 shard 串行模式。
  • Fetcher:负责忠实搬运和 checkpoint 推进,强调页级批量物化,不承担最终业务裁决。
  • Transformer:只做外部语义到平台语义的翻译、标准化和风险前移,不越权决定是否正式发布。
  • Publisher:对象级状态主权、最终版本保护和正式落地都收口在这一层,不能绕过商品主域写侧。

这一小节的目标不是预演后文,而是帮读者先建立“每个 Phase 最值钱的一句话判断”。

3.6.3 整体设计:任务治理

这一节开始,讨论重点会从“怎么设计执行链路”进一步切到“怎么治理长任务”。如果说前面的内容主要在回答架构和建模,那么这里开始回答的就是另一个更难的问题:

当任务已经进入真实生产环境,开始面对慢源、脏数据、长尾失败、版本冲突和人工介入需求时,系统靠什么机制把风险兜住。

3.6.3.1 异常治理:Shard 失败、补偿与人工介入

对于某酒店供应商这类长任务,最忌讳的一件事就是把异常恢复逻辑塞回主同步链路,最后把 Shard -> Fetch -> Transform -> Publish 重新写成一个满是分支的大黑盒。更稳的做法是:

  1. 主同步任务负责正常生产和推进 shard。
  2. shard 失败时,只更新主表状态和 checkpoint,并写失败证据。
  3. 专门的 supplier_sync_error_task 负责消费失败 shard,判断是自动重试、人工介入、跳过归档,还是柔性补偿。

这里的关键分工是:

  • supplier_sync_shard_task:恢复主权表,记录“当前在哪个阶段、从哪恢复”。
  • supplier_sync_shard_error_log:失败审计表,记录“为什么失败、失败时上下文是什么”。
  • supplier_sync_error_task:异常治理表,记录“这次准备怎么处理失败 shard、处理结果如何”。

这样设计的好处是:

  • 主同步任务保持干净,不被异常治理分支污染。
  • 大批量任务不会因为少量坏 shard 长时间卡死。
  • 失败治理从“看日志人工排查”升级成“显式任务化治理”。

这里需要特别区分两类失败:

  • 分片级失败:例如整个页拉取失败、某个 shard 持续超时、某片需要整体暂停。
  • 对象级失败:例如某个酒店映射失败、某个对象版本冲突、某条记录被跳过。

分片级失败强调“任务管理和恢复入口”,对象级失败强调“状态机和精准补偿”,两者不能混成一层。

3.6.3.2 收尾、对账与批次终结

长任务真正困难的地方,不只是把对象拉回来、发出去,还在于最后怎么安全收口。

收尾阶段至少要解决两件事:

  1. 这次批次什么时候算真正结束
  2. 全量 List 模式下,哪些对象本次缺席,应不应该自动下线

先看批次终结判定。推荐的收口协议是:

  • 接入层用 INCRBY + end_of_stream + DECR 的动态计数模型推进
  • 大盘统计基于 supplier_sync_state_ledger 做轻量聚合
  • 如果 Consumer 猝死或 Redis 计数异常,再由 DB 旁路盘点补触发归档

这意味着批次收尾不是“看最后一页拉完了没”,而是要同时看:

  • end_of_stream=true
  • 对象级消费是否已经收敛
  • 大盘是否进入可归档状态

再看全量 List 下的自动下线。它本质上不是行级逻辑,而是批次级治理动作

  • 批次启动时记录 T_start
  • 本批次命中的对象刷新最近打卡时间
  • 批次收尾时,找出仍在线但最近打卡时间早于 T_start 的对象,形成候选下线集
  • 先做比例熔断,再决定是否通过统一治理链路发起下线

这里的关键判断只有两个:

  • 全量下线识别必须放在批次收尾,而不是行级消费里顺手判断
  • 候选下线集必须先做比例熔断和原因归因,不能直接批量改正式状态
3.6.3.3 本节结论:为什么这是一条工业级长任务流水线

3.6.23.6.3 合在一起看,这条链路之所以值得单独建模,不是因为它“多了几个任务表”,而是因为它同时满足了工业级长任务系统的几个关键特征:

  • 它不是“大 Consumer 泥球”,而是显式分成 Sharder / Fetcher / Transformer / Publisher 四个职责边界。
  • 它不是“单 Worker 串行黑盒”,而是在总体架构上进入了可观测、可恢复、可补偿的流水线模式。
  • 它不是“只会拉数据”,而是把主任务、批次、分片、对象四层治理粒度都建了出来。
  • 它不是“跑完就结束”,而是把异常治理、批次终结、自动下线和正式发布保护都纳入了主设计。

换句话说,3.6.2 讲的是整体设计和关键决策,3.6.3 讲的是任务治理;两者合在一起,才构成这类供应商同步真正的工程主体。

所以这条链路的本质,不是一条“供应商拉取脚本”,而是一条 分阶段、强状态、可恢复、可治理 的工业级同步流水线。

3.6.4 Phase 0: Sharder 设计详情

Sharder 不处理具体酒店数据,它只负责回答两个问题:

  1. 这次同步的边界是什么。
  2. 这次同步应该切成多大的独立执行单元。

输入通常是主任务元数据,例如:

  • supplier_id=hotel_supplier_a
  • sync_mode=FULL / DELTA
  • trigger_time
  • task_code

输出是一个 ShardContext 列表或流。每个 ShardContext 至少需要包含:

  • batch_id
  • shard_id
  • sync_mode
  • fetch_cursor / page_range / hotel_id_list / geo_scope
  • checkpoint
  • priority

一个统一的 ShardContext 可以抽象成:

{
  "batch_id": "sb_20260604_0001",
  "shard_id": "hotel-supplier-delta-023",
  "supplier_id": "hotel_supplier_a",
  "sync_mode": "DELTA",
  "hotel_id_list": ["HOTEL_EXT_78231", "HOTEL_EXT_78232"],
  "fetch_cursor": null,
  "page_range": null,
  "checkpoint": null,
  "priority": 50
}

Sharder 最核心的价值是可插拔策略。典型策略如下:

策略适用场景分片方法
FullSharder全量酒店同步按酒店 ID 区间、国家/城市、页范围切分
DeltaSharder过去 1 小时变更同步先查 Change Log,再按每 500 家一包切批
CursorSharder供应商只支持 cursor 连续拉取只生成 1 个或极少数串行 shard

对某酒店供应商这类慢源,虽然可以在概念上保留 Sharder,但很多时候真正落地会退化成:

  • 全量同步时,只生成少量大 shard,甚至只有 1 个串行 shard。
  • 增量同步时,先通过 Change Log 拿到过去一段时间变化的酒店 ID,再按每 500 家一包切成多个 shard。

在工程落地上,Phase 0: Sharder 至少需要两张表来承接主任务和分片任务:

职责关键字段
supplier_sync_task主任务定义与管理表,描述“这类同步任务是什么、何时触发、采用什么同步模式与分片策略”,同时承接任务开关、运行态观察、人工干预入口task_code / supplier_id / sync_mode / data_scope / sharder_type / shard_strategy_version / cron_expr / status / last_run_at / next_run_at / last_batch_id
supplier_sync_shard_task分片任务执行与管理表,描述“这次批次被切出了哪些 shard、每个 shard 现在跑到哪”,同时承接 shard 级重试、卡点定位、补偿与人工接管batch_id / shard_task_id / shard_no / shard_type / shard_payload / checkpoint / status / retry_count / worker_id / lease_until

这里的职责边界要非常明确:

  1. supplier_sync_task 不是纯静态模板,它既回答“应该怎么跑”,也回答“这类任务现在整体跑得怎么样”。
  2. supplier_sync_shard_task 也不是单纯技术分片,它既回答“这次具体切出了什么”,也回答“哪一片卡住了、哪一片失败了、哪一片该补偿”。

可以把它理解成:

  • supplier_sync_task 是某酒店供应商同步这类任务的“作战计划模板 + 总控台”。
  • supplier_sync_shard_task 是 2026-06-04 这次增量同步真正拆出来的 100 个 shard 任务 + 分片级执行控制台。

这两张表之所以必须同时承担 任务管理,核心原因只有一个:避免任务黑盒。如果它们只保存配置和少量执行元数据,那么一旦同步跑了 6 小时、卡在第 42 个 shard、某个供应商接口反复超时,平台只能看到“任务还没结束”,却不知道:

  • 当前主任务是卡在分片生成、数据拉取、转换还是发布。
  • 哪些 shard 已完成,哪些 shard 在重试,哪些 shard 已经失败待人工处理。
  • 是否需要整体暂停主任务,还是只重试个别 shard。
  • 当前失败是否是供应商全局故障,还是局部数据脏点。

所以更准确的表达应该是:

supplier_sync_task 负责主任务级管理与可观测,supplier_sync_shard_task 负责 shard 级管理与可定位。两者合起来,才能把超长同步任务从黑盒变成可治理流水线。

具体来说,建议它们共同承担以下管理能力:

管理能力supplier_sync_tasksupplier_sync_shard_task
任务启停主任务级启用、暂停、熔断不承担
运行总览展示最近批次、整体成功率、最后运行时间汇总到主任务
进度定位看到当前批次号、当前阶段、整体完成度看到具体 shard 的 checkpoint 和卡点
失败治理判断是否需要整批终止或降级精确定位失败 shard 并单片重试
人工介入平台运营或研发可暂停 / 恢复主任务定向重跑某一片、摘除坏 shard、手动接管
审计追踪记录主任务级调度与状态变更记录 shard 级重试、迁移、失败原因

下面给一组简化样例数据。

主任务样例:supplier_sync_task

{
  "id": 2001,
  "task_code": "HOTEL_SUPPLIER_DELTA_SYNC",
  "supplier_id": 88,
  "sync_mode": "PULL",
  "data_scope": "HOTEL",
  "sharder_type": "DELTA_SHARDER",
  "shard_strategy_version": "v3",
  "concurrency_policy": "SERIAL_FETCH_PARALLEL_PUBLISH",
  "status": "ACTIVE",
  "cron_expr": "0 */1 * * * ?",
  "last_run_at": "2026-06-04 01:00:00",
  "next_run_at": "2026-06-04 02:00:00",
  "last_batch_id": "sb_20260604_010000",
  "last_run_status": "PARTIAL_SUCCESS",
  "last_stage": "PUBLISHING",
  "ext_json": {
    "delta_window_minutes": 60,
    "shard_size": 500,
    "max_fetch_qps": 2,
    "checkpoint_mode": "CHANGE_LOG_CURSOR"
  }
}

分片任务样例:supplier_sync_shard_task

[
  {
    "id": 910001,
    "batch_id": "sb_20260604_020000",
    "shard_task_id": "sst_20260604_0001",
    "task_code": "HOTEL_SUPPLIER_DELTA_SYNC",
    "supplier_id": 88,
    "shard_no": 1,
    "shard_type": "HOTEL_ID_LIST",
    "shard_payload": {
      "hotel_ids": ["HOTEL_EXT_78231", "HOTEL_EXT_78232", "HOTEL_EXT_78233"],
      "source_change_cursor": "chg_20260604015900_001"
    },
    "worker_id": "fetcher-03",
    "lease_until": "2026-06-04 02:05:00",
    "checkpoint": null,
    "status": "INIT",
    "retry_count": 0
  },
  {
    "id": 910002,
    "batch_id": "sb_20260604_020000",
    "shard_task_id": "sst_20260604_0002",
    "task_code": "HOTEL_SUPPLIER_DELTA_SYNC",
    "supplier_id": 88,
    "shard_no": 2,
    "shard_type": "HOTEL_ID_LIST",
    "shard_payload": {
      "hotel_ids": ["HOTEL_EXT_78731", "HOTEL_EXT_78732", "HOTEL_EXT_78733"],
      "source_change_cursor": "chg_20260604015900_002"
    },
    "worker_id": "fetcher-04",
    "lease_until": "2026-06-04 02:05:00",
    "checkpoint": "hotel_id=HOTEL_EXT_78732",
    "status": "RUNNING",
    "retry_count": 1,
    "last_error_code": "SUPPLIER_TIMEOUT"
  }
]

如果是全量同步,supplier_sync_task 不变,但 supplier_sync_shard_taskshard_payload 往往会换成 ID 区间或地理范围,例如:

{
  "batch_id": "sb_20260605_020000",
  "shard_task_id": "sst_20260605_0042",
  "task_code": "HOTEL_SUPPLIER_FULL_SYNC",
  "shard_no": 42,
  "shard_type": "HOTEL_ID_RANGE",
  "shard_payload": {
    "start_hotel_id": 420000,
    "end_hotel_id": 429999,
    "country_code": "TH"
  },
  "checkpoint": "hotel_id=424120",
  "status": "RUNNING",
  "retry_count": 1
}

3.6.5 Phase 1: Fetcher 设计详情

Fetcher 接收 ShardContext 后,真正去供应商侧拉数据。它的职责非常克制:

  • 调用供应商 API
  • 处理鉴权、限流、超时、重试
  • 维护当前 shard 的 current_checkpoint
  • 把原始响应保存成 RAW snapshot

它不负责做平台业务语义判断,也不负责最终商品裁决。

对某酒店供应商慢源,更推荐 Fetcher 保持串行慢拉:

  • 一次只拉一页或一个 cursor
  • 每成功一页就推进一次 current_checkpoint
  • 每成功一页就落一次 RAW snapshot
  • 机器重启后从最近一次安全 checkpoint 继续

为了让后续处理和排障更稳,推荐在 RAW snapshot 旁边同时保存一个轻量 raw_hash。它不是最终业务裁决依据,但很适合用于:

  • 辅助判断供应商本次返回内容是否真的变化
  • 观测重复页、脏页和异常重放
  • 给后续 payload_hash / hash_sign 提供更稳定的底层输入

这一步的本质是:忠实搬运外部数据,并把“已经安全拉到哪里”这件事固化下来。

3.6.6 Phase 2: Transformer 设计详情

Transformer 的输入是 RAW snapshot,输出是内部标准酒店模型。它主要做 4 类工作:

  1. 外部字段映射,例如某酒店供应商的酒店、房型、价格日历字段映射到平台内部模型。
  2. 数据标准化,例如国家、城市、币种、时区、退款规则统一口径。
  3. 基础校验与风险标注,例如字段缺失、模板失配、异常价格、可疑履约规则。
  4. 生成平台标准对象快照,供后续 Publisher 使用。

这一步建议只做“把外部语义翻译成平台语义”,不要在这里直接下最终发布裁决。因为:

  • Transformer 看得到原始数据,但看不到最终正式商品主权。
  • 它适合做结构化解释,不适合做最终线上覆盖裁决。

推荐在标准化产物里额外生成两类辅助信息:

  • normalized_hash:标准化后的对象哈希,用于观测和局部去重短路。
  • field_version_map:记录本次变更覆盖了哪些字段,供 Publisher 做更细粒度的字段主导权判断。

例如供应商只对价格、房态、上下架状态有主导权,而标题、卖点、运营标签仍以平台人工编辑为准时,field_version_map 能明显降低“整对象覆盖”的粗暴程度。

3.6.7 Phase 3: Publisher 设计详情

Publisher 的输入是标准酒店模型,输出是持久化和分发结果。它要承担的事情最多,但边界也最重要:

  • Upsert supplier_sync_state_ledger
  • 更新 supplier_product_mapping_tab
  • 投递发布消息或调用商品主域写侧
  • 根据实时 publish_version、锁定状态、字段主导权做最终写前保护
  • 记录 SUCCESS / FAILED / SKIPPED

这一层才是供应商同步与商品主域、审核治理、版本保护真正接壤的地方。换句话说:

Sharder 决定怎么切,Fetcher 决定怎么拉,Transformer 决定怎么翻译,Publisher 决定怎么以平台规则落地。

如果把这 4 步混在一起写成一个大 Consumer,短期能跑,长期几乎一定失控。

在真正进入 Publisher 的对象级落地与主域交互之前,还需要补一个批次级视角。这里的批次级运行状态,主要沉淀在 supplier_sync_batch。它记录的是“这次同步整体怎么跑、现在跑到哪、谁在跑、租约是否有效”,而不是每个酒店对象的明细结果。

一个典型的 supplier_sync_batch 可以长这样:

字段含义某酒店供应商串行 Pull 示例
batch_id本次执行批次 IDsb_20260604_0001
task_code对应哪个业务同步任务HOTEL_SUPPLIER_PULL_DAILY
status批次状态RUNNING
sync_batch_version本次批次版本号20260604-1
trigger_id哪次调度触发的xxljob-20260604-020000
worker_id当前执行 Workersw-03
lease_token当前租约 Tokenlt_8f7a9c21
lease_until租约过期时间2026-06-04 02:15:15
heartbeat_at最近心跳时间2026-06-04 02:10:15
current_checkpoint已安全推进的游标cursor=abc123&page=250
last_checkpoint_at最近推进 checkpoint 的时间2026-06-04 02:09:58
page_no当前处理到第几页250
pulled_count已成功拉回的对象数125000
enqueued_count已成功投 MQ 的对象数125000
success_count下游成功处理数118420
failed_count下游失败处理数312
skipped_count下游跳过数6268
end_of_stream是否已经拉到尾页false
last_error_code最近一次批次级错误码``
last_error_message最近一次批次级错误信息``

如果用一条样例记录来表达,可以写成:

{
  "batch_id": "sb_20260604_0001",
  "task_code": "HOTEL_SUPPLIER_PULL_DAILY",
  "status": "RUNNING",
  "sync_batch_version": "20260604-1",
  "trigger_id": "xxljob-20260604-020000",
  "worker_id": "sw-03",
  "lease_token": "lt_8f7a9c21",
  "lease_until": "2026-06-04 02:15:15",
  "heartbeat_at": "2026-06-04 02:10:15",
  "current_checkpoint": "cursor=abc123&page=250",
  "last_checkpoint_at": "2026-06-04 02:09:58",
  "page_no": 250,
  "pulled_count": 125000,
  "enqueued_count": 125000,
  "success_count": 118420,
  "failed_count": 312,
  "skipped_count": 6268,
  "end_of_stream": false,
  "last_error_code": null,
  "last_error_message": null
}
3.6.7.1 Publisher 处理阶段时序图
sequenceDiagram
    participant MQP as "Publish MQ"
    participant CONS as "Publisher Consumer"
    participant HDB as "HBase / Object Store"
    participant SL as "supplier_sync_state_ledger"
    participant P as "商品主域写侧"

    loop 行级消费
        CONS->>MQP: 拉取 item message (获取 outer_goods_id)
        CONS->>HDB: 读取 RAW / NORMALIZED snapshot
        CONS->>SL: CAS 强占状态:INIT -> PROCESSING
        CONS->>P: 实时反查线上正式 DTO 与 publish_version
        alt 运营锁定 / 线上版本已演进
            P-->>CONS: REJECT_STALE_OR_LOCKED
            CONS->>SL: 更新 state_ledger=SKIPPED
        else READY_TO_UPSERT
            P-->>CONS: ready
            CONS->>SL: 更新 state_ledger=READY_TO_UPSERT
        else mapping失败 / 字段缺失 / 高风险阻断
            CONS->>SL: 更新 state_ledger=FAILED
        end
    end

这张图只回答一个问题:Publisher 如何在真正落地前,拦截因为队列时间差导致的旧数据覆盖新数据。

3.6.7.2 Publisher 请求商品主域写侧时序图
sequenceDiagram
    participant CONS as "Publisher Consumer"
    participant P as "商品主域写侧"
    participant SL as "supplier_sync_state_ledger"

    CONS->>SL: 读取 object_key / last_snapshot_ref
    CONS->>P: 携带标准对象 + base_publish_version + ownership_result 发起写请求
    alt 写入成功
        P-->>CONS: UPSERT_ACCEPTED / Draft 已写入
        CONS->>SL: 更新 state_ledger=SUCCESS
    else 线上版本已演进或对象被锁定
        P-->>CONS: SKIPPED_BY_VERSION_OR_LOCK
        CONS->>SL: 更新 state_ledger=SKIPPED
    else 映射失败 / 字段缺失 / 高风险阻断
        P-->>CONS: REJECTED
        CONS->>SL: 更新 state_ledger=FAILED
    end

这张图只回答最后一个问题:Publisher 在完成标准对象准备后,如何把结果请求到商品主域写侧。

3.6.8 表和版本映射

对象作用关键字段
supplier_sync_task定义和管理同步任务task_code / sync_mode / data_scope / sharder_type / shard_strategy_version / concurrency_policy
supplier_sync_batch一次执行批次与租约控制batch_id / status / sync_batch_version / current_checkpoint / worker_id / lease_token / lease_until / heartbeat_at / end_of_stream
supplier_sync_shard_task一次批次下的分片任务执行上下文batch_id / shard_task_id / shard_no / shard_type / shard_payload / checkpoint / status / retry_count
supplier_sync_error_taskshard 失败后的异常治理任务error_task_id / source_shard_task_id / recovery_type / target_stage / status / start_checkpoint
supplier_sync_shard_error_logshard 级失败证据shard_task_id / stage / error_code / error_message / checkpoint
supplier_sync_state_ledger供应商对象状态台账supplier_id / outer_goods_id / last_batch_id / state / last_snapshot_ref
supplier_product_mapping_tab外部资源到平台资源映射supplier_id / external_resource_id / internal_object_id / mapping_status
publish_version平台正式发布版本item_id / publish_version

3.6.9 某酒店供应商同步样例数据

下面给一组简化样例,帮助把这些表和某酒店供应商同步场景串起来。这里默认同步对象是 HOTEL_EXT_78231,同步模式是串行 Pull,目标是把供应商侧的酒店基础信息、房型信息和价格/库存差异同步到平台治理链路。

对象样例数据说明
supplier_sync_tasktask_code=HOTEL_SUPPLIER_PULL_DAILY
sync_mode=PULL
data_scope=HOTEL
sharder_type=DELTA_SHARDER
shard_strategy_version=v3
concurrency_policy=SINGLE_WORKER
业务级同步定义,表达“每天串行拉取某酒店供应商数据,并采用可插拔的分片策略”
supplier_sync_batchbatch_id=sb_20260604_0001
status=RUNNING
sync_batch_version=20260604-1
current_checkpoint=cursor=abc123
worker_id=sw-03
lease_token=lt_8f7a
lease_until=2026-06-04 02:15:15
heartbeat_at=2026-06-04 02:10:15
end_of_stream=false
一次实际执行批次,记录当前拉到哪了、由谁跑、租约是否有效,以及是否已经拉到尾页
supplier_sync_shard_taskbatch_id=sb_20260604_0001
shard_task_id=sst_20260604_0023
shard_no=23
shard_type=HOTEL_ID_RANGE
shard_payload=[78231...78730]
status=RUNNING
checkpoint=hotel_id=78510
对四阶段框架里的 ShardContext 做持久化,表达“本批次切出的一个具体 shard task”
supplier_sync_error_taskerror_task_id=set_20260604_0007
source_shard_task_id=sst_20260604_0023
recovery_type=RETRY_PUBLISH
target_stage=PUBLISH
status=INIT
start_checkpoint=object_key=HOTEL_EXT_78510
当 shard 失败后,单独用错误处理任务承接恢复与治理,而不是污染主同步链路
supplier_sync_state_ledgersupplier_id=hotel_supplier_a
outer_goods_id=HOTEL_EXT_78231
last_batch_id=sb_20260604_0001
state=INIT
last_snapshot_ref=oss://hotel-supplier-sync/raw/78231.json
记录某酒店供应商某个酒店对象在这次同步中的最新状态和最近快照引用,用来支撑状态推进、补偿和收口
HBase / Object Storeraw_ref=oss://hotel-supplier-sync/raw/78231.json
raw_hash=sha256:abc...
normalized_ref=oss://hotel-supplier-sync/norm/78231.json
normalized_hash=sha256:def...
原始报文和标准化对象都沉淀到 HBase / 对象存储,供回放、复算和审计使用
supplier_product_mapping_tabsupplier_id=hotel_supplier_a
external_resource_id=HOTEL_EXT_78231
internal_object_id=hotel_10086
mapping_status=ACTIVE
维护某酒店供应商酒店 ID 到平台酒店对象 ID 的权威映射
publish_versionitem_id=hotel_10086
publish_version=18
平台当前正式发布版本,用于 Diff 和版本保护

如果继续细化到对象级状态,可以直接在 supplier_sync_state_ledger 上看到这种结果:

对象样例数据说明
supplier_sync_state_ledgersupplier_id=hotel_supplier_a
outer_goods_id=HOTEL_EXT_78231
last_batch_id=sb_20260604_0001
state=SUCCESS
last_snapshot_ref=oss://hotel-supplier-sync/raw/78231.json
表示某酒店供应商酒店 78231 在版本校验通过后成功进入商品主域写侧
supplier_sync_state_ledgersupplier_id=hotel_supplier_a
outer_goods_id=HOTEL_EXT_78232
last_batch_id=sb_20260604_0001
state=SKIPPED
last_snapshot_ref=oss://hotel-supplier-sync/raw/78232.json
表示该对象在消费时发现线上版本已经演进或被运营锁定,因此不再请求商品主域写侧
supplier_sync_state_ledgersupplier_id=hotel_supplier_a
outer_goods_id=HOTEL_EXT_78233
last_batch_id=sb_20260604_0001
state=FAILED
last_snapshot_ref=oss://hotel-supplier-sync/raw/78233.json
表示某个房型无法映射到平台对象,因此停留在失败状态,等待补偿或人工修复

3.7 核心功能:库存

3.7.1 场景画像

供给平台里的库存能力,不是库存中心那套账本、预占、确认、释放的权威事实模型,而是 库存 B 端入口与操作编排层

这一节要覆盖的,不只是“调库存”这种通用动作,更重要的是:创建商品时,如何兼容创建库存

在酒旅、到店餐饮、景区票务和本地生活券这类场景里,创建库存至少要支持 3 种完全不同的模式:

  1. QTY:纯数字库存

    • 创建商品时,运营手工填写库存数字,例如 100
    • 供给平台只需要声明初始数量
    • 库存中心持有真实可售数量
    • 下单时扣减的是数字,不是预先存在的券码
  2. SYS_GEN:系统动态发券

    • 创建商品时,同样填写一个库存数字,例如 500
    • 但用户支付成功后,平台会按规则动态生成核销券码或二维码
    • 库存本体仍然是数量,券码只是履约载体
    • 除了初始数量外,还要声明券码生成规则或生成策略
  3. EXT_POOL:外部死码池

    • 创建商品时,不能把“手填数量”当成库存真相
    • 运营必须上传 Excel 或外部批次文件,文件内包含真实券码、密码、有效期等信息
    • 库存的真正来源是券码池里的有效行数
    • 用户下单时不是“数字减一”,而是从券码池中占用一张真实的死码

因此,供给平台在“创建商品”时,必须同步声明一项关键业务属性:

  • inventory_mode = QTY / SYS_GEN / EXT_POOL

商品创建成功后,供给平台再根据这个模式,把库存初始化动作路由到不同的命令编排路径,而不是所有商品都走同一种 CreateInventory

这一节后续要覆盖的典型动作包括:

  • 初始化库存
  • 调整库存
  • 导入券码
  • 生码
  • 锁库存

核心边界要先钉死:

入口归供给,事实归库存。

也就是说,供给平台负责承接操作入口、审批、权限、审计、错误文件和命令编排,但不持有库存事实、预占、账本或券码池权威状态。

3.7.2 创建商品时顺带创建库存的时序图

sequenceDiagram
    participant U as "运营/商家"
    participant W as "供给平台 Web"
    participant T as "product_supply_task"
    participant I as "product_supply_task_item"
    participant S as "供给平台服务"
    participant P as "商品主域写侧"
    participant IC as "库存中心"
    participant F as "错误文件/审计"

    U->>W: 创建商品时选择 inventory_mode 并填写库存参数
    W->>T: 创建 task
    W->>I: 创建 task_item
    W->>S: 提交商品与库存配置
    S->>T: task=CREATED
    S->>I: item=CREATED
    S->>S: 校验商品参数 / inventory_mode / 权限 / 参数完整性
    alt 校验失败
        S->>I: 更新 item=FAILED,记录 error_code / error_message
        S->>T: 更新 task=FAILED
        S->>F: 记录错误文件 / 审计日志
    else 校验通过
        S->>T: task=CREATING_PRODUCT
        S->>P: CreateDraft / SubmitCreate
        alt 商品主域写侧失败
            P-->>S: 返回 error_code / error_message
            S->>I: 更新 item=FAILED
            S->>T: 更新 task=FAILED
            S->>F: 记录错误文件 / 审计日志
        else 商品主域写侧成功
            P-->>S: 返回 item_id / draft_id
            S->>T: task=CREATING_INVENTORY
            S->>S: 根据 inventory_mode 路由库存创建命令
            alt QTY 纯数字库存
                S->>IC: CreateInventory(item_id, init_quantity)
            else SYS_GEN 系统动态发券
                S->>IC: CreateInventory(item_id, init_quantity, voucher_rule)
            else EXT_POOL 外部死码池
                S->>IC: CreateInventory(item_id, pool_mode=EXT_POOL)
                S->>IC: ImportCodeBatch(item_id, code_batch_file / code_rows)
            end
            alt 库存中心成功
                IC-->>S: 返回 command_id / result
                S->>I: 更新 item=DONE
                S->>T: 更新 task=DONE
            else 库存中心失败
                IC-->>S: 返回 error_code / error_message
                S->>I: 更新 item=FAILED
                S->>T: 更新 task=INVENTORY_FAILED
                S->>F: 记录错误文件 / 审计日志
            end
        end
    end

这张图只回答一个问题:

当“创建商品”和“创建库存”被收敛到同一个供给任务里时,供给中心如何先完成商品创建,再按库存类型路由库存初始化。

因此它故意不展开库存中心内部怎么记账、怎么预占、怎么维护券码池,只强调供给中心这一侧的 4 个动作:

  • 商品创建和库存初始化共用一个任务锚点
  • 商品创建阶段必须先声明 inventory_mode
  • 供给中心先调商品主域写侧创建商品,再根据结果决定是否进入库存初始化
  • 供给中心先把库存初始化操作任务化、审计化
  • 供给中心根据 inventory_mode 决定库存初始化走哪条命令路径
  • QTYSYS_GEN 都以“数量”为库存源头
  • EXT_POOL 以“券码池”为库存源头,手填数量不是最终真相

3.7.3 商品已存在时单独创建或增加库存的时序图

如果商品已经创建完成,后续还需要支持一种更轻量的库存入口:单独创建库存或增加库存。这时不再需要走商品创建链路,而是直接围绕既有 item_id 发起库存操作。

sequenceDiagram
    participant U as "运营/商家"
    participant W as "供给平台 Web"
    participant T as "product_supply_task"
    participant I as "product_supply_task_item"
    participant S as "供给平台服务"
    participant IC as "库存中心"
    participant F as "错误文件/审计"

    U->>W: 基于已有 item_id 发起创建库存 / 增加库存
    W->>T: 创建 task
    W->>I: 创建 task_item
    W->>S: 提交 item_id / inventory_mode / inventory_action
    S->>T: task=CREATING_INVENTORY
    S->>I: item=CREATED
    S->>S: 校验 item_id / inventory_mode / 权限 / 参数完整性
    alt 校验失败
        S->>I: 更新 item=FAILED
        S->>T: 更新 task=FAILED
        S->>F: 记录错误文件 / 审计日志
    else 校验通过
        alt 创建或增加数量库存
            S->>IC: CreateInventory / AdjustInventory(item_id, quantity)
        else 创建或增加系统生券库存
            S->>IC: CreateInventory / AdjustInventory(item_id, quantity, voucher_rule)
        else 导入外部死码池
            S->>IC: CreateInventory(item_id, pool_mode=EXT_POOL)
            S->>IC: ImportCodeBatch(item_id, code_batch_file / code_rows)
        end
        alt 库存中心成功
            IC-->>S: 返回 command_id / result
            S->>I: 更新 item=DONE
            S->>T: 更新 task=DONE
        else 库存中心失败
            IC-->>S: 返回 error_code / error_message
            S->>I: 更新 item=FAILED
            S->>T: 更新 task=INVENTORY_FAILED
            S->>F: 记录错误文件 / 审计日志
        end
    end

这张补充图只回答另一个问题:

当商品已经存在时,供给中心如何围绕既有 item_id 单独创建库存或增加库存。

它和前一张图的区别在于:

  • 不再调用商品主域写侧创建商品
  • 任务直接从 CREATING_INVENTORY 开始
  • 入口重点变成已有商品的库存补建、补量、导码和补码

3.7.4 创建商品与创建库存共用任务的状态机

当商品创建和库存初始化被收敛到同一个 product_supply_task 时,任务状态机要同时表达两段动作:

  • 商品是否已经成功创建
  • 库存是否已经成功初始化

推荐的任务级状态如下:

状态含义
CREATED任务刚被受理,商品和库存都还未开始处理
CREATING_PRODUCT供给中心正在请求商品主域写侧创建商品
CREATING_INVENTORY商品已创建成功,供给中心正在按 inventory_mode 请求库存中心初始化库存
DONE商品创建成功,库存初始化也成功,整个联合任务结束
FAILED商品创建阶段失败,库存阶段不会继续执行
INVENTORY_FAILED商品已创建成功,但库存初始化失败,需要错误文件、补偿或人工修复
CANCELED人工取消或系统撤销任务

如果希望进一步表达行级明细,product_supply_task_item 可以保持轻量:

task_item.status含义
CREATED明细刚受理
FAILED参数校验或执行失败
DONE商品与库存动作都已完成

这个状态机的关键点不是追求状态越多越好,而是要明确区分两类失败:

  • 商品没建成FAILED
  • 商品建成了,但库存没建成INVENTORY_FAILED

只有这样,运营侧才能知道是整单回退,还是只需要补库存。

3.7.5 关键设计点

如果把这一节再往上提炼,供给平台在“商品创建 + 库存创建”这条链路上,真正要解决的是 4 个高风险技术点:

  1. 多模式库存建模怎么统一入口、分离事实。
  2. 商品创建和库存初始化如何用一个联合任务表达半成功。
  3. 单品页面直接改库存时,为什么不能把绝对值直接写回库存中心。
  4. 外部死码池为什么必须走“资源导入”,而不是“数量加减”。
设计点说明
商品创建必须显式声明库存模式供给平台在创建商品时,不能只收商品信息而不收库存模式。至少要支持 QTY / SYS_GEN / EXT_POOL 三种模式,否则后续库存初始化会失去路由依据。
创建商品时就要决定库存创建路径这不是商品创建完成后的附属动作,而是商品创建契约的一部分。供给中心在创建商品时就要知道是走数量库存、系统生券,还是外部死码池。
商品创建与库存初始化可以共用一个任务对运营侧来说,这通常是“一次提交”。因此可以共用一个 product_supply_task,但状态机必须至少拆出 CREATING_PRODUCTCREATING_INVENTORY 两段。
多模式库存建模要“一元化商品契约,分流化库存命令”商品侧不应该为 QTY / SYS_GEN / EXT_POOL 割裂成三套建模,而是通过统一的 inventory_mode 把商品契约收口,再由供给中心的策略路由把库存初始化分流成不同命令。这样商品主域仍保持一元化表达,库存事实则在库存中心按模式落地。
商品主数据与库存事实要在契约层耦合、在事实层解耦商品创建时可以声明 inventory_mode / init_quantity / voucher_rule 等契约字段,但真正的库存余额、券码池、预占和账本仍归库存中心。
命令入口与库存事实分离供给平台承接的是库存操作入口,不是库存事实库。真正的余额、账本、预占、券码池权威状态仍归库存中心。
操作要任务化初始化库存、批量调库存、导入券码等操作都建议写入 product_supply_task / task_item,统一审计、补偿和错误文件输出。
QTYSYS_GEN 共享数量型库存底座这两类模式在创建商品时都可以录入初始数量。差异在于:QTY 只关心可售数,SYS_GEN 还要附带券码生成规则,但它们都不是预先导入死码池。
EXT_POOL 必须走池化库存模型对外购死码场景,Excel 中的券码行才是真实库存来源。供给平台可以收“预计数量”做提示,但不能把手填数量当库存真相。
券码导入与数量调整分离券码导入是唯一资源导入问题,数量调整是数值变化问题,不能混成同一种库存命令模型。ImportCodeBatchAdjustInventory 应走两套不同命令。
动态发券与死码导入分离系统生码是“支付后印券”,外部死码是“创建前已有券”。两者虽然都叫券,但库存建模完全不同,不能共用一张“券码导入”表来表达。
联合任务必须显式表达“库存失败但商品已成功”商品创建成功、库存初始化失败,是本地生活和酒旅供给里最典型的半成功状态。用 INVENTORY_FAILED 单独承接这类失败,能避免重复建商品,也能把补偿动作收敛成“只补库存、不回滚商品”的柔性修复链路。
单品页面编辑库存时,供给中心应把绝对值转成 Delta运营在页面上输入的是把 20 改成 50 这种绝对值,但供给中心发往库存中心的命令应是 AdjustInventory(+30)。否则在用户下单、高频扣减或多人盘点的并发窗口里,直接写绝对值会抹掉中间发生的真实库存变化。
Delta 调整要绑定 base_inventory_version把绝对值转换成 Delta 还不够。供给中心还应带上页面打开时看到的 base_inventory_version,让库存中心用乐观锁校验版本是否已被他人改动。版本冲突时,应返回可运营化的错误,而不是静默覆盖。
供给平台保留审批与理由库存变更往往涉及资损风险,供给平台应保留审批、操作理由、权限校验和审计日志。
发布后可触发库存初始化商品发布后,供给平台可以负责编排库存初始化或默认库存配置。对于 EXT_POOL,更常见的是“先创建库存容器,再导入券码批次”。
EXT_POOL 的数量必须强锚定资源行数对外购死码商品,库存中心感知到的“加库存 1000”不应该来自一个手填数字,而必须来自成功导入的 1000 行有效券码。也就是说,库存数量是池化资源导入的影子结果,不是独立的人工输入事实。
资源导入与数值变更要物理隔离EXT_POOL 的导码本质是唯一资源写入,QTY / SYS_GEN 的调库存本质是数值增量。两者在命令模型、幂等语义、审计要求和失败补偿上都不一样,必须分开建模。
失败运营化库存命令失败后,供给平台要能输出错误文件、审计日志和补偿入口,而不是让问题停留在下游日志里。
不展开库存中心内部账本这一节只讲供给侧库存能力,不在这里重复库存中心的 inventory_balance / reservation / ledger 内部模型。

3.7.6 表和版本映射

对象作用关键字段
product_supply_task一次库存操作任务锚点task_type / execution_mode / source_type / operator_id / status
product_supply_task_item单次库存操作明细object_type / object_key / status / error_code / item_id
inventory_mode商品创建时声明的库存模式QTY / SYS_GEN / EXT_POOL

这一节刻意不再把库存入口拆成太多请求对象。对于供给平台来说,最重要的是:

  • 任务锚点是否存在
  • 任务明细是否能表达失败和补偿
  • 商品创建时是否声明了正确的 inventory_mode

base_inventory_version、导码文件引用、券码规则等更细的字段,继续在 3.7.5 关键设计点 和具体时序图里表达即可,不必在这里再拆成一串对象清单。

3.8 发布编排、商品主域交互与外部系统集成

供给平台在这一层不做正式落库,而是负责把治理结果编排成可执行的发布动作,并把结果扩散给商品主域和外部协同系统。

建议把这一节拆成三类内容来讲:

  1. 商品主域交互

    • 发布命令
    • base_publish_version
    • product_publish_record
    • Draft / Staging / QC / merge 边界
  2. 外部系统集成

    • 搜索
    • 缓存
    • 计价
    • 营销
    • 数据平台 / 画像 / 订阅方
  3. 一致性表达

    • 供给平台不做正式落库
    • 供给平台负责发布编排和跟踪
    • 商品主域负责正式 merge
    • 外部系统通过事件 / Outbox / 投影刷新感知

这里要钉死一句:

供给平台负责编排和触发发布;Draft / Staging / QC / publish merge 由商品主域写侧闭环完成;搜索、缓存、计价、营销和数据平台等外部系统通过投影刷新或事件订阅感知变更。

3.9 异常处理与运营闭环

供给平台最终要把失败运营化,而不是把问题藏在日志里。

这里要先钉死一条异常治理总原则:

失败后不能只靠“直接重试覆盖”来掩盖问题,而应该保留完整的审计链路、错误归因和补偿闭环。
只有这样,系统才具备可回放、可追责、可人工修复的工程确定性。

这一小节建议覆盖:

  • 错误文件
  • 部分成功
  • DLQ
  • 补偿任务
  • 人工修复
  • 审计日志
  • 质量看板
  • 同步任务卡死
  • 下架误推送
  • mapping 失效后的人工介入与重试

对运营侧要能回答:

  • 哪个任务失败了
  • 哪一行失败了
  • 为什么失败
  • 是交互型导入问题还是系统型同步问题
  • 能否下载错误文件修复
  • 是否可以重新投递

3.10 供给平台表清单与使用场景

这一小节建议显式列出供给平台权威表,避免和商品主域写侧、库存系统混淆。

3.10.1 每个表的使用场景

这里先回答“这张表为什么存在”。保持当前按职责分类的方式不变,但只讨论每张表的使用场景、负责链路和关键边界;字段和 schema 统一收口到 3.10.2

A. 接入与任务编排表
使用场景解决的问题类型
product_supply_task单品创建、运营编辑、Excel 导入、API/ISV 推送交互型导入问题 / 统一编排锚点
product_supply_task_itemExcel 每行、批量编辑每条、标准化后每个对象交互型导入问题 / 行级状态
product_supply_operation_log提交、重试、撤回、错误文件、补偿触发交互型与系统型共同审计
B. 标准化与冲突治理补充表
使用场景解决的问题类型
product_field_ownership供应商字段与运营字段冲突系统型同步问题
C. 发布编排与追踪表
使用场景解决的问题类型
product_publish_record一次任务最终触发了哪次发布交互型与系统型共同发布追踪
product_supply_object_mapping供给对象与正式 item_id 映射交互型与系统型共同追溯
product_outbox_event下游刷新事件跟踪交互型与系统型共同一致性
D. 失败恢复与运营治理表
使用场景解决的问题类型
product_supply_dead_letter发布失败、重试超限、关键校验失败交互型与系统型共同失败恢复
product_compensation_task下游刷新失败、库存初始化失败、事件重投系统一致性问题
product_quality_issue巡检发现缺履约、缺库存配置、搜索滞后质量治理问题
E. 供应商同步执行层表
使用场景解决的问题类型
supplier_sync_task定义要同步什么、怎么同步系统型同步问题
supplier_sync_batch一次执行批次、进度、水位、租约系统型同步问题
supplier_sync_state_ledger记录对象级状态、水位和最近快照引用系统型同步问题
supplier_product_mapping_tab外部资源到平台资源映射系统型同步问题

这里还要明确反向排除:

  • product_supply_draft
  • product_supply_staging
  • product_qc_review

这些不应列为供给平台权威表,而应归商品主域写侧。

3.10.2 每个表的 Schema

这里再回答“这张表长什么样”。字段口径、推荐索引和 DDL 示例统一放在这一小节,避免和 3.10.1 的用途说明重复。

先看最核心的接入与任务编排表。

这 3 张表建议这样设计:

product_supply_task
字段含义设计要点
id任务主键雪花 ID 或 UUID
task_no任务号对外展示,便于运营检索
task_type任务类型CREATE / EDIT / IMPORT / API_PUSH / BATCH_EDIT
source_type来源类型LOCAL / EXCEL / API / ISV / OPS
biz_scope业务范围例如 PRODUCT / INVENTORY_ENTRY / PUBLISH
receipt_id受理号入口受理即返回,用于异步查询
trigger_id触发源 ID例如上传文件、按钮点击、定时任务实例
operation_id操作链路 ID串起任务、发布、补偿、审计
execution_mode执行模式SYNC / ASYNC;单品常为 SYNC,批量常为 ASYNC
operator_type操作人类型MERCHANT / OPS / SYSTEM / ISV
operator_id操作人 ID人工或系统主体
merchant_id商家 ID本地运营场景可为空
tenant_id租户 ID多租户场景需要
status任务状态CREATED / RUNNING / PARTIAL_SUCCESS / FAILED / DONE / CANCELED
total_count总对象数单品场景固定为 1
success_count成功数任务汇总字段
failed_count失败数任务汇总字段
risk_level整体风险级别LOW / MEDIUM / HIGH,供后续路由
error_file_url错题本地址Excel 导入常用
ext_json扩展字段存任务级上下文,如模板、入口配置
created_at创建时间审计基础字段
updated_at更新时间审计基础字段
product_supply_task_item
字段含义设计要点
id行级主键雪花 ID 或 UUID
task_id所属任务 ID关联 product_supply_task.id
item_no行号/序号Excel 行号或批量对象序号
object_type对象类型PRODUCT / SKU / OFFER / INVENTORY_ENTRY
object_key对象业务键如外部商品 ID、临时对象键
item_action本行动作CREATE / UPDATE / UPSERT / DELETE / PUBLISH
raw_ref原始输入引用Excel 行引用、上传对象引用、消息体引用
normalized_snapshot标准化快照结构化 JSON,供后续校验与发布编排
diff_summary变更摘要记录关键字段差异
status行级状态PENDING / VALIDATING / READY / FAILED / PUBLISHED / SKIPPED
error_code错误码面向机器处理
error_message错误信息面向运营排查
retry_count重试次数行级重试控制
publish_record_id发布记录 ID成功进入发布编排后回填
ext_json扩展字段存模板版本、路由策略等
created_at创建时间审计基础字段
updated_at更新时间审计基础字段
product_supply_operation_log
字段含义设计要点
id日志主键雪花 ID 或 UUID
operation_id操作链路 ID审计主锚点
task_id关联任务 ID可为空,兼容轻量同步场景
task_item_id关联行级 ID行级事件可回填
event_type事件类型SUBMIT / RETRY / WITHDRAW / GENERATE_ERROR_FILE / TRIGGER_COMPENSATION / PUBLISH
event_stage事件阶段INGEST / VALIDATE / ROUTE / PUBLISH / COMPENSATE
operator_type操作人类型MERCHANT / OPS / SYSTEM / SCHEDULER
operator_id操作人 ID触发主体
before_snapshot变更前摘要不建议存整行大对象,只存关键摘要
after_snapshot变更后摘要只存关键摘要
result_status结果状态SUCCESS / FAILED / SKIPPED
result_code结果码便于分类统计
message审计说明面向运营和排障
trace_id链路追踪 ID打通日志平台
created_at事件时间审计基础字段

推荐索引:

  • product_supply_task
    • 唯一索引:uk_receipt_id
    • 普通索引:idx_operation_id
    • 普通索引:idx_source_type_status_created_at
  • product_supply_task_item
    • 普通索引:idx_task_id_status
    • 普通索引:idx_object_key
    • 普通索引:idx_publish_record_id
  • product_supply_operation_log
    • 普通索引:idx_operation_id_created_at
    • 普通索引:idx_task_id_event_type
    • 普通索引:idx_trace_id
核心表 Schema 示例

前面的表清单和字段说明,解决的是“这张表负责什么”。如果要真正指导团队落库实现,还需要再往下一层,明确这些核心表的 schema 轮廓。

这里不追求一开始就给出百分之百完整的线上 DDL,而是给出一版 足够指导实现、且能稳定支撑当前设计心智 的 schema 示例。团队在落地时可以按业务规模再补分库分表键、审计字段和冷热分层策略。

product_supply_task
CREATE TABLE `product_supply_task` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '任务主键ID',
  `task_no` VARCHAR(64) NOT NULL COMMENT '对外任务号',
  `receipt_id` VARCHAR(64) NOT NULL COMMENT '受理号',
  `trigger_id` VARCHAR(64) DEFAULT NULL COMMENT '触发源ID',
  `operation_id` VARCHAR(64) NOT NULL COMMENT '操作链路ID',
  `task_type` VARCHAR(32) NOT NULL COMMENT 'CREATE / EDIT / IMPORT / API_PUSH / BATCH_EDIT',
  `source_type` VARCHAR(32) NOT NULL COMMENT 'LOCAL / EXCEL / API / ISV / OPS',
  `biz_scope` VARCHAR(32) NOT NULL COMMENT 'PRODUCT / INVENTORY_ENTRY / PUBLISH',
  `execution_mode` VARCHAR(16) NOT NULL COMMENT 'SYNC / ASYNC',
  `operator_type` VARCHAR(16) NOT NULL COMMENT 'MERCHANT / OPS / SYSTEM / ISV',
  `operator_id` VARCHAR(64) NOT NULL COMMENT '操作人ID',
  `merchant_id` BIGINT DEFAULT NULL COMMENT '商家ID',
  `tenant_id` BIGINT DEFAULT NULL COMMENT '租户ID',
  `status` VARCHAR(32) NOT NULL COMMENT '任务状态',
  `worker_id` VARCHAR(64) DEFAULT NULL COMMENT '持有任务的执行器',
  `lease_token` VARCHAR(64) DEFAULT NULL COMMENT '租约令牌',
  `lease_until` DATETIME(3) DEFAULT NULL COMMENT '租约过期时间',
  `heartbeat_at` DATETIME(3) DEFAULT NULL COMMENT '最后心跳时间',
  `last_heartbeat_stage` VARCHAR(64) DEFAULT NULL COMMENT '最后心跳阶段',
  `parse_checkpoint` VARCHAR(512) DEFAULT NULL COMMENT '解析进度断点',
  `file_url` VARCHAR(512) DEFAULT NULL COMMENT '原始上传文件地址',
  `error_file_url` VARCHAR(512) DEFAULT NULL COMMENT '错题本地址',
  `total_count` INT NOT NULL DEFAULT 0 COMMENT '总对象数',
  `success_count` INT NOT NULL DEFAULT 0 COMMENT '成功数',
  `failed_count` INT NOT NULL DEFAULT 0 COMMENT '失败数',
  `skipped_count` INT NOT NULL DEFAULT 0 COMMENT '跳过数',
  `risk_level` VARCHAR(16) DEFAULT NULL COMMENT 'LOW / MEDIUM / HIGH',
  `ext_json` JSON DEFAULT NULL COMMENT '扩展上下文',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_task_no` (`task_no`),
  UNIQUE KEY `uk_receipt_id` (`receipt_id`),
  KEY `idx_operation_id` (`operation_id`),
  KEY `idx_status_lease` (`status`, `lease_until`),
  KEY `idx_source_type_status_created_at` (`source_type`, `status`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供给平台统一任务主表';
product_supply_task_item
CREATE TABLE `product_supply_task_item` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '行级主键ID',
  `task_id` BIGINT UNSIGNED NOT NULL COMMENT '所属任务ID',
  `item_no` INT NOT NULL COMMENT '行号/序号',
  `object_type` VARCHAR(32) NOT NULL COMMENT 'PRODUCT / SKU / OFFER / INVENTORY_ENTRY',
  `object_key` VARCHAR(128) NOT NULL COMMENT '对象业务键',
  `item_action` VARCHAR(32) NOT NULL COMMENT 'CREATE / UPDATE / UPSERT / DELETE / PUBLISH',
  `raw_ref` VARCHAR(512) DEFAULT NULL COMMENT '原始输入引用',
  `normalized_snapshot` JSON DEFAULT NULL COMMENT '标准化快照',
  `diff_summary` JSON DEFAULT NULL COMMENT '差异摘要',
  `business_fingerprint` VARCHAR(64) DEFAULT NULL COMMENT '业务指纹',
  `base_publish_version` BIGINT DEFAULT NULL COMMENT '更新链路版本锚点',
  `publish_record_id` BIGINT DEFAULT NULL COMMENT '发布记录ID',
  `status` VARCHAR(32) NOT NULL COMMENT '行级状态',
  `error_code` VARCHAR(64) DEFAULT NULL COMMENT '错误码',
  `error_message` VARCHAR(1024) DEFAULT NULL COMMENT '错误信息',
  `retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数',
  `ext_json` JSON DEFAULT NULL COMMENT '扩展字段',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  KEY `idx_task_id_status` (`task_id`, `status`),
  KEY `idx_object_key` (`object_key`),
  KEY `idx_publish_record_id` (`publish_record_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供给平台任务行级明细表';
product_supply_operation_log
CREATE TABLE `product_supply_operation_log` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '日志主键ID',
  `operation_id` VARCHAR(64) NOT NULL COMMENT '操作链路ID',
  `task_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联任务ID',
  `task_item_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联行级ID',
  `event_type` VARCHAR(32) NOT NULL COMMENT 'SUBMIT / RETRY / WITHDRAW / GENERATE_ERROR_FILE / TRIGGER_COMPENSATION / PUBLISH',
  `event_stage` VARCHAR(32) NOT NULL COMMENT 'INGEST / VALIDATE / ROUTE / PUBLISH / COMPENSATE',
  `operator_type` VARCHAR(16) NOT NULL COMMENT 'MERCHANT / OPS / SYSTEM / SCHEDULER',
  `operator_id` VARCHAR(64) NOT NULL COMMENT '触发主体',
  `before_snapshot` JSON DEFAULT NULL COMMENT '变更前摘要',
  `after_snapshot` JSON DEFAULT NULL COMMENT '变更后摘要',
  `result_status` VARCHAR(16) NOT NULL COMMENT 'SUCCESS / FAILED / SKIPPED',
  `result_code` VARCHAR(64) DEFAULT NULL COMMENT '结果码',
  `message` VARCHAR(1024) DEFAULT NULL COMMENT '审计说明',
  `trace_id` VARCHAR(128) DEFAULT NULL COMMENT '链路追踪ID',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '事件时间',
  PRIMARY KEY (`id`),
  KEY `idx_operation_id_created_at` (`operation_id`, `created_at`),
  KEY `idx_task_id_event_type` (`task_id`, `event_type`),
  KEY `idx_trace_id` (`trace_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供给平台操作审计日志表';
supplier_sync_task
CREATE TABLE `supplier_sync_task` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `task_code` VARCHAR(64) NOT NULL COMMENT '业务任务编码',
  `supplier_id` BIGINT NOT NULL COMMENT '供应商ID',
  `sync_mode` VARCHAR(16) NOT NULL COMMENT 'PULL / PUSH',
  `data_scope` VARCHAR(32) NOT NULL COMMENT 'HOTEL / PRODUCT / OFFER',
  `sharder_type` VARCHAR(32) NOT NULL COMMENT 'FULL_SHARDER / DELTA_SHARDER / CURSOR_SHARDER',
  `shard_strategy_version` VARCHAR(32) NOT NULL COMMENT '分片策略版本号',
  `concurrency_policy` VARCHAR(32) NOT NULL COMMENT 'SINGLE_WORKER / SERIAL_BATCH',
  `status` VARCHAR(16) NOT NULL COMMENT 'ACTIVE / PAUSED',
  `cron_expr` VARCHAR(64) DEFAULT NULL COMMENT '调度表达式',
  `last_batch_id` VARCHAR(64) DEFAULT NULL COMMENT '最近一次执行批次',
  `last_run_status` VARCHAR(32) DEFAULT NULL COMMENT '最近一次执行结果',
  `last_stage` VARCHAR(32) DEFAULT NULL COMMENT '最近一次运行停留阶段',
  `last_run_at` DATETIME(3) DEFAULT NULL COMMENT '最近一次运行时间',
  `next_run_at` DATETIME(3) DEFAULT NULL COMMENT '下一次调度时间',
  `ext_json` JSON DEFAULT NULL COMMENT '扩展配置',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_task_code` (`task_code`),
  KEY `idx_supplier_id_status` (`supplier_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步任务定义表';
supplier_sync_batch
CREATE TABLE `supplier_sync_batch` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `batch_id` VARCHAR(64) NOT NULL COMMENT '批次ID',
  `task_code` VARCHAR(64) NOT NULL COMMENT '业务任务编码',
  `supplier_id` BIGINT NOT NULL COMMENT '供应商ID',
  `status` VARCHAR(32) NOT NULL COMMENT 'RUNNING / FAILED / DONE / PARTIAL_SUCCESS',
  `sync_batch_version` VARCHAR(64) NOT NULL COMMENT '批次版本号',
  `trigger_id` VARCHAR(64) DEFAULT NULL COMMENT '触发源ID',
  `worker_id` VARCHAR(64) DEFAULT NULL COMMENT '执行worker',
  `lease_token` VARCHAR(64) DEFAULT NULL COMMENT '租约令牌',
  `lease_until` DATETIME(3) DEFAULT NULL COMMENT '租约到期时间',
  `heartbeat_at` DATETIME(3) DEFAULT NULL COMMENT '最后心跳时间',
  `current_checkpoint` VARCHAR(512) DEFAULT NULL COMMENT '当前checkpoint',
  `last_checkpoint_at` DATETIME(3) DEFAULT NULL COMMENT '最近推进checkpoint时间',
  `page_no` INT NOT NULL DEFAULT 0 COMMENT '当前页号',
  `pulled_count` BIGINT NOT NULL DEFAULT 0 COMMENT '已拉取数量',
  `enqueued_count` BIGINT NOT NULL DEFAULT 0 COMMENT '已投MQ数量',
  `success_count` BIGINT NOT NULL DEFAULT 0 COMMENT '成功数',
  `failed_count` BIGINT NOT NULL DEFAULT 0 COMMENT '失败数',
  `skipped_count` BIGINT NOT NULL DEFAULT 0 COMMENT '跳过数',
  `end_of_stream` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否拉到尾页',
  `last_error_code` VARCHAR(64) DEFAULT NULL COMMENT '最近错误码',
  `last_error_message` VARCHAR(1024) DEFAULT NULL COMMENT '最近错误信息',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_batch_id` (`batch_id`),
  KEY `idx_task_code_status` (`task_code`, `status`),
  KEY `idx_status_lease` (`status`, `lease_until`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步批次表';
supplier_sync_shard_task
CREATE TABLE `supplier_sync_shard_task` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `batch_id` VARCHAR(64) NOT NULL COMMENT '所属批次ID',
  `shard_task_id` VARCHAR(64) NOT NULL COMMENT '分片任务ID',
  `task_code` VARCHAR(64) NOT NULL COMMENT '业务任务编码',
  `supplier_id` BIGINT NOT NULL COMMENT '供应商ID',
  `shard_no` INT NOT NULL COMMENT '分片序号',
  `shard_type` VARCHAR(32) NOT NULL COMMENT 'HOTEL_ID_LIST / HOTEL_ID_RANGE / GEO_SCOPE / CURSOR',
  `shard_payload` JSON NOT NULL COMMENT '分片上下文,如酒店ID列表、ID范围、国家城市、cursor等',
  `status` VARCHAR(32) NOT NULL COMMENT 'INIT / RUNNING / SUCCESS / FAILED / PARTIAL_SUCCESS / SKIPPED / PAUSED',
  `checkpoint` VARCHAR(512) DEFAULT NULL COMMENT '当前分片checkpoint',
  `retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数',
  `worker_id` VARCHAR(64) DEFAULT NULL COMMENT '当前执行worker',
  `lease_token` VARCHAR(64) DEFAULT NULL COMMENT '租约令牌',
  `lease_until` DATETIME(3) DEFAULT NULL COMMENT '租约到期时间',
  `heartbeat_at` DATETIME(3) DEFAULT NULL COMMENT '最后心跳时间',
  `pulled_count` BIGINT NOT NULL DEFAULT 0 COMMENT '该分片已拉取数量',
  `published_count` BIGINT NOT NULL DEFAULT 0 COMMENT '该分片已发布数量',
  `failed_count` BIGINT NOT NULL DEFAULT 0 COMMENT '该分片失败数量',
  `last_error_code` VARCHAR(64) DEFAULT NULL COMMENT '最近错误码',
  `last_error_message` VARCHAR(1024) DEFAULT NULL COMMENT '最近错误信息',
  `ext_json` JSON DEFAULT NULL COMMENT '扩展上下文',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_shard_task_id` (`shard_task_id`),
  KEY `idx_batch_id_status` (`batch_id`, `status`),
  KEY `idx_task_code_batch_id` (`task_code`, `batch_id`),
  KEY `idx_status_lease` (`status`, `lease_until`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步分片任务表';
supplier_sync_error_task
CREATE TABLE `supplier_sync_error_task` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `error_task_id` VARCHAR(64) NOT NULL COMMENT '错误处理任务ID',
  `batch_id` VARCHAR(64) NOT NULL COMMENT '所属批次ID',
  `source_shard_task_id` VARCHAR(64) NOT NULL COMMENT '来源分片任务ID',
  `recovery_type` VARCHAR(32) NOT NULL COMMENT 'RETRY_FETCH / RETRY_TRANSFORM / RETRY_PUBLISH / MANUAL_REVIEW / SKIP',
  `target_stage` VARCHAR(32) NOT NULL COMMENT 'FETCH / TRANSFORM / PUBLISH',
  `status` VARCHAR(32) NOT NULL COMMENT 'INIT / RUNNING / SUCCESS / FAILED / CANCELED',
  `start_checkpoint` VARCHAR(512) DEFAULT NULL COMMENT '恢复起点',
  `retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数',
  `operator_type` VARCHAR(16) DEFAULT NULL COMMENT 'SYSTEM / OPS / SCHEDULER',
  `operator_id` VARCHAR(64) DEFAULT NULL COMMENT '触发主体',
  `last_error_code` VARCHAR(64) DEFAULT NULL COMMENT '最近错误码',
  `last_error_message` VARCHAR(1024) DEFAULT NULL COMMENT '最近错误信息',
  `ext_json` JSON DEFAULT NULL COMMENT '扩展上下文',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_error_task_id` (`error_task_id`),
  KEY `idx_source_shard_task_id_status` (`source_shard_task_id`, `status`),
  KEY `idx_batch_id_status` (`batch_id`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步错误处理任务表';
supplier_sync_shard_error_log
CREATE TABLE `supplier_sync_shard_error_log` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `batch_id` VARCHAR(64) NOT NULL COMMENT '所属批次ID',
  `shard_task_id` VARCHAR(64) NOT NULL COMMENT '分片任务ID',
  `stage` VARCHAR(32) NOT NULL COMMENT 'FETCH / TRANSFORM / PUBLISH',
  `error_code` VARCHAR(64) NOT NULL COMMENT '错误码',
  `error_message` VARCHAR(1024) DEFAULT NULL COMMENT '错误信息',
  `checkpoint` VARCHAR(512) DEFAULT NULL COMMENT '失败时checkpoint',
  `raw_ref` VARCHAR(512) DEFAULT NULL COMMENT '原始快照引用',
  `normalized_ref` VARCHAR(512) DEFAULT NULL COMMENT '标准化快照引用',
  `retry_count` INT NOT NULL DEFAULT 0 COMMENT '失败时重试次数',
  `trace_id` VARCHAR(128) DEFAULT NULL COMMENT '链路追踪ID',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_shard_task_id_created_at` (`shard_task_id`, `created_at`),
  KEY `idx_batch_id_stage` (`batch_id`, `stage`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步分片失败日志表';
supplier_sync_state_ledger
CREATE TABLE `supplier_sync_state_ledger` (
  `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `supplier_id` BIGINT NOT NULL COMMENT '供应商ID',
  `outer_goods_id` VARCHAR(128) NOT NULL COMMENT '外部对象ID',
  `last_batch_id` VARCHAR(64) DEFAULT NULL COMMENT '最近处理批次',
  `state` VARCHAR(32) NOT NULL COMMENT 'INIT / PROCESSING / SUCCESS / FAILED / SKIPPED / BLOCKED / OFFLINE_CANDIDATE',
  `last_snapshot_ref` VARCHAR(512) DEFAULT NULL COMMENT '最近快照引用',
  `raw_hash` VARCHAR(128) DEFAULT NULL COMMENT '最近原始快照哈希',
  `normalized_hash` VARCHAR(128) DEFAULT NULL COMMENT '最近标准化快照哈希',
  `payload_hash` VARCHAR(64) DEFAULT NULL COMMENT '供应商报文哈希,仅用于观测或安全短路',
  `last_seen_at` DATETIME(3) DEFAULT NULL COMMENT '最近打卡时间',
  `off_reason` VARCHAR(128) DEFAULT NULL COMMENT '下线原因',
  `off_batch_id` VARCHAR(64) DEFAULT NULL COMMENT '候选下线批次',
  `error_code` VARCHAR(64) DEFAULT NULL COMMENT '最近错误码',
  `error_message` VARCHAR(1024) DEFAULT NULL COMMENT '最近错误信息',
  `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_supplier_outer_goods` (`supplier_id`, `outer_goods_id`),
  KEY `idx_last_batch_id_state` (`last_batch_id`, `state`),
  KEY `idx_supplier_state_seen` (`supplier_id`, `state`, `last_seen_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='供应商同步状态台账表';

这些 schema 的核心价值不在于字段有多全,而在于它们把供给平台真正承担的三类主权表达清楚了:

  • 任务编排主权task / task_item
  • 审计与补偿主权operation_log
  • 供应商长任务状态主权supplier_sync_task / batch / state_ledger

其中有两个实现细节需要明确:

  • Local 单品创建/编辑虽然是同步交互,但后端仍建议创建轻量 product_supply_task(total_count=1, execution_mode=SYNC),这样才能统一审计、发布追踪和失败补偿。
  • product_supply_task_item.normalized_snapshot 保存的是供给平台完成标准化后的对象快照,用于校验、Diff 和发布编排;它不是商品主域写侧的 Draft / Staging 资产本体。

4. 详细设计 - 商品中心

4.1 商品中心定位与职责边界

商品中心不是“后台录入页面直接写的数据库”,而是 商品主域写侧 + 正式读模型 的组合体。

它负责:

  • Draft / Item / Item Snapshot / Merge
  • 正式商品的版本演进与发布
  • 订单可解释的交易快照
  • 面向搜索、缓存、计价、营销、数据平台的正式事件输出

它不负责:

  • 供给入口线程池、Excel 跑批、供应商接入
  • 库存账本、预占释放、券码池
  • 人工审核工作台和审核队列

在这套架构里:

  • 供给平台负责接入、标准化、任务编排和发布触发
  • QC 平台负责机审核、人工审核和 verdict
  • 库存中心负责库存事实和扣减账本
  • 商品中心负责把“一个可编辑草稿”收敛成“一个可交易的正式版本”

一句话说,商品中心沉淀的不是录入过程,而是 正式交易契约与其可发布版本

4.2 决策点

4.2.1 决策点 1:为什么正式商品表不能直接承接后台录入流程

方案优点缺点 / 风险推荐结论
方案 A:后台直接改正式商品表实现简单正式态与流程态混杂;审核、驳回、撤回难以表达;旧数据容易污染线上不推荐
方案 B:拆 Draft / Item流程态和正式态分离;商品中心可以承接审核态锁定、本地 merge 和正式版本推进比直接改正式表更复杂推荐

结论:

  • 商品中心必须显式拆开 Draft / Item
  • 正式表只表达正式商品,不承接 DRAFT / QC_PENDING / REJECTED 这类流程态

4.2.2 决策点 2:只有一个 Draft,提交审核后锁定不允许编辑,是否还需要 Staging

方案优点缺点 / 风险推荐结论
方案 A:单 Draft + 审核态锁定模型简单;数据天然一份;实现成本低;审核与发布链路短审核期间运营无法继续修改;QC 回调丢失会形成僵尸草稿;长审核周期下体验较差当前采用
方案 B:Draft + Staging + Item审核对象完全冻结;运营可并行准备下一版;更适合未来版本、回滚和复杂时间轴模型更重;需要维护额外副本与状态未来可演进

结论:

  • 当前设计只保留一个 Draft
  • 提交审核后,将 Draft 切换到 QC_PENDING 并禁止继续编辑,用“审核态锁定”保证审的是 A,发的也是 A
  • 这个方案本质上是“用运营阻塞换系统简单”,适合审核时间短、运营冲突低、商品结构相对稳定的阶段
  • 若未来出现长审核周期、并行准备多个未来版本、定时生效或高确定性版本回滚要求,再演进为 Draft + Staging + Item

4.2.3 决策点 3:商品中心是否需要 base_publish_version

方案优点缺点 / 风险推荐结论
方案 A:不带版本锚点,直接覆盖调用简单旧编辑覆盖新版本;补偿重放污染线上不推荐
方案 B:编辑链路强制带 base_publish_version能识别并发冲突;能阻断旧版本回滚需要版本校验和冲突提示推荐

结论:

  • 编辑链路必须携带 base_publish_version
  • 商品中心是最终的版本冲突裁决者

4.2.4 决策点 4:publish_version 是否需要单独成表

方案优点缺点 / 风险推荐结论
方案 A:轻量方案,publish_version 只内联在 product_item_tab模型轻;实现简单;足以承接 base_publish_version 校验、并发保护和正式态版本推进缺少完整版本历史;版本级审计、回滚和外部对账能力较弱当前采用
方案 B:独立 publish_version 表,承接正式版本历史发布历史完整;易做版本审计、历史回溯和版本级回滚模型更重;需要额外维护版本副本与一致性未来可演进

结论:

  • 当前商品中心先采用轻量方案,在 product_item_tab 上只保留一个当前 publish_version 数字
  • 只要能满足 base_publish_version 校验、并发保护和正式态版本推进,就不必先引入独立版本历史表
  • 当后续需要完整发布历史、版本审计、版本级回滚或版本级外部对账时,再平滑演进为独立 publish_version

4.2.5 决策点 5:发布时是否允许跨服务长事务

方案优点缺点 / 风险推荐结论
方案 A:商品、QC、搜索、营销同步长事务提交表面一致耦合重;回滚困难;下游故障放大不推荐
方案 B:商品中心内部本地 merge + Outbox 异步投影正式态闭环清晰;下游解耦;易补偿接受最终一致推荐

结论:

  • 商品中心内部本地事务完成 merge
  • 外部系统通过 Outbox / 事件驱动刷新投影

4.2.6 决策点 6:无效变更过滤应该放在供给侧还是商品中心

方案优点缺点 / 风险推荐结论
方案 A:供给侧做最终无效变更裁决上游流量少供给侧不掌握正式真相,容易误跳过不推荐
方案 B:商品中心承担最终 Diff / 版本保护 / 幂等裁决真相源统一;可结合正式 DTO、版本和字段主导权裁决商品中心承担更多过滤职责推荐

结论:

  • 最终业务级 Diff、幂等和真相裁决由商品中心承担
  • 供给侧只做安全过滤,不做最终业务跳过裁决

4.2.7 决策点 7:重复商品识别和供应商无效更新过滤,是否应该前移到供给侧

方案优点缺点 / 风险推荐结论
方案 A:供给侧识别重复商品并做最终无效更新过滤上游压力更小;进入商品中心的流量更少供给侧不掌握正式商品真相;不同来源的商品语义唯一键难统一;容易误判重复或误跳过真实变更不推荐
方案 B:商品中心负责重复商品识别与最终无效更新过滤自然键、正式 DTO、字段主导权和 publish_version 全在商品中心闭环;裁决更准确商品中心要承担更多前置比较和幂等逻辑推荐

结论:

  • 重复商品识别最终无效更新过滤 都收敛到商品中心
  • 供给侧只做拓扑级安全过滤,例如同批次重复页、重投踩脚、无映射死链
  • 商品中心负责:
    • 基于业务自然键识别重复商品
    • 基于正式 DTO、字段主导权和 base_publish_version 过滤掉无效更新
    • 基于乐观锁阻断并发覆盖

4.2.8 决策点 8:商品中心写模型的幂等,应该靠单点防重还是多层立体防御

方案优点缺点 / 风险推荐结论
方案 A:只靠单一防重表或单一请求 ID实现简单;入门成本低只能挡住部分重复请求,无法同时解决重复创建、旧版本覆盖、状态乱序推进不推荐
方案 B:请求级、对象级、版本级、状态机级四层立体防御能分别拦截网络重试、自然键冲突、旧版本覆盖和状态乱序;和商品中心真相模型天然对齐设计更复杂,需要把幂等分层落实到接口、表结构和状态机推荐

结论:

  • 商品中心写模型幂等不能靠单一“防重表”解决
  • 在当前 Draft + product_item_tab.publish_version + 本地 merge 的设计中,应采用四层立体防御:
    • 请求级幂等request_id / operation_id
    • 对象级幂等:业务自然键唯一约束
    • 版本级幂等draft.base_publish_version vs item.publish_version
    • 状态机幂等:状态推进必须条件更新
  • 这四层分别拦截:
    • 网络重试 / MQ 重投
    • 重复商品创建
    • 旧请求覆盖新版本
    • 状态乱序推进与僵尸回调

4.2.9 决策点 9:异构异质多品类(服务、时空资源、契约)商品应该如何组织

这个决策点本质上是在权衡:

  • 商品中心是否要把话费充值、账单还款、酒店、电影、演出票、优惠券都建成同一种“重商品对象”
  • 还是只维护统一交易货架,把真正复杂的物理资源、时空资源和渠道契约下沉到垂直业务域

这里需要先看清 4 个核心冲突:

  1. 资产属性冲突
    • 实物、券码更接近静态实体
    • 酒店、电影属于时空资源,价格和可售性跟日期、场次、房型强绑定
    • 话费、账单更接近 API 驱动的数据服务,本身并没有稳定的“自建库存商品”
  2. 写频率冲突
    • 实物标题、图文、卖点更新频率低
    • 酒店房态价、话费渠道进价高频波动
    • 如果全部走标准 Draft -> QC -> Publish,正式版本号会被高频资源波动打爆
  3. 状态主权冲突
    • 平台是否允许卖某个品类是一层主权
    • 某一家酒店、某一个电影场次、某一个充值通道是否可卖是另一层主权
    • 如果所有资源都塞进商品中心统一状态机,写模型会过度膨胀
  4. 交易底座共用需求
    • 尽管供给模型差异极大,但购物车、营销、支付、收银台仍然希望看到统一交易契约
方案优点缺点 / 风险推荐结论
方案 A:大一统宽表模型交易系统只认一套 DTO,看起来最统一异构字段混杂;代码充满 if biz_type == HOTEL;高频资源波动会把商品中心写模型拖垮不推荐
方案 B:完全垂直分治模型各业务高内聚,独立演进快交易、营销、支付、结算无法共底座;多品类合单和统一治理能力很弱不推荐
方案 C:中台化“主干 + 品类外挂 Schema / 资源域”模型兼顾统一交易货架和垂直业务灵活性;商品中心保持轻,资源域承接高频复杂变化前期模型设计更抽象,需要明确主干契约与资源主权边界推荐

结论:

  • 当前商品中心采用 “主干交易契约 + 垂直资源域” 的组织模型
  • 商品中心维护的是全平台统一的 售卖货架与交易契约
  • 真正复杂的资源真相下沉到各自垂直业务域治理,例如:
    • 酒店域维护 hotel_id / room_type / date -> price & availability
    • 电影域维护 cinema_id / show_id / seat -> inventory
    • 充值域维护 carrier / amount / channel -> price & capability

进一步的落地结论是:

  1. 商品中心维护的是 Meta Item,而不是全量物理资源
    • 不为每一家物理酒店、每一个场次、每一个充值通道都创建重商品对象
    • 商品中心只维护少量可交易的“品类元商品”或统一货架契约
  2. 交易链路通过动态参数携带具体资源上下文
    • 例如 hotel_idroom_typebiz_datephone_numbercarrier_code
    • 商品中心在交易时负责返回统一交易契约,不负责承载所有资源细节
  3. 订单快照通过运行时多态合成
    • 订单侧拿到 item_id + ext_params
    • 商品中心以主干 Item 为底稿,按需向垂直资源域取当时真相,合成 item_snapshot
    • 这样既保证统一交易格式,又不把高频资源变化硬塞进商品中心主表
  4. 生命周期采用双层状态防御
    • 商品中心状态只表达“平台是否允许售卖这一类商品契约”
    • 具体某家酒店、某个场次、某条充值通道是否可售,由垂直资源域继续判定

一句话说:

商品中心写的核心,不是去记录这个世界上所有物理资源的变化,而是去维护全平台统一的交易货架与售卖契约。让商品中心保持轻量,让资源复杂度回归垂直业务域,通过运行时快照在交易发生的瞬间完成真相交汇。

4.3 商品中心核心模型

商品中心当前只显式建模这 3 个核心对象:

  • Draft
  • Item
  • Item Snapshot

4.3.1 Draft

  • 待审核、待发布、可编辑
  • 是供给平台与商品中心交互时最主要的写入对象
  • 同一时刻只保留一个当前 Draft
  • 提交审核后会被锁定,直到审核结论返回或人工撤回

4.3.2 Item

  • 只表达正式可交易商品
  • 承担对外读模型主权
  • 不能混入草稿、审核中的流程态
  • publish_version 当前作为 Item 的内联字段存在,是并发保护与正式版本推进的锚点

4.3.3 Item Snapshot

  • 是订单解释事实
  • 订单只信交易时绑定的 item_snapshot
  • 不依赖“当前线上商品是否又被改过”

4.3.4 对象关系与核心字段

对象角色关键字段备注
Draft可编辑流程态draft_id / base_publish_version / status审核中会被锁定
Item正式交易对象item_id / publish_version只承接正式态
Item Snapshot订单解释事实item_snapshot_id交易快照

4.4 核心功能:创建商品草稿与审核态锁定

4.4.1 场景画像

这个场景解决的是:

  • 供给平台提交一份标准化商品 DTO
  • 商品中心不直接生成正式 Item
  • 而是先创建一个可审核、可锁定、可追溯的 Draft

核心目标是:

  • 创建草稿不等于正式发布
  • 审核对象必须稳定
  • 供给侧拿到的是 draft_id,不是正式商品主权

4.4.2 完整时序图

sequenceDiagram
    participant S as "供给平台"
    participant P as "商品中心"
    participant D as "Draft"

    S->>P: CreateDraft(标准 DTO, operation_id)
    P->>D: 创建 Draft
    D-->>P: 返回 draft_id
    P-->>S: 返回 draft_id / current_publish_version

    S->>P: SubmitDraft(draft_id)
    P->>D: 读取当前 Draft
    P->>D: status = QC_PENDING,锁定草稿
    P-->>S: 返回 draft_id / base_publish_version

4.4.3 关键技术点

技术点说明
创建草稿不等于正式发布商品中心先落 Draft,不直接改正式 Item
审核态锁定是当前设计的核心防线提交审核后将 Draft 切到 QC_PENDING,物理拒绝后续编辑,避免“审的是 A,发的是 B”。
商品中心返回的是可审核对象返回 draft_id / base_publish_version,而不是正式商品主权。
当前版本锚点直接来自 Itemcurrent_publish_versionbase_publish_version 都直接对齐 product_item_tab.publish_version

4.5 核心功能:商品编辑、版本冲突与增量保护

4.5.1 场景画像

这个场景解决的是:

  • 运营或供给侧基于当前正式版本做编辑
  • 多人并发或异步补偿重放时,如何防止旧版本覆盖新版本
  • 最终的 Diff、字段主导权和版本裁决应该由谁负责

4.5.2 完整时序图

sequenceDiagram
    participant S as "供给平台"
    participant P as "商品中心"
    participant I as "正式 Item"
    participant D as "Draft"

    S->>P: 加载当前正式对象(item_id)
    P->>I: 读取正式 Item / publish_version
    I-->>P: 返回 current_publish_version / 当前 DTO
    P-->>S: 返回 current_publish_version

    S->>P: UpdateDraft(draft_id, base_publish_version, 增量字段)
    P->>I: 校验当前 publish_version
    alt 版本不匹配
        P-->>S: VERSION_CONFLICT
    else 版本匹配
        P->>D: 更新 Draft
        P-->>S: DraftUpdated
    end

4.5.3 关键技术点

技术点说明
必须带 base_publish_version没有版本锚点,就无法阻断旧编辑覆盖新版本。
商品中心承担最终 Diff 与版本校验商品中心拥有正式真相,不能把最终裁决主权上移到供给侧。
旧版本回调或旧数据写入必须被拦截无论同步提交还是异步补偿,旧版本都应被物理拒绝。
重复商品识别要基于业务自然键而不是 item_id创建链路不能等拿到 item_id 才判断重复。商品中心应基于标品条码、品牌 + 型号、供应商 ID + 外部 SPU 等自然键建立唯一约束,必要时可加 Redis / 布隆过滤器做前置拦截。
供应商无效更新过滤是“商品中心内存 Diff + 字段主导权”的组合防线商品中心在进入本地事务前,应先基于当前正式 DTO 做轻量字段级 Diff;若供应商传来的字段在主导权矩阵下没有任何有效变化,则直接返回成功,不惊动正式落库、快照和 Outbox。
字段主导权矩阵决定“哪些变化算有效变化”价格、库存、状态等高频字段通常允许供应商驱动;标题、主图、类目、规格等一旦经过人工治理,主权应收归商品中心。供应商即使传了这些字段,也必须先经过主导权裁剪,再参与 Diff。
前置 Diff 只能裁决流量,后置版本锚点裁决并发即使前置 Diff 认为是有效变化,也不能直接覆盖正式态。真正进入更新链路后,仍然要依赖 base_publish_versionproduct_item_tab.publish_version 的比较,防止两个并发更新请求互相覆盖。

商品中心对“是否跳过本次更新”的判断,建议遵循下面这条心智:

  1. 先读取当前正式 Item DTO 与 publish_version
  2. 依据字段主导权矩阵裁掉供应商无权覆盖的字段
  3. 对保留下来的有效字段做轻量内存 Diff
  4. 如果有效字段完全一致,则直接判定为 NO_OP 并返回成功
  5. 如果存在有效变化,再进入正式的版本校验和本地事务 merge

在 Go 里的典型实现,可以是这种“强类型快路径 + 版本锚点兜底”的组合:

type SPUTruth struct {
    Title     string   `json:"title"`
    BrandID   int64    `json:"brand_id"`
    Price     int64    `json:"price"`
    ImageURLs []string `json:"image_urls"`
}

func ShouldSkipUpdate(currentLive SPUTruth, incomingReq SPUTruth) bool {
    if currentLive.Title == incomingReq.Title &&
        currentLive.BrandID == incomingReq.BrandID &&
        currentLive.Price == incomingReq.Price &&
        sliceEqual(currentLive.ImageURLs, incomingReq.ImageURLs) {
        return true
    }
    return false
}

这里的 ShouldSkipUpdate() 只负责过滤掉 完全没有有效变化 的请求;真正的并发保护仍然依赖:

  • base_publish_version
  • product_item_tab.publish_version
  • merge 时的乐观锁或版本校验

4.6 核心功能:审核输入、发布编排与本地 Merge

4.6.1 场景画像

这个场景解决的是:

  • 商品中心如何接收 QC verdict
  • verdict 与当前锁定草稿不一致时如何阻断
  • 发布时如何在本地事务中完成 merge,而不是依赖跨服务长事务

4.6.2 完整时序图

sequenceDiagram
    participant Q as "QC 平台"
    participant P as "商品中心"
    participant D as "Draft"
    participant I as "正式 Item"
    participant SNAP as "Item Snapshot"
    participant O as "Outbox"

    Q->>P: ReceiveQcVerdict(draft_id, publish_version, result)
    P->>D: 校验 draft_id / status=QC_PENDING / base_publish_version
    alt verdict 不匹配 / 版本已演进
        P-->>Q: REJECT_STALE_VERDICT
    else PASS
        P->>I: 本地事务 merge 到正式 Item
        P->>I: publish_version = publish_version + 1
        P->>SNAP: 写 item_snapshot
        P->>O: 写发布事件
        P->>D: status = PUBLISHED
        P-->>Q: PUBLISH_ACCEPTED
    end

4.6.3 关键技术点

技术点说明
verdict 只是轻量判决书QC 回传的是审核结果,不是整份商品资产。
商品中心自己完成最终 merge正式态落库必须在商品中心内部闭环完成。
发布不依赖跨服务长事务商品中心内部本地提交,外部通过 Outbox 异步感知。
正式版本号直接推进在 Item当前轻量方案不单独维护 publish_version 表,merge 成功后直接推进 product_item_tab.publish_version
紧急下架优先级高于审核与发布OFFLINE 属于高主权运营动作,不需要再走 QC。一旦运营在正式商品上执行紧急下架,而当前 Draft 正处于 QC_PENDING,商品中心必须在同一控制动作中把 product_item_tab.status 切为 OFFLINE,并将当前锁定草稿从 QC_PENDING 强制熔断为 TERMINATED 或可恢复的 EDITING。否则旧的 QC PASS 回调晚到时,会把已经下架的商品重新推回线上。
平台封禁会冻结整个写模型BANNED 是平台级否决态,比普通 OFFLINE 更强。一旦商品进入 BANNED,商品中心必须在最外层校验中直接拒绝 CreateDraftUpdateDraftSubmitDraftPublish,直到解除封禁。这样可以防止供给侧、供应商或异步补偿把已经被平台封禁的商品重新带回编辑或发布链路。
归档不是下架的别名,而是历史终态ARCHIVED 用于表达长期退出经营、只保留历史解释能力的正式商品。进入 ARCHIVED 后,不应再直接复用原对象进入正常编辑和发布链路;如需恢复,更推荐复制为新草稿或走显式恢复动作,避免历史态和新经营态混淆。

4.7 核心功能:商品编辑历史与审计轨迹

4.7.1 场景画像

这个场景解决的是:

  • 运营、商家或系统在商品中心里到底改过什么
  • 谁在什么时间点修改了哪些字段
  • 一次修改到底属于草稿编辑、审核流转,还是正式发布
  • 当线上商品出现争议时,如何回放“这版商品是怎么变成现在这样”的路径

这里的目标不是做一份通用日志,而是沉淀一条 商品中心可解释的编辑历史链路

  • 面向运营,能回答“谁改了什么”
  • 面向排障,能回答“这版数据从哪来”
  • 面向审计,能回答“这是 Draft、QC,还是 Publish 阶段的动作”
  • 面向前台展示,能直接渲染出“字段从旧值变成新值”的结构化 Diff,而不是只给一条“某某改了商品”的流水文本

因此这里不能只做一张泛化操作日志,而是要先把历史分成 3 类,再决定每类记录什么:

  1. Draft 历史

    • 表示草稿编辑阶段的字段变更
    • 重点回答:谁改了哪些字段
    • 典型动作:EDIT_DRAFT / WITHDRAW / RESUBMIT
  2. QC 历史

    • 表示审核流转阶段的状态动作
    • 重点回答:谁在什么时候提交审核、驳回、重新提交、通过
    • 典型动作:SUBMIT_FOR_QC / REJECT / APPROVE / ABORT
    • QC 历史更偏“流程动作”,不一定总有字段级 Diff
  3. Publish 历史

    • 表示锁定草稿最终如何进入正式态
    • 重点回答:哪一个 Draft 在什么时候发布成了哪一个正式版本
    • 典型动作:PUBLISH / ROLLBACK / OFFLINE
    • Publish 历史要和 publish_versionitem_snapshot 绑定,保证正式版本可解释

这意味着商品中心保存的不应该只是“操作事件”,而应该是 对象级 / 字段级的变更差异。例如:

  • 商品标题:iPhone 15 基础版 -> iPhone 15 降价促销版
  • 商品底价:5999.00 -> 5499.00
  • 发货时效:48h -> 24h

而不是仅仅记录:

  • 张三在 2026-06-05 12:00 修改了商品

4.7.2 完整时序图

sequenceDiagram
    participant S as "供给平台 / 运营后台"
    participant P as "商品中心"
    participant D as "Draft"
    participant H as "Edit History"
    participant I as "正式 Item"

    S->>P: UpdateDraft(draft_id, base_publish_version, 增量字段, operator)
    P->>D: 读取当前 Draft
    P->>D: 更新 Draft
    P->>H: 记录字段级 Diff(change_payload, operator)
    P-->>S: DraftUpdated

    S->>P: SubmitDraft(draft_id, operator)
    P->>H: 记录 QC 历史(action=SUBMIT_FOR_QC)
    P-->>S: Submitted

    S->>P: Publish(draft_id, operator)
    P->>I: merge 到正式 Item
    P->>H: 对比旧 Item 与锁定 Draft,记录 Publish 历史(action=PUBLISH, publish_version, change_payload)
    P-->>S: PublishAccepted

4.7.3 关键技术点

技术点说明
编辑历史是商品主域能力,不是外围日志拼接只有商品中心最清楚草稿、审核和正式发布之间的对象关系,因此编辑历史必须在商品中心内生成。
历史必须先分层再落库不能把 Draft、QC、Publish 全混成一条“操作流水”。至少要能从 action_stage=DRAFT / QC / PUBLISH 看出动作所处阶段。
核心不是“有人改过”,而是“到底改了什么”编辑历史要沉淀成字段级 Diff,而不是只有一条操作流水。日志主载荷应该是 change_payload=[{field, old, new}] 这一类结构化变更报文。
最优生成时机是商品中心事务内闭环不推荐在 Controller/AOP 层截请求,因为那时只知道“上游传了什么”,不知道“正式对象最终变成了什么”。更合理的做法是在商品中心完成 Draft 更新或本地 merge 时,拿“旧真相”和“新真相”做 Diff,再与业务对象一起落库。
不追求存整份大对象,重点记录“谁、何时、改了哪些字段”审计记录要以 operator / action / changed_fields / change_payload / before_summary / after_summary 为核心,避免把日志表变成第二份商品主表。
Draft、QC、Publish 要分别记录“改草稿”“提交/驳回审核”“正式发布”不是一回事,必须能从审计链路上区分动作阶段、动作类型和是否带字段 Diff。
大字段要分级降级标题、价格、状态等小字段可以精确记录 old/new;图文 HTML、长图列表、超大 SKU 结构不直接整段塞进日志,只记录 changed=true 或摘要。需要深度比对时,再按 publish_versionsnapshot_id 去异步拉两份快照做前端 Diff。
Go 语言里的 DiffUtil 优先走“轻量反射 + 结构化输出”在 Go 里最务实的实现通常是基于 reflect 的轻量通用 Diff:通过结构体标签控制字段名与忽略策略,输出 [{field, old, new}] 形式的 change_payload。如果模型嵌套极深,也可以引入 go-cmp 或 JSON Patch 作为补充,但核心目标仍是产出结构化差异,而不是一段不可查询的文本。
高并发事务内不能无脑深度 reflect.DeepEqual反射比对在复杂对象上会带来明显 CPU 和逃逸开销,因此要做三层防线:先走快路径拦截(例如指针、版本、轻字段快速比较),再做有限字段 Diff;对大文本和复杂 SKU 结构只记 changed=true;真正昂贵的深度比对通过异步审计或前端本地 Diff 解决。
极端性能场景可以用代码生成替代反射如果商品 DTO 很大、发布吞吐高,Go 里可以通过 go generate 或静态生成函数的方式,为核心 DTO 产出专用 CustomDiff(),把运行时反射替换成编译期硬编码字段比较,进一步降低事务内开销。
历史记录服务于解释和回放,不承担正式真相主权真正的正式商品仍以 product_item_tab 为准;编辑历史负责解释这版数据是如何演进而来的。

建议的 change_payload 形态示例:

[
  {
    "field": "title",
    "label": "商品标题",
    "old": "iPhone 15 基础版",
    "new": "iPhone 15 降价促销版"
  },
  {
    "field": "price",
    "label": "商品底价(元)",
    "old": 5999.00,
    "new": 5499.00
  },
  {
    "field": "detail_html",
    "label": "商品详情",
    "changed": true
  }
]

在 Go 里的实现建议是:

  1. 默认方案:轻量反射 Diff

    • 基于 reflect 遍历 DTO 字段
    • 结合结构体标签控制:
      • 字段展示名
      • 是否参与 Diff
    • 输出统一的 change_payload=[{field, label, old, new}]
  2. 复杂嵌套补充:go-cmp / JSON Patch

    • 如果对象层级非常深,可以引入:
      • github.com/google/go-cmp/cmp
      • github.com/evanphx/json-patch
    • 但它们更适合作为辅助工具,不建议直接把原始文本 Diff 结果塞进主日志表
  3. 高性能优化:代码生成

    • 若事务内反射成本过高,可以为核心 DTO 生成专用 Diff 方法
    • 例如:
func (old *ProductDTO) CustomDiff(new *ProductDTO) []ChangeLogDetail
  • 用原生 if old.Price != new.Price 这类静态比较替换反射
  • 适合高频发布、深对象和严格事务延迟场景

一句话说:

Go 里的 DiffUtil 不追求“最炫的通用框架”,而追求“结构化输出、事务内足够轻、重 Diff 可以延后”。

前台展示心智应该是:

  • 操作人:张三
  • 时间:2026-06-05 12:00:00
  • 生效版本:V3
  • 变更内容:
    • 修改了商品标题:iPhone 15 基础版 -> iPhone 15 降价促销版
    • 修改了商品底价:5999.00 -> 5499.00
    • 修改了商品详情:点击查看 Diff

4.8 发布后读模型与外部投影

商品中心正式发布后,要统一驱动:

  • 搜索索引刷新
  • 缓存刷新
  • 计价上下文更新
  • 营销圈品
  • 数据平台投影
  • 订单快照读取

这里的边界要明确:

  • 商品中心负责发布正式事件
  • 外部系统自己消费并刷新投影
  • 订单只认 item_snapshot,不认“此刻线上商品”

4.9 异常处理与版本恢复

这一节要回答的不是“如何永不失败”,而是失败后如何解释、恢复、阻断污染。

商品中心写链路的终极心智是:

在商品中心主域里,永远不要试图去“修改”或“倒退”历史。所有的修改、发布、下架、封禁,甚至是回滚,在底层的物理世界上,都是一次携带着最新版本锚点的正向时间演进(+1)。只要把住这条铁律,商品中心的写模型就立于不败之地。

异常类型触发条件风险处理策略
版本冲突base_publish_version 不匹配旧编辑覆盖新版本直接拒绝提交,返回版本冲突
旧审核回调乱序旧 verdict 晚于新版本到达用旧审核结果污染新版本校验 draft_id + status=QC_PENDING + base_publish_version,不匹配直接拒绝
紧急下架与 QC 并发商品已被运营切到 OFFLINE,但旧的 PASS 回调晚到已下架商品被“死灰复燃”重新上架下架动作为高优先级主权动作。执行下架时同步终止当前 QC_PENDING 草稿,将其切为 TERMINATED 或恢复到 EDITING;后续旧 verdict 因 draft_id + status + base_publish_version 校验失败被直接丢弃
平台封禁后的写请求商品已进入 BANNED,供给侧仍发来创建、编辑或发布请求被平台封禁的商品重新进入可售链路在商品中心写模型最外层直接校验 item.status=BANNED,返回 ITEM_BANNED 并拒绝生成或推进任何 Draft;解除封禁前冻结版本推进和发布路径
发布成功但投影失败Item 已 merge,下游未刷新外部系统短暂不一致依赖 Outbox 重试与补偿,不回滚正式 Item
Draft 已建成但 verdict 丢失QC 长时间未回调草稿悬空标记超时待处理,允许重新提交或人工干预
merge 失败本地事务中断新版本未生效事务回滚,保留 Draft,待补偿重试
大字段 Diff 内存耗尽超长图文详情、复杂 SKU 结构在事务内做深度 DiffCPU 飙高、内存逃逸、事务耗时拉长事务内只做轻字段 Diff 和 changed=true 标记,不在本地事务里计算大文本逐字差异;重 Diff 延迟到异步审计或前端本地渲染
snapshot 未成功落盘正式商品已写,快照未固化订单解释事实缺失item_snapshot 必须与正式 Item merge 处于同一个本地事务;快照写失败直接回滚整个事务,阻断发布完成态,等待上游重试

目标是:

  • 阻断旧版本回滚污染
  • 保证正式态始终可解释
  • 补偿不跨越商品中心真相边界

4.10 商品中心表清单与使用场景

4.10.1 每个表的使用场景

使用场景关键边界
product_draft_tab承接待审、待编辑草稿可编辑流程态,不是正式商品
product_item_tab承接正式商品与当前正式版本号只表达正式可交易商品;publish_version 当前以内联字段存在
item_snapshot订单解释事实订单只信快照
product_edit_history_log记录商品 Draft/QC/Publish 三阶段历史负责解释演进路径和字段 Diff,不承接正式商品真相

如果后续第 5 章把 QC 平台表完全独立出来,则:

  • product_qc_review 在第 3 章只作为交互对象提及
  • 不应列为商品中心权威表

4.10.2 每个表的 Schema

product_draft_tab
字段含义
draft_id草稿主键
item_id对应正式商品 ID,可为空
base_publish_version基于哪个正式版本开始编辑
draft_snapshot当前草稿对象快照
statusEDITING / QC_PENDING / APPROVED / REJECTED / PUBLISHED / WITHDRAWN / TERMINATED
created_at / updated_at审计时间
product_item_tab
字段含义
item_id正式商品主键
publish_version当前正式版本
item_snapshot_ref当前正式快照引用
statusONLINE / OFFLINE / ENDED / BANNED / ARCHIVED
updated_at最近正式态变更时间
item_snapshot
字段含义
snapshot_id快照主键
item_id对应正式商品
publish_version快照对应版本
snapshot_payload交易快照内容
created_at快照时间
product_edit_history_log
字段含义
history_id编辑历史主键
item_id对应正式商品,可为空
draft_id对应草稿
action_stageDRAFT / QC / PUBLISH
publish_version若为发布动作,对应正式版本
actionEDIT_DRAFT / SUBMIT_FOR_QC / PUBLISH / WITHDRAW / REJECT
operator_id操作人或系统标识
operator_name操作人展示名称
op_time操作时间
changed_fields本次改动涉及的字段列表
change_payload字段级变更 Diff,如 [{field, old, new}]
before_summary变更前摘要
after_summary变更后摘要
created_at事件时间

5. 详细设计 - 库存中心

5.1 库存中心定位与职责边界

库存中心管理的不是一个简单数字,而是平台对用户的一种 可承诺供给能力

它负责:

  • 库存对象与范围建模
  • 可售量、预占、确认、释放
  • 券码池、锁库存、账本
  • 对账、修复、热视图恢复

它不负责:

  • B 端商品编辑
  • 商品审核流程
  • 正式商品资产主权

5.2 决策点

5.2.1 决策点 1:库存中心是否只管理一个数字

方案优点缺点 / 风险推荐结论
方案 A:只维护一个可售数量字段模型简单无法表达预占、确认、释放、锁定、券码和资源实例;也无法解释库存为什么变成现在这样不推荐
方案 B:库存中心管理“可承诺供给能力”能同时承接余额、预占、账本、券码池和修复治理模型更复杂推荐

结论:

  • 库存中心管理的不是一个单纯数字,而是用户可被平台承诺的供给能力
  • 余额只是当前视图,真正的库存事实还包括预占、账本和资源实例

5.2.2 决策点 2:库存是否统一按 SKU 管,还是按 inventory_key

方案优点缺点 / 风险推荐结论
方案 A:所有库存统一只按 SKU 管看起来直观无法覆盖门店、日期、渠道、批次、券码池、场次等差异维度不推荐
方案 B:按 inventory_key 统一表达库存范围能把商品、门店、日期、渠道、资源池等范围抽象收口对 key 设计要求更高推荐

结论:

  • 库存不天然只按 SKU 管,而是按“承诺范围”管
  • 推荐以 inventory_key 统一表达范围,典型维度包括:
    • 商品 / SKU
    • 门店
    • 日期
    • 渠道
    • 批次
    • 券码池 / 资源池

5.2.3 决策点 3:热路径 Redis 与 MySQL 权威账本的职责分离

方案优点缺点 / 风险推荐结论
方案 A:Redis 同时承担热路径和权威真相延迟低,路径短一旦故障或漂移,无法解释“库存为什么变成这样”;热视图和事实混在一起,恢复成本极高不推荐
方案 B:Redis 负责热路径裁决,MySQL 负责权威余额与账本吞吐、延迟、可解释性和可恢复性兼顾要接受异步落库、热视图重建和最终一致治理推荐

结论:

  • Redis 负责 C 端热路径裁决
  • MySQL 负责 库存中心唯一的权威余额与权威账本
  • Redis 只是 可丢弃、可重建、可漂移的热视图
  • 所有落库解释、冲正修复、全量恢复都必须以 MySQL 为准,绝不能反向用 Redis 改写账本

一句话说:

Redis 解决“现在能不能卖得出去”,MySQL 解决“库存最终为什么会变成这样”。

5.2.4 决策点 4:数量库存、系统发券库存、外部死码库存能否共用一套事实模型

方案优点缺点 / 风险推荐结论
方案 A:全部退化成普通数字库存模型统一;实现快无法表达系统发券规则和外部死码的唯一资源事实;极易产生“有数无码”的资损不推荐
方案 B:交易命令统一,事实模型分流对外命令保持统一,对内根据 QTY / SYS_GEN / EXT_POOL 走不同事实模型需要清楚区分数量视图和资源真相推荐

结论:

  • QTY / SYS_GEN / EXT_POOL 可以共用命令层表达
  • 但事实模型必须分流
  • 尤其 EXT_POOL 必须走资源型库存,不允许退化为一串数字

5.2.5 决策点 5:库存调整应该传绝对值还是 Delta

方案优点缺点 / 风险推荐结论
方案 A:直接把页面上的绝对值写入库存中心接口简单会抹掉并发扣减、他人盘点和预占过程中的真实变化,导致账本失真不推荐
方案 B:库存中心只接受带 base_inventory_version 的 Delta能准确表达“在当前事实基础上加减多少”,并用版本锚点防并发覆盖上游需要做绝对值到 Delta 的转换推荐

结论:

  • B 端看到的是绝对值
  • 但库存中心只接受带 base_inventory_version 的 Delta 命令
  • 库存事实的版本裁决必须由库存中心完成

5.2.6 决策点 6:资源型库存(券码、座位、房态)是否应该折算成普通数字库存

方案优点缺点 / 风险推荐结论
方案 A:只保存聚合数字模型看起来统一丢失唯一资源真相;无法解释坏码、漏码、重复发码、座位冲突、房态冲突不推荐
方案 B:数字视图 + 资源实例事实双层模型既能给交易链路提供聚合余额,又能保留唯一资源级真相模型更重推荐

结论:

  • 资源型库存不能折算成普通数字库存
  • 数字只是影子,权威真相仍在资源实例表

5.2.7 决策点 7:供应商实时库存是否直接写库存中心正式账本

方案优点缺点 / 风险推荐结论
方案 A:供应商实时库存直接覆盖库存中心正式账本接入路径短容易把外部抖动、脏数据、时间差和误推送直接变成平台正式真相不推荐
方案 B:通过资源域或库存命令层受控进入能先做受控校验、限流、路由和幂等,再进入库存事实层接入链路更长推荐

结论:

  • 供应商实时库存不应直接盲写库存中心正式账本
  • 应先经过资源域或库存命令层受控进入

5.2.8 决策点 8:库存编辑中的 +10 / -10 增量操作,应该直接改余额,还是走原子增量 + 账本幂等

方案优点缺点 / 风险推荐结论
方案 A:先查当前余额,再在应用内做加减,最后覆盖写回实现直观在高并发下会发生 Lost Update;网络重试会导致重复加减;出错后无法追溯是哪次操作造成的不推荐
方案 B:数据库原子增量 + 流水账本 + 请求号幂等能防并发覆盖、能防网络重试、能完整追溯每一次变更来源模型和事务更重推荐

结论:

  • 库存中心必须把 +10 / -10 这类操作建模成 增量命令
  • 余额表更新必须采用数据库原子更新,不能先查再算再覆盖
  • 同一个事务内必须同时完成两件事:
    • inventory_ledger 写入一条增量流水
    • inventory_balance 执行原子增量更新
  • 所有增量命令都必须携带全局唯一的 request_no / operation_id
  • inventory_ledger 必须对该请求号建立唯一索引,用于拦截网络超时重试带来的重复加减

典型落地形态是:

UPDATE inventory_balance
SET usable_qty = usable_qty + :delta,
    updated_at = NOW()
WHERE inventory_key = :inventory_key
  AND (usable_qty + :delta) >= 0;

以及:

UNIQUE KEY uk_request_no (request_no)

一句话说:

库存中心里真正的真相不是“当前余额是多少”,而是“每一次 +10 / -10 到底是谁、在什么时候、基于什么请求号改进去的”。余额只是聚合结果,账本和请求号幂等才是准确性的底座。

5.2.9 决策点 9:库存追加或调整的审批流,应该放在库存中心还是供给平台

方案优点缺点 / 风险推荐结论
方案 A:审批流直接下沉到库存中心看起来链路更短,库存变更和审批状态靠得更近会把库存事实层污染成流程平台;审批人、附件、驳回、OA 单号、权限校验等流程语义侵入库存域;后续不同入口都要重复适配审批语义不推荐
方案 B:供给平台负责编排审批流,库存中心只接收已获批命令业务流程和库存事实边界清晰;供给平台统一承接 B 端入口、审批、理由、附件、任务状态和审计;库存中心专注账本、余额、资源池和幂等执行供给平台到库存中心之间多一层命令编排推荐

结论:

  • 审批流属于 业务流程主权,应放在供给平台
  • 库存中心只负责 事实执行主权
  • 供给平台应负责:
    • 发起库存调整申请
    • 审批流转
    • 操作理由、附件、OA 单号、权限校验
    • 审批通过后的正式命令编排
  • 库存中心只接收“已经获批、允许执行”的命令,例如:
    • AdjustInventory(delta, request_no, approval_no)
    • ImportCodeBatch(task_id, approval_no)
    • GenerateCodeBatch(task_id, request_no, approval_no)

这样可以形成稳定分层:

  • 供给平台负责“谁申请、谁审批、为什么改”
  • 库存中心负责“到底加了多少、减了多少、写进了哪条账本、当前余额变成多少”

一句话说:

审批属于流程治理,应该放在供给平台;库存中心只做库存事实的受控执行,不演化成审批工作流平台。

5.3 库存中心核心模型

库存中心建议显式建模以下对象:

对象作用关键边界
inventory_config定义库存管理方式、扣减时机、是否允许超卖、资源模式配置,不是事实
inventory_key统一表达库存承诺范围库存按范围表达,不等于天然只按 SKU
inventory_balance当前聚合库存视图负责当前可售量,不负责解释全部历史
inventory_reservation记录订单或业务流程中的预占过程态事实,不等于最终账本
inventory_ledger每次库存变动的权威账本真正解释“为什么变成今天这样”的事实源
inventory_code_pool_xx券码 / 唯一资源实例池资源型库存事实,不是数字余额的附属字段

这里的模型边界要钉死:

  • inventory_balance 不是审计真相,只是当前聚合视图
  • inventory_ledger 才是库存中心的最终解释事实
  • inventory_code_pool_xx 是资源型库存的主权表,不应被压扁成数字字段

5.4 核心功能:B 端库存初始化与库存类型路由

5.4.1 场景画像

这个场景解决的是:

  • 创建商品时如何同步初始化库存
  • 已有商品如何单独创建库存
  • inventory_mode = QTY / SYS_GEN / EXT_POOL 如何路由到不同初始化路径

库存中心面对的不是一个“永远只有数字”的世界,而是三类差异很大的初始化入口:

  • QTY:直接初始化数量库存
  • SYS_GEN:库存中心负责闭环生成券码,供给平台只负责发起生成任务和跟踪进度
  • EXT_POOL:初始化动作不接受手填数量,而是等待唯一资源导入来生成数量影子

5.4.2 场景一:QTY 数量库存初始化时序图

sequenceDiagram
    participant S as "供给平台"
    participant IC as "库存中心"
    participant CFG as "inventory_config"
    participant BAL as "inventory_balance"

    S->>IC: CreateInventory(item_id, inventory_mode=QTY, init_quantity)
    IC->>CFG: 写库存配置
    IC->>BAL: 初始化 available_qty
    IC-->>S: INIT_SUCCESS

5.4.3 场景二:EXT_POOL 外部死码导入初始化时序图

sequenceDiagram
    autonumber
    actor O as "运营 / 供应商"
    participant S as "供给平台"
    participant IC as "库存中心"
    participant CFG as "inventory_config / inventory_balance"
    participant POOL as "inventory_code_pool_xx"
    participant MQ as "Message Queue / Outbox Event"

    O->>S: 上传券码 Excel
    S->>S: 生成 task_id,状态=PROCESSING
    S->>IC: CreateInventory(item_id, inventory_mode=EXT_POOL, task_id)
    IC->>CFG: 创建配置,init_status=PENDING_IMPORT, usable_qty=0
    IC-->>S: INIT_PENDING

    loop 分批导码
        S->>S: 清洗本批券码,去除文件内重复
        S->>IC: ImportCodeBatch(item_id, codes_chunk[], task_id)
        IC->>POOL: INSERT IGNORE 批量落库
        IC-->>S: 返回本批成功数
    end

    S->>IC: CompleteImport(item_id, task_id)
    IC->>POOL: COUNT 有效券码真相
    IC->>CFG: usable_qty = real_count, init_status = READY
    IC->>MQ: 事务内写完成事件
    MQ-->>S: INVENTORY_IMPORT_COMPLETED(task_id, real_count)
    S->>S: CAS 更新任务状态为 SUCCESS
    S-->>O: 前台进度条完成 / 通知完成

5.4.4 场景三:SYS_GEN 系统活码生成与回调时序图

sequenceDiagram
    autonumber
    actor O as "运营 / 供应商"
    participant S as "供给平台"
    participant IC as "库存中心"
    participant W as "Inventory Gen Worker"
    participant CFG as "inventory_config / inventory_balance"
    participant POOL as "inventory_code_pool_xx"
    participant MQ as "Message Queue / Outbox Event"

    O->>S: 发布商品并要求系统生成 target_qty 个券码
    S->>S: 初始化生成任务 task_id,状态=PROCESSING
    S->>IC: CreateInventory(item_id, inventory_mode=SYS_GEN, target_qty, task_id)
    IC->>CFG: 创建配置,init_status=PENDING_GEN, usable_qty=0
    IC-->>S: TASK_ACCEPTED
    S-->>O: 商品已发布,后台造码中

    IC->>W: 异步触发内部造码 Worker
    loop 直到物理有效码数达到 target_qty
        W->>W: 生成一批带盐随机券码
        W->>POOL: INSERT IGNORE 批量落库
        W->>POOL: COUNT 当前真实有效码数
    end

    W->>CFG: usable_qty = target_qty, init_status = READY
    W->>MQ: 事务内写 INVENTORY_GEN_COMPLETED 事件
    MQ-->>S: INVENTORY_GEN_COMPLETED(task_id, real_count)
    S->>S: CAS 更新任务状态为 SUCCESS
    S-->>O: 进度条拉满 / 通知完成

5.4.5 场景四:QTY 数量库存追加时序图

sequenceDiagram
    autonumber
    actor O as "运营 / 供给平台"
    participant IC as "库存中心"
    participant BAL as "inventory_balance"
    participant LED as "inventory_ledger"

    O->>IC: AdjustInventory(item_id, delta=+10, request_no, base_inventory_version)
    IC->>LED: 事务内插入 +10 流水(request_no 唯一)
    IC->>BAL: usable_qty = usable_qty + 10(原子更新)
    IC-->>O: ADJUST_SUCCESS

5.4.6 场景五:EXT_POOL 外部死码追加时序图

sequenceDiagram
    autonumber
    actor O as "运营 / 供应商"
    participant S as "供给平台"
    participant IC as "库存中心"
    participant POOL as "inventory_code_pool_xx"
    participant LED as "inventory_ledger"
    participant BAL as "inventory_balance"

    O->>S: 上传包含 10 个新券码的 Excel
    S->>S: 创建追加任务 task_id,状态=PROCESSING

    loop 分批导码
        S->>IC: ImportCodeBatch(item_id, codes_chunk[], task_id)
        IC->>POOL: INSERT IGNORE 批量落库
        IC-->>S: 返回本批真实成功行数
    end

    S->>IC: CompleteImport(item_id, task_id)
    IC->>POOL: COUNT 本次任务真实新增有效码数
    IC->>LED: 事务内写 delta_qty = +real_count 流水
    IC->>BAL: usable_qty = usable_qty + real_count
    IC-->>S: IMPORT_SUCCESS(real_count)
    S-->>O: 提示“上传 10 个码,成功导入 9 个,库存追加 9”

5.4.7 场景六:SYS_GEN 系统活码追加时序图

sequenceDiagram
    autonumber
    actor O as "运营 / 供应商"
    participant S as "供给平台"
    participant IC as "库存中心"
    participant W as "Inventory Gen Worker"
    participant POOL as "inventory_code_pool_xx"
    participant LED as "inventory_ledger"
    participant BAL as "inventory_balance"
    participant MQ as "Message Queue / Outbox Event"

    O->>S: 输入“追加 10 个系统券码”
    S->>S: 创建追加任务 task_id,状态=PROCESSING
    S->>IC: GenerateCodeBatch(item_id, target_qty=10, task_id, request_no)
    IC-->>S: TASK_ACCEPTED

    IC->>W: 异步触发内部造码 Worker
    loop 直到真实新增 10 个有效码
        W->>W: 生成一批带盐随机券码
        W->>POOL: INSERT IGNORE 批量落库
        W->>POOL: COUNT 本次任务真实新增有效码数
    end

    W->>LED: 事务内写 delta_qty = +10 流水
    W->>BAL: usable_qty = usable_qty + 10
    W->>MQ: 事务内写 INVENTORY_GEN_COMPLETED 事件
    MQ-->>S: INVENTORY_GEN_COMPLETED(task_id, real_count=10)
    S->>S: CAS 更新任务状态为 SUCCESS
    S-->>O: 追加完成,库存已增加 10

5.4.8 关键技术点

技术点说明
初始化命令是库存中心入口,不是商品事实商品主域负责商品契约,库存中心负责库存事实,两者在初始化命令处衔接。
QTY 是同步初始化,EXT_POOL / SYS_GEN 是异步装填数量库存可以秒级完成初始化;导码和造码都属于长任务,不应阻塞商品发布和供给平台前台操作。
EXT_POOL 不能手填数量外部死码库存的数量必须由成功导入的资源行数决定,不能来自一个孤立数字。
SYS_GEN 由库存中心闭环生成,而不是供给平台生成后灌入券码唯一性、防碰撞补齐、安全加盐和全局 UK 去重都应放在最靠近库存数据库的一侧,也就是库存中心。供给平台只负责编排任务和展示进度。
SYS_GEN 完成后必须异步回调供给平台库存中心在本地事务里把 init_status 改为 READY 的同时,要通过 Outbox 写出 INVENTORY_GEN_COMPLETED 事件。供给平台收到事件后,再用 CAS 把自己的任务状态从 PROCESSING 推进到 SUCCESS
EXT_POOLSYS_GEN 都要以物理 COUNT 作为真相收尾无论是导入外部死码,还是库存中心内部造码,最终都不能盲信累计计数,必须对底层 inventory_code_pool_xx 做物理盘点,再回写 usable_qty
QTY 的追加必须走“流水 + 原子更新”+10 / -10 这类数字库存变更,绝对不能先查余额再覆盖写回,必须在一个事务内先落 inventory_ledger,再对 inventory_balance 执行原子增量。
EXT_POOL 的追加不是“+10”,而是“导入多少有效资源就加多少”运营表面上是在追加库存,但对死码池来说,真正的增量来自成功导入的有效码行数。上传 10 个码,撞重后只有 9 个落库成功,库存就只能加 9。
SYS_GEN 的追加是“输入目标数,后台闭环补齐真实资源”运营输入的是目标增量 10,但库存中心真正写账前,必须先在码池里把 10 个真实有效的新码造出来。只有真实资源存在,账本和余额才允许记 +10
追加库存和初始化库存都必须带任务化与幂等约束EXT_POOL / SYS_GEN 这类长任务,供给平台必须挂任务状态;库存中心必须用 task_id + request_no 防止重复导入、重复造码和重复记账。
商品契约层耦合、库存事实层解耦商品创建时可声明 inventory_mode,但真实余额、资源池和账本仍归库存中心。
初始化失败要允许独立补偿商品成功但库存初始化失败时,库存中心要允许后续单独补建库存,而不是逼迫商品重建。

5.5 核心功能:C 端预占、确认、释放与订单链路联动

5.5.1 场景画像

这个场景解决的是:

  • 下单时如何先预占库存,避免超卖
  • 支付失败、超时取消或风控拦截时如何归还库存
  • 支付成功后如何确认扣减,并把库存状态推进到可履约
  • 履约发货或到店核销时,如何继续沿着订单生命周期推进资源状态

库存中心不能只表达“当前剩余多少”,还必须承接 C 端交易过程态:

  • AVAILABLE
  • RESERVED
  • CONFIRMED
  • RELEASED
  • LOCKED
  • CONSUMED

5.5.2 场景一:下单预占库存时序图

sequenceDiagram
    participant O as "订单中心"
    participant IC as "库存中心"
    participant BAL as "inventory_balance"
    participant RES as "inventory_reservation"
    participant LED as "inventory_ledger"

    O->>IC: ReserveInventory(order_id, inventory_key, qty)
    IC->>BAL: 扣减可售 / 写预占视图
    IC->>RES: 写 reservation
    IC->>LED: 写预占账本
    IC-->>O: RESERVED

5.5.3 场景二:支付失败、超时取消与风控拦截后的库存归还时序图

sequenceDiagram
    participant O as "订单中心"
    participant IC as "库存中心"
    participant BAL as "inventory_balance"
    participant RES as "inventory_reservation"
    participant LED as "inventory_ledger"

    O->>IC: ReleaseInventory(order_id)
    IC->>BAL: 回补可售
    IC->>RES: 标记 RELEASED
    IC->>LED: 写释放账本
    IC-->>O: RELEASED

5.5.4 场景三:支付成功后的库存确认时序图

sequenceDiagram
    participant O as "订单中心"
    participant IC as "库存中心"
    participant RES as "inventory_reservation"
    participant LED as "inventory_ledger"

    O->>IC: ConfirmInventory(order_id)
    IC->>RES: 标记 CONFIRMED
    IC->>LED: 写确认账本
    IC-->>O: CONFIRMED

5.5.5 场景四:实物发货或履约完成后的库存消费时序图

sequenceDiagram
    participant O as "订单中心 / 履约中心"
    participant IC as "库存中心"
    participant RES as "inventory_reservation"
    participant LED as "inventory_ledger"

    O->>IC: ConsumeInventory(order_id)
    IC->>RES: 标记 CONSUMED
    IC->>LED: 写消费 / 履约完成账本
    IC-->>O: CONSUMED

5.5.6 场景五:到店核销或券码核销时序图

sequenceDiagram
    participant O as "订单中心 / 核销中心"
    participant IC as "库存中心"
    participant RES as "inventory_reservation"
    participant LED as "inventory_ledger"

    O->>IC: VerifyAndConsume(order_id, code)
    IC->>RES: 标记 CONSUMED
    IC->>LED: 写核销消费账本
    IC-->>O: VERIFIED_AND_CONSUMED

5.5.7 关键技术点

技术点说明
预占和确认必须拆开下单不等于支付成功,库存中心必须能表达“已占未成”的中间态。先预扣,再根据支付结果决定确认还是归还。
支付失败、超时取消和风控失败都要走显式释放这几类失败都不是“没发生过预占”,而是“预占之后未成交”。库存中心必须留下一条可解释的释放轨迹。
支付成功不等于库存生命周期结束对实物履约、酒店到店、券码核销这类业务,支付成功只是把库存从 RESERVED 推进到 CONFIRMED。后续还要继续走发货、履约完成或核销消费。
核销场景也属于库存生命周期的一部分对券码、门票、到店服务等资源型库存,最终交易闭环不是发货,而是核销。库存中心应能表达 CONFIRMED -> CONSUMED
幂等预占、重复确认、重复释放要单独防重订单链路天然存在超时重试和重复回调,库存中心要基于 reservation_id / order_id 做命令幂等。
高并发下先裁决过程态,再推进最终余额只有把 RESERVED -> CONFIRMED / RELEASED 独立出来,才能避免重复扣减、重复回补和负库存。
资源型库存也要走同样的过程态券码、座位、房态虽然不是简单数字,但预占、确认、释放仍然成立,只是命中的对象变成资源实例。

5.6 核心功能:券码池与唯一资源型库存

5.6.1 场景画像

这个场景解决的是:

  • 导码
  • 生码
  • 发码
  • 回补
  • 坏码、失效码和重复发放的治理

券码库存不是数字库存的一个附属字段,而是一套独立的一码一实例模型。

5.6.2 完整时序图

sequenceDiagram
    participant S as "供给平台 / 运营后台"
    participant IC as "库存中心"
    participant POOL as "inventory_code_pool_xx"
    participant BAL as "inventory_balance"
    participant O as "订单中心"

    S->>IC: ImportCodeBatch(item_id, code_rows)
    IC->>POOL: 批量写入唯一资源
    IC->>BAL: 根据有效资源行数回写聚合数量
    IC-->>S: IMPORT_SUCCESS

    O->>IC: ReserveInventory(order_id, item_id, qty=1)
    IC->>POOL: 锁定一张可用券码
    IC->>BAL: 扣减聚合数量
    IC-->>O: RESERVED(code_id)

    alt 支付成功 / 发码成功
        O->>IC: ConfirmInventory(order_id)
        IC->>POOL: 标记券码 CONFIRMED / USED
    else 取消 / 失败
        O->>IC: ReleaseInventory(order_id)
        IC->>POOL: 券码回补为可用
        IC->>BAL: 回补聚合数量
    end

5.6.3 关键技术点

技术点说明
券码池不能退化成一个数字数字只是发放能力的影子,真正的事实是每一张码的状态。
一码一实例要有独立状态机导入、锁定、确认、释放、失效、坏码都应落在资源实例表上。
数量和资源实例要强锚定inventory_balance 的可售数量必须始终由可发放的资源实例数推导出来。
外部死码导入必须做去重与审计要防止跨批次重复导入、坏码污染和重复发放。

5.7 热视图、账本与高并发治理

5.7.1 场景画像

这一节只回答库存中心在 C 端高并发交易热路径 下的热点治理问题,不重复 5.8 的异常矩阵。

当面对大促、爆款秒杀、热门房态和热门场次时,如果让每一次 ReserveInventory 都直接穿透到 MySQL:

  • inventory_balance 单行会形成排他锁串行排队
  • 订单链路 RT 会被瞬间拉高
  • 热点对象会把交易数据库拖入雪崩

因此库存中心在 C 端场景下采用的是:

  • Redis 做热路径状态裁决
  • Redis 内同步推进逻辑位点 seq
  • MQ 做削峰与异步落库
  • MySQL 做权威账本、权威余额和已处理水位线
  • 定时对账 + 动态冲正 + 极端容灾重建 做最终一致修复

这里的核心口径是:

  • Redis 是“出纳快照”,负责快速判断能不能卖,并在热路径内推进逻辑时钟
  • MySQL 是“会计真相”,负责解释每一笔库存到底怎么变来的,并记录当前已经处理到哪个逻辑位点
  • 视图允许短期漂移,但账本必须最终正确
  • 只有当 Redis 与 MySQL 的逻辑位点完全对齐时,系统才进入“时空静默”窗口,允许执行安全冲正

5.7.2 C 端高并发预占与异步落库时序图

sequenceDiagram
    autonumber
    participant O as "订单中心"
    participant IC as "库存中心"
    participant R as "Redis 热路径裁决"
    participant MQ as "Message Queue / Outbox Event"
    participant W as "Inventory Persist Worker"
    participant BAL as "MySQL inventory_balance"
    participant LED as "MySQL inventory_ledger"

    O->>IC: ReserveInventory(order_id, inventory_key, qty)
    IC->>R: 执行 Lua 原子预占脚本(扣减 + 推进 seq)

    alt Redis 裁决库存不足
        R-->>IC: return INSUFFICIENT
        IC-->>O: RESERVE_REJECTED
    else Redis 裁决预占成功
        R-->>IC: return RESERVED, seq=N
        IC->>R: HSET reserve_cache:{order_id}
        IC->>MQ: 写 INVENTORY_RESERVE_EVT(order_id, inventory_key, qty, seq=N)
        IC-->>O: RESERVED

        MQ->>W: 异步消费预占事件
        W->>BAL: SELECT last_processed_seq FOR UPDATE
        alt seq 已落后于 DB 水位线
            W->>LED: 写存证流水(order_id 作为 request_no)
            W-->>MQ: ACK_STALE_EVENT
        else seq 正常推进
            W->>LED: 写 RESERVE 流水(order_id 作为 request_no, seq=N)
            W->>BAL: 事务内更新权威余额 + last_processed_seq=N
            W-->>MQ: ACK
        end
    end

5.7.3 Redis Lua 热路径原语

为了防止“先 GET 再 SET”带来的竞态条件,Redis 热路径必须走 Lua 原子脚本,而不能在应用层拆成多次命令。

典型预占脚本思路如下:

local key_avail = "inventory:available_qty:" .. KEYS[1]
local key_reserve = "inventory:reserved_qty:" .. KEYS[1]
local key_seq = "inventory:seq:" .. KEYS[1]
local qty = tonumber(ARGV[1])

local current_avail = redis.call('GET', key_avail)
if not current_avail or tonumber(current_avail) < qty then
    return {-1, 0}
end

redis.call('DECRBY', key_avail, qty)
redis.call('INCRBY', key_reserve, qty)
local new_seq = redis.call('INCR', key_seq)
return {1, new_seq}

这条脚本的边界要讲清楚:

  • Lua 负责热路径原子裁决与逻辑位点推进
  • Lua 不负责生成最终解释账本
  • MQ 消息必须携带这个 seq,把 Redis 热路径和 MySQL 慢路径绑在同一条逻辑时间线上
  • 真正的库存真相仍以后续落到 inventory_ledger + inventory_balance 的结果为准

5.7.4 热视图漂移、单向对账与动态冲正

由于 Redis 天然存在:

  • AOF / RDB 异步落盘窗口
  • 主从切换丢失瞬时状态
  • 异步消息重放乱序
  • B 端直接改账本而热视图未同步

所以 Redis 与 MySQL 之间出现短期漂移是系统设计内允许发生的事情。

库存中心不追求“Redis 和 DB 永远瞬时强一致”,而是通过 逻辑水位线 + 定时准实时对账 + 动态冲正修复 来收敛。

对账 Worker 至少会取到 4 个关键值:

  • GET inventory:seq:{key}
  • GET inventory:available_qty:{key}
  • inventory_balance.last_processed_seq
  • inventory_balance.available_qty

随后分 3 种情况处理:

  1. redis_seq > mysql_seq
    • 说明高并发热路径仍在向前推进,异步落库仍在追赶
    • 正常情况下直接 IN_FLIGHT_SKIP
    • 若 MySQL 很久没有推进,说明异步消费可能卡死,应报警而不是贸然修复
  2. redis_seq == mysql_seq
    • 说明 Redis 与 MySQL 在逻辑时间线上已经完全对齐,进入可安全对账窗口
    • 如果数量也一致,则系统健康
    • 如果数量不一致,则以 MySQL 为准计算 drift_delta,对 Redis 执行 增量冲正
  3. redis_seq < mysql_seq
    • 正常生产环境中几乎不应该出现
    • 一旦出现,通常意味着 Redis 重启、热视图丢失或极端故障恢复后的基准回退
    • 此时应触发 Redis 冷启动重建,而不是普通增量修补

这里的原则必须锁死:

  • 绝不能用 Redis 覆盖 MySQL
  • 只能用 MySQL 的权威真相去重建 Redis 热视图
  • 所谓“对账”不是 Redis 和 MySQL 平等互相纠偏,而是 Redis 向 MySQL 单向收敛
  • 冲正也不应直接 SET 绝对值覆盖,而应优先采用基于差值的增量修补;只有 Redis 崩溃冷启动这类极端场景,才允许全量基准重建

5.7.5 Redis 全量故障下的受控慢路径降级

如果 Redis 分片集群整体故障,库存中心仍要尽量保证核心交易不中断,只是吞吐和 RT 降级。

推荐策略是:

  1. 配置中心下发降级开关,热路径切到 “DB 受控慢路径”
  2. 网关或订单入口对热点商品执行限流、排队和熔断
  3. 预占直接走 MySQL 条件更新,例如:
UPDATE inventory_balance
SET available_qty = available_qty - :qty,
    reserved_qty = reserved_qty + :qty,
    last_processed_seq = last_processed_seq + 1
WHERE inventory_key = :inventory_key
  AND available_qty >= :qty;
  1. Redis 恢复后,由对账任务按 MySQL 当前余额和预占量全量重刷热视图
  2. 确认重建完成后,再切回 Redis 热路径

这条链路的核心目标不是“性能不受影响”,而是:

  • 在 Redis 不可用时,交易链路仍能 带降级地活下去
  • 在 Redis 恢复后,热视图能 重新从权威账本拉平
  • 在 Redis 位点归零时,库存中心还能基于 last_processed_seq 把逻辑时钟顶回正确基准

对于 Redis 崩溃且位点丢失的极端情况,推荐专门走“冷启动重建”:

  1. 读取 inventory_balance.available_qty / reserved_qty / last_processed_seq
  2. 使用高危受控脚本重置:
    • SET inventory:available_qty:{key} = mysql_available
    • SET inventory:reserved_qty:{key} = mysql_reserved
    • SET inventory:seq:{key} = mysql_last_processed_seq
  3. 记录 inventory_repair_task
  4. 放开流量并恢复 Redis 热路径

当某个商品首次跌到 0 时,还应向应用层广播“售罄信号”,让 App 节点在本地短期缓存 is_sold_out:{inventory_key},把后续洪峰尽量挡在 Redis 之前。

5.7.6 关键技术点

技术点说明
Redis 只负责热路径状态裁决和逻辑位点推进,不负责最终解释Redis 的使命是挡住高并发洪峰,让 C 端在毫秒级得到“有货 / 无货”的裁决,并为每次成功操作分配一个严格递增的逻辑位点。
C 端预占成功后要异步落库,而不是同步穿透 DB订单中心拿到 Redis 预占成功后即可返回,真正的账本写入由 MQ / Worker 慢路径异步完成,从而把热点写流量从数据库上剥离掉。
MQ 的价值在于削峰,并把 seq 绑定给 MySQL 慢路径MQ 不只是削峰,还承担把 Redis 热路径产生的逻辑位点稳定地传给 MySQL 侧,形成统一时间线。
MySQL 权威余额要保存 last_processed_seq这样异步消费者才能识别老消息、乱序消息和 Redis 崩溃后的基准恢复点。
乱序消息必须以前置水位线拦截,而不是落库后再“猜”当消息 seq <= last_processed_seq 时,它属于过期事件,不得再改写权威余额。
Redis 热视图允许短期漂移,但必须等水位线对齐后再修只有 redis_seq == mysql_seq 时,才能安全判断数量漂移并执行冲正,避免在高并发飞行中误修复。
对账修复必须“以账本为准冲正热视图”任何漂移修复都必须从 MySQL 向 Redis 单向纠偏,绝不能反过来让 Redis 改写账本。
Redis 宕机时要允许受控慢路径兜底降级到 DB 会变慢,但只要配合热点限流、排队和条件更新,库存中心仍能保住交易连续性。
Redis 崩溃恢复要连同位点一起重建重建的不只是可售量,还包括 inventory:seq:{key},否则恢复后的第一批请求会发生时间线回退。
Redis 是出纳,MySQL 是会计这是这一节最重要的架构心智:出纳可以快、可以短期漂,但会计必须准,最终所有热路径都要回到账本真相上闭环。
库存中心不存在“Redis 真相”和“MySQL 真相”两套口径库存中心只有一套权威真相:MySQL。Redis 只是为了性能引入的派生视图,不参与真相裁决。

5.8 异常处理与对账修复

库存中心不是“永远不出错”,而是出错后必须可解释、可回放、可对账、可修复。

异常类型触发条件风险处理策略
负库存高并发扣减、顺序错误或异常回补超卖、账本失真立刻熔断写路径,冻结热点对象,按 inventory_ledger 回放并修复
预占泄漏订单超时但未收到释放可售量长期偏低,形成假售罄通过超时巡检扫描 inventory_reservation,按状态和时间窗自动释放
Redis 与账本漂移热视图丢失、异步更新失败、重放乱序前台看到的可售量与权威真相不一致以 MySQL 余额和账本为准重建热视图,Redis 不参与最终裁决
券码坏码 / 重码 / 漏码导码错误、供应商脏数据或批次处理异常有数无码、重复发码、履约失败券码实例要有坏码状态、批次去重和回收机制,并支持按批次重算数量
超时订单未释放支付回调丢失、订单状态未闭环长时间占用库存通过订单状态对账任务补发 ReleaseInventory 或直接做库存修复
重复确认 / 重复释放上游重试或回调重复重复扣减、重复回补基于 reservation_id / order_id 做命令幂等,状态流转必须条件更新
热视图丢失Redis 故障、分片切换、缓存淘汰读侧可售量瞬时失真允许热视图快速重建,期间交易链路回退到受控慢路径或降级路径
资源域状态变化导致库存失真酒店房态、座位、券码资源域状态变化未及时反映聚合数量与资源真相不一致通过资源域回查、对账任务和增量修复把数量视图重新锚回资源事实

5.9 库存中心表清单与使用场景

5.9.1 每个表的使用场景

使用场景关键边界
inventory_config定义库存管理方式、扣减时机、资源模式配置层,不直接承接交易事实
inventory_balance承接当前聚合可售视图当前视图,不是最终审计真相
inventory_reservation承接订单过程中的预占记录过程态事实,不等于最终账本
inventory_ledger记录每次库存变动权威解释事实
inventory_code_pool_xx管理券码、卡密、唯一资源实例资源型库存主权表,不是数字库存附属字段
inventory_reconcile_task对账巡检、重建热视图、发现漂移治理表,不参与实时交易事实
inventory_repair_task承接人工或系统修复动作修复闭环,不直接承接交易事实

5.9.2 每个表的 Schema

inventory_config
字段含义
inventory_key库存范围标识
inventory_modeQTY / SYS_GEN / EXT_POOL
deduct_timing扣减时机
allow_oversell是否允许超卖
status配置状态
inventory_balance
字段含义
inventory_key库存范围标识
available_qty当前可售量
reserved_qty当前预占量
confirmed_qty已确认量
last_processed_seqMySQL 已确认处理到的 Redis 逻辑位点
version库存版本号
updated_at最近更新时间
inventory_reservation
字段含义
reservation_id预占主键
order_id对应订单
inventory_key对应库存范围
qty预占数量
statusRESERVED / CONFIRMED / RELEASED
expire_at预占超时时间
inventory_ledger
字段含义
ledger_id账本主键
inventory_key对应库存范围
biz_type业务类型,如初始化、预占、确认、释放、回补
delta本次增减变化
seq对应 Redis 热路径逻辑位点
request_no幂等请求号,如 order_id / operation_id
before_value / after_value变更前后数量
biz_id关联订单、任务或批次
created_at账本时间
inventory_code_pool_xx
字段含义
code_id资源实例主键
inventory_key对应库存范围
code_value券码 / 卡密 / 唯一资源值
statusINIT / RESERVED / CONFIRMED / RELEASED / INVALID
batch_no所属导入或生成批次
order_id若被占用,对应订单
inventory_reconcile_task
字段含义
task_id对账任务主键
scope对账范围
status任务状态
drift_summary漂移摘要
created_at / updated_at时间
inventory_repair_task
字段含义
repair_id修复任务主键
inventory_key修复对象
repair_type修复类型
reason修复原因
status修复状态
created_at / updated_at时间

6. 详细设计 - QC 和治理

6.1 QC 平台定位与职责边界

这里的 QC 指的是 商品侧 / 供给侧审核中心,不指库存质检。

QC 平台负责:

  • 机审核
  • 人工审核
  • 审核编排
  • 审核审计
  • 审核治理看板

QC 平台不负责:

  • 正式商品主权
  • 商品本地 merge
  • 库存事实

6.2 为什么 QC 要独立于供给平台和商品中心

QC 独立出来的价值在于:

  • 审核规则演进更快
  • 人工审核工作台可以独立建设
  • 策略编排和审核审计不污染供给平台
  • 风控和合规逻辑不耦合进商品中心事务链路

6.3 机审核设计

机审核不是“写几条正则就完事”,而是一套把高频可规则化风险前移拦截的执行系统。它的目标不是完全替代人工,而是把明显正确和明显错误的对象尽量自动化,让人工审核只处理真正需要判断的灰区。

机审核典型覆盖以下风险面:

  • 敏感词与违规描述
  • 类目规则与属性约束
  • 主图 / 素材基础规则
  • 资质与证照完整性
  • 价格异常与履约承诺异常
  • 交易契约缺失,例如退款规则、预约规则、有效期缺失

从执行链路看,推荐拆成 4 层:

  1. 基础结构校验层:字段必填、格式、范围、枚举、跨字段依赖。
  2. 类目规则层:类目模板、属性完整性、品类专属约束。
  3. 风控合规模型层:敏感词、资质、素材风险评分。
  4. 发布风险裁决层:根据规则命中结果给出 PASS / REJECT / MANUAL_REVIEW

推荐使用“规则 + 策略路由 + 风险分值”三段式模型,而不是把所有结论硬编码在一个 if/else 里:

层次负责什么输出
规则执行器执行单条规则命中明细
路由策略根据来源、类目、字段集决定规则包应执行规则集合
裁决器汇总命中明细并给出 verdictPASS / REJECT / MANUAL_REVIEW

推荐把机审核结果统一收敛为三类:

  • 自动通过:低风险且规则全部满足,可以直接进入发布或自动准入队列。
  • 自动驳回:存在确定性违规,必须回退给供给侧修改。
  • 转人工审核:规则只能判断出“有风险但不够确定”,需要人工裁决。

这样设计的关键价值在于:规则系统只负责“发现风险”,真正的发布裁决仍然有一层独立的 verdict 模型,便于后续插入人工审核、风控加签、特殊品类白名单等流程。

6.4 人工审核设计

人工审核要解决的不是“再看一眼”,而是把不确定性风险转化为可运营、可追责、可度量的工作流系统。很多团队的问题不在规则本身,而在审核池失控、抢单无序、标准不统一和积压无法消化。

一个可用的人工审核系统,至少要解决 5 件事:

  1. 审核对象怎么进池。
  2. 审核任务怎么分发给合适的人。
  3. 审核员看到的是哪一版冻结快照。
  4. 驳回和复审怎么闭环。
  5. 审核结果怎么沉淀成规则优化输入。

推荐的人审工作台能力包括:

模块核心能力说明
审核池待审队列、优先级、SLA 倒计时支持按类目、风险等级、来源分池
分单机制自动派单、抢单、回收重派高价值类目优先自动派给专门审核组
审核视图冻结快照、Diff 高亮、历史 verdict避免审核员自己拼上下文
审核动作通过、驳回、挂起、转审、加签每种动作都要有明确状态流转
复审体系二审、抽检、申诉回流用于高风险类目和质量校正

在执行模型上,推荐把人工审核任务显式建模成“工作项”,而不是直接在商品草稿上打审核字段。因为审核任务天然带有:

  • 队列属性
  • 领取属性
  • SLA 属性
  • 升级属性
  • 审计属性

如果这些能力都塞回商品表或供给任务表里,后面只会越来越难治理。

6.5 审核路由与分级策略

审核绝不能一刀切。真正的平台会根据 变更内容、品类风险、来源可信度、历史行为、发布时间窗口 做分层路由。

推荐至少从 4 个维度做路由:

维度典型取值影响
字段风险标题、主图、履约规则、价格、上下架、退款规则决定是否允许自动准入
品类风险酒旅、医疗、教育、虚拟券码、高投诉类目决定规则包和人审等级
来源可信度平台运营、头部商家、长尾商家、供应商同步决定默认信任等级
历史表现命中率、申诉率、历史违规率决定是否加严或放宽

一个常见但错误的做法是:只要改了商品就一律进人工审核。这样短期看“稳”,长期一定拖垮平台效率。

更合理的路由策略通常是:

  1. 低风险字段小改动:例如副标题、运营标签、小幅价格修正,可走自动准入。
  2. 中风险结构性变更:例如履约规则、退款条件、素材变化,先过机审,再视命中情况转人工。
  3. 高风险关键变更:例如类目迁移、资质变更、上下架、供应商关键属性替换,强制进入人工审核。
  4. 平台强制裁决类事件:例如风控下架、法务拦截,可绕过普通审核队列,走高优先级裁决链路。

审核分级的本质不是“谁都审一下”,而是用最小的人力成本把最大的风险拦下来。

6.6 审核对象与冻结快照

这一节是 QC 设计最关键的地方。QC 审的一定是 冻结快照,不能审供给侧正在被编辑的动态草稿,也不能直接审线上正式商品表。

原因很简单:

  • 如果审核员看到的是动态草稿,那么在审核过程中,运营或商家继续编辑,就会出现“审的是 A,发的是 B”。
  • 如果审核员直接看正式商品表,就会把未生效变更和已上线资产混在一起,失去版本边界。

推荐采用以下对象关系:

对象语义是否可变
draft_id运营中的当前编辑态可变
publish_version一次提交审核 / 发布尝试的版本号单调递增
snapshot本次送审冻结快照不可变

推荐链路如下:

  1. 供给平台或商品中心维护可编辑 Draft
  2. 用户点击“提交审核”后,商品中心生成新的 publish_version
  3. 系统把对应字段冻结成送审 snapshot
  4. QC 只对 snapshot 做机审和人审。
  5. QC 返回 verdict 时,必须带回 draft_id + publish_version + snapshot_id 或等价冻结标识。
  6. 商品中心只允许对“当前待发布版本”执行 merge 和正式发布。
flowchart LR
    A["可编辑 Draft"] --> B["提交审核"]
    B --> C["冻结 Snapshot"]
    C --> D["机审核 / 人工审核"]
    D --> E["返回 verdict + publish_version"]
    E --> F["商品中心本地 merge 发布"]

这套模型的核心价值不是“多建一个快照表”,而是把审核对象从“会继续变化的编辑态”切成“可追责的冻结态”。这样后面无论是申诉、回放、抽检还是事故复盘,都知道当时到底审了什么。

6.7 审核 verdict 与商品中心交互

QC 平台回传的应该是 轻量 verdict,而不是整份商品 DTO。QC 的职责是裁决,不是替商品中心做主数据写入。

推荐回传结构如下:

{
  "draft_id": 555,
  "publish_version": 102,
  "snapshot_id": 900123,
  "result": "PASS",
  "review_level": "AUTO",
  "reason_codes": ["TITLE_OK", "PRICE_OK"]
}

商品中心收到 verdict 后,再在本地事务里完成 merge 和正式发布。这里必须坚持两个边界:

  1. QC 不直写正式商品。
  2. 商品中心不信任“没有版本号的审核结果”。

推荐交互规则如下:

场景商品中心动作
PASS 且版本匹配执行本地 merge,推进发布状态机
REJECT 且版本匹配标记该版本驳回,保留驳回原因
verdict 重复到达依据 publish_versionreview_id 幂等处理
verdict 乱序到达只接受当前待裁决版本,旧版本直接丢弃或归档
verdict 针对已撤回版本忽略并记审计日志

也就是说,QC 更像法官,商品中心更像执行机关。法官只给结论,不帮你改资产;执行机关拿到结论后,在自己的事务边界里决定状态怎么推进、正式态怎么落库、事件怎么广播。

6.8 QC 治理闭环

审核系统真正难的地方,往往不是单次“过还是不过”,而是长周期治理闭环。一个成熟的 QC 平台必须让错误能修、积压能控、规则能进化、事故能回放。

推荐重点治理以下 8 类问题:

问题风险处理策略
驳回后无法修复业务反复重提、体验差驳回原因结构化,支持字段级修复提示
撤回后结果乱入已撤回版本被误发布verdict 必须携带版本号,商品中心做版本闸门
审核超时发布链路卡死SLA 超时自动升级、转派或降级到人工池
人工积压大面积延迟上线分池、扩容、自动准入比例调优
规则误判大量误杀或漏审抽检集、申诉回流、误判复盘
回调重复 / 乱序状态错乱review_id + publish_version 幂等拦截
审计不可追溯事故无法复盘全链路审计日志 + 冻结快照留存
审核策略失效旧规则挡不住新问题规则版本化、灰度发布、效果监控

治理闭环里最有价值的两个运营指标通常是:

  1. 审核吞吐指标:自动通过率、人工命中率、平均审核时长、SLA 超时率。
  2. 审核质量指标:误判率、申诉成功率、上线后追罚率、漏拦截事故数。

只有把这两类指标一起看,平台才能知道自己是在“提效”,还是只是在“把风险放过去”。

6.9 QC 表清单与使用场景

推荐把 QC 平台最少拆成以下几类表:

使用场景关键边界
product_qc_review一次审核主单,记录对象、版本、总体 verdict审核主事实,不承接商品正式态
product_qc_review_item字段级 / 规则级命中结果用于解释“为什么过 / 为什么不过”
qc_rule规则定义、版本、开关、阈值规则配置,不直接承接审核结果
qc_route审核路由配置控制对象该进哪个规则包、哪个审核池
qc_work_queue人审工作队列队列与分单事实,不等于审核主单
qc_audit_log操作与状态流转审计事故复盘与追责依据

进一步可参考如下字段设计:

product_qc_review
字段含义
review_id审核主单 ID
draft_id对应草稿 ID
publish_version本次提交审核版本
snapshot_id冻结快照 ID
review_type自动审 / 人工审 / 复审
statusPENDING / REVIEWING / PASSED / REJECTED / CANCELED
final_verdict最终裁决
priority优先级
created_at / updated_at时间
product_qc_review_item
字段含义
item_id明细主键
review_id所属审核主单
field_path命中字段路径
rule_code规则编码
risk_level风险等级
result命中结果
reason_text解释文本
qc_work_queue
字段含义
queue_item_id工作项 ID
review_id对应审核主单
queue_name队列名称
assignee当前处理人
sla_expire_atSLA 到期时间
statusWAITING / CLAIMED / DONE / EXPIRED
qc_audit_log
字段含义
log_id审计日志主键
review_id对应审核主单
operator操作人或系统
action抢单、通过、驳回、转审、撤回等动作
detail变更详情
created_at操作时间

7. 典型业务场景串讲

7.1 本地生活券商品创建到上线

  1. 运营在后台表单创建商品,供给平台调用商品中心生成 Draft
  2. 同步阶段完成字段校验、类目模板校验、库存模式声明和基础幂等防重。
  3. 点击提交后生成 publish_version 并冻结 snapshot
  4. 机审核先处理敏感词、类目、资质和履约规则,低风险对象直接通过。
  5. 高风险对象进入人工审核池,审核员基于冻结快照完成 verdict。
  6. 商品中心收到 PASS verdict 后,本地 merge 到正式 Item,同时初始化库存配置。
  7. 发布事件通过 Outbox 广播到搜索、营销、缓存、计价等下游。

这个场景最重要的面试表达是:创建商品并不止是写一条商品记录,而是同时建立审核、发布和可售能力。

7.2 券码库存导入与发码

  1. 运营先创建商品,并声明库存模式为 EXT_POOL
  2. 商品审核通过并发布后,供给平台允许发起券码导入任务。
  3. Excel 解析阶段完成坏码、重码、格式错误识别,并生成批次明细。
  4. 库存系统把合法券码写入 inventory_code_pool_xx,同时更新聚合数量视图。
  5. 用户下单支付成功后,库存系统按订单预占并分配唯一券码。
  6. 若发码失败或履约回调异常,则通过账本和码池状态回放进行回补或人工修复。

这个场景体现的是:商品创建和库存建立可以分阶段完成,但最终都要收敛到同一条受治理生命周期里。

7.3 酒店商品同步与变更发布

  1. 定时任务触发供应商全量或增量同步,先把原始数据落到接入层。
  2. 通过 supplier_mapping 识别平台已有资源,并对酒店房型、库存日期、取消规则做归一化。
  3. 差异识别区分“无效更新”“低风险变更”“高风险变更”。
  4. 无效更新直接过滤,低风险字段可自动发布,高风险字段冻结后进入 QC。
  5. 审核通过后商品中心递增 publish_version,并广播到搜索、详情投影、计价和订单侧。
  6. 下游只接受更高版本事件,避免供应商晚到旧数据反向覆盖最新状态。

这个场景最能体现“供应商同步不是简单 Upsert,而是带治理能力的增量发布系统”。

7.4 运营批量编辑

  1. 运营上传 Excel,供给平台创建批量任务主单与行级 task_item
  2. Parser Worker 解析文件,做字段映射、数据清洗和模板校验。
  3. 每一行根据变更类型路由到自动准入、人工审核或直接失败。
  4. 通过的行生成新的 publish_version 并进入发布编排,失败的行写回错误文件。
  5. 最终任务产出成功报告、失败报告和可重试对象集。

这个场景体现的是:批量编辑真正难的是行级隔离、任务化和可恢复,不是一次把 Excel 跑完。

7.5 风控强制下架

  1. 风控或法务系统命中高风险商品,发起高优先级平台裁决事件。
  2. 商品中心不经过普通供给编辑流,直接把正式商品状态推进到 BANNEDOFFLINE
  3. 库存、搜索、营销、详情页投影和交易准入同时进入不可售收敛。
  4. 审计日志记录触发来源、操作人、证据链和恢复条件。
  5. 若后续申诉成功,再通过受控恢复链路重新进入审核和发布。

这个场景反过来证明:平台不仅要支持“怎么上”,也要支持“怎么立刻下”。


8. 面试答辩与工程总结

如果要把这一章压缩成一句面试表达,可以这样说:

商品、供给、库存、上架、QC 和运营不是几个孤立后台,而是一条受治理的生命周期流水线。入口态数据从供给侧进入,Draft / Staging 归商品主域写侧管理,正式商品读模型只承接已发布正式态,库存事实归库存系统,发布通过版本和 Outbox 保证最终一致,所有批量与外部同步链路都要任务化、幂等化、可补偿、可审计。

面试里建议重点讲清五个判断:

  1. Draft 不属于正式商品读模型,可审核、可发布的草稿资产应归商品主域写侧。
  2. Staging 的价值是隔离“正在编辑”和“已提交待发布”的快照。
  3. 库存运营入口可以在供给平台,但库存事实必须归库存系统。
  4. 发布不能同步写所有下游,应该用版本化 + Outbox。
  5. 真正的平台设计重点不是 CRUD,而是状态分层、任务化、审计、补偿和资损防控。

如果希望把这一章讲得更像一个成熟架构师,而不只是“会列系统名”,建议再补上三层表达:

  1. 业务视角:解释为什么商品创建天然连着审核、库存、搜索和订单,而不是孤立建档。
  2. 模型视角:解释 Draft / Staging / Item / Snapshot / publish_version 为什么必须分层。
  3. 治理视角:解释任务化、版本化、审计、补偿、对账为什么比 CRUD 更重要。

如果读者能把这五个判断和这三层表达讲清楚,就已经从“会画商品系统架构图”进入“能解释平台治理设计”的阶段了。

第 36 章 电商用户 C 端搜索、交易、履约与售后全生命周期设计

本章定位:如果第 35 章回答的是“平台怎样把商品供给进来、治理好、发布出去”,这一章回答的就是“用户怎样从发现商品一路走到支付、履约、核销与售后”。它不是对搜索、购物车、订单、支付四章的简单拼接,而是站在 C 端交易旅程 视角,把弱一致读链路、强一致交易链路、履约后链路以及解释历史事实的快照体系收敛成一条完整主线。

如果前面的专题章节分别回答的是“某个系统内部怎么设计”,这一章回答的则是:

  1. 用户在 C 端到底会经历哪些关键交易阶段。
  2. 为什么搜索、详情、购物车、结算、订单、支付、履约、售后不能揉成一个系统。
  3. 为什么搜索和详情可以弱一致,而下单和支付必须强校验。
  4. 为什么订单之后不应该再依赖“最新商品真相”,而要依赖快照和履约事实。
  5. 为什么真正难的不是画一条交易链路,而是跨商品、库存、营销、计价、订单、支付、履约之间的一致性和资损防控。

建议配合以下章节交叉阅读:


1. 核心使用场景:C 端用户到底在完成什么交易动作

很多团队讲 C 端架构时,会按系统模块切开:搜索一章、购物车一章、订单一章、支付一章。这样当然清楚,但读者很容易失去真正的主线,因为用户不会感知自己“正在使用哪个中台系统”,他只会感知:

我能不能找到商品、看懂规则、拿到正确价格、顺利下单、正常支付、按承诺履约,以及出问题后能不能退款。

1.1 搜索与导购找商品

用户进入平台后的第一步,通常不是直接下单,而是先找到“值得交易的候选集”。

场景用户动作典型特征系统重点
关键词搜索搜品牌、搜型号、搜服务词强相关性、结果多Query 理解、召回、排序、Hydrate
类目导购逛频道、逛类目、逛店铺弱文本、强筛选类目树、Facet、稳定排序
活动导购进会场、看榜单、看运营专区强陈列、强运营控制搜索索引 + 运营露出 + 营销标

这一阶段要回答几个问题:

  1. 搜索结果页为什么可以弱一致。
  2. 为什么列表页不直接读商品中心主库。
  3. 为什么用户看到的“列表价”和最终下单价不一定完全相同。

1.2 详情页理解商品

详情页是用户第一次把“可检索商品”理解为“可交易契约”的地方。

用户关注点背后依赖的域
商品标题、主图、卖点、规格商品中心
当前价格、到手价、阶梯价、实时价计价中心
库存是否充足、是否限购库存中心
活动、优惠券、赠品、满减露出营销中心
发货时效、门店核销、预约规则、退款规则商品中心 / 履约规则域

这意味着详情页本质上不是“查一张表”,而是一个 多域聚合页。它天然带来两个工程问题:

  1. 详情页为什么不能直接等于下单事实。
  2. 商品、价格、库存、营销版本不一致时,到底以谁为准。

1.3 加购与购物车暂存

购物车表达的是“用户意愿”,而不是“交易事实”。

场景典型动作关键差异
未登录加购cart_token 暂存允许匿名、弱一致、可过期
登录后加购绑定用户购物车支持跨端同步
登录合并匿名购物车合并到用户态要处理数量合并、失效商品、限购截断

这里最重要的判断是:

  • 购物车里为什么不锁库存。
  • 为什么购物车允许展示价滞后,但结算必须实时试算。

1.4 结算、校验与提交订单

结算页是整条 C 端链路第一次真正进入 强一致交易编排 的地方。

结算阶段动作关键系统
价格试算计价系统
库存预占库存系统
优惠校验 / 占用营销系统
地址与运费计算地址 / 运费系统
商品静态合规校验商品中心

这一步要回答:

  1. 为什么购物车不锁库存,结算才预占库存。
  2. 为什么订单提交前要再次做版本校验。
  3. 为什么结算页的本质是一个短生命周期 Saga,而不是简单表单确认。

1.5 下单、支付、履约与售后

用户点击“提交订单”之后,系统就从“交易前”进入“交易中与交易后”。

阶段用户看到的动作系统主导域
下单生成订单、等待支付订单中心
支付调起收银台、支付结果回流支付中心
履约发货、发码、预约、核销履约 / 券码 / 供应商域
售后退款、退货、取消、争议订单 / 支付 / 履约协同

这阶段的核心问题包括:

  1. 订单为什么必须保存商品、价格、履约快照。
  2. 支付为什么不能直接决定商品状态和库存真相。
  3. 为什么售后必须基于订单事实,而不是去查最新商品。

1.6 场景到系统问题的映射

场景类型典型问题
搜索导购弱一致索引、动态 Hydrate、排序与活动露出
商品详情多域聚合、库存与价格展示口径、规则解释
购物车匿名暂存、登录合并、失效商品治理
结算价格试算、库存预占、优惠校验、提交前最终校验
下单支付幂等、快照、支付回调、状态机推进
履约售后发货 / 发码 / 核销、退款回补、历史订单解释

2. 整体方案设计

这一节按照“先看系统边界,再看主链路,最后看关键决策”的顺序展开。先把商品、库存、计价、营销、订单、支付和履约各自的职责划清楚,再把这些系统放进一条 C 端完整交易主链路中理解,最后集中讨论搜索弱一致、详情聚合、结算预占、订单快照和售后事实这些关键设计选择。

2.1 系统边界和职责

系统负责什么不负责什么
商品中心正式商品契约、履约规则、退款规则、商品快照购物车暂存、库存事实、支付状态
搜索与导购商品召回、排序、导购投影商品正式真相、订单事实
购物车与结算用户意愿暂存、结算编排、试算与预占协同正式创单、支付状态机
计价系统实时报价、到手价解释、价格快照订单状态推进、库存扣减
库存系统可售库存事实、预占、确认、释放、消费购物车、订单金额、营销规则
营销系统优惠规则、券可用性、权益占用与释放商品正式态、库存总账
订单系统订单事实、订单状态机、交易快照渠道支付、库存脚本实现
支付系统支付单、渠道交互、支付事实订单商品解释、商品真相
履约 / 核销 / 售后发货、发码、核销、退款闭环商品供给、搜索索引

最重要的三条原则是:

  1. 搜索和详情服务用户发现,订单和支付服务用户承诺,履约和售后服务用户交付。
  2. 商品中心负责交易前契约,订单中心负责交易事实,支付中心负责资金事实。
  3. 订单之后,任何解释都优先基于快照和履约事实,而不是基于最新主数据。

2.2 C 端交易主链路总览

flowchart LR
    A["搜索 / 导购"] --> B["商品详情页"]
    B --> C["购物车 / 直接购买"]
    C --> D["结算页试算与预占"]
    D --> E["创建订单"]
    E --> F["支付单创建与支付回调"]
    F --> G["履约 / 发货 / 发码 / 核销"]
    G --> H["退款 / 售后 / 对账闭环"]

    B -.-> P["商品中心正式契约"]
    D -.-> I["计价 / 库存 / 营销协同"]
    E -.-> S["商品 / 价格 / 履约快照"]
    F -.-> O["Outbox / 事件广播"]

这条主链路表达的是:

  1. 搜索、列表和详情是交易前的信息发现链路,允许适度弱一致。
  2. 结算、下单、支付是交易承诺链路,必须逐步收紧校验口径。
  3. 履约和售后是交易后事实链路,必须围绕订单快照和支付结果闭环。

2.3 决策点 1:为什么 C 端不能只用一个“大前台服务”承接全部链路

如果把搜索、详情、购物车、结算、订单、支付、履约全塞进一个“大前台交易服务”,短期当然看似简单,但长期一定会失控。

方案优点缺点 / 风险推荐结论
方案 A:大一统前台服务入口统一、联调简单搜索弱一致和交易强一致混在一起;边界塌陷;状态机膨胀不推荐
方案 B:按交易旅程拆域职责清晰;一致性口径可分层;便于扩容和治理系统间编排更复杂推荐

推荐结论:

  • 搜索 / 导购负责发现。
  • 商品详情负责解释。
  • 购物车负责意愿暂存。
  • 结算负责强校验编排。
  • 订单负责承诺落地。
  • 支付负责资金事实。
  • 履约与售后负责交易后闭环。

2.4 决策点 2:为什么搜索结果和详情页允许不同一致性口径

搜索结果页面对的是高 QPS 召回,详情页面对的是单商品强解释。

对象推荐一致性原因
搜索结果页弱一致索引是投影,允许短暂滞后
详情页近实时一致需要展示交易前关键信息
下单 / 支付强校验不能靠展示信息直接成交

推荐结论:

  • 搜索索引和列表投影可以异步刷新。
  • 搜索结果页不是“所有字段都由 ES 直接吐出”;只有参与召回、过滤、排序且可容忍滞后的骨架字段进索引,高频强一致字段要通过商品中心、计价和库存批量 Hydrate。
  • 详情页必须以正式商品契约为底,再补充当前价、库存和营销。
  • 下单永远不能只信列表或详情页展示结果。

2.5 决策点 3:为什么购物车不锁库存,结算才预占库存

购物车表达的是“想买”,不是“已经占用交易资源”。

方案风险推荐结论
购物车加购即锁库存大量僵尸占用;资源利用率极差;售罄假象不推荐
结算时再预占库存只在交易临门一脚时收紧资源推荐

推荐结论:

  • 购物车只保存用户意愿。
  • 进入结算页时,库存中心才执行 ReserveInventory
  • 支付失败、超时取消、风控拒绝后,必须显式释放预占。

2.6 决策点 4:为什么订单必须保存商品 / 价格 / 履约快照

如果订单创建后还依赖“实时查最新商品”,未来只会产生解释灾难。

事实为什么要快照
商品标题、规格、履约规则商品可能后续被改名、改规格、改退款口径
价格与优惠结果价格和活动经常变化
履约参数发码、预约、核销规则可能调整

推荐结论:

  • 订单保存或引用创单时的商品快照、价格快照、履约快照。
  • 售后和客服解释历史订单时,只认快照,不认最新商品。

2.7 决策点 5:为什么支付不能反向驱动订单主状态机

支付是资金事实,但不是订单全业务事实。

方案风险推荐结论
支付中心直接改订单所有主状态状态主权错位;订单域被支付绑死不推荐
支付中心发支付事实事件,订单域自行推进状态支付和订单解耦;幂等更清晰推荐

推荐结论:

  • 支付系统只负责支付单与支付结果。
  • 订单系统消费支付成功 / 失败事件,自行推进自己的状态机。

2.8 决策点 6:为什么售后必须基于订单事实,而不是回查最新商品

退款、退货、取消和争议处理面对的是“历史承诺”,不是“当前最新售卖状态”。

推荐结论:

  • 售后判断以订单快照、支付结果、履约状态和库存回补策略为准。
  • 不允许用最新商品标题、最新价格、最新规则反向解释旧单。

3. 商品搜索、导购与详情链路

3.1 场景画像

从用户进入平台到决定”我要不要买”,通常依次经过两种读路径:

  1. 搜索 / 导购结果页:看的是候选集。
  2. 商品详情页:看的是交易前解释。

这两条链路看起来都只是”读”,但它们的职责截然不同:

  • 搜索结果页服务于召回和转化,强调高吞吐和可排序。
  • 详情页服务于交易前理解,强调解释性和当前性。

从系统角度看,搜索、导购、详情之间不能揉成一个系统,也不能简单地串成一条长链路,而应该按各自的职责边界和一致性要求独立设计,再通过编排层聚合。

3.2 关键技术点

这一节先前置所有高价值架构结论,后续场景化时序图再展开具体调用关系。

3.2.1 为什么搜索、导购、详情不能揉成一个系统

很多团队早期会把”商品搜索、类目导购、详情查询”统一塞进一个搜索服务里,代码复用率高,看起来也方便。但这会带来三个致命问题:

  • 职责塌陷:搜索负责高吞吐召回和排序,导购负责多维度筛选和聚合卡片展示,详情负责多域事实聚合。三者面对的下游依赖链、缓存策略、降级粒度完全不同,揉在一起只会让每个场景都背上不该背的依赖。
  • 一致性口子撕裂开:搜索页天然允许弱一致,详情页需要近实时一致。如果把两个场景放在同一个服务里,开发人员很难在代码层面区分”这个字段可以滞后 5 分钟”和”这个字段必须实时校验”。
  • 大促放大故障面:搜索页 QPS 远高于详情页。一旦合并在同一个服务里,高并发搜索流量会直接把详情页的计价、库存、营销下游一起拖垮。

因此推荐结论很明确:

  • 搜索服务解决召回、筛选、排序。
  • 导购服务解决聚合卡片展示。
  • 详情聚合服务解决多域事实聚合。

3.2.2 哪些字段来自 ES,哪些字段来自商品中心

这条链路里最关键的设计点,不是”多调了几个下游”,而是:搜索结果页到底哪些数据应该直接来自搜索中心,哪些数据应该回商品中心 / 计价 / 库存实时补齐

决策原则只有三条:

  1. 是否参与召回、过滤、排序:参与这些能力的字段,优先放到 ES。
  2. 变动频次高不高:高频变化字段不要把绝对真相压进 ES。
  3. 对准确性要求高不高:一旦字段直接影响成交,就应该让实时权威域说了算。

因此,搜索结果页通常采用”ES 出骨架,商品中心与交易相关域补血肉”的模式:

字段类型主要来源为什么这么设计
标题、副标题、主图、品牌、类目、属性标签搜索中心 / ES这些字段参与检索、过滤、聚合或高频展示,适合做索引骨架。
上下架状态、可搜状态搜索中心 / ES 过滤必须在搜索阶段先过滤掉不可售对象,避免返回无意义结果。
销量、评分、热度等排序因子搜索中心 / ES直接用于粗排或综合排序,允许弱一致。
实时到手价、会员价、促销价计价中心 / 商品中心 Hydrate高敏感、高频变化,列表允许短暂滞后,但展示时最好用实时结果覆盖。
绝对库存数、紧张库存文案库存中心 Hydrate属于高频高并发变化字段,不能让 ES 承担绝对数写入。
活动标签、圈品命中、促销露出营销中心 Hydrate露出逻辑变化快,且失败时可以局部降级。
冷门说明类字段商品中心不参与搜索与排序,没必要进 ES。

一个很实用的判断方法是:

  • 只要字段决定”搜不搜得到、排在第几位、能不能按它筛选”,就应该优先放进 ES。
  • 只要字段决定”现在到底多少钱、到底还有没有货、这个活动此刻还能不能领”,就应该优先让商品中心、计价或库存域返回实时结果。

3.2.3 搜索结果页为什么只认 ES 骨架,不直接等于交易事实

搜索结果页的目标是高吞吐召回、排序和转化,不承担交易真相主权。它展示的是”当前最有可能被用户点击的候选集”,不是”此刻可以直接下单成交的最终合同”。

因此,搜索页允许:

  • 索引异步刷新
  • 列表字段局部滞后
  • 某些弱展示字段降级缺失

但它不能越界去承担:

  • 实时价格真相
  • 实时库存真相
  • 权益资格最终裁决

这也意味着:下单前必须重新校验价格、库存、权益。搜索结果页拿到的是”可浏览卡片”,不是”可下单事实”。

3.2.4 搜索结果页的 Hydrate 编排为什么不是纯串行,也不是无脑全并行

Hydrate 层最容易被低估的一个问题是:下游依赖到底应该串行调用,还是并行调用

如果完全按照”商品中心 -> 库存中心 -> 营销中心 -> 计价中心”的直觉串行走,业务理解上很顺,但性能上很危险。因为只要每个 RPC 平均耗时 20ms,四段串行下来就已经接近 80ms;任意一个下游轻微抖动,整条列表页链路就会很容易冲到 150ms 以上。

更稳妥的工程实践通常不是”全串行”,也不是”无脑全并行”,而是带依赖关系的半并行编排

  1. 第一阶段并行
    • 商品中心:拿商品卡片骨架、类目、品牌、商家等基础信息
    • 库存中心:拿库存摘要、是否有货、紧张状态
  2. 第二阶段
    • 营销中心:基于商品基础信息和用户上下文,先判断活动命中、优惠资格、券与满减露出
  3. 第三阶段
    • 计价中心:在拿到营销结果之后,再基于商品基础信息、营销结果和用户上下文计算展示价、会员价、到手价

这样做的原因是:

  • 库存中心通常只依赖 item_id / sku_id,并不依赖商品标题、品牌、类目这些骨架字段;
  • 商品中心也不依赖价格和库存,它本身就是卡片骨架来源;
  • 营销中心 往往依赖商品类目、商家、售卖属性来判断露出与资格;
  • 计价中心 在很多实现里并不是独立”拍脑袋算价”,而是要把营销命中的结果、优惠叠加关系、会员折扣、活动门槛一起折进最终展示价,所以它天然位于营销之后。

这种编排的总耗时,更接近:

  • max(商品中心, 库存中心) + 营销中心 + 计价中心

而不是四段简单相加。所以它通常仍然明显优于”商品 -> 库存 -> 营销 -> 计价”的纯串行模式,同时又不会像”盲目全并行”那样把前置依赖关系搞乱。

如果业务继续追求更低的列表页延迟,还可以进一步演进到近似全并行:在计价中心和营销中心内部也冗余一份极简的商品基础信息,例如:

  • item_id -> 类目
  • item_id -> 商家
  • item_id -> 基础售卖属性

这样一来,Hydrate 层在收到 ES 返回的 item_id 列表后,就可以:

  • 同时查商品中心
  • 同时查库存中心
  • 同时查营销中心
  • 等营销结果返回后,再调计价中心

因为营销不再必须等待商品中心先返回类目信息,而计价至少不需要再回头额外查一次商品中心。这本质上是:

用数据冗余换实时编排延迟。

但要注意,这种”绝对并行”只适合在两个前提下使用:

  1. 营销和计价中心内部维护的商品轻量副本足够新鲜;
  2. 你们愿意承担多一层数据同步和一致性治理的复杂度。

所以默认推荐结论是:

Hydrate 层优先采用”商品 + 库存第一阶段并行,营销第二阶段,计价第三阶段”的半并行架构;只有在对延迟极度敏感、且下游已经具备足够商品副本和营销结果缓存时,才继续压缩计价前置依赖。

3.2.5 详情页为什么必须做动静分离

商品详情页(PDP)是交易漏斗里最核心的读页面之一。它的特点是:

  • 流量极大
  • 读多写少
  • 高可用要求极苛刻
  • 用户对页面解释能力和加载速度都极其敏感

因此,详情页不能做成”每次请求实时查所有下游”的重聚合链路,而要先做动静分离

从工程上看,详情页的数据通常可以拆成两类:

  • 静态数据
    • 标题、副标题
    • 主图、详情图
    • 商品参数说明
    • 低频变化的文案和结构化描述
  • 动态数据
    • 当前到手价
    • 库存摘要
    • 当前用户可见的权益和促销露出
    • 配送时效、门店核销、预约规则、评价摘要

更稳妥的详情页架构通常是:

  1. 静态骨架优先
    • 商品静态内容通过静态 JSON、静态片段或 CDN 化页面骨架快速返回。
    • 用户先看到稳定的页面结构、标题和主图,而不是等待所有后端系统都准备完毕。
  2. 动态数据异步 Hydrate
    • 前端或详情聚合层再去批量拿价格、库存、营销和履约数据。
    • 动态内容可以稍晚几十毫秒填充,但不能把首屏完全卡死。

为了让这条链路在大促和高并发下仍然稳定,通常要配合多级缓存

  • CDN / 静态内容缓存
    • 兜底标题、主图、详情骨架
  • 聚合层本地缓存(如 Caffeine)
    • 缓住极短时间内的重复请求和热点详情
  • 分布式缓存(如 Redis)
    • 缓住基础价、库存摘要、详情聚合片段或短期动态结果

这样做的目标不是追求”所有详情字段都绝对实时”,而是:

先保证详情页稳定打开,再保证动态关键信息足够新鲜,最后在创单前做最终强校验。

3.2.6 详情页里的实时计价、库存摘要与大促降级

详情页里最敏感的两个问题,永远是:

  1. 现在多少钱
  2. 现在还有没有货

这两个字段之所以难,是因为它们都不适合做成简单的全量缓存。

对于价格来说,真正的到手价往往同时受以下因素影响:

  • 会员等级
  • 商品基础价
  • 店铺活动
  • 平台券 / 品类券
  • 用户专属权益

因此更常见的做法不是缓存”所有用户的最终价”,而是:

  • 缓存基础价 / 会员价矩阵 / 基础促销价
  • 在详情请求进来时,再结合用户上下文和营销命中做轻量实时计算

对于库存来说,详情页一般不追求展示精确库存数字,而是展示:

  • 有货
  • 无货
  • 紧张库存
  • 仅剩少量

这样可以显著降低库存读压力,同时也避免把高频变化的绝对库存数暴露到前台。

但真正的大问题出现在大促、热点商品和下游抖动场景下。此时详情页必须具备明确的降级能力:

  • 计价服务超时
    • 优先展示基础销售价或指导价
  • 库存摘要超时
    • 退化为保守文案,如”库存确认中”或”请以下单时校验为准”
  • 营销露出超时
    • 隐藏活动标签,不阻塞主页面
  • Redis 或下游依赖异常
    • 先用本地缓存或静态兜底页保证详情可打开

对热点商品,还要配合:

  • 热 Key 探测
  • 本地热点缓存
  • 限流与线程池隔离

这样即使某个爆款详情在几秒内被打到极高 QPS,也不至于把 Redis、计价、库存或营销服务一起拖崩。

所以详情页的核心不是”把所有数据都实时算到最准”,而是:

在可接受的一致性范围内,把最重要的动态字段算出来,把不重要的字段降级掉,并且永远保证详情页页面本身不因为单个下游故障而整体不可用。

3.2.7 搜索与详情的一致性边界怎么定

第 2 节已经给出了整体一致性分层,这里把结论聚焦到搜索和详情内部:

对象推荐一致性原因
搜索结果页弱一致索引是投影,允许短暂滞后
详情页静态骨架近实时一致商品契约信息,变更频率低,可缓存
详情页动态字段允许短暂偏差计价、库存、营销可能发生秒级变化
创单 / 结算强校验不能靠展示信息直接成交

推荐结论:

  • 搜索索引和列表投影可以异步刷新。
  • 搜索结果页不是”所有字段都由 ES 直接吐出”;只有参与召回、过滤、排序且可容忍滞后的骨架字段进索引,高频强一致字段要通过商品中心、计价和库存批量 Hydrate。
  • 详情页必须以正式商品契约为底,再补充当前价、库存和营销。
  • 下单永远不能只信列表或详情页展示结果。

3.2.8 为什么详情页之后还需要结算页和订单快照

详情页虽然比列表页更接近交易真相,但它仍然只是用户下单前的解释界面。它需要把:

  • 正式商品契约
  • 当前价格
  • 当前库存摘要
  • 营销露出
  • 履约规则

聚合成一个可理解的商品页面,但它仍然不能替代:

  • 结算页的价格试算
  • 预占库存
  • 优惠校验
  • 创单前最终版本校验

一句话:搜索与详情只负责”看”,结算与订单才负责”认”。订单快照则是把”认下来的事实”持久化,确保后续履约和售后不再回头依赖最新商品真相。

3.2.9 高并发下如何保护搜索 Hydrate 下游

搜索和详情链路的 Hydrate 阶段依赖多个下游:商品中心、库存中心、营销中心、计价中心。高并发下,这些下游很容易被放大后的请求量打穿。

工程上推荐用分层保护策略:

  • 批量接口:Hydrate 层不再逐条调用下游 RPC,而是按 item_id 列表做批量查询,减少 RPC 数量。
  • 线程池隔离 / 舱壁隔离:为商品、库存、营销、计价分别分配独立线程池,避免某一个下游慢调用占满公共线程池。
  • 超时控制:每个下游 RPC 独立设置超时时间,任一超时触发即时降级。
  • 局部降级:超时后按字段维度降级(如计价超时不阻断整个卡片,只用基础价兜底),而不是整页失败。
  • 热点缓存:热点商品在本地缓存和 Redis 里保护下游不被击穿。

这些技术和 3.6 的场景三时序图互相呼应,形成”文字说明原则 + 图说明流程”的对照。

3.2.10 为什么酒店要单独作为特殊场景处理

普通实物电商商品,信息变更以商品本身为主(价格、库存、活动)。但酒店天然带有 日期维度用户上下文。同一家酒店,在不同入住日、房型、会员等级和连住天数下,真实价格和真实可售状态都可能完全不同。这意味着:

  • 酒店的”可检索属性”和”可交易真相”之间的鸿沟比普通商品大得多;
  • 搜索索引、详情聚合、结算校验三层之间的一致性治理复杂度远高于标品;
  • 价格排序、深翻页、实时房态一致性等问题需要专门的架构策略。

因此本章将酒店相关内容统一收口到 3.8 节作为独立特殊场景处理。以下内容从 3.3 开始按场景化时序图展开。

3.3 场景一:搜索索引视图的构建

在深入搜索结果页、详情页的查询链路之前,必须先回答一个更前置的问题:搜索中心的索引数据到底是怎么从商品中心同步过来的

电商系统中,商品中心是商品数据的“权威源“(Source of Truth),存储在关系型数据库(如 MySQL、TiDB)中。而搜索中心使用专业的搜索引擎(如 Elasticsearch、OpenSearch)构建倒排索引。搜索中心本质上不是另一套“商品数据库“,而是商品中心数据的 索引视图。因此,索引视图的构建质量直接决定了搜索、导购和详情链路上所有查询的可用性和准确性。

3.3.1 整体架构流程

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

这条链路的核心思想是:商品中心负责写入真相,搜索中心通过可靠的异步管道构建索引视图,搜索服务再基于索引视图对外提供高吞吐的读能力

3.3.2 数据同步机制

搜索中心不能直接读写商品中心的库,而是通过以下方式同步数据。

(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)。

3.3.3 字段选择、映射与 ETL

从商品中心提取关键字段并映射到搜索索引,是索引构建中最核心的设计决策。

字段类型示例字段映射类型(Elasticsearch)用途
全文检索title, subtitle, desctext(IK / jieba 分词)搜索匹配
精确匹配 / 过滤spu_id, sku_id, brand_id, category_id, statuskeyword精确过滤
数值price, sale_price, stock, sales, ratinginteger / float / scaled_float范围查询、排序
数组 / 分面attrs(颜色、尺寸等规格属性), tagsnested / object多维度筛选
地理locationgeo_point附近门店 / LBS 排序
权重 / 时间create_time, update_time, weightdate / integer排序、boost

在写入索引之前,通常还需要经过一层轻量 ETL:

  • 清洗:去除 HTML 标签、特殊字符,统一单位。
  • 丰富:拆分 SKU 属性为独立字段或 nested 对象;生成搜索建议(completion suggester);加入同义词(“手机”→“智能手机”);计算动态权重(销量 × 0.4 + 评分 × 0.3 + 新品加分等)。
  • 去重:以 SPU 为维度聚合 SKU 信息。

索引构建方式

  • 单索引 + alias:大多数情况用一个主索引 + alias 切换。
  • 零停机重建:新建索引 products-2026-06-10-v2 → 全量导入 → 验证通过后切换 alias → 删除旧索引。
    • 中小型项目用 products_v2 命名即可。
    • 中大型或亿级以上数据,推荐日期命名 + alias 的方式,天然支持滚动索引和按时间归档。

3.3.4 版本控制与更新顺序保证

搜索结果允许最终一致,但绝不能出现“旧数据覆盖新数据“。在 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 作为分区键,保证同一商品的消息天然有序。

这套机制的核心原则是:

用版本号做乐观锁,旧消息到了直接丢弃,新消息到了才覆盖。网络乱序不伤害数据正确性。

3.3.5 Nested 对象与 SKU 规格处理

Nested 是 Elasticsearch 中处理一对多关联字段的关键技术,在电商商品属性(多规格筛选)上几乎是必备。

为什么需要 Nested:普通 object 类型在数组场景下会被扁平化,导致 key-value 关联丢失。例如商品属性 [{"颜色": "红色"}, {"尺寸": "XL"}],用 object 存储后,ES 会把所有 key 和 value 拆成两个独立集合,查询 颜色=红色 AND 尺寸=XL 时可能错误匹配到其他组合。而 nested 把每个对象作为独立的内部子文档存储,查询时必须使用 nested query,才能保证字段间的关联性。

电商典型用途

  • SKU 规格组合(颜色 + 尺码 + 版本)
  • 商品动态属性(不同类目下的属性差异大)
  • 商品标签列表、促销信息

注意事项nested 会增加索引体积和查询开销(内部是隐藏的子文档),建议单个文档内 nested 对象不超过几百个。对于高频筛选的关键属性(如 colorsize),可以同时提升为顶层 keyword 字段以加速过滤。

3.3.6 高并发与大促下的索引写入保护

搜索索引不仅要承受高并发查询,还要承受商品变更带来的写入压力。大促期间,库存和价格高频变更会把写入放大到峰值,读写混合压力下 Elasticsearch 容易出现 refresh 积压和 GC 抖动。

工程上的保护策略通常包括:

  • 价格与库存不压进搜索索引:只在 ES 里保留用于排序的粗粒度 base_min_pricehas_stock 标记,真实价格和库存摘要通过 Hydrate 层从商品中心、计价、库存服务实时补齐。这是 3.5、3.6、3.7 三张时序图的共同前提。
  • 写入限流与批量提交:索引构建服务控制写入并发度,合并小请求为 _bulk 批量提交。
  • 冷热分离:热数据(在售商品)与冷数据(下架 / 历史商品)分索引存放,减少单索引体积和写入放大面。

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: 搜索 / 导购结果

关键点

  • 搜索页拿到的是”可浏览卡片”,不是”可下单事实”。
  • 计价请求是最后一步,因为它依赖营销结果。

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(静态骨架 + 动态数据)

关键点

  • 详情页看到的动态值仍然只是”当前展示真相”,不是最终交易事实。
  • 后续进入结算与创单时仍需重新校验价格、库存和权益。

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 兜底动态数据可能不够新鲜,但不影响页面打开

3.7 特殊场景:酒店搜索与动态报价

酒店搜索比普通实物电商更复杂,因为它的库存和价格天然带有 日期维度用户上下文。同一家酒店,在不同入住日、房型、会员等级和连住天数下,真实价格和真实可售状态都可能完全不同。本节统一收口前面积累的酒店案例,用酒店证明:当商品是强时空属性资源时,搜索和详情链路必须做专门的动态报价与库存治理。

3.7.1 酒店哪些信息放 ES,哪些信息回商品中心 / 报价引擎

酒店搜索的字段边界比标品电商更严格:

  • 来自搜索中心 / ES 的信息
    • 酒店名称、别名、地址、经纬度、星级、品牌、设施标签、商圈、地标、基础房型名
    • 粗粒度的 has_room
    • 用于价格粗排的 base_min_price
  • 来自商品中心 / 报价引擎的实时信息
    • 指定入住日到离店日之间的真实可售房态
    • 具体房型在该日期区间下的实时到手价、会员价、连住价
    • 退改政策、确认时效、最晚保留时间

所以酒店搜索的标准模式通常是:

  1. ES 先负责按城市、星级、地理位置、设施等条件召回和粗排。
  2. 搜索服务拿着 hotel_ids + checkin + checkout + user_id 去商品中心 / 计价 / 库存资源域批量查询。
  3. 用实时价格和真实房态覆盖掉 ES 的粗粒度字段,再返回给前端。

这也解释了为什么列表页不能直接把 ES 的价格和库存当成交易真相:

  • ES 擅长做大规模检索和排序。
  • 商品中心、计价、库存才是交易前最后的权威真相。

3.7.2 酒店列表页如何做价格排序

酒店搜索里,”按价格从低到高排序”是一个典型的工程取舍点。难点在于:ES 必须先拿到一个字段值才能完成全局排序,但酒店的真实价格又是动态计算出来的,它同时受到入住日期、连住天数、会员等级、售卖计划、实时促销和税费规则影响。

如果试图把”所有用户、所有日期、所有房型组合下的真实到手价”全部塞进 ES,不但索引维度会爆炸,写入频率也会把搜索集群拖垮。因此主结论固定为:

  1. ES 按 base_min_price 粗排:ES 用基础价完成全局排序和分页。
  2. 当前页实时查 30 家:搜索服务拿当前页酒店 ID 去商品中心 / 计价引擎算真实价。
  3. 当前页内存二次微调排序:对这 30 条结果按真实价做轻量重排,确保用户看到的顺序基于真实到手价。

可以把它理解成两层排序:

  • 第一层:ES 粗排
    • 目标:快速筛出大体上价格更低的候选酒店。
    • 使用字段:base_min_price、地理距离、评分等低频或可容忍滞后的排序因子。
  • 第二层:当前页精展 + 微调
    • 目标:把当前用户、当前日期区间下的真实到手价展示给用户,并在当前页内按真实价微调顺序。
    • 使用输入:hotel_ids + checkin + checkout + user_id + member_level

这种做法的优点是:

  • ES 可以继续承担高并发检索和翻页,不会因为实时价格频繁变动而被拖垮。
  • 商品中心只需要对当前页有限数量的酒店做实时算价,成本可控。
  • 最终展示价足够新鲜,不至于让用户看到完全错误的成交价格。

它的代价也要诚实承认:可能存在轻微排序错位。例如某酒店在 ES 粗排时基础价更高,但在当前会员和促销条件下真实价反而更低。工程上通常接受这种小范围偏差,因为相比”绝对精确排序”,大促和高并发场景下的系统可用性更重要。

3.7.3 深翻页怎么处理

在 ES 里,深翻页不是看”一个城市总共有多少家酒店”,而是看 from + size 有多深。默认情况下,max_result_window = 10000,超过这个窗口 ES 会直接拒绝请求。

因此,”一个城市 1000 家酒店”本身不算问题;真正的问题是:

  • 用户是否在持续向后翻页;
  • 系统是否还想对越往后的结果继续做高成本的实时算价和二次精排。

这也是酒店搜索比普通商品搜索更难的地方。因为一旦用户选择”按价格排序”,后端往往不只是简单翻页,而是在做:

  1. ES 先按基础价召回一批候选;
  2. 商品中心对候选酒店批量算真实价;
  3. 搜索服务在内存里重新精排;
  4. 再截取当前页返回。

如果对所有翻页都维持这套逻辑,哪怕用户只翻到第 3 页,后端也可能已经在做”前 100 条甚至前 300 条候选的批量算价和内存重排”,这就进入了类深翻页问题:不是 ES 一定先死,而是商品中心和 Hydrate 编排先被拖垮。

酒店搜索里更稳妥的处理方式通常有三层:

  1. 产品侧先限深

    • C 端列表采用懒加载,而不是允许无限页码跳转。
    • 滚到较深位置时,优先引导用户缩小日期、商圈、价格区间、设施等筛选范围。
    • 这一步往往比任何底层技术优化都更有效。
  2. ES 侧用 search_after,不用无脑 from + size

    • 对 C 端连续下拉场景,使用 search_after 更合适。
    • 它不支持任意跳页,但非常适合移动端”下一页、再下一页”的滚动浏览。
    • 这样可以避免 ES 为了翻到更后面的页而反复丢弃前面的大量结果。
  3. 精排只保证前 N 条,后面自动降级

    • 可以定义一个”最高精排桶”,例如只保证前 100 家酒店的价格排序绝对精准。
    • 前 100 条以内:走”扩大召回 + 批量算价 + 内存精排”。
    • 超过 100 条之后:只对当前页做实时价覆盖,不再对更大候选集做二次精排。
    • 这样既保住前几屏的用户体验,也不会让后端为极低转化概率的深页结果付出无限成本。

为了进一步保护商品中心的报价引擎,通常还会加一层 批量报价缓存

  • 当用户带着同一组条件(如入住日、离店日、人数、会员等级)连续翻页时;
  • 商品中心第一次批量算出来的结果可以短暂缓存;
  • 后续翻页优先走缓存或 MGET,而不是每翻一页都重新全量计算。

因此,这里的推荐结论可以落成:

酒店搜索要把”深翻页问题”和”动态价格排序问题”一起看。ES 负责可扩展的游标翻页,商品中心只为前部高价值结果做精排和算价,越往后越要主动降级,而不是对所有结果做无限精确排序。

3.7.4 房态与价格的一致性怎么处理

酒店搜索里还有一个非常高频的决策点:列表页到底应不应该实时去查 30 家酒店的价格和房态

这个问题没有统一答案,关键看两件事:

  1. 单次批量查询的真实耗时能不能稳定控制在可接受范围内。
  2. 高并发、大促、爬虫和跨境供应商抖动时,底层资源层能不能扛住放大的吞吐压力。

如果压测结果表明:

  • 同一批次实时查询 30 家酒店的价格与房态只需大约 100ms;
  • 且这条链路经过了限流、隔离、超时和降级设计;

那么列表页采用”ES 粗排 + 当前页 30 家实时查询”是完全合理的,并且会比死缓存带来更好的用户体验。因为它能显著减少”列表页看到 300 元有房,点进详情页变成 500 元或满房”的落差。

但这里真正的难点,不是”单次 100ms 能不能做到”,而是以下三个工程问题。

第一,怎么扛住高并发下的整体吞吐量。

单次查 30 家只要 100ms,不代表在大促、暑期、国庆或被外部爬虫高频抓取时依然成立。因为一旦搜索 QPS 被放大,请求总量会迅速变成:

  • 搜索 QPS × 每次查询酒店数

这时候真正需要保护的不是搜索服务本身,而是后面的:

  • 商品中心 / 报价引擎
  • 库存 / 房态资源层
  • 海外供应商接口

因此更稳妥的做法是:

  • 在搜索服务到资源层之间做 线程池隔离 / 舱壁隔离
  • 对供应商或报价 RPC 做 超时控制与限流
  • 对异常来源流量做 防刷与防爬
  • 超过保护阈值时,自动退化为”ES 基础价 + 粗房态”模式

也就是说,实时查 30 家可以作为主路径,但必须有明确的降级开关

第二,怎么避免慢供应商拖垮整个列表页。

酒店列表经常会混入海外供应商或跨境资源方,它们的接口 RT 波动可能远大于本地酒店资源系统。如果 30 家里有 2~3 家供应商超时,不能让整页结果跟着被拖慢。

因此列表页更推荐采用:

  • 批量并行查询
  • 严格的总超时预算
  • 单酒店或单供应商分支的独立超时中断

在这种模式下:

  • 100ms 内成功返回的酒店正常展示实时价
  • 超时或失败的酒店退化成 ES 基础价、基础房态或”参考价”文案
  • 绝不允许少数慢分支把整页响应时间拉穿

第三,实时查和价格排序怎么同时成立。

这正是为什么 3.7.2 里强调”ES 粗排,当前页实时价覆盖 + 局部内存微调”。因为即使当前页 30 家的实时查询只要 100ms,ES 在全局排序时也不可能提前知道所有酒店对当前用户、当前入住日期下的真实到手价。所以更现实的做法仍然是:

  1. ES 先按 base_min_price 做全局粗排。
  2. 取出当前页 30 家酒店 ID。
  3. 搜索服务实时查这 30 家的真实价格与真实房态。
  4. 在内存中对这 30 条结果做一次轻量二次微调排序。

同时,必须在进入详情或结算时再做更强的校验,因为:

  • 列表页允许轻微延迟;
  • 当前页 30 家虽然可实时查,但受总超时预算约束;
  • 用户点进详情或进入结算时,才是真正要收紧校验口径的节点。

因此,这里的推荐结论不是”必须缓存”或”必须实时查”,而是:

酒店列表页可以实时查当前页 30 家数据,但前提是这条路径经过限流、隔离、超时和降级保护;真正的主架构仍然是 ES 负责粗排,商品中心 / 报价引擎负责当前页实时修正;进入详情和结算时再做更强的真实性校验。

3.8 搜索、导购与详情链路的统一架构原则

最后,用 4 句话收口,和第 4、5、6 节保持风格一致:

  1. 搜索负责召回和排序,不负责给出交易最终真相。
  2. 详情页负责多域聚合展示,不负责生成交易事实。
  3. 搜索与详情允许弱一致和局部降级,但结算与订单必须强校验。
  4. ES 提供骨架,商品、库存、营销、计价提供动态血肉,订单只认快照与凭证。

把这 4 句话和第 3 节的技术要点、时序图、酒店特殊场景串在一起,搜索、导购与详情链路的全貌就已经足够清晰,也为后续第 4 节”购物车与结算链路”和第 5 节”下单、支付与订单编排链路”做好了准确的边界铺垫。


4. 购物车与结算链路

4.1 场景画像

购物车和结算虽然常常出现在同一个前端页面体系里,但它们在系统设计上承担完全不同的角色:

  • 购物车是“意愿篮”,弱一致、可长期暂存。
  • 结算页是“交易前总校验器”,会触发价格试算、库存预占和优惠校验。

用户从加购到结算,通常会依次跨过下面几种典型动作:

  1. 未登录用户把商品先放进匿名购物车。
  2. 登录后把匿名购物车和账号购物车合并。
  3. 在购物车里勾选部分商品进入结算页。
  4. 结算服务对商品、价格、库存、权益、运费做一次交易前汇总校验。
  5. 用户点击“提交订单”前,系统再次确认这些结算结果是否还有效。

因此,购物车与结算链路的本质不是“把商品列出来然后直接下单”,而是:

  • 购物车负责保存用户意图;
  • 结算负责把用户意图收敛成一次可提交的交易尝试;
  • 订单创建必须建立在结算凭证仍然有效的前提之上。

4.2 关键技术点

4.2.1 为什么购物车不锁库存,而要等到结算阶段才预占

购物车天然是一个长生命周期容器,商品可能被用户放进去几分钟、几小时,甚至几天。如果在加购时就锁库存,会出现两个严重问题:

  • 资源被大量无效占用,真实买家反而看见“无货”;
  • 购物车会从“意愿篮”退化成“半订单系统”,导致系统复杂度失控。

因此更合理的边界是:

  • 加购阶段只记录意愿,不锁任何资源;
  • 结算阶段才做短 TTL 的库存预占和权益占用。

4.2.2 为什么购物车服务和结算服务要分开

购物车服务面对的是:

  • 高读写、弱一致、频繁加减数量;
  • 匿名态与登录态合并;
  • 失效商品标记、限购截断、展示优化。

结算服务面对的是:

  • 商品正式态校验;
  • 价格试算与营销试算;
  • 库存预占、权益占用;
  • 生成一组可供订单提交消费的结算凭证。

二者虽然都服务于“买东西”,但读写模型完全不同。如果把它们揉进一个服务里,最终会变成:

  • 购物车流量冲击交易校验逻辑;
  • 结算逻辑污染购物车的简单读写路径;
  • 系统很难分别做缓存、限流、降级和扩展。

所以更清晰的职责划分是:

  • 购物车服务负责意愿存储和合并;
  • 结算服务负责交易前编排和凭证生成。

4.2.3 结算页为什么本质上是一笔短生命周期 Saga

进入结算页时,系统需要跨多个域临时拿到“当前这笔交易是否可成立”的真相:

  • 商品是否仍然可售;
  • 当前价格和优惠是否仍有效;
  • 库存是否足够;
  • 地址、运费、履约规则是否匹配。

这些结果不是永久事实,而是一组瞬时成立的交易前提。因此结算页本质上不是订单,而是一笔短生命周期的 Saga 编排,其输出通常包括:

  • price_token / price_snapshot_id
  • reserve_ids
  • coupon_tokens / benefit_tokens
  • freight_snapshot

这些凭证只在短时间内有效,供后续提交订单消费。

4.2.4 结算页为什么必须返回凭证,而不是只返回一个总价

如果结算页只把“总价 299 元”返回给前端,而不携带任何可验证的结算凭证,那么提交订单时系统无法判断:

  • 这个价格是不是旧价格;
  • 这个优惠是不是已经失效;
  • 这批库存是不是已经被别人买走;
  • 用户有没有抓包篡改前端金额。

因此结算页必须返回可验证的凭证集合,而不是一个纯展示结果。到了提交订单阶段,结算服务或订单中心才能拿这些凭证再次校验,确认这次提交是否仍然合法。

4.2.5 购物车合并为什么不是简单的“两个列表拼起来”

匿名购物车和登录购物车合并时,至少要处理下面几类现实问题:

  • 同一 sku 在两个桶里都存在,需要合并数量;
  • 合并后可能触发限购上限,需要截断;
  • 某些商品已经下架、缺货或不可售,需要打失效标记;
  • 某些商品规格变了、价格变了,需要提示刷新;
  • 不同端(H5、App、小程序)带来的本地购物车格式可能不同。

所以购物车合并的正确语义不是“简单拼数组”,而是:

以用户账号购物车为权威桶,对匿名购物车做一次规则化并入。

4.2.6 结算确认为什么必须支持“失效与刷新”

用户进入结算页到真正提交订单,中间可能经过几十秒甚至几分钟。期间世界已经变了:

  • 商品被下架;
  • 价格发生变化;
  • 优惠券被别的订单占用了;
  • 库存被抢空;
  • 配送范围或运费模板发生变化。

所以结算确认一定要支持:

  • 显式校验失效;
  • 告知前端“哪些条件失效了”;
  • 允许用户一键刷新结算页重新生成凭证。

这也是为什么订单系统只认校验通过后的结算凭证,而不认用户页面上肉眼看到的旧数据。

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: 返回合并后的购物车

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)

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

4.6 购物车与结算链路的统一架构原则

购物车与结算链路的统一设计原则,可以浓缩成下面四句话:

  1. 购物车只保存购买意愿,不保存交易事实。
  2. 结算服务负责把商品、价格、库存、权益和履约规则收敛成一次可提交的交易尝试。
  3. 结算页返回的是一组短期有效的交易凭证,而不是最终订单。
  4. 提交订单前必须再次校验这些凭证,才能把“意愿”推进成真正的“交易事实”。

5. 下单、支付与订单编排链路

5.1 场景画像

用户点击“提交订单”时,系统会从“交易前校验”进入“交易事实落地”:

  1. 订单中心基于结算凭证创建订单。
  2. 订单保存商品、价格和履约快照。
  3. 支付系统创建支付单并和外部渠道交互。
  4. 支付结果回流后,订单状态推进。
  5. 支付失败、超时取消或风控拒绝时,显式释放库存与权益。

5.2 关键技术点

在订单提交链路里,面试官最喜欢围绕“幂等、一致性、高并发、快照、补偿、安全、扩展”连续追问。为了便于答辩,可以先用一张总览表把这些问题压住:

追问方向典型问题简要回答口径
幂等与重复提交token/order_no 过期怎么办先按 order_no 反查订单;查到则返回已有订单,查不到再提示刷新确认页
幂等与重复提交Redis 挂了幂等怎么保证Redis 只做前置消峰,最终靠订单表 order_no unique 兜底
幂等与重复提交多个请求同时消费凭证怎么办Redis 用 Lua / 原子删除一次性消费;提交时还要校验凭证绑定的 user_id / session_id,防止别人拿到凭证越权提交;数据库唯一索引做最后防线
幂等与重复提交重复点击、网络重试、MQ 重发怎么分层处理前端防抖处理点击;同步接口靠 submit_token/order_no;异步链路靠消息幂等键
幂等与重复提交技术幂等和业务重复单提醒有什么区别技术幂等解决“同一请求别处理两次”;业务重复单提醒解决“同一用户短时间内别无意中下两笔相似订单”,两者要同时存在但不要混为一谈
分布式事务与一致性跨服务调用怎么保证一致性不做 XA;主链路快速失败,靠事件补偿 + 延迟反查释放做最终一致性
分布式事务与一致性库存预占成功但创单失败怎么办发布 OrderCreateFailedEvent;库存和营销中心异步解冻,并保留延迟反查自愈
分布式事务与一致性用 Saga、TCC 还是本地消息表标准电商更常用 Saga / 本地消息表 + 补偿;避免重型强一致事务
分布式事务与一致性真正扣库存在哪里做创单阶段通常只预占;支付成功后由订单中心编排库存确认
高并发与性能order_no 怎么高性能全局唯一常用 Snowflake / 号段;避免纯数据库自增暴露给外部
高并发与性能这条链路瓶颈在哪里通常在计价、库存预占、营销校验和订单库写入;通过缓存、批量接口、隔离和削峰扩容
高并发与性能热点 order_no 被狂刷怎么办网关限流 + 用户态校验 + Redis 短 TTL 幂等凭证 + 本地缓存已有结果
高并发与性能唯一索引高并发插入有什么问题会放大热点写竞争;但它是最后兜底,前面必须用 Redis 把大部分重复流量挡掉
下游凭证设计price_tokenreserve_idscoupon_tokens 是什么分别代表价格真相、库存预占凭证、权益占用凭证,是创单前各域冻结结果的引用
下游凭证设计这些凭证有效期怎么定按业务风险定:价格一般秒级到分钟级,库存预占和券占用通常与结算超时时间一致
下游凭证设计营销占用失败怎么办直接阻断创单,主链路快速失败,不进入订单落库
下游凭证设计商品静态合规校验校什么上下架、可售状态、类目限制、店铺状态、限购规则、黑名单等交易前静态约束
下游凭证设计价格版本校验意义是什么防抓包改价、防旧价格重放、防优惠过期后继续创单
订单快照为什么必须落快照订单之后不能再依赖最新商品真相;退款、客诉、售后都要基于历史成交事实解释
订单快照快照和订单主表怎么关联一般按 order_id / order_item_id 一对一或一对多关联,主表和快照表物理分离
订单快照快照数据量太大怎么办只保留维权关键字段;大字段不进快照;历史快照可冷热分层下沉
异常与补偿前端超时、后端超时怎么办前端超时靠幂等重试;后端超时按订单真相反查,必要时用结果缓存顺水推舟
异常与补偿部分成功怎么补偿依赖事件补偿、延迟反查和人工对账,不在主请求里同步回滚所有域
异常与补偿风控在哪做一般在创单主链路前段或中段做硬拦截,避免无意义占库存和占券
数据一致性与查询刷新页面如何立刻查到订单创单成功后优先读主库 / 写后读一致路径,避免立刻落到延迟从库
数据一致性与查询列表和详情走主库还是从库订单详情、支付后短时间查询优先强一致;普通历史列表可走从库或读模型
数据一致性与查询order_no 和内部 order_id 区别order_no 偏业务外显与幂等;order_id 偏内部主键和关系索引
安全与风控怎么防别人拿别人的 order_no 提交order_no 必须绑定用户身份、会话上下文和提交签名,不允许裸号直接创单
安全与风控怎么防中间人改价价格签名 / 价格版本核销 + 时间窗口 + nonce 防重放
安全与风控怎么防恶意刷 Redis网关限流、人机校验、用户级频控、短 TTL、异常流量隔离
技术细节Redis 原子性怎么做关键消费凭证通常用 Lua;简单抢锁可用 SET NX EX
技术细节数据库事务怎么开创单本地事务只包订单事实与快照落库,保持短事务;隔离级别通常用 RC/RR 结合唯一键
技术细节监控埋点看什么提单 RT、成功率、重复提交率、Redis 命中率、唯一键冲突率、补偿成功率、订单创建失败率
扩展性与演进合并支付、分单怎么扩展结算服务继续负责编排,订单中心拆出主单/子单/支付单关系模型
扩展性与演进预售、0 元购怎么扩展复用创单骨架,替换价格确认、库存锁定和支付触发条件
扩展性与演进怎么做降级和熔断计价、营销、库存预占分别限流熔断;必要时关闭复杂权益,优先保核心创单链路

5.2.1 订单应该在“提交订单”时创建,还是在“点击支付”时创建

这在电商里是一个非常经典的交易架构决策点,通常有两种主流方案:

  • 方案 A:提单时创单
    • 用户点击“提交订单”时,先创建一笔待支付订单,再进入收银台选择支付渠道。
  • 方案 B:支付时创单
    • 用户点击“立即支付”时才真正建单;支付成功后,再落最终订单事实。

二者的本质区别在于:库存锁定发生得早还是晚、订单事实生成得早还是晚

方案优点风险适用场景
提单时创单用户一旦进入收银台,库存和交易资格通常已经预留;支持待支付、催付、继续支付更容易被恶意占库存;高并发时前置写库和锁资源压力更大实物电商、大多数标准电商、酒店、机酒票务、重体验场景
支付时创单极大减轻前置创单压力;不容易被恶意占库存;更适合极端高并发可能出现“钱付了但后置建单 / 扣库存失败”的补偿复杂度;用户体验更容易受损秒杀、抢购、部分虚拟商品、部分极端高并发场景

这章默认采用的是 方案 A:提单时创单。原因是本章的主线更偏向:

  • 购物车 -> 结算 -> 提交订单 -> 支付 -> 履约

这类标准交易旅程。它的优点是:

  • 订单能够稳定承接商品、价格、履约快照;
  • 用户进入收银台后,交易关系已经明确;
  • 支付结果回调只需要推进订单状态,而不是同时承担“先建单、再扣库存、再补偿”的复杂责任。

但也要明确:这不是唯一正确答案。如果业务是:

  • 极端高并发秒杀
  • 超稀缺票券抢购
  • 低客单价虚拟商品

那么“支付时创单”往往更合理,因为它能把大量无效创单和恶意占坑挡在支付前面。

因此这里更稳妥的结论是:

标准电商与重体验商品,优先采用“先创单、后支付”;极端高并发和强防刷场景,再考虑“支付时创单”。

5.2.2 订单提交流程应该由独立聚合服务编排,还是由订单中心自己协调下游

这也是交易架构里非常经典的一个分歧点,通常存在两种方案:

  • 方案 A:单独起一个结算 / 交易聚合服务
    • 由结算服务或 Trade-CO 统一去协调商品、库存、计价、营销,再把最终结果交给订单中心落单。
  • 方案 B:前端直接调用订单中心,由订单中心自己协调下游
    • 订单中心既管订单写库,又去调用商品、库存、计价、营销这些服务完成前置校验与编排。

这两种方案的本质区别是:交易主流程编排逻辑,是放在订单领域之外的场景聚合层,还是直接压进订单中心内部。

方案优点风险适用场景
独立聚合服务订单中心更纯净;读写压力与场景编排隔离;更适合复杂结算页和多业务协同多一跳 RPC;多一个服务需要治理中大型电商、业务复杂、团队分工清晰、存在独立预结算页
订单中心自己协调链路短;服务更少;前期开发快订单中心容易膨胀成万能大管家;读写混合;迭代风险高小团队、早期系统、短期快速上线

从工程演进角度看,很多系统早期会采用“订单中心自己协调”的方式快速上线,但随着以下问题出现,通常都会向独立聚合服务演进:

  • 结算页逻辑越来越复杂
  • 商品、计价、库存、营销团队开始独立演进
  • 订单中心既要承接创单写流量,又要承接大量预览与预结算读流量
  • 大促时希望把“重编排逻辑”和“订单写库主链路”物理隔离

本章默认采用的是 方案 A:独立结算聚合服务编排,下游原子服务各自归位。原因是:

  • 结算页本身就天然是一个跨商品、库存、计价、营销、地址的聚合场景;
  • 订单中心更适合回归为“交易事实持久化 + 状态机推进”的原子领域服务;
  • 支付、履约、售后后续也都更容易围绕清晰的订单事实展开。

因此这里的推荐结论是:

当系统存在独立的确认订单页 / 预结算页,且交易链路已经明显跨多个领域服务时,优先采用“结算聚合服务编排,下游原子服务落地”的架构;只有在系统非常轻量时,才让订单中心临时兼做协调者。

5.2.3 创单接口的幂等性应该怎么设计

创单接口的幂等性,不能只理解成“用户重复点按钮怎么办”。它真正要解决的是:

  • 同一个下单意图因为网络抖动、页面重试、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 负责前置消峰,数据库负责最终绝对幂等。

5.2.4 预生成的订单号能不能直接作为创单幂等 token

在创单幂等设计里,还有一个非常实用的工程化问题:

  • 结算页是不是一定要单独发一个随机 submit_token
  • 还是说,预生成的订单号本身就可以兼做创单幂等键

答案是:可以,而且在很多系统里这是更推荐的做法。

这里的关键不是“先写一条待支付订单”,而是:

  • 用户进入确认订单页时,系统先生成一个全局唯一的 order_no
  • 这个 order_no 先不落订单主表;
  • 而是先作为一次性的创单提交凭证,写入 Redis,设置合理的过期时间;
  • 真正提交订单时,前端带着这个 order_no 回来,由后端原子消费它,再执行正式创单。

这意味着,一个预生成订单号同时承担了两层身份:

  • 业务主键:后续支付、履约、售后都围绕这个订单号推进;
  • 提交幂等键:用于防止同一个结算确认页被重复提交多次。

与“纯随机 token”相比,它的优势很明显:

  • 不需要再维护一套独立 token 体系;
  • 幂等键和订单主键天然合一,链路更清晰;
  • 如果提交时 Redis token 已经过期,后端可以直接按 order_no 去订单库反查是否已经成功创单;
  • 即使前端回包丢失,用户重试时也更容易平滑返回已有订单。

但要想把这个方案用稳,必须满足几个前提:

设计点要求
订单号生成时机必须在确认订单页阶段就预生成,而不是创单事务最后才生成
Redis 侧order_no 要作为一次性可消费凭证写入缓存,并设置 TTL
MySQL 侧订单表必须对 order_no 做唯一索引,承担最终兜底
提交失败处理Redis token 失效后,不能直接报错,要先按 order_no 反查订单是否已存在

最稳妥的提交流程通常是:

  1. 用户进入确认订单页;
  2. 结算服务预生成 order_no
  3. Redis 写入 order:submit:{order_no},TTL 例如 15~30 分钟;
  4. 前端提交订单时,原样带回这个 order_no
  5. 后端先原子消费 Redis 中的提交凭证;
  6. 再进入正式创单事务;
  7. 订单表以 order_no unique 最终兜底。

这里尤其要注意一个容易答错的点:

token 过期,不等于订单一定创建失败。

所以当 Redis 里发现 order_no 已不存在时,更合理的后端处理顺序是:

  • 先按 order_no 去订单库查;
  • 如果已经有订单,直接返回这笔已有订单;
  • 如果没有订单,再提示用户“页面已超时,请刷新后重新提交”。

当然,这种方案也有边界:

  • 不要直接暴露纯自增订单号;
  • 更适合使用 Snowflake、号段 + 随机扰动、带业务前缀的全局唯一号;
  • 并且 Redis 只负责前置防重,绝不能替代数据库唯一约束

因此这里更稳妥的结论是:

预生成订单号完全可以直接作为创单幂等 token。最佳实践是“预生成订单号 = 提交幂等键 = 订单业务主键”,再配合 Redis 一次性消费和数据库唯一索引,形成一前一后的双保险。

5.2.5 技术幂等和业务重复单提醒,为什么要分两层设计

创单链路里还有一个很容易被忽略的点:

  • 技术幂等,解决的是“同一个请求重复到达,系统不要创建两笔订单”;
  • 业务重复单提醒,解决的是“同一个用户在很短时间内,可能无意中下了两笔高度相似的订单”。

这两者看起来都在处理“重复”,但其实完全不是一回事。

技术幂等的典型触发原因通常是:

  • 用户重复点击提交;
  • 网络抖动导致前端自动重试;
  • 网关超时后客户端再次发起请求;
  • 消息重发或服务重试。

它的目标非常纯粹:

同一个下单请求,多次到达,只能被系统真正处理一次。

业务重复单提醒更偏用户体验和业务治理,它处理的是:

  • 用户已经成功下过一笔极其相似的订单;
  • 但因为没注意页面状态、没看到待支付订单、或者切了支付方式,又重新下了一笔;
  • 这两笔订单在技术上是两次不同请求,但在业务上很可能是用户误操作。

它的目标不是强拦截,而是:

在不破坏正常下单自由度的前提下,提示用户“你刚刚可能已经下过一笔类似订单了”。

所以一个成熟的订单系统,通常要把这两层拆开设计:

层次目标典型手段
技术幂等防止同一请求被重复处理submit_token/order_no、Redis 一次性消费、数据库唯一索引
业务重复单提醒防止用户短时间内误下两笔相似订单按用户、商品、地址、金额、时间窗口做相似订单检测,并给出二次确认提示

业务重复单提醒一般不会像技术幂等那样做成强约束唯一键,而是更偏“软校验”。例如:

  • 同一用户;
  • 在最近 1~5 分钟;
  • 针对相同商品 / SKU / 房型 / 行程;
  • 收货地址、数量、金额高度一致;
  • 且前一笔订单还处于待支付或刚支付状态。

这时系统更合理的动作通常是:

  • 弹出提醒:
    • “你刚刚已经提交过一笔相似订单,是否继续下单?”
  • 给用户两个选择:
    • 去查看已有订单;
    • 继续提交当前订单。

这样做的原因是:

  • 有些重复单确实是误操作;
  • 但也有些重复单是用户有意为之,例如:
    • 给不同人各买一份相同商品;
    • 同一酒店房型连续下两间;
    • 同一活动商品分开下单。

如果把业务重复单也像技术幂等一样硬挡掉,反而会伤害真实交易。

因此更稳妥的结论是:

技术幂等和业务重复单提醒必须分层设计:技术幂等前置,负责防止同一请求被系统处理两次;业务重复单提醒后置,负责识别“相似订单”并给用户二次确认。技术幂等优先,业务提醒辅助。

5.2.6 创单失败后,库存预占和权益占用怎么做最终一致性补偿

这也是结算编排里非常关键的一道资损防线。

在标准的提单链路里,前面往往已经发生了这些动作:

  • 价格快照已经生成
  • 库存已经预占,拿到了 reserve_ids
  • 营销权益已经占用,拿到了 coupon_tokens

这时如果最后一步:

  • 结算服务 -> 订单中心 CreateOrder

在写库时因为数据库超时、网络断开、主从抖动等原因失败,系统就会落入一个非常危险的中间态:

  • 前端看到的是“创单失败”
  • 但库存和权益其实已经被前置链路冻结住了

如果不处理,就会出现:

  • 僵尸库存预占
  • 优惠券被卡死
  • 用户无法再次下单
  • 商家可售资源被无故锁住

这里不能靠 Seata / XA 这类强一致事务硬拉平,因为它们会把高并发交易主链路拖得过重。更可落地的做法是:

  • 主链路快速失败
  • 异步补偿兜底
  • 延迟反查再兜底

推荐的补偿模型一般有两层:

第一层:消息补偿

当结算服务或订单中心感知到创单明确失败时,发布一条 OrderCreateFailedEvent

  • 库存中心订阅后释放 reserve_ids
  • 营销中心订阅后释放 coupon_tokens

这样可以把大部分“明确失败”的场景快速回滚掉,而不需要让前台请求同步等待所有补偿完成。

第二层:下游主动反查 + 延迟释放

为了防止消息丢失、网络抖动或“创单结果未知”这种灰色状态,库存中心和营销中心本身还应该有一层延迟自愈:

  • 在预占 / 占用成功时,挂一条延迟检查任务
  • 到达超时时间后,主动去订单中心反查:
    • 这个 reserve_ids / coupon_tokens 对应的订单到底创建成功了吗
  • 如果订单不存在,或者订单已经被关闭 / 取消,就自动释放资源

这意味着:

  • 结算服务负责主流程编排
  • 订单中心负责创单真相
  • 库存和营销中心各自对自己的冻结资源负责自我救赎

因此这里更稳妥的结论是:

创单失败后的最终一致性,不能依赖运行时强事务,而要依赖“失败事件补偿 + 延迟反查释放”的双层自愈机制。

5.2.7 价格防篡改应该重新计算一遍,还是依赖价格签名 / 版本核销

创单链路里还有一个非常关键的安全问题:前端提交的价格到底能不能信

如果黑客通过抓包改包,把前端传给 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 防重放”的轻量方案;只有在业务对一次性核销、价格版本冻结要求特别强时,再演进到有状态版本核销。

5.2.8 订单快照应该由谁构建:商品中心、订单中心,还是结算服务

订单快照的职责划分也是一个非常容易设计错的点。这里真正的问题不是“谁能拿到商品标题”,而是:

  • 谁手里拥有最完整的下单上下文;
  • 谁最适合在创单前把商品、价格、履约三类事实揉成一份订单解释材料;
  • 谁应该只负责持久化,而不要再次退化成交易大管家。

这类职责一般会出现三种候选方案:

  • 方案 A:由商品中心构建快照
    • 看起来商品中心最懂商品,但它只拥有“当前商品真相”
    • 它并不知道这次交易用了什么券、最终成交价是多少、履约承诺是什么
    • 如果让商品中心在提单时参与组装订单快照,会把交易上下文反向污染到商品域
  • 方案 B:由订单中心构建快照
    • 订单中心在收到创单请求时,理论上可以再去查商品、计价、营销、履约
    • 但这会让订单中心重新变成“大管家”
    • 创单链路的耗时、依赖数和失败面都会急剧上升
  • 方案 C:由结算服务构建快照,订单中心只负责落库
    • 结算服务在提交订单前,本来就已经拿到了:
    • 商品静态信息
    • 价格明细和优惠分摊结果
    • 履约承诺与交付上下文
    • 它是最适合在内存里把这些信息揉成 SnapshotDTO 的那一层
    • 订单中心收到后,只需要把订单事实和快照 JSON 一起持久化

三种方案的关键差异如下:

方案优点风险推荐度
商品中心构建商品标题、主图、类目天然可得职责越界;不拥有价格与履约上下文;容易把交易逻辑污染回商品域不推荐
订单中心构建创单和落库在一个服务里闭环订单中心重新退化成大管家;创单链路变重;依赖面暴涨谨慎使用
结算服务构建最接近完整交易上下文;适合在创单前聚合和揉快照需要明确快照字段边界,避免把无意义大字段塞进快照推荐

因此这里更稳妥的结论是:

订单快照应由结算服务在提交订单前完成组装,订单中心只负责把订单事实与快照结果持久化;商品中心提供静态契约,但不直接参与快照构建。

进一步说,快照也不能无限膨胀。真正应该进入订单快照的,通常是:

  • 商品 ID、SKU ID、标题、下单时主图 URL、规格属性;
  • 成交单价、原价、优惠分摊、券抵扣、运费、税费;
  • 履约类型、承诺送达时间、退改规则摘要。

而像商品详情图文、长文本介绍、视频等大字段,不应该进入订单快照。订单列表查询也不应该把大 JSON 混在主表里,而应该通过独立的 order_snapshot 之类的附表按需读取。

5.2.9 订单中心负责交易事实编排,商品中心负责交易前静态契约

商品中心负责“商品身份与价格合规”的静态校验,订单中心负责“交易行为与流水合规”的最终编排。

5.2.10 订单必须保存商品快照、价格快照和履约快照

订单必须保存商品快照、价格快照和履约快照,而不是回读最新商品。

5.2.11 支付发起和支付结果回调是两条不同链路

支付发起和支付结果回调是两条不同链路:前者负责创建支付单,后者负责提供支付事实。

5.2.12 支付系统只提供支付事实,订单系统自己推进状态机

支付系统只提供支付事实,订单系统自行推进自己的状态机。

5.2.13 支付成功之后,订单中心再去编排库存确认、权益确认和后续履约触发

支付成功回调之后,订单中心才去编排库存确认、权益确认和后续履约触发。

5.2.14 支付失败、超时取消和风控拒绝后,库存和营销权益必须显式释放

支付失败、超时取消和风控拒绝后,库存和营销权益必须显式释放。

5.2.15 订单模型的演进:从支付单 + 订单,到主单 / 子单 / 商品维度退款

订单模型的演进,本质上是在回答一个问题:系统到底要先解决“支付聚合”,还是先解决“履约拆分、售后拆分和商品维度退款”。

在业务早期,很多系统只有三张核心表:

  • pay_order_tab
  • order_tab
  • refund_tab

这套模型足以支撑最基础的交易闭环:

  • 一个支付单对应一个或多个订单
  • 一个订单下挂多个订单明细
  • 退款主要按整单维度处理

它的优点是简单、上线快、链路短。但当业务开始出现下面这些诉求时,模型就会越来越吃力:

  • 一个支付单下挂多个业务订单,需要合并支付
  • 一个用户视角上的“大订单”需要按商家、仓库、履约方式拆成多个子单
  • 售后不再只是整单退款,而是按某个商品、某个 order_item 退款
  • 后续还可能出现部分发货、部分签收、部分退款、部分核销

这时候,更稳妥的演进方向是把“支付域聚合”和“订单域聚合”拆开:

  • pay_order_tab:属于支付域,只解决一次支付要付多少钱、走哪个渠道、支付状态是什么
  • parent_order:属于订单域,解决用户视角和业务聚合问题
  • order_tab:承载子单,解决分商家、分仓、分履约、分售后的独立处理
  • order_item_tab:承载商品维度事实,给商品维度退款、部分售后、部分履约提供锚点
  • refund_tab:从“只关联合同/整单”演进到既可关联订单,也可关联 order_item_id

可以用一句话概括这种演进模型:

主单解决“支付和用户视角的统一”问题,子单解决“履约、结算、售后等多维度独立处理”问题。

一个比较务实的演进顺序通常是三步走:

  1. 先保留现有 pay_order_tab + order_tab + refund_tab,快速支撑基础交易。
  2. 在订单域引入 parent_order,或者至少先在 order_tab 上补 parent_order_id,开始支持合并支付和业务聚合。
  3. refund_tab 上增加 order_item_id,让退款能力从整单退款演进到商品维度退款。

这里有一个很重要的边界要守住:

  • pay_order_tab 不应该替代 parent_order
  • 支付单是支付域对象,关注资金流
  • 主单是订单域对象,关注用户视角、履约拆分和售后聚合

也就是说,支付单可以聚合多笔业务订单,但它不应该承接“用户看到的是一单还是多单”“履约怎么拆”“退款按哪个粒度处理”这类订单域问题。

如果系统还处在简单阶段,完全没必要一开始就把主单、子单、订单项、退款项全部做满;但设计时要预留一条清晰演进路径:

  • 简单模式先跑通支付和整单退款
  • 复杂模式再逐步引入主单、子单和商品维度退款

这样既能避免过度设计,也不会在后期被早期模型彻底卡死。

5.2.16 为什么库存 Confirm 不应该反查订单中心,而订单中心必须主动查询支付网关

这两个场景表面上都像“结果确认”,但它们的依赖方向其实完全不同。

维度库存 Confirm支付结果查询
对象库存中心(内部服务)支付网关(外部第三方)
控制权我们完全可控我们不可控
推荐方向订单中心主动调用库存 Confirm订单中心主动查询支付网关状态
是否希望被动反查订单中心不希望不适用
核心原因避免内部服务双向依赖和职责污染第三方回调不可靠,必须主动核实

库存中心之所以不应该反查订单中心,核心原因有三个:

  • 避免循环依赖
    • 订单中心调用库存中心做 Reserve / Confirm / Cancel 是合理的单向依赖;
    • 如果库存中心再反过来查询订单中心,就会形成双向耦合。
  • 保持库存中心职责纯净
    • 库存中心的职责是管理库存资源和 reserve_token 状态;
    • 它不应该理解“这个订单是不是已经支付成功”这样的订单域语义。
  • 防止订单中心膨胀成上帝服务
    • 如果库存、营销、物流都回头查订单中心,订单中心会变成整个交易系统的公共查询枢纽;
    • 这会放大瓶颈,也会放大故障传播面。

因此,更合理的做法是:

  • 订单中心作为协调者,在支付成功后主动调用库存中心做 Confirm
  • 库存中心只根据 reserve_token 做幂等状态流转:
    • RESERVED -> CONFIRMED
    • RESERVED -> CANCELED

这也意味着,订单中心必须能正确处理库存 Confirm 的响应结果

  • SUCCESS
  • ALREADY_CONFIRMED
  • RESERVE_TOKEN_NOT_FOUND
  • 网络超时 / 调用失败

并且这条 Confirm 调用本身必须支持幂等重试。通常的推荐做法是:

  • 支付成功后,订单中心在本地事务里写一条待确认库存的 Outbox / 本地消息;
  • 异步 Worker 调用库存中心 ConfirmInventory(reserve_token)
  • 只有收到明确成功响应,才把本地消息标记为完成;
  • 若响应丢失或调用失败,则按退避策略重试;
  • 多次失败后,把订单打到“支付成功_库存确认异常”状态,进入自动退款或人工补偿。

和库存中心不同,支付网关必须允许订单中心主动查询。原因也非常明确:

  • 它是外部第三方,我们无法完全掌控回调行为;
  • 回调可能丢失、延迟、重复甚至异常;
  • 所以只依赖回调不足以支撑订单状态推进。

因此支付结果的推荐模型通常是:

  • 以回调为主
  • 以主动查询为兜底

也就是说:

  • 支付网关回调来了,订单中心先按回调推进支付事实;
  • 如果回调迟迟不来,订单中心可以通过定时任务、用户主动查询或收银台轮询去调用支付网关 query 接口确认状态;
  • 一旦确认支付成功,再继续触发库存 Confirm、权益 Confirm 和履约编排。

一句话总结就是:

库存是我们的内部资源中心,要保持干净、独立、单向依赖;支付网关是外部不可信第三方,订单中心必须主动多长一个心眼去确认它的最终状态。

5.2.17 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_noreserve_token
payload真正的调用参数,如 reserve_token / order_no / sku_list
status当前处理状态,支持重试和死信管理
retry_count已重试次数
next_retry_time下一次允许被扫描和重试的时间点

对应到库存 Confirm 场景,订单中心最典型的本地事务写法就是:

  1. 支付回调确认成功;
  2. 在同一个数据库事务里:
    • 更新订单状态为 PAID
    • 插入一条 biz_type = INVENTORY_CONFIRM 的 Outbox 记录
  3. 事务提交后,异步 Worker 扫描 PENDING/FAILEDnext_retry_time <= now() 的消息;
  4. Worker 调用库存中心 ConfirmInventory(reserve_token)
  5. 若收到明确成功响应,则把 Outbox 记录标记为 SUCCESS
  6. 若调用失败或响应丢失,则把状态改成 FAILED,并按指数退避推进 next_retry_time

这里尤其要注意两层幂等:

  • 订单中心侧幂等
    • 同一笔 biz_type + biz_key 的 Outbox 记录只能插一次;
    • Worker 重复扫描时,也不能重复推进本地状态。
  • 库存中心侧幂等
    • reserve_token 为主键推进状态机:
    • RESERVED -> CONFIRMED
    • 重复 Confirm 返回成功或 ALREADY_CONFIRMED

因此,订单中心处理库存 Confirm 的推荐口径可以总结成:

  • 同步调用可以有,但不能只依赖同步 response
  • 最终必须以 Outbox + Worker 重试为准;
  • 只有收到明确成功响应,才把本地消息标记完成;
  • 多次重试仍失败,就把订单打到“支付成功_库存确认异常”状态,进入自动退款或人工补偿。

如果用 Go 落地,这个 Worker 的职责通常就是:

  • 周期性扫描 outbox
  • 反序列化 payload
  • 调库存中心 Confirm 接口
  • 根据返回结果更新 status / retry_count / next_retry_time

也就是说,Go 代码真正需要保证的不是“调一次 RPC 就好”,而是:

订单状态推进、Outbox 持久化、异步 Worker 重试、库存 Confirm 幂等,这四层一起构成最终一致性。

5.2.18 混合支付的状态机应该怎么设计

当一笔订单不是“纯现金支付”,而是由 Coin + Voucher + 支付渠道 共同组成时,支付链路的难点就不再只是调起支付,而是多种资产的锁定顺序、状态流转以及部分失败时的回滚

这里最容易出问题的点有两个:

  • 多种资产同时参与,扣减顺序稍有不慎就会在高并发下形成死锁
  • 渠道支付成功、券已锁定、Coin 已冻结,但后续某一步失败时,必须能可靠回滚

因此,混合支付更推荐采用三段式状态流转:

阶段动作目标
冻结阶段Lock Voucher + Freeze Coin在不真正消耗资产的前提下,先锁定用户可用权益
确认阶段Use Voucher + Deduct Coin渠道支付成功后,把冻结态转成最终消耗态
释放阶段Unlock Voucher + Unfreeze Coin用户取消、超时关闭或支付失败后,归还所有锁定资产

推荐的设计原则是:

  • 结算服务或支付中心在拉起支付前,只做锁定 / 冻结
  • 渠道支付成功回调之后,再做确认使用
  • 支付失败、超时关闭、风控拦截之后,再做逆向释放

这样做的好处是:

  • 用户点击支付时,平台已经知道这笔单最多能用多少券、多少 Coin
  • 最终需要请求第三方支付网关的现金金额是确定的
  • 资产和渠道支付被拆成“可逆阶段”和“不可逆阶段”,更适合 Saga 编排

在实现层面,推荐让营销 / 资产中心都提供三段式接口:

  • Lock / Freeze
  • Confirm / Use / Deduct
  • Cancel / Unlock / Unfreeze

不要让订单中心或支付中心自己去推导“这张券是不是已经用了”“Coin 到底是冻结还是已扣减”,而应该以下游返回的凭证和状态机为准。

还有一个很关键的财务边界:

用户现金实付 + 平台营销补贴 = 商户实收 + 渠道手续费。

因此,混合支付场景下,系统不仅要记录最终现金支付金额,还要记录:

  • marketing_discount_amount
  • coin_deduct_amount
  • voucher_discount_amount
  • channel_fee_amount

否则后续对账、退款分摊、商家结算都会变得非常困难。

5.3 场景一:提交订单时序图

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

5.4 场景二:提交支付时序图

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

5.5 场景三:支付结果回调与订单编排时序图

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)

5.6 场景四:支付失败与超时回滚时序图

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)

5.7 特殊场景:预售订单

预售订单的本质,不是“普通订单加一个活动标签”,而是延迟履约 + 分阶段支付 + 长周期资源占用。它和现货单最大的区别在于:交易事实可以先成立,但资源最终确认和履约发生在更晚的时间点。

5.7.1 业务生命周期

一个典型的预售订单,通常会经历下面几个阶段:

  • 预热期:用户可以浏览和加购,但还不能正式下单
  • 定金期:用户支付定金,锁定购买资格
  • 尾款期:活动切换到尾款支付窗口,用户补齐尾款
  • 履约期:尾款支付成功后,订单才进入正常发货 / 履约流程
  • 异常终止:定金支付后尾款超时、用户主动取消、活动终止

因此,预售订单不是一次支付完成全部交易,而是把“购买承诺”和“最终成交”拆成了两个时间点。

5.7.2 核心模型

预售订单建议显式建模,而不是把逻辑散落在普通订单字段里拼凑:

  • order_type = PRESALE
  • presale_end_time:尾款支付截止时间
  • delivery_time:预计发货时间
  • lock_deadline:库存锁定截止时间

其中最关键的是“分阶段支付”模型。更推荐单独抽一层 pay_stage 或等价支付阶段表,而不是在 pay_order_tab 上硬塞一组定金 / 尾款字段。原因很简单:

  • 每个阶段都可能有独立的支付流水 trade_no
  • 退款时可能只退定金,或者只退尾款
  • 财务对账更容易按阶段落账
  • 后续即使出现三阶段支付,也不需要推翻原模型

5.7.3 与其他服务的交互边界

预售单会把多个中心的责任拉得更长,因此边界必须先钉死:

  • 商品中心:提供预售商品快照,尤其是定金金额、尾款金额、预计发货时间、活动承诺文案
  • 营销中心:提供预售活动规则,如定金比例、尾款优惠、限购数量
  • 库存中心:在定金支付成功后做长时间预占;在尾款支付成功后再转成最终 Confirm
  • 消息中心:在尾款截止前做多轮提醒,例如提前 24 小时、3 小时、30 分钟

这里的设计重点是:

结算服务在定金阶段解决“资格锁定”,订单中心在尾款支付完成后才把这笔交易推进到真正可履约状态。

5.7.4 预售链路的关键挑战

预售最大的风险,不在于普通创单,而在于它把资源和资金拉成了一个长周期博弈。

挑战一:用户付了定金,但长时间不付尾款

  • 风险:库存被长时间占用,影响后续售卖
  • 处理:库存中心使用长 TTL 预占;尾款超时后由订单中心通过 Outbox 异步 Cancel;必要时按规则扣除部分定金作为违约成本

挑战二:尾款支付窗口集中爆发

  • 风险:大量用户在最后几小时同时补尾款,触发支付、库存 Confirm、营销结算的洪峰
  • 处理:尾款支付仍然走普通支付主链路的幂等、防重、Outbox、Confirm 编排,不因为它是“第二阶段支付”就绕开主流程

挑战三:退款复杂度更高

  • 风险:预售退款不再是简单整单退款,而可能区分定金退款、尾款退款、履约前退款
  • 处理:退款模型增加 refund_stage = DEPOSIT / FINAL 一类字段,显式标记退款属于哪个支付阶段

5.7.5 推荐的落地原则

预售场景最容易犯的错误,是把它当成“普通订单 + 两次付款”来实现。更稳妥的思路是:

  • 把预售当成一种独立 order_type
  • 把定金和尾款视为两个支付阶段
  • 把库存看成“长预占 + 最终确认”的两段式资源状态
  • 把尾款超时和活动终止视为标准 Saga Cancel 场景

一句话总结:

预售订单的核心,不是先收一笔钱,而是先锁定资格,再在更晚的时间点完成真正成交与履约。

5.8 特殊场景:0 元购订单

0 元购的本质,不是“没有支付所以更简单”,而是营销驱动的超高风险低价订单。它的目标通常是拉新、促活、带动搭售,但系统设计上反而要更谨慎,因为一旦风控、营销核销或库存确认做得不严,很容易直接形成资损。

5.8.1 业务本质与核心模型

0 元购订单建议也显式建模,而不是混在普通订单里只靠金额判断:

  • order_type = ZERO_YUAN
  • real_pay_amount:实际支付金额,很多场景为 0
  • marketing_discount_amount:营销补贴金额,给财务和活动归因使用
  • campaign_token:营销中心返回的强凭证,后续用于核销和反查
  • risk_score / risk_level:风控打分结果

这里要特别强调一个设计边界:

0 元购不是“没有成本”,只是用户不付钱,平台通过营销补贴替用户付款。

因此,订单里必须保留补贴金额和活动凭证,否则后续对账、活动归因、反作弊和财务核销都会变得很被动。

5.8.2 是否一定要走支付网关

0 元购最常见的设计争议,是要不要经过支付网关。

推荐结论是:

  • 如果用户仍需支付运费或其他附加费用,就必须走正常支付链路
  • 如果商品、运费、附加费用全部为 0,可以跳过第三方支付网关

但即使跳过真实支付,也建议保留一笔 pay_order_tab 记录,原因包括:

  • 用户订单列表和交易流水仍需要一条完整支付事实
  • 财务和活动分析需要知道这笔单是“0 元成交”,不是“没有支付过程”
  • 后续退款、活动冲正、对账也更容易统一模型

也就是说,0 元购可以跳过外部支付渠道,但不应该跳过系统内部的支付事实建模。

5.8.3 库存、营销和风控的处理原则

0 元购最容易犯的错误,是把它当成“福利单”,然后在库存和营销链路上放松规则。更稳妥的做法是:

  • 库存仍然走正常的 Reserve -> Confirm / Cancel
  • 营销权益建议在订单创建成功后异步核销,而不是在主请求里同步硬卡死
  • 风控必须前置,并且允许在订单创建后继续异步二次审查

为什么营销核销更推荐异步?

  • 同步核销会把营销中心稳定性直接传染给下单主链路
  • 0 元购经常是活动洪峰场景,营销系统更容易成为热点瓶颈
  • 订单事实先落下,再通过 Outbox 异步核销,更符合本章的最终一致性设计

5.8.4 风控是 0 元购的第一优先级

0 元购的最大风险,不是支付失败,而是被羊毛党刷穿。推荐采用三层风控:

事前风控

  • 用户画像
  • 设备指纹
  • 历史 0 元购记录
  • IP / 设备 / 账号限频

事中风控

  • 营销中心限购规则
  • 实时风险打分
  • 对高风险请求直接拒绝创单

事后风控

  • 订单创建成功后做异步复审
  • 对异常订单做人工审核、活动回收或后续履约拦截

典型规则包括:

  • 单用户 / 单设备每日限购 N 单
  • 新用户 + 老设备组合高风险
  • 短时间内同一商品出现大量 0 元单,直接熔断活动入口

5.8.5 0 元购的共性架构要求

虽然预售和 0 元购看起来很不一样,但它们都在提醒我们一件事:

不要把所有业务特性都塞进一个大状态机,而要通过 order_type + 扩展字段 + 异步编排 来承接复杂场景。

因此,这类特殊订单更适合统一遵守下面几条原则:

  • 基础交易状态仍保持简单:PENDING -> PAID -> FULFILLING -> COMPLETED
  • 业务差异通过 order_type 和专属扩展字段表达
  • 营销核销、库存确认、风控补偿都依赖 Outbox 和重试机制保证最终一致性
  • 关键峰值场景仍要靠 Redis + 幂等 + 唯一索引兜底

一句话总结:

0 元购不是“省掉支付就结束了”,而是把支付简化成了营销补贴问题,同时把风控和最终一致性的要求提高到了更高优先级。

5.9 结算服务中的“预占凭证 + 本地事务 + 异步确认”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 这种重型运行时强一致;
  • 下游域边界清楚,各自只负责自己的资源预占、确认和释放;
  • 即使出现网络抖动或部分失败,也可以依赖消息补偿和延迟反查完成自愈。

5.9.1 为什么核心交易长事务一定要做方案选型

“用户点击下单 -> 校验商品 -> 预占库存 -> 占用优惠 -> 请求支付” 这条链路,本质上是一个跨订单、库存、营销、支付多个服务的分布式长事务。单机时代的本地事务在这里已经失效,系统必须在下面几个目标之间做权衡:

  • 数据一致性
  • 高并发吞吐
  • 外部支付网关可接入性
  • 失败后的补偿成本

因此,创单链路不能只问“能不能保证一致”,而要问:

在高并发电商场景下,系统要用什么代价去换一致性。

5.9.2 四种典型方案的核心差异

方案一:2PC / XA

2PC 依赖全局协调器和底层数据库 XA 协议。所有参与方先进入 Prepare,锁住本地资源但不提交;只有全部成功后,协调器才下发 Commit

它的问题非常直接:

  • 底层数据库锁会跟着整个长事务一起悬挂
  • 高并发下吞吐量会被严重拖死
  • 外部支付网关根本不支持 XA 协议

所以在电商创单支付链路里,2PC 基本等于一条走不通的死路。

方案二:经典 Saga

经典 Saga 把长事务拆成一串本地子事务,每个服务先执行正向动作并立刻提交;如果后续失败,再逆向执行补偿。

它的优点是吞吐高、没有数据库全局锁,但缺点也很致命:

  • 缺少隔离性,容易出现“先真扣库存,后又补回来”的僵尸占位
  • 容易被网络乱序拖进悬挂、空补偿和重复补偿问题
  • 防悬挂、防乱序的代码会越来越重

方案三:标准 TCC

TCC 把两阶段提交抬升到业务层,要求每个下游都实现:

  • Try
  • Confirm
  • Cancel

库存中心在 Try 阶段不是直接扣库存,而是冻结一部分资源;支付成功后走 Confirm,失败时走 Cancel

它的优点是隔离性很强,特别适合资金类、账户类系统;但代价也很高:

  • 每个下游都要手写三套接口和状态机
  • 编排器一般依赖同步 RPC,链路长时容易拖垮线程池
  • 对研发规范和团队成熟度要求非常高

方案四:工业级改良 Saga

这也是本章一直在推荐的方案:把 TCC 的“预留资源思想”和 Saga 的“异步最终一致性”结合起来。

它的典型形态就是:

  • 创单时只做 Reserve
  • 订单中心先落本地交易事实
  • 支付成功后再异步 Confirm
  • 支付失败、超时关闭后异步 Cancel
  • 整个过程依赖 Outbox + Worker + 幂等状态机

这也是为什么本章一直强调:

“预占凭证 + 本地事务落订单 + Outbox 异步确认 / 取消” 才是标准电商创单支付链路的主流工业实现。

5.9.3 四种方案综合对比

对比维度2PC / XA经典 Saga标准 TCC改良版 Saga(推荐)
一致性级别强一致最终一致业务层强一致最终一致
并发吞吐极低
资源隔离性高,但依赖数据库锁
对外部支付友好度极差较好一般极好
开发成本极高
高并发电商适配度很差一般有限适配最优

把它翻译成更贴近业务的话就是:

  • 2PC 太重,锁不起
  • 经典 Saga 太莽,容易伤库存和权益
  • 标准 TCC 太贵,不适合所有链路都重做三段式
  • 改良版 Saga 在复杂度、性能和一致性之间最平衡

5.9.4 本章的选型结论

因此,对标准的电商创单支付链路,推荐结论非常明确:

  • 坚决不选 2PC / XA
  • 不建议在核心交易主链路直接使用经典 Saga
  • 只有金融级资管、账务划转这类场景才值得咬牙做标准 TCC
  • 普通电商创单支付链路优先使用改良版 Saga

这也是为什么结算服务在本章里的角色不是“硬拉所有服务做一次全局提交”,而是:

  • 组织预占
  • 快速确立订单事实
  • 把最终确认和取消交给订单中心异步编排

一句话总结:

在电商结算场景中,我们追求的不是运行时强一致,而是“资源先预占、订单先落地、后续异步确认、失败可靠补偿”的高吞吐最终一致。

5.9.5 落地建议

如果团队自己维护状态机和消息补偿能力,改良版 Saga 完全可以直接落地为:

  • 下游三段式接口:Reserve / Confirm / Cancel
  • 订单中心本地事务:订单事实 + outbox
  • Worker 异步扫描 + 幂等重试

如果后续链路继续拉长,比如:

  • 跨境履约
  • 多段商旅履约
  • 超长时间预约 / 改签 / 多次补偿

那么也可以再演进到更成熟的工作流 / Saga 引擎,例如:

  • Seata 的 Saga 模式
  • Temporal / Cadence 这一类代码化工作流引擎

但无论是否引入框架,原则都不变:

先把交易链路拆成可预占、可确认、可取消的资源状态机,再谈框架选型。


6. 履约、发货、核销与售后闭环

6.1 场景画像

订单支付成功只是用户交易旅程的中点,不是终点。后面的系统挑战包括:

  • 实物商品怎么发货、签收、退货退款。
  • 券码商品怎么发码、核销、退款。
  • 酒旅、到店和预约型商品怎么预约、履约、取消和退款。

这里最重要的统一原则是:

订单之后,系统不再回头依赖最新商品真相,而是基于订单快照和履约事实继续推进。

6.2 关键技术点

履约、发码、核销、退款、库存回补、权益回补,看起来像很多独立动作,但真正落地时不能让订单中心自己直接编排所有下游,否则它会再次膨胀成“大管家”。

因此,第 6 节建议显式引入一个统一角色:

履约与售后服务

它的职责不是拥有订单主状态,而是承接支付之后的流程编排:

  • 组织履约创建
  • 接收外部履约回调
  • 路由发码、核销、预约等不同履约分支
  • 编排退款、库存回补、权益回补
  • 把这些结果统一翻译成订单中心可消费的标准化事实

这套设计里需要先钉死 5 个原则:

  1. 订单中心拥有交易主事实和用户可见订单状态。 履约与售后服务只负责组织流程,不直接决定最终订单展示语义。

  2. 履约结果、核销结果、退款结果都只是事实输入。 外部仓配、发码、核销、退款系统只负责回传“发生了什么”,状态推进仍由订单中心完成。

  3. 售后必须基于订单快照、支付事实和履约事实。 不能在退款时重新查当前商品价格、当前详情页规则或当前库存展示。

  4. 实物、券码、预约型商品虽然路径不同,但必须复用同一套幂等、回调归一和补偿机制。

  5. 外部回调先进入履约与售后服务,再由订单中心消费标准化事件。 不让仓配、核销、退款系统直接改订单主状态。

可以把它和第 5 节做一个镜像理解:

  • 结算服务解决“如何生成一笔订单”
  • 履约与售后服务解决“订单支付之后如何被执行、被核销、被退款、被回补”

6.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

6.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

6.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: 推进订单 / 子单状态

6.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

6.7 特殊场景:预约 / 酒旅 / 到店履约

这几类场景看起来不像传统“发货”,但它们并不是独立订单系统,而只是履约与售后服务下的不同履约分支。

它们的共同点是:

  • 都不再依赖最新商品真相
  • 都依赖订单快照和支付后的履约事实推进
  • 都需要把外部世界的状态翻译成订单中心可理解的标准化事件

其中最典型的事实包括:

  • 预约 / 酒旅:
    • BookingConfirmed
    • CheckedIn
    • CheckedOut
    • BookingCanceled
  • 到店 / 券码:
    • VoucherIssued
    • VoucherConsumed
    • VoucherExpired

也就是说,履约与售后服务本质上承担了一个“协议翻译层”的角色:

  • 下游系统说的是“已发码”“已预约”“已入住”“已核销”
  • 订单中心认的是“这笔订单现在是否已履约、是否可退款、是否已完结”

6.8 履约与售后服务的统一架构原则

第 6 节之所以也需要一个明确的编排角色,原因和第 5 节是对称的:

  • 第 5 节有结算服务 / 支付编排服务,解决“怎么生成一笔订单”
  • 第 6 节有履约与售后服务,解决“订单支付之后如何被执行、核销、退款、回补”

如果没有这一层,订单中心就必须直接对接:

  • 仓配
  • 发码
  • 核销
  • 预约资源
  • 退款
  • 库存回补
  • 权益回补

这样它会再次膨胀成“大管家”,把整个后交易阶段的复杂性都吸进去。

因此本章的统一结论是:

  • 订单中心拥有订单主事实和用户可见订单状态
  • 履约与售后服务拥有支付后的流程编排职责
  • 下游仓配、发码、核销、退款、回补服务只提供事实,不直接拥有订单主状态

最后用一句话收口:

结算服务解决“如何生成订单”,履约与售后服务解决“订单支付之后如何被执行、被核销、被退款、被回补”。


7. 典型业务场景串讲

7.1 实物电商主链路

用户搜索手机 → 看详情页 → 加购物车 → 进入结算页试算和预占 → 创建订单 → 支付成功 → 仓配发货 → 签收完成 → 可发起退货退款。

这一链路的核心风险在于:

  • 列表页展示价与下单价不一致。
  • 支付失败后库存未释放。
  • 发货后退款和库存回补规则错乱。

7.2 券码 / 到店商品主链路

用户搜索餐饮券或景区票 → 详情页看使用规则 → 结算页试算与预占 → 支付成功 → 发码 → 到店核销 → 核销完成 → 按规则退款或不可退。

这一链路的核心风险在于:

  • 发空码。
  • 券码核销与订单状态不一致。
  • 退款后券码或权益没有正确回收。

7.3 酒旅 / 预约型商品主链路

用户查看房型、日期、价格日历 → 结算页锁房 / 预占 → 创建订单 → 支付成功 → 预约 / 确认单生成 → 入住 / 出行 / 使用完成 → 按取消规则退款。

这一链路的核心风险在于:

  • 详情页看见的日期价与下单时的真实价格不一致。
  • 预约资源已变动但订单仍按旧状态继续推进。
  • 售后取消没有依据订单时的规则解释。

8. 面试答辩与工程总结

如果面试官问“C 端交易链路最难的地方是什么”,比较好的回答不是背一串系统名词,而是先给出总心智:

C 端系统的难点,不是把搜索、购物车、订单、支付这些服务拆出来,而是让用户在整条交易旅程里既感受到响应足够快,又始终不会用旧价格、旧库存、旧规则成功交易,更不会在支付后、履约后和售后阶段失去解释依据。

这章建议记住 6 句话:

  1. 搜索结果页是弱一致投影,详情页是交易前解释,创单时再做强校验。
  2. 购物车保存的是意愿,不是资源占用。
  3. 结算页是一次短生命周期的 Saga,负责把价格、库存、营销和地址收敛成可提交交易。
  4. 订单是交易事实,不是“重新查商品”的入口。
  5. 支付系统提供资金事实,订单系统自己推进状态。
  6. 订单之后,任何履约和售后都优先基于快照和履约事实解释。

当你能把这 6 句话和上面的时序图、快照、预占、幂等、回补策略串起来时,这一章就不只是一个“电商流程介绍”,而是一套真正可落地、可答辩、可治理的 C 端全生命周期设计。

第 37 章 系统设计题库:使用说明、双索引与训练路线

本题库把系统设计面试拆成可限时练习、可追问、可复盘的题卡。候选人用题卡训练稳定的判断和表达;面试官用相同结构观察需求理解、权威状态、链路设计和取舍能力。

题卡标签、难度与建议用时约定

每张题卡的元信息使用同一套口径,便于候选人选题和面试官配题:

  • 能力标签标记需要展示的可迁移能力,例如领域建模、状态机、容量规划、数据一致性、可观测性或架构演进;每题保留 2 至 4 个标签,避免只罗列中间件名称。
  • 场景标签标记题目的业务和运行条件,例如商品供给、交易主链路、大促峰值、支付回调、故障演练或运营后台;它用于组合题目,不替代能力标签。
  • 范围区分组件级、领域级、系统级和平台级问题。先从范围相近的题开始,再跨域组合。
  • 难度分为基础中等进阶:基础题要求能说清核心概念和边界;中等题要求给出主链路、状态和失败处理;进阶题要求量化约束、权衡一致性与性能,并覆盖恢复和演进。
  • 建议用时是一次完整口述练习的参考:基础题约 15 分钟,中等题约 30 分钟,进阶题约 45 分钟,综合案例可延长至 60 分钟。时间不足时优先保留目标、状态、主链路、失败处理和取舍。

候选人训练工作流

每次练习先选一张题卡和时长,按下面顺序作答:

  1. 澄清目标、核心用户、成功指标、约束和非目标。
  2. 估算峰值请求、读写比例、数据增长、延迟预算和容量余量。
  3. 先画高层主路径,再说明关键状态放在哪里。
  4. 分开讲读路径如何加速、写路径如何保证正确性。
  5. 补充超时、重试、幂等、降级、补偿、监控和人工处理。
  6. 收束当前方案的边界、代价与下一阶段演进。

开场可先说明:“我先确认目标和约束,再估算规模,画出主链路并拆开读写路径,随后说明失败处理,最后补充演进和取舍。”这能让面试官知道回答结构,也方便被打断后回到主线。

练习结束后,不要只检查是否提到缓存、消息队列或分库分表;应检查每个组件是否有明确职责、权威事实是否清楚、失败后果是否被覆盖。

面试官评估工作流

面试官先给出题干中的必要约束,观察候选人是否主动量化不完整信息,再沿候选人自己的假设追问。评估重点是:

观察点合格表现深入表现
问题定义先问目标、规模和边界说明不同约束会改变哪些方案
主链路能顺序说明请求流转明确同步点、异步点和用户可见结果
状态与数据能给出存储选择区分权威状态、缓存和派生读模型
风险处理提到常见故障说明幂等、补偿、对账和恢复责任
取舍与演进能说出优缺点能给出当前边界和逐步演进条件

递进追问应优先围绕候选人主动提出的数字、组件和承诺,例如“峰值再放大十倍怎么办”“缓存失效时谁是事实来源”“重试导致重复副作用如何收敛”。

白板与容量表达

白板从少到多展开,避免一开始堆满组件:

  1. 写出目标、核心指标和非目标。
  2. 写出容量假设和峰值放大口径。
  3. 画入口、应用服务、权威存储、缓存、异步链路和关键下游。
  4. 用不同箭头或编号拆读路径与写路径。
  5. 在有风险的边上标记超时、限流、幂等、重试或补偿。
  6. 在图旁写当前瓶颈、监控指标和演进方向。

容量估算至少覆盖读请求、写请求、峰值并发、读写比例、数据增长、带宽、热点、异步重试流量和外部依赖。估算不需要假装精确到机器数,但必须说明口径如何影响选择:读高时优先设计缓存和读模型,写高时考虑削峰、分片和异步化,热点集中时考虑预热、隔离和限流,消息量大时考虑积压、回压和消费恢复。

可使用稳定句式:

  • “这里先把权威数据和读模型分开。”
  • “这条链路允许最终一致,但需要补偿和对账兜底。”
  • “这个组件解决吞吐或延迟问题,不替代业务幂等。”
  • “规模未到阈值前先保持简单实现,同时保留分片键和归档策略。”
  • “故障时优先保护核心交易,非核心推荐、通知和营销能力可以降级。”

自我复盘方法

每道题结束后按三层复盘:

层次检查问题
结构是否按目标、规模、链路、状态、失败、演进的顺序展开?
内容每个关键结论是否有业务约束或数量级依据?是否说明权威来源和失败处理?
表达是否先报结构、避免术语堆叠,并在结尾说明取舍和边界?

把失分点记录为下一次可验证的动作,例如“下次先写读写比例”“下次补充支付回调的幂等键”,而不是笼统记为“加强可靠性”。复练时改变一个约束并重答,确认模板能够适应变化而不是死记答案。

按能力索引

能力题卡
需求澄清与范围Q-GEN-SCOPE-01Q-GEN-SCOPE-02
容量估算Q-GEN-CAP-01
权威状态与一致性Q-GEN-DATA-01Q-GEN-DATA-02Q-GEN-DATA-03Q-GEN-DATA-04Q-GEN-DATA-05Q-GEN-DATA-06
可靠性Q-GEN-REL-01Q-GEN-REL-02Q-GEN-REL-03
中间件选型Q-GEN-MW-01Q-GEN-MW-02Q-GEN-MW-03Q-GEN-MW-04Q-GEN-MW-05
架构抽象与演进Q-GEN-EVO-01Q-GEN-EVO-02

按场景索引

场景题卡
面试方法与白板表达Q-GEN-SCOPE-01Q-GEN-CAP-01Q-GEN-EVO-02
秒杀、库存与交易Q-GEN-SCOPE-02Q-GEN-DATA-03Q-GEN-DATA-04
缓存与并发控制Q-GEN-DATA-05Q-GEN-DATA-06
异步消息Q-GEN-MW-01Q-GEN-MW-03Q-GEN-MW-04
搜索与复杂查询Q-GEN-MW-02Q-GEN-MW-05
故障治理与演进Q-GEN-REL-01Q-GEN-REL-02Q-GEN-REL-03Q-GEN-EVO-01

训练路线

先完成 Q-GEN-SCOPE-01Q-GEN-CAP-01,建立答题顺序;再选择数据、可靠性和中间件题卡练习权威状态、异常和取舍;最后用 Q-GEN-EVO-01 做综合归纳,并以 Q-GEN-EVO-02 把失分点转成下一轮训练动作。短时练习先保证结构完整,长时练习再增加容量、追问和演进约束。

第 38 章 通用系统设计题库

本章按通用能力组织题卡。每张题卡都要求从业务目标、规模、主路径、权威状态、失败处理和演进取舍展开,不把组件名称当作答案。

需求澄清与范围控制

Q-GEN-SCOPE-01:系统设计面试与算法面试的区别是什么?

元信息

项目内容
题目编号Q-GEN-SCOPE-01
来源01-system-design-interview-overview.md,本章面试题与追问第 1 题
时长10 分钟
难度基础
场景通用面试
题型短追问
能力标签问题定义、结构化表达

题干与约束

说明系统设计面试与算法面试考察的差异,以及为什么系统设计题应先澄清问题。

候选人作答任务

用一段完整回答说明考察目标,并给出首轮澄清问题。

推荐作答顺序

先区分确定输入下的求解与不完整约束下的设计,再说明目标、规模、状态和风险如何影响方案。

答案骨架

算法题更强调在确定条件下构造正确、高效的求解;系统设计题要求在信息不完整时定义问题、选取边界并解释取舍。先澄清可避免为错误目标堆组件,随后才能讨论容量、数据与可靠性。

评分锚点

基础回答能说出先澄清。较好回答能指出约束改变架构。优秀回答能主动给出非目标和验证指标。

递进追问

业务方只说“用户很多且必须稳定”时,下一句如何追问?

常见失分点

把系统设计说成算法题的放大版;直接罗列中间件;遗漏非目标。

关联正文

第 1 章 系统设计方法论第 3 章 生产系统治理

复盘清单

  • 我是否先说明了目标、约束和非目标?
  • 我是否把澄清结果连到后续决策?

Q-GEN-SCOPE-02:设计秒杀系统前需要澄清什么?

元信息

项目内容
题目编号Q-GEN-SCOPE-02
来源01-system-design-interview-overview.md,本章面试题与追问第 2 题
时长10 分钟
难度基础
场景秒杀
题型专题设计
能力标签需求澄清、热点识别

题干与约束

设计一个秒杀系统,但尚未给出商品规模、库存、用户资格和支付规则。

候选人作答任务

列出会改变库存、限流和异步方案的关键问题,并说明原因。

推荐作答顺序

确认活动峰值和商品热点,再确认库存事实、资格规则、下单与支付时限,最后确认可接受的排队、失败和降级体验。

答案骨架

需要确认瞬时请求量、商品库存和每人限购、是否预热、库存以何处为准、订单是否必须同步创建、未支付释放规则、风控和地域限制。极端热点决定入口限流与排队;正确性要求决定预扣、条件更新和对账方案。

评分锚点

基础回答问流量和库存。较好回答补充资格、支付超时和风控。优秀回答能把每项约束映射到对应机制。

递进追问

十万人同时抢一万件商品,和一小时内均匀产生十万订单,设计有什么不同?

常见失分点

把平均流量当峰值;只谈 Redis;没有问库存释放和重复提交。

关联正文

第 7 章 高准确性与强一致性第 9 章 高并发写与热点第 27 章 库存系统

复盘清单

  • 我是否区分了活动瞬时峰值与平均流量?
  • 我是否说明了库存和订单的权威状态?

容量估算与性能设计

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 章 高准确性与强一致性第 32 章 订单系统

复盘清单

  • 我是否明确了每类状态的权威来源?
  • 我是否说明了发现和修复偏差的闭环?

Q-GEN-DATA-02:系统设计题中如何回答一致性问题?

元信息

项目内容
题目编号Q-GEN-DATA-02
来源01-system-design-interview-overview.md,本章面试题与追问第 8 题
时长15 分钟
难度进阶
场景跨系统协作
题型短追问
能力标签一致性、取舍表达

题干与约束

如何避免把强一致和最终一致说成空泛口号?

候选人作答任务

以交易主链路和搜索投影为例,说明对象、强度、时限、失败处理和代价。

推荐作答顺序

先界定对象与错误后果,再确定同步边界和允许延迟,随后说明幂等、补偿、对账和监控。

答案骨架

订单提交、支付状态和库存确认等资损敏感事实需要强约束或同步确认;搜索、推荐、报表等派生读模型通常允许最终一致。最终一致必须说明延迟目标、可靠投递、重放幂等、对账和人工修复,而不是只说异步。

评分锚点

基础回答能分类。较好回答说出延迟和补偿。优秀回答能量化错误后果并解释性能代价。

递进追问

支付已成功但订单状态尚未更新时,什么数据对用户可见,谁负责补偿?

常见失分点

所有链路都强一致;所有异步都最终一致;没有时间边界和修复责任。

关联正文

第 4 章 大事务处理第 7 章 高准确性与强一致性第 33 章 支付系统

复盘清单

  • 我是否说明了一致性的对象和可接受时限?
  • 我是否给出了失败后的补偿和对账路径?

Q-GEN-DATA-03:为什么核心交易状态通常放在 MySQL?

元信息

项目内容
题目编号Q-GEN-DATA-03
来源02-middleware-reliability-interview.md,MySQL 高频追问:为什么核心交易状态通常放在 MySQL?
时长10 分钟
难度基础
场景交易存储
题型中间件追问
能力标签权威存储、事务

题干与约束

解释为什么订单、支付和库存流水通常以 MySQL 承载权威状态。

候选人作答任务

说明 MySQL 的职责、缓存和消息的职责,以及边界。

推荐作答顺序

先讲事务、约束、审计和恢复,再说明读扩展与异步传播。

答案骨架

核心交易需要原子更新、唯一约束、状态机校验、审计和恢复能力,因此以关系型事务存储承载权威事实。缓存用于加速与削峰,消息用于传播副作用;它们都不能替代最终状态的可验证来源。

评分锚点

基础回答提到事务。较好回答提到约束和账本。优秀回答能说明热点下的缓存预扣与数据库兜底。

递进追问

数据库成为瓶颈后,怎样扩展而不把权威状态交给缓存?

常见失分点

把 MySQL 说成所有数据的唯一选择;忽略读写分离、分片和归档;把消息当存储替代品。

关联正文

第 7 章 高准确性与强一致性第 27 章 库存系统第 32 章 订单系统

复盘清单

  • 我是否说清了权威状态需要的能力?
  • 我是否说明了缓存和消息不承担什么职责?

Q-GEN-DATA-04:库存扣减如何防止超卖?

元信息

项目内容
题目编号Q-GEN-DATA-04
来源02-middleware-reliability-interview.md,MySQL 高频追问:库存扣减如何防止超卖?
时长20 分钟
难度进阶
场景库存扣减
题型专题设计
能力标签并发控制、库存一致性

题干与约束

高并发下单时既要防超卖,也要处理取消、支付失败和重复请求。

候选人作答任务

设计库存检查、预占、确认、释放和对账链路。

推荐作答顺序

先确定库存事实和售卖规则,再讲原子扣减或条件更新,随后补充预占生命周期、幂等和恢复。

答案骨架

数据库使用条件更新、乐观锁或库存账本保护最终正确性;热点可先在缓存中原子预扣和限流。订单创建、支付成功、超时取消都通过唯一业务键和状态机推进预占、确认或释放;定期对账修复缓存与权威库存偏差。

评分锚点

基础回答有条件更新。较好回答有预占释放。优秀回答覆盖重复、乱序、对账和资损告警。

递进追问

支付回调重复且取消任务晚到时,如何避免已售库存被释放?

常见失分点

只用分布式锁;没有库存流水;支付失败后不释放;没有幂等键。

关联正文

第 7 章 高准确性与强一致性第 9 章 高并发写与热点第 27 章 库存系统

复盘清单

  • 我是否覆盖了预占、确认、释放和对账?
  • 我是否说明了并发与重复下的状态机约束?

Q-GEN-DATA-05:缓存和数据库不一致怎么办?

元信息

项目内容
题目编号Q-GEN-DATA-05
来源02-middleware-reliability-interview.md,Redis 高频追问:缓存和数据库不一致怎么办?
时长15 分钟
难度进阶
场景缓存
题型中间件追问
能力标签缓存一致性、故障恢复

题干与约束

核心数据更新后,缓存删除或刷新可能失败,业务允许的不一致窗口有限。

候选人作答任务

选择一致性策略,说明失败恢复和适用边界。

推荐作答顺序

先确认数据是否可容忍短暂陈旧,再确定权威读写路径,最后设计失效、重试、补偿和监控。

答案骨架

通常采用缓存旁路:写入权威存储成功后删除缓存,读取未命中时回源重建,并设置合理过期。对更敏感的数据可使用消息驱动刷新、延迟删除、版本号或直接读权威存储;删除失败必须重试、告警和补偿,不能承诺绝对一致。

评分锚点

基础回答会删除缓存。较好回答说明并发窗口。优秀回答能按业务风险选择策略并提供可观测性。

递进追问

更新成功但删除缓存失败,怎样避免陈旧数据长期存在?

常见失分点

先删缓存再写库且不处理失败;读写都强制同步更新缓存;没有过期和补偿。

关联正文

第 3 章 生产系统治理第 8 章 低延迟复杂读第 30 章 搜索与导购

复盘清单

  • 我是否先判断陈旧数据的业务后果?
  • 我是否说明了缓存刷新失败后的恢复闭环?

Q-GEN-DATA-06:分布式锁能解决所有并发问题吗?

元信息

项目内容
题目编号Q-GEN-DATA-06
来源02-middleware-reliability-interview.md,Redis 高频追问:分布式锁能解决所有并发问题吗?
时长10 分钟
难度进阶
场景并发控制
题型中间件追问
能力标签锁、幂等、状态机

题干与约束

多个节点并发修改库存或订单状态,团队希望统一使用分布式锁。

候选人作答任务

说明锁能解决什么、不能解决什么,以及核心状态的更可靠保护方式。

推荐作答顺序

先界定临界区和锁的失效条件,再说明数据库约束、条件更新和业务幂等。

答案骨架

分布式锁只能降低特定临界区的并发冲突,还会面对超时、续租、误释放、主从切换和网络分区。资金、库存和状态流转应以唯一约束、条件更新、版本号和状态机为最终保护;锁可作为优化,业务操作仍必须幂等。

评分锚点

基础回答能指出锁超时。较好回答有条件更新。优秀回答说明围栏令牌或失锁后的停止写入。

递进追问

持锁节点长时间停顿后恢复,如何防止它覆盖新持锁者的结果?

常见失分点

把锁当事务;不设置业务幂等;只讨论获取锁,不讨论失锁和释放。

关联正文

第 5 章 长生命周期业务流程第 7 章 高准确性与强一致性第 27 章 库存系统

复盘清单

  • 我是否区分了并发优化和最终正确性保护?
  • 我是否覆盖了锁失效后的业务行为?

高可用、故障恢复与可观测性

Q-GEN-REL-01:如何解释高可用?

元信息

项目内容
题目编号Q-GEN-REL-01
来源01-system-design-interview-overview.md,本章面试题与追问第 7 题
时长15 分钟
难度进阶
场景生产治理
题型短追问
能力标签高可用、可观测性

题干与约束

解释限流、熔断、降级、隔离、数据保护和演练如何组成可用性设计。

候选人作答任务

按流量、依赖、数据和运维四层给出具体措施。

推荐作答顺序

先说明要保护的核心路径和可用性目标,再分别覆盖流量、依赖、数据和运营恢复。

答案骨架

高可用不是增加实例数,而是让故障被限制、被发现、被恢复。流量层限流和隔离,依赖层超时、重试、熔断和快速失败,数据层复制、对账和补偿,运维层监控、告警、压测、演练和扩容预案;非核心能力在故障时让路给主交易。

评分锚点

基础回答会列举保护手段。较好回答能关联到链路。优秀回答能定义降级优先级和恢复指标。

递进追问

推荐服务异常时,商品详情和下单链路分别应该如何表现?

常见失分点

只说多机房;所有功能同等保护;没有监控和演练。

关联正文

第 3 章 生产系统治理第 9 章 高并发写与热点第 36 章 电商用户全生命周期

复盘清单

  • 我是否先定义了必须保护的核心业务?
  • 我是否覆盖了预防、发现、恢复三个阶段?

Q-GEN-REL-02:超时、重试与幂等如何协作?

元信息

项目内容
题目编号Q-GEN-REL-02
来源02-middleware-reliability-interview.md,可靠性高频追问:超时、重试和幂等是什么关系?
时长15 分钟
难度进阶
场景同步与异步调用
题型中间件追问
能力标签超时、重试、幂等

题干与约束

下游偶发超时,调用方需要提高成功率但不能重复创建订单、扣库存或支付。

候选人作答任务

说明三者的职责、重试条件和幂等落点。

推荐作答顺序

先设定超时边界,再区分可重试与不可重试错误,最后定义业务唯一键和结果查询方式。

答案骨架

超时防止资源无限等待,重试处理可恢复的瞬时失败,幂等确保重复请求产生相同业务结果。调用方使用有限次数、退避和抖动;服务端用请求键、唯一约束、状态机或去重记录收敛副作用,并在未知结果时查询最终状态而非盲目重试。

评分锚点

基础回答能定义三者。较好回答有退避和错误分类。优秀回答能处理超时后结果未知与跨服务幂等。

递进追问

支付请求超时但渠道可能已扣款,客户端能否重试?服务端应返回什么?

常见失分点

所有异常都重试;只在客户端做幂等;没有重试上限和观测。

关联正文

第 3 章 生产系统治理第 4 章 大事务处理第 33 章 支付系统

复盘清单

  • 我是否区分了失败、超时和结果未知?
  • 我是否说明了幂等键与最终状态查询?

Q-GEN-REL-03:什么时候用熔断,什么时候用降级?

元信息

项目内容
题目编号Q-GEN-REL-03
来源02-middleware-reliability-interview.md,可靠性高频追问:什么时候用熔断,什么时候用降级?
时长10 分钟
难度基础
场景依赖故障
题型中间件追问
能力标签熔断、降级、业务优先级

题干与约束

推荐、通知和支付渠道等依赖发生持续失败,主业务需要保持可用。

候选人作答任务

区分两种机制的目标,并给出具体业务动作。

推荐作答顺序

先确定下游故障是否拖垮调用方,再判断当前功能是否可被替代或延后。

答案骨架

熔断在错误率或延迟异常时快速拒绝调用,保护调用方资源并等待探测恢复;降级是在非核心能力不可用时提供简化结果或关闭功能,保护核心业务。例如详情页可移除推荐模块,支付渠道异常应切换渠道、保留处理中状态或转人工,不能伪造成功。

评分锚点

基础回答能区分定义。较好回答能给出恢复条件。优秀回答能按业务风险设计不同降级结果。

递进追问

通知服务熔断后,怎样保证交易状态仍可被用户查询和后续补发?

常见失分点

把熔断等同于服务下线;对支付直接返回成功;没有恢复和补偿策略。

关联正文

第 3 章 生产系统治理第 33 章 支付系统第 36 章 电商用户全生命周期

复盘清单

  • 我是否按核心程度定义降级结果?
  • 我是否说明熔断后的恢复探测和补偿?

中间件与基础设施

Q-GEN-MW-01:什么场景应该引入消息队列?

元信息

项目内容
题目编号Q-GEN-MW-01
来源01-system-design-interview-overview.md,本章面试题与追问第 5 题
时长15 分钟
难度进阶
场景异步协作
题型中间件追问
能力标签消息队列、异步设计

题干与约束

订单成功后需要通知、积分和索引更新,但主链路不能被非核心下游拖慢。

候选人作答任务

说明何时引入消息队列,以及顺序、重复、积压和失败如何处理。

推荐作答顺序

先定义必须同步确认的事实,再识别可异步的副作用,最后设计可靠投递和消费治理。

答案骨架

消息队列适合削峰、异步解耦、事件传播和失败重试,不是“解耦”万能答案。核心状态先在本地事务内落稳,通过本地消息表或可靠事件发布异步驱动下游;按业务键设计分区顺序,消费者幂等,积压时限流、扩容、回压或降级。

评分锚点

基础回答能说出异步化。较好回答覆盖幂等和积压。优秀回答能区分业务顺序、投递保证和补偿边界。

递进追问

订单创建成功但事件未发出,怎样避免下游长期遗漏?

常见失分点

事务内同步发送消息且无补偿;以为消息顺序等于业务正确;忽略积压和死信。

关联正文

第 4 章 大事务处理第 6 章 任务处理第 32 章 订单系统

复盘清单

  • 我是否说明了消息队列不承担的职责?
  • 我是否覆盖投递、消费、积压和补偿?

Q-GEN-MW-02:检索场景为什么不总是使用 Elasticsearch?

元信息

项目内容
题目编号Q-GEN-MW-02
来源01-system-design-interview-overview.md,本章面试题与追问第 6 题
时长10 分钟
难度基础
场景查询选型
题型中间件追问
能力标签搜索、存储选型

题干与约束

需要解释复杂搜索与按主键、简单条件查询的不同技术选择。

候选人作答任务

说明 Elasticsearch 的能力边界和引入后的代价。

推荐作答顺序

先识别查询模式,再比较事务存储和搜索读模型,最后说明索引同步与降级。

答案骨架

主键和有限条件查询通常由事务存储和索引满足;全文检索、多字段筛选、相关性排序、聚合和复杂分页更适合作为独立搜索读模型。引入 Elasticsearch 后要接受索引延迟,设计重建、异步同步、版本控制和回退查询。

评分锚点

基础回答区分查询能力。较好回答提到索引延迟。优秀回答能说明何时不引入搜索系统。

递进追问

搜索索引落后于商品主数据时,详情页和交易创建分别以什么数据为准?

常见失分点

把 Elasticsearch 当权威事务库;只谈性能;忽略运维和索引重建成本。

关联正文

第 8 章 低延迟复杂读第 25 章 商品中心第 30 章 搜索与导购

复盘清单

  • 我是否先按查询模式判断?
  • 我是否说明了搜索索引不是权威数据?

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 章 长生命周期业务流程第 32 章 订单系统

复盘清单

  • 我是否明确了幂等键对应的业务语义?
  • 我是否说明了重复与崩溃后的恢复?

Q-GEN-MW-05:何时选择 Elasticsearch 而不是 MySQL?

元信息

项目内容
题目编号Q-GEN-MW-05
来源02-middleware-reliability-interview.md,Elasticsearch 高频追问:为什么搜索不用 MySQL 直接查?
时长10 分钟
难度基础
场景搜索系统
题型中间件追问
能力标签搜索架构、读模型

题干与约束

商品需要关键词搜索、筛选、排序和聚合,同时交易系统不能依赖搜索索引正确性。

候选人作答任务

说明选择 Elasticsearch 的门槛、同步方案和交易边界。

推荐作答顺序

先界定搜索体验需求,再说明索引作为派生读模型,最后说明同步失败与重建。

答案骨架

当业务需要全文检索、多维筛选、相关性排序、聚合或深分页治理时选择 Elasticsearch;简单查询继续使用 MySQL。主数据变更通过异步事件更新索引,索引按版本幂等,支持全量重建和补偿;交易创建始终以权威商品、库存和价格校验为准。

评分锚点

基础回答有功能边界。较好回答有异步同步。优秀回答有索引重建、回退和交易隔离。

递进追问

索引更新延迟时,搜索结果中的过期商品怎样处理?

常见失分点

所有查询上搜索系统;同步写主库和索引;让订单直接信任索引字段。

关联正文

第 8 章 低延迟复杂读第 25 章 商品中心第 30 章 搜索与导购

复盘清单

  • 我是否说明了选择搜索系统的具体门槛?
  • 我是否保留了交易对权威状态的校验?

架构演进、成本与技术取舍

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 分钟
难度基础
场景面试复盘
题型方法题
能力标签复盘、表达改进

题干与约束

一次面试后发现自己“懂很多但讲不清”,如何建立下一轮训练计划?

候选人作答任务

给出结构、内容和表达三层复盘方法,并转化为可执行练习。

推荐作答顺序

先回放回答顺序,再核对缺失的状态与失败处理,最后把问题写成下一次的限时动作。

答案骨架

结构层检查是否先讲目标、规模、主链路、状态、失败和演进;内容层检查结论是否有依据、是否遗漏权威状态与补偿;表达层检查是否先报框架、是否术语堆叠。每个失分点转成可验证动作,下一轮在改变约束后重答并比较改进。

评分锚点

基础回答会复盘。较好回答能分层。优秀回答能设置可观察的训练目标并根据追问调整。

递进追问

如果每次都遗漏异常处理,下一次白板上应加入什么固定检查点?

常见失分点

只看答案对错;只背更多题;没有记录假设和被追问的边界。

关联正文

第 1 章 系统设计方法论第 3 章 生产系统治理

复盘清单

  • 我是否能列出本次最影响结果的三项遗漏?
  • 我是否把每项遗漏转化为下一次可验证动作?

通用答题速查

以下内容用于补足题卡中的选型表达,不替代题卡的完整作答。

缓存策略

策略适用场景主要代价
缓存旁路通用读多写少需要处理失效窗口和回源
读穿透读密集实现与排障边界更复杂
写穿透对读一致性要求较高写延迟与吞吐受影响
异步回写写密集需面对数据丢失和恢复风险

分布式事务

方案适用判断主要代价
两阶段提交必须同步收敛且参与方可控可用性与性能成本高
TCC资源可预留、可确认和可取消业务实现复杂
Saga长流程可补偿需接受中间状态和补偿设计
本地消息表多数异步副作用需要消费者幂等和对账

限流算法

算法适用判断注意点
计数器简单固定窗口限制边界突刺明显
滑动窗口需要更平滑的统计成本更高
令牌桶允许有限突发需设置补充速率和容量
漏桶需要平滑输出可能增加排队延迟

分库分表

分片键应服务于主要查询和写入均衡,常见选择包括用户标识、订单标识与时间。先确认跨分片查询、扩容、归档和全局唯一标识的成本;规模未到阈值前可保持单库,同时预留分片键和归档策略。

第 39 章 电商系统设计专项题库

本章将原电商架构、商品库存营销计价、交易链路、综合案例和补充答辩材料统一为可独立使用的题卡。所有迁移题卡保留原始来源定位与详细答案材料。

专题答辩资料与技术速查

面试官题库导航

电商专项面试可按候选人的项目背景组合题卡:先用平台边界或商品建模题确认业务语义,再用库存、订单或支付题检查状态与一致性,最后用大促、同步或综合案例题观察恢复能力和长期取舍。60 至 90 分钟的面试通常覆盖一个架构题、一个交易或供给题,以及一个峰值或故障追问。

专题答辩资料:商品、库存、营销与计价综合题回答思路

如果题目要求设计“商品下单前链路”,可以按下面顺序讲:

  1. 商品中心提供商品、SKU、类目和上下架状态。
  2. 库存系统提供可售判断和预占能力。
  3. 营销系统完成优惠试算、活动资格校验和权益锁定。
  4. 计价系统汇总价格、优惠、运费、税费并生成价格明细。
  5. 结算或订单系统保存必要快照,避免后续价格变化影响历史订单。

这个回答顺序能自然体现边界和协作,而不是把所有逻辑堆进订单系统。

专题答辩资料:业务架构图表述模板

面试与评审中的表述模板:「业务架构图不是组织架构图;它描述的是能力边界与语义所有权。若两个团队共改一张宽表,通常意味着限界上下文识别失败或缺少明确的集成契约。」

专题答辩资料:常用 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/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:11

题干与约束

假设你接手一个日订单50万的电商平台,目前是单体应用,团队规模100人。现在需要进行微服务化改造。请设计服务拆分方案。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 假设你接手一个日订单50万的电商平台,目前是单体应用,团队规模100人。现在需要进行微服务化改造。请设计服务拆分方案。

答案

问题分析: 服务拆分的核心挑战在于:

  1. 既要保证领域边界清晰(DDD视角)
  2. 又要考虑团队规模和协作效率
  3. 还要兼顾性能和一致性要求
  4. 需要平衡拆分粒度和运维复杂度

方案一:按业务能力垂直拆分

核心服务划分:

  • 核心交易域:订单、支付、结算、购物车(4个服务)
  • 商品供给域:商品中心、库存、计价、营销(4个服务)
  • 用户导购域:搜索、推荐、用户中心(3个服务)
  • 运营支撑域:商品上架、B端运营、数据分析(3个服务)

拆分原则:

  1. 每个服务对应一个限界上下文(Bounded Context)
  2. 服务间通过稳定的API契约通信
  3. 核心链路服务优先拆分,支撑域可暂时保留

优点:

  • 团队自治性强,可并行开发
  • 业务边界清晰,易于理解和维护
  • 符合DDD最佳实践
  • 故障隔离效果好

缺点:

  • 需要处理分布式事务
  • 服务间调用增加网络开销
  • 初期实施复杂度较高
  • 需要完善的基础设施支持

方案二:按技术特征水平拆分

划分:

  • 高并发读服务:商品详情、搜索、列表(使用缓存和ES)
  • 强一致写服务:订单、支付、库存扣减(使用分布式事务)
  • 异步处理服务:消息通知、数据同步、报表生成

优点:

  • 技术栈统一,便于基础设施复用
  • 性能优化方向明确
  • 团队技能要求更聚焦

缺点:

  • 业务边界模糊,团队协作困难
  • 不符合微服务自治原则
  • 业务变更可能需要跨多个服务修改

方案三:绞杀者模式渐进拆分

实施步骤:

  1. 第一阶段:拆出搜索(读多写少,影响面小)
  2. 第二阶段:拆商品中心(依赖少,数据模型清晰)
  3. 第三阶段:拆订单链路(核心但复杂,需要Saga编排)
  4. 第四阶段:处理遗留单体(逐步清理剩余功能)

优点:

  • 风险可控,可持续交付
  • 团队学习曲线平缓
  • 每个阶段都有明确产出

缺点:

  • 迁移周期较长(可能需要6-12个月)
  • 中间状态维护成本高
  • 需要维护双写逻辑

方案对比

维度方案一(垂直)方案二(水平)方案三(渐进)
团队自治★★★★★★★☆☆☆★★★☆☆
技术复杂度★★★☆☆★★★★☆★★☆☆☆
交付速度★★★☆☆★★★★☆★★★★☆
长期维护★★★★★★★☆☆☆★★★★☆

推荐方案: 采用方案一+方案三的结合:按领域垂直划分目标架构,采用绞杀者模式渐进实施。

实施要点:

  1. 前期准备:梳理系统依赖图,识别核心路径和边界
  2. 基础设施先行:建立服务网格、监控、链路追踪、配置中心
  3. 服务契约规范:定义API标准、版本管理、错误码体系
  4. 数据迁移策略:双写+对账+延迟删除
  5. 灰度发布机制:按用户维度或地域逐步切流量

延伸思考

  1. 如何处理拆分过程中的数据迁移?
  2. 分布式事务如何保证?
  3. 服务间调用的超时和重试策略如何设计?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:11。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-002:如何设计电商系统的数据一致性方案?

元信息

项目内容
题目编号Q-ECOM-ARCH-002
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界分布式一致性
场景标签中大型电商平台架构跨服务协作
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:111

题干与约束

电商系统中,订单创建涉及库存扣减、优惠券核销、积分扣除等多个操作,这些操作分散在不同的微服务中。如何保证数据一致性?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商系统中,订单创建涉及库存扣减、优惠券核销、积分扣除等多个操作,这些操作分散在不同的微服务中。如何保证数据一致性?

答案

问题分析: 数据一致性的核心挑战:

  1. 多个微服务涉及写操作,无法使用传统数据库事务
  2. 部分操作可能失败,需要补偿机制
  3. 性能要求高,不能因为一致性牺牲太多性能
  4. 需要考虑系统可用性,不能因为一个服务故障导致整体不可用

方案一:Saga模式(编排式)

设计思路: 由订单服务作为编排器(Orchestrator),协调各个服务的操作。

流程:

  1. 订单服务:创建订单(状态:PENDING)
  2. 调用库存服务:预占库存(成功继续,失败取消订单)
  3. 调用营销服务:锁定优惠券(成功继续,失败释放库存)
  4. 调用积分服务:扣除积分(成功继续,失败释放库存+券)
  5. 更新订单状态:CONFIRMED

优点:

  • 流程清晰,易于理解和调试
  • 中心化控制,便于监控和排查问题
  • 补偿逻辑集中管理

缺点:

  • 订单服务成为单点,压力较大
  • 流程变更需要修改编排器
  • 服务间耦合度较高

方案二:Saga模式(事件编排式)

设计思路: 通过事件总线(Kafka)进行编排,各服务监听事件并发布新事件。

流程:

  1. 订单服务:创建订单 → 发布 OrderCreated 事件
  2. 库存服务:监听 OrderCreated → 预占库存 → 发布 InventoryReserved 事件
  3. 营销服务:监听 InventoryReserved → 锁定优惠券 → 发布 CouponLocked 事件
  4. 积分服务:监听 CouponLocked → 扣除积分 → 发布 PointsDeducted 事件
  5. 订单服务:监听 PointsDeducted → 更新订单状态为 CONFIRMED

优点:

  • 服务解耦,各服务独立演进
  • 无中心化瓶颈,扩展性好
  • 天然支持异步,性能更好

缺点:

  • 流程分散,难以全局把控
  • 调试困难,需要完善的链路追踪
  • 事件顺序和幂等性要求高

方案三:TCC(Try-Confirm-Cancel)

设计思路: 分为三个阶段:Try(预留)、Confirm(确认)、Cancel(取消)。

流程:

  • Try阶段:预留资源(库存预占、券锁定、积分冻结)
  • Confirm阶段:确认扣减(库存确认、券核销、积分扣除)
  • Cancel阶段:取消操作(释放库存、释放券、解冻积分)

优点:

  • 强一致性,业务语义清晰
  • 资源锁定明确,不会出现超卖
  • 适合对一致性要求极高的场景

缺点:

  • 实现复杂,需要每个服务提供三个接口
  • 性能开销大(锁定资源时间长)
  • Try阶段占用资源,影响并发度

方案对比

维度Saga-编排Saga-事件TCC
实现复杂度★★★☆☆★★★★☆★★★★★
性能★★★☆☆★★★★☆★★☆☆☆
一致性强度最终一致最终一致强一致
可观测性★★★★☆★★★☆☆★★★★☆

推荐方案: 对于电商系统,推荐Saga-编排模式作为主方案,辅以事件通知。

实施要点:

  1. 订单服务作为编排器,维护状态机
  2. 每个操作携带唯一业务ID保证幂等性
  3. 每个步骤设置合理超时(如库存3s,优惠券2s)
  4. 维护补偿表记录需要补偿的操作
  5. 每个步骤记录日志,包含traceId

延伸思考

  1. 如何处理补偿失败的情况?
  2. 最终一致性的“最终“是多久?
  3. 如何进行一致性验证和对账?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:111。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-003:如何设计服务间的调用链路追踪?

元信息

项目内容
题目编号Q-ECOM-ARCH-003
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界可观测性
场景标签中大型电商平台架构故障定位
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:430

题干与约束

微服务架构下,一个用户请求可能经过十几个服务。当出现问题时,如何快速定位是哪个服务出了问题?请设计分布式追踪方案。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 微服务架构下,一个用户请求可能经过十几个服务。当出现问题时,如何快速定位是哪个服务出了问题?请设计分布式追踪方案。

答案

问题分析: 链路追踪的核心挑战:

  1. 如何关联一次请求涉及的所有服务调用
  2. 如何记录调用链路的详细信息(耗时、参数、结果)
  3. 如何在性能开销和可观测性之间平衡
  4. 如何快速检索和分析海量追踪数据

方案一:自研追踪系统

核心思路: 基于唯一TraceID串联整个调用链,每个服务记录SpanID。

设计:

  • TraceID:全局唯一ID,标识一次完整请求
  • SpanID:服务内部的调用单元ID
  • 传递机制:通过HTTP Header或RPC Context传递
  • 数据收集:每个服务将Span数据异步上报
  • 存储分析:存储到Elasticsearch,Kibana可视化

实现步骤:

  1. 网关生成TraceID
  2. 服务间传递TraceID和ParentSpanID
  3. 每个服务记录:服务名、方法名、开始时间、结束时间、状态
  4. 异步上报到追踪系统

优点:

  • 完全可控,可定制
  • 无外部依赖
  • 数据私密性好

缺点:

  • 开发成本高
  • 需要所有服务埋点
  • 维护成本高

方案二:使用开源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. 采样策略:不是所有请求都追踪,按比例采样(如1%)
  2. 性能优化:异步上报,避免阻塞主流程
  3. 标准化:定义统一的TraceID、SpanID规范
  4. 关键节点:重点追踪慢查询、外部调用、错误日志
  5. 告警配置:P99延迟、错误率等关键指标告警

延伸思考

  1. 如何在不影响性能的前提下采集足够的追踪数据?
  2. 如何处理跨语言服务的追踪?
  3. 追踪数据如何与日志、指标关联?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:430。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-004:如何平衡微服务拆分的粒度?

元信息

项目内容
题目编号Q-ECOM-ARCH-004
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界架构演进
场景标签中大型电商平台架构
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:545

题干与约束

在进行微服务拆分时,服务拆得太粗会失去微服务的优势,拆得太细会导致运维复杂度爆炸。如何平衡服务拆分的粒度?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 在进行微服务拆分时,服务拆得太粗会失去微服务的优势,拆得太细会导致运维复杂度爆炸。如何平衡服务拆分的粒度?

答案

问题分析: 服务粒度的核心挑战:

  1. 服务太粗:失去独立部署、技术异构的优势
  2. 服务太细:服务数量多,运维成本高,调用链路长
  3. 边界不清:职责重叠,数据冗余
  4. 团队匹配:服务划分要与团队结构匹配

方案一:按领域模型拆分(DDD)

核心思路: 基于领域驱动设计(DDD)的限界上下文划分服务。

判断标准:

  1. 业务完整性:一个聚合根对应一个服务
  2. 独立演进:服务内部变化不影响其他服务
  3. 团队自治:一个团队(5-9人)负责一个服务
  4. 数据自治:服务拥有独立的数据库

示例:

  • 订单服务:负责订单生命周期管理
  • 库存服务:负责库存预占、扣减、释放
  • 支付服务:负责支付路由、状态管理、对账

优点:

  • 业务边界清晰
  • 符合康威定律
  • 易于理解和维护

缺点:

  • 需要团队理解DDD
  • 前期建模成本高
  • 对业务专家依赖大

方案二:按变化频率拆分

核心思路: 将变化频繁的功能和稳定的功能拆开。

判断标准:

  1. 变化频率:营销活动(周级)vs 用户中心(月级)
  2. 技术栈:搜索(ES)vs 订单(MySQL)
  3. 性能要求:详情页(缓存)vs 下单(强一致)

示例:

  • 营销服务:促销规则频繁变化,独立拆分
  • 计价服务:计算逻辑复杂但相对稳定
  • 用户服务:用户信息变化少,可合并

优点:

  • 快速响应业务变化
  • 技术选型灵活
  • 易于优化性能

缺点:

  • 可能打破业务边界
  • 数据一致性复杂
  • 难以预测变化频率

方案三:按团队规模拆分(两个披萨原则)

核心思路: 一个团队负责的服务数量,要让团队可以“用两个披萨喂饱“(5-9人)。

判断标准:

  1. 团队规模:一个5-9人团队负责2-3个服务
  2. 认知负载:团队能理解和维护的复杂度
  3. 沟通成本:减少跨团队协作

示例:

  • 订单团队:订单服务 + 售后服务 + 物流编排服务
  • 商品团队:商品服务 + 类目服务 + 品牌服务
  • 库存团队:库存服务 + 仓储服务

优点:

  • 团队职责清晰
  • 减少沟通成本
  • 符合组织架构

缺点:

  • 服务粒度可能不均衡
  • 团队变化时需要调整
  • 可能打破业务边界

方案对比

维度DDD拆分变化频率团队规模
业务清晰度★★★★★★★★☆☆★★★☆☆
响应速度★★★☆☆★★★★★★★★★☆
实施难度★★☆☆☆★★★★☆★★★★★
长期维护★★★★★★★★☆☆★★★★☆

推荐方案: 采用DDD为主,兼顾变化频率和团队规模的混合策略。

实施要点:

  1. 核心域优先DDD:订单、支付等核心域严格按DDD拆分
  2. 支撑域灵活拆分:搜索、推荐等可按技术特征拆分
  3. 团队匹配:确保每个团队能hold住负责的服务
  4. 逐步演进:初期粗粒度,随业务发展逐步拆细
  5. 防止过度拆分:服务数量控制在团队规模的2-3倍

判断粒度是否合适的指标:

  • 服务代码量:1-3万行最佳
  • 团队负担:一个团队2-3个服务
  • 调用链路:核心链路不超过5跳
  • 部署频率:每周至少部署1次
  • 故障恢复:单服务故障不影响全局

延伸思考

  1. 如何判断服务拆得太细了?
  2. 已有服务如何合并?
  3. 服务拆分后如何保证数据一致性?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:545。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-005:电商系统的技术债治理策略

元信息

项目内容
题目编号Q-ECOM-ARCH-005
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界架构演进
场景标签中大型电商平台架构
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:668

题干与约束

随着业务快速发展,系统积累了大量技术债(如代码重复、过度耦合、缺少测试)。如何在保证业务持续交付的前提下,有效治理技术债?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 随着业务快速发展,系统积累了大量技术债(如代码重复、过度耦合、缺少测试)。如何在保证业务持续交付的前提下,有效治理技术债?

答案

问题分析: 技术债治理的核心挑战:

  1. 业务压力大,没有时间重构
  2. 技术债范围广,不知从何下手
  3. ROI不明确,难以说服业务
  4. 改造风险高,担心引入新问题

方案一:停止新功能,集中还债

核心思路: 暂停新功能开发1-2个月,团队集中精力重构优化。

实施步骤:

  1. 评估技术债清单(代码质量、架构问题、性能问题)
  2. 按影响面和风险排优先级
  3. 集中2个月时间重构
  4. 重构完成后恢复业务开发

优点:

  • 集中资源,效率高
  • 可以做系统性重构
  • 团队专注度高

缺点:

  • 业务方难以接受(2个月不交付)
  • 改造风险集中爆发
  • 团队压力大

方案二:绞杀式重构,逐步替换

核心思路: 不停止业务开发,在交付新功能时逐步重构。

实施策略:

  1. 新功能新写法:新功能用新架构实现
  2. 改功能顺便重构:修改老功能时顺便重构
  3. 热点优先:优先重构变化频繁的模块
  4. 设置重构配额:每个迭代20%时间用于重构

示例:

  • 迭代1:新功能A(新架构) + 重构模块X
  • 迭代2:新功能B(新架构) + 重构模块Y
  • 迭代3:新功能C(新架构) + 重构模块Z

优点:

  • 业务持续交付
  • 风险分散,可控
  • 团队适应性好

缺点:

  • 周期长(可能需要6-12个月)
  • 需要团队自律
  • 新老代码共存,维护成本高

方案三:分层治理,重点突破

核心思路: 识别高价值技术债,集中资源重点治理。

分层策略:

  1. P0紧急债:影响线上稳定性(立即处理)
    • 例:核心接口性能问题、安全漏洞
  2. P1重要债:影响开发效率(1个月内处理)
    • 例:核心模块耦合严重、缺少测试
  3. P2普通债:代码质量问题(逐步优化)
    • 例:代码重复、命名不规范
  4. P3可忽略:历史遗留问题(不处理)
    • 例:废弃功能的代码

实施步骤:

  1. 扫描技术债(代码扫描工具 + 人工评估)
  2. 分级打标(P0/P1/P2/P3)
  3. P0立即处理,P1排入迭代,P2见缝插针
  4. 每月review技术债清单

优点:

  • 重点突出,ROI高
  • 灵活可控
  • 容易说服业务

缺点:

  • 需要持续跟进
  • 分级标准难以统一
  • P2/P3的债永远还不完

方案对比

维度集中还债绞杀重构分层治理
业务影响★★☆☆☆★★★★★★★★★☆
治理效率★★★★★★★★☆☆★★★★☆
风险控制★★☆☆☆★★★★☆★★★★★
实施难度★★★☆☆★★★★☆★★★★☆

推荐方案: 采用分层治理为主,绞杀重构为辅的组合策略。

实施要点:

  1. 建立技术债看板:可视化展示技术债清单和进度
  2. 设置重构配额:每个迭代15-20%时间用于技术债
  3. 重构优先级:P0立即处理,P1必须进迭代,P2机动
  4. 度量指标:代码覆盖率、圈复杂度、重复率、Bug密度
  5. 自动化检测:SonarQube扫描,PR卡点
  6. 技术债评审会:每月评审技术债治理进展

关键原则:

  • 不积累新债:新代码必须符合质量标准
  • 热点优先:优先重构变化频繁的模块
  • 小步快跑:每次重构范围可控,及时验证
  • 有始有终:重构必须完整,不能半途而废
  • 文档同步:重构后及时更新架构文档

延伸思考

  1. 如何评估技术债的严重程度和优先级?
  2. 重构过程中如何保证不引入新问题?
  3. 如何说服业务投入资源治理技术债?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:668。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-006:如何设计配置中心?

元信息

项目内容
题目编号Q-ECOM-ARCH-006
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界架构演进
场景标签中大型电商平台架构
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:794

题干与约束

微服务架构下,配置分散在各个服务中,修改配置需要重启服务。请设计一个配置中心,支持配置的集中管理和动态更新。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 微服务架构下,配置分散在各个服务中,修改配置需要重启服务。请设计一个配置中心,支持配置的集中管理和动态更新。

答案

问题分析: 配置中心的核心挑战:

  1. 配置变更如何实时推送到服务
  2. 如何保证配置的一致性和可靠性
  3. 如何支持灰度发布和快速回滚
  4. 如何保证配置的安全性(敏感信息加密)

方案一:基于文件+定时拉取

核心思路: 配置存储在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作为配置中心

实施要点:

  1. 配置分层

    • 环境配置(dev/test/prod)
    • 公共配置(数据库连接池、日志级别)
    • 应用配置(业务参数)
    • 敏感配置(密码、密钥)加密存储
  2. 命名规范

    • dataId:${应用名}-${环境}.${格式}
    • group:${业务域}
    • 例:order-service-prod.yaml,group:transaction
  3. 配置刷新

    • 使用 @RefreshScope 注解
    • 或实现 ConfigChangeListener 接口
    • 敏感配置(数据库连接)不支持热更新
  4. 灰度发布

    • 先在灰度环境验证
    • 按IP或比例逐步推送
    • 监控关键指标,出问题立即回滚
  5. 安全控制

    • 敏感配置加密存储(AES/RSA)
    • RBAC权限管理(谁能改什么配置)
    • 审计日志(谁在什么时候改了什么)
    • 配置变更必须经过审批流程
  6. 高可用

    • Nacos集群部署(至少3节点)
    • 客户端本地缓存(Nacos不可用时降级)
    • 配置备份(定期导出到Git)

延伸思考

  1. 配置中心本身如何保证高可用?
  2. 敏感配置如何加密存储?
  3. 如何保证配置变更的安全性(防止误操作)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:794。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-007:领域驱动设计在电商系统中的应用

元信息

项目内容
题目编号Q-ECOM-ARCH-007
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界架构演进
场景标签中大型电商平台架构
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:933

题干与约束

你需要用DDD的思想设计电商订单系统。请描述如何识别聚合根、实体、值对象,以及如何划分限界上下文。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 你需要用DDD的思想设计电商订单系统。请描述如何识别聚合根、实体、值对象,以及如何划分限界上下文。

答案

问题分析: DDD在电商中的核心挑战:

  1. 如何识别领域边界(限界上下文)
  2. 如何设计聚合根和实体关系
  3. 如何处理跨聚合的数据一致性
  4. 如何平衡领域纯粹性和工程实践

方案一:贫血模型+服务层

核心思路: 实体只包含数据,业务逻辑在服务层。

设计:

实体:
- 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复杂业务高性能+审计

推荐方案: 对于电商订单系统,推荐充血模型+聚合根

实施要点:

  1. 识别限界上下文

    • 订单上下文:订单生命周期、订单状态管理
    • 库存上下文:库存预占、扣减、释放
    • 支付上下文:支付路由、支付状态、对账
    • 物流上下文:物流单、轨迹跟踪
  2. 设计聚合根

    • Order(订单聚合根)
      • 实体:OrderItem
      • 值对象:Address、Money
      • 行为:create、pay、ship、cancel
      • 不变量:订单金额一致性、状态流转规则
  3. 跨聚合协作

    • 强一致性:同一聚合内(订单和订单项)
    • 最终一致性:跨聚合(订单和库存)
    • 集成方式:领域事件 + Saga编排
  4. 代码组织

order/
├── domain/           # 领域层
│   ├── Order.java    # 聚合根
│   ├── OrderItem.java # 实体
│   ├── Address.java   # 值对象
│   └── OrderStatus.java # 枚举
├── application/      # 应用层
│   └── OrderService.java # 应用服务(编排)
├── infrastructure/   # 基础设施层
│   ├── OrderRepository.java # 仓储接口
│   └── OrderRepositoryImpl.java # 仓储实现
└── api/             # 接口层
    └── OrderController.java # REST API
  1. 关键设计决策
    • 聚合边界:Order包含OrderItem,不包含Product(属于商品上下文)
    • ID生成:聚合根负责生成ID(UUID或雪花ID)
    • 领域事件:订单状态变更发布事件(OrderPaid、OrderShipped)
    • 仓储模式:通过Repository加载/保存聚合根
    • 工厂模式:复杂的聚合创建用工厂

延伸思考

  1. 如何处理跨聚合根的事务(如订单和库存)?
  2. 充血模型如何映射到数据库(JPA/MyBatis)?
  3. DDD在微服务中如何落地(一个聚合根一个服务?)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:933。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-008:设计订单与库存的一致性保证方案

元信息

项目内容
题目编号Q-ECOM-ARCH-008
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界分布式一致性
场景标签中大型电商平台架构跨服务协作
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1268

题干与约束

在订单创建流程中,需要同时扣减库存。订单服务和库存服务是两个独立的微服务,如何保证订单创建和库存扣减的一致性?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 在订单创建流程中,需要同时扣减库存。订单服务和库存服务是两个独立的微服务,如何保证订单创建和库存扣减的一致性?

答案

问题分析: 订单库存一致性的核心挑战:

  1. 不能使用分布式事务(性能差、可用性低)
  2. 需要防止库存超卖
  3. 订单创建失败时库存要回滚
  4. 用户取消订单时库存要释放

方案一:订单服务调用库存服务(同步)

核心思路: 订单创建时同步调用库存服务扣减库存,失败则取消订单。

流程:

  1. 订单服务:创建订单(状态:PENDING)
  2. 同步调用库存服务:扣减库存
    • 成功:订单状态更新为CONFIRMED
    • 失败:订单状态更新为CANCELLED
  3. 返回结果给用户

优点:

  • 实现简单直观
  • 实时一致性
  • 用户立即知道结果

缺点:

  • 同步调用性能差
  • 库存服务故障影响订单创建
  • 网络超时难处理(已扣减但订单不知道)

方案二:本地消息表+最终一致性

核心思路: 订单创建后发送消息给库存服务,库存服务异步扣减。

流程:

  1. 订单服务:
    • 开启事务
    • 插入订单(状态:PENDING)
    • 插入本地消息表(OrderCreated事件)
    • 提交事务
  2. 消息发送器:定时扫描本地消息表,发送到MQ
  3. 库存服务:监听MQ,扣减库存
  4. 库存服务:发送库存扣减成功事件
  5. 订单服务:监听事件,更新订单状态为CONFIRMED

优点:

  • 异步解耦,性能好
  • 库存服务故障不影响订单创建
  • 消息可靠性高(本地消息表)

缺点:

  • 最终一致性(用户不能立即知道结果)
  • 实现复杂
  • 需要处理消息重复

方案三:库存预占+两阶段提交

核心思路: 订单创建时先预占库存,支付成功后确认扣减,取消时释放。

流程:

  1. 订单服务:创建订单(状态:PENDING)
  2. 同步调用库存服务:预占库存(库存减少,预占量增加)
  3. 用户支付成功后:
    • 订单状态更新为PAID
    • 异步通知库存服务确认扣减(预占量减少)
  4. 用户取消订单:
    • 订单状态更新为CANCELLED
    • 异步通知库存服务释放预占(库存增加,预占量减少)

优点:

  • 防止超卖(预占时检查库存)
  • 用户体验好(立即知道有没有库存)
  • 支持取消释放

缺点:

  • 库存设计复杂(实际库存、预占库存、可售库存)
  • 预占超时释放机制
  • 长时间预占影响其他用户

方案对比

维度同步调用本地消息表库存预占
一致性强一致最终一致强一致
性能★★☆☆☆★★★★★★★★★☆
用户体验★★★★★★★☆☆☆★★★★★
实施难度★★★★☆★★☆☆☆★★★☆☆

推荐方案: 对于电商系统,推荐库存预占方案

实施要点:

  1. 库存表设计:
    • total_stock:总库存
    • reserved_stock:预占库存
    • available_stock = total_stock - reserved_stock
  2. 预占接口:先检查available_stock,再扣减
  3. 预占超时:定时任务扫描超时订单,释放预占
  4. 幂等保证:使用orderId+action作为唯一键
  5. 监控告警:监控预占释放率、超时订单量

延伸思考

  1. 如果库存预占接口超时,订单服务如何处理?
  2. 预占超时时间如何设定(太短影响支付,太长占用库存)?
  3. 秒杀场景下如何优化库存扣减性能?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1268。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-009:Saga模式在电商系统中的应用

元信息

项目内容
题目编号Q-ECOM-ARCH-009
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度进阶
建议用时45 分钟
能力标签领域建模服务边界分布式一致性
场景标签中大型电商平台架构跨服务协作
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1384

题干与约束

电商订单创建涉及多个服务(库存、优惠券、积分、支付),如何使用Saga模式保证分布式事务的一致性?请详细设计Saga编排方案。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商订单创建涉及多个服务(库存、优惠券、积分、支付),如何使用Saga模式保证分布式事务的一致性?请详细设计Saga编排方案。

答案

问题分析: Saga在电商中的核心挑战:

  1. 如何设计补偿逻辑
  2. 如何处理部分失败
  3. 如何保证幂等性
  4. 如何监控和排查问题

方案一:编排式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

实施要点:

  1. 状态机设计

    状态表: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: 错误信息
    
  2. 幂等性保证

    • 每个步骤携带saga_id作为业务唯一键
    • 服务侧记录已处理的saga_id
    • 重复请求直接返回成功
  3. 超时处理

    • 每个步骤设置超时时间(如3秒)
    • 超时后执行补偿
    • 定时任务兜底扫描长时间未完成的Saga
  4. 补偿策略

    • 向前补偿:重试直到成功
    • 向后补偿:回滚已完成的步骤
    • 混合补偿:核心步骤向前,非核心向后
  5. 监控告警

    • Saga成功率
    • 平均执行时间
    • 补偿执行次数
    • 长时间未完成的Saga

延伸思考

  1. 如果补偿操作也失败了怎么办?
  2. Saga执行过程中服务重启如何恢复?
  3. 如何设计Saga的测试策略?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1384。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-010:如何选择同步调用vs异步消息?

元信息

项目内容
题目编号Q-ECOM-ARCH-010
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界事件驱动
场景标签中大型电商平台架构异步集成
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1569

题干与约束

在微服务架构中,服务间通信可以使用同步RPC或异步消息队列。在电商系统中,如何选择合适的通信方式?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 在微服务架构中,服务间通信可以使用同步RPC或异步消息队列。在电商系统中,如何选择合适的通信方式?

答案

问题分析: 同步vs异步的核心考量:

  1. 业务语义:是否需要立即返回结果
  2. 性能要求:延迟vs吞吐量
  3. 可靠性:是否允许消息丢失
  4. 复杂度:实现和运维成本

方案一:全部使用同步RPC

适用场景:

  • 查询操作(查询订单详情、商品信息)
  • 强实时性要求(下单时检查库存)
  • 需要立即返回结果(用户等待响应)

优点:

  • 实现简单
  • 调用链路清晰
  • 易于调试

缺点:

  • 性能瓶颈(串行调用)
  • 可用性差(下游故障影响上游)
  • 难以削峰

方案二:全部使用异步消息

适用场景:

  • 通知类操作(发送短信、邮件)
  • 可延迟处理(数据同步、报表生成)
  • 需要削峰填谷(秒杀、大促)

优点:

  • 解耦(服务独立)
  • 削峰(消息堆积)
  • 高吞吐

缺点:

  • 最终一致性
  • 消息丢失风险
  • 调试困难

方案三:混合使用(推荐)

决策矩阵:

场景通信方式理由
查询商品详情同步RPC需要立即返回
下单扣减库存同步RPC需要立即知道结果
订单支付成功→通知物流异步消息不需要立即处理
订单支付成功→发送短信异步消息允许延迟
商品信息变更→更新搜索异步消息最终一致即可
计算订单金额同步RPC需要立即返回金额
订单创建→更新统计报表异步消息非实时

决策原则:

  1. 用户在等待:使用同步(如下单、支付、查询)
  2. 用户不在等待:使用异步(如通知、数据同步)
  3. 强一致性:使用同步(如扣款、扣库存)
  4. 最终一致性:使用异步(如积分、优惠券)
  5. 高并发:优先异步(如秒杀、大促)

优点:

  • 平衡性能和一致性
  • 灵活应对不同场景
  • 整体架构合理

缺点:

  • 需要维护两套通信机制
  • 团队需要理解选择原则

方案对比

维度全同步全异步混合
实时性★★★★★★★☆☆☆★★★★☆
吞吐量★★☆☆☆★★★★★★★★★☆
可用性★★☆☆☆★★★★★★★★★☆
复杂度★★★★☆★★☆☆☆★★★☆☆

推荐方案: 采用混合模式,根据场景选择合适的通信方式。

实施要点:

  1. 同步调用优化

    • 设置合理超时(如3秒)
    • 使用断路器防止雪崩
    • 重要接口设置重试机制
    • 监控调用成功率和延迟
  2. 异步消息优化

    • 使用本地消息表保证可靠性
    • 消费端幂等处理
    • 死信队列处理失败消息
    • 监控消息积压和消费延迟
  3. 场景识别技巧

    • 问:用户是否在等待结果?
    • 问:失败了是否需要立即知道?
    • 问:是否需要强一致性?
    • 问:并发量有多大?
  4. 混合调用模式

    示例:订单支付成功后
    
    同步:
    - 更新订单状态(用户需要立即看到)
    - 扣减库存(强一致性)
    
    异步:
    - 通知物流(可延迟)
    - 发送短信(可延迟)
    - 更新报表(可延迟)
    - 赠送积分(最终一致)
    
  5. 降级策略

    • 异步消息:队列满时拒绝接入
    • 同步调用:超时降级(返回默认值或缓存)

延伸思考

  1. 如何处理异步消息丢失的情况?
  2. 同步调用超时后如何判断是否成功?
  3. 如何在同步和异步之间切换(如异步改同步)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1569。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-011:分布式事务的几种实现方案对比

元信息

项目内容
题目编号Q-ECOM-ARCH-011
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度进阶
建议用时45 分钟
能力标签领域建模服务边界分布式一致性
场景标签中大型电商平台架构跨服务协作
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1704

题干与约束

请对比2PC、TCC、Saga、本地消息表这几种分布式事务方案,并说明在电商系统中各自的适用场景。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 请对比2PC、TCC、Saga、本地消息表这几种分布式事务方案,并说明在电商系统中各自的适用场景。

答案

问题分析: 分布式事务方案的核心差异:

  1. 一致性强度(强一致 vs 最终一致)
  2. 性能开销(锁定时间、网络开销)
  3. 实现复杂度(接口数量、补偿逻辑)
  4. 适用场景(金融 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)

延伸思考

  1. 为什么电商系统很少用2PC?
  2. TCC的Try阶段如何设计才能保证性能?
  3. Saga的补偿操作如何保证一定成功?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1704。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-012:设计事件驱动架构

元信息

项目内容
题目编号Q-ECOM-ARCH-012
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界事件驱动
场景标签中大型电商平台架构异步集成
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1886

题干与约束

电商系统需要实现事件驱动架构,当订单状态变更时,自动触发物流、消息通知、数据统计等下游操作。请设计事件驱动方案。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商系统需要实现事件驱动架构,当订单状态变更时,自动触发物流、消息通知、数据统计等下游操作。请设计事件驱动方案。

答案

问题分析: 事件驱动架构的核心挑战:

  1. 如何设计领域事件
  2. 如何保证事件不丢失
  3. 如何处理事件顺序性
  4. 如何避免事件风暴

方案一:基于数据库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的系统
  • 需要强可靠性的场景

方案对比

维度CDCOutbox事务消息
事件语义★★☆☆☆★★★★★★★★★☆
可靠性★★★★★★★★★★★★★★★
侵入性★★★★★★★★☆☆★★★☆☆
实施难度★★★★☆★★★☆☆★★★★☆

推荐方案: 对于电商系统,推荐Outbox模式

实施要点:

  1. 事件设计原则

    • 事件名称:过去时(OrderPaid、OrderShipped)
    • 包含完整上下文(避免下游再查询)
    • 不可变(发出后不能修改)
    • 幂等性(包含event_id)
  2. 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
    );
    
  3. 事件发布器优化

    • 批量读取(每次100条)
    • 批量发送(提高吞吐)
    • 失败重试(指数退避)
    • 定期清理已发送事件(保留7天)
  4. 消费端幂等

    消费端维护已处理事件表:
    CREATE TABLE processed_events (
      event_id VARCHAR(64) PRIMARY KEY,
      processed_at TIMESTAMP
    );
    
    处理逻辑:
    1. 检查event_id是否已处理
    2. 如果已处理,直接返回
    3. 执行业务逻辑
    4. 记录event_id到processed_events
    
  5. 监控告警

    • Outbox待发送事件数
    • 事件发送失败率
    • 消费延迟

延伸思考

  1. 如何保证事件的顺序性(同一订单的多个事件)?
  2. 如何处理事件风暴(大量事件同时发布)?
  3. 事件版本如何管理(EventV1、EventV2)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1886。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-013:如何保证消息的可靠投递?

元信息

项目内容
题目编号Q-ECOM-ARCH-013
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界分布式一致性
场景标签中大型电商平台架构跨服务协作
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:2102

题干与约束

在消息队列(Kafka/RocketMQ)中,如何保证消息从生产者到消费者的可靠投递,不丢失、不重复、不乱序?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 在消息队列(Kafka/RocketMQ)中,如何保证消息从生产者到消费者的可靠投递,不丢失、不重复、不乱序?

答案

问题分析: 消息可靠性的三个维度:

  1. 生产端:如何保证消息发送成功
  2. 存储端:如何保证消息不丢失
  3. 消费端:如何保证消息处理成功

方案一: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 + 业务幂等

实施要点:

  1. 生产端可靠性

    // 同步发送(等待确认)
    producer.send(record).get(3, TimeUnit.SECONDS)
    
    // 配置
    acks=all              // 所有副本确认
    retries=3            // 重试3次
    max.in.flight.requests.per.connection=1  // 保证顺序
    
  2. MQ端可靠性

    Kafka配置:
    - replication.factor=3     // 3副本
    - min.insync.replicas=2    // 至少2个副本确认
    - unclean.leader.election.enable=false  // 禁止非ISR副本成为leader
    
    RocketMQ配置:
    - flushDiskType=SYNC_FLUSH  // 同步刷盘
    
  3. 消费端可靠性

    // 手动提交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)
      }
    }
    
  4. 幂等性设计

    方案1:基于唯一键
    - 消息包含messageId
    - 消费端用messageId去重(Redis/DB)
    
    方案2:基于业务唯一键
    - 订单号、支付流水号等
    - 数据库唯一索引约束
    
    方案3:基于版本号
    - 数据包含版本号
    - 更新时检查版本号(乐观锁)
    
  5. 顺序性保证

    发送端:
    - 同一订单的消息发到同一分区(按orderId hash)
    - max.in.flight.requests=1(保证分区内有序)
    
    消费端:
    - 单线程消费同一分区
    - 或使用版本号检查顺序
    

延伸思考

  1. 如果消费失败,消息应该重试几次?
  2. 如何处理消息积压问题?
  3. 如何实现消息的延迟投递?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:2102。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-014:幂等性设计的最佳实践

元信息

项目内容
题目编号Q-ECOM-ARCH-014
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界分布式一致性
场景标签中大型电商平台架构跨服务协作
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:2336

题干与约束

在分布式系统中,由于网络重试、消息重复等原因,同一个请求可能被处理多次。如何设计幂等机制,保证重复请求不会产生副作用?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 在分布式系统中,由于网络重试、消息重复等原因,同一个请求可能被处理多次。如何设计幂等机制,保证重复请求不会产生副作用?

答案

问题分析: 幂等性的核心挑战:

  1. 如何识别重复请求
  2. 如何防止并发重复处理
  3. 如何设计幂等键
  4. 幂等状态如何存储和清理

方案一:基于唯一请求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

实施要点:

  1. 幂等键设计原则

    • 唯一性:能唯一标识一次操作
    • 稳定性:多次请求幂等键相同
    • 业务相关:优先用业务键(orderId)而非技术键(requestId)
  2. 幂等粒度

    粗粒度(操作级):
    - 幂等键:orderId
    - 含义:同一订单不能重复创建
    
    细粒度(步骤级):
    - 幂等键:orderId + operationType(如 "order123:pay")
    - 含义:同一订单的支付操作不能重复
    
  3. 幂等状态清理

    Redis存储:
    - 设置过期时间(24小时)
    - 定期清理过期数据
    
    数据库存储:
    - 不需要清理(利用业务唯一键)
    
  4. 并发安全

    分布式锁:
    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=?
    
  5. 幂等测试

    测试用例:
    1. 相同参数调用2次,验证第2次返回相同结果
    2. 并发调用2次,验证只有1次生效
    3. 延迟重试,验证中间状态不影响幂等性
    

延伸思考

  1. 如何设计支付接口的幂等性?
  2. 消息队列消费端如何保证幂等性?
  3. 幂等状态如何跨服务共享?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:2336。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-015:设计数据最终一致性的对账补偿机制

元信息

项目内容
题目编号Q-ECOM-ARCH-015
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界分布式一致性
场景标签中大型电商平台架构跨服务协作
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:2574

题干与约束

在分布式系统中,虽然采用了Saga等最终一致性方案,但仍可能因为网络、重试等原因导致数据不一致。如何设计对账补偿机制?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 在分布式系统中,虽然采用了Saga等最终一致性方案,但仍可能因为网络、重试等原因导致数据不一致。如何设计对账补偿机制?

答案

问题分析: 对账补偿的核心挑战:

  1. 如何发现数据不一致
  2. 如何定位不一致的根本原因
  3. 如何自动补偿修复
  4. 如何避免补偿引入新问题

方案一:实时对账

核心思想: 在关键操作后立即对账,发现问题立即修复。

设计:

订单支付成功后:
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. 对账维度设计

    维度1:订单-支付对账
    - 订单状态=PAID ⇔ 存在成功的支付记录
    - 订单金额 = 支付金额
    
    维度2:订单-库存对账
    - 订单商品 ⇔ 库存扣减记录
    - 订单数量 = 库存扣减数量
    
    维度3:订单-物流对账
    - 订单状态=SHIPPED ⇔ 存在物流单
    
    维度4:财务对账
    - 订单收入 = 支付收入 - 退款
    
  2. 补偿策略

    自动补偿(低风险):
    - 订单已支付,库存未扣减 → 自动扣减库存
    - 订单已取消,库存未释放 → 自动释放库存
    
    人工介入(高风险):
    - 订单金额与支付金额不一致 → 人工审核
    - 库存为负数 → 人工调整
    - 重复支付 → 人工退款
    
  3. 对账任务调度

    实时对账:
    - 支付成功后:立即对账订单-支付
    
    分钟级对账:
    - 每5分钟:对账最近10分钟的订单
    
    小时级对账:
    - 每小时:对账最近2小时的订单
    
    日级对账:
    - 每天凌晨:对账前一天所有订单
    - 生成对账报表
    
  4. 补偿幂等性

    补偿操作必须幂等:
    - 记录补偿历史(避免重复补偿)
    - 使用业务唯一键
    - 状态机保证(只能从错误状态补偿到正确状态)
    
  5. 监控告警

    指标:
    - 对账差异数量
    - 自动补偿成功率
    - 人工处理待办数量
    
    告警:
    - 差异数量超过阈值
    - 自动补偿失败
    - 关键差异(金额不一致)
    

延伸思考

  1. 如果对账也失败了(如查询超时),如何处理?
  2. 补偿操作失败后如何处理?
  3. 如何设计对账结果的可视化展示?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:2574。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-016:如何处理分布式系统的时钟问题?

元信息

项目内容
题目编号Q-ECOM-ARCH-016
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界架构演进
场景标签中大型电商平台架构
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:2819

题干与约束

在分布式系统中,不同服务器的时钟可能不同步,导致时间戳不一致、时序错误等问题。如何处理分布式系统的时钟问题?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 在分布式系统中,不同服务器的时钟可能不同步,导致时间戳不一致、时序错误等问题。如何处理分布式系统的时钟问题?

答案

问题分析: 分布式时钟的核心挑战:

  1. 时钟漂移:不同机器时钟不一致
  2. 时序依赖:如何判断事件先后顺序
  3. 超时判断:如何准确计算超时
  4. 数据时效:如何判断数据是否过期

方案一:使用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同步 + 避免依赖绝对时间

实施要点:

  1. NTP基础设施

    - 部署内网NTP服务器
    - 所有应用服务器同步内网NTP
    - 监控时钟偏移(超过100ms告警)
    - 禁止手动修改系统时间
    
  2. 避免依赖绝对时间

    错误示例:
    // 判断订单是否超时(依赖绝对时间)
    if (now() - order.createdAt > 30分钟) {
      cancelOrder()
    }
    问题:如果时钟回拨,可能误判
    
    正确示例:
    // 使用相对时间或逻辑状态
    if (order.status == PENDING && order.createdAt < now() - 30分钟) {
      // 且使用定时任务扫描(而非实时判断)
      cancelOrder()
    }
    
  3. 处理时钟回拨

    生成ID时(如雪花ID):
    if (currentTime < lastTime) {
      // 时钟回拨,等待追上
      wait(lastTime - currentTime)
    }
    
    或使用单调递增ID:
    // 不依赖时间,只保证递增
    nextId = atomicIncrement()
    
  4. 使用逻辑版本号

    数据表:
    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. 时间窗口设计

    对账任务:
    // 不对账"最近5分钟"的数据(避免时钟误差)
    SELECT * FROM orders
    WHERE created_at < NOW() - INTERVAL 5 MINUTE
      AND created_at >= NOW() - INTERVAL 1 HOUR
    

延伸思考

  1. 如何设计分布式系统的全局唯一ID(考虑时钟回拨)?
  2. 如何在不同时区的数据中心部署系统?
  3. 时钟跳跃(突然快进)如何处理?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:2819。本章不依赖旧 Part Four 文件链接。

相关章节:电商系统全景图

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-ARCH-017:微服务间的版本兼容性设计

元信息

项目内容
题目编号Q-ECOM-ARCH-017
题型系统设计题
范围平台级:服务边界、集成与架构演进
难度中等
建议用时30 分钟
能力标签领域建模服务边界架构演进
场景标签中大型电商平台架构
能力域平台架构、服务边界与跨系统一致性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:3032

题干与约束

微服务架构下,服务A调用服务B的接口。当服务B升级时,如何保证向后兼容,不影响服务A?请设计API版本管理方案。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 微服务架构下,服务A调用服务B的接口。当服务B升级时,如何保证向后兼容,不影响服务A?请设计API版本管理方案。

答案

问题分析: API版本兼容的核心挑战:

  1. 如何在不影响老版本的情况下升级
  2. 如何管理多个版本的共存
  3. 如何平滑下线老版本
  4. 如何处理数据结构变更

方案一: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版本控制

实施要点:

  1. API设计规范

    必须遵守:
    - 新增字段必须可选
    - 新增字段必须有默认值
    - 不删除现有字段
    - 不修改字段类型
    - 不修改字段语义
    
    字段弃用流程:
    1. 标记@Deprecated,文档说明
    2. 观察调用量,确认无人使用
    3. 至少保留3个月
    4. 下个大版本时删除
    
  2. Protobuf向后兼容

    message Order {
      string order_id = 1;
      double amount = 2;
    
      // V2新增字段
      double discount_amount = 3;
      string payment_method = 4;
    
      // 不要重用字段编号!
      // reserved 5; // 如果删除字段5,标记为reserved
    }
    
  3. 版本协商机制

    客户端声明支持的版本:
    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); // 降级返回
    }
    
  4. 版本监控

    监控指标:
    - 各版本API调用量
    - 废弃API的调用量
    - 客户端版本分布
    
    告警:
    - 废弃API仍有调用
    - 客户端版本过低
    
  5. 契约测试

    测试用例:
    1. V1客户端调用V2服务(向后兼容)
    2. V2客户端调用V1服务(新字段有默认值)
    3. 并发调用不同版本
    4. 字段缺失时的降级处理
    

延伸思考

  1. 如何处理数据库表结构的版本兼容?
  2. 如何平滑下线一个旧版本API?
  3. gRPC/Protobuf的版本兼容如何保证?



评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/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/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:19

题干与约束

电商平台需要支持实物商品(服装、3C)、虚拟商品(充值卡、会员)、服务类商品(保险、课程)。如何设计一个统一且可扩展的商品数据模型?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台需要支持实物商品(服装、3C)、虚拟商品(充值卡、会员)、服务类商品(保险、课程)。如何设计一个统一且可扩展的商品数据模型?

答案

问题分析: 多品类商品模型的核心挑战:

  1. 不同品类属性差异巨大(服装有尺码颜色,充值卡有卡密)
  2. 需要支持灵活的属性扩展,避免频繁加字段
  3. 查询性能要求高(详情页、列表页高并发)
  4. 需要支持类目体系和属性继承

方案一: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字段查询能力有限
  • 需要应用层解析和校验
  • 不同数据库支持程度不同

方案三:混合模式(推荐)

核心设计:

  1. 主表存储通用字段:product_core(id, spu, name, category, status)
  2. 类目模板定义属性规范:attribute_meta(属性元数据、类型、校验规则)
  3. 分层存储
    • product_common_attr:高频查询字段(品牌、价格区间)
    • product_ext_attr:JSONB,低频字段
    • product_spec:SKU规格,单独表
  4. 搜索侧异步构建宽表:ES文档包含所有筛选字段

数据流:

  • 写入:商品创建 → 按模板校验 → 分表存储 → 事件发布 → ES同步
  • 读取详情页:主表+扩展表(缓存)
  • 读取列表页:直接查ES
  • 后台管理:全量字段(可接受慢查询)

优点:

  • 扩展性强
  • 查询性能好
  • 支持复杂筛选(通过ES)
  • 核心字段有索引

缺点:

  • 架构复杂度中等
  • 需要维护ES同步
  • 最终一致性

方案对比

维度EAV宽表+JSON混合模式
扩展性★★★★★★★★★☆★★★★★
查询性能★★☆☆☆★★★★☆★★★★★
开发复杂度★★★☆☆★★★★☆★★★☆☆
类型安全★★☆☆☆★★★☆☆★★★★☆

推荐方案: 采用混合模式

实施要点:

  1. 核心字段晋升机制:高频查询字段从JSON移到固定列
  2. JSONB索引:PostgreSQL建立GIN索引
  3. ES映射模板:自动从类目模板生成
  4. 缓存策略:L1进程内 + L2 Redis,TTL分层设置
  5. 属性校验:类目模板定义规则,运行时校验

虚拟商品特殊处理:

  • 充值卡:卡密存储加密、核销记录独立表
  • 会员服务:有效期、权益包用JSON存储
  • 服务类:预约时间、服务人员信息扩展字段

延伸思考

  1. 如何处理类目属性变更(模板升级)?
  2. 历史订单中的商品快照如何存储?
  3. 跨类目搜索时如何统一属性映射?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:19。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-002:商品详情页的缓存架构设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-002
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:157

题干与约束

商品详情页是电商系统访问量最大的页面,QPS可达百万级。请设计商品详情页的缓存架构,保证高性能和数据一致性。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 商品详情页是电商系统访问量最大的页面,QPS可达百万级。请设计商品详情页的缓存架构,保证高性能和数据一致性。

答案

问题分析: 详情页缓存的核心挑战:

  1. 流量巨大,需要多级缓存
  2. 数据来源多(商品、价格、库存、营销),聚合复杂
  3. 数据更新频繁,缓存一致性难保证
  4. 热点商品流量集中

方案一:纯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多级缓存缓存+预热
性能★★★★★★★★★☆★★★★★
实时性★★☆☆☆★★★★☆★★★★☆
个性化★☆☆☆☆★★★★★★★★★★
复杂度★★★★★★★★☆☆★★☆☆☆

推荐方案: 采用多级缓存+热点预热的组合。

实施要点:

  1. 缓存分层

    L1(本地缓存):
    - 容量:1000条
    - TTL:30秒
    - 淘汰策略:LRU
    - 适用:超热门商品(TOP 100)
    
    L2(Redis):
    - 容量:100万条
    - TTL:5分钟
    - 集群部署:主从+哨兵
    - 适用:热门+普通商品
    
    L3(数据库):
    - 全量数据
    - 读写分离
    
  2. 缓存键设计

    方案1:不带版本号
    Key: product:detail:{productId}
    Value: JSON
    更新:商品变更时主动删除key
    
    方案2:带版本号(推荐)
    Key: product:detail:{productId}:{version}
    Value: JSON
    更新:版本号+1,旧key自然过期
    
  3. 缓存更新策略

    Cache Aside模式:
    1. 读取:先查缓存,未命中再查DB,写入缓存
    2. 更新:先更新DB,再删除缓存
    
    Write Through模式:
    1. 更新:同时更新DB和缓存
    2. 读取:直接读缓存
    
  4. 热点治理

    识别热点:
    - 实时统计访问频率(滑动窗口)
    - 超过阈值(如10000 QPS)标记为热点
    
    热点处理:
    - 本地缓存延长TTL(30秒 → 5分钟)
    - Redis分片存储(product:123:1, product:123:2...)
    - 限流保护(单商品限流)
    
  5. 缓存穿透/击穿/雪崩

    穿透(查询不存在的数据):
    - 布隆过滤器预判
    - 空值缓存(TTL短,如1分钟)
    
    击穿(热点key过期):
    - 互斥锁(只有一个请求回源)
    - 热点key永不过期(后台异步更新)
    
    雪崩(大量key同时过期):
    - TTL加随机值(5分钟±30秒)
    - 缓存预热
    - 降级方案(返回旧数据)
    

延伸思考

  1. 缓存和数据库数据不一致如何处理?
  2. 如何设计缓存的监控指标?
  3. 大促时如何做缓存容量规划?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:157。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-003:如何解决商品信息变更后搜索不一致问题?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-003
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:391

题干与约束

运营修改了商品标题和价格,但搜索结果中仍然显示旧信息。这是典型的最终一致性问题。如何设计商品到搜索的数据同步方案?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 运营修改了商品标题和价格,但搜索结果中仍然显示旧信息。这是典型的最终一致性问题。如何设计商品到搜索的数据同步方案?

答案

问题分析: 商品搜索一致性的核心挑战:

  1. 数据变更频繁(价格调整、库存变化)
  2. 搜索索引构建有延迟
  3. 用户期望实时看到最新信息
  4. 大量商品同步对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增量同步
   - 作为对账的补充

优点:

  • 接近实时
  • 有补偿机制

缺点:

  • 双写失败处理复杂
  • 两个数据源可能不一致
  • 实现复杂

方案对比

维度实时同步异步同步双写+对账
实时性★★★★★★★★★☆★★★★☆
系统解耦★★☆☆☆★★★★★★★★☆☆
一致性保证★★★★☆★★★★★★★★★★
实施难度★★★★☆★★★☆☆★★☆☆☆

推荐方案: 采用异步同步+对账

实施要点:

  1. 事件设计

    ProductCreated:商品创建
    ProductUpdated:商品信息变更(title、desc、images)
    ProductPriceChanged:价格变更
    ProductStatusChanged:上下架
    ProductDeleted:删除
    
  2. 同步Worker设计

    消费逻辑:
    1. 从Kafka消费ProductUpdated事件
    2. 根据productId查询完整商品信息
    3. 构建ES文档
    4. 批量更新ES(bulk API,提高吞吐)
    5. 提交offset
    
    批量优化:
    - 攒批:100条或1秒批量提交
    - 去重:同一商品多次变更只保留最新
    - 合并:多个字段变更合并为一次更新
    
  3. 幂等处理

    ES文档设计:
    {
      "productId": "123",
      "title": "iPhone 15",
      "price": 5999,
      "version": 10,  // 版本号
      "updatedAt": 1679800000
    }
    
    更新逻辑:
    if (event.version > doc.version) {
      update ES
    } else {
      skip (乱序消息)
    }
    
  4. 对账机制

    对账任务(每小时):
    SELECT product_id, version, updated_at
    FROM products
    WHERE updated_at >= NOW() - INTERVAL 2 HOUR
    
    对每个商品:
    - 查询ES中的version
    - 如果MySQL.version > ES.version
    - 发送补偿事件到Kafka
    
  5. 监控告警

    指标:
    - 同步延迟(消息产生到ES更新完成的时间)
    - 失败率(同步失败的比例)
    - 对账差异数(MySQL和ES不一致的商品数)
    
    告警:
    - 同步延迟 > 10秒
    - 失败率 > 1%
    - 对账差异 > 100条
    

延伸思考

  1. 如果ES集群故障,搜索如何降级?
  2. 商品删除后ES索引如何处理?
  3. 大批量商品导入如何优化ES同步性能?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:391。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-004:直接订阅 Binlog 同步 ES 的弊端是什么?如果不同变更之间存在依赖关系,应该怎么处理?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-004
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/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 需要的是“某个商品聚合视图应该变成什么样”。这会带来几个典型问题:

  1. 业务语义弱:Binlog 只表达 insert/update/delete,不表达 ProductPublishedProductOfflineOfferChangedRefundRuleChanged
  2. 强依赖表结构:字段新增、删除、顺序变化、JSON 结构变化,都可能影响同步逻辑。
  3. 跨表依赖复杂:一个 ES 商品文档可能依赖 item、spu、sku、offer、resource、category、stock config、refund rule 等多张表。
  4. 顺序不稳定:同一业务发布可能写多张表,Binlog 事件到达不同 consumer 时不一定按业务语义有序。
  5. 并发覆盖风险:两个表变更同时 patch 同一个 ES doc,可能出现后写基于旧 doc 覆盖前写结果。
  6. 版本语义不足:Binlog timestamp 或 position 不等价于商品业务版本,难以判断旧事件是否应该覆盖新事件。
  7. 失败补偿困难:失败消息只知道表和字段,不一定知道影响哪个商品、哪个发布版本、是否可以安全重建。

典型错误做法:按每条 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 PatchDirty Doc 重建业务事件 + Outbox
实现成本中高
业务语义
跨表依赖处理很好
防并发覆盖很好
防乱序能力
对表结构耦合
故障补偿很好
适合场景简单索引多表聚合索引核心商品发布链路

推荐方案

短期采用 Binlog → Dirty Doc Queue → Full Rebuild ES Doc,中长期演进到 业务事件 + Outbox + 商品快照重建 ES

推荐落地路径:

  1. 定义 ES doc 聚合根

    product index:
      doc_id = item_id
    
    carrier index:
      doc_id = carrier_id
    
    event index:
      doc_id = event_id
    
  2. 维护依赖映射

    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
    
  3. 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)
    
  4. Index Worker 串行处理同一个 doc

    SELECT *
    FROM es_sync_dirty_doc
    WHERE status = 'PENDING'
    ORDER BY updated_at ASC
    LIMIT 100;
    

    同一个 doc_type + doc_id 通过唯一键合并,Worker 抢占后重建完整文档。

  5. 重建时读取 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
    
  6. 写 ES 带版本

    商品类索引用 publish_version;没有业务版本的对象至少使用 updated_atrebuild_seqsource_version

  7. 失败进入 DLQ 和补偿

    失败时记录:

    doc_type
    doc_id
    source_table
    error_code
    retry_count
    next_retry_at
    
  8. 定期 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 里猜商品到底发生了什么。

延伸思考

  1. 如何设计 resolveAffectedDocIds,避免一张配置表变更导致全量商品都被标脏?
  2. ES 写入使用 external version 有什么限制?
  3. Dirty Queue 堆积时,如何区分高优先级商品和普通商品?
  4. 全量重建和增量同步同时发生时,如何避免旧增量写到新索引?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:603。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-005:设计商品类目体系和属性管理

元信息

项目内容
题目编号Q-ECOM-SUPPLY-005
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:955

题干与约束

电商平台有上千个类目(如手机、服装、食品),每个类目有不同的属性(手机有内存、颜色,服装有尺码、材质)。如何设计类目体系和属性管理系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台有上千个类目(如手机、服装、食品),每个类目有不同的属性(手机有内存、颜色,服装有尺码、材质)。如何设计类目体系和属性管理系统?

答案

问题分析: 类目属性管理的核心挑战:

  1. 类目层级深(最多5-6级)
  2. 属性类型多样(文本、数值、枚举、多选)
  3. 属性继承和覆盖
  4. 属性校验规则复杂

方案一:树形类目+固定属性

核心思想: 类目按树形组织,每个类目预定义固定属性。

设计:

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查询能力有限
  • 属性分组需要人工维护

方案对比

维度固定属性动态模板分组+扩展
灵活性★★☆☆☆★★★★★★★★★☆
性能★★★★★★★★☆☆★★★★☆
实施难度★★★★★★★☆☆☆★★★☆☆
可维护性★★★☆☆★★★★☆★★★★☆

推荐方案: 采用动态属性模板+继承

实施要点:

  1. 类目层级设计

    建议:不超过4级
    L1:大类(手机、服装、食品)
    L2:中类(智能手机、T恤、零食)
    L3:小类(iPhone、圆领T恤、膨化食品)
    L4:细分类(iPhone 15系列)
    
  2. 属性校验

    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()); // 类型、范围、枚举值校验
      }
    }
    
  3. 属性搜索支持

    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"}
            }
          }
        }
      }
    }
    
  4. 属性演进

    新增属性:
    1. 在attribute_meta表添加属性定义
    2. 关联到类目模板
    3. 存量商品渐进补齐(批量任务或人工)
    
    弃用属性:
    1. 标记为deprecated
    2. 新商品不展示该属性
    3. 老商品保留(不删除)
    
  5. 多语言支持

    attribute_i18n(属性国际化)
    ├── attribute_id
    ├── locale(zh_CN/en_US)
    ├── name
    └── description
    

延伸思考

  1. 如何处理类目合并和拆分?
  2. 属性过多时如何优化详情页加载性能?
  3. 跨类目搜索时属性如何映射?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:955。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-006:商品图片的存储和CDN方案

元信息

项目内容
题目编号Q-ECOM-SUPPLY-006
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1198

题干与约束

电商平台商品图片数量巨大(百万级),每天上传图片数万张。如何设计图片存储和CDN方案,保证加载速度和成本可控?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台商品图片数量巨大(百万级),每天上传图片数万张。如何设计图片存储和CDN方案,保证加载速度和成本可控?

答案

问题分析: 图片存储的核心挑战:

  1. 存储成本高(TB级数据)
  2. 访问量大(详情页、列表页都需要图片)
  3. 需要支持多种尺寸(缩略图、中图、大图)
  4. 图片上传和审核流程

方案一:自建存储+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

实施要点:

  1. 图片命名规范

    {bucket}/{年}/{月}/{日}/{category}/{uuid}.{ext}
    
    示例:
    product-images/2026/04/18/phone/550e8400-e29b-41d4-a716-446655440000.jpg
    
  2. 多尺寸策略

    方案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
    
  3. CDN配置

    缓存策略:
    - 原图:缓存7天
    - 缩略图:缓存30天
    - 回源策略:304协商缓存
    
    防盗链:
    - Referer白名单
    - 签名URL(临时访问)
    - IP黑名单
    
  4. 图片审核

    流程:
    1. 上传到临时bucket
    2. 触发审核(内容安全API)
    3. 审核通过 → 移动到正式bucket
    4. 审核不通过 → 标记为违规,删除
    
    审核内容:
    - 色情识别
    - 暴恐识别
    - 二维码识别
    - 文字OCR+敏感词
    
  5. 性能优化

    图片格式:
    - 优先WebP(体积小30%)
    - 降级JPEG/PNG(老浏览器)
    
    懒加载:
    - 首屏图片优先加载
    - 下方图片懒加载
    - 占位图优化体验
    
    压缩:
    - JPEG质量80%(肉眼无感知)
    - PNG使用TinyPNG压缩
    

延伸思考

  1. 如何防止图片盗链?
  2. 商家上传违规图片如何处理?
  3. 图片存储成本如何优化?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1198。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-007:虚拟商品vs实物商品的设计差异

元信息

项目内容
题目编号Q-ECOM-SUPPLY-007
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1395

题干与约束

实物商品需要物流配送,虚拟商品(如充值卡、会员)是即时发货。两者在系统设计上有哪些差异?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 实物商品需要物流配送,虚拟商品(如充值卡、会员)是即时发货。两者在系统设计上有哪些差异?

答案

问题分析: 虚拟商品的核心差异:

  1. 无需物流,履约方式不同
  2. 库存是卡密池,不是物理库存
  3. 发货是推送卡密,不是创建运单
  4. 支持自动发货

方案一:统一建模,类型区分

核心思想: 实物和虚拟商品共用一套模型,通过类型字段区分。

设计:

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. 标记卡密为已分配

优点:

  • 订单模型统一
  • 支持混合订单
  • 履约解耦

缺点:

  • 履约系统复杂度增加

方案对比

维度统一建模拆分系统统一订单+差异履约
模型清晰度★★★☆☆★★★★★★★★★☆
混合订单★★★★★★★☆☆☆★★★★★
实施难度★★★★☆★★☆☆☆★★★☆☆
用户体验★★★★★★★★☆☆★★★★★

推荐方案: 采用统一订单+差异化履约

实施要点:

  1. 虚拟商品特殊字段

    virtual_product_ext
    ├── product_id
    ├── card_type(MOBILE_CHARGE/VIP_CARD/GAME_COIN)
    ├── face_value(面值)
    ├── validity_days(有效天数)
    └── auto_deliver(是否自动发货)
    
  2. 卡密池设计

    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)
    
  3. 自动发货

    触发条件:
    - 支付成功事件
    - 商品类型=虚拟
    - auto_deliver=true
    
    发货流程:
    1. 从卡密池分配卡密
    2. 更新订单状态=COMPLETED
    3. 推送卡密给用户(短信/App推送)
    4. 记录发货日志
    
  4. 卡密补货

    监控:
    - 可用卡密数量 < 1000 → 告警
    
    补货:
    - 供应商批量导入
    - 或系统自动生成(如游戏币)
    
  5. 安全控制

    - 卡密加密存储(AES)
    - 卡密脱敏展示(只显示后4位)
    - 限制查询频率(防止爬虫)
    - 异常查询告警
    

延伸思考

  1. 如何防止卡密被盗刷?
  2. 卡密分配失败如何处理?
  3. 虚拟商品是否需要支持退款?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1395。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-008:商品上架流程的工作流设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-008
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1594

题干与约束

商品从创建到上架需要经过多个环节(信息录入、图片上传、价格设置、审核)。请设计商品上架的工作流系统。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 商品从创建到上架需要经过多个环节(信息录入、图片上传、价格设置、审核)。请设计商品上架的工作流系统。

答案

问题分析: 商品上架工作流的核心挑战:

  1. 流程长,涉及多个环节和角色
  2. 需要支持驳回和重新提交
  3. 审核规则复杂(机审+人审)
  4. 大批量商品上架性能

方案一:状态机模式

核心思想: 商品的状态流转按状态机管理。

状态定义:

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可视化
  • 需要自己维护

方案对比

维度状态机工作流引擎轻量引擎
实施难度★★★★★★★☆☆☆★★★★☆
流程表达力★★☆☆☆★★★★★★★★★☆
维护成本★★★★☆★★★☆☆★★★★☆
适用场景简单流程复杂流程中等流程

推荐方案: 对于商品上架,推荐轻量级流程引擎

实施要点:

  1. 审核规则设计

    机器审核:
    - 图片审核(色情、暴恐)
    - 标题敏感词检测
    - 价格合理性检测(异常低价)
    - 类目属性完整性检测
    
    人工审核:
    - 机器审核不通过 → 必须人审
    - 高风险类目(药品、食品) → 必须人审
    - 新商家首批商品 → 必须人审
    - 其他商品 → 机审通过直接上架
    
  2. 批量上架优化

    单个上架:
    - 提交 → 立即审核 → 立即上架
    
    批量上架:
    - 提交100个商品
    - 异步审核(队列)
    - 审核完成后批量回调
    - 生成审核报告
    
  3. 驳回重审

    驳回原因分类:
    - 图片问题(重新上传图片即可)
    - 价格问题(重新设置价格)
    - 类目错误(重新选择类目,属性重填)
    
    重审流程:
    - 修改后自动重新提审
    - 或需要人工重新提交
    
  4. 工作流监控

    指标:
    - 待审核商品数量
    - 平均审核时长
    - 审核通过率
    - 驳回原因分布
    
    告警:
    - 待审核积压 > 1000
    - 审核通过率 < 80%
    

延伸思考

  1. 如何设计商品的定时上架功能?
  2. 批量上架如何保证事务性?
  3. 审核规则如何动态配置?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1594。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-009:如何支持商品的多规格选择(颜色、尺码等)?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-009
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1816

题干与约束

服装类商品有多个规格(颜色、尺码),用户需要先选择规格再下单。如何设计商品规格和SKU的选择逻辑?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 服装类商品有多个规格(颜色、尺码),用户需要先选择规格再下单。如何设计商品规格和SKU的选择逻辑?

答案

问题分析: 多规格选择的核心挑战:

  1. 规格组合爆炸(3个颜色×5个尺码=15个SKU)
  2. 无效组合处理(某颜色没有某尺码)
  3. 库存关联(每个SKU独立库存)
  4. 价格差异(不同规格价格不同)

方案一:预生成所有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. 前端规格选择组件

    逻辑:
    1. 加载所有有效SKU
    2. 构建规格树
    3. 根据已选规格,计算可选项
    4. 禁用无货或无效选项
    
    示例(用户已选"黑色"):
    可选尺码 = 筛选(所有SKU, color="黑色" && stock>0)
    禁用尺码 = 筛选(所有SKU, color="黑色" && stock=0)
    
  2. 规格约束表达

    方案A:黑名单
    "不存在黑色XL"
    
    方案B:白名单
    "只有这些组合:黑色+M, 黑色+L, 白色+S, ..."
    
    推荐:黑名单(灵活)
    
  3. SKU图片

    商品主图:展示默认规格
    规格图:每个颜色独立图片
    
    用户选择颜色 → 切换主图
    
  4. 性能优化

    缓存:
    - 缓存商品的所有SKU(减少查询)
    - 缓存规格树(减少计算)
    
    压缩:
    - 规格数据压缩传输
    

延伸思考

  1. 如何支持规格变更(新增颜色、下架尺码)?
  2. 用户加购时记录SKU还是规格组合?
  3. 如何优化规格选择的用户体验?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:1816。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-010:商品快照在订单中的应用

元信息

项目内容
题目编号Q-ECOM-SUPPLY-010
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2011

题干与约束

用户下单后,商家可能修改商品标题、价格、图片。为了避免纠纷,需要在订单中保存商品快照。请设计商品快照方案。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户下单后,商家可能修改商品标题、价格、图片。为了避免纠纷,需要在订单中保存商品快照。请设计商品快照方案。

答案

问题分析: 商品快照的核心挑战:

  1. 快照内容:保存哪些字段
  2. 存储成本:每个订单都存快照,数据量大
  3. 快照时机:下单时还是支付时
  4. 快照更新:商品变更后订单快照是否更新

方案一:订单表冗余字段

核心思想: 在订单明细表中冗余商品关键字段。

设计:

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. 如果商品已删除,快照为空

优点:

  • 存储成本低
  • 按需生成

缺点:

  • 延迟生成可能获取不到准确信息
  • 商品删除后无法生成

方案对比

维度冗余字段独立快照表按需快照
快照完整性★★☆☆☆★★★★★★★★☆☆
存储成本★★★☆☆★★☆☆☆★★★★★
查询性能★★★★★★★★★☆★★★☆☆
准确性★★★★★★★★★★★★★☆☆

推荐方案: 采用独立快照表+去重优化

实施要点:

  1. 快照内容设计

    必须包含:
    - 商品标题、主图
    - SKU规格、价格
    - 品牌、类目
    
    可选包含:
    - 商品详情图(占用空间大)
    - 营销信息(优惠券、满减)
    - 服务承诺(七天无理由退货)
    
  2. 快照去重

    生成流程:
    1. 计算快照内容的MD5: content_hash
    2. 查询是否已存在相同hash的快照
    3. 如果存在,复用snapshot_id
    4. 如果不存在,创建新快照
    
    收益:
    - 相同商品的订单共享快照
    - 存储成本降低50%+
    
  3. 快照压缩

    JSON压缩:
    - 使用gzip压缩snapshot_data
    - 读取时解压
    
    字段裁剪:
    - 只保留关键字段
    - 详情图等大字段不保存
    
  4. 快照过期清理

    策略:
    - 订单完成后保留2年(法律要求)
    - 2年后匿名化处理(删除用户信息,保留快照)
    - 5年后归档到对象存储
    
  5. 快照版本化

    快照schema版本:
    V1: {title, price, image}
    V2: {title, price, images[], brand, specs}
    
    读取时兼容:
    if (snapshot.version == 1) {
      return convertV1ToV2(snapshot)
    }
    

延伸思考

  1. 商品快照如何支持营销信息(如“限时折扣“)?
  2. 快照生成失败如何处理?
  3. 如何设计快照的版本兼容?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2011。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-011:设计商品推荐系统的架构

元信息

项目内容
题目编号Q-ECOM-SUPPLY-011
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2215

题干与约束

电商平台需要在详情页、列表页、首页展示个性化推荐商品。请设计商品推荐系统的架构。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台需要在详情页、列表页、首页展示个性化推荐商品。请设计商品推荐系统的架构。

答案

问题分析: 推荐系统的核心挑战:

  1. 推荐算法复杂(协同过滤、深度学习)
  2. 实时性要求(用户行为实时影响推荐)
  3. 冷启动问题(新用户、新商品)
  4. 性能要求高(毫秒级响应)

方案一:基于规则的推荐

核心思想: 使用人工配置的规则进行推荐。

规则示例:

规则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获取实时推荐结果

优点:

  • 实时性好(秒级)
  • 支持个性化
  • 反馈快

缺点:

  • 架构复杂
  • 成本高
  • 算法受限(不能用复杂模型)

方案对比

维度规则推荐离线+在线实时推荐
推荐效果★★☆☆☆★★★★☆★★★★★
实时性★★★★★★★☆☆☆★★★★★
实施难度★★★★★★★★☆☆★★☆☆☆
成本★★★★★★★★☆☆★★☆☆☆

推荐方案: 采用离线推荐+实时规则补充的混合方案。

实施要点:

  1. 推荐场景分类

    首页推荐:
    - 个性化推荐(基于用户画像)
    - 热门推荐(兜底)
    
    详情页推荐:
    - 看了还看(基于商品相似度)
    - 买了还买(基于订单关联)
    
    购物车推荐:
    - 凑单推荐(基于购物车商品关联)
    - 优惠推荐(基于满减规则)
    
  2. 推荐召回链路

    第一层:个性化召回(离线计算)
    - 协同过滤召回
    - 内容召回(基于用户兴趣标签)
    
    第二层:规则召回(在线计算)
    - 热门商品
    - 运营配置
    
    第三层:排序
    - 点击率预估
    - 转化率预估
    - 业务规则调权(如新品扶持)
    
    第四层:过滤
    - 去重
    - 过滤下架/无货商品
    - 多样性(不全是同一类目)
    
  3. 冷启动处理

    新用户:
    - 展示热门商品
    - 根据注册信息推断兴趣(地域、年龄)
    - 引导用户选择兴趣标签
    
    新商品:
    - 基于类目和属性推荐给相关用户
    - 运营人工推送给种子用户
    - 根据早期反馈调整推荐策略
    
  4. A/B测试

    实验:
    - 对照组:规则推荐
    - 实验组:算法推荐
    
    指标:
    - 点击率(CTR)
    - 转化率(CVR)
    - 人均订单金额
    
  5. 监控指标

    业务指标:
    - 推荐位点击率
    - 推荐商品转化率
    - 推荐覆盖度(多少用户有推荐)
    
    技术指标:
    - 推荐响应时间
    - 推荐服务可用性
    - 离线计算任务成功率
    

延伸思考

  1. 如何评估推荐系统的效果?
  2. 推荐系统如何防止马太效应(热门更热,冷门更冷)?
  3. 如何保护用户隐私(不过度使用用户数据)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2215。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-012:商品搜索的倒排索引设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-012
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2418

题干与约束

搜索引擎的核心是倒排索引。请说明电商商品搜索的倒排索引如何设计,包括分词、索引结构、查询优化等。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 搜索引擎的核心是倒排索引。请说明电商商品搜索的倒排索引如何设计,包括分词、索引结构、查询优化等。

答案

问题分析: 倒排索引的核心要点:

  1. 分词策略(中文分词难点)
  2. 索引字段选择(哪些字段需要索引)
  3. 相关性打分(如何排序)
  4. 性能优化(索引大小、查询速度)

方案一:基于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分词+多字段权重

实施要点:

  1. 自定义词典

    品牌词:小米、iPhone、华为
    型号词:13Ultra、15Pro、Mate60
    行业词:闪充、快充、护眼屏
    
    维护:
    - 定期更新词典
    - 新品牌/新词及时添加
    
  2. 同义词处理

    {
      "filter": {
        "synonym_filter": {
          "type": "synonym",
          "synonyms": [
            "手机,移动电话",
            "充电器,充电头",
            "iPhone,苹果手机"
          ]
        }
      }
    }
    
  3. 拼音搜索

    支持拼音搜索:
    "xiaomi" → 小米
    "pingguo" → 苹果
    
    实现:
    - 使用pinyin分词插件
    - 或维护拼音映射表
    
  4. 搜索建议(suggest)

    输入"xiao"  → 建议:[小米, 小天才, 小度]
    输入"iphone" → 建议:[iPhone 15, iPhone 14, iPhone 13]
    
    实现:
    - 使用ES的completion suggester
    - 基于前缀匹配
    
  5. 性能优化

    索引优化:
    - 只索引需要搜索的字段
    - 使用doc_values减少内存占用
    - 定期合并段(segment merge)
    
    查询优化:
    - 结果分页(from+size < 10000)
    - 深度分页用scroll或search_after
    - 热门查询结果缓存
    

延伸思考

  1. 如何实现搜索纠错(“小米手及” → “小米手机”)?
  2. 如何优化长尾查询的性能?
  3. 搜索结果如何排序(相关性、销量、价格)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2418。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-013:如何处理商品数据的历史版本?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-013
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2650

题干与约束

商品信息会不断变更(价格调整、标题修改、图片更换)。为了审计和纠纷处理,需要保留商品的历史版本。如何设计商品版本管理?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 商品信息会不断变更(价格调整、标题修改、图片更换)。为了审计和纠纷处理,需要保留商品的历史版本。如何设计商品版本管理?

答案

问题分析: 商品版本管理的核心挑战:

  1. 版本数据量大(每次变更都存储)
  2. 查询历史版本(某个时间点的商品信息)
  3. 版本对比(对比两个版本的差异)
  4. 存储成本

方案一:全量版本存储

核心思想: 每次变更都保存完整的商品数据。

设计:

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. 得到目标版本

优点:

  • 平衡存储和查询性能
  • 快照恢复快
  • 增量节省空间

缺点:

  • 实现复杂度中等

方案对比

维度全量版本增量版本混合模式
存储成本★★☆☆☆★★★★★★★★★☆
查询性能★★★★★★★☆☆☆★★★★☆
实施难度★★★★★★★★☆☆★★★☆☆
审计能力★★★★★★★★★★★★★★★

推荐方案: 对于电商系统,推荐混合模式

实施要点:

  1. 快照策略

    触发快照的时机:
    - 商品上架时(V1)
    - 每周日凌晨(定期快照)
    - 重大变更时(价格变动>20%)
    
  2. 变更日志记录

    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);
    }
    
  3. 版本查询API

    GET /api/products/{productId}/versions
    → 返回所有版本列表
    
    GET /api/products/{productId}/versions/{version}
    → 返回指定版本数据
    
    GET /api/products/{productId}/diff?from=10&to=12
    → 返回版本差异
    
  4. 存储优化

    - 快照使用压缩存储(gzip)
    - 超过1年的版本归档到对象存储
    - 变更日志保留2年(法律要求)
    

延伸思考

  1. 如何支持版本回滚(恢复到历史版本)?
  2. 版本数据如何支持跨表查询(如关联订单)?
  3. 大批量商品版本查询如何优化?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2650。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-014:多租户场景下的商品数据隔离

元信息

项目内容
题目编号Q-ECOM-SUPPLY-014
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2846

题干与约束

在B2B2C平台中,多个商家共用一套系统。如何设计商品数据的租户隔离,保证数据安全和性能?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 在B2B2C平台中,多个商家共用一套系统。如何设计商品数据的租户隔离,保证数据安全和性能?

答案

问题分析: 多租户隔离的核心挑战:

  1. 数据隔离:商家A看不到商家B的商品
  2. 性能隔离:商家A的流量不影响商家B
  3. 成本优化:共享基础设施降低成本
  4. 个性化:支持商家自定义配置

方案一:独立数据库(物理隔离)

核心思想: 每个租户独立数据库。

设计:

租户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混合隔离
隔离性★★★★★★★☆☆☆★★★★☆
成本★★☆☆☆★★★★★★★★★☆
运维复杂度★★☆☆☆★★★★★★★★☆☆
扩展性★★★★★★★★☆☆★★★★☆

推荐方案: 采用混合隔离(分库分表)

实施要点:

  1. 租户分级

    VIP租户(月GMV>1000万):
    - 独立数据库
    - 独立Redis
    - 独立ES索引
    
    普通租户:
    - 共享分片数据库
    - 共享Redis(按tenant_id前缀隔离)
    - 共享ES索引(按tenant_id过滤)
    
  2. 数据源路由

    @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();
      }
    }
    
  3. 租户升降级

    普通→VIP(升级):
    1. 创建独立数据库
    2. 数据迁移(双写验证)
    3. 切换路由
    4. 清理旧数据
    
    VIP→普通(降级):
    1. 迁移到共享分片
    2. 切换路由
    3. 删除独立数据库
    
  4. 安全控制

    - 强制tenant_id过滤(ORM拦截器)
    - 禁止跨租户查询
    - API鉴权(JWT包含tenant_id)
    - 审计日志(记录租户操作)
    

延伸思考

  1. 如何防止误查询跨租户数据(ORM层面)?
  2. 租户数据如何备份和恢复?
  3. 如何支持租户级别的功能开关?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:2846。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-015:商品导入的批量处理优化

元信息

项目内容
题目编号Q-ECOM-SUPPLY-015
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3040

题干与约束

商家需要批量导入商品(一次导入1000-10000个)。如何设计批量导入功能,保证性能和数据正确性?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 商家需要批量导入商品(一次导入1000-10000个)。如何设计批量导入功能,保证性能和数据正确性?

答案

问题分析: 批量导入的核心挑战:

  1. 数据量大,处理时间长
  2. 需要校验每个商品(格式、必填项、业务规则)
  3. 部分成功部分失败如何处理
  4. 导入进度如何实时反馈

方案一:同步导入

核心思想: 用户上传文件,服务端同步处理,处理完返回结果。

流程:

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连接
  • 实现最复杂

方案对比

维度同步导入异步导入流式导入
用户体验★★☆☆☆★★★★☆★★★★★
支持规模★★☆☆☆★★★★★★★★★☆
实施难度★★★★★★★★☆☆★★☆☆☆
实时反馈★★★★★★★☆☆☆★★★★★

推荐方案: 采用异步导入+进度查询

实施要点:

  1. 文件解析

    支持格式:
    - Excel(.xlsx)
    - CSV
    - JSON
    
    解析优化:
    - 流式解析(不一次加载全文件)
    - 分批处理(每100条一批)
    
  2. 数据校验

    校验层级:
    L1:格式校验(必填字段、字段类型)
    L2:业务校验(价格合理性、类目有效性)
    L3:关联校验(品牌是否存在、图片URL是否有效)
    
    快速失败:
    - 格式错误直接返回,不处理后续数据
    
  3. 事务处理

    方案A:全量事务
    - 全部成功才提交,任一失败全部回滚
    - 适合小批量、关联性强的数据
    
    方案B:分批事务(推荐)
    - 每100条一个事务
    - 部分失败不影响其他批次
    - 生成失败报告
    
  4. 性能优化

    - 批量INSERT(100条一次)
    - 异步同步ES(不阻塞导入)
    - 限流(防止导入占用所有资源)
    - 分时段(凌晨处理大批量)
    
  5. 失败处理

    失败记录:
    - 生成Excel文件,标注失败原因
    - 用户下载修改后重新导入
    
    部分成功:
    - 成功的商品已入库
    - 失败的记录在error_file中
    

延伸思考

  1. 如何支持导入任务的取消?
  2. 导入过程中商品数据变更如何处理?
  3. 如何设计商品导入的幂等性?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3040。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-016:商品审核流程的设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-016
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3231

题干与约束

商家上传的商品需要经过审核才能上架(防止违规商品)。请设计商品审核系统,包括机审和人审。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 商家上传的商品需要经过审核才能上架(防止违规商品)。请设计商品审核系统,包括机审和人审。

答案

问题分析: 商品审核的核心挑战:

  1. 审核效率:大量商品等待审核
  2. 审核准确性:机审误报,人审成本高
  3. 审核优先级:重点类目优先审核
  4. 申诉流程:商家对审核结果不满

方案一:纯人工审核

核心思想: 所有商品都由审核人员人工审核。

流程:

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审核。

实施要点:

  1. 审核规则配置化

    审核规则表:
    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"
    }
    
  2. 审核任务队列

    优先级队列:
    P0:付费商家、大商家
    P1:普通商家
    P2:新商家
    
    分配策略:
    - P0优先分配
    - 同优先级按提交时间
    - 负载均衡(每个审核员任务量相当)
    
  3. 审核SLA

    目标:
    - 机审:5秒内完成
    - 人审:2小时内完成(工作时间)
    
    超时告警:
    - 待审核任务积压 > 500
    - 人审超时 > 50个
    
  4. 申诉流程

    商家不满审核结果:
    1. 点击"申诉"
    2. 填写申诉理由
    3. 转高级审核员复审
    4. 复审结果通知商家
    

延伸思考

  1. 如何设计审核人员的绩效考核?
  2. 机审规则如何动态调整(根据审核质量)?
  3. 如何防止商家恶意提交违规商品?


评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3231。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-017:库存是怎么创建出来的?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-017
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/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_balanceINIT/INBOUND 流水,不能绕过账本直接改 stock
门店数量库存sku_id + store_id 创建库存行门店上下线要支持锁定、迁移和审计
日期 / 时段库存sku_id + store_id + date + slot 创建切片高流量品类提前物化,长尾门店懒创建
导入券码库存创建 inventory_code_batch,逐行写 inventory_code_pool_XX加密存储、哈希去重、Redis LIST 只预热 code_id
系统生成券码预生成批次,或按订单幂等生成后落库返回给用户前必须先有 MySQL 权威行
供应商库存创建供应商映射和本地快照本地快照不是最终承诺,下单前需要强刷或预订

面试时可以强调三个原则:

  1. 库存创建要任务化:商品发布事务不应该同步创建海量券码或未来 365 天日历库存,否则发布链路会被库存写放大拖垮。
  2. 库存创建要幂等:同一个发布版本、导入批次或供应商快照重复投递时,不能重复入库或重复生成券码。
  3. 库存创建要能解释来源:每一次初始化、导入、补货、系统生码都要有任务、批次和账本流水,否则后续对账只能看到“库存变了”,无法解释为什么变。

对于券码制,最容易踩坑的是把 Redis 当成码池权威。正确做法是:

导入或生成券码
  → 加密写入 inventory_code_pool_XX
  → status=AVAILABLE
  → Redis LIST 只灌入 code_id
  → 下单时弹出 code_id
  → MySQL CAS: AVAILABLE -> BOOKING

只有 MySQL 状态机更新成功,才算真正锁码成功。Redis 可以丢、可以重建,但不能成为唯一账本。

延伸思考

  1. 库存创建任务部分成功时,哪些数据可以继续保留,哪些必须回滚?
  2. 系统生成券码如何防止被猜测和批量撞库?
  3. 酒店或门店预约类库存,未来多久的日历切片应该提前物化?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3443。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-018:库存如何和商品供给运营平台、商品生命周期联动?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-018
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/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 重试

要避免的反模式:

  1. 供给后台直接改 stock 字段,绕过库存账本;
  2. 商品 ONLINE 后默认可卖,忽略库存、价格、履约和搜索刷新状态;
  3. 下架时删除库存行,导致历史订单、售后和券码核销不可追溯;
  4. 供应商同步直接覆盖运营手工修复的库存策略;
  5. 库存系统直接改商品生命周期,绕过审核和发布版本。

一句话总结:

供给平台治理变更,生命周期控制线上状态,库存系统提供可承诺资源,可售投影把这些状态合成用户能否下单。它们通过命令、事件、版本和幂等键协作,而不是互相直接改库。

延伸思考

  1. 商品已发布但库存初始化失败,是否允许展示“售罄”?
  2. 运营手工补货和供应商同步库存冲突时,字段主导权怎么判定?
  3. 下架后已有预占订单是否继续履约,谁来仲裁?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3513。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-019:设计防止库存超卖的方案

元信息

项目内容
题目编号Q-ECOM-SUPPLY-019
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3602

题干与约束

电商大促时,热门商品库存100件,但短时间涌入1000个订单。如何设计库存扣减方案,防止超卖?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商大促时,热门商品库存100件,但短时间涌入1000个订单。如何设计库存扣减方案,防止超卖?

答案

问题分析: 库存超卖的核心原因:

  1. 并发扣减:多个请求同时扣减库存
  2. 分布式环境:库存分散在多个节点
  3. 缓存不一致:Redis和DB库存不同步
  4. 库存回滚:订单取消后库存未释放

方案一:数据库悲观锁

核心思想: 使用数据库行锁保证原子性。

实现:

-- 查询并锁定
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

实施要点:

  1. 双层库存设计

    Redis(实时库存):
    - 用于扣减判断
    - 高性能
    - 可能丢失
    
    MySQL(权威库存):
    - 定期同步Redis
    - 数据持久化
    - 对账基准
    
  2. 库存同步

    Redis → MySQL:
    - 定时任务(每10秒)
    - 批量更新(减少DB压力)
    - 增量同步(只同步变更的SKU)
    
    MySQL → Redis:
    - 商品上架时初始化Redis
    - 运营调整库存时更新Redis
    - Redis故障恢复时从MySQL加载
    
  3. 库存预热

    大促前:
    1. 识别热门商品(预测销量)
    2. 提前加载到Redis
    3. 设置永不过期
    4. 多副本(主从)
    
  4. 降级方案

    Redis故障:
    - 降级到MySQL悲观锁
    - 限流(降低并发度)
    - 提示用户(商品火爆)
    
  5. 监控告警

    指标:
    - Redis和MySQL库存差异
    - 库存扣减QPS
    - 库存不足次数
    - 超卖告警(库存为负)
    
    告警:
    - 库存差异 > 100
    - 超卖发生
    - Redis同步延迟 > 1分钟
    

延伸思考

  1. 秒杀场景如何进一步优化(如库存分段、令牌桶)?
  2. Redis故障导致库存丢失如何恢复?
  3. 如何处理订单取消后的库存回补?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3602。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-020:如何设计分布式库存系统?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-020
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3813

题干与约束

电商平台有多个仓库(北京、上海、深圳),商品在不同仓库有不同库存。如何设计分布式库存系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台有多个仓库(北京、上海、深圳),商品在不同仓库有不同库存。如何设计分布式库存系统?

答案

问题分析: 分布式库存的核心挑战:

  1. 库存分布:如何在多仓库间分配库存
  2. 库存查询:如何快速查询总库存
  3. 库存分配:用户下单时选择哪个仓库发货
  4. 库存调拨:仓库间库存转移

方案一:集中式库存

核心思想: 所有仓库库存汇总到一个中心库存池。

设计:

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最高的仓库

优点:

  • 用户体验好(总库存可见)
  • 灵活分配(后台优化)
  • 支持复杂路由

缺点:

  • 实现复杂
  • 需要智能分配算法

方案对比

维度集中式分布式虚拟池
用户体验★★★★★★★★☆☆★★★★★
性能★★★☆☆★★★★★★★★★☆
库存利用率★★★★★★★★☆☆★★★★★
实施难度★★★★☆★★★★☆★★★☆☆

推荐方案: 采用虚拟库存池

实施要点:

  1. 库存聚合

    实时聚合(Redis):
    total_stock:sku:123 =
      stock:warehouse:1:sku:123 +
      stock:warehouse:2:sku:123 +
      stock:warehouse:3:sku:123
    
    更新触发:
    - 仓库库存变更 → 更新总库存
    - 使用Redis Pipeline批量更新
    
  2. 仓库选择算法

    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;
    }
    
  3. 库存预占

    预占流程:
    1. 用户下单 → 预占库存(reserved_stock +quantity)
    2. 用户支付 → 确认扣减(stock -quantity, reserved_stock -quantity)
    3. 用户取消 → 释放库存(reserved_stock -quantity)
    
    超时释放:
    - 未支付订单30分钟后自动取消
    - 定时任务扫描超时预占,自动释放
    
  4. 库存调拨

    场景:
    - 北京仓库存100,上海仓库存0
    - 上海用户下单,需要从北京调拨
    
    调拨流程:
    1. 创建调拨单
    2. 北京仓库:stock -10
    3. 运输中...
    4. 上海仓库:stock +10
    
  5. 安全库存

    设计:
    available_stock = physical_stock - reserved_stock - safety_stock
    
    作用:
    - 预留库存应对盘点误差
    - 预留库存应对损坏、丢失
    - 建议:safety_stock = physical_stock * 5%
    

延伸思考

  1. 如何设计库存预警机制(库存不足提醒)?
  2. 多仓库场景下如何最优化运费成本?
  3. 如何处理商品跨仓拆单(一单多仓发货)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:3813。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-021:库存预占与释放的设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-021
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4264

题干与约束

用户加入购物车或进入结算页时,需要预占库存,防止其他用户抢走。但如果用户不支付,需要释放库存。如何设计库存预占机制?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户加入购物车或进入结算页时,需要预占库存,防止其他用户抢走。但如果用户不支付,需要释放库存。如何设计库存预占机制?

答案

问题分析: 库存预占的核心挑战:

  1. 预占时机:什么时候预占(加购、结算、下单)
  2. 预占时长:预占多久(太短影响支付,太长占用库存)
  3. 超时释放:如何自动释放超时预占
  4. 并发安全:多个请求同时预占

方案一:下单时预占

核心思想: 用户下单时才预占库存,加购和结算不预占。

设计:

加购物车:不预占库存
进入结算页:不预占库存
提交订单:预占库存
  → 成功:进入支付流程
  → 失败:提示库存不足

预占超时: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分钟
}

优点:

  • 差异化服务
  • 库存利用率高
  • 灵活调整

缺点:

  • 规则复杂
  • 实现成本高

方案对比

方案用户体验库存利用率超卖风险实施难度
下单预占★★★☆☆★★★★★★★★☆☆★★★★★
结算预占★★★★★★★★★☆★★★★★★★★☆☆
分级预占★★★★★★★★★★★★★★★★★☆☆☆

推荐方案: 采用结算时预占

实施要点:

  1. 预占时长设置

    考虑因素:
    - 支付流程耗时(通常2-3分钟)
    - 用户犹豫时间(5-10分钟)
    - 库存周转率(紧俏商品缩短)
    
    建议:
    - 默认15分钟
    - 库存<10%时缩短到5分钟
    - VIP用户延长到30分钟
    
  2. 预占幂等性

    使用order_id作为幂等键:
    INSERT INTO reservation (reservation_id, order_id, ...)
    ON DUPLICATE KEY UPDATE updated_at=NOW();
    
    防止重复预占:
    - 同一订单多次预占,使用相同reservation记录
    - 延长expire_at即可
    
  3. 超时释放优化

    方案A:定时任务扫描
    - 每分钟扫描一次
    - 查询expire_at < NOW()
    - 批量释放
    
    方案B:延迟队列(推荐)
    - 预占时发送延迟消息(延迟15分钟)
    - 消息到期时检查状态
    - 如果未支付,释放库存
    
    优点:精确释放,无需轮询
    
  4. 库存保护

    最大预占比例:
    - 允许预占库存 <= total_stock * 90%
    - 保留10%库存应对预占释放后的瞬时需求
    
    预占限流:
    - 单用户最多预占5个订单
    - 单商品最多被预占total_stock * 80%
    

延伸思考

  1. 用户在结算页停留很久不支付,如何处理?
  2. 预占释放后其他用户如何得知库存恢复?
  3. 如何设计库存预占的监控指标?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4264。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-022:如何设计库存的分级管理(前台可售vs仓库实际)?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-022
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4477

题干与约束

仓库实际库存100件,但前台可售库存只有80件(预留20件应对售后、损耗)。如何设计库存的分级管理?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 仓库实际库存100件,但前台可售库存只有80件(预留20件应对售后、损耗)。如何设计库存的分级管理?

答案

问题分析: 库存分级的核心挑战:

  1. 不同层级库存含义不同
  2. 层级间库存同步
  3. 安全库存设置
  4. 库存占用追踪

方案一:单一库存(简化版)

核心思想: 只维护一个库存字段,不区分层级。

设计:

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)

优点:

  • 灵活,支持多种占用类型
  • 可追溯所有占用历史
  • 易于扩展

缺点:

  • 查询需要聚合计算
  • 性能较差

方案对比

维度单一库存多级库存占用日志
清晰度★★☆☆☆★★★★★★★★★☆
性能★★★★★★★★★☆★★★☆☆
灵活性★★☆☆☆★★★☆☆★★★★★
实施难度★★★★★★★★☆☆★★☆☆☆

推荐方案: 采用多级库存

实施要点:

  1. 安全库存设置

    策略:
    - 标准:safety_stock = 5% * physical_stock
    - 易损商品:safety_stock = 10% * physical_stock
    - 高价商品:safety_stock = 2% * physical_stock
    
    动态调整:
    - 根据历史损耗率调整
    - 旺季增加,淡季减少
    
  2. 库存同步检查

    不变量检查:
    physical_stock =
      available_stock +
      reserved_stock +
      sold_stock +
      safety_stock
    
    定期对账:
    如果不等式不成立,说明库存有问题
    
  3. 库存调整接口

    运营调整物理库存:
    adjustPhysicalStock(skuId, delta, reason)
    
    自动调整安全库存:
    adjustSafetyStock(skuId, percentage)
    
  4. 库存报表

    库存健康度:
    - 库存周转率 = 销量 / 平均库存
    - 滞销率 = 30天未售商品数 / 总商品数
    - 缺货率 = 用户下单失败次数 / 总下单次数
    

延伸思考

  1. 如何设计库存盘点功能(盘点期间库存锁定)?
  2. 安全库存不足时如何处理?
  3. 已售库存发货后如何核减?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4477。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-023:库存扣减失败的补偿机制

元信息

项目内容
题目编号Q-ECOM-SUPPLY-023
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4660

题干与约束

在订单创建流程中,扣减库存可能失败(并发冲突、网络超时、服务故障)。如何设计补偿机制,保证数据一致性?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 在订单创建流程中,扣减库存可能失败(并发冲突、网络超时、服务故障)。如何设计补偿机制,保证数据一致性?

答案

问题分析: 库存扣减失败的核心场景:

  1. 网络超时:不知道是否扣减成功
  2. 服务故障:库存服务不可用
  3. 并发冲突:乐观锁更新失败
  4. 数据不一致:订单已创建但库存未扣减

方案一:同步重试

核心思想: 扣减失败时立即重试,最多重试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次仍失败
   - 转人工处理
   - 排查根本原因

优点:

  • 多层保障
  • 可靠性高
  • 覆盖各种异常

缺点:

  • 实现最复杂

方案对比

方案实时性可靠性用户体验实施难度
同步重试★★★★★★★★☆☆★★★☆☆★★★★★
异步补偿★★★☆☆★★★★☆★★★★☆★★★☆☆
补偿+对账★★★☆☆★★★★★★★★★☆★★☆☆☆

推荐方案: 采用补偿+对账

实施要点:

  1. 幂等性保证

    public void deductInventory(DeductRequest req) {
      // 使用orderId作为幂等键
      if (isAlreadyDeducted(req.getOrderId())) {
        return; // 已扣减,直接返回
      }
    
      // 执行扣减
      doDeduct(req);
    
      // 记录已扣减
      markDeducted(req.getOrderId());
    }
    
  2. 补偿任务重试策略

    指数退避:
    第1次:立即重试
    第2次:1分钟后
    第3次:5分钟后
    第4次:15分钟后
    第5次:1小时后
    
    超过5次 → 转人工
    
  3. 补偿任务优先级

    P0:已支付订单(优先处理)
    P1:待支付订单
    P2:其他
    
  4. 对账规则

    检查项:
    1. 订单状态=PAID → 库存必须已扣减
    2. 订单金额 = 商品价格 × 数量
    3. 库存不能为负数
    
    差异处理:
    - 自动补偿(低风险)
    - 人工介入(高风险)
    

延伸思考

  1. 如果补偿重试多次仍失败,如何处理?
  2. 补偿过程中订单状态如何展示给用户?
  3. 如何监控补偿任务的执行情况?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4660。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-024:设计库存盘点系统

元信息

项目内容
题目编号Q-ECOM-SUPPLY-024
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4857

题干与约束

仓库需要定期盘点库存,核对系统库存和实际库存是否一致。如何设计库存盘点系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 仓库需要定期盘点库存,核对系统库存和实际库存是否一致。如何设计库存盘点系统?

答案

问题分析: 库存盘点的核心挑战:

  1. 盘点期间如何处理库存变更
  2. 盘点差异如何调整
  3. 大规模商品盘点效率
  4. 盘点结果审核

方案一:冻结盘点

核心思想: 盘点期间冻结库存,禁止出入库。

流程:

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. 正常差异汇总报告

优点:

  • 分散盘点,效率高
  • 重点商品关注度高
  • 不影响业务

缺点:

  • 需要分类管理
  • 全盘点周期长

方案对比

方案对业务影响准确性效率适用场景
冻结盘点★★☆☆☆★★★★★★★☆☆☆小仓库
动态盘点★★★★★★★★★★★★★★☆大仓库
循环盘点★★★★★★★★★☆★★★★★商品多

推荐方案: 采用动态盘点+循环盘点的组合。

实施要点:

  1. 盘点任务生成

    创建盘点单:
    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(是否已调整)
    
  2. 盘点APP设计

    功能:
    - 扫码盘点(扫条码自动录入)
    - 语音录入(解放双手)
    - 拍照记录(有问题的商品拍照)
    - 离线模式(网络不好时)
    
    优化:
    - 按货架号排序(减少走动)
    - 实时同步(避免数据丢失)
    
  3. 差异分析

    差异原因分类:
    - 损耗(DAMAGE):商品破损
    - 丢失(LOSS):商品丢失
    - 错发(WRONG_SHIP):发错货
    - 漏记(MISSING_RECORD):出入库漏记
    - 系统bug(SYSTEM_ERROR)
    
    自动调整规则:
    - diff < 5% → 自动调整
    - diff >= 5% → 需要审核
    - diff > 20% → 必须复盘(可能系统bug)
    
  4. 盘点报告

    报告内容:
    - 盘点汇总:总商品数、差异数、差异金额
    - 差异TOP 10:差异最大的商品
    - 差异原因分布:损耗X件、丢失Y件
    - 仓库对比:各仓库差异率
    

延伸思考

  1. 如何设计盘点的权限控制(防止作弊)?
  2. 盘点差异过大时如何追责?
  3. 如何设计移动盘点的离线模式?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4857。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-025:如何处理库存的并发更新?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-025
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5058

题干与约束

多个订单同时扣减同一商品库存,如何处理并发冲突,保证库存不超卖?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 多个订单同时扣减同一商品库存,如何处理并发冲突,保证库存不超卖?

答案

问题分析: 并发更新的核心场景:

  1. 秒杀场景:1万人抢100件商品
  2. 正常场景:多个用户同时下单
  3. 分布式场景:多个服务器同时扣减

方案一:数据库行锁(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+Lua100K+最终一致★★★☆☆

推荐方案: 根据场景选择:

  • 普通商品:乐观锁(MySQL)
  • 秒杀商品:Redis+Lua
  • 低并发:悲观锁

实施要点:

  1. Redis高可用

    - Redis主从+哨兵
    - 双机房部署
    - 持久化:AOF every second
    
  2. 库存同步

    Redis → MySQL:
    - 定时任务(每10秒)
    - 批量更新(减少DB压力)
    - 对账纠偏(每小时)
    
  3. 降级方案

    Redis故障 → 降级到MySQL乐观锁
    MySQL故障 → 停止扣减,返回系统繁忙
    
  4. 监控

    - Redis和MySQL库存差异
    - 扣减成功率
    - 扣减耗时P99
    - 并发冲突次数
    

延伸思考

  1. 如何设计秒杀的库存扣减(更极端的高并发)?
  2. 分库分表场景下如何扣减库存?
  3. Redis和MySQL数据不一致如何恢复?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5058。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-026:虚拟库存vs实物库存的差异

元信息

项目内容
题目编号Q-ECOM-SUPPLY-026
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5230

题干与约束

实物商品有物理库存限制,虚拟商品(如充值卡、游戏币)可以无限生成。两者在库存设计上有什么差异?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 实物商品有物理库存限制,虚拟商品(如充值卡、游戏币)可以无限生成。两者在库存设计上有什么差异?

答案

问题分析: 虚拟库存的核心特点:

  1. 可按需生成(理论无限)
  2. 实际受限于供应商配额
  3. 卡密池管理(有卡密才能售卖)
  4. 即时发货(无需物流)

方案一:无限库存模式

核心思想: 虚拟商品库存设为无限大,不限制购买。

设计:

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. 发货给用户

优点:

  • 无需提前准备卡密
  • 按需申请
  • 库存灵活

缺点:

  • 实时性依赖供应商
  • 供应商故障风险

方案对比

方案准确性供应商依赖实施难度适用场景
无限库存★★☆☆☆★★★★★★★★★★可生成虚拟品
卡密池★★★★★★★★☆☆★★★☆☆充值卡、券码
配额模式★★★★☆★★☆☆☆★★★☆☆供应商直连

推荐方案: 根据虚拟商品类型选择:

  • 可生成(游戏币、积分):无限库存
  • 卡密类(充值卡、激活码):卡密池
  • 供应商直连(机票、酒店):配额模式

实施要点:

  1. 卡密安全

    - 卡密加密存储(AES-256)
    - 卡密传输加密(HTTPS)
    - 卡密脱敏展示(**** **** **** 1234)
    - 限制查询频率(防止批量获取)
    
  2. 卡密补货

    补货触发:
    - 可用卡密 < 安全阈值(如1000张)
    - 自动告警
    
    补货方式:
    - 供应商API自动拉取
    - 或人工Excel导入
    
  3. 卡密有效期

    过期处理:
    - 定时任务扫描过期卡密
    - 状态更新为INVALID
    - 库存减少(不可售)
    - 向供应商申请补卡
    
  4. 虚拟发货

    自动发货:
    - 支付成功 → 立即分配卡密
    - 推送给用户(短信/App)
    - 订单状态 → COMPLETED
    
    发货耗时:< 30秒
    

延伸思考

  1. 卡密被盗用如何防范?
  2. 虚拟商品是否需要支持退款?
  3. 供应商配额不足时如何处理?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5230。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-027:多仓库场景下的库存分配策略

元信息

项目内容
题目编号Q-ECOM-SUPPLY-027
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5465

题干与约束

电商平台有5个仓库(华北、华东、华南、西南、西北),用户下单时如何选择仓库发货?请设计库存分配策略。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台有5个仓库(华北、华东、华南、西南、西北),用户下单时如何选择仓库发货?请设计库存分配策略。

答案

问题分析: 仓库选择的核心考量:

  1. 配送时效:就近仓库配送快
  2. 运费成本:距离影响运费
  3. 库存充足度:优先选择库存多的仓库
  4. 仓库负载:避免单仓库压力过大

方案一:就近原则

核心思想: 根据用户地址,选择最近的仓库。

设计:

仓库覆盖范围:
- 北京仓:北京、天津、河北
- 上海仓:上海、江苏、浙江
- 深圳仓:广东、广西、福建
- 成都仓:四川、重庆、云南
- 西安仓:陕西、甘肃、新疆

路由逻辑:
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. 目标仓库入库

优点:

  • 库存均衡,利用率高
  • 减少缺货
  • 优化全局

缺点:

  • 调拨成本高
  • 调拨周期长(天级)
  • 需要预测算法

方案对比

方案配送时效成本库存利用率复杂度
就近原则★★★★★★★★★☆★★★☆☆★★★★★
智能调度★★★★☆★★★★★★★★★☆★★★☆☆
均衡策略★★★☆☆★★★☆☆★★★★★★★☆☆☆

推荐方案: 采用智能调度

实施要点:

  1. 仓库路由服务

    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);
    }
    
  2. 拆单策略

    场景:用户购买商品A、B、C
    - 商品A:北京仓有货
    - 商品B:上海仓有货
    - 商品C:两个仓库都有货
    
    策略1:优先合单
    - 查找能满足所有商品的仓库
    - 减少拆单,降低运费
    
    策略2:就近发货
    - 每个商品从最近仓库发货
    - 可能拆多单,但配送快
    
    策略3:混合
    - 大件商品就近发货
    - 小件商品合单发货
    
  3. 库存预测

    预测模型:
    - 输入:历史销量、季节、促销活动
    - 输出:未来7天各仓库销量预测
    
    预分配:
    - 根据预测提前调拨库存
    - 避免大促时调拨来不及
    
  4. 负载均衡

    仓库容量管理:
    - 每个仓库设置日处理能力(如1万单/天)
    - 接近容量时降低选择权重
    - 超过容量时停止分配
    
    动态调整:
    - 实时监控各仓库订单量
    - 动态调整路由权重
    

延伸思考

  1. 如何处理跨仓拆单的运费计算?
  2. 用户能否指定发货仓库?
  3. 仓库之间如何协同(库存调拨、应急支援)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5465。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-028:如何设计库存安全水位和补货机制?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-028
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5676

题干与约束

电商系统需要设置库存安全水位,当库存低于安全水位时自动触发补货。如何设计这套机制?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商系统需要设置库存安全水位,当库存低于安全水位时自动触发补货。如何设计这套机制?

答案

问题分析: 库存安全水位的核心要素:

  1. 安全水位如何设置(太高占用资金,太低容易缺货)
  2. 补货时机和数量
  3. 补货周期(供应商交付时间)
  4. 多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分类

实施要点:

  1. 销量预测模型

    简单移动平均:
    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开源)
    - 考虑季节性、趋势、促销影响
    
  2. 补货决策表

    replenishment_rule
    ├── sku_id
    ├── category(ABC分类)
    ├── safety_stock
    ├── reorder_point
    ├── lead_time(补货周期)
    ├── order_quantity(建议补货量)
    ├── max_stock(最大库存)
    └── updated_at
    
  3. 自动补货流程

    定时任务(每天凌晨):
    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
    
  4. 补货优先级

    优先级计算:
    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)
    - 高销量
    - 高利润
    
  5. 监控告警

    告警条件:
    - 库存 < 安全库存 → 缺货预警
    - 库存 > 最大库存 * 1.5 → 积压告警
    - 补货单超期未到货 → 交付延迟告警
    
    报表:
    - 缺货率(SKU缺货天数 / 总天数)
    - 库存周转率(销量 / 平均库存)
    - 补货及时率(按时到货 / 总补货单)
    

延伸思考

  1. 促销活动前如何调整补货策略?
  2. 供应商交付不稳定如何应对?
  3. 新品如何设置安全库存(无历史数据)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5676。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-029:库存快照在订单中的应用

元信息

项目内容
题目编号Q-ECOM-SUPPLY-029
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5893

题干与约束

订单下单时需要记录当时的库存状态,用于售后和数据分析。如何设计库存快照机制?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 订单下单时需要记录当时的库存状态,用于售后和数据分析。如何设计库存快照机制?

答案

问题分析: 库存快照的核心目的:

  1. 售后分析(为何超卖、缺货)
  2. 数据审计(库存变更追溯)
  3. 报表统计(某时刻库存状态)
  4. 性能要求(不能影响下单)

方案一:订单表冗余库存字段

核心思想: 在订单表记录下单时的库存数量。

设计:

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

优点:

  • 平衡性能和存储
  • 快照恢复快
  • 审计能力强

缺点:

  • 实现复杂度中等

方案对比

方案查询性能存储成本审计能力实施难度
冗余字段★★★★★★★★★★★★☆☆☆★★★★★
变更日志★★★☆☆★★☆☆☆★★★★★★★★☆☆
快照+日志★★★★☆★★★★☆★★★★★★★★☆☆

推荐方案: 采用定期快照+增量日志

实施要点:

  1. 快照生成策略

    定时快照:
    - 每小时生成一次快照
    - 或库存变更超过1000次时生成
    
    快照内容:
    - SKU ID
    - 物理库存
    - 预占库存
    - 已售库存
    - 可售库存
    - 快照时间
    
  2. 变更日志记录

    @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;
      }
    }
    
  3. 历史库存查询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"
    }
    
  4. 数据归档

    归档策略:
    - 变更日志保留90天
    - 90天后归档到对象存储(OSS)
    - 快照保留1年
    - 1年后删除(保留年度快照)
    
  5. 应用场景

    场景1:售后分析
    用户投诉超卖 → 查询下单时库存 → 分析扣减日志 → 定位问题
    
    场景2:数据对账
    每日对账:今日库存 = 昨日库存 + 今日入库 - 今日出库
    不一致 → 查询变更日志 → 找出差异
    
    场景3:报表统计
    生成"每日库存报表" → 查询每日0点快照 → 生成报表
    

延伸思考

  1. 如何设计库存变更的审计流程?
  2. 变更日志如何支持回滚操作?
  3. 大批量商品的快照如何优化存储?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:5893。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-030:库存的实时性vs一致性权衡

元信息

项目内容
题目编号Q-ECOM-SUPPLY-030
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6111

题干与约束

库存系统中,Redis提供高性能但可能丢失数据,MySQL提供强一致但性能较低。如何在实时性和一致性之间权衡?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 库存系统中,Redis提供高性能但可能丢失数据,MySQL提供强一致但性能较低。如何在实时性和一致性之间权衡?

答案

问题分析: 实时性vs一致性的核心矛盾:

  1. 用户期望实时看到库存
  2. 系统要保证不超卖
  3. 高并发下性能压力大
  4. 数据一致性难保证

方案一:强一致性优先(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();
}

优点:

  • 灵活权衡
  • 性能和一致性兼顾
  • 差异化服务

缺点:

  • 实现复杂
  • 需要商品分类

方案对比

方案一致性性能实现难度适用场景
强一致★★★★★★★☆☆☆★★★★☆高价商品
最终一致★★★☆☆★★★★★★★★☆☆秒杀
分层一致★★★★☆★★★★☆★★☆☆☆综合场景

推荐方案: 采用分层一致性

实施要点:

  1. 一致性级别定义

    强一致(Strong Consistency):
    - MySQL事务
    - 悲观锁或串行化
    - 实时一致
    
    最终一致(Eventual Consistency):
    - Redis扣减 + 异步同步
    - 秒级延迟
    - 需要对账
    
    因果一致(Causal Consistency):
    - 同一用户操作有序
    - 不同用户可能看到不同状态
    
  2. 降级策略

    正常模式:
    - 秒杀商品:Redis(最终一致)
    - 普通商品:MySQL乐观锁(强一致)
    
    降级模式(Redis故障):
    - 秒杀商品:暂停售卖或限流到MySQL
    - 普通商品:MySQL悲观锁
    
    极端模式(MySQL故障):
    - 只读Redis,禁止扣减
    - 提示用户稍后再试
    
  3. 一致性检查

    实时检查:
    - 扣减后检查Redis和MySQL差异
    - 差异 > 阈值(如100)→ 告警
    
    定期对账:
    - 每小时全量对账
    - 自动纠正小差异(< 5)
    - 大差异(> 10)→ 人工介入
    
  4. 监控指标

    一致性指标:
    - Redis-MySQL差异数量
    - 差异持续时间
    - 对账修复次数
    
    性能指标:
    - 扣减TPS
    - 扣减耗时P99
    - Redis命中率
    

延伸思考

  1. 如何设计Redis的持久化策略(AOF/RDB)?
  2. 分布式场景下如何保证Redis和MySQL一致性?
  3. CAP理论在库存系统中如何权衡?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6111。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-031:库存回滚机制的设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-031
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6318

题干与约束

用户下单后未支付,或者订单取消,需要回滚库存。如何设计库存回滚机制,保证幂等性和正确性?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户下单后未支付,或者订单取消,需要回滚库存。如何设计库存回滚机制,保证幂等性和正确性?

答案

问题分析: 库存回滚的核心场景:

  1. 订单取消(用户主动取消)
  2. 超时未支付(30分钟自动取消)
  3. 支付失败(扣款失败)
  4. 售后退货(订单完成后退货)

核心挑战:

  1. 幂等性:重复回滚不能多加库存
  2. 并发安全:多个回滚请求同时执行
  3. 部分回滚:一单多商品部分退货
  4. 补偿机制:回滚失败如何处理

方案一:直接加库存

核心思想: 取消订单时直接增加库存。

实现:

-- 订单取消
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();
}

优点:

  • 幂等性强
  • 可追溯
  • 支持重试
  • 审计友好

缺点:

  • 实现复杂度高
  • 需要额外表

方案对比

方案幂等性并发安全可追溯实施难度
直接加库存★☆☆☆☆★★☆☆☆★☆☆☆☆★★★★★
基于状态★★★★☆★★★★☆★★★☆☆★★★☆☆
回滚记录★★★★★★★★★★★★★★★★★☆☆☆

推荐方案: 采用回滚记录表

实施要点:

  1. 回滚类型设计

    CANCEL:订单取消
    - 释放预占库存
    - 回补可售库存
    
    REFUND:售后退货
    - 增加物理库存
    - 增加可售库存
    
    TIMEOUT:超时未支付
    - 释放预占库存
    
    ADJUST:库存调整(人工)
    
  2. 回滚执行逻辑

    @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. 部分退货处理

    场景:用户购买3件商品,退货1件
    
    处理:
    1. 创建部分回滚记录
    2. 回滚数量 = 退货数量(1件)
    3. 更新订单项状态(2件已发货,1件已退货)
    
  4. 失败重试

    补偿Worker:
    1. 定时扫描FAILED状态的回滚记录
    2. 重试执行回滚
    3. 最多重试5次
    4. 仍失败 → 转人工处理
    
  5. 监控告警

    指标:
    - 回滚成功率
    - 回滚延迟(下单到回滚的时间)
    - 失败回滚数量
    
    告警:
    - 回滚成功率 < 99%
    - 失败回滚 > 100条
    

延伸思考

  1. 如何防止恶意下单占用库存?
  2. 库存回滚失败如何人工介入?
  3. 大批量订单取消如何优化回滚性能?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6318。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-032:跨境电商的库存管理(多国库存)

元信息

项目内容
题目编号Q-ECOM-SUPPLY-032
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6540

题干与约束

跨境电商在中国、美国、欧洲都有仓库,同一商品在不同地区有库存。如何设计全球库存管理系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 跨境电商在中国、美国、欧洲都有仓库,同一商品在不同地区有库存。如何设计全球库存管理系统?

答案

问题分析: 跨境库存的核心挑战:

  1. 时区差异(中国和美国相差12小时)
  2. 币种不同(人民币、美元、欧元)
  3. 清关周期长(跨境物流10-30天)
  4. 库存调拨困难

方案一:独立库存池

核心思想: 每个国家/地区独立管理库存,互不共享。

设计:

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. 都无货 → 缺货

优点:

  • 平衡速度和成本
  • 灵活
  • 用户可选

缺点:

  • 需要智能路由
  • 用户决策成本

方案对比

方案库存利用率配送速度用户体验实施难度
独立池★★☆☆☆★★★★★★★★☆☆★★★★★
全球池★★★★★★★☆☆☆★★★★★★★★☆☆
混合模式★★★★☆★★★★☆★★★★☆★★☆☆☆

推荐方案: 采用混合模式

实施要点:

  1. 库存数据结构

    global_inventory
    ├── sku_id
    ├── region_code(US/CN/EU/JP)
    ├── warehouse_id
    ├── stock
    ├── currency
    ├── local_price(本地售价)
    └── shipping_cost_to_other(跨境运费)
    
  2. 库存分配策略

    初始分配(新品上架):
    - 根据各地区历史销量预测
    - US: 40%, EU: 30%, CN: 20%, JP: 10%
    
    动态调整(运营中):
    - 每周根据销量调整
    - 滞销地区调拨到热销地区
    
  3. 跨境发货流程

    用户下单:
    1. 显示配送选项:
       - 本地发货(2-3天,免运费)
       - 跨境发货(10-15天,运费$20)
    
    2. 用户选择跨境发货
    
    3. 扣减源国库存
    
    4. 清关、物流
    
    5. 配送到用户
    
  4. 币种和价格

    价格策略:
    - 每个地区独立定价(考虑关税、运费)
    - 实时汇率转换
    
    示例:
    商品成本:$100
    - 美国售价:$150(含税15%,利润$35)
    - 中国售价:¥1200(含税13%,利润约$40)
    - 欧洲售价:€140(含税20%,利润约$30)
    
  5. 库存同步

    同步机制:
    - 各地区库存独立数据库
    - 聚合到全球视图(Redis缓存)
    - 更新延迟 < 1秒
    
    时区处理:
    - 所有时间戳使用UTC
    - 本地展示转换为用户时区
    

延伸思考

  1. 如何设计跨境库存调拨的审批流程?
  2. 清关失败如何处理库存回滚?
  3. 不同国家的退货政策如何影响库存管理?


评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6540。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-033:设计支持多种促销规则的价格计算引擎

元信息

项目内容
题目编号Q-ECOM-SUPPLY-033
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模营销规则权益核销
场景标签商品供给营销活动
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6748

题干与约束

电商平台有多种促销(满减、折扣、优惠券、满赠、阶梯价),用户下单时需要计算最终价格。如何设计灵活的价格计算引擎?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台有多种促销(满减、折扣、优惠券、满赠、阶梯价),用户下单时需要计算最终价格。如何设计灵活的价格计算引擎?

答案

问题分析: 价格计算的核心挑战:

  1. 规则类型多(满减、折扣、优惠券、积分抵扣)
  2. 规则可组合(同时使用多种优惠)
  3. 优先级和互斥(有些优惠不能同时用)
  4. 实时计算性能

方案一:硬编码规则

核心思想: 在代码中直接编写每种促销规则的计算逻辑。

实现:

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();
}

优点:

  • 极致灵活(可写任意逻辑)
  • 无需发布代码

缺点:

  • 安全风险(脚本注入)
  • 调试困难
  • 性能开销大

方案对比

方案灵活性性能运营友好安全性实施难度
硬编码★★☆☆☆★★★★★★☆☆☆☆★★★★★★★★★★
规则引擎★★★★☆★★★★☆★★★★★★★★★☆★★★☆☆
脚本引擎★★★★★★★★☆☆★★★☆☆★★☆☆☆★★☆☆☆

推荐方案: 采用规则引擎

实施要点:

  1. 规则抽象

    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);
      }
    }
    
  2. 规则组合策略

    互斥规则:
    - 满减和折扣互斥(选优惠力度大的)
    - 用户只能使用一张优惠券
    
    可叠加规则:
    - 满减 + 积分抵扣
    - 会员折扣 + 优惠券
    
    执行顺序:
    1. 商品级促销(商品折扣)
    2. 订单级促销(满减)
    3. 用户级促销(会员折扣)
    4. 优惠券
    5. 积分抵扣
    
  3. 价格明细

    原价:¥500
    - 商品折扣:-¥50(9折)
    - 满减优惠:-¥30(满200减30)
    - 会员折扣:-¥42(额外9折)
    - 优惠券:-¥20
    = 实付:¥358
    
    用户可见每项优惠的金额
    
  4. 性能优化

    规则缓存:
    - 缓存活跃的促销规则(Redis)
    - TTL 5分钟
    - 规则变更时主动刷新
    
    批量计算:
    - 购物车多商品批量计算
    - 减少数据库查询
    
  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
    }
    

延伸思考

  1. 如何设计促销规则的AB测试?
  2. 多种促销组合时如何选择最优组合?
  3. 促销规则变更如何保证已下单的订单价格不变?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:6748。本章不依赖旧 Part Four 文件链接。

相关章节:营销系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-034:优惠券系统的设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-034
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模营销规则权益核销
场景标签商品供给营销活动
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7049

题干与约束

电商平台需要支持优惠券(满减券、折扣券、品类券)。如何设计优惠券系统,包括发放、使用、核销?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台需要支持优惠券(满减券、折扣券、品类券)。如何设计优惠券系统,包括发放、使用、核销?

答案

问题分析: 优惠券的核心要素:

  1. 发放方式(批量发放、用户领取、定向发放)
  2. 使用规则(满减、折扣、品类限制、商品限制)
  3. 并发领取(秒杀券,1万人抢100张)
  4. 防刷机制(防止用户重复领取)

方案一:简单优惠券

核心思想: 优惠券模板+用户优惠券实例。

设计:

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. 领券流程

    用户点击"领取":
    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();
    }
    
  2. 用券流程

    下单时使用优惠券:
    1. 检查优惠券是否属于当前用户
    2. 检查优惠券状态(UNUSED)
    3. 检查是否过期
    4. 检查订单是否满足使用条件(品类、金额)
    5. 锁定优惠券(防止重复使用)
    6. 计算优惠金额
    
    支付成功:
    - 核销优惠券(status=USED)
    
    订单取消:
    - 释放优惠券(status=UNUSED, lock_order_id=NULL)
    
  3. 防刷策略

    策略1:用户限制
    - 每人限领1张
    - 同一手机号/设备ID限领
    
    策略2:行为检测
    - 短时间多次领取 → 拉黑
    - 领取后不使用 → 降低权重
    
    策略3:风控
    - 新注册用户限制
    - 异常IP拦截
    
  4. 券叠加规则

    规则:
    - 单笔订单最多使用1张优惠券
    - 优惠券和满减活动可叠加
    - 优惠券和积分抵扣可叠加
    
    选券策略:
    - 自动选择优惠最大的券
    - 或用户手动选择
    
  5. 券过期处理

    定时任务(每天凌晨):
    1. 扫描即将过期的券(expire_at < NOW() + 3天)
    2. 发送提醒通知(App推送、短信)
    3. 扫描已过期的券
    4. 状态更新为EXPIRED
    

延伸思考

  1. 如何设计优惠券的转赠功能?
  2. 优惠券如何支持多次使用(如月卡券)?
  3. 如何设计优惠券的效果分析(发放ROI)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7049。本章不依赖旧 Part Four 文件链接。

相关章节:营销系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-035:阶梯价和批发价的设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-035
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7280

题干与约束

电商平台支持批发场景,购买数量越多价格越低(如买1件100元,买10件90元,买100件80元)。如何设计阶梯价系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台支持批发场景,购买数量越多价格越低(如买1件100元,买10件90元,买100件80元)。如何设计阶梯价系统?

答案

问题分析: 阶梯价的核心要素:

  1. 阶梯定义(数量区间和对应价格)
  2. 混合SKU计算(多个商品如何累计数量)
  3. 拆单问题(阶梯内和阶梯外商品分开发货)
  4. 实时计算性能

方案一: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级★★☆☆☆★★☆☆☆★★★★★零售
品类级★★★★☆★★★★☆★★★☆☆批发
订单级★★★★★★★★★★★★★★☆混合

推荐方案: 采用订单级阶梯价

实施要点:

  1. 阶梯计算引擎

    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());
      }
    }
    
  2. 实时试算

    购物车实时显示:
    - 当前数量:8件
    - 当前价格:¥1000
    - 提示:"再买2件,享受95折,可省¥50"
    
    动态提示:
    引导用户凑单,提高客单价
    
  3. 拆单策略

    场景:用户购买120件商品
    - 100件享受阶梯价(¥80/件)
    - 20件普通价(¥100/件)
    
    方案A:不拆单
    - 所有商品按最高阶梯价
    - 用户体验好
    
    方案B:拆单
    - 100件一单,20件一单
    - 复杂,不推荐
    
  4. 会员叠加

    规则:
    - 阶梯价和会员折扣可叠加
    - 先应用阶梯价,再应用会员折扣
    
    示例:
    原价:¥10000
    阶梯价(95折):¥9500
    会员折扣(98折):¥9310
    
  5. 报表分析

    阶梯价效果分析:
    - 各阶梯成交订单数
    - 平均客单价提升
    - 转化率(凑单率)
    

延伸思考

  1. 阶梯价如何与优惠券组合?
  2. 用户退货部分商品如何重新计算价格?
  3. 大促期间阶梯价如何调整?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7280。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-036:会员等级和积分体系的设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-036
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7494

题干与约束

电商平台有会员体系(普通、银卡、金卡、钻石),不同等级享受不同权益(折扣、包邮、专属客服)。如何设计会员和积分系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台有会员体系(普通、银卡、金卡、钻石),不同等级享受不同权益(折扣、包邮、专属客服)。如何设计会员和积分系统?

答案

问题分析: 会员体系的核心要素:

  1. 等级划分(如何升降级)
  2. 权益设计(不同等级的差异化权益)
  3. 积分规则(获取、消耗、过期)
  4. 成长值体系(区分消费积分和成长值)

方案一:简单会员(单一积分)

核心思想: 只有积分,达到一定积分自动升级。

设计:

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积分
    - 会员等级倍率(金卡1.5倍)
    
    行为积分:
    - 签到:5积分/天
    - 分享:10积分/次
    - 评价:20积分/次(带图50积分)
    - 首次购买:100积分
    
  2. 积分消费

    抵扣规则:
    - 100积分 = 1元
    - 单笔订单最多抵扣订单金额的50%
    - 部分品类不支持积分抵扣(如iPhone)
    
    兑换商品:
    - 积分商城
    - 固定积分兑换商品
    
  3. 等级维护

    升级:
    - 成长值达到阈值立即升级
    - 发送升级通知
    
    降级:
    - 每年12月31日统计年度成长值
    - 未达标的会员降级
    - 降级前1个月提醒
    - 保级活动(充值、消费保级)
    
  4. 积分过期

    策略:
    - 积分有效期1年
    - 每年12月31日清零即将过期积分
    - 提前3个月、1个月、1周提醒
    
  5. 防刷策略

    - 签到积分:每天限1次
    - 分享积分:每天限3次
    - 评价积分:每订单限1次
    - 异常行为检测(短时间大量操作)
    

延伸思考

  1. 如何设计会员等级的有效期(年度会员)?
  2. 积分如何支持转赠功能?
  3. 会员权益如何动态调整(AB测试)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7494。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-037:动态定价系统的设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-037
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模价格建模规则计算
场景标签商品供给价格试算
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7905

题干与约束

电商平台希望实现动态定价(如机票、酒店根据供需实时调价)。如何设计动态定价系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台希望实现动态定价(如机票、酒店根据供需实时调价)。如何设计动态定价系统?

答案

问题分析: 动态定价的核心要素:

  1. 定价因子(库存、时间、竞争对手、需求)
  2. 定价策略(规则还是算法)
  3. 价格变动频率
  4. 用户体验(频繁变价影响用户信任)

方案一:规则引擎定价

核心思想: 根据预设规则调整价格。

规则示例:

规则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测试★★★★☆★★★★☆★★★★☆新品

推荐方案: 采用规则引擎+算法定价的混合方案。

实施要点:

  1. 定价数据收集

    price_history(价格历史)
    ├── sku_id
    ├── price
    ├── stock
    ├── sales_quantity(该价格下的销量)
    ├── conversion_rate(转化率)
    ├── start_time
    └── end_time
    
    competitor_price(竞对价格)
    ├── sku_id
    ├── competitor_name
    ├── price
    ├── crawled_at
    └── ...
    
  2. 定价决策流程

    定时任务(每小时):
    1. 收集数据(库存、销量、竞对价格)
    2. 输入定价模型
    3. 模型输出推荐价格
    4. 人工审核(可选)
    5. 更新商品价格
    6. 记录价格变更日志
    
  3. 价格锁定

    用户加购物车:
    - 锁定当前价格15分钟
    - 15分钟内下单按锁定价
    - 超时按最新价
    
    或:
    - 不锁定价格
    - 下单时实时计算(用户体验差)
    
  4. 价格展示

    对用户:
    - 显示当前价
    - 历史最低价(增加紧迫感)
    - 降价通知(用户订阅)
    
    对运营:
    - 价格趋势图
    - 竞对价格对比
    - 销量-价格关系
    
  5. 价格保护

    规则:
    - 单次调价幅度 <= 20%
    - 每天最多调价3次
    - 价格不低于成本价 × 1.1(保证毛利)
    - 价格不高于市场价 × 1.5(防止离谱)
    

延伸思考

  1. 如何处理用户对频繁变价的不满?
  2. 价格歧视(同一商品不同用户不同价)的法律风险?
  3. 如何设计价格保护机制(买贵退差价)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7905。本章不依赖旧 Part Four 文件链接。

相关章节:计价系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-038:跨境电商的汇率和税费计算

元信息

项目内容
题目编号Q-ECOM-SUPPLY-038
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8102

题干与约束

跨境电商需要处理多币种和不同国家的税费。如何设计汇率转换和税费计算系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 跨境电商需要处理多币种和不同国家的税费。如何设计汇率转换和税费计算系统?

答案

问题分析: 跨境价格的核心要素:

  1. 汇率实时变动
  2. 不同国家税率不同(关税、增值税)
  3. 币种展示(用户看到本地币种)
  4. 结算币种(实际收款币种)

方案一:实时汇率

核心思想: 每次计算价格时查询实时汇率。

设计:

价格计算:
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

优点:

  • 平衡稳定性和准确性
  • 减少价格变化频率

缺点:

  • 实现复杂度高

方案对比

方案准确性稳定性用户体验实施难度
实时汇率★★★★★★★☆☆☆★★☆☆☆★★★☆☆
固定汇率★★★☆☆★★★★★★★★★★★★★★☆
浮动区间★★★★☆★★★★☆★★★★☆★★☆☆☆

推荐方案: 采用固定汇率(每日更新)

实施要点:

  1. 汇率管理

    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);
      }
    }
    
  2. 税费计算

    tax_rule(税费规则)
    ├── country_code
    ├── category_id
    ├── import_duty_rate(关税率)
    ├── vat_rate(增值税率)
    ├── min_tax_free_amount(免税额)
    └── ...
    
    示例:
    中国:
    - 关税:10%
    - 增值税:13%
    - 免税额:¥5000以下免税
    
    美国:
    - 关税:0%
    - 州税:0-10%(各州不同)
    
  3. 价格展示

    商品页展示:
    - 商品价格:$100
    - 运费:$20
    - 关税:$10(预估)
    - 总计:$130(约¥936)
    
    结算页:
    - 确认最终价格(包含税费)
    - 币种选择(CNY/USD)
    
  4. 结算币种

    策略1:统一结算币种
    - 平台统一收USD
    - 用户支付CNY → 银行自动换汇
    
    策略2:多币种账户
    - 平台有USD、CNY、EUR账户
    - 用户付CNY → 直接入CNY账户
    - 减少汇兑成本
    
  5. 汇率风险对冲

    风险:
    - 用户下单时汇率7.2
    - 商家收款时汇率7.0
    - 平台损失2%
    
    对冲策略:
    - 购买外汇期货
    - 设置汇率浮动保护(±1%)
    - 及时结汇
    

延伸思考

  1. 如何设计多币种支付(用户用USD支付CNY订单)?
  2. 汇率变化导致退款金额不一致如何处理?
  3. 跨境税费如何合规申报?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8102。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-039:组合促销的价格计算(满减+折扣+券)

元信息

项目内容
题目编号Q-ECOM-SUPPLY-039
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模营销规则权益核销
场景标签商品供给营销活动
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8304

题干与约束

用户下单时同时享受满减(满200减30)、商品折扣(9折)、优惠券(20元)。如何设计组合促销的价格计算逻辑?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户下单时同时享受满减(满200减30)、商品折扣(9折)、优惠券(20元)。如何设计组合促销的价格计算逻辑?

答案

问题分析: 组合促销的核心挑战:

  1. 计算顺序(先满减还是先折扣影响最终价)
  2. 规则冲突(有些促销不能同时用)
  3. 最优组合(如何选择让用户优惠最大)
  4. 性能(实时计算)

方案一:固定计算顺序

核心思想: 规定促销的固定计算顺序。

计算顺序:

原价:¥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

优点:

  • 平衡灵活性和性能
  • 运营可配置

缺点:

  • 可能不是全局最优

方案对比

方案最优性性能灵活性实施难度
固定顺序★★☆☆☆★★★★★★★☆☆☆★★★★★
最优组合★★★★★★★★☆☆★★★★★★★☆☆☆
优先级+互斥★★★★☆★★★★☆★★★★☆★★★☆☆

推荐方案: 采用优先级+互斥,必要时计算最优组合。

实施要点:

  1. 促销配置

    promotion
    ├── promotion_id
    ├── name
    ├── type(DISCOUNT/FULL_REDUCE/COUPON)
    ├── priority(优先级)
    ├── exclusive_group(互斥组,NULL表示可叠加)
    ├── stackable(是否可叠加)
    └── ...
    
  2. 价格明细

    订单价格明细:
    {
      "originalPrice": 500,
      "appliedPromotions": [
        {
          "name": "商品9折",
          "discountAmount": 50,
          "afterPrice": 450
        },
        {
          "name": "满200减30",
          "discountAmount": 30,
          "afterPrice": 420
        },
        {
          "name": "优惠券",
          "discountAmount": 20,
          "afterPrice": 400
        }
      ],
      "finalPrice": 400
    }
    
    用户可见每一步的优惠
    
  3. 试算接口

    POST /api/price/preview
    {
      "items": [...],
      "promotions": [...],
      "coupon": "SUMMER20"
    }
    
    响应:
    {
      "scenarios": [
        {
          "name": "推荐方案",
          "finalPrice": 400,
          "savings": 100,
          "appliedPromotions": [...]
        },
        {
          "name": "仅用优惠券",
          "finalPrice": 480,
          "savings": 20,
          "appliedPromotions": [...]
        }
      ]
    }
    
    让用户选择方案
    
  4. 性能优化

    缓存:
    key: price:calculate:{商品ID}:{促销IDs哈希}
    value: 计算结果
    TTL: 5分钟
    
    避免重复计算
    

延伸思考

  1. 如何向用户推荐最优促销组合?
  2. 促销规则变更如何保证已下单的订单价格不变?
  3. 如何设计促销的AB测试?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8304。本章不依赖旧 Part Four 文件链接。

相关章节:营销系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-040:预售和定金膨胀的设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-040
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8540

题干与约束

预售活动中,用户支付定金(如50元),尾款时定金可抵100元。如何设计预售和定金膨胀系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 预售活动中,用户支付定金(如50元),尾款时定金可抵100元。如何设计预售和定金膨胀系统?

答案

问题分析: 预售定金的核心要素:

  1. 定金不可退(锁定用户)
  2. 定金膨胀(50元抵100元)
  3. 尾款支付期限(超时定金不退)
  4. 库存预占

方案一:双订单模式

核心思想: 定金订单和尾款订单分开。

设计:

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. 实付 = 商品价格 - 抵扣券金额

优点:

  • 复用现有优惠券系统
  • 灵活

缺点:

  • 定金和尾款割裂
  • 用户可能不理解

方案对比

方案清晰度实施难度用户体验适用场景
双订单★★★☆☆★★★☆☆★★★☆☆复杂预售
单订单分阶段★★★★★★★★★☆★★★★★通用
虚拟商品★★☆☆☆★★★★☆★★☆☆☆简单预售

推荐方案: 采用单订单分阶段

实施要点:

  1. 定金膨胀计算

    商品总价:¥999
    定金:¥50
    定金膨胀:¥100(2倍膨胀)
    尾款:¥999 - ¥100 = ¥899
    
    用户总共支付:¥50 + ¥899 = ¥949(省¥50)
    
  2. 库存管理

    定金支付成功:
    - 预占库存(reserved_stock +1)
    - 锁定到该订单
    
    尾款支付成功:
    - 确认库存(sold_stock +1, reserved_stock -1)
    
    超时未付尾款:
    - 释放库存(reserved_stock -1)
    - 定金不退
    
  3. 尾款提醒

    提醒策略:
    - 尾款开始:立即推送
    - 尾款截止前3天:提醒
    - 尾款截止前1天:紧急提醒
    - 尾款截止前1小时:最后提醒
    
    提醒渠道:
    - App推送
    - 短信
    - 站内信
    
  4. 超时处理

    定时任务(每小时):
    1. 扫描超时未付尾款的订单
    2. 订单状态 → CLOSED
    3. 释放库存
    4. 定金记为平台收入(不退)
    5. 通知用户
    
  5. 退款规则

    规则:
    - 支付定金后,不可取消订单
    - 定金不退
    - 尾款支付后,可申请退款
    - 退款金额 = 定金 + 尾款
    

延伸思考

  1. 如何防止用户恶意付定金占用库存?
  2. 预售商品如何设置发货时间?
  3. 定金膨胀活动如何设计ROI分析?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8540。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-041:价格歧视与个性化定价的设计

元信息

项目内容
题目编号Q-ECOM-SUPPLY-041
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模价格建模规则计算
场景标签商品供给价格试算
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8751

题干与约束

电商平台希望根据用户画像(新老用户、购买力、价格敏感度)实现个性化定价。如何设计价格歧视系统?同时如何规避法律风险?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台希望根据用户画像(新老用户、购买力、价格敏感度)实现个性化定价。如何设计价格歧视系统?同时如何规避法律风险?

答案

问题分析: 个性化定价的核心要素:

  1. 用户分层(高价值、普通、价格敏感)
  2. 定价策略(不同用户看到不同价格)
  3. 法律风险(价格歧视在某些国家违法)
  4. 用户信任(发现差价后的负面影响)

方案一:明面价格歧视(不推荐)

核心思想: 不同用户直接看到不同价格。

示例:

用户A(新用户):¥99
用户B(老用户):¥129
用户C(高价值用户):¥149

价格查询:
price = getPriceByUser(skuId, userId);

优点:

  • 简单直接
  • 收益最大化

缺点:

  • 法律风险大(违反价格法)
  • 用户信任崩塌(发现后口碑崩盘)
  • 媒体曝光风险

方案二:差异化优惠(推荐)

核心思想: 价格统一,但不同用户获得不同优惠。

设计:

基础价格:统一¥129

新用户:
- 新人专享券:¥30
- 实付:¥99

普通用户:
- 无优惠
- 实付:¥129

高价值用户:
- 会员折扣:9折
- 实付:¥116

关键:
- 价格统一展示
- 优惠透明(标注"新人专享"、"会员专享")

优点:

  • 合法合规
  • 用户可接受
  • 价格透明

缺点:

  • 收益优化程度不如价格歧视

方案三:隐性定价(灰色地带)

核心思想: 通过算法展示不同的商品推荐和排序。

策略:

高价值用户:
- 推荐高价商品
- 搜索结果优先展示高价商品

价格敏感用户:
- 推荐促销商品
- 搜索结果优先展示低价商品

不直接改价格,但影响用户选择

优点:

  • 间接影响购买
  • 法律风险小

缺点:

  • 效果不如直接定价
  • 算法复杂

方案对比

方案收益合规性用户信任风险
明面歧视★★★★★★☆☆☆☆★☆☆☆☆★★★★★
差异化优惠★★★★☆★★★★★★★★★☆★★☆☆☆
隐性定价★★★☆☆★★★★☆★★★★☆★★★☆☆

推荐方案: 采用差异化优惠

实施要点:

  1. 用户分层

    基于RFM模型:
    - R(最近一次购买)
    - F(购买频次)
    - M(购买金额)
    
    用户分层:
    - 高价值用户(VIP):R<30天, F>10次, M>1万
    - 活跃用户:R<90天, F>3次
    - 沉睡用户:R>90天
    - 新用户:注册<30天,F=0
    - 价格敏感用户:经常搜索低价、使用优惠券
    
  2. 差异化优惠策略

    新用户:
    - 新人专享券(大额)
    - 首单免运费
    - 新人专区(低价引流商品)
    
    沉睡用户:
    - 唤醒券(定向发放)
    - "好久不见,给你优惠"
    
    高价值用户:
    - 会员折扣
    - 生日礼包
    - 专属客服
    
    价格敏感用户:
    - 推荐促销商品
    - 凑单优惠
    
  3. 透明化展示

    商品页:
    - 价格:¥129(统一价格)
    - 您的优惠:
      ✓ 新人券:-¥30
      ✓ 首单免运费
    - 实付:¥99
    
    标注优惠来源,避免误解
    
  4. 法律合规

    避免:
    - 同一商品同一时间不同价格(价格歧视)
    - 隐藏真实价格
    - 大数据杀熟
    
    合法:
    - 不同用户不同优惠(促销活动)
    - 会员专享价(明确标注)
    - 新人优惠(限定条件)
    
  5. 监控与风控

    监控指标:
    - 用户投诉率(价格差异投诉)
    - 媒体舆情
    - 价格离散度(同商品价格差异)
    
    风控:
    - 价格差异 < 30%
    - 优惠透明化
    - 避免同一用户看到不同价格
    

延伸思考

  1. 如何平衡个性化定价和用户信任?
  2. 用户发现价格差异后如何应对?
  3. 如何设计价格歧视的AB测试(避免法律风险)?



评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:8751。本章不依赖旧 Part Four 文件链接。

相关章节:计价系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-042:商品中心和商品供给平台有什么区别?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-042
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:173

题干与约束

商品中心和商品供给平台有什么区别?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:商品供给平台负责 Draft、Staging、QC、批量导入、库存创建 / 修改运营入口、供应商同步、DLQ 和发布编排;商品中心负责正式商品主数据、交易前契约、发布版本、商品快照和读模型。供给平台是“商品和供给能力如何进入平台”,商品中心是“正式商品如何被交易系统稳定使用”。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:173。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-043:为什么商品正式表不应该有 Draft、QC Pending、Rejected 状态?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-043
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:177

题干与约束

为什么商品正式表不应该有 Draft、QC Pending、Rejected 状态?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:这些是供给流程状态,不是正式商品生命周期状态。新建商品在发布前还没有正式 item_id;编辑商品待审时,线上旧版本仍然有效。如果把流程状态塞进正式商品表,会导致搜索、缓存、订单误读未审核数据。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:177。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-044:为什么需要 Resource,而不是只用 SPU/SKU?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-044
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:181

题干与约束

为什么需要 Resource,而不是只用 SPU/SKU?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:很多数字商品依附于现实或外部资源,例如酒店、门店、活动、运营商、账单机构。Resource 表达资源事实,SPU/SKU 表达商品定义,Offer 表达销售承诺。这样供应商同步可以先沉淀资源,运营再决定如何售卖。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/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/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:185

题干与约束

Product Item、SPU、SKU、Offer 的关系是什么?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:Product Item 是前台商品入口或聚合根;SPU 表达共性商品定义;SKU 表达可下单规格单元;Offer 表达销售条件、渠道、销售期、库存来源、输入、履约和退款规则。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:185。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-046:为什么需要 publish_version?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-046
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:189

题干与约束

为什么需要 publish_version?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:publish_version 用于防止旧编辑覆盖新版本,也用于搜索、缓存、订单按版本处理。编辑发布时要校验 base_publish_version == current_publish_version,否则进入版本冲突。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:189。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-047:订单为什么不能回读最新商品表?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-047
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:193

题干与约束

订单为什么不能回读最新商品表?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:商品会不断修改标题、价格计划、履约参数和退款规则。历史订单必须按照下单时的商品、报价、履约和退款契约解释,所以订单要保存或引用创单时的商品快照。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:193。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-048:商品中心是否保存实时库存和最终成交价?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-048
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模库存一致性并发控制
场景标签商品供给库存履约
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:197

题干与约束

商品中心是否保存实时库存和最终成交价?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:不应该把实时库存和最终成交价作为商品中心唯一事实。商品中心保存库存配置和基础价、Offer 规则;实时库存由库存系统判断,最终成交价由计价系统试算。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:197。本章不依赖旧 Part Four 文件链接。

相关章节:库存系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-049:发布成功后搜索刷新失败怎么办?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-049
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:201

题干与约束

发布成功后搜索刷新失败怎么办?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:不回滚商品发布。商品正式表、版本、快照和 Outbox 同事务写入;搜索刷新通过 Outbox 异步重试,失败进入补偿任务。搜索索引按 publish_version 幂等更新,并通过巡检发现版本落后。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:201。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-050:供应商同步是否直接写商品中心?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-050
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:205

题干与约束

供应商同步是否直接写商品中心?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:不建议。供应商同步先保存 Raw Snapshot,然后标准化、映射、Diff,进入供给平台 Staging 和发布治理。商品中心只接收发布命令和正式结果。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:205。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-051:商品中心如何支持异构品类?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-051
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:209

题干与约束

商品中心如何支持异构品类?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:用类目模板定义 Resource、SPU、SKU、Offer、交易契约和展示字段;核心交易字段结构化,品类核心字段可以用扩展表,长尾展示字段用 JSON,搜索筛选字段进入搜索投影。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:209。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-052:为什么发布事务里不直接刷新缓存和 ES?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-052
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:213

题干与约束

为什么发布事务里不直接刷新缓存和 ES?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:刷新缓存和 ES 是下游投影动作,可能慢、可能失败,不应该放在商品发布事务里。发布事务只写正式表、版本、快照和 Outbox;下游通过事件异步刷新并可补偿。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:213。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-053:商品中心如何排查线上展示旧数据?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-053
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:217

题干与约束

商品中心如何排查线上展示旧数据?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:先查 product_item_tab.current_publish_version,再查 product_snapshot_tabproduct_outbox_event,然后对比 Redis、搜索索引里的 publish_version。如果索引或缓存落后,通过 Outbox 重放或补偿任务刷新。


评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:217。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-054:为什么商品供给与运营平台不能设计成商品中心的后台 CRUD?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-054
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:255

题干与约束

为什么商品供给与运营平台不能设计成商品中心的后台 CRUD?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:商品中心负责正式主数据和查询契约,供给平台负责 Draft、Task、Staging、审核、发布、补偿和审计。后台直接 CRUD 会让未审核数据污染线上,也会造成搜索、缓存、订单快照和发布版本不可追溯。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:255。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-055:商品中心和供给运营平台的边界是什么?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-055
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:259

题干与约束

商品中心和供给运营平台的边界是什么?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:商品中心保存正式 Resource / SPU / SKU / Offer / Rulepublish_version;供给运营平台保存供给流程对象,例如 Draft、Staging、QC、Task、DLQ。供给平台通过发布事务写商品中心,不直接让运营后台改正式表。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:259。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-056:供应商同步和人工上传要分成两套系统吗?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-056
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:263

题干与约束

供应商同步和人工上传要分成两套系统吗?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:不要简单回答“分开”或“不分开”。更准确的设计是:入口层、执行层要分开,标准化之后的治理和发布层要合流

供应商同步和人工上传的执行问题完全不同:

维度人工上传 / 批量导入供应商同步
触发方式用户点击、上传 Excel、保存 Draft定时任务、全量同步、增量同步、Push
数据来源本地运营、商家外部供应商 API / 消息
失败原因字段填错、模板错误、图片不合规超时、限流、5xx、游标失效、字段漂移
进度模型文件行号、导入 item、错误文件city / page / cursor / checkpoint
恢复机制重传文件、失败行重试checkpoint 续跑、lease 抢占、DLQ
证据保存上传文件、表单 payloadRaw 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/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:263。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-057:本地运营上传和商家上传为什么审核策略不同?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-057
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:342

题干与约束

本地运营上传和商家上传为什么审核策略不同?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:本地运营是内部可信来源,默认 AUTO_APPROVE,但仍要走 Validation、Staging、Publish 和 Outbox;商家是外部来源,默认 QC_REQUIRED,QC 通过后才能发布。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:342。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-058:为什么 Draft、Staging、QC、正式 Item 要有不同状态机?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-058
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:348

题干与约束

为什么 Draft、Staging、QC、正式 Item 要有不同状态机?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:它们描述的是不同对象。Draft 是可编辑工作区,Staging 是提交冻结快照,QC 是审核工单,正式 Item 是线上商品资产。把这些状态混在一个字段里,会出现 DRAFT/QC_PENDING/ONLINE/REJECTED 语义混乱。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:348。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-059:新建商品在 Draft 和 QC 阶段还没有正式 item_id,怎么追踪全链路?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-059
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:352

题干与约束

新建商品在 Draft 和 QC 阶段还没有正式 item_id,怎么追踪全链路?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:首次创建时生成 supply_trace_idtemporary_object_key。发布成功后生成正式 item_id,再写 product_supply_object_mapping,后续可以用 item_id 反查 supply_trace_id

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:352。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-060:商品已经在线后再次编辑,哪些 ID 会新建,哪些 ID 会复用?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-060
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:356

题干与约束

商品已经在线后再次编辑,哪些 ID 会新建,哪些 ID 会复用?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:item_idsupply_trace_id 复用;operation_iddraft_idstaging_id、可能的 review_id 都新建;发布成功后 publish_version 递增。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:356。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-061:为什么商家提交 Draft 后要生成不可随意修改的 Staging Ticket?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-061
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:360

题干与约束

为什么商家提交 Draft 后要生成不可随意修改的 Staging Ticket?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:Draft 是工作区,可以反复保存;Staging 是提交证据和审核发布对象。冻结 Staging 可以保证 QC 审核的内容和最终发布内容一致,避免“审核 A,发布 B”。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:360。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-062:Pending 阶段发现内容填错,还能直接编辑吗?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-062
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:364

题干与约束

Pending 阶段发现内容填错,还能直接编辑吗?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:不建议直接编辑待审 Staging。推荐语义是撤回当前 QC 和 Staging,再基于 Staging payload 生成新 Draft,修改后重新提交,生成新的 Staging 和 QC。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:364。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-063:为什么需要 product_qc_reviewproduct_qc_review_item 两张表?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-063
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:370

题干与约束

为什么需要 product_qc_reviewproduct_qc_review_item 两张表?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:product_qc_review 是审核工单主表,记录整体状态、审核人、结论;product_qc_review_item 是审核项明细,记录字段 Diff、风险原因、分项结论和驳回原因。批量任务和字段级驳回都需要明细表。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:370。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-064:product_change_requestproduct_supply_operation_logproduct_field_ownership 分别解决什么问题?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-064
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:374

题干与约束

product_change_requestproduct_supply_operation_logproduct_field_ownership 分别解决什么问题?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:product_change_request 记录改了什么、风险多高、是否需要 QC;product_supply_operation_log 记录从 Draft 到下线全过程发生了什么;product_field_ownership 记录字段由谁主导,防止供应商同步覆盖人工治理结果。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:374。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-065:product_publish_recordproduct_publish_snapshotproduct_change_log 的区别是什么?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-065
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:378

题干与约束

product_publish_recordproduct_publish_snapshotproduct_change_log 的区别是什么?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:publish_record 记录一次发布动作和结果;publish_snapshot 保存发布后的正式商品上下文;change_log 解释正式商品从旧版本到新版本变了哪些字段。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:378。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-066:Publish 背后的实际流程是什么?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-066
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:382

题干与约束

Publish 背后的实际流程是什么?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:发布前校验 Staging 状态、QC 状态、幂等、版本冲突和交易契约完整性;事务内写正式商品表、库存配置、履约退款规则、publish_version、发布快照、变更日志和 Outbox;事务后由搜索、缓存、计价上下文和数据平台消费者异步重建投影。涉及活动配置时,由供给平台发起营销系统命令或营销资格事件,但营销规则、预算和优惠计算仍归营销系统。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:382。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-067:为什么 QC 通过不等于商品已经上线?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-067
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:386

题干与约束

为什么 QC 通过不等于商品已经上线?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:QC 通过只表示允许发布。真正上线还需要 Publish Worker 完成正式表写入、生成发布版本、写 Outbox、刷新搜索缓存,并且商品满足销售时间、库存、渠道和风控可售条件。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:386。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-068:为什么发布前要校验 base_publish_version

元信息

项目内容
题目编号Q-ECOM-SUPPLY-068
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:390

题干与约束

为什么发布前要校验 base_publish_version

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:编辑是基于某个线上版本产生的。如果当前线上版本已经变化,旧 Staging 继续发布会覆盖别人的新修改。发布前做 CAS 版本校验,冲突时进入 VERSION_CONFLICT 并要求重新编辑。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:390。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-069:为什么批量导入不能同步循环写正式表?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-069
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:396

题干与约束

为什么批量导入不能同步循环写正式表?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:批量导入可能有大量数据、行级错误、长耗时和下游压力。应该先创建 product_supply_task,再流式解析文件、生成 task_item、行级校验、部分成功、错误文件和补偿任务。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:396。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-070:Parser Worker 和 Item Worker 为什么要拆开?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-070
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:400

题干与约束

Parser Worker 和 Item Worker 为什么要拆开?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:Parser Worker 只负责文件解析和生成行级 item;Item Worker 负责标准化、校验、Staging、Diff、QC 和发布准备。拆开后解析失败不会污染业务处理,行级处理可以限流、重试和并行。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:400。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-071:product_supply_taskproduct_supply_task_item 的关系是什么?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-071
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:404

题干与约束

product_supply_taskproduct_supply_task_item 的关系是什么?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:Task 是一次供给动作的批次状态,Task Item 是行级或对象级处理单元。Task 状态由 Item 聚合,支持部分成功、部分失败、部分等待 QC,避免一个失败项拖垮整批任务。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:404。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-072:供应商同步为什么需要 Raw Snapshot、Checkpoint 和 DLQ?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-072
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:408

题干与约束

供应商同步为什么需要 Raw Snapshot、Checkpoint 和 DLQ?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:供应商同步是长任务且外部不稳定。Raw Snapshot 用于追溯和回放;Checkpoint 用于断点续跑;DLQ 用于保存字段缺失、映射失败、发布失败等可运营问题单。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:408。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-073:如何保证商品发布后搜索、缓存、计价上下文最终一致?营销活动如何协同?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-073
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:414

题干与约束

如何保证商品发布后搜索、缓存、计价上下文最终一致?营销活动如何协同?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:正式表、发布记录、发布快照和 Outbox 在同一事务内写入;搜索、缓存、计价上下文和数据平台通过 Outbox 异步消费,按 event_idpublish_version 幂等处理,失败进入重试、DLQ 或 product_compensation_task。供给平台不直接写搜索索引、不直接写最终价格,也不直接改订单。营销活动是控制面协同:供给平台可以发起圈品、活动资格和活动绑定命令,营销系统负责活动规则、预算、券、补贴、营销库存和最终优惠计算。订单创建只相信当时的商品、报价、履约和退款快照,不回读最新商品解释历史订单。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:414。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-074:商品供给运营平台、商品生命周期和库存系统如何联动?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-074
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:418

题干与约束

商品供给运营平台、商品生命周期和库存系统如何联动?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考要点:创建和修改库存属于供给运营平台的业务工作,但不属于供给运营平台的数据事实。供给平台是库存变更的控制面,负责表单、导入、审批、任务、错误文件、可售诊断和审计;库存系统是库存事实的数据面,负责 inventory_configinventory_balance、券码池、预占记录、状态机和账本流水。供给平台治理变更是否可以发布,商品生命周期控制正式商品何时在线、下线、结束和封禁,库存系统控制某个范围内是否有可承诺资源,营销系统控制活动、券、补贴、预算和优惠规则。发布成功不等于可售成功,Publish 只写正式商品、发布版本、交易契约和 Outbox;库存通过 CreateInventoryAdjustInventoryImportCodeBatchGenerateCodeBatch 等命令异步创建或调整库存实例,再发布 InventoryReady/InventoryChanged/InventoryFailed。最终由可售投影合成 product_status + inventory_status + price_status + marketing_status + fulfillment_status + channel_policy + risk_status,告诉搜索、缓存和详情页是否可卖。供给后台不能直接改库存余额,也不能直接写营销优惠计算结果;库存系统也不能绕过审核和发布版本直接改商品生命周期。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:418。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-075:库存运营任务为什么要单独建模?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-075
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/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/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:422。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-076:为什么不能把 100 万酒店同步设计成一个单进程长循环?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-076
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:440

题干与约束

为什么不能把 100 万酒店同步设计成一个单进程长循环?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:因为任务运行时间长,中途机器重启、供应商超时、进程发布、网络抖动的概率都很高。单进程长循环的进度通常在内存里,失败后只能从头跑,排查也困难。更合理的设计是 Task + Batch + Checkpoint + DLQ:任务先落库,执行过程持续推进 checkpoint,失败数据进入 DLQ,机器重启后从 checkpoint 恢复。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:440。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-077:Task 和 Batch 有什么区别?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-077
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:444

题干与约束

Task 和 Batch 有什么区别?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:Task 是任务定义,描述“同步什么、怎么同步、什么时候同步”,比如某供应商酒店全量同步;Batch 是一次具体执行,描述“这一次跑到了哪里、成功多少、失败多少、当前 worker 是谁、租约什么时候过期”。一个 Task 会产生多次 Batch。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:444。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-078:Checkpoint 是什么,什么时候更新?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-078
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:448

题干与约束

Checkpoint 是什么,什么时候更新?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:Checkpoint 是任务进度水位,例如当前城市、页码、供应商 cursor、最后处理成功的供应商酒店 ID。它用于断点续跑。推荐先处理本页数据,再推进 checkpoint。这样即使机器在中间宕机,最多重复处理上一页,不会跳过未处理数据。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:448。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-079:worker 如何抢占任务?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-079
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:454

题干与约束

worker 如何抢占任务?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:通过数据库 CAS 抢占。worker 执行一条带条件的 UPDATE,只有 status=PENDINGstatus=RUNNING AND lease_until < NOW() 的 batch 才能被抢占。rows_affected=1 才说明抢占成功,其他 worker 必须退出。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:454。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-080:worker_idlease_token 的区别是什么?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-080
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:458

题干与约束

worker_idlease_token 的区别是什么?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:worker_id 标识执行器实例,通常由服务名、机器名或容器名、进程号、启动时间组成,方便排查和监控。lease_token 标识一次抢占行为,每次抢占都重新生成。关键写操作必须同时校验 batch_id + worker_id + lease_token,防止旧 worker 恢复后覆盖新 worker 的进度。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:458。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-081:心跳、租约、checkpoint 分别解决什么问题?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-081
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:462

题干与约束

心跳、租约、checkpoint 分别解决什么问题?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:心跳说明 worker 是否还活着;租约说明当前任务执行权属于谁;checkpoint 说明任务恢复时从哪里继续。心跳正常不代表任务在前进,所以还要看 last_checkpoint_at。租约过期才允许新 worker 抢占,checkpoint 用于恢复位置。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:462。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-082:机器重启后怎么恢复?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-082
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/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/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:466。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-083:旧 worker 在长 GC 或网络抖动后恢复了怎么办?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-083
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:470

题干与约束

旧 worker 在长 GC 或网络抖动后恢复了怎么办?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:旧 worker 恢复后可能以为自己还拥有任务。所有续租、checkpoint、发布和结束任务的 SQL 都必须带 worker_id + lease_token 条件。如果更新影响行数为 0,说明租约已经丢失,旧 worker 必须停止执行,不能继续写平台表。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:470。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-084:上一次任务还没跑完,又下发了一次任务怎么办?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-084
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:476

题干与约束

上一次任务还没跑完,又下发了一次任务怎么办?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:要用显式互斥策略。默认 SKIP_IF_RUNNING,如果已有同 task_codePENDING/RUNNING batch,新任务直接跳过;人工强制重跑可使用 CANCEL_PREVIOUS;只有数据范围不重叠时才允许 ALLOW_PARALLEL

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:476。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-085:心跳正常但 checkpoint 长时间不动,说明什么?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-085
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:480

题干与约束

心跳正常但 checkpoint 长时间不动,说明什么?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:说明 worker 还活着,但任务可能卡在某个阶段,例如供应商慢请求、对象存储写入慢、数据库锁等待、发布阻塞。此时不应立即抢占,而应告警并结合 last_heartbeat_stage 定位卡点。只有租约过期才允许新 worker 抢占。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:480。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-086:checkpoint 更新失败怎么办?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-086
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:484

题干与约束

checkpoint 更新失败怎么办?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:如果本页处理成功但 checkpoint 更新失败,下次恢复可能重复处理本页。因此页内落库和发布必须幂等。相反,不能先更新 checkpoint 再处理数据,否则宕机会跳过未处理页面。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:484。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-087:任务被人工取消时 worker 还在跑,怎么停?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-087
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:488

题干与约束

任务被人工取消时 worker 还在跑,怎么停?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:worker 不应该只在启动时读取状态,而要在每页处理前后检查 batch status。如果发现 CANCELLED,停止继续拉供应商,不再发布新数据,只做必要的清理和日志记录。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:488。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-088:Raw Snapshot 的价值是什么?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-088
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:494

题干与约束

Raw Snapshot 的价值是什么?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:Raw Snapshot 是供应商原始响应的证据,不是平台商品模型。它用于排查线上问题、回放同步、验证标准化规则、做 diff,也能区分是供应商数据错误还是平台映射错误。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:494。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-089:为什么需要 Diff?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-089
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:498

题干与约束

为什么需要 Diff?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:同步成功不等于应该发布。Diff 用来比较标准化后的数据和当前线上发布版本,识别字段变化、图片变化、坐标变化、房型变化和可售变化。低风险变化可以自动发布,高风险变化进入审核或 DLQ。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:498。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-090:DLQ 为什么建议用 MySQL,而不是只用消息队列?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-090
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:502

题干与约束

DLQ 为什么建议用 MySQL,而不是只用消息队列?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:供应商同步失败往往不是简单消息消费失败,而是字段缺失、城市映射失败、价格异常、发布失败等需要人工修复、状态流转和审计的问题。MySQL DLQ 可以作为权威问题单,支持查询、分派、重试、忽略、修复和报表。消息队列 DLQ 可以做短期缓冲,但不适合作为运营修复台账。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:502。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-091:worker 可以从 Redis 中抢占任务吗?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-091
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/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/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:512。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-092:为什么商品供给不能直接写商品正式表?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-092
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:530

题干与约束

为什么商品供给不能直接写商品正式表?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:因为供给入口产生的是未校验、未审核、未发布的数据。直接写正式表会让半成品商品被搜索、下单或履约系统读取。正确做法是先写 Draft / Staging,通过校验、审核和发布事务后再写正式表。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:530。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-093:供应商同步是不是商品供给链路的一部分?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-093
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:534

题干与约束

供应商同步是不是商品供给链路的一部分?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:是。供应商同步是商品供给的一种自动化入口,但不是全部。统一供给平台要承接人工创建、批量导入、运营编辑、库存创建 / 修改和供应商同步。供应商同步因为有长任务、Checkpoint、Raw Snapshot、Worker 租约和新鲜度问题,所以需要专项链路。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:534。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-094:批量导入为什么要支持部分成功?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-094
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:538

题干与约束

批量导入为什么要支持部分成功?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:大批量导入中少量错误很常见。如果 10 万行因为 100 行失败全部回滚,运营效率会非常低。更合理的是行级状态,成功项继续发布,失败项生成错误文件,运营修复后重新提交。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:538。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-095:运营编辑如何防止覆盖供应商同步的数据?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-095
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度进阶
建议用时45 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:542

题干与约束

运营编辑如何防止覆盖供应商同步的数据?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:要定义字段主导权。平台运营主导的标题、卖点、活动标签不应被供应商自动覆盖;供应商主导的库存和价格可以按策略更新;高风险字段进入审核。运营人工覆盖供应商字段时要记录原因、责任人和保护期。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:542。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-096:审核通过为什么不等于商品可售?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-096
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:546

题干与约束

审核通过为什么不等于商品可售?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:审核只说明变更可以发布,真正可售还依赖商品主数据、Offer、库存、Input Schema、履约规则、退款规则、搜索索引和缓存刷新都就绪。因此发布后还要做可售校验和下游刷新补偿。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:546。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-097:如何保证商品发布和搜索缓存一致?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-097
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:550

题干与约束

如何保证商品发布和搜索缓存一致?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:商品正式表更新和 Outbox 事件写入同一个事务。事务提交后,由异步消费者刷新 ES、缓存、计价上下文和数据平台。涉及营销活动时,由供给平台发起活动绑定、圈品或营销资格命令,营销系统负责规则、预算、券和优惠计算。刷新或协同失败进入补偿任务,保证最终一致。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:550。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-098:为什么订单要保存商品快照?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-098
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:554

题干与约束

为什么订单要保存商品快照?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:商品会持续变化,包括价格、标题、履约规则和退款规则。如果历史订单回读最新商品配置,会导致售后争议和财务对不上。创单时必须保存商品快照、报价快照、履约契约和退款规则快照。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:554。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-099:Draft 和 Staging 有什么区别?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-099
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:558

题干与约束

Draft 和 Staging 有什么区别?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:Draft 面向编辑过程,允许运营反复保存、撤销和补充信息,不代表一次可发布变更。Staging 面向发布过程,保存已经标准化、校验过、可审核的候选数据。Draft 更像工作区,Staging 更像发布前的候选版本,两者都不能直接被 C 端读取。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:558。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-100:为什么批量导入需要 Task 和 Task Item 两层模型?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-100
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:562

题干与约束

为什么批量导入需要 Task 和 Task Item 两层模型?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:Task 描述一次批量动作的整体状态,例如总行数、成功数、失败数和当前阶段;Task Item 描述每一行、每个商品、每个 Offer 的处理结果。没有 Item 层,就无法定位“第几行为什么失败”,也无法支持部分成功、错误文件和行级重试。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:562。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-101:供给任务如何做幂等?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-101
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:566

题干与约束

供给任务如何做幂等?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:入口层要有业务幂等键,例如 task_type + trigger_id、文件 Hash、供应商批次号或变更单号。发布层要用 publish_idbase_publish_version 和唯一约束防止重复写正式表。Outbox 消费侧也要支持事件幂等,避免重复刷新缓存、索引或下游配置。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:566。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-102:大文件批量导入如何避免内存爆和长事务?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-102
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:570

题干与约束

大文件批量导入如何避免内存爆和长事务?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:上传后先落对象存储,异步流式解析文件,按批次写入 Task Item,不把全文件一次性加载到内存。校验和标准化由 Worker 分片处理,发布时按商品或变更单分批提交,避免一个 10 万行文件变成一个超大事务。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:570。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-103:商品供给链路中哪些校验必须前置?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-103
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:574

题干与约束

商品供给链路中哪些校验必须前置?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:字段格式、必填项、类目属性、SPU / SKU / Offer 关系、价格、库存来源、履约规则、退款规则、渠道和站点可售性都要前置校验。尤其是交易契约类字段不能只在下单时兜底,否则线上商品看似发布成功,实际无法购买或无法履约。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:574。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-104:类目模板变更后,历史商品怎么处理?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-104
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:578

题干与约束

类目模板变更后,历史商品怎么处理?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:类目模板要版本化,历史商品保留创建或上次发布时的模板版本。模板升级后可以触发质量巡检或迁移任务,对缺失新必填属性的商品标记风险、限制重新发布或要求运营补齐,不能静默把历史商品全部判为非法。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:578。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-105:哪些运营变更需要强审核?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-105
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:582

题干与约束

哪些运营变更需要强审核?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:高风险字段需要强审核,例如价格大幅调整、批量下架、退款规则收紧、履约方式变更、类目迁移、供应商字段覆盖、C 端展示敏感文案和活动标签。系统可以用 Diff + 风险评分决定是否自动通过、二次确认或进入人工审核。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:582。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-106:为什么发布时要校验 base_publish_version

元信息

项目内容
题目编号Q-ECOM-SUPPLY-106
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:586

题干与约束

为什么发布时要校验 base_publish_version

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:供给变更通常基于某个旧版本编辑。如果发布时正式商品已经被其他任务修改,直接覆盖会丢失别人刚发布的变更。base_publish_version 用来做乐观并发控制,发现版本不一致时进入冲突处理,让运营选择合并、放弃或重新编辑。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:586。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-107:商品发布失败如何回滚?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-107
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:590

题干与约束

商品发布失败如何回滚?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:发布要尽量把正式表变更和 Outbox 写入放在同一事务中。事务内失败直接回滚;事务后下游刷新失败不回滚主数据,而是进入补偿队列和 DLQ。对于已发布但业务上要撤回的变更,应通过新的反向发布版本恢复,而不是手工改库。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:590。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-108:下游搜索、缓存或计价上下文刷新失败怎么办?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-108
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模价格建模规则计算
场景标签商品供给价格试算
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:594

题干与约束

下游搜索、缓存或计价上下文刷新失败怎么办?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:发布事务提交后通过 Outbox 事件驱动读侧投影刷新。消费者失败时记录重试次数、错误原因和 TraceID,超过阈值进入 DLQ,由补偿任务或人工修复重新投递。运营后台要展示“商品已发布但部分投影未同步”的状态;如果是营销活动协同失败,也要展示活动绑定失败原因和重试入口,避免误判为完全成功。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:594。本章不依赖旧 Part Four 文件链接。

相关章节:计价系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-109:错误文件应该包含哪些信息?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-109
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:598

题干与约束

错误文件应该包含哪些信息?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:错误文件要能让运营直接修复问题,至少包含原始行号、商品标识、字段名、错误类型、错误原因、修复建议和是否可重试。对于映射类错误,还应给出候选类目、候选属性或合法枚举值,而不是只写“导入失败”。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:598。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-110:如何设计商品质量分?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-110
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:602

题干与约束

如何设计商品质量分?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:质量分可以从内容完整度、类目属性完整度、图片质量、价格有效性、库存可售性、履约规则、退款规则、供应商新鲜度和历史投诉率等维度计算。质量分既用于运营看板,也可以驱动自动下架、限制投放、召回补录和供应商质量考核。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:602。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-111:运营后台最需要展示哪些链路状态?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-111
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:606

题干与约束

运营后台最需要展示哪些链路状态?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:运营后台要展示任务进度、成功失败数量、当前处理阶段、失败明细、审核状态、发布版本、下游同步状态、错误文件下载、可重试入口和质量巡检结果。后台不是简单 CRUD,而是要把长链路的不确定性运营化。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:606。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-112:两个运营同时编辑同一个商品怎么办?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-112
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:610

题干与约束

两个运营同时编辑同一个商品怎么办?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:可以用草稿锁、版本号和 Diff 合并共同解决。编辑态可以提示“商品已被某人编辑”,提交时用 base_publish_version 做并发校验;若版本冲突,则展示双方 Diff,让运营选择覆盖、合并或重新基于最新版本编辑。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:610。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-113:供应商价格和库存很新,但运营有人工覆盖,系统听谁的?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-113
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:614

题干与约束

供应商价格和库存很新,但运营有人工覆盖,系统听谁的?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:要按字段主导权处理。价格、库存通常由供应商或库存系统主导,但平台可以允许有期限、有原因、有审批记录的人工覆盖。覆盖期内供应商同步只记录 Diff 或进入待审核,覆盖到期后再恢复自动更新,避免人工修复被下一次同步冲掉。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:614。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-114:商品上下架和发布版本是什么关系?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-114
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度基础
建议用时15 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:618

题干与约束

商品上下架和发布版本是什么关系?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:上下架是商品生命周期状态,发布版本是商品数据变更记录。一次发布可以改变上下架状态,但两者不能混为一谈。商品可以有多个历史发布版本,当前只有一个生效版本;上下架还要受质量、库存、价格、渠道、风控和审核状态共同约束。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:618。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-115:如何支持定时发布和灰度发布?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-115
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:622

题干与约束

如何支持定时发布和灰度发布?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:变更单可以携带生效时间、渠道范围、城市范围、人群范围或流量比例。到达生效时间后由发布调度器执行,先写正式版本和 Outbox,再按范围刷新搜索、缓存和计价上下文;如涉及营销活动,同步发起营销活动配置或资格变更命令。灰度期间要能观察转化、投诉和履约异常,必要时快速撤回。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:622。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-116:自动下架会带来什么风险?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-116
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:626

题干与约束

自动下架会带来什么风险?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:自动下架能阻止缺图、缺价、无库存或无履约规则的商品继续售卖,但也可能误伤 GMV 和活动资源。设计时要区分阻断级、告警级和观察级问题;高风险商品自动下架,低风险商品先告警并给运营修复窗口,同时保留审计和恢复入口。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:626。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-117:供给链路如何做审计追踪?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-117
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模供给治理任务编排
场景标签商品供给运营后台
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:630

题干与约束

供给链路如何做审计追踪?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:每次变更都要记录操作者、来源入口、TraceID、原始输入、标准化结果、Diff、审核记录、发布版本、下游事件和补偿记录。审计不是只看最终商品表,而是要能还原“谁在什么时候因为什么把商品改成了什么”。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:630。本章不依赖旧 Part Four 文件链接。

相关章节:商品供给、运营与生命周期治理

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-SUPPLY-118:如果面试官让你一句话概括架构,你怎么说?

元信息

项目内容
题目编号Q-ECOM-SUPPLY-118
题型系统设计题
范围领域级:商品、库存、营销、计价与供给治理
难度中等
建议用时30 分钟
能力标签领域建模商品建模数据治理
场景标签商品供给商品主数据
能力域商品、供给、库存、营销与计价
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:634

题干与约束

如果面试官让你一句话概括架构,你怎么说?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:我会说商品供给与运营链路的核心是“统一入口、暂存隔离、质量校验、风险审核、版本发布、事件同步、失败补偿和运营可观测”。它的目标不是让运营能改商品,而是让商品从各种入口进入平台后,稳定、可控、可追溯地变成可售供给。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:634。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

搜索、购物车、订单与支付

本节覆盖搜索、购物车、订单与支付。

专题答辩资料:搜索与导购专题

搜索系统的核心是把商品主数据转化为适合检索和排序的读模型。它不应该成为商品权威存储,也不应该承担交易状态。

高频追问:

  • 商品变更如何同步到搜索索引?
  • 深分页为什么慢,如何优化?
  • 多条件筛选和排序如何设计索引?
  • 搜索结果和商品状态不一致怎么办?
  • 搜索服务故障时如何降级?

答题重点是区分主数据和读模型:商品中心负责权威事实,搜索系统负责检索体验,二者通过异步同步、补偿重建和一致性校验维持可接受的一致性。

专题答辩资料:购物车与结算专题

购物车偏用户体验,结算偏交易前校验。购物车可以允许一定程度的弱一致,结算必须重新校验商品、价格、库存、营销和地址配送。

高频追问:

  • 未登录购物车和登录购物车如何合并?
  • 购物车数据放 Redis 还是 MySQL?
  • 结算页为什么不能完全相信购物车价格?
  • 提交订单前要校验哪些内容?
  • 重复提交订单如何防止?

回答时要强调:购物车不是订单。购物车里的价格、库存和优惠都只是用户侧展示,结算和下单必须重新试算和锁定。

专题答辩资料:订单系统专题

订单系统的核心是状态机和交易事实。它协调商品快照、价格快照、库存预占、营销核销、支付状态和履约状态,但不应该吞掉所有领域逻辑。

高频追问:

  • 订单状态机如何设计?
  • 下单链路如何保证幂等?
  • 库存预占、支付成功和订单关闭如何协作?
  • 订单列表越来越慢怎么办?
  • 订单数据如何分库分表和归档?

订单题最忌讳只画组件图。更好的回答是先讲状态流,再讲数据模型、核心事务、异步事件和补偿任务。

专题答辩资料:支付系统专题

支付系统的核心是资金状态可信。支付回调天然会重复、乱序和延迟,因此支付单状态机、幂等处理、渠道对账和人工处理入口都很重要。

高频追问:

  • 支付回调重复怎么办?
  • 用户支付成功但本地订单未更新怎么办?
  • 支付单和订单是什么关系?
  • 退款如何设计状态机?
  • 多支付渠道如何抽象?

支付题的底线是不要把“收到回调”直接等同于“订单完成”。系统必须校验支付单、订单、金额、渠道流水和状态迁移是否合法。

专题答辩资料:交易主链路串联模板

如果让你设计电商交易链路,可以这样组织:

  1. 搜索和导购通过 ES / 缓存提供高性能读路径。
  2. 购物车保存用户意图,但不作为最终交易依据。
  3. 结算页重新校验商品、库存、营销、价格和配送。
  4. 下单接口使用幂等键创建订单,并预占库存、锁定优惠、保存快照。
  5. 支付系统通过支付单和渠道回调推进资金状态。
  6. 订单根据支付事件更新状态,并通过消息驱动履约、积分、通知等下游。
  7. 对账、补偿和 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。把四条线闭合,通常比堆技术名词更有说服力。

专题答辩资料:支付系统子系统职责对照

子系统职责对照(面试与评审常用):

子系统核心职责关键数据典型反模式
账户系统余额、冻结、流水、充值提现账户余额、冻结单、账务流水把渠道手续费写进用户余额宽表
支付网关路由、报文、签名、重试渠道请求 / 响应摘要、路由决策在网关里改支付单状态机
支付核心支付单、退款单、幂等、OutboxpaymentrefundoutboxHandler 直连第三方 SDK
清结算引擎分账、批次、结算周期分账明细、结算单、提现单清结算回调里直接操作支付单
风控系统限额、名单、挑战风控决策流水风控结果不落库导致无法复盘

专题答辩资料:支付系统边界反模式

反模式清单(面试与评审可直用):在支付服务里写供应商下单、在支付服务里计算运费、在支付回调里直接改 SKU 库存、在支付库里维护商品税率版本。它们共同症状是:支付发布频率被迫与业务域绑定,任何小改动都触碰资金链路。

专题答辩资料:搜索导购链路总结

面试时可以这样总结:搜索导购不是单纯 ES 查询,而是“召回 + 排序 + 商品补齐 + 库存价格营销融合 + 降级”的完整读链路


附录:常见技术方案速查

Q-ECOM-TRADE-001:电商搜索引擎的架构设计

元信息

项目内容
题目编号Q-ECOM-TRADE-001
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:20

题干与约束

电商平台每天有百万级搜索请求,需要支持全文搜索、属性筛选、排序。如何设计电商搜索引擎的整体架构?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台每天有百万级搜索请求,需要支持全文搜索、属性筛选、排序。如何设计电商搜索引擎的整体架构?

答案

问题分析: 电商搜索的核心要素:

  1. 海量数据(千万级商品)
  2. 复杂查询(关键词+品类+价格区间+品牌)
  3. 实时性(商品上下架实时更新)
  4. 相关性排序(搜索“手机“优先展示热门手机)
  5. 性能要求(毫秒级响应)

方案一:基于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

实施要点:

  1. 索引设计

    索引名称:products_v1
    分片数:5(根据数据量调整)
    副本数:2(高可用)
    
    字段类型选择:
    - keyword:不分词(品牌、类目ID)
    - text:分词(标题、描述)
    - nested:嵌套对象(属性列表)
    
  2. 数据同步

    实时同步:
    - 商品创建/更新 → 发送Kafka消息
    - 同步Worker消费消息 → 更新ES
    - 延迟 < 5秒
    
    全量同步(兜底):
    - 每天凌晨全量同步
    - 对比MySQL和ES差异
    - 修复不一致数据
    
  3. 搜索优化

    查询缓存:
    - 热门搜索词缓存(Redis)
    - TTL 5分钟
    
    搜索建议:
    - 输入"iph" → 建议"iPhone 15"
    - 使用completion suggester
    
    拼写纠错:
    - 输入"ipone" → 自动纠正为"iPhone"
    
  4. 性能优化

    分页优化:
    - 浅分页:from+size(前10页)
    - 深分页:search_after(10页以后)
    
    字段裁剪:
    - 只返回必要字段
    - _source: ["productId", "title", "price"]
    
    路由优化:
    - 按类目路由到不同分片
    
  5. 监控告警

    监控指标:
    - 搜索QPS
    - 搜索延迟P99
    - ES集群健康度
    - 索引大小
    
    告警:
    - 搜索延迟 > 500ms
    - ES集群RED状态
    - 数据同步延迟 > 1分钟
    

延伸思考

  1. 如何设计搜索的AB测试(不同排序策略)?
  2. 搜索无结果时如何处理(推荐、纠错)?
  3. 如何防止恶意搜索(刷流量、爬虫)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:20。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-002:搜索相关性排序算法设计

元信息

项目内容
题目编号Q-ECOM-TRADE-002
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:257

题干与约束

用户搜索“手机“,返回1000个结果,如何排序保证用户最想要的商品排在前面?请设计相关性排序算法。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户搜索“手机“,返回1000个结果,如何排序保证用户最想要的商品排在前面?请设计相关性排序算法。

答案

问题分析: 相关性排序的核心要素:

  1. 文本相关性(标题匹配度)
  2. 商品热度(销量、点击量)
  3. 商品质量(评分、评价数)
  4. 商品新鲜度(新品)
  5. 个性化(用户偏好)

方案一:单一得分排序

核心思想: 只按一个维度排序(如销量)。

实现:

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. 在线预测:
   - 搜索返回候选商品
   - 模型预测点击率
   - 按预测得分排序

优点:

  • 效果最优
  • 自动学习最优权重
  • 支持个性化

缺点:

  • 需要算法团队
  • 需要大量训练数据
  • 冷启动问题

方案对比

方案效果实施难度计算成本个性化
单一得分★★☆☆☆★★★★★★★★★★★☆☆☆☆
多因子加权★★★★☆★★★☆☆★★★★☆★★☆☆☆
机器学习★★★★★★★☆☆☆★★★☆☆★★★★★

推荐方案: 采用多因子加权,逐步引入机器学习。

实施要点:

  1. 初期(多因子加权)

    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;
    }
    
  2. 权重调优

    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)
    - 用户停留时长
    
    选择效果最好的权重
    
  3. 个性化因子

    用户偏好品牌:
    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;
    }
    
  4. 排序规则

    规则1:置顶广告位
    - 前3个位置:竞价广告
    - 标注"广告"
    
    规则2:新品扶持
    - 7天内新品得分 × 1.5
    
    规则3:库存保护
    - 库存 < 10件,降权(× 0.8)
    - 避免缺货商品排前面
    
  5. 监控与迭代

    监控指标:
    - 搜索结果点击率
    - 搜索转化率
    - 平均点击位置
    
    定期优化:
    - 每月分析数据
    - 调整权重
    - 新增因子
    

延伸思考

  1. 如何处理搜索作弊(刷销量、刷好评)?
  2. 长尾商品如何获得曝光机会?
  3. 如何设计搜索排序的解释性(为何这个商品排第一)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:257。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-003:搜索建议(Suggest)的实现

元信息

项目内容
题目编号Q-ECOM-TRADE-003
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:522

题干与约束

用户输入“iph“,搜索框下方实时展示“iPhone 15“、“iPhone 14“等建议。如何实现搜索建议功能?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户输入“iph“,搜索框下方实时展示“iPhone 15“、“iPhone 14“等建议。如何实现搜索建议功能?

答案

问题分析: 搜索建议的核心要素:

  1. 实时性(输入即显示)
  2. 准确性(建议与输入相关)
  3. 热度排序(热门建议优先)
  4. 性能(毫秒级响应)

方案一:数据库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

实施要点:

  1. 数据准备

    建议词来源:
    - 热门搜索词(用户历史搜索)
    - 商品标题(高销量商品)
    - 品牌名称
    - 类目名称
    - 运营配置词(促销活动)
    
    权重设置:
    - 用户搜索频次作为权重
    - 权重 = log(search_count + 1)
    
  2. 拼音支持

    安装pinyin分词器:
    - elasticsearch-analysis-pinyin
    
    索引配置:
    {
      "keyword": {
        "type": "completion",
        "analyzer": "pinyin_analyzer"
      }
    }
    
    输入"pingguo" → 建议"苹果"、"iPhone"
    
  3. 个性化建议

    用户维度:
    - 记录用户搜索历史(Redis)
    - 优先展示用户历史搜索
    
    示例:
    用户输入"ip"
    → ES返回:["iPhone 15", "iPad Pro", "iPod"]
    → 叠加用户历史:["iPhone 14"(历史搜索), "iPhone 15", "iPad Pro"]
    → 最终展示前10个
    
  4. 缓存策略

    热门建议缓存:
    - 缓存TOP 1000热门前缀的建议结果
    - key: suggest:iph
    - value: ["iPhone 15", "iPhone 14", ...]
    - TTL: 10分钟
    
    减少ES压力
    
  5. 建议词更新

    实时更新:
    - 用户搜索 → Kafka → 统计Worker → 更新ES
    
    定时更新(每小时):
    - 统计最近1小时热搜词
    - 更新权重
    - 新增热搜词
    

延伸思考

  1. 如何防止建议词中的敏感词?
  2. 搜索建议如何支持纠错(ipone → iPhone)?
  3. 如何设计多语言的搜索建议?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:522。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-004:商品筛选和多维度过滤的设计

元信息

项目内容
题目编号Q-ECOM-TRADE-004
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:760

题干与约束

用户搜索“手机“后,可以按品牌、价格区间、屏幕尺寸、内存等多个维度筛选。如何设计筛选系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户搜索“手机“后,可以按品牌、价格区间、屏幕尺寸、内存等多个维度筛选。如何设计筛选系统?

答案

问题分析: 筛选系统的核心要素:

  1. 动态筛选项(不同类目的筛选项不同)
  2. 多条件组合(品牌AND价格区间AND内存)
  3. 筛选项计数(显示每个选项的商品数量)
  4. 性能(实时计算筛选结果)

方案一:前端筛选

核心思想: 一次性返回所有结果,前端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)

实施要点:

  1. 筛选项配置

    category_filter_config(类目筛选配置)
    ├── category_id
    ├── filter_name(品牌、价格、属性名)
    ├── filter_type(TERM/RANGE/NESTED)
    ├── display_order(展示顺序)
    └── ...
    
    示例:
    手机类目:
    - 品牌(TERM)
    - 价格(RANGE: 0-1000, 1000-3000, ...)
    - 屏幕尺寸(NESTED: attrs.屏幕尺寸)
    - 内存(NESTED: attrs.内存)
    
  2. 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);
    }
    
  3. 前端交互

    URL设计:
    /search?q=手机&brand=Apple,小米&price=5000-10000&memory=8GB
    
    前端:
    - 用户点击筛选项 → 更新URL → 请求后端
    - 后端返回筛选结果 + 筛选项计数
    - 前端更新展示
    
    已选筛选展示:
    - 品牌:Apple ×  小米 ×
    - 价格:5000-10000 ×
    - 内存:8GB ×
    
    点击 × 取消该筛选
    
  4. 性能优化

    筛选缓存:
    key: search:q=手机&brand=Apple&price=5000-10000
    value: {商品列表, 筛选项计数}
    TTL: 5分钟
    
    热门筛选组合预加载
    
  5. 筛选项排序

    排序规则:
    1. 按配置的display_order
    2. 品牌按热度(商品数量)
    3. 价格区间固定顺序(低到高)
    4. 属性按字母顺序
    

延伸思考

  1. 如何设计筛选项的动态展示(只显示有商品的筛选项)?
  2. 筛选条件过多时如何优化性能?
  3. 如何设计筛选的撤销和重置功能?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:760。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-005:搜索结果的无结果优化

元信息

项目内容
题目编号Q-ECOM-TRADE-005
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1012

题干与约束

用户搜索“iPhne 15“(拼写错误),没有结果。如何优化无结果页,提升用户体验?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户搜索“iPhne 15“(拼写错误),没有结果。如何优化无结果页,提升用户体验?

答案

问题分析: 无结果场景:

  1. 拼写错误(iPhne → iPhone)
  2. 搜索词过于精确(“iPhone 15 Pro Max 256GB 深空黑色”)
  3. 商品确实不存在
  4. 分词问题

优化策略:

  1. 自动纠错
  2. 模糊搜索
  3. 推荐相关商品
  4. 引导用户

方案一:简单提示

核心思想: 直接提示“没有找到相关商品“。

优点:

  • 实现简单

缺点:

  • 用户体验差
  • 流失率高

方案二:拼写纠错(推荐)

核心思想: 检测拼写错误,自动纠正或建议正确词。

算法:

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

推荐商品:
[展示热销手机]

方案对比

方案用户体验转化率实施难度
简单提示★☆☆☆☆★☆☆☆☆★★★★★
拼写纠错★★★★☆★★★★☆★★★☆☆
模糊搜索+推荐★★★★★★★★★★★★☆☆☆
引导式★★★★☆★★★☆☆★★★★☆

推荐方案: 采用拼写纠错+模糊搜索+推荐的组合。

实施要点:

  1. 纠错流程

    用户搜索 → 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);
    }
    
  2. 纠错词库

    来源:
    - 用户搜索日志(搜索A无结果,搜索B有结果)
    - 商品标题词库
    - 品牌名称
    - 常见错误(人工维护)
    
    存储:
    spell_correction
    ├── wrong_word(错误词)
    ├── correct_word(正确词)
    ├── correction_count(纠正次数)
    └── ...
    
  3. 模糊搜索策略

    策略1:降低匹配度要求
    minimum_should_match: "75%"(原本100%)
    
    策略2:增加同义词
    "手机" = "智能手机" = "移动电话"
    
    策略3:分词后部分匹配
    "iPhone 15 Pro Max" → ["iPhone", "15", "Pro", "Max"]
    匹配任意3个词即可
    
  4. 推荐策略

    推荐来源:
    1. 类目热销(如果能识别类目)
    2. 全站热销(兜底)
    3. 相关搜索("其他用户还搜索了...")
    4. 促销商品(引导转化)
    
  5. 监控优化

    监控指标:
    - 无结果搜索率(无结果搜索数/总搜索数)
    - 无结果页跳出率
    - 纠错成功率
    
    目标:
    - 无结果搜索率 < 5%
    - 无结果页跳出率 < 50%
    

延伸思考

  1. 如何处理恶意搜索(脏词、广告)?
  2. 无结果搜索如何用于商品补货建议?
  3. 如何设计多语言搜索的纠错?

(继续生成后续5题…)

由于内容较长,我将分批次完成。继续生成3.1的剩余5题:

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1012。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-006:搜索日志分析与优化

元信息

项目内容
题目编号Q-ECOM-TRADE-006
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1238

题干与约束

电商平台每天产生百万级搜索日志,如何分析搜索日志,发现问题并优化搜索体验?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台每天产生百万级搜索日志,如何分析搜索日志,发现问题并优化搜索体验?

答案

问题分析: 搜索日志分析的核心目标:

  1. 发现热门搜索词
  2. 识别无结果搜索
  3. 分析用户搜索路径
  4. 优化搜索排序

推荐方案

数据收集:

搜索日志表:
search_log
├── log_id
├── user_id
├── keyword(搜索词)
├── result_count(结果数量)
├── clicked_products(点击的商品ID列表)
├── converted(是否转化购买)
├── search_time
└── session_id

分析维度:

  1. 热门搜索词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;
    
    用途:
    - 运营决策(备货)
    - 搜索建议(热词优先展示)
    - 广告投放
    
  2. 无结果搜索分析

    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;
    
    优化方向:
    - 拼写纠错词库补充
    - 商品补货建议
    - 同义词扩展
    
  3. 点击率分析

    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关键词 → 排序策略需要优化
    
  4. 转化漏斗

    搜索 → 点击 → 加购 → 下单 → 支付
    
    分析每个环节的转化率,找到瓶颈
    

延伸思考

  1. 如何识别恶意搜索(刷流量)?
  2. 搜索日志如何用于个性化推荐?
  3. 如何设计搜索AB测试平台?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1238。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-007:跨境电商的多语言搜索

元信息

项目内容
题目编号Q-ECOM-TRADE-007
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度进阶
建议用时45 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1330

题干与约束

跨境电商支持中文、英文、日文搜索。如何设计多语言搜索系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 跨境电商支持中文、英文、日文搜索。如何设计多语言搜索系统?

答案

问题分析: 多语言搜索的核心挑战:

  1. 不同语言分词规则不同
  2. 用户可能用中文搜英文商品
  3. 同义词跨语言匹配

推荐方案

  1. 多语言索引

    {
      "mappings": {
        "properties": {
          "title": {
            "properties": {
              "zh": {"type": "text", "analyzer": "ik_max_word"},
              "en": {"type": "text", "analyzer": "english"},
              "ja": {"type": "text", "analyzer": "kuromoji"}
            }
          }
        }
      }
    }
    
  2. 语言检测

    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);
    }
    
  3. 跨语言搜索

    用户输入中文"手机",也能搜到英文标题"phone"
    
    方案:翻译API
    - 调用翻译API(Google Translate)
    - keyword="手机" → translate → "phone"
    - 搜索中文字段 OR 英文翻译
    

延伸思考

  1. 如何处理多语言同义词?
  2. 不同国家的搜索习惯差异如何处理?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1330。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-008:搜索性能优化

元信息

项目内容
题目编号Q-ECOM-TRADE-008
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1392

题干与约束

搜索响应时间P99达到2秒,用户体验差。如何优化搜索性能到100ms以内?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 搜索响应时间P99达到2秒,用户体验差。如何优化搜索性能到100ms以内?

答案

问题分析: 搜索慢的常见原因:

  1. ES查询复杂(深度分页、大量聚合)
  2. 索引设计不合理
  3. 数据量大
  4. 网络延迟

优化方案

  1. 查询优化

    避免深度分页:
    ❌ from=10000, size=20(跳过1万条数据)
    ✅ search_after(游标分页)
    
    减少聚合计算:
    ❌ 聚合100个字段
    ✅ 聚合最常用的10个字段
    
    字段裁剪:
    ❌ 返回所有字段
    ✅ _source: ["id", "title", "price"]
    
  2. 缓存策略

    热门搜索缓存:
    key: search:q=iPhone&page=1
    value: {商品列表}
    TTL: 5分钟
    
    命中率:70%+
    
  3. 索引优化

    分片数量:
    - 单分片大小:20-50GB
    - 过多分片影响性能
    
    副本数量:
    - 副本数=2(高可用+读负载均衡)
    
    Segment合并:
    - 定期force_merge减少segment数量
    

延伸思考

  1. 如何设计搜索的降级方案(ES故障)?
  2. 搜索性能如何监控和告警?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1392。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-009:智能搜索(NLP+AI)

元信息

项目内容
题目编号Q-ECOM-TRADE-009
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1452

题干与约束

用户搜索“适合送女朋友的礼物“,如何理解用户意图,推荐合适商品?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户搜索“适合送女朋友的礼物“,如何理解用户意图,推荐合适商品?

答案

问题分析: 传统搜索只能匹配关键词,无法理解语义。

解决方案

  1. 意图识别

    NLP分析:
    "适合送女朋友的礼物"
    → 意图:礼物推荐
    → 对象:女性
    → 场景:送礼
    
    映射到类目:
    - 珠宝首饰
    - 化妆品
    - 鲜花
    
  2. 语义搜索

    使用BERT等模型:
    - 将搜索词编码为向量
    - 商品标题也编码为向量
    - 计算向量相似度
    - 按相似度排序
    

延伸思考

  1. 如何训练电商领域的语义模型?
  2. 语义搜索如何与传统搜索结合?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1452。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-010:搜索结果的多样性优化

元信息

项目内容
题目编号Q-ECOM-TRADE-010
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签搜索架构读模型
场景标签搜索导购高并发读
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1493

题干与约束

用户搜索“手机“,前10个结果都是iPhone,缺乏多样性。如何优化搜索结果的多样性?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户搜索“手机“,前10个结果都是iPhone,缺乏多样性。如何优化搜索结果的多样性?

答案

问题分析: 多样性不足的问题:

  1. 马太效应(热门商品更热门)
  2. 用户需求多样,不都想要iPhone
  3. 影响长尾商品曝光

优化方案

  1. 品牌打散

    规则:前10个结果中,同一品牌最多出现3次
    
    算法:
    1. 按相关性排序
    2. 遍历结果,统计品牌出现次数
    3. 如果某品牌超过阈值,跳过该商品,选下一个
    
  2. MMR算法(最大边际相关性)

    score = λ × relevance - (1-λ) × max_similarity
    
    relevance: 与查询的相关性
    max_similarity: 与已选结果的最大相似度
    λ: 权衡参数(0.7)
    
    每次选择score最高的商品,保证相关性和多样性
    
  3. 类目多样性

    前10个结果覆盖2-3个子类目
    - 智能手机(5个)
    - 老人机(3个)
    - 游戏手机(2个)
    

延伸思考

  1. 多样性和相关性如何权衡?
  2. 如何评估搜索结果的多样性?


评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1493。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-011:购物车的数据存储设计

元信息

项目内容
题目编号Q-ECOM-TRADE-011
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1547

题干与约束

用户将商品加入购物车,需要跨设备同步(手机APP、Web、小程序)。如何设计购物车的存储方案?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户将商品加入购物车,需要跨设备同步(手机APP、Web、小程序)。如何设计购物车的存储方案?

答案

问题分析: 购物车的核心要素:

  1. 跨设备同步
  2. 用户未登录也能加购
  3. 数据持久化
  4. 高并发读写

方案一: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双写

实施要点:

  1. 未登录用户

    未登录:
    - 生成临时cart_id(存Cookie)
    - 购物车数据存Redis
    - key: cart:temp:{cart_id}
    
    登录后:
    - 合并临时购物车到用户购物车
    - 删除临时购物车
    
  2. 购物车合并

    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);
    }
    
  3. 失效商品处理

    商品失效场景:
    - 商品下架
    - 商品删除
    - 库存不足
    
    展示:
    - 失效商品置灰
    - 提示"商品已下架"
    - 提供"删除"或"移入收藏"选项
    
  4. 购物车清理

    定时任务(每天凌晨):
    - 删除90天未更新的购物车
    - 减少存储成本
    
  5. 购物车同步

    跨设备同步:
    - 用户在APP加购 → 写Redis+MySQL
    - 用户在Web打开 → 读Redis → 显示购物车
    
    实时同步(WebSocket):
    - 用户在设备A加购
    - 推送到设备B
    - 设备B实时更新购物车数量
    

延伸思考

  1. 购物车数量显示在导航栏,如何实时更新?
  2. 如何处理购物车中的促销信息过期?
  3. 购物车数据如何备份和恢复?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1547。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-012:购物车的价格计算

元信息

项目内容
题目编号Q-ECOM-TRADE-012
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1711

题干与约束

购物车中有多个商品,每个商品可能有不同促销(满减、折扣、优惠券)。如何设计购物车的实时价格计算?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 购物车中有多个商品,每个商品可能有不同促销(满减、折扣、优惠券)。如何设计购物车的实时价格计算?

答案

问题分析: 购物车价格计算的复杂性:

  1. 多商品组合
  2. 多种促销叠加
  3. 实时计算(用户修改数量即刻更新)
  4. 价格明细展示

推荐方案

价格计算引擎:

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分钟)

延伸思考

  1. 购物车价格和下单后价格不一致如何处理?
  2. 大促时购物车价格计算如何优化性能?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1711。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-013:购物车的推荐功能

元信息

项目内容
题目编号Q-ECOM-TRADE-013
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1785

题干与约束

用户购物车中有商品A,如何推荐相关商品B,提升客单价?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户购物车中有商品A,如何推荐相关商品B,提升客单价?

答案

推荐策略

  1. 关联推荐

    "买了还买":
    - 统计购买商品A的用户还购买了哪些商品
    - 推荐高频商品
    
    示例:
    购物车有"iPhone 15" → 推荐"手机壳"、"钢化膜"、"充电器"
    
  2. 凑单推荐

    购物车总价¥180
    满¥200减¥30
    
    推荐:再买¥20-30的商品,即可享受优惠
    
  3. 替代推荐

    购物车中商品缺货 → 推荐同类商品
    

延伸思考

  1. 购物车推荐如何避免打扰用户?
  2. 推荐商品点击率如何提升?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1785。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-014:购物车的库存校验

元信息

项目内容
题目编号Q-ECOM-TRADE-014
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1823

题干与约束

用户加购物车时商品有货,结算时可能已无货。如何设计购物车的库存校验机制?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户加购物车时商品有货,结算时可能已无货。如何设计购物车的库存校验机制?

答案

校验时机

  1. 加购时校验

    用户点击"加入购物车" → 检查库存
    库存充足 → 允许加购
    库存不足 → 提示"库存不足"
    
  2. 结算时校验

    用户点击"去结算" →
    1. 批量查询购物车所有商品库存
    2. 标记缺货商品
    3. 展示:
       - 有货商品(可结算)
       - 缺货商品(置灰,不可结算)
    
  3. 实时推送

    商品库存变化(如售罄) → WebSocket推送
    前端实时更新购物车状态
    

延伸思考

  1. 购物车中的商品是否需要预占库存?
  2. 库存不足时如何引导用户?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1823。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-015:购物车的性能优化

元信息

项目内容
题目编号Q-ECOM-TRADE-015
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1861

题干与约束

大促期间,购物车服务QPS达10万+,如何优化购物车性能?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 大促期间,购物车服务QPS达10万+,如何优化购物车性能?

答案

优化方案

  1. 读写分离

    写操作(加购、删除):
    - 写MySQL主库
    - 异步同步到Redis
    
    读操作(查询购物车):
    - 读Redis(快)
    - 未命中读MySQL从库
    
  2. 批量操作

    ❌ 单个加购:N次请求
    ✅ 批量加购:1次请求
    
    POST /api/cart/batch-add
    {
      "items": [
        {"skuId": "123", "quantity": 2},
        {"skuId": "456", "quantity": 1}
      ]
    }
    
  3. 本地缓存

    热点用户购物车:
    - 加载到应用服务器内存
    - 减少Redis访问
    
  4. 限流降级

    限流:
    - 单用户购物车操作频率限制(10次/分钟)
    
    降级:
    - Redis故障 → 降级到MySQL
    - MySQL故障 → 只读模式(不能加购)
    

延伸思考

  1. 购物车数据如何分片(sharding)?
  2. 购物车服务如何实现高可用?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1861。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-016:购物车商品失效的处理策略

元信息

项目内容
题目编号Q-ECOM-TRADE-016
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1918

题干与约束

用户购物车中的商品可能因为下架、删除、库存清零而失效。如何设计失效商品的处理策略,优化用户体验?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户购物车中的商品可能因为下架、删除、库存清零而失效。如何设计失效商品的处理策略,优化用户体验?

答案

问题分析: 商品失效场景:

  1. 商品下架(运营操作)
  2. 商品删除(商品不再销售)
  3. 库存售罄(暂时缺货)
  4. 商品涨价(价格变动)
  5. 促销过期(活动结束)

方案一:定时批量检测

核心思想: 定时任务扫描购物车,标记失效商品。

实现:

定时任务(每小时):
1. 查询所有购物车商品
2. 批量查询商品状态
3. 标记失效商品
4. 更新购物车

优点:

  • 批量处理,效率高
  • 服务器压力均匀

缺点:

  • 实时性差(最长延迟1小时)
  • 用户可能看到失效商品

方案二:实时校验(推荐)

核心思想: 用户打开购物车时,实时校验商品状态。

流程:

用户打开购物车 →
1. 查询购物车商品列表
2. 批量查询商品最新状态(Redis缓存)
3. 分类展示:
   - 正常商品(可结算)
   - 失效商品(置灰,不可结算)
4. 标注失效原因

失效商品展示:

[置灰显示]
iPhone 15 Pro 256GB
¥7999
状态:该商品已下架
操作:[删除] [移入收藏夹]

优点:

  • 实时性好
  • 用户体验清晰

缺点:

  • 每次打开购物车都校验
  • QPS增加

方案三:消息推送

核心思想: 商品状态变化时,主动推送更新购物车。

架构:

商品下架 →
发布事件(Kafka)→
购物车Worker消费 →
1. 查询包含该商品的购物车
2. 标记商品为失效
3. WebSocket推送用户(如果在线)

优点:

  • 实时性最好
  • 用户感知及时

缺点:

  • 架构复杂
  • 需要消息队列

方案对比

方案实时性用户体验实施难度系统负载
定时检测★★☆☆☆★★★☆☆★★★★★★★★★☆
实时校验★★★★☆★★★★★★★★★☆★★★☆☆
消息推送★★★★★★★★★★★★☆☆☆★★★★☆

推荐方案: 采用实时校验+消息推送的组合。

实施要点:

  1. 商品状态缓存

    Redis存储商品状态:
    key: product:status:{skuId}
    value: {
      "onSale": true,
      "stock": 100,
      "price": 7999,
      "promotionId": "xxx",
      "updatedAt": 1679800000
    }
    TTL: 10分钟
    
    商品变更时主动刷新
    
  2. 批量校验优化

    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);
    }
    
  3. 失效商品操作

    用户操作:
    1. 删除:直接从购物车删除
    2. 移入收藏夹:
       - 加入收藏
       - 从购物车删除
       - 商品恢复上架时通知用户
    3. 查看替代品:
       - 推荐同类商品
       - 一键替换
    
  4. 主动通知

    通知策略:
    - 商品下架 → App推送
      "您购物车中的【iPhone 15】已下架"
    - 商品降价 → App推送
      "您购物车中的【iPhone 15】降价了"
    - 库存恢复 → 收藏夹商品有货通知
    
  5. 失效原因分类

    原因分类:
    - 已下架:运营下架
    - 已售罄:库存为0
    - 已删除:商品不存在
    - 已涨价:价格变动超过10%
    - 活动结束:促销过期
    
    针对性提示:
    - 已售罄 → "补货中,可先收藏"
    - 已涨价 → "当前价格¥xxx,加购时¥xxx"
    

延伸思考

  1. 如何设计购物车的自动清理(失效商品30天后自动删除)?
  2. 失效商品是否计入购物车数量显示?
  3. 如何处理部分失效(如只有某个规格缺货)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:1918。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-017:购物车的跨平台同步设计

元信息

项目内容
题目编号Q-ECOM-TRADE-017
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2107

题干与约束

用户在手机APP加购商品,打开电脑Web也能看到。如何实现购物车的跨平台实时同步?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户在手机APP加购商品,打开电脑Web也能看到。如何实现购物车的跨平台实时同步?

答案

问题分析: 跨平台同步的核心要素:

  1. 数据一致性(同一购物车)
  2. 实时性(秒级同步)
  3. 冲突处理(同时操作)
  4. 离线支持

方案一:轮询同步

核心思想: 客户端定时轮询服务器,获取最新购物车。

实现:

// 前端定时轮询
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)+ 轮询兜底(不支持时降级)。

实施要点:

  1. 连接管理

    // 用户连接映射
    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)));
          }
        }
      }
    }
    
  2. 版本控制

    购物车版本号:
    - 每次修改version+1
    - 客户端记录本地version
    - 接收推送时检查version
    - 如果本地version更新,忽略旧推送
    
    冲突解决:
    - 客户端操作携带version
    - 服务端CAS更新
    - 失败则拉取最新数据重试
    
  3. 心跳保活

    // 客户端定时发送心跳
    setInterval(() => {
      if (websocket.readyState === WebSocket.OPEN) {
        websocket.send(JSON.stringify({type: 'PING'}));
      }
    }, 30000); // 每30秒
    
    // 服务端响应心跳
    if (message.type === 'PING') {
      session.sendMessage(new TextMessage('{"type":"PONG"}'));
    }
    
  4. 降级策略

    // 检测WebSocket支持
    if ('WebSocket' in window) {
      connectWebSocket();
    } else {
      // 降级到轮询
      setInterval(pollCart, 10000);
    }
    
    // WebSocket断开时降级
    websocket.onclose = () => {
      console.log('WebSocket断开,降级到轮询');
      setInterval(pollCart, 10000);
    };
    
  5. 离线支持

    离线操作:
    1. 用户离线时,操作保存到本地队列
    2. 用户上线后,批量同步到服务器
    3. 服务器合并操作,返回最终购物车
    
    冲突处理:
    - 添加:合并数量
    - 删除:以最新操作为准
    - 修改:以最新操作为准
    

延伸思考

  1. 如何处理网络不稳定导致的频繁重连?
  2. 跨平台同步如何支持多账号(家庭共享)?
  3. WebSocket服务如何实现横向扩展?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2107。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-018:购物车推荐算法设计

元信息

项目内容
题目编号Q-ECOM-TRADE-018
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2360

题干与约束

用户购物车有“iPhone 15“,如何推荐相关商品(配件、保险、AppleCare)提升客单价?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户购物车有“iPhone 15“,如何推荐相关商品(配件、保险、AppleCare)提升客单价?

答案

问题分析: 购物车推荐的核心目标:

  1. 提升客单价(关联销售)
  2. 提升转化率(凑单满减)
  3. 提升用户体验(需要的商品)

推荐策略

  1. 关联推荐(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%)
    
  2. 凑单推荐

    购物车总价:¥180
    满减活动:满¥200减¥30
    
    推荐策略:
    - 推荐价格在¥20-¥50的商品
    - 优先推荐与购物车商品相关的
    - 标注"再买¥20即享满减"
    
  3. 类目互补推荐

    购物车有"相机" → 推荐:
    - 存储卡
    - 相机包
    - 三脚架
    
    购物车有"婴儿奶粉" → 推荐:
    - 奶瓶
    - 尿不湿
    - 湿巾
    
  4. 个性化推荐

    基于用户历史:
    - 用户A经常买Apple产品
      → 推荐AppleCare+、AirPods
    - 用户B价格敏感
      → 推荐高性价比配件
    

实施要点

  1. 关联规则挖掘

    # 使用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)
    
  2. 推荐展示位置

    位置1:购物车下方
    "买了还买":展示3-5个商品
    
    位置2:结算页
    "凑单优惠":满减差额商品
    
    位置3:加购弹窗
    用户加购商品A → 弹窗推荐配件B
    
  3. 推荐排序

    score = w1 × 关联度 +
            w2 × 利润率 +
            w3 × 库存充足度 +
            w4 × 用户个性化得分
    
    w1=0.4, w2=0.3, w3=0.2, w4=0.1
    
  4. AB测试

    测试维度:
    - A组:展示3个推荐
    - B组:展示5个推荐
    - C组:不展示推荐
    
    评估指标:
    - 推荐点击率
    - 推荐加购率
    - 客单价提升
    

延伸思考

  1. 推荐商品如何避免干扰用户(显得推销)?
  2. 推荐算法如何冷启动(新商品无关联数据)?
  3. 推荐效果如何评估和持续优化?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2360。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-019:购物车的结算流程设计

元信息

项目内容
题目编号Q-ECOM-TRADE-019
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2488

题干与约束

用户点击“去结算“,进入结算页面,需要选择地址、优惠券、支付方式。如何设计结算流程?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户点击“去结算“,进入结算页面,需要选择地址、优惠券、支付方式。如何设计结算流程?

答案

问题分析: 结算流程的核心环节:

  1. 确认商品(数量、价格)
  2. 选择收货地址
  3. 选择配送方式
  4. 应用优惠(优惠券、积分)
  5. 选择支付方式
  6. 提交订单

方案一:单页结算

核心思想: 所有信息在一个页面完成。

页面布局:

结算页:
┌─────────────────┐
│ 1. 收货地址      │
│ [北京市朝阳区...] │
├─────────────────┤
│ 2. 商品清单      │
│ iPhone 15 × 1   │
│ ¥7999           │
├─────────────────┤
│ 3. 配送方式      │
│ ○ 标准配送(免费)│
│ ○ 次日达(¥10)  │
├─────────────────┤
│ 4. 优惠         │
│ 优惠券:¥30     │
│ 积分抵扣:¥10   │
├─────────────────┤
│ 5. 支付方式      │
│ ○ 支付宝        │
│ ○ 微信支付      │
├─────────────────┤
│ 总计:¥7959     │
│ [提交订单]       │
└─────────────────┘

优点:

  • 流程简洁
  • 一目了然
  • 减少跳转

缺点:

  • 页面信息多
  • 移动端显示困难

方案二:分步结算(推荐)

核心思想: 分多个步骤完成结算。

流程:

步骤1:选择地址
→ 步骤2:确认商品和配送
→ 步骤3:选择优惠
→ 步骤4:支付

优点:

  • 逻辑清晰
  • 移动端友好
  • 可保存中间状态

缺点:

  • 步骤多
  • 可能流失

推荐方案: PC端使用单页结算,移动端使用分步结算

实施要点:

  1. 结算前校验

    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);
    }
    
  2. 地址选择

    展示用户地址列表:
    - 默认地址(置顶)
    - 最近使用地址
    - 其他地址
    
    新增地址:
    - 省市区三级联动
    - 详细地址输入
    - 联系人和电话
    - 设为默认地址
    
  3. 优惠券选择

    展示可用优惠券:
    - 按优惠力度排序
    - 标注"最优"推荐
    - 显示使用门槛
    
    自动选择:
    - 默认选择优惠最大的券
    - 用户可手动切换
    
    不可用优惠券:
    - 置灰显示
    - 标注不可用原因(如"不满足使用条件")
    
  4. 价格实时计算

    // 监听用户操作
    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 });
    };
    
  5. 订单确认信息

    最终确认页展示:
    - 收货人:张三 138****1234
    - 收货地址:北京市朝阳区xxx
    - 商品清单:iPhone 15 × 1
    - 配送方式:标准配送(预计3天送达)
    - 优惠明细:
      * 商品折扣:-¥100
      * 满减优惠:-¥30
      * 优惠券:-¥20
    - 实付金额:¥7849
    
    用户确认无误后点击"提交订单"
    

延伸思考

  1. 如何设计结算页的防重复提交?
  2. 结算过程中价格变动如何处理?
  3. 结算流程如何优化转化率?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2488。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-020:购物车的分享功能设计

元信息

项目内容
题目编号Q-ECOM-TRADE-020
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2679

题干与约束

用户想分享购物车给朋友(如“帮我看看这些商品怎么样“),如何设计购物车分享功能?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户想分享购物车给朋友(如“帮我看看这些商品怎么样“),如何设计购物车分享功能?

答案

问题分析: 购物车分享的核心场景:

  1. 征求意见(送礼选择)
  2. 代购(帮朋友买)
  3. 拼单(一起买更便宜)

方案一:生成分享链接

核心思想: 生成唯一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. 分享类型

    类型1:只读分享
    - 生成分享链接
    - 朋友只能查看,不能修改
    - 可一键导入到自己购物车
    
    类型2:协同编辑
    - 创建共享购物车
    - 邀请成员
    - 成员可添加/删除商品
    
  2. 分享页面设计

    分享页头部:
    "张三分享了购物车给你"
    
    商品列表:
    [展示所有商品]
    
    操作按钮:
    - [全部加入我的购物车]
    - [选择部分加入]
    - [保存为我的收藏清单]
    
  3. 隐私控制

    隐私选项:
    - 公开:任何人都可查看
    - 仅好友:需要登录且是好友
    - 密码保护:需要输入密码
    
    敏感信息隐藏:
    - 不显示价格(可选)
    - 不显示数量(可选)
    
  4. 分享统计

    统计指标:
    - 分享次数
    - 查看人数
    - 转化人数(查看后购买)
    - 传播路径(A分享给B,B分享给C)
    
  5. 场景化推荐

    场景1:送礼征询
    "想送女朋友礼物,帮我选一个"
    → 展示多个候选商品
    → 朋友投票或评论
    
    场景2:拼单
    "一起买,更便宜"
    → 共享购物车
    → 凑满减金额
    → 分摊运费
    

延伸思考

  1. 如何设计购物车的协同冲突解决(同时删除同一商品)?
  2. 分享购物车如何防止恶意刷单?
  3. 共享购物车如何拆单结算(各付各的)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2679。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-021:购物车的满减凑单提示

元信息

项目内容
题目编号Q-ECOM-TRADE-021
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2869

题干与约束

购物车总价¥180,有满¥200减¥30活动。如何设计智能凑单提示,引导用户加购?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 购物车总价¥180,有满¥200减¥30活动。如何设计智能凑单提示,引导用户加购?

答案

推荐方案

  1. 差额计算

    当前金额:¥180
    满减门槛:¥200
    差额:¥20
    
    提示:"再买¥20,立减¥30"
    
  2. 智能商品推荐

    推荐商品筛选条件:
    - 价格在¥20-¥50之间(差额附近)
    - 与购物车商品相关(配件、同类目)
    - 库存充足
    - 高评分
    
    排序:
    - 优先推荐价格接近差额的
    - 优先推荐关联度高的
    
  3. 视觉引导

    进度条展示:
    [████████░░] 90% (¥180/¥200)
    "再买¥20,立减¥30,相当于打8.5折"
    
    推荐商品卡片:
    ┌───────────┐
    │ 手机壳     │
    │ ¥29       │
    │ [加入购物车]│
    └───────────┘
    
  4. 多档位满减

    满减档位:
    - 满¥100减¥10(已达成✓)
    - 满¥200减¥30(差¥20)
    - 满¥500减¥100(差¥320)
    
    提示优先显示最接近的下一档
    

延伸思考

  1. 凑单推荐如何避免过度营销(让用户反感)?
  2. 多个满减活动同时存在时如何提示?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2869。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-022:购物车的批量操作设计

元信息

项目内容
题目编号Q-ECOM-TRADE-022
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2930

题干与约束

用户购物车有50个商品,想批量删除、批量加入收藏。如何设计批量操作功能?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户购物车有50个商品,想批量删除、批量加入收藏。如何设计批量操作功能?

答案

推荐方案

  1. 批量选择

    界面设计:
    [全选] 已选0件
    
    ☑ 商品A  ¥100
    ☑ 商品B  ¥200
    ☐ 商品C  ¥300
    
    批量操作:
    [删除选中] [加入收藏] [移除失效商品]
    
  2. 批量接口

    POST /api/cart/batch-delete
    {
      "skuIds": ["123", "456", "789"]
    }
    
    POST /api/cart/batch-move-to-favorite
    {
      "skuIds": ["123", "456"]
    }
    
  3. 事务处理

    批量操作的事务性:
    - 部分成功部分失败如何处理?
    
    方案A:全量事务
    - 全部成功才提交
    - 任一失败全部回滚
    
    方案B:部分成功(推荐)
    - 成功的操作提交
    - 失败的返回错误信息
    - 前端展示"成功X件,失败Y件"
    
  4. 性能优化

    批量删除50个商品:
    ❌ for循环50次DELETE
    ✅ 一次DELETE WHERE sku_id IN (...)
    
    批量更新库存:
    ❌ 50次UPDATE
    ✅ 批量UPDATE CASE WHEN
    

延伸思考

  1. 批量操作如何支持撤销(Undo)?
  2. 批量操作的进度如何展示?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2930。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-023:购物车的收藏夹联动

元信息

项目内容
题目编号Q-ECOM-TRADE-023
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2997

题干与约束

购物车和收藏夹如何联动?商品从购物车移入收藏,或从收藏加入购物车。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 购物车和收藏夹如何联动?商品从购物车移入收藏,或从收藏加入购物车。

答案

推荐方案

  1. 数据模型

    favorite
    ├── favorite_id
    ├── user_id
    ├── sku_id
    ├── source(CART/BROWSE)
    ├── added_at
    └── ...
    
  2. 互相转换

    购物车 → 收藏夹:
    1. 用户点击"移入收藏"
    2. 加入收藏夹
    3. 从购物车删除
    4. 提示"已移入收藏夹"
    
    收藏夹 → 购物车:
    1. 用户点击"加入购物车"
    2. 加入购物车
    3. 保留在收藏夹(不删除)
    
  3. 降价提醒

    收藏商品降价:
    - 监控收藏商品价格
    - 降价时推送通知
    - 引导用户加购
    

延伸思考

  1. 收藏夹和购物车的区别是什么?
  2. 如何设计收藏夹的分组功能?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:2997。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-024:购物车的历史记录

元信息

项目内容
题目编号Q-ECOM-TRADE-024
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3045

题干与约束

用户删除了购物车商品,想恢复。如何设计购物车的历史记录功能?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户删除了购物车商品,想恢复。如何设计购物车的历史记录功能?

答案

推荐方案

  1. 软删除

    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
    
  2. 恢复功能

    历史记录页面:
    最近删除:
    - 商品A(3天前删除)[恢复]
    - 商品B(7天前删除)[恢复]
    
    恢复操作:
    UPDATE shopping_cart
    SET deleted=0, deleted_at=NULL
    WHERE cart_id=?
    
  3. 自动清理

    定时任务:
    - 删除30天后的历史记录
    - 减少存储成本
    

延伸思考

  1. 购物车历史记录是否需要版本控制(记录每次修改)?
  2. 如何设计购物车的快照功能(保存多个购物清单)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3045。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-025:购物车的AB测试设计

元信息

项目内容
题目编号Q-ECOM-TRADE-025
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签结算编排交易前校验
场景标签购物车结算页
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3097

题干与约束

想测试新的购物车布局对转化率的影响。如何设计购物车的AB测试?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 想测试新的购物车布局对转化率的影响。如何设计购物车的AB测试?

答案

推荐方案

  1. 分流策略

    public String getCartVersion(Long userId) {
      // 基于用户ID哈希分流
      int hash = userId.hashCode();
      if (hash % 2 == 0) {
        return "A"; // 对照组
      } else {
        return "B"; // 实验组
      }
    }
    
  2. 实验设计

    对照组A(50%用户):
    - 旧购物车布局
    
    实验组B(50%用户):
    - 新购物车布局(优化后)
    
    评估指标:
    - 加购率
    - 结算率
    - 转化率
    - 客单价
    
  3. 数据埋点

    // 购物车页面浏览
    track('cart_view', {
      version: 'A', // 或 'B'
      cartItemCount: 5
    });
    
    // 点击结算
    track('cart_checkout_click', {
      version: 'A',
      cartTotal: 1000
    });
    
    // 完成下单
    track('order_created', {
      version: 'A',
      orderAmount: 1000
    });
    
  4. 结果分析

    结果对比:
    | 指标 | A组 | B组 | 提升 |
    |------|-----|-----|------|
    | 结算率 | 60% | 65% | +8.3% |
    | 转化率 | 40% | 45% | +12.5% |
    | 客单价 | ¥800 | ¥850 | +6.25% |
    
    结论:B组效果更好,全量发布
    

延伸思考

  1. AB测试如何保证结果的统计显著性?
  2. 多个AB测试同时进行时如何隔离影响?
  3. 如何设计购物车的渐进式发布(灰度发布)?


评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3097。本章不依赖旧 Part Four 文件链接。

相关章节:购物车与结算

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-026:订单状态机的设计

元信息

项目内容
题目编号Q-ECOM-TRADE-026
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3178

题干与约束

订单从创建到完成,经历多个状态(待支付、待发货、待收货、已完成)。如何设计订单状态机,保证状态流转的正确性?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 订单从创建到完成,经历多个状态(待支付、待发货、待收货、已完成)。如何设计订单状态机,保证状态流转的正确性?

答案

问题分析: 订单状态流转的核心要素:

  1. 状态定义清晰
  2. 流转规则明确
  3. 防止非法跳转
  4. 支持异常流程(取消、退款)

状态定义

正向流程:
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

延伸思考

  1. 如何设计订单的子状态(如待发货细分为待拣货、待打包、待出库)?
  2. 订单状态变更如何触发后续操作(如发货后通知物流)?
  3. 如何处理状态流转的并发冲突?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3178。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-027:订单号生成规则

元信息

项目内容
题目编号Q-ECOM-TRADE-027
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3303

题干与约束

订单号需要唯一、有序、不易被猜测。如何设计订单号生成规则?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 订单号需要唯一、有序、不易被猜测。如何设计订单号生成规则?

答案

订单号设计要求

  1. 全局唯一
  2. 趋势递增(便于分库分表)
  3. 信息可读(包含时间、业务类型)
  4. 安全性(不易被遍历)
  5. 长度适中(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;
}

延伸思考

  1. 如何设计订单号的校验规则(防止伪造)?
  2. 订单号如何支持多业务类型(普通订单、预售订单、拼团订单)?
  3. 分库分表场景下订单号如何设计路由键?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3303。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-028:订单超时自动取消

元信息

项目内容
题目编号Q-ECOM-TRADE-028
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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)

实施要点:

  1. 幂等性保证

    @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());
    }
    
  2. 异常重试

    取消失败的处理:
    - 消息重新入队,稍后重试
    - 最多重试3次
    - 仍失败则记录告警,人工处理
    
  3. 监控告警

    监控指标:
    - 超时订单数量
    - 取消成功率
    - 延迟队列堆积量
    
    告警:
    - 取消失败率 > 1%
    - 延迟队列堆积 > 10000
    

延伸思考

  1. 如何设计不同订单类型的不同超时时间(普通30分钟,秒杀10分钟)?
  2. 订单超时取消如何通知用户?
  3. 大促期间超时订单激增如何处理?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3454。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-029:订单拆单与合单策略

元信息

项目内容
题目编号Q-ECOM-TRADE-029
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3686

题干与约束

用户购买多个商品,可能来自不同仓库或不同商家。如何设计订单拆单与合单策略?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户购买多个商品,可能来自不同仓库或不同商家。如何设计订单拆单与合单策略?

答案

问题分析: 拆单场景:

  1. 多仓库发货(就近发货)
  2. 多商家发货(平台+第三方卖家)
  3. 预售+现货(发货时间不同)
  4. 自营+跨境(清关时间不同)

合单场景:

  1. 同一地址多笔订单(节省运费)
  2. 同一商家商品(方便发货)

方案一:用户下单时拆单

核心思想: 用户提交订单时,系统自动拆分为多个子订单。

流程:

用户购物车:
- 商品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

优点:

  • 用户无感知(看到的是一个订单)
  • 退款简单(按订单退)
  • 灵活(可随时调整拆单规则)

缺点:

  • 实现复杂
  • 需要维护订单和发货单的关系

拆单规则

  1. 按仓库拆分

    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;
    }
    
  2. 按商家拆分

    平台订单包含:
    - 自营商品(平台发货)
    - 第三方商品(商家发货)
    
    拆分:
    - 子订单1:自营商品
    - 子订单2:商家A的商品
    - 子订单3:商家B的商品
    
  3. 按发货时间拆分

    订单包含:
    - 现货商品(立即发货)
    - 预售商品(15天后发货)
    
    拆分:
    - 发货单1:现货(立即发)
    - 发货单2:预售(延迟发)
    

合单策略

  1. 同地址合并

    用户A在1小时内下了3笔订单:
    - 订单1:商品A(北京仓)
    - 订单2:商品B(北京仓)
    - 订单3:商品C(上海仓)
    
    合单:
    - 发货单1:订单1+订单2的商品(北京仓合并发货)
    - 发货单2:订单3的商品(上海仓单独发货)
    
    好处:
    - 节省运费
    - 减少包裹数量
    
  2. 运费优化

    规则:
    - 同一仓库、同一地址、24小时内的订单
    - 自动合并发货
    - 运费退还到用户余额
    

推荐方案: 采用后台自动拆单

实施要点:

  1. 拆单时机

    时机选择:
    - 订单支付后立即拆单(推荐)
    - 发货前拆单(更灵活)
    
  2. 用户展示

    订单详情页:
    订单号:OR123456
    总金额:¥1000
    
    发货信息:
    - 包裹1:商品A+B(运单号:SF123)
      状态:已发货
    - 包裹2:商品C(运单号:SF456)
      状态:待发货
    
  3. 退款处理

    部分商品退款:
    - 用户申请退商品A
    - 计算退款金额(商品价 + 分摊运费)
    - 只退部分金额
    - 其他商品正常履约
    

延伸思考

  1. 如何设计拆单的运费分摊规则?
  2. 拆单后如何保证库存一致性?
  3. 跨境订单的拆单有何特殊性?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3686。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-030:订单的并发创建与幂等性

元信息

项目内容
题目编号Q-ECOM-TRADE-030
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3916

题干与约束

用户可能重复点击“提交订单“按钮,导致创建多个订单。如何保证订单创建的幂等性?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户可能重复点击“提交订单“按钮,导致创建多个订单。如何保证订单创建的幂等性?

答案

问题分析: 重复下单的原因:

  1. 用户重复点击
  2. 网络超时重试
  3. 前端未防抖
  4. 恶意刷单

方案一:前端防抖

核心思想: 前端限制用户短时间内多次点击。

实现:

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机制的组合。

实施要点:

  1. 多层防护

    L1:前端防抖(用户体验)
    L2:Token机制(防恶意)
    L3:唯一索引(最后防线)
    
  2. 幂等键设计

    幂等键组成:
    userId + cartVersion + timestamp
    
    例如:
    123_v10_1679800000
    
    说明:
    - userId:用户ID
    - cartVersion:购物车版本(购物车内容变化版本号+1)
    - timestamp:提交时间戳(精确到秒)
    
  3. 异常处理

    try {
      return createOrder(request, token);
    } catch (DuplicateKeyException e) {
      // 唯一索引冲突,查询已存在的订单
      Order existingOrder = findByIdempotentKey(idempotentKey);
      return existingOrder;
    } catch (BizException e) {
      // Token无效等业务异常
      throw e;
    }
    

延伸思考

  1. 如何设计订单创建的限流(防止刷单)?
  2. 订单创建失败如何回滚库存?
  3. 分布式事务下如何保证订单创建的一致性?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:3916。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-031:订单的分布式事务设计(Saga模式)

元信息

项目内容
题目编号Q-ECOM-TRADE-031
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度进阶
建议用时45 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:4180

题干与约束

订单创建涉及多个服务(订单服务、库存服务、优惠券服务、积分服务)。如何使用Saga模式保证分布式事务一致性?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 订单创建涉及多个服务(订单服务、库存服务、优惠券服务、积分服务)。如何使用Saga模式保证分布式事务一致性?

答案

问题分析: 订单创建的分布式事务流程:

  1. 扣减库存(库存服务)
  2. 核销优惠券(营销服务)
  3. 扣减积分(会员服务)
  4. 创建订单(订单服务)

任一环节失败,已执行的操作需要回滚。

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)
}

优点

  • 逻辑清晰(正向+补偿)
  • 解耦各服务
  • 支持长事务

缺点

  • 实现复杂
  • 补偿可能失败(需要人工介入)
  • 中间状态可见(不是强一致性)

延伸思考

  1. Saga补偿失败如何处理?
  2. 如何设计Saga的可视化监控?
  3. Saga vs 2PC(两阶段提交)如何选择?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:4180。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-032:订单数据的分库分表设计

元信息

项目内容
题目编号Q-ECOM-TRADE-032
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:4344

题干与约束

订单表数据量达到亿级,单表查询性能下降。如何设计订单的分库分表方案?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 订单表数据量达到亿级,单表查询性能下降。如何设计订单的分库分表方案?

答案

问题分析: 订单分库分表的核心要素:

  1. 分片键选择(user_id还是order_id)
  2. 分片数量(16、32、64、128)
  3. 跨片查询(如运营查询某时间段订单)
  4. 数据扩容

方案一:按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实现):

  1. 分片路由中间件

    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
    }
    
  2. 订单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)
    }
    
  3. 订单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
    }
    
  4. 扩容方案

    扩容策略(64 → 128分片):
    
    方案A:双写期
    1. 新建64个分片(总共128个)
    2. 新订单写入新分片规则
    3. 老订单保留在老分片
    4. 查询时先查新分片,未命中再查老分片
    
    方案B:一致性哈希
    1. 使用一致性哈希算法
    2. 扩容时只需迁移部分数据
    3. 数据迁移期间双写
    

延伸思考

  1. 如何设计分库分表的全局查询(如运营后台)?
  2. 订单归档如何设计(冷热数据分离)?
  3. 分库分表如何支持跨库JOIN?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:4344。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-033:订单履约流程的编排

元信息

项目内容
题目编号Q-ECOM-TRADE-033
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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)
}

延伸思考

  1. 履约流程如何支持异常处理(缺货、商品损坏)?
  2. 多个发货单如何协调履约进度?
  3. 履约时效如何监控和告警?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:4663。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-034:订单的退款和售后流程设计

元信息

项目内容
题目编号Q-ECOM-TRADE-034
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签支付状态机资金安全
场景标签支付回调资金链路
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:4811

题干与约束

用户申请退款(仅退款、退货退款),如何设计售后流程,保证资金安全和用户体验?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 用户申请退款(仅退款、退货退款),如何设计售后流程,保证资金安全和用户体验?

答案

退款场景

  1. 仅退款(未发货)
  2. 退货退款(已发货)
  3. 部分退款(退部分商品)
  4. 售后退款(商品质量问题)

推荐方案(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
}

延伸思考

  1. 退款失败如何重试和补偿?
  2. 恶意退款如何识别和防范?
  3. 部分退款如何计算退款金额(商品价+运费分摊)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:4811。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-035:订单的异常处理(缺货、地址错误)

元信息

项目内容
题目编号Q-ECOM-TRADE-035
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:4984

题干与约束

订单履约过程中可能出现异常(缺货、地址无法送达、商品损坏)。如何设计异常处理流程?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 订单履约过程中可能出现异常(缺货、地址无法送达、商品损坏)。如何设计异常处理流程?

答案

异常场景及处理方案

  1. 库存不足(超卖)

    // 发现超卖
    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)
    }
    
  2. 地址无法送达

    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
    }
    
  3. 商品损坏

    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)
    }
    

延伸思考

  1. 异常订单如何统计和分析?
  2. 如何设计异常的自动化处理规则?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:4984。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-036:订单的搜索和查询优化

元信息

项目内容
题目编号Q-ECOM-TRADE-036
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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. 订单数据如何归档(如1年前的订单)?
  2. 分库分表+ES同步如何保证一致性?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5054。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-037:订单的消息通知设计

元信息

项目内容
题目编号Q-ECOM-TRADE-037
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5180

题干与约束

订单状态变化时需要通知用户(下单成功、发货、签收)。如何设计消息通知系统?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 订单状态变化时需要通知用户(下单成功、发货、签收)。如何设计消息通知系统?

答案

通知渠道

  1. App推送
  2. 短信
  3. 微信公众号/服务号
  4. 站内信
  5. 邮件

推荐方案(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
}

延伸思考

  1. 通知失败如何重试?
  2. 如何设计通知的用户偏好设置(关闭某些通知)?
  3. 大批量通知如何限流(避免骚扰)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5180。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-038:订单数据的冷热分离

元信息

项目内容
题目编号Q-ECOM-TRADE-038
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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
}

延伸思考

  1. 归档订单如何支持查询?
  2. 冷数据恢复到热库的策略?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5332。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-039:订单的实时数据统计

元信息

项目内容
题目编号Q-ECOM-TRADE-039
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签订单编排状态机
场景标签交易主链路履约协作
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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
}

延伸思考

  1. 实时统计如何保证准确性(与离线对账)?
  2. 多维度统计(按类目、品牌)如何设计?


评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5520。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-040:支付系统的整体架构设计

元信息

项目内容
题目编号Q-ECOM-TRADE-040
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签支付状态机资金安全
场景标签支付回调资金链路
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5603

题干与约束

电商平台需要支持多种支付方式(支付宝、微信、银行卡)。如何设计支付系统的整体架构?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台需要支持多种支付方式(支付宝、微信、银行卡)。如何设计支付系统的整体架构?

答案

问题分析: 支付系统的核心要素:

  1. 多渠道接入(支付宝、微信、银联)
  2. 支付安全性
  3. 异步回调处理
  4. 对账和资金安全

架构设计(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)
}

延伸思考

  1. 支付系统如何实现高可用?
  2. 支付渠道故障如何降级?
  3. 支付回调丢失如何处理?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5603。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-041:支付回调的幂等性处理

元信息

项目内容
题目编号Q-ECOM-TRADE-041
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签支付状态机资金安全
场景标签支付回调资金链路
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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()
}

延伸思考

  1. 如何设计支付回调的重试机制?
  2. 回调处理失败如何人工介入?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5824。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-042:支付的对账系统设计

元信息

项目内容
题目编号Q-ECOM-TRADE-042
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签支付状态机资金安全
场景标签支付回调资金链路
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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
}

延伸思考

  1. 对账差异如何自动修复?
  2. 对账失败如何告警和处理?
  3. 实时对账和T+1对账如何结合?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5910。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-043:支付的异步回调处理

元信息

项目内容
题目编号Q-ECOM-TRADE-043
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签支付状态机资金安全
场景标签支付回调资金链路
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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)
}

延伸思考

  1. 回调接口如何防止伪造(恶意请求)?
  2. 回调处理超时如何设置?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:6094。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-044:支付的分账系统设计(平台+商家)

元信息

项目内容
题目编号Q-ECOM-TRADE-044
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签支付状态机资金安全
场景标签支付回调资金链路
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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:月结算(新商家)

延伸思考

  1. 如何设计结算的对账机制?
  2. 商家提现如何设计?
  3. 结算失败如何处理?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:6223。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-045:支付密码和安全设计

元信息

项目内容
题目编号Q-ECOM-TRADE-045
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签支付状态机资金安全
场景标签支付回调资金链路
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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
}

延伸思考

  1. 支付密码如何加密存储?
  2. 如何设计支付的二次确认(大额支付)?
  3. 支付安全如何平衡用户体验?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:6341。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-046:支付的退款处理

元信息

项目内容
题目编号Q-ECOM-TRADE-046
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签支付状态机资金安全
场景标签支付回调资金链路
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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:       "部分退货",
	})
}

延伸思考

  1. 退款失败如何通知用户?
  2. 如何设计退款的限额控制(防止洗钱)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:6599。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-TRADE-047:预授权支付的设计(酒店、租车场景)

元信息

项目内容
题目编号Q-ECOM-TRADE-047
题型系统设计题
范围领域级:搜索、结算、订单与支付主链路
难度中等
建议用时30 分钟
能力标签支付状态机资金安全
场景标签支付回调资金链路
能力域搜索、购物车、订单与支付
来源books/system-design-architecture-book/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
}

延伸思考

  1. 预授权过期如何自动解冻?
  2. 预授权场景下的对账如何设计?


评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:6887。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

大促、秒杀、容量与可靠性

本节覆盖大促、秒杀、容量与可靠性。

专题答辩资料:搜索导购容量估算口径

容量估算(面试与立项常问)可以按乘法拆解:峰值 QPS × 每页条数 ×(Hydrate 下游 RPC 数)≈ 下游扇出;再叠加 重试风暴 系数(建议保守取 1.3~1.8)。ES 侧则关注 分片热点(超大店、超级品牌)与 聚合查询 对 CPU 的抢占;必要时对 shop 场景做 单独索引或单独副本组,把热点从全站检索隔离出去,成本换稳定性。

Q-ECOM-PEAK-001:大促期间如何保证系统稳定性?

元信息

项目内容
题目编号Q-ECOM-PEAK-001
题型系统设计题
范围平台级:峰值流量、容量规划与可靠性
难度进阶
建议用时45 分钟
能力标签容量规划稳定性治理容灾恢复
场景标签大促峰值故障演练
能力域大促、秒杀、容量与可靠性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:215

题干与约束

公司准备参加双11大促,预估流量是平时的50倍,订单量达到平时的100倍。你需要确保系统在大促期间稳定运行,请设计保障方案。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 公司准备参加双11大促,预估流量是平时的50倍,订单量达到平时的100倍。你需要确保系统在大促期间稳定运行,请设计保障方案。

答案

问题分析: 大促稳定性的核心挑战:

  1. 流量突增:如何应对峰值流量(平时5000 QPS → 25万 QPS)
  2. 资源瓶颈:数据库、缓存、网络等基础设施能否支撑
  3. 热点问题:少量商品承载大部分流量
  4. 故障隔离:如何避免局部故障扩散为全局故障

方案一:垂直扩容+全链路压测

核心思路: 通过提升单机性能和压测验证来保障稳定性。

技术方案:

  1. 数据库层:升级配置(32核128G → 64核256G),增加只读从库
  2. 应用层:服务器扩容(50台 → 200台),JVM调优
  3. 缓存层:Redis扩容(3节点 → 12节点),缓存预热
  4. 压测验证:提前1个月进行全链路压测,发现瓶颈

优点:

  • 改动小,风险可控
  • 实施周期短
  • 回退方便

缺点:

  • 成本高(需要高配机器)
  • 扩展性有限
  • 单点风险依然存在

方案二:限流降级+分级保障

核心思路: 通过限流降级保护核心链路,非核心功能可降级。

技术方案:

  1. 多级限流

    • 网关层:总QPS限流(25万)
    • 服务层:单服务限流(如订单创建10万)
    • 接口层:单接口限流(如查询详情5万)
  2. 分级降级

    • P0核心:下单、支付、查询订单(必保)
    • P1重要:搜索、详情、加购(降级返回缓存)
    • P2一般:推荐、评论、收藏(直接关闭)
  3. 熔断机制:连续失败达阈值后自动熔断

优点:

  • 保护核心链路
  • 成本可控
  • 局部故障不扩散

缺点:

  • 用户体验下降(部分功能不可用)
  • 降级逻辑需要提前准备
  • 限流阈值难以精确设定

方案三:异步化+削峰填谷

核心思路: 将同步操作改为异步,通过消息队列削峰填谷。

技术方案:

  1. 订单异步化:下单成功后立即返回,后台异步处理
  2. 库存预占:Redis预扣,后台异步同步到DB
  3. 消息队列:Kafka承载峰值流量,消费者慢慢处理
  4. 任务调度:非实时任务延迟处理(如数据统计)

优点:

  • 峰值流量平滑处理
  • 用户体验好(快速响应)
  • 系统压力平缓

缺点:

  • 架构改造较大
  • 数据最终一致性
  • 异常处理复杂

方案对比

维度垂直扩容限流降级异步化
成本★★☆☆☆★★★★☆★★★☆☆
用户体验★★★★★★★★☆☆★★★★☆
实施难度★★★★☆★★★☆☆★★☆☆☆
扩展性★★☆☆☆★★★☆☆★★★★★

推荐方案: 采用三种方案的组合:垂直扩容作为基础,限流降级作为保护,异步化作为优化。

实施要点:

  1. 提前3个月准备:容量规划、全链路压测、应急演练
  2. 分级保障策略:明确P0/P1/P2功能,准备降级开关
  3. 实时监控:QPS、成功率、响应时间、错误率
  4. 应急预案:数据库主从切换、缓存雪崩处理、快速扩容
  5. 值班机制:7×24值守,关键节点实时响应

延伸思考

  1. 如何评估系统需要的容量?
  2. 大促期间出现故障如何应急?
  3. 如何验证限流降级策略是否有效?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:215。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-PEAK-002:设计电商系统的多机房多活架构

元信息

项目内容
题目编号Q-ECOM-PEAK-002
题型系统设计题
范围平台级:峰值流量、容量规划与可靠性
难度进阶
建议用时45 分钟
能力标签容量规划稳定性治理容灾恢复
场景标签大促峰值故障演练
能力域大促、秒杀、容量与可靠性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:325

题干与约束

电商平台需要支持异地多活,要求在任一机房故障时,业务可以快速切换到其他机房继续提供服务。请设计多机房多活方案。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商平台需要支持异地多活,要求在任一机房故障时,业务可以快速切换到其他机房继续提供服务。请设计多机房多活方案。

答案

问题分析: 多机房多活的核心挑战:

  1. 数据一致性:跨机房数据同步延迟和一致性保证
  2. 流量路由:如何将用户请求路由到合适的机房
  3. 故障切换:机房故障时如何快速切换
  4. 成本控制:多机房部署成本是单机房的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个机房)
  • 数据一致性复杂(跨城同步)
  • 运维复杂度高

方案对比

维度单元化两地三中心三地五中心
数据一致性★★★★☆★★★★☆★★★☆☆
成本★★★☆☆★★★★☆★☆☆☆☆
容灾能力★★★☆☆★★★★☆★★★★★
实施难度★★★★☆★★★☆☆★★☆☆☆

推荐方案: 对于中大型电商,推荐两地三中心+单元化的组合方案。

实施要点:

  1. 单元划分:按用户ID分8个单元,每个单元在2个机房部署
  2. 同城双活:北京A、B机房互为主备
  3. 异地灾备:上海C机房作为灾备
  4. 路由策略:用户→单元→机房的三级路由
  5. 数据同步:同城强同步(Raft/Paxos),异地异步同步
  6. 故障切换:自动检测+自动切换,RTO<5分钟

延伸思考

  1. 如何处理跨单元的全局数据(如商品库存)?
  2. 机房故障时如何保证不丢数据?
  3. 如何验证多活架构的有效性?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:325。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-PEAK-003:设计电商系统的监控告警体系

元信息

项目内容
题目编号Q-ECOM-PEAK-003
题型系统设计题
范围平台级:峰值流量、容量规划与可靠性
难度进阶
建议用时45 分钟
能力标签容量规划稳定性治理可观测性
场景标签大促峰值故障演练
能力域大促、秒杀、容量与可靠性
来源books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1098

题干与约束

电商系统涉及十几个微服务,需要建立完善的监控告警体系。请设计监控方案,确保能及时发现和处理线上问题。

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 电商系统涉及十几个微服务,需要建立完善的监控告警体系。请设计监控方案,确保能及时发现和处理线上问题。

答案

问题分析: 监控告警的核心挑战:

  1. 监控什么:需要监控哪些指标
  2. 如何监控:使用什么工具和技术
  3. 告警策略:如何避免告警疲劳
  4. 快速定位:如何从告警快速定位问题

方案一:基础监控(单维度)

核心思路: 监控基础指标(CPU、内存、磁盘),发现异常告警。

监控内容:

  • 主机监控:CPU、内存、磁盘、网络
  • 应用监控:JVM(堆内存、GC)
  • 接口监控:QPS、响应时间、错误率
  • 日志监控:ERROR日志、异常堆栈

工具选择:

  • 指标采集:Prometheus
  • 可视化:Grafana
  • 告警:Prometheus Alertmanager

优点:

  • 覆盖基础指标
  • 开源免费
  • 社区活跃

缺点:

  • 单维度监控,难以关联分析
  • 告警规则简单(阈值告警)
  • 缺少业务视角

方案二:全链路监控(多维度)

核心思路: 结合指标、日志、链路追踪三个维度,全方位监控。

监控体系:

  1. 指标监控(Metrics):

    • RED指标:Rate(QPS)、Error(错误率)、Duration(延迟)
    • USE指标:Utilization(使用率)、Saturation(饱和度)、Error(错误)
    • 业务指标:订单量、支付成功率、库存水位
  2. 日志监控(Logging):

    • 结构化日志:JSON格式,包含traceId
    • 日志聚合:ELK(Elasticsearch + Logstash + Kibana)
    • 日志告警:关键错误日志触发告警
  3. 链路追踪(Tracing):

    • APM工具:Skywalking / Jaeger
    • 调用链可视化:拓扑图、火焰图
    • 性能分析:慢查询、慢接口
  4. 关联分析

    • traceId串联三个维度
    • 从告警跳转到日志和链路
    • 快速定位问题根因

优点:

  • 立体化监控,全面覆盖
  • 可快速定位问题
  • 支持根因分析

缺点:

  • 成本高(存储、计算)
  • 运维复杂度高
  • 需要统一traceId

方案三:智能监控(AIOps)

核心思路: 基于机器学习,智能检测异常和预测故障。

核心能力:

  1. 异常检测

    • 基线学习:学习历史数据建立基线
    • 异常识别:偏离基线自动告警
    • 减少误报:智能过滤噪音
  2. 根因分析

    • 故障关联:自动分析告警之间的关联
    • 根因定位:从众多告警中识别根因
    • 建议修复:推荐修复方案
  3. 容量预测

    • 趋势分析:分析资源使用趋势
    • 容量预警:提前预警资源不足
    • 扩容建议:推荐扩容方案

优点:

  • 智能化,减少人工成本
  • 预测性,提前发现问题
  • 自动化根因分析

缺点:

  • 成本高(算法研发、算力)
  • 需要大量历史数据
  • 准确率受限

方案对比

维度基础监控全链路监控智能监控
实施成本★★★★★★★★☆☆★☆☆☆☆
覆盖全面性★★☆☆☆★★★★★★★★★★
定位效率★★☆☆☆★★★★☆★★★★★
适用规模小型中大型大型

推荐方案: 对于中大型电商系统,推荐全链路监控

实施要点:

  1. 指标体系设计

    • 黄金指标(核心业务):

      • 订单创建成功率 > 99.9%
      • 支付成功率 > 99.95%
      • 接口P99延迟 < 500ms
    • 基础指标(资源):

      • CPU使用率 < 70%
      • 内存使用率 < 80%
      • 磁盘使用率 < 85%
    • 业务指标(自定义):

      • 实时订单量
      • 库存水位
      • 支付渠道成功率
  2. 告警分级

    • P0(紧急):影响核心功能(下单、支付失败),5分钟响应
    • P1(重要):影响部分功能(搜索慢、详情页错误),30分钟响应
    • P2(一般):资源告警(CPU高、磁盘满),2小时响应
    • P3(提示):趋势告警(流量增长、容量预警),工作时间处理
  3. 告警策略

    • 避免告警疲劳:合并同类告警、设置静默期
    • 智能降噪:工作时间和非工作时间不同阈值
    • 分级通知:P0电话+短信,P1钉钉,P2邮件
    • 告警收敛:同一问题5分钟内只告警一次
  4. 监控大盘

    • 业务大盘:实时订单量、支付成功率、GMV
    • 应用大盘:服务健康度、QPS、错误率、P99延迟
    • 基础设施大盘:主机、数据库、缓存、消息队列
    • 告警大盘:实时告警、告警趋势、MTTR
  5. 应急响应

    • SOP(标准操作流程):不同告警对应的处理步骤
    • On-call轮值:7×24值班,保证及时响应
    • 故障复盘:每次P0/P1故障必须复盘,沉淀经验
    • 演练机制:定期故障演练,验证应急预案

延伸思考

  1. 如何设计监控指标体系(业务指标 vs 技术指标)?
  2. 如何避免告警疲劳(告警太多导致麻木)?
  3. 监控数据如何存储和查询(时序数据库)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/07-ecommerce-basics-interview.md:1098。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-PEAK-004:大促场景下的库存预热和削峰方案

元信息

项目内容
题目编号Q-ECOM-PEAK-004
题型系统设计题
范围平台级:峰值流量、容量规划与可靠性
难度进阶
建议用时45 分钟
能力标签容量规划稳定性治理削峰与预热
场景标签大促峰值故障演练秒杀活动
能力域大促、秒杀、容量与可靠性
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4055

题干与约束

双11大促,预计订单量是平时的100倍。如何对库存系统进行预热和削峰,保证不超卖且性能可控?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 双11大促,预计订单量是平时的100倍。如何对库存系统进行预热和削峰,保证不超卖且性能可控?

答案

问题分析: 大促库存的核心挑战:

  1. 瞬时流量暴增(平时1000 QPS → 10万 QPS)
  2. 热点商品集中(TOP 100商品占80%流量)
  3. Redis/DB压力大
  4. 需要防止库存击穿

方案一:库存分段+令牌桶

核心思想: 将库存分为多段,每段独立扣减,最后汇总。

设计:

库存分段:
总库存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. 扣减失败(无货)通知用户

优点:

  • 削峰效果好
  • 库存系统压力可控
  • 用户体验可接受(秒杀场景)

缺点:

  • 用户等待时间长
  • 需要排队机制
  • 实现复杂

方案对比

方案性能削峰效果用户体验实施难度
库存分段★★★★☆★★★☆☆★★★★★★★★☆☆
本地库存★★★★★★★★★★★★★★★★★★☆☆
队列削峰★★★☆☆★★★★★★★★☆☆★★☆☆☆

推荐方案: 采用库存分段+本地库存的组合。

实施要点:

  1. 库存预热

    大促前3天:
    1. 识别热销商品(TOP 1000)
    2. Redis预加载:
       - 库存数据
       - 商品信息
       - 价格信息
    3. 本地缓存预加载
    4. 压测验证
    
  2. 分段策略

    分段数量 = max(库存数量 / 100, 服务器数量)
    
    示例:库存10000,服务器100台
    → 分段数 = max(10000/100, 100) = 100段
    → 每段100件
    
    优点:
    - 降低单key热度
    - 并发度=分段数
    
  3. 本地库存管理

    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;
      }
    }
    
  4. 监控大盘

    实时监控:
    - 总库存水位
    - 扣减QPS
    - 成功率
    - Redis热key
    - 本地库存分布
    
    告警:
    - 库存水位 < 20%
    - 扣减失败率 > 5%
    - Redis单key QPS > 10万
    

延伸思考

  1. 秒杀开始前如何预热(避免冷启动)?
  2. 大促结束后如何回收本地库存?
  3. 如何应对恶意刷单占用库存?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:4055。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-PEAK-005:秒杀活动的价格和库存设计

元信息

项目内容
题目编号Q-ECOM-PEAK-005
题型系统设计题
范围平台级:峰值流量、容量规划与可靠性
难度进阶
建议用时45 分钟
能力标签容量规划稳定性治理削峰与预热
场景标签大促峰值故障演练秒杀活动
能力域大促、秒杀、容量与可靠性
来源books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7696

题干与约束

秒杀活动商品价格远低于平时,流量集中,如何设计秒杀的价格和库存系统,保证不超卖且性能可控?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 秒杀活动商品价格远低于平时,流量集中,如何设计秒杀的价格和库存系统,保证不超卖且性能可控?

答案

问题分析: 秒杀的核心挑战:

  1. 瞬时高并发(10万+ QPS)
  2. 库存精准控制(100件商品,10万人抢)
  3. 价格隔离(秒杀价和正常价不能混淆)
  4. 防黄牛(防止脚本抢购)

方案一:独立秒杀表

核心思想: 秒杀商品和库存独立存储,与正常商品隔离。

设计:

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)
- 秒杀订单(独立表)
- 秒杀队列(削峰)

正常系统:
- 商品表
- 库存表
- 订单表

数据同步:
- 秒杀结束后同步到正常订单表
- 库存变更同步

优点:

  • 完全隔离
  • 互不影响
  • 可针对性优化

缺点:

  • 架构复杂
  • 数据同步成本

方案对比

方案隔离性性能实施难度适用场景
独立秒杀表★★★★☆★★★★☆★★★☆☆中小型
共享表★★☆☆☆★★★☆☆★★★★★小型
分层架构★★★★★★★★★★★★☆☆☆大型

推荐方案: 采用独立秒杀表

实施要点:

  1. 秒杀库存

    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
    
  2. 限购控制

    Redis Set记录已购买用户:
    key: seckill:bought:{seckill_id}
    value: Set<user_id>
    
    检查:
    if (redis.sismember(key, user_id)) {
      return "已购买,不能重复购买";
    }
    
    记录:
    redis.sadd(key, user_id);
    
  3. 排队机制

    流程:
    1. 用户点击"立即抢购"
    2. 请求进入队列(Kafka)
    3. 显示排队位置
    4. Worker消费队列,限速扣减库存
    5. 扣减成功,通知用户
    6. 扣减失败,提示已售罄
    
    优点:
    - 削峰
    - 用户体验可控
    - 系统稳定
    
  4. 防黄牛

    策略1:验证码
    - 点击抢购后弹出验证码
    - 通过验证才能提交订单
    
    策略2:实人认证
    - 首次参与秒杀需要实人认证
    - 人脸识别
    
    策略3:行为分析
    - 检测异常高频请求
    - IP黑名单
    - 设备指纹
    
  5. 价格展示

    商品详情页:
    - 正常价:¥999(划线价)
    - 秒杀价:¥199(红色突出显示)
    - 倒计时:距开始还剩 01:23:45
    - 提醒:每人限购1件
    

延伸思考

  1. 秒杀订单未支付如何处理(是否释放库存)?
  2. 秒杀活动如何预热(提前加载数据)?
  3. 秒杀流量如何监控和应急处理?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/08-product-inventory-marketing-pricing-questionbank.md:7696。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-PEAK-006:订单的限流和防刷

元信息

项目内容
题目编号Q-ECOM-PEAK-006
题型系统设计题
范围平台级:峰值流量、容量规划与可靠性
难度进阶
建议用时45 分钟
能力标签容量规划稳定性治理容灾恢复
场景标签大促峰值故障演练
能力域大促、秒杀、容量与可靠性
来源books/system-design-architecture-book/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, "订单完成")
	}
}

延伸思考

  1. 如何识别黄牛和恶意用户?
  2. 限流策略如何针对不同用户等级差异化?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:5412。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-PEAK-007:支付渠道的路由和降级

元信息

项目内容
题目编号Q-ECOM-PEAK-007
题型系统设计题
范围平台级:峰值流量、容量规划与可靠性
难度进阶
建议用时45 分钟
能力标签容量规划稳定性治理支付降级
场景标签大促峰值故障演练支付高峰
能力域大促、秒杀、容量与可靠性
来源books/system-design-architecture-book/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},
		},
	}
}

延伸思考

  1. 如何设计支付渠道的成本优化(选择手续费低的)?
  2. 支付渠道限额如何处理?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:6485。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-PEAK-008:支付的容灾和降级

元信息

项目内容
题目编号Q-ECOM-PEAK-008
题型系统设计题
范围平台级:峰值流量、容量规划与可靠性
难度进阶
建议用时45 分钟
能力标签容量规划稳定性治理支付降级
场景标签大促峰值故障演练支付高峰
能力域大促、秒杀、容量与可靠性
来源books/system-design-architecture-book/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("熔断器关闭,恢复正常")
	}
}

延伸思考

  1. 如何设计支付系统的多机房容灾?
  2. 支付降级后如何通知用户?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/09-search-cart-order-payment-questionbank.md:6764。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-PEAK-009:100 万酒店 10 小时如何估算吞吐?

元信息

项目内容
题目编号Q-ECOM-PEAK-009
题型系统设计题
范围平台级:峰值流量、容量规划与可靠性
难度进阶
建议用时45 分钟
能力标签容量规划稳定性治理容灾恢复
场景标签大促峰值故障演练
能力域大促、秒杀、容量与可靠性
来源books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:506

题干与约束

100 万酒店 10 小时如何估算吞吐?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

参考回答:100 万 / 10 小时约等于 27.8 个酒店/秒。如果每页 100 个酒店,大约需要 10000 页,10 小时内只需要 0.28 页/秒。但如果要逐个拉详情,就是 27.8 QPS,还要受供应商限流、超时、重试和数据处理速度约束。

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/03-ecommerce-architecture-interview.md:506。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

综合案例与白板题

本节覆盖综合案例与白板设计。

Q-ECOM-CASE-001:设计一个百万级QPS的商品详情页系统

元信息

项目内容
题目编号Q-ECOM-CASE-001
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡高并发读
场景标签综合案例白板推演商品详情
能力域综合案例与白板设计
来源books/system-design-architecture-book/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
}

性能优化点

  1. 多级缓存:本地内存(1ms)→ Redis(5ms)→ MySQL(50ms)
  2. CDN静态化:核心商品预生成HTML,加载时间<100ms
  3. 缓存预热:大促前1小时预热热门商品
  4. 异步刷新:Cache Aside模式,回源不阻塞请求
  5. 降级策略:Redis故障时降级到MySQL+限流

容量规划

QPS:100万
平均响应时间:50ms
并发连接数:100万 * 0.05 = 5万

服务器配置:
- 应用服务器:100台(每台1万QPS)
- Redis集群:50个主节点(每节点2万QPS)
- MySQL:主从+分库分表(32个分片)

延伸思考

  1. 商品详情页的AB测试如何设计?
  2. 图片加载优化(WebP、懒加载)如何实现?
  3. 缓存雪崩如何防范?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:13。本章不依赖旧 Part Four 文件链接。

相关章节:商品中心

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-CASE-002:秒杀系统的完整设计

元信息

项目内容
题目编号Q-ECOM-CASE-002
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡削峰与库存一致性
场景标签综合案例白板推演秒杀活动
能力域综合案例与白板设计
来源books/system-design-architecture-book/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)
		}
	}
}

架构要点

  1. 前端限流:按钮置灰、验证码、排队页
  2. 网关限流:Nginx限流(10万QPS)
  3. Redis预扣库存:Lua脚本原子操作
  4. 异步下单:MQ削峰,提升吞吐
  5. 超时取消:15分钟未支付自动取消+回补库存

延伸思考

  1. 秒杀如何防止黄牛?
  2. 分布式锁如何选型(Redis vs Etcd)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:182。本章不依赖旧 Part Four 文件链接。

相关章节:营销系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-CASE-003:订单履约的全链路监控

元信息

项目内容
题目编号Q-ECOM-CASE-003
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡交易编排
场景标签综合案例白板推演订单履约
能力域综合案例与白板设计
来源books/system-design-architecture-book/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
}

延伸思考

  1. 如何设计告警规则(P95延迟>500ms告警)?
  2. 如何追踪跨语言调用链(Go → Java → Python)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:387。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-CASE-004:大促准备的全链路压测

元信息

项目内容
题目编号Q-ECOM-CASE-004
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡韧性工程
场景标签综合案例白板推演大促峰值
能力域综合案例与白板设计
来源books/system-design-architecture-book/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

延伸思考

  1. 如何设计压测数据构造?
  2. 压测导致的脏数据如何清理?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:517。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-CASE-005:跨境电商的多币种结算

元信息

项目内容
题目编号Q-ECOM-CASE-005
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡资金安全
场景标签综合案例白板推演跨境支付
能力域综合案例与白板设计
来源books/system-design-architecture-book/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
}

延伸思考

  1. 汇率波动如何处理(订单创建时汇率 vs 支付时汇率)?
  2. 跨境支付的关税如何计算?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:706。本章不依赖旧 Part Four 文件链接。

相关章节:支付系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-CASE-006:电商搜索的智能排序

元信息

项目内容
题目编号Q-ECOM-CASE-006
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡搜索架构
场景标签综合案例白板推演搜索导购
能力域综合案例与白板设计
来源books/system-design-architecture-book/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),
		// ... 更多特征
	}
}

延伸思考

  1. 如何设计AB测试验证排序效果?
  2. 如何平衡新品曝光和热销商品?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:885。本章不依赖旧 Part Four 文件链接。

相关章节:搜索与导购

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-CASE-007:异常流量的应急处理

元信息

项目内容
题目编号Q-ECOM-CASE-007
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡韧性工程
场景标签综合案例白板推演大促峰值
能力域综合案例与白板设计
来源books/system-design-architecture-book/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",
	},
}

延伸思考

  1. 如何设计自动化的应急响应系统?
  2. 如何平衡防御和用户体验(误封正常用户)?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:1065。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-CASE-008:订单的柔性事务设计

元信息

项目内容
题目编号Q-ECOM-CASE-008
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡交易编排
场景标签综合案例白板推演订单履约
能力域综合案例与白板设计
来源books/system-design-architecture-book/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
}

延伸思考

  1. 本地消息表 vs Saga vs TCC如何选择?
  2. 消息发送失败如何保证最终一致性?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:1240。本章不依赖旧 Part Four 文件链接。

相关章节:订单系统

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-CASE-009:用户画像系统的设计

元信息

项目内容
题目编号Q-ECOM-CASE-009
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡数据建模
场景标签综合案例白板推演用户运营
能力域综合案例与白板设计
来源books/system-design-architecture-book/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 "低"
	}
}

延伸思考

  1. 如何保护用户隐私(GDPR合规)?
  2. 画像准确性如何评估?

评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:1413。本章不依赖旧 Part Four 文件链接。

相关章节:电商客户生命周期

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

Q-ECOM-CASE-010:大促后的系统复盘

元信息

项目内容
题目编号Q-ECOM-CASE-010
题型综合案例 / 白板题
范围系统级:跨域方案设计与白板推演
难度进阶
建议用时60 分钟
能力标签系统设计跨域权衡韧性工程
场景标签综合案例白板推演大促峰值
能力域综合案例与白板设计
来源books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:1557

题干与约束

618大促结束后,需要对系统表现进行复盘。如何设计复盘报告,总结经验和改进点?

候选人作答任务

基于题干说明领域边界、权威状态、主链路、失败处理与取舍;对涉及高并发或异步协作的场景,明确容量、幂等、补偿和可观测性。

推荐作答顺序

先澄清业务目标与约束,再给出状态和数据归属,随后说明主流程、异常路径、关键权衡以及上线后的监控和补偿闭环。

答案骨架

以下保留原题的完整分析、方案、实现细节、示例与延伸材料:

问题描述: 618大促结束后,需要对系统表现进行复盘。如何设计复盘报告,总结经验和改进点?

答案

复盘维度

  1. 业务指标

    • GMV:50亿
    • 订单量:1000万
    • 转化率:3.5%
    • 客单价:500元
  2. 技术指标

    • 峰值QPS:50万
    • 平均响应时间:200ms
    • P99响应时间:800ms
    • 可用性:99.95%
  3. 故障复盘

    • 23:00-23:15 订单服务QPS突增导致响应变慢
    • 根因:数据库连接池不足
    • 影响:15分钟内订单延迟,影响1000笔订单
    • 改进:增加连接池大小,增加熔断降级
  4. 优化建议

    • 缓存命中率从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
}

延伸思考

  1. 如何设计大促演练(压测、故障演练)?
  2. 如何量化技术优化的ROI?


评分锚点

  • 能说明问题中的业务边界与权威数据,而非只罗列组件。
  • 能覆盖正常路径、重复、超时、乱序、失败补偿和可观测性。
  • 能说明方案取舍,并将一致性、性能与运营成本落到具体机制。

递进追问

  • 哪些步骤必须同步确认,哪些步骤可以通过事件最终一致?
  • 在重试、并发和下游不可用时,如何保证状态可恢复、可追踪?
  • 规模扩大十倍后,热点、容量、隔离和降级策略如何变化?

常见失分点

  • 混淆领域事实、派生读模型和流程状态。
  • 只描述成功路径,遗漏幂等、对账、补偿或人工处理入口。
  • 把缓存、消息队列或搜索索引错误地当作交易权威来源。

关联正文

迁移来源:books/system-design-architecture-book/src/part03/10-ecommerce-case-studies-interview.md:1557。本章不依赖旧 Part Four 文件链接。

相关章节:生产韧性与稳定性保障

复盘清单

  • 我是否定义了数据所有权、状态机和跨域协作边界?
  • 我是否解释了失败、重试、对账和补偿如何闭环?
  • 我是否给出了与业务规模相匹配的性能与可靠性取舍?

第 40 章 系统设计模拟面试

本章提供基于题卡的限时模拟流程。候选人应在每个阶段主动报出假设、同步思考过程,并在结束时用自评问题复盘。

30 分钟:通用后端系统设计

本场使用三张题卡:Q-GEN-CAP-01Q-GEN-DATA-01Q-GEN-REL-02

阶段时长候选人任务面试官指引
开场与假设4 分钟确认业务目标、使用者、规模口径和非目标。只补充必要约束,记录候选人主动提出的假设。
容量推导7 分钟围绕 Q-GEN-CAP-01 给出计算口径,并把结果落到组件规模。追问峰谷差异和余量来源,观察推导是否自洽。
数据边界9 分钟围绕 Q-GEN-DATA-01 说明关键状态、读写路径与责任边界。要求明确每个关键状态的归属和更新顺序。
可靠性追问7 分钟围绕 Q-GEN-REL-02 处理调用失败、重复与恢复情形。改变一个失败条件,观察方案是否仍能闭环。
收束3 分钟总结方案、主要风险和下一步验证项。请候选人指出一个最需要取舍的决定。

面试官指引:

  • 按题卡顺序推进,不朗读题卡正文,也不提前给出实现选项。
  • 候选人跳过假设或量化依据时,要求其补足依据后再进入下一阶段。
  • 记录其是否能把容量、数据权威和失败处理连接为同一条链路。

候选人自评问题:

  • 我是否在开始阶段说明了范围、数量级和关键假设?
  • 我是否明确了关键状态的归属,以及读写路径的责任边界?
  • 我是否对失败、重试和重复结果给出了可以验证的处理方式?

45 分钟:电商专项系统设计

本场使用三张题卡:Q-ECOM-ARCH-001Q-ECOM-TRADE-026Q-ECOM-PEAK-004

阶段时长候选人任务面试官指引
业务边界7 分钟确认平台角色、交易范围和最重要的业务约束。用一个边界变化检查候选人的领域划分。
架构拆分11 分钟围绕 Q-ECOM-ARCH-001 给出服务边界、依赖关系和核心接口。追问边界两侧的数据归属与同步方式。
交易主线13 分钟围绕 Q-ECOM-TRADE-026 描述状态演进、触发条件和异常回退。在中途插入一个重复或乱序事件,要求候选人调整说明。
峰值追问9 分钟围绕 Q-ECOM-PEAK-004 说明峰值前准备、入口控制和压力转移。逐步提高流量或缩小资源,观察降级优先级。
收束与复盘5 分钟说明最关键的取舍、风险指标和验证计划。请候选人比较一个备选方案并作出选择。

面试官指引:

  • 先确认交易语义,再要求候选人画出架构与状态变化,避免只讨论组件名。
  • 每次追问只改变一个约束,保留候选人上一轮方案作为比较基线。
  • 重点观察服务边界、状态演进和峰值处理是否相互一致。

候选人自评问题:

  • 我是否先确定了业务边界与数据归属,再划分服务职责?
  • 我是否完整说明了交易状态的进入、退出和异常分支?
  • 我是否把峰值措施与日常架构的责任边界说清楚?

60 分钟:高级系统设计答辩

主案例使用 Q-ECOM-CASE-002。递进追问依次使用 Q-ECOM-SUPPLY-019Q-ECOM-TRADE-030Q-ECOM-PEAK-006

阶段时长候选人任务面试官指引
约束协商8 分钟明确目标、成功标准、规模口径、资源边界和不可接受的风险。至少引入一个冲突约束,要求候选人排序优先级。
白板架构15 分钟围绕 Q-ECOM-CASE-002 在白板画出入口、核心链路、状态存储与依赖关系。要求图中每条关键连线都说明方向、同步方式和失败后的去向。
递进追问一10 分钟围绕 Q-ECOM-SUPPLY-019 调整关键资源的并发控制与校验边界。追问高并发下的结果判定和恢复责任。
递进追问二10 分钟围绕 Q-ECOM-TRADE-030 补充请求去重、状态衔接和异常后的可恢复性。改变请求到达顺序,检查设计是否仍保持一致。
递进追问三8 分钟围绕 Q-ECOM-PEAK-006 说明入口保护、优先级和观测信号。要求候选人给出触发保护与解除保护的判断依据。
最终取舍讨论9 分钟比较两个可行方案,说明可靠性、成本、复杂度和演进上的取舍。质询最脆弱的假设,并要求候选人说明何时应推翻当前选择。

面试官指引:

  • 白板阶段只要求结构、边界和关键流向;细节应在递进追问中按需展开。
  • 每张追问题卡都以前一阶段方案为前提,要求候选人明确哪些结论保持不变、哪些需要修改。
  • 最终讨论聚焦决策依据,避免把答辩变成对题卡正文的复述。

候选人自评问题:

  • 我的白板是否让他人能看出边界、流向、状态位置和主要故障路径?
  • 我是否在每个递进追问后明确了保留的假设与修改的决定?
  • 我是否用业务影响、可靠性、成本和复杂度解释了最终取舍?

附录 A:术语表

本附录汇总全书高频术语,便于读者在阅读方法论、可靠性和电商实战章节时统一语义。

架构与建模

术语说明
系统设计围绕业务目标、容量、数据、边界、依赖、失败处理和演进路径做出的整体工程设计。
架构系统中最难回退的一组关键决策,包括职责边界、数据事实、集成方式、部署形态和治理机制。
限界上下文(Bounded Context)DDD 中承载统一语言和模型边界的业务语义范围。上下文之间通过显式契约协作。
通用语言(Ubiquitous Language)业务、产品、研发、测试和运营共同使用的一组稳定术语,用来减少沟通偏差。
聚合(Aggregate)DDD 战术设计中的一致性边界,聚合内部维护不变量,聚合之间通过 ID、事件或服务协作。
防腐层(ACL)隔离外部系统、旧系统或第三方模型的转换层,避免外部语义污染核心领域模型。
CQRS命令查询职责分离。写模型关注一致性和状态变更,读模型关注查询效率和展示体验。
ADRArchitecture Decision Record,架构决策记录,用于记录关键取舍、备选方案和后续影响。

数据与一致性

术语说明
SSOTSingle Source of Truth,权威数据源。系统设计中必须明确每类事实由谁负责。
最终一致性系统允许短时间不一致,但必须有明确的收敛路径、最大漂移时间和修复机制。
幂等同一业务请求重复执行多次,最终业务结果保持一致。支付、库存、消息消费和补偿任务必须重点设计。
Outbox在本地事务内写业务数据和待发布事件,再由 Relay 异步投递,降低业务写入和消息发送之间的双写风险。
Saga长事务拆分为多个本地事务,并为每个步骤设计补偿动作,常用于订单、库存、营销和支付协作。
对账比较两个或多个系统中的事实口径,发现差异并推动补偿、人工处理或风险拦截。
补偿主链路未完成或状态不一致时,通过异步任务、事件重放、人工审核等方式让系统回到正确状态。
DLQDead Letter Queue,死信队列。承载无法继续自动处理的失败消息,并进入可观测、可重放、可审计的恢复流程。

可靠性与治理

术语说明
SLIService Level Indicator,服务水平指标,例如成功率、P99 延迟、消息积压时间。
SLOService Level Objective,服务水平目标,用来约束系统在一段时间内应达到的可靠性水平。
SLAService Level Agreement,对外承诺的服务水平协议,通常包含可用性、响应时间和赔付条款。
错误预算SLO 允许的失败空间,用于平衡稳定性和发布速度。
RTORecovery Time Objective,恢复时间目标,表示故障后多久恢复服务。
RPORecovery Point Objective,恢复点目标,表示故障时最多可接受丢失多少数据。
限流控制入口或内部调用流量,防止系统被超过承载能力的请求打穿。
熔断当下游持续异常时快速失败,保护调用方资源并避免故障扩散。
降级在非核心能力异常时牺牲部分体验,优先保护核心业务成功率。
舱壁隔离将线程池、连接池、队列或部署单元隔离,避免一个依赖拖垮整个系统。

电商核心概念

术语说明
SPUStandard Product Unit,标准商品单元,表达一组共同商品属性。
SKUStock Keeping Unit,库存计量单元,通常对应可售规格、库存和价格粒度。
Offer面向售卖的承诺,通常组合商品、价格、渠道、库存、履约和售后规则。
商品快照下单时保存的商品关键事实,用于历史订单解释、纠纷处理、退款和对账。
可售量当前可对外承诺售卖的数量,通常由库存事实、预占、冻结、渠道和安全库存共同决定。
预占在订单或结算阶段临时锁定库存、权益或资源,后续根据支付和订单结果确认或释放。
计价将基础价、营销、会员权益、运费、税费和抵扣合成为可解释、可追溯的应付金额。
资损因系统设计、实现、配置、运营或外部依赖问题导致平台、商家、用户或资金方产生资产损失。
供给治理围绕商品创建、导入、审核、发布、同步、补偿、审计和质量巡检建立的运营侧治理能力。
供应商同步将外部供应商的资源、价格、库存、上下架和履约状态转化为平台可治理数据的链路。

附录 B:参考文献与延伸阅读

本附录收录全书写作中反复涉及的基础资料和延伸阅读。正文以工程判断和实践模型为主,本附录用于帮助读者继续深入。

系统设计与架构方法

  • Martin Kleppmann, Designing Data-Intensive Applications
  • Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software
  • Vaughn Vernon, Implementing Domain-Driven Design
  • Sam Newman, Building Microservices
  • Martin Fowler: Patterns of Enterprise Application Architecture
  • Martin Fowler: Microservices
  • Martin Fowler: CQRS

可靠性、SRE 与工程治理

数据库、中间件与基础设施

分布式事务、消息与一致性

  • Chris Richardson: Microservices Patterns
  • Chris Richardson: Saga Pattern
  • Chris Richardson: Transactional Outbox
  • Pat Helland, Life Beyond Distributed Transactions
  • Nancy Lynch, Seth Gilbert: Brewer’s Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services

电商系统与业务架构

  • 商品、库存、计价、营销、订单和支付章节中的模型来自通用电商业务抽象,可结合所在公司的品类、供应商、履约和监管要求调整。
  • 资金、优惠、库存和退款相关设计应同时咨询财务、法务、风控和客服团队,不能只由研发侧独立决定。
  • 所有涉及支付渠道、银行卡、个人信息、发票、税务和跨境业务的落地方案,都应以当地法律法规和支付机构要求为准。

代码与工程实践

维护建议

  • 新增章节时,在本附录补充对应的官方文档、经典书籍或工程案例。
  • 外部链接优先选择官方文档、原始论文、作者主页或长期维护的资料页。
  • 对强时效内容,例如云产品能力、开源组件版本、支付渠道规范,应在正文中注明“以官方最新文档为准”。

附录 C:工具与构建说明

mdBook

与仓库内其他 mdBook 相同:安装 mdBook,可选安装 mdbook-mermaid 以渲染 Mermaid。

cd books/system-design-architecture-book
mdbook build
mdbook serve

Mermaid

book.tomladditional-js 指向与 book.toml 同目录下的 mermaid.min.jsmermaid-init.js;Mermaid 代码块由 tools/mermaid-preprocessor.py 统一预处理。若升级版本,请同步检查这两个静态文件。