15. sharding-jdbc中的分布式ID
Posted 阿飞的博客
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了15. sharding-jdbc中的分布式ID相关的知识,希望对你有一定的参考价值。
实现动机
传统数据库软件开发中,主键自动生成技术是基本需求。而各大数据库对于该需求也提供了相应的支持,比如mysql的自增键。 对于MySQL而言,分库分表之后,不同表生成全局唯一的Id是非常棘手的问题。因为同一个逻辑表内的不同实际表之间的自增键是无法互相感知的, 这样会造成重复Id的生成。我们当然可以通过约束表生成键的规则来达到数据的不重复,但是这需要引入额外的运维力量来解决重复性问题,并使框架缺乏扩展性。
目前有许多第三方解决方案可以完美解决这个问题,比如UUID等依靠特定算法自生成不重复键(由于InnoDB采用的B+Tree索引特性,UUID生成的主键插入性能较差),或者通过引入Id生成服务等。 但也正因为这种多样性导致了Sharding-JDBC如果强依赖于任何一种方案就会限制其自身的发展。
基于以上的原因,最终采用了以JDBC接口来实现对于生成Id的访问,而将底层具体的Id生成实现分离出来。
摘自sharding-jdbc分布式主键:http://shardingjdbc.io/1.x/docs/02-guide/key-generator/
sharding-jdbc的分布式ID采用twitter开源的snowflake算法,不需要依赖任何第三方组件,这样其扩展性和维护性得到最大的简化。但是snowflake算法最大的缺陷,即强依赖时间,如果时钟回拨,就会生成重复的ID,sharding-jdbc没有给出解决方案,如果用户想要解决这个问题,需要自行优化;
美团的分布式ID生成系统也是基于snowflake算法,并且解决了时钟回拨的问题,读者有兴趣请阅读Leaf——美团点评分布式ID生成系统:https://tech.meituan.com/MT_Leaf.html
分布式ID简介
github上对分布式ID这个特性的描述是:Distributed Unique Time-Sequence Generation
,两个重要特性是:分布式唯一和时间序;基于Twitter Snowflake算法实现,长度为64bit;64bit组成如下:
1bit :签名校验位,也可以当做预留位。
41bits :当前时间与服务第一版本发布时间的差值。
10bits :进程ID(也可以分解成2bit+8bit,其中2bit表示数据中心,可以容纳4个数据中心,8bit表示进程ID,可以256个进程)。
12bits :每一毫秒的自增值。所以snowflake算法能达到4096/ms的并发能力。
分布式ID源码分析
核心源码在sharding-jdbc-core模块中的io.shardingjdbc.core.keygen.DefaultKeyGenerator.java
中:
public final class DefaultKeyGenerator implements KeyGenerator {
public static final long EPOCH;
// 自增长序列的长度(单位是位时的长度)
private static final long SEQUENCE_BITS = 12L;
// workerId的长度(单位是位时的长度)
private static final long WORKER_ID_BITS = 10L;
private static final long SEQUENCE_MASK = (1 << SEQUENCE_BITS) - 1;
private static final long WORKER_ID_LEFT_SHIFT_BITS = SEQUENCE_BITS;
private static final long TIMESTAMP_LEFT_SHIFT_BITS = WORKER_ID_LEFT_SHIFT_BITS + WORKER_ID_BITS;
// 位运算计算workerId的最大值(workerId占10位,那么1向左移10位就是workerId的最大值)
private static final long WORKER_ID_MAX_VALUE = 1L << WORKER_ID_BITS;
@Setter
private static TimeService timeService = new TimeService();
// 工作机器ID
private static long workerId;
// EPOCH就是起始时间,从2016-11-01 00:00:00开始的毫秒数,这是sharding-jdbc第一个版本的发布时间
static {
// 省略逻辑代码
EPOCH = "2016-11-01 00:00:00".getTimeInMillis();
}
// 每一毫秒的自增序列数
private long sequence;
// 上一次取分布式ID的时间
private long lastTime;
// 得到分布式唯一ID需要先设置workerId,workId的值限定范围[0, 1024)
public static void setWorkerId(final long workerId) {
// google-guava提供的入参检查方法:workerId只能在0~WORKER_ID_MAX_VALUE之间;
Preconditions.checkArgument(workerId >= 0L && workerId < WORKER_ID_MAX_VALUE);
DefaultKeyGenerator.workerId = workerId;
}
// 调用该方法,得到分布式唯一ID
@Override
public synchronized Number generateKey() {
long currentMillis = timeService.getCurrentMillis();
// 每次取分布式唯一ID的时间不能少于上一次取时的时间
Preconditions.checkState(lastTime <= currentMillis, "Clock is moving backwards, last time is %d milliseconds, current time is %d milliseconds", lastTime, currentMillis);
// 如果同一毫秒范围内,那么自增,否则从0开始
if (lastTime == currentMillis) {
// 如果自增后的sequence值超过4096,那么等待直到下一个毫秒
if (0L == (sequence = ++sequence & SEQUENCE_MASK)) {
currentMillis = waitUntilNextTime(currentMillis);
}
} else {
sequence = 0;
}
// 更新lastTime的值,即最后一次获取分布式唯一ID的时间
lastTime = currentMillis;
// 从这里可知分布式唯一ID的组成部分;
return ((currentMillis - EPOCH) << TIMESTAMP_LEFT_SHIFT_BITS) | (workerId << WORKER_ID_LEFT_SHIFT_BITS) | sequence;
}
// 获取下一毫秒的方法:死循环获取当前毫秒与lastTime比较,直到大于lastTime的值;
private long waitUntilNextTime(final long lastTime) {
long time = timeService.getCurrentMillis();
// 毕竟最长死循环1ms,而且还要在1ms内产生了4096个分布式唯一ID,所以这个while循环不能造成CPU飙高的问题。
while (time <= lastTime) {
time = timeService.getCurrentMillis();
}
return time;
}
}
获取workerId的三种方式
sharding-jdbc的sharding-jdbc-plugin
模块中,提供了三种方式获取workerId的方式,并提供接口获取分布式唯一ID的方法--generateKey()
,接下来对各种方式如何生成workerId进行分析;
HostNameKeyGenerator
根据hostname获取,源码如下(HostNameKeyGenerator.java):
/**
* 根据机器名最后的数字编号获取工作进程Id.如果线上机器命名有统一规范,建议使用此种方式.
* 例如机器的HostName为:dangdang-db-sharding-dev-01(公司名-部门名-服务名-环境名-编号)
* ,会截取HostName最后的编号01作为workerId.
*
* @author DonneyYoung
**/
static void initWorkerId() {
InetAddress address;
Long workerId;
try {
address = InetAddress.getLocalHost();
} catch (final UnknownHostException e) {
throw new IllegalStateException("Cannot get LocalHost InetAddress, please check your network!");
}
// 先得到服务器的hostname,例如JTCRTVDRA44,linux上可通过命令"cat /proc/sys/kernel/hostname"查看;
String hostName = address.getHostName();
try {
// 计算workerId的方式:
// 第一步hostName.replaceAll("\\d+$", ""),即去掉hostname后纯数字部分,例如JTCRTVDRA44去掉后就是JTCRTVDRA
// 第二步hostName.replace(第一步的结果, ""),即将原hostname的非数字部分去掉,得到纯数字部分,就是workerId
workerId = Long.valueOf(hostName.replace(hostName.replaceAll("\\d+$", ""), ""));
} catch (final NumberFormatException e) {
// 如果根据hostname截取不到数字,那么抛出异常
throw new IllegalArgumentException(String.format("Wrong hostname:%s, hostname must be end with number!", hostName));
}
DefaultKeyGenerator.setWorkerId(workerId);
}
IPKeyGenerator
根据IP获取,源码如下(IPKeyGenerator.java):
/**
* 根据机器IP获取工作进程Id,如果线上机器的IP二进制表示的最后10位不重复,建议使用此种方式
* ,列如机器的IP为192.168.1.108,二进制表示:11000000 10101000 00000001 01101100
* ,截取最后10位 01 01101100,转为十进制364,设置workerId为364.
*/
static void initWorkerId() {
InetAddress address;
try {
// 首先得到IP地址,例如192.168.1.108
address = InetAddress.getLocalHost();
} catch (final UnknownHostException e) {
throw new IllegalStateException("Cannot get LocalHost InetAddress, please check your network!");
}
// IP地址byte[]数组形式,这个byte数组的长度是4,数组0~3下标对应的值分别是192,168,1,108
byte[] ipAddressByteArray = address.getAddress();
// 由这里计算workerId源码可知,workId由两部分组成:
// 第一部分(ipAddressByteArray[ipAddressByteArray.length - 2] & 0B11) << Byte.SIZE:ipAddressByteArray[ipAddressByteArray.length - 2]即取byte[]倒数第二个值,即1,然后&0B11,即只取最后2位(IP段倒数第二个段取2位,IP段最后一位取全部8位,总计10位),然后左移Byte.SIZE,即左移8位(因为这一部分取得的是IP段中倒数第二个段的值);
// 第二部分(ipAddressByteArray[ipAddressByteArray.length - 1] & 0xFF):ipAddressByteArray[ipAddressByteArray.length - 1]即取byte[]最后一位,即108,然后&0xFF,即通过位运算将byte转为int;
// 最后将第一部分得到的值加上第二部分得到的值就是最终的workId
DefaultKeyGenerator.setWorkerId((long) (((ipAddressByteArray[ipAddressByteArray.length - 2] & 0B11) << Byte.SIZE) + (ipAddressByteArray[ipAddressByteArray.length - 1] & 0xFF)));
}
IPSectionKeyGenerator
根据IP段获取,源码如下(IPSectionKeyGenerator.java):
/**
* 浏览 {@link IPKeyGenerator} workerId生成的规则后,感觉对服务器IP后10位(特别是IPV6)数值比较约束.
*
* <p>
* 有以下优化思路:
* 因为workerId最大限制是2^10,我们生成的workerId只要满足小于最大workerId即可。
* 1.针对IPV4:
* ....IP最大 255.255.255.255。而(255+255+255+255) < 1024。
* ....因此采用IP段数值相加即可生成唯一的workerId,不受IP位限制。
* 2.针对IPV6:
* ....IP最大ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
* ....为了保证相加生成出的workerId < 1024,思路是将每个bit位的后6位相加。这样在一定程度上也可以满足workerId不重复的问题。
* </p>
* 使用这种IP生成workerId的方法,必须保证IP段相加不能重复
*
* @author DogFc
*/
static void initWorkerId() {
InetAddress address;
try {
address = InetAddress.getLocalHost();
} catch (final UnknownHostException e) {
throw new IllegalStateException("Cannot get LocalHost InetAddress, please check your network!");
}
// 得到IP地址的byte[]形式值
byte[] ipAddressByteArray = address.getAddress();
long workerId = 0L;
//如果是IPV4,计算方式是遍历byte[],然后把每个IP段数值相加得到的结果就是workerId
if (ipAddressByteArray.length == 4) {
for (byte byteNum : ipAddressByteArray) {
workerId += byteNum & 0xFF;
}
//如果是IPV6,计算方式是遍历byte[],然后把每个IP段后6位(& 0B111111 就是得到后6位)数值相加得到的结果就是workerId
} else if (ipAddressByteArray.length == 16) {
for (byte byteNum : ipAddressByteArray) {
workerId += byteNum & 0B111111;
}
} else {
throw new IllegalStateException("Bad LocalHost InetAddress, please check your network!");
}
DefaultKeyGenerator.setWorkerId(workerId);
}
总结
大道至简,笔者还是比较推荐HostNameKeyGenerator方式获取workerId,只需服务器按照标准统一配置好hostname即可;这种方案有点类似spring-boot:约定至上;并能够让架构最简化,不依赖任何第三方组件。因为依赖的组件越多,维护成本越高,出问题概率越大,可靠性更低!
当然HostNameKeyGenerator这种方式的话,就不能一台服务器上部署多个实例,事实上这不是问题。一般生产环境会有很多的服务器,也有很多的服务实例。我们可以通过交叉部署来避免这个问题。比如不要一台服务器上部署3个订单服务,而是3台服务器上分别部署1个订单服务实例,服务器上多余的资源可以部署其他服务。
总之,每种方法都有它的优缺点,我们要做的就是trade-off。
以上是关于15. sharding-jdbc中的分布式ID的主要内容,如果未能解决你的问题,请参考以下文章
数据库分库分表中间件 Sharding-JDBC 源码分析 —— 分布式主键