一、分布式ID策略
1.目前数据库主键ID生成的策略整理了这么几个,我们分析下每个的问题
1.1 数据库自增ID分析
我们创建数据库的时候,指定我们的id字段是主键,并且是自增的
create table demo_table
(
id int(10) primary key auto_increment,
name varchar(200)
)
这种方式如果我们的数据量小的话,其实是没有问题的。但是如果我们要涉及到分库分表的话,那么这么做是不是就会出现问题了呢?
一旦我们的业务出现了,分库分表,我们的自增ID只能在一个数据库实例里面保持一个唯一性。没办法在我们整个数据库实例里面保证一个唯一性。
1.1.1这种方式怎么解决呢?
不知道大家有没有用过 oracle,还记得oracle里面的自增id是怎么进行创建的吗?他是不是基于一个序列这么做的。如果我们使用mysql的话,我们可以单独建一个基于这个表的主键表。每次添加的时候 去通过 max(id)拿到最大的id,然后添加到数据库里面 不就可以了吗?对吧?但是实际想一下。如果我们这么做的话?首先主键表走下添加。然后我们在select下。这样真的好吗?(如果遇到并发的情况下呢?)
1.2 使用UUID作为主键的形式
UUID (Universally Unique Identifier):通用唯一识别码,UUID是基于当前时间、计数器(counter)和硬件标识(通常为无线网卡的MAC地址)等数据计算生成的。
UUID由以下几部分组成:
- 当前日期和时间,UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成了一个UUID,则第一个部分不同,其余相同
- 时钟序列
- 全局唯一的IEEE机器识别号,如果有网卡,从网卡MAC地址获得,没有网卡以其他方式获得。
UUID是由一组32位数的16进制数字所构成的。以连字号分隔的五组来显示,形式为8-4-4-4-12,总共有36个字符(既三十二个英数字母和四个连字号)。例如
aefbbd3a-9cc5-4655-8363-a2a43e6e6c80
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
1.2.1 使用UUID的缺点和优点:
如果需求是只保持唯一性,那么UUID是可以胜任的。如果按照分布式ID的要求,UUID是不能做分布式ID的,为什么呢?
- 首选id作为主键,尽量越短越好。为什么?因为在mysql官方推荐我们表的主键要尽量越短越好,而UUID每一个都很长。所以不推荐
- mysql的索引是通过b+树来实现的。每一次心的UUID数据的插入,为了查询的优化,都会对底层b+树进行修改,因为UUID是无序的,所以每一次UUID数据的插入都会对主键生成的B+数进行修改,这一点很不好
- 信息不安全:基于mac地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
1.3 依赖Redis生产分布式ID
基于全局唯一ID的特性,我们可以通过Redis的INCR命令来生成全局唯一ID
同样使用Redis也有对应的缺点:
- ID生成的持久化问题,如果Redis宕机了,redis里面的数据怎么恢复?
当前这个问题也是可以解决的。我们可以搭建Redis集群(主从复制的架构模式)
1.3.1 依赖Redis的优缺点
优点:
- 不依赖于数据库,灵活方便,且性能优于数据库
- 数字ID有序,对分页处理和排序都很友好。
- 防止了Redis的单机故障
缺点:
- Redsi单节点,无法保证Redis的数据的持久
- 集群节点确定是3个后,后面调整不是很友好
这里为什么确定是3个?因为Redis官方推荐的(主从复制架构模式)就必须是三个Redis节点,并且是在不同的宿主机上面。
1.3.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;
}
}
1.4 雪花算法生成ID
Snowflake,雪花算法是有Twitter开源的分布式ID生成算法,以划分命名空间的方式将64bit位分割成了多个部分,每个部分都有具体的不同含义,在Java中64Bit位的整数是Long类型,所以在Java中Snowflake算法生成的ID就是long来存储的。具体如下
第一部分:占用1bit,第一位为符号位,不适用
第二部分:41位的时间戳,41bit位可以表示241个数,每个数代表的是毫秒,那么雪花算法的时间年限是(241)/(1000×60×60×24×365)=69年
第三部分:10bit表示是机器数,即 2^ 10 = 1024台机器,通常不会部署这么多机器
第四部分:12bit位是自增序列,可以表示2^12=4096个数,一秒内可以生成4096个ID
1.4.1 雪花算法的缺点:
- 时钟回拨的问题:
一个服务可能部署在多个服务器节点,一个服务器节点的时间快,一个服务器节点的时间慢。那这个慢节点所在的服务,可能会生成重复的id。
雪花算法生成ID伪代码
/**
* Twitter_Snowflake
* SnowFlake的结构如下(每部分用-分开):
* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
* 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0
* 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
* 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69
* 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId
* 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号
* 加起来刚好64位,为一个Long型。
* SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
*/
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);
}
}
}
这里的机器id和机房id,小伙伴们可以根据自己不同的服务自己指定。
1.4.2 mybatis-plus id生成的雪花算法
mybatis-plus 自增的雪花算法和我们上面这个雪花算法是不一样的。他里面的算法是基于上面的算法进行了一个升级。
他在设置workerIdBits和datacenterId 是通过获取当前机器所在的ip和当前服务部署的一个进程然后求一个hasecode数值。来进行生成的
当然我也建议小伙伴们用他内部的这个:
1.5 百度(Uidgenerator)
源码地址
中文文档地址:
UidGenerator是百度开源的Java语言实现,基于Snowflake算法的唯一ID生成器。它是分布式的,并克服了雪花算法的并发限制。单个实例的QPS能超过6000000。需要的环境:JDK8+,MySQL(用于分配WorkerId)。
百度的Uidgenerator对结构做了部分的调整,具体如下:
UidGenerator的时间部分只有28位,这就意味着UidGenerator默认只能承受8.5年(2^28-1/86400/365)也可以根据你业务的需求,UidGenerator可以适当调整delta seconds、worker node id和sequence占用位数。
1.6 美团(Leaf)
由美团开发,开源项目链接:
Leaf同时支持号段模式和snowflake算法模式,可以切换使用。ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。
Leaf的snowflake模式依赖于ZooKeeper,不同于原始snowflake算法也主要是在workId的生成上,Leaf中workId是基于ZooKeeper的顺序Id来生成的,每个应用在使用Leaf-snowflake时,启动时都会都在Zookeeper中生成一个顺序Id,相当于一台机器对应一个顺序节点,也就是一个workId。
Leaf的号段模式是对直接用数据库自增ID充当分布式ID的一种优化,减少对数据库的频率操作。相当于从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如 (1,1000] 代表1000个ID,业务服务将号段在本地生成1~1000的自增ID并加载到内存.。
特性:
- 全局唯一,绝对不会出现重复的ID,且ID整体趋势递增。
- 高可用,服务完全基于分布式架构,即使MySQL宕机,也能容忍一段时间的数据库不可用。
- 高并发低延时,在CentOS 4C8G的虚拟机上,远程调用QPS可达5W+,TP99在1ms内。
- 接入简单,直接通过公司RPC服务或者HTTP调用即可接入。
Leaf采用双buffer的方式,它的服务内部有两个号段缓存区segment。当前号段已消耗10%时,还没能拿到下一个号段,则会另启一个更新线程去更新下一个号段。
简而言之就是Leaf保证了总是会多缓存两个号段,即便哪一时刻数据库挂了,也会保证发号服务可以正常工作一段时间。
1.7 滴滴(TinyID)
由滴滴开发,开源项目链接:
Tinyid是在美团(Leaf)的leaf-segment算法基础上升级而来,不仅支持了数据库多主节点模式,还提供了tinyid-client客户端的接入方式,使用起来更加方便。但和美团(Leaf)不同的是,Tinyid只支持号段一种模式不支持雪花模式。Tinyid提供了两种调用方式,一种基于Tinyid-server提供的http方式,另一种Tinyid-client客户端方式。每个服务获取一个号段(1000,2000]、(2000,3000]、(3000,4000]
特性:
1)全局唯一的long型ID
2)趋势递增的id
3)提供 http 和 java-client 方式接入
4)支持批量获取ID
5)支持生成1,3,5,7,9…序列的ID
6)支持多个db的配置
适用场景:只关心ID是数字,趋势递增的系统,可以容忍ID不连续,可以容忍ID的浪费
不适用场景:像类似于订单ID的业务,因生成的ID大部分是连续的,容易被扫库、或者推算出订单量等信息