Broker(负责保存和转发消息的服务端)投递消息后,需要消费者返回 acknowledgement(完成确认,简称 ack)。如果消费者把积分写入数据库后、尚未来得及 ack 就崩溃,Broker 只知道“没有收到完成确认”,于是重新投递。第一次业务其实已经成功,第二次投递仍是可靠系统的正常行为。
最应记住:幂等不是阻止 Broker 重投,而是让同一业务标识再次到达时不重复改变结果。常见误读是用进程内内存 Set 去重,却忽略重启和多实例。
MQ(Message Queue,消息队列)是在生产者和消费者之间暂存、转发消息的系统。幂等是同一个业务操作执行一次或重复执行多次,最终业务结果都相同;重试是暂时失败后再次尝试;死信是被拒绝、过期或超过投递限制后转入专门出口的消息。三者共同把“至少一次交付”变成可运营的业务流程。
Broker(负责保存和转发消息的服务端)投递消息后,需要消费者返回 acknowledgement(完成确认,简称 ack)。如果消费者把积分写入数据库后、尚未来得及 ack 就崩溃,Broker 只知道“没有收到完成确认”,于是重新投递。第一次业务其实已经成功,第二次投递仍是可靠系统的正常行为。
| 手段 | 适合 | 关键点 | 代价 |
|---|---|---|---|
| 数据库唯一约束 | 消息 ID、订单号唯一 | 插入重复时由最终存储拒绝 | 要保留去重记录并清理 |
| 幂等消费表 | 通用事件消费 | 消费记录与业务修改同一事务 | 表增长、跨库困难 |
| 业务状态机 | 订单 CREATED→PAID→SHIPPED | 只允许合法状态迁移 | 要设计乱序与终态 |
| 条件更新/版本号 | 余额版本、库存状态 | WHERE 中携带期望状态 | 冲突要重读或丢弃 |
写去重表成功后,积分更新失败。重试看到“已处理”直接跳过,业务永久缺失。
积分更新成功后,去重表写入失败。重试再次增加积分。
BEGIN;
INSERT INTO processed_messages(consumer, message_id)
VALUES ('points', 'msg-88'); -- UNIQUE(consumer, message_id)
INSERT INTO points_ledger(user_id, source_id, delta)
VALUES (7, 'msg-88', 10); -- 业务变化
COMMIT;
-- 唯一键冲突表示此前事务已经整体成功,可安全 ack。
| 错误 | 例子 | 动作 |
|---|---|---|
| 暂时失败 | 数据库瞬时超时、下游限流 | 指数退避加随机抖动,有上限 |
| 永久失败 | 字段缺失、业务规则拒绝 | 不要原队列无限重试,进入死信并告警 |
| 结果未知 | 调用下游超时,不知道是否成功 | 先按幂等键查询状态,再决定重试 |
指数退避表示每次等待逐步变长,例如 1 秒、2 秒、4 秒、8 秒;随机抖动是在此基础上增加随机量,避免大量消费者同一时刻再次冲击下游。
DLQ(Dead Letter Queue,死信队列)是保存当前正常消费者无法继续处理消息的专用队列。TTL(Time To Live,存活时间)规定消息还能在队列保留多久;以 RabbitMQ 为例,消息被拒绝且不重新入队、TTL 到期、队列超长或多数副本队列超过投递限制时,都可能进入死信流程。
订单事件按 CREATED、PAID、CANCELED 产生,却可能以 PAID、CREATED、CANCELED 到达。每条消息单独不重复,仍可能把状态倒退。
| 方案 | 适合 | 注意 |
|---|---|---|
| 同一业务键固定分区/队列 | 要求每个订单内部有序 | 单个热点键可能限制吞吐 |
| 状态机条件迁移 | 有明确合法状态 | 拒绝 PAID→CREATED |
| 业务版本号 | 状态更新可比较新旧 | 只接受 version 更大的事件 |
| 缓冲等待缺失事件 | 允许短暂等待 | 需要超时与补偿出口 |
func Consume(msg Message) {
err := db.WithTransaction(func(tx Tx) error {
if tx.Processed("points", msg.BusinessID) { return nil }
if err := tx.ApplyPoints(msg); err != nil { return err }
return tx.MarkProcessed("points", msg.BusinessID)
})
switch classify(err) {
case Success:
msg.Ack()
case Temporary:
msg.RetryWithBackoff()
case Permanent:
msg.RejectWithoutRequeue() // 由策略送入 DLQ
}
}
积分、通知、订单状态同步、Webhook 等至少一次投递流程需要消费幂等。天然覆盖写入和可安全替换的结果可用业务键直接幂等;累加、扣款等非幂等动作应使用账本、唯一约束或条件状态迁移。