商业策略成长型企业应盯紧的 12 个 KPI,以及一块真正有人看的数据看板
覆盖销售、运营和财务的十二个 KPI,每个都有公式、负责人和查看频率。附一整个月的算例、看板设计规则,以及某个数字变红时该怎么做。
夜间同步和纠缠的调用,会让库存和账目对不上。通过实例看看事件、事务性发件箱和幂等监听器,如何让一个订单可靠地更新库存、总账和积分。
Author
Anichur Rahaman

大促周末,一家拥有六家门店的零售商共收到 1,900 个订单。周一早上,财务负责人打开报表:库存表显示还剩 14 件夹克,可隔壁城市的门店周六就把最后一件卖掉了。账面更糟:收入由夜间批处理过账,这批任务在第 1,412 单时超时,有 488 个订单没进账。要等到周二对账,才会有人发现。
这个故事里没有人犯错。是系统让每个部门只能自己去打听订单的消息:靠轮询、靠导出,或者靠一个来得不是时候的调用。本文主张反过来:事情一发生,就把它作为一个事实只记录一次,让业务的其他部分各自对这个事实做出反应。这就是事件驱动架构。
这也是我职业生涯中花时间最多的一块系统设计,所以观点会比较鲜明。读完之后,你应该能分辨真正的事件驱动系统和营销话术,也知道该向供应商要什么。
多数业务软件一开始都是直接调用。结账完成,结账代码就去调用库存代码,再调用会计代码,再调用邮件代码。演示时没问题,但会在三个可以预见的地方出毛病。
常见的补救办法是夜间同步:凌晨 2 点导出订单,再导入别处。这样做只是把故障挪到没人盯着的时段,还让每个数字最多滞后 24 小时。于是团队开始做对账表去追差异,最后这张表成了真正的系统。
两个词承担了大部分工作,而把它们混为一谈,是设计上最常见的第一个错误。
命令(command)是请求:“下这个订单”“退这笔款”。它发给唯一的负责方,用现在时,并且可以被拒绝。事件(event)是事实:OrderPlaced、PaymentReceived、ParcelDelivered。它用过去时命名,因为已经发生,谁也无法拒绝。拥有这个事实的部分是生产者(producer),所有关心它的部分是监听器(listener),也叫消费者或订阅者。
生产者不知道谁在监听。全部好处都来自这一点。加积分,只需要给 ParcelDelivered 新写一个监听器。结账代码一行不动,也就不可能被改坏。
用一个示例会更直观。假设(示例,非真实数据)顾客购买两件 SKU 为 JKT-M 的夹克,每件 40.00(单位成本 22.00),另加运费 5.00 和税 8.00,合计 93.00,刷卡支付。之后三天里,这个订单产生三个事件。

每个事件都带一份很小的负载(payload),足够监听器直接行动,不必回头去问生产者。
| 事件 | 负载字段 |
|---|---|
| OrderPlaced | event_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 |
| PaymentReceived | event_id evt_5002, order_id 1042, payment_id 9001, amount 93.00, method card, occurred_at |
| ParcelDelivered | event_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。它之所以平衡,是因为监听器本来就是按“只过平衡分录”写的,而不是月底有人核对出来的。这里按送达确认收入;你们的会计政策可能不同,那是总账监听器自己要做的决定。
生产者这一侧有个陷阱。下单要做两次写入:把订单存进数据库,再把 OrderPlaced 发布到队列。这是两个不同的系统,没有哪个事务能同时覆盖它们。如果数据库已提交,进程却在发布前挂了,订单存在而事件不存在,库存永远不会知道。如果先发布、数据库后来回滚,其他部分就会对一个从未保存的订单做出反应。这就是双写问题(dual-write problem)。
解法是事务性发件箱(transactional outbox),Chris Richardson 在他的微服务模式目录中有详细描述。订单和它的事件在同一个数据库、同一个事务里写入,事件写进 outbox 表。要么两行都在,要么都不在。然后由一个独立的 relay 进程读取尚未发送的 outbox 行,发布到队列,并标记为已发送。

relay 也可能在发布之后、标记已发送之前崩溃,重启后它会把这个事件再发一遍。所以 outbox 给你的保证很精确:事件绝不会丢,但可能重复送达。下一个问题由此而来。
供应商喜欢承诺“恰好一次”投递。但在通过网络相连的不同机器之间,一般做不到:发送方收不到确认,就无从知道消息到没到,只能在重发和冒丢失的风险之间二选一。可靠的系统会选重发。支付服务商对此毫不讳言,比如 Stripe 的文档就要求你预期同一个 webhook 事件会收到不止一次,并按事件 ID 去重。
所以真正可用的保证是至少一次投递,加幂等处理。监听器幂等,指的是同一个事件处理两次,效果和处理一次相同。常用机制很小:一张 processed_events 表,在 (listener, event_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 后台,直接调用函数更简单也更好。需要立刻得到答案的步骤也一样,比如“这张卡有效吗?”这是有回复的命令,不是事件。
我用的门槛是:当一个动作有三个或更多相互独立的后果,分属不同团队或模块(库存、资金、消息、配送),事件就开始值回成本。
代价也要说实话。监听器在订单之后片刻才运行,所以结账后立刻读库存的页面,可能短暂显示旧数字。好的产品会在结账事务内部完成预留,并显示“处理中”而不是瞎猜。
想在真实系统上验证这些说法,可以看看像 StoreConsole 这样的自托管平台:它的库存、会计和积分模块,就是作为独立的监听器订阅同一批订单事件。
回到那位面对 1,900 个订单的财务负责人。每个订单都在销售的同一个事务里写下了自己的事件,所以有 1,900 行 outbox,一行都没丢。到第 1,412 单时,总账监听器超时了。第 1,412 到 1,900 单在队列里等待,退避重试,几分钟内全部过账。有两单因为税码错误失败了五次,进入死信队列,告警在周六下午就响了,而不是周二。
隔壁门店周六的那笔销售在发生的当下就预留了库存,所以那 14 件夹克确实是 14 件。周一的偏差检查,两栏都是零。对账表根本没打开过。
Anichur Rahaman 是一名软件架构师,也是 StoreConsole 的创建者。他为成长型企业设计电商与 ERP 系统,专注于事件驱动架构、数据完整性和自托管部署。
About the Author
Anichur Rahaman
Continue Reading