基于REA模型的事件驱动记账系统设计与实践
有段时间我在重构一套业务系统订单一进来订单表、发货表、开票表、回款表各记各的月底财务对账能把人逼疯。财务要的是借贷平衡业务里只有一堆状态流转两边死活对不上。后来我换了个思路不再按“单据类型”建表而是用 REA 模型——Resource资源、Event事件、Agent代理——把业务和账务统一成一条经济事件流。基于这套思路我写了个小型项目代号就叫 rea正好也是三个核心词的词头拼出来的名字。这篇文章是 rea 从设计、建表、写代码到上线踩坑的完整复盘。适合正在做进销存、财务模块、订单履约这类系统的开发者也适合对“业务和账务怎么对齐”这个问题头疼的产品和研发。读完你至少能拿走一套可以直接落地的事件模型以及我踩出来的坑位地图。1. 整体设计与思路拆解为什么非要用 REA 建模1.1 传统记账模式到底疼在哪先说我原来的系统长什么样。一张订单背后有订单表、发货表、开票表、收款表各表都有自己的状态字段订单已发货、已开票、已回款是三个互不相干的布尔值。查询“这个订单赚了多少钱”要把四张表 JOIN 起来再逐条核对时间和金额。遇到部分发货、部分退款、预付定金抵货款这类情况每一处都得写专门的 if-else代码里到处是特例。月底对账更痛苦。财务导出的应收账款余额和业务系统里“已发货未收款”的订单金额总和经常差出几笔。因为发货单和开票单生成的时间点不同回款又可能拆成好几笔两边对不上之后只能人工逐单核查一查就是两三天。这种割裂的根源不是哪张表设计得不好而是模型层面没有统一语义业务系统和财务系统各说各话。当时我也试过把借贷分录直接落到业务表里比如订单表加一个“借方科目”“贷方科目”字段。结果更糟业务上的“销售给客户”和会计上的“借应收账款、贷主营业务收入”根本不是一一对应中间还隔着成本结转、税费、折扣分摊。把会计语言硬塞进业务结构只会让两张皮变成三张皮。1.2 REA 的核心视角先记录经济事实再解读成账REA 的做法和上面完全反过来。它不去管借贷怎么记只管一件事经济事实是什么。一次销售事实是“货从我的库存里少了十箱”同时“我多了一笔向客户收款的权利”。这两个事实各自都是一个经济事件前者让库存资源减少后者让应收账款资源增加。至于“这笔交易赚了多少钱”“要不要确认收入”“按什么税率开票”都是后续从事实推导出来的解读。三个要素Resource资源是任何有价值、可用事件改变其存量的经济对象Event事件是导致资源增减的行为必须在一个时间点发生Agent代理是参与事件的主体可以是客户、供应商也可以是内部部门或员工。三者之间有三条核心关系——事件对资源的存量影响叫 stock-flow交换中一给一得的事件配对叫 duality代理与事件的关联叫 involvement。概念上就这么简单真正的工作量在如何把现有业务翻译成这三类对象。这套模型在会计信息系统理论里已经沉淀了很多年我最初也是将信将疑直到自己跑通一单销售后才发现妙处账务变成了一组可解释、可重放的规则视图而业务变成了不可变的事件日志。借贷不再需要刻在业务表里随时可以从事件推导出来。1.3 项目 rea 的定位与边界rea 不是完整 ERP它是一个“事件记账内核”主要做四件事维护资源、代理两类主数据并登记事件保证事件幂等且不可变提供任一时刻的资源余额查询按规则把事件派生为会计凭证。我把业务层订单、发货单这种放在内核之外由上层往内核提交事件再由内核反馈“这单能记库存够不够、派生凭证是否平”。技术选型我刻意从简数据库用了开源的 PostgreSQL应用层用 Python。没引入消息队列事件先落库再发通知。原因很实际记账内核最怕丢事件、重复事件事务性优先于吞吐量等到数据量真大到撑不住再在事件表前面加队列也来得及不要让架构一开始就复杂。同时我也明确哪些场景不适合硬套 REA例如需要复杂成本分摊、合并报表的企业REA 只能作为底层事实层报表和成本的“解读层”必须单独设计。这也是我在项目后期才彻底想清楚的后面会专门讲。2. 资源、事件、代理核心概念与数据结构设计2.1 资源Resource别只想着库存商品最开始设计 resource 表时我犯过一个经典错误只把仓库里的商品当资源。等到想表达“客户还欠我们钱”时发现没有地方放。REA 里的资源要比“实物”宽得多——现金、商品、原材料是资源应收账款这种权利是资源应付账款这种义务也可以建模为资源只是它的增减方向要特别约定。我的资源表分了三类physical实物、claim权利、obligation义务。每个资源有自己的计量单位商品的单位是箱应收账款的单位是元。这样设计之后“卖货”这个动作就变成了两个事件库存商品流出 10 箱应收账款流入 500 元。两者的单位不同但都在同一张事件表里用各自资源对应的计量单位记录。有一类常见的坑把“资源价值”直接存进资源表。比如给商品表加一个“最新单价”字段随时 UPDATE。这在 REA 里不能做因为资源的价值是随事件变化的你要的是成交时刻的价格不是当前的标价。价格、成本这些信息应该挂在事件上资源表只保存稳定的主数据。我把 unit_price 留在事件侧后面生成凭证时按事件取价再也没出现“改了基础资料导致历史账目漂移”的问题。resource 表结构参考id、资源编码、名称、资源类型、计量单位。主键之外资源编码要做唯一约束因为上层业务需要拿编码去关联。CREATE TABLE resource ( id BIGSERIAL PRIMARY KEY, code VARCHAR(32) NOT NULL UNIQUE, name VARCHAR(128) NOT NULL, resource_type VARCHAR(16) NOT NULL CHECK (resource_type IN (physical,claim,obligation)), unit VARCHAR(16) NOT NULL );2.2 事件Event日志式追加不可修改事件表是整套模型的心脏。它的设计原则只有一条只追加不改删。任何冲正都通过一个新事件负向数量去抵消而不是把旧事件 UPDATE 掉。这样做换来的东西非常值钱余额可重算、审计可追溯、派生凭证可复算。如果说 REA 有一种信仰那就是“记录事实要像历史档案一样严肃”。每个事件至少需要这些字段事件类型、资源、数量增减、参与代理、发生时间、幂等键。增减数量我用符号区分正数代表资源流入负数代表资源流出。应收账款核销就是一条负向的应收账款事件库存发货就是一条负向的库存商品事件语义非常直接。配套的设计是幂等键。我在 event 表上建了 UNIQUE(ref_id)ref_id 由上层业务单据号加动作序号拼接。同一条发货单重试提交时数据库直接拒绝重复的 ref_id从根上拦住幂等性问题。这个设计后来救了我很多次。CREATE TABLE economic_event ( id BIGSERIAL PRIMARY KEY, ref_id VARCHAR(64) NOT NULL, event_type VARCHAR(64) NOT NULL, resource_id BIGINT NOT NULL REFERENCES resource(id), delta_qty DECIMAL(18,4) NOT NULL, unit_price DECIMAL(18,4) NOT NULL DEFAULT 0, occurred_at TIMESTAMPTZ NOT NULL, pair_id UUID, UNIQUE (ref_id) ); CREATE INDEX idx_event_resource_time ON economic_event (resource_id, occurred_at);pair_id 是我用来关联 duality 配对事件的字段。一次销售会产生两个互为镜像的事件货流出、应收流入它们共享同一个 pair_id后续查询“这笔交易对应的完整事件对”特别方便。严格说 REA 理论里 duality 是事件与事件的关系落到表上就是一个可空的外键或 UUID 组怎么命名不重要关键是配对信息不能丢。2.3 代理Agent与多方参与代理相对简单我只维护一张 agent 表类型分 internal部门/员工和 external客户/供应商。但“谁参与了事件”“以什么身份参与”这两个问题一开始被我简化成了两个字段from_agent_id 和 to_agent_id。做简单买卖还行一遇到三方场景就尴尬——比如一笔退货要同时记录客户、经办销售、仓库验收人两个字段根本不够。后来我把参与关系拆成了关联表事件和代理之间可以有多条记录每条记录带一个 role。发起方、接收方、审批人、经办人都能表达。这里有一条心得不要为了省一张表把语义硬塞在固定字段里参与关系是天然的 many-to-many老老实实用关联表最稳。CREATE TABLE economic_event_agent ( event_id BIGINT NOT NULL REFERENCES economic_event(id), agent_id BIGINT NOT NULL REFERENCES agent(id), role VARCHAR(32) NOT NULL, PRIMARY KEY (event_id, agent_id, role) );3. 实操过程从零实现一个 REA 记账模块3.1 建表与初始化约定先把账套初始化说清楚。我用一个账套表记录每个业务组织的基础设置比如本位币、成本核算方式资源表里预置几类默认资源库存商品、银行存款、应收账款、应付账款。这几类资源在大多数业务里都跑不掉提前建好后续再按业务扩展。这里有个容易被忽略的点库存商品资源要关联“计量单位转换”配置比如箱和瓶的换算率在事件层就要把数量统一成基础单位否则后面算库存时单位混乱。数据表建好之后我在资源编码上做了全局约定以 IC_ 开头是库存类以 CL_ 开头是权利类以 OB_ 开头是义务类以 CA_ 开头是资金类。编码约定看着小实际对排查问题帮助巨大。比如日志里看到 CA_BANK 就知道是资金类资源不用翻主数据表去猜。3.2 核心服务逻辑事件提交与余额计算事件提交是唯一写入口。核心函数 record_exchange 接收一组互为 duality 的事件在一个事务里落库落完同时触发派生凭证生成。我的伪代码大致是这样def record_exchange(session, events, ref_prefix): with session.begin(): for ev in events: ev.ref_id f{ref_prefix}:{ev.event_type} validate_event(session, ev) insert_event(session, ev) if not is_balanced(events): raise ValueError(配对事件不平衡) for ev in events: build_vouchers(session, ev)validate_event 里主要做三件事校验事件类型和资源类型匹配比如 sale_delivery 只能作用于 physical 资源校验代理角色是否齐全校验合计平衡即配对事件的增减金额逻辑成立。校验规则用配置表维护而不是写死在代码里。后面对账排查时你会发现规则可视化比代码分支容易核对得多。余额查询我封装成两个函数。current_stock 给实时查询用直接对事件表按资源分组求和period_stock 给报表用支持指定时间点。一开始我担心 SUM 慢实际跑下来几十万事件级别在加了 (resource_id, occurred_at) 索引后毫秒级返回完全够用并不需要一开始就上物化视图。def stock(resource_code, as_ofNone): sql SELECT COALESCE(SUM(delta_qty), 0) FROM economic_event e JOIN resource r ON r.id e.resource_id WHERE r.code :code if as_of: sql AND e.occurred_at :as_of return session.execute(sql, {code: resource_code, as_of: as_of}).scalar()3.3 事件到凭证派生引擎与标识规则库从事件到借贷分录我建立了一个派生规则库。每条规则声明某个事件类型触发哪些科目的借贷金额如何从事件字段计算。例如sale_delivery借 主营业务成本贷 库存商品金额 abs(delta_qty) × 成本单价claim_receivable借 应收账款贷 主营业务收入金额 abs(delta_qty) × 成交单价cash_receipt借 银行存款贷 应收账款金额 事件金额claim_settle核销事件不生成凭证只更新应收余额口径我把规则挂在配置表里事件表加一个 rule_version 字段每次派生凭证时记录用的是哪个版本的规则。这样万一规则调整历史凭证仍然知道自己当时是按什么逻辑生成的重放不会错乱。新增业务类型时核心代码几乎不用动只加配置这是我做了三轮迭代后换来的最大爽点。3.4 跑一遍完整示例卖货、收款、结账拿一套采购入库后销售的场景完整走一遍。假设仓库有 100 箱坚果成本价 30 元/箱卖给客户 A 10 箱售价 50 元/箱客户随后全额付款。整个过程在 rea 中是这样落的第一步采购入库事件资源 IC_GOODSdelta_qty100单价 30agent供应商ref_idPO20250101-01。这一步让库存商品的存量从 0 变 100同时建立一个应付义务事件资源 OB_PAYABLEdelta_qty-3000表示对供应商的资金流出方向。注意我这里约定资金流出记为负这个方向约定要在文档里反复强调。第二步销售发货事件资源 IC_GOODSdelta_qty-10单价 50agent客户。第三步应收权利事件资源 CL_RECEIVABLEdelta_qty500agent客户。这两个事件共享同一个 pair_idref_id 分别为 SO20250101-01:sale 和 SO20250101-01:claim。此时按资源汇总库存余额立刻变成 90 箱应收余额变成 500 元。第四步客户付款资源 CA_BANKdelta_qty500agent客户第五步核销应收资源 CL_RECEIVABLEdelta_qty-500agent客户与第四步共享一个 pair_id。此时银行存款多了 500应收余额归零。派生凭证层发货生成借主营业务成本 30010 箱×30、贷库存商品 300应收生成借应收账款 500、贷主营业务收入 500收款生成借银行存款 500、贷应收账款 500。三张凭证借贷均平。整个过程里没有任何一处直接写过一个科目的余额。所有科目余额都是从事件重新计算的。月底对账流程从“人工比对总额”变成了“重放事件流比对余额”哪一笔对不上顺着 ref_id 就能定位到原始单据。这就是 REA 带来的最直接收益。4. 常见问题与排查技巧实录4.1 库存余额对不上的 4 个根因上线第一个月最常被问的就是“库存怎么又多了/少了”。排查下来根因集中在四个地方。第一事件重复提交同一张发货单被接口重试落了两条这个靠 UNIQUE(ref_id) 解决注意幂等键要把单据号和动作序号都带上。第二事件被人工 DELETE运维图省事直接删行历史余额全乱了规矩只能是冲销事件禁止 DELETE。第三跨时区的时间边界事件记录的 occurred_at 我统一存 UTC业务查询时转本地时区否则 23 点的单会被算到第二天。第四库存单位不统一上游下单单据统计仓库按瓶出库换算漏了后来我在入库事件就把数量统一折算成基础单位。排查方法也有技巧。不要一上来翻所有流水先看这个资源当前余额和上一期快照的差值再倒推时间窗口内的事件列表逐条对比。因为 REA 的余额是可重算的所以定位到可疑事件后可以把它从事件流里“逻辑剔除”再算一遍余额如果和期望一致基本就锁定元凶了。4.2 派生凭证不平规则和事实脱节凭证不平的案例几乎都出在规则库和业务事实脱节。最典型的是部分退款业务上客户退货 3 箱但应收核销时如果直接用 -3×50 的事件去冲和原应收的匹配关系就丢了账上会出现负收入。正确做法是把“退货入库”和“应收冲销”当成两个独立事件且各带各的 ref_id金额用原数量乘原单价完全对称地冲减。我后来在规则库加了一条硬校验冲销事件的 ref_id 必须关联到被冲销事件没有关联的一律拒绝。另一个坑是含税价和不含税价。事件上记录的是成交单价但开票和收入确认要按不含税价如果事件里只存一个价派生规则没法区分。我最后在事件上加了 price_type 字段明确成交价是含税还是不含税派生时按规则换算。结论是事件字段要尽量保留业务原貌不要把“为了做账而算出来的中间量”覆盖掉原始事实。4.3 事件表膨胀与慢查询事件表只增不改一年下来几百万行很正常。我的处理是保留一张独立的余额快照表每天凌晨按资源加账套聚合一次当日 delta查询历史余额先取快照再补算当天增量事件明细只用于审计和追溯。这样报表查询从扫全表变成扫当天速度提升非常明显。注意快照表也是可重算的——任何时候都能从事件表全量重建这一点保证快照出问题时有退路。索引上除了 (resource_id, occurred_at) 外给 agent_id 也加一个索引应收账款的余额按客户维度统计时会用到。如果未来要按 pair_id 查事件对再建 pair_id 索引。索引别贪多事件表是写频繁的表索引太多会影响落库性能。4.4 踩坑速查表现象根因处理库存多了一笔接口重试导致事件重复落库事件表加 UNIQUE(ref_id)幂等键 单据号 动作序号期末余额突然和上期对不上有人 DELETE 了历史事件禁止直接删行统一用负向冲销事件凭证借贷差 0.01单价与合计金额舍入不一致单价 DECIMAL(18,4)凭证合计 DECIMAL(18,2)尾差按事件顺序分摊部分退款出现负收入直接对原应收做负数退货与冲销拆成两个独立事件并关联被冲销事件跨天统计差异事件时间用了业务时区occurred_at 统一存 UTC查询时再转业务时区5. 这套模型的扩展与适用边界5.1 和事件溯源的关系做过事件溯源的同学看到这里应该会心一笑event 表本身就是天然的事实源。rea 里的事件记录一旦落库就可以作为对外发布的消息源下游系统订阅事件做履约、风控、报表而不是反向去查业务单据。区别在于事件溯源通常关心状态的重建REA 更关心经济含义两者可以结合把 REA 事件作为领域事件的骨架带业务字段的通用事件做扩展既保证财务口径又保留业务灵活性。另外前后端如果都按“提交事件”而非“提交状态变更”的语义交互接口设计会清爽很多。比如“确认收货”这个动作前端提交的是“签收事件”后端只负责落一条事件并返回新余额而不是去 update 一个订单状态字段。整个系统的状态流转都变成对事件流的投影。5.2 什么时候不要用 REA我在项目里踩过最贵的一课是在一个其实很简单的 CRUD 项目中强行引入 REA。当时以为“面向未来”结果为了一个没有交易复杂度的系统维护资源、事件、规则三套维护入口反而拖慢交付。REA 解决的是“业务事实与财务解读分裂”的问题如果系统根本没有财务和库存账纯粹的信息登记用普通表更合适。高并发场景也要谨慎。秒杀场景下库存余额用 SUM 算会放大热点正确做法是先用缓存预扣再异步落 REA 事件。事件表只是事实层不等同于所有实时读都必须直接查它。成熟企业的合并报表、税务合规通常已经被稳定的财务系统覆盖重构核心总账风险极大。REA 更适合作为新业务模块的底座或者在拆分单体系统时对账务域重新建模而不是去替换一个已经稳定运行多年的总账核心。5.3 一点点个人体会rea 这个项目做完后我最大的感受不是“建模理论有多高级”而是“业务和财务终于说同一种语言了”。研发不用再维护两套口径产品讲“客户付款”的时候开发知道那是两个事件、两类资源、一套派生规则的事。这种一致性带来的效率比任何代码优化都明显。如果你想在自己系统里尝试我的建议是别一口吃成胖子先挑“库存 应收应付”这块最痛的场景把资源、事件、代理三张表建起来跑通一个销售-收款闭环剩下的业务类型慢慢加。边做边体会你自然知道哪些地方需要灵活、哪些地方值得坚持不可变。至少对我而言下一次再碰到财务和业务对不上的系统我会第一时间先画事件图而不是急着拆表。