不能在 HTTP handler 里直接发消息做补偿,因为会阻塞主链路导致超时与雪崩;应采用本地事务表+轮询投递实现原子性,配合幂等消费与定期对账兜底。

为什么不能在 HTTP handler 里直接发消息做补偿
因为会卡死主链路——用户下单接口超时、下游服务雪崩,不是“修复不一致”,而是先搞垮了正常流量。你本想扣库存,结果订单服务挂了三次重试,库存服务却收到五条重复扣减指令。
-
http.Handler是同步阻塞的,调用amqp.Publish或kafka.Producer.WriteMessages一旦网络抖动或 MQ 拒绝连接,整个请求就 hang 住 - 用
go func() { ... }()起 goroutine 看似异步,但进程崩溃时未发送的消息直接丢失,无持久化、无重试、无追踪 -
time.AfterFunc更危险:它依赖进程存活,容器重启、OOM kill 后,所有待触发的补偿全丢
本地事务表 + 轮询投递才是 Go 里最稳的发件箱
核心思想是:把“发消息”也变成数据库里的一行记录,和业务数据共处一个事务,确保原子性。
- 建表
outbox_events,字段至少含:id、topic、payload(JSON)、processed(bool)、created_at - 在订单创建事务中,用同一个
*sql.Tx插入订单记录 + 插入outbox_events记录,状态为false - 另起独立 goroutine 或定时 job(如
asynq的 cron task),执行:SELECT * FROM outbox_events WHERE processed = false LIMIT 100,逐条发送并UPDATE SET processed = true - 轮询间隔建议用指数退避(
time.Sleep(time.Second * 1)→*2→*4),别用固定 100ms,否则 DB 压力陡增
消费者必须幂等,且不能只靠 status 字段判重
MQ 天然不保证“恰好一次”,Kafka 可能重复投递,RabbitMQ 手动 nack 会重入,网络分区也可能触发重试。光靠 UPDATE inventory SET stock = stock - 1 WHERE order_id = ? AND status = 'pending' 不够。
- 加唯一约束:比如在
inventory_adjustments表中对(order_id, adjustment_type)加UNIQUE索引,插入失败即跳过 - 查状态再处理:扣库存前先
SELECT COUNT(*) FROM inventory_adjustments WHERE order_id = ? AND type = 'deduct' AND status = 'done',有则直接 return - 避免逻辑错位:比如恢复库存时写
WHERE status = 'frozen',而不是WHERE status != 'restored',后者可能误放行已修复的记录
对账不是备选方案,是最终兜底的强制动作
再严谨的流程也会漏:DB 主从延迟导致轮询读到旧数据、MQ 分区丢失某批次消息、补偿任务被误删……这些不会报错,只会静默不一致。
立即学习“go语言免费学习笔记(深入)”;
- 每天凌晨跑对账 job,比对订单服务的
orders和库存服务的inventory_logs,按order_id关联 - 发现差异后,不直接修 DB,而是往死信队列发一条
repair:inventory_mismatch事件,由人工审核后再触发修复 - 对账结果必须写入监控系统(如 Prometheus + Grafana),异常率 > 0.001% 就告警——这个数字不是拍的,是线上压测得出的基线
真正难的不是写通一条补偿链路,而是让每条消息可追溯、每个状态可验证、每次失败可感知。没人会盯着日志看是否少了一条 InventoryDeductedEvent,所以机制必须比人更可靠。


















