TiDB 写入慢流程排查系列(三)— TiDB Server 写入流程

本文讲述一条 SQL 经过哪些步骤最终构建一个合适的请求发送到 TiKV,主要围绕 TiDB server 写流程进行介绍。在不影响正确性的情况下对整个主流程进行简化为如下步骤:

  1. 从客户端的 Socket 读取一条 SQL

  2. 获取一个 Token

  3. 从 PD 获取 TSO (异步获取,此处拿到一个 tsFuture,后续的流程中可以通过 tsFuture 结构拿到真正的 TSO)

  4. 使用 Parser 将 SQL parse 为 AST

  5. 将 AST compile 为执行计划(此过程包含很多细节,比如 Validator / LogicalPlanOptimizer / PhysicalPlanOptimizer / Executor builder 等,由于都反映在 Compile duration 的监控之中,此处合为一个步骤)

  6. 执行上一步得到的执行计划

  7. 最底层的 Executor 会根据这条 SQL 处理的 Key 范围构建出多个要下发到 TiKV 的请求,并通过不同的 InsertExec / UpdateExec / DeleteExec executor 调用 table 接口写入数据 。

一个粗略的处理框架:TiDB 在收到客户端请求后,首先需要跟 client 建立链接,之后进行 MySQL 协议解析和转换,SQL 语法解析,执行计划的制定和优化等过程。最后需要根据已经定义的 Schama 转换为 KV 键值对,并将这些 KV 键值对写入 TiKV。经过 TiKV 的处理后返回计算结果,最后将写入成功结果返回给客户端。

由于这一部分涉及的监控非常多且复杂,本小节先从概览到细节对监控进行梳理:

  1. connection count:每个 server 的连接数以及总连接数
  2. QPS:每秒的查询数量
  3. Duration:SQL 执行的耗时统计
  4. Get Token Duration:建立连接后获取 Token 耗时
  5. Parse Duration:SQL 语句解析耗时统计
  6. Compile Duration:将 SQL AST 编译成执行计划耗时统计
  7. Execution Duration:SQL 语句执行耗时统计

以上几个监控可以从宏观上反映一个SQL 的时间消耗主要是在哪个大模块,下面是一些常见的例子,如:

  1. Parse Duration / Compile Duration 是纯 CPU 操作,如果 CPU 负载不高,但是耗时比较长,大部分情况是 insert … values 太多,Compile 高更可能的情况是带了非关联子查询。

  2. Get Token Duration 耗时比较高说明目前已经在执行的 SQL 达到了 TokenLimiter 的上限,具体情况可能很复杂,比如可能是简单的数量达到了上限,或则内部出现了卡死导致 Token 没有释放。

  3. Execution Duration 包含了 Executor 执行过程中的总耗时,内部涉及的组件比较多,后面将专门对这一部分进行解释。

  4. TSO 获取比较慢,相关的监控有:

  • TSO RPC Duration:pd client 从发送请求到请求返回的耗时,等于网络 roundtrip 耗时 + PD 服务器处理耗时
  • TSO Async Wait:从获取 ts future,到开始 wait ts future 的耗时。反映了 TiDB 内部处理的耗时情况,一般是 parse、compile 以及 auto_increment 的 rebase。向 pd client 发送请求之后,调用者不会卡住,而是得到一个 ts future,只有 wait ts future 的时候,如果 ts future 没有准备好,才会卡住调用者
  • TSO Wait Duration:调用 wait ts future 之后等待 future 返回的耗时
1赞

1.协议层

TiDB 兼容 MySQL 5.7 协议,TiDB 4.0 以上,兼容部分 MySQL 8.0,详情请参考 GitHub

首先客户端需要跟 TiDB server 建立连接,当和客户端的连接建立好之后,TiDB 中会有一个 Goroutine 监听端口,等待从客户端发来的包,并对发来的包做处理。

通过 max-server-connections 参数控制每个 server 的连接数大小,默认为 0 ,即不限制连接数

token-limit 控制并发执行的会话数。 它应该大于连接数。该参数默认值为 1000,如果这个参数设置过小,新建连接会花费更长的时间。

注: max-server-connections 和 token-limit 是两个不同的限制参数,OLTP 场景下,建议连接数应该少于500个,并且需要密切关注不同 tidb-server 之间连接数是否均匀分布。

监控说明

  • 位置:TiDB – server – get token duration 面板
    正常情况下,”Get Token Duration” 应该小于 2us
    Get Token Duration 耗时比较高说明目前已经在执行的 SQL 达到了 TokenLimiter 的上限,具体情况可能很复杂,比如可能是简单的数量达到了上限,或则内部出现了卡死导致 Token 没有释放。
  • 位置:TiDB – server – Connection Count 面板
1赞

2. Parser 模块

Parser 主要是检查关键字的正确语法和拼写,并将文本解析为 AST(抽象语法树)

监控说明

Parser 相关问题

  • 报错原理解读
    ➢ MySQL client 报错内容:“ERROR 1064 (42000): You hava an error in your SQL syntax;…” 说明:如果有这个报错,可以明确是 Parser 报错
    ➢ 语法报错 log 日志内容:“parser error” and with stack “(Parser).Parse”|

  • Parser 报错判断
    ➢ 查看错误消息或日志,是否有报错码 1064 等。
    ➢ 在 MySQL 中是否可以执行: MySQL 5.7 语法 MySQL 8.0 部分语法
    ➢ 判断 TiDB 是否支持,可以看 parser 文件 或者 sqlgram 图来确认 TiDB 已经支持的语法,也可以通过 MySQL 兼容性列表 来进行确认。
    ➢ 检查 MySQL 的定义
    当发现语法是 TiDB 支持,如果有报错,可以判断为非预期,需要提交 BUG
    当发现语法是 TiDB 不支持,但是 MySQL 支持,可以向我们提需求做兼容

  • Parser 性能调优
    ➢ 使用 PrepareStatement 避免每次 Parse
    ➢ 简化 SQL 中不必要的表达式 (如,太多的’ ifnull() '表达式)

1赞

3. Compile 模块

拿到 AST 之后,就可以做各种验证(预处理、合法性验证,权限检验)、变换、优化,这一系列动作的入口在 compile,最重要的三个步骤:

  1. 做一些合法性检查以及名字绑定;
  2. 制定查询计划,并优化,这是最核心的步骤之一。这里有一个特殊的函数 TryFastPlan,如果这里判断规则可以符合 PointGet,会跳过后续的优化,直接走点查
  3. 构造 executor.ExecStmt 结构:这个 ExecStmt 结构持有查询计划,是后续执行的基础

监控说明

  • 位置:TiDB – Executor – Compile Duration 面板
    正常情况下,” Compile Duration” 小于 30 ms|

    注意:Parse Duration / Compile Duration 是纯 CPU 操作,如果 CPU 负载不高,但是耗时比较长,大部分情况是 insert … values 太多,Compile 高更可能的情况是带了非关联子查询。
1赞

4. 生成 + 运行 Executor

TiDB 会将 plan 转换成 executor,运行时会将所有的物理 executor 构造一个树状结构,每一层通过调用下一层获取结果。

监控说明

  • 位置:TiDB – Executor – Execution Duration 面板
    executor duration 是指 parse 结束之后到执行出结果的时间,即从优化 SQL 到 tidb 计算出结果这个时间,这个时间长可能是 tikv 数据到 tidb 汇聚计算时间比较长

4.1.获取 Key 所在的 Region 和 TSO

TiDB 通过向 PD 发送请求实现 region 的定位以及获取 TSO。

Region Cache

在 TiDB 中每个 kv 请求都需要根据 Key 定位到能处理该 Key 请求所在的 Store 地址, 而 Key 到 Store 的映射信息实际存分散存在于每个 kv 中且集中收集于 PD 中, 理论上对于每个请求 TiDB 可以通过每次向 PD 查询 Key 对应哪个 Store 的信息, 但出于性能考虑在 TiDB 侧我们维护了一个内存 Cache 来缓存映射信息避免重复的查询。TiDB 会先访问 pd 获取 tso,然后访问 tidb-server 本地 Region Cache,然后按照获得的路由信息,将请求发给 TiKV,如果 TiKV 返回错误说明路由信息过旧,这个时候 tidb-server 会去 pd 重新取 region 的最新路由信息,并更新 region cache。如果,请求发送到 follower 了,TiKV 会返回 not leader 的错误并把谁是 leader 的信息返回给 tidb-server,tidb-server 会进行重试,根据返回的 leader 信息到所在的 TiKV 读取数据。 然后更新 Region Cache。

获取 TSO

所有 TiDB 与 PD 交互的逻辑都是通过一个 PD Client 的对象进行的,这个对象会在服务器启动时创建 Store 的时候创建出来,创建之后会开启一个新线程,专门负责批量从 PD 获取 TSO。对于这些 TSO 请求,分为三个阶段:

  • 将一个 TSO 请求放入 channel,对应的函数为 GetTSAsync,调用这个函数会得到一个 tsFuture 对象
  • TSO 线程从 channel 拿到请求向 PD 发起 RPC 请求,获取 TSO,获取到 TSO 之后分配给相应的请求
  • TSO 分配给相应的请求之后,就可以通过持有的 tsFuture 获取 TSO(调用 tsFuture.Wait())
监控说明

目前系统没有对第一个阶段设置相应的监控信息,这个过程通常很快,除非 channel 满了,否则 GetTSAsync 函数很快返回,而 channel 满表示 RPC 请求延迟可能过高,所以可以通过 RPC 请求的 Duration 来进一步分析。

第二阶段中 TiDB 向 PD 发起 RPC 请求的 Duration 对应的监控项是 PD TSO RPC Duration。

  • 位置:TiDB – PD Client – PD TSO RPC Duration 面板
  • PD TSO RPC Duration : 反应向 PD 发起 RPC 请求的耗时,如果 duration 比较高,可能的原因如下:
    ➢ TiDB 和 PD 之间的网络延迟高,可以通过 Blackbox exporter Dashboard 下面的 Network Status - Ping Latency 监控项确认。
    ➢ PD 负载太高,不能及时处理 TSO 的 RPC 请求,可以通过 PD Dashboard 下的 TiDB - handle requests duration 监控项确认。

第一阶段拿到 tsFuture 对象后到第三阶段调用 tsFuture.Wait() 这段过程的 Duration 对应的监控项是 TSO Async Duration;拿到 tsFuture 之后还需要进行 SQL Parse 和 Compile 成执行计划,真正执行的时候才会调用 tsFuture.Wait()

  • 位置:TiDB – PD Client – PD Client CMD Duration (wait)
  • TSO Async Duration:从获取 ts future,到开始 wait ts future 的耗时,如果 duration 比较高,可能的原因如下:
    ➢ 这个 SQL 很复杂,Parse 花费了很长的时间
    ➢ Compile 花费了很长时间

如果 Parser 和 Compile 过程很快完成了,第三阶段中调用 tsFuture.Wait() 时 PD 的 TSO RPC 还没有返回,就需要等待,这段等待时间对应的监控项是 PD TSO Wait Duration

  • 位置:TiDB – PD Client – PD TSO Wait Duration 面板
  • PD TSO Wait Duration:调用 wait ts future 之后等待 future 返回的耗时,受网络和 runtime 影响

注意:读取只向 PD拿一次 TSO 就够了。写入的话,需要拿两次,因为写流程两阶段提交,需要取两次 tso,包括:start_ts + commit_ts。并且暂时不会支持通过 start_ts 计算出 commit_ts 减少拿的次数。所以在压测场景下,写入场景一般会比读场景的 PD TSO Wait Duration 的指标值高很多。

DistSQL API

请求的分发与汇总会有很多复杂的处理逻辑,比如上小节说的出错重试、获取路由信息、控制并发度以及结果返回顺序,为了避免这些复杂的逻辑与 SQL 层耦合在一起,TiDB 抽象了一个统一的分布式查询接口,称为 DistSQL API,位于 distsql 这个包中。
DistSQL 是位于 SQL 层和 Coprocessor 之间的一层抽象,它把下层的 Coprocessor 请求封装起来对上层提供一个简单的 Select 方法。执行一个单表的计算任务。最上层的 SQL 语句可能会包含 JOIN,SUBQUERY 等复杂算子,涉及很多的表,而 DistSQL 只涉及到单个表的数据。一个 DistSQL 请求会涉及到多个 Region,我们要对涉及到的每一个 Region 执行一次 Coprocessor 请求。

监控说明

  • 位置:TiDB – DistSQL 面板
    ➢ Distsql Duration:Distsql 处理的时长
    ➢ Distsql QPS:Distsql 的数量统计
    ➢ Distsql Partial QPS:每秒 Partial Results 的数量
    ➢ Scan Keys Num:每个 Query 扫描的 Key 的数量
    ➢ Scan Keys Partial Num:每一个 Partial Result 扫描的 Key 的数量
    ➢ Partial Num:每个 SQL 语句 Partial Results 的数量

  • 位置:TiDB – KV Request 面板 & Overview - TiDB 面板
    ➢ KV Duration(KV Request Duration 999 by store/type、KV Cmd Duration 99/999)
    KV Request Duration 999 by store:KV Request 执行时间,根据 TiKV 显示
    KV Request Duration 999 by type:KV Request 执行时间,根据请求类型显示
    KV Cmd Duration 99/999:KV 命令执行的时间

  • KV Count
    ➢KV Cmd OPS(Overview - TiDB 面板):KV 命令执行数量统计
    ➢KV Txn OPS(TiDB - Transaction 面板):启动事务的数量统计
    ➢Txn Regions Num 90(TiDB - Transaction 面板):事务使用的 Region 数量统计
    ➢Txn Max Write Size Bytes 100:事务写入的字节数统计
    ➢Txn Max Write KV Num 100:事务写入的 KV 数量统计
    ➢Load SafePoint OPS(TiDB - Transaction 面板):更新 SafePoint 的数量统计





4.3 executor 执行写入

输入的 SQL 会经过 parse,compile 等过程最后变成一个 Executor
最终会对应于 InsertExec / UpdateExec / DeleteExec 等
以 insert 写入为例,Insert Executor 会通过调用 Table 接口写入数据,Table 接口写入有以下作用:

  • 将 Row 转化成 Key-value
  • 维护数据和索引的一致性
  • 保障 DDL 变更过程中的 schema 可见性
    在将 Row 转化成 Key-value 的过程中,会通过 Add/Update/Remove 等接口函数调用 tablecodec 编码规则将数据写入类似 Key-value 的容器 unionstore,包括 snapshot + membuffer 两部分。此时数据的所有修改在未提交之前都缓存在内存中,那么在读取的时候,需要先读取缓存,再去读快照。

大事务

在 4.0 之前的版本,由于 TiDB 两阶段提交的要求,修改数据的单个事务过大时会存在以下问题:

  • 客户端在提交之前,数据都写在内存中,而数据量过多时易导致 OOM (Out of Memory) 错误。
  • 在第一阶段写入数据耗时增加,与其他事务出现写冲突的概率会指数级增长。
  • 最终导致事务完成提交的耗时增加。
    因此,TiDB 对事务做了一些限制:
  • 单个事务包含的 SQL 语句不超过 5000 条(默认)
  • 每个键值对不超过 6 MB
  • 键值对的总数不超过 300000
  • 键值对的总大小不超过 100 MB
    为了使性能达到最优,建议每 100~500 行写入一个事务。
    在 4.0 之后的版本开始支持大事务,取消了单个事务的键值对的总数量不超过 30 万条的限制,同时之前默认事务的 100MB 大小可以通过 txn-total-size-limit 进行修改,最大支持 10 GB。即 4.0 之后事务大小限制为:
  • 每个键值对不超过 6 MB
  • txn-total-size-limit 控制键值对总大小,最大 10G
    需要注意的是,由于在提交前事务的数据修改还是缓存在内存里,因此实际的单个事务大小限制还取决于服务器剩余可用内存的大小,执行事务时 TiDB 进程的内存消耗大约是事务大小的 6 倍以上。

悲观锁

如果使用悲观事务模型,在悲观锁加锁的过程中也有可能会因为重试和锁等待而引起写入变慢的情况。
悲观事务在乐观事务基础上实现,在 Prewrite 之前增加了 Acquire Pessimistic Lock 阶段:

  • 每个 DML 都会加悲观锁,锁写到 TiKV 里,同样会通过 raft 同步。
  • 悲观事务在加悲观锁时检查各种约束,如 Write Conflict、key 唯一性约束等。
  • 悲观锁不包含数据,只有锁,只用于防止其他事务修改相同的 key,不会阻塞读,但 Prewrite 后会阻塞读(和 Percolator 相同)。
  • 提交时同 Percolator,悲观锁的存在保证了 Prewrite 不会发生 Write Conflict,保证了提交一定成功。

悲观事务会用 for update ts 读和检查 Write Conflict,效果等同于可更新的 start ts,当悲观事务执行 DML 发现 Write Conflict 时,就会更新 for update ts 再重试这个 DML。

悲观事务遇到锁时要等待锁释放,为了高效的实现锁等待,每个 TiKV 都有 Waiter Manager。当 Acquire Pessimistic Lock 遇到 KeyIsLocked 时,会在 Waiter Manager 里等锁释放(commit/rollback)。悲观锁支持 innodb_lock_wait_timeout,默认单条 DML 等锁 50s 会报等锁超时错误。

悲观事务相关监控:

  • 位置:TiDB – Transcation 面板
    ➢ Duration:事务执行的时间
    ➢ Statement Lock Keys:单个语句的加锁个数
    ➢ Acquire Pessimistic Locks Duration:加锁所消耗的时间
    ➢ Pessimistic Statement Retry OPS:悲观语句重试次数统计。当语句尝试加锁时,可能遇到写入冲突,此时,语句会重新获取新的 snapshot 并再次加锁

事务提交:

在所有的事务修改完成之后会进行事务的提交,在 TiDB 中事务提交为两阶段提交,详细介绍如下:

通过前面的处理,对于写入算子 Insert / Update / Delete 在执行过程中将待写入的数据编码为 Key-Value 并先写入事务的 In-Memory Buffer,写入的数据包含两类:

  1. 普通数据插入
  2. 索引数据插入

在事务缓存中,普通数据和索引数据使用不同的编码方式转换 Key 和 Value,统一理解为 Key-Value 即可。两阶段提交的本质就是将 In-Memory Buffer 中的 Key-Value 通过 tikvclient 写入到 TiKV 中。

两阶段提交顾名思义就是将事务的提交分成两个阶段:

1、Prewrite

2、Commit

在每个 Transaction 开启时会获取一个 TSO 作为 start_ts,在 Prewrite 成功后 Commit 前获取 TSO 作为 commit_ts,如下图:

事务相关监控

  • Transaction 面板
    ➢ Transaction OPS:事务执行数量统计
    ➢ Duration:事务执行的时间(如果启用 Latches,则包含 Latches 的时间)
    ➢ Transaction Retry Num:事务重试次数
    ➢ Transaction Statement Num:一个事务中的 SQL 语句数量
    ➢ Session Retry Error OPS:事务重试时遇到的错误数量

  • KV Count 面板
    ➢ KV Txn OPS:启动事务的数量统计
    ➢ Txn Regions Num 90:事务使用的 Region 数量统计
    ➢ Txn Write Size Bytes 100:事务写入的字节数统计
    ➢ Txn Write KV Num 100:事务写入的 KV 数量统计

  • KV Errors
    ➢ KV Retry Duration:KV 重试请求的时间
    ➢ TiClient Region Error OPS:TiKV 返回 Region 相关错误信息的数量
    ➢ KV Backoff OPS:TiKV 返回错误信息的数量(事务冲突等)
    ➢ Lock Resolve OPS:事务冲突相关的数量

可能出现的错误

发送请求过程中,根据 RegionCache 中的 Region 信息切分,但是 Region 信息可能已经过期,包括

Region 发生了分裂

Region 发生了合并

Region 发生了调度

一个请求中的部分 Key 上可能有锁

其他错误

错误是如何处理的?

如果是 Region 相关的错误,会先进行 Backoff,然后进行重试,比如:

Region 信息过期会使用新的 Region 信息重试任务

Region 的 leader 切换,会把请求发送给新的 Leader

如果部分 Key 上有锁,会进行 Resolve lock

如果有其他错误,则立即向上层返回错误,中断请求

错误相关的监控有哪些?

KV Errors

KV Retry Duration:KV 重试请求的时间

TiClient Region Error OPS:TiKV 返回 Region 相关错误信息的数量

KV Backoff OPS(TiDB - KV Errors):TiKV 返回错误信息的数量(事务冲突等)

Lock Resolve OPS:事务冲突相关的数量

Other Errors OPS:其他类型的错误数量,包括清锁和更新 SafePoint

一些常见问题补充:

  1. Backoff 是什么意思?→ Backoff 就是本次请求失败了,sleep 一小段时间再进行重试

  2. Backoff sleep 的时间长短如何确定?→ TiDB 中的 Backoff 对于不同的请求以及错误类型使用不同的 Backoff 算法,具体可以参考文档1 参考文档2

  3. Backoff 会一直进行吗?→ 不会

  4. Backoff 什么时候终止?→ 每一次失败重试之前都会 sleep 一段时间,Backoff 终止的依据是多次重试的 sleep 时间总和大于阈值

  5. Backoff 的阈值如何确定的?→ 目前是写死在代码之中,参考以下列表,单位毫秒

  6. copBuildTaskMaxBackoff = 5000

  7. tsoMaxBackoff = 15000

  8. scannerNextMaxBackoff = 20000

  9. batchGetMaxBackoff = 20000

  10. copNextMaxBackoff = 20000

  11. getMaxBackoff = 20000

  12. prewriteMaxBackoff = 20000

  13. cleanupMaxBackoff = 20000

  14. GcOneRegionMaxBackoff = 20000

  15. GcResolveLockMaxBackoff = 100000

  16. deleteRangeOneRegionMaxBackoff = 100000

  17. rawkvMaxBackoff = 20000

  18. splitRegionBackoff = 20000

  19. maxSplitRegionsBackoff = 120000

  20. scatterRegionBackoff = 20000

  21. waitScatterRegionFinishBackoff = 120000

  22. locateRegionMaxBackoff = 20000

  23. pessimisticLockMaxBackoff = 10000

  24. pessimisticRollbackMaxBackoff = 10000

  25. Region Error 和 Lock 可以通过监控排查,其他错误怎排查?→ 在日志中搜索 “other error”

举一个简单的例子:

一个请求发送到 TiKV 的超时时间是 60s,如果返回了错误之后每次 backoff 1s,总的 Backoff 时间是 5s,那么这种情况可以重试 5 次。考虑一种情况:前四次请求失败,且每次失败都是可重试错误,最后一次请求成功,每次请求返回时间都是 50s,那么这个请求的总的时间为 50 * 4 + 1 * 4 + 50。

这个例子想要说明的问题是什么呢?如果发现 Duration 耗时很长,不但需要去看 Backoff 的监控,还需要去查看单次 KV Duration 的耗时,已经 KV Errors 的监控进一步排查问题。

5. TiDB 层与 TiKV 层

在数据需要写入到 TiKV 时,TiDB 通过 grpc 与 TiKV 通讯,而 gPRC 支持在单 TCP 连接上多路复用,所以多个并发的请求可以在单个连接上执行而不会相互阻塞。通过 grpc-connection-count 参数来控制跟每个 KV 建立的连接数大小。 我们知道 当PD选举 或者 region 分裂等情况出现时,Tidb-server 的请求是无法被及时响应(notleader/staleepoch/serverbusy)。为了解决这个问题,Backoffer 实现了 fork 功能, 在发送每一个子请求的时候,需要 fork 出一个 child Backoffer,child Backoffer 负责单个 RPC 请求的重试,它记录了 parent Backoffer 已经等待的时间,保证总的等待时间,不会超过 query 超时时间。

1赞

先收藏