在订单系统中,当消费者成功下单后,都会生成一个用于唯一识别的订单编号。目前,大多数的服务系统都为分布式系统,如何在分布式系统中生成具有一定顺序的全局唯一编号,既方便用作唯一标识,又方便数据库存储和索引,SnowFlake算法就派上用场啦。
为了满足Twitter每秒上万条消息的请求,每条消息都必须分配一条唯一的id,这些id还需要一些大致的顺序(方便客户端排序),并且在分布式系统中不同机器产生的id必须不同,于是,Twitter公司推出了SnowFlake方法,目前被各互联网公司广泛使用。
1.SnowFlake序号结构
SnowFlake所生成的ID一共分成四部分:
1.第一位占用1bit,其值始终是0,没有实际作用;
2.时间戳占用41bit,精确到毫秒,总共可以容纳约140年的时间;
3.工作机器id占用10bit,其中高位5bit是数据中心ID(DATACENTER),低位5bit是工作节点ID(MACHINE),也就是说,该算法可以容纳1024个节点的集群;
4.序列号占用12bit,也就是说,同一个节点一毫秒内可以容纳4095次请求或操作。
SnowFlake算法在同一毫秒内最多可以生成多少个全局唯一ID呢?只需要做一个简单的乘法:同一毫秒的ID数量 = 1024 X 4096 = 4194304。这个数字在绝大多数并发场景下都是够用的。
2.SnowFlake时间戳
在基于JAVA的SnowFlake中,我们发现算法的时间戳是一个本地方法,即他是调用本地函数来实现的,目的是获取当前的毫秒数字,这个数字是不会以数据中心或节点的改变而变化的,而只以客观的时间变化而变化。
JAVA中,获取时间戳的方法为getNewstmp,可以看到代码如下:
private long getNewstmp() {
return System.currentTimeMillis();
}
而System类中的currentTimeMillis方法如下:
public static native long currentTimeMillis();
3.SnowFlake工作机器ID
根据笔者的理解,在JAVA源码中,工作机器ID号是需要人为设定的,需要根据代码处在不同的服务器上设置不同的编号,比如,可以使用MAC或IP地址来唯一标示工作机器,如果工作机器比较少,也可以使用配置文件来直接设置ID。它的值是SnowFlake类在构造时确定的,在new这个类时,需要我们传入工作机器ID的参数。
SnowFlake类的构造方法如下:
public SnowFlake(long datacenterId, long machineId) {
if (datacenterId > MAX_DATACENTER_NUM || datacenterId < 0) {
throw new IllegalArgumentException("datacenterId can't be greater than MAX_DATACENTER_NUM or less than 0");
}
if (machineId > MAX_MACHINE_NUM || machineId < 0) {
throw new IllegalArgumentException("machineId can't be greater than MAX_MACHINE_NUM or less than 0");
}
this.datacenterId = datacenterId;
this.machineId = machineId;
}
可以看到,在new一个SnowFlake类时,需要我们传入数据中心ID号和机器ID号,用于唯一标识我们分布式集群中的某一台服务器,至于具体ID号的生成办法,需要使用者自己去确定,而该方法本身不会给出具体方案。
4.SnowFlake序列号
当我们确定了秒级为单位的时间、当前使用的工作机器ID号,接下来要关注的点就是该台机器在1ms内的并发量,这就是序列号的含义。
简单来说,序列号就是一系列的自增ID,为了给同一毫秒内的多条消息按时间顺序分配唯一识别ID,若同一毫秒把序列号用完了,则“等待至下一毫秒”,这个就有点类似令牌桶的思想。
SnowFlake生成序号的方法叫nextId,其代码如下:
public synchronized long nextId() {
long currStmp = getNewstmp();
if (currStmp < lastStmp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id");
}
if (currStmp == lastStmp) {
//相同毫秒内,序列号自增
sequence = (sequence + 1) & MAX_SEQUENCE;
//同一毫秒的序列数已经达到最大
if (sequence == 0L) {
currStmp = getNextMill();
}
} else {
//不同毫秒内,序列号置为0
sequence = 0L;
}
lastStmp = currStmp;
return (currStmp - START_STMP) << TIMESTMP_LEFT //时间戳部分
| datacenterId << DATACENTER_LEFT //数据中心部分
| machineId << MACHINE_LEFT //机器标识部分
| sequence; //序列号部分
}
其中,sequence即代表序列号,方法中间部分即为生成序列号的过程,在return部分,返回秒级时间戳+工作机器ID+序列号,就是我们所需要的自增全局唯一ID啦。
另外,需要注意的是该方法采用了synchronized来控制并发,这是考虑到一定会有很多的高并发请求来生成序列号,在这种背景下,用悲观锁要比用乐观锁更合适。由于该方法是线程安全的,即同一时刻只会有一个线程在该方法中,因此,如果该线程获取到的序列号超过了最大值,也只会有这一个线程在方法内阻塞,直到下一毫秒到来后,该线程的序列号被置为下一毫秒的0号。
其中,阻塞的方法为getNextMill(),其源码为:
private long getNextMill() {
long mill = getNewstmp();
while (mill <= lastStmp) {
mill = getNewstmp();
}
return mill;
}
可以看到,实际上就是当前线程不断轮询获取当前时刻,直到下一毫秒的到来,并且返回下一毫秒。
5.优缺点
算法优点:
1.生成ID时不依赖于DB,完全在内存生成,高性能高可用。
2.ID呈趋势递增,后续插入索引树的时候性能较好。
算法缺点:
依赖于系统时钟的一致性。如果某台机器的系统时钟回拨,有可能造成ID冲突,或者ID乱序。