我不会起名字322· 后端 / 算法 / 数据库
📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL
文章目录
- 死信队列什么时候该用:3 类必须进 DLQ 的消息与 2 种重试策略
- 一、先说一个反面案例
- 二、3 类必须进 DLQ 的消息
- 第 1 类:格式错误 / 无法解析的消息(poison message)
- 第 2 类:业务上不可能成功的消息
- 第 3 类:重试次数耗尽的"真失败"
- 三、不该进 DLQ 的:瞬态失败
- 四、2 种重试策略
- 策略 1:延迟队列(分级重试)
- 策略 2:应用层重试 + 自建重试表
- 两种策略怎么选
- 五、DLQ 必须做的三件事
- 1. 监控 DLQ 深度
- 2. DLQ 消息要带上下文
- 3. 要有重放工具
- 六、带走物
死信队列什么时候该用:3 类必须进 DLQ 的消息与 2 种重试策略
一、先说一个反面案例
很多队伍的做法是:所有消费失败的消息都进重试队列,重试 N 次后进死信队列,然后……就没有然后了。
死信队列里堆了几万条消息没人看,等于把问题藏起来了。这比不配死信队列更糟——你以为有兜底,其实没有。
死信队列的价值不在于"接收失败消息",而在于:把"不可自动恢复"的消息从主链路里隔离出去,让主链路不被拖垮,同时让这部分消息可被人工/专项处理。
所以关键问题是:什么样的消息该进 DLQ?
二、3 类必须进 DLQ 的消息
第 1 类:格式错误 / 无法解析的消息(poison message)
这类消息重试一百万次也不会成功:
- JSON 反序列化失败;
- 必填字段缺失;
- 版本号不认识(生产者升级了但你还没兼容)。
funchandle(msg[]byte)error{varevt Eventiferr:=json.Unmarshal(msg,&evt);err!=nil{// 解析失败 → 重试毫无意义,直接进 DLQreturnErrPoison// 标记为不可重试}...}这类消息最危险的地方在于它会阻塞主链路:如果它一直重试,消费位点就一直卡在那里(对 RocketMQ 尤其明显),后面几万条正常消息全部被堵住。
第 2 类:业务上不可能成功的消息
比如下单消息里的商品 ID 在数据库里根本不存在、用户已被注销、金额是负数。
这类和格式错误的区别是:消息本身是合法的,但业务状态决定了它不可能成功。重试同样没有意义。
判断依据:失败原因是"确定性"的还是"瞬态"的。
| 失败原因 | 性质 | 处理 |
|---|---|---|
| JSON 解析失败 | 确定性 | 直接 DLQ |
| 商品不存在 | 确定性 | 直接 DLQ + 告警 |
| 数据库连接超时 | 瞬态 | 重试 |
| 下游返回 503 | 瞬态 | 重试 |
| 限流 | 瞬态 | 重试(带退避) |
| 参数校验失败 | 确定性 | 直接 DLQ |
代码里必须能区分这两类。最实用的做法是用错误类型而不是错误字符串:
// 定义可重试与不可重试两类错误typePermanentErrorstruct{Errerror}// 不可重试 → DLQfunc(e*PermanentError)Error()string{returne.Err.Error()}typeTransientErrorstruct{Errerror}// 可重试 → 重试队列func(e*TransientError)Error()string{returne.Err.Error()}funchandle(msg Msg)error{evt,err:=parse(msg.Body)iferr!=nil{return&PermanentError{err}// 解析失败,永久错误}iferr:=doBusiness(evt);err!=nil{iferrors.Is(err,ErrDBTimeout){return&TransientError{err}// 数据库超时,可重试}return&PermanentError{err}}returnnil}第 3 类:重试次数耗尽的"真失败"
前面两类是"一眼就知道没救",这一类则是"试过了才知道没救"。
比如:调用下游支付接口,重试 5 次全部超时。这时候不能再重试了——继续重试只会加剧下游压力,而且大概率还是失败。
策略是:重试 N 次后进 DLQ,但 DLQ 必须带上下文(重试次数、每次的错误、原始消息),否则后面没法排查。
三、不该进 DLQ 的:瞬态失败
这是最容易犯的错:把"下游抖动"当成"永久失败"扔进 DLQ。
后果:一次 3 分钟的数据库抖动,几千条完全正常的消息被扔进死信队列,需要人工捞回来重放。这是自己给自己制造事故。
正确的做法是重试 + 退避,只有重试耗尽才进 DLQ。
判断标准很简单:这个失败,过一会儿再试会不会成功?
- 会 → 重试;
- 不会 → DLQ。
四、2 种重试策略
策略 1:延迟队列(分级重试)
RocketMQ 的做法最典型:重试消息进入不同延迟级别的队列。
第 1 次失败 → 10s 后重试 第 2 次 → 30s 第 3 次 → 1min 第 4 次 → 2min ... 第 16 次 → 2h 16 次全失败 → %DLQ%<group>优点:实现简单,MQ 原生支持,而且重试消息不影响主 topic 的消费位点(它们在重试 topic 里)。
缺点:延迟级别是固定的,不能自定义;重试次数上限也是固定的。
策略 2:应用层重试 + 自建重试表
对于需要精细控制的场景(比如"支付类消息重试 5 次,通知类重试 3 次"),自建更灵活:
CREATETABLEretry_task(idBIGINTPRIMARYKEY,biz_idVARCHAR(64),payloadBLOB,attemptINTDEFAULT0,next_atDATETIME,-- 下次重试时间last_errorTEXT,statusTINYINT-- 0待重试 1成功 2进DLQ);funcscheduleRetry(task*RetryTask){task.Attempt++iftask.Attempt>maxAttempts{task.Status=2// 进 DLQreturn}// 指数退避 + 抖动backoff:=time.Duration(1<<uint(task.Attempt))*time.Second jitter:=time.Duration(rand.Int63n(int64(backoff/2)))task.NextAt=time.Now().Add(backoff+jitter)task.Status=0}优点:可以按业务类型定制策略、可以看到每次失败的原因、可以人工干预(改 status 重新投递)。
缺点:要自己实现,且重试任务的调度本身要有高可用保障。
两种策略怎么选
| 场景 | 推荐 |
|---|---|
| 简单业务、失败即重试 | MQ 原生延迟重试 |
| 需要区分业务类型的重试策略 | 自建重试表 |
| 需要看到失败原因做运营处理 | 自建重试表 |
| 重试耗时要人工介入 | 两者都要有,DLQ 是终点 |
五、DLQ 必须做的三件事
配置了 DLQ 但不管它,等于没配。三件事必须做:
1. 监控 DLQ 深度
# 告警规则 rabbitmq_queue_messages{queue=~".*DLQ.*"} > 0DLQ 里有消息就应该是 P2 以上告警(除非你明确接受某些业务失败)。
2. DLQ 消息要带上下文
只存原始消息是不够的,后面排查时会问:
{"original":{...},"attempts":5,"errors":[{"at":"2026-10-13T00:01:00","err":"dial tcp 10.0.1.5:3306: i/o timeout"},{"at":"2026-10-13T00:01:30","err":"dial tcp 10.0.1.5:3306: i/o timeout"}],"consumerGroup":"order-consumer","firstFailedAt":"2026-10-13T00:01:00"}有了errors数组,一眼就能看出"5 次全是数据库超时" → 那是下游故障,不是消息问题,可以批量重放。
3. 要有重放工具
DLQ 里的消息不是终点,要能捞回来重放:
# 从 DLQ 读出,写回主 topic# 关键:重放前先确认根因已修复,否则只是再失败一次重放的前提是根因已经修好。没修就重放,等于把 DLQ 搬回主队列再失败一次。
六、带走物
| 消息类型 | 处理 |
|---|---|
| 格式错误 / 无法解析 | 直接 DLQ,不要重试(会阻塞主链路) |
| 业务上不可能成功 | 直接 DLQ+ 告警 |
| 瞬态失败(超时、503、限流) | 重试 + 退避,耗尽后才 DLQ |
| 重试耗尽 | 进 DLQ,但必须带 attempts + errors 上下文 |
配套三件事:监控 DLQ 深度并告警 / DLQ 消息带上下文 / 有重放工具且根因修好后再重放。
一句话结论:死信队列不是垃圾桶,它是"不可自动恢复消息的人工处理入口"——判断标准只有一条:再试一次会不会成功。会就重试,不会才进 DLQ。