乐趣区

关于数据库:如何在-TiDB-上高效运行序列号生成服务

TiDB 从 v4.0 版本开始正式反对序列性能,而除了序列之外还有多种序列号生成计划,这些计划在没有对 TiDB 优化的时候个别会产生写入热点问题。本文将介绍如何应答写入热点问题高效运行序列号服务。

为什么须要(惟一)序列号

主键是关系模型设计中的第二范式,参照第二范式,所有表都应具备主键。实际操作中,OLTP 零碎中承载交易的要害表会通过设置主键来确保记录的唯一性。

主键应具备不可变性,而具备业务属性的字段都不具备这样的个性,即便如身份证号,也存在升位、过期、屡次办理等业务场景,将身份证号作为主键而不得不进行批改时,就会对业务产生重大影响。因而选取主键的一个根本准则就是采纳与业务不相干的字段作为代理键,惟一序列号即承载这样的性能。

常见的序列号生成计划

惟一序列号生成计划有很多种,有依赖数据库本身个性的序列和自增列,有开源的分布式惟一 ID 生成器,也有非常灵活的号段调配计划:

  1. 自增列:自增(auto_increment)是大多数兼容 MySQL 协定的 RDBMS 上列的一种属性,通过配置该属性来使数据库为该列的值主动赋值,用户不须要为该列赋值,该列的值随着表内记录减少会主动增长,并确保唯一性。在大多数场景中,自增列被作为无业务涵义的代理主键应用。自增列的局限性在于:自增列只能采纳整型字段,所赋的值也只能为整型。假如业务所须要的序列号由字母、数字及其他字符拼接而成,用户是难以通过自增列来获取序列号中所需的数字自增值的。
  2. 序列(Sequence):序列是一种数据库对象,应用程序通过调用某个序列能够产生递增的序列值,应用程序能够灵便的应用这个序列值为一张表或多张表赋值,也能够应用序列值进行更简单的加工,来实现文本和数字的组合,来赋予代理键以肯定的跟踪和分类的意义。TiDB 从 v4.0 版本开始提供序列性能,详情请参考官网文档。
  3. 号段调配计划:号段(segment)调配是从数据库一次获取一批 ID,将获取的 ID 看成一个范畴,例如 (500,1000],这个范畴称为一个号段或步进(step),利用一次申请一个号段,加载到内存中,而后利用生成 ID,当号段应用完后,再次申请一个新的号段,这样以批量获取的形式来提高效率,理论应用过程中,能够通过调节获取号段大小管制数据库记录更新频度。号段调配计划须要通过利用代码来实现相干逻辑,具备很好的灵活性,例如能够引入工夫因素,来实现序列号在工夫上的递增,来防止反复;也能够灵便的通过文本和数字的组合来赋予代理键以肯定的跟踪和分类的意义。但相应的带来了肯定的额定开发工作量。具体构建计划请参考 tidb-in-action 文章。
  4. 类 snowflake 分布式惟一 ID 生成器:这种计划是由 Twitter 提出的分布式 ID 生成计划,它通过划分命名空间来生成 ID,这种计划把 64-bit 划分为多段,切分后的段别离用以标识工夫、机器号、序列号等。该计划不依赖于数据据库,稳定性高,ID 生成速度快,还能够依据本身业务配置 bit 位,非常灵活。该计划十分依赖发号机器的本地时钟,时钟回拨可能会导致发号反复,在应用中须要留神这一点。除了 Twitter snowflake 之外,相似的惟一 ID 生成器还有百度 uid-generator 和美团 Leaf 等。具体构建计划请参考 tidb-in-action 文章。

图 1. Twitter snowflake 64 位 id 构造

序列号与 TiDB 写入热点

惟一序列号多被用于为表的主键字段赋值。

大部分单机 RDBMS 采纳 B+ tree 数据结构,主键往往是用于组织数据的要害索引(此时表被称作索引组织表),同一数据页内的记录按主键程序寄存。因而单机 RDBMS 产品个别举荐写入间断的序列号,这样每次写入新的记录,都会程序增加到以后 B+ tree 索引节点的后续地位,以后的数据页写满时,会主动开始新一页的写入。相同,过于随机的主键值,会导致新记录被写入到数据页的某个两头地位,造成数据的挪动而带来了额定的开销。

尽管 TiDB 具备不同于单机 RDBMS 的数据结构,但程序的主键值写入,在 TiDB 上也会产生类的成果:TiKV 上一个的 region 被写满,进而决裂出一个新的 region,后续的写入转由新的 reigon 来承载。但甲之蜜糖,乙之砒霜,单机 RDBMS 的最佳实际放到 TiDB 上,会使写入压力总是集中在一个 region 上,这样就形成了继续的写入热点,无奈施展出 TiDB 这种分布式数据库的并行写入能力,升高了业务写入瓶颈,造成了系统资源的节约。

TiDB v4.0 版本提供了便于迅速辨认集群负载的 Dashboard 流量可视化页面(Key Visualizer),下图展现了写入热点的显示成果,两头一条亮堂的曲线即标记着存在一张间断写入 Key 值的表。而右上侧的一组线条则显示出一个写入压力较为平均的负载。Key Visualizer 的具体应用办法请参考官网文档。

图 2. 写入热点在 Dashboard Key Visualizer 中的显示成果

具体来说,TiDB 的写入热点是因为 TiKV 中 KV 的 Key 值间断写入造成的,依据 TiDB 的编码规定,在 TiDB v4.0 及更早的版本中,Key 的取值存在以下两种状况:

  1. 当表的主键为繁多字段,且该字段的类型为整型时,Key 值由该字段形成,Value 为所有字段值的拼接,因而整型主键的表为索引组织表。
  2. 其余状况,TiDB 会为表构建一个暗藏列 _tidb_rowid,Key 值由该暗藏列形成,Value 为所有字段值的拼接,表的主键(如果有的话)形成一个非聚簇索引,即数据并不以主键来组织。拿具备非整型主键的表来举例,它须要比单 int 型主键的表多写一个索引。

对于第二种状况,为了防止因为暗藏列 _tidb_rowid 的程序赋值而引起写入热点,TiDB 提供一个表属性 SHARD_ROW_ID_BITS 来管制所生成的暗藏列的值扩散到足以跳过一个 region 大小(96MB)的多个区间(分片)上,再借助 PD 的热点调度能力,最终将写入压力摊派到整个 TiKV 集群中。因为暗藏列不具备任何业务属性,因而这种打散热点的办法是对用户通明的。一般来说,咱们倡议用户为所有非繁多整型主键的表配置这个表属性,来打消这部分的热点隐患,具体应用办法请参考官网文档。

在第二章中形容的常见的四种序列号生成计划中,因为自增主键面对的是间断的整型数值的写入,因而它的打散形式比拟非凡,请参考官网文档对自增主键进行打散。

对于其余三种计划而言,它们都具备集成到利用代码的能力,也因而具备肯定的灵活性,本文将以 Twitter snowflake 为例,展现如何设计应用逻辑来取得较高的惟一 ID 生成效率。

在 TiDB 上高效的运行序列号生成服务

本测试基于两张表进行,在原始表构造中,主键为整型,其中一张表有一个索引,另一张表有两个索引,表构造如下:

CREATE TABLE `T_TX_GLOBAL_LIST` (`global_tx_id` varchar(32) NOT NULL,
  `global_tx_no` bigint NOT NULL,
  `trace_id` varchar(18) NOT NULL,
  `busi_unique_seq` varchar(18) NOT NULL,
  `as_code` varchar(10) NOT NULL,
  `as_version` varchar(5) NOT NULL,
  `framework_type` char(1) NOT NULL,
  `tx_stat` char(1) NOT NULL,
  `code` char(2) DEFAULT NULL,
  `msg` varchar(32) DEFAULT NULL,
  `create_time` timestamp NOT NULL,
  `update_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`global_tx_no`),
  KEY `index1` (`create_time`,`tx_stat`)
);

CREATE TABLE `T_TX_BRANCH_LIST` (`branch_tx_id` varchar(32) NOT NULL,
  `branch_tx_no` bigint NOT NULL,
  `global_tx_no` bigint NOT NULL,
  `trace_id` varchar(18) NOT NULL,
  `busi_unique_seq` varchar(18) NOT NULL,
  `ms_code` varchar(10) NOT NULL,
  `ms_version` varchar(5) NOT NULL,
  `framework_type` char(1) NOT NULL,
  `tx_stat` char(1) NOT NULL,
  `code` char(2) DEFAULT NULL,
  `msg` varchar(32) DEFAULT NULL,
  `input_data` longtext NOT NULL,
  `create_time` timestamp NOT NULL,
  `update_time` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`branch_tx_no`),
  KEY `index1` (`create_time`,`tx_stat`),
  KEY `index3` (`global_tx_no`)
);

基于这两张表,咱们编写了一个压测程序,压测的场景为批量写入(即 batch insert,形如 insert into t values(),(),(),(),()......();),每个事务向 T_TX_GLOBAL_LIST 表写入 20 行记录,向 T_TX_BRANCH_LIST 表写入 100 行记录。两张表中的 global_tx_no 字段和 branch_tx_no 字段(高亮)应用 Twitter snowflake 生成。

Twitter snowflake 生成的惟一序列号类型为整型,因为序列号的后面大部分的 bit 位由工夫戳和机器号占据,只有最初的几个 bit 位为递增序列值,因而在一个时间段内生成的序列号的前几位数值雷同:

561632049706827776
561632049706827777
561632049706827778
561632049706827779
561632049706827780
561632049706827781
… ...

咱们将通过以下三个试验来展现如何打散 Twitter snowflake 的写入热点。

1. 第一个试验中,咱们采纳默认的表构造和默认 snowflake 设置,向表写入整型序列号,压测继续了 10h。通过 Key Visualizer 展现的负载能够发现显著的写入热点。写入点有 5 个,对应着两张表和 3 个索引。其中一条写入负载十分亮堂,是整张图中写入压力最大的一部分,从左侧的标识能够看到是 T_TX_BRANCH_LIST 的表记录局部的写入。

图 3. 默认设置下的写入负载

10h 的测试,两张表写入记录数为:

表名 10h 写入记录数
T_TX_GLOBAL_LIST 76685700
T_TX_BRANCH_LIST 383428500

2. 对 Snowflake 生成的序列号进行转换,将最初一位数字挪动到左数第二个数字的地位,原左数第二位数字及之后的所有数字向右挪动一位。以此来让生成的 ID 逾越 96MB 的 region 容量,落在 10 个不同的 region 中。间接在二进制 id 上做位运算会导致转换后的十进制 id 位数不稳固,因而这个转换须要将整型的序列号先转为字符型,进行文本操作换位之后再转为整型,经测试,这个转换带来 10% 左右的额定耗费,因为这个额定耗费产生在应用程序中,绝对于提早较高的数据库,其带来的额定的影响在整个压测链路中微不足道。

原始序列号 转换后的序列号
561632371724517376 566163237172451737
561632371728711680 506163237172871168
561632371728711681 516163237172871168
561632371728711682 526163237172871168
561632371732905984 546163237173290598
561632371732905985 556163237173290598
561632371732905986 566163237173290598
561632371732905987 576163237173290598
561632371732905988 586163237173290598
561632371737100288 586163237173710028

压测继续了 10h。通过 Key Visualizer 展现的负载能够看到,两张表的记录局部曾经被各自打散到 10 个写入分片上,三个索引的其中一个因为字段值的转换,也呈现出一种较为扩散的负载,负载图的整体亮度比拟平衡,没有显著的写入热点。

图 4. 序列号换位后的写入负载

10h 的测试,两张表写入记录数为:

表名 10h 写入记录数
T_TX_GLOBAL_LIST 150778840
T_TX_BRANCH_LIST 753894200

3. 将两张表中的 global_tx_no 字段和 branch_tx_no 字段改为字符型,这样两张表从繁多整型主键的索引组织表变为了按暗藏列组织的表。对两张表减少 shard_row_id_bits=4 pre_split_regions=4 参数,以扩散写入压力。因为主键类型产生了变动,还须要再程序中对 snowflake 生成的序列号类型做整型到字符型的转换。

压测继续了 10h。通过 Key Visualizer 展现的负载能够看到,存在 5 条亮堂的线条,这是因为两张表的主键变为了非聚簇索引,导致须要独自 region 来寄存主键,索引的数目因而变为了 5 个。而数据局部因为减少了打散参数,各自呈现出 16 个分片的平均写入负载。

图 5. 将主键转为字符型并打散后的写入负载

10h 的测试,两张表写入记录数为:

表名 10h 写入记录数
T_TX_GLOBAL_LIST 136921680
T_TX_BRANCH_LIST 684608400

测试论断及示例代码

测试论断

a. 从上面的测试成绩表能够看出,默认表构造配合 snowflake 默认配置生成的序列号,因为存在重大的写入热点,其写入性能较另外两个测试有较大的差距。

b. 整型主键配合序列号换位,取得了本次测试中的最佳性能。咱们还另外进行了开端 2 位数字与开端 3 位数字的换位测试,但过多的写入分片(2 位数字 100 个分片,3 位数字 1000 个分片)反而拖慢了写入性能,一般来讲使分片数量靠近集群 tikv 实例个数能够充沛的施展集群的性能,用户须要依据本身的集群规模来制订换位策略。

图 6. 整型主键换位(2 地位换),100 个分片

图 7. 整型主键换位(3 地位换),1000 个分片

c. 字符型主键 shard 也取得了不错的写入性能,但因为额定的热点索引写入,其性能略低于序列号换位计划。易用性是它的劣势,用户能够通过简略的表构造变更来获取优异的写入性能。

测试轮次 T_TX_GLOBAL_LIST 表记录数(行) T_TX_BRANCH_LIST 表记录数(行)
测试一,整型主键默认配置 76685700 383428500
测试二,整型主键换位(1 位 150778840 753894200
测试二,整型主键换位(2 位) 123447080 617235400
测试二,整型主键换位(3 位) 101330340 506651700
测试三,字符型主键 shard 136921680 684608400

示例代码

###   
public synchronized long nextId() {long currStmp = getNewstmp();
        if (currStmp < lastStmp) {throw new RuntimeException("Clock moved backwards.  Refusing to generate id");
        }

        if (currStmp == lastStmp) {
            // 雷同毫秒内,序列号自增
            sequence = (sequence + 1) & MAX_SEQUENCE;
            // 同一毫秒的序列数曾经达到最大
            if (sequence == 0L) {currStmp = getNextMill();
            }
        } else {
            // 不同毫秒内,序列号置为 0
            sequence = 0L;
        }

        lastStmp = currStmp;
        /**
         * XX......XX XX000000 00000000 00000000    时间差 XX
         *              XXXXX0 00000000 00000000    数据中心 ID XX
         *                   X XXXX0000 00000000    机器 ID XX
         *                         XXXX XXXXXXXX    序列号 XX
         *  三局部进行 | 位或运算:如果绝对应位都是 0,则后果为 0,否则为 1
         */
        long id = (currStmp - START_STMP) << TIMESTMP_LEFT // 工夫戳局部
                | datacenterId << DATACENTER_LEFT       // 数据中心局部
                | machineId << MACHINE_LEFT             // 机器标识局部
                | sequence;                             // 序列号局部

        String strid = String.valueOf(id);
        int length = strid.length();
        String lastnum = strid.substring(length - 1, length);
        String str = strid.substring(1, length - 1);
        String head = strid.substring(0, 1);

        StringBuilder sb = new StringBuilder(head).append(lastnum).append(str);
        return Long.valueOf(sb.toString());
    }
###
  • 必须应用时间差作为首段做位运算,因为其它段调整为首段做位运算会呈现生成序列号位数不统一的问题。
  • 应用字符串拼接形式效率尽管升高,然而从一次交易总体工夫上看是能够疏忽不记的。

结语

以后版本(v4.0)的易用性还有待增强,TiDB v5.0 版本将正式推出聚簇索引性能,新版本中的聚簇索引将反对任意类型的索引字段,而具备整型主键的表也能够被设置为非主键组织表,这代表采纳整型主键的表能够很便捷的通过表属性 SHARD_ROW_ID_BITS 来扩散写入热点,大家敬请期待!

退出移动版