做数据建模的时候,见过太多订单、库存、财务各表各说各话的项目了,数据口径对不上,改需求要牵连一堆报表。这次想聊的是一款内部称为“rea”的项目,把一套老旧的进销存系统按Resource-Event-Agent(资源-事件-代理)模型重新梳理了一遍。如果你正在为“业务单据到底怎么设计表结构”“订单和库存之间怎么保持语义一致”“财务和业务数据为什么老是对不上”这类问题头疼,这篇文章应该能给你一个不同角度的参考。全文不贴复杂框架,不引入新中间件,只讲模型思路、表结构落法和实操中踩过的坑。
1. 项目背景与模型选型:为什么从借贷分录表转向REA
1.1 老系统的问题:表很多,口径很乱
接手这套系统时,数据库里已经躺了几百张表。订单表、采购单表、付款单表、收款单表、调拨单表、库存流水表、会计凭证表,应有尽有。但每次对账都像在查案:仓库说库存剩 320 件,财务说账上该有 280 件,销售说已经卖出去 380 件,三个数一对,差出 40 件,没人能说清是损耗、赠品还是录单延迟。
问题出在表结构只记录了“结果”,没记录“原因”。比如库存表只存当前结存数,但结存数是怎么一步步变到今天这个值的,散落在各种流水里;收入表只存客户付了多少钱,但对应的商品出库、订单承诺、退换货,并没有在表结构层面绑死。简单说,数据是齐的,语义是散的。
1.2 REA模型的核心思想:把业务还原成“因果链”
REA 模型不是新东西,本质上一套业务本体论:把一切业务活动拆成资源(Resource)、事件(Event)、代理(Agent)三个原子要素,再用固定的语义规则把它们串起来。
- 资源:有价值、可追踪的东西,比如商品、现金、库存、积分、服务工时。
- 事件:让资源发生增减或转移的业务行为,比如下订单、发货、付款、收货。
- 代理:参与事件的主体,比如客户、供应商、销售员、仓库管理员、公司本身。
模型的价值在于,它不是画一张 ER 图就完了,而是强制你回答三个问题:这个事件消耗了什么资源?这个事件产出了什么资源?谁参与了这个事件?把这三个问题回答清楚,数据之间的因果链自然浮现。原来那种“库存流水凭空少一笔”的抹账操作,在 REA 结构里就过不去,因为每个事件必须和资源、代理关联上。
1.3 选型时要看清的成本与收益
当然,REA 不是一个无脑套用的银弹。它最擅长的场景是“业务过程必须可追溯、资源流动必须可审计”的系统,比如进销存、供应链、财务核算、会员资产。但如果你的需求是一个强流程审批工单系统,状态流转和角色审批才是核心,REA 就偏重了,强行套会绕远。
我在这个项目里的选择是“局部引入”:核心交易链路(采购、销售、库存、资金)全面重构成 REA,外围的报表、审批流、消息通知不强行套用,只从 REA 表上做数据消费。这个折中在后期帮了大忙,既保住了建模一致性,又没有推倒重来的成本。
2. 核心概念拆解:资源、事件、代理到底怎么理解
2.1 资源:别只盯着库存,现金和权证同样算
很多人一听到“资源”就想到商品,这其实是误区。REA 里的资源是“任何价值可以量化、可以在交易中被转换或消耗的东西”。商品、原材料是资源,现金是资源,应收账款算广义资源,甚至一个可兑换的积分、一个在途的物流单,也可以建模成资源。
项目里我习惯先画一张“资源清单”,把系统里所有被业务方当作“有价值对象”的关键词全部列出来,再去重。这一步很有用,因为你会发现业务方口中的“库存”其实包含两个资源:可售库存和暂存库存,前者才参与销售事件,后者只存储不在售。资源定义不清楚,后面所有事件都会打架。
2.2 事件:不是所有单据都是事件
刚开始实操时最容易犯的错,是把业务单据直接当事件。订单、发货单、发票、回执,这些是“载体”,不是“事件”。REA 关心的是事情本质发生了什么,而不是纸面上开了什么单。
举个例子:“客户下了一个订单”,这个事件本质上是“承诺销售”,关系的是一批商品将在未来某个时候出库;“仓库完成拣货并发货”,这是另一个事件“商品出库”;“客户把钱打到账户”,这是“收到现金”。一张订单,在 REA 里往往拆成“销售承诺”“出库”“收现”等多个事件,它们之间靠共享资源和代理形成因果链。
2.3 代理:要把“公司”也看作代理
代理比较好理解,就是“谁干的”“对谁干的”。但有一个点容易被忽略:公司自身也是一个代理。采购事件里,公司是资源的接收方,供应商是资源的提供方;销售事件里,公司是资源的转移方,客户是接收方。如果不把公司实体建模成代理,只记录客户和供应商,那么资金流和货物流的平衡关系就缺乏一个支点。
项目里我们在代理表上加了一个agent_type字段,区分内部代理(员工、部门、公司)和外部代理(客户、供应商、承运商),同时给每个代理加上时间有效区间,避免“员工离职后还挂在历史事件上”的脏数据。
2.4 三条语义规则:让模型自动长出约束力
REA 最有价值的部分其实是三条语义规则,它们不是概念装饰,而是可落地的校验逻辑:
第一条是存量-流转规则:资源永远是增量和减量的累计结果,不允许直接修改存量。库存当前值应该等于所有入库事件之和减所有出库事件之和,一旦存量和流水对不上,系统必须报警。
第二条是控制-职责规则:每个事件必须有一个内部代理负责,同时对应一个外部代理交割。这么做是为了保证“谁来操作”“和谁交割”两个问题一定同时存在,避免事件变成无主资产。
第三条是外部-内部规则:涉及资源转移的事件,必须是外部代理和内部代理成对出现。比如“卖给客户”这个事件,必然同时存在客户代理和公司代理。
这套规则在后期写定时校验和日结对账时,几乎像免费拿到了审计逻辑。我只需要写一套通用校验脚本,跑在事件关联表上,就能把大多数数据问题拦在前面。
3. 表结构落地:从ER图到能跑的DDL
3.1 核心表的划分策略
项目里把表分成了三组:基础资源表、核心事件表、关联关系表。基础资源表存「有什么」,核心事件表存「发生了什么」,关联关系表存「事件消耗/产出了哪些资源、哪些代理参与」。这样做的好处是,资源、事件、代理可以各自演进,不用把几十个业务字段全部堆到一张大事件表里。
资源这边通常有resource主表,加上resource_stock存量表和resource_attr属性扩展表。事件这边有通用的event主表,加上按领域扩展的事件明细表(比如销售事件明细)。关联表则有event_resource和event_agent,承载多对多关系。
3.2 表结构示例:以库存销售为例
下面是一组简化后的 DDL,仅展示核心字段和关联思路,实际落地时记得按业务补索引和审计字段。
-- 资源主表 CREATE TABLE res_resource ( id BIGINT PRIMARY KEY, resource_code VARCHAR(50) NOT NULL, resource_name VARCHAR(200) NOT NULL, unit VARCHAR(20), -- 计量单位 category VARCHAR(50), -- 库存类、现金类、积分类 status TINYINT, -- 有效/停用 created_at DATETIME ); -- 库存存量表:通过事件流水回放累加,但保留物化快照方便查询 CREATE TABLE res_stock ( resource_id BIGINT PRIMARY KEY, current_qty DECIMAL(18,3) NOT NULL, frozen_qty DECIMAL(18,3) DEFAULT 0, updated_at DATETIME ); -- 事件主表 CREATE TABLE evt_event ( id BIGINT PRIMARY KEY, event_code VARCHAR(64) NOT NULL, -- 事件编码:销售、采购、支付、调拨... occurred_at DATETIME NOT NULL, -- 事件发生时间 location VARCHAR(200), -- 仓门店位置 external_ref VARCHAR(100), -- 原始单据号,回追溯源 status TINYINT, -- 草稿/已生效/已作废 created_at DATETIME ); -- 事件-资源关联表:描述资源流入流出 CREATE TABLE rel_event_resource ( event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction TINYINT NOT NULL, -- 1=流入(入库/收款),-1=流出(出库/付款) quantity DECIMAL(18,3) NOT NULL, unit_price DECIMAL(18,2), line_total DECIMAL(18,2), PRIMARY KEY (event_id, resource_id) ); -- 代理表 CREATE TABLE agt_agent ( id BIGINT PRIMARY KEY, agent_code VARCHAR(50) NOT NULL, agent_name VARCHAR(200), agent_type VARCHAR(20), -- INTERNAL/EXTERNAL/COMPANY valid_from DATE, valid_to DATE ); -- 事件-代理关联表 CREATE TABLE rel_event_agent ( event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(30), -- 销售员、客户、供应商、库管员 PRIMARY KEY (event_id, agent_id, role) );这套结构最直观的收益是,新增业务不再靠仿照老表“抄一个订单表”,而是先在资源清单上登记新资源,再定义事件类型,把关联表一挂,业务就通了。上次新增“赠品出库”,没有加一张新表,只在资源表加了赠品类目,再往出库事件里引用一行数据,报表就自动能把赠品成本和正品成本分开。
3.3 关键索引与数据一致性建议
关联表查询频繁,event_resource.resource_id和event_agent.agent_id上一定要建索引。日期范围查询靠event.occurred_at走索引,尽量不要用external_ref做模糊匹配。
一致性方面,存量值不能直接 update,它只是一个物化快照。真实数据源是事件关联表的direction * quantity汇总结果。项目里每天凌晨跑一次回放任务,把前一天的差值重算后更新快照,白天高峰只读快照,这样既保证准确又承担得起性能。
4. 实操过程:一套订单业务如何一步步落成REA
4.1 四步识别法:从业务描述到事件大纲
实际做业务梳理时,我用一套很笨但有效的方法:先把业务人员的原话写下来,再逐句圈关键词。
- 第一步,圈出所有名词,判断哪些是有价值的资源;
- 第二步,圈出所有动词,判断哪些是改变资源的事件;
- 第三步,圈出所有主体(谁发起、谁接收、谁操作),归类为代理;
- 第四步,画关联:哪个事件消耗/生产了哪个资源,哪个代理参与哪个事件。
这个方法的价值在于,业务人员不需要懂数据建模,只要讲“我们怎么干活”,我们就能翻译成模型。
4.2 完整案例:线下会员店的采购到销售闭环
假定门店业务是这样描述的:“我们向供应商采购咖啡豆,付款后供应商发货,豆子到店入库存;客人来店买咖啡,收银员收款,出杯时消耗咖啡豆。”
圈关键词结果:
- 名词:咖啡豆、钱、库存、供应商、门店、客人、收银员。
- 动词:采购、付款、发货、入库、销售出杯、收款。
- 主体:供应商(外部)、门店(公司代理)、客人(外部)、收银员(内部)。
接下来定义事件序列:
- 采购事件:资源流入方向为咖啡豆入库,对应流出方向为公司应付现金增加(应收或应付类资源被确认)。代理为供应商与门店。
- 付款事件:公司现金资源流出,资源流入方向为空,代理为供应商与公司。
- 入库事件:严格说入库是采购事件的结果,而不是独立资源流动。项目里直接用采购事件驱动库存快照增加,配合收货确认状态。
- 销售出杯事件:咖啡豆库存流出,资源流入追加一张“已售商品价值权证”,用来衔接收入确认。
- 收款事件:现金流入,关联的代理是客人与收银员,同时核销销售事件产生的价值权证。
这个序列一旦建好,对账逻辑就完全透明。比如月底对库存,咖啡豆的期初值加上所有入库采购事件数量,减去所有销售出杯事件数量,结果必须等于库存表读数。对收入,现金账户的所有收款事件合计,减去退款事件合计,必须等于财务账面收入。对不上时,不再是无头悬案,直接定位到具体事件和相关代理、资源,很快就能找到是哪笔单子录错了类型。
4.3 实操中要注意的边界:促销、赠品和组合商品
实操里最容易乱的是促销。买一送一时,赠品引出库事件要不要单独建模?组合套餐里多个商品一个价格,怎么分配成本?
我的做法是:赠品强制记为销售事件的一部分,方向为出库,单价记 0,但不能不记。否则库存永远是平的,财务永远对不上。组合商品则先把套餐定义成资源模板,在售前由系统展开成子资源明细,销售事件仍然直接挂子资源的库存消耗。这样做报表侧要稍微做一层聚合展示,但数据底层的因果链不会乱。
促销折扣也一样,不要试图在 REA 框架里“凭空减少资源”。折扣体现在单位售价上,不体现在数量上。比如原价 20 元卖 18 元,销售事件资源数量为 1,单价 18,总价 18。现金收款事件流入 18,和销售事件完美对应,账就平了。
4.4 事件拆分粒度:拆到哪一层才算够
如果事件粒度拆得太粗,比如把“销售订单”整体当一个事件,库存和资金的明细就丢了;拆得太细,比如把“扫码”的每一帧都当事件,系统直接被数据淹没。
我自己的平衡标准是:一个事件的触发点必须能对应上“业务上的一次资源变化”。客户提交订单、仓库发一件货、收款到账、发起退款,都是明确的资源变化点,各自作为一个事件。客户修改订单不影响资源,只影响原事件的状态,不算新事件。用这个标准衡量,方案一般不会跑偏。
5. 常见问题与排查技巧实录
5.1 事件和单据混淆:改了单据状态,库存却不对
初期团队经常把“作废订单”直接 UPDATE 成“已取消”,然后发现库存还是被扣了。原因很简单:销售出库事件已经生效,订单取消并没有自动反向生成一个退货事件。
排查思路是,所有资源变化只能通过事件改变,状态字段不是资源变化的行为。作废订单应该触发两种处理:如果货物没出库,把出库事件置为作废并回退库存;如果货物已出库,就必须再建一个“退货入库”事件。没有第三种抹平方式。
5.2 资源双重身份:同一个东西被建成了两个资源
有一次对账出现 6 元差异,排查很久发现“储值卡余额”被销售部门当成“现金”发起了退款,而财务把它记在“预付余额”里。本质上同一个资金池,被两个资源编码分别统计,数据当然对不上。
解决办法是资源编码统一归口。资源只有来源单一、语义明确的唯一编码。如果业务侧确实需要从多个视角看同一资源,就用视图或标签维度去展示,不要复制资源主数据。
5.3 代理没有生命周期:过期员工还在参与事件
离职员工的账号停用后,历史数据上的代理名称如果直接删除,历史事件会悬空。比较好的实践是物理保留代理行,用valid_to标记失效,不在业务操作里继续选,但历史关联永远有据可查。
5.4 常见问题速查表
| 现象 | 通常原因 | 排查路径 |
|---|---|---|
| 库存快照与流水汇总不一致 | 有人直接改了存量字段 | 查事件回放任务的日志和最近直接 UPDATE 存量表的操作记录 |
| 收款对不上销售 | 收款事件未关联销售事件的价值权证 | 检查收款事件关联的event_resource是否有指向性明细 |
| 作废单库存还挂着 | 只改状态未回退事件 | 查作废单对应事件是否已置为失效并触发反向事件 |
| 同样的商品出现两个编码 | 资源主数据未统一归口 | 全库扫描资源表,按名称和单位聚类去重 |
| 报表金额差 0.01 | 单价精度或分摊精度不一致 | 统一金额精度策略,计价用 decimal,不建议 float |
5.5 避坑心得:不要一开始就追求完美建模
最后想说一个团队里很深的体会。第一版不要用“最正确”的 REA 把每一个事件都设计到终极形态,那样项目会陷在方案里出不来。先挑一条主链路做通,比如销售到收款,把资源、事件、代理各表跑起来,再用存量-流转规则做日结对账。主链路跑顺了,采购、调拨、售后再沿着同样的套路铺开,你会发现后面新增业务模块的时间越来越短,因为模型骨架已经稳定。
这个顺序反过来做,一上来就想把采购销售库存财务一次性全部重构,大概率会死在“事件拆分粒度”的争论里。
6. 收尾:一点个人实践经验
这套 rea 项目做完之后,我最明显的感受是:数据模型真的可以承担业务规则的抽象。以前对账靠人肉解释,现在对账靠模型自洽;以前新增业务要讨论要不要加字段,现在新增业务先登记资源、挂事件,思路清晰很多。
当然它也不是没有代价。比如事件关联表更多的关联查询,牺牲了一部分直查性能;比如业务人员一开始很难理解为什么不能直接改库存,而要去“做事件”。但这些代价换来的是数据长期的一致性和可追溯性,值不值,看项目定位:如果你做的是交易、库存、资金这类“错账成本极高”的系统,REA 大概率不会让你后悔。
最后分享一个小技巧:正式切模型前,拿历史一个月的真实数据做一次回放,把 REA 模型算出的账和旧系统报表逐行比对,差异全部清零后再切换,切换当天的风险会小很多。我们当时卡这个步骤卡了两周,但上线后几乎没有再回过账。