一个能用的注册中心只需要三块:并发注册表、心跳剔除线程、消费者本地缓存,剩下的工程量基本都花在防误杀和注册中心宕机这两件事上。

注册表就是一张带版本号的并发 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-secondslease-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 在分区期间可能拒绝服务,消费者拿不到地址列表,可用性直接归零);心跳和主动健康检查的差别(心跳只证明进程还活着,端口能不能处理请求它答不上来,所以软剔除和消费者侧熔断要一起用);本地缓存的时间窗口代价(缓存越久,下线实例被误打的概率越高,刷新间隔本质是在可用性和一致性之间选一个点)。

还有个容易被忽略的细节:所有超时判断统一用注册中心侧时间戳,客户端时钟不可信。