本来想给项目起个正经名字,翻来翻去敲了三个字母:REA。懂行的人一眼就明白,这不是拼错单词,而是 Resource、Event、Agent 三个词的缩写。我最近在用这套建模方式梳理交易与账务相关的领域设计,越做越觉得它比传统的“实体加状态”建模方式更扛得住对账、审计和后续业务扩展。如果你也在为库存和订单对不上账、业务流水被状态字段覆盖后找不回完整历史、或者新需求一来就要改表结构而头疼,那这篇文章值得读完。后面所有内容都会用一套订单场景串起来,从概念拆解到字段设计再到踩坑经验,尽量一次讲透。
1. 为什么我会在业务系统里引入 REA 这套建模方式
1.1 传统的“实体+状态”建模到底差在哪
很多业务系统一开始建模时,都是照着后端管理界面的需求来设计表结构的:一张订单表、一张库存表、一张用户表,订单表里放一个状态字段,从待支付切成已支付,再从已支付切成已发货。开发初期这种方式上手极快,接口也好写,前端拿到状态数字直接映射文案就行。问题在于,状态切换这个动作本身是没有数据模型的,它产生的结果只是把旧值覆盖掉。
举个例子。某张订单金额原本是 100 元,运营手工改价成 90 元,状态还是待支付。如果表里只有一个 amount 字段,那改完以后原来的 100 元就彻底没了。想审计只能翻操作日志,日志如果没记全项,就只能靠猜。更麻烦的是库存:出库单扣掉数量,只改一个剩余量字段,没人记录是哪个订单、哪个操作员、在哪个时刻扣的。等到月底对账,财务说账面库存和仓库实际盘点的数字对不上,开发就得去翻各种流水表拼接时间线,这种体验经历过的人都懂。
状态字段不是不能用,而是它适合表达“当前快照”,不适合表达“变化过程”。可业务的本质恰恰是一连串的变化过程。订单状态、库存数量、账户余额,这些只是变化累计到某一时刻的投影。如果核心表一开始就把它当成唯一事实来源,后期补审计、补追溯、补双向核对,代价会非常高。
1.2 REA 的切入点:把“发生了什么”当作一等公民
REA 这个思路最早是会计和信息系统领域提出的,但放到今天的软件架构里看,本质上就是要求建模者先回答一个问题:系统里究竟有哪些资源,谁在什么时间通过什么业务动作让资源发生了什么变化。
这里面有三层意思。一层是资源。业务系统里凡是能被衡量、被交换、被消耗的东西,都是资源,比如库存商品、现金、优惠券、积分。第二层是代理,也就是参与业务的人或者组织,比如客户、供应商、仓库管理员。第三层是事件,事件描述的是代理之间发生的一次资源转移或转换,比如客户下单、仓库发货、财务收款。
传统设计会把资源和代理混在业务表里,比如订单表里既存客户 id,又存商品 id,还存金额。REA 会把资源账户、代理角色、经济事件分开建模。业务表的职责是描述单据头部信息,而资源和钱的变动细节,落在按 REA 规则登记的事件台账里。这件事做对之后,业务表和台账表可以互为校验,任何人改过金额、扣过库存,都会留下不可抵赖的事实记录。
打个比方:传统方式记账时只记账户余额,月底结账发现多了一百块,不知道从哪里来的;REA 方式就像先记日记账,再根据日记账汇总余额,每一笔来源都能翻出来。余额错误不可怕,可怕的是余额没有对应的明细支撑。
1.3 这套思路适合放在哪些领域
我在规划订单、库存、支付、对账、会员积分、租赁计费等模块时,都会先尝试往 REA 上靠一靠。它尤其适合那些存在双向权责、需要互相审计的业务。比如电商下单,客户拿到商品的同时,商家得到应收款;用户充值,平台多了一笔应履约的服务额度。任何一笔业务动作都同时影响两个代理人的资源账户,这种“一进一出、双向记账”的结构,天然适合对账和审计。
当然,并不是所有系统都要全面 REA。简单的内容展示、流程审批、报表查询类系统,用传统责任建模更省事。判断标准很简单:如果业务规则里频繁出现“甲方欠乙方”“谁给谁多了”“需要追溯历史某个瞬间的状态”,那就说明你已经踩在需要 REA 的地盘上了。
2. REA 三大核心概念拆解
2.1 Resource(资源):能被衡量和交换的业务对象
先说说资源在模型里到底指什么。很多开发第一次接触 REA 时会觉得,资源不就是商品和钱吗?其实不止。凡是能进入业务活动、并且能以某种单位计量的东西,都可以建模为资源。
- 实物资源:库存商品、原材料、赠品。
- 资金类资源:现金、账户余额、预付款、应收款。
- 权益类资源:优惠券、积分、会员时长。
- 服务资源:一次上门服务、一小时咨询时长。
资源需要有一个清晰的归属者,这直接决定了后续记账时“从哪个账户扣减、向哪个账户增加”。我见过不少令人头大的存量模型,就是因为商品表中没有归属概念,导致多仓共用一套库存数据,扣减时无法区分是哪个仓库的库存。过早丢失归属信息,后面想做库存维度拆分就非常痛苦。
另外,资源描述中必须保留“记账单位”。商品按件计、服务按时长计、资金按分计。单位不统一时,事件记录的价值度量和数量要拆开保存,避免后续聚合统计时把斤和千克混为一起。
2.2 Event(事件):改变资源存量的业务事实
事件是 REA 模型中最关键的角色。它记录的不是“现在有多少”,而是“某个时刻发生了什么”。比如“订单号 1001 在 15 点 30 分创建,占用库存 2 件”“退款单 5002 在 16 点操作,返还库存 2 件”。
事件必须包含四个基本信息:发生时间、事件类型、参与的代理人、涉及的资源和数量。这几项缺一不可。缺了时间,无法做时序分析;缺了类型,无法区分新增和抵减;缺了代理人,无法界定责任;缺了资源,整个事件失去经济含义。
这里要特别提醒一点:事件是不可变数据。已经登记的事件不允许修改和删除,如果录错了,要再补一笔冲正事件,把错误事件的资源影响反转回来。这样做不是给自己找麻烦,而是为了保留审计路径。真实世界里,做错一笔账最有效的方式不是把那笔记录涂掉,而是再做一笔红字冲销,过程透明且可追溯。在系统设计时我始终坚持这个原则。
2.3 Agent(代理):发起和承担业务后果的角色
代理定义的是“谁”。这个“谁”可以是自然人,也可以是内部部门、外部商户,甚至是一台自动化程序。建模时一般会区分类别和身份:
- 类别:客户、供应商、员工、平台。
- 身份:用户在缓存中的唯一标识、商户编码、仓库编码。
代理是资源的权利主体。没有代理归属的资源,在记账时不知道把增加项登记到谁身上,对账时无从归集。比如平台给用户发了一张优惠券,这里代理是用户;用户下单时核销掉优惠券,代理还是用户。如果把优惠券统一建模成平台资产而不建立到用户的关联,后续统计某用户贡献了多少核销量会非常麻烦。
我在设计代理时建议加一个 agent_type,它允许同一张表承载客户、供应商、渠道等多种角色,避免为每种角色都单独建一套账户体系。但要小心不要把所有业务角色都塞进代理表,代理只负责“权责归属”,业务单据上的操作员属于辅助信息,不应该参与记账校验。
2.4 双记账约束:为什么要同时记两条边
REA 模型借鉴了会计复式记账的底层逻辑:一笔业务事件必然涉及两个代理之间的资源转移,所以它同时影响两个资源账户。客户支付 100 元,平台收到现金类资源增加 100 元,同时收入类资源也增加 100 元;平台发货,库存类资源减少,同时应收款或者履约成本增加。
这种映射关系直接解决了对账的一致性问题。任何一笔事件,从参与双方的角度同时看,都应该得到镜像的账目。销售看得到收款,财务看得到收入,两边的数字天然相等。如果系统只记了“收款”,没有记“收入确认”,月底就会出来差异。
落地的时候不需要每一条都人工写两个字段,很多框架可以做成配置化事件模板:事件类型定义好了,系统自动创建一组账户变更项。代码里只需要把经济事件和账户变动项的关系维护好,达到“一笔事件、两处记账”的效果。
3. 用一个订单场景把 REA 落成代码
3.1 场景定义:下单、支付、发货、收货
讲概念容易,落成代码才是关键。我用一个最小化的订单场景来说明整套模型。假设系统有两个代理人:用户 U、商家 S;两种资源:商品库存、商家收入账户。业务流程包含四步:
- 用户下单,系统锁定并扣减商品库存。
- 用户支付,资金从用户账户转入商家收入账户。
- 商家发货,库存从待售资源转为已售资源。
- 用户确认收货,完成收入确认。
这里我先用一个领域事件的写法来表达业务动作,它不依赖具体框架,你看代码就能读懂数据流:
type AgentType = "customer" | "merchant"; interface Agent { id: string; agentType: AgentType; name: string; } interface ResourceType { code: string; // 例如 "ITEM_STOCK", "MERCHANT_INCOME" name: string; unit: string; // "件", "分" } interface EconomicEvent { id: string; eventType: string; // "ORDER_CREATED", "PAYMENT_RECEIVED", ... occurredAt: string; fromAgent: string; toAgent: string; resourceType: string; quantity: number; amount: number; // 以最小单位保存,例如“分” }这两层对象是核心骨架。有了 Agent、ResourceType、EconomicEvent,后续所有业务动作都可以归结为增加一条事件,并在资源账户表里登记借贷方向。
3.2 账户与账户变动项
资源账户表则负责维护资源的存量信息。我通常不会直接每次算全量事件,而是在事件登记后同步更新资源账户快照,同时保留明细事件表。这样查询余额时可以直接读快照,审计时回溯事件。
账户表可以设计成这样:
CREATE TABLE resource_account ( id VARCHAR(64) PRIMARY KEY, agent_id VARCHAR(64) NOT NULL, resource_code VARCHAR(32) NOT NULL, balance_qty BIGINT NOT NULL DEFAULT 0, balance_amt BIGINT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL );事件明细表记录每次变化的源和去向:
CREATE TABLE economic_event ( id VARCHAR(64) PRIMARY KEY, event_type VARCHAR(32) NOT NULL, occurred_at DATETIME NOT NULL, from_agent VARCHAR(64), -- 可以为空 to_agent VARCHAR(64), resource_code VARCHAR(32) NOT NULL, quantity BIGINT NOT NULL, amount BIGINT NOT NULL, ref_id VARCHAR(64) -- 对应业务单据号 );每次发生业务动作时,先插入 economic_event,再更新 resource_account 的 balance_qty 和 balance_amt。两个操作放在同一个数据库事务里,保证一致。
3.3 从订单到事件:一次完整的数据流
还是用用户下单这个动作举例。传统写法是:
UPDATE item_stock SET stock_qty = stock_qty - 2 WHERE item_id = 'P001';REA 写法会先把动作建模成一条事件:
INSERT INTO economic_event (id, event_type, occurred_at, from_agent, to_agent, resource_code, quantity, amount) VALUES ('E20240101001', 'ORDER_CREATED', '2024-01-01 10:00:00', 'U1001', 'S2001', 'ITEM_STOCK', -2, 0); UPDATE resource_account SET balance_qty = balance_qty - 2 WHERE agent_id = 'S2001' AND resource_code = 'ITEM_STOCK';关键点在 event_type 和 quantity 正负号的含义。COST 类事件 quantity 为负,表示库存消耗;RECEIVE 类事件 quantity 为正,表示资源进入。我建议符号规则统一由事件模板驱动,不要让前端直接传正负号,否则同一个接口传参不同,方向就容易出错。
3.4 余额查询与对账的实现方式
当数仓需要输出“某商家当前的库存数量”时,传统系统直接读库存表即可。REA 下我既提供了快照账户表,也能支持从事件表回溯。快照表适合在线查询,事件表适合审计和补数。
实际做法是:账户表保留当前值;如果数据异常或事后核对,用事件明细重建任意时刻的余额。重建的 SQL 大致是:
SELECT to_agent, SUM(CASE WHEN event_type = 'RECEIVE' THEN quantity ELSE 0 END) AS total_in, SUM(CASE WHEN event_type = 'COST' THEN quantity ELSE 0 END) AS total_out FROM economic_event WHERE occurred_at <= '2024-05-01 00:00:00' GROUP BY to_agent;这样就能看出余额与明细之间稳态是否一致。余额表给出的是投影结果,明细表给出的是过程事实。两者互为校验,这是 REA 在工程上最有价值的地方。
4. 落地过程中的四个关键决策与避坑方向
4.1 事件与状态快照要不要同时保留
只存事件不存快照,查询性能会很差。只存快照不存事件,又倒退回传统覆盖式建模。我的折中方案是:核心账户同时维护当前快照,并保留完整事件明细。读路径优先走快照,写路径必须走事件。如果团队资源紧张,也可以把事件明细做异步归档,但写数据时务必保证事件先落库、快照后更新。业务前台只依赖快照,内部对账依赖明细。
4.2 重复事件与幂等设计
在分布式环境下,同一个业务动作可能因为重试被提交两次。比如支付网关回调重复通知,系统如果连续插入两条收款事件,账户余额就虚增了。处理办法是在事件表上建立唯一业务键,也就是 ref_id 和 event_type 的唯一索引。插入前校验是否存在,存在则直接返回成功,但不重复更新账户。
我建议事件表设计里额外加一个 trace_id,与业务单据号绑定。这样既能做幂等,又能快速定位“同一笔业务对应哪些事件”。我在实践中踩过的是:只对事件主键做幂等,没对业务主键做幂等,导致同一笔订单生成两条订单事件。后来统一改成业务主键唯一索引,问题才根除。
4.3 事务边界与最终一致性怎么选
如果订单、库存、支付都处于同一个服务、同一个数据库内,直接用一个本地事务解决,插入事件和更新账户要同生共死。如果订单和支付分布在两个服务中,远端操作无法纳入同一本地事务,需要按最终一致性设计。
此时我的做法是尽量把“库存扣减”和“订单状态翻转”放在同一个聚合内,把支付收款做成异步事件。聚合边界主要围绕资源和代理来切,而不是围绕单据结构来切。如果硬要把跨服务操作塞进一个事务,性能会下降,出错时的回滚范围也难以控制。
4.4 老系统迁移到 REA 的可行路径
老系统已经跑了好几年,不可能推倒重来。比较现实的做法是保留现有业务表,新增一个事件日志表,在关键写操作处额外登记一条 REA 事件。初期先不做实时账户重构,先积累一个月左右的事实数据,再通过分析重建所需账户。这个阶段的核心目标不是立竿见影地改造核心逻辑,而是先拿到历史事实,为后续迁移提供数据依据。
迁移时我会选择增量试点:把一个资源维度比较独立的模块切出来,比如优惠券发放与核销,用 REA 重建;验证稳定后,再移库存账、资金账。尽量不要一次性把老订单库全部重构,业务规则复杂时,改造周期会拉得很长,中途需求一变,半成品系统最容易出事故。
4.5 团队协作与职责划分
REA 不是纯后端的技术方案,它需要产品、财务、研发一起定义事件语义。我不建议让开发独自拍板“什么是事件、什么是资源”,否则后面容易和财务口径打架。最有效的做法是出一份事件字典,列清楚每个事件的参与者、资源、正负方向,由财务和业务共同评审。这份字典既是建表依据,也是后续对账的规则说明书。
在编码分工上,领域层负责事件模型的校验,应用层负责组装用例,基础设施层负责持久化与事件发布。只要把事件字典定清楚,每个人写出来的业务代码都会指向同一套事实,而不是各维护一套状态。
5. 常见问题与排查技巧实录
5.1 账户余额与明细对不上
这是上 REA 之后最容易出现的问题。症状是账户余额和事件明细加总出的数字不一致。排查思路按顺序走:先看有没有重复事件,比如同一业务键插入了两次;再看有没有事件插入了但账户没更新,或账户更新了但事件没插入;最后看是否被并发写覆盖。我一般会写一个定时任务,每小时对全量账户做一次“明细加总对快照”的校验,发现问题立刻报警。这个对账任务虽然简单,却让我省下了大量排查时间。
5.2 事件顺序错乱导致余额异常
事件表是按插入时间排序的,但业务上有些依赖先后的动作,比如先退款再撤销退款,如果并发提交,数据库可能先生成撤销记录,再生成退款记录。这样余额会出现负数。
解决方案有两个:一是给同一聚合内的事件加上序号,比如用业务单号加序列号作为排序键;二是在应用层给同一资源账户增加乐观锁版本,更新账户快照时带上上一次事件 ID,防止旧事件后写入。明确规则的原则是:同一条业务链路的事件,以业务链路时间线为准,不以数据库插入时间为准。
5.3 事件只覆盖了资源变更,没有覆盖代理变更
业务中经常出现“资源从一个代理转移到另一个代理”,比如套餐从企业主转移到员工。很多设计会只记资源的增减,不记代理关系变化。结果员工用套餐时发现资源不在自己名下,企业主名下却被扣减两次。
设计时,资源转移类事件必须同时包含 from_agent 和 to_agent,并且账户表不能只关注单一代理,还要支持按代理分组查询。凡是看到“改归属”类的需求,都要当成一次资源转移事件来建模,而不是直接 update 资源的 owner 字段。
5.4 领域事件、系统事件和 REA 事件混用
团队习惯用事件溯源时会自然创建很多内部消息事件,比如订单已创建、邮件已发送,这些并不都是 REA 事件。REA 事件的判定标准只有一个:是否引起某个代理持有的资源账户发生数量或金额变化。没有资源变化的系统事件,不应该进入经济事件表,否则对账时会把非业务动作的错误带进来。
我在团队里定了一个简单的检查清单:事件有 from_agent 吗?有 to_agent 吗?有 resource_code 和数量吗?三个都有才允许写入经济事件表。少一个的,去业务日志表找,别混在一个表里。
5.5 查询性能变差怎么办
事件明细表数据量增长很快,尤其是按小时记录资源变动。在线查询余额可以依赖 resource_account 快照表,但审计类报表需要扫描大量事件记录,性能容易吃紧。我一般会把经济事件表按时间做分区,同时把高频查询字段建联合索引,例如 occured_at、agent_id、resource_code 组合索引。对于更大的场景,可以基于事件明细做周期聚合,生成按日、按月汇总的事实表,而不是让审计页面每次实时全量扫描。
6. 一些我自己沉淀出来的使用边界和后续扩展思路
REA 不是一个银弹,它解决的是业务事实记录和对账置信度的问题,而不是存储引擎或者查询性能的问题。引入时要注意资源账户表不是万能的,对不同资源类型要设置不同精度;金额统一用最小货币单位,避免浮点误差混进来。
我实际用下来最舒服的场景是系统同时有正向业务和逆向业务,也就是既有订单又有退款,既有发货又有退货。因为 REA 本身对每一笔事件都保持原始事实,正向和逆向可以在事件表里自然对齐,而不是靠程序判断正负数。只要你坚持“事件不可变、资源账户可追溯”这两个原则,系统里几乎所有历史问题都能从明细表中重新推导出来。
后续再往深走,可以把未来发生的预排事件建模为“计划事件”,比如预约服务、预订单,它们还未实际影响资源账户,但模型结构可以预先挂接。等到真正履约时,再分解为实际事件冲抵计划。这套延伸做出来后,业务的前瞻性排期和实际资源占用就能在同一套模型里统一管理,是我觉得 REA 最有想象力的扩展方向之一。