1
1
3
1
专栏/.../

TiDB 事务与锁整理

 苏州刘三枪  发表于  2023-01-03

笔记有点乱,整理一下,以备查阅,如有错误请指出。

一、TiDB 隔离级别

TiDB 支持的隔离级别是 SI(Snapshot Isolation)。

  • 只能读取提交后的数据,并且只能读取早于 start_ts 提交的其它事务

参数 transaction_isolation 只是为了兼容 MySQL,在 TiDB 无实际意义。

1.1 写不阻塞读

以乐观事务为例,TiDB 支持的是 Snapshot Isolation,每个事务只能读到在事务 start timestamp 之前最新已提交的数据。在这种隔离级别下如果一个事务读到了锁需要等到锁被释放才能读到值,原因是有可能这个锁所属的事务已经获取了 commit timestamp 且比读到锁的事务 start timestamp 小,读事务应该读到写事务提交的新值。 为了实现写不阻塞读,TiDB 在事务的 Primary Lock 里保存了 minCommitTs,即事务提交时满足隔离级别的最小的 commit timestamp。读事务读到锁时会使用自己的 start timestamp 来更新锁对应事务的 Primary Lock 里的该字段,从而将读写事务进行了强制排序,保证了读事务读不到写事务提交的值,从而实现了写不阻塞读

1.2 线性一致

图例 2

示例,原始数据:no=001

txn1 start_ts 为 8点,更新 no=002,未提交

txn2 start_ts 为 9点,读取不会阻塞,只能读取到 no=001

txn1 在 9点10分 提交

txn2 在 9点20分 继续读取,读取不会阻塞,也只能读取到 no=001

小结:

1、TiDB 不会产生幻读

2、只能读取到 Commit_TS < Start_TS 的数据

1.3 MVCC 读写流程

image.png

原始数据:1,Hilen

事务A 在 TSO=100 时,写入数据:1,Alice

然后写入锁信息到 Lock CF,直到开始进行2PC中的 Commit 写入数据到Write CF

事务B 在 TSO=120 时,开始读取 id = 1 的数据

读取流程

(1) 首先在 Write CF 查找,找到 版本信息为 110 的数据

(2) 检查 Lock CF,根据 110 找到锁信息,这里有锁,事务还未提交完成

(3) 继续查找上一个版本的数据:1,Hilen

修改流程

(1)

二、TiDB 事务

自 TiDB 3.0.8 起默认为悲观事务模型,参数 tidb_txn_mode='pessimistic'。

使用悲观事务模型时:

  • 自动提交事务首先尝试使用开销更小的乐观事务模式进行提交
  • 如果发生了写冲突,重试时才会使用悲观事务提交

如果想要在首次提交时就使用悲观事务,需要满足下面2个条件:

  • tidb_txn_mode='pessimistic'
  • 显示开启事务 [ BEGIN | START TRANSACTION ] / COMMIT

或者关闭自动提交 autocommit=0 (生产应该不会有这个骚操作),默认使用悲观事务模式。

使用 BEGIN PESSIMISTIC、BEGIN OPTIMISTIC 的优先级高于 tidb_txn_mode 系统变量。使用这两个语句开启的事务,会忽略系统变量,从而支持悲观、乐观事务混合使用。

2.1 事务自动重试(显示事务不会重试)

自动提交事务遇到写冲突会报错,然后进行自动重试,直到【悲观事务参数】见下文,达到限制将会返回给客户端错误。可以通过 grep 'prewrite encounters lock|Write conflict' tidb.log 查看锁冲突时的报错信息

  • prewrite encounters lock
  • pessimistic write conflict, retry statement

2.2 悲观事务参数

pessimistic-txn.max-retry-count

默认值:256,悲观事务中单个语句最大重试次数。超过则会报错:pessimistic lock retry limit reached;

可以通过 grep -i Exec_retry_count tidb_slow_query.log 查看 SQL 的重试次数

innodb_lock_wait_timeout

默认值:50秒,在悲观锁模式下,事务最大等锁时间。超过则会报错:Lock wait timeout exceeded;try restarting transaction;

2.3 乐观事务参数

tidb_disable_txn_auto_retry

默认为 false,表示不禁用显式的乐观事务自动重试。

tidb_retry_limit

默认为10,表示乐观事务的自动重试次数。

2.4 事务其他重要参数

storage.scheduler-concurrency: 2048000

scheduler 内置一个内存锁机制,防止同时对一个 key 进行操作。每个 key hash 到不同的槽。

performance.committer-concurrency

默认为 16,在单个事务的提交阶段,用于执行提交操作相关请求的 goroutine 数量;

performance.max-txn-ttl

默认为 10 分钟,悲观事务的执行时间的上限,它的含义是从悲观事务第一次加锁,或者乐观事务的第一个 prewrite 开始。超过则报错:TTL manager has timed out;

performance.stmt-count-limit

默认为5000,单个事务最大语句数。

performance.txn-total-size-limit

默认为 100MB,最大为10G。

  • enable-batch-dml 默认false,官方建议禁用此参数。
  • max-txn-time-use 默认gc_lifetime-10,4.0 已经移除此参数。升级版本到4.0时需要删除此参数。

2.5 TiDB 事务的限制

  • 单个事务包含的 SQL 语句不超过 5000 条(可修改)
  • 每个键值对不超过 6 MB (4.0.10开始支持:raft-entry-max-size、txn-entry-size-limit)
  • 键值对的总数不超过 300000 (代码写死,无法修改)
  • 键值对的总大小不超过 100 MB (可修改,不超过10GB。DELETE语句不受此限制)

注意:

  1. 开启大事务建议最大不超过2G,事务大小只受 txn-total-size-limit 控制,开启后30W键值对的总数失效。
  2. 事务对内存的占用可能会有 3-4 倍的放大,10GB 大的事务可能会占用 30-40GB 的内存。如果需要执行特别大的事务,需要提前做好内存的规划,避免对业务产生影响。

三、乐观事务模型

TiDB 中事务使用两阶段提交协议,和 MySQL 中的 2PC 不一样,参考图片:

image.png

再来一张简图:

image.png

3.1 事务执行完整流程

  1. 客户端开始一个事务
  2. TiDB 从 PD 获取 start_ts,start_ts 同时也作为该事务获取的数据库快照版本
  3. 客户端发起读请求
  4. TiDB 从 PD 获取数据路由信息,即数据具体存在哪个 TiKV 节点上
  5. TiDB 从 TiKV 获取 start_ts 版本下对应的数据
  6. 客户端发起写请求 ( 如果只是读取数据,这里就应该为TiDB将数据返回给客户端 ),TiDB 校验写入数据是否符合约束(如数据类型是否正确、是否符合非空约束等)。校验通过的数据将存放在 TiDB 中该事务的私有内存里。
  7. 客户端发起 commit
  8. TiDB 开始两阶段提交,见 3.2 节
  9. TiDB 向客户端返回事务提交成功
  10. TiDB 异步清理本次事务遗留的锁信息

3.2 两阶段提交2PC

RocksDB Column Family 简称:

D 列:rocksdb.defaultcf

L 列:rocksdb.lockcf

W 列:rocksdb.writecf

事务提交前,会在 TiDB 缓存所有数据。这边有遇到过同时并发写入上百MB数据,结果导致 TiDB OOM。

1、TiDB 从当前要写入的行中选择一个 Key 作为当前事务的 Primary Key,剩于的 Keys 为 Secondary

2、TiDB 从 PD 获取所有数据的写入路由信息,并将所有的 Key 按照路由进行分类

3、TiDB 发起 prewrite 请求,将Primary Key与数据写入到 TiKV,并进行加锁【加锁前会检查是否有写入冲突】,

加锁成功后执行下面操作:

(1) 锁信息写入L列,示例:<1,(W,pk,1,100 ... )>

(2) 行数据写入D列,示例:put<1_100,'相亲相爱一家人'>

4、然后 Secondary Key 并发地向所有涉及的 TiKV 发起 prewrite 请求,流程同 Primary Key 类似

区别是锁信息指向了 Primary Key :

(1) 锁信息写入L列,示例:<2,(W,@1,2,100 ... )>

5、TiDB 收到所有 prewrite 都成功

6、TiDB 向 PD 获取 commit_ts

7、TiDB 向 Primary Key 所在 TiKV 发起第二阶段提交的 Commit

(1) 写入 W 列,示例:put<1_110,100>

(2) 写入 L 列,表示删除锁信息,示例:<1,(D,pk,1,100 ... )>

(3) 最后清理锁信息

8、Primary Commit 提交成功后,Secondary 可以进行异步提交

9、TiDB 收到两阶段提交成功

【加锁前会检查是否有写入冲突】

  • 检查 L 列,是否已经有别的客户端已经上锁 (Locking)
  • 检查 W 列,在本次事务开始时间之后,是否有更新 [startTs, +Inf) 的写操作已经提交 (Conflict)

Prewrite 出现冲突,当前事务回滚。

Primary Commit 出现冲突,全事务回滚。

3.2 乐观事务优缺点

优点:事务在二阶段提交的 Prewrite 时才会检测冲突。在事务提交的过程中锁检测的代价是比较大的,所以乐观事务在一些场景有较好的写入提升。比如基于id自增主键的写入情景,或者有唯一索引但是很少或者不会出现多个并发同时对同一个行的DML操作的情景。

缺点:事务冲突不可避免,乐观模式采用了内部重试功能。

重试的好处:写冲突的情况避免直接报错给client。

重试的缺点:每次重试时间间隔会逐渐变长,写冲突高的情况下,一条SQL可能需要较长时间才能写入成功,另外TiDB 默认不进行事务重试,因为重试事务可能会导致更新丢失,从而破坏可重复读的隔离级别。

四、悲观事务模型

上图:

悲观事务模型下的 Percolator

TiDB 在乐观事务模型的基础上支持了悲观事务模型,将上锁的时机提前到进行DML时。TiDB 的悲观锁实现的原理确实如此,在一个事务执行 DML (UPDATE/DELETE) 的过程中,TiDB 不仅会将需要修改的行在本地缓存,同时还会对这些行直接上悲观锁,这里的悲观锁的格式和乐观事务中的锁几乎一致,但是锁的内容是空的,只是一个占位符,待到 Commit 的时候,直接将这些悲观锁改写成标准的 Percolator 模型的锁,后续流程跟乐观模型一样。

4.1 悲观事务模式的行为

悲观事务的行为和 MySQL 基本一致:

  • UPDATEDELETE 或 INSERT 语句都会读取已提交的最新数据来执行,并对所修改的行加悲观锁。
  • SELECT FOR UPDATE 语句会对已提交的最新的数据而非所修改的行加上悲观锁。
  • 悲观锁会在事务提交或回滚时释放。其他尝试修改这一行的写事务会被阻塞,等待悲观锁的释放。其他尝试读取这一行的事务不会被阻塞,因为 TiDB 采用多版本并发控制机制 (MVCC)。
  • 如果多个事务尝试获取各自的锁,会出现死锁,并被检测器自动检测到。其中一个事务会被随机终止掉并返回兼容 MySQL 的错误码 1213
  • 如果多个事务同时等待同一个锁释放,会大致按照事务 start ts 顺序获取锁。
  • 乐观事务和悲观事务可以共存,事务可以任意指定使用乐观模式或悲观模式来执行。
  • 支持 FOR UPDATE NOWAIT 语法,遇到锁时不会阻塞等锁,而是返回兼容 MySQL 的错误码 3572
  • 如果 Point Get 和 Batch Point Get 算子没有读到数据,依然会对给定的主键或者唯一键加锁,阻塞其他事务对相同主键唯一键加锁或者进行写入操作。

4.2 和 MySQL InnoDB 的差异

1、有些 WHERE 子句中使用了 range,TiDB 在执行这类 DML 语句和 SELECT FOR UPDATE 语句时,不会阻塞 range 内并发的 DML 语句的执行。

举例:

CREATE TABLE t1 (
 id INT NOT NULL PRIMARY KEY,
 pad1 VARCHAR(100)
);
INSERT INTO t1 (id) VALUES (1),(5),(10);
BEGIN /*T! PESSIMISTIC */;
SELECT * FROM t1 WHERE id BETWEEN 1 AND 10 FOR UPDATE;
BEGIN /*T! PESSIMISTIC */;
INSERT INTO t1 (id) VALUES (6); -- 仅 MySQL 中出现阻塞。
UPDATE t1 SET pad1='new value' WHERE id = 5; -- MySQL 和 TiDB 处于等待阻塞状态。

产生这一行为是因为 TiDB 当前不支持 gap locking(间隙锁)。

2、TiDB 不支持 SELECT LOCK IN SHARE MODE

3、DDL 可能会导致悲观事务提交失败。

MySQL 在执行 DDL 语句时,会被正在执行的事务阻塞住,而在 TiDB 中 DDL 操作会成功,造成悲观事务提交失败:ERROR 1105 (HY000): Information schema is changed. [try again later]。TiDB 事务执行过程中并发执行 TRUNCATE TABLE 语句,可能会导致事务报错 table doesn't exist

4、START TRANSACTION WITH CONSISTENT SNAPSHOT 之后,MySQL 仍然可以读取到之后在其他事务创建的表,而 TiDB 不能。

5、Autocommit 事务优先采用乐观事务提交。

6、对语句中 EMBEDDED SELECT 读到的相关数据不会加锁。

7、垃圾回收 (GC) 不会影响到正在执行的事务

五、Pipelined 特性

加悲观锁需要向 TiKV 写入数据,要经过 Raft 提交并 apply 后才能返回,相比于乐观事务,不可避免的会增加部分延迟。为了降低加锁的开销,TiKV 实现了 pipelined 加锁流程:当数据满足加锁要求时,TiKV 立刻通知 TiDB 执行后面的请求,并异步写入悲观锁,从而降低大部分延迟,显著提升悲观事务的性能。但当 TiKV 出现网络隔离或者节点宕机时,悲观锁异步写入有可能失败,从而产生以下影响:

  • 无法阻塞修改相同数据的其他事务。如果业务逻辑依赖加锁或等锁机制,业务逻辑的正确性将受到影响。
  • 有较低概率导致事务提交失败,但不会影响事务正确性。

如果业务逻辑依赖加锁或等锁机制,或者即使在集群异常情况下也要尽可能保证事务提交的成功率,应关闭 pipelined 加锁功能。

image.png

该功能默认关闭,若集群是 v4.0.9 及以上版本,可通过在线修改 TiKV 配置功能动态开启该功能:

set config tikv pessimistic-txn.pipelined='true';

六、监控指标

6.1 锁冲突

TiDB -> KV Errors 

image.png

txnlock 表示写写冲突

txnLockFast 表示读写冲突

expired、not_expired、wait_expired 表示对应的 lock 状态

七、QA

7.1 TxnLockNotFound

TxnLockNotFound 错误是由于事务提交的慢了,超过了 TTL 的时间。当要提交时,发现被其他事务给 Rollback 掉了。在开启 TiDB 自动重试事务的情况下,会自动在后台进行事务重试(注意显示和隐式事务的差别)。

pingcap/tidb/blob/master/store/tikv/lock_resolver.go#L124

// IsCommitted returns true if the txn's final status is Commit.
func (s TxnStatus) IsCommitted() bool { return s.ttl == 0 && s.commitTS > 0 }

// CommitTS returns the txn's commitTS. It is valid iff `IsCommitted` is true.
func (s TxnStatus) CommitTS() uint64 { return uint64(s.commitTS) }

// By default, locks after 3000ms is considered unusual (the client created the
// lock might be dead). Other client may cleanup this kind of lock.
// For locks created recently, we will do backoff and retry.
var defaultLockTTL uint64 = 3000

// TODO: Consider if it's appropriate.
var maxLockTTL uint64 = 120000

// ttl = ttlFactor * sqrt(writeSizeInMiB)
var ttlFactor = 6000

// Lock represents a lock from tikv server.
type Lock struct {
	Key      []byte

defaultLockTTL 默认为3s 代码写死不能修改,这里的 TTL 并不是 performance.max-txn-ttl。

区别如下:

performance.max-txn-ttl:从客户端发起一个事务开始执行的最长时间,超过则报超时错误。

defaultLockTT:指的是 2PC 中的 Primary Commit 执行超过 3s 还未提交完成,则有可能被其他事务回滚掉。嫌弃你提交的太慢了,长时间占着锁不释放,把你的锁清理掉我先加锁执行。然后你自动重试吧。

继续完善中。

References

[1] Percolator 和 TiDB 事务算法

[2] TiDB 新特性漫谈:悲观事务

[3] 线性一致性和 Raft

[4] TiKV 源码解析系列文章(十二)分布式事务

[5] Transaction in TiDB

[6] Large-scale Incremental Processing Using Distributed Transactions and Notifications

1
1
3
1

版权声明:本文为 TiDB 社区用户原创文章,遵循 CC BY-NC-SA 4.0 版权协议,转载请附上原文出处链接和本声明。

评论
暂无评论