本文实战使用Mysql 8

锁的概念

锁是计算机协调多个进程或线程并发访问某一资源的机制,除了传统的计算机资源(如CPU、RAM、I/O等)之外,数据也是一种被并发访问的资源。

如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,所以就需要数据库的锁机制

锁的分类

我们事先在数据库准备了一张表sys_user用于测试:

  1. DROP TABLE IF EXISTS `sys_user`;
  2. CREATE TABLE `sys_user` (
  3. `id` int(0) NOT NULL AUTO_INCREMENT COMMENT '主键',
  4. `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '姓名',
  5. `age` int(0) NULL DEFAULT NULL COMMENT '年龄',
  6. `sex` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '性别',
  7. `score` int(0) NULL DEFAULT NULL,
  8. PRIMARY KEY (`id`) USING BTREE
  9. ) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
  10. # 插入数据
  11. INSERT INTO `sys_user`(`name`,`age`,`sex`,`score`) VALUES ('xiaoming', 100, '女', 40);
  12. INSERT INTO `sys_user`(`name`,`age`,`sex`,`score`) VALUES ('xiaoxiong', 20, '男', 50);

从性能分类:乐观锁与悲观锁

乐观锁认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行版本检测,如果发现冲突了,则返回错误的信息,让用户决定如何去做。【你的版本号不是最新的就不给你改

Lock&Transation-锁与事务 - 图1

一般通过版本号机制和CAS算法实现乐观锁。

我们在表中新增一个字段version,如果更新操作顺序执行,则数据的版本(version)依次递增,不会产生冲突。
但是如果发生有不同的业务操作对同一版本的数据进行修改,那么,先提交的操作(图中B)会把数据version更新为2,当A在B之后提交更新时发现数据的version已经被修改了,那么A的更新操作会失败。

  1. # 获取数据的时候同时获取当前数据的版本号
  2. select (col01,col02,version) from table_name;
  3. # 更新数据的时候携带版本号进行校验,同时更新版本号
  4. # 这样同时发送的更新操作就会失败,因为版本号已经变了
  5. update table_name set col01=2,version=version+1 where id=#{id} and version=#{version};

悲观锁总是假设最坏的情况,每次取数据都会认为其他线程会修改,所以都会加锁一旦加锁,不同线程同时执行的时,只能有一个线程执行,其他线程在入口等待,直至锁被释放。【不会直接不让其他人吃,而是让其他人先等等,除非其他人等不下去了(超时)】

悲观锁在Mysql和JAVA中被广泛应用
Mysql的读锁、写锁、行锁
Java的synchronized关键字

从数据库操作分类:读锁与写锁【都是悲观锁】

共享锁(S锁,读锁,): 在加锁后允许其他事务进行查询操作,但不允许自身和其他事务进行修改操作,直至锁解除,即共享锁

image.png
案例:为表sys_user添加读锁

  1. # 加锁
  2. lock table sys_user read;
  3. # 解锁
  4. unlock tables;

image.png
image.png
image.png

为什么连自己也不给修改? 原因:读锁一般用在数据迁移阶段,除了不让外部事务修改,也应该避免自己人为误操作,所以就直接锁定了写操作

排他锁(X锁,写锁):在加锁后,不允许其他事务进行任何操作,即排他锁
image.png

案例:给sys_user添加写锁

  1. # 加锁
  2. lock table sys_user write;
  3. # 解锁
  4. unlock tables;

image.png
image.png
image.png

简而言之,就是读锁会阻塞写,但是不会阻塞读。而写锁则会把读和写都阻塞

从对数据操作的粒度分:表锁和行锁

表锁(Table Lock,偏读)

锁的目标是表

表锁偏向MyISAM存储引擎【不支持事务】,开销小,加锁快,无死锁,锁定粒度大,发生锁冲突的概率最高,并发度最低。

MyISAM存储引擎在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁,在执行增删改操作前,会自动给涉及的表加写锁。

如上述案例中的读写锁,都是对表进行加锁
为表加读锁时,允许共享读操作,不允许任何会话的写操作,一般用于数据迁移的时候。
为表加写锁时,允许本会话的读写,不允许其他会话对表的读写操作。

行锁(Row Lock,偏写)

锁的目标是数据行

行锁偏向InnoDB存储引擎,开销大,加锁慢,会出现死锁,锁定粒度最小,发生锁冲突的概率最低,并发度也最高

InnoDB与MYISAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁

为某一行加上共享锁:

  1. mysql> select * from sys_user where id = 1 lock in share mode;

为某一行加上排他锁:

  1. mysql> select * from sys_user where id = 1 for update ;

学习行锁需要结合事务进行,所以我们先了解一下事务的概念

事务

事务的概念

数据库事务( transaction)是访问并可能操作各种数据项的一个数据库操作序列,这些操作要么全部执行,要么全部不执行,是一个不可分割的工作单位。事务由事务开始与事务结束之间执行的全部数据库操作组成

事务是由一组SQL语句组成的逻辑处理单元。

MySQL 默认采用 AutoCommit 模式,也就是每个 sql 都是一个事务,并不需要显式的执行事务。
如果 autoCommit 关闭,那么每个 sql 都默认开启一个事务,只有显式的执行“commit”后这个事务才会被提交。

事务的ACID属性

事务的 ACID 原则保证了一个事务或者成功提交,或者失败回滚,二者必居其一。因此,它对事务的修改具有可恢复性。即当事务失败时,它对数据的修改都会恢复到该事务执行前的状态

原子性(Atomicity)

事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。【关注的是操作】

以银行转账事务为例,A账户向B账户转账属于一个事务,如果该事务提交了,则这两个账户的数据将会更新。如果由于某种原因,事务在成功更新这两个账户之前终止了,则不会更新这两个账户的余额,并且会撤销对任何账户余额的修改,事务不能部分提交

一致性(Consistent)

在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持数据的完整性;事务结束时,所有的内部数据结构(如B树索引或双向链表)也都必须是正确的。【关注的是数据最终状态】

也就是说,在事务开始之前,数据库中存储的数据处于一致状态。在正在进行的事务中. 数据可能处于不一致的状态,如数据可能有部分被修改。然而,当事务成功完成时,数据必须再次回到已知的一致状态。通过事务对数据所做的修改不能损坏数据,或者说事务不能使数据存储处于不稳定的状态。

以银行转账事务为例。在事务开始之前,A和B账户余额的总额处于一致状态【原始状态】。在事务进行的过程中,A账户余额减少了,而B账户余额尚未修改。因此,所有账户余额的总额处于不一致状态。事务完成以后,账户余额的总额再次恢复到一致状态【最终状态,A账户余额被修改,B账户余额也被修改】

隔离性(Isolation)

数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然。【关注的是中间状态】

也就是说修改数据的事务可以在另一个使用相同数据的事务开始之前访问这些数据,或者在另一个使用相同数据的事务结束之后访问这些数据

以银行转账事务为例。在A向B转账的事务开始之后,此时C要向A进行转账,需要等待A向B转账事务完成提交后,才能执行对A的余额新增操作。

持久性(Durable)

事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持。

并发事务处理带来的问题

并发事务指的是两个或多个事务同时对同一个资源产生操作。

更新丢失(Lost Update)

  
两个事务T1和T2读入同一个数据并修改,T2提交的结果覆盖了T1提交的结果,导致T1的修改被丢失。

脏读(Dirty Reads)

  
一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致的状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此作进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象的叫做“脏读”。
 
A读取到了B修改但是还没提交的数据,A拿去做别的数据修改了,这时候B事务回滚了,A就出现了脏读这种问题。

简而言之:事务A读取到了事务B已经修改但尚未提交的数据,还在这个数据基础上做了操作。此时,如果B事务回滚,A读取的数据无效,不符合一致性要求。

不可重复读(Non-Repeatable Reads)

  
一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。

A事务中多次读取某一条数据,发现结果都不相同,因为B修改并提交了的事务影响到了A事务。

   简而言之:事务A读取到了事务B已经提交的修改数据,不符合隔离性

幻读(Phantom Reads)

  一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。

A事务刚进去的时候查的明明只需要处理5条数据,后来B事务又往这个表插入了一条数据并提交了,A事务这时候发现原来需要处理6条,仿佛活在梦里。

  简而言之:事务A读取到了事务B已经提交的新增数据,不符合隔离性

事务隔离级别

“脏读”、“不可重复读”和“幻读”,其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决。

一张老掉牙的图片,可以看到,从上到下,隔离级别是越来越严格的:

Lock&Transation-锁与事务 - 图10

读未提交(read uncommit)

最低级别的隔离,事务A可以读取到事务B未提交的修改

案例:
(1)设置数据库隔离级别为read-uncommitted

  1. mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

image.png
(2)连接两个客户端,开启两个事务A和B

  1. mysql> begin;

查询数据一致
image.png
(3)事务B修改数据但不提交事务:

  1. mysql> update sys_user set score=40 where id = 28;

image.png
此时事务A读取到了事务B修改但是没有提交的数据

(4)此时事务B因为某种原因进行回滚

  1. mysql> rollback;

image.png

(5)事务A进行数据更新

  1. mysql> update sys_user set score=score-20 where id = 28;

image.png

此处A事务中,使用的却是50-20=20,而不是40-20,这是因为mysql数据库在更新的时候拿的不是旧数据,而是实时数据(mvcc机制)。嗯?那又哪来的脏读呢?

那么脏读问题主要体现在我们的实际应用中,比如在java程序中我们会直接根据拿到的数据score=40进行计算,而当此时事务B进行回滚,我们的应用程序是不知道的,这样就会导致我们的数据不一致,要解决这个问题需要设置隔离级别=读已提交

读已提交(read commit)

事务A不可以读取到事务B未提交的修改,但是可以读取到B事务已提交的修改

效果:解决脏读问题

案例:
(1)设置数据库隔离级别为read-committed

  1. mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

image.png
(2)开启两个事务A和B

  1. mysql> begin;

image.png
(3)事务B修改数据但不提交事务:

  1. mysql> update sys_user set score=40 where id = 28;

image.png
此时事务A没有读取到事务B修改但是没有提交的数据,解决了脏读问题

(4)此时事务B提交

  1. mysql> commit;

image.png
此时事务A执行同一个查询读取到了事务B提交的修改,这就导致事务A内两次相同查询得到了不一致的结果,也就是”不可重复读”问题,违反了隔离性

可重复读(repeatable read)【默认】

事务进行中不会读取到其他事务已提交的修改只会使用第一次查询时所获得的快照结果。这里使用到了MVCC(多版本并发控制)机制来达到这个效果

效果:解决不可重复读问题

案例:
(1)设置数据库隔离级别为repeatable-read

  1. mysql> SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

image.png
(2)开启两个事务A和B

  1. mysql> begin;

image.png
(3)事务B修改数据但不提交事务:

  1. mysql> update sys_user set score=50 where id = 28;

image.png
此时事务A没有读取到事务B修改但是没有提交的数据,解决了脏读问题

(4)此时事务B提交

  1. mysql> commit;

image.png
可以看到,即便事务B已经提交修改,事务A查询得到的数据一直没变。这就解决了不可重复读的问题

(5)事务B新增一条数据并提交事务,验证幻读

  1. mysql> INSERT INTO `sys_user`(`name`,`age`,`sex`,`score`) VALUES ('xiaohua', 25, '男', 40);
  2. mysql> commit;

image.png
这是不是意味着没有幻读的问题呢?别急,往下看

(6)在事务A执行update语句,验证幻读

  1. mysql> update sys_user set score=50 where id >29;

image.png

可以看到,如果事务A查询是查不到事务B新增的数据的,但是,当它进行update操作的时候,却又可以更新成功1条数据,证明事务A在update的时候出现了幻读,读取到了事务B新增的那条数据

为什么会出现这种情况呢?

这是因为mysql在可重复读的隔离级别下使用了MVCC机制,select操作不会更新版本号,是快照读(历史版本);insert、update和delete会更新版本号,是当前读(当前版本)
image.png
也就是说,可重复读的隔离级别下,MVCC机制会保存快照版本,本事务的select操作读取的一直是这个快照版本,而insert、update和delete操作则会读取最新版本且更新版本号

那该如何解决幻读?

串行化(Serializable)

将事务转化为串行的形式来执行,该级别会进行锁表,因此不会出现幻读的情况,这种隔离界别的并发性极低,开发中很少会用到。【连并发都没有了,怎么可能出现并发问题?】

案例:
(1)设置数据库隔离级别为Serializable

  1. mysql> SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;

image.png
(2)开启两个事务A和B

  1. mysql> begin;

image.png
(3)事务B插入数据

  1. mysql> INSERT INTO `sys_user`(`name`,`age`,`sex`,`score`) VALUES ('xiaoli', 25, '女', 40);

image.png
我们看到,事务A尚未提交的时候,事务B是无法执行更新操作的,也就是说同一时间只允许一个事务操作表,这就没有所谓的并发问题。

(4)提交事务A后再执行事务B的插入操作
image.png
插入成功

间隙锁解决幻读问题

数据库的默认隔离级别是:repeatable-read

但是可重复读这种隔离级别无法解决幻读问题,是否可以使用别的方式去解决呢?

间隙锁:Innodb在可重复读隔离级别下为了解决幻读问题时引入的锁机制

在事务开头执行下述语句,可以使得其他事务无法在当前事务未提交前在id为33到100之间插入数据,这就是间隙锁

当然如果你不想真的修改数据的话,请保证间隙锁范围内没有数据。

  1. mysql> update sys_user set name = 'jxs' where id>=33 and id < 100;

image.png
image.png
在事务B执行插入数据操作的时候,由于新数据自增id=33,在间隙锁范围,被阻塞导致超时,插入失败。

不过由于大部分场景下不需要处理幻读的情况,所以平时要尽量避免间隙锁,因为这样的确会降低并发效率

数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。

锁和事务到底有啥关系?

通过上述学习,我们可以看出:锁是实现事务其中一个特性的机制,这个特性就是隔离性

Innodb所有的普通select都是快照读,快照读不加锁

事务中所有的delete/update/insert 默认加了X锁(排他锁)

我们可以测试一下

读操作加锁测试

假设一个事务中select对目标数据加了锁,那么,在另一个事务中获取锁是肯定会报错的
image.png

事务A中执行普通查询

  1. mysql> select * from sys_user where id = 1 for update;

事务B中执行普通查询并获取排他锁

  1. mysql> select * from sys_user where id = 1 for update;

image.png
没有报错,证明事务A的select没有加上排他锁

且此时事务A依旧能够正常访问数据,即便是事务B已经拿到了排他锁,变相证明了select访问的是快照数据,而不是数据表数据。

事务B中执行普通查询并获取共享锁

  1. mysql> select * from sys_user where id = 1 lock in share mode;

image.png
没有报错,证明事务A的select没有加上共享锁

结论:InnoDB事务中select没有上锁,且读的是快照数据

写操作加锁测试

我们假设事务中执行delete/update/insert操作是会上锁的【不管是什么锁】,那么当我们对同一条数据执行更新的时候,按理说后面执行的会阻塞,因为事务没有提交就不会释放锁

事务A执行update:

  1. mysql> update sys_user set score = 50 where id = 32;

事务B执行update:

  1. mysql> update sys_user set score = 60 where id = 32;

image.png
事务B的更新操作被阻塞,因为事务A对id=32这行数据的锁尚未释放。

至于是排他锁还是共享锁,我暂时还没想到办法去证明,有想法的读者可以评论指教

结论:update/delete/insert操作都对行数据进行了加锁

优化建议

  • 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁
  • 合理设计索引,尽量缩小锁的范围
  • 尽可能减少检索条件,避免间隙锁
  • 尽量控制事务大小,减少锁定资源量和时间长度
  • 尽可能使用低级别事务隔离

MVCC机制

上面我们说了,InnoDB在事务中select操作读取的是快照版本数据,而insert/delete/update操作读取的是当前数据,这种机制我们成为MVCC机制。

  1. MVCC全称是:** Multiversion concurrency control**,**多版本并发控制**,提供并发访问数据库时,对事务内读取的到的内存做处理,用来**避免写操作堵塞读操作的并发问题**。提高并发读写的性能、不用锁就能让多个事务并发读写。

MVCC针对的隔离级别是 读已提交和可重复读

mysql官方文档: Internally, InnoDB adds three fields to each row stored in the database. A 6-byte DB_TRX_ID field indicates the transaction identifier for the last transaction that inserted or updated the row. Also, a deletion is treated internally as an update where a special bit in the row is set to mark it as deleted. Each row also contains a 7-byte DB_ROLL_PTR field called the roll pointer. The roll pointer points to an undo log record written to the rollback segment. If the row was updated, the undo log record contains the information necessary to rebuild the content of the row before it was updated. A 6-byte DB_ROW_ID field contains a row ID that increases monotonically as new rows are inserted. If InnoDB generates a clustered index automatically, the index contains row ID values. Otherwise, the DB_ROW_ID column does not appear in any index.

InnoDB对MVCC机制的实现是通过在每个表的每一行添加三个隐藏的字段:

id name DB_TRX_ID DB_ROLL_PTR DB_ROW_ID
1 xiaoming

一个6字节的DB_TRX_ID字段: 表示插入或更新该行的最后一个事务的事务ID。此外,删除在内部被视为更新,在该更新中,行中的特殊位将其标记为已删除。

一个7字节的 DB_ROLL_PTR字段:称为回滚指针。回滚指针指向写入回滚段的撤消日志记录。如果行已更新,则撤消日志记录将包含在更新行之前重建行内容所必需的信息。实际就是指向该记录的一个版本链.

一个6字节的DB_ROW_ID字段:包含一个行ID,该行ID随着插入新行而增加。如果有指定主键,那么该值就是主键。如果没有主键,那么就会使用定义的第一个唯一索引。如果没有唯一索引,那么就会默认生成一个值

也就是说,事务id相当于每一条数据产生不同的版本,不同的事务读取的是对应事务版本的数据,这就保证了一致性读取。【有点乐观锁的意思,但是MVCC是无锁操作】

死锁(DeadLock)

死锁:是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去.此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。表级锁不会产生死锁.所以解决死锁主要还是针对于最常用的InnoDB

死锁的关键在于:两个(或以上)的Session加锁的顺序不一致

产生死锁的四个必要条件

(1) 互斥条件:一个资源每次只能被一个进程使用。

(2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

(3) 不可剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。

(4) 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

死锁的影响

当产生某表死锁的一开始,所有涉及这张表的操作都将受到阻塞。

假设这张表在业务逻辑上是读写频繁的,那就会使很多操作在那里排队等待,而排队等待会占用数据库连接,当该达到该数据库连接数的最大承载数之后,就会使所有数据库操作均无法再继续下去,致使数据库各项指标异常,导致整个环境崩溃。

死锁实例

(1)开启两个事务A和事务B

  1. mysql> begin;

image.png

(2)事务A获取id=28的记录的行级锁:

  1. --行级锁 id=28 的记录
  2. mysql> select * from sys_user where id=28 for update ;

image.png
(3)事务B获取id=29的记录的行级锁

  1. --行级锁 id=29 的记录
  2. mysql> select * from sys_user where id=29 for update ;

image.png
(4)事务A更新id=29的数据

  1. --更新 id=29 的记录
  2. mysql> update sys_user set id=id where id=29;

image.png
事务A执行更新阻塞,因为事务B持有对id= 29这行记录的行锁,不允许被修改

(5)事务B更新id=28的数据

  1. --更新 id=28 的记录
  2. mysql> update sys_user set id=id where id=28;

image.png

因为事务A持有对id = 28这行记录的行锁,所以事务B也是没办法修改这行记录的。
image.png
正常来说,事务A和事务B都会处于一种相互等待对方释放锁的阻塞状态,但是mysql在发现死锁的时候,如果是简单的死锁,会自动回滚最后的一个事务,强行解开死锁。如上图所示,事务B被回滚,事务A执行更新成功

说到死锁,还得看明家三兄弟啊!!!
image.png
image.png
image.png
image.png

处理死锁

查看数据库进程

  1. mysql> SHOW FULL PROCESSLIST;

主要看State字段,如果出现大量 waiting for ..lock 即可判定死锁:
image.png

通过上述输出可以定位死锁进程,直接kill掉即可

  1. kill 进程ID