一个能用的注册中心只需要三块:并发注册表、心跳剔除线程、消费者本地缓存,剩下的工程量基本都花在防误杀和注册中心宕机这两件事上。
注册表就是一张带版本号的并发 Map
服务名到实例列表用两层 ConcurrentHashMap 装,实例 ID 用 host:port:cluster 保证全局唯一,注册走 putIfAbsent,重复注册当成一次续约处理。
public final class Instance {
private final String service;
private final String instanceId; // host:port:cluster
private final String host;
private final int port;
private volatile long lastHeartbeatAt; // 注册中心侧时间
private volatile boolean healthy = true;
private volatile long unhealthySince = 0L;
public void renew() { this.lastHeartbeatAt = System.currentTimeMillis(); }
public void markUnhealthy(long now) { this.healthy = false; this.unhealthySince = now; }
public void markHealthy() { this.healthy = true; this.unhealthySince = 0L; }
}
public class Registry {
private final ConcurrentMap<String, ConcurrentMap<String, Instance>> services = new ConcurrentHashMap<>();
private final ConcurrentMap<String, AtomicLong> revisions = new ConcurrentHashMap<>();
public void register(Instance ins) {
ConcurrentMap<String, Instance> map =
services.computeIfAbsent(ins.getService(), k -> new ConcurrentHashMap<>());
Instance old = map.putIfAbsent(ins.getInstanceId(), ins);
if (old != null) {
old.renew();
} else {
bump(ins.getService());
}
}
public List<Instance> getInstances(String service) {
Map<String, Instance> map = services.get(service);
if (map == null || map.isEmpty()) return Collections.emptyList();
List<Instance> result = new ArrayList<>(map.size());
for (Instance ins : map.values()) {
if (ins.isHealthy()) result.add(ins); // 软剔除的实例不在这里返回
}
return result;
}
}
revision 是这套实现里最容易被漏掉的字段。消费者刷新、长轮询、变更推送都靠它判断地址列表有没有变。心跳续约只更新 lastHeartbeatAt,不能动 revision,否则每 5 秒把所有消费者全唤醒一次,纯属自找麻烦。真正要递增的只有三种情况:实例新增、实例被摘除(软剔除或硬删除)、实例从软剔除恢复。
lastHeartbeatAt 用服务端时间戳,不接受客户端上报的时间。容器里时钟漂移几十秒很常见,用它做超时判断,剔除行为会变得没法解释。
注册、续约、下线三件事的语义
心跳接口返回 boolean:实例已经被硬删除时返回 false,客户端拿到 false 应该重新走注册,而不是继续傻发心跳。这个约定能省掉一类「实例明明活着但注册中心里查不到」的诡异问题。
public boolean heartbeat(String service, String instanceId) {
Map<String, Instance> map = services.get(service);
Instance ins = (map == null) ? null : map.get(instanceId);
if (ins == null) return false; // 已被硬删除,让客户端重新注册
ins.renew();
if (!ins.isHealthy()) { // 软剔除窗口内心跳恢复
ins.markHealthy();
bump(service); // 地址集合变了,通知消费者
}
return true;
}
下线走 shutdown hook,反注册是立即删除,不参与超时剔除。
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
client.deregister(service, instanceId);
Thread.sleep(3_000); // 给消费者刷新缓存留个窗口
} catch (Exception ignore) { }
}));
这 3 秒值不值?值。消费者刷新间隔如果是 5 秒,反注册后立刻 kill 进程,会有一批请求打到已经关掉的端口,日志里一片 Connection refused。更稳的做法是配合优雅停机:先从负载均衡摘流量,再反注册,然后等 in-flight 请求跑完。
剔除分两级:软剔除摘流量,硬删除腾内存
只设一个超时阈值是不够的,超时十五秒和超时一分钟,处理方式应该完全不同。
static final long SOFT_TIMEOUT_MS = 15_000L; // 约 3 个心跳周期:摘流量
static final long HARD_TIMEOUT_MS = 60_000L; // 真没了:删元数据
void sweep() {
long now = System.currentTimeMillis();
for (Map.Entry<String, ConcurrentMap<String, Instance>> e : services.entrySet()) {
ConcurrentMap<String, Instance> map = e.getValue();
if (!passSelfProtection(map)) continue; // 健康比例过低,整轮跳过
boolean changed = false;
for (Instance ins : map.values()) {
long silent = now - ins.getLastHeartbeatAt();
if (ins.isHealthy() && silent > SOFT_TIMEOUT_MS) {
ins.markUnhealthy(now);
changed = true;
} else if (!ins.isHealthy() && silent > HARD_TIMEOUT_MS) {
map.remove(ins.getInstanceId());
changed = true;
}
}
if (changed) bump(e.getKey());
}
}
private boolean passSelfProtection(Map<String, Instance> map) {
if (map.size() < 10) return true; // 实例太少,比例没意义
long healthy = map.values().stream().filter(Instance::isHealthy).count();
return healthy * 100L / map.size() >= 85L;
}
剔除线程用一个单线程 ScheduledExecutorService 就够,3 秒扫一次。扫描复杂度是 O(实例数),几千个实例一轮在毫秒级。想省 CPU 可以按服务分片到多个线程,但收益有限,别提前优化。心跳 5 秒、软剔除 15 秒、硬删除 60 秒这个比例是租约模型的常见量级,Eureka 也是同一套思路,配置项 lease-renewal-interval-in-seconds 和 lease-expiration-duration-in-seconds 的默认值就在这个量级。
软剔除为什么能防误杀
触发心跳超时的原因里,进程真死掉的只占一部分。Full GC 停顿、宿主机 CPU 被抢、容器被限流、跨机房网络抖动,都会让心跳晚到十几秒。硬删除的话,实例重新心跳还得再走一遍注册,revision 抖两次,消费者连接池来回重建。
软剔除的语义是:从 getInstances 的返回里摘掉,元数据保留,给一个恢复窗口,心跳在窗口内回来直接翻回 healthy。消费者看到的是地址列表少了一个实例,而不是这个实例的注册记录消失又出现。
代价也得说清楚。软剔除期间流量集中到剩下的健康实例上,如果一次网络抖动同时影响一半实例,剩余实例会被瞬间压垮,接着雪崩。保护阈值就是为这个准备的:健康比例低于阈值就跳过本轮剔除,宁可让消费者打到几个坏实例,也不能把整个服务摘空。Eureka 的自我保护模式是这套逻辑最出名的实现,Sentinel 的「保护阈值」思路一致。
反过来看,软剔除也意味着消费者拿到的列表不等于全健康列表。消费者侧的重试、熔断、连接池探活一个都不能少。
消费者本地缓存:内存快照加磁盘快照
public class LocalRouter {
private final AtomicReference<Map<String, List<Instance>>> snapshot =
new AtomicReference<>(Collections.emptyMap());
private final Path file;
private final RegistryClient client;
public void start() throws IOException {
if (Files.exists(file)) { // 先本地兜底
snapshot.set(readJson(file));
}
try {
apply(client.fetchAll()); // 再尝试拉注册中心
} catch (Exception ignore) {
log.warn("registry unreachable on startup, use local snapshot");
}
Thread t = new Thread(this::refreshLoop, "registry-refresh");
t.setDaemon(true);
t.start();
}
private void apply(Map<String, List<Instance>> fresh) throws IOException {
snapshot.set(fresh);
writeAtomically(file, fresh);
}
public List<Instance> route(String service) {
return snapshot.get().getOrDefault(service, Collections.emptyList());
}
}
启动顺序不能反:先读本地文件,再尝试连注册中心。反过来的话,注册中心正在重启时你的应用也重启,路由表是空的,整个服务起不来。
磁盘快照必须原子写,先写 .tmp 再 rename。直接覆盖写,进程在写一半时被 kill,下次启动读到半截 JSON,兜底文件本身变成故障源。这个坑我踩过。
static void writeAtomically(Path file, Object value) throws IOException {
Path tmp = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(tmp, JSON.writeValueAsBytes(value));
try {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (AtomicMoveNotSupportedException ex) {
Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING);
}
}
ATOMIC_MOVE 在部分文件系统上会抛 AtomicMoveNotSupportedException,捕获后降级成普通 move,别让它把刷新线程搞崩。
刷新失败时不清空缓存
private void refreshLoop() {
int failures = 0;
while (!Thread.currentThread().isInterrupted()) {
try {
Map<String, List<Instance>> fresh = client.fetchAll();
if (sane(fresh)) apply(fresh);
failures = 0;
sleep(5_000L);
} catch (Exception ex) {
failures++;
if (failures == 3) log.warn("registry down 3 rounds, running on local snapshot");
sleep(Math.min(30_000L, 1_000L << Math.min(failures, 5))); // 指数退避封顶 30s
}
}
}
private boolean sane(Map<String, List<Instance>> fresh) {
Map<String, List<Instance>> current = snapshot.get();
for (Map.Entry<String, List<Instance>> e : current.entrySet()) {
List<Instance> now = fresh.get(e.getKey());
if (now == null || now.size() * 2 < e.getValue().size()) {
return false; // 服务凭空消失或缩水过半,先当可疑响应
}
}
return true;
}
两条纪律:刷新异常时保持上一份快照不动,绝不能 set(empty);响应要过一遍合理性校验。注册中心重启、选主、配置出错时返回空列表的概率不低,直接覆盖缓存等于自己把流量掐死。
失败要退避,别 5 秒一次硬刷。连续失败三次打一条告警,运维需要知道「现在整个集群在靠本地缓存跑」。
注册中心不可用期间,剔除能力等于零,这个事实必须传导到消费者:连接失败率或超时率超过阈值就熔断,把请求打到剩下的实例上,而不是照着过期列表挨个试。本地缓存兜住的是「拿不到新列表」,兜不住「列表本身已经过期」。
长轮询还是定时轮询
消费者几十个、实例几百个这个量级,5 秒定时轮询足够,实现简单,服务端无状态。上到几百消费者、上千实例,轮询的 QPS 和返回体积开始难受,再考虑长轮询。
public WatchResult watch(String service, long clientRevision, long timeoutMs) {
long deadline = System.currentTimeMillis() + timeoutMs;
while (System.currentTimeMillis() < deadline) {
long current = revisionOf(service);
if (current != clientRevision) {
return new WatchResult(current, getInstances(service));
}
LockSupport.parkNanos(200_000_000L);
}
return new WatchResult(revisionOf(service), Collections.emptyList());
}
这段代码能说明机制,但别直接用在业务线程里。每个挂起请求占一个线程,Tomcat 默认 200 个工作线程,几百个消费者挂上来线程池就没了。要么走 Servlet 3 的 async,要么上 Netty 用 CompletableFuture 挂回调。长轮询换来的实时性,代价是服务端连接状态管理复杂度上升一个档次。
这套实现的边界
单机内存版没有集群一致性,进程挂了注册中心就没了。要做集群,先决定分片还是全量复制:全量复制要求每个节点都能返回完整列表,写路径得选主或者走一致性协议;分片则要求客户端按服务名路由到固定节点,节点故障时的迁移逻辑比注册逻辑本身还复杂。
面试里常被追问的几个点,落到具体实现上其实都有答案:注册中心选 AP 而非 CP 的原因(ZooKeeper 在分区期间可能拒绝服务,消费者拿不到地址列表,可用性直接归零);心跳和主动健康检查的差别(心跳只证明进程还活着,端口能不能处理请求它答不上来,所以软剔除和消费者侧熔断要一起用);本地缓存的时间窗口代价(缓存越久,下线实例被误打的概率越高,刷新间隔本质是在可用性和一致性之间选一个点)。
还有个容易被忽略的细节:所有超时判断统一用注册中心侧时间戳,客户端时钟不可信。