恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
从账务模型到REA事件驱动:进销存数据库设计重构实践
首页
资讯中心
/
从账务模型到REA事件驱动:进销存数据库设计重构实践
从账务模型到REA事件驱动:进销存数据库设计重构实践
发布时间:2026/10/14 12:48:48
最近在整理一个进销存系统重构方案表结构已经改了快三轮。第一轮加税率字段第二轮加渠道字段第三轮把订单主表拆成了五张小表。改完那周我自己都快认不出这套结构了。问题出在哪其实就是底层一直在用“凭证、科目、余额”的账务思路去装业务流业务一变形表就跟着崩。后来我把这个项目代号定为 rea顺着 REAResource、Event、Agent这套资源事件代理模型重新设计了数据底座。这轮改完订单、库存、收付款不再是一张张互相硬拉的表格而是一串可以随时回放的事件。这篇文章就记录我怎么从一张失控的台账表走到事件化建模的过程也把实测中的几个坑一并写出来。如果刚好在做进销存、电商、ERP 这类系统的数据库设计或者总觉得自己的表字段越来越多、越来越乱这篇值得往下看。我不打算堆理论尽量用具体例子和 SQL 说话。1. 从一张一直改不完的进销存表说起账务模型为什么在失控1.1 复式记账给系统设计留下的底子复式记账这套机制能流传这么多年确实有它的价值。每一笔交易至少进入两个科目一借一贷最后资产负债表能对平。做财务系统的人靠它发现笔误、防止差错它天然自带一套校验逻辑。于是很多做企业信息系统的团队顺理成章地把这套东西复制进了业务系统建科目表、建凭证表、建余额表把所有业务动作都翻译成借贷分录。可这里藏着一个错位复式记账擅长的是“价值运动的守恒校验”它并不擅长表达“业务是怎么发生的”。销售、发货、收款、退货在账面上都能变成一组借贷分录但业务发生的时间顺序、参与人、库存变化这些过程信息被压缩进了几张抽象的凭证表里。日常对账没问题一旦要按订单、按用户、按批次去追溯就发现这套抽象层次离业务细节太远。这就好比记日记时只记“今日存款500元消费-300元”却完全不写买的是什么、在哪个店、为什么买。月底确实能平衡但回过头想追一笔账你根本看不出当时发生了什么。业务系统的底子如果只关心金额平衡那它天然就会把大量过程细节丢掉。1.2 我是怎么一步步把表改乱的我最初的设计其实很朴素订单表、库存表、流水表。没过多久业务方开始提需求要支持折扣、要支持预售、要支持售后、要区分渠道。第一版我做了最直接的操作——加字段。tax_rate、discount_amount、channel_source、business_type 一个个往订单表上堆。堆到第三轮订单主表已经有三十多列其中十几个字段在不同业务线下永远是 NULL。真正让我崩溃的是新业务接入的那次。产品提了个“预购模式”用户付定金之后几个星期再发货。乍一听是小改动但落到当时的结构里要同时动订单表、付款表、库存预留表、退款表还牵扯到发货时间的判断逻辑。我们评估了一个星期越评估越觉得这套结构扛不住。因为底层的模型是在描述“账目”而不是描述“业务本身”所以业务的每个新动作都会翻出一堆连锁修改。这也是很多项目共同的路径刚开始是业务驱动到中期变成表结构驱动后期就是“改表救火”。根子往往不在需求多而在模型抽象的层级从一开始就错了。1.3 REA给出的不同答案让“事件”成为核心REA 模型做的第一件事就是重新定义“数据库里应该记录什么”。它不关心余额而是关心“事实”谁在什么时间对什么资源做了什么资源的量发生了什么变化。它把所有东西拆成三类——资源Resource、事件Event、代理Agent。一笔销售不再是一条借贷凭证而是多个事件组合成的过程商品资源离开仓库现金资源进入账户销售员和买家各自作为代理参与在其中。每个事件只描述一件原子事实某商品减少了 3 件或者某账户增加了 299 元。事件之间用一套业务流 ID 串起来就是完整的一笔交易。这样做的好处很明显当业务方说“我们要加一个新的动作”你通常不需要改主表结构只要为这个动作新增一个事件类型让它关联到已有的资源和代理上。以前那种“每来一个需求就动一次主表”的毛病从根本上被化解了。2. REA的核心拆解资源、事件、代理三者到底在表达什么2.1 资源组织里一切可以被控制且有价值的流动量资源是组织拥有或控制的、能够带来价值的东西。最典型的例子是库存商品、现金、原材料进一步还可以是积分、优惠券、服务工时、专利授权。识别一个东西是不是资源有个很实用的判断方法问它会不会“被消耗”或者“被增加”。会变化的才值得建模成资源永远不变的很可能只是一个描述性属性。传统账务里我们习惯叫“科目”但科目是记账口径的分组和“可流动的标的物”是两回事。比如库存现金科目下存的是钞票应收账款科目下存的是债权而 REA 直接把“现金”当成一类资源把“应收账款”当成另一类资源。它不关心科目怎么分类只关心资源如何流入流出。建模初期最容易犯的错误是资源划分粒度不统一。我见过有人把“库存”当成一个资源结果每次聚合时所有商品都混在一起也有人把“某商品的某个批次”都当成一个资源资源表膨胀得厉害。我的建议是粒度至少到“用户可感知的业务对象”比如 SKU、货币币种、积分账户。如果后续要支持批次和序列号可以在资源上再加批次属性而不是把资源无限细分。2.2 事件唯一记录“发生什么”的核心实体事件是 REA 模型里真正的主角。每个事件记录了一次资源的流入或流出它是审计的最小单位也是所有余额的唯一数据来源。一件商品卖出去、一笔钱收进来、一项服务完成交付这些都在事件表里留下一行不可变的记录。在设计事件表时有两条铁律。第一事件只可追加、不可修改、不可删除。如果录错了就新增一个方向相反的事件去冲正。这一点和复式记账里的红字冲销思路一致但它是在数据系统层面做的硬约束。第二事件必须携带充分的时间信息和来源信息。做不到“一个键查清所有来龙去脉”在审计阶段会非常痛苦。有一点很多人会忽略事件表本身不保存余额。余额是事件流聚合出来的结果这意味着你可以从任意时间点重新计算历史状态也可以在修复数据时通过重放事件找回一致性。在传统表结构里余额字段被到处冗余和更新对账往往演变成“到底哪个字段才是准的”这种问题。2.3 代理把责任落到具体的人或组织代理是参与事件的个人或组织它回答的是“谁”的问题。REA 把代理分成两类内部代理和外部代理。内部代理是组织里的员工、仓库管理员、客服人员外部代理是客户、供应商、物流公司。每个事件至少要关联一个代理甚至可以同时关联多个代理。为什么这个要素不能省因为没有代理的事件是没有责任人的事件。某个库存调整发生了如果不知道是谁做的操作出了差错连追索的路径都断了。传统流水表里经常只记录 created_by 一个字段甚至很多系统直接留空。在 REA 里代理不是一个可选的“操作人”字段而是建模的第一等要素必须显式出现在表结构里。实际项目中代理表可以先简单建agent_type 区分 internal 和 external再根据业务需要挂上部门、职级、联系方式。不要一上来就设计复杂的组织架构表大多数情况下用户只需要知道“这是哪个仓库的某个人”。2.4 用一个订单全部走一遍把三者串起来看一段业务就很清楚了。假设一个买家在某电商下单买了一件售价 299 元的商品。拆成 REA 事件是两桩一桩销售出库事件商品资源减少 1 件一桩收款事件现金资源增加 299 元。两个事件都发生在同一个业务流里前者由仓管员执行后者由系统自动确认而买家和卖家分别充当外部代理和内部代理。如果把这笔业务放进传统凭证模型它只是一张会计分录在 REA 里你能分别看到商品资源和货币资源的两次流动还能顺着代理、时间、业务流 ID 把两个事件还原成一次完整交易。这个“事件配对”关系是 REA 模型里的一个核心概念流入与流出共同构成一笔经济事务。很多报表字段能不能追溯出来就看当时有没有把配对关系存好。3. 用REA给一家小型电商企业建模从圈资源到落数据库表3.1 第一步把所有资源列全做任何建模都建议先列资源清单而不是先画表。拿一个卖家居用品的小型电商团队来举例它的资源大概有可售库存商品、现金账户余额、预付款/押金、平台优惠券、积分账户甚至可以包括未发货的订单占用的“承诺库存”。把资源列全面之后的事件定义才有抓手。列资源时不要漏掉“钱”。很多第一次建模的人只盯着实物商品现金资源被忘得干干净净结果收款事件无处安放。我的经验是把“现金及等价物”拆成两个粒度——企业账户余额和客户预收款余额它们在后续退款结算时会表现得完全不一样。资源清单不是一次定死后续新业务引入新的标的物时就把它追加到资源表。这是追加型变更不会推翻已有结构。3.2 第二步给每个资源定义事件资源定了事件就围绕资源的流入和流出来定义。以库存商品为例采购入库是流入销售出库是流出盘点调整可能是流入也可能是流出售后退货入库是流入报废出库是流出。对现金账户而言收款是流入付款是流出退款是逆向流出。每个事件都要能明确回答它让哪个资源增了还是减了量是多少。建议给事件类型维护一张字典表并在代码中禁止随意裸写字符串。我一开始图省事直接写 event_typein后来根本分不清是采购入库还是退货入库。改成 purchase_in、sale_out、payment_in 这类带语义的名称之后报表和审计都顺眼多了。一个小技巧事件类型名称用“业务语义”命名不要用“in/out”命名。in 和 out 是方向会被多个资源复用purchase_in 能直接说清来源和去向后面查问题省了很多事。3.3 第三步识别代理并确定事件归属接下来给每个事件安排代理。库存类事件通常是内部代理比如仓库管理员、质检员资金类事件涉及外部代理比如买家、供应商。在每个事件里至少保留两个代理引用internal_agent_id 和 external_agent_id。有些系统里还能看到“经办人”和“复核人”那就需要一张事件-代理角色关联表我这里为了讲清楚主干先按两个代理字段来设计。这一步看起来简单却是很容易被忽视的环节。我见过不少项目把代理合并到什么 created_by 字段里结果“谁执行了操作”和“这单是谁的”彻底分离查起账来极其痛苦。建模阶段多留两个外键后期的运营报表和风控审计都会感谢你。3.4 第四步翻译成表结构下面是一组能直接跑起来的 PostgreSQL 建表语句我把它作为落地参考。CREATE TABLE resource ( id BIGSERIAL PRIMARY KEY, resource_code VARCHAR(32) UNIQUE NOT NULL, resource_name VARCHAR(128) NOT NULL, unit VARCHAR(16) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE agent ( id BIGSERIAL PRIMARY KEY, agent_code VARCHAR(32) UNIQUE NOT NULL, agent_name VARCHAR(128) NOT NULL, agent_type VARCHAR(16) NOT NULL, contact_info TEXT ); CREATE TABLE flow ( id BIGSERIAL PRIMARY KEY, flow_code VARCHAR(32) UNIQUE NOT NULL, business_type VARCHAR(32) NOT NULL, external_agent_id BIGINT REFERENCES agent(id), occurred_at TIMESTAMPTZ NOT NULL ); CREATE TABLE business_event ( id BIGSERIAL PRIMARY KEY, flow_id BIGINT NOT NULL REFERENCES flow(id), event_type VARCHAR(32) NOT NULL, resource_id BIGINT NOT NULL REFERENCES resource(id), quantity NUMERIC(18, 4) NOT NULL, unit_amount NUMERIC(18, 4), currency VARCHAR(8) NOT NULL DEFAULT CNY, internal_agent_id BIGINT REFERENCES agent(id), occurred_at TIMESTAMPTZ NOT NULL, source_ref VARCHAR(64), event_uuid UUID UNIQUE NOT NULL, CHECK ( (event_type IN (purchase_in, payment_in, adjust_in, return_in) AND quantity 0) OR (event_type IN (sale_out, payment_out, adjust_out, return_out) AND quantity 0) ) );解释一下其中的设计取舍。第一quantity 的语义由资源决定库存商品的 quantity 是件数货币资源的 quantity 是金额所以现金收款事件和商品出库事件共用一张表也不违和。第二unit_amount 是带语义的冗余字段用于记录商品的成交单价或成本单价它不是聚合所需的原始事实但财务和运营报表需要它。第三CHECK 约束在数据库层保证方向语义不被滥用比在代码里维护要可靠得多。最后是 source_ref 字段。它是业务单据上的单号或外部流水号用来把事件和原始合同、订单、物流单关联。事件表尽可能保持纯净不要去存收货地址、促销文案这类业务上下文它们是单据的属性不是资源的流动事实。3.5 关联事件用 flow_id 把一笔交易串起来一张销售订单会拆出商品出库和收款两个事件如果系统里有分期付款收款还可能拆成多个事件。为了把这些事件还原成一笔完整交易我在 business_event 里引入了 flow_id指向 flow 表。flow 表记录业务流的整体信息业务类型、发生时间、外部代理相当于“这笔买卖的总卡片”。有了 flow审计就非常舒服。根据单据号找到 flow再按 flow_id 把事件全部捞出来就能看到一笔交易从开单、出库到收款的完整时间线。做对账、做财务月结、做逆向流程时都只需要围绕 flow 去操作不需要在各张表之间反复猜测关系。4. 事件表不是流水账聚合查询、余额计算与幂等设计4.1 计算当前库存/账户余额的两种方式事件表只存增量事实所以余额永远在聚合的时候才能得到。实现方式有两种。一种是全量聚合每次查询都把表扫一遍另一种是维护周期物化视图或汇总表定时从增量事件里刷新。事件量小的时候全量聚合没有任何问题事件量到了几十万甚至上千万就必须引入快照机制。我倾向于把事件表当作唯一的“事实来源”所有物化视图、汇总表都是可以随时重建的投影。一旦发现投影数据不对优先做的不是手动改数据而是重建投影。这套原则让运维过程中的应急修复变得很简单——你永远相信事件表是准的其他都是派生物。打个比方事件表是一卷原始监控录像汇总表是监控室整理的警情摘要。摘要写错了可以重写但录像带既不能剪也不能误覆盖。只要录像还在任何时刻的状态都能重新推出来。4.2 关键SQL示例按资源聚合、按时间点找余额先看当前余额一条 GROUP BY 就够。SELECT r.id, r.resource_name, r.unit, SUM(e.quantity) AS balance FROM business_event e JOIN resource r ON r.id e.resource_id GROUP BY r.id, r.resource_name, r.unit ORDER BY r.id;如果需要某个时间点的余额加上时间过滤条件。SELECT r.id, r.resource_name, SUM(e.quantity) AS balance_at_time FROM business_event e JOIN resource r ON r.id e.resource_id WHERE e.occurred_at 2025-06-30 23:59:59 GROUP BY r.id, r.resource_name;这里的核心逻辑很简单余额就是把截止到某个时间点的所有事件累加起来。正因为简单它才不容易被业务规则撬动。你新增任何业务事件只要方向正确余额天然正确。如果事件量真的上来了再建物化视图。注意物化视图不是必须的可以先用一张 balance_snapshot 汇总表记录“每个资源上一次结算到哪个事件、当时余量是多少”下次刷新时只处理那个事件 ID 之后的新记录。这样把计算量从全表聚合降成增量聚合。CREATE TABLE resource_balance_snapshot ( resource_id BIGINT PRIMARY KEY, balance NUMERIC(18, 4) NOT NULL, last_event_id BIGINT NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );4.3 event_uuid 幂等与并发控制事件表追加写入本身不难难在保证同一笔业务事实不会因为重试、并发而被写两遍。我在 event 表上保留了一个唯一键 event_uuid它由业务系统在创建事件时生成通常用业务流水号加一个随机后缀拼出来。只要这个 UUID 唯一数据库唯一索引天然保证了幂等同一个业务事实再怎么重复提交也只能落一行。这个设计在对接支付回调时尤其有用。支付平台可能同时推送多条通知传统做法是在业务代码里加锁、加状态判断但分布式环境下很容易漏。把 event_uuid 的唯一约束下沉到数据库就让重复提交在最后一层被挡死省心很多。还需要补充的是纠正错误不要 DELETE。事件发生了就发生即使录错了也应该新增一个“冲正事件”把量抵消回去。数据库层甚至可以用权限控制让应用账号没有 DELETE 和 UPDATE 权限从技术上逼着所有修改走追加路线。5. 拿REA和传统方案对比什么变容易了什么变麻烦了5.1 一张表看区别对比维度传统凭证/余额模型REA 模型核心实体科目、凭证、余额资源、事件、代理余额来源余额字段随交易实时更新事件流聚合得出扩展新业务通常要加字段、加表、改流程新增事件类型追加表达审计追溯凭证分录与业务单据反复 JOIN事件按时间追加按 flow 还原修改/删除需要软删除和复杂权限设计上禁止修改删除只能冲正学习成本会计思维上手直观更抽象需要切换建模视角这张表没有绝对的好坏它只是两种建模哲学的对立一个面向结果一个面向过程。前者适合状态明确、规则稳定的业务后者适合流程复杂、变化频繁的业务。5.2 变得容易的地方一是新业务接入不再炸表。预售、拼团、礼卡、虚拟商品本质上都是“给某个资源增加或减少一定量”一次建模之后新业务只是多一种 event_type 和 resource 组合。二是审计追溯变得通顺。想查某笔订单的问题先按单号找 flow再顺着 flow 事件列表看资源变化和代理天然就有一条完整链路。三是数据修护有据可依。因为事件是唯一事实源发生账实不符时优先怀疑的是投影而不是源头。5.3 变得麻烦的地方麻烦也很明显。报表不再是一个整数余额字段直接读而是要先算聚合或者依赖物化视图初期开发量会多一截。对不熟悉这套模型的同事来说“为什么表里没有余额列”是每天都会被问一遍的问题。另外如果需要频繁展示实时余额你还得额外做一个高吞吐读模型或者引入缓存复杂度并没有消失只是从写路径挪到了读路径。读路径的优化要提前规划否则上线后才发现报表查询慢就只能在事发后补物化视图。建议业务跑起来之前就设计好事件量的预估和刷新策略别让事件表真的变成一辆没有刹车的车。5.4 什么项目不要硬上REA如果一个系统只有一张业务表和一个库存余额字段业务规则也非常固定比如就是简单的实体店记账那直接用传统余额模型就行REA 属于过度设计。REA 真正发挥价值是在业务类型多、事件种类杂、流程经常调整的中大型系统里。衡量标准就一句话未来三个月内你要不要在一个实体上支持三种以上会产生“增减”的业务如果需要REA 值得认真考虑如果不需要余额字段够了。6. 我在模拟项目X中踩过的三个坑与最终体会6.1 坑一把“台账”当成事件来建模第一次重构时我把“库存台账表”直接换了一张皮当成事件表在用。每算一次账就往里头写一条“当前余额”的记录。结果事件表里堆积了海量重复的快照聚合时余额翻倍账目一团糟。后来才想明白台账是事件的投影不是另一个事实。仓库里真正发生的只有每次增减余额应该是查的时候算出来的而不是每次写入的动作。这个坑的本质是混淆了“写模型”和“读模型”。事件表是写模型只记录不可变事实余额表是读模型服务于展示和查询。两者可以并存但绝不能混在一张表里。6.2 坑二事件表里塞太多业务上下文第二个坑是过度亢奋想让事件表包罗万象。后来我往事件表里塞了税率、优惠分摊、收货地址、营销活动标签。最后发现这些字段大多是业务单据属性不是资源流动事实。当我要写一个通用的余额计算 SQL 时被一大堆无关字段干扰做审计时也因为事件表的字段过多而变得沉重。后来的做法是事件表只保留身份、时间、资源、数量、代理、来源引用这几个核心字段其他业务上下文放进 flow 表或专门的单据表。source_ref 是桥业务详情通过它去关联而不是把整个宇宙都塞进事件里。6.3 坑三没有提前设计余额物化视图第三个坑发生在数据量上来之后。当时事件表大约跑了八个月累积了四百多万行。原本跑得飞快的按资源余额统计开始出现一次查询两三秒甚至更久的情况。更难受的是按时间点回溯历史余额的报表每次都要扫一大片事件监控告警几乎每天都在响。补救方案就是前一章说的增量汇总表维护 balance_snapshot 和 last_event_id每小时跑一次增量刷新。上线之后常规查询从秒级掉到毫秒级。这件事给我最大的教训是如果预估半年后事件量会破百万就在建表的同时把快照机制设计好别等报表炸了再救火。6.4 最终体会在模拟项目X里走完这一轮我最大的感受是REA 其实不只是一套表结构方案它更像一种对“业务事实”的建模态度。设计任何模块前先问自己——这里真正发生的事件是什么哪个资源在变动谁参与的把这三个问题回答清楚了表结构通常是水到渠成的事。如果后续系统要考虑事件溯源或者 CQRSREA 的事件表几乎可以直接作为事件流底座两者在理念上非常契合。我也打算下一步把异步通知、消息重放和事件表统一起来让下游系统直接订阅事件来同步各自的读模型。这几个月的实践证明在业务变化频繁的系统里把事实写对比把结果算对更容易长期维护。最后聊一个实操细节开始用 REA 就别在应用代码里留 UPDATE 的入口。哪怕团队初期不习惯也尽量通过数据库权限和唯一索引把它堵死。只要这个口子开着改数据的人迟早会把事件表改成一锅粥。追加事实、冲正错误、聚合出结果这套循环才是模型能跑下去的关键。