分庫分表的 9種分佈式主鍵ID 生成方案,挺全乎的

《sharding-jdbc 分庫分表的 4種分片策略》 中咱們介紹了 sharding-jdbc 4種分片策略的使用場景,能夠知足基礎的分片功能開發,這篇咱們來看看分庫分表後,應該如何爲分片表生成全局惟一的主鍵 IDjavascript

引入任何一種技術都是存在風險的,分庫分表固然也不例外,除非庫、表數據量持續增長,大到必定程度,以致於現有高可用架構已沒法支撐,不然不建議你們作分庫分表,由於作了數據分片後,你會發現本身踏上了一段踩坑之路,而分佈式主鍵 ID 就是遇到的第一個坑。css

不一樣數據節點間生成全局惟一主鍵是個棘手的問題,一張邏輯表 t_order 拆分紅多個真實表 t_order_n,而後被分散到不一樣分片庫 db_0db_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...

目前已經有了許多第三放解決方案能夠完美解決這個問題,好比基於 UUIDSNOWFLAKE算法 、segment號段,使用特定算法生成不重複鍵,或者直接引用主鍵生成服務,像美團(Leaf)和 滴滴(TinyId)等。git

sharding-jdbc 內置了兩種分佈式主鍵生成方案,UUIDSNOWFLAKE,不只如此它還抽離出分佈式主鍵生成器的接口,以便於開發者實現自定義的主鍵生成器,後續咱們會在自定義的生成器中接入 滴滴(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 執行的數據會以賦的值爲準。
  • 不要給主鍵字段設置自增屬性,不然主鍵ID 會以默認的 SNOWFLAKE 方式生成。好比:用 mybatis plus@TableId 註解給字段 order_id 設置了自增主鍵,那麼此時配置哪一種方案,老是按雪花算法生成。

下面咱們從源碼上分析下 sharding-jdbc 內置主鍵生成方案 UUIDSNOWFLAKE 是怎麼實現的。數據庫

UUID

打開 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

SNOWFLAKE(雪花算法)是默認使用的主鍵生成方案,生成一個 64bit的長整型(Long)數據。

sharding-jdbc 中雪花算法生成的主鍵主要由 4部分組成,1bit符號位、41bit時間戳位、10bit工做進程位以及 12bit 序列號位。

雪花算法ID組成

符號位(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 遞增的自增主鍵該怎麼實現呢?別急,後邊已經會給出瞭解決辦法!

SNOWFLAKE 主鍵ID

自定義

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

自定義主鍵 SPI 配置

上面這些弄完咱們測試一下,配置定義好的主鍵生成類型 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 生成方案,挺全乎的

舉一反九

既然能夠自定義生成方案,那麼實現分佈式主鍵的思路就不少了,又想到以前我寫的這篇 《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-nds/Springboot-Notebook/tree/master/springboot-sharding-jdbc

相關文章
相關標籤/搜索