写点什么

TIKV 分布式事务 -- 悲观锁

  • 2024-03-01
    北京
  • 本文字数:12224 字

    阅读完需:约 40 分钟

作者: 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 UPDATEINSERT/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 事务提交后,才能确定这个事务的更改是否可对事务 tSELECT 语句可见。因此需要 等锁、清锁或者 重试。


对于 悲观事务 来说,


事务默认是 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_tsSELECT 语句可见。因此需要 等锁、清锁或者 重试。

悲观事务原理

加锁流程概述

悲观事务是在乐观事务的基础上进行改造的。基本流程为:


  • 每次进行 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 接口尝试加悲观锁,这时候会遇到 t0KEY 关联的悲观锁,从而会阻塞,直到 t0 事务提交或者回滚完成清理锁

PessimisticLock 接口

整体流程

参考: pipelined


检查 TiKV 中锁情况,如果发现有锁

  • 不是当前同一事务的锁,直接返回 KeyIsLocked

  • 是同一个事务的锁,但是锁的类型不是悲观锁,返回锁类型不匹配(意味该请求已经超时)

  • 如果发现 TiKV 里锁的 t.for_update_ts>lock.ts (同一个事务重复更新), 使用当前请求的 for_update_ts 更新该锁

  • 如果请求参数 should_not_existtrue (一般用于主键或者唯一索引的悲观锁) ,那么还要检验没有 write 记录

  • 其他情况,为重复请求,直接返回成功

检查是否存在更新的写入版本,如果有写入记录

  • 若已提交的 commit_ts 大于 for_update_ts 更新,说明存在冲突,返回 WriteConflict

  • 若已提交的 commit_ts>t.start_ts ,说明在当前事务 begin 后有其他事务提交过

  • 检查历史版本,如果发现当前请求的事务有没有被 Rollback 过,返回 PessimisticLockRollbacked 错误

  • 如果已提交的数据 commit_ts=t.start_ts ,那么是当前事务的 Rollback 记录,返回 PessimisticLockRollbacked 错误

  • 如果请求参数 should_not_existtrue (一般用于主键或者唯一索引的悲观锁) ,那么还要检验没有 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 UPDATERC 隔离级别下不应该存在间隙锁,因此该参数需要设置为 true,当 RowID 或者 唯一索引 不存在的时候,不加锁

  • allow_lock_with_conflict : 该参数为 true 的时候,启动公平锁优化功能

  • 当查询计划为点查的时候,该参数开始启用

  • 启用该参数后,即使存在写冲突,依然还会对 KEY 进行加锁,一般用于大量请求对同一数据进行更新的场景。详细优化可以参考 : RFC

  • 该参数目前仅用于命中点查的场景,例如使用 RowID 或者唯一索引进行请求的场景

INSERT 场景

SQL 语句为:


INSERT INTO MANAGERS_UNIQUE(MANAGER_ID,FIRST_NAME, LAST_NAME, LEVEL)                    VALUES ('14275','Brad9','Craven9',9);
复制代码


| | 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_existsfalse

DELETE 场景

SQL 语句为


DELETE FROM MANAGERS_UNIQUE where FIRST_NAME='Brad7';
复制代码


| | 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_valueTRUE,同时命中了唯一索引的点查,因此 allow_lock_with_conflicttrue

  • 接下来,TIDB 获取到 RowID 后,还需要通过 RowID 获取行数据,因此 need_valueTRUE,同时命中了主键的点查,因此 allow_lock_with_conflicttrue

UPDATE 场景

SQL 语句为


UPDATE MANAGERS_UNIQUE SET FIRST_NAME="Brad9" where FIRST_NAME='Brad7';
复制代码


| | 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_valueTRUE,同时命中了唯一索引的点查,因此 allow_lock_with_conflicttrue

  • 接下来,TIDB 获取到 RowID 后,还需要通过 RowID 获取行数据,因此 need_valueTRUE,同时命中了主键的点查,因此 allow_lock_with_conflicttrue

  • 最后,TIDB 还需要对新的唯一索引加锁,避免其他事务也想把 FIRST_NAME 更新为 Brad9,同时此时唯一索引不应该存在,因此 should_not_existTRUE

SELECT FOR UPDATE 场景

SQL 语句为


select * from MANAGERS_UNIQUE where FIRST_NAME="Brad7"  for update;
复制代码


| | 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_valueTRUE,同时命中了唯一索引的点查,因此 allow_lock_with_conflicttrue

  • 接下来,TIDB 获取到 RowID 后,还需要通过 RowID 获取行数据,因此 need_valueTRUE,同时命中了主键的点查,因此 allow_lock_with_conflicttrue

  • 如果是 RC 级别的事务隔离,那么 lock_only_if_exists 也会为 TRUE,对不存在的数据不锁 (间隙锁)

源码快读

简化的代码如下

检测锁

检查 TiKV 中锁情况,如果发现有锁

  • 不是当前同一事务的锁,直接返回 KeyIsLocked

  • 是同一个事务的锁,但是锁的类型不是悲观锁,返回锁类型不匹配 LockTypeNotMatch(意味该请求已经超时)

  • 如果 need_valuetrue,那么需要加载最新的 write 数据

  • 如果请求参数 should_not_existtrue (一般用于主键或者唯一索引的悲观锁) ,那么还要检验没有 write 记录

  • 如果发现 TiKV 里锁的 t.for_update_ts>lock.ts (同一个事务重复更新), 使用当前请求的 for_update_ts 更新该锁

  • 其他情况,为重复请求,直接返回成功


pub fn acquire_pessimistic_lock<S: Snapshot>(    txn: &mut MvccTxn,    reader: &mut SnapshotReader<S>,    key: Key,    primary: &[u8],    should_not_exist: bool,    lock_ttl: u64,    mut for_update_ts: TimeStamp,    need_value: bool,    need_check_existence: bool,    min_commit_ts: TimeStamp,    need_old_value: bool,    lock_only_if_exists: bool,    allow_lock_with_conflict: bool,) -> MvccResult<(PessimisticLockKeyResult, OldValue)> {
let mut need_load_value = need_value;
if let Some(lock) = reader.load_lock(&key)? { if lock.ts != reader.start_ts { return ErrorInner::KeyIsLocked...; } if !lock.is_pessimistic_lock() { return ErrorInner::LockTypeNotMatch... }
let requested_for_update_ts = for_update_ts; let locked_with_conflict_ts = if allow_lock_with_conflict && for_update_ts < lock.for_update_ts { ... need_load_value = true; for_update_ts = lock.for_update_ts; Some(lock.for_update_ts) } else { None };
if need_load_value || need_check_existence || should_not_exist { let write = reader.get_write_with_commit_ts(&key, for_update_ts)?; if let Some((write, commit_ts)) = write { check_data_constraint(reader, should_not_exist, &write, commit_ts, &key).or_else( |e| { if is_already_exist(&e) && commit_ts > requested_for_update_ts { ... return Err(write_conflict_error...)); } Err(e) }, )?;
... } }
// Overwrite the lock with small for_update_ts if for_update_ts > lock.for_update_ts { let lock = PessimisticLock { ... }; txn.put_pessimistic_lock(key, lock, false); } else { ... } return Ok(( PessimisticLockKeyResult::new_success... ),... )); }
复制代码

检测新提交记录

检查是否存在更新的写入版本,如果有写入记录

  • 若已提交的 commit_ts 大于 for_update_ts 更新,说明存在冲突,返回 WriteConflict

  • 特别的如果 allow_lock_with_conflicttrue 的话,继续加锁流程

  • 若已提交的 commit_ts>t.start_ts ,说明在当前事务 begin 后有其他事务提交过

  • 检查历史版本,如果发现当前请求的事务有没有被 Rollback 过,返回 PessimisticLockRollbacked 错误

  • 如果已提交的数据 commit_ts=t.start_ts ,那么是当前事务的 Rollback 记录,返回 PessimisticLockRollbacked 错误

  • 如果 need_valuetrue,那么需要加载最新的 write 数据

  • 如果请求参数 should_not_existtrue (一般用于主键或者唯一索引的悲观锁) ,那么还要检验没有 write 记录

  • 给当前请求 key 加上悲观锁,并返回成功

  • 某些情况当请求参数 lock_only_if_exists 为 true 的时候,如果没有发现任何 write 记录的话,不加锁


 if let Some((commit_ts, write)) = reader.seek_write(&key, TimeStamp::max())? {                if commit_ts > for_update_ts {            if allow_lock_with_conflict {                ...                for_update_ts = commit_ts;                need_load_value = true;            } else {                return ErrorInner::WriteConflict...            }        }
if commit_ts == reader.start_ts && (write.write_type == WriteType::Rollback || write.has_overlapped_rollback) { return Err(ErrorInner::PessimisticLockRolledBack... } .into()); }
if commit_ts > reader.start_ts { if let Some((older_commit_ts, older_write)) = reader.seek_write(&key, reader.start_ts)? { if older_commit_ts == reader.start_ts && (older_write.write_type == WriteType::Rollback || older_write.has_overlapped_rollback) { return Err(ErrorInner::PessimisticLockRolledBack... } } }
// Check data constraint when acquiring pessimistic lock. check_data_constraint(reader, should_not_exist, &write, commit_ts, &key).or_else(|e| { if is_already_exist(&e) { if let Some(conflict_info) = conflict_info { return ErrorInner::WriteConflict... } } Err(e) })?;
if need_value || need_check_existence || conflict_info.is_some() { val = match write.write_type { // If it's a valid Write, no need to read again. WriteType::Put if write .as_ref() .check_gc_fence_as_latest_version(reader.start_ts) => { if need_load_value { Some(reader.load_data(&key, write)?) } else { Some(vec![]) } } WriteType::Delete | WriteType::Put => None, WriteType::Lock | WriteType::Rollback => { if need_load_value { reader.get(&key, commit_ts.prev())? } else { reader.get_write(&key, commit_ts.prev())?.map(|_| vec![]) } } }; } }
let lock = PessimisticLock { ... };
// When lock_only_if_exists is false, always acquire pessimistic lock, otherwise // do it when val exists if !lock_only_if_exists || val.is_some() { txn.put_pessimistic_lock(key, lock, true); } else if let Some(conflict_info) = conflict_info { return Err(write_conflict_error...)); }

Ok(( PessimisticLockKeyResult::new_success( ... ), ))
复制代码

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 类型

源码快读

简化代码如下:


pub fn prewrite<S: Snapshot>(    txn: &mut MvccTxn,    reader: &mut SnapshotReader<S>,    txn_props: &TransactionProperties<'_>,    mutation: Mutation,    secondary_keys: &Option<Vec<Vec<u8>>>,    pessimistic_action: PrewriteRequestPessimisticAction,    expected_for_update_ts: Option<TimeStamp>,) -> Result<(TimeStamp, OldValue)> {    let mut mutation =        PrewriteMutation::from_mutation(mutation, secondary_keys, pessimistic_action, txn_props)?;
let lock_status = match reader.load_lock(&mutation.key)? { Some(lock) => mutation.check_lock(lock, pessimistic_action, expected_for_update_ts)?, None if matches!(pessimistic_action, DoPessimisticCheck) => { amend_pessimistic_lock(&mut mutation, reader)?; lock_amended = true; LockStatus::None } None => LockStatus::None, };
...
let is_new_lock = !matches!(pessimistic_action, DoPessimisticCheck) || lock_amended; let final_min_commit_ts = mutation.write_lock(lock_status, txn, is_new_lock)?;
Ok((final_min_commit_ts, old_value))}
pub fn load_lock(&mut self, key: &Key) -> Result<Option<Lock>> { if let Some(pessimistic_lock) = self.load_in_memory_pessimistic_lock(key)? { return Ok(Some(pessimistic_lock)); }
let res = match self.snapshot.get_cf(CF_LOCK, key)? { ... }
Ok(res)}
fn check_lock( &mut self, lock: Lock, pessimistic_action: PrewriteRequestPessimisticAction, expected_for_update_ts: Option<TimeStamp>, ) -> Result<LockStatus> { if lock.ts != self.txn_props.start_ts { if matches!(pessimistic_action, DoPessimisticCheck) { return ErrorInner::PessimisticLockNotFound... }
return ErrorInner::KeyIsLocked(self.lock_info(lock)?; }
if lock.is_pessimistic_lock() { if !self.txn_props.is_pessimistic() { return Err(ErrorInner::LockTypeNotMatch... }
if let Some(ts) = expected_for_update_ts && lock.for_update_ts != ts { return Err(ErrorInner::PessimisticLockNotFound... }
return Ok(LockStatus::Pessimistic(lock.for_update_ts)); }
Ok(LockStatus::Locked(min_commit_ts)) }
fn amend_pessimistic_lock<S: Snapshot>( mutation: &mut PrewriteMutation<'_>, reader: &mut SnapshotReader<S>,) -> Result<()> { let write = reader.seek_write(&mutation.key, TimeStamp::max())?; if let Some((commit_ts, write)) = write.as_ref() { if *commit_ts >= reader.start_ts { return ErrorInner::PessimisticLockNotFound... }
} else { mutation.last_change = LastChange::NotExist; }
Ok(())}
fn write_lock( self, lock_status: LockStatus, txn: &mut MvccTxn, is_new_lock: bool, ) -> Result<TimeStamp> { ... let mut lock = Lock::new( self.lock_type.unwrap(), self.txn_props.primary.to_vec(), self.txn_props.start_ts, self.lock_ttl, None, for_update_ts_to_write, self.txn_props.txn_size, self.min_commit_ts, false, ) ....
if try_one_pc { txn.put_locks_for_1pc(self.key, lock, lock_status.has_pessimistic_lock()); } else { txn.put_lock(self.key, &lock, is_new_lock); }
final_min_commit_ts}
pub(crate) fn put_lock(&mut self, key: Key, lock: &Lock, is_new: bool) { if is_new { self.new_locks .push(lock.clone().into_lock_info(key.to_raw().unwrap())); } let write = Modify::Put(CF_LOCK, key, lock.to_bytes()); self.modifies.push(write);}
复制代码

悲观事务的优化

RcCheckTs

参考:rc_write_check_ts


从 v6.3.0 版本开始,TiDB 支持通过开启系统变量 tidb_rc_write_check_ts 对点写冲突较少情况下优化时间戳的获取。开启此变量后,点写语句会尝试使用当前事务有效的时间戳进行数据读取和加锁操作,且在读取数据时按照开启 tidb_rc_read_check_ts 的方式读取数据。目前该变量适用的点写语句包括 UPDATEDELETESELECT ...... 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 存在很多悲观事务 (t1t2、…) 对相同一行数据进行加锁,他们都在一个队列里面等待当前事务 t 的提交

  • 这个时候,t 提交了事务,TIKV 唤醒了其中一个事务 t1 来继续处理,t1 由于发现需要加锁的数据已经被更新,会向客户端 (TIDB) 返回 WriteConflict 来进行重试加锁流程

  • 恰好这个时候,t2 超过了 wake-up-delay-duration 时间被唤醒,也会尝试进行加锁流程

  • t1 客户端收到 WriteConflict 错误后,还需要 rollback 所有之前加的悲观锁。然后开始重试 statment

  • t1 由于某些原因,请求到达 TIKV 的时候,t2 已经加锁完毕,因此 t1 整个流程相当于空转

为了解决这个问题,TIKV 对悲观事务进行了一系列优化,我们再重复上述场景:

  • 目前 TIKV 存在很多悲观事务 (t1t2、…) 对相同一行数据进行加锁,他们都在一个队列里面等待当前事务 t 的提交

  • 这个时候,t 提交了事务,TIKV 会唤醒了 *** 最早请求的 *** 事务 t1 来继续处理。与以往不同的是 ,t1 的加锁流程将会 成功,即使发现加锁的数据已经被更新,也不会返回 WriteConflict 错误。但是该成功的请求会携带最新 write 记录的 commit_ts ,用来通知客户端,虽然加锁成功,但是数据其实是有冲突的

  • wake-up-delay-duration 时间将不会起效,因此不会有其他事务突然唤醒来与 t2 事务并发

  • t1 事务加锁成功的结果到达客户端后,由于 commit 数据有变动,客户端可能依旧会使用最新的 ts 进行再次重试


发布于: 35 分钟前阅读数: 4
用户头像

TiDB 社区官网:https://tidb.net/ 2021-12-15 加入

TiDB 社区干货传送门是由 TiDB 社区中布道师组委会自发组织的 TiDB 社区优质内容对外宣布的栏目,旨在加深 TiDBer 之间的交流和学习。一起构建有爱、互助、共创共建的 TiDB 社区 https://tidb.net/

评论

发布
暂无评论
TIKV 分布式事务--悲观锁_TiDB 底层架构_TiDB 社区干货传送门_InfoQ写作社区