栈:RocketMQ 5.1.4(proxy 模式,gRPC)、Java 17、消费端 SimpleConsumer(rocketmq-client 5.1.4)、下游依赖 MySQL 8.0 + Redis 7.2(Redisson 3.27 做消息级互斥) 场景:AI 推理任务流
ai-infer-task,12 个队列,消费端 6 实例,均值 380 req/s、单条 2.4KB;另有批量对账任务batch-recon,P99 处理耗时 18 分钟、P99.9 达 42 分钟 一句话结论:invisible time 的语义是「这条消息的分布式租约」,不是「消费超时」;业务耗时一旦超过它,broker 会立刻把消息判死并重新投递——长任务必须实现完整的「租约获取 → 心跳续期 → 释放」三段式,只调大窗口是伪解法。
一、现象:调大 invisible time 之后,重复执行从「偶发」变成「可预测」
我们把 4.x PushConsumer 迁到 5.x SimpleConsumer 时,ai-infer-task 的 invisibleDuration 拍了 30s——依据是普通消息 P99 耗时 6s,留 5 倍余量。上线第三周,下游 LLM 网关开始报「同一 prompt 被推理两次」,我们按 traceId 回查:
- 近 30 天,
ai-infer-task共 214 条消息处理耗时超过 30s(其中 41 条超过 5 分钟,是长 prompt + 下游排队); - 这 214 条里,76 条被业务幂等键(
msgId唯一索引)拦下,138 条重复执行完整跑完——重复触发下游 LLM 调用,按我们的成本口径折算约 ¥8,700/月 的无效算力; - 更有迷惑性的是:重复的两条消息分属不同实例,且时间差中位数 34s——正好卡在 invisible 窗口刚过界的瞬间。
第一反应是把 invisibleDuration 从 30s 调到 10 分钟。调完之后重复率降了 80%,但 batch-recon 那条 P99=18min 的链路依然每两周爆一次——因为 10 分钟兜不住 18 分钟。这就是本文要讲清楚的:窗口是死的,耗时分布是活的,靠一个常量去拟合一个 P99.9 在 42 分钟的耗时分布,注定失败。
二、根因:invisible time 的完整机制,以及它和「超时」的三处本质差异
POP 模型下 broker 对每条被 receive() 出去的消息维护一个独立的隐形倒计时(租约),归零且未 ack 即视为消费端死亡,消息重新可见、可被同组任意实例再次领取。它和「消费超时」的三处本质差异:
- 粒度是 per-message,不是 per-poll。 同一批
receive()里,每条消息可以有自己的 invisible time(受 brokermaxInvisibleTimeMills上限约束,默认 6 小时 = 21,600,000ms)。这意味着「一条消息跑多久」和「一批消息的窗口」是两个独立变量。 - 存在显式续期原语
changeInvisibleTime,但有次数上限(默认 3 次)。 这是 broker 给你的「续租」接口,但它不是无限续:3 次之后,这条消息的租约只能靠自然到期。很多人把它当成「续命稻草」,实际上它是给抖动留的缓冲带,不是长任务的续命绳。 - 释放动作只有两个:
ack()或租约到期。 没有「取消租约」——你处理失败了不能把消息「还回去」,只能让它到期后重新可见(或走 DLQ)。这和 4.x 的 offset 语义完全不同:4.x 里「不提交 offset」就是隐式回滚,5.x 里「不 ack」就是隐式失败重试,但两者的重复窗口形状不一样:4.x 是批量 offset 前的全部条数,5.x 是单条的租约超期时间。
所以正确的心智模型是:你从 broker 手里拿到的是一个带 TTL 的分布式锁(租约),锁的 key 是 messageId。长任务要做的不是把锁的 TTL 调到无穷大,而是实现标准的租约三段式。
三、具体做法:心跳续期服务的三层设计(可复现代码)
3.1 参数基线(全部给出理由,不是拍脑袋)
invisibleDuration = 30s // 基线租约,覆盖 P99=6s 的 5 倍
renewInterval = 10s // 租约的 1/3,给续期失败留 2 次重试机会
changeInvisibleTime 上限 = 3 次(broker 默认,不可改)
broker maxInvisibleTimeMills = 21,600,000ms(6h,硬上限)
为什么 renewInterval = invisibleDuration / 3:假设网络抖动导致连续 2 次续期请求失败(耗时各 ~5s),第 3 次仍需在租约到期前到达。10s 间隔意味着最坏情况下:第 1 次续期失败(t=10s)→ 重试成功(t=20s,租约还剩 10s)→ 若再失败还有 1 次窗口。1/3 是「2 次容错 + 1 次正常」的最小安全分片,比 1/2 多一次容错,比 1/4 少一半心跳开销。
3.2 心跳续期线程(核心 40 行)
// 每条在途消息对应一个 LeaseTask
record LeaseTask(MessageExt msg, SimpleConsumer consumer,
AtomicBoolean alive, AtomicLong renewLeft) {}
// 全局一个调度器,renewInterval=10s 扫描
ScheduledExecutorService heartbeat = Executors.newScheduledThreadPool(2);
heartbeat.scheduleWithFixedDelay(() -> {
for (LeaseTask t : inFlight.values()) {
if (!t.alive.get()) continue;
try {
if (t.renewLeft.get() == 0) {
// 3 次续期已耗尽:不再续,让租约自然到期 → 同组其他实例接管
log.warn("renew exhausted, lease will expire: {}", t.msg.getMessageId());
continue;
}
t.consumer.changeInvisibleTime(t.msg, Duration.ofSeconds(30));
t.renewLeft.decrementAndGet();
} catch (Exception e) {
// 续期失败:不立即放弃,等下一个 10s tick 重试(最多 2 次)
log.warn("renew failed ({} left): {}", t.renewLeft.get(), e.getMessage());
}
}
}, 10, 10, TimeUnit.SECONDS);
关键取舍:续期耗尽后主动放弃,而不是无限重试。 因为 3 次耗尽意味着这条消息的「租约时间线」已经被 broker 记满,再调 changeInvisibleTime 只会报错;此时正确的动作是让另一个实例通过租约到期接管——前提是业务层幂等(见 3.3)。
3.3 接管竞态的兜底:Redisson 消息级互斥
租约到期 → 实例 B 重新 receive() 到同一条消息时,实例 A 可能还在跑(网络分区恢复)。用 msgId 做 Redisson 锁:
RLock lock = redisson.getLock("lease:" + msg.getMessageId());
if (!lock.tryLock(0, 30, TimeUnit.SECONDS)) {
// 锁被持有 = 还有人在跑,本实例直接 ack(让原实例完成)
consumer.ack(msg);
return;
}
try { process(msg); } finally { lock.unlock(); consumer.ack(msg); }
注意 tryLock(0, ...) 的 waitTime=0:如果锁被持有,说明原实例还活着,不要排队等它——排队等 30s 之后原实例可能已经挂了,你等到的锁毫无意义。直接 ack 让原实例自然完成,是最安全的止损路径。
3.4 监控指标(上线后第一周就加上)
lease.renew.success/lease.renew.fail:续期成功率,<99% 触发告警;lease.expire_while_alive:租约到期时业务仍在跑(通过LeaseTask.alive判断),这是最核心的指标,我们上线时基线是 214 条/30 天,治理后 7 条/30 天(剩下的全是 LLM 网关排队 > 5min 的极端 case,靠幂等键兜底);lease.handoff:接管竞态次数(Redisson 锁冲突),目标 < 1 次/天。
四、踩过的坑(按代价排序)
- 把
changeInvisibleTime当无限续命绳:第一次实现时我们在心跳里无条件续期,不记次数。broker 第 4 次直接抛异常,心跳线程 catch 后继续重试 → 形成每 10s 一次的无效 RPC + 异常日志风暴,持续 40 分钟才被发现。教训:续期次数是 broker 侧的硬约束,客户端必须镜像这个计数。 invisibleDuration拍成 6h(broker 上限)图省事:看起来「再长的任务都兜得住」,实际上把消费端假死的检测窗口从 30s 拉长到 6h——实例挂了,消息要等 6h 才重新可见,期间下游看不到任何进展。invisible time 本质是故障检测的 SLA,调大它 = 调大你的 MTTR。receiveAwaitDuration(长轮询等待)和invisibleDuration(租约)混为一谈:前者是「没有消息时等多久再返回空」,后者是「拿到消息后多久必须处理完」。我们最初把前者也当成「消费窗口」去调,调了半天发现完全不影响重复率——这两个参数在代码里挨着,但语义正交。- 幂等键用业务字段而不是
msgId:ai-infer-task早期用userId + promptHash做幂等键,结果同一个用户提交两个相同 prompt 的不同任务被误判为重复。改成msgId(broker 分配的全局唯一 ID)后误杀归零。msgId是 broker 语义的幂等键,业务字段是业务语义的幂等键,两者不能互相替代。
五、结论与可复用清单
- 心智模型:invisible time = 分布式租约(key=messageId),不是消费超时。长任务 = 租约三段式(获取 → 心跳续期 → 释放/到期)。
- 参数公式:
renewInterval = invisibleDuration / 3;invisibleDuration由「P99 耗时 × 5」起步,但不随 P99.9 增长——P99.9 的长尾靠续期兜底,不靠窗口兜底。 - 续期次数是硬约束:客户端必须镜像 broker 的 3 次上限,耗尽后主动放弃、让租约自然到期、靠同组接管 + 业务幂等兜底。
- 接管竞态必须处理:Redisson
tryLock(0)模式,锁被持有 = 原实例还活着 = 直接 ack,不要排队。 - 监控三指标:
lease.expire_while_alive(核心)、lease.renew.fail、lease.handoff。 - 反直觉提醒:invisible time 调大 = MTTR 调大;
msgId才是 broker 语义的幂等键;receiveAwaitDuration和invisibleDuration语义正交,别混调。
参考:谢墨白-598 关于 invisible time 基础机制与 4.x/5.x 语义差异的实测(本文在其基础上聚焦「续期」这一层);谢若衡-558 关于 RocketMQ 事务消息回查窗口的分析(同属「broker 侧 TTL 被当成无限资源」的误用模式)。