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
或者唯一索引不存在,此时设置为true
need_value
: 该参数为 true 的时候,需要返回 KEY 对应的 VALUE 数据当执行
UPDATE
的时候,需要根据唯一索引获取对应的RowID
数据 , 或者根据RowID
获取对应的行数据 (例如UPDATE TABLE SET ... WHERE UK="xxx"
), 这个参数必须是true
SELECT FOR UPDATE
的时候,这个参数当然必须是true
need_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
,那么我们将会返回错误PessimisticLockNotFound
check_lock
如果存在锁的话检查锁的类型是否是悲观锁,不符合的话报错:
PessimisticLockNotFound
检查锁的
for_update_ts
,如果和客户端存储的悲观锁ts
不同的话,说明我们当前事务的锁已经丢失,当前这个锁是其他悲观事务的锁,返回PessimisticLockNotFound
write_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
会特意检查,之前需要加的悲观锁的KEY
LOCK
,是否真的存在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】。文章转载请联系作者。
评论