事务的隔离性由锁来实现
当多个线程并发访问某个数据的时候,尤其是一些敏感的数据(比如订单、金额),我们就需要保证这个数据在任何时刻“最多只有一个线程”在访问,保证数据的完整性 和 一致性。
1、并发事务访问相同记录
1.读-读情况
读-读情况,即并发事务相继读取相同的记录,这种情况非常安全。。。不需要考虑锁
======================
2.写-写情况
写-写情况,即并发事务相继对相同的记录做出改动,可能会发生脏写问题。
任何一种隔离级别都不允许脏写问题的发生。所以在多个未提交事务相继对这条记录做改动时,需要让它们排队执行,这个排队的过程其实是通过锁来实现的。
这个所谓的锁其实是一个内存中的结构,在事务执行前本来是没有锁的,也就是说一开始是没有锁结构和记录进行关联的。
注意:有几个事务,就会有几个锁结构
当一个事务想对这条记录做改动时,首先会看看内存中有没有与这条记录关联的锁结构,当没有的时候就会在内存中生成一个锁结构与之关联。比如,事务T1要对这条记录做改动,就需要生成一个事务T1的锁结构与之关联:
锁结构的属性解释:
trx信息:代表这个锁结构是哪个事务生成的。
is-waiting:代表当前事务是否在等待。
在事务T1提交或者回滚之后,就会把该事务生成的锁结构释放掉,然后看看还有没有别的事务在等待获取锁, 发现了事务T2还在等待获取锁,所以把事务T2对应的锁结构的is-waiting属性设置为false,然后把该事务对应的线程唤醒,让它继续执行,此时事务T2就算获取到锁了。
===================
3.读-写情况 (研究重点)
读-写或写-读,即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读、不可重复读、幻读的问题。
注意:MySQL在REPEATABLE READ隔离级别上就已经解决了幻读问题
2、并发问题的2种解决方案
脏写的问题,任何一种隔离级别都给解决掉了,这里的并发问题主要指脏读、不可重复读、幻读
方案一:读操作利用多版本并发控制(MVCC),写操作进行加锁。
方案二:读、写操作都采用加锁的方式。
3、如和解决读写冲突
- 采用MVCC方式的话,读-写操作彼此并不冲突,性能更高。
- 采用加锁方式的话,读-写操作彼此需要排队执行,影响性能。
一般情况下我们当然愿意采用MVCC来解决读-写操作并发执行的问题,但是业务在某些特殊情况下,要求必须采用加锁的方式执行。
4、共享锁和排它锁
共享锁和排他锁,也叫读锁(readlock)和写锁(write lock)。
容易误会的点:以为排他锁锁住一行数据后,其他事务就不能读取和修改该行数据,其实不是这样的。排他锁指的是一个事务在一行数据加上排他锁后,其他事务不能再在其上加其他的锁。加过排他锁的数据行在其他事务种是不能修改数据的,也不能通过for update和lock in share mode锁的方式查询数据,但可以直接通过select …from…查询数据,因为普通查询没有任何锁机制。
关键规则:共享锁和排他锁是互斥的!
在无 MVCC 的系统中(如纯锁机制),为了保证一致性:
读操作需要加共享锁(S Lock),会阻塞写操作(排他锁 X Lock)。
写操作需要加排他锁(X Lock),会阻塞读操作(S Lock) 和其他写操作。
共享锁阻塞排他锁,确保了在读取过程中,数据不会被其他未提交的事务修改,从而避免了脏读。
需要注意的是对于InnoDB引擎来说,读锁和写锁可以加在表上,也可以加在行上。
5、Select操作加S锁或X锁
SELECT ... 语句正常情况下为快照读,不加锁;
SELECT ... LOCK IN SHARE MODE 语句为当前读,加 S 锁;
SELECT ... FOR UPDATE 语句为当前读,加 X 锁;
常见的 DML 语句(如 INSERT、DELETE、UPDATE)为当前读,加 X 锁;
常见的 DDL 语句(如 ALTER、CREATE 等)加表级锁,且这些语句为隐式提交,不能回滚。
为什么读操作会SELECT ... FOR UPDATE
,核心目的:实现“先锁后改”的原子操作
场景 | 锁类型 | 说明 |
---|---|---|
普通 SELECT (快照读) | 无锁 | InnoDB 默认行为。通过 MVCC 读取历史快照,无需加锁。 |
SELECT ... LOCK IN SHARE MODE |
共享锁 (S Lock) | 显式要求加共享锁,阻塞其他事务的写操作(X Lock)。 |
SELECT ... FOR UPDATE |
排他锁 (X Lock) | 显式要求加排他锁,阻塞其他事务的读写操作(S/X Lock)。 |
对读取的记录加 S锁:
SELECT ... LOCK IN SHARE MODE; #或 SELECT ... FOR SHARE;(8.0新增语法)
对读取的记录加 X锁:
SELECT ... FOR UPDATE;
悲观锁的核心思想是在操作期间持有锁来保护数据的一致性,但也会降低并发性能。因此,在使用悲观锁时需要注意锁的粒度和持有时间,避免过度锁定导致性能问题。
读操作是可以加S锁或X锁的,演示思路如下:
1.开启事务1,加s锁,开启事务2,加s锁(成功),s锁之间是共享的
2.在1的基础上再开启事务3,加x锁(阻塞)提交事务1,事务3仍然阻塞,继续提交事务2,事务3结束阻塞
开启事务1,加X锁,开启事务2,加s锁/x锁(都会阻塞),因为X锁是排它的
6、mysql8.0中的新特性
能查就查,查不了也不会去阻塞,会执行相应的行为
在8.O版本中,SELECT...FOR UPDATE,SELECT...FOR SHARE添加NOWAIT、SKIP LOCKED语法, 跳过锁等待,或者跳过锁定。
通过添加NOWAIT、SKIP LOCKED语法,能够立即返回。如果查询的行已经加锁:
1.那么NOWAIT会立即报错返回。
2.而SKIP LOCKED也会立即返回,只是返回的结果中不包含被锁定的行。
写操作指增删改,是一定加要X锁(排它锁)的
MVCC 多版本并发控制
MVCC更好的去处理 读写冲突,提高数据库的并发性能。MVCC的实现依赖于:隐藏字段、Undo Log、Read View。
1、快照读
快照读又叫一致性读,读取的是快照数据。不加锁的简单的SELECT都属于快照读,如下
SELECT * FROM player WHERE ...
快照读:读取到的并不一定是数据的最新版本,可能是之前的历史版本
2、当前读
当前读:读取的是记录的最新版本,最新数据。加锁的SELECT或者对数据进行增删改都会进行当前读,如:
3、行格式中的隐藏字段
4、ReadView
ReadView和事务是一对一的。
ReadView就是一个事务在使用MVCC机制进行快照读操作时产生的读视图。ReadView要解决的核心问题是:判断版本链中的哪个版本是当前事务可见的
ReadView中4个重要的内容如下
活跃指,已经启动但是没提交的事务,提交ReadView访问规则了的事务不在ids里边
5、ReadView访问规则
在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见,某个版本也就是下文的被访问的版本。
6、ReadView(读已提交)
在隔离级别为读已提交(Read Committed)时,一个事务中的每一次SELECT查询都会重新获取一次Read View。
7、ReadView(可重复读)
当隔离级别为可重复读的时候,一个事务只在第一次SELECT的时候会获取一次Read View,
而后面所有的SELECT都会复用这个Read View,如下表所示:
Innodb锁的粒度
1、mysql表级锁
表级锁:锁整张表
- 开销小,加锁快;
- 不会出现死锁;
- 锁定粒度大,发生锁冲突的概率最高,并发度最低。
2、mysql行级锁
行级锁:对一行或者多行记录加锁
- 开销大,加锁慢;
- 会出现死锁;
- 锁定粒度最小,发生锁冲突的概率最低,并发度也最高
记录锁(Record Lock)、间隙锁(Gap Lock)、临键锁(Next-key Lock)
3、mysql页级锁
页级锁:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般
从操作粒度来说:表级锁>页级锁>行级锁
为了尽可能的提高并发度,每次锁定的数据范围越小越好
4、索引没命中(表锁)
当我们执行UPDATE、DELETE语句时,如果WHERE条件中字段没有命中索引的话,就会导致扫描全表对表中的所有记录进行加锁
索引没命中,行锁变表锁
1.在session1会话窗口,
BEGIN; -- 加 X 锁(索引未命中时 锁粒度为表锁) UPDATE t_customer SET age=55 WHERE phone='13811112222'
2.在session2会话窗口,操作另外俩条记录
UPDATE t_customer SET age=55 WHERE id=5; #转圈等锁。 或者 UPDATE t_customer SET age=44 WHERE id=6; #转圈等锁。
会发现转圈现象,有了表锁。。
3.对session1中的事务 commit/rollback;接着session就好使了
解释:在session1中操作数据时,phone字段上面我们没有建索引,不会命中索引,使得行锁变表锁
5、主键索引命中(行锁)
InnoDB的行锁,是通过锁住索引来实现的,如果加锁查询的时候没有使用到索引,会将整个聚簇索引都锁住,相当于锁表了。
按照主键索引 id
id主键索引、聚簇索引、一级索引 都是一个意思
操作前
1.session1中,注意此时我们使用到了主键索引id,则会是行锁
BEGIN; -- 加 X 锁(索引命中时 锁粒度为行锁) UPDATE t_customer SET age=55 WHERE id=4
2.在session2中,只要你不跟人家抢那一行,都是OK的
UPDATE t_customer SET age=55 WHERE id=5; # OK 或者 UPDATE t_customer SET age=33 WHERE id=6; # OK 或者 UPDATE t_customer SET age=11 WHERE id=4; # 转圈圈
6、二级索引命中(行锁)
按照二级索引cname
辅助索引、非聚簇索引、二级索引 都是一个意思
1.在cname字段自建一个二级索引
CREATE INDEX idx_cname ON t_customer(cname);
此时t_customer表中数据如下:
2.按照我们自建的索引去命中
在session1中,使用到了我们自建的索引。所以会是行锁,只会把这一条记录锁住
BEGIN; -- 加 X 锁(索引命中时 锁粒度为行锁) UPDATE t_customer SET age=1 WHERE cname='z3'
===============
在session2中,这俩个SQL操作的是另外两条记录,所以可以。
UPDATE t_customer SET age=44 WHERE cNAME='z4'; #ok UPDATE t_customer SET age=55 WHERE cNAME='z5'; #ok
在session2中,这俩个操作都是不行的,因为被session1行锁了。
UPDATE t_customer SET age=11 WHERE cNAME='z3' # 转圈圈 UPDATE t_customer SET age=11 WHERE id=4 # 转圈圈
在session1中,使用率commit/rollback,一切都回归正常