
这三个字母我盯了很久它不是什么长单词的缩写至少不只是。在我把一套业务系统翻来覆去重写第三遍的时候我意识到问题的根源不在某个字段、某条SQL而在于我从一开始就用快照思维在建模——订单表只记当前状态库存表只记剩余数量流水表写得再细致也解释不了数字为什么变成现在这个样子。REAResource-Event-Agent资源-事件-代理不是新鲜理论会计信息系统领域讲了二十多年。但真正把它用到通用业务后端开发的案例并不多。这篇文章是我在一套进销存项目里的完整落地记录从传统表结构为什么会失控到REA模型如何拆解业务变化到最终的数据库设计、核心代码和半年实践里踩出来的坑。如果你正在做订单、库存、账户这类强状态系统或者觉得现有模块的字段表像打补丁一样越堆越厚这篇应该能给你一条不一样的技术路线。1. 传统数据模型为什么越改越乱1.1 从订单表加字段到逻辑里到处if大多数后端项目的起点都很朴素用户下单就把订单主表加一条记录状态从待付款改成已付款再把库存表里的quantity减掉一。这个流程在demo阶段毫无问题但业务方很快会提需求预售定金怎么处理赠品算不算入订单金额退货是从库存表加回去还是建一张退货表拼团失败要自动取消订单库存什么时候回补一个月后你的订单表可能长成这个样子除了status之外还有is_pre_sell、deposit_paid、refund_status、promotion_id、original_order_id。每一个新字段都对应一段业务规则而业务规则之间还互相纠缠。写查询的人最痛苦要算今天的实际销售额先得在status、refund_status、is_pre_sell里做一堆判断。核心问题在于这些表记录的是实体的实时快照而不是状态为什么会变。你把订单当成一个持续变化并不断自我覆盖的对象去建模每一次业务调整都等于在这个对象上继续加状态位。时间一长实体这个词变得不像真实世界的东西倒像一台塞满标签的旧冰箱。1.2 复式记账思维带来的启发传统的复式记账法从来不直接记录余额是多少它只记录每一笔分录——借了什么、贷了什么。余额永远是所有分录累加的结果而不是一个独立维护的数字。这套体系运行了数百年它的优势是极其明显的任何账目都天然可审计因为每一分钱的变动都能追溯到一笔具体交易。我尝试把同样的思路嫁接到后端业务建模中不再把库存剩余数量当成一个需要被update的字段而是当成所有入库/出库事件的数量累加结果。不再用status字段表示订单走到哪一步而是用一系列已发生的事件已下单、已付款、已发货、已签收、已退货来推导当前状态。这样做的第一感觉是别扭连一个保存当前订单状态的字段都不设查询时还得现场算是不是有点违背常识但当你真的做完第一版之后会体会到这种别扭恰恰是数据关系的真实还原——当前状态从来不是一个客观存在它只是过去事件序列的综合结果。这也是REA模型从第一天起就坚持的核心观事件优先状态靠推导代理责任全程可查。2. REA模型的三个支柱资源、事件、代理2.1 资源Resource什么才是真正被管理和交换的东西REA里的资源定义非常严格在业务环境中被一个或多个代理所控制、具有经济价值、并且能够参与经济事件的物品、权利或服务。这个定义看着绕实操中我一般用一个不严谨但特别有效的判断标准来帮助理解如果这东西在业务里从不存在从一个代理手上转移到另一个代理手上的过程那它大概率不是REA意义上的资源。商品、现金、优惠券额度、积分、仓库库位使用权这些都是资源。订单本身呢不是。订单只是描述了一个意向和一组即将发生的经济事件的载体它不属于任何代理的财富池也没有被交换的过程。想通这个区分整个建模思路会一下子清爽很多。用生活化一点的类比资源就像水库里的水平常你看的是水位线但水位线只是上游进水和下游泄水长期作用的结果。如果你只记录水位线那等到某一天需要排查水为什么少了时你就只能靠猜了。但如果你把进水和泄水的每一个事件都记下来水位线就永远算得清、追溯得明。2.2 事件Event一切都从发生了什么出发REA的事件定义是使资源数量或所有权发生变化的活动。采购入库、销售出库、退货回仓、盘点盘盈、库存报废、收款、付款、积分发放这些全是事件。建模时事件不是发生之后顺手写一条日志的附属品而是整个系统的中心。换句话说传统建模是实体先存在事件围绕实体展开REA是事件先记录实体状态从事件推导。我一直觉得这方面可以和事件溯源Event Sourcing放在一起体会。事件溯源里有一个事实不可篡改状态只是投影的说法REA也有类似的味道——唯一区别是REA更强调经济语义每个事件必须明确指向某项资源必须指向至少一个代理。它不关心你的事件存储用什么格式它关心的是你在业务逻辑层有没有真正按资源增减的视角去划分事件边界。我习惯把事件分为三类这种分类对后面写代码很有帮助资源流入事件资源的数量或可用额度增加比如采购入库、客户退货。资源流出事件资源的数量或可用额度减少比如销售出库、库存报废。资源转换事件一种资源变成另一种比如原材料加工为成品或者用积分兑换优惠券。2.3 代理Agent这件事到底该算在谁头上代理分为内部代理和外部代理。内部代理是你组织内的员工角色——销售员、仓管员、客服。外部代理则是对面的一方——客户、供应商、物流方。代理存在的意义不只是为了记录操作人字段方便追责。在REA模型里代理承担着匹配经济交易两端的关键作用一笔销售出库事件同时关联内部代理销售员和外部代理客户而它对应的收款事件也要关联同一个客户代理。这两组事件放到一起才构成一个完整的经济契约——卖了东西就该收到钱。如果项目中不做这种配对会出现一个很常见的烂摊子出库模块记了一份库存变更收款模块更新了一下应收余额两边各跑各的到了月底对账发现货发了但钱没到系统却没有任何机制能快速暴露这一问题。引入代理配对后你可以在查询层很自然地把销售事件组和收款事件组拉在一起对于只出库未收款的记录一眼就能看到。2.4 REA与传统建模的本质区别用一句话就能说清传统建模在数据库里存当前值REA在数据库里存变化的规则和事实。前者就像给你的车拍了一张照片后者则是完整地录下了整段行驶视频。拍照当然方便——想显示当前库存直接读字段。但当你要回答这个数为什么会变成这样时你的系统必须依赖一堆充满临时判断的逻辑或者靠DBA去翻binlog。REA把算账这一层彻底交给了数据本身任何当前值都是查询时对事件序列做聚合的结果永远不算抹掉历史也永远不需要靠改字段来修复事实。想明白这一点你就会发现REA不是给你增加建模负担的它是把为什么这个责任从你的大脑里转移到了数据结构里。接下来的数据库落地就变得非常明确。3. 把REA模型落进数据库核心表设计与关联规则3.1 五张核心表的字段设计理论讲得再多最后还是要落到建表上。我自己最终沉淀出一套相对固定的schema用来说明REA落地时数据库长什么样你可以直接参考改核心语义不要动。这五张表分别是resource资源、event事件、agent代理、event_resource事件资源关联、event_agent事件代理关联。其中event_resource还需要附带上资源的增减方向和数量这是整个设计的灵魂。表名关键字段说明resourceid, name, resource_type, unit, status资源本身如商品、现金、积分agentid, name, agent_type, contact_info代理如客户、供应商、员工eventid, event_type, event_no, occurred_at, description一次经济事件如销售出库、采购入库event_resourceid, event_id, resource_id, direction, quantity, unit_pricedirection取in或out表示资源流入/流出event_agentid, event_id, agent_id, agent_roleagent_role标记内部/外部客户/销售员等建表时有几个细节值得单独提event表上不要设置类似业务对象类型的字段来区分是销售单还是采购单直接用event_type字符串即可。event_no建议业务生成并唯一因为后续对账、客服查询、审计追责都要靠它。occurred_at记录业务发生时间要和数据库插入时间区分开因为事件溯源场景里经常有补录历史事件的需求。event_resource的direction字段我建议用枚举字符in/out而不是存正负数值。原因一个是可读性好另一个是后续如果要支持资源的方向不确定的特殊场景比如调拨中的锁定资源扩展起来更方便。quantity必须是可正可负的数值具体语义由direction决定。3.2 事件-资源-代理的关联约束只建表不足以保证数据质量REA模型的价值要在约束层面体现出来。我至少会加下面几条约束每条背后都对应一个真实的业务事故每个事件至少要关联一个资源否则一笔库存变更记录没有指向任何资源那和垃圾数据没区别。每个事件至少要关联一个代理否则无法回答谁干的。同一事件的event_agent里内部代理和外部代理不能都为NULL或都为空可以两个都有但至少保证责任主体清晰。还有一个比较微妙的问题event和event_resource的数量关系。一个销售事件必然涉及商品出库和现金流入两类资源。如果你把销售出库建模成一个事件关联了商品资源directionout和现金资源directionin那在REA语义上完全成立因为你确实在一条业务操作里同时改变了两种资源的状态。如果你希望更细粒度地把出库和收款拆成两个事件也完全没问题区别只在于你的经济契约粒度选在哪一层。这里我给一个我压箱底的建议对于订单销售这种场景我倾向把发货出库和客户收款余额增加建模为同一个事件的两个资源关联因为它们同时发生且互为前提而把之后收到的货款在银行账户入账建模为另一个收款事件。按照这种拆分方式你可以在同一events记录上用一个事件完整表达销售履约实体流转的全过程后续的每个报表口径都从这同一个事实里取数整个系统口径会异常统一。3.3 从REA语义推导当前库存与余额先说历史教训我刚落地REA时因为不习惯偷偷在resource表上保留了一个current_quantity字段每次事件发生后通过事务去update它。这算是我踩过的第一个大坑因为很快你就会被两个问题逼疯一是并发更新这个数字的锁竞争二是查历史任意时间点快照时发现根本算不出来。正确的做法是在resource表上保留一个与current_quantity同义的冗余字段但它只能作为查询缓存绝不能作为业务写入目标所有数量的变化全部由事件驱动幂等重算。我实际推荐一个折中方案用物化视图或者汇总表定期从event_resource聚合出最新数量兼顾实时性、准确性和查询性能。查询某个资源的当前可用数量的核心SQL如下SELECT r.id, r.name, COALESCE(SUM(CASE WHEN er.direction in THEN er.quantity WHEN er.direction out THEN -er.quantity ELSE 0 END), 0) AS current_stock FROM resource r LEFT JOIN event_resource er ON er.resource_id r.id LEFT JOIN event e ON e.id er.event_id WHERE r.id 123 AND e.occurred_at NOW() GROUP BY r.id, r.name;你可能注意到这个聚合结果里没有排除掉已作废或已冲销的事件。这就是事件不可变性要处理的另一层问题往简单做的话你可以在event表上加一个revoked布尔标志来屏蔽已冲销的事件。但更好的做法是我第5章会详细讲的红冲事件即不修改任何历史数据而是新增一条方向相反的事件把原事件抵消。采用红冲方案后上面这条SQL连revoked判断都不需要逻辑上更干净。聚合推导这件事本质上就是给整个业务系统装了一个任意时间点快照的能力。想算三月底的库存把occurred_at 2024-03-31 23:59:59带上就行。这在传统订单库存表结构里几乎不可能干净地实现。4. 实操用REA思想重构销售与库存模块4.1 场景定义一个带赠品、退货、预售的销售模块我拿项目里最典型的销售链路来做完整演示。业务规则如下客户下单时支付部分定金尾款在发货前补足。订单可以包含正常商品和赠品赠品成本为0但也要在库存层面出库。支持整单退货也支持部分商品退货退货后库存回补。需要对库存做定期盘点盘点差异需要调整库存数量但不涉及资金变动。商品出库后才允许对订单做后续退款防止货未发钱先退的漏洞。这些规则单用传统状态机也能实现但当我把定金尾款发货退货盘点同时塞进一个模块时状态机里的状态数量会膨胀到几乎不可维护。用REA建模就按部就班把每个动作都定义成事件把状态留给查询层去推导。4.2 从业务操作到事件流核心事件定义我先把整个业务链条里发生的所有动作整理成一张事件表这一步花的时间不多但价值极大事件类型涉及的资源方向备注ORDER_PLACED无无订单创建不改变任何资源但是后续事件的锚点DEPOSIT_PAID现金/银行余额in客户交定金BALANCE_PAID现金/银行余额in客户补尾款SHIPMENT_OUT商品库存out正常商品出库GIFT_OUT赠品库存out赠品出库金额为0RETURN_IN商品库存in退货回仓STOCKTAKE_ADJUST商品库存in或out盘点调整这里有个细节值得展开说ORDER_PLACED明明不改变任何资源为什么也要作为一个事件记录下来因为后续所有事件——付款、发货、退货——都必须围绕同一个锚点顺序发生。没有这个锚点你无法定位这一组事件是在履约哪一次订单。这也回答了REA模型一个常见的质疑不是所有业务表都要对应一个资源变化有些实体存在的作用纯粹是为了把零散事件串成一个业务契约。从数据库表设计的角度我建议在event表增加一个link_group_id字段把同一业务链路上所有事件编成一个组。比如某一笔订单的ORDER_PLACED、DEPOSIT_PAID、SHIPMENT_OUT三个事件其link_group_id都相同。查询时想了解一笔订单的完整生命周期只需要WHERE link_group_id 123。4.3 核心代码实现事件写入与库存推导事件写入的核心逻辑很简单但必须注意原子性因为一个事件可能同时影响多个资源。下面是写入发货事件的伪代码from dataclasses import dataclass dataclass class ResourceEffect: resource_id: int direction: str # in or out quantity: int unit_price: Decimal def create_event(event_type: str, agents: list, effects: list[ResourceEffect], link_group_idNone): with db.transaction(): event_id db.insert( event, event_typeevent_type, event_nogenerate_event_no(event_type), occurred_atnow(), link_group_idlink_group_id, ) for effect in effects: db.insert( event_resource, event_idevent_id, resource_ideffect.resource_id, directioneffect.direction, quantityeffect.quantity, unit_priceeffect.unit_price, ) for agent in agents: db.insert( event_agent, event_idevent_id, agent_idagent.id, agent_roleagent.role, )调用方只需要负责组装事件语义不用关心订单状态机现在该转移到哪一步。比如销售出库def ship_order(order_id: int, customer_id: int, seller_id: int, items: list): effects [] for item in items: direction out if item.is_gift: effects.append(ResourceEffect(item.product_id, direction, item.quantity, Decimal(0))) else: effects.append(ResourceEffect(item.product_id, direction, item.quantity, item.sale_price)) create_event( event_typeSHIPMENT_OUT, agents[ Agent(customer_id, customer), Agent(seller_id, internal), ], effectseffects, link_group_idorder_id, )读取当前订单状态的方式则从读字段变为基于事件推导def get_order_fulfillment_status(order_id: int) - str: events db.query( SELECT event_type FROM event WHERE link_group_id %s, order_id, ) types {e.event_type for e in events} if SHIPMENT_OUT in types and RETURN_IN in types and BALANCE_PAID not in types: return 已退货未退款待人工处理 if SHIPMENT_OUT in types: return 已发货 if BALANCE_PAID in types: return 已付全款 if DEPOSIT_PAID in types: return 已付定金 return 待付款你可能会想这比直接读status字段复杂太多了吧确实单看一次查询更复杂。但换来的是你不再需要维护几十个状态迁移分支新增一个业务动作比如换货时只需要加一种事件类型而不是改一堆状态机的case判断。系统越复杂这个优势越明显。4.4 试跑一遍完整业务链路我用一个具体数字走一遍帮助你把上面的概念串起来初始库存商品A库存10件商品B库存5件。客户张三下单买2件A、1件B订单创建事件ORDER_PLACED无资源变化。张三支付定金20元写入DEPOSIT_PAID事件现金资源流入20。仓库发货2件A、1件B写入SHIPMENT_OUT事件A资源关联两条记录directionout, quantity2B资源关联directionout, quantity1。张三收到货后申请退回其中1件A仓库验货入库写入RETURN_IN事件A资源directionin, quantity1。月底盘点发现A仓库实存9件但系统按事件累加算出来应该是9件10-219一致无需调整。如果实存8件则写入盘点调整事件A资源directionout, quantity1。需要查询当前A库存执行3.3里的聚合SQL得到结果9件。查询任意时刻比如发货完成后、退货前的库存用occurred_at过滤得到当时是8件。这套流程跑顺之后你会发现每个数字都能被解释。业务方问为什么库存是8件你直接拉出该资源在时间轴线上的所有事件记录当场对质。这在传统模型下做不到因为它只剩结果的数字。5. 我在落地REA模型时踩过的坑5.1 别把资源理解成数据库里的某个实体我刚上手时犯过一个经典错误把商品的各种属性SKU、价格、条码直接当成资源去建模。结果resource表越做越像商品主数据表真正关键的资源变化反而淹没在里面。REA里的资源强调参与经济交换的财货不是业务系统的对象。商品主数据、订单号、合同文本这些不参与出入库增减的实体应该继续放在普通的业务表里。而库存、现金、积分、预充值余额、优惠券剩余额度这些才应该进入resource。建模时如果拿不准问自己一句它在业务上是否存在数量会变、会从一个代理到另一个代理的场景这个问题问完边界就清楚了。5.2 事件写错了怎么办红冲而不是修改这是所有第一次落地REA的人都会撞上的难题。传统做法里数据库记录物理行直接update就是改数据。但REA推事件不可变——过去的已发生事实不该被覆盖。如果某次出库数量填错强行update会让后续所有基于事件累加的结果都带上一个无法解释的断层。我采用的方案是红冲也叫冲销事件新增一条与原事件方向相反的冲销事件把错误事件彻底抵消掉然后补一条正确的事件。比如误出库5件A那就补一条directionin, quantity5的事件标为冲销再补一条directionout, quantity3的正确事件。原始错误事件保留不动人一眼就能从时间线上看到完整修正轨迹。红冲事件需要特别注意防止无限套娃不要在冲销事件上再做冲销而是在应用层维护一个reversal_of_event_id字段保证冲销关系是单链接的。否则时间线一长查询会绕进死胡同。5.3 REA不等于事件溯源两者可以配合使用很多资料喜欢把REA和事件溯源画等号我的实际体会是它们解决的问题有交集但不等同。事件溯源的关键在于把应用状态当作消息流的投影它关心的是系统内部状态如何演化REA的关键在于用经济资源变化来解释业务语义它关心的是业务事实本身。完全不冲突但也不一个东西。你可以只引入REA做数据库模型设计不引入事件溯源基础设施也可以两者结合在领域事件里同时携带REA语义。我在项目里选了后者应用层面用类似事件溯源的方式记录消息数据库投影层用REA表结构落库兼顾了业务追溯的清晰度和常规查询的便利性。这个组合看起来有点重但一旦业务逻辑复杂度上来性价比非常划算。5.4 性能问题靠分层聚合解决别靠破坏模型由于每次查询当前库存都需要对event_resource做聚合数据量大了之后性能确实会变差。我见过有人为了提速回退到在resource表上实时更新current_quantity的旧方案这是拿系统的核心逻辑去换一点查询速度很不划算。正确的体面做法是维护汇总表每天凌晨或在每次事件写入后把当天增量聚合到一张resource_daily_summary表中字段设计为resource_id, date, total_in, total_out, final_stock。日常列表页只查汇总表需要精确回溯时才去查明细。对账场景一般发生在月底、季度底这种低频但对数据准确性要求高的查询在汇总表的基础上做全量聚合也是很快的。5.5 团队协作时REA最大的阻碍不是技术是习惯最后这是一个非常现实的坑。你兴致勃勃地引入REA团队里的资深开发第一反应通常是这个设计有点高级但我改了三年订单表为什么要换 这时候拿出50页理论文档效果很差真正能说服人的是一张事件流图。我建议在新项目或小模块上先行试点用一张白纸把业务链路里所有涉及资源增减和代理责任的动作画成事件节点对应的资源和代理挂在旁边。画完这张图大家基本就理解了为什么状态字段不重要、事件才是主角。如果连这张图都画不出来说明你对业务的理解还停留在表面那也更不适合急着开写代码。最后再分享一点个人体会REA模型真正改变我的不是那几张表结构而是逼着我在写每一行业务代码之前先回答一个灵魂问题这条数据的变化是因为哪个业务事件由谁负责影响了哪项资源以前我建模是先想对象订单、商品、用户然后围绕对象堆字段现在我先想事件对象退居其次。这个思维上的翻转让我在后来的好几个项目里都少走了大量弯路。如果你正打算在项目里尝试REA一个小经验千万不要从定义概念开始直接从你系统里最乱的那个链路开始画事件流图。先找事件再找资源和代理——这个顺序反了你会卡在这到底算资源还是算实体的哲学问题里很久。另外虽然REA是会计信息系统的老概念但把它用在现代微服务后端中依然很新社区里几乎没有成熟的开源框架。这意味着前路需要你自己摸着走。不过也正因如此它留给动手能力强的人的发挥空间特别大。我期待看到更多人在真实的业务系统里把这条路走顺。