在分布式系统中常会需要生成系统唯一ID,生成ID有很多方法,根据不同的生成策略,以满足不同的场景、需求以及性能要求。以下为几种实现方式:
方式一、数据库自增序列
这是最常见的一种方式,利用DB来生成全库唯一ID。
优点:
1)使用简单,代码方便,性能可以接受。
2)ID为数字类型,排序方便。
缺点:
1)不同数据库语法和实现不同,数据库迁移或多数据库版本支持时需要处理。
2)在单数据库、读写分离或一主多从场景下,只有一个主库可以生成,有单点故障风险。
3)在性能达不到要求时,难于扩展。
4)当多个系统需要合并或涉及数据迁移时处理复杂。
5)在分库分表时麻烦。
优化方案:
针对主库单点,如果有多个Master,可以设置每个Master库的起始数字不同,步长相同,步长可以是Master的个数。如:Master1 生成的是 1,4,7,10,Master2生成的是2,5,8,11,Master3生成的是 3,6,9,12。这样可以有效生成集群中的唯一ID,也可以大大降低ID生成库操作的负载。
方式二、UUID
也是常见的方式,可以用数据库或程序生成,全球唯一。
优点:
1)使用简单。
2)生成ID性能高,基本不会有性能问题。
3)全球唯一,当数据迁移、数据合并、数据库变更时,可以从容应对。
缺点:
1)数据无序,无法保证趋势递增。
2)UUID使用字符串存储,查询效率较低。
3)存储空间较大,如果是海量数据库,需考虑存储量的问题。
4)传输数据量大。
5)可读性差。
方式三、UUID变种
1)为了解决UUID不可读,可以使用UUID to Int64的方法。
//根据GUID获取唯一数字序列
public static long GuidToInt64() {
byte[] bytes = Guid.NewGuid().ToByteArray();
return BitConverter.ToInt64(bytes, 0);
}
2)为了解决UUID无序问题,NHibernate在其主键生成方式中提供了Comb算法(combined guid/timestamp)。保留GUID的10个字节,用另6个字节表示GUID生成的时间(DateTime)。
private Guid GenerateComb() {
byte[] guidArray = Guid.NewGuid().ToByteArray();
DateTime baseDate = new DateTime(1900, 1, 1);
DateTime now = DateTime.Now;
// Get the days and milliseconds which will be used to build the byte string
TimeSpan days = new TimeSpan(now.Ticks - baseDate.Ticks);
TimeSpan msecs = now.TimeOfDay;
// Convert to a byte array Note that SQL Server is accurate to 1/300th of a millisecond so we divide by 3.333333
byte[] daysArray = BitConverter.GetBytes(days.Days);
byte[] msecsArray = BitConverter.GetBytes((long)(msecs.TotalMilliseconds / 3.333333));
// Reverse the bytes to match SQL Servers ordering
Array.Reverse(daysArray);
Array.Reverse(msecsArray);
// Copy the bytes into the guid
Array.Copy(daysArray, daysArray.Length - 2, guidArray, guidArray.Length - 6, 2);
Array.Copy(msecsArray, msecsArray.Length - 4, guidArray, guidArray.Length - 4, 4);
return new Guid(guidArray);
}
方式四、用Redis生成ID
当用数据库生成ID的性能不满足要求时,可以使用Redis来生成ID。因为Redis是单线程的,也可以用来生成全局唯一ID。可以用Redis的原子操作INCR和INCRBY来实现。
此外,可以使用Redis集群来获取更高的吞吐量。假如一个集群中有5台Redis,可以初始化每台Redis的值分别是1,2,3,4,5,步长都是5,各Redis生成的ID如下:
A:1,6,11,16
B:2,7,12,17
C:3,8,13,18
D:4,9,14,19
E:5,10,15,20
这种方式是负载到哪台机器提前定好,未来很难做修改。3~5台服务器基本能够满足需求,都可以获得不同的ID,但步长和初始值一定需要事先确定,使用Redis集群也可以解决单点故障问题。
另外,比较适合使用Redis来生成每天从0开始的流水号,如订单号=日期+当日自增长号。可以每天在Redis中生成一个Key,使用INCR进行累加。
优点:
1)不依赖于数据库,灵活方便,且性能优于数据库。
2)数字ID天然排序,对分页或需要排序的结果很有帮助。
缺点:
1)如果系统中没有Redis,需要引入新的组件,增加系统复杂度。
2)需要编码和配置的工作量较大。
方式五、用zookeeper生成唯一ID
zookeeper主要通过其znode数据版本来生成序列号,可以生成32位和64位的数据版本号,客户端可以使用这个版本号作为唯一的序列号。
通常很少会使用zookeeper来生成唯一ID。原因是需要依赖zookeeper,并且是多步调用API,在竞争大时需要考虑使用分布式锁。因此,性能在高并发的分布式环境下,也不甚理想。
方式六、利用MongoDB的ObjectId
MongoDB的ObjectId和snowflake算法类似。它是轻量型的,不同的机器都能用全局唯一的同种方法方便地生成它。MongoDB从一开始就设计用来作为分布式数据库,处理多个节点是一个核心要求,使其在分片环境中要容易生成得多。
ObjectId由12个字节组成,分成四个部分:timestamp+machash+pid+inc。默认mongodb collection内的_id是唯一的。ObjectId使用12字节的存储空间,是一个由24个16进制数字组成的字符串(每个字节可以存储两个16进制数字)。- 前4个字节是从标准纪元开始的时间戳,单位为秒。时间戳与随后的5个字节组合起来,提供了秒级别的唯一性。
前4个字节其实隐藏了文档创建的时间,并且时间戳处在于字符的最前面,这就意味着ObjectId大致会按照插入进行排序,这对于某些方面起到很大作用,如作为索引提高搜索效率等。使用时间戳还有一个好处是,某些客户端驱动可以通过ObjectId解析出该记录是何时插入的,这也解答了我们平时快速连续创建多个Objectid时,会发现前几位数字很少发现变化的现实,因为使用的是当前时间,很多用户担心要对服务器进行时间同步,其实这个时间戳的真实值并不重要,只要其总不停增加就好。
ObjectId("53102b43bf1044ed8b0ba36b").getTimestamp();
ISODate("2014-02-28T06:22:59Z");
- 接下来的3字节是所在主机的唯一标识符,通常是机器主机名的散列值,这样就可以确保不同主机生成不同的ObjectId,不产生冲突。
- 为确保在同一台机器上并发的多个进程产生的ObjectId是唯一的,接下来的两字节来产生ObjectId的进程标识符(PID)。
- 前9字节保证了同一秒不同机器不同进程产生的ObjectId是唯一的。最后3字节就是一个自动增加的计数器,确保相同进程同一秒产生的ObjectId也是不一样的。一秒钟最多允许每个进程拥有256的3次方(16777216)个不同的ObjectId。
方式七、snowflake算法
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。
其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号,最后还有一个符号位,永远是0。
这个算法单机每秒内理论上最多可以生成1000*(2^12),也就是409.6万个ID。
雪花算法描述:
- 最高位是符号位,始终为0,不可用。
- 41位的时间序列,精确到毫秒级,41位的长度可以使用69年。时间位还有一个很重要的作用是可以根据时间进行排序。
- 10位的机器标识,10位的长度最多支持部署1024个节点。10位器标识符一般是5位IDC+5位machine编号,唯一确定一台机器。
- 12位的计数序列号,序列号即一系列的自增id,可以支持同一节点同一毫秒生成多个ID序号,12位的计数序列号支持每个节点每毫秒产生4096个ID序号。
snowflake算法可以根据自身项目的需要进行一定的修改。比如估算未来的数据中心个数,每个数据中心的机器数以及统一毫秒可以能的并发数来调整在算法中所需要的bit数。
优点:
1)不依赖于数据库,速度快,性能高。
2)ID按照时间在单机上是递增的。
3)可以根据实际情况调整各各位段,方便灵活。
缺点:
1)在单机上是递增的,由于涉及到分布式环境,每台机器上的时钟不可能完全同步,有时也会出现不是全局递增的情况。
2)只能趋势递增。(如果绝对递增,竞对中午下单,第二天再下单即可大概判断该公司的订单量,危险!)
3)依赖机器时间,如果发生回拨会导致可能生成id重复。
算法的java实现:
public class SnowflakeIdWorker {
/** 开始时间截 (2015-01-01) */
private final long twepoch = 1420041600000L;
/** 机器id所占的位数 */
private final long workerIdBits = 5L;
/** 数据标识id所占的位数 */
private final long datacenterIdBits = 5L;
/** 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数) */
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
/** 支持的最大数据标识id,结果是31 */
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
/** 序列在id中占的位数 */
private final long sequenceBits = 12L;
/** 机器ID向左移12位 */
private final long workerIdShift = sequenceBits;
/** 数据标识id向左移17位(12+5) */
private final long datacenterIdShift = sequenceBits + workerIdBits;
/** 时间截向左移22位(5+5+12) */
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
/** 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095) */
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
/** 工作机器ID(0~31) */
private long workerId;
/** 数据中心ID(0~31) */
private long datacenterId;
/** 毫秒内序列(0~4095) */
private long sequence = 0L;
/** 上次生成ID的时间截 */
private long lastTimestamp = -1L;
public SnowflakeIdWorker(long workerId, long datacenterId) {
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
}
/**
* 获得下一个ID (该方法是线程安全的)
* @return SnowflakeId
*/
public synchronized long nextId() {
long timestamp = timeGen();
//如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
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;
}
//上次生成ID的时间截
lastTimestamp = timestamp;
//移位并通过或运算拼到一起组成64位的ID
return ((timestamp - twepoch) << timestampLeftShift)
| (datacenterId << datacenterIdShift)
| (workerId << workerIdShift)
| sequence;
}
/**
* 阻塞到下一个毫秒,直到获得新的时间戳
* @param lastTimestamp 上次生成ID的时间截
* @return 当前时间戳
*/
protected long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 返回以毫秒为单位的当前时间
* @return 当前时间(毫秒)
*/
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(Long.toBinaryString(id));
System.out.println(id);
}
}
}
snowflake算法时间回拨问题
分析时间回拨产生原因:
1)人为操作,在真实环境一般不会出现,基本可以排除。
2)由于有些业务的需要,机器需要同步时间服务器(在这个过程中可能会存在时间回拨)。
时间问题回拨的解决方法:
1)当回拨时间小于15ms,就等时间追上来后继续生成。
2)当时间大于15ms,通过更换workid来产生之前都没有产生过的来解决。
3)把workid的位数进行调整(15位可以达到3万多,一般够用了)
Snowflake算法调整下位段:
- sign(1bit):固定1bit符号标识,即生成的畅途分布式唯一id为正数。
- delta seconds (38 bits):当前时间,相对于时间基点"2017-12-21"的增量值,单位:毫秒,最多可支持约8.716年。
- worker id (15 bits):机器id,最多可支持约3.28万个节点。
- sequence (10 bits):每秒下的并发序列,10 bits,这个算法单机每秒内理论上最多可以生成1000*(2^10),也就是100W的ID,完全能满足业务的需求。
由于服务无状态化关系,所以一般workid也并不配置在具体配置文件里面,这里我们选择redis来进行中央存储(zk、db)都是一样的,只要是集中式的就可以。
现在把3万多个workid放到一个队列中(基于redis),由于需要一个集中的地方来管理workId,每当节点启动时,(先在本地某个地方看看是否有借鉴弱依赖zk本地先保存),如果有那么值就作为workid,如果不存在,就在队列中取一个当workid来使用(队列取走了就没了 ),当发现时间回拨太多的时候,我们就再去队列取一个来当新的workid使用,把刚刚那个使用回拨的情况的workid存到队列里面(队列我们每次都是从头取,从尾部进行插入,这样避免刚刚a机器使用又被b机器获取的可能性)。
参考文档:
https://blog.csdn.net/u010953880/article/details/85089098
https://www.cnblogs.com/haoxinyue/p/5208136.html
https://blog.csdn.net/u011499747/article/details/78254990
https://www.cnblogs.com/jiangxinlingdu/p/8440413.html