《Java 服务端通信工程》5.1 注册中心模型

服务发现的第一步不是选组件,而是定义注册中心里到底存什么。本节用 linkd-v3 的 StaticRegistry 给出服务、端点、修订号和快照的数据模型,讨论租约、健康与失效,并把注册、解析、管理端三条消费路径接起来,附可编译实现与启动顺序。

本节目标:能说清一个注册中心条目包含哪些字段、为什么需要修订号和快照、端点失效时应该删除还是标记不健康,并写出一份可被 NameResolver 和网关共用的最小注册表。
适用版本:JDK 25 + gRPC-Java 1.76.0;注册表本身不依赖 gRPC,只用 JDK 的并发容器。

承接的问题

4.3 之前,LibraryClient 的目标地址还是 libraryd.internal:9093 这样的固定字符串。单机开发没问题,一旦 libraryd 扩到两个实例,固定地址只能指向其中一台:要么在客户端写死两个地址轮询,要么在前面加一层负载均衡器。前者把拓扑写进了代码,后者多了一跳网络和一套要维护的组件。

服务注册中心解决的是「实例在哪里」这个问题。它看起来只是「一个 service 对应一组 host:port」,但真正落到工程里,至少要回答:

  • 一个服务实例用什么标识?host:port 够不够,需不需要 zone、版本、权重?
  • 实例上线、下线、心跳超时分别对应什么状态?删除和「标记不健康」有什么区别?
  • 客户端拿到列表后缓存多久?注册表变了,怎么让客户端知道?
  • 注册中心自己挂了,客户端还能不能调用?

这一节先定义数据模型和状态机,再用 StaticRegistry 给出最小实现;5.2 把它接到 gRPC 的 NameResolver,5.3 讨论 round_robin、pick_first 和健康检查。TLS、鉴权不在本节范围,注册中心的管理端口默认只监听内网。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。本节代码只依赖 JDK,不涉及网络实测。

核心机制一:注册中心的数据模型

把注册中心想成一张「服务名 → 实例列表」的表是不够的。linkd-v3 的模型至少包含四层:

层级字段说明
服务service逻辑名,例如 library,不是域名
实例host、port可连接的端点;port 必须是实际监听端口,不能是 0
元数据zone、version、weight影响路由和权重,不参与连通性判断
版本revision每次变更递增,用于缓存失效和排查

service 用逻辑名而不是 libraryd.internal,是为了把「服务是什么」和「部署在哪」分开。测试环境可以把 library 解析到 127.0.0.1:9093,生产环境解析到两个 Pod,客户端代码不变。

revision 是一个单调递增的整数。客户端缓存解析结果时,同时记住 revision;注册表推送变更时带上新 revision,客户端发现比自己缓存的小就忽略,避免乱序消息把新列表覆盖成旧列表。revision 不需要全局唯一,单个注册表实例内单调即可;多实例部署时用「版本号 + 注册表实例 ID」组成复合版本。

端点要不要带权重?linkd-v3 的第一版不带:round_robin 按连接均分,权重只会让问题复杂化。等出现「新实例预热、老实例多扛一点」的需求,再在 Attributes 里加权重,而不是改 Endpoint 的构造签名。5.2 会展示如何把 zone 放进 Attributes,权重同理。

健康状态不是端点的主键的一部分。一个端点可能「注册了但没准备好」,也可能「曾经健康、现在探活失败」。前者不应该被解析出来,后者应该被降权或剔除。把健康状态塞进 Endpoint 的 equals/hashCode 会让同一个端点在健康和不健康之间变成两个不同的 key,注册表很快会出现重复条目。

核心机制二:StaticRegistry 的最小实现

StaticRegistry 是 comm-core 里的一个进程内注册表,用于本地开发、单元测试和单机部署。它不解决跨进程复制,但把数据模型、不可变快照和 revision 语义固定下来,后面换成真正的注册中心时,接口不用改。

package com.plume.linkd.v3.core.registry;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;

public final class StaticRegistry {

    public record Endpoint(String host, int port) {
        public Endpoint {
            Objects.requireNonNull(host, "host");
            if (host.isBlank()) {
                throw new IllegalArgumentException("host is blank");
            }
            if (port <= 0 || port > 65535) {
                throw new IllegalArgumentException("port out of range: " + port);
            }
        }
    }

    public record Snapshot(long revision, Map<String, List<Endpoint>> services) {
    }

    private final ConcurrentHashMap<String, List<Endpoint>> services =
            new ConcurrentHashMap<>();
    private final ConcurrentHashMap<Endpoint, Boolean> health =
            new ConcurrentHashMap<>();
    private final AtomicLong revision = new AtomicLong();

    public void register(String service, String host, int port) {
        requireService(service);
        Endpoint endpoint = new Endpoint(host, port);
        services.compute(service, (key, current) -> {
            List<Endpoint> next = current == null
                    ? new ArrayList<>()
                    : new ArrayList<>(current);
            if (!next.contains(endpoint)) {
                next.add(endpoint);
            }
            return List.copyOf(next);
        });
        revision.incrementAndGet();
    }

    public boolean deregister(String service, String host, int port) {
        requireService(service);
        Endpoint endpoint = new Endpoint(host, port);
        AtomicBoolean removed = new AtomicBoolean();
        services.computeIfPresent(service, (key, current) -> {
            List<Endpoint> next = new ArrayList<>(current);
            removed.set(next.remove(endpoint));
            return next.isEmpty() ? null : List.copyOf(next);
        });
        if (removed.get()) {
            health.remove(endpoint);
            revision.incrementAndGet();
        }
        return removed.get();
    }

    public void markHealth(Endpoint endpoint, boolean healthy) {
        Objects.requireNonNull(endpoint, "endpoint");
        health.put(endpoint, healthy);
    }

    public List<Endpoint> resolve(String service) {
        requireService(service);
        return services.getOrDefault(service, List.of()).stream()
                .filter(endpoint -> health.getOrDefault(endpoint, true))
                .toList();
    }

    public Snapshot snapshot() {
        return new Snapshot(revision.get(), Map.copyOf(services));
    }

    private static void requireService(String service) {
        if (service == null || service.isBlank()) {
            throw new IllegalArgumentException("service is blank");
        }
    }
}

这段代码有四个设计选择:

  • 列表不可变。 register 和 deregister 都在 compute 里创建新列表,再用 List.copyOf 冻结。读线程拿到的列表不会被写线程改掉,resolve 可以无锁返回。
  • compute 保证同一服务的串行更新。 ConcurrentHashMap 对同一个 key 的 compute 是原子的,不会出现两个线程同时读到旧列表、各自加一个端点、最后丢一个的丢失更新。
  • 空服务删除映射。 deregister 把最后一个端点删掉时返回 null,computeIfPresent 会移除整个 key;resolve 因此返回空列表,而不是一个空的可变列表。
  • revision 在变更后递增。 先改数据再递增版本,读到的 revision 一定对应一个完整的列表;反过来会出现「版本已变、数据还没写完」的窗口。

Snapshot 用 Map.copyOf 复制外层 map,内层列表已经是不可变的,所以快照整体是只读的。管理端 9095 可以用快照渲染 JSON,NameResolver 也可以基于快照做一次解析。Map.copyOf 的迭代顺序不保证,渲染 JSON 前如果要稳定输出,按 key 排序。

核心机制三:租约、健康与失效

真实的注册中心不会只靠 register/deregister 两个动作,还需要处理「进程崩了但没来得及注销」的情况。常见做法是租约(lease):

  1. 实例启动时注册,同时拿到一个 TTL,例如 15 秒。
  2. 实例每隔 5 秒续约;注册中心在 TTL 内没收到续约,就把实例标记为过期。
  3. 过期实例先从解析结果里剔除,再保留一段时间用于排查,最后彻底删除。

租约的关键是「客户端看到的列表」和「注册中心保留的记录」分开。直接删除会让排障时看不到「它曾经在这里」;只标记不剔除则会让流量打到已经死掉的实例。linkd-v3 的选择是:

  • 解析结果只包含健康且未过期的端点。
  • 管理端快照保留过期端点,并带上状态和时间戳。
  • 删除是延迟的,至少保留一个排障窗口。

StaticRegistry 为了保持最小,没有实现 TTL;单机部署时进程退出会调用 deregister,异常退出由进程重启覆盖。它保留了 markHealth(Endpoint, boolean) 和解析时的健康过滤:resolve 只返回 health 里没有被标记为 false 的端点,管理端的 snapshot 仍然能看到全量。这样健康检查只改一个布尔值,不需要反复注册和注销。把它换成带租约的实现时,register 和 resolve 的签名不变,resolve 内部多一层过滤。这就是先固定接口的价值。

健康状态和租约是两件事。租约回答「实例还在不在」,健康检查回答「实例能不能服务」。一个实例可能进程活着、租约正常,但数据库连不上,健康检查返回 NOT_SERVING。这种情况下不应该删除端点,而应该把它从解析结果里剔除或降权;等健康恢复,再放回去。5.3 会给出服务端 HealthStatusManager 和客户端探活的代码。

推送还是拉取?两种都常见:

模式客户端如何知道变更优点缺点
拉取定时 resolve 或 gRPC 定期 refresh实现简单,注册中心压力可控有延迟,频率难调
推送注册中心长连接/回调通知变更快连接管理复杂,乱序要版本号

linkd-v3 的第一版用「gRPC NameResolver 定时 refresh + 注册表 revision」的拉取模型:实现简单,5 秒的收敛延迟对内部服务可以接受。等注册表规模上去,再在 StaticRegistry 外面加 watch,把变更推给 resolver;接口仍然是「给我一份地址列表」。

核心机制四:三条消费路径

同一份注册数据会被三个地方消费,它们的缓存策略不同:

消费方读取方式缓存失败行为
gRPC NameResolverresolve(service)由 gRPC 的 LB 持有解析失败保留旧地址,进入 TRANSIENT_FAILURE
HTTP 网关resolve(service)进程内短期缓存缓存过期且注册表不可用时拒绝新请求
管理端 9095snapshot()不缓存直接返回错误

NameResolver 的缓存由 gRPC 管理,业务代码不要自己再包一层「地址列表缓存」,否则会出现两份状态不一致。网关需要自己缓存,因为它不在 gRPC 的解析链路里;缓存要有 TTL 和「注册表不可用时是否继续用旧值」的策略,不能无限期使用过期地址。

管理端读 snapshot() 是为了排障,不参与调用链。它应该展示 revision、每个服务的端点数、每个端点的注册时间;不要展示 token、内部 IP 之外的可利用信息。9095 默认只监听 127.0.0.1 或内网,公网暴露管理端是常见事故。

在 linkd-v3 里落地:注册、解析与启动顺序

linkd-v3 的启动顺序是「先注册自己,再启动 gRPC 服务,最后对外宣告就绪」:

package com.plume.linkd.v3.server;

import com.plume.linkd.v3.core.registry.StaticRegistry;
import io.grpc.Server;
import java.io.IOException;

public final class LinkdV3Bootstrap {

    public static void main(String[] args) throws IOException {
        StaticRegistry registry = new StaticRegistry();
        String host = System.getenv().getOrDefault("LINKD_HOST", "127.0.0.1");
        int grpcPort = Integer.parseInt(
                System.getenv().getOrDefault("LINKD_GRPC_PORT", "9093"));

        registry.register("library", host, grpcPort);
        Server server = LibraryGrpcServer.start(grpcPort, new LibraryServiceImpl());
        System.out.println("linkd-v3 started, registry revision="
                + registry.snapshot().revision());

        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            registry.deregister("library", host, grpcPort);
            server.shutdown();
            try {
                if (!server.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
                    server.shutdownNow();
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                server.shutdownNow();
            }
        }, "linkd-shutdown"));
    }
}

顺序很重要:先注册再 start(),客户端可能在服务真正监听之前就解析到地址,第一次连接会失败并重试;先 start() 再注册,则服务已经能处理请求,但注册表里还没有它,窗口更短。更好的做法是启动后先做一次自检(端口可连、依赖可用),再注册并标记健康。本节为了聚焦注册表,省略了自检。

管理端 9095 可以用快照渲染一段 JSON。预期输出如下(revision 从 1 开始,端点数随注册次数变化):

{"revision":1,"services":{"library":[{"host":"127.0.0.1","port":9093}]}}

这段 JSON 不是 gRPC 的响应,而是 9095 的管理接口;它不参与 9093 的调用链,也不应该被客户端当作服务发现入口。客户端的入口是 5.2 的 static-registry:///library。

常见错误与边界

错误一:把注册中心当成配置中心。 注册表里只放「实例在哪里」和少量路由元数据;超时、重试、限流阈值属于配置,变更频率和回滚策略不同。混在一起会让一次配置发布触发全量服务重连。

错误二:解析结果缓存永不过期。 实例下线后,客户端还在用旧地址,表现为持续 UNAVAILABLE。缓存必须有 TTL,并且在解析失败时决定「用旧值」还是「快速失败」;两个策略都要有指标。

错误三:注册 0.0.0.0 或端口 0。 0.0.0.0 是监听地址,不是可连接地址;客户端拿到它之后会连到自己。端口 0 只用于测试随机端口,注册前必须换成实际端口。

错误四:健康检查失败就删除端点。 删除会让注册表丢失排障信息,也会让实例恢复后需要重新注册。正确做法是先标记不健康、从解析结果剔除,保留记录一个窗口。

错误五:忽略 revision 乱序。 推送模式下,旧消息晚到会把新列表覆盖成旧列表。客户端要比较 revision,只接受更大的版本;注册中心要保证同一服务的 revision 单调。

边界:注册中心不是强一致的。 多实例注册中心之间复制有延迟,客户端可能短暂看到不一致的列表。服务发现能接受最终一致,前提是客户端有重试和健康检查兜底;不要在这里追求分布式事务。

边界:注册表不解决负载均衡。 它只提供候选地址;选哪一个由 gRPC 的 LB 策略决定。把权重、zone、健康都塞进注册表,会让注册表变成半个负载均衡器,职责过重。

小结

  • 注册中心的数据模型至少包含服务名、端点、元数据和 revision;service 用逻辑名,端点用可连接的 host:port。
  • StaticRegistry 用 ConcurrentHashMap.compute 保证同一服务的串行更新,用 List.copyOf 提供不可变列表,用 revision 标识快照版本。
  • 租约回答「实例还在不在」,健康检查回答「能不能服务」;解析结果只包含健康且未过期的端点,管理端保留完整快照。
  • 拉取模型实现简单,推送模型收敛快;无论哪种,客户端都要用 revision 防止乱序。
  • 三条消费路径(NameResolver、网关、管理端)的缓存策略不同,不能共用一份「永久缓存」。
  • 启动顺序建议「启动服务 → 自检 → 注册 → 宣告就绪」,停止时先注销再关闭。

下一节把 StaticRegistry 接到 gRPC 的名字解析 SPI:定义 static-registry scheme,实现 NameResolverProvider 和 NameResolver.Listener2,让 forTarget("static-registry:///library") 真正解析出地址。

阅读导航:上一节 4.3 Codec、压缩与消息上限 | 下一节 5.2 自定义 NameResolver

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收