MySQL - 03事务和锁

86 阅读16分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

Mysql - 事务和锁

本文已参与「新人创作礼」活动, 一起开启掘金创作之路。

什么是事务?

事务的概念:

  • 事务是 对数据库中数据操作保证数据逻辑一致的最小操作单位。
  • 事务就是要保证一组数据库操作,要么全部成功,要么全部失败。

事务的ACID

  • Atomicity 原子性
    • 主要对操作动作来讲:对数据的修改结果,要么全部执行,要么全都不执行
    • 相当于原子的不可分割特性
  • Consistency 一致性
    • 主要对数据层面来讲,数据保持一致性;相当于能量守恒定律
    • 一致性有必要举例说明:
      • 经典例子是银行转账:转出和转入账户必须金额相等,如果不等那就意味着不合理,也就是不一致。
      • 把一瓶可乐倒进玻璃杯中也是一样,可乐瓶中少的与玻璃杯中增加的必然要一致;这个在我们客观世界成为 能量守恒定律,但在虚拟世界,就要用一致性来保证能量守恒!
  • Isolation 隔离性
    • 事务之间相互独立,互不影响
  • Durability 持久性
    • 事务完成,对于数据的修改是永久的;

事务的隔离级别

四种隔离级别

  • 读未提交
    • 一个事务还没提交前,他做的变更就被其他事务读到了;
    • 读未提交会产生 脏读:事务A修改了数据,未提交,事务B可以读取到值;
  • 读已提交
    • 事务A修改了数据,提交事务后,事务B才能读取到值
    • 读已提交会出现 不可重复读:(同一个事务中,两次读到的数据不一致)
  • 可重复读
    • 事务A在执行过程中读到的值,与事务A开启时读到的值一致;
    • 注意:未提交的事务,对其他事务不可见
  • 串行化
    • 事务的读、写都会加锁;其他事务遇到锁按序排队等待
    • 加锁机制:
      • A事务读 + B事务读,A、B不会加锁;
      • A事务读 + B事务写,A会加锁;
      • A事务写 + B事务读,A会加锁;
      • A事务写 + B事务写,A会加锁;

什么是脏写?

  • 概括:事务最后的更新覆盖了由其它事务所做的更新。

    • 举例:

      举例:A、B两个事务同时做更新; A: update table set age = age -5; B:update table set age = age -1;

      A、B两事务同时开始更新,读到的age = 10; A先提交事务,将age = 5; B然后提交事务,将age = 9;

      此时,就age的值,就出现了问题; B事务将A事务的更新给覆盖了; 正常来说,B事务提交的应该是 age = 5-1;

  • 解决脏写:使用行锁,依靠锁机制让多个事务更新一行数据的时候串行化,避免同时更新一行数据

  • 两阶段锁:在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

    • 使用两阶段锁,可以防止脏写的发生;
    • 如果行锁修改完就释放,可能第二个事务就来更新行数据,则第一个事务回滚时,可能会覆盖回滚掉其他事务的更新值;

什么是幻读?

  • 一个事务执行期间前后两次查询同一个范围,后一次查询看到了前一次查询没有看到的行。(指新增数据)
  • 可重复隔离级别下,会默认加间隙锁;一般不会出现幻读,
    • 可重复隔离级别下,出现场景是:使用当前读(for update) 才会出现
  • 幻读 和 不可重复读的区别是什么?
    • 幻读专指“新插入的行”,重点是新增;同样的条件,第2次查询看到了第1次没有看到的行数据;
    • 不可重复读的重点是修改;同一事务,两次读取的数据不一致

四种隔离级别,是如何通过视图构建实现的?

  1. 读未提交:没有视图的概念,直接返回记录的最新值;
  2. 读已提交:每次执行sql语句之前创建视图; 事务ID也是此时被分配的;
  3. 可重复读:每次创建事务的时候创建视图,整个事务存在期间都用这个视图。
  4. 我们要注意事务的启动时机:
    • begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作(增删改查) InnoDB 表的语句,事务才真正启动。也是在此时创建一致性视图。
    • 如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。
  5. 串行化:通过加锁来避免并行访问 ;

事务的隔离性:多版本并发控制(MVCC)

  • 事务的隔离性是依靠 多版本并发控制(MVCC)实现的;

  • MVCC机制的实现就是**通过 read-view一致性视图机制 与 undo版本链 **比对机制,使得不同的事务会根据数据版本链对比规则读取同一条数据在版本链上的不同版本数据。

read-view一致性视图

  1. 一致性视图作用是:事务执行期间用来定义“我能看到什么数据”

  2. 视图快照 在MVCC里是怎么工作的?

    1. 可重复读隔离级别下,事务在启动的时候就"拍了个快照"。 这个快照是基于整库的;

    2. InnoDB 里面每个事务有一个唯一的事务ID,叫做 transaction id;它是在事务开始的时候,向InnoDB的事务系统申请的,是按申请顺序严格递增的

    3. 每行的数据也都是有多个版本的

image-20220321213343801.png

  • 每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id赋值给这个数据版本的事务ID,记为 row trx_id。
  • 同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它

undoLog 是逻辑日志!主要用于 事务回滚 和 MVCC多版本并发控制

实际上,上图中的三个虚线箭头,就是 undo log;

而 V1、V2、V3 版本并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

  • 也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。
  1. InnoDB 是怎么定义“100G”的快照的。

    1. 按照可重复读的定义:

      • 一个事务启动的时候,能够看到所有已经提交的事务结果。

      • 但是之后,这个事务执行期间,其他事务的更新对它不可见。

    2. 因此,一个事务只需要在启动的时候声明说:

      • 以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,我就认它的数据;如果是我启动之后才生成的,我就不认,我必须要找到它的上一个版本;
      • 几个隔离级别的实践:
        • 对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
        • 对于读提交,查询只承认在每个语句启动前就已经提交完成的数据;
        • 对于当前读,总是读取已经提交完成的最新版本数据
  2. 快照是怎么实现的 --> InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

image-20220321215454520.png

  1. InnoDB 为每个事务构造了一个数组,用来保存这个事务**启动瞬间:当前正在”活跃“的所有事务ID。”活跃“指的就是 启动了还没提交。

  2. 数组里面事务ID的最小值记为 低水位,当前系统里面已经创建过的事务ID的 最大值+1 记为高水位。

  3. 这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

  4. 各个事务隔离级别通用的的判断规则:一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:

    1. 已提交事务:可见

    2. 未提交事务集合中:

      • 行的版本id 在集合中:不可见
      • 行的版本id 不在集合中:可见
    3. 将来启动的事务:不可见

undolog 逻辑日志

主要用于事务回滚 和MVCC 多版本并发控制

  • undo log分为 insert和update,根据插入语句的undo log提交后可以直接删除;而update类型的根据MVCC的需求不能删除,需要purge线程判断能否删除。

  • insert类型的undo log格式如下,记录了next下一跳日志、类型、undo no、表id和主键kv,如需回滚直接根据主键定位到数据行进行删除。

    img

  • update类型是针对update语句和delete语句,除了以上 还记录了修改数据的事务id、数据的回滚指针、update vector 修改的列的原始和修改值,可以根据这个写出相反的update语句和insert语句。

img

数据行的多版本

  • 数据行row trx_id + undolog逻辑日志,实现了数据行的多版本控制

*>可重复读的能力是怎么实现的?

  • 快照(视图数组和高水位),就组成了当前事务的一致性视图(read-view)。
  • 根据当前事务的 一致性视图 与 数据行多版本 对比结果,得到当前数据的可见性规则

更新语句时使用 当前读

  • 规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。 update语句采用当前读

  • 为什么更新要使用当前读?

    • 原因:当要去更新数据的时候,不能在历史版本上更新了,否则前面事务的更新就丢失了。所以读当前最新的值,进行更新操作
  • 场景: 如果a行,已经被 A事务更新了,但是A事务没有提交; 那么此时,B事务要来更新a行的同一个值,事务B会怎么处理?

    • 行锁使用两阶段锁:
    • 事务A没有提交,代表a的行锁没有释放;
    • 而事务B是当前读,必须要读最新版本,而且必须加锁,因为就被锁住了;
    • 必须等A事务释放这个锁,才能继续事务B的当前读。
    • 这个例子,就把一致性读、当前读、行锁串起来了。

数据库锁

为什么要有锁?

  • 数据库锁设计的初衷是解决并发问题

从性能上分为乐观锁和悲观锁

  • 乐观锁 在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新提交的时候判断一下在此期间别人是否修改了数据(用版本对比来实现):如果别人修改了数据则放弃操作,否则执行操作。
    • 乐观锁有一个优点,它在提交的时候才进行锁定的,因此不会造成死锁。
  • 悲观锁 在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
    • synchronized 属于悲观锁

从数据库操作的类型 分为读锁和写锁

  • 读锁和写锁都属于悲观锁

对数据的操作粒度来分

全局锁(库锁)

  • 全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL);当你需要让整个库处于只读状态的时候,可以使用这个命令;
  • **全局锁的典型使用场景是,做全库逻辑备份。**也就是把整库每个表都 select 出来存成文本。
    • 但是整个库都是只读,听起来就很恐怖;
    • 但在全局备份时,我们必须要拿到一个逻辑一致的视图;不然AB两张表数据有逻辑关系的话,会导致数据逻辑不一致
    • 用户账户余额表和用户课程表;先扣余额后加课程;如果先备份了余额表,然后用户购买,然后备份了课程表。会出现用户余额没扣减,课程增加的情况
  • 如何在全局备份时,不需要锁库呢?
    • 我们想到了 可重复读隔离级别下的一致性视图;事务启动时,会拿到数据库快照;
    • 而由于 MVCC 的支持,这个过程中数据是可以正常更新的。
    • **官方自带的逻辑备份工具是 mysqldump。**当 mysqldump 使用参数–single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。

表锁

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁一般是在数据库引擎不支持行锁的时候才会被用到的。 ( MyISAM 引擎不支持行锁,不支持事务)

  • 表锁(一般不使用)

    • 对于InnoDB这种支持行锁的引擎
    • 一般不使用 lock tables 命令来控制并发,锁住整个表的影响面还是太大
  • MDL锁 - 元数据锁(meta data lock,MDL)

    • MDL锁使用场景:

      • 当对一个表做增删改查操作的时候,加MDL读锁。(所有对表的增删改查操作都需要先申请 MDL 读锁)
      • 当要对表做结构变更操作的时候,加MDL写锁。
      • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查
      • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性
    • 所以给表加字段的时候,会跟查询语句冲突

    • MDL锁什么时候加?什么时候释放?

      • MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。

      • 事务中的 MDL 锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。

      • 我们要解决长事务,事务不提交,就会一直占着MDL锁

        在 MySQL 的 information_schema 库的 innodb_trx 表中,你可以查到当前执行中的事务。如果你要做 DDL 变更的表刚好有长事务在执行,要考虑先暂停 DDL,或者 kill 掉这个长事务。

        如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而你不得不加个字段,你该怎么做呢?

        这时候 kill 可能未必管用,因为新的请求马上就来了。比较理想的机制是,在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后开发人员或者 DBA 再通过重试命令重复这个过程。

        ALTER TABLE tbl_name NOWAIT add column ...

        ALTER TABLE tbl_name WAIT N add column ...

行锁

  • 两阶段协议锁

    • 在InnoDB的事务里,行锁是需要用的时候才加的,是要等到事务结束时才释放!这就是两阶段协议锁
    • 为什么要做两阶段协议锁?
      • 如果行锁用完就释放掉,那么有可能你释放后有B事务来更新了同一个值,而如果你此时要回滚,就会覆盖B事务的更新,这样显然不合适
    • 在开发时,如何安排正确的事务语句顺序?
      • 如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发读的锁的申请时机尽量往后放。
  • 死锁

    • 事务A 和 事务B 在互相等待对方的资源释放,就是进入了死锁状态。

    • 出现死锁后,有两种策略:

      1. 直接进入等待,直到超时自动回滚;这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。

        如果加锁等待超过这个时间,就会回滚,但回滚的话有两种方式: 第一种:回滚当前加锁的这条语句; 第二种:回滚整个事务。这两种方式是通过参数innodb_rollback_on_timeout来控制的。 如果是OFF,表示加锁超时回滚时,只回滚加锁超时的那条SQL语句; 如果是ON,表示回滚整个事务。默认是OFF。在《MySQL Admin Cookbook》一书中,作者强烈建议该参数设置为ON,但其实OFF也没关系,你可以在应用程序中捕获那个加锁超时,然 后应用程序去执行ROLLBACK,这样就可以保证原子性,只要你没执行COMMIT,就不会破坏原子性。

      2. 发起死锁检测 我们采用的

  • 死锁检测

    • 发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
    • 缺点是:如果有1000个并发线程要同时更新同一行,死锁检测要耗费大量的CPU资源。怎么解决由这种热点行更新导致的性能问题呢?
      • 如果确保不会出现死锁,可以临时关闭死锁检测
      • 另一个思路是控制并发度
        • 并发控制可以引入 中间件
        • 考虑将一行逻辑改为多行 来减少锁的冲突
  • 调优:如何通过减少锁冲突,来提升业务并发度?

    • 了解两阶段锁之后:如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放
    • 但是,调整语句顺序并不能完全避免死锁,所以我们引入了死锁检测
    • 减少死锁的主要方向,就是控制访问相同资源的并发事务量;
      • 引入中间件控制并发度
      • 考虑将一行逻辑改为多行来减少锁冲突