引入任何一种技术都是存在风险的,分库分表当然也不例外,除非库、表数据量持续增加,大到一定程度,以至于现有高可用架构已无法支撑,否则不建议大家做分库分表,因为做了数据分片后,你会发现自己踏上了一段踩坑之路,而分布式主键 ID
就是遇到的第一个坑。
不同数据节点间生成全局唯一主键是个棘手的问题,一张逻辑表 t_order
拆分成多个真实表 t_order_n
,然后被分散到不同分片库 db_0
、db_1
… ,各真实表的自增键由于无法互相感知从而会产生重复主键,此时数据库本身的自增主键,就无法满足分库分表对主键全局唯一的要求。
db_0–
|-- t_order_0
|-- t_order_1
|-- t_order_2
db_1–
|-- t_order_0
|-- t_order_1
|-- t_order_2
尽管我们可以通过严格约束,各个分片表自增主键的 初始值
和 步长
的方式来解决 ID
重复的问题,但这样会让运维成本陡增,而且可扩展性极差,一旦要扩容分片表数量,原表数据变动比较大,所以这种方式不太可取。
步长 step = 分表张数
db_0–
|-- t_order_0 ID: 0、6、12、18…
|-- t_order_1 ID: 1、7、13、19…
|-- t_order_2 ID: 2、8、14、20…
db_1–
|-- t_order_0 ID: 3、9、15、21…
|-- t_order_1 ID: 4、10、16、22…
|-- t_order_2 ID: 5、11、17、23…
目前已经有了许多第三放解决方案可以完美解决这个问题,比如基于 UUID
、SNOWFLAKE
算法 、segment
号段,使用特定算法生成不重复键,或者直接引用主键生成服务,像美团(Leaf
)和 滴滴(TinyId
)等。
而sharding-jdbc
内置了两种分布式主键生成方案,UUID
、SNOWFLAKE
,不仅如此它还抽离出分布式主键生成器的接口,以便于开发者实现自定义的主键生成器,后续我们会在自定义的生成器中接入 滴滴(TinyId
)的主键生成服务。
前边介绍过在 sharding-jdbc 中要想为某个字段自动生成主键 ID,只需要在 application.properties
文件中做如下配置:
主键字段
spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id
主键ID 生成方案
spring.shardingsphere.sharding.tables.t_order.key-generator.type=UUID
工作机器 id
spring.shardingsphere.sharding.tables.t_order.key-generator.props.worker.id=123
key-generator.column
表示主键字段,key-generator.type
为主键 ID 生成方案(内置或自定义的),key-generator.props.worker.id
为机器ID,在主键生成方案设为 SNOWFLAKE
时机器ID 会参与位运算。
在使用 sharding-jdbc 分布式主键时需要注意两点:
- 一旦
insert
插入操作的实体对象中主键字段已经赋值,那么即使配置了主键生成方案也会失效,最后SQL 执行的数据会以赋的值为准。 - 不要给主键字段设置自增属性,否则主键ID 会以默认的
SNOWFLAKE
方式生成。比如:用mybatis plus
的@TableId
注解给字段order_id
设置了自增主键,那么此时配置哪种方案,总是按雪花算法生成。
下面我们从源码上分析下 sharding-jdbc 内置主键生成方案 UUID
、SNOWFLAKE
是怎么实现的。
UUID
打开 UUID
类型的主键生成实现类 UUIDShardingKeyGenerator
的源码发现,它的生成规则只有 UUID.randomUUID()
这么一行代码,额~ 心中默默来了一句卧槽。
UUID 虽然可以做到全局唯一性,但还是不推荐使用它作为主键,因为我们的实际业务中不管是 user_id
还是 order_id
主键多为整型,而 UUID 生成的是个 32 位的字符串。
它的存储以及查询对 MySQL
的性能消耗较大,而且 MySQL
官方也明确建议,主键要尽量越短越好,作为数据库主键 UUID 的无序性还会导致数据位置频繁变动,严重影响性能。
public final class UUIDShardingKeyGenerator implements ShardingKeyGenerator {
private Properties properties = new Properties();
public UUIDShardingKeyGenerator() {
}
public String getType() {
return “UUID”;
}
public synchronized Comparable<?> generateKey() {
return UUID.randomUUID().toString().replaceAll(“-”, “”);
}
public Properties getProperties() {
return this.properties;
}
public void setProperties(Properties properties) {
this.properties = properties;
}
}
SNOWFLAKE
SNOWFLAKE
(雪花算法)是默认使用的主键生成方案,生成一个 64bit的长整型(Long
)数据。
sharding-jdbc
中雪花算法生成的主键主要由 4部分组成,1bit
符号位、41bit
时间戳位、10bit
工作进程位以及 12bit
序列号位。
符号位(1bit位)
Java 中 Long 型的最高位是符号位,正数是0,负数是1,一般生成ID都为正数,所以默认为0
时间戳位(41bit)
41位的时间戳可以容纳的毫秒数是 2 的 41次幂,而一年的总毫秒数为 1000L * 60 * 60 * 24 * 365
,计算使用时间大概是69年,额~,我有生之间算是够用了。
Math.pow(2, 41) / (365 * 24 * 60 * 60 * 1000L) = = 69年
工作进程位(10bit)
表示一个唯一的工作进程id,默认值为 0,可通过 key-generator.props.worker.id
属性设置。
spring.shardingsphere.sharding.tables.t_order.key-generator.props.worker.id=0000
序列号位(12bit)
同一毫秒内生成不同的ID。
时钟回拨
了解了雪花算法的主键 ID 组成后不难发现,这是一种严重依赖于服务器时间的算法,而依赖服务器时间的就会遇到一个棘手的问题:时钟回拨
。
为什么会出现时钟回拨呢?
互联网中有一种网络时间协议 ntp
全称 (Network Time Protocol
) ,专门用来同步、校准网络中各个计算机的时间。
这就是为什么,我们的手机现在不用手动校对时间,可每个人的手机时间还都是一样的。
我们的硬件时钟可能会因为各种原因变得不准( 快了
或 慢了
),此时就需要 ntp
服务来做时间校准,做校准的时候就会发生服务器时钟的 跳跃
或者 回拨
的问题。
雪花算法如何解决时钟回拨
服务器时钟回拨会导致产生重复的 ID,SNOWFLAKE
方案中对原有雪花算法做了改进,增加了一个最大容忍的时钟回拨毫秒数。
如果时钟回拨的时间超过最大容忍的毫秒数阈值,则程序直接报错;如果在可容忍的范围内,默认分布式主键生成器,会等待时钟同步到最后一次主键生成的时间后再继续工作。
最大容忍的时钟回拨毫秒数,默认值为 0,可通过属性 max.tolerate.time.difference.milliseconds
设置。
最大容忍的时钟回拨毫秒数
spring.shardingsphere.sharding.tables.t_order.key-generator.max.tolerate.time.difference.milliseconds=5
下面是看下它的源码实现类 SnowflakeShardingKeyGenerator
,核心流程大概如下:
最后一次生成主键的时间 lastMilliseconds
与 当前时间currentMilliseconds
做比较,如果 lastMilliseconds
> currentMilliseconds
则意味着时钟回调了。
那么接着判断两个时间的差值(timeDifferenceMilliseconds
)是否在设置的最大容忍时间阈值 max.tolerate.time.difference.milliseconds
内,在阈值内则线程休眠差值时间 Thread.sleep(timeDifferenceMilliseconds)
,否则大于差值直接报异常。
/**
- @author xiaofu
*/
public final class SnowflakeShardingKeyGenerator implements ShardingKeyGenerator{
@Getter
@Setter
private Properties properties = new Properties();
public String getType() {
return “SNOWFLAKE”;
}
public synchronized Comparable<?> generateKey() {
/**
- 当前系统时间毫秒数
/
long currentMilliseconds = timeService.getCurrentMillis();
/* - 判断是否需要等待容忍时间差,如果需要,则等待时间差过去,然后再获取当前系统时间
/
if (waitTolerateTimeDifferenceIfNeed(currentMilliseconds)) {
currentMilliseconds = timeService.getCurrentMillis();
}
/* - 如果最后一次毫秒与 当前系统时间毫秒相同,即还在同一毫秒内
/
if (lastMilliseconds == currentMilliseconds) {
/* - &位与运算符:两个数都转为二进制,如果相对应位都是1,则结果为1,否则为0
- 当序列为4095时,4095+1后的新序列与掩码进行位与运算结果是0
- 当序列为其他值时,位与运算结果都不会是0
- 即本毫秒的序列已经用到最大值4096,此时要取下一个毫秒时间值
/
if (0L == (sequence = (sequence + 1) & SEQUENCE_MASK)) {
currentMilliseconds = waitUntilNextTime(currentMilliseconds);
}
} else {
/* - 上一毫秒已经过去,把序列值重置为1
*/
vibrateSequenceOffset();
sequence = sequenceOffset;
}
lastMilliseconds = currentMilliseconds;
/**
- XX…XX XX000000 00000000 00000000 时间差 XX
-
XXXXXX XXXX0000 00000000 机器ID XX
-
XXXX XXXXXXXX 序列号 XX
- 三部分进行|位或运算:如果相对应位都是0,则结果为0,否则为1
*/
return ((currentMilliseconds - EPOCH) << TIMESTAMP_LEFT_SHIFT_BITS) | (getWorkerId() << WORKER_ID_LEFT_SHIFT_BITS) | sequence;
}
/**
- 判断是否需要等待容忍时间差
/
@SneakyThrows
private boolean waitTolerateTimeDifferenceIfNeed(final long currentMilliseconds) {
/* - 如果获取ID时的最后一次时间毫秒数小于等于当前系统时间毫秒数,属于正常情况,则不需要等待
/
if (lastMilliseconds <= currentMilliseconds) {
return false;
}
/* - ===>时钟回拨的情况(生成序列的时间大于当前系统的时间),需要等待时间差
/
/* - 获取ID时的最后一次毫秒数减去当前系统时间毫秒数的时间差
/
long timeDifferenceMilliseconds = lastMilliseconds - currentMilliseconds;
/* - 时间差小于最大容忍时间差,即当前还在时钟回拨的时间差之内
/
Preconditions.checkState(timeDifferenceMilliseconds < getMaxTolerateTimeDifferenceMilliseconds(),
“Clock is moving backwards, last time is %d milliseconds, current time is %d milliseconds”, lastMilliseconds, currentMilliseconds);
/* - 线程休眠时间差
*/
Thread.sleep(timeDifferenceMilliseconds);
return true;
}
// 配置的机器ID
private long getWorkerId() {
long result = Long.valueOf(properties.getProperty(“worker.id”, String.valueOf(WORKER_ID)));
Preconditions.checkArgument(result >= 0L && result < WORKER_ID_MAX_VALUE);
return result;
}
private int getMaxTolerateTimeDifferenceMilliseconds() {
return Integer.valueOf(properties.getProperty(“max.tolerate.time.difference.milliseconds”, String.valueOf(MAX_TOLERATE_TIME_DIFFERENCE_MILLISECONDS)));
}
private long waitUntilNextTime(final long lastTime) {
long result = timeService.getCurrentMillis();
while (result <= lastTime) {
result = timeService.getCurrentMillis();
}
return result;
}
}
但从 SNOWFLAKE
方案生成的主键ID 来看,order_id
它是一个18位的长整型数字,是不是发现它太长了,想要 MySQL
那种从 0 递增的自增主键该怎么实现呢?别急,后边已经会给出了解决办法!
自定义
sharding-jdbc
利用 SPI
全称( Service Provider Interface
) 机制拓展主键生成规则,这是一种服务发现机制,通过扫描项目路径 META-INF/services
下的文件,并自动加载文件里所定义的类。
实现自定义主键生成器其实比较简单,只有两步。
第一步,实现 ShardingKeyGenerator
接口,并重写其内部方法,其中 getType()
方法为自定义的主键生产方案类型、generateKey()
方法则是具体生成主键的规则。
下面代码中用 AtomicInteger
来模拟实现一个有序自增的 ID 生成。
/**
- @Author: xiaofu
- @Description: 自定义主键生成器
*/
@Component
public class MyShardingKeyGenerator implements ShardingKeyGenerator {
private final AtomicInteger count = new AtomicInteger();
/**
- 自定义的生成方案类型
*/
@Override
public String getType() {
return “XXX”;
}
/**
- 核心方法-生成主键ID
*/
@Override
public Comparable<?> generateKey() {
return count.incrementAndGet();
}
@Override
public Properties getProperties() {
return null;
}
@Override
public void setProperties(Properties properties) {
}
}
第二步,由于是利用 SPI
机制实现功能拓展,我们要在 META-INF/services
文件中配置自定义的主键生成器类路劲。
com.xiaofu.sharding.key.MyShardingKeyGenerator
上面这些弄完我们测试一下,配置定义好的主键生成类型 XXX
,并插入几条数据看看效果。
spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id
spring.shardingsphere.sharding.tables.t_order.key-generator.type=XXX
通过控制台的SQL 解析日志发现,order_id
字段已按照有序自增的方式插入记录,说明配置的没问题。
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!
由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!
如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)
![img](https://img-blog.csdnimg.cn/img_convert/11526f00f4b29fa70e1c306465186ac8.jpeg)
架构学习资料
由于篇幅限制小编,pdf文档的详解资料太全面,细节内容实在太多啦,所以只把部分知识点截图出来粗略的介绍,每个小节点里面都有更细化的内容!
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
4)]
[外链图片转存中…(img-aKxpgLrL-1713549203224)]
[外链图片转存中…(img-JkUAV7zX-1713549203224)]
[外链图片转存中…(img-sBofwMIA-1713549203225)]
[外链图片转存中…(img-j9xbxEU6-1713549203225)]
由于篇幅限制小编,pdf文档的详解资料太全面,细节内容实在太多啦,所以只把部分知识点截图出来粗略的介绍,每个小节点里面都有更细化的内容!
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!