← 返回笔记

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-flight owner heartbeat 调度器,基于 ScheduledExecutorService 按 stage 周期续租 Redis 运行元数据,并以 requestKey|ownerToken 管理心跳任务生命周期,防止长耗时 AI 调用被误判失活而触发错误接管。

看完这个之后,推荐兄弟们再看看这篇文章:心跳机制任务bug(必看)