首页 / 资讯中心 / 文章详情

REA建模实战:用事件驱动统一订单、库存与财务口径

REA建模实战:用事件驱动统一订单、库存与财务口径 ★ FEATURED ARTICLE
项目名写的是 rea我在这里把它理解成 REA 业务建模方法Resource、Event、Agent。做业务系统的朋友应该都有过这种经历订单、库存、财务各有一套口径同一个“出库”动作仓管叫发货财务叫成本结转客服叫物流更新。系统表面在跑对账永远在补窟窿。我最近在一个内部重构项目下面就叫它模拟项目X里实践了一轮 REA 建模体会是它不能直接给你代码但能把“业务事实”和“数据表”之间的裂缝补上。这篇文章会把 REA 的核心概念、建模拆解、表设计、代码实现和排查经验一起讲清楚适合正在做订单、库存、财务系统的产品、开发和架构师参考。1. REA 到底是什么把业务从“状态”还原成“事件”1.1 三个基础元素一张图看懂REA 的名字很直白。Resource 是有价值的资源包括商品、现金、银行存款、应收款甚至积分和知识产权Event 是让资源状态发生变化的一次动作比如销售出库、收款到账Agent 是参与这个动作的个人或组织可以来自公司内部也可以来自外部。三者的关系是一个经济事件至少让一种资源流出、另一种资源流入并且至少有一个内部代理和一个外部代理参与。举个例子仓库给客户发出 1000 件商品。这个“销售出库”事件里流出资源是库存商品流入资源可以拆成应收账款和销售成本。外部代理是客户内部代理是仓管员。整个模型不去画“订单走到哪一步”的状态而是用一张张事件记录回答“谁在什么时间给了谁什么资源”。这一句话我建议读到这先记下来后面所有设计都围绕它展开。传统 ER 建模会先问“表里有哪些字段”REA 会先问“业务里有哪些动作”。前者以实体为中心后者以动作为中心。如果你只做一个简单管理系统ER 建模够用但如果系统需要长期承载业务规则、财务核算、审计追溯REA 的视角省掉的坑是后面几倍的工作量。1.2 为什么事件模型比状态机更抗变化大多数订单系统是状态机思维订单表里有一列 status从“待支付”到“已支付”再到“已完成”。这套设计本身没毛病可业务不会乖乖沿着状态走。客户可能付款后又改型号仓库可能先发货后补审批财务可能跨月才确认收入。一旦出现这些情况状态枚举就要加分支状态迁移代码就要加判断时间一长每一个状态字段背后都藏着十几个 if。REA 换个思路我不维护“当前状态”只维护“已经发生的事实”。订单当前处于什么状态完全由事件流推导出来。比如订单关联了付款事件但没有发货事件那它就是“已付款待发货”如果发货事件也有了它就是“已发货”。新业务动作根本不需要改旧状态机只需要新增一种事件类型并配置好它应当释放和吸收哪些资源流。这个类比很像记账软件。状态机像在冰箱门上贴便签“里面有青菜”REA 像记每一笔食材买入和消耗。你随时可以从流水里算出冰箱里还剩什么便签就算丢了也不怕。系统未来的扩展、退款、换货、补差价在事件流模型里都只是新事件历史事件依旧不变。1.3 它和会计复式记账为什么天生一对会计上每一笔记账凭证都必须借贷平衡。REA 的每个经济事件也要求资源流出和流入至少各一条两者逻辑完全同构。以前我问过业务同事为什么销售出库要在库存系统和财务系统各录一遍因为两个系统建模语言不同。现在用 REA 建好事件和资源流后库存异动、财务凭证都可以从同一张事件表派生。比如发货事件库存商品减少同时销售成本增加、应收账款增加、销售收入增加。这件事在库存系统里叫“出库”在财务系统里叫“结转成本、确认应收”在业务系统里叫“发货”。REA 只是把这件事记录成若干条资源流谁都能从自己需要的视角去投影。这也是为什么我说 REA 能成为订单、库存、财务三套口径之间的共同语言。2. 拿一个订单履约场景先做业务事件拆解2.1 把流程动作逐条过一遍区分事件和承诺为了讲清楚我假设一个面向企业客户的商品销售场景客户下单、仓库发货、财务收款。流程很短但足够暴露传统建模的很多问题。先逐条列动作客户提交订单仓库拣货并发出商品财务收到货款。这中间的“提交订单”最容易混淆。订单本身没有改变任何资源只是双方对未来资源转移的约定。在 REA 中这叫承诺Commitment不是经济事件。如果把“下单”当成事件你会发现它没有资源流硬要套资源流就会把订单余额、锁定库存这类派生值当成真实资源账就乱了。所以订单我单独建模为承诺表只记录客户要买什么、数量多少、单价多少。真正的经济事件从发货开始货物从库存转移给客户同时公司对客户的应收账款增加、销售成本确认。等到财务收款又一个独立事件发生应收账款减少、银行存款增加。业务方如果问“订单状态是什么”我在后台把关联事件按时间排序算一遍就行。2.2 给每个事件标注资源和代理现在把核心事件拆出来事件流出资源流入资源外部代理内部代理业务含义仓库发货库存商品应收账款、销售成本、销售收入客户仓管员货物转移确认应收和收入财务收款应收账款银行存款客户财务人员货款结清应收减少银行增加这里有一个容易钻牛角尖的地方销售成本是费用销售收入是权益它们不是严格意义上的“资源账户”。但把会计科目当成资源账户来建模是可以的关键是每个科目的余额随事件变动的方向要定义清楚。应收账款在发货时增加收款时减少这就是资源的流入和流出。代理方面每条事件都必须有内部代理和外部代理。发货事件外部代理是客户、内部代理是仓管员收款事件外部代理是客户、内部代理是财务人员。这样一个疑问会自然消失库存扣了没人签字不会代理缺失时事件根本无法完整记录。2.3 资源账户与代理清单合并成一张业务地图资源账户我建议单独维护它代表公司真正要盘点、要记账的对象。在这个例子里至少有五个库存商品、应收账款、销售成本、销售收入、银行存款。代理表维护客户、仓管员、财务人员等主体。建模时最容易踩的坑是把代理和资源混在一个表里。比如客户表加一个“应收账款余额”字段看似方便实际上客户是代理应收账款是资源两者是事件关联起来的。如果直接给客户挂余额每笔账款变动都要 update 客户表审计和历史追溯全部丢失。代理不直接持有余额余额由资源账户表达代理只是参与引起资源变化的事件。把这一步做完业务地图已经清楚了资源账户是会计科目代理是参与人事件是资源和代理之间的连接点。后面所有表结构都是这个逻辑模型的投影。3. 从 REA 模型到数据库表设计的落地方案3.1 逻辑模型承诺表加事件表状态字段退居二线落到关系型数据库我会设计四类核心表资源账户表、代理表、承诺表、事件表外加事件资源流表。订单表作为承诺表存在但订单状态不再作为事实依据只作为可选的聚合展示字段。事件流设计我比较建议用主事件表加子表“事件资源流”的方式。一条经济事件可能对应多笔资源变动发货事件会同时影响库存商品、应收账款、销售成本、销售收入四个账户。如果把这四笔直接写在事件表字段里扩展性很差一旦业务再增加“税费”账户就要改表结构。拆成子表后一个事件关联多少资源流都行查询用 JOIN。承诺表与事件表的关系是一对多一个订单后续可能产生发货事件、收款事件、退款事件。事件表通过 commitment_id 关联回订单同时持有自己的业务编号和发生时间。查询订单当前状态时把该订单的所有事件按时间拉出来就可以推导。3.2 可复用的核心表结构与 DDL下面给一套最小可用的建表 SQL已经去除多余字段保留最能体现 REA 思想的部分CREATE TABLE resource_account ( id BIGSERIAL PRIMARY KEY, code VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(64) NOT NULL, category VARCHAR(16) NOT NULL DEFAULT ASSET, -- ASSET/LIABILITY/EQUITY/REVENUE/EXPENSE unit VARCHAR(16) NOT NULL DEFAULT CNY ); CREATE TABLE agent ( id BIGSERIAL PRIMARY KEY, agent_type VARCHAR(16) NOT NULL, -- CUSTOMER/INTERNAL name VARCHAR(128) NOT NULL, ext_id VARCHAR(64) NULL ); CREATE TABLE commitment ( id BIGSERIAL PRIMARY KEY, commitment_no VARCHAR(64) NOT NULL UNIQUE, agent_id BIGINT NOT NULL REFERENCES agent(id), total_amount NUMERIC(18,2) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE economic_event ( id BIGSERIAL PRIMARY KEY, event_type VARCHAR(32) NOT NULL, -- SHIPMENT/RECEIPT/... commitment_id BIGINT REFERENCES commitment(id), occurred_at TIMESTAMPTZ NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), external_agent_id BIGINT NOT NULL REFERENCES agent(id), internal_agent_id BIGINT NOT NULL REFERENCES agent(id), biz_no VARCHAR(64) NOT NULL, CONSTRAINT uniq_event_biz UNIQUE (event_type, biz_no) ); CREATE TABLE event_resource_flow ( id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL REFERENCES economic_event(id), resource_account_id BIGINT NOT NULL REFERENCES resource_account(id), flow_type VARCHAR(8) NOT NULL CHECK (flow_type IN (IN,OUT)), amount NUMERIC(18,2) NOT NULL, quantity NUMERIC(18,4) NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );把事件表和资源流表分开之后好处马上能看到。想要知道“发货事件影响了哪些账户”一条 SQL 就能查出来想要知道“库存商品账户所有变动”按 resource_account_id 过滤即可。同时uniq_event_biz唯一键非常关键它保证同一事件不会因为接口重试而重复入账。3.3 金额、数量、时区的三类规范化约束字段设计上有几个细节我吃了不少亏先写在这。金额一律用NUMERIC(18,2)不要用FLOAT或DOUBLE。金额计算出现 0.1 加 0.2 变成 0.30000000000000004 的问题在财务系统里就是事故。数量字段用NUMERIC(18,4)而不是INTEGER因为商品有可能按重量、长度计费整数不够用。时间字段一律TIMESTAMPTZ并且严格区分业务发生时间occurred_at和系统记录时间recorded_at。补单、跨时区操作、月底冲销的时候这两个字段会救命。我之前遇到对账差异追了一天发现是有人用now()当业务时间一笔昨天的补单被算到了今天。资源流表里的金额和数量必须同用一条业务规则决定正负方向。我建议flow_typeIN表示资源账户余额增加flow_typeOUT表示余额减少。这样对账脚本只需要按方向汇总不需要在每次写的业务代码里判断“这个账户该加还是该减”规则集中在一张配置表里。4. 核心环节实现从下单支付到出库结转4.1 创建承诺与发货事件事务内双写资源流创建承诺非常简单只是往commitment表插一行不放业务规则不锁库存。真正的核心在发货事件。发货事件必须在一个数据库事务里完成三件事插入economic_event、插入若干条event_resource_flow、更新库存余额投影表。任何一步失败都要全部回滚。我用类似这样的伪代码描述框架换成 Java、Python 都一样def process_shipment(event_dto, flows): with db.transaction(): event_id insert_event(event_dto) for flow in flows: insert_flow(event_id, flow.resource_account_id, flow.flow_type, flow.amount, flow.quantity) # 更新库存等余额投影需要加锁 update_balance(event_dto).check_and_apply() # 生成会计凭证或库存流水也写在同一个事务里 generate_voucher(event_id)这里有个很容易被忽略的动作幂等。支付回调、ERP 推送都可能重复调用如果接口没有幂等控制同一张发货单会被插两次事件库存和应收全部翻倍。办法就是前面 DDL 里的uniq_event_biz用event_type biz_no做唯一键。第一次插入成功第二次插入因为唯一键冲突直接失败业务端捕获后返回“已处理”即可。很多团队习惯先写业务主表再异步发消息给库存系统和财务系统。异步没问题但事件表和资源流表绝对不能分成两个事务写。一旦中间进程崩溃事件存在但资源流不存在对账马上不平。把事件和资源流放进同一个事务是 REA 落地最底线的要求。4.2 负数库存校验余额表只是投影在事件模型里真实的事实是event_resource_flow表里的一条条流水库存余额只是这些流水汇总出来的投影。理论上每次查询库存都可以重算但性能扛不住所以我会额外维护一张resource_balance表专门存放每个资源账户的最新余额。这张余额表和事件流水必须在同一个事务里更新。扣减库存前先锁定余额行SELECT quantity FROM resource_balance WHERE resource_account_id $1 FOR UPDATE;如果quantity小于要出库的数量直接抛异常回滚。锁住行的目的是防止两个发货请求同时读到相同的可用库存最后超卖。事务完成后再 update 余额表把余额减掉。这个设计把“不可变事件流”和“可变投影表”结合起来了。平时查询和校验都走余额表速度快出问题时从事件表重放把余额重建出来不需要相信任何一个中间状态。我见过有人用 update 订单状态字段去扣库存扣完以后连“当初为什么扣”都查不到这属于把投影当成了事实早晚出事。4.3 事件回放与现实对账程序对账程序的核心逻辑就是按资源账户汇总事件流再和余额表对比。以库存为例一天的出库入库流水汇总出来应该等于期初余额加当天的流入减流出SELECT resource_account_id, SUM(CASE WHEN flow_type IN THEN quantity ELSE 0 END) AS total_in, SUM(CASE WHEN flow_type OUT THEN quantity ELSE 0 END) AS total_out FROM event_resource_flow WHERE event_id IN ( SELECT id FROM economic_event WHERE occurred_at $1 AND occurred_at $2 ) GROUP BY resource_account_id;这个查询结果拿给财务对账不再需要两边翻 Excel。若对账不平直接查所有资源流按事件时间一条条比对即可。财务同事曾经问我“这个余额是怎么来的”我把事件流列表导出来从第一笔开始加给他看问题立刻清楚。再配合一个定时任务每天从事件表重建一次全量余额和现有余额表做差异比对。差异为零说明系统自洽差异不为零就说明某个事务里事件流和投影表没有同步可以很快定位到具体事件和操作人。5. 常见问题与排查技巧实录5.1 五个典型的建模错误在实际项目中最常见的问题不是代码而是建模走偏。我整理了五个反复出现的错误第一个错误是把所有状态都当成事件。有些人把订单状态的每次变更都写成 event比如“状态从待支付变成已支付”。这不是资源流动只是状态变化。REA 的事件必须改变资源账户余额没有这种变化放进事件表只会增加噪音还会让对账无从下手。第二个错误是单边记录资源流。只记录库存商品减少却没有同步记录销售成本或应收账款的增加。这样库存余额是准的财务账永远对不上。REA 的“每事件至少一出一入”不是理论教条是保障账平的基本约束。第三个错误是直接 update 余额表。系统每天都把库存余额、应收余额直接改成新值没有事件流。某天数据被改错没有任何回溯依据。事件不可变余额表只是投影这两条原则不能动摇。第四个错误是把资源与代理混在一起。客户表里放应收余额商品表里放库存数量。短期看很方便长期看业务扩展极其别扭。换货、冻结、折扣这些动作会渗透到每一张业务表最终改无可改。第五个错误是忽略承诺。订单作为承诺被简单压成一个状态字段后续改价、取消、拆分都只能覆盖原数据。一旦发生纠纷想还原当时客户究竟承诺了什么历史全部丢失。把订单做成承诺表把所有变更动作做成事件这个成本值得。5.2 三个能直接用的 SQL 排查模板排查事件完整性我常用下面这三个 SQL建议直接存到运维工具里。第一找出资源流数量少于两条的事件SELECT e.id, e.event_type, COUNT(f.id) AS flow_count FROM economic_event e LEFT JOIN event_resource_flow f ON f.event_id e.id GROUP BY e.id, e.event_type HAVING COUNT(f.id) 2;第二找出同一业务编号重复入库的事件SELECT event_type, biz_no, COUNT(*) FROM economic_event GROUP BY event_type, biz_no HAVING COUNT(*) 1;第三找出资源流方向缺失的账户。比如某事件只有流出没有流入但是事件表本身看起来正常SELECT e.id, e.event_type, f.flow_type FROM economic_event e JOIN event_resource_flow f ON f.event_id e.id WHERE e.id IN ( SELECT event_id FROM event_resource_flow GROUP BY event_id HAVING SUM(CASE WHEN flow_type IN THEN 1 ELSE 0 END) 0 );这三个查询基本覆盖了数据对不上的常见原因。我每次遇到库存账和财务账差异先跑一遍这三个 SQL通常十分钟内能定位到问题事件。5.3 落地过程中的几条实在建议最后给几条建议都是我在模拟项目X里实际验证过的东西。第一事件不可更新。发现错误不要直接修改原事件而是新增一个冲正事件比如金额原本多记了就新增一个等额反方向的事件。这样做的原因是方便回放任何时刻都可以通过事件流重建真实历史而不是看到中间被改过的痕迹。第二业务编号要有全局意识。支付回调单号、物流单号、手工调整单号都要保证全局唯一并且入库时使用唯一键兜底。系统集成越多重复报文就越常见靠代码判重不如靠数据库约束。第三小步引入不做一刀切。如果现有系统已经很复杂不要试图一天把所有表都改成 REA。先挑一个经常对不平的环节比如“发货”或“收款”建事件表和资源流表其它系统继续走老逻辑。存量数据可以做一个“初始余额事件”一次性进入新模型之后新增业务全部走事件流。等团队跑顺了再逐步扩展比推倒重来安全得多。最后说一点个人体会。REA 不是银弹它不会自动帮你解决所有业务问题但它强迫你把“事实”和“状态”分开。以前大家争论发货后库存怎么减、应收怎么确认现在只需要确认一个事件带多少条资源流以前对账靠两边系统导出 Excel现在直接从同一张事件表投影。这套思路不挑技术栈也不需要什么新框架有一张会写 SQL 的表就够起步。我自己的感觉是认真走完一遍 REA你会重新看到很多“理所当然”的需求背后到底缺了哪个事件。
阅读完成 · 觉得有帮助?
咨询建站