做者:Shirlyios
TiDB 最佳实践系列是面向广大 TiDB 用户的系列教程,旨在深刻浅出介绍 TiDB 的架构与原理,帮助用户在生产环境中最大限度发挥 TiDB 的优点。咱们将分享一系列典型场景下的最佳实践路径,便于你们快速上手,迅速定位并解决问题。
在前两篇的文章中,咱们分别介绍了 TiDB 高并发写入常见热点问题及规避方法 和 PD 调度策略最佳实践,本文咱们将深刻浅出介绍 TiDB 乐观事务原理,并给出多种场景下的最佳实践,但愿你们可以从中收益。同时,也欢迎你们给咱们提供相关的优化建议,参与到咱们的优化工做中来。算法
建议你们在阅读以前先了解 TiDB 的总体架构 和 Percollator 事务模型。另外,本文重点关注原理及最佳实践路径,具体的 TiDB 事务语句你们能够在 官方文档 中查阅。服务器
TiDB 使用 Percolator 事务模型,实现了分布式事务(建议未读过该论文的同窗先浏览一下 论文 中事务部份内容)。网络
说到事务,不得不先抛出事务的基本概念。一般咱们用 ACID 来定义事务(ACID 概念定义)。下面咱们简单说一下 TiDB 是怎么实现 ACID 的:session
截止本文发稿时,TiDB 一共提供了两种事务模式:乐观事务和悲观事务。那么乐观事务和悲观事务有什么区别呢?最本质的区别就是何时检测冲突:架构
下面咱们将着重介绍乐观事务在 TiDB 中的实现。另外,想要了解 TiDB 悲观事务更多细节的同窗,能够先阅读本文,思考一下在 TiDB 中如何实现悲观事务,咱们后续也会提供《悲观锁事务最佳实践》给你们参考。并发
有了 Percolator 基础后,下面咱们来介绍 TiDB 乐观锁事务处理流程。异步
TiDB 在处理一个事务时,处理流程以下:分布式
a. TiDB 从 PD 获取一个全局惟一递增的版本号做为当前事务的开始版本号,这里咱们定义为该事务的 start_ts
。高并发
a. TiDB 从 PD 获取数据路由信息,数据具体存在哪一个 TiKV 上。
b. TiDB 向 TiKV 获取 start_ts
版本下对应的数据信息。
a. TiDB 对写入数据进行校验,如数据类型是否正确、是否符合惟一索引约束等,确保新写入数据事务符合一致性约束,将检查经过的数据存放在内存里。
a. TiDB 从当前要写入的数据中选择一个 Key 做为当前事务的 Primary Key。
b. TiDB 从 PD 获取全部数据的写入路由信息,并将全部的 Key 按照全部的路由进行分类。
c. TiDB 并发向全部涉及的 TiKV 发起 prewrite 请求,TiKV 收到 prewrite 数据后,检查数据版本信息是否存在冲突、过时,符合条件给数据加锁。
d. TiDB 收到全部的 prewrite 成功。
e. TiDB 向 PD 获取第二个全局惟一递增版本,做为本次事务的 commit_ts
。
f. TiDB 向 Primary Key 所在 TiKV 发起第二阶段提交 commit 操做,TiKV 收到 commit 操做后,检查数据合法性,清理 prewrite 阶段留下的锁。
g. TiDB 收到 f 成功信息。
从上面这个过程能够看到, TiDB 事务存在如下优势:
缺点以下:
基于以上缺点的分析,咱们有了一些实践建议,将在下文详细介绍。
为了下降网络交互对于小事务的影响,咱们建议小事务打包来作。如在 auto commit 模式下,下面每条语句成为了一个事务:
# original version with auto_commit UPDATE my_table SET a='new_value' WHERE id = 1; UPDATE my_table SET a='newer_value' WHERE id = 2; UPDATE my_table SET a='newest_value' WHERE id = 3;
以上每一条语句,都须要通过两阶段提交,网络交互就直接 *3, 若是咱们可以打包成一个事务提交,性能上会有一个显著的提高,以下:
# improved version START TRANSACTION; UPDATE my_table SET a='new_value' WHERE id = 1; UPDATE my_table SET a='newer_value' WHERE id = 2; UPDATE my_table SET a='newest_value' WHERE id = 3; COMMIT;
同理,对于 insert 语句也建议打包成事务来处理。
既然小事务有问题,咱们的事务是否是越大越好呢?
咱们回过头来分析两阶段提交的过程,聪明如你,很容易就能够发现,当事务过大时,会有如下问题:
为了解决这个问题,咱们对事务的大小作了一些限制:
所以,对于 TiDB 乐观事务而言,事务太大或者过小,都会出现性能上的问题。咱们建议每 100~500 行写入一个事务,能够达到一个比较优的性能。
事务的冲突,主要指事务并发执行时,对相同的 Key 有读写操做,主要分两种:
在 TiDB 的乐观锁机制中,由于是在客户端对事务 commit 时,才会触发两阶段提交,检测是否存在写写冲突。因此,在乐观锁中,存在写写冲突时,很容易在事务提交时暴露,于是更容易被用户感知。
由于咱们本文着重将乐观锁的最佳实践,那么咱们这边来分析一下乐观事务下,TiDB 的行为。
默认配置下,如下并发事务存在冲突时,结果以下:
在这个 case 中,现象分析以下:
t1
开始事务,事务 B 在事务 t1
以后的 t2
开始。t4
时,事务 A 想要更新 id = 1
的这一行数据,虽然此时这行数据在 t3
这个时间点被事务 B 已经更新了,可是由于 TiDB 乐观事务只有在事务 commit 时才检测冲突,因此时间点 t4
的执行成功了。t5
,事务 B 成功提交,数据落盘。t6
,事务 A 尝试提交,检测冲突时发现 t1
以后有新的数据写入,返回冲突,事务 A 提交失败,提示客户端进行重试。根据乐观锁的定义,这样作彻底符合逻辑。
咱们知道了乐观锁下事务的默认行为,能够知道在冲突比较大的时候,Commit 很容易出现失败。然而,TiDB 的大部分用户,都是来自于 MySQL;而 MySQL 内部使用的是悲观锁。对应到这个 case,就是事务 A 在 t4
更新时就会报失败,客户端就会根据需求去重试。
换言之,MySQL 的冲突检测在 SQL 执行过程当中执行,因此 commit 时很难出现异常。而 TiDB 使用乐观锁机制形成的两边行为不一致,则须要客户端修改大量的代码。 为了解决广大 MySQL 用户的这个问题,TiDB 提供了内部默认重试机制,这里,也就是当事务 A commit 发现冲突时,TiDB 内部从新回放带写入的 SQL。为此 TiDB 提供了如下参数,
tidb_disable_txn_auto_retry
:这个参数控制是否自动重试,默认为 1
,即不重试。tidb_retry_limit
:用来控制重试次数,注意只有第一个参数启用时该参数才会生效。如何设置以上参数呢?推荐两种方式设置:
session 级别设置:
set @@tidb_disable_txn_auto_retry = 0; set @@tidb_retry_limit = 10;
全局设置:
set @@global.tidb_disable_txn_auto_retry = 0; set @@global.tidb_retry_limit = 10;
那么重试是否是万能的呢?这要从重试的原理出发,重试的步骤:
start_ts
。细心如你可能会发现,咱们这边只对写入的 SQL 进行回放,并无说起读取 SQL。这个行为看似很合理,可是这个会引起其余问题:
start_ts
发生了变动,当前这个事务中,读到的数据与事务真正开始的那个时间发生了变化,写入的版本也是同理变成了重试时获取的 start_ts
而不是事务一开始时的那个。打开了重试后,咱们来看下面的例子:
咱们来详细分析如下这个 case:
t2
开始事务 2,t5
提交成功。session A 的事务 1 在事务 2 以前开始,在事务 n2 提交完成后提交。session A 提交事务 1 时,发现冲突,tidb 内部重试事务 1。
start_ts
为 t8’
。update tidb set name='pd' where id =1 and status=1
。i. 发现当前版本 t8’
下并不存在符合条件的语句,不须要更新。
ii. 没有数据更新,返回上层成功。
这里咱们能够看到,对于重试事务,若是自己事务中更新语句须要依赖查询结果时,由于重试时会从新取版本号做为 start_ts
,于是没法保证事务本来的 ReadRepeatable
隔离型,结果与预测可能出现不一致。
综上所述,若是存在依赖查询结果来更新 SQL 语句的事务,建议不要打开 TiDB 乐观锁的重试机制。
从上文咱们能够知道,检测底层数据是否存在写写冲突是一个很重的操做,由于要读取到数据进行检测,这个操做在 prewrite 时 TiKV 中具体执行。为了优化这一块性能,TiDB 集群会在内存里面进行一次冲突预检测。
TiDB 做为一个分布式系统,咱们在内存中的冲突检测主要在两个模块进行:
其中 TiDB 层的冲突检测能够关闭,配置项能够启用:
txn-local-latches:事务内存锁相关配置,当本地事务冲突比较多时建议开启。
enable
- 开启
- 默认值:false
capacity
- Hash 对应的 slot 数,会自动向上调整为 2 的指数倍。每一个 slot 占 32 Bytes 内存。当写入数据的范围比较广时(如导数据),设置太小会致使变慢,性能降低。
- 默认值:1024000
细心的朋友可能又注意到,这边有个 capacity 的配置,它的设置主要会影响到冲突判断的正确性。在实现冲突检测时,咱们不可能把全部的 Key 都存到内存里,占空间太大,得不偿失。因此,真正存下来的是每一个 Key 的 hash 值,有 hash 算法就有碰撞也就是误判的几率,这里咱们经过 capacity 来控制 hash 取模的值:
在真实使用时,若是业务场景可以预判断写入不存在冲突,如导入数据操做,建议关闭。
相应地,TiKV 内存中的冲突检测也有一套相似的东西。不一样的是,TiKV 的检测会更严格,不容许关闭,只提供了一个 hash 取模值的配置项:
scheduler-concurrency
- scheduler 内置一个内存锁机制,防止同时对一个 Key 进行操做。每一个 Key hash 到不一样的槽。
- 默认值:2048000
此外,TiKV 提供了监控查看具体消耗在 latch 等待的时间:
若是发现这个 wait duration 特别高,说明耗在等待锁的请求上比较久,若是不存在底层写入慢问题的话,基本上能够判断这段时间内冲突比较多。
综上所述,Percolator 乐观事务实现原理简单,可是缺点诸多,为了优化这些缺陷带来的性能上和功能上的开销,咱们作了诸多努力。可是谁也不敢自信满满地说:这一块的性能已经达到了极致。
时至今日,咱们还在持续努力将这一块作得更好更远,但愿能让更多使用 TiDB 的小伙伴能从中受益。与此同时,咱们也很是期待你们在使用过程当中的反馈,若是你们对 TiDB 事务有更多优化建议,欢迎联系我 wuxuelian@pingcap.com 。您看似不经意的一个举动,都有可能使更多饱受折磨的互联网同窗们从中享受到分布式事务的乐趣。
原文阅读:https://pingcap.com/blog-cn/best-practice-optimistic-transaction/