JAVA 分布式系统全局唯一 ID 实现:雪花算法与 Redis 实战
大家好,今天我们来聊聊分布式系统中的一个重要话题:全局唯一 ID 的生成。在单体应用中,我们通常依赖数据库的自增 ID 来保证唯一性。但在分布式环境下,各个服务拥有独立的数据库,自增 ID 机制不再适用。我们需要一种能在分布式环境下也能保证全局唯一性的 ID 生成方案。
为什么需要全局唯一 ID?
全局唯一 ID 在分布式系统中至关重要,它能解决以下问题:
- 数据分片/分库分表: 将数据分散到多个数据库或表中,需要一个全局唯一的 ID 来标识每条记录。
- 分布式事务: 跨多个服务的事务需要一个唯一的 ID 来跟踪整个事务流程。
- 消息队列: 消息需要一个唯一的 ID 来防止重复消费。
- 唯一性约束: 在不同服务间需要保证某些数据的唯一性,例如用户 ID、订单 ID 等。
- 数据分析和聚合: 在不同服务产生的数据需要聚合分析时,需要一个全局唯一的 ID 来关联不同来源的数据。
全局唯一 ID 的常见方案
有很多种生成全局唯一 ID 的方案,常见的包括:
- UUID: Universally Unique Identifier,通用唯一识别码。
- 数据库自增 ID: 使用单独的数据库实例生成自增 ID。
- 雪花算法 (Snowflake Algorithm): Twitter 开源的分布式 ID 生成算法。
- Redis 自增: 利用 Redis 的原子自增特性生成 ID。
- Zookeeper: 利用 Zookeeper 的有序节点特性生成 ID。
- Leaf: 美团点评开源的分布式 ID 生成系统。
每种方案都有其优缺点,我们需要根据实际业务场景选择合适的方案。
UUID 的优缺点
优点:
- 简单易用,生成 ID 不需要依赖任何外部系统。
- 能够生成大量的 ID,重复概率极低。
缺点:
- 长度较长,通常为 36 个字符(包含连字符)。
- 无序,不利于数据库索引,可能导致性能问题。
- 可读性差。
由于 UUID 的无序性,在面对大量数据写入时,会导致 B+ 树索引频繁分裂,影响数据库性能。因此,在对性能有较高要求的场景下,不建议使用 UUID 作为主键。
数据库自增 ID 的优缺点
优点:
- 简单易用,利用数据库的自增机制。
- ID 是有序的,有利于数据库索引。
缺点:
- 存在单点故障风险。
- 横向扩展困难。
- ID 生成性能受限于数据库性能。
如果使用数据库自增 ID,需要考虑高可用性和扩展性问题。可以采用主备数据库方案,或者使用多个数据库实例生成不同范围的 ID,但实现起来比较复杂。
雪花算法 (Snowflake Algorithm)
雪花算法是一种经典的分布式 ID 生成算法,由 Twitter 开源。它能够生成 64 位的 Long 型 ID,保证全局唯一和趋势递增。
雪花算法的结构:
0 | 41 位时间戳 (timestamp) | 5 位数据中心 ID (datacenterId) | 5 位机器 ID (workerId) | 12 位序列号 (sequence)
- 1 位符号位: 始终为 0,表示正数。
- 41 位时间戳: 存储的是从某个固定时间点(epoch)开始的毫秒数。可以使用
System.currentTimeMillis()获取。41 位时间戳可以使用 69 年((1L << 41) / (1000 60 60 24 365) = 69)。 - 5 位数据中心 ID: 用于标识不同的数据中心。最多支持 32 个数据中心。
- 5 位机器 ID: 用于标识同一数据中心下的不同机器。最多支持 32 台机器。
- 12 位序列号: 用于在同一毫秒内生成不同的 ID。最多支持 4096 个 ID。
雪花算法的优点:
- 高性能: 能够在单机上生成大量的 ID。
- 全局唯一: 保证生成的 ID 在分布式环境中是唯一的。
- 趋势递增: 生成的 ID 基本上是递增的,有利于数据库索引。
- 可配置: 可以根据实际需要调整数据中心 ID 和机器 ID 的位数。
- 不依赖外部系统: 除了初始配置,不依赖于外部系统,降低了系统的复杂度。
雪花算法的缺点:
- 依赖时钟,如果发生时钟回拨,可能会生成重复的 ID。
- 需要提前配置数据中心 ID 和机器 ID。
雪花算法的代码实现 (Java)
public class SnowflakeIdWorker {
private final long epoch = 1609459200000L; // 设置起始时间 (2021-01-01 00:00:00)
private final long workerIdBits = 5L;
private final long datacenterIdBits = 5L;
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
private final long sequenceBits = 12L;
private final long workerIdShift = sequenceBits;
private final long datacenterIdShift = sequenceBits + workerIdBits;
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
private long datacenterId;
private long workerId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public SnowflakeIdWorker(long datacenterId, long workerId) {
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("DatacenterId can't be greater than %d or less than 0", maxDatacenterId));
}
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("WorkerId can't be greater than %d or less than 0", maxWorkerId));
}
this.datacenterId = datacenterId;
this.workerId = workerId;
}
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - epoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
protected long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
protected long timeGen() {
return System.currentTimeMillis();
}
public static void main(String[] args) {
SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
for (int i = 0; i < 1000; i++) {
long id = idWorker.nextId();
System.out.println(id);
}
}
}
代码解释:
epoch: 起始时间戳,用于计算时间差。workerIdBits和datacenterIdBits: 定义了 workerId 和 datacenterId 的位数。maxWorkerId和maxDatacenterId: 计算了 workerId 和 datacenterId 的最大值。sequenceBits: 定义了序列号的位数。workerIdShift,datacenterIdShift,timestampLeftShift: 定义了各个部分的偏移量,用于将它们组合成一个 64 位的 ID。sequenceMask: 用于生成序列号的掩码。datacenterId和workerId: 实际的数据中心 ID 和机器 ID。sequence: 当前序列号。lastTimestamp: 上一次生成 ID 的时间戳。nextId(): 生成下一个 ID 的方法,使用了synchronized关键字来保证线程安全。timeGen(): 获取当前时间戳的方法。tilNextMillis(): 如果当前时间戳小于等于上一次的时间戳,则等待下一毫秒。
时钟回拨问题的处理:
代码中检测到了时钟回拨的情况 (timestamp < lastTimestamp),并抛出了异常。 实际应用中,可以采用以下策略处理时钟回拨:
- 拒绝服务: 直接抛出异常,停止生成 ID。这是最简单的处理方式,但可能会影响系统的可用性。
- 等待时钟追赶: 等待时钟追赶到上一次的时间戳之后再生成 ID。这种方式可能会导致 ID 生成的延迟。
- 使用备用时间源: 使用 NTP 服务器等备用时间源来校准时钟。
- 记录回拨事件: 记录时钟回拨事件,并人工介入处理。
- 扩展时间戳位数: 如果对ID长度要求不高,可以牺牲一部分datacenterId或者workerId的位数,来扩展时间戳的位数,从而容忍一定范围内的时钟回拨。
选择哪种处理方式取决于业务对可用性和一致性的要求。
数据中心 ID 和机器 ID 的配置
数据中心 ID 和机器 ID 需要在系统启动时进行配置。 可以采用以下方式配置:
- 配置文件: 将数据中心 ID 和机器 ID 配置在配置文件中。
- 环境变量: 将数据中心 ID 和机器 ID 设置为环境变量。
- 数据库: 将数据中心 ID 和机器 ID 存储在数据库中。
- Zookeeper: 使用 Zookeeper 来动态分配数据中心 ID 和机器 ID。
推荐使用 Zookeeper 来动态分配数据中心 ID 和机器 ID,可以避免手动配置的麻烦,并提高系统的灵活性。
Redis 自增
Redis 是一种高性能的内存数据库,它提供了原子自增 (INCR) 命令,可以用来生成全局唯一的 ID。
Redis 自增的优点:
- 高性能: Redis 的自增操作非常快。
- 简单易用: 只需要调用 Redis 的 INCR 命令即可。
- 高可用: Redis 可以通过主从复制和 Sentinel 来实现高可用。
Redis 自增的缺点:
- 依赖外部系统: 需要依赖 Redis 集群。
- 非趋势递增: ID 不是趋势递增的,不利于数据库索引。
- 可能存在空洞: 如果 Redis 发生故障,可能会导致 ID 出现空洞。
Redis 自增的代码实现 (Java)
import redis.clients.jedis.Jedis;
public class RedisIdWorker {
private String redisHost;
private int redisPort;
private String idKey;
public RedisIdWorker(String redisHost, int redisPort, String idKey) {
this.redisHost = redisHost;
this.redisPort = redisPort;
this.idKey = idKey;
}
public long nextId() {
try (Jedis jedis = new Jedis(redisHost, redisPort)) {
return jedis.incr(idKey);
} catch (Exception e) {
throw new RuntimeException("Failed to generate ID from Redis", e);
}
}
public static void main(String[] args) {
RedisIdWorker idWorker = new RedisIdWorker("localhost", 6379, "order_id");
for (int i = 0; i < 1000; i++) {
long id = idWorker.nextId();
System.out.println(id);
}
}
}
代码解释:
redisHost和redisPort: Redis 服务器的地址和端口。idKey: Redis 中用于存储 ID 的 key。nextId(): 从 Redis 中获取下一个 ID 的方法。- 使用
Jedis连接 Redis 服务器。 - 调用
jedis.incr(idKey)命令来原子自增idKey对应的 value,并返回自增后的值。
Redis 自增的优化
为了提高 Redis 自增的性能,可以采用以下优化策略:
- 批量获取 ID: 一次性从 Redis 中获取多个 ID,减少网络开销。
- 使用 Redis 集群: 使用 Redis 集群来提高 Redis 的可用性和扩展性。
- Key 的选择: 使用不同的 Key 来生成不同类型的 ID,例如订单 ID、用户 ID 等。
- 设置 Key 的过期时间: 为 Key 设置过期时间,防止 Redis 中存储过多的 ID。
Redis 自增的空洞问题
Redis 自增可能会出现空洞,例如 Redis 发生故障重启后,ID 可能会从一个较大的值开始,导致之前的 ID 没有被使用。 为了解决这个问题,可以采用以下策略:
- 预分配 ID: 提前从 Redis 中获取一批 ID,并存储在本地。
- 记录已分配的 ID: 使用数据库或其他存储介质记录已分配的 ID,并在生成 ID 时进行检查。
- 接受空洞: 如果业务对 ID 的连续性没有严格要求,可以接受空洞。
雪花算法与 Redis 的结合
可以将雪花算法和 Redis 结合起来,利用 Redis 来存储 workerId 和 datacenterId。 在系统启动时,从 Redis 中获取 workerId 和 datacenterId,然后使用雪花算法生成 ID。 这样可以避免手动配置 workerId 和 datacenterId 的麻烦,并提高系统的灵活性。
实现步骤:
- 在 Redis 中存储 workerId 和 datacenterId,可以使用 Hash 数据结构。
- 在系统启动时,从 Redis 中获取 workerId 和 datacenterId。如果 Redis 中不存在 workerId 和 datacenterId,则生成一个新的 workerId 和 datacenterId,并存储到 Redis 中。可以使用 Redis 的 INCR 命令来生成新的 workerId 和 datacenterId。
- 使用获取到的 workerId 和 datacenterId 初始化 SnowflakeIdWorker。
- 使用 SnowflakeIdWorker 生成 ID。
代码示例 (仅展示关键部分):
import redis.clients.jedis.Jedis;
public class RedisSnowflakeIdWorker {
private String redisHost;
private int redisPort;
private String workerIdKey;
private String datacenterIdKey;
private SnowflakeIdWorker idWorker;
public RedisSnowflakeIdWorker(String redisHost, int redisPort, String workerIdKey, String datacenterIdKey) {
this.redisHost = redisHost;
this.redisPort = redisPort;
this.workerIdKey = workerIdKey;
this.datacenterIdKey = datacenterIdKey;
init();
}
private void init() {
try (Jedis jedis = new Jedis(redisHost, redisPort)) {
long workerId = getOrGenerateId(jedis, workerIdKey, 0, 31); //假设 workerId 范围是 0-31
long datacenterId = getOrGenerateId(jedis, datacenterIdKey, 0, 31); //假设 datacenterId 范围是 0-31
this.idWorker = new SnowflakeIdWorker(datacenterId, workerId);
} catch (Exception e) {
throw new RuntimeException("Failed to initialize RedisSnowflakeIdWorker", e);
}
}
private long getOrGenerateId(Jedis jedis, String key, long min, long max) {
String idStr = jedis.get(key);
if (idStr == null) {
// 利用 Redis 的原子自增,保证分配的 ID 唯一
long newId = jedis.incr(key + "_seq"); // 使用一个单独的 key 来记录序列号
if (newId > max) {
throw new RuntimeException("Reached maximum ID for key: " + key);
}
jedis.set(key, String.valueOf(newId));
return newId;
} else {
return Long.parseLong(idStr);
}
}
public long nextId() {
return idWorker.nextId();
}
public static void main(String[] args) {
RedisSnowflakeIdWorker idWorker = new RedisSnowflakeIdWorker("localhost", 6379, "worker_id", "datacenter_id");
for (int i = 0; i < 1000; i++) {
long id = idWorker.nextId();
System.out.println(id);
}
}
}
代码解释:
getOrGenerateId(): 从 Redis 中获取或生成 ID。 如果 Redis 中不存在 ID,则使用jedis.incr()命令生成一个新的 ID,并存储到 Redis 中。 为了保证 ID 的唯一性,使用了 Redis 的原子自增特性。workerIdKey和datacenterIdKey用于存储 workerId 和 datacenterId 的 Redis key。- 通过将 workerId 和 datacenterId 存储到 Redis 中,可以实现动态分配,避免了手动配置的麻烦。
总结:选择合适的 ID 生成方案
全局唯一 ID 的生成方案有很多种,选择哪种方案取决于实际业务场景。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| UUID | 简单易用,无需依赖外部系统 | 长度较长,无序,不利于数据库索引 | 对性能要求不高,不需要保证 ID 有序的场景 |
| 数据库自增 ID | 简单易用,ID 有序 | 存在单点故障风险,横向扩展困难,ID 生成性能受限于数据库性能 | 单体应用,或者对性能要求不高,且可以容忍单点故障的场景 |
| 雪花算法 | 高性能,全局唯一,趋势递增,可配置,不依赖外部系统 | 依赖时钟,需要提前配置数据中心 ID 和机器 ID | 对性能有较高要求,需要保证 ID 全局唯一和趋势递增的场景 |
| Redis 自增 | 高性能,简单易用,高可用 | 依赖外部系统,非趋势递增,可能存在空洞 | 对性能有较高要求,可以容忍 ID 非趋势递增和存在空洞的场景 |
| 雪花算法 + Redis | 结合了雪花算法和 Redis 的优点 | 增加了系统的复杂度,需要同时维护 Redis 和雪花算法的配置 | 对性能有较高要求,需要保证 ID 全局唯一和趋势递增,且需要动态分配数据中心 ID 和机器 ID 的场景 |
最后的建议
在选择全局唯一 ID 生成方案时,需要综合考虑以下因素:
- 性能: ID 生成的速度是否满足业务需求。
- 唯一性: 是否能够保证 ID 在分布式环境下的全局唯一性。
- 有序性: 是否需要保证 ID 的有序性,有利于数据库索引。
- 可用性: 系统是否能够容忍单点故障。
- 可维护性: 系统的复杂度是否可控。
- 成本: 系统的部署和维护成本。
希望今天的分享对大家有所帮助,谢谢!