1. 为什么我会把一整套业务模型改成 rea(Resource-Event-Agent)
如果你做过一段时间中后台业务开发,应该对这种事不陌生:订单表里一个 status 字段从 0 改到 1 再到 2,状态机画得清清楚楚,可一旦有人问“这单为什么突然从待支付跳到已完成”,你得翻代码、翻日志、翻操作记录,最后还不一定对得上账。
我最近在内部项目里做了一次重构,项目代号就叫 rea。这个名字不是拍脑袋起的,而是 Resource-Event-Agent 三个词的缩写。简单说,rea 把一切资源变化都建模成“某个代理在某个事件中作用于某个资源”,最终形成一条可回放、可审计、可对齐业务边界的事件流。适合看这篇内容的人,是正在被状态字段、审计日志和脏数据来回折磨的后端同学,以及想了解轻量级事件溯源建模的架构学习者。
1.1 状态表为什么总是不够用
传统设计里,我们最喜欢做的是一张“大而全”的业务主表。订单域就是 orders 表,字段无非是 status、price、operator_id、updated_at。业务跑起来之后,需求会变成这样:
- 运营问:这个订单的价格是谁改的?改之前是多少?
- 财务问:这个月有多少单走了人工改价?操作人是谁?有没有越权?
- 客服问:用户说退款没到账,这个订单的支付、退款的真实先后顺序是什么?
如果是传统主表加审计日志,每一类问题几乎都要 join 两三张表,而且结果还未必准确。原因在于状态字段只保留当前值,历史值被 update 覆盖;操作记录表保留的是“最终变化”,不是“为什么产生变化”。更麻烦的是,业务里总有绕过状态机的特殊操作,比如运营直接改库、脚本批量修正、定时任务自动补偿。这些动作在主表上表现为一次字段跳变,但审计表里可能什么都没有。
我自己遇到过最典型的情况是:线上订单状态被人为从“已发货”改回“待支付”,状态机完全没拦住,审计日志也没有记录。后来排查发现,是某个数据订正脚本用 update 语句直接覆盖了主表字段。从那一刻我就意识到,光靠“最新状态 + 操作日志”这套组合,根本追不出完整业务事实。
1.2 三元组:把“谁、动了什么、发生了什么”拆开
rea 的核心就三样东西:Resource(资源)、Event(事件)、Agent(代理)。
- Resource 是被管理和追踪的对象,比如订单、库存、账户余额、优惠券;
- Event 是资源发生变化的一次原子业务事实,比如“支付订单”“修改价格”“冻结库存”;
- Agent 是触发事件的主体,可以是用户、管理员、定时任务、外部回调,也可以是系统本身。
我自己的理解是:一次业务操作等于一个 Event,Event 记录相关资源的前后状态和触发上下文,Agent 则回答“谁干的”。举个例子,“用户 A 支付了订单 O 的 100 元”,在 rea 里不是一个订单状态更新,而是一个 Event:agent = 用户 A,resource = [订单 O,账户余额],payload = {金额: 100,支付渠道: 微信支付}。
这个表达方式最关键的地方是:一个 Event 可以同时影响多个 Resource。支付事件既改了订单状态,也改了账户余额,还生成了流水号。如果事件只是挂在订单下,账户那边就丢了过程;如果只是挂在账户下,订单这边又对不上。把事件独立出来,让两边都引用它,才能对上账。
| 要素 | 回答的问题 | 典型例子 |
|---|---|---|
| Resource | 什么东西被影响 | 订单、库存、余额 |
| Event | 发生了什么业务事实 | 支付、改价、退款 |
| Agent | 谁触发的 | 用户、管理员、定时任务 |
1.3 Agent 为什么必须单独建模
很多人做事件日志时,习惯把操作人 ID 直接塞进事件 payload 里,觉得没必要单独抽象 Agent。但实践中很容易遇到这些情况:一个操作不是用户触发的,而是定时任务扫描触发的;一个操作是第三方回调触发的,比如支付网关的回调;一个操作是内部服务间调用触发的,比如订单服务调了库存服务。
如果 Agent 只是事件里的一个普通字段,那查“这个月所有系统自动发起的改价操作”就只能靠扫 payload,效率低不说,不同团队约定的字段名还不一样。把 Agent 单独建模之后,查询口径就统一了:无论谁触发的,都有 agent_type 和 agent_id,可以回答“什么类型的主体,在什么时间,动了哪些资源”。
我实际用下来觉得,Agent 这一层不需要做得很重。它不等于用户中心,也不需要引出一整套权限模型。在大多数系统里,一张几百行的表就够了,甚至可以在事件表里冗余 agent_name 方便查询。
2. rea 落地过程中最容易被绕进去的四个边界决策
概念理解起来很快,真正落地时坑都在边界上。下面这几个决策我几乎每个都返工过,写出来给后来人省点时间。
2.1 事件和资源,到底谁应该是主实体
第一次设计 rea 时,我按直觉把事件设计成资源的子表:resource_id 作为外键,每个资源下面挂一串事件。单资源场景下很好用,订单详情页直接按订单 ID 查事件列表,一条 SQL 搞定。
但遇到转账、拆单、合并支付这类跨资源操作时,这个设计就顶不住了。一笔转账涉及转出账户、转入账户、订单流水三个资源,如果事件挂在其中一个资源下,其他两个资源就看不到完整链路;如果复制三份事件,又会导致对账时事件重复计数。最后我只能改成事件独立成表,用 resource_refs 这个 JSON 数组字段关联多个资源。资源只是事件在时间轴上的投影,事件本身才是业务事实的主干。
这个调整的代价是查询变复杂了,原来按资源 ID 直接查子表,现在要先扫 resource_refs。解决办法是给 event 表增加一个 resource_id 生成列,或者维护一张 event_resource 关联表。对于绝大多数业务量来说,维护一张轻量关联表反而更清晰。
2.2 快照和事件,不能只选一个
纯事件流非常理想化:所有状态都从事件重放出来,没有任何冗余。但现实是,一次查询如果要从上万条事件里重算出某个订单的当前状态,体验会非常差。反过来,如果只保留最新快照,又回到了传统状态表的路子,事件溯源就没有意义。
rea 采用的思路是“快照为主读,事件为主写”的双轨制。resource 表永远保存最新快照和版本号,负责高频查询;event 表保存不可变事实,负责追溯、对账、重建。两者之间的关系不是备份,而是投影:事件是源,快照是投影。
这里有个非常重要的顺序约束。业务命令处理时,必须先写 Event,成功之后再更新 resource 快照,不能反过来。如果先更新快照再写事件,一旦事件写入失败,资源状态就领先于事件流,重放时会算出另一个结果。反过来,事件写成功了快照更新失败,至少可以通过重放事件把快照找回来。把“事件优先”当作铁律之后,数据一致性压力一下子小了很多。
2.3 重复事件:消息队列语义下的幂等难题
rea 的事件如果只由业务代码同步写入数据库,重复问题不严重。可一旦事件通过消息队列异步落地,或者第三方回调可能重试,就不得不处理 at-least-once 投递带来的重复。
我最早犯的错是用业务字段做幂等键:比如认为同一资源同一秒内不可能有两条相同类型事件,于是把 (resource_id, event_type, occurred_at) 设成唯一约束。结果线上很快就炸了,因为刚好有并发请求在同一个毫秒改同一个字段。后来我换成在业务源头生成稳定的事件 ID,规则是{resource_type}-{business_no}-{seq}。例如order-20240115001-001。这个 ID 在业务第一次产生时就确定,不会因为重试而变化。
event 表直接对 event_id 建唯一索引,写入时如果发生冲突,就当成幂等成功返回。这一套下来,既不需要分布式锁,也不需要复杂的去重逻辑,只要保证源头生成 ID 的逻辑不重不漏就行。
2.4 双向记账到底拆不拆事件
做账户域时我纠结了很久:转账事件是拆成“A 扣款”和“B 入账”两个事件,还是合成一个大事件?第一次我拆成了两个,因为脑子里全是“每个资源变化对应一个事件”的刻板印象。结果每次对账都要做一次 sum,中途如果有一个事件写入失败,两边永远差一分钱,还得写补偿逻辑。
后来我把模型改成:一次转账是一个 Event,Event 内部可以带多个资源变化条目。也就是说,事件是最小的原子业务事实,但它可以有多个资源影响。拆事件解决的是语义原子性,不是数据表物理结构。这项修改最直接的收益是:对账时只要按事件维度汇总,天然就是平的。如果不做拆分,Event 内部就需要引入类似借贷分录的概念。这不是复杂化,反而是简化。
3. rea 核心表结构和重放机制:可以直接抄的底子
我会把经过调整后的表结构完整列出来,你可以直接参考,不使用额外组件,一个普通关系数据库就能跑。
3.1 resource 表:读模型快照
CREATE TABLE resource ( resource_id VARCHAR(64) NOT NULL, resource_type VARCHAR(32) NOT NULL, version BIGINT NOT NULL, snapshot JSONB NOT NULL, last_event_id VARCHAR(128) NOT NULL, updated_at TIMESTAMPTZ NOT NULL, PRIMARY KEY (resource_id, resource_type) );version 的作用有两个:一是乐观锁,所有更新语句都带上WHERE version = 预期值,避免并发覆盖;二是重放起点,当我们要从事件流恢复某个资源时,只需要从 last_event_id 之后继续应用事件,不需要从头扫全部历史。
snapshot 字段存的是资源最新业务状态,比如订单的完整结构体。注意这里存的是业务状态,不是数据库行记录。订单被支付后,snapshot 里订单状态是 paid,但历史事件仍然保留着创建、改价、支付的全部过程。
3.2 event 表:写模型事实
CREATE TABLE event ( event_id VARCHAR(128) NOT NULL, event_type VARCHAR(64) NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, agent_id VARCHAR(64) NOT NULL, agent_type VARCHAR(32) NOT NULL, resource_refs JSONB NOT NULL, payload JSONB NOT NULL, prev_event_id VARCHAR(128), PRIMARY KEY (event_id) ); CREATE INDEX idx_event_resources ON event USING GIN (resource_refs); CREATE INDEX idx_event_type_time ON event (event_type, occurred_at);resource_refs 是一个 JSON 数组,内容是本次事件涉及的资源 ID 列表,例如["order_1001", "account_2002"]。GIN 索引可以支持“查某个资源相关的所有事件”的逆向查询。payload 里我建议只存业务变更的必要信息,不要一上来就把整个资源对象塞进去。比如改价事件,payload 可以包含 before_price、after_price、reason、source_bill_no,但不建议包含订单里所有商品明细。
prev_event_id 指向同一资源链路上的上一个事件,主要用来做严格顺序校验。如果业务对事件顺序要求不高,可以去掉。保留的话,写入的时候要额外查一次,会增加一点开销。
3.3 agent 表:不需要做成用户中心
CREATE TABLE agent ( agent_id VARCHAR(64) PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, agent_name VARCHAR(128) );agent 表我最终只保留了三个字段。agent_type 区分 USER、SYSTEM、CRON、EXTERNAL_CALLBACK 等,agent_name 是冗余展示字段。如果团队有自己的用户服务,也可以在查询时实时关联,但那样 event 表里就要冗余存一份 agent_name,方便历史追溯不被用户删除影响。
3.4 更新与重放的完整流程
事件写入采用“事件优先”策略。一个业务命令的处理逻辑如下:
- 从 resource 表读取当前快照和 version;
- 校验业务前置条件,不满足则拒绝;
- 在业务源头生成事件 ID,组装 Event 对象;
- 向 event 表插入 Event;
- 根据 Event payload 计算出新的资源快照,更新 resource 表,同时
SET version = version + 1, last_event_id = 新事件ID,更新条件带上旧 version; - 如果第 5 步冲突,说明有并发更新,回滚事件并重试整个流程。
重放逻辑更简单:从某个资源的快照出发,找到 last_event_id,然后按时间顺序取出该资源之后的事件,逐个应用即可。伪代码如下:
def replay(resource, events): current = resource.snapshot for ev in sorted(events, key=lambda e: (e.occurred_at, e.event_id)): current = transition(current, ev.payload) return currenttransition 是业务状态转移函数。它必须是无副作用的纯函数,同样的快照加同样的事件序列,必须算出同样的结果。这一点是验证 rea 数据正确性的根基,可以把它写进单元测试。
4. 用 rea 重做订单域:从“字段加审计日志”到“事件流”的迁移实测
我找了一个典型的老订单系统做试点。原来结构不复杂:orders 主表保存订单最新状态,order_logs 表保存操作痕迹。问题集中在三点:操作痕迹字段自由填写,格式不统一;状态跳转不记录原因;主表被脚本 update 后,审计日志完全失效。
迁移过程我分了三步走,每一步都有明确验证口径。
4.1 第一步:反解历史数据,生成初始快照事件
老系统没有完整事件流,所以不能直接谈“回放历史”。我的做法是写一次性脚本,把每个订单当前状态打包成一个order_initialized事件,resource 表里的 snapshot 直接取当前值,version 初始化为 1,last_event_id 指向这个初始化事件。
这一步看起来像是作弊,因为历史过程依然丢失了,但至少把存量数据接入了 rea 体系。从接入那天起,所有新增变更都以事件方式记录。如果老系统里有些字段能反推部分历史,比如 update_time 和 operator_id,我也会额外生成一些补偿事件,但不会无中生有编造业务过程。
4.2 第二步:双写与对账
迁移期间同时写老表和新表。老表继续服务现有接口,新表由一段旁路逻辑同步写入 event 和 resource。对账任务每五分钟跑一次,用订单号关联老表 status 与新表 snapshot 里的 status,不一致就告警。
这一步最大的坑在于并发更新。老系统的 update 语句没有乐观锁,导致旁路读到的是一个中间状态,写出来的事件顺序会乱。后来我在同步逻辑里加上了“基于更新时间的先后排序”和“同订单串行处理”,才稳住乱序问题。
4.3 第三步:切读与效果
切读之后,几个典型问题都变得非常直接。
想看某个订单的完整变更链路,一条 SQL 按 resource_refs 过滤即可:从创建、支付、改价、发货到退款,每一次事件都有 agent 和 payload。财务要当月的所有改价信息,直接按event_type = 'price_changed'和occurred_at过滤,不需要再 join 三张表。
在故障恢复场景下,测试过更极端的情况:老系统数据库误操作删掉了一批资源行。传统方案几乎不可恢复,只能靠备份加手工补数;rea 方案下,resource 表可以重建,即使损失了部分快照,只要 event 表还在,就能根据 last_event_id 往前找到事件并重放,把资源恢复出来。
代价也客观存在:订单写入路径从一次 update 变成一次 insert event 加一次 update resource,写入延迟肯定有增加。如果订单量一天超过几十万,event 表会膨胀得很快。我的经验是保留一个按月的归档计划,把超过半年的冷事件搬到归档表,同时留一个统一的查询视图掩盖物理分表。
| 能力 | 传统字段+审计日志 | rea 方案 |
|---|---|---|
| 查询当前状态 | 快,直接查主表 | 快,查快照 |
| 追溯完整变更链路 | 难,需要多表 join 且可能缺失 | 易,按事件流查询 |
| 恢复误删数据 | 依赖备份,精确到秒很困难 | 可用事件重放追赶 |
| 写入代价 | 一次 update | 一次事件 insert 加一次快照 update |
| 对账能力 | 弱,操作记录与主表可能不一致 | 强,事件即事实,快照可校验 |
5. 如果让我再搭一次 rea,我会保留的和果断砍掉的
这套方案我在内部跑了近半年,踩过坑也捞到过好处,重新梳理一下,哪些设计是真正值得保留的,哪些是过度设计。
5.1 保留:不可变事件、快照版本号、定期校验任务
不可变事件是整套体系的基石。事件一旦写入,不允许 update 和 delete,即使业务发错了,也只能追加一个reverse_xxx的冲正事件。这个约束要写进代码规范,也要靠数据库权限限制账号,不能给业务账号更新 event 表的权限。
快照版本号对并发控制太重要了。早期我没有版本号,直接用快照内容匹配,结果总被并发问题坑。加上 version 乐观锁之后,冲突直接报错重试,简单可靠。
定期校验任务是我强烈建议加的。双轨制最大的隐患是快照和事件流不一致,比如代码 bug 导致快照更新了但事件没写全。我每晚会跑一次校验任务,随机抽取一部分资源,从 last_event_id 往前重放事件,对比快照 version 和内容。早期这个任务帮我抓到了三个隐藏 bug,后来就一直保留着。
5.2 保留:从业务源头生成的稳定幂等键
这个设计看起来不起眼,但在接入消息队列和第三方回调时救过命。稳定的 event_id 既保证了重复投递不产生脏数据,又让跨库迁移时不需要重新映射自增 ID。如果你现在才开始设计,建议不要依赖数据库自增作为事件主键。
5.3 砍掉:全域事件总线、完美事件模型、把所有表都套 rea
最初我设想过做一个“全域事件中台”,公司里所有业务都把事件汇进来。后来发现跨团队的事件语义很难统一,每个系统对“订单已支付”的理解都不一样,强行统一只会把时间耗在处理歧义上。rea 最终只在订单域自己的服务边界内使用,跨系统同步仍然走普通消息队列,事件流只服务自己域内的审计、追溯、对账。
也不要试图把所有表都改造成 rea。纯字典表、外部数据快照表、指标缓存表,根本没有“谁在哪个事件中动过它”的业务含义,强行套用只会增加写入开销和建表成本。
5.4 配套工具:普通数据库就够了
最后一句经验:rea 不一定要引入专业事件存储,普通数据库加 JSONB 完全够用。业务规模到几十万事件一天之前,单表加索引就能扛住;规模再大,再考虑按月分表和冷热归档。不要为了“事件溯源”这个概念本身买一堆基础设施,先在核心业务域跑通,比什么都重要。
我现在养成了一个习惯:看到一张新业务表,先问一句——如果这张表只保留最新状态,三个月后我还能准确回答“这个数据为什么变成这样”吗?如果答不上来,那这条变化就值得用一条事件记下来。这正是 rea 给我的最大改变。