消息可靠性:确认机制、重试策略、死信队列、消息去重
各位同学,今天我们来聊聊消息队列里最让人头疼的问题——消息可靠性。
说实话,我在做嵌入式系统这十几年里,被消息丢失坑过不止一次。有一次在工业控制项目里,传感器上报的数据莫名其妙少了几条,结果导致执行器没动作,差点酿成事故。从那以后,我对消息可靠性这块就特别较真。
消息可靠性说白了就四个字:不丢不重。但要做到,得从确认机制、重试策略、死信队列、消息去重这四个维度下手。咱们一个一个说。
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 + 时间戳 + 序列号”组合生成,基本不会重复。
知识体系总览
下面这张图把消息可靠性的四个维度串起来了:
避坑指南
最后,分享几个我踩过的坑:
- 不要依赖网络层确认:TCP虽然可靠,但只能保证数据到了网卡,不能保证应用层处理了。应用层ACK才是真确认。
- 去重表要加锁:多线程环境下,去重表的读写要加互斥锁,否则并发时可能重复处理。
- 死信队列要监控:我习惯给死信队列加告警,一旦有消息进入就通知人工处理。否则死信堆成山,没人知道。
- 重试要考虑业务场景:有些消息不能重试(比如“删除”操作),重试可能导致数据不一致。这种情况我直接进死信队列。
嗯,消息可靠性这块就聊这么多。说白了就是:确认保底、重试保活、死信保安全、去重保一致。这四个机制配合好,你的消息队列就稳了。