分库分表的 9种分布式主键ID 生成方案,挺全乎的

运维 数据库运维 分布式
引入任何一种技术都是存在风险的,分库分表当然也不例外,除非库、表数据量持续增加,大到一定程度,以至于现有高可用架构已无法支撑,否则不建议大家做分库分表,因为做了数据分片后,你会发现自己踏上了一段踩坑之路,而分布式主键 ID 就是遇到的第一个坑。

 [[351290]]

本文转载自微信公众号「 程序员内点事」,作者 程序员内点事。转载本文请联系 程序员内点事公众号。

引入任何一种技术都是存在风险的,分库分表当然也不例外,除非库、表数据量持续增加,大到一定程度,以至于现有高可用架构已无法支撑,否则不建议大家做分库分表,因为做了数据分片后,你会发现自己踏上了一段踩坑之路,而分布式主键 ID 就是遇到的第一个坑。

不同数据节点间生成全局唯一主键是个棘手的问题,一张逻辑表 t_order 拆分成多个真实表 t_order_n,然后被分散到不同分片库 db_0、db_1... ,各真实表的自增键由于无法互相感知从而会产生重复主键,此时数据库本身的自增主键,就无法满足分库分表对主键全局唯一的要求。

  1. db_0-- 
  2.    |-- t_order_0 
  3.    |-- t_order_1 
  4.    |-- t_order_2 
  5. db_1-- 
  6.    |-- t_order_0 
  7.    |-- t_order_1 
  8.    |-- t_order_2 

尽管我们可以通过严格约束,各个分片表自增主键的 初始值 和 步长 的方式来解决 ID 重复的问题,但这样会让运维成本陡增,而且可扩展性极差,一旦要扩容分片表数量,原表数据变动比较大,所以这种方式不太可取。

  1. 步长 step = 分表张数 
  2.  
  3. db_0-- 
  4.    |-- t_order_0  ID: 0、6、12、18... 
  5.    |-- t_order_1  ID: 1、7、13、19... 
  6.    |-- t_order_2  ID: 2、8、14、20... 
  7. db_1-- 
  8.    |-- t_order_0  ID: 3、9、15、21... 
  9.    |-- t_order_1  ID: 4、10、16、22... 
  10.    |-- t_order_2  ID: 5、11、17、23... 

目前已经有了许多第三放解决方案可以完美解决这个问题,比如基于 UUID、SNOWFLAKE算法 、segment号段,使用特定算法生成不重复键,或者直接引用主键生成服务,像美团(Leaf)和 滴滴(TinyId)等。

而sharding-jdbc 内置了两种分布式主键生成方案,UUID、SNOWFLAKE,不仅如此它还抽离出分布式主键生成器的接口,以便于开发者实现自定义的主键生成器,后续我们会在自定义的生成器中接入 滴滴(TinyId)的主键生成服务。

前边介绍过在 sharding-jdbc 中要想为某个字段自动生成主键 ID,只需要在 application.properties 文件中做如下配置:

  1. # 主键字段 
  2. spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id 
  3. # 主键ID 生成方案 
  4. spring.shardingsphere.sharding.tables.t_order.key-generator.type=UUID 
  5. # 工作机器 id 
  6. 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 的无序性还会导致数据位置频繁变动,严重影响性能。

  1. public final class UUIDShardingKeyGenerator implements ShardingKeyGenerator { 
  2.     private Properties properties = new Properties(); 
  3.  
  4.     public UUIDShardingKeyGenerator() { 
  5.     } 
  6.  
  7.     public String getType() { 
  8.         return "UUID"
  9.     } 
  10.  
  11.     public synchronized Comparable<?> generateKey() { 
  12.         return UUID.randomUUID().toString().replaceAll("-"""); 
  13.     } 
  14.  
  15.     public Properties getProperties() { 
  16.         return this.properties; 
  17.     } 
  18.  
  19.     public void setProperties(Properties properties) { 
  20.         this.properties = properties; 
  21.     } 

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年,额~,我有生之间算是够用了。

  1. Math.pow(2, 41) / (365 * 24 * 60 * 60 * 1000L) = = 69年  

工作进程位(10bit)

表示一个唯一的工作进程id,默认值为 0,可通过 key-generator.props.worker.id 属性设置。

  1. 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 设置。

  1. # 最大容忍的时钟回拨毫秒数 
  2. 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),否则大于差值直接报异常。

  1. /** 
  2.  * @author xiaofu 
  3.  */ 
  4. public final class SnowflakeShardingKeyGenerator implements ShardingKeyGenerator{ 
  5.     @Getter 
  6.     @Setter 
  7.     private Properties properties = new Properties(); 
  8.      
  9.     public String getType() { 
  10.         return "SNOWFLAKE"
  11.     } 
  12.      
  13.     public synchronized Comparable<?> generateKey() { 
  14.      /** 
  15.       * 当前系统时间毫秒数  
  16.       */  
  17.         long currentMilliseconds = timeService.getCurrentMillis(); 
  18.         /** 
  19.          * 判断是否需要等待容忍时间差,如果需要,则等待时间差过去,然后再获取当前系统时间  
  20.          */  
  21.         if (waitTolerateTimeDifferenceIfNeed(currentMilliseconds)) { 
  22.             currentMilliseconds = timeService.getCurrentMillis(); 
  23.         } 
  24.         /** 
  25.          * 如果最后一次毫秒与 当前系统时间毫秒相同,即还在同一毫秒内  
  26.          */ 
  27.         if (lastMilliseconds == currentMilliseconds) { 
  28.          /** 
  29.           * &位与运算符:两个数都转为二进制,如果相对应位都是1,则结果为1,否则为0 
  30.           * 当序列为4095时,4095+1后的新序列与掩码进行位与运算结果是0 
  31.           * 当序列为其他值时,位与运算结果都不会是0 
  32.           * 即本毫秒的序列已经用到最大值4096,此时要取下一个毫秒时间值 
  33.           */ 
  34.             if (0L == (sequence = (sequence + 1) & SEQUENCE_MASK)) { 
  35.                 currentMilliseconds = waitUntilNextTime(currentMilliseconds); 
  36.             } 
  37.         } else { 
  38.          /** 
  39.           * 上一毫秒已经过去,把序列值重置为1  
  40.           */ 
  41.             vibrateSequenceOffset(); 
  42.             sequence = sequenceOffset; 
  43.         } 
  44.         lastMilliseconds = currentMilliseconds; 
  45.          
  46.         /** 
  47.          * XX......XX XX000000 00000000 00000000 时间差 XX 
  48.          *    XXXXXX XXXX0000 00000000 机器ID XX 
  49.          *               XXXX XXXXXXXX 序列号 XX 
  50.          *  三部分进行|位或运算:如果相对应位都是0,则结果为0,否则为1 
  51.          */ 
  52.         return ((currentMilliseconds - EPOCH) << TIMESTAMP_LEFT_SHIFT_BITS) | (getWorkerId() << WORKER_ID_LEFT_SHIFT_BITS) | sequence
  53.     } 
  54.      
  55.     /** 
  56.      * 判断是否需要等待容忍时间差 
  57.      */ 
  58.     @SneakyThrows 
  59.     private boolean waitTolerateTimeDifferenceIfNeed(final long currentMilliseconds) { 
  60.      /** 
  61.       * 如果获取ID时的最后一次时间毫秒数小于等于当前系统时间毫秒数,属于正常情况,则不需要等待  
  62.       */ 
  63.         if (lastMilliseconds <= currentMilliseconds) { 
  64.             return false
  65.         } 
  66.         /** 
  67.          * ===>时钟回拨的情况(生成序列的时间大于当前系统的时间),需要等待时间差  
  68.          */ 
  69.         /** 
  70.          * 获取ID时的最后一次毫秒数减去当前系统时间毫秒数的时间差  
  71.          */ 
  72.         long timeDifferenceMilliseconds = lastMilliseconds - currentMilliseconds; 
  73.         /** 
  74.          * 时间差小于最大容忍时间差,即当前还在时钟回拨的时间差之内  
  75.          */ 
  76.         Preconditions.checkState(timeDifferenceMilliseconds < getMaxTolerateTimeDifferenceMilliseconds(),  
  77.                 "Clock is moving backwards, last time is %d milliseconds, current time is %d milliseconds", lastMilliseconds, currentMilliseconds); 
  78.         /** 
  79.          * 线程休眠时间差  
  80.          */ 
  81.         Thread.sleep(timeDifferenceMilliseconds); 
  82.         return true
  83.     } 
  84.      
  85.     // 配置的机器ID 
  86.     private long getWorkerId() { 
  87.         long result = Long.valueOf(properties.getProperty("worker.id", String.valueOf(WORKER_ID))); 
  88.         Preconditions.checkArgument(result >= 0L && result < WORKER_ID_MAX_VALUE); 
  89.         return result; 
  90.     } 
  91.      
  92.     private int getMaxTolerateTimeDifferenceMilliseconds() { 
  93.         return Integer.valueOf(properties.getProperty("max.tolerate.time.difference.milliseconds", String.valueOf(MAX_TOLERATE_TIME_DIFFERENCE_MILLISECONDS))); 
  94.     } 
  95.      
  96.     private long waitUntilNextTime(final long lastTime) { 
  97.         long result = timeService.getCurrentMillis(); 
  98.         while (result <= lastTime) { 
  99.             result = timeService.getCurrentMillis(); 
  100.         } 
  101.         return result; 
  102.     } 

但从 SNOWFLAKE 方案生成的主键ID 来看,order_id 它是一个18位的长整型数字,是不是发现它太长了,想要 MySQL 那种从 0 递增的自增主键该怎么实现呢?别急,后边已经会给出了解决办法!

自定义

sharding-jdbc 利用 SPI 全称( Service Provider Interface) 机制拓展主键生成规则,这是一种服务发现机制,通过扫描项目路径 META-INF/services 下的文件,并自动加载文件里所定义的类。

实现自定义主键生成器其实比较简单,只有两步。

第一步,实现 ShardingKeyGenerator 接口,并重写其内部方法,其中 getType() 方法为自定义的主键生产方案类型、generateKey() 方法则是具体生成主键的规则。

下面代码中用 AtomicInteger 来模拟实现一个有序自增的 ID 生成。

  1. /** 
  2.  * @Author: xiaofu 
  3.  * @Description: 自定义主键生成器 
  4.  */ 
  5. @Component 
  6. public class MyShardingKeyGenerator implements ShardingKeyGenerator { 
  7.  
  8.  
  9.     private final AtomicInteger count = new AtomicInteger(); 
  10.  
  11.     /** 
  12.      * 自定义的生成方案类型 
  13.      */ 
  14.     @Override 
  15.     public String getType() { 
  16.         return "XXX"
  17.     } 
  18.  
  19.     /** 
  20.      * 核心方法-生成主键ID 
  21.      */ 
  22.     @Override 
  23.     public Comparable<?> generateKey() { 
  24.         return count.incrementAndGet(); 
  25.     } 
  26.  
  27.     @Override 
  28.     public Properties getProperties() { 
  29.         return null
  30.     } 
  31.  
  32.     @Override 
  33.     public void setProperties(Properties properties) { 
  34.  
  35.     } 

第二步,由于是利用 SPI 机制实现功能拓展,我们要在 META-INF/services 文件中配置自定义的主键生成器类路劲。

  1. com.xiaofu.sharding.key.MyShardingKeyGenerator 

上面这些弄完我们测试一下,配置定义好的主键生成类型 XXX,并插入几条数据看看效果。

  1. spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id 
  2. spring.shardingsphere.sharding.tables.t_order.key-generator.type=XXX 

通过控制台的SQL 解析日志发现,order_id 字段已按照有序自增的方式插入记录,说明配置的没问题。

举一反九

既然可以自定义生成方案,那么实现分布式主键的思路就很多了,又想到之前我写的这篇 《9种 分布式ID生成方案》,发现可以完美兼容,这里挑选其中的 滴滴(Tinyid)来实践一下,由于它是个单独的分布式ID生成服务,所以要先搭建环境了。

Tinyid 的服务提供Http 和 Tinyid-client 两种接入方式,下边使用 Tinyid-client 方式快速使用,更多的细节到这篇文章里看吧,实在是介绍过太多次了。

Tinyid 服务搭建

先拉源代码 https://github.com/didi/tinyid.git。

由于是基于号段模式实现的分布式ID,所以依赖于数据库,要创建相应的表 tiny_id_info 、tiny_id_token 并插入默认数据。

  1. CREATE TABLE `tiny_id_info` ( 
  2.  `id` BIGINT (20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增主键'
  3.  `biz_type` VARCHAR (63) NOT NULL DEFAULT '' COMMENT '业务类型,唯一'
  4.  `begin_id` BIGINT (20) NOT NULL DEFAULT '0' COMMENT '开始id,仅记录初始值,无其他含义。初始化时begin_id和max_id应相同'
  5.  `max_id` BIGINT (20) NOT NULL DEFAULT '0' COMMENT '当前最大id'
  6.  `step` INT (11) DEFAULT '0' COMMENT '步长'
  7.  `delta` INT (11) NOT NULL DEFAULT '1' COMMENT '每次id增量'
  8.  `remainder` INT (11) NOT NULL DEFAULT '0' COMMENT '余数'
  9.  `create_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '创建时间'
  10.  `update_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间'
  11.  `version` BIGINT (20) NOT NULL DEFAULT '0' COMMENT '版本号'
  12.  PRIMARY KEY (`id`), 
  13.  UNIQUE KEY `uniq_biz_type` (`biz_type`) 
  14. ) ENGINE = INNODB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COMMENT 'id信息表'
  15.  
  16. CREATE TABLE `tiny_id_token` ( 
  17.  `id` INT (11) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增id'
  18.  `token` VARCHAR (255) NOT NULL DEFAULT '' COMMENT 'token'
  19.  `biz_type` VARCHAR (63) NOT NULL DEFAULT '' COMMENT '此token可访问的业务类型标识'
  20.  `remark` VARCHAR (255) NOT NULL DEFAULT '' COMMENT '备注'
  21.  `create_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '创建时间'
  22.  `update_time` TIMESTAMP NOT NULL DEFAULT '2010-01-01 00:00:00' COMMENT '更新时间'
  23.  PRIMARY KEY (`id`) 
  24. ) ENGINE = INNODB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COMMENT 'token信息表'
  25.  
  26. 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'); 
  27.  
  28. 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 服务中配置上边表所在数据源信息

  1. datasource.tinyid.primary.url=jdbc:mysql://47.93.6.e:3306/ds-0?autoReconnect=true&useUnicode=true&characterEncoding=UTF-8 
  2. datasource.tinyid.primary.username=root 
  3. datasource.tinyid.primary.password=root 

最后项目 maven install ,右键 TinyIdServerApplication 启动服务, Tinyid 分布式ID生成服务就搭建完毕了。

自定义 Tinyid 主键类型

Tinyid 服务搭建完下边在项目中引入它,新建个 tinyid_client.properties 文件其中添加 tinyid.server 和 tinyid.token 属性,token 为之前 SQL 预先插入的用户数据。

  1. # tinyid 分布式ID 
  2. # 服务地址 
  3. tinyid.server=127.0.0.1:9999 
  4. # 业务token 
  5. tinyid.token=0f673adf80504e2eaa552f5d791b644c 

代码中获取 ID更简单,只需一行代码,业务类型 order 是之前 SQ L 预先插入的数据。

  1. Long id = TinyId.nextId("order"); 

我们开始自定义 Tinyid 主键生成类型的实现类 TinyIdShardingKeyGenerator 。

  1. /** 
  2.  * @Author: xiaofu 
  3.  * @Description: 自定义主键生成器 
  4.  */ 
  5. @Component 
  6. public class TinyIdShardingKeyGenerator implements ShardingKeyGenerator { 
  7.      
  8.     /** 
  9.      * 自定义的生成方案类型 
  10.      */ 
  11.     @Override 
  12.     public String getType() { 
  13.         return "tinyid"
  14.     } 
  15.  
  16.     /** 
  17.      * 核心方法-生成主键ID 
  18.      */ 
  19.     @Override 
  20.     public Comparable<?> generateKey() { 
  21.          
  22.         Long id = TinyId.nextId("order"); 
  23.          
  24.         return id; 
  25.     } 
  26.  
  27.     @Override 
  28.     public Properties getProperties() { 
  29.         return null
  30.     } 
  31.  
  32.     @Override 
  33.     public void setProperties(Properties properties) { 
  34.  
  35.     } 

并在配置文件中启用 Tinyid 主键生成类型,到此配置完毕,赶紧测试一下。

  1. # 主键字段 
  2. spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id 
  3. # 主键ID 生成方案 
  4. spring.shardingsphere.sharding.tables.t_order.key-generator.type=tinyid 

测试 Tinyid 主键

向数据库插入订单记录测试发现,主键ID字段 order_id 已经为趋势递增的了, Tinyid 服务成功接入,完美!

总结

后续的八种生成方式大家参考 《9种 分布式ID生成方案》 按需接入吧,整体比较简单这里就不依次实现了。

案例 GitHub 地址:https://github.com/chengxy-nds/Springboot-Notebook/tree/master/springboot-sharding-jdbc刘一手

原文链接:https://mp.weixin.qq.com/s/x1gVtnKh2OEAzSwv0sFDxg

 

责任编辑:武晓燕 来源: 程序员内点事
相关推荐

2024-04-18 09:24:32

分布式ID分库分表

2017-06-19 17:55:22

CASID分布式

2016-11-29 09:12:21

数据库分布式ID

2024-02-22 17:02:09

IDUUID雪花算法

2023-09-03 22:14:23

分布式ID

2023-01-12 17:46:37

分库分表id如何生成

2023-08-26 20:08:15

分库分表Spring

2017-07-19 16:25:07

数据库开发DB分库主键生成策略

2017-07-01 16:02:39

分布式ID生成器

2020-07-31 10:15:32

分布式ID数据库MySQL

2020-11-04 14:20:58

分布式数据库MySQL

2017-07-04 16:18:15

分布式云应用导图

2021-06-05 07:33:09

ID分布式架构

2019-09-05 13:06:08

雪花算法分布式ID

2023-03-05 18:23:38

分布式ID节点

2019-09-03 09:22:08

数据库Redis算法

2023-04-03 10:00:00

Redis分布式

2022-06-16 07:31:15

MySQL服务器服务

2021-08-04 10:38:51

分布式 ID策略

2024-03-28 10:01:38

点赞
收藏

51CTO技术栈公众号