TiDB乐观事务实现原理
参考官网文档:https://docs.pingcap.com/zh/tidb/stable/optimistic-transaction
- TiDB加锁非原子操作,先读取,后加锁,可能存在写写冲突
其他数据库一般是在读的时候直接加锁,然后再修改。TiDB 在执行完 DML 后才能得到会修改的 key,这时才会加锁
TiDB悲观事务实现原理
参考官网文档: https://docs.pingcap.com/zh/tidb/stable/pessimistic-transaction
- 部分场景下,更新前后未变化的key不进行加锁,系统变量
- TiDB尽量按照FIFO方式对解锁等待的事务唤醒,MySQL 8.0 按照SATS(Contention-Aware Transaction Scheduling)模式唤醒
悲观锁不阻塞读,因为遇到悲观锁,说明事务一定没有获取到commit_ts
TiDB的加锁、清锁、锁续期
等锁
悲观事务遇到锁时要等待锁释放,为了高效的实现锁等待,每个 TiKV 都有 Waiter Manager。当 Acquire Pessimistic Lock 遇到 KeyIsLocked 时,会在 Waiter Manager 里等锁释放(commit/rollback)。
单次等锁有超时时间:
-
为了支持 innodb_lock_wait_timeout,TiDB 会传入 wait timeout。
-
TiKV 有 wait-for-lock-timeout 配置,为默认和最大的单次等锁超时时间,原因是:
- 若是其他事务遗留下来的锁,需要 TiDB Resolve Lock 才能清理。
- 若 lock 所在的 region leader 切换到其他 TiKV 上,在之前 leader 等锁的事务不会被唤醒。
v7.0开始使用加强悲观锁唤醒模型 tidb_pessimistic_txn_fair_locking
加锁
在prewrite 阶段的 primary key 加锁(悲观事务此时唤醒 ttl manager 开始续期,乐观事务大于32M此时开始唤醒 ttl manager 开始续期)
悲观锁会写到tikv,要走raft消息同步流程
悲观事务TiDB 收到来自客户端的更新数据的请求会加锁,此时的锁为悲观锁,与prewrite阶段加的锁类型不一样
唤醒
若多个事务等待相同锁,会先唤醒 start ts 最小的事务,其他事务会在 wake-up-delay-duration 后同时唤醒;若该锁再被释放,会唤醒 start ts 第二小的,其他事务再往后推 wake-up-delay-duration,以此类推。为了防止一直被推导致饥饿或其他问题,单次等锁时间最大仍是 wait-for-lock-timeout。
清锁
为了防止挂掉的事务写下的或者 Commit 没清理掉的 lock 一直阻塞后面事务的执行,每个 lock 都有 TTL,当事务遇到锁时会等待锁被清理或者等锁过期执行 Resolve Lock。
Resolve Lock 的流程是:查看 Primary Lock 的状态,若是已提交则把遇到的 lock 提交;若是未提交或者不存在则写入 Rollback 记录,再把遇到的 lock Rollback。
清锁只有tidb Resolve Lock 才能清理,异常状态下,比如tidb_server断连,前序事务会遗留之前加的锁,在等待 wait-for-lock-timeout 后tidb 发起 resolve lock 清理残留的锁(因为前序事务已经不会自己处理 commit 或者 rollback)
TTL manager
加锁后,TTL manager对锁的生命周期进行管理,主要以txn heartbeat的方式
- lockTTL最小3s,最大20s,其余情况根据参数设置及事务大小进行计算,最终值会加上事务查询时间作为浮动
ManagedLockTTL uint64 = 20000 // 20s
MaxPipelinedTxnTTL uint64 = 24 * 60 * 60 * 1000 // 24h
const bytesPerMiB = 1024 * 1024
// ttl = ttlFactor * sqrt(writeSizeInMiB)
var ttlFactor = 6000
var defaultLockTTL uint64 = 3000
const DefTxnCommitBatchSize uint64 = 16 * 1024
func txnLockTTL(startTime time.Time, txnSize int) uint64 {
// Increase lockTTL for large transactions.
// The formula is `ttl = ttlFactor * sqrt(sizeInMiB)`.
// When writeSize is less than 256KB, the base ttl is defaultTTL (3s);
// When writeSize is 1MiB, 4MiB, or 10MiB, ttl is 6s, 12s, 20s correspondingly;
lockTTL := defaultLockTTL
if txnSize >= int(kv.TxnCommitBatchSize.Load()) {
sizeMiB := float64(txnSize) / bytesPerMiB
lockTTL = uint64(float64(ttlFactor) * math.Sqrt(sizeMiB))
if lockTTL < defaultLockTTL {
lockTTL = defaultLockTTL
}
if lockTTL > ManagedLockTTL {
lockTTL = ManagedLockTTL
}
}
// Increase lockTTL by the transaction's read time.
// When resolving a lock, we compare current ts and startTS+lockTTL to decide whether to clean up. If a txn
// takes a long time to read, increasing its TTL will help to prevent it from been aborted soon after prewrite.
elapsed := time.Since(startTime) / time.Millisecond
return lockTTL + uint64(elapsed)
}
- 每10s进行一次txn heartbeat,按照lockttl对事务进行续期,并打印newttl
const keepAliveMaxBackoff = 20000
const pessimisticLockMaxBackoff = 20000
const maxConsecutiveFailure = 10
# ttl-refreshed-txn-size decides whether a transaction should update its lock TTL.
# If the size(in byte) of a transaction is large than `ttl-refreshed-txn-size`, it update the lock TTL during the 2PC.
ttl-refreshed-txn-size = 33554432
func keepAlive(
...
// Ticker is set to 1/2 of the ManagedLockTTL.
ticker := time.NewTicker(time.Duration(atomic.LoadUint64(&ManagedLockTTL)) * time.Millisecond / 2)
uptime := uint64(oracle.ExtractPhysical(now) - oracle.ExtractPhysical(c.startTS))
maxTtl := config.GetGlobalConfig().MaxTxnTTL
if isPipelinedTxn {
maxTtl = max(maxTtl, MaxPipelinedTxnTTL)
}
newTTL := uptime + atomic.LoadUint64(&ManagedLockTTL)
对于悲观事务,加悲观锁的时候就会启动 ttl 续约直到 txn 结束,和事务大小无关。
乐观事务较小不会启动 ttl,由ttl-refreshed-txn-size
配置控制,默认 32MB,以该大小作为下限启动TTL