前言
在单机应用中, ID 的生成是非常简单的,直接使用数据库的自增ID就行。然而,在分布式系统中,这样就不行了,因为你的数据库分布在多台服务器上,如果使用自增ID就会生成重复的 ID。
如果仅仅是为了解决重复性,那么在分布式系统中,还有一种更加简单的方式,使用 UUID。这种方案解决了重复性问题,但是又引出了 ID 随机性的问题,在大量的数据插入过程中,会导致数据库重建索引的性能太高。
并非友好的「分布式 ID 系统」
因此,大家希望在分布式系统中,这个 ID 不重复,又有一定的自增性。如果你通过搜索引擎搜索,发现有很多方案,但是基本上都要引入一个分布式 ID 系统。如果不引入分布式系统,方案就会变的比较脆弱。
分布式系统本来就很复杂,而且对性能要求又很高,现在又要引入一个分布式 ID 系统,这无疑增加了系统的复杂性。
多一套系统就要多一份成本,维护运营都会来带复杂度。而且这样还会产生系统调用,无疑也增加了系统延迟。
超高性能的 ID 生成方案
这里要提前说明,既然是分布式系统了,那么这个分布式系统肯定要依赖于 Kubernetes 或者 Docker 这样的服务编排能力,那么它们启动分布式系统的每个实例时,都会生成唯一的 ID。
这种唯一的 ID 在这个方案中非常重要,但是,也未必使用服务编排的 ID。既然是分布式系统了,那么 Redis 这样的服务一般也是必不可少的。
服务实例唯一的 ID
为了让这个方案跟部署具有较高的松耦性,我们这里采用 Redis。如果理解了这个方案之后,使用部署方案提供的 ID,性能和效果会更好。
这里未来演示这个方案,且任何人拿来都可以使用,于是采用了 Redis 生成服务实例 ID 的方案。
csharp
const incrementedId = await this.redisClient.incr('serverIdCounter');
利用高进制缩短 ID 长度
这个 ID 是 10 进制的,如果我们设计一个高进制的计数方式,就可以缩短 ID 长度。这里我们观察ASCII字符,其中一些字符比较合适,你可以可以根据自己的理解设计高进制。
这里我们设计一个 64 进制的计数方式,代码如下:
typescript
/**
* 将十进制数转换为64进制数
* @param decimal 十进制数
* @param length 生成的64进制数的长度
* @returns 64进制数
*/
export default function decimalTo64(
decimal: number = 0,
length: number = 0,
): string {
const chars =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
const result: string[] = [];
while (decimal > 0) {
result.push(chars[decimal & 63]); // 使用位运算
decimal = (decimal / 64) | 0; // 更快的向下取整
}
let output = result.reverse().join('');
// 在前面补足 '0' 以达到所需的长度
while (output.length < length) {
output = '0' + output;
}
return output;
}
这是一个性能非常高的进制转换函数,把给定的 10 进制数,转换成 64 进制数,可以大大减少 ID 的长度。
业务中 ID 组成
先回顾一下重点,每个实例都有一个唯一的 ID,10 进制的数可以高效的转换成 64 进制,大大的缩短字符长度。
最简单的 ID 组成
7 位实例ID + 7 位实例中的自增数
因为是 64 进制,7 位就可以表示百亿级别的数字。你可以设想,什么级别的分布式系统,可以产生数千亿级别的服务实例,单个实例生成数千亿级别的数据。是单个实例,不是整个分布式系统。而且自增一下服务实例 ID,又可以产生数千亿级别的数据 ID。
用不完,根本用不完。
如果你觉得不够用,或者太长用不完,可以适当的减少或者增加长度。
带时间戳的 ID 组成
5 位时间戳(表示天)+ 7 位实例ID + 5 位实例中的自增数
- 5 位时间戳(表示天),可以记录数百万年的数据。 根据实际情况调整。
- 7 位实例ID,可以生成数千亿服务实例。
- 5 位实例中的自增数,每天可以记录十几亿的数据量。 且是单个实例。
总长度 17 位 ,其实有点儿长,根本用不完。4位时间戳就可以表示数万年呢,可以适当调整,毕竟一万年太久。
完整代码
如果是 NodeJs 的话,基本拿来就可以用。
typescript
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';
import decimalTo64 from './common/decimalTo64';
// 枚举,定义不同类型的 ID
export enum IdType {
User, // 用户 ID
Chat, // 对话 ID
Message, // 消息 ID
MessageStatus, // 消息状态 ID
}
@Injectable()
export class IdGeneratorService implements OnModuleInit {
private readonly SERVER_ID_COUNTER_KEY = 'serverIdCounter';
private serverId: string;
private serverIdShort: string; // 7位的短服务器ID
private indexCounters = [0, 0, 0, 0]; // 分别为不同 ID 类型的计数器
private lastDateCheck: number = 0; // 上次日期检查的时间
private cachedDateString: string = ''; // 缓存的日期字符串
constructor(@Inject('REDIS_CLIENT') private readonly redisClient: Redis) {}
// 在模块初始化时调用,用于初始化服务器 ID
async onModuleInit() {
await this.initializeServerId();
}
// 从 Redis 获取并递增服务器 ID,然后转换为 64 进制表示
async initializeServerId() {
const incrementedId = await this.redisClient.incr(
this.SERVER_ID_COUNTER_KEY,
);
this.serverId = decimalTo64(incrementedId);
this.serverIdShort = decimalTo64(incrementedId, 7);
}
// 返回完整的服务器 ID
get fullServerId() {
return this.serverId;
}
// 返回短服务器 ID
get shortServerId() {
return this.serverIdShort;
}
// 检查和更新日期,如果是新的一天,则重置计数器并更新缓存的日期字符串
private checkAndUpdateDate() {
const currentDate = Math.floor(Date.now() / 86400000); // 获取当前日期的整数表示
if (currentDate != this.lastDateCheck) {
this.indexCounters.fill(0); // 重置计数器
this.lastDateCheck = currentDate;
this.cachedDateString = decimalTo64(currentDate, 5); // 更新日期字符串
}
}
// 根据 ID 类型生成唯一 ID
// 结构:5位日期字符串 + 7位短服务器ID + 5位计数器值
generateId(idType: IdType) {
this.checkAndUpdateDate(); // 检查并更新日期
const counterValue = decimalTo64(this.indexCounters[idType]++, 5); // 获取并递增计数器值
return this.cachedDateString + this.serverIdShort + counterValue; // 组合生成 ID
}
}
代码如此之简单,注释也非常的清晰,简单修改就可以适用于自己的系统,好像也没有什么可解释的。
如果是其它语言,会有多线程的问题,需要给计数器加锁处理,也是非常,无需多加解释。
回顾
这个方案,生成 ID 的效率是相当的高,目前还未发现有什么缺点不能接受。
可能的疑问:
- 时间回拨问题。不存在,如果系统出问题,会生成新的实例ID,所有计数重新开始。
- 想对历史某一天进行数据插入。也非常简单,找到那一天的最后一个自增,开始计数即可。
- 想对历史某一天的某一时刻进行数据插入。重新设计时间戳,以小时或者分为单位,同时减少计数长度。比如
6 位时间戳(表示分)+ 7 位实例ID + 4 位实例中的自增数
- 想在ID中加入业务表示,也很简单:
5 位时间戳(表示天)+ 7 位实例ID + 4 位实例中的自增数 + 2 位业务标识[用户/消息/对话等等]
可能还有很多吧,但是基本都能很容易的解决。
优点
- 第一大优点,方案非常非常简单且非常非常实用。
- 代码实现也是如此之简单。在我的系统设计中,我最喜欢的是,简单的方案、简单的代码、解决复杂的问题。
- 系统适应性非常强,很容易调整 ID 结构。
- 一旦实例启动,整个过程不依赖任何系统,ID生成效率非常之高。
- 全是内存计数,性能非常好。
- 伸缩性非常之高。不用担心实例挂掉和重启。
- 在水平扩展的时候,完全透明,不会对系统产生任何压力。
- ...
好的停不下来。如果有一天你遇到了分布式系统,你就知道这个方案有多么的精妙了。
有一天,如果遇到分布式系统,回头看看这个方案,希望对你有所帮助。