基于本文回答
0
评论

如何利用 ZooKeeper 生成全局唯一 ID?

知识点图片

利用 ZooKeeper 生成全局唯一 ID 是一种经典且可靠的方法。其核心思想是利用 ZooKeeper 的顺序节点临时节点 特性来生成一个不会重复的、可排序的 ID。

下面我将从原理、具体实现方案、代码示例以及优缺点等方面进行详细阐述。

核心原理

  1. 顺序节点:当在 ZooKeeper 中创建一个节点时,如果指定其类型为 PERSISTENT_SEQUENTIAL(持久顺序节点)或 EPHEMERAL_SEQUENTIAL(临时顺序节点),ZooKeeper 会自动在节点路径后附加一个单调递增的序列号(例如 /id_generator/node_ 可能会变成 /id_generator/node_0000000001)。这个序列号由 ZooKeeper 服务器集群统一维护,保证在同一个父节点下是唯一的
  2. 高可用性:ZooKeeper 本身是一个集群(Ensemble),通过 ZAB 协议保证了数据的一致性。因此,即使部分服务器宕机,生成的 ID 也不会重复。
  3. 时钟同步:ZooKeeper 的序列号是基于其服务器自身的逻辑时钟生成的,不依赖于客户端的系统时间,避免了因客户端时间回拨导致 ID 重复的问题。

常见的实现方案

主要有两种模式:简单顺序节点模式分段缓存模式

方案一:简单顺序节点模式(最直接)

这是最基础、最容易理解的方式。每次需要 ID 时,就向 ZooKeeper 的一个固定父节点下创建一个顺序子节点,然后从该节点的名称中提取序列号作为 ID。

步骤:

  1. 在 ZooKeeper 中创建一个持久节点作为父节点,例如 /unique_id
  2. 当需要生成一个 ID 时,客户端在 /unique_id 下创建一个 EPHEMERAL_SEQUENTIAL 类型的子节点,节点名前缀自定义,如 id-
    bash
    # 创建后的节点可能类似于:
    /unique_id/id-0000000001
    /unique_id/id-0000000002
  3. 客户端获取到创建成功的节点路径后,解析出后面的序列号(0000000001),将其转换为 Long 型或其他数字类型,即为生成的唯一 ID。
  4. (可选)如果使用的是临时节点,在完成 ID 获取后,可以立即删除该节点以清理数据。但如果客户端崩溃,节点也会自动删除,不会造成垃圾数据。如果使用持久节点,则需要考虑定期清理策略。

优点:

  • 实现简单,逻辑清晰。
  • 绝对保证全局唯一性。

缺点:

  • 性能瓶颈:每次生成 ID 都需要与 ZooKeeper 进行一次网络交互(创建节点)。在高并发场景下,这会成为系统的瓶颈,并且给 ZooKeeper 集群带来巨大压力。
  • ID 不连续:由于网络延迟、重试等原因,生成的 ID 可能不是严格连续的,但整体是趋势递增的。

方案二:分段缓存模式(推荐用于生产环境)

为了解决方案一的性能问题,我们引入“缓存”和“分段”的概念。预先从 ZooKeeper 获取一个较大的 ID 段(例如 1000 个 ID),然后在本地内存中进行分配。当这段 ID 用完时,再向 ZooKeeper 申请下一个段。

这类似于数据库的自增主键批量申请。

步骤:

  1. 初始化:在 ZooKeeper 上创建一个持久节点,用于存储当前已分配的最大 ID 值,例如 /id_generator/segment,并初始化其数据为 0
  2. 申请 ID 段
    • 客户端在申请 ID 前,先尝试更新 /id_generator/segment 节点。这是一个原子操作(使用 setData 并设置版本号或 compare-and-set)。
    • 更新数据时,采用类似 current_max_id + segment_size 的逻辑。例如,当前值是 0,段大小是 1000,则尝试将其更新为 1000。如果更新成功,则表明当前客户端获得了 [1, 1000] 这个段的 ID 使用权。
    • 如果更新失败(因为其他客户端同时修改了它),则重试此过程。
  3. 本地分配:客户端成功获得一个段(比如 [current_start, current_end])后,就在自己的内存中维护两个变量:currentId(初始为 current_start)和 maxId(为 current_end)。每次需要 ID 时,直接返回 currentId++
  4. 段用尽与续租:当 currentId > maxId 时,表示该段已用完,客户端需要重新向 ZooKeeper 申请下一个段。为了应对客户端崩溃导致 ID 段浪费的情况,可以为每个段设置一个短暂的“租约”(Lease)。客户端需要定期在 ZooKeeper 上刷新这个租约。如果租约超时,其他客户端可以回收这个未用完的段。(这一步增加了复杂性,简单的实现可以先忽略租约,依赖重启后段的重置)

优点:

  • 高性能:将网络 IO 次数从“每生成一个 ID 一次”降低到“每生成 N 个 ID 一次”,极大地提升了吞吐量,适合高并发场景。
  • 仍然保持了全局唯一性。

缺点:

  • 实现复杂度较高。
  • ID 不是绝对实时有序的,但在一个段内是连续的。
  • 存在“惊群效应”的风险(所有客户端同时去申请新段),需要通过合理的重试策略和随机退避来避免。

代码示例(基于方案一)

以下是一个使用 Apache Curator 框架(ZooKeeper 的客户端库)实现简单顺序节点模式的示例。

添加 Maven 依赖:

xml
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.5.0</version> <!-- 请使用最新版本 -->
</dependency>
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>5.5.0</version>
</dependency>

Java 代码:

java
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ZKUniqueIdGenerator {

    private CuratorFramework client;
    private final String idNodePath = "/unique_id";
    private final String nodePrefix = "id-";

    public ZKUniqueIdGenerator(String zkConnectString) {
        // 创建 Curator 客户端
        this.client = CuratorFrameworkFactory.builder()
                .connectString(zkConnectString)
                .sessionTimeoutMs(5000)
                .connectionTimeoutMs(3000)
                .retryPolicy(new ExponentialBackoffRetry(1000, 3))
                .build();
        this.client.start();

        // 确保父节点存在
        try {
            if (client.checkExists().forPath(idNodePath) == null) {
                client.create().creatingParentsIfNeeded().forPath(idNodePath);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    /**
     * 生成一个全局唯一的 ID
     * @return 生成的唯一 ID
     */
    public long generateId() throws Exception {
        // 创建一个临时顺序节点
        String path = client.create()
                .creatingParentsIfNeeded()
                .withMode(CreateMode.EPHEMERAL_SEQUENTIAL)
                .forPath(idNodePath + "/" + nodePrefix);

        // 从路径中提取序号
        String sequenceStr = path.substring(path.lastIndexOf(nodePrefix) + nodePrefix.length());
        return Long.parseLong(sequenceStr);
    }

    public void close() {
        if (client != null) {
            client.close();
        }
    }

    // 测试
    public static void main(String[] args) throws Exception {
        ZKUniqueIdGenerator generator = new ZKUniqueIdGenerator("localhost:2181");

        ExecutorService executor = Executors.newFixedThreadPool(10);
        for (int i = 0; i < 10; i++) {
            executor.submit(() -> {
                try {
                    long id = generator.generateId();
                    System.out.println("Generated ID: " + id + " by " + Thread.currentThread().getName());
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
        }
        executor.shutdown();
        Thread.sleep(1000);
        generator.close();
    }
}

总结与选择

特性 简单顺序节点模式 分段缓存模式
实现难度 简单 复杂
性能 低(每次网络 IO) 高(批量获取)
吞吐量 有限制
ID 连续性 不连续 段内连续
适用场景 并发量不高、对实现简单性要求高的场景 高并发、大规模分布式系统

建议:

  • 对于大多数中小型项目或并发量不高的场景,方案一 因其简单易用而足够胜任。
  • 对于大型互联网等高并发场景,强烈推荐使用方案二(分段缓存模式),并在此基础上考虑增加段租约机制以防止资源浪费。

无论哪种方案,利用 ZooKeeper 生成 ID 都是一种强一致性、高可用的解决方案,非常适合作为分布式系统的基石服务。

右滑查看面试常问