【转载】MySQL:多个事务更新同一行数据时,通过加行锁避免脏写的
引入
-
多个事务并发运行的时候,如果同时要读写一批数据,此时读和写事件的关系需要协调好,否则可能会有脏读、不可重复读、幻读等一系列问题
-
简单来说,脏读、不可重复读、幻读,都是别人在更新数据的时候,你怎么读的问题,读的不对,那就有问题 ,读的方法对了,那就不存在问题了。
-
那怎么协调呢?这就要靠基于undo log版本链条以及ReadView实现的MVCC机制了
-
如果有多个事务同时并发更新一行数据的时候,会有脏写的问题,而脏写是绝对不允许的,那么这个脏写要靠怎么防止呢?
解决方法
说白了,就是靠锁机制,依靠锁机制让多个事务更新一行数据的时候串行化,避免同时更新一行数据
- 在MySQL里,假设有一行数据在那儿不动,此时有一个事务来了要更新这行数据,这个时候它会先看一下,看看这行数据此时有没有人加锁
- 一看没人加锁,说明它是第一个,这个时候事务就会创建一个锁,里面包含了自己的trx_id和等待状态,然后把锁跟这行数据关联在一起。
- 必须明确的是,更新一行数据必须把它所在的数据页从磁盘文件里读取到缓存页里来才能更新的。所以说,此时这行数据和关联的锁数据结构,都是在内存里的。如下图:
- 如上图,因为事务A给那行数据加了锁,所以此时就可以说那行数据已经被加锁了。
- 那么既然被加锁了,此时就不能再让别人访问了。
- 假如此时另外一个事务B过来了,这个事务B也想更新那行数据,此时就会检查一下,当前这行数据有没有别人加锁。
- 事务B就会发现有别的事务抢先给这行数据加锁了,那么怎么办呢?
- 事务B这个时候就会也生成一个锁,然后等着排队。这个锁数据结构,里面有事务B的trx_id,还有自己的等待状态,但是因为它是在排队等待,所以它的等待状态就是true了,意思是当前正在等待这个锁。
- 接着事务A这个时候也更新完了数据,就会把自己的锁给释放调了。锁一旦释放了,它就会去找,此时还有没有别人也对这行数据加锁了呢?它会发现事务B也加锁了
- 于是这个时候,就会把事务B的锁的等待状态修改为false,然后唤醒事务B继续执行,此时事务B就获取到锁了。如下图:
上述就是MySQL中锁机制的一个最基本的原理
如何理解?
从上面可以看出,它锁住了一行。所以也叫做行锁。
什么是行锁
- 行锁,就是针对数据表中行记录的锁。比如事务A更新了一行,而这时候事务B也要更新同一行,则必须等待事务A的操作完成之后才能进行更新
- 行锁是锁住单个记录的锁,防止其他事务对其update、delete的操作,在RR和RC隔离级别中都支持。
- 行锁是通过锁住索引来实现的
- 在多个事务并发更新数据的时候,都是要在行级别加独占锁的,这就是行锁。
特点
- 对一行数据加锁
- 开销大
- 加锁慢
- 会出现死锁
- 锁粒度小,发生锁冲突概率最低,并发性高
- 行锁是通过对索引上的索引项加锁来实现的
分类
行锁可以分为两种:
共享锁
- 读锁(
read lock
),也叫共享锁(shared lock
)- 允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁
- 一个事务给一个数据行加共享锁时,必须先获得表的 意向共享锁(
IS
)
排他锁
- 写锁(
write lock
),也叫排他锁(exclusive lock
)- 允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享锁和排他锁(只允许获取写锁的线程操作,其他线程的任何操作都不能进行)
- 一个事务给一个数据行加排他锁时,必须先获得该表的意向排它锁(
IX
)
注意:
MySQL
InnoDB
引擎默认修改语句:update
、delete
、insert
都会自动给涉及到的数据加上排他锁,select
语句默认不会加任何锁类型,如果加排他锁可以使用select …for update
语句,加共享锁可以使用select … lock in share mode
语句。- 所以通过加排他锁的数据行在其他事务中是不能修改数据的,也不能通过
for update
和lock in shared mode
锁的方式查询数据,但是可以直接通过select … from …
查询数据,因为普通查询没有任何锁数据 - InnoDB的行锁是通过对索引加的锁,如果不通过索引条件检索数据,那么
InnoDB
将对表中的所有记录加锁,此时就会升级为表锁
理解
多个事务并发更新同一行数据时,它加的是什么锁呢?
- 加的是独占锁:当有一个事务加了独占锁之后,此时其他事务再要更新这行数据,都是要加独占锁的,但是只能生成独占锁在后面等待。
已经加了行锁了(正在更新中),如果有另一个事务此时要求写,会是什么样呢?
- 会阻塞,直到被唤醒
已经加了行锁了(正在更新中),如果有另一个事务此时要求读,会是什么样呢?
如果数据更新的时候,别的事务去读取这行数据,有两种可能:
- 第一种可能是基于MVCC机制进行事务隔离,读取快照版本,这是比较常见的
- 第二种可能是查询的同时基于特殊语法去加独占锁或者共享锁
- 如果你查询的时候加独占锁,那么跟其他更新数据的事务加的独占锁都是互斥的;
- 如果你查询的时候加共享锁,那么跟其他查询加的共享锁不是互斥的,但是跟其他事务更新数据就加的独占锁是互斥的,跟其他查询加的独占锁也是互斥的。
也就是说,如果已经加了独占锁了,如果有另一个事务要求读,这个读的事务:
- 不能显式加独占锁
- 不能显式加共享锁
如果显示加了,那么这个读事务就只能阻塞了
如果你先加了共享锁,然后别人来更新要加独占锁行吗?
- 不行
- 共享锁和独占锁是互斥的。那个事务只能等待
如果你先加了共享锁,然后别人也能加共享锁吗?
- 可以
- 共享锁和共享锁之间不是互斥的
问题是:当有人在更新数据的时候,其他的事务可以读取这行数据吗?默认情况下需要加锁吗?
答案是:可以读取,而且不需要加锁。
- 因为默认情况下,有人在更新数据,然后其他事务去读取这行数据,直接默认是开启
MVCC
机制的 - 也就是说,此时对一行数据的读和写两个操作默认是不会加锁互斥的,因为
MySQL
设计MVCC
机制就是为了解决这个问题,避免频繁加锁互斥 - 此时你读取数据,完全可以根据你的
ReadView
,去在undo log
版本链条中找一个你能读取的版本,完全不用去顾虑别人在不在更新 - 就算你真的等他更新完毕了还提交了,基于
MVCC
机制你也读不到它更新的值。因为ReadView
机制是不允许的。所以默认情况下的读,完全不需要加锁,不需要去关心其他事务的更新加锁问题,直接基于MVCC
机制读某个快照就可以了
如何上锁
(1)隐式上锁(默认,自动加锁自动释放)
select //不会上锁
insert、update、delete //上写锁
12
(2)显式上锁(手动)
select * from tableName lock in share mode;//读锁
select * from tableName for update;//写锁
12
(3)解锁(手动)
1. 提交事务(commit)
2. 回滚事务(rollback)
3. kill 阻塞进程
123
为什么上了写锁,别的事务还可以读操作?
因为InnoDB
有MVCC
机制(多版本并发控制),可以使用快照读,而不会被阻塞。
使用建议
不过一般在开发业务系统的时候,查询主动加共享锁很少见。一般不会在数据库层面做复杂的手动加锁操作,而是会基于redis
的分布式锁来控制业务系统的加锁逻辑。
另外,查询的时候也能加互斥锁,语法是:select * from table for update
。
- 意思是,我查出来数据以后还要更新,此时我加独占锁了,其他事务都不得更新这个数据了
- 一旦你查询的时候加了独占锁,此时在你事务提交之前,任何人都不能更新数据了,只能你在本事务里更新数据,等你提交了,别人再更新数据
行锁的注意点
- 只有通过索引条件检索数据时,
InnoDB
才会使用行锁,否则会使用表锁(索引失效,行锁变表锁) - 即使是访问不同行的记录,如果使用的是相同的索引键,会发生锁冲突
- 如果数据表建有多个索引时,可以通过不同的索引锁定不同的行
行锁的三种算法
MySQL
的InnoDB
存储引擎支持三种行锁算法:
record lock
(记录锁):单个记录上的锁gap lock
(间隙锁):锁定一个范围,但是不包含记录本身next-key lock
(record lock + gap lock
):锁定一个范围,并且锁定记录本身
record lock(记录锁)
record lock
:单个行记录上的锁- 如果
InnoDB
在建表的时候没有设置索引,那么会使用隐式的主键来进行锁定 InnoDB
的行锁是通过对索引加的锁,如果不通过索引条件检索数据,那么InnoDB
将对表中的所有记录加锁,此时就会升级为表锁。
gap lock(间隙锁)
为什么要间隙锁?
- 我们说
MySQL
在REPEATABLE READ
隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用MVCC
方案解决,也可以采用 加锁 方案解决。 - 但是加锁时有个大问题,那就是事务在第一次执行读取操作时,哪些幻影记录尚不存在,我们无法给这些幻影记录加上记录锁。因此,引入了间隙锁,
比如我们需要把number
值为 8
的那条记录加一个 gap
锁 的示意图如下:
如图中为 number
值为 8 的记录加了 gap
锁 ,意味着不允许别的事务在 number
值为 8
的记录前边的 间隙插入新记录,其实就是 number
列的值 (3, 8) 这个区间的新记录是不允许立即插入的。比方说有另外一个事务再想插入一条 number
值为 4 的新记录,它定位到该条新记录的下一条记录的 number
值为8,而这条记录上又有一个 gap
锁 ,所以就会阻塞插入操作,直到拥有这个 gap
锁 的事务提交了之后, number
列的值在区间 (3, 8) 中的新记录才可以被插入。
这个 gap
锁 的提出仅仅是为了防止插入幻影记录而提出的,虽然有 共享gap
锁 和 独占gap
锁 这样的说法,但是它们起到的作用都是相同的。而且如果你对一条记录加了 gap
锁 (不论是 共享gap
锁 还是 独占gap
锁 ),并不会限制其他事务对这条记录加 正经记录锁 或者继续加 gap
锁 ,gap
锁 的作用仅仅是为了防止插入幻影记录的而已。
但是此时有一个问题,我们如何给最后一条记录的后面的间隙加锁了,这就要用到 数据页 的知识了。数据页其实是有两条伪记录的:
Infimum
记录,表示该页面中最小的记录。Supremum
记录,表示该页面中最大的记录
为了实现阻止其他事务插入 number
值在 (20, +∞) 这个区间的新记录,我们可以给索引中的最后一条记录,也就是 number
值为 20
的那条记录所在页面的 Supremum
记录加上一个 gap
锁 ,画个图就是这样:
这样就可以阻止其他事务插入 number
值在 (20, +∞
) 这个区间的新记录
小结:
- 当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,
InnoDB
会给符合条件的已有数据记录的索引加锁,对于键值在条件范围内但并不存在的记录。- 正常等值条件 并且值存在的情况下加的是行锁
- 如果等值条件 值不存在的情况下加的是间隙锁,或者范围查询,加的也是间隙锁
- 优点:解决了事务并发的幻读问题
- 不足:
- 因为
query
执行过程中通过范围查找的话,他会锁定争个范围内所有的索引键值,即使这个键值并不存在。 - 间隙锁有一个致命的弱点,就是当锁定一个范围键值之后,即使某些不存在的键值也会被无辜的锁定,而造成锁定的时候无法插入锁定键值范围内任何数据。在某些场景下这可能会对性能造成很大的危害。
- 因为
小结:间隙锁是锁住记录之间的间隙,防止其他事务在某个间隙进行insert
的操作,产生幻读。在RR
隔离级别中都支持。如图所示:
Next-key Lock (临键锁)
- 临键锁是行锁和间隙锁的组合,同时锁住数据和数据之间的间隙,在
RR
的隔离级别中支持。如图所示:
- 默认情况下,
InnoDB
在RR
的事务隔离级别运行,InnoDB
会使用next-key lock
锁进行搜索和索引扫描,以防止幻读。- 索引上的等值查询(唯一索引),给不存在的记录加锁时,优化为间隙锁。
- 索引上的等值查询(普通索引),向右遍历时最后一个值不满足查询需求时,next-key lock退化为间隙锁。
- 索引上的范围查询(唯一索引)–会访问到不满足条件的第一个值为止。
注意:间隙锁唯一目的是防止其他事务插入间隙。间隙锁可以共存,一个事务采用的间隙锁不会阻止另一个事务在同一间隙上采用间隙锁。
小结
- 一个事务更新,多个事务读取,是使用
MVCC
机制实现的,避免了脏读 - 多个事务更新同一行数据,是通过加行记录锁实现的,避免了脏写
- 多个事务更新不同行数据,是通过加临键锁来解决幻读问题的