最近在做分布式任务调度系统,遇到分布式id的问题,我们需要一个全局唯一的id,但是服务又部署在多台服务器上面。因为之前没有什么分布式系统的经验,想当然的就是用了全局分布式锁来保证id的唯一性。后来小组周会,经领导一点拨,突然想起之前看过的一些分布式id解决方案(所以说知识需要不断巩固实践以及复习,不然全忘光了- -)。其实网上的分布式id的文章也很多了,但是为了让自己理解更加深刻,决定专门写一篇博客来记录一下这些分布式id的解决方案。
一、UUID
UUID 是 通用唯一识别码(Universally Unique Identifier)的缩写,在一个机器上生成的UUID,可以保证在同一个时空下都是唯一的。UUID的生成主要和以太网卡地址、纳秒级时间、芯片ID码和许多可能的数字有关,可以保证绝对唯一,完全满足分布式id的唯一性原则。
java实现
String id = UUID.randomUUID().toString();
优点
- 实现简单,不依赖第三方组件,,java语言就自带了uuid的实现
- 性能高,在单机上通过算法直接生成,速度非常快
- 全球唯一
缺点
- 生成的id是36个字符的字符串,占用空间太大,不适合用在数据库主键上面,也不适合作为索引字段
- 由于是字符串,不适用于需要id自增的场景,也不能用来排序
二、数据库自增长序列
先给某个字段设置为自增长,然后每次插入一条记录并获取最新的id。
mysql实现
先创建一张表 test
create table test(
id int auto_increment,
name varchar(8),
PRIMARY KEY (`id`)
)
每次要获取id使用以下语句
begin;
REPLACE INTO test (NAME) VALUES ('a');
SELECT LAST_INSERT_ID();
commit;
优点
- 实现简单,直接利用数据库语句就可以了
- 全局id单调递增,适合对id有特殊要求的业务
缺点
- 每次获取id都要进行一次IO,性能较差
- 高并发场景下性能也比较差
- 重度依赖于DB,如果DB挂了,那么获取id的功能就完全不能用了
- 每个业务都需要用到一张表,扩展非常不方便
三、数据库分段获取id
第二种方案每次都要去数据库获取id,这就意味着频繁的IO操作造成了性能的瓶颈。所以就有人想出了优化方案,也就是提前把一个号码段从数据库中取出来放到内存中,之后拿到号码段的进程再慢慢消耗这些号码段,再去数据库中获取新的号码段,这样就大大的减少了IO次数。
mysql实现方案
create table test2(
id int auto_increment,
tag varchar(255) comment '用来表示业务',
max_id int comment '表示当前已取走的最大id',
step int comment '表示一次取多长的号段',
PRIMARY KEY (`id`)
);
之后用以下sql语句获取号段
Begin;
UPDATE test2 SET max_id=max_id+step WHERE tag='test';
SELECT tag,max_id,step from test2 WHERE tag='test';
Commit;
这样进程就拿到了step个号在进程中使用。
优点
- 所有的业务用一张表就可以实现,扩展方便
- 满足id递增的需求
- 有一定的容灾能力,假如DB挂掉了,缓存的号码段还能用一段时间
- 可以自定义max_id,方便业务从其他地方迁移过来
缺点
- 在号段用完时会进行一次IO获取新的号段,所以在性能曲线上会尖刺
- 还是依赖于DB,如果DB长时间挂了就无法工作了
- 号码段不够随机,这也是所有id自增方案的风险。竞争对手很可能通过id来推断一天的数据量。
- 实现稍微麻烦写,现实使用中还要考虑持久化号段使用情况信息,也就是记录下当前使用到了哪里,以便重启后可以继续使用该号段,避免号码的浪费
优化方案
- 针对缺点1,我们可以使用双号段缓存,当一个号段用完马上可以使用第二个,同时开启一个异步线程再去获取一个号段,保证系统同时维护两个号段
- 针对缺点2,可以做一些mysql主备方案来应对
四、snowflake
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。
java实现
/**
* @author yangjb
* @since 2018-07-26 20:22
* <p>
* snowflake分布式id生成器
* 最高位在long中表示符号位,id一般都是正数,所以第一位默认都用0表示
* 接下来的41位表示时间戳,注意,这里的时间戳不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
* 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序类的TWEPOCH属性)。41位的时间截,可以使用69年
* 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId
* 12位序列号,在同一个毫秒同一个机器内,可以产生4096个ID序号使用
* 1+41+10+12 = 64,也就是一个long类型的长度。当然,不一定要严格使用这样的分配来计算id,比如比如机器较少,可以把机器的10位切割出几位出来给序列号使用等等
*/
public class IdGenerator {
//id生成器的开始使用时间
private final static long TWEPOCH = 1288834974657L;
// 机器标识位数
private final static long WORKER_ID_BITS = 5L;
// 数据中心标识位数
private final static long DATA_CENTER_ID_BITS = 5L;
// 机器ID最大值 31
private final static long MAX_WORKER_ID = -1L ^ (-1L << WORKER_ID_BITS);
// 数据中心ID最大值 31
private final static long MAX_DATA_CENTER_ID = -1L ^ (-1L << DATA_CENTER_ID_BITS);
// 毫秒内自增位
private final static long SEQUENCE_BITS = 12L;
// 机器ID偏左移12位
private final static long WORKER_ID_SHIFT = SEQUENCE_BITS;
private final static long DATA_CENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
// 时间毫秒左移22位
private final static long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATA_CENTER_ID_BITS;
private final static long SEQUENCE_MASK = -1L ^ (-1L << SEQUENCE_BITS);
private long lastTimestamp = -1L;
private long sequence = 0L;
private final long workerId;
private final long dataCenterId;
IdGenerator(long workerId, long dataCenterId) {
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException(String.format("%s must range from %d to %d", workerId, 0,
MAX_WORKER_ID));
}
if (dataCenterId > MAX_DATA_CENTER_ID || dataCenterId < 0) {
throw new IllegalArgumentException(String.format("%s must range from %d to %d", dataCenterId, 0,
MAX_DATA_CENTER_ID));
}
this.workerId = workerId;
this.dataCenterId = dataCenterId;
}
synchronized long nextValue() {
long timestamp = time();
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards, refuse to generate id for "
+ (lastTimestamp - timestamp) + " milliseconds");
}
if (lastTimestamp == timestamp) {
// 当前毫秒内,则+1
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
// 当前毫秒内计数满了,则等待下一秒
timestamp = untilNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
lastTimestamp = timestamp;
// ID偏移组合生成最终的ID,并返回ID
return ((timestamp - TWEPOCH) << TIMESTAMP_LEFT_SHIFT)
| (dataCenterId << DATA_CENTER_ID_SHIFT) | (workerId << WORKER_ID_SHIFT) | sequence;
}
private long untilNextMillis(final long lastTimestamp) {
long timestamp = this.time();
while (timestamp <= lastTimestamp) {
timestamp = this.time();
}
return timestamp;
}
private long time() {
return System.currentTimeMillis();
}
public static void main(String[] args) throws ParseException {
long id = new IdGenerator(1, 1).nextValue();
System.out.println(Long.toBinaryString(id));
}
}
优点
- id是递增的,且数值随机,安全性较高
- 性能高,直接在单机上通过算法计算出来,不用跨网络跨IO
- 不用依赖第三方组件,健壮性好
- 根据业务特性分配bit位,非常灵活
缺点
- 强依赖于系统时钟,如果出现机器时间回拨,可能造成id重复的问题
优化 和 使用注意点
使用snowflake最需要防止由于系统时钟回拨导致的id重复问题。上面的java实现方案中有做一些简单的时间戳检查来防止大幅度的系统时钟回拨,但是也没有彻底的解决时钟回拨问题。由于lastTimestamp变量是存储在内存的,一旦程序关闭,就失去了这个值,所以最好的方案还是需要定时的把lastTimestamp持久化起来。可以考虑利用zk的节点时间来记录这个lastTimestamp。
另外提一句,MongoDB的objectId也是用类snowflake的方案来生成id的。objectId由12个字节组成,4个字节表示秒级时间戳,3个字节表示机器id,2个字节表示pid,最后3个字节表示自增序列。也就是表示,在同一秒同一台机器的同一个进程内,可以产生4096个id。
objectId最终标识成一个24长度的十六进制字符。
五、redis incr命令实现id自增
使用redis的incr命令可以很简单的实现id的自增,并保证全局id唯一。
redis语句
每次要获取id,只要执行以下语句
incr test
优点
- 实现简单,直接执行redis的一个命令就可以获取到id
- 性能较高,redis是内存型数据库,获取id时不需要经过io,速度很快
- 获取的id是递增的,可以满足对id有要求的场景
缺点
- 重度依赖于redis,假如redis挂了,系统也就不能正常运行了
- 同样由于id是自增的,有暴露自己业务数据量的风险
总结
可以说除了第二种方案,其他各个分布式id解决方案都各自有各自的使用场景,也不能一概而论的说哪种方案是最好的。没有最好的方案,只有最适合的方案。当我们需要用到分布式id的时候,应该要先考虑好使用场景,之后分析各个分布式id解决方案各自的优缺点,综合考虑后再决定使用哪种方案。
本人的CSDN博客地址:
https://blog.csdn.net/u013332124/article/details/81234125
美团的leaf解决方案:
https://tech.meituan.com/MT_Leaf.html?utm_source=tool.lu