☰
REA业务建模:用事件流水替代状态字段,让进销存系统不再混乱
2026/10/21 23:33:50 网站建设 项目流程

接触REA(Resource-Event-Agent,资源-事件-代理)这个业务建模框架,纯属被现实逼出来的。那段时间我一直在折腾一套进销存系统,需求方今天说库存口径不对,明天说要加一个“预占”状态,改来改去,ER模型里的字段越来越多,后来一咬牙把所有状态字段全砍了,改成记流水账,世界一下子清净了。后来才知道,这个思路在会计学里早就有人总结过,而且它还发展出了一套完整的建模方法,叫REA。这篇就聊聊我怎么理解它、怎么落地,以及过程中踩过的坑,希望能给正在为业务状态混乱发愁的后端、架构和业务分析同学一点参考。

REA不是某个开源中间件,也不是什么新潮编程语言,它是一套用来梳理业务领域模型的思考方法。核心就三样东西:资源、事件、代理。听起来简单,但真正能把这三种概念分清楚、并且落到表结构上,不是一件容易事。文章里我会用一个模拟的零售仓储项目做完整拆解,从概念到建表,再到代码实现和常见问题排查,尽量做到可以照着做。

1. 为什么是REA:建模范式选择的背后逻辑

1.1 从ER建模到REA的思路转变

绝大部分业务系统最开始建模,都是从ER图开始的。订单表、订单明细表、商品表、库存表,每个表上挂满状态字段。订单有状态,付款有状态,发货有状态,退货又有状态,而状态之间永远有没枚举全的组合。最典型的问题就是“已付款待发货”和“已付款已发货但部分退款”到底怎么表达,需求方和开发经常为此撕扯。

REA的核心思路是:把状态从表结构里彻底删掉,状态只作为事实(事件)累加后的投影。举个例子,传统建模会在一张库存表里保存当前库存数量,每次出库就减数量;REA的做法是只记录“商品出库”这个事件本身,当前库存数是通过所有入库事件减去所有出库事件算出来的。这就像你家里记账,与其盯着存钱罐里的钱数,不如把每一笔收入和支出都写进流水账,需要知道余额的时候,把流水加总一下就行。

这个转变背后的逻辑是:业务事实是不可变的,已经发生的事件不该被修改;而状态是可变的、可推导的,它本质上是事件的投影。把投影当实体存储,就会面临同步更新、状态覆盖、时序错乱等问题。REA把可变部分压缩到最小,让系统核心变成一条只追加的事件流,这是它最让我服气的地方。

1.2 REA与DDD、事件溯源的配合点

如果你是DDD的实践者,很快会发现REA和领域事件天然能合到一起。在DDD里,聚合根负责维护业务不变式,通过领域事件向外发布事实;在REA里,事件同样是核心,资源通过事件转移,代理通过事件参与。可以说,REA给出了一个宏观的业务全景图,DDD则承接了具体的战术设计。

我个人的经验是:用REA来划定系统边界,用DDD来落地聚合和服务。比如“收货”这件事,REA会告诉你这是一个经济事件,必须记录资源增加和代理关系;DDD则会告诉你在哪个聚合根上执行命令,用哪个领域事件去通知下游。两层各干各的,冲突很少。

REA和事件溯源的关系也不复杂。事件溯源要求所有状态都由事件流重建,REA同样鼓励把事件作为系统事实的源头。我实际项目中并没有用全套事件溯源基础设施,而是用REA的表结构加一个不可变事件表,既拿到了追溯能力,又不用引入复杂的事件总线。这一点对很多团队来说是比较务实的折中。

2. 三类核心元素:资源、事件、代理的实操定义

2.1 资源:不是所有业务对象都是资源

资源在REA里指那些会被经济事件增加或减少的有价值的东西。现金是资源,库存商品是资源,供应商提供的服务工时也算资源。但很多常规业务对象并不是资源,比如客户名称、地址、订单编号,这些更像标识或描述,不会作为经济事件的输入输出出现。

我判断一个对象是不是资源,就一个简单标准:会不会出现在“某个事件导致它变多或变少”的描述里。如果会,它就是资源;如果只是用来描述事件背景,它就不是。比如订单上的优惠券,如果优惠券会在核销事件里被消耗掉,那它就是资源;如果只是订单备注里的一个标签,那就不是。

这里还要注意资源的口径问题。同一种商品可能同时有数量单位、库存单位、采购单位,如果建模时不统一,资源表会乱套。我建议资源本身只保存基础标识和分类,把计量单位放到事件明细里,这样同一个资源可以在不同事件中用不同单位记录,换算逻辑放到服务层。很多人一开始把单位绑死在资源表上,后面做采购和销售单位不同的系统时会非常痛苦。

2.2 事件:经济事件的原子性

事件是REA里最核心的元素。它记录的是“什么资源在什么时间、由哪些代理参与、发生了一次增加或减少”。事件具备不可变性、原子性和可追溯性。不可变性是说事件一旦写入就不允许update或delete,只能新增冲正事件;原子性是说这个事件对应的资源增减必须在一个事务里完成,不能拆分。

我踩过的坑是把状态变更也当成事件。比如“订单状态从待付款变成已付款”,这是结果,不是原因。真正的事件应该是“收款成功”,收款导致现金增加和应收账款减少,同时改变订单是否已结清的状态。如果直接记录“状态变更”事件,等于把投影又存了一遍,既冗余又容易产生不一致。

设计事件时要特别关注幂等。同一个外部请求可能被重复提交,比如支付回调重试、消息队列重复投递。我的方案是在事件表上设计业务唯一键,用业务编号加事件类型来保证同一个业务事实只记录一次。这比依赖系统自增ID更可靠,因为自增ID只能保证不重复,不能保证业务上不重。

2.3 代理:内外部代理的划分

代理是事件的参与方。REA把代理分成内部代理和外部代理,分别对应组织内的人和外部客户、供应商。这种划分不是为了分类,而是为了建立明确的责任链。一个经济事件至少要有一个内部代理和一个外部代理,否则很难回答“这件事是谁做的、跟谁做的”。

在我做的零售系统里,一次销售出库事件,内部代理是操作员,外部代理是客户。先记外部代理是哪个客户,再记内部代理是哪个仓管员。这样后续做业绩核算、绩效考核、客户对账时,直接查事件表就能拿到数据,不用再翻好几张业务表拼信息。

代理建模还涉及权限边界。内部代理与外部代理如果混在一个表里,很容易出现外部客户渠道绕过内部审批的风险。我的做法是代理类型单独区分,事件代理表里保存代理ID和角色。角色用来表示代理是执行人、审批人还是对端参与人,一个事件可能有多条代理记录,但至少要保证一部分属于内部、一部分属于外部。

元素核心问题建模建议典型反例
资源什么东西有价值只保留会增减的对象把订单状态当资源
事件发生了什么事实不可变、可追溯、幂等记录“状态变为已发货”
代理谁参与了内外分离、区分角色只记录一个操作员,丢了客户

3. 从业务场景到REA模型:一个模拟项目的完整拆解

3.1 业务背景与最初建模失败

为了讲清楚整个落地过程,我虚构一个模拟项目X:一个零售仓储系统,需要管理商品入库、销售出库、采购付款和客户回款。第一次建模时,我照着传统思路设计了商品表、库存表、采购订单表、销售订单表、收款单表,库存表里直接存当前数量,订单表里存状态。上线后一个月,问题开始冒头。

状态字段多了以后,每个业务操作都要判断“当前状态是否允许执行下一步”。比如售后退货时,如果订单已经关闭,那要不要允许重新入库?假如允许,又要新增一种“已关闭但部分退货”的状态。改了几轮之后,状态枚举变成了巨大的if-else集合,没人能说清楚所有合法路径。同时库存表因为直接更新,经常出现超卖和负数,定位问题只能靠翻日志。

后来我决定用REA重写核心模块,不再保存任何“当前数量”和“订单状态”,只记录事件。第一次跑通时,心里其实很忐忑,因为查询库存要从几十万条事件里去聚合,担心性能崩掉。结果加上投影表和分区之后,查询速度完全可用,而且数据一致性逻辑变得异常干净。

3.2 识别三类元素

重写前花了一天做事件梳理。我把业务故事一条条列出来,然后从中抽资源和事件。这个环节最建议业务分析师一起参加,因为很多隐含的业务规则只有他们知道。

这个零售仓储系统中,我识别出来的资源包括:库存商品、现金、应收款、应付款。事件包括:采购入库(商品流入、应付款增加)、销售出库(库存流出、应收款增加)、收款事件(现金流入、应收款减少)、付款事件(现金流出、应付款减少)、退货事件(分采购退货和销售退货,方向不同)。代理包括:客户、供应商、仓管员、财务员、销售员。

这里有一个很微妙的地方:应收款和应付款其实是派生资源,不是物理存在的钱,但在REA里把它当作资源处理非常有用,因为债权的增减是真实的经济后果。如果只盯着现金和商品,就会漏掉客户欠款这个最重要的业务对象。我第一次建模就漏了它,结果财务对账完全没法自动做。

3.3 设计表结构与关系

识别完成后,我设计了事件表、事件资源明细表、事件代理表、资源表。这里给出一个简化但能运行的结构,方便你直接参考。

CREATE TABLE rea_resource ( id BIGINT PRIMARY KEY, resource_code VARCHAR(64) NOT NULL, resource_name VARCHAR(255) NOT NULL, resource_type VARCHAR(32) NOT NULL ); CREATE TABLE rea_event ( id BIGINT PRIMARY KEY, event_type VARCHAR(64) NOT NULL, occurred_at TIMESTAMPTZ NOT NULL, business_ref_no VARCHAR(128) NOT NULL, location_id BIGINT, CONSTRAINT uq_event_biz UNIQUE (event_type, business_ref_no) ); CREATE TABLE rea_event_resource ( event_id BIGINT NOT NULL REFERENCES rea_event(id), resource_id BIGINT NOT NULL REFERENCES rea_resource(id), quantity DECIMAL(18, 4) NOT NULL, unit CHAR(3) NOT NULL, direction CHAR(3) NOT NULL CHECK (direction IN ('in', 'out')) ); CREATE TABLE rea_event_agent ( event_id BIGINT NOT NULL REFERENCES rea_event(id), agent_id BIGINT NOT NULL, agent_type CHAR(1) NOT NULL CHECK (agent_type IN ('I', 'E')), role VARCHAR(32) NOT NULL );

为什么明细表里用direction字段而不用正负数量?因为不同资源在不同业务里,“增加”和“减少”的方向含义并不统一。现金入账是in,库存出库也是in(对客户来说),如果统一用正负号,语义会乱。保留direction后,聚合库存时用in加、out减,加总逻辑一目了然。至于unit字段,我坚持放事件明细,不放资源主表,就是为了支持同一资源跨不同单位换算。

库存手头量的聚合查询是这样的:

SELECT resource_id, SUM(CASE WHEN direction = 'in' THEN quantity ELSE -quantity END) AS on_hand_qty FROM rea_event_resource GROUP BY resource_id;

这一步完成后,系统里不再有“库存表”,只有“库存投影”,而投影可以随时从事件流重建。这一点在出问题做审计时价值极大。

4. 落地实现的五个关键步骤

4.1 先定事件契约

推荐先写一组事件契约再动手写事务逻辑。契约就是每个事件类型包含哪些字段、哪些资源方向、哪些代理角色。事件类型用过去式命名,比如GoodsReceived、GoodsShipped、PaymentReceived,一眼就能看出这是一件已经发生的事实。字段上要有业务引用号,用来做幂等。

我一般会给每个事件写一个简单的字段表,效果类似接口文档。比如GoodsShipped包含发货单号、出库时间、仓库ID,明细里是每个SKU的实发数量,代理里是发货员(内部)和客户(外部)。契约定了以后,开发不需要反复猜业务含义,新人也容易上手。

4.2 构建事务脚本与服务编排

事件写入必须在一个数据库事务里完成,同时写入事件主表、资源明细表和代理明细表,任何一步失败都要回滚。我可以用服务层方法封装,伪代码大概是:

def receive_goods(po_no, items, operator, supplier): event = Event.create("GOODS_RECEIVED") event.set_biz_ref(po_no) event.occurred_at = now() for item in items: event.add_resource(item.product_id, item.qty, item.unit, "in") event.add_agent(operator, "I", "EXECUTOR") event.add_agent(supplier, "E", "COUNTERPARTY") event.commit()

这里有一个容易忽略的点:必须先创建事件,再根据事件结果更新投影,投影更新不能脱离事件事务。如果先更新库存再写事件,一旦失败就会出现库存变了但流水没有的情况。另一种方案是使用事务性发件箱模式,在同一个事务里把事件写入发件箱表,由后台任务消费更新投影,能解耦但会引入延迟,看项目取舍。

4.3 用投影表状态,而非事件表查状态

如果每次查询库存都从事件表聚合,数据量大了之后性能会很难受。我的方案是维护投影表,也就是把事件聚合结果物化到一张当前状态表里。投影表在事件提交后同步更新,或者通过消费者异步更新。

投影表结构一般和以前的“库存表”长得很像,但写法和语义完全变了。以前的update库存数量是在已有业务上原地改数据,现在只做append事件、同时set投影。事件表是真相,投影表只是缓存。一旦发现投影数据不对,重建投影表就行,不用修改任何历史事实。

CREATE TABLE inventory_projection ( resource_id BIGINT PRIMARY KEY, quantity DECIMAL(18, 4) NOT NULL, updated_at TIMESTAMPTZ NOT NULL );

在事件事务里更新投影,就能保证强一致。如果系统流量大,可以基于事件表做流计算,但至少要接受分钟级延迟。我建议先把强一致版本跑通,再考虑异步方案,否则排查问题会被延迟搞得很痛苦。

4.4 设置一致性边界和幂等

REA建模不会自动解决所有并发问题。同一个资源的两个事件同时发生,如果都用“先查投影再写事件”的方式判断库存是否充足,仍然可能超卖。我的处理是加库存充足校验时使用数据库锁或者基于资源ID的分布式锁,保证同一资源的事件串行化。

幂等同样重要。支付回调可能发两次,采购单也可能因为网络超时被提交两次。我在rea_event表设计了唯一约束(event_type, business_ref_no),一旦重复插入直接报错,服务层捕获后返回成功,而不是继续执行。这个约束从数据库层兜底,比纯应用层判断可靠得多。

4.5 测试与审计

用REA重写之后,测试模式也要跟着变。除了常规的接口测试,我会额外做一个对账测试:随机抽取一批事件,先跑一遍事件聚合结果,再和投影表比较,数据必须一致。这种测试在每次重构后跑一次,能及时发现投影更新遗漏。

审计上要保证事件表不能有人为修改。我给事件表只开了INSERT权限,没有UPDATE和DELETE权限;即使开发账号也不给改。如果业务上确实需要回滚或者冲正,就新增一条反向事件,把之前的错误业务后果抵消掉。这件事和会计原理一样,要给每一笔错误留痕迹,而不是直接删账。

5. 常见问题与排查技巧实录

5.1 把计算资源和物理资源混为一谈

初次建模时很容易把“库存金额”也当成资源。库存金额确实会随出入库变化,但它不是独立的资源,而是商品数量乘成本价的结果。如果把它建成资源,事件里就会出现和商品数量平行的金额事件,两边还容易对不上。我建议派生指标都不建资源,需要时通过事件明细实时计算或另建报表表。

5.2 事件粒度过粗或过细

事件粒度是一个很实际的问题。比如“销售订单”整体算一个事件,还是拆成“订单行”事件?我试过整单事件,优势是写起来快,缺点是退货时如果只退其中一行,就会很尴尬,必须把原整单事件反向冲掉再新建剩余部分。试过拆到每一行事件,查询又会复杂不少。

我的经验是:事件粒度要落在“独立产生经济后果”的原子动作上。一个商品行的销售出库,就是一个独立事件;整单只是聚合根,关联一批行事件。这样退货只对新退的那一行做反向事件,其他行不受影响。事件的聚合根标识(比如订单号)可以作为普通字段保留,不用进主键。

5.3 代理数据缺失

常见问题是只记录内部代理,忘记外部代理。比如入库事件记录了仓管员,却没有记录这个商品来自哪个供应商,导致后面供应商对账时找不到数据。我的强制规则是:每个事件至少一个内部代理和一个外部代理,缺一不可。这条规则可以在服务层的校验逻辑里固化,事件链接约束里也可以做应用层判断,但数据库层比较难强制,所以需要开发自律。

代理缺失的另一个隐蔽场景是退款事件。退款给客户时,可能系统里没有客户信息,只记了一个退款申请号。结果后续做客户分析时,退款事实找不到归属人,数据直接废掉。我发现这类问题往往在后续账单分析时才暴露,所以越早强制代理字段越省事。

5.4 事件表变大之后怎么办

事件表是只追加的,容量增长比状态表快不少,这是REA比较常见的一个性能疑虑。我的应对策略有几点:第一,事件表按月份分区,这样清冷数据和查热数据都很方便;第二,投影表定期重建,防止因长期bug产生漂移;第三,报表查询尽量走数据仓库副本,不要直接压在线事件表。

查询事件流本身如果要按条件过滤,很多场景不需要全表扫。例如查某个客户的回款历史,只要在rea_event_agent表上有agent_id和role的联合索引即可。这里建议给资源明细表加(resource_id, direction)索引,给代理明细表加(agent_id, role)索引,给事件表加(occurred_at, event_type)索引。这些索引建立后,我这边查询性能基本没有瓶颈。

我把排查过程中比较典型的场景整理成了一张速查表,方便大家对照:

现象可能原因排查思路
库存投影越来越不准投影更新遗漏或并发覆盖重建投影,和事件聚合结果对账
重复支付导致资金虚增支付回调没有幂等处理检查事件唯一键,增加数据库约束
事件有,代理对不上客户入账时未写入外部代理补数据,修改服务层强制逻辑
某资源负数严重并发出库未加锁增加资源锁,保证事件按资源串行
历史订单无法部分退货事件粒度过粗按订单行拆分事件,增加冲正事件

6. 个人实操感悟与拓展建议

在实际项目中完整尝试了REA之后,我最大的体会是:它不是一个数据库模板,而是一种改变你看待业务事实的角度。传统建模先画实体关系,REA先讲业务故事。这个先后顺序的变化,让团队从“数据库里要存哪些字段”转向“公司到底发生过哪些事”。尤其是做进销存、财务、订单这类强流程系统,REA能大幅减少状态流转的复杂度。

如果你也想尝试,我的建议是不要一开始就全盘重构,挑一个核心模块先做试点。比如只做库存流水或者资金流水,把状态表和事件表并存一段时间,跑一段时间后再把状态表摘掉。这个过程能让你充分理解投影和事件的关系,而不是盲目照搬。

这个思路后续还可以往两个方向扩展:一是和数据分析结合,事件流本身就是天然的埋点数据,做经营分析、剩余价值预测都更加清晰;二是和微服务结合,每个服务只维护自己关注的资源事件,服务之间通过事件传递事实,不用跨服务查状态。我自己已经在后续两个模拟项目里这么做了,稳定性和可解释性都很让我满意。

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

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

立即咨询