什么是分布式系统唯一ID
在复杂的分布式系统中,往往需要对大量的数据进行唯一标识,如在金融、电商、支付等产品的系统中,随着数据的日益增长,数据库的自增ID显然不能作为某个订单的唯一标识。除此之外还有其他分布式场景对分布式ID有一些要求:
- 趋势递增:由于多数RDBMS使用B-Tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。
- 单调递增:保证下一ID一定大于上一个ID,例如排序。
- 信息安全:如果ID是连续的,恶意用户的爬取工作就非常容易了。
就不同场景及要求,市面上诞生了很多分布式ID解决方案。本文针对多中方案进行介绍,包括其特点、使用场景及代码示例。
一、UUID
1、介绍
UUID是基于当前时间、计数器和硬件标识(通常为无线网卡的MAC地址)等数据计算生成的。包含32个16进制数字,以连字号分为5段,形式为8-4-4-4-12的36个字符,可以生成全球唯一的编码并且性能高效。
2、案例
JDK提供了UUID的生成工具,代码如下:
import java.util.UUID;
public class Test {
public static void main(String[] args) {
System.out.println(UUID.randomUUID());
}
}
3、特点
虽说UUID可以满足分布式唯一标识,并且性能非常高(本地生成没有网络消耗),但在实际应用中一般不采用,来看一下它的缺点吧
- 不易存储:UUID太长,16字节128位,通常以36长度的字符串标识,很多场景不适用。
- 信息不安全:基于MAC地址生成的UUID算法会暴露MAC地址,曾经梅丽莎病毒的制造者就是根据UUID寻找的。
- 不符合Mysql主键要求:MySQL官方由明确的建议主键尽量越短越好,因为太长对MySQL索引不利:如果作为数据库主键,在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能。
二、 数据库自增ID
利用Mysql的特性ID自增,可以达到数据唯一标识,但是分库分表后只能保证一个表中的ID的唯一,而不能保证整体的ID唯一。为了避免这种情况,我们有以下两种方式解决该问题。
1、主键表
通过单独创建主键表维护唯一标识,作为ID的输出源可以保证整体ID的唯一。举个例子,创建一个主键表:
CREATE TABLE `unique_id` (
`id` bigint NOT NULL AUTO_INCREMENT,
`biz` char(1) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `biz` (`biz`)
) ENGINE = InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET =utf8;
业务通过更新操作来获取ID信息,然后添加到某个分表中。 在插入数据之前都来主键维护表中来获取对应的主键值,通过这个来保证ID的唯一性。
BEGIN;
REPLACE INTO unique_id (biz) values ('o') ;
SELECT LAST_INSERT_ID();
COMMIT;
2、ID自增步长设置
我们可以设置Mysql主键自增步长,让分布在不同实例的表数据ID做到不重复,保证整体的唯一。如下,可以设置Mysql实例步长为1、2。
3、号段模式
号段模式是当下分布式ID生成器的主流实现方式之一。其原理如下:
- 号段模式每次从数据库取出一个号段范围,加载到服务内存中。业务获取时ID直接在这个范围递增取值即可。
- 等这批号段ID用完,再次向数据库申请新号段,对max_id字段做一次update操作,新的号段范围是(
max_id
,max_id +step]
。由于多业务端可能同时操作,所以采用版本号version乐观锁方式更新。
四、Redis INCR
1、介绍
基于全局唯一ID的特性,我们可以通过Redis的INCR命令(原子性保证、Redis单线程)来生成全局唯一ID。
2、案例
Redis分布式ID的简单案例:
/**
* Redis 分布式ID生成器
*/
@Component
public class RedisDistributedId {
@Autowired
private StringRedisTemplate redisTemplate;
private static final long BEGIN_TIMESTAMP = 1659312000l;
/**
* 生成分布式ID
* 符号位 时间戳[31位] 自增序号【32位】
* @param item
* @return
*/
public long nextId(String item){
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
// 格林威治时间差
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
// 我们需要获取的 时间戳 信息
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序号 --》 从Redis中获取
// 当前当前的日期
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 获取对应的自增的序号
Long increment = redisTemplate.opsForValue()
.increment("id:" + item + ":" + date);
return timestamp << 32 | increment;
}
}
3、特点
优点:
- 不依赖于数据库,灵活方便,且性能优于数据库。
- 数字ID天然排序,对分页或者需要排序的结果很有帮助。
缺点:
- 如果系统中没有Redis,还需要引入新的组件,增加系统复杂度。
- 需要编码和配置的工作量比较大。
五、雪花算法(snowflake)
1、介绍
雪花算法(Snowflake)是twitter公司内部分布式项目采用的ID生成算法,这种方案大致来说是一种以划分命名空间(UUID也算,由于比较常见,所以单独分析)来生成ID的一种算法,这种方案把64-bit分别划分成多段,分开来标示机器、时间等。
比如在snowflake中的64-bit分别表示如下图(图片来自网络)所示:
第一部分:占用1bit,第一位为符号位,不使用
第二部分:41位的时间戳,41bit位可以表示241个数,每个数代表的是毫秒,那么雪花算法的时间年限是(1L<<41)/(1000L*3600*24*365)=69年
第三部分:10位工作机器id,前五位是数据中心,后五位为机器id,即 2^10 = 1024台机器,通常不会部署这么多机器
第四部分:12bit位是自增序列,可以表示
2^12=4096
个数,一秒内可以生成4096个ID,理论上snowflake方案的QPS约为409.6w/s
2、案例
public class SnowflakeIdWorker {
// ==============================Fields===========================================
/**
* 开始时间截 (2020-11-03,一旦确定不可更改,否则时间被回调,或者改变,可能会造成id重复或冲突)
*/
private final long twepoch = 1604374294980L;
/**
* 机器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;
//==============================Constructors=====================================
/**
* 构造函数
*
*/
public SnowflakeIdWorker() {
this.workerId = 0L;
this.datacenterId = 0L;
}
/**
* 构造函数
*
* @param workerId 工作ID (0~31)
* @param datacenterId 数据中心ID (0~31)
*/
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;
}
// ==============================Methods==========================================
/**
* 获得下一个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();
}
/**
* 随机id生成,使用雪花算法
*
* @return
*/
public static String getSnowId() {
SnowflakeIdWorker sf = new SnowflakeIdWorker();
String id = String.valueOf(sf.nextId());
return id;
}
//=========================================Test=========================================
/**
* 测试
*/
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(id);
}
}
}
3、特点
优点:
- 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。
- 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的。
- 可以根据自身业务特性分配bit位,非常灵活。
缺点:
- 强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。
六、美团(Leaf)
Leaf由美团开发,github地址:GitHub - Meituan-Dianping/Leaf: Distributed ID Generate Service
同时支持号段模式和Snowflake算法模式,可以切换使用。
1、Leaf-segment号段模式
号段模式的具体实现思路同TinyID一样,在使用数据库的方案上,做了如下改变:
- 原方案每次获取ID都得读写一次数据库,造成数据库压力大。改为利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。
- 各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。
2、Leaf-segment雪花算法
完全沿用snowflake方案的bit位设计,即是“1+41+10+12”的方式组装ID号。为了解决集权下workID的问题,美团的Leaf-snowflake跟百度不一样,百度是通过数据库来生成workID,而美团是通过Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID,所以Leaf是依赖ZK服务的。特点:
- 弱依赖ZooKeeper:除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。这样做到了对ZK的弱依赖。
- 解决时钟问题:因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,需要解决时钟回退的问题。
七、百度(Uidgenerator)
1、介绍
UidGenerator是百度开源的Java语言实现,基于Snowflake算法的唯一ID生成器。它是分布式的,并克服了雪花算法的并发限制。单个实例的QPS能超过6000000。
需要的环境:JDK8+,MySQL(用于分配WorkerId)。
2、案例
源码地址:
- https://github.com/baidu/uid-generator
中文文档地址:
https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md
3、结构
百度的Uidgenerator对结构做了部分的调整,具体如下:
- sign(1bit)
- 固定1bit符号标识,即生成的UID为正数。
- delta seconds (28 bits)
- 当前时间,相对于时间基点"2016-05-20"的增量值,单位:秒,最多可支持约8.7年
- worker id (22 bits)
- 机器id,最多可支持约420w次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略。
- sequence (13 bits)
- 每秒下的并发序列,13 bits可支持每秒8192个并发。
八、滴滴(TinyID)
1、介绍
Tinyid是在美团(Leaf)的leaf-segment
算法基础上升级而来,不仅支持了数据库多主节点模式,还提供了tinyid-client
客户端的接入方式,使用起来更加方便。但和美团(Leaf)不同的是,Tinyid只支持号段一种模式不支持雪花模式。Tinyid提供了两种调用方式,一种基于Tinyid-server
提供的http方式,另一种Tinyid-client
客户端方式。
开源项目连接:https://github.com/didi/tinyid
2、原理
- tinyid是基于数据库发号算法实现的,简单来说是数据库中保存了可用的id号段,tinyid会将可用号段加载到内存中,之后生成id会直接内存中产生。
- 可用号段在第一次获取id时加载,如当前号段使用达到一定量时,会异步加载下一可用号段,保证内存中始终有可用号段。当前号段使用完毕,下一号段会替换为当前号段。依次类推。
3、特点
优点:
- id为本地生成(调用AtomicLong.addAndGet方法),性能大大增加。
- client对server访问变的低频,减轻了server的压力,无须担心网络延迟。
- 即便所有server挂掉,因为client预加载了号段,依然可以继续使用一段时间。
缺点:
- 如果client机器较多频繁重启,可能会浪费较多的id。(原因是什么?)
九、总结
优点 | 缺点 | |
UUID | 代码实现简单、没有网络开销,性能好 | 占用空间大、无序 |
数据库自增ID | 利用数据库系统的功能实现,成本小、ID自增有序 | 并发性能受Mysql限制、强依赖DB,当DB异常时系统不可用,致命 |
Redis INCR | 不依赖于数据库、ID有序 | 解决单点为题带来的数据一致性问题使得复杂度提高 |
雪花算法 | 不依赖数据库等第三方系统,性能也是非常高、可根据自身业务特性分配bit位,非常灵活 | 强依赖机器时间钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态 |
号段模式 | 数据库的压力小 | 单点故障ID不连续 |
Leaf、Unidgenerator、TinyID | 高性能、高可用、接入简单 | 依赖第三方组件如Zookeeper、Mysql |