在分布式系统中常会需要生成系统唯一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生成库操作的负载。
也是常见的方式,可以用数据库或程序生成,全球唯一。
优点:
1)使用简单。
2)生成ID性能高,基本不会有性能问题。
3)全球唯一,当数据迁移、数据合并、数据库变更时,可以从容应对。
缺点:
1)数据无序,无法保证趋势递增。
2)UUID使用字符串存储,查询效率较低。
3)存储空间较大,如果是海量数据库,需考虑存储量的问题。
4)传输数据量大。
5)可读性差。
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);
}
当用数据库生成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主要通过其znode数据版本来生成序列号,可以生成32位和64位的数据版本号,客户端可以使用这个版本号作为唯一的序列号。
通常很少会使用zookeeper来生成唯一ID。原因是需要依赖zookeeper,并且是多步调用API,在竞争大时需要考虑使用分布式锁。因此,性能在高并发的分布式环境下,也不甚理想。
MongoDB的ObjectId和snowflake算法类似。它是轻量型的,不同的机器都能用全局唯一的同种方法方便地生成它。MongoDB从一开始就设计用来作为分布式数据库,处理多个节点是一个核心要求,使其在分片环境中要容易生成得多。
ObjectId由12个字节组成,分成四个部分:timestamp+machash+pid+inc。默认mongodb collection内的_id是唯一的。ObjectId使用12字节的存储空间,是一个由24个16进制数字组成的字符串(每个字节可以存储两个16进制数字)。
ObjectId("53102b43bf1044ed8b0ba36b").getTimestamp();
ISODate("2014-02-28T06:22:59Z");
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。
其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号,最后还有一个符号位,永远是0。
这个算法单机每秒内理论上最多可以生成1000*(2^12),也就是409.6万个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万多,一般够用了)
由于服务无状态化关系,所以一般workid也并不配置在具体配置文件里面,这里我们选择redis来进行中央存储(zk、db)都是一样的,只要是集中式的就可以。
现在把3万多个workid放到一个队列中(基于redis),由于需要一个集中的地方来管理workId,每当节点启动时,(先在本地某个地方看看是否有借鉴弱依赖zk本地先保存),如果有那么值就作为workid,如果不存在,就在队列中取一个当workid来使用(队列取走了就没了 ),当发现时间回拨太多的时候,我们就再去队列取一个来当新的workid使用,把刚刚那个使用回拨的情况的workid存到队列里面(队列我们每次都是从头取,从尾部进行插入,这样避免刚刚a机器使用又被b机器获取的可能性)。