☰
REA模型实战:事件驱动领域建模如何重构进销存系统
2026/10/20 4:04:09 网站建设 项目流程

上个月我给一个进销存项目做架构复盘,翻到三年前画的ER图,鼻子差点气歪。商品表、库存流水表、销售单表、收款单表,四张表之间堆满了状态字段,查询时恨不得用十个JOIN加三个CASE WHEN才能算出“某客户到底欠多少钱”。后来我把这套模型推倒重做,换成了REA模型,也就是以资源(Resources)、事件(Events)、代理(Agents)为核心的事件驱动领域建模方式。这篇文章想聊聊我为什么换、怎么落地、踩过哪些坑,以及如果你也正被订单、库存、财务这类“状态+流水”业务折磨,REA能给你带来什么。

先说清楚,REA不是某个框架,也不是一套开源代码,它更像一种领域建模的思维模型。它最早在会计信息系统研究里被提出,用来替代传统借贷科目模型的底层表达方式。最近几年,在事件溯源、CQRS、领域驱动设计这些话题的影响下,REA重新被后端团队拿出来用。如果你要做进销存、交易系统、账务系统,或者任何需要“业务事实可审计、报表口径灵活变化”的系统,REA值得认真研究。当然,它也不是银弹。我在模拟项目X里试过一次,第一个版本确实比传统CRUD建模慢,但后续加需求、改口径时,节省的时间远远超过前期投入。下面我会从一个完整销售场景出发,把REA的资源、事件、代理三个概念拆开讲清楚,再给出表结构、查询、财务对接、常见坑和选择建议。

1. REA模型到底是什么:三个让领域模型脱胎换骨的概念

1.1 从“单据怎么存”到“业务到底发生了什么”

大部分业务系统建模,天然会从“单据”开始。订单、发货单、收款单、付款单,每张单子都有头表、行表、状态、类型、备注。听起来很自然,但一旦业务复杂起来,这种单据思维会让模型越来越臃肿。原因是单据只是业务过程的呈现方式,同一件业务可能被多个单据重复记录。比如销售开单、财务开票、仓库发货,明明是同一笔交易,却散落在三张表里,状态还不一定同步,经常出现“财务说已收款、仓库说未发货”的扯皮。

REA模型的第一课,就是把提问方式从“这张单据包含哪些字段”改成“这笔业务到底改变了哪些资源、由哪些代理参与、产生了哪些事件”。拿面包店卖面包举例:顾客支付现金买走面包,在传统模型里是一张销售单加一张收款单;在REA里,核心只有三件事:库存里的面包这个资源减少了,现金这个资源增加了,销售事件发生了;参与者是顾客和收银员。概念说起来简单,但它带来的影响很大:单据是结果快照,事件才是业务发生的现场。单据可以被补录、伪造或覆盖,而事件一旦按事实流记录下来,整个系统的审计和追溯基础就完全不一样。

1.2 资源、事件、代理三者关系怎么读

REA的名字就是三个核心概念,要完全读懂它,得知道它们之间的三种关系。第一种是事件和资源的关系。所有经济事件都会影响至少一个资源,要么让它增加,要么让它减少。销售事件让“库存面包”减少、让“现金”增加;采购事件让“原材料”增加、让“现金”减少。这个关系一定要落到明细行,记录数量、单价、金额,以及方向是入还是出。很多人一开始会问“资源表里要不要直接存当前数量”,我的答案是不要,资源表只存初始值和属性,当前数量一律由事件聚合推导。你可以把资源想象成一个咖啡杯,事件是往里倒水和往外倒水的动作,当前水位由所有动作累计得出,而不是在杯子上刻一个会过期的数字。

第二种是事件与事件的关系。REA里最核心的是对偶(duality)关系:一个增加资源的交换事件,往往对应一个减少资源的交换事件。比如“销售”和“收款”是一对,因为销售增加了应收资源,而收款减少了应收资源;“采购”和“付款”也是一对。生产场景里还有转换链,比如领料事件和产成品入库事件是前后衔接的。第三种是事件与代理的关系。每个经济事件至少有两个参与代理:一个内部代理,比如销售员、收银员;一个外部代理,比如顾客、供应商。这条关系看似多余,但它是日后做权限、对账、部门考核的重要依据。

有些扩展版本会加L,变成REAL,也就是增加Location维度,用来回答“发生在哪个门店、哪个仓库”。我建议刚开始不要一上来就加L,先把资源、事件、代理和对偶关系做扎实。位置可以当资源的一部分来处理,否则表结构会迅速膨胀,最后反而把核心关系淹没在大量边缘字段里。

1.3 为什么说REA是更接近真相的语义模型

传统会计里,凭证和科目是对业务结果的汇总视图。一笔销售,会计看到的是“借:应收账款,贷:主营业务收入”,它已经把业务简化成一个记账规则。问题在于,当你想分析“某个客户这三个月买过什么、退过多少、实际回款几次”,会计视图给不了你,因为它的目的就是汇总,不是记录细节。REA则相反,它把业务事实完整保留下来,任何你想得到的报表,都可以从事件流重新聚合出来。库存余额是初始库存加上所有IN事件减去所有OUT事件;应收余额是销售事件总额减去收款事件总额;毛利是销售事件金额减去对应的库存成本。

口径统一、来源唯一,这是REA最值钱的地方。以前销售部说GMV、财务部说收入、仓库说发出商品,三个数字各差一点,每次开会都在“数据打架”。换成REA之后,所有视图都是同一套事件流投影出来的,不会出现数据仓库里“同一个指标三种解释”的尴尬。我也是在那一刻意识到,模型离业务真相越近,后面维护成本和认知负担就越低。

2. 我为什么在一个进销存项目里把模型换成REA

2.1 旧模型让我崩溃的三个瞬间

做重构前,旧系统的核心是一张超大流水表,里面什么都有:出入库、销售、退货、调拨,全靠一个type字段区分,再加上source_type和source_id去关联各种单据。第一个让我崩溃的时刻,是需求方提了一句“我想看这个月每个客户实收多少钱”。我一看表结构,现金流水和销售流水混在一起,对账逻辑要先把销售流水按客户分组,再把收款流水按客户分组,最后还要处理退款和折扣,一个简单的实收指标,排查两天才把SQL写对。

第二个崩溃时刻是加新业务类型时,一个“退货待确认”的状态牵扯了五张表。要把原来的状态机改一通,还要写一堆兼容代码。只要业务类型增加,表结构就跟着膨胀,Java代码里的枚举和if分支越来越多,代码评审时大家都很沉默。第三个崩溃时刻是财务对账。每月的凭证视图实际上是“把流水表重新翻译成分录”,这套翻译逻辑塞在报表SQL里,复杂到没人敢动。每次口径变化,都要拉上几个人对照旧报表算一遍,效率极低。这三个场景其实是业务型系统的通病:把结果与过程混在一个模型里,用大量状态和类型字段来模拟业务演化。REA要解决的,恰恰是“过程”与“结果”的分离。

2.2 换成REA之后,结构的收益立竿见影

重构时我用事件表做唯一的事实来源,把资源表只当作“属性档案”,把余额和报表当成推导结果。第一个直观收益是复杂度掉了一大截:任何业务行为都变成“事件,以及事件和资源、代理的连线”。后面新增一个“积分兑换”业务,不需要在订单表上增加积分状态字段,只需要新增一个“积分兑换事件”,再连上“库存商品”和“积分资产”两个资源,核心表结构完全不用动。

第二个收益是口径收敛。以前销售、库存、财务各算各的,虽然数字勉强对得上,但谁也不敢保证完全一致。现在所有模块都从同一套事件推导,销售看事件、库存看事件、财务也看事件,至少在一个系统内实现了“同一个事实,同一种算法”。第三个收益在审计上。事件表我用不可变追加的方式来维护,每条事件有业务单号(biz_no)做幂等,系统即使收到重复请求,也不会重复入账。遇到纠纷时,可以完整还原一笔交易的来龙去脉。这在传统流水表里很难做到,因为传统流水表一旦被UPDATE,原值就没了。

2.3 一张表看懂REA和传统模型的区别

为了在评审会上说服团队,我做了一张对比表,现在拿出来也很有用。

对比维度传统流水/单据模型REA模型
建模中心单据、凭证、状态机业务事件、资源变化
业务扩展加类型、加状态、改表结构增加事件类型与连线关系
报表口径多个系统各算各的从同一事实流推导
审计追溯依赖单据流转日志事件本身即全链路事实
学习成本上手直观,后期痛苦前期反直觉,后期省心
典型适用面界面展示为主的管理后台交易、账务、审计敏感系统

这张表并不是说传统模型一无是处,而是提醒我:如果系统未来注定要经历高频变化,从第一天就应该把事实和投影分开。很多项目之所以后期重构,就是因为当初只考虑了“现在有什么表单”,没考虑“未来业务会怎么演变”。

3. 用一个销售场景把REA落地到表结构和代码

3.1 先把销售这件事拆成一条事件链

落地的第一步不是写表,是先画事件链。以一次标准零售销售为例,我通常把它拆成四类事件。第一是销售事件。它把“库存商品”资源调低,把“应收账款”资源调高(如果没当场收款),或者把“现金”资源调高(如果当场结清)。参与代理是收银员和顾客。第二是收款事件。它把“应收账款”资源调低,把“现金”或“银行存款”资源调高,与销售事件构成对偶关系。第三是退货事件,当顾客退货时,资源方向全部反过来,并且与原销售事件建立冲销关系。第四是盘点调整事件,它不直接对应某个客户或供应商,而是解决账面和实盘不一致的问题。

每个事件都要有明确的业务编号、发生时间、描述。这里有个细节容易忽略:销售和收款必须拆开,不能合并成一个“销售完成”事件。因为销售发生和实际回款的时间经常不一致,金额也可能是分次结算。把这两个事件拆开,后续做应收账龄、对账、核销都会容易很多。还有一点,不要把“开票”当作一个经济事件。发票是法务和税务流程节点,它不直接改变资源,强行把它塞进REA事件链只会增加噪音。

3.2 建表SQL到底怎么设计才不翻车

我落地时用的表结构大致是这样的,用你熟悉的SQL方言调整一下就能用。

-- 资源表:有价值的东西,一般不做数量变更 CREATE TABLE res_resource ( id BIGINT PRIMARY KEY, resource_code VARCHAR(32) NOT NULL UNIQUE, resource_name VARCHAR(128) NOT NULL, unit VARCHAR(16), initial_qty NUMERIC(18,4) DEFAULT 0 ); -- 事件表:业务事实,追加写入,不做物理更新 CREATE TABLE evt_event ( id BIGINT PRIMARY KEY, event_type VARCHAR(32) NOT NULL, biz_no VARCHAR(64) NOT NULL UNIQUE, occurred_at TIMESTAMP NOT NULL, description TEXT ); -- 事件与资源的连线 CREATE TABLE lnk_event_resource ( event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction VARCHAR(4) NOT NULL CHECK (direction IN ('IN', 'OUT')), quantity NUMERIC(18,4) NOT NULL, unit_price NUMERIC(18,4), amount NUMERIC(18,4) NOT NULL ); -- 事件与代理的连线 CREATE TABLE lnk_event_agent ( event_id BIGINT NOT NULL, internal_agent_id BIGINT NOT NULL, external_agent_id BIGINT NOT NULL ); -- 事件对偶关系:销售事件与收款事件 CREATE TABLE lnk_event_duality ( sales_event_id BIGINT NOT NULL, cash_event_id BIGINT NOT NULL, matched_amount NUMERIC(18,4), matched_at TIMESTAMP );

这里有几个设计决策背后是有讲究的。金额和数量一律用定点数,比如NUMERIC(18,4),而不是浮点,因为钱和库存经不起浮点误差。事件表里的biz_no必须唯一,它是幂等写入的依据,比如支付回调重复通知时,靠它直接丢弃重复事件。direction字段用IN和OUT两个值,不要用1、0这种不直观的编码,减少后续查询时的心智负担。还有一条铁律:不要在事件表上加一堆业务状态字段。状态是事件处理过程中暂时性的东西,真正的当前状态应该由事件聚合推导,而不是散落在事件行里。如果实在需要展示用状态,单独建投影表,不要污染事实层。

索引方面,我一般会在evt_event的event_type、occurred_at上建复合索引,在lnk_event_resource的resource_id上加普通索引,在lnk_event_agent的external_agent_id上加索引。因为这些表是“查询入口”,没有索引的话,后面做聚合分析会全表扫描。另外,这里的ID我故意用应用层生成,方便在分布式场景下保证全局唯一,也方便事件和连线表之间按ID做批量插入。

3.3 查询设计:从事件回溯出余额和报表

有了事件和连线,查询就变成聚合推导。比如算每个客户当前应收账款余额,思路是:先统计所有销售事件产生的应收总和,再减去所有匹配掉的收款金额。SQL大致是这样:

SELECT a.external_agent_id, SUM(CASE WHEN e.event_type = 'sale' THEN r.amount ELSE 0 END) AS billed, SUM(COALESCE(d.matched_amount, 0)) AS received, SUM(CASE WHEN e.event_type = 'sale' THEN r.amount ELSE 0 END) - SUM(COALESCE(d.matched_amount, 0)) AS balance FROM lnk_event_agent a JOIN evt_event e ON e.id = a.event_id JOIN lnk_event_resource r ON r.event_id = e.id LEFT JOIN lnk_event_duality d ON d.sales_event_id = e.id WHERE a.internal_agent_id IS NOT NULL GROUP BY a.external_agent_id;

这个查询在数据量小的时候没问题,数据量一旦上来,实时聚合就会变慢。所以我在项目里引入了投影表,或者叫读模型。每天晚上定时重建,或者在事件写入后异步更新投影。查余额时只走投影,不扫描事件表。这块其实就是事件溯源加CQRS的思路:事件是写模型,投影是读模型,两者口径由同一套事件流保证,不会产生“库存表数值对不上”的尴尬。我见过一些团队在这里硬扛,结果每次报表查询都要扫几百万行事件表,最后被迫回退到各种冗余表,那就失去了REA的优势。

3.4 把事件翻译成财务凭证的最后一公里

很多团队会有疑虑:REA模型不生成借和贷,财务怎么认?我的做法是加了一个“科目映射服务”。它不做业务计算,只做规则翻译:根据事件类型、资源类型、方向,决定对应科目的借或贷。下面是一张简化映射表:

事件类型+资源方向借方科目贷方科目
sale / OUT / 库存商品主营业务成本库存商品
sale / IN / 应收账款应收账款主营业务收入
receipt / OUT / 应收账款银行存款或现金应收账款
purchase / IN / 原材料原材料应付账款
purchase / OUT / 现金应付账款银行存款或现金

财务部日常看到的依然是凭证界面,但凭证已经不是手工录入的结果,而是从事件流自动翻译出来的视图。这样既保住了财务人员的习惯,又让业务底层保持事实流。这个“翻译层”是REA落地时最容易被忽略的一环,强烈建议从一开始就设计上。否则事件流再漂亮,对接不了总账和税务,项目也很难推下去。

4. 落地REA必踩的四个坑,以及排查经验

4.1 坑一:财务看不见凭证,心里发慌

重构之后第一次给财务演示,对方第一句话就是:“凭证呢?我每天要打凭证的。”这是正常反应。解决方案不是把REA推回给财务,而是快速做出一个“还原财务会计视图”的报表模块,让财务看到他们熟悉的凭证和科目余额表,同时告诉他们底层数据现在更规范了,每个凭证都能追溯到具体事件和单据。这个坑的本质其实是变更管理问题。技术团队觉得事件模型更优秀,但业务协作方只关心每天的操作界面和输出物。所以每次改动大模型时,一定要留一个“兼容输出层”,不要让对方一次性接受一个完全陌生的概念。

4.2 坑二:事件录错了,能不能修改

事件不可变的规则听起来很理想,但实际业务中,比如录入金额错了怎么办?我的答案是:不直接更新原事件,而是新增一条冲销事件,把原事件标记为已冲销,然后重新录一条正确事件。这样财务报表上的收入和成本会有一个“红冲”的过程,审计可以看见完整操作,而不是看到某个金额被静默改成另一个值。具体操作上,我给事件表增加一个canceled_event_id字段,或者在冲销关系表里记录下来。删除订单也一样,不要物理DELETE,而是加一条负向销售事件,让库存和应收自然回滚。这样设计的好处是,任何“删除”都变成了一次可审计的业务行为,而不会因为误删导致整个数据链条断裂。

排查时,如果发现某个汇总数字不对,第一步要去查是不是有人直接修改或删除了事件。我在系统里专门写了一个巡检任务,扫描那些存在修改痕迹或者物理删除残留的记录。一旦发现,立刻从备份时间点重建事件,再看投影表重新计算。这听起来麻烦,但它保证了整个系统的可信度。

4.3 坑三:账面库存和实盘库存永远对不上

就算事件记录得再准确,仓库也可能出现损耗、移库不登记、盘点差异。如果只依赖事件聚合得出库存,账面数和实盘数迟早会有偏差。很多团队在这种场景下会直接update资源表的数量,这恰恰违背了REA原则。正确做法是定期执行盘点事件,把所有差异都还原成“盘点调整事件”,关联一个特殊的“库存差异”资源或者直接把差异金额化。盘点后实盘数与账面数之间的差,会作为一次事件进入事实流。这样以后审计时,看到的是“7月盘点短少两件商品”,而不是某天库存数量被悄悄改了10个。账面数、实盘数、差异数,三套数都能对上。

实操时还有一个细节:盘点事件本身要由谁来发起?我建议做成独立的盘点批次,每次盘点生成一个批次号,所有差异都挂在批次下。报表上不仅能看“今天少了多少货”,还能看“这个季度每次盘点是哪个仓库、哪个责任人在场”。这比一张干巴巴的调整记录有说服力得多。

4.4 坑四:把REA做成什么都能塞的大事件表

我见过最糟糕的落地方式,是把所有业务行为不加区分地塞进一张事件表,结果那张表里有几十个字段,事件类型五花八门,很多字段对大部分事件都是空的。这其实违背了REA的初衷,因为REA不是“通用事件表”,它要求在一个限界上下文里明确事件语义。建议是在一个业务域内设计一种“最小事件集合”,比如交易域就这样几类事件:销售、采购、收款、付款、退货、盘点。超过这些,要么它属于另一个上下文,而不是硬塞进来。每个事件要追问它到底改变了哪个资源,如果改变不了任何资源,大概率它是流程节点而非经济事件,应该单独建模,别混进事实流。

排查时如果发现事件表字段逐渐失控,我有个简单办法:每个事件列一张“必须填写的字段清单”,不属于清单的字段一律不允许出现在事实表里。宁可在投影层多做几个视图,也不要让事实层变成垃圾桶。

5. 什么时候该选REA,什么时候别碰

5.1 三个适合信号

第一,业务需要回答“为什么”和“如何演变”,例如争议对账、交易审计、库存追溯,系统不能只给一个最终值。第二,报表口径经常变,今天按订单统计、明天按客户维度、后天按渠道维度。如果底层事件保存完整,新口径只是新增一个查询或投影。第三,团队愿意接受不可变数据和事件建模的理念,愿意在前期多花一点设计时间,而不是急着两个月上线。只要三个信号占两个,REA的收益基本就能跑出来。

5.2 三个不该硬上的场景

第一种是纯展示型后台,只需要把几张表单的数据管理起来,业务规则几年不变,用传统CRUD更快。第二种是团队大都是新手,也没有领域驱动设计的经验,硬上REA容易在事件语义上吵半天,最后做成一张大杂烩表。第三种是现有系统已经稳定运行多年,所有报表、权限、财务流程都绑死,重构收益小于风险。这种情况未必需要推倒重来,可以在新模块上先试点,比如先做一个对账中心,用REA承载原始流水,再慢慢向周边铺开。

5.3 我的一点最终建议:从小闭环开始

如果你决定试试REA,我的建议是不要第一天就规划完整的企业级事件模型。找一个最小闭环,比如“商品销售到收款”这个场景,先落一张资源表、一张事件表,再加两类连线表。等这个闭环跑顺了,团队对事件的理解成熟之后,再逐步扩展到采购、退货、调拨。我在模拟项目X里的实际体验是:第一个版本比传统建模大概慢三成,但第二次迭代加“账龄分析”功能时,几乎所有需求都只是新增一个查询或投影,没有任何表结构变动。这种“后劲很足”的感觉,是我愿意继续用REA的最大原因。

最后再说一个小技巧:模型的每个事件,最好都配一句话的业务接受标准,比如“这个事件发生后,库存商品在报表上应该减少多少”。别小看这句话,它能逼着团队在写表之前先把业务语义讲清楚。数据模型的分歧,绝大多数都不是表结构问题,而是业务语义没有对齐造成的。REA只是把这个问题提前暴露出来而已。

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

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

立即咨询