朋友发消息问我:“你听说‘rea’没有?”我第一反应是某个新框架,后来他发来一张模型图,我才意识到他说的是 REA——Resource-Event-Agent,资源-事件-参与者模型。这玩意在会计信息系统和企业建模领域存在了快四十年,最近这两年又被人翻出来,因为搞微服务、搞DDD、搞数据中台的人发现,用 REA 来梳理核心业务,意外地好用。
简单说,REA 是一种建模方式,它把业务世界里的东西分成三类:资源(Resource)、事件(Event)、参与者(Agent)。资源是经济价值载体,比如商品、现金、积分;事件是资源发生变化的行为,比如销售、收款、出库;参与者是发起或参与事件的人或组织,比如客户、门店、仓库管理员。这套模型能解决的问题很实在:账对不齐、库存漂移、业务状态满天飞、流程走一半数据就乱了。适合后端开发、架构师、产品经理,尤其是做交易、库存、账务系统的朋友。
下面我把 REA 的底层逻辑、建模方法、落地步骤、踩坑经验一次性说清楚。
1. 为什么需要 REA:传统业务建模的三大硬伤
1.1 你每天在维护的"状态",其实一直在掩盖问题
大多数业务系统落地时,第一反应是建表。商品表、订单表、库存表、流水表,每个表里塞几个状态字段。订单表里有"待支付""已支付""已发货""已完成",库存表里有"现有量""可用量""冻结量",对账的时候靠这些状态字段去猜业务到底进行到哪一步。
我见过太多项目改不动、对不上账,根子就在这:系统保存的是"结果",不是"原因"。你只知道库存表里剩余数量是 50,但不知道这 50 是怎么来的——是入库了 100 后来卖了 50?还是入库了 80 后来退了 30?如果直接改状态,数据审计无从谈起,出问题只能干瞪眼。
1.2 复式记账很伟大,但它只活在财务软件里
财务系统用借贷记账法,每一笔经济业务记两笔账,有借必有贷,借贷必相等。这解决了"钱去哪了"的问题,但问题在于:业务系统的开发人员普遍不熟悉借贷,设计数据库的时候不会套用复式记账的思路,于是业务库和财务库长期是两张皮。数据先落到业务表,再手工或定时任务转到财务系统,中间对不上就产生一堆差异表。
REA 的出身恰恰是会计信息系统研究,它把复式记账提升成了更适合信息系统建模的范式:不再强调"借"和"贷"两个账户维度,而是用"事件"作为业务的核心,把资源的流入流出、参与者的交互用统一的方式表达。实际上,你记住一句话就能切入 REA:业务系统真正该存储的,是事件本身,而不是事件造成的影响。
1.3 一个例子:传统订单表到底哪里别扭
假设有个电商小程序,用户下单买一个杯子。传统做法是:
- 订单表加一条记录,状态"待支付"
- 库存表减少一个可用数量
- 用户表加一个积分变动记录
这三张表由三个服务去更新,靠分布式事务或消息队列硬凑一致性。一旦某个环节失败,要么订单扣库存不一致,要么积分发重,排查起来非常痛苦。
用 REA 的视角看,下单这个"事件"包含三个视角:资源是"杯子"和"钱";事件是"销售订单确认";参与者是"用户"和"平台"。你要做的是把"一次交易产生哪些资源变动"记录下来,后续的库存多少、账户余额多少、毛利多少,都是从这个事件流"算"出来的。
理解了这一点,REA 的建模就有了方向:尽量不依赖冗余的状态字段,而是从事件历史推导业务现状。这在架构上叫事件溯源,在会计上叫流水账,在业务上叫可追溯。REA 就是这三者的统一抽象。
2. REA 的三个核心元素与两种关键关系
2.1 资源(Resource):被管理的价值载体
资源是业务里被操作、被交换、被消耗的对象,特性是可以被计量、有经济意义。商品是资源,现金是资源,积分是资源,库房空间、工时也可以被看作资源。资源不一定是实物,"服务时长""营销预算"都可以建模为资源。
资源的属性通常有时间维度和数量维度,比如"库存盘点时点的数量""某笔交易涉及的金额"。REA 特别强调一点:资源不应该直接存一个"当前状态",而是记录资源的"可得历史",通过事件推导当前状态。
2.2 事件(Event):系统最核心的一等公民
事件是 REA 的灵魂。它表示在某个时间点,某种资源发生了增加或减少,且有明确的参与者。比如"用户支付了 50 元""仓库发出了一个杯子""用户退还了杯子"。事件必须有时间戳、有方向(流入还是流出)、有数量、有关联资源、有关联参与者。
识别事件是 REA 建模最关键的步骤。我的经验是看两个特征:
- 事件对应的是"变化"本身,不是变化后的结果。比如"订单状态变为已支付"不是事件,"收到一笔支付款"才是事件。
- 事件必须有实际业务文件支撑,比如订单号、支付流水号、出库单号。这些单据就是事件的证据。
2.3 参与者(Agent):谁动了这些资源
参与者和事件关联,表示"谁参与了这笔业务"。参与者可以是个人、组织、系统角色。常见的情况是内部参与者(销售员、仓管员)和外部参与者(客户、供应商)。
参与者角色的引入,让 REA 模型天然能回答"这件事是谁做的"这种审计问题。这也是很多常规表结构做不到的:订单表里通常只有一个"用户 ID",但"谁审核了这笔订单""谁最终确认了发货"往往被我扔在操作日志里,查起来非常痛苦。
2.4 两两关系:入/出、互换、控制
REA 定义了资源、事件、参与者之间的三种基本关系:
- 资源-事件关系(存量与流量):事件使资源增加或减少,叫 inflow 或 outflow。一个事件至少涉及一个资源流入或流出。
- 事件-事件关系(互换):典型的是销售和收款,一笔销售事件对应一笔现金流入事件。不是每笔业务都需要配对,但涉及交换的业务一定有这种成对关系。
- 事件-参与者关系(控制):参与者"参与"事件,负责发起、执行或接收。一个事件至少有一个内部参与者,通常也有一个外部参与者。
这三个关系就是 REA 建模的全部语法。听起来简单,真上手时很容易乱,尤其是把"资源的变化"和"事件"混为一谈。我见过有人把"商品上架"当成事件,但从资源视角看,上架本身没有发生经济价值的变化,它只是商品这个资源的属性变化,真正的资源流入事件是"采购入库"。
3. 完整案例:用 REA 给积分商城系统建模
3.1 业务需求
做的是一个积分商城,核心业务包括:用户通过签到、消费获得积分;积分可以在商城兑换商品;兑换后扣减积分;后台可以调整积分(比如人工补偿、活动赠送);月底要能输出积分发放明细、消耗明细、用户余额对账单。
用传统表结构设计,很自然地会做一张 user_points 表,存一个当前余额字段,然后每次发放和扣减都 UPDATE 一下。问题在于:一旦出现并发操作很头疼,余额要么用乐观锁要么用悲观锁;人工调整积分没有留痕;月底报表不知道该信操作日志还是信余额表。
3.2 识别 R/E/A
把需求翻译成 REA:
- 资源:积分(Points)、兑换商品(Product)、订单额度(OrderQuota)
- 事件:积分发放(PointsGranted)、积分扣减(PointsRedeemed)、积分调整(PointsAdjusted)、兑换订单创建(OrderCreated)、兑换订单发货(OrderShipped)、兑换订单完成(OrderCompleted)
- 参与者:用户(Customer)、运营人员(Operator)、系统(System)、仓储人员(WarehouseStaff)
画出关系:
- 积分发放事件:inflow 积分,由用户和系统参与
- 积分扣减事件:outflow 积分,由用户参与
- 兑换订单创建事件:outflow 积分(预占),同时 inflow 兑换商品(预留商品)
- 兑换订单发货事件:outflow 商品,由仓储人员参与
这里有个设计要点:订单创建时并不马上扣减积分,而是创建一条"预占"事件,真正发货后才做最终扣减。这个预占和最终扣减,就是一对"承诺事件"与"实现事件",对应 REA 里的成对事件关系。落地时可以用 status 字段区分,但所有资源数量的变化,都用事件记录而不是字段覆盖。
3.3 表结构落地
数据库设计上,不用把每个事件都建一张物理表。更常见的做法是统一一张 point_events 表,加一个事件类型字段去区分发放、扣减、调整。核心表结构如下:
-- 积分资源流水表 CREATE TABLE point_events ( id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_id BIGINT NOT NULL, -- 外部参与者 operator_id BIGINT, -- 内部参与者,空表示系统 event_type VARCHAR(32) NOT NULL, -- GRANT/REDEEM/ADJUST/FREEZE/UNFREEZE direction TINYINT NOT NULL, -- 1 流入,-1 流出 points INT NOT NULL, -- 变动数量 resource_id BIGINT NOT NULL, -- 积分账户 ID,即资源实例 order_id BIGINT, -- 关联业务单据,事件的证据 event_time DATETIME NOT NULL, remark VARCHAR(255) ); -- 积分账户表(只存"投影",可由事件重算) CREATE TABLE point_accounts ( account_id BIGINT PRIMARY KEY, customer_id BIGINT NOT NULL, total_points INT NOT NULL, -- 当前余额快照 updated_event_id BIGINT NOT NULL -- 最近一次事件 ID );point_accounts 里的 total_points 不是唯一的数据源,它只是一个"投影"。任何一笔余额想被验证,都可以通过 sum(point_events.points) 重新计算。这个设计的好处是:异常数据产生后,不用拍脑袋改余额,重新对账就能发现问题。
3.4 核心代码逻辑:积分发放与扣减
事件回调的伪代码大致是这样:
// 发放积分:先写事件,再更新投影 @Transactional public void grantPoints(Long customerId, int points, Long orderId) { PointEvent event = new PointEvent(); event.setCustomerId(customerId); event.setEventType("GRANT"); event.setDirection(1); event.setPoints(points); event.setOrderId(orderId); event.setEventTime(now()); pointEventRepository.insert(event); PointAccount account = accountRepository.lockByCustomerId(customerId); account.setTotalPoints(account.getTotalPoints() + points); account.setUpdatedEventId(event.getId()); accountRepository.update(account); } // 扣减积分:校验余额,写扣减事件 @Transactional public boolean deductPoints(Long customerId, int points, Long orderId) { PointAccount account = accountRepository.lockByCustomerId(customerId); if (account.getTotalPoints() < points) { return false; } PointEvent event = new PointEvent(); event.setCustomerId(customerId); event.setEventType("REDEEM"); event.setDirection(-1); event.setPoints(points); event.setOrderId(orderId); event.setEventTime(now()); pointEventRepository.insert(event); account.setTotalPoints(account.getTotalPoints() - points); account.setUpdatedEventId(event.getId()); accountRepository.update(account); return true; }关键点是:先写事件,再更新投影。为什么不先扣余额再写流水?因为事件是"事实",余额是"结果"。如果先动余额,事后审计没法区分是逻辑 bug 还是并发问题;先写事件的话,哪怕投影更新失败,也可以重放事件把余额修回来。
提示:余额投影表不要用 MySQL 的 AUTO_INCREMENT 做主键做并发控制,要用行锁(SELECT FOR UPDATE 或用版本号)保证同一账户并发扣减时不会超扣。否则事件是对的,投影也照样会漂。
4. REA 如何秒杀库存与账务的历史遗留问题
4.1 库存系统:从"减库存"到"记出库事件"
库存系统是 REA 体现价值最明显的地方。传统做法是 orders 表减库存,退款单表加库存,库存表总有个 available_stock 字段,两份单据同时改它,冲突和抖动是家常便饭。
REA 的做法是:任何库存变动都必须对应一个入库事件或出库事件。采购入库、销售出库、退货入库、盘点调整、报废出库,每类事件都有独立的业务编号。库存表只做全量重算的视图或投影汇总:
SELECT warehouse_id, product_id, SUM(CASE WHEN event_type IN ('PURCHASE_IN', 'RETURN_IN', 'ADJUST_IN') THEN quantity ELSE 0 END) AS total_in, SUM(CASE WHEN event_type IN ('SALE_OUT', 'SCRAP_OUT', 'ADJUST_OUT') THEN quantity ELSE 0 END) AS total_out FROM inventory_events GROUP BY warehouse_id, product_id每一条库存流水都能回答"什么时候、哪个仓库、谁操作的、为什么变动"这四个问题。做库存对账时,不再依赖两个系统之间靠 Excel 转来转去,而是让两套系统都输出事件流,然后做流与流之间的比对。
4.2 财务对账:用成对事件自动找平
做交易系统的人都知道,最怕的就是渠道账单和业务账单对不上。传统做法是业务系统导出一张交易明细表,渠道系统导出一张账单明细表,两边按订单号匹配,匹配不上的进异常池。
REA 的成对事件思想在这里能直接派上用场。渠道支付成功是一个"资金流入事件",业务系统订单确认是另一个"资源流出事件"。把两边的事件都落库,比对的就是"同一笔业务对应的事件对是否完整"。
举例:订单 A 应该有一条产品销售事件和一条渠道收款事件,两事件通过关联键(order_id、payment_no)关联。如果只有销售事件没有收款事件,就是"应收未收";只有收款事件没有销售事件,就是"多收等待退款"。这比直接比对金额字段更可靠,因为事件本身携带了业务语义,而金额字段只是数字。
4.3 审计视角:每个事实都不可丢
REA 模型天然支持审计追踪,因为所有变更都是追加式的事件记录,不是覆盖式更新。这让系统在面对"为什么这笔数据变成这样"时,可以直接回放发生过的所有事件,找到问题源头。
我实际做过的项目中,有一个用户投诉"积分被莫名扣减"。传统表结构只能看到当前余额减少了,查不到是谁扣的。改造后通过 point_events 表按用户维度查询,立刻看到某条 ADJUST 事件:操作者是某运营账号,时间是某天某时,备注是"活动补偿修正"。问题当场定位,用户也信服。
这就是 REA 的隐性价值:它不直接创造业务功能,但它让"责任"变得可见,让数据经得起追问。
5. 常见问题与落地心得
5.1 实践中的典型翻车现场
我整理了一张问题速查表,都是初次落地 REA 时容易踩的坑:
| 典型表现 | 根本原因 | 解决思路 |
|---|---|---|
| 事件表和业务单据表重复,不知道该写哪张 | 把"业务单据"和"事件"当成两回事 | 业务单据是证据,事件是证据在模型中的投影。先有单据,再生成事件,事件反查单据 |
| 事件识别不出来,画图画了半天全是资源 | 混淆了"对象"和"对象的变化" | 问自己:这个业务动作是否让资源增加或减少?没有增量的动作不配叫事件 |
| 事件数量爆炸,表膨胀得厉害 | 把高频查询结果也设计成事件 | 只记录经济增量类事件,查询条件变化本身不该入事件流,用普通索引或日志解决 |
| 账号余额对不上,事件流是准的,投影表是错的 | 只做了事件落库,投影更新没做幂等或事务处理 | 投影必须在事件事务内同步更新,并用事件 ID 做幂等 |
| 存模型的代码层成了"面条代码",判断逻辑散落 | 没有把 REA 的成对事件语义固化进领域层 | 在领域服务里统一封装事件创建和关系关联,不要让每个接口自己拼事件 |
5.2 实操心得一:先画 REA 图,再建库表
我强烈建议,动手写建表 SQL 前,先在白板上把所有核心业务动作按 R/E/A 画一遍。画图过程中你会发现很多原来"差不多"的概念其实边界模糊:比如"退款",它是销售事件的取消,还是一个新的资源流出事件?不同业务定义会导向完全不同的表结构。
我的选择是:退款单独建模为一类事件,并和原销售事件通过 refund_order_id 关联。因为退款有自己的凭证、有独立的审批流、有复杂的金额计算,硬要把两件事"冲掉"会让历史信息丢失。宁可事件粒度细一点,也不要为了减少记录数而合并事实。
5.3 实操心得二:不是所有项目都值得上 REA
坦白讲,REA 不是银弹。如果你做的系统是简单 CRUD、不需要审计、不需要多方对账、业务动作几乎没有资源增减,硬套 REA 只会让代码更绕。比如一个内容管理后台,文章只是增删改查,就不需要拆成资源事件参与者。
我的判断标准就三条:
- 是否有明确的经济价值流转(钱、货、积分、点数)
- 是否需要回答"某时间点为什么是这个数据"
- 是否有多个系统、多个角色对同一资源进行操作
三条里命中两条,REA 就是值得投入的建模方向;一条都没命中,老老实实建业务表更高效。
5.4 实操心得三:事件 ID 和业务单号必须分离
做事件流设计时,一定要区分业务单号和事件 ID。业务单号可能有重试、有作废、有合并,比如一个支付单可能对应多笔收款事件。如果把业务单号当主键,后续扩展会非常痛苦。事件 ID 必须是独立的雪花 ID,业务单号只是事件里的一个关联属性。
我第一次做积分系统时偷懒,直接用 order_id 当事件主键,后来同一个订单因为退款产生二次扣减时,直接主键冲突,只能做数据迁移。现在所有事件表一律用自增或雪花 ID 做主键,业务单号走普通索引,再也没出过这种问题。
最后再分享一个小体会:REA 模型第一次接触会觉得抽象,但一旦你顺着"资源-事件-参与者"的视角重新看一遍现有系统的核心表,会有种豁然开朗的感觉。库存、余额、积分这些"状态类数据"全部变成派生数据后,系统的扩展性和可排查性都会明显上一个台阶。如果团队里有人正在为账目对不上发愁,可以试试用 REA 把核心交易链路重新梳理一遍,哪怕不重构代码,光是画几张模型图,也足够帮你发现不少隐藏的设计问题。