这本书我从头啃到尾,做微服务架构设计的时候反复翻了很多次。第六章“使用事件溯源开发业务逻辑”乍看像是一门“新潮设计模式”的科普,实际上它戳中的是微服务架构里最让人头疼的问题:业务状态变了,怎么可靠地让下游知道?常规的“先改库再发消息”总是断一条腿,事件溯源给了另一个答案——不存状态,只存事实。
这一章的核心价值在于,它不只是讲事件溯源(Event Sourcing)这个概念,而是把业务逻辑的组织方式、聚合设计、事件存储、命令处理流程全部串了起来,给出了一套能够直接落地的方法。这篇笔记我会按自己读完之后的实践顺序来写:先讲为什么业务逻辑在微服务里成了问题,再拆事件与聚合的关系,然后落到表结构和代码,最后把踩过的坑一条条摆出来。
如果你正在做微服务拆分,又被跨服务的数据一致性问题折磨过,这篇笔记应该能帮你在动手前看清事件溯源的全貌,同时也知道哪些坑能绕开就绕开。
1. 微服务里,为什么“业务逻辑”突然不够用了
1.1 事务脚本到领域模型:表面是代码组织,其实是数据一致性决策
单体应用时代,业务逻辑怎么写其实没那么讲究,因为所有状态都锁在同一个数据库里,一个本地事务能包住所有变更。到了微服务架构,每个服务拥有自己的数据库,原来那个“万能事务”被拆没了。这时候业务逻辑怎么组织,直接决定了数据一致性好不好做。
我见过不少团队做微服务,服务是拆出去了,业务逻辑还是在Service层里平铺一段段SQL操作,也就是书里说的“事务脚本”(Transaction Script)。这个方法简单直接,CRUD型的服务用它完全没问题。但一旦业务复杂起来,脚本会越长越失控,同一个业务规则被复制到多个方法里,改一处漏三处,最后没人敢动那段代码。
Richardson在这一章给出的方向是领域模型(Domain Model),核心组织单位是聚合(Aggregate)。简单说,就是把业务状态和修改状态的规则封装在一个对象内部,外部不能直接操作内部状态。这么做的好处是业务规则内聚,跨实体的不变式由聚合根统一维护。微服务之间的API边界其实就是聚合的边界,聚合内部怎么变,对外部来说是黑盒,这比一堆Service方法互相调用要干净得多。
但这章的重点不是让你换个代码风格就完事,而是要引出下一个问题:业务逻辑执行完之后,状态变化怎么传递给其他服务?于是就有了双写难题。
1.2 双写问题的本质:为什么“先存库再发消息”是微服务头号隐患
假设一个订单服务要创建订单,同时通知库存服务扣减库存。常规做法是:先写订单表,然后发一条消息到MQ,库存服务收到消息后执行扣减。看起来没啥问题,实际上两个操作之间没有事务保护。
你可能会想:那我把发消息放在数据库事务里面不行吗?如果MQ宕机或者网络抖动,事务提交了但消息没发出去,数据库认为订单创建成功,下游却对这件事一无所知,订单就是“孤儿状态”。反过来,如果先发消息再写库,下游已经开始处理,数据库事务却回滚了,两边数据就分叉了。这两种先后顺序,本质上都在赌“另一个系统在我眼皮子底下不会出错”,而现实是你总会撞上它出错。
事务性发件箱(Transactional Outbox)是第一种解法,数据库和MQ之间加一张发件箱表,靠后台任务扫表保证最终一致。事件溯源则是另一种更彻底的解法:不写状态表,直接写事件表。状态变化本身就是一条事件记录,写入事件表这一条操作,同时完成了“记录业务事实”和“准备通知下游”两件事,压根不存在“先写哪个”的问题。
理解了这个因果链,再看事件溯源就容易了:它不是为了新潮而新潮,而是为了解决微服务架构下业务逻辑与事件发布的原子性问题。
2. 先把两个地基概念抠明白:事件与聚合
2.1 数据库里的“状态”到底是什么
我建议你忘掉“数据库里存当前状态”这件事,否则事件溯源怎么想都别扭。
传统模式下,一张账户表里余额字段是1000,就代表这个人现在有1000块。事件溯源模式下,数据库里存的是一串事件:开户存入1000、消费支出150、工资入账200。当前余额1050是怎么来的?把这一串事件从头到尾重放一遍算出来的。
状态是照片,事件是录像。照片能让你一眼看到当时的模样,但你看不到它怎么变成这样;录像完整记录了每个瞬间,任何时候都能回放到任意时间点。业务系统如果只有照片,出问题的时候只能看着余额发呆,因为它不记得这笔钱到底是哪一步算错的。
事件有两个天然特性,是我在实际项目里体会最深的:第一个是不可变。事件一旦写入就是事实,没有“修改”这回事。张三昨天下单买了东西,这个事件永远存在,今天你想“改正”它,只能追加一条“订单取消”或“退款”事件,不能把昨天的记录抹掉。这跟现实是一致的,交易发生了就是发生了,错了要冲正,不能假装没发生过。第二个是可重放。这是事件溯源的立身之本,后面讲快照和读模型时还会用到。
还有一个反直觉的地方:事件本身没有“被删除”的按钮,数据库只会越来越大。这是我后来才意识到的运维压力,所以别等第5章,这里先记住:事件存储要准备好扩容方案和生命周期策略。
2.2 领域事件和普通消息的区别
很多人把“领域事件”和“普通消息”混为一谈,这会导致事件字段设计得很随意。领域事件是业务世界里真实发生的事情,命名必须是过去时态,OrderCreated、PaymentReceived、CustomerCreditReserved。它不是命令,不是“请你做什么”,而是“已经发生了什么”。
我踩过的第一个坑,就是想偷懒把整个聚合对象塞进事件里。比如OrderCreated事件,直接把order对象序列化进去。当时觉得方便,下游订阅者想要啥都有,结果后面聚合结构一改,老事件的字段全部失配,重放直接报错。正确做法是事件只携带业务事实本身,比如orderId、customerId、lineItems、totalPrice、occurredAt。订阅者需要更多信息,自己去查或者投影到读模型。
这里必须区分命令和事件。命令是意图,比如CreateOrderCommand、CancelOrderCommand,意图有可能被拒绝,比如订单状态不允许取消。事件是结果,事件一发生就不可撤销。判断标准很简单:如果你不确定这件事是否一定会发生,它就是命令;只有它已经真实发生了,才是事件。很多项目在这地方翻车,把CreateOrderCommand直接当事件存进事件表,重放时还要处理“失败的命令”这种根本不该存在的状态,逻辑会越来越拧巴。
3. 六步把业务逻辑“改造”成事件溯源
3.1 第一步:把业务命令列全
事件溯源的重构不是从表结构开始,而是从业务行为清单开始。以一个订单服务为例,我会先把服务支持的命令全部列出来:创建订单、修改订单、取消订单、审批订单、拒绝订单。列的时候不要嫌多,也别觉得理所当然,每个命令都要追问一遍:它成功时会触发什么?失败时是被拒绝还是允许?有没有可能命令发过来后什么都没发生?
这个步骤我习惯用EventStorming的简化版来做:拿一张白板,左侧写命令,右侧写命令成功后的结果事件,中间标注业务规则和不变量条件。这样画完之后,你会得到一个相对完整的事件清单,也就是事件溯源系统的事实基础。
3.2 第二步:为每个命令设计“事件发生序列”
一个命令不一定会产生一条事件。创建订单可能触发两条事件:OrderCreated加上PaymentRequested。取消一个已支付的订单可能会产生:OrderCancelled、RefundInitiated两个事件。审批一个订单则会启动一系列跨服务交互,其中包含对客户信用额度的检查。
在设计事件序列时,我的经验是先从“成功路径”开始,把业务结果梳理出来,之后再补充异常分支。别一上来就假设事件和命令是1:1关系,真实业务里一对多非常普遍。比如一个订单仅凭业务规则就可以同时在很多环节发起动作,这是事件驱动本质:状态一变,后续的变化会被连锁触发。
3.3 第三步:命令处理器的标准流程(代码骨架)
事件溯源命令处理器通常分四步走,这是一个可以照抄的骨架:
public class OrderCommandHandler { public List<DomainEvent> handleCreateOrder(CreateOrderCommand cmd) { // 1. 通过orderId从事件存储中加载已有事件,并重建聚合状态 Order order = orderRepository.load(cmd.getOrderId()); // 2. 调用聚合根的业务方法,业务方法和校验都封装在聚合内部 List<DomainEvent> newEvents = order.create( cmd.getCustomerId(), cmd.getLineItems()); // 3. 将新事件追加到事件流的末尾,这一步是原子写入 eventStore.append(cmd.getOrderId(), order.getVersion(), newEvents); // 4. 发布事件到消息总线,让下游订阅者异步感知 eventBus.publish(newEvents); return newEvents; } }第一步的load不是从数据库查“当前订单状态”,而是把这个订单过去所有的事件拉出来,一个接一个apply到内存对象上,重建出最新状态。这也是事件溯源最消耗性能的地方,后面讲快照时会说优化方案。
第二步是全部业务规则的所在地。订单能不能创建、能不能取消,都在这一个方法里判断,不满足条件就直接抛异常或返回失败。事件溯源里,命令处理是“无状态”的:输入是聚合历史 + 命令,输出是新的事件列表,不修改任何数据库里的旧数据。
第三步的append是整个系统的关键原子点。事件存储必须保证:这些事件要么全部写入,要么一个都不写。写入成功后,这些事件就是唯一的业务事实来源。
第四步的发布是异步的,它和第三步之间天然有间隙。如果总线推送失败,事件表里的事件仍然有效,后台会有发布器扫描未发布的事件,补偿投递。这就是为什么事件表和消息总线之间最终一致而不是强一致,你得接受这个设定,否则后面会遇到性能上的自我折磨。
3.4 第四到六步:命令路由、总线与查询
第四步是命令路由。命令从外部进来,通常是HTTP或gRPC调用,也可能是MQ消息,不管走哪条路,最终要把命令送到对应聚合ID的处理方法上。如果系统里只有一个聚合根在管理订单,这条路由很简单;如果聚合数量多、类型多,你可以在路由层做一层“聚合类型+聚合ID”的映射,避免所有命令都挤到一个逻辑里。
第五步是事件总线发布。我建议发布采用“至少一次”投递语义,也就意味着下游会收到重复事件。这不是Bug,是特性。下游必须自己做幂等,具体做法后面第6章会展开。
第六步是读模型。别直接用事件表做业务查询。要查询“当前订单是什么状态”,要么维护一个当前状态表,要么建立专门的投影(Projection)表,让事件订阅者异步更新。这一点和第5章快照、读模型一起讲。
4. 事件存储落地:表结构、索引与并发控制
4.1 一张事件表能跑吗
最小可用的事件存储,一张表就够了。参考书里的设计,结合我的实践,核心字段大概是这个样子:
CREATE TABLE events ( event_id BIGINT PRIMARY KEY, aggregate_type VARCHAR(64) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, event_type VARCHAR(128) NOT NULL, event_data JSON NOT NULL, version INT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, published TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uq_aggregate_version (aggregate_type, aggregate_id, version), KEY idx_aggregate (aggregate_type, aggregate_id, version), KEY idx_published (published, created_at) );event_id是全局唯一,建议用雪花ID或数据库自增。aggregate_type和aggregate_id决定这条事件属于哪条事件流,一组(aggregate_type, aggregate_id)就是一个聚合的全部历史。event_type是业务语义类型,比如OrderCreated,它告诉系统该把event_data反序列化成哪个Java类或哪个结构体。version是这个聚合的第几个版本,从1开始递增,它是并发控制的核心。
published字段是我强烈建议加的。它解决了事务性发件箱的核心问题:事件已经写入事件表,但尚未成功投递到消息总线。后台发布器定时扫published=0的记录,投递成功后更新成1。这样即使MQ挂掉,事件一条都不会丢,最多延迟。
event_data用JSON不是必须,但我觉得对大多数团队最友好。如果对性能和体积有要求,可以换protobuf或Avro,但代价是反序列化对事件版本更敏感,需要花钱花精力做兼容。事件量不大时,JSON足够用。
4.2 并发控制不靠感觉,靠版本号
事件存储有一个绕不开的问题:两个请求同时往同一个聚合追加事件怎么办?比如两个客户端同时尝试取消同一个订单。
解法就是版本号乐观锁。append新事件时,版本号必须等于当前聚合最大版本号加1。假设聚合当前版本是10,两个并发请求都尝试追加版本11,唯一索引uq_aggregate_version会保证只有一条成功,另一条触发重复键异常。业务层捕获到异常,直接返回“操作冲突,请重试”即可。
这里有个容易被忽视的细节:验证“当前版本”和“写入版本”之间没有原子保护,靠的是数据库唯一索引。所以表结构里的唯一索引不是装饰品,它是并发控制的最后一道防线。如果哪天你为了省空间把它去掉了,事件流就会悄悄出现重复版本,重放时状态会完全错乱,而且非常难排查。
除此之外,业务唯一性约束也需要特殊处理。比如“一个手机号只能注册一个用户”,在传统数据库里加个唯一索引就完事。事件溯源里,事件流并不天然支持这种跨聚合全局约束。我的做法是额外建一张业务约束表,专门存放需要唯一性的键,事件追加成功后同步插入。或者更简单一点,在events表里增加一个可空字段,比如customer_phone_unique,加唯一索引,靠数据库兜底。这种方式需要额外开发,但它是事件溯源绕不开的代价。
5. 状态重建、快照与读模型
5.1 从事件流重建聚合状态
命令处理器要load聚合,本质就是把历史事件apply到内存对象。这部分逻辑要写在聚合根内部,让聚合自己负责解释“收到某类事件时,我该把内部状态切成什么样”。
public class Order { private OrderState state; private String customerId; private List<OrderLineItem> lineItems; public void apply(DomainEvent event) { if (event instanceof OrderCreated e) { this.state = OrderState.CREATED; this.customerId = e.getCustomerId(); this.lineItems = e.getLineItems(); } else if (event instanceof OrderApproved e) { this.state = OrderState.APPROVED; } else if (event instanceof OrderCancelled e) { this.state = OrderState.CANCELLED; } } }这里我建议只保留业务判断需要的最小状态,别把事件里所有字段都倒进内存对象。比如客户姓名、收货地址这种事件里可能带的数据,如果业务规则用不到,就不要存在聚合内存里。内存对象越精简,重放越少,性能越好,逻辑也越不容易被无关字段干扰。
5.2 快照:等不了重放一万条事件的妥协方案
事件溯源最显而易见的问题:一个订单如果有一万条事件,每次执行命令都要重放一万条,响应时间会很难看,越到后面越明显。
解法是做快照。快照就是把某个版本下聚合的内存状态序列化并持久化,恢复时直接从快照开始,只重放快照之后的事件:
CREATE TABLE snapshots ( aggregate_type VARCHAR(64) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, version INT NOT NULL, state_json JSON NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (aggregate_type, aggregate_id) );我一般在事件追加后检查当前版本,如果version % 100 == 0,就生成一次快照。100这个值是经验值,事件量大、轻量的事件可以调大到500甚至1000,事件体大、包含复杂列表的就调小到50。具体阈值要拿压测数据说话,我见过一个库存服务快照间隔500,一个订单服务间隔50,因为订单事件里挂着lineItems列表,重建成本高很多。
恢复逻辑是:先查快照,拿到state_json和version,再查该聚合事件表中version大于快照版本的事件,逐步apply。快照本身可以当作缓存,丢了也不怕,顶多多重放历史事件,所以不需要过度保护,但建议别删得太频繁。
5.3 查询别再走事件表了
事件溯源写路径很强,读路径却很弱。你总不能为了查“这个订单现在什么状态”,把几千条事件全load出来apply一遍。
最省事的折中方案是维护“当前状态表”:订单服务订阅自己的事件,比如OrderCreated、OrderApproved、OrderCancelled,每收到一个事件就update当前状态表的对应行。这个表和事件表共存,但它的角色是读模型的缓存,不是事实来源。
更复杂的查询,比如“近30天订单总额”“按用户维度统计消费”,可以订阅事件建立投影表。投影表的结构完全按查询需求设计,可以反规范化,可以冗余,怎么快怎么来。这部分其实就是轻量级CQRS。事件订阅者负责把事件同步到投影表,查询服务只读投影表,不再碰事件表。
需要接受的事实是:投影表是异步更新的,读到的数据大概率存在几十毫秒到几百毫秒的延迟。对后台报表、管理列表、运营看板来说没任何问题;对账户余额这类强一致业务,就不能只靠投影表,要在写路径上同步处理或者叠加实时查询。
6. 实践中的五个坑,以及我绕过去的方式
6.1 坑一:把“当前状态”混进事件里
这是我最早犯的错。刚开始写事件溯源时,我直接把聚合的整个当前状态序列化成事件数据,事件就叫OrderSnapshotCreated,每次状态变化就写一条新快照。这也能跑,但它退化成“带日志的CRUD”,事件溯源的审计、重放、问题定位价值全部丢失。
正确姿势是事件表达“发生了什么变化”,比如UserAddressChanged(oldAddress, newAddress),OrderApproved(approvedAt, approverId),而不是UserUpdated(stateJson)。事件字段应该落在业务语义上,不是落在数据库行上。
6.2 坑二:下游消费不幂等
事件总线是“至少一次”投递,重复事件是常态。下游收到PaymentReceived后如果直接发短信,用户就会收到两条短信,这在生产环境是要被投诉的。
建议消费者侧专门维护一张processed_event表,以event_id为主键。处理逻辑是:先尝试把event_id插入processed表,插入成功说明是第一次处理,继续执行业务;插入冲突说明已经处理过,直接跳过。这套幂等方案在事件溯源场景里几乎是标配,别嫌麻烦,省掉的话后面排查重复扣款时会怀疑人生。
6.3 坑三:事件版本演进没规划
我见过最让人头疼的事件溯源事故,就是线上已经开始跑业务,突然发现OrderCreated事件里缺了一个字段,比如taxAmount。老事件里根本没有这个字段,新逻辑重放老事件时就会因为缺字段报错。
对策有三种:
第一种是给事件类型加版本号,OrderCreatedV1、OrderCreatedV2,处理器优先处理V2,老V1事件通过一个适配器方法转换成新版,缺的字段给默认值。这是最稳的做法,我推荐优先选它。
第二种是事件适配器,读取老事件时在序列化层做转换,不修改原始事件字节。适用于老事件数量不多、字段变化不大的场景。
第三种是整体迁移事件流,把所有老事件读出来,转成新版本再写回。成本很高,一般只在必须统一格式时才做,千万别在项目初期就选这条路。
经验是:新事件字段尽量向后兼容,新增字段时只做“可选字段兼容”,不要修改已有字段的语义,否则事件流就是一条被反复掐断的胶带。
6.4 坑四:命令重复提交
用户手抖点了两次下单按钮,后台生成两个CreateOrderCommand,分别追加两条订单事件,这在事件溯源里比传统CRUD更容易发生,因为create命令本身没有天然幂等性。
我给的方案是命令层加commandId。聚合在内存里维护一个最近处理过的commandId集合(或者持久化到一个专门的幂等表),重复命令进来时直接忽略或返回“已接受”。尤其是创建型命令,必须在创建之前判断这个commandId是否处理过,否则聚合还处于不存在状态时,两次创建都能通过“当前无聚合”的校验,产生两条订单。
6.5 坑五:全项目强行事件溯源
不是每个业务都适合事件溯源。字典表、配置项、基础数据这类纯CRUD场景,引入事件溯源就是杀鸡用牛刀,团队痛苦,收益不明显。
我现在的判断标准是:需要审计追踪、需要回放历史、需要跨服务事件驱动的核心链路才用。订单、支付、账户、库存这类业务很适合;报表系统、简单的元数据管理,还是用传统CRUD加Outbox更实在。
如果真要在团队里推广,建议先画事件流图,把命令和事件全部列出来,如果事件数量少于10个,就别硬上事件溯源了,维护成本远大于收益。拿一个非核心但有业务价值的服务先试点,把事件语义、表结构、快照策略完整走一遍,团队有手感了再决定要不要规模铺开。
读完这一章后我最大的感受是,事件溯源不是让你多写几十行代码,而是换了一种思考方式:不再关心系统“现在是什么”,而是关心它“怎么一步步变成现在”。只要接受这个转变,后面写业务逻辑的顺畅感和排查问题的痛快感,是传统CRUD给不了的。