前言
首先我们需要大概了解分布式事务的悲观锁:
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语句可见。因此需要 等锁、清锁或者 重试。
如果 t0 事务提交在 t.start_ts 之前,那么我们事务 t 的 SELECT 语句需要将 t0 的数据查询出来
对于 悲观事务 来说,
事务默认是 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事务提交或者回滚完成清理锁
SELECT FOR UPDATE 特别的,这个语句也会触发 PessimisticLock 的接口,对 KEY 加一个悲观锁,防止其他事务对这个 KEY 进行修改。
同时在事务提交后,这个 KEY 也会有一个 LOCK 类型的 write 记录,即使事务没有对这个 KEY 进行任何更改。
这样可以防止有异常的情况,其他事务误将自己的记录提交到了 SELECT FOR UPDATE 的前面,破坏了事务的隔离性。
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 语句为:
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_exists为false
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_value为TRUE,同时命中了唯一索引的点查,因此allow_lock_with_conflict为true - 接下来,
TIDB获取到RowID后,还需要通过RowID获取行数据,因此need_value为TRUE,同时命中了主键的点查,因此allow_lock_with_conflict为true
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_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 语句为
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_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更新该锁- 其他情况,为重复请求,直接返回成功
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_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记录的话,不加锁
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
从 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所有之前加的悲观锁。然后开始重试statmentt1由于某些原因,请求到达TIKV的时候,t2已经加锁完毕,因此t1整个流程相当于空转为了解决这个问题,
TIKV对悲观事务进行了一系列优化,我们再重复上述场景:
- 目前
TIKV存在很多悲观事务(t1、t2、...)对相同一行数据进行加锁,他们都在一个队列里面等待当前事务t的提交- 这个时候,
t提交了事务,TIKV会唤醒了最早请求的 事务t1来继续处理。与以往不同的是 ,t1的加锁流程将会 成功,即使发现加锁的数据已经被更新,也不会返回WriteConflict错误。但是该成功的请求会携带最新write记录的commit_ts,用来通知客户端,虽然加锁成功,但是数据其实是有冲突的wake-up-delay-duration时间将不会起效,因此不会有其他事务突然唤醒来与t2事务并发t1事务加锁成功的结果到达客户端后,由于commit数据有变动,客户端可能依旧会使用最新的ts进行再次重试


