在微服务架构中,服务提供者的地址往往是动态变化的。如果还在用硬编码的 IP 地址进行通信,系统将极其脆弱。本文将深入探讨如何利用 Zookeeper 构建一个具备缓存容错能力的服务发现中间件,让你的后端架构在注册中心故障时依然坚挺。

为什么我们需要动态寻址?

想象一下,你手机里存着朋友家的座机号码。只要他不搬家,这根电话线就是你们沟通的桥梁。但一旦他搬家换了号码,你手里的旧号码就彻底失效了。在微服务的世界里,服务提供者(Provider)的 IP 和端口就是那个“座机号码”。

如果我们将这些地址写死在调用方(Consumer)的配置文件中,就会遭遇经典的运维痛点:

  • 扩容失灵:Provider 新增了三台机器,Consumer 完全不知情,流量依然打在旧机器上。
  • 死连接:某台 Provider 宕机,Consumer 还在傻傻地往死机上发请求,导致业务报错。
  • 迁移噩梦:机房迁移导致 IP 全变,必须重新发布 Consumer 配置。

解决这一问题的核心思路就是动态寻址。这就好比我们上网用的 DNS,你输入 www.example.com,DNS 服务器帮你解析出具体的 IP。服务发现做的事情本质相同:通过一个中间件,将服务名动态映射为地址列表。

Zookeeper:微服务世界的公告栏

在我们的轻量级 RPC 框架中,Zookeeper(ZK)扮演着“公告栏”的角色。它是一个高可用的分布式协调服务,但我们不需要深究其 ZAB 协议等底层原理,只需把它看作一个存储元数据的中间件即可。

整个工作流程非常直观:

  1. Provider 启动时:在公告栏贴一张“纸条”,声明“我叫 AddService,我的地址是 192.168.1.1:9091”。
  2. Consumer 调用时:去公告栏查看“谁提供了 AddService?”,拿到地址列表后,选择其中一个发起请求。

在代码层面,我们通过 Curator 客户端封装了这两个核心动作。初始化时建立连接并配置指数退避重试策略,所有的服务信息都统一存储在 /rpc 路径下,保证了命名空间的隔离性。

// Provider 启动时调用,在 ZK 上注册自己
@Override
public void registry(ServiceMateData mateData) throws Exception {
ServiceInstance<ServiceMateData> serviceInstance = ServiceInstance.<ServiceMateData>builder()
  .address(mateData.getServiceName())
  .port(mateData.getPort())
  .name(mateData.getServiceName())
  .payload(mateData)
  .build();
  serviceDiscovery.registerService(serviceInstance);
  }
  // Consumer 发起调用时,查询 ZK 上的地址列表
  @Override
  public List<ServiceMateData> fetchSeviceList(String serviceName) throws Exception {
    return serviceDiscovery.queryForInstances(serviceName)
    .stream()
    .map(ServiceInstance::getPayload)
    .toList();
    }
client = CuratorFrameworkFactory.builder()
.connectString(config.getConnectString())
.sessionTimeoutMs(60000)
.connectionTimeoutMs(3000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();

ServiceMateData:服务的名片

Provider 注册到 ZK 上的信息不能只是一串乱码,它需要一张标准化的“名片”。为此,我们定义了 ServiceMateData 类。这张名片包含了三个关键字段,简洁且完备,足以让 Consumer 建立连接:

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ServiceMateData {
private String serviceName;  // 服务名,如 "com.baichen.demo.AddService"
private String host;         // 机器 IP
private int port;            // 监听端口
}

当 Consumer 从 ZK 获取到这张名片的 JSON 序列化数据后,就能精准地知道去哪里、以什么端口连接这个服务。这种数据结构的设计是后端架构中API契约精神的体现。

DefaultServiceRegistry:注册中心挂了怎么办?

这是本篇设计的核心亮点。如果直接调用 ZookeeperServiceRegistry 获取地址,会面临一个致命风险:ZK 一旦故障,所有 RPC 调用立刻全线瘫痪。Consumer 查不到地址,就像断了线的风筝。

为了解决这个问题,我们引入了 DefaultServiceRegistry。它的核心逻辑只有十几行,却极为精妙:

@Slf4j
public class DefaultServiceRegistry implements ServiceRegistry {
private final ServiceRegistry delegate;  // 被装饰的"真正"注册中心
private Map<String, List<ServiceMateData>> serviceCache = new ConcurrentHashMap<>();
  @Override
  public List<ServiceMateData> fetchSeviceList(String serviceName) {
    try {
    // 正常路径:调 ZK 拿最新地址,顺手更新缓存
    List<ServiceMateData> serviceMateData = delegate.fetchSeviceList(serviceName);
      serviceCache.put(serviceName, serviceMateData);
      return serviceMateData;
      } catch (Exception e) {
      // 降级路径:ZK 挂了,从本地缓存返回上次的地址列表
      return serviceCache.getOrDefault(serviceName, new ArrayList<>());
        }
        }
        }

逻辑解析:

  • ZK 正常时:调用 delegate.fetchSeviceList() 拿到最新地址,存入本地缓存 serviceCache,并返回结果。
  • ZK 异常时:捕获异常,从 serviceCache 返回上次缓存的地址列表,让框架继续工作。

这里巧妙地运用了装饰器模式。DefaultServiceRegistry 包裹了一个 ServiceRegistry(实际是 ZookeeperServiceRegistry),在不改变对外接口的前提下,叠加了缓存容错能力。调用方完全感知不到这层包装的存在。

Consumer
   └─ fetchSeviceList("AddService")
         ↓
   DefaultServiceRegistry          ← 装饰器:负责缓存 + 容错
         ↓ (正常) / ↑ (ZK 挂了,走缓存)
   ZookeeperServiceRegistry        ← 真正的 ZK 访问实现
         ↓
   Zookeeper 集群
[AFFILIATE_SLOT_1]

设计追问:缓存层的性能与一致性权衡

Q1:为什么要加缓存层?直接调 ZK 不行吗?

两个原因缺一不可:

  • 性能瓶颈:fetchSeviceList 并非只在启动时调用,它发生在每一次 RPC 调用链路上。高并发下每秒几千次请求,如果每次都去查 ZK,ZK 会瞬间成为系统的性能短板。缓存将查询开销摊薄至接近零。
  • 可用性风险:ZK 也是分布式系统,网络抖动、主从切换时有发生。没有缓存,ZK 一挂,业务全挂。有了缓存,ZK 短暂不可用时,Consumer 拿着旧地址继续发请求,系统依然存活。

缓存的本质是用“轻微的地址延迟”换取“系统整体的高可用”,这在微服务架构中是非常划算的买卖。

Q2:缓存会不会拿到过期地址?

会的,这是缓存容错的代价,必须正视。如果 Provider 下线了,但 ZK 恰好在那一刻宕机,Consumer 的缓存里还存着旧地址,请求就会失败。

但这并非灾难,因为框架有配套的容错机制:

  • RPC 调用失败后,触发重试策略(FailOverPolicy),自动换其他节点重试。
  • 地址列表通常是集群部署,一个节点过期不影响整体。
  • ZK 恢复后,下一次成功的调用会自动刷新缓存。

真实的行业权衡是:接受“ZK 故障期间极少数请求因地址过期多重试一次”,换取“整体服务继续可用”。宁可偶尔重试,也要保证系统能转,这是分布式设计的黄金法则。

Q3:DefaultServiceRegistry 用了什么设计模式?

答案是装饰器模式。它持有一个 DefaultServiceRegistry 成员变量(即 ServiceRegistry delegate 实例),所有调用先转发给 ZookeeperServiceRegistry,成功则更新缓存,失败则从缓存兜底。

角色对应实现
公共接口
被装饰的原始对象
装饰器
新增的能力缓存 + 降级容错

这个模式的优点在于开闭原则:不改动 ZookeeperServiceRegistry 的任何代码,就为它增加了缓存能力。未来要加监控埋点、超时控制,只需再套一层装饰器即可。如果用继承方式(写一个 CachedZookeeperServiceRegistry extends ZookeeperServiceRegistry),就把缓存逻辑和 ZK 逻辑绑死了,换成 Redis 注册中心时还得再写一个 CachedRedisServiceRegistry,代码极度冗余。

ServiceRegistryManager:SPI 实现可插拔架构

注册中心的实现不应写死为 Zookeeper。如果业务需要换成 Nacos 或 Etcd,难道要改框架源码?ServiceRegistryManager 利用 Java SPI 机制解决了这个问题:

public class ServiceRegistryManager {
private final Map<String, ServiceRegistry> registries = new HashMap<>();
  public ServiceRegistryManager() {
  // 通过 Java SPI 加载所有 ServiceRegistry 实现
  ServiceLoader<ServiceRegistry> loader = ServiceLoader.load(ServiceRegistry.class);
    for (ServiceRegistry registry : loader) {
    // 读取实现类上的 @SpiTag 注解,获取名称(如 "zookeeper")
    SpiTag annotation = registry.getClass().getAnnotation(SpiTag.class);
    if (annotation == null) {
    log.warn("ServiceRegistry {} does not have SpiTag annotation, skipping",
    registry.getClass().getName());
    continue;
    }
    String name = annotation.value().toLowerCase();
    registries.put(name, registry);
    }
    }
    public ServiceRegistry getServiceRegistry(String type) {
    ServiceRegistry registry = registries.get(type.toLowerCase());
    if (registry == null) {
    throw new IllegalArgumentException("Unsupported registry type: " + type);
    }
    return registry;
    }
    }

ZookeeperServiceRegistry 上标注了 @SpiTag("zookeeper"),启动时扫描所有实现类,按 tag 名字建立映射表。想换注册中心?只需新建一个实现类并标上 RedisServiceRegistry implements ServiceRegistry,在配置文件中修改 registryType 即可,框架代码零改动。

[AFFILIATE_SLOT_2]

大白话总结

想象你出门吃饭找火锅店。以前的做法是把店名地址写在纸条上,但店可能搬家或倒闭。新的做法是打开美团搜“火锅”,平台列出附近所有在营业的门店。

这就是动态查找地址:服务提供方主动登记,调用方来平台查名单。但如果美团服务器宕机了呢?你手机里还有“收藏夹”,存着上次查到的店。平台挂了,打开收藏夹,大概率那几家店还开着,你依然能去吃饭。这就是本地缓存兜底。代价是万一某家店刚搬走,你去了扑个空,换一家就行——总比完全找不到吃饭的地方强。

至于美团为什么能支持饿了么的门店?那是框架里另一套“插件机制”(SPI)在保证的。只要门店在任意平台登记过,你都能查得到,不管背后用的是哪家系统。

下一篇:第 5 篇 — 连接管理:找到地址之后,连接如何建立、复用,以及一个请求从发出到收到响应的完整生命周期。

ServiceRegistryZookeeperServiceRegistryDefaultServiceRegistry