关于分库分表:分库分表的-9种分布式主键ID-生成方案挺全乎的

8次阅读

共计 10494 个字符,预计需要花费 27 分钟才能阅读完成。

《sharding-jdbc 分库分表的 4 种分片策略》中咱们介绍了 sharding-jdbc 4 种分片策略的应用场景,能够满足根底的分片性能开发,这篇咱们来看看分库分表后,应该如何为分片表生成全局惟一的主键 ID

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

不同数据节点间生成全局惟一主键是个辣手的问题,一张逻辑表 t_order 拆分成多个实在表 t_order_n,而后被扩散到不同分片库 db_0db_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...

目前曾经有了许多第三放解决方案能够完满解决这个问题,比方基于 UUIDSNOWFLAKE算法、segment号段,应用特定算法生成不反复键,或者间接援用主键生成服务,像美团(Leaf)和 滴滴(TinyId)等。

sharding-jdbc 内置了两种分布式主键生成计划,UUIDSNOWFLAKE,不仅如此它还抽离出分布式主键生成器的接口,以便于开发者实现自定义的主键生成器,后续咱们会在自定义的生成器中接入 滴滴(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 内置主键生成计划 UUIDSNOWFLAKE 是怎么实现的。

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 字段已依照有序自增的形式插入记录,阐明配置的没问题。

举一反九

既然能够自定义生成计划,那么实现分布式主键的思路就很多了,又想到之前我写的这篇《9 种 分布式 ID 生成计划》,发现能够完满兼容,这里筛选其中的 滴滴(Tinyid)来实际一下,因为它是个独自的分布式 ID 生成服务,所以要先搭建环境了。

Tinyid 的服务提供HttpTinyid-client 两种接入形式,下边应用 Tinyid-client 形式疾速应用,更多的细节到这篇文章里看吧,切实是介绍过太屡次了。

Tinyid 服务搭建

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

因为是基于号段模式实现的分布式 ID,所以依赖于数据库,要创立相应的表 tiny_id_infotiny_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 服务搭建完下边在我的项目中引入它,新建个 tinyid_client.properties 文件其中增加 tinyid.servertinyid.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

测试 Tinyid 主键

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

总结

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

案例 GitHub 地址:https://github.com/chengxy-nd…

正文完
 0