☰
REA建模实战:用事件流替代状态字段,构建可追溯的资源、事件与代理人体系
2026/10/17 4:25:22 网站建设 项目流程

最近在梳理“项目代号 rea”的资料时,不少同事对这个缩写的第一反应是“read”“reply”之类的英语单词,等我把全称写出来,大家才明白:它就是 Resource / Event / Agent,翻译过来是“资源、事件、代理人”。我当时的想法很简单:内部某供应链项目里,库存账越维护越乱,字段越加越复杂,最后不得不换一套建模思路。rea 不是新的前端框架,也不是某个协议,它就是一套把业务流水重新组织起来的数据建模方法。

如果你正在被各种“状态位”和“大宽表”折磨,比如明明只是调整库存,却要同时改四五个字段,或者想追溯一条数据到底怎么变成现在这个样子的,却发现根本没有操作痕迹,那这篇文章会比较适合你。我会从问题讲起,把 rea 的资源层、事件层、代理层分别拆开,再给一套可以直接落地的表结构、接口设计,以及几个真实场景的推演过程。整个方案本质上不复杂,难点在于你能不能忍住不去改那张“状态字段表”。

1. 从状态字段的坑说起:我为什么决定放弃“库存大表”

1.1 一张表堆十个字段之后,问题就来了

最初接到这个供应链项目时,现状非常典型:仓库所有数量变化都落在一张“库存快照表”上。表里有库存总量、可用量、锁定量、在途量、冻结量、待检量,再加上各种更新时间戳和操作人字段,随便一列就是十几个。看起来每个字段都有道理,可业务一变化,这些字段之间的关系就非常模糊。

举一个最常见的例子:用户在商城下单,前端生成订单后,需要先锁定库存,然后再扣减可用库存。按照传统做法,这是一个事务里的连续动作:先把“锁定量 +1”,再把“可用量 -1”。如果两个字段不在同一行里,或者更新顺序反了,就会出现“库存总量没变,可用量变成负数”这种诡异现象。更麻烦的是,一旦出现“锁定后又取消锁定”“部分发货”“部分退款”,你需要维护的状态组合呈指数增加:哪些锁定已释放、哪些锁定已过期、哪些退款得回补库存,全部要靠脑子去计算。

我当时把整个系统的库存相关 Bug 翻了一遍,发现大多数问题都出在同一处:我们在用一个“结果变量”去表达一串“过程动作”。仓库真正发生的动作是什么?是“入库”“出库”“盘点”“调拨”“锁定”“释放”。而我们却把这些动作拍平成了字段:available_qty、locked_qty、in_transit_qty……每个动作发生后的结果被存下来了,动作本身的因果关系却被丢掉了。

1.2 不要把余额改来改去,要记录每一笔流水

这件事让我想起一个生活里的场景:银行账户从来没有“可用余额表”让你直接改数字,它保存的是每一笔收入、支出、冻结、解冻流水,余额只不过是把流水加总后算出来的一个视图。如果银行只给你一个“当前余额”字段,那存钱、取钱、刷卡冻结、退款解冻全部都得更新同一个字段,最后谁也没法解释这个余额是怎么来的。

rea 要解决的核心问题,就是这种“只记录结果、不记录过程”的建模缺陷。它把业务世界拆成三个层面:先确认有哪些东西可以被分享、被交换、被消耗,这些是资源;再确认这些资源在什么时间点发生了增、减、冻结、释放,这些是事件;最后确认每笔事件到底由哪个主体推动、哪个主体接收,这些是代理人。库存不再是表里的一个字段,而是由一组事件推导出来的动态结果。

这套思路最直接的价值是:任何人看到一批事件,都能按时间顺序把业务回放一遍。你不会再遇到“为什么库存少了 3 件”这种查半天查不到答案的问题,因为所有导致减少的事件都明明白白躺在流水表里。

2. rea 的基本骨架:资源、事件、代理人到底是什么关系

2.1 三个名词,先拿业务对象套一遍

在 rea 模型里,“资源”不单指商品。只要能被业务识别、被分配数量或资格的东西都可以算资源:仓库里的货品、一个库位、一笔预算额度、一张优惠券的剩余次数、甚至一个工单的处理工时,都可以建模成资源。

“事件”则对应资源上发生的每一次变化。注意,这里不是“订单状态变成已发货”这种状态描述,而是具体的、可度量的业务动作,例如“从供应商处收货 10 件”“发往客户 3 件”“盘点后调整库存 -2 件”“为某订单锁定 5 件”。事件必须有一个时间、一个资源、一个数量变化、至少一个代理人。

“代理人”是事件的责任主体。它可以是人,也可以是部门、系统、供应商、客户这一类业务单位。之所以把代理人也抽象出来,是因为很多业务纠纷最后都要回到“谁在什么时间对资源做了什么事”这个问题上。

如果把三者的关系画在纸上,就是这样一种结构:

  • 一个资源会参与多笔事件;
  • 每笔事件至少关联一个资源;
  • 每笔事件可以关联一个或两个代理人,比如“A 把货交给 B”就是 fromAgent 和 toAgent 两条链路;
  • 同一个代理人有自己的资源归属,部门有预算额度、客户有账户余额、仓库有可配货范围。

用一句话概括:资源是被作用的对象,事件是作用本身,代理人是作用的发起方与接收方。数据模型不围绕“单据”建,而是围绕“动作”建。

2.2 用一个采购入库事例把模型完整走一遍

假设业务场景是:某仓库采购了 100 件商品,供应商送货上门,仓库验收入库。传统建表会建一张采购单、一张入库单,再用字段记录“待收货”“部分收货”“已入库”。rea 的做法是:

  • 资源层:建立商品资源实例,比如“SKU-10058”在“常温仓 A 区”,初始库存为 0。
  • 事件层:生成一笔“采购到货入库事件”,delta_qty 为 +100,发生时间是送达验收完成的时间。
  • 代理层:fromAgent 是“供应商”;toAgent 是“仓库收货组”,实际录入操作人可以作为第二个 agent 绑定在事件上。

这个加工出来的结果,是“SKU-10058 的可用量现在为 100”。这 100 不是直接写进 resource 表的,而是由事件累加得到的。以后如果发生退货,就再记录一笔“采购退货出库事件”,delta_qty 为 -100。这样,从第一次入库到最终退完货,整个流程可以完整还原。

这个例子看起来很基础,但它揭示了一个关键转变:传统模型问的是“现在还剩多少”,rea 问的是“它这一路是怎么变成现在这个数的”。存量一旦可以被任意状态字段表达,就会失去追溯能力;而流量一旦被记录,存量随时可以重算。

3. 从零搭一个能用的 rea 最小系统:三张表的设计

3.1 资源表:先区分“实例型资源”和“额度型资源”

落地时,我不建议真的只建一张“资源表”然后所有东西都往里塞。可能更贴近实战的是先分两种资源:一种是单件可追踪的资源,比如某个序列号对应的设备;另一种是额度型资源,比如某类商品的总库存、可用预算、优惠券剩余量。两者可以共用一套表统一抽象,只是业务上要识别清楚。

资源表可以精简成下面的样子:

CREATE TABLE rea_resource ( rid TEXT PRIMARY KEY, rname TEXT NOT NULL, res_type TEXT NOT NULL, -- INSTANCE / QUOTA unit TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'ACTIVE', -- 仅作为物化状态 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

这里要强调一下status字段。它存在的意义是给前端查询提供“当前资源是否启用”的快捷判断,但它不是凄存量的真实来源。真正决定库存数量的,永远是事件表。如果你把 status 当作业务约束的唯一依据,那就又走回老路了。

对于实例型资源,resource 表里一个rid就代表一个完整实体。对于额度型资源,rid代表一个计量对象的聚合体,比如“常温仓 A 区的 SKU-10058”,它可以被多笔事件加减。总数不放在这张表里,而是靠rea_event表求和,或定期物化到一个读模型。

3.2 事件表:只增不改,这是 rea 的命根子

事件表是整个模型的核心,也是最容易出现设计分歧的地方。我的落地版本如下:

CREATE TABLE rea_event ( event_seq BIGSERIAL PRIMARY KEY, event_type TEXT NOT NULL, -- RECEIPT / ISSUE / TRANSFER_OUT / TRANSFER_IN / RESERVE ... request_id TEXT NOT NULL UNIQUE, occur_at TIMESTAMP NOT NULL, rid TEXT NOT NULL REFERENCES rea_resource(rid), delta_qty NUMERIC NOT NULL, source_agent_id TEXT, target_agent_id TEXT, biz_no TEXT, memo TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );

几个关键规则,都是我后来踩坑后总结出来的:

  • request_id必须有唯一约束。它是幂等的关键,哪怕前端因为网络超时重复提交十次,数据库也只会插入一条有效事件。
  • delta_qty我建议统一用有符号数值表达。入库写正数,出库写负数。不建议再拆一个“入库数量”和一个“出库数量”字段,那会让汇总 SQL 变得冗余且容易写错。
  • source_agent_id和target_agent_id是可选的。比如一次单纯盘点调整,可能只有操作人,没有 from/to 的传递概念。但大多数事件仍然应该尽量把“谁交出来”“谁接收”两个角色表达清楚。
  • 核心约束:数据落库之后,业务上不允许 UPDATE 和 DELETE。如果事件录错了,正确的做法是再补一笔冲销事件,把影响抵消掉。这样才能保留完整的证据链。

按这个结构,某个资源当前的库存计算公式可以是:

SELECT rid, SUM(delta_qty) AS current_qty FROM rea_event WHERE rid = :rid GROUP BY rid;

如果数据量太大,长期SUM全表会吃力。这时候再加一个物化读模型,每隔一段时间把事件累加结果同步过去,但原始事件表依然保留。

3.3 代理人表:不要把人名直接塞进事件表

有人会说,事件表里已经有 source_agent_id 了,那直接存“张三”不就好了?我的建议是不要这么做。人不是稳定的业务主体:部门可能要改编制,供应商的联系人会更换,系统账号也可能被回收。如果把“张三”作为 agent 写死,后续人员调整时,历史事件的责任归属就会断裂。

我会把代理人抽象成表:

CREATE TABLE rea_agent ( aid TEXT PRIMARY KEY, agent_type TEXT NOT NULL, -- PERSON / DEPT / SUPPLIER / SYSTEM agent_name TEXT NOT NULL, org_path TEXT );

事件表里存的aid指向这条代理记录。实际操作人确实是一个人,业务上也常常需要精确到人,但人首先是一个“代理人身份”,然后才是一个“自然账号”。后续无论账号怎么换人、部门怎么调整,这个 agent 不会凭空消失,历史审计链路始终是完整的。

4. 一次前端操作到事件落库:接口和服务的完整调用链

4.1 出库动作的请求到底长什么样

很多同事刚接触 rea 时会有疑问:把库存表换成事件表,接口是不是也得翻天覆地?其实接口依然很简单,只不过语义变成了“提交事件”。比如一次出库动作,请求大概长这样:

{ "eventRequestId": "REQ-20250618-001", "eventType": "ISSUE", "occurredOn": "2025-06-18T10:00:00+08:00", "resourceId": "SKU-10058@WH-A", "delta": -2, "sourceAgentId": "AGENT-DEPT-SALES-001", "targetAgentId": "AGENT-CUST0001", "bizNo": "SO-20250618-0001" }

eventRequestId是调用方生成的唯一流水号;delta是业务语义上的变化,出库为 -2;sourceAgentId表示资源当前归属的销售部门,targetAgentId表示接收方客户。后端收到这个 JSON 后,可以按下面几步处理:

def create_event(req): # 1. 幂等检查 if event_repo.exists_by_request_id(req.eventRequestId): return event_repo.get_by_request_id(req.eventRequestId) # 2. 资源检查 resource = resource_repo.lock(req.resourceId) if resource is None: raise BizError("资源不存在") # 3. 业务校验:按事件类型执行不同规则 if req.eventType == "ISSUE": available = event_repo.sum_quantity(req.resourceId) if available + req.delta < 0: raise BizError("库存不足,不能出库") # 4. 插入事件,作为一个事务 event = Event( request_id=req.eventRequestId, event_type=req.eventType, rid=resource.id, delta_qty=req.delta, source_agent_id=req.sourceAgentId, target_agent_id=req.targetAgentId, occur_at=req.occurredOn, biz_no=req.bizNo, ) event_repo.save(event) # 5. 更新物化读模型(可选,但有利于高频读) materialized_repo.apply_event(event) return event

这里唯一需要小心的是“资源检查”和“事件插入”之间可能存在的并发问题。最稳妥的方式是在同一个数据库事务里给rea_resource对应行加锁,然后再执行SUM校验和事件插入。如果出现高频并发操作同一资源,可以先把事件插入,再做异步校验,等发现负库存时补一条“库存校正事件”来纠正,但这种方式只适合允许适度超卖的场景。

4.2 库存显示不能每次扫全表:物化读模型怎么同步

事件表如果只有几千行,怎么查都无所谓。业务上到十万、百万行之后,每个前端页面都要实时SUM就会很吃力。这时候处理方式不是回到“状态字段”,而是增加一个“读模型表”。

读模型表本质上就是一张镜像表:resource_id, available_qty, locked_qty, updated_version。它只是让你查询更快,真正的数据源依然是rea_event。

更新方式有两种,我做过对比:

一种是同步更新。在创建事件之后,立刻更新对应的读模型行,适合一致性要求很高的场景。缺点是对同一个资源的高频操作会形成热点写入。

另一种是异步周期更新。每隔几秒或几分钟,把新增事件聚合到读模型,前端可以接受秒级延迟。适合展示型页面,比如库存大屏、报表中心。

我个人更推荐把两者结合:重要业务接口走同步更新,报表展示走异步同步。但无论哪种方式,记住一句话:读模型可以丢,事件表不能丢。读模型丢了可以从事件表重新算,事件表丢了,整个审计和追溯体系就彻底没了。

5. 用三个真实场景验证这套模型是否真的能打

5.1 案例一:库存盘点差异必须敢记“盈亏事件”

库存盘点是最容易暴露建模缺陷的场景。传统做法是:盘点人员报一个“实际库存”,系统直接把库存表的qty改成这个数,然后附上一句“盘点差异已处理”。问题在于,“实际库存=100”和“原来账面=102”这两个信息被覆盖进了同一个数字里,事后没人知道这 2 件到底是之前漏记了入库,还是真丢了。

用 rea 处理,我会拆成两个事件:

  • 第一笔记录“盘点观察结果”:假设该资源账面库存是 102,现在盘点实际数量是 100,记录一个RECOUNT事件,delta 为 0,但在 memo 里写明“账面 102 / 实盘 100”。
  • 第二笔记录“盘点调整”:一个STOCK_ADJUST事件,delta = -2,来源代理人是“仓库盘点组”,目标代理人是“财务复盘人员”。

为什么必须分成两笔?因为“盘点观察结果”是动作,“账面差异调整”是动作带来的结果。两者分开后,如果财务复核发现盘亏原因,可以直接针对差异事件做解释或追责,而不会把整个盘点动作都推翻。未来如果发现盘点是误盘,还可以补一笔STOCK_ADJUST_REVERSE事件,把 -2 冲销掉,重新变成 -0,原始事件依然保留。

5.2 案例二:跨仓库调拨是一次“出库+入库”,不是改一个状态

很多系统把调拨实现成一张调拨单,单子上有一个“状态”字段:待发、在途、已签收。看起来没问题,但一旦中间环节滞留,整个调拨单的状态就像卡住了一样,你没法单独知道货到底还在 A 仓,还是已经在路上。

rea 的做法是把调拨拆成两个相对独立的事件:

  • TRANSFER_OUT:A 仓出库,delta = -2,sourceAgentId = A 仓库,targetAgentId 可以设为“运输在途”或“B 仓库待收”。
  • TRANSFER_IN:B 仓入库,delta = +2,sourceAgentId = “运输在途”,targetAgentId = B 仓库。

两次事件通过同一个biz_no关联。这样做的好处是:A 仓的库存立刻减少,不会因为 B 仓迟迟不收货而导致 A 仓超卖;B 仓在没收到货之前,库存不会虚增。至于“在途量”,就是biz_no相同且只存在TRANSFER_OUT还没有TRANSFER_IN的事件集合,完全可以从事件表实时计算,不需要再加一个“在途字段”。

5.3 案例三:预占库存时,别写 locked_qty 字段

系统需要“锁库存”,最直觉的写法是给库存表加一个字段:locked_qty。可一旦锁定、取消、过期、转实际扣减几个动作并发,这个字段就会变成一块试验田:你根本不知道某一次加锁到底有没有被后续流程释放正确。

在 rea 里,锁库存也是事件:

  • RESERVE:为某订单锁定 2 件,delta = -2,但事件类型不是出库。
  • CONSUME_RESERVATION:订单确认发货时,真正把预占的 2 件出库。
  • RELEASE_RESERVATION:订单取消时,把预占的 2 件释放,delta = +2。

这里的关键在于,你不需要为“锁定”设计一个新字段。资源的可用量 = “所有正向事件合计 + 所有 RELEASE 事件合计”,而锁定中的资源 = “所有 RESERVE 事件合计 - 已被 CONSUME 或 RELEASE 抵消的合计”。听起来要绕一下,但落到 SQL 上就是按event_type分组条件求和。

用事件做预占的好处是,每个锁定动作都有独立的 request_id,取消锁定只需要生成对应类型的冲销事件,不会出现“锁没解锁”这种悬案。如果业务要求锁定有效期,那只需要在定时任务里扫描RESERVE事件中 created_at 超过有效期的记录,自动生成RELEASE事件。

6. 上了 real 项目之后最容易踩的五个坑

6.1 把“角色”当成“账号”来处理

事件表设计出来之后,团队很容易直接把用户表的主键填进source_agent_id。短期看没问题,但时间久了你会遇到一个尴尬场景:某员工离职,账号被禁用,他的历史事件还在业务系统里承担着审计责任。这时候如果因为账号被禁用导致历史事件查询失败,整个追溯链就断了。

我在项目里的处理方式是:代理人表里存“业务身份”,比如“仓库收货岗”“销售组某账号”“供应商主体”。具体系统账号单独建映射,想解绑就解绑,想换人就换人。历史事件永远指向代理人的业务身份,不指向某一条随时会失效的系统账号。

6.2 为图性能提前“合并事件”

做 rea 最大的诱惑,就是看到某类事件特别多,然后想出一个“优化方案”:把重复的加减合并成一条净变化。比如今天入库 5 次、出库 3 次,干脆合并成“净入库 2 次”。如果这样做,事件表的追溯能力就会被破坏,你只能知道总数,不知道每一步具体发生了什么。

如果确实查询太慢,我建议只合并读模型,不合并原始事件。比如把每天、每个资源 ID 的净变化提前计算好,查询时从“日汇总表”取数。原始事件表一行都不许动。这两件事一旦混淆,整个 rea 模型就会变成一个伪装成事件表的状态表。

6.3 允许负库存,但没设任何边界条件

rea 模型允许 delta_qty 为负,但“允许出现负库存”和“无限制地允许负库存”是两回事。如果业务上确实允许先卖后采,通常会设置一个“可负数上限”,比如 -10。超过这个上限就要阻塞,或者留一张待审核补货单。

我建议把“库存可负”做成事件类型级别的配置,而不是全局放宽。比如ISSUE事件默认不允许把 total 算成负数,但STOCK_ADJUST事件允许 -2 以纠正盘点差异。通过策略配置,既能满足业务场景,又不会让系统彻底失控。

6.4 把审批流的状态机塞进事件模型

有些同事会用 rea 去建模“审批单”:申请创建、待审批、已批准、已驳回。这些当然也是一系列状态,但它们本质上是流程状态,不是资源变化。如果把“审批通过”写成一笔事件,事件表就会混杂了业务流程和资源账目,再过半年谁也无法写出干净的 SQL。

我会明确区分:审批状态放在业务单据自己的字段里;事件表只记录“审批通过后导致的资源变化”。比如审批通过不是事件,“审批通过后给某部门分配预算额度”才是一笔预算分配事件。这个边界一定要提前跟团队讲清楚。

6.5 一开始就想把全公司所有业务都塞进 rea

rea 的优势是建模简单,但简单不意味着可以无限扩张。如果刚试点就想着把订单、人事、合同、客户关系全部用同一张事件表管理,event_type 会膨胀到几百种,agent 类型也会极其复杂,最后每一条事件都要写一堆特殊判断,维护成本直线上升。

我的建议是,第一次落地只选一个高价值且边界清晰的域,比如库存、预算、优惠券。跑顺之后,再复制同一套模式去建第二个域。域与域之间可以用不同的事件表或资源表隔离,而不是把所有东西堆在一张宇宙表里。

7. 往生产级走:对账、审计、扩展的一些补充设计

7.1 对账:把事件重算当作最后的裁判

rea 建好之后,最直接受益的模块就是对账。以前对账要用两张甚至多张状态表互相印证,现在只需要把同一资源、同一时间范围的事件拉出来重算一遍,跟业务期望值核对。

举个例子,某条资源希望月结时的库存为 200,那只需要查出这个月所有事件,按照SUM(delta_qty)累加,再加上月初初始数就可以了。如果有差异,不需要去猜哪个字段写错了,直接从事件列表里逐条排查,肯定会在十分钟内定位到问题。因为事件表里每一条都有 request_id、代理人和 biz_no,可追溯性天然强于状态表。

7.2 审计与权限:把“谁干的”写进事件,而不只是写在操作日志里

操作日志和事件表最大的区别是:日志可以写得很随意,满了还可以定期清;事件表是业务资产,必须有业务含义。

所以我在生产落地时,会在每个事件上额外补两个字段:actor_session_id和operator_agent_id。前者记录操作会话,后者记录实际操作人。查询审计记录时,先按事件表找到有兴趣的范围,再去日志系统补充操作详情。这样,业务端的数据流和审计端的数据流是统一的,不会出现“业务数据正常,日志却缺了好几天”的问题。

7.3 落地验收清单:什么样的 rea 才算合格

最后分享一个实践中常用的验收清单,适合每次新模块接入 rea 时快速自检:

  • 是否所有影响资源数量的动作都能被对应的事件类型描述,而不是通过直接改资源表字段实现?
  • 是否每个事件都带独立 request_id,并且数据库具备唯一约束,能抵御重复提交?
  • 是否存在任何一条业务 UPDATE 语句在修改rea_event表?如果有,说明流程设计存在问题。
  • 是否明确区分了资源表的“状态字段是物化视图”和“事件表是事实来源”?
  • 是否做到业务审计时只需看事件表和代理人表,就能还原当时现场?

如果这几点都能通过,那我基本可以放心地说,这个模块的建模没有走偏。你应该也发现了,rea 表面上是一套表结构设计,本质上其实是在逼着你把业务动作重新理解一遍:哪些是资源,哪些是动作,哪些是责任主体。这个思考过程,比表结构本身更能决定系统稳定性的上限。如果你最近也在被“状态字段大杂烩”折磨,不妨在迁动手游之前,先拿一两个高价值的资源练练手。

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

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

立即咨询