learning note
owner heartbeat 调度器设计详解
梳理分布式 Single-flight 中 owner 心跳调度器的设计目标、续租流程和失活接管判断。
背景:
没想到吧,其实这个还是设计分布式Single-flight落地中的一个技术。在分布式Single-flight的场景下,我们使用存放在Redis中的Flight共享变量来表示谁是owner节点。
为了避免Owner节点阻塞导致我们服务崩溃,我们给Owner节点标识了过期时间。而AI接口的调用本来就比较耗时间。那如果最开始的TTL过期了,导致Owner节点失效,其他节点重新成为owner节点。那就会导致接口重复调用AI接口。
所以我们就需要在本地搭建一个心跳机制,当Owner节点还在正常处理任务的时候,要给Redis中的Owner标识去续租期。
heartbeat 的作用不是通知成功,而是证明存活
这里 heartbeat 的作用更像是:
-
owner 定时续租自己的运行权
-
告诉其他 follower:我还在执行,不要接管
所以它保护的不是业务结果本身,而是:owner 执行期的独占身份
工作流程:
用来搭建Owner心跳机制的核心类是FlightHeartbeatManager
代码位置在:
admin/src/main/java/com/hewei/hzyjy/xunzhi/interview/application/guard/singleflight/coordinator/FlightHeartbeatManager.java
这个类代码不长,但在整条链路里承担的职责非常重要:
- 为当前
owner节点维持一条“我还活着、我还在执行”的心跳续租链路
如果没有这层机制,分布式 single-flight 在遇到长耗时 AI 调用时,很容易出现:
-
旧
owner其实还在跑 -
但 Redis 里的运行 TTL 先过期了
-
其他节点误以为
owner已失活 -
于是错误接管,最终出现双执行、结果覆盖或成本翻倍
所以从本质上说,FlightHeartbeatManager 不是一个普通定时器工具类,而是用来更新Owner存活节点的工具。
一整个通过心跳机制续租的机制可以概括为:
当前节点先通过 Lua 协调成为 owner
当 DistributedInterviewAiSingleFlightService 收到请求后,会先执行:
acquireOrJoin(...)
由 Redis Lua 脚本决定当前节点是:
-
新 owner
-
接管 owner
-
follower 等待
-
直接回放成功结果
-
回放失败语义
成为 owner 后,先标记进入运行态
如果当前节点拿到了:
-
OWNER_NEW -
OWNER_TAKEOVER
就会进入 ownerExecute(...)。在这个方法里,会先调用:
markRunning(...)
把 Redis 元数据从抢占态推进到运行态,并刷新运行 TTL。
再启动 heartbeat 调度器
一旦进入真正执行期,ownerExecute(...)代码会启动:
flightHeartbeatManager.start(...)
?????????????????????????? 让本机定时调用:
flightCoordinatorRepository.heartbeat(...)
不断刷新:
-
heartbeatAt -
updatedAt -
expireAt -
Redis key 的 TTL
结束时停止 heartbeat
无论 owner 最后是:
-
成功写入结果
-
失败写入失败状态
-
还是中途抛异常
都会在 finally 里执行:
flightHeartbeatManager.stop(heartbeatTaskKey)
?????????????????????????? 这就形成了一个非常完整的生命周期,避免了执行结束之后的无意义续期。
代码逻辑讲解:
/**
* owner 节点的 heartbeat 调度器,负责定时执行续租动作,
* 保证长耗时 AI 请求在运行期间不会因为 TTL 到期而被误接管。
*
* @author 程序员牛肉
*/
@Service
@RequiredArgsConstructor
public class FlightHeartbeatManager {
@Qualifier("scheduledExecutorService")
private final ScheduledExecutorService scheduledExecutorService;
private final Map<String, ScheduledFuture<?>> futures = new ConcurrentHashMap<>();
public String start(FlightOwnerContext ownerContext, BooleanSupplier heartbeatAction) {
long intervalMillis = ownerContext.getPolicy() == null || ownerContext.getPolicy().getHeartbeatIntervalMillis() == null
? 3000L
: Math.max(500L, ownerContext.getPolicy().getHeartbeatIntervalMillis());
String taskKey = ownerContext.getRequestKey() + "|" + ownerContext.getOwnerToken();
ScheduledFuture<?> future = scheduledExecutorService.scheduleAtFixedRate(
() -> heartbeatAction.getAsBoolean(),
intervalMillis,
intervalMillis,
TimeUnit.MILLISECONDS
);
futures.put(taskKey, future);
return taskKey;
}
public void stop(String taskKey) {
if (taskKey == null) {
return;
}
ScheduledFuture<?> future = futures.remove(taskKey);
if (future != null) {
future.cancel(true);
}
}
}
start逻辑:
第一步:从 stage policy 里解析 heartbeat 间隔
代码会这样取值:
long intervalMillis = ownerContext.getPolicy() == null || ownerContext.getPolicy().getHeartbeatIntervalMillis() == null
? 3000L
: Math.max(500L, ownerContext.getPolicy().getHeartbeatIntervalMillis());
这背后的设计思路是:
-
heartbeat 周期按 stage 策略配置化
-
没配置时默认 3 秒
-
最小不低于 500ms,防止错误配置导致心跳过于频繁
也就是说,心跳频率不是写死的,而是:按业务阶段细粒度控制
第二步:构造任务唯一键
然后它会构造:
String taskKey = ownerContext.getRequestKey() + "|" + ownerContext.getOwnerToken();
这里不单纯用Requestkey的原因很复杂。更多细节可以看这篇文档:为什么要这么设计任务唯一key
第三步:注册固定频率调度任务
真正启动周期任务的是下面这段代码。没啥好讲的,就是开了一个scheduleWithFixedDelay去执行定时任务。
ScheduledFuture<?> future = scheduledExecutorService.scheduleAtFixedRate(
() -> heartbeatAction.getAsBoolean(),
intervalMillis,
intervalMillis,
TimeUnit.MILLISECONDS
);
第四步:把 ScheduledFuture`放进注册表
启动后,代码会:
futures.put(taskKey, future);
这一步的意义就是:让后面 stop(taskKey) 能精确找到它并取消
stop:
public void stop(String taskKey) {
if (taskKey == null) {
return;
}
ScheduledFuture<?> future = futures.remove(taskKey);
if (future != null) {
future.cancel(true);
}
}
stop的逻辑没有什么难的,其实就是根据key找到对应的future,然后调用cancel方法把他取消了。
当前的心跳机制其实是存在一些问题的。因为它只管续约,而不考虑owner的实际情况。
- 如果只是 owner 的业务线程阻塞/卡慢,但 JVM 还活着、调度线程池还在跑,那 heartbeat 任务仍会继续执行,Redis 租约也会继续被续上。
而只要Redis的TTL没有过期,那我们的其他fllow节点就会一直认为对应的Owner节点没有仍然存活有效。在一定程度上造成阻塞问题。
更多内容可以看这一篇文档:心跳机制任务bug
面试里怎么讲这个技术点
如果你口头讲给面试官,我建议这样表达:
在分布式 AI single-flight 里,我专门设计了 owner heartbeat 调度器,用
ScheduledExecutorService在 owner 执行期定时续租 Redis 中的运行元数据,防止长耗时 AI 调用因为 TTL 先过期而被 follower 误接管。任务键不是简单用 requestKey,而是用requestKey|ownerToken绑定请求和 owner 任期,这样在接管场景下能精确隔离不同 owner 的心跳生命周期。心跳启动、停止分别嵌入ownerExecute(...)的开始和finally收口阶段,并结合 Redis 侧 ownerId/ownerToken CAS 校验,保证只有合法 owner 才能续租。
如果要写成更简历化的一句,可以写成:
- 设计分布式
single-flightowner heartbeat 调度器,基于ScheduledExecutorService按 stage 周期续租 Redis 运行元数据,并以requestKey|ownerToken管理心跳任务生命周期,防止长耗时 AI 调用被误判失活而触发错误接管。
看完这个之后,推荐兄弟们再看看这篇文章:心跳机制任务bug(必看)