TIKV 分布式事务 -- 悲观锁
作者: ylldty 原文来源:https://tidb.net/blog/3e1db518
前言
首先我们需要大概了解分布式事务的悲观锁:
TiDB 新特性漫谈:悲观事务 https://cn.pingcap.com/blog/pessimistic-transaction-the-new-features-of-tidb/
TiDB 悲观锁实现原理: https://tidb.net/blog/7730ed79
内存悲观锁原理浅析与实践:https://tidb.net/blog/b29eb6fd
乐观事务与悲观事务的区别
INSERT/UPDATE/DELETE
根据上面资料的描述,我们了解到:
对于乐观事务来说,
INSERT/UPDATE/DELETE等语句均只更改TIDB内存的状态,不会在存储层例如TIKV修改数据。只有在Commit阶段才会检测KEY的并发状态,并且对KEY进行Lock。如果
KEY存在锁冲突或者写冲突,commit返回给客户端等锁、清锁或者重试。而对于悲观事务来说,
INSERT/UPDATE/DELETE等语句会去存储层立刻检测KEY的并发状态,并且对KEY进行Lock。如果
KEY存在锁冲突或者写冲突,INSERT/UPDATE/DELETE等语句返回给客户端等锁、清锁或者重试。
乐观事务中,T2 的 delete 并不会阻塞,反而在 commit 语句的时候返回了 Error。
悲观事务中,T2 的 delete 就会一直阻塞,直到 T1 提交成功后才会返回。T2 的 commit 会正常执行不会失败。
![]()
SELECT FOR UPDATE
对于乐观事务来说,理论上是不应该出现这个语句的
对于悲观锁来说,
SELECT FOR UPDATE和INSERT/UPDATE/DELETE等语句相同,会去存储层立刻检测KEY的并发状态,并且对KEY进行Lock。如果
KEY存在锁冲突或者写冲突,INSERT/UPDATE/DELETE等语句返回给客户端等锁、清锁或者重试。
SELECT
对于 *** 乐观事务 *** 来说,
由于事务是 SI 的隔离级别,TIDB 需要保障查询到的数据 data 是事务开启前 t.start_ts的 *** 最新 *** 数据。因此SELECT 语句需要发送给存储层例如 TIKV,严格检测 KEY 的并发状态:
如果没有发现
KEY存在一个关联lock,那我们就可以 *** 直接 *** 返回t.start_ts前 *** 最新 *** 数据即可。如果发现
KEY存在一个关联lock,并且lock.ts<t.start_ts,那么说明存在一个比当前事务t更早的事务t0。我们需要等待t0事务提交后,才能确定这个事务的更改是否可对事务t的SELECT语句可见。因此需要 等锁、清锁或者 重试。
对于 悲观事务 来说,
事务默认是 RR 的隔离级别,兼容 Mysql,事务开启的时候,会获取 事务开启 的时间戳 ts 作为 read_ts,后面查询到的数据 data 都是以这个 read_ts 作为参数。
也可以调整为 RC 的隔离级别,每次进行 SELECT 请求的时候,都会获取 当前 的时间戳 ts 作为 read_ts,因此每次 SELECT 请求的到的数据都可能不相同。
相同的如果没有发现
KEY存在一个关联lock,那我们就可以 *** 直接 *** 返回read_ts前 *** 最新 *** 数据即可。如果发现
KEY存在一个关联lock,那么TIKV将会判断lock的类型:如果
lock是悲观锁,那么直接忽略即可。因为SELECT快照读请求不应该被其他悲观事务的写操作 (例如UPDATE) 而阻塞如果
lock是一个正常的锁,并且lock.ts<t.start_ts,那么说明存在一个事务t0正在进行二阶段提交。我们需要等待t0事务提交后,才能确定这个事务的更改是否可对read_ts的SELECT语句可见。因此需要 等锁、清锁或者 重试。
悲观事务原理
加锁流程概述
悲观事务是在乐观事务的基础上进行改造的。基本流程为:
每次进行
INSERT/UPDATE/DELETE/SELECT FOR UPDATE等语句的时候,不只是更改tidb的内存状态,还调用了PessimisticLock的接口,在LOCK_CF上面对各个KEY进行加悲观锁。事务提交的时候,
Prewrite会修改这些悲观锁为正常的锁,后面的流程就与乐观锁相同了。当
t0事务执行INSERT/UPDATE/DELETE/SELECT FOR UPDATE等语句对各个KEY加了悲观锁以后, 如果存在并发更新的t1事务,也想对相同的KEY进行INSERT/UPDATE/DELETE/SELECT FOR UPDATE,也会调用PessimisticLock接口尝试加悲观锁,这时候会遇到t0对KEY关联的悲观锁,从而会阻塞,直到t0事务提交或者回滚完成清理锁
PessimisticLock 接口
整体流程
参考: pipelined
检查
TiKV中锁情况,如果发现有锁
不是当前同一事务的锁,直接返回
KeyIsLocked是同一个事务的锁,但是锁的类型不是悲观锁,返回锁类型不匹配(意味该请求已经超时)
如果发现
TiKV里锁的t.for_update_ts>lock.ts(同一个事务重复更新), 使用当前请求的for_update_ts更新该锁如果请求参数
should_not_exist为true(一般用于主键或者唯一索引的悲观锁) ,那么还要检验没有write记录其他情况,为重复请求,直接返回成功
检查是否存在更新的写入版本,如果有写入记录
若已提交的
commit_ts大于for_update_ts更新,说明存在冲突,返回WriteConflict
若已提交的
commit_ts>t.start_ts,说明在当前事务begin后有其他事务提交过检查历史版本,如果发现当前请求的事务有没有被
Rollback过,返回PessimisticLockRollbacked错误如果已提交的数据
commit_ts=t.start_ts,那么是当前事务的Rollback记录,返回PessimisticLockRollbacked错误如果请求参数
should_not_exist为true(一般用于主键或者唯一索引的悲观锁) ,那么还要检验没有write记录
给当前请求
key加上悲观锁,并返回成功某些情况当请求参数
lock_only_if_exists为 true 的时候,如果没有发现任何write记录的话,也可以不加锁
PessimisticLockRequest
重要参数
我们先看 Pessimistic Lock 比较重要的参数都有哪些:
should_not_exist: 该参数为 true 的时候,加锁的 KEY 不应该存在当执行
INSERT/UPDATE的时候,需要验证插入的新RowID或者唯一索引不存在,此时设置为trueneed_value: 该参数为 true 的时候,需要返回 KEY 对应的 VALUE 数据当执行
UPDATE的时候,需要根据唯一索引获取对应的RowID数据 , 或者根据RowID获取对应的行数据 (例如UPDATE TABLE SET ... WHERE UK="xxx"), 这个参数必须是trueSELECT FOR UPDATE的时候,这个参数当然必须是trueneed_check_existence: 该参数为 true 的时候,需要检查其VALUE,如果有VALUE则将会返回给客户端大部分的
DML都需要验证加锁的KEY是否存在lock_only_if_exists: 该参数为true的时候,只有KEY数据存在才加锁,如果相应数据不存在则不加锁RC隔离级别下,该参数开始启用。例如
SELECT FOR UPDATE,RC隔离级别下不应该存在间隙锁,因此该参数需要设置为true,当RowID或者 唯一索引 不存在的时候,不加锁allow_lock_with_conflict: 该参数为true的时候,启动公平锁优化功能当查询计划为点查的时候,该参数开始启用
启用该参数后,即使存在写冲突,依然还会对
KEY进行加锁,一般用于大量请求对同一数据进行更新的场景。详细优化可以参考 : RFC该参数目前仅用于命中点查的场景,例如使用
RowID或者唯一索引进行请求的场景
INSERT 场景
SQL 语句为:
| | should_not_exist | need_value | | lock_only_if_exists | allow_lock_with_conflict || ——— | —————— | ———— | - | ——————— | ————————– || PK LOCK | TRUE | FALSE | | FALSE | FALSE || UK LOCK | TRUE | FALSE | | FALSE | FALSE |
INSERT 场景下,会分别对主键和唯一索引发出 PessimisticLockRequest 的请求,两个请求的参数基本一致:
要求
TIKV上主键和唯一索引的KEY值不存在,因此should_not_exist参数为false
由于事务是
RR级别,因此lock_only_if_exists为false
DELETE 场景
SQL 语句为
| | should_not_exist | need_value | | lock_only_if_exists | allow_lock_with_conflict || ——— | —————— | ———— | - | ——————— | ————————– || PK LOCK | FALSE | TRUE | | FALSE | TRUE || UK LOCK | FALSE | TRUE | | FALSE | TRUE |
DELETE 场景下,
首先
TIDB需要通过唯一索引值 ‘Brad7’ 去查询RowID,因此UK LOCK过程中,need_value为TRUE,同时命中了唯一索引的点查,因此allow_lock_with_conflict为true接下来,
TIDB获取到RowID后,还需要通过RowID获取行数据,因此need_value为TRUE,同时命中了主键的点查,因此allow_lock_with_conflict为true
UPDATE 场景
SQL 语句为
| | should_not_exist | need_value | | lock_only_if_exists | allow_lock_with_conflict || ————- | —————— | ———— | - | ——————— | ————————– || PK LOCK | FALSE | TRUE | | FALSE | TRUE || OLD UK LOCK | FALSE | TRUE | | FALSE | TRUE || NEW UK LOCK | TRUE | FALSE | | FALSE | FALSE |
UPDATE 场景下,
首先
TIDB需要通过唯一索引值 ‘Brad7’ 去查询RowID,因此OLD UK LOCK过程中,need_value为TRUE,同时命中了唯一索引的点查,因此allow_lock_with_conflict为true接下来,
TIDB获取到RowID后,还需要通过RowID获取行数据,因此need_value为TRUE,同时命中了主键的点查,因此allow_lock_with_conflict为true最后,
TIDB还需要对新的唯一索引加锁,避免其他事务也想把FIRST_NAME更新为Brad9,同时此时唯一索引不应该存在,因此should_not_exist为TRUE
SELECT FOR UPDATE 场景
SQL 语句为
| | should_not_exist | need_value | | lock_only_if_exists | allow_lock_with_conflict || ——— | —————— | ———— | - | ——————— | ————————– || PK LOCK | FALSE | TRUE | | FALSE | TRUE || UK LOCK | FALSE | TRUE | | FALSE | TRUE |
SELECT FOR UPDATE 场景下,
首先
TIDB需要通过唯一索引值 ‘Brad7’ 去查询RowID,因此UK LOCK过程中,need_value为TRUE,同时命中了唯一索引的点查,因此allow_lock_with_conflict为true接下来,
TIDB获取到RowID后,还需要通过RowID获取行数据,因此need_value为TRUE,同时命中了主键的点查,因此allow_lock_with_conflict为true如果是 RC 级别的事务隔离,那么
lock_only_if_exists也会为TRUE,对不存在的数据不锁 (间隙锁)
源码快读
简化的代码如下
检测锁
检查
TiKV中锁情况,如果发现有锁
不是当前同一事务的锁,直接返回
KeyIsLocked是同一个事务的锁,但是锁的类型不是悲观锁,返回锁类型不匹配
LockTypeNotMatch(意味该请求已经超时)如果
need_value为true,那么需要加载最新的write数据如果请求参数
should_not_exist为true(一般用于主键或者唯一索引的悲观锁) ,那么还要检验没有write记录如果发现
TiKV里锁的t.for_update_ts>lock.ts(同一个事务重复更新), 使用当前请求的for_update_ts更新该锁其他情况,为重复请求,直接返回成功
检测新提交记录
检查是否存在更新的写入版本,如果有写入记录
若已提交的
commit_ts大于for_update_ts更新,说明存在冲突,返回WriteConflict特别的如果
allow_lock_with_conflict为true的话,继续加锁流程
若已提交的
commit_ts>t.start_ts,说明在当前事务begin后有其他事务提交过检查历史版本,如果发现当前请求的事务有没有被
Rollback过,返回PessimisticLockRollbacked错误如果已提交的数据
commit_ts=t.start_ts,那么是当前事务的Rollback记录,返回PessimisticLockRollbacked错误如果
need_value为true,那么需要加载最新的write数据如果请求参数
should_not_exist为true(一般用于主键或者唯一索引的悲观锁) ,那么还要检验没有write记录
给当前请求
key加上悲观锁,并返回成功某些情况当请求参数
lock_only_if_exists为 true 的时候,如果没有发现任何write记录的话,不加锁
PessimisticPrewrite
悲观锁加锁成功后,后期事务进行开始进行二阶段提交。和悲观锁相关的就是 Prewrite 阶段,经过 Prewrite 后,悲观锁将会被转化为普通的锁。
Prewrite 阶段关于悲观锁做了哪些事情呢?
load_lock: 将会检测之前事务调用PessimisticLockRequest所加的锁,是否还存在。(由于悲观事务的优化,例如 pipelined 、内存悲观锁 等等,可能存在锁丢失问题)amend_pessimistic_lock: 如果不存在,可能发生了锁丢失,继续检查write记录如果此时
KEY并没有其他并发事务修改,那么我们可以忽略这个异常,继续在Prewrite阶段加锁如果已经有并发事务更新了该
KEY,那么我们将会返回错误PessimisticLockNotFoundcheck_lock如果存在锁的话检查锁的类型是否是悲观锁,不符合的话报错:
PessimisticLockNotFound检查锁的
for_update_ts,如果和客户端存储的悲观锁ts不同的话,说明我们当前事务的锁已经丢失,当前这个锁是其他悲观事务的锁,返回PessimisticLockNotFoundwrite_lock将悲观锁修改为普通的锁如果悲观锁已经丢失,那么将会写入新的正常锁
如果是
1PC的话,需要直接把悲观锁直接删除,直接进入二阶段 commit 流程否则的话,将会修改
LOCK,特别是修改LOCK的类型从Pessimistic类型为Put/Delete/Lock类型
源码快读
简化代码如下:
悲观事务的优化
RcCheckTs
从 v6.3.0 版本开始,TiDB 支持通过开启系统变量
tidb_rc_write_check_ts对点写冲突较少情况下优化时间戳的获取。开启此变量后,点写语句会尝试使用当前事务有效的时间戳进行数据读取和加锁操作,且在读取数据时按照开启tidb_rc_read_check_ts的方式读取数据。目前该变量适用的点写语句包括UPDATE、DELETE、SELECT ...... FOR UPDATE三种类型。点写语句是指将主键或者唯一键作为过滤条件且最终执行算子包含POINT-GET的写语句。目前这三种点写语句的共同点是会先根据 key 值做点查,如果 key 存在再加锁,如果不存在则直接返回空集。
如果点写语句的整个读取过程中没有遇到更新的数据版本,则继续使用当前事务的时间戳进行加锁。
如果加锁过程中遇到因时间戳旧而导致写冲突,则重新获取最新的全局时间戳进行加锁。
如果加锁过程中没有遇到写冲突或其他错误,则加锁成功。
如果读取过程中遇到更新的数据版本,则尝试重新获取一个新的时间戳重试此语句。
在使用
READ-COMMITTED隔离级别且单个事务中点写语句较多、点写冲突较少的场景,可通过开启此变量来避免获取全局时间戳带来的延迟和开销。
Pipelined
参考:pipelined
针对悲观锁带来的时延增加问题,在 TiKV 层增加了 pipelined 加锁流程优化,优化前后逻辑对比:
优化前:满足加锁条件,等待 lock 信息通过 raft 写入多副本成功,通知 TiDB 加锁成功
pipelined :满足加锁条件,通知 TiDB 加锁成功、异步 lock 信息 raft 写入多副本 (两者并发执行)
异步 lock 信息 raft 写入流程后,从用户角度看,悲观锁流程的时延降低了;但是从 TiKV 负载的角度,并没有节省开销
有较低概率导致事务提交失败,但不会影响事务正确性。
那么说起来,TIKV 是如何保障事务的正确性的呢?答案是 Prewrite 的时候。
假如悲观事务使用了 pipelined 优化,又恰好 TIKV 多副本写入成功前崩溃了。TIDB 以为自己悲观锁加锁成功,其实并没有成功。后面 TIDB 事务提交过程中,会触发 Prewrite 流程:
Prewrite会特意检查,之前需要加的悲观锁的KEYLOCK,是否真的存在TIKV存储层里面如果这个
KEY不存在,那么直接返回错误,终止整个事务的提交
如果业务逻辑依赖加锁或等锁机制,或者即使在集群异常情况下也要尽可能保证事务提交的成功率,应关闭 pipelined 加锁功能。
内存悲观锁
参考:内存悲观锁原理
RFC: https://github.com/tikv/rfcs/blob/master/text/0077-in-memory-pessimistic-locks.md
pipelined 优化只是减少了 DML 时延,lock 信息跟优化前一样需要经过 raft 写入多个 region 副本,这个过程会给 raftstore、磁盘带来负载压力。
内存悲观锁针对 lock 信息 raft 写入多副本,做了更进一步优化,总结如下:
lock 信息只保存在内存中,不用写入磁盘
lock 信息不用通过 raft 写入多个副本,只要存在于 region leader
lock 信息写内存,延迟相对于通过 raft 写多副本,延迟极小
从优化逻辑上看,带来的性能提升会有以下几点:
减小 DML 时延
降低磁盘的使用带宽
降低 raftstore CPU 消耗
当 Region 发生合并或 leader 迁移时,为避免悲观锁丢失,TiKV 会将内存悲观锁写入磁盘并同步到其他副本。
内存悲观锁实现了和 pipelined 加锁类似的表现,即集群无异常时不影响加锁表现,但当 TiKV 出现网络隔离或者节点宕机时,事务加的悲观锁可能丢失。
如果业务逻辑依赖加锁或等锁机制,或者即使在集群异常情况下也要尽可能保证事务提交的成功率,应关闭内存悲观锁功能。
Fair Locking 公平锁优化
Enhanced pessimistic lock queueing RFC:https://github.com/tikv/rfcs/pull/100/files?short_path=b1bee83#diff-b1bee83cbc9b96a0f2b6ddcd382ac9d1b97f41d2c8aa840bf9043520af3d86bb
如果业务场景存在单点悲观锁冲突频繁的情况,原有的唤醒机制无法保证事务获取锁的时间,造成长尾延迟高,甚至获取锁超时。我们可以想象一下这个场景:
假如目前
TIKV存在很多悲观事务 (t1、t2、…) 对相同一行数据进行加锁,他们都在一个队列里面等待当前事务t的提交这个时候,
t提交了事务,TIKV唤醒了其中一个事务t1来继续处理,t1由于发现需要加锁的数据已经被更新,会向客户端 (TIDB) 返回WriteConflict来进行重试加锁流程恰好这个时候,
t2超过了wake-up-delay-duration时间被唤醒,也会尝试进行加锁流程
t1客户端收到WriteConflict错误后,还需要rollback所有之前加的悲观锁。然后开始重试statment
t1由于某些原因,请求到达TIKV的时候,t2已经加锁完毕,因此t1整个流程相当于空转为了解决这个问题,
TIKV对悲观事务进行了一系列优化,我们再重复上述场景:
目前
TIKV存在很多悲观事务 (t1、t2、…) 对相同一行数据进行加锁,他们都在一个队列里面等待当前事务t的提交这个时候,
t提交了事务,TIKV会唤醒了 *** 最早请求的 *** 事务t1来继续处理。与以往不同的是 ,t1的加锁流程将会 成功,即使发现加锁的数据已经被更新,也不会返回WriteConflict错误。但是该成功的请求会携带最新write记录的commit_ts,用来通知客户端,虽然加锁成功,但是数据其实是有冲突的
wake-up-delay-duration时间将不会起效,因此不会有其他事务突然唤醒来与t2事务并发
t1事务加锁成功的结果到达客户端后,由于commit数据有变动,客户端可能依旧会使用最新的ts进行再次重试
版权声明: 本文为 InfoQ 作者【TiDB 社区干货传送门】的原创文章。
原文链接:【http://xie.infoq.cn/article/f5e74098e9c17be1354aaeba6】。文章转载请联系作者。







评论