在微服务架构中,服务提供者的地址往往是动态变化的。如果还在用硬编码的 IP 地址进行通信,系统将极其脆弱。本文将深入探讨如何利用 Zookeeper 构建一个具备缓存容错能力的服务发现中间件,让你的后端架构在注册中心故障时依然坚挺。
为什么我们需要动态寻址?
想象一下,你手机里存着朋友家的座机号码。只要他不搬家,这根电话线就是你们沟通的桥梁。但一旦他搬家换了号码,你手里的旧号码就彻底失效了。在微服务的世界里,服务提供者(Provider)的 IP 和端口就是那个“座机号码”。
如果我们将这些地址写死在调用方(Consumer)的配置文件中,就会遭遇经典的运维痛点:
- 扩容失灵:Provider 新增了三台机器,Consumer 完全不知情,流量依然打在旧机器上。
- 死连接:某台 Provider 宕机,Consumer 还在傻傻地往死机上发请求,导致业务报错。
- 迁移噩梦:机房迁移导致 IP 全变,必须重新发布 Consumer 配置。
解决这一问题的核心思路就是动态寻址。这就好比我们上网用的 DNS,你输入 ,DNS 服务器帮你解析出具体的 IP。服务发现做的事情本质相同:通过一个中间件,将服务名动态映射为地址列表。www.example.com
Zookeeper:微服务世界的公告栏
在我们的轻量级 RPC 框架中,Zookeeper(ZK)扮演着“公告栏”的角色。它是一个高可用的分布式协调服务,但我们不需要深究其 ZAB 协议等底层原理,只需把它看作一个存储元数据的中间件即可。
整个工作流程非常直观:
- Provider 启动时:在公告栏贴一张“纸条”,声明“我叫
,我的地址是AddService”。192.168.1.1:9091 - 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 上的信息不能只是一串乱码,它需要一张标准化的“名片”。为此,我们定义了 类。这张名片包含了三个关键字段,简洁且完备,足以让 Consumer 建立连接:ServiceMateData
@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:注册中心挂了怎么办?
这是本篇设计的核心亮点。如果直接调用 获取地址,会面临一个致命风险:ZK 一旦故障,所有 RPC 调用立刻全线瘫痪。Consumer 查不到地址,就像断了线的风筝。ZookeeperServiceRegistry
为了解决这个问题,我们引入了 。它的核心逻辑只有十几行,却极为精妙: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 不行吗?
两个原因缺一不可:
- 性能瓶颈:
并非只在启动时调用,它发生在每一次 RPC 调用链路上。高并发下每秒几千次请求,如果每次都去查 ZK,ZK 会瞬间成为系统的性能短板。缓存将查询开销摊薄至接近零。fetchSeviceList - 可用性风险:ZK 也是分布式系统,网络抖动、主从切换时有发生。没有缓存,ZK 一挂,业务全挂。有了缓存,ZK 短暂不可用时,Consumer 拿着旧地址继续发请求,系统依然存活。
缓存的本质是用“轻微的地址延迟”换取“系统整体的高可用”,这在微服务架构中是非常划算的买卖。
Q2:缓存会不会拿到过期地址?
会的,这是缓存容错的代价,必须正视。如果 Provider 下线了,但 ZK 恰好在那一刻宕机,Consumer 的缓存里还存着旧地址,请求就会失败。
但这并非灾难,因为框架有配套的容错机制:
- RPC 调用失败后,触发重试策略(FailOverPolicy),自动换其他节点重试。
- 地址列表通常是集群部署,一个节点过期不影响整体。
- ZK 恢复后,下一次成功的调用会自动刷新缓存。
真实的行业权衡是:接受“ZK 故障期间极少数请求因地址过期多重试一次”,换取“整体服务继续可用”。宁可偶尔重试,也要保证系统能转,这是分布式设计的黄金法则。
Q3:DefaultServiceRegistry 用了什么设计模式?
答案是装饰器模式。它持有一个 成员变量(即 DefaultServiceRegistry 实例),所有调用先转发给 ServiceRegistry delegate,成功则更新缓存,失败则从缓存兜底。ZookeeperServiceRegistry
| 角色 | 对应实现 |
|---|---|
| 公共接口 | |
| 被装饰的原始对象 | |
| 装饰器 | |
| 新增的能力 | 缓存 + 降级容错 |
这个模式的优点在于开闭原则:不改动 的任何代码,就为它增加了缓存能力。未来要加监控埋点、超时控制,只需再套一层装饰器即可。如果用继承方式(写一个 ZookeeperServiceRegistry),就把缓存逻辑和 ZK 逻辑绑死了,换成 Redis 注册中心时还得再写一个 CachedZookeeperServiceRegistry extends ZookeeperServiceRegistry,代码极度冗余。CachedRedisServiceRegistry
ServiceRegistryManager:SPI 实现可插拔架构
注册中心的实现不应写死为 Zookeeper。如果业务需要换成 Nacos 或 Etcd,难道要改框架源码? 利用 Java SPI 机制解决了这个问题:ServiceRegistryManager
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,启动时扫描所有实现类,按 tag 名字建立映射表。想换注册中心?只需新建一个实现类并标上 @SpiTag("zookeeper"),在配置文件中修改 RedisServiceRegistry implements ServiceRegistry 即可,框架代码零改动。registryType
大白话总结
想象你出门吃饭找火锅店。以前的做法是把店名地址写在纸条上,但店可能搬家或倒闭。新的做法是打开美团搜“火锅”,平台列出附近所有在营业的门店。
这就是动态查找地址:服务提供方主动登记,调用方来平台查名单。但如果美团服务器宕机了呢?你手机里还有“收藏夹”,存着上次查到的店。平台挂了,打开收藏夹,大概率那几家店还开着,你依然能去吃饭。这就是本地缓存兜底。代价是万一某家店刚搬走,你去了扑个空,换一家就行——总比完全找不到吃饭的地方强。
至于美团为什么能支持饿了么的门店?那是框架里另一套“插件机制”(SPI)在保证的。只要门店在任意平台登记过,你都能查得到,不管背后用的是哪家系统。
下一篇:第 5 篇 — 连接管理:找到地址之后,连接如何建立、复用,以及一个请求从发出到收到响应的完整生命周期。
ServiceRegistryZookeeperServiceRegistryDefaultServiceRegistry
浙公网安备 33010602011771号