这个项目的代号就叫 Rea,我把它理解为 Resource-Event-Agent,也就是“资源-事件-参与者”三要素组成的业务建模方式。简单说,它不是帮你写某个页面的组件库,也不是一个现成的微服务框架,而是一套用来重新审视业务流程、设计数据模型和系统结构的思维工具。我在某公司的进销存系统重构里试着把它落地,效果比预期好得多——原本动一次需求就要改四五张表的局面,变成了先改事件定义、再补对应处理的清晰流程。
这篇文章不是讲理论,而是把我从REA模型理解到落地踩坑的完整过程拆开。适合正在做ERP、财务系统、订单中心、供应链这类业务系统,受够了“订单状态满天飞、对账靠人肉”的开发者,也适合想给自己的领域建模方法找个新视角的架构师。
1. 先聊聊为什么要用REA:从一次库存系统改不动的教训说起
1.1 传统结构为什么让我头疼
之前我维护过一个进销存系统,表结构长得很“标准”:一张库存台账表,记录商品在每一个仓库的当前数量;一张出入库单表,记录每次单据的明细;再加一张流水表,记录每一次数量变动。听起来没问题,可一旦业务问“上个月的账面库存和实际库存为什么差了两件”,我就得靠手工比对多项数据才能回答。更崩溃的是加需求,比如“需要记录破损报废”“需要支持供应商直发客户”,每次都要给表加字段、加状态、加判断逻辑,改完还要担心历史数据对不对得上。
传统做法的问题不是表设计者不努力,而是模型的底层逻辑是把“系统当前状态”作为核心。当前状态天生会变,而且一旦变化,历史事实就被覆盖或者只能靠补丁来维护。需求量小的时候还能忍,业务链条一长,这种结构就像堆满家具的屋子,挪一步就碰倒一个花瓶。
1.2 三要素和一个关键前提
REA模型的核心只有三个概念:
- 资源(Resource):企业拥有的、有经济价值的东西,比如现金、库存商品、应收账款、积分余额。资源是相对稳定的,它是系统要保护和追踪的对象。
- 事件(Event):导致资源增加或减少的业务行为,比如采购入库、销售出库、退款、转账。事件是真实世界“发生了什么”的记录,也是整个模型最核心的锚点。
- 参与者(Participant):参与事件的人、部门或系统,比如客户、仓库管理员、财务系统、供应商。参与者负责驱动事件,并因此对资源产生控制或责任。
这三者不是拿着名词硬凑,而是有一个关键前提:业务事实不可覆盖。你不需要在表里存“当前订单已经付款”这个状态,你需要存“在什么时间,哪个客户,为哪个订单,支付了多少钱”这个事件。当前状态应该可以从事件集合推导出来,而不是被直接保存成一个孤立的字段。
这个思路听起来像简单的“加日志表”,但实际差别很大。日志表通常只是记录表象,REA则逼你先回答“这个动作改变了哪些资源”,回答不出来,说明你还没真正理解业务。
2. 用REA重新建模订单系统:从两张表到一组稳定的关系
2.1 传统订单表为什么是“伪业务模型”
我拿最常见的订单系统举例。传统设计一般是这样:订单头表保存订单号、客户ID、订单状态、总金额、下单时间;订单明细表保存商品ID、数量、单价。开发初期很快,但问题在于“订单状态”承担了太多信息——它既要表示“客户是否付款”,又要表示“仓库是否发货”,还要表示“是否已对账”。表面上它只是一个枚举,实际上它是多个业务事实的压缩结果。
一旦状态压缩了事实,出问题时就需要猜。某张订单状态是“已发货”,但财务说钱没到账,那么到底是货先走了钱后付,还是根本就是另一个订单发错了货?状态字段根本没有能力记录这些细节。
2.2 REA视角下的完整订单拆解
换成REA,我不会先设计“订单表”,而是先画一遍业务交付链路:
- 客户提交订单,产生“下单事件”。
- 客户支付款项,产生“收款事件”,对应资源“现金”增加、资源“应收账款”减少。
- 仓库出库发货,产生“出库事件”,对应资源“库存商品”减少。
- 客户退货,产生“退货事件”,对应资源“库存商品”增加、资源“应付退款”增加。
事件之间是有父子或前后关联的,但这些关系的表达方式不是靠状态字段,而是通过引用事件ID来完成。比如“收款事件”可以引用它对应的“下单事件”,“出库事件”也可以引用“下单事件”。每次事件都是一条独立、不可覆盖的事实记录。
有人会问:那订单还存在吗?存在,但它不再是主角。订单变成了“下单事件”及其关联明细的一个视图。实际存储时,你可以把订单作为一个聚合根来方便查询,但所有变更的判定依据都来自事件,而不是来自订单上的状态字段。
2.3 事件表是整个模型真正的枢纽
在传统表结构里,订单表是枢纽,所有模块都指着它。在REA里,事件表才是枢纽。这样设计带来的最大好处是:时间线永远完整。系统里任何一个资源余额都是“初始值加所有事件变化量”计算出来的结果,而不是一个被反复改写的字段。
这个理念落到数据模型上时,我建议把事件分成“公共事件头”和“事件明细”两层。事件头记录:事件ID、事件类型、业务时间、参与者ID、关联的其他事件ID。事件明细记录:涉及哪些资源、资源增减数量、单位、金额。这样一套结构即可支撑多种事件类型,而不是每种单据建一张表。
3. 从模型到代码落地:表结构、领域服务与微服务划分
3.1 一组可以直接参考的表结构
我在落地时用的核心表大概五张:
- resource_master:资源主数据,包含商品、现金、积分、应收款等。
- participant_master:参与者主数据,包含客户、仓库、供应商、员工。
- biz_event:事件主表,字段包括event_id、event_type、biz_time、system_time、participant_id、source_event_id。
- biz_event_line:事件明细表,字段包括line_id、event_id、resource_id、quantity_change、amount_change。
- resource_balance:资源余额表,用于加速查询,不承担事实记录职责。
关键字段可以这样设计,我直接给出建表参考:
create table biz_event ( event_id bigint primary key, event_type varchar(32) not null, biz_time datetime not null, system_time datetime not null, participant_id bigint not null, source_event_id bigint, note varchar(255) ); create table biz_event_line ( line_id bigint primary key, event_id bigint not null, resource_id bigint not null, quantity_change decimal(18,3) not null, amount_change decimal(18,2) not null );业务时间这里特意用了biz_time和system_time两个字段。biz_time是业务实际发生时间,system_time是系统记录时间。做过财务对账都知道,这两个时间不一致是常态,缺了任何一个都会给自己挖坑。
3.2 代码层面怎么组织:事件处理是核心
代码落地时,我把处理流程收敛成一个模式:先校验事件的合法性,再保存事件及其明细,最后更新资源余额。看似简单,但每个环节都有讲究。
合法性校验只看事件本身和它引用的关联事件,不允许跨业务逻辑直接判断资源余额。比如“确认发货”,只校验有没有对应的下单事件和收款事件,而不去看库存余额够不够。库存校验放在“生成出库事件”的时候,两个关注点分开。伪代码大致是这样:
def process_outbound_event(order_event_id, warehouse_id, lines): with transaction(): validate_source_event(order_event_id) event_id = next_event_id() create_event(event_id, "OUTBOUND", now(), warehouse_id, order_event_id) for line in lines: create_event_line(event_id, line.resource_id, -line.qty, -line.amount) update_resource_balance(line.resource_id, -line.qty)我刻意控制了处理逻辑的复杂度:不回滚事件、不做复杂的跨服务状态同步。事实上,事件一旦写入,就视为历史,后续问题通过冲销事件修正。比如出库数量录错了,不直接改原事件,而是新增一条“盘亏调整事件”把差额冲掉。这样审计的时候,整条业务流都是可追溯的。
3.3 对微服务划分的启发
REA对服务划分还有一个现实指导:按资源域和事件链来切,而不是按“订单CRUD”“商品CRUD”这种界面视角来切。
我见过很多项目,服务边界按页面划分,订单服务、商品服务、用户服务,每个服务都是增删改查的包皮。用REA的视角去梳理,同一个业务的多个事件可能归属不同域:下单事件属于订单一域,收款事件属于资金域,出库事件属于库存域。这样做微服务拆分时,服务之间的通信不是“请把订单状态改成已发货”,而是产生并发布一个事件。下游服务订阅这个事件,生成自己的事件记录并更新自己的资源余额。各服务的数据自治性明显增强,跨服务对账也只需要按事件ID配对。
这套方案唯一的代价是短期开发量比直接改状态字段大,但长期维护成本显著下降。尤其是多团队并行开发时,服务间只需要约定事件模型,不需要共享数据库表。
4. 落地过程中遇到的坑与排查心得
4.1 最大的坑:把状态字段当成事件来用
我见过有人按照REA的思想建了事件表,但保留原订单状态字段作为主状态源,结果两边数据不一致,审计时还要搞清楚哪个是真的。状态字段不是不能用,而是只能作为“派生结果”,不能作为“事实输入”。
正确的做法是:当需要判断“这个订单能不能发货”时,去查它是否已经存在一条成功支付事件和一条已确认发货事件,而不是去读订单状态字段。如果状态字段仍然存在,就明确它是缓存,并且只允许根据事件计算结果回写。
这样排查问题就变成流水回放:我看到订单当前状态是“已发货”,但发现支付事件缺失,那问题就出在支付环节,而不是在订单状态字段上纠结。整个排查思路从“看一个结果猜原因”变成了“沿着事件链找断点”。
4.2 “资源”画着画着就变成“单据”了
第二个高频問題是,很多人在建模时把“订单”“发货单”“发票”当成资源。这不对。资源是经济资源,是价值的载体。订单不是资源,因为它没有价值;它是事件的记录纸。发票也不是资源,它是证据材料。正确建模时,涉及“库存商品”“现金”“应收账款”这些才是资源,单据只是事件的入口或出口。
判断标准很简单:问自己“这个对象会因为业务而增加或减少吗?”商品会减少,现金会增加,它们就是资源。订单只会改变状态,不会长大变小,所以它不是资源。这个认知不纠正,后面表结构里就会出现“订单余额”“发票余额”这种诡异字段。
4.3 幂等处理不好,事故就会加倍
事件模型最怕的一件事是重复写入。实际系统里,支付回调、消息队列重投、用户反复点提交按钮,都可能导致同一事件到来两次。如果事件表没有幂等保障,余额就会加两次。
我的做法是给biz_event加一个业务唯一键,通常由事件类型、业务时间、来源事件ID和参与者ID组合生成。写入时先尝试插入,遇到唯一约束冲突就认为事件已存在。消息队列的消费者必须把“处理消息”和“保存事件”放在同一个本地事务里,否则就会出现“事件已存但消息未确认,重试后重复处理”的情况。
4.4 常见问题速查表
我整理了一张速查表,团队内部排查时直接照着对。
| 症状 | 可能原因 | 排查思路 |
|---|---|---|
| 余额对不上 | 原事件被update或delete | 关闭事件表的update/delete权限,只允许insert |
| 某个订单状态正确但报表异常 | 状态字段被手工改了 | 比对状态字段与事件聚合结果,确认哪个是假的 |
| 重复入账 | 幂等唯一键缺失 | 检查事件表唯一约束,把业务唯一键加上 |
| 时间线乱 | 业务时间被写成系统时间 | 核对biz_time与system_time的差异 |
| 退货单无法关联原始销售 | source_event_id没落库 | 强制写事件时填充关联事件ID |
这里多说一句,权限控制相当重要。我在生产环境直接禁止对事件表的update和delete,改错数据只能通过新的调整事件来修正。严格审计大概审计的就是这个“改了就是改了,没改就是没改”的信用底座。
5. 什么时候别用这套模型:边界比技术本身更值钱
5.1 适合REA的典型场景
REA真正发力的场景有几个明显特征:业务有明确经济资源、事件链长、对审计要求高、需要支持复杂对账。典型的就是进销存、收银记账、供应链协同、积分会员体系、预算与报销系统。在这些系统里,资源增减是命脉,事件的时序和关联比单一“当前值”更有价值。
我负责的内部项目就是先拿“入库、出库、盘点、调拨”四类场景做了试点,三个月后财务人工对账时间缩短了一半,才逐步推广到销售、采购和退款相关模块。节奏上的建议是:从一个真实业务链路做起,别一上来就把所有模块重画一遍。
5.2 不适合REA的场景
如果业务场景是内容发布、CMS、IM聊天、简单数据管理后台,那这个模型就是过度设计。一个待办事项列表,今天需要的是“删除任务”,改成事件模型反而让简单操作复杂化。
判断标准是看你是否需要回答“历史上发生了什么、为什么余额是这个数”。不需要,就老老实实用状态字段和普通CRUD;需要,才值得引入事件建模的成本。
5.3 轻量变体:先只引入事件表也够用
如果暂时没有信心全量改造,也可以做一个中间态:保留现有业务表和状态字段,但把关键节点的变更用事件表单独记录。这不是最纯粹的REA,但可以快速止血。后续每遇到一次“状态字段不够用”的改造,就把对应的逻辑迁移到事件模型上。
这种渐进式落地最大的好处是风险低。你不需要推翻现有系统,只需要在团队认可的方向上持续加砖。等事件表覆盖了主要业务链后,自然会发现旧的状态字段变成了冗余缓存,再移除就水到渠成了。
6. 关于实践节奏的几句实在话
我在实际使用中最大的体会是,REA模型最难的不是理解三要素,而是克制住“把模型当数据库表”的冲动。它更像一套透镜,帮你看到业务中真正稳定不变的事物。顺序应该是:先画资源,再画事件,最后才想表结构。资源画错了后面全歪,事件画漏了审计会空。
我的第二个体会是,团队里不能只有你一个人懂这套思路。至少要有一个既懂业务又愿意较真的人一起画事件流,否则模型很容易被“老板要看订单金额”之类的短期需求带偏。
最后一个实战建议:每个事件表里都保留一个业务时间字段和系统时间字段,而且两层模型不要合并。踩过几次坑之后你会发现,几乎每一个“账面不平”的疑难问题,最后都需要靠这两个时间的匹配关系才能定位。做这一件事的成本极低,收益却会在项目维护到第二年时不期而至。