看到“rea”这三个字母,很多人的第一反应是某个前端框架拼错了,或者是 real、react 打了一半。但在我这几年设计业务系统的经历里,rea 最值得关注的是 REA——Resource、Event、Agent,也就是资源、事件、代理模型。我第一次用它重构一套订单、库存、财务长期对不上账的系统时,才真正理解了什么叫“以事件为核心”的建模思路。这篇文章我会从模型的基本理念讲到具体的表结构、接口流程和排查方法,尽量用能直接落地的方式呈现。如果你正在做电商、供应链、企业内部结算这类系统,或者被状态字段搞得头皮发麻,这篇文章应该能给你一个完全不同的解题视角。
1. 为什么 REA 能把订单、库存、财务串在同一张模型上
1.1 传统“借贷”记账模式的问题
先说一个很常见的现实困境。绝大多数公司里,“业务账”、“库存账”、“财务账”其实是三套系统,甚至三套人马在维护。业务系统说订单已经成交了,库存系统说扣减失败了,财务系统说没收到钱。三个部门各执一词,最后只能靠人工导 Excel 对账,一面对就是一整天。
问题的根源往往不在团队协作,而在当初建模的时候就埋下了雷。传统财务系统以“科目 + 借贷 + 凭证”为核心,这套模型适合财务人员记账,却不适合描述真实的业务链条。比如“发货”这个动作,在业务侧是订单状态流转,在库存侧是库存出库,在财务侧是收入确认,在物流侧是物流单生成。同一个动作,四个系统各记各的,字段名不一样、时间口径不一样、数量单位也不一样,后面当然容易对不齐。
REA 模型的核心思路恰好相反:它先统一“业务语义”,把任何一次业务变动都抽象成一条事件流。订单、库存、财务,本质上都是站在不同视角去观察同一条事件流。比如一笔销售订单:客户(代理)订购了一件商品(资源),仓库出库产生一条出库事件,财务确认应收产生一条资金事件。只要事件流的底子是一致的,三个系统的口径就不再需要“翻译”。
1.2 Resource、Event、Agent 三个词到底在说什么
REA 三个字母代表的含义,是理解整篇文章的起点。
先说 Resource,资源。它是一切有价值、可以被交易、被消耗、被生产的对象。库存商品是资源,现金是资源,服务工时是资源,甚至连会员积分也是资源。识别“什么东西在流动、什么在变化、什么被交换”,是建模的第一步。
然后是 Event,事件。事件是改变资源的动作,包括入库、出库、销售、收款、兑换、加工转换等等。REA 把事件当作系统的核心事实,事件一旦发生,就永久存在于事件表里,不会被修改,只会被追加。这个设计理念和事件溯源(Event Sourcing)非常相似,但 REA 更强调业务语义层面的抽象,而不仅仅是技术层面的日志存储。
最后是 Agent,代理。代理是参与事件的“人”或“系统”,通常分为内部代理和外部代理。内部代理是员工、部门、仓库;外部代理是客户、供应商、承运商。比如采购入库这个事件,内部代理是采购员,外部代理是供应商。
举个例子方便理解。一件商品从供应商采购入库,资源是商品,事件是采购入库,代理是采购员和供应商。客户下单买走这件商品,资源还是商品,但事件变成了销售出库,代理换成了销售员和客户。通过这种方式,连原本分散在采购、销售、库存、财务各个模块的数据,都能用同一套模型完整表达出来。
1.3 为什么“以事件为中心”而不是“以状态为中心”
我之前做系统的时候,习惯在订单表上放一个 status 字段,0 表示待支付,1 表示已支付,2 表示已发货,3 表示已完成。这种“状态优先”的建模方式查询确实简单,但后续维护起来会非常痛苦。
举个例子。一个订单已经发货,客户申请退货,仓管确认退货入库,此时订单状态要退回到“已收货”还是直接变成“已退货”?中间如果还有部分退款、部分发货,状态机的复杂度会直接爆炸。业务规则散落在一堆 if 分支里,很多人不敢改,一改就出错。
REA 的思路完全不同:我根本不保存当前状态,只保存所有发生过的事件。当前状态永远只是“所有事件推导出来的结果”。
这样做至少有三个好处:
- 天然可追溯。所有历史都在,不会出现“状态被覆盖了查不回来”的情况。
- 余额可以回放。基于任意时间点重建当时的库存、资金、欠款快照,对审计和纠纷处理非常有价值。
- 并发控制更简单。不用在状态字段上加锁,只需要保证事件本身的幂等写入。
打个比方,银行不会在卡表里存“余额”作为唯一事实,它一定会保留完整的交易流水。余额是每天夜里或者在查询时对流水汇总出来的结果。REA 本质上就是把这个思路推广到整个业务系统。
2. 落库之前先想明白的 4 个关键细节
2.1 四张基础表,比想象中少得多
很多人一听“模型”就觉得表会很多,但 REA 的核心其实只有四类结构。
第一类是 resource 表,存所有资源的主数据。第二类是 agent 表,存所有内部和外部代理的主数据。第三类是 economic_event 表,也就是核心事件表,每个业务动作一行。第四类是 contract 表,也叫契约表,用来把一组相关事件串成一张业务单据。
这里有一个认知需要纠正:订单表在 REA 里并不是主角。订单更像是“契约 + 事件”的聚合结果,而不是一个必须单独维护状态的对象。这一点和传统 ER 建模差别很大。传统建模习惯先定义订单主表和订单明细表,然后围绕它加各种状态字段;REA 则先把“发生了什么”记录清楚,再把“哪些事件属于同一笔交易”通过契约表关联起来。
我在第一次设计时也踩过这个坑,总觉得没有订单表不踏实。后来才明白,订单只是客户购买意图的载体,真正的业务事实是“销售事件发生了”。只要事件完整,订单随时可以重建。
2.2 事件表必须包含哪些字段
事件表是整个模型的心脏,字段设计直接影响后续所有查询和扩展。我给出一份可以直接参考的字段清单:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigserial | 事件唯一主键 |
| event_type | varchar(32) | 事件类型,如 SALE、STOCK_OUT、RECEIPT |
| contract_id | bigint | 关联的契约/单据 |
| resource_id | bigint | 关联的资源 |
| agent_id | bigint | 内部代理 |
| counterparty_id | bigint | 外部代理 |
| quantity | numeric(18,4) | 事件涉及的数量 |
| amount | numeric(18,4) | 事件涉及的金额 |
| biz_no | varchar(64) | 业务单号,用于幂等 |
| occurred_at | timestamptz | 业务发生时间 |
| recorded_at | timestamptz | 系统记录时间 |
有一个细节特别重要:不要在事件表里存“变化后的余额”或者“当前库存”。比如出库事件只需要记录出库数量是 3,不要去更新一个库存余额字段。余额是派生数据,应该由事件汇总得出。把这个原则坚持住,后面才不会被一堆不一致的快照折磨。
2.3 双向关系与契约表:别把模型做成大宽表
初学者最容易走偏的地方,是把所有字段塞进一张大宽表,比如订单明细表里同时放商品、单价、数量、优惠、库存、物流、收款状态。这种设计在业务初期看起来效率很高,但一旦遇到“一个订单拆成多次发货”、“一个订单部分退款”、“换货需要重新出库”这种真实场景,就会很难扩展。
REA 的落地方式更推荐这样:每一类动作单独建事件表或共用一张事件表并用 event_type 区分,成对事件通过 contract_id 聚合。比如销售出库和应收记账是两个事件,它们有各自的资源类型,但都指向同一个 contract_id,这样关系就串起来了。
我习惯把 contract 表设计成单据头,contract_id 就是订单号或者单号。事件的扩展只需要增加新的 event_type,而不需要改表结构。新业务上线时,往往只需要新增资源类型和事件类型,这比改一张大宽表的风险低得多。
2.4 余额怎么算:SUM 事件,而不是 UPDATE 余额
既然事件表只记录流水,那么所有余额都通过聚合事件获得。以库存为例:
select resource_id, sum(case when event_type = 'STOCK_IN' then quantity when event_type = 'STOCK_OUT' then -quantity else 0 end) as stock_balance from economic_event group by resource_id;这段 SQL 返回的是每个资源的当前库存余额。逻辑很简单:入库加、出库减。如果还要算批次、算库位,就在资源主数据上增加属性维度,事件表结构不需要变化。
很多人担心性能问题。不用慌,小规模系统直接查没问题;数据量上来之后,可以做事件表按日期分区,或者定时跑任务把汇总结果写入物化视图。关键是物化视图只是查询加速的缓存,事件表仍然是唯一的事实来源。这个原则守住,就不会出现“物化视图跑偏了,业务部门对着错误报表做事”的情况。
3. 最小闭环实操:从 DDL 到三个核心接口
3.1 选定最值得先跑的用例:订单-出库-收款
讲完理念,来看一个完整的最小闭环用例。我选择“客户下单 -> 仓库出库 -> 财务确认应收”这个流程,原因很简单:这是大多数电商、贸易、供应链系统最主干的一条链路,也是“业务、库存、财务”最容易打架的地方。先把这条闭环跑通,其他场景都可以照葫芦画瓢。
业务规则暂定三条:
- 客户下单时,锁定销售事件和金额。
- 仓库出库时,才真正扣减库存。
- 财务应收在出库后生成,应收金额等于出库数量乘以商品单价。
这里特意把“下单”和“出库”拆成两个事件,而不是下单时就直接改库存。这种区分非常关键,因为现实里就是会有“单下了但库存不够发不了货”的情况。
3.2 表结构 DDL(可直接套用)
我用 PostgreSQL 风格给出建表脚本。实际项目中换成 MySQL 也没问题,把 timestamptz 换成 datetime 即可。
create table resource ( id bigserial primary key, resource_code varchar(64) unique not null, name varchar(128) not null, resource_type varchar(32) not null ); create table agent ( id bigserial primary key, agent_code varchar(64) unique not null, agent_type varchar(32) not null, name varchar(128) not null ); create table contract ( id bigserial primary key, contract_no varchar(64) unique not null, contract_type varchar(32) not null, contract_date date not null ); create table economic_event ( id bigserial primary key, event_type varchar(32) not null, contract_id bigint references contract(id), resource_id bigint references resource(id), agent_id bigint references agent(id), counterparty_id bigint references agent(id), quantity numeric(18,4) not null default 0, amount numeric(18,4) not null default 0, biz_no varchar(64) not null, occurred_at timestamptz not null, recorded_at timestamptz not null default now(), unique (event_type, biz_no) );最后一行unique (event_type, biz_no)是幂等约束,后面的章节会讲到它为什么重要。整套表总共四张,数量上比传统订单系统的十几张表要克制得多,但表达能力反而更强。
3.3 三个核心接口流程:事件如何串起来
下单接口的核心逻辑是创建一个 contract,同时插入一条销售事件。
@Service public class SalesService { private final ContractRepository contractRepo; private final EconomicEventRepository eventRepo; @Transactional public void createOrder(CreateOrderRequest req) { Contract contract = contractRepo.save( new Contract(req.getOrderNo(), LocalDate.now(), "SALE")); eventRepo.save(new EconomicEvent( "SALE", contract.getId(), req.getSkuId(), req.getSellerAgentId(), req.getBuyerAgentId(), req.getQuantity(), req.getUnitPrice().multiply(req.getQuantity()), req.getOrderNo(), req.getBizTime())); } }出库接口的核心逻辑是插入一条 STOCK_OUT 事件,同时可以增加一个简单的库存校验。
@Transactional public void ship(ShippingRequest req) { BigDecimal stock = stockQuery.getCurrentStock(req.getSkuId()); if (stock.compareTo(req.getQuantity()) < 0) { throw new InsufficientStockException(req.getSkuId()); } eventRepo.save(new EconomicEvent( "STOCK_OUT", req.getContractId(), req.getSkuId(), req.getWarehouseAgentId(), null, req.getQuantity(), null, req.getDeliveryNo(), req.getBizTime())); }应收接口则是插入一笔 RECEIVABLE 事件,金额从订单金额里带过来。
@Transactional public void confirmReceivable(ReceivableRequest req) { eventRepo.save(new EconomicEvent( "RECEIVABLE", req.getContractId(), req.getCashResourceId(), req.getFinanceAgentId(), req.getBuyerAgentId(), null, req.getAmount(), req.getOrderNo(), req.getBizTime())); }三个方法都放在事务里,保证事件表和契约表的写入要么同时成功、要么同时失败。这就是事件驱动最舒服的地方:不需要专门去更新订单状态、库存余量和应收余额,只需要追加事件。
3.4 用 SQL 直接拉出“未出库订单”和“应付余额”
有了事件表,报表视图反而比传统状态表更简单。
查“已下单但未出库的订单”:
select distinct c.contract_no from contract c join economic_event e on e.contract_id = c.id where e.event_type = 'SALE' and not exists ( select 1 from economic_event o where o.contract_id = c.id and o.event_type = 'STOCK_OUT' );查客户应付余额:
select counterparty_id as customer_id, sum(amount) as receivable_balance from economic_event where event_type = 'RECEIVABLE' group by counterparty_id;如果想看“订单、出库、应收”三者的完整对应关系,只需要按 contract_id 做一次聚合:
select c.contract_no, coalesce(sale.amount, 0) as sale_amount, coalesce(stock.quantity, 0) as stock_quantity, coalesce(recv.amount, 0) as receivable_amount from contract c left join ( select contract_id, sum(amount) as amount from economic_event where event_type = 'SALE' group by contract_id ) sale on sale.contract_id = c.id left join ( select contract_id, sum(quantity) as quantity from economic_event where event_type = 'STOCK_OUT' group by contract_id ) stock on stock.contract_id = c.id left join ( select contract_id, sum(amount) as amount from economic_event where event_type = 'RECEIVABLE' group by contract_id ) recv on recv.contract_id = c.id;这段 SQL 跑出来的结果,就是业务、库存、财务三个部门都能对齐的一张主表。以往需要三个系统导数据再手工匹配的活,现在一条查询就解决。
4. 我在真实项目里踩过的坑与排查技巧
4.1 事件重复写入,余额直接翻倍
第一次用事件驱动建模,我栽得最狠的是幂等。下游系统因为网络抖动重试,或者消息队列 consumer 重复消费,同一个事件被插入了两次。后果很直观:库存余额多扣了一倍,应收金额翻倍。排查日志的时候很难发现,因为两条记录看着都一样。
解决办法就是在建表时就加上唯一索引。我在 DDL 里写的unique (event_type, biz_no)就是干这个用的。biz_no 取业务单据号,比如订单号加一个业务动作后缀,这样每次操作都有唯一标识。插入事件时用“insert on conflict do nothing”,重复过来的消息直接忽略,万无一失。
这块一定要提醒:事件的 id 不能用自增主键来判断是否重复,因为消费者拿到的事件很可能包含新的主键,但 biz_no 是业务上天然唯一的。
4.2 状态字段戒不掉,怎么办
有朋友问过我:“REA 很好,但产品经理就是要看订单状态,运营后台要按状态筛选,怎么办?”
我的做法是:状态可以存在,但只能作为“派生视图”,不能作为唯一事实。具体落地时,定期根据事件表刷新一个状态宽表。这个宽表可以带 status 字段,可以带余额快照,但它是事件流的下游产物,而不是写入时的最终产物。
我比较推荐每天凌晨跑一次汇总任务,或者用数据库物化视图。报表需要实时数据的话,也可以每次查询时临时聚合,只要性能扛得住。重点是不要在业务写入路径里手写一堆update status = xxx,一旦散落的更新比事件还多,就又会回到传统模型的老路上。
4.3 和旧系统对接时如何平稳过渡
改造老系统最忌讳一步到位。我经历过一个项目,要求把旧的订单状态表和新的 REA 事件表同时上线,结果两边数据对不上,加班整整两周。
正确的做法是“双写过渡”。历史数据先通过脚本回放成事件,老的状态字段继续保留,新接口在写入状态表的同时追加事件表。上线后每天跑对账任务,确认两边数据完全一致之后,再把查询路径切到事件表,最后老的状态表降级为临时只读。
这个过程听起来慢,但最稳。业务系统最怕数据口径突然变化,双写期可以在不影响业务的前提下验证模型可靠性。通常跑一两个业务周期左右,双方数据能稳定一致,就可以放心切换。
4.4 时间字段分开存,别把对账搞乱
事件表里我特意把 occurred_at 和 recorded_at 分开,这个看似不起眼的设计救过我很多次。
发生过一件事:客服在月底补录上个月的订单,直接用now()写入 occurred_at,结果整条业务时间线全部错位,营销报表、财务月结、库存周转全部对不上。
从那以后,我定了一条规矩:occurred_at 必须由上游业务传入,代表真实业务发生时间;recorded_at 是系统接收时间,用于排查数据写入问题。对账和分析都基于 occurred_at,查看系统问题才用 recorded_at。补单也好、重放也好,都不会再污染业务时间口径。
最后再分享一个我自己的习惯:每张事件表都留一个 event_json 或备注字段,把原始入参和上下文存一份。平时不查,但真到了争议单、客诉单的时候,这个字段能救命。REA 不是银弹,团队完全没有事件建模经验时,前期会有学习成本;但只要跑通一套最小闭环,后面每加一个新业务线,你就会发现速度明显变快。很多看似复杂的业务,本质上不过是一串事件的组合而已。