Skip to content

架构演进:每拆开一个边界,都要重新回答谁负责提交和恢复 ​

任务管理系统刚上线时,一个进程里创建任务、更新状态、发送通知,几个人就能维护。后来报表拖慢了任务列表,通知故障影响创建,两个团队又总要等对方一起发布。此时需要演进,但这三种问题未必需要同一种拆法。

本文沿着这个假设系统说明单体、垂直拆分、服务化和事件驱动怎样解决不同问题,再分别解释 CQRS、事件溯源与读写模型分离。主线是数据责任:谁有权修改一项事实,什么必须一起提交,其他人看到旧状态时怎么办,以及迁移失败后怎样退回。

先分清这些词在拆什么 ​

概念拆开的对象可以仍在同一进程/数据库吗新增的主要成本
模块化单体代码与业务职责可以,通常同一部署单元需要持续维护模块边界
垂直拆分业务能力,如任务、成员、通知可以先做逻辑拆分,再独立部署跨能力依赖与数据归属要明确
服务化部署、调用与数据所有权边界可以暂用同一数据库服务器,但不应随意互写表网络失败、契约、运维、跨服务一致性
事件驱动状态变化与后续处理的时间耦合可以同进程,也可以跨服务延迟、重复、乱序、事件版本、排障
CQRS命令模型与查询模型可以共用同一数据库两套模型的维护和一致性解释
事件溯源权威事实的保存方式可以单体,也可以服务化历史事件兼容、回放、快照和隐私管理
数据库读写分离写节点与只读副本的执行路径数据模型可以完全相同复制滞后、读写路由、切换与读一致性

这不是必须按顺序走完的成熟度阶梯。一个业务边界清楚、流量可控的模块化单体可以长期工作;一个独立报表模型也不要求把所有业务都改成事件溯源。选型要对应已观察到的问题。

单体的第一步:让模块先能独立理解 ​

单体指作为一个主要单元构建和部署,不等于所有代码都放在同一个包。任务、成员、通知可以各自有应用接口和内部数据访问。创建任务只依赖成员模块提供的“是否有权限”能力,不直接到处读取成员模块的内部表字段。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    H["HTTP 入口"] --> T["任务模块:创建、改状态"]
    H --> M["成员模块:权限、团队"]
    T -->|"通过明确接口"| M
    T --> N["通知模块接口"]
    subgraph D["一个数据库事务环境"]
        TD[("任务表与创建结果")]
        MD[("成员表")]
        OD[("outbox")]
    end
    T --> TD
    M --> MD
    T --> OD
    OD -.-> N
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    H["HTTP 入口"] --> T["任务模块:创建、改状态"]
    H --> M["成员模块:权限、团队"]
    T -->|"通过明确接口"| M
    T --> N["通知模块接口"]
    subgraph D["一个数据库事务环境"]
        TD[("任务表与创建结果")]
        MD[("成员表")]
        OD[("outbox")]
    end
    T --> TD
    M --> MD
    T --> OD
    OD -.-> N

模块边界要落实到四件事:谁拥有表和业务规则;哪些接口允许其他模块调用;哪些数据结构不能向外泄漏;哪些操作必须在同一事务内完成。代码包分好了,却允许每个模块直接更新其他模块的表,之后仍很难拆开。

好处是本地调用便宜、事务直接、调试和发布路径短。代价是资源争抢和统一发布仍在,一个模块的内存失控可能影响整个进程。可以先把慢报表放到后台任务、限制连接池、优化查询,未必立刻拆成微服务。Fowler 的 Monolith First讨论了先发现稳定边界再拆分的理由,也明确其经验性;它不是“所有系统必须从单体开始”的定律。

判断是否值得拆,可先收集具体证据:一个业务模块是否需要不同扩容节奏,是否被独立团队长期维护,发布互相等待是否确实增加交付时间,以及隔离故障的价值能否覆盖多服务运维成本。“代码行数多”本身不足以回答这些问题。

垂直拆分:围绕业务能力,别只把层变成服务 ​

垂直拆分可以先得到任务、成员和通知三个业务模块,也可以将某个模块独立部署。它与“前端服务、逻辑服务、数据库服务”这种按技术层切开不同:若每次业务变化仍要同时改三层服务,部署数增加了,业务耦合却没减少。

这个案例先拆通知比较合理:通知允许延迟,外部渠道故障不应阻塞创建,吞吐和重试模式也不同。任务与成员权限可能仍保留较紧密的同步关系,因为创建时需要回答“这个人现在能否操作”。边界不是由目录名决定,而是由事务与业务时效决定。

拆分候选希望获得什么拆之前必须补齐什么
通知渠道故障隔离、独立 worker 扩容持久通知意图、幂等消费、超时和重试上限
报表慢查询不占核心请求资源可接受的数据滞后、可重建的数据来源
成员与授权明确团队所有权、独立权限演进权限缓存失效、撤权生效时间、依赖不可用策略
任务核心独立部署与数据生命周期请求幂等、并发版本、迁移时单写者

拆分后的“数据库归某服务所有”通常意味着其他服务不能绕过接口直接改它的业务数据,不一定要求立刻使用不同数据库产品或物理服务器。先有明确所有权,再根据隔离、资源和维护需求决定物理部署。共享数据库中跨服务 JOIN 很方便,却会让内部 schema 变成隐式公共契约,需要明确它是临时迁移方案还是长期约束。

服务化:进程边界变成失败边界 ​

一个本地函数调用要么返回、要么进程失败;远程调用还会遇到超时但对方已完成、请求没到、响应丢了、连接断开等情况。拆服务时必须补上 deadline、取消、幂等业务键、有限重试、并发上限、身份与授权、链路关联和错误分类。

如果创建任务先同步调用通知,再提交数据库,通知可能已经发出但任务回滚;如果先提交任务再发通知,进程可能在两步之间崩溃。把这两行移到不同服务,并不会自动出现一个跨服务事务。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant A as 任务服务
    participant D as 任务数据库
    participant R as outbox 转发器
    participant N as 通知服务
    A->>D: 单事务写任务与 TaskCreated 事件意图
    D-->>A: 提交成功
    R->>D: 读取待发送事件
    R->>N: 发送 event_id
    N->>N: 去重与创建通知放在本地事务
    N-->>R: 已接受
    R->>D: 记录投递进度
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant A as 任务服务
    participant D as 任务数据库
    participant R as outbox 转发器
    participant N as 通知服务
    A->>D: 单事务写任务与 TaskCreated 事件意图
    D-->>A: 提交成功
    R->>D: 读取待发送事件
    R->>N: 发送 event_id
    N->>N: 去重与创建通知放在本地事务
    N-->>R: 已接受
    R->>D: 记录投递进度

转发器在发送后、记录进度前崩溃,会重发;所以通知服务按 event_id 去重。outbox 解决本地提交与外部投递之间的丢失窗口,不承诺外部副作用绝对只发生一次。原模式说明见 Transactional Outbox。

在拆分前,至少画出一个失败时序,而不只画成功调用链。尤其要问:下游已成功但本地不知道时,查询哪个结果?是否允许重复?哪条状态能证明工作完成?这些答案比服务数量更影响可靠性。

事件驱动:发布已经发生的事实 ​

命令表达希望发生的动作,如 CompleteTask;事件表达已经发生的事实,如 TaskCompleted。命令可能被拒绝,事件应能对应一个已提交的业务变化。事件里只写“数据变了,来查一下”会减少载荷,却使消费者强依赖生产者当时可用;携带需要的事实能降低查询耦合,但增加复制的数据、隐私和版本维护成本。

一个最小事件信封可以包含 event_id、event_type、schema_version、aggregate_id、aggregate_version、occurred_at、payload,以及追踪所需的 correlation_id。时间戳用于展示和诊断;不能只按墙上时间推断业务先后。对于同一个任务,递增版本更适合发现重复和缺口;多个任务之间通常不需要全局顺序。

契约问题本案例的选择为什么要明确
重复投递允许;消费者持久去重发送成功与 ACK 丢失无法只靠发送方区分
单任务顺序按 task_id 分区,并检查 aggregate_version换分区或并发消费时可能破坏顺序
未知版本暂停或隔离并告警静默跳过会让投影永久缺数据
消费进度与本地投影更新原子提交,或用可重放的等价协议先写数据后记进度会重复,反过来会漏处理
积压有限保留、按优先级隔离,监控最老事件年龄队列长度不能直接表示业务延迟
schema 兼容新旧消费者共存期内保持兼容,先升级能读新格式的消费者否则一次发布会阻断所有下游

这里的“原子提交”依赖具体数据库与消息系统。事件日志与投影库是两个系统时,不能靠一句“事务”把它们包起来。可以把已处理事件 ID 与投影同库提交,消息确认放到提交之后,允许重复读取再去重;也可以使用目标产品明确支持的事务协议。Kafka 的事务与 exactly-once 能力有适用边界,见 Kafka 4.1 消息语义。

事件驱动让通知不阻塞创建,却会增加看见全流程的难度。排障时需要从请求 ID 找到任务版本,再找到 event_id、消费结果和投递状态。把所有日志打得更多,不能替代这些关联键和状态查询入口。

CQRS:写入规则与查询形状可以分别设计 ​

CQRS 是 Command Query Responsibility Segregation:命令负责改变状态,查询负责返回信息。任务的写模型可能围绕“只有未完成任务能改标题”“修改时版本必须匹配”;看板的读模型则需要负责人姓名、团队名称、状态统计和最近活动。让一套结构同时满足两者,可能导致写规则散落或查询昂贵。

最小 CQRS 可以只是同一进程中的命令接口与查询接口,仍读写同一个数据库。再往前一步,命令更新规范化表,查询读取单独维护的看板投影。只有需要独立性能、可用性或存储形态时,才进一步拆开数据库或服务。定义和复杂度边界见 Fowler CQRS 与 Azure CQRS 模式。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    C["命令:完成任务,expected_version=2"] --> W["写模型:验证状态和版本"]
    W --> A[("权威当前状态或事件流")]
    A -->|"已提交变化,可能延迟"| P["投影器"]
    P --> R[("看板读模型")]
    Q["查询:团队看板"] --> R
    W -->|"写结果及版本 3"| U["客户端"]
    R -->|"结果及已处理版本"| U
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    C["命令:完成任务,expected_version=2"] --> W["写模型:验证状态和版本"]
    W --> A[("权威当前状态或事件流")]
    A -->|"已提交变化,可能延迟"| P["投影器"]
    P --> R[("看板读模型")]
    Q["查询:团队看板"] --> R
    W -->|"写结果及版本 3"| U["客户端"]
    R -->|"结果及已处理版本"| U

图中的权威数据可以是普通当前状态表,也可以是事件流,所以 CQRS 不要求事件溯源。反过来,采用事件溯源也不强制每一种查询都使用独立服务。

投影异步更新时,写成功后立即读取看板可能还看不到变化。可以直接把命令返回的新状态显示在客户端;可以让后续查询带 min_version,并在有限等待内等投影追上;也可以把需要写后读的请求路由到权威模型。等待必须有超时和回退,不能把“最终一致”变成无限转圈。

本机模型的权威事件流已经写到版本 2,但读投影只处理了版本 1:

text
write_version=2 read_version=1 title=初稿

旧页面再带 expected_version=1 提交修改,返回:

text
stale command: version conflict

版本检查保护写状态,读模型滞后仍要通过接口解释。它们解决的是两个不同问题。

事件溯源:把变化序列作为权威事实 ​

普通持久化常把任务当前标题覆盖为新标题。事件溯源保存“创建任务”“改名”“完成”等已提交事件,再由事件推导当前状态。例如 t1 的事件序列是 Created(v1) → Renamed(v2) → Completed(v3)。当前标题与完成标志可以重建,过去发生过什么也有来源。

这与普通审计日志不同:如果删掉事件就无法可靠重建权威状态,那么事件是主要存储;如果当前表才是权威,旁边日志可能漏记也不影响主流程,就不能只因“有事件表”称为事件溯源。事件驱动关注交互方式,事件溯源关注事实存储方式,两者可以单独采用。Fowler Event Sourcing和Azure Event Sourcing分别讨论了这一模型及其使用条件。

能得到什么必须付出的成本
保留业务变化历史,可构建新投影历史事件 schema 长期可读,修错流程更复杂
能重放到某个版本检查状态回放耗时与快照管理;外部依赖不能按“今天的值”乱重算历史
同一事实派生不同读模型多投影的一致性、滞后、重建资源与版本管理
通过追加修正事件保留原因删除个人数据、保留政策和不可变日志之间需要专门设计

事件应该包含重放所需的确定事实。比如事件记录的是当时采用的业务结果与必要参数,而不是在回放时重新访问外部接口获取今日结果。事件处理函数也不要在内部读随机数、当前时钟或可变远端状态来决定历史状态,否则相同事件可能重建出不同结果。

写入时对每个聚合检查 expected_version,再追加下一事件。本文把一个任务当聚合,即一次规则检查与原子修改的边界。模型的关键部分是:

go
if expected != len(hs) {
    return Event{}, errors.New("version conflict")
}
e.Seq = len(hs) + 1

完整实现还先 upcast,再用 reducer 验证合法状态,最后才追加。测试让 10 个 goroutine 争着从版本 1 改名,只有一个成功。它使用内存 mutex 表示原子边界;没有磁盘提交、跨进程锁或集群复制。生产还需独立 command_id 幂等结果:提交后响应丢失时,仅靠 expected_version 冲突,客户端仍不知道是不是自己的命令已经成功。模型没有实现这项生产能力。

投影如何处理重复、缺口和重建 ​

投影把一个事件转换为可查询状态。本文要求同一任务逐版本应用,重复 event_id 且内容相同则直接成功;同一个 ID 内容不同则报错。遇到版本缺口,先暂停该任务的推进并查缺失事件,不能直接跳到最新版本。

go
if old, ok := p.Seen[e.ID]; ok {
    if old != e { return errors.New("event ID payload conflict") }
    return nil
}

把版本 2 应用两遍,输出仍是:

text
after replay: version=2 title=审校

把版本 3 直接发给空投影,输出:

text
gap: sequence gap

这里的版本是聚合内部序号。消息系统的 partition offset 则是某个分区中的读取位置,不是任务版本;一个分区可能混着许多任务。还可能有快照版本与投影代码版本,不应塞进同一个 version 字段里。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    E[("历史事件 + 持续新增事件")]
    E --> O["旧投影,继续服务"]
    E --> N["新投影:独立进度从头重建"]
    N --> C["追平水位、校验样本与总量"]
    O --> R["查询路由"]
    C -->|"满足切换条件后"| R
    R --> U["用户查询"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    E[("历史事件 + 持续新增事件")]
    E --> O["旧投影,继续服务"]
    E --> N["新投影:独立进度从头重建"]
    N --> C["追平水位、校验样本与总量"]
    O --> R["查询路由"]
    C -->|"满足切换条件后"| R
    R --> U["用户查询"]

重建时不要清空正在服务的读表后再慢慢回放。建立新投影,记下回放范围与水位,继续消费新增事件,核对结果,再切查询路由。保留旧投影一段明确的回退窗口。注意:这里的路由回退容易,写模型和事件语义若已改变,回退到旧写程序未必安全。

快照保存某个聚合在版本 N 的状态,加载时只回放 N 之后的事件。快照是性能优化,要包含版本、schema 和必要校验信息;若只是可丢弃缓存,就不能在历史事件已删后仍声称能从头重建。保留多少期事件、多久能重建、重建会占用多少资源,需要作为运行要求测量。

还有一个容易遗漏的事务:更新投影与保存已处理标记必须一致。受控反例先把计数加一,在记录 seen 前模拟崩溃,再重投,输出:

text
broken split commit count=2

示例测试明确验证错误发生,不靠真正杀进程。正确方向是在同一数据库事务内写投影与去重/进度记录,或设计等价的幂等赋值协议。内存示例只能表达这个边界,不能验证某个数据库的崩溃恢复。

schema 演进:老事件不会随新代码一起消失 ​

假设旧事件 Created 的 schema 1 字段叫 Name,新版 schema 2 叫 Title。不能简单让新代码只认 Title,否则重建旧数据时标题会变空。模型保留旧事件,读取时 upcast 成统一内部结构:

go
switch e.Schema {
case 1:
    c.Title = e.Name
case 2:
    c.Title = e.Title
default:
    return Canonical{}, fmt.Errorf("unsupported schema %d", e.Schema)
}

这是字段重命名的确定映射,不是任意补全缺失事实。旧事件从未记录的数据不能凭空恢复;若必须推导,保存推导依据、版本与不确定性,必要时走一次显式迁移,而不是悄悄给所有历史事件填今天的默认值。

变更常见处理检查点
新增可选字段老消费者忽略,新消费者接受缺省缺省是否真的保持原语义
字段改名或类型改变版本化事件,读取时 upcast所有旧版本有测试夹具
业务含义改变引入新事件类型或明确新版本不能只改名字继续复用旧含义
修复历史错误追加更正/撤销事实,必要时受控修复保留审计链;所有投影如何反映更正
个人数据删除减少事件中的个人信息,使用受控引用等方案保留法律与产品要求需专门评估,不能承诺删一行就完成

重建结果和现有投影一致,才说明当前 reducer 对这组事件稳定。它不证明旧、新 schema 在所有历史输入上兼容,所以要保留真实的历史样本、未知版本负例和业务边界测试。

重放时,别把通知再发一遍 ​

如果处理 TaskCompleted 时既更新看板又发邮件,重新建看板就可能重发所有历史邮件。把纯状态变换与外部副作用分开:reducer 只算状态;需要发通知的消费者有自己的幂等记录、处理进度和投递策略。重建读模型时根本不走发送通道。

模型先执行一次独立通知消费者,再重建投影:

text
rebuild equal=true done=true notifications=1
retry notifications=0

notifyCompleted 只是向内存 map 记账并计数,没有真实发信。生产中“发送成功”和“记录发送成功”之间仍有故障窗口,能否避免重复取决于接收端幂等和状态查询能力。不能从这两行输出推导邮件、支付等外部动作恰好执行一次。

读写分离:同一数据模型,也可能读到旧数据 ​

数据库主节点写、只读副本读,常用于分担查询。这与 CQRS 的模型职责分离不是同一件事:主从可以仍用同一张 tasks 表;CQRS 也可以不使用副本。只读副本的复制可能滞后,新任务写入主库后立即读副本可能暂时不存在。具体数据库还可能因回放与长查询冲突取消查询,见 PostgreSQL 18 Hot Standby。

读取要求可选策略代价
允许几秒滞后的报表只读副本或异步投影标注刷新时间并监控实际滞后
本人刚写后必须看到返回写结果,或短期路由主库主库读负载增加;固定时间窗不一定覆盖最坏滞后
需要至少读到某次提交使用系统支持的提交水位/版本令牌并有限等待客户端与路由更复杂;不支持时不能假装有保证
权限撤销、库存资格判断在明确权威边界做判定不能因读副本更空闲就读旧授权或库存

复制副本也不是备份:错误删除可以被复制过去。是否能回到错误发生前,要看独立备份、日志保留、恢复程序与演练结果。

跨服务流程:补偿是新业务动作 ​

服务拆开后,原来一笔事务可能变成多个本地事务。例如培训系统先预留名额,再创建报名;创建失败,需要释放预留。Saga 通过一系列本地事务和相应补偿组织流程,可以由协调者推进,也可以由事件触发;定义见 Saga 模式。

步骤本地提交的事实后续失败时可能的补偿
预留名额reservation=reserved条件释放该预留,避免重复返还
创建报名enrollment=confirmed追加取消报名状态
发送确认邮件提供方接受了消息无法撤回已读邮件,必要时发送更正说明

补偿不像数据库回滚那样让世界恢复成完全没发生过。名额释放前可能有其他人操作,邮件可能已经被读到,补偿自己也会超时或失败。每一步与补偿都需要幂等键、可查询状态、重试期限和人工异常队列;并且要确定哪些步骤可以重试完成、哪些必须补偿。参见 Compensating Transaction。

Saga 不提供与单库事务相同的隔离。中间状态可能被其他流程看到;若业务要求整个过程绝对不可见,可能需要保留更大的事务边界、使用预留状态或重新设计交互,而不是给每个步骤补一条事件就认为问题解决。

迁移:先证明新路径正确,再交出写入权 ​

渐进替换可以在统一入口后按业务或租户切换旧、新实现,相关思路见 Strangler Fig。关键是写入所有权。让两个系统长期独立写同一份业务事实,然后靠定时任务“最终同步”,会使冲突与回退难以解释。

一次有界迁移可以这样执行:

  1. 在旧系统内建立模块接口和唯一写入口,先消除绕过模块的写操作。
  2. 把新 schema 或新服务部署成兼容旧数据的状态;用历史夹具、契约测试和失败场景验证。
  3. 从一致的基线回填,再持续消费增量,检查遗漏、重复、分区进度和权限数据。
  4. 对只读请求做影子比较,核对结果与延迟;影子路径不执行邮件、扣款等副作用。
  5. 按明确租户或分区切换写入所有者。暂停旧写或使用受控路由/版本屏障,保证同一对象不会双写。
  6. 验证新写、新读和恢复路径,保留可操作的回退方案;确认没有旧消费者后,再移除旧字段和旧路径。

schema 的 expand-contract 也遵循这个顺序:先添加兼容结构,让新旧版本都能运行;迁移并校验;最后才删除旧结构。数据库加列、回填和索引构建是否锁表、占多少空间,必须按具体数据库版本测试。代码回滚不等于数据迁移自动回滚,删掉旧列后再运行旧二进制通常就来不及了。

如果拆分后跨服务调用过多、每次发布仍须全体协调、故障定位变慢,而隔离收益很小,可以把边界重新合并。演进的验收指标应是交付、故障隔离、容量与恢复能力是否改善,不能只数服务或消息主题增加了多少。

依据与验证边界 ​

  • Fowler Monolith First、Strangler Fig:模块边界与渐进替换的原作者讨论。
  • Fowler CQRS、Azure CQRS:命令、查询模型与数据同步。
  • Fowler Event Sourcing、Azure Event Sourcing:权威事件、重建、快照及采用成本。
  • Transactional Outbox、Saga、Compensating Transaction:本地事务、消息和跨服务恢复边界。
  • Kafka 4.1 Design、PostgreSQL 18 Hot Standby:投递语义与读副本限制。本文没有搭建这两个产品做实验。
  • 示例在 2026-10-13、Go 1.27.2、Linux/amd64 实际运行。完整输出如正文;普通测试、race 和 vet 通过。包含旧/新 schema、重复与同 ID 异参、版本缺口、未知 schema、非法事件无写入、10 个并发命令只有一个成功、无通知副作用的重建及错误分步提交反例。
  • 本模型只验证内存状态机。事件持久化、数据库投影事务、消息系统崩溃恢复、生产回填和跨服务补偿没有实测。迁移步骤是设计建议,需要在目标系统上形成有停止条件和回退条件的实施计划。

完整代码与测试 ​

把下列文件放在同一目录。程序无外部依赖、网络服务和文件持久化;所有任务与事件都是固定教学数据。Store 的写入用 mutex 保护;Projection 与通知消费者按顺序运行,没有宣称可被多个 goroutine 随意并发调用。

sh
go run .
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...

go.mod ​

go.mod
module example.com/evolution

go 1.27.0

main.go ​

代码块 · 187 行 · 点击收起 / 展开
go
package main

import (
	"errors"
	"fmt"
	"slices"
	"sync"
)

type Event struct {
	ID, Task, Kind string
	Seq, Schema    int
	Name, Title    string
}
type Canonical struct {
	ID, Task, Kind, Title string
	Seq                   int
}

func upcast(e Event) (Canonical, error) {
	c := Canonical{ID: e.ID, Task: e.Task, Kind: e.Kind, Seq: e.Seq}
	switch e.Schema {
	case 1:
		c.Title = e.Name
	case 2:
		c.Title = e.Title
	default:
		return Canonical{}, fmt.Errorf("unsupported schema %d", e.Schema)
	}
	return c, nil
}

type State struct {
	Title   string
	Done    bool
	Version int
}

func reduce(s State, e Canonical) (State, error) {
	if e.Seq != s.Version+1 {
		return s, errors.New("sequence gap")
	}
	switch e.Kind {
	case "Created":
		if s.Version != 0 || e.Title == "" {
			return s, errors.New("invalid create")
		}
		s.Title = e.Title
	case "Renamed":
		if s.Version == 0 || s.Done || e.Title == "" {
			return s, errors.New("invalid rename")
		}
		s.Title = e.Title
	case "Completed":
		if s.Version == 0 || s.Done {
			return s, errors.New("invalid complete")
		}
		s.Done = true
	default:
		return s, errors.New("unknown event kind")
	}
	s.Version = e.Seq
	return s, nil
}

// Atomic in-memory append and expected-version check. No disk persistence.
type Store struct {
	mu      sync.Mutex
	History map[string][]Event
}

func (s *Store) Append(expected int, e Event) (Event, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	hs := s.History[e.Task]
	if expected != len(hs) {
		return Event{}, errors.New("version conflict")
	}
	e.Seq = len(hs) + 1
	e.ID = fmt.Sprintf("%s:%d", e.Task, e.Seq)
	state := State{}
	for _, old := range hs {
		c, err := upcast(old)
		if err != nil {
			return Event{}, err
		}
		state, err = reduce(state, c)
		if err != nil {
			return Event{}, err
		}
	}
	c, err := upcast(e)
	if err != nil {
		return Event{}, err
	}
	if _, err = reduce(state, c); err != nil {
		return Event{}, err
	}
	s.History[e.Task] = append(hs, e)
	return e, nil
}
func (s *Store) Events(task string) []Event {
	s.mu.Lock()
	defer s.mu.Unlock()
	return slices.Clone(s.History[task])
}

// This projection is called serially. Production needs transactional update+offset.
type Projection struct {
	Rows map[string]State
	Seen map[string]Event
}

func NewProjection() *Projection {
	return &Projection{Rows: map[string]State{}, Seen: map[string]Event{}}
}
func (p *Projection) Apply(e Event) error {
	if old, ok := p.Seen[e.ID]; ok {
		if old != e {
			return errors.New("event ID payload conflict")
		}
		return nil
	}
	c, err := upcast(e)
	if err != nil {
		return err
	}
	next, err := reduce(p.Rows[e.Task], c)
	if err != nil {
		return err
	}
	// Logical atomic boundary; no external calls in this reducer.
	p.Rows[e.Task] = next
	p.Seen[e.ID] = e
	return nil
}
func rebuild(es []Event) (*Projection, error) {
	p := NewProjection()
	for _, e := range es {
		if err := p.Apply(e); err != nil {
			return nil, err
		}
	}
	return p, nil
}

// An independent, explicitly invoked effect consumer. Rebuild never invokes it.
func notifyCompleted(es []Event, delivered map[string]bool) int {
	n := 0
	for _, e := range es {
		if e.Kind == "Completed" && !delivered[e.ID] {
			delivered[e.ID] = true
			n++
		}
	}
	return n
}
func must(err error) {
	if err != nil {
		panic(err)
	}
}
func main() {
	s := &Store{History: map[string][]Event{}}
	p := NewProjection()
	e1, err := s.Append(0, Event{Task: "t1", Kind: "Created", Schema: 1, Name: "初稿"})
	must(err)
	must(p.Apply(e1))
	e2, err := s.Append(1, Event{Task: "t1", Kind: "Renamed", Schema: 2, Title: "审校"})
	must(err)
	fmt.Printf("write_version=%d read_version=%d title=%s\n", e2.Seq, p.Rows["t1"].Version, p.Rows["t1"].Title)
	_, err = s.Append(1, Event{Task: "t1", Kind: "Renamed", Schema: 2, Title: "旧页面"})
	fmt.Println("stale command:", err)
	must(p.Apply(e2))
	must(p.Apply(e2))
	fmt.Printf("after replay: version=%d title=%s\n", p.Rows["t1"].Version, p.Rows["t1"].Title)
	e3, err := s.Append(2, Event{Task: "t1", Kind: "Completed", Schema: 2})
	must(err)
	fmt.Println("gap:", NewProjection().Apply(e3))
	must(p.Apply(e3))
	delivered := map[string]bool{}
	sent := notifyCompleted(s.Events("t1"), delivered)
	rebuilt, err := rebuild(s.Events("t1"))
	must(err)
	fmt.Printf("rebuild equal=%v done=%v notifications=%d\n", rebuilt.Rows["t1"] == p.Rows["t1"], rebuilt.Rows["t1"].Done, sent)
	fmt.Printf("retry notifications=%d\n", notifyCompleted(s.Events("t1"), delivered))
}

main_test.go ​

代码块 · 97 行 · 点击收起 / 展开
go
package main

import (
	"sync"
	"sync/atomic"
	"testing"
)

func TestEvolution(t *testing.T) {
	s := &Store{History: map[string][]Event{}}
	e1, err := s.Append(0, Event{Task: "t", Kind: "Created", Schema: 1, Name: "old"})
	if err != nil {
		t.Fatal(err)
	}
	e2, err := s.Append(1, Event{Task: "t", Kind: "Renamed", Schema: 2, Title: "new"})
	if err != nil {
		t.Fatal(err)
	}
	p := NewProjection()
	if err = p.Apply(e2); err == nil || len(p.Rows) != 0 {
		t.Fatal("accepted gap")
	}
	for _, e := range []Event{e1, e1, e2, e2} {
		if err = p.Apply(e); err != nil {
			t.Fatal(err)
		}
	}
	if p.Rows["t"] != (State{Title: "new", Version: 2}) {
		t.Fatal(p.Rows)
	}
	bad := e2
	bad.Title = "tampered"
	if err = p.Apply(bad); err == nil {
		t.Fatal("accepted payload conflict")
	}
	bad = e2
	bad.ID = "bad"
	bad.Schema = 99
	if err = p.Apply(bad); err == nil {
		t.Fatal("accepted unknown schema")
	}
	rebuilt, err := rebuild(s.Events("t"))
	if err != nil || rebuilt.Rows["t"] != p.Rows["t"] {
		t.Fatal(err)
	}
	if _, err = s.Append(2, Event{Task: "t", Kind: "Unknown", Schema: 2}); err == nil || len(s.Events("t")) != 2 {
		t.Fatal("invalid append mutated history")
	}
}
func TestOptimisticConcurrency(t *testing.T) {
	s := &Store{History: map[string][]Event{}}
	_, err := s.Append(0, Event{Task: "t", Kind: "Created", Schema: 2, Title: "first"})
	if err != nil {
		t.Fatal(err)
	}
	var success atomic.Int32
	var wg sync.WaitGroup
	for range 10 {
		wg.Go(func() {
			if _, err := s.Append(1, Event{Task: "t", Kind: "Renamed", Schema: 2, Title: "second"}); err == nil {
				success.Add(1)
			}
		})
	}
	wg.Wait()
	if success.Load() != 1 || len(s.Events("t")) != 2 {
		t.Fatal(success.Load())
	}
}
func TestRebuildDoesNotNotify(t *testing.T) {
	es := []Event{{ID: "t:1", Task: "t", Kind: "Created", Seq: 1, Schema: 1, Name: "old"}, {ID: "t:2", Task: "t", Kind: "Completed", Seq: 2, Schema: 2}}
	delivered := map[string]bool{}
	if notifyCompleted(es, delivered) != 1 {
		t.Fatal("missing live effect")
	}
	if _, err := rebuild(es); err != nil {
		t.Fatal(err)
	}
	if notifyCompleted(es, delivered) != 0 {
		t.Fatal("duplicate effect")
	}
}

// Counterexample: applying a projection before remembering progress duplicates a delta.
func TestSplitCommitCounterexample(t *testing.T) {
	count := 0
	seen := false
	count++ // crash before seen=true
	if !seen {
		count++
		seen = true
	}
	if count != 2 || !seen {
		t.Fatal(count, seen)
	}
	t.Logf("broken split commit count=%d", count)
}

保留各书来源与许可 · 示例运行条件以正文为准