本文主要对常见的几种唯一ID生成方案做了分析,包括UUID、业务规则生成、数据库生成、Redis生成、雪花算法及其开源框架实现。
UUID,英文全称 Universally Unique Identifier,翻译成中文就是通用唯一识别码。
使用 JDK 自带的工具类,就能生成,代码如下
public static void main(String[] args) {
UUID uuid = UUID.randomUUID();
System.out.println(uuid);
}
输出结果
9ddf5b2d-de13-4116-b9d9-6e67a8163898
他的优点是非常突出的,本地生成,性能极高,且不会存在并发问题。
他的缺点同样也是非常明显的,那就是他太长了。不利于持久化落库。
UUID 不适合作为 MySQL 数据库的主键,因为无序过长的主键,会频繁造成页分裂,对性能的损耗是非常大的。对 MySQL 知识感兴趣的小伙伴可以移步小七的另一篇文章,这里就不再展开了。
这种方案,极度依赖于业务场景。
比如下单买东西的订单号,就完全可以通过以下规则生成【身份证号(客户 ID) + 业务发生时间戳 + 顺序号】,按这种规则生成的订单号,正常情况下根本不可能重复,除非客户恶意刷单。
再举个例子,比如我们使用打车软件打车。如果使用【时间戳 + 车牌号 + 起点编号】生成订单号,你觉得在这种场景下,id 会重复吗?
本地生成,实现简单,且不会存在并发问题。
极度依赖业务场景,且没有统一发号器,不好维护。
这种方案就是使用数据库的自增主键作为唯一 ID。
如果你想搞一个全局唯一 ID,还可以在你自己的库里专门搞一张表,然后用这一张表专门生成 ID。
也可以进一步升级,专门搞一个库,里面只有一张表去生成 ID。
实现超简单,基本不用动脑子。
承载的并发低,且单表数据会越来越大,水平扩展困难,在分布式数据库环境下,无法保证唯一性。
该方案其实也是通过数据库生成 ID 的一种变种方案。他的核心思想是使用 replace into 语法替代insert into,这样可以避免表中的行数过大。last_insert_id()函数是 connection 级别的,客户端之间相互不影响,代表你这个连接最近生成的ID。
注意:不存在事务问题,存储引擎使用MyISAM更高效。
CREATE TABLE `id_generator` (
`id` bigint(20) unsigned NOT NULL auto_increment,
`stub` char(1) NOT NULL default '',
PRIMARY KEY (`id`),
UNIQUE KEY `stub` (`stub`)
) ENGINE=MyISAM;
REPLACE INTO id_generator(stub) VALUES ('a');
SELECT LAST_INSERT_ID();
实现简单,如果对并发要求不高,可以用于生产。
同样存在数据库瓶颈问题,扛不住高并发,并且扩容困难,分库分表困难。
以上说的数据库方案,原则上都需要进行高可用升级和集群部署,也就是说,可以根据不同的步长部署机器。
我们知道分布式锁是可以通过 redis 实现的,那么同理 redis 是否可以用来生成分布式唯一 ID 呢?
我们可以利用Redis的单线程特性,使用他的自增命令,想要获取ID,直接通过客户端拿就行了。但是在Redis集群下,需要设置每台机器的步长,比如5台机器,每台机器的初始值依次为1、2、3、4、5,那么每台机器的自增步长是5。
不用额外开发,直接用就行,高性能,高并发,高可用,全局唯一。
存在极大的扩容问题,比如5台Redis扛不住了,我们现在想要再增加一台,那么增加以后步长就是6,以前的ID怎么办?而且一旦上了主从同步和高可用,我们也要考虑主从同步是异步的这个问题。
Snowflake算法是由Twitter公司,开源出来的一种生成全局唯一ID的算法,github地址如下:
原生Snowflake算法使用一个64 bit的整型数据,根据当前的时间来生成ID。 结果如下:
通过观察以上数据结构,我们可以知道该算法主要依赖时间戳和机器号。
那么现在摆在我们眼前的问题就是如何确定时间戳和机器号。
机器号,好解决,最笨的方法,每个jia包单独指定一个机器号,或者在磁盘上持久化一个机器号也行,启动的时候取加载他就好了,当然如果你有使用zookeeper等注册中心,直接使用zookeeper来获取就行了。
机器号解决了,那时间戳呢?服务器存在时钟回拨的问题。什么是时间回拨,说通俗一点就是,多台机器的时间不一致了,他们需要同步成一样的时间,如果是当前时间慢了,需要往后拨,这是没什么问题的。但是如果时间快了,那么这个时候就需要把时间往前拨了,那么时间戳必然就会有重复,那么同一台机器的机器号也是相同的,就有可能会生成重复的ID了,这个是原生雪花算法没有解决的问题。
接下来我们分析一下,常见的两个开源项目
<dependency>
<groupId>cn.hutoolgroupId>
<artifactId>hutool-allartifactId>
<version>5.5.7version>
dependency>
//参数1为终端ID
//参数2为数据中心ID
Snowflake snowflake = IdUtil.getSnowflake(1, 1);
long id = snowflake.nextId();
IdUtil
public static Snowflake getSnowflake(long workerId, long datacenterId) {
// 这个方法保证我们获取到的对象是单例的
return Singleton.get(Snowflake.class, workerId, datacenterId);
}
Snowflake
public synchronized long nextId() {
// 当前时间戳
long timestamp = genTime();
// 上一个时间戳,比当前时间戳还大,说明发生了时钟回拨
if (timestamp < this.lastTimestamp) {
if(this.lastTimestamp - timestamp < 2000){
// 容忍2秒内的回拨,避免NTP校时造成的异常
timestamp = lastTimestamp;
} else{
// 如果服务器时间有问题(时钟后退) 报错。
throw new IllegalStateException(StrUtil.format("Clock moved backwards. Refusing to generate id for {}ms", lastTimestamp - timestamp));
}
}
// 如果上次时间与当前时间相等(回拨后,也是相等的)
if (timestamp == this.lastTimestamp) {
final long sequence = (this.sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
this.sequence = sequence;
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampLeftShift) | (dataCenterId << dataCenterIdShift) | (workerId << workerIdShift) | sequence;
}
private long tilNextMillis(long lastTimestamp) {
long timestamp = genTime();
// 循环直到操作系统时间戳变化
while (timestamp == lastTimestamp) {
timestamp = genTime();
}
if (timestamp < lastTimestamp) {
// 如果发现新的时间戳比上次记录的时间戳数值小,说明操作系统时间发生了倒退,报错
throw new IllegalStateException(
StrUtil.format("Clock moved backwards. Refusing to generate id for {}ms", lastTimestamp - timestamp));
}
return timestamp;
}
通过阅读以上代码我们可以知道hutool解决时间回拨的办法是比较暴力的,在2秒之内的回拨是循环等待时钟追上的。
美团leaf有很多种使用方式,这里我们讲解一种:使用leaf-starter注解来启动leaf。
注:除了雪花算法的优化实现,leaf还有另外一种基于数据库号段的模式(相当于Flickr 的全局主键生成方案的优化实现,感兴趣的同学可以参考上面的github连接)
git clone [email protected]:Meituan-Dianping/Leaf.git
git checkout feature/spring-boot-starter
cd leaf
mvn clean install -Dmaven.test.skip=true
<dependency>
<artifactId>leaf-boot-starterartifactId>
<groupId>com.sankuai.inf.leafgroupId>
<version>1.0.1-RELEASEversion>
dependency>
leaf.name=com.sankuai.leaf.opensource.test
leaf.segment.enable=false
#leaf.segment.url=
#leaf.segment.username=
#leaf.segment.password=
leaf.snowflake.enable=false
#leaf.snowflake.address=
#leaf.snowflake.port=
//EnableLeafServer 开启leafserver
@SpringBootApplication
@EnableLeafServer
public class LeafdemoApplication {
public static void main(String[] args) {
SpringApplication.run(LeafdemoApplication.class, args);
}
}
//直接使用 spring注入
public class T {
@Autowired
private SegmentService segmentService;
@Autowired
private SnowflakeService snowflakeService;
}
Leaf-snowflake是按照下面几个步骤启动的:
//发生了回拨,此刻时间小于上次发号时间
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset <= 5) {
try {
//时间偏差大小小于5ms,则等待两倍时间
wait(offset << 1);//wait
timestamp = timeGen();
if (timestamp < lastTimestamp) {
//还是小于,抛异常并上报
throwClockBackwardsEx(timestamp);
}
} catch (InterruptedException e) {
throw e;
}
} else {
//throw
throwClockBackwardsEx(timestamp);
}
}
//分配ID