One self-hosted console to run your entire business — commerce, ERP, HRM, CRM & manufacturing

写给企业主的事件驱动架构:为什么订单应该自己通知库存和账本

夜间同步和纠缠的调用,会让库存和账目对不上。通过实例看看事件、事务性发件箱和幂等监听器,如何让一个订单可靠地更新库存、总账和积分。

Author

Anichur Rahaman

2 个月前11 min read3 views
写给企业主的事件驱动架构:为什么订单应该自己通知库存和账本

大促周末,一家拥有六家门店的零售商共收到 1,900 个订单。周一早上,财务负责人打开报表:库存表显示还剩 14 件夹克,可隔壁城市的门店周六就把最后一件卖掉了。账面更糟:收入由夜间批处理过账,这批任务在第 1,412 单时超时,有 488 个订单没进账。要等到周二对账,才会有人发现。

这个故事里没有人犯错。是系统让每个部门只能自己去打听订单的消息:靠轮询、靠导出,或者靠一个来得不是时候的调用。本文主张反过来:事情一发生,就把它作为一个事实只记录一次,让业务的其他部分各自对这个事实做出反应。这就是事件驱动架构。

这也是我职业生涯中花时间最多的一块系统设计,所以观点会比较鲜明。读完之后,你应该能分辨真正的事件驱动系统和营销话术,也知道该向供应商要什么。

为什么夜间同步和纠缠的调用总出问题

多数业务软件一开始都是直接调用。结账完成,结账代码就去调用库存代码,再调用会计代码,再调用邮件代码。演示时没问题,但会在三个可以预见的地方出毛病。

  • 最慢的一步拖住了顾客。邮件服务要 8 秒,买家就得等 8 秒;邮件服务宕机,订单干脆失败。
  • 每个新需求都要改老代码。想加积分,就得打开公司里最危险的文件,也就是结账,再添一个调用。
  • 做到一半的事没有人负责。库存扣了,会计失败了。没有任何记录说明第二步还欠着。

常见的补救办法是夜间同步:凌晨 2 点导出订单,再导入别处。这样做只是把故障挪到没人盯着的时段,还让每个数字最多滞后 24 小时。于是团队开始做对账表去追差异,最后这张表成了真正的系统。

命令与事件:必须分清的两个词

两个词承担了大部分工作,而把它们混为一谈,是设计上最常见的第一个错误。

命令(command)是请求:“下这个订单”“退这笔款”。它发给唯一的负责方,用现在时,并且可以被拒绝。事件(event)是事实:OrderPlaced、PaymentReceived、ParcelDelivered。它用过去时命名,因为已经发生,谁也无法拒绝。拥有这个事实的部分是生产者(producer),所有关心它的部分是监听器(listener),也叫消费者或订阅者。

生产者不知道谁在监听。全部好处都来自这一点。加积分,只需要给 ParcelDelivered 新写一个监听器。结账代码一行不动,也就不可能被改坏。

一个订单,三个事件,五个监听器

用一个示例会更直观。假设(示例,非真实数据)顾客购买两件 SKU 为 JKT-M 的夹克,每件 40.00(单位成本 22.00),另加运费 5.00 和税 8.00,合计 93.00,刷卡支付。之后三天里,这个订单产生三个事件。

一个 OrderPlaced 事件分发给库存、总账、积分、通知和配送监听器的示意图
一个事实,五个互相独立的反应。结账从不直接调用其中任何一个。

每个事件都带一份很小的负载(payload),足够监听器直接行动,不必回头去问生产者。

事件负载字段
OrderPlacedevent_id evt_5001, order_id 1042, customer_id 77, lines [JKT-M, qty 2, price 40.00, cost 22.00], shipping 5.00, tax 8.00, total 93.00, USD, location WH-1, occurred_at
PaymentReceivedevent_id evt_5002, order_id 1042, payment_id 9001, amount 93.00, method card, occurred_at
ParcelDeliveredevent_id evt_5003, order_id 1042, delivery_id 311, delivered_at, lines [JKT-M, qty 2]

接下来是老板真正关心的:事后留下了哪些行,是谁写的。

事件监听器写入的行
OrderPlaced库存stock_movements: JKT-M, WH-1, type reserve, qty 2, ref order 1042。在手库存仍为 48,预留 +2,可售 46
OrderPlaced通知notifications: 向 customer 77 发送订单 1042 的确认,status queued
PaymentReceived总账借 Card clearing 93.00;贷 Customer deposits 93.00
ParcelDelivered库存stock_movements: type sale, qty -2,释放预留。在手库存 46
ParcelDelivered总账借 Customer deposits 93.00;贷 Sales 80.00、Shipping income 5.00、Tax payable 8.00。借 Cost of goods sold 44.00;贷 Inventory 44.00
ParcelDelivered积分loyalty_entries: customer 77, +80 分(商品金额每 1 个货币单位计 1 分), ref order 1042
ParcelDelivered通知notifications: 送达通知和评价邀请,status queued

验算一下:送达分录借方 93.00 + 44.00 = 137.00,贷方 80.00 + 5.00 + 8.00 + 44.00 = 137.00。它之所以平衡,是因为监听器本来就是按“只过平衡分录”写的,而不是月底有人核对出来的。这里按送达确认收入;你们的会计政策可能不同,那是总账监听器自己要做的决定。

发件箱(outbox):事件为什么不会丢

生产者这一侧有个陷阱。下单要做两次写入:把订单存进数据库,再把 OrderPlaced 发布到队列。这是两个不同的系统,没有哪个事务能同时覆盖它们。如果数据库已提交,进程却在发布前挂了,订单存在而事件不存在,库存永远不会知道。如果先发布、数据库后来回滚,其他部分就会对一个从未保存的订单做出反应。这就是双写问题(dual-write problem)。

解法是事务性发件箱(transactional outbox),Chris Richardson 在他的微服务模式目录中有详细描述。订单和它的事件在同一个数据库、同一个事务里写入,事件写进 outbox 表。要么两行都在,要么都不在。然后由一个独立的 relay 进程读取尚未发送的 outbox 行,发布到队列,并标记为已发送。

outbox 模式示意图:一个数据库事务写入订单和 outbox 行,relay 发布到队列,幂等监听器消费
一个事务,两行数据。第二行由 relay 送进队列,送几次都行。

relay 也可能在发布之后、标记已发送之前崩溃,重启后它会把这个事件再发一遍。所以 outbox 给你的保证很精确:事件绝不会丢,但可能重复送达。下一个问题由此而来。

“恰好一次”其实是至少一次,加上幂等的监听器

供应商喜欢承诺“恰好一次”投递。但在通过网络相连的不同机器之间,一般做不到:发送方收不到确认,就无从知道消息到没到,只能在重发和冒丢失的风险之间二选一。可靠的系统会选重发。支付服务商对此毫不讳言,比如 Stripe 的文档就要求你预期同一个 webhook 事件会收到不止一次,并按事件 ID 去重。

所以真正可用的保证是至少一次投递,加幂等处理。监听器幂等,指的是同一个事件处理两次,效果和处理一次相同。常用机制很小:一张 processed_events 表,在 (listener, event_id) 上建唯一键。

幂等监听器流程图:事件到达,是否已处理,跳过或应用变更,记录事件 ID,退避重试,死信队列并告警
监听器对每个事件的处理方式,包括它已经见过的那个。

两个细节决定这套做法能不能成。第一,“应用变更”和“记录事件 ID”必须在同一个数据库事务里提交。先应用、再另起一步记录,中间一旦崩溃,重试时就会重复过账。第二,唯一键必须让检查具备原子性,这样同一事件的两份副本同时到达时,不会双双通过。

回到示例:如果 ParcelDelivered 到了两次,总账监听器第二次发现 evt_5003 已经记录,直接跳过。分录不会过两次,80 积分也不会变成 160。

顺序、重试,以及白送的历史记录

顺序

不同订单的事件,以什么顺序处理都行。同一订单的事件则不行:RefundIssued 先于 PaymentReceived 处理毫无意义。两道防线要配合使用。按订单 ID 路由事件,让同一订单的事件依次走同一条通道;再给每个事件加上按订单递增的序号,让监听器能识别过旧的事件,把它搁置或忽略。

重试与死信队列

监听器失败时,比如数据库忙,或者快递接口宕机,事件会以递增的间隔回去重试,例如 10 秒、1 分钟、5 分钟、30 分钟、2 小时。退避很重要:立刻重试,会把一次短暂故障变成自己制造的过载。达到固定次数后,比如五次,事件进入死信队列(dead-letter queue),并通知相关的人。死信看得见,也找得回。对比一下失败的夜间导出,那是直接消失了。

历史记录

业务里的每个变化都是带时间、ID 和负载的事件,审计轨迹不用另外建。“这位顾客为什么有 80 积分?”答案现成:ParcelDelivered evt_5003,由积分监听器在某个时刻处理。事件保留一段规定的时间;监听器有 bug 时,修好之后把受影响的事件重放(replay)进去即可。幂等让重放是安全的。

什么时候不该用

事件驱动会增加活动部件:队列、relay、工作进程、监控,还有按最终一致性思考的习惯。对于宣传型网站、单人使用的工具,或者只更新一张表、由一个页面读取的普通 CRUD 后台,直接调用函数更简单也更好。需要立刻得到答案的步骤也一样,比如“这张卡有效吗?”这是有回复的命令,不是事件。

我用的门槛是:当一个动作有三个或更多相互独立的后果,分属不同团队或模块(库存、资金、消息、配送),事件就开始值回成本。

代价也要说实话。监听器在订单之后片刻才运行,所以结账后立刻读库存的页面,可能短暂显示旧数字。好的产品会在结账事务内部完成预留,并显示“处理中”而不是瞎猜。

如何落地,如何确认它在工作

步骤

  1. 列出你的业务本来就在谈论的事实:订单已下、款项已收、包裹已送达、退货已批准、库存已盘点。每一个都用过去时命名。
  2. 为每个事件定义负载,字段要足够多,让监听器不必回头查询,并给每个事件一个唯一 ID 和时间戳。
  3. 在记录变更的同一个事务里,把事件写入 outbox 表。
  4. 运行 relay,把 outbox 行发布到队列并标记为已发送。
  5. 把每个监听器做成幂等的,已处理事件的记录与它自己的写入放在同一个事务里。
  6. 在第一个真实订单之前,配好退避重试、死信队列以及对它的告警。
  7. 逐个迁移消费者:先库存,再总账,最后是消息和积分。夜间任务放到最后下线,且要在数字连续一周对得上之后。

可以问供应商的问题

  • 事件是和业务变更写在同一个事务里,还是事后才发布?
  • 同一个事件送达两次会怎样?请对方展示去重用的键。
  • 失败的事件去哪了,谁会收到告警,我能重放它们吗?
  • 我能看到单个订单的事件历史吗,包括时间戳和每个事件由哪个处理器处理?

想在真实系统上验证这些说法,可以看看像 StoreConsole 这样的自托管平台:它的库存、会计和积分模块,就是作为独立的监听器订阅同一批订单事件。

该监控什么

  • Outbox 延迟:最老的未发送行的年龄。几秒钟算健康。
  • 队列深度和处理耗时,按监听器分别看。
  • 死信数量:应接近于零,绝不能悄悄增长。
  • 重复跳过率:大于零,恰好证明去重在起作用。
  • 每日偏差检查:库存账的数量对比订单推算的数量,总账收入对比订单收入。两处差额都应为零。

同一个周一,重建之后

回到那位面对 1,900 个订单的财务负责人。每个订单都在销售的同一个事务里写下了自己的事件,所以有 1,900 行 outbox,一行都没丢。到第 1,412 单时,总账监听器超时了。第 1,412 到 1,900 单在队列里等待,退避重试,几分钟内全部过账。有两单因为税码错误失败了五次,进入死信队列,告警在周六下午就响了,而不是周二。

隔壁门店周六的那笔销售在发生的当下就预留了库存,所以那 14 件夹克确实是 14 件。周一的偏差检查,两栏都是零。对账表根本没打开过。

核心要点

  • 命令是请求,可以被拒绝;事件是已经发生的事实。生产者只负责宣布事件,不知道谁在监听。
  • 把事件和业务变更写在同一个事务里(outbox),它就不会丢。
  • “恰好一次”投递不是现实的承诺。要做的是至少一次投递,加幂等的监听器。
  • 已处理事件的 ID,要和监听器的写入放在同一个事务里记录。
  • 退避重试,之后进死信队列并告警。看得见的失败,胜过悄无声息的缺口。
  • 一个动作有三个或更多独立后果时再用;简单的 CRUD 就不必了。

Anichur Rahaman 是一名软件架构师,也是 StoreConsole 的创建者。他为成长型企业设计电商与 ERP 系统,专注于事件驱动架构、数据完整性和自托管部署。

About the Author

Anichur Rahaman

Continue Reading