TiDB 高并发写入场景最佳实践

    本文假设你已对 TiDB 有一定的了解,推荐先阅读 TiDB 原理相关的三篇文章(,说计算,),以及 TiDB Best Practice

    高并发批量插入场景

    高并发批量插入的场景通常出现在业务系统的批量任务中,例如清算以及结算等业务。此类场景存在以下特点:

    • 数据量大
    • 需要短时间内将历史数据入库
    • 需要短时间内读取大量数据

    这就对 TiDB 提出了以下挑战:

    • 写入/读取能力是否可以线性水平扩展
    • 随着数据持续大并发写入,数据库性能是否稳定不衰减

    对于分布式数据库来说,除了本身的基础性能外,最重要的就是充分利用所有节点能力,避免让单个节点成为瓶颈。

    如果要解决以上挑战,需要从 TiDB 数据切分以及调度的原理开始讲起。这里只作简单说明,详情可参阅。

    TiDB 以 Region 为单位对数据进行切分,每个 Region 有大小限制(默认 96M)。Region 的切分方式是范围切分。每个 Region 会有多副本,每一组副本,称为一个 Raft Group。每个 Raft Group 中由 Leader 负责执行这块数据的读 & 写(TiDB 支持 Follower-Read)。Leader 会自动地被 PD 组件均匀调度在不同的物理节点上,用以均分读写压力。

    从原理上来说,只要没有业务上的写入热点(即业务写入没有 的主键和单调递增的索引,更多细节可参阅 ),依靠这个架构,TiDB 不仅具备线性扩展的读写能力,也能够充分利用分布式资源。从这一点看,TiDB 尤其适合高并发批量写入场景的业务。

    但理论场景和实际情况往往存在不同。以下实例说明了热点是如何产生的。

    热点产生的实例

    以下为一张示例表:

    这个表的结构非常简单,除了 id 为主键以外,没有额外的二级索引。将数据写入该表的语句如下,id 通过随机数离散生成:

    1. INSERT INTO TEST_HOTSPOT
    2. SELECT
    3. n, -- ID
    4. RAND()*80, -- 0 80 之间的随机数
    5. CONCAT('user-',n),
    6. CONCAT(
    7. CHAR(65 + (RAND() * 25) USING ascii), -- 65 65+25 之间的随机数,转换为一个 A-Z 字符
    8. '-user-',
    9. n,
    10. '@example.com'
    11. )
    12. FROM
    13. (WITH RECURSIVE nr(n) AS
    14. UNION ALL SELECT n + 1 -- 每次循环 n 增加 1
    15. ) SELECT n FROM nr
    16. ) a;

    负载是短时间内密集地执行以上写入语句。

    以上操作看似符合理论场景中的 TiDB 最佳实践,业务上没有热点产生。只要有足够的机器,就可以充分利用 TiDB 的分布式能力。要验证是否真的符合最佳实践,可以在实验环境中进行测试。

    部署拓扑 2 个 TiDB 节点,3 个 PD 节点,6 个 TiKV 节点。请忽略 QPS,因为测试只是为了阐述原理,并非 benchmark。

    QPS1

    客户端在短时间内发起了“密集”的写入,TiDB 收到的请求是 3K QPS。理论上,压力应该均摊给 6 个 TiKV 节点。但是从 TiKV 节点的 CPU 使用情况上看,存在明显的写入倾斜(tikv - 3 节点是写入热点):

    QPS3

    raftstore 线程的 CPU 使用率,通常代表写入的负载。在这个场景下 tikv-3 为 Raft Leader,tikv-0 和 tikv-1 是 Raft 的 Follower,其他的 TiKV 节点的负载几乎为空。

    以上测试并未达到理论场景中最佳实践,因为刚创建表的时候,这个表在 TiKV 中只会对应为一个 Region,范围是:

    1. [CommonPrefix + TableID, CommonPrefix + TableID + 1)

    短时间内大量数据会持续写入到同一个 Region 上。

    TiKV Region 分裂流程

    上图简单描述了这个过程,随着数据持续写入,TiKV 会将一个 Region 切分为多个。但因为首先发起选举的是原 Leader 所在的 Store,所以新切分好的两个 Region 的 Leader 很可能还会在原 Store 上。新切分好的 Region 2,3 上,也会重复之前发生在 Region 1 上的过程。也就是压力会密集地集中在 TiKV-Node 1 上。

    在持续写入的过程中,PD 发现 Node 1 中产生了热点,会将 Leader 均分到其他的 Node 上。如果 TiKV 的节点数多于副本数的话,TiKV 会尽可能将 Region 迁移到空闲的节点上。这两个操作在数据插入的过程中,也能在 PD 监控中得到印证:

    在持续写入一段时间后,整个集群会被 PD 自动地调度成一个压力均匀的状态,到那个时候整个集群的能力才会真正被利用起来。在大多数情况下,以上热点产生的过程是没有问题的,这个阶段属于表 Region 的预热阶段。

    但是对于高并发批量密集写入场景来说,应该避免这个阶段。

    热点问题的规避方法

    为了达到场景理论中的最佳性能,可跳过这个预热阶段,直接将 Region 切分为预期的数量,提前调度到集群的各个节点中。

    TiDB 在 v3.0.x 以及 v2.1.13 后支持一个叫 Split Region 的新特性。这个特性提供了新的语法:

    1. SPLIT TABLE table_name [INDEX index_name] BY (value_list) [, (value_list)]

    但是 TiDB 并不会自动提前完成这个切分操作。原因如下:

    Table Region Range

    从上图可知,根据行数据 key 的编码规则,行 ID (rowID) 是行数据中唯一可变的部分。在 TiDB 中,rowID 是一个 Int64 整型。但是用户不一定能将 Int64 整型范围均匀切分成需要的份数,然后均匀分布在不同的节点上,还需要结合实际情况。

    如果行 ID 的写入是完全离散的,那么上述方式是可行的。如果行 ID 或者索引有固定的范围或者前缀(例如,只在 [2000w, 5000w) 的范围内离散插入数据),这种写入依然在业务上不产生热点,但是如果按上面的方式进行切分,那么有可能一开始数据仍只写入到某个 Region 上。

    作为一款通用数据库,TiDB 并不对数据的分布作假设,所以开始只用一个 Region 来对应一个表。等到真实数据插入进来以后,TiDB 自动根据数据的分布来作切分。这种方式是较通用的。

    所以 TiDB 提供了 Split Region 语法,专门针对短时批量写入场景作优化。基于以上案例,下面尝试用 Split Region 语法提前切散 Region,再观察负载情况。

    由于测试的写入数据在正数范围内完全离散,所以用以下语句,在 Int64 空间内提前将表切分为 128 个 Region:

    1. SPLIT TABLE TEST_HOTSPOT BETWEEN (0) AND (9223372036854775807) REGIONS 128;

    切分完成以后,可以通过 SHOW TABLE test_hotspot REGIONS; 语句查看打散的情况。如果 SCATTERING 列值全部为 0,代表调度成功。

    再重新运行写入负载:

    QPS7

    可以看到已经消除了明显的热点问题了。

    本示例仅为一个简单的表,还有索引热点的问题需要考虑。读者可参阅 文档来了解如何预先切散索引相关的 Region。

    问题一:

    如果表没有主键或者主键不是整数类型,而且用户也不想自己生成一个随机分布的主键 ID 的话,TiDB 内部有一个隐式的 _tidb_rowid 列作为行 ID。在不使用 SHARD_ROW_ID_BITS 的情况下,_tidb_rowid 列的值基本也为单调递增,此时也会有写热点存在(参阅 )。

    要避免由 _tidb_rowid 带来的写入热点问题,可以在建表时,使用 SHARD_ROW_ID_BITS 和 这两个建表选项(参阅 PRE_SPLIT_REGIONS 的详细说明)。

    SHARD_ROW_ID_BITS 用于将 _tidb_rowid 列生成的行 ID 随机打散。PRE_SPLIT_REGIONS 用于在建完表后预先进行 Split region。

    高并发写入场景最佳实践 - 图12注意

    PRE_SPLIT_REGIONS 的值必须小于或等于 SHARD_ROW_ID_BITS

    以下全局变量会影响 PRE_SPLIT_REGIONS 的行为,需要特别注意:

    • tidb_scatter_region:该变量用于控制建表完成后是否等待预切分和打散 Region 完成后再返回结果。如果建表后有大批量写入,需要设置该变量值为 1,表示等待所有 Region 都切分和打散完成后再返回结果给客户端。否则未打散完成就进行写入会对写入性能影响有较大的影响。

    示例:

    1. create table t (a int, b int) SHARD_ROW_ID_BITS = 4 PRE_SPLIT_REGIONS=3;
    • SHARD_ROW_ID_BITS = 4 表示 tidb_rowid 的值会随机分布成 16 (16=2^4) 个范围区间。
    • PRE_SPLIT_REGIONS=3 表示建完表后提前切分出 8 (2^3) 个 Region。

    开始写数据进表 t 后,数据会被写入提前切分好的 8 个 Region 中,这样也避免了刚开始建表完后因为只有一个 Region 而存在的写热点问题。

    问题二:

    如果表的主键为整数类型,并且该表使用了 AUTO_INCREMENT 来保证主键唯一性(不需要连续或递增)的表而言,由于 TiDB 直接使用主键行值作为 _tidb_rowid,此时无法使用 SHARD_ROW_ID_BITS 来打散热点。

    要解决上述热点问题,可以利用 AUTO_RANDOM 列属性(参阅 ),将 AUTO_INCREMENT 改为 AUTO_RANDOM,插入数据时让 TiDB 自动为整型主键列分配一个值,消除行 ID 的连续性,从而达到打散热点的目的。

    TiDB 2.1 版本中在 SQL 层引入了 latch 机制,用于在写入冲突比较频繁的场景中提前发现事务冲突,减少 TiDB 和 TiKV 事务提交时写写冲突导致的重试。通常,跑批场景使用的是存量数据,所以并不存在事务的写入冲突。可以把 TiDB 的 latch 功能关闭,以减少为细小对象分配内存:

    1. [txn-local-latches]