☰
REA模型实战:用资源、事件、代理重构进销存系统
2026/10/21 14:30:11 网站建设 项目流程

REA 这个缩写,乍看平平无奇,但做业务系统的人一旦理解透它,很多账实不符、流程难改的毛病都能从根上解决。REA 指资源、事件、代理三个核心概念,最早来自会计信息系统领域,后来因为表达业务语义特别干净,被不少企业软件团队拿去重新设计进销存、订单、资产盘点甚至 CRM。这篇文章我就用实际项目改造的视角,讲讲怎么把一个传统表结构的库存系统,逐步拆成 REA 模型,以及在这个过程里我踩过的坑和总结出的设计取舍。

## 1. 为什么传统进销存系统“改不动”

很多中小团队的第一套库存系统,往往是从订单表加库存表开始的。结构很简单:商品表、仓库表、入库单、出库单,再配一堆更新库存的定时任务或事务代码。刚上线时挺好用,一旦业务开始加采购、退货、调拨、盘点、赠品、损耗这些动作,麻烦就来了。

1.1 传统表结构的典型困境

你很容易遇到三类问题。第一,业务表越堆越多,采购单是采购单,退货单是退货单,调拨单又是另外一张表,每张表的字段长得差不多,但查询和统计都得分别处理。第二,库存数字分散在多个地方,订单表算一次库存,出入库表又算一次,两个口径对不上,财务和运营各拿各的数,经常为了一个库存差异吵到半夜。第三,业务流程一变,表结构就得跟着改,比如从“先发货后开票”改成“先开票后发货”,如果不重构数据模型,就只能靠一堆补丁接口把逻辑硬串起来。

我见过最典型的翻车现场:某团队为了支持“门店调拨”,在出库单上硬加了一个“调入门店”字段,结果退货、盘点、报废全部按照这个字段的逻辑套,最总库存依然是错的。原因不是程序员写错了代码,而是领域模型没有把“库存变动”的本质表达清楚。

1.2 库存变动的本质到底是什么

所有业务动作,不管名字叫入库、出库、调拨、盘点、退货,还是报损,本质上都在做同一件事:改变某个资源在某个主体的控制状态。这里有两个基本元素——资源(商品、现金、积分)和主体(仓库、门店、供应商、客户、内部部门)。库存系统真正要记录的,不是“现在有多少”,而是“曾经发生过哪些事”。

传统表结构把焦点放在“结果”上,每次出入库都去 update 库存表的数量字段。REA 不同,它把焦点放在“事件”上。事件是不可再拆的业务事实,比如“仓库 A 收到供应商 X 送来的 100 个键盘”。至于库存结余是多少,那是事件累加出来的视图,不是存储的“唯一真相”。这个思维的转换非常关键。

1.3 REA 模型的三个核心概念

REA 分别是 Resource(资源)、Event(事件)、Agent(代理)。资源是经济活动中被交换、消耗、产出的东西,比如商品、原材料、现金、工时。事件是与资源变动相关的业务活动,比如“采购入库”“销售出库”“调拨出库”“盘点差异调整”。代理是参与事件的个人或组织,比如供应商、客户、仓库管理员、物流公司。

光看概念有点寡淡,我用卖咖啡的例子帮大家顺一遍。客户买一杯拿铁,涉及到的资源是拿铁和现金,事件是“销售拿铁”和“收银收款”,代理是客户和店员。这两个事件一对,资源和资源之间就形成了经济交换关系。理解到这个粒度,设计表结构时就只需要问三个问题:资源是谁?发生了什么事件?谁参与?所有业务场景都能往这个框子里装。

## 2. 基于 REA 建模的整体设计思路

真正动手设计前,要先把团队认知对齐。REA 不是银弹,它是一套让人按业务本质建模的方法。设计过程我从四个步骤来说。

2.1 识别资源池与事件链

第一步是盘点系统里的核心资源。比如一套进销存系统,资源通常有商品、库存、现金、应收账款、应付账款。有些资源是有形库存,有些是账面上的权益,都需要单独建模。

第二步是识别事件链。以一笔销售业务为例,事件链大概是:客户下单 → 仓管发货 → 物流签收 → 财务收款。每一个环节都是一个事件。REA 强调“一件事记一笔”,不允许把多个事件揉进一张大表。为什么要这样?因为后续做统计时,你可以从任意一个事件维度出发做透视,比如只看发货环节的时效,不用捞整张订单表。

2.2 用“流入流出”定义资源增减

每个事件都会对应资源的流入或流出。采购入库时,商品资源流入仓库;销售出库时,商品资源流出仓库。这个概念容易被忽略的是“双向性”。比如退货,可以建模成商品流入仓库,同时现金资源流出公司。

在设计事件表时,我建议把“资源方向”作为显式字段。比如 direction 字段,取值 in 或 out。这样后续做汇总,只需要对同一张事件表按方向分组,不需要去 join 好几张业务单表。这个字段看起来简单,但能救很多命。

2.3 代理层与组织权限的落法

代理这个概念跟权限系统关系密切。每个事件必须记录“谁干的”“对谁干”。比如一次盘点调整,经办人是仓管,审核人是库控主管。“谁干的”是执行代理,“对谁干”是接收或影响代理。

具体建表时,可以用 agent_id 加 agent_type 来标识代理类型,比如 employee、supplier、customer、warehouse。仓库本身可以作为代理出现,因为它是一个可以持有资源的实体。权限模块不用单独造轮子,直接从事件表反查某个代理参与过哪些业务,就能做数据隔离。

2.4 为什么选择 REA 而不是通用 ERP 表结构

很多通用 ERP 的表结构参考了标准财务凭证设计,其实已经是半 REA 的思路了。比如凭证表里“借”和“贷”就是两类事件方向,科目余额就是资源汇总。但通用 ERP 对中小团队太重,配置成本高,而且改字段困难。

自研系统如果直接按业务单据建表,后面加业务类型累死。用 REA 方式建核心层,外层可以自由包装各种单据视图,既有标准化内核,又有业务柔性,这是我最看重的一点。后面查账、出报表、做数据分析,都是在一套统一的事件模型上做映射,不需要各个业务各写各的报表。

## 3. 核心表结构与字段解读

模型设计清楚了,接下来就是落地。这套结构我实际部署过两次,第一版和第二版差别主要在于索引和冗余字段的取舍。下面给出我比较满意的一版。

3.1 资源表设计

资源表存的是“静态档案”,比如商品表、仓库表、往来单位表。这些表字段不多,但必须稳定,不能三天两头加业务状态。

CREATE TABLE res_resource ( resource_id BIGINT PRIMARY KEY, resource_type VARCHAR(20) NOT NULL, -- PRODUCT, WAREHOUSE, CASH, RECEIVABLE resource_code VARCHAR(64) NOT NULL, resource_name VARCHAR(128) NOT NULL, owner_agent_id BIGINT, -- 当前持有代理 unit VARCHAR(20), -- 计量单位 status VARCHAR(10) NOT NULL DEFAULT 'ACTIVE', extra_json JSON, created_at TIMESTAMP, updated_at TIMESTAMP );

resource_type 决定资源大类,unit 一定要存标准计量单位,避免后续换算公斤和斤、个和箱时出错。owner_agent_id 代表当前资源归属,配合代理表可以做权限范围控制,但要注意它不是实时算出来的,理论上讲它只是一个缓存字段,最终真相要以事件流水为准。

3.2 事件表设计

这是整套系统的核心。所有库存变动、资金变动都写在这张表里。

CREATE TABLE evt_event ( event_id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, -- PURCHASE_IN, SALE_OUT, TRANSFER_IN, ... resource_id BIGINT NOT NULL, direction VARCHAR(4) NOT NULL, -- IN / OUT quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4), amount DECIMAL(18,4), execute_agent_id BIGINT NOT NULL, -- 执行代理 receive_agent_id BIGINT, -- 接收/影响代理 warehouse_id BIGINT, biz_no VARCHAR(64), -- 业务单据号 ref_event_id BIGINT, -- 关联事件,用于退货、冲正 occurred_at TIMESTAMP NOT NULL, created_at TIMESTAMP, created_by BIGINT );

这张表乍一看像一个流水账,但妙就妙在它是所有业务单据的唯一数据源。采购入库写一条 event_type=PURCHASE_IN 且 direction=IN 的记录,销售出库写一条 SALE_OUT 且 direction=OUT 的记录。退货时写一条事件,并用 ref_event_id 指向原事件。这样做的好处是“业务单据”可以随心所欲地改变外观,底层永远只有一套事件流。

3.3 代理表与关联关系设计

代理表比较简单,重点是 agent_type 这个字段,用来区分客户、供应商、员工、仓库、门店。有时候仓库既是资源存储地,又是代理,建模时容易混淆。我的建议是:仓库作为资源存在,同时把仓库管理员作为代理,不把仓库本身既当资源又当代理,否则逻辑会打架。

CREATE TABLE agt_agent ( agent_id BIGINT PRIMARY KEY, agent_type VARCHAR(16) NOT NULL, agent_name VARCHAR(128) NOT NULL, ext_info JSON );

关联关系不需要单独做一张很大的图谱,因为 REA 的事件表已经天然携带了资源、执行代理、接收代理的关联。要查“某个客户近三个月的所有销售”,就是在事件表上按 receive_agent_id = 客户ID 过滤。不需要额外建立关系表,查询性能也足够好。

3.4 视图层的“库存结余”怎么做

事件表是事实,库存结余是计算结果。我建议直接用物化视图或者定时汇总表,不建议每次查询实时跨表汇总几百万条事件,那样会很慢。

一个简单标准的 SQL 思路是:

SELECT resource_id, warehouse_id, SUM(CASE WHEN direction='IN' THEN quantity ELSE 0 END) AS total_in, SUM(CASE WHEN direction='OUT' THEN quantity ELSE 0 END) AS total_out, SUM(CASE WHEN direction='IN' THEN quantity ELSE -quantity END) AS balance FROM evt_event GROUP BY resource_id, warehouse_id;

实际业务里,我们一般维护一张 monthly_stock_summary,按月、按资源、按仓库汇总期初加本期流入减流出,查询时只用拉最近几个月的数据。每个月开账做一次快照,历史查询直接读快照,速度很快。

## 4. 实操改造:把一套库存系统迁移到 REA

说完了理论,讲讲实际操作。这次改造的模拟项目是一个仓库进销存系统,原来有采购单、销售单、调拨单、盘点单四张表,每张表都是独立结构,库存更新散落在各个模块里。改造分了三步走。

4.1 加表、并行写入、再切换查询

我没有选择“大爆炸”式迁移,那样风险太高。第一步只新增 REA 相关表,同时保留旧表结构,让新写的代码在写入旧表的同时,也往事件表里写一条数据,相当于双写。这个阶段跑了一周,用 er_submit 对比脚本每天核对两边数据是否一致。

双写阶段一旦发现偏差,基本能定位到代码逻辑错误,不会影响线上业务。比如调拨业务,原逻辑是从 A 仓库存减,B 仓库存加;新逻辑必须写两条事件,一条方向 OUT,关联仓库 A,一条方向 IN,关联仓库 B,并且两条事件要同一个事务提交。对比脚本的计算逻辑跟新逻辑一致,这样旧的调拨单也能逐步补录成事件数据。

4.2 写事件,读视图,按业务角度重查

切换查询前,先把月度汇总快照跑起来,确保物化视图和旧库存表的数据对得上。切换后,所有库存查询走 REA 视图层,但业务单据仍在旧表。这意味着旧系统可以继续用,同时新功能开始基于事件表做扩展,比如自动对账、库存周转率分析。

这里有个点很关键:不要让业务代码直接操作 REA 事件表。我封装了一个 StockEventService,对外暴露接口,比如 createInboundEvent、createOutboundEvent、reverseEvent,内部负责幂等、事务和校验。所有业务方都走这个服务,不允许有人偷偷往事件表里 insert。否则事件语义很快就会被“脏数据”污染。

4.3 对账与审计为什么天然顺滑

REA 模型最大的隐藏收益是审计追踪。传统表结构里,如果库存数量被改错了,你很难反查是被哪张单改的。REA 结构里,任何库存变化都能找到对应的事件、代理、时间和业务单据号,连退货冲正也能通过 ref_event_id 串成完整链条。

我们做了一次演练:财务要求核对某个 SKU 全年所有影响库存的事件。换作以前要 join 五张业务表,还得对上各自的状态字段;现在按 resource_id 过滤事件表,半小时就交付了一份包含时间、数量、方向、经办人、关联单据的完整流水。财务同事看完之后对这套结构的接受度明显提升了。

## 5. 改造过程中的典型问题与排查心得

数据模型改造从来不缺坑,这里挑几个最有代表性的问题记录下来,希望对正在动手的团队有帮助。

5.1 事件重复写入与幂等控制

双写阶段最容易出现重复事件。比如某个业务方成功写入了旧表,但事件表写入超时没返回,业务方重试,结果旧表更新了一遍,事件表也更新了两遍。库存对账立即报错。

我的解决办法是给事件表增加业务唯一键,直接拿“业务日期 + 业务单据号 + 事件类型 + 资源ID”做联合唯一索引。业务服务在调用 StockEventService 前,先生成维度唯一的 event_biz_key,数据库层面防重。如果发现重复写入,直接返回已存在的事件ID,不报错,这样重试的动作就变得幂等了。

5.2 反向冲正时事件方向别搞反

退货或者单据作废,正确做法不是删除原事件,而是新增一条方向相反、数量为正的事件,并用 ref_event_id 指向原事件。这样原事件保留,新事件明确表达“对立动作”,后续统计时如果同一 ref_event_id 下存在两条方向相反的事件,可以自动互相抵消。

千万别用“负数数量”来表示冲正。一个 -5 的库存事件和方向字段组合起来非常灾难,报表上会把累计入库和累计出库算错,而且排查问题时你根本不知道一条负数记录到底是“原事件录错”还是“主动冲正”。我吃过这个亏,而且要纠正已经污染的历史数据很痛苦,每次都得靠业务侧跑手工脚本确认。

5.3 仓库既当资源又当代理的建模混淆

做调拨单时,同事问,仓库是资源还是代理?一开始模型里把仓库既放进 res_resource,又放进 agt_agent,结果查“某仓库的调入记录”时得到两份数据,一份来自资源关联,一份来自代理关联,逻辑完全对不上。

后来统一为:资源表只放商品、现金、应收应付;仓库只作为存放位置字段 warehouse_id,不作为代理参与事件。代理表只放人和组织,比如员工、供应商、客户、物流公司。这样模型干净很多,写代码时也少了一大类判断分支。

5.4 金额精度与小数位数要提前定好

事件表里的 quantity 用了 DECIMAL(18,4),金额用 DECIMAL(18,4),价格用 DECIMAL(18,4)。千万别用 FLOAT 或 DOUBLE,浮点数的精度问题在财务对账时会让你怀疑人生。哪怕是数量为整数的商品,也建议固定小数位,因为某些商品可能按重量出入库,需要保留 3 到 4 位小数。

实际中我们还发现,客户在下单时的金额与我们计算的金额差一分钱。原因在于单价有 4 位小数,而金额可能因为税率产生舍入差异。我们的规则是:金额字段允许存 4 位小数,但对外展示时按业务规则的进位方式展示,事件表保留最原始精度,不做四舍五入。这个细节让后续对账省了非常多口舌。

## 6. 这套模型后续还能扩展什么

改造完核心账户系统之后,我发现 REA 模型的可扩展性远超预期。很多过去要新建表才能支持的场景,现在只需要增加 event_type 的取值。

比如把库存借贷变成“可用库存”和“在途库存”两个资源概念,就可以通过事件流水区分。参考“可用”和“冻结”资源,当订单创建时,写一条从可用资源到冻结资源的转移事件;订单取消,再写一条反向转移。再加上一个资源维度,业务上的库存预占逻辑就清晰了。

再比如多币种、多计量的场景,事件表的 resource_id 可以指向“币种资源”或“计量单位资源”,金额做汇率换算后保存基准币金额。同一套事件模型能同时处理商品流、资金流、甚至工时流,这是最让我满意的部分。

我个人的体会是,REA 模型最大的门槛不在数据库设计,而在团队认知转变。很多程序员习惯把“业务动作”直接变成“字段”,而不是变成“事件记录”。一旦大家都接受“只记事实、由事实推导结果”这个共识,后面的开发效率会提升一大截。最后再分享一个心得:迁移期别图快,双写至少跑两个完整的业务周期,把月初、月中、月末对账都覆盖到,再切换查询。数据模型一旦立住了,后期比功能代码复用带来的价值高得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询