写点什么

香,聊聊 TiDB 的分布式事务模型

发布于: 2021 年 02 月 11 日

这篇文章原始链接:https://mp.weixin.qq.com/s/WKfAo7qYvcr1IdbBuPD_RA

作者:程序员 jinjunzhu


在传统关系型数据库领域,我们常常通过配置事务的隔离级别来解决脏读、幻读、不可重复读的问题。不同的事务隔离级别对应解决问题的力度是不一样的,下表是不同事务隔离级别对脏读、幻读、不可重复读的容忍度,我们一起看一下:


注意:

Repeatable read 的读锁会一直到事务结束才释放;

Read committed 的读锁不等到事务结束,而是读取完成后立即释放。


当然,传统数据库解决并发控制的手段还有 mvcc,这里就不展开了。


上面我提到了读锁,写锁、GAP 锁,实际上锁的种类远远不止这些。对于我们开发者来讲,经常会谈到乐观锁和悲观锁。乐观锁实际上是不加锁的,悲观锁需要真正的加锁。而在分布式数据库领域,同样需要并发控制,同样也有乐观事务和悲观事务。


就 TiDB 来说,v3.0 版本开始支持悲观事务,从 v3.0.8 开始,新搭建的 TiDB 集群已经默认使用悲观事务了。

传统数据库加锁


传统数据库的乐观锁,主要是在表中加入一个版本号字段,在更新的时候根据更新结果来进行判断是否成功。比如我们有一张表 table_a, 我们在其中加一个 version 字段,下面是 table_a 表的 1 条记录


我们更新这条 id=1 的记录,SQL 如下:


update table_a set name='xiaoming',version = version + 1 where id=1 and version=4
复制代码


这时如果 SQL 执行结果返回更新行数是 0,说明别的事务已经更新了 version 字段,写冲突产生,业务代码必须处理这个冲突。高并发下如果对同一条记录的修改操作非常多,势必造成大量写失败。所以乐观锁更适合读多写少的场景。


传统数据库的悲观锁,是用物理加锁的方式,还是上面的表,不需要 version 字段了,假如有 2 条记录:

这时加入我们要同时更新 id=1 的记录和 id=2 的记录,如果在一个事务内完成,加锁 sql 如下:


select * from table_a where id in(1, 2) for update;update table_a set name = 'zhangsan' where id=1;update table_a set name = 'lisi' where id=2;
复制代码


悲观锁的问题是遇到长事务,其他事务需要较长时间的锁等待,所以 oracle 提供了下面的优化,即发现待修改数据被锁定后立刻返回失败:


select * from table_a for update nowait
复制代码

Percolator 模型


Percolator 模型是 Google 提出的构建在 BigTable 之上的分布式事务解决方案。Google 的论文如下,文章链接见延伸阅读[1]:


《Large-scale Incremental Processing Using Distributed Transactions and Notifications》
复制代码


我们以经典的电商系统为例,假如系统中有订单、账户和库存 3 张表,用户一次购物需要增加 1 条订单记录,账户表需要扣减金额,库存表需要扣减库存,而这 3 张表要操作的记录分别在分布式数据库的 3 个切片上,这时就需要应对分布式事务了。


我们看一下 Percolator 算法模型:



初始阶段


初始阶段,我们假设订单表记录订单数量是 0,账户表记录账户金额 1000,库存表记录商品数量是 100,客户下了 1 个订单后,订单表增加 1 个订单,账户表扣除金额 100,库存表扣减商品数量 1。各个表的初始数据如下表:

上面表格中,":"前面是用时间戳表示的数据版本,后面是数据值。第一列是表名,第二列的低版本保存了数据,第三列列保存了事务操作给数据加的锁。第四列的高版本保存了指向保存数据版本的指针,比如 6 这个版本保存了指向了 5 这个版本数据的指针 6:data@5。

Prewrite 阶段


在 Prewrite 阶段,协调节点向每个切片发送 Prewrite 命令。Percolator 定义了 primary lock 即主锁的概念,Prewrite 阶段,每个分布式事务只能有一个要修改的数据行可以获得主锁,本案例假如订单表获得了主锁,其他表的锁是指向这个主锁的指针,叫做 secondary lock,如下表:


Prewrite 阶段,每个要修改的数据行会写日志,并且根据时间戳记录事务的私有版本,这里的私有版本就是 7,这样其他事务就不能操作这三条数据了。


注意,获取主锁时,如果出现了下面的情况,就会加锁失败:

1.其他事务已经加锁;

2.本次事务开始之后,要更新的数据被其他数据更新了。

commit 阶段


在 commit 阶段,协调节点只需要跟拥有 primary lock 的切片进行通信,所以本案例只需要跟订单表所在切片通信。这时数据如下表:



我们注意到 order 表的锁没有了,而且增加了版本 8 指向版本 7。说明订单表已经提交成功,没有私有版本了,但是账户表和库存表的私有版本还在。这是因为 Percolator 模型并不会同步 commit 账户表和库存表,而是启动异步线程来 commit 这两张表并清理锁。如果订单表提交失败,账户表和库存表也都需要回滚。


提交成功后,最终数据如下表:

commit 阶段,因为协调节点只需要跟拥有主锁的切片(这里是订单表所在切片)进行通信,保证了原子性,这样就避免了 commit 时节点不能全部成功导致的数据不一致问题。


而 Prewrite 阶段记录了日志和私有版本,如果账户表和库存表所在切片 commit 失败,可以根据日志进行再次 commit,这样就保证了数据最终一致。


这里要注意 2 点:

1.主锁的选择是随机的,比如本例中并不一定会选择订单表;

2.协调节点发送 commit 后订单表先提交成功,这时如果其他事务要读取账户服务和库存服务的 2 条数据,虽然 2 条数据上面还有 lock,但是查找 primary@order.bal 发现已提交,所以是可以读取的。不过读取时需要做一下 secondary lock 清理工作。

TiDB 乐观事务模型


上面我们分析了 Percolator 模型,TiDB 的乐观事务正是使用了 Percolator 模型。


TiDB 支持 MVCC,事务启动的时候,会使用一个时间戳 start_ts 作为当前事务 ID,同时作为 MVCC 的快照版本,之后的读请求会读取当前快照版本下的数据,数据校验成功后客户端进行两阶段 commit,我们看一下下面的时序图:


第一阶段,TiDB 收到客户端请求后,首先会从缓存的待修改 key 中找出第一个发送 prewrite 请求,这个 key 加 primary lock 后返回成功。然后 TiDB 会对这个事务其他的所有的 key 发送 prewrite 请求,这些 key 加 secondary lock 后返回成功。


第二阶段,prewrite 成功后,TiDB 首先会从 PD 获取一个时间戳作为当前事务的 commit_ts,然后向 primary lock key 发送 commit 请求,primary lock key 提交数据成功后清理掉 primary lock 返回成功。TiDB 收到 primary lock key 的成功消息后给客户端返回成功。


乐观事务的冲突检测主要是在 prewrite 阶段,如果检测到当前的 key 已经加锁,会有一个等待时间,这个时间过后如果还没有获取到锁,就返回失败。因此当多个事务修改同一个 key 时,必然导致大量的锁冲突。


注意:TiDB 也有重试机制,默认是关闭的。TiDB 的重试会重新获取 start_ts,但是不会重新读取数据,因此不能保证可重复读的隔离级别。详细参考 TiDB 官方文档。

TiDB 悲观事务模型


TiDB 从 v3.0 版本开始,引入了悲观事务。

注意:v3.0.7 及之前版本创建的集群升级到更高版本后,默认还是采用乐观事务,只有新创建集群才会默认使用悲观事务。我们也可以采用下面命令来开启悲观事务。下面第 1 个语句会修改 TiDB 系统参数,后面 2 个语句会忽略系统参数,优先级更高:

SET GLOBAL tidb_txn_mode = 'pessimistic';

BEGIN PESSIMISTIC;

BEGIN OPTIMISTIC;


为了兼容 mysql,TiDB 的悲观事务和 mysql 很类似。悲观事务支持可重复读和读已提交两种隔离级别,默认使用可重复读。TiDB 中乐观事务和悲观事务可以共存,会优先会采用乐观事务,只有锁冲突时,才会使用悲观事务。


使用悲观事务的语句如下:


UPDATE、DELETE、INSERT、SELECT FOR UPDATE
复制代码


TiDB 的悲观事务有几点需要注意:


  • SELECT FOR UPDATE 语句会对已提交的最新的数据而非所修改的行加上悲观锁

  • TiDB 不支持 GAP 锁,所以在 FOR UPDATE 语句的 WHERE 条件使用范围条件时,还是可以插入的,比如下面的 sql 如果 id 不冲突,还是可以插入成功的:

SELECT * FROM t1 WHERE id BETWEEN 1 AND 10 FOR UPDATE;
复制代码


  • 可以通过 innodb_lock_wait_timeout 变量设置等待锁超时时间,默认是 50s

  • 不支持支持 FOR UPDATE NOWAIT 语法

  • 如果 Point Get 和 Batch Point Get 算子没有读到数据,依然会对给定的主键或者唯一键加锁,阻塞其他事务对相同主键加锁或者进行写入操作

  • 在悲观事务执行期间,如果执行 DDL 操作,是可以成功的,但之后事务会提交失败

  • 悲观事务的执行时间有上限,默认为 10 分钟,可以通过参数配置

总结


业务场景的复杂化,必然导致乐观事务冲突变多,这也是 TiDB 后续版本转向悲观事务的重要原因。TiDB 中乐观事务和悲观事务可以共存。


延伸阅读:

[1].https://www.cs.princeton.edu/courses/archive/fall10/cos597B/papers/percolator-osdi10.pdf

[2].https://docs.pingcap.com/zh/tidb/stable/pessimistic-transaction

[3].https://docs.pingcap.com/zh/tidb/stable/optimistic-transaction

[4].https://pingcap.com/blog-cn/percolator-and-txn/


发布于: 2021 年 02 月 11 日阅读数: 15
用户头像

资深后端开发,长期关注分布式、云原生领域 2018.11.24 加入

近10年写码经验,目前仍在深耕一线

评论

发布
暂无评论
香,聊聊TiDB的分布式事务模型