
“rea”拿到这个项目代号的时候我盯着聊天框看了十秒钟。没有前缀没有下划线没有 version 尾巴就孤零零三个字母。我第一反应是需求方是不是把 readme 少打了两个字母。直到对面补了一句“我们要基于 REA 把进销存模型重做一版”我这才反应过来——Resource、Event、Agent资源、事件、代理三个词的首字母合起来就是 REA。这不是一个随手起的代号而是一套很值得展开讲的业务建模方法。如果你是个和我一样做过几年业务系统的人大概率也遇到过这种“宁可把界面原型拿来当表结构、也不愿多想想业务本质”的项目。而 REA 模型恰恰是治这个毛病的一剂良药。它不新鲜最早起于会计信息系统设计领域但直到今天用在库存、订单、财务对账、供应链协同这些场景里依然非常能打。这篇文章我就以刚结束的这个“rea”项目为主线把资源、事件、代理这套框架讲透再附上我们落地时的表结构、参数选择、踩坑记录。无论你是后端开发、产品经理还是刚从 CRUD 阶段往领域建模走的新人这篇文章都值得花十分钟看完。1. 为什么在信息时代我们会需要 REA 这类业务建模方式很多人在做数据库设计的时候第一直觉是按页面原型去抠表。页面上有个“入库单列表”那就建一张入库表页面上有“商品信息”那就建一张商品表页面上有“库存数量”那就直接给商品表加一个 stock 字段。这套做法不完全是错的但它有一个致命的问题表的形状被界面绑架业务一变表就要跟着拆。REA 模型的出发点完全不同。它认为一个业务系统里真正值得被记录的不是“单据”和“状态”而是底层的事实。事实主要由三类东西构成发生了什么事Event、涉及了什么东西Resource、由谁参与Agent。先有这三个维度再围绕它们去组织界面和查询数据结构自然就稳定了。我自己体会最深的一点是REA 把“状态”从存储里剥离了。传统模型里你会设计一个 order_status 字段用 0、1、2 表示待支付、已支付、已完成而在 REA 模型里你只存“支付事件”“出库事件”“签收事件”订单到底处于什么状态是这些事件推出来的结果而不是一个需要实时维护的枚举值。这个思维转变说起来简单做起来却能省掉一大堆状态同步的 bug。这里做一个传统方式和 REA 的对比对比维度传统“凭证科目”思路REA 思路建模目标满足记账和报表合规还原真实业务语义核心对象凭证、科目、余额资源、事件、代理库存获取方式直接读库存字段通过事件聚合计算扩展性加报表字段难加资源或事件类型即可财务兼容性天然贴合会计准则需要额外映射到账务学习门槛熟悉会计概念的人容易上手需要抽象思维和复盘需要强调的是REA 和传统账务不是敌对关系。实际项目里完全可以混用核心业务层用 REA 建模保证事件完整和可追溯对外再写一套映射逻辑把事件落成凭证和科目应付审计和财务。这样两边的好处都拿到了。以我这个项目为例需求方最初只给了我三个字母但等他们真的把“采购入库、销售出库、退货、库存查询、来源追溯”这五个场景列出来之后我发现用 REA 建模几乎是一气呵成的。它不要求你先设计菜单而是要求你先说清楚业务里究竟有哪些资源会流动哪些动作被认可为事件哪些人参与其中。想清楚这三件事表结构反而变成了一件顺理成章的事。2. REA 核心组成资源、事件、代理2.1 三个实体与它们的含义先说 Resource也就是资源。资源指的是那些有价值、可识别、能被交易或运营的对象。最典型的是商品、现金、原材料但也可以抽象一点比如“库房容量”“员工工时”“客户信用额度”。判断一个东西是不是资源只需要问一句它是否会在事件中流入、流出、消耗或被创造。如果答案是肯定的那它就是资源。再说 Event也就是事件。事件是业务动作本身比如采购入库、销售出库、付款、收款、领料、退料。事件有三个特征发生在某个时间点、至少关联一个资源、至少有一个代理参与。在落库的时候事件记录几乎是“只追加”的不做物理删除不做原地修改错了用冲销事件解决。这一点非常像金融系统的流水账也是整个 REA 模型能保证审计追溯的基础。最后是 Agent也就是代理。代理是参与事件的人或组织。内部代理包括员工、部门、仓库管理员外部代理包括供应商、客户、物流商、平台方。为什么要把代理单独建模而不是简单地在事件表里放两个字段因为在真实业务里同一个事件上往往出现多个代理角色例如销售出库事件里销售员是发起人仓管员是执行人客户是接收方。如果只在事件表里放一个 supplier_id角色一多就立刻捉襟见肘。2.2 为什么事件是建模的“大象”我在项目里和同事开了个玩笑说事件是 REA 里的“大象”因为大部分设计重心都压在它身上。资源是相对静态的描述代理是相对固定的主体只有事件是动态变化的。整个系统的时间线、数量变化、金额变化、责任归属全部靠事件串联。举一个具体例子。在传统模式下如果你想知道“上个月哪个业务员带来了多少毛利”可能要同时 join 订单表、订单明细表、商品表、回款表中间还容易漏掉退货。在 REA 模式下这个需求变成了一件很简单的事把“销售出库”类型的事件筛选出来关联代理表找到业务员再关联资源表找到商品再把“退货”事件抵扣掉一个分组查询就完成了。这就是事件优先建模的优势。它不是把报表需要的字段预先塞进一张大宽表而是把最小粒度的业务事实记录下来让报表成为查询的一种可能。实践中这一层也被很多人称作“基础事实层”数据仓库里的明细层、ODS 层其实和它有相同的哲学。2.3 关系必须成对经济联系与闭环REA 模型里实体之间的关系不是随意画的而是高度可预测的。归纳下来其实就是三类成对关系。第一类是资源—事件关系Resource-Event描述资源从何而来、去向何方。比如采购入库事件与库存商品资源的关系是“流入”销售出库事件与库存商品资源的关系是“流出”。第二类是事件—代理关系Event-Agent描述谁参与了这次事件。比如付款事件与客户代理之间的关系是“付款方”与收款员代理的关系是“操作员”。第三类是事件—事件关系Event-Event描述不同事件之间的因果配对。比如销售出库事件对应着一次资金流入事件采购入库事件对应着一次资金流出事件。这种“货物流与资金流成对出现”的关系是 REA 能做闭环对账的核心。为什么说“成对”很关键因为很多业务 bug 都来自单边记录。只记了出库没记收款财务那边永远对不上账只记了采购没记付款供应商那边迟早问你要钱。REA 从模型层面逼着你把事件配对好哪怕不在同一时刻发生也要在结构上预留关联。系统里有对应关系对账就是一条 SQL 的事没对应关系对账就只能靠人肉核对 Excel。3. 小场景实战从零构建一个库存出入库 REA 模型3.1 场景与需求这个“rea”项目的需求很典型。某公司目前用 Excel 管理进销存货一多就乱想做一套系统具体要求包括能登记采购入库、销售出库、退货三个核心动作能实时查询每个商品的当前库存能追溯任意一笔出库商品的来源批次能统计每个部门、每个业务员的销售业绩。看起来不难但如果按传统方式直接建商品表加库存字段第一个需求还好实现后面的追溯和统计就会越做越别扭。所以我们决定先放弃界面回到业务本身用 REA 把模型理清楚。3.2 用事件链描述业务闭环我们当时在纸上画了三组事件链没有画特别复杂的图就用简单的箭头和分组。下面我把过程用文字还原一下。第一组采购入库。供应商把商品送进仓库仓库管理员验收后登记入库。这里的事件是“采购入库”资源是“库存商品”内部代理是仓管员外部代理是供应商。这件事会让库存商品这种资源增加同时它未来一定对应一笔应付或预付的资金流出事件。第二组销售出库。客户下单销售员开单仓库发货。这里的事件是“销售出库”资源是“库存商品”内部代理是销售员和仓管员外部代理是客户。这件事会让库存商品减少同时对应一笔应收或收现的资金流入事件。第三组退货。客户退回商品仓管员验收后建立退货事件。事件为“销售退货”资源同样是“库存商品”但方向是流入和销售出库正好相反。把这三组事件列完之后我们发现整个库存在模型层面已经是非常平衡的闭环了。库存从来不需要在表里显式存储而是“流入总量减去流出总量”的聚合结果。这个聚合可以实时执行也可以每小时物化一次但底层数据永远只有事件没有所谓“当前值”的歧义。3.3 落库从 REA 到 SQL 表结构模型理清楚之后落库反而是比较机械的事情。下面是我们实际用的核心表结构稍微简化了一些字段去掉了租户 ID 和软删除标记但整体骨架没变。CREATE TABLE resource ( id BIGINT PRIMARY KEY, resource_code VARCHAR(64) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, unit VARCHAR(16) NOT NULL, category_id BIGINT ); CREATE TABLE agent ( id BIGINT PRIMARY KEY, agent_type TINYINT NOT NULL COMMENT 1内部代理2外部代理, name VARCHAR(128) NOT NULL ); CREATE TABLE event ( id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL COMMENT purchase_in/sale_out/sale_return, occurred_at TIMESTAMP NOT NULL, reference_no VARCHAR(64), note VARCHAR(255) ); CREATE TABLE event_item ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction TINYINT NOT NULL COMMENT 1流入2流出, quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4) ); CREATE TABLE event_agent ( event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(32) NOT NULL COMMENT operator/customer/supplier/salesman, PRIMARY KEY (event_id, agent_id, role) );这套结构的核心是 event_item 表。它支持一张入库单包含多行商品也支持一个事件同时影响多个资源。每个明细行都带有 direction 和 quantity方向决定增减数量一律为正数计算库存时再通过方向转换正负号。这样能避免很多新手把数量直接填成负数导致统计口径混乱的问题。当前库存视图也很简单CREATE VIEW current_stock AS SELECT resource_id, SUM(CASE WHEN direction 1 THEN quantity ELSE -quantity END) AS stock_quantity FROM event_item GROUP BY resource_id;这里需要记住一个原则视图只是查询手段不是存储目标。库存不应该作为字段存在 resource 表里。如果你真的因为性能需求必须落一个库存快照也应该把它看作一个“物化视图”由事件异步刷新而不是每次业务操作时直接 UPDATE。这两者的差别在并发场景下极其关键。后者容易出现超卖、负库存、并发覆盖前者只要事件写入是串行的聚合永远一致。3.4 外键和索引的务实建议表结构出来后我们又补了几个必要的索引和外键但也不是盲目堆索引。event_item 表必须给 event_id 建索引因为查出一笔事件后要立刻带出明细同时要给 resource_id 建索引因为库存聚合经常按资源分组。event 表要给 occurred_at 建索引时间范围查询很常见event_agent 表因为主键已经是 (event_id, agent_id, role)所以常规按事件查代理无需额外加索引但如果你常常按代理找事件那就要反过来再加一个 (agent_id, event_id) 的复合索引。外键我们保留了 event_item.event_id 指向 event.id、event_item.resource_id 指向 resource.id 这两条用来防止垃圾数据。但 event_agent 和 event 之间的外键没加因为在高并发插入场景下外键会影响写入性能而且应用层已经能保证逻辑上引用正确。这是典型的“约束与性能取舍”问题团队如果很守规范外键可以省如果团队流动大宁可加外键求稳。4. 落地过程中的参数设计与业务细节4.1 数量与价格的精度选择这个项目的库存商品包括按件卖的成品和按公斤算的原材料所以数量字段不能用整数我们统一用了 DECIMAL(18,4)。为什么不直接 DECIMAL(10,2)因为很多采购结算尾差会出现在小数第三位、第四位你存的时候四舍五入以后对账永远差几分钱。四个小数位虽然不能完全消灭误差但已经能覆盖绝大多数进销存场景。真正要算财务总账时再按会计精度做一次舍入而不是在业务层提前舍。价格字段也同样用 DECIMAL(18,4)。这里要特别强调千万不要用 DOUBLE 存金额。你看着数据库里写 0.1其实存的是 0.1000000000000000055511151231257827021181583404541015625等到累加求和、生成报表时各种尾差就冒出来了。钱这种东西精度就是纪律一点都不能贪计算性能。4.2 订单状态用事件推导而不是字段维护项目进行到一半业务方提了一个新要求他们想在订单列表看到“待发货”“已发货”“已完成”的状态。按照习惯很多开发会直接在订单表上加一个 status 字段。但在 REA 模型里这些状态本质上是事件是否发生的判断结果。待发货就是存在销售出库事件对应的订单尚未出库已发货就是销售出库事件已经落库已完成则是销售出库和收款事件都完成了。我们前端的列表查询里只需要关联事件表做一个条件判断就能动态算出状态。这样做的最大好处是永远不会出现“状态显示已发货实际却没有发货事件”这种低级矛盾。举个反例。传统模式下某些开发会在订单完成后把 status 直接改成 3。如果之后发现发错了货要退货很多人会先把 status 改回 2再生成退货单。一旦改漏了系统状态就和真实业务不一致后续统计也是错的。而事件模型天然没有这个问题退货就是补一个销售退货事件订单状态依旧是事件聚合的结果不存在“中间状态改一半”的可能。4.3 审计字段与“只追加不修改”的纪律REA 建模因为强调事件不可修改所以在设计上要额外留审计字段。我们给 event 表增加了 occurred_at、operator_id、source_type、source_ref 四列。其中 source_type 用来标记这个事件是从哪一个入口进来的比如是后台手工录入、接口导入还是定时任务生成source_ref 则保存外部流水号方便回头追踪。既然事件只追加不修改那发现录错单了怎么办我们的做法是写一个“冲销事件”把原事件的影响抵消掉再重新补一个正确事件。冲销事件和原事件用同一个 trade_group_id 关联这样用 SQL 查历史时既能回溯原始录入也能看清最终净额。这个思路就跟会计里的红字冲销非常像。我知道很多刚接触 REA 的人会觉得这是“绕远路”但只要你吃过几次“改旧数据改出一堆连带 bug”的亏就会彻底认同这条纪律。5. 常见误区与排查实录5.1 误区一库存直接存在商品表里做完第一个版本后业务方贪图方便想让我们在商品表上直接加一个 stock 字段说这样前端查库存最快。我没有直接答应而是给他演示了一个场景两个仓管员同时入库同一种商品各入 100 件。如果每次都先 SELECT stock 再加 100 再 UPDATE在并发高的时候就会出现丢失更新最终库存可能只有 100 而不是 200。REA 的做法是把入库动作变成一条不可变的事件记录库存则从事件明细里聚合。聚合查询确实比直接读字段慢一点但这可以通过缓存、物化视图、夜间任务解决而不是在源头上引入一个不安全的可变状态。数据源纯净、可追溯永远比“看得快”重要。大家要时刻记得库存只是一个派生值事件才是事实。5.2 误区二用一堆状态枚举表达业务流程不少项目的订单表字段越加越多比如 status_1、status_2、audit_status、finance_status每个字段对应一套状态枚举。新的业务流程一接入就开始排列组合最后没人说得清到底哪些状态能共存只能靠一堆 if 判断来兜底。我的经验是如果你发现某个表的状态字段超过两个并且状态之间有复杂的流转关系那就该考虑把状态还原成事件链了。REA 的事件不是用来取代所有状态但它能替代那些“由事实引发的状态”。例如“是否已付款”这个状态本质上就是存在付款事件“是否已开发票”就是存在开票事件。真正需要保留的只有那些无法由事件直接推导的业务属性比如“该订单被风控冻结”这种外部判定。把该事件化的事件化把该保留属性的保留属性模型就不会乱。5.3 误区三代理角色写死在表字段里我曾经见过一张出库单表字段长这样out_order_id, customer_id, supplier_id, operator_id, auditor_id。看起来考虑得很周全但后来业务要加“代发客户”和“物流承运商”这张表就又开始加字段。每加一个角色就要改一次表结构改一次代码上线一次极其疲惫。REA 环境下代理是一个独立的表事件和代理之间用 event_agent 关联并用 role 字段区分角色。这样一个事件可以关联任意多个代理每个代理承担不同角色新增角色时不需要改表只需要加一行关联数据。我们项目里就靠这个结构在完全不动表 DDL 的情况下先后接入了“销售员”“cc 客服”“财务审核人”三种新角色上线效率高了很多。5.4 误区四把聚合查询硬生生做成实时大宽表另一个很容易犯的错是为了避免每次查询都要 join 好几张表索性把 event_item、resource、agent 全部打平成一个大宽表每天刷新一次。刚开始数据量小觉得很快等数据量大了宽表更新脚本越写越复杂而且一旦某天刷新失败整张报表数据就过期。正确的做法是分复用层。底层的事件表是唯一的真实来源中间可以做一些轻量聚合比如按商品按天生成一个库存快照表但快照表只能通过事件增量计算不能成为业务新写入的目标。这样既保证了查询速度又避免了大宽表维护的灾难。我们项目里就是保留 current_stock 视图用于实时查询同时每天凌晨跑一个 stock_daily_snapshot 表用于趋势报表两边各得其所。6. 从 REA 到实施工具与协作建议6.1 白板优先先画事件再谈表结构很多团队在需求分析阶段就急着画原型、定表结构结果讨论半天都在争论字段名和页面布局。我建议凡是涉及复杂业务流程的新模块都先用“资源、事件、代理”三个词过一遍白板。具体操作是这样的。先把业务里的名词全写出来比如商品、客户、供应商、订单、付款单、入库单、仓库、员工。然后划掉那些其实是“单据”的名词因为单据更像是事件的载体不是领域基础概念。再问一句“这个名词是否会流动、增减、被创建或消耗”留下的一定是资源再问“哪个动作导致了变化”那就是事件最后把参与动作的人和组织标成代理。这个过程一般在 30 分钟内能结束但它能把团队里对业务理解不一致的地方很快暴露出来。比如“库存”到底是资源还是状态有人说是资源有人说是聚合结果。讨论清楚后模型就统一了。我强烈建议把这套流程用在每次需求评审前成本低收益却很大。6.2 建表前的最终检查清单定稿 DDL 之前我通常会过一遍下面的清单作为自查每个资源是否有唯一标识和单位每个事件是否有明确发生时间和至少一条明细每条事件明细是否都带方向、数量和可核算金额每个事件是否都能找到至少一个内部代理和一个外部代理是否存在看起来像事件、却只记录结果不记录过程的字段是否预留了冲销事件和事件分组的关联字段所有金额字段是否使用定点数类型而不是浮点数当前库存视图是否可以在 5 分钟内写出来且不依赖手工任务。如果有一条不满足我就会停下来重新补模型而不是急着让前端先联调。因为表定了再改代价是所有人一起付出时间的平方。6.3 与现有框架如何配合目前大多数项目还是跑在 Spring Boot 这类主流后端框架里。REA 里的“事件表”和消息中间件里的事件不是一回事但两者可以很好地联动。业务动作发生时先在一个本地事务里写入 event 表和 event_item 表提交成功后再发出一个消息事件让下游去消费比如触发财务映射、刷新物化视图、通知上游系统。这样做既保证了核心业务数据的强一致性又让下游任务松耦合。如果项目里使用了 DDD 的分层思想REA 模型里的资源、事件、代理天然适合作为聚合根或实体对象。但我不建议在一开始就把领域事件的附加逻辑搞得太重先落库、后发消息跑通主链路再逐步叠加这样对团队更友好。7. 个人体会与一个小技巧这个“rea”项目从头到尾做了不到三周。第一次按 REA 建模时我们也曾经嫌它麻烦觉得不如直接按 Excel 表格建表快。但等到后期接退货、接代发客户、接财务对账那些当初多花的时间都十倍还了回来。数据模型本身就是业务逻辑的沉淀把模型做对了后面的路就好走了。最后分享一个我常用的检查技巧。每次业务方提一个新需求我都会用“资源、事件、代理”三方向它三个问题这个需求涉及哪些资源它要求记录什么新事件哪些代理参与了这些事件这三问听着简单但只要哪一问答不上来说明需求本身就还没想清楚。这时候不要急着动手写 SQL而是把业务方拉到白板前再捋一遍。等三问都能回答得明明白白开发实现往往只是时间问题。