很多人在做企业系统重构时,都绕不开凭证、流水、账户余额这一套老逻辑。早期我也一样,张口闭口就是科目余额表、借贷匹配。直到某天接手一个租赁设备中心的库存系统,我才真正意识到,传统复式记账模型在企业业务系统里已经拧巴到了什么程度:业务上明明是一件事,库里却被拆得七零八落;对账时能对上,业务却解释不清。那次我认真研究了 REA 模型(Resource-Evaluating-Agent,资源-事件-代理模型),用一套事件驱动的业务建模思路把系统从底层重写了一遍,体验非常不一样。这篇文章我想把 REA 的来龙去脉、建模步骤、落地代码和实际坑点完整讲清楚,适合那些正在做进销存、租赁管理、资产管理或任何与“价值转移”相关的数据模型设计者参考。
1. 为什么我盯上了 REA:传统复式记账在企业系统里的尴尬
1.1 账户、借贷和施工队式的数据表
先说说我之前用传统方式做的那套库存系统。业务原型是一家设备租赁中心,核心业务很清晰:设备入库、设备出库、客户租赁、到期归还、设备报废。听起来挺简单吧?但传统会计模型做出来,系统里全是台账、科目、凭证关联。一次设备出库要被拆成库存减少、应收增加、折旧计提等好几笔分录,每笔分录还有借方贷方。
这种设计的问题不是数学上对不上,而是对不上的时候根本查不出错在哪。有一次我负责的库里期末库存数量比实盘少了两台,我先对总账、再对明细账、再对凭证,从晚上八点查到半夜十二点。最后发现是一个退货流程里,冲销单被某开发人员写成了新增凭证而不是红字冲销。数据模型的语义表达能力太弱,导致错误总是以“余额不平”的方式暴露出来,而不是以“事件本身不对”的方式暴露出来。
事后我反思,传统借贷模型本质上是面向“账户”的建模,不是面向“业务事实”的建模。你把真实世界里的一次业务动作翻译成多行借贷分录后,信息就碎了一地。库存、应收账款、折旧,这些其实都是结果,而真正重要的那件事——设备从甲方转移到了乙方——反而没有一张表在描述。
1.2 REA 视野里的世界:资源、事件、代理
后来我在一次架构会议上听某导师提到 REA 模型。他的解释特别朴素:不要一上来就问“这笔钱应该记哪个科目”,要先问三个问题:业务里有哪些有价值的东西?什么东西让这些有价值的东西发生了增减?谁参与了这次增减?
这三个问题对应 REA 的三大核心概念:资源(Resource)、事件(Event)、代理(Agent)。
资源就是企业能控制的、有经济价值的对象,比如现金、设备、原材料、服务能力。事件就是让资源发生变化的那一次客观动作,比如“入库”“出库”“租赁归还”“支付租金”。代理则是参与事件的个人或组织,比如客户、供应商、业务员。REA 认为,一切业务都是一连串的资源增减事件,每次事件至少涉及一个代理,每次事件必然影响某种资源。
听着很简单?但把它落实到系统里,整个世界观就变了:你不再是维护一堆静态余额,而是在记录一条条不可变的事件流。余额可以从事件流推导出来,但事件才是唯一的事实来源。
1.3 我选择 REA 的直接触发点
真正让我下决心试用 REA 的,是一次“又平账但对不上业务”的惨痛经历。某月的租金收入总账是平的,但财务和运营互相拿不出同一份客户账单。财务说按应收账款科目走,运营说按合同期间算,两边都对,但口径不统一。
如果用 REA,“这个客户在 7 月租了一台型号为 X 的设备,租金 3000 元一次付清”就只是一组事件链条:设备流出事件、租金流入事件、时间点明确、参与代理明确。它不需要被翻译成“借应收账款还是借银行存款”,业务语义和财务语义在一个模型里统一了。这笔借款之争,本质上是我对业务建模的层次理解错了。
2. 把 REA 讲透:三个核心词和一个对偶关系
2.1 资源(Resource):一切可交换的价值标的
在 REA 里,资源不是指物理意义上的东西,而是指“能带来未来经济利益”的对象。一台设备是资源,但“这台设备的剩余租期”也是资源吗?严格来说它属于服务资源。服务比较特殊,是非存货型的资源,生产即消费,比如机时、工时。我建议做系统时把资源拆成两类:实体资源(设备、物料、现金)和服务资源(租赁时长、咨询时长)。
资源需要有一个可识别的账本维度和数量维度。现实中我们很容易把“设备类别”和“具体某台设备”混在一起。租赁中心里,如果一次出库租走的是 10 台同型号设备,建模时要先想清楚:资源粒度量到类别级别还是序列号级别?这在 REA 里不是细节问题,而是直接影响后续所有事件一致性的问题。
2.2 经济事件(Economic Event):真正值得记录的那一瞬
事件是 REA 里最核心也最容易理解错的概念。REA 事件强调“瞬间性”和“客观性”:它一定是已经发生的事情,而且是发生的那一刻的事实。不是“计划入库”,也不是“预计收回”,而是“已经入库”“已经收回”。
有个很容易踩的坑:混淆业务动作和业务事件。比如“客户下单”是不是事件?在 REA 里,下单通常不是经济事件,因为下单只是表达了意向,并没有导致任何资源的实际增减。真正的资源增减发生在这个订单被履行——也就是商品出库、资金流入——的时候。所以做系统时,我会明确区分“订单”是业务单据,而“出库事件”“收款事件”才是 REA 里的经济事件。
事件也决定了模型不可被随意修改。已发生的出库事件,哪怕填错了数量,也应该通过“新事件冲正”而不是“直接 UPDATE”来修正。这一点几年的实践经验下来,确实是保证数据可信的命门。
2.3 代理(Agent):谁在真实世界做了这个动作
代理是参与事件的内部或外部实体。内部代理比如仓库管理员、销售员,外部代理比如客户、供应商。每次经济事件至少关联两个代理吗?不一定。一次“设备入库”事件,最少要关联“供应商”和“仓库管理员”。一次“设备报废”事件,可能只涉及企业内部的“设备管理员”。代理建模的核心问题是“这个事件的主体和客体都要留下记录”。
代理这个维度往往在传统系统里被弱化了。传统进销存虽然也会维护经办人字段,但经办人在数据链条里是游离的,无法顺着事件追踪权限和责任。REA 把代理作为平等的概念纳入模型后,审计追踪变得天然:每一个数量的变化,都能直接回答“谁、和谁、对哪个资源、做了什么事”。
2.4 对偶关系(Duality):为什么库存一定等于出入之差
REA 最基本的骨架,是“一增一减”两个事件成对出现。资源要流入企业,必然有一个对应的流出。这种成对关系在 REA 术语里叫对偶关系(Duality)。
举例:客户支付租金,是“现金资源增加”事件;同时中心把设备租给客户,是“设备资源减少”事件。这两件事并不是一次完成的,但它们在业务上是对偶的。如果没有这种对偶关系,光记录“现金增加了 3000 元”,没人知道这 3000 元对应的是什么义务或权利。REA 的对偶关系让整个模型自带闭环校验:设备不会凭空消失,资金不会凭空产生。
这个思想其实非常像物理学里的守恒定律。我后来做库存模型设计时,干脆把对偶关系当成模型正确性的检定标准:任何资源数量的变化,都必须能找到一条完整的“从哪里来、到哪里去”的事件链路。
3. 实战:用 REA 给某设备租赁中心重构核心数据模型
3.1 建模前的“五官”:先画业务事件流
动手画表结构之前,我建议先建立一张事件流图。这一步千万别省。我当时拉上运营、财务、仓库负责人一起过了一遍全部业务流程,把里面所有“引起资产数量变化”的动作列了出来。
以设备租赁中心为例,核心事件大概是这五类:设备采购入库、设备出库出租、客户归还设备、收到租金、设备报废。注意,这些事件都是从业务事实剥离出来的。采购入库是设备资源增加事件;出库出租是设备资源减少、同时产生“应收租金”这个债权资源的增加;归还设备是设备资源回库增加;收到租金是现金资源增加、债权资源减少;报废是设备资源减少。
画完这些事件流,你会发现自己不再关注“科目余额”,而是关注“事件之间的因果链”。这一步的产物,可以直接与人沟通对齐,也方便和财务审计聊口径。
3.2 识别资源、事件、代理的几个实用清单
实际建模时我习惯用一个问题清单来帮助识别:它能换成钱吗?它数量会变吗?它是瞬时发生的吗?谁参与了这个数量的变化?
这个清单的实操价值很高。比如“租赁合同”这张表,它本身不是资源,因为合同不会直接带来经济利益,它在 REA 里更像是连接“租出事件”和“收款事件”的凭证纽带。但“合同剩余的收款权利”则可以建模成资源,因为它直接代表未来的经济利益。
代理识别也要建模到合适的粒度。客户、供应商这类外部代理肯定要建表,但“仓库”算不算代理?严格说仓库是一个位置,不是代理。位置信息可以作为事件的分组维度或标签,但不要把仓库当作代理建进主体表,否则后续想在地点维度上做统计分析时会很别扭。
3.3 ER 映射:从业务概念到关系表
把 REA 概念映射到 ER 模型时,我常用的做法是四张基本表加一张对偶表:
资源表存资源主数据和当前属性,事件表存不可变事件,代理表存参与方,对偶表存一增一减事件之间的关联。其中事件表需要区分资源流入和流出,我用 event_type 字段来区分,而不是建两张物理表。
下面是我当时用的简化版建表脚本(脱敏后):
CREATE TABLE resource ( resource_id UUID PRIMARY KEY, resource_code VARCHAR(64) NOT NULL UNIQUE, resource_type VARCHAR(32) NOT NULL, -- 'EQUIPMENT', 'CASH', 'SERVICE' spec_json JSONB NOT NULL, -- 型号、批次、规格等 created_at TIMESTAMP NOT NULL DEFAULT now() ); CREATE TABLE agent ( agent_id UUID PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, -- 'CUSTOMER', 'SUPPLIER', 'EMPLOYEE' name VARCHAR(128) NOT NULL, contact VARCHAR(64) ); CREATE TABLE economic_event ( event_id UUID PRIMARY KEY, event_type VARCHAR(32) NOT NULL, -- 'RECEIVE', 'RELEASE', 'CONSUME' happened_at TIMESTAMP NOT NULL, resource_id UUID NOT NULL REFERENCES resource(resource_id), agent_id UUID NOT NULL REFERENCES agent(agent_id), counterparty_id UUID REFERENCES agent(agent_id), quantity NUMERIC(18, 4) NOT NULL, unit VARCHAR(16) NOT NULL, meta_json JSONB, posted_at TIMESTAMP NOT NULL DEFAULT now() ); CREATE TABLE economic_duality ( duality_id UUID PRIMARY KEY, increment_event UUID NOT NULL REFERENCES economic_event(event_id), decrement_event UUID NOT NULL REFERENCES economic_event(event_id), note TEXT );这张 event 表里我特意加了 counterparty_id,它是相对 agent_id 的对方代理。比如出库事件里 agent_id 是企业内部员工,counterparty_id 是客户;收款事件里 agent_id 是客户,counterparty_id 是财务人员。这样一个事件表就能同时支撑“谁操作”和“针对谁”两个问题。
3.4 落地细节:主键、外键和不可变事件表
这一层容易被忽略,但实际是最影响稳定性的。我建议所有事件表都用 UUID 主键,而不用自增整数。自增主键最大的问题是它会暴露写库顺序,而且跨库合并或数据迁移时会冲突。UUID 虽然在索引上稍慢一点,但对事件流这种只追加、极少更新的表来说性价比很高。
事件表的 nobody 改是最后一层防线。我在应用层做了硬性约定:update 权限不授予任何人,delete 同样。修正必须用反向事件做冲销。比如某设备出库时数量多了 1 台,不是改原事件的 quantity=1,而是新增一条“归还入库”事件,事件类型填 CORRECTION_RECEIVE,并通过对偶表关联到原错误事件。审计人员看到一对事件就知道哪个错了,账面依然守恒。
这个设计也意味着事件表只需要 INSERT 和 SELECT 权限,连 UPDATE 权限都从权限体系里摘掉了。安全性和稳定性上,这种做法带来的好处是巨大的。
4. 从模型到代码:一套最小但完整的 REA 实现
4.1 事件写入服务的核心逻辑
有了表和模型,接下来就是代码落地。我先分享一个读取请求并写入事件的方法,这段代码的核心原则:先查资源当前数量,再校验事件合法性,最后写入事件表和对偶表,整个过程在同一个事务里完成。
@Transactional public EconomicEvent postEvent(PostEventCommand cmd) { Resource resource = resourceRepo.findById(cmd.getResourceId()) .orElseThrow(() -> new BusinessException("资源不存在")); // 1. 根据事件类型,预检资源条件 if ("RELEASE".equals(cmd.getEventType())) { BigDecimal available = stockQuery.availableQuantity(resource.getResourceId()); if (available.compareTo(cmd.getQuantity()) < 0) { throw new BusinessException("可用数量不足,当前可用=" + available); } } // 2. 写入主事件 EconomicEvent event = new EconomicEvent(); event.setEventId(IdGen.uuid()); event.setEventType(cmd.getEventType()); event.setHappenedAt(cmd.getHappenedAt()); event.setResourceId(resource.getResourceId()); event.setAgentId(cmd.getAgentId()); event.setCounterpartyId(cmd.getCounterpartyId()); event.setQuantity(cmd.getQuantity()); event.setUnit(resource.getUnit()); event.setMetaJson(cmd.getMetaJson()); event = eventRepo.save(event); // 3. 如果有对偶事件,则建立关联 if (cmd.getPairEventId() != null) { EconomicDuality duality = new EconomicDuality(); if ("RECEIVE".equals(cmd.getEventType())) { duality.setIncrementEvent(event.getEventId()); duality.setDecrementEvent(cmd.getPairEventId()); } else { duality.setIncrementEvent(cmd.getPairEventId()); duality.setDecrementEvent(event.getEventId()); } dualityRepo.save(duality); } return event; }这段代码最有价值的地方其实是第 1 步的资源预检。很多人写库存系统,直接把数量减掉,但没有检查“可用量”,结果一超卖就抓瞎。有了基于事件的实时查询,预检的成本很低,却能在源头上挡住绝大多数非法事件。
4.2 视图查询:怎么实时计算设备可用库存
事件表只追加不修改,那“当前库存”怎么算?原则是不要单独维护一张余额表,而是通过聚合事件实时算出来。如果数据量实在太大,可以引入物化视图或快照表,但最终一致性一定基于事件流。
SELECT resource_id, SUM(CASE WHEN event_type IN ('RECEIVE', 'RETURN', 'CORRECTION_RECEIVE') THEN quantity ELSE 0 END) - SUM(CASE WHEN event_type IN ('RELEASE', 'REJECT', 'SCRAP') THEN quantity ELSE 0 END) AS current_quantity FROM economic_event GROUP BY resource_id;这种写法的最大好处是,任何一条事件错了,都不会导致余额表与事件表失配。你要做的只是修正事件,而不是手工去调“当前库存”。审计时可以逐条溯源,用户对可用数也放心。性能方面,我后来用物化视图十分钟刷新一次,同样保持事件表只追加、报表层快速读取,两边互不影响。
4.3 服务资源怎么处理:以“租赁时长”为例
设备租赁业务里有一个非实物资源,就是租赁时长。租出一台设备,客户获得 30 天的使用权,从 REA 视角,这是服务资源的增加事件与设备资源减少事件成对发生。到期归还,服务资源自然“消耗完毕”。如果客户续租,那就是新的一对事件,而不是延长原事件。
服务资源的建模比较特殊,它的数量单位不是“台”,而是“天”或“小时”。我在 resource 表里直接塞了一个 unit 字段,租赁时长用 SERVICE_DAY 类型存。后续生成账单、算收入、统计设备利用率,都从这些服务资源事件出发,量纲天然统一。
这种“把服务权作为资源”的建模方式,一开始会有点反直觉。但一旦想通,它会帮你打破实物资产和无形业务的隔阂,让后续统计口径彻底一致。
5. 踩坑笔记:REA 实践中我犯过的错
5.1 把“修改”当成“新事件”:不可变边界的建立
第一次用 REA 时,我心血来潮想给事件表加一个 UPDATE 接口,理由很现实:有时候工作人员确实录错了。于是某次出货事件被直接 UPDATE 掉了数量和对方代理,账面确实平了,但对账审计时,系统给出的解释链条是断裂的,最终还得靠人工比对导出表才能还原真实过程。
后来我把修正动作统统改成“冲正 + 新事件”,虽然写入量多了一点,但每一次数量变化都有完整的前因后果。模拟过一次审计后,所有人都觉得这种“笨办法”才是最省力的。
5.2 对偶关系粒度过大:要不要每个事件都强制配对
理想情况下每个资源增减事件都有对偶面,但现实很骨感。有时事件发生得太快,关联信息还没到位。早期我为了追求完美对偶,要求事件必须抓到对偶事件才能落库,结果业务方反馈“客户付钱和出库根本不可能在同一秒发生,你让我怎么填?”
这个问题的解法不是放宽约束,而是把对偶关联拆成“异步补全”。先写主干事件,比如收款事件先落库;之后出库事件生成时,再通过对偶表反向补关联。对偶关系变成了一个可延迟填充的关联,但仍要求在结算周期内补齐,否则系统自动告警。这样做既保住了模型闭环,又没有过度干扰一线操作。
5.3 和 DDD / 事件溯源模型的边界划分
REA 和近年流行的领域驱动设计、事件溯源容易混在一起。我有段时间也差点把它们当成一回事。实际使用中,我发现它们在边界之外可以有清晰分工:
- DDD 是一种领域建模方法,管的是业务逻辑分层、聚合根和边界上下文;
- 事件溯源(Event Sourcing)是一种持久化机制,管的是“状态如何由事件重建”;
- REA 更像是一种领域本体论,它告诉你“业务世界里到底有哪些类型的基础概念”。
所以完全可以在 DDD 聚合里把库存写成一个聚合根,用事件溯源持久化,而整个聚合内的事件类型和资源关系用 REA 来约束。换句话说,REA 给事件模型提供了语义基础,DDD 解决了代码组织,事件溯源提供了技术实现,三者是互补关系。但如果你不分清楚,代码里会同时出现“DO、事件、资源”等好几个相似概念,最后改混了,维护成本剧增。
6. 什么时候该用 REA,什么时候别硬上
6.1 适合 REA 的场景矩阵
我复盘了几个项目后,总结出一个粗略的判断矩阵。如果业务是强流程、强对账、强审计的类型,比如租赁、进销存、资产盘点、交易平台,REA 模型带来的收益远大于成本。
| 场景 | 推荐度 | 原因 |
|---|---|---|
| 设备租赁/资产台账 | 高 | 天然关心资源的进出与归属,审计追踪要求高 |
| 电商进销存 | 高 | 库存增减与资金流可建模成对偶事件,防超卖 |
| 会员积分系统 | 中 | 积分增减本质也是资源事件,但不牵涉对账,REA 略重 |
| 内容管理CMS | 低 | 没有明显的资源增减与对偶关系,REA 过于理论化 |
| 人事考勤系统 | 低 | 数据变动主要是状态流转,并不涉及经济资源交换 |
这套判断框架不是我凭空想的,而是踩过一轮轮坑后的总结。人事实务、内容发布这些场景没有真正的资源守恒需求,硬套 REA 只会增加理解和维护成本。
6.2 与兼顾现实系统整合:对账、报表和外部审计
用 REA 模型的系统,最终还是要跟财务对账、跟外部系统对接。这点必须提前想清楚。我采用的方案是在 REA 事件流之上,做一层“财务投影层”。每当经济事件发生,就会异步生成财务记账用的分录投影。事件流是原始事实,分录是面向财报的派生结果。两者用 event_id 关联,任何时候分录都可以重新从事件流中重建,对不上账时能快速定位到具体哪个事件导致的。
这个“事实层+投影层”的设计帮我解决了一个长期矛盾:业务系统用 REA 保持语义清晰,财务系统用科目余额满足合规。不用强行把 REA 塞进复式记账的壳子里,也不用推翻财务系统。
投射层建成后,审计人员拿到的不再是孤立的分录,而是一条从业务事实到会计分录的完整追踪链。审计沟通效率提升了很多,这也是我把 REA 当作长期模型的一套重要理由。
6.3 扩展思考:REA 与事件驱动架构的化学反应
如果你所在团队已经有 Kafka 或类似事件总线,REA 会带来一个额外惊喜:它是天然的事件分类法。我后来给消息主题起名时,完全不再靠拍脑袋,而是直接按 REA 结构分配:resource-created、event-posted、duality-linked、agent-registered。消费端按资源维度和代理维度组织,处理逻辑清晰很多。
从长期演进的角度看,REA 给了团队一套统一的“业务语法”,任何新成员加入时,只需要理解资源、事件、代理这三个词,就能读懂大部分核心代码。这种模型价值会随着团队规模扩大而持续放大。
REA 不是银弹,更不是每个项目必用的最佳实践。但如果你和我一样,受够了那种“账平了但解释不清”的系统状态,不妨先找一个设备租赁、库存管理这类边界清晰的小模块试水,跑一个季度看看对账效率变化。我在实践里最直接的体感是,之后每次业务方来问“这个数量为什么变成这样了”,我不再需要从三四张表里倒查,只需要顺着事件表往下滚,答案就在那里——这种感觉,确实让人上瘾。