第 1 章 系统设计与方案写作:从问题定义到工程落地的决策方法
从系统设计总论到技术方案落地,建立架构师的问题判断、方案表达与评审推进能力。
很多人把系统设计理解成“高并发组件大全”,或者把技术方案理解成“把接口、表结构和流程图拼在一起的文档”。这两种理解都抓住了一部分现实,但都不够完整。
系统设计真正关心的是:在业务目标、资源预算和风险约束下,如何做出一条可落地、可演进、可运维的系统路径。技术方案真正关心的是:如何把这条路径写成一份可评审、可执行、可复盘的设计文档,让不同角色都能基于同一份材料做判断和协作。
所以这一章不再把“系统设计通识”和“技术方案设计方法论”割裂开来,而是把它们放在同一个认知骨架里:
- 前半部分回答“系统设计到底在权衡什么”
- 后半部分回答“架构师如何把这些权衡写成一份真实可执行的 TD”
读完这一章,你应该至少能回答三件事:
- 系统设计不是背组件,而是做边界清晰的工程决策。
- 架构师写 TD 不是堆图和堆接口,而是把关键取舍显式化。
- 一份好的技术方案,既要让评审者看懂,也要让实现者、测试和运维真正能落地。
1. 系统设计总论
1.1 系统设计的核心问题
系统设计并不是“把几个中间件背熟”,而是在给定业务目标、资源预算和风险约束下,设计一条可落地、可演进、可运维的系统路径。无论是做业务系统还是准备面试,真正反复出现的核心问题其实都很稳定:
- 系统要解决什么问题,成功指标是什么。
- 请求链路会经过哪些模块,哪里最容易成为瓶颈。
- 数据放在哪里,以什么方式读写、复制、缓存和恢复。
- 当流量上涨、依赖抖动、机器故障时,系统如何维持可接受的服务质量。
- 当成本、人力和交付时间有限时,哪些复杂度值得引入,哪些应该延后。
很多人把系统设计理解成“画一张架构图”。架构图当然重要,但它只是表达形式,不是设计本身。真正的设计工作包括问题定义、容量估算、模块划分、数据模型、一致性策略、容灾方案、可观测性,以及对各种取舍的显式说明。
如果把系统设计抽象成一句话,可以理解为:围绕性能、容量、可靠性与成本,持续做出边界清晰的工程决策。
1.2 性能、容量与成本的基本权衡
系统几乎不可能同时在所有维度上最优。多数设计问题,最后都落到性能、容量与成本三者之间的平衡。
性能不是只看响应时间
性能通常至少包含两个维度:
- 延迟:单个请求从发起到完成需要多久。
- 吞吐:单位时间内系统能处理多少请求或任务。
低延迟并不等于高吞吐。一个同步串行、逻辑简单的服务可以把单次请求做得很快,但在高并发下很容易被线程、连接数或数据库写入能力卡住。反过来,批处理、异步削峰、顺序写磁盘等策略可能提升整体吞吐,却拉高了单次请求完成时间。
容量估算决定方案上限
很多架构失误不是技术不会,而是没有先做数量级判断。至少要回答这些问题:
- 峰值 QPS 和平均 QPS 分别是多少。
- 读写比是多少,热点是否集中。
- 单条数据多大,按日、按月的增长量如何。
- 可接受的响应时间、失败率、恢复时间分别是多少。
例如,一个读多写少的商品详情系统,常见手段是缓存、读写分离、CDN 和多级副本;而一个写多且顺序性要求高的订单流水系统,就更关注数据库写入能力、消息队列削峰和分库分表策略。同样的“高并发”标签,背后对应的设计完全不同。
成本不仅是机器费用
设计里最容易被低估的,是复杂度带来的隐性成本:
- 开发成本:系统越分散,联调、测试和发布成本越高。
- 运维成本:组件越多,监控、告警、升级、容灾演练越重。
- 认知成本:团队越难理解系统,越容易出现误用和故障。
- 迁移成本:未来替换存储、重构链路、扩容架构时的代价。
所以系统设计不是“配置越豪华越好”,而是“当前阶段最合适”。对小流量业务,上来就做多机房多活通常是不划算的;对资金交易、库存扣减等核心链路,过度节省又会把风险留到线上。
1.3 一致性、可用性与扩展性的取舍
分布式系统设计之所以难,是因为很多需求天然彼此拉扯。
一致性与可用性
一旦进入副本复制、跨机房部署和异步链路,一致性就出现层次差异:
- 强一致性:适合金融交易、余额扣减、强约束库存。
- 最终一致性:适合评论计数、消息通知、搜索索引同步。
- 会话一致性或读己之写:适合用户中心、个人设置等体验敏感场景。
一致性要求越强,往往意味着写入路径更重、故障场景更复杂、可用性成本更高。相反,如果系统接受短暂不一致,就能利用缓存、异步复制、消息队列和多副本读扩展获得更好的可用性与吞吐。
可扩展性不是简单“加机器”
扩展性至少分三类:
- 垂直扩展:升级单机 CPU、内存、磁盘。
- 水平扩展:增加实例、副本、分片。
- 功能扩展:在不推翻既有系统的前提下增加新能力。
水平扩展是互联网系统的主路线,但代价是系统从“本地调用”变成“远程协作”:需要负载均衡、服务发现、分布式缓存、消息重试、链路追踪、配置管理,以及对网络分区和局部失败的处理。
常见权衡例子
| 场景 | 优先目标 | 常见手段 | 代价 |
|---|---|---|---|
| 商品详情、资讯流 | 可用性与读性能 | CDN、缓存、读写分离 | 可能读到旧数据 |
| 订单创建、支付扣减 | 一致性与正确性 | 事务、幂等、串行化关键写路径 | 吞吐更低,链路更重 |
| 日志采集、行为埋点 | 吞吐与扩展性 | 批量、异步、消息队列 | 端到端延迟更高 |
| 搜索与推荐 | 可扩展性与演进速度 | 索引异步构建、近实时更新 | 数据收敛有延迟 |
成熟的答案不是“既强一致又高可用还能无限扩容”,而是先确认业务优先级,再说明愿意牺牲什么、换来什么。
1.4 从单机到分布式的思维切换
系统设计能力真正的分水岭,在于能否完成从单机程序思维到分布式系统思维的转变。
在单机开发中,我们默认很多事情天然成立:
- 函数调用几乎总能立即返回。
- 内存共享简单直接。
- 时钟、状态和数据都集中在本地。
- 故障通常表现为进程退出,而不是链路局部异常。
这些默认前提一旦搬到分布式环境,大部分都失效了。进入分布式系统后,你必须默认以下事实:
- 网络不可靠,调用可能超时、重试、乱序、重复到达。
- 带宽有限,跨机房通信代价高于机房内调用。
- 节点会宕机,副本会延迟,拓扑会变化。
- 远程调用比本地调用慢几个数量级。
- 局部故障很常见,系统要学会降级而不是等全局恢复。
也正因为如此,系统设计会反复出现这些基础设施组件:DNS、负载均衡、反向代理、缓存、消息队列、服务发现、监控告警、链路追踪。它们本质上都是为了应对“单机默认成立、分布式默认不成立”的现实。
1.5 常见系统设计误区
误区一:只背组件,不理解问题
把 MySQL、Redis、Kafka、Elasticsearch、Kubernetes 分别背得很熟,并不自动等于会做系统设计。组件只是手段,关键是知道为什么引入、什么时候不用、出现问题如何兜底。
误区二:先讲方案,后补需求
很多设计讨论一开始就说“这里上 Redis、那里上 MQ”。如果需求边界、读写模型、容量目标都没澄清,方案几乎注定会跑偏。
误区三:忽略数量级
“数据量很大”“流量很高”这种说法几乎没有工程价值。没有 QPS、峰值、存储增长、延迟目标,设计就是悬空的。
误区四:过度追求完美架构
新业务起步阶段最需要的往往不是“终局架构”,而是足够稳定且便于迭代的起点。过早引入微服务、多活、复杂编排,常常会先把团队拖垮。
误区五:只考虑正常流程,不考虑失败流程
一个设计如果没有回答超时、重试、幂等、限流、熔断、降级、回滚、监控这些问题,那它往往只能在 PPT 上运行。
误区六:把面试答案讲成组件清单
好的系统设计表达不是罗列名词,而是沿着“需求 -> 估算 -> 方案 -> 取舍 -> 风险”的顺序推进,让听者知道你是在做工程决策,而不是在背模板。
1.6 系统设计总论小结
系统设计的核心不在于记住多少术语,而在于能否围绕业务目标做出清晰、可解释、可落地的工程选择。到这里先建立三个基本视角:
- 用性能、容量、成本一起看问题,而不是只盯单点优化。
- 用一致性、可用性、扩展性的冲突理解分布式设计的难点。
- 用分布式现实校正单机直觉,接受网络、节点和依赖都会失败。
但对架构师来说,这还不够。真正的挑战不是“想清楚”,而是“写清楚、讲清楚、推动落地”。这也是为什么懂系统设计,最后会自然过渡到会写技术方案。
2. 技术方案设计方法论
2.1 为什么架构师必须会写 TD
很多技术方案质量不高,不是因为实现能力不足,而是因为问题定义模糊、边界不清、关键取舍没有显式化。一个成熟的架构师,不能只会想方案,还必须会把方案写成一份不同角色都能消费的 TD(Technical Design)。
一份好的 TD 至少承担五个作用:
- 对齐理解:让业务、研发、测试、运维对目标和边界达成一致。
- 显式决策:把关键取舍写出来,而不是藏在作者脑子里。
- 组织协作:让跨团队依赖、前置条件、owner、上线顺序变得可讨论。
- 降低返工:在实现前暴露问题,比上线后补锅便宜得多。
- 沉淀知识:后续 review、复盘、交接、新人 onboarding 都能复用。
很多人把 TD 写成三种危险形态:
- 方案写成实施手册:命令、SQL、接口、页面路径很多,但核心设计决策不清楚。
- 方案写成大纲汇总:背景、目标、接口、排期都列了,但没有一条主链路和关键判断。
- 方案写成记录本:会议纪要、争议点、草图、截图都堆在一起,读者无法快速定位“这份方案最终到底怎么定”。
架构师写 TD 的价值,不是文档漂亮,而是让评审者能在有限时间内看清楚:
- 这件事为什么值得做
- 这次到底怎么做
- 为什么这么做
- 哪些地方有风险
- 如果做坏了怎么退
2.2 什么时候需要写技术方案与 TD
不是每个需求都值得写一份完整 TD,但也绝不能把所有设计工作都压缩成几句口头说明。一个简单判断标准是:当一件事已经不是“直接写代码就能安全落地”,而是需要提前澄清边界、做关键取舍、控制风险、对齐多人协作时,就应该写 TD。
从实践看,下面几类场景最适合进入 TD:
- 涉及新业务能力:例如支付、推荐、实时协作、新履约链路、新审批机制。这类需求往往要同时定义模型、接口、状态机和扩展点。
- 涉及核心链路变更:如下单、支付、库存、账户、计价、发布、配置生效等主流程。主链路一旦改动,正确性和回滚成本都会显著上升。
- 涉及核心数据变更:新表设计、字段语义调整、数据迁移、双写回切、索引重建、统计口径变更。这类改动最难回滚,也最容易留下长期隐患。
- 涉及架构演进或重构:单体拆分、服务边界重划、存储替换、链路异步化、读写分离、缓存前置。此时重点不只是“新方案是什么”,还包括“旧系统怎么安全过渡”。
- 涉及外部依赖或跨团队协作:第三方接入、遗留系统对接、多团队联合交付、多服务契约调整。只靠口头同步很容易出现理解偏差。
- 涉及高风险上线:容量提升、稳定性治理、容灾切换、灰度放量、合规安全改造。越是“出一次问题就代价很大”的需求,越需要把方案显式写出来。
反过来说,常规 bugfix、边界清楚的小型优化、明确职责范围内的简单 CRUD 改动,通常不需要单独写完整 TD,在工单、设计备注或 Merge Request 里写清实现思路即可。
真正成熟的团队,不是“所有事都写 TD”,而是知道什么时候必须写、写到什么深度、谁需要参与评审。这一点决定了后续方法论能否真正高效落地。
2.3 技术方案 / TD 的常见类型
判断“要不要写”之后,下一个问题不是立刻套模板,而是先判断:这次到底是哪一类方案。 不同类型的 TD,关注点、交付物和评审方式都不一样。为了便于团队使用,我更推荐把常见 TD 收敛成四大类型。
业务特性开发型 TD(New Feature TD)
这是最常见的一类,服务于新功能、新流程、新业务能力的落地。
典型场景:新增支付方式、推荐能力、营销玩法、审批流、履约节点、实时协作特性。
设计重点:
- 领域模型是否清晰,核心概念是否稳定。
- 接口契约、前后端交互和服务间协议是否明确。
- 状态流和异常路径是否被显式建模。
- 未来如果扩展新渠道、新规则、新租户,是否容易演进。
架构演进与重构型 TD(Architecture Evolution / Refactoring TD)
当旧架构已经不适配新阶段业务时,就进入这一类。
典型场景:单体拆分、服务边界重划、数据库拆分、核心链路改造、老模块重写、跨代架构替换。
设计重点:
- 当前系统的痛点是什么,是否有足够事实支撑。
- 目标形态是什么,新旧架构的关键差异在哪里。
- 迁移路径如何设计,包括灰度、双写、对账、回切和回滚。
- 如何做到“在高速公路上换轮胎”,尽量不影响线上存量用户。
性能优化与容量规划型 TD(Performance / Capacity TD)
这类 TD 通常不是由产品需求驱动,而是由技术指标或大促目标驱动。
典型场景:QPS 提升、RT 降低、热点优化、缓存改造、异步削峰、数据库扩容、双十一备战。
设计重点:
- 先用压测、APM、火焰图、SQL 分析等手段证明瓶颈在哪里。
- 明确优化手段与收益预期,是缓存、异步、分片、索引还是架构调整。
- 给出容量模型、资源预算和稳定性兜底策略。
- 说明优化后的新风险,例如缓存一致性、雪崩、回源放大、限流误伤。
技术选型与基础设施型 TD(Tech Selection / Infra TD)
当团队要引入新技术、替换基础组件或升级工程平台时,通常属于这一类。
典型场景:数据库替换、消息中间件迁移、引入 AI 能力、CI/CD 重构、日志与监控平台升级。
设计重点:
- 为什么选 A,不选 B 和 C。
- PoC 是否覆盖了最关键的真实业务场景。
- 学习成本、运维成本、组织适配成本是否可接受。
- 如果未来发现不合适,退出策略和迁移路径是什么。
为什么先分类,再写文档
因为分类本质上是在回答三件事:
- 这篇文档的核心读者是谁。
- 这次评审最应该挑战什么。
- 文档该详写模型和接口,还是详写迁移与回滚,还是详写压测和容量。
如果类型判断错了,作者就很容易出现两种偏差:要么对一个简单功能写成“大而全”的架构长文,要么对一次高风险重构只写了几张接口图。前者浪费团队时间,后者直接把风险带进线上。
2.4 需求澄清与问题定义
在进入方案设计前,至少要回答以下问题:
- 这个系统服务谁,核心使用场景是什么。
- 当前方案或旧系统的痛点是什么。
- 哪些指标是必须达成的,例如延迟、吞吐、稳定性、正确性。
- 哪些内容属于非目标,本期明确不做。
- 外部依赖有哪些,是否受别的团队、第三方服务或基础设施约束。
一个常见错误是把“背景”写成“解决方案摘要”。背景部分应该聚焦现状、问题和目标,而不是提前给出实现细节。否则评审者很难区分哪些是事实,哪些是方案假设。
目标与非目标必须显式写
设计不是追求完美,而是追求平衡。把目标和非目标显式写出来,有两个好处:
- 帮助评审者理解你的取舍依据。
- 防止项目在推进过程中不断被隐性扩 scope。
例如,一个面向内部运营的报表系统,本期目标可能是“支持 T+1 数据更新、百人级并发查询、操作留痕”,非目标则可能包括“实时分析”“跨地域多活”“统一对外开放 API”。这些边界会直接影响存储、链路和投入方式。
2.5 容量估算与真实约束
好的方案不是从组件清单开始,而是从数字开始。数字不需要百分之百精确,但必须足够支撑决策。
为什么容量估算是设计入口
容量估算回答的是两个问题:
- 方案大概要承受多大规模。
- 哪些约束会成为架构边界。
缺少数量级,很多讨论都会变成空话。比如“是否需要分库分表”“要不要上消息队列”“缓存层值不值得做”,都依赖数据规模与流量模型。
至少要估哪些数字
常见估算维度包括:
- 请求量:平均 QPS、峰值 QPS、突发流量倍数。
- 数据量:单条大小、日增量、冷热分布、保留周期。
- 读写模型:读多写少、写多读少、是否存在热点。
- 时延目标:接口 RT、异步任务完成时间、批处理窗口。
- 可用性目标:SLA、可接受失败率、恢复时间目标。
- 外部约束:预算、人力、上线时间、合规、安全审计。
识别真实约束,而不是理想约束
工程里最重要的约束往往不是技术本身,而是现实条件:
- 项目只有两周交付,意味着不能引入过多新基础设施。
- 团队没人维护分布式存储,意味着方案要优先复用成熟组件。
- 依赖下游 SLA 很弱,意味着要提前设计降级、重试和隔离。
- 数据涉及权限和审计,意味着安全、追踪和权限模型必须前置。
容量估算的意义,不只是为了“算多大”,更是为了知道系统最先会被什么限制住。
2.6 核心链路与职责拆解
当问题和约束清楚之后,才进入架构拆解。拆解的目标不是把图画复杂,而是把职责边界、调用方向和关键路径讲明白。
一个合格的高层设计,通常应该先回答:
- 系统由哪些核心模块组成。
- 请求从入口到结果返回,会经过哪些关键环节。
- 哪些模块在主链路上,哪些是异步旁路。
- 哪些依赖来自外部系统,失败时如何影响主流程。
核心链路比全景图更重要
很多设计评审失败,不是因为没有覆盖面,而是没有突出核心链路。所谓核心链路,就是最能决定系统价值和风险的那条主路径。例如:
- 下单系统中的“校验库存 -> 扣减库存 -> 生成订单 -> 支付确认”
- 内容平台中的“写入内容 -> 审核 -> 建索引 -> 用户检索”
- 配置发布系统中的“冻结输入 -> 构建产物 -> 切换版本 -> 验证可读 -> 收敛状态”
如果核心链路没有讲清楚,后面的高可用、扩展性和风险管理就很难讨论扎实。
职责拆解的最低要求
架构师至少要回答:
- 谁是入口方,谁是编排方,谁是状态拥有者。
- 谁负责写入真相源,谁负责投影、索引、缓存或通知。
- 哪些步骤必须同步完成,哪些允许异步收敛。
- 哪些模块承担主链路责任,哪些模块只是辅助能力。
2.7 数据流、状态流与边界划分
系统设计不只是模块设计,更是数据与状态的设计。模块边界一旦模糊,最终往往会在一致性、权限和故障恢复上出问题。
数据流回答“信息怎么走”
设计数据流时,至少要说明:
- 数据从哪里来,由谁写入。
- 哪些服务读主库,哪些走缓存、副本或索引。
- 哪些数据需要同步返回,哪些允许异步收敛。
- 数据在不同存储之间如何复制、投递、校验和修复。
状态流回答“系统怎么演进”
很多业务的复杂度不在 CRUD,而在状态机。订单、审批、履约、任务调度都属于典型状态驱动场景。设计时要明确:
- 系统有哪些核心状态。
- 状态转移由谁触发,是否允许回滚或补偿。
- 并发更新时如何防止状态错乱。
- 失败重试会不会造成重复执行。
边界划分回答“谁负责什么”
边界划分的目标是高内聚、低耦合、隔离变化。可以借助这些原则检查设计:
- 一个模块的职责是否足够单一。
- 一个接口是否暴露了过多实现细节。
- 一个服务是否承担了本不属于它的状态管理责任。
- 未来变化最频繁的部分,是否被隔离到了独立边界内。
SOLID、正交性、高内聚、低耦合,本质上都在强调同一件事:系统应该把容易变化的部分隔离开,让稳定部分尽量少受影响。
2.8 设计阶段(Design Phase):用六维画布把方案想清楚
在进入 TD 骨架之前,还有一个很容易被跳过的阶段:把方案真正想清楚。很多方案写到一半写不下去,不是因为不会画图或不会写接口,而是因为设计阶段的几个关键维度没有覆盖全,导致写到后面才发现矛盾、遗漏或逻辑不自洽。
我把这个阶段称为 Design Phase——它不是画草图的阶段,也不是写文档的阶段,而是用一套结构化的维度把模糊的直觉变成可执行的方案。它的核心输出不是一个文档模板,而是一组经过推敲的设计决策。
为什么需要一个显式的设计阶段
现实中最常见的翻车路径是:拿到需求 → 画几条链路 → 开始写接口 → 写到 rollout 时才发现“这个风险之前没想过“。原因是设计过程中缺少一套稳定的检查维度,导致作者被主流程牵着走,忽略了系统的横切面。
显式地把设计阶段拆成六个维度,不是为了增加工作量,而是为了让设计者在动手写文档之前,就已经把最关键的问题过了一遍。这六维的作用不是“写满“,而是“别漏“。
六维设计画布
我把设计阶段的关键关注点收敛为六个维度:Model(模型)、Process(流程)、Capability(能力)、Governance(治理)、Measurement(度量)、Evolution(演进)。这六维不是六个章节标题,而是六种审视方案的视角——一个方案如果在这六个维度上都经得起追问,基本就站稳了。
1. Model(模型)——数据与领域核心
关注什么:业务概念在系统中如何被建模,包括实体、关系、状态机、聚合边界、事实源(Source of Truth)。
为什么重要:模型是方案的地基。如果核心实体的职责边界不清晰、事实源不唯一、状态转移规则有歧义,往上的流程和接口写得再详细也是沙上建塔。
至少产出:
- 核心实体与关系的明确描述(不一定是完整 ER 图,但关键实体及其事实源必须说清楚)
- 关键业务对象的状态机或生命周期(哪怕只有一个核心对象)
- 事实源声明:每个核心数据的主写入方是谁,谁只读副本或投影
事实源是最容易被忽略的设计决策:一个常见错误是,同一份数据在多个服务中被修改,但没人说清楚谁是“真相“。到对账的时候才发现,A 服务改了一个字段、B 服务改了另一个字段、C 服务只读了缓存,三方对不上一笔数。所以设计阶段的第一件事不是画架构图,而是声明每条核心数据的 Source of Truth——哪个模块拥有写入权,其他模块只能通过它获取最新状态或持有最终一致的投影。
2. Process(流程)——请求链路与事务边界
关注什么:一次请求或一个业务流程如何跨越多个模块,经过哪些步骤,每一步是同步还是异步,事务边界在哪里,失败时如何回滚或补偿。
为什么重要:主链路是评审者理解方案的第一入口。如果核心链路画不清楚,后面的容量估算、容灾设计、灰度发布都缺乏锚点。
至少产出:
- 核心业务场景的端到端时序或流程图(标注同步/异步边界)
- 每个跨模块操作的事务范围声明(一个事务最多跨越哪些操作)
- 异常路径处理:超时、重试、幂等、补偿的明确策略
先写最坏情况,再画正常流程:一个很实用的设计习惯是,先写清楚最坏情况下系统应该怎么保护核心事实。比如“如果支付回调丢失““如果库存扣减成功但订单创建失败”“如果 MQ 消息重复投递”——把这些失败场景的处理策略写出来之后,正常流程自然就清楚了。反之,如果只画正常流程,这些边界条件往往要到上线后才会被触发。
3. Capability(能力)——模块职责与技术选型
关注什么:系统需要哪些核心能力,这些能力如何分配到模块或服务,每个模块的关键技术选型是什么,为什么选这个而不是别的。
为什么重要:能力划分决定了团队的开发边界和运维边界。选型决策是最容易被挑战的地方——不是因为评审者觉得你的选型不对,而是因为你没有解释选型依据。
至少产出:
- 核心模块列表及其职责的一句话声明
- 关键选型的决策记录:选项、选择、依据、代价、fallback
- 模块间的依赖方向(谁依赖谁,是否合理)
4. Governance(治理)——风险、安全与合规
关注什么:方案在运行时会面临哪些风险,如何通过限流、熔断、降级、幂等、对账、权限控制等手段把风险控制在可接受范围内。
为什么重要:治理层是区分“PPT 架构“和“可上线架构“的分水岭。一个方案如果没有回答超时怎么办、重复怎么办、数据不一致怎么发现、敏感数据怎么保护,它就只能活在理想环境里。
至少产出:
- 风险清单:每个风险的影响范围、触发条件、缓解措施
- 关键链路的限流、熔断、降级策略声明
- 数据一致性保障方案:幂等、对账、补偿的层次设计
- 敏感数据的安全边界(加密、脱敏、访问控制)
对账与补偿不是“锦上添花“,而是核心设计:涉及资金、库存、积分等场景时,对账和补偿不是运维阶段才考虑的事,而是设计阶段就必须定义的。对账回答“我怎么知道数据不一致了“——包括实时对账、准实时核对、离线全量对账;补偿回答“不一致了怎么修“——包括自动补偿(如 Saga)和人工介入的修复工作台。设计阶段至少要说清楚:对账的粒度和频率是什么,补偿的方向和幂等保障是什么。
5. Measurement(度量)——可观测性与 SLA
关注什么:方案上线后如何确认它在正常运行,哪些指标是关键健康信号,出问题时如何快速定位。
为什么重要:一个没有度量方案的设计,上线后基本等于“盲飞“。可观测性不是运维的事后补丁,而是设计阶段就必须嵌入方案的一部分。
至少产出:
- 关键业务指标和 SLI/SLO 声明
- 核心链路的监控埋点位置和告警触发条件
- 故障排查路径:哪个日志、哪个大盘、哪个 trace
6. Evolution(演进)——兼容性与迁移路径
关注什么:方案不是一次建成的,后续迭代时如何保证兼容、如何迁移旧数据和旧逻辑、如何让团队逐步接手。
为什么重要:很多方案第一期设计得很完美,但第二期需求一来就推倒重来,因为第一期没有预留演进空间。演进能力不是“过度设计未来“,而是“今天的设计不堵死明天的路“。
至少产出:
- 本期明确不做但可能影响未来扩展的假设说明
- 数据模型和 API 的兼容策略(向前兼容、版本管理)
- 如果未来需要替换核心组件,迁移路径大概是什么方向
六维设计画布速查表
| 维度 | 一句话 | 最关键的三个问题 | 最少交付物 |
|---|---|---|---|
| Model | 数据与事实 | 事实源是谁?状态怎么流转?核心实体边界在哪? | 核心实体描述、状态机、事实源声明 |
| Process | 链路与事务 | 主链路怎么走?事务边界在哪?失败了怎么补偿? | 核心时序图、事务范围、异常处理策略 |
| Capability | 能力与选型 | 模块怎么划分?为什么选这个组件?依赖方向是否合理? | 模块职责声明、选型决策记录 |
| Governance | 风险与防护 | 最坏情况是什么?怎么兜底?对账和补偿怎么做? | 风险清单、限流/熔断/降级策略、对账方案 |
| Measurement | 度量与观测 | 怎样算正常?出了事怎么发现?怎么快速定位? | SLI/SLO、监控埋点、告警规则 |
| Evolution | 兼容与迁移 | 今天的设计会不会堵死明天的路?旧数据怎么迁移? | 兼容策略、非目标假设、迁移路径草图 |
设计阶段的轻量交付模板
经过六维画布的审视之后,设计阶段至少应产出一组可交付的产物。这不是 TD 模板本身,而是进入 TD 写作之前就已经就绪的“弹药“:
- 架构图(1 张):表达核心模块、职责边界和依赖方向,不追求细节,但追求边界清晰。
- 核心链路时序图(1-2 张):展示最关键业务场景的端到端调用链,必须标注同步/异步、重试、降级。
- 状态迁移表(1 张):至少包含核心业务对象的状态、事件、守卫条件和转移动作。
- 风险清单(1 份):每一条风险都包含触发条件、影响范围、缓解措施和回滚方式。
- 选型决策记录(1 份):关键的技术选型必须有选项、选择、依据、代价和 fallback。
- 容量预估(1 份):峰值 QPS、存储量级、带宽、核心接口 RT 目标的估算与推导过程。
这六项产物不需要写得很长,但它们覆盖了六维画布的核心输出。如果这六项在动手写 TD 之前已经就绪,写 TD 的过程就变成了“把这些决策按评审顺序组织和表达“,而不是“边写边想“。
从设计阶段到 TD 骨架
六维画布是“想清楚“的工具,TD 8 段式骨架是“写清楚“的框架。两者天然衔接:
- Model + Process 的输出 → TD 的 End-to-End Flow / Responsibility Split
- Capability 的输出 → TD 的 Key Decisions
- Governance 的输出 → TD 的 Dependency / Rollout / Risk / Rollback
- Measurement + Evolution 的输出 → 分散在 TD 的 Assumptions、Rollout 和 Work Breakdown 中
- 轻量模板的六项产物 → TD 各段的核心素材
换句话说,六维画布保证了方案的完备性——六个维度都想到了,不容易有重大遗漏;TD 骨架保证了表达的可评审性——关键信息按评审者关心的顺序组织,不容易被打回来。两者合在一起,就是“想清楚 → 写清楚 → 评清楚“的完整闭环。
2.9 一份好 TD 的 8 段式骨架
真实项目里,评审者很少有耐心跟着作者的思路一点点猜。你需要把最关键的信息按一种稳定顺序摆出来。实践中,我更推荐以下 8 段式骨架:
Background / Objective / ScopeCurrent State / Problem StatementKey DecisionsEnd-to-End Flow / Responsibility SplitAPI / Data ContractDependency / Rollout / Risk / RollbackOpen Questions / AssumptionsWork Breakdown / Owner / Timeline
为什么这 8 段是评审最关心的顺序
- 先看 为什么做,再看 做什么
- 先看 问题是否成立,再看 方案是否合理
- 先看 关键取舍,再看 细节接口
- 先看 风险与 rollout,再看 排期和 owner
这个顺序不是为了“写得规范”,而是为了降低评审摩擦。评审会上最常见的问题,本质上都在追问这 8 段里的某一段。
每一段分别回答什么
1. Background / Objective / Scope
- 为什么现在要做这件事
- 成功标准是什么
- 本期明确不做什么
2. Current State / Problem Statement
- 当前系统或流程是什么样
- 痛点是实际存在的,还是假设出来的
- 如果不改会怎样
3. Key Decisions
- 这次最关键的 2-5 个设计决定是什么
- 候选方案有哪些
- 为什么选这个,不选那个
- 代价和 fallback 是什么
4. End-to-End Flow / Responsibility Split
- 从输入到输出,链路怎么走
- 哪个系统在什么步骤负责什么
- 同步 / 异步边界在哪里
5. API / Data Contract
- 请求、响应、事件、状态字段分别是什么意思
- 哪些字段是兼容新增,哪些字段会改变行为
- 幂等、顺序、版本、精度是否说清楚
6. Dependency / Rollout / Risk / Rollback
- 依赖谁
- blocker 是什么
- 上线顺序是什么
- 出问题怎么降级、怎么退
7. Open Questions / Assumptions
- 哪些问题还没定
- 哪些地方是在基于默认假设推进
- 哪些结论要等外部条件成立才有效
8. Work Breakdown / Owner / Timeline
- 工作怎么拆
- 谁负责
- 哪些步骤有先后依赖
- 如何判断“设计准备完成,可以进入实现”
2.10 关键设计决策怎么写
很多方案不是没列选项,而是没有把“决策标准”写清楚。结果读者看完知道有 A、B、C 三个方案,却不知道为什么最后选了 B。
一个合格的决策段落,至少要包含:
- 候选方案
- 选择结果
- 选择依据
- 明确代价
- fallback 或后续迁移路径
推荐写法:
决策:发布链路采用“先冻结输入,再构建不可变产物,再切换版本指针”的状态机模型。
候选方案:
- 方案 A:直接同步覆盖多处存储
- 方案 B:引入 snapshot + task + pointer
选择依据:
- A 实现简单,但状态难以收敛,回滚困难
- B 增加模型复杂度,但能显式表达发布状态并支持补偿与回滚
代价:
- 需要新增状态表、补偿任务和观测指标
Fallback:
- 第一阶段只在试点配置上启用新状态机,其他配置维持旧路径
2.11 风险、依赖、灰度与回滚
很多文档前面分析得很好,到了 rollout 和 rollback 就开始写模板套话,这是最容易被架构评审打回来的地方。
风险不是“列几个大词”
真正有价值的风险描述要回答:
- 风险是什么
- 何时会发生
- 影响范围是什么
- 如何发现
- 如何缓解
- 如果缓解失败,怎么回滚
依赖必须集中表达
依赖和 blocker 不应散落在各章节里。建议统一收口:
- 依赖的系统 / 团队 / 第三方
- 当前状态
- 若依赖不满足,本方案会卡在哪
- 临时兜底方案是什么
rollout 必须能执行
上线方案至少要说清:
- 是一次性切换还是灰度放量
- 灰度维度是什么:用户、流量、市场、类目、开关
- 观测指标是什么
- 失败阈值是什么
- 谁负责拍板扩大或停止
rollback 必须能落地
回滚不是“有问题就回滚”这一句话,而是:
- 回滚对象是什么:版本、快照、开关、配置、数据批次
- 回滚动作由谁执行
- 回滚前需要检查什么
- 回滚后如何确认系统回到稳定态
2.12 方案评审与推进
一份技术方案写完并不代表工作结束。评审和风险管理,决定了方案能否真正落地。
评审到底在评什么
评审不是挑文档格式,而是检查这几个层面:
- 问题定义是否准确,目标和非目标是否清晰。
- 方案是否能支撑容量、性能和稳定性目标。
- 关键取舍是否解释充分,是否讨论过替代方案。
- 风险是否被识别,是否有缓解和回滚措施。
- 任务拆分、排期和依赖是否现实。
评审者视角不同,问题也不同
- 业务 / 产品:目标是否清楚,边界是否合理
- 架构师:关键决策是否站得住,tradeoff 是否充分
- 实现者:职责、链路、接口、状态是否足够明确
- 运维 / 稳定性角色:灰度、监控、回滚、人工介入是否准备好
如果作者自己没有带着这四种视角审一遍,评审会上通常会被打得很散。
2.13 高质量 TD 的常见反模式
结合真实项目 review,最常见的高频失败模式有这些:
- 方案写成实施手册:命令、表结构、接口细节很多,但缺少主设计决策。
- 选项列了但没有决策标准:A/B 方案并列展示,却没有说明为什么选这个。
- 字段语义不统一:金额、状态、快照、版本等核心字段在不同章节含义漂移。
- 依赖和 blocker 散落在正文里:读者要靠自己拼图。
- rollout 和 rollback 只有模板占位:看起来写了,实际上不可执行。
- 开放问题没有显式出口:真正没定的内容被埋在自然语言里。
- 设计与运行手册混写:评审想看系统行为,却被后台操作步骤淹没。
评审时,真正值得优先指出的,永远是这些结构性问题,而不是措辞或排版。
2.14 TD 模板、AI Skill 与方法论沉淀
方法论最终不能只停留在脑子里。一个成熟团队通常会把它沉淀成三类工件:
- TD 模板:统一结构,让作者少走弯路
- Review Checklist:统一审查口径,让评审少遗漏关键风险
- AI Skill / 写作助手:把结构化 author/review 工作流自动化,降低表达成本
例如一个公开版的 td-writing-review skill,可以服务三类输入:
- 技术方案草稿
- 需求摘要 / 会议纪要
- 已完成但结构混乱的设计文档
它的价值不在于“帮你润色”,而在于:
- author 模式先帮助作者收敛 Top Decisions
- review 模式先暴露 决策缺口、风险缺口、责任缺口
- 统一作者、评审者和实现者对一份 TD 的阅读顺序
从这个角度看,书里的方法论回答“为什么这样写”,skill 回答“如何把它操作化”。两者不是替代关系,而是同一套认知的不同形态。
2.15 技术方案方法论小结
技术方案设计的本质,是把一个模糊问题逐步收敛为一套可评审、可实施、可演进的工程决策。本部分的方法论可以压缩为一条主线:
- 先澄清问题与边界,再谈方案。
- 先用数据识别容量与约束,再谈组件选型。
- 先讲核心链路、数据流和状态流,再补充横切能力。
- 再用六维画布(Model / Process / Capability / Governance / Measurement / Evolution)把方案从六个维度推敲一遍,确保没有重大遗漏。
- 再用 8 段式骨架把关键信息按评审顺序摆出来。
- 最后用评审、风险、灰度和回滚把方案从”想法”变成”工程计划”。
3. 架构师的武器库
从边界设计到内部结构、系统协作、质量保障与容量韧性,建立架构师的通用作战能力。
3.1 组合拳总论
软件架构的本质挑战
对一个做了 8 年左右后端或系统设计的工程师来说,软件架构真正难的地方,通常不是“有没有听过某个名词”,而是你会一遍又一遍遇到同一类问题:业务越来越复杂,团队越来越大,系统之间的调用越来越多,查询越来越重,规则越来越碎,而每一次新需求都在把这些问题继续放大。
很多人第一次认真接触架构,往往是从技术概念开始的:分层、DDD、CQRS、事件驱动、六边形架构、Clean Architecture。但真正在项目里工作几年之后,你会慢慢发现,架构设计并不是在概念之间“站队”,而是在不断回答几个非常现实的问题:
- 业务边界到底应该怎么划,才能避免概念混乱?
- 服务内部应该怎么组织,才能扛住持续变化?
- 系统拆开之后,彼此之间怎么协作才不会越来越乱?
- 代码怎么写、评审怎么做、上线怎么控,才能让设计不在落地时变形?
也正因为如此,本章并不打算把几个流行概念逐一解释一遍,而是想先做一件更重要的事:从工程实践的角度,沉淀软件架构面对的本质挑战,再引出架构师通常会如何打出一套“组合拳”来应对这些挑战。
为什么软件架构问题总会反复出现
软件架构之所以让人反复感到“似曾相识”,是因为很多系统最终都会落进相同的复杂性轨道。
项目刚开始时,大家通常只觉得“先把功能做出来”最重要。于是一个订单服务里,先有了下单接口,再加上支付回调、取消订单、超时关单、库存预占、营销校验、事件通知。前几个月看起来一切都还可控,但只要系统开始承接更多业务,问题就会陆续出现:
- 原本只有几条状态流转,后来变成十几种特殊流程
- 原本只有一个团队维护,后来变成多个团队协作
- 原本只需要简单查询,后来页面需要展示越来越多聚合信息
- 原本只改一处代码就够,后来一个需求要改接口、应用层、数据库、缓存和消息逻辑
这些问题反复出现,不是因为团队不努力,也不是因为某个框架选错了,而是因为复杂性会自然增长。如果没有一套稳定的边界、结构和协作方式,系统最终都会从“能跑”走向“难改”,再从“难改”走向“没人敢动”。
从这个角度看,架构设计并不是锦上添花,它更像是在系统复杂性不断上升时,为团队建立一套能够持续控住复杂性的秩序。
复杂系统的四类核心矛盾
如果把这些年在项目里反复遇到的问题抽象一下,大多数复杂系统最终都会面对四类矛盾。
第一类矛盾:业务复杂性不断上升
- 商品、订单、库存、支付、营销之间的边界到底怎么划?
- 同一个“订单”“商品”“价格”概念,在不同团队和不同系统里是否还是同一个意思?
- 规则越来越多之后,它们到底应该落在流程里、模型里,还是散落在各个 Service 里?
第二类矛盾:系统必须持续应对变化
- 业务规则会变,促销玩法会变,履约流程会变
- 技术栈也会变,数据库、框架、消息中间件都可能调整
- 如何让这些变化被限制在局部,而不是每次都牵动整条链路?
第三类矛盾:多人协作需要统一认知
- 50+ 开发者同时开发,如何降低彼此干扰?
- 产品、运营、业务专家和开发是否真的在使用同一套语言?
- 新成员加入时,是否能快速理解系统边界和核心规则?
第四类矛盾:性能、一致性与可维护性彼此拉扯
- 查询希望越快越好,往往需要宽表、缓存、搜索和反范式化
- 写入希望越稳越好,往往需要事务、一致性和严格规则保护
- 如果为了性能不断破坏模型,系统会变脏;如果为了模型纯净牺牲响应能力,业务体验又会变差
这些矛盾单独看都不新鲜,但真正困难的地方在于:它们几乎总是一起出现。这也是为什么软件架构很少有“一招鲜”,而更像是一套围绕不同问题分层出招的组合动作。
电商系统为什么是观察架构问题的最佳样本
电商系统之所以适合拿来讨论架构,不是因为它更“高级”,而是因为它几乎天然把前面四类矛盾全部放大了。
从业务上看,电商系统同时涉及商品、库存、计价、营销、订单、支付、履约、售后,业务规则彼此交叉,非常容易出现边界模糊和语义冲突。
从流量上看,商品详情、搜索、列表页有很高的读压力;下单、支付、库存扣减等链路又对一致性极其敏感。这意味着系统既要面对高并发,又不能把正确性让位给性能。
从组织上看,电商项目通常不是一个小团队就能长期覆盖的。商品团队、营销团队、交易团队、支付团队、履约团队会围绕同一个平台长期协作。边界一旦划不清,协作成本会非常快地吞掉研发效率。
也正因为如此,电商系统非常适合拿来回答这样一个问题:面对真实复杂系统,架构师到底需要哪些方法论,它们分别在什么阶段解决什么问题?
架构师如何应对这些挑战
如果说上一节回答的是“问题到底是什么”,那么这一节要回答的是另一个更重要的问题:架构师通常会按什么顺序来应对这些问题?
在真实项目中,成熟的架构设计很少是“先选一个流行概念,再把所有问题往里塞”。更常见的做法是按问题的层次逐步处理:先确定边界,再稳定内部结构,再设计系统之间的协作方式,最后用代码规范和质量机制把这套设计守住。
这也是本书第一部分的主线。
先做业务边界
复杂系统最先要解决的,通常不是“选哪种分层”,而是“系统到底应该怎么切”。
如果业务边界没有先划清楚,那么后面几乎所有设计都会失焦:
- 团队不知道哪个规则属于哪个系统
- 同一个概念在不同服务里被重复定义
- 上下游关系长期靠口头约定维持
- 一个变更会反复穿透多个模块和多个团队
因此,架构师首先要做的,往往是识别核心域、支撑域、通用域,划分限界上下文,建立通用语言,并明确上下文之间的关系模式。换句话说,先画地图,再开始谈地图里的建筑。
这一步对应本章的 3.2 业务边界与战略设计 部分。
再建系统内部秩序
边界画清楚之后,问题会自然转向单个系统内部:这个服务应该如何分层?依赖方向怎么控制?业务规则落在哪?读写路径是否应该继续共用同一套模型?
这一步其实是在解决“单个系统内部如何建立秩序”的问题。也是大多数团队最熟悉、但最容易混在一起的一步。
在工程实践里,这一层通常不是靠单一方法论完成的,而是几种方法一起配合:
- 用三层架构先建立基本职责分层
- 用 Clean Architecture 约束依赖方向
- 用 DDD 战术设计承载复杂业务规则
- 在读写矛盾明显的地方引入 CQRS
这一步对应本章的 3.3 系统内部结构设计 部分。
再处理系统之间的协作
系统一旦拆分成多个上下文、多个服务,新的问题就出现了:调用如何解耦?跨服务流程怎么编排?消息如何可靠投递?事务边界跨不过去时,一致性又怎么保证?
也就是说,边界和内部结构解决的是“单个系统怎么设计”,而系统间协作解决的是“多个系统如何一起工作”。
这一层关注的不再只是类、包和目录,而是:
- 事件驱动与同步调用如何搭配
- 防腐层和集成契约如何守住边界
- Outbox 如何处理双写问题
- Saga 和补偿事务如何处理长流程一致性
这一步对应本章的 3.4 系统集成与一致性设计 部分。
最后落实代码与质量保障
很多设计图看起来都很漂亮,但真正决定系统能否长期维持下去的,往往不是图画得多完整,而是代码怎么写、评审怎么做、上线怎么控。
如果没有编码原则,系统内部结构很快会在日常开发中被打穿;如果没有质量保障机制,再好的边界和架构也会在交付压力下被不断开洞。
因此,架构的最后一公里,通常落在两个层面:
- 代码层面:如何写出可读、可改、可测试的实现
- 质量层面:如何通过评审、检查清单和上线前验证守住设计
这两部分分别对应本书的:
- 第 2 章《编码与 Code Review》
- 本章的 3.5《架构质量保障》部分
如果把这一整套顺序收成一句话,就是:
先划清业务边界,再建立系统内部秩序,再处理系统之间的协作,最后通过代码原则与质量机制把架构真正落地。
架构设计的方法论地图
理解了问题层次和应对顺序之后,我们就可以把常见的方法论重新放回同一张地图中。
在实际落地中,架构师通常不会只选一种方法论,而是把它们嵌套使用,形成一个完整闭环。这也是“组合拳”这个说法最核心的含义:不是每种方法论都解决所有问题,而是每一种方法论都在处理复杂系统中的一个关键维度。
DDD:解决业务边界与领域建模
DDD 在这套组合拳中有两层作用。
第一层是战略设计:帮助架构师识别核心域、划分限界上下文、建立通用语言和上下文映射。它回答的是“系统应该怎么切”的问题。
第二层是战术设计:帮助团队在单个上下文内部用聚合根、实体、值对象、领域事件表达复杂业务规则。它回答的是“切出来的系统内部,规则应该怎么承载”的问题。
因此,DDD 既属于“先划边界”,也会延伸到“内部结构设计”。这也是为什么本书会把它拆成不同层次分别展开。
三层架构:建立工程分层的默认起点
三层架构不是最“先进”的方法论,但它仍然是很多项目最实用的起点。
它先解决的是一个非常现实的问题:代码应该按什么职责落位,团队才能顺利协作。表现层、业务逻辑层、数据访问层的分工,让系统至少先具备了基本秩序。
从架构师视角看,三层架构的价值不在于它能一次解决所有问题,而在于它能以很低的成本让项目快速从“无结构”进入“有分层”的状态。因此,它经常成为后续引入更强结构的起点。
Clean Architecture:约束依赖方向
如果说三层架构先回答“代码放在哪”,那么 Clean Architecture 进一步回答“核心业务应该依赖谁”。
它最核心的要求是依赖方向向内:外层技术可以依赖内层业务,但内层业务不应该知道数据库、框架和中间件的具体存在。这个约束看起来很抽象,但它直接影响系统是否可测试、可替换、可长期演进。
很多项目在目录上看起来仍然是三层,但一旦在依赖方向上开始服从这个规则,它其实就已经在朝 Clean Architecture 靠拢了。
CQRS:解决读写目标冲突
CQRS 解决的是另一个常见矛盾:同一套模型能否同时服务好读和写。
在简单系统里,共用统一模型通常更经济;但在复杂系统里,写侧关注一致性和规则保护,读侧关注查询性能和展示效率,二者的目标很容易发生冲突。这个时候,继续强行共用一条路径,往往会让双方互相拖累。
CQRS 的价值,正在于允许我们承认这种冲突,并把命令侧和查询侧分别优化。它经常出现在系统内部结构演进的后期,同时又会自然和事件驱动、投影、最终一致性衔接起来。
事件驱动与一致性设计:解决系统间协作
当系统之间开始协作时,单个服务内部的分层已经不够了。新的问题变成了:事件怎么发、消息怎么投、双写怎么解、长流程怎么补偿、一致性怎么守。
这一组方法论通常包括:
- 事件驱动
- 集成模式
- Outbox Pattern
- Saga / 补偿事务
- 最终一致性设计
它们不再主要解决“单个系统内部怎么组织”,而是解决“多个系统之间如何可靠协作”。这也是为什么这里会把它们放到本章的 3.4 部分集中讨论。
编码原则与质量保障:守住架构落地质量
一套架构设计如果不能稳定落到代码和交付流程里,最终就会退化成 PPT 上的结构。
因此,架构师的组合拳里还缺最后两类能力:
- 用编码原则和设计模式保证实现层不把架构意图写坏
- 用评审、检查清单、测试与上线前验证保证架构不在交付时变形
这两类能力看起来不像 DDD 或 CQRS 那样“有名词感”,但它们恰恰决定了一套架构能不能在团队里真正活下来。
如果把整套方法论再压缩成一句话,就是:
DDD 帮助我们认清业务边界,三层架构和 Clean Architecture 帮助我们建立系统内部秩序,CQRS 帮助我们在读写冲突中做结构优化,事件驱动与一致性设计帮助我们处理系统间协作,而编码原则与质量保障负责把这一切真正守住。
3.2 业务边界与战略设计
为什么需要战略设计
第 1 章从 Clean Architecture、DDD 与 CQRS 的协作关系出发,已经用「三位一体」搭好了工程骨架,并简要触及了限界上下文、上下文映射与通用语言。本章不再重复分层目录、Outbox 或读写分离的细节,而是把镜头拉近到 DDD 的战略设计:它回答的是「边界在哪里、团队如何协作、概念如何对齐」——这些问题若未澄清,战术层的聚合与仓储很容易变成「漂亮的样板代码」,却无法降低沟通与演进成本。
战术先行常见症状
许多团队第一次接触 DDD 时,会直接从「实体 / 聚合 / 仓储」入手,短期内代码结构变整齐,但很快遇到以下矛盾:
-
同名不同义:产品口中的「商品」指前台可售的 SKU;库存同学口中的「商品」是可售量与仓位的组合;订单里的「商品」又是下单快照。没有上下文边界时,一个
Product结构体会被迫承载三套语义。典型症状是代码中出现Product.InventoryQty(库存关注)和Product.DisplayTitle(商品关注)混杂在同一个类型中,任何修改都需要跨团队协调,变更成本高昂。 -
跨团队改同一张表:订单服务为了赶需求直接更新库存表,或营销脚本回写订单金额字段。短期省事,长期让「谁拥有这条数据」变得模糊。某次故障中,订单团队修改了库存表的索引,导致库存域的查询性能暴跌;两边互相推诿责任,最终花了一周才定位问题。这种「绕过契约直连数据库」的路径,是边界模糊的最大隐患。
-
集成靠私下约定:RPC 参数、Kafka Topic、回调字段在口口相传中演进,缺少显式的上下游关系与防腐策略。某次营销域修改了优惠券事件的字段名(从
coupon_id改为couponCode),订单域的消费者直接报错,才发现双方没有契约测试与版本策略。线上故障持续了 2 小时,影响了数万订单。
真实案例:某团队在引入 DDD 后,代码中充满了精美的聚合、仓储、领域服务,但每次跨服务需求都需要「拉群对齐」,因为没有明确的上下文地图。产品经理提需求时说「改一下商品价格显示逻辑」,三个团队(商品、订单、营销)都认为这是自己的职责,最终在会议室吵了一下午才确定归属。
战略设计的目标,是把上述隐性知识变成可评审的工件:上下文地图、术语表、子域投资优先级与集成模式(客户-供应商、防腐层等)。战术设计再在这些边界之内展开。有了清晰的上下文映射,「改商品价格显示」的需求可以在 10 分钟内定位到正确的上下文(计价域),而不是三方扯皮。
关键认知:战略设计不是「画图玩」,而是团队协作的操作系统。当产品提需求、技术做设计、代码做评审时,都参考同一张上下文地图、同一份术语表,沟通成本会显著降低。没有战略设计的团队,每个需求都要重新「对齐理解」;有战略设计的团队,大部分对齐工作已经提前完成。
战略设计与第 1 章的分工
| 主题 | 第 1 章侧重 | 本章侧重 |
|---|---|---|
| 限界上下文 | 与分层、CQRS 并列介绍概念 | 识别方法、划分原则、电商多域对照 |
| 上下文映射 | 模式列表与示意 | 关系选型、Go 侧接口与适配器落地 |
| 通用语言 | 命名对照示例 | 工作坊流程、术语治理与演进 |
| 子域分类 | 核心 / 支撑 / 通用与评分 | 投资策略、资源配比与常见误判 |
读完本章,你应能用一页纸向团队说明:我们有哪些限界上下文、各自语言是什么、之间用哪种映射集成、哪几块值得重仓投入。
实践建议:战略设计不必一次性做到完美。可以先用一次 2 小时的工作坊识别出 3-5 个核心上下文,画出简单的映射图,建立初版术语表(20-30 个核心术语)。在后续迭代中,根据实际协作痛点逐步细化边界、补充术语、调整映射关系。重要的是让战略设计的产物(上下文地图、术语表、集成契约)成为团队评审与决策的依据,而不是藏在某个架构师的脑子里。
限界上下文(Bounded Context)
限界上下文是模型的显式边界:在边界之内,术语含义稳定、规则可推敲;跨边界则允许同名不同义,但必须通过契约(API、事件、发布语言)连接。
识别限界上下文
识别不是一次性「微服务切分」,而是对业务能力与协作现实的建模。可组合使用以下线索:
-
业务能力:下单、收款、发货、圈品投放通常是不同能力,各自有独立生命周期。例如「订单履约」是一个完整的业务能力,包含订单创建、支付确认、发货、收货等完整流程,这些步骤紧密耦合,应该归属同一个上下文。而「商品展示」则是另一个独立能力,包含商品上架、搜索、详情页等,两者可以独立演进。
-
语言边界:当同一个词在两处讨论时含义开始分叉,往往意味着边界临近。例如「锁库」在订单侧可能是预留,在库存侧可能是可售量扣减。再比如「价格」,在商品域是「标价」,在计价域是「试算结果」,在订单域是「合同金额」,在支付域是「实付金额」——四个上下文中的「价格」含义完全不同,需要明确划分。
-
一致性边界:需要同事务维护的不变量,通常落在同一上下文内;可接受最终一致的协作,适合跨上下文用事件衔接。例如订单总价必须等于各明细之和(强一致),适合在订单上下文内用聚合保证;而库存扣减后通知搜索索引更新(可接受秒级延迟),适合跨上下文用事件。
-
团队与发布节奏(康威定律):若两个模块永远由同一小队同节奏发布,拆成两个部署单元的紧迫性要重新评估;反之则倾向清晰上下文与契约。例如订单域和支付域虽然业务相关,但由不同团队维护、发布节奏独立、技术栈不同(订单用 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
识别要点:每个上下文有清晰的核心职责与生命周期——订单管理订单状态流转,商品管理可售商品信息,库存管理可售量与预占,营销管理优惠规则。它们通过定义明确的接口与事件协作,而不是共享同一个「大而全」的模型。
上下文边界的划分原则
-
优先保护不变量:订单总价与明细一致、库存不为负等,各自应在所属上下文的聚合内守护,而不是靠分布式事务「一把梭」。例如订单聚合在
AddLine方法中同步更新TotalAmount,保证总价与明细一致性;而库存扣减与订单创建分属两个上下文,通过事件最终一致。 -
拒绝共享大模型:不要把「全局统一 Product」当作目标;不同上下文各自建模,用 ID 与快照连接。订单上下文的
OrderLine包含商品快照(标题、单价),商品上下文的Product包含展示信息(详情、图片),两者模型不同但通过ProductID关联。 -
数据所有权清晰:每个业务表有唯一写入方;其他上下文只通过 API 或事件消费。库存表只能由库存服务写入,订单服务需要库存数据时通过
GetStockAPI 查询,而不是直接读库存表。 -
映射显式化:同步调用、异步事件、批量对账的选择应写进架构说明,而不是隐含在代码路径里。例如在上下文映射图中明确标注「订单 → 库存:同步预占 API + 超时释放事件」。
拆分决策树(可与团队工作坊共用):
flowchart TD
A[是否存在稳定业务边界?]
A -->|是| B[候选独立上下文]
A -->|否| C{一致性要求是否冲突?}
C -->|是| D[倾向拆分并定义集成]
C -->|否| E{是否不同团队 / 发布节奏?}
E -->|是| F[拆分 + 强化契约与监控]
E -->|否| G[模块化单体中先划清包边界]
使用建议:这个决策树适合在「是否拆分」的争议中使用。当团队对某个功能模块是否应该独立成服务有分歧时,逐个回答上述问题,多数情况下能达成共识。关键是不要为了拆而拆——模块化单体同样可以有清晰的限界上下文,只是物理部署在同一个进程中。等到团队规模、发布节奏确实需要独立时,再升级为独立服务。
电商案例:订单域、商品域、营销域
以下表格用于对齐职责与对外能力(示例命名可按团队语言替换):
| 限界上下文 | 核心关注点 | 典型聚合(示例) | 对其他上下文承诺的能力 |
|---|---|---|---|
| 订单域 | 订单生命周期、应付金额、状态机 | Order、OrderLine | PlaceOrder、CancelOrder、领域事件 OrderPlaced / OrderPaid |
| 商品域 | 可售商品、类目属性、媒体素材 | Product、SKU | 批量查询基础信息、按 SKU 返回标题与规格 |
| 营销域 | 券、活动、互斥叠加规则 | CouponCampaign、DiscountRule | PreviewPromotion、CommitPromotionHold |
实战案例:边界划分的决策过程
某电商平台早期把计价能力分散在订单、营销、商品三个域:订单域计算小计,营销域计算优惠,商品域返回基础价。随着业务复杂度上升,出现多处问题:
症状:
- 购物车、订单创建、支付确认三处的价格计算逻辑不一致
- 营销规则变更需要同步修改订单与商品的计算代码
- 无法支持「PDP 加购试算」场景,因为没有统一的计价入口
重构决策:
- 识别核心能力:「给定商品清单、营销规则、用户身份,计算出各层级价格」是一个完整的业务能力
- 独立上下文:新建计价上下文,职责是提供统一的试算接口,收敛所有价格计算逻辑
- 定义边界:
- 计价上下文不拥有商品基础价、营销规则、订单状态——它是编排者
- 对外提供
Calculate(items, promotions, context) -> PriceBreakdown - 各场景(PDP / 购物车 / 订单)通过统一接口获取价格
收益:
- 价格计算的一致性得到保证(同一套代码服务所有场景)
- 营销规则变更只需在营销域发布事件,计价域订阅后自动生效
- 支持了试算、价格预览、价格审计等新需求
经验:识别边界不是一次性切分,而是在「职责不清、协作成本高、重复逻辑多」的信号出现时,主动重构出清晰边界。
Go 建模提示:在订单上下文中,不要直接引用商品聚合的类型,而是使用 ID + 快照 表达跨边界依赖。
package order
type ProductID string
// Money 在订单上下文中表示「合同金额」;实现细节可复用共享包,但语义归属订单。
type Money struct {
Cents int64
Currency string
}
// OrderLine 属于订单上下文:保存下单时刻解释合同所需的快照。
type OrderLine struct {
ProductID ProductID
ProductName string // 快照:避免商品改标题影响历史订单
UnitPrice Money // 快照:避免改价影响已生成应付金额
Qty int
}
package catalog
type ProductID string
// Product 属于商品上下文:关注展示与销售属性,而非订单合同解释。
type Product struct {
ID ProductID
Title string
Description string
OnShelf bool
}
要点:两个包里的「商品信息」形状不同不是重复,而是上下文各有权威——合同解释以订单快照为准,陈列以商品上下文为准。
实践建议:在代码评审时,如果发现两个上下文共享同一个 Product 类型,应该追问:「这两处对商品的关注点是否相同?」如果答案是「不同」(一个关注展示,一个关注合同),那么应该拆分为两个独立的类型。宁可有一些字段重复,也不要为了「消除重复」而强行共享模型——这种重复是有意义的重复,体现了不同上下文的自治性。
通用语言(Ubiquitous Language)
通用语言是业务方与研发共同维护的精确词汇系统,贯穿需求、设计与代码。战略阶段的价值在于:先对齐语言,再讨论服务拆分与表结构。
建立通用语言
推荐从一次轻量工作坊开始:
- 列出动词与名词:下单、支付、发货、锁库、核销、退款……标记同义词(「关闭订单」vs「取消订单」)。
- 为每个词写一句业务定义:谁触发、前置状态、成功后的世界有何不同。
- 映射到代码锚点:包名、类型名、公开方法名尽量使用一致词汇(如
PlaceOrder而非CreateOrderRecord)。 - 记录禁用词:例如团队约定不用「更新状态 2」这类技术黑话对外沟通。
工作坊实践流程(2 小时示例)
参与者:产品经理、领域专家、架构师、核心开发各 1-2 人。
第一阶段(30 分钟):业务流程梳理
- 在白板上画出核心业务流程(如「用户下单到收货」)
- 标记出关键状态节点与触发动作
- 识别出现频率最高的业务名词(订单、商品、库存、优惠券)
第二阶段(45 分钟):术语对齐
- 逐个讨论每个名词的精确定义
- 示例:「库存」在商品上架时指初始可售量,在订单创建时指预占后的剩余量,在发货后指实际扣减
- 决策:用「可售库存(Available Stock)」、「预占库存(Reserved Stock)」、「已扣库存(Deducted Stock)」三个明确术语替代模糊的「库存」
- 识别同义词并统一
- 示例:技术侧说「关单」,业务侧说「取消订单」→ 统一为
CancelOrder - 示例:「锁库」、「预占库存」、「冻结库存」→ 统一为
ReserveStock
- 示例:技术侧说「关单」,业务侧说「取消订单」→ 统一为
第三阶段(30 分钟):映射到代码
- 为每个术语分配英文命名(供代码使用)
- 明确哪些术语属于哪个限界上下文
- 示例输出:
| 中文术语 | 英文命名 | 所属上下文 | 定义 |
|---------|---------|-----------|------|
| 下单 | PlaceOrder | 订单域 | 用户提交购买意图,生成待支付订单 |
| 预占库存 | ReserveStock | 库存域 | 为订单预留库存,防止超卖;超时后自动释放 |
| 核销优惠券 | RedeemCoupon | 营销域 | 将优惠券从可用状态变更为已使用 |
第四阶段(15 分钟):归档与宣导
- 将术语表提交到代码仓库(
docs/glossary.md) - 在下次需求评审时强制对齐:新需求必须使用术语表中的词汇
- 代码评审时检查:新增 API / 事件命名是否符合术语表
工作坊成果示例:
#### 订单上下文术语(v1.0)
- **下单(PlaceOrder)**:用户提交购买意图,生成待支付订单;不等于支付成功。
- 前置条件:商品可售、库存充足、优惠券可用
- 后置状态:订单状态为 `PendingPayment`,库存为 `Reserved`
- **锁库 / 预占库存(ReserveStock)**:为指定订单行预留可售库存,防止超卖;不等同于「扣减库存」。
- 触发方:订单域在创单时调用库存域接口
- 超时策略:30 分钟未支付自动释放
- **订单已支付(OrderPaid)**:支付渠道确认成功后的领域事实;会触发履约与扣减等后续流程。
- 事件订阅者:库存域(扣减库存)、物流域(创建配送单)、营销域(核销优惠券)
实战技巧:
- 不要追求第一版术语表的完美——先建立 60% 共识,剩余在迭代中补充
- 争议术语标记「待定」,给出 2-3 个候选,在实际编码中验证哪个更顺
- 每季度 Review 一次术语表,淘汰不再使用的术语,补充新增的核心概念
Go:让类型系统承载语言:
package order
type OrderID string
type UserID string
type ProductID string
// PlaceOrderCommand 用业务动词命名命令,而非数据库操作。
type PlaceOrderCommand struct {
BuyerID UserID
Lines []OrderLineDraft
}
type OrderLineDraft struct {
ProductID ProductID
Qty int
}
语言的演进与维护
语言会随业务演进,需要低成本维护机制:
- ADR / RFC:当术语含义变化(例如「预售」从全款改为定金),用简短架构记录说明新旧语义与兼容期。
- 版本化 API:对外契约(REST / gRPC / 事件 Schema)与术语表联动更新,避免「文档是新的、代码是旧的」。
- 定期 Review:每个迭代挑一个争议需求,反问「我们用的是哪一个上下文里的定义?」
演进示例:当业务引入「先用后付」,需明确它属于支付上下文的授信产品,还是订单上下文的支付子状态——结论应写回术语表,并调整 OrderStatus 与集成事件名,而不是仅在 if 分支加 flag。
版本化策略:术语表应该有版本号(如 v1.0、v1.1),每次重大变更(如删除术语、修改定义)都升级版本并记录变更日志。这样新人可以追溯「为什么当初选择这个词」,避免重复讨论已解决的问题。同时,对外API的命名也应该与术语表版本对应,例如PlaceOrder v1使用术语表v1.0的定义,PlaceOrder v2使用v1.1的定义,保证向后兼容。
跨团队同步:术语表变更应该通知所有相关团队。可以在 Git 仓库中设置术语表文件的 CODEOWNERS,任何修改都需要相关团队的 Approver 确认。这样可以避免「术语表改了但代码没改」或「不同团队理解不一致」的问题。
反模式:技术术语污染业务讨论
典型反模式包括:在评审中使用 OrderDTO / OrderVO、把数据库动词当业务语言(InsertOrder)、用魔法状态码沟通。它们会阻断业务专家的参与。
package badexample
// ❌ 技术噪声:业务方无法从命名理解用例意图
type OrderService struct{}
func (s *OrderService) HandleSubmit(data map[string]any) error { return nil }
// ✅ 使用业务动词与强类型参数,评审可对读
package order
import "context"
type OrderService interface {
PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error)
}
更多语言污染案例与纠正
反模式 1:用数据库字段名代替业务概念
// BAD: 数据库思维泄漏到业务层
type Order struct {
OrderNo string
UserId int64
TotalAmt int64
StatusCode int // 0=待支付 1=已支付 2=已取消 3=已关闭
}
func (s *OrderService) UpdateStatusCode(orderNo string, code int) error {
// 业务规则隐藏在魔法数字背后
}
// GOOD: 业务语言驱动设计
type Order struct {
ID OrderID
CustomerID CustomerID
Total Money
Status OrderStatus // 枚举类型
}
type OrderStatus int
const (
StatusPendingPayment OrderStatus = iota
StatusPaid
StatusCanceled
StatusFulfilled
)
// 业务动词显式化
func (o *Order) MarkAsPaid(paidAt time.Time) error {
if o.Status != StatusPendingPayment {
return ErrInvalidTransition
}
o.Status = StatusPaid
o.PaidAt = paidAt
return nil
}
收益:代码评审时,业务方能直接参与讨论状态转换规则,而不是盯着 SQL 猜测 status = 1 的含义。
反模式 2:接口命名只有 CRUD,没有业务意图
// BAD: 贫血模型 + CRUD
type OrderRepository interface {
Insert(ctx context.Context, order *Order) error
Update(ctx context.Context, order *Order) error
Delete(ctx context.Context, id string) error
Select(ctx context.Context, id string) (*Order, error)
}
问题:
Update可以改任意字段,无法表达业务约束- 新人看不出「订单支付」应该调用哪个方法
- 业务规则散落在 Service 层的 if-else 中
// GOOD: 用例驱动的接口
type OrderUseCase interface {
PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) (OrderID, error)
MarkAsPaid(ctx context.Context, orderID OrderID, paidAt time.Time) error
CancelOrder(ctx context.Context, orderID OrderID, reason string) error
}
收益:接口即文档——每个方法对应一个明确的业务用例。
反模式 3:在需求评审中使用技术黑话
真实案例:产品提需求「用户支付后,订单状态改为 2」。
问题:
- 产品被迫记忆「2」的含义(下次可能记错)
- 新人无法从文档理解业务流程
- 数据库状态码变更时,文档需要全局替换
纠正方案:
- 需求文档使用业务语言:「用户支付后,订单状态改为已支付」
- 代码中使用枚举常量:
StatusPaid - 数据库存储可以是数字,但对外接口和文档必须是业务术语
落地检查:
- 代码评审时,发现「魔法数字」或「技术缩写」,要求作者用业务术语重命名
- API 文档生成时,枚举值自动展示为业务含义(如
"status": "paid") - 新人培训时,术语表是必读文档
上下文映射(Context Mapping)
上下文映射描述谁依赖谁、如何集成。它把组织关系与架构关系对齐,避免「谁都能改」的隐式耦合。
上下游关系模式
常见关系(节选):
| 模式 | 关系 | 典型集成 | 电商提示 |
|---|---|---|---|
| 客户-供应商(Customer-Supplier) | 下游依赖上游,上游需考虑下游诉求 | 版本化查询 API、批量接口 | 订单(客户)依赖商品(供应商) |
| 遵奉者(Conformist) | 下游无力改变上游模型 | 直接采用对方模型 | 税务、监管、强势渠道 |
| 防腐层(ACL) | 下游翻译上游模型,保护自身核心 | 适配器封装第三方 SDK | 对接微信 / 支付宝支付 |
| 开放主机服务(OHS) | 上游提供稳定多租户接口 | 标准 REST / gRPC + 兼容策略 | 商品中心对搜索、推荐、活动统一供数 |
| 发布语言(Published Language) | 双方约定中立交换格式 | JSON Schema、Avro、开放事件规范 | OrderPaid 事件字段集 |
graph LR
subgraph 上游
Catalog[商品上下文]
Promo[营销上下文]
end
subgraph 下游
Checkout[结算上下文]
Order[订单上下文]
end
Catalog -->|Customer-Supplier| Order
Promo -->|Customer-Supplier| Checkout
Checkout -->|同步编排| Order
各模式的实战应用
客户-供应商(Customer-Supplier)实践
场景:订单域(客户)依赖商品域(供应商)获取商品信息。
关键点:
- 上游(商品域)提供版本化 API,保证向后兼容
- 下游(订单域)通过契约测试验证依赖稳定性
- 定期召开「契约评审会」,下游提需求,上游评估可行性
实现示例:
// 商品域对外提供的稳定接口(v1版本)
package catalogapi
type GetProductRequest struct {
ProductID string `json:"product_id"`
}
type GetProductResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Price float64 `json:"price"`
Available bool `json:"available"`
}
// 订单域依赖商品域的接口
package order
type ProductAPI interface {
GetProduct(ctx context.Context, productID string) (*catalogapi.GetProductResponse, error)
}
契约测试:
func TestProductAPI_Contract(t *testing.T) {
// 验证商品域的响应格式是否符合订单域的预期
resp := &catalogapi.GetProductResponse{
ID: "SKU123",
Title: "iPhone 15",
Price: 5999.00,
Available: true,
}
// 断言必需字段存在
assert.NotEmpty(t, resp.ID)
assert.NotEmpty(t, resp.Title)
}
遵奉者(Conformist)实践
场景:对接税务系统、支付渠道等强势上游,无力改变对方模型。
策略:
- 直接使用对方的数据结构(避免无谓的翻译层)
- 在上游变更时快速跟进(监听对方发布公告)
- 内部文档记录「为什么使用对方模型」(避免后人困惑)
示例:
// 直接使用支付宝 SDK 的类型
import "github.com/alipay/alipay-sdk-go"
type AlipayAdapter struct {
client *alipay.Client
}
func (a *AlipayAdapter) CreatePayment(orderID string, amount float64) error {
// 直接使用 Alipay SDK 的请求结构
req := alipay.TradeCreateRequest{
OutTradeNo: orderID,
TotalAmount: fmt.Sprintf("%.2f", amount),
Subject: "订单支付",
}
_, err := a.client.TradeCreate(&req)
return err
}
适用场景:上游是成熟的外部系统,模型变更频率低,翻译成本高于收益。
开放主机服务(OHS)+ 发布语言实践
场景:商品中心需要服务多个下游(搜索、推荐、营销、订单),避免为每个下游定制接口。
策略:
- 设计通用查询接口,支持灵活的筛选与投影
- 发布标准事件(JSON Schema / Protobuf),所有下游订阅相同事件
- 使用 API Gateway 管理多租户访问(限流、鉴权、版本路由)
示例:
// 商品域发布统一的查询接口
type ProductQueryAPI interface {
ListProducts(ctx context.Context, req ListProductsRequest) (*ListProductsResponse, error)
}
type ListProductsRequest struct {
CategoryID string `json:"category_id,omitempty"`
Tags []string `json:"tags,omitempty"`
OnShelf *bool `json:"on_shelf,omitempty"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
发布语言(事件):
{
"event_type": "ProductOnShelf",
"version": "1.0",
"product_id": "SKU123",
"title": "iPhone 15",
"price": 5999.00,
"occurred_at": "2026-04-17T10:00:00Z"
}
收益:
- 下游(搜索、推荐)可以独立订阅事件,无需与商品域强耦合
- 商品域只需维护一套接口,降低维护成本
- 通过 Schema Registry 管理事件版本,保证向后兼容
选型决策树:
flowchart TD
start([识别上下游关系]) --> q1{上游是否可控?}
q1 -->|是| q2{下游数量?}
q2 -->|1-2个| customer[Customer-Supplier]
q2 -->|3个以上| ohs[OHS + Published Language]
q1 -->|否| q3{模型是否冲突?}
q3 -->|冲突| acl[ACL 防腐层]
q3 -->|不冲突| conformist[Conformist 遵奉者]
共享内核
共享内核是两方共同维护的一小块模型或库。它减少重复,但会牺牲自治,需要强治理。
电商谨慎场景:订单与库存若共享「SKU ID 类型 + 基础校验函数」这类极小内核尚可;一旦共享「库存数量字段」或「订单状态枚举」,边界会迅速模糊。
// sharedkernel/sku.go — 保持极小、稳定、少变更
package sharedkernel
type SKUCode string
func (c SKUCode) IsWellFormed() bool {
return len(string(c)) >= 6 // 示例规则:长度下限
}
实践建议:共享内核应能通过双人评审 + 语义化版本演进;否则优先改为 Published Language(如清晰的事件字段)而非代码级共享。
何时使用共享内核:
- 两个上下文由同一团队维护,且模型变更成本低
- 共享的是极其稳定的基础类型(如 ID、Money、Email 等值对象)
- 双方都同意「修改共享内核需要通知并等待对方确认」
何时避免共享内核:
- 两个上下文由不同团队维护(会严重拖慢发布节奏)
- 共享的是经常变化的业务规则(如订单状态、库存策略)
- 无法保证「修改前通知」的纪律(会导致隐式破坏性变更)
真实案例:某团队在订单与库存之间共享了 ProductStatus 枚举。营销需求要求增加「预售」状态,订单团队快速修改了枚举并发布,但忘记通知库存团队。库存服务在处理「预售商品」时因为没有对应的分支处理逻辑,导致库存同步失败。最终双方约定:将共享内核降级为 Published Language(事件 Schema),每次变更必须走 RFC 流程并双方确认。
防腐层
防腐层把外部不稳定协议挡在边界之外,领域层只依赖自己的端口接口。
package order
import (
"context"
"fmt"
)
type OrderID string
// Money 表示订单上下文的应付金额(示例:用分存储,避免 float)。
type Money struct {
cents int64
currency string
}
func NewMoneyFromCents(cents int64, currency string) Money {
return Money{cents: cents, currency: currency}
}
func (m Money) DecimalYuan() string {
if m.cents < 0 {
return "0.00"
}
yuan := m.cents / 100
fen := m.cents % 100
return fmt.Sprintf("%d.%02d", yuan, fen)
}
// PaymentSession 是领域侧对「可跳转支付」的最小抽象,不暴露渠道字段。
type PaymentSession struct {
CheckoutURL string
}
type StartPaymentCommand struct {
OrderID OrderID
Payable Money
}
// PaymentGateway 由订单领域定义:表达「我需要的支付能力」,由基础设施实现。
type PaymentGateway interface {
StartPayment(ctx context.Context, cmd StartPaymentCommand) (*PaymentSession, error)
}
package alipayacl
import (
"context"
"fmt"
"example/order"
)
// 仅示意第三方 SDK 的能力边界,避免示例依赖真实包名。
type alipayPrecreateClient interface {
Precreate(ctx context.Context, body map[string]any) (*alipayPrecreateResult, error)
}
type alipayPrecreateResult struct {
QRCodeURL string
}
// AlipayACL 位于基础设施侧:翻译领域命令 ↔ 支付宝请求 / 响应。
type AlipayACL struct {
client alipayPrecreateClient
}
func NewAlipayACL(client alipayPrecreateClient) *AlipayACL {
return &AlipayACL{client: client}
}
func (a *AlipayACL) StartPayment(ctx context.Context, cmd order.StartPaymentCommand) (*order.PaymentSession, error) {
req := map[string]any{
"out_trade_no": string(cmd.OrderID),
"total_amount": cmd.Payable.DecimalYuan(),
"subject": fmt.Sprintf("订单支付 %s", string(cmd.OrderID)),
}
resp, err := a.client.Precreate(ctx, req)
if err != nil {
return nil, err
}
return &order.PaymentSession{CheckoutURL: resp.QRCodeURL}, nil
}
收益:
- 当渠道字段变更时,修改集中在 ACL;订单聚合与用例不被第三方类型污染
- 可以为同一个端口提供多个实现(支付宝 ACL、微信 ACL、PayPal ACL),通过工厂模式或配置切换
- 测试时可以使用 Fake 实现替代真实支付渠道,提升测试速度和可靠性
- 防腐层承担了「翻译」职责,领域层保持纯粹,不受外部依赖污染
反模式警示:有些团队会在防腐层之外再包一层「防防腐层」,过度封装导致代码层级过深。原则是一次翻译足矣——从第三方模型翻译到领域模型,中间不需要再多一层「通用模型」。
电商系统的上下文映射实例
综合一版可挂在 Wiki 首页的示意(箭头表示依赖方向):
graph LR
OrderBC[订单上下文]
CatalogBC[商品上下文]
InventoryBC[库存上下文]
PaymentBC[支付上下文]
LogisticsBC[物流上下文]
PromoBC[营销上下文]
OrderBC -->|Customer-Supplier| CatalogBC
OrderBC -->|Customer-Supplier| InventoryBC
OrderBC -->|ACL| PaymentBC
OrderBC -->|Customer-Supplier| LogisticsBC
OrderBC -->|Customer-Supplier| PromoBC
InventoryBC -.->|极小共享内核| CatalogBC
落地检查清单:
- 每个箭头是否有明确契约(OpenAPI / Proto / 事件表)?
- 失败模式(超时、重试、幂等)是否写清归属上下文?
- 是否存在「绕过契约直连数据库」的路径?若有,计划消除。
集成失败案例与解决方案
案例 1:订单域直接读取库存表(违反边界)
背景:订单服务为了展示「剩余库存」,在查询订单详情时直接 JOIN 库存表。
问题:
- 库存表结构变更时,订单服务也要修改(耦合)
- 库存域无法独立演进(加缓存、分库、切换存储)
- 破坏了「库存域拥有库存数据」的所有权原则
解决方案:
// 订单域定义端口
type StockQueryPort interface {
GetAvailableQty(ctx context.Context, sku string) (int, error)
}
// 基础设施层实现(调用库存域 API)
type RemoteStockAdapter struct {
client *stockservice.Client
}
func (a *RemoteStockAdapter) GetAvailableQty(ctx context.Context, sku string) (int, error) {
resp, err := a.client.GetStock(ctx, &stockpb.GetStockRequest{Sku: sku})
if err != nil {
return 0, fmt.Errorf("query stock: %w", err)
}
return int(resp.AvailableQty), nil
}
收益:库存域可以独立优化存储、加缓存、切换数据库,订单域只依赖接口契约。
案例 2:营销规则变更导致订单金额计算不一致
背景:营销域上线新规则后,订单域的价格计算逻辑未同步更新,导致订单金额与用户预览不一致。
根因:没有统一的计价入口,订单域、购物车、PDP 各自实现计算逻辑。
解决方案:
- 新建计价上下文作为编排者
- 所有场景通过计价域的
Calculate接口获取价格 - 营销规则变更时,只需更新营销域;计价域自动订阅规则变更事件
graph LR
PDP[商品详情页] -->|试算请求| Pricing[计价上下文]
Cart[购物车] -->|试算请求| Pricing
Order[订单域] -->|确认金额| Pricing
Pricing -->|查基础价| Catalog[商品域]
Pricing -->|查规则| Promo[营销域]
案例 3:支付回调丢失导致订单状态不同步
背景:支付域通过 HTTP 回调通知订单域支付成功,但网络抖动导致回调丢失,订单长时间停留在「待支付」状态。
问题:
- 同步回调不可靠(网络、超时、重启)
- 缺少补偿机制
解决方案:
- 异步事件 + 重试:支付域发布
PaymentCaptured事件到 Kafka,订单域订阅并幂等处理 - 对账任务:每小时扫描「待支付」订单,调用支付域查询实际状态,发现不一致则补偿
- 状态机保护:订单状态机禁止「已支付 → 待支付」的逆向转换,防止数据损坏
// 订单域订阅支付事件
func (s *OrderService) HandlePaymentCaptured(ctx context.Context, event PaymentCapturedEvent) error {
order, err := s.repo.FindByID(ctx, event.OrderID)
if err != nil {
return err
}
// 幂等检查
if order.Status == StatusPaid {
return nil // 已处理
}
return order.MarkAsPaid(event.PaidAt)
}
经验:跨上下文集成必须考虑失败场景——同步调用加超时与重试,异步事件加幂等与对账。
领域的分类
战略精炼的重要产出,是把公司能力地图分为 核心域、支撑域、通用域,以指导人力与风险的投放。
核心域、支撑域、通用域
- 核心域:差异化竞争力所在,复杂且多变,应重仓自研与深度建模。例如亚马逊的推荐算法、阿里的交易风控,这些是竞争壁垒,必须投入顶尖人才与架构资源。
- 支撑域:业务必需但非胜负手,可适度定制,避免过度设计。例如商品管理、库存管理,参考业界成熟模型即可,不必追求极致创新。
- 通用域:行业共性,成熟外包或 SaaS 更划算。例如短信通知、对象存储、日志监控,云服务比自研更经济。
投资策略
可用「四维度」快速评分:业务价值、复杂度、变化频率、差异化。不必追求精确分数,关键是相对比较与资源承诺。
| 子域示例 | 倾向 | 投资建议(示意) |
|---|---|---|
| 订单履约 | 核心域 | 架构师 + 高可用工程化,明确 SLA 与演练 |
| 库存准确性 | 核心域 | 并发控制、对账与仿真压测 |
| 商品管理 | 支撑域 | 参考业界模型,控制自定义范围 |
| 消息通知 | 通用域 | 使用云短信 / 邮件 SaaS,内部仅封装网关 |
投资决策案例
案例 1:搜索推荐从支撑域升级为核心域
背景:某平台早期将搜索视为支撑域,采用开源 Elasticsearch + 简单配置。随着 GMV 增长,发现 70% 流量来自搜索,转化率直接影响营收。
决策过程:
- 重新评估:搜索从「辅助发现」变为「核心转化入口」
- 投资升级:
- 组建搜索算法团队(相关性、个性化排序)
- 自研召回引擎与排序服务(而非依赖 ES 默认评分)
- 建设 ABTest 平台与实时指标体系
- 资源配比:从 1 人维护 → 8 人团队(算法 + 工程)
收益:搜索点击率提升 40%,GMV 贡献占比从 70% 提升到 85%。
案例 2:自研消息队列的代价
背景:某团队因「担心 Kafka 运维复杂」,自研了轻量级消息队列。
问题暴露:
- 维护成本:集群故障、数据丢失、性能瓶颈需要专人处理
- 功能缺失:不支持事务、延迟消息、死信队列等企业级特性
- 团队分心:核心业务开发被「救火中间件」打断
纠正方案:
- 迁移到云服务商托管的 Kafka(或 RocketMQ)
- 内部仅封装薄的 SDK 层(日志、监控、错误处理)
- 释放的人力投入到核心业务优化
经验:通用域的自研往往是「过早优化」——先用成熟方案,待瓶颈确认后再评估自研价值。
常见误判:
- 把所有模块都标成核心域:资源分散,真正的差异化无人深耕。
- 把支撑域做成「无设计」:质量太差会反向拖垮核心域(数据错误、不可用)。
- 在通用域自研中间件:短期有掌控感,长期维护成本侵蚀核心业务投入。
- 忽视支撑域的稳定性:商品数据错误、库存不准确会直接影响订单转化——支撑域不是「二等公民」。
投资复盘机制
建议每半年召开一次「子域投资复盘会」:
- 回顾当前分类:哪些域的重要性发生了变化?
- 评估资源配比:核心域是否得到了足够的架构师与工程资源?
- 识别欠投资域:哪些支撑域因质量问题拖累了核心域?
- 调整策略:将资源从「过度投资的通用域」转移到「欠投资的核心域」
输出示例:
| 子域 | 上次分类 | 本次分类 | 资源变化 | 理由 |
|---|---|---|---|---|
| 搜索推荐 | 支撑域 | 核心域 | +5 人 | 转化率提升是 Q1 关键目标 |
| 消息队列 | 通用域 | 通用域 | -2 人 | 迁移到云服务,释放人力 |
| 库存准确性 | 核心域 | 核心域 | +3 人 | 大促准备,加强对账与监控 |
电商平台的领域分类
结合国内中大型平台的常见划分(需按公司战略微调):
核心域候选:交易订单、库存准确性、交易风控、推荐与搜索体验(若差异化来自发现与转化)。
支撑域候选:商品中心、营销规则、计价、物流对接、客服工单。
通用域候选:登录注册、对象存储、日志监控、基础消息通道。
详细领域分类与资源配比
| 子域 | 分类 | 业务价值 | 复杂度 | 变化频率 | 差异化 | 资源配比示例 |
|---|---|---|---|---|---|---|
| 订单履约 | 核心域 | 极高 | 高 | 中 | 高 | 10人团队,架构师+高工 |
| 库存准确性 | 核心域 | 极高 | 高 | 中 | 高 | 8人团队,专项优化 |
| 交易风控 | 核心域 | 极高 | 极高 | 高 | 极高 | 算法团队+工程团队 |
| 搜索推荐 | 核心域 | 极高 | 极高 | 高 | 高 | 算法+工程双轨 |
| 商品中心 | 支撑域 | 高 | 中 | 低 | 中 | 5人团队,参考业界 |
| 营销系统 | 支撑域 | 高 | 高 | 高 | 中 | 6人团队,规则引擎 |
| 计价系统 | 支撑域 | 高 | 中 | 中 | 低 | 3人团队,统一接口 |
| 物流对接 | 支撑域 | 中 | 中 | 低 | 低 | 2人维护,适配器模式 |
| 登录注册 | 通用域 | 中 | 低 | 低 | 无 | 使用 OAuth2 服务 |
| 对象存储 | 通用域 | 低 | 低 | 低 | 无 | 云服务(OSS/S3) |
| 消息通知 | 通用域 | 中 | 低 | 低 | 无 | 云服务+薄封装 |
分类决策的实战案例
案例 1:库存系统从支撑域升级为核心域
初始阶段:
- 分类:支撑域(「库存只是个数字,没啥复杂的」)
- 投资:2 人维护,简单 Redis + MySQL
问题暴露:
- 大促期间频繁超卖,投诉量激增
- 供应商库存同步延迟,导致用户下单后被取消
- 库存准确性成为用户信任的核心指标
重新评估:
- 业务价值:库存不准 → 超卖 → 投诉 → 品牌损失(极高)
- 复杂度:多仓、多供应商、实时同步、预占释放(高)
- 差异化:准确率 99.9% vs 竞品 95%(高)
- 结论:升级为核心域
投资升级:
- 团队扩充到 8 人(架构师 + 高工 + 算法)
- 建设预占释放机制、对账系统、实时监控
- 引入分布式锁与 Lua 脚本保证并发正确性
- 建立库存准确性 SLA(99.95%)
收益:超卖率从 5% 降到 0.1%,用户满意度显著提升。
案例 2:消息通知从自研降级为云服务(通用域)
初始阶段:
- 分类:支撑域
- 投资:4 人团队自研短信 / 邮件 / 推送网关
问题暴露:
- 维护成本高(渠道对接、失败重试、速率控制)
- 功能落后(不支持模板管理、A/B 测试)
- 团队被「救火」占用,无法投入核心业务
重新评估:
- 业务价值:中(通知到达率影响体验,但非核心竞争力)
- 差异化:无(所有平台都需要通知,无差异化空间)
- 结论:降级为通用域,使用云服务
调整方案:
- 迁移到云服务商(阿里云 / 腾讯云 / AWS SNS)
- 内部仅保留薄封装层(日志、监控、降级)
- 释放的 4 人转投到核心域(搜索推荐优化)
收益:维护成本降低 80%,功能更丰富(模板管理、多渠道支持),团队聚焦核心业务。
包结构与投资映射
// 用包结构反映投资重心(示意):核心域包内更完整领域模型,外围保持薄封装。
// 核心域:完整的 DDD 分层
// core/order/
// ├── domain/ (聚合、实体、值对象、领域服务)
// ├── application/ (用例、命令处理器)
// ├── adapter/ (HTTP、gRPC、事件订阅)
// └── infra/ (仓储实现、外部集成)
// 支撑域:适度建模,控制复杂度
// supporting/catalog/
// ├── service/ (业务逻辑)
// ├── repository/ (数据访问)
// └── api/ (对外接口)
// 通用域:薄封装,优先使用外部服务
// generic/notification/
// ├── client/ (云服务 SDK 封装)
// └── config/ (配置与降级)
说明:包划分只是辅助沟通的手段,真正重要的是团队边界、发布边界与数据所有权是否与之一致。核心域投入更多架构设计与代码质量保障,支撑域保持适度复杂度,通用域优先复用成熟方案。
本章小结
核心要点回顾
- 战略设计解决边界、协作与语言问题,是战术建模的前置条件;与第 1 章的架构骨架互补而非重复。
- 限界上下文按业务能力、语言分叉、一致性与团队现实划分;订单、商品、营销应各自维护模型,以 ID 与快照连接。
- 通用语言需要术语表、命令动词与演进机制,避免技术黑话污染评审。
- 上下文映射把依赖关系产品化:客户-供应商、防腐层、共享内核(克制使用)、开放主机服务与发布语言各得其所。
- 子域分类驱动投资:核心域求精,支撑域求稳,通用域求省,并定期复盘调整。
落地检查清单
在项目启动或重构时,可用以下清单自查:
限界上下文识别:
- 是否识别出 3-8 个主要限界上下文?(过少说明边界模糊,过多说明过度拆分)
- 每个上下文是否有清晰的职责描述(一句话能说清楚)?
- 是否避免了「大而全的领域模型」(如一个 Product 类服务所有上下文)?
- 跨上下文的数据引用是否通过 ID 与快照,而非直接依赖对方的聚合类型?
通用语言建立:
- 是否建立了术语表(
docs/glossary.md)? - 代码中的核心类型、方法名是否与术语表一致?
- 是否消除了魔法数字与技术黑话(如
status=2、UpdateData)? - 新需求评审时,是否强制使用术语表中的词汇?
上下文映射:
- 是否有明确的上下文映射图(类似 4.4.4 的 Mermaid 图)?
- 每个依赖关系是否标注了集成模式(Customer-Supplier / ACL / OHS)?
- 是否为关键集成定义了契约(API 文档 / Proto / 事件 Schema)?
- 是否考虑了失败场景(超时、重试、降级、对账)?
子域投资:
- 是否明确标记了核心域、支撑域、通用域?
- 核心域是否得到了架构师与高级工程师的投入?
- 通用域是否优先使用成熟的开源 / 云服务,而非自研?
- 是否有定期复盘机制(每半年 Review 一次子域分类)?
常见陷阱总结
| 陷阱 | 症状 | 纠正方向 |
|---|---|---|
| 战术先行 | 代码结构优美,但跨团队协作仍然混乱 | 补充战略设计:画出上下文地图,建立术语表 |
| 边界过度拆分 | 10+ 个微服务,每个只有几百行代码 | 合并职责相近的上下文,先模块化后服务化 |
| 共享大模型 | 一个 Product 类被所有服务依赖 | 每个上下文独立建模,用 ACL 翻译 |
| 术语不一致 | 同一概念有 3 种命名(Order / Purchase / Transaction) | 通过工作坊对齐,强制使用术语表 |
| 忽视集成失败 | 只考虑 Happy Path,线上频繁出现数据不一致 | 为每个集成点设计失败处理(重试、降级、对账) |
| 投资不均衡 | 核心域缺人,通用域自研中间件占用大量资源 | 重新评估子域分类,调整资源配比 |
实践建议
-
战略设计不是一次性工作——建议每季度召开一次「边界复盘会」,回顾上下文划分是否合理,术语表是否需要更新。
-
工作坊轻量化——不必追求「完美的限界上下文图」,先建立 60% 共识,剩余在实际编码中验证和调整。
-
术语表即文档——将术语表提交到代码仓库,作为新人培训与需求评审的必读材料。
-
从小处着手——若团队尚未实践 DDD,可以从「建立一个术语表」或「重命名一个核心 API」开始,而非一次性重构整个系统。
-
与第 1 章方法论结合——战略设计画出边界 → Clean Architecture 确保依赖方向 → DDD 战术设计守护不变量 → CQRS 优化读写路径,四者协同发力。
与下一节的衔接:接下来将把镜头转向单个系统内部,讨论三层架构、Clean Architecture、DDD 战术与 CQRS 如何共同建立稳定的内部秩序。战略设计先回答「边界是什么、边界在哪里」,系统内部结构再回答「边界之内如何组织代码与数据流」。
3.3 系统内部结构设计
为什么要单独讨论系统内部结构
如果说本章解决的是「系统应该被划分成哪些业务边界」,那么这一章要回答的是另一个同样关键的问题:在边界已经划定之后,单个系统内部到底应该怎么组织?
很多团队在架构演进时,会把两个层面混在一起讨论:
- 一边在谈限界上下文怎么划
- 一边又在争论 Handler 能不能直接调 Repository
- 同时还在讨论聚合根、值对象、读写分离和查询性能
这样做的问题是,边界问题、依赖问题、建模问题和数据流问题被混成一团,最后往往谁都没有真正讲清楚。
对架构师来说,系统内部结构设计至少要同时回答四个问题:
- 代码先按什么方式分层,团队才容易协作?
- 依赖应该按什么方向流动,外部技术才不会污染核心业务?
- 复杂业务规则应该由什么模型承载,而不是散落在 Service 里?
- 当读和写的目标已经出现冲突时,数据流又该如何拆开?
这也正对应了本章的四个关键抓手:
- 三层架构:先建立最基本的职责秩序
- Clean Architecture:再约束依赖方向
- DDD 战术设计:让复杂规则回到模型内部
- CQRS:当读写目标冲突时,再拆分读写路径
从这个角度看,这四者并不是彼此替代,而是逐层叠加。
从业务边界到系统内部结构
本章讲的战略设计,解决的是「地图」问题:哪些能力属于订单,哪些属于库存,哪些属于营销,团队之间怎样通过上下文地图协作。
而本章解决的是「房间内部装修」问题:当我们已经确定了这里是订单上下文、那里是库存上下文之后,订单系统内部又该如何分层、如何建模、如何组织读写路径。
如果没有本章的边界,系统内部结构设计会失去落点;
如果只有边界、没有内部结构,系统很快又会退化成新的大泥球。
因此,这一章在全书里承担的是承上启下的角色:
- 承接本章:边界已经划清
- 通向本章的 3.4 部分:单个系统内部有了秩序之后,才有条件进一步讨论系统之间如何协作
架构师在这一层真正关注什么
在系统内部结构设计这一层,架构师最需要关注的,通常不是“某个类放在哪个目录”,而是以下几类长期成本:
- 变更成本:一个新需求会不会牵一发而动全身?
- 理解成本:新人能不能在有限时间内看懂业务主线?
- 测试成本:核心逻辑能否脱离数据库和消息中间件被验证?
- 扩展成本:当新促销规则、新支付方式、新查询场景出现时,系统能否局部演进?
这也是为什么系统内部结构设计,不能只靠“项目目录看起来很整齐”来判断。真正重要的是:职责有没有分清、依赖有没有收束、规则有没有回到模型内部、读写目标有没有被正确拆分。
标准三层架构:多数项目的默认起点
为什么三层架构会成为默认起点
在系统设计的早期阶段,最重要的往往不是“架构是否优雅”,而是“职责是否基本清楚”。对于一个刚开始建设的 order-service 来说,下单、查询订单、支付后更新状态、超时关单,这些需求虽然已经涉及接口、业务逻辑和数据存储,但复杂度通常还不足以支撑更重的架构方法论。此时,标准三层架构往往是一个合适的起点。
它之所以在很多项目里成为默认选择,不是因为它“最先进”,而是因为它在低复杂度阶段最务实。对多数团队而言,先让代码按职责分区、让调用链基本清晰,往往比一开始就引入大量抽象更重要。
三层架构的核心分工
三层架构的核心思想很简单:把系统按职责拆成表现层、业务逻辑层和数据访问层。如果用更统一的工程命名来表达,这三类职责通常会分别落在 interfaces、application 和 infrastructure 中。这样做的目的不是追求抽象,而是让不同类型的代码各归其位:
- 表现层(Interfaces):接收 HTTP、RPC 或消息请求,完成参数解析和响应组装
- 业务逻辑层(Application):承载核心业务流程,例如创建订单、计算总价、更新状态
- 数据访问层(Infrastructure 中的 persistence):负责和数据库交互,执行查询、插入和更新操作
这里要特别注意,三层架构首先强调的是职责分层,而不是接口隔离。在这个阶段,层与层之间通常可以直接调用,不需要一开始就为每一层都设计 Port、Adapter 和复杂的依赖注入。
Go 项目中的典型目录映射
对于一个订单服务,这种分层通常可以组织成如下结构:
order-service/
├── cmd/
│ ├── order-server/ # 同步服务入口:HTTP / RPC
│ │ └── main.go
│ ├── order-job/ # 定时任务入口
│ │ └── main.go
│ └── order-consumer/ # 消息消费入口
│ └── main.go
├── internal/
│ ├── interfaces/ # 表现层:接收外部请求
│ │ ├── http/
│ │ │ └── order.go
│ │ ├── rpc/
│ │ │ └── order.go
│ │ ├── job/
│ │ │ ├── close_timeout_order.go
│ │ │ └── retry_publish.go
│ │ └── event/
│ │ ├── payment_paid.go
│ │ ├── stock_reserved.go
│ │ └── stock_failed.go
│ ├── application/ # 业务逻辑层:创单、支付、取消、关单
│ │ ├── service/
│ │ │ ├── order.go
│ │ │ ├── job.go
│ │ │ ├── consumer.go
│ │ │ └── event.go
│ │ └── dto/
│ │ ├── request.go
│ │ └── response.go
│ ├── model/ # 早期共享模型,承载数据结构
│ │ ├── order.go
│ │ ├── request.go
│ │ └── event.go
│ ├── infrastructure/ # 基础设施:MySQL、日志、事件总线
│ │ ├── persistence/
│ │ │ ├── order.go
│ │ │ └── transaction.go
│ │ ├── event/
│ │ │ └── event_bus.go
│ │ ├── logger/
│ │ │ └── logger.go
│ │ └── mysql/
│ │ └── mysql_db.go
│ └── bootstrap/
│ └── app.go # 程序启动与依赖组装
└── go.mod
这里虽然目录名已经统一成了 interfaces / application / infrastructure 这套风格,但它的本质仍然是三层架构。interfaces 对应表现层,application 对应业务逻辑层,infrastructure/persistence 对应数据访问层。额外出现的 model 只是为了集中承载共享数据结构,例如 Order、CreateOrderRequest,它还不是后续 DDD 阶段那种真正承担业务规则的 domain。
一个订单服务的调用链
以“创建订单”接口为例,这条调用链通常是这样的:
HTTP / RPC 请求
-> interfaces.CreateOrder
-> application.CreateOrder
-> infrastructure.persistence.Save
-> MySQL
在这条链路中,每一层都只做自己该做的事情。
**表现层(Interfaces)**负责与外部世界打交道。它知道请求从哪里来,也知道响应该怎么返回,但它不应该承担核心业务判断。一个典型的创单 Handler 通常会做下面几件事:
- 接收 HTTP 或 RPC 请求
- 解析
customer_id、商品列表等参数 - 做最基础的参数合法性校验
- 调用
OrderService.CreateOrder - 把结果组装成 API 响应返回
也就是说,表现层回答的是“外部如何调用系统”,而不是“系统内部如何完成下单”。
**业务逻辑层(Application)**是三层架构的中心。它负责把“创建订单”这个业务动作真正组织起来,例如校验商品列表是否为空、计算订单总价、生成订单号、设置订单初始状态、调用 Repository 持久化订单,以及在需要时发布订单创建事件。
示例代码可以写成这样:
// application/service/order.go — 标准三层架构中的业务逻辑层
type OrderService struct {
repo *persistence.OrderRepository // 直接依赖具体实现
db *sql.DB
}
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) (*Order, error) {
order := &Order{
CustomerID: req.CustomerID,
Items: req.Items,
}
order.Total = s.calculateTotal(order.Items)
return s.repo.Save(ctx, order)
}
这段代码很好地体现了三层架构在早期的特点:简单、直接、上手快。Application Service 直接依赖持久化实现,不需要先设计接口、适配器、领域对象等抽象。对于一个业务规则还不复杂的系统来说,这种直接性反而能提高开发效率。
**数据访问层(Infrastructure/Persistence)**的职责是把业务层提出的“保存订单”“查询订单”这些需求,翻译成具体的数据库操作。例如,OrderRepository 里可能包含下面这些方法:
Save(order):插入或更新订单FindByID(orderID):根据订单 ID 查询订单ListPendingPaymentBefore(t):查询超时未支付订单
它的价值在于:把 SQL、表结构、连接池、事务细节集中到一个位置。这样,业务层不需要到处散落 SQL 语句,表现层也不必知道数据库长什么样。
为什么这种结构在项目初期很好用
在标准三层架构中,层与层之间通常不需要一开始就为每一层专门定义接口和适配器。更常见的做法是:
interfaces直接调用applicationapplication直接调用infrastructure/persistence- 各层之间通过普通的函数签名和结构体协作
也就是说,三层架构首先解决的是“代码应该放在哪一层”,而不是“核心业务到底应该依赖谁”。这也是它和后续 Clean Architecture 的关键区别。
这种结构在项目初期很好用,通常有三个原因。
第一,它容易理解。即使团队成员没有 DDD 或 Clean Architecture 背景,也能快速知道一个需求应该落在哪一层。
第二,它开发成本低。不需要在一开始就设计大量接口和抽象,代码路径短,调试方便,适合快速迭代。
第三,它足够支撑早期业务。对于订单创建、订单查询、支付回调、超时关单这类相对直接的业务流程,三层架构通常已经可以胜任。
换句话说,三层架构不是“过时的做法”,而是在业务复杂度还低时,一种成本最低的秩序化手段。
三层架构的边界与典型问题
三层架构的问题,通常不是一开始就出现,而是在业务逐渐变复杂时慢慢显现。最常见的几个信号是:
Application层越来越大,开始同时处理业务规则、事务、SQL 细节和外部依赖Infrastructure/Persistence和数据库实现耦合过深,换存储成本高Model逐渐退化成纯数据结构,真正的业务规则散落在多个 Application Service 方法里- 单元测试越来越困难,因为业务逻辑强依赖数据库和基础设施
- 同一个“订单”概念,在不同模块里开始出现不一致的含义
例如,当你想把 MySQL 更换为 PostgreSQL 时,可能会发现 Application 层虽然没有直接操作数据库连接,但它已经对持久化实现、事务组织方式,甚至某些 MySQL 特有能力形成了隐式依赖。此时,问题就不再只是“换个数据库驱动”这么简单,而是说明:业务逻辑与基础设施实现已经纠缠在一起了。
这正是三层架构的边界所在。它非常适合作为系统的起点,但当复杂度继续增长时,仅靠“Interfaces / Application / Persistence”这条主线,已经不足以持续控制系统复杂性。接下来,就需要进一步引入更明确的依赖边界、更强的业务模型表达,以及更清晰的读写分离策略。这也是后续要引入 Clean Architecture、DDD 和 CQRS 的原因。
Clean Architecture(整洁架构)
核心思想:依赖规则
Clean Architecture 由 Robert C. Martin(Uncle Bob)在 2012 年提出,其核心思想非常简单:
业务逻辑应该独立于 UI、数据库、框架或任何外部代理。
这句话的含义是:当你决定从 MySQL 换到 PostgreSQL,或者把 Web 框架从 Gin 换到 Echo 时,核心的业务逻辑(Use Cases 和 Entities)不需要改动一行代码。
依赖规则:源代码的依赖方向只能向内。外层(如数据库、Web 框架)可以依赖内层,但内层绝不能知道外层的存在。
┌──────────────────────────────────────────────────────────────┐
│ Frameworks & Drivers (Web, DB, External APIs) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Interface Adapters (Controllers, Gateways, Repos) │ │
│ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ Application Business Rules (Use Cases) │ │ │
│ │ │ ┌──────────────────────────────────────┐ │ │ │
│ │ │ │ Enterprise Business Rules (Entities) │ │ │ │
│ │ │ └──────────────────────────────────────┘ │ │ │
│ │ └──────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
依赖方向 ──────→ 向内
四层模型
Clean Architecture 将系统划分为四层,每层有清晰的职责:
| 层级 | 职责 | 示例 |
|---|---|---|
| Entity(实体) | 最核心的业务规则,与应用无关 | Order, Product 的领域模型 |
| Use Cases(用例) | 特定于应用的业务逻辑 | “处理订单”、“计算运费” |
| Interface Adapters(接口适配器) | 数据格式转换,连接内外层 | Controller, Presenter, Repository 接口实现 |
| Frameworks & Drivers(框架和驱动) | 具体技术实现 | MySQL, Redis, Gin, gRPC |
关键理解:
- Entity 层是纯业务逻辑,不知道 HTTP、数据库、消息队列的存在
- Use Case 层编排业务流程,依赖 Entity 层的接口,不依赖具体实现
- Adapter 层连接内外,实现 Entity/Use Case 层定义的接口
- Framework 层是具体的技术选型,可以随时替换
Go 项目中的典型目录映射
在 Go 项目中,Clean Architecture 通常可以映射成和前面三层架构相近、但依赖方向更清晰的目录结构。例如,一个 order-service 可以演进成这样:
order-service/
├── cmd/
│ ├── order-server/
│ │ └── main.go # HTTP / RPC 服务入口
│ ├── order-job/
│ │ └── main.go # 定时任务入口
│ └── order-consumer/
│ └── main.go # 消息消费入口
├── internal/
│ ├── interfaces/ # 接口层:所有进入系统的请求入口
│ │ ├── http/
│ │ │ ├── order.go
│ │ │ └── health.go
│ │ ├── rpc/
│ │ │ └── order.go
│ │ ├── event/
│ │ │ ├── payment_paid.go
│ │ │ ├── stock_reserved.go
│ │ │ └── stock_failed.go
│ │ └── job/
│ │ ├── close_timeout_order.go
│ │ └── retry_publish.go
│ ├── application/ # 应用层:编排业务用例
│ │ ├── service/
│ │ │ ├── create_order.go
│ │ │ ├── cancel_order.go
│ │ │ ├── mark_order_paid.go
│ │ │ └── close_timeout_order.go
│ │ ├── dto/
│ │ │ ├── request.go
│ │ │ ├── response.go
│ │ │ └── event.go
│ │ └── transaction.go # 应用层依赖的事务抽象(可选)
│ ├── domain/ # 最内层:业务概念与规则
│ │ ├── order.go # Order 实体 / 核心业务规则
│ │ ├── order_item.go # OrderItem 实体 / 值对象
│ │ ├── repository.go # Repository Port
│ │ ├── event_publisher.go # EventPublisher Port
│ │ ├── event.go # 领域事件定义
│ │ └── errors.go # 领域错误
│ ├── infrastructure/ # 基础设施层:技术实现
│ │ ├── persistence/
│ │ │ ├── order_repo.go # Repository Port 的 MySQL 实现
│ │ │ ├── order_po.go # 持久化对象 / 表映射
│ │ │ └── transaction.go # 事务实现
│ │ ├── event/
│ │ │ └── publisher.go # EventPublisher Port 的实现
│ │ ├── cache/
│ │ │ └── order_cache.go
│ │ ├── mysql/
│ │ │ └── db.go
│ │ ├── mq/
│ │ │ └── producer.go
│ │ ├── logger/
│ │ │ └── logger.go
│ │ └── config/
│ │ └── config.go
│ └── bootstrap/
│ ├── app.go # 依赖注入
│ ├── router.go # HTTP / RPC 路由注册
│ └── wiring.go # application / interfaces / infrastructure 组装
├── api/
│ ├── http/
│ │ └── order.md
│ └── proto/
│ └── order.proto
└── go.mod
关键设计原则:
- 依赖方向向内:
interfaces依赖application,application依赖domain,infrastructure负责从外层实现内层需要的能力 - 接口在内层定义:例如
internal/domain/repository.go定义 Port,internal/infrastructure/persistence/order_repo.go提供实现 - 框架在外层:Gin、MySQL、Kafka、Redis 等技术细节都停留在
interfaces或infrastructure,不会侵入核心业务
三层架构与 Clean Architecture 的区别
这一点非常容易被误解。很多团队以为自己已经做了三层分层,就等于已经做了 Clean Architecture。其实两者并不等价:
- 三层架构关注的是分层职责:表现层、业务层、数据访问层分别负责什么
- Clean Architecture 关注的是依赖方向:核心业务是否只依赖抽象,而不是依赖具体实现
所以,三层架构回答的是“代码应该放在哪一层”,而 Clean Architecture 回答的是“核心业务应该依赖谁”。
这也是为什么一个项目可以“已经分层”,但仍然没有真正获得可替换性和可测试性。只要 application/service 仍然依赖某个具体的 MySQL repository、具体的消息发布器、具体的框架对象,业务逻辑就还没有真正从基础设施中解耦出来。
改造要点:引入 Port,把实现推到外层
阶段 1 的关键动作通常有两个:
- 在内层定义接口,例如
OrderRepository - 让外层提供具体实现,例如
MySQLOrderRepository
代码通常会变成这样:
// domain/repository.go — 内层定义接口
package domain
type OrderRepository interface {
Save(ctx context.Context, order *Order) error
}
// application/service/create_order.go — 应用层依赖接口而非实现
type CreateOrderService struct {
repo domain.OrderRepository
}
func (svc *CreateOrderService) Execute(ctx context.Context, req CreateOrderRequest) error {
order := domain.NewOrder(req.CustomerID)
// ... 应用逻辑 ...
return svc.repo.Save(ctx, order)
}
在这段代码里,CreateOrderService 并不知道底层是不是 MySQL,也不知道外面是不是 HTTP Handler 调用它。它只关心“创建订单”这件业务本身。这种内层稳定、外层可替换的结构,就是 Clean Architecture 的核心价值。
以一个订单服务的调用链为例
如果把 Clean Architecture 放到一个具体的 order-service 中,它的调用链通常会比三层架构多出一层清晰的“依赖反转”:
HTTP 请求
-> adapter/inbound/http.OrderHandler
-> usecase.PlaceOrderUseCase
-> domain.Order / domain.Repository
-> adapter/outbound/persistence.MySQLOrderRepo
-> MySQL
这条链路的关键,不只是“多了几层目录”,而是每一层的依赖关系开始变得可控:
- HTTP Handler 只负责接收请求、解析参数、返回响应
- Use Case 只负责组织业务流程,不直接操作数据库
- Domain 负责承载核心业务规则和抽象接口
- Outbound Adapter 负责把领域层定义的接口落到 MySQL、Redis 或 MQ 上
从外面看,它和三层架构一样,仍然是在处理“下单请求”;但从里面看,业务逻辑和基础设施已经不再直接耦合。也正因为如此,后面不管你是替换存储、补单元测试,还是进一步引入 DDD,都有了更清晰的落脚点。
核心价值:技术无关的业务逻辑
让我们通过一个具体的代码示例来理解 Clean Architecture 的价值。
反例:依赖具体实现
// ❌ 反例:OrderService 直接依赖具体的 MySQL 实现
package service
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
type OrderService struct {
db *sql.DB // 直接依赖 MySQL
}
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderReq) (*Order, error) {
// 业务逻辑和数据库操作混在一起
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
order := &Order{
CustomerID: req.CustomerID,
Items: req.Items,
Total: calculateTotal(req.Items),
}
// SQL 语句直接写在业务逻辑中
_, err = tx.ExecContext(ctx,
"INSERT INTO orders (customer_id, total, status) VALUES (?, ?, ?)",
order.CustomerID, order.Total, "pending")
if err != nil {
return nil, err
}
return order, tx.Commit()
}
问题:
- 业务逻辑和数据库操作混在一起,难以测试
- 换数据库(如 PostgreSQL)需要改动业务逻辑代码
- 无法编写不依赖数据库的单元测试
OrderService直接依赖database/sql和 MySQL 驱动
正例:依赖抽象
// ✅ 正例:Clean Architecture 方式
// domain/order/repository.go — 内层只定义接口
package order
type Repository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id string) (*Order, error)
}
// domain/order/order.go — 领域模型
package order
type Order struct {
id string
customerID string
items []OrderItem
status Status
totalPrice Money
}
func NewOrder(customerID string) *Order {
return &Order{
id: generateID(),
customerID: customerID,
items: make([]OrderItem, 0),
status: StatusDraft,
}
}
func (o *Order) AddItem(product Product, qty int) error {
if o.status != StatusDraft {
return ErrOrderNotEditable
}
if qty <= 0 {
return ErrInvalidQuantity
}
item := NewOrderItem(product, qty)
o.items = append(o.items, item)
o.recalculateTotal()
return nil
}
func (o *Order) Place() error {
if len(o.items) == 0 {
return ErrEmptyOrder
}
o.status = StatusPlaced
return nil
}
// usecase/place_order.go — Use Case 依赖接口而非实现
package usecase
import "myapp/domain/order"
type PlaceOrderUseCase struct {
orderRepo order.Repository // 依赖抽象接口
}
func (uc *PlaceOrderUseCase) Execute(ctx context.Context, req PlaceOrderRequest) (*PlaceOrderResponse, error) {
// 创建订单聚合
o := order.NewOrder(req.CustomerID)
// 添加商品
for _, item := range req.Items {
product := order.Product{ID: item.ProductID, Price: item.Price}
if err := o.AddItem(product, item.Quantity); err != nil {
return nil, err
}
}
// 下单
if err := o.Place(); err != nil {
return nil, err
}
// 持久化(通过接口)
if err := uc.orderRepo.Save(ctx, o); err != nil {
return nil, err
}
return &PlaceOrderResponse{OrderID: o.ID()}, nil
}
// adapter/persistence/mysql_order_repo.go — 外层实现接口
package persistence
import (
"database/sql"
"myapp/domain/order"
)
type MySQLOrderRepo struct {
db *sql.DB
}
func NewMySQLOrderRepo(db *sql.DB) order.Repository {
return &MySQLOrderRepo{db: db}
}
func (r *MySQLOrderRepo) Save(ctx context.Context, o *order.Order) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO orders (id, customer_id, total, status) VALUES (?, ?, ?, ?)",
o.ID(), o.CustomerID(), o.Total().Amount, o.Status().String())
return err
}
func (r *MySQLOrderRepo) FindByID(ctx context.Context, id string) (*order.Order, error) {
// 实现查询逻辑
// ...
return nil, nil
}
// adapter/persistence/mongo_order_repo.go — 换存储只需新增实现
package persistence
import (
"go.mongodb.org/mongo-driver/mongo"
"myapp/domain/order"
)
type MongoOrderRepo struct {
collection *mongo.Collection
}
func NewMongoOrderRepo(col *mongo.Collection) order.Repository {
return &MongoOrderRepo{collection: col}
}
func (r *MongoOrderRepo) Save(ctx context.Context, o *order.Order) error {
_, err := r.collection.InsertOne(ctx, bson.M{
"_id": o.ID(),
"customer_id": o.CustomerID(),
"total": o.Total().Amount,
"status": o.Status().String(),
})
return err
}
对比收益:
| 维度 | 反例(依赖具体实现) | 正例(依赖抽象) |
|---|---|---|
| 测试 | 必须启动 MySQL 才能测试 | 用 Mock 实现接口即可测试 |
| 换存储 | 改动业务逻辑代码 | 只需新增一个 Adapter |
| 理解成本 | 业务逻辑和技术细节混在一起 | 业务逻辑清晰独立 |
| 并行开发 | 数据库schema确定后才能开发 | 定义好接口就可以并行开发 |
依赖注入的 Go 实现
在 Clean Architecture 中,组装(将接口与实现绑定)发生在最外层——通常是 main.go 或 cmd/server/main.go。
方式一:手动注入(推荐,适合中小项目)
// cmd/server/main.go
package main
import (
"database/sql"
"log"
"myapp/adapter/inbound/http"
"myapp/adapter/outbound/persistence"
"myapp/infra/mysql"
"myapp/usecase"
_ "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin"
)
func main() {
// 1. Infrastructure 层:初始化基础设施
db, err := sql.Open("mysql", "user:pass@tcp(localhost:3306)/mydb")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 2. Adapter 层:创建实现(实现 domain 接口)
orderRepo := persistence.NewMySQLOrderRepo(db)
productRepo := persistence.NewMySQLProductRepo(db)
// 3. Use Case 层:注入依赖
placeOrderUC := usecase.NewPlaceOrderUseCase(orderRepo, productRepo)
cancelOrderUC := usecase.NewCancelOrderUseCase(orderRepo)
// 4. Adapter 层(Inbound):创建 HTTP Handler
orderHandler := http.NewOrderHandler(placeOrderUC, cancelOrderUC)
// 5. Framework 层:启动 Web 服务器
router := gin.Default()
orderHandler.RegisterRoutes(router)
router.Run(":8080")
}
优点:
- 零依赖,不需要引入任何 DI 框架
- 编译时检查,类型安全
- 调试直观,依赖关系一目了然
缺点:
- 当依赖超过 20 个时,
main.go变得冗长 - 手动管理依赖顺序,容易出错
方式二:Wire(适合大型项目)
Google 的 Wire 通过代码生成实现依赖注入:
// cmd/server/wire.go
//go:build wireinject
package main
import (
"myapp/adapter/inbound/http"
"myapp/adapter/outbound/persistence"
"myapp/infra/mysql"
"myapp/usecase"
"github.com/google/wire"
)
func InitializeOrderHandler() (*http.OrderHandler, error) {
wire.Build(
// Infrastructure
mysql.NewConnection,
// Adapters (Outbound)
persistence.NewMySQLOrderRepo,
persistence.NewMySQLProductRepo,
// Use Cases
usecase.NewPlaceOrderUseCase,
usecase.NewCancelOrderUseCase,
// Adapters (Inbound)
http.NewOrderHandler,
)
return nil, nil
}
运行 wire ./cmd/server 后,Wire 会自动生成 wire_gen.go:
// Code generated by Wire. DO NOT EDIT.
func InitializeOrderHandler() (*http.OrderHandler, error) {
db, err := mysql.NewConnection()
if err != nil {
return nil, err
}
orderRepo := persistence.NewMySQLOrderRepo(db)
productRepo := persistence.NewMySQLProductRepo(db)
placeOrderUC := usecase.NewPlaceOrderUseCase(orderRepo, productRepo)
cancelOrderUC := usecase.NewCancelOrderUseCase(orderRepo)
handler := http.NewOrderHandler(placeOrderUC, cancelOrderUC)
return handler, nil
}
优点:
- 自动处理依赖顺序
- 编译时检查,类型安全
- 适合大型项目(100+ 依赖)
缺点:
- 需要学习 Wire 的 API
- 代码生成可能影响调试体验
方式三:Uber Fx(运行时注入)
// cmd/server/main.go
package main
import (
"go.uber.org/fx"
"myapp/adapter/inbound/http"
"myapp/adapter/outbound/persistence"
"myapp/infra/mysql"
"myapp/usecase"
)
func main() {
fx.New(
// Infrastructure
fx.Provide(mysql.NewConnection),
// Adapters
fx.Provide(persistence.NewMySQLOrderRepo),
fx.Provide(persistence.NewMySQLProductRepo),
// Use Cases
fx.Provide(usecase.NewPlaceOrderUseCase),
fx.Provide(usecase.NewCancelOrderUseCase),
// HTTP Handler
fx.Provide(http.NewOrderHandler),
// Start server
fx.Invoke(func(h *http.OrderHandler) {
router := gin.Default()
h.RegisterRoutes(router)
router.Run(":8080")
}),
).Run()
}
优点:
- 支持生命周期管理(启动/关闭钩子)
- 支持依赖图可视化
- 适合微服务框架
缺点:
- 运行时注入,类型错误要到运行时才能发现
- 学习曲线较陡
推荐选择:
- 小型项目(<50 个依赖):手动注入
- 中型项目(50-200 个依赖):Wire
- 大型项目(200+ 个依赖,微服务):Fx
为什么这一步通常早于 DDD
很多读者会问:既然后面还要引入 DDD,为什么不一步到位?原因是 Clean Architecture 先解决的是一个更基础的问题:先把业务逻辑和技术实现拆开。
在这一步之前,系统最大的问题往往不是“领域模型不够优雅”,而是“业务代码和基础设施绑得太紧”。如果这个问题不先处理,后面即使想引入聚合根、值对象、领域事件,也很容易被外层技术细节拖住,导致模型表达和工程实现互相污染。
所以,从演进顺序上看,先引入 Clean Architecture,往往比直接上 DDD 更稳妥。它为后续的领域建模先清出了一块相对干净的内层空间。
收益:可替换、可测试、可演进
引入 Clean Architecture 之后,最直接的收益通常有三个。
第一,更容易替换基础设施。MySQL、PostgreSQL、Redis、消息中间件都变成外层实现细节,而不是业务层的一部分。
第二,更容易做单元测试。CreateOrderService 可以直接注入 Mock Repository 进行测试,不需要启动数据库。
第三,为后续演进留出空间。当业务复杂度进一步上升时,你可以在内层继续引入更强的领域模型表达,而不必同时和框架、数据库实现纠缠。
当然,代价也很明显:目录更多、抽象更多、依赖注入也更复杂。对于业务非常简单的项目,这一步可能会显得“有点重”。但一旦系统已经出现“换数据库很痛”“单元测试写不动”“业务层越来越黏住基础设施”这些信号,引入 Clean Architecture 往往是值得的。
架构风格对比:Clean vs 六边形 vs 洋葱
在学习 Clean Architecture 时,你可能还会遇到另外两个相似的概念:六边形架构(Hexagonal Architecture) 和 洋葱架构(Onion Architecture)。它们经常被混用,但实际上有细微差别:
| 维度 | Clean Architecture | 六边形架构 (Hexagonal) | 洋葱架构 (Onion) |
|---|---|---|---|
| 提出者 | Robert C. Martin (2012) | Alistair Cockburn (2005) | Jeffrey Palermo (2008) |
| 核心隐喻 | 同心圆,层层向内 | 六边形,端口与适配器 | 洋葱,层层剥开 |
| 关键概念 | Entity, Use Case, Adapter | Port(接口), Adapter(实现) | Domain Model, Domain Service, App Service |
| 外部交互方式 | 通过 Interface Adapter 层 | 通过 Port + Adapter 对 | 通过 Infrastructure 层 |
| 核心共识 | 依赖方向向内,业务逻辑不依赖外部技术 | 同左 | 同左 |
graph TB
subgraph "Clean Architecture"
direction TB
CA_E[Entity]
CA_U[Use Case] --> CA_E
CA_A[Adapter] --> CA_U
CA_F[Framework] --> CA_A
end
subgraph "Hexagonal"
direction TB
H_D[Domain Core]
H_PI[Inbound Port] --> H_D
H_PO[Outbound Port] --> H_D
H_AI[Driving Adapter] --> H_PI
H_AO[Driven Adapter] --> H_PO
end
subgraph "Onion"
direction TB
O_DM[Domain Model]
O_DS[Domain Service] --> O_DM
O_AS[App Service] --> O_DS
O_IF[Infrastructure] --> O_AS
end
实际差异很小,三者在 Go 项目中的落地几乎一样——关键是守住一条线:内层定义接口,外层实现接口。
Port & Adapter 模式的 Go 实现
六边形架构中,**Port(端口)**是接口,**Adapter(适配器)**是实现。在 Go 中天然契合:
// domain/port.go — Outbound Port(领域层定义接口)
package domain
type PaymentGateway interface {
Charge(ctx context.Context, orderID string, amount Money) (*PaymentResult, error)
}
// adapter/payment/stripe_adapter.go — Driven Adapter(基础设施层实现接口)
package payment
import (
"myapp/domain"
"github.com/stripe/stripe-go/v72"
)
type StripeAdapter struct {
client *stripe.Client
}
func NewStripeAdapter(apiKey string) domain.PaymentGateway {
return &StripeAdapter{
client: stripe.NewClient(apiKey),
}
}
func (a *StripeAdapter) Charge(ctx context.Context, orderID string, amount domain.Money) (*domain.PaymentResult, error) {
params := &stripe.ChargeParams{
Amount: stripe.Int64(amount.Amount),
Currency: stripe.String(amount.Currency),
}
resp, err := a.client.Charges.New(params)
if err != nil {
return nil, fmt.Errorf("stripe charge failed: %w", err)
}
return &domain.PaymentResult{
TransactionID: resp.ID,
Status: "success",
}, nil
}
// adapter/payment/mock_adapter.go — 测试时可替换为 Mock
package payment
type MockPaymentAdapter struct {
ShouldFail bool
}
func NewMockPaymentAdapter() domain.PaymentGateway {
return &MockPaymentAdapter{ShouldFail: false}
}
func (a *MockPaymentAdapter) Charge(ctx context.Context, orderID string, amount domain.Money) (*domain.PaymentResult, error) {
if a.ShouldFail {
return nil, errors.New("mock payment failure")
}
return &domain.PaymentResult{
TransactionID: "mock-txn-001",
Status: "success",
}, nil
}
关键理解:
- Port(接口)在领域层定义,表达“我需要什么能力“
- Adapter(实现)在基础设施层提供,表达“我如何提供这个能力“
- 测试时,可以用 Mock Adapter 替换真实的 Stripe Adapter
- 换支付渠道(如从 Stripe 换到支付宝),只需新增一个 Adapter
反模式:常见违规案例
在实际项目中,Clean Architecture 的违规往往不是故意的,而是在时间压力下“顺手“写下的。以下是三个最常见的反模式:
Anti-pattern 1:跨层调用
// ❌ 反例:Handler 直接引用了 MySQL 包(跳过了 domain 和 usecase 层)
package handler
import (
"database/sql"
"net/http"
)
func GetOrder(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
row := db.QueryRow("SELECT * FROM orders WHERE id = ?", r.URL.Query().Get("id"))
// 直接在 handler 里写 SQL...
}
}
问题:
- Handler 直接依赖数据库,无法测试
- 业务逻辑散落在各个 Handler 中,无法复用
- 换数据库需要改动所有 Handler
// ✅ 正例:Handler 只依赖 Use Case 接口
package handler
type OrderQuerier interface {
GetOrderDetail(ctx context.Context, id string) (*OrderDetailDTO, error)
}
type OrderHandler struct {
querier OrderQuerier
}
func NewOrderHandler(q OrderQuerier) *OrderHandler {
return &OrderHandler{querier: q}
}
func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) {
dto, err := h.querier.GetOrderDetail(r.Context(), r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(dto)
}
Anti-pattern 2:基础设施泄漏到领域层
// ❌ 反例:领域实体中使用了 sql.NullString(基础设施类型侵入领域)
package domain
import "database/sql"
type Order struct {
ID string
Remark sql.NullString // ← 领域层不应该知道 SQL 的存在
Status int // ← 用魔数表示状态
}
问题:
sql.NullString是database/sql包的类型,领域层不应该依赖基础设施包- 领域模型变得“贫血“,只是数据容器,没有行为
// ✅ 正例:领域层使用纯 Go 类型,转换在 adapter 层完成
package domain
type Order struct {
id OrderID // 强类型 ID
remark string // 空字符串表示无备注
status OrderStatus // 枚举类型,不是魔数
items []OrderItem
}
func (o *Order) UpdateRemark(remark string) error {
if len(remark) > 500 {
return ErrRemarkTooLong
}
o.remark = remark
return nil
}
// adapter/persistence/converter.go — 在 Adapter 层做类型转换
func toDomain(po *OrderPO) *domain.Order {
remark := ""
if po.Remark.Valid {
remark = po.Remark.String
}
status := domain.StatusFromInt(po.Status)
return domain.ReconstructOrder(
domain.OrderID(po.ID),
remark,
status,
toItemList(po.Items),
)
}
func toPO(o *domain.Order) *OrderPO {
return &OrderPO{
ID: string(o.ID()),
Remark: sql.NullString{String: o.Remark(), Valid: o.Remark() != ""},
Status: o.Status().ToInt(),
}
}
Anti-pattern 3:循环依赖
❌ domain/order.go imports adapter/notification
adapter/notification imports domain/order
→ 编译失败:import cycle
问题:
- Go 不允许循环依赖,编译直接报错
- 即使在允许循环依赖的语言(如 C#),也会导致模块耦合
解法:在 domain 层定义 Notifier 接口,adapter 层实现它。方向始终向内。
// domain/notifier.go — 领域层定义接口
package domain
type Notifier interface {
NotifyOrderPlaced(ctx context.Context, order *Order) error
}
// usecase/place_order.go — Use Case 依赖接口
type PlaceOrderUseCase struct {
orderRepo order.Repository
notifier domain.Notifier // 依赖抽象
}
func (uc *PlaceOrderUseCase) Execute(ctx context.Context, req PlaceOrderRequest) error {
// ... 创建订单 ...
if err := uc.orderRepo.Save(ctx, o); err != nil {
return err
}
// 通过接口调用通知
return uc.notifier.NotifyOrderPlaced(ctx, o)
}
// adapter/notification/sms_notifier.go — Adapter 层实现接口
package notification
type SMSNotifier struct {
smsClient *SMSClient
}
func (n *SMSNotifier) NotifyOrderPlaced(ctx context.Context, order *domain.Order) error {
message := fmt.Sprintf("您的订单 %s 已创建", order.ID())
return n.smsClient.Send(ctx, order.CustomerPhone(), message)
}
依赖方向:
domain (定义 Notifier 接口)
↑
usecase (依赖 Notifier 接口)
↑
adapter/notification (实现 Notifier 接口)
DDD(领域驱动设计)
战略设计:架构层面
DDD 不是一种架构,而是一套方法论。它认为软件的灵魂在于其解决的业务问题(即“领域“)。
DDD 分为两个层面:
- 战略设计:架构层面,关注如何划分领域、如何确定投资策略、如何划分上下文边界
- 战术设计:代码层面,关注如何用聚合、实体、值对象等战术模式编写高质量的领域模型
我们先讲战略设计。
领域分层与投资策略
为什么需要领域分层?
一个中大型系统往往包含十几个甚至几十个子系统。假设你是一家电商平台的 CTO,面对以下子系统:
- 订单系统、支付系统、商品管理、库存管理
- 用户系统、搜索系统、推荐系统、评价系统
- 消息通知、物流跟踪、风控系统、数据报表
核心问题:资源有限(人力、预算、时间),不可能对所有子系统投入同等精力。如何决定:
- 哪些系统必须自研,投入最好的团队?
- 哪些系统可以定制开发,用常规团队?
- 哪些系统直接买现成方案或用开源?
如果投资决策错误:
- ❌ 把资源浪费在通用能力上(如自研消息队列),错失核心业务创新
- ❌ 在核心竞争力上妥协(如用低质量的订单系统),导致业务受限
DDD 的答案:按照业务价值对领域分层,实施差异化投资策略。这就是核心域(Core Domain)、支撑域(Supporting Domain)、通用域(Generic Domain)的由来。
三种领域的定义与特征
| 域类型 | 定义 | 业务价值 | 竞争差异化 | 投资策略 | 组织形式 | 技术选型 |
|---|---|---|---|---|---|---|
| 核心域 Core Domain | 平台的核心竞争力,创造差异化价值 | 最高,决定平台成败 | 高度差异化,竞品难模仿 | 重点投入,自研 | 最优秀团队,独立编制 | 自主可控,完全掌握 |
| 支撑域 Supporting Domain | 支撑核心业务的必要能力 | 中等,必须有但不差异化 | 有一定特色但可被超越 | 适度投入,可定制 | 常规团队,共享资源 | 定制开发,参考业界 |
| 通用域 Generic Domain | 通用基础能力,行业共性 | 低,无差异化 | 行业标准,无竞争优势 | 最小投入,采购 | 外包/工具团队 | 开源/SaaS/采购 |
核心域(Core Domain):
- 什么是“核心竞争力“? 直接影响营收、用户体验、留存率的能力,是公司在市场中胜出的关键
- 特点:频繁变化(紧跟业务创新)、技术复杂、需要领域专家
- 识别标志:如果这个域做不好,公司会输;如果做得特别好,会赢
- 案例:电商的订单系统、金融的交易系统、SaaS 的租户管理
支撑域(Supporting Domain):
- 为什么“必须有但不差异化“? 业务依赖但不产生竞争优势,做到 80 分和 95 分对业务影响不大
- 特点:相对稳定、有一定复杂度、需要理解业务
- 识别标志:缺了不行,但不是赢的关键
- 案例:电商的商品管理、金融的账户系统、SaaS 的权限系统
通用域(Generic Domain):
- 为什么可以采购? 行业已有成熟方案,无需重复造轮子,自研的投入产出比很低
- 特点:标准化、变化少、技术成熟
- 识别标志:市面上有多个成熟产品可选
- 风险:过度依赖外部服务,但可通过多供应商策略缓解
- 案例:用户认证(Auth0/Keycloak)、消息推送(Twilio)、存储(AWS S3)
领域划分方法论
如何判断一个子域属于哪一类? 下面提供一套可操作的评分框架。
判断维度与评分模型
| 判断维度 | 核心域(8-10分) | 支撑域(4-7分) | 通用域(1-3分) | 评分问题 |
|---|---|---|---|---|
| 业务价值 | 直接影响收入/利润/核心指标 | 间接影响业务,必需但不关键 | 不影响业务差异化 | 这个域对营收/留存的影响有多大? |
| 竞争差异化 | 独特能力,竞品难以模仿 | 有特色但可被超越 | 行业标准,无差异 | 竞品能轻易复制这个能力吗? |
| 变化频率 | 频繁变化,紧跟业务创新 | 定期调整优化 | 稳定,很少大改 | 多久需要大改一次? |
| 技术复杂度 | 高度复杂,需要领域专家 | 中等复杂,需要业务理解 | 成熟方案可解决 | 普通团队能否 hold 住? |
评分方法:
- 每个维度打分 1-10 分
- 总分 = 四个维度分数相加(满分 40 分)
总分判断标准:
- 32-40 分 → 核心域(Core Domain)
- 16-31 分 → 支撑域(Supporting Domain)
- 4-15 分 → 通用域(Generic Domain)
注意事项:
- 边界分数(如 31-32 分)需要结合公司战略、团队能力综合判断
- 初创公司可以适当放宽核心域标准(28 分以上即可),聚焦资源
- 成熟公司标准更严格,避免核心域过多导致资源分散
方法论应用:电商系统实战分析
下面选择电商系统的 3 个典型域,应用评分模型进行深度分析。
案例 1:订单域(核心域)
| 维度 | 评分 | 详细分析 |
|---|---|---|
| 业务价值 | 10 | 订单流程直接影响 GMV(成交总额),每提升 1% 转化率就是百万级营收 |
| 竞争差异化 | 9 | 拼团、秒杀、预售、分期等玩法是核心竞争力,竞品难以完全模仿 |
| 变化频率 | 9 | 每个大促(618、双11)都会调整订单流程,支持新的营销玩法 |
| 技术复杂度 | 9 | 分布式事务(Saga)、状态机、高并发、幂等性、最终一致性 |
| 总分 | 37 | 核心域 |
为什么是核心域?
- 订单流程的流畅度直接影响用户下单转化率
- 支持的营销玩法越丰富,平台竞争力越强
- 每个促销活动都可能需要调整订单逻辑
- 技术上涉及多个复杂的分布式系统问题
投资建议:
- 团队配置:最优秀的架构师 + 3-5 名资深后端开发,独立团队
- 技术选型:自研,完全掌控,不依赖外部服务
- 质量要求:99.99% 可用性,全链路监控,灰度发布
- 迭代策略:快速响应业务需求,2 周一个迭代
- 文档要求:完整的设计文档、接口文档、故障预案
案例 2:商品域(支撑域)
| 维度 | 评分 | 详细分析 |
|---|---|---|
| 业务价值 | 7 | 商品管理是必需的,但 SPU/SKU 模型本身不产生差异化 |
| 竞争差异化 | 5 | 各家电商的商品模型大同小异,主要差异在类目和属性配置 |
| 变化频率 | 6 | 新品类上线时需要调整,但不频繁(季度级别) |
| 技术复杂度 | 6 | 有一定复杂度(EAV 模型、搜索索引),但方案成熟 |
| 总分 | 24 | 支撑域 |
为什么是支撑域?
- 商品管理做到 80 分和 95 分,对用户体验影响不大
- SPU/SKU 模型是行业通用方案,没有太多创新空间
- 但又不能没有(缺了商品管理,电商就玩不转)
投资建议:
- 团队配置:常规开发团队 2-3 人,可以与其他支撑域共享资源
- 技术选型:参考业界成熟方案(如有赞、Shopify 的商品模型),适度定制
- 质量要求:99.9% 可用性,降级策略
- 迭代策略:稳定为主,谨慎迭代,充分测试后再上线
- 文档要求:基础设计文档和接口文档
案例 3:用户域(通用域)
| 维度 | 评分 | 详细分析 |
|---|---|---|
| 业务价值 | 3 | 用户注册登录是基础能力,但不产生差异化(用户不会因为注册流程选择平台) |
| 竞争差异化 | 2 | 注册登录是行业标准(手机号、邮箱、第三方登录),无差异 |
| 变化频率 | 2 | 很少变化,除非监管要求(如实名认证) |
| 技术复杂度 | 3 | SSO、OAuth 2.0 都有成熟方案(Auth0、Keycloak) |
| 总分 | 10 | 通用域 |
为什么是通用域?
- 注册登录不会成为平台的竞争优势
- 市面上有大量成熟的身份认证服务
- 自研的投入产出比很低
投资建议:
- 团队配置:外包或使用 SaaS 服务,内部只需 1 人对接
- 技术选型:采购(Auth0、Keycloak、AWS Cognito)
- 质量要求:依赖服务商 SLA(通常 99.95%+)
- 迭代策略:按需对接新的认证方式(如生物识别),最小投入
- 文档要求:对接文档即可
跨行业对比:方法论的通用性
同样的方法论在不同行业如何应用?下表展示三个典型行业的域划分:
| 行业 | 核心域(差异化竞争力) | 支撑域(业务必需) | 通用域(行业标准) |
|---|---|---|---|
| 电商 | • 订单系统(交易流程) • 支付系统(资金安全) | • 商品管理 • 库存管理 • 计价引擎 • 营销系统 | • 用户认证 • 搜索 • 消息推送 • 物流跟踪 • 风控 |
| 金融 | • 交易系统(买卖撮合) • 风控系统(反欺诈) | • 账户系统 • 清结算 • 合规报送 | • 用户认证 • 消息通知 • 报表系统 • 存储 |
| SaaS | • 租户管理(多租户隔离) • 计费系统(订阅模式) | • 权限系统(RBAC) • 审计日志 • 集成中心(API) | • 用户认证 • 消息 • 存储 • 监控告警 |
关键洞察:
-
核心域因行业而异:
- 电商的核心是「交易流程」和「资金安全」
- 金融的核心是「买卖撮合」和「风控合规」
- SaaS 的核心是「多租户」和「订阅计费」
- → 核心域反映了行业的本质和竞争焦点
-
通用域高度相似:
- 用户、消息、存储在各行业都是通用域
- 这些能力已经高度标准化,有大量成熟方案
- → 通用域是「不需要重新发明轮子」的领域
-
支撑域体现业务特点:
- 电商的商品、库存、计价有一定特色,但不是核心竞争力
- 金融的账户、清结算是必需的,但各家差异不大
- SaaS 的权限、审计是基础能力,但实现相对标准
- → 支撑域是「需要理解业务,但可以参考业界实践」的领域
限界上下文(Bounded Context)
同一个“商品“在不同的上下文中有完全不同的含义:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 商品上下文 │ │ 订单上下文 │ │ 物流上下文 │
│ │ │ │ │ │
│ 商品 = SKU + │ │ 商品 = 商品快照 + │ │ 商品 = 包裹 + │
│ 价格 + 库存 │ │ 购买数量 + 金额 │ │ 重量 + 体积 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
为什么需要限界上下文?
在一个大型系统中,如果所有模块都对“商品“有统一的定义,会导致:
- ❌ 商品模型越来越臃肿(既要支持展示,又要支持下单,还要支持物流)
- ❌ 一个模块的需求变化影响所有其他模块
- ❌ 团队之间沟通成本巨大(每次讨论都要对齐“商品“的定义)
Bounded Context 的解决方案:
- 每个上下文内,“商品“有自己的定义和模型
- 上下文之间通过明确的接口或领域事件通信
- 上下文内部的变化,不会影响其他上下文
电商系统的 Bounded Context 示例:
graph TB
subgraph 商品上下文
P1[Product<br/>SKU ID, Name, Price<br/>Stock, Images]
end
subgraph 订单上下文
O1[OrderItem<br/>Product Snapshot<br/>Quantity, Price at Order Time]
end
subgraph 搜索上下文
S1[SearchDocument<br/>SKU ID, Title, Price<br/>Category, Sales Count]
end
商品上下文 -->|商品变更事件| 订单上下文
商品上下文 -->|索引同步| 搜索上下文
不同上下文之间通过防腐层(Anti-Corruption Layer)或领域事件通信,避免概念混淆。我们会在 5.4.4 详细讲解。
上下文映射(Context Map)
限界上下文划分好之后,它们之间如何协作?Context Map(上下文映射)定义了上下文之间的关系模式。
常见的上下文关系模式
| 模式 | 定义 | 适用场景 | 电商案例 |
|---|---|---|---|
| 共享内核 Shared Kernel | 两个上下文共享部分模型代码 | 紧密协作的两个团队 | 商品上下文和库存上下文共享 SKU 定义 |
| 客户-供应商 Customer-Supplier | 下游依赖上游,上游需要考虑下游需求 | 明确的上下游关系 | 订单上下文依赖商品上下文 |
| 遵奉者 Conformist | 下游完全遵循上游模型,无话语权 | 接入第三方系统 | 接入微信支付API |
| 防腐层 Anti-Corruption Layer | 下游通过翻译层隔离上游变化 | 上游模型不稳定或不可控 | 接入供应商API时的适配层 |
| 开放主机服务 Open Host Service | 上游提供标准化接口供多方使用 | 上游服务多个下游 | 商品中心提供统一的商品查询API |
| 发布语言 Published Language | 定义标准的数据交换格式 | 跨团队/跨公司协作 | 订单事件的JSON Schema定义 |
电商系统的 Context Map 实例
graph LR
A[商品上下文] -->|发布领域事件| B[订单上下文]
B -->|调用防腐层| C[支付上下文]
B -->|发布领域事件| D[物流上下文]
A -->|共享内核| E[库存上下文]
F[供应商系统] -.->|遵奉者模式| B
A -->|开放主机服务| G[搜索上下文]
A -->|开放主机服务| H[营销上下文]
关系说明:
- 商品 → 订单:发布领域事件(
ProductPriceChanged),订单上下文异步消费 - 订单 → 支付:通过防腐层调用支付API,隔离支付系统的变化
- 订单 → 物流:发布领域事件(
OrderShipped),物流上下文异步消费 - 商品 ↔ 库存:共享内核(共享 SKU 的定义)
- 供应商 → 订单:遵奉者模式(完全遵循供应商的履约接口)
- 商品 → 搜索/营销:开放主机服务(提供标准化的商品查询API)
Go 项目中的典型目录映射
当 DDD 落到 Go 项目时,目录结构通常会围绕“限界上下文”和“聚合”展开,而不是围绕数据库表或接口协议展开。以一个订单服务为例,比较常见的组织方式如下:
order-service/
├── cmd/
│ └── server/
│ └── main.go
│
├── domain/
│ ├── order/ # 订单上下文 / 订单聚合
│ │ ├── aggregate.go
│ │ ├── entity.go
│ │ ├── value_object.go
│ │ ├── repository.go
│ │ ├── event.go
│ │ └── service.go
│ ├── inventory/ # 库存上下文
│ │ ├── stock.go
│ │ └── repository.go
│ └── payment/ # 支付上下文
│ ├── payment.go
│ └── gateway.go
│
├── application/
│ ├── service/
│ │ ├── place_order.go
│ │ ├── cancel_order.go
│ │ └── mark_order_paid.go
│ └── dto/
│ ├── request.go
│ └── response.go
│
├── interfaces/
│ ├── http/
│ │ └── order.go
│ ├── rpc/
│ │ └── order.go
│ └── event/
│ └── payment_paid.go
│
└── infrastructure/
├── persistence/
│ ├── order_repo.go
│ ├── inventory_repo.go
│ └── payment_repo.go
├── mq/
└── mysql/
和阶段 1 相比,这里的核心变化不是目录变得更深,而是 domain 不再只是“放接口和几个结构体的地方”,而是开始真正承载业务语义。换句话说,阶段 1 的 domain 更像“业务核心的边界”;阶段 2 的 domain 才开始成为“业务规则真正居住的地方”。
从贫血模型到充血模型
阶段 1 中,很多项目虽然已经把 Order 放进了 domain,但这个 Order 仍然只是一个贫血模型:它主要承担数据承载作用,真正的规则依旧写在 application/service 里。例如:
// 阶段 1 的 "贫血模型"
type Order struct {
ID string
Status int // 用魔数表示状态
Total float64 // 用 float 表示金额
}
这类模型的问题在于:它看起来像“领域对象”,但实际上并没有把业务语义和不变量收进去。订单是否允许下单、是否允许取消、金额是否有效、状态能否迁移,仍然需要调用者在外面自己判断。
而在阶段 2 中,Order 会逐渐演进成一个真正的聚合根:
// 阶段 2 的 "充血模型"
type Order struct {
id OrderID
status OrderStatus // 值对象,枚举约束
total Money // 值对象,精度安全
items []OrderItem
}
func (o *Order) Place() ([]DomainEvent, error) {
if len(o.items) == 0 {
return nil, ErrEmptyOrder // 聚合根保护不变量
}
o.status = OrderStatusPlaced
return []DomainEvent{OrderPlacedEvent{...}}, nil
}
这里的关键不只是把字段从公有变成私有,也不只是把 int 改成 OrderStatus。真正重要的是:业务规则开始内聚到模型本身。订单能否下单、状态如何流转、哪些约束必须被保护,不再由外部调用者“记得去做”,而是由聚合根主动维护。
DDD 在这一阶段具体引入什么
阶段 2 最常引入的几个战术设计元素包括:
- 聚合根(Aggregate Root):例如
Order,它负责维护订单聚合内部的一致性边界 - 实体(Entity):例如
OrderItem,有身份或生命周期,属于某个聚合的一部分 - 值对象(Value Object):例如
OrderID、Money、OrderStatus,用来表达语义并约束非法状态 - 领域事件(Domain Event):例如
OrderPlacedEvent、OrderCancelledEvent,表达“业务事实已经发生” - 领域服务(Domain Service):当某条业务规则不自然属于某个单一聚合时,再用领域服务承载
需要强调的是,DDD 不是要求你“把所有东西都做成值对象和事件”。它真正的目的,是让代码结构尽可能贴近业务语言,而不是贴近数据库表结构。
应用层在这一阶段如何变化
引入 DDD 之后,application/service 并不会消失,但它的职责会变得更清晰:它不再自己堆满业务规则,而是更像一个应用服务,负责调度聚合根、协调事务和调用 Port。
也就是说:
- 在阶段 1 中,
CreateOrderService往往自己承担很多规则判断 - 在阶段 2 中,
PlaceOrderService更像一个 orchestrator,它负责加载聚合、调用order.Place()、保存聚合、发布领域事件
这也是为什么 DDD 不是对 Clean Architecture 的替代,而是对其内层表达能力的增强。
以一个订单服务的调用链为例
如果把 DDD 放到下单场景里,调用链通常会长这样:
HTTP 请求
-> interfaces/http.OrderHandler
-> application/service.PlaceOrderService
-> domain/order.Order 聚合根
-> domain/order.Repository
-> infrastructure/persistence.OrderRepo
-> 发布 OrderPlacedEvent
和三层架构相比,这里最重要的变化不是“步骤变多了”,而是业务规则开始收拢到聚合根内部。
例如:
- Handler 不再自己判断订单状态是否合法
- Application Service 不再堆满状态迁移和金额校验
Order.Place()、Order.Cancel()、Order.MarkPaid()这类方法开始成为真正的业务入口
这意味着,当我们讨论“订单能不能取消”“订单支付后能不能再次修改”时,答案主要应该在 domain/order 里找到,而不是散落在多个应用服务和数据库更新逻辑中。
为什么这一步值得做
当业务规则还很少时,把逻辑写在 application/service 里看起来并没有问题;但一旦规则数量和状态迁移开始增多,继续让应用层承担所有判断,很快会出现几个典型症状:
- 一个
application/service文件动辄数百行,充满if/else - 状态迁移规则散落在多个命令处理器里
- 同样的金额校验、状态校验、约束判断在多个地方重复出现
- 新成员很难回答“订单到底有哪些业务规则”
这时,引入 DDD 的最大收益,就是把这些规则收拢回业务概念本身。新成员阅读 Order.Place()、Order.Cancel()、Order.MarkPaid(),就能理解订单生命周期中的核心约束,而不是在多个应用服务文件之间来回跳转。
收益:模型更贴近业务,规则更容易维护
阶段 2 最直接的收益通常有三个。
第一,业务规则开始真正内聚。规则不再散落在各个 application/service 中,而是集中在聚合根和相关值对象里。
第二,模型表达能力更强。Money 不再只是 float64,OrderStatus 不再只是魔数,代码本身开始带有业务语义。
第三,系统更适合继续演进。当后续要引入领域事件驱动、跨上下文协作,或者进一步拆分读写模型时,一个表达清晰的领域模型会成为非常稳固的基础。
当然,代价也很现实:DDD 的学习门槛比前两阶段更高,过度使用聚合、值对象和事件,也很容易把简单业务写得过于复杂。因此,阶段 2 的关键不是“为了 DDD 而 DDD”,而是在业务规则确实已经复杂到值得建模时,再让模型承担它本来就该承担的职责。
通用语言(Ubiquitous Language)
DDD 的战术设计关注的是代码层面的实现:如何用聚合、实体、值对象等战术模式编写高质量的领域模型。
战术设计概述
| 概念 | 定义 | 示例 |
|---|---|---|
| Aggregate(聚合) | 一组相关对象的集合,确保数据的一致性边界 | Order 聚合包含 OrderItem 列表 |
| Aggregate Root(聚合根) | 聚合的入口对象,外部只能通过它访问聚合 | Order 是聚合根,OrderItem 不能被单独访问 |
| Entity(实体) | 有唯一标识的对象,按 ID 区分 | User(不同 ID = 不同用户) |
| Value Object(值对象) | 没有唯一标识,仅由属性定义 | Money(100, "USD")、Address |
| Domain Event(领域事件) | 领域中发生的有意义的事实 | OrderPlaced、PaymentCompleted |
| 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)
}
关键设计要点:
- 业务规则内聚:所有的业务规则都在聚合根的方法中,而不是散落在 Service 层
- 不变量保护:聚合根保证内部数据的一致性(如
totalPrice始终是items的总和) - 领域事件:聚合根发生重要状态变化时,返回领域事件
- 值对象:
Money是值对象,保证金额计算的精度和货币一致性
开发者和业务专家用同一套词汇交流,代码里的变量名就是业务里的术语:
| 业务术语 | 代码命名 | 反面教材 |
|---|---|---|
| 下单 | Order.Place() | Order.SetStatus(1) |
| 加入购物车 | Cart.AddItem() | Cart.Insert() |
| 发起退款 | Refund.Initiate() | Refund.Create() |
| 库存扣减 | Stock.Deduct() | Stock.Update() |
为什么重要?
在传统的开发模式中,业务专家和开发者之间存在“翻译“过程:
- 业务专家说“用户下单后锁定库存“
- 产品经理翻译成“创建订单后更新库存状态“
- 开发者实现成
updateInventoryStatus(orderId, status=2)
问题:
- 业务术语(锁定库存)→ 技术术语(更新状态 = 2),中间丢失了业务含义
- 代码中的
status=2没人知道是什么意思 - 业务规则隐藏在魔数和 SQL 中,无法追溯
通用语言的解决方案:
// ✅ 代码直接使用业务术语
func (s *InventoryService) LockStock(ctx context.Context, skuID string, qty int) error {
stock, err := s.stockRepo.FindBySKU(ctx, skuID)
if err != nil {
return err
}
// 业务术语:锁定库存
if err := stock.Lock(qty); err != nil {
return err
}
return s.stockRepo.Save(ctx, stock)
}
// domain/inventory/stock.go
func (s *Stock) Lock(qty int) error {
if s.available < qty {
return ErrInsufficientStock
}
s.available -= qty
s.locked += qty
return nil
}
对比:
- ❌
updateInventoryStatus(orderId, status=2)— 技术术语,无业务含义 - ✅
stock.Lock(qty)— 业务术语,直接表达意图
聚合设计原则
聚合设计是 DDD 战术层面最难的部分。三条核心原则:
原则一:一个事务只修改一个聚合
// ❌ 反例:一个事务同时修改 Order 和 Inventory 两个聚合
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCmd) error {
return s.txManager.RunInTx(ctx, func(tx *sql.Tx) error {
order := domain.NewOrder(cmd.CustomerID)
order.AddItem(cmd.ProductID, cmd.Qty)
order.Place()
s.orderRepo.SaveTx(tx, order) // 修改 Order 聚合
s.inventoryRepo.DeductTx(tx, cmd.ProductID, cmd.Qty) // ← 同时修改 Inventory 聚合
return nil
})
}
问题:
- 两个聚合在同一个事务中修改,导致锁粒度过大
- Order 和 Inventory 强耦合,无法独立演进
- 高并发场景下容易死锁
// ✅ 正例:通过领域事件实现跨聚合协作
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCmd) error {
order := domain.NewOrder(cmd.CustomerID)
order.AddItem(cmd.ProductID, cmd.Qty)
events, err := order.Place()
if err != nil {
return err
}
// 只保存 Order 聚合
if err := s.orderRepo.Save(ctx, order); err != nil {
return err
}
// 发布事件,由 Inventory 服务异步消费
s.eventBus.Publish(ctx, events...)
return nil
}
// inventory 服务的事件处理器
func (h *InventoryEventHandler) OnOrderPlaced(ctx context.Context, e OrderPlacedEvent) error {
return h.stock.Deduct(ctx, e.ProductID, e.Qty)
}
收益:
- Order 和 Inventory 解耦,可以独立扩展
- 事务范围缩小,提高并发性能
- 通过事件实现最终一致性
原则二:小聚合优于大聚合
| 维度 | 小聚合 | 大聚合 |
|---|---|---|
| 并发冲突 | 低(锁粒度小) | 高(整个大聚合被锁) |
| 内存占用 | 小(按需加载) | 大(整棵树一次加载) |
| 一致性范围 | 单个核心不变量 | 多个不变量混在一起 |
| 适用场景 | 高并发写入 | 强一致性要求的小规模数据 |
判断标准:如果两个实体之间没有需要在同一个事务中保护的业务不变量,就应该拆成两个聚合。
示例:
// ❌ 大聚合:Order 聚合包含 Customer 的完整信息
type Order struct {
id OrderID
customer *Customer // 包含 Customer 的所有信息
items []OrderItem
}
// 问题:加载 Order 时被迫加载 Customer 的所有信息(地址、订单历史等)
// ✅ 小聚合:Order 只保存 CustomerID
type Order struct {
id OrderID
customerID CustomerID // 只存 ID,需要时按需查询
items []OrderItem
}
// 需要 Customer 信息时,通过 Repository 查询
func (uc *OrderDetailUseCase) GetOrderDetail(ctx context.Context, orderID string) (*OrderDetailDTO, error) {
order, err := uc.orderRepo.FindByID(ctx, orderID)
if err != nil {
return nil, err
}
customer, err := uc.customerRepo.FindByID(ctx, order.CustomerID())
if err != nil {
return nil, err
}
return &OrderDetailDTO{
OrderID: string(order.ID()),
CustomerName: customer.Name(),
Items: order.Items(),
}, nil
}
原则三:通过 ID 引用其他聚合
// ❌ 聚合内直接持有另一个聚合的引用
type Order struct {
customer *Customer // 直接引用 → 加载 Order 时被迫加载 Customer
}
// ✅ 通过 ID 引用
type Order struct {
customerID CustomerID // 只存 ID,需要时按需查询
}
收益:
- 聚合边界清晰,加载 Order 不会加载 Customer
- 聚合之间松耦合,可以独立演进
- 减少内存占用和数据库 JOIN
Repository 与 Unit of Work 模式
Repository 是领域模型和持久化之间的桥梁,它提供了类似集合的接口来访问聚合。
标准 Repository 模式
// domain/order/repository.go — 领域层定义接口
package order
type Repository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id OrderID) (*Order, error)
FindByCustomerID(ctx context.Context, customerID string, limit int) ([]*Order, error)
}
Repository 的职责:
- 提供类似集合的接口(Save, FindByID, Remove)
- 隐藏底层存储细节(MySQL/MongoDB/Redis)
- 保证聚合的完整加载和保存
Unit of Work 模式
标准 Repository 每个操作独立,但有时需要在一个事务中协调多个 Repository(例如保存聚合根 + 写 Outbox 表)。Unit of Work 模式解决这个问题:
// domain/uow.go — 领域层定义接口
package domain
type UnitOfWork interface {
OrderRepo() order.Repository
OutboxRepo() OutboxRepository
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
}
// infrastructure/uow_impl.go — 基础设施层实现
package infrastructure
type mysqlUnitOfWork struct {
tx *sql.Tx
orderRepo *MySQLOrderRepo
outboxRepo *MySQLOutboxRepo
}
func NewUnitOfWork(db *sql.DB) (domain.UnitOfWork, error) {
tx, err := db.Begin()
if err != nil {
return nil, err
}
return &mysqlUnitOfWork{
tx: tx,
orderRepo: &MySQLOrderRepo{tx: tx},
outboxRepo: &MySQLOutboxRepo{tx: tx},
}, nil
}
func (u *mysqlUnitOfWork) OrderRepo() order.Repository { return u.orderRepo }
func (u *mysqlUnitOfWork) OutboxRepo() OutboxRepository { return u.outboxRepo }
func (u *mysqlUnitOfWork) Commit(ctx context.Context) error { return u.tx.Commit() }
func (u *mysqlUnitOfWork) Rollback(ctx context.Context) error { return u.tx.Rollback() }
// application/command/place_order.go — Use Case 使用 UoW
func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCmd) error {
uow, err := h.uowFactory(ctx)
if err != nil {
return err
}
defer uow.Rollback(ctx)
order := domain.NewOrder(cmd.CustomerID)
events, err := order.Place()
if err != nil {
return err
}
// 同一个事务中保存聚合和 Outbox
if err := uow.OrderRepo().Save(ctx, order); err != nil {
return err
}
for _, e := range events {
if err := uow.OutboxRepo().Save(ctx, toOutboxEntry(e)); err != nil {
return err
}
}
return uow.Commit(ctx)
}
领域事件异步化:Outbox Pattern
问题:保存聚合到数据库后,还要发送事件到 Kafka。这两个操作无法在一个事务中完成(双写问题)。如果先写 DB 再发 Kafka,发送失败则事件丢失;如果先发 Kafka 再写 DB,写 DB 失败则产生幽灵事件。
解法:Outbox Pattern——将事件写入本地数据库的 Outbox 表(与业务数据同一事务),再由独立的 Relay 进程异步发送到 Kafka。
sequenceDiagram
participant App as Application
participant DB as MySQL
participant Relay as Outbox Relay
participant MQ as Kafka
App->>DB: BEGIN TX
App->>DB: INSERT orders (聚合数据)
App->>DB: INSERT outbox (领域事件)
App->>DB: COMMIT TX
loop 定期轮询
Relay->>DB: SELECT * FROM outbox WHERE status='pending'
Relay->>MQ: Publish(event)
MQ-->>Relay: ACK
Relay->>DB: UPDATE outbox SET status='sent'
end
Outbox 表设计
CREATE TABLE outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(128) NOT NULL,
event_key VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
status ENUM('pending', 'sent', 'failed') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMP NULL,
retry_count INT DEFAULT 0,
INDEX idx_status_created (status, created_at)
);
Relay 实现
package infrastructure
import (
"context"
"log/slog"
"time"
)
type OutboxRelay struct {
outboxRepo OutboxRepository
producer MessageProducer
}
func (r *OutboxRelay) Run(ctx context.Context) {
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
entries, err := r.outboxRepo.FetchPending(ctx, 100)
if err != nil {
slog.Error("fetch outbox failed", "error", err)
continue
}
for _, entry := range entries {
if err := r.producer.Publish(ctx, entry.EventType, entry.EventKey, entry.Payload); err != nil {
slog.Error("publish event failed", "id", entry.ID, "error", err)
r.outboxRepo.MarkFailed(ctx, entry.ID)
continue
}
r.outboxRepo.MarkSent(ctx, entry.ID)
}
}
}
}
关键保证:
- At-least-once delivery:Relay 崩溃后重启会重新发送 pending 的事件,消费者必须做幂等处理
- 顺序保证:按
created_at顺序拉取,同一event_key的事件保持顺序 - 死信处理:
retry_count > 5的事件转入死信表,人工介入
CQRS(命令查询职责分离)
为什么要读写分离
CQRS 的逻辑非常直白:处理“改变数据“(Command)的逻辑和处理“读取数据“(Query)的逻辑应该完全分开。
在复杂系统中,写的逻辑和读的需求往往是矛盾的:
| 维度 | 写(Command) | 读(Query) |
|---|---|---|
| 关注点 | 业务规则、校验、权限、事务 | 跨表关联、全文搜索、分页排序 |
| 数据模型 | 范式化(3NF),保证一致性 | 反范式化(宽表),优化查询速度 |
| 性能目标 | 保证正确性 > 速度 | 保证速度 > 实时性 |
| 扩展方式 | 垂直扩展(事务安全) | 水平扩展(读副本、缓存) |
| 典型存储 | MySQL, PostgreSQL | Elasticsearch, Redis, ClickHouse |
电商订单详情页的矛盾:
用户打开订单详情页,需要展示:
- 订单基本信息(订单号、状态、总价)
- 商品信息(名称、图片、规格)
- 价格明细(商品价、运费、优惠券)
- 物流信息(快递公司、运单号、物流轨迹)
- 售后信息(退款状态、退货进度)
如果用写模型(范式化)来查询:
-- ❌ 需要 JOIN 5-6 张表,性能差
SELECT o.*, oi.*, p.*, l.*, r.*
FROM orders o
JOIN order_items oi ON o.id = oi.order_id
JOIN products p ON oi.product_id = p.id
JOIN logistics l ON o.id = l.order_id
LEFT JOIN refunds r ON o.id = r.order_id
WHERE o.id = ?
如果用读模型(反范式化):
// ✅ 一个宽表或一个 ES 文档,性能极佳
{
"order_id": "123",
"status": "delivered",
"total_price": 299.00,
"items": [
{"product_name": "iPhone", "image": "...", "price": 299.00}
],
"logistics": {"company": "SF Express", "tracking_no": "SF123"},
"refund": null
}
DDD 与 CQRS 的关系
如果说阶段 2 的 DDD 解决的是“业务逻辑本身应该如何表达”,那么阶段 3 的 CQRS 解决的则是另一个更现实的问题:这些业务逻辑表达清楚之后,读和写是否还应该继续走同一条路径。
到了这一阶段,系统的主要矛盾往往已经不再是“模型贫血”或“规则散落”,而是“写模型为了保证一致性必须保持克制,但读场景又越来越追求宽表、搜索、排序和高并发”。也就是说,DDD 帮你把写侧建模建清楚了,但读侧未必适合继续共享同一套模型。
这正是 CQRS 和 DDD 的关系。DDD 让命令侧的模型更有表达力,CQRS 则进一步承认:命令侧和查询侧本来就不是一回事。写侧继续走领域模型,读侧则按展示需求单独设计,这样两者才能各自优化。
Go 项目中的典型目录映射
当 CQRS 落到 Go 项目中,最常见的变化不是整个项目推倒重来,而是在原有的应用层和适配层中,把读写路径显式拆开。以订单服务为例,比较常见的组织方式如下:
order-service/
├── cmd/
│ └── server/
│ └── main.go
│
├── domain/
│ └── order/
│ ├── aggregate.go
│ ├── value_object.go
│ ├── repository.go
│ └── event.go
│
├── application/
│ ├── command/ # 写路径
│ │ ├── place_order.go
│ │ ├── cancel_order.go
│ │ └── mark_order_paid.go
│ └── query/ # 读路径
│ ├── order_detail.go
│ └── order_list.go
│
├── interfaces/
│ ├── http/
│ │ └── order.go
│ └── rpc/
│ └── order.go
│
├── infrastructure/
│ ├── persistence/ # Write DB
│ │ └── order_repo.go
│ ├── readmodel/ # Read DB / 宽表 / ES
│ │ ├── order_detail_repo.go
│ │ └── order_list_repo.go
│ ├── projection/ # Event -> Read Model
│ │ └── order_projector.go
│ ├── mq/
│ └── mysql/
和前面的 DDD 相比,这里的核心变化不是 domain 再次升级,而是 application 和 infrastructure 开始明确区分“命令处理”和“查询处理”。从这个时候开始,大家讨论问题时,不再只是说“订单接口怎么写”,而是会进一步区分“这是一条命令链路,还是一条查询链路”。
以一个订单服务的调用链为例
如果把 CQRS 放到下单和查单场景中,最直观的变化就是系统里开始并存两条调用链。
写路径通常是这样的:
HTTP 请求
-> interfaces/http.OrderHandler
-> application/command.PlaceOrderHandler
-> domain/order.Order 聚合根
-> infrastructure/persistence.OrderRepo
-> 发布领域事件
读路径则通常是这样的:
HTTP 请求
-> interfaces/http.OrderHandler
-> application/query.OrderDetailHandler
-> infrastructure/readmodel.OrderDetailRepo
-> Read DB / 宽表 / ES
这两条链路最重要的区别不只是“目录不同”,而是它们的目标根本不同:
- 写路径 关注的是业务规则、一致性和状态变更
- 读路径 关注的是展示效率、关联结果和查询性能
也正因为如此,CQRS 并不是“把原来的 Service 拆成两个文件”那么简单,而是承认读写两类需求的优化方向从一开始就不一样。
架构全景
flowchart LR
subgraph 写路径 Command Side
A[Client] -->|Command| B[Command Handler]
B --> C[Domain Model / Aggregate]
C --> D[(Write DB - MySQL)]
C -->|Domain Event| E[Event Bus]
end
subgraph 读路径 Query Side
E -->|同步/异步投影| F[Read Model Builder]
F --> G[(Read DB - ES/Redis)]
H[Client] -->|Query| I[Query Handler]
I --> G
end
关键理解:
- Command Side(写路径):走领域模型,保证业务规则,数据存储在 MySQL
- Query Side(读路径):绕过领域模型,直接从优化的读库(ES/Redis)返回 DTO
- 同步机制:通过领域事件 + 投影器(Projector)将写模型同步到读模型
Command 与 Query 的设计
Command — 表达意图,不返回业务数据
// application/command/place_order.go
package command
type PlaceOrderCommand struct {
CustomerID string
Items []OrderItemDTO
}
type CommandResult struct {
Success bool
ID string
Error error
}
type PlaceOrderHandler struct {
orderRepo order.Repository
eventBus EventBus
}
func (h *PlaceOrderHandler) Handle(ctx context.Context, cmd PlaceOrderCommand) CommandResult {
order := domain.NewOrder(cmd.CustomerID)
for _, item := range cmd.Items {
if err := order.AddItem(item.ProductID, item.Qty); err != nil {
return CommandResult{Error: err}
}
}
events, err := order.Place()
if err != nil {
return CommandResult{Error: err}
}
if err := h.orderRepo.Save(ctx, order); err != nil {
return CommandResult{Error: err}
}
h.eventBus.Publish(ctx, events...)
return CommandResult{Success: true, ID: string(order.ID())}
}
Command 的特点:
- 表达用户的意图(下单、取消、退款)
- 走领域模型,执行业务规则
- 只返回操作结果(成功/失败 + ID),不返回完整的业务数据
Query — 直接返回展示层需要的 DTO
// application/query/order_detail.go
package query
type OrderDetailQuery struct {
OrderID string
}
type OrderDetailDTO struct {
OrderID string `json:"order_id"`
CustomerName string `json:"customer_name"`
Items []ItemDTO `json:"items"`
TotalPrice string `json:"total_price"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
type OrderDetailHandler struct {
readDB ReadModelRepository
}
func (h *OrderDetailHandler) Handle(ctx context.Context, q OrderDetailQuery) (*OrderDetailDTO, error) {
// 绕过领域模型,直接从读库获取
return h.readDB.FindOrderDetail(ctx, q.OrderID)
}
Query 的特点:
- 绕过领域模型,不触发任何业务逻辑
- 直接从优化的读库(ES/Redis/宽表)返回 DTO
- 数据结构完全匹配前端需求,减少转换
为什么这一步值得做
当系统还比较简单时,把读和写放在同一套模型里看起来完全没有问题;但一旦查询开始变宽、流量开始倾斜、读写优化目标开始冲突,继续强行共用同一套模型,通常会出现几个典型症状:
- 写模型为了保证一致性保持范式化,查询却不得不频繁做多表关联
- 一个订单详情页需要拼接商品、价格、物流、售后等多个维度,查询越来越重
- 读流量远高于写流量,但系统却只能按同一套方式扩展
- 业务层为了兼顾读写两端,逐渐长成“既不像领域模型,也不像查询模型”的折中结构
这时,引入 CQRS 的最大收益,就是允许“写侧继续为正确性服务,读侧单独为性能服务”。你不再需要让一套模型同时满足两种天然冲突的目标。
核心价值
极致的性能优化。你可以针对写操作使用关系型数据库(保证强一致性),针对读操作使用 Elasticsearch 或 Redis(保证高并发)。读写模型可以独立扩展、独立优化。
电商系统的实际案例:
| 场景 | 写模型(MySQL) | 读模型(Elasticsearch) |
|---|---|---|
| 下单 | 保证事务一致性,写入订单表 | 不涉及 |
| 订单详情页 | 不涉及 | 从 ES 读取宽表(包含商品、物流、售后) |
| 订单列表 | 不涉及 | 从 ES 搜索(支持筛选、排序、分页) |
| 数据分析 | 不涉及 | 从 ClickHouse 读取(OLAP) |
收益:写侧更稳,读侧更快
引入 CQRS 之后,最直接的收益通常有三个。
第一,写侧模型可以继续保持克制。命令处理依旧围绕聚合根和一致性边界展开,不必为了适配复杂查询去牺牲建模质量。
第二,读侧可以针对场景单独优化。订单详情页、订单列表页、搜索页、报表页,可以分别使用适合自己的读模型,而不必共享同一套查询结构。
第三,读写可以独立扩展。当读 QPS 远高于写 QPS 时,你可以优先扩读模型、缓存和搜索引擎,而不是被迫整体扩容。
当然,代价也很现实:读写分离之后,系统复杂度会上升,读模型同步、最终一致性、幂等处理和投影器维护都需要额外关注。因此,CQRS 不是“默认就该上”的方案,而是在读写矛盾已经明显出现时,才值得付出的复杂性成本。
Event Sourcing:事件溯源
Event Sourcing 经常和 CQRS 一起被提及,但它们是独立的概念,可以单独使用,也可以组合使用。
核心思想
传统方式存储的是当前状态(state),Event Sourcing 存储的是导致状态变化的事件序列(events)。当前状态通过重放事件计算得出。
传统方式:
orders 表: {id: 1, status: "paid", total: 200, updated_at: "2026-04-07"}
Event Sourcing:
events 表:
{seq: 1, type: "OrderCreated", data: {id: 1, customer: "alice"}}
{seq: 2, type: "ItemAdded", data: {product: "shoe", price: 100, qty: 2}}
{seq: 3, type: "OrderPlaced", data: {total: 200}}
{seq: 4, type: "PaymentReceived", data: {amount: 200, method: "credit_card"}}
与 CQRS 的关系
graph LR
A[CQRS] --- B[可以独立使用]
C[Event Sourcing] --- B
A --- D[组合使用效果最佳]
C --- D
D --> E[写侧用事件存储<br/>读侧用物化视图]
- 只用 CQRS 不用 ES:写侧用普通数据库,读侧用独立的读模型。最常见的方式。
- 只用 ES 不用 CQRS:事件存储 + 重放计算状态,读写用同一个模型。适合审计场景。
- CQRS + ES:写侧用事件存储,读侧通过投影事件构建物化视图。适合金融、交易系统。
适用与不适用场景
| 适用 | 不适用 |
|---|---|
| 需要完整审计追踪(金融、合规) | 简单 CRUD 应用 |
| 需要时间旅行/回放(调试、分析) | 高频更新的状态(计数器、在线人数) |
| 事件本身有业务价值 | 数据模型频繁变更 |
| 需要撤销/补偿操作 | 团队对 ES 没有经验且交期紧 |
本书立场:电商系统大部分场景只用 CQRS 不用 Event Sourcing。Event Sourcing 适合金融、合规等场景,但对于电商的商品、订单等模块,增加的复杂度超过了收益。
最终一致性处理策略
引入 CQRS 后,写模型和读模型之间存在延迟(通常毫秒到秒级)。这需要在架构层面和用户体验层面同时处理。
架构层面
策略一:幂等消费
投影器可能收到重复事件(at-least-once delivery),必须做幂等处理:
func (p *OrderProjector) Project(ctx context.Context, event DomainEvent) error {
// 幂等性检查:事件是否已处理
exists, err := p.readDB.EventProcessed(ctx, event.ID())
if err != nil {
return err
}
if exists {
return nil // 已处理过,跳过
}
// 处理事件
switch e := event.(type) {
case OrderPlacedEvent:
dto := OrderDetailDTO{
OrderID: string(e.OrderID),
Status: "placed",
TotalPrice: e.Total.String(),
CreatedAt: e.At.Format(time.RFC3339),
}
if err := p.readDB.Upsert(ctx, dto); err != nil {
return err
}
}
// 标记事件已处理
return p.readDB.MarkEventProcessed(ctx, event.ID())
}
策略二:补偿事务(Saga)
当跨服务操作中某一步失败,通过发布补偿事件回滚前面的步骤:
正向流程:CreateOrder → ReserveStock → ChargePayment
补偿流程: ReleaseStock ← RefundPayment ← PaymentFailed
我们会在本章的 3.4 部分详细讲解 Saga 模式。
用户体验层面
Optimistic UI(乐观更新):前端在发送 Command 后立即更新 UI,不等待读模型同步。
用户点击"下单"
→ 前端立即显示"订单已创建"(乐观更新)
→ 后端 Command 异步处理
→ 读模型延迟 200ms 后更新
→ 用户下次刷新时看到真实状态
Read-your-writes:Command 成功后返回版本号,Query 时带上版本号,确保读到的是自己写入之后的数据。
// Command 返回版本号
type CommandResult struct {
Success bool
ID string
Version int64 // 版本号
}
// Query 时带上版本号
type OrderDetailQuery struct {
OrderID string
MinVersion int64 // 期望读到的最小版本
}
// Query Handler 检查版本
func (h *OrderDetailHandler) Handle(ctx context.Context, q OrderDetailQuery) (*OrderDetailDTO, error) {
dto, err := h.readDB.FindOrderDetail(ctx, q.OrderID)
if err != nil {
return nil, err
}
// 如果读模型版本低于期望版本,返回错误或等待
if dto.Version < q.MinVersion {
return nil, ErrReadModelNotReady
}
return dto, nil
}
投影器(Projector)实现模式
投影器是 CQRS 架构中将领域事件转化为读模型的组件。
flowchart LR
A[Event Store / MQ] --> B[Projector]
B --> C[(Read DB)]
B --> D{事件类型路由}
D -->|OrderPlaced| E[创建订单读模型]
D -->|ItemAdded| F[更新商品明细]
D -->|OrderCancelled| G[标记订单取消]
完整实现
// adapter/projection/projector.go
package projection
type Projector interface {
Handles() []string // 返回该 Projector 关心的事件类型列表
Project(ctx context.Context, event DomainEvent) error
}
type OrderReadModelProjector struct {
readDB ReadModelRepository
}
func (p *OrderReadModelProjector) Handles() []string {
return []string{"OrderPlaced", "OrderCancelled", "ItemAdded", "PaymentCompleted"}
}
func (p *OrderReadModelProjector) Project(ctx context.Context, event DomainEvent) error {
switch e := event.(type) {
case OrderPlacedEvent:
return p.readDB.Upsert(ctx, OrderReadModel{
OrderID: string(e.OrderID),
Status: "placed",
Total: e.Total.Amount,
Currency: e.Total.Currency,
CreatedAt: e.At,
})
case OrderCancelledEvent:
return p.readDB.UpdateStatus(ctx, string(e.OrderID), "cancelled")
case PaymentCompletedEvent:
return p.readDB.UpdateStatus(ctx, string(e.OrderID), "paid")
default:
return nil
}
}
投影器的运行模式
| 模式 | 机制 | 延迟 | 适用场景 |
|---|---|---|---|
| 同步投影 | Command Handler 执行完后同步调用 Projector | 零延迟 | 读写在同一进程、低吞吐 |
| 异步投影 | 事件通过 MQ 传递,Projector 独立消费 | 毫秒~秒级 | 高吞吐、读写分离部署 |
| Catch-up 投影 | Projector 从事件存储按序号拉取事件 | 可控 | 重建读模型、新增投影视图 |
电商系统推荐:异步投影,理由:
- 读写可以独立扩展(写用MySQL,读用ES)
- 故障隔离(读模型崩溃不影响写操作)
- 性能最优(异步处理不阻塞写操作)
本章小结
本章的核心不是再介绍几个新概念,而是把系统内部结构设计这件事重新排成一条清晰主线:
- 先用三层架构建立最基本的职责秩序
- 再用 Clean Architecture 收紧依赖方向
- 再用 DDD 战术设计把复杂规则收回到模型内部
- 最后在必要时用 CQRS 拆分读写路径
换句话说,系统内部结构设计不是在问“该选哪一种架构”,而是在问:面对职责、依赖、建模和数据流这四类问题时,架构师应该按什么顺序出招。
下一节将继续沿着这条主线往外走:当单个系统内部已经有了秩序之后,多个系统之间又该如何集成,怎样在分布式环境中处理协作与一致性问题。
3.4 系统集成与一致性设计
分布式事务挑战
跨服务、跨库、跨供应商的电商链路,本质上是在没有全局锁的前提下完成协作。讨论「分布式事务」之前,先要统一语言:我们追求的往往不是银行核心那种「强一致实时可见」,而是用户可理解的一致性与财务可审计的一致性的组合。
把一致性目标写成 SLO:方法论章节最容易停留在概念层,落地时建议把「一致性」翻译成可监控指标。例如:「创单成功后 99.9% 用户在 1 秒内读到订单详情」「支付成功后 5 分钟内订单状态同步完成」「对账差异在 T+1 日内闭环率」。没有指标,团队就会在事故后争论「这算不算一致」。SLO 也会反向约束集成模式:如果你承诺秒级读己之所写,就不能把关键读路径绑在慢消费者之后。
可观测性是最小一致性保障:跨系统集成里,最大风险不是「短暂不一致」,而是「不知道不一致发生在哪」。因此链路追踪(Trace)、关联 ID(order_id / payment_id / idempotency_key)、以及跨系统日志检索规范,应被视为一致性基础设施的一部分,而不是「运维锦上添花」。当你能在 5 分钟内从用户投诉定位到具体参与方与具体请求,你已经把大量 P1 事故降级为 P3。
CAP 理论在电商中的应用
CAP 指出:在**网络分区(P)**客观存在时,系统只能在 C(线性化一致性) 与 A(可用性:非故障节点可继续响应) 之间做权衡。电商里更务实的表述是:
- P 不可逃避:机架故障、运营商割接、云厂商区域抖动都会制造分区;你的系统要么承认它,要么假装它不存在(后者通常以事故收场)。
- C 与 A 不是 0/1:多数业务选择的是 延迟可接受的一致性(例如订单创建立即可见、搜索索引秒级滞后)与 降级后的可用性(例如暂停个性化推荐但不阻断下单)。
下面的三角图用三个顶点表达「三者不可同时取满」的直觉(示意,非形式化证明)。
flowchart LR
subgraph CAP[CAP 在分区下的取舍三角]
C((C\n强一致 / 线性化))
A((A\n高可用 / 持续服务))
P((P\n分区容错))
end
C ---|分区下难与 A 兼得| A
A ---|要一致则需限制写入| P
P ---|承认分区| C
style C fill:#ffebee
style A fill:#e8f5e9
style P fill:#e3f2fd
电商映射示例:
| 场景 | 更偏向 | 原因 |
|---|---|---|
| 支付记账、余额扣减 | CP 倾向 | 差错成本高,宁可失败重试也不要错账 |
| 商品详情、推荐列表 | AP 倾向 | 短暂旧数据可接受,可用性影响转化 |
| 创单 + 库存预占 | 工程上常选 BASE + 补偿 | 同步强一致跨多服务代价大,Saga / TCC 更常见 |
在 CAP 之外,工程讨论里还常用 PACELC:在**正常(Latency)与分区(Partition)**两种状态下,分别在 C 与 A、**C 与 L(延迟)**之间权衡。电商系统的真实矛盾往往不在「选 CP 还是 AP」这种标签,而在「把哪一类不一致暴露给用户」:用户能容忍搜索列表晚 1 秒,但很难容忍「支付成功却订单仍待付款」这种语义断裂。于是产品与技术要共同定义 RPO/RTO 的业务翻译——例如「支付回调最长可延迟多久仍可被用户理解」「库存预占展示与真实可售不一致的上限是多少」。
另一个常见误区是把 「强一致」当成银弹。跨服务的两阶段提交(2PC)与其变体在微服务规模下会放大故障域:协调者不可用、参与者长时间持锁、尾延迟抖动都会被交易洪峰放大。更稳妥的工程路径通常是:在单个聚合 / 单个服务内用数据库事务守住硬约束,跨边界用 Saga / TCC / 可靠消息组合,再用 对账兜住不可避免的外部异步。
一致性分类
从工程师落地角度,建议把「一致性」从口号拆成可见性、容错模型与经济后果三层,再映射到技术策略。
flowchart TB
subgraph L1[用户可见一致性]
U1[读己之所写 Read-your-writes]
U2[单调读 Monotonic reads]
U3[因果一致 Causal]
end
subgraph L2[跨系统事实来源]
S1[单主库权威 Single source of truth]
S2[多副本异步复制]
S3[事件驱动物化视图]
end
subgraph L3[财务 / 履约后果]
F1[强一致账务]
F2[最终一致业务态]
F3[对账闭环]
end
L1 --> L2
L2 --> L3
谱系速查:
- 强一致(线性化 / 串行化):单个分片内靠数据库事务;跨分片靠分布式协调(代价高、故障域大)。
- 会话一致 / 单调读:常见于「用户刚下的单,列表里立刻能看到」——可用主从路由策略与 sticky 读实现。
- 最终一致:允许短暂漂移,但必须有版本、水位、对账与补偿兜住边界。
- 因果一致:适合「评论回复依赖发帖可见」等场景;电商里多用于协作类附属功能,主交易链路仍以订单状态机为准。
为了把「一致性」从论文语言落到排障语言,建议团队在架构评审里强制区分三类问题,并把它们映射到不同手段:
- 读可见性问题:用户「刚操作完却看不到」——多数是读写路由、缓存、投影延迟;优先查主从延迟、Outbox 堆积、消费者 lag。
- 跨系统事实冲突:订单说已支付、支付说未成功——多数是回调丢失、幂等键不一致、状态机非法迁移;优先查渠道流水与平台流水对齐。
- 跨时间窗口的统计不一致:GMV 看板与财务口径对不上——多数是异步汇总、时区、退款冲正口径;优先查离线任务水位与口径文档。
下表给出「用户感知」与「系统手段」的对照,用于和需求方对齐预期(避免把技术极限包装成业务承诺)。
| 用户感知目标 | 典型手段 | 需要额外付出的代价 |
|---|---|---|
| 下单后列表立刻出现 | 读主库 / 读己之所写路由 | 主库压力、热点风险 |
| 全站搜索秒级更新 | Outbox + 近实时索引 | 运维复杂度、契约治理 |
| 支付成功即刻可履约 | 同步确认 + 强校验 + 幂等 | 尾延迟、渠道配额 |
| 大促高峰仍可下单 | 异步化、削峰、降级读 | 短暂不一致、对账压力 |
典型场景分析
- 创单链路(订单、库存、营销、计价):跨多个限界上下文,同步点越多尾延迟越大;典型解法是「结算页强校验 + 创单 Saga + 异步投影」。
- 搜索与商品主数据:索引滞后是常态,关键是可观测的延迟与降级展示(与本章的 3.6 部分呼应)。
- 支付回调与渠道对账:渠道侧状态与平台侧状态异步收敛;必须幂等、必须可对账(与支付相关章节及 6.5 节呼应)。
- 供应商库存:供应商为权威时,平台侧是快照 + 同步策略;分区时以可解释的拒单 / 延迟确认交换「永不超卖」承诺。
- 退款与售后:涉及支付渠道、营销回退、库存回冲、供应商拦截等多参与方,不可逆节点(已打款、已出票)会把「补偿」切换为「工单 / 人工」。这类链路更需要 Saga 日志 + 对账批次号 + 幂等退款单号 三件套,避免重复退款与部分成功。
- 秒杀与抢购:热点 SKU 的约束是「库存单调递减」与「请求风暴」叠加。工程上通常是 Redis 原子预减 + 异步落库对账,而不是跨服务同步强一致;否则会把尾延迟与失败面扩散到整个站点入口。
- 清结算与分账:平台、商家、渠道、营销补贴多方账本需要在 T+N 周期收敛。这里的「一致性」更像会计恒等式:借贷平衡、可追溯、可审计,而不是用户请求路径上的毫秒级一致。
- 跨境与多币种:汇率快照、税费、支付路由使得「金额一致」必须显式引入 snapshot_id / rate_version,否则对账会把产品问题误判为技术故障。
这些场景的共同点,是都要求你在架构文档里写清楚一句话:一致性的责任边界在哪里结束。例如库存预占由库存服务负责语义,订单只保存 reserve_id 凭证;支付金额以支付核心的记账为准,订单侧只保存 pay_amount 快照与引用号。边界写清楚,集成才不会退化成「谁都能改一笔」。
Saga 编排模式
Saga 把长事务拆为本地事务序列,用补偿事务撤销已提交步骤的可逆效果。它不要求全局锁表,也不要求所有参与方实现预留接口(对比 TCC 的 Try/Confirm/Cancel),因此在跨团队集成中最常见。
很多团队会把「分布式事务」理解成「找一个中间件把多个数据库一次性提交」。在电商微服务里,这种理解往往会把问题推向两个极端:要么 过度依赖全局协调(可用性与尾延迟受损),要么 完全回避一致性话题(只能靠人肉修数)。Saga 的价值在于承认现实:跨服务没有免费的全局原子性,但可以把不确定性收敛到 可测试的本地事务 + 可审计的补偿 + 可对账的凭证。当你能清楚说出「哪一步失败会留下什么外部痕迹」,你就已经比大多数项目更接近可控。
落地时还要区分 业务失败 与 系统失败:库存不足是业务失败,通常不应触发重试;渠道超时是系统失败,需要退避重试与查询对齐。把两者混在一个 if err != nil 里,Saga 会表现为「无意义重试放大雪崩」或「该补偿却不补偿」。建议在错误模型里显式引入 BusinessError 与 TransientError(或等价错误码),编排器据此分支。
Saga 基础概念
- 子事务(Local Transaction):在一个服务 / 一个库边界内可原子提交。
- 补偿(Compensation):语义上撤销前一步业务效果;必须幂等,且要能处理「原操作其实失败」的空补偿。
- Saga 日志:记录每一步状态,支撑断点续跑、人工介入与审计。
与 TCC(Try-Confirm-Cancel) 相比,Saga 对参与方的接口要求更低,但业务侧要承担更多「补偿语义」的设计成本。可以用下表做模式选型(不是非此即彼,很多系统会在支付子域用更严格的协议,在营销子域用 Saga)。
| 维度 | Saga | TCC |
|---|---|---|
| 参与方改造 | 低:正向 + 补偿即可 | 高:三阶段接口与资源预留语义 |
| 一致性强度 | 依赖补偿正确性与对账 | Try 成功后可更强约束提交 |
| 失败处理 | 补偿链 + 人工兜底 | Cancel 路径必须可靠 |
| 典型适用 | 创单、结算编排 | 支付、余额、库存强约束场景(团队成熟度高) |
编排 vs 协同
flowchart LR
subgraph Orch[编排 Orchestration]
O[OrderOrchestrator\n集中状态机]
O --> I[InventorySvc]
O --> M[MarketingSvc]
O --> P[PricingSvc]
end
subgraph Cho[协同 Choreography]
E1[OrderCreated 事件]
E2[InventoryReserved 事件]
E3[MarketingLocked 事件]
OrdSvc[订单服务]
InvSvc[库存服务]
MktSvc[营销服务]
OrdSvc --> E1 --> InvSvc
InvSvc --> E2 --> MktSvc
MktSvc --> E3
end
选型经验:
- 编排:调试路径清晰,适合强流程(创单、退款、结算);缺点是编排器可能成为热点与变更集中点。
- 协同:解耦参与者,适合弱流程扩展;缺点是全局可观测性与顺序约束更难,需要严格的事件契约与版本治理。
电商创单、退款等有严格顺序与对账要求的链路,业界更常见的是编排为主、事件为辅。
混合形态:编排器完成「强一致点的凭证收集」(试算快照、预占号、营销锁),聚合根提交后再通过 Outbox 广播 OrderCreated 给搜索、推荐、风控等读模型或旁路系统。这样既保留集中调试与审计的主线,又避免把读侧耦合进同步链路。
补偿机制设计
补偿不是「数据库回滚」的同义词,而是业务语义撤销。设计要点:
- 可逆性分级:库存释放、券解锁可逆;已发货、已出票常不可逆,需要转入人工 / 工单 / 财务流程。
- 补偿的幂等:网络重试会导致补偿重复执行;应用层用业务幂等键或状态检查保证安全。
- 失败面分类:业务拒绝(库存不足)与基础设施故障(超时)要分流,后者才触发重试与退避。
- 超时与悬挂:子调用超时后,编排器要通过查询接口把「未知」落成「成功 / 失败」之一,再决定前进或回滚。
Saga 持久化与恢复:请求线程崩溃、实例重启、发布滚动都会导致「执行到一半」的外部视图。生产系统应至少落一张 saga_instance 表(或等价事件日志),字段建议包含:saga_id、biz_key(幂等键)、current_step、status(running/compensating/succeeded/failed)、payload_json、started_at、updated_at、version(乐观锁)。恢复线程按 status=running 扫描,结合每步的 query 结果把流程推向下一个合法状态。没有这张表,你只能依赖日志拼凑现场,事故复盘成本会指数级上升。
并发与重入:同一个 biz_key 的重复请求不应启动第二个 Saga 实例。常见做法是「数据库唯一约束 + 返回进行中的 saga_id」或「Redis 分布式锁(短 TTL + 续期谨慎)」。锁方案要警惕:锁超时后另一个实例进入,会造成双轨执行;因此最终仍应以 幂等键与 Saga 状态机 作为真相来源。
sequenceDiagram
participant O as Orchestrator
participant S as StepService
O->>S: forward()
alt 成功
S-->>O: OK
O->>O: persist saga step done
else 明确失败
S-->>O: FAIL business
O->>S: compensate previous (idempotent)
S-->>O: OK
else 超时 / 未知
S-->>O: TIMEOUT
O->>S: query()
S-->>O: committed? yes/no
O->>O: reconcile then forward or compensate
end
订单创建的 Saga 实例
下面给出一个教学裁剪版但结构完整的 Go 示例:三步正向(计价快照绑定、库存预占、营销锁定)与两步补偿(释放库存、解锁营销)。示例省略了真实 RPC、链路追踪与部分错误包装,但保留编排骨架、幂等键透传、补偿逆序等关键结构。
Saga 流程图:
flowchart TD Start([开始 CreateOrderSaga]) --> T1[Step1: AttachPriceSnapshot] T1 -->|失败| X1([结束: 失败无补偿]) T1 -->|成功| T2[Step2: ReserveInventory] T2 -->|失败| C1[Comp1: 无需库存补偿] T2 -->|成功| T3[Step3: LockMarketing] T3 -->|失败| C2[Comp2: ReleaseInventory] T3 -->|成功| Done([持久化订单\n提交本地事务]) C2 --> X2([结束: 已回滚库存]) style Done fill:#e8f5e9 style X1 fill:#ffebee style X2 fill:#fff3e0
Go 编排骨架(单文件可 go run,将下游调用替换为真实 gRPC / HTTP SDK 即可落地):
Walk-through(对照流程图读代码):
AttachSnapshot失败时,系统尚未占用库存与营销资源,因此直接返回错误即可,对应流程图左侧「失败无补偿」分支。Reserve成功后,reserve_id成为库存侧的外部凭证;后续任何释放都必须携带它,并在库存服务侧以幂等方式处理。LockPromo失败进入补偿:只释放库存,不解锁营销(因为锁未成功)。若未来某版本LockPromo变成「部分成功」(例如锁定成功但写审计失败),必须把补偿扩展为「先查锁是否存在再解锁」,否则会出现补偿空转或补偿遗漏。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_id、step、status、payload、error_code)。 - 所有 outbound 调用携带关联 ID(
order_id、trace_id、idempotency_key)。 - 对「超时未知」统一走 query + reconcile 状态机,而不是立刻补偿。
Saga 表结构示例(MySQL,示意):落地时按你们公司的审计规范补全操作者与 trace 字段。
CREATE TABLE saga_instance (
saga_id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_key VARCHAR(128) NOT NULL,
name VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
current_step INT NOT NULL,
payload JSON NOT NULL,
version INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE KEY uk_saga_biz (biz_key, name)
);
CREATE TABLE saga_step (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
saga_id BIGINT NOT NULL,
step_no INT NOT NULL,
step_name VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
req JSON,
resp JSON,
err TEXT,
created_at DATETIME NOT NULL,
KEY idx_saga (saga_id, step_no)
);
事件驱动架构
事件驱动架构(EDA)把系统间的耦合从「知道对方的表结构」变为「订阅对方愿意公布的事实」。它与 DDD 的**领域事件(Domain Event)**天然契合:事件名应是业务过去式(OrderPlaced),而不是命令式(PlaceOrder)。
EDA 并不自动带来解耦:如果事件载荷里塞满下游私有字段,或消费者之间隐式依赖顺序却缺乏分区策略,你只会得到「异步耦合的大泥球」。因此本章强调三件事:契约、顺序边界、可观测的投递语义。与第 38 章的事件发布实践结合时,请把「事件平台能力」与「领域建模能力」分开评估:Kafka 再强也替代不了你对聚合边界的判断。
领域事件
领域事件用于表达聚合内已发生且不可变的事实。好的事件:
- 自描述:携带必要标识与版本(
schema_version)。 - 可演进:兼容字段新增,慎改语义。
- 与命令分离:命令可丢弃重试;事件一旦发布,消费者会据此做副作用。
命名与版本:事件名建议稳定且可检索(order.placed.v1),避免把促销规则编码进 topic 名称。载荷里携带 occurred_at、producer、schema_version,消费者才能做 向后兼容 解析。另一个实践是把「业务关键字段」与「展示字段」分层:关键字段用于幂等与投影,展示字段允许缺失并由读模型降级。
聚合边界:领域事件应从聚合根的不变量中自然产生,而不是为了通知某个下游临时「造事件」。后者会导致事件泛滥、顺序难以推理、回放成本失控。若你发现自己需要 SomethingMaybeChanged 这类含糊事件,通常意味着限界上下文边界需要重塑。
事件的发布与订阅
flowchart LR
subgraph OrderBC[订单限界上下文]
AR[Order Aggregate]
AR -->|产生| DE[Domain Events]
DE --> OB[Outbox Table]
end
subgraph Infra[基础设施]
REL[Outbox Relay / Poller]
BUS[(Message Bus)]
end
subgraph Consumers[订阅方]
InvProj[库存读模型投影]
Srch[搜索索引增量]
Risk[风控评分任务]
end
OB --> REL --> BUS
BUS --> InvProj
BUS --> Srch
BUS --> Risk
发布订阅的工程细节:
- Topic 分区键:与顺序性强相关的字段(同一
order_id)应映射到同一分区,避免乱序消费;但分区键过粗会造成热点分区,需要业务侧权衡。 - 消费者组:一组消费者共享进度,实现水平扩展;重平衡(rebalance)会带来短暂停顿,要评估是否影响实时性 SLA。
- 至少一次投递:因此消费者必须 幂等;常见实现是
UNIQUE(consumer, event_id)或业务唯一键。 - 顺序与并行:能并行就并行(不同聚合互不相关),不能并行就必须把「会改变含义的顺序」收敛到单分区或单线程处理器。
- 背压:投影任务落后时,应有 lag 告警与降级读策略,避免把读路径拖死。
与第 38 章「事件发布」对齐时,把「谁允许发什么事件」纳入治理:事件不是自由文本广播,而是受版本管理的契约。建议在仓库中维护 events/ 目录(或 Buf Schema Registry),把破坏性变更当作发布流程的一部分。
事件溯源(Event Sourcing)
事件溯源(ES)把状态还原为事件流的折叠:state = fold(events)。它带来强大审计与回放能力,但也引入:
- 模型复杂度:投影、快照、版本迁移成本高。
- 查询压力:多数业务仍需要物化读模型(CQRS)。
电商建议:账务、支付指令、库存流水等强审计子域可评估 ES;一般商品展示、搜索索引用物化视图 + Outbox 性价比更高(与第 1 章 CQRS 小节呼应)。
何时值得上 ES:当你明确需要「按时间回放任意业务态」且愿意投入 投影重建、快照策略、事件迁移工具链;否则先用 审计日志表 + 不可变对象存储归档 + 定期校验 往往更划算。ES 不是银弹,它是把复杂度从数据库迁移到了事件存储与投影运维。
快照(Snapshot):长生命周期聚合(例如会员账户、长期预售订单)如果每次都从头折叠事件,读路径会不可接受。快照本质是「在某版本截断事件流」,需要定义 快照写入频率 与 快照与事件的版本对齐规则。
实践要点与 Outbox 完整实现
Outbox 模式解决的核心矛盾是:数据库事务提交与消息发布难以跨资源原子化。做法是:在同一本地事务中写入业务表与 outbox 表;由独立进程异步投递到消息总线,实现 at-least-once 发布且不丢单(消费者仍需幂等)。
Outbox 时序图:
sequenceDiagram
participant API as Order API
participant DB as MySQL
participant R as Outbox Relay
participant K as Kafka
API->>DB: BEGIN
API->>DB: INSERT orders ...
API->>DB: INSERT outbox(event_type,payload,status)
API->>DB: COMMIT
loop poll
R->>DB: SELECT ... FOR UPDATE SKIP LOCKED
R->>K: produce message(idempotent key)
K-->>R: ack
R->>DB: UPDATE outbox SET status=published
end
Go 实现骨架(使用 database/sql;生产环境可替换为 sqlx / ORM,但保持「同事务写两张表」不变):
package outboxdemo
import (
"context"
"database/sql"
"encoding/json"
"time"
)
type OutboxEvent struct {
ID int64
Aggregate string
EventType string
Payload json.RawMessage
Status string // pending / published / dead
CreatedAt time.Time
}
type OrderRepository struct {
DB *sql.DB
}
// CreateOrderWithOutbox 演示:订单写入与 outbox 同事务
func (r *OrderRepository) CreateOrderWithOutbox(ctx context.Context, orderID string, evtType string, payload any) error {
bytes, err := json.Marshal(payload)
if err != nil {
return err
}
tx, err := r.DB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx, `INSERT INTO orders(id, status) VALUES(?, 'CREATED')`, orderID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO outbox(aggregate_id, event_type, payload, status, created_at)
VALUES(?,?,?,?,?)`,
orderID, evtType, bytes, "pending", time.Now().UTC(),
); err != nil {
return err
}
return tx.Commit()
}
// Relay 轮询投递:演示 SKIP LOCKED 多实例安全
type Relay struct {
DB *sql.DB
}
func (relay *Relay) PollOnce(ctx context.Context, publish func(ctx context.Context, ev OutboxEvent) error) (int, error) {
tx, err := relay.DB.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer func() { _ = tx.Rollback() }()
rows, err := tx.QueryContext(ctx, `
SELECT id, aggregate_id, event_type, payload, status, created_at
FROM outbox
WHERE status='pending'
ORDER BY id ASC
LIMIT 50
FOR UPDATE SKIP LOCKED`)
if err != nil {
return 0, err
}
defer rows.Close()
var batch []OutboxEvent
for rows.Next() {
var ev OutboxEvent
var agg string
if err := rows.Scan(&ev.ID, &agg, &ev.EventType, &ev.Payload, &ev.Status, &ev.CreatedAt); err != nil {
return 0, err
}
ev.Aggregate = agg
batch = append(batch, ev)
}
if len(batch) == 0 {
return 0, tx.Commit()
}
for _, ev := range batch {
if err := publish(ctx, ev); err != nil {
return 0, err
}
if _, err := tx.ExecContext(ctx, `UPDATE outbox SET status='published' WHERE id=?`, ev.ID); err != nil {
return 0, err
}
}
return len(batch), tx.Commit()
}
func DemoDDL() string {
return `
CREATE TABLE IF NOT EXISTS orders (
id VARCHAR(64) PRIMARY KEY,
status VARCHAR(32) NOT NULL
);
CREATE TABLE IF NOT EXISTS outbox (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_outbox_pending (status, id)
);`
}
工程清单:
- Relay 进程要独立扩容,与 API 进程分离;发布失败应退避重试并将多次失败送入死信队列人工处理。
- 消息体应携带
event_id/aggregate_id/causation_id,与消费者表上的唯一约束联合实现端到端幂等。 - 与第 38 章「事件发布」衔接时,把事件契约(JSON Schema / Protobuf)纳入 CI,避免「字段悄悄改名」造成投影脏写。
Outbox 运维要点:pending 堆积通常不是 Kafka 坏了,而是 Relay 吞吐不足、DB 锁竞争、或下游拒绝消息。建议把以下指标做成仪表盘:outbox_pending_count、relay_lag_seconds、publish_fail_rate、dead_letter_count。出现持续堆积时,优先扩容 Relay 与检查热点 aggregate_id(大单事件风暴)。
顺序投递 vs 批量投递:某些支付相关事件需要严格顺序,Relay 可以按 aggregate_id 分区串行投递;而搜索增量可批量合并,降低总线开销。关键是不要把「所有事件都塞进一个全局顺序」里,否则系统吞吐会被最慢的消费者绑架。
与第 1 章 Outbox 小节的关系:第 1 章强调「领域事件异步化」的动机与边界;本章补齐 实现骨架、时序与运维指标,便于你在后续中间件与实战章节落地到具体服务时直接对照检查清单。
幂等性设计通用方案
幂等性的本质
幂等性回答的问题是:同一个业务意图被执行多次,是否与只执行一次等价。分布式系统里重复来源包括:用户双击、网关重试、消息重复投递、回调重放。
从接口语义上,幂等还应区分两类返回:
- 语义幂等:第二次调用返回与第一次业务等价的结果(可能 HTTP 状态码不同,但业务码一致),典型是支付创建。
- 严格幂等:第二次调用应尽可能返回同一响应体(含错误),以便客户端无需分支处理;这通常依赖网关或应用侧的 响应缓存。
另一个关键维度是 时间窗口:创单幂等键可能只需 24 小时;支付幂等键可能要跨结算周期。窗口外的重复请求应被明确拒绝还是进入人工?这属于产品策略,但必须在技术方案里写死,否则会出现「以为幂等永远有效」的误用。
实现策略
| 策略 | 适用 | 注意 |
|---|---|---|
| 天然幂等 | SET status='CANCELLED' WHERE id=? AND status='PAID' | 仍需防止错误状态迁移 |
| 业务幂等键 | 支付、创单、退款 | 需要落库索引与 TTL 治理 |
| 令牌桶 / 去重表 | 高并发写 | 定期归档,冷热分离 |
| 唯一约束 | DB 层最终防线 | 冲突即视为重复成功需返回同一结果 |
组合策略才是常态:接口层挡住「明显重复」;服务层用状态机挡住「非法重放」;数据库用唯一约束挡住「并发双插」。任何单层都可能被绕过(例如内部任务不经过网关),因此不要迷信「只加 Header 就安全」。
测试清单:至少覆盖「并发双请求同一幂等键」「第一次超时后重试」「第一次失败第二次成功」「消息重复投递」四类用例;支付与退款还要覆盖「渠道侧已成功但平台超时」的对称场景(与支付相关章节联动)。
各层的幂等性保证
flowchart TB
subgraph Edge[接入层]
GW[API Gateway\nIdempotency-Key 透传 / 快速去重]
end
subgraph App[应用服务层]
SVC[Service\n幂等表 / 状态机守卫]
end
subgraph Data[数据层]
DB[(MySQL UNIQUE\n业务键 / 请求键)]
MQ[消息消费者\n消费位点 + 业务唯一键]
end
Client[Client] --> GW --> SVC --> DB
BUS[(Kafka)] --> MQ --> SVC
接口层示例:幂等键落库:
type IdempotencyStore interface {
// TryBegin 返回 true 表示首次;false 表示重复,应返回缓存响应
TryBegin(ctx context.Context, key, route string) (bool, error)
SaveResponse(ctx context.Context, key string, code int, body []byte) error
GetResponse(ctx context.Context, key string) (code int, body []byte, found bool, err error)
}
func WithCreateOrderIdempotency(store IdempotencyStore, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("Idempotency-Key")
if key == "" {
http.Error(w, "missing Idempotency-Key", http.StatusBadRequest)
return
}
first, err := store.TryBegin(r.Context(), key, r.URL.Path)
if err != nil {
http.Error(w, "idempotency error", http.StatusInternalServerError)
return
}
if !first {
code, body, found, err := store.GetResponse(r.Context(), key)
if err != nil || !found {
http.Error(w, "duplicate without cached response", http.StatusConflict)
return
}
w.WriteHeader(code)
_, _ = w.Write(body)
return
}
// 实现时需要包装 ResponseWriter 捕获状态码与 body,成功后 SaveResponse
next.ServeHTTP(w, r)
})
}
服务层:把幂等键与业务键(order_id、payment_id)建立映射;对「处理中」状态设置合理超时,避免永久悬挂。
数据层:对 merchant_order_no、channel_trade_no 等建立 UNIQUE 索引;冲突时读取已有行并返回与首次一致的结果体(支付场景极其关键,见支付相关章节)。
消息消费者层:除了业务唯一键,还要注意 at-least-once 带来的「处理成功但 ack 前崩溃」重复。常见做法是:业务写入与「消费记录」同事务,或采用 幂等表 + 业务状态机 组合。Kafka 的幂等 producer 只能保证生产端不重复,不能保证消费端。
与第 13–15 章的衔接:购物车提交、结算创单、支付创建与回调,是幂等设计最密集的区域。请把这些链路里的 Idempotency-Key 来源、TTL、冲突返回码统一成一份「交易接口规范」,否则每个团队会发明一种错误语义,客户端与对账都会被拖垮。
数据一致性保证
最终一致性
最终一致性不是「暂时不一致然后祈祷」,而是满足三个条件:
- 收敛性:在无新写入时,系统应到达稳定态。
- 可观测性:能度量漂移(延迟、差异条数)。
- 可修复性:通过对账与补偿把漂移拉回业务可接受范围。
最终一致的工程含义:允许短暂不一致,但不允许「永远不一致」。因此必须定义 最大允许漂移时间(MTTD) 与 修复时限(MTTR) 的业务含义。例如「支付回调最多延迟 5 分钟,对账必须在 T+1 日内闭环」,这类指标比对程序员说「我们最终一致」更有约束力。
与缓存的关系:读路径缓存(商品详情、列表价)引入的是另一类一致性。原则是:写路径更新权威,再异步失效 / 刷新缓存;不要反过来用缓存驱动写。库存热路径若使用 Redis,必须把 对账与回补 当作一等能力,而不是事后补丁。
对账机制
对账回答的问题是:两份账本是否在说同一件事。电商常见对账维度:
- 平台订单 vs 支付渠道:金额、手续费、状态、退款。
- 库存流水 vs 实物出库:WMS / OMS 对齐。
- 营销预算 vs 实际核销:防止薅羊毛与预算透支。
设计要点(与库存、支付相关章节呼应):
- 对账文件与解析:渠道侧日终文件 + 平台侧流水导出;解析必须版本化。
- 三层匹配:长款(渠道有平台无)、短款(平台有渠道无)、金额不一致。
- 差错工单:自动修复仅限白名单场景;其余进入人工复核。
- 幂等与回放:同一对账批次重复跑不产生重复账务分录。
对账批次生命周期(建议):INIT → PARSED → MATCHED → DIFF_GENERATED → FIXUP_APPLIED → CLOSED。每一步落审计日志,支持监管问询与内部复盘。短款与长款不要混在一个工单模板里:短款更像「钱可能丢了」,长款更像「重复记账风险」,处理 SLA 与审批链往往不同。
平台侧数据准备:对账不只读「业务库」,还要聚合 渠道回调日志、网关请求日志、消息投递记录。否则你会出现「业务状态对,但财务凭证缺角」的尴尬。实践中常用 不可变事件流水 作为对账输入之一,因为它比业务表更抗「事后改字段」。
与库存对账的衔接:库存侧常见是 Redis 计数与 MySQL 流水、供应商快照三方对齐。支付侧则是平台支付单与渠道清算文件对齐。两者共享同一套工程套路:差异分类 → 自动白名单修复 → 人工复核 → 复盘入库。
package recon
import (
"context"
"database/sql"
"time"
)
type ChannelRow struct {
TradeNo string
AmountCents int64
Status string
OccurredAt time.Time
}
type PlatformRow struct {
PaymentID string
ChannelRef string
AmountCents int64
Status string
}
// ReconcileBatch 演示:以 channel_ref 对齐(生产需处理多币种、多清算周期)
func ReconcileBatch(ctx context.Context, db *sql.DB, ch []ChannelRow, pf []PlatformRow) error {
pidx := map[string]PlatformRow{}
for _, p := range pf {
pidx[p.ChannelRef] = p
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
for _, c := range ch {
p, ok := pidx[c.TradeNo]
if !ok {
_, _ = tx.ExecContext(ctx, `INSERT INTO recon_diff(batch_at, kind, ref, detail) VALUES(?,?,?,?)`,
time.Now().UTC(), "SHORT_PLATFORM", c.TradeNo, "channel has, platform missing")
continue
}
if p.AmountCents != c.AmountCents || p.Status != c.Status {
_, _ = tx.ExecContext(ctx, `INSERT INTO recon_diff(batch_at, kind, ref, detail) VALUES(?,?,?,?)`,
time.Now().UTC(), "MISMATCH", c.TradeNo, "amount or status")
}
}
return tx.Commit()
}
差错分类与处理策略(支付对账视角的抽象):无论渠道是微信、支付宝还是银行卡收单,差异最终都会落到有限几类。
- 状态不一致但金额一致:常见于回调丢失或延迟。处理上优先以渠道终态为准触发状态迁移,并确保迁移动作幂等。
- 金额不一致:高风险,通常需要冻结相关支付单与关联订单,禁止自动发货,进入财务复核;同时要回溯是否存在重复退款、重复记账或币种转换错误。
- 手续费 / 分账字段不一致:多见于规则变更窗口与历史数据混跑。处理上应引入 规则版本号 与 生效时间,避免用新规则解释旧交易。
- 时间窗口不一致:渠道文件是「清算日」,平台流水是「交易发生日」。对账前要先把口径写到 SOP:以哪个时区、哪个切分点为准。
自动化修复的边界:只有满足「可逆、可证明、可回放」三条件的动作才适合自动化。例如「补写缺失的支付回调记录」可以自动化;「直接给用户退款」通常需要更高等级审批与多重校验。自动化的目标是减少人工 机械劳动,不是替代 风险判断。
补偿任务
补偿任务与 Saga 补偿不同:后者在请求生命周期内;前者是异步修复器,用于:
- 回调迟到导致的悬挂单;
- 消息堆积造成的投影落后;
- 对账发现的轻微差异批量冲正。
设计清单:可重入、批大小上限、死信隔离、人工止血的开关。
任务编排建议:补偿任务尽量 幂等、可观测、可暂停。出现大面积渠道故障时,最危险的是「自动修复脚本跑得比人还快」,把差错扩散成二次事故。要有 全局开关 + 分渠道开关 + 最大自动修复笔数阈值。
与支付补偿的差异:Saga 补偿发生在用户请求上下文内,强调快速失败与回滚;异步补偿任务发生在分钟到小时级窗口,强调批处理、限流与审计。两者不要混用同一套重试策略。
案例化走读:支付回调迟到(与支付相关章节呼应):用户支付成功,渠道回调因网络抖动晚到 10 分钟。期间订单可能停留在「待支付」,客服系统可能提示用户重复支付。补偿任务不应「直接改状态为成功」了事,而应执行一条可审计的状态迁移:WAIT_PAY → PAID,并触发 履约消息、发票消息、积分入账 等下游;每一步仍要带幂等键,避免回调重复造成重复履约。
案例化走读:库存 Redis 与 MySQL 漂移(与库存侧实践呼应):热路径扣减在 Redis,权威在 MySQL。对账发现 Redis 小于 MySQL 的可用量,可能意味着回补丢失;反过来可能意味着 Redis 多扣。修复策略应区分「业务可自动纠正」与「需要冻结 SKU 人工介入」。自动纠正必须带 上限 与 来源证据(流水号、操作者、任务批次),否则会把数据修复变成新的数据破坏源。
集成模式总结
同步调用模式
- 适用:强实时、需要立即失败反馈(试算、库存预占校验)。
- 要点:超时、重试、熔断、幂等键、向后兼容的 API 版本。
常见反模式:把十几个同步调用串成「上帝编排」,任何一个下游抖动都会放大尾延迟;没有 bulkhead(舱壁) 时,还会出现「支付抖动拖垮创单」的级联故障。治理手段包括:并发化可并行步骤、硬超时 + 部分降级、把非关键校验挪到异步。
接口演进:同步集成最怕破坏性变更。建议强制 version 字段或 URL 版本,并在网关层做 灰度路由;同时给客户端明确的 错误码字典(业务拒绝 vs 基础设施失败),否则重试风暴不可避免。
异步消息模式
- 适用:解耦峰值、跨团队广播事实、最终一致投影。
- 要点:Outbox、消费者幂等、严格有序 vs 并行的权衡、死信队列。
典型反模式:业务先写库再「顺手发 Kafka」,崩溃窗口会导致消息丢失;或消费者不做幂等,靠「应该不会重复」的侥幸心理。另一个反模式是 把异步当同步用:通过轮询消息结果阻塞用户请求,这会把消息系统的延迟特性原封不动搬进关键路径。
观测性:异步链路必须能回答三个问题:消息发出去了吗、消息被处理了吗、处理正确吗。分别对应 Outbox 状态、消费者 lag、对账差异。
数据同步模式
- CDC / Binlog 订阅:近实时同步到数仓或搜索;关注 schema 变更治理。
- 定时批量:对账、报表、冷数据归档。
- 双写:高风险,仅在迁移窗口短期使用,需校验任务护航。
CDC 的边界:它擅长复制「事实行变更」,但不自动复制「业务含义」。例如拆表、改主键、把枚举从字符串改成数字,都会让下游投影误读。需要 契约变更流程 与 双读双写过渡期。
定时批量的价值:很多一致性不是实时问题,而是「日终必须平」。批量任务的关键是 可重跑、可分段、可限流,并在大促日提前做 容量演练。
选型决策树
flowchart TD
Q1{需要立即知道\n下游成功与否?}
Q1 -->|是| Q2{失败是否必须\n阻断用户?}
Q1 -->|否| M[异步消息 +\nOutbox / 消费者幂等]
Q2 -->|是| S[同步 RPC\n+ 超时 / 熔断 / 幂等键]
Q2 -->|否| Q3{是否可以接受\n秒级最终一致?}
Q3 -->|是| M
Q3 -->|否| S
S --> Q4{是否广播给\n多个订阅方?}
Q4 -->|是| HY[同步拿到关键凭证\n+ 异步事件分发读模型]
Q4 -->|否| S
M --> Q5{是否需要强审计\n可回放?}
Q5 -->|是| ES[评估事件溯源 /\n不可变日志]
Q5 -->|否| P[物化视图 +\n对账修复]
style S fill:#e3f2fd
style M fill:#e8f5e9
style HY fill:#fff3e0
style ES fill:#f3e5f5
style P fill:#eceff1
如何使用决策树(避免误用):决策树的每个叶子都不是「唯一正确答案」,而是默认起点。真实系统往往处在叶子之间的灰区:例如创单需要同步拿到 price_snapshot_id,但搜索索引更新可以异步。灰区的处理原则是:把「用户当下要看到的结果」留在同步路径,把「世界最终会知道的结果」放到异步路径,并用对账兜底。
与实时性相关的常见误判:团队容易把「运营后台要立即看到」误认为「用户主链路必须同步」。后台可采用 近实时 CDC + 物化视图,而用户侧主链路仍应保持最小同步半径。把后台需求塞进核心交易链路,是尾延迟与大促故障的高频来源。
与后续章节的关系(阅读地图):
- 商品、搜索、推荐:AP + 异步投影为主,强调延迟可观测(第 7、12 章)。
- 库存、营销:同步预占 / 锁定 + 异步对账(第 8、9 章)。
- 计价、购物车:读路径可弱一致;写路径谨慎用缓存(第 11、13 章)。
- 订单、支付:幂等 + 对账 + 补偿任务三位一体的资金安全网(第 14、15 章)。
本章小结
本章建立了系统集成的方法论「四件套」:
- CAP 与一致性谱系帮助你在分区现实下做可解释的取舍,而不是用「都要」掩盖矛盾。
- Saga(编排优先)给出跨服务长流程的工程主路径,补偿必须可逆且幂等。
- 事件驱动 + Outbox 把「写库再发消息」变成可验证的本地事务,为 CQRS 投影与搜索增量提供底座。
- 幂等与对账 是分布式世界的安全带:前者防重复,后者治漂移;补偿任务负责把系统从边角态拉回主航道。
落地检查清单(建议你复制到评审模板):
- 每个跨服务写链路是否写明 一致性级别(用户可见 / 财务 / 读模型)?
- 是否存在「先外部成功、后本地提交」的窗口?若有,是否有 query/reconcile?
- 关键接口是否具备 幂等键 与 冲突返回语义?
- 是否避免「双写」作为长期方案?若必须双写,是否有 校验任务?
- 事件是否走 Outbox?Relay 是否有 堆积告警?
- 是否定义 对账批次 与 差错分级?自动修复是否有 阈值与开关?
- Saga / 异步任务是否 可重入?是否能在发布滚动中恢复?
给团队负责人的一句话建议:把「集成复杂度」当作与「业务复杂度」并列的成本项。没有 Outbox、没有对账、没有幂等键的系统也能上线,但它会把成本推迟到 大促夜、监管审计、渠道切流 这些最难的时刻一次性兑现。本章的目的,是把这部分成本前移为 可评审、可测试、可监控 的工程资产。
给一线开发者的一句话建议:写跨服务调用时,默认网络会超时、消息会重复、回调会迟到;把这三条写进单元测试与集成测试的假设里,比写一百行防御性注释更有用。
带着这套语言进入后续可靠性章节和电商实战篇,你会更容易判断:此处该同步还是异步、事件应不应该广播、失败该当场回滚还是记账异步修。
3.5 架构质量保障
为什么需要分阶段评审
软件工程里有一句常被引用的话:好的代码是重构出来的,不是一次写出来的。初稿几乎必然欠打磨,真正可靠的质量来自持续、有纪律的迭代。Code Review 把这种迭代前移到合并之前——它把个人习惯拉平到团队标准,把隐性知识显性化,把缺陷拦截在扩散之前。
然而,「随便看看」式的评审往往流于表面:有人只看风格,有人只看有没有明显 bug,有人被 diff 的噪声淹没。结果是:架构层面的失误晚到无法廉价修正,设计层面的模糊在代码里被放大成技术债,上线前才发现性能或可观测性缺口。
单次 PR 评审的认知陷阱
| 陷阱 | 典型表现 | 后果 |
|---|---|---|
| 问题域混杂 | 在讨论 SQL 索引时顺便「拍板」限界上下文 | 决策缺少干系人与记录,后续反复 |
| 噪声淹没信号 | 2000 行 MR 里找架构问题 | 高风险项被 style nitpick 挤出注意力 |
| 缺少外部脚手架 | 依赖评审者当天状态 | 遗漏与团队经验强相关,不可复制 |
Checklist 的价值在于降低认知负荷:在疲劳、时间压力或上下文切换时,仍有一个外部脚手架防止遗漏。它并不替代经验与判断力——遇到清单未覆盖的灰区,恰恰说明团队应该把新教训反哺进清单或 ADR(Architecture Decision Record)。
四阶段评审:在正确时机问正确问题
本书建议按四个阶段组织评审,而不是在单次 PR 里眉毛胡子一把抓:
- 架构评审:新项目、新服务、新子域或大规模模块拆分——确认分层、边界、读写路径与技术选型。
- 设计评审:接口与模型冻结前——核对聚合、命令 / 查询、领域事件与模式选型是否与领域一致。
- 代码评审:日常 MR——用 SOLID、函数质量、命名、错误处理与依赖方向守住实现细节。
- 上线前检查:发布窗口——补齐性能、并发、可观测性、测试、回滚与文档。
flowchart LR A[架构评审<br/>设计期] --> B[设计评审<br/>详设期] B --> C[代码评审<br/>MR 期] C --> D[上线前检查<br/>合并期] D --> E[发布 / 观测 / 复盘]
运作建议:让清单「活」起来
- 责任人明确:架构项由 Tech Lead / 架构负责人主评;设计项由领域 Owner 主评;PR 项由作者与至少一名熟悉该域的审阅者共担;上线前项与 SRE / On-call 对齐。
- 粒度分层:巨型 MR 可先要求作者附「自审清单」勾选说明,再在评论里对争议点逐条引用章节编号,避免无结构的「感觉不对」。
- 与工具链结合:复杂度、静态检查、依赖图、覆盖率门槛作为门禁;清单作为人工语义层补充(例如:覆盖率够了但测的是 happy path,仍需人眼过业务不变量)。
- 可追溯结论:架构与设计阶段的结论落在 ADR、RFC 或设计文档;Code Review 只核对「实现是否背离结论」。PR 中发现架构级问题应上升到设计讨论,而不是在局部 hack 里修掉症状。
团队实践案例
案例 1:架构评审会的标准流程(某电商团队实践)
时机:新服务立项、重大重构(影响 3+ 服务)、技术选型变更
参与者:
- 必需:Tech Lead、系统负责人、相关团队代表
- 可选:SRE(高可用关注)、DBA(存储关注)、安全(合规关注)
流程(60 分钟):
- 背景介绍(5分钟):系统负责人讲解业务背景、问题域、核心挑战
- 架构方案宣讲(15分钟):分层、限界上下文、读写路径、技术选型
- 质疑与讨论(30分钟):按 7.2 清单逐项检查,重点追问:
- 依赖方向是否违反?
- 边界划分是否合理?(参考本章战略设计)
- 读写比假设是否量化?
- YAGNI 检查:是否过度设计?
- 决策与记录(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 聚合:履约记录(独立生命周期)
- 集成:通过领域事件(
OrderPlaced、OrderPaid)衔接
收益:订单聚合从平均 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 ...")
}
处理流程:
- 识别问题:违反 7.2.1 依赖方向检查
- 上升讨论:在 PR 中标记
needs-architecture-review - 解决方案:
- 定义应用层用例:
PlaceOrderUseCase - 定义领域层端口:
OrderRepository - Handler 只依赖应用层
- 定义应用层用例:
- 后续:将此类问题补充到团队的「评审反模式」文档
经验:当 PR 中出现架构级违规时,不要在代码层面修修补补,而是叫停并重新设计。短期看延迟了交付,长期避免了技术债累积。
何时进入哪一阶段:决策树
flowchart TD
start([变更进入评审]) --> q1{是否改变系统边界<br/>或核心数据流?}
q1 -->|是| arch[必须先过架构评审<br/>必要时更新 ADR]
q1 -->|否| q2{是否改变聚合 / 事件契约<br/>或对外 API 语义?}
q2 -->|是| design[设计评审 + 契约评审]
q2 -->|否| code[代码评审 MR]
arch --> design
design --> code
code --> q3{是否进入发布窗口<br/>或影响关键路径 SLO?}
q3 -->|是| ship[上线前检查]
q3 -->|否| merge[合并后持续观测]
ship --> merge
架构评审阶段
适用时机:立项、新服务、新子域或大规模模块拆分。目标是在写大量代码之前,把分层、边界、一致性、读写特征与技术选型对齐。
分层结构检查
标准:是否明确定义 Domain / Application / Adapter / Infrastructure(或等价四层)?源代码依赖是否一律指向内层(Domain 为最内),外层通过接口向内依赖?
反例(违反依赖方向):HTTP Handler 直接 import 具体 MySQL 驱动或 ORM 包,绕过应用服务与领域端口。
// BAD: handler depends on concrete DB package
import "github.com/org/repo/infra/mysql"
func HandlePlaceOrder(w http.ResponseWriter, r *http.Request) {
db := mysql.Default()
_, _ = db.ExecContext(r.Context(), "INSERT INTO orders ...")
}
合规方向:Handler 只依赖应用层用例;持久化通过 Repository 接口在领域或应用边界声明,由 Infra 实现。
// GOOD: handler -> application port -> domain; infra implements port
type PlaceOrderHandler struct {
App *application.OrderService
}
func (h *PlaceOrderHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
cmd, err := decodePlaceOrder(r)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := h.App.PlaceOrder(r.Context(), cmd); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
w.WriteHeader(http.StatusCreated)
}
| 检查点 | 通过标准 | 常见反模式 |
|---|---|---|
| 依赖方向 | domain 不引用 adapter / infra | Handler 内写 SQL |
| 端口归属 | Repository 接口由内层拥有 | 接口定义在 infra 被 domain 引用 |
| 组装根 | main / cmd 完成绑定 | 在领域 New* 里创建具体 DB |
评审追问:若团队暂时未引入完整四层,是否至少在包级约定 adapter 不得被 domain import,并在 CI 用 grep / 自定义 linter 守护?
限界上下文验证
标准:是否识别 核心域、支撑域、通用域?每个 BC 是否有清晰的 Ubiquitous Language 与对外契约(API / 事件),避免「一个大而全的领域模型」?
反例:订单子域与库存子域共用同一个 Product 结构体,字段含义在两边互相拉扯。
// BAD: one struct serves two contexts with conflicting meanings
type Product struct {
ID string
Title string
PriceCent int64 // pricing in order context
WarehouseQty int // stock in inventory context — coupling contexts
}
合规 sketch:不同 BC 使用不同模型与防腐层翻译;集成通过 API、消息或显式 ACL。
// GOOD: separate models + explicit mapping at boundary
type catalog.ProductView struct{ ID, Title string }
type ordering.OrderLine struct {
ProductID string
UnitPrice Money
SnapshotTitle string
}
type inventory.StockUnit struct {
SKU string
OnHand int
}
| 检查点 | 通过标准 | 评审问题 |
|---|---|---|
| 模型隔离 | 各 BC 有独立类型与映射层 | 是否共享「富模型」而非仅 ID? |
| 契约稳定 | 对外 API / 事件有版本与兼容性策略 | 破坏性变更如何灰度? |
| 语言一致 | docs/glossary.md 或等价物 | Customer 与 User 是否混用? |
读写路径分析
标准:是否量化 读写比、延迟与一致性要求?读路径若存在重 JOIN、宽表、复杂筛选,是否考虑 独立读模型 / 投影,而不是全部堆在写模型上?
反例:在命令路径(下单)同步执行多表 JOIN 报表查询,拖慢写入尾延迟。
// BAD: command handler does heavy read for side UI
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) error {
_ = s.db.QueryRowContext(ctx, `SELECT ... heavy join for dashboard ...`)
return s.persistOrder(ctx, cmd)
}
// GOOD: split; async projection or query DB
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) error {
if err := s.orders.Save(ctx, newOrderFrom(cmd)); err != nil {
return err
}
return s.outbox.Publish(ctx, OrderPlaced{OrderID: cmd.IdempotencyKey})
}
| 检查点 | 通过标准 |
|---|---|
| 写路径 | 只持久化命令所需最小一致性数据 |
| 读路径 | 物化视图、搜索索引或专用查询服务 |
| 指标 | 是否测量 p99 写延迟 与 读 QPS |
评审追问:若读是写的两个数量级以上,独立读模型往往是经济解(与第 1 章 CQRS 呼应)。
技术选型审查
标准:存储与中间件是否与 访问模式 匹配(点查、范围扫、全文检索、图关系、流处理)?是否记录选型假设与回退方案?
| 维度 | 评审问题 |
|---|---|
| 数据量与热点 | 预估行数、分区键、热点键 |
| 一致性 | 强一致 / 最终一致是否与业务容忍度一致 |
| 运维成本 | 备份、多 AZ、升级窗口 |
| 合规 | 留存周期、脱敏、跨地域 |
反例:全文搜索需求用 MySQL LIKE '%keyword%' 扛流量,缺少倒排索引与相关性能力。
过度设计与 YAGNI(纳入技术选型同一关口)
标准:是否仅为已确认的变更点引入抽象?能否用更简单的模型先交付,再演化?
反例:典型 CRUD 后台强行上 DDD + CQRS + Event Sourcing 全家桶,团队无力维护投影与版本化事件。
评审追问:若去掉 Event Sourcing,业务是否仍成立?若答案是肯定的,则 ES 很可能是可选优化而非当前必需。CQRS 是否由观测到的读写不对称驱动,而不是由「流行架构标签」驱动?
设计评审阶段
适用时机:接口评审、领域模型评审、用例与事件清单冻结前。目标是让 战术设计(聚合、Repo、Command / Query、事件)与战略分层一致。
聚合边界检查
标准:一致性边界是否以聚合为单位设计?是否避免在单个事务中强行修改多个聚合根,除非有显式的领域规则与补偿策略?
反例:一个数据库事务内同时更新 Order 与 Inventory 聚合,绕过领域事件与最终一致性。
// BAD: one transaction mutates two aggregates directly
func SaveOrderAndDeductStock(ctx context.Context, tx *sql.Tx, o *Order, inv *Inventory) error {
if err := persistOrder(tx, o); err != nil {
return err
}
inv.Quantity -= o.LineItems[0].Qty
return persistInventory(tx, inv)
}
| 检查点 | 通过标准 |
|---|---|
| 聚合根入口 | 外部只能通过根修改状态 |
| 一事务一根 | 跨聚合协作走事件 + 最终一致(或已文档化的 Saga) |
| 暴露集合 | 不返回可变内部 slice 引用 |
聚合根识别补充:外部代码禁止绕过根直接改内部实体(如导出 []*OrderLine 被外部改 Qty)。若根方法数量爆炸,区分是聚合过大还是缺少领域服务。
实体与值对象(本小节一并核对):实体有稳定标识、状态变更走受控方法;值对象不可变、按值相等;Money 等禁止提供可变 setter,对外构造函数保证合法组合。
命令查询分离验证
Command 设计
标准:命令是否表达 业务意图(PlaceOrder、CancelSubscription),而不是贫血 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
}
领域事件设计
标准:关键业务状态变更是否发布 领域事件?命名是否使用 过去式(OrderPlaced、PaymentCaptured)并携带必要上下文(版本、发生时间)?
// BAD: imperative name
type PlaceOrder struct{ OrderID string }
// GOOD: past tense, domain vocabulary
type OrderPlaced struct {
OrderID string
OccurredAt time.Time
Version int
}
| 检查点 | 通过标准 |
|---|---|
| 命名 | 过去式 + 领域词汇 |
| 载荷 | 消费者演进所需字段(版本、关联 ID) |
| 投递 | Outbox / 至少一次 + 消费者幂等 |
模式选型审查
详设阶段可快速对照下表,避免「每个地方都 if-else」或「每个地方都上框架」。
| 场景特征 | 推荐模式 | 说明 |
|---|---|---|
| 多步骤顺序流程 | Pipeline(管道) | 与第 2 章 Pipeline 呼应 |
| 同一接口多种实现 | 策略模式 | 扩展点清晰 |
| 频繁变化的业务规则 | 规则引擎 / 规则表驱动 | 需版本化与评审 |
| 跨聚合协作 | 领域事件 + Outbox | 与第 1 章 Outbox 呼应 |
反例:全系统统一 RuleEngine.Execute(ctx, ruleSetID, facts),但规则集无人版本化与评审,线上等于「可执行的配置漂移」。
合规:规则变更走 PR + 审计 + 影子流量;核心不变量仍保留在代码与单测中,引擎只编排可变的参数化策略。
代码评审阶段
适用时机:每次合并请求。把设计约束落到 Go 代码的可观察性质上。
SOLID 原则检查
对每一项,用「一句检查问句」把握核心;争议点再用第 1 章分层与端口对齐。
| 原则 | 检查问句 | 典型反例 | 合规方向 |
|---|---|---|---|
| S | 该类型是否只有一个变化理由? | OrderService 又发邮件又导 CSV | 按职责拆服务 |
| O | 扩展新行为是否无需改稳定路径? | switch payment 无限增长 | PaymentGateway 接口 + 多实现 |
| L | 实现是否可替换且不 surprise? | Charge 静默成功 | 显式 Fake / 诚实错误 |
| I | 客户端是否不被迫依赖不需要的方法? | Storage 胖接口 | Reader / Writer 隔离 |
| D | 高层是否依赖抽象? | NewApp 内 sql.Open | 构造注入 Repository |
DIP 延伸——包级依赖方向:domain 不 import adapter / infra;application 不直接引用 HTTP、ORM、消息 SDK;无循环依赖(必要时提取 domain/sharedkernel 最小类型)。go list -deps 或 IDE 依赖图可抽查。
LSP 反例 sketch:
// BAD: implementation surprises caller
type NoOpPaymentGateway struct{}
func (NoOpPaymentGateway) Charge(ctx context.Context, amount int64) error {
return nil // silently skips payment
}
函数质量审查
| 维度 | 阈值 / 标准 | 工具或手段 |
|---|---|---|
| 长度 | 单函数宜 < 80 行 | 拆私有步骤或 Pipeline 阶段 |
| 圈复杂度 | < 10(团队可校准) | golangci-lint / gocyclo |
| 嵌套深度 | < 3 | Guard clause 早返回 |
| 参数个数 | < 5 | Options 结构体或 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 边界函数,而不是塞进结构体字段隐式携带?
命名与可读性
- 变量 / 函数名反映业务术语:名称来自 Ubiquitous Language,而非数据库列名机械翻译。
- 团队内一致:同一概念只有一个词(
CustomervsUser要治理)。 - 避免技术术语代替业务术语:不用
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
}
错误处理验证
- 禁止静默忽略错误:是否存在
_ = xxx或空白if err != nil { }? - 错误 wrap 携带上下文:跨层
fmt.Errorf("place order: %w", err)。 - 区分业务错误与系统错误:调用方能否区分「库存不足」与「应重试的基础设施错误」?
var ErrOutOfStock = errors.New("out of stock")
func (s *InventoryService) Reserve(ctx context.Context, sku string, qty int) error {
if qty > available(sku) {
return fmt.Errorf("reserve %s: %w", sku, ErrOutOfStock)
}
return nil
}
DDD 战术与聚合不变量(与错误语义一并核对)
聚合不变量 sketch:
func (o *Order) AddLine(sku string, qty int, unitCent int64) error {
if qty <= 0 {
return ErrInvalidQty
}
if o.status != StatusDraft {
return ErrOrderNotEditable
}
lineTotal := unitCent * int64(qty)
if lineTotal < 0 {
return ErrOverflow
}
o.lines = append(o.lines, OrderLine{SKU: sku, Qty: qty, UnitCent: unitCent})
o.totalCent += lineTotal
return nil
}
上线前检查
适用时机:发布分支、灰度前、重大重构合并前。与功能完成度无关的「生产就绪」项在此收敛。
性能与并发
性能:
- 关键路径是否有 benchmark 或压测基线?
- 是否关注 alloc/op、GC 停顿、锁竞争(
mutexprofile)? - 异步路径是否避免无界队列导致内存膨胀?
func BenchmarkPlaceOrder(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
// exercise hot path
}
}
并发安全:
// BAD: unsynchronized map writes
var cache = map[string]int{}
func Set(k string, v int) { go func() { cache[k] = v }() }
// GOOD: mutex or single-owner goroutine
type SafeCache struct {
mu sync.RWMutex
m map[string]int
}
| 检查点 | 通过标准 |
|---|---|
| 数据竞争 | go test -race 纳入 CI 或发布前门禁 |
| 泄漏 | 长测采样 NumGoroutine;channel 不阻塞在默认分支 |
| 锁内 I/O | 避免在持锁时调用慢外部依赖 |
可观测性
标准:metrics(RED / USE)、trace(关键 span)、结构化日志(request_id、order_id 等关联字段)。
logger.Info("order_placed",
"order_id", orderID,
"customer_id", customerID,
"duration_ms", elapsed.Milliseconds(),
)
| 检查点 | 通过标准 |
|---|---|
| 日志 | 键值字段可查询,而非仅拼接长句 |
| 链路 | 跨服务传播 trace 上下文 |
| SLO | 新路径有指标与告警阈值 |
测试覆盖
标准:核心业务规则覆盖率按团队约定(例如 > 80%);集成测试覆盖仓储、消息、外部 HTTP 的 fake / 容器。
| 检查点 | 通过标准 |
|---|---|
| 边界 | 表格驱动覆盖错误路径 |
| Flaky | 修复或隔离,避免 t.Skip 永久化 |
| 语义 | 覆盖不变量,而非仅「能跑通」 |
func TestPlaceOrder_OutOfStock(t *testing.T) {
t.Parallel()
// arrange: 0 stock -> expect ErrOutOfStock
}
回滚方案
标准:feature flag 或配置开关;数据库迁移可回滚或具备向前兼容的双写 / 双读;事件 schema 向后兼容或双写新字段。
回滚方案检查清单
| 维度 | 检查项 | 通过标准 |
|---|---|---|
| 代码回滚 | Feature Flag | 关键功能可通过配置开关禁用,无需重新发布 |
| 数据库迁移 | 双向脚本 | UP/DOWN 脚本齐全,测试过回滚流程 |
| 事件 Schema | 向后兼容 | 新增字段可选,旧消费者不受影响 |
| API 兼容性 | 版本策略 | 新版本 API 与旧版本共存,客户端可选升级 |
| 配置变更 | 灰度发布 | 配置分批推送,每批观察指标后再继续 |
| 依赖服务 | 降级预案 | 下游服务故障时,上游可降级(返回默认值/缓存) |
Feature Flag 实践
// 使用 Feature Flag 控制新功能
package order
import "context"
type FeatureFlags interface {
IsEnabled(ctx context.Context, feature string) bool
}
func (s *OrderService) PlaceOrder(ctx context.Context, cmd PlaceOrderCommand) error {
// 旧逻辑
if err := s.validateBasic(cmd); err != nil {
return err
}
// 新功能:风控检查(可通过 Feature Flag 关闭)
if s.flags.IsEnabled(ctx, "order.fraud_detection") {
if err := s.fraudDetector.Check(ctx, cmd); err != nil {
return err
}
}
return s.repo.Save(ctx, newOrderFrom(cmd))
}
收益:
- 新功能上线后发现问题,可立即关闭 Feature Flag,无需回滚代码
- 灰度发布:先对 5% 用户开启,观察指标后再逐步放量
- A/B 测试:对不同用户群开启不同策略,对比效果
文档与运维:架构变更(新 BC、事件契约、SLA)同步到 README / ADR / 运维手册;On-call 知道降级、重放消息、解读关键告警;新人能仅凭文档拉起本地依赖(docker-compose / make 目标)。
运维文档模板:
#### 服务运维手册
##### 关键告警
- `order_create_latency_p99 > 500ms`:订单创建延迟过高
- **可能原因**:数据库慢查询、库存服务超时
- **处理步骤**:
1. 查看 Grafana 面板确认瓶颈(DB/库存/计价)
2. 若库存服务超时,执行降级:`kubectl set env deployment/order INVENTORY_FALLBACK=true`
3. 通知库存团队排查
##### 降级开关
- `INVENTORY_FALLBACK=true`:库存查询降级,使用本地缓存
- `FRAUD_DETECTION=false`:关闭风控检查(紧急情况)
- `PROMOTION_ENABLED=false`:关闭营销试算(性能问题)
##### 回滚流程
1. 确认回滚目标版本:`kubectl rollout history deployment/order`
2. 执行回滚:`kubectl rollout undo deployment/order --to-revision=N`
3. 观察监控:关注错误率、延迟、上下游调用
4. 数据库回滚(如需要):执行 DOWN 脚本
本章小结
全阶段总览表(评审清单)
| 阶段 | 必查项(高杠杆) |
|---|---|
| 架构评审 | 依赖向内、BC 划分、聚合边界、读写评估、YAGNI |
| 设计评审 | 聚合根入口、值对象不可变、Repo 在领域层、Command 意图、领域事件 |
| 代码评审 | SRP、函数规模与复杂度、业务命名、错误 wrap、依赖方向 |
| 上线前 | Benchmark / 压测证据、并发与 race、可观测性、测试与集成、回滚与文档 |
MR 描述区模板(可复制)
#### Self review (author)
- [ ] 7.4 SOLID: 新类型职责与扩展点合理
- [ ] 7.4 函数长度 / 复杂度 / 嵌套 / 参数个数
- [ ] 7.4 命名与 glossary 一致
- [ ] 7.4 错误 wrap,无静默 `_ = err`
- [ ] 7.4 依赖方向与 DDD 战术(不变量、VO)
#### Release readiness (if applicable)
- [ ] 7.5 Benchmark 或压测链接
- [ ] 7.5 并发 / race 检查
- [ ] 7.5 Metrics + logs + traces
- [ ] 7.5 核心规则测试与集成测试
- [ ] 7.5 回滚 / 迁移 / 双写方案
- [ ] 7.5 文档 / ADR 更新
#### Design links
- ADR / RFC: ...
实战案例与反模式
案例 A:库存预占接口「顺手」改了聚合边界(设计评审失效)
背景:结算服务在「创单前预占」需求中,直接在订单聚合的事务内更新库存行,图省事。
症状:大促锁竞争升高;库存与订单发布节奏耦合,回滚困难。
处理:设计评审阶段强制改为 OrderPlaced / ReserveStockRequested 事件驱动或显式 Saga;代码评审拦截「双聚合同一事务」。
案例 B:营销规则 JSON 线上漂移(模式选型 + 运维失守)
背景:规则引擎读取未版本化的 JSON,运营后台可直接保存到生产。
症状:线上行为与测试环境不一致,难以复盘。
处理:规则集 版本号 + PR 审核 + 审计日志;核心不变量仍在单测与代码中;影子流量验证。
案例 C:Handler 直连 DB(架构评审后置到 PR)
背景:原型代码直接进入主干,后续 MR 只在 SQL 层修修补补。
症状:领域规则散落在 SQL;单测必须起库。
处理:上升架构评审,引入 端口 + 用例;本 MR 仅允许「垂直切片」式重构到合规结构,不接受继续堆 SQL。
案例 D:缺少性能测试导致的线上故障
背景:订单服务上线了「批量取消」功能,代码评审通过,但未做性能测试。
线上故障:
- 运营同学一次性取消 5000 个订单
- 服务在循环中逐个发送取消事件到 Kafka,耗时 30 秒
- 期间所有订单查询请求超时(共享同一个 goroutine 池)
- 用户投诉量激增
根因分析:
- 代码评审通过:功能逻辑正确,无明显bug
- 缺失上线前检查:没有性能测试,没有评估「批量场景下的资源占用」
改进方案:
- 补充 7.5.1 性能检查:批量操作必须有 Benchmark
- 异步化:批量取消改为后台任务,分批处理(每批 100 个)
- 限流:批量接口加频控,防止运营误操作
经验:代码评审通过≠生产就绪。上线前检查(7.5)是最后一道防线,必须覆盖性能、并发、可观测性。
案例 E:聚合不变量在 PR 中被破坏
背景:订单聚合有不变量「总价 = 各明细之和」,某次 PR 为了修复 bug,直接修改了 TotalAmount 字段。
代码变更:
// BAD: 直接修改总价,破坏不变量
func (o *Order) ApplyDiscount(amount int64) {
o.TotalAmount -= amount // ❌ 绕过了明细,破坏一致性
}
后果:
- 订单详情页显示的小计与总价不一致
- 财务对账时发现差异,追溯到这次变更
评审反思:
- 设计评审阶段应明确聚合不变量(7.3.1)
- 代码评审阶段应检查是否有直接修改聚合字段的行为
- 测试应覆盖不变量(如
assert(order.Total == sum(order.Lines)))
正确方案:
// GOOD: 通过明细修改,自动更新总价
func (o *Order) ApplyDiscountToLine(lineIndex int, discountAmount int64) error {
if lineIndex >= len(o.Lines) {
return ErrInvalidLineIndex
}
o.Lines[lineIndex].UnitPrice -= discountAmount
o.recalculateTotal() // 重新计算总价,保证不变量
return nil
}
func (o *Order) recalculateTotal() {
total := int64(0)
for _, line := range o.Lines {
total += line.UnitPrice * int64(line.Qty)
}
o.TotalAmount = total
}
案例 F:缺少回滚方案的数据库迁移
背景:库存服务需要新增字段 reserved_qty,开发者提交了 PR 包含数据库迁移脚本。
问题:
- 只有 UP 脚本,没有 DOWN 脚本(无法回滚)
- 没有双写策略:新代码直接依赖新字段,回滚时会报错
线上故障:
- 新版本上线后发现性能问题,需要回滚
- 回滚代码后,服务启动失败(读取不存在的字段)
- 被迫紧急修复:手动删除字段、重新上线旧版本
改进方案(7.5.4 回滚方案):
- 三阶段迁移:
- 阶段 1:加字段,代码双写(写新旧两个字段),读旧字段
- 阶段 2:代码切换为读新字段
- 阶段 3:删除旧字段
- 每阶段可独立回滚:任何一步出问题都能回到上一阶段
- UP/DOWN 脚本齐全:迁移工具(如 migrate)强制要求两个方向
评审清单补充:
- 数据库迁移是否有 DOWN 脚本?
- 新字段是否通过双写 / 双读策略引入?
- 回滚后服务是否仍能正常启动?
按角色的最小阅读路径
| 角色 | 建议优先阅读 |
|---|---|
| 作者(提 MR) | 7.4 全文 + 7.6.2 模板 |
| 审阅者(同域) | 7.4.3–7.4.5 + 与 7.3 冲突点 |
| Tech Lead(新模块) | 7.2、7.3 + 7.5 |
| SRE / On-call | 7.5 + 事件与迁移说明 |
核心要点
系统化的 Code Review 不是挑剔,而是把重构前移到成本最低的阶段。按 架构 → 设计 → 代码 → 上线前 四段清单推进,并与前文的方法论、战略设计、内部结构设计交叉引用,团队可以在一致语言下讨论分层、边界与实现细节。建议将 7.6.1 嵌入 MR 模板,并在复盘时根据失效案例增补第 21 条——最好的 Checklist 永远是活文档。
3.6 容量规划、压测、限流、熔断与降级
容量规划从业务量开始
容量估算不要从机器配置开始,而要从业务量开始。以电商系统为例,至少要估算:
- 日活用户、峰值活跃用户和高峰时间分布。
- 商品浏览、搜索、加购、结算、下单、支付的转化漏斗。
- 核心表日增量、历史数据保留周期和归档策略。
- 秒杀、促销、批量导入、供应商同步等非均匀流量。
- 失败重试、消息积压、补偿任务带来的额外流量。
一个常见误区是只估算正常请求 QPS,却忽略重试流量。下游服务抖动时,如果上游没有超时、退避和熔断,请求量会被重试放大,最终把原本局部故障扩散成全链路故障。
压测验证什么
压测不是为了得到一个漂亮的 QPS 数字,而是为了发现系统的边界。一次有价值的压测至少要回答:
| 问题 | 观察指标 |
|---|---|
| 单服务瓶颈在哪里? | CPU、内存、GC、连接池、线程池 |
| 数据库是否扛得住? | 慢 SQL、锁等待、连接数、主从延迟 |
| 缓存是否稳定? | 命中率、热点 Key、内存淘汰、网络延迟 |
| 消息系统是否积压? | 生产速率、消费速率、消费延迟、分区倾斜 |
| 降级策略是否生效? | 错误率、超时率、降级命中率、核心链路成功率 |
压测结果要沉淀成容量水位线,例如“订单创建链路在当前配置下安全水位为峰值 3000 QPS,超过后库存预占 P99 延迟明显上升”。没有水位线的压测,很难指导后续扩容和限流配置。
限流、熔断与降级
限流、熔断和降级经常被放在一起,但职责不同。
| 手段 | 触发条件 | 目标 |
|---|---|---|
| 限流 | 入口流量超过系统承载能力 | 防止系统被打穿 |
| 熔断 | 下游错误率或延迟持续恶化 | 防止故障扩散 |
| 降级 | 非核心能力影响核心链路 | 保住关键业务成功率 |
在电商场景中,商品详情页可以降级推荐模块,结算页可以降级非关键营销提示,但订单创建、库存预占和支付状态更新不能随意降级为“不处理”。降级设计必须区分核心链路和非核心链路。
容量保护的工程落点
容量保护不能只靠网关。成熟系统通常会在多个层次放保护:
- 入口层:按用户、IP、商家、活动、API 维度限流。
- 应用层:线程池隔离、连接池上限、超时和快速失败。
- 缓存层:热点 Key 保护、本地缓存、请求合并。
- 数据库层:慢 SQL 治理、读写分离、归档、分库分表。
- 消息层:消费者限速、批量消费、重试退避、DLQ。
- 运维层:容量水位告警、自动扩容、压测基线和演练。
这些措施的共同目标不是让系统永远不失败,而是让失败以可预期的方式发生,并优先保护最重要的业务结果。
面试中的表达方式
系统设计面试里谈容量和韧性,建议按“估算、瓶颈、保护、验证”四步表达:
- 先估算峰值流量和数据规模。
- 再指出最可能的瓶颈,例如数据库写入、库存热点、搜索查询或消息积压。
- 然后说明保护措施,包括缓存、异步、限流、熔断、降级和补偿。
- 最后补充如何压测、监控、告警和演练。
这样回答比直接说“加 Redis、上 MQ、做限流”更有说服力,因为它说明了每个手段对应的压力来源和失败后果。
3.7 本章小结
核心观点回顾
本章想传达的核心观点其实很简单:
- 软件架构面对的不是单一问题,而是一组会长期反复出现的复杂性挑战
- 架构师真正做的,不是“选择某一个流行概念”,而是按问题层次打一套组合拳
- 这套组合拳通常遵循固定顺序:先做业务边界,再建系统内部秩序,再处理系统间协作,最后通过代码原则和质量机制把设计守住
也就是说,架构设计的本质不是追逐术语,而是控制复杂性如何在系统中传播。
与后续章节的关系
这一章只是全书的总序,负责建立方法论地图。后续章节会沿着这条主线逐步展开:
- 本章的 3.2《业务边界与战略设计》部分:先讨论系统应该如何划清边界
- 本章的 3.3《系统内部结构设计》部分:讨论单个系统内部如何建立秩序
- 本章的 3.4《系统集成与一致性设计》部分:讨论多个系统之间如何协作
- 本章的 3.5《架构质量保障》部分:讨论如何用评审和验证机制守住架构质量
- 第 2 章《编码与 Code Review》:讨论架构如何真正落到代码与团队评审
读完这一章之后,你不必已经掌握所有细节,但应该先建立一个判断框架:当系统变复杂时,架构师会从哪些层次出手,又该如何把这些方法论组合起来。
延伸阅读
- Robert C. Martin, Clean Architecture: A Craftsman’s Guide to Software Structure and Design, 2017
- Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, 2003
- Vaughn Vernon, Implementing Domain-Driven Design, 2013
- Martin Fowler, CQRS Pattern
4. 软件工程绘图完全指南
从系统设计与 TD 方法论继续往前走,架构师还必须掌握另一项核心表达能力:把抽象的边界、链路、状态与决策画成不同角色都能快速理解的图。下面这部分内容整体保留原有写法,只调整标题层级,把它作为第 1 章的第三部分。
从图种基础到架构心法,建立软件工程师的绘图全栈能力。
3.1 软件工程绘图全景
3.1.1 画图是工程师的“第二语言“
写代码是对机器的表达,画图是对人的表达。一个只会写代码但不擅长画图的工程师,面对跨团队协作、技术评审、向上汇报时,沟通成本会指数级上升。
反过来说,一张好图能在 3 分钟内讲清 30 分钟白板都讲不清的事。这不是天赋,是技能——可以学,也必须学。
3.1.2 图的四象限分类体系
软件工程中需要画的图,可以归入四个维度。先建立这个分类框架,后面每学一种图种,都能找到它在框架中的位置:
| 维度 | 核心问题 | 典型图种 |
|---|---|---|
| 结构图 | 系统“有什么“?模块怎么划分? | 类图、ER 图、包图、组件图、部署图 |
| 行为图 | 系统“怎么动“?数据怎么流转? | 时序图、活动图、状态图、流程图 |
| 交互图 | “谁调了谁”?依赖方向是什么? | 组件图、集成图、数据流图 |
| 物理图 | “跑在哪里”?机器怎么连? | 部署拓扑图、网络拓扑图 |
简单记忆:结构图看盒子,行为图看时间线,交互图看箭头方向,物理图看机器。
3.1.3 图种误用的三种典型反模式
在进入具体画法之前,先看看最常见的三种“画错图“:
反模式一:用技术架构图向老板汇报。 框图里塞满 Kafka、Redis、Saga、DLQ,老板只想知道“你的系统能支撑什么业务,边界在哪“。结果:老板迷失,方案被否。
反模式二:画时序图不标注异常路径。 大多数时序图只画“正常流程“——A 调 B,B 调 C,返回 OK。真实系统里,超时怎么办?重试几次?降级到哪个兜底逻辑?不标注这些的时序图,等于没画。
反模式三:用一张图解释一切。 试图在一张图里塞进业务架构、技术架构、部署架构、数据流。结果所有人都不满意。一张图只解决一个视角的问题。
3.1.4 场景 → 图种速查表
遇到以下场景,查这张表就知道该画什么:
| 场景 | 首选图种 | 配合图种 |
|---|---|---|
| 需求澄清 & 用户故事 | 用例图 | 活动图(描述主流程) |
| 业务流程梳理 | 流程图 / 活动图 | 状态图(关键对象) |
| 接口设计 & 调用链 | 时序图 | — |
| 数据模型设计 | ER 图 | 类图(面向对象项目) |
| 领域建模(DDD) | 限界上下文图 | 时序图(验证依赖方向) |
| 架构评审(技术) | 技术治理图 + 核心时序图 | 部署拓扑图 |
| 架构评审(业务) | 全景功能架构图 | — |
| 故障复盘 | 故障传播时序图 | 恢复流程图 |
| 新人 Onboarding | 系统全景图 + 部署拓扑图 | 核心链路时序图 |
| 技术方案文档 | 4+1 视图套件 | — |
3.2 图种实战手册
每种图按统一模板讲解:一句话定义 → 何时用 → 核心要素 → 画法步骤 → 常见错误 → Mermaid 模板。
3.2.1 用例图(Use Case Diagram)
一句话定义:描述“谁“(Actor)能用系统“做什么“(Use Case)。
何时用:
- 项目启动阶段,与产品/业务方对齐功能范围
- 梳理用户角色及其可操作的功能边界
- 新人快速理解系统对外提供的能力清单
核心要素:
- Actor(参与者):人形图标,代表用户角色或外部系统
- Use Case(用例):椭圆,代表一个功能目标
- 系统边界:矩形框,框住所有用例,表示系统范围
- 关联线:连接 Actor 和 Use Case
画法步骤:
- 确定系统边界——画一个矩形框,写上系统名称
- 列出所有 Actor(用户角色、外部系统)——画在框外
- 对每个 Actor,列出它触发的用例(动词+名词,如“下单““查看订单”)——画在框内
- 连线:Actor 到用例
常见错误:
- 把用例拆得太细(“输入用户名”“输入密码”“点击登录“不是用例,应以用户目标为单位)
- 用例之间用箭头连线形成“流程图“(用例图只表达功能包含关系,不表达先后顺序)
- 在用例图上标注技术细节(如“调用 Redis 缓存“)
Mermaid 模板:
graph LR
subgraph 电商系统
UC1((浏览商品))
UC2((加入购物车))
UC3((下单))
UC4((支付))
UC5((查看订单))
end
买家 --- UC1
买家 --- UC2
买家 --- UC3
买家 --- UC4
买家 --- UC5
3.2.2 流程图 & 活动图(Flowchart & Activity Diagram)
一句话定义:描述一个业务流程或算法中“先做什么、判断什么、再做什么“的执行顺序。
何时用:
- 梳理业务流程(如订单状态流转、退款审批)
- 描述算法逻辑(分治、回溯、状态机)
- 对账、补偿、异常处理流程
核心要素:
- 开始/结束节点:圆角矩形或椭圆
- 处理步骤:矩形
- 判断节点:菱形(一个入口,至少两个出口,标注判定条件)
- 流向箭头:连接各节点
画法步骤:
- 确定起点和终点
- 梳理“主执行轴“——正常流程走完的 5-8 个核心节点
- 在每个判断节点处,画出分支(是/否,成功/失败)
- 补充分支的汇合点(分支最终要去哪)
- 对异常分支特别标注(超时、重试、降级)
常见错误:
- 主流程和异常流程混在一起,分不清楚“正常路径“和“兜底路径“
- 判断节点只画出口不标条件(菱形两条线出去,没写“是/否“)
- 流程线交叉缠绕(必要时应拆分或使用子流程)
Mermaid 模板:
graph TD
Start([用户提交订单]) --> LockStock[预占库存]
LockStock --> CheckStock{库存充足?}
CheckStock -->|是| CreateOrder[创建订单]
CheckStock -->|否| Fail([返回库存不足])
CreateOrder --> Payment{支付成功?}
Payment -->|是| Confirm[确认订单]
Payment -->|否| TimeoutCheck{超时?}
TimeoutCheck -->|是| ReleaseStock[释放库存]
TimeoutCheck -->|否| Payment
ReleaseStock --> CancelOrder([订单取消])
Confirm --> End([完成])
3.2.3 时序图(Sequence Diagram)
一句话定义:描述一次请求中,多个参与者(服务、模块、组件)之间按时间顺序发生的消息交互。
何时用:
- 接口设计:描述一次 API 调用涉及哪些服务及调用顺序
- 技术评审:展示核心链路的数据流转
- 故障排查:还原故障发生时的调用链
- 方案对比:展示重构前后调用链的变化
核心要素:
- 生命线(Lifeline):每个参与者一条垂直虚线,从上到下表示时间推进
- 激活条(Activation Bar):生命线上的矩形,表示该参与者正在处理请求
- 消息(Message):水平箭头,表示调用/返回
- 自调用(Self Message):指向自己的箭头
画法步骤:
- 列出参与交互的所有角色(服务、模块、数据库、消息队列等)
- 从左到右排列参与者(按调用顺序:调用方在左,被调用方在右)
- 沿时间线,逐条画出消息交互(每一条线就是一个 RPC/HTTP/MQ 消息)
- 标注关键参数和返回值
- 重点:标注异常分支——超时、重试、降级、熔断
常见错误:
- 只画正常流程,不画异常路径(超时怎么办?依赖挂了怎么办?)
- 参与者太多(超过 6-7 个时应考虑抽象或拆分为多张图)
- 忘记标注同步/异步(实线箭头=同步,虚线箭头=异步返回)
- 激活条缺失或长度不对(应精确表示处理开始和结束的时间)
Mermaid 模板:
sequenceDiagram
participant C as Client
participant GW as API Gateway
participant Order as 订单服务
participant Stock as 库存服务
participant Pay as 支付服务
participant MQ as 消息队列
C->>GW: POST /orders
GW->>Order: 创建订单请求
Order->>Stock: 预占库存(itemId, qty)
Stock-->>Order: 预占成功
Order->>Pay: 发起支付(orderId, amount)
Pay-->>Order: 支付受理中
Order->>MQ: 发送"订单创建"事件
Order-->>GW: 返回订单号
GW-->>C: 201 Created
Note over Order,Stock: 库存不足时 Order 直接返回错误
Note over Order,Pay: 支付超时 3s 则降级为"待支付"状态
3.2.4 类图 & ER 图(Class Diagram & ER Diagram)
一句话定义:类图描述代码层面的类、接口及其关系;ER 图描述数据库层面的实体、属性和关系。两者都是“结构建模“的核心工具,但面向不同层级。
何时用:
- 类图:面向对象设计、代码评审、设计模式沟通
- ER 图:数据库建模、表结构设计、数据治理
核心要素(类图):
- 类:三层矩形(类名 / 属性 / 方法)
- 关系:继承(空心三角实线)、实现(空心三角虚线)、关联(实线箭头)、聚合(空心菱形)、组合(实心菱形)、依赖(虚线箭头)
- 多重性:1、0..1、1..*、*
核心要素(ER 图):
- 实体:矩形(表名)
- 属性:椭圆或列表(列名 + 类型)
- 关系:菱形或连线(1:1、1:N、N:M)
- 主键/外键:下划线或 PK/FK 标注
画法步骤:
- 识别核心实体/类(先列出来,不连线)
- 标注每个实体/类的关键属性(3-5 个,不是全部)
- 确定关系类型(“一个订单包含多个订单项”→ 1:N)
- 连线并标注多重性
- 检查:能否用这张图回答“某个查询要 join 哪些表“?
常见错误:
- 把类图画成“所有属性和方法的大全“(类图应高亮关键结构和关系,不是代码镜像)
- ER 图中所有关系都用 N:M(实际大部分可以化简为 1:N)
- 遗漏外键或关联条件标注
- 类图和 ER 图画成同一张图(混用会导致抽象层次混乱)
Mermaid 模板(ER 图):
erDiagram
Customer ||--o{ Order : places
Order ||--|{ OrderItem : contains
OrderItem }|--|| Product : references
Order ||--o{ Payment : "paid by"
Customer {
int id PK
string name
string email
}
Order {
int id PK
int customer_id FK
string status
decimal total
datetime created_at
}
OrderItem {
int id PK
int order_id FK
int product_id FK
int quantity
decimal unit_price
}
Product {
int id PK
string name
decimal price
int stock
}
Payment {
int id PK
int order_id FK
string method
decimal amount
string status
}
3.2.5 部署图 & 拓扑图(Deployment Diagram & Topology Diagram)
一句话定义:描述系统的物理部署形态——哪些服务跑在哪些节点上,节点之间通过什么网络协议通信。
何时用:
- 技术评审:说明系统如何部署、是否高可用
- 运维交接:让运维团队理解服务分布和依赖
- 容量规划:展示各节点的资源规格和扩缩容策略
- 安全审计:标明网络区域、防火墙、加密边界
核心要素:
- 节点(Node):物理/虚拟机器、容器、Pod
- 组件(Component):跑在节点上的服务/进程
- 通信路径:节点间的连线(标注协议和端口,如
HTTPS:443、gRPC:9090) - 网络区域:用不同颜色或边界线区分(公网/DMZ/内网/数据库子网)
画法步骤:
- 划分网络区域(公有云区域、VPC、子网)
- 画出各区域的节点(标注机型、核心数、内存)
- 在节点上放置服务组件
- 连线并标注协议、端口、是否加密
- 标注高可用特性(多 AZ、主从、集群)
常见错误:
- 物理节点和逻辑服务混在一张图(应将部署图和组件图分开)
- 忽略网络区域划分(所有节点放在一起,没有安全边界意识)
- 不标注端口和协议(运维无法据此配置防火墙规则)
Mermaid 模板:
graph TB
subgraph Public[公网]
CDN[CDN]
User[用户]
end
subgraph DMZ[DMZ]
LB[负载均衡 :443]
GW[API Gateway]
end
subgraph AppSubnet[应用子网]
OrderSvc[订单服务 x3]
StockSvc[库存服务 x2]
PaySvc[支付服务 x2]
end
subgraph DataSubnet[数据子网]
MySQL_M[MySQL 主]
MySQL_S[MySQL 从]
Redis[Redis Cluster]
Kafka[Kafka Broker x3]
end
User --> CDN
CDN --> LB
LB --> GW
GW --> OrderSvc
GW --> StockSvc
OrderSvc --> PaySvc
OrderSvc --> MySQL_M
StockSvc --> Redis
OrderSvc --> Kafka
MySQL_M -.->|主从复制| MySQL_S
3.2.6 状态图(State Diagram)
一句话定义:描述一个对象或系统从创建到消亡的完整生命周期,以及在什么事件触发下发生状态转移。
何时用:
- 订单、支付、审批等有明确状态机的业务对象
- 设计状态机实现前,先画状态图与产品确认
- 排查“为什么订单卡在待支付状态不动了“之类的问题
核心要素:
- 状态节点:圆角矩形,表示对象在某一时刻的状态
- 转移箭头:标注
事件 [守卫条件] / 动作 - 初始状态:实心圆
- 终态:实心圆 + 外圈
画法步骤:
- 画出初始状态(对象被创建时的状态)
- 逐个添加“可能到达的状态“节点
- 在每个转移箭头上标注“什么事件触发“+“什么条件”+“执行什么动作”
- 检查每个状态是否都有“出边“(除了终态)——确保没有“死状态“
- 与产品/业务方逐条确认状态流转逻辑
常见错误:
- 只画主流程状态,遗漏异常状态(退款中、已取消、已超时)
- 转移条件不写清楚(“支付成功” vs “支付超时” vs “支付失败“是不同的转移)
- 一个状态图塞进多个对象的生命周期
Mermaid 模板:
stateDiagram-v2
[*] --> 待支付
待支付 --> 已支付: 支付成功
待支付 --> 已取消: 超时30min / 自动取消
待支付 --> 已取消: 用户主动取消
已支付 --> 履约中: 商家接单
履约中 --> 已发货: 出库扫描
已发货 --> 已完成: 用户签收
已发货 --> 退款中: 用户申请退货
退款中 --> 已退款: 退货审核通过
退款中 --> 已发货: 退货审核拒绝
已完成 --> [*]
已取消 --> [*]
已退款 --> [*]
3.3 架构图的黄金法则
掌握了基础图种之后,进入架构师层面的进阶话题:如何用图推动决策、争取资源、对齐团队。
3.3.1 铁律一:视图分离——看人下菜,明确沟通对象
核心原则:没有一张架构图能讲清楚所有事情。面向不同的听众,必须切换不同的视觉语言。
第一类听众:高层 / 产品 / 业务(战略宣讲)
关注点:系统能支撑什么?业务资产怎么划分?团队边界在哪?
对应图种:全景功能架构图。
画法:
- 横向分层(用户层、能力层、底层核心),纵向分域(如将大盘划分为“稳健供给“与“炽烈运营“)
- 方框里只写业务能力名称,不出现任何技术术语
- 用颜色区分自研(核心域)、外采(支撑域)、外包(通用域)
禁忌:绝对不要出现 Kafka、Redis、Saga、ACL 等底层技术名词。听众会在 10 秒内失去兴趣。
第二类听众:架构师 / 核心开发 / 测试(技术评审)
关注点:数据怎么进?怎么保证不丢?挂了怎么兜底?
对应图种:流式技术治理图 + 核心链路时序图。
画法:
- 强烈的 Pipeline 推进感(分阶段流水线),粗线条高亮“黄金主执行轴“
- 必须挂载横向的治理层(分布式事务、防资损)、设施层与 SRE 保障层(熔断限流、DLQ 死信队列)
- 时序图必须标注异常路径:超时时间、重试次数、降级策略
禁忌:不要只画“理想状态下的正常流程“——评审的核心价值恰在于挑战你的异常处理。
第三类听众:组织架构 / 核心开发(DDD 落地)
关注点:谁听谁的?依赖方向是什么?防腐层在哪?
对应图种:限界上下文图(Context Map)。
画法:
- 只画限界上下文的“圈子“和显式连线
- 连线标注关系类型:ACL(防腐层)、Hydrate(灌注)、Domain Event(领域事件)、Upstream/Downstream
- 圈的大小可以暗示职责轻重(核心域画大一点)
禁忌:不要在 DDD 图上画技术实现细节——这是战略设计图,不是战术设计图。
3.3.2 铁律二:动态与静态结合,不要画“死图“
很多架构图画完就像一张安静的“功能方块阵列“,看图的人完全不知道系统在真实运转时会发生什么。
静态定边界(方块):方块的作用仅仅是定义“职责与高内聚的限界“。例如商品中心、计价中心,它们是一个静态的盒子——盒子内部你可以再画一张图去展开。
动态拉主轴(连线/流水线):必须引入“时间线/生命周期“来激活静态图。
做法一:像流式治理图一样,顶部拉出 1 → 2 → 3 → 4 → 5 的数据流向阶段。
做法二:在静态图的底部,加一条带箭头的横向长条,写明核心交易生命周期(如:预览 → 试算 → 下单 → 履约 → 对账),并用虚线与上方的功能方块关联,表明“在下单阶段,交易中心会调用计价中心的哪个网关“。
核心技巧:静态盒子定义“谁是谁“,动态时间线回答“然后呢“。两者都画清楚,读者才能真正理解系统。
3.3.3 铁律三:在图上明确表达架构决策
好的架构图不只是画出方块,它是在画出你的架构决策。
表达“收敛态势“
如果你在推动某个核心模块的收敛(如统一计价中心、统一用户中心),不要把它缩在角落。把它放在图的核心中枢或公共腰带位置,让上游的各个模块形成“向心力“向它收敛,而它向下游发起调用。可视化本身就是说服力。
表达“防腐与隔离(ACL)“
在对接外部系统或脏数据源时,图上必须显式画出一个隔离带(防腐层)。例如:供给中心接进来的三方脏数据,必须经过清洗、标准化映射(Normalize)后才能进入纯净的主数据中心。这在图上要用颜色或虚线框明确标出——“这是脏数据暂存区,这是纯净的标准域”。
表达“容错与补偿(降级兜底)“
一个合格的战术架构图,必须在底层标出 Saga 逆向补偿、DLQ 死信队列、人工修复工作台和降级策略。例如在图注或连线旁写上一行小字:“当营销中心发生熔断时,计价中心降级为仅计算商品原价”。这能证明你的方案不是空中楼阁,而是具备线上生产力保障的。
3.3.4 架构图的演进:从概念到落地
一张好图会在项目的不同阶段“长“出不同的形态:
| 阶段 | 图的形态 | 特征 |
|---|---|---|
| POC/预研 | 概念草图 | 只有大块服务和大致数据流,方块少连线粗,表达“这个方向可行“ |
| 详细设计 | 战术行军图 | 细化每个节点的内部结构,标注接口、协议、异常路径 |
| 上线后 | 实况图 | 补上实际部署后的拓扑、监控埋点、容灾切换路径 |
关键实践:将图源文件(如 .drawio、.excalidraw)纳入 Git 管理,与设计文档放在同一目录。每次架构变更时,同时更新图和文档。图文不一致的架构文档,比没有文档更危险。
3.3.5 在架构图中表达非功能性需求
非功能性需求(性能、可用性、安全、成本)往往是评审的核心关注点,但大部分架构图中它们完全缺席。以下是在图中表达它们的技巧:
- 性能:在关键调用连线上标注
P99 < 50ms或QPS 峰值 5000 - 可用性:用虚线画出故障切换路径(主库 → 从库的箭头),标注
RTO < 5min - 安全性:用锁图标或颜色标记加密通信(mTLS)、敏感数据存储(加密列)
- 容量:在服务节点上标注资源规格(
4C8G × 3,峰值 QPS 2000) - 成本:在技术栈旁边标注大致的成本估算(如“Redis Cluster 32G 约 ¥3000/月“)
3.4 按场景组合出招
技能学了,图种会了,心法懂了。但面对真实工作场景,真正的问题变成:这次我到底该画哪几张图?
以下六种常见场景的组合策略,直接可用。
3.4.1 向上汇报:让老板一眼看懂
场景:向 CTO/VP/业务负责人汇报新系统方案或重构计划。
核心目标:让对方在 1 分钟内理解“这个系统能支撑什么业务、边界在哪、为什么值得做“。
图件组合:
- 1 张全景功能架构图:横向分层(用户层→能力层→核心层),纵向分域(按业务能力划分)。只写业务名词,不出现技术组件。
- 可选:1 张“改造前后对比“简化图:左边画当前痛点(箭头散乱、重复建设),右边画改造后(统一收敛、边界清晰)。
注意:不要画时序图、不要画部署图。这些是技术评审用的,在上层汇报中只会分散注意力。
3.4.2 技术评审:经得起挑战的方案
场景:向架构师委员会/核心开发团队/测试团队做方案评审。
核心目标:证明方案在正常流程、异常流程、高并发、故障恢复四个维度都经得起推敲。
图件组合:
- 1 张技术治理图:Pipeline 式的主执行轴 + 横向治理层(事务、防资损、SRE 保障)
- 1-2 张核心链路时序图:展示最关键的 1-2 条链路的完整调用链,标注异常路径
- 1 张对账与补偿流程图:如果涉及资金/库存,必须画出对账流程和异常补偿流程
- 1 张部署拓扑图:展示多 AZ 部署、主从架构、容灾切换
3.4.3 领域建模:让 DDD 落地
场景:团队进行领域驱动设计(DDD)的战略设计阶段,需要对齐领域边界和依赖关系。
核心目标:让所有人对“哪个上下文负责什么“和“上下文之间怎么协作“达成共识。
图件组合:
- 1 张限界上下文图(Context Map):圈出每个限界上下文,标注关系(ACL / 领域事件 / 上下游)
- 2-3 张关键场景的时序图:选最复杂的 2-3 个跨上下文协作场景画时序图,验证依赖方向是否合理(核心域不应该依赖通用域)
- 可选:每个核心上下文的内部类图:只在代码实现前画
3.4.4 故障复盘:讲清楚“发生了什么“
场景:线上故障后的复盘会议,需要还原故障过程、分析根因、提出改进措施。
核心目标:让所有参与者(开发/运维/产品/管理者)对故障全貌有一致理解。
图件组合:
- 1 张故障传播时序图:关键——用红色标记故障起点和传播路径,标出每个节点响应时间的变化(正常 50ms → 超时 3000ms),标出熔断和降级的触发点
- 1 张恢复流程图:描述故障发生后人工介入的完整过程(谁发现了→怎么定位→什么操作→怎么确认恢复)
- 1 张改进措施对照表:左列问题根因,右列改进措施及负责人,附上预计完成时间
3.4.5 新人 Onboarding:最快的上手路径
场景:新同事入职,需要在最短时间内理解系统全貌和核心工作流。
核心目标:先给全景,再给抓手,让新人知道“出了问题去哪看、找谁“。
图件组合:
- 1 张系统全景图:展示所有服务/模块及其职责的一句话描述,不展开内部细节
- 1 张核心链路时序图:展示“一次用户请求从进入到返回“的完整路径,这是新人理解系统运行方式的最佳入口
- 1 张部署拓扑图:标注各个环境的访问地址、日志查看方式、监控大盘链接
- 可选:团队与系统对照表:每个系统/模块对应的 Owner 团队,让新人知道找谁
3.4.6 写设计文档:标准图件套件
场景:正式的技术方案文档,需要覆盖多个视角。
核心目标:让阅读者从逻辑、进程、物理、开发四个视角完整理解方案。这里借鉴“4+1 视图“的思想:
| 视角 | 对应图种 | 回答的问题 |
|---|---|---|
| 逻辑视图 | 类图 / 包图 / 模块图 | 代码怎么组织的?模块怎么划分? |
| 进程视图 | 时序图 / 活动图 | 运行时怎么协作?数据怎么流转? |
| 物理视图 | 部署图 / 拓扑图 | 服务跑在哪?怎么容灾? |
| 开发视图 | 组件图 / 分层图 | 代码仓库怎么拆分?模块间依赖? |
| 场景视图 | 用例图 + 核心时序图 | 关键业务场景怎么贯穿各层? |
注意:不是每次都要画满 4+1。小需求 2-3 张图足够,大型项目按需补齐。
3.5 工具与视觉规范
3.5.1 工具矩阵对比
没有“最好的绘图工具“,只有“最适合你场景的工具“。以下是按六个维度对主流工具的对比:
| 工具 | 架构图 | UML 标准 | 时序图 | ER 图 | 团队协作 | Git 友好 | 适用人群 |
|---|---|---|---|---|---|---|---|
| Draw.io | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐(XML 文件) | 全能型,免费 |
| Excalidraw | ⭐⭐⭐ | ⭐(无标准 UML) | ⭐⭐ | ⭐ | ⭐⭐ | ⭐⭐⭐(JSON 文件) | 手绘风格,适合快速表达想法 |
| PlantUML | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐(纯文本协作) | ⭐⭐⭐(文本 diff) | 代码驱动,CI 友好 |
| Mermaid | ⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐(Markdown 原生) | ⭐⭐⭐(内嵌 Markdown) | GitHub/GitLab 原生渲染 |
| Figma/FigJam | ⭐⭐ | ⭐ | ⭐ | ⭐ | ⭐⭐⭐ | ⭐(二进制文件) | 设计师协作 |
| ProcessOn | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐(在线协作) | ⭐ | 国内团队,快速上手 |
| Lucidchart | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐ | 企业级,付费 |
选购建议:
- 个人学习 / 博客配图:Mermaid(直接写在 Markdown 里)或 Excalidraw(快速画草图)
- 团队协作 / 设计文档:Draw.io + Git 管理源文件
- CI/CD 集成 / 自动化文档:PlantUML 或 Mermaid
- 正式架构评审:Draw.io 或 ProcessOn(视觉效果更好)
3.5.2 大厂视觉规范:让你的图具备技术高级感
3.5.2.1 三色原则
全图的主色调控制在 3 种以内。大厂常用配色:
- 浅蓝:通用域 / 基础能力(消息队列、网关、监控)
- 浅绿:支撑域 / 适度定制(运营后台、配置中心)
- 浅橙 / 浅红:核心域 / 重点自研(业务核心链路,如计价、交易)
不要把图画成彩虹——颜色越多,视觉权重越分散,信息的层级感越弱。
3.5.2.2 粗细线条区分主次
- 普通服务间调用:最细的灰色线条(
#999, 1px) - 核心主执行轴:粗彩色线条(3-4px 明蓝或橙色),横向贯穿全图
- 异常/降级路径:虚线
- 在主轴下方配一行小字手写体注脚,例如:
supplier_sync → mapping_normalize → product_sku
3.5.2.3 图例说明(Legend)是强制项
图的右上角或右下角,必须放一个干净的图例说明框。至少标出:
- 每种颜色的含义(核心域 / 支撑域 / 通用域)
- 每种线型的含义(同步调用 / 异步消息 / 降级路径)
- 特殊符号的含义(锁=加密、盾牌=安全边界)
3.5.2.4 标注让图从“好看“变成“有用“
- 在关键连线旁标注接口名或事件名(如
OrderCreated而不是“发消息“) - 在服务节点标注 QPS 峰值或资源规格
- 在数据库旁标注容量(如
500GB, QPS 3000)
3.5.3 代码即图:Mermaid & PlantUML 的工程优势
传统拖拽式绘图的问题是:改一张图要重新打开工具、拖拽、对齐、导出、替换。而代码驱动的绘图方案有三大工程优势:
优势一:Git Diff 友好。 图的变更是纯文本,Code Review 时能精确看到“哪条连线被改了“。
优势二:CI 可检查。 可以在 CI 流水线中渲染验证,图挂了构建就报错。
优势三:复用与模板化。 团队的公共组件可以抽象成模板,新人复制模板改节点名即可,不需要从头学习工具操作。
核心原则:代码驱动画“规范型“的图(时序图、ER 图、状态图,这些有严格语法),拖拽工具画“表达型“的图(架构图、全景图,这些需要自由排布视觉权重)。
3.5.4 版本管理与团队协作
图源文件管理策略:
docs/
├── design-doc.md
├── diagrams/
│ ├── architecture.drawio # 架构图源文件
│ ├── order-sequence.md # Mermaid 源码(嵌入文档或独立存放)
│ └── er-diagram.puml # PlantUML 源文件
图文一致性检查清单:
- 代码接口变了 → 时序图更新了吗?
- 新加了服务 → 架构图更新了吗?
- 改了数据库 Schema → ER 图更新了吗?
- 调整了依赖方向 → 限界上下文图更新了吗?
团队图库建设:建立团队的“图模板库“(Excalidraw 的 Library 功能),包含:标准服务节点样式、标准连线样式、标准网络区域模板、标准图例框。所有人从模板出发,视觉语言自然统一。
3.6 总结
3.6.1 软件工程绘图的三个层次
┌───────────────────────────┐
│ 层次三:架构决策的表达 │ ← 第三、四章
│ 图是你的说服力和领导力 │
├───────────────────────────┤
│ 层次二:图种的选择与组合 │ ← 第二章
│ 知道什么场景画什么图 │
├───────────────────────────┤
│ 层次一:图种的画法基本功 │ ← 第二章
│ 掌握每类图的核心要素 │
└───────────────────────────┘
最核心的一句话:画图之前先想听众,定义好边界再连线,用动态主轴激活静态方块,用底层底座展现高可用防线,用异常路径证明你的方案经过深思熟虑。
最后的建议:把画图当成和写代码同等重要的技能来刻意练习。每次写设计文档时,问自己:如果只能放三张图,我选哪三张?每张图要回答的那一个问题是什么?图上的每一根线、每一个字,都应该是答案的一部分。
5. 架构师的自我修养:从“会 Coding“到“会 Solving“
前面的三个部分分别讲了系统设计的判断框架、技术方案的表达方法,以及软件工程绘图的全部基本功。这三样东西合在一起,构成了架构师工具箱里的“硬技能“。但真正决定一个架构师能走多远的,往往不是工具熟练度,而是一套更深层的素质——思考方式、决策原则、落地习惯,以及持续成长的自我要求。
这一部分把这些内容收拢在一起,称为“架构师的自我修养“。
从高级工程师到架构师的跨越,不是从“关注如何 Coding“到“关注如何 Design“,而是从“关注如何 Coding“到“关注如何 Solving“。
4.1 架构师的思考模型:多维滤网
普通工程师面对需求时,第一反应通常是“这个功能怎么写代码“。架构师面对同样的需求,脑海里启动的是一套多维滤网——需求在经过多层过滤之后,才变成方案。这四层滤网,是架构师思考问题的基本框架。
长期视角 vs 短期视角
架构师必须同时持有两套时间尺度:
- 短期视角:当前业务量是多少,两周内能不能交付,团队现在的能力能不能接住。
- 长期视角:半年后数据量会涨到什么级别,一年后业务模型是否会变,今天的选型会不会成为明天的瓶颈。
这两个视角天然冲突:短期要快,长期要稳。普通工程师可以选择只关注其中一个,架构师不能。架构师的工作就是在“快速交付“和“长远规划“之间,找到当前阶段最合适的平衡点。
一个很实用的判断标准是:这个决策未来要改的成本有多高? 如果只是换一个缓存策略、调整一个超时参数,成本很低,那就不值得现在过度设计;如果要换存储引擎、改数据模型、迁移协议,成本很高,那就值得在早期多花时间把方向选对。
全局视角 vs 局部视角
普通开发盯着自己的模块,架构师看的是整个生态:
- 这个改动对上游调用方有什么影响?
- 对下游依赖的吞吐会有什么压力?
- 网络拓扑上,调用链路会经过哪些区域?
- 安全边界在哪里,敏感数据会不会因为这次改动被暴露到不该出现的链路上?
全局视角不是“什么都知道“,而是习惯性地追问“这个决策的外溢效应是什么“。一个模块内部做得很漂亮,但如果它的接口设计导致上游被迫改 10 个调用方,那它就不是一个好方案。
非功能性需求永远是第一反应
业务方说“我要一个购物功能“,普通工程师想的是“商品表、购物车接口、下单流程“。架构师脑子里弹出来的第一屏是:
- 这条链路的峰值 QPS 会到多少?
- 库存扣减的一致性要求是什么级别?
- 支付回调丢了怎么办?
- 如果依赖的第三方挂了,用户还能不能看到购物车?
- 这个功能的监控指标是什么?
- 做这一套要花多少钱?
非功能性需求不是写完功能之后再来“补“的,而是方案设计的骨架。架构师的训练,本质上就是把对这些维度的敏感度从“事后想起“变成“事前条件反射“。
商业与技术结合
技术是为商业服务的。架构师面对一个技术方案时,至少要想三个问题:
- ROI:这个技术投入(开发时间、机器成本、维护人力)换回来的商业收益是什么?是用户体验提升,还是客诉下降,还是成本节省?
- 技术债:今天这个选择,会在未来产生什么隐性代价?如果两年后要换掉它,迁移成本多大?
- 战略对齐:这个方案是否符合公司的技术战略?如果公司正在推进“去 Oracle“,那就不要再引入 Oracle 依赖。
技术深度是架构师的底气,但商业判断力才是决定一个架构师能否被业务方和老板信任的关键。一个只会说“这个方案技术更先进“的架构师,和一个能说“这个方案虽然技术更保守,但交付快两周、运维成本低一半、而且团队已经熟练“的架构师,影响力是完全不同的。
4.2 核心原则与方法论
架构师不是靠直觉做决策,而是靠一套经过千锤百炼的原则和方法论。这些原则不会直接告诉你“这里用 Redis 还是 Kafka“,但它们会给你一个判断框架,让你在面对任何新技术、新场景时都能做出结构化的决策。
4.2.1 三条必须内化的设计原则
康威定律(Conway’s Law)
系统架构复制了组织的沟通结构。
这不是一句鸡汤,而是一个经过反复验证的组织工程规律。如果你的团队分了 3 个组,各自独立汇报、独立考核,那你的系统大概率会变成 3 个耦合度很低的主模块。反过来,如果你想得到一条高内聚的 Bounded Context,你就需要一支专属的、跨职能的团队来守护它。
康威定律给架构师的两条行动指引:
- 设计架构时,先看看团队怎么组织的。如果组织和架构不匹配,要么调整架构去适应组织现实,要么推动组织调整去支持目标架构。
- 推动组织变革,本质上也是架构师工作的一部分。如果你想要一个收敛的、统一的核心域,却让 3 个团队各自维护一块,那架构图再漂亮也落不了地。
奥卡姆剃刀原则(Occam’s Razor)
如无必要,勿增实体。
能用简单架构解决的问题,绝不要上复杂架构。能用单库解决的就不要分库分表,能用单体解决的就不要拆微服务,能用 MySQL 解决的就不要引入分布式 NoSQL。
这不是“反对新技术“,而是复杂度的每一层增加,都会在开发、测试、运维、排障、新人上手等环节持续产生利息。架构师的职责,就是在引入任何一种复杂度之前,问自己:“这个复杂度,是业务真的需要,还是我觉得它酷?”
一个实用的自检标准:如果两年后你要离开这个团队,你会愿意把这个系统交接给一个中级工程师吗? 如果答案是否定的,那这个架构可能过重了。
演进式架构(Evolutionary Architecture)
不要试图一步到位设计出 10 年不落后的系统。
架构是长出来的。第一版的目标不是“完美“,而是“足够稳定且便于迭代“。好的架构不追求预判未来所有需求,而是追求具备“易于在未来发生变革“的能力——当你发现某个模块的边界划错了、某个存储选型顶不住了、某个抽象不再适用了,你能不能在不大动干戈的情况下把它换掉。
演进式架构的核心不是预测未来,而是为变化留好接口、隔离好边界、控制好变更半径。
4.2.2 三套核心方法论
领域驱动设计(DDD)
DDD 是微服务拆分的理论基石。它的核心思想是通过划分“限界上下文(Bounded Context)“,把复杂的业务拆解为边界清晰的领域模型。每个限界上下文内部拥有自己的领域语言(Ubiquitous Language),上下文之间通过明确的依赖关系和防腐层(ACL)进行协作。
DDD 最重要的贡献,不是那些战术模式(聚合、实体、值对象),而是把“业务理解“前置为设计的第一步。在动手画架构图之前,先跟业务方把领域概念对齐,把限界上下文划清楚,把依赖方向定下来——这些工作决定了系统架构的骨架是否正。
本书不会展开 DDD 的完整体系,但在第 2.8 节“六维设计画布“中,Model 维度和 Capability 维度的核心思路与 DDD 一脉相承。
ATAM(Architecture Tradeoff Analysis Method)
ATAM 是一套架构评估方法,核心思想可以浓缩为一句话:世界上没有完美的架构,只有最适合当前场景的架构。
架构师的工作,本质上就是在做权衡。常见的权衡对包括:
- 用空间换时间(缓存、索引、预计算)
- 用一致性换可用性(最终一致性、异步收敛)
- 用吞吐换延迟(批量处理、异步削峰)
- 用复杂度换扩展性(分库分表、微服务拆分)
ATAM 的价值不在于告诉你“选哪个“,而在于让你养成一个习惯:在做每一个决策时,能说清楚你选择了什么、放弃了什么、为什么这个取舍在当前场景下是合理的。
4+1 视图
4+1 视图是从不同视角描述架构的一套模型:
| 视图 | 关注点 | 典型受众 |
|---|---|---|
| 逻辑视图 | 功能需求和模块划分 | 产品、业务方 |
| 开发视图 | 代码组织、模块依赖、构建方式 | 开发者 |
| 进程视图 | 运行时行为、并发、通信 | 架构师、SRE |
| 物理视图 | 部署拓扑、网络、机器 | 运维、SRE |
| 场景视图 | 关键用例如何贯穿各层(+1) | 所有角色 |
4+1 视图的核心洞见是:没有人能用一张图理解整个系统。 架构师的职责不是画出一张“万能图“,而是为不同的受众选择正确的视图,用正确的语言表达。这一点与本书第三部分“软件工程绘图完全指南“的核心理念完全一致。
4.3 六维画布:高准确率场景的实战应用
在第 2.8 节中,我们已经系统介绍了六维设计画布(Model / Process / Capability / Governance / Measurement / Evolution)作为通用设计阶段的方法论。这里不再重复展开,而是针对支付、库存、账务、交易等“不能错“的高风险场景,补充几个实战要点。
这类场景的共同特征很简单:错了就是资损,错了就要赔钱,错了监管就要来。 所以在六维画布的应用上,每一层都会被提到更高的标准:
【 核心层(底座)】 --> 1. Model(模型): 定义事实,数据长什么样
【 运行层(骨干)】 --> 2. Process(流程): 驱动状态,业务怎么跑起来
【 扩展层(翅膀)】 --> 3. Capability(能力): 提炼复用,怎么支持更多业务
【 防御层(免疫)】 --> 4. Governance(治理): 容错补偿,出错了怎么兜底
【 观测层(眼睛)】 --> 5. Measurement(度量):白盒监控,运行得怎么样
【 未来层(生命力)】 --> 6. Evolution(演进): 平滑升级,怎么应对未来的变化
- Model:明确唯一事实源。余额不能只有
balance字段,应当采用不可变流水账本模型——“余额是流水计算出的结果,而不是一个可被直接覆盖的字段”。这在高准确率场景下不是锦上添花,而是最低要求。 - Process:缩小强一致边界。广泛采用“预占 + 确认/取消“模式(如 TCC),内建幂等与防重契约。核心原则是:让强一致区域越小越好,让补偿链路越清楚越好。
- Capability:策略与核心分离。新业务、新渠道来临时,优先通过配置驱动、规则引擎或 SPI 插件化来支持,而不是频繁修改核心代码。核心代码是经过去重考验的,每一次修改都是风险。
- Governance:设计异常闭环。主动探测未知状态(如支付结果未返回),遇到超时或失败时通过 Saga 逆向补偿或自动对账来修复。对账不是运维的事,是方案设计的一部分。
- Measurement:白盒化可观测性。除了 QPS、RT、错误率这些技术指标,还必须度量业务健康指标——如一致性滞后时间、对账不平率、未处理异常单量。
- Evolution:规划平滑迁移。涉及数据迁移的场景,至少要考虑“双写双发、历史离线比对、切读写“的完整流程,并预留降级和回退空间。
六维画布的核心价值,在于把一句“这个场景很重要,要认真设计“,变成了六个可以逐项检查的维度。在高准确率场景下,这六个维度少看一个,都有可能在线上变成真金白银的损失。
4.4 日常工作中的落地法则
架构师不仅要想得清楚、讲得明白,还要能落地。很多方案在设计阶段看起来很完美,一到执行就变形——不是因为方案有问题,而是因为架构师没有把落地这件事本身当成设计的一部分。
别做“象牙塔“里的架构师
必须写代码。 不写代码的架构师会逐渐丧失对技术的敏感度。当你不再清楚一个 PR 从提交到上线要走多少步、一个分布式事务在真实网络里会遇到多少种超时模式、一个配置变更在凌晨三点会不会把值班同事叫醒的时候,你设计的方案就会越来越像“理论上成立,实际上悬空“。
一个可操作的底线是:每年至少参与一个核心模块的编码或定期的深度 Code Review。 不是为了证明“我还能写“,而是为了保持对系统真实状态的体感。
在一线听炮火。 系统重构、线上大故障、性能瓶颈发生时,架构师必须在现场。不是“事后看报告“,而是跟着值班同事一起排查、一起决策、一起复盘。只有知道痛点长什么样、排查链路有多绕、当时的判断有多难做,下一次方案评审时你才能问出真正关键的问题。
控制技术陷阱,避免过度设计
警惕技术自嗨。 技术社区每天都在推新东西,架构师最容易犯的错误之一,就是为了“简历好看“而引入最新、最复杂的开源框架。盲目引入服务网格、分布式事务框架、图数据库、事件溯源等技术,在业务体量还没有到那个级别的时候,往往不是“为未来准备“,而是“给今天添乱“。
一个很直白的判断标准:你引入这个技术,是因为不引入就做不了,还是因为引入了会让你觉得更有成就感? 如果是后者,那大概率是过度设计。
管理技术债。 架构师要定期盘点系统中的“坏味道“和存量技术债,并有计划地推动重构。技术债不是不存在的债,而是暂时没有还的债。架构师的职责不是把债藏起来,而是:
- 把债显式化:记录在哪里、影响范围、严重程度
- 把债排优先级:哪些债已经快还不上了(比如每次发版都要祈祷不出事),哪些还可以再缓一缓
- 把还债计划嵌入业务迭代:争取在每次业务需求中,顺带清理一部分关联的技术债
沟通与消灭模糊性
把复杂问题说简单。 架构师一半以上的时间在沟通,而沟通最核心的能力不是“把简单问题说复杂“,而是“把复杂问题说简单“。
面对不同角色,切换不同的语言:
- 对老板和业务方:用商业语言和 ROI——“这个方案可以减少 30% 的客诉”“这个重构可以让新功能上线快两周”
- 对开发团队:用清晰的 UML 图、接口定义、技术规范——把模糊的“这里要做个接口“变成精确的“这个接口的请求体长这样、幂等键是这个字段、超时是 3 秒“
- 对测试:用状态机、边界条件、异常路径——不要只给正常流程,把最坏情况也列出来
定义边界,达成共识。 在项目初期,最值钱的工作不是画漂亮的架构图,而是明确划分“谁该做什么、谁不该做什么“。很多项目后期冲突,根源都在前期边界没有对齐。架构师要做的是在利益相关者之间建立统一的术语表(Ubiquitous Language),确保不同角色在说同一个词的时候,指的是同一件东西。
关注团队梯队与规范
沉淀规范。 定义团队的代码规范、CI/CD 流程、日志规范、异常处理规范。好的架构不只是你自己画的图,而是你留下的框架和规范——让“哪怕资质平平的开发,在你的框架下写代码,也不会把系统搞塌“。
这才是架构师最大的杠杆:你不是通过自己多写代码来影响系统质量,而是通过你设计的框架、规范和约束,让整个团队产出的代码质量都上一个台阶。
技术传帮带。 通过架构评审、技术分享、结对编程,把自己的思考方式赋能给高级工程师。架构师的重要输出不是只有文档和代码,还有“下一代架构师“。一个只会自己做决策的架构师,迟早会成为团队的瓶颈;一个能培养出更多能做决策的人的人,才是真正意义上的技术 Leader。
4.5 架构师自我修养小结
从高级工程师到架构师的跨越,不是一夜之间的突变,而是一层一层积累的结果。把本部分的要点浓缩为几条:
- 技术深度是底气:不写代码、不懂底层、不看故障现场,其他能力都是空中楼阁。
- 全局视野与权衡艺术是核心:架构师不是在找“正确答案“,而是在找“当前场景下代价最小、收益最大的方案“。
- 沟通能力是放大镜:能把复杂问题讲简单、能把不同角色拉到同一张桌子上达成共识,决定了你的方案能不能从纸面上走到线上。
- 团队杠杆是上限:你一个人做得好是高级工程师,你能让一个团队都做得好才是架构师。沉淀规范、培养梯队、建立标准,这些工作的影响力远超过你亲自写的任何一行代码。
最后,用一句话来总结“架构师的自我修养“:
技术深度是你的底气,全局视野与权衡艺术是你的核心能力,沟通与推动落地是你从“想清楚“到“做成事“的桥梁。而这一切加起来,最终的目标不是让你自己更强大,而是让你所在的系统、你的团队、你的组织,因为你而变得更强大。
6. 本章小结
这一章分五个部分:第一部分讲系统设计的判断框架,第二部分讲技术方案的表达与推进方法,第三部分讲架构师的武器库——从边界设计到系统内部结构、系统集成与一致性、质量保障与容量韧性(合并原第 2 章内容),第四部分讲软件工程绘图的全部基本功,第五部分补充架构师的自我修养——从思考模型、方法论到落地习惯。
四部分看似不同领域,本质上是在回答同一个问题:
架构师如何在现实约束下,把复杂问题做成一套可落地、可协作、可复盘的工程决策。
如果只懂前半部分,你可能能想清楚问题,却很难推动团队按同一套理解落地;如果只会后半部分,你可能能写出格式完整的 TD,却缺少真正的系统设计判断力。如果只停留在技术和工具层面,你可能能画出漂亮的图、写出严谨的文档,却无法在组织约束下长期推动落地和演进。
所以真正成熟的能力不是”会画图””会写接口””会背组件”,而是:
- 能定义问题
- 能识别约束
- 能做关键取舍
- 能把取舍写清楚
- 能带着评审视角推进方案落地
- 能把复杂问题讲简单,把模糊需求变成清晰边界
- 能通过规范、框架和传帮带,让整个团队的质量一起提升
后续章节会继续展开:
- 第二部分 技术方案设计方法论
- 第三部分 架构师的武器库(业务边界、系统内部结构、集成与一致性、质量保障)
- 第四部分 软件工程绘图完全指南
- 第五部分 架构师的自我修养
- 第 2 章:编码与 Code Review
7. 本章面试题与追问
- 什么是系统设计?系统设计面试到底在考什么? 追问:你会如何区分“会用组件”和“有设计能力”?
- 为什么系统设计里一定要先做容量估算? 追问:如果没有精确数据,你会如何给出合理区间?
- 高并发系统为什么不能只靠加机器解决? 追问:哪些瓶颈是水平扩展也解决不了的?
- 强一致性和最终一致性分别适合哪些业务? 追问:订单、评论数、搜索索引分别怎么选,为什么?
- 为什么说可扩展性会带来复杂度? 追问:从单体走向微服务后,通常新增了哪些治理问题?
- Redis、Kafka、MySQL 在系统设计里分别解决什么类型的问题? 追问:什么时候不该引入它们?
- 为什么分布式系统必须重视超时、重试和幂等? 追问:如果客户端重试导致重复下单,你会怎么兜底?
- 负载均衡和缓存分别解决什么问题? 追问:它们引入了哪些新的故障模式?
- 为什么架构师不能只会画图或列接口? 追问:一份真正可评审的 TD 至少要回答哪些关键问题?
- 技术方案里的关键设计决策应该怎么写? 追问:如果你列了 A/B 方案,怎么证明你选的那个更合理?
- 为什么很多 TD 会在 rollout 和 rollback 上被打回? 追问:你会如何把灰度、观测、回滚写成可执行方案?
- 如果让你在 5 分钟内回答一道系统设计题,你会按什么顺序组织表达? 追问:这个顺序和你写真实 TD 时有什么共通点?