三年前第一次看到rea这个缩写,我以为是某个前端库或者新的状态管理框架。直到自己做了一套供应链系统,被库存对账和财务分录的“剪不断理还乱”折磨到深夜,我才真正意识到:rea 代表的 Resource-Events-Agents(资源-事件-代理)不仅是一个学术概念,更是一剂能够重构业务模型、把流水账变成可追溯事实的技术良方。
如果你是一个后端开发者、业务架构师,或者正在维护库存、订单、记账这类系统的工程师,这篇文章应该对你有用。我会带你从 REA 的核心定义讲起,结合我实际重构一套系统时的库表设计、代码实现和踩坑记录,把这件事说透。不吹不黑,只说怎么落地,以及哪些地方容易出事。
1. 我为什么决定用 rea 重新设计业务模型
当时项目背景很简单:一套在线零售的订单与库存管理模块,传统做法是“库存表 + 加减库存”的逻辑,每天对账靠跑脚本,改了库存流水后经常出现余额对不上、历史数据改不动、财务口径和业务口径打架的问题。
后来我看到 REA 的思路,第一反应是“这不就是给系统拍一条完整的时间轴吗”。传统模型是果:直接记录当前库存量、当前金额、当前余额。REA 模型是因:只记录发生了什么事、谁参与了、资源在事件中是流入还是流出。所有余额、库存、财务数字,都通过事件计算得出。
1.1 rea 到底是什么:资源、事件、代理这六个字母
REA 是三个核心概念的首字母缩写,对应着业务过程里不可再拆的三类元素:
- Resource(资源):所有参与业务、并且有经济价值的对象。库存商品、现金、原材料、甚至一条物流线路的运力,都可以算资源。
- Event(事件):发生在某个时间点、导致资源状态发生变化的行为。例如“商品出库”“完成支付”“原材料入库”都是事件。
- Agent(代理):参与事件的或人或系统。常见的是客户、供应商、内部员工,也可以把物流方、支付渠道作为代理来建模。
把它们放到一个业务场景里很明显:消费者是客户代理,店员是公司内部代理,商品是资源,一次“销售出库”是事件。消费者通过事件拿到商品,公司同时拿到现金。一个完整的业务场景,至少由两个事件组成,并且两个事件之间有资源互换关系。这个“最少两个事件”的规则,后来帮了我大忙,因为它强制建模者去思考业务的双向性,而不是只盯着某一方的余额。
1.2 传统“库存表 + 加减”模型到底哪里脆
传统库存模型最常见的写法是:
- 一张库存表存储每个 SKU 的当前数量。
- 每次出库、入库都执行
UPDATE stock SET quantity = quantity - 5 WHERE sku = ...。
增量事务一多,问题就来了。
第一,历史可追溯性极差。如果某一天有一条“负库存”记录,或者有一笔出库数量被误改,当前数量可能早就覆盖了历史状态。想回答“三天前仓库里到底有多少货”基本只能靠日志和猜测。
第二,与业务事件的耦合度太低。同一笔订单,在库存系统里是一条出库记录,在财务系统里变成应收账款,在 CRM 里又变成销售记录。三个系统各自用自己的表,却很难还原同一单背后的真实因果链。
第三,双向确认缺失。传统销售记录往往只记录公司视角的“出库”,不考虑客户是否签收、物流是否完成、是否存在换货。传统模型里这些被塞进一个又一个字段,最终变成“状态机地狱”。
REA 的优势在于:它建模时就把“谁对谁做了什么”写进了结构里。后来我在一个简单的开源测试项目里实现了一次库存核销模型,发现只要把“事件”和“代理/资源关系”独立保存,对账的麻烦少了一多半。这也是我今天写这篇文章的原因——真实体会比文档给的结论更深刻。
2. 核心细节:资源、事件与代理之间的建模规则
我在实践里发现,真正要想把 REA 用对,不只是建三张表那么简单。资源、事件、代理之间有几条隐含规则,一旦漏掉,模型就退化成普通的流水账。
2.1 经济交换必须由“事件对”构成
REA 中最基本的关系是交换(exchange):一个代理把一种资源流出,另一个代理同时或有协议地把另一种资源流入。开发时最容易犯的错是只记录流出,不记录流入。
举个实际例子:客户下单并支付 100 元,系统把商品出库。如果只记了一条“商品出库”事件,那客户付出的“100 元现金”这个资源就没有任何事件承接,财务模块后续要做账,就得靠额外脚本去匹配订单,牵一发动全身。
正确的建模是拆成两个事件:
- 销售事件:客户代理取得商品资源,库存减少。
- 收款事件:公司代理取得货币资源,客户代理减少对应的应收或货币。
这两个事件不能合并成一个。即使业务流程是“先付款后发货”,从领域建模的角度仍然应该拆开,因为两个事件的时间点、代理、资源可能各不相同。如果系统是点外卖,先支付了,但商家后来退款了,那么退款也是在“收款事件”上做的反向资源流入。事件模型保存了这些语义,后续什么都能推导。
2.2 事件里记“流向”,而不是记“加减号”
我在设计事件表时,很多同事问:为什么不在 Event 表里直接放change_amount?这其实是一个经典陷阱。
如果事件表里直接写“+5”或“-3”,那这个数字同时包含了事件类型和资源变化量两层语义,后面做复杂查询时会很痛苦。REA 的做法是把“流向”作为事件与资源关系表中的一个字段,而不是把它编码进事件类型。
例如用direction字段表示in或out,或者用正负数量表示流方向:
- 商品销售出库:
direction = out,quantity = 商品数量。 - 原材料采购入库:
direction = in,quantity = 材料数量。
这样做的好处是:同样的 Resource 可以被多个事件的direction引用,你可以用同一个查询拿到某个资源所有“流入”和“流出”的累计,而不需要给每一种事件单独写卡死的逻辑。
从代码上看,就是用联合表来关联事件和资源,而不是把资源数量复制到每一行事件里。我们后面会看到代码实现。
2.3 代理关系中的“保管者”与“外部人”
REA 规范里把 Agent 进一步分成两类:内部代理和外部代理。内部代理负责公司内部的资源保管、事件执行,外部代理比如客户和供应商。这个概念用在权限与职责分析上特别顺手。
一个卖货场景至少有两个代理:客户将钱交给收银员(内部代理),收银员把商品转交给客户。如果你要追责或者做权限控制,直接看事件关联的代理角色就能定位到责任主体。
千万不要只保存一个代理,比如订单只关联“客户”,而“收银员/执行员工”丢失。很多统计销售业绩的需求,会依赖“事件属于谁”。如果这个信息早期没建模,后面只能通过额外操作去补“业绩归属”,非常尴尬。
3. 落地实现:从领域模型到库表与代码
理论上听完 REA 感觉不难,但真开始敲代码时,表结构设计往往让人犹豫。我尝试过不少方案,给你分享一个我认为最简单、又不失语义的落地方式,适合中小型系统的重构。
3.1 先给事件表建模,而不是给库存表建模
传统表设计是先建一张stock表,然后建一堆stock_log。REA 反过来,核心是event表和event_resource关系表。
我用 SQL 来表现一下最核心的几张表结构:
-- 代理:客户、供应商、员工等 CREATE TABLE agent ( id BIGSERIAL PRIMARY KEY, agent_type VARCHAR(20) NOT NULL, -- customer / supplier / employee / system name VARCHAR(128) NOT NULL ); -- 资源:商品、现金等价物、原材料 CREATE TABLE resource ( id BIGSERIAL PRIMARY KEY, resource_type VARCHAR(20) NOT NULL, -- product / cash / raw_material name VARCHAR(128) NOT NULL, unit VARCHAR(20) NOT NULL ); -- 经济事件:真正记录“发生什么事” CREATE TABLE event ( id BIGSERIAL PRIMARY KEY, event_type VARCHAR(64) NOT NULL, -- sale / purchase / payment / receipt / conversion occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- 事件与资源的关联:事件使哪些资源流入/流出 CREATE TABLE event_resource ( id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES event(id), resource_id BIGINT NOT NULL REFERENCES resource(id), direction VARCHAR(3) NOT NULL CHECK (direction IN ('in', 'out')), quantity DECIMAL NOT NULL DEFAULT 0, amount DECIMAL NOT NULL DEFAULT 0 ); -- 事件与代理的关联:事件有哪些代理参与,角色是什么 CREATE TABLE event_agent ( id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES event(id), agent_id BIGINT NOT NULL REFERENCES agent(id), role VARCHAR(20) NOT NULL -- seller / buyer / custodian / receiver );这套结构最大的特点是:没有库存表、没有余额表。如果你想知道某个商品的当前库存,你只需要聚合event_resource表里所有direction='in'和direction='out'的数量,然后做差值。
为了避免重复依赖复杂查询,可以建一个物化视图作为“库存快照”,但这只是性能优化,不是业务主存储。
3.2 用 Python 实现一次“销售出库+收款”事件记录
我写代码时习惯把一个业务动作做成一个事务性函数,内部同时写入 event、event_resource、event_agent。为了方便演示,我用简单 Python 风格来说明流程:
def record_sale(sale_id, customer_id, seller_id, cash_resource_id, product_items): with transaction(): # 1. 创建销售事件 sale_event = Event.objects.create( event_type="sale", occurred_at=now() ) # 2. 商品出库,资源流出 for item in product_items: EventResource.objects.create( event=sale_event, resource_id=item.product_resource_id, direction="out", quantity=item.quantity, amount=item.price ) # 3. 销售事件关联代理:客户是 buyer,员工是 seller EventAgent.objects.create( event=sale_event, agent_id=customer_id, role="buyer" ) EventAgent.objects.create( event=sale_event, agent_id=seller_id, role="seller" ) # 4. 创建收款事件,现金资源流入公司 payment_event = Event.objects.create( event_type="payment", occurred_at=now() ) EventResource.objects.create( event=payment_event, resource_id=cash_resource_id, direction="in", quantity=1, amount=total_amount ) EventAgent.objects.create( event=payment_event, agent_id=customer_id, role="payer" ) EventAgent.objects.create( event=payment_event, agent_id=seller_id, role="receiver" )有几个细节我在实际代码里做了约束:
- 事件必须和资源关联,不允许出现“库存发生变化但谁也不知道是哪个事件造成的”情况。
direction是检查约束,避免乱写“in/out”之外的值。amount和quantity分离。商品出库的金额并不一定等于销售金额,因为存在折扣、积分抵扣。金额明细单独维护,可以避免后续对账时扯皮。
3.3 怎么把库存余额做成一个可复用查询
没有余额表的系统,读取库存会让人担心性能。实际用下来,只要给event_resource表建好索引,返回任意商品“当前库存”并不困难。
基础查询长这样:
SELECT resource_id, SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END) AS balance FROM event_resource WHERE resource_id = 1001 GROUP BY resource_id;如果业务需要“查询截至某个时间点的库存快照”,就简单加上event.occurred_at <= ?条件。这点在传统加减库存模型里几乎做不到,因为历史余额早就被更新覆盖了。
为了减少单次查询的计算量,我后来建了一个定期刷新的物化视图,比如每五分钟聚合一次“商品库存余额”。业务上允许轻微延迟,但报表查询性能提高明显。
实际经验告诉我,不要在事件表里放过多的“冗余状态”,比如事件是否完成、是否撤销。这些状态可以用事件类型来扩展,例如增加“sale_cancel”事件,并记录被取消的原事件 ID。如果直接在销售事件上维护is_cancelled字段,会破坏 REA 的不可变性。正确做法是“取消”也是一个新事件。
4. 实操中的几个坑与排查技巧实录
任何一种建模方式都有代价。REA 让数据追溯性和业务表达清晰度变得更好,但如果没有准备好应对下面这些问题,开发到一半可能会想改回老方案。
4.1 事件表变成流水账,查询越来越慢怎么办
事件表会持续增长,尤其当你的系统还记录了订单、退款、调拨、盘点等每一个动作。一次商品促销可能产生几十万条事件记录,直接聚合余额时性能会崩。
我的解决办法分为三档:
- 先保证核心表的索引合理:
event_resource(event_id)、event_resource(resource_id)、event(occurred_at)必须有索引。 - 再做“分层聚合”:每天凌晨跑定时任务,把前一天的资源流入流出汇总到
daily_resource_flow表,历史日报快照直接查这将表。 - 最后才考虑物化视图或者同步到数仓。中小项目没必要一上来就引入大数据组件,容易把架构撑复杂。
4.2 幂等性和并发控制:同一个业务事件被重复写入怎么办
使用 REA 后,所有业务状态都由事件推导。如果某个写入逻辑被微服务重试机制调用两次,库存数量就会被扣两次,而且从数据上很难察觉。
我当时做了一套简单的幂等键:
ALTER TABLE event ADD COLUMN external_biz_id VARCHAR(128); ALTER TABLE event ADD COLUMN external_biz_type VARCHAR(64); CREATE UNIQUE INDEX idx_event_biz_unique ON event(external_biz_type, external_biz_id);每次创建事件前,先检查同一外部业务 ID 是否已经存在对应事件。对于真正的高并发场景,比如抢购、秒杀,需要在数据库事务里用唯一索引做写入约束,避免并发插入重复事件。
4.3 不要急着把 REA 事件模型直接映射成会计总账
我刚开始做 REA 时,总想把“进销存事件”和“借贷记账”一一对应,结果把简单的事件建模搞成了财务核算系统,异常复杂。
后来才想明白:REA 适合描述业务事实,财务总账适合反映会计口径。很多财务指标需要对业务事实进行再加工,比如月末计提折旧、坏账准备。这些操作不属于基础业务事件,应该放在财务引擎层。
所以在工程实践中,我建议先让 REA 模型专注解决“发生了什么”,财务模块再通过事件数据生成分录。这个边界不清晰的话,你会为了迎合会计规则而给每张表加一堆负债、权益字段,最后连库存都查不明白。
4.4 资源记录的是“类型”还是“实例”
这是一个极易被忽略的坑。库存表里的商品,到底应该对应资源类型(“iPhone 15”这个 SKU),还是资源实例(“序列号为 12345 的这台手机”)?
REA 理论上可以包含 Resource Type 和 Resource Instance。做平台电商时往往只需要资源类型,但如果做售后维修、二手交易、固定资产管理,则需要追踪到具体序列号。
我的建议是设计时先问业务:是否需要追踪单个资源的生命周期?如果需要,事件资源关联表要加入resource_instance_id;如果不需要,则直接用resource_id做聚合。
最怕的是业务要求不明确,把资源实例 ID 硬填进资源表,导致一张表里既有类型信息又有实例信息,后续非常难受。
4.5 测试要从“改库存数字”思维切换成“加入事件”思维
在普通模型里,测试用例是“执行出库,断言库存减少 1”。REA 模型里则是“创建销售事件,断言商品资源流出 1,现金资源流入 100”。
测试代码没什么难度,难在前置数据准备。造数据时要先准备好资源、代理,再构造事件关系。建议写一套内部测试工厂,快速生成“某个客户在 2023-01-01 买了一件外套”这样有语义的事件组合。
我在项目里重构测试时,最大的感受是:REA 让测试变得更贴近业务语言了。普通代码测试读起来像“改了哪些表”,REA 测试读起来像“发生了什么业务”。这也是很多业务方愿意配合你的原因。
5. 一个更容易上手的落地顺序:从简单模块开始改造
不要试图一夜之间把整个系统的核心表全部改成 REA。我当时差点犯这个错,被劝住了,后来按两步走,效果稳得多。
5.1 先用“事件流水”代替“状态字段”
在一个小模块里面,比如“物流轨迹”或“退款记录”,先把状态机相关的多个字段替换成事件表。每一件事都变成一行,事件类型包括已提交、已发货、已签收、已退款、已关闭。
这一步不涉及库存和金额,风险最低。
5.2 再把库存和资金模型调整成 REA
当事件流水的模式稳定后,再引入event_resource和event_agent,把库存单据和支付单据关联起来。这时候团队对“事件”的理解已经统一,代码评审时不会有人再问“为什么库存没有 balance 字段”。
我个人的体会是:REA 的价值不在“某张表存了哪些字段”,而在整个团队是否认可“业务结果都是可推导的”这个理念。如果大家仍然习惯在事件表旁边放一个冗余的“当前余额”字段,那模型退化只是时间问题。
6. 最后聊点实际感受和后续扩展方向
我真切感受到 REA 的优势,是在一次对账事故中。客户投诉某笔订单支付成功但后台显示未付款。传统模型里,订单状态和支付状态是两个独立字段,同步失败就会对不上。切到 REA 后,支付事件只要写进事件表,用户是否支付就取决于“是否存在有效的 payment 事件”,而不是依赖某个状态位。
如果你准备自己做一套进销存系统,或者想重构一段写了非常多 if 状态判断的业务模块,我会建议先花一天时间,用resource / event / event_resource / event_agent四张表搭一个小 Demo,把一买一卖跑通。等你看清所有余额都来自事件聚合后,大概率会愿意丢掉原来的库存加减法。
后面想继续做的话,还可以考虑把这套事件数据接入事件总线,做实时数仓、风控分析和业务审计。毕竟事件本身就是最有价值的数据资产,只是在传统表结构里,它被拆散、冗余、覆盖了。REA 只是帮你还原事件,仅此而已。