说实话,接到一个只有“rea”三个字母的需求时,我整个人是懵的。没有后文,没有标注,也没有任何上下文提示。排查了信箱、部署记录和会议纪要之后,确定这是一个项目代号——但也仅仅是一个代号。那天晚上我顺着“rea”往下挖,最后锁定了企业信息系统领域一个我非常熟悉的缩写:Resource-Event-Agent,资源-事件-参与者模型。恰好我正在负责一个进销存模块的重构,之前一直对“余额表+流水账”的老设计不满意,于是索性把这个含糊的代号当成一次试验:用REA模型把业务底层重新梳理一遍。这篇文章就是这次实践的完整复盘,内容包括REA模型的本质、表结构设计、服务端落地、踩过的坑,以及这套模式能扩展到的其他场景。适合后端开发、业务架构师,以及所有被业务报表折腾到头秃的同事。
1. 一个模棱两可的代号,为什么最后落地成一整套建模框架
1.1 “rea”只是一个引子,真正的主角是Resource-Event-Agent
先别急着笑话这个标题。很多时候,含糊的需求反而会逼你跳出“照着老代码改”的惯性。“rea”这个代号我查了两天,最终认定它其实是某个系统内部约定的缩写——在信息系统设计领域,REA模型是会计信息系统和业务建模中一个非常经典的理论框架。
它的基本思想不复杂:许多业务系统习惯记录“现在的状态”,而REA模型要求记录“发生过的事实”。
- Resource:资源。任何有价值的东西,比如库存的商品、现金、应收账款、服务工时。
- Event:事件。发生在资源上的业务动作,比如采购、销售、生产、收款、付款。
- Agent:参与者。事件的责任主体,比如顾客、供应商、内部员工。
把三者连起来,业务就变成一条条可以追溯的事件链:供应商把面粉送进来,仓库收下,面包师傅把它烤成面包,收银员把面包卖给顾客,顾客之后再付款。每一步都是一条明确的事件记录,而不是在若干张余额表上打几个补丁。这就是我想要的东西。
1.2 传统的“余额表+流水账”为什么会在复杂业务里越走越窄
团队之前的设计其实很典型:一张库存表存当前数量,一张订单表存销售记录,一张收款表存到账金额。每卖出一单,代码里要同时更新三个地方。逻辑全写在函数里,看起来没什么问题,可业务一丰富就露馅了:
- 订单改了状态,但库存还没扣,系统重启了;
- 货发到一半,客户退款,这时是应该加库存还是减订单;
- 财务要查“这个顾客上一季度到底欠了多少钱”,只能靠拼接几个表后再算,很容易出差错。
问题的根源在于,我们保存的是“结果”,而不是“过程”。结果可以被随意写坏,而且当过程不完整时,结果到底是怎么来的,没人说得清楚。
REA模型把事件当作基础,资源余额只是一种可以被实时计算的“投影”。任何一条余额数据都能追溯到一系列事件,业务正确性就不依赖“某段代码碰巧更新了某张表”,而是依赖“你能否记录下产生这个结果的全部事件”。
2. REA模型的三个构件,以及它们如何把“买了几个面包”变成一条可审计的真相链
这里我用一个具体的面包工坊做例子。这个工坊每天采购面粉,烤出面包,卖给周边的顾客,有的顾客是当场现金支付,有的则是允许先拿货再付钱。简单吧?但就这一个场景,已经能覆盖REA模型的大部分复杂度。
2.1 资源:不只有实物才算“有价值之物”
资源是REA模型里最容易被误解的词。一说“Resource”,很多人第一反应是仓库里的原料和成品。但按REA的定义,只要有价值、能被追踪、能在事件里发生增减的,都是资源:
- 实物资源:面粉、酵母、烤好的面包;
- 货币资源:现金、银行存款;
- 权利类资源:应收账款,也就是“向某顾客索取付款的权利”;
- 义务类资源:应付账款,也就是“必须向供应商付款的义务”;
- 服务类资源:烘焙师傅的一小时工时。
注意,这里的关键是:价值是外显的,一个类似“应收款”的东西虽然看不见摸不着,但它确实会在销售事件后凭空增加,也会在收款事件后消失。如果一开始只觉得“面包”和“现金”是资源,后面做起客户欠款分析就会一直别扭。
2.2 事件:业务动作的原子事实
事件是REA的核心单元。一次采购、一次生产、一次销售、一次收款,都是事件。事件有几个特点:
- 它发生在某个时间点;
- 它至少影响一个资源;
- 它一定涉及至少一个参与者;
- 它经常与另一个事件构成配对,比如“销售”与“收款”配对。
面包工坊里的“烘烤”事件就是最好的例子。一次烘烤消耗了10公斤面粉,消耗了2小时人工服务,同时产出了50个面包。在传统表设计里,你要分别去改“面粉库存”“工时表”“面包库存”,而在REA模型里,你只需要插入一条“烘烤”事件,并记录它对哪些资源产生了多少流入和流出。
这里我越来越感觉到一件事:事件模型天然适合描述真实世界,因为你不需要预先决定“收入”和“成本”的科目归属,这些都可以根据事件流后面的规则推导出来。
2.3 参与者:事件的责任方是谁
参与者因人而异。在面包工坊里:
- 买家:买面包的顾客;
- 卖家:收银员或店长;
- 体力劳动者:面包师傅;
- 供应商:面粉供应商。
一个事件可以有多个参与者。比如一笔销售订单,经手人是收银员,审批人是店长,买方是顾客。设计时要避免把参与者随意塞进一个叫“操作人”的文本字段里。正确的做法是单独建立事件与参与者的多对多关联表,并且在关联上记录角色。
否则等你某天想做“按员工统计业绩”或“看哪个顾客买得最多”时,你会发现自己根本没法用SQL进行规范的筛选。
2.4 三条基本链约束是模型的自洽基础
REA不是把三张表随便连起来就算完,它有很严格的关系约束:
- 资源与事件:事件决定资源流动,资源的流入或流出必须挂在某个事件下;
- 事件与参与者:每个事件必须有明确的责任人;
- 事件与事件:一个交换事件会对应另一个交换事件。比如“销售事件”对应“收款事件”,“采购事件”对应“付款事件”。
这些约束才是REA模型真正值钱的地方。传统系统里,业务规则要靠程序员在service层写一堆if-else,而在REA模型里,表的关联结构本身就强制你遵守这些业务规则。如果你试着插入一笔“应收款增加”却没有对应的“销售事件”,数据库整个关联结构就会看着很别扭,你也会被迫回头审视到底漏了什么。
3. 用一套面包工坊订单场景,把REA从概念落成数据库表
3.1 先识别场景中的REA实例
做表结构之前,我先把面包工坊的日常业务列了一遍,并按REA的三个构件分类。
| 构件 | 实例 | 说明 |
|---|---|---|
| 资源 | 面粉 | 原材料库存,按公斤计 |
| 资源 | 面包 | 成品库存,按个计 |
| 资源 | 现金 | 收款收到的现钞,按元计 |
| 资源 | 应收账款 | 顾客赊账后产生的收款权利,按元计 |
| 资源 | 应付账款 | 欠供应商的货款,按元计 |
| 事件 | 采购 | 向供应商进货,货物流入 |
| 事件 | 付款 | 给供应商钱,现金流出 |
| 事件 | 烘烤 | 消耗面粉和工时,产出面包 |
| 事件 | 销售 | 面包流出,应收账款或现金流入 |
| 事件 | 收款 | 应收账款减少,现金增加 |
| 参与者 | 供应商、顾客、师傅、收银员、店长 | 不同事件负责不同角色 |
做完这一步,我发现所有业务场景都逃不出资源和事件的组合。采购是资源流入,付款是资源流出,销售是有参与者的交换事件。当你看什么都像“资源加事件”时,说明你已经在用REA的方式思考了。
3.2 反例设计:为什么Event-Resource中间表不能省
我也知道很多人第一反应是“搞这么复杂干嘛,直接一张流水表不行吗”。我一开始还真试过。当时的表结构大致是:
create table sale ( id bigint primary key, customer_id bigint, product_id bigint, quantity int, amount numeric(12,2), paid_amount numeric(12,2) ); create table payment ( id bigint primary key, sale_id bigint, amount numeric(12,2), pay_time timestamp ); create table inventory ( product_id bigint primary key, current_quantity int );这个方案在小门店模式下确实跑得通,但业务人员很快提出了几个需求:要按订单行分别查收入;要查某段时间内面包退货产生的库存回补;要按客户看应收账龄;还要看面包师傅“烤废了多少斤面粉”。这些需求一上来,我就发现自己需要在sale表里加一堆冗余字段,比如product_price_normal、product_price_discount、is_returned,而inventory表也被迫加一个called“变动原因”的文本字段。整个表结构变成了一个大杂烩,任何报表都变得异常痛苦。
3.3 基于REA的PostgreSQL结构化设计
于是我开始按REA的思路重新设计,核心是几张主表和中间表。下面这版结构经过简化,但保留了完整语义:
create table resource ( id bigserial primary key, name varchar(100) not null, resource_type varchar(30) not null, unit varchar(20) not null ); create table agent ( id bigserial primary key, name varchar(100) not null, agent_type varchar(30) not null, contact varchar(200) ); create table event ( id bigserial primary key, event_type varchar(30) not null, event_time timestamp not null, description text ); create table event_resource_flow ( id bigserial primary key, event_id bigint not null references event(id), resource_id bigint not null references resource(id), direction smallint not null check (direction in (1, -1)), quantity numeric(12,2) not null, amount numeric(12,2) 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(30) not null );几点说明:
- 每个资源都可以有原子单位。面粉的unit是kg,面包的unit是个,现金的unit是元。
- event_resource_flow 里同时有 quantity 和 amount。quantity 记录数量,amount 记录金额,因为在面包工坊这种场景里,进货单价和销售单价不一样,而且同一个面包可以因为折扣变动而金额不同。
- direction 取值只能是 1 或 -1。1 表示资源增加,-1 表示资源减少。
这个模型里最“先进”的地方在于:它没有像inventory表那样存“当前库存在哪个仓库里”,而是通过实时汇总event_resource_flow得到当前库存。你问“现在还有多少面包”,本质是“历史所有增加面包的事件,减掉历史所有减少面包的事件”,余额纯粹是推导出来的,自然也不可能出现“减库存减多了”这种问题。
3.4 用一次销售事件走一遍完整写入流程
假设顾客A用赊账方式买了2个面包,每个5元,没有收款。我可以把这次销售事件拆成这样:
- 创建销售事件:event_type='SALE',event_time=now;
- 确认面包资源存在,并检查可用数量;
- 在event_resource_flow里插入两行:
- 面包资源,direction=-1,quantity=2,amount=-10;
- 应收账款资源,direction=1,quantity=1,amount=10;
- 在event_agent里插入两行:
- 顾客A,role='BUYER';
- 收银员,role='SELLER'。
看到这一步你很可能会问:“应收账款是一个资源,它在表里怎么表示?”对,我建议为每个客户动态创建一个“客户应收款资源”,资源名称就叫“A顾客应收款”,资源类型是'RECEIVABLE'。它本质上代表了“向顾客A收钱的权利”。这样当顾客A后来拿着现金来还款时,我可以再创建一个“收款”事件,这个事件会让“A顾客应收款”资源的余额减少10,同时让“门店现金”资源的余额增加10。
正因为事件记录的是流,而不是余额,所以任何人都能看懂这笔业务:销售事件把面包流出去、把钱权流进来;收款事件再把钱权流出去、真钱流进来。一条链,清清楚楚。
4. 服务端代码落地:我用事务和约束把REA模型焊进业务逻辑
4.1 为什么不直接“库存减一”,而要写事件流
项目组里有个同事一开始很不理解:“我直接写一条update库存减一,再insert一条交易流水,不是一样吗?你这么搞,一条交易要插入三张表,不慢吗?”
我当时的回答是:在业务量没有达到几万笔并发的时候,性能根本不是瓶颈。真正的瓶颈是你有没有办法在三天以后,准确地回答“这批库存为什么会变成这个数”。REA模型把“业务日志”和“状态”合一了,你不需要再用定时任务去对账,因为根本没有地方可以对错。
还有一层原因:当你用事件流作为唯一业务入口时,任何底层误操作都会被约束挡住。比如如果某位DBA同学手误把库存表数量改了,传统系统毫无知觉,过几天报表错得一塌糊涂才被发现。而REA模型下,库存不存在,只有事件流,胡乱改动事件流会影响所有后置计算,很快在测试环境就会被发现。
4.2 一次带事务的销售服务方法
我拿Python+SQLAlchemy风格的伪代码展示一个完整销售事件的处理逻辑。真实项目里还加了分布式事务和消息队列,但核心就是这样一个函数:
def create_sale(customer_id, product_resource_id, qty, unit_price, seller_id): with db.transaction(): # 1. 检查并锁定产品资源 product = get_resource_for_update(product_resource_id) current_stock = get_resource_balance(product_resource_id) if current_stock < qty: raise InsufficientStockError( f"库存不足:已有{current_stock},需要{qty}" ) # 2. 插入销售事件 sale_event = insert_event( event_type="SALE", event_time=datetime.now(timezone.utc), description=f"销售给客户{customer_id}" ) # 3. 插入两条资源流 insert_flow( event_id=sale_event.id, resource_id=product_resource_id, direction=-1, quantity=qty, amount=-qty * unit_price ) receivable = get_or_create_receivable_resource(customer_id) insert_flow( event_id=sale_event.id, resource_id=receivable.id, direction=1, quantity=1, amount=qty * unit_price ) # 4. 插入参与者和角色 insert_event_agent(sale_event.id, customer_id, "BUYER") insert_event_agent(sale_event.id, seller_id, "SELLER") # 5. 返回事件id,便于下游环节引用 return sale_event.id这段代码看起来简单,但有两个关键细节:
- 第一个细节是
get_resource_for_update。它会对产品资源行加锁,避免两个并发销售同时扣同一批库存。REA模型虽然不直接存余额,但求和资源流也需要在事务里保证并发一致性,这一点不能偷懒。 - 第二个细节是
get_resource_balance不是查表,而是一个聚合查询:
因此在翻译服务时,这个函数应使用数据库的实时聚合,而不是从某个“余额字段”读值。select coalesce(sum(quantity * direction), 0) from event_resource_flow where resource_id = :resource_id;
4.3 数据库层为什么要写约束和触发器
业务代码写的逻辑只是第一道防线。我更看重数据库层面的完整性约束,因为业务代码可能被后续迭代污染,数据库约束却很难被绕过。
我建议大家至少加上两条约束:
- 方向约束:在
event_resource_flow表上定义check (direction in (1,-1)),防止有人写出0或其他数字; - 余额非负约束:用触发器检查在插入或更新事件资源流之后,每个资源实时余额不能为负数。
下面是一个简化的触发器示例,用于在销售事件写入前校验库存:
create function check_resource_balance() returns trigger language plpgsql as $$ declare current_balance numeric; begin select coalesce(sum(quantity * direction), 0) into current_balance from event_resource_flow where resource_id = new.resource_id; if current_balance < 0 then raise exception 'resource balance cannot be negative, resource_id=%', new.resource_id; end if; return new; end; $$; create trigger trg_check_resource_balance before insert or update on event_resource_flow for each row execute function check_resource_balance();这个触发器不是银弹,比如它无法阻止多行共同导致余额为负的情况,但在绝大多数简单校验场景里非常有效。如果你想做到完美,可以把这个逻辑放进事务里用“读已提交”模式,或者在业务代码里结合行锁。
我还会给事件表加上event_time not null约束,并要求后端统一传入服务端时间,而不是接收客户端时间。原因很简单:客户端时间可以被用户篡改,会让历史事件链错乱。服务端时间才可信。
4.4 联表查询的便利性
一旦事件流建好,很多之前复杂的统计就变成了简单的SQL。比如统计某个月的收入,可以直接去找所有类型为'SALE'的事件,再联查event_resource_flow里针对“应收账款/现金”资源的正向流水:
select e.id as event_id, e.event_time, sum(erf.amount) as sale_amount from event e join event_resource_flow erf on erf.event_id = e.id where e.event_type = 'SALE' and e.event_time between :month_start and :month_end and erf.resource_id in ( select id from resource where resource_type in ('CASH', 'RECEIVABLE') ) group by e.id, e.event_time;这种查询方式在所有资源类型下都是统一的。想查产值?查“烘烤”事件流中成品增加。想查耗材?查“烘烤”事件流中面粉减少。报表位置已经完全不是“一张专门的表”,而是一个通用的事件读取接口。
5. 我在实际落地中踩过的四个坑,每一个都差点让库存对不上账
5.1 坑一:把“现金”和“收款权利”混为同一个资源
我第一次做“销售后收款”业务时,图省事,直接让销售事件产生“现金+10”,然后哪天真收到现金时再让现金资源不变。结果没过多久,应收账款没有任何去处,客户欠款对不上账。
后来才意识到问题:销售发生时,你获得的是“收款的权利”,而不是“现金本身”。在REA模型里,这两种资源必须分开。正确的做法是:
- 销售事件:面包 -2,客户应收款 +10;
- 后续收款事件:客户应收款 -10,现金 +10。
只有把这两个事件都记录好,才能回答“这个客户是否已经还钱”。
5.2 坑二:为了“简单”省略了中间表,把事件和资源直接放在同一张表里
我一度觉得event_resource_flow本身也是一张中间表,完全可以合并到event表里:每个事件只允许关联一个资源行。结果做“烘烤”事件时露馅了——烘烤同时消耗面粉、消耗工时、产出面包,至少三行资源变动,如果把它们并入event表,要么拆成三个事件,要么塞JSON字段。拆成三个事件会破坏原子性,塞JSON又没法做SQL聚合。
最后还是老老实实回到event_resource_flow。这类中间表看起来啰嗦,却是保证REA模型灵活性的关键。
5.3 坑三:没有做事件配对,导致采购和付款脱节
REA模型强调事件与事件之间的配对。但在早期设计中,我没有一个表记录“这笔付款付的是哪笔采购”。结果月底财务发现应付账款余额是零,但采购总额和付款总额根本对不上,原因是有一笔采购还没付款,而另一笔付款打错了采购单。
我后来加了事件关系表:
create table event_link ( id bigserial primary key, from_event_id bigint not null references event(id), to_event_id bigint not null references event(id), link_type varchar(30) not null );销售和后续收款建立link_type='SETTLE'的关系,采购和付款建立link_type='PAY_FOR'的关系。同时写一个定时任务,找出“已经超过30天但没有配对的收款事件”,暴露给财务人员。这件事之后,团队成员开始真正理解REA为什么强调“事件链”而不是“状态快照”。
5.4 坑四:用负数表示反向流水,方向语义被搞混
一开始我在event_resource_flow里记录销售时,用的是“面包资源,quantity=-2,amount=-10”。数据库里看着没问题,可写退货逻辑时,我发现自己不知道“-2”到底表示“退货产生负销售”,还是“之前那一单卖少了”。方向语义一旦模糊,所有报表口径都会跟着乱。
于是我把direction从“正负”改成了“flow_type”,用'IN'和'OUT'表示资源流入和流出。数量永远为正,由flow_type决定增减:
create table event_resource_flow ( event_id bigint not null references event(id), resource_id bigint not null references resource(id), flow_type varchar(10) not null check (flow_type in ('IN', 'OUT')), quantity numeric(12,2) not null check (quantity >= 0), amount numeric(12,2) not null );这个设计看着多了一个字段,却让各种事件都变得非常清晰:销售事件是“面包OUT + 应收IN”,收货事件是“现金IN + 应收OUT”,采购事件是“面粉IN + 应付OUT”。所有行为一致,聚合时使用case when flow_type = 'IN' then quantity else -quantity end来计算余额,规则统一且不容易误解。
6. 从库存销售再往前一步:REA模型能带我们走到哪里
6.1 随时随地推演出利润表和资产负债表
项目上线后,管理层最常问的问题是“这个月赚了多少钱”。传统系统里做月度结账是一个很重的工作,要加班对数据。但在REA模型下,“利润”不是一张表,而是一段可以实时计算的事件序列:
- 收入 = 销售事件中“应收/现金”资源流入的金额;
- 成本 = 销售事件中“商品”资源流出的成本金额;
- 利润 = 收入 - 成本。
想要看资产负债也很简单:
- 资产 = 所有资源类型的实时余额总和;
- 负债 = 应付账款资源的余额;
- 权益 = 资产 - 负债。
因为这些余额全部由事件流推导,所以即使今天是月中,业务报表都能准确给出结果。月末不再需要“对账”,只需要把当天的事件快照固定下来,形成一份历史审计底稿。
6.2 扩展到采购、生产、人力资源、预算等多场景
用REA建模的价值在于通用性。我后来又把面包工坊的模型扩展到了员工考勤和绩效:
- 员工工时是一种“服务资源”;
- 排班和打卡是“员工提供工时”的事件;
- 发工资是“现金流出 + 应付减少”的事件;
- 报销是“现金流出 + 员工应收减少”的事件。
预算也一样:预算额度是一种虚拟的资源,每次批准采购时,先检查预算资源余额是否够用,再创建采购事件。这样预算、执行、分析全部共用一套事件流,部门之间的口径不再有分歧。
6.3 什么时候不适合用REA,这个取舍要说清楚
当然,REA不是万能药。如果你的系统只是一个简单demo,或者数据量小到不需要追溯,那引入这个模型会让你前期开发变得繁琐。我自己的判断标准是:
- 资源数量是否超过3个;
- 事件是否需要多对多关联;
- 是否经常需要回答“某笔数据是怎么来的”;
- 是否有多方参与者,比如审核、审批、售后。
只要其中两个回答为“是”,REA模型带来的长期收益就远大于前期的建模成本。反过来,如果你的业务就是一个目录展示,连订单状态都没有,那还是别折腾了。
最后再分享一个个人体会。完成这次重构后,我最大的感受不是报表更快,而是当业务同事再问“这个月采购金额怎么比入库金额高”时,我可以直接打开事件链给他看:哪几笔采购还没有对应的付款事件,哪个供应商的应付账龄已经超期。这种“打开账本就是证据”的踏实感,是任何缓存和索引都替换不了的。如果你也遇到一个含糊的代号,或者你正被进销存和财务系统的改造成本折磨,不妨试着回归到“资源、事件、参与者”这三个词上。真正把业务拆到这三层,很多复杂度自然就消解了。