MySQL锁机制!

锁结构

当一个事务想对这条记录做改动时,会看看内存中有没有与这条记录关联的锁结构。

  • 当没有的时候就会在内存中生成一个锁结构与之关联。

锁结构信息:

  • trx信息:这个锁结构是哪个事务生成的。
  • is_waiting : 当前事务是否在等待。

9cc38410df3b9a1992e3d5e24842a704 (1)

锁分类

读锁、写锁:

读锁

  • 共享锁,用S表示。
    • 针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。

写锁

  • 排他锁,用X表示。
    • 当前写操作没有完成前,它会阻断其他写锁和读锁。
    • 这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。

对读取的记录加S锁:

1
2
3
SELECT ... LOCK IN SHARE MODE;
# 或
SELECT ... FOR SHARE; # (8.0新增语法)

对读取的记录加X锁:

1
SELECT ... FOR UPFATE;

表锁:

LOCK TABLES t READ:

  • 对表t加表级别的S锁

LOCK TABLES t WRITE:

  • 对表t加表级别的X锁

意向锁:

有两个事务,分别是T1和T2,其中T2试图在该表级别上应用共享或排它锁:

  • 如果没有意向锁,T2就需要去检查各个页或行是否存在锁。
  • 如果存在意向锁,那么此时就会受到由T1控制的表级别意向锁的阻塞。

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

如果事务想要获得数据表中某些记录的排他锁,需要在数据表上添加意向排他锁

  • 意向锁会告诉其他事务已经有人锁定了表中的某些记录。

在为数据行加共享/排他锁之前InooDB会先获取该数据行所在数据表的对应意向锁

元数据锁(MDL锁):

MDL 的作用是 保证读写的正确性。

  • 如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,增加了一列,那么查询线程拿到的结果跟表结构对不上。

当对一个表做增删改查操作的时候,加 MDL读锁。

当要对表做结构变更操作的时候,加 MDL 写锁。

行锁:

行锁(Row Lock)也称为记录锁,就是锁住某一行(某条记录Row)。

间隙锁(Gap Locks):

MySQL解决幻读问题方案有两种:

  • 使用 MVCC
  • 采用加锁(Gap Locks)。

图中ID值为8的记录加了Gap锁,意味着 不允许别的事务在id值为8的记录前边的间隙插入新记录

  • ID列的值(3, 8)这个区间的新记录是不允许立即插入的。

在这里插入图片描述

间隙锁的实现:

当一个事务对索引范围内的记录执行查询时,MySQL会在查询的范围内对记录和记录之间的间隙进行加锁。

对于范围查询,MySQL会在范围内的记录和记录之间的间隙加锁,以确保其他事务无法在这个范围内插入新记录。

间隙锁是Next-Key Locks的一种特殊形式,同时锁定记录和记录之间的间隙。

  • 防止幻读不可重复读等并发问题的发生。

间隙锁可能会导致一些并发性能问题,特别是在高并发写入场景下。

  • 因此,在使用间隙锁时需要谨慎评估并发控制的成本和性能影响。

临键锁(Next-Key Locks):

既想锁住某条记录,又想阻止其他事务在该记录前边的间隙插入新记录,InnoDB就提出了Next-Key Locks。

  • 它是在 可重复读 的情况下使用的数据库锁,Innodb默认的锁就是Next-Key Locks

1、update 命令会施加一个 X 型记录锁,X 型记录锁是写写互斥的。

  • 如果 A 事务对 goods 表中 id = 1 的记录行加了记录锁,B 事务想要对这行记录加记录锁就会被阻塞。

2、insert 命令会施加一个插入意向锁,但插入意向锁是互相兼容的。

  • 如果 A 事务向 order 表 insert 一条记录,不会影响 B 事务 insert 一条记录。

select……for update锁表还是锁行

如果查询条件用了索引/主键select ..... for update 会进行行锁。

如果是普通字段(没有索引/主键), select ..... for update 会进行锁表。

如何有效的避免死锁的发生

设置事务等待锁的超时时间:

  • 当一个事务的等待时间超过该值后,就对这个事务进行回滚,于是锁就释放了,另一个事务就可以继续执行了。
  • 在 InnoDB 中,参数 innodb_lock_wait_timeout 是用来设置超时时间的,默认值时 50 秒。

开启主动死锁检测:

  • 主动死锁检测在发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。
  • 将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑,默认就开启。

修改数据库隔离级别为RC:

  • MySql默认级别为RR,RC没有间隙锁Gap Lock和组合锁Next-Key Lock,能一定程度的避免死锁的发生。

尽量少使用当前读for update,数据更新时尽量使用主键。

悲观锁

当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势。

  • 因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。

当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势。

  • 因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。

乐观锁

乐观锁假设认为数据一般情况下不会造成冲突。

  • 所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测。

如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。

使用版本号实现乐观锁

  • 版本号的实现方式有两种,一个是数据版本机制,一个是时间戳机制。

使用数据版本(Version)记录机制实现:

  • 为数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 version 字段来实现。

  • 当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。

  • 当提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对:

    • 如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。

这种版本号的方法并不是适用于所有的乐观锁场景。

当电商抢购活动时,大量并发进入,如果仅仅使用版本号或者时间戳,就会出现大量的用户查询出库存存在。

  • 但是却在扣减库存时失败了,而这个时候库存是确实存在的。

img

1
2
3
update t_goods 
set status=2,version=version+1
where id=#{id} and version=#{version};

使用条件限制实现乐观锁

这个适用于做数据安全校验,适合库存模型,扣份额和回滚份额,性能更高。

更新库存操作如下:

  • 注意:乐观锁的更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新时会锁表。
1
2
3
4
5
6
UPDATE t_goods
SET num = num - #{buyNum}
WHERE
id = #{id}
AND num - #{buyNum} >= 0
AND STATUS = 1

MySQL那些场景下会造成死锁?

1、批量更新id没排序,一个update 1,2,另外一个update 2,1,方案是id排序。

2、update 条件不一样导致死锁,尽量主键更新。

1
2
3
4
5
6
7
8
update my_table set name = 'test',age = 22 where name = "ischuang";
这个SQL会先对name加锁, 然后再回表对id加锁。

select * from my_table where id = 15 for update;

update my_table set age = 33 where name like "lis%";

以上SQL,会先获取主键的锁,然后再获取name的锁。

3、插入间隙锁导致的死锁。

4、并发插入唯一索引导致的死锁。

解决方案:

InnoDB 会选择资源最小的事务进行回滚,另一个事务执行成功。

可采取以下措施:

  • 尽量避免大事务,降低锁冲突的可能性。
  • 死锁回滚后,记录原始 SQL,手动处理。
1
2
3
4
5
6
7
8
try {
// 事务代码
} catch (DataAccessException e) {
if (e.getCause() instanceof MySQLTransactionRollbackException) {
// 遇到 MySQL 死锁异常后,记录 SQL,人工处理插入数据
log.error("Caught MySQLTransactionRollbackException, manualSql={}", generateInsertSQL(records));
}
}