最近在复盘内部代号为“rea”的数据重构项目,核心目标是把原来那套以借贷分录为中心的老旧账务系统,换成基于 REA(Resource-Event-Agent,资源-事件-代理)模型的事件驱动数据底座。这个项目踩了不少坑,也积累了一些实打实的经验,今天整理出来分享给正在做财务系统重构、数据架构升级或者 ERP 产品设计的同学。如果你也想搞清楚“怎么把业务事实留下来,而不是只记一张凭证”,这篇内容应该能帮上忙。
项目做完之后最大的感触是:传统复式记账模型在合规报表上依然不可替代,但作为企业内部的数据底座,它丢掉了大量可以被分析、被追溯、被重放的业务语义。REA 模型则把这些语义重新捡了回来,让我们可以从“事情发生的视角”去组织数据,而不是从“科目加减的视角”去压缩数据。
1. 项目整体设计与建模思路拆解
1.1 为什么放弃传统科目余额表
老系统沿用的是标准的借贷复式记账模型,凭证、科目余额表、明细账三个核心表支撑了所有财务流程。这套模型的好处很明显:经过了几百年的检验,财务人员熟悉,审计也认,报表规则基本不会变。但它有一个很大的问题:数据是以“凭证”为最小单位的,一笔销售业务会被拆成“借:应收账款,贷:主营业务收入”两条分录,业务中真正重要的信息,比如“客户是谁”“卖了哪些商品”“合同对应的付款节点是什么”,全部散落在辅助明细和备注字段里。
这意味着下游的数据仓库、经营分析、风控系统每次取数,都要靠人工去理解凭证摘要,然后写大量清洗脚本去还原业务事实。数据团队经常要问财务“这笔收入到底是哪个产品的”,财务也说不清楚,因为凭证上只有科目和金额。REA 模型正好击中这个痛点:它先记录“发生了什么”,再通过规则映射到科目和报表,业务事实不需要被压扁。
1.2 REA 核心三维度
REA 模型最早是在会计信息系统领域提出的,核心就三个概念。
资源(Resource)是指企业能够控制并且有经济价值的对象,比如现金、库存商品、固定资产、服务工时。事件(Event)是指资源流入或流出企业的经济活动,比如采购入库、销售出库、收款、付款。代理(Agent)是指参与事件的个人、部门或外部机构,比如客户、供应商、员工。其中内部代理负责具体操作,外部代理代表资源交换的另一方。
用一个生活化的例子来类比:你去便利店买饮料,饮料是资源,你扫码付款这个动作是事件,你和收银员都是代理。收银员作为内部代理,他手中的扫码枪记录了事件;你作为外部代理,用自己的账户完成了资源流出。REA 模型就是要把这个场景化成数据模型,而不是只记“银行存款减少、主营业务收入增加”。
1.3 项目范围与建模边界
这个项目代号“rea”,没有特殊含义,就是 Resource-Event-Agent 三个单词首字母的组合。我们当时把范围限定在两个端到端链路:采购到付款、销售到收款。选择这两个链路是因为它们覆盖了企业最核心的资源流:存货和资金,而且老系统的历史数据问题也集中在这里。
项目目标不是推翻会计系统,也不是把总账干掉,而是在会计系统旁边建一套事件明细底座。上游业务系统通过消息队列向底座投递事件,底座负责存储、关联、校验,下游报表和分析平台直接从底座取数。这样一来,总账该怎么做还怎么做,但底层数据已经变成结构化事件流,不再依赖人工解读。
2. 核心建模细节与实操要点
2.1 资源识别:不要一上来就把“科目”当资源
建模时最容易犯的错,是把自己熟悉的会计科目直接映射成资源。比如把“应收账款”当成一个资源,但应收账款并不是真实存在的可用对象,它是“销售事件发生但钱还没到”的派生状态。如果把状态当资源建模,后面的对账和追溯一定会乱。
正确的做法是回到业务现场,只把那些能被控制、能在企业内外转移、价值可计量的对象作为资源。我们最终确认的资源只有四类:库存商品、现金(包括银行存款)、服务工时、预付/应付形成的合同权益。每个资源都必须有一个唯一标识,比如商品条码、资金账户号,不能只靠名称区分。
这里还有一个细节:同一资源在不同事件中的价值口径可能不同。采购入库时用实际成本,销售出库时可能用移动加权平均成本。这个差异不要塞进资源表,而是应该作为事件属性存到事件关联表里,保留“当时那一刻的成本”,这样后续成本还原才有依据。
2.2 事件建模:用不可变事实,不用状态表单
事件是整个 REA 模型的核心,也是最容易设计偏的地方。很多同学会把“订单状态”当成事件,比如订单表里加一个 status 字段,从“待付款”改成“已付款”。但状态字段是变化的、可覆盖的,丢失了历史轨迹,而且会破坏事件的事实性。
我们的做法是事件只追加、不修改。业务上每一件已经发生的经济事实,在事件表里就是一行记录,事件类型由事件注册表统一维护。比如“销售订单确认”是一个承诺事件,“销售出库”是一个实际事件,“退货”是另一个实际事件,它们各自独立存在,谁也不是谁的字段值。
如果用户取消了一张销售订单怎么办?不是把订单状态改成“已取消”,而是新增一条“销售订单取消”事件,通过关联字段指向原来的订单事件。查询时用事件流重放,就能知道这笔订单经历了什么。虽然存储上比状态字段多了一些行,但换回来的是完整的审计轨迹和无限可重算能力。
2.3 代理角色与内外边界
代理建模相对直观,但角色边界一定要理清。我们为每个代理建了唯一代理表,同时维护一张“代理角色映射表”,因为同一个实体可能同时以多个身份出现。比如某个公司既是你供应商又是你客户,它在代理表中只有一条主记录,但通过角色映射可以同时关联到“供应商组”和“客户组”。
内部代理和外部代理必须分开记录。内部代理通常关联员工、部门、仓库、门店;外部代理关联客户、供应商、承运商。每一个事件必须至少包含一个内部代理和一个外部代理,否则这个事件不完整。这个约束不是技术强加,而是业务事实要求:任何资源流入或流出都一定有内外部两方参与,否则就是一笔没有责任方的糊涂账。
2.4 基数约束与业务规则
REA 模型中的关系基数决定了模型能表达多少真实业务。我们建表时主要关注三组关系:事件与资源、事件与代理、承诺事件与实际事件。每一个事件至少关联一个资源、一个内部代理、一个外部代理。同一个事件也可以关联多个资源,比如一张销售出库单包含多种商品,这时需要关系表来承载数量和金额。
承诺事件与实际事件的关联在基数上更容易出错。一张采购订单可以分多次收货,所以订单和收货是一对多;一张收货单又可能对应多张订单,这在现实中不常见,但在退货和换货场景里就会出现。如果模型只支持一对一,业务稍微一复杂就要打补丁。我们用关系表加“链接类型”字段来支持这些多态关系,每种链接类型单独维护基数规则。
3. 实操过程:从数据模型到系统落地
3.1 场景拆解:采购到付款
我们以采购到付款链路为例,说明整个落地过程。这个链路涉及的主要资源是库存商品和现金。外部代理是供应商,内部代理是采购员、仓库管理员、应付会计。事件按顺序拆成四类:采购订单确认、采购收货、发票校验、付款。
采购订单确认是一个承诺事件,它承诺未来会有一批资源流入,并不实际产生库存变化。采购收货是实际事件,库存资源真正流入,需要记录收货数量、单价、批次号。发票校验是介于承诺和实际之间的控制事件,它确认这笔收货对应的债务关系。付款是资金资源流出事件,它和发票校验、采购收货一起形成完整的业务闭环。
四个事件不是孤立存在的,它们通过关联关系串成一条链路。采购收货指向采购订单,发票校验指向采购收货和采购订单,付款指向发票校验。这样任何一环出问题,都能沿着事件关系追根溯源。
3.2 落地步骤
第一步,建立资源表和代理表。资源表和代理表都是主数据表,重点做好唯一性校验和生命周期管理。第二步,建立事件类型注册表。这个表里维护所有事件的编码、名称、涉及的资源类别、内外代理要求、对应的会计科目映射规则。第三步,设计事件表和关系表。事件表只存事件基本信息,比如事件类型、发生时间、来源单号;关系表存事件与资源、代理的具体关联,以及数量和金额。
第四步,在应用层写好事件写入接口。这个接口必须保证事件和关系在同一事务内写入。我们一开始曾试图用触发器同步老系统表单,但触发器很难表达复杂业务规则,后来改成了业务服务显式发布事件,可靠性明显提升。第五步,创建物化视图用于余额和报表查询。物化视图不是直接映射表,而是通过汇总事件动态生成结果,后面会详细说。
3.3 关键转换:把 REA 数据变成财务报表
REA 模型本身不直接产生借贷分录,它需要通过规则引擎转换成会计科目余额。转换思路很简单:定义每个事件类型对应的借方科目、贷方科目、金额方向,然后把事件累加起来。
下面是一个库存余额计算的简化 SQL 示例,基于常见的关系数据库。
SELECT r.resource_name, SUM(CASE WHEN el.flow_type = 'INFLOW' THEN el.amount ELSE -el.amount END) AS stock_balance FROM evt_event e JOIN evt_resource_link el ON e.event_id = el.event_id JOIN res_resource r ON el.resource_id = r.resource_id WHERE e.event_type IN ('PURCHASE_RECEIPT', 'SALE_ISSUE', 'RETURN_RECEIPT', 'WASTE_WRITE_OFF') GROUP BY r.resource_name;这个查询直接从事件表汇总资源流入流出,而不是读取一个“库存余额”字段。好处是任何时候都能重算,坏处是如果历史事件量巨大,查询会慢。我们最终的解决方案是构建“每月事件快照表”,把截止每月末的余额固化下来,当天的实时余额只增量计算当天事件,这样既保证实时性又避免全表扫描。
3.4 数据迁移与回算
迁移是最折磨人的环节。老系统只有凭证和余额,没有完整事件,必须把凭证“逆向还原”成事件。我们的做法分三步。
第一步,从凭证摘要、原始单据号、辅助核算项里尽可能提取业务要素。第二步,把能拆分的一借多贷凭证按业务单号拆分,生成临时事件表。第三步,用临时事件表和新系统上线的期初余额做对账,核对库存总金额和应收应付余额是否一致。
这里特别提醒:老系统经常出现“多单合一”的凭证,比如三笔销售合并记一张凭证。这时不能简单按金额拆分,必须回到原始销售单据去匹配,否则事件数量、客户归属都会错。迁移中最容易出的问题不是金额不平,而是事件数量与业务单据对不上。
4. 常见问题与排查技巧实录
4.1 事件被更新:不可变原则被破坏
项目上线后不久,数据团队就发现某些销售事件的金额变了,原因是有个同事在图方便直接在事件表上执行了 update。这在短时间内不影响报表,但审计追溯时历史数据就对不上了。
解决办法是在数据库层面禁止对事件表做 update,只允许 insert。我们把事件表权限设置成应用账号仅可插入,修改只能通过新增变更事件完成。同时在代码评审规范里明确规定:任何业务调整都对应一个新事件,而不是修改旧事件。
4.2 派生数据与性能问题
查询余额越来越慢,一开始我们对所有物化视图每分钟刷新一次,结果数据库负载很高,报表仍然卡。后来想明白一个道理:REA 模型存储的是事件,余额是派生数据,不需要做到秒级一致。
我们改成按自然日刷新日结快照,同时保留上次快照之后未汇总的事件。查询时把“上月快照 + 本月事件增量”合并计算,性能提升了近十倍,数据准确性也没有下降。这条经验后来也被用到应收应付、库存账龄等场景。
4.3 与财务同事沟通难点
项目中后期最大的阻力不是技术,而是沟通。财务团队习惯用“科目”“凭证”“余额”说话,一听到“事件”“代理”“关系表”就皱眉头。后来我们把 REA 包装成“业务流水”:外部代理就是往来单位,内部代理就是经办人,资源就是商品和账户。财务只需要看一张“流水大表”,复杂建模细节全部隐藏。
这个视角转换非常重要。建模再怎么严谨,如果业务方看不见价值,项目就很难拿到持续支持。我们最终通过一个“业务流水查询界面”让财务自己按客户、按商品、按时间段去查历史事件,他们很快就理解了。
4.4 避坑清单速查表
| 问题 | 根因 | 解决方案 |
|---|---|---|
| 余额对不上 | 把状态当字段直接改 | 事件表只插入,变更新增事件 |
| 订单多对多导致重复 | 关系基数没定好 | 用关系表加链接类型,完善基数校验 |
| 报表全表扫描很慢 | 对明细事件做聚合 | 用快照加增量事件计算派生数据 |
| 迁移后单据数量不符 | 多单合一被强行拆分 | 回到原始单据逐笔匹配 |
| 财务看不懂模型 | 直接展示概念模型 | 包装成“业务流水”,下沉技术细节 |
| 付款与收货无法对应 | 缺少承诺-实际事件关联 | 建立订单、收货、发票、付款的链路关系 |
这个项目做完,我个人最大的体会是:REA 不是银弹,它不会自动生成报表,也不会自动让财务流程变规范,但它提供了一套极其清晰的语言,让业务、财务和技术团队能够根据同一个“事实底座”讨论问题。过去我们花了一半时间解释“业务数据和财务数据为什么对不上”,现在大家直接看事件流,差异在哪里一目了然。
如果在建模早期能拉上业务和数据团队一起过事件清单,把核心链路的边界一次性定清楚,会少走很多弯路。还有一点值得反复强调:事件数据的核心原则就是“只追加、不修改、可重放”,谁破坏了这条铁律,后续的数据诊断成本就会成倍上升。