企业级 .NET 领域驱动框架深度解析-第 1 章 全景图:一套框架的七条脊柱

企业级 .NET 领域驱动框架深度解析-第 1 章 全景图:一套框架的七条脊柱

本章定位:在深入任何模块之前,先建立一张"可导航的地图"。读完之后,你应该能够回答:这套框架把哪些事情变成了默认行为?这些默认行为依赖哪些不变量?当一个请求变慢、数据不一致或远程调用失败时,应该沿着哪条链路去找答案?

前置知识:熟悉 C#、ASP.NET Core 的基本请求模型、关系型数据库事务、至少一种消息中间件的基本概念。

本章不会:逐个项目介绍目录结构,也不会罗列全部 API。那属于手册,而不是架构分析。


0. 引子:三个故障,三种幻觉

先看三个在企业系统里反复出现的故障。它们表面上分属缓存、网络、分页三个不同领域,实际上都指向同一件事:开发者以为框架的某个默认行为等价于某个更强的一致性保证,但框架并没有做出那个保证。

故障一:保存成功,列表却"看不见"

某天下午,运营同学反馈:商品价格已经改成了 199,商品详情页也显示 199,但商品列表仍然是 299。刷新无效,重启应用后恢复正常。

排查过程通常是这样展开的:

  1. 查数据库,价格确实是 199;
  2. 查缓存,列表缓存里仍是旧对象;
  3. 查代码,保存逻辑调用了领域服务的保存方法,返回 true;
  4. 再查提交链路,发现这次保存走的是"只写数据库、不刷新缓存"的上下文选项;
  5. 结论:保存成功不等于所有读模型都已更新。

这类问题的根因不在某一行代码,而在写路径与读路径的契约没有被显式表达。框架提供了"是否更新缓存""是否更新二级索引""是否广播事件"等开关,但开关的默认值、组合语义、失败后的补偿策略,才是系统真正的行为。把它们当作"框架会自动搞定",就会得到"数据库对了、列表错了"的幻觉。

故障二:RPC 超时,订单却创建了两次

客户端调用下单接口,10 秒后超时。客户端框架按策略重试,第二次调用返回成功。用户刷新订单列表,看到两笔一模一样的订单。

服务端日志显示两次请求都完整执行了。数据库没有唯一约束能拦住它们,因为两笔订单的请求号不同------重试时客户端重新生成了请求号。

这类问题的根因是:超时是"结果未知",不是"失败"。在分布式系统里,一次调用只有三种确定状态:成功、明确失败、未知。大多数重试策略把"未知"当成"失败",而服务端又没有幂等键,于是"至少一次"投递变成了"至少两次副作用"。

框架能提供超时、重试、连接重建、异常封装,但它无法凭空发明业务幂等键。真正的不变量是:任何可能被重试的写操作,都必须携带一个跨重试稳定的业务唯一标识,并且服务端要对这个标识做原子去重。

故障三:分页查询偶尔"丢"数据

一个后台列表按创建时间倒序分页。用户从第 1 页翻到第 2 页,发现某条记录既不在第 1 页,也不在第 2 页;再刷新一次,它又出现了。

原因通常有三类:

  1. 排序键不唯一,数据库在并列值之间的返回顺序不稳定;
  2. 分页查询与总数查询使用了不同的过滤条件或不同的快照;
  3. 查询走了缓存/搜索索引,而索引尚未追平数据库。

框架把"分页"封装成一个简单参数,但分页从来不是简单参数。它要求稳定的全序 、一致的过滤条件 、明确的读一致性级别。缺少任何一个,翻页就可能重复或遗漏。

三个故障的共同点

这三个故事分别对应本系列的三条主线:

  • 写路径与读路径的一致性(上下文、工作单元、缓存与索引);
  • 远程调用的确定性与幂等性(RPC、重试、消息消费);
  • 查询语义的稳定性(规约、排序、分页、分片)。

它们也揭示了一个事实:框架的价值不在于"让代码变短",而在于把一组经过验证的约束固化成默认路径。理解一套框架,本质上就是理解它固化了哪些约束、遗漏了哪些约束、以及当约束被打破时系统会以什么方式坏掉。


1. 第一性原理:领域模型是系统的一等公民

1.1 从"数据表中心"到"领域模型中心"

大多数企业系统的起点是一张数据库表。业务需求来了,先加字段;关系复杂了,再加关联表;查询慢了,再加索引。代码围绕表转:数据访问层返回 DataTable 或 DTO,服务层做字段拼装,控制器直接暴露这些结构。

这种"表中心"的开发方式在早期非常高效,但它有三个隐性成本:

  1. 业务规则无处安放。折扣怎么算、订单何时可取消、库存何时预占,这些规则散落在服务方法、SQL、前端校验里,重复且互相矛盾。
  2. 一致性边界模糊。一次业务操作应该同时修改哪些表、哪些修改必须原子完成,没有显式表达。开发者只能靠"记得"来维护。
  3. 演化困难。数据库结构成为事实上的领域模型,任何业务概念的变化都直接变成表结构变化,迁移成本极高。

领域驱动设计给出的答案是:用聚合(Aggregate)表达一致性边界,用实体(Entity)表达具有生命周期和身份的对象,用值对象(Value Object)表达没有身份的描述性概念,用领域服务表达跨聚合的协作。

这套框架选择了"领域模型中心"的路线。它没有把实体做成贫血的 DTO,而是让实体自身承载:

  • 身份:主键的生成、比较、序列化;
  • 状态跟踪:哪些属性被修改、修改前后的值;
  • 关系导航:一对多、一对一、隐藏关系与显示关系;
  • 懒加载:按需从数据库装载关联对象;
  • 业务规则:保存前校验、状态机迁移、领域事件;
  • 横切能力:权限、工作流、租户、版本。

这意味着,框架里的一个聚合对象远不只是一个"数据容器"。它是一个带有运行时状态的复杂对象,其行为由类型系统 + 元数据 + 上下文共同决定。

1.2 同一份模型的三种表示

理解这套框架,最关键的心智模型是:同一个业务概念,在系统里有三种表示,框架的核心职责是让三者在演化中保持同步。

表示 形态 产生时机 主要使用者
CLR 类型 C# 类、属性、特性 编译期 编译器、IDE、业务代码
运行时元数据 聚合树、关系节点、表名/列名映射 启动期反射构建 仓储、工厂、代码生成、查询
持久化形态 表、列、索引、关联表 建表/迁移期 数据库、搜索索引、缓存

举例来说,一个订单聚合可能长这样(以下为化名代码,仅表达结构):

csharp 复制代码
[Table(Name = "biz_order", Key = "OrderId")]
public class Order : XAggregateRoot
{
    [Column(Name = "OrderNo", Length = 32, IsUnique = true)]
    public string OrderNo { get; set; }

    [Column(Name = "Amount", Precision = 18, Scale = 2)]
    public decimal Amount { get; set; }

    [Column(Name = "Status")]
    public OrderStatus Status { get; set; }

    [OneToManyShowRelation(typeof(OrderLine))]
    public IList<OrderLine> Lines { get; set; }

    [OneToOneEntity(typeof(Customer))]
    public Customer Customer { get; set; }
}

这份类型声明同时承载了三类信息:

  1. CLR 语义 :Order 是一个类,Lines 是一个列表,Amount 是 decimal;
  2. 元数据语义 :Order 映射到 biz_order 表,主键是 OrderId,Lines 是"一对多显示关系",Customer 是"一对一实体关系";
  3. 持久化语义:需要一张主表、一张子表、若干列与索引;查询时需要 JOIN 还是分次加载,由关系类型决定。

框架在启动时扫描这些类型,构建一棵聚合元数据树 。之后,仓储用它来决定 SQL 形状,工厂用它来决定如何从 DataRow 还原对象,代码生成器用它来产出建表语句与数据访问代码,查询引擎用它来把属性表达式翻译成列名。

这就是"元数据驱动"的含义:业务代码只声明一次,后续所有基础设施行为都从这份声明推导出来。 它带来的好处是惊人的一致性;代价是,一旦元数据推断错误,错误会同时出现在建表、查询、序列化、代码生成等多个环节。

1.3 聚合边界:框架强制了什么,没有强制什么

这套框架对聚合的支持非常"重",但它并不是一个完整的 DDD 约束执行器。需要区分两类约束:

框架强制的约束:

  • 聚合根必须实现可标识接口,拥有主键;
  • 子实体通过关系特性声明与根的关系;
  • 保存聚合时,框架按元数据树决定先写主表还是子表;
  • 删除聚合时,框架按关系类型决定级联行为;
  • 查询聚合时,框架按关系类型决定装载策略;
  • 代码生成时,框架按元数据生成表结构。

框架不强制、需要团队自律的约束:

  • 聚合之间不能持有可变对象引用(框架允许你声明一对一实体关系,但这不意味着跨聚合事务是好的);
  • 一次事务只修改一个聚合(框架的工作单元可以注册多个聚合,但一致性成本由你承担);
  • 领域服务不直接操作其他聚合的仓储(框架没有阻止);
  • 领域事件必须在提交后发布(框架提供时机,但不强制业务语义)。

这个区分非常重要。很多团队误以为"用了这套框架就自动 DDD 了",结果把整个对象图塞进一个聚合,导致保存时产生大量 SQL、锁竞争和级联加载。框架给你的是表达能力 ,不是边界判断。

1.4 框架的三层抽象

从抽象层次看,这套框架可以分成三层:

text 复制代码
┌──────────────────────────────────────────────────────────┐
│  业务层:聚合、领域服务、业务规则、DTO、API 契约          │
├──────────────────────────────────────────────────────────┤
│  领域基础设施层:元数据、仓储、工作单元、上下文、规约      │
├──────────────────────────────────────────────────────────┤
│  运行时层:配置、IoC、AOP、序列化、RPC、消息、数据访问    │
└──────────────────────────────────────────────────────────┘
  • 运行时层回答"对象如何被创建、调用如何被拦截、消息如何被编码、连接如何被管理";
  • 领域基础设施层回答"聚合如何被描述、保存如何被编排、查询如何被翻译、事务如何被界定";
  • 业务层只回答"业务规则是什么"。

三层之间通过接口与元数据耦合,而不是通过具体实现耦合。例如,领域服务不直接依赖某个数据库驱动,而是依赖数据访问抽象;仓储不直接依赖某个缓存客户端,而是依赖缓存抽象;RPC 客户端不直接依赖网络库,而是依赖代理接口。

这种分层的一个直接后果是:框架可以在"本地调用"和"远程调用"之间切换,而业务代码几乎不变。 领域服务门面会根据注册信息决定是直接调用本地实例,还是通过 RPC 代理调用远程实例。对业务层来说,两者都是同一个接口。


2. 七条脊柱:框架的整体骨架

一套大型框架不可能靠单个"万能层"支撑。它更像脊椎动物:有多条并行的支撑结构,每条负责一类不变量,彼此通过关节连接。把这套框架拆开,可以识别出七条脊柱。

2.1 第一脊柱:配置与对象图(K0)

核心问题:一个由几十个模块组成的系统,如何在不重新编译的情况下完成装配?

机制:

  • 分层 XML 配置,节点名与处理器类型建立映射;
  • 支持 refConfig 引用其他配置片段,形成可复用的配置单元;
  • 支持从本地文件或 ZooKeeper 节点加载;
  • 配置解析后生成"对象定义描述",由对象工厂按需实例化;
  • 支持构造参数注入、属性注入、单例/非单例生命周期;
  • 支持运行时重新加载配置(部分场景)。

它固化的不变量 :对象的创建方式由配置描述,而不是由调用方 new 出来。这让"替换实现""按环境切换""集中管理连接串"成为可能。

它没有固化的:配置的语义正确性。配置能装配出一个对象图,不代表这个对象图符合业务预期。配置错误往往在启动后第一次调用时才暴露。

2.2 第二脊柱:代理与横切(K1)

核心问题:日志、事务、缓存、权限、监控这些横切关注点,如何不侵入业务代码?

机制:

  • 运行时用 Reflection.Emit 生成代理类型;
  • 支持继承式代理与组合式代理;
  • 方法调用被重写为"构建调用上下文 → 执行前置切面 → 调用目标 → 执行后置切面 → 处理异常/返回值";
  • 切面有顺序,顺序决定嵌套关系;
  • 代理类型缓存,避免重复生成;
  • 无切面时退化为直接调用,减少开销。

它固化的不变量:横切逻辑在代理层统一执行,业务方法不需要知道日志或事务的存在。

它没有固化的:切面之间的语义冲突。例如,缓存切面在事务切面之外还是之内,直接决定"事务回滚后缓存是否已被写入"。顺序是配置问题,但后果是数据一致性问题。

2.3 第三脊柱:序列化与协议(K2)

核心问题:同一个对象,如何在进程内、跨进程、跨语言、跨运行时版本地传输?

机制:

  • 自定义混合报文头:魔数 + 运行时标记 + 序列化器标识 + 类型名长度 + 类型名 + 载荷;
  • 支持 JSON 与 Protobuf 两种载荷;
  • 类型名会做规范化,去掉版本、文化等易变部分;
  • 处理 mscorlib 与 System.Private.CoreLib 之间的类型名差异;
  • 通过 AsyncLocal 支持"当前调用链使用哪种序列化器";
  • Protobuf 侧需要预先初始化类型元数据。

它固化的不变量:报文自带类型信息,接收方可以在不知道声明类型的情况下还原对象。

它没有固化的:模式演进的安全性。类型名写进报文意味着重命名类型、移动命名空间、拆分程序集都可能导致反序列化失败。框架提供的是"自描述报文",不是"向后兼容的契约"。

2.4 第四脊柱:元数据与代码生成(K4/K11)

核心问题:如何让一份领域模型声明同时驱动运行时行为与数据库结构?

机制:

  • 反射扫描聚合类型,构建树形元数据;
  • 识别五种关系:一对一值、一对一实体、一对一显示、一对多隐藏、一对多显示;
  • 处理继承与多态,支持按类层次或按子类映射;
  • 计算同层同类型属性的序号,解决重复类型属性的命名冲突;
  • 从 XML 注释中提取业务含义,作为元数据的一部分;
  • 根据元数据生成建表语句(多数据库方言)与工厂/仓储代码。

它固化的不变量:模型声明是唯一事实来源,数据库结构与运行时映射从它推导。

它没有固化的:演进期的迁移。代码生成可以创建新表,但无法自动处理数据迁移、索引重建、历史数据回填。

2.5 第五脊柱:上下文与工作单元(K6)

核心问题:一次业务操作可能修改多个对象、涉及多个存储,如何界定事务边界与提交顺序?

机制:

  • 服务上下文持有本次操作的所有变更集合、事件、选项;
  • 工作单元登记新增、修改、删除的实体;
  • 提交时按上下文选项决定是否写缓存、是否写二级索引、是否单写多读;
  • 先更新缓存/索引,再持久化数据库(或按配置的顺序);
  • 支持批量提交,把多条 SQL 合并为批处理;
  • 支持事务范围,失败时回滚;
  • 资源路由信息写入 Redis,供后续查询定位。

它固化的不变量 :一次提交是一个有顺序的管道,而不是一堆独立的 Save 调用。

它没有固化的:跨存储的原子性。数据库事务无法覆盖 Redis 与搜索引擎;框架通过顺序与补偿降低不一致窗口,但不能消除。

2.6 第六脊柱:查询与规约(K8/K9)

核心问题:如何用类型安全的方式表达查询,同时支持 SQL、参数化 SQL、缓存索引、分片与远程调用?

机制:

  • 规约对象封装属性名、值、比较操作、逻辑组合;
  • 支持从 Lambda 表达式解析出属性路径;
  • 生成普通 SQL 或参数化 SQL;
  • 支持 And/Or/Not 组合、排序、分页、Top N;
  • 支持"不查询总数"的 NP 变体,降低大表分页成本;
  • 支持按分片键路由与跨分片聚合;
  • 领域服务提供流式 API,把规约、排序、分页串起来。

它固化的不变量:查询条件是可组合的对象,而不是拼接字符串。

它没有固化的:查询计划的优劣。规约能生成正确的 SQL,但索引是否命中、是否发生隐式转换、是否产生笛卡尔积,仍取决于数据库与模型设计。

2.7 第七脊柱:分布式运行时(K10/K14/K15/K16)

核心问题:当服务从单进程拆成多进程,调用、注册、发现、消息、故障恢复如何工作?

机制:

  • 服务门面维护本地与远程服务注册表;
  • ZooKeeper 保存服务地址与类型信息;
  • 服务宿主支持 AppDomain 隔离、热重载、优雅退出;
  • RPC 基于 DotNetty,长度字段分帧,方法调用/返回消息序列化;
  • 客户端用 Roslyn 生成代理类,超时、重试、连接重建;
  • 发布订阅支持本地 Provider 与 Kafka Provider;
  • 消费语义支持 ALO(至少一次)与 EO(恰好一次的努力);
  • 本地消息表记录消息状态,支持重投与补偿。

它固化的不变量:远程调用看起来像本地调用,消息发布看起来像普通方法调用。

它没有固化的:网络分区、重复交付、顺序性。这些是分布式系统的基本事实,框架只能提供工具,不能提供豁免。

2.8 七条脊柱如何协作

把七条脊柱放进一次典型的"下单"请求里:

text 复制代码
HTTP 请求
  │
  ├─ [K13] Web 运行时:认证、限流、异常过滤、结果包装
  │
  ├─ [K12] 动态 API:把控制器方法映射到领域服务方法
  │
  ├─ [K10] 服务门面:判断本地还是远程
  │        ├─ 本地:直接调用
  │        └─ 远程:[K15] RPC 代理 → DotNetty → 服务端
  │
  ├─ [K9] 领域服务:开启上下文、执行业务规则
  │
  ├─ [K4] 元数据:确定聚合的关系与表映射
  │
  ├─ [K6] 工作单元:登记变更、选择提交选项
  │
  ├─ [K7] 仓储:构建 SQL、参数、批量
  │
  ├─ [K3] 数据访问:连接、事务、执行、映射
  │
  ├─ [K18] 缓存/索引:更新读模型
  │
  └─ [K16] 消息:发布领域事件/集成事件

这张图会在后续章节被反复展开。现在只需要记住:任何一次请求都不是"一层调用下一层"这么简单,而是多条脊柱在不同切点介入。 一个看似简单的 Save,可能同时触发元数据解析、代理拦截、事务开启、缓存更新、索引写入、消息发布。


3. 逻辑分层与依赖方向

3.1 物理工程不等于逻辑分层

这套框架的解决方案里有三十多个物理工程。如果按目录去理解,很容易迷失在"这个工程依赖那个工程"的网里。更有效的方式是先看逻辑分层,再把物理工程映射进去。

逻辑上可以分成八层:

text 复制代码
L8 业务宿主(Web API 宿主、RPC 服务宿主、消息消费者宿主)
L7 接入与协议(动态 API、Web 运行时、RPC、发布订阅)
L6 领域服务(领域服务基类、服务门面、服务寻址)
L5 领域模型(聚合、实体、值对象、业务规则)
L4 领域基础设施(元数据、仓储、工作单元、规约、工厂)
L3 平台能力(序列号、缓存、对象存储、身份、日志)
L2 数据访问(连接、事务、命令、路由、各数据库 Provider)
L1 运行时内核(配置、对象工厂、AOP、序列化、公共工具)

依赖方向整体向下,但存在几个关键的"倒置点":

  • L5 的实体依赖 L1 的序列化/代理抽象,而不是依赖具体数据库;
  • L6 的领域服务依赖 L4 的仓储接口,而不是依赖 L2 的具体 Provider;
  • L7 的 RPC 依赖 L6 的领域服务接口,而不是依赖具体实现;
  • L8 的宿主依赖 L7 的接入组件,通过配置决定装载哪些服务。

3.2 三条依赖禁令

从大量实际项目的事故中,可以总结出三条硬性禁令。它们不是框架强制的,但违反后几乎一定会付出代价。

禁令一:业务层不得直接依赖具体数据库驱动。

一旦业务代码 new 了某个连接对象,或直接拼接某家数据库的方言 SQL,数据库就不再是可替换的基础设施,而变成了业务逻辑的一部分。框架通过数据访问抽象与仓储封装了这层,但允许你在仓储子类里写方言 SQL------这是扩展点,不是常规路径。

禁令二:聚合不得持有其他聚合的仓储。

聚合是内存中的一致性边界,不是服务定位器。让聚合去查询另一个聚合,会导致加载路径不可预测、事务边界扩散、单元测试困难。跨聚合协作应放在领域服务或应用服务中。

禁令三:接入层不得绕过领域服务直接操作仓储。

动态 API 的价值在于"把领域服务的契约暴露为 HTTP",而不是"把数据库暴露为 HTTP"。绕过领域服务意味着跳过业务规则、权限、工作流、事件与上下文,最终得到的是一套披着 DDD 外衣的 CRUD。

3.3 依赖倒置的三个具体位置

位置一:仓储接口与实现。

领域服务看到的是一组查询/保存方法;具体实现可能走 SQL Server、MySQL、SQLite,甚至走远程服务。框架通过泛型仓储基类与数据库特定的子类完成这层适配。

位置二:领域服务与调用方式。

领域服务接口既可以被本地调用,也可以被 RPC 代理调用。调用方只依赖接口;服务门面在运行时决定路由。这是"位置透明"的基础。

位置三:缓存/索引与存储。

工作单元不直接依赖 Redis 或搜索引擎客户端,而是依赖上下文中的能力开关与平台抽象。是否启用缓存、是否启用索引,由配置与上下文选项决定。

3.4 一个反例:为什么"什么都放领域服务"也会失控

有些团队理解了"接入层不要碰仓储"之后,走向另一个极端:所有逻辑都塞进领域服务,领域服务之间互相调用,形成一张巨大的调用网。结果是:

  • 事务边界不清:一个服务调用另一个服务,后者又开启自己的上下文;
  • 事件重复发布:内层服务提交后发布一次,外层再发布一次;
  • 异常语义混乱:内层捕获后返回 false,外层以为成功;
  • 性能不可预测:一次入口调用触发几十次数据库往返。

正确的做法是区分领域服务 与应用服务的职责:

  • 领域服务:实现一个明确的业务能力,管理自己的聚合与事务边界;
  • 应用服务/接入层:编排多个领域服务,处理跨能力的流程与补偿;
  • 领域事件:用于解耦"已发生的事实"与"后续反应",而不是用于同步调用链。

这套框架没有强制区分二者,但它的上下文与工作单元机制隐含了这种分工:一个上下文应该对应一个明确的业务操作,而不是一整棵调用树。


4. 三条主调用链

理解框架最快的办法,是沿着三条真实调用链走一遍。后续章节会逐段展开,这里先给出全局时序与关键决策点。

4.1 链路一:HTTP 写请求

以"创建订单"为例:

text 复制代码
Client
  │  POST /api/order
  ▼
Web 运行时
  │  1. 认证:解析 Token,构建用户身份
  │  2. 限流:按 IP/用户/接口维度判断
  │  3. 路由:匹配控制器与动作
  ▼
动态 API 层
  │  4. 参数绑定:DTO → 领域服务方法参数
  │  5. 结果包装:统一响应结构
  ▼
领域服务
  │  6. 权限检查:当前用户是否有权创建
  │  7. 业务校验:金额、库存、状态
  │  8. 构建聚合:工厂/构造函数/领域方法
  │  9. 开启上下文:PersistContext + 选项
  ▼
工作单元
  │ 10. 登记新增实体
  │ 11. 选择提交策略:是否写缓存、是否写索引
  ▼
仓储
  │ 12. 读取元数据:表名、列名、关系
  │ 13. 构建 INSERT/UPDATE/DELETE
  │ 14. 生成参数,防止注入
  ▼
数据访问
  │ 15. 获取连接(可能来自池)
  │ 16. 开启事务
  │ 17. 执行命令/批处理
  │ 18. 提交或回滚
  ▼
缓存/索引/消息
  │ 19. 更新 Redis 缓存与二级索引
  │ 20. 写入搜索索引(可选)
  │ 21. 发布领域事件/集成事件(可选)
  ▼
返回

关键决策点有五个:

  1. 认证与身份:当前用户是谁?租户是谁?这些信息会贯穿上下文,影响权限、数据过滤、审计。
  2. 本地还是远程:领域服务可能不在当前进程。门面根据注册信息选择调用方式。
  3. 上下文选项:这次操作要不要写缓存?要不要写索引?要不要单写多读?选项决定提交管道的形状。
  4. 事务边界:哪些写操作在同一个事务里?批量提交是否开启?
  5. 提交后动作:事件在事务内还是事务后发布?缓存更新失败是否阻断主流程?

4.2 链路二:RPC 调用

text 复制代码
调用方
  │  1. 注入接口 IXxxService
  │  2. 实际拿到 Roslyn 生成的代理类
  ▼
代理
  │  3. 组装方法名、参数类型、参数值
  │  4. 生成调用 ID(RId)
  │  5. 序列化 MethodCallMessage
  │  6. 通过连接池发送,启动超时计时
  ▼
DotNetty 客户端
  │  7. 长度字段分帧
  │  8. 连接不可用时重建
  │  9. 失败按策略重试
  ▼
网络
  ▼
DotNetty 服务端
  │ 10. 拆帧、反序列化
  │ 11. 查找接口/方法/构造器映射
  │ 12. 构建服务实例(可能来自对象工厂)
  │ 13. 注入调用上下文:用户、TraceId、租户
  │ 14. 反射调用目标方法
  │ 15. 捕获返回值/出参/异常
  │ 16. 序列化 MethodReturnMessage
  ▼
调用方
  │ 17. 按 RId 找到等待中的 TaskCompletionSource
  │ 18. 设置结果或异常
  │ 19. 继续业务代码

关键决策点:

  1. 服务发现:接口对应的地址列表从哪里来?ZooKeeper 注册信息如何刷新?
  2. 超时:超时时间设多少?超时后是重试、降级还是抛错?
  3. 重试:哪些异常可重试?重试几次?退避策略是什么?是否所有方法都适合重试?
  4. 连接管理:连接断开后如何重建?重建期间请求是排队还是快速失败?
  5. 上下文传播:用户身份、TraceId、租户、语言等如何跨进程传递?

4.3 链路三:消息消费

text 复制代码
生产者
  │  1. 构造事件对象
  │  2. 选择主题(可能带应用前缀)
  │  3. 序列化载荷(JSON/Protobuf)
  │  4. 发送到 Kafka/本地 Provider
  ▼
Broker
  │  5. 持久化、分区、副本
  ▼
消费者
  │  6. 拉取消息
  │  7. 反序列化,还原事件类型
  │  8. 查找订阅处理器
  │  9. 执行业务逻辑(可能开启领域服务上下文)
  │ 10. 成功后提交位点/更新本地消息表状态
  │ 11. 失败则重试/进入重试主题/人工补偿

关键决策点:

  1. 主题命名:是否加应用前缀?多环境如何隔离?
  2. 消费语义:ALO 还是 EO?EO 的"恰好一次"是端到端保证,还是仅指消费端去重?
  3. 提交时机:业务成功后提交,还是先提交再处理?前者可能重复,后者可能丢失。
  4. 顺序性:同一聚合的事件是否要求有序?分区键怎么选?
  5. 幂等:消费端用什么键去重?去重记录保存多久?

4.4 三条链路的公共结构

把三条链路叠在一起,会发现它们共享同一个"骨架":

text 复制代码
入口适配(HTTP/RPC/消息)
  → 身份与上下文建立
  → 领域服务编排
  → 聚合状态变更
  → 工作单元提交
  → 读模型/事件传播
  → 结果返回/位点提交

框架的真正价值,是让这个骨架在三种入口下保持一致。你在 HTTP 链路里学到的上下文、事务、缓存语义,同样适用于 RPC 与消息消费。反过来,任何一条链路里出现的"特例",都值得警惕------它往往意味着某个不变量被绕过了。

5. 启动时序:一个对象图是如何被装配出来的

5.1 为什么先讲启动

领域模型、仓储、RPC、消息,所有这些能力都建立在同一个前提上:运行时已经正确装配了一棵对象图。 如果配置装载顺序错了,后面所有链路都会以难以理解的方式失败。例如:

  • 日志组件还没初始化,数据访问层就开始打印 SQL,于是启动日志丢失;
  • 数据访问路由还没注册,领域模型工厂就尝试连接数据库,于是报"找不到默认数据源";
  • 服务门面还没拿到注册中心地址,宿主就开始注册服务,于是服务在注册中心里"消失";
  • 序列化器的 Protobuf 元数据还没初始化,第一条 RPC 消息就到达,于是报类型未注册。

因此,理解框架的第一站不是领域模型,而是启动。

5.2 启动入口的三个层次

从外到内,启动通常经过三个层次:

  1. 进程入口 :控制台程序的 Main、Web 宿主的 Program,或者测试宿主的引导代码;
  2. 框架初始化器:静态构造函数或显式初始化方法,负责设置全局状态、线程池参数、配置来源;
  3. 业务宿主初始化:加载领域服务、构建控制器、绑定端口、注册到注册中心。

三者之间不是简单的顺序调用,而是存在"隐式初始化":某些类型在被第一次访问时会触发静态构造函数,从而顺带完成一部分初始化。这种设计的好处是"用不到就不初始化",坏处是启动顺序变得依赖调用顺序,在并发场景下尤其危险。

一个典型的启动伪代码如下(化名):

csharp 复制代码
public sealed class XBootstrap
{
    private static int s_initialized;

    public static void EnsureInitialized()
    {
        if (Interlocked.Exchange(ref s_initialized, 1) == 1)
            return;

        // 1. 基础运行参数
        XRuntime.SetMinThreads(Environment.ProcessorCount * 8);
        XRuntime.EnableTrace = ReadFlag("trace");

        // 2. 配置来源:本地文件或配置中心
        XConfigSource source = XConfigSource.Resolve(
            localPath: AppContext.BaseDirectory,
            zkAddress: ReadArg("--zkConfigServer"),
            role: ReadArg("--zkAppRole"));

        // 3. 分层解析配置,构建处理器链
        XConfigRuntime.Current.Load(source);

        // 4. 初始化全局门面:日志、数据访问、序列化、发布订阅......
        XFacadeInitializer.InitializeAll(XConfigRuntime.Current);

        // 5. 初始化领域元数据(可选,部分场景延迟到首次使用)
        XDomainMetadata.WarmUp();
    }
}

这段代码里有两个值得注意的设计:

  • Interlocked.Exchange 保证初始化只执行一次。但"只执行一次"不等于"线程安全地完成":如果初始化过程中抛异常,标志位已经被置为 1,后续调用会以为初始化成功。生产级实现需要更严谨的状态机(未初始化/初始化中/成功/失败)。
  • 初始化被拆成多个阶段,每个阶段依赖前一个阶段的产物。阶段之间的依赖关系没有显式建模,而是靠代码顺序保证。这是框架启动期最脆弱的地方。

5.3 配置来源:本地文件与配置中心

框架支持两种配置来源:

本地文件:适合开发、测试、单机部署。优点是无外部依赖、启动快、易于调试;缺点是多实例配置难以统一,修改需要重启或触发重载。

配置中心(ZooKeeper):适合生产多实例部署。优点是集中管理、变更可审计、支持按角色/环境隔离;缺点是引入外部依赖,配置中心不可用时启动会失败(除非有本地兜底)。

配置中心模式下,通常按"应用角色"定位配置节点。应用角色不是简单的应用名,而是"同一份代码在不同部署形态下的逻辑身份"。例如:

  • order-api:面向公网的 API 宿主;
  • order-worker:只消费消息的后台宿主;
  • order-rpc:只提供 RPC 的服务宿主。

三者可能共享大部分配置,但在数据源、线程池、注册地址、日志级别上不同。用角色而不是机器名来定位配置,才能做到"同一角色扩容时配置自动一致"。

5.4 分层 XML:节点即插件

配置文件的组织方式很有特点:XML 元素名直接对应处理器类型。伪配置如下:

xml 复制代码
<XConfig>
  <Log level="Info" provider="file|kafka" />
  <AOP enabled="true">
    <Aspect name="trace" order="10" type="TraceAspect, X.Aspects" />
    <Aspect name="tx" order="20" type="TransactionAspect, X.Aspects" />
  </AOP>
  <DataAccesses default="main">
    <Object id="main" type="SqlServerAccess, X.Data">
      <ConnectionString>...</ConnectionString>
    </Object>
    <Object id="cache" type="RedisAccess, X.Data">
      <ConnectionString>...</ConnectionString>
    </Object>
  </DataAccesses>
  <DomainModel>
    <Assembly name="X.Domain" />
  </DomainModel>
  <DomainService mode="inner|rpc" sqlType="sqlserver">
    <In>
      <Dll mode="con">X.Order.Service</Dll>
    </In>
  </DomainService>
  <PublishSubscribe provider="kafka" app="order">
    <Broker servers="..." />
  </PublishSubscribe>
  <SerialNumber provider="snowflake" workerId="1" />
</XConfig>

解析器的核心思路是:

  1. 遍历根节点的子元素;
  2. 用元素名查找注册的"配置节处理器";
  3. 把该节的 XML 子树交给处理器;
  4. 处理器解析出对象定义,交给对象工厂;
  5. 工厂按需实例化,并注册到全局门面。

这种设计带来的扩展点是:新增一个基础设施模块,只需要新增一个配置节处理器,而不需要修改解析器主流程。 它的问题也很明显:

  • 元素名是隐式契约,没有编译期检查;
  • 处理器之间的初始化顺序不透明;
  • 配置错误往往只会在运行时暴露;
  • 不同模块可能对同名元素有不同解释。

5.5 refConfig:配置复用的双刃剑

大型系统里,同一份配置片段(例如 Redis 连接、日志格式、AOP 切面列表)会在多个环境复用。框架通过 refConfig 支持引用外部配置片段:

xml 复制代码
<DataAccesses default="main">
  <Object id="main" refConfig="config/data-main.xml" />
</DataAccesses>

它的好处是减少重复、统一治理。风险是:

  • 引用链可能形成循环,导致解析栈溢出;
  • 外部片段的相对路径依赖工作目录,容器化部署时容易找不到;
  • 片段变更会影响所有引用方,缺乏"影响面分析";
  • 片段中的相对引用可能在不同上下文中解析成不同对象。

一个实用的治理原则是:只允许引用"叶子配置"(连接串、密钥、端点),不允许引用"结构配置"(对象图、切面顺序)。 结构配置应该显式写在使用方,避免隐式耦合。

5.6 对象工厂:配置到实例的最后一公里

配置解析的结果不是对象,而是"对象定义描述":

csharp 复制代码
public sealed class XObjectDef
{
    public string Id { get; set; }
    public string TypeName { get; set; }
    public bool Singleton { get; set; }
    public List<XArgumentDef> ConstructorArgs { get; set; }
    public Dictionary<string, XArgumentDef> Properties { get; set; }
}

对象工厂负责:

  1. 根据 TypeName 解析类型(可能跨程序集);
  2. 判断生命周期:单例从缓存取,非单例每次新建;
  3. 解析构造参数:ref 参数从已注册对象中取,值参数做类型转换;
  4. 调用构造函数;
  5. 执行属性注入;
  6. 缓存并返回。

伪代码如下:

csharp 复制代码
public object Resolve(XObjectDef def)
{
    if (def.Singleton && _singletons.TryGetValue(def.Id, out var existing))
        return existing;

    var type = _typeResolver.Resolve(def.TypeName);
    var ctor = SelectConstructor(type, def.ConstructorArgs);
    var args = def.ConstructorArgs.Select(a => a.IsRef
        ? ResolveById(a.Value)
        : ConvertValue(a.Value, a.TargetType)).ToArray();

    var instance = ctor.Invoke(args);

    foreach (var (name, arg) in def.Properties)
    {
        var prop = type.GetProperty(name, BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
        prop.SetValue(instance, arg.IsRef ? ResolveById(arg.Value) : ConvertValue(arg.Value, prop.PropertyType));
    }

    if (def.Singleton)
        _singletons[def.Id] = instance;

    return instance;
}

这段代码看似简单,却隐藏着几类典型故障:

故障一:构造函数选择歧义。 同一个类型有多个构造函数,参数数量相同但类型不同,工厂可能选中了错误的一个。解决方案是显式标注构造函数,或在配置中写清参数类型。

故障二:循环依赖。 A 的构造参数引用 B,B 的构造参数引用 A。单例模式下,如果工厂没有提前暴露"正在构造"的状态,就会无限递归。常见做法是构造参数只允许引用"更底层"的对象,属性注入用于打破循环,但属性注入又可能读到半初始化对象。

故障三:单例污染。 单例对象持有请求级状态(例如当前用户、当前租户),在并发请求之间串数据。这是最危险的一类故障,因为它在低并发测试中完全正常,一到生产就出现"张三看到李四的数据"。

故障四:类型转换的静默失败。 配置里的 "10" 转成 int 成功,转成 TimeSpan 可能得到意外的默认值;"true" 转成枚举可能失败但被吞掉。健壮的实现应该在启动期快速失败,而不是在第一次调用时抛异常。

5.7 启动顺序的隐性依赖

把常见模块的初始化依赖画出来:

text 复制代码
线程池/全局参数
  → 日志
    → 配置中心客户端(本身可能依赖日志)
      → 数据访问(依赖日志、连接串)
        → 缓存客户端(依赖日志、连接串)
          → 序列化器(依赖日志、类型扫描)
            → 领域元数据(依赖日志、程序集)
              → 仓储/工厂(依赖数据访问、元数据)
                → 领域服务(依赖仓储、上下文)
                  → 服务门面/注册中心(依赖领域服务、配置)
                    → 网络服务器(依赖序列化、领域服务)
                      → 宿主(依赖以上全部)

这个顺序不是框架文档里写死的,而是从依赖关系推导出来的。实际代码中,某些模块会延迟初始化以打破顺序约束,例如:

  • 日志在配置解析前使用一个最小化的控制台输出,配置完成后再切换;
  • 数据访问在第一次执行 SQL 时才真正打开连接;
  • 领域元数据在第一次保存/查询时才构建;
  • RPC 连接在第一次调用时才建立。

延迟初始化提高了启动速度,但也把故障从"启动期"推迟到了"第一次请求"。对于核心链路,建议在启动时做一次"预热":

  1. 主动解析默认数据源,执行一条 SELECT 1;
  2. 主动连接 Redis,执行一次 PING;
  3. 主动构建核心聚合的元数据;
  4. 主动解析一次 RPC 服务地址;
  5. 主动发送一条测试消息(可选)。

预热的本质是把运行期的不确定性前移到部署期。在滚动升级场景里,这一点尤其重要:一个"启动成功但第一次请求必失败"的实例,比"启动失败"的实例危害大得多。

5.8 配置热更新:理想与现实的差距

框架在部分场景支持配置热更新,例如日志级别、限流阈值、某些开关。但"热更新"从来不是一个单一能力,而是分层的:

配置类型 能否热更新 代价
日志级别 通常可以 低
限流阈值 通常可以 低
功能开关 可以 中,需要注意并发可见性
连接串 部分可以 高,需要重建连接池
数据源路由 困难 高,涉及存量连接与事务
AOP 切面顺序 通常不可以 极高,需要重建代理类型
领域元数据 通常不可以 极高,涉及已加载类型与缓存

一个常见的误解是"配置中心推了变更,所有实例立刻生效"。实际上:

  • 推送有延迟;
  • 实例可能没订阅到;
  • 本地缓存可能未失效;
  • 变更可能只对"新请求"生效,正在执行的请求仍用旧值;
  • 某些配置需要重启才能生效,但没有任何提示。

因此,生产系统应该把配置分成"热配置"和"冷配置",并在配置中心里明确标注。冷配置的变更必须走发布流程,而不是指望热更新。


6. 一个端到端示例:从模型到 API

理论讲到这里,需要一个可以贯穿全书的例子。我们选择一个足够简单、又能覆盖大部分机制的领域:订单。

6.1 需求边界

  • 一个订单包含若干订单行;
  • 订单有客户、金额、状态;
  • 创建订单时校验金额与行数量;
  • 支付成功后状态变为"已支付";
  • 取消订单需要记录原因;
  • 查询支持按客户、状态、时间范围过滤与分页;
  • 订单列表需要缓存;
  • 订单创建后发布"订单已创建"事件。

这个例子里有聚合、子实体、业务规则、状态迁移、查询、缓存、事件,足以串起七条脊柱。

6.2 模型声明

csharp 复制代码
[Table(Name = "biz_order", Key = "OrderId", KeyType = typeof(long))]
public class Order : XAggregateRoot
{
    [Column(Name = "OrderNo", Length = 32, IsUnique = true)]
    public string OrderNo { get; set; }

    [Column(Name = "CustomerId")]
    public long CustomerId { get; set; }

    [Column(Name = "Amount", Precision = 18, Scale = 2)]
    public decimal Amount { get; set; }

    [Column(Name = "Status")]
    public OrderStatus Status { get; set; }

    [Column(Name = "CreatedAt")]
    public DateTime CreatedAt { get; set; }

    [Column(Name = "CancelReason", Length = 200, Nullable = true)]
    public string CancelReason { get; set; }

    [OneToManyShowRelation(typeof(OrderLine))]
    public IList<OrderLine> Lines { get; set; } = new List<OrderLine>();

    [OneToOneEntity(typeof(Customer))]
    public Customer Customer { get; set; }

    public void AddLine(long productId, int quantity, decimal unitPrice)
    {
        if (quantity <= 0)
            throw new XBusinessException("数量必须大于 0");

        Lines.Add(new OrderLine
        {
            ProductId = productId,
            Quantity = quantity,
            UnitPrice = unitPrice
        });

        Amount += quantity * unitPrice;
    }

    public void Pay()
    {
        if (Status != OrderStatus.Created)
            throw new XBusinessException("当前状态不可支付");

        Status = OrderStatus.Paid;
    }

    public void Cancel(string reason)
    {
        if (Status == OrderStatus.Paid)
            throw new XBusinessException("已支付订单不可直接取消");

        Status = OrderStatus.Cancelled;
        CancelReason = reason;
    }

    protected override void Validate()
    {
        if (Lines.Count == 0)
            throw new XBusinessException("订单至少包含一行");

        if (Amount <= 0)
            throw new XBusinessException("订单金额必须大于 0");
    }
}

子实体:

csharp 复制代码
[Table(Name = "biz_order_line", Key = "OrderLineId", KeyType = typeof(long))]
public class OrderLine : XEntity<long>
{
    [Column(Name = "OrderId")]
    public long OrderId { get; set; }

    [Column(Name = "ProductId")]
    public long ProductId { get; set; }

    [Column(Name = "Quantity")]
    public int Quantity { get; set; }

    [Column(Name = "UnitPrice", Precision = 18, Scale = 2)]
    public decimal UnitPrice { get; set; }
}

这里有几个设计选择值得说明:

为什么 Customer 是一对一实体关系,而不是把客户字段平铺进订单? 因为客户是一个有独立生命周期的聚合,订单只需要引用它。声明为"一对一实体"意味着查询订单时可以按需装载客户;如果平铺,客户信息变更就需要同步所有历史订单,违反不可变快照原则。实际项目中,更常见的做法是同时保留 CustomerId 和一份下单时的客户快照(姓名、地址),两者并不矛盾。

为什么 Lines 是一对多显示关系? "显示关系"意味着子实体在聚合内部可见、可导航、可一起保存。与之相对的"隐藏关系"更适合那些不需要在聚合根上暴露、但需要级联维护的对象。关系类型的选择直接决定加载策略与 SQL 形状,第 6 章会展开。

为什么金额既在订单头上,又在订单行上? 订单头金额是汇总值,行金额是明细。汇总值必须在聚合内部保持一致。这里用 AddLine 维护,而不是让外部直接改 Amount。框架不会阻止外部赋值,所以封装仍然靠团队自律。

6.3 数据库形状

根据元数据,代码生成器会推导出两张表:

sql 复制代码
CREATE TABLE biz_order (
    OrderId       BIGINT       NOT NULL PRIMARY KEY,
    OrderNo       VARCHAR(32)  NOT NULL,
    CustomerId    BIGINT       NOT NULL,
    Amount        DECIMAL(18,2) NOT NULL,
    Status        INT          NOT NULL,
    CreatedAt     DATETIME     NOT NULL,
    CancelReason  VARCHAR(200) NULL,
    LastUpdateTime DATETIME    NOT NULL
);

CREATE UNIQUE INDEX UX_biz_order_OrderNo ON biz_order(OrderNo);
CREATE INDEX IX_biz_order_CustomerId ON biz_order(CustomerId);

CREATE TABLE biz_order_line (
    OrderLineId BIGINT       NOT NULL PRIMARY KEY,
    OrderId     BIGINT       NOT NULL,
    ProductId   BIGINT       NOT NULL,
    Quantity    INT          NOT NULL,
    UnitPrice   DECIMAL(18,2) NOT NULL
);

CREATE INDEX IX_biz_order_line_OrderId ON biz_order_line(OrderId);

注意几个细节:

  • LastUpdateTime 通常由框架维护,用于乐观并发检查;
  • 唯一索引不是框架自动创建的,需要在特性里声明或手工建表;
  • 子表外键是否建立,取决于团队的数据库规范;框架的级联删除是应用层级联,不依赖数据库外键;
  • 枚举通常存为整数,便于索引与比较,但可读性差,需要配套字典表或文档。

6.4 服务接口

csharp 复制代码
public interface IOrderService
{
    Task<long> CreateAsync(CreateOrderInput input);
    Task PayAsync(long orderId);
    Task CancelAsync(long orderId, string reason);
    Task<OrderOutput> GetAsync(long orderId);
    Task<PageResult<OrderListItem>> SearchAsync(OrderQuery query);
}

接口定义在独立的契约程序集里,供 Web 层、RPC 客户端、消息消费者共同引用。这是"位置透明"的前提:调用方只知道接口,不知道实现部署在哪里。

6.5 服务实现

csharp 复制代码
public class OrderService : XDomainService<Order, long>, IOrderService
{
    public async Task<long> CreateAsync(CreateOrderInput input)
    {
        var order = new Order
        {
            OrderNo = await NextOrderNoAsync(),
            CustomerId = input.CustomerId,
            CreatedAt = DateTime.UtcNow,
            Status = OrderStatus.Created
        };

        foreach (var line in input.Lines)
            order.AddLine(line.ProductId, line.Quantity, line.UnitPrice);

        await SaveAsync(order);

        await PublishAsync(new OrderCreatedEvent
        {
            OrderId = order.OrderId,
            CustomerId = order.CustomerId,
            Amount = order.Amount,
            OccurredAt = DateTime.UtcNow
        });

        return order.OrderId;
    }

    public async Task PayAsync(long orderId)
    {
        var order = await GetByKeyAsync(orderId)
            ?? throw new XBusinessException("订单不存在");

        order.Pay();
        await SaveAsync(order);
    }

    public async Task CancelAsync(long orderId, string reason)
    {
        var order = await GetByKeyAsync(orderId)
            ?? throw new XBusinessException("订单不存在");

        order.Cancel(reason);
        await SaveAsync(order);
    }

    public Task<OrderOutput> GetAsync(long orderId)
    {
        return QueryOneAsync<OrderOutput>(orderId);
    }

    public Task<PageResult<OrderListItem>> SearchAsync(OrderQuery query)
    {
        return Where(o => o.CustomerId == query.CustomerId)
            .AndIf(query.Status.HasValue, o => o.Status == query.Status.Value)
            .AndIf(query.From.HasValue, o => o.CreatedAt >= query.From.Value)
            .AndIf(query.To.HasValue, o => o.CreatedAt < query.To.Value)
            .OrderByDescending(o => o.CreatedAt)
            .ToPageAsync(query.PageIndex, query.PageSize);
    }
}

这段代码刻意保留了框架的典型风格:

  • 服务继承泛型领域服务基类,自动获得保存、查询、规约能力;
  • SaveAsync 不是简单的仓储调用,而是一条保存管线;
  • PublishAsync 的时机由上下文决定;
  • 查询用 Lambda 表达,而不是拼 SQL;
  • 分页返回统一的 PageResult<T>。

6.6 API 暴露

如果使用动态 API,控制器可以极薄,甚至不需要手写:

csharp 复制代码
[DynamicApi]
public class OrderApi : XApiController
{
    private readonly IOrderService _orders;

    public OrderApi(IOrderService orders) => _orders = orders;

    [HttpPost("create")]
    public Task<long> Create([FromBody] CreateOrderInput input) => _orders.CreateAsync(input);

    [HttpPost("{id:long}/pay")]
    public Task Pay(long id) => _orders.PayAsync(id);

    [HttpGet("{id:long}")]
    public Task<OrderOutput> Get(long id) => _orders.GetAsync(id);
}

动态 API 层会自动处理:

  • 路由前缀与命名约定;
  • 参数绑定与校验;
  • 统一响应包装;
  • 异常到状态码的映射;
  • 结果格式化(或按特性关闭)。

6.7 配置片段

xml 复制代码
<DataAccesses default="main">
  <Object id="main" type="SqlServerAccess, X.Data">
    <ConnectionString>Server=...;Database=...;...</ConnectionString>
  </Object>
  <Object id="cache" type="RedisAccess, X.Data">
    <ConnectionString>127.0.0.1:6379</ConnectionString>
  </Object>
</DataAccesses>

<DomainService mode="inner" sqlType="sqlserver" cache="cache" log="on">
  <In>
    <Dll mode="con">X.Order.Service</Dll>
  </In>
</DomainService>

<PublishSubscribe provider="kafka" app="order">
  <Broker servers="127.0.0.1:9092" />
</PublishSubscribe>

<SerialNumber provider="snowflake" workerId="1" datacenterId="1" />

6.8 一次创建请求的完整路径

把前面的链路具体化:

  1. HTTP 进入 :POST /api/order/create,携带 Token。
  2. 认证:解析 Token,得到用户 ID、租户 ID、角色。
  3. 限流:按用户维度检查。
  4. 控制器 :绑定 CreateOrderInput,执行模型校验。
  5. 动态 API:包装结果,准备统一响应。
  6. 服务门面 :IOrderService 解析到本地实例(或远程代理)。
  7. 领域服务 :进入 CreateAsync。
  8. 订单号:调用序列号服务,获取全局唯一 ID。
  9. 构建聚合 :new Order(),逐行 AddLine,金额累加。
  10. 保存 :SaveAsync 触发保存管线。
  11. 元数据 :确定 Order 与 OrderLine 的表映射、关系类型。
  12. 工作单元:登记新增的订单与订单行。
  13. 事务:开启数据库事务。
  14. 仓储 :生成主表 INSERT 与子表 INSERT,参数化。
  15. 数据访问:执行批处理,提交事务。
  16. 缓存:按上下文选项更新订单缓存与列表索引。
  17. 事件 :OrderCreatedEvent 入队,事务提交后发布到 Kafka。
  18. 返回:订单 ID 返回控制器,包装为统一响应。
  19. 审计:记录操作日志、耗时、TraceId。

任何一步失败,系统的行为都不同:

  • 第 3 步失败:请求被拒绝,无副作用;
  • 第 8 步失败:没有订单,但序列号可能已被消耗(取决于号段实现);
  • 第 13 步失败:无法开启事务,通常直接失败;
  • 第 15 步失败:事务回滚,缓存可能已更新(取决于顺序);
  • 第 16 步失败:数据库已提交,缓存可能不一致;
  • 第 17 步失败:数据库已提交,事件可能丢失或重复。

这张"失败语义表"比任何架构图都重要。设计系统时,应该为每一步明确:失败是否可重试、是否可补偿、是否需要告警、是否会留下脏数据。

6.9 如何测试这条链路

一个常见的错误是只写"服务层单元测试",mock 掉仓储,验证 SaveAsync 被调用。这种测试无法发现:

  • 元数据映射错误(表名、列名、关系类型);
  • SQL 方言问题;
  • 事务提交顺序问题;
  • 序列化/反序列化问题;
  • 缓存与数据库不一致。

更有效的测试分层:

层级 目标 依赖
聚合单元测试 业务规则、状态迁移 无
仓储集成测试 SQL 正确性、映射、并发 真实数据库(可用 SQLite/容器)
服务集成测试 保存管线、上下文、事件 数据库 + 内存消息/缓存
API 契约测试 路由、绑定、响应结构 测试宿主
端到端测试 关键业务流 完整环境或容器编排

第 28 章会给出完整的测试宿主与数据隔离方案。

6.10 这个例子在演化时会遇到什么

假设半年后业务变化:

  1. 订单需要支持部分支付;
  2. 订单行需要记录折扣;
  3. 订单需要按商家拆分;
  4. 查询需要支持全文搜索;
  5. 订单量从百万级增长到十亿级。

这些变化分别会触碰不同的脊柱:

  • 部分支付:聚合状态机与支付记录子实体,触碰 K5/K6;
  • 行折扣:模型与表结构,触碰 K4/K11;
  • 按商家拆分:聚合边界与分片键,触碰 K23;
  • 全文搜索:索引同步与查询路由,触碰 K18/K8;
  • 十亿级:分库分表、归档、冷热分离,触碰 K23/K7。

框架能帮你完成"机械部分",但边界怎么划、分片键怎么选、一致性怎么保证,仍然是架构决策。这也是本系列反复强调的观点:框架解决的是"如何表达",不是"表达什么"。

7. 部署拓扑与运行时角色

7.1 进程边界决定了一切

同一套代码,部署成一个进程还是十个进程,行为会完全不同。进程内调用可以共享事务、共享缓存、共享异常栈;跨进程调用只能共享协议与契约。框架通过"位置透明"尽量抹平差异,但抹不平的是故障模型。

先看四种常见的运行时角色:

角色 主要职责 典型入口 状态
Web 宿主 面向前端/第三方,处理 HTTP ASP.NET Core 管道 无状态(会话在 Token/缓存)
RPC 宿主 面向内部服务,提供领域服务 DotNetty 端口 无状态(上下文随调用传入)
Worker 宿主 消费消息、执行定时任务 Kafka/定时器 有消费位点状态
网关/代理 路由、鉴权、限流、聚合 HTTP 无状态

这四种角色可以合并,也可以拆分。选择哪种拓扑,取决于三个因素:团队边界、故障隔离需求、伸缩模式。

7.2 拓扑一:一体化进程

text 复制代码
┌─────────────────────────────────────────┐
│             单一进程                      │
│  HTTP 管道 + 领域服务 + 仓储 + 消息消费   │
└─────────────────────────────────────────┘
        │              │
        ▼              ▼
     数据库          Redis/Kafka

优点:

  • 部署简单,一个包启动全部能力;
  • 调用都是进程内调用,没有网络故障;
  • 事务、缓存、事件在同一进程内协调,调试方便;
  • 适合中小规模系统、内部工具、快速验证。

缺点:

  • 任一模块的资源问题会拖垮整个进程;
  • 无法按模块独立伸缩;
  • 发布必须整体进行,变更影响面大;
  • 长耗时任务(消息消费、报表)会占用 Web 线程池。

适用判断:日请求量在百万级以下、团队规模小于十人、没有严格的故障隔离要求时,一体化进程是理性选择。不要为了"架构先进"提前拆分。

7.3 拓扑二:API 与 RPC 分离

text 复制代码
         ┌────────────┐
Client → │  Web 宿主   │──RPC──┐
         └────────────┘       │
                              ▼
                       ┌────────────┐
                       │  RPC 宿主   │──→ DB / Redis / Kafka
                       └────────────┘

动机:

  • Web 层负责协议转换、认证、限流、聚合;RPC 层负责领域能力;
  • 两者可以独立伸缩:Web 层抗并发,RPC 层扛计算;
  • Web 层可以不持有数据库连接,降低攻击面;
  • RPC 层可以被多个前端(Web、App、第三方)复用。

代价:

  • 一次请求多一跳网络,P99 延迟上升;
  • 需要服务注册与发现;
  • 上下文(用户、租户、TraceId)必须显式传播;
  • 分布式事务/一致性复杂度上升;
  • 故障排查需要跨进程串联日志。

关键设计点:

  1. 粒度:RPC 接口应该按业务能力划分,而不是按表划分。一个"创建订单"接口可能内部操作多张表,这没问题;但不要暴露"更新订单表某一列"这种数据库级接口。
  2. 超时预算:Web 层超时必须大于 RPC 层超时加上网络抖动,否则 Web 层先超时,RPC 层还在执行,造成"客户端已放弃、服务端仍在写"。
  3. 幂等:跨进程调用必须假设可能重试。创建类接口要带幂等键;更新类接口要带版本号或状态条件。
  4. 上下文传播:至少传播用户身份、租户、TraceId、语言、调用来源。缺少 TraceId,排障会退化成"猜"。

7.4 拓扑三:多分组服务

当服务规模变大,单一 RPC 宿主也会成为发布瓶颈。框架支持按"服务分组"部署:同一类服务可以注册到不同分组,调用方按分组路由。

text 复制代码
                 ┌──────────────┐
                 │ 服务注册中心  │
                 └──────┬───────┘
        ┌───────────────┼───────────────┐
        ▼               ▼               ▼
   ┌─────────┐     ┌─────────┐     ┌─────────┐
   │ 订单组   │     │ 商品组   │     │ 用户组   │
   │ RPC×3   │     │ RPC×5   │     │ RPC×2   │
   └─────────┘     └─────────┘     └─────────┘

分组维度:

  • 按业务域分组(订单、商品、用户);
  • 按租户分组(大客户独立部署);
  • 按版本分组(灰度);
  • 按机房/可用区分组(就近调用);
  • 按读写分离分组(查询走只读实例)。

风险:

  • 分组过多导致注册信息膨胀、路由复杂;
  • 跨组调用形成隐式依赖,发布顺序难以协调;
  • 分组故障时,调用方可能重试到其他组,造成雪崩;
  • 配置漂移:不同组的配置不一致,行为不同。

一个实用原则是:分组应该对应"独立的故障域与伸缩单元",而不是对应"代码目录"。 如果两个服务总是同时发布、共享同一数据库、故障时一起挂,那它们不应该分成两组。

7.5 拓扑四:多租户与单元化

当系统服务多个租户,尤其是大客户要求数据隔离时,常见三种模式:

模式 隔离级别 成本 适用
共享库共享表 低(租户字段隔离) 最低 中小租户、SaaS 标准版
共享库独立 Schema 中 中 中大型租户
独立库/独立部署 高 最高 金融、医疗、大客户定制

框架层面,多租户通常体现为:

  • 实体基类带租户字段;
  • 上下文携带租户 ID;
  • 仓储自动追加租户过滤;
  • 数据源路由按租户选择连接;
  • 缓存键带租户前缀;
  • 消息主题/分区键带租户维度。

最容易出问题的地方:

  1. 漏加租户过滤:某个手写 SQL 忘了带租户条件,导致跨租户数据泄露;
  2. 缓存键冲突:两个租户的相同业务键命中同一缓存;
  3. 异步任务丢租户:消息消费时没有恢复租户上下文;
  4. 定时任务跨租户:批处理误操作所有租户;
  5. 索引未隔离:搜索索引没有租户字段,查询串数据。

第 25 章会给出完整的租户隔离检查清单。

7.6 服务注册与发现

在 RPC 拓扑中,调用方需要知道服务地址。框架通常把注册信息写入 ZooKeeper 一类的协调服务。一个典型的注册结构:

text 复制代码
/服务根/{作用域}/Dll/{程序集名}/{服务接口名}
    └── 子节点:地址列表(IP:Port),临时节点

注册过程:

  1. 宿主启动,加载领域服务;
  2. 绑定网络端口;
  3. 向注册中心写入临时节点;
  4. 定期发送心跳(由客户端库维护会话);
  5. 进程退出时主动删除节点。

调用过程:

  1. 服务门面按接口名查询地址列表;
  2. 在本地缓存地址;
  3. 按策略选择一个地址(轮询/随机/最少连接);
  4. 建立或复用连接;
  5. 调用失败时,刷新地址列表并重试。

故障模式:

  • 僵尸注册:进程已经假死,但 ZooKeeper 会话还没过期,地址仍在列表中。调用方会持续把请求发给一个不响应的实例。
  • 会话抖动:网络抖动导致临时节点被删除,服务"消失",流量打到剩余实例,引发过载。
  • 注册中心不可用:新实例无法注册,已有实例的地址缓存还能工作,但扩容/重启会失败。
  • 地址缓存过期:调用方缓存了旧地址,实例已经下线,请求持续失败。

应对策略:

  1. 注册中心与业务请求解耦:注册中心故障不应阻断已有调用;
  2. 客户端缓存 + 定期刷新 + 失败时强制刷新;
  3. 健康检查与主动摘除;
  4. 优雅下线:先摘除地址,等待存量请求完成,再关闭进程;
  5. 限制重试次数与熔断,避免把一个实例的故障放大为全局故障。

7.7 网络分区与超时预算

跨进程调用必须设定超时。超时不是一个数字,而是一组预算:

text 复制代码
总预算 3000ms
  ├── 连接建立 300ms
  ├── 服务端排队 200ms
  ├── 业务执行 1500ms
  ├── 序列化/网络 300ms
  └── 重试预留 700ms

如果客户端超时 1000ms,而服务端业务平均耗时 1200ms,那么大部分请求都会"客户端超时、服务端成功"。这不仅浪费资源,还会让重试变成重复执行。

正确的做法是:

  • 服务端暴露 P50/P95/P99 耗时;
  • 客户端超时设置在 P99 之上,并留出重试预算;
  • 对长耗时操作使用异步任务 + 轮询/回调,而不是无限拉长超时;
  • 超时后不要盲目重试写操作,除非有幂等保证。

7.8 部署拓扑的选择矩阵

维度 一体化 API/RPC 分离 多分组 单元化
部署复杂度 低 中 高 很高
故障隔离 差 中 好 最好
独立伸缩 无 有 强 强
调用延迟 最低 +1 跳 +1~2 跳 取决于路由
一致性难度 低 中 高 很高
适用规模 小 中 大 超大/强隔离
团队要求 低 中 高 很高

选择拓扑时,不要只看"最终形态",要看"演化路径"。一个务实的路线是:

  1. 从一体化开始,但保持模块边界清晰;
  2. 当某一模块的资源消耗或发布频率显著高于其他模块时,拆出独立宿主;
  3. 当多个前端需要复用同一能力时,引入 RPC;
  4. 当单一服务成为瓶颈或故障域过大时,再考虑分组;
  5. 只有当租户隔离成为硬性合规要求时,才做单元化。

提前拆分不是免费的投资,而是提前承担分布式复杂度。 在没有足够运维能力与可观测性之前,拆分只会让问题更难定位。


8. 设计取舍:这套框架为什么长成这样

任何框架都是取舍的产物。理解取舍,比记住 API 更有价值。本节挑出十个最关键的设计决策,逐一分析其收益、代价与适用边界。

8.1 约定优于配置

表现:控制器命名、路由生成、服务实现查找、表名推导、主键命名、仓储方法选择,都有一套默认约定。

收益:

  • 减少重复配置,降低新项目启动成本;
  • 同一团队的项目结构一致,降低维护成本;
  • 约定本身成为一种"架构约束",减少随意性。

代价:

  • 约定不直观时,排查成本高("为什么这个接口没被注册?");
  • 约定冲突时,覆盖机制复杂;
  • 不同版本之间约定可能变化,升级时出现隐性破坏;
  • 过度约定会让框架变成"黑盒"。

适用边界:约定应该只覆盖"高频、稳定、无歧义"的决策。对于安全、事务、序列化格式这类高风险决策,应该显式配置,而不是依赖默认值。

8.2 元数据驱动

表现:聚合树、关系类型、表映射、代码生成都从类型声明推导。

收益:

  • 单一事实来源,模型变更自动传导;
  • 减少手写映射代码;
  • 支持代码生成、文档生成、影响分析;
  • 运行时可以基于元数据做通用处理(加载、保存、查询)。

代价:

  • 反射与元数据构建有启动成本;
  • 元数据错误会同时影响多个环节;
  • 动态行为难以静态分析,IDE 支持有限;
  • 复杂继承/多态/泛型场景容易触发边界缺陷;
  • 调试时需要理解元数据树,而不是只看代码。

适用边界:领域模型相对稳定、团队愿意遵守声明规范时,收益最大。如果模型频繁变化、大量使用动态类型或跨程序集继承,元数据的维护成本会迅速上升。

8.3 IL 动态代理

表现 :运行时用 Reflection.Emit 生成代理类型,把方法调用重写为切面管道。

收益:

  • 业务代码零侵入;
  • 可以对接口、虚方法、甚至部分具体类型织入;
  • 切面顺序、全局切面、异常处理统一;
  • 性能优于 DispatchProxy 一类的高层反射代理(调用路径更短)。

代价:

  • 生成逻辑复杂,调试困难;
  • 不同运行时(.NET Framework/.NET Core)IL 差异需要兼容;
  • 泛型方法、ref/out 参数、异步方法的状态机处理容易出错;
  • 代理类型无法被静态分析工具完整理解;
  • 生成失败时错误信息可能非常晦涩。

适用边界:横切逻辑多、调用量大、希望零侵入时值得。若团队无法维护 IL 代码,应优先考虑源码生成器或显式装饰器。

8.4 环境上下文(Ambient Context)

表现 :当前用户、租户、TraceId、序列化器、服务上下文等通过 AsyncLocal 或线程静态变量隐式传递。

收益:

  • 方法签名干净,不需要层层传递上下文对象;
  • 框架可以在任意深度获取调用信息;
  • 对业务代码透明。

代价:

  • 隐式依赖,阅读代码时看不出上下文从哪来;
  • 异步/并发场景容易丢失或串数据;
  • 测试时需要显式设置上下文;
  • 后台线程、线程池任务、消息回调可能继承错误的上下文;
  • 调试困难,出现"上下文为空"时很难定位是哪个环节丢的。

适用边界:上下文应该是"只读的调用元数据",而不是"可变的业务状态"。一旦把业务状态放进环境上下文,系统就会变得难以推理。第 8 章会详细讨论上下文的生命周期与传播规则。

8.5 富领域模型与重基类

表现:实体基类非常重,内置状态跟踪、懒加载、序列化、权限、工作流、事件等能力。

收益:

  • 业务实体开箱即用;
  • 框架可以统一处理所有实体;
  • 减少重复代码;
  • 领域模型与基础设施紧密结合,开发效率高。

代价:

  • 基类成为"上帝对象",职责过多;
  • 继承链固化,难以替换实现;
  • 单元测试需要绕过基类的静态依赖;
  • 序列化/反序列化与基类状态耦合;
  • 领域模型难以脱离框架独立演化。

适用边界:这是典型的"框架化"取舍------用侵入性换取一致性。如果项目生命周期长、团队规模大、需要统一治理,这种取舍是合理的;如果只是一个小型服务,轻量实体 + 显式基础设施可能更合适。

8.6 自定义序列化协议

表现:不是直接使用 JSON.NET 或 Protobuf 的裸格式,而是在外面套一层自定义报文头,携带类型信息与序列化器标识。

收益:

  • 报文自描述,接收方无需提前知道类型;
  • 可以在 JSON 与 Protobuf 之间切换;
  • 可以处理跨运行时类型名差异;
  • 便于协议演进与诊断。

代价:

  • 报文体积增大(类型名可能很长);
  • 与标准工具(Postman、抓包分析器)不兼容;
  • 类型名成为协议的一部分,重命名类型会破坏兼容性;
  • 需要自己维护跨语言/跨版本兼容逻辑;
  • 安全风险:反序列化任意类型名可能导致类型加载攻击。

适用边界:内部服务之间、类型相对稳定、需要自描述能力时适用。对外 API 应使用标准协议(JSON/Protobuf/gRPC),不要暴露自定义报文头。

8.7 自研 RPC 而非 gRPC

表现:基于 DotNetty 自建 RPC,方法调用/返回消息自定义,客户端代理用 Roslyn 生成。

收益:

  • 完全可控:超时、重试、连接池、异常、上下文传播都可以按需定制;
  • 与框架的领域服务模型深度集成;
  • 不依赖 HTTP/2、TLS 等外部条件;
  • 报文可以复用自定义序列化协议。

代价:

  • 生态与工具链弱:没有标准的反射、拦截器、负载均衡、可观测性集成;
  • 跨语言支持几乎为零;
  • 协议演进需要自己设计版本机制;
  • 安全问题需要自己处理(加密、认证、重放);
  • 人才稀缺,维护成本高。

适用边界:内部 .NET 服务、对性能与可控性要求高、团队有网络编程能力时适用。若需要跨语言或与云原生生态集成,gRPC 是更稳妥的选择。

8.8 应用层 SQL 构建而非完整 ORM

表现:仓储根据元数据与规约拼装 SQL,而不是使用完整的 ORM 跟踪对象图。

收益:

  • SQL 可控,性能可预测;
  • 批量操作、分页、分片可以做针对性优化;
  • 不依赖 ORM 的变更跟踪与懒加载代理;
  • 对数据库方言有更强的控制力。

代价:

  • 需要自己处理参数化、注入防护、类型映射;
  • 复杂关联查询需要手写 SQL;
  • 数据库方言差异需要适配;
  • 元数据与数据库结构可能漂移;
  • 缺少 ORM 的迁移工具与生态。

适用边界:查询模式相对固定、性能敏感、需要精细控制 SQL 时适用。对于复杂报表、临时查询,仍然需要预留手写 SQL 的通道。

8.9 本地消息表与至少一次投递

表现:消息发送与业务写入在同一个数据库事务中落表,再由后台任务投递;消费端记录位点/状态,支持重试。

收益:

  • 避免"业务成功、消息丢失";
  • 可以审计消息状态;
  • 支持人工补偿与重放;
  • 不依赖分布式事务。

代价:

  • 消息表成为热点,需要分表/归档;
  • 投递延迟高于直接发送;
  • 消费端必须幂等;
  • 状态机复杂(待发送/发送中/成功/失败/死信);
  • 多实例投递需要抢占机制,避免重复发送。

适用边界:业务事件不能丢失、可以接受秒级延迟时适用。对于日志、指标这类可丢失数据,不需要本地消息表。

8.10 多数据库支持

表现:同一套仓储抽象支持 SQL Server、MySQL、SQLite 等方言。

收益:

  • 开发/测试可以用轻量数据库;
  • 客户可以按环境选择数据库;
  • 降低供应商锁定。

代价:

  • 抽象泄漏:分页语法、类型映射、事务隔离级别、锁行为都不同;
  • 测试矩阵膨胀;
  • 某些高级特性只能在特定数据库使用;
  • "支持"往往意味着"最低共同 denominator",性能与功能都受限。

适用边界:产品需要交付到不同客户环境时,多数据库支持是必要成本。若只运行在自有云环境,选择一种数据库并深度优化,往往更划算。

8.11 这套框架不适合什么场景

任何框架都有边界。以下场景使用这套框架会"用力过猛"或"南辕北辙":

  1. 纯 CRUD 后台:只有表单增删改查,没有复杂业务规则。直接使用轻量 ORM + 控制器更高效。
  2. 极致低延迟:每请求要求微秒级响应,无法承受反射、代理、序列化、上下文开销。应使用手写管线与零分配设计。
  3. 跨语言为主:主要服务用 Java/Go,.NET 只是边缘组件。自研 RPC 与自定义序列化会成为互操作障碍。
  4. 事件溯源原生:系统以事件日志为唯一事实来源,需要重放、快照、投影。这套框架的聚合保存模型不是事件溯源。
  5. 强实时计算:流式计算、复杂事件处理、低延迟交易。应使用专门的流处理引擎。
  6. 团队没有 DDD 经验:框架提供了 DDD 的"形状",但无法替代边界判断。强行使用会得到"有聚合根的事务脚本"。

8.12 取舍总表

决策 主要收益 主要代价 关键风险
约定优于配置 开发效率、一致性 黑盒感、升级破坏 约定冲突难排查
元数据驱动 单一事实来源 启动成本、调试难 元数据错误放大
IL 代理 零侵入、统一横切 复杂、难调试 异步/泛型边界缺陷
环境上下文 签名干净 隐式依赖 异步丢上下文/串数据
富基类 开箱即用 侵入性强 基类上帝对象
自定义序列化 自描述、可切换 兼容性、安全 类型名演进破坏
自研 RPC 可控、深度集成 生态弱、维护贵 跨语言/安全缺口
应用层 SQL 性能可控 映射/方言负担 SQL 注入/漂移
本地消息表 不丢消息、可审计 延迟、状态复杂 重复投递
多数据库 交付灵活 抽象泄漏 测试矩阵膨胀

理解这张表的意义在于:当你在项目中遇到框架的"别扭之处",先判断它是设计取舍的代价 ,还是实现缺陷。前者需要通过架构手段规避,后者值得反馈与修复。把两者混为一谈,就会陷入"要么全盘接受、要么推翻重写"的极端。

9. 十二个常见误解

这一节列出在实际项目中反复出现的误解。每一个误解都不是"知识盲点",而是"把某个局部保证错误地推广到了全局"。

9.1 "保存返回成功,数据就一定一致了"

保存返回成功,最多说明主存储的写入路径没有抛出异常。它不保证:

  • 缓存已更新;
  • 搜索索引已更新;
  • 领域事件已发布;
  • 读库已同步;
  • 其他聚合已收到通知;
  • 下游系统已处理。

在读写分离、缓存、搜索引擎、消息队列并存的环境里,"保存成功"只是一个局部事实。系统级的一致性需要额外的机制:事务、补偿、对账、重试、幂等。

正确的问法:保存成功之后,哪些读模型可能仍然是旧的?旧多久?谁负责修复?

9.2 "把操作包进事务就一致了"

数据库事务只覆盖同一个数据库连接内、同一组资源上的操作。它无法覆盖:

  • Redis 写入;
  • 搜索引擎写入;
  • Kafka 发送;
  • 另一个数据库;
  • 远程服务调用。

即使所有操作都在一个数据库里,长事务也会带来锁竞争、日志膨胀、回滚成本。更常见的情况是:事务包住了远程调用,远程服务成功,本地事务回滚,于是远程副作用无法撤销。

正确的问法:这个事务的边界在哪里?边界之外的副作用如何补偿?

9.3 "缓存只是性能优化,不影响正确性"

缓存一旦参与读路径,它就是读模型的一部分。缓存不一致就是数据不一致,只是表现为"某些用户在某些时刻看到旧值"。

更危险的是"缓存 + 二级索引"的组合:列表查询不是直接查数据库,而是查一个由写入路径维护的索引。索引更新失败时,数据库是对的,列表是错的,而且错误会持续到下一次修复。

正确的问法:缓存的失效策略是什么?写入路径失败时,缓存由谁修复?缓存与数据库的版本如何对齐?

9.4 "RPC 调用和本地调用差不多"

本地调用只有两种结果:返回或抛异常。RPC 调用有第三种结果:未知。超时、连接断开、进程崩溃都可能发生在服务端已执行之后。

本地调用不会序列化,RPC 会;本地调用共享事务,RPC 不共享;本地调用共享内存,RPC 共享的是协议;本地调用的异常栈完整,RPC 的异常需要跨进程序列化;本地调用的对象引用有效,RPC 传递的是值。

正确的问法:这次调用超时后,服务端是否可能已经成功?如果是,客户端应该如何确认或补偿?

9.5 "用了消息队列就不会丢消息"

消息队列通常保证"Broker 收到消息后不丢"(在持久化与副本配置正确的前提下),但不保证:

  • 生产者发送成功(网络失败可能发生在确认之前);
  • 消费者处理成功(消费后、提交位点前崩溃会重复;先提交后处理会丢失);
  • 消息顺序(多分区、多消费者);
  • 消息只消费一次(除非有端到端幂等)。

"至少一次"意味着重复是常态;"至多一次"意味着丢失是常态;"恰好一次"通常只在特定范围内成立(例如 Kafka 事务 + 幂等生产者 + 消费端去重)。

正确的问法:这条消息重复消费会怎样?丢失会怎样?哪种后果更不可接受?

9.6 "聚合越大,用起来越方便"

把订单、订单行、支付记录、物流记录、发票、优惠券全塞进一个聚合,确实可以在一次保存里搞定所有关系。但代价是:

  • 加载一个订单会带回大量无关数据;
  • 保存时产生大量 SQL 与锁;
  • 并发修改不同子对象时互相阻塞;
  • 聚合边界模糊,业务规则耦合;
  • 分库分表时,所有子表必须同片。

聚合应该小到"一个事务内必须保持一致"的最小集合,而不是"页面上一起展示"的最大集合。

正确的问法:哪些数据必须在同一个事务里原子更新?哪些可以最终一致?

9.7 "懒加载没有成本"

懒加载把查询推迟到真正访问属性时,看起来"按需加载"。但它有两个隐性成本:

  1. N+1 查询:遍历 100 个订单,每个订单访问一次客户,可能产生 101 次查询;
  2. 上下文依赖:懒加载需要数据库连接、元数据、会话上下文。在序列化、后台线程、缓存反序列化时访问懒加载属性,可能直接失败。

正确的问法:这个属性在哪些场景会被访问?是否需要显式预加载?序列化时是否会触发懒加载?

9.8 "分页只是把 page 和 size 传给数据库"

分页涉及三个独立的语义:

  • 排序是否稳定:排序键不唯一时,数据库可以任意排列并列值;
  • 总量是否一致:分页查询与 count 查询是否在同一快照、同一过滤条件下执行;
  • 偏移是否高效 :OFFSET 很大时,数据库需要扫描并丢弃大量行。

此外,分页还涉及 UI 语义:用户看到的是"第几页",还是"游标"?数据在翻页过程中新增/删除时,页码会漂移。

正确的问法:排序键是否唯一?是否需要游标分页?count 是否必须实时?

9.9 "元数据只在启动时构建一次,没有运行时代价"

元数据构建确实通常只发生一次,但运行时的读取并不免费:

  • 每次保存/查询都可能查元数据字典;
  • 反射获取属性值有成本;
  • 元数据缓存可能因为类型动态加载而失效;
  • 多态/继承场景需要遍历树;
  • 元数据错误会在运行时才暴露。

正确的问法:热路径上是否有重复的元数据查找?能否缓存到局部变量?元数据构建失败时,启动是否会快速失败?

9.10 "框架会自动处理多租户"

框架可以提供租户字段、上下文、过滤器的"形状",但无法保证:

  • 每一处查询都加了租户条件;
  • 每一个缓存键都带了租户前缀;
  • 每一个异步任务都恢复了租户上下文;
  • 每一个索引都包含租户字段;
  • 每一次跨租户操作都被显式审计。

多租户是系统性约束,需要代码规范、测试、审计、静态检查共同保证。

正确的问法:如果我在任意一个查询里删掉租户条件,系统会不会发现?测试能不能覆盖?

9.11 "重试是免费的可靠性提升"

重试的收益是"提高瞬时故障下的成功率",代价是:

  • 放大流量:下游已经过载,重试让它更过载;
  • 延长延迟:每次重试都在原有超时上叠加;
  • 重复副作用:写操作可能被执行多次;
  • 掩盖问题:持续重试让真正的故障延迟暴露;
  • 耗尽资源:连接、线程、内存被重试请求占用。

重试必须与超时、幂等、熔断、退避、重试预算一起设计。没有这些配套,重试是故障放大器。

正确的问法:这个错误是瞬时的还是持久的?重试会不会重复副作用?下游是否已经过载?

9.12 "日志越多,排障越容易"

日志的价值取决于信噪比,而不是数量。过多的日志会导致:

  • 磁盘/网络/存储成本上升;
  • 关键信息被淹没;
  • 日志框架本身成为性能瓶颈;
  • 敏感信息泄露风险上升;
  • 分布式环境下难以关联。

有效的日志应该具备:结构化字段、统一 TraceId、明确的级别语义、采样策略、敏感信息脱敏。第 23 章会给出日志规范与追踪设计。


10. 性能、安全与可靠性总览

这三个主题贯穿全书。这里先建立一张"成本地图",后续章节会逐项展开。

10.1 性能成本地图

一次典型的领域服务调用,成本分布大致如下(数量级仅供建立直觉,实际以压测为准):

环节 典型成本 可控手段
网络往返 0.1~10ms 连接复用、就近部署、减少跳数
认证/限流 0.01~1ms 缓存身份、本地限流
参数绑定/校验 0.01~0.5ms 减少反射、缓存元数据
AOP 代理 微秒级/次 减少切面、避免重复代理
序列化 微秒~毫秒 选择协议、避免大对象、复用缓冲
元数据查找 纳秒~微秒 本地缓存、避免热路径反射
SQL 生成 微秒级 缓存 SQL 模板、参数化
数据库执行 0.1~100ms 索引、减少往返、批量
缓存访问 0.1~2ms 连接池、批量、Pipeline
消息发送 1~20ms 批量、异步、本地消息表
日志 微秒~毫秒 异步、采样、结构化

几个容易忽略的成本:

  1. 反射:属性读取、特性解析、构造函数调用都比直接代码慢一个数量级。框架通过缓存与 IL 降低开销,但热路径仍应避免重复反射。
  2. 装箱 :值类型在 object 参数、字典键、序列化过程中可能装箱。高频调用会产生 GC 压力。
  3. 字符串拼接:SQL 构建、日志、序列化类型名都会产生大量字符串。大对象堆(LOH)上的字符串会加剧 GC。
  4. 异步状态机 :async/await 不是免费的。大量短小的异步方法会产生状态机分配与调度开销。
  5. 锁竞争:单例缓存、连接池、序列号生成器都是潜在热点。锁粒度与无锁结构的选择直接影响吞吐。

10.2 安全风险清单

风险 典型场景 防护
SQL 注入 拼接查询条件、排序字段 参数化、白名单、规约
反序列化攻击 接收任意类型名 类型白名单、签名、隔离
越权 只校验登录,不校验数据权限 资源级授权、租户过滤
敏感信息泄露 日志打印密码/Token 脱敏、结构化日志
签名 URL 滥用 OSS 链接永久有效 短有效期、最小权限
重放攻击 RPC/消息被截获重发 时间戳、Nonce、幂等键
CORS 配置过宽 允许任意源携带凭证 白名单、精确匹配
限流缺失 接口被刷 多维度限流、熔断
密钥硬编码 配置文件明文 密钥管理、环境变量
供应链风险 引入不可信依赖 锁定版本、审计、签名

安全不是一个模块,而是一条贯穿请求全链路的约束。框架提供的认证、权限、过滤、限流只是工具,真正的安全边界需要架构师显式设计。

10.3 可靠性设计的基本盘

可靠性设计可以归纳为五个动作:

  1. 超时:任何跨进程调用都必须有超时,且超时预算要分层;
  2. 重试:只对瞬时故障重试,配合退避与预算;
  3. 幂等:写操作必须能安全重复执行;
  4. 隔离:线程池、连接池、队列按依赖隔离,避免一个依赖拖垮全部;
  5. 可观测:日志、指标、追踪三件套,缺一不可。

再叠加两个架构手段:

  1. 补偿:对无法原子完成的操作,设计补偿流程;
  2. 对账:定期比对不同存储之间的数据,发现并修复不一致。

这七个动作不是"最佳实践清单",而是故障发生时的最后防线。一个系统可以没有微服务、没有消息队列,但不能没有超时、幂等和可观测性。


11. 术语表

以下是全系列高频术语的统一定义。后续章节不再重复解释。

术语 定义 关键点
聚合(Aggregate) 一组具有一致性强关联的对象集合 以聚合根为唯一入口
聚合根(Aggregate Root) 聚合的唯一标识与访问入口 外部只能持有根的引用
实体(Entity) 有身份、有生命周期、可变化的对象 身份比属性更重要
值对象(Value Object) 无身份、不可变、以值比较的对象 金额、地址、时间范围
领域服务(Domain Service) 表达跨实体/跨聚合的业务能力 无状态,操作聚合
应用服务(Application Service) 编排领域服务、处理用例 事务边界、权限、DTO
仓储(Repository) 聚合的持久化抽象 面向聚合,不面向表
工作单元(Unit of Work) 跟踪变更、统一提交 事务与提交顺序
规约(Specification) 可组合的查询/校验条件 可翻译为 SQL
DTO 跨边界的数据传输对象 不含行为,不暴露领域对象
O2OE 一对一实体关系 关联对象有独立身份
O2OV 一对一值关系 关联对象是值对象
O2OS 一对一显示关系 关联对象只读展示
O2MH 一对多隐藏关系 子集合不直接暴露
O2MS 一对多显示关系 子集合可导航、可保存
AOP 面向切面编程 日志、事务、缓存、权限
IL 中间语言 动态代理的生成目标
AsyncLocal 异步调用链上下文 随 async/await 流动
ALO At-Least-Once 至少一次,可能重复
EO Exactly-Once 恰好一次(通常需去重)
CAP 本地消息表模式 业务与消息同事务落表
NLB 网络负载均衡 地址路由与健康检查
ZK ZooKeeper 配置中心/注册中心
影子表 与主表结构一致的辅助表 迁移、对账、灰度
分片键 决定数据落在哪个分片的键 决定查询路由
单元化 按租户/地域独立部署 故障隔离与数据合规

12. 一次跨存储不一致的完整复盘

为了避免全书停留在抽象层面,这里给出一个综合案例。它同时涉及上下文、缓存、索引、RPC、消息与重试,是前面七条脊柱的"联合会诊"。

12.1 现象

某电商系统在促销期间出现三类投诉:

  1. 用户支付成功后,订单详情显示"已支付",但订单列表仍显示"待支付";
  2. 运营后台按"已支付"筛选,偶尔能查到订单,偶尔查不到;
  3. 客服系统收到重复的"订单已支付"通知,同一订单有时收到两三次。

三件事发生在同一时间段,但看起来属于不同系统。初步排查时,各团队都认为"自己这边没问题"。

12.2 时间线

text 复制代码
T0  用户点击支付
T1  支付服务回调订单服务(RPC)
T2  订单服务更新订单状态为 Paid
T3  订单服务更新订单缓存
T4  订单服务更新订单列表索引(Redis)
T5  订单服务更新搜索索引(ES)
T6  订单服务发布 OrderPaid 事件
T7  支付服务等待回调响应
T8  支付服务超时(3s),触发重试
T9  订单服务第二次处理回调
T10 订单服务再次更新状态、缓存、索引、事件
T11 客服系统消费到两条 OrderPaid 事件
T12 运营后台查询列表,命中旧索引

12.3 第一轮假设与排除

假设 A:数据库更新失败。 查数据库,状态是 Paid,排除。

假设 B:缓存没有失效。 查 Redis,订单详情缓存是 Paid,列表索引中的状态字段是 Pending。说明详情缓存更新了,列表索引没更新。为什么两者不一致?

假设 C:索引更新代码没执行。 查日志,发现索引更新抛出了异常,但被捕获后只记录了一条 Warn,没有影响主流程。异常原因是 Redis 连接池耗尽,超时 500ms。

假设 D:为什么连接池耗尽? 促销期间支付回调并发高,加上支付服务超时重试,订单服务承受了约 2 倍的写流量。每次写操作都同步更新多个索引,连接占用时间变长,最终池耗尽。

假设 E:为什么支付服务会重试? 订单服务的回调处理耗时在高峰期超过 3s,支付服务的 RPC 超时是 3s。超时后支付服务按"失败"处理并重试。

假设 F:为什么客服收到重复事件? 订单服务在第一次处理时已经发布了事件,第二次处理时又发布了一次。消费端没有幂等去重。

12.4 根因链

把假设串起来,得到一个完整的因果链:

text 复制代码
促销流量上升
  → 订单服务写路径变慢(多次 Redis/ES 往返)
  → 支付服务 RPC 超时(3s)
  → 支付服务重试
  → 订单服务写流量翻倍
  → Redis 连接池耗尽
  → 列表索引更新失败(异常被吞)
  → 列表查询命中旧索引
  → 同时,重复请求导致重复事件
  → 客服重复通知

这不是单一模块的 bug,而是五个设计缺陷叠加:

  1. 写路径过长:一次状态更新同步做了缓存、二级索引、搜索索引、消息发布,任何一环慢都会拖长整个请求。
  2. 失败处理错误:索引更新失败被降级为 Warn,没有进入重试队列,也没有阻断主流程或标记不一致。
  3. RPC 超时与业务耗时倒挂:客户端 3s 超时,服务端 P99 却超过 3s,导致大量"客户端放弃、服务端继续"。
  4. 写操作不幂等:重复回调被当作两次独立的状态变更,重复发布事件。
  5. 消费端不去重:事件消费没有基于业务键的幂等控制。

12.5 修复方案

修复分为"止血"和"根治"两个阶段。

止血(当天):

  1. 支付回调接口增加幂等键(支付流水号),服务端用唯一约束/Redis SETNX 去重;
  2. 将列表索引更新改为异步队列,失败进入重试队列,不再阻塞主流程;
  3. 临时调大 Redis 连接池与 RPC 超时;
  4. 客服系统按 OrderId + EventType 做短期去重。

根治(两周):

  1. 缩短写路径:状态更新只写数据库与本地消息表;缓存失效、索引更新、事件发布由后台任务处理。
  2. 引入本地消息表:业务更新与消息落表在同一事务,后台投递,失败重试,超过阈值进入死信。
  3. 索引版本化 :索引文档带 Version 字段,更新时比较版本,避免旧值覆盖新值。
  4. 读路径兜底:列表查询命中索引后,对关键状态字段回查数据库(或使用短 TTL 缓存),降低脏读窗口。
  5. 超时预算重排:支付服务超时调整为 8s,订单服务内部对 Redis/ES 设置 200ms 超时并快速失败;长耗时操作异步化。
  6. 统一幂等框架:所有消息消费者与 RPC 写接口使用统一的幂等键规范。
  7. 对账任务:每小时比对数据库与索引的状态分布,发现不一致自动修复并告警。

12.6 复盘:五个可以复用的判断

判断一:同步写多个存储,等于把可用性相乘。

如果数据库、Redis、ES 的可用性分别是 99.99%、99.9%、99.9%,串行同步写入的整体可用性约为 99.79%。再叠加超时与重试,实际体验会更差。能异步化的读模型更新,应尽量异步化。

判断二:被吞掉的异常,会以数据不一致的形式回来。

"索引更新失败不影响主流程"听起来是容错,实际上是把一致性债务推迟到未来。正确的做法不是忽略,而是记录、重试、告警、对账。

判断三:超时不是服务端的错,但后果由服务端承担。

客户端超时后重试,服务端会收到重复请求。服务端不能假设"客户端只调用一次",必须把幂等作为默认要求。

判断四:事件重复不是消息队列的缺陷,而是投递语义的必然。

至少一次投递意味着重复必然发生。消费端必须问:"同一条消息消费两次,会发生什么?"如果答案是"会重复扣款/重复通知/重复发货",那就是设计缺陷。

判断五:读模型的一致性需要显式契约。

"最终一致"不是一句口号,它需要明确:多久算最终?谁负责修复?用户看到旧数据时如何提示?没有这些契约,最终一致就是"永远可能不一致"。

12.7 这个案例与七条脊柱的对应关系

脊柱 在本案例中的体现
K0 配置 Redis 连接池、RPC 超时等参数配置不当
K1 代理 事务/缓存切面的顺序影响失败语义
K2 序列化 RPC 消息与事件载荷的版本兼容
K4 元数据 索引字段与实体属性的映射
K6 上下文 写路径的选项决定更新哪些读模型
K7 仓储 状态更新的 SQL 与并发控制
K8 查询 列表查询命中旧索引
K10/K15 服务发现、RPC 超时与重试
K16 消息 重复事件、消费幂等
K18 缓存 二级索引更新失败与版本覆盖

一个生产故障往往横跨五到七条脊柱。这也是为什么排障不能只看单个模块------你必须有一张全局图。


13. 框架能力矩阵

在选型或评估时,可以用一张矩阵快速判断"哪些能力是框架内置的,哪些需要自己补"。下表是概念级矩阵,具体实现随版本变化。

能力 内置程度 说明 需自建/注意
配置装载 强 XML + 配置中心 + 对象工厂 配置校验、灰度、审计
IoC 中 配置驱动的对象图 与标准 DI 容器共存策略
AOP 强 IL 代理、切面排序 异步/泛型边界测试
序列化 强 JSON/Protobuf 混合协议 版本兼容、安全白名单
数据访问 强 多数据库、批处理、路由 连接池调优、慢 SQL
领域建模 强 聚合、关系、懒加载 边界设计、聚合大小
保存管线 强 上下文、UoW、批量 跨存储一致性
查询 强 规约、分页、参数化 索引设计、执行计划
缓存 中 Redis 客户端、二级索引 一致性、击穿、雪崩
搜索 中 ES/Redis OM 集成 索引同步、映射
动态 API 强 约定、路由、包装 契约版本、文档
认证授权 中 Token、IdentityServer 集成 资源级权限、租户
RPC 强 DotNetty、代理、重试 跨语言、安全、可观测
消息 强 Kafka、本地消息表 幂等、顺序、积压
序列号 强 Snowflake、号段 时钟回拨、容量
对象存储 强 多 Provider 签名 URL、生命周期
日志 中 log4net + Kafka 桥 结构化、采样、脱敏
追踪 中 TraceId 传播 跨进程、跨消息
指标 弱 依赖外部 需接入 Prometheus 等
测试 弱 提供宿主与示例 测试框架、数据隔离
部署 弱 提供宿主 容器、编排、发布策略

"内置程度"不是质量评价,而是"开箱即用的范围"。强内置意味着上手快,但也意味着定制时要绕过更多默认行为;弱内置意味着自由度高,但需要团队自己补齐工程能力。


14. 从单体到分布式:一条务实的迁移路线

很多团队在读到"RPC、消息、分片"之后,会迫不及待地拆分系统。但拆分本身不产生价值,拆分带来的独立伸缩与故障隔离才产生价值。下面给出一条按"痛点驱动"的迁移路线。

14.1 阶段零:模块化单体

目标:在不引入分布式复杂度的前提下,把边界划清楚。

做法:

  • 按领域划分程序集与命名空间;
  • 聚合之间不共享实体,只共享 ID 与 DTO;
  • 领域服务之间通过接口调用,不直接引用实现;
  • 数据表按领域分组,禁止跨域 JOIN;
  • 配置按模块分节,日志按模块打标签。

验收标准:任意一个领域模块可以被单独替换实现而不影响其他模块的编译。

14.2 阶段一:读写分离与缓存

目标:解决读压力,而不是拆服务。

做法:

  • 主库写、从库读;
  • 热点数据加缓存;
  • 列表查询引入二级索引;
  • 明确缓存一致性策略;
  • 增加对账任务。

风险:读写延迟导致"写完立刻读"不一致。解决方案是"写后读主库"或"会话一致性"。

14.3 阶段二:异步化

目标:把非关键路径从请求链路中剥离。

做法:

  • 事件发布异步化;
  • 索引更新异步化;
  • 通知、积分、报表等副作用异步化;
  • 引入本地消息表保证不丢;
  • 消费端做幂等。

验收标准:主链路 P99 下降,且异步任务的延迟与失败率可观测。

14.4 阶段三:拆分宿主

目标:让不同负载特征的服务独立伸缩。

拆分信号:

  • 某模块 CPU/内存占用显著高于其他模块;
  • 某模块发布频率远高于其他模块;
  • 某模块的故障不应影响其他模块;
  • 某模块需要独立的安全边界。

做法:

  • 先拆"无状态、依赖少"的模块;
  • 保持数据库共享,避免分布式事务;
  • 通过 RPC 暴露能力;
  • 建立服务注册、发现、超时、重试、追踪。

风险:分布式调用链变长,故障定位变难。必须同步建设可观测性。

14.5 阶段四:数据拆分

目标:解决单库容量与写入瓶颈。

做法:

  • 按业务域拆库;
  • 按分片键分表;
  • 引入分布式 ID;
  • 处理跨片查询与聚合;
  • 设计数据迁移与双写方案。

风险:跨库事务、跨片 JOIN、全局唯一性、数据倾斜。这是复杂度最高的一步,非必要不做。

14.6 阶段五:单元化与多活

目标:故障隔离、地域就近、合规。

做法:

  • 按租户/地域划分单元;
  • 单元内闭环,减少跨单元调用;
  • 全局数据与单元数据分离;
  • 设计单元路由与容灾切换。

风险:成本成倍增加,运维复杂度极高。只有业务规模与合规要求达到一定程度才值得。

14.7 迁移的反模式

  1. 为了微服务而微服务:没有独立伸缩/隔离需求,却拆成十几个服务,结果只是把本地调用变成网络调用。
  2. 数据库不拆,服务先拆:多个服务共享一个库, schema 变更仍然互相耦合,拆分收益有限。
  3. 没有可观测性就拆分:出了问题无法定位,只能靠重启。
  4. 一次性大迁移:风险集中,回滚困难。应该按能力逐步迁移,保持双跑与灰度。
  5. 忽略组织边界:服务边界与团队边界不一致,导致跨团队协调成本高于收益。

15. 版本演进与兼容性策略

框架会升级,业务不能停。兼容性是架构问题,不是"升级时再说"的问题。

15.1 三类兼容性

类型 含义 破坏性
二进制兼容 已编译的程序集能否在新版本上运行 高
源码兼容 现有代码能否重新编译 中
行为兼容 相同输入是否产生相同输出 隐蔽但致命

大多数团队只关注前两类,忽略了行为兼容。例如:

  • 默认超时从 10s 改成 3s;
  • 分页默认排序从主键改成创建时间;
  • 缓存默认开启;
  • 序列化默认从 JSON 改成 Protobuf;
  • 异常类型从业务异常改成系统异常。

这些变更都能编译通过,却会改变生产行为。

15.2 版本策略建议

  1. 契约版本化:RPC 接口、消息 schema、API 路径都要有版本概念;
  2. 向后兼容优先:新增字段可选、新增枚举值有默认分支、不删除已发布字段;
  3. 灰度发布:新版本先接少量流量,观察错误率、延迟、业务指标;
  4. 双写/双读:数据结构变更时,先双写,再迁移读路径,最后删旧;
  5. 回滚预案:每次发布都要能回滚,包括数据库变更;
  6. 变更审计:记录谁在什么时候改了哪个配置/契约。

15.3 序列化兼容性

自定义序列化协议把类型名写进报文,这意味着:

  • 重命名类型 → 旧客户端无法反序列化;
  • 移动命名空间 → 同上;
  • 拆分程序集 → 类型解析可能失败;
  • 删除字段 → 新客户端读旧报文可能得到默认值;
  • 新增必填字段 → 旧客户端读新报文可能校验失败。

应对策略:

  • 对外协议使用稳定的 DTO,不直接序列化领域实体;
  • DTO 放在独立契约程序集,类型名保持稳定;
  • 字段只增不删,删除时先标记废弃;
  • 枚举新增值时,消费端必须有 default 分支;
  • 消息消费端保留旧版本处理能力至少一个发布周期。

15.4 数据库兼容性

  • 新增列必须可空或有默认值;
  • 删除列前先停止读写该列至少一个版本;
  • 重命名列使用"新增 + 双写 + 迁移 + 删除";
  • 索引变更评估锁表时间;
  • 大表变更使用在线工具或影子表;
  • 迁移脚本必须幂等、可重入。

15.5 配置兼容性

  • 新增配置项必须有合理默认值;
  • 删除配置项前先标记废弃并告警;
  • 配置项重命名时同时支持旧名称;
  • 配置中心的结构变更要版本化;
  • 不同角色的配置要分别验证。

16. 请求生命周期:把七条脊柱放进同一个时间轴

16.1 线程视角

一个 HTTP 写请求在服务端通常经历多个线程阶段:

text 复制代码
[IO 线程] 接收连接、解析 HTTP
    │
    ▼
[线程池线程] 认证、路由、绑定、执行业务
    │
    ├── 同步数据库调用:阻塞当前线程(或异步等待)
    ├── Redis 调用:异步等待
    ├── RPC 调用:异步等待 + 超时计时器
    └── 消息发送:异步等待
    │
    ▼
[IO 线程] 写回响应

在理想情况下,异步 I/O 不会占用线程池线程等待网络。但以下情况会导致线程被占用:

  • 使用了同步 API(.Result、.Wait());
  • 数据库驱动不支持真异步;
  • 锁竞争导致线程阻塞;
  • 大量 CPU 密集型操作(序列化、加密、压缩);
  • 线程池饥饿,回调排队。

线程池饥饿是生产环境最常见的"性能悬崖":平时 QPS 很稳定,一旦某个依赖变慢,线程被大量占用,回调无法执行,QPS 骤降,超时与重试进一步加剧饥饿。

防护手段:

  1. 禁止在请求链路中同步等待异步任务;
  2. 为不同依赖设置独立超时,快速失败;
  3. 限制并发(信号量/限流),避免无上限排队;
  4. 监控线程池队列长度与完成端口线程数;
  5. 对 CPU 密集型操作做隔离(独立线程池/队列)。

16.2 事务视角

一个写请求的事务边界通常如下:

text 复制代码
[无事务] 认证、参数校验、幂等检查
    │
    ▼
[事务开始] 读取聚合、修改状态、写主表、写子表、写本地消息表
    │
    ▼
[事务提交]
    │
    ├── 提交后:更新缓存(可能失败)
    ├── 提交后:更新索引(可能失败)
    ├── 提交后:发送消息(可能失败)
    └── 提交后:返回响应

关键问题是:提交后动作失败怎么办?

动作 失败后果 处理策略
更新缓存 读到旧值 删除缓存 + 下次读回填 + 对账
更新索引 列表/搜索不一致 重试队列 + 版本号 + 对账
发送消息 下游无感知 本地消息表 + 后台投递
返回响应 客户端重试 幂等键 + 查询确认

一个常见的错误是把"提交后动作"放进事务里:

csharp 复制代码
using (var tx = BeginTransaction())
{
    SaveOrder(order);
    UpdateCache(order);      // 外部资源,不受事务保护
    SendMessage(order);      // 外部资源,不受事务保护
    tx.Commit();
}

这段代码给人一种"要么全成功、要么全失败"的错觉。实际上:

  • 缓存更新成功、数据库回滚 → 缓存里有不存在的订单;
  • 消息发送成功、数据库回滚 → 下游收到不存在的订单;
  • 数据库提交成功、缓存更新失败 → 缓存旧值。

事务只能保护它真正能控制的资源。跨资源的"原子性"需要分布式事务或补偿,而不是把代码缩进到一个 using 块里。

16.3 一致性视角

把请求链路中的每个读模型列出来,标注更新方式与一致性级别:

读模型 更新方式 一致性 失败修复
主库 同步事务 强 事务回滚
从库 复制 最终(毫秒~秒) 等待/强制读主
详情缓存 提交后删除/更新 最终(毫秒) TTL + 回填
列表索引 异步/同步更新 最终(秒) 重试 + 对账
搜索索引 异步 最终(秒~分钟) 重试 + 重建
下游系统 消息 最终(秒~分钟) 重投 + 幂等
数据仓库 ETL 最终(小时) 重跑

这张表应该成为架构文档的一部分。它回答了用户和客服最常问的问题:"为什么我这里看到的是旧的?"

16.4 失败语义表

对一次"创建订单"请求,逐阶段定义失败语义:

阶段 失败时已发生的副作用 是否可安全重试 客户端应看到
认证 无 是 401
限流 无 是(稍后) 429
参数校验 无 否(需修正) 400
幂等检查 无 是 返回已有结果
开启事务 无 是 500/503
写主表 事务内,可回滚 是 500/503
写子表 事务内,可回滚 是 500/503
写消息表 事务内,可回滚 是 500/503
提交事务 结果未知(可能成功) 仅幂等重试 超时/未知
更新缓存 数据库已成功 是(幂等) 成功
更新索引 数据库已成功 是(幂等) 成功
投递消息 数据库已成功 是(幂等) 成功
写响应 客户端可能未收到 仅幂等重试 超时

这张表的价值在于:它迫使你回答"失败发生在哪一步",而不是笼统地说"请求失败了"。客户端 SDK、重试策略、告警规则都应该基于这张表设计。

16.5 所有权视角

每个环节都应该有明确的所有者:

环节 所有者 责任
认证/限流 网关/Web 运行时 拒绝非法流量
业务规则 领域服务 保证业务正确
事务边界 应用服务/领域服务 定义原子性
读模型更新 领域服务/后台任务 保证最终一致
消息投递 消息基础设施 不丢、可重试
消费幂等 消费者 不重复副作用
对账修复 运维/数据团队 发现并修复漂移
可观测性 全团队 可诊断

很多事故的根因不是"技术不行",而是"没有人负责"。例如,缓存更新失败后,领域服务认为"缓存是基础设施的事",基础设施认为"业务没告诉我何时失效",最终没人修复。

16.6 一个可执行的请求检查清单

在实现一个新的写接口时,逐项回答:

  1. 这个接口是否幂等?幂等键是什么?保存多久?
  2. 事务边界在哪里?事务内是否包含远程调用/缓存/消息?
  3. 失败时,哪些副作用已经发生?如何补偿?
  4. 读模型有哪些?各自的一致性级别是什么?
  5. 缓存何时失效?失效失败怎么办?
  6. 是否发布事件?事件在事务内还是事务后?丢失/重复会怎样?
  7. 超时预算是多少?超时后客户端会重试吗?
  8. 权限检查在哪一层?是否覆盖资源级权限?
  9. 租户过滤是否覆盖所有查询与缓存键?
  10. 日志/指标/追踪是否足以定位一次失败?

这十个问题不需要复杂工具,只需要在代码评审时逐项确认。它们能挡住大部分"看起来能跑、上线就出事"的设计。


17. 架构自检清单:一百个问题

以下清单按七条脊柱分组。它不是考试,而是评审工具。每一个"否"都值得进一步调查。

17.1 配置与启动(K0)

  1. 应用启动是否只依赖一份明确的配置来源?
  2. 本地配置与配置中心冲突时,优先级是否明确?
  3. 配置缺失时,启动是否快速失败并给出可读错误?
  4. 配置项是否有类型校验与范围校验?
  5. 是否存在"配置了但没生效"的静默失败?
  6. 配置中的密钥是否与代码分离?
  7. 配置变更是否需要重启?是否明确标注?
  8. 多实例的配置是否按角色一致?
  9. 配置中心不可用时,应用能否启动/降级?
  10. 配置引用(refConfig)是否避免了循环与路径依赖?
  11. 对象工厂的构造函数选择是否唯一?
  12. 单例对象是否持有请求级状态?
  13. 启动顺序是否有隐式依赖?是否经过预热验证?
  14. 配置解析失败是否会导致半初始化状态?
  15. 是否有配置变更的审计记录?

17.2 AOP 与代理(K1)

  1. 哪些方法会被代理?接口、虚方法还是具体方法?
  2. 切面的执行顺序是否明确?顺序变化会影响什么?
  3. 缓存切面与事务切面的相对顺序是否经过验证?
  4. 异步方法上的切面是否正确处理 Task?
  5. 泛型方法、ref/out 参数是否有测试覆盖?
  6. 代理生成失败时,是否有可读的诊断信息?
  7. 代理类型的缓存是否会无限增长?
  8. 无切面时是否退化为直接调用?
  9. 切面中的异常是否会改变业务异常类型?
  10. 切面是否可能捕获并吞掉关键异常?
  11. 全局切面是否会影响第三方库类型?
  12. 代理对象的序列化/反序列化是否正常?
  13. 代理对象的 Equals/GetHashCode 行为是否一致?
  14. 切面是否在递归调用中重复执行?
  15. 切面的性能开销是否经过压测?

17.3 序列化与协议(K2)

  1. 报文是否包含类型信息?类型名是否稳定?
  2. 类型重命名/移动命名空间是否有兼容方案?
  3. JSON 与 Protobuf 的选择是全局还是按调用?
  4. 反序列化是否限制可加载类型(白名单)?
  5. 跨 .NET 运行时(Framework/Core)类型名差异是否处理?
  6. 大对象序列化是否会产生 LOH 压力?
  7. 循环引用如何处理?
  8. 多态类型如何还原?
  9. 枚举新增值是否向后兼容?
  10. 日期/时区/精度是否一致?
  11. 字节序、编码、长度字段是否跨平台一致?
  12. 报文大小是否有限制?超限如何报错?
  13. 序列化异常是否包含足够上下文?
  14. 是否对不可信输入做大小/深度限制?
  15. 协议版本是否可协商?

17.4 领域元数据(K4)

  1. 聚合边界是否清晰?是否有"大聚合"?
  2. 关系类型选择是否符合加载/保存需求?
  3. 多态继承的映射策略是否明确?
  4. 同层重复类型属性的命名是否可预测?
  5. 元数据树的最大深度是否受限?
  6. 懒加载属性是否会在序列化时被触发?
  7. 元数据构建失败是否会导致启动失败?
  8. 表名/列名推导规则是否与 DBA 规范一致?
  9. 代码生成与手工建表是否可能漂移?
  10. 注释元数据是否参与文档生成?
  11. 元数据缓存是否会在动态加载程序集后失效?
  12. 循环引用关系是否会导致无限递归?
  13. 删除聚合时的级联规则是否明确?
  14. 子实体的主键生成策略是否清晰?
  15. 元数据变更是否影响历史数据?

17.5 聚合与上下文(K5/K6)

  1. 聚合根是否只暴露业务方法,而不是公共 setter?
  2. 状态迁移是否集中管理?是否有非法状态防护?
  3. 业务校验在保存前还是构造时执行?
  4. 乐观并发字段是否覆盖所有更新路径?
  5. 工作单元是否登记了所有变更?
  6. 提交顺序是否明确?主表/子表/消息表的顺序?
  7. 上下文选项的组合语义是否清楚?
  8. 嵌套上下文如何合并?内层提交是否独立?
  9. 批量提交的失败语义是什么?部分成功如何处理?
  10. 事务回滚后,内存中的实体状态是否恢复?
  11. 上下文是否会在异步线程间丢失?
  12. 上下文是否会被意外复用(串数据)?
  13. 事件在事务内还是事务后发布?
  14. 事件发布失败是否影响主事务?
  15. 资源路由信息何时写入?失败怎么办?

17.6 仓储与查询(K7/K8/K9)

  1. 仓储是否只面向聚合,而不是表?
  2. SQL 是否全部参数化?排序/表名是否白名单?
  3. 分页是否有稳定排序键?
  4. 深分页是否使用游标或延迟关联?
  5. count 查询是否与列表查询条件一致?
  6. 查询是否可能产生 N+1?
  7. 关联查询是否可能产生笛卡尔积?
  8. 分片查询是否正确路由?跨片聚合如何处理?
  9. 规约组合是否会产生意外的 OR/AND 优先级?
  10. 空规约是否等价于"全表"?
  11. 查询超时/最大行数是否限制?
  12. 结果映射是否处理 DBNull/类型转换?
  13. 二级索引查询的过滤条件是否与数据库一致?
  14. 索引未命中时是否有降级路径?
  15. 领域服务的查询方法是否绕过了权限/租户过滤?

17.7 分布式运行时(K10/K14/K15/K16)

  1. 服务注册是否有健康检查与优雅下线?
  2. 地址缓存刷新策略是否合理?
  3. RPC 超时是否大于服务端 P99?
  4. 重试是否只针对幂等操作/瞬时错误?
  5. 重试是否有预算与退避?是否会放大流量?
  6. 连接池大小是否与并发匹配?
  7. 服务端线程池是否隔离?慢请求是否拖垮全部?
  8. 上下文(用户/租户/TraceId)是否跨进程传播?
  9. 远程异常是否保留可读信息而不泄露堆栈?
  10. 消息消费是否幂等?重复/丢失/积压是否有告警与补偿?

17.8 如何使用这份清单

建议每个季度做一次"架构体检":

  1. 选取一个核心写链路与一个核心读链路;
  2. 逐项回答清单,标记"是/否/不确定";
  3. 对"否"和"不确定"评估风险与修复成本;
  4. 把高风险项纳入迭代计划;
  5. 对修复项补充测试与监控,防止回归。

清单的目的不是追求 100% "是",而是让隐藏的假设显性化。一个团队知道自己在哪些地方做了妥协,比盲目自信更安全。

18. 实验手册:用五个最小实验建立直觉

读架构文章最容易犯的错误,是"看懂了文字,但没有手感"。本节给出五个可以在半天内完成的小实验。它们不依赖完整业务系统,只保留最小结构。实验中的类型名与配置均为化名,重点是观察行为,而不是照抄代码。

18.1 实验零:准备环境

  • 安装 .NET 8 SDK;
  • 准备一个控制台工程(用于配置、AOP、序列化、元数据实验);
  • 准备一个 SQLite 文件库(用于持久化实验);
  • 可选:本地 Redis(用于缓存实验);
  • 可选:本地 Kafka(用于消息实验,没有则用内存队列替代)。

建议为每个实验建立独立目录,避免配置互相污染。所有实验都应能从命令行启动,并输出可对比的结果。

18.2 实验一:配置驱动的对象图

目标:观察 XML 配置如何变成对象,理解构造函数注入、属性注入、单例生命周期。

配置:

xml 复制代码
<XConfig>
  <DataAccesses default="main">
    <Object id="main" type="FakeDbAccess, Lab.Config">
      <ConnectionString>Data Source=lab.db</ConnectionString>
    </Object>
  </DataAccesses>
  <DomainService mode="inner" dataAccess="main">
    <In>
      <Dll mode="con">Lab.Domain</Dll>
    </In>
  </DomainService>
</XConfig>

代码:

csharp 复制代码
public interface IFakeDb { string Name { get; } }

public sealed class FakeDbAccess : IFakeDb
{
    public FakeDbAccess(string connectionString)
    {
        ConnectionString = connectionString;
        Console.WriteLine($"[ctor] {connectionString}");
    }

    public string ConnectionString { get; }
    public string Name => "fake";
}

public sealed class OrderService
{
    public IFakeDb Db { get; set; }
}

步骤:

  1. 用对象工厂解析 main 对象,打印连接串;
  2. 再次解析,比较引用是否相同(单例);
  3. 把配置里的 singleton 改为 false,重复实验;
  4. 故意把 type 写错,观察错误信息;
  5. 让构造函数参数引用一个不存在的对象,观察错误信息。

预期观察:

  • 单例模式下两次解析返回同一引用;
  • 非单例模式下返回不同引用,但构造参数中的 main 仍是单例;
  • 类型名错误通常在解析时抛出,而不是编译时;
  • 引用不存在时,错误信息是否包含"缺少哪个 id",决定了排障效率。

进阶:给对象工厂增加一个"依赖图输出"功能,打印每个对象的构造依赖。这个功能在排查循环依赖时非常有用。

18.3 实验二:AOP 切面顺序

目标:观察切面的嵌套顺序,以及异常如何穿过切面。

代码:

csharp 复制代码
public interface IOrderOps { void Pay(long id); }

public class OrderOps : IOrderOps
{
    public void Pay(long id)
    {
        Console.WriteLine("target: pay");
        throw new InvalidOperationException("boom");
    }
}

public class TraceAspect : IAspect
{
    public void Before(XInvokeContext ctx) => Console.WriteLine("trace before");
    public void After(XInvokeContext ctx) => Console.WriteLine("trace after");
    public void OnException(XInvokeContext ctx, Exception ex) => Console.WriteLine($"trace ex: {ex.Message}");
}

public class TxAspect : IAspect
{
    public void Before(XInvokeContext ctx) => Console.WriteLine("tx begin");
    public void After(XInvokeContext ctx) => Console.WriteLine("tx commit");
    public void OnException(XInvokeContext ctx, Exception ex) => Console.WriteLine("tx rollback");
}

步骤:

  1. 配置 trace order=10、tx order=20,调用 Pay;
  2. 交换顺序,再次调用;
  3. 让 After 抛异常,观察目标方法是否已执行;
  4. 把接口方法改成 async Task,观察切面是否在 await 前后正确执行;
  5. 给方法加 ref/out 参数,观察返回值处理。

预期观察:

  • 顺序小的切面在外层,先 Before、后 After;
  • 异常会按相反顺序穿过 OnException;
  • After 抛异常可能掩盖目标异常;
  • 异步方法的切面如果只包住"返回 Task 之前"的同步部分,await 之后的逻辑不在切面范围内;
  • ref/out 参数需要代理层显式回写。

思考:如果你的"事务切面"在"缓存切面"外层,缓存写入发生在事务提交前还是后?如果事务回滚,缓存会怎样?这个实验能直观地说明"切面顺序即语义"。

18.4 实验三:序列化报文解剖

目标:看清自定义报文的每个字节,理解类型名的作用与风险。

步骤:

  1. 定义一个简单 DTO,序列化为字节数组;
  2. 打印前 64 个字节的十六进制;
  3. 标出魔数、运行时标记、序列化器标识、类型名长度、类型名、载荷;
  4. 修改 DTO 的命名空间,重新序列化/反序列化,观察兼容性;
  5. 构造一个包含旧类型名的报文,尝试反序列化,观察错误;
  6. 用 JSON 与 Protobuf 各跑一次,比较大小时与兼容性。

示例输出(示意):

text 复制代码
2A 6A 1F 4C 61 62 2E 44 74 6F 2E 4F 72 64 65 72 ...
│  │  │  └── 类型名 "Lab.Dto.Order"(长度 0x1F)
│  │  └───── 序列化器标识(0x6A 表示 JSON)
│  └──────── 运行时标记
└─────────── 魔数 '*'

预期观察:

  • 类型名占用了可观的字节数,短类型名/小对象时尤其明显;
  • 类型名变化会导致反序列化失败;
  • JSON 可读性好但体积大,Protobuf 体积小但需要类型元数据;
  • 恶意构造的类型名可能触发任意类型加载(安全风险)。

进阶:实现一个简单的类型白名单,只允许反序列化指定命名空间下的类型,观察攻击面如何缩小。

18.5 实验四:聚合元数据树

目标:观察框架如何把类型声明变成树,以及关系类型如何影响结构。

模型:

csharp 复制代码
[Table(Name = "lab_order", Key = "Id")]
public class LabOrder : XAggregateRoot
{
    [Column(Name = "No")] public string No { get; set; }
    [OneToManyShowRelation(typeof(LabLine))] public IList<LabLine> Lines { get; set; }
    [OneToOneEntity(typeof(LabCustomer))] public LabCustomer Customer { get; set; }
    [OneToOneValue(typeof(LabAddress))] public LabAddress Address { get; set; }
}

[Table(Name = "lab_line", Key = "Id")]
public class LabLine : XEntity<long>
{
    [Column(Name = "ProductId")] public long ProductId { get; set; }
}

[Table(Name = "lab_customer", Key = "Id")]
public class LabCustomer : XAggregateRoot
{
    [Column(Name = "Name")] public string Name { get; set; }
}

public class LabAddress
{
    public string City { get; set; }
}

步骤:

  1. 触发元数据构建;
  2. 打印树的层级、节点类型、属性名、同层序号;
  3. 把 Lines 改成 OneToManyHiddenRelation,观察差异;
  4. 增加一个同类型的第二个集合属性,观察同层序号;
  5. 增加一个继承子类,观察多态节点;
  6. 构造一个循环引用(A 引用 B,B 引用 A),观察是否栈溢出或被深度限制截断。

预期观察:

  • 根节点是 DomainModel,其下是 AggregationRoot;
  • 关系节点有明确的类型(O2OE/O2OV/O2OS/O2MH/O2MS);
  • 同层同类型属性会得到序号,用于区分表名/列名;
  • 隐藏关系不会递归展开子属性;
  • 树深度通常有限制,防止无限递归;
  • 多态子类会作为子聚合节点挂载。

思考 :如果把 LabCustomer 声明为值对象,保存订单时会发生什么?查询订单时会不会自动加载客户?删除订单时客户会被删除吗?这些问题的答案,决定了关系类型的选择。

18.6 实验五:保存管线与本地消息表

目标:在不依赖 Kafka 的情况下,观察"业务写入 + 消息落表 + 后台投递"的完整流程。

表结构(SQLite):

sql 复制代码
CREATE TABLE lab_order (
    Id INTEGER PRIMARY KEY,
    No TEXT NOT NULL,
    Status INTEGER NOT NULL,
    LastUpdateTime TEXT NOT NULL
);

CREATE TABLE lab_outbox (
    Id INTEGER PRIMARY KEY AUTOINCREMENT,
    EventId TEXT NOT NULL UNIQUE,
    Topic TEXT NOT NULL,
    Payload TEXT NOT NULL,
    Status INTEGER NOT NULL,      -- 0 待投递, 1 已投递, 2 失败
    RetryCount INTEGER NOT NULL DEFAULT 0,
    CreatedAt TEXT NOT NULL,
    NextRetryAt TEXT NULL
);

代码骨架:

csharp 复制代码
public async Task CreateOrderAsync(CreateOrderInput input)
{
    var order = new LabOrder { Id = NextId(), No = input.No, Status = 0 };

    using var conn = OpenSqlite();
    using var tx = conn.BeginTransaction();

    InsertOrder(conn, tx, order);
    InsertOutbox(conn, tx, new OutboxMessage
    {
        EventId = Guid.NewGuid().ToString("N"),
        Topic = "lab.order.created",
        Payload = JsonSerializer.Serialize(new { order.Id, order.No }),
        Status = 0,
        CreatedAt = DateTime.UtcNow
    });

    tx.Commit();
    await Task.CompletedTask;
}

public async Task DispatchLoopAsync(CancellationToken ct)
{
    while (!ct.IsCancellationRequested)
    {
        var batch = LoadPending(conn, limit: 50);
        foreach (var msg in batch)
        {
            try
            {
                await _publisher.PublishAsync(msg.Topic, msg.Payload);
                MarkSent(conn, msg.Id);
            }
            catch (Exception ex)
            {
                MarkFailed(conn, msg.Id, ex.Message);
            }
        }

        await Task.Delay(500, ct);
    }
}

步骤:

  1. 调用创建订单,查询数据库,确认订单与消息在同一事务中落表;
  2. 启动投递循环,观察消息状态从 0 变为 1;
  3. 让发布器抛异常,观察状态变为 2,重试次数增加;
  4. 在 tx.Commit() 之后、DispatchLoop 之前杀掉进程,重启后观察消息仍会投递;
  5. 在消费端模拟重复投递,观察幂等逻辑是否生效。

预期观察:

  • 业务与消息在同一事务中,避免"业务成功、消息丢失";
  • 投递是至少一次,可能重复;
  • 失败消息需要重试与死信策略;
  • 进程崩溃不会丢失待投递消息;
  • 消费端必须用 EventId 或业务键去重。

进阶:

  • 给 outbox 增加 LockedBy/LockedAt,支持多实例抢占;
  • 增加指数退避;
  • 增加死信表与人工重放;
  • 增加消息积压监控。

18.7 实验后的复盘问题

完成五个实验后,尝试回答:

  1. 哪个环节最容易被"看起来成功"误导?
  2. 哪个异常被吞掉后最难发现?
  3. 哪个配置错误在启动时不会报错,却在第一次请求时才失败?
  4. 哪个切面顺序会导致数据不一致?
  5. 哪个序列化变更会破坏旧客户端?
  6. 哪个聚合关系会导致 N+1 或大事务?

如果这些问题能结合实验现象回答,说明你已经建立了"从行为到机制"的直觉。后续章节会逐层解释这些现象背后的实现。

19. 章节依赖与阅读路线

19.1 章节依赖图

text 复制代码
第 1 章(全景)
  ├── 第 2 章(配置)
  │     └── 第 16 章(宿主)
  ├── 第 3 章(AOP)
  │     ├── 第 11 章(领域服务)
  │     └── 第 17 章(RPC 代理)
  ├── 第 4 章(序列化)
  │     ├── 第 15 章(Web 协议)
  │     ├── 第 17 章(RPC)
  │     └── 第 18 章(消息)
  ├── 第 5 章(数据访问)
  │     ├── 第 8 章(工作单元)
  │     └── 第 9 章(仓储)
  ├── 第 6 章(元数据)
  │     ├── 第 7 章(聚合)
  │     ├── 第 9 章(仓储)
  │     └── 第 13 章(代码生成)
  ├── 第 8 章(上下文)
  │     ├── 第 11 章(领域服务)
  │     ├── 第 18 章(消息)
  │     └── 第 20 章(缓存)
  ├── 第 10 章(规约)
  │     ├── 第 11 章(查询)
  │     └── 第 25 章(分片查询)
  └── 第 12 章(门面)
        ├── 第 14 章(动态 API)
        ├── 第 16 章(宿主)
        └── 第 17 章(RPC)

实线依赖表示"读后续章节前建议先读前置章节";虚线(未画出)表示"可以交叉阅读"。例如,第 18 章(消息)与第 17 章(RPC)共享序列化与上下文知识,但可以独立理解。

19.2 路线 A:业务开发者

目标:能独立完成一个领域模块的建模、保存、查询、API 暴露,并知道常见坑。

阶段 章节 产出
建立全局观 1 能画出一次请求经过的层
理解模型 6、7 能正确选择关系类型、划分聚合
理解保存 8、9、11 能解释一次保存的 SQL 与提交顺序
理解查询 10、11 能写出可参数化、可分页的查询
暴露 API 14、15 能定义契约、处理异常与权限
工程化 26、28 能写聚合单测与仓储集成测试

检查点:

  • 给定一个业务需求,能在 30 分钟内画出聚合边界与表结构;
  • 能解释"为什么这个查询会慢",并给出索引/分页方案;
  • 能列出一次保存失败时可能出现的副作用;
  • 能写出一个不依赖数据库的聚合单元测试。

19.3 路线 B:框架维护者/架构师

目标:理解框架的实现细节,能定位深层缺陷、做扩展与升级。

阶段 章节 产出
运行时 2、3、4 能解释对象图、代理、报文的生成过程
数据 5、8、9 能定位连接、事务、SQL 构建问题
模型 6、7、13 能修改元数据规则、扩展代码生成
分布式 12、16、17、18 能解释注册、RPC、消息的完整链路
规模化 25、27 能设计分片、容灾、一致性修复
演进 30 能评估扩展点与升级风险

检查点:

  • 能在一个陌生调用栈中快速定位"哪一层做了决策";
  • 能写出一个最小的 AOP/序列化/元数据实验验证假设;
  • 能评估一次框架升级的行为兼容性;
  • 能设计一个跨存储一致性方案并说明其失败模式。

19.4 路线 C:SRE/运维

目标:能部署、监控、排障、容量规划。

阶段 章节 产出
启动 2、16 能解释启动失败与热重载
网络 12、17 能诊断注册、连接、超时问题
消息 18 能处理积压、重复、死信
存储 5、20、21 能诊断连接池、缓存、对象存储
可观测 23 能建立日志/指标/追踪体系
规模化 25、27、29 能做容量规划与故障演练

检查点:

  • 能根据错误日志判断是配置、网络、数据库还是业务异常;
  • 能解释一次 RPC 超时的完整链路与可能副作用;
  • 能设计一套消息积压告警与恢复流程;
  • 能评估一次扩容/迁移的风险。

19.5 六周学习计划

周 内容 实验
第 1 周 第 1-3 章 配置对象图、AOP 顺序
第 2 周 第 4-5 章 报文解剖、SQLite 数据访问
第 3 周 第 6-8 章 元数据树、保存管线
第 4 周 第 9-11 章 规约查询、分页、事件
第 5 周 第 14-18 章 动态 API、RPC、消息
第 6 周 第 19-30 章(选读) 缓存、分片、故障演练

每周建议投入 8-10 小时:4 小时阅读,4 小时实验,2 小时复盘。不要追求"读完",要追求"能用自己的话解释并验证"。

19.6 每一卷的产出物

  • 第一卷:一张"请求生命周期图",标注配置、代理、序列化、数据访问的介入点;
  • 第二卷:一个可运行的订单域,包含聚合、仓储、服务、查询、测试;
  • 第三卷:一个 API + RPC + 消息的最小分布式系统;
  • 第四卷:一套平台能力的接入清单(缓存、OSS、身份、日志);
  • 第五卷:一份容量规划、故障演练与升级方案。

20. 如何阅读这套框架的源码

本系列的结论来自对源码的逐层分析。掌握阅读方法,比记住结论更重要。

20.1 三种阅读路径

路径一:顺藤摸瓜(请求驱动)

从一个入口开始,沿着调用链向下:

text 复制代码
HTTP 路由 → 控制器 → 服务门面 → 领域服务 → 工作单元 → 仓储 → 数据访问 → 数据库

优点:有明确的目标,能快速建立"端到端"的理解;缺点:容易陷入分支,忽略横向能力。

路径二:按图索骥(类型驱动)

从一个核心类型开始,追踪它的创建、使用、销毁:

text 复制代码
聚合类型 → 特性 → 元数据树 → 工厂 → 仓储 → SQL → 结果映射

优点:能理解"元数据如何驱动行为";缺点:容易忽略调用上下文,只看静态结构。

路径三:故障驱动

从一个异常/错误日志开始,反向追踪:

text 复制代码
异常堆栈 → 代理层 → 目标方法 → 仓储 → SQL → 数据库错误

优点:贴近真实问题,记忆深刻;缺点:覆盖面窄,容易"头痛医头"。

建议交替使用:先用路径一建立全局,再用路径二补齐机制,最后用路径三验证理解。

20.2 一个具体的追踪示例

以"保存一个聚合"为例,追踪步骤可以是:

  1. 找到领域服务的保存方法,记录它的参数与返回值;
  2. 进入保存管线,标记"准备阶段/校验阶段/提交阶段";
  3. 找到工作单元的登记方法,观察它登记了哪些实体、哪些状态;
  4. 找到提交方法,记录事务开始、SQL 执行、事务提交的顺序;
  5. 找到仓储的 SQL 构建方法,观察表名、列名、参数从哪里来;
  6. 找到数据访问的执行方法,观察连接、命令、事务如何传递;
  7. 找到提交后的缓存/索引/事件逻辑,记录失败处理;
  8. 回到入口,确认异常如何被包装、返回给调用方。

每一步都问三个问题:

  • 输入是什么? 谁提供,可能是空吗?
  • 决策是什么? 哪个条件分支决定了行为?
  • 副作用是什么? 失败了会留下什么?

把答案记成一张表,比读十遍代码更有效。

20.3 调试技巧

条件断点:在保存方法上设置条件断点,只在特定实体类型/主键时停下,避免被海量请求淹没。

日志注入:在关键分支临时加日志,输出元数据、SQL、参数、上下文选项。注意不要打印敏感信息。

单元测试作为探针:写一个最小测试,直接调用目标方法,构造特定输入,观察输出。比启动整个宿主快得多。

IL 反汇编:对于动态代理、编译器生成代码,反汇编可以看到真实调用路径。重点关注方法调用、装箱、异常处理块。

性能分析器:当怀疑反射/序列化/GC 时,用分配分析器看热点,而不是猜。

依赖图工具:对程序集依赖、类型依赖做可视化,快速发现循环依赖与意外耦合。

20.4 阅读反编译代码的注意事项

这套框架的源码带有反编译痕迹,阅读时要注意:

  1. 编译器生成的名字 :<>c__DisplayClass、d__、<>f__AnonymousType 等,不代表设计意图;
  2. 异步状态机 :MoveNext 中的逻辑是编译器重写的,原始代码在 async/await 里;
  3. 闭包:lambda 捕获变量会生成字段,阅读时要还原为原始逻辑;
  4. LINQ:链式调用可能被反编译成多个中间变量,语义不变但可读性差;
  5. 空值注解 :#nullable 指令与 ? 可能丢失,不能完全依赖;
  6. 默认参数/重载:反编译可能展开为多个方法,注意调用方实际使用的是哪个;
  7. 异常过滤器 :catch when 可能被反编译成嵌套 if;
  8. 字符串插值 :可能被展开为 DefaultInterpolatedStringHandler,不要误以为是手写优化。

阅读时应该关注控制流与数据流,而不是逐字逐句复刻反编译结果。遇到可疑之处,用实验验证,而不是相信注释。

20.5 建立自己的"框架笔记"

建议为每个模块记录四类信息:

  1. 不变量:这个模块保证什么,不保证什么;
  2. 扩展点:哪些方法是 virtual/接口/配置项,可以安全替换;
  3. 失败模式:常见异常、静默失败、性能悬崖;
  4. 验证方法:如何用最小实验证明行为。

例如,对于"工作单元"模块:

text 复制代码
不变量:
- 同一事务内的实体变更要么全部提交,要么全部回滚(仅限同一数据库)
- 提交顺序由上下文选项决定

扩展点:
- 仓储的 Persist* 方法
- 上下文的提交前后钩子
- 缓存/索引的更新策略

失败模式:
- 缓存更新失败不阻断主流程
- 批量提交部分失败
- 嵌套上下文重复提交

验证方法:
- SQLite 事务回滚实验
- 故障注入:让缓存客户端抛异常

这种笔记比"源码导读"更有长期价值,因为它是你自己的理解,而不是别人的总结。

20.6 阅读源码的五个反模式

  1. 从第一个文件读到最后:效率极低,且容易迷失。应该从入口/核心类型开始。
  2. 只看happy path:异常分支、空值、并发才是bug高发区。
  3. 相信注释:反编译代码的注释可能缺失或过时,行为以代码为准。
  4. 忽略配置:同一个方法在不同配置下行为不同,必须结合配置阅读。
  5. 不做实验:看懂≠会用。每个关键结论都应该有一个可复现的实验。

21. 本章小结:记住七组不变量

这一章覆盖了很多概念。如果只能记住七句话,应该是这七句:

21.1 配置脊柱

对象由配置描述,而不是由调用方凭空创建;配置只能保证装配成功,不能保证语义正确。

排障时先问:当前实例实际加载的是哪份配置?对象图是否完整?单例是否被污染?

21.2 代理脊柱

切面顺序就是语义顺序;代理层能统一横切逻辑,但不能替你判断横切逻辑是否该在这里执行。

排障时先问:这个方法被代理了吗?切面顺序是什么?异常在哪个切面被处理/吞掉?

21.3 协议脊柱

报文自描述是便利,也是兼容性负担;类型名一旦进入协议,就成为契约的一部分。

排障时先问:收发双方的序列化器一致吗?类型名/版本一致吗?报文是否被截断/超限?

21.4 元数据脊柱

声明是唯一事实来源,元数据是声明的运行时投影;投影错了,保存、查询、建表会一起错。

排障时先问:关系类型选对了吗?表名/列名推导符合预期吗?元数据树是否把不该展开的节点展开了?

21.5 上下文脊柱

一次提交是一条有顺序的管道,不是一个原子动作;跨存储的一致性靠顺序、重试与补偿,不靠愿望。

排障时先问:上下文选项是什么?提交顺序是什么?哪一步失败被吞掉了?失败后谁负责修复?

21.6 查询脊柱

查询条件是可组合的对象,但查询结果是否稳定取决于排序、分页、索引与分片,而不是取决于 API 好不好看。

排障时先问:排序键唯一吗?count 与列表条件一致吗?走的是数据库还是索引?分片路由正确吗?

21.7 分布式脊柱

远程调用的第三种结果是"未知";消息的默认语义是"至少一次";分布式系统不会因为框架封装得好就消失。

排障时先问:超时后服务端是否已执行?重试是否幂等?消息重复/丢失/积压的后果是什么?上下文是否跨进程传播?


22. 思考题

以下问题没有标准答案,但每一个都能检验你是否真正理解了本章。建议先写下自己的答案,再对照提示。

22.1 配置与启动

问题 1:一个单例对象在构造函数中读取了当前请求的用户 ID。为什么这在低并发测试中正常,在生产中会出错?如何从设计上避免?

提示:考虑单例的生命周期、线程安全、请求上下文的存储位置。思考"构造时求值"与"调用时求值"的区别。

问题 2:配置中心推送了新的连接串,但应用没有生效。列出至少五种可能原因,并给出验证方法。

提示:从"推送→订阅→解析→应用→连接池"逐段排查。注意"新请求生效"与"存量连接生效"的区别。

22.2 AOP

问题 3:缓存切面在事务切面外层。事务回滚时,缓存已经被写入。会产生什么问题?如何调整顺序或补偿?

提示 :画出两个切面的嵌套关系,标出 Before/After/OnException 的执行顺序。考虑"删除缓存"与"更新缓存"的差异。

问题 4 :一个 async Task 方法的切面只在方法返回 Task 之前执行了 After,await 之后的逻辑没有被覆盖。这是代理实现的缺陷还是使用方式的问题?如何验证?

提示 :异步方法在第一个 await 之前是同步执行的,之后由状态机延续。切面要覆盖完整异步生命周期,需要包装 Task 或使用异步切面协议。

22.3 序列化

问题 5:把 DTO 从命名空间 A 移到命名空间 B,编译通过,但旧客户端反序列化失败。为什么?有哪些修复方案?

提示:自定义报文包含类型名。考虑类型别名、兼容层、双版本 DTO、协议升级。

问题 6:为什么"反序列化任意类型名"是安全风险?攻击者能做什么?如何防护?

提示:类型加载可能触发静态构造函数、工厂方法、文件/网络访问。防护包括白名单、签名、隔离加载、禁用危险类型。

22.4 元数据

问题 7:一个订单聚合包含 200 个订单行。每次查询订单都加载所有行,导致页面很慢。应该改关系类型、改查询方式,还是拆分聚合?分别有什么后果?

提示:考虑显示关系与隐藏关系的加载策略、分页查询、聚合边界。没有一种方案适用于所有场景。

问题 8:两个同类型的属性在同一层级,元数据为它们分配了不同的序号。这个序号影响什么?如果顺序变化,会有什么后果?

提示:序号影响表名/列名/节点标识的生成。顺序变化可能导致映射漂移,尤其是在代码生成与历史数据之间。

22.5 上下文与持久化

问题 9:一次保存包含主表、子表、缓存、索引、消息。哪些操作应该在事务内?哪些应该在事务外?如果缓存更新失败,是否应该回滚数据库?

提示:事务只能保护同一数据库资源。缓存失败通常不回滚数据库,而是重试/删除/对账。关键是明确"失败后系统处于什么状态"。

问题 10:批量提交 1000 条记录,第 500 条失败。应该全部回滚、部分提交,还是分批重试?如何设计才能既保证一致性又保证吞吐?

提示:考虑事务大小、锁竞争、失败隔离、幂等。批量操作通常需要"分片提交 + 失败重试 + 最终对账"。

22.6 分布式

问题 11:RPC 超时后,客户端重试导致订单重复创建。服务端应该在哪一层做幂等?幂等键如何设计?保存多久?

提示:幂等键应该由客户端生成、跨重试稳定、全局唯一。服务端可以用唯一约束/Redis SETNX/状态机条件更新。保存时间取决于业务重试窗口与对账周期。

问题 12:消息消费"至少一次",消费端已经做了幂等,但下游仍然收到重复通知。可能的原因是什么?

提示:幂等可能只覆盖了数据库写入,没有覆盖外部副作用(短信、邮件、支付)。需要把外部副作用也纳入幂等/去重范围,或使用事务性消息/本地消息表。

22.7 综合

问题 13:设计一个"支付成功后更新订单状态"的链路,要求:不丢事件、不重复扣款、列表最终一致、可在故障后对账。画出组件与数据流,并标注每个失败点的处理。

提示:可以参考第 12 章的案例。关键组件:支付回调幂等、订单状态条件更新、本地消息表、事件消费幂等、索引更新重试、对账任务。

问题 14:如果要把这套系统从单库迁移到分库分表,哪些假设会失效?按影响程度排序。

提示:自增主键、跨表 JOIN、事务、全局唯一约束、分页排序、二级索引、跨片 count、消息顺序。影响最大的是"跨分片事务与查询"。


附录 A:故障速查表

症状 高概率原因 关键证据 参考章节
启动时报"找不到类型/对象" 配置类型名错误、程序集未加载 配置节、加载日志 2
启动成功,首次请求报"未初始化" 延迟初始化失败、顺序依赖 首次调用栈 2、16
保存成功但列表是旧数据 索引未更新/缓存未失效 数据库 vs 索引对比 8、20
保存后详情是旧数据 缓存更新失败/读写分离延迟 缓存 TTL、主从延迟 8、20
同一请求重复创建数据 超时重试、无幂等键 请求 ID、时间线 15、17、27
RPC 超时但服务端有日志 客户端超时小于服务端耗时 两端时间戳 17
RPC 报类型转换/找不到类型 序列化类型名不一致 报文头、版本 4、17
消息重复消费 至少一次投递、位点提交晚 消费位点、事件 ID 18
消息丢失 先提交位点后处理、发送未确认 生产/消费日志 18
分页重复/丢数据 排序键不唯一、count 条件不一致 执行计划、SQL 9、10
查询突然变慢 索引失效、参数嗅探、数据倾斜 执行计划、慢日志 9、25
线程池饥饿 同步等待、下游变慢 队列长度、线程数 5、26
内存持续上涨 缓存无上限、事件订阅泄漏、大对象 dump、GC 指标 20、26
多租户串数据 漏加租户条件、缓存键冲突 请求上下文、SQL 25
反序列化失败 类型重命名、版本不兼容 类型名、程序集版本 4
权限校验通过但越权 只做功能权限,未做数据权限 资源归属、SQL 22
工作流状态与业务不一致 事务边界错误、事件丢失 状态机日志 7、18
对象存储上传失败 签名过期、CORS、内容类型 HTTP 状态、签名参数 21
日志缺失关键请求 异步日志丢队列、采样 日志配置、磁盘 23
发布后大面积超时 连接池/线程池配置、冷启动 发布时刻指标 26、29

这张表不能替代诊断,但可以作为"第一反应"的索引。真正的根因往往需要沿着调用链收集证据,而不是凭症状猜。


附录 B:一页纸架构画布

text 复制代码
┌─────────────────────────────────────────────────────────────────────┐
│ 入口层                                                               │
│  HTTP(认证/限流/路由)  RPC(帧协议/方法消息)  消息(Kafka/本地)    │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
┌──────────────────────────────▼──────────────────────────────────────┐
│ 编排层                                                               │
│  动态 API · 服务门面(本地/远程路由) · 领域服务 · 应用服务             │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
┌──────────────────────────────▼──────────────────────────────────────┐
│ 领域层                                                               │
│  聚合根 · 实体 · 值对象 · 业务规则 · 领域事件 · 工作流 · 权限          │
│  元数据树(O2OE/O2OV/O2OS/O2MH/O2MS)                                 │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
┌──────────────────────────────▼──────────────────────────────────────┐
│ 持久化层                                                             │
│  工作单元 · 仓储 · 规约/SQL 构建 · 乐观锁 · 批处理 · 分片路由          │
└──────────────────────────────┬──────────────────────────────────────┘
                               │
┌──────────────────────────────▼──────────────────────────────────────┐
│ 基础设施层                                                           │
│  数据访问(SQL Server/MySQL/SQLite) · Redis/索引 · 搜索 · 消息        │
│  序列号 · 对象存储 · 身份 · 日志/追踪 · 配置中心/注册中心              │
└─────────────────────────────────────────────────────────────────────┘

横切:配置对象图(K0) · AOP 代理(K1) · 序列化协议(K2) · 可观测性(K21)

使用这张画布时,针对每个新需求问四个问题:

  1. 它属于哪一层?是否越层?
  2. 它依赖哪些不变量?这些不变量由谁保证?
  3. 它的失败模式是什么?失败后如何恢复?
  4. 它需要哪些可观测信号?出问题时如何定位?

23. 下一章预告

第 1 章给出的是地图。从第 2 章开始,我们进入地图的第一块实地:启动与配置内核。

下一章会回答:

  • 一份 XML 配置如何被解析成对象图?
  • 配置节处理器如何注册、匹配、执行?
  • 对象工厂如何选择构造函数、解析引用、处理单例?
  • 本地文件与配置中心的加载路径有何不同?
  • 配置错误为什么常常在第一次请求才暴露?
  • 如何设计一个可诊断、可热更新、可回滚的配置体系?

如果你只有一个周末的时间,建议先完成第 2 章的实验:亲手用对象工厂装配一个包含数据访问、缓存、领域服务的对象图。这个实验会让你在后续所有章节中受益。

附录 C:十五个反模式

反模式不是"错误的代码",而是"在某个阶段有效、在规模/复杂度上升后开始反噬的做法"。它们往往有合理的初衷,因此更难识别。

C1. 万能基类

症状:实体基类承担了几十项职责,任何新需求都往基类加方法;基类一改,全系统重新编译与回归。

根因:用继承复用代替组合,用"大家都需要"合理化职责膨胀。

修复:把非核心能力下沉为基础设施(仓储、服务、切面),基类只保留身份/状态等真正通用的部分。

预防:给基类设定"职责预算",超出时必须评审。

C2. 聚合吞噬

症状:一个聚合加载出上千行数据,保存时执行几十条 SQL,并发修改经常冲突。

根因:把"页面上一起展示"当成"事务上必须一起原子"。

修复:按一致性边界拆分聚合,用 ID 引用替代对象引用,跨聚合用事件/查询组合。

预防:建模时问"这些数据是否必须在同一个事务里更新"。

C3. 事务包一切

症状:事务里包含远程调用、缓存、消息发送;回滚后外部副作用无法撤销。

根因 :把"代码块"当成"事务边界",误以为 using 能保护所有资源。

修复:事务只包数据库写入;外部副作用改为提交后动作 + 本地消息表/补偿。

预防:在代码评审中检查事务块内是否出现 I/O 边界。

C4. 缓存当数据库

症状:缓存成为某些数据的唯一存储;缓存重启后数据丢失;不同缓存之间不一致。

根因:为了性能跳过持久化,或把缓存当作"临时的数据库"。

修复:明确 source of truth;缓存只做加速,必须可从主存储重建;关键数据不依赖缓存持久化。

预防:任何缓存键都必须能回答"主存储在哪里"。

C5. 重试即容错

症状:异常就重试,重试次数很多;下游过载时流量翻倍;写操作重复执行。

根因:把"重试"当成通用容错,而不区分错误类型与幂等性。

修复:只重试瞬时错误;写操作要求幂等;配合退避、预算、熔断、隔离。

预防:为每个重试点写明"为什么安全"。

C6. 吞异常保流程

症状 :catch { log.Warn(...) } 遍布代码;主流程永远成功;数据不一致在几天后爆发。

根因:把"不阻断主流程"等同于"忽略失败"。

修复:区分可降级与不可降级;失败进入重试/死信/对账;关键失败必须告警。

预防:禁止空的 catch 块;每个 catch 必须回答"失败后谁负责修复"。

C7. 隐式上下文滥用

症状:任意方法都能拿到当前用户/租户/上下文;异步任务偶尔丢失上下文;测试需要大量环境准备。

根因:把环境上下文当成全局变量使用。

修复:上下文只承载只读元数据;业务依赖显式传参;异步边界显式传递/恢复。

预防:为上下文定义生命周期与传播规则,并写入规范。

C8. 盲目标注关系

症状:所有一对多都标成显示关系;查询越来越慢;保存时级联过多;删除时误删。

根因:不理解关系类型的加载/保存/删除语义,复制粘贴特性。

修复:按访问模式选择关系类型;对不需要导航的关系使用隐藏关系或独立查询。

预防:建模评审时逐个解释关系类型的理由。

C9. SELECT *

症状:查询返回大量无用列;网络与内存开销高;表结构变更导致映射异常;覆盖索引失效。

根因:图省事,或框架默认生成全列查询。

修复:只选需要的列;为列表查询定义投影 DTO;为详情查询明确列集。

预防:在仓储层提供投影查询 API,并默认使用。

C10. 分页无稳定排序

症状:翻页重复/遗漏;同一页两次请求结果不同;深分页越来越慢。

根因:排序键不唯一,或没有排序;使用大偏移。

修复:增加唯一键作为排序补充;深分页使用游标/延迟关联;保证 count 与列表条件一致。

预防:分页 API 强制要求排序参数,或使用默认唯一排序。

C11. 同步转异步

症状 :代码里大量 .Result、.Wait();高峰期线程池饥饿;死锁;QPS 断崖。

根因:异步链路中混入同步等待,或旧代码无法改造。

修复:全链路异步;无法异步的边界使用专用线程池隔离;禁止在请求线程上阻塞。

预防:静态分析规则 + 代码评审 + 线程池监控。

C12. 配置硬编码

症状:环境相关参数写死在代码里;部署需要重新编译;不同环境行为不一致。

根因:图方便,或配置体系太复杂。

修复:外置配置;提供默认值与校验;敏感信息走密钥管理。

预防:把"可配置性"纳入设计评审,而不是上线前补。

C13. 日志当监控

症状:出了问题靠 grep 日志;没有指标/告警;故障发现依赖用户投诉。

根因:把"有日志"当成"可观测"。

修复:日志用于诊断,指标用于告警,追踪用于定位;三者配合。

预防:每个核心链路定义 SLI/SLO 与告警规则。

C14. 目录当边界

症状:按目录拆了模块,但模块之间随意引用;改一个模块导致全量回归;无法独立部署。

根因:把"文件夹结构"当成"架构边界",没有编译期/运行期约束。

修复:用独立程序集/接口/包管理强制边界;禁止跨层引用;用依赖图工具检查。

预防:把依赖规则写入构建流程,违反即失败。

C15. 升级不读变更

症状:升级框架后行为变化;默认值改变;序列化/分页/超时行为不同;回滚困难。

根因:只关注编译是否通过,忽略行为兼容性。

修复:阅读变更说明;在预发环境做行为对比;灰度发布;准备回滚方案。

预防:建立升级 checklist,覆盖二进制/源码/行为三类兼容性。


附录 D:延伸阅读与下一步

如果你想在本系列之外继续深入,建议按以下顺序扩展:

  1. 分布式系统基础:超时、重试、幂等、共识、分区、一致性模型;
  2. 数据库内核:索引结构、执行计划、事务隔离、锁、MVCC;
  3. 消息系统:分区、副本、位点、事务消息、消费组再平衡;
  4. 可观测性:结构化日志、指标、分布式追踪、SLO;
  5. 领域驱动设计:聚合、限界上下文、事件风暴、上下文映射;
  6. 性能工程:队列理论、Amdahl 定律、GC、分配分析、压测方法。

技术细节会过时,但这些基础不会。框架的 API 可能在下个版本改变,而"超时后结果未知""缓存不是数据库""重试需要幂等"这些事实不会改变。


附录 E:架构决策记录模板与示例

架构决策如果不能被记录,就会被遗忘,然后以"历史遗留问题"的形式重新出现。建议为每个影响面较大的决策写一份 ADR(Architecture Decision Record)。模板如下:

markdown 复制代码
# ADR-编号:决策标题

## 状态
提议 / 已接受 / 已废弃 / 被替代

## 背景
描述问题、约束、相关方与时间压力。

## 决策
用一句话说明选择了什么。

## 备选方案
列出至少两个被认真考虑过的方案。

## 取舍
每个方案的收益、代价、风险。

## 影响
受影响的模块、团队、数据、运维流程。

## 验证
如何证明决策有效?指标、实验、回滚条件。

## 后续
需要跟进的事项与负责人。

示例:领域服务通过进程内调用还是 RPC 暴露

状态:已接受。

背景:订单能力同时被 Web 前端、后台任务与第三方回调使用。当前所有调用都在同一进程内。团队计划拆分服务,但不确定是否应该立即引入 RPC。

决策:第一阶段保持进程内调用,只把接口抽到独立契约程序集;当出现以下任一信号时再引入 RPC:

  1. 订单模块需要独立伸缩,且资源占用显著高于其他模块;
  2. 订单模块的发布频率显著高于调用方;
  3. 订单模块需要独立的安全边界或故障隔离;
  4. 多个异构前端需要直接复用订单能力。

备选方案:

  • 方案 A:立即拆分为 RPC 服务;
  • 方案 B:保持进程内调用,仅抽接口;
  • 方案 C:通过消息异步化所有跨模块调用。

取舍:

  • 方案 A 的收益是边界清晰、可独立部署;代价是引入网络故障、超时、重试、序列化、注册发现、分布式追踪,团队当前运维能力不足。
  • 方案 B 的收益是零网络成本、调试简单;代价是模块间仍是编译期依赖,无法独立伸缩。
  • 方案 C 的收益是彻底解耦;代价是查询类场景不适用,且一致性从强变最终,业务方难以接受。

影响:契约程序集成为共享依赖;需要建立接口版本规范;未来拆分时不需要改业务代码。

验证:如果六个月内没有出现上述四个信号,说明决策有效;如果出现,按预案启动拆分,并以"订单创建 P99"和"发布回滚次数"作为拆分前后的对比指标。

后续:为契约程序集建立兼容性检查;在架构看板上标注"待拆分"状态。

为什么 ADR 值得写

  1. 保留上下文:后人看到代码时,能理解"为什么不是另一种做法";
  2. 减少重复争论:同一个问题不必每次重新讨论;
  3. 支持复盘:当决策失效时,可以对照当时的假设,找出哪一条变了;
  4. 传递经验:新成员通过 ADR 快速理解决策网络。

ADR 不需要很长,一页足够。关键是写清楚"约束"与"取舍",而不是只写"我们用了 X"。


附录 F:十个高频问题

F1. 这套框架和 ASP.NET Core 自带的依赖注入是什么关系?

两者解决的是不同层面的问题。标准 DI 容器负责"类型到实例"的注册与解析;这套框架的配置对象工厂负责"从外部配置构建对象图",并且与领域服务、数据访问、AOP 等运行时能力深度绑定。实际项目中常见的做法是:用标准 DI 管理控制器、过滤器等 Web 组件,用配置对象工厂管理基础设施与领域服务。关键是明确边界,避免同一个对象被两个容器各创建一份。

F2. 可以只用领域模型,不用 Web 层吗?

可以。领域模型、仓储、工作单元、序列化、RPC 都是独立能力。后台任务、消息消费者、命令行工具都可以直接使用领域服务。Web 层只是一个入口适配器。事实上,很多批处理与数据修复工具就是"无 Web 的领域服务宿主"。

F3. 为什么不用 EF Core?

EF Core 是优秀的 ORM,但它的变更跟踪、LINQ 翻译、迁移模型与这套框架的元数据驱动、分片、批量、缓存/索引协同有较多重叠与冲突。这套框架选择"应用层 SQL 构建 + 元数据映射",换取更可控的 SQL 与更直接的性能优化空间。代价是需要自己处理映射、方言与迁移。没有绝对优劣,取决于团队对 SQL 的掌控程度与性能要求。

F4. 支持 .NET Framework 吗?

框架的实现大量使用 .NET Core/.NET 8 的特性(如 AsyncLocal、System.Text.Json、新的程序集加载模型、跨平台网络栈)。虽然部分模块可能兼容旧运行时,但整体应按 .NET 8 设计。跨运行时时尤其要注意序列化类型名差异(mscorlib 与 System.Private.CoreLib)。

F5. 可以跨语言调用吗?

RPC 与自定义序列化协议主要面向 .NET 内部服务。跨语言调用需要额外工作:使用标准协议(gRPC/HTTP+JSON)、定义语言中立的契约、处理类型名与枚举映射。如果跨语言是核心需求,建议不要把自研 RPC 作为主通道。

F6. 如何做单元测试?

聚合可以脱离框架测试:直接 new 对象,调用业务方法,断言状态与异常。领域服务需要测试保存管线时,使用真实数据库(SQLite/容器)或测试仓储;不要 mock 掉元数据与 SQL 构建,否则测不到真正的风险点。第 28 章会给出完整的测试分层与宿主方案。

F7. 如何调试 IL 动态代理?

先确认方法是否被代理(接口/虚方法/配置);再确认切面顺序;然后在切面的 Before/After/OnException 加日志;必要时反汇编代理类型,观察 IL 调用路径。对于异步方法,重点检查切面是否覆盖了 await 之后的逻辑。不要只靠断点------代理层的调用栈可能与源码不一致。

F8. 为什么保存后缓存没有更新?

常见原因:上下文选项未开启缓存更新;缓存键不一致;缓存更新失败被吞掉;更新的是详情缓存而不是列表索引;多实例本地缓存未失效;读写分离导致读到旧库。排查顺序是"选项 → 键 → 失败日志 → 索引 → 本地缓存 → 主从延迟"。

F9. JSON 还是 Protobuf?

JSON 可读性好、调试方便、跨语言支持广,但体积大、序列化慢;Protobuf 体积小、速度快,但需要类型元数据、可读性差、跨版本需要 schema 管理。内部高频 RPC 适合 Protobuf;对外 API、日志、事件(需要人读)适合 JSON。也可以按调用链动态选择,但要有明确的默认值与监控。

F10. 如何做灰度发布?

灰度不仅是"把流量分给新版本",还包括配置、数据库、消息、缓存的兼容。基本步骤:契约向后兼容 → 数据库双写/兼容读 → 新版本只接少量流量 → 对比错误率/延迟/业务指标 → 逐步放量 → 保留回滚窗口。对于消息消费者,灰度期间新旧版本可能同时消费,必须保证幂等与顺序兼容。


结语

第 1 章的目的不是让你记住所有模块,而是让你在遇到问题时知道"该往哪里看"。一套成熟的框架,最怕的不是复杂,而是复杂得没有地图。有了七条脊柱、三条链路、七组不变量,你已经拥有了这张地图的骨架。接下来的每一章,都会把其中一个区域放大,直到你能看清每一条调用路径、每一个失败分支。

阅读时请始终保持一个习惯:把"框架做了什么"与"框架保证了什么"分开。前者是 API 与实现,后者才是架构。真正决定系统成败的,永远是后者。


相关推荐
weixin_4481199440 分钟前
Datawhale DeepSeek Harness 技术入门与架构原理 笔记3
笔记·架构
济南壹软网络科技有限公司1 小时前
盲盒源码系统小程序V6MAX底层架构解析,PHP+Uniapp高性能商用底座发布
小程序·架构·uni-app
小满zs1 小时前
Go语言第十四章(文件操作)
后端·go
打工仔折腾 AI1 小时前
Linux 网络编程:从零掌握 TCP Socket 编程与 C++ 实战落地
linux·网络·人工智能·后端·python·tcp/ip·性能优化
沙蒿同学1 小时前
Wails v3 实战:一个桌面提醒客户端,从 v2 升到 v3 的全过程
前端·后端·github
aibo8603081 小时前
基于 Spring Boot + Vue3 的爱心众筹管理系统设计与实现
java·spring boot·后端
SimonKing1 小时前
OpenCode免费模型直接给`Workbuddy`用,一键导入
java·后端·程序员
我命由我123451 小时前
Python 开发 - Python 项目部署(Windows 与 Linux)
java·linux·windows·后端·python·java-ee·python3.11
海宇数据2 小时前
零信任架构实战:基于海宇普通维保查询构建自动化二手车认证展厅车况质检档案网关
运维·人工智能·架构·自动化