如何利用 ZooKeeper 生成全局唯一 ID?
利用 ZooKeeper 生成全局唯一 ID 是一种经典且可靠的方法。其核心思想是利用 ZooKeeper 的顺序节点 和 临时节点 特性来生成一个不会重复的、可排序的 ID。
下面我将从原理、具体实现方案、代码示例以及优缺点等方面进行详细阐述。
核心原理
- 顺序节点:当在 ZooKeeper 中创建一个节点时,如果指定其类型为
PERSISTENT_SEQUENTIAL(持久顺序节点)或EPHEMERAL_SEQUENTIAL(临时顺序节点),ZooKeeper 会自动在节点路径后附加一个单调递增的序列号(例如/id_generator/node_可能会变成/id_generator/node_0000000001)。这个序列号由 ZooKeeper 服务器集群统一维护,保证在同一个父节点下是唯一的。 - 高可用性:ZooKeeper 本身是一个集群(Ensemble),通过 ZAB 协议保证了数据的一致性。因此,即使部分服务器宕机,生成的 ID 也不会重复。
- 时钟同步:ZooKeeper 的序列号是基于其服务器自身的逻辑时钟生成的,不依赖于客户端的系统时间,避免了因客户端时间回拨导致 ID 重复的问题。
常见的实现方案
主要有两种模式:简单顺序节点模式 和 分段缓存模式。
方案一:简单顺序节点模式(最直接)
这是最基础、最容易理解的方式。每次需要 ID 时,就向 ZooKeeper 的一个固定父节点下创建一个顺序子节点,然后从该节点的名称中提取序列号作为 ID。
步骤:
- 在 ZooKeeper 中创建一个持久节点作为父节点,例如
/unique_id。 - 当需要生成一个 ID 时,客户端在
/unique_id下创建一个EPHEMERAL_SEQUENTIAL类型的子节点,节点名前缀自定义,如id-。bash# 创建后的节点可能类似于: /unique_id/id-0000000001 /unique_id/id-0000000002 - 客户端获取到创建成功的节点路径后,解析出后面的序列号(
0000000001),将其转换为 Long 型或其他数字类型,即为生成的唯一 ID。 - (可选)如果使用的是临时节点,在完成 ID 获取后,可以立即删除该节点以清理数据。但如果客户端崩溃,节点也会自动删除,不会造成垃圾数据。如果使用持久节点,则需要考虑定期清理策略。
优点:
- 实现简单,逻辑清晰。
- 绝对保证全局唯一性。
缺点:
- 性能瓶颈:每次生成 ID 都需要与 ZooKeeper 进行一次网络交互(创建节点)。在高并发场景下,这会成为系统的瓶颈,并且给 ZooKeeper 集群带来巨大压力。
- ID 不连续:由于网络延迟、重试等原因,生成的 ID 可能不是严格连续的,但整体是趋势递增的。
方案二:分段缓存模式(推荐用于生产环境)
为了解决方案一的性能问题,我们引入“缓存”和“分段”的概念。预先从 ZooKeeper 获取一个较大的 ID 段(例如 1000 个 ID),然后在本地内存中进行分配。当这段 ID 用完时,再向 ZooKeeper 申请下一个段。
这类似于数据库的自增主键批量申请。
步骤:
- 初始化:在 ZooKeeper 上创建一个持久节点,用于存储当前已分配的最大 ID 值,例如
/id_generator/segment,并初始化其数据为0。 - 申请 ID 段:
- 客户端在申请 ID 前,先尝试更新
/id_generator/segment节点。这是一个原子操作(使用setData并设置版本号或compare-and-set)。 - 更新数据时,采用类似
current_max_id + segment_size的逻辑。例如,当前值是 0,段大小是 1000,则尝试将其更新为 1000。如果更新成功,则表明当前客户端获得了[1, 1000]这个段的 ID 使用权。 - 如果更新失败(因为其他客户端同时修改了它),则重试此过程。
- 客户端在申请 ID 前,先尝试更新
- 本地分配:客户端成功获得一个段(比如
[current_start, current_end])后,就在自己的内存中维护两个变量:currentId(初始为current_start)和maxId(为current_end)。每次需要 ID 时,直接返回currentId++。 - 段用尽与续租:当
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 都是一种强一致性、高可用的解决方案,非常适合作为分布式系统的基石服务。
右滑查看面试常问