《sharding-jdbc 分库分表的 4种分片策略》 中咱们介绍了 sharding-jdbc
4种分片策略的使用场景,能够知足基础的分片功能开发,这篇咱们来看看分库分表后,应该如何为分片表生成全局惟一的主键 ID
。javascript
引入任何一种技术都是存在风险的,分库分表固然也不例外,除非库、表数据量持续增长,大到必定程度,以致于现有高可用架构已没法支撑,不然不建议你们作分库分表,由于作了数据分片后,你会发现本身踏上了一段踩坑之路,而分布式主键 ID
就是遇到的第一个坑。css
不一样数据节点间生成全局惟一主键是个棘手的问题,一张逻辑表 t_order
拆分红多个真实表 t_order_n
,而后被分散到不一样分片库 db_0
、db_1
... ,各真实表的自增键因为没法互相感知从而会产生重复主键,此时数据库自己的自增主键,就没法知足分库分表对主键全局惟一的要求。java
db_0-- |-- t_order_0 |-- t_order_1 |-- t_order_2 db_1-- |-- t_order_0 |-- t_order_1 |-- t_order_2
尽管咱们能够经过严格约束,各个分片表自增主键的 初始值
和 步长
的方式来解决 ID
重复的问题,但这样会让运维成本陡增,并且可扩展性极差,一旦要扩容分片表数量,原表数据变更比较大,因此这种方式不太可取。mysql
步长 step = 分表张数 db_0-- |-- t_order_0 ID: 0、六、十二、18... |-- t_order_1 ID: 一、七、1三、19... |-- t_order_2 ID: 二、八、1四、20... db_1-- |-- t_order_0 ID: 三、九、1五、21... |-- t_order_1 ID: 四、十、1六、22... |-- t_order_2 ID: 五、十一、1七、23...
目前已经有了许多第三放解决方案能够完美解决这个问题,好比基于 UUID
、SNOWFLAKE
算法 、segment
号段,使用特定算法生成不重复键,或者直接引用主键生成服务,像美团(Leaf
)和 滴滴(TinyId
)等。git
而sharding-jdbc
内置了两种分布式主键生成方案,UUID
、SNOWFLAKE
,不只如此它还抽离出分布式主键生成器的接口,以便于开发者实现自定义的主键生成器,后续咱们会在自定义的生成器中接入 滴滴(TinyId
)的主键生成服务。github
前边介绍过在 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 会参与位运算。spring
在使用 sharding-jdbc 分布式主键时须要注意两点:sql
insert
插入操做的实体对象中主键字段已经赋值,那么即便配置了主键生成方案也会失效,最后SQL 执行的数据会以赋的值为准。SNOWFLAKE
方式生成。好比:用 mybatis plus
的 @TableId
注解给字段 order_id
设置了自增主键,那么此时配置哪一种方案,老是按雪花算法生成。下面咱们从源码上分析下 sharding-jdbc 内置主键生成方案 UUID
、SNOWFLAKE
是怎么实现的。数据库
打开 UUID
类型的主键生成实现类 UUIDShardingKeyGenerator
的源码发现,它的生成规则只有 UUID.randomUUID()
这么一行代码,额~ 心中默默来了一句XX。
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
(雪花算法)是默认使用的主键生成方案,生成一个 64bit的长整型(Long
)数据。
sharding-jdbc
中雪花算法生成的主键主要由 4部分组成,1bit
符号位、41bit
时间戳位、10bit
工做进程位以及 12bit
序列号位。
Java 中 Long 型的最高位是符号位,正数是0,负数是1,通常生成ID都为正数,因此默认为0
41位的时间戳能够容纳的毫秒数是 2 的 41次幂,而一年的总毫秒数为 1000L * 60 * 60 * 24 * 365
,计算使用时间大概是69年,额~,我有生之间算是够用了。
Math.pow(2, 41) / (365 * 24 * 60 * 60 * 1000L) = = 69年
表示一个惟一的工做进程id,默认值为 0,可经过 key-generator.props.worker.id
属性设置。
spring.shardingsphere.sharding.tables.t_order.key-generator.props.worker.id=0000
同一毫秒内生成不一样的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
字段已按照有序自增的方式插入记录,说明配置的没问题。
既然能够自定义生成方案,那么实现分布式主键的思路就不少了,又想到以前我写的这篇 《9种 分布式ID生成方案》,发现能够完美兼容,这里挑选其中的 滴滴(Tinyid
)来实践一下,因为它是个单独的分布式ID生成服务,因此要先搭建环境了。
Tinyid
的服务提供Http
和 Tinyid-client
两种接入方式,下边使用 Tinyid-client
方式快速使用,更多的细节到这篇文章里看吧,实在是介绍过太屡次了。
先拉源代码 https://github.com/didi/tinyid.git
。
因为是基于号段模式实现的分布式ID,因此依赖于数据库,要建立相应的表 tiny_id_info
、tiny_id_token
并插入默认数据。
CREATE TABLE `tiny_id_info` ( `id` BIGINT (20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键', `biz_type` VARCHAR (63) NOT NULL DEFAULT '' COMMENT '业务类型,惟一', `begin_id` BIGINT (20) NOT NULL DEFAULT '0' COMMENT '开始id,仅记录初始值,无其余含义。初始化时begin_id和max_id应相同', `max_id` BIGINT (20) NOT NULL DEFAULT '0' COMMENT '当前最大id', `step` INT (11) DEFAULT '0' COMMENT '步长', `delta` INT (11) NOT NULL DEFAULT '1' COMMENT '每次id增量', `remainder` INT (11) NOT NULL DEFAULT '0' COMMENT '余数', `create_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '建立时间', `update_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间', `version` BIGINT (20) NOT NULL DEFAULT '0' COMMENT '版本号', PRIMARY KEY (`id`), UNIQUE KEY `uniq_biz_type` (`biz_type`) ) ENGINE = INNODB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COMMENT 'id信息表'; CREATE TABLE `tiny_id_token` ( `id` INT (11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增id', `token` VARCHAR (255) NOT NULL DEFAULT '' COMMENT 'token', `biz_type` VARCHAR (63) NOT NULL DEFAULT '' COMMENT '此token可访问的业务类型标识', `remark` VARCHAR (255) NOT NULL DEFAULT '' COMMENT '备注', `create_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '建立时间', `update_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE = INNODB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COMMENT 'token信息表'; INSERT INTO `tiny_id_token` (`id`, `token`, `biz_type`, `remark`, `create_time`, `update_time`) VALUES ('1', '0f673adf80504e2eaa552f5d791b644c', 'order', '1', '2017-12-14 16:36:46', '2017-12-14 16:36:48'); INSERT INTO `tiny_id_info` (`id`, `biz_type`, `begin_id`, `max_id`, `step`, `delta`, `remainder`, `create_time`, `update_time`, `version`) VALUES ('1', 'order', '1', '1', '100000', '1', '0', '2018-07-21 23:52:58', '2018-07-22 23:19:27', '1');
并在 Tinyid
服务中配置上边表所在数据源信息
datasource.tinyid.primary.url=jdbc:mysql://47.93.6.e:3306/ds-0?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8 datasource.tinyid.primary.username=root datasource.tinyid.primary.password=root
最后项目 maven install
,右键 TinyIdServerApplication
启动服务, Tinyid
分布式ID生成服务就搭建完毕了。
Tinyid
服务搭建完下边在项目中引入它,新建个 tinyid_client.properties
文件其中添加 tinyid.server
和 tinyid.token
属性,token
为以前 SQL 预先插入的用户数据。
# tinyid 分布式ID # 服务地址 tinyid.server=127.0.0.1:9999 # 业务token tinyid.token=0f673adf80504e2eaa552f5d791b644c
代码中获取 ID更简单,只需一行代码,业务类型 order
是以前 SQ L 预先插入的数据。
Long id = TinyId.nextId("order");
咱们开始自定义 Tinyid
主键生成类型的实现类 TinyIdShardingKeyGenerator
。
/** * @Author: xiaofu * @Description: 自定义主键生成器 */ @Component public class TinyIdShardingKeyGenerator implements ShardingKeyGenerator { /** * 自定义的生成方案类型 */ @Override public String getType() { return "tinyid"; } /** * 核心方法-生成主键ID */ @Override public Comparable<?> generateKey() { Long id = TinyId.nextId("order"); return id; } @Override public Properties getProperties() { return null; } @Override public void setProperties(Properties properties) { } }
并在配置文件中启用 Tinyid
主键生成类型,到此配置完毕,赶忙测试一下。
# 主键字段 spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id # 主键ID 生成方案 spring.shardingsphere.sharding.tables.t_order.key-generator.type=tinyid
向数据库插入订单记录测试发现,主键ID字段 order_id
已经为趋势递增的了, Tinyid
服务成功接入,完美!
后续的八种生成方式你们参考 《9种 分布式ID生成方案》 按需接入吧,总体比较简单这里就不依次实现了。
案例 GitHub 地址:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-sharding-jdbc