TiDB 最佳实践系列(三)乐观锁事务

做者:Shirlyios

TiDB 最佳实践系列是面向广大 TiDB 用户的系列教程,旨在深刻浅出介绍 TiDB 的架构与原理,帮助用户在生产环境中最大限度发挥 TiDB 的优点。咱们将分享一系列典型场景下的最佳实践路径,便于你们快速上手,迅速定位并解决问题。

在前两篇的文章中,咱们分别介绍了 TiDB 高并发写入常见热点问题及规避方法PD 调度策略最佳实践,本文咱们将深刻浅出介绍 TiDB 乐观事务原理,并给出多种场景下的最佳实践,但愿你们可以从中收益。同时,也欢迎你们给咱们提供相关的优化建议,参与到咱们的优化工做中来。算法

建议你们在阅读以前先了解 TiDB 的总体架构Percollator 事务模型。另外,本文重点关注原理及最佳实践路径,具体的 TiDB 事务语句你们能够在 官方文档 中查阅。服务器

TiDB 事务定义

TiDB 使用 Percolator 事务模型,实现了分布式事务(建议未读过该论文的同窗先浏览一下 论文 中事务部份内容)。网络

说到事务,不得不先抛出事务的基本概念。一般咱们用 ACID 来定义事务(ACID 概念定义)。下面咱们简单说一下 TiDB 是怎么实现 ACID 的:session

  • A(原子性):基于单实例的原子性来实现分布式事务的原子性,和 Percolator 论文同样,TiDB 经过使用 Primary Key 所在 region 的原子性来保证。
  • C(一致性):自己 TiDB 在写入数据以前,会对数据的一致性进行校验,校验经过才会写入内存并返回成功。
  • I(隔离性):隔离性主要用于处理并发场景,TiDB 目前只支持一种隔离级别 Repeatable Read,即在事务内可重复读。
  • D(持久性):事务一旦提交成功,数据所有持久化到 TiKV, 此时即便 TiDB 服务器宕机也不会出现数据丢失。

截止本文发稿时,TiDB 一共提供了两种事务模式:乐观事务和悲观事务。那么乐观事务和悲观事务有什么区别呢?最本质的区别就是何时检测冲突:架构

  • 悲观事务:顾名思义,比较悲观,对于每一条 SQL 都会检测冲突。
  • 乐观事务:只有在事务最终提交 commit 时才会检测冲突。

下面咱们将着重介绍乐观事务在 TiDB 中的实现。另外,想要了解 TiDB 悲观事务更多细节的同窗,能够先阅读本文,思考一下在 TiDB 中如何实现悲观事务,咱们后续也会提供《悲观锁事务最佳实践》给你们参考。并发

乐观事务原理

有了 Percolator 基础后,下面咱们来介绍 TiDB 乐观锁事务处理流程。异步

TiDB 在处理一个事务时,处理流程以下:分布式

  1. 客户端 begin 了一个事务。

    a. TiDB 从 PD 获取一个全局惟一递增的版本号做为当前事务的开始版本号,这里咱们定义为该事务的 start_ts高并发

  2. 客户端发起读请求。

    a. TiDB 从 PD 获取数据路由信息,数据具体存在哪一个 TiKV 上。

    b. TiDB 向 TiKV 获取 start_ts 版本下对应的数据信息。

  3. 客户端发起写请求。

    a. TiDB 对写入数据进行校验,如数据类型是否正确、是否符合惟一索引约束等,确保新写入数据事务符合一致性约束,将检查经过的数据存放在内存里

  4. 客户端发起 commit。
  5. 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 成功信息。

  6. TiDB 向客户端返回事务提交成功。
  7. TiDB 异步清理本次事务遗留的锁信息。

优缺点分析

从上面这个过程能够看到, TiDB 事务存在如下优势:

  • 简单,好理解。
  • 基于单实例事务实现了跨节点事务。
  • 去中心化的锁管理。

缺点以下:

  • 两阶段提交,网络交互多。
  • 须要一个中心化的版本管理服务。
  • 事务在 commit 以前,数据写在内存里,数据过大内存就会暴涨。

基于以上缺点的分析,咱们有了一些实践建议,将在下文详细介绍。

事务大小

1. 小事务

为了下降网络交互对于小事务的影响,咱们建议小事务打包来作。如在 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 语句也建议打包成事务来处理。

2. 大事务

既然小事务有问题,咱们的事务是否是越大越好呢?

咱们回过头来分析两阶段提交的过程,聪明如你,很容易就能够发现,当事务过大时,会有如下问题:

  • 客户端 commit 以前写入数据都在内存里面,TiDB 内存暴涨,一不当心就会 OOM。
  • 第一阶段写入与其余事务出现冲突的几率就会指数级上升,事务之间相互阻塞影响。
  • 事务的提交完成会变得很长很长 ~~~

为了解决这个问题,咱们对事务的大小作了一些限制:

  • 单个事务包含的 SQL 语句不超过 5000 条(默认)
  • 每一个键值对不超过 6MB
  • 键值对的总数不超过 300,000
  • 键值对的总大小不超过 100MB

所以,对于 TiDB 乐观事务而言,事务太大或者过小,都会出现性能上的问题。咱们建议每 100~500 行写入一个事务,能够达到一个比较优的性能。

事务冲突

事务的冲突,主要指事务并发执行时,对相同的 Key 有读写操做,主要分两种:

  • 读写冲突:存在并发的事务,部分事务对相同的 Key 读,部分事务对相同的 Key 进行写。
  • 写写冲突:存在并发的事务,同时对相同的 Key 进行写入。

在 TiDB 的乐观锁机制中,由于是在客户端对事务 commit 时,才会触发两阶段提交,检测是否存在写写冲突。因此,在乐观锁中,存在写写冲突时,很容易在事务提交时暴露,于是更容易被用户感知。

默认冲突行为

由于咱们本文着重将乐观锁的最佳实践,那么咱们这边来分析一下乐观事务下,TiDB 的行为。

默认配置下,如下并发事务存在冲突时,结果以下:

在这个 case 中,现象分析以下:

  • 如上图,事务 A 在时间点 t1 开始事务,事务 B 在事务 t1 以后的 t2 开始。
  • 事务 A、事务 B 会同时去更新同一行数据。
  • 时间点 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 提供了如下参数,

如何设置以上参数呢?推荐两种方式设置:

  1. session 级别设置:

    set @@tidb_disable_txn_auto_retry = 0;
    set @@tidb_retry_limit = 10;
  2. 全局设置:

    set @@global.tidb_disable_txn_auto_retry = 0;
    set @@global.tidb_retry_limit = 10;

万能重试

那么重试是否是万能的呢?这要从重试的原理出发,重试的步骤:

  1. 从新获取 start_ts
  2. 对带写入的 SQL 进行重放。
  3. 两阶段提交。

细心如你可能会发现,咱们这边只对写入的 SQL 进行回放,并无说起读取 SQL。这个行为看似很合理,可是这个会引起其余问题:

  1. start_ts 发生了变动,当前这个事务中,读到的数据与事务真正开始的那个时间发生了变化,写入的版本也是同理变成了重试时获取的 start_ts 而不是事务一开始时的那个。
  2. 若是当前事务中存在更新依赖于读到的数据,结果变得不可控。

打开了重试后,咱们来看下面的例子:

咱们来详细分析如下这个 case:

  • 如图,在 session B 在 t2 开始事务 2,t5 提交成功。session A 的事务 1 在事务 2 以前开始,在事务 n2 提交完成后提交。
  • 事务 一、事务 2 会同时去更新同一行数据。
  • session A 提交事务 1 时,发现冲突,tidb 内部重试事务 1。

    • 重试时,从新取得新的 start_tst8’
    • 回放更新语句 update tidb set name='pd' where id =1 and status=1

      i. 发现当前版本 t8’ 下并不存在符合条件的语句,不须要更新。

      ii. 没有数据更新,返回上层成功。

  • tidb 认为事务 1 重试成功,返回客户端成功。
  • session A 认为事务执行成功,查询结果,在不存在其余更新的状况下,发现数据与预想的不一致。

这里咱们能够看到,对于重试事务,若是自己事务中更新语句须要依赖查询结果时,由于重试时会从新取版本号做为 start_ts,于是没法保证事务本来的 ReadRepeatable 隔离型,结果与预测可能出现不一致。

综上所述,若是存在依赖查询结果来更新 SQL 语句的事务,建议不要打开 TiDB 乐观锁的重试机制。

冲突预检

从上文咱们能够知道,检测底层数据是否存在写写冲突是一个很重的操做,由于要读取到数据进行检测,这个操做在 prewrite 时 TiKV 中具体执行。为了优化这一块性能,TiDB 集群会在内存里面进行一次冲突预检测。

TiDB 做为一个分布式系统,咱们在内存中的冲突检测主要在两个模块进行:

  • TiDB 层,若是在 TiDB 实例自己发现存在写写冲突,那么第一个写入发出去后,后面的写入就已经能清楚地知道本身冲突了,不必再往下层 TiKV 发送请求去检测冲突。
  • TiKV 层,主要发生在 prewrite 阶段。由于 TiDB 集群是一个分布式系统,TiDB 实例自己无状态,实例之间没法感知到彼此的存在,也就没法确认本身的写入与别的 TiDB 实例是否存在冲突,因此会在 TiKV 这一层检测具体的数据是否有冲突。

其中 TiDB 层的冲突检测能够关闭,配置项能够启用:

txn-local-latches:事务内存锁相关配置,当本地事务冲突比较多时建议开启。

  • enable

    • 开启
    • 默认值:false
  • capacity

    • Hash 对应的 slot 数,会自动向上调整为 2 的指数倍。每一个 slot 占 32 Bytes 内存。当写入数据的范围比较广时(如导数据),设置太小会致使变慢,性能降低。
    • 默认值:1024000

细心的朋友可能又注意到,这边有个 capacity 的配置,它的设置主要会影响到冲突判断的正确性。在实现冲突检测时,咱们不可能把全部的 Key 都存到内存里,占空间太大,得不偿失。因此,真正存下来的是每一个 Key 的 hash 值,有 hash 算法就有碰撞也就是误判的几率,这里咱们经过 capacity 来控制 hash 取模的值:

  • capacity 值越小,占用内存小,误判几率越大。
  • capacity 值越大,占用内存大,误判几率越小。

在真实使用时,若是业务场景可以预判断写入不存在冲突,如导入数据操做,建议关闭。

相应地,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/

相关文章
相关标签/搜索