REA模型实战:用资源、事件、代理重构进销存系统
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_typePURCHASE_IN 且 directionIN 的记录销售出库写一条 SALE_OUT 且 directionOUT 的记录。退货时写一条事件并用 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 directionIN THEN quantity ELSE 0 END) AS total_in, SUM(CASE WHEN directionOUT THEN quantity ELSE 0 END) AS total_out, SUM(CASE WHEN directionIN 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 模型最大的门槛不在数据库设计而在团队认知转变。很多程序员习惯把“业务动作”直接变成“字段”而不是变成“事件记录”。一旦大家都接受“只记事实、由事实推导结果”这个共识后面的开发效率会提升一大截。最后再分享一个心得迁移期别图快双写至少跑两个完整的业务周期把月初、月中、月末对账都覆盖到再切换查询。数据模型一旦立住了后期比功能代码复用带来的价值高得多。