zoukankan      html  css  js  c++  java
  • TiDB的乐观锁与悲观锁模式

    为什么要写这篇笔记?

    TiDB自3.0.8版本开始默认使用悲观事务模型(只限新建集群,从之前的版本升级上来的默认还是使用乐观事务模式)。

    事务模型影响着数据库高并发场景下的写入性能并且关系到数据的完整性,如果不了解其中的差异那么在面对事务冲突引发的问题时就会比较盲目。

    很多新人(包括我在内)在学习TiDB的最初阶段对于TiDB的事务模型不甚了解,官方文档的解释虽精辟但并不很人性化,这篇笔记从最初的悲观和乐观模式的概念出发来探究乐观模式与悲观模式的差异,以及优劣。

    为了解决什么问题?

    精通TiDB的事务模型可以帮助了解日常生产遇到的写冲突异常,并可以帮助决定是使用悲观锁还是乐观锁模式。

    笔记正文:

    TiDB 乐观事务模型 | PingCAP Docs

    TiDB 悲观事务模型 | PingCAP Docs

    TiDB 4.0 新特性前瞻(二)白话“悲观锁” | PingCAP

    Percolator 和 TiDB 事务算法 | PingCAP

    6 TiDB 事务模型 · TiDB in Action

    完整的官网文档见上述链接,一些FAQ和具体细节就不复制粘贴了,本文主要通过一些描述来帮助理解这两种事务模型。

    一、广义概念的悲观锁和乐观锁

    乐观锁:并发场景下,数据库认为并发会话之间并不会互相影响,因此在事务的内存读写阶段不对数据加锁,只在事务提交时进行写冲突检测(例如数据版本是否过期,被删除等等)。

    悲观锁:并发场景下,数据库认为并发会话之间的数据写入是有冲突的,因此在事务做更改的初始阶段就会对要修改的数据加锁,直到事务结束。

    乐观锁与悲观锁模式的主要差异在于:到底是事务提交阶段加锁来检测事务冲突?还是在事务开始阶段就直接对数据加锁来阻塞其他写操作?

    可以看到,乐观锁模式下,只有在事务提交阶段才会检测写入冲突,而悲观模式全程加锁,所以相对而言乐观模式的并发量会高于悲观锁模式,但是其数据一致性性却比悲观锁差,并且事务失败时需要依赖应用层进行重试。

    主流的关系型数据库例如oracle、sqlserver、mysql等,大多采用悲观事务模型以保证高并发场景下事务的一致性。

    但是这里要强调一下,主流关系型数据库在事务模型的处理上多采用悲观事务模型以保证数据一致性,但并不是所有场景都使用悲观模型,例如mysql的commit和锁获取阶段都是乐观事务模型,只有在针对数据的获取方面采用悲观模型,而且在RDBMS系统中也鲜少会有产品在所有场景中都采用悲观模式,因为代价太大,且谁都不能确保自己的网络、硬件等永远不出问题。

    此外这里推几篇链接帮助理解乐观/悲观锁模式:

    pessimistic locking vs optimistic locking - Ask TOM (oracle.com)

    sql server - Optimistic vs. Pessimistic locking - Stack Overflow

    Concurrency Control - SQL Server | Microsoft Docs

    二、TiDB的乐观事务模型

    这里可以直接用一幅图来描述TiDB的乐观事务模型:

    略微解析一下上述的流程,这幅图需要按从左往右,从上往下顺序来看,其主要步骤为(详细解释参考官网链接):

    1.客户端开启事务后,tidb向pd获取一个tso作为事务的start_ts, 理解为start timestamp即可,以下同理。

    2.tidb从pd获取读取数据的路由(既数据存在哪些tikv节点上),然后从tikv读取数据,读取小于此start_ms的最新数据版本。

    3.数据在tidb的内存中完成修改。

    4.客户端发起commit操作,接下来就是TiDB的两阶段提交流程:

    5.第一阶段(prewrite阶段,也可以叫做加锁阶段):

        。tidb将要写入的数据按照key分类,然后从pd获取数据的写入路由(既数据应该写入到哪些tikv节点)。

        。tidb并发的向所有涉及的tikv发起请求,tikv收到请求后检查对应记录是否过期或者存在版本冲突,正常的话会加锁。

        。tidb收到所有prewrite成功的响应,至此第一阶段完成。

    6.第二阶段(正式提交阶段)

        。tidb向pd获取一个tso作为commit_ts

        。tidb向tikv发起第二阶段的提交请求,tikv进行数据写入,然后清理第一阶段的锁。

        。tidb收到两阶段提交成功的信息,客户端收到tidb反馈的事务成功的信息。

    7.最后tidb异步的清理本次事务遗留的锁信息。

    详细的2PC commit加锁比上述步骤描述的要复杂些,涉及到primary row的选择和secondries的异步提交以及行存储中某些字段的变更,可以参考上述链接了解。

    乐观事务的优点和缺陷:

    乐观事务的优点在于无需在事务执行阶段加锁,减少了锁获取的消耗,这样可以略微增加并发的性能。但前提是并发之间不会互相影响。

    乐观事务最大的缺陷在于出现写入冲突时,只有一个会话可以成功,其他的都只能失败,套用Tom Kyte的一句话就是:

    “我花了那么多时间来更新数据,结果到提交的时候你告诉我说:对不起你更改的数据已被其他会话变更,请重新开始???”

    这就是传统RDBMS事务中使用悲观事务模型的原因,因为可以避免此类写冲突问题,且实际上有很多方法来极大减小悲观锁模型下的获取锁的消耗。

    乐观事务下的重试机制:

    从上边的描述我们知道,乐观锁模型下会出现写失败,全部依赖程序解决有点不现实,所以tidb内部增加了重试机制。

    重试就相当于重新执行了事务,这样破坏了原本的事务一致性,可能产生更新丢失,不过一般情况下高并发时的更新丢失不会对业务造成什么影响,更新实际并未丢失,只是先后提交的问题。

    但:慎重开启乐观事务重试,如不能确保事务重试对业务的影响,那么使用悲观事务,或关闭乐观事务重试,至少这样程序侧会返回错误。

    以下两个参数控制重试的行为:

    # 设置是否禁用自动重试,默认为 “on”,即不重试。
    tidb_disable_txn_auto_retry = OFF
    # 控制重试次数,默认为 “10”。只有自动重试启用时该参数才会生效。
    # 当 “tidb_retry_limit= 0” 时,也会禁用自动重试。
    tidb_retry_limit = 10
    # 上述两个参数可以session或者global设置,上述参数对悲观事务不生效,因为悲观事务有自己的一套不会影响事务完整性的重试体系。

    三、TiDB的悲观事务模型

    TiDB如何开启悲观事务模式:(需要提前配置tidb:pessimistic-txn.enable=true以及tikv:pessimistic-txn.enabled=true)

    SET GLOBAL tidb_txn_mode = 'pessimistic';
    # 或者执行以下 SQL 语句显式地开启悲观事务:
    BEGIN PESSIMISTIC;

    在了解了乐观事务之后,我们再理解悲观事务就很简单了,乐观事务不是在提交阶段才加锁吗,悲观事务就是事务的起始阶段就加锁,

    以上边乐观事务的图为例,加锁就发生在get data from TiKV or cache with start_ts阶段,如果事务中包含select语句(不带for update的)那么会在执行首个DML语句时加锁。

    另外悲观模式下由于阻塞获取锁失败也有次数限制,默认256次,可以通过pessimistic-txn.max-retry-limit修改。

    TiDB的悲观事务模型依然基于Percolator,可以看做是基于传统Percolator的改进,改进点在于将加锁的阶段从2PC prewrite阶段提前到了事务执行阶段,只是悲观锁相比乐观锁而言只有一个占位符(除此之外锁的位置和锁格式几乎一模一样),等2PC prewrite时会直接将悲观锁改写为标准的Percolator乐观锁。

    Percolator模型的每条记录都有L(lock)列和W(write)列,前者用于记录事务上锁信息,后者用于记录各个已提交行版本的commitTs信息。

    悲观事务下可以解决乐观事务模型下写入冲突的问题吗?

    想啥呢......当然不可以,悲观事务模式下同一时刻依然只能有一个会话执行成功,但是其他会话并不是直接失败,而是被阻塞直到可以获取锁。这种模式更接近innodb的悲观锁模式。可以避免程序进行写冲突处理,或者避免事务重试时造成的更新丢失。

    Pipelined加锁流程(默认关闭的):

    悲观事务模型下事务加锁需要向tikv写入数据,经过raft提交并apply之后才会返回,这样开销比较大,所以TiDB通过pipelined机制降低加锁消耗:

    当数据满足加锁要求时,TiKV 立刻通知 TiDB 执行后面的请求,并异步写入悲观锁,从而降低大部分延迟,显著提升悲观事务的性能。但当 TiKV 出现网络隔离或者节点宕机时,悲观锁异步写入有可能失败,从而产生以下影响:

    无法阻塞修改相同数据的其他事务。如果业务逻辑依赖加锁或等锁机制,业务逻辑的正确性将受到影响。

    有较低概率导致事务提交失败,但不会影响事务正确性。

    如果业务逻辑依赖加锁或等锁机制,或者即使在集群异常情况下也要尽可能保证事务提交的成功率,应关闭 pipelined 加锁功能。

    四、为什么我们应该使用乐观/悲观事务模型?

    回到最核心最迫切的问题上,我们的TiDB应该使用哪种事务模型?悲观or乐观?

    官方的回答如下:

    乐观事务模型下,将修改冲突视为事务提交的一部分。因此并发事务不常修改同一行时,可以跳过获取行锁的过程进而提升性能。但是并发事务频繁修改同一行(冲突)时,乐观事务的性能可能低于悲观事务

    启用乐观事务前,请确保应用程序可正确处理 commit语句可能返回的错误。如果不确定应用程序将会如何处理,建议改为使用悲观事务。

    我的回答如下:

    用悲观事务模型就好了(重要:4.0以下请继续使用乐观模型),不必了一丁点虚无缥缈的性能提升采用乐观事务模型,而且悲观事务模型下使用mysql driver访问tidb的BUG更少,行为更加贴近访问mysql本身。从我个人的观测看来,悲观事务下集群性能相比乐观事务模型并无明显下降。

    此外如果业务本身并不要求事务的强一致性,且追求极限的读写性能,那么可以继续使用乐观事务模型并开启重试。

    五、该使用什么事务隔离级别?

    在TiDB4.0.0-beta版本之前,无论是乐观还是悲观事务模式都只能使用RR事务隔离级别(snapshot隔离级别),在4.0.0之后tidb引入了RC事务隔离级别。

    关于TiDB事务隔离级别的官方解释及其与MySQL的差异,参考:TiDB 事务隔离级别 | PingCAP Docs

    RR隔离级别下tidb和mysql的区别是tidb的当前事务发现自己的欲锁定数据被更新后会失败回滚,而MySQL会更新成功,但MySQL更新成功并不是指更新生效,只是返回更新成功,实际上更新并未被写入磁盘。

    RC隔离级别下tidb的行为模式与mysql、oracle等更加相似,所以官方建议悲观事务模型下采用RC事务隔离级别,并关闭事务重试功能。

    六、关于读写阻塞

    TiDB 锁冲突问题处理 | PingCAP Docs

    官网关于TiDB读写阻塞的相关信息比较少,参考上述链接。在一些博客和线下的meetup中我们也可以抓取到一些信息,但是因为版本更迭较快文档较少还是无法一窥全貌。

    如果不看上述链接,TiDB现有官方文档就会潜移默化的传递给你一个写不会阻塞读的信息,因为tidb采用MVCC机制并且事务执行阶段不加锁。但实质上某些情况下写是会阻塞读操作的。

    乐观模型:

        当事务进行到2PC commit阶段时会对当前行版本加锁,其他更新相同行的事务会由于冲突进行事务回滚,之后进行重试(如果重试开启的话);select语句在需要读取相应行记录时会被阻塞,因为无法确定被锁定的行commit_ts是否小于读事务start_ts,为确保一致性读,只能等待至超时或者锁释放。

    悲观模型:

        事务执行阶段,悲观锁不会阻塞读事务,因为还没到prewrite阶段;当事务进行到2PC commit阶段时,悲观锁会转为乐观锁,此时读事务依然可能被阻塞并重试,原因同上。

    MVCC: 

        当读事务读取数据时相应记录上已经没有锁,那么比较行记录W列中的commit_ts,找到小于start_ts的最新行版本进行读取。

    TiDB4.0的大事务尤其需要解决这种写阻塞读的问题,因为大事务时出现读写阻塞的几率会极大增加,因此TiDB 4.0做出了相应改进:

    简单来说就是悲观事务进入2pc commit的prewrite阶段时,TiDB4.0的事务会在锁中保留一个minCommitTs(4.0之前这个ts其实就是真正的commit_ts)。当读事务读到锁时如果根据锁的状态发现写事务还处于执行阶段那么直接用读事务的start_ts更新这个minCommitTs,然后直接到各个历史行版本里找对应的满足一致性读的行版本即可,这样读事务的start_ms永远<=写事务的commit_ts。这样依据minCommitTs进行读取的结果就是读事务遇到锁之后不会等待也不会读到写事务提交的值。缺陷在于会强制推进写事务的commit_ts,导致写事务记载的提交时间被延后,但这不算是个大问题,因为同一时刻只有一个写事务能获取锁,大家一起延后呗。

    而当读事务读到锁时如果根据锁的状态发现写事务处于commit阶段,那么就像上边说的:读事务不知道写事务什么时候提交,所以会产生等待,直到读事务提交才可以判断被锁定的记录是否是自己需要的,如果直接读取数据可能产生幻读。

    因此在TiDB4.0版本之后,我们可以说由于开启了大事务支持所以悲观锁不再会阻塞读(但是高并发单点更新依然会触发读被阻塞)。

    想建一个数据库技术和编程技术的交流群,用于磨炼提升技术能力,目前主要专注于Golang和Python以及TiDB,MySQL数据库,群号:231338927,建群日期:2019.04.26,截止2021.02.01人数:300人 ... 如发现博客错误,可直接留言指正,感谢。
  • 相关阅读:
    Hadoop 解除 “Name node is in safe mode”
    ubuntu永久修改主机名
    ssh免密码登录
    su 和 su- 会影响环境变量
    卸载ubuntu自带openJDK,更改成自己的JDK版本
    ubuntu安装jdk 1.6
    147.Insertion Sort List
    145.Binary Tree Postorder Traversal
    144.Binary Tree Preorder Traversal
    143.Reorder List
  • 原文地址:https://www.cnblogs.com/leohahah/p/14313652.html
Copyright © 2011-2022 走看看