ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

REA模型实战:用事件驱动建模告别状态字段与数据不一致

REA模型实战:用事件驱动建模告别状态字段与数据不一致

我在某互联网公司做后台开发时,接手过一个内部代号叫"rea"的项目。起因很朴素:公司自研的电商后台,订单、库存、资金三个模块的数据总是对不齐。订单显示已发货,仓库说没出库;财务说收到钱,订单却查不到流水;运营导出一份销售报表,跟业务系统的数字差了一百多件。业务方每天在群里发截图,研发只能靠临时脚本去"修数据"。

排查到最后,问题根源不在某个接口,而在整个系统的建模方式——大家都在用"单据+状态"的方式描述业务,状态字段越加越多,最后谁也说不清一笔订单到底经历了什么。正当我准备继续打补丁时,一位前同事提到"为什么不试试REA模型"。我花两个晚上把Resources-Events-Agents这套概念啃了一遍,又用一个月在项目里做了最小化落地验证,效果比预期好不少。这篇文章就是把那段经历完整记录下来,包括建模思路、表结构设计、查询实施、以及只有写代码时才会踩到的坑。适合被状态字段折磨的后端开发、正在做业务数据建模的架构师,以及想理解"事件驱动建模"但不想啃论文的人。

1. 为什么传统的"单据+状态"建模会越改越烂

1.1 状态字段的本质是"结果的快照",不是"事实的记录"

绝大多数业务系统一开始都是这样建表的:订单表里有order_status,库存表里有stock_quantity,账户表里有balance。字段一多,问题就来了。

订单状态从"待支付"变成"已支付",系统只是把一个字符串改了。可这个状态对应的资金流向、支付渠道回调、库存预占、发货单生成,全部散落在各个模块的日志和流水表里。一旦中间某个环节失败或者被人工干预,状态字段就和真实业务脱节了。这时候你查数据库,看到的只是系统"认为"的状态,而不是实际发生过的业务轨迹。

我在项目里遇到的最典型问题是发货环节。订单系统把状态改为"已发货",但仓储系统没有生成出库记录。两个系统各写各的,状态不一致,然后就有了"补偿任务"——定时扫描状态和流水不匹配的订单,强行纠偏。纠偏脚本越写越复杂,逻辑越堆越绕,最终变成只有原作者敢碰的代码。

1.2 两张表解决不了的库存问题

库存模块更典型。传统设计通常是inventory表保存当前库存数,每次出库就在字段上做减法。看似简单,但一旦并发上来、退单进来、调拨发生,光靠一个数字根本说不清"这批货到底是哪次出库消耗的、哪次入库增加的"。

某次盘点,实际库存比系统少127件。查来查去,发现是订单取消后退货入仓,但库存字段只在"订单完成"路径上做过回加,取消路径漏掉了。这种问题的共性原因:你在记录状态,而不是记录事件。状态是单点的、可覆盖的,事件是流式的、不可丢弃的。当业务复杂度超过一定阈值,单点状态模型必然要往事件模型迁移。

REA模型最打动我的地方,就是它不记录"结果",只记录"谁,在什么时候,对什么资源,做了什么"。这正是一个业务系统本该有的样子。

2. REA的三个核心概念:资源、事件、参与者到底在说什么

REA是三个英文单词的首字母:Resources(资源)、Events(事件)、Agents(参与者)。它最早是会计信息系统领域提出来的一个本体模型,目的是让计算机能够理解"经济活动"的语义,而不是像复式记账那样只记借贷数字。我之前总觉得这是学院派的东西,等真的用它去建模一个订单流程时才发现,这套概念的划分精度刚刚好。

2.1 资源:从"商品"到"可追溯的经济价值"

资源就是一切有价值、可以在业务中流动的东西。商品是资源,现金是资源,仓储空间是资源,一次服务也可以算资源。它不一定是物理的,关键是"有价值"和"可流动"。

建模时要把握一个原则:资源只描述"它是什么",不描述"它当前还剩多少"。库存数量不是资源的一个属性,而是资源相关事件的统计结果。比如商品A的库存剩余,应当由"入库事件的总量 - 出库事件的总量"推导出来。

这个思路刚上手时会觉得绕,但它能天然避免一类bug:多个业务同时修改库存字段导致互相覆盖。你不修改数字,你只是新增一条事件记录,后续统计永远从事件流中计算。坏处是需要做一定的查询优化,后面我会详细说。

2.2 事件:记录发生了什么,而不是记录什么结果

事件是REA模型的核心,它的定义是"一次让资源发生数量或归属变化的活动"。卖出一件商品是事件,收到一笔打款是事件,仓库完成一次盘点也是事件。

事件有几个关键特征:

  • 不可变。已经发生的事件不应该被修改,只能被另一个事件纠正或对冲。
  • 有方向。事件必须表达资源是从谁那里流向谁。REA里常用"给予-接收"配对来定义方向:仓库给予商品,客户接收商品;客户给予现金,仓库接收现金。
  • 有时间。至少要有业务发生时间和数据记录时间两个维度的字段。
  • 有参与者。事件必须落到具体的人或组织上,责任边界清晰。

事件与事件的配对,天然对应了"交换"语义。一笔交易不是一条记录,而是至少两个事件:一个资源流出事件,一个资源流入事件。这种对称性在传统单据表设计里经常被简化掉。

2.3 参与者的边界:人、部门、系统账户

参与者是资源变化的执行者或受影响者。客户、供应商、仓库管理员、财务审核员,甚至自动对账的系统进程,都可以作为参与者建模。

设计时要注意:不要把所有参与者都设计成一个角色属性。参与者在不同事件中的身份可能不同,同一批货,在"采购入库"事件里,供应商是给予方,仓库是接收方;在"订单出库"事件里,仓库是给予方,客户是接收方。把它建模成事件的一个引用字段,比在资源上挂一个"当前负责人"直观得多。

3. 用一个下单场景演示REA落库:从业务故事到表结构

3.1 先讲故事,再画ER图

我习惯把业务场景先描述成一段"谁给谁什么东西"的句子,再把句子翻译成增删记录。比如一个简单的订单流程可以拆成这样:

  • 客户A订购了商品B:这是一个"订购事件",参与者是客户A和订单系统,涉及资源商品B。
  • 仓库将商品B出库交付给客户A:这是"出库事件",仓库是给予方,客户A是接收方。
  • 客户A支付了货款:这是"收款事件",客户A是给予方,公司账户是接收方,资源是资金。

每个事件至少对应一次资源数量变化,而且变化的方向可以通过"给予/接收"来区分。如果你把一个流程从头到尾都能写成这种句式,那表结构就顺理成章了。

3.2 表结构设计

我在最小化落地时用了四张表:参与者表、资源表、事件表、事件明细表。简单的Django项目或者Spring项目都能直接套用。

参与者表用于统一归集所有内部和外部参与者:

CREATE TABLE agents ( id BIGINT PRIMARY KEY, agent_type VARCHAR(32) NOT NULL COMMENT 'customer/supplier/internal_process', name VARCHAR(128) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

资源表只保存资源的基本属性和计量单位,不保存数量:

CREATE TABLE resources ( id BIGINT PRIMARY KEY, resource_type VARCHAR(32) NOT NULL COMMENT 'product/cash/storage_service', name VARCHAR(128) NOT NULL, unit VARCHAR(16) NOT NULL COMMENT '件/元/小时', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

事件表和事件明细表是核心:

CREATE TABLE events ( id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL COMMENT 'order_placed/order_shipped/payment_received', business_time DATETIME NOT NULL COMMENT '业务实际发生时间', record_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '系统记录时间', payload JSON NULL COMMENT '临时字段,存完整的事件上下文' ); CREATE TABLE event_line_items ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, direction VARCHAR(16) NOT NULL COMMENT 'give/receive/consume/produce', quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4) NULL, INDEX idx_event_resource (event_id, resource_id) );

event_line_items就是REA模型里"资源-事件-参与者"三元关系的具体落点。一个事件可以有多个明细,每个明细描述"某一类资源从某个参与者流到另一个参与者(或状态变化)"。例如出库事件,明细里direction='give'的agent是仓库,direction='receive'的agent是客户,quantity是商品数量。

这种设计把订单、订单明细、出库单、出库明细这些传统表全部统一成了events和event_line_items的组合。业务上复杂的"订单状态",在这里变成了"与订单相关的事件流"。

3.3 查询示例:库存数量怎么算

库存余额不再是一个表中的字段,而是查询结果。计算某商品当前可用库存,只需要统计该资源相关的所有入库与出库事件明细,做一次聚合:

SELECT SUM(CASE WHEN direction IN ('produce', 'receive') THEN quantity WHEN direction IN ('consume', 'give') THEN -quantity ELSE 0 END) AS current_stock FROM event_line_items WHERE resource_id = 1001;

这个查询在数据量大的时候性能不会太好,所以实际落地我会加一张物化快照表定期聚合,或者用增量缓存来兜底。但架构上有一个关键收益:库存数字永远可以追溯到它产生的每一笔事件,任何一次对不上的盘点,都能用SQL按事件逐条核对。

查询某笔订单的全部业务轨迹也变得更简单:

SELECT e.id, e.event_type, e.business_time, r.name AS resource_name, a.name AS agent_name, li.direction, li.quantity FROM events e JOIN event_line_items li ON e.id = li.event_id JOIN resources r ON li.resource_id = r.id JOIN agents a ON li.agent_id = a.id WHERE e.payload -> '$.order_no' = 'ORD20240218001' ORDER BY e.business_time;

这类查询把"一个订单经历了什么"完整地展现在一条结果集里,比在订单状态表里翻日志字段直观得多。

4. 落地时最容易踩的三个坑

4.1 坑一:过度抽象,把所有业务都塞进REA

刚理解REA时很容易兴奋,觉得它可以统一一切,于是想把客户CRM、审批流、问卷记录全部塞进events表。结果项目进度拖慢,查询动辄连接五张表,代码可读性断崖式下跌。

后来我的原则是:REA只用于"有明确资源流动且需要严格对账"的业务域,比如订单、库存、资金、采购。审批流、用户配置这类不涉及资源数量变化的场景,用普通业务表就够了。抽象不是越多越好,抽象是哪里有痛点才用哪里。

4.2 坑二:事件粒度切错,导致业务复盘失败

事件到底该拆多细,是落地中最难拿捏的。刚开始我把"订单创建、库存预占、支付回调、仓库拣货"全拆成独立事件。粒度太细,很多事件其实是同一个业务事务的中间步骤,拆开之后事务边界变得模糊,一旦中途失败,补偿逻辑和正常流程抢数据。

后来我采用的经验是:以"资源实际发生物理或资金层面的变化"作为事件拆分边界。库存预占不改变实际库存,就不单独建事件;真正把货挪出仓库,才建出库事件。支付回调代表资金实际到账,建收款事件。按这个标准切出来的事件,数量合适,业务关联清晰,补偿逻辑也能聚焦到真正需要补偿的点。

4.3 坑三:和财务总账对接时,差点被复式记账打脸

REA模型的优势是记录业务语义,但财务报表认的是借贷分录。系统切换到REA之后,财务那边依旧需要科目余额表、需要借贷双方平衡,否则无法出报表。

当时的教训是:REA不能直接替换财务会计模块。资产、负债、收入、费用的入账逻辑,仍然需要从REA事件流里做一次投影转换,生成标准的记账凭证。这个转换层可以手工实现,也可以做配置化映射,但必须有。否则你的系统数据很"真实",财务却无法使用。

我们最终的方案是:在事件表里增加一个"是否已生成凭证"的标记,用一个同步任务把业务事件翻译成借贷分录。翻译规则写在独立的映射表里,财务人员可以直接在后台调整,不碰代码。

5. 如果重新做一遍,我会改进这三件事

5.1 先画事件流图,再画ER图

第一次做REA建模时,我直接上手设计表,结果反复返工。后来换成先画事件流图,用方框表示事件、线条表示资源流向、角标标注参与者,把整个业务流程走一遍,才动手写建表语句。ER图表达的是静态关系,事件流图表达的是动态行为,REA建模的核心是行为,不是关系。这个顺序颠倒,后面会多花一倍的时间。

5.2 把事件设计成不可变日志,而不是可更新的业务记录

事件表一旦写入,最好不要允许update和delete。如果写错了,应该新建一个纠正事件,比如"库存调整:盘盈3件",而不是改掉原来的出库记录。

这个约束能带来一个很大的好处:所有审计和回溯都可以建立在对事件流的信任上。一旦允许任何人改事件,对账就失去意义。我会在数据库层面做权限控制,只允许insert和select,不允许update和delete,业务人员要纠正数据,走专门的界面生成反向事件。

5.3 重新看待内部项目代号"rea"

这个项目代号"rea"最初只是顺手取的,直到做完才意识到名字里恰好蕴含着Resources-Events-Agents这套关键思想。整个项目的代码量不大,但对数据一致性的改善非常明显。上线之后,订单-库存-资金对账脚本从每天十几次告警降到每周偶尔一两次,纠错人力节省了大半。更重要的是,新需求的建模方式变了:不再讨论"状态字段怎么加",而是讨论"新增了哪些业务事件、资源在哪个环节流动"。讨论的颗粒度变了,很多架构层面的问题就提前消失了。

如果读者想在自己的项目里尝试这套思路,建议不要一上来就重构所有模块。挑一个状态最多、对账最痛、数据量可控的模块,比如库存或资金流水,用REA重写一小块,跑通之后再推广。模型本身没有多复杂,复杂的是从旧思维模式切换到"记事件,不记状态"的过程。这个切换一旦完成,你的数据模型会变得非常耐脏、耐改,也最好维护。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进