1. 业务系统建模为什么会突然对不上账
1.1 一款进销存系统上线一年后的账实不符现场
某团队找我诊断一套进销存系统时,业务已经跑了一年多,但每个月末库存盘点都会出现几百条差异记录,财务和仓库对账要花三天。团队一开始怀疑是并发扣库存的问题,后来发现即使低峰期单量不大,差异依然存在。问题定位到数据模型之后,我给他们提了一个听起来很"学术"的建议:用REA(Resource-Events-Agents,资源-事件-代理人)重新梳理一遍核心业务。REA不是某个开源框架,也不是新的数据库中间件,而是一套从会计信息系统领域沉淀出来的业务流程建模范式。它把"资源""事件""代理人"作为业务事实的三要素,围绕经济事件而不是单据或主数据来组织数据。这篇文章把我实际用REA梳理订单、库存和资金流的完整过程写出来,包括边界判断、表结构设计、容易踩的坑,以及和DDD、事件溯源共存的方式,适合正在做进销存、订单中心、财务中台,或者任何带"交易"属性的后台系统的读者。
1.2 传统"单据-主档"模型到底缺了什么
传统业务系统建模时,最常见的做法是照着单证来建表。订单主表、订单明细表、入库单、出库单、收款单,每张表都对应一张纸质单据或界面表单,主数据单独维护一张,外键把彼此串起来。
这种模型在管理层面很清晰,但一旦业务跑起来,问题就来了。同一笔销售,在订单表里有一行"已下单",在出库单里有一行"已发货",在收款单里有一行"已收款"。这三个状态分别由三个服务、三个时间点更新,中间一旦出现超时、回滚失败或人工补录,行与行之间的对应关系就会断裂。报表查询时要JOIN六张表才能还原"这笔订单实际赚了多少钱",而且任何一个环节的口径定义不一致,结果就会差出几万块。
REA模型换了一个角度:先把"业务事实"定义清楚,再让单据去适配事实。一笔销售,真正的事实是"商品从企业流向了客户"和"资金从客户流向了企业",这两个事实各是一个经济事件(Economic Event)。商品和资金是资源(Resource),客户、销售员、财务经办人是代理人(Agent)。三层关系理清之后,订单表、出库单都只是这些事实的不同视图,不再充当真相源。
提示:REA中的E代表Event而非Entity,这是很多初学者第一眼容易误解的地方。建模时要记住,事件才是真正的主角。
2. REA三要素的边界判断标准
2.1 资源:不是所有放在数据库里的东西都算Resource
在REA语境下,资源指"被经济主体控制、具有经济价值、并且会被经济事件改变其数量或状态的对象"。判断一个实体能否建模成Resource,我一般用四个问题过滤:
- 它是否拥有稀缺性且能带来未来经济利益?
- 它的数量或状态能否被某个事件增加、减少或转化?
- 它能否明确归属到一个控制主体名下?
- 它的计量单位是否唯一且可累计?
用这四个问题过滤一遍,很多日常名词会立刻被排除。比如"客户兴趣标签"不拥有经济价值且没法可靠计量,不是资源;"订单行项目"只是事件的明细投影,不是资源。而库存商品、货币资金、应收账款、原材料、成品、厂房设备,都是标准的资源。
实践中还有一个容易模糊的点:服务算不算资源?我的处理方式是,如果服务是一次性交付、且交付后能被事件明确记录(比如一次咨询、一次维修工单),就把它建模成资源,因为后续的收入确认、结转成本都需要围绕它展开;如果是长期订阅型的服务,通常把"剩余服务期间"建模为资源,发生的每次扣费则作为资源流出事件。
2.2 事件:交换与转换是两条必须分开的主线
经济事件是REA的核心事实单元,只要发生了"资源的流入、流出或内部转化",就应该记录一个事件。我把事件分成两大类:
- 交换事件(Exchange):企业通过一个事件让渡资源,同时通过另一个配对事件获得资源,典型是买卖。销售发出商品是一个事件,收到客户款项是另一个事件,两者通过Duality(二元性关系)配对。
- 转换事件(Conversion):企业内部把一种资源加工成另一种资源,典型是生产。领料出库是原材料流出事件,产成品入库是成品流入事件,人工工时消耗也可以是一个事件,它们共同解释了资源形态的转变。
分清楚这两类的好处是:交换事件通常对应外部损益,转换事件通常对应内部成本结转,后续做财务凭证映射时逻辑会非常干净。
事件的三要素判定可以用一个简单的提问:谁(Agent),在什么时候,让什么资源(Resource)发生了什么样的增减?如果某个业务流程动作回答不了这个问题,它大概率不是经济事件,只是一个状态变更或界面操作。
2.3 代理人:内部与外部不能混在一个池子里
代理人指"参与经济事件的责任主体",包括企业内部部门、岗位人员,以及外部的客户、供应商、合作伙伴。
我见过不少项目在REA落地上栽跟头,原因就是把内外部代理人塞进同一张表,然后用类型字段区分。前期确实方便,但后续报表会出现一个很头疼的统计口径:同一个客户ID在客户主档表里是一套编码,在事件表中又要重新维护一套编码,两套编码一旦不同步,客户维度的应收统计就会凭空多出几十行。
比较稳妥的做法是两张表:内部代理人维表(员工、部门)和外部代理人维表(客户、供应商),事件表同时挂内外两个外键。如果一个事件理论上不需要外部代理人(比如内部调拨),外部代理字段置空即可,不必强行填一个"内部往来单位"。
注意:事件与代理人之间是完整的参与关系,一个事件可以同时涉及多个代理人。比如收货事件至少包含仓库人员和供应商,一个是接收方、一个是提供方,两者的角色如果只存一个,后面追溯责任时就会缺数据。
3. 用"订单到收款"完整跑通一次REA建模
3.1 把业务动作拆成事件链
为了说明REA不是纸上谈兵,我拿最常见的"订单到收款"全流程来走一遍。假设业务是标准B2C零售,不涉及生产和复杂成本分摊。
整个流程可以拆成下面几个阶段:
- 客户下单并确认订单(这个动作在REA里通常不作为独立经济事件,因为订单本身没有改变资源,只是表达交易意图,应该建模为契约)
- 商品出库发货(库存商品减少,属于交换事件的前半段)
- 物流完成签收(如果以签收作为风险报酬转移点,则确认销售成立,生成应收资源)
- 财务收款(货币资金增加,应收降低,属于交换事件的后半段)
这里要特别说明"订单"的定位。REA标准模型里有个概念叫契约(Contract),比喻是"承诺"。订单、采购合同、销售框架协议都是契约,它的本质是"未来会发生的经济事件的协议"。契约不是Resource,也不是Event,它连接着后续两个或多个经济事件。所以我在设计表结构时,会单独建contract表,把订单号和后续的shipment_event、payment_event通过contract_id关联起来。
3.2 每个事件的三元组长什么样
以销售出库为例,把它拆成三元组:
- Resource:库存商品(减少方向)
- Agent(内部):仓库发货员、销售订单归属人
- Agent(外部):客户
- 时间:发货完成时间
- 数量与金额:出库数量、单位成本
以收款为例:
- Resource:货币资金(增加方向)、应收账款(减少方向)
- Agent(内部):财务收款经办人
- Agent(外部):客户
- 时间:资金到账确认时间
- 金额:实际到账金额
这里有必要把事件做得更细。很多系统只记录"收款单金额",但一笔收款可能对应多个订单,一个订单也可能被多次收款。如果建模时事件粒度过粗,后续对账就只能靠人工撮合。REA的做法是让每个经济事件保持原子性,即一个事件只描述一个资源在一次时空上的增减;至于"这笔收款还清了哪些订单",通过duality关系和契约关联去表达,而不是在收款单里堆多个订单号。
3.3 从事件链反推会计分录
我用的一个实用技巧是:把REA事件表当成会计分录的数据源,在ETL层做映射,而不是在业务写入时同时写凭证。这样做的好处是业务服务不需要知道会计科目,财务口径调整时只改映射层。
映射逻辑大致如下:
- 销售出库事件:借主营业务成本,贷库存商品(按成本)
- 签收确认应收事件:借应收账款,贷主营业务收入(按售价)
- 收款事件:借银行存款,贷应收账款
- 采购入库事件:借库存商品,贷应付账款
- 付款事件:借应付账款,贷银行存款
这套映射规则写成一张配置表即可。我见过有团队把"借/贷"关系写死在业务代码里,后来财务增加一个科目就发一次版,非常痛苦。用REA事件作为中性事实,把会计口径独立出去,是更可持续的方式。
4. REA建模中四个最容易走偏的细节
4.1 增量事件与存量快照不要混为一谈
REA强调的是发生式数据(流数据),即每个事件是一笔增量记录。但业务上经常还需要"当前库存多少""当前应收余额多少"这类存量查询。
如果只在事件表里累计,查询时每次SUM整个历史表,数据量大后性能会崩;如果只在快照表里维护状态,事件流一旦补录或修正,快照就很容易对不上。
我的实践方案是双轨制:事件表负责流水和审计,快照表负责在线查询。快照表可以在每次事件写入后增量更新,也可以通过离线任务以事件表为源重算。重算任务必须支持幂等,以事件ID为唯一键做去重,这样即使重复执行也不会污染结果。踩过一次坑之后,我对团队的要求是:任何快照表都必须有能够从事件表完整重建它的能力,否则快照就不可信。
4.2 交换事件的双向配对必须显式建模
销售出库和收款在业务上天然配对,但如果表设计里只是"两个事件都有相同的客户字段",而没有把配对关系单独建表,后续会出现一个经典问题:出库100笔、收款90笔时,到底哪些订单欠款?余额只能靠日期人工顺序去凑。
REA用Duality关系解决这个问题。我会建一张event_duality表,保存event_id和paired_event_id,以及配对类型(如"销售出库-收款"、"采购入库-付款")。这张表不仅服务于对账,还是财务"暂估""预收"处理的底层依据。实际操作中,配对不一定是1:1,可能一笔收款对应多笔出库,这时建配对明细行即可,每行记录配对金额。
提示:不要试图把所有配对都做成精确匹配,特别是零售POS场景,收款经常是批量勾兑。可以让配对任务做成每日批处理,并把人工调整入口留给财务,而不是依赖实时精确撮合。
4.3 费用类对象不是Resource,别硬塞进资源池
很多初学者会把"运费""手续费""折扣"也建模成资源,因为它们也有金额字段。这个做法会让后续的资源余额表变得很奇怪:运费余额是什么?折扣余额可以累计吗?
我的判断标准是:资源必须能形成"存量",或者至少能被明确地流入/流出。运费、折扣、税费更多是事件的属性或契约条款,不是一个独立的经济资源池。如果业务确实需要核算运费收入或运费成本,可以把它建模为"运输服务资源",按票计量,而不是把"运费金额"本身作为资源。
4.4 外部代理人的身份在事件层必须保留原始值
跨系统集成时,客户ID、供应商ID经常会因为主数据合并而变更。REA事件是审计事实,如果把事件表里的外部代理人也跟着主数据一起UPDATE,后面的追溯和审计就全乱套了。
正确做法是:事件表里保存业务当时的原始编码和名称,用于展示和追溯;同时保存一个"当前代理维表外键",用于统计。这样主数据合并前和合并后的历史事件都能被正确归类,不会出现"发往A客户的货在合并后变成B客户的"这类审计事故。
5. REA落地的物理表结构设计
5.1 六张核心表的职责分配
概念模型要落到数据库,我一般会建下面这些表,命名和字段可以按业务调整,但职责边界建议保持清晰:
- resource(资源维表):resource_id、resource_name、resource_type、unit、is_controlled
- economic_agent(内部代理人维表):agent_id、agent_name、agent_type、department_id
- external_agent(外部代理人维表):agent_id、agent_name、agent_type、tax_no、contact
- economic_event(经济事件事实表):event_id、event_type、resource_id、resource_direction、qty、unit_price、amount、occurred_at、internal_agent_id、external_agent_id、contract_id
- contract(契约表):contract_id、contract_type、contract_no、status、customer_id、signed_at
- event_duality(事件配对表):duality_id、event_id、paired_event_id、duality_type、pair_amount
这套结构的核心是:资源维表和事件事实表是多对多关系,但绝大多数场景下事件表会用resource_direction字段表达"流入/流出/转化",而不是建三张中间表。只有当资源在事件中动态组合时,才需要resource_event中间表。
5.2 事件表的字段设计细节与幂等控制
事件表是整套模型的命脉,字段设计上我特别强调几个点:
- event_id必须由业务侧生成并保证全局唯一,不能用数据库自增ID作为去重依据,因为自增ID无法对接外部系统的幂等检查。
- resource_direction用枚举(IN/OUT/CONVERT),不要用布尔值,因为转换事件既不是入也不是出,而是资源形态变化。
- occurred_at建议存业务发生时间,而不是落库时间。很多对账差异的根源,恰恰是业务和时间混在了一起。
写入事件时还要做幂等控制。最简单的方式是给event_id建唯一索引,插入时捕获冲突。复杂一点,如果消息队列可能重复投递,消费逻辑要保证"同一event_id只写一次关联的多个资源变更"。
5.3 余额与报表的物化思路
有了事件表和配对表,常用报表就可以非常直接地派生。以"客户应收余额"为例,SQL逻辑是:
SELECT e.external_agent_id, SUM(CASE WHEN e.resource_id = 'AR' AND e.resource_direction = 'IN' THEN e.amount ELSE 0 END) - SUM(CASE WHEN e.resource_id = 'AR' AND e.resource_direction = 'OUT' THEN e.amount ELSE 0 END) AS ar_balance FROM economic_event e WHERE e.occurred_at < :query_date GROUP BY e.external_agent_id;这个查询在数据量大的时候会很慢,但好处是逻辑完全透明,可以通过物化视图或异步预计算来优化。我在生产环境里的做法是每日凌晨根据事件表生成客户余额快照,白天查询走快照;如果当天需要近实时余额,则通过事件表增量聚合,再把增量追加到快照。
注意:只要报表能从基础事件重算,就不要在业务表里保留冗余汇总字段。冗余字段看似提升性能,实际会成为对账灾难的来源,因为你永远不知道它是什么时候、被谁、用什么规则更新的。
6. REA与DDD、事件溯源、微服务怎么共存
6.1 在领域驱动设计中,REA是聚合划分的指南针
做DDD设计时最常见的争论是"订单到底是不是聚合根"。REA视角能给出一个相对稳定的回答:真正的聚合根应该是"经济事件",而不是"订单"或"商品"。
一个订单之所以重要,是因为它触发了后续一系列经济事件。如果把订单当成聚合根,所有规则都会围绕订单状态机来写,出库、收款都变成订单的状态字段。一旦出现预收、退款、部分发货,状态机的复杂度会快速上升。把"发货事件""收款事件"作为独立聚合,订单只是契约,状态由事件的叠加推导出来,复杂度会低很多,也更容易做并发控制。
6.2 事件溯源系统中,经济事件就是天然的事实流
事件溯源(Event Sourcing)的核心假设是"所有状态改变都通过追加不可变事件来记录"。REA的经济事件定义完全符合这个假设。我在做事件溯源项目时,通常直接用REA事件作为领域事件的基础类型,其好处是事件结构稳定、语义清晰,不用每次开会重新定义Event的字段。
具体的映射关系是:领域事件名对应event_type,事件载荷对应resource_id、resource_direction、amount等字段,事件元数据对应internal_agent_id和external_agent_id。这样事件流、审计日志、对账单可以共用同一套存储,真正实现"一份事实到处服务"。
6.3 微服务拆分时按经济事件类型切边界
微服务的服务边界如果按部门或页面来切,往往不稳定。按REA的经济事件类型切,边界会非常自然:
- 订单服务负责写契约(订单)和销售事件
- 库存服务负责库存转换事件和出库事件
- 资金服务负责收款和付款事件
- 结算服务负责读写event_duality配对关系
服务之间通过事件总线通信,契约ID作为关联键。这个思路最大的好处是,跨服务对账时不需要依赖复杂的分布式事务,只需要比较各服务产出的事件流是否完整即可。哪个服务丢了事件、多了事件,一目了然。
6.4 什么时候真的不建议用REA
REA不是银弹。如果一个系统只是简单CRUD,比如内部OA审批流、CMS内容管理,硬套REA反而会让简单问题变复杂。我总结下来,适合REA的信号有三个:
- 系统的核心价值在于"资源在不同主体之间流动"
- 业务中存在可以被审计的不可变事实
- 复杂的对账、结转、统计是刚需
如果三个信号全部命中,那REA的收益会非常明显。如果命中不到两个,不妨先维持传统的单据建模,等业务复杂到出现对账难题再迁移也不迟。
我个人在实际项目中体会最深的一点是:REA最有价值的地方,不是给你一套可以直接用的表结构,而是逼着你把每个业务动作重新问一遍"它到底让什么资源、在谁和谁之间、发生了什么样的增减"。这个提问过程在项目初期会显得特别繁琐,甚至被质疑是不是过度设计。但只要系统开始同时面对库存、订单、资金、报表四个领域的复杂查询,REA的优势就会逐渐显现出来。我习惯的做法是,在每个新项目启动的第一周不急着建表,而是先拉着业务和研发花一个下午把核心经济事件清单列出来,用白板画一遍资源流动方向。这张白板图,比任何需求文档都更能预测未来系统会不会在对账上出问题。
最后再分享一个实战小技巧:REA落地时,把事件表的写入接口做得足够薄,只负责记录事实,不负责计算余额。余额永远通过派生查询或异步任务获得。只要坚持这个原则,后续无论是调整报表口径还是接入新的业务类型,改动都会集中在一个地方,而不是散落到几十个服务里。这套模型我用了很多年,希望这篇文章能帮你在做业务系统建模时少走一些弯路。