不丢的三段论
「消息不丢」是 MQ 可靠性的第一命题。一条消息从生到死要过三关:生产端送达 Broker、Broker 自己不弄丢、消费端确认后再干完活。三关都要设防,本篇先守第一关:生产端。
生产端丢消息的三种死法
死法一:oneway 一把梭。发送模式里最浪的一种——消息扔出去就不管了,不等应答也不重试。网络抖一下,消息就消失在世界上。oneway 只该用在允许丢的埋点日志上,业务消息碰都别碰。
死法二:异步回调吞异常。异步发送吞吐高,但很多人写出这样的代码:
producer.send(msg, new SendCallback() {
public void onSuccess(SendResult result) { log.info("发送成功"); }
public void onException(Exception e) {
// 什么都没写——异常被吞了,消息无声无息地没了
}
});回调里不打日志、不落补偿、不告警,等于失败了对全世界保密。异步发送的正确姿势是 onException 里必须做事:记表、重试或告警,三选一起码做一个。
死法三:进程崩溃消息还在内存。业务先提交事务,再发消息,如果业务成功后、消息发出前进程被杀,这条消息就永远停在「该发未发」的状态。这是状态机缺口问题,普通重试救不了,要用下面的本地消息表。
第一层防线:同步发送与发送重试
同步发送是最基本的要求:send 返回 SendResult,检查发送状态再往下走。RocketMQ 的 send 默认同步模式,发送失败自动重试 2 次(共 3 次机会),并且会自动换一个 Broker 重试——第一次碰上的 Broker 恰好挂了,第二次换个路口再试。
SendResult result = producer.send(msg);
if (result.getSendStatus() != SendStatus.SEND_OK) {
// FLUSH_DISK_TIMEOUT / SLAVE_NOT_AVAILABLE 等,按业务决定重投或告警
localMessageMapper.markForRetry(msg);
}注意重试的副作用:重试可能导致重复发送。第一次其实成功了,只是 ACK 超时,重试后 Broker 里就有了两条一模一样的消息。所以「不丢」和「不重」天生是一对,生产端负责不丢,消费端负责不重(第 7 篇)。
第二层防线:本地消息表
重试解决「发送时失败」,解决不了「该发未发」。终极兜底是把消息先落到自己的数据库里,和业务数据同一个事务提交:
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order);
// 消息记录与订单同库同事务,要么都在,要么都不在
localMessageMapper.insert(new LocalMessage(order.getId(), ORDER_PAID_EVENT, PENDING));
}
// 事务提交后投递,失败由定时任务扫描补偿
afterCommit(() -> mq.sendAndMark(localMessage));本地消息表的关键字段:业务单号(幂等键)、消息内容、状态(待发送/已发送/发送失败)、重试次数、下次重试时间。定时任务扫描 PENDING 和失败超限的记录,保证只要业务数据在,消息终将被投出。代价是每条消息多一次落库,用性能换确定性,核心链路值得。RocketMQ 的事务消息(第 10 篇)本质上是把这张表内置到了 Broker 里。
三层防线怎么排兵布阵
| 防线 | 解决的问题 | 代价 | 适用 |
|---|---|---|---|
| 同步发送+检查 | 发送失败无感知 | 吞吐下降 | 所有业务消息的底线 |
| 发送重试 | 瞬时抖动、单点故障 | 可能重复发送 | 客户端默认开启 |
| 本地消息表 | 业务与消息的原子性 | 多一次落库+补偿任务 | 核心链路,不可丢的场景 |
小结
生产端不丢的三板斧:不用 oneway 发业务消息、异步回调必须处理异常、核心链路上本地消息表。再记住一条:重试和重复是一对孪生兄弟,生产端把消息送到了,重复的烂摊子交给消费端的幂等去收拾。
消息送到了 Broker,就安全了吗?下一篇讲消息不丢(下):Broker 的刷盘策略、主从复制,以及消费端那个最容易踩坑的 ACK 时机——先干活再提交,还是先提交再干活,选错一边就是丢或者重。
评论 (0)