TiDB事务方案

TiDB事务方案

分布式数据库上的分布式事务一直是数据库中研究的重点,目前而言,Newsql类的数据库都拿出了较为可行的解决方案,本篇文章主要研究学习关于 TiDB 的事务处理模型,TiDB 的事务处理模型来源于 google 的 Percolator 系统,普适性较强,方便开发者开发学习。

关于TiDB的整体架构设计不再赘述,需要认识到的问题是,TiDB 所提供的事务方案和多版本方案都与下层存储层的方案有所区别,下层以 RocksDB 为底层所实现的存储层的多版本方案并不能被上层使用,以及在分布式服务层面的 server 上的 log 和下层在存储中涉及到的 log 也不能划等号,这说明在处理事务的数据粒度上也大有不同。

这篇文章所研究的TiDB代码是6.0版本。TiDB 中关于事务部分的源码比较繁多,在 github 上的 TiDB/src 部分主要涉及从计算层面到事务层的转变,真正提供 TSO 方案的 PD 部分代码以及执行2PC 部分源码则在 client-go 部分。

事务模型概述

Percolator

关于 Google Percolator:Percolator 是 Google 的上一代分布式事务解决方案,构建在 BigTable 之上,在 Google 内部 用于网页索引更新的业务,原始论文的翻译在此。原理比较简单,总体来说就是一个经过优化的二阶段提交的实现,进行了一个二级锁的优化。而 TiDB 的事务模型实现沿用了 Percolator 的事务模型,在整体流程中,事务的提交依赖于 TS(timestamp),在每一步操作中都要先获取一个唯一的 TS,而为了保证 TS 的唯一性,Percolator 引入了 TSO(Timestamp Oracle)。

Percolator 系统为了实现事务,对原先的存储结构进行了改进,对原先 BigTable 存储结构添加了两个列族 lock(L 列) 和 write(W 列:

  • D 列:数据信息
  • L 列:锁信息
  • W 列:数据提交的版本信息

P e r c o l a t o r 中的表结构: Percolator 中的表结构: Percolator中的表结构:
在这里插入图片描述

分布式事务模型

写事务模型
  • 本地事务准备
    1. 事务提交前,**先在本地的 buffer 执行所有的 update/delete/insert 操作。**具体的流程在 SQL 的编译和执行部分讲解。
  • PreWrite 阶段
    1. 在事务开启时会从 TSO 获取一个 timestamp 作为 start_ts;
    2. 首先在所有行的写操作的数据行中选出一个作为 primary,其他的为 secondaries。
    3. primary 的 prewrite 流程,对 primaryRow 写入 L 列(上锁信息),L 列中记录本次事务的开始时间戳。写入 L 列前会检查:
      1. 是否已经有别的客户端已经上锁 (Locking),若有则回滚事务。
      2. 检查 W 列(提交信息),是否在本次事务开始时间之后,是否有更新在 [startTS, +Inf) 的相同 key 的写操作已经提交 ,则(Conflict),进行回滚。
    4. 将 primaryRow 的锁上好了以后,进行 secondaries 的 prewrite 流程:
      1. 类似 primaryRow 的上锁流程,只不过锁的内容为事务开始时间及 primaryRow 的 Lock 的信息。
      2. 检查的事项同 primaryRow 的一致。
    5. 当锁成功写入后,写入 row,时间戳设置为 startTS。
  • Commit 阶段
    1. 从TSO处获取一个timestamp作为事务的提交时间(后称为commitTs);
    2. primary 的 commit 流程:检查 L 列时间戳是否为 startTS,表明当前事务仍拥有当前行的锁,删除L列,写入 W 列新数据。
    3. 如果 primary row 提交失败的话,全事务回滚,回滚逻辑同 prewrite。如果 commit primary 成功,则可以异步的 commit secondaries,流程和 commit primary 一致, 这一步从逻辑上讲不会出现失败。
读事务模型
  1. 检查该行是否有 L 列,时间戳为 [0, start_TS],如果有,表示目前有其他事务正占用此行,如果这个锁已经超时则尝试清除,否则等待超时或者其他事务主动解锁。
  2. 如果步骤 1 发现锁不存在,则可以安全的读取。

事务类型

TiDB 中的事务,从事务和 SQL 语句的关系上讲,可以分为显式事务隐式事务两种,显式事务由 BEGIN/START TRANSACTION 这样的 SQL 语句显示开启,而隐式事务则在单一的 DML 语句执行时产生。

TiDB 中的分布式事务,从类型上讲,分为悲观事务乐观事务两种:

  • 乐观事务

    执行 DML 语句时,乐观事务默认不会检查主键约束唯一约束,而是在 COMMIT 事务时进行这些检查。

  • 悲观事务

    在执行每一条 DML 语句时都会检查主键约束唯一约束

TiDB默认的事务类型是乐观事务,两种事务类型的区别主要在于对冲突的检测时机和处理方案上,下面这张图可以略窥一二。
悲观事务和乐观事务的区别: 悲观事务和乐观事务的区别: 悲观事务和乐观事务的区别:
在这里插入图片描述

两幅图中红色方框的地方就是这两种事务类型的不同之处,在悲观事务中,事务的每一条 SQL 语句都需要执行冲突检测。而在乐观事务中,所有的修改都先存储在 buffer(kv.Membuffer类型对象) 中,最终在提交流程的 prewrite 阶段进行冲突检测。这其中的具体实现如乐观锁和悲观锁这里暂不展开,可以先参考下面的一些blog:

TiDB 悲观锁的实现原理:https://asktug.com/t/topic/33550

TiDB 悲观事务: https://pingcap.com/zh/blog/pessimistic-transaction-the-new-features-of-tidb

TSO

TSO 是一个单调递增的时间戳,由 PD leader 分配。

Placement Driver(后续以 PD 简称))是 TiDB 里面全局中心总控节点,它负责整个集群的调度,负责全局 ID 的生成,以及全局时间戳 TSO 的生成等。PD 还保存着整个集群 TiKV 的元信息,负责给 client 提供路由功能。

关于为什么选用 TSO 可以保证分布式数据库的线性一致性,有很多文章都有涉及,证明略复杂:

​ https://developer.aliyun.com/article/838079

TiDB 在事务开始时会获取 TSO 作为 start_ts、提交时获取 TSO 作为 commit_ts,依靠 TSO 实现事务的 MVCC。TiDB 中的 TSO 为 64 位的整型数值,由物理部分和逻辑部分组成,高 48 位为物理部分是 unixtime 的毫秒时间,低 18 位为逻辑部分是一个数值计数器,理论上每秒可产生 262144000(即 2 ^ 18 * 1000)个 TSO。

在这里插入图片描述

为保证性能 PD 并不会每次为一个请求生成一个 TSO,而是会预先申请一个可分配的时间窗口,时间窗口是当前时间和当前时间+3 秒后的 TSO,并保存在 etcd 内,之后便可以从窗口中分配 TSO。每隔一定时间就会触发更新时间窗口。当 PD 重启或 leader 切换后会从 etcd 内获取保存的最大 TSO 开始分配,以保证 TSO 的连续递增。

TiDB 6.0:让 TSO 更高效 https://pingcap.com/zh/blog/tidb-6.0-make-tso-more-efficient

TiDB 的事务处理与实现

实现流程

开启事务

TiDB 会在每一条 SQL 语句开始执行开启一个事务,但是这时开启的事务并非是活跃事务,因为由一些 SQL 语句的事务并不需要真正被使用。 TiDB 中的事务所使用的 TS 时间戳是唯一确定,而产生用不上时间戳对于 PD 来说是一种资源的浪费,因此对于不活跃的事务,PD并不会赋予其时间戳。

新事务的创建最早在 Stmt 执行前就完成了,但是这个事务并不处于活跃状态,是直接调用 EnterNewTxn 函数创建的。对于 BIGEN/START TRANSACTION 语句,会调用 executeBegin 函数,在执行中再次创建一个事务代替这个事务。

而对于除过 BIGEN/START TRANSACTION 的语句,会在语句在 buffer 上执行成功后开启这个事务。

func (m *txnManager) EnterNewTxn(ctx context.Context, r *sessiontxn.EnterNewTxnRequest) error {
	ctxProvider, err := m.newProviderWithRequest(r)
	if err != nil {
		return err
	}

	if err = ctxProvider.OnInitialize(ctx, r.Type); err != nil {
		m.sctx.RollbackTxn(ctx)
		return err
	}

	m.ctxProvider = ctxProvider
	if r.Type == sessiontxn.EnterNewTxnWithBeginStmt {
		m.sctx.GetSessionVars().SetInTxn(true)
	}
	return nil
}

TiDB 在开启事务时会调用 Txn 函数,如果输入参数为 false,表示默认的事务并不开启,如果参数为 true,表示默认的事务开启。默认的 Txn 存在于 Stmt 的 Context 中

func (s *session) Txn(active bool) (kv.Transaction, error) {
	if !active {
		return &s.txn, nil
	}
	_, err := sessiontxn.GetTxnManager(s).ActivateTxn() //调用 ActivateTxn 正是开启事务
	return &s.txn, err
}

每个事务都同一个用户访问数据库的 session 对应,BEGIN/START TRANSACTION 这两条 SQL 的执行就是使之后的 SQL 在活跃的事务中执行。在关于 TiDB 的计算层的blog中有关于 TiDB 中 SQL 执行流程的描述,所有的 SQL 语句最终都会转换成 Physical Plan 被执行,下面是 BEGIN 语句对应的 Physical Plan 执行时触发事务进入活跃期的流程。

在这里插入图片描述

LazyTxn 是 TiDB 客户端中的事务类型,包装了 kv.Transaction 和 txnFuture 两种类型的对象,kv.Transaction 指向在 KvStore 中的一个事务实例,而 txnFuture 类型则是一个对未来事务的承诺,由于事务在正式有效之前需要进行一些初始化(包括获取Start_TS )因此需要先创建一个 txnFuture 实例,等到初始化完成且获取到实际的 TS,kv.Transaction才会真正可用。

PD leader 通过 getTimestampWithRetry 调用 oracle.tso 产生全局唯一的 TS 作为事务的Start_TS并返回给客户端。
在这里插入图片描述

当事务处于活跃期且已经获得了 Start_TS 时,事务的开启已经完成,事务下一步将关注于 DML 的执行。

数据写入

对于事务中执行的 SQL 语句,TiDB 关注会影响数据一致性的 SQL 语句。DDL 语句并不会产生需要返回的数据结果,TiDB 采用简单的两阶段提交完成,而对于 DML 语句,TiDB 会先在事务的缓存 membuff中存储对应的数据,这样在事务失败时便于回滚。INSERT/DELETE/UPDATE 三种语句在执行时,都涉及到将数据写入事务缓存的操作,这里以 INSERT 语句为例展示缓存写入的流程:

在这里插入图片描述

事务提交

TiDB 的提交是一个比较标准的两阶段提交过程,但涉及的函数调用较为复杂,这里暂时不展开,后面补上:

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值