消息可靠性:确认机制、重试策略、死信队列、消息去重

各位同学,今天我们来聊聊消息队列里最让人头疼的问题——消息可靠性

说实话,我在做嵌入式系统这十几年里,被消息丢失坑过不止一次。有一次在工业控制项目里,传感器上报的数据莫名其妙少了几条,结果导致执行器没动作,差点酿成事故。从那以后,我对消息可靠性这块就特别较真。

消息可靠性说白了就四个字:不丢不重。但要做到,得从确认机制、重试策略、死信队列、消息去重这四个维度下手。咱们一个一个说。

1. 确认机制:消息到底到没到?

消息发出去,对方到底收没收到?这是最基础的问题。我习惯用ACK(确认应答)来解决。

简单说就是:发送方发消息,接收方收到后回一个“我收到了”的信号。如果发送方没等到ACK,就知道消息可能丢了。

核心思路:发送方等待ACK,超时未收到则触发重试。

这里有个细节——ACK的类型。我见过不少新手把ACK和NACK搞混。ACK是“收到了,没问题”,NACK是“收到了,但数据有问题”。在嵌入式系统里,我建议区分对待:

  • ACK:消息正常消费,可以删除
  • NACK:消息有问题,需要重新发送或进入死信队列
  • 超时无响应:网络或接收方挂了,需要重试

我的经验:在低功耗设备上,ACK的发送时机要把握好。我一般让接收方先处理完消息再发ACK,而不是收到就发。为什么?因为如果收到就发ACK,然后处理时崩溃了,消息就丢了。这叫“至少一次交付”的保障。

2. 重试策略:失败了怎么办?

消息没确认,就得重试。但怎么重试?这里坑很多。

我曾经见过一个项目,重试间隔设成固定的1秒,结果网络一抖,所有设备同时重试,直接把服务器打崩了。这就是典型的重试风暴

我推荐用指数退避 + 随机抖动

// 重试策略示例
typedef struct {
    uint32_t base_interval_ms;  // 基础间隔,比如100ms
    uint32_t max_interval_ms;   // 最大间隔,比如10秒
    uint8_t max_retries;        // 最大重试次数
    uint8_t retry_count;        // 当前重试次数
} retry_policy_t;

uint32_t calc_retry_delay(retry_policy_t *policy) {
    // 指数退避:2^retry_count * base_interval
    uint32_t delay = policy->base_interval_ms << policy->retry_count;
    
    // 限制最大值
    if (delay > policy->max_interval_ms) {
        delay = policy->max_interval_ms;
    }
    
    // 加入随机抖动,避免同时重试
    uint32_t jitter = rand() % (delay / 4);  // 抖动范围0~25%
    return delay + jitter;
}

这个策略的好处是:刚开始重试间隔短,快速恢复;后面间隔指数增长,避免压垮系统。加上随机抖动,大家的重试时间错开,系统就稳了。

注意:重试次数一定要有限制。我一般设3~5次,超过就进死信队列。否则一条坏消息能重试到天荒地老,浪费资源。

3. 死信队列:处理不了的消息去哪了?

有些消息就是处理不了。比如格式错误、业务逻辑不满足、重试次数用尽。这些消息不能一直占着队列,也不能直接丢掉(万一以后有用呢?)。

我的做法是:扔进死信队列

死信队列就是个“垃圾桶”,专门存放那些无法正常处理的消息。但它不是真垃圾桶——里面的消息可以人工排查、重新投递。

场景 处理方式
消息格式错误 直接进死信队列,记录错误原因
业务逻辑不满足 进死信队列,等待人工处理
重试次数用尽 进死信队列,避免无限重试
消息过期 进死信队列,或直接丢弃(看业务需求)

我的习惯:死信队列里的消息要带上原始元数据,比如发送时间、重试次数、失败原因。这样排查问题时,一眼就能看出问题在哪。

4. 消息去重:同一消息收到两次怎么办?

重试机制有个副作用——消息可能被重复投递。比如接收方处理完了,但ACK在路上丢了,发送方以为没收到,又发一次。

去重的核心思路是:幂等性。即同一个消息处理多次,效果跟处理一次一样。

我常用的方法是消息ID去重

// 消息去重示例
typedef struct {
    uint32_t msg_id;        // 全局唯一消息ID
    uint8_t  processed;     // 是否已处理
    uint32_t timestamp;     // 处理时间戳
} dedup_entry_t;

// 去重表(用哈希表或红黑树实现)
dedup_entry_t dedup_table[1024];

int is_duplicate(uint32_t msg_id) {
    // 查找消息ID是否已存在
    for (int i = 0; i < 1024; i++) {
        if (dedup_table[i].msg_id == msg_id) {
            return 1;  // 已处理过,去重
        }
    }
    return 0;  // 新消息
}

void mark_processed(uint32_t msg_id) {
    // 记录已处理的消息ID
    int slot = msg_id % 1024;
    dedup_table[slot].msg_id = msg_id;
    dedup_table[slot].processed = 1;
    dedup_table[slot].timestamp = get_tick();
}

这里要注意:去重表不能无限增长。我一般设一个过期时间,比如30分钟前的记录就删掉。因为消息重试不会超过30分钟,太老的记录留着也没用。

关键点:消息ID必须全局唯一。我习惯用“设备ID + 时间戳 + 序列号”组合生成,基本不会重复。

知识体系总览

下面这张图把消息可靠性的四个维度串起来了:

消息可靠性架构 发送方 消息队列 接收方 发送消息 投递消息 ACK确认 重试策略 死信队列 消息去重 核心流程: 1. 发送方发消息到队列,等待ACK 2. 接收方消费后回复ACK,同时去重 3. 超时未ACK → 发送方重试(指数退避) 4. 重试次数用尽 → 消息进入死信队列

避坑指南

最后,分享几个我踩过的坑:

  • 不要依赖网络层确认:TCP虽然可靠,但只能保证数据到了网卡,不能保证应用层处理了。应用层ACK才是真确认。
  • 去重表要加锁:多线程环境下,去重表的读写要加互斥锁,否则并发时可能重复处理。
  • 死信队列要监控:我习惯给死信队列加告警,一旦有消息进入就通知人工处理。否则死信堆成山,没人知道。
  • 重试要考虑业务场景:有些消息不能重试(比如“删除”操作),重试可能导致数据不一致。这种情况我直接进死信队列。

嗯,消息可靠性这块就聊这么多。说白了就是:确认保底、重试保活、死信保安全、去重保一致。这四个机制配合好,你的消息队列就稳了。

公众号:蓝海资料掘金营,微信deep3321