从状态字段到事件事实:REA业务建模彻底解决订单与财务追溯难题
业务系统做到一定复杂度几乎都会撞上这样一类需求“订单要支持部分退款客户可能分多次付款另外我们要能追溯到每一笔收入到底来自哪张原始单据。”如果你习惯用实体关系图一步步定义状态字段这类需求会让你越改越别扭。我自己在帮某家公司重构进销存数据模型时就被逼了一回最后把整套订单模型推翻改成事件驱动的方式建模问题才顺过来。这套思路在业内有一个经典名字——REAResource-Event-Agent资源-事件-主体。它不像ER图那样只描述“名词与关系”而是把业务运行过程拆成“谁在什么时间对什么资源做了什么”再用事件之间的配对关系构成完整业务循环。对做订单、库存、财务、数据中台的开发者和架构师来说REA是值得认真掌握的一套模型。这篇文章我会先讲清楚REA的核心构造然后用一个门店零售场景完整走一遍建模流程最后把我实际落地时踩过的坑和注意事项一并列出来。读完之后你可以拿这套框架重新审视手头的业务系统哪怕不重构也能把“状态”和“事实”的边界理清楚。1. 为什么我建议业务建模别再只画实体关系图1.1 实体关系图擅长描述名词不擅长描述动词ER图现在仍然是很多团队建模的首选它确实成熟客户、订单、商品之间的关系一目了然数据库设计工具也好用。但ER图有一个天然偏向——它擅长描述名词不擅长描述动词。客户、订单、商品是名词建模很顺畅可一旦遇到“销售”“支付”“退款”“调拨”这些动作ER图里通常只能把它们压缩成一个状态字段或者一张流水表。流水表稍好一点但很多人只把流水当附属记录核心还是那张大宽表。状态字段的问题在于随着业务规则扩张状态机会越来越复杂。一个电商订单的状态往往有十几个中间还可能插入“待接单”“仓库已接单”“配送中”之类的新节点。每新增一个状态所有查询、判断逻辑、报表统计都要跟着改。更麻烦的是很多所谓的“状态”其实可以由多条事件推导出来根本不值得长期硬存。比如“已付款”不是订单固有属性它是“存在一条对应该订单的收款事件”这个事实的结果。1.2 REA模型是从会计信息系统里长出来的业务本体REA模型从根本上换了一个问法不再问“订单现在处于什么状态”而是问“这笔业务活动里发生过哪些事件每个事件涉及什么资源、由谁参与”。这个问法更接近业务运行的真实过程。它的渊源很有意思。上世纪八十年代会计信息系统领域的研究者试图设计一套不依赖借贷记账法、能直接描述业务事实的数据模型最后收敛到三个基础概念资源、事件、主体。这套模型比一般领域模型更抽象但它触及了业务系统的本质——一切业务都是资源在主体之间流动事件就是流动发生的记录。也正因为它足够抽象才能稳定应对业务调整而不是每换一个业务规则就推倒重来。1.3 REA与借贷记账的差异这里要特别区分一下会计里的借贷分录是“面向财务报表”的语言它关注借方、贷方、科目、余额而REA是“面向业务真实”的语言它关注业务活动中真正发生了什么。打个比方客户付了款借贷记账法记的是“银行存款增加应收账款减少”这是财务视角的抽象REA记录的是“收款事件发生资金资源增加参与主体是客户和收银员”。前者对财务人员友好后者对业务人员、产品经理、开发人员更友好。两者可以转换但REA位于更靠近业务的一层。也正因为如此REA很容易成为业务部门和IT部门共同认可的一门通用语言。2. REA模型的三大构造资源、事件、主体到底在说什么2.1 资源不只是“商品”才有价值服务和权利也是资源REA里的资源指业务中可计量、有价值的对象。很多人以为资源就是库存里的商品其实不止。常见资源大致分三类实物资源商品、现金、原材料、固定资产。服务资源咨询工时、维修服务时长、培训课时。权利资源会员积分、优惠券、软件授权、订阅时长。一个东西能不能成为资源有两个判断标准第一能被计量有数量或金额第二能带来价值无论是增加收益还是降低成本。像“顾客情绪”这种没法计量、业务系统也不管的东西就不是资源。区分这一点很有用它逼着建模者想清楚一堆数据里真正构成业务价值的池子到底是什么。2.2 事件系统里最值得存的那条“事实记录”事件是REA模型里的一等公民它指业务过程中发生的、影响资源流入流出的动作。销售、采购、收款、付款、入库、出库、退货、退款这些都是事件。事件有三个核心要素发生了什么、什么时间发生、涉及哪些资源的增减以及数量和金额。此外事件还要关联“谁参与了”。所以一张事件表至少要有事件类型、业务发生时间、录入时间、来源单据编号、备注等基础字段再通过明细子表记录资源数量单价、参与者角色。注意事件和普通流水账不一样。流水账只是表格里的一行记录常常是为了对账用的附属数据而在REA模型里事件本身就是业务事实的权威来源。你要判断“这单付钱了没有”不是去看某个状态字段而是去查关联的收款事件存不存在、金额是否匹配。状态是推导出来的事件才是存下来的。2.3 主体业务动作由谁发起、由谁接收、由谁授权主体是参与事件的人或组织。它可以是内部主体比如店员、收银员、库管员、门店也可以是外部主体比如顾客、供应商、服务商。一个事件可以有多个主体参与而且每个主体的角色不同。销售事件里顾客是接收方店员是执行方有的场景还需要店长授权那店长就是授权方。所以在建表时事件和主体的关系不是简单地加一个外键而是一张专门的关联表保存参与者ID和角色。“门店”到底算主体还是订单里的一个属性这个问题最好在建模初期就定下来。我的建议是只要实际参与资源增减的责任方都建模为主体用主体类型字段区分配置。这样后续要做门店维度报表、加盟商维度报表就不用改结构直接按主体过滤就行。2.4 二元性REA的灵魂所在业务很少是单边动作通常成对发生。销售这件事意味着商品资源流出随后必然对应资金资源流入采购意味着商品资源流入对应资金资源流出。REA把这种配对称为二元性Duality这是整个模型最关键、也最容易被忽略的地方。“销售”和“收款”是一对“采购”和“付款”是一对“领用”和“归还”是一对。强调这种配对是为了从根本上解决“订单表加一个支付状态字段”的冲动。订单是否完成收款不再是一列真值而是“是否存在一笔收款事件与该销售事件配对且配对金额是否充足”。这是可验证、可追溯的事实推导不是拍脑袋维护的一个值。实际建模时二元性不是一比一那么简单。可以部分收款、多次收款、一次收款合并多笔销售单。所以我会单独建一张二元性配对表而不是在销售事件表上加一个“收款事件ID”字段。这张配对表维护的是“哪个增量事件和哪个减量事件构成了哪笔金额”的关系灵活性大得多。3. 用一个门店销售场景完整走一遍REA建模3.1 场景定义与建模边界先设定一个具体的虚拟场景某社区便利店销售几十种商品支付方式支持现金、微信、支付宝偶尔有退货进货来自几家供应商。老板的需求是每天看到每个商品的实时库存、每个收银员的销售和收款明细月底账目要对得上。建模第一步是定边界先不做赊销不处理多门店多仓库供应链模型先到“采购入库”为止。这个边界非常重要否则事件类型一下铺开模型还没建好就乱了。建模本来就是迭代的第一版能覆盖住主营闭环就够了。3.2 第一步识别资源、事件、主体把场景里的“名词”和“动词”分别梳理出来。资源表资源类型说明商品实物资源每个SKU一条资源记录现金资金资源收银机里的备用金和营业款微信零钱资金资源微信收款电子钱包支付宝余额资金资源支付宝收款电子钱包事件表事件影响资源方向典型参与者采购入库商品增加供应商、店员收货销售出库商品减少顾客、店员收款现金/微信/支付宝增加顾客、收银员退货入库商品增加顾客、店员退款现金/微信/支付宝减少顾客、收银员付款给供应商现金/微信/支付宝减少供应商、店员这里说一个关键判断如果你把“收款”当作销售事件的属性而不是独立事件那后续处理部分付款、多次付款、组合支付时就很容易把这张表或这个状态字段改到崩溃。这也是判断要不要上REA的一个直观标尺——只要你觉得“这单的付款状态很难用一列描述清楚”那就是该拆事件的时候了。3.3 第二步定义事件之间的关系资源、事件、主体识别出来之后第二步是把它们连起来看。销售出库是一条减量事件对应商品池的减少收款是一条增量事件对应资金池的增加销售出库和收款通过二元性配对构成“卖出并收到钱”的完整业务循环。退货入库和退款构成一条逆向的双事件组合。采购入库对应付款给供应商同样是一对。主体连接关系则是销售出库事件参与主体是顾客和店员收款事件参与主体是顾客和收银员。这两个事件在建模时通过事件-主体关联表挂上参与者及角色。3.4 第三步映射成物理表结构我不建议为了REA故意设计过度复杂的物理结构只需要四张核心表加两张关联表。以下是我在实际项目中验证过的表结构可以直接复用。CREATE TABLE resource ( id BIGINT PRIMARY KEY, name VARCHAR(200) NOT NULL, resource_type VARCHAR(50) NOT NULL -- PRODUCT / FUND_POOL / SERVICE_RIGHT ); CREATE TABLE agent ( id BIGINT PRIMARY KEY, name VARCHAR(200) NOT NULL, agent_type VARCHAR(50) NOT NULL -- INTERNAL_STAFF / INTERNAL_ORG / EXTERNAL_CUSTOMER / EXTERNAL_SUPPLIER ); CREATE TABLE event ( id BIGINT PRIMARY KEY, event_type VARCHAR(50) NOT NULL, -- SALE / PURCHASE / COLLECTION / REFUND occurred_at TIMESTAMP NOT NULL, -- 业务发生时间 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 系统录入时间 src_bill_id BIGINT NULL, -- 来源单据ID便于和业务单据关联 remark VARCHAR(500) ); CREATE TABLE event_resource_line ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, resource_id BIGINT NOT NULL, direction SMALLINT NOT NULL, -- 1 增加, -1 减少 quantity DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,6) NOT NULL ); CREATE TABLE event_agent ( id BIGINT PRIMARY KEY, event_id BIGINT NOT NULL, agent_id BIGINT NOT NULL, role VARCHAR(50) NOT NULL -- CUSTOMER / CASHIER / SUPERVISOR / SUPPLIER ); CREATE TABLE event_pairing ( id BIGINT PRIMARY KEY, increment_event_id BIGINT NOT NULL, decrement_event_id BIGINT NOT NULL, amount DECIMAL(18,6) NOT NULL );这里强调一个习惯resource表不要存“当前库存”字段。当前库存的正确计算方式是“初始库存快照 所有事件明细方向上数量之和”。如果查询量确实大就单独建一张读模型缓存表每小时从事件流重算一次但权威数据永远在事件明细里。3.5 第四步用模型回答真实业务查询建表不是为了好看得能回答业务问题。我列三个最常见的查询需求。判断某笔销售单是否已收款SELECT * FROM event_pairing WHERE decrement_event_id 某个销售出库事件ID;统计今天某个收银员的现金收款总额SELECT SUM(erl.quantity * erl.unit_price) AS total_amount FROM event_resource_line erl JOIN event e ON e.id erl.event_id JOIN event_agent ea ON ea.event_id e.id WHERE e.event_type COLLECTION AND erl.resource_id 现金资源ID AND ea.agent_id 某个收银员ID AND ea.role CASHIER AND e.occurred_at 2024-01-01 00:00:00 AND e.occurred_at 2024-01-02 00:00:00;查询某个商品当前实时库存SELECT erl.resource_id, SUM(erl.direction * erl.quantity) AS current_stock FROM event_resource_line erl WHERE erl.resource_id 某个商品ID GROUP BY erl.resource_id;看起来SQL比传统单表查询复杂一点但这个复杂是值得的因为每个数字都能沿事件链追溯到底层凭证。3.6 与传统订单模型的关键对照业务需求传统订单模型REA事件模型部分付款订单表加部分付款金额字段逻辑越堆越乱一条销售事件可对应多条收款事件天然支持变更支付方式改订单支付方式字段历史数据难追溯错误支付方式用冲正事件纠错再新增正确事件退款订单状态机增加“退款中”“已退款”新增退款事件与原收款事件配对即可审计追溯依赖操作日志碎片化事件链完整可精确追溯库存余额库存表直接存余额需要反复做库存更新由事件流推导读模型缓存权威数据清晰4. REA模型在真实项目中解决过的那些“改需求之痛”4.1 新增支付渠道不用再改表结构我之前参与的一个零售项目第一版只支持现金很快客户要求支持微信和支付宝后来又要求“现金微信组合支付”。放在传统模型里订单表加字段、流水表加类型、统计报表重写一轮轮改下来很累。用REA之后收款事件本身就是一条记录支付渠道只是资源的选择收现金就关联现金资源收微信就关联微信资源。组合支付就是同一条销售事件配对多条收款事件。模型层面完全不用动。4.2 退款不再依赖状态机传统模式里“支付状态”和“订单状态”两个状态机叠加一旦出现“部分退款后再取消订单”这种组合判断逻辑很容易失控。REA的处理方式则很干脆退款就是方向相反的收款事件和原收款事件配对金额在配对表里体现。订单有没有欠款计算“已收金额之和 - 已退金额之和”即可不需要维护一个不断膨胀的状态字段。4.3 审计追溯直达原始凭证财务要求“报表里某笔收入来自哪张单据、哪个收银员、几点发生的”在REA模型里是一条自然查询。收入报表只是事件的重组视图点击金额就能打开销售事件、收款事件、对应的业务单据和操作人。对准备融资或要过审计的公司来说这个能力是刚需。4.4 多组织、多门店扩展不推翻模型后来客户开了三家店每家店独立收银。传统模型的做法是所有表加store_id然后每个报表重写一遍。REA模型完全不需要动核心表门店就是一个agent参与每个事件时带上门店角色。门店维度报表不过是一次按agent过滤的查询。这个优势在规模扩大时会越来越明显。4.5 与财务系统的衔接变得顺畅REA事件是财务记账的事实基础。有了事件流财务系统生成借贷分录只是增加一层转换视图收款事件可以映射为“银行卡增加、应收账款减少”销售出库可以映射为“主营业务收入、库存减少”。两个方向的数据源头是同一套事件业财一体化的根基其实就是这一层统一的业务事实层。5. 把REA落地到项目里几个绕不开的坑5.1 简单CRUD别硬套REAREA不是万能锤子。如果业务只是一个留言板、一个内容管理后台事件配对、主体角色、二元性都是多余的重量。我给的判断标准是三条是否有跨周期的多段业务活动比如先销售后收款、先采购后付款是否有审计追溯需求业务规则是否经常变化。三条满足两条以上REA才划算。最合适的试点是从“采购到付款、销售到收款”这样一条完整闭环开始。5.2 别把“事件表”和“业务单据表”混为一谈业务单据是流程载体比如销售单、退货单REA事件是业务事实。一张销售单可能对应一次发货事件和一次收款事件一个付款单可能对应多张销售单。所以我会保留原有的业务单据表比如biz_bill让流程审批、打印、线下跟踪继续用单据表事件表通过src_bill_id关联单据两者并行各司其职。如果把单据字段全塞进事件表反而会失去REA的灵活性。5.3 状态字段可以留但要明确它是缓存不是真相为了查询方便在订单表里保留pay_status字段项目开发中也说得过去。关键是团队心里必须清楚状态字段只是从事件流异步计算出来的一份缓存不是权威事实。我会在开发规范里写明所有状态字段的更新必须由事件流驱动禁止业务代码直接改状态字段。否则过半年支付状态又被改成了手工维护的“真假混合体”REA就白做了。5.4 事件不可变纠错只能冲正这是最容易让人别扭的一点。如果某条收款事件录错了很多人的第一反应是DELETE掉重录。这一删审计链就断了。正确做法是为错误事件追加一条冲正事件金额方向反转然后再增加一条正确事件。保留所有历史事实纠错本身也变成事件才能支撑审计追溯。这也是为什么从事财务系统的人比较容易接受REA因为他们早就习惯“红字冲销”而不是DELETE。5.5 主体粒度必须在模型设计时定清楚“门店”算主体还是订单属性“部门”算主体还是员工属性如果建模时摇摆不定后面报表口径一定会乱。我的建议是只要实际参与资源增减的责任方就建模为主体用agent_type区分内外。哪怕现在用不到也先留好类型字典。部门归属、门店归属这类纯组织架构内容放在另一个组织维度表里不要把两者混在一起。5.6 三个时间戳不能搞混事件模型里至少有三个时间概念业务发生时间happened_at、系统录入时间created_at、会计期间period。月末统计用period日常报表用happened_at审计排查用created_at。如果只用一列时间跨日销售、延迟录入、跨期报表都会对不上账。这个细节我几乎在每个REA落地项目里都要反复强调。5.7 团队认知磨合期要留足刚引入REA时开发团队容易觉得“多此一举”——明明一个状态字段就能解决非要建五六张表。我的经验是不要靠概念说服人直接拿真实需求做演练让大家列出“支持部分退款”在传统模型里的改动清单再看REA模型里的改动范围。用报表可追溯做demo展示给产品经理和财务他们会最先支持这种建模方式。认知转过弯之后后面协作效率会大幅提升。6. REA模型在数据中台、审计系统和事件溯源里的新位置6.1 数据中台的主题域建模可以换一种切法很多数据中台的主题域是按部门划分的比如销售域、采购域、财务域结果同一个业务事件被拆到三个域里口径不一致。REA提供的是按“业务活动”切分的统一骨架资源、事件、主体、二元性对。数据部门、业务部门、财务部门如果共用这一套语言很多口径冲突会自动消解。6.2 REA可以成为事件溯源架构的领域语义骨架微服务架构里经常碰到Event Sourcing但不少团队把event仅仅当成一个JSON payload导致事件类型、事件语义五花八门后续消费端很难统一解释。REA恰好回答了“业务事件到底应该记录什么”至少要有事件类型、发生时间、来源单据、资源明细、参与主体、配对关系。把REA作为事件溯源在业务域落地的领域语义骨架比自定义一套事件规范要成熟得多。6.3 审计存证与不可篡改场景的结合REA事件天然是不可变记录与区块链式的哈希链、数字签名存证非常契合。把关键事件表做成只追加事务配合哈希摘要存储就能形成一套可验证的业务证据链。在合规审计要求越来越高的行业里这套模型比普通状态模型更接近“能直接过审”的数据结构。6.4 落地的起步路径给正在犹豫要不要上REA的团队一个建议从一条业务主线开始比如“销售-收款-退货退款”先跑通报表和审计追溯再逐渐把“采购-付款-入库”接进来一路延伸到库存、报表、财务。不要试图第一次建模就把整个企业模型全部REA化那样大概率会因为过度设计而失败。小步快跑让业务方看到可追溯报表带来的实际好处自然就有动力继续推进。我自己最大的体会是REA最难的部分从来不是建表而是让团队从上到下接受“状态是快照事件是事实”这个观念转变。一旦观念转过来了后续的新需求都会变得好沟通——开发不再问“订单状态字段还能不能加”而是问“这个需求对应哪些新事件和哪些已有事件配对”。这个转变本身就是建模水平的一次跃升。