系统设计 | 利用心跳机制实现自动续期 —— 看门狗机制在注册中心中的应用
这几天在学习手写 RPC 项目,一开始服务注册的实现我只是使用 ConcurrentHashMap
在本地完成,随着功能的逐步完善,我对框架进行了扩展,抽象出了统一的 Registry
接口,作为注册中心的统一规范。后续只需要实现这个接口,即可灵活地接入不同的注册中心实现。
在实践过程中,我选择使用了 etcd 作为注册中心中间件。不过,在接入使用的过程中,我注意到一个关键问题:
etcd 和其他键值对存储系统一样,采用租约机制(Lease)来限制键值对的生存时间。换句话说,一个服务注册信息如果没有被续租(即延长过期时间),就会被自动删除。这种机制虽然可以避免脏数据,但也带来两个挑战:
如果服务节点已经下线,那么注册中心需要及时删除该节点的注册信息,防止客户端请求到不可用的节点;
如果服务节点仍然可用,则必须定期续租,保持注册状态不失效。
仅仅依靠 etcd 的过期时间设置并不能很好地实现这两个目标,因为 etcd 本身无法感知某个服务节点是否宕机或失联。
常见思路分析
为了解决上述问题,我调研并总结了以下两种思路:
方案一:轮询探活
对注册中心内所有节点定期进行探活检查,判断其是否仍然存活。如果不响应,则主动移除。这种方式的问题在于节点的注册时间和过期时间可能不同,导致轮询的时间间隔难以控制;并且如果节点量多,开销也会非常大。
方案二:引入看门狗机制(Watchdog)
参考 Redisson 分布式锁的设计思路,Redisson 为了避免锁被提前释放,会在客户端内部单独启动一个定时任务线程,如果业务还在执行中,就会自动向 Redis 续租该锁。
类似地,我们也可以为服务节点设置一个心跳机制:服务提供者定期向 etcd 注册中心发送心跳请求,更新其注册状态,从而实现自动续期。
通过进一步调研可以发现,方案二(心跳续期)已经成为大多数注册中心实现中的主流做法。
实现过程
下面是我在现有 RPC 框架基础上对注册中心进行的具体改造。
1. 改造 Registry 接口,添加续期方法
我们首先在统一的 Registry
接口中添加 heartBeat
方法:
/**
* Registry interface
*/
public interface Registry {
void init(RegistryConfig registryConfig);
void register(ServiceMetaInfo serviceMetaInfo) throws Exception;
void unRegister(ServiceMetaInfo serviceMetaInfo);
List<ServiceMetaInfo> serviceDiscovery(String serviceKey);
void destroy();
/**
* Heartbeat for lease renewal
*/
void heartBeat();
}
2. 存储本地注册的节点 Key,用于续期
在 etcd 的具体实现类中,我们使用一个 HashSet<String>
来记录当前节点注册的所有 key,以便后续定时对其进行续期:
@Slf4j
public class EtcdRegistry implements Registry {
// ...
/**
* 本机注册的节点 key 集合(用于续期)
*/
private final HashSet<String> localRegisterNodeKeySet = new HashSet<>();
// ...
}
这样一来,不仅注册服务时我们能记住注册的 key,在节点下线或者定时续期时,也能准确找到所有本地注册的节点。
3. 实现心跳续期逻辑
@Override
public void heartBeat() {
// 每 10 秒执行一次心跳续期
CronUtil.schedule("*/10 * * * * *", (Task) () -> {
for (String key : localRegisterNodeKeySet) {
try {
List<KeyValue> keyValues = kvClient.get(ByteSequence.from(key, StandardCharsets.UTF_8))
.get()
.getKvs();
// 节点已过期,不进行续期(可考虑重启注册逻辑)
if (CollUtil.isEmpty(keyValues)) {
continue;
}
// 节点未过期,执行重新注册(续签)
KeyValue keyValue = keyValues.get(0);
String value = keyValue.getValue().toString(StandardCharsets.UTF_8);
ServiceMetaInfo serviceMetaInfo = JSONUtil.toBean(value, ServiceMetaInfo.class);
register(serviceMetaInfo);
} catch (Exception e) {
throw new RuntimeException(key + "续签失败", e);
}
}
});
CronUtil.setMatchSecond(true);
CronUtil.start();
}
⚠️ 注意:续期间隔应小于 etcd 的租约过期时间,以避免在下一次心跳前租约已失效。
4. 修改原有方法逻辑,集成心跳机制
为了让心跳机制生效,我们需要对原有代码进行适配:
初始化阶段启动心跳:
@Override
public void init(RegistryConfig registryConfig) {
// 初始化操作...
heartBeat(); // 启动心跳定时任务
}
注册和下线时维护本地节点集合:
@Override
public void register(ServiceMetaInfo serviceMetaInfo) throws ExecutionException, InterruptedException {
// 注册逻辑...
localRegisterNodeKeySet.add(registerKey); // 添加到本地节点集合
}
@Override
public void unRegister(ServiceMetaInfo serviceMetaInfo) {
// 注销逻辑...
localRegisterNodeKeySet.remove(registerKey); // 从集合移除
}
节点销毁时,释放资源前手动注销:
@Override
public void destroy() {
log.info("当前节点下线");
// 主动删除注册信息
for (String key : localRegisterNodeKeySet) {
try {
kvClient.delete(ByteSequence.from(key, StandardCharsets.UTF_8)).get();
} catch (ExecutionException | InterruptedException e) {
log.error("{} 节点下线失败", key, e);
}
}
// 关闭客户端连接
if (kvClient != null) {
kvClient.close();
}
if (client != null) {
client.close();
}
}
总结
引入看门狗机制(Watchdog)是解决服务注册状态自动维护问题的一种经典方式。通过服务节点主动发送心跳,实现对 etcd 注册信息的自动续期,不仅可以确保服务长期可用时注册信息不会失效,还可以在节点宕机时通过租约过期机制自动清理。
相比被动探活,心跳机制更加主动、低成本、易于实现,也符合当前微服务注册中心普遍采用的设计模式。
如果你在实现过程中也遇到服务注册信息过期、节点状态更新不及时等问题,不妨考虑用「心跳 + 看门狗」的思路来简化实现逻辑、提升系统健壮性。
🛠️ 下一步,我计划将心跳机制进一步抽象并集成到框架初始化流程中,同时支持自定义心跳间隔、租期配置等能力,让整个注册中心实现更通用和灵活。