MQ(消息队列)在分布式系统里几乎是标配,但聊到“消息不丢失”这个话题,很多同学的第一反应是:我们配置了高可用,应该不会丢吧。我在过去几年排查过的丢消息事故里,至少有一半的根因都不是Broker直接崩了,而是生产者、消费者或配置细节上一环没锁住,消息就悄无声息地没了。这篇文章我就从一条消息从产生到被业务真正处理这条链路出发,把生产端、Broker存储端、消费端以及延迟消息这类特殊场景全部拆开讲清楚。适合正在用或打算用MQ的开发和运维同学,尤其是已经遇到“莫名的丢消息”却不知道从哪下手的人。
1. 先弄明白一条消息从哪丢到哪:端到端故障模型
1.1 三个环节一次都不能断
一条正经消息从产生到被消费,经过三段:
- 生产端:业务应用把消息发到MQ的Broker
- Broker存储:消息在Broker持久化并同步副本
- 消费端:消费者拉取/推送消息,执行业务逻辑,提交进度
用快递类比:你在电商平台下单(生产),包裹在中转中心(Broker)暂存,最后由快递员派送给你并签收(消费)。任何一环出了问题,包裹就收不到了。MQ的可靠性也一样,必须全链路闭环,哪怕只有生产端一个环节“默认成功”,整体都会丢。
这就是为什么我经常说,不要试图用一个“巨大的配置项”解决所有问题。Kafka设置acks=all不代表消费端一定不丢,因为消费者可能自动提交offset;RocketMQ开了同步刷盘也不代表生产者发了就万事大吉,因为你的发送代码可能根本不检查返回值。后面几节我会按链路逐一展开。
1.2 丢消息的七个常见现场
先把我这些年见过的高频丢消息场景列出来,后面再逐个拆:
- 生产者异步发送消息,回调里的异常被吞掉,消息没有重试。
- 生产者端收到Broker的ACK,但Broker把消息丢在PageCache里还没刷盘,节点断电重启。
- Broker的主从复制是异步的,主节点故障切换后,从节点上少了刚才“成功”的数据。
- 消费者使用自动提交offset,在业务逻辑执行前偏移量已经被提交,业务异常时消息再也拉不回来。
- 消费者在处理消息过程中发生Rebalance,分区被分配给别的实例,旧实例已拉取但未提交的消息被新消费者重新消费,如果业务处理了一半没有幂等保护,可能造成数据错乱。
- 延迟消息被存在进程内存的时间轮里,还没有到期,部署重启后消息直接消失。
- 事务消息本地事务执行成功了,但Broker的回查失败或超时,最终消息没有被commit,业务方没有补偿。
这些场景单独看都挺简单,但放在真实环境里往往叠加在一起出现。先意识到“所有环节都可能丢”,后面才能设计出一套端到端不丢的方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 生产端:把消息“送出去”前必须锁死的三板斧
2.1 发送确认机制:别把“发出去了”当成“收到了”
生产端的第一道防线,是确认你的消息真的被Broker接收了。
很多开发刚接触MQ时,习惯用类似producer.send(msg)的单向/异步发送,不关心返回值,也不注册回调。在低峰期看起来一切正常,一旦网络抖动或Broker出现瞬时故障,发送请求可能直接抛出异常,或者虽然发送成功但Broker返回响应超时,消息就丢了。
正确做法是“发送后必须确认”。拿Kafka生产端的Java客户端举例:
java复制producer.send(new ProducerRecord<>("order_topic", key, value), (metadata, exception) -> {
if (exception != null) {
// 记录失败消息,进入补偿流程
handleSendFailure(message);
} else {
// metadata.topic() 和 metadata.offset() 能拿到消息真正落位的位置
log.info("mq message sent ok, topic={}, offset={}", metadata.topic(), metadata.offset());
}
});
RocketMQ的同步发送写法更直白:
java复制SendResult result = producer.send(msg);
if (result.getSendStatus() != SendStatus.SEND_OK) {
// 重试或落库补偿
}
无论用哪种客户端,核心就一句话:发送代码必须有明确的成功/失败判断,失败必须走重试或补偿,不能静默吞掉。这里的“成功”指的是Broker返回了ACK,而不是本地方法执行完没有抛异常。
2.2 重试与幂等:不丢和不重往往是一对矛盾
一旦决定失败重试,就会引入一个新的问题:重复消息。
比如生产者发送一条订单创建消息,Broker已经落盘并返回了ACK,但ACK在网络上超时了,生产者判断发送失败,于是重发。消费者第一次已经把订单建好了,第二次又收到同一条消息,如果没有防重措施,就会出现重复下单。
所以在生产端设计重试时,一定要给消息带上全局唯一的业务ID。最直接的做法是用消息的key承载业务ID,比如:
java复制String businessId = order.getId();
ProducerRecord<String, String> record = new ProducerRecord<>(
"order_topic", businessId, JSON.toJSONString(order)
);
消费端拿到key后,在业务表里用唯一索引或Redis SETNX做一个幂等标记。比如订单表有个biz_id字段,加了唯一索引,重复插入直接报冲突,捕捉后当作成功处理。这样生产端的重试就不再可怕。
重试本身还要有上限和退避策略。我见过有人用for循环无限重试,结果Broker故障期间把生产者线程全部打满,整个业务链路瘫痪。合理做法是:重试3到5次,使用指数退避(1s、2s、4s),如果最终仍然失败,把消息落到本地数据库的重试表,由定时任务继续补偿。
2.3 本地消息表:最粗暴也最可靠的补偿手段
如果你们的业务对消息可靠性要求特别高,比如支付、订单、库存,单纯靠“发送后确认”还不够。更稳妥的做法是先把消息持久化到业务数据库,再异步发送。
这个方案叫本地消息表。流程是这样的:
- 在同一个本地数据库事务里,写完业务数据后,同时插入一条消息记录。
- 消息记录初始状态为“待发送”。
- 独立发送线程或定时任务扫描待发送记录,实时或按批次发送到MQ。
- 发送成功后,把消息记录状态更新为“已发送”;发送失败则继续扫描重试。
这样业务数据和消息数据要么一起成功,要么一起失败,天然具备原子性。即使生产进程在发送前后崩溃,重启后仍然能从数据库里捞起未完成的消息,不会丢。
我最早用这种方式做过一个订单通知系统,当时还没有成熟的分布式事务框架。表结构大概是这样:
sql复制create table outbox_message (
id bigint primary key auto_increment,
biz_id varchar(64) not null,
topic varchar(128) not null,
payload text not null,
status tinyint not null default 0,
create_time datetime not null,
update_time datetime not null,
unique key uk_biz_id(biz_id)
);
定时任务每次扫描最近的待发送记录时,就给一个很小的发送超时保护,比如“状态为0且create_time在30秒之前”的记录。发送成功后更新状态为1。这里的关键点在于:消费者侧依然要做幂等,因为发送方可能会发送多次。
这套方案比事务消息更“土”,但非常直观,我现在在不少新项目里还会推荐。不过要注意别把所有发送任务都塞进同一个表,如果消息量很大,请按业务topic拆分或加分区。
3. Broker端:存储层才是“防丢”的大本营
3.1 单机持久化:刷盘不是靠运气
消息到达Broker后,不会立刻落到物理磁盘里。绝大多数MQ会先把消息写入操作系统的PageCache(页缓存),然后再由内核异步刷到磁盘。这样做的原因很现实:顺序写PageCache的速度可以达到每秒几十万甚至上百万条,而同步fsync落到磁盘通常只有几千到几万条。
问题在于PageCache是内存,节点断电或内核崩溃时,未刷盘的数据会丢。所以选什么刷盘策略,直接决定了单机可靠性。
以RocketMQ为例,刷盘策略主要有两种:
| 刷盘方式 | 原理 | 可靠性 | 性能 |
|---|---|---|---|
| 异步刷盘(ASYNC_FLUSH) | 消息写入PageCache即返回成功,后台异步刷盘 | 节点宕机可能丢失未刷盘消息 | 高 |
| 同步刷盘(SYNC_FLUSH) | 消息写入PageCache后主动执行fsync,落盘成功才返回 | 单机几乎不丢 | 较低 |
Kafka虽然默认依靠副本机制而不是单机刷盘来保证可靠性,但也有log.flush.interval.messages和log.flush.interval.ms等参数。很多生产环境为了吞吐,会刻意调大刷盘间隔,这就要有“丢最近几条消息的觉悟”。
我见过最典型的翻车场景:某团队为了追求性能,关闭了数据目录的fsync,机器在一个异常断电后重启,最近几万条消息全部从PageCache消失。因为业务方只做了生产端确认,没有意识到Broker内部还有这一层缓存,最终用户侧看到的就是“MQ说成功,但消息没了”。
如果你的业务要求不能丢,那就必须在“性能”和“刷盘”之间做主动取舍。单机同步刷盘 + 多副本同步复制,是可靠性最高的一档。
3.2 主从复制:从ACK到ISR的可靠性
单机刷盘解决了节点重启丢数据的问题,但解决不了磁盘损坏、机器报废、机房断电之类的物理故障。所以MQ需要主从复制。
Kafka的机制是最典型的。Producer可以设置acks=all,意味着leader分区写入后,要等ISR(In-Sync Replicas)里的所有副本都写入成功,才向生产者返回ACK。这里的ISR是“保持同步的副本集合”,如果某个follower落后太多、或者出故障,会被踢出ISR。假设你设置了min.insync.replicas=2,那么至少要有一个leader加一个follower同步成功,才允许写入,否则直接返回异常。
RocketMQ也有同步复制和异步复制两种主从模式。同步复制下,主节点写入后等待从节点复制成功才返回;异步复制下,主节点写完就返回。如果使用异步复制,一旦主节点宕机,从节点上缺失的数据就再也补不回来。
这里有个必须澄清的误区:不是所有“主从”都等于“不丢”。很多管理后台显示着“主从同步正常”,实际可能是异步复制,主节点故障切换的过程中丢数据的窗口仍然存在。要判断是否可靠,不要只看拓扑,要看写入路径上的ACK条件。
以Kafka为例,推荐配置至少是这样:
properties复制acks=all
min.insync.replicas=2
这两个参数配合,生产者在任何一个副本不可用时都不会盲目写入,避免“消息写进leader,但副本同步跟不上”的假成功。
3.3 顺序写与PageCache:性能与持久化的妥协
前面提到PageCache和刷盘之间的矛盾,其实背后是整个MQ高性能的核心秘密:消息队列使用顺序追加写日志文件,而不是随机写。顺序写的磁盘占用率很低,再加上PageCache的缓冲,所以吞吐量能远超普通数据库。
但性能再好,也不可能绕过物理定律。不少同学问:为什么不能既把所有数据放内存保证快,又实时刷盘保证不丢?答案是可以,但代价是吞吐量急剧下降。同步刷盘会让每次消息发送都要等待一次磁盘I/O,这在高吞吐场景下几乎是不可接受的。
所以在生产环境,我习惯把可靠性拆成两个维度来处理:
- 核心资金链路:必须同步刷盘 + 同步复制 + 生产端本地消息表兜底,牺牲一部分吞吐。
- 一般日志、通知类链路:异步刷盘 + 多副本异步复制就行,允许极端情况下丢几条。
判断标准很简单:这条消息丢了,用户能否感知?是否会直接造成经济损失?如果会,那它就必须走最高可靠链路。
另外,Broker端的参数不能拍脑袋乱调。比如Kafka的replica.lag.time.max.ms如果调得太小,follower稍微一卡就会被踢出ISR,导致生产端写入可用性下降,甚至分区不可写。调参数前要理解它背后的可靠性语义。
4. 消费端:真正“消费成功”才有资格提交offset
4.1 手动ACK:先处理业务再提交位移
生产端和Broker端都做到位了,消息已经在磁盘和副本里躺得稳稳当当,接下来就看消费者怎么取。这里最容易丢消息的原因,就是offset提交时机不对。
以Kafka消费者为例,如果开启enable.auto.commit=true,消费者会在后台周期性提交offset。假设消息已经被拉取到本地,业务代码还没执行完,甚至还没有开始执行,auto-commit就因为到了一个提交周期把offset提交了。这时消费者崩溃或业务抛异常,消息就从那个offset之后被跳过,再也拉不回来。
解决的办法是关掉自动提交,改手动提交。核心流程必须是:
- 从MQ拉取一批消息。
- 逐条或批量执行业务逻辑,包括写数据库、调外部接口。
- 业务全部成功后,再手动提交offset。
- 业务失败则抛出异常,不提交offset,让消息被重新拉取或进入重试队列。
下面是一个Kafka手动提交的简化示例:
java复制consumer.subscribe(Collections.singletonList("order_topic"));
while (running) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
for (ConsumerRecord<String, String> record : records) {
try {
process(record);
} catch (Exception e) {
// 记录失败日志,根据失败类型决定是否重试
// 这里不要提交offset
continue;
}
}
// 批量处理完后统一提交offset
consumer.commitSync();
}
这里有个细节:如果process里业务已经写库成功,但后续提交offset时进程崩溃,消息会被重复消费。这对业务来说不可怕,因为有幂等保证。真正可怕的是业务还没成功就提交了offset,那才叫丢消息。所以“先业务后ACK”是消费端铁的纪律。
4.2 消费幂等:重复消息是常态,不是Bug
上面说的“业务成功后提交offset,崩溃后会重复消费”是MQ系统里再正常不过的事情。很多新手把重复消费当成事故,其实重复消费是“不丢”的必然副产物。你需要做的不是消灭重复,而是让业务对重复无感。
消费幂等的常见方案:
- 数据库唯一索引:把消息里的业务ID设为唯一键,插入冲突时捕获异常,直接返回成功。
- Redis SETNX:以业务ID为key,第一次消费时存入value,并设置一个较长的过期时间;消费前检查这个key是否存在。
- 状态机校验:很多业务单据有明确状态流转,比如订单从“待支付”到“已支付”,只允许合法流转。重复消息带上旧状态,可以被校验拦截。
以订单创建场景为例,最简单也最有效的做法是给订单表加一个唯一索引。消息key就是订单号。消费端处理时先尝试插入订单,如果报唯一键冲突,说明之前已经处理过,直接确认消费:
java复制try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
log.info("duplicate order ignored, orderId={}", order.getOrderId());
}
幂等逻辑一定要写在“业务处理”里,而不是寄希望于MQ“只投递一次”。至少目前主流MQ提供的是“至少一次”投递语义,不是“恰好一次”。除非你愿意付出跨系统分布式事务的代价,否则请不要天真地依赖“不会重复”。
4.3 慢消费与堆积:消息还在,但“近实时”没了
“消息不丢失”是最低标准,满足之后还得看处理时效。消费者处理速度跟不上生产速度,会造成消息积压,消费位点越来越落后。这时候消息并没有丢,但对业务来说,延迟一小时才处理完的“实时通知”已经变得没有意义。
我之前处理过一起线上问题:某个消费者线程池的核心线程数被人改成了1,凌晨大促流量一进来,消费速度直接原地踏步。监控面板显示consumer lag一路涨到几十万,用户下单后迟迟收不到库存扣减通知,最后触发了业务超时,看起来就像消息丢了一样。
排查消费端积压,主要看几个指标:
- consumer lag:当前消费位点与最新消息位点之间的差值。Kafka内置监控可以看,RocketMQ也有ConsumeQueue的差值。
- 消费者实例数:单个topic分区固定,消费者实例数超过分区数时,多出的实例是空闲的,并不会提升消费速度。
- 消费耗时:单条消息平均处理耗时如果超过几秒,就要看是业务逻辑慢还是外部依赖慢。
如果积压已经发生,最快的止血方案往往是增加消费者实例数(如果分区允许)或临时调大消费线程数。但根本解决还是要优化业务逻辑,减少不必要的串行RPC。记住,消息系统只能保证消息“不丢”,不能保证消息“及时”。
5. 延迟消息队列是个例外:不丢还得“准时”
5.1 延迟消息的存储与扫描机制
延迟消息是MQ里一个很特殊的类型:消息发送后不会立刻被消费者看到,而是要等一个指定的延迟时间,比如订单超时未支付,30分钟后自动关闭。这类消息在很多场景里都代表“错过就完蛋”的业务规则,可靠性要求往往比普通消息更高,但实现上又容易踩坑。
以RocketMQ为例,它把延迟消息在Broker内部先存到一个特殊的调度主题(如SCHEDULE_TOPIC_XXXX),并带上目标topic和延迟时间。Broker会起定时扫描任务,到了时间再把消息从调度主题投递到真正的业务topic,这时候消费者才会消费到。RabbitMQ则常通过死信队列结合消息TTL来实现延迟效果:消息先进入一个设置了TTL的队列,过期后转投到死信交换机,消费者监听死信队列。Kafka本身没有原生延迟消息,需要应用层用Redis ZSet、时间轮或者数据库定时任务来实现。
这些实现有一个共同点:延迟消息必须“先存下来,再定时投递”。如果这个“存储”和“投递”两个动作不是同样可靠,延迟消息就比普通消息更容易丢。
5.2 延迟消息丢失的高危场景:重启、时间轮、落盘
我在生产环境见过的延迟消息丢失,几乎都集中在下面几种情况:
- 纯内存时间轮:某些自研延迟队列用一个HashMap或时间轮把延迟消息放在进程内存里,到期后由当前进程投递。一旦这个进程重启,所有还没到期的消息全部蒸发。
- 延迟消息到期投递时,目标topic或队列不存在,或者消费者还没有绑定好,消息被直接丢弃。这个在RabbitMQ用死信队列实现时特别常见,交换机名写错、队列未声明,消息一过期就没了。
- 扫描线程崩溃:延迟消息虽然落库了,但负责扫描并投递的定时任务没有做重启补偿。比如数据库里有记录,但状态一直停留在“待投递”,没有进程把它捞起来。
- 延迟消息存储没有同步副本:如果Broker的调度主题是单副本,Broker宕机,未到期消息就丢了。
- 扫描间隔设置过大:时间轮每5分钟才转一圈,一条延迟1分钟的消息实际被投递时已经过了6分钟,对业务来说是“迟到”,很多场景下等价于“丢了”。
所以你看,延迟消息的可靠性要求不只是“持久化”,还包括“持久化之后随时能恢复调度”。它比普通消息多了一个调度维度的风险。
5.3 实际业务中如何配合重试保障
如果你们正在用延迟消息,我的建议是不要在业务侧完全依赖MQ的延迟能力。最稳的做法是把延迟任务做成一张数据库表,由调度框架控制执行时机,到期后再投递到MQ进行正常消费。这样即使调度进程重启,数据库里仍有待执行任务,不会被内存丢失坑掉。
延迟任务表大概可以这样设计:
sql复制create table delay_task (
id bigint primary key auto_increment,
biz_id varchar(64) not null,
execute_time datetime not null,
status tinyint not null default 0, -- 0待执行 1已投递 2已完成
retry_count int not null default 0,
payload text not null,
create_time datetime not null,
update_time datetime not null,
unique key uk_biz_id(biz_id),
index idx_execute_time(execute_time, status)
);
调度任务每隔几秒扫描一次execute_time <= now()且status=0的记录,把消息发送到MQ业务topic,发送成功后标记为1。如果发送失败,通过retry_count控制重试次数和退避。
如果你还是选用RocketMQ这种原生延迟消息,也建议在业务侧加一个“对账任务”:定期扫描那些“应该已经被关闭的订单”,如果发现状态还没变,说明延迟消息可能丢了,就重新发一条补偿消息。把MQ当成“尽力准时”的通道,而不是唯一的定时器。
延迟消息的水比普通消息深得多。普通消息只要保证生产、存储、消费三段可靠即可,延迟消息还要额外保证“调度状态”可靠。能落库就落库,能用对账补偿就用对账补偿,这是我踩过几次坑之后最想先告诉你的一句话。
