MySQL :事务与隔离级别,以及悲观锁和乐观锁

260 阅读18分钟

文章内容输出来源:拉勾教育大数据高薪训练营

通过我这次在 Java 预科班的“回炉重造”(不得不说这两个月时间的预科内容是真的十足!),勾起了我对MySQL事务、隔离级别和锁的一些兴趣及思考,紧接着就安排自己记录一下自己曾经模糊的一些概念和知识。

结合课程和一些书籍资料,本文将着重讲 MySQL 的事务隔离级别以及锁。

引言

我们知道在 MySQL 5.5 之前,默认的存储引擎是 MyISAM,在之后的版本默认存储引擎都改为了 InnoDB。其中两者最大的一个区别就是 InnoDB 支持事务,也可以说这是其取代 MyISAM 的一个重要原因。那么什么是事务呢?事务的英文为 transaction,从英文我们也能看出来它是进行一次处理的基本单元,要么完全执行,要么都不执行。

举一个 🌰,就好像我们去线下的一个摊位买东西的时候,已经交过钱了,但是这个时候老板比较忙没有看到,不知道我们是否交过钱,然后再让我们重新付款,这时还要找之前的付款记录证明我们已经完成了付款。

实际上如果我们线下的交易也能够支持事务(满足事务的特性),就不会出现交了钱却拿不到商品的情况了,同样,对于摊位的老板来说也不会存在给出了商品却没有收到汇款的风险。比如,我们在支付的过程中得到了老板全程注意,付款结束后并向我们点点头微微笑说再来,执行完这整个过程就像完成了一个事务。

总之事务保证了一次处理的完整性,也保证了数据库中的数据一致性。它是一种高级的数据处理方式,如果我们在增加、删除、修改的时候某一个环节出了错,它允许我们回滚到操作开始之前(还原)。也正是因为这个特点,事务非常适合应用在安全性高的场景里,比如金融行业、电商行业等。

ACID

首先再次简单概括一下事务的特性:ACID

开头提到了事务的特性:要么完全执行,要么都不执行。不过要对事务进行更深一步的理解,还要从事务的 4 个特性说起,这 4 个特性用英文字母来表达就是 ACID。

  1. 原子性A-tomicity)。原子的概念就是不可分割,可以把它理解为组成物质的基本单位,也是我们进行数据处理操作的基本单位。

每个事务都是一个整体,不可再拆分,事务中所有的 SQL 语句要么都执行成功,要么都失败。

  1. 一致性C-onsistency)。一致性指的就是数据库在进行事务操作后,会由原来的一致状态,变成另一种一致的状态。也就是说当事务提交后,或者当事务发生回滚后,数据库的完整性约束不能被破坏。

事务在执行前数据库的状态与执行后数据库的状态保持一致。如:转账前2个人的 总金额 是 2000,转账后 2 个人总金额也是 2000。

  1. 隔离性I-solation)。它指的是每个事务都是彼此独立的,不会受到其他事务的执行影响。也就是说一个事务在提交之前,对其他事务都是不可见的。

事务与事务之间不应该相互影响,执行时保持隔离的状态。

  1. 持久性D-urability)。事务提交之后对数据的修改是持久性的,即使在系统出故障的情况下,比如系统崩溃或者存储介质发生故障,数据的修改依然是有效的。因为当事务完成,数据库的日志就会被更新,这时可以通过日志,让系统恢复到最后一次成功的更新状态。

一旦事务执行成功,对数据库的修改是持久的。就算关机,数据也是要保存下来的。

在这四个特性中,原子性是基础,隔离性是手段,一致性是约束条件,而持久性是我们的目的

数据库的隔离级别

并发访问

在了解数据库隔离级别之前,我们需要了解设定事务的隔离级别都要解决哪些可能存在的问题,也就是事务并发处理时会存在哪些异常情况。实际上,SQL-92 标准中已经对 3 种异常情况进行了定义,这些异常情况级别分别为脏读(Dirty Read)、不可重复读(Nnrepeatable Read)和幻读(Phantom Read)。

  • 脏读(Dirty Read):一个事务读取到了另一个事务中尚未提交的数据。
  • 不可重复读(Nnrepeatable Read):一个事务中两次读取的数据内容不一致,要求的是在一个事务中多次读取时数据是一致的。这是进行update操作时引发的问题。
  • 幻读(Phantom Read):一个事务中,一次的select操作得到的结果所表征的数据状态,无法支撑后续的业务操作。查询得到的数据状态不准确导致幻读。

隔离级别

脏读、不可重复读和幻读这三种异常情况,是在 SQL-92 标准中定义的,同时 SQL-92 标准还定义了 4 种隔离级别来解决这些异常情况。

了解了数据并发访问的问题问题之后,再来说一说事务隔离的级别都有哪些。

通过设置隔离级别,可以防止上面的三种并发问题 MySQL数据库有四种隔离级别,上面的级别最低,下面的级别最高。

级别隔离级别脏读不可重复读幻读数据库的默认隔离级别
1读未提交
read uncommitted
2读已提交
read committed
Oracle和SQLServer
3可重复读 repeatalbe readMySQL
4串行化 serializable
  • 读未提交(Read uncommitted),就是一个事务能够看到其他事务尚未提交的修改,这是最低的隔离水平,允许脏读出现。
  • 读已提交(Read committed),事务能够看到的数据都是其他事务已经提交的修改,也就是保证不会看到任何中间性状态,当然脏读也不会出现。读已提交仍然是比较低级别的隔离,并不保证再次读取时能够获取同样的数据,也就是允许其他事务并发修改数据,允许不可重复读和幻象读(Phantom Read)出现。
  • 读已提交(Read committed),事务能够看到的数据都是其他事务已经提交的修改,也就是保证不会看到任何中间性状态,当然脏读也不会出现。读已提交仍然是比较低级别的隔离,并不保证再次读取时能够获取同样的数据,也就是允许其他事务并发修改数据,允许不可重复读和幻象读(Phantom Read)出现。
  • 串行化(Serializable),并发事务之间是串行化的,通常意味着读取需要获取共享读锁,更新需要获取排他写锁,如果 SQL 使用 WHERE 语句,还会获取区间锁(MySQL 以 GAP 锁形式实现,可重复读级别中默认也会使用),这是最高的隔离级别。

使用 MySQL 客户端来模拟三种异常

SQL 准备如下,可以执行下面的 SQL 语句来完成heros_temp数据表的创建。

-- ----------------------------
-- Table structure for heros_temp
-- ----------------------------
DROP TABLE IF EXISTS `heros_temp`;
CREATE TABLE `heros_temp`  (
  `id` int(11) NOT NULL,
  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of heros_temp
-- ----------------------------
INSERT INTO `heros_temp` VALUES (1, '张飞');
INSERT INTO `heros_temp` VALUES (2, '关羽');
INSERT INTO `heros_temp` VALUES (3, '刘备');

模拟的时候我们需要准备两个 MySQL 客户端,分别是客户端 1客户端 2

客户端1中,我们先来执行命令来查看当前绘画的隔离级别:

mysql> SHOW VARIABLES LIKE 'transaction_isolation';

从图中能看到当前的隔离级别是REPEATABLE-READ,也就是可重复读。

现在我们把隔离级别降到最低,设置为 READ UNCOMMITTED(读未提交)。

mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

同样再查看一下当前会话(SESSION)下的隔离级别,结果如下:

因为 MySQL 默认是事务自动提交,这里我们还需要将 autocommit 参数设置为 0,命令如下:

mysql> SET autocommit = 0;

然后我们再来查看 SESSION 中的 autocommit 取值,结果如下:

接着我们以同样的操作启动客户端 2,也就是将隔离级别设置为 READ UNCOMMITTED(读未提交),autocommit 设置为 0。

模拟“脏读”

我们在客户端 2中开启一个事务,在heros_temp表中写入一个新的英雄“吕布”,注意这个时候不要提交。

然后我们在客户端 1中,查看当前的英雄表:

可以发现客户端 1中读取了客户端 2未提交的新英雄“吕布”,实际上客户端 2可能马上回滚,从而造成了**“脏读”**。即一个事务读取到了另一个事务中尚未提交的数据。

模拟“不可重复读”

我们用客户端 1来查看 id=1 的英雄:

然后用客户端 2id=1的英雄姓名进行修改:

这时再用客户端 1进行查询:

我们能发现,对于客户端 1来说同一条查询语句出现了**“不可重复读”**。即一个事务中两次读取的数据内容不一致,要求的是在一个事务中多次读取时数据是一致的。

模拟“幻读”

我们先用客户端 1查询数据表中的所有英雄:

然后用客户端 2,开始插入新的英雄“吕布”:

这时,我们再用客户端 1重新进行查看:

我们会发现数据表多出一条数据,也就是出现了**“幻读”**。即一个事务中,某一次的 select 操作得到的结果所表征的数据状态,无法支撑后续的业务操作。

总结

隔离级别越低,意味着系统吞吐量(并发程度)越大,但同时也意味着出现异常问题的可能性会更大。在实际使用过程中我们往往需要在性能和正确性上进行权衡和取舍,没有完美的解决方案,只有适合与否。

悲观锁和乐观锁是什么

至于悲观锁和乐观锁,也并不是 MySQL 或者数据库中独有的概念,而是并发编程的基本概念。主要区别在于,操作共享数据时,“悲观锁”即认为数据出现冲突的可能性更大,而“乐观锁”则是认为大部分情况不会出现冲突,进而决定是否采取排他性措施。

分别举一个 🌰:

  • 悲观锁:有一个人总是小心翼翼的,吃饭喝水要验毒,家里要装摄像头,走路都要左顾右盼,生怕别人害他,在数据库中也就是生怕别人操作它的数据,干什么之前都要先检查一遍(加锁操作),等我使用完了再开放。(这样的人生其实太累了,容易死锁)
  • 乐观锁:有个人干什么都很放心自己,也放心别人,大大咧咧,吃饭也不管有没有毒就吃,类似于这样的操作就叫做是乐观锁,总是认为别人不会操作他的数据,就去操作数据,在提交的时候,根据固定的信息去检查一下,一般就是version。这样的话,如果发现信息不对(别人谋害他),就不更新了(去医院治愈好了)。

索引和锁是数据库中的两个核心知识点,不论在工作中,还是在面试中,我们都会经常跟它们打交道。之前我们已经从不同维度对索引进行了了解,比如 B+ 树、Hash 索引、页结构、缓冲池和索引原则等,了解它们的工作原理可以加深我们对索引的理解。

同时在上面的部分中,我们回顾了事务的 4 大原则以及不同的隔离级别。这些隔离级别的实现都是通过锁来完成的,那么为什么我们需要给数据加锁呢?

实际上加锁是为了保护并发应用程序中的数据完整性和原子性,在这些应用程序中记录可以获取读/写请求。

举个 🍐:
在同一时刻,有多个用户想要购买一件商品,而库存中只剩下了1件,该公司在各个电商平台都上架了该商品,并立即有2000为用户想要购买这个商品。当然,由于库存中仅剩1件,因此这个商品显然只能出售给一位“幸运”用户。
为了处理这种情况,我们使用了锁,否则可能会导致这个商品出售给了2000个用户,从而使商家损失了1999的 ItemPrice。

悲观锁 Pessimistic Concurrency Control

悲观并发控制:在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作读某行数据应用了锁,那只有当这个事务把锁释放,其他事务才能够执行与该锁冲突的操作。(——摘自 Wikipedia 🔗 悲观并发控制

悲观锁也就是我们假设所有的用户都会同时访问同一条记录,它实际上是为首次启动的事务锁定该记录,直到成功完成或失败为止。然后释放锁,并以相同方式处理记录中的下一个事务。

悲观锁在数据上提供了更好的完整性,但是锁的管理比较困难,如果无法管理所,则应用程序可能会遇到死锁(当两个以上的运算单元,双方都在等待对方停止运行,以获取系统资源,但是没有一方提前退出时,就称为死锁。——摘自 Wikipedia)。

  1. 在上面的案例中,如果我们应用了悲观锁,则第一个购买该商品的最后一个库存的用户将单击“购买”。
  2. 这将锁定该对象,直到付款完成或者失败。
  3. 假设这位用户能够完成付款,并且该商品的库存的值现在设置为0。
  4. 在此过程中,所有其他用户都必须等待。
  5. 现在,所有其他的用户将看到该商品为缺货状态,并且无法再对该商品执行任何操作。

悲观锁的具体手段包括:

  1. 共享锁 — 多个事务可以同时查询数据,但在其它事务查询的时候不能进行更新操作。
  2. 排他锁 — 排他锁用于更新操作,保证在同一时刻只有一个事务可以对数据进行更新。
  3. 意向锁 — 由于获取共享锁和排他锁需要确保数据表中没有行锁,所以需要扫描每一行来判断是否有锁,效率不高。意向所其实就是在表的层面加了一标识,如果表中有行锁那在表的层面也会有一个对应的意向锁。

乐观锁 Optimistic Concurrency Control

乐观并发控制:在关系数据库管理系统里,乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。(——摘自 Wikipedia 🔗 乐观并发控制

乐观锁在对数据进行读/写以确保所处理的数据之前,可以通过检查数据库中的一些特殊值(如版本号、时间戳、日期等)来管理数据。如果数据是旧的,则说明事务未成功完成,并且引发错误以指示当前情况(如“您尝试的操作记录在获得原始值后被其他用户修改了”等)。

  1. 在上面的案例中,如果我们使用乐观锁,则第一个购买者可以点击“购买”。
  2. 假设这个用户能够完成付款操作流程,并且在付款步骤之前检查了库存值,然后将其从1更改为0。
  3. 如果版本号匹配,则执行操作,并改为该商品缺货。
  4. 现在,所有其他再尝试购买该商品的用户的购物车中将被提醒该商品不再可用。

乐观锁的版本号机制

在表中设计一个版本字段 version,第一次读的时候,会获取 version 字段的取值。然后对数据进行更新或删除操作时,会执行UPDATE ... SET version=version+1 WHERE version=version。此时如果已经有事务对这条数据进行了更改,修改就不会成功。

这种方式类似我们熟悉的 SVN、CVS 版本管理系统,当我们修改了代码进行提交时,首先会检查当前版本号与服务器上的版本号是否一致,如果一致就可以直接提交,如果不一致就需要更新服务器上的最新代码,然后再进行提交。

乐观锁的时间戳机制

时间戳和版本号机制一样,也是在更新提交的时候,将当前数据的时间戳和更新之前取得的时间戳进行比较,如果两者一致则更新成功,否则就是版本冲突。

能看到乐观锁就是程序员自己控制数据并发操作的权限,基本是通过给数据行增加一个戳(版本号或者时间戳),从而证明当前拿到的数据是否最新。

总结

从这两种锁的设计思想中,我们能看出乐观锁和悲观锁的适用场景:

  1. 悲观锁:适合写操作多的场景,因为写的操作具有排它性。采用悲观锁的方式,可以在数据库层面阻止其他事务对该数据的操作权限,防止读 - 写和写 - 写的冲突。

  2. 乐观锁:适合读操作多的场景,相对来说写的操作比较少。它的优点在于程序实现,不存在死锁问题,不过适用场景也会相对乐观,因为它阻止不了除了程序以外的数据库操作。

乐观锁和悲观锁并不是锁,而是锁的设计思想。

既然有锁的存在,就有可能发生死锁的情况。死锁就是多个事务(如果是在程序层面就是多个进程)在执行过程中,因为竞争某个相同的资源而造成阻塞的现象。发生死锁,往往是因为在事务中,锁的获取是逐步进行的。

P.S. 下面还有一些可以避免死锁发生的方法:

  1. 如果事务涉及多个表,操作比较复杂,那么可以尽量一次锁定所有的资源,而不是逐步来获取,这样可以减少死锁发生的概率;
  2. 如果事务需要更新数据表中的大部分数据,数据表又比较大,这时可以采用锁升级的方式,比如将行级锁升级为表级锁,从而减少死锁产生的概率;
  3. 不同事务并发读写多张数据表,可以约定访问表的顺序,采用相同的顺序降低死锁发生的概率。

当然在数据库中,也有一些情况是不会发生死锁的,比如采用乐观锁的方式。另外在 MySQL MyISAM 存储引擎中也不会出现死锁,这是因为 MyISAM 总是一次性获得全部的锁,这样的话要么全部满足可以执行,要么就需要全部等待。