做者:黄东旭node
关注 TiDB 的朋友大概会注意到,TiDB 在 3.0 中引入了一个实验性的新功能:悲观事务模型。这个功能也是千呼万唤始出来的一个功能。mysql
你们知道,发展到今天,TiDB 不只仅在互联网行业普遍使用,更在一些传统金融行业开花结果,而悲观事务是在多数金融场景不可或缺的一个特性。另外事务做为一个关系型数据库的核心功能,任何在事务模型上的改进都会影响无数的应用,并且在一个分布式系统上如何漂亮的实现悲观事务模型,是一个颇有挑战的工做,因此今天咱们就来聊聊这块“硬骨头”。算法
在聊事务以前,先简单科普一下 ACID 事务,下面是从 Wikipedia 摘抄的 ACID 的定义:sql
举个直观的例子,就是银行转帐,要么成功,要么失败,在任何状况下别出现这边扣了钱那边没加上的状况。数据库
所谓分布式事务,简单来讲就是在一个分布式数据库上实现和传统数据库同样的 ACID 事务功能。缓存
不少人介绍乐观事务和悲观事务的时候会扯一大堆数据库教科书的名词搞得很专业的样子,其实这个概念并不复杂, 甚至能够说很是好理解。我这里用一个生活中的小例子介绍一下。网络
想象一下你立刻出发要去一家餐厅吃饭,可是你去以前不肯定会不会满桌,你又不想排号。这时的你会有两个选择,若是你是个乐观的人,心里戏可能会是「管他的,去了再说,大不了没座就回来」。反之,若是你是一个悲观的人,可能会先打个电话预定一下,先确认下确定有座,同时交点定金让餐厅预留好这个座位,这样就能够直接去了。并发
上面这个例子很直观的对应了两种事务模型的行为,乐观事务模型就是直接提交,遇到冲突就回滚,悲观事务模型就是在真正提交事务前,先尝试对须要修改的资源上锁,只有在确保事务必定可以执行成功后,才开始提交。 异步
理解了上面的例子后,乐观事务和悲观事务的优劣就很好理解了。对于乐观事务模型来讲,比较适合冲突率不高的场景,由于直接提交(“直接去餐厅”)大几率会成功(“餐厅有座”),冲突(“餐厅无座”)的是小几率事件,可是一旦遇到事务冲突,回滚(回来)的代价会比较大。悲观事务的好处是对于冲突率高的场景,提早上锁(“打电话交定金预定”)的代价小于过后回滚的代价,并且还能以比较低的代价解决多个并发事务互相冲突、致使谁也成功不了的场景。分布式
在 TiDB 中分布式事务实现一直使用的是 Percolator 的模型。在聊咱们的悲观事务实现以前,咱们先简单介绍下 Percolator。
Percolator 是 Google 在 OSDI 2010 的一篇 论文 中提出的在一个分布式 KV 系统上构建分布式事务的模型,其本质上仍是一个标准的 2PC(2 Phase Commit),2PC 是一个经典的分布式事务的算法。网上介绍两阶段提交的文章不少,这里就不展开了。可是 2PC 通常来讲最大的问题是事务管理器(Transaction Manager)。在分布式的场景下,有可能会出现第一阶段后某个参与者与协调者的链接中断,此时这个参与者并不清楚这个事务到底最终是提交了仍是被回滚了,由于理论上来讲,协调者在第一阶段结束后,若是确认收到全部参与者都已经将数据落盘,那么便可标注这个事务提交成功。而后进入第二阶段,可是第二阶段若是某参与者没有收到 COMMIT 消息,那么在这个参与者复活之后,它须要到一个地方去确认本地这个事务后来到底有没有成功被提交,此时就须要事务管理器的介入。
聪明的朋友在这里可能就看到问题,这个事务管理器在整个系统中是个单点,即便参与者,协调者均可以扩展,可是事务管理器须要原子的维护事务的提交和回滚状态。
Percolator 的模型本质上改进的就是这个问题。下面简单介绍一下 Percolator 模型的写事务流程:
其实要说没有单点也是不许确的,Percolator 的模型内有一个单点 TSO(Timestamp Oracle)用于分配单调递增的时间戳。可是在 TiDB 的实现中,TSO 做为 PD leader 的一部分,由于 PD 原生支持高可用,因此天然有高可用的能力。
每当事务开始,协调者(在 TiDB 内部的 tikv-client 充当这个角色)会从 PD leader 上获取一个 timestamp,而后使用这个 ts 做为标记这个事务的惟一 id。标准的 Percolator 模型采用的是乐观事务模型,在提交以前,会收集全部参与修改的行(key-value pairs),从里面随机选一行,做为这个事务的 Primary row,剩下的行自动做为 secondary rows,这里注意,primary 是随机的,具体是哪行彻底不重要,primary 的惟一意义就是负责标记这个事务的完成状态。
在选出 Primary row 后, 开始走正常的两阶段提交,第一阶段是上锁+写入新的版本,所谓的上锁,其实就是写一个 lock key, 举个例子,好比一个事务操做 A、B、C,3 行。在数据库中的原始 Layout 以下:
假设咱们这个事务要 Update (A, B, C, Version 4),第一阶段,咱们选出的 Primary row 是 A,那么第一阶段后,数据库的 Layout 会变成:
上面这个只是一个释义图,实际在 TiKV 咱们作了一些优化,可是原理上是相通的。上图中标红色的是在第一阶段中在数据库中新写入的数据,能够注意到,A_Lock
、B_Lock
、C_Lock
这几个就是所谓的锁,你们看到 B 和 C 的锁的内容其实就是存储了这个事务的 Primary lock 是谁。在 2PC 的第二阶段,标志事务是否提交成功的关键就是对 Primary lock 的处理,若是提交 Primary row 完成(写入新版本的提交记录+清除 Primary lock),那么表示这个事务完成,反之就是失败,对于 Secondary rows 的清理不须要关心,能够异步作(为何不须要关心这个问题,留给读者思考)。
理解了 Percolator 的模型后,你们就知道实际上,Percolator 是采用了一种化整为零的思路,将集中化的事务状态信息分散在每一行的数据中(每一个事务的 Primary row 里),对于未决的状况,只须要经过 lock 的信息,顺藤摸瓜找到 Primary row 上就能肯定这个事务的状态。
对于不少普通的互联网场景,虽然并发量和数据量都很大,可是冲突率其实并不高。举个简单的例子,好比电商的或者社交网络,刨除掉一些比较极端的 case 例如「秒杀」或者「大V」,访问模式基本能够认为仍是比较随机的,并且在互联网公司中不少这些极端高冲突率的场景都不会直接在数据库层面处理,大多经过异步队列或者缓存在来解决,这里不作过多展开。
可是对于一些传统金融场景,因为种种缘由,会有一些高冲突率可是又须要保证严格的事务性的业务场景。举个简单的例子:发工资,对于一个用人单位来讲,发工资的过程其实就是从企业帐户给多个员工的我的帐户转帐的过程,通常来讲都是批量操做,在一个大的转帐事务中可能涉及到成千上万的更新,想象一下若是这个大事务执行的这段时间内,某个我的帐户发生了消费(变动),若是这个大事务是乐观事务模型,提交的时候确定要回滚,涉及上万个我的帐户发生消费是大几率事件,若是不作任何处理,最坏的状况是这个大事务永远没办法执行,一直在重试和回滚(饥饿)。
另一个更重要的理由是,有些业务场景,悲观事务模型写起来要更加简单。此话怎讲?
由于 TiDB 支持 MySQL 协议,在 MySQL 中是支持可交互事务的,例如一段程序这么写(伪代码):
mysql.SetAutoCommit(False); txn = mysql.Begin(); affected_rows = txn.Execute(“UPDATE t SET v = v + 1 WHERE k = 100”); if affected_rows > 0 { A(); } else { B(); } txn.Commit();
你们注意下,第四行那个判断语句是直接经过上面的 UPDATE 语句返回的 affected_rows
来决定究竟是执行 A 路径仍是 B 路径,可是聪明的朋友确定看出问题了,在一个乐观事务模型的数据库上,在 COMMIT 执行以前,实际上是并不知道最终 affected_rows
究竟是多少的,因此这里的值是没有意义的,程序有可能进入错误的处理流程。这个问题在只有乐观事务支持的数据库上几乎是无解的,须要在业务侧重试。
这里的问题的本质是 MySQL 的协议支持可交互事务,可是 MySQL 并无原生的乐观事务支持(MySQL InnoDB 的行锁能够认为是悲观锁),因此原生的 MySQL 在执行上面这条 UPDATE 的时候会先上锁,确认本身的 Update 可以完成才会继续,因此返回的 affected_rows
是正确的。可是对于 TiDB 来讲,TiDB 是一个分布式系统,若是要实现几乎和单机的 MySQL 同样的悲观锁行为(就像咱们在 3.0 中干的那样),仍是比较有挑战的,好比须要引入一些新的机制来管理分布式锁,因此呢,咱们选择先按照论文实现了乐观事务模型,直到 3.0 中咱们才动手实现了悲观事务。下面咱们看看这个“魔法”背后的实现吧。
在讨论实现以前,咱们先聊聊几个重要的设计目标:
TiDB 实现悲观事务的方式很聪明并且优雅,咱们仔细思考了 Percolator 的模型发现,其实咱们只要将在客户端调用 Commit 时候进行两阶段提交这个行为稍微改造一下,将第一阶段上锁和等锁提早到在事务中执行 DML 的过程当中不就能够了吗,就像这样:
TiDB 的悲观锁实现的原理确实如此,在一个事务执行 DML (UPDATE/DELETE) 的过程当中,TiDB 不只会将须要修改的行在本地缓存,同时还会对这些行直接上悲观锁,这里的悲观锁的格式和乐观事务中的锁几乎一致,可是锁的内容是空的,只是一个占位符,待到 Commit 的时候,直接将这些悲观锁改写成标准的 Percolator 模型的锁,后续流程和原来保持一致便可,惟一的改动是:
对于读请求,遇到这类悲观锁的时候,不用像乐观事务那样等待解锁,能够直接返回最新的数据便可(至于为何,读者能够仔细想一想)。
至于写请求,遇到悲观锁时,只须要和本来同样,正常的等锁就好。
这个方案很大程度上兼容了原有的事务实现,扩展性、高可用和灵活性都有保证(基本复用原来的 Percolator 天然没有问题)。
可是引入悲观锁和可交互式事务,就可能引入另一个问题:死锁。这个问题其实在乐观事务模型下是不存在的,由于已知全部须要加锁的行,因此能够按照顺序加锁,就天然避免了死锁(实际 TiKV 的实现里,乐观锁不是顺序加的锁,是并发加的锁,只是锁超时时间很短,死锁也能够很快重试)。可是悲观事务的上锁顺序是不肯定的,由于是可交互事务,举个例子:
这俩事务若是并发执行,就可能会出现死锁的状况。
因此为了不死锁,TiDB 须要引入一个死锁检测机制,并且这个死锁检测的性能还必须好。其实死锁检测算法也比较简单,只要保证正在进行的悲观事务之间的依赖关系中不能出现环便可。
例如刚才那个例子,事务 1 对 A 上了锁后,若是另一个事务 2 对 A 进行等待,那么就会产生一个依赖关系:事务 2 依赖事务 1,若是此时事务 1 打算去等待 B(假设此时事务 2 已经持有了 B 的锁), 那么死锁检测模块就会发现一个循环依赖,而后停止(或者重试)这个事务就行了,由于这个事务并无实际的 prewrite + 提交,因此这个代价是比较小的。
<center>TiDB 悲观锁的死锁检测</center>
在具体的实现中,TiKV 会动态选举出一个 TiKV node 负责死锁检测(实际上,咱们就是直接使用 Region1 所在的 TiKV node),在这个 TiKV node 上会开辟一块内存的记录和检测正在执行的这些事务的依赖关系。在悲观事务在等锁的时候,第一步会通过这个死锁检测模块,因此这部分可能会多引入一次 RPC 进行死锁检测,实际实现时死锁检测是异步的,不会增长延迟(回想一下交给饭店的定金 :P)。由于是纯内存的,因此性能仍是很不错的,咱们简单的对死锁检测模块进行了 benchmark,结果以下:
基本能达到 300k+ QPS 的吞吐,这个吞吐已经可以适应绝大多数的并发事务场景了。另外还有一些优化,例如,显然的悲观事务等待的第一个锁不会致使死锁,不会发送请求给 Deadlock Detector 之类的,其实在实际的测试中, 悲观事务模型带来的 overhead 其实并不高。另外一方面,因为 TiKV 自己支持 Region 的高可用,因此必定能保证 Region 1 会存在,间接解决了死锁检测服务的高可用问题。
关于悲观锁还须要考虑长事务超时的问题,这部分比较简单,就不展开了。
在 TiDB 3.0 的配置文件中有一栏:
将这个 enable
设置成 true
便可,目前默认是关闭的。
第二步,在实际使用的时候,咱们引入了两个语法:
BEGIN PESSIMISTIC
BEGIN /*!90000 PESSIMISTIC */
用这两种 BEGIN 开始的事务,都会进入悲观事务模式,就这么简单。
悲观事务模型是对于金融场景很是重要的一个特性,并且对于目标是兼容 MySQL 语义的 TiDB 来讲,这个特性也是提高兼容性的重要一环,但愿你们可以喜欢,Enjoy it!
原文阅读:https://pingcap.com/blog-cn/pessimistic-transaction-the-new-features-of-tidb/