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

《电商系统架构设计与实现》

面向中大型团队的实战指南

最后更新: 2026-04-18


项目简介

本书是基于作者多年电商系统开发经验,系统梳理电商平台架构设计与工程实践的技术专著。全书以理论与实践结合架构与代码并重的方式,深入讲解从领域建模到系统落地的完整过程。

定位

  • 工程实践指南:提供可落地的实现方案和代码示例
  • 架构设计参考:讲解架构决策过程和系统边界划分

适合读者

  • 中高级后端工程师(3-8年经验)
  • 准备系统设计面试的候选人
  • 电商/O2O 领域的架构师
  • 希望系统学习 DDD 和 Clean Architecture 的开发者

内容结构

全书分为三大部分,共 17 章:

第一部分:架构设计方法论(6章)

  • 第1章:架构师的组合拳
  • 第2章:业务边界与战略设计
  • 第3章:系统内部结构设计
  • 第4章:系统集成与一致性设计
  • 第5章:编码原则与设计模式
  • 第6章:架构质量保障

第二部分:电商核心系统设计(10章)

Part A:全局架构

  • 第7章:电商系统全景图

Part B:商品供给与运营

  • 第8章:商品中心系统
  • 第9章:库存系统
  • 第10章:营销系统
  • 第11章:商品供给管理:运营、库存与生命周期

Part C:交易链路

  • 第12章:计价系统(基础模块:PDP/购物车/创单/支付全链路试算)
  • 第13章:搜索与导购
  • 第14章:购物车与结算
  • 第15章:订单系统
  • 第16章:支付系统

第三部分:综合案例与落地(1章)

  • 第17章:B2B2C 平台完整架构(200人团队、日订单200万)

核心特色

1. 理论与实践深度结合

  • 每个系统都讲解架构设计思路具体实现方案
  • 提供 Go 语言的生产级代码示例
  • 包含真实案例踩坑经验

2. 系统边界与集成贯穿始终

  • 每个核心系统章节都有**“系统边界与职责”**小节
  • 详细讲解与其他系统的集成模式和契约
  • 提供集成失败处理降级策略

3. 完整的知识体系

  • 方法论(Clean Architecture + DDD + CQRS)
  • 核心系统(商品、库存、订单、支付等10个系统)
  • 再到综合案例(200人团队的 B2B2C 平台)

4. 面试与工程双重价值

  • 涵盖系统设计面试的高频考点
  • 提供工程落地的实战方案
  • 附录包含面试题精选集成模式速查表商品供给与运营治理平台供应商数据同步链路完整案例

预期篇幅

850-950 页,包含:

  • 核心正文:约 750 页
  • 附录(技术选型、面试题、术语表等):约 100 页

当前进度:已完成约 15 万字(17 章),预计占正文的 50-60%


相关资源

GitHub 仓库

开源地址github.com/wxquare/wxquare.github.io

仓库内容

  • 书籍完整内容(Markdown 格式)
  • 配套代码示例(Go 语言)
  • 架构图源文件
  • 勘误与讨论(Issues)

博客文章

本书的很多内容源自博客文章,可以提前阅读:

  • 电商系统设计系列
  • 架构与整洁代码系列

许可协议

本书计划采用 CC BY-NC-SA 4.0(署名-非商业性使用-相同方式共享)协议开源:

  • 可以自由阅读和分享
  • 可以用于学习和教学
  • 不可用于商业出版(需获得授权)

最后更新时间:2026-04-23
当前状态:已完成 17 章(第1-17章),共计约 150,000 字
最新进展:第一部分目录结构已重排,第二部分(电商核心系统设计)全部完成!

导航书籍主页 | 完整目录 | 下一章:第2章


第1章 架构师的组合拳

复杂系统设计的方法论地图


1.1 软件架构的本质挑战

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

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

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

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

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

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

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

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

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

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

1.1.2 复杂系统的四类核心矛盾

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


1.2 架构师如何应对这些挑战

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

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

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

1.2.1 先做业务边界

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

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

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

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

这一步对应本书的 第 2 章《业务边界与战略设计》

1.2.2 再建系统内部秩序

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

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

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

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

这一步对应本书的 第 3 章《系统内部结构设计》

1.2.3 再处理系统之间的协作

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

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

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

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

这一步对应本书的 第 4 章《系统集成与一致性设计》

1.2.4 最后落实代码与质量保障

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

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

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

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

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

  • 第 5 章《编码原则与设计模式》
  • 第 6 章《架构质量保障》

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

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


1.3 架构设计的方法论地图

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

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

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

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

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

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

因此,DDD 既属于“先划边界”,也会延伸到“内部结构设计”。这也是为什么本书把它拆成了第 2 章和第 3 章两部分来讲。

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

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

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

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

1.3.3 Clean Architecture:约束依赖方向

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

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

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

1.3.4 CQRS:解决读写目标冲突

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

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

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

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

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

这一组方法论通常包括:

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

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

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

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

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

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

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

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

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


1.4 本章小结

1.4.1 核心观点回顾

本章想传达的核心观点其实很简单:

  • 软件架构面对的不是单一问题,而是一组会长期反复出现的复杂性挑战
  • 架构师真正做的,不是“选择某一个流行概念”,而是按问题层次打一套组合拳
  • 这套组合拳通常遵循固定顺序:先做业务边界,再建系统内部秩序,再处理系统间协作,最后通过代码原则和质量机制把设计守住

也就是说,架构设计的本质不是追逐术语,而是控制复杂性如何在系统中传播。

1.4.2 与后续章节的关系

这一章只是全书的总序,负责建立方法论地图。后续章节会沿着这条主线逐步展开:

  • 第 2 章《业务边界与战略设计》:先讨论系统应该如何划清边界
  • 第 3 章《系统内部结构设计》:讨论单个系统内部如何建立秩序
  • 第 4 章《系统集成与一致性设计》:讨论多个系统之间如何协作
  • 第 5 章《编码原则与设计模式》:讨论架构如何真正落到代码
  • 第 6 章《架构质量保障》:讨论如何用评审和验证机制守住架构质量

读完这一章之后,你不必已经掌握所有细节,但应该先建立一个判断框架:当系统变复杂时,架构师会从哪些层次出手,又该如何把这些方法论组合起来。

1.4.3 延伸阅读

  • 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

下一章第2章 业务边界与战略设计 将深入讲解限界上下文、通用语言与上下文映射,回答系统应该如何划分边界、团队如何围绕边界协作。


导航返回目录 | 书籍主页 | 下一章:第2章

导航书籍主页 | 完整目录 | 上一章:第1章 | 下一章:第3章


第2章 业务边界与战略设计

限界上下文、通用语言与上下文映射,先画对系统的地图


2.1 为什么需要战略设计

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

2.1.1 战术先行常见症状

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

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

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

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

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

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

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

2.1.2 战略设计与第 1 章的分工

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

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

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


2.2 限界上下文(Bounded Context)

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

2.2.1 识别限界上下文

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

  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

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

2.2.2 上下文边界的划分原则

  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[模块化单体中先划清包边界]

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

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

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

限界上下文核心关注点典型聚合(示例)对其他上下文承诺的能力
订单域订单生命周期、应付金额、状态机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 类型,应该追问:「这两处对商品的关注点是否相同?」如果答案是「不同」(一个关注展示,一个关注合同),那么应该拆分为两个独立的类型。宁可有一些字段重复,也不要为了「消除重复」而强行共享模型——这种重复是有意义的重复,体现了不同上下文的自治性。


2.3 通用语言(Ubiquitous Language)

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

2.3.1 建立通用语言

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

  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
}

2.3.2 语言的演进与维护

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

  • 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 确认。这样可以避免「术语表改了但代码没改」或「不同团队理解不一致」的问题。

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

典型反模式包括:在评审中使用 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"
  • 新人培训时,术语表是必读文档

2.4 上下文映射(Context Mapping)

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

2.4.1 上下游关系模式

常见关系(节选):

模式关系典型集成电商提示
客户-供应商(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 遵奉者]

2.4.2 共享内核

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

电商谨慎场景:订单与库存若共享「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 流程并双方确认。

2.4.3 防腐层

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

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 实现替代真实支付渠道,提升测试速度和可靠性
  • 防腐层承担了「翻译」职责,领域层保持纯粹,不受外部依赖污染

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

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

综合一版可挂在 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)
}

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


2.5 领域的分类

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

2.5.1 核心域、支撑域、通用域

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

2.5.2 投资策略

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

子域示例倾向投资建议(示意)
订单履约核心域架构师 + 高可用工程化,明确 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 人大促准备,加强对账与监控

2.5.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/       (配置与降级)

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


2.6 本章小结

2.6.1 核心要点回顾

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

2.6.2 落地检查清单

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

限界上下文识别

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

通用语言建立

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

上下文映射

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

子域投资

  • 是否明确标记了核心域、支撑域、通用域?
  • 核心域是否得到了架构师与高级工程师的投入?
  • 通用域是否优先使用成熟的开源 / 云服务,而非自研?
  • 是否有定期复盘机制(每半年 Review 一次子域分类)?

2.6.3 常见陷阱总结

陷阱症状纠正方向
战术先行代码结构优美,但跨团队协作仍然混乱补充战略设计:画出上下文地图,建立术语表
边界过度拆分10+ 个微服务,每个只有几百行代码合并职责相近的上下文,先模块化后服务化
共享大模型一个 Product 类被所有服务依赖每个上下文独立建模,用 ACL 翻译
术语不一致同一概念有 3 种命名(Order / Purchase / Transaction)通过工作坊对齐,强制使用术语表
忽视集成失败只考虑 Happy Path,线上频繁出现数据不一致为每个集成点设计失败处理(重试、降级、对账)
投资不均衡核心域缺人,通用域自研中间件占用大量资源重新评估子域分类,调整资源配比

2.6.4 实践建议

  1. 战略设计不是一次性工作——建议每季度召开一次「边界复盘会」,回顾上下文划分是否合理,术语表是否需要更新。

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

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

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

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

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


导航返回目录 | 上一章:第1章 | 书籍主页 | 下一章:第3章

导航书籍主页 | 完整目录 | 上一章:第2章 | 下一章:第4章


第3章 系统内部结构设计

三层架构、Clean Architecture、DDD 战术与 CQRS 的协同落地


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • 承接第 2 章:边界已经划清
  • 通向第 4 章:单个系统内部有了秩序之后,才有条件进一步讨论系统之间如何协作

3.1.2 架构师在这一层真正关注什么

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

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

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


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

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

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

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

3.2.2 三层架构的核心分工

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

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

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

3.2.3 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

3.2.4 一个订单服务的调用链

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

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 语句,表现层也不必知道数据库长什么样。

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

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

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

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

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

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

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

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

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

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

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

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

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

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


3.3 Clean Architecture(整洁架构)

3.3.1 核心思想:依赖规则

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) │    │    │    │
│  │  │  └──────────────────────────────────────┘    │    │    │
│  │  └──────────────────────────────────────────────┘    │    │
│  └──────────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────────┘

                   依赖方向 ──────→ 向内

3.3.2 四层模型

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 层是具体的技术选型,可以随时替换

3.3.3 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,不会侵入核心业务

3.3.4 三层架构与 Clean Architecture 的区别

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

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

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

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

3.3.5 改造要点:引入 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 的核心价值。

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

如果把 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,都有了更清晰的落脚点。

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

让我们通过一个具体的代码示例来理解 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确定后才能开发定义好接口就可以并行开发

3.3.8 依赖注入的 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

3.3.9 为什么这一步通常早于 DDD

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

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

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

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

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

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

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

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

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

3.3.11 架构风格对比: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

3.3.12 反模式:常见违规案例

在实际项目中,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 接口)

3.4 DDD(领域驱动设计)

3.4.1 战略设计:架构层面

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

DDD 分为两个层面:

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

我们先讲战略设计。

3.4.2 领域分层与投资策略

为什么需要领域分层?

一个中大型系统往往包含十几个甚至几十个子系统。假设你是一家电商平台的 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 的权限、审计是基础能力,但实现相对标准
    • → 支撑域是「需要理解业务,但可以参考业界实践」的领域

3.4.3 限界上下文(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)领域事件通信,避免概念混淆。我们会在 3.4.4 详细讲解。

3.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)

3.4.5 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 才开始成为“业务规则真正居住的地方”。

3.4.6 从贫血模型到充血模型

阶段 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。真正重要的是:业务规则开始内聚到模型本身。订单能否下单、状态如何流转、哪些约束必须被保护,不再由外部调用者“记得去做”,而是由聚合根主动维护。

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

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

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

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

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

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

也就是说:

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

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

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

如果把 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 里找到,而不是散落在多个应用服务和数据库更新逻辑中。

3.4.10 为什么这一步值得做

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

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

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

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

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

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

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

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

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

3.4.12 通用语言(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) — 业务术语,直接表达意图

3.4.13 聚合设计原则

聚合设计是 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

3.4.14 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)
}

3.4.15 领域事件异步化: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 的事件转入死信表,人工介入

3.5 CQRS(命令查询职责分离)

3.5.1 为什么要读写分离

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
}

3.5.2 DDD 与 CQRS 的关系

如果说阶段 2 的 DDD 解决的是“业务逻辑本身应该如何表达”,那么阶段 3 的 CQRS 解决的则是另一个更现实的问题:这些业务逻辑表达清楚之后,读和写是否还应该继续走同一条路径

到了这一阶段,系统的主要矛盾往往已经不再是“模型贫血”或“规则散落”,而是“写模型为了保证一致性必须保持克制,但读场景又越来越追求宽表、搜索、排序和高并发”。也就是说,DDD 帮你把写侧建模建清楚了,但读侧未必适合继续共享同一套模型。

这正是 CQRS 和 DDD 的关系。DDD 让命令侧的模型更有表达力,CQRS 则进一步承认:命令侧和查询侧本来就不是一回事。写侧继续走领域模型,读侧则按展示需求单独设计,这样两者才能各自优化。

3.5.3 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 开始明确区分“命令处理”和“查询处理”。从这个时候开始,大家讨论问题时,不再只是说“订单接口怎么写”,而是会进一步区分“这是一条命令链路,还是一条查询链路”。

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

如果把 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 拆成两个文件”那么简单,而是承认读写两类需求的优化方向从一开始就不一样。

3.5.5 架构全景

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)将写模型同步到读模型

3.5.6 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
  • 数据结构完全匹配前端需求,减少转换

3.5.7 为什么这一步值得做

当系统还比较简单时,把读和写放在同一套模型里看起来完全没有问题;但一旦查询开始变宽、流量开始倾斜、读写优化目标开始冲突,继续强行共用同一套模型,通常会出现几个典型症状:

  • 写模型为了保证一致性保持范式化,查询却不得不频繁做多表关联
  • 一个订单详情页需要拼接商品、价格、物流、售后等多个维度,查询越来越重
  • 读流量远高于写流量,但系统却只能按同一套方式扩展
  • 业务层为了兼顾读写两端,逐渐长成“既不像领域模型,也不像查询模型”的折中结构

这时,引入 CQRS 的最大收益,就是允许“写侧继续为正确性服务,读侧单独为性能服务”。你不再需要让一套模型同时满足两种天然冲突的目标。

3.5.8 核心价值

极致的性能优化。你可以针对写操作使用关系型数据库(保证强一致性),针对读操作使用 Elasticsearch 或 Redis(保证高并发)。读写模型可以独立扩展、独立优化

电商系统的实际案例

场景写模型(MySQL)读模型(Elasticsearch)
下单保证事务一致性,写入订单表不涉及
订单详情页不涉及从 ES 读取宽表(包含商品、物流、售后)
订单列表不涉及从 ES 搜索(支持筛选、排序、分页)
数据分析不涉及从 ClickHouse 读取(OLAP)

3.5.9 收益:写侧更稳,读侧更快

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

第一,写侧模型可以继续保持克制。命令处理依旧围绕聚合根和一致性边界展开,不必为了适配复杂查询去牺牲建模质量。

第二,读侧可以针对场景单独优化。订单详情页、订单列表页、搜索页、报表页,可以分别使用适合自己的读模型,而不必共享同一套查询结构。

第三,读写可以独立扩展。当读 QPS 远高于写 QPS 时,你可以优先扩读模型、缓存和搜索引擎,而不是被迫整体扩容。

当然,代价也很现实:读写分离之后,系统复杂度会上升,读模型同步、最终一致性、幂等处理和投影器维护都需要额外关注。因此,CQRS 不是“默认就该上”的方案,而是在读写矛盾已经明显出现时,才值得付出的复杂性成本。

3.5.10 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 适合金融、合规等场景,但对于电商的商品、订单等模块,增加的复杂度超过了收益。

3.5.11 最终一致性处理策略

引入 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

我们会在第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
}

3.5.12 投影器(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)
  • 故障隔离(读模型崩溃不影响写操作)
  • 性能最优(异步处理不阻塞写操作)

3.6 本章小结

本章的核心不是再介绍几个新概念,而是把系统内部结构设计这件事重新排成一条清晰主线:

  • 先用三层架构建立最基本的职责秩序
  • 再用 Clean Architecture 收紧依赖方向
  • 再用 DDD 战术设计把复杂规则收回到模型内部
  • 最后在必要时用 CQRS 拆分读写路径

换句话说,系统内部结构设计不是在问“该选哪一种架构”,而是在问:面对职责、依赖、建模和数据流这四类问题时,架构师应该按什么顺序出招。

下一章将继续沿着这条主线往外走:当单个系统内部已经有了秩序之后,多个系统之间又该如何集成,怎样在分布式环境中处理协作与一致性问题。


下一章第4章 系统集成与一致性设计 将从跨系统调用、事件驱动、最终一致性、Outbox 与 Saga 等角度,讨论系统之间如何协作。


导航返回目录 | 上一章:第2章 | 书籍主页 | 下一章:第4章

导航书籍主页 | 完整目录 | 上一章:第3章 | 下一章:第5章


第4章 系统集成与一致性设计

事件驱动、集成模式与最终一致性,解决系统之间的协作问题

本章定位:前两章分别解决了业务边界与系统内部结构,本章开始讨论第三个层面的问题:当多个上下文或多个服务开始协作时,系统应该如何集成,以及不再共享同一本地事务后如何保持一致性。这里的方法论重点不再是分层与建模,而是事件驱动、幂等、补偿、对账、Outbox 与 Saga。

阅读提示:若你更熟悉「一个数据库里用本地事务搞定一切」的单体思维,建议带着三个问题读完全章——第一,分区发生时你更不愿意牺牲哪一项(一致性、可用性、延迟);第二,失败是可逆还是不可逆(决定补偿语义);第三,重复请求与重复消息是否已被建模为一等公民(决定幂等与 Outbox 是否值得投资)。

章节衔接:第 1 章给出组合拳全景,第 2 章划边界,第 3 章解决边界之内的结构设计;本章则把视角抬升到边界之间。后续第 7 章到第 16 章在讲商品、库存、营销、计价、购物车、订单与支付时,都会反复回到本章的词汇表与模式库。

写作约定:文中 Go 示例为教学裁剪版,聚焦结构与语义;生产落地请补齐超时、退避、注入、鉴权、观测字段与错误码规范,并把「成功 / 业务失败 / 可重试失败」三类语义贯穿全链路。阅读时建议对照你们公司的网关规范、数据库迁移规范与消息平台手册做二次裁剪,并把示例中的表名与字段名映射到真实审计字段、合规要求与数据留存周期。


6.1 分布式事务挑战

跨服务、跨库、跨供应商的电商链路,本质上是在没有全局锁的前提下完成协作。讨论「分布式事务」之前,先要统一语言:我们追求的往往不是银行核心那种「强一致实时可见」,而是用户可理解的一致性财务可审计的一致性的组合。

把一致性目标写成 SLO:方法论章节最容易停留在概念层,落地时建议把「一致性」翻译成可监控指标。例如:「创单成功后 99.9% 用户在 1 秒内读到订单详情」「支付成功后 5 分钟内订单状态同步完成」「对账差异在 T+1 日内闭环率」。没有指标,团队就会在事故后争论「这算不算一致」。SLO 也会反向约束集成模式:如果你承诺秒级读己之所写,就不能把关键读路径绑在慢消费者之后。

可观测性是最小一致性保障:跨系统集成里,最大风险不是「短暂不一致」,而是「不知道不一致发生在哪」。因此链路追踪(Trace)、关联 ID(order_id / payment_id / idempotency_key)、以及跨系统日志检索规范,应被视为一致性基础设施的一部分,而不是「运维锦上添花」。当你能在 5 分钟内从用户投诉定位到具体参与方与具体请求,你已经把大量 P1 事故降级为 P3。

6.1.1 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 / 可靠消息组合,再用 对账兜住不可避免的外部异步。

6.1.2 一致性分类

从工程师落地角度,建议把「一致性」从口号拆成可见性容错模型经济后果三层,再映射到技术策略。

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 + 近实时索引运维复杂度、契约治理
支付成功即刻可履约同步确认 + 强校验 + 幂等尾延迟、渠道配额
大促高峰仍可下单异步化、削峰、降级读短暂不一致、对账压力

6.1.3 典型场景分析

  1. 创单链路(订单、库存、营销、计价):跨多个限界上下文,同步点越多尾延迟越大;典型解法是「结算页强校验 + 创单 Saga + 异步投影」。
  2. 搜索与商品主数据:索引滞后是常态,关键是可观测的延迟降级展示(第 12 章)。
  3. 支付回调与渠道对账:渠道侧状态与平台侧状态异步收敛;必须幂等、必须可对账(第 15 章与 6.5 呼应)。
  4. 供应商库存:供应商为权威时,平台侧是快照 + 同步策略;分区时以可解释的拒单 / 延迟确认交换「永不超卖」承诺(第 8 章)。
  5. 退款与售后:涉及支付渠道、营销回退、库存回冲、供应商拦截等多参与方,不可逆节点(已打款、已出票)会把「补偿」切换为「工单 / 人工」。这类链路更需要 Saga 日志 + 对账批次号 + 幂等退款单号 三件套,避免重复退款与部分成功。
  6. 秒杀与抢购:热点 SKU 的约束是「库存单调递减」与「请求风暴」叠加。工程上通常是 Redis 原子预减 + 异步落库对账,而不是跨服务同步强一致;否则会把尾延迟与失败面扩散到整个站点入口。
  7. 清结算与分账:平台、商家、渠道、营销补贴多方账本需要在 T+N 周期收敛。这里的「一致性」更像会计恒等式:借贷平衡、可追溯、可审计,而不是用户请求路径上的毫秒级一致。
  8. 跨境与多币种:汇率快照、税费、支付路由使得「金额一致」必须显式引入 snapshot_id / rate_version,否则对账会把产品问题误判为技术故障。

这些场景的共同点,是都要求你在架构文档里写清楚一句话:一致性的责任边界在哪里结束。例如库存预占由库存服务负责语义,订单只保存 reserve_id 凭证;支付金额以支付核心的记账为准,订单侧只保存 pay_amount 快照与引用号。边界写清楚,集成才不会退化成「谁都能改一笔」。


6.2 Saga 编排模式

Saga 把长事务拆为本地事务序列,用补偿事务撤销已提交步骤的可逆效果。它不要求全局锁表,也不要求所有参与方实现预留接口(对比 TCC 的 Try/Confirm/Cancel),因此在跨团队集成中最常见。

很多团队会把「分布式事务」理解成「找一个中间件把多个数据库一次性提交」。在电商微服务里,这种理解往往会把问题推向两个极端:要么 过度依赖全局协调(可用性与尾延迟受损),要么 完全回避一致性话题(只能靠人肉修数)。Saga 的价值在于承认现实:跨服务没有免费的全局原子性,但可以把不确定性收敛到 可测试的本地事务 + 可审计的补偿 + 可对账的凭证。当你能清楚说出「哪一步失败会留下什么外部痕迹」,你就已经比大多数项目更接近可控。

落地时还要区分 业务失败系统失败:库存不足是业务失败,通常不应触发重试;渠道超时是系统失败,需要退避重试与查询对齐。把两者混在一个 if err != nil 里,Saga 会表现为「无意义重试放大雪崩」或「该补偿却不补偿」。建议在错误模型里显式引入 BusinessErrorTransientError(或等价错误码),编排器据此分支。

6.2.1 Saga 基础概念

  • 子事务(Local Transaction):在一个服务 / 一个库边界内可原子提交。
  • 补偿(Compensation):语义上撤销前一步业务效果;必须幂等,且要能处理「原操作其实失败」的空补偿。
  • Saga 日志:记录每一步状态,支撑断点续跑、人工介入与审计。

TCC(Try-Confirm-Cancel) 相比,Saga 对参与方的接口要求更低,但业务侧要承担更多「补偿语义」的设计成本。可以用下表做模式选型(不是非此即彼,很多系统会在支付子域用更严格的协议,在营销子域用 Saga)。

维度SagaTCC
参与方改造低:正向 + 补偿即可高:三阶段接口与资源预留语义
一致性强度依赖补偿正确性与对账Try 成功后可更强约束提交
失败处理补偿链 + 人工兜底Cancel 路径必须可靠
典型适用创单、结算编排支付、余额、库存强约束场景(团队成熟度高)

6.2.2 编排 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 给搜索、推荐、风控等读模型或旁路系统。这样既保留集中调试与审计的主线,又避免把读侧耦合进同步链路。

6.2.3 补偿机制设计

补偿不是「数据库回滚」的同义词,而是业务语义撤销。设计要点:

  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

6.2.4 订单创建的 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)
);

6.3 事件驱动架构

事件驱动架构(EDA)把系统间的耦合从「知道对方的表结构」变为「订阅对方愿意公布的事实」。它与 DDD 的**领域事件(Domain Event)**天然契合:事件名应是业务过去式(OrderPlaced),而不是命令式(PlaceOrder)。

EDA 并不自动带来解耦:如果事件载荷里塞满下游私有字段,或消费者之间隐式依赖顺序却缺乏分区策略,你只会得到「异步耦合的大泥球」。因此本章强调三件事:契约顺序边界可观测的投递语义。与第 16 章的事件发布实践结合时,请把「事件平台能力」与「领域建模能力」分开评估:Kafka 再强也替代不了你对聚合边界的判断。

6.3.1 领域事件

领域事件用于表达聚合内已发生且不可变的事实。好的事件:

  • 自描述:携带必要标识与版本(schema_version)。
  • 可演进:兼容字段新增,慎改语义。
  • 与命令分离:命令可丢弃重试;事件一旦发布,消费者会据此做副作用。

命名与版本:事件名建议稳定且可检索(order.placed.v1),避免把促销规则编码进 topic 名称。载荷里携带 occurred_atproducerschema_version,消费者才能做 向后兼容 解析。另一个实践是把「业务关键字段」与「展示字段」分层:关键字段用于幂等与投影,展示字段允许缺失并由读模型降级。

聚合边界:领域事件应从聚合根的不变量中自然产生,而不是为了通知某个下游临时「造事件」。后者会导致事件泛滥、顺序难以推理、回放成本失控。若你发现自己需要 SomethingMaybeChanged 这类含糊事件,通常意味着限界上下文边界需要重塑。

6.3.2 事件的发布与订阅

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 告警与降级读策略,避免把读路径拖死。

与第 16 章「事件发布」对齐时,把「谁允许发什么事件」纳入治理:事件不是自由文本广播,而是受版本管理的契约。建议在仓库中维护 events/ 目录(或 Buf Schema Registry),把破坏性变更当作发布流程的一部分。

6.3.3 事件溯源(Event Sourcing)

事件溯源(ES)把状态还原为事件流的折叠state = fold(events)。它带来强大审计与回放能力,但也引入:

  • 模型复杂度:投影、快照、版本迁移成本高。
  • 查询压力:多数业务仍需要物化读模型(CQRS)。

电商建议:账务、支付指令、库存流水等强审计子域可评估 ES;一般商品展示、搜索索引用物化视图 + Outbox 性价比更高(与第 1 章 CQRS 小节呼应)。

何时值得上 ES:当你明确需要「按时间回放任意业务态」且愿意投入 投影重建、快照策略、事件迁移工具链;否则先用 审计日志表 + 不可变对象存储归档 + 定期校验 往往更划算。ES 不是银弹,它是把复杂度从数据库迁移到了事件存储与投影运维。

快照(Snapshot):长生命周期聚合(例如会员账户、长期预售订单)如果每次都从头折叠事件,读路径会不可接受。快照本质是「在某版本截断事件流」,需要定义 快照写入频率快照与事件的版本对齐规则

6.3.4 实践要点与 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,与消费者表上的唯一约束联合实现端到端幂等。
  • 与第 16 章「事件发布」衔接时,把事件契约(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 章强调「领域事件异步化」的动机与边界;本章补齐 实现骨架、时序与运维指标,便于你在第 7–15 章落地到具体服务时直接对照检查清单。


6.4 幂等性设计通用方案

6.4.1 幂等性的本质

幂等性回答的问题是:同一个业务意图被执行多次,是否与只执行一次等价。分布式系统里重复来源包括:用户双击、网关重试、消息重复投递、回调重放。

从接口语义上,幂等还应区分两类返回:

  • 语义幂等:第二次调用返回与第一次业务等价的结果(可能 HTTP 状态码不同,但业务码一致),典型是支付创建。
  • 严格幂等:第二次调用应尽可能返回同一响应体(含错误),以便客户端无需分支处理;这通常依赖网关或应用侧的 响应缓存

另一个关键维度是 时间窗口:创单幂等键可能只需 24 小时;支付幂等键可能要跨结算周期。窗口外的重复请求应被明确拒绝还是进入人工?这属于产品策略,但必须在技术方案里写死,否则会出现「以为幂等永远有效」的误用。

6.4.2 实现策略

策略适用注意
天然幂等SET status='CANCELLED' WHERE id=? AND status='PAID'仍需防止错误状态迁移
业务幂等键支付、创单、退款需要落库索引与 TTL 治理
令牌桶 / 去重表高并发写定期归档,冷热分离
唯一约束DB 层最终防线冲突即视为重复成功需返回同一结果

组合策略才是常态:接口层挡住「明显重复」;服务层用状态机挡住「非法重放」;数据库用唯一约束挡住「并发双插」。任何单层都可能被绕过(例如内部任务不经过网关),因此不要迷信「只加 Header 就安全」。

测试清单:至少覆盖「并发双请求同一幂等键」「第一次超时后重试」「第一次失败第二次成功」「消息重复投递」四类用例;支付与退款还要覆盖「渠道侧已成功但平台超时」的对称场景(与第 15 章联动)。

6.4.3 各层的幂等性保证

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
		}
		// TODO: 包装 ResponseWriter 捕获状态码与 body,成功后 SaveResponse
		next.ServeHTTP(w, r)
	})
}

服务层:把幂等键与业务键(order_idpayment_id)建立映射;对「处理中」状态设置合理超时,避免永久悬挂。

数据层:对 merchant_order_nochannel_trade_no 等建立 UNIQUE 索引;冲突时读取已有行并返回与首次一致的结果体(支付场景极其关键,见第 15 章)。

消息消费者层:除了业务唯一键,还要注意 at-least-once 带来的「处理成功但 ack 前崩溃」重复。常见做法是:业务写入与「消费记录」同事务,或采用 幂等表 + 业务状态机 组合。Kafka 的幂等 producer 只能保证生产端不重复,不能保证消费端。

与第 13–15 章的衔接:购物车提交、结算创单、支付创建与回调,是幂等设计最密集的区域。请把这些链路里的 Idempotency-Key 来源、TTL、冲突返回码统一成一份「交易接口规范」,否则每个团队会发明一种错误语义,客户端与对账都会被拖垮。


6.5 数据一致性保证

6.5.1 最终一致性

最终一致性不是「暂时不一致然后祈祷」,而是满足三个条件:

  1. 收敛性:在无新写入时,系统应到达稳定态。
  2. 可观测性:能度量漂移(延迟、差异条数)。
  3. 可修复性:通过对账与补偿把漂移拉回业务可接受范围。

最终一致的工程含义:允许短暂不一致,但不允许「永远不一致」。因此必须定义 最大允许漂移时间(MTTD)修复时限(MTTR) 的业务含义。例如「支付回调最多延迟 5 分钟,对账必须在 T+1 日内闭环」,这类指标比对程序员说「我们最终一致」更有约束力。

与缓存的关系:读路径缓存(商品详情、列表价)引入的是另一类一致性。原则是:写路径更新权威,再异步失效 / 刷新缓存;不要反过来用缓存驱动写。库存热路径若使用 Redis,必须与第 8 章一样把 对账与回补 当作一等能力,而不是事后补丁。

6.5.2 对账机制

对账回答的问题是:两份账本是否在说同一件事。电商常见对账维度:

  • 平台订单 vs 支付渠道:金额、手续费、状态、退款。
  • 库存流水 vs 实物出库:WMS / OMS 对齐。
  • 营销预算 vs 实际核销:防止薅羊毛与预算透支。

设计要点(与第 8 章库存、第 15 章支付呼应):

  1. 对账文件与解析:渠道侧日终文件 + 平台侧流水导出;解析必须版本化。
  2. 三层匹配:长款(渠道有平台无)、短款(平台有渠道无)、金额不一致。
  3. 差错工单:自动修复仅限白名单场景;其余进入人工复核。
  4. 幂等与回放:同一对账批次重复跑不产生重复账务分录。

对账批次生命周期(建议)INITPARSEDMATCHEDDIFF_GENERATEDFIXUP_APPLIEDCLOSED。每一步落审计日志,支持监管问询与内部复盘。短款与长款不要混在一个工单模板里:短款更像「钱可能丢了」,长款更像「重复记账风险」,处理 SLA 与审批链往往不同。

平台侧数据准备:对账不只读「业务库」,还要聚合 渠道回调日志、网关请求日志、消息投递记录。否则你会出现「业务状态对,但财务凭证缺角」的尴尬。实践中常用 不可变事件流水 作为对账输入之一,因为它比业务表更抗「事后改字段」。

与第 8 章库存对账的衔接:库存侧常见是 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:以哪个时区、哪个切分点为准。

自动化修复的边界:只有满足「可逆、可证明、可回放」三条件的动作才适合自动化。例如「补写缺失的支付回调记录」可以自动化;「直接给用户退款」通常需要更高等级审批与多重校验。自动化的目标是减少人工 机械劳动,不是替代 风险判断

6.5.3 补偿任务

补偿任务与 Saga 补偿不同:后者在请求生命周期内;前者是异步修复器,用于:

  • 回调迟到导致的悬挂单;
  • 消息堆积造成的投影落后;
  • 对账发现的轻微差异批量冲正。

设计清单:可重入批大小上限死信隔离人工止血的开关

任务编排建议:补偿任务尽量 幂等、可观测、可暂停。出现大面积渠道故障时,最危险的是「自动修复脚本跑得比人还快」,把差错扩散成二次事故。要有 全局开关 + 分渠道开关 + 最大自动修复笔数阈值

与支付补偿的差异:Saga 补偿发生在用户请求上下文内,强调快速失败与回滚;异步补偿任务发生在分钟到小时级窗口,强调批处理、限流与审计。两者不要混用同一套重试策略。

案例化走读:支付回调迟到(与第 15 章呼应):用户支付成功,渠道回调因网络抖动晚到 10 分钟。期间订单可能停留在「待支付」,客服系统可能提示用户重复支付。补偿任务不应「直接改状态为成功」了事,而应执行一条可审计的状态迁移:WAIT_PAYPAID,并触发 履约消息、发票消息、积分入账 等下游;每一步仍要带幂等键,避免回调重复造成重复履约。

案例化走读:库存 Redis 与 MySQL 漂移(与第 8 章呼应):热路径扣减在 Redis,权威在 MySQL。对账发现 Redis 小于 MySQL 的可用量,可能意味着回补丢失;反过来可能意味着 Redis 多扣。修复策略应区分「业务可自动纠正」与「需要冻结 SKU 人工介入」。自动纠正必须带 上限来源证据(流水号、操作者、任务批次),否则会把数据修复变成新的数据破坏源。


6.6 集成模式总结

6.6.1 同步调用模式

  • 适用:强实时、需要立即失败反馈(试算、库存预占校验)。
  • 要点:超时、重试、熔断、幂等键向后兼容的 API 版本

常见反模式:把十几个同步调用串成「上帝编排」,任何一个下游抖动都会放大尾延迟;没有 bulkhead(舱壁) 时,还会出现「支付抖动拖垮创单」的级联故障。治理手段包括:并发化可并行步骤硬超时 + 部分降级把非关键校验挪到异步

接口演进:同步集成最怕破坏性变更。建议强制 version 字段或 URL 版本,并在网关层做 灰度路由;同时给客户端明确的 错误码字典(业务拒绝 vs 基础设施失败),否则重试风暴不可避免。

6.6.2 异步消息模式

  • 适用:解耦峰值、跨团队广播事实、最终一致投影。
  • 要点:Outbox、消费者幂等严格有序 vs 并行的权衡、死信队列。

典型反模式:业务先写库再「顺手发 Kafka」,崩溃窗口会导致消息丢失;或消费者不做幂等,靠「应该不会重复」的侥幸心理。另一个反模式是 把异步当同步用:通过轮询消息结果阻塞用户请求,这会把消息系统的延迟特性原封不动搬进关键路径。

观测性:异步链路必须能回答三个问题:消息发出去了吗、消息被处理了吗、处理正确吗。分别对应 Outbox 状态、消费者 lag、对账差异。

6.6.3 数据同步模式

  • CDC / Binlog 订阅:近实时同步到数仓或搜索;关注 schema 变更治理。
  • 定时批量:对账、报表、冷数据归档。
  • 双写:高风险,仅在迁移窗口短期使用,需校验任务护航。

CDC 的边界:它擅长复制「事实行变更」,但不自动复制「业务含义」。例如拆表、改主键、把枚举从字符串改成数字,都会让下游投影误读。需要 契约变更流程双读双写过渡期

定时批量的价值:很多一致性不是实时问题,而是「日终必须平」。批量任务的关键是 可重跑、可分段、可限流,并在大促日提前做 容量演练

6.6.4 选型决策树

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 章)。

6.7 本章小结

本章建立了系统集成的方法论「四件套」:

  1. CAP 与一致性谱系帮助你在分区现实下做可解释的取舍,而不是用「都要」掩盖矛盾。
  2. Saga(编排优先)给出跨服务长流程的工程主路径,补偿必须可逆且幂等
  3. 事件驱动 + Outbox 把「写库再发消息」变成可验证的本地事务,为 CQRS 投影与搜索增量提供底座。
  4. 幂等与对账 是分布式世界的安全带:前者防重复,后者治漂移;补偿任务负责把系统从边角态拉回主航道。

落地检查清单(建议你复制到评审模板)

  • 每个跨服务写链路是否写明 一致性级别(用户可见 / 财务 / 读模型)?
  • 是否存在「先外部成功、后本地提交」的窗口?若有,是否有 query/reconcile
  • 关键接口是否具备 幂等键冲突返回语义
  • 是否避免「双写」作为长期方案?若必须双写,是否有 校验任务
  • 事件是否走 Outbox?Relay 是否有 堆积告警
  • 是否定义 对账批次差错分级?自动修复是否有 阈值与开关
  • Saga / 异步任务是否 可重入?是否能在发布滚动中恢复?

给团队负责人的一句话建议:把「集成复杂度」当作与「业务复杂度」并列的成本项。没有 Outbox、没有对账、没有幂等键的系统也能上线,但它会把成本推迟到 大促夜、监管审计、渠道切流 这些最难的时刻一次性兑现。本章的目的,是把这部分成本前移为 可评审、可测试、可监控 的工程资产。

给一线开发者的一句话建议:写跨服务调用时,默认网络会超时、消息会重复、回调会迟到;把这三条写进单元测试与集成测试的假设里,比写一百行防御性注释更有用。

带着这套语言进入第 7 章之后的各子系统,你会更容易判断:此处该同步还是异步、事件应不应该广播、失败该当场回滚还是记账异步修。下一章(第 7 章)将从商品中心开始,把这些模式落实到具体边界与接口之上。


导航书籍主页 | 完整目录 | 上一章:第3章 | 下一章:第5章

导航书籍主页 | 完整目录 | 上一章:第4章 | 下一章:第6章


第5章 编码原则与设计模式

用整洁代码、Pipeline、策略与规则引擎承接架构设计


3.1 复杂业务代码的痛点

第1章解决了「系统如何分层、依赖如何向内」的问题;本章解决「同一层里,代码如何写得可演进、可测试」。电商的下单、库存、营销是典型的长流程 + 多分支 + 高频变更场景,若缺少约束,业务代码会迅速腐化。

3.1.1 千行函数的噩梦

下面这段伪代码浓缩了真实项目中常见的「上帝函数」形态:校验、读用户、查库存、算价、营销、风控、落库、支付、通知全部堆在一个入口里。

// ❌ 反例:单函数承载整条下单链路(职责过多、难以测试)
func CreateOrder(req *CreateOrderRequest) (*Order, error) {
	if req == nil || req.UserID == "" || len(req.Items) == 0 {
		return nil, errors.New("invalid request")
	}
	// 用户信息、库存、价格、券、积分、运费、活动、风控……
	// 数百行嵌套在同一函数中,修改任意一步都可能波及其他步骤
	return &Order{}, nil
}

直接后果

  • 无法为「只改营销校验」写独立单测,只能起全套集成环境;
  • Code Review 难以聚焦,合并冲突集中在一个文件;
  • 新人需要整段读完才敢改一行。

3.1.2 代码腐化的根因

根因表现与架构的关系
缺少流程骨架步骤顺序靠注释和隐式顺序Clean Architecture 只保证分层,不自动拆分函数
分支爆炸if-else 按品类、地区、活动维度嵌套领域模型仍在,但应用编排层失控
上下文随意传递十几个参数或「万能 struct」未用显式 Context 对象承载流水线状态
规则与代码绑死运营改文案/门槛就要发版与第9章营销系统呼应:需数据驱动的规则层

3.1.3 技术债的累积

技术债不一定是「烂代码」,更多时候是当时合理、后来失配的设计:例如早期用三层 Service 足够,随着营销玩法与多仓库存策略叠加,仍坚持在一个 OrderService 里堆逻辑,债就滚雪球。

可操作的止损线(团队可写入评审清单):

  • 单个导出函数超过约 80~120 行(视团队约定)必须拆分或抽 Pipeline;
  • 圈复杂度(McCabe)超过约定阈值必须拆解策略或子函数;
  • 同一文件内出现 3 处以上「复制粘贴再改一点」的折扣/库存分支,优先考虑策略或规则表。
flowchart LR
	A[需求变更] --> B{能否局部修改?}
	B -->|否| C[千行函数 / 深层 if-else]
	C --> D[测试困难]
	D --> E[不敢重构]
	E --> C

3.2 函数设计原则

在引入模式之前,先收紧函数级的纪律:这是所有模式能落地的前提。

3.2.1 单一职责

一个函数应回答一个业务问题。例如「校验下单请求」与「持久化订单」不应混在同一函数中。

// ❌ 反例:校验与副作用混在一起
func PlaceOrder(ctx context.Context, req *PlaceOrderRequest, db *sql.DB) error {
	if req.CustomerID == "" {
		return errors.New("customer required")
	}
	_, err := db.ExecContext(ctx, `INSERT INTO orders (...) VALUES (...)`, req.CustomerID)
	return err
}

// ✅ 正例:校验纯函数化,写库由仓储承担
func ValidatePlaceOrder(req *PlaceOrderRequest) error {
	if req.CustomerID == "" {
		return errors.New("customer required")
	}
	if len(req.Lines) == 0 {
		return errors.New("order lines required")
	}
	return nil
}

3.2.2 函数长度控制

经验法则:一屏能读完(含错误处理)较理想。长逻辑不是「拆成很多小函数」就够了,还要让调用方读出业务流程,这正是 3.3 节 Pipeline 的价值。

3.2.3 参数设计

参数过多往往说明缺少「用例级上下文」。把一次下单所需输入收敛为 PlaceOrderInput,中间态收敛为 PlaceOrderState(或下文 OrderPipeContext),Handler 只负责绑定 HTTP → DTO → 用例输入。

// ❌ 反例:参数平面展开
func PriceForCheckout(userID, region, platform string, qty int, skuID string, useCoupon bool, couponCode string) (int64, error) {
	return 0, nil
}

// ✅ 正例:输入聚合为结构体
type CheckoutPriceQuery struct {
	UserID     string
	Region     string
	Platform   string
	SKU        string
	Qty        int
	CouponCode string
}

3.2.4 错误处理

在 Go 中建议:

  • 可预期业务失败用哨兵错误或自定义类型,便于上层映射 HTTP 4xx;
  • 系统/依赖故障%w 包装,保留链路与 errors.Is / As 能力;
  • 不要在业务深层 log.Fatal,把决策留在 main 或任务入口。
// ✅ 正例:包装依赖错误
func (r *MySQLOrderRepo) Save(ctx context.Context, o *Order) error {
	if _, err := r.db.ExecContext(ctx, `INSERT INTO orders (id, customer_id) VALUES (?, ?)`, o.ID, o.CustomerID); err != nil {
		return fmt.Errorf("order repo save: %w", err)
	}
	return nil
}

3.3 Pipeline 模式

Pipeline(管道)把阶段化流程显式化:每一步是一个 Processor,共享一个上下文,由 Pipeline 顺序驱动。它与责任链的共同点都是「链式处理」,不同之处在于:Pipeline 更强调数据沿管道变换、阶段职责清晰,常用于下单、结算试算、营销列表组装等。

3.3.1 从嵌套到流水线

flowchart LR
	P1[Validate] --> P2[LoadUser]
	P2 --> P3[ReserveInventory]
	P3 --> P4[ApplyPricing]
	P4 --> P5[PersistOrder]

对比

维度深层嵌套Pipeline
流程可见性靠读完全文组装处即文档
扩展改中央函数增删 Processor
单测每步独立 Mock

3.3.2 实现要点

  1. 上下文对象:承载输入、中间结果、错误累积标记;避免用包级变量。
  2. 早失败:任一步返回错误即中止管道(或实现可配置的「继续/中止」策略)。
  3. 命名Processor.Name() 便于日志与指标打标签。
  4. 与 Clean Architecture:Pipeline 通常落在 Application / Use Case 编排层,领域不变量仍在聚合根内。

3.3.3 订单处理的 Pipeline 实例

下面给出一份可编译思路的精简实现:创建订单流水线——校验 → 加载商品行 → 预留库存 → 写单。

package orderpipe

import (
	"context"
	"errors"
	"fmt"
)

// OrderPipeContext 承载一次「创单」流水线的输入与中间态。
type OrderPipeContext struct {
	Req            PlaceOrderRequest
	ResolvedLines  []OrderLine
	ReservationID  string
	CreatedOrderID string
}

type PlaceOrderRequest struct {
	CustomerID string
	Lines      []LineRequest
}

type LineRequest struct {
	SKU string
	Qty int
}

type OrderLine struct {
	SKU       string
	Qty       int
	UnitCents int64
}

// Processor 单步处理逻辑。
type Processor interface {
	Name() string
	Process(ctx context.Context, c *OrderPipeContext) error
}

// Pipeline 顺序执行。
type Pipeline struct {
	steps []Processor
}

func NewPipeline(steps ...Processor) *Pipeline {
	return &Pipeline{steps: steps}
}

func (p *Pipeline) Run(ctx context.Context, c *OrderPipeContext) error {
	for _, s := range p.steps {
		if err := ctx.Err(); err != nil {
			return err
		}
		if err := s.Process(ctx, c); err != nil {
			return fmt.Errorf("%s: %w", s.Name(), err)
		}
	}
	return nil
}

// --- Processors ---

type validateProcessor struct{}

func (validateProcessor) Name() string { return "validate" }

func (validateProcessor) Process(_ context.Context, c *OrderPipeContext) error {
	if c.Req.CustomerID == "" {
		return errors.New("customer_id required")
	}
	if len(c.Req.Lines) == 0 {
		return errors.New("at least one line")
	}
	return nil
}

type Catalog interface {
	ResolveLines(ctx context.Context, lines []LineRequest) ([]OrderLine, error)
}

type resolveCatalogProcessor struct{ cat Catalog }

func (p resolveCatalogProcessor) Name() string { return "resolve_catalog" }

func (p resolveCatalogProcessor) Process(ctx context.Context, c *OrderPipeContext) error {
	lines, err := p.cat.ResolveLines(ctx, c.Req.Lines)
	if err != nil {
		return err
	}
	c.ResolvedLines = lines
	return nil
}

type Inventory interface {
	Reserve(ctx context.Context, customerID string, lines []OrderLine) (reservationID string, err error)
}

type reserveInventoryProcessor struct{ inv Inventory }

func (p reserveInventoryProcessor) Name() string { return "reserve_inventory" }

func (p reserveInventoryProcessor) Process(ctx context.Context, c *OrderPipeContext) error {
	rid, err := p.inv.Reserve(ctx, c.Req.CustomerID, c.ResolvedLines)
	if err != nil {
		return err
	}
	c.ReservationID = rid
	return nil
}

type OrderRepository interface {
	Insert(ctx context.Context, c *OrderPipeContext) (orderID string, err error)
}

type persistOrderProcessor struct{ repo OrderRepository }

func (p persistOrderProcessor) Name() string { return "persist_order" }

func (p persistOrderProcessor) Process(ctx context.Context, c *OrderPipeContext) error {
	id, err := p.repo.Insert(ctx, c)
	if err != nil {
		return err
	}
	c.CreatedOrderID = id
	return nil
}

组装处(例如在 cmdapplication 包)一眼读完流程

func NewPlaceOrderPipeline(cat Catalog, inv Inventory, repo OrderRepository) *Pipeline {
	return NewPipeline(
		validateProcessor{},
		resolveCatalogProcessor{cat: cat},
		reserveInventoryProcessor{inv: inv},
		persistOrderProcessor{repo: repo},
	)
}

3.4 策略模式

当分支维度稳定、而每个分支内部都很厚时,用策略(Strategy)把「选谁」与「怎么做」拆开:注册表负责选择,策略对象负责算法。

3.4.1 消除 if-else

// ❌ 反例:按库存类型硬编码
func ReserveStock(kind string, sku string, qty int) error {
	if kind == "platform" {
		return platformReserve(sku, qty)
	} else if kind == "vendor_realtime" {
		return vendorRealtimeReserve(sku, qty)
	} else if kind == "vendor_async" {
		return vendorAsyncReserve(sku, qty)
	}
	return errors.New("unknown stock kind")
}

新增一种库存来源时,必须修改该函数,违反开闭原则,且冲突面大。

3.4.2 策略的注册与选择

package stockstrategy

import (
	"context"
	"errors"
	"fmt"
)

type ReserveInput struct {
	SKU string
	Qty int
}

// Reserver 库存预留策略。
type Reserver interface {
	Kind() string
	Reserve(ctx context.Context, in ReserveInput) error
}

type Registry struct {
	byKind map[string]Reserver
}

func NewRegistry(rs ...Reserver) (*Registry, error) {
	m := make(map[string]Reserver, len(rs))
	for _, r := range rs {
		k := r.Kind()
		if _, dup := m[k]; dup {
			return nil, fmt.Errorf("duplicate reserver: %s", k)
		}
		m[k] = r
	}
	return &Registry{byKind: m}, nil
}

func (reg *Registry) Reserve(ctx context.Context, kind string, in ReserveInput) error {
	r, ok := reg.byKind[kind]
	if !ok {
		return errors.New("unknown stock kind")
	}
	return r.Reserve(ctx, in)
}

3.4.3 库存策略的实例

type platformReserver struct{}

func (platformReserver) Kind() string { return "platform" }

func (platformReserver) Reserve(ctx context.Context, in ReserveInput) error {
	// 调用平台自有库存服务(Redis + DB 等)
	_ = ctx
	if in.Qty <= 0 {
		return errors.New("qty must be positive")
	}
	return nil
}

type vendorRealtimeReserver struct{}

func (vendorRealtimeReserver) Kind() string { return "vendor_realtime" }

func (vendorRealtimeReserver) Reserve(ctx context.Context, in ReserveInput) error {
	// 同步调用供应商库存 API
	_ = ctx
	return nil
}

type vendorAsyncReserver struct{}

func (vendorAsyncReserver) Kind() string { return "vendor_async" }

func (vendorAsyncReserver) Reserve(ctx context.Context, in ReserveInput) error {
	// 写入待同步队列,由异步任务向供应商确认
	_ = ctx
	_ = in
	return nil
}
classDiagram
	class Reserver {
		<<interface>>
		+Kind() string
		+Reserve(ctx, in) error
	}
	Reserver <|.. platformReserver
	Reserver <|.. vendorRealtimeReserver
	Reserver <|.. vendorAsyncReserver
	Registry --> Reserver : lookup by kind

与 Pipeline 的分工:Pipeline 回答「先做啥后做啥」;策略回答「同一类步骤里用哪套算法」。例如「预留库存」这一步内部再根据 kind 选策略。


3.5 规则引擎

3.5.1 何时需要规则引擎

适合

  • 运营频繁调整的门槛、互斥、叠加(满减、品类券、会员日);
  • 需要按优先级尝试多条规则,并输出可追溯的「命中说明」。

不适合

  • 极少变化且分支很少(直接写在领域服务里更清晰);
  • 强实时、超低延迟且规则解释执行成本高(需编译型或预计算方案)。

3.5.2 轻量级规则引擎设计

核心思想:规则 = 条件(数据) + 动作(数据),引擎负责排序、匹配、互斥与累计。下面示例用内存规则列表演示;生产可替换为从 DB 加载并带版本号缓存。

package rules

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"time"
)

type MarketingContext struct {
	Now       time.Time
	NewUser   bool
	Subtotal  int64 // 分
	VIPLevel  int
	Category  string
}

type Effect struct {
	RuleID   int
	RuleName string
	Discount int64 // 分,正数表示扣减应付
}

type Rule struct {
	ID         int
	Name       string
	Priority   int
	Enabled    bool
	Start, End time.Time
	MutexWith  []int
	When       func(MarketingContext) bool
	Apply      func(*MarketingContext, *Evaluation) error
}

type Evaluation struct {
	Applied []Effect
	used    map[int]struct{}
}

func NewEvaluation() *Evaluation {
	return &Evaluation{used: make(map[int]struct{})}
}

func (e *Evaluation) markUsed(id int) {
	e.used[id] = struct{}{}
}

func (e *Evaluation) hasUsedAny(ids []int) bool {
	for _, id := range ids {
		if _, ok := e.used[id]; ok {
			return true
		}
	}
	return false
}

type Engine struct {
	rules []*Rule
}

func NewEngine(r ...*Rule) *Engine {
	rs := append([]*Rule(nil), r...)
	sort.Slice(rs, func(i, j int) bool { return rs[i].Priority > rs[j].Priority })
	return &Engine{rules: rs}
}

func (eng *Engine) Evaluate(ctx context.Context, mc MarketingContext) (*Evaluation, error) {
	_ = ctx
	out := NewEvaluation()
	for _, rule := range eng.rules {
		if !rule.Enabled {
			continue
		}
		if mc.Now.Before(rule.Start) || mc.Now.After(rule.End) {
			continue
		}
		if out.hasUsedAny(rule.MutexWith) {
			continue
		}
		if rule.When == nil || !rule.When(mc) {
			continue
		}
		out.Applied = append(out.Applied, Effect{RuleID: rule.ID, RuleName: rule.Name})
		if err := rule.Apply(&mc, out); err != nil {
			out.Applied = out.Applied[:len(out.Applied)-1]
			return nil, fmt.Errorf("rule %d: %w", rule.ID, err)
		}
		out.markUsed(rule.ID)
	}
	return out, nil
}

3.5.3 营销规则引擎实例

func DemoRules() *Engine {
	return NewEngine(
		&Rule{
			ID: 1, Name: "新客首单立减", Priority: 100, Enabled: true,
			Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
			End:   time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC),
			When: func(m MarketingContext) bool { return m.NewUser },
			Apply: func(m *MarketingContext, ev *Evaluation) error {
				d := int64(2000)
				m.Subtotal -= d
				if m.Subtotal < 0 {
					return errors.New("subtotal underflow")
				}
				ev.Applied[len(ev.Applied)-1].Discount = d
				return nil
			},
		},
		&Rule{
			ID: 2, Name: "满 100 减 15", Priority: 80, Enabled: true,
			Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
			End:   time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC),
			MutexWith: []int{1},
			When:      func(m MarketingContext) bool { return m.Subtotal >= 10000 },
			Apply: func(m *MarketingContext, ev *Evaluation) error {
				d := int64(1500)
				m.Subtotal -= d
				ev.Applied[len(ev.Applied)-1].Discount = d
				return nil
			},
		},
		&Rule{
			ID: 3, Name: "数码品类满减", Priority: 70, Enabled: true,
			Start: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
			End:   time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC),
			When: func(m MarketingContext) bool { return m.Category == "digital" && m.Subtotal >= 5000 },
			Apply: func(m *MarketingContext, ev *Evaluation) error {
				d := int64(500)
				m.Subtotal -= d
				ev.Applied[len(ev.Applied)-1].Discount = d
				return nil
			},
		},
	)
}

生产落地时,可将 When / Apply 替换为声明式条件 AST + 有限动作集,配合配置中心热更新;与第9章「营销计算引擎」衔接时,注意资损防控(双写试算、幂等锁券)。

flowchart TB
	subgraph Engine
		R1[按优先级遍历规则]
		R2{时间窗与开关}
		R3{互斥检查}
		R4{条件匹配}
		R5[执行动作 / 累计结果]
		R1 --> R2 --> R3 --> R4 --> R5
	end
	MC[(MarketingContext)] --> Engine
	Engine --> OUT[(Evaluation / 命中说明)]

3.6 依赖注入与测试

3.6.1 接口与依赖反转

Pipeline 的 Processor、策略的 Reserver、规则引擎依赖的「加载器」都应在内层定义接口,由外层适配器实现——这与第1章的 Port-Adapter 一致。

3.6.2 依赖注入模式

推荐顺序(与第1章一致):

  1. 手动构造(依赖数量可控时最清晰);
  2. Wire 生成装配代码(中大型服务);
  3. Fx(需要生命周期与动态插件时)。

Pipeline 本身通过构造函数注入 CatalogInventoryOrderRepository不要在 Processor 内部 sql.Open

3.6.3 可测试性设计

// ✅ 正例:假库存用于单测 Pipeline
type fakeInventory struct{}

func (fakeInventory) Reserve(ctx context.Context, customerID string, lines []OrderLine) (string, error) {
	_ = ctx
	_ = customerID
	_ = lines
	return "resv_test_1", nil
}

测试用例应覆盖:

  • 每步 Processor失败路径(确保错误带上 Name);
  • 策略注册表的重复 Kind、未知 Kind;
  • 规则引擎的互斥优先级(同一输入下命中顺序稳定)。

3.7 本章小结

本章从电商落地视角串联了四件事:

  1. 痛点:千行函数与深层 if-else 是技术债的外显,要用团队约定的长度与复杂度红线约束。
  2. 函数纪律:单一职责、参数对象化、fmt.Errorf 包装错误,是 Pipeline / 策略能读得懂的前提。
  3. Pipeline:把「创单」等长流程拆为阶段与共享上下文,编排层即文档。
  4. 策略与轻量规则引擎:策略消除「选算法」的 if-else;规则引擎把高频变更从代码挪到数据,并保留优先级与互斥扩展位。
  5. 依赖注入:Processor / Reserver / Loader 均通过接口注入,测试以 Fake 替换,延续第1章的整洁架构边界。

与全书的关系:第1章给出组合拳总览,第3章讨论系统内部结构,第4章讨论系统间协作;本章则把这些架构原则继续下沉到代码层,给出同层内的组织手法。下一章将进一步从评审与上线检查角度,防止这些工程纪律在落地中回潮。


导航返回目录 | 上一章:第4章 | 书籍主页 | 下一章:第6章

导航书籍主页 | 完整目录 | 上一章:第5章 | 下一章:第7章


第6章 架构质量保障

从设计评审到上线检查,建立架构落地的质量防线


4.1 为什么需要分阶段评审

软件工程里有一句常被引用的话:好的代码是重构出来的,不是一次写出来的。初稿几乎必然欠打磨,真正可靠的质量来自持续、有纪律的迭代。Code Review 把这种迭代前移到合并之前——它把个人习惯拉平到团队标准,把隐性知识显性化,把缺陷拦截在扩散之前。

然而,「随便看看」式的评审往往流于表面:有人只看风格,有人只看有没有明显 bug,有人被 diff 的噪声淹没。结果是:架构层面的失误晚到无法廉价修正,设计层面的模糊在代码里被放大成技术债,上线前才发现性能或可观测性缺口。

4.1.1 单次 PR 评审的认知陷阱

陷阱典型表现后果
问题域混杂在讨论 SQL 索引时顺便「拍板」限界上下文决策缺少干系人与记录,后续反复
噪声淹没信号2000 行 MR 里找架构问题高风险项被 style nitpick 挤出注意力
缺少外部脚手架依赖评审者当天状态遗漏与团队经验强相关,不可复制

Checklist 的价值在于降低认知负荷:在疲劳、时间压力或上下文切换时,仍有一个外部脚手架防止遗漏。它并不替代经验与判断力——遇到清单未覆盖的灰区,恰恰说明团队应该把新教训反哺进清单或 ADR(Architecture Decision Record)。

4.1.2 四阶段评审:在正确时机问正确问题

本书建议按四个阶段组织评审,而不是在单次 PR 里眉毛胡子一把抓:

  1. 架构评审:新项目、新服务、新子域或大规模模块拆分——确认分层、边界、读写路径与技术选型。
  2. 设计评审:接口与模型冻结前——核对聚合、命令 / 查询、领域事件与模式选型是否与领域一致。
  3. 代码评审:日常 MR——用 SOLID、函数质量、命名、错误处理与依赖方向守住实现细节。
  4. 上线前检查:发布窗口——补齐性能、并发、可观测性、测试、回滚与文档。
flowchart LR
  A[架构评审<br/>设计期] --> B[设计评审<br/>详设期]
  B --> C[代码评审<br/>MR 期]
  C --> D[上线前检查<br/>合并期]
  D --> E[发布 / 观测 / 复盘]

4.1.3 运作建议:让清单「活」起来

  • 责任人明确:架构项由 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分钟):按 4.2 清单逐项检查,重点追问:
    • 依赖方向是否违反?
    • 边界划分是否合理?(参考第 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. 识别问题:违反 4.2.1 依赖方向检查
  2. 上升讨论:在 PR 中标记 needs-architecture-review
  3. 解决方案
    • 定义应用层用例:PlaceOrderUseCase
    • 定义领域层端口:OrderRepository
    • Handler 只依赖应用层
  4. 后续:将此类问题补充到团队的「评审反模式」文档

经验:当 PR 中出现架构级违规时,不要在代码层面修修补补,而是叫停并重新设计。短期看延迟了交付,长期避免了技术债累积。

4.1.4 何时进入哪一阶段:决策树

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

4.2 架构评审阶段

适用时机:立项、新服务、新子域或大规模模块拆分。目标是在写大量代码之前,把分层、边界、一致性、读写特征与技术选型对齐。

4.2.1 分层结构检查

标准:是否明确定义 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 守护?

4.2.2 限界上下文验证

标准:是否识别 核心域、支撑域、通用域?每个 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 是否混用?

4.2.3 读写路径分析

标准:是否量化 读写比、延迟与一致性要求?读路径若存在重 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 呼应)。

4.2.4 技术选型审查

标准:存储与中间件是否与 访问模式 匹配(点查、范围扫、全文检索、图关系、流处理)?是否记录选型假设与回退方案?

维度评审问题
数据量与热点预估行数、分区键、热点键
一致性强一致 / 最终一致是否与业务容忍度一致
运维成本备份、多 AZ、升级窗口
合规留存周期、脱敏、跨地域

反例:全文搜索需求用 MySQL LIKE '%keyword%' 扛流量,缺少倒排索引与相关性能力。

过度设计与 YAGNI(纳入技术选型同一关口)

标准:是否仅为已确认的变更点引入抽象?能否用更简单的模型先交付,再演化?

反例:典型 CRUD 后台强行上 DDD + CQRS + Event Sourcing 全家桶,团队无力维护投影与版本化事件。

评审追问:若去掉 Event Sourcing,业务是否仍成立?若答案是肯定的,则 ES 很可能是可选优化而非当前必需。CQRS 是否由观测到的读写不对称驱动,而不是由「流行架构标签」驱动?


4.3 设计评审阶段

适用时机:接口评审、领域模型评审、用例与事件清单冻结前。目标是让 战术设计(聚合、Repo、Command / Query、事件)与战略分层一致。

4.3.1 聚合边界检查

标准一致性边界是否以聚合为单位设计?是否避免在单个事务中强行修改多个聚合根,除非有显式的领域规则与补偿策略?

反例:一个数据库事务内同时更新 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,对外构造函数保证合法组合。

4.3.2 命令查询分离验证

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
}

4.3.3 领域事件设计

标准:关键业务状态变更是否发布 领域事件?命名是否使用 过去式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 / 至少一次 + 消费者幂等

4.3.4 模式选型审查

详设阶段可快速对照下表,避免「每个地方都 if-else」或「每个地方都上框架」。

场景特征推荐模式说明
多步骤顺序流程Pipeline(管道)与第 3 章 Pipeline 呼应
同一接口多种实现策略模式扩展点清晰
频繁变化的业务规则规则引擎 / 规则表驱动需版本化与评审
跨聚合协作领域事件 + Outbox与第 1 章 Outbox 呼应

反例:全系统统一 RuleEngine.Execute(ctx, ruleSetID, facts),但规则集无人版本化与评审,线上等于「可执行的配置漂移」。

合规:规则变更走 PR + 审计 + 影子流量;核心不变量仍保留在代码与单测中,引擎只编排可变的参数化策略


4.4 代码评审阶段

适用时机:每次合并请求。把设计约束落到 Go 代码的可观察性质上。

4.4.1 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
}

4.4.2 函数质量审查

维度阈值 / 标准工具或手段
长度单函数宜 < 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 边界函数,而不是塞进结构体字段隐式携带?

4.4.3 命名与可读性

  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
}

4.4.4 错误处理验证

  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
}

4.5 上线前检查

适用时机:发布分支、灰度前、重大重构合并前。与功能完成度无关的「生产就绪」项在此收敛。

4.5.1 性能与并发

性能

  • 关键路径是否有 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避免在持锁时调用慢外部依赖

4.5.2 可观测性

标准metrics(RED / USE)、trace(关键 span)、结构化日志request_idorder_id 等关联字段)。

logger.Info("order_placed",
    "order_id", orderID,
    "customer_id", customerID,
    "duration_ms", elapsed.Milliseconds(),
)
检查点通过标准
日志键值字段可查询,而非仅拼接长句
链路跨服务传播 trace 上下文
SLO新路径有指标与告警阈值

4.5.3 测试覆盖

标准:核心业务规则覆盖率按团队约定(例如 > 80%);集成测试覆盖仓储、消息、外部 HTTP 的 fake / 容器。

检查点通过标准
边界表格驱动覆盖错误路径
Flaky修复或隔离,避免 t.Skip 永久化
语义覆盖不变量,而非仅「能跑通」
func TestPlaceOrder_OutOfStock(t *testing.T) {
    t.Parallel()
    // arrange: 0 stock -> expect ErrOutOfStock
}

4.5.4 回滚方案

标准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 脚本

4.6 本章小结

4.6.1 全阶段总览表(评审清单)

阶段必查项(高杠杆)
架构评审依赖向内、BC 划分、聚合边界、读写评估、YAGNI
设计评审聚合根入口、值对象不可变、Repo 在领域层、Command 意图、领域事件
代码评审SRP、函数规模与复杂度、业务命名、错误 wrap、依赖方向
上线前Benchmark / 压测证据、并发与 race、可观测性、测试与集成、回滚与文档

4.6.2 MR 描述区模板(可复制)

## Self review (author)
- [ ] 4.4 SOLID: 新类型职责与扩展点合理
- [ ] 4.4 函数长度 / 复杂度 / 嵌套 / 参数个数
- [ ] 4.4 命名与 glossary 一致
- [ ] 4.4 错误 wrap,无静默 `_ = err`
- [ ] 4.4 依赖方向与 DDD 战术(不变量、VO)

## Release readiness (if applicable)
- [ ] 4.5 Benchmark 或压测链接
- [ ] 4.5 并发 / race 检查
- [ ] 4.5 Metrics + logs + traces
- [ ] 4.5 核心规则测试与集成测试
- [ ] 4.5 回滚 / 迁移 / 双写方案
- [ ] 4.5 文档 / ADR 更新

## Design links
- ADR / RFC: ...

4.6.3 实战案例与反模式

案例 A:库存预占接口「顺手」改了聚合边界(设计评审失效)

背景:结算服务在「创单前预占」需求中,直接在订单聚合的事务内更新库存行,图省事。

症状:大促锁竞争升高;库存与订单发布节奏耦合,回滚困难。

处理:设计评审阶段强制改为 OrderPlaced / ReserveStockRequested 事件驱动或显式 Saga;代码评审拦截「双聚合同一事务」。

案例 B:营销规则 JSON 线上漂移(模式选型 + 运维失守)

背景:规则引擎读取未版本化的 JSON,运营后台可直接保存到生产。

症状:线上行为与测试环境不一致,难以复盘。

处理:规则集 版本号 + PR 审核 + 审计日志;核心不变量仍在单测与代码中;影子流量验证。

案例 C:Handler 直连 DB(架构评审后置到 PR)

背景:原型代码直接进入主干,后续 MR 只在 SQL 层修修补补。

症状:领域规则散落在 SQL;单测必须起库。

处理:上升架构评审,引入 端口 + 用例;本 MR 仅允许「垂直切片」式重构到合规结构,不接受继续堆 SQL。


案例 D:缺少性能测试导致的线上故障

背景:订单服务上线了「批量取消」功能,代码评审通过,但未做性能测试。

线上故障

  • 运营同学一次性取消 5000 个订单
  • 服务在循环中逐个发送取消事件到 Kafka,耗时 30 秒
  • 期间所有订单查询请求超时(共享同一个 goroutine 池)
  • 用户投诉量激增

根因分析

  • 代码评审通过:功能逻辑正确,无明显bug
  • 缺失上线前检查:没有性能测试,没有评估「批量场景下的资源占用」

改进方案

  1. 补充 4.5.1 性能检查:批量操作必须有 Benchmark
  2. 异步化:批量取消改为后台任务,分批处理(每批 100 个)
  3. 限流:批量接口加频控,防止运营误操作

经验:代码评审通过≠生产就绪。上线前检查(4.5)是最后一道防线,必须覆盖性能、并发、可观测性。


案例 E:聚合不变量在 PR 中被破坏

背景:订单聚合有不变量「总价 = 各明细之和」,某次 PR 为了修复 bug,直接修改了 TotalAmount 字段。

代码变更

// BAD: 直接修改总价,破坏不变量
func (o *Order) ApplyDiscount(amount int64) {
    o.TotalAmount -= amount // ❌ 绕过了明细,破坏一致性
}

后果

  • 订单详情页显示的小计与总价不一致
  • 财务对账时发现差异,追溯到这次变更

评审反思

  • 设计评审阶段应明确聚合不变量(4.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 脚本(无法回滚)
  • 没有双写策略:新代码直接依赖新字段,回滚时会报错

线上故障

  • 新版本上线后发现性能问题,需要回滚
  • 回滚代码后,服务启动失败(读取不存在的字段)
  • 被迫紧急修复:手动删除字段、重新上线旧版本

改进方案(4.5.4 回滚方案):

  1. 三阶段迁移
    • 阶段 1:加字段,代码双写(写新旧两个字段),读旧字段
    • 阶段 2:代码切换为读新字段
    • 阶段 3:删除旧字段
  2. 每阶段可独立回滚:任何一步出问题都能回到上一阶段
  3. UP/DOWN 脚本齐全:迁移工具(如 migrate)强制要求两个方向

评审清单补充

  • 数据库迁移是否有 DOWN 脚本?
  • 新字段是否通过双写 / 双读策略引入?
  • 回滚后服务是否仍能正常启动?

4.6.4 按角色的最小阅读路径

角色建议优先阅读
作者(提 MR)4.4 全文 + 4.6.2 模板
审阅者(同域)4.4.3–4.4.5 + 与 4.3 冲突点
Tech Lead(新模块)4.2、4.3 + 4.5
SRE / On-call4.5 + 事件与迁移说明

4.6.5 核心要点

系统化的 Code Review 不是挑剔,而是把重构前移到成本最低的阶段。按 架构 → 设计 → 代码 → 上线前 四段清单推进,并与第 1 章方法论、第 2 章战略设计、第 3 章战术实现交叉引用,团队可以在一致语言下讨论分层、边界与实现细节。建议将 4.6.1 嵌入 MR 模板,并在复盘时根据失效案例增补第 21 条——最好的 Checklist 永远是活文档。


导航返回目录 | 上一章:第5章 | 书籍主页 | 下一章:第7章

导航书籍主页 | 完整目录 | 上一章:第6章 | 下一章:第8章


第7章 电商系统全景图

本章定位:在读完第一部分的方法论之后,进入第二部分「电商核心系统设计」之前,用一张可落地的全景图把业务能力、应用系统、数据资产与技术基础设施对齐到同一坐标系。后续第 8 章至第 16 章将沿本章划定的边界逐域深入;第 4 章已经从方法论层面讨论了跨系统集成与一致性模式。

阅读建议:若你已熟悉 DDD 战略术语,可快速浏览 5.2 后进入 5.1 的图示;若你更习惯从接口与数据表入手,建议从 5.1.3 数据架构读起,再回到 5.1.1 校正业务语义。无论哪种路径,都请至少完成 5.4 的三条时序走读,因为后续各章的「集成小节」默认你已经知道主链路的参与者与先后次序

本章产出物(可用于团队对齐)

  1. 一页 限界上下文图(5.1.1)贴在内网架构 wiki。
  2. 一张 服务依赖图(5.1.2)导入架构治理工具(如 Backstage / 内部 CMDB)。
  3. 一份 主数据清单(5.1.3 表格)作为数据 Owner 会议的输入。
  4. 一套 集成模式评审话术(5.3)写进 RFC 模板。
  5. 一张 十二系统职责表(5.5.1)作为新人 Onboarding 必读。

5.1 系统全景架构

中大型电商平台的架构讨论,如果只停留在「微服务拆分清单」,很容易失去业务语义;如果只停留在「业务功能列表」,又难以指导工程依赖与数据落点。实践中常用 EA(企业架构)+ 4A 的多视角方法并行:

视角英文缩写回答的问题本章对应小节
业务架构BA(Business Architecture)平台提供哪些业务能力?投资优先级如何?5.1.1
应用架构AA(Application Architecture)系统如何划分?依赖方向与编排关系?5.1.2
数据架构DA(Data Architecture)主数据、索引、缓存、事件各自承担什么角色?5.1.3
技术架构TA(Technology Architecture)运行时、中间件、可观测性与安全如何承载上述系统?5.1.4

下面四节分别给出图示与解读要点。图示刻意与后续各章的术语保持一致,便于你在阅读第 7 章至第 15 章时「对照地图」。

与博客文章《电商系统设计(一):全景概览与领域划分》的关系:原文从 EA + 4A 视角给出了高质量的全景素材与系列文章索引;本书第 5 章在此基础上做了三件事——按书籍目录重排小节编号;把 C 端读路径(搜索、购物车)与结算编排显式画入应用架构;把十二个核心系统与第 7 章至第 15 章的章节锚点一一对齐,便于纸质阅读时的交叉引用。若你在网上已读过该文,可把本章视为其书籍化、边界化、评审化的增强版。

术语对照(避免口语歧义)

口语本书用语说明
价格中心 / 促销算价计价系统 + 营销系统「谁制定规则、谁做试算快照」应分开讨论。
交易中心订单 + 结算 + 购物车交易是链路,不是单服务。
上架后台商品上架 + 运营平台流程编排与批量工具职责不同。
搜索推荐搜索与导购(第 12 章)推荐可作为子模块,但集成模式与搜索高度相似。

从单体到分布式的认知迁移:在单体时代,模块边界靠包名与 Code Review 维持;在分布式时代,网络边界会放大设计缺陷——原本一次函数调用的地方,变成了超时、重试与部分失败。因此全景章的价值,不在于「数有多少个微服务」,而在于为每个跨边界调用预先分配 一致性语义、超时预算与观测标签。当你在第 14 章阅读订单状态机时,应能指出:某次迁移对应 5.4.2 中的哪一步、失败时由谁补偿。

5.1.1 业务架构(DDD 视角)

从 DDD 战略设计看,电商平台的业务能力应被组织为一组限界上下文(Bounded Context):每个上下文内部有独立的通用语言与生命周期;上下文之间通过显式关系(客户方 / 供应方、防腐层、发布语言等)协作。下图用「限界上下文 + 域分类」表达业务架构,颜色区分核心域、支撑域、通用域(分类标准与第 2 章 2.5 节一致,此处侧重系统级映射)。

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. 通用域中的搜索本书会深入(第 12 章),因其在工程上与商品、计价、库存的 Hydrate 编排强耦合;用户与消息等更常采购标准方案,在全景中保留接口位即可。

上下文映射(与第 2 章衔接):上图中实线箭头多表示「客户方依赖供应方」的下游调用关系;虚线表示「通过发布语言(Published Language)或 ACL 防腐」的弱耦合。落地时建议显式标出:

  • 供应方(Upstream):商品中心对「商品快照 ID」、计价系统对「价格快照版本」、库存对「预占凭证」拥有定义权。
  • 客户方(Downstream):订单与结算消费上述契约,但不应要求供应方暴露内部表结构。
  • 防腐层(ACL):对接供应商、旧单体或外采营销引擎时,把外部模型挡在边界之外,避免污染核心域通用语言。

答辩提示:业务架构图的表述模板已统一收录到附录B

5.1.2 应用架构(微服务视角)

应用架构关注可部署单元之间的依赖。原则是:依赖方向自上而下、由稳定侧指向易变侧,避免出现「基础数据服务回调订单服务」这类环。下图在博客原文分层思路上,补全 C 端读路径(搜索、购物车)与结算编排,并标注后续章节编号,便于索引。

flowchart TB
  subgraph L0["接入与 BFF"]
    GW[API Gateway / BFF]
  end

  subgraph L_read["读路径与暂存"]
    SearchSvc["搜索与导购服务<br/>第12章"]
    CartSvc["购物车服务<br/>第13章"]
  end

  subgraph L_data["主数据与规则基座"]
    ProductSvc["商品中心<br/>第7章"]
    InvSvc["库存系统<br/>第8章"]
    MktSvc["营销系统<br/>第9章"]
    PriceSvc["计价系统<br/>第11章"]
  end

  subgraph L_trade["交易编排与资金"]
    CheckoutSvc["结算编排服务<br/>第13章"]
    OrderSvc["订单系统<br/>第14章"]
    PaySvc["支付系统<br/>第15章"]
  end

  subgraph L_supply["供给与运营"]
    ListingSvc["商品上架<br/>第10章"]
    OpsSvc["供给与运营管理<br/>第10章"]
    LifeSvc["生命周期协调<br/>第10章"]
  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 编排器」,在应用层与购物车解耦:购物车偏会话与展示,结算偏资源锁定与一致性门槛(详见第 13 章)。
  • 上架、运营、生命周期在应用层可能合并为一个「供给平台」团队维护的多个服务,逻辑上仍建议按限界上下文拆分数据与发布节奏(第 10 章展开)。

典型调用链(便于与 5.4 对照)

用户意图入口服务同步扇出(节选)异步副作用(节选)
搜索列表搜索与导购商品中心、计价、库存 Hydrate曝光日志、排序特征回流
加购购物车商品中心校验 SKU无或弱:会话写 Redis
打开结算页结算编排计价试算、库存预占、营销校验审计日志、风控评分
提交订单订单系统快照固化、库存确认、营销扣减OrderCreated 驱动清购物车、发券统计
去支付支付系统渠道路由、收银台创建PaymentSucceeded 驱动分账、消息触达

循环依赖治理:若发现「商品中心回调订单」一类需求,优先改为事件订阅查询倒置(由订单侧拉取快照),而不是在数据层打开反向通道。

5.1.3 数据架构

数据架构回答三件事:主数据放哪、派生数据如何构建、事件与缓存如何对齐。下图描述一条典型的「写主库、异步投影、读多路」路径,与第 1 章 CQRS 与 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)与周期对账(第 8 章)。

主数据与派生数据清单(评审用)

数据类型权威存储常见派生副本一致性策略
商品主档MySQL(商品中心库)ES 文档、CDN 静态化、本地缓存Outbox / CDC → 最终一致
价格规则与快照MySQL + 计价服务缓存订单行上的快照 JSON创单时以订单持久化为准
可售库存Redis 计数 + MySQL 流水搜索侧的「是否有货」标签单写者 + 定时对账
订单合同MySQL(订单库)数仓订单事实表、客服只读库Binlog / 事件双播
支付单与账务MySQL(支付库)渠道对账文件、会计凭证T+0 / T+1 对账任务

数据所有权一句话:谁对「业务不变量」负责,谁就拥有该数据的写入 API;其余路径只能投影或引用。

离线数仓与实时数仓的边界:订单与支付事件进入数仓后,用于分析与风控建模,不得反向写回在线交易库作为业务依据;若运营需要「实时看板」,应通过专用 OLAP 或流式聚合服务读取消息总线,而不是直接查询订单主库拖垮 P99。若确需运营干预线上数据,应走带审批的正式 API 与审计日志,而不是「数仓导表回灌」。这类约束也是第 4 章上线前检查中「数据变更路径」的必审项。

5.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

与后续章节的关系:第 7 章至第 15 章主要在应用与数据架构层面展开;当你评估「是否需要 Service Mesh」「Kafka 分区策略」时,应回到本节检查观测性是否先于网格消息是否已成为事实管道等平台前提。

非功能需求(NFR)与全景的对应关系

  • 可用性:网关限流与服务熔断保护核心交易路径;搜索与报表故障不得拖垮创单。
  • 性能:读路径大量使用缓存与索引;写路径控制扇出深度,结算页试算可合并批量 RPC(第 13 章)。
  • 安全:密钥不进仓库;支付回调验签在独立模块;内部服务 mTLS 或网络策略隔离。
  • 可观测性:以 trace_id 贯穿网关、结算、订单、支付;对 Saga 每一步有结构化日志与业务指标(转化率、预占失败率)。
  • 合规与审计:订单与支付字段变更可追溯;营销补贴与实付金额可对账。

渐进式演进建议:早期可用「单体 + 清晰包边界」模拟上图拓扑;当团队规模与发布冲突上升时,再按限界上下文拆出独立部署单元,避免「先拆微服务、后补边界」的高成本路径。

容灾与多活(点到为止):技术架构图未展开「单元化 / 多 Region」,但在全景阶段应预留认知:订单与支付数据往往要求 Region 内强一致 + 跨 Region 异步复制;搜索索引与购物车会话更适合 就近读取。若在多活场景下仍沿用单 Region 的强同步调用链,容灾切换时容易遭遇「依赖未起、核心不可用」;因此第 4 章在谈 Saga 时也会隐含「地理边界上的超时预算」问题。


5.2 核心域与支撑域

DDD 强调:不是所有子域都值得同等投入。战略设计的产出之一,是一张「域分类表」,用于指导组织排兵布阵与技术选型(自研 / 定制 / 采购)。

5.2.1 核心域:交易与支付

核心域承载差异化与最高业务风险,典型包括:

限界上下文业务价值失败影响工程特征
订单合同与履约编排的单一事实来源错单、重复下单、无法履约状态机、幂等、Saga、审计
支付资金收付与对账闭环资损、监管与信任危机幂等、渠道适配、账务分录
结算把「可卖」推进为「可付」转化暴跌、资源错锁长事务编排、降级与超时释放
购物车购买意图与会话合并体验与转化问题高并发读写、合并策略

本书将订单、支付、购物车与结算作为交易链路主轴(第 14 章至第 16 章),并在第 4 章从一致性模式上把它们串成可复用的集成语言。

投资与组织策略(与第 2 章 2.5 节对照)

维度建议
团队配置核心域配最强工程与业务分析能力;接口契约由领域 Owner 签字。
发布节奏核心域应支持高频小步发布 + 特性开关;重大促销前冻结非关键变更。
质量门禁核心域 PR 适用第 4 章全阶段评审;支付与订单变更默认要求双人审。
技术债核心域技术债「零容忍排队」;偿债预算单独列项,不与功能挤同一队列。

常见误区:把「购物车」当成纯前端本地存储。实际上购物车是高并发有状态服务,涉及登录合并、库存展示与营销提示,与结算的边界必须在 API 契约上划清(第 13 章)。

5.2.2 支撑域:商品、库存、营销、定价与供给

支撑域是核心域的「地基」:没有可售商品与可算价格,订单与支付无从谈起;没有库存与营销约束,结算编排也会失去输入。

分组限界上下文与核心域的接口关系
商品与供给商品中心、上架、运营、生命周期提供 SPU/SKU、快照、上下架状态;不直接参与支付
规则与资源库存、营销、计价提供预占、券活动、试算与快照;被结算与订单编排调用

第 7 章至第 11 章分别深入各支撑系统;第 10 章从组织上常合并「上架 + 运营 + 生命周期」,但限界上下文仍建议在模型层分开,以避免「一个上帝服务」拖垮发布节奏。

为什么支撑域也值得深度自研:支撑域虽非「卖点」,却是故障的放大器。例如库存超卖、计价错误、营销叠加漏洞,都会在订单层集中爆发。架构评审中常问:「若该支撑域宕机 30 分钟,核心域能否优雅降级?」——答案决定缓存策略、兜底价、降级开关的设计深度。

与核心域的集成契约(摘要)

  • 商品中心输出:快照 ID、类目路径、禁售标签
  • 库存输出:预占凭证、可售数量区间、渠道库存类型(第 8 章二维模型)。
  • 营销输出:可叠加规则集、锁定 token、预算占用凭证
  • 计价输出:试算结果哈希或版本号,供创单时校验「结算页所见即所得」。

5.2.3 通用域:用户、搜索、消息等

通用域标准化程度高,通常采购或薄封装即可;例外是搜索与导购:虽然模式成熟,但在中大型平台中与商品、价格、库存的实时编排深度交织,本书第 12 章单独成章。

能力常见策略与交易链关系
用户与会员SSO、OAuth、IdP提供主体身份与风控标签
消息通知短信、邮件、Push订阅订单与支付事件
搜索ES + 召回排序 + HydratePDP/列表需联动计价与库存态

反模式提醒:把「通用域 = 可以随便写」等同于降低质量要求。正确做法是:减少自研范围,但不降低 SLO 与可观测性要求

关于搜索域的「重要性升级」:从严格 DDD 分类看,搜索常被归为通用域(技术方案成熟);但从业务入口与 GMV 贡献看,它又接近核心体验。本书采取工程折中:在域分类上保留通用属性,在章节权重上按核心链路对待(第 12 章),因其失败模式会直接影响列表价、库存态与活动标签的呈现。

用户与消息:用户域提供主体标识与会员等级,消息域消费订单与支付事件做触达。二者与交易链的耦合主要是读侧鉴权异步通知,应避免在下单同步路径强依赖外部推送可用性。


5.3 系统间的交互模式

跨系统协作可归纳为三类:同步 RPC异步事件数据同步(批式或流式)。它们不是互斥的,同一链路常组合使用;选型取决于一致性语义、延迟上限与故障隔离需求。

5.3.1 同步调用

典型场景:结算页试算、创单前库存确认、支付创建。特征是调用方阻塞等待结果,语义接近「读己之写」或强校验。

优点:实现直观、调试路径短。
风险:级联故障、线程占用、超时风暴;需配合超时、重试、熔断、舱壁与清晰的错误契约(第 4 章相关小节)。

工程要点(Go 服务常见落地):为出站 RPC 设置上下文超时每依赖独立超时;重试仅对幂等读或带幂等键的写开放;对核心交易路径实施舱壁线程池并发上限,避免试算扇出把进程拖死。返回错误时区分业务可预期错误(如券不可用)与基础设施错误(如超时),前者映射为 4xx 与明确 code,后者触发降级与告警。

电商实例:结算页打开时,编排服务并行调用计价、库存、营销;只要任一关键依赖超时,应整体返回「请稍后重试」或切换至缓存兜底价 + 延迟锁券策略,而不是无限等待(第 13 章详述降级矩阵)。

5.3.2 异步事件

典型场景:订单已创建、支付已成功、商品变更。特征是最终一致,通过消息中间件解耦峰值与异构消费者。

优点:吞吐与弹性好,天然适合多订阅者(搜索索引、数仓、营销统计)。
风险:重复消息、乱序、滞后;需幂等消费、版本号、可补偿流程(第 1 章 Outbox、第 4 章事件驱动)。

工程要点:事件体应携带聚合 ID、版本号、发生时间、幂等键;消费者使用「处理表」或唯一索引实现 at-least-once 下的精确一次业务效果。对支付成功类事件,建议以支付系统 Outbox 为唯一发布源,避免订单与支付双写双发导致重复记账。

电商实例ProductChanged 发布后,搜索索引、推荐特征、运营看板可能各自消费;它们失败不应阻塞商品主事务,但需要通过死信队列与可观测面板暴露积压,防止索引长期陈旧引发客诉。

5.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
}

5.4 数据流转全景

本节用三条完整时序链把 5.1 至 5.3 的静态结构串成动态故事线。图中参与者命名与后续章节标题一致,便于对照。

三条链路的共同模式(背诵版):每条链路都同时存在 同步确认(保证局部不变量)与 异步传播(放大读模型与运营可见性)两类步骤。设计时请先标出「哪一步失败会导致资损或客诉」——这些步骤应尽量落入短事务 + 明确幂等键;其余步骤尽量推出消息总线。另一个共同点是 Hydrate:搜索与列表在 C 端读路径上,往往需要二次拉取商品、价格、库存以修补索引延迟;这与订单创单时的「快照固化」是同一思想的不同形态——用显式版本与快照对抗时间差

与大促场景的关系:商品流在大促前表现为「批量改价、改库存、改活动」的洪峰;订单流在秒杀瞬间表现为「创单与扣减」的尖峰;支付流在峰值表现为「渠道限流与回调延迟」。全景上需要预留 降级开关与异步化边界:例如列表页短时跳过非关键 Hydrate、支付回调与订单状态更新解耦等(细节分散在第 8、12、13、15 章)。

5.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 体现 生命周期对上架与主数据的回流:下架、供应商同步修正、违规处罚都会触发再次审核或索引失效。

伏笔:第 7 章讲清商品模型与快照;第 8 章区分预占与实物库存;第 10 章拆解上架与运营编辑的权限与状态机;第 12 章展开 Hydrate 编排与降级。

5.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 或签名摘要,订单持久化时校验,防止「页面价与实付不一致」引发纠纷。库存侧若已在结算预占,创单多为确认;若仅在结算校验、创单时才预占,则需评估高峰下的重试风暴(第 8 章)。营销锁定与扣减宜拆成「锁定 → 确认 / 释放」两阶段,与订单状态机对齐,避免券冻结长期占用。

异常路径(图中未展开但工程必备):创单任一步失败应沿 Saga 反向释放预占与券锁定;若订单已写库但后续异步失败,应依赖订单状态机驱动补偿任务,而不是人工改库。

伏笔:第 12 章定义试算与快照边界;第 14 章给出 Saga 步骤与超时释放;第 15 章深入状态机与补偿;第 4 章把这些步骤抽象为可复用的一致性模式。

5.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 语义,避免在回调线程堆叠扇出。

与订单的边界:订单系统关心「应付金额与履约状态」;支付系统关心「渠道收单结果与资金事实」。订单不应直接保存渠道原始报文全字段,应由支付系统规范化后回写支付结果摘要

伏笔:第 16 章展开渠道适配、对账与退款;第 4 章讨论跨系统幂等与补偿事务编排。


5.5 系统边界总览

5.5.1 各系统的职责边界(十二个核心系统)

下表给出本书采用的十二个核心可部署系统(与 5.1.2 应用架构及第 7 章至第 15 章对应)。「不负责」列用于架构评审时的负面清单,防止边界侵蚀。

编号系统核心职责明确不负责深入章节
1商品中心SPU/SKU、类目属性、商品快照与主数据质量库存扣减、营销计算、支付第 7 章
2库存系统可售量、预占/确认/释放、对账与供应商同步价格计算、订单状态机第 8 章
3营销系统券/活动/补贴、圈品、预算与防刷订单持久化、支付渠道第 9 章
4计价系统多场景试算、费用、价格快照与降级营销资金账、库存数量第 11 章
5搜索与导购Query、召回、排序、Hydrate 编排不作为订单或支付事实来源第 12 章
6购物车行项目暂存、合并、批量操作不持有支付契约第 13 章
7结算编排结算页 Saga、预占协调、拆单预览不替代订单合同存储第 13 章
8订单系统合同、状态机、拆单、履约协调入口渠道密钥、资金划拨第 14 章
9支付系统支付单、渠道路由、回调、对账与退款商品主数据、库存数量第 15 章
10商品上架上架审核、发布流程、供给侧状态机不复制商品中心全量模型职责第 10 章
11供给与运营管理批量任务、配置工具、权限与审计不绕过商品中心直接写「影子库」第 10 章
12生命周期与供给治理同步/编辑/下架边界、跨系统编排约束不实现全量搜索召回第 10 章

说明:第 10 章在目录上合并了上架、运营与生命周期;在工程上可拆为多服务,但在边界表中仍建议分开陈述职责,以便治理。

十二系统「一句话职责」扩展(评审口播版)

  • 商品中心:维护「卖得是什么」——结构化商品、类目约束与面向订单的快照能力;对外暴露稳定读模型与快照创建接口。
  • 库存系统:维护「还能卖多少」——把可售量、渠道库存、预占凭证与对账闭环收敛在库存库表与热点缓存中。
  • 营销系统:维护「怎么促卖」——圈品、券活动、补贴预算与叠加互斥规则;不负责把最终应付金额写入订单。
  • 计价系统:维护「收多少钱」的规则引擎与快照——对接商品基础价与营销减免,输出可校验的试算版本。
  • 搜索与导购:维护「怎么找得到」——索引与排序是手段,Hydrate 与场景识别是业务核心;索引永远晚于主库一秒是常态而非事故。
  • 购物车:维护「用户想买什么」——会话级行项目与合并逻辑,不承担资金与库存的最终承诺。
  • 结算编排:维护「现在能不能付」——把试算、预占、营销校验收敛为短窗口内的可执行计划,再交给订单持久化。
  • 订单系统:维护「合同与履约状态」——状态机、拆单、快照引用与对外协调接口;不保存渠道密钥与支付通道报文。
  • 支付系统:维护「资金事实」——支付单、渠道、回调、账务分录与对账;不反向驱动商品编辑。
  • 商品上架:维护「供给侧流程」——审核、发布、异步补偿与状态机;它是编排器而非商品主数据的越权写入者。
  • 供给与运营管理:维护「运营效率」——批量导入导出、配置工具、权限审计;所有落库应通过正式领域 API。
  • 生命周期与供给治理:维护「时间与责任的边界」——上下架、同步冲突、编辑互斥与跨系统回滚策略的协调者。

按价值链聚类(便于向业务方解释)

价值链阶段涉及系统(编号见上表)业务语言
进场与治理10、11、12、1、2、4「有货、有价、合规可售」
发现与暂存5、6、1、4、2、3「看得见、算得清、加得进」
成交与资金7、8、9「锁得住、记得准、收得到」

5.5.2 边界不清的常见问题

  1. 商品中心写库存流水:库存的并发语义与对账域应收敛在库存上下文,否则易出现双写不一致。
  2. 营销系统直接改订单金额:优惠「算出来」与订单「记下来」应分离;否则退款与审计难以追溯。
  3. 搜索索引当主库:索引延迟会导致「搜得到但买不了」,必须在 Hydrate 或结算侧再次以权威服务为准。
  4. 支付系统承载订单状态机:资金状态与履约状态相关但不同;混写会导致渠道回调与拆单场景难以治理。
  5. 计价系统写订单行:计价负责「算」与试算令牌;订单负责「记」与版本校验。混写会让退款金额拆分失去依据。
  6. 购物车持有库存预占:预占属于资源锁定,应落在结算或订单编排;购物车仅存意图与展示缓存。
  7. 运营后台直连生产库改价:绕过计价与审计,极易产生监管与对账风险;应走审批流 + 正式 API。
  8. 搜索服务在召回阶段调用支付:读路径不应触碰资金系统;价格与活动以 Hydrate 调用计价与营销只读接口为界。

5.5.3 边界划分原则(落地检查清单)

  1. 单一事实来源(SSOT):每个聚合只有一个权威上下文持久化。
  2. 编排与状态分离:编排服务可以无状态或仅存会话;合同状态由订单上下文持有。
  3. 读模型可替换:搜索、推荐、报表可重建;不可重建的是资金流水与订单合同
  4. 跨域用契约,不用隐式共享表:表连接是反模式;用 API、事件与明确 DTO。
  5. 把「能不能买」的最后一次校验放在离钱最近且可审计的一步(通常是创单或支付创建)。
  6. 明确「编排」与「领域服务」:编排负责步骤顺序与超时;领域服务负责业务规则判定。二者勿混在同一「上帝类」中。
  7. 每个跨系统接口都有 SLI:例如试算 P99、索引延迟上限、支付回调处理延迟;无指标的接口等于无边界。
  8. 用例驱动的边界测试:为每个系统维护「本系统拒绝处理的请求样例」,在 CI 或契约测试中固定下来,防止回归侵蚀。

5.6 本章小结

本章是全书的总领章,目标不是替代后续各章的深度,而是建立三样东西:同一套词汇(限界上下文与十二个系统)、同一张依赖图(应用与数据架构)、同一套交互纪律(同步 / 异步 / 数据同步的组合拳)。

你可以带走的关键结论

  1. 四视角对齐:BA 决定投资与语义边界,AA 决定可部署单元与依赖方向,DA 决定权威数据与投影路径,TA 决定规模化运行时能力。缺任一视角,评审容易出现「各说各话」。
  2. 十二个核心系统:商品中心、库存、营销、计价、搜索、购物车、结算编排、订单、支付、商品上架、供给与运营、生命周期治理——分别对应第 7 章至第 15 章的主体叙事;其中第 10 章在工程上常合并多个部署单元,但在治理上仍建议按职责拆分讨论。
  3. 域分类指导排兵:核心域(订单、支付、结算、购物车)追求正确性与可审计性;支撑域追求稳定与可替换的集成契约;通用域追求成本与 SLO 的平衡。搜索处于「通用技术 + 核心体验」的交叉带,第 12 章会展开其 Hydrate 与降级策略。
  4. 交互模式不可偏科:只有同步会导致故障传播;只有异步会拉长不一致窗口;只有批式同步无法满足实时导购。实际架构是在 SLA、成本、团队成熟度 约束下的组合。
  5. 三条主链路是阅读地图:商品流回答「货怎么进来并被发现」;订单流回答「承诺如何形成」;支付流回答「资金如何闭环」。后续章节均可挂载到这三条链上自检:本章的哪个小节、哪张图覆盖了当前话题。

与第 4 章的衔接:第 4 章将把 5.3 的交互模式上升为 Saga、幂等、对账、事件驱动 等一致性语言,并把 CAP 折中讲透。建议在读第 4 章前,先用 5.4 的时序图遮住文字,尝试口述一遍每条箭头上的失败与补偿,检验是否已建立全景肌肉记忆。

与第 7 章至第 15 章的衔接:进入任一系统章时,建议先回答四个问题——谁是上游、谁是下游、我的 SSOT 是什么、我发布哪些事件。答不上来则回到 5.5.1 边界表补齐。

面向架构师的自检清单(离开本章前):能否在 10 分钟内手绘 5.1.2 依赖图并标出三条可能形成环的依赖?能否用业务语言向非技术干系人解释「为什么搜索不是订单的一部分」?能否列举支付回调失败时的三个系统状态组合及各自补偿动作?若尚不能,建议在笔记中重画一遍 5.4 时序图,再进入第 4 章。

建议阅读顺序:若你更熟悉业务,可按 5.4 节三条链路走读,再回看 5.1.1 的限界上下文;若你更熟悉工程,可从 5.1.2 与 5.3 节开始,把依赖与交互模式对齐后再进入各系统章节。若你正在准备架构评审,可携带 5.1.3、5.3.3 与 5.5.3 作为一页纸附录。


导航书籍主页 | 完整目录 | 上一章:第6章 | 下一章:第8章

导航书籍主页 | 完整目录 | 上一章:第7章 | 下一章:第9章


第8章 商品中心系统

本章定位:商品中心不是供给后台,也不是商家上传系统。商品中心是平台的正式商品主数据中心和交易前契约中心,负责沉淀已经发布生效的 Resource、SPU/SKU、Offer、库存配置、履约规则、退款规则、发布版本和商品快照。第 11 章讨论商品如何从 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 事件和版本化读模型形成最终一致。

完整的供给治理链路见:


8.1 系统定位与边界

8.1.1 商品中心是什么

商品中心是平台对“商品事实”的正式表达。它不关心商家正在编辑哪份草稿,也不关心某条 Excel 第几行是否解析失败;它关心的是已经发布生效的商品版本。

商品中心至少要回答:

问题商品中心的回答
卖的是什么资源Resource,例如酒店、门店、机场、活动、充值运营商
商品如何定义SPU/SKU,例如套餐、面额、房型、服务规格
用什么销售承诺卖Offer / Rate Plan,例如价格计划、渠道、销售期、可售规则
下单前需要什么信息Input Schema,例如手机号、账单号、入住人、证件
如何履约Fulfillment Rule,例如充值、发券、出票、供应商确认
如何退款售后Refund Rule,例如随时退、过期退、不可退、取消政策
哪个版本当前生效publish_version
历史订单如何解释商品快照、报价快照、履约契约快照、退款规则快照

一句话:

商品中心负责正式商品和交易前契约,不负责供给过程。

8.1.2 商品中心不是什么

商品中心不应该承接所有 B 端流程状态。下面这些内容应该属于第 11 章的供给与运营平台:

内容应该归属原因
草稿保存供给平台 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、正式商品的生命周期混在一起,状态机很快失控。

8.1.3 与供给平台的关系

第 11 章的供给平台负责“如何把商品送进来”,商品中心负责“已经发布的商品如何被交易系统稳定使用”。

供给平台:
  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 等审计引用,但不应该反向保存供给侧的完整流程状态。


8.2 总体架构

8.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 或自动准入策略,再进入正式商品。

8.2.2 读写分离

商品中心的写入链路强调一致性和可追溯:

Publish Command
  → 版本校验
  → 正式表事务写入
  → 快照生成
  → Outbox

商品中心的读取链路强调低延迟和稳定:

详情页
  → Redis / Local Cache
  → 商品中心 Query API
  → DB 回源

列表页
  → Search
  → 商品中心批量 Hydrate

创单页
  → 商品中心版本化快照
  → 库存确认
  → 计价试算

不要让搜索列表直接扫商品中心 MySQL,也不要让商品中心在详情接口里临时计算复杂优惠价和实时库存。商品中心提供交易前契约,计价和库存由各自领域负责。

8.2.3 核心原则

商品中心设计要坚持几个原则:

原则说明
正式表只保存发布数据Draft、QC、Rejected 不进入商品正式表
写入必须版本化每次发布递增 publish_version
订单只信快照历史订单不回读最新商品解释
读模型可最终一致搜索、缓存通过 Outbox 异步刷新
下游按版本幂等消费旧事件不能覆盖新版本
高变化数据不强塞商品表实时库存、最终成交价、供应商实时房态不属于商品中心唯一事实

8.3 核心领域模型

8.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,运营再决定如何售卖。

8.3.2 Resource

Resource 表达“现实或外部世界里的资源对象”。它不是一定可售的商品,而是商品的承载对象。

品类Resource 示例商品示例
酒店酒店、房间资源、地理位置房型套餐、Rate Plan
本地生活门店、服务地点双人餐券、洗车券、按摩套餐
票务演出、场馆、场次门票档位、座位区
充值运营商、国家、产品线话费面额、流量包
账单账单机构、缴费类型电费、水费、宽带账单

Resource 的状态不等于商品状态。酒店资源可以是有效的,但某个房型 Offer 暂时不可售;门店可以营业,但某张券可以下架。

8.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 是把这些内容组合成“前台商品”的聚合根。

8.3.4 SPU / SKU

SPU 表达商品共性定义,SKU 表达可下单的规格单元。

对象作用示例
SPU商品共性信息iPhone 16、KFC 套餐、酒店豪华房
SKU可售规格单元黑色 256G、双人套餐、含早可取消

不是所有数字商品都需要复杂多 SKU,但建议保留默认 SKU。这样订单、库存、计价、履约都能使用统一结构。

8.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

8.4 类目模板与异构品类

8.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 风险识别;商品中心用它约束正式数据和生成读模型。

8.4.2 属性分类

商品属性至少分四类:

属性类型示例是否影响交易
基础展示属性标题、图片、卖点、描述间接影响
关键决策属性品牌、城市、门店、酒店星级影响搜索和转化
销售规格属性颜色、尺码、面额、房型影响 SKU
交易契约属性退款规则、履约方式、输入字段直接影响下单和售后

不要把所有属性都塞进一个 ext_info。适合搜索、筛选、风控、计价、履约使用的字段,要结构化存储或生成专门投影;纯展示和低频字段可以放 JSON 扩展。

8.4.3 扩展字段策略

扩展字段有三种方式:

方式优点缺点适用
JSON 扩展上线快,适合异构字段查询和约束弱低频展示字段
垂直扩展表类型清晰,查询友好表多,建模成本高酒店、门店、票务核心属性
属性表 EAV灵活查询复杂,容易失控通用筛选属性

推荐组合:

核心交易字段
  → 结构化列

品类核心字段
  → 垂直扩展表

长尾展示字段
  → JSON

搜索筛选字段
  → 搜索投影

8.5 正式商品状态与版本

8.5.1 Item 状态

正式商品的生命周期状态建议放在 product_item_tab.item_status

状态含义
PUBLISHED已有正式发布版本,但未必已经开始售卖
ONLINEC 端可见,满足上线条件
OFFLINE手动下线或暂不售卖
ENDED销售期结束
BANNED平台封禁
ARCHIVED归档,仅保留历史查询和审计

它不应该包含 DRAFTQC_PENDINGREJECTED。这些状态属于供给侧对象。

8.5.2 Sellable 状态

item_status 表达商品资产状态,sellable_status 表达当前是否可交易:

状态含义
SELLABLE当前允许下单
UNSELLABLE不允许下单
NOT_STARTED销售期未开始
SOLD_OUT库存或券码池不足
EXPIRED销售期或有效期结束
RISK_BLOCKED风控或平台规则阻断

这两个状态可以不同。例如商品 ONLINE,但库存为 0 时 sellable_status=SOLD_OUT。列表页可以展示该商品,但下单按钮不可用。

8.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

如果不相等,说明线上商品已经变化,当前提交应进入版本冲突,不能静默覆盖。

8.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 的状态机在第 11 章。


8.6 核心表模型

商品中心表模型围绕“正式主数据 + 交易前契约 + 发布版本 + 快照”设计。

8.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。

8.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 状态,只保存正式商品的线上状态和当前发布版本。

8.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。

8.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 来统一交易链路。

8.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 里的价格字段只能表达基础价或标价。最终成交价应该由计价系统根据活动、优惠、会员、渠道和风控规则试算。

8.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。

8.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='商品库存配置';

这张表保存库存“如何接入”和“扣减时机”,不保存实时库存事实。实时库存属于库存系统或供应商实时确认。

8.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 必须发布为正式契约,否则订单系统无法稳定校验用户输入。

8.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='商品履约规则';

履约规则是交易前契约的一部分。订单创建时要保存对应快照,避免商品后续修改供应商参数后影响历史订单。

8.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='商品退款规则';

退款规则变更属于高风险变更。它应在第 11 章供给平台中经过 Diff、风险识别和 QC 或强权限确认,再发布到商品中心。

8.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 记录正式版本事实。

8.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='商品发布快照';

快照是订单系统解释历史订单的基础。订单不应该回读最新商品表,而应该保存或引用创单时的快照。

8.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 重试和补偿。


8.7 发布写入链路

8.7.1 只接受发布命令

商品中心写接口不应该暴露成通用 CRUD:

POST /product/updateTitle
POST /sku/updatePrice
POST /offer/updateRefundRule

更合理的是命令式接口:

PublishProductVersion
OfflineProduct
ReactivateProduct
BanProduct
UnbanProduct
ArchiveProduct
RollbackProductVersion

原因很简单:商品变更不是字段修改,而是交易前契约变更。每次变更都需要版本、快照、事件和审计。

8.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

8.7.3 发布前校验

商品中心发布前要做最后一道强校验:

  1. publish_id 是否幂等。
  2. 新建商品是否没有重复外部对象映射。
  3. 编辑商品的 base_publish_version 是否匹配当前版本。
  4. Resource、SPU、SKU、Offer 关系是否完整。
  5. 库存配置、输入 Schema、履约规则、退款规则是否满足类目模板。
  6. 商品是否被封禁、归档或锁定。
  7. 发布 payload hash 是否已经发布过。

如果校验失败,商品中心应该返回结构化错误给供给平台,由供给平台记录 DLQ 或展示给运营。

8.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 异步执行。

8.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 说明线上版本已经变化,本次发布必须失败并返回版本冲突。


8.8 读取模型与查询接口

8.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、文档库或宽表。它是正式表的投影,不是新的事实源。

8.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 必须能和商品中心对账。

8.8.3 创单前读取

创单前读取和详情页读取不同。详情页可以为了体验返回缓存,创单前必须重新确认交易安全:

Create Order
  → 读取商品当前 publish_version
  → 读取商品快照
  → 库存确认
  → 计价试算
  → 供应商实时确认(如果需要)
  → 保存订单快照

对酒店、机票、电影票这类动态品类:

列表页可以缓存
详情页尽量刷新
创单前必须实时确认

8.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"]
}

8.9 商品快照与订单契约

8.9.1 为什么订单只信快照

商品会持续变化:标题、图片、价格、退款规则、履约参数、供应商映射都可能调整。如果历史订单回读最新商品表,就会出现:

  1. 用户下单时看到可退款,售后时变成不可退款。
  2. 下单时是供应商 A,履约时商品映射变成供应商 B。
  3. 下单时价格计划为 RP_001,后续改成 RP_002,订单对账无法解释。
  4. 商品下架后历史订单详情无法展示。

所以订单创建时必须保存商品上下文快照。

8.9.2 快照内容

订单至少需要保存或引用:

快照内容
商品快照标题、图片、类目、Resource、SPU/SKU
Offer 快照销售期、渠道、基础价、Rate Plan
价格快照计价系统试算结果、优惠、实付金额
输入 Schema 快照用户下单时需要提供什么
履约规则快照供应商、履约参数、超时策略
退款规则快照取消政策、退款条件、售后路径
供应商映射快照外部商品、资源、Rate Plan 对应关系

商品中心负责商品、Offer、输入、履约、退款等交易前契约快照;计价系统负责价格快照;库存系统负责库存预占或扣减记录。

8.9.3 快照生成策略

快照可以在发布时生成,也可以在创单时按版本读取。

推荐:

发布时生成版本化商品快照
  → 创单时引用 item_id + publish_version + snapshot_id
  → 订单保存必要字段冗余

这样既能减少创单时组装成本,也能保证历史解释稳定。


8.10 与库存、计价、搜索、订单的边界

8.10.1 与库存系统

商品中心保存库存配置,不保存实时库存事实。

商品中心库存系统
库存类型实时库存数量
库存来源引用预占、扣减、释放
券码池引用券码分配
扣减时机并发控制
是否需要供应商确认供应商库存查询

例如:

product_stock_config.stock_type = CODE_POOL
inventory_ref = code_pool_10001

商品中心只告诉订单系统这个商品使用券码池,真正券码是否可用由库存或券码系统判断。

8.10.2 与计价系统

商品中心可以保存基础价、标价、Offer 规则引用,但最终成交价属于计价系统。

商品中心:
  list_price
  currency
  offer_id
  rate_plan_id

计价系统:
  channel price
  campaign price
  coupon
  service fee
  settlement amount
  final payable amount

不要把活动价、券后价、会员价直接写死在商品表里,否则退款、对账和营销成本会失去来源。

8.10.3 与搜索系统

搜索系统是商品中心的读投影。商品中心通过 Outbox 发送:

ProductPublished
ProductContentChanged
ProductOnline
ProductOffline
ProductSellableChanged

搜索消费事件后更新索引。索引要保存 publish_version,方便巡检:

product_item.current_publish_version
  vs
search_index.publish_version

如果不一致,生成补偿任务重建索引。

8.10.4 与订单系统

订单系统使用商品中心的方式是:

  1. 创单前读取商品当前版本和快照。
  2. 校验商品状态和基础交易契约。
  3. 调库存系统确认可售。
  4. 调计价系统确认金额。
  5. 必要时调供应商实时确认。
  6. 保存订单快照。

订单不应该直接依赖商品中心最新表解释历史订单。

8.10.5 与履约系统

履约系统需要的不是最新商品详情,而是订单创建时的履约规则快照。

fulfillment_type
supplier_id
supplier_product_code
rate_plan_code
timeout_seconds
retry_policy

商品发布后的履约规则变更只影响新订单,不影响旧订单。


8.11 缓存、索引与一致性

8.11.1 缓存策略

商品详情适合多级缓存:

Local Cache
  → Redis
  → DB / Read Model

缓存 key 要带版本:

product:detail:{item_id}:{publish_version}

这样新版本发布后可以直接读新 key,旧版本仍可供历史订单或短期页面使用。

8.11.2 缓存失效

发布成功后,不建议同步删除所有缓存。更稳的方式:

写新版本缓存
  → Outbox 通知缓存失效
  → 旧版本自然过期

如果使用不带版本的热 key:

product:detail:{item_id}

则必须通过 Outbox 事件失效或刷新,并让消费者按 publish_version 幂等,避免旧事件覆盖新缓存。

8.11.3 搜索最终一致

商品中心 DB 和搜索索引天然是最终一致。关键不是强行同步刷新,而是:

  1. 事件不丢。
  2. 消费可重试。
  3. 消费按版本幂等。
  4. 有巡检发现索引落后。
  5. 有补偿任务重建索引。

巡检公式:

索引落后 = product_item.current_publish_version > search_index.publish_version

8.11.4 Outbox 补偿

Outbox 失败不应影响商品发布成功。失败事件进入重试:

PENDING
  → SENDING
  → SENT

SENDING / FAILED
  → retry
  → DLQ

常见补偿任务:

  1. 重建搜索索引。
  2. 刷新商品详情缓存。
  3. 重放商品发布事件。
  4. 校验营销圈品是否同步。
  5. 校验订单创单快照是否可读取。

8.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 事件按版本投递

8.13 商品中心 API 设计

8.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. 写操作必须产生版本、快照和事件。

8.13.2 Query API

Query API 面向前台、订单、搜索 Hydrate 和内部系统。

API作用
GetProductDetail商品详情
BatchGetProductSummary批量商品摘要
GetProductSnapshot按版本读取商品快照
BatchGetOfferContract批量读取 Offer 契约
GetInputSchema读取下单输入 Schema
GetFulfillmentRuleSnapshot读取履约规则快照
GetRefundRuleSnapshot读取退款规则快照

Query API 要区分:

C 端展示读取
  → 可以走缓存和读模型

创单交易读取
  → 必须读取版本化快照并做状态校验

8.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 防乱序。


8.14 质量治理与可观测性

8.14.1 商品质量维度

商品中心要参与发布后质量巡检,但质量问题的修复流程仍然回到供给平台。

常见质量问题:

问题影响
缺图C 端转化下降,搜索降权
缺价或价格异常详情页不可展示,计价失败
无库存配置创单不可售
无履约规则支付后无法履约
无退款规则售后不可解释
Resource 映射缺失供应商履约失败
搜索索引版本落后前台搜不到或展示旧数据
缓存版本落后详情页展示旧内容

8.14.2 监控指标

指标说明
商品详情 P99Query API 延迟
缓存命中率详情缓存、摘要缓存命中
发布成功率发布命令成功 / 总命令
发布版本冲突率版本 CAS 失败比例
快照生成失败率发布后快照失败比例
Outbox 堆积量待投递商品事件数
索引版本落后数搜索索引落后商品数
商品契约缺失率缺库存、履约、退款规则的商品占比

8.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 回到第 11 章供给平台:

publish_id / operation_id
  → product_supply_operation_log
  → draft / staging / qc / publish_record

8.15 答辩材料

本章相关问题、总结话术和追问要点已统一收录到附录B

延伸阅读建议


导航书籍主页 | 完整目录 | 上一章:第7章 | 下一章:第9章

导航书籍主页 | 完整目录 | 上一章:第8章 | 下一章:第10章


第9章 库存系统

本章定位:库存是交易链路的硬约束之一。本章先从通用库存系统出发,解释库存为什么不是一个简单数字,而是一种可承诺供给能力;再用库存对象、库存范围、事实来源、单元形态和扣减时机抽象多品类差异,最后落到虚拟商品库存的券码、充值、供应商实时生成、Redis 原子扣减、账本对账与补偿实践。


9.1 背景与挑战

9.1.1 库存系统的本质

库存系统管理的不是一个简单数字,而是平台对用户的一种可承诺供给能力

某个售卖对象
  在某个库存范围
  某个时间窗口
  某种业务约束下
  还能承诺给用户多少

这句话比“SKU 还有多少件”更接近真实电商。实物电商要考虑仓库、门店、调拨、发货;本地生活要考虑门店、券码、活动名额;酒店要考虑日期、房型、供应商确认;票务要考虑场次、座位、出票;虚拟商品要考虑卡密、充值、供应商实时生成和不可逆履约。

因此,库存系统的第一原则不是“扣一个数”,而是:

在高并发、可重试、可能失败的交易链路里,稳定地回答“能不能卖”,并把每一次承诺、占用、确认、释放、补偿都记录清楚。

从通用库存视角看,库存至少有五种状态语义:

语义含义常见字段
总库存平台或供应商声明的库存规模total_stock
可售库存当前还能承诺给用户的数量available_stock
预占库存已被订单占用但未最终成交booking_stock/reserved_stock
锁定库存被风控、运营、活动或异常处理临时锁住locked_stock
已售库存已经确认成交或已经出库 / 出码 / 出票sold_stock/issued_stock

库存语义混淆的答辩提示已统一收录到附录B

9.1.2 从通用库存到虚拟商品库存

通用库存系统先解决“可售承诺”的建模问题,再针对不同品类选择不同策略。虚拟商品没有传统仓库和物流,但它不是更简单,而是把仓储复杂度换成了履约和一致性复杂度:券码不能重复发,充值提交后不能随便撤回,供应商实时生成可能超时,卡密泄露会变成资损。

场景库存形态核心难点技术手段
实物电商仓库 / 门店 / 批次数量仓配范围、锁库、出库、退货回补库存范围建模、账本流水、WMS 对接、预占释放
本地生活SKU 数量、门店配额、券码门店可用、券码唯一、营销叠加Scope 维度、券码池、商品库存 + 营销库存双预占
酒店房型 + 日期库存连住多晚、供应商同步延迟、下单二次确认日期切片、快照 + 实时刷新、供应商 booking
票务 / 机票场次、座位、舱位强动态库存、出票不可逆短缓存、实时查询、异步出票状态机
数字券 / 礼品卡卡密 / code防重复出码、卡密安全、空池补货码池状态机、Redis LIST、加密存储、出码幂等
话费 / 充值无限或供应商实时额度平台无实物库存但履约可能失败无限库存策略、供应商错误分级、补偿工单

所以这一章的叙事顺序应该是:先建立通用库存系统,再过渡到虚拟商品库存类型。后文的 Redis Lua、供应商同步、对账补偿,本质上都是服务于这些难点,而不是为了展示某个技术组件。

9.1.3 核心难点与解决手段

库存系统的技术含量集中在“高并发下把承诺做对,失败后能修回来”。

难点典型表现为什么难解决手段
并发超卖多个用户同时抢同一 SKU,库存被扣成负数Check 和扣减分离会产生竞态Redis Lua、DB CAS、行锁、库存分片
重复扣减支付回调重放、接口超时重试、消息重复消费分布式系统默认至少一次order_id/event_id 幂等键、唯一约束、状态机
预占泄漏下单占库存但用户不支付订单、支付、库存异步推进TTL、延时队列、定时扫描、幂等 Release
热点库存秒杀单 SKU 形成 Redis 热 Key单 Key QPS 打满单线程限流、库存分片、令牌桶、队列削峰
多库存联动商品库存成功,营销库存失败多资源无法本地事务提交Saga、逆序补偿、补偿任务、人工门闩
供应商不确定查询超时、库存过期、异步确认晚到外部系统不可控快照、实时查询、熔断、供应商 booking 表
库存漂移Redis、MySQL、订单预占记录不一致热路径和账本路径分离库存流水、聚合对账、Outbox、修复任务
虚拟履约不可逆卡密已发、充值已提交后失败回滚不等于把数字加回来履约状态机、发货幂等、人工核损

这张表也决定了本章的技术主线:账本模型保证可追溯,原子扣减保证并发安全,幂等状态机保证可重试,对账补偿保证最终修复,供应商适配保证外部不确定性可控

9.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、对账修复时延、补偿积压量。两者混谈会导致“系统看起来没挂,但用户体验已经崩了”。


9.2 通用库存模型与分类体系

9.2.1 四个建模问题

设计库存系统时,不应该先问“用 Redis 还是 MySQL”,而应该先问四个建模问题:

问题含义示例
库存对象是什么被承诺和扣减的最小业务对象sku_idoffer_id、房型、场次、券批次
库存范围是什么这份库存在哪个范围内可用仓库、门店、城市、渠道、日期、批次、供应商
库存事实来源是谁谁对库存真实性负责平台自管、供应商管理、无限库存
扣减时机是什么什么时候把可售变成占用或已售下单、支付、发货、供应商确认

这四个问题共同决定库存 Key、数据表唯一键、缓存 Key、扣减策略和对账维度。缺少“库存范围”时,系统很容易只支持虚拟商品数量制,一旦接入仓库、门店、酒店日期或活动独占库存,就会开始堆 if category == xxx

9.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,再把日期、批次、渠道作为显式字段。这样既能保持查询可控,也能让业务表达足够清晰。

9.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 重复告警。

9.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 轮询

无限库存 + 无范围 + 支付后履约
  → 不扣库存,但写履约审计和风控流水

9.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

与营销库存的关系:商品库存回答“有没有货”,营销库存回答“活动名额 / 补贴预算够不够”。秒杀等场景往往需要双扣减(商品 + 营销),本章在 9.7 节说明集成边界;营销细节见第 10 章。

9.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 重试、部分成功回滚或人工修复。

9.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、固定顺序、逆序补偿

一句话总结:

虚拟商品不是“没有库存”,而是库存从仓库货架变成了数量承诺、唯一凭证、供应商额度和不可逆履约状态。

9.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

这样可以把“展示性能”和“交易安全”分开,避免为了列表页性能牺牲创单正确性。


9.3 库存扣减策略

9.3.1 扣减时机

扣减时机是交易体验与资损风险的权衡轴:

  • 下单预占(Reserve / Book):用户体验好(下单即锁货),但占用时长内库存不可用,需要可靠的超时释放。
  • 支付后扣减(Sell on pay):减少无效占用,更适合供应商成本高或确认链路长的品类。
  • 发货扣减:实物电商更常见;数字商品平台多用前两者的组合。

工程上建议把时机写入 inventory_config.deduct_timing,由订单 / 结算编排读取,而不是散落在订单代码的 switch

配置值与交易编排的契约deduct_timing 只是标签,真正决定行为的是订单状态机与库存 API 的组合。推荐在内部文档中固定一张「状态 × 库存动作」表,例如:PENDING_PAYMENT → ReleasePAID → ConfirmCLOSED → Release(幂等)。当同一品类在不同国家 / 不同供应商合同中扣减时机不同,用配置驱动可以避免为每个市场复制一套订单服务。

9.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 维度的动态字段,用于表达「活动独占库存」。商品详情页展示的可售量,与下单强校验使用的可售量,可能不是同一个聚合口径——务必在接口契约里写清楚,避免运营配置误解导致客诉。

9.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 补偿语义)。

9.3.4 超卖防护

超卖防护应分层:

  1. 热路径原子性:Redis Lua 或单分片事务,保证「检查 + 扣减」不可分割。
  2. 业务幂等:同一 order_id 重复确认只生效一次。
  3. 冷路径校验:支付回调后,在确认库存前读取 MySQL 侧汇总做二次校验(容忍更高延迟)。
  4. 对账兜底:周期任务发现 available + booking + sold 恒等式破坏或 Redis / MySQL 偏差过大,自动冻结商品并告警(见 9.5.2)。

CheckStock 与 ReserveStock 为什么要拆开? 只读 Check 适合列表页、加购前的快速失败;但它不能保证并发下的正确性。正确做法是:创单路径必须以 Reserve 这种「读改写原子操作」为准,Check 只是辅助。否则会出现「校验时还有货,下单时被抢走」的经典竞态。

秒杀场景的 Facade(可选优化):当商品库存与营销库存必须同事务化编排时,常规做法是订单 Saga 两步调用;在极端 QPS 下可以引入 FlashSaleInventoryFacade.CheckAndReserve 聚合接口,把限流、热点治理、重复请求拦截收敛到库存域的专用入口。注意:Facade 是性能与风控的「窄接口」,不要让它反向吞噬订单领域的编排职责。


9.4 供应商集成

供应商集成本质是 把「外部库存事实」映射为平台可售视图,并在预订 / 取消时调用供应商 API 对齐状态。

9.4.1 实时查询

适用:变化快、对超卖极度敏感(机票、部分热门票务)。

模式

  • 读路径:短 TTL 缓存 + 超时控制 + 熔断降级。
  • 写路径:同步预订或异步预订(供应商返回 pending 时需轮询,见博客原文异步 booking 状态机)。

读路径的 Go 骨架(与第 16 章风格一致:先缓存、后供应商、再回写、可观测)

// 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,否则线上只能看到「库存服务慢」,无法判断是供应商还是自研逻辑。

9.4.2 定时同步

适用:变化中等、可接受分钟级延迟(部分酒店库存)。

模式

  • 定时任务拉取供应商库存,写入本地 inventory 快照字段(如 supplier_stocksupplier_sync_time)。
  • 读路径优先读本地快照,必要时触发「刷新任务」。

9.4.3 推送模式

适用:供应商能力较强,主动推送房态 / 价格变更。

要点

  • Webhook 入口必须鉴权、幂等、重放安全。
  • 推送与定时拉取可并存:推送负责快变字段,拉取负责兜底对齐。

9.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、超时与人工介入队列、订单侧状态机联动、对账任务对「平台已占 / 供应商未确认」的专项扫描。否则极易出现「钱扣了但供应商没单」或「供应商有单但平台没单」的双向不一致。


9.5 数据一致性保证

9.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. 对账修复为主、消息为辅:接受短窗口不一致,用对账把差异拉回(适合容忍度稍高、但吞吐极大的场景)。

选型没有银弹:机票酒店类强一致诉求更高,虚拟券码大促类更偏向吞吐与事后修复。

9.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
}

9.5.3 补偿任务

补偿任务用于处理:

  • Kafka 消费失败导致日志未落库。
  • 供应商异步预订最终态与本地订单状态不一致。
  • Saga 补偿某一步失败后的「人 + 程序」协同修复。

建议补偿任务具备:可观测进度、可重入、可限流、可人工跳过,并在执行前获取分布式锁或基于 order_id 的行级互斥,避免双写打架。

补偿与对账的分工:对账偏「批量、周期性、发现漂移」;补偿偏「单点、事件触发、把状态推进到合法终态」。两者叠加才能覆盖「消息乱序」「重复投递」「供应商晚到回调」等真实世界的粗糙边缘。

Kafka 消费者的吞吐与顺序:库存事件消费端建议「按 item_id 分区有序 + 批量落库」:item_id 分区可以保证同一商品变更串行应用,批量 INSERT 日志与合并更新可以降低 MySQL TPS。需要警惕的是:重试会导致重复消息,因此 MySQL 写入必须基于 event_id 或业务幂等键去重;否则对账会看到「日志重复 / 库存多减」。

跨库存类型一致性(商品 + 营销):秒杀场景下商品预占成功但营销失败时,必须回滚商品预占。回滚失败不要把系统留在「半占用」状态:应记录缺陷单并阻塞该 order_id 的继续支付,直到补偿成功或人工判定。该话题与第 4 章 Saga、第 10 章营销库存紧密相关,本章强调 库存侧 API 必须可单独幂等重放,以便编排器反复补偿。


9.6 系统边界与职责

9.6.1 库存系统的职责边界

库存系统应该负责

  • SKU 维度的可售数量 / 券码 / 日历切片视图的维护。
  • 预占、确认、释放、退款相关的原子操作与审计日志。
  • 供应商库存同步策略的执行与降级。

库存系统不应该负责

  • 订单优惠分摊、支付路由、用户风控评分(可读取必要参数,但不拥有规则)。
  • 商品详情文案、主图、类目属性(属于商品中心)。

9.6.2 库存 vs 商品:边界划分

维度商品中心库存系统
核心聚合SPU/SKU、属性、类目SKU(或批次 / 日期)库存数量与码池
上架生成可售商品视图根据模板初始化 inventory_config / 初始库存
快照商品快照用于订单展示可选择是否在快照中冗余「库存展示字段」

建议:商品详情页展示库存「有 / 无」可以来自搜索 / 商品聚合读模型;下单路径的强校验必须调用库存服务。

9.6.3 平台库存 vs 供应商库存

  • 平台自管:平台能强约束不超卖(在自有数据正确前提下)。
  • 供应商管理:平台只能「尽力而为」,必须定义 同步延迟下 的用户协议与技术降级(例如显示「库存紧张」、下单后异步确认)。

把「可售」定义成合同:供应商管理并不等于「平台不承担责任」。产品条款、详情页提示、客服话术需要与技术策略一致:例如列表页展示的是「上次同步快照」,下单页展示的是「下单瞬间强刷结果」,支付页又可能进入「供应商二次确认」。这些差异如果只靠前端临时拼接字段,极易引发纠纷;建议由商品 / 库存领域共同产出 可售声明(availability disclaimer) 的配置,并在关键触点统一渲染。

时间维度下的边界:酒店类库存往往以「入住日」为切片,查询与扣减都携带日期参数。库存服务应提供明确的日期合法性校验(不可售日期、最小连住、跨日边界),但不要吞掉「价格日历」职责——价格仍归计价系统,库存只回答「这一天还有没有房 / 席位」。

9.6.4 库存预占的归属

推荐由 库存服务提供 Reserve / Confirm / Release API,订单系统编排调用。避免订单服务直接写 Redis,否则:

  • 权限边界模糊,排障困难;
  • 原子脚本难以复用;
  • 监控指标分散。

进一步建议:把「预占记录」视为库存域内的聚合片段(可用 Redis HASH、也可用独立表存储影子状态),对外只暴露语义化 API。订单系统持有 order_id 与支付超时策略;库存系统持有「这单占了多少、占在哪一批次 / 哪一天」。当两边都要保存时,必须明确 主键映射与幂等回放 规则:支付回调重复到达时,Confirm 只能执行一次;超时释放与支付成功并发时,必须以「订单最终状态」为仲裁者。

组合型(Bundle)扣减的边界:套餐类商品是「一个售卖单元,多个库存单元」。库存系统可以提供 BundleReserve 事务式 API,内部仍以子 SKU 为单位调用原子脚本,但整体成功准则由库存域定义(全成或全败)。不建议把子项拆解交给订单服务循环调用——否则补偿顺序、部分失败、日志关联都会变得脆弱。


9.7 与其他系统的集成

9.7.1 与商品中心集成(商品上架时初始化库存)

商品中心在 SKU 生效时发出领域事件(或消息)是最佳挂钩点:库存服务消费事件后生成 InventoryCreateCommand,再由创建任务异步初始化 inventory_configinventory_balance、券码批次或日期 / 门店切片。完整创建模型见 9.2.6,本节只强调系统边界。

这里的关键是 幂等:同一 SKU 的重复发布 / 回滚发布不得生成重复库存行;建议使用 source_type + source_id + inventory_keyitem_id + sku_id + scope + publish_version 做唯一约束,并在消费端用「版本号 / 生效时间窗」判定是否应用变更。

对「供应商管理」品类,初始化阶段就要写入 supplier_idsync_strategy,并创建供应商适配器所需的 外部商品编码映射(否则库存同步与预订调用会在上线后才发现无法对齐)。对于券码制,还要初始化 batch_id 维度与分表路由规则,避免大促时临时改路由。

9.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. 生命周期和库存状态互相递归调用:建议事件驱动 + 可售投影,避免同步调用链变成大事务。

9.7.3 与订单系统集成(预占 / 扣减 / 释放)

创单路径建议以 Reserve 作为硬闸门:订单系统先拿到库存服务的成功回执,再写入订单主表为 PENDING_PAYMENT。如果顺序反过来,会出现「订单已创建但库存未占」的不可恢复窗口,除非再引入复杂补偿。

支付成功后的 Confirm 应与支付回调幂等键绑定(支付单号 / 回调事件 id)。实践中常见错误是:支付重放导致库存二次加 issued,或支付失败却误触发 Confirm。关单 / 超时释放 应与订单状态机严格对齐:只有从可取消状态进入释放,才调用 Release;对于已进入履约的订单,释放必须转为退款域的逆向流程(可能涉及供应商取消接口)。

9.7.4 与供应商系统集成(实时查询 / 定时同步)

供应商集成建议落在 供应商网关库存适配器层,由库存服务调用,而不是让订单服务直连供应商:订单系统只需要知道「库存服务承诺的结果」,不需要理解每家供应商的 OAuth、签名算法与重试语义。

适配器层应统一:超时、重试(仅对幂等读 / 明确幂等写)、熔断、隔离舱(bulkhead)、以及 错误码映射。强烈建议把供应商错误抽象为三类:Retryable(可重试)、Fatal(明确失败)、Unknown(需要人工核对)。Unknown 类错误不要自动重试写入路径,否则极易造成重复预订。

9.7.5 库存变更事件发布

事件字段建议包含:event_idevent_typeitem_idsku_idorder_idquantitybefore/after 快照、时间戳。消费者可以是:搜索引擎刷新可售标签、报表、风控。

事件设计要兼顾 可排序可去重event_id 建议全局唯一;event_type 建议稳定枚举;before/after 用于审计与对账回放。对于券码制,还应携带 code_ids 或哈希摘要(避免明文扩散到不该出现的下游)。如果下游是搜索索引,通常只需要「可售阈值变化」而非每一次微抖动,可增加 聚合投影(projector)把高频事件折叠为低频索引更新。

9.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

降级策略(库存不可用):严格模式直接失败;宽松模式允许「先创单后补扣」(极易超卖,仅适合内部试单或供应商兜底能力极强且可取消的场景)。若启用宽松模式,必须同步启用 更频繁对账 + 更强支付确认校验 + 明确法务条款


9.8 工程实践

9.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,并对脚本变更做版本号控制,避免滚动发布期间混用旧脚本。

9.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。

9.8.3 监控告警

建议至少监控:

  • reserve/confirm/release 成功率与 P99 延迟;
  • Redis / MySQL 差异直方图;
  • 供应商调用错误率与熔断状态;
  • 对账修复次数与人工介入队列长度。

告警分级示例(可与 Prometheus 规则结合)

级别触发条件响应目标
P0任意 SKU 出现「已售大于总量」或恒等式破坏立即停售 + 紧急修复
P1Redis / MySQL 可用量长期分叉且持续扩大1 小时内定位根因
P2供应商同步延迟超阈值但未破坏交易降级展示 + 供应商工单

9.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 + 人工队列

阅读建议:若读者刚完成第 8 章商品中心与第 4 章一致性章节,可按「商品发布 → 库存创建 → 创单预占 → 支付确认 → 对账修复」的顺序对照本章示意图走读一遍;再把自家品类的库存对象、库存范围、事实来源、单元形态和扣减时机填入矩阵,通常能在工作坊中快速对齐产品与工程预期。建议同时准备 1~2 个真实故障案例作为讨论锚点,避免停留在抽象原则层面。

下一章预告:第 10 章将深入营销系统,重点讨论优惠计算与营销库存(券、活动、补贴)如何与商品库存协同,避免「算得便宜却卖超了」这类跨域问题。


导航书籍主页 | 完整目录 | 上一章:第8章 | 下一章:第10章

导航书籍主页 | 完整目录 | 上一章:第9章 | 下一章:第11章


第10章 营销系统

交易链路关键域:营销系统负责「增长与让利」的可编排表达——优惠券、积分、活动、补贴等工具在成本可控前提下提升转化;同时必须与计价、订单、库存、支付等系统严格分工,避免「算价口径漂移」与「资源扣减双写」。


9.1 系统概览

9.1.1 营销系统的定位

一句话定位:营销系统是电商平台的增长引擎让利规则中心,它不替代「商品价格事实来源」(商品中心 / 计价中心),而是对可售商品集合施加条件化权益(券、活动价、积分抵扣、平台补贴),并在交易链路中完成可审计的占用与核销

与相邻系统的关系(职责视角):

系统营销系统依赖它什么营销系统不替它承担什么
商品中心类目、SPU/SKU、可售状态、圈品范围商品主数据维护、上下架编排
计价中心统一试算编排、价格分层模型、快照口径基础价/渠道价等「标价事实」的唯一来源(按组织边界而定)
库存系统可售库存、预占结果实物库存扣减与释放
订单系统创单编排、状态机、补偿入口订单主单据生命周期(营销只参与其中资源步骤)
用户系统画像标签、等级、风控信号账号体系与鉴权主责
支付系统实付金额、分账、补贴清算渠道对接与支付状态机

价值闭环:投放 → 领取/参与 → 试算曝光 → 下单锁定 → 支付核销 → 结算对账 → 报表 ROI。缺任何一环都会出现「看得见优惠、对不上账」的工程事故。

9.1.2 核心业务场景

典型场景可按用户生命周期与平台目标拆分:

拉新:新人券、首单立减、渠道专属券批次、注册礼包(券 + 积分组合)。

促活与留存:签到积分、任务体系、会员等级权益、生日礼、沉默召回券。

成交提升(GMV / AOV):满减满折、跨店凑单、限时折扣、N 元购、买赠。

热点营销(高并发):秒杀、抢券、限量补贴;对系统提出与普通促销完全不同的容量与一致性要求。

平台型业务(B2B2C):商家自营销 + 平台统一规则 + 审核流;成本承担方可能是商家、平台或按比例共担,必须在支付与清结算链路可解释、可分摊。

B2C 与 B2B2C 的营销差异(决定你是否要引入「审核流、分账、跨店叠加」三件套):

维度B2C(自营为主)B2B2C(平台 + 商家)
营销主体平台单一主体平台与多商家并存
成本承担平台预算闭环即可需定义商家承担、平台补贴、联合出资比例
活动审核通常内部运营闭环商家活动常需平台审核与风控评分
优惠叠加平台统一互斥组即可需处理跨店、跨卖家券、店铺券与平台券的优先级
结算复杂度订单金额 ≈ 平台收入口径需分账、分润、逆向退款时的营销成本回冲

非功能需求(NFR)速查:营销系统既要「算得对」,也要「扛得住、赔得起、查得到」。建议在架构评审材料中显式写出下列指标,并与监控看板一一映射:

  • 一致性:券与积分的状态迁移与订单支付状态单调一致;允许的最终一致边界写清楚(例如报表延迟分钟级)。
  • 幂等性:领取、冻结、核销、回滚接口全部带业务幂等键;消息消费以 event_id(biz_type,biz_id,action) 去重。
  • 可用性:试算路径可降级;写路径失败可补偿;热点活动具备独立熔断域,避免拖垮全站下单。
  • 可观测性:每一次试算输出 trace_id;每一次核销写审计流水;预算与库存类 Redis Key 有容量与过期策略。
  • 安全与合规:防刷、频控、隐私最小化;补贴与券的发放记录满足审计留存周期。

9.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. 读路径:试算以「低耦合聚合」为目标,尽量通过计价中心统一编排(见 9.6、9.7),营销服务提供「可用工具集合 + 规则解释」。
  2. 写路径:领取、冻结、核销、回滚必须可幂等、可补偿;与订单 Saga 步骤一一对应(见 9.7.3)。
  3. 观测路径:任何金额差异必须能定位到「规则版本 + 输入快照 + 引擎输出」。

典型技术选型(可按团队资产替换,但角色分工建议保留)

组件类型常见选型在营销系统中的职责
关系型数据库MySQL / PostgreSQL券批次、用户券、积分流水、活动配置、审计表;强一致事实源
缓存与计数Redis热点库存、用户领券次数、预算桶、分布式锁、滑动窗口限流
消息队列Kafka / Pulsar异步通知、对账、数据仓库同步、延迟核销补偿
搜索与分析Elasticsearch活动检索、运营圈人、券批次检索;与交易主路径解耦
流量治理Sentinel / Envoy / 自研网关热点接口限流、熔断、排队策略入口

容量与体验的经验区间(用于评审对齐,不是 SLA 承诺):日常试算 QPS 与购物车刷新强相关;大促峰值往往来自「领券 + 秒杀下单」叠加。实践中常把「试算」与「领券写路径」在网关层拆分集群,避免读放大拖慢写。秒杀接口的目标不是无限吞吐,而是失败要快、成功要稳:失败请求在边缘以毫秒级返回,成功请求进入受控队列,尾部延迟可接受。


9.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

9.2.1 优惠券系统

模型拆分

  • Coupon(券批次):描述面额/折扣、门槛、总库存、每用户限领、适用范围(全场/类目/SKU)、承担方(平台/商家)。
  • CouponUser(用户券实例):领取时间、过期时间、状态(未使用/冻结/已使用/作废)。
  • CouponLog(审计流水):谁在何时以何因做了何动作;是对账与客服判责依据。

关键实现约束

  1. 超发控制:热点券批次用 Redis 原子扣减「可领库存」,DB 落库作为最终事实;二者通过异步对账修正(见 9.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,
	})
}

9.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
}

9.2.3 活动系统

活动系统负责规则配置 + 圈品 + 生命周期治理。活动类型差异很大,但工程上可收敛为:

  1. 活动元数据:时间窗、状态机(草稿/待审/生效/结束/作废)。
  2. 参与单元:SKU 级活动价、店铺级满减、平台级跨店活动。
  3. 执行策略:由计算引擎解释 rule_config(JSON / DSL),活动服务自身避免堆叠 switch 地狱(与 9.3 联动)。

圈品(与商品中心集成详见 9.7.1):活动侧存 activity_product 映射或存规则表达式;运行时以 product_id/sku_id/category_id 多路判定,注意索引与缓存击穿。

活动运营与工程协作:活动系统往往是运营配置最高频的子系统。建议把配置错误分为三类分别治理:语法错误(JSON Schema 校验拒绝保存)、语义风险(例如折扣低于成本阈值触发风控审核)、容量风险(圈品过大导致试算超时,需异步预计算 + 结果缓存)。Engineering 侧提供「沙箱试算」与「灰度发布」能力,比单纯堆人审核更有效。

常见活动形态与工程关注点(节选):

活动形态业务目标工程关注点
满减满折提升客单价跨店分摊、尾差、与券叠加顺序
限时直降清库存 / 打爆款与基础价、渠道价冲突检测
秒杀抢购引流热点库存、风控、异步下单、超卖校准
买赠关联销售赠品行生成、赠品库存、履约拆单

9.2.4 补贴系统

补贴与「券/活动」不同之处在于:它往往不直接以用户可见凭证表达,而是以平台/商家承担比例进入清结算。典型场景:

  • 平台秒杀补贴:活动价低于供货价差额由平台承担。
  • 联合营销:商家出资 70%,平台出资 30%。
  • 支付立减:渠道补贴 + 平台补贴叠加(需风控与预算)。

数据落点:订单行级记录「营销成本分摊字段」;支付成功后由营销结算服务生成结算事实表,推送给财务/对账系统(与 9.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}
}

9.3 营销计算引擎

营销计算引擎是「把业务上含糊的便宜」翻译成「可执行、可分摊、可回滚」的工程模块。它输入购物车行、用户工具实例、活动集合、规则版本;输出每个 SKU 行的优惠拆分与订单级汇总。

为什么必须单独建设「引擎」而不是散落在各接口里? 因为营销规则的变化频率远高于交易主流程:运营每周都可能调整叠加策略、临时插入互斥组、或对某渠道单独放量。若把规则散落在购物车、结算、创单多个服务,最终一定出现「页面能买、结算不能买」或「结算能买、支付少减」的漂移。引擎化的核心价值是把规则解释收敛到单一模块,并把输入输出契约化,让其他系统以「黑盒服务」方式依赖它。

输入输出的工程契约(建议写进接口文档的第一页)

  • 输入必须可序列化快照化:不仅是商品 ID 列表,还应包含价格快照引用、店铺维度、会员等级、渠道、时区与活动版本。任何无法快照的输入都不应进入创单强一致路径。
  • 输出必须可分摊:除了订单级优惠总额,还要给出「行级拆分」与「税/运费处理建议字段」(若业务需要),否则财务与发票域会再次各自实现一套拆分。
  • 输出必须可回放trace 不是日志炫技,而是客服判责与线上排障的最低成本工具;建议以结构化 JSON 存储关键决策点(命中、未命中原因、互斥裁决)。

9.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
}

9.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
}

9.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 明确提示损失金额或不可用原因,减少客诉。

9.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 应可被订单原样持久化为「营销快照」子文档;创单重放时不得再次调用可能变化的试算逻辑去「修正」历史订单,除非走明确的改价流程(通常需要客服授权与审计)。


9.4 高并发场景设计

9.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. 库存拆分:商品库存与营销库存(见 9.5)分别扣减,避免「营销卖爆但仓库没货」或反向超卖。
  2. 令牌化:网关层发放有限令牌,后端只验证令牌,避免打穿 DB。
  3. 排队与等待:返回「排队中」优于同步拖垮线程池(取决于体验要求)。

抢券与秒杀的共性差异:抢券失败通常是「库存耗尽」;秒杀失败还可能是「商品库存不足但营销库存仍显示可买」这类双库存不一致。务必在架构层定义哪一个是用户可见的剩余量,以及异步校准任务的 SLA(例如 1 秒内把 DB 回灌到 Redis)。

9.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
		},
	})
}

9.4.3 防刷与风控

防刷是「业务风控 + 工程限流」的组合:设备指纹、代理 IP 聚类、异常领取节奏、黑名单、券码猜测防护。工程上务必:

  • 热点 Key 分片;避免单 Key 成为 Redis 热点。
  • 异步写审计,主链路只做最小校验。
  • 与风控系统通过评分结果而不是全量明细耦合,降低 RT。

黑产对抗的分层策略:第一层是「明显的工程滥用」(高频请求、批量注册、同设备多号),用限流与验证码解决;第二层是「业务规则套利」(拆单、凑单、退款薅券),需要规则与订单域联合治理;第三层是「支付侧套利」(拒付、chargeback),已超出营销系统边界,但必须把营销核销数据完整输出给风控与财务。

策略落地建议:不要把所有风控判断都改成同步 RPC。典型做法是:领券接口同步只做硬规则(黑名单、频控),复杂模型异步回扫;一旦发现异常,可下发「冻结券使用资格」事件,让用户在结算页看到需要人脸核验或客服介入。这样可以在不大幅增加主链路 RT 的前提下提升对抗能力。


9.5 营销库存系统

营销库存是活动参与配额,与商品可售库存解耦。秒杀中「500 件活动库存 + 10000 件商品库存」意味着两路都要成功才能成交。

营销库存 vs 商品库存(概念对齐表,避免团队各说各话):

维度商品库存营销库存
本质可售实物或履约能力活动参与名额 / 补贴预算的数字化表达
典型驱动采购、仓储、供应商可用量营销预算、活动目标、风控阈值
管理维度SKU、仓、批次活动、SKU、用户、时段
扣减含义少一件货少一次优惠资格或一分预算
失败体验缺货活动结束 / 已抢光 / 超出限购
一致性策略强一致预占 + 补偿常采用 Redis 原子脚本 + 异步校准

工程结论:下单链路里若同时存在两类库存,编排顺序必须写死(先营销后商品或相反)并配套一致的回滚顺序;任何「只扣一类」的实现都会在极端并发下出现难复现的幽灵订单。

9.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,便于运营实时看到「被锁住的成本」。

9.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
}

9.5.3 实时监控

核心指标:

  • 领取成功率 / 拒绝原因分布(库存不足 vs 风控 vs 限流)。
  • 冻结/核销/回滚计数与订单状态对齐曲线。
  • 预算消耗速率(每分钟消耗,预测耗尽时间)。
  • 引擎 RT 分位与规则版本维度下钻。

从指标到告警的落地方法:不要只对「错误率」告警,要对「结构变化」告警。例如:领取失败率不变,但「风控拒绝占比」突然上升,往往意味着活动被黑产盯上;又如:冻结成功但确认核销失败升高,通常是支付回调或订单状态机异常的前兆。营销监控看板建议固定三类视图:活动运营视图(转化、消耗、ROI)、稳定性视图(RT、限流、熔断)、资金风险视图(预算、异常大额订单、补贴分账失败队列)。


9.6 系统边界与职责

9.6.1 营销系统的职责边界

营销系统应负责:工具发放与状态机、活动配置与圈品、营销库存、补贴分摊事实生成、试算解释与审计日志、与订单冻结/回滚对应的资源操作。

营销系统不应负责:商品主数据、基础标价、支付渠道、物流、发票税务口径的唯一裁定。

边界不清的典型症状(出现任一条都值得开专项治理):

  • 订单表出现大量「手写促销字段」,营销服务却不知道这些字段如何产生。
  • 同一个满减规则在详情页、购物车、结算页算出三种金额。
  • 支付回调后才发现优惠无法分账,只能人工补单。
  • 风控拦截发券,但试算仍展示可用,用户完成下单后失败。

9.6.2 营销 vs 计价:谁算什么

这是最容易跨团队扯皮的边界。推荐清晰分工

计算内容建议负责方说明
商品基础价、渠道价、会员价计价中心(或商品+计价组合域)作为「价格事实」与快照源头
券/活动/积分是否可用与优惠额营销计算引擎输出结构化应用明细
购物车/结算页统一应付计价中心编排调用营销用户看到单一「应付」
创单价格快照订单域落库 + 引用计价/营销版本售后按快照解释

反模式:订单服务里手写一段「满 100 减 20」与营销服务另一套重复逻辑——必然漂移。

推荐协作模式(一句话):计价中心负责「把钱算清楚并快照」,营销系统负责「把规则讲清楚并证明合规」。当两者接口契约稳定后,前端与订单域都应对营销细节保持「无知」,只消费结构化结果。

9.6.3 平台营销 vs 商家营销

平台券与商家券在成本承担、审核、叠加策略、结算上不同;系统上建议「券实例维度绑定承担方」,订单行维度记录分摊,支付后生成清算明细。

9.6.4 营销规则 vs 营销执行

规则:可配置、可版本、可读多。执行:冻结、扣减、回滚、消息投递,必须可幂等与可补偿。不要在规则脚本里直接写数据库副作用;执行器与规则解释器分离(9.3.1 的分层)。


9.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只读短超时 + 部分失败降级

9.7.1 与商品中心集成(圈品规则)

商品中心提供类目、标签、上下架状态;营销读取时应缓存 + 兜底超时降级(降级策略需业务拍板:宁可不可用券,不可错误可用券)。

失败模式:商品中心超时 → 试算无法判断圈品 → 建议默认「该活动对此 SKU 不适用」而不是「适用」,避免错误让利。对于已加购用户,可提示稍后重试或刷新。

9.7.2 与用户系统集成(画像与风控)

画像用于定向投放;注意隐私合规与最小必要原则。风控评分作为硬门槛时,应有明确失败原因码供前端展示(避免「神秘失败」)。

失败模式:画像服务延迟 → 定向券领取接口可异步化处理(先返回受理中),但创单路径若依赖画像,必须设置硬超时并走保守策略(按非定向规则校验)。

9.7.3 与订单系统集成(锁定 / 扣减 / 回退)

订单创建立即涉及「资源锁定」;营销侧需提供 Try/Confirm/Cancel 语义或等价 Saga 接口:freeze_couponconfirm_couponrollback_coupon,积分同理。

失败模式:订单 Try 成功但网络超时导致订单重试 → 营销接口必须幂等,重复 Try 不得重复扣减。Confirm 晚到必须先识别「已确认 / 已回滚」状态,避免二次核销。

9.7.4 与计价系统集成(试算接口)

计价中心调用营销预览接口时,传入价格快照而不是实时价字符串,避免时间差;返回应用明细后由计价做最终取整与应付。

失败模式:营销试算成功但创单延迟十分钟 → 必须以快照版本为准;若规则版本在此期间变更,创单应拒绝或提示用户重新结算,而不能静默改价。

9.7.5 与支付系统集成(补贴分账)

支付成功事件触发:营销确认核销、生成补贴分账数据、推送给清算系统。必须处理重复回调幂等

失败模式:支付回调重复 → Confirm 幂等;支付成功但营销 Confirm 失败 → 必须有补偿任务把订单推到一致状态,并阻断发货或数字履约直到营销侧确认完成(视业务风险阈值而定)。

9.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;超过阈值人工介入。对账任务按日核对营销核销与订单快照。


9.8 工程实践

9.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
}

9.8.2 数据一致性

主路径用 Saga;异步用 Outbox 发 Kafka;消费者幂等键用 event_id 或业务联合键;日终对账修数据。

对账维度清单(营销侧最小集)

对账项对比双方发现差异后的处理
券核销营销核销流水 vs 订单快照以订单快照为准回补或冲正
积分变动积分流水 vs 订单支付事件重放补偿任务
活动消耗Redis 计数 vs DB 汇总以 DB 为准回灌或人工修正
补贴分账订单行分摊 vs 支付清算单冻结差异单,财务介入

9.8.3 成本控制

预算桶、单用户上限、异常消耗报警、活动 ROI 看板;技术上防止「无限重试放大写压力」。

成本与体验平衡:预算耗尽应「快速失败」而不是「排队重试吞吞吐」。对于平台补贴型活动,建议设置分钟级消耗速率告警:一旦斜率异常(脚本薅羊毛),可自动触发熔断与黑名单联动。


9.9 本章小结

本章从工具体系(券、积分、活动、补贴)出发,拆解了营销系统的职责边界与架构分层;深入讲解了营销计算引擎的规则分层、叠加互斥与最优求解;针对秒杀抢券给出了高并发架构与限流降级策略;阐述了营销库存与预算独立于商品库存的原因与原子扣减模式;重点厘清了营销 vs 计价的算价边界,并通过集成时序图说明订单、计价、营销、库存、支付在全链路的 Try/Confirm 与补偿关系。

落地检查清单

  1. 试算与创单是否引用同一 pricing_snapshot_idmarketing_rule_version
  2. 券/积分状态机是否覆盖冻结、过期、回滚全路径?
  3. 秒杀链路是否分离商品库存与营销库存,并有异步校准?
  4. 补贴分账是否能在财务对账中还原到订单行?

与全书其他章节的阅读顺序建议:若你正在实现交易链路,建议将本章与第 12 章(计价系统)、第 14 章(购物车与结算)、第 15 章(订单系统)交叉阅读:把「试算 → 锁定 → 支付确认 → 清算」同一条时间轴画在白板上,再把每个系统的接口填进去,你会很快发现团队里哪些职责被重复实现、哪些补偿路径尚未覆盖。


延伸阅读:本书第 4 章(一致性)、第 12 章(计价系统)、第 15 章(订单系统)、第 16 章(支付系统);博客原文《电商系统设计(四):营销系统深度解析》可作为附录级细节与 SQL/Lua 参考。

导航书籍主页 | 完整目录 | 上一章:第10章 | 下一章:第12章


第11章 商品供给管理:运营、库存与生命周期

本章定位:承接第 8 章「商品中心」的 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

图源文件:

  • ecommerce-book/images/product-create-publish-swimlane.svg
  • ecommerce-book/images/product-lifecycle-state-machine.svg
  • ecommerce-book/images/product-supply-data-flow.svg

11.1 系统定位与边界

11.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

11.1.2 五类供给入口

商品供给来源通常有五类:

入口典型场景入口特点执行方式
本地运营创建平台运营创建本地生活券、礼品卡、充值套餐、账单缴费入口低量、强交互、可信操作源同步体验 + 自动准入 + 发布治理
商家上传商家自助上传门店、套餐、服务商品、素材外部操作源,质量不稳定同步提交 + 默认 QC
批量导入大促前批量创建商品、门店、套餐、价格计划、券码池大量、行级失败、需要错误文件异步任务
运营编辑修改标题、图片、类目、价格、库存、上下架、退款规则基于线上版本变更,风险差异大同步提交 + 审核/发布
供应商同步酒店、影院、票务、活动等外部数据全量/增量/Push/刷新长任务、外部不稳定、需要断点续跑专项同步链路

这五类入口不能完全拆成五套系统。更合理的设计是:

入口层分开
  → 执行策略分开
  → 标准化后进入统一 Staging
  → 统一 Validation / Diff / Review / Publish / Outbox

11.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

一句话总结:

供应商同步执行层独立,商品发布治理层复用。


11.2 核心难点与设计策略:从供给治理到可售闭环

商品供给管理真正难的不是“建几张商品表”,而是把不同入口、不同状态、不同事实源和不同交易风险收敛成一条可治理、可回放、可补偿的供给链路。

核心矛盾典型表现设计策略
多入口人工创建、批量导入、运营编辑、库存创建 / 修改、供应商同步都会改变供给能力入口分开,标准化后统一进入 Supply Task、Staging、Validation、Publish
多状态Draft、Staging、QC、正式商品、库存任务、Outbox 都有自己的状态谁拥有生命周期,谁拥有状态字段,避免一个 status 表达所有语义
多事实源商品、库存、计价、营销、搜索、订单都关心商品变化,但事实归属不同供给平台做控制面,事实数据留在各自系统,通过命令和事件协作
多交易风险缺图、缺价、无库存、无履约规则、活动配置失败都会导致不可售发布版本、交易契约、库存任务、营销协同和可售投影分层推进
多失败形态导入失败、审核失败、发布失败、下游投影失败、库存创建失败DLQ、错误文件、补偿任务和运营看板把失败运营化

这一章后续所有设计都围绕六个目标展开:

  1. 入口统一:所有供给动作都有任务、来源、操作者和 TraceID。
  2. 线上隔离:草稿、导入中数据、未审核变更不进入正式表。
  3. 质量可控:标准化、类目模板、主数据校验、交易契约校验和风险规则形成发布门禁。
  4. 状态分离:发布、上线、可售、库存 ready、营销 ready 不能混成一个状态。
  5. 最终一致:正式表、快照、Outbox 同事务,读侧投影和营销协同异步完成。
  6. 失败可运营:任务、Item、DLQ、错误文件、补偿任务和可售诊断形成闭环。

11.2.1 供给链路的核心矛盾:多入口、多状态、多事实源

商品供给平台看上去像一个后台,但它本质上是供给控制面。控制面不直接承诺“库存一定够”“价格一定正确”“活动一定可用”“订单一定能履约”,它承诺的是:任何供给变更都必须有入口、有证据、有校验、有发布版本、有审计和可补偿路径。

一个商品从进入平台到被用户购买,至少会经过三类对象:

对象类型例子设计重点
流程对象Draft、Task、Task Item、Staging、QC Review记录供给变更如何被提交、校验、审核和发布
正式对象Resource、SPU、SKU、Offer、Rate Plan、交易前契约支撑 C 端查询、交易校验和订单快照
派生对象搜索索引、商品缓存、计价上下文、营销资格、可售投影面向读性能、导购体验和交易前判断,可以异步重建

如果把这三类对象混在一张宽表里,短期会觉得简单,长期一定会遇到几个问题:未审核数据污染线上,供应商同步覆盖人工修复,库存补货绕过账本,搜索索引和商品版本对不上,历史订单无法解释当时为什么能买、为什么这个价。

11.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 补偿重放

11.2.3 供给控制面与事实数据面的边界

供给平台负责让变更安全进入平台,但不能替代各个事实系统。边界可以这样理解:

系统在供给链路里的角色权威事实
供给运营平台入口、任务、暂存、校验、审核、发布编排、补偿和审计供给流程事实
商品中心正式商品主数据、交易前契约、发布版本和快照商品定义事实
库存系统库存实例、券码池、预占、扣减、释放和账本库存事实
计价系统基础价、渠道价、优惠叠加、试算和结算价价格事实
营销系统活动、券、补贴、预算、营销库存和优惠规则营销事实
搜索系统可检索投影、召回、排序和索引版本搜索读模型
订单系统商品快照、报价快照、履约和退款契约快照订单交易事实

这里的关键不是“供给平台能不能调用别的系统”,而是“调用表达什么语义”。供给平台可以发起 CreateInventoryBindProductToCampaignPublishProductVersion 这类业务命令;但不能直接更新库存余额、直接写 ES、直接写最终成交价,也不能修改订单状态。

11.2.4 库存创建 / 修改的运营归属

库存创建和修改属于供给运营平台的业务工作,但不属于供给运营平台的数据事实。原因很简单:库存动作往往带有强运营属性。

场景为什么需要供给运营平台承接
简单数量库存随商品发布创建需要和商品类目、Offer、销售范围、扣减时机一起校验
后台补货 / 调库存 / 锁库存需要权限、审批、操作原因、风险提示和审计
手动上传券码需要文件上传、行级错误、重复码提示、错误文件和任务进度
系统生成券码需要生码规则、数量、有效期、审批和批次追踪
门店 / 日期 / 时段库存需要门店范围、营业时间、节假日、批量复制和局部调整
批量编辑库存需要异步任务、部分成功、失败重试和运营可见进度

所以更准确的说法是:

供给运营平台:负责库存任务的入口、审批、编排、进度、错误文件和审计
库存系统:负责库存实例、余额、券码状态机、预占、扣减、释放和账本

库存任务会在 11.6 单独展开。这里先建立一个原则:供给平台发起库存命令,库存系统幂等执行库存事实变更。

11.2.5 发布后的最终一致与可售投影

供给发布事务内只做商品中心必须强一致的事情:写正式商品主数据、交易前契约、发布版本、发布快照、变更日志和 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. 历史订单回读最新商品配置,导致售后和财务无法复盘。

11.3 商品生命周期管理

11.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

11.3.2 四套核心状态机

11.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。

11.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 已过期

11.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 要严格区分:前者代表内容不合规,后者代表审核单不应该继续处理,例如重复单、任务取消、版本冲突或审核路由错误。

11.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 才递增。

11.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,并要求基于最新版本重新编辑。

11.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 或事件版本防止旧事件覆盖新状态。

11.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 这些供给流程状态。

11.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 和后续映射

11.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,而应该走“撤回后编辑”的流程。

11.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到指定时间自动发布大促、预售、定时上新

11.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

11.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

11.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 中的待审或驳回版本。

11.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通常不可只保留历史和审计

11.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

这样线上用户继续看到稳定版本,商家也能看到自己提交中的新版本。

11.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;下线只改变正式商品可售状态,不删除历史版本和订单快照。

11.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 端可搜、可买、可履约、可售后。


11.4 供给入口与执行方式

11.4.1 同步与异步的取舍

供给平台不能所有动作都异步,也不能所有动作都同步。

场景推荐方式原因
单商品草稿保存同步运营需要立即看到保存结果
单商品提交校验同步为主基础错误要即时反馈
单商品发布可同步也可异步简单品类可同步,复杂品类进入发布任务
批量导入商品异步文件解析、行级错误、部分成功、错误文件
批量编辑价格 / 上下架异步风险高、影响面大,需要进度和审核
供应商全量同步异步长任务,需要 checkpoint、lease、DLQ
供应商 Push 单条变更异步优先需要幂等、削峰、失败补偿

统一抽象:

product_supply_task.execution_mode = SYNC / ASYNC

单商品创建也可以生成 product_supply_task(total_count=1),这样审计、审核、发布记录统一。

11.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. 快速回滚。

11.4.3 人工创建

人工创建是“从 0 到 1”生成商品,核心是完整性。这里要区分本地运营创建和商家自助创建:本地运营创建默认自动准入,商家创建默认进入 QC。

选择类目
  → 加载类目模板
  → 填写 Resource / SPU / SKU / Offer / Rule
  → 前端实时校验
  → 后端同步强校验
  → 保存 Draft
  → 提交生成 Staging
  → 质量校验和风险判断
  → 来源准入策略:LOCAL_OPS 自动准入,MERCHANT 进入 QC
  → 发布正式表

人工创建必须一次性收齐交易前契约:

契约示例
商品模型Resource、SPU、SKU、Offer、Rate Plan
库存契约库存来源、券码池、供应商实时库存能力
输入契约手机号、账单号、入住人、乘客证件
履约契约充值、发券、出票、预订确认
售后契约退款规则、取消政策、过期处理

如果这些契约不完整,商品即使写入主表,也不能认为创建成功。

11.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. 可控制下游压力。

11.4.5 运营编辑

运营编辑是“基于线上版本的变更”,核心是 Diff、风险和主导权。

读取 current_publish_version
  → 创建编辑 Draft
  → 修改字段
  → 提交生成 Staging
  → 与线上版本做 Diff
  → 判断字段主导权
  → 计算风险等级
  → 来源准入策略 / QC 审核 / 阻断
  → 发布新 publish_version

常见风险:

变更风险策略
标题、描述、小图修正自动准入,记录变更日志
普通图片变更低/中图片质量校验后发布
库存水位调整自动校验,通过后发布,异常告警
价格或 Offer 规则变更中高超阈值进入 QC
类目变更强制 QC
履约类型或退款规则变更强制 QC
Resource / Supplier Mapping 变更强制 QC 并触发巡检

11.4.6 供应商同步

供应商同步是自动化程度最高、数据治理要求最强的入口。

它需要独立执行层:

supplier_sync_task
  → supplier_sync_batch
  → Page / Cursor Fetch
  → Raw Snapshot
  → Normalize
  → Supplier Mapping
  → Diff
  → product_supply_staging
  → Publish

供应商同步不应该直接写正式商品表。它应该先保存 Raw Snapshot,再标准化、校验、映射、Diff,然后进入统一发布治理链路。


11.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

11.5.1 表分组

表组典型表作用
Draft 草稿表product_supply_draftproduct_supply_draft_version保存单商品创建和编辑过程中的草稿
Task 任务表product_supply_task记录一次供给动作
File 文件表product_supply_file保存批量导入源文件、规范化文件、错误文件和文件 hash
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_file
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

11.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='商品供给草稿';

11.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,
    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_trace (supply_trace_id),
    KEY idx_status (status),
    KEY idx_category_status (category_code, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给任务';

11.5.4 File 文件表

批量导入不建议只在 Task 表里放一个 input_file_ref。源文件、规范化文件、错误文件、文件 hash、扫描状态都需要独立记录,方便幂等、审计、错误文件下载和重新解析。

CREATE TABLE product_supply_file (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    file_id VARCHAR(64) NOT NULL,
    task_id VARCHAR(64) NOT NULL,
    file_type VARCHAR(32) NOT NULL COMMENT 'INPUT/NORMALIZED/ERROR/REPORT',
    file_name VARCHAR(256) DEFAULT NULL,
    file_ref VARCHAR(512) NOT NULL,
    file_hash VARCHAR(64) NOT NULL,
    file_size BIGINT DEFAULT NULL,
    template_version VARCHAR(64) DEFAULT NULL,
    row_count INT DEFAULT NULL,
    status VARCHAR(32) NOT NULL
        COMMENT 'UPLOADED/SCANNING/READY/PARSING/PARSED/FAILED/EXPIRED',
    error_code VARCHAR(128) DEFAULT NULL,
    error_message VARCHAR(1024) DEFAULT NULL,
    uploader_id VARCHAR(64) DEFAULT NULL,
    created_at DATETIME NOT NULL,
    parsed_at DATETIME DEFAULT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_file_id (file_id),
    UNIQUE KEY uk_task_file_type (task_id, file_type),
    KEY idx_task (task_id),
    KEY idx_hash (file_hash),
    KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品供给文件';

如果一个任务支持多个附件,可以把 uk_task_file_type 改成 (task_id, file_type, file_id),并增加 file_seq

11.5.5 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='商品供给任务明细';

11.5.6 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 很重要。运营编辑或批量导入可能基于旧版本,如果发布时线上版本已经变化,必须识别冲突,不能静默覆盖。

11.5.7 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、系统或任务主动终止,不应计入驳回率,而应单独看撤销率和撤销原因分布。

11.5.8 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='商品供给校验结果';

11.5.9 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='商品供给变更请求';

11.5.10 发布记录与发布快照表

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='商品发布快照';

11.5.11 商品变更日志表

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='商品正式发布变更日志';

11.5.12 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 事件';

11.5.13 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='商品供给死信问题单';

11.5.14 对象映射表

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='供给对象与正式商品映射';

11.5.15 操作流水表

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='商品供给操作流水';

11.5.16 字段主导权表

字段主导权用于解决供应商同步和人工运营编辑互相覆盖的问题。没有这张表,供应商同步很容易把运营刚修复的标题、坐标、退款规则覆盖掉。

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='商品字段主导权';

11.5.17 补偿任务表

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='商品供给补偿任务';

11.5.18 质量问题表

质量巡检发现的问题要变成可分派、可跟进、可统计的问题单。它和 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='商品质量问题单';

11.5.19 表模型 review 结论

这一组表的边界可以这样记:

一句话定位
product_supply_draft可编辑工作区
product_supply_task一次供给动作的执行批次
product_supply_file批量导入和错误文件元数据
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,仍然属于商品中心和交易前契约,不属于供给任务表。供给平台只通过发布事务写入它们。


11.6 库存运营任务:从库存创建、补货到券码池管理

库存系统负责库存事实,但库存任务的运营入口应该在供给平台。原因是库存创建、补货、券码导入、系统生码、门店日期库存调整,往往不是一个纯技术接口调用,而是一组带有类目约束、审批、批量进度、错误文件、风险提示和审计要求的运营工作。

本节讨论的是 库存任务如何被供给平台发起和治理,不是库存系统内部如何扣减。库存系统内部的余额、预占、扣减、释放、券码状态机和账本,仍然属于第 9 章库存系统。

11.6.1 为什么库存任务属于供给运营平台

有些库存很简单,商品发布时顺手创建一行数量库存即可;有些库存很复杂,需要运营手动上传券码、系统生成券码、按门店和日期批量物化库存切片,还要支持后续补货、锁库存、局部调整和错误修复。如果这些入口散落在库存后台、商品后台、商家后台和供应商同步任务里,后续一定会出现三类问题:

  1. 入口不可控:同一个 SKU 的库存可以从多个后台修改,谁改的、为什么改、基于哪个商品版本改,很难追踪。
  2. 风险不可审:大额补货、批量锁库存、券码导入失败、活动前临时调库存,没有统一的风险评分和审批。
  3. 失败不可运营:文件第几行错了、哪批券码重复、哪些门店日期库存创建失败,如果只在库存系统日志里,运营无法闭环。

因此更合理的分工是:

供给运营平台:
  负责库存任务的入口、表单、审批、任务编排、进度、错误文件、补偿和审计

库存系统:
  负责库存配置、余额、券码池、预占、扣减、释放、账本和对账

这个边界和商品发布一致:供给平台不直接改正式商品表,也不直接改库存余额。它发起表达业务意图的命令,由库存系统幂等执行。

11.6.2 库存任务的通用模型

库存运营任务可以复用供给平台的 Task / Task Item / File / DLQ 模型,但要有清晰的 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

11.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 = ?;

11.6.4 数量制库存:后台补货、调库存与锁库存

商品上线后,运营仍然会做补货、盘点调整、锁库存和解锁。它们都属于库存任务,但业务语义不同,不能都叫“改库存”。

操作业务语义典型风险库存系统动作
补货增加可售供给补错 SKU、补错门店、活动前超卖写入库流水,增加可用或总量
盘点调整修正账实差异人工改错导致账本不可解释写调整流水,保留原因和审批单
锁库存暂停某部分库存继续售卖误锁导致大面积售罄增加锁定量或锁定范围
解锁库存恢复被锁库存可售解锁已售或异常库存校验状态后释放锁定
活动预留给营销活动预留一部分库存与普通售卖池冲突生成独立 reservation 或 lock reason

补货任务建议至少经过:

创建补货草稿
  → 选择商品 / SKU / 门店 / 日期范围
  → 输入补货数量和原因
  → 风险校验:数量阈值、活动中商品、历史投诉、供应商主导权
  → 自动通过 / 人工审批
  → 发 AdjustInventory 命令
  → 库存系统 CAS 执行并写 inventory_ledger
  → 返回 InventoryChanged

锁库存任务要更加谨慎。它经常来自风控、质检、履约异常、供应商停供或门店停业。锁定后不能删除库存行,也不能影响历史订单和已预占记录;它只应该阻止新的 Reserve。

11.6.5 券码制库存:手动上传券码批次

券码制库存不能只用一个数量字段表达。每个券码都是一份可交付资源,必须一码一行、有状态机、有加密存储、有去重能力、有订单关联和审计记录。

手动上传券码推荐链路:

上传券码文件
  → 写 product_supply_file(file_type=CODE_IMPORT)
  → 创建 CODE_IMPORT task
  → 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 码不要因为退款直接回到可售池,退款要走售后和履约规则。

11.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
  → ...

这样可以支持进度展示、部分失败重试和批次级对账。

11.6.7 门店 / 日期 / 时段库存:批量物化与局部调整

本地生活、预约、票务、酒店、活动类商品经常不是一行全局库存,而是按门店、日期、时段、房型、场次等维度切片。供给平台要提供适合运营使用的批量工具,库存系统负责物化和扣减。

典型库存范围:

GLOBAL
STORE
CITY
DATE
STORE_DATE
STORE_DATE_TIME_SLOT
SUPPLIER_SKU
CHANNEL

门店日期库存任务要支持三种创建方式:

创建方式适用场景设计要点
提前物化热门门店、热门日期、活动库存发布后批量创建未来 N 天切片
懒创建长尾门店、低频日期首次查询或首次编辑时创建库存行
模板复制多门店同规则、多日期同库存从营业模板、节假日模板或门店分组复制

例如运营要给 100 家门店创建未来 30 天、每天 6 个时段的库存,任务会展开成 18000 个库存切片。这个操作不能同步阻塞在页面提交里,必须任务化、分批执行、展示进度、支持部分失败。

局部调整也很重要。例如某门店临时停业,只应该锁定该门店未来几天的库存切片,不应该下架整个商品;某个时段履约能力不足,也只应该调整这个时段的可售能力。

11.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

11.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 通常需要运营重新基于最新库存状态编辑。

11.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。库存系统写账本时也要保存这些字段,后续才能从一条库存流水反查到对应的供给任务、审批单和发布版本。

11.6.11 库存任务的审计、补偿与可售诊断

库存任务完成后,运营后台不能只显示“成功 / 失败”。更好的展示是:

任务状态:PARTIAL_FAILED
总行数:10000
成功:9850
失败:120
跳过:30
库存系统命令成功率:98.5%
错误文件:可下载
影响商品:128 个
影响门店:46 个
可售投影:等待 12 个商品刷新

补偿要区分三类:

失败类型示例处理方式
供给侧失败文件格式错误、字段缺失、门店不存在生成错误文件,运营修复后重新提交
库存侧失败库存系统超时、CAS 冲突、重复码自动重试、DLQ、人工确认
投影侧失败可售投影刷新失败、缓存未失效、搜索状态落后Outbox 重放、补偿任务重建

最终运营要能看到一条完整链路:

谁在什么时间
基于哪个商品版本
因为什么原因
创建了哪个库存任务
影响了哪些库存实例或券码批次
库存系统是否执行成功
可售投影是否刷新成功
如果失败,下一步该谁处理

这就是把库存运营任务放在供给平台里的价值:库存事实不被供给平台篡改,但库存变更过程被供给平台治理起来。


11.7 单商品创建和编辑链路

11.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"
    }
  ]
}

11.7.2 单商品编辑

编辑比创建更复杂,因为它基于线上版本修改。

打开商品详情
  → 读取 current_publish_version
  → 创建编辑 Draft
  → 修改字段
  → 保存草稿
  → 提交编辑
  → 同步校验
  → 生成 Staging
  → 与 current_publish_version 做 Diff
  → 判断字段主导权
  → 计算风险等级
  → 自动准入 / 创建 QC 审核单 / 阻断
  → 发布新 publish_version

单商品编辑必须具备三种能力:

能力说明
版本锁编辑基于 base_publish_version,发布时如果线上版本已变化,要提示冲突
字段 Diff审核员看到字段级变化,而不是整段 JSON
字段主导权判断运营编辑是否可以覆盖供应商同步字段

编辑示例:

操作策略
改标题低风险,自动准入
改主图图片质量校验,通过后自动准入或进入 QC
改价格超过阈值进入 QC
改退款规则高风险,强制 QC
改供应商映射高风险,强制 QC 并触发巡检

11.7.3 Draft 与 Staging 的区别

作用是否影响线上
Draft编辑中的草稿,允许反复保存
Staging提交后的待发布快照,进入校验、审核、发布
正式表C 端、搜索、订单真正读取的数据
Draft
  → 用户可反复修改

Staging
  → 系统校验、审核、发布使用

正式表
  → 只有发布事务能写入

11.8 批量导入和批量编辑链路

批量链路要按“任务 + 行级明细 + 暂存快照 + 错误文件 + 补偿”设计,不能把整个 Excel 读进内存后循环写正式表。

11.8.1 异步执行总流程

上传文件 / 批量提交
  → 写 product_supply_file(file_type=INPUT)
  → 创建 product_supply_task(status=PENDING, execution_mode=ASYNC)
  → Parser Worker 抢占任务并流式解析文件
  → 批量写入 product_supply_task_item
  → Item Worker 分批处理 item
  → 标准化 / 校验 / Staging / Diff
  → 低风险自动准入,高风险生成 QC item
  → QC 通过项进入发布,驳回项进入错误文件或 DLQ
  → Publish Worker 发布正式表并写 Outbox
  → 生成错误文件 / DLQ / 质量报告

这个拆法有三个好处:

  1. 解析失败不会污染正式商品表。
  2. 行级失败不会拖垮整批任务。
  3. 发布和下游刷新可以限速、重试和补偿。

11.8.2 Parser Worker

Parser Worker 只负责解析,不负责发布。

1. CAS 抢占 product_supply_task
2. 读取 product_supply_file,校验 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)

11.8.3 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
  → 按类目模板标准化
  → 写 normalized_ref
  → 执行 Schema / 主数据 / 商品模型 / 交易契约校验
  → 校验通过后写 product_supply_staging
  → 与线上 publish_version 做 Diff
  → 生成 product_change_request
  → 根据 risk_level 自动准入或创建 product_qc_review_item
  → 更新 item.status

不要用一个大事务包住 500 行。否则一行失败会拖垮整批,也会造成长事务和锁等待。

11.8.4 Item 状态机

PENDING
  → NORMALIZING
  → VALIDATING
  → STAGING
  → DIFFING
  → QC_PENDING
  → QC_REVIEWING
  → QC_APPROVED
  → PUBLISHING
  → SUCCESS

失败分支:
NORMALIZING / VALIDATING / STAGING / DIFFING / QC_REVIEWING / PUBLISHING
  → FAILED / DLQ / SKIPPED

Task 状态从 item 聚合:

Item 汇总结果Task 状态
全部 SUCCESSPUBLISHED
部分 SUCCESS,部分 FAILED/DLQPARTIAL_FAILED
全部失败FAILED
存在 QC_PENDING/QC_REVIEWINGQC_REVIEWING
存在 PUBLISHINGPUBLISHING

11.8.5 错误文件

错误文件要能指导运营修复,而不是只写“导入失败”。

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_file(file_type=ERROR),这样运营后台可以通过 task 直接找到源文件、规范化文件和错误文件。


11.9 供应商商品同步链路

11.9.1 为什么单独设计

供应商同步和批量导入有相似之处:都是外部或非正式数据进入平台商品模型,都需要任务、明细、标准化、校验、Diff、发布和补偿。

但供应商同步还有额外复杂度:

维度批量导入 / 批量编辑供应商同步
来源运营、商家、内部系统外部供应商
触发方式人工上传、运营操作定时、全量、增量、Push、刷新
数据形态Excel、CSV、表单API、消息、分页、游标
失败原因格式错误、字段缺失、人工误操作超时、限流、5xx、游标失效、字段漂移
恢复重点错误文件、失败行重提Checkpoint、Worker Lease、Raw Snapshot、DLQ
新鲜度通常不是秒级很多品类强依赖新鲜度
交易前确认多数依赖平台配置Hotel / Flight / Movie 必须实时确认

11.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

11.9.3 新鲜度分层

不同数据的刷新策略不同。

数据类型示例新鲜度要求策略
静态资源酒店名称、地址、设施、机场、车站小时级或天级全量 + 增量同步
半动态数据酒店最低价、可售状态、热门库存水位分钟级定时刷新 + 热门加频
强动态数据机票报价、座位图、下单前房态房价秒级或实时搜索缓存,详情刷新,下单实时确认
交易契约退款规则、履约参数、供应商映射强一致倾向发布版本控制,不随意覆盖

原则是:

列表页可以快,详情页要准,创单必须安全。


11.10 标准化、质量校验与风险审核

11.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

这对字段主导权、供应商覆盖、事故追溯非常重要。

11.10.2 质量校验

质量校验不能只做字段必填。

校验层校验内容失败处理
Schema 校验类型、必填、枚举、长度、格式行级失败
类目模板校验类目要求的对象和字段是否完整阻断提交
主数据校验城市、商户、品牌、Resource 是否存在进入人工映射
商品模型校验SPU、SKU、Offer、Rate Plan 关系是否成立阻断发布
交易契约校验库存来源、Input Schema、履约规则、退款规则阻断发布
可售校验商品状态、库存、价格、渠道、站点是否允许售卖阻断上线或告警
风险校验价格、类目、履约、退款、映射是否高风险自动准入、进入 QC 或阻断

11.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、风险原因、历史版本、供应商原始数据或运营输入证据。

11.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 或质量问题单,由运营修复后重新提交。


11.11 发布一致性设计

QC 通过或自动准入不代表商品已经可售。发布要保证商品主数据、资源映射、交易契约、库存可售、搜索缓存和下游系统最终一致。

11.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 必须拒绝发布。这样可以防止绕过审核接口直接调用发布接口。

11.11.2 发布版本和快照

每次发布生成 publish_version

product_id = 10001
old_publish_version = 21
new_publish_version = 22

发布快照用于:

  1. 订单创单保存商品上下文。
  2. 事故回滚。
  3. 对账和排查。
  4. 审核复盘。
  5. 搜索索引一致性校验。

订单系统不能回读最新商品解释历史订单,必须保存:

商品快照
报价快照
履约契约快照
退款规则快照
供应商映射快照

11.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 补偿。


11.12 运营管理能力

11.12.1 字段主导权

字段主导权解决的是“供应商同步和人工运营谁覆盖谁”。

字段主导方供应商同步能否覆盖运营策略
标题、卖点、活动标签平台运营运营编辑为准
酒店名称、地址、设施供应商/平台治理低风险可覆盖,高风险审核可人工修正并设置保护期
展示图片平台运营/供应商取决于来源质量图片变更需要质量校验
基础价、Rate Plan供应商/计价取决于品类超阈值审核
库存水位、可售状态库存域/供应商人工覆盖必须有有效期
退款规则、履约规则平台/供应商契约高风险覆盖强制 QC
类目、Resource 映射平台治理强制 QC 和数据巡检

当运营覆盖供应商字段时,建议记录:

field_path
owner_type
override_until
override_reason
operator_id

供应商同步遇到保护字段时,不自动覆盖,只记录 Diff 和冲突日志。

11.12.2 权限与审计

权限要拆成两层:

  1. 功能权限:是否能创建、编辑、导入、审核、发布。
  2. 数据范围权限:能操作哪些类目、商家、供应商、站点、渠道。

审计日志至少记录:

who
when
what
before
after
reason
trace_id
task_id
publish_version

高风险操作必须强制备注,例如:

  1. 批量改价。
  2. 类目迁移。
  3. 退款规则变更。
  4. 供应商映射变更。
  5. 热门商品下架。

11.12.3 回滚与灰度

回滚不是简单把字段改回去。需要区分:

回滚对象处理
商品主数据回滚到指定 publish_version
搜索索引根据快照重建索引
缓存失效或刷新旧版本
营销圈品重新向营销系统提交活动配置命令,或重新投递商品营销资格事件
订单不回滚历史订单快照

灰度发布可以按:

  1. 站点。
  2. 渠道。
  3. 城市。
  4. 白名单用户。
  5. 商品热度。

11.13 DLQ、补偿与质量巡检

11.13.1 失败分类

失败类型示例处理
输入失败Excel 字段非法、必填缺失生成错误文件,运营修复后重新提交
映射失败城市、商户、品牌、Resource 找不到进入人工映射队列
审核失败高风险变更被驳回回到草稿,保留驳回原因
发布失败DB 冲突、版本过期重试或要求基于最新版本重新编辑
下游失败ES 刷新失败、缓存失效失败Outbox 补偿
质量失败缺图、缺价、无库存、不可履约质量巡检下架或告警

11.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

11.13.3 质量巡检

质量巡检要覆盖:

  1. 缺图商品。
  2. 缺价商品。
  3. 无库存商品。
  4. 无履约规则商品。
  5. 退款规则缺失商品。
  6. 供应商映射缺失商品。
  7. 发布版本与搜索索引不一致。
  8. 运营覆盖字段过期。

质量指标:

商品质量缺陷率 = 缺陷商品数 / 在线商品数
发布失败率 = 发布失败任务 / 发布任务
索引刷新成功率 = ES 刷新成功 / 总刷新
DLQ 修复率 = RESOLVED DLQ / TOTAL DLQ

11.14 可观测性与稳定性

11.14.1 任务看板

运营后台至少要能看到:

任务进度:总数、成功、失败、跳过、当前阶段
失败原因:错误码、错误字段、建议修复方式、错误文件
审核队列:风险等级、命中规则、Diff、责任人
发布结果:publish_version、Outbox 状态、索引/缓存刷新状态
质量看板:缺图、缺价、无库存、无履约规则、映射缺失

核心指标:

指标说明
任务成功率成功任务 / 总任务
行级成功率成功 item / 总 item
任务完成耗时从创建到发布完成
自动准入占比自动准入 / 总提交
QC 驳回率驳回 / QC 提交
发布失败率发布失败 / 发布任务
索引刷新成功率ES 刷新成功 / 总刷新
商品质量缺陷率缺图、缺价、无库存、映射缺失商品占比

11.14.2 隔离与限流

批量任务不能拖垮交易链路。

建议隔离:

  1. 批量导入队列。
  2. 批量发布队列。
  3. 供应商同步队列。
  4. Outbox 刷新队列。
  5. 质量巡检队列。

大促前夜,运营批量改价、供应商全量同步、搜索索引重建如果共用同一组 Worker 和数据库连接池,很容易互相放大故障。默认策略应该是:

交易读链路优先
单商品编辑优先
小批量发布优先
大批量任务限速
供应商异常熔断

11.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. 供应商同步遇到运营保护字段时不覆盖。

11.15 与其他系统的集成

11.15.1 商品中心

供给平台通过命令 API 写商品中心,不直连商品正式表。

命令应该表达业务意图:

CreateProductDefinition
ChangeProductContent
ChangeOfferRule
ChangeRefundRule
PublishProductVersion
OfflineProduct

不要让运营后台执行:

UPDATE product_sku SET price = ? WHERE sku_id = ?;

11.15.2 库存系统

库存创建和修改的运营入口在供给平台,但库存事实必须留在库存系统。11.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 = ?;

11.15.3 营销系统

供给平台可以和营销系统集成,但集成的是活动配置、圈品和营销资格,不是优惠计算。常见动作包括:

  1. BindProductToCampaign:把商品、SKU 或门店范围加入活动。
  2. UpdatePromotionEligibility:同步商品是否具备参加活动的资格。
  3. SyncProductMarketingTags:同步活动标签、频道标签或运营分组。
  4. UnbindProductFromCampaign:商品下架、售罄、禁售或活动结束时解除圈品。

边界要清楚:供给平台负责表单、审批、Diff、发布版本和审计;营销系统负责活动规则、预算、券、补贴、营销库存、优惠叠加和成本归因。供给平台不要保存最终活动价,也不要直接核销券或锁定营销预算。

活动配置不能放进商品发布事务里同步调用。更稳妥的链路是:

商品发布 / 活动资格变更
  → 写 product_outbox_event
  → Marketing Coordinator 消费事件
  → 调用营销系统命令
  → 营销系统返回 CampaignBindingReady / Failed
  → 供给运营后台展示协同状态和补偿入口

11.15.4 计价系统

商品供给发布的是基础价格事实和 Offer 规则,计价系统根据商品版本、渠道、会员、营销活动和结算规则做试算。供给平台不要直接调用计价系统写最终成交价,发布后只通过 Outbox 让计价上下文消费者重建价格投影。

不要把活动价手填到商品表里,否则会造成:

  1. 计价口径漂移。
  2. 营销成本无法对账。
  3. 退款时无法解释优惠来源。

11.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

11.15.6 订单系统

供给平台不直接调用订单系统修改订单,也不向订单系统推送“最新商品配置”。订单系统只在创单时读取当时可交易上下文,并保存商品、报价、履约和退款规则快照。

订单创建时不能只保存 sku_id,还要保存商品上下文快照。

product_snapshot
offer_snapshot
price_snapshot
input_schema_snapshot
fulfillment_rule_snapshot
refund_rule_snapshot
supplier_mapping_snapshot

这样后续商品改价、下架、退款规则调整,不影响历史订单解释。


11.16 答辩材料

本章相关总结、常见问题和参考要点已统一收录到附录B

延伸阅读建议


导航书籍主页 | 完整目录 | 上一章:第10章 | 下一章:第12章

导航书籍主页 | 完整目录 | 上一章:第11章 | 下一章:第13章


第12章 计价系统设计与实现

本章基于《电商系统设计(五):计价引擎》与《电商系统设计(六):计价系统 DDD 实践》整理扩展,聚焦交易链路中的统一计价能力:四层价格模型、场景化计算、DDD 战术建模与系统边界。

阅读提示:若你更熟悉「订单里直接存一个 total_amount」的朴素模型,可以先带着两个问题读完全章——第一,券与积分为何常常不进创单快照;第二,为什么支付阶段坚持校验而不是重算。搞清这两个问题,就能理解计价中心存在的必然性,而不是把它简单看成「又一个中台服务」。文中 Go 示例为教学裁剪版,省略了错误包装、观测字段与部分依赖注入,落地时请按项目规范补全。


11.1 背景与挑战

11.1.1 价格计算的复杂性

在电商系统中,用户看到的「价格」并非单一标量,而是多因子分层叠加的结果:基础售价、营销活动、订单级抵扣、运费与增值服务、支付渠道手续费等,共同决定订单应付最终支付。若各系统各自实现一套加减逻辑,极易出现「商详 99 元、下单 105 元」的体验问题,甚至引发重复优惠、二次扣券等资损。

典型分解可概括为:

  • 基础价格:市场价、日常折扣价、渠道价等;
  • 营销价格:秒杀、新人价、满减、Bundle 等;
  • 抵扣:优惠券、积分、支付立减等;
  • 费用与附加:运费、碎屏险等平台服务费、跨境或信用卡手续费等。

同一 SKU 在 PDP(商品详情页)购物车创单收银台支付 各阶段,对计算深度、一致性与性能的要求并不相同,这要求计价中心以场景驱动的方式暴露能力,而不是「一刀切」的全量重算。

在传统拆分里,商品服务算「标价」、营销服务算「活动价」、订单服务在创单时再算一遍总价、支付服务为渠道优惠再算一遍——表面看各团队各管一段,实际上同一业务概念被多处隐式定义,边界一模糊就会出现「券用了两次」「满减门槛按行算还是按单算各执一词」等问题。更隐蔽的是:浮点金额四舍五入顺序外币汇率取整点不一致,会在大规模订单下累积成对账差异。计价中心的意义,就是把「一次购物旅程中所有与钱有关的加减」收敛到同一套编排与同一套舍入规则,让其他系统变成数据供给方或执行方,而不是第二个计算器。

11.1.2 核心挑战

  1. 一致性:展示价、订单快照、支付金额必须可追溯、可校验;规则版本与快照版本应对齐。
  2. 准确性:金额以为单位的整数运算;订单级优惠需按比例分摊到行,否则退款无法闭合。
  3. 性能:PDP / 列表页高 QPS,需多级缓存与轻量路径;创单 / 收银台可接受更高延迟但不容错算
  4. 异构品类:Topup、酒店、机票等定价因子差异大,需要策略插件而非巨石 if-else
  5. 供应商品类实时价:报价可能在浏览至支付间变化,需要 BookingToken、支付前反查等机制。

此外有两类「软挑战」往往在事故后才被写入复盘:组织边界产品口径。前者表现为多个团队各维护一段计算逻辑,接口文档写「参考订单域」,实际上订单域又在调另一套历史脚本;后者表现为 PRD 写「到手价」,但未定义是否含运费、是否含可叠加券的上限——技术再完美的引擎也无法收敛未定义的业务。计价项目启动时,建议把统一语言表(见 11.5 与系列第六篇)作为需求评审门禁,与 SkipLayers 表双签,再进入排期。

从工程视角,上述挑战可以压成三条硬约束:算得对(正确性)、大家认(一致性)、扛得住(性能与可用性)。其中正确性又依赖两条底座:一是整数分与确定的舍入;二是快照——把某一刻的规则解释结果固化为事实,后续链路只解释事实,不再在暗处重算。一致性则依赖版本化:规则集、活动表、券批次、汇率表都带有业务版本或生效区间,计价请求必须携带「我按哪一版解释」的线索,否则 PDP 与创单永远可能对不齐。

11.1.3 设计目标

目标说明
准确性计价结果可审计,关键路径可空跑比对
一致性统一入口计算,订单 / 支付以快照为准
高性能前台场景高缓存命中;交易路径少 IO、可并发
可扩展新营销类型、新费用项以策略 / 配置扩展
可观测分层耗时、缓存命中、差异告警

上述目标之间存在天然张力:例如「极致缓存」与「强一致快照」方向相反,需要通过场景分流化解,而不是用一套参数打天下。团队 OKR 里若只写 P99 延迟而不写差异率 / 资损事件数,很容易把系统优化成「快但不准」。建议在质量看板上同时跟踪:快照校验失败次数空跑 diff 超标次数客服价格类工单占比,与延迟指标并列。

11.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 约束,正是为了避免这两种腐化。


11.2 计价引擎架构

11.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 则与订单域协作:创单成功后写入订单快照,收银台基于订单行再生成支付快照;二者生命周期不同,不可混用一张表、一个过期策略

11.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 内部也建议再分子阶段:先算与履约相关的运费与增值服务费,再在收银台根据用户所选支付渠道计算手续费,这样创单快照不会错误地绑定某一渠道费率。

11.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,以便支付校验阶段做二次确认或优雅失败。


11.3 核心实现

11.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,与 11.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 拆出,取决于实现里是否将「订单附加费」与「支付渠道费」分为两个子处理器;关键是创单口径不包含随渠道变化的费率

11.3.2 快照生成

快照是防资损的关键:创单生成订单价格快照(含行明细、规则版本、供应商 BookingToken 等),收银台生成支付快照(含券积分与手续费)。快照应包含:

  • snapshot_idversioncalculated_atexpire_at
  • 各层贡献的结构化明细(便于对账与客服解释);
  • 可选:rule_bundle_hash 用于比对「当时用的是什么规则集」。

快照与订单数据的关系:订单表应保存 snapshot_id 或内嵌只读 JSON,但不建议在订单域再实现一套价格公式去「验算」——验算应回调计价或读快照服务,否则双实现又会分叉。快照表建议支持只追加:修正价格走新快照版本(v2),旧版本保留审计;支付失败回滚不应删除历史快照记录。TTL:订单快照常对齐库存锁定时间(如 30 分钟);支付快照对齐收银台支付超时(如 15 分钟),二者解耦。

11.3.3 试算接口

试算与正式计算共用同一套 Layer,通过 Scene 控制深度:PDP 试算只读展示;购物车允许预估券(标注 estimated=true);创单 / 收银台必须明确用户已选权益(券码、积分数量、渠道)。

试算响应里应显式区分三类字段:事实(已锁定、写入快照)、建议(系统推荐最优券但用户未确认)、估算(缺地址导致运费按默认规则猜)。前端展示时必须用不同标签,避免用户把「估算运费」当成承诺。对于 DryRun(空跑比对):上线新引擎时,生产流量旁路调用新旧两套,只在差异超阈值时采样上报,可在 PricingResponse 中附加 diff_summary 而不影响主路径延迟。

11.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 提示;若必须比对,应使用宽松阈值防浮点,或统一为整数分。


11.4 多级缓存与降级

11.4.1 缓存策略

场景是否缓存TTL 思路
PDP / 列表批量L1 短 TTL + L2 较长;命中要求高于展示 SLA
购物车部分自营可中等 TTL;供应商品类报价短 TTL
创单 / 收银台 / 支付否(结果可落快照表)以强一致计算为主

缓存 key 设计要同时防击穿脏读:key 中应包含 item_idsku_idregionchanneluser_segment(若价随人群变化)、以及规则版本摘要。大促时热门商品可采用**单飞(singleflight)**合并回源。对购物车这类高 churn 场景,可缓存「行哈希 → 计价结果」短 TTL,而不是整购物车超长缓存,避免用户改数量后长期读到旧价。

11.4.2 降级方案

  • 依赖超时:返回上一版本缓存并打标 stale=true(仅允许非交易路径);
  • 营销服务不可用:PDP 可降级为仅 Layer 1;创单路径应失败快速而非静默吞错;
  • 供应商报价失败:使用 DB 缓存价并限制最大陈旧度,超阈值则拦截创单。

降级策略要与法务与用户协议对齐:若页面上承诺了「展示价即购买价」,则任何返回陈旧价的降级路径都必须附带明确提示或干脆失败;否则可能构成虚假宣传风险。技术团队常忽略这一点,把「能卖出去」置于「合规展示」之上。开关治理上,降级与熔断配置应纳入配置中心审计,谁在什么时间打开「允许陈旧价」,需要可追溯。

11.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;此处展示「先读内存、未命中再计算回写」的最小闭环。


11.5 DDD 建模实践(重点)

DDD 在计价系统中的价值,在于用统一语言消除 originalPrice / salePrice / actualPay 混用,并用聚合边界保证「基础价 + 选中营销 + 费用 − 抵扣」在同一事务语义内一致。

11.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
}

11.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 聚合 + 订单级领域服务是常见组合:行内互斥活动放在行聚合,跨行满减分摊放在服务。

11.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
}

11.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
}

领域服务无状态,入参出参均为领域对象或值对象。

11.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 与外部服务的字段映射;端到端只保留少量黄金用例,防止全链路测试过慢导致无人运行。


11.6 不同场景的价格计算

11.6.1 PDP 场景(商品详情页 / 加购试算)

PDP 的首要 KPI 是转化,技术侧对应的是极低延迟与稳定展示。计算上通常停留在 Layer 1 与 Layer 2:用户需要知道「日常卖多少、活动卖多少、我是否命中新人/秒杀」。券与积分如果在 PDP 就做全量最优解,RPC 扇出会爆炸,因此常见做法是:主路径同步返回展示价,券预估走异步任务或边缘计算,并在 UI 上用弱提示展示「领券最高可再减 X 元」。

加购(AddToCart) 与 PDP 类似,往往不锁任何资源;若要做「凑满减」提示,可在服务端维护轻量规则缓存,仍以 Layer 1–2 为主。PDP 与创单的价格差异若不可避免,必须在交互上降级为「以结算页为准」,同时在日志里记录 rule_bundle_hash,便于客诉时复盘。

11.6.2 购物车场景

购物车是多品聚合用户频繁编辑的交集:行增删、数量变化、地址切换都会触发重算。技术上通常批量拉取基础价与活动,再对共享的订单级优惠做编排;Layer 3 在购物车阶段多为试算而非锁定,返回体应用 estimated 标记。运费若无默认地址,可返回区间或按城市模板估算,并在进入结算页时用真实地址覆盖。

供应商品类在购物车仍需注意外部报价抖动:可短时缓存供应商返回,但 TTL 要显著短于自营;用户停留过久时,结算页应主动提示「价格已更新」。

11.6.3 创单场景(订单金额与快照)

创单是价格从「展示」走向「事实」的分水岭:此时应完成与履约相关的费用(运费、服务费等),并生成订单快照。不包含券与积分并非技术偷懒,而是业务上常把「用户尚未进入收银台选择的支付权益」排除在订单应付之外,避免订单应付随用户换券剧烈波动;若业务要求订单应付即含券,应在需求层显式调整 Layer 映射,而不是在代码里硬塞。

创单路径还要与库存预占、营销库存锁定同事务或同 Saga 编排:计价不负责预占,但要在预占成功之后再冻结快照,否则会出现「快照有了库存没了」的僵尸数据。供应商品类在创单常同步拉取供应商报价并生成 BookingToken,写入快照供支付确认。

11.6.4 支付场景

支付侧理想状态是 O(1) 查表校验:读取 snapshot_id 对应金额、币种、过期时间,与支付请求比对;供应商品类增加「预订确认」RPC。任何在支付路径重新跑全量 Layer 的做法,都应视为技术债:渠道回调重复、用户重复点击支付,都会让重算路径产生非确定性。若必须重算(极少数风控场景),应产生新快照版本并阻断旧支付单。

11.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 缓存,避免用户反复刷新产生大量孤儿快照占满存储。另一个易错点是部分失败:创单成功但写快照失败时,必须有补偿任务阻断支付或自动关单,否则会出现「订单存在却无快照」的不可恢复状态。


11.7 系统边界与职责

11.7.1 计价系统的职责边界

计价中心负责

  • 统一编排各层价格;
  • 输出明细与快照版本;
  • 金额校验、尾差、分摊与安全阈值(如最大折扣率)。

不负责

  • 营销活动配置与圈品 CRUD;
  • 券的发放与库存扣减(由营销执行),计价仅消费「已锁定 / 已选中」结果;
  • 支付路由与渠道签约。

11.7.2 计价 vs 营销:谁算什么

维度营销系统计价系统
规则定义✅ 活动、券模板、互斥叠加
最优券搜索(可选)✅ 或协同推荐服务可消费候选集
金额编排提供命中规则与减免额✅ 汇总为价格事实
执行扣减✅ 锁定 / 核销

边界口诀:营销回答「能不能用、用哪条」;计价回答「用了以后多少钱」

进一步细化:**「最优券推荐」**可以放在营销、推荐或独立优惠参谋服务里,但「用户已勾选某张券后的应付」必须由计价统一给出,避免前端本地算法与后端不一致。支付渠道立减有时由渠道 SDK 返回,计价需约定是「事前写入快照」还是「支付回调后补记账」——两种模式都能做,但不能混用两种口径于同一报表周期。

11.7.3 试算 vs 订单价格快照

  • 试算:可重复、可缓存、允许短暂不一致(需标注);
  • 快照:一次创单事实,不可变(修正走补差单、客服单等流程)。

11.7.4 基础价 vs 促销价 vs 支付价

  • 基础价:商品域维护的标价体系经 ACL 投影;
  • 促销价:营销规则作用后的价格带;
  • 支付价:在订单应付基础上叠加用户支付相关抵扣与手续费后的渠道实扣口径。

争议场景举例:若平台补贴在营销侧记账,但支付渠道又有「立减」,需要明确支付价是否含渠道补贴、财务对账时GMV 与实收各扣哪一段——这属于清结算域的规则,但计价必须在 PriceComponent 上打好 sourceledger_account 类标签,否则报表会对不齐。再如「部分退款是否回收满减」:这是营销与订单策略,计价提供按行分摊的实付结构即可支撑多种回收算法。


11.8 与交易链路各系统的集成

11.8.1 与商品系统集成(基础价读取)

商品中心提供 SPU/SKU 主数据、规格价、渠道价;计价通过 ACL 转为 Money 与可选的「划线价」展示字段。约定:商品系统不实现营销价,避免双源;若商品侧已有「日常售价」字段,应在数据字典中与计价的 Layer 1 对齐命名。批量接口应支持按 sku_id IN (...) 拉取,减少 N+1。

11.8.2 与营销系统集成(营销规则应用)

营销系统输出「命中了哪些活动、互斥关系、是否可叠加、券批次剩余」等;计价把这些投影为 PromotionOffer 再进入 Layer 2。锁定 / 核销仍由营销执行:创单前调用营销「预占」,失败则整单创单失败;计价只消费预占成功后的面额事实。若营销 RPC 慢,优先考虑异步刷新购物车缓存而非缩短创单超时。

11.8.3 与 PDP 集成(加购试算)

PDP 网关调用 GetItemPrice 类接口,应带齐 regionplatform、用户分群键;CDN 上只能缓存匿名价时,登录态价需回源或边缘二次请求。对 SEO 落地页,注意缓存穿透:热门失效 SKU 要有布隆过滤或空值短缓存。

11.8.4 与购物车集成(实时试算)

购物车服务维护行表,计价侧接收「行快照 + 指纹」;指纹变化(数量、选中券)即缓存失效。购物车合并(登录前后)要以服务端合并结果为准重新试算,避免客户端本地算价。

11.8.5 与结算系统集成(确认价格)

结算编排地址、配送方式、可用券列表,调用计价生成支付快照;结算页展示的每一项优惠,都应在快照明细中有对应 component_id,方便客服追溯。

11.8.6 与订单系统集成(创单金额计算)

订单系统保存 order_snapshot_id 与行级分摊明细;后续改价(客服改运费)应走订单变更流程并生成新快照或差值单,而不是直接 UPDATE 金额字段。订单取消释放营销锁时,计价一般不参与,但要保证幂等释放

11.8.7 与支付系统集成(支付金额校验)

支付创建时上传 snapshot_id 与应付总额;支付核心对比快照与渠道金额(含外币换算规则)。重复支付回调通过支付单号幂等;部分支付、合并支付等高级场景要在协议层约定快照粒度(整单 vs 子单)。

11.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: 收银台支付

11.8.9 降级与容错策略

  • 计价依赖故障时,交易路径默认 fail-fast;展示路径可降级;
  • 重试需配合幂等键避免双快照;
  • 全链路 trace id 贯通,便于按 snapshot_id 定位规则版本与下游返回。

超时配置建议:PDP 调用链应「短超时 + 部分降级」,创单链可「较长超时 + 严格失败」。熔断打开时,要有人工开关把流量切到备用集群或旧版本引擎,而不是无限重试。对供应商报价,超时后是否允许用缓存价创单属于业务决策:机票酒店类往往不允许,实物自营类可能允许——决策应写在品类策略配置里而不是写死在代码分支。

审计与合规:计价日志应能重建「当时为什么是这个价」,包括各层输入输出哈希;日志中避免打印完整用户 PII,但需保留 user_idorder_id 关联键。对外部监管或商家对账,常导出快照明细而非实时重算结果。


11.9 工程实践

11.9.1 性能优化

  • 分层埋点:每层耗时、RPC 次数、跳过率;
  • 批量接口上限(如 100 SKU)与背压;
  • 大促前预热与限流按 scene + category 维度配置。

除指标外,建议在引擎内建自适应批大小:当单次购物车行数超过阈值时自动拆批并发,再合并结果,防止单次请求拖垮 GC。对 Go 服务,注意 context 超时传递:上游取消时应中断未完成的供应商调用。内存方面,PricingState 可能持有大切片,必要时在返回后显式重置对象池复用缓冲区,降低大促分配压力。

11.9.2 监控告警

  • 空跑 diff 金额 / 比例阈值告警;
  • 快照校验失败率;
  • 供应商报价失败与降级占比。

告警应区分用户可感知失败(创单失败率)与后台差异(空跑 diff)。对后者可采用采样 + 自动建 JIRA/工单。另建议监控 「创单成功但快照写入失败」 这类罕见组合——往往来自数据库半成功状态,需要补偿任务修复。

11.9.3 故障处理

  • 回滚:灰度开关切回旧引擎;快照已落库则不以新逻辑改写历史
  • 数据修复:通过补差、退款重算由财务域流程驱动,而非直接改库内金额。

演练层面,每季度做一次**「营销配置误发」桌面推演**:若运营错误配置了叠加券,计价能否通过安全校验器拦截?若不能,规则引擎侧也要有发布前仿真。事故后复盘要输出「哪一层本应挡住」的改进行项,而不是只修数据。


11.10 本章小结

本章从交易链路视角定义了计价中心的定位:以四层价格模型统一基础、营销、抵扣与费用语义,用场景驱动的责任链控制计算深度与性能,并以 DDDPrice 聚合、Money / PriceLayer 值对象与领域服务结合,保障边界清晰快照一致。与营销系统的分工上,应坚持「营销定义规则与执行权益,计价产出可审计的价格事实」。落地时务必配套幂等、分摊、空跑比对与可观测性,才能在复杂促销与高并发下同时满足体验与资损防控。

延伸阅读建议:读完本章可对照第 9 章营销系统边界与第 14 章订单价格快照设计,把「试算 → 创单 → 收银台 → 支付」四个时间点的口径表画在团队 wiki 上,作为跨团队评审的检查清单。实现上新加一层价格或一类费用时,先更新该表,再写代码,能显著降低联调返工。

落地检查清单(摘录):① 各场景 skipLayers 与产品 PRD 是否逐条签字;② 快照表是否支持版本与只追加;③ 金额是否全链路整数分;④ 分摊单测是否覆盖「末行尾差」与「单行边界」;⑤ ACL 是否禁止领域层引用外部 proto;⑥ 空跑比对是否在灰度期全量开启;⑦ 支付校验失败是否有客服可查的 snapshot_id 与规则哈希。团队可在发版前用此清单做十分钟走查,把「架构上正确」落实为「发布时可控」。

术语说明:文中 PDP 指商品详情与导购详情类页面;创单指订单创建请求在服务端落库的关键步骤;收银台泛指用户确认支付前选择券、积分与支付方式的交互阶段。不同公司团队命名可能为 Checkout、Cashier 或 Payment Preview,本书统一以「收银台」称呼,重在语义阶段而非具体页面 URL。

与博客原文的映射关系:四层模型与场景跳过表对应系列第五篇中的分层示例与 getSkipLayers 思路;统一语言、子域划分、聚合与防腐层示例对应第六篇中的战略 / 战术设计。本书将两篇合并为交易链路章节写法,删去了部分供应商 YAML 配置与大段业界对比,读者若需查阅更长的配置样例与演进史,可回到博客原文细读。

稿约说明:本章正文汉字约一万字以上(随修订浮动),配套多幅 Mermaid 图与若干 Go 代码块;若纸质排版,请注意图宽与代码等宽字体设置,以免版心溢出。引用的接口名为示例,与任何生产代码仓库无强制对应关系。

编辑备忘:仓库中对应原文为 24-ecommerce-pricing-engine.md25-ecommerce-pricing-ddd.md(若他处写作「25-ecommerce-pricing-practice.md」,以仓库内 25-ecommerce-pricing-ddd.md 为准)。合并时已将 DDD 篇中的事务脚本反例与上下文映射图转写为本书叙述体例。后续若博客原文更新,请同步修订本章「走数示例」与 skipLayers 对照表,以免书籍与线上实现漂移。读者若在落地中遇到本章未覆盖的品类,可沿用「先补 Calculator、再补 Layer 子处理器」的扩展顺序,避免破坏四层语义的一致性。批量计价接口建议对请求体大小与 SKU 个数双限流,并在响应头返回 X-Pricing-Partial: true 以标记降级后的不完整结果,便于上游决定是否重试或裁剪展示。此做法在搜索 Hydrate 与推荐列表页尤为实用,可作为默认工程约定。


导航书籍主页 | 完整目录 | 上一章:第11章 | 下一章:第13章

导航书籍主页 | 完整目录 | 上一章:第12章 | 下一章:第14章


第13章 搜索与导购

本章定位:搜索与结构化导购(类目列表、店铺内浏览)是电商平台最主要的 读流量入口 之一,直接影响转化与 GMV。本章在「统一导购查询服务」主叙事下,串起 Query → Recall → Rank → Hydrate 全链路,并以 Elasticsearch 作为召回与粗排主引擎,厘清与商品中心、计价、库存、营销、推荐等系统的 边界与契约。内容基于《电商系统设计(十二):搜索与导购》扩展,面向中大型团队的工程落地。

阅读提示:若你习惯把「列表页」简单等同于「查 ES 返回 JSON」,建议带着三个问题读完全章——第一,搜索与导购在产品目标上与推荐有何不同;第二,哪些字段必须进索引、哪些必须 Hydrate;第三,索引滞后与 Hydrate 超时时,列表弱一致如何不与交易强一致冲突。搞清这三点,就能把读路径工程化讲清楚,而不是停留在「调个 DSL」的层面。文中 Go 示例为教学裁剪版,落地时请补全观测、注入与错误包装。


13.1 系统定位

12.1.1 搜索与导购的区别

在日常口语里,「搜索」「列表」「导购」常被混用;在架构文档里,建议用 用户意图约束形态 区分:

维度关键词搜索(Search)结构化导购(Browse / Merchandising)
用户输入显式 query,可能含糊、多义通常无文本 query,或 query 为辅助
主约束文本相关性 + 硬 filter类目 / 品牌 / 店铺 / 多维筛选项
失败体验零结果、纠错、同义词空列表、Facet 互斥错误
典型 scenekeywordcategoryshop
引擎侧重分析链、改写、BM25 / 向量(可选)聚合导航、稳定排序、强 filter

二者在工程上应 共享同一套流水线内核(召回 → 排序 → Hydrate),否则极易出现「同一批商品在搜索与类目列表排序不一致」的线上事故。产品层面,搜索偏意图检索:用户带着问题来,系统要回答「最相关的候选集」;导购偏可控陈列:运营希望用户在既定类目树与筛选体系内高效浏览。推荐系统(Feed)则偏 个性化发现,目标函数、特征 freshness、在线学习与搜索不同,本章在 12.5.2 单独划界。

12.1.2 核心挑战

挑战根因设计方向
相关性同义词多、类目错挂、拼写噪声可控词典与改写 + 埋点闭环
列表价与索引不一致促销、会员、渠道价变化快于索引Hydrate + 产品话术;结算强一致
高并发读大促与热搜集中ES 扩展、缓存、限流、降级
深分页from/size 成本随页数上升search_after + 产品限制
跨系统编排Hydrate 依赖多、尾延迟叠加并发上限、独立超时、部分降级
索引与主数据漂移异步链路、至少一次消费幂等 version、对账与补偿任务

与订单、支付等 写路径 不同,导购链路往往 QPS 高、容忍短暂最终一致,但必须处理好 相关性、价格与库存展示口径、营销露出、以及索引滞后 带来的用户预期落差。详情页应以 商品中心读模型 为准做强一致或近实时;列表页承认 弱一致,并在创单 / 结算阶段由库存、计价、营销再次校验。

组织协作 视角看,导购链路往往是「商品、搜索、推荐、营销、前端、数据」多条职能线的交汇点:任何一方在接口里多塞一点排序逻辑,短期能加快需求交付,长期会把 归因、回滚、实验 变成不可能任务。因此本章反复强调 统一查询内核 + 版本化 rank,并不是架构洁癖,而是把 变更半径 收敛到可治理的边界内。另一个常被低估的协作点是 口径对齐:运营口中的「到手价」、产品文档里的「列表价」、计价服务返回的字段名,若不能在数据字典层统一,Hydrate 再快也只能放大混乱。

风险 视角看,导购事故通常不是「ES 挂了」这种单点,而是 组合型:索引滞后叠加 Hydrate 超时,再叠加前端把「展示价」当成「下单价」渲染,最终在社交媒体被放大成「平台偷偷涨价」。工程上要用 字段语义 + UI 文案 + 快照校验 三道闸兜底;单纯优化 ES 延迟并不能消灭这类问题。

12.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 维度,并在压测脚本里显式模拟「半数依赖降级」。

容量估算:导购链路的容量估算答辩口径已统一收录到附录B


12.2 统一导购查询服务

12.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")
    }
}

12.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,而不是塞进自由文本日志。

12.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 语义一旦上线,就不应在无迁移的情况下改变含义(例如从「仅价格缺失」扩展成「任意字段缺失」),否则前端埋点与客服话术会同时失效。


12.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

12.3.1 Query 理解

目标不是通用 NLP 搜索引擎,而是 可控、可解释、可回归

  • 归一化:全半角、大小写、去噪字符、重复空格。
  • 同义词 / 类目词典:运营可配表驱动;变更走 版本号,与排序实验解耦。
  • 拼写纠错:可选;需 限流 + 白名单,避免引入合规或品牌风险。

输出物建议固定为:normalized_queryintent_tagsrewrites[](有限条数),供 DSL 组装与埋点。

工程落地建议:把 Query 理解的输出定义成 不可变结构体(或值对象),并在日志里同时打印 raw_querynormalized_query,但注意隐私合规(手机号、地址片段误入搜索框并不少见)。改写表(同义词、类目映射)应支持 灰度发布:先 shadow 记录「若启用改写将变成什么」,再按桶启用,避免运营配置错误导致大面积零结果。

与「大模型改写」的边界:生成式改写很诱人,但在电商场景要先回答 责任归属:改写后的 query 若召回违规商品,谁承担合规责任?更稳妥的路径通常是 受控词典 + 小模型 / 规则纠错,把 LLM 放在离线挖掘与运营辅助,而不是在线默认链路的第一跳。

12.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 参数、召回评测集 三件事;否则极易出现「文本搜得到、向量搜不到」的双轨撕裂。多数业务在规模化前,同义词 + 类目意图 + 运营纠错 的投入产出比更高。

12.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 与 投诉率 / 零结果率

12.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 与批量大小,还应对 同一用户 做轻量节流(例如滑动窗口),防止脚本或异常客户端发起「并发多页请求」放大扇出。


12.4 Elasticsearch 专题

与商品中心的分工:索引字段清单、nested 取舍、商品变更如何进索引,以商品中心相关章节为权威叙述。本节聚焦 查询侧契约、典型 DSL、深分页与性能调优,并给出 索引生命周期与 mapping 视角 的架构图。

12.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 级属性必须父子联合约束时使用;滥用会放大成本
反模式对大文本无意义排序;高基数深度聚合默认全开

12.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,
    }
}

12.4.3 深分页问题

方式适用风险
from + size前若干页from 过大时全局排序,内存与延迟陡增
search_after深度翻页 / 连续浏览需稳定 sort key;不适合随机跳页
scroll离线导出、对账不适合 C 端高并发

答辩提示:深分页问题的标准答法已统一收录到附录B

search_after 的工程细节:sort 数组必须 全链路稳定,任何「仅用于展示」的字段都不应参与 tie-break,否则会出现翻页跳变。常见做法是 _score + 业务排序字段 + 主键升序。客户端需要缓存上一页最后一条的 sort 值;若中间发生 索引刷新 导致顺序变化,产品上要接受「轻微抖动」或通过 会话级快照(成本更高)解决。

随机跳页的产品替代:电商 C 端常见替代是「跳到第 N 页」改为「继续浏览 / 相似推荐 / 细化筛选」,把深分页需求转化为 更强的约束更相关的子集,既保护集群也提升转化。

12.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熔断(例如限制每节点脚本编译频率)。


12.5 系统边界与职责

12.5.1 搜索系统的职责边界

搜索与导购系统应负责:

  • 派生索引的构建与查询(含增量、幂等、版本对齐)。
  • 召回与(可配置)排序,以及 列表读模型的编排(Hydrate)
  • 观测与实验 位点(query_idrank_versionexp_id)。

不应负责:

  • 商品主数据真相源、订单事务、营销算价与券扣减、库存预占。

12.5.2 搜索 vs 推荐:边界划分

维度搜索 / 导购推荐(Feed)
主信号query + 筛选意图用户行为序列与画像
目标相关性 + 平台规则下的转化发现与时长 / GMV 组合目标
失败模式零结果、相关性差信息茧房、疲劳
架构检索引擎 + 规则/LTR特征平台 + 在线排序 + 重排

二者可 共享埋点、特征与实验平台,但服务边界建议解耦,避免「搜索里偷偷塞推荐」导致可解释性与合规审计困难。

落地协作模式:推荐团队常希望复用搜索召回做「候选池」,这在技术上是可行的,但要把契约写清:候选池的版本、过滤条件、以及责任边界(例如禁售过滤由谁兜底)。更推荐的方式是推荐系统维护 自己的候选生成链路,在特征层复用搜索的 类目、品牌、文本 embedding 等中间产物,而不是在运行时强耦合调用搜索 HTTP 接口——否则搜索一旦降级,推荐会被连带拖死,故障半径不可控。

12.5.3 索引数据 vs 实时数据

数据类型放索引Hydrate / 实时读
标题、主图、类目、品牌可选补全
上架状态、站点强一致场景再二次校验
展示价、会员价粗粒度 / 索引价是(列表口径)
可售 / 紧张粗信号是(更准确)
活动标、圈品命中可缓存只读是(失败可降级)

12.5.4 召回 vs 排序 vs Hydrate 的职责

  • 召回:在高召回率前提下控延迟;避免跨系统调用。
  • 排序:可解释、可版本化、可回滚;重排承载运营与合规。
  • Hydrate:补齐易变展示字段;单卡失败不拖死整页

答辩提示:搜索边界类追问已统一收录到附录B


12.6 与上下游系统集成

12.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))
}

12.6.2 与计价系统集成(列表价 Hydrate)

计价只读接口建议 批量 + 站点 + 会员等级 维度;字段命名与 PDP / 结算 严格区分「列表价」与「应付价」,避免客户端误用。超时策略见 12.7.2。

会员价与未登录态:未登录用户可能只能看到「起售价」或「公开价」,此时 Hydrate 请求不应隐式携带会员身份;登录态切换时要小心 前端缓存 造成「登录后列表仍显示旧价」,可通过 price_as_of 或短 TTL 缓存失效解决。对「登录看价」类降级,建议同时返回 可解释的降级原因码(如 PRICING_TIMEOUT),便于埋点区分转化率下降根因。

12.6.3 与库存系统集成(可售状态)

列表展示 弱一致 库存摘要即可;下单前以库存服务 强校验 为准(与订单、库存章节衔接)。降级策略需业务拍板:偏保守利于防客诉,偏乐观利于转化。

12.6.4 与营销系统集成(活动标签)

营销在列表侧 只读展示:活动标、圈品是否命中;不算价、不锁券。资格与叠加仍以结算与创单为准。

活动标与合规:列表展示「满减」「券」等文案时,要避免暗示用户已领取或已满足门槛。活动标更像 广告露出,不是 权益状态;否则容易与营销执行域产生口径冲突并引发投诉。对敏感类目(医疗、金融类比商品),活动文案可能需要额外 合规审核字段 控制展示。

12.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

12.6.6 集成性能优化

  • 批量 RPC 合并网络往返;连接池按 QPS × 每页条数 估算。
  • 热门 SPU 短 TTL 缓存 + singleflight 防击穿;注意 个性化价 与缓存 key 冲突。
  • 重试 指数退避 + 用户维度熔断,避免拖垮计价 / 库存。

依赖拓扑与故障隔离:Hydrate 依赖建议按 关键路径分级:标题主图属于「强展示」;活动标属于「弱展示」;价格属于「强体验但可降级」。分级后可以定义 不同的超时与重试策略,避免弱依赖拖长尾。若使用服务网格,可为营销只读设置更小的超时与更激进的熔断,把尾延迟从全链路中剥离出去。


12.7 一致性与降级

12.7.1 索引延迟处理

现象:上架后短暂搜不到;改价后列表旧价。组合手段:详情强一致读商品中心;列表展示 数据时间戳 或「价格以结算为准」;大促关键池可走 强制刷新队列(与商品中心刷新策略对齐)。

运营活动窗口的同步策略:大促「清单商品」往往要求更高新鲜度,技术上可采用 活动商品白名单 + 更高优先级消费队列,甚至短时 双写直刷(写入路径旁路触发索引更新)。但要警惕:旁路越多,幂等与对账越复杂;因此白名单规模必须可控,并在活动结束后及时回收,避免把临时机制固化成永久债务。

12.7.2 降级策略

与 12.6.5 呼应:Hydrate 独立超时;ES 故障 缓存 / 简化查询;suggest 与主搜 配额隔离

12.7.3 缓存策略

  • 查询结果缓存:key 需含站点、筛选 hash、排序版本;个性化价场景慎用或细分桶。
  • Facet 缓存:更短 TTL 或异步;注意筛选变更失效。
  • 索引别名切换:蓝绿 reindex 后一次性切 read_alias,缩短双读不一致窗口。

索引延迟的「产品 + 技术」组合拳:除了技术手段(强制刷新队列、提高消费并行、热点分片治理),还需要 产品话术与 UI 引导:例如「刚刚上架,正在全网同步」或提供 直达详情 的链接入口。否则用户会把「搜不到」理解为「平台没货」,对转化伤害更大。对价格类客诉,客服工具应能输入 spu_id 查到 index_versionprice_as_of,否则只能复读「以结算为准」。

Hydrate 风暴的防护:当 ES 变慢时,应用层往往会 放大重试拉长等待,进而把计价与库存拖入雪崩。防护要点是:入口限流先于下游扩容;超时短于下游默认;失败快速返回 partial;并对同一 query_id 的重复提交做 去重(幂等键 + 短窗缓存)。


12.8 工程实践

12.8.1 性能优化

  • 网关按 用户 / IP / 设备 限流;异常流量对接风控。
  • 压测覆盖:大 filter + 多排序键 + search_afterHydrate 半数超时
  • 零结果率P99 端到端hydrate 超时率 建立 SLO。

热点治理:除店铺维度外,还要关注 超级品牌、超级类目、大促会场 的查询模式是否会把 ES 查询打成「同一 filter 反复出现」的形状。此类热点更适合 边缘缓存查询结果短缓存,并把缓存 key 与 rank_version 绑定,避免实验回滚后缓存污染。对 suggest 接口要单独做 更低配额,否则 App 输入框的每个字符都会放大成 ES 压力。

12.8.2 可观测性

日志与追踪携带 query_idsceneexp_idrank_version;ES 查询记录 归一化 query(注意隐私脱敏)。对慢查询 profile 采样

「可回放」是搜索排障的生命线:建议在测试环境保存 DSL 生成器版本fixture query 集,线上问题能一键回放同一 DSL(脱敏后)到预发集群对比 hits。没有回放能力时,团队只能依赖工程师记忆改写了什么,排障周期会从小时级变成天级。

12.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、以及 客服价格类工单占比。最后一项能把「体验问题」翻译成管理层听得懂的损失函数。


12.9 本章小结

搜索与导购是电商 读模型工程化 的主战场:统一 scene 与编排 降低系统熵;Elasticsearch 承担召回与部分粗排,但必须与商品、上架、生命周期、计价、库存、营销的 契约 清晰划分;Query → Recall → Rank → Hydrate 主链路上,一致性与体验通过 索引版本化 + Hydrate 限时降级 + 产品话术 组合兜底。与推荐系统保持 目标与架构边界 上的解耦,在特征与实验平台上 复用能力,是多数中大型平台的务实演进路径。

本章答辩总结已统一收录到附录B

演进路线 看,多数团队会经历「ES 直出 → 引入 Hydrate → 引入统一 scene 与 rank 版本 → 引入完整观测与对账」四阶段;每一阶段都能单独带来收益,但不要把四阶段压缩成一次「大爆炸重构」,否则会在大促窗口付出惨痛代价。最稳妥的切分是:先统一排序内核与日志字段,再逐步把易变字段迁出索引。


参考资料

  1. Elasticsearch 官方文档 — 查询 DSL、分页、profile、聚合。
  2. 本书相关章节:商品中心(索引与缓存)、库存系统、营销系统、计价系统、商品供给与运营管理。
  3. 博客原文:电商系统设计(十二):搜索与导购(Search & Discovery)

导航书籍主页 | 完整目录 | 上一章:第13章 | 下一章:第15章


第14章 购物车与结算

本章基于《电商系统设计(十三):购物车与结算域》整理扩展,聚焦转化漏斗中的意愿暂存交易前置校验两段能力:购物车弱一致、不锁资源;结算页强一致、编排计价 / 库存 / 营销 / 地址,并通过 Saga 补偿幂等键 保证可重入与可回滚。文中 Go 示例为教学裁剪版,落地时请补全超时、观测、注入与错误语义。

阅读提示:若你习惯把「购物车、结算、创单」写在同一个服务里,可以带着三个问题读完全章——第一,预占库存为何不应出现在购物车;第二,试算与扣券为何必须拆开两个系统时刻;第三,拆单预览与真正拆单的边界应落在哪。把这三件事想清楚,就能把本章与第 9 章(库存)、第 12 章(计价)、第 15 章(订单)自然衔接起来。

答辩提示:购物车与结算域的追问答法已统一收录到附录B


13.1 系统定位

13.1.1 购物车与结算域

在典型电商链路 浏览 → 加购 → 结算 → 下单 → 支付 中,购物车域承担「意愿篮」:长期暂存 SKU 与数量、支持跨端查看、允许展示价与可售状态弱一致滞后结算域(Checkout)承担「交易前的最后一次强校验」:价格试算拿到 price_snapshot_id、库存预占拿到 reserve_ids、营销只做可用性校验、地址与运费可短时缓存;用户点击提交后,把上述凭证交给订单系统创单,自身不推进订单状态机、不执行支付。

二者哲学差异可概括为:

维度购物车结算页
一致性弱一致可接受价格 / 库存 / 优惠需实时
资源锁定不锁定预占库存(如 15 分钟)
生命周期可长期保留用完即焚或极短会话
失败策略标记失效、不阻断浏览关键依赖失败应阻断或明确降级

13.1.2 核心职责

购物车服务应内聚的职责包括:匿名 cart_token 发放与校验、登录后合并、Redis 主存储与 DB 异步备份、批量选择与数量修改(乐观锁)、列表 Hydrate(批量读商品、可选读展示价与库存状态)、失效商品标记。不应承担:计价规则、库存预占、券扣减、拆单履约路由。

结算服务应内聚:进入结算时的 Saga 编排(并发试算 / 预占 / 校验 / 地址运费)、提交订单前的 幂等去重、订单创建失败时的 显式释放预占、拆单与运费的轻量预览不应承担:订单持久化与状态机、营销扣券事务、支付渠道路由。

限界上下文(Bounded Context) 视角看,购物车与结算可以部署为两个服务,也可以先合在一个进程里用包级边界隔离,但语言层边界要先立住:购物车领域的聚合通常是「购物车行集合」;结算领域的聚合更接近「一次结算尝试(CheckoutAttempt)」——它甚至不一定要落库,可以以请求上下文 + 外部系统返回的凭证组合存在。把这两个聚合混在一个 Order 聚合根里,是单体时代最常见的腐化起点:你会看到订单服务里长出「顺便改下购物车」的私有 API,最后谁也不敢删。

团队分工建议:购物车更接近 增长与体验团队(关注转化、列表性能、推荐插卡);结算更接近 交易与资金安全团队(关注幂等、补偿、风控)。若组织上同属一个小组,也应在代码评审里用不同的 OWNERS 文件与 SLO 分栏,避免用购物车的发布节奏去承载结算的严谨性,反之亦然。

13.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 直到提交或超时,这样可以把复杂度压到可接受范围。若产品强需求「刷新页面仍保留勾选与券选择」,可在 13.8 引入轻量 checkout_session 并严格对齐预占 TTL 与快照过期时间,否则极易出现「页面看到的是 A 价、提交时已是 B 价」的认知冲突。

本章显式非目标:不把支付路由、支付渠道对账、订单履约全状态机纳入结算服务;不展开秒杀极端优化(仅在后文工程小节点到为止);不把计价规则引擎、库存 Lua 细节、营销券批次台账重写一遍——这些分别归属第 12、9、10 章及订单第 15 章。

与第 4 章(Saga 总论)的关系:第 4 章给出编排 / 协同、补偿幂等与事件驱动的一般模式;本章把它落到「购物车弱一致 + 结算强编排」这一条具体链路上。你在评审架构时可以用一句话自检:购物车里永远不该出现 Saga,因为那里没有跨系统资源需要一致回滚;结算页几乎必然出现 Saga,因为试算、预占、创单分布在不同限界上下文。


13.2 购物车设计

除「能加购、能合并」外,购物车还需要回答四个体验问题:加购后价格变了怎么办商品下架了怎么办跨端是否一致风控与刷单边界在哪。下面分小节把模型与工程一次说透。

13.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 被删除 / 查无此品「商品失效」占位不允许勾选结算

13.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 推送,但不要反向把推送当成库存真相。

13.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()})
}

13.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
}

13.3 结算页设计

13.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: 结算页聚合结果

13.3.2 价格试算

结算页必须调用计价中心的 试算接口scene=checkout),拿到 应付总额、分项明细、快照 ID 与过期时间。购物车列表上的价格只能是「参考价」,产品话术需统一为 「以结算页为准」,否则客服与舆情成本极高。

试算失败属于 P0 阻断:不允许进入可提交状态。可选优化是快照过期后由订单系统二次校验或拒绝创单,但不应在结算页静默使用陈旧价。

触发重新试算的事件(与前端埋点一一对应,便于解释「为什么总价跳了」):

事件是否必须重算说明
首次进入结算建立基准快照
切换收货地址通常要运费与可达店铺集合可能变化
切换 / 取消优惠券影响分层抵扣
修改数量(仍在结算页)行金额与门槛类活动联动
仅切换发票抬头视税制可能不影响含税价

快照过期的产品策略:常见做法是快照 30~60 分钟内有效,过期提示用户刷新;订单系统在创单时再做一次 硬校验,防止「结算页停留过久」绕过。不要试图在结算服务内「续命」快照,那会把计价系统的版本语义搅浑。

13.3.3 库存预占

预占解决的是「从结算到支付窗口内库存被抢走」的体验与超卖风险。预占时长常用 900 秒,由库存服务维护 TTL 与释放任务;结算服务在 订单创建失败 时显式调用 release-reserve,避免等待 TTL 造成的资源浪费。

预占与试算的失败组合处理见 13.6 节补偿表。核心原则:结算页不实现扣减,只持有 reserve_ids 凭证。

用户在结算页改数量:应走「释放旧预占 → 按新数量重新预占」的两段调用,中间态要对前端屏蔽或短锁按钮,避免双份预占。若释放成功而重新预占失败,应整体回退到「请返回购物车重选」的确定语义,而不是半提交。

stateDiagram-v2
  [*] --> 可售
  可售 --> 预占中: Reserve
  预占中 --> 已扣减: ConfirmReserve
  预占中 --> 可售: TTL 到期或 Release
  已扣减 --> [*]: 关单回补等由订单域处理

13.3.4 营销校验

结算页调用营销 只读校验:判断券是否可用、圈品是否命中、互斥规则是否满足。不扣券。扣券放在订单创建事务路径(或订单 Saga 的下一步),避免「结算扣券成功、创单失败」带来的复杂回滚与客诉。

营销超时可 降级:隐藏优惠入口,以原价试算结果继续(需产品同意);若业务不允许无券结算,则应阻断。

券在结算与订单之间的「两段式」价值:结算阶段输出的是 可解释性(为什么这张券灰掉),订单阶段输出的是 事实(券批次余额少了一次)。中间没有第三段「半锁定券」,除非你单独引入锁券服务——那会把领域模型再劈一叉,一般不值得。

可选:有状态结算会话(复杂度权衡):默认仍建议无状态;若产品要求「刷新保留勾选与券」,需要额外持久化会话,并与预占 TTL、快照过期严格对齐,否则会出现「页面展示与提交凭证不一致」。表结构示例见 13.8.3。


13.4 拆单与地址运费

13.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

预览接口建议由 订单域 提供只读计算(与真正拆单共享规则内核),避免结算域复制一套拆单逻辑。

13.4.2 地址选择

地址列表由用户域或履约子域提供。结算页缓存默认地址 ID,切换地址时触发 运费重算 与可选的 试算重算(运费是否进快照取决于计价模型)。需防止用户用「切换地址」刷爆运费服务:对 (user_id, address_id, cart_hash) 做频控与短 TTL 缓存。

跨境与身份证 / 通关信息:若地址切换会触发额外字段(实名、税号),不要把敏感信息长期缓存在结算会话里;遵循最小留存原则,提交创单时一次性写入订单快照或合规存储。地址校验失败(不可达、风控拦截)应区分「硬失败」与「软提示」:硬失败直接阻断;软提示允许用户继续但要在支付前再次确认。

默认地址漂移:用户可能在结算过程中于「地址管理页」修改默认地址。结算服务应以 进入结算时锁定 address_id 为主策略;若产品要求实时联动,需要 WebSocket 或轮询刷新,并重新跑试算与预占,复杂度会迅速上升——这是有状态会话最容易踩的坑之一。

13.4.3 运费计算

运费计算输入至少包含:地址结构化信息店铺维度SKU 体积重量模板促销包邮规则。缓存 Key 示例:freight:{address_id}:{cart_hash},TTL 20~60 秒。购物车变更或地址变更必须使 cart_hash 失效。

运费与试算的关系要在一开始就写进契约:如果运费进入计价快照,则切换地址必须同时触发试算 + 运费;如果运费独立,则订单系统创单时也要携带运费版本号,否则会出现「结算看到 10 元运费、订单变成 12 元」的纠纷。B2B2C 下常见是 计价统一收口的应付金额 已含运费,这时地址服务只作为试算的输入因子,而不是第二套计算器。

拆单与运费的耦合:跨店场景下,预览接口宜返回 按店铺分组的运费数组,前端展示「每店一笔运费」;不要在前端把多段运费硬加成单一标量,否则与后续子订单对账困难。冷链、大件、送货上门加价等,可作为 运费模板扩展字段 由地址 / 履约服务解释,结算域只展示结果不做规则。


13.5 系统边界与职责

13.5.1 购物车域与结算域

能力购物车域结算域
暂存 SKU / 数量否(用购物车快照或请求体)
展示 Hydrate仅必要时复用
试算 / 快照否(可选展示价)
预占
营销扣减否(仅校验)

13.5.2 结算与订单

结算服务在提交阶段只做三件事:幂等闸门、组装创单请求、调用订单 Create。订单系统负责:真正拆单、写订单与明细、确认预占转扣减、扣券、发布 order.created 事件。结算服务不应写订单表,也不应持有订单状态机。

为何不能把「创单」继续留在结算服务里:短期看少一次 RPC,长期看你会得到「结算发布 order.created、订单服务也发布 order.created」的双头龙,消费者不知道以谁为准;更糟的是版本升级时,两个团队对「部分失败是否算创单成功」理解不一致,线上会出现只有结算库有记录、订单库没有的幽灵交易。边界一旦划给订单,就要让订单成为 订单事实的唯一写入者

BFF(Backend for Frontend)与结算编排器的分工:移动端 BFF 可以做字段裁剪、聚合多个读接口、甚至缓存用户地址列表;但不要把试算与预占藏在 BFF 里「顺便算一下」,否则 Web 与 App 会各自实现半套结算逻辑。推荐做法是 BFF 薄、结算编排厚、领域服务更厚。

13.5.3 资源锁定的归属

资源锁定发生地释放 / 确认
库存预占结算进入时TTL 自动释放;创单确认;失败显式释放
价格快照计价系统生成订单校验快照有效性
营销券未锁定订单创建时扣减

13.5.4 谁负责拆单预览

建议归属 订单域只读 API(或拆单内核库被订单服务托管)。结算域仅编排调用。若预览放在结算服务内,极易与履约变更耦合,出现「预览两单、创单变三单」的舆情风险——需版本化规则与免责声明。

反模式速查(评审清单可直接复用):

反模式为何糟糕正确方向
购物车预占库存长期占用,利用率差只在结算预占
购物车存成交价与促销回溯冲突行上不存价,展示时拉价
结算页扣券创单失败要回滚券订单扣券
结算页内嵌拆单履约变更面爆炸预览与真正拆单分离
结算服务写订单表双写一致性与职责越界只调订单 API

13.6 与其他系统集成

13.6.1 与订单系统衔接(提交订单)

创单请求应携带:idempotency_keyuser_idcart_itemsprice_snapshot_idreserve_idscoupon_idsaddress_idshipping_method。订单系统内部再驱动库存确认与营销扣减(详见第 14 章)。

边界:结算服务 不得 根据创单结果去修改订单状态;支付 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
}

13.6.2 与计价系统集成(价格试算)

结算只认计价返回的 price_snapshot_id 与过期时间;不在本地拼接促销表达式。

契约要点:试算请求应携带 场景枚举用户身份地址因子已选券列表购物车行;响应必须包含 可审计明细快照过期时间。结算服务侧禁止缓存「最终应付」超过秒级,否则与风控频控冲突。

13.6.3 与库存系统集成(库存预占)

调用 POST /inventory/reserve,设置 expire_seconds;保存返回的 reserve_ids[] 直至创单成功或失败释放。

幂等与重试:预占接口在超时重试场景下必须由库存侧保证 同一业务重放键 不产生双倍占用(常见做法是基于 user_id + checkout_tracerequest_token 去重)。结算侧则要把 reserve_ids 当作 opaque handle,不在本地推断库存数量。

13.6.4 与营销系统集成(优惠校验)

调用 validate-coupons;返回不可用原因用于前端提示。扣减走订单。

购物车域为什么不调用营销:购物车阶段引入营销,会把「意愿篮」变成「半个交易」,用户未表达购买意图就要承担券解释成本;更麻烦的是券规则与圈品频繁变更,购物车 Hydrate 会变成 O(N×规则) 的热点路径。

13.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不拆单、不推进状态机

13.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 流水线,但不能没有。


13.7 幂等性与去重

13.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
}

13.7.2 重复提交防护

三层组合:前端按钮禁用 + 请求级幂等键 + 订单唯一索引。仅依赖前端不可靠;仅依赖 Redis 可能因过期导致双单,因此 DB 唯一约束不可或缺。

移动端弱网:重试库(例如自动重放 POST)必须与业务幂等键协同,否则会在用户无感知的情况下放大写压力。建议移动端网络层对 写操作 默认关闭盲重试,或仅在收到明确可重试错误码时重放,并始终携带同一 Idempotency-Key

网关层去重与业务层去重的边界:API Gateway 可以做粗粒度 IP + path 频控,但不要把「业务幂等」全部交给网关规则引擎;网关不知道 reserve_ids 是否已被使用,也不知道订单是否已支付。网关负责 削峰,结算与订单负责 正确性

13.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)
}

13.8 工程实践

13.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 响应体前端重复渲染 造成的体感慢,这要靠前端性能与网关压缩共治。

13.8.2 转化漏斗监控

建议埋点维度:scene(搜索 / 活动 / 推荐)、deviceregion。核心比率:加购率、进入结算率、结算 Init 成功率、提交成功率、支付成功率。对 幂等拦截率 单独监控:异常升高可能意味着前端重复提交或网络重试策略错误。

分层漏斗与告警:除全站均值外,建议对 新客 / 沉默唤醒 / 高客单 分桶,否则会被大盘平均掩盖。告警上至少拆三条:结算 Init 错误率、预占失败占比、创单失败占比——三者根因不同,混在一个「下单失败率」里会排障困难。

与业务运营协同:漏斗面板应能下钻到 错误码分布(库存不足、券不可用、地址不可达、快照过期),否则运营只会看到「转化率掉了」,技术只会说「系统没挂」。把错误码映射到「可行动项」(补货、调整券门槛、修正运费模板)是平台化团队的工作方式。

13.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 购物车清理;预占释放巡检(备份补偿)。每一项都要有 可观测执行次数与失败率


13.9 本章小结

购物车与结算域分别回答 「想买什么」「现在能不能买」 两个问题:前者弱一致、不锁资源,以 Redis + DB 双写与匿名合并保障体验;后者以 Saga 编排把计价试算、库存预占、营销校验、地址运费组合为可提交凭证,并通过幂等键与补偿释放保证韧性。清晰划分 拆单预览 vs 真正拆单试算 vs 扣券结算 vs 订单状态机,是避免边界腐化的关键。

如果把全章压成三条工程戒律,它们分别是:购物车 never lock结算 always orchestrate with timeoutssubmit always idempotent end-to-end。前两条保证体验与资源利用率,最后一条保证「用户只点一次,系统只落一单」这一最低限度的交易正义。

与全书其他章节的衔接:库存预占细节见第 9 章;计价与快照见第 12 章;订单创建、拆单与状态推进见第 15 章;支付见后续支付章节。

最后一页检查清单(发布前自问):是否在 PRD 里写清了「价格以结算为准」;是否在接口契约里禁止购物车预占;是否在订单创单接口上强制 idempotency_key;是否为 release-reserve 写了幂等测试;是否在监控里拆分 Init 与 Submit 的成功率;是否为大促准备了预占 TTL 与线程池隔离参数。六项都打勾,这一章才算真正「从文章走进了系统」。若还能补充一页 故障演练剧本(依赖逐个超时、Redis 丢键、订单重复返回),团队在真实大促里会少很多「第一次见」的慌乱。


延伸阅读与引用

  • 本书第 4 章:Saga 与幂等通用模式。
  • 本书第 9 章:库存预占、确认与释放。
  • 本书第 12 章:试算场景与快照校验。
  • 本书第 15 章:订单创建与分布式事务实践。
  • 外部参考:Microsoft Azure Architecture Center — Saga pattern;Redis Hashes 文档。

落地阅读顺序建议:先读第 12 章理解「快照从哪来」,再读第 9 章理解「预占与确认的语言」,最后读第 15 章看「订单如何把券与库存变成事实」。本章处在三者的交汇处:最容易写成「什么都能调一点的脚本服务」,也最考验你是否坚持用 编排 + 凭证 + 幂等 把复杂度关在门内。读完若只能记住一句话,建议记住:购物车是缓存意志,结算是换取凭证,订单是写下事实——三者顺序不可倒置;任何把「事实」前移到购物车或结算持久层的 shortcut,都会在客诉与对账里连本带息还回来,务必警惕为好。

导航书籍主页 | 完整目录 | 上一章:第14章 | 下一章:第16章


第15章 订单系统

本章定位:订单系统是交易链路的编排中枢。本章在《电商系统设计(七):订单系统》基础上按书籍体例重组,覆盖数据模型、状态机、创单 Saga、分布式事务(TCC / Saga)、幂等、特殊单类型、履约与系统边界;Go 示例为教学裁剪版,落地时请补全观测、鉴权与错误包装。

阅读提示:若你已读完第 12 章计价与第 14 章结算,可带着三个问题阅读——第一,订单快照与计价快照如何对齐版本;第二,库存预占发生在结算还是创单,失败时谁补偿;第三,支付域与订单域的状态机如何解耦又不丢一致性。搞清边界后,订单服务才不会长成「上帝对象」。

与第 17 章案例的衔接:在 B2B2C 聚合平台中,订单往往还要承载 供应商订单号、供应商错误码、重试策略版本 等跨域信息;本章给出的是「平台自营电商」的主干模型,落地到机票、酒店等品类时,应把供应商差异收敛到 履约网关与扩展表,避免主表字段爆炸。第 17 章 17.5.3 从全景角度回顾订单在微服务拓扑中的位置,可与本章边界小节交叉阅读。


14.1 系统概览

14.1.1 业务场景

订单系统连接用户、商品、库存、计价、营销、支付、履约与售后,典型职责包括:

  • 创单编排:校验、拆单、落库、驱动库存 / 营销等资源侧执行;
  • 状态管理:主单状态机与子域(支付、履约、售后)协同;
  • 事件发布OrderCreatedOrderPaid 等驱动异步履约与数据分析;
  • 可追溯:行级快照、金额快照、状态流水满足审计与对账。

订单系统负责流程编排与订单事实的持久化;不负责库存原子扣减实现、支付渠道路由、物流轨迹计算——这些属于各自限界上下文。

从用户旅程看,订单是「一次购买意图」在系统中的物化载体:购物车表达意图,结算页收敛约束,订单把约束写成不可抵赖的事实(谁、在什么时间、以什么价格、买了什么、用了哪些权益)。因此订单域的 API 设计应偏向命令式与幂等CreateOrderCancelOrder),而不是把计价规则或库存脚本再暴露一遍。否则网关层会出现大量「为了创单临时拼出来的 DTO」,后续每次改营销口径都要联动发版。

在多租户或 B2B2C 场景下,订单还要携带租户、店铺、销售渠道等维度,这些字段会直接影响分库键、对账口径与发票主体。建议在模型早期就固定「主单维度集合」,避免后期把店铺 ID 塞进备注字段。对客服与风控而言,订单号是串联各系统的公共关联键,应在日志规范中强制全链路透传。

14.1.2 核心挑战

挑战表现设计抓手
高并发大促创单峰值、热点用户分库分表、异步削峰、限流熔断
一致性跨库存 / 营销 / 计价多写Saga + 补偿表 + 对账
状态复杂主状态 + 子流程并行子状态机 + 显式事件 + 审计日志
类型多样实物、虚拟、O2O、预售策略模式 + 扩展点
幂等重试、回调、用户连点幂等表 + 状态机 + 业务唯一键
可追溯客诉、风控、财务快照版本、Outbox、状态历史

组织层面的补充:订单团队常被拉去「顺便」做营销试算或支付路由,短期能救火,长期会造成循环依赖与发布耦合。建议在架构评审中把「编排」与「计算 / 执行」拆开画依赖图:订单只依赖稳定的 RPC 契约,不依赖对方存储模型。

容量视角:订单创建往往是洪峰「汇聚点」——上游购物车、结算已经做过一次编排,创单仍需在短时间内完成多次 RPC。此时瓶颈常在下游库存热键、营销锁券、DB 事务持锁时间。压测时应区分「创单接口自身 QPS」与「端到端成功率」,并单独观测 Saga 每步 P99补偿队列深度,否则容易出现「接口没超时但用户看到一直转圈」的体验问题。

一致性视角:订单域最容易犯的错误,是把「用户看到的状态」与「内部资源状态」混在一张表里频繁互转。更稳妥的做法是:主单状态只表达对用户承诺的阶段;资源是否释放、券是否退回,由子域与补偿任务保证,主单只记录结果事件。这样客服解释成本更低,报表口径也更稳定。

14.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 章的案例化深读材料。

阅读顺序建议:若时间有限,可优先精读 14.3、14.4、14.5、14.6、14.9、14.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 多副本,读副本延迟通过版本号或「刷新」按钮产品化,而不是在接口层偷偷强读从库。


14.2 订单数据模型

数据模型是订单域与周边系统对话的「共同语言」。表结构一旦轻率扩展,会在数年后以 报表口径漂移、对账困难、索引失效 的方式反噬团队。设计时建议坚持三条约束:第一,主表保持瘦——只放跨行、跨流程的汇总与阶段字段;行级细节、履约扩展、风控标签下沉子表。第二,金额字段语义单一——每个金额列在数据字典中绑定唯一业务定义(含税 / 不含税、含运费 / 不含运费),禁止复用同一列表达多种口径。第三,时间字段成体系——created_atpaid_atclosed_at 与状态机一一对应,避免用 updated_at 推断业务时刻。

在国际化与多店铺场景,模型还要提前容纳 税号、发票类型、买家身份(C 端 / B 端) 等字段,即使首期不上线,也应预留扩展位或使用 JSON 扩展表,以免后续 ALTER 大表锁死业务。与数据仓库的衔接上,建议把订单变更以 CDC 或领域事件 方式同步到 ODS,避免数仓直接扫主库大表。

14.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 与状态机双轨,会出现「状态已支付但记录被软删」的灾难组合。

14.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 变成「第二个代码仓库」。部分退款时,需能根据行级快照与分摊规则还原「可退金额」,这与第 11 章的分摊口径强相关。

赠品与换购:赠品行可标记 line_type=gift,金额为 0 但仍占用库存与履约单元;换购行则绑定主行 parent_line_id。建模时务必让履约与库存识别「最小履约单元」,否则会出现赠品未发但主单已完成的口径争议。

14.2.3 价格快照

价格快照解决事后解释问题:用户下单后商品改价,应以快照为准。实践中常并存两层:

  1. 计价中心快照(第 11 章):规则解释结果、分层明细、舍入版本;
  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,支付校验阶段只校验不重新全量试算(与第 11 章口径一致),避免渠道变化导致「可支付金额漂移」。

快照与客服工单:客服系统应能按 snapshot_id 拉取当时的展示文案与分层价格,而不是实时读商品中心。否则用户截图与后台看到的不一致,纠纷成本极高。若快照体积过大,可采用 对象存储 + 哈希引用,MySQL 仅存指针与版本。

多币种Currency 与汇率快照需同事务写入;支付若支持用户切换币种,应生成新的支付上下文而不是覆盖原订单金额字段,除非业务流程明确允许改价。

14.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。


14.3 订单状态机

14.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。若只有主单状态没有子单记录,排障时很难解释「用户看到支付成功但订单仍待支付」的短暂不一致窗口。

14.3.2 状态转换规则

显式白名单 表达合法迁移,禁止散落 if 隐式跳转。下表为示意矩阵(行:from,列:to, 合法)。

from \ toPendingPayPaidFulfillingCompletedCancelledAfterSale
PendingPay
Paid
Fulfilling
Completed视业务
Cancelled
AfterSale

回退约束:默认不允许 Paid → PendingPay;若支付风控冲正,应走 支付域事件 PaymentReversed,由订单编排显式迁移到 Cancelled 或特殊 PaymentFailed 终态,并触发资源回补 Saga。

并发与乱序:支付回调可能晚于用户主动取消;履约回调可能早于支付成功(脏数据)。统一策略是:任何迁移前读取当前主状态 + CAS;非法迁移记录审计并打指标,而不是「静默成功」。对疑似乱序消息可进入 延迟队列 二次投递,但要有上限避免永远悬挂。

超时关单PendingPay 超时迁移到 Cancelled 时,应同时触发 库存释放与营销解锁;若关单任务与支付回调并发,必须以数据库行锁或 CAS 决定只有一个赢家,失败方进入补偿或幂等返回。

14.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)
}

14.3.4 状态变更历史

历史表与主表 CAS 同事务提交;若需 对外通知,使用 Outbox 保证「日志落库 ⇒ 消息必达」。监控上应对 illegal_transition_totalfrom,to 聚合,异常飙升多为回调乱序或重复投放。

回放与对账:状态日志是订单域最重要的法务与财务证据链之一。导出报表时,应能按时间序重放 from → toevent,并与支付流水、物流轨迹交叉验证。若仅保存终态而无过程日志,出现「用户声称未收到货」时将难以举证。

测试策略:为 transitionTable 维护单元测试矩阵,覆盖所有 (from, event);对并发场景增加 模糊测试(随机交叉回调与关单任务)。生产灰度时,可短暂打开「非法迁移采样日志」定位历史脏规则。


14.4 订单创建流程

创单可拆四步:参数校验 → 资源侧扣减 / 锁定 → 订单持久化 → 异步事件

14.4.1 参数校验

  • 用户风控、地址、发票抬头;
  • SKU 可售、限购、黑白名单;
  • 计价快照版本与当前试算差异阈值(超限则拒绝创单提示刷新);
  • 营销资格(券批次、人群标签)。

校验分层:建议拆为 语法校验(网关)权限校验(用户会话)业务不变式校验(订单域)。不变式包括:行数上限、单用户并发创单上限、敏感 SKU 需二次验证等。不要把所有校验堆在单个「上帝函数」里,可按 Pipeline 组织,便于单测与观测每步耗时。

与结算一致性:若用户从结算页提交,创单请求应携带 结算会话 ID 或 booking_token(第 11 章),订单域校验其未过期且未被消费。否则会出现「结算页显示可买,创单失败」的合理但体验差场景——需用明确错误码驱动前端刷新。

拆单预览:若创单前已展示拆单结果,创单请求应携带 拆单版本号;服务端重新计算拆单,不一致则拒绝以保护用户预期。

14.4.2 库存扣减

与第 8 章对齐:创单常用 Reserve(预占)Deduct(直接扣),取决于品类 deduct_timing。失败快速返回,不必进入订单落库。

失败语义:库存返回 INSUFFICIENT_STOCKHOTKEY_THROTTLED 应对前端不同提示;后者可触发排队或重试策略。订单域不应把供应商超时简单映射为库存不足,以免误导用户。

跨仓:多仓 reserve 应按「子单顺序」或「固定 SKU 字典序」调用,降低死锁概率;任一子仓失败应整体失败并释放已成功部分。

14.4.3 营销扣减

营销侧提供 Lock / Deduct + Compensate 语义;订单携带 promotion_trace_id,保证券与积分操作可回滚。

券与积分顺序:若同时用券与积分,营销接口应保证 原子锁;订单侧避免先发两次 RPC 再本地补偿。常见实现是营销提供 BatchLockBenefits 单接口,内部保证事务。

风控联动:营销返回「疑似黄牛」时,订单应快速失败并记录 risk_trace_id,避免进入复杂 Saga 后再被风控拦截,浪费库存预占窗口。

14.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

与结算(第 13 章)边界:若结算页已预占库存,创单应携带 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 应协调,避免用户以为「已下单」实际草稿过期。草稿转正时仍需重新校验库存与价格,不可盲信草稿内缓存

拆单与父子单支付:一次支付覆盖多子单时,支付回调需携带 平台支付单号 → 子订单列表 的映射;若映射缺失,会出现一笔支付成功但部分子单仍处于待支付。建议在支付创建阶段由支付服务持久化该映射,订单服务只消费标准化结构。


14.5 分布式事务

14.5.1 TCC 模式

Try / Confirm / Cancel 三阶段,适合强一致资源预留,典型在支付(见第 15 章)。订单创单一般不强行 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)
}

14.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。

14.5.3 选型对比

维度TCCSaga
一致性更强,资源预留明确最终一致
改造参与方需三接口正向 + 补偿即可
适用支付、金融类创单、售后、长流程
复杂度中,高在补偿治理
flowchart TD
  Q1{资金 / 支付渠道是否核心参与方?}
  Q1 -->|是| TCC[TCC 或渠道原生两阶段]
  Q1 -->|否| Q2{步骤>3 且可接受中间态?}
  Q2 -->|是| SG[Saga 编排 + 补偿表]
  Q2 -->|否| LOCAL[单库事务 + Outbox 单步事件]

14.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 事件污染语义。


14.6 幂等性设计

14.6.1 幂等性原则

  • 技术幂等:HTTP 重试、唯一约束防双插;
  • 业务幂等:同一业务键多次提交得到同一业务结果(返回首单)。

14.6.2 实现方案

  1. 数据库唯一键idempotent_key
  2. Redis SETNX:短 TTL 防抖;
  3. 状态机短路:非法迁移直接返回成功或明确错误码;
  4. 业务外键order_id + coupon_id 唯一扣减记录。

处理中状态:幂等表应有 processing 态,防止客户端疯狂重试导致风暴;可配合 429 + Retry-Afterprocessing 超时由后台任务回收或允许用户重放(需业务决策)。

时钟与 TTLexpire_at 应略大于业务 SLA(如 24h),并定期清理历史幂等记录,避免表无限膨胀。清理前需确认该键不再可能被渠道重放。

14.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
}

支付回调三重防重(与第 15 章衔接):幂等表 + 主状态机 + Confirm 内部状态短路,缺一不可。只依赖状态机会在「重复回调但状态已前进」时误报失败;只依赖幂等表会在表损坏时放大风险。

监控:按 biz_type 统计幂等命中、冲突、处理中滞留;对 idem_conflict_rate 设定基线告警,异常上升通常意味着客户端重试策略变更或渠道重放策略调整。

客户端协作:移动端在弱网下会放大重试;应在 SDK 层统一生成 client_request_id 并持久化到本地,直到收到明确成功响应才清理。Web 端则可用 sessionStorage 暂存,刷新页面不丢失。服务端返回 409 + 已有订单号 优于静默成功,便于客户端跳转「订单详情」。

安全视角:幂等键不应可被枚举猜测;对匿名下单场景,应绑定 设备指纹或会话令牌,防止撞库式刷接口。对公开回调接口必须 验签 + IP 白名单 + Replay window


14.7 特殊订单类型

14.7.1 虚拟订单

无物流,支付后 即时履约(发券、开通权益)。状态机在 Paid 后可直接进入 Granting → Completed,失败重试必须 发放流水唯一约束

stateDiagram-v2
  [*] --> PendingPay
  PendingPay --> Paid: 支付成功
  Paid --> Granting: 触发履约
  Granting --> Completed: 权益到账
  Granting --> GrantFailed: 下游失败
  GrantFailed --> Granting: 重试 / 人工回放
  PendingPay --> Cancelled: 超时

资损防控:虚拟履约接口必须具备 可查询结果 能力(QueryGrantResult),以便在超时后判断是「已成功未回包」还是「确实失败」。否则重试可能造成重复发放,不重试又可能用户付款无权益。

与会员体系:若权益挂在账号维度,订单应记录 目标账号(本人 / 赠送人),避免客服手工改绑带来的审计缺口。

14.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 常出现「商家缺货部分退款」,需要行级部分退与状态机协同;主单可能仍处于履约中,财务上已发生部分结算,报表需单独建模。

14.7.3 预售订单

定金 + 尾款 拆两张支付单;库存使用 Reserve 锁定到尾款结束。尾款超时释放预订并关单。

stateDiagram-v2
  [*] --> PendingDeposit
  PendingDeposit --> DepositPaid: 定金成功
  PendingDeposit --> Cancelled: 未付超时
  DepositPaid --> PendingBalance: 开启尾款期
  PendingBalance --> BalancePaid: 尾款成功
  PendingBalance --> Cancelled: 尾款超时
  BalancePaid --> Fulfilling: 进入发货流程

财务与税务:定金可能不计入收入确认节点,尾款成功后才触发 ERP 凭证;订单模型应能区分 两笔支付子单一笔主单 的映射,避免对账系统把定金当全款。

库存语义:定金阶段常用 软预留(不占可售库存但占名额)或 硬预留(直接减可售),需与供应链共识;尾款失败释放策略必须可观测,否则大促会出现「幽灵占用」。


14.8 订单履约

14.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 子状态)。

14.8.2 供应商对接

对机票、酒店等供应商品类,履约即 供应商下单 / 出票 / 确认;需映射供应商返回码到统一 FulfillmentErrorCode,并支持 重试与人工工单

幂等与供应商:供应商接口常「同一请求重试返回同一确认号」,订单侧应保存 supplier_request_id 与响应指纹,避免重复下单造成双份资源。

降级:供应商大面积故障时,可暂停自动履约、进入 人工队列,并在订单详情展示「处理中」与预计时间,减少进线量。

14.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 离店)。这些差异最好通过 履约子状态机配置 注入,而不是复制一套订单主表。


14.9 系统边界与职责

14.9.1 订单系统的职责边界

负责:创单编排、主状态机、订单事实存储、领域事件、补偿编排入口。
不负责:支付渠道协议、库存原子脚本、营销规则解释、物流路由算法。

反模式清单:在订单库里维护「渠道费率表」、在订单服务里直连 Elasticsearch 做商品搜索、在订单进程里跑供应商长轮询——这些都会让订单成为最难部署的巨石。应通过 防腐层 + 专门服务 吸收。

14.9.2 订单 vs 支付:边界划分

订单提供 应付金额事实与支付上下文;支付服务生成 支付单 并对接渠道。回调默认进入 支付域,由其校验签名后调用订单 Application Service 推进状态,避免订单服务直接解析多渠道报文导致膨胀。

金额变更:除部分业务(邮费后补、税费调整)外,支付金额应以订单快照为准;若必须改价,应走 订单变更单 或关闭旧单重建,而不是在支付回调里悄悄改 payment_amount

14.9.3 订单 vs 物流:边界划分

订单保存 运单号快照 与履约状态;轨迹查询走物流查询服务。订单不应存储全量轨迹点。

异常协同:丢件、拒收、改址属于物流域流程,结果以事件通知订单;订单负责更新主状态与触发售后入口,而不是在订单服务内嵌物流公司客服规则。

14.9.4 订单作为编排者的角色

订单是 Process Manager:定义 Saga 顺序、超时、补偿策略;各子域提供幂等 API。编排者不缓存子域权威数据副本(除快照外)。

可观测性:编排者应输出 结构化 Saga 日志saga_idsteplatencyresult),并在 Trace 中把下游 Span 串为子节点,否则大促排障只能看「创单慢」而无法定位瓶颈环节。

14.9.5 订单 vs 履约:职责划分

订单给出「应履约什么」;履约服务决定「如何履约」:拆包裹、选承运商、对接供应商 API。虚拟品可将履约内嵌 Worker,但仍建议独立模块便于扩缩容。

售后交错:履约中发起售后时,应冻结部分发运或拦截未发商品;订单主状态进入 AfterSale 后,履约 Worker 需识别 继续履约 / 中止 的策略位,避免「已退款仍发货」的严重事故。


14.10 与其他系统的集成

14.10.1 与商品中心集成(快照读取)

创单只读 商品只读视图 + 快照构建器,禁止直接 join 商品运营库。快照哈希可复用第 7 章策略。

变更传播:商品标题、类目变更不应反写历史订单;若发生合规下架,应通过 风控事件 拦截新创单,而不是修改已落库快照。对 SEO 友好的长描述可不入库,仅保留客服需要的最小字段集以控存储。

14.10.2 与库存系统集成(扣减与回退)

统一使用库存暴露的 Reserve / Commit / ReleaseDeduct / Restore 语义;订单保存 reservation_id 便于超时释放。

支付后确认:若品类要求支付后才向供应商下单,创单阶段可能是 软占,支付成功后再 Commit;订单需记录两阶段 token,关单时只释放对应阶段。

14.10.3 与计价系统集成(价格快照)

创单请求携带 pricing_token,计价服务返回 不可变快照;订单落库字段与支付校验字段保持一致。

舍入与分摊:快照应包含 行级分摊结果尾差处理规则版本(第 11 章),否则部分退款时营销与财务会对不上。支付校验只验证「渠道应付 == 快照应付」在允许误差内。

14.10.4 与营销系统集成(锁定与扣减)

先锁后付:创单锁券,支付成功转实扣;关单 / 超时统一走营销回滚接口,携带 order_id 幂等。

平台与商家券:若一单混合出资,营销回滚需支持 按比例撤销;订单应保存 subsidy_split 片段,避免退款时无法拆分平台补贴与商家让利。

14.10.5 与支付系统集成(支付触发)

订单在 PendingPay 调用支付 创建支付单 API;之后状态迁移以支付回调为准,前端轮询仅作体验辅助。

前端轮询:轮询间隔应指数退避并设上限;服务端对同一用户并发轮询限流,防止把订单库打挂。更优方案是 SSE / WebSocket 推送支付结果,但仍要以回调为准。

14.10.6 与供应商系统集成(履约)

供应商网关隔离协议差异;订单只面向网关请求 CreateSupplierOrder,不感知对方 SOAP / REST 细节。

契约版本:网关应携带 供应商契约版本号,订单落库保存该版本,便于供应商升级后追溯「老单走老协议」。

14.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 并返回错误码

14.10.8 失败补偿与重试策略

  • 可重试错误(网络抖动):有限次重试 + 指数退避;
  • 业务拒绝(库存不足):立即失败,不做无意义重试;
  • 补偿失败:入补偿表,定时拉升 + 人工兜底;
  • 监控compensation_backlogsaga_step_latencyduplicate_callback_total

跨系统对账:每日按 订单支付成功总额 与支付系统、营销补贴账、供应商结算单进行三方抽样对账;出现差异时,以支付渠道流水为资金真相,以订单行快照为业务真相,营销与库存走补偿闭环。

集成测试建议:在 CI 中维护 合约测试(Pact 类)对库存 / 计价 / 营销的关键响应做快照校验;订单编排层的集成测试应覆盖「任一步失败」与「补偿失败入队」两条主线。


14.11 工程实践

14.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 号段批量领取;代价是需要容灾切换时的 跳号容忍 与对账。

14.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)
}

14.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 分区迁移演练,验证订单写路径在极端情况下的降级开关是否生效(例如暂停非核心消息投影)。


14.12 本章小结

本章从数据模型出发,强调订单主表、明细、快照与状态历史四位一体:主表表达用户可见阶段,明细绑定商品快照与分摊信息,计价快照锁定金额解释,状态日志提供审计证据链。在状态机层面,主单与子域(支付、履约、售后)协同,所有迁移走白名单 + CAS,乱序与并发通过幂等与行级竞争消解。

创单流程中,我们以 Saga 编排库存、营销与本地落库,以 Outbox 保证事件可靠投递;分布式事务层面区分 TCC(支付资金)与 Saga(长流程资源),并配套补偿优先级与人工工单。幂等贯穿创单、回调与履约,三重防重降低资损概率。特殊订单类型通过差异化状态机与策略扩展点接入,避免污染核心路径。履约侧则强调异步、幂等与供应商契约版本。

系统边界看,订单是交易编排者而非执行者:计价解释、库存原子、支付渠道、物流轨迹各有其主;订单负责把它们的结果事实串成可审计的业务故事。掌握这些原则后,阅读第 15 章支付系统时,可重点关注 支付单状态机如何回调驱动订单、以及 清结算如何与订单快照对齐

答辩提示:订单系统白板讲解顺序和追问应对已统一收录到附录B

演进建议:早期团队可用「单服务 + 清晰包边界」模拟限界上下文,待调用链路过长再拆 Order / Payment / Fulfillment。拆分时优先把 回调入口定时任务 迁出,因为它们最容易与核心创单抢资源。无论是否微服务,本章强调的 快照、状态机、Saga、幂等 四件套都仍然成立。

下一章将进入支付系统,重点讨论 渠道回调、清结算与资金安全,请保持「订单事实不变,支付驱动状态」的心智模型继续阅读。


导航书籍主页 | 完整目录 | 上一章:第14章 | 下一章:第16章

导航书籍主页 | 完整目录 | 上一章:第15章 | 下一章:第17章


第16章 支付系统

本章定位:支付系统是交易链路的资金收口外部渠道适配中心。本章在《电商系统设计(八):支付系统深度解析》的基础上,按书籍体例展开:分层架构、主链路时序、状态机、退款与营销核算、清结算与对账、幂等与一致性、系统边界与集成。文中 Go 示例为教学裁剪版,落地时请补全观测、鉴权、错误包装与依赖注入。

阅读提示:读本章时建议始终带着三个问题——谁拥有支付单的真相状态回调与主动查询以谁为准闭合差错入账如何可审计回滚。把这三个问题回答清楚,就能把「渠道差异」与「分布式一致性」从口号落到可执行的工程清单。

与全书脉络的关系:第 12 章计价给出「应付金额的合理解释」,第 14 章结算完成「资源锁定」,第 15 章订单沉淀「业务承诺」,本章则把承诺兑现为外部世界的扣款事实;第 17 章会把支付放回 B2B2C 聚合场景,观察供应商履约与渠道结算如何共同挤压支付边界。


15.1 业务背景

15.1.1 支付系统的定位

在电商交易链路中,订单系统负责「承诺与履约编排」,计价系统负责「金额解释与快照」,而支付系统负责把应付金额转化为真实资金转移(或渠道侧支付承诺),并把结果以可审计、可幂等、可对账的方式回写给订单、营销、财务等协作方。

支付系统通常同时服务三类角色:

  • 消费者(C 端):收银台展示、组合支付、支付结果通知、退款进度查询;
  • 商家与平台(B 端):结算、提现、分账视图、对账差错工单;
  • 内部中台:风控、财务核算、客服调账、数据仓库离线口径。

因此,支付系统既不是「订单的子模块」,也不是「财务系统的渠道壳」。更合理的边界是:支付域管理支付单、渠道交互、资金事实;订单域管理履约状态机;财务域管理会计科目与总账口径。三者通过事件与对账任务最终对齐。

交易形态看,实物电商更强调「支付成功 → 库存扣减 / 发货」的串联;虚拟商品与 B2B2C 聚合平台则更强调「支付成功 → 供应商下单 / 出票」的外部 API 依赖。支付系统不应把供应商履约细节写进支付单,但必须在扩展字段中保留可追溯的外联单号(例如渠道子商户号、分账接收方),否则一旦出现拒付或 chargeback,运营与风控很难在分钟级定位问题根因。

组织协作看,支付团队往往与财务、风控、客服、数据治理交叉。接口契约里最容易被忽略的不是「字段类型」,而是语义口径:例如「成功」到底指渠道受理成功、银行扣款成功,还是可结算到账。建议在支付域建立统一语言表(状态枚举、事件名、对账字段含义),并在评审门禁中与订单、财务双签,避免线上出现「订单已支付但财务未入账」的真空地带。

15.1.2 核心业务场景

场景说明工程要点
标准支付微信 / 支付宝 / 银行卡 / 余额等渠道路由、渠道限额、组合支付顺序
退款全额 / 多次部分退款可退金额闭合、营销资金回冲、渠道退款单号
清结算平台佣金、商家应收、营销补贴分账模型版本、结算周期、冻结与解冻
对账交易对账、资金对账、差错闭环文件解析幂等、长短款分类、人工复核 SLA
风控与合规限额、黑白名单、反洗钱报送(视地区)规则引擎、审计日志、敏感字段脱敏
争议与拒付chargeback、调单、凭证上传证据链留存、订单快照关联、工单闭环
企业采购对公转账、账期支付、开票衔接支付单与应收单映射、核销流程

场景串联示例:用户在结算页确认应付 199 元 → 订单系统生成待支付订单并携带计价快照 → 支付系统创建支付单并把金额与快照版本绑定 → 用户选择微信 → 网关路由到指定商户号与费率套餐 → 回调成功后 Outbox 投递 ORDER_PAID → 库存与营销在各自消费者内幂等确认。任何一步回滚都必须明确补偿顺序(先释放营销锁定还是先关支付单),否则会出现「钱没付但券没了」的资损路径。

15.1.3 核心挑战

维度具体问题典型技术回应
资金安全重复支付、伪造回调、金额篡改验签、幂等键、乐观锁、不可变流水
高并发大促峰值、热点商户限流、异步化、渠道 QPS 配额与降级
最终一致性支付成功与订单已支付不同步Outbox、本地消息表、补偿扫描
渠道异构字段、状态、回调时序不一致网关适配器、统一渠道模型、对账闭合
可运营性差错处理、调账、追溯工单系统、操作审计、双人复核
观测与排障渠道抖动、跨系统扯皮TraceID 贯通、支付会话回放、原始报文留存策略
合规与隐私PCI、卡号、证件信息最小采集、字段加密、密钥托管、脱敏展示

挑战之间的耦合需要提前说清:例如「极致性能」往往鼓励异步化与缓存,但「资金安全」又要求强审计与落库完整性;「渠道快速接入」若缺少网关隔离,会把各渠道 if-else 泄漏到支付核心,最终形成不可测试的巨石。折中办法通常是:热路径短事务 + 冷数据异步落盘 + 严格分层单测与对账兜底——性能靠结构与容量规划解决,而不是靠跳过幂等校验解决。


15.2 整体架构

15.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

15.2.2 支付网关

支付网关对外暴露统一的 CreatePaymentQueryPaymentRefundParseNotify 等接口,对内以策略 + 适配器组合消化渠道差异。网关层应内聚以下能力:

  • 渠道路由:按支付方式、费率、成功率、灰度比例、商户号维度选择具体适配器;
  • 报文转换:统一内部 ChannelCommand 与外部 API 字段映射;
  • 签名与加密:各渠道证书、公钥轮换、时钟偏移容错;
  • 超时与重试策略:仅对可安全重试的读操作与幂等写操作重试;
  • 观测:按 channelmerchant_noscene 打点,便于大促排障。

15.2.3 支付核心

支付核心维护支付单聚合根、状态机、流水与幂等索引。它不应直接调用「原始 HTTP 渠道 SDK」,而应依赖网关接口,从而保证领域模型不被渠道 JSON 污染。支付核心还应产出领域事件(如 PaymentSucceeded),通过 Outbox 保证与数据库状态同事务。

15.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),避免把渠道字符串透传到订单域。

子系统职责对照:支付系统职责对照和答辩口径已统一收录到附录B

数据落库建议

数据落库建议:支付单主表保持「窄」——只放状态、金额、币种、渠道标识、幂等键、版本号;大报文、扩展参数、渠道原始回调进入扩展表或对象存储,查询走异步索引。这样可以在大促时显著降低行更新放大效应,同时满足合规留存。


15.3 支付流程

主链路可以概括为:创建支付单(幂等)→ 渠道路由与预下单 → 用户交互 → 异步回调 / 主动查单 → 发布成功事实 → 下游投影。其中「回调」与「查单」是双保险:回调丢包时必须靠主动查询 + 补偿任务闭合。

15.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),让前端可以稳定切换到「轮询支付结果」而不是再次创单。

15.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}
}

灰度与容灾:路由层应能按百分比切流到新渠道、在新渠道错误率超阈值时自动回滚到旧渠道。此类「动态路由」必须有审计记录,否则财务对账会发现同一商户号在不同日期走了不同费率套餐却无法解释。

15.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
}

15.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(幂等)

15.4 支付状态机

15.4.1 支付状态

建议将支付单状态控制在可理解且可枚举的集合内。示例(可按业务增删):

  • PENDING:已创单,尚未唤起渠道;
  • PAYING:已唤起渠道,等待最终结果;
  • SUCCESS:支付成功;
  • FAILED:明确失败,可重新发起(是否允许换渠道由产品决定);
  • CLOSED:超时关单或业务关闭;
  • PARTIAL_REFUNDED:仍存在可退余额;
  • REFUNDED:已无可退余额(含全额退款累计闭合)。

15.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
}

15.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: 全额退款完成

15.5 退款流程

15.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()
}

15.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}
}

15.5.3 部分退款

每一次部分退款生成独立退款单,记录渠道退款单号。支付单维度的 refunded_amount 单调递增,直到等于 paid_amount 才进入 REFUNDED。若业务需要展示「第 N 次退款」,应对退款单列表做分页查询。

15.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: 异步冲销补贴 / 退券(可编排)

15.6 清结算与对账

15.6.1 分账模型

清结算层把单笔支付成功事实拆成多方应收应付:平台佣金商家货款渠道手续费营销补贴等。模型要点:

  • 分账版本:规则应版本化,支付单引用 split_rule_version
  • 最小粒度:通常到「子订单 / 明细行」级别,避免汇总误差;
  • 冻结与解冻:未到结算日期的资金先记入「待结算余额」,防止重复提现。

示例(简化,不含税与渠道费):用户实付 100 元,平台佣金 5%,商家货款 95%,营销补贴由平台另行记账,不重复从商家侧扣减。

角色口径金额(元)说明
用户实付100支付单记录
平台佣金5清结算生成应收
商家货款95进入待结算余额
渠道手续费按渠道账单往往单独维度对账

分账计算输入应来自支付成功事件 + 订单行快照,而不是实时去读商品中心促销价,否则会出现「支付按 A 规则、结算按 B 规则」的结构性差错。

15.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 由仓储层查询当日已提现金额后传入,避免示例函数隐式依赖未定义符号。

15.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

15.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)
}

自动修复只应对极少数确定性场景开放(例如「回调晚到导致短款」且查单已证实成功),且必须双人复核或二次审批,避免自动化把资金风险放大。

人工复核材料包应一键生成:本地流水、渠道流水、原始回调、查单响应、订单快照、客服沟通记录链接。没有材料包的差错工单往往会在团队之间空转数日,最后靠「某个老员工记得当时切了灰度」来收场——这是可复用的技术债。

长短款的业务含义也要培训到位:长款不等于立刻给用户加余额,短款也不等于立刻从商家扣回;它们首先是对账系统的待确认差异项,必须经过规则引擎与人工阈值判断,避免把运营操作变成新的资金风险源。


15.7 幂等性与一致性

15.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 命名、长度、字符集、过期策略),否则订单、支付、营销各自发明一套键,联调阶段会指数级爆炸。

15.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
}

退避与抖动:对渠道主动查询类任务,应加随机抖动,避免整点对渠道形成查询尖峰。对内部消息重试,应区分可重试错误(网络)与业务错误(余额不足),后者重试只会放大噪音。

15.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 长时间分叉不自知。


15.8 系统边界与职责

边界章节的判据很简单:如果某个需求改动会让支付团队与订单团队同时大改表结构,通常说明边界画错了。好的边界让「最常变」的渠道差异停在网关,让「最不该变」的资金状态机停在支付核心。

15.8.1 支付系统的职责边界

支付系统应拥有:支付单与退款单渠道交互与回调验签支付侧流水与幂等索引触发清结算批次的事实渠道对账原始凭证关联。不应拥有:订单履约、物流、商品库存数量真相(除非余额支付与账户强绑定)。

反模式清单已统一收录到附录B

15.8.2 支付 vs 订单:谁负责什么

主题订单域支付域
应付金额解释引用计价快照校验快照与支付单金额
支付状态PAID 等业务状态SUCCESS 等资金状态
关单 / 取消驱动是否允许继续支付执行关单并同步渠道撤销(若支持)
售后退款策略是否允许退、退多少(业务规则)执行资金退回与累计已退
发票与税务展示订单展示口径提供支付流水号、渠道单号

关单竞态:用户支付最后一秒订单被取消,或支付成功回调晚于关单。必须在订单状态机定义终态优先级(例如「已支付优先于待支付关闭」),支付侧也要能识别「订单已关但支付已成功」并进入异常工单而不是静默吞掉。

15.8.3 支付 vs 财务清结算

支付系统产出资金事实与分账明细,财务系统将其映射为会计凭证税务口径。不要在支付库直接记总账。

技术团队常低估的点:财务需要期间币种折算信息,而支付系统常只存「展示币种」。若平台做多币种,应在支付成功事件中固化清算币种与汇率来源,否则月末调账会演变成跨团队扯皮。

15.8.4 平台支付 vs 第三方支付渠道

平台支付(余额、礼品卡)往往走账户系统闭环,仍需流水与对账;第三方支付走渠道。组合支付要定义失败回滚顺序(例如先渠道后余额或相反),并在状态机里显式建模。

部分成功是组合支付的最大坑:渠道成功、余额扣减失败如何处理?常见策略是:先扣内部可控资源,再调渠道(降低外部不可控失败面),或在产品层直接禁止某些组合。无论哪种,都要写进用户可见的错误文案与客服话术。

15.8.5 支付 vs 钱包 / 余额

余额属于预付价值,涉及充值、提现、冻结、监管要求(视地区)。建议独立「账户子域」,支付核心通过账户服务完成扣减,避免把账户表与支付单表强耦合在同一张宽表。

钱包若支持「零钱 + 银行卡」混合,仍建议把零钱视作内部渠道走同一套路由与对账框架,这样运营监控可以统一看「渠道成功率」,而不是另起炉灶一套报表。


15.9 与其他系统的集成

集成章节的共同目标是:把支付系统变成可替换、可观测、可回滚的协作节点,而不是「所有系统都要在支付回调里串一圈」的上帝节点。

15.9.1 与订单系统集成(状态同步)

订单系统应订阅 ORDER_PAID 事件或通过同步 API(弱不推荐)更新状态。无论哪种,订单更新接口必须幂等:重复 payment_id 不应推进到非法状态。

推荐事件载荷至少包含:order_idpayment_idpaid_amount_fencurrencypricing_snapshot_versionpaid_at。订单侧据此做二次校验(金额是否与创单快照一致),不一致进入人工工单而不是静默成功。

15.9.2 与营销系统集成(补贴核算)

营销补贴如果是支付时分账,需要明确分账参与方与失败重试;如果是事后结算,支付成功事件应携带可被清结算消费的补贴分解标识。

若营销侧需要「支付成功后才真正扣减预算」,必须定义失败回滚语义:支付关单时发送 PAYMENT_CLOSED,营销消费者释放锁定;若营销扣减失败但支付已成功,应进入异步补扣或人工处理,绝不能反向把支付单改成失败。

15.9.3 与用户系统集成(余额 / 积分)

余额支付应走 Deduct -> ConfirmTry -> Confirm/Cancel 的可补偿路径,并与支付单状态机关联。积分抵现建议由订单 / 计价域先行锁定,支付成功后再确认扣减,失败则释放。

余额账户建议提供可查询的冻结单号与支付单关联,客服排障时可以直接回答「这笔钱对应哪笔冻结」。积分系统若延迟较高,应避免在支付回调线程同步等待。

15.9.4 与第三方支付渠道集成

渠道集成要点:证书轮换时钟同步回调 IP 白名单沙箱与生产隔离配置。网关层提供模拟器(mock)支撑联调。

生产环境还需准备:渠道公告订阅(费率、维护窗口)、密钥到期提醒多商户号容灾(主商户异常时切备用)。渠道 SDK 升级应走灰度,并用回放样本验证验签与解析路径。

15.9.5 与财务系统集成(分账与对账)

向财务导出结算批次明细行,并保证金额字段为整数分、附带币种与汇率快照。任何手工调账必须留下审计记录。

财务更关心会计期间科目映射:技术侧输出应携带 biz_datesettlement_batch_idmerchant_idfee_item。避免让财务同学从 JSON 大字段里手工抠数。

15.9.6 集成异常处理与重试

对下游失败应区分:可重试(网络抖动)不可重试(业务拒绝)需要人工(数据不一致)。消息消费者应使用幂等处理表或业务唯一键防重复消费。

典型异常:订单服务短暂不可用 → Outbox 堆积;解决思路是扩容消费者、限流非核心订阅、并对核心 ORDER_PAID 单独 topic 保障 SLA。另一类异常是订单返回成功但内部逻辑部分失败(例如库存服务超时)——这属于订单域自己的 Saga,支付侧不应「自作主张退款」,除非产品明确配置自动拒单策略。

15.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})
	})
}

15.10 工程实践

15.10.1 多渠道接入

新渠道接入建议清单:沙箱对齐用例集、字段映射表、对账文件样本、异常码枚举、回调重放工具、灰度开关(按商户 / 百分比)。

建议为每个渠道维护兼容性矩阵:API 版本、最低 SDK 版本、已知缺陷列表(例如某版本退款接口延迟)。上线前用「同一批黄金用例」在沙箱回放,避免只在 happy path 自测。

15.10.2 性能优化

热点路径:创单读多写少可用缓存;回调写路径应短事务,只更新必要列;大字段(原始报文)异步落对象存储。避免在回调线程同步调用多个下游。

数据库层可对 payment(status,updated_at)refund(payment_id,status) 建立合适组合索引;对 channel_trade_no 建立唯一索引支撑幂等。大促前做容量评估:预估回调峰值、写入 QPS、消息投递延迟,并准备只读副本承载客服查询。

15.10.3 监控告警

最低限度指标:notify_latencynotify_fail_ratepaying_timeout_countoutbox_backlogrecon_open_issuesrefund_unknown_count。每条指标应能下钻到 payment_id

告警阈值建议分层:页面级(影响用户支付成功率)、资金级(对账差异、短款)、运维级(证书到期、磁盘满)。资金级告警必须带跳转链接到工单或 Runbook,减少 On-call 临场检索成本。

15.10.4 资金安全

原则:最小权限密钥双人复核调账不可变审计日志敏感信息脱敏展示关键操作二次验证。技术方案之外,运营流程同样是系统的一部分。

建议每年至少进行一次红队演练或渗透测试,覆盖伪造回调、重放报文、越权查询他人支付单等路径;密钥使用 KMS / HSM 托管,开发人员默认不应接触生产明文私钥。


15.11 本章小结

本章从业务背景出发,给出了支付平台的分层架构图,并以时序图贯穿「创单 → 路由 → 回调 → Outbox 同步订单」的主链路;用状态机图约束支付单生命周期,并补充退款单状态联动;在清结算与对账部分给出分账示例、T+N 口径区分、对账 join 思路与差错闭环流程图;在幂等与一致性部分拆分创建、回调、退款、消息消费等幂等键,并给出指数退避重试、补偿任务调度、Saga + 本地消息表与 TCC 选型边界;最后通过系统边界对外集成回扣订单、营销、用户、渠道、财务协作中的异常分层与回调幂等清单。

若把本章压缩成上线前检查表,可以只保留八条:唯一幂等键验签先于业务状态机集中校验成功响应晚于提交Outbox 同事务消费者幂等对账可回放密钥与权限最小化。这八条都做到,未必能保证「永不故障」,但能保证故障可定位、可止血、可复盘

把支付系统做好,本质上是在持续回答一句话:在不可靠的网络与不可控的第三方之上,如何让用户与平台都相信「这笔钱的状态是真的」。下一章将进入全书综合案例(第17章),从平台视角回看支付在整体架构中的位置与演进路径。


导航书籍主页 | 完整目录 | 上一章:第15章 | 下一章:第17章

导航书籍主页 | 完整目录 | 上一章:第16章


第17章 B2B2C平台完整架构

综合案例:一个中大型B2B2C电商平台的完整架构设计,从品类分析到技术选型,从系统设计到团队协作,覆盖200+人团队、日订单200万级的实战经验与架构决策。


16.1 项目背景与业务约束(Business Context)

本章讨论的是一个中大型 B2B2C 聚合电商平台。它不是传统实物电商,也不是单一供应商商城,而是连接多类外部供应商与自营虚拟商品的平台型系统。平台侧负责商品组织、搜索导购、价格试算、营销、下单、支付和履约编排;供应商侧负责实际资源确认与数字履约。

这个背景很重要。因为系统的核心复杂度不来自物流仓配,而来自多品类、多供应商、强实时交易和不一致外部接口的叠加:机票和酒店要求零超卖,充值和礼品卡允许失败后补偿,电子券又依赖本地券码池发放。不同品类背后的库存、价格、履约模型完全不同,直接决定后续的领域划分、服务边界和一致性策略。

16.1.1 业务定位

平台采用“聚合供应商 + 自营虚拟商品”的 B2B2C 模式,连接航司/GDS、酒店 OTA/PMS、运营商、院线、券码供应商等外部系统,同时保留部分自营业务能力。

业务范围

业务类型典型品类履约方式关键特征
供应商聚合机票、酒店、充值、电影票调用供应商 API 完成出票、确认、充值、锁座接口差异大,实时性和可用性依赖外部系统
平台自营优惠券、线下券、礼品卡平台本地发券码或调用内部发码系统可控性更强,但需要券码池、核销和过期管理
数字履约所有品类API 调用、异步确认、电子凭证发放无物流链路,但交易状态和补偿链路更复杂

业务全景图

B2B2C 数字商品平台业务全景

这张图展示了平台业务链路的四个关键视角:供应商供给侧负责资源供给与数字履约;平台核心能力负责商品、库存、价格、营销、订单、支付、履约和售后编排;本地商家与平台运营负责商品录入、审核、维护、促销和上下架;C 端用户则围绕搜索导购、结算下单、支付、履约结果和退款售后形成完整交易闭环。

本平台的一个关键前提是无物流场景。所有商品都是虚拟数字商品,履约不经过仓库、分拣、配送,而是通过 API 调用、电子票、确认单、充值结果或券码完成。因此,本章不会讨论仓配、物流轨迹、签收等实物电商问题,而是聚焦数字商品平台中更核心的四类问题:

  1. 供应商差异:50+ 外部供应商接口形态不一致,可能同时存在实时查询、定时同步和事件推送。
  2. 库存差异:机票/酒店依赖供应商实时库存,电子券依赖本地券码池,充值类商品近似无限库存。
  3. 价格差异:机票动态定价、酒店日历价、充值固定面额、优惠券固定折扣价并存。
  4. 履约差异:有的品类同步返回结果,有的品类需要异步确认,有的品类需要本地分配唯一券码。

16.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 天规则引擎、计价输入标准化、活动配置平台

16.1.3 本章的核心架构命题

在上述业务背景下,架构设计的难点不是“要不要拆微服务”,而是如何在品类差异、供应商不确定性和交易一致性之间找到可演进的边界

本章后续会围绕四个问题展开:

  1. 如何理解品类差异:机票、酒店、充值、电子券为什么不能用同一套库存和履约模型。
  2. 如何划分系统边界:订单、商品、库存、计价、营销、支付、供应商网关各自拥有怎样的数据和职责。
  3. 如何组织交易链路:搜索、详情、结算、下单、支付如何在性能与准确性之间取舍。
  4. 如何沉淀架构决策:通过 ADR 记录关键取舍,避免团队在同一问题上反复争论。

16.2 品类业务模型分析(Business Architecture)

不同品类的业务模型存在显著差异,直接影响架构设计决策。理解这些差异是系统设计的基础。

16.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秒

16.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小时缓存
  • 价格变更:主动失效缓存

16.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
}

16.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("券码池热队列需要回填")
}

16.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. 可扩展性:新增品类只需新增策略,不影响主流程

16.3 DDD战略设计与系统边界(Application Architecture - 设计过程)

基于16.2的品类业务分析,本节展示如何运用DDD战略设计方法,从业务领域识别限界上下文、划分系统边界、设计服务间集成方式,最终形成16.4的整体架构全貌。

16.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

过大:多个不相关的聚合根在一个上下文 ❌
• 导致上下文职责不清晰
• 团队协作困难

16.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批量查询商品价格

16.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存储、消息、监控业务规则

16.4.2 微服务拆分

拆分原则

  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

16.4.3 服务依赖关系

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. 降级友好:下游故障不影响上游核心功能

16.4.4 数据流转

同步数据流(关键路径)

用户搜索商品:
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秒

16.4小结

以上展示了系统的整体架构全貌:四层架构、12个核心微服务、服务依赖关系、数据流转模式。这些是16.3战略设计的具体落地——12个限界上下文对应12个微服务,上下文映射关系决定了服务间的集成方式。

接下来16.5节将讨论技术选型决策,16.6节将深入各个系统的详细设计。


16.4 整体架构设计(Application Architecture - 设计结果)

基于16.3节识别的12个限界上下文和上下文映射关系,本节展示如何将它们落地为具体的架构设计:四层架构、微服务拆分、服务依赖关系、数据流转模式。

16.3 → 16.4的映射关系

16.3 限界上下文           →    16.4 微服务
├─ 订单上下文             →    Order Service
├─ 商品上下文             →    Product Center
├─ 库存上下文             →    Inventory Service
├─ 计价上下文             →    Pricing Service
├─ 营销上下文             →    Marketing Service
├─ 支付上下文             →    Payment Service
├─ 搜索上下文             →    Search Service
└─ 供应商上下文           →    Supplier Gateway

16.3 上下文映射           →    16.4 服务集成
├─ Customer-Supplier      →    同步RPC调用
├─ Anti-Corruption Layer  →    适配器模式
└─ Published Language     →    Kafka事件

16.4.1 分层架构

采用经典的四层架构,确保职责清晰、易于维护。

基于前面识别的限界上下文和映射关系,本节通过实际案例展示如何划分边界、重构边界。

案例1:计价系统的边界重构

初始问题

  • 价格计算逻辑分散在订单、营销、商品三个域
  • 购物车、订单创建、支付确认三处价格计算不一致
  • 无法支持“PDP加购试算“场景

重构方案

  1. 新建计价上下文:职责是提供统一的试算接口
  2. 定义边界
    • 计价上下文不拥有商品基础价、营销规则、订单状态
    • 对外提供 Calculate(items, promotions, context) -> PriceBreakdown
    • 各场景通过统一接口获取价格
  3. 收益
    • 价格一致性得到保证
    • 营销规则变更只需在营销域发布事件
    • 支持了试算、价格预览、价格审计等新需求

案例2:库存预占的归属

争议:库存预占应该放在订单域还是库存域?

决策:放在库存域

理由

  • 库存域拥有库存数据所有权
  • 预占是库存的一种状态(可售 → 预占 → 扣减)
  • 订单域只需调用库存域的 Reserve 接口
  • 降低耦合:订单域不需要了解库存的存储结构

16.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
}

16.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
}

16.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实现替代真实供应商

16.5 技术选型决策(Technology Architecture)

16.5.1 选型原则

原则1:成熟度优先

  • 优先选择生产级成熟技术(避免踩坑)
  • 社区活跃、文档完善、案例丰富
  • 避免使用 alpha/beta 版本

原则2:团队能力匹配

  • 技术栈与团队技能对齐
  • 学习曲线可控(新技术培训 < 1个月)
  • 有内部专家支持

原则3:生态完整性

  • 工具链完善(测试、监控、部署)
  • 第三方库丰富
  • 云服务支持(AWS/GCP/阿里云)

原则4:成本可控

  • 开源优先(降低License成本)
  • 云服务按需使用(避免自建中间件)
  • 运维成本可接受

16.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
}

16.4.3 数据库选型

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秒

16.4.4 中间件选型

Kafka(消息队列)

场景TopicPartitionReplication
订单事件order-events163
库存事件inventory-events83
日志采集logs322

Consul(服务发现)

  • 健康检查:HTTP/TCP/gRPC
  • 配置中心:动态配置热更新
  • KV存储:Feature Flag

Envoy(Service Mesh)

  • 流量管理:灰度发布、A/B测试
  • 可观测性:自动生成Trace
  • 安全:mTLS加密

16.6 核心系统设计(Application + Data Architecture详细设计)

基于16.4的整体架构,本节深入每个核心系统的详细设计,包括应用层的业务逻辑设计和数据层的模型设计。

16.6.1 商品中心设计

16.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 服务”,而是数字商品平台交易前链路的核心系统。它统一商品定义、库存能力和搜索导购能力,屏蔽供应商商品模型差异,对外稳定输出商品、库存、搜索结果和能力配置,并通过事件机制驱动营销、计价、订单和履约系统协同。


16.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. 供应商差异通过映射和防腐层隔离:商品中心只保留平台模型和供应商映射,不让供应商字段污染主模型。

这一节的核心结论是:商品中心真正统一的是经营表达和交易前契约,而不是统一所有品类的实时资源状态。这是数字商品平台避免商品模型失控的关键。


16.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. 动态价格和实时库存不进商品主表:商品主表保存稳定定义,动态报价、房态、座位库存通过缓存、供应商查询和订单快照处理。

16.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 负责身份和关系,扩展表负责高频业务字段。


16.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 = 实时返回

这类商品的可售单元不是固定面额,而是“用户输入账号后形成的一次账单支付上下文”。因此商品中心只保存账单机构、输入规则、手续费规则和履约契约,具体账单金额进入计价上下文和订单快照。


16.6.1.6 商品供给与运营链路

商品供给与运营链路解决的是“商品如何进入平台,以及上线后如何被持续维护”的问题。供应商同步本质上属于供给链路,但它不是唯一入口。更准确的划分是:

商品供给与运营治理平台
  ├─ 人工创建/上传:运营/商家从 0 到 1 创建商品
  ├─ 批量导入:文件、模板、批量任务导入商品和配置
  ├─ 运营编辑:标题、图片、类目、价格、库存、上下架、退款规则变更
  └─ 供应商同步:外部供给数据全量/增量/Push/刷新

这四类入口应该进入同一个“供给治理控制面”,共享任务模型、校验、审核、发布版本、Outbox、补偿和可观测性;但供应商同步因为存在长任务、Checkpoint、Raw Snapshot、Worker 租约、DLQ、数据新鲜度等复杂问题,可以单独展开成 16.6.1.7 和附录案例。

相关答辩判断已统一收录到附录B

如果这条链路设计不好

如果这条链路设计不好,问题会很快暴露到 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 → 发布接口不稳定、模型不一致、新鲜度、长任务失败独立同步链路,见 16.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 不完整时不能提交
审核证据完整审核员看到的是标准化后的商品快照、字段来源、风险命中和历史版本
创建后不等于上线发布成功后还要等待库存初始化、索引刷新、可售校验通过

人工创建链路的答辩提示已统一收录到附录B

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
  → 数据新鲜度

所以本章采用“主链路 + 专项链路”的写法:16.6.1.6 讲统一商品供给与运营治理平台,完整设计见附录G:商品供给与运营治理平台16.6.1.7 专门讲供应商同步,因为它有长任务恢复、外部数据追溯和供应商质量治理等额外复杂度。

本节答辩总结已统一收录到附录B

16.6.1.7 供应商商品同步链路

16.6.1.7 供应商商品同步链路

供应商同步链路解决的是“外部供给数据如何进入平台,并持续保持可用、可信、足够新鲜”的问题。它不是简单的定时任务,也不是把供应商字段原样搬进商品表,而是一个完整的数据治理链路:接入外部数据、适配不同协议、完成平台模型映射、校验数据质量、生成发布版本、刷新搜索缓存、通知下游系统,并在失败时可追踪、可补偿、可人工修复。

从系统边界上看,它属于 16.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 和监控指标,见附录F:供应商数据同步链路

图中可以看到,供应商数据进入平台后会经过五个阶段:

供应商数据源
  → 接入适配与同步任务
  → 标准化、质量校验、平台模型映射
  → 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,高风险数据不发布,进入人工修复或补偿队列。

供应商同步失败治理的答辩提示已统一收录到附录B

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 的答辩总结已统一收录到附录B

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商户、品牌、面额、有效期、核销规则本地券码池库存或供应商券码状态更偏平台自营库存,重点是券码池和核销状态

供应商同步整体答辩总结已统一收录到附录B

16.6.1.8 库存与可售设计

16.6.1.8 库存与可售设计

在本平台中库存没有独立拆服务,而是由商品中心内部的库存与可售域承接。这里的关键不是把库存字段放进商品主表,而是建立统一的库存抽象,屏蔽不同品类的库存来源差异。

库存类型典型品类库存来源处理方式
无限库存Topup、Bill无明确库存或供应商容量足够只做可售规则与供应商可用性校验
池化库存Voucher、Gift Card平台券码池或本地库存支付后分配券码,库存不足时停止售卖
实时库存Flight、Hotel、Movie供应商接入层 / 外部供应商实时查询搜索展示可缓存,下单前必须实时确认

统一可售判断

可售 = 商品状态可售
    + 类目/站点/渠道可售
    + 库存满足
    + 供应商可用
    + 风控/业务规则允许

库存相关动作包括查询、预占、释放、扣减、回补和对账。对于 Flight/Hotel/Movie 这类实时库存,商品中心更多是统一入口和状态判断,真实资源确认仍然要通过供应商网关或供应商接入层完成。


16.6.1.9 搜索与导购设计

由于搜索也由商品中心负责,商品中心不仅要管理商品数据,还要负责把商品组织成用户可浏览、可搜索、可筛选、可点击的前台体验。

搜索导购链路

首页入口/类目导航
  → 列表页搜索与筛选
  → ES 召回
  → 排序与过滤
  → Hydrate 商品信息、库存、展示价、营销标签
  → 返回列表页
  → 详情页聚合

关键设计点

能力设计重点
首页入口入口配置、类目分组、排序、发布快照、CDN/Redis 缓存
ES 索引商品主数据、类目、实体、Tag、可售状态、运营排序字段
Hydrate搜索只返回候选 ID,详情信息、库存、价格、营销标签统一补齐
缓存热门商品、本地缓存、Redis、索引快照结合使用
降级价格不可用时展示基础价,营销不可用时隐藏标签,库存不可用时弱提示
新鲜度L 页允许缓存,D 页更接近实时,创单必须实时校验

搜索导购的答辩总结已统一收录到附录B


16.6.1.10 跨系统集成与事件设计

商品中心位于交易前链路,需要向多个系统输出稳定契约。

下游系统商品中心输出对方使用方式
营销系统类目、Tag、商品范围、业务实体、可营销状态圈品、活动配置、券可用范围
计价中心基础价、类目、属性、库存上下文、能力配置PDP 价格、结算试算价、下单价、结算价
订单系统商品快照、上下架状态、可售校验、库存预占结果创建订单前校验与订单快照落库
履约系统履约类型、供应商映射、履约参数出票、充值、发券、预订确认
供应商网关/供应商接入层平台 SKU、供应商映射、同步任务上下文查价、查库存、同步、履约调用
数据平台CDC、商品变更日志、质量监控数据经营分析、质量报表、异常发现

核心事件

事件触发时机典型消费者
ProductCreated商品创建成功搜索索引、营销、数据平台
ProductUpdated商品字段变化搜索索引、缓存刷新、质量监控
ProductOnShelf商品上架搜索、营销、推荐
ProductOffShelf商品下架搜索、订单前校验、运营看板
StockChanged库存变化搜索导购、告警、数据平台
SupplierSyncFailed供应商同步失败告警、运营后台、任务补偿

事件发布建议使用 Outbox 或可靠消息机制,避免“商品已更新但事件丢失”导致搜索、营销、计价数据不一致。


16.6.1.11 架构取舍与经验总结

1. 为什么没有独立拆库存中心和搜索中心?

这是团队规模和演进阶段下的取舍。库存和搜索都与商品强相关,早期拆成独立服务会增加团队协作、接口维护和数据一致性成本。把它们放在商品中心内部,可以减少跨服务调用,提高迭代效率。但内部必须按域隔离,避免主数据、库存状态和搜索索引混成一团。

2. 为什么不用一张大宽表?

大宽表短期开发快,但会很快变成 80+ 字段、15+ 品类混杂、字段语义不清的“大泥球”。更好的做法是:稳定字段进主表,可检索字段进属性表,展示型差异进 ExtInfo,高频品类能力进入扩展表。

3. ExtInfo JSON、EAV、扩展表怎么取舍?

方案适合场景不适合场景
ExtInfo JSON低频展示字段、品类专属配置高频查询、筛选、排序
EAV 属性表可搜索、可筛选、可分析属性强事务、高频更新字段
扩展表高频访问的品类核心字段低频字段和一次性配置

4. 为什么 Flight 不沉淀完整 SKU?

机票报价由日期、航线、航班、舱位、乘客类型、供应商策略共同决定,价格和库存变化太快。如果把每次报价都沉淀成 SKU,会产生巨量临时 SKU,且数据很快过期。更合理的是商品中心维护城市、机场、航司等基础资源,搜索和创单实时请求供应商。

5. 为什么 Hotel 要静态信息和动态房态房价分离?

酒店名称、地址、设施、图片相对稳定,适合同步到商品中心并进入搜索索引;房态和房价按日期、间夜、人数实时变化,适合缓存刷新和下单前实时确认。两者分离可以兼顾搜索性能和交易准确性。

6. 如何避免商品中心变成“大泥球”?

关键是三条边界:内部按六个域拆分,数据库按主表/属性/扩展/映射/日志拆分,对外用 API 和事件契约隔离。即便物理上是一个商品中心,逻辑上也要保持清晰边界,为未来拆分成商品中台、库存中心、搜索中心留下演进空间。


16.6.1.12 实现落地参考:DDD 分层与代码组织

前面 16.6.1.2 到 16.6.1.11 讨论的是商品中心的业务架构和数据架构。本节保留一个简化版 DDD 代码落地参考,用于说明 Product 聚合根、Repository、接口层、事件订阅和缓存如何组织。它不是上述完整商品中心的全量实现,而是帮助读者理解“架构如何落到代码”的最小示例。

16.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 输出交易前上下文,订单保存交易快照,履约系统执行交付,售后系统执行退款规则

领域模型设计思想:商品域的特点是“树形结构+读多写少“,与订单域的“复杂状态机+高并发写“完全不同。

16.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
}
16.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
}
16.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
}

16.6.1.12.5 基础设施层(Infrastructure Layer)
16.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编码查询

16.6.1.12.5.2 缓存策略

详见下文“16.6.1.12.8 三级缓存实现“。


16.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定价事件(价格计算完成)

详见 16.6.1.12.7.4 事件订阅者的分层设计


16.6.1.12.6 接口层(Interface Layer)
16.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;  // 下架
}
16.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 # 下架
16.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负责)

详见 16.6.1.12.7.4 事件订阅者的分层设计


16.6.1.12.7 应用服务层(Application Layer)
16.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

16.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)
}

16.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"
}

16.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 (持久化)

示例代码:参见 /ecommerce-book/example-codes/product-service/ 完整 Demo


16.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结构)

16.6.1.12.9 完整示例代码 ⭐️

本章节的完整代码实现(包含 DDD 四层架构、事件发布订阅、三级缓存等)详见:

示例代码路径/ecommerce-book/example-codes/product-service/

这部分示例代码不是完整生产系统,而是为了帮助读者把前面的架构设计映射到工程结构中。它重点覆盖商品中心的 DDD 分层、聚合根、Repository、缓存、事件发布订阅和接口适配。前面讨论的供给运营、供应商同步、库存可售、搜索导购等能力,在真实项目中会继续扩展为更多应用服务和任务模块;示例工程先保留最小可读骨架,避免把主线淹没在实现细节里。

目录结构

example-codes/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 ecommerce-book/example-codes/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 的商品模型差异

16.6.2 库存系统设计

二维库存模型(参考16.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("库存不足")
}

16.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)

16.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
}

16.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
}

16.7 完整业务链路(系统集成与数据流)

从子系统到完整链路:前面章节展示了各个子系统的内部设计(商品中心、库存、订单、支付、供应商集成),本章展示这些子系统如何协作,形成端到端的业务链路。

两条关键链路

  • B端链路(供应商 → 运营 → 平台):商品生命周期管理,决定“商品如何进入、如何管理、如何退出“
  • C端链路(用户 → 交易 → 履约):交易流完整路径,决定“用户如何发现、如何下单、如何完成支付“

集成视角的关键点

  • 数据流转:跨系统的数据传递(事件驱动、同步调用、异步任务)
  • 状态同步:多系统间的状态一致性(商品状态、库存状态、订单状态)
  • 异常处理:跨系统的容错与补偿(Saga、重试、降级)

16.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%商品数据、订单数据

16.7.2 C端交易流完整链路

交易流是电商的核心价值链,从用户搜索到完成支付的完整路径。本节展示五个阶段的设计与集成。

与B端链路的对比

维度B端链路(16.7.1)C端链路(16.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%

16.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",
                })
            }
        }
    }
}

16.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%)
  • 价格变化拦截率(创单价格校验触发频率)

16.10 高可用与性能优化(Infrastructure & Operations)

16.7.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)
}

16.7.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;

16.7.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)
    }
}

16.11 团队组织与协作(Organization & Governance)

16.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

16.8.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开发
- ✓ 已完成:与订单服务集成
- ✓ 已完成:灰度上线

16.8.3 技术治理

代码评审清单

  • 是否符合分层架构(依赖方向正确)
  • 是否有单元测试(覆盖率 > 80%)
  • 是否有集成测试(核心路径)
  • 是否有性能测试(Benchmark)
  • 是否有监控指标(Prometheus Metrics)
  • 是否有日志(结构化日志)
  • 是否有文档(API文档、设计文档)
  • 是否考虑降级方案

技术债管理

## 技术债清单

| 优先级 | 类型 | 描述 | 负责人 | 预计工作量 |
|-------|------|------|--------|-----------|
| P0 | 性能 | 订单查询慢查询优化 | @张三 | 2天 |
| P1 | 安全 | 支付回调签名验证 | @李四 | 1天 |
| P2 | 代码 | 商品中心重复代码重构 | @王五 | 3天 |

16.12 上线与演进(Deployment & Evolution)

16.12.1 上线策略

分阶段上线

阶段1:基础功能(2周)
• 商品中心、库存服务、订单服务
• 支持机票、酒店两个品类
• 单机房部署

阶段2:营销功能(2周)
• 营销服务、计价服务
• 支持优惠券、活动

阶段3:新品类(每周1个)
• 充值、电影票、优惠券、礼品卡

阶段4:多机房(4周)
• 双机房部署
• 流量灰度切换

16.9.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. 下线老版本

16.9.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

16.9.4 系统演进路径

已完成

  • ✅ 基础架构搭建(微服务、服务发现、监控)
  • ✅ 核心品类上线(机票、酒店、充值)
  • ✅ 营销系统(优惠券、活动)
  • ✅ 双机房部署

进行中

  • 🚧 性能优化(P99延迟 < 200ms)
  • 🚧 新品类接入(电影票、礼品卡)
  • 🚧 供应商扩展(50+ → 100+)

规划中

  • 📅 国际化(多语言、多币种)
  • 📅 推荐系统(AI推荐)
  • 📅 智能客服(NLP)
  • 📅 区块链溯源(高端商品)

16.13 经验总结(Lessons Learned)

16.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自动运行契约测试

16.10.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分钟)
  • 请求参数校验(前置拦截非法请求)

16.10.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%流量

16.14 本章小结(Chapter Summary)

本章通过一个中大型B2B2C电商平台的完整案例,展示了从业务分析到技术落地的全过程,是全书知识点的综合实践验证。本章不仅覆盖了架构方法论(第1-6章),还深入展示了**供给运营系统(第11章)C端核心交易流(第12-16章)**的完整实现,真正做到了“理论→实践→落地“的闭环。


核心要点回顾

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. 供给运营是平台的核心能力(新增16.5.6)

三种核心场景

场景业务语义处理逻辑审核策略
商品上架新商品首次进入平台Create完整审核流程
供应商同步供应商数据变更Upsert差异化审核
运营编辑已上线商品维护Update差异化审核

设计要点

  • 幂等性保证:task_code唯一索引(上架)、sync_id唯一索引(同步)
  • 差异化审核:高风险变更(价格变化>50%、类目变更)必须审核
  • 批量操作:异步任务 + 进度追踪(100+ SKU批量编辑)
  • 状态机:DRAFT → PENDING → APPROVED → PUBLISHED
  • 与商品中心集成:审核通过后写入商品中心、初始化库存/价格

7. C端交易流贯穿整个业务链路(新增16.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战术设计落地实践(新增16.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战略设计(16.5.8)、CQRS读写分离
第2章(领域驱动设计)12个限界上下文划分、上下文映射、防腐层(16.6.4)
第3章(代码整洁)策略模式(品类策略)、适配器模式(供应商集成)、SOLID原则
第4章(质量保障)ADR(13个决策)、代码评审清单、测试策略
第8章(商品中心)SPU/SKU模型、类目属性、商品快照
第9章(库存系统)二维库存模型、预占机制、超时释放(16.5.2)
第10章(营销系统)营销规则引擎、优惠券锁定、最优解求解
第11章(供给运营)商品上架、供应商同步、运营编辑(16.5.6 新增)
第12章(计价系统)四层价格模型、试算接口、快照生成
第13章(搜索导购)Query→Recall→Rank→Hydrate链路(16.5.7 新增)
第14章(购物车结算)未登录加购、登录合并、Saga编排(16.5.7 新增)
第15章(订单系统)状态机、Saga模式、幂等性(16.5.7、16.5.8 新增)
第16章(支付系统)支付创建、回调处理、对账流程

后续演进提示:如果后续继续扩展本书,可以在本章基础上继续展开系统演进与重构、团队协作与工程实践等主题。


给读者的建议

  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%单元测试 + 集成测试

导航返回目录 | 上一章:第16章 | 书籍主页

附录A 技术栈选型指南

附录B 面试题精选

使用说明

本附录为《电商系统架构设计与实现》的配套面试准备资料,包含 123 道主线精选题 / 案例78 道章节补充题,合计 201 道明确题目,重点关注电商核心系统的架构设计、工程实现和面试答辩。

题型标注

  • 📊 系统设计题: 需要设计架构、数据模型、分析流程
  • 🔧 技术方案题: 需要给出具体技术选型和实现思路
  • 💡 场景分析题: 给定业务场景,分析问题并提出解决方案
  • 🚀 综合案例题: 跨系统的复杂场景,考察全局架构能力

答案结构

每道题的答案包含:

  1. 问题分析:核心挑战是什么
  2. 方案设计:2-3种可选方案
  3. 方案对比:trade-offs分析
  4. 推荐方案:结合实际场景的建议
  5. 延伸思考:相关的深入问题

难度说明

本附录的题目均面向中高级工程师和架构师(3-5年以上经验),重点考察:

  • 对电商业务的深度理解
  • 分布式系统设计能力
  • 技术方案权衡能力
  • 实战经验和坑点认知

如何使用

面试准备:

  • 按章节顺序学习,每题都尝试独立思考后再看答案
  • 重点关注“方案对比“和“推荐方案“部分
  • 结合书中章节深入理解背景知识

自我评估:

  • 完成一章后,挑选相关题目进行测试
  • 对比答案找出思考盲区
  • 关注“延伸思考“拓展知识面

团队讨论:

  • 选择综合案例题进行技术分享
  • 讨论不同方案的适用场景
  • 结合实际项目经验交流

面试官版题库导航

201 道题不应该按顺序逐题使用。真实面试要先判断候选人的层级,再选择不同深度的问题。建议把题库分成四层:

层级用途适合候选人面试目标
必问题判断是否具备电商后端基本盘中高级后端、资深后端看边界、状态、一致性、幂等、缓存和交易安全是否扎实
深挖题判断是否做过真实复杂业务资深后端、技术骨干看是否能讲清失败、补偿、异步任务、运营后台和可观测性
专家题区分架构师和技术负责人架构师、TL、平台负责人看是否能处理多系统边界、长期演进、组织协作和成本权衡
备选题库按项目背景补充追问所有候选人根据简历方向选择搜索、支付、营销、供应商同步、库存等专项

题量总览

部分内容题量
第一部分电商架构基础20
第二部分商品与库存管理43
第三部分交易核心链路50
第四部分综合实战案例10
第五部分章节补充面试题与答辩话术78
合计主线题库 + 章节补充题201

第五部分里的答辩话术、表述模板和技术速查不计入 201 道明确题目。

核心必问题清单

如果只做一轮 60-90 分钟面试,优先从下面这些题里选。它们能快速判断候选人是否真的做过电商核心系统,而不是只背通用分布式八股。

能力域推荐题目重点看什么
系统边界1.1-1 服务拆分、1.1-6 微服务粒度、1.1-9 DDD 应用能否按业务语义拆边界,而不是按表或接口拆服务
一致性1.2-1 订单库存一致性、1.2-3 同步 vs 异步、1.2-6 可靠投递、1.2-8 对账补偿是否理解本地事务、Outbox、幂等、补偿和对账
商品中心2.1-1 SPU/SKU、2.1-3 搜索不一致、2.1-7 上架流程、2.1-9 商品快照是否能区分正式主数据、流程对象、快照和派生读模型
库存系统2.2-0 库存创建、2.2-0B 生命周期联动、2.2-1 超卖、2.2-4 预占释放、2.2-8 并发更新是否能讲清库存事实、预占、账本、券码池和可售投影
计价营销2.3-1 价格引擎、2.3-2 优惠券、2.3-5 秒杀活动、2.3-8 组合促销是否能区分基础价、优惠计算、营销预算和结算价
搜索导购3.1-1 搜索架构、3.1-4 多维过滤、3.1-8 性能优化、5.10 搜索边界追问是否理解 ES 只是派生读模型,交易不能信索引
购物车结算3.2-1 购物车存储、3.2-2 价格计算、3.2-4 库存校验、3.2-9 结算流程是否能把购物车、结算页、计价、库存和订单边界拆清
订单支付3.3-1 状态机、3.3-5 并发创建、3.3-6 Saga、3.4-1 支付架构、3.4-2 回调幂等是否理解订单状态机、支付单、回调幂等、资金安全和补偿
商品供给5.2-1 供给平台边界、5.2-13 Publish 流程、5.2-20 下游一致、5.2-21 生命周期库存联动、5.2-22 库存运营任务是否理解供给控制面、发布版本、库存任务、营销协同和可售闭环
供应商同步5.3-1 长任务设计、5.3-4 Worker 抢占、5.3-9 任务互斥、5.3-15 MySQL DLQ是否理解外部供给治理、Checkpoint、租约、Raw Snapshot 和 DLQ

面试路径建议

60 分钟资深后端面试:

10 分钟:项目背景和候选人真实职责
15 分钟:订单 / 库存 / 支付一致性
15 分钟:商品中心 / 供给平台 / 库存创建边界
10 分钟:缓存、搜索、Outbox 或异步任务深挖
10 分钟:线上故障、监控、补偿和复盘

90 分钟架构师面试:

15 分钟:业务架构与限界上下文
20 分钟:商品、库存、计价、营销、订单的事实归属
20 分钟:发布、上线、可售、Outbox、可售投影
15 分钟:大促、供应商同步、批量任务和 DLQ
10 分钟:成本、演进、组织协作和技术债
10 分钟:候选人反问与方案复盘

商品 / 库存专项面试:

商品中心:2.1-1、2.1-3、2.1-7、2.1-9、5.1-1、5.1-5
库存系统:2.2-0、2.2-0B、2.2-1、2.2-4、2.2-8、2.2-14
供给运营:5.2-1、5.2-13、5.2-20、5.2-21、5.2-22、5.5-20

评分标准

面试官不要只看候选人是否能背出“缓存、MQ、分库分表”。更重要的是看他能否在约束下做边界和取舍。

评分维度合格表现优秀表现
业务边界能说清系统职责能说明事实归属、控制面 / 数据面、读模型 / 写模型
状态建模能画基本状态机能区分流程状态、生命周期状态、交易状态和补偿状态
一致性能说出 MQ、事务、重试能落到 Outbox、幂等键、版本、对账、DLQ 和人工修复
数据模型能给出核心表能解释唯一键、索引、分片、快照、历史版本和审计字段
高并发能讲缓存和限流能讲热点、降级、预热、削峰、隔离队列和读写路径分离
可运营性能说有后台能设计任务进度、错误文件、可售诊断、补偿入口和审计链路
风险意识能识别超卖、重复支付能识别资损、历史订单解释、营销成本、供应商脏数据和人工误操作

常见减分点:

  1. 把商品供给平台讲成商品表 CRUD。
  2. 把库存当成一个 stock 字段,不讲预占、释放、账本和幂等。
  3. 把 Redis、ES、MQ 当权威事实源。
  4. 订单回读最新商品、价格、履约规则解释历史交易。
  5. 发布事务同步调用所有下游,忽略最终一致和补偿。
  6. 只讲技术组件,不讲运营修复、错误文件、DLQ 和审计。

第一部分:电商架构基础(20题)

1.1 系统全景与架构设计(10题)

📊 题目1:如何设计一个中大型电商平台的服务拆分边界?

问题描述: 假设你接手一个日订单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. 服务间调用的超时和重试策略如何设计?

🔧 题目2:如何设计电商系统的数据一致性方案?

问题描述: 电商系统中,订单创建涉及库存扣减、优惠券核销、积分扣除等多个操作,这些操作分散在不同的微服务中。如何保证数据一致性?

答案

问题分析: 数据一致性的核心挑战:

  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. 如何进行一致性验证和对账?

💡 题目3:大促期间如何保证系统稳定性?

问题描述: 公司准备参加双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. 如何验证限流降级策略是否有效?

📊 题目4:设计电商系统的多机房多活架构

问题描述: 电商平台需要支持异地多活,要求在任一机房故障时,业务可以快速切换到其他机房继续提供服务。请设计多机房多活方案。

答案

问题分析: 多机房多活的核心挑战:

  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. 如何验证多活架构的有效性?

🔧 题目5:如何设计服务间的调用链路追踪?

问题描述: 微服务架构下,一个用户请求可能经过十几个服务。当出现问题时,如何快速定位是哪个服务出了问题?请设计分布式追踪方案。

答案

问题分析: 链路追踪的核心挑战:

  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. 追踪数据如何与日志、指标关联?

💡 题目6:如何平衡微服务拆分的粒度?

问题描述: 在进行微服务拆分时,服务拆得太粗会失去微服务的优势,拆得太细会导致运维复杂度爆炸。如何平衡服务拆分的粒度?

答案

问题分析: 服务粒度的核心挑战:

  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. 服务拆分后如何保证数据一致性?

📊 题目7:电商系统的技术债治理策略

问题描述: 随着业务快速发展,系统积累了大量技术债(如代码重复、过度耦合、缺少测试)。如何在保证业务持续交付的前提下,有效治理技术债?

答案

问题分析: 技术债治理的核心挑战:

  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. 如何说服业务投入资源治理技术债?

🔧 题目8:如何设计配置中心?

问题描述: 微服务架构下,配置分散在各个服务中,修改配置需要重启服务。请设计一个配置中心,支持配置的集中管理和动态更新。

答案

问题分析: 配置中心的核心挑战:

  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. 如何保证配置变更的安全性(防止误操作)?

💡 题目9:领域驱动设计在电商系统中的应用

问题描述: 你需要用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在微服务中如何落地(一个聚合根一个服务?)?

📊 题目10:设计电商系统的监控告警体系

问题描述: 电商系统涉及十几个微服务,需要建立完善的监控告警体系。请设计监控方案,确保能及时发现和处理线上问题。

答案

问题分析: 监控告警的核心挑战:

  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. 监控数据如何存储和查询(时序数据库)?

1.2 系统集成与一致性(10题)

📊 题目1:设计订单与库存的一致性保证方案

问题描述: 在订单创建流程中,需要同时扣减库存。订单服务和库存服务是两个独立的微服务,如何保证订单创建和库存扣减的一致性?

答案

问题分析: 订单库存一致性的核心挑战:

  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. 秒杀场景下如何优化库存扣减性能?

🔧 题目2: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的测试策略?

💡 题目3:如何选择同步调用vs异步消息?

问题描述: 在微服务架构中,服务间通信可以使用同步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. 如何在同步和异步之间切换(如异步改同步)?

🔧 题目4:分布式事务的几种实现方案对比

问题描述: 请对比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的补偿操作如何保证一定成功?

📊 题目5:设计事件驱动架构

问题描述: 电商系统需要实现事件驱动架构,当订单状态变更时,自动触发物流、消息通知、数据统计等下游操作。请设计事件驱动方案。

答案

问题分析: 事件驱动架构的核心挑战:

  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)?

🔧 题目6:如何保证消息的可靠投递?

问题描述: 在消息队列(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. 如何实现消息的延迟投递?

💡 题目7:幂等性设计的最佳实践

问题描述: 在分布式系统中,由于网络重试、消息重复等原因,同一个请求可能被处理多次。如何设计幂等机制,保证重复请求不会产生副作用?

答案

问题分析: 幂等性的核心挑战:

  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. 幂等状态如何跨服务共享?

📊 题目8:设计数据最终一致性的对账补偿机制

问题描述: 在分布式系统中,虽然采用了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. 如何设计对账结果的可视化展示?

🔧 题目9:如何处理分布式系统的时钟问题?

问题描述: 在分布式系统中,不同服务器的时钟可能不同步,导致时间戳不一致、时序错误等问题。如何处理分布式系统的时钟问题?

答案

问题分析: 分布式时钟的核心挑战:

  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. 时钟跳跃(突然快进)如何处理?

💡 题目10:微服务间的版本兼容性设计

问题描述: 微服务架构下,服务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的版本兼容如何保证?


第二部分:商品与库存管理(43题)

2.1 商品中心系统(16题)

📊 题目1:设计支持多品类的SPU/SKU数据模型

问题描述: 电商平台需要支持实物商品(服装、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. 跨类目搜索时如何统一属性映射?

🔧 题目2:商品详情页的缓存架构设计

问题描述: 商品详情页是电商系统访问量最大的页面,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. 大促时如何做缓存容量规划?

💡 题目3:如何解决商品信息变更后搜索不一致问题?

问题描述: 运营修改了商品标题和价格,但搜索结果中仍然显示旧信息。这是典型的最终一致性问题。如何设计商品到搜索的数据同步方案?

答案

问题分析: 商品搜索一致性的核心挑战:

  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同步性能?

🔧 题目3 扩展:直接订阅 Binlog 同步 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. 全量重建和增量同步同时发生时,如何避免旧增量写到新索引?

📊 题目4:设计商品类目体系和属性管理

问题描述: 电商平台有上千个类目(如手机、服装、食品),每个类目有不同的属性(手机有内存、颜色,服装有尺码、材质)。如何设计类目体系和属性管理系统?

答案

问题分析: 类目属性管理的核心挑战:

  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. 跨类目搜索时属性如何映射?

🔧 题目5:商品图片的存储和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. 图片存储成本如何优化?

💡 题目6:虚拟商品vs实物商品的设计差异

问题描述: 实物商品需要物流配送,虚拟商品(如充值卡、会员)是即时发货。两者在系统设计上有哪些差异?

答案

问题分析: 虚拟商品的核心差异:

  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. 虚拟商品是否需要支持退款?

📊 题目7:商品上架流程的工作流设计

问题描述: 商品从创建到上架需要经过多个环节(信息录入、图片上传、价格设置、审核)。请设计商品上架的工作流系统。

答案

问题分析: 商品上架工作流的核心挑战:

  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. 审核规则如何动态配置?

🔧 题目8:如何支持商品的多规格选择(颜色、尺码等)?

问题描述: 服装类商品有多个规格(颜色、尺码),用户需要先选择规格再下单。如何设计商品规格和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. 如何优化规格选择的用户体验?

💡 题目9:商品快照在订单中的应用

问题描述: 用户下单后,商家可能修改商品标题、价格、图片。为了避免纠纷,需要在订单中保存商品快照。请设计商品快照方案。

答案

问题分析: 商品快照的核心挑战:

  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. 如何设计快照的版本兼容?

📊 题目10:设计商品推荐系统的架构

问题描述: 电商平台需要在详情页、列表页、首页展示个性化推荐商品。请设计商品推荐系统的架构。

答案

问题分析: 推荐系统的核心挑战:

  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. 如何保护用户隐私(不过度使用用户数据)?

🔧 题目11:商品搜索的倒排索引设计

问题描述: 搜索引擎的核心是倒排索引。请说明电商商品搜索的倒排索引如何设计,包括分词、索引结构、查询优化等。

答案

问题分析: 倒排索引的核心要点:

  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. 搜索结果如何排序(相关性、销量、价格)?

💡 题目12:如何处理商品数据的历史版本?

问题描述: 商品信息会不断变更(价格调整、标题修改、图片更换)。为了审计和纠纷处理,需要保留商品的历史版本。如何设计商品版本管理?

答案

问题分析: 商品版本管理的核心挑战:

  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. 大批量商品版本查询如何优化?

📊 题目13:多租户场景下的商品数据隔离

问题描述: 在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. 如何支持租户级别的功能开关?

🔧 题目14:商品导入的批量处理优化

问题描述: 商家需要批量导入商品(一次导入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. 如何设计商品导入的幂等性?

💡 题目15:商品审核流程的设计

问题描述: 商家上传的商品需要经过审核才能上架(防止违规商品)。请设计商品审核系统,包括机审和人审。

答案

问题分析: 商品审核的核心挑战:

  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. 如何防止商家恶意提交违规商品?

2.2 库存系统(17题)

🔧 题目0扩展:库存是怎么创建出来的?

问题描述: 很多库存系统只讲扣减、预占和释放,但真实业务里库存首先要被创建出来。有的 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. 酒店或门店预约类库存,未来多久的日历切片应该提前物化?

🔧 题目0扩展B:库存如何和商品供给运营平台、商品生命周期联动?

问题描述: 作为一个长期做电商平台的工程师,不能只讲库存扣减。商品从供给入口进入平台、经过审核发布、上线、下架、结束销售、售后核销,库存系统应该如何和商品供给运营平台以及商品生命周期联动?

答案

核心判断是:商品发布不等于商品可售,审核通过也不等于库存 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. 下架后已有预占订单是否继续履约,谁来仲裁?

📊 题目1:设计防止库存超卖的方案

问题描述: 电商大促时,热门商品库存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. 如何处理订单取消后的库存回补?

🔧 题目2:如何设计分布式库存系统?

问题描述: 电商平台有多个仓库(北京、上海、深圳),商品在不同仓库有不同库存。如何设计分布式库存系统?

答案

问题分析: 分布式库存的核心挑战:

  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. 如何处理商品跨仓拆单(一单多仓发货)?

💡 题目3:大促场景下的库存预热和削峰方案

问题描述: 双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. 如何应对恶意刷单占用库存?

📊 题目4:库存预占与释放的设计

问题描述: 用户加入购物车或进入结算页时,需要预占库存,防止其他用户抢走。但如果用户不支付,需要释放库存。如何设计库存预占机制?

答案

问题分析: 库存预占的核心挑战:

  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. 如何设计库存预占的监控指标?

🔧 题目5:如何设计库存的分级管理(前台可售vs仓库实际)?

问题描述: 仓库实际库存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. 已售库存发货后如何核减?

💡 题目6:库存扣减失败的补偿机制

问题描述: 在订单创建流程中,扣减库存可能失败(并发冲突、网络超时、服务故障)。如何设计补偿机制,保证数据一致性?

答案

问题分析: 库存扣减失败的核心场景:

  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. 如何监控补偿任务的执行情况?

📊 题目7:设计库存盘点系统

问题描述: 仓库需要定期盘点库存,核对系统库存和实际库存是否一致。如何设计库存盘点系统?

答案

问题分析: 库存盘点的核心挑战:

  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. 如何设计移动盘点的离线模式?

🔧 题目8:如何处理库存的并发更新?

问题描述: 多个订单同时扣减同一商品库存,如何处理并发冲突,保证库存不超卖?

答案

问题分析: 并发更新的核心场景:

  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数据不一致如何恢复?

💡 题目9:虚拟库存vs实物库存的差异

问题描述: 实物商品有物理库存限制,虚拟商品(如充值卡、游戏币)可以无限生成。两者在库存设计上有什么差异?

答案

问题分析: 虚拟库存的核心特点:

  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. 供应商配额不足时如何处理?

📊 题目10:多仓库场景下的库存分配策略

问题描述: 电商平台有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. 仓库之间如何协同(库存调拨、应急支援)?

🔧 题目11:如何设计库存安全水位和补货机制?

问题描述: 电商系统需要设置库存安全水位,当库存低于安全水位时自动触发补货。如何设计这套机制?

答案

问题分析: 库存安全水位的核心要素:

  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. 新品如何设置安全库存(无历史数据)?

💡 题目12:库存快照在订单中的应用

问题描述: 订单下单时需要记录当时的库存状态,用于售后和数据分析。如何设计库存快照机制?

答案

问题分析: 库存快照的核心目的:

  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. 大批量商品的快照如何优化存储?

📊 题目13:库存的实时性vs一致性权衡

问题描述: 库存系统中,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理论在库存系统中如何权衡?

🔧 题目14:库存回滚机制的设计

问题描述: 用户下单后未支付,或者订单取消,需要回滚库存。如何设计库存回滚机制,保证幂等性和正确性?

答案

问题分析: 库存回滚的核心场景:

  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. 大批量订单取消如何优化回滚性能?

💡 题目15:跨境电商的库存管理(多国库存)

问题描述: 跨境电商在中国、美国、欧洲都有仓库,同一商品在不同地区有库存。如何设计全球库存管理系统?

答案

问题分析: 跨境库存的核心挑战:

  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. 不同国家的退货政策如何影响库存管理?

2.3 营销与计价系统(10题)

📊 题目1:设计支持多种促销规则的价格计算引擎

问题描述: 电商平台有多种促销(满减、折扣、优惠券、满赠、阶梯价),用户下单时需要计算最终价格。如何设计灵活的价格计算引擎?

答案

问题分析: 价格计算的核心挑战:

  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. 促销规则变更如何保证已下单的订单价格不变?

🔧 题目2:优惠券系统的设计

问题描述: 电商平台需要支持优惠券(满减券、折扣券、品类券)。如何设计优惠券系统,包括发放、使用、核销?

答案

问题分析: 优惠券的核心要素:

  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)?

💡 题目3:阶梯价和批发价的设计

问题描述: 电商平台支持批发场景,购买数量越多价格越低(如买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. 大促期间阶梯价如何调整?

📊 题目4:会员等级和积分体系的设计

问题描述: 电商平台有会员体系(普通、银卡、金卡、钻石),不同等级享受不同权益(折扣、包邮、专属客服)。如何设计会员和积分系统?

答案

问题分析: 会员体系的核心要素:

  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测试)?

🔧 题目5:秒杀活动的价格和库存设计

问题描述: 秒杀活动商品价格远低于平时,流量集中,如何设计秒杀的价格和库存系统,保证不超卖且性能可控?

答案

问题分析: 秒杀的核心挑战:

  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. 秒杀流量如何监控和应急处理?

💡 题目6:动态定价系统的设计

问题描述: 电商平台希望实现动态定价(如机票、酒店根据供需实时调价)。如何设计动态定价系统?

答案

问题分析: 动态定价的核心要素:

  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. 如何设计价格保护机制(买贵退差价)?

📊 题目7:跨境电商的汇率和税费计算

问题描述: 跨境电商需要处理多币种和不同国家的税费。如何设计汇率转换和税费计算系统?

答案

问题分析: 跨境价格的核心要素:

  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. 跨境税费如何合规申报?

🔧 题目8:组合促销的价格计算(满减+折扣+券)

问题描述: 用户下单时同时享受满减(满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测试?

💡 题目9:预售和定金膨胀的设计

问题描述: 预售活动中,用户支付定金(如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分析?

📊 题目10:价格歧视与个性化定价的设计

问题描述: 电商平台希望根据用户画像(新老用户、购买力、价格敏感度)实现个性化定价。如何设计价格歧视系统?同时如何规避法律风险?

答案

问题分析: 个性化定价的核心要素:

  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测试(避免法律风险)?


第三部分:交易核心链路(50题)

3.1 搜索与导购(10题)

📊 题目1:电商搜索引擎的架构设计

问题描述: 电商平台每天有百万级搜索请求,需要支持全文搜索、属性筛选、排序。如何设计电商搜索引擎的整体架构?

答案

问题分析: 电商搜索的核心要素:

  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. 如何防止恶意搜索(刷流量、爬虫)?

🔧 题目2:搜索相关性排序算法设计

问题描述: 用户搜索“手机“,返回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. 如何设计搜索排序的解释性(为何这个商品排第一)?

💡 题目3:搜索建议(Suggest)的实现

问题描述: 用户输入“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. 如何设计多语言的搜索建议?

📊 题目4:商品筛选和多维度过滤的设计

问题描述: 用户搜索“手机“后,可以按品牌、价格区间、屏幕尺寸、内存等多个维度筛选。如何设计筛选系统?

答案

问题分析: 筛选系统的核心要素:

  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. 如何设计筛选的撤销和重置功能?

🔧 题目5:搜索结果的无结果优化

问题描述: 用户搜索“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题:

📊 题目6:搜索日志分析与优化

问题描述: 电商平台每天产生百万级搜索日志,如何分析搜索日志,发现问题并优化搜索体验?

答案

问题分析: 搜索日志分析的核心目标:

  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测试平台?

🔧 题目7:跨境电商的多语言搜索

问题描述: 跨境电商支持中文、英文、日文搜索。如何设计多语言搜索系统?

答案

问题分析: 多语言搜索的核心挑战:

  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. 不同国家的搜索习惯差异如何处理?

💡 题目8:搜索性能优化

问题描述: 搜索响应时间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. 搜索性能如何监控和告警?

📊 题目9:智能搜索(NLP+AI)

问题描述: 用户搜索“适合送女朋友的礼物“,如何理解用户意图,推荐合适商品?

答案

问题分析: 传统搜索只能匹配关键词,无法理解语义。

解决方案

  1. 意图识别

    NLP分析:
    "适合送女朋友的礼物" 
    → 意图:礼物推荐
    → 对象:女性
    → 场景:送礼
    
    映射到类目:
    - 珠宝首饰
    - 化妆品
    - 鲜花
    
  2. 语义搜索

    使用BERT等模型:
    - 将搜索词编码为向量
    - 商品标题也编码为向量
    - 计算向量相似度
    - 按相似度排序
    

延伸思考

  1. 如何训练电商领域的语义模型?
  2. 语义搜索如何与传统搜索结合?

🔧 题目10:搜索结果的多样性优化

问题描述: 用户搜索“手机“,前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. 如何评估搜索结果的多样性?

3.2 购物车与结算(15题)

📊 题目1:购物车的数据存储设计

问题描述: 用户将商品加入购物车,需要跨设备同步(手机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. 购物车数据如何备份和恢复?

🔧 题目2:购物车的价格计算

问题描述: 购物车中有多个商品,每个商品可能有不同促销(满减、折扣、优惠券)。如何设计购物车的实时价格计算?

答案

问题分析: 购物车价格计算的复杂性:

  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. 大促时购物车价格计算如何优化性能?

💡 题目3:购物车的推荐功能

问题描述: 用户购物车中有商品A,如何推荐相关商品B,提升客单价?

答案

推荐策略

  1. 关联推荐

    "买了还买":
    - 统计购买商品A的用户还购买了哪些商品
    - 推荐高频商品
    
    示例:
    购物车有"iPhone 15" → 推荐"手机壳"、"钢化膜"、"充电器"
    
  2. 凑单推荐

    购物车总价¥180
    满¥200减¥30
    
    推荐:再买¥20-30的商品,即可享受优惠
    
  3. 替代推荐

    购物车中商品缺货 → 推荐同类商品
    

延伸思考

  1. 购物车推荐如何避免打扰用户?
  2. 推荐商品点击率如何提升?

📊 题目4:购物车的库存校验

问题描述: 用户加购物车时商品有货,结算时可能已无货。如何设计购物车的库存校验机制?

答案

校验时机

  1. 加购时校验

    用户点击"加入购物车" → 检查库存
    库存充足 → 允许加购
    库存不足 → 提示"库存不足"
    
  2. 结算时校验

    用户点击"去结算" → 
    1. 批量查询购物车所有商品库存
    2. 标记缺货商品
    3. 展示:
       - 有货商品(可结算)
       - 缺货商品(置灰,不可结算)
    
  3. 实时推送

    商品库存变化(如售罄) → WebSocket推送
    前端实时更新购物车状态
    

延伸思考

  1. 购物车中的商品是否需要预占库存?
  2. 库存不足时如何引导用户?

🔧 题目5:购物车的性能优化

问题描述: 大促期间,购物车服务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. 购物车服务如何实现高可用?

📊 题目6:购物车商品失效的处理策略

问题描述: 用户购物车中的商品可能因为下架、删除、库存清零而失效。如何设计失效商品的处理策略,优化用户体验?

答案

问题分析: 商品失效场景:

  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. 如何处理部分失效(如只有某个规格缺货)?

🔧 题目7:购物车的跨平台同步设计

问题描述: 用户在手机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服务如何实现横向扩展?

💡 题目8:购物车推荐算法设计

问题描述: 用户购物车有“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. 推荐效果如何评估和持续优化?

📊 题目9:购物车的结算流程设计

问题描述: 用户点击“去结算“,进入结算页面,需要选择地址、优惠券、支付方式。如何设计结算流程?

答案

问题分析: 结算流程的核心环节:

  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. 结算流程如何优化转化率?

🔧 题目10:购物车的分享功能设计

问题描述: 用户想分享购物车给朋友(如“帮我看看这些商品怎么样“),如何设计购物车分享功能?

答案

问题分析: 购物车分享的核心场景:

  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. 共享购物车如何拆单结算(各付各的)?

💡 题目11:购物车的满减凑单提示

问题描述: 购物车总价¥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. 多个满减活动同时存在时如何提示?

📊 题目12:购物车的批量操作设计

问题描述: 用户购物车有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. 批量操作的进度如何展示?

🔧 题目13:购物车的收藏夹联动

问题描述: 购物车和收藏夹如何联动?商品从购物车移入收藏,或从收藏加入购物车。

答案

推荐方案

  1. 数据模型

    favorite
    ├── favorite_id
    ├── user_id
    ├── sku_id
    ├── source(CART/BROWSE)
    ├── added_at
    └── ...
    
  2. 互相转换

    购物车 → 收藏夹:
    1. 用户点击"移入收藏"
    2. 加入收藏夹
    3. 从购物车删除
    4. 提示"已移入收藏夹"
    
    收藏夹 → 购物车:
    1. 用户点击"加入购物车"
    2. 加入购物车
    3. 保留在收藏夹(不删除)
    
  3. 降价提醒

    收藏商品降价:
    - 监控收藏商品价格
    - 降价时推送通知
    - 引导用户加购
    

延伸思考

  1. 收藏夹和购物车的区别是什么?
  2. 如何设计收藏夹的分组功能?

💡 题目14:购物车的历史记录

问题描述: 用户删除了购物车商品,想恢复。如何设计购物车的历史记录功能?

答案

推荐方案

  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. 如何设计购物车的快照功能(保存多个购物清单)?

📊 题目15:购物车的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. 如何设计购物车的渐进式发布(灰度发布)?

3.3 订单系统(15题)

📊 题目1:订单状态机的设计

问题描述: 订单从创建到完成,经历多个状态(待支付、待发货、待收货、已完成)。如何设计订单状态机,保证状态流转的正确性?

答案

问题分析: 订单状态流转的核心要素:

  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. 如何处理状态流转的并发冲突?

🔧 题目2:订单号生成规则

问题描述: 订单号需要唯一、有序、不易被猜测。如何设计订单号生成规则?

答案

订单号设计要求

  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. 分库分表场景下订单号如何设计路由键?

💡 题目3:订单超时自动取消

问题描述: 用户下单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. 大促期间超时订单激增如何处理?

📊 题目4:订单拆单与合单策略

问题描述: 用户购买多个商品,可能来自不同仓库或不同商家。如何设计订单拆单与合单策略?

答案

问题分析: 拆单场景:

  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. 跨境订单的拆单有何特殊性?

🔧 题目5:订单的并发创建与幂等性

问题描述: 用户可能重复点击“提交订单“按钮,导致创建多个订单。如何保证订单创建的幂等性?

答案

问题分析: 重复下单的原因:

  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. 分布式事务下如何保证订单创建的一致性?

📊 题目6:订单的分布式事务设计(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(两阶段提交)如何选择?

🔧 题目7:订单数据的分库分表设计

问题描述: 订单表数据量达到亿级,单表查询性能下降。如何设计订单的分库分表方案?

答案

问题分析: 订单分库分表的核心要素:

  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?

💡 题目8:订单履约流程的编排

问题描述: 订单支付成功后,需要依次执行:分配仓库、创建拣货单、打包、出库、创建运单、发货。如何设计订单履约流程的编排?

答案

推荐方案:事件驱动+状态机

架构(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. 履约时效如何监控和告警?

📊 题目9:订单的退款和售后流程设计

问题描述: 用户申请退款(仅退款、退货退款),如何设计售后流程,保证资金安全和用户体验?

答案

退款场景

  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. 部分退款如何计算退款金额(商品价+运费分摊)?

🔧 题目10:订单的异常处理(缺货、地址错误)

问题描述: 订单履约过程中可能出现异常(缺货、地址无法送达、商品损坏)。如何设计异常处理流程?

答案

异常场景及处理方案

  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. 如何设计异常的自动化处理规则?

💡 题目11:订单的搜索和查询优化

问题描述: 用户需要查询历史订单(按时间、状态、商品筛选),运营需要查询全部订单。如何设计订单查询系统?

答案

方案一:主从分离

用户查询(读从库):

// 查询我的订单
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同步如何保证一致性?

📊 题目12:订单的消息通知设计

问题描述: 订单状态变化时需要通知用户(下单成功、发货、签收)。如何设计消息通知系统?

答案

通知渠道

  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. 大批量通知如何限流(避免骚扰)?

🔧 题目13:订单数据的冷热分离

问题描述: 订单数据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. 冷数据恢复到热库的策略?

💡 题目14:订单的限流和防刷

问题描述: 恶意用户频繁下单不支付,占用库存和系统资源。如何设计订单的限流和防刷机制?

答案

推荐方案(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. 限流策略如何针对不同用户等级差异化?

📊 题目15:订单的实时数据统计

问题描述: 运营大盘需要实时显示订单量、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. 多维度统计(按类目、品牌)如何设计?

3.4 支付系统(10题)

📊 题目1:支付系统的整体架构设计

问题描述: 电商平台需要支持多种支付方式(支付宝、微信、银行卡)。如何设计支付系统的整体架构?

答案

问题分析: 支付系统的核心要素:

  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. 支付回调丢失如何处理?

🔧 题目2:支付回调的幂等性处理

问题描述: 支付回调可能重复发送(网络重试、第三方重推)。如何保证支付回调处理的幂等性?

答案

推荐方案(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. 回调处理失败如何人工介入?

💡 题目3:支付的对账系统设计

问题描述: 每天需要与支付宝、微信对账,确保平台账和渠道账一致。如何设计支付对账系统?

答案

对账流程(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对账如何结合?

📊 题目4:支付的异步回调处理

问题描述: 支付成功后,第三方通过回调通知平台。回调可能延迟、丢失、重复。如何设计健壮的回调处理机制?

答案

推荐方案(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. 回调处理超时如何设置?

🔧 题目5:支付的分账系统设计(平台+商家)

问题描述: 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. 结算失败如何处理?

📊 题目6:支付密码和安全设计

问题描述: 支付环节涉及资金安全,如何设计支付密码、短信验证码等安全机制?

答案

推荐方案(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. 支付安全如何平衡用户体验?

🔧 题目7:支付渠道的路由和降级

问题描述: 支付宝渠道故障时,如何自动切换到微信支付?如何设计支付渠道的路由和降级策略?

答案

推荐方案(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. 支付渠道限额如何处理?

💡 题目8:支付的退款处理

问题描述: 用户申请退款,需要原路退回。如何设计退款流程,处理退款失败、部分退款等场景?

答案

推荐方案(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. 如何设计退款的限额控制(防止洗钱)?

🔧 题目9:支付的容灾和降级

问题描述: 支付是核心链路,不能中断。如何设计支付系统的容灾和降级方案?

答案

推荐方案(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. 支付降级后如何通知用户?

📊 题目10:预授权支付的设计(酒店、租车场景)

问题描述: 酒店预订需要预授权(冻结资金但不扣款),退房时根据实际消费扣款。如何设计预授权支付?

答案

推荐方案(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. 预授权场景下的对账如何设计?

第四部分:综合实战案例(10题)

本部分将前面所学知识点整合,设计完整的端到端场景,涵盖系统设计、技术选型、性能优化、故障处理等多个维度。


🚀 案例1:设计一个百万级QPS的商品详情页系统

问题描述: 电商平台的商品详情页是流量最大的页面,大促期间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. 缓存雪崩如何防范?

💡 案例2:秒杀系统的完整设计

问题描述: 设计一个支持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)?

🔧 案例3:订单履约的全链路监控

问题描述: 订单从创建到签收,涉及多个服务(订单、库存、物流、支付)。如何设计全链路监控,快速定位问题?

答案

推荐方案:分布式追踪(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)?

📊 案例4:大促准备的全链路压测

问题描述: 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. 压测导致的脏数据如何清理?

🔧 案例5:跨境电商的多币种结算

问题描述: 跨境电商平台支持美元、欧元、人民币等多币种。如何设计多币种的定价、支付、结算系统?

答案

推荐方案(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. 跨境支付的关税如何计算?

💡 案例6:电商搜索的智能排序

问题描述: 用户搜索“手机“,返回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. 如何平衡新品曝光和热销商品?

🚀 案例7:异常流量的应急处理

问题描述: 凌晨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. 如何平衡防御和用户体验(误封正常用户)?

🔧 案例8:订单的柔性事务设计

问题描述: 订单创建涉及多个服务(扣库存、扣优惠券、扣积分)。如何设计柔性事务,保证最终一致性?

答案

推荐方案:本地消息表 + 定时补偿

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. 消息发送失败如何保证最终一致性?

📊 案例9:用户画像系统的设计

问题描述: 为了实现个性化推荐,需要构建用户画像(年龄、性别、消费能力、兴趣偏好)。如何设计用户画像系统?

答案

推荐方案:实时+离线双层架构

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. 画像准确性如何评估?

💡 案例10:大促后的系统复盘

问题描述: 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?

第五部分:章节补充面试题与答辩话术(78题)

本部分集中收录原本散落在正文和专题附录中的面试总结、常见追问、参考回答和答辩话术。正文各章只保留交叉引用,避免同一类面试材料分散在多个位置。

5.1 第8章 商品中心:常见问题与总结

来源:第8章 商品中心系统

8.15 常见面试问题

问题 1:商品中心和商品供给平台有什么区别?

参考回答:商品供给平台负责 Draft、Staging、QC、批量导入、库存创建 / 修改运营入口、供应商同步、DLQ 和发布编排;商品中心负责正式商品主数据、交易前契约、发布版本、商品快照和读模型。供给平台是“商品和供给能力如何进入平台”,商品中心是“正式商品如何被交易系统稳定使用”。

问题 2:为什么商品正式表不应该有 Draft、QC Pending、Rejected 状态?

参考回答:这些是供给流程状态,不是正式商品生命周期状态。新建商品在发布前还没有正式 item_id;编辑商品待审时,线上旧版本仍然有效。如果把流程状态塞进正式商品表,会导致搜索、缓存、订单误读未审核数据。

问题 3:为什么需要 Resource,而不是只用 SPU/SKU?

参考回答:很多数字商品依附于现实或外部资源,例如酒店、门店、活动、运营商、账单机构。Resource 表达资源事实,SPU/SKU 表达商品定义,Offer 表达销售承诺。这样供应商同步可以先沉淀资源,运营再决定如何售卖。

问题 4:Product Item、SPU、SKU、Offer 的关系是什么?

参考回答:Product Item 是前台商品入口或聚合根;SPU 表达共性商品定义;SKU 表达可下单规格单元;Offer 表达销售条件、渠道、销售期、库存来源、输入、履约和退款规则。

问题 5:为什么需要 publish_version?

参考回答:publish_version 用于防止旧编辑覆盖新版本,也用于搜索、缓存、订单按版本处理。编辑发布时要校验 base_publish_version == current_publish_version,否则进入版本冲突。

问题 6:订单为什么不能回读最新商品表?

参考回答:商品会不断修改标题、价格计划、履约参数和退款规则。历史订单必须按照下单时的商品、报价、履约和退款契约解释,所以订单要保存或引用创单时的商品快照。

问题 7:商品中心是否保存实时库存和最终成交价?

参考回答:不应该把实时库存和最终成交价作为商品中心唯一事实。商品中心保存库存配置和基础价、Offer 规则;实时库存由库存系统判断,最终成交价由计价系统试算。

问题 8:发布成功后搜索刷新失败怎么办?

参考回答:不回滚商品发布。商品正式表、版本、快照和 Outbox 同事务写入;搜索刷新通过 Outbox 异步重试,失败进入补偿任务。搜索索引按 publish_version 幂等更新,并通过巡检发现版本落后。

问题 9:供应商同步是否直接写商品中心?

参考回答:不建议。供应商同步先保存 Raw Snapshot,然后标准化、映射、Diff,进入供给平台 Staging 和发布治理。商品中心只接收发布命令和正式结果。

问题 10:商品中心如何支持异构品类?

参考回答:用类目模板定义 Resource、SPU、SKU、Offer、交易契约和展示字段;核心交易字段结构化,品类核心字段可以用扩展表,长尾展示字段用 JSON,搜索筛选字段进入搜索投影。

问题 11:为什么发布事务里不直接刷新缓存和 ES?

参考回答:刷新缓存和 ES 是下游投影动作,可能慢、可能失败,不应该放在商品发布事务里。发布事务只写正式表、版本、快照和 Outbox;下游通过事件异步刷新并可补偿。

问题 12:商品中心如何排查线上展示旧数据?

参考回答:先查 product_item_tab.current_publish_version,再查 product_snapshot_tabproduct_outbox_event,然后对比 Redis、搜索索引里的 publish_version。如果索引或缓存落后,通过 Outbox 重放或补偿任务刷新。


8.16 面试总结

商品中心不是供给后台,也不是简单 SPU/SKU CRUD。一个成熟的商品中心应该是正式商品主数据和交易前契约中心:用 Resource、Product Item、SPU/SKU、Offer / Rate Plan 表达“卖什么”和“怎么卖”,用库存配置、Input Schema、履约规则、退款规则表达“如何下单、如何履约、如何售后”,用 publish_version 和商品快照保证历史订单可解释,用 Outbox 保证搜索、缓存、计价上下文、数据平台和营销资格消费者最终一致。

面试时可以这样概括:

第 11 章的供给平台解决“商品如何进入平台”,第 8 章的商品中心解决“正式商品如何稳定支撑交易”。Draft、Staging、QC、批量任务、供应商同步都不应该污染商品正式表;商品中心只接受发布命令,写入正式主数据、交易前契约、发布版本、商品快照和 Outbox。订单只信快照,下游按版本消费事件。这样商品中心才能同时支撑多品类、多供应商、高并发导购和长期运营治理。


5.2 第11章 商品供给管理:总结与常见题

来源:第11章 商品供给管理:运营、库存与生命周期

11.16 答辩材料:面试总结

面试中可以这样总结商品供给管理:

我不会把商品供给设计成后台 CRUD,也不会把供应商同步、人工运营和库存运营割裂成几套系统。我的设计是统一供给治理平台:人工创建、批量导入、运营编辑、库存创建 / 修改和供应商同步都先进入 Draft、Task、Item 和 Staging,通过标准化、质量校验、Diff、风险审核、版本化发布、Outbox、DLQ、补偿和质量巡检后,再写入正式商品主数据和交易契约。库存配置、补货、券码导入、系统生码、门店 / 日期库存调整属于供给平台的运营工作流,但库存余额、码池状态机、预占记录和账本流水属于库存系统。单商品创建和编辑要保证同步体验,批量导入、批量编辑、库存批量导入和供应商同步必须异步任务化。Draft、Staging、QC、正式 Item、Task 状态要分开建模,避免状态语义混乱。发布时生成 publish_version 和商品快照,订单只相信创单时快照,下游刷新通过 Outbox 最终一致。这样才能支撑多品类、多供应商和强运营平台的长期演进。

如果只能做三件事:

  1. 收敛写入口:所有商品变更必须经过命令 API、Task、Staging 和发布事务。
  2. 建立版本和审核:所有高风险变更必须有 Diff、风险理由、审核记录和 publish_version
  3. 补齐补偿闭环:错误文件、DLQ、Outbox、质量巡检和运营看板必须能让失败被发现、被修复、被重新投递。

11.16 答辩材料:常见面试题

11.16.1 基础边界

问题 1:为什么商品供给与运营平台不能设计成商品中心的后台 CRUD?

参考要点:商品中心负责正式主数据和查询契约,供给平台负责 Draft、Task、Staging、审核、发布、补偿和审计。后台直接 CRUD 会让未审核数据污染线上,也会造成搜索、缓存、订单快照和发布版本不可追溯。

问题 2:商品中心和供给运营平台的边界是什么?

参考要点:商品中心保存正式 Resource / SPU / SKU / Offer / Rulepublish_version;供给运营平台保存供给流程对象,例如 Draft、Staging、QC、Task、DLQ。供给平台通过发布事务写商品中心,不直接让运营后台改正式表。

问题 3:供应商同步和人工上传要分成两套系统吗?

参考要点:不要简单回答“分开”或“不分开”。更准确的设计是:入口层、执行层要分开,标准化之后的治理和发布层要合流

供应商同步和人工上传的执行问题完全不同:

维度人工上传 / 批量导入供应商同步
触发方式用户点击、上传 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 合并

面试时可以收束成一句话:

供应商同步有自己的“采集和恢复系统”,但不应该有自己的“商品发布系统”。采集链路可以分,发布治理必须合。

问题 4:本地运营上传和商家上传为什么审核策略不同?

参考要点:本地运营是内部可信来源,默认 AUTO_APPROVE,但仍要走 Validation、Staging、Publish 和 Outbox;商家是外部来源,默认 QC_REQUIRED,QC 通过后才能发布。

11.16.2 生命周期与 ID 设计

问题 5:为什么 Draft、Staging、QC、正式 Item 要有不同状态机?

参考要点:它们描述的是不同对象。Draft 是可编辑工作区,Staging 是提交冻结快照,QC 是审核工单,正式 Item 是线上商品资产。把这些状态混在一个字段里,会出现 DRAFT/QC_PENDING/ONLINE/REJECTED 语义混乱。

问题 6:新建商品在 Draft 和 QC 阶段还没有正式 item_id,怎么追踪全链路?

参考要点:首次创建时生成 supply_trace_idtemporary_object_key。发布成功后生成正式 item_id,再写 product_supply_object_mapping,后续可以用 item_id 反查 supply_trace_id

问题 7:商品已经在线后再次编辑,哪些 ID 会新建,哪些 ID 会复用?

参考要点:item_idsupply_trace_id 复用;operation_iddraft_idstaging_id、可能的 review_id 都新建;发布成功后 publish_version 递增。

问题 8:为什么商家提交 Draft 后要生成不可随意修改的 Staging Ticket?

参考要点:Draft 是工作区,可以反复保存;Staging 是提交证据和审核发布对象。冻结 Staging 可以保证 QC 审核的内容和最终发布内容一致,避免“审核 A,发布 B”。

问题 9:Pending 阶段发现内容填错,还能直接编辑吗?

参考要点:不建议直接编辑待审 Staging。推荐语义是撤回当前 QC 和 Staging,再基于 Staging payload 生成新 Draft,修改后重新提交,生成新的 Staging 和 QC。

11.16.3 审核、Diff 与发布

问题 10:为什么需要 product_qc_reviewproduct_qc_review_item 两张表?

参考要点:product_qc_review 是审核工单主表,记录整体状态、审核人、结论;product_qc_review_item 是审核项明细,记录字段 Diff、风险原因、分项结论和驳回原因。批量任务和字段级驳回都需要明细表。

问题 11:product_change_requestproduct_supply_operation_logproduct_field_ownership 分别解决什么问题?

参考要点:product_change_request 记录改了什么、风险多高、是否需要 QC;product_supply_operation_log 记录从 Draft 到下线全过程发生了什么;product_field_ownership 记录字段由谁主导,防止供应商同步覆盖人工治理结果。

问题 12:product_publish_recordproduct_publish_snapshotproduct_change_log 的区别是什么?

参考要点:publish_record 记录一次发布动作和结果;publish_snapshot 保存发布后的正式商品上下文;change_log 解释正式商品从旧版本到新版本变了哪些字段。

问题 13:Publish 背后的实际流程是什么?

参考要点:发布前校验 Staging 状态、QC 状态、幂等、版本冲突和交易契约完整性;事务内写正式商品表、库存配置、履约退款规则、publish_version、发布快照、变更日志和 Outbox;事务后由搜索、缓存、计价上下文和数据平台消费者异步重建投影。涉及活动配置时,由供给平台发起营销系统命令或营销资格事件,但营销规则、预算和优惠计算仍归营销系统。

问题 14:为什么 QC 通过不等于商品已经上线?

参考要点:QC 通过只表示允许发布。真正上线还需要 Publish Worker 完成正式表写入、生成发布版本、写 Outbox、刷新搜索缓存,并且商品满足销售时间、库存、渠道和风控可售条件。

问题 15:为什么发布前要校验 base_publish_version

参考要点:编辑是基于某个线上版本产生的。如果当前线上版本已经变化,旧 Staging 继续发布会覆盖别人的新修改。发布前做 CAS 版本校验,冲突时进入 VERSION_CONFLICT 并要求重新编辑。

11.16.4 批量任务与供应商同步

问题 16:为什么批量导入不能同步循环写正式表?

参考要点:批量导入可能有大量数据、行级错误、长耗时和下游压力。应该先创建 product_supply_task,再流式解析文件、生成 task_item、行级校验、部分成功、错误文件和补偿任务。

问题 17:Parser Worker 和 Item Worker 为什么要拆开?

参考要点:Parser Worker 只负责文件解析和生成行级 item;Item Worker 负责标准化、校验、Staging、Diff、QC 和发布准备。拆开后解析失败不会污染业务处理,行级处理可以限流、重试和并行。

问题 18:product_supply_taskproduct_supply_task_item 的关系是什么?

参考要点:Task 是一次供给动作的批次状态,Task Item 是行级或对象级处理单元。Task 状态由 Item 聚合,支持部分成功、部分失败、部分等待 QC,避免一个失败项拖垮整批任务。

问题 19:供应商同步为什么需要 Raw Snapshot、Checkpoint 和 DLQ?

参考要点:供应商同步是长任务且外部不稳定。Raw Snapshot 用于追溯和回放;Checkpoint 用于断点续跑;DLQ 用于保存字段缺失、映射失败、发布失败等可运营问题单。

11.16.5 一致性、补偿与交易安全

问题 20:如何保证商品发布后搜索、缓存、计价上下文最终一致?营销活动如何协同?

参考要点:正式表、发布记录、发布快照和 Outbox 在同一事务内写入;搜索、缓存、计价上下文和数据平台通过 Outbox 异步消费,按 event_idpublish_version 幂等处理,失败进入重试、DLQ 或 product_compensation_task。供给平台不直接写搜索索引、不直接写最终价格,也不直接改订单。营销活动是控制面协同:供给平台可以发起圈品、活动资格和活动绑定命令,营销系统负责活动规则、预算、券、补贴、营销库存和最终优惠计算。订单创建只相信当时的商品、报价、履约和退款快照,不回读最新商品解释历史订单。

问题 21:商品供给运营平台、商品生命周期和库存系统如何联动?

参考要点:创建和修改库存属于供给运营平台的业务工作,但不属于供给运营平台的数据事实。供给平台是库存变更的控制面,负责表单、导入、审批、任务、错误文件、可售诊断和审计;库存系统是库存事实的数据面,负责 inventory_configinventory_balance、券码池、预占记录、状态机和账本流水。供给平台治理变更是否可以发布,商品生命周期控制正式商品何时在线、下线、结束和封禁,库存系统控制某个范围内是否有可承诺资源,营销系统控制活动、券、补贴、预算和优惠规则。发布成功不等于可售成功,Publish 只写正式商品、发布版本、交易契约和 Outbox;库存通过 CreateInventoryAdjustInventoryImportCodeBatchGenerateCodeBatch 等命令异步创建或调整库存实例,再发布 InventoryReady/InventoryChanged/InventoryFailed。最终由可售投影合成 product_status + inventory_status + price_status + marketing_status + fulfillment_status + channel_policy + risk_status,告诉搜索、缓存和详情页是否可卖。供给后台不能直接改库存余额,也不能直接写营销优惠计算结果;库存系统也不能绕过审核和发布版本直接改商品生命周期。

问题 22:库存运营任务为什么要单独建模?

参考要点:库存运营任务不是库存扣减本身,而是库存变更进入平台的控制面。简单数量制库存可以随商品发布自动创建,但也应该由供给平台发起 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


5.3 附录F 供应商数据同步链路:总结与题目

来源:附录F 供应商数据同步链路

17. 面试总结

100 万酒店、10 小时的供应商全量同步任务,我不会设计成依赖进程内状态的单进程长循环,而会先设计成 Batch + Page/Cursor Checkpoint 的可恢复流水线。供应商原始响应先保存 Raw Snapshot,然后做标准化、质量校验、平台模型映射、Diff 和发布。同步版本、快照版本和发布版本要分离,保证可追溯、可回放和可审计。失败数据进入 MySQL DLQ,支持自动重试、人工修复和重新投递。监控上不仅看任务成功率,还要看字段缺失率、映射失败率、新鲜度延迟、DLQ 修复率和下游刷新失败率。任务分片和分布式 Worker 抢占可以作为后续优化,在第一阶段不强行复杂化主链路。

18. 面试题目

18.1 基础理解

问题 1:为什么不能把 100 万酒店同步设计成一个单进程长循环?

参考回答:因为任务运行时间长,中途机器重启、供应商超时、进程发布、网络抖动的概率都很高。单进程长循环的进度通常在内存里,失败后只能从头跑,排查也困难。更合理的设计是 Task + Batch + Checkpoint + DLQ:任务先落库,执行过程持续推进 checkpoint,失败数据进入 DLQ,机器重启后从 checkpoint 恢复。

问题 2:Task 和 Batch 有什么区别?

参考回答:Task 是任务定义,描述“同步什么、怎么同步、什么时候同步”,比如某供应商酒店全量同步;Batch 是一次具体执行,描述“这一次跑到了哪里、成功多少、失败多少、当前 worker 是谁、租约什么时候过期”。一个 Task 会产生多次 Batch。

问题 3:Checkpoint 是什么,什么时候更新?

参考回答:Checkpoint 是任务进度水位,例如当前城市、页码、供应商 cursor、最后处理成功的供应商酒店 ID。它用于断点续跑。推荐先处理本页数据,再推进 checkpoint。这样即使机器在中间宕机,最多重复处理上一页,不会跳过未处理数据。

18.2 分布式执行与恢复

问题 4:worker 如何抢占任务?

参考回答:通过数据库 CAS 抢占。worker 执行一条带条件的 UPDATE,只有 status=PENDINGstatus=RUNNING AND lease_until < NOW() 的 batch 才能被抢占。rows_affected=1 才说明抢占成功,其他 worker 必须退出。

问题 5:worker_idlease_token 的区别是什么?

参考回答:worker_id 标识执行器实例,通常由服务名、机器名或容器名、进程号、启动时间组成,方便排查和监控。lease_token 标识一次抢占行为,每次抢占都重新生成。关键写操作必须同时校验 batch_id + worker_id + lease_token,防止旧 worker 恢复后覆盖新 worker 的进度。

问题 6:心跳、租约、checkpoint 分别解决什么问题?

参考回答:心跳说明 worker 是否还活着;租约说明当前任务执行权属于谁;checkpoint 说明任务恢复时从哪里继续。心跳正常不代表任务在前进,所以还要看 last_checkpoint_at。租约过期才允许新 worker 抢占,checkpoint 用于恢复位置。

问题 7:机器重启后怎么恢复?

参考回答:机器重启后原 worker 不再续租,lease_until 到期。新 worker 通过 CAS 抢占过期 batch,读取 end_checkpoint,从对应城市、页码或 cursor 继续。由于可能重复处理上一页,所以落库必须基于 supplier_id + supplier_resource_code + supplier_product_code 做幂等。

问题 8:旧 worker 在长 GC 或网络抖动后恢复了怎么办?

参考回答:旧 worker 恢复后可能以为自己还拥有任务。所有续租、checkpoint、发布和结束任务的 SQL 都必须带 worker_id + lease_token 条件。如果更新影响行数为 0,说明租约已经丢失,旧 worker 必须停止执行,不能继续写平台表。

18.3 任务互斥与边界场景

问题 9:上一次任务还没跑完,又下发了一次任务怎么办?

参考回答:要用显式互斥策略。默认 SKIP_IF_RUNNING,如果已有同 task_codePENDING/RUNNING batch,新任务直接跳过;人工强制重跑可使用 CANCEL_PREVIOUS;只有数据范围不重叠时才允许 ALLOW_PARALLEL

问题 10:心跳正常但 checkpoint 长时间不动,说明什么?

参考回答:说明 worker 还活着,但任务可能卡在某个阶段,例如供应商慢请求、对象存储写入慢、数据库锁等待、发布阻塞。此时不应立即抢占,而应告警并结合 last_heartbeat_stage 定位卡点。只有租约过期才允许新 worker 抢占。

问题 11:checkpoint 更新失败怎么办?

参考回答:如果本页处理成功但 checkpoint 更新失败,下次恢复可能重复处理本页。因此页内落库和发布必须幂等。相反,不能先更新 checkpoint 再处理数据,否则宕机会跳过未处理页面。

问题 12:任务被人工取消时 worker 还在跑,怎么停?

参考回答:worker 不应该只在启动时读取状态,而要在每页处理前后检查 batch status。如果发现 CANCELLED,停止继续拉供应商,不再发布新数据,只做必要的清理和日志记录。

18.4 数据治理与补偿

问题 13:Raw Snapshot 的价值是什么?

参考回答:Raw Snapshot 是供应商原始响应的证据,不是平台商品模型。它用于排查线上问题、回放同步、验证标准化规则、做 diff,也能区分是供应商数据错误还是平台映射错误。

问题 14:为什么需要 Diff?

参考回答:同步成功不等于应该发布。Diff 用来比较标准化后的数据和当前线上发布版本,识别字段变化、图片变化、坐标变化、房型变化和可售变化。低风险变化可以自动发布,高风险变化进入审核或 DLQ。

问题 15:DLQ 为什么建议用 MySQL,而不是只用消息队列?

参考回答:供应商同步失败往往不是简单消息消费失败,而是字段缺失、城市映射失败、价格异常、发布失败等需要人工修复、状态流转和审计的问题。MySQL DLQ 可以作为权威问题单,支持查询、分派、重试、忽略、修复和报表。消息队列 DLQ 可以做短期缓冲,但不适合作为运营修复台账。

问题 16:100 万酒店 10 小时如何估算吞吐?

参考回答:100 万 / 10 小时约等于 27.8 个酒店/秒。如果每页 100 个酒店,大约需要 10000 页,10 小时内只需要 0.28 页/秒。但如果要逐个拉详情,就是 27.8 QPS,还要受供应商限流、超时、重试和数据处理速度约束。

18.5 Redis 抢占问题

问题 17:worker 可以从 Redis 中抢占任务吗?

参考回答:可以,但我会把 Redis 作为加速锁或短租约,不把它作为唯一权威状态。任务状态、checkpoint、统计、DLQ 和审计仍然落 MySQL。Redis 可以用 SET lock_key value NX EX 300 抢锁,用 Lua 保证续租和释放的原子性。但真正开始执行前仍要更新 MySQL batch 的 worker_id + lease_token + lease_until,避免 Redis 主从切换、锁丢失或网络分区导致状态不可追溯。

5.4 附录G 商品供给与运营治理平台:面试总结

来源:附录G 商品供给与运营治理平台

18. 面试总结

我不会把商品供给和运营链路设计成后台 CRUD。我的设计是统一供给治理平台:人工创建、批量导入、运营编辑、库存创建 / 修改和供应商同步都先进入任务和暂存区,经过标准化、质量校验、Diff、风险审核、版本化发布、Outbox、补偿和质量巡检后,才写入正式商品主数据和交易契约。系统难点不在于建几张商品表,而在于入口语义统一、线上隔离、类目差异、批量部分成功、库存控制面、运营误操作防护、字段主导权、发布一致性、订单快照和失败运营化。供应商同步属于供给链路,但因为它有 Checkpoint、Raw Snapshot、Worker 租约和数据新鲜度问题,所以作为专项链路单独设计。

5.5 附录G 商品供给与运营治理平台:常见题

来源:附录G 商品供给与运营治理平台

19. 面试题目

问题 1:为什么商品供给不能直接写商品正式表?

参考回答:因为供给入口产生的是未校验、未审核、未发布的数据。直接写正式表会让半成品商品被搜索、下单或履约系统读取。正确做法是先写 Draft / Staging,通过校验、审核和发布事务后再写正式表。

问题 2:供应商同步是不是商品供给链路的一部分?

参考回答:是。供应商同步是商品供给的一种自动化入口,但不是全部。统一供给平台要承接人工创建、批量导入、运营编辑、库存创建 / 修改和供应商同步。供应商同步因为有长任务、Checkpoint、Raw Snapshot、Worker 租约和新鲜度问题,所以需要专项链路。

问题 3:批量导入为什么要支持部分成功?

参考回答:大批量导入中少量错误很常见。如果 10 万行因为 100 行失败全部回滚,运营效率会非常低。更合理的是行级状态,成功项继续发布,失败项生成错误文件,运营修复后重新提交。

问题 4:运营编辑如何防止覆盖供应商同步的数据?

参考回答:要定义字段主导权。平台运营主导的标题、卖点、活动标签不应被供应商自动覆盖;供应商主导的库存和价格可以按策略更新;高风险字段进入审核。运营人工覆盖供应商字段时要记录原因、责任人和保护期。

问题 5:审核通过为什么不等于商品可售?

参考回答:审核只说明变更可以发布,真正可售还依赖商品主数据、Offer、库存、Input Schema、履约规则、退款规则、搜索索引和缓存刷新都就绪。因此发布后还要做可售校验和下游刷新补偿。

问题 6:如何保证商品发布和搜索缓存一致?

参考回答:商品正式表更新和 Outbox 事件写入同一个事务。事务提交后,由异步消费者刷新 ES、缓存、计价上下文和数据平台。涉及营销活动时,由供给平台发起活动绑定、圈品或营销资格命令,营销系统负责规则、预算、券和优惠计算。刷新或协同失败进入补偿任务,保证最终一致。

问题 7:为什么订单要保存商品快照?

参考回答:商品会持续变化,包括价格、标题、履约规则和退款规则。如果历史订单回读最新商品配置,会导致售后争议和财务对不上。创单时必须保存商品快照、报价快照、履约契约和退款规则快照。

问题 8:Draft 和 Staging 有什么区别?

参考回答:Draft 面向编辑过程,允许运营反复保存、撤销和补充信息,不代表一次可发布变更。Staging 面向发布过程,保存已经标准化、校验过、可审核的候选数据。Draft 更像工作区,Staging 更像发布前的候选版本,两者都不能直接被 C 端读取。

问题 9:为什么批量导入需要 Task 和 Task Item 两层模型?

参考回答:Task 描述一次批量动作的整体状态,例如总行数、成功数、失败数和当前阶段;Task Item 描述每一行、每个商品、每个 Offer 的处理结果。没有 Item 层,就无法定位“第几行为什么失败”,也无法支持部分成功、错误文件和行级重试。

问题 10:供给任务如何做幂等?

参考回答:入口层要有业务幂等键,例如 task_type + trigger_id、文件 Hash、供应商批次号或变更单号。发布层要用 publish_idbase_publish_version 和唯一约束防止重复写正式表。Outbox 消费侧也要支持事件幂等,避免重复刷新缓存、索引或下游配置。

问题 11:大文件批量导入如何避免内存爆和长事务?

参考回答:上传后先落对象存储,异步流式解析文件,按批次写入 Task Item,不把全文件一次性加载到内存。校验和标准化由 Worker 分片处理,发布时按商品或变更单分批提交,避免一个 10 万行文件变成一个超大事务。

问题 12:商品供给链路中哪些校验必须前置?

参考回答:字段格式、必填项、类目属性、SPU / SKU / Offer 关系、价格、库存来源、履约规则、退款规则、渠道和站点可售性都要前置校验。尤其是交易契约类字段不能只在下单时兜底,否则线上商品看似发布成功,实际无法购买或无法履约。

问题 13:类目模板变更后,历史商品怎么处理?

参考回答:类目模板要版本化,历史商品保留创建或上次发布时的模板版本。模板升级后可以触发质量巡检或迁移任务,对缺失新必填属性的商品标记风险、限制重新发布或要求运营补齐,不能静默把历史商品全部判为非法。

问题 14:哪些运营变更需要强审核?

参考回答:高风险字段需要强审核,例如价格大幅调整、批量下架、退款规则收紧、履约方式变更、类目迁移、供应商字段覆盖、C 端展示敏感文案和活动标签。系统可以用 Diff + 风险评分决定是否自动通过、二次确认或进入人工审核。

问题 15:为什么发布时要校验 base_publish_version

参考回答:供给变更通常基于某个旧版本编辑。如果发布时正式商品已经被其他任务修改,直接覆盖会丢失别人刚发布的变更。base_publish_version 用来做乐观并发控制,发现版本不一致时进入冲突处理,让运营选择合并、放弃或重新编辑。

问题 16:商品发布失败如何回滚?

参考回答:发布要尽量把正式表变更和 Outbox 写入放在同一事务中。事务内失败直接回滚;事务后下游刷新失败不回滚主数据,而是进入补偿队列和 DLQ。对于已发布但业务上要撤回的变更,应通过新的反向发布版本恢复,而不是手工改库。

问题 17:下游搜索、缓存或计价上下文刷新失败怎么办?

参考回答:发布事务提交后通过 Outbox 事件驱动读侧投影刷新。消费者失败时记录重试次数、错误原因和 TraceID,超过阈值进入 DLQ,由补偿任务或人工修复重新投递。运营后台要展示“商品已发布但部分投影未同步”的状态;如果是营销活动协同失败,也要展示活动绑定失败原因和重试入口,避免误判为完全成功。

问题 18:错误文件应该包含哪些信息?

参考回答:错误文件要能让运营直接修复问题,至少包含原始行号、商品标识、字段名、错误类型、错误原因、修复建议和是否可重试。对于映射类错误,还应给出候选类目、候选属性或合法枚举值,而不是只写“导入失败”。

问题 19:如何设计商品质量分?

参考回答:质量分可以从内容完整度、类目属性完整度、图片质量、价格有效性、库存可售性、履约规则、退款规则、供应商新鲜度和历史投诉率等维度计算。质量分既用于运营看板,也可以驱动自动下架、限制投放、召回补录和供应商质量考核。

问题 20:运营后台最需要展示哪些链路状态?

参考回答:运营后台要展示任务进度、成功失败数量、当前处理阶段、失败明细、审核状态、发布版本、下游同步状态、错误文件下载、可重试入口和质量巡检结果。后台不是简单 CRUD,而是要把长链路的不确定性运营化。

问题 21:两个运营同时编辑同一个商品怎么办?

参考回答:可以用草稿锁、版本号和 Diff 合并共同解决。编辑态可以提示“商品已被某人编辑”,提交时用 base_publish_version 做并发校验;若版本冲突,则展示双方 Diff,让运营选择覆盖、合并或重新基于最新版本编辑。

问题 22:供应商价格和库存很新,但运营有人工覆盖,系统听谁的?

参考回答:要按字段主导权处理。价格、库存通常由供应商或库存系统主导,但平台可以允许有期限、有原因、有审批记录的人工覆盖。覆盖期内供应商同步只记录 Diff 或进入待审核,覆盖到期后再恢复自动更新,避免人工修复被下一次同步冲掉。

问题 23:商品上下架和发布版本是什么关系?

参考回答:上下架是商品生命周期状态,发布版本是商品数据变更记录。一次发布可以改变上下架状态,但两者不能混为一谈。商品可以有多个历史发布版本,当前只有一个生效版本;上下架还要受质量、库存、价格、渠道、风控和审核状态共同约束。

问题 24:如何支持定时发布和灰度发布?

参考回答:变更单可以携带生效时间、渠道范围、城市范围、人群范围或流量比例。到达生效时间后由发布调度器执行,先写正式版本和 Outbox,再按范围刷新搜索、缓存和计价上下文;如涉及营销活动,同步发起营销活动配置或资格变更命令。灰度期间要能观察转化、投诉和履约异常,必要时快速撤回。

问题 25:自动下架会带来什么风险?

参考回答:自动下架能阻止缺图、缺价、无库存或无履约规则的商品继续售卖,但也可能误伤 GMV 和活动资源。设计时要区分阻断级、告警级和观察级问题;高风险商品自动下架,低风险商品先告警并给运营修复窗口,同时保留审计和恢复入口。

问题 26:供给链路如何做审计追踪?

参考回答:每次变更都要记录操作者、来源入口、TraceID、原始输入、标准化结果、Diff、审核记录、发布版本、下游事件和补偿记录。审计不是只看最终商品表,而是要能还原“谁在什么时候因为什么把商品改成了什么”。

问题 27:如果面试官让你一句话概括架构,你怎么说?

参考回答:我会说商品供给与运营链路的核心是“统一入口、暂存隔离、质量校验、风险审核、版本发布、事件同步、失败补偿和运营可观测”。它的目标不是让运营能改商品,而是让商品从各种入口进入平台后,稳定、可控、可追溯地变成可售供给。

5.6 业务架构图表述模板

来源:第5章 业务架构与上下文映射

面试与评审中的表述模板:「业务架构图不是组织架构图;它描述的是能力边界与语义所有权。若两个团队共改一张宽表,通常意味着限界上下文识别失败或缺少明确的集成契约。」

5.7 搜索导购容量估算口径

来源:第12章 搜索与导购

容量估算(面试与立项常问)可以按乘法拆解:峰值 QPS × 每页条数 ×(Hydrate 下游 RPC 数)≈ 下游扇出;再叠加 重试风暴 系数(建议保守取 1.3~1.8)。ES 侧则关注 分片热点(超大店、超级品牌)与 聚合查询 对 CPU 的抢占;必要时对 shop 场景做 单独索引或单独副本组,把热点从全站检索隔离出去,成本换稳定性。

5.8 bool 查询语义高频点

来源:第12章 搜索与导购

bool 查询语义 是面试高频点:must 参与评分,适合承载关键词相关性;filter 不计分且可缓存,适合承载「硬门槛」。实践中常见错误是把「品牌=耐克」放在 must 里参与打分,导致品牌词意外影响相关性曲线;更推荐 品牌进 filter,把「品牌相关 boost」交给 function_score 或在精排阶段处理。另一个错误是把大量 低选择性 条件全部堆在 must,使 _score 退化为常数,精排阶段只能「白手起家」——这会放大后续服务压力。

5.9 搜索深分页标准答法

来源:第12章 搜索与导购

面试标准答法:C 端深分页用 search_after + 稳定 tie-breaker(如 spu_id);随机跳页用产品约束(最多第 N 页)或改写交互。

5.10 搜索系统边界追问

来源:第12章 搜索与导购

面试追问小结(边界类):「搜索是不是商品中心的一部分?」标准回答是 :商品中心是主数据真相源;搜索维护 派生读模型 以优化检索与排序特征。「为什么不在 ES 里算最终价?」因为价格解释依赖会员、渠道、动态规则与版本,索引天然滞后;列表允许弱一致,交易必须强一致。「推荐能否直接调用搜索接口拿候选?」不建议默认耦合:推荐候选集生成逻辑与搜索意图不同,但可以在 特征与埋点层复用

5.11 搜索导购一句话总结

来源:第12章 搜索与导购

若你用一句话向面试官总结本章,可以这样说:搜索索引解决「找得到与排得动」,Hydrate 解决「展示得像且不太假」;交易链路再用强一致服务解决「买得对」。 三条链路各司其职,边界清晰,才能把高并发读路径做成 可演进、可回滚、可观测 的工程系统,而不是一堆 DSL 拼接技巧。

5.12 购物车与结算域追问答法

来源:第13章 购物车与结算

面试映射:面试官若问「购物车要不要用分布式锁」,优先回答「行级乐观锁 + Redis 原子写足够,锁购物车会放大死锁与热点」;若问「结算页是不是微服务必须的拆分点」,可以回答「逻辑边界必须清晰,物理部署可渐进」——先把包边界与数据边界立住,比一上来拆两个集群更有性价比。

5.13 订单系统白板答辩串联

来源:第14章 订单系统

面试与答辩串联:若需要在白板前讲解订单系统,推荐顺序为:先画主状态机,再画创单 Saga 时序,再补幂等键与补偿表,最后点出「订单不直连渠道」。评委追问超卖时,回到库存 Reserve 与 CAS;追问重复支付时,回到支付幂等与订单状态机;追问消息丢失时,回到 Outbox。把四条线闭合,通常比堆技术名词更有说服力。

5.14 支付系统子系统职责对照

来源:第15章 支付系统

子系统职责对照(面试与评审常用):

子系统核心职责关键数据典型反模式
账户系统余额、冻结、流水、充值提现账户余额、冻结单、账务流水把渠道手续费写进用户余额宽表
支付网关路由、报文、签名、重试渠道请求 / 响应摘要、路由决策在网关里改支付单状态机
支付核心支付单、退款单、幂等、OutboxpaymentrefundoutboxHandler 直连第三方 SDK
清结算引擎分账、批次、结算周期分账明细、结算单、提现单清结算回调里直接操作支付单
风控系统限额、名单、挑战风控决策流水风控结果不落库导致无法复盘

5.15 支付系统边界反模式

来源:第15章 支付系统

反模式清单(面试与评审可直用):在支付服务里写供应商下单、在支付服务里计算运费、在支付回调里直接改 SKU 库存、在支付库里维护商品税率版本。它们共同症状是:支付发布频率被迫与业务域绑定,任何小改动都触碰资金链路。

5.16 供应商同步重复下发答法

来源:附录F 供应商数据同步链路

面试时可以强调:重复下发不是靠“大家约定别点两次”解决,而要靠任务互斥策略和数据库状态控制解决

5.17 商品供给系统核心判断

来源:附录G 商品供给与运营治理平台

面试时可以先强调:商品供给系统最难的不是“建商品表”,而是入口治理、暂存隔离、质量校验、风险审核、发布一致性和失败运营化

5.18 库存语义混淆答辩提示

来源:第9章 库存系统

面试和架构评审里最容易犯的错误,是把这些语义混成一个 stock 字段。这样短期能跑,长期一定会在支付重试、订单关单、退款回补、供应商晚到回调和运营手工调整时失控。

5.19 商品供给与供应商同步关系判断

来源:第16章 电商系统综合设计

面试时可以先给出这个判断:

供应商同步是商品供给链路的一种入口,但不能把商品供给系统等同于供应商同步系统。商品供给平台要统一承接人工创建、批量导入、运营编辑、库存创建 / 修改和供应商同步;其中供应商同步是最复杂的自动化供给分支,需要独立的同步任务、Checkpoint 和数据治理链路。

5.20 人工创建链路答辩提示

来源:第16章 电商系统综合设计

面试时可以强调:人工创建链路最容易被低估。真正难点不是页面表单,而是类目差异、交易契约完整性、审核证据和发布一致性。

5.21 商品供给统一治理总结

来源:第16章 电商系统综合设计

面试总结

我不会把商品供给设计成后台 CRUD,也不会把供应商同步、人工运营和库存运营割裂成几套互不相干的系统。我的设计是统一供给治理平台:人工创建、批量导入、运营编辑、库存创建 / 修改和供应商同步都先进入任务和暂存区,通过标准化、质量校验、Diff、差异化审核、版本化发布、Outbox、补偿和巡检后,再写入正式商品主数据和交易契约。供应商同步属于供给链路,但因为它涉及 Raw Snapshot、Checkpoint、Worker 租约、DLQ 和数据新鲜度,所以作为专项链路单独展开。这样既能保证入口统一,又能处理不同来源的复杂度差异。


5.22 供应商同步失败治理答法

来源:第16章 电商系统综合设计

面试时可以强调:供应商同步系统不是追求 100% 同步成功,而是要做到失败可定位、可隔离、可修复、可补偿

5.23 供应商同步 DLQ 总结

来源:第16章 电商系统综合设计

面试时可以这样总结:

我会把供应商同步 DLQ 设计成 MySQL 主存储,而不是只依赖 Kafka 死信 Topic。因为同步失败往往不是单纯消息消费失败,而是字段缺失、映射失败、价格异常、发布失败这些需要人工修复、状态流转和审计的问题。Kafka 可以作为失败消息的缓冲层,但真正的死信治理要落 MySQL,记录供应商、外部编码、平台映射、错误阶段、payload 引用、重试次数、下次重试时间和处理状态。这样问题才能被查询、补偿、统计和运营化处理。

5.24 供应商同步整体总结

来源:第16章 电商系统综合设计

面试总结

我不会把供应商同步理解成“写几个定时任务拉数据”。在 OTA、O2O 和虚拟商品平台里,供应商同步本质上是一套外部供给数据治理体系。它要解决幂等映射、版本回溯、质量校验、新鲜度控制、失败补偿和监控治理。列表页可以使用缓存提升性能,详情页要更接近实时,创单前必须实时确认。这样才能在供应商接口不稳定、商品模型不一致、价格库存频繁变化的情况下,仍然保证平台商品数据可信、交易链路安全、问题可追踪。


5.25 搜索导购链路总结

来源:第16章 电商系统综合设计

面试时可以这样总结:搜索导购不是单纯 ES 查询,而是“召回 + 排序 + 商品补齐 + 库存价格营销融合 + 降级”的完整读链路


附录:常见技术方案速查

缓存策略

策略适用场景优点缺点
Cache Aside通用实现简单缓存穿透风险
Read Through读密集透明缓存实现复杂
Write Through读写均衡一致性强写性能差
Write Behind写密集写性能好数据可能丢失

分布式事务

方案一致性性能复杂度适用场景
2PC强一致金融、支付
TCC最终一致订单、库存
Saga最终一致长流程
本地消息表最终一致通用

限流算法

算法平滑性突发处理实现难度
计数器简单
滑动窗口
令牌桶
漏桶最好

分库分表

维度用户ID分片订单ID分片时间分片
用户查询★★★★★★☆☆☆☆★★☆☆☆
订单查询★★☆☆☆★★★★★★★★☆☆
数据均匀★★★☆☆★★★★★★★☆☆☆
扩容难度

常用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

结语

本面试题精选覆盖了电商系统的核心技术栈,从系统架构到具体实现,从理论到实践。建议读者:

  1. 系统学习:按章节顺序学习,建立完整知识体系
  2. 动手实践:核心算法和代码自己实现一遍
  3. 举一反三:理解原理,能够应用到实际项目
  4. 持续更新:技术在演进,保持学习新技术

面试技巧

  • 先理清思路,再动手画图
  • 多提供几种方案,对比优缺点
  • 关注非功能性需求(性能、可用性、扩展性)
  • 结合实际项目经验

祝各位求职顺利!🎉

附录C 系统集成模式速查表

附录D 术语表

附录E 参考资料

分布式 ID 与唯一性

附录F 供应商数据同步链路

1. 背景

数字商品平台需要从外部供应商同步供给数据。本方案讨论的是一条通用的供应商数据同步链路,并以酒店供给全量同步为例展开。酒店数据规模大、结构复杂、变化频率不一致:酒店名称、地址、设施、图片等静态信息变化较慢;房型、套餐、最低价、可售状态等半动态信息需要更高频刷新;下单前房态房价必须实时确认。

本设计聚焦一个典型任务:

通过遍历所有城市,从供应商拉取酒店信息
酒店规模约 100 万
任务预计运行 10 小时
需要支持断点续跑、失败补偿、数据追溯和质量监控

这类任务不能只依赖进程内状态做一个长循环。第一阶段更推荐设计成 Batch + Checkpoint + DLQ 的可恢复流水线:任务可以按城市和分页顺序遍历,进度持久化在数据库里,失败后从 checkpoint 继续。任务分片和分布式 Worker 抢占可以作为后续优化项目,而不是一开始就进入主链路。

2. 设计目标

  1. 可恢复:任务中断后可以从 checkpoint 继续,不从头重跑。
  2. 可追溯:保存供应商原始数据 Raw Snapshot,支持问题排查和回放。
  3. 可治理:通过标准化、质量校验、Diff、版本控制,避免错误数据污染平台模型。
  4. 可补偿:失败数据进入 DLQ,支持自动重试、人工修复和重新投递。
  5. 可观测:实时查看任务进度、失败原因、供应商质量和业务影响指标。
  6. 不影响交易安全:列表页可缓存,详情页更接近实时,创单前必须实时确认。

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 + 索引补偿

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

图文件:

  • ecommerce-book/images/supplier-sync-architecture.png
  • ecommerce-book/images/supplier-sync-architecture.svg
  • ecommerce-book/images/supplier-sync-data-flow.png
  • ecommerce-book/images/supplier-sync-data-flow.svg

5. 任务模型

5.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

5.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

6. 任务创建、互斥与执行恢复

6.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

6.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
  → 检查数据范围是否重叠
  → 不重叠才允许创建

相关答辩提示已统一收录到附录B

6.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。

6.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 日志。

6.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 抢走。

6.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 同时处理同一页,只是其中一个更慢。

6.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 负责减少重跑范围,幂等负责保证重复处理也不会写错。

6.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 秒续租心跳。如果一页处理时间可能超过心跳间隔,则需要独立心跳协程,不能等整页处理完成才心跳。

6.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 恢复

7. Checkpoint 与断点续跑

7.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
  → ...

7.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 更新时间

7.3 Checkpoint 是什么

Checkpoint 是同步任务“跑到哪里了”的进度记录。它用于断点续跑。

示例:

{
  "city_code": "BKK",
  "page": 120,
  "cursor": "abc123",
  "last_supplier_hotel_id": "H998877"
}

如果 Bangkok 第 120 页失败,下次可以从 page 120 或 cursor abc123 继续,而不是从第一页重跑。

7.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 只能保证“不大范围重跑”,不能保证“绝不重复处理”。因此它必须和幂等设计配合使用。

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. 供应商异常时熔断。

9. Raw Snapshot 与标准化

9.1 Raw Snapshot

Raw Snapshot 是供应商原始响应数据的快照。它不是平台商品模型,也不是最终发布数据,而是证据和可回放数据。

作用:

  1. 排查问题:线上价格或酒店信息异常时,可以还原供应商当时返回了什么。
  2. 支持回放:修复映射规则后,可以用原始数据重新跑同步。
  3. 支持 Diff:比较本次和上次数据变化。
  4. 明确责任:区分供应商数据错误和平台清洗映射错误。

9.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

9.3 标准化

供应商字段需要转换成平台标准模型:

供应商字段平台字段
supplier_hotel_idsupplier_resource_code
hotel_nameresource_name
city_codeplatform_city_id
addressaddress
latitudegeo.lat
longitudegeo.lng
facilitiesext_info.facilities

标准化后生成 NORMALIZED snapshot。

10. 质量校验

质量校验分为五层:

校验层校验内容失败处理
Schema 校验必填字段、类型、枚举、时间格式、货币单位进入失败明细
主数据校验城市、国家、商圈、品牌是否存在进入人工映射
模型校验是否能映射 Resource / SPU / SKU / Offer阻断发布
交易校验价格异常、库存异常、可售状态矛盾高风险拦截
业务规则校验站点、渠道、品类是否允许售卖审核或灰度

质量校验要支持部分成功。100 万酒店同步中,不能因为 100 条失败就整批失败。

成功数据:继续发布
失败数据:写入 DLQ
高风险数据:进入审核或人工修复

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套餐、房型、房价计划等销售配置

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

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. 营销、计价、订单读取新版本商品上下文。

14. DLQ 与补偿

14.1 为什么用 MySQL DLQ

酒店同步失败通常不是单纯消息失败,而是字段缺失、映射失败、价格异常、发布失败、索引失败等需要人工修复、状态流转和审计的问题。因此推荐:

Kafka DLQ:短期失败消息缓冲,可选
MySQL DLQ:权威问题单和补偿状态

14.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

14.3 状态机

PENDING
  → RETRYING
  → RESOLVED

PENDING
  → MANUAL_FIX
  → RETRYING
  → RESOLVED

PENDING
  → IGNORED

RETRYING
  → FAILED

14.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)

15. 监控指标

指标类型指标
任务进度总城市数、已完成城市数、当前城市、当前 page/cursor
处理统计酒店总数、成功数、失败数、跳过数
性能指标任务耗时、供应商 QPS、平均耗时、P99 耗时
质量指标字段缺失率、映射失败率、重复数据率、异常价格率
新鲜度指标数据延迟、过期数据比例、热门酒店刷新延迟
补偿指标DLQ 数量、重试成功率、人工修复数量
下游指标ES 刷新失败数、缓存刷新失败数、事件发布失败数

核心指标公式:

同步成功率 = 成功处理酒店数 / 总酒店数
映射失败率 = 映射失败酒店数 / 总酒店数
字段缺失率 = 缺失关键字段酒店数 / 总酒店数
数据新鲜度延迟 = now - last_success_sync_time
DLQ 修复率 = resolved_dlq_count / total_dlq_count

16. 异常场景

异常处理
某城市同步失败从该城市对应 checkpoint 继续
某页接口超时从 page checkpoint 重试
单个酒店字段缺失写入 DLQ,不阻塞整批
供应商限流降低 worker 数,指数退避
城市映射失败进入人工映射,修复后重新投递
ES 刷新失败Outbox 补偿重试
发布版本异常保留旧版本,新版本不生效

17. 答辩材料

本专题相关总结、常见问题和参考回答已统一收录到附录B

18. 后续优化项目

18.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='供应商同步分片';

18.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 继续执行。

18.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 才更有价值。

18.4 为什么放在后续优化

任务分片和分布式 Worker 会引入额外复杂度:

  1. Shard 状态机。
  2. Worker 租约和心跳。
  3. 旧 Worker 恢复后的并发写保护。
  4. 跨 shard 的批次统计聚合。
  5. 热点城市和长尾城市的任务倾斜。

如果第一阶段的 10 小时任务可以接受,优先实现 Batch + Checkpoint + DLQ 的简单闭环。等同步窗口、供应商限流、数据规模或恢复时间成为瓶颈,再引入 shard 和分布式 Worker。

附录G 商品供给与运营治理平台

1. 背景

商品供给与运营治理平台解决的是“商品如何进入平台、如何被审核发布、如何创建和修改库存、上线后如何持续维护”的问题。它不是运营后台的 CRUD,也不是供应商同步的一组定时任务,而是一条长期运行的供给治理流水线。

在数字商品平台中,商品供给来源通常有五类:

人工创建/上传
  → 运营或商家从 0 到 1 创建商品

批量导入
  → 通过模板、Excel、CSV 或文件批量创建和修改商品

运营编辑
  → 对线上商品做标题、图片、类目、价格、库存、上下架、履约和退款规则变更

库存创建 / 修改
  → 初始化库存、补货、导入券码、系统生码、锁库存、门店和日期库存调整

供应商同步
  → 从外部供应商全量、增量、Push 或主动刷新供给数据

供应商同步属于商品供给链路,但它不是商品供给链路的全部。更合理的设计是:用一套统一的供给治理控制面承接五类入口,共享任务模型、暂存区、校验、审核、发布版本、Outbox、DLQ、补偿和质量监控;其中供应商同步因为有长任务、Checkpoint、Raw Snapshot、Worker 租约和数据新鲜度问题,单独作为专项链路展开。供应商同步的完整设计见附录F:供应商数据同步链路

本附录聚焦统一供给治理平台,尤其补足人工上传、批量导入、运营编辑和库存运营四条控制面链路。

2. 设计目标

  1. 入口统一:人工创建、批量导入、运营编辑、库存创建 / 修改、供应商同步进入统一任务和发布框架。
  2. 线上隔离:所有未校验、未审核、未发布的数据只进入 Draft / Staging,不污染正式商品表。
  3. 质量可控:通过类目模板、主数据校验、交易契约校验、风险规则和审核流控制发布质量。
  4. 发布一致:商品主数据、资源映射、Offer、库存控制面、履约规则、退款规则、营销协同、搜索索引、缓存和计价上下文最终一致。
  5. 失败可恢复:任务、行级明细、错误文件、DLQ、Outbox 和补偿任务形成闭环。
  6. 变更可追溯:每次发布都有 Diff、审核记录、操作者、TraceID、发布版本和商品快照。
  7. 运营可用:运营能看到任务进度、失败原因、错误文件、审核状态、发布结果和质量报表。

3. 核心难点与解决方法

难点典型表现风险解决方法
入口多且语义不同人工创建、批量导入、运营编辑、库存创建 / 修改、供应商同步都在改变供给能力流程混乱、重复逻辑、审计缺失统一为 Supply Task,但按 task_type 路由不同策略
未发布数据污染线上表单保存、导入半成品直接写商品正式表前台展示脏数据,订单拿到半成品契约Draft / Staging 与正式表分离,只有发布事务写正式表
类目差异大酒店、话费、账单、券码、电影票字段完全不同表单和校验 if-else 爆炸类目模板 + 能力矩阵 + Schema 驱动表单和校验
批量导入规模大大促前一次导入 10 万行商品或价格内存爆、长事务、失败难定位流式解析、行级任务、分批处理、部分成功、错误文件
运营误操作批量改价、类目迁移、退款规则变更资损、投诉、履约失败Diff、风险评分、二次确认、人工审核、灰度发布
供应商与运营冲突供应商同步覆盖运营修正字段运营修复失效,线上数据反复抖动字段主导权、保护期、版本锁、冲突日志
审核策略粗糙所有变更都人工审核或全部自动通过效率低或风险失控风险分级:低风险自动,中风险规则校验,高风险强审
发布不一致DB 成功,ES / 缓存 / 计价上下文没刷新,或营销活动协同失败搜不到、价格错、活动不可用、下单失败发布事务 + Outbox + 营销命令 + 异步投影 + 补偿重试
历史订单受影响商品改价、改退款规则后影响旧订单售后争议、财务对不上创单保存商品快照、报价快照、履约和退款规则快照
失败不可运营只在日志里记录导入失败运营不知道怎么修MySQL DLQ + 错误文件 + 修复建议 + 重新投递
质量缺陷长期存在缺图、缺价、无库存、无履约规则转化差、履约失败商品质量巡检、质量分、自动下架或告警

核心判断已统一收录到附录B

4. 总体架构

架构图如下:

商品供给与运营治理平台总体架构

图源文件:

  • ecommerce-book/images/product-supply-ops-architecture.png
  • ecommerce-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、补偿任务、质量日报

4.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

5. 领域边界

商品供给与运营平台不应该替代商品中心、库存系统、计价系统、搜索系统、订单系统或营销系统。它的职责是“供给流程和发布治理”,不是所有商品数据的唯一存储,也不是到处同步写下游的超级后台。

系统负责什么不负责什么
供给与运营平台入口、任务、暂存、校验、审核、发布编排、库存创建 / 修改运营入口、营销活动配置入口、补偿、审计C 端高 QPS 商品查询、库存扣减、库存账本事实、计价试算、搜索索引直写、订单状态维护、营销优惠计算
商品中心Resource、SPU、SKU、Offer、Rate Plan、类目、属性正式模型运营任务进度和错误文件
库存系统库存事实、库存创建命令执行、库存扣减、券码池、实时可售、库存账本商品标题、图片、类目、运营审核流
计价系统价格规则、试算、应付金额、优惠叠加商品生命周期审核
营销系统活动、券、补贴、预算、营销库存、圈品规则、优惠计算规则商品供给流程、商品生命周期和库存账本
搜索系统可检索字段、召回、排序、索引刷新商品发布事务
订单系统商品快照、报价快照、履约契约快照商品最新主数据维护

设计原则:

  1. 供给平台负责流程,商品中心负责正式模型。
  2. 库存创建 / 修改的运营入口在供给平台,库存事实和扣减账本在库存系统。
  3. 搜索、缓存、计价上下文和数据平台通过 Outbox 事件感知变更,不由运营后台直接写入。
  4. 营销系统通过活动配置命令或营销资格事件协同,但活动规则、预算、券、补贴、营销库存和优惠计算仍归营销系统。
  5. 订单只相信创单时保存的快照,不回读最新商品配置解释历史订单,也不由供给平台直接修改订单。

6. 任务模型

6.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='商品供给任务';

6.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='商品供给任务明细';

6.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人工取消

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. 支持失败后修复并重新发布。

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}
  ]
}

9. 批量导入链路

批量导入适合大促、类目迁移、商家批量上新、套餐批量配置。

下载模板
  → 上传文件
  → 文件格式预检
  → 创建 product_supply_task
  → 流式解析
  → 每行生成 product_supply_task_item
  → 分批标准化
  → 行级校验
  → 成功项发布或审核
  → 失败项生成错误文件
  → 汇总任务状态

核心难点与解决方法:

难点解决方法
文件过大流式解析,不一次性读入内存
导入耗时长分批提交,后台异步执行,前台轮询进度
局部失败行级状态,成功项继续,失败项生成错误文件
重复上传task_type + trigger_id 幂等,行级 idempotency_key 去重
模板演进文件记录 template_version,旧模板兼容或拒绝
批量事故高风险字段批量变更进入抽样审核或二次确认
下游被打爆发布和索引刷新限速,使用 Outbox 背压

9.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. 发布和下游刷新可以限速、重试和补偿。

9.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 控制重跑范围,幂等保证重复处理不写错。

9.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自动重试控制

9.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 行。正确做法是行级或小批次事务,否则一行失败会拖垮整批,也会造成长事务、锁等待和回滚成本过高。

9.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 消费者异步刷新;营销活动配置走营销系统命令或营销资格事件,不在发布事务内同步写营销规则。

9.6 Task 状态汇总

Task 状态不要靠 Worker 主观判断,而要从 item 状态聚合。

Item 汇总结果Task 状态
全部 SUCCESSPUBLISHED
部分 SUCCESS,部分 FAILED/DLQPARTIAL_FAILED
全部失败FAILED
存在 REVIEWINGREVIEWING
存在 PUBLISHINGPUBLISHING
任务被人工取消CANCELLED

统计可以每批 item 处理完成后增量更新,也可以由定时聚合 Job 修正。运营后台看到的进度来自 task 计数,但失败定位必须下钻到 item。

9.7 失败处理

批量异步链路的失败要按阶段处理:

失败阶段示例处理
文件级失败文件损坏、模板版本不支持、列结构缺失task 直接 FAILED,不生成大量 item
行级格式失败价格非法、字段缺失、枚举非法item FAILED,写错误文件
主数据失败城市、商户、品牌不存在item DLQMANUAL_FIX
风险失败改价过大、退款规则变化change_request REVIEWING
发布失败版本冲突、DB 冲突、唯一键冲突item 延迟重试,超过次数进 DLQ
下游失败ES、缓存刷新失败Outbox 补偿,不回滚发布事务

错误文件应该从 product_supply_task_itemproduct_validation_result 生成,而不是从日志拼出来。

9.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

10. 运营编辑链路

运营编辑针对线上商品,需要解决“谁能改、改什么、是否覆盖供应商数据、什么时候生效、如何回滚”的问题。

读取当前 publish_version
  → 创建编辑草稿
  → 修改字段
  → 生成 Diff
  → 字段主导权判断
  → 风险评分
  → 自动通过 / 人工审核 / 阻断
  → 发布新 publish_version
  → Outbox 通知读侧投影
  → 营销活动配置异步协同

10.1 字段主导权

字段主导方供应商同步能否覆盖运营策略
标题、卖点、活动标签平台运营运营编辑为准
酒店名称、地址、设施供应商/平台治理低风险可覆盖,高风险审核可人工修正并设置保护期
展示图片平台运营/供应商取决于来源质量图片变更需要质量校验
基础价、Rate Plan供应商/计价取决于品类超阈值审核
库存水位、可售状态库存域/供应商人工覆盖必须有有效期
退款规则、履约规则平台/供应商契约高风险覆盖强制审核
类目、Resource 映射平台治理强制审核和数据巡检

10.2 冲突处理

常见冲突:

运营改了酒店名称
  → 供应商增量同步又推回旧名称

运营批量下架一批商品
  → 供应商同步推送可售状态为可售

运营修复城市映射
  → 供应商全量同步发现城市字段不同

解决方法:

  1. 对每个字段定义 owner_type:OPS、SUPPLIER、SYSTEM。
  2. 运营覆盖供应商字段时记录 override_untiloverride_reason
  3. 供应商同步遇到运营保护字段时只记录 Diff,不自动覆盖。
  4. 高风险冲突进入审核队列。
  5. 保护期到期后由巡检任务决定是否恢复供应商主导。

11. 标准化与质量校验

质量校验要分层,不要只做字段必填。

校验层校验内容失败处理
Schema 校验类型、必填、枚举、长度、格式行级失败
类目模板校验类目要求的对象和字段是否完整阻断提交
主数据校验城市、商户、品牌、Resource 是否存在进入人工映射
商品模型校验SPU、SKU、Offer、Rate Plan 关系是否成立阻断发布
交易契约校验库存来源、Input Schema、履约规则、退款规则阻断发布
可售校验商品状态、库存、价格、渠道、站点是否允许售卖阻断上线或告警
风险校验价格、类目、履约、退款、映射是否高风险进入审核

质量分可以作为运营看板:

quality_score =
  content_score
  + model_score
  + sellability_score
  + fulfillment_score
  + risk_score

如果商品缺图、缺价、无库存、无履约规则,即使主表写入成功,也不能认为供给成功。

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

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

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 补偿重试

15. 可观测性

运营后台和监控系统要能回答五个问题:

  1. 任务跑到哪里了?
  2. 为什么失败?
  3. 谁需要处理?
  4. 修复后如何重新投递?
  5. 发布后前台是否真的可见、可买、可履约?

核心指标:

指标类型指标
任务进度总数、成功数、失败数、跳过数、当前阶段
效率指标任务完成耗时、P95 / P99、排队时间
质量指标字段缺失率、映射失败率、缺图率、缺价率、无库存率
审核指标自动审核占比、人工审核耗时、驳回率
发布指标发布成功率、版本冲突数、回滚次数
下游指标ES 刷新失败数、缓存失效失败数、Outbox 堆积
运营指标错误文件下载次数、人工修复耗时、DLQ 修复率

质量巡检任务:

  1. 缺图商品巡检。
  2. 缺价商品巡检。
  3. 无库存商品巡检。
  4. 无履约规则商品巡检。
  5. 退款规则缺失巡检。
  6. 搜索索引与发布版本一致性巡检。
  7. 缓存版本与发布版本一致性巡检。
  8. 运营覆盖字段到期巡检。

16. 典型异常场景

异常风险处理
运营重复点击提交重复创建任务task_type + trigger_id 幂等
导入文件 10 万行中 500 行失败整批回滚影响效率部分成功,失败行生成错误文件
发布时发现版本冲突覆盖别人刚发布的变更base_publish_version 乐观锁,要求重新编辑
审核通过但 ES 刷新失败前台搜不到Outbox 补偿刷新
商品发布成功但库存未初始化前台可见不可买可售校验不通过,不进入 ONLINE
运营改标题后被供应商覆盖人工修复失效字段主导权和保护期
大批量改价低于底价资损风险规则阻断,人工审核
退款规则变更影响历史订单售后争议订单保存退款规则快照
质量巡检发现缺履约规则下单后无法履约自动下架或阻断可售,进入 DLQ

17. 与供应商同步的关系

统一供给治理平台和供应商同步专项链路的关系如下:

统一供给治理平台
  → 统一任务模型
  → 统一暂存与发布模型
  → 统一校验、Diff、审核、Outbox、补偿

供应商同步专项链路
  → Raw Snapshot
  → Sync Batch
  → Checkpoint
  → Worker Lease
  → Supplier Mapping
  → 数据新鲜度

供应商同步产生的标准化数据最终也应该进入供给治理平台的校验、Diff、审核和发布机制。不同点在于,供应商同步多了拉取、分页、断点续跑、租约抢占、原始快照和供应商质量监控。

18. 答辩材料

本专题相关总结、常见问题和参考回答已统一收录到附录B

附录H 全局 ID 体系设计

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 体系是电商系统的基础设施治理问题,不只是一个工具函数问题。

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,它表达“同一次业务请求”,必须配合唯一索引和状态机来防止重复下单、重复扣款或重复退票。

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 的核心不是“看起来唯一”,而是业务上能判断“这是不是同一次请求”。

4. 常见发号方案对比

4.1 DB 自增

DB 自增是最简单的方案:表主键使用 AUTO_INCREMENT 或数据库原生 identity。它适合单库单表、小规模后台配置、内部字典表和教学示例。

优点是简单、强一致、无需额外服务。缺点也明显:强依赖单库,跨库分表困难;连续递增容易暴露业务量;高并发交易链路可能把数据库打成瓶颈。

在电商系统中,DB 自增可以用于后台低频配置表,但不建议直接作为对外订单号、支付单号或全局 SKU ID。

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。这个方案比直接使用业务表自增稍微解耦,但本质仍是数据库中心化发号。

它适合早期系统、低中并发内部单据和容易理解的教学实现。不适合高并发交易核心,也不适合直接对外暴露连续序列。

4.3 Redis INCR

Redis INCR 可以按 key 递增,例如:

INCR id:order:20260429

再格式化为:

ORD2026042900012345

它的优点是性能高、实现简单、天然适合按天流水号。缺点是强依赖 Redis 高可用和持久化策略;主从切换、数据回滚、双活部署时要谨慎;同时,按天连续递增仍可能暴露业务量。

Redis INCR 适合活动流水、短期批次、低风险业务编号。核心订单和支付单如果使用 Redis INCR,必须设计持久化、主从切换和重复保护。

4.4 Snowflake

Snowflake 是经典的分布式趋势递增 ID 方案。常见实现把一个 64 位整数拆成:

时间戳 + 机器 / 机房标识 + 毫秒内序列

例如常见切分是 41 位毫秒时间戳、10 位机器标识、12 位序列。它的优点是本地生成、低延迟、高吞吐、趋势递增、适合 BIGINT 主键。缺点是依赖时钟,必须治理 worker_id,还要处理时钟回拨。

Snowflake 适合订单内部 ID、支付内部 ID、库存账本 ID、营销 ID,以及需要高并发写入的实体 ID。对外单号可以基于 Snowflake 再格式化,而不是直接暴露原始数字。

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。

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无需协调、跨服务方便字段较长、索引成本高流程单据、事件、链路

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 服务随便生成,而要和用户、购物车快照、请求来源或业务动作绑定。

这个混合架构可以同时满足性能、可读性、治理和扩展性。

6. ID 服务架构

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" 这种裸字符串。裸字符串无法治理,也无法做容量规划和审计。

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”。

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 层各自发明一套规则。

6.4 Segment Generator

Segment Generator 从数据库申请号段,然后在本地内存中发号。为了避免号段耗尽造成请求抖动,应该支持双 Buffer:

当前号段使用到 70% 时,后台预取下一段;
当前号段耗尽时,如果下一段已就绪,立即切换;
预取失败时,继续使用当前号段并告警;
当前号段完全耗尽且无法预取时,返回明确错误。

6.5 Snowflake Generator

Snowflake Generator 的关键不是位运算,而是 worker 治理:

  1. worker_id 不能靠配置文件随手写,应该由租约表、注册中心或部署平台分配。
  2. 实例启动时申请 worker,定期心跳,退出或过期后释放。
  3. 发现时钟回拨时,要短暂等待、切换 worker 或熔断,而不是继续发号。
  4. 多机房部署时,要预留 region 或 datacenter 位。

6.6 ULID / UUIDv7 Generator

这类生成器适合本地生成,但仍然要受 namespace 约束。推荐格式:

draft_01JABCD...
staging_01JABCE...
qc_01JABCF...
evt_01JABCG...
op_01JABCH...

prefix 不是随意字符串,而是 Registry 中登记过的前缀。这样日志、排障和数据治理可以快速识别 ID 类型。

6.7 Business Number Formatter

业务单号通常不直接等于底层 ID。订单号可以设计为:

ORD + yyyyMMdd + base36(snowflake_id) + check_digit

例如:

ORD20260429CN7K3F9Q2X

这种格式便于客服和对账按日期定位,同时不直接暴露连续自增值。校验位可以降低人工录入错误。

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 服务会被审计拖垮。更合理的方式是:常规路径打指标,异常路径写审计。

7. 关键业务 ID 设计

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

两种方案都可行,但必须在附录和代码中讲清楚边界。

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 这类连续单号。它会暴露业务量,也容易被枚举。

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)

这样用户重复点击“提交订单”时,系统返回同一笔订单,而不是生成多笔订单。

7.4 payment_id、渠道单号与对账

支付系统至少要区分三类编号:

字段说明
payment_id平台内部支付主键
payment_no平台对外支付单号
channel_trade_no支付渠道返回的交易号

平台调用渠道时,还需要一个稳定的渠道请求号,例如 out_trade_no。这个请求号通常应该由平台生成,并作为调用渠道的幂等键。不要用渠道返回单号作为平台支付单的唯一依据,因为渠道单号只有调用成功后才出现。

7.5 draft_idstaging_id 与供给审核单

供给流程 ID 推荐使用字符串:

draft_01J...
staging_01J...
qc_01J...

原因是它们不是正式商品资产,不需要像 sku_id 一样参与高频交易查询。字符串 prefix 能快速表达流程类型,便于运营后台、日志检索和问题排查。

关键边界是:Draft、Staging、QC 阶段的 ID 不应替代正式 item_idspu_idsku_id。只有发布事务成功后,商品中心才持有正式商品主数据 ID。

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 投递,也能实现业务上的精确一次效果。

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 服务负责降低冲突概率和统一规则,业务数据库负责最后一道硬约束。

9. 数据库与接口设计

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 命名空间注册表';

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 = ?;

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

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、号段申请、异常、回拨和人工操作。

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
}

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 体系统一治理。

本附录只给出改造方向,不要求立刻重构示例代码。教学代码可以保留简化实现,但正文要让读者知道生产系统应该如何演进。

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 体系还停留在工具函数层面,没有进入基础设施治理层面。

12. 小结

统一 ID 体系的重点不是某个算法,而是按业务语义治理 namespace、生成策略、暴露形式和失败处理。

电商系统中,sku_idorder_nodraft_idevent_ididempotency_key 看起来都叫 ID,但它们解决的问题完全不同。生产级设计应该把它们分开建模:

实体 ID:稳定引用,索引友好
业务单号:对外展示,防枚举,便于对账
流程单据:追踪流程,支持审计
事件 ID:幂等消费,支持重放
幂等键:表达同一次业务请求
链路 ID:贯穿调用链和操作链

推荐的默认架构是:Segment 号段 + Snowflake + ULID/UUIDv7 + 幂等键治理。这套组合不是最炫的方案,但足够贴近真实电商系统:它尊重不同业务场景的差异,也给后续多实例、多机房、开放平台和长期演进留下空间。