1. 为什么我会把一个订单系统推翻重做:旧模型的两个死穴
前一阵我把一个后台系统的订单模块整个推倒重来了。原因不是业务代码写得乱,而是数据模型根本撑不住对账和审计。那套旧方案很常见:订单表一行数据,上面挂一个状态字段,从“待支付”到“已支付”再到“已发货”,状态一变,更新一下字段,前端的进度条跟着走。表面看没问题,可一旦出现部分退款、赠品抵扣、坏账核销、换货,这行订单记录就变成了一堆状态来回横跳的怪胎。
真正压垮我的场景是这样的:某个客户有一笔订单,系统里显示“已关闭”,财务却坚持说这笔钱已经收到了。两边打开后台一对,订单状态是关闭,但支付流水表里确实有一笔入账记录。这个“已关闭”到底是谁关的?关闭之前发生过什么?当时库里的余额为什么和银行流水对不上?没人能从一个状态字段里找出答案,只能去翻日志、查操作记录,一个晚上就这么没了。
1.1 状态字段越长,业务真相越模糊
状态字段本身不是一个错误,它的问题是只能表达“当前在哪”,表达不了“怎么来的”。一个订单从创建到完成,中间可能经历了改价、合并发货、部分退款、补发,这一系列过程才是这笔业务真正的样子。把所有这些过程压缩成一个当前状态,相当于把一整部电影缩略成一张海报,镜头里的细节全丢了。
更麻烦的是,状态还经常互相矛盾。比如仓库已经扫描出库,物流单也生成了,订单状态却还停在“待发货”或者“处理中”。状态字段一旦和真实世界的动作脱节,后面所有基于状态的统计、对账、报表都会失真。我后来意识到,问题不在状态机的定义,而在我们根本没有一个稳定的数据载体去描述“动作”本身。
1.2 “余额对不上”是最低成本的失败方式
另一个让我下定决心重做的原因是库存和资金余额的对账。很多系统喜欢在表里直接存一个“当前库存”字段、一个“账户余额”字段,每次业务动作发生时加减它。看似高效,实际等于把计算结果和业务发生过程混在了一起。一旦出现并发超卖、重复回调、手工改库存,余额和真实情况不一致就成了必然。
我记得有一次排查库存差异,账上显示有货,仓库实际没有。查来查去,最后发现是有个测试操作直接改了库存字段的值,既没有留下业务单据,也没有关联到任何订单。所有基于“存量字段”的架构都有这个隐患:你能看到差值,却永远追不到差值的来源。
1.3 REA的思路:不谈余额,谈事件
我开始寻找一种建模思路,能让业务数据的来龙去脉本身成为数据结构的一部分。后来在一个会计信息系统领域的研究资料里看到Resource-Events-Agents模型,项目代号我直接用了REA。它的核心思想概括起来就一句话:不要用“余额”或“当前状态”作为建模中心,而是用“事件”作为建模中心,资源从哪来、到哪去,参与者是谁、有什么责任,全部绕着事件记录展开。
这套思路解决了我上面遇到的两个死穴:事件记录了过程,等价于把电影的关键镜头都保留下来;余额不再是一个被直接修改的字段,而是通过事件记录聚合计算出来的推导值。谁改过余额、在哪一单改的、为什么改,在REA模型里都是一条条明确的事件。
我把这个项目的整个推演过程、数据库落地方案、踩过的坑都整理在这里。如果你也在做订单、库存、财务对账类系统,或者只是觉得当前数据模型在面对业务追溯时使不上劲,这套思路值得参考。它并不是银弹,但确实让我的系统从“猜谜式查账”变成了“链条式追溯”。
2. 资源、事件、参与者:三要素的边界比想象中难划
REA模型听起来简单,资源、事件、参与者,三个词而已。但真正动手建模时,边界划分比想象中难得多。同一个东西,在不同业务阶段可能是资源,也可能只是描述信息;同一个动作,可能算事件,也可能只是一次普通日志。这部分我不想堆理论,直接结合我做订单重构时的判断过程来讲。
2.1 资源:重点是“存量”,不是“字典”
先说资源。REA里的资源,指的是经济资源,也就是能给企业带来价值、能在业务过程中被交易、消耗、转换的那些东西。常见的有商品、现金、应收账款、积分、服务工时、仓库库存,甚至可以是某种必须消耗的物料。
判断一个实体能不能当资源,我有一个很笨但很有效的检查办法:问自己一句——“它有没有存量?它能不能在一个事件里减少,又在另一个事件里增加?”如果答案是肯定的,它才可能成为资源。比如“商品”有库存,可以被出库减少,被入库增加,所以它是资源。“商品名称”不会因为业务动作而变化,那它就只是一个描述属性。类似地,“客户名单”如果没有被消耗或转移,也没有存量,那它就不是资源,至少不是这个模型关心的资源。
这里有个容易混淆的点:主数据和资源的关系。我在项目里一开始把“商品表”整个当成资源表,后来发现不对劲。商品表里的条码、名称、规格是静态主数据,属于商品实体的描述;真正会随业务变化的,是“某仓某商品的可售库存”。所以落地时我把商品主数据和库存资源分开了:商品存描述,库存作为资源,跟着事件走。资源表只记录“有什么东西可被业务增减”,不承担字典表的职责。
2.2 事件:不是所有操作都能成为事件
事件是REA最核心的表,也是我重新建模时花时间最多的部分。它对应的是业务过程中让资源发生流入或流出的动作。比如“客户付款”让现金资源流入,“仓库发货”让商品库存流出,“采购收货”让库存流入。一个事件,至少要改变一个资源的数量,这是进入事件表的硬门槛。
我第一次建模时犯过一个错误:把“用户点击下单按钮”记录成了一个事件。点击只是操作痕迹,它并没有让任何经济资源发生增减,也不会产生法律义务。真正的“下单”其实是一个承诺事件,它让商家对客户产生了一项交付义务,让客户产生了一项付款义务。义务也是价值,所以它值得建模,但它和“点击”完全是两层东西。
区分事件和日志有一条实际标准:这个动作是否会引起资源数量的联动变化。比如“审核通过”只是一条管理动作,不会直接让库存变少,除非审核之后紧跟一个“出库”动作。我后来把这类状态变化归为“事实”或“步骤记录”,不放进经济事件表,避免把事件表和操作日志混在一起。
2.3 参与者:区分谁拥有、谁经手、谁受益
参与者容易理解,就是人、部门、组织、系统账户这些活跃角色。但做详细设计时,我发现参与者内部要区分两种性质:谁对事件负有直接责任,谁在事件中拥有或接收资源。
举个例子,“客户下订单”这个事件,参与者有客户和销售员。客户是外部参与者,他承诺付款;销售员是内部参与者,他负责跟进订单。到了“收款”事件,客户付款,财务人员确认入账。客户是资源流出方,财务是资源接收方,两边的责任完全不同。如果不区分这些角色,事件表后面连“找哪个环节的人确认这笔账”都做不到。
我落地时用两张关联表分别处理外部和内部参与者的接入,比塞进一个字段清楚得多。内部参与者常用于权限和绩效追溯,外部参与者用于对账和客户往来。中间还加了一层“参与角色”的概念,同一参与者可以在不同事件里扮演不同角色。前期多花一点时间理清参与者的角色边界,后面写权限、报表、责任追溯时会非常顺。
3. 一个订单业务的完整推演:从需求清单到REA模型
前面讲的是概念,这一章我直接用一个简化版的订单业务场景,走一遍完整建模流程。场景很典型:客户浏览商品、下单、仓库发货、客户付款、财务确认到账。里面可能还包含部分退款、发票开具。我会按照我当时实际执行的四个步骤来讲,每一步的光景大致是:先做什么、依据什么判断、输出什么结果。
3.1 第一步:先列业务动作,别急着建表
我一般不会一上来就打开数据库设计工具,而是先在白板上把业务过程里所有能被称为“动作”的东西列出来。这个阶段要求先放开“表”“字段”“状态机”的概念,纯从业务行为出发,把角色做了什么事、资源发生了什么变化都写清楚。
以订单场景为例,列出来的动作大致有:客户浏览商品、客户提交订单、系统扣除库存、仓库拣货、仓库发货、物流配送、客户确认收货、客户发起支付、支付渠道回调、财务确认收款、开具发票、申请退款、审批退款、退款完成。这些动作可以多列,哪怕有一部分后来会被淘汰掉。先求全,再求准。
列完动作后,我会做一次粗筛:把那些没有引起经济资源变化、也不产生实质性义务的动作先圈出来。比如“客户浏览商品”“物流配送”在这个业务里只是支撑信息,不是核心经济事件;前者是行为日志,后者是服务过程记录。真正具备事件资格的主要是:提交订单、发货、收款、退款、开票。粗筛后剩下这些,再进入第二步,进一步分析它们和资源的关系。
3.2 第二步:把动作转成“资源流入/流出”
事件必须有资源变动,所以第二步是给候选事件一一配对资源,标注流入方向还是流出方向,并说明对应什么价值。
“客户提交订单”这个动作,从REA角度属于承诺事件,它不直接让库存减少,但建立了一项义务:企业承诺交付商品,客户承诺交付现金。这个承诺如果要建模,可以抽象为“交付承诺”和“付款承诺”两种资源,但在第一版我建议不用强建模,先把实际买卖双方的真实动作梳理清楚。
“仓库发货”是明确的经济事件:商品库存这个资源发生流出,方向为out;与此同时,企业付出了一项“销售成本”,也是资源流出。很多系统只记录了库存减少,忽略了销售成本,结果后期的毛利分析完全没法做。REA的好处在于,它会逼着你去想:这个事件除了库存外,还动了哪些资源。
“客户付款”则让现金资源流入,方向为in;同时,客户的“应付义务”减少,这又是一种资源变化。开票事件对应的是“应收义务”的确认和税金的产生。一路梳理下来,事件和资源的映射就能落到表里:
| 事件 | 资源变化 | 方向 | 价值体现 |
|---|---|---|---|
| 提交订单 | 交付承诺 / 付款承诺 | 流入+流出 | 建立双方义务 |
| 仓库发货 | 商品库存 | 流出 | 销售成本 |
| 客户付款 | 现金 / 银行存款 | 流入 | 实收货款 |
| 开具发票 | 应收义务 / 税金 | 流入+流出 | 收入确认 |
这一步做完,业务动作就有了“经济后果”。看起来只是在画箭头,实际上是在为后面每个数据表字段提供逻辑依据。
3.3 第三步:用参与者挂上责任链
资源变化确定后,第三步就是把参与者和事件连起来。我问的问题通常是:这个事件是哪个角色触发的?哪个角色执行?哪个角色负责确认结果?
“提交订单”是客户触发,销售客服确认;“仓库发货”是仓库操作员执行,仓管负责人审核;“客户付款”是客户发起支付,财务人员确认到账。不同的事件,内外部参与者的边界不一样。把这些对应关系整理成一张责任矩阵,后面做权限、审批流、业绩归属都会很轻松。
这一步容易忽略的是“系统自动化参与方”。比如支付回调,其实是支付渠道这个外部参与者触发的“付款事件”,而不是财务人员本人。财务人员只是后续审核确认。如果模型里不记录这个链路,以后排查“这笔钱到底哪个环节进来的”又会回到翻日志的老路。REA落地时,我建议把系统、渠道、计时任务也作为特殊参与者,统一放进参与者表。
3.4 第四步:用恒等式做一次“体检”
所有元素都画完之后,我会做一道很通用的体检题:对每个资源,把流入总量加起来,减去流出总量,应该等于这个资源的“期初存量 + 期末存量”变化。换句话说,不能有流入没有来源,也不希望有流出没有去处。
我拿库存资源验证:采购入库的流入之和,减去销售出库、报损出库、盘亏出库的流出之和,应该等于期末库存。如果算出来不对,就说明要么事件清单漏了,要么事件关联错了资源方向。这道体检题的价值在于,它能在写代码之前就暴露业务理解的漏洞,而不是等上线之后让对账来背锅。
我实际跑下来,最常发现的问题是“退货”事件方向填反。客户退货导致库存流入,同时要退款给客户,现金流出。一正一反,两个资源都要动,如果建模时只记录了库存流入、漏了现金流出,恒等式当场就能发现总资产平不了。这就是REA建模比普通ER设计有用的地方:它能通过经济恒等式自检,而不是等数据错乱后再修。
4. 落地关系型数据库:表怎么拆,余额怎么算
模型图画完,接下来是真刀真枪的数据库设计。我之前见过很多REA相关的文章,都在概念层面打转,一到建表就语焉不详。这边我把实际能用的一组表结构分享出来,并把其中的关键设计原因讲清楚。不需要用特别复杂的数据库,一套PostgreSQL足够,其他关系型数据库可以平移。
4.1 核心表结构:事件为主,资源、参与者为辅
我的落地库表结构围绕三张中心表展开:economic_event,resource,agent;再配三张关联表:event_resource,event_agent,event_link。
第一张中心表是事件表,字段包括事件类型、业务时间、业务参考号等。事件表里不冗余存参与者ID和资源ID,统一通过关联表挂接,目的是让“一个事件可以涉及多个资源、多个参与者”这件事变得自然。比如“仓库发货”事件,可能同时涉及多个SKU,如果每个SKU的库存变化都拆成独立事件,粒度太碎,后期核对订单维度就很麻烦。用事件主表和资源明细关联表,一个发货单对应多个资源变化行,才能保持业务事件和记录的1:1关系。
CREATE TABLE economic_event ( id BIGSERIAL PRIMARY KEY, event_type VARCHAR(50) NOT NULL, happened_at TIMESTAMPTZ NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), business_ref_no VARCHAR(64) NOT NULL ); CREATE TABLE resource ( id BIGSERIAL PRIMARY KEY, resource_code VARCHAR(50) NOT NULL UNIQUE, resource_type VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL ); CREATE TABLE agent ( id BIGSERIAL PRIMARY KEY, agent_code VARCHAR(50) NOT NULL UNIQUE, agent_type VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL ); CREATE TABLE event_resource ( event_id BIGINT NOT NULL REFERENCES economic_event(id), resource_id BIGINT NOT NULL REFERENCES resource(id), direction VARCHAR(4) NOT NULL CHECK (direction IN ('in', 'out')), quantity NUMERIC(18,4) NOT NULL, valuation NUMERIC(18,4), PRIMARY KEY (event_id, resource_id, direction) ); CREATE TABLE event_agent ( event_id BIGINT NOT NULL REFERENCES economic_event(id), agent_id BIGINT NOT NULL REFERENCES agent(id), role VARCHAR(50) NOT NULL, PRIMARY KEY (event_id, agent_id, role) );细心的人会注意到,event_resource表里我加了一个valuation字段。它存在的原因很简单:库存数量变化和价值变化并不总是一回事。比如商品数量没变,但单价调整了,或者因为汇率波动,账面价值需要修正。把价值和数量分开记录,报表口径才能灵活调整。
另外我建了一张event_link表,专门用来记录事件之间的关系。比如“发货事件”和“收款事件”并不相互独立,它们通过同一个订单发生关联。用事件关联表,可以表达“这个收款是用于抵消哪几个发货”的多对多关系。没有这张表,后面做对账时又要回到“通过订单号反查”的老路,等于把事件之间的业务联系又丢给了应用层。
4.2 库存和余额不靠字段存,靠事件聚合算
这是REA落地时最容易引发争议的一条,因为性能团队通常会抱怨:“每次算库存都SUM整个表,数据量大怎么办?”我的回答很直接:第一,事件表是追加式数据,几乎不更新,可以靠索引优化;第二,可以建立物化视图或每日汇总表,但结算依据仍然是事件明细,而不是一个随手改的“库存字段”。
拿库存举例,真正的当前库存是资源表上的一个推导值,不是存储值。SQL表达起来很直白:
SELECT r.id, r.name, COALESCE(SUM(CASE WHEN er.direction = 'in' THEN er.quantity ELSE 0 END), 0) - COALESCE(SUM(CASE WHEN er.direction = 'out' THEN er.quantity ELSE 0 END), 0) AS stock_now FROM resource r LEFT JOIN event_resource er ON er.resource_id = r.id WHERE r.resource_type = 'INVENTORY' GROUP BY r.id, r.name;现实项目里我实际更常用“按日汇总”的物化视图。也就是把事件明细按资源、按天、按方向先聚合成存量增量表,查询时只查汇总,不用每天扫全量明细。但这个汇总表永远是“可重建的缓存”,它只服务于性能,不服务于真相。真相留在事件明细里,任何汇总出了偏差,都能用明细重新算出来。
“余额”和“库存”一样,属于事件聚合出的推导值。每个客户的应收余额,等于所有销售事件产生的应收增加,减去所有收款事件产生的应收减少,再减去退款产生的影响。用事件聚合算余额,最大的好处是对账不再是“两边余额比大小”,而是“两边的事件序列是否一致”。哪怕两边余额碰巧相等,只要中间少了一条事件,也能通过事件序列比较发现。
4.3 防止重复入账:业务唯一键和幂等设计
事件表是追加式的,这带来一个潜在问题——重复写入。比如支付回调由于网络超时被重试两次,同一个渠道支付流水就可能被记成两条收款事件,余额就多算了。
我的处理办法是给business_ref_no建唯一索引。这个字段存放的是业务系统里天然的幂等标识:支付单号、发货单号、采购入库单号。业务上同一笔支付只有一个支付单号,数据库层面用唯一约束直接拦住第二次插入,比应用层判断更可靠。
ALTER TABLE economic_event ADD CONSTRAINT uk_event_business_ref UNIQUE (business_ref_no);还有一个细节:业务参考号字段必须由上游业务系统生成,而不是由事件记录系统自己生成。原因很简单,只有上游生成的单号才能代表“真实世界的同一笔业务”。如果是事件表自己用自增ID做判断,那么同一笔业务在两个环境里就会拥有不同ID,幂等约束就形同虚设。
针对极端并发场景,还可以在event_resource表上加“同一事件同一资源只能有一个方向的减记”这类约束,防止同一张发货单不小心关联了同一SKU的两条流出记录。这类约束看起来繁琐,但每一个都是对“重复入账”这个坑的主动防御。
4.4 对账不再是对余额,而是对事件序列
传统对账方式:系统余额 vs 银行/仓库账面余额,两边各自算一个期末数,比较是否相等。REA落地后,我把它改成了事件序列比对。具体做法是:把系统内的收款事件按业务参考号、金额、时间导出一份清单,把银行渠道流水也按同样的口径导出一份清单,然后逐笔匹配。匹配不上的,不是“最终余额不对”,而是“中间有一笔差异”,定位到具体是哪一笔的时间点,通常很快就能找到原因。
这种模式在数据库层面的表达,是一个简单的“未匹配事件”查询:
SELECT e.business_ref_no, e.event_type, e.happened_at, SUM(er.valuation) AS amount FROM economic_event e LEFT JOIN event_resource er ON er.event_id = e.id WHERE e.event_type = 'receive_payment' AND e.business_ref_no NOT IN ( SELECT matched_ref_no FROM external_statement ) GROUP BY e.business_ref_no, e.event_type, e.happened_at;这个查询的假设是有一条external_statement表格来记录银行流水的匹配状态。实际项目中这部分会做得更复杂,但核心思路不变:对账系统变成“找两边事件集合的差集”,而不是“比较两个标量余额”。我做了这次改造以后,财务同事反馈最多的一句话是“终于知道差在哪一笔了”,而不是以前那种“数量对上就行,具体哪笔再说”。
5. 这模型不是万能药:踩坑记录与适用边界
REA确实解决了我上面提到的追溯性和一致性问题,但它不是毫无代价的万能结构。我用下来大概半年,踩过好几个坑,也摸出了一些适用边界。把这些经验写出来,是为了让你别像我一样在某个地方反复权衡很久才想明白。
5.1 最容易失控的是事件粒度
事件粒度是我踩得最狠的坑。一开始我担心事件记录不够细,于是把“客户修改收货地址”“客服发起改价”“运营添加备注”都做成了事件关联到订单。结果模型变得越来越重,满屏都是“事件”,业务方却看不懂任何一条事件的价值。后来我把粒度原则收敛成一句话:只有会改变资源数量或法律义务的动作,才被认定为经济事件;其他作为操作日志保存,关联订单号即可。
粒度失控还会拖垮查询性能。事件表一旦膨胀到上亿行,实时聚合的成本会显著上升。我后来按日、按资源重建物化视图才把查询拉回来。如果你在事件粒度上拿不准,建议先用“是否会引发资源增减”判断,再问一句“把这个动作从模型中删掉,业务追溯会不会缺核心证据”。两个问题都通过,才值得进入事件表。
5.2 非经济动作硬塞进REA,反而失去可读性
不是所有业务元素都适合塞进REA框架。报关单号、物流轨迹、图片审核、风控规则命中,这些要么是法规要求,要么是流程控制,要么是机器学习推断,它们不直接交换经济资源,硬塞进去只会让模型变得又长又难懂。
我的处理方式是把系统分成两层:经济核心层和业务支撑层。经济核心层使用REA,承载订单、库存、资金、应收应付;业务支撑层保存物流轨迹、风控记录、审核步骤,通过事件ID和经济核心层关联。这两层共用一套主数据,但不互相污染。这样REA模型保持精简,支撑层的丰富信息也不丢失。
5.3 与常规CRUD开发的思维冲突
最后要提醒的是心态问题。习惯了CRUD开发的同事,遇到REA会有一种强烈的“不舒服”:为什么“更新库存”不是UPDATE一个库存表,而是INSERT一条新事件记录?为什么订单状态变了,不直接改状态字段,而要关联一串事件?
这种冲突不只是技术问题,更是数据思维的转变。CRUD的默认假设是“数据记录代表当前状态”;REA的默认假设是“数据记录代表发生变化的过程”。如果你所在团队的成员没有这种意识,哪怕表结构设计成REA,编码时他们也会想方设法绕过事件表,去存一个“方便”的状态字段,最后模型依然会老化回旧形态。
我的建议是,导入REA模型的初期不要急着动所有模块,先选一个对追溯要求高的模块试点,比如应收对账或库存流水。跑通一轮后,再把经验迁移到其他模块。避免一次重构范围过大,否则项目本身就变成了灾难事件。
说来也怪,做完这次重构后,我的感受不是“我掌握了一个高深的建模方法”,而是“我终于敢让代码解释历史了”。以前每次遇到数据不一致,都要靠人肉翻日志去猜;现在只要顺着事件表往下游查,该谁的责任、该补哪条记录,一目了然。最后分享一个小习惯,是我现在建每一张业务表之前都会做的自检:这行记录能不能回答“谁在什么时间做了什么动作,导致了哪个资源的增加或减少”。如果回答不了,就回到事件表重新设计。这个习惯,比任何建模工具都管用。