1. 什么是数据库事务

事务:指的是一组逻辑操作单元,使数据从一种状态转换到另一种状态
比如:最常见的转账操作,两条update语句加起来是一组事务

  1. update account set money=money-100 where id=1;
  2. update account set money=money+100 where id=2;

事务的处理原则:当一个事务执行多个操作时,要么所有的事务都被提交,事务执行成功,这些修改被永久的保存下来(刷到计算机磁盘中);要么所有的事务都执行失败,数据库管理系统放弃所有的修改,整个事务回滚到最初的状态
比如上面的例子,1号账户要给2号账户转100元,假设1号账户原来有余额200元,2号账户原来有余额0元

id money
1 300
2 0

假设转账的整个过程十分顺利,两条sql语句都被执行,那么之后再执行查询操作时,1号账户的余额就变为200元,2号账户的余额就变为100元

id money
1 200
2 100

但现在假设在转账的时候,1号操作成功之后,钱已经转出去了,但银行的网络出现了问题,钱并没有打到2号账户的余额中,这时,理想的结果就是1号转过去的钱需要重新回到1号的账户中,当作没有执行update account set money=money-100 where id=1;这条sql语句,之后再去查询表中的数据时,仍然是初始状态

id money
1 300
2 0

一句话概括来说,一组事务操作,如果事务中的所有逻辑操作都成功,这个事务才算执行成功;只要事务中的一个逻辑操作失败,整个事务就算操作失败,并需要回到一开始执行事务的原始状态

2. 事务的ACID属性

1.原子性 —- Automicity

原子性是指事务是一个不可分割的工作单位,要么全部提交,要么全部失败回滚

在操作系统中有一个叫原子操作的概念,指的是某一个操作在执行的过程中不能被打断,事物的原子性类似于这样的原子操作,不同的是,当事物执行失败时,需要从最初的状态开始。

2. 一致性 —- Consistency

一致性是指事务执行前后,数据从一个合法状态变换到另一个合法状态
需要注意的是这个合法状态,什么是合法状态?

  • 合法状态指的是满足预定约束的状态
  • 比如,银行卡中的余额不能是负数,这就是一个约束;之后在取钱的时候,取钱之前银行卡中有200元,假设要取300元,取完之后,卡中的余额就变为-100元,不满足约束,就不能完成这个取钱的操作
  • 再比如,两个人转账,预定约束是转账之后两人金额的总和要与转账之前相同

    3. 隔离性 —- isolation

    事务的隔离性是指一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的

    这类似于Java多线程中,如果多个线程要操作一个共享数据,那么就需要排队操作,当前一个线程操作完成之后再去操作这个共享数据

4. 持久性 —- Durability

指一个事务一旦被提交(事务执行成功),它对数据库的改变就是永久性的,接下来的其他操作和数据库故障不应该对其有任何影响

3. 事务的状态

MySQL事务 - 图1

4. 数据并发产生的问题

1. 脏写

脏写指的是事务B修改了一个未提交的事务A修改过的数据

T A T B
begin 开启事务
update xxx set col1=value1 begin 开启事务
其他事务A的操作 update xxx set col1 = value2
其他事务A的操作 commit 事务B提交
事务A出错回滚

在事务B看来,已经修改了xxx表的col1列的值,并且已经提交了数据,下次在查询的时候数据理应是value2
但事务A在操作的过程中出现了差错导致事务A回滚,使得col1列的值回到了事务A开始执行之前的状态,这就导致了事务B的修改是无效的

2. 脏读

事务A读取了事务B已经修改但未提交的数据,如果事务B回滚,那事务A读到的数据就是临时且无效的
假设xxx表的col1列的值是value1

T A T B
begin 开启事务
begin 开启事务 update xxx set col1=value2
select col1 from xxx 事务A查询col1结果为value2 其他事务B操作
其他事务A操作 事务B操作出错回滚
commit 提交事务

在事务B的第四步,事务B出错回滚,导致原来做过的修改update操作无效,col1的值重新回到value1
而事务A在查询时,查出的值为value2,但它并不知道事务B回滚了,之后的操作也均使用value2的值进行操作

3. 不可重复读

事务A读取了一个字段,然后事务B更新了该字段的值,之后事务A再次读取字段时,获取到的值就不同了

T A T B
begin 开启事务
select col1 from xxx 此时查询出来的col1的值为value1 begin 开启事务
其他事务A的操作 update xxx set col1=value2
select col1 from xxx 此时查询出来的col1的值为value2 其他事务B的操作

事务A在第一次执行查询操作时,查询出col1的值为value1;之后事务B对col1的值进行了修改,当事务A再次去查询col1的值时,查询出来的值就变为value2,这与之前查询的值不相同

4. 幻读

事务A读取了一张表记录,然后事务B在该表中插入了几条数据,之后事务A再次去查询这张表记录时,就会多出几条数据来,这就意味着发生了幻读

T A T B
begin 开启事务 begin 开启事务
select * from xxx 事务A当前查询出xxx表中有1条记录
insert into xxx (col1) values(value2)
select * from xxx 事务A当前查询出xxx表中有2条记录

事务A在第一次执行查询操作时,查询出xxx表中只有1条数据;之后事务B在xxx表中又添加了一条数据,当事务A再次去查询xxx表中的记录时,就会发现多出了一条数据

总结:对比这些数据并发问题,可以发现脏写和脏读是比较严重的数据并发问题,大多数情况下操作数据库时一定要解决;不可重复读和幻读是比较正常的现象,在一定情况下可以忽略这两个问题带来的影响

5. SQL标准下的隔离级别

1. Read uncommitted

没有解决脏读、不可重复读、幻读的问题,数据一致性较差,但并发性很好

2. Read committed

解决了脏写的问题,没有解决不可重复读和幻读的问题
Oracle数据库默认支持的隔离级别

3. Repeatable Read

解决了脏读、不可重复读的问题,但没有完全解决幻读的问题
MySQL数据库默认的隔离级别

4. Serializable

解决了脏读,不可重复读、幻读的问题,数据一致性很好但并发性很差

SQL默认隔离级别下解决的问题

隔离级别 脏写 脏读 不可重复读 幻读
Read uncommitted 解决 未解决 未解决 未解决
Read committed 解决 解决 未解决 未解决
Repeatable Read 解决 解决 解决 未解决
Serializable 解决 解决 解决 解决

注意:脏写问题在数据库操作的过程中是绝对不允许出现的,所以,这四种隔离级别都解决了脏写的问题

6. 事务隔离性的实现 —- 锁

1. 锁的基本概念

锁是计算机协调多个进程或线程并发访问某一资源的机制。在程序并发执行的过程中会存在多线程的同步问题,当多个线程同时访问某个数据时,就需要保证这个数据在任何时刻最多只有一个线程在访问,保证数据的完整性和一致性
比如在java中锁有synchronized关键字,Lock接口等
MySQL中锁机制也为实现MySQL的各个隔离级别提供了保证,所以锁对数据库而言尤其重要

2. MySQL并发访问相同记录的情况

2.1 读 —- 读

两个或者多个事务相继读取相同的记录,由于读操作并不会对记录本身进行修改,所以,多个读操作并不会产生什么问题,MySQL也允许这样的情况发生

2.2 写 —- 写

两个或者多个事务相继对相同的记录进行修改
在这种情况下就会发生之前说过的脏写问题,任何一种隔离级别都不会允许这种情况发生,所以在多个未提交事务相继对一条记录进行改动时,需要让多个事务排队执行,这个排队机制就是通过锁来实现的,具体的操作步骤如下:
(1)当第一个事务A想要对一条记录进行修改时会先查看是否有锁结构与这条记录关联,如果没有会给这条记录加上一个锁,并在内存中生成一个锁结构
MySQL事务 - 图2

  • trx信息:标识这个锁是由哪个事务生成的,记录事务的ID
  • is_waiting:代表当前事务是否在等待

false代表当前没有事务对这条记录进行修改,获取锁成功
true代表当前有事务正在对记录进行修改,需等待,获取锁失败
(2)现在另一个事务B也想对这条记录进行修改,查看到有一个锁结构和记录关联,然后自身也生成一个所结构,只不过is_waiting这个属性的值为true,代表事务B需要等待前一个事务修改完毕才能对记录进行操作
MySQL事务 - 图3
(3)当事务A修改完毕提交之后,事务B所对应的锁结构的is_waiting属性就变为false,事务B就能对记录进行操作了

2.3 读 —- 写 / 写 —- 读

两个或者多个事务,一个事务对记录进行读操作,另一个事务对记录进行修改操作,这种情况下就会发生脏读,不可重复读和幻读的问题
各个数据库厂商对SQL标准的支持不太一样,比如SQL标准下Repeatable Read的隔离级别下不能解决幻读的问题,在MySQL中的Repeatable Read可以解决幻读问题

2. 并发问题的解决方案

对于 读—-读问题,MySQL会容许这样的情况发生,毕竟不会产生什么问题,对于写 —- 写,MySQL的各个隔离级别下都解决了这个问题,那么对于读 —- 写问题呢
MySQL对读写问题的解决有两种方案:

  1. 读操作利用多版本并发控制(MVCC),写操作加锁
  2. 读写操作都加锁

先具体来介绍一下加锁的方法

3. 锁的分类

3.1 从操作的类型分:共享锁和排他锁

MySQL实现了一个由两种类型的锁组成的锁系统,这两种锁通常被称为:共享随和排他锁,也称为读锁和写锁

  • 共享锁:用S表示,也称为S锁,针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,读操作相互不阻塞
  • 排他锁:用X表示,也称为X锁,在当前的写操作没有完成之前,它会阻断其他的写操作和读操作,这样就能保证在给定的时间里,只有一个事务能执行写入操作,并防止其他用户读取正在写入的同一资源

对于InnoDB存储引擎来说,共享锁和排他锁可以加在表上,也可以加在记录上

  1. #对于读取的记录加上共享锁
  2. select * from student where id = 1 for share; #这是MySQL8.0新增的方式
  3. select * from student where id = 1 lock in share mode; #通用的方式
  4. #对于修改的记录加上排他锁
  5. update/insert/delete/select ...具体操作 for update;

两种锁的兼容情况:

X锁 S锁
X锁 不兼容 不兼容
S锁 不兼容 兼容

兼容性的意思是指:如果一个事务TA已经获得了某个行row的共享锁,那么此时,另一个事务TB也是可以去获得row的共享锁的
如果一个事务TC想要获得row的排他锁,则需要等待事务A,B将锁释放之后才能够获得row的排他锁
其余情况也类似

3.2 从锁的粒度分:表锁,行锁和页锁

想要提高数据库的并发性,每次锁定的数据范围肯定越小越好
比如,事务A操作表1中的某条记录,比如行r,做个修改,如果事务A把整张表都锁了,其他任何事务想要操作这张表中的数据都需要等待A把锁释放才能操作;比如现在事务B想要读取记录k,其实k与r这两条记录没有什么关联,但由于A把表锁了,所以B只能等待,这样就大大降低了并发性;所以可以只对一条记录加锁
但其实一个事务可以在表级别进行加锁,也可以在行级别进行加锁(行锁仅限于InnoDB存储引擎,MyISAM只支持表锁)

  1. 表锁

表锁会锁定整张表,它是MySQL中最基本的锁策略,并不依赖于存储引擎(指的是所有存储引擎对于表锁的策略都是一样的,并且也支持表锁),优点是开销小,可以很好的避免死锁的问题(只有一个事务访问表当然不会产生什么问题);缺点就是并发性很差

这里需要说明的一点是,在InnoDB存储引擎下,进行select,insert,delete,update操作时,一般不会对这个表添加表级别的锁,因为InnoDB存储引擎支持粒度更小的行锁。除了个别特殊情况下,比如崩溃恢复过程中会使用表级锁,一般情况下不使用InnoDB的表级锁,所以在之后的叙述中,都是以MyISAM存储引擎来说明的

(1)在表锁的层面上也有S锁和X锁之分

  1. #查看表是否有被上锁
  2. SHOW OPEN TABLES (WHERE in_use > 0); #括号中的部分也可以不加
  3. #给表上S锁
  4. LOCK TABLES table_name READ;
  5. #给表上X锁
  6. LOCK TABLES table_name WRITE;
  7. #释放锁
  8. UNLOCK TABLES;

表级别的S锁和X锁与之前所述的S锁和X锁没什么区别,只是对象变成了一张表而已

演示:

  1. #准备工作,创建一张表
  2. CREATE TABLE mylock(
  3. id INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
  4. `name` VARCHAR(20)
  5. );
  6. #插入一条数据
  7. INSERT INTO mylock(`name`) VALUES('a');
  1. 给表加上S锁: ```sql

    给mylock加上S锁

    LOCK TABLES mylock READ;

在表上加上读锁之后,自己可读

SELECT * FROM mylock;

在表上加上读锁之后,自己不可更改表

INSERT INTO mylock(name) VALUES(‘b’);

在表上加上读锁之后,不可访问其他表

SELECT * FROM student;

释放锁

UNLOCK TABLES;

  1. 结果:<br />不可更改表数据:<br />![表锁--读锁--不可写.jpg](https://cdn.nlark.com/yuque/0/2022/jpeg/26710108/1648623206972-f05589d9-224f-4d3f-a45e-50b36617121c.jpeg#clientId=uc8042aaf-03b3-4&crop=0&crop=0&crop=1&crop=1&from=ui&id=u7fe00409&margin=%5Bobject%20Object%5D&name=%E8%A1%A8%E9%94%81--%E8%AF%BB%E9%94%81--%E4%B8%8D%E5%8F%AF%E5%86%99.jpg&originHeight=237&originWidth=645&originalType=binary&ratio=1&rotation=0&showTitle=false&size=32880&status=done&style=none&taskId=u4f4c40b9-aa6d-42d5-bb92-ea5928f3dff&title=)<br />不可访问其他表:<br />![表锁--读锁--不可访问其他.jpg](https://cdn.nlark.com/yuque/0/2022/jpeg/26710108/1648623233161-e48a6dba-3e38-47cf-a182-731859ac97d0.jpeg#clientId=uc8042aaf-03b3-4&crop=0&crop=0&crop=1&crop=1&from=ui&id=ub3f44980&margin=%5Bobject%20Object%5D&name=%E8%A1%A8%E9%94%81--%E8%AF%BB%E9%94%81--%E4%B8%8D%E5%8F%AF%E8%AE%BF%E9%97%AE%E5%85%B6%E4%BB%96.jpg&originHeight=213&originWidth=607&originalType=binary&ratio=1&rotation=0&showTitle=false&size=27423&status=done&style=none&taskId=udfc21961-af62-4d5b-9ccd-f47a23b4253&title=)
  2. 2. 给表加上X
  3. ```sql
  4. #给mylock加上S锁
  5. LOCK TABLES mylock READ;
  6. #在表上加上读锁之后,自己可读
  7. SELECT * FROM mylock;
  8. #在表上加上读锁之后,自己不可更改表
  9. INSERT INTO mylock(`name`) VALUES('b');
  10. #在表上加上读锁之后,不可访问其他表
  11. SELECT * FROM student;
  12. #释放锁
  13. UNLOCK TABLES;

结果:
自己可读:
表锁--写锁--可读.jpg
自己可写:
表锁-写锁--自己可写.jpg

总结:

锁类型 自己可读 自己可写 操作其他表 他人可读 他人可写
S锁
X锁

(2)表锁之意向锁:
意向锁的存在是为了协调行锁和表锁的关系,支持多个粒度的锁并存
意向锁是一种不与行锁冲突的表级锁
表明某个事务正在某些行持有了锁或该事务准备去持有锁

意向锁要解决的问题:
假设现在有两个事务,分别为T1,T2,现在T2想给整个表加上共享锁或者排他锁,如果没有意向锁的存在,那么T2就需要去检查各个页或者行是否存在锁,这样的效率是很低的;如果存在意向锁,T2就会受到由T1控制的表级别的意向锁的控制,不需要去一条一条记录查找是否有上锁

在数据表的场景中,如果我们给某一行数据加上了排它锁,数据库会自动给更大一级的空间加上意向锁,高速其他事务这个数据页或者数据表已经有排它锁了

  • 如果事务想要获取数据表中某些记录的共享锁,就需要在数据表上加上意向共享锁
  • 如果事务想要获得数据表中某些记录的排它锁,就需要在数据表上加上意向排它锁

意向锁示例:

  1. #事务1
  2. BEGIN;
  3. #在查询某条记录时,给记录加上一个X锁,InnoDB会自动在表上加上一个意向X锁
  4. SELECT * FROM mylock WHERE id = 1 FOR UPDATE;
  5. COMMIT;
  6. #事务2
  7. BEGIN;
  8. #在其他事务给表加上了一个意向锁之后,其他事务想要给表加上S锁或者X锁时,会被阻塞,待其他事务提交释放锁之后,可以继续执行
  9. LOCK TABLES mylock READ;
  10. COMMIT;

虽然其他事务不能给表上读锁或者写锁,但意向锁之间是兼容的

  1. #事务1在表上加上了意向锁
  2. BEGIN;
  3. SELECT * FROM mylock WHERE id = 1 FOR UPDATE;
  4. COMMIT;
  5. #事务2仍然能够给表加上意向锁,而并不会报错或阻塞
  6. BEGIN;
  7. SELECT * FROM mylock WHERE id = 2 FOR UPDATE;
  8. COMMIT;

结果:
表锁 -- 意向锁 -- 兼容1.jpg
表锁 -- 意向锁 --兼容2.jpg
所以:


意向共享锁 意向排它锁
意向共享锁 兼容 兼容
意向排它锁 兼容 兼容

但上述的关系适用于意向锁,而意向锁和S锁和X锁之间的兼容关系并不是这样的

意向共享锁 意向排它锁
共享锁 兼容 不兼容
排它锁 不兼容 不兼容

(3)表锁之自增锁:
自增锁是当向含有AUTO_INCREMENT列的表中插入数据时需要获取的一种特殊的表级锁,了解即可

(4)表锁之元数据锁:
在MySQL5.5之后引入了元数据锁,简称MDL锁,属于表锁范畴,MDL的作用就是保证读写的正确性,比如,如果一个查询正在遍历表中的数据,而执行期间,另一个线程对这个表结构做了变更,增加了一列,那么查询线程拿到的结果跟表结果对不上,肯定是不行的

因此,当对一个表做增删改查操作时,加MDL读锁;当想要对表结构做变更操作时,加MDL写锁
读锁之间不互斥,因为不会对表结构产生变更,可以由多个线程同时对一张表中的数据进行增删改查操作
写锁与读锁互斥,写锁之间互斥,用来保证变更表结构操作的安全性,解决DDL与DML操作之间的一致性问题

  1. 行锁

行锁也称为记录锁,锁住某一行,行级别的锁只在存储引擎层实现
优点:锁粒度小,发生锁冲突概率低,可以实现并发度高
缺点:对于锁的开销比较大,加锁比较满,容易出现死锁的情况
MyISAM存储引擎不支持行锁

(1)行锁之记录锁
记录锁也就是仅仅给一条记录上锁,不会影响其他数据
也有S锁和X锁之分,性质也和之前所介绍的X锁和S锁相似

  • 当一个事务获取了一条记录的S型记录锁之后,其他事务可以继续获取该记录的S型记录锁,但不能获取X型记录锁
  • 当一个事务获取了一条记录的X型记录锁之后,其他事务不能获取该记录的S锁和X锁

(2)行锁之间隙锁
MySQL在Repeatable Read的隔离级别下能够解决幻读问题,一种方法是MVCC,另一种方法是加锁的方式,这个锁就是间隙锁,但这种方法不能完全解决幻读问题
举一个简单的例子:

id 1 2 3 8 15
name 张三 李四 王五 赵六 钱七

现在在id = 8的记录上加上一个间隙锁,那么就不能在3号记录和8号记录之间插入数据,比如现在其他事务想向表中插入一条id = 4的记录,这时候这个添加操作就会被阻塞,直到当前事务释放了id = 8的间隙锁之后,添加才能成功
也可以在一条不存在的记录上添加间隙锁,比如上表,可以使用select * from student where id = 20 lock in share mode;那么在区间[15, 正无穷]之间都不能添加记录

间隙锁的提出仅仅是为了防止插入幻影记录而提出的,虽然也有共享锁和排它锁之分,但区别不大,因为作用是一样的

(3)行锁之临键锁
说的简单一点的话,临键锁 = 记录锁 + 间隙锁
有时候我们既想锁住某条记录,又想阻止其他事务在该记录的前面的间隙插入新记录,这时候就可以使用临键锁
还是上表,给8号记录添加临键锁,其他事务不能在(3, 8)之间添加记录,也不能操作8这条记录