做业务系统设计这些年,我经手过不少库存、订单、财务对账相关的项目,有一个特别明显的感触:很多系统的数据模型一开始就建歪了,后面再怎么打补丁都别扭。最近我给自己整了一个代号叫“rea”的业余项目,用来试验一套更贴合业务事实的建模方式。这里的“rea”其实就是 Resource-Event-Agent 模型的简称,三个单词分别是资源、事件、代理。这套思路把业务活动拆成“什么东西、发生了什么、谁参与”,让系统数据结构跟真实世界的业务动作一一对应,而不是一上来就堆一堆会计科目或者下单、审核、记账这类孤立单据。如果你正在设计进销存、订单中心或者电商后台,被多套系统之间的数据口径不一致搞得头大,那这篇内容应该对你有点用。我会把这套模型的原理、表结构设计、实际落地步骤和常见坑都讲一遍,尽量用能直接抄作业的方式来讲。
1. 为什么我用“rea”这个代号来启动数据建模
1.1 rea 到底是什么
REA 是一个很经典的企业数据建模框架,最早来自会计信息系统领域,但你别被“会计”两个字劝退,它本质上解决的是“怎么描述业务事实”的问题。它把业务世界抽象成三类对象:
- 资源:有经济价值、可以被量化、在系统里流动的东西。比如现金、商品、原材料、服务工时,甚至某种优惠券额度。
- 事件:让资源发生增减变化的动作。比如采购入库、销售出库、付款、收款、内部领用、盘点调整。
- 代理:参与事件的人或组织。比如客户、供应商、仓库管理员、销售人员、部门、门店。
你会发现这个分类特别朴素,几乎不需要什么行业背景就能理解。但这恰恰是它的优点:因为贴近业务直觉,所以后续无论是做权限隔离、数据审计还是经营分析,都能从同一个底层模型出发,而不是为了每个需求重新解释一遍“这个字段是什么意思”。
我起名“rea”的时候偷了个懒,直接取了 Resource、Event、Agent 三个词的首字母。后来项目越做越顺,这个名字反而成了提醒:每次加表、加字段之前,先问一句“这是资源、是事件,还是代理?”,如果哪个都归不进去,往往说明建模思路又跑偏了。
1.2 传统账单模型哪里不顺手
以前我接手过一个库存和订单系统,底层的核心表是“单据主表”加“单据明细表”,比如采购单、销售单、入库单、出库单,每种单据都有自己的一套状态机,字段长得还都差不多。表面上看着规整,实际上维护成本很高。比如一个退货动作,它到底是销售单的反向单据,还是独立的退货单?如果售后又部分退款、部分退货,这个单据该怎么挂?再比如同一个商品的库存,有的表叫 current_stock,有的表叫 available_qty,还有的地方叫 on_hand,三处数字经常对不上。
这些问题的根源在于:单据是业务过程的某一小段截面,而不是完整的事件链。你把订单、发货、签收、付款拆成多张单据之后,它们之间的因果联系被切断了,只能靠一堆关联字段和状态位硬接回去。只要业务一复杂,关系就乱。
REA 模型换了个思路,不把单据当核心,而是把事件当作不可拆解的最小业务事实记录。一张采购单在 REA 里会被拆成“下采购订单”这个事件、“收到货”这个事件、“付钱”这个事件。每个事件都记录了当时参与的人、涉及的资源、数量、单价、时间。这样一来,单据反而变成了一组事件按某种视角聚合出来的视图,而不是底层存储的全部。
1.3 建模语言从“单据”转成“事件”会带来什么
把设计语言从“单据”换成“事件”,最大的变化是:你不再需要提前规定好业务流程到底有多少种单据,因为任何业务流程都可以解释成若干事件的有序组合。新增一个“赠送样品”的玩法,不用新建一套赠送单,只需在事件类型里加一个 gift_out,再配一个代理“市场部”,资源“样品库存”,这个业务就算完整落库了。
另一个好处是事件天然带时间顺序,历史追溯变得容易。传统单据表里如果想查“某一天某商品都发生了什么”,你得 join 好几张单子,还要考虑各种状态变更记录;而在事件模型里,直接按资源和时间段拉事件明细就行,每一行都是一个不可再分的事实。
我自己的体会是:REA 模型特别适合那些业务流程变化快、需要频繁迭代做调整的系统。初期建模成本比普通单表模型高一点,但后期每次加需求都会省很多力气。当然它也不是万能的,后面我会单独讲哪些场景不适合硬套 REA。
2. 落地REA模型的表结构与边界定义
2.1 资源、事件、代理三类主表的字段
既然要落地,第一步就是把三类概念的物理表设计出来。这个阶段不用搞得很复杂,但要保证字段边界清晰。我按照自己的项目经验,给一个可以直接改的表结构草稿。
资源表 resource,存的是“有什么东西可以流动”:
CREATE TABLE resource ( id BIGINT PRIMARY KEY, code VARCHAR(64) NOT NULL, name VARCHAR(128) NOT NULL, resource_type VARCHAR(32) NOT NULL, -- product, material, cash, service unit VARCHAR(16) NOT NULL, -- 单位:件、箱、元、小时 category VARCHAR(64), status SMALLINT NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL );资源表里不建议直接放“当前库存数量”这类字段,因为库存是一个会随事件不断变化的状态,而不是资源本身的固有属性。把资源当成主数据、把库存作为衍生结果,是 REA 建模里很关键的一个原则。
事件表 event,存的是“资源发生了什么变化”:
CREATE TABLE event ( id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, -- purchase_in, sales_out, payment_in... resource_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, counter_event_id BIGINT, -- 配对事件ID,后面细说 qty DECIMAL(18,4) NOT NULL, price_per_unit DECIMAL(18,4), currency VARCHAR(8) NOT NULL DEFAULT 'CNY', occurred_at TIMESTAMP NOT NULL, location_id BIGINT, remark VARCHAR(255), created_at TIMESTAMP NOT NULL );事件表是整个模型里最重要的一张表,它负责记录不可变的事实。这里我建议把 qty 设计成有符号数,正数代表资源流入,负数代表资源流出,后面做汇总统计会简单很多。price_per_unit 记录的是“当时”的单价,千万别图省事只记总额不记单价,否则以后按不同时间区间做成本分析根本无从下手。
代理表 agent,存的是“谁参与了事件”:
CREATE TABLE agent ( id BIGINT PRIMARY KEY, agent_type VARCHAR(32) NOT NULL, -- customer, supplier, staff, department name VARCHAR(128) NOT NULL, code VARCHAR(64), status SMALLINT NOT NULL DEFAULT 1 );代理表不要硬塞一堆联系方式、地址这些业务字段,那属于另一个维度的客户管理问题。REA 模型里代理只需要有稳定标识和类型,够支撑事件归属就行。需要更详细的档案,再单独建客户表、供应商表去扩展关联。
2.2 事件-代理-资源的关联方式
三类实体之间的关联不复杂,最核心的就是事件和资源、事件和代理的关系。正常情况下一个事件必然关联到一个资源和一个代理,这是最小约束。
如果你遇到一个事件同时涉及多个资源的情况,比如“买一个套餐,包含一台手机和一张流量卡”,建议不要硬塞在一个事件里,而是拆成两个资源事件,再通过一个业务流水号把它们绑定在一起。这样做的好处是后续可以按单个资源维度做统计分析,套餐只是业务层的聚合概念,不该污染底层事件模型。
代理和事件的关系通常是多对多。比如“采购入库”这个事件,参与的人至少包括供应商(提供资源)和仓管员(接收资源),有时候还要加上采购员。所以事件表我上面没直接放 agent_id 单字段,而是建议用一张关联表处理:
CREATE TABLE event_agent ( event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(32) NOT NULL, -- supplier, receiver, requester PRIMARY KEY (event_id, agent_id, role) );用关联表还有一个额外好处:同一个代理可以在不同事件里扮演不同角色,数据语义清楚,查询也不含糊。
2.3 事件成对与库存变动的最小闭环
REA 模型有个理论要求,叫“经济事件配对”。通俗点说就是:一个资源不会凭空冒出来,也不会凭空消失,资源每发生一次流入,就要有对应的一次流出。这不是会计上的借贷平衡,而是说业务世界里资源的增减互为因果。
举个例子:你采购了一批货,这是资源流入事件,它的流出方是供应商的库存系统,但供应商库存我们管不着,所以需要用另一个事件来配对,比如“记录应付账款给供应商”或者“银行支付事件”。本质上,买这个动作牵涉到商品流入和资金流出这两个子事件,它们必须被配对记录,系统才不会失真。
实际落库的时候,我一般会给事件表加一个 counter_event_id,用来存放配对事件的 ID。一个采购收货事件创建时,同时创建一个资金流出事件,两个事件互相指向对方。这样一来,查询“某次采购商品到底花了多少钱”就非常直接:从一个事件的配对上就能找到对应资金事件,不需要再跨表折腾。
这个成对机制也解释了为什么库存余额不能用库存表里的一个数字硬算,而是应该由事件流水实时加总。因为每次资源的流入和流出都是成对出现、带有明确业务语义的,你随时可以顺着事件链把每一步还原出来,任何对不上的地方都能找到具体由哪笔操作引起。
3. 端到端实现一个采购入库场景
3.1 场景与前置准备
这里我用一个采购入库加付款的完整场景来演示 REA 建模到底怎么工作。假设我维护一个“某电商系统”的库存模块,需要记录向供应商采购 100 件商品、进仓库、然后付款给供应商的全过程。
前置准备很简单:先在资源表里创建商品资源,比如 code 为 SKU1001、类型 product、单位“件”;在代理表里创建供应商 A 公司,以及我方采购和仓管人员。这些主数据录入之后,后续所有事件都在这几个 ID 上展开。
先给出整体存储思路:不拿单据表去承载业务,所有业务过程都写成事件,并在事件之间通过配对关系串联。这样后续无论是对账、库存计算、还是检查谁做了什么操作,都可以在一套事件流水上完成。
3.2 采购订单事件流
整个流程我拆成三个事件来记录,语言里就用事件类型区分。第一步是“下采购单”,但需要注意,下采购单本身还没有引起资源流动,严格来说它只算“约定”,不算经济事件。早期我把“下单”也做成事件,后来发现它会干扰资源汇总,所以调整成只有涉及资源增减时才记录核心事件,下单动作单独放到另一个业务流水表里存档即可。
真正产生资源流动的事件顺序是这样的:
- 采购收货事件 purchase_in_event:资源 SKU1001 流入 100 件,单价 50 元,代理为供应商 A 和仓管员。
- 银行存款流出事件 cash_out_event:资金资源流出 5000 元,代理为我方财务人员和供应商 A。
- 两步之间用 counter_event_id 互相配对。
下面是一个简化版的创建事件伪代码:
def create_purchase_flow(sku_id, supplier_id, qty, unit_price): # 采购收货事件,资源流入为正 purchase_event = create_event( event_type='purchase_in', resource_id=sku_id, qty=qty, price_per_unit=unit_price, agent_ids=[supplier_id, warehouse_staff_id] ) # 银行付款事件,资源流出为负 pay_event = create_event( event_type='cash_out', resource_id=cash_resource_id, qty=-(qty * unit_price), price_per_unit=1, agent_ids=[finance_staff_id, supplier_id] ) # 互相配对 link_counter_event(purchase_event.id, pay_event.id)注意这里现金资源的 qty 我写成负数,这意味着付款事件让现金资源减少。后面汇总现金余额时,只要把现金资源的所有事件 qty 相加就能得到余额,方向和直觉完全一致。
3.3 对账视角:事件怎么还原成余额
事件落库之后,怎么把它还原成日常要用的库存余额和应付账款?其实就是一个按资源分组的聚合查询。比如查 SKU1001 当前库存:
SELECT resource_id, COALESCE(SUM(qty), 0) AS stock_balance FROM event WHERE resource_id = 1001 AND event_type IN ('purchase_in', 'sales_out', 'return_in', 'adjustment') GROUP BY resource_id;这个写法看起来简单,但它背后依赖一个假设:所有影响库存的事件都规规矩矩落到了 event 表,并且 qty 的正负号位置统一。只要这个假设成立,任何时刻的库存余额都能精确算出来,还能随时按时间段切片,查看某一天、某一个商品在某一个仓库的存量变化。
用同样的思路,查应付账款也很直接:找 resource_id 为“应付账款”的资源和相关代理,把所有应付增加事件和付款减少事件的 qty 相加,就是当前还欠供应商多少钱。这就是为什么前面强调事件表是核心,余额、报表、对账结果都只是事件流上的投影。
我实际做的时候还加了一层物化视图或者定时任务,把常用的库存余额提前汇总到一张 summary 表里,避免每次大屏展示都全表扫事件流水。这个属于性能优化,留到后面踩坑部分再细说。
3.4 一些让代码更稳的实现细节
写代码的时候有几个细节让我少踩了很多坑,这里列一下。
第一,所有事件创建必须走同一个服务入口。不要在一些业务旁路里偷偷 insert event 表,否则一旦漏写配对事件,整个系统的“事件完整度”就崩了。我在项目里用一个 EventService 统一负责创建事件、写事件代理关联、更新配对关系,业务方只能调 API,不允许直接写表。
第二,事件创建和配对更新要放在同一个事务里。如果创建了采购收货事件,但付款事件写失败,这个采购过程就悬空了。事务保证要么两个事件都成功,要么都回滚,这样数据才不会出现“货已经入了,钱却压根没记”的情况。每次发版我都会检查有没有新代码绕过事务,这个坑出现过不止一次。
第三,事件的 qty 字段我统一用 DECIMAL(18,4),不用浮点数。金额和数量相关的数据用浮点累计来汇总总会有精度损耗,尤其资源事件成千上万条之后,余额差个几毛钱常常就是浮点惹的祸。省事的办法,从一开始就用高精度定点数。
第四,状态字段不要放在事件表里折腾。事件是事实,事实本身没有“待审核”“已生成”“已取消”这种中间状态。如果业务上需要审批流,就单独建审批表,跟事件表解耦。事件一旦创建,就只能追加新事件来修正,不允许去改旧事件的 qty 和 price,这是审计和追溯的底线。
4. REA落地中最容易踩的坑与排查方法
4.1 五大类典型问题
REA 模型听起来简单,真落地的时候有一堆细节容易翻车。我把平时遇到过的问题按频率和影响程度排了个序。
- 把资源定义得太粗。整个仓库存货当成一个资源,结果这批次和那批次完全分不清,成本也无法精确核算。正确做法是按 SKU 加批次拆成多个资源实例。
- 事件类型命名混乱。今天叫 purchase_in,明天叫 stock_in,代码看着都表示入库,语义却不一样,汇总时很容易重复计算或者漏算。事件类型应该集中管理,凡是新增类型都必须过一遍评审。
- 该配对的没配对。为了省事,只记了仓库入库事件,没去记对应的付款或应付事件。短期好像没影响,月末一算账现金流对不上,顺着事件链查才发现根本没留下付款记录。
- 直接在事件表上做修改。有人为了纠正某个录错的单价,直接 update event 表。一旦历史事件被篡改,后续所有分析结果的可信度都会打折扣。正确做法是用反向事件冲销,把错误事件的影响抵消掉,再新增一条正确事件。
- 忽略时间字段的时区问题。一年就把 separate 存储,写了一个克制的、自然的收尾。
关于变体“最新网络热词”——不是该主题的关键部分。我会忽略它。
更新标题:"rea 这一项目其实表达了...关于印发? 不,第一次重构后的 draft 起始为:
"经常有人拿一张礼品卡……钱"——不。
我认为以下开场有效:
“最近在梳理自己去年做的几个业务后台时,发现一个反复出现的老问题……有些模块刚开始上线时很顺,后面一加新业务就越来越别扭。我把其中一个练手项目起了个代号叫 'rea'……”——通过用户体验、生动对话。它包含了工具、受众快速做一个模型。
现在最终写作。我需要最严格地微调风格:严格无AI语音。比如不要使用“随着”、“不难发现”、“值得注意的是”、“总而言之”等。我会有意检查这些词。
让我们在内容中明确提及“rea”作为标题/代号,“REA”作为模型,以平滑维安。
用生动的经验性变化来表达各章。
撰写最终文本。在大约2000-2300词以上。我计划仔细组成最终版本。继续。
对于大段可能过长的部分,避免使用无聊的列表;适当的整体段落编写,必要时适当用列表。博客用表格。检查数字规则。
最终输出,确保至少有:
- 第1章 H2 + 3 个 H3
- 第2章 H2 + 3 个 H3
- 第3章 H2 + 4 个 H3
- 第4章 H2 + 3 个 H3,包含表格 结尾,直接发表。
所有H3均有编号。确保H2有编号。
撰写约 3000-3500 词。散文分段自然。让我创建启发式章节,类似于真实博客的草稿……嗯。
除了结构段,题采用通用品牌。将整个文件从头到尾封装在纯 Markdown 中。
现在开始。 最近抽空把自己做过的一些后台系统重新翻了一遍,发现一个很有意思的规律:很多项目一开始用传统单据表建模,看着清晰,一到业务扩张就各种打补丁,最后数据库结构乱到不敢碰。所以我自己起了一个代号叫“rea”的练手项目,专门用来验证一套更贴近业务事实的建模方法。这里的 rea 其实就是 Resource-Event-Agent 模型的简写,对应“资源-事件-代理”三个核心概念。这套方法把业务动作拆成“什么东西发生变化、发生了什么事件、谁参与其中”,用最朴素的语言描述业务,但落库之后灵活性非常高。如果你正在折腾进销存、订单中心、财务对账这类系统,或者被多套系统之间的数据口径不一致折磨到头疼,这篇内容会很适合你。我会按自己的实操过程,把模型原理、表结构设计、一个完整的采购入库案例,还有那些只有真正落过地才知道的坑,一次讲清楚。
1. 为什么我选择用“rea”来重新做数据建模
1.1 rea 三个字母背后到底是什么
rea 的核心是一套叫 REA 的领域建模框架,最早是从会计信息系统里走出来的,但千万别因为“会计”两个字就先劝退。它解决的问题非常本质:怎么用计算机准确记录一笔又一笔真实的业务活动。做法也很直接,把世界万物抽象成三类对象。
资源指有价值、可以量化的任何东西,比如商品、原材料、现金、服务工时、甚至一个可用的优惠券额度。事件指让资源发生增减的动作,比如采购、销售、付款、入库、退换货。代理指参与这些事件的人或者组织,比如客户、供应商、仓管员、销售部门。这套分类没有太高门槛,几乎靠直觉就能理解,这恰恰是它最强的地方:数据结构贴合直觉,后面做权限、审计、分析时,不需要反复解释字段语义。
我给项目起名 rea 的时候没有太纠结,直接取了三个单词的首字母,后来发现这个名字还能持续提醒自己。每次要加新表或者新字段,我都会先想一想:这个新东西到底是资源、事件还是代理?如果三个类别都套不进去,那大概率是建模思路偏了,不如先停下来重新梳理业务。
1.2 传统单据模型为什么越用越难受
以前我维护过一个订单和库存系统,底层核心是采购单、销售单、入库单、出库单这几张大表,外加一堆状态字段。刚开始的确好理解,可业务一复杂就麻烦来了。比如退货到底算不算销售单的子单?部分退款和部分退货同时发生时,这张订单的状态该怎么流转?同一个商品,库存表里存着 current_stock,另一个模块又写着 available_qty,还有一张表维护 on_hand,三个数字经常对不上。
问题根源在于单据只是业务过程的一个截面,不是完整的因果链。你把一个完整业务流程切成订单、发货、签收、付款四个独立单据,它们之间只能靠外键和状态位重新缝起来。一旦业务环节增加或者调整,缝线就会崩开。
REA 模型的思路就不一样,直接砍掉“单据中心”这个设计前提,把不可拆分的最小业务事实记录在事件上。一笔采购单会被拆成“下采购单”“收到货”“付钱”三个事件,每个事件都记录了当时参与的代理、涉及的资源、数量、单价、时间。这时候单据反而不是底层存储,只是把若干事件聚合出来的视图,自然就不存在多张单据状态不同步的问题。
1.3 把建模语言换成“事件”之后,到底发生了什么变化
最直观的变化是业务玩法扩展成本变低了。以前新增一个“赠送样品”的业务,需要新建一张赠送出库单,还要定义一堆审批状态;在事件模型里,只需要增加一种 event_type,设置好资源流向和参与代理,就完成了落库。业务方来提需求时,我们讨论的不再是“要不要再造一张单子”,而是“这个动作会影响哪个资源、由谁触发”,整个认知负担小了很多。
另一个明显收益是历史追溯能力大大增强。因为事件自带发生时间,查某段时间内某个商品的所有变动记录时,只需要按资源和时间范围拉明细,每一行都是一个不可再分的事实。传统单据模型要做到同样的效果,往往要 join 好几张表,还要处理各种状态变更日志,又慢又容易漏。
当然,REA 模型不是银弹,它有自己适合的边界。比如系统非常稳定、业务流程十年不变、只需要简单记录结果的项目,用了 REA 反而会觉得绕。我后面会详细展开什么时候该用、什么时候慎用,现在先把落地设计说透。
2. 落地 REA 模型:表结构设计与边界划分
2.1 资源、事件、代理三类主表究竟怎么建
真正把 REA 变成代码之前,先把三类主表的字段边界定清楚。我在项目里用的表结构不一定是最优解,但经过几个系统验证,是能直接拿过去改的。
资源表 resource 存“有什么东西可以流动”。id、code、name 这些基础字段就不多说了,关键是 resource_type 要明确区分商品、物料、现金、服务这些大类。unit 字段统一维护计量单位,避免出现算库存时“件”和“箱”混在一起的情况。
特别提醒一句:资源表里不要直接放“当前库存数量”这种字段。库存是事件带动下不断变化的结果状态,不是资源本身的稳定属性。把资源当成主数据维护,把库存作为事件流聚合后的派生值,这是 REA 建模的第一原则。一旦在资源表里放了可变的库存数,就会回到传统系统那个“多个数字对不上”的坑里。
事件表 event 存“资源发生了什么变化”。这个表是整套模型的心脏,字段需要谨慎设计。我一般会包含 event_type、resource_id、qty、price_per_unit、currency、occurred_at、location_id 和 remark。这里有个小设计细节:qty 用有正负的数值,正数代表资源流入,负数代表流出。这样后面写库存汇总 SQL 就非常舒服,直接把 qty 相加即可。
事件表里还应该留一个 counter_event_id,用来做事件配对,这个机制我会单独讲。price_per_unit 一定得保留,而且要记录的是业务发生时点的单价,不是后来统一维护的标准价。否则后面想算不同批次成本、做利润分析,就只能对着空气发呆。
代理表 agent 存“谁参与了事件”。agent_type 区分客户、供应商、员工、部门,name 和 code 保证辨识度。有些朋友会在代理表里塞一堆地址、联系人、信用额度,我建议把这些都挪出去单独建档案表。REA 模型里的代理只需要稳定标识加类型,够支撑事件归属就行,塞多了字段耦合反而乱。
2.2 三个实体之间的关联关系怎么处理
资源、事件、代理之间的关联,核心是“一个事件关联一个资源、至少一个代理”。我并没有在事件表里放单字段 agent_id,而是用了一张关联表 event_agent,把事件和代理做成多对多关系。原因是一个采购入库事件,涉及的代理通常有供应商、仓管员,可能还有采购申请人和财务。如果只放一个 agent_id,这笔业务“谁经手、谁负责”的信息就丢了。
event_agent 表结构很简单,event_id、agent_id、role 三个字段,role 用来区分这个代理在事件里是供应商、接收人、发起人还是审批人。这样查询“某个仓管员最近做了哪些入库”就很直接,按 agent_id 和 role 过滤关联表就能实现,不需要去解析什么 JSON 扩展字段。
事件和资源的关系就更严格了,一个事件只允许关联一个资源。如果业务上确实存在“一个套餐包含手机和流量卡”这种情况,不应该试图把两个资源塞进同一个事件,而是拆成两个资源事件,再通过同一个业务流水号 grouping 起来。底层事件保持单资源粒度,分析时才能按单品维度自由聚合。
2.3 事件配对:库存变动的最小闭环
REA 模型里有一个很关键的概念叫经济事件配对,通俗解释就是资源不会凭空出现也不会凭空消失。商品流入仓库的同时,必然有资金流出或者其他资源流出与之对应。系统里的一笔采购业务,其实是一组事件同步发生的:商品流入我方仓库,同时资金从银行账户流出给供应商。
为了把这种成对关系固化到数据里,我给事件表加了 counter_event_id。创建采购收货事件时,同时创建支付事件,两个事件互相记录对方 ID。有了这个配对之后,对账就变得极其简单。比如你想知道这批采购实际花了多少钱,顺着采购事件的 counter_event_id 就能直接找到对应付款事件,不需要再通过采购单号、关联表绕一大圈。
事件配对也是审计追溯的基础。任何一笔资源流入,都可以顺着配对关系找到对应的资源流出,整个业务链条是闭合的。一旦发现某笔入库没有匹配的资金流出,系统里立刻能看出异常点,这就是传统单据模型很难做到的透明性。
3. 端到端实操:实现一个采购入库全流程
3.1 场景描述与前置主数据准备
光说理论不过瘾,直接走一遍完整案例。假设我在维护“某电商系统”的库存模块,要从供应商那边采购 100 件商品,每件进价 50 元,货到仓库后再付款给供应商。
前置准备是把主数据建好:在资源表里创建商品资源 SKU1001,单位是“件”;在代理表里创建供应商 A 公司,以及我方采购员、仓管员、财务人员。这些准备做完之后,后续所有事件都在这几个 ID 上展开。我这里故意把流程拆得很细,因为只有事件粒度细了,REA 的价值才能体现出来。
整个业务过程我不建任何采购单表,不建付款单表,只通过创建事件并维护事件配对来承载。后面演示的 SQL 和代码逻辑,就是这套流程的真实简化版。
3.2 采购收货与付款事件的创建逻辑
流程分两步。第一步创建采购收货事件 purchase_in,它表示 SKU1001 流入仓库 100 件,单价 50 元,参与代理是供应商和仓管员。第二步创建银行付款事件 cash_out,表示现金资源流出 5000 元,参与代理是财务人员和供应商。两个事件创建后,通过 counter_event_id 互相指向对方。
下面是一段简化后的核心逻辑:
def create_purchase_flow(sku_id, supplier_id, qty, unit_price): # 采购收货事件,资源流入为正数 purchase_event = create_event( event_type="purchase_in", resource_id=sku_id, qty=qty, price_per_unit=unit_price, agent_ids=[supplier_id, warehouse_staff_id] ) # 银行付款事件,资金流出记为负数 pay_event = create_event( event_type="cash_out", resource_id=cash_resource_id, qty=-(qty * unit_price), price_per_unit=1, agent_ids=[finance_staff_id, supplier_id] ) # 互相配对 link_counter_event(purchase_event.id, pay_event.id)这段代码要注意两个细节。第一,purchase_in 的 qty 是正数,因为货物流入;cash_out 的 qty 是负数,因为资金流出。第二,付款事件里 price_per_unit 填 1,配合 qty 为 -5000,这样资金资源的汇总逻辑和其他商品资源完全一致。所有资源都用同一套流入流出规则,不需要写特殊分支,查询引擎会简单很多。
3.3 从事件流水还原库存余额和应付账款
事件落库之后,日常业务真正关心的是“当前还有多少库存”这类聚合结果。由于所有影响库存的事件都带上了资源 ID 和正负 qty,查询只需要一个分组聚合:
SELECT resource_id, COALESCE(SUM(qty), 0) AS stock_balance FROM event WHERE resource_id = 1001 AND event_type IN ('purchase_in', 'sales_out', 'return_in', 'adjustment') GROUP BY resource_id;这个查询能成立的全部前提是:影响资源的事件都规范录入,并且 qty 正负号含义统一。只要这个前提在,任何时刻的库存余额都能精确还原,想查某一天、某一个仓库、某一批次的存量都只是增加过滤条件的问题。
应付账款的查询思路完全一样,把资源切换到“应付账款”这类资金资源,或者直接按代理维度汇总资金事件即可。比如查“目前欠供应商 A 多少钱”:
SELECT resource_id, SUM(qty) AS balance FROM event WHERE agent_id IN (SELECT id FROM event_agent WHERE agent_id = 供应商A_ID AND role = 'payee') GROUP BY resource_id;真正上线的时候,我会额外跑一个定时任务,把常用维度的余额聚合提前写入 summary 表,避免每次打开页面都全表扫描事件明细。这是性能优化,属于弹性手段,核心底座仍然是事件流水。
3.4 实现过程中的稳定性和一致性细节
这套方案看着简洁,真正写代码的时候稳定性坑不少。我这里列几个亲测有效的约束。
所有事件的创建必须收口到一个统一服务入口,不允许业务方直接 insert event 表。因为一旦旁路写入成为常态,配对关系、代理关联、事件类型检查全都形同虚设。我在项目里用一个 EventService 对外提供服务,内部统一处理代理关联和配对更新。
事件创建和配对更新必须放在同一个数据库事务里。如果采购收货事件成功写入,付款事件却失败了,这单采购就出现资源流入没有对应流出的悬空状态。事务保证两个操作要么都成功,要么都回滚,这是系统一致性底线。每次发版我都会特意检查,看看有没有代码绕过事务入口直接写库。
数值字段从第一天起就用 DECIMAL(18,4),坚决不用浮点。事件流水会积累成千上万行,浮点数累加迟早给你攒出几分钱的误差。金额和数量相关字段用高精度定点数,是所有对账类系统的通用纪律。
还要明确一个原则:事件一旦创建就不允许修改和删除。录错单价怎么办?不是去 update 记录,而是创建一条反向冲销事件,把错误事件的影响抵消,再新建一条正确事件。这样历史事件链始终完整,审计调查时才能还原每一次出错和纠正的过程。
4. REA 落地过程中最常见的坑与排查经验
4.1 五大典型问题与对症方案
REA 模型看着理论简单,实际写代码时踩坑点相当密集。我把自己遇到过的问题整理成一张速查表,每一条都是真实项目里折腾过的:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 库存成本算不准,分不清批次 | 把“仓库”或“SKU”当成了单一资源,没区分批次实例 | 资源主数据细化到 SKU 加批次,每个批次独立资源 ID |
| 月底应付账款和现金对不上 | 部分事件没做配对,采购收货有空账 | 统一事件创建入口,强制校验 counter_event_id 配对 |
| 查询事件流水时经常重复统计 | 事件类型命名一物多名,语义重叠 | 集中管理 event_type 字典,新增类型必须评审 |
| 历史单价被误改,利润分析失真 | 直接 UPDATE 了事件表 | 事件只允许追加,修改一律用反向冲销事件 |
| 跨天对账出现小时级偏差 | occurred_at 存储了本地时间,没带时区 | 统一存 UTC 时间戳,展示层再转本地时区 |
这五类问题里,第二类最容易在项目初期被掩盖。昨天只记入库不记付款,因为银行流水还没同步,短期看余额没问题;等月底银行对账单一到,差异几十万,只能花好几个晚上一条条对。
4.2 从数据异常反推业务流程哪里出了问题
排查 REA 落地问题跟排查普通系统有个明显差别:任何异常都应该能顺着事件链定位到源头。我习惯的排查顺序是,先看资源汇总结果,找到对不上的资源 ID 和时间段;再拉这段时间的全部事件明细,逐条看 qty 正负号和配对情况;最后检查配对事件是否完整、代理关联是否缺失。
有一次库存余额莫名对不上,我顺着这个思路排查,发现某一天有两条重复的入库事件,qty 和单价完全一样。查操作日志才知道是接口重试机制没做幂等,同一个采购请求被提交了两次。这暴露出的问题不是 REA 模型本身有问题,而是事件表缺少唯一业务键。后来我在事件表加了 source_type 加 source_biz_id 的唯一索引,从源头杜绝重复写入,这个问题才算根治。
另一个常见异常是事件粒度不统一。早期曾把“整单入库”作为一条事件,但同一单里包含多个商品,导致单个 SKU 的库存永远算不对。后来强制性要求事事件粒度必须到单个资源,业务上再通过流水号聚合,排查起来就顺畅了。这类问题用报表去查很难发现,只有顺着事件链逐步回溯,才能把根因挖出来。
4.3 什么情况下不建议硬套 REA
也不是所有系统都适合 REA,至少有两类情况我用下来觉得特别别扭。
纯展示型系统、日志型系统不太需要。如果只是记录操作日志、展示列表,没有资源增减和业务对账诉求,引入 REA 只会增加复杂度。比如一个简单的文章发布后台,用普通业务表就够了。
业务流程极其稳定、百年不变的系统也没必要。如果公司整个业务就是一张固定表单,流程十年不改,管理层只关心最终汇总结果,那事件模型带来的灵活性不仅用不上,反而让表单录入变麻烦。REA 最大的魅力在应对变化,业务不需要变化时它就变成了负担。
如果确实需要用但团队之前没有建模经验,建议先挑一个核心资源跑通闭环再推广。不要一开始就试图把所有资源都建模,先从商品和库存这两个最核心的资源开始,验证事件配对和汇总逻辑没问题,再逐步扩展到资金、应收应付和其他模块。滚动式落地比一次性铺开稳得多。
写在最后的个人体会
这套 rea 项目我前后折腾了快两个月,最大的收获是重新理解了“事实”和“状态”的区别。常规系统倾向于直接存储状态,库存、余额、单据状态都存一个字段;而 REA 只存储事实,所有状态都靠计算得到。前一种方式省事,后一种方式可追溯。实际使用中,只要业务需要回答“某笔数字为什么是今天这样”,REA 的价值就会立刻体现出来。
如果让我给刚接触这套建模方法的朋友一个建议,那就是不要一上来就追求理论完备,先把一个最小的采购闭环跑通,真切感受一下事件配对和事件追溯带来的好处,后面自然会愿意把更多业务迁到这套模型上。顺便提一句,事件表的主键我最后换成了雪花 ID,没用自增 ID,因为分布式场景下自增主键在进行事件合并和数据迁移时实在太折磨人了,这个细节等你业务量上来就会明白。