ZooKeeper 在这个项目中的作用与具体体现

ZooKeeper 的理论介绍

ZooKeeper 是什么

ZooKeeper 是 Apache 开源的分布式协调服务,本质上是一个高可用的分布式数据一致性系统。它基于 ZAB(ZooKeeper Atomic Broadcast)协议 实现,保证在分布式环境下数据的强一致性。

核心抽象模型:一个树形命名空间(类似文件系统),每个节点(ZNode)可以存储少量数据,客户端可以对这些节点进行 CRUD 操作并注册 Watcher 监听变化。

/ (根)
├── dubbo/
│   └── org.example.mall.common.service.GoodsService/
│       ├── providers/
│       │   └── dubbo://192.168.1.5:38090  (临时节点,存地址信息)
│       └── consumers/
│           └── dubbo://192.168.1.6:38093  (临时节点,存地址信息)

ZooKeeper 的三大核心特性

临时节点(Ephemeral Node)

临时节点与客户端会话绑定,会话断开则节点自动删除。这是服务注册的基础:

  • goods_service 启动 → 创建临时节点注册自身地址
  • goods_service 宕机 → 会话断开 → 临时节点被 ZooKeeper 自动删除
  • order_service 通过 Watcher 感知节点变化 → 更新本地可用服务列表

不需要人工维护,服务上下线自动感知。

Watcher 机制(观察者模式)

客户端可以对 ZNode 注册 Watcher,当节点数据变化时 ZooKeeper 主动推送通知。这是服务发现的核心:

  • order_service 对 /dubbo/.../GoodsService/providers/ 注册 Watcher
  • goods_service 上线/下线 → 该目录子节点变化 → ZooKeeper 推送事件给 order_service
  • order_service 收到通知 → 拉取最新的提供方列表,更新本地缓存

事件驱动,非轮询,避免了客户端频繁拉取造成的资源浪费。

集群高可用(Quorum 机制)

ZooKeeper 通常部署奇数个节点(3/5/7),基于 过半写成功(Quorum) 原则保证一致性:

  • 写入必须超过半数节点确认才算成功
  • 只要半数以上节点存活,整个集群就能正常工作
  • 3 节点集群可容忍 1 台宕机,5 节点可容忍 2 台

CAP 理论视角

分布式系统无法同时满足一致性(Consistency)、可用性(Availability)、分区容错性(Partition Tolerance),只能三选二。

组件 选择 说明
ZooKeeper CP(一致性 + 分区容错) 优先保证数据一致,Leader 宕机时短暂不可用
Eureka AP(可用性 + 分区容错) 优先保证可用,各节点数据可能不一致
Nacos 可切换 CP/AP 同时支持两种模式

ZooKeeper 选择 CP 意味着:宁愿短暂拒绝服务,也不返回过期的注册信息。这对 Dubbo 服务调用是合适的——拿不到地址顶多重试,拿到错误地址会导致调用失败。


在这个项目中的理论体现

服务注册过程

1. goods_service 启动
2. Dubbo 自动连接 ZooKeeper,创建会话
3. 在 /dubbo/GoodsService/providers/ 下创建临时顺序节点
4. 节点内容:dubbo://192.168.1.5:38090?version=1.0&...
5. 如果 goods_service 有多个实例 → 该目录下会有多个临时节点

服务发现过程

1. order_service 启动
2. Dubbo 连接 ZooKeeper,读取 /dubbo/GoodsService/providers/ 下的所有子节点
3. 解析出所有提供方地址,构建本地服务列表
4. 对该目录注册 Watcher
5. 提供方列表变化 → ZooKeeper 推送通知 → 更新本地列表

负载均衡在消费方

ZooKeeper 只负责返回所有可用提供方地址,负载均衡由 Dubbo 在消费方本地完成:

  • RandomLoadBalance:随机选择
  • RoundRobinLoadBalance:轮询
  • LeastActiveLoadBalance:最少活跃调用数

为什么 Dubbo 需要用 ZooKeeper

Dubbo 是 RPC 框架,它的核心问题就是:消费者如何知道提供方在哪?

没有注册中心时,消费者只能硬编码提供方地址,这在微服务架构下不可行:

  • 服务实例数量动态变化(扩缩容)
  • 服务实例 IP 可能变化(容器化部署)
  • 需要自动摘除故障节点

ZooKeeper 解决的就是服务注册与发现(Service Registry & Discovery) 这个分布式系统的经典问题,通过临时节点 + Watcher 机制实现了服务上下线的自动感知。

为什么接口不需要 ZooKeeper”


接口的本质:依赖倒置原则(DIP)

SOLID 原则中的 依赖倒置原则(Dependency Inversion Principle) 指出:

高层模块不应依赖低层模块,二者都应依赖抽象。抽象不应依赖细节,细节应依赖抽象。

接口就是那个抽象。它的存在意义是定义契约,消除编译时耦合,而不是参与运行时行为。

// 接口 = 抽象契约,编译后只是 class 文件中一段方法签名表
public interface GoodsService {
    Goods selectById(Integer id);
}
// 实现类 = 具体细节,运行时真实存在,占用内存、监听端口
@DubboService
public class GoodsServiceImpl implements GoodsService {
    @Override
    public Goods selectById(Integer id) { ... }
}

ZooKeeper 注册的是运行时对象(具体实现类的实例),不是编译时类型(接口)。接口根本没有”运行”这个概念,它只是字节码中的元数据。


接口的生命周期 vs ZooKeeper 的生命周期

维度 接口 ZooKeeper 注册信息
存在阶段 编译时(Compile-time) 运行时(Runtime)
存储位置 JAR 包中的 .class 文件 ZooKeeper 服务端内存
生命周期 打包后永久存在 随服务启动创建,宕机后自动消亡
可寻址性 无网络地址 必须有 IP:Port

ZooKeeper 是分布式系统中的运行时基础设施,它解决的是:

  • 哪个 IP 的哪个端口正在提供这个服务?
  • 这些实例现在是否还活着?

接口回答的是完全不同的两个问题:

  • 这个服务提供了哪些方法?
  • 调用这些方法需要传什么参数,返回什么值?

分层架构理论:关注点分离(Separation of Concerns)

按分层架构,一个分布式系统可以划分为:

┌──────────────────────────────────────────┐
│  通信层 (Transport Layer)                │  ← ZooKeeper/Dubbo 在这一层
│  解决:谁在哪里?怎么连?                 │
├──────────────────────────────────────────┤
│  服务层 (Service Layer)                  │  ← 接口实现在这一层
│  解决:具体业务逻辑怎么做?               │
├──────────────────────────────────────────┤
│  契约层 (Contract Layer)                 │  ← 接口定义在这一层
│  解决:双方约定什么格式?                 │
└──────────────────────────────────────────┘

common 模块处于契约层,它只负责”格式约定”:

  • 方法名是什么
  • 参数类型是什么
  • 返回值类型是什么

ZooKeeper 处于通信层,它只负责”地址发现”:

  • 实现这个契约的实例在哪个地址
  • 现在有多少个可用实例

这两层之间是正交的(orthogonal)——互相独立,互不依赖。契约层不需要知道通信层用什么实现,今天是 ZooKeeper,明天换成 Nacos,接口定义不需要改一行代码。


动态代理理论:接口的”活化”发生在消费方

Dubbo 消费方通过 JDK 动态代理 在运行时生成接口的实现类:

// order_service 中写的代码
@DubboReference
private GoodsService goodsService;  // 这是接口类型

运行时实际注入的对象结构:

goodsService (接口引用)
    │
    └── Proxy$0 (JDK 动态代理对象)
          │
          └── InvocationHandler.invoke()
                │
                ├── 从 ZooKeeper 获取地址列表
                ├── 负载均衡选择目标地址
                ├── 序列化请求参数
                ├── 通过 Netty 发送 Dubbo 协议数据包
                ├── 反序列化响应
                └── 返回结果

接口从始至终不需要 ZooKeeper,因为:

  1. 接口本身是静态契约,编译后就是 class 文件中的常量池和方法表
  2. 接口的”活化”(变成可调用的远程代理)发生在消费方启动时,由 Dubbo 框架通过动态代理技术完成
  3. 消费方通过 ZooKeeper 发现的是提供方的地址,不是接口定义
  4. 接口定义通过共享 JAR 包(common 模块)传递,不通过 ZooKeeper

类比:DNS 理论

ZooKeeper 的作用类似于 DNS:

DNS 告诉你:域名 www.example.com → 93.184.216.34
ZooKeeper 告诉你:服务 GoodsService → 192.168.1.5:38090

接口相当于协议规范(HTTP 协议),它定义了”请求格式是什么样的、响应格式是什么样的”。DNS 不需要知道 HTTP 协议的内容,它只管域名解析。同理,ZooKeeper 不需要知道接口定义了什么方法,它只管”服务名 → 地址列表”的映射。


总结:一句话

ZooKeeper 解决的是”在哪”(Where)的问题,接口解决的是”怎么调”(How)的问题。这两个问题在分布式系统理论中属于不同层次——前者是服务发现,后者是服务契约。它们通过不同的机制传递(ZooKeeper 传递地址,JAR 包传递契约),不存在耦合关系。

接口不需要 ZooKeeper 的原因很简单

接口只是”合同”,不是”执行者”

ZooKeeper 注册的是运行中的服务实例(IP + 端口),不是 Java 接口。你把这两者混为一谈了。

打个比方:

接口(GoodsService.java)  =  饭店的菜单(纸上印的菜名和价格)
ZooKeeper 注册中心         =  大众点评(记录哪家饭店正在营业、地址在哪)

你会把菜单印在大众点评上吗?不会。菜单是给顾客看的,大众点评是记录”哪家店开着门、地址在哪”的。


技术上的本质区别

接口(GoodsService) ZooKeeper 注册信息
存在形式 .java 编译成 .class 文件 运行时的网络地址
生命周期 编译时确定,打包在 jar 里 服务启动时注册,关闭时注销
内容 方法签名(入参、返回值) IP + 端口 + 服务名

接口是死的,它不运行。 它只是告诉编译器”这个方法存在”,编译完它的使命就结束了。

ZooKeeper 要的是活的东西。 它需要知道”现在哪台机器上有一个进程在监听 38090 端口,可以接收 RPC 请求”。


具体到这个项目

common 模块的 GoodsService.java:

public interface GoodsService {
    Goods selectById(Integer id);
    boolean reduceStock(Integer goodsId, Integer count);
}

这个东西编译后就是一个 .class 文件,被打包进 common-1.0.0.jar。goods_service 和 order_service 都引用这个 jar,编译器用它来做类型检查。

ZooKeeper 里实际注册的东西是这样的路径:

/dubbo/org.example.mall.common.service.GoodsService/providers/dubbo://192.168.1.5:38090

它记录的是服务名 + 真实的网络地址,而不是接口源码。接口名只是作为 key 来索引,真正的价值在于那个 192.168.1.5:38090——order_service 拿到这个地址才能发起 RPC 调用。


一句话总结

接口不需要 ZooKeeper,就像菜单不需要 GPS 定位。 菜单只管定义”有什么菜”,GPS 管的是”饭店开在哪”。common 模块只定义接口,它不运行、不监听端口、不提供服务,所以和 ZooKeeper 没有任何关系。

ZooKeeper 在这个项目中的作用与具体体现

ZooKeeper 的角色:Dubbo 的注册中心

在这个项目中,ZooKeeper 本身不直接参与业务逻辑,它是作为 Apache Dubbo 的服务注册与发现中心 来使用的。简单来说,ZooKeeper 就是一个”电话本”——服务提供方把自己的地址登记上去,服务消费方通过它找到提供方的地址,然后直接通信。


具体体现:goods_service 与 order_service

服务提供方 — goods_service

暴露服务(GoodsServiceImpl.java):

@DubboService  // 把这个实现类注册到 ZooKeeper
public class GoodsServiceImpl implements GoodsService {
    // 查询商品、扣减库存等...
}

ZooKeeper 配置(application.yml):

dubbo:
  application:
    name: goods_service
  protocol:
    name: dubbo
    port: 38090          # Dubbo 协议监听端口
  registry:
    address: zookeeper://127.0.0.1:2181   # 注册中心地址

启动类:

@EnableDubbo  // 启用 Dubbo,将 @DubboService 标注的类注册到 ZK
public class GoodsServiceApplication { ... }

启动时,goods_service 会向 ZooKeeper 注册一条记录,大致相当于:

“我是 goods_service,提供 GoodsService 接口,你可以通过 dubbo://192.168.x.x:38090 找到我”

服务消费方 — order_service

引用远程服务(OrderServiceImpl.java、CartServiceImpl.java):

@Service
public class OrderServiceImpl implements OrderService {

    @DubboReference  // 从 ZooKeeper 发现并注入远程服务代理
    private GoodsService goodsService;

    // 下单时调用 goodsService.selectById() 查询商品信息
    // 支付时调用 goodsService.reduceStock() 扣减库存
}

ZooKeeper 配置(application.yml):

dubbo:
  application:
    name: order_service
  registry:
    address: zookeeper://127.0.0.1:2181   # 同一个 ZK 地址

启动时,order_service 会:

  1. 连接到 ZooKeeper
  2. 查找”谁提供了 GoodsService 接口”
  3. ZooKeeper 返回 goods_service 的地址(192.168.x.x:38090)
  4. Dubbo 生成一个动态代理注入到 @DubboReference 字段
  5. 之后每次调用 goodsService.selectById() 都会通过 Dubbo 协议发送 RPC 请求到 goods_service

完整调用链路图

┌──────────────────────────────────────────────────────────────────┐
│                        ZooKeeper (127.0.0.1:2181)                │
│   ┌─────────────────────────────────────────────────────────┐    │
│   │  /dubbo/org.example.mall.common.service.GoodsService     │    │
│   │    └── providers/dubbo://192.168.1.5:38090/...           │    │
│   │    └── consumers/dubbo://192.168.1.6:38093/...           │    │
│   └─────────────────────────────────────────────────────────┘    │
└──────────────────────────────────────────────────────────────────┘
         │ ② 注册自己                    │ ③ 发现提供方
         ▼                               ▼
┌──────────────────┐            ┌──────────────────┐
│  goods_service   │            │  order_service   │
│  port: 8090      │◄───────────│  port: 8093      │
│  Dubbo: 38090    │ ④ Dubbo RPC│  Dubbo: 38093    │
│                  │  直接调用   │                  │
│ @DubboService    │            │ @DubboReference  │
│ GoodsServiceImpl │            │ GoodsService     │
│  ├─ selectById() │            │  ├─ selectById() │
│  └─ reduceStock()│            │  └─ reduceStock()│
└──────────────────┘            └──────────────────┘

关键点:ZooKeeper 只负责第②③步(服务注册与发现),第④步的 RPC 调用是 order_service 和 goods_service 直接通信,不经过 ZooKeeper。


为什么 common 模块不需要 ZooKeeper

common 模块只放了 接口定义(GoodsService.java),不涉及任何运行时行为:

// common 中的接口——纯契约,不依赖任何框架
public interface GoodsService {
    Goods selectById(Integer id);
    boolean reduceStock(Integer goodsId, Integer count);
}
  • goods_service 实现这个接口 → 需要 Dubbo + ZooKeeper(暴露服务)
  • order_service 引用这个接口 → 需要 Dubbo + ZooKeeper(发现服务)
  • common 只是定义接口 → 不需要 ZooKeeper(它只是”合同”,不参与通信)

这就像三个人:common 起草合同模板,goods_service 签字提供服务,order_service 签字消费服务,而 ZooKeeper 是公证处——common 不需要去公证处,因为它不签约。

下一篇