事务的隔离性和锁(一) 事务并发问题 两个事务同时读写同一张表的内容数据库会如何处理呢?如果同时读似乎没什么问题,同一个数据你读我读都一样,但是如果发生写操作,数据应该变成谁的修改结果呢?
这时候就应该隔离两个事务的数据,让它们读写互相独立,相互不受影响。最简单的解决方法就是加锁,保证两个事务是串行的。
InnoDB中的锁 标准读写锁 共享数据时为了避免数据异常,通常会使用如下两种锁作为标准方案,InnoDB存储引擎实现了如下两种行级锁。
排他锁 X-Lock(eXclusive Lock) 允许事务删除或者更新一行数据。当给数据加上排他锁时,其他事务不能再次对该数据增加排他锁或者共享所锁。
共享锁S-Lock(Share Lock) 允许事务读取一行数据。当给数据加上共享锁时,其他事务可以对该数据加共享锁,不能加排他锁。
排他锁和共享锁的兼容性
对行加锁 对据行加锁有以下两种语句
SELECT … LOCK IN SHARE MODE;
对数据行加共享锁 S-Lock
SELECT … FOR UPDATE;
对数据行加排他锁 X-Lock
下面发起两个事务对tagId=1 的数据行加锁,事务一加X锁成功后可以看到,事务二无论是加S锁还是X锁都会被阻塞,直到事务释放锁,有三种释放方式:手动释放、提交事务或者阻塞超时。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 mysql> begin - > ; Query OK, 0 rows affected (0.00 sec) mysql> select * from tag where id = 1 for update ; + | id | tag_name | tag_value | create_time | update_time | enable | + | 1 | tt | 10 | 2024 -06 -20 20 :33 :08 | 2024 -06 -20 20 :33 :09 | 1 | + 1 row in set (0.00 sec)mysql> begin ; Query OK, 0 rows affected (0.00 sec) mysql> select * from tag where id = 1 for update ; ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction mysql> select * from tag where id = 1 LOCK IN SHARE MODE; ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
可以通过SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
来查看当前会话的事务超时时间,这里是50秒
1 2 3 4 5 6 7 mysql> SHOW VARIABLES LIKE 'innodb_lock_wait_timeout' ; + | Variable_name | Value | + | innodb_lock_wait_timeout | 50 | + 1 row in set , 1 warning (0.00 sec)
查看加锁事务 information_schema.INNODB_LOCKS;
表中记录了任务加锁的信息,可以看到一个S锁,一个X锁,注意lock_type
是RECORD
1 2 3 4 5 6 7 8 9 mysql> SELECT * FROM information_schema.INNODB_LOCKS; + | lock_id | lock_trx_id | lock_mode | lock_type | lock_table | lock_index | lock_space | lock_page | lock_rec | lock_data | + | 7748 :68 :3 :6 | 7748 | S | RECORD | `fs_demo`.`tag` | PRIMARY | 68 | 3 | 6 | 1 | | 7747 :68 :3 :6 | 7747 | X | RECORD | `fs_demo`.`tag` | PRIMARY | 68 | 3 | 6 | 1 | + 2 rows in set , 1 warning (0.00 sec)
information_schema.INNODB_LOCK_WAITS;
表中记录了等待上锁的事务
1 2 3 4 5 6 7 8 mysql> SELECT * FROM information_schema.INNODB_LOCK_WAITS; + | requesting_trx_id | requested_lock_id | blocking_trx_id | blocking_lock_id | + | 7748 | 7748 :68 :3 :6 | 7747 | 7747 :68 :3 :6 | + 1 row in set , 1 warning (0.00 sec)
information_schema.INNODB_TRX;
此时表中的事务状态也变成了LOCK WAIT
1 2 3 4 5 6 7 8 9 mysql> SELECT trx_id,trx_state FROM information_schema.INNODB_TRX; + | trx_id | trx_state | + | 7748 | LOCK WAIT | | 7747 | RUNNING | + 2 rows in set (0.00 sec)
对表加锁 事务一先对tag表加读锁(read),事务二再对表加写锁(write)时会被阻塞
1 2 3 4 5 6 7 8 9 10 11 12 13 mysql> begin ; Query OK, 0 rows affected (0.00 sec) mysql> lock tables tag read; Query OK, 0 rows affected (0.00 sec) mysql> begin ; Query OK, 0 rows affected (0.00 sec) mysql> lock tables tag write;
直到事务一释放读锁,事务二中的命令才得到响应,可以看到等待了4分钟左右
1 2 3 4 5 6 7 mysql> unlock tables; Query OK, 0 rows affected (0.00 sec) mysql> lock tables tag write; Query OK, 0 rows affected (4 min 23.23 sec)
在阻塞期间可以通过SHOW PROCESSLIST;
查看当前阻塞会话的状态,在info属性中可以看到它在等待给tag表上写锁,同时注意state属性中的Waiting for table metadata lock
,稍后会说metadata lock
1 2 3 4 5 6 7 8 9 mysql> SHOW PROCESSLIST; + | Id | User | Host | db | Command | Time | State | Info | + | 3 | root | localhost:60112 | information_schema | Sleep | 8 | | NULL | | 6 | root | localhost:53243 | fs_demo | Query | 0 | starting | SHOW PROCESSLIST | | 7 | root | localhost:53244 | fs_demo | Query | 11 | Waiting for table metadata lock | lock tables tag write | + 3 rows in set (0.00 sec)
很久之前的MySQL有一著名的的BUG ,在一个事务中对表A插入数据,在另一个客户端中删除表A,binlog中有如下内容:
1 2 3 4 DROP TABLE t;BEGIN ;INSERT INTO t ... ;COMMIT ;
MySQL执行时会先删除表t,然后执行insert 会报1032 error。为了避免事务中DDL(Data Definition Languages)和DML(Data Manipulation Language)发生冲突,MySQL后续就引入了元数据锁MDL。
MySQL的DDL和DML操作都会加元数据锁,并且它是一个表级别的锁,为了减少加锁带来的负面效果,MDL也有读锁和写锁,当对一个表做增删改查的时候就加MDL读锁;当对表结构做变更时就加MDL写锁。
MDL阻塞复现 在第一个session中开启事务,然后查询一下表,然后再第二个session中对tag_name列添加索引,此时第二个session就会被阻塞,直到session1的事务提交。
1 2 3 4 5 6 7 8 9 10 mysql> begin ; Query OK, 0 rows affected (0.00 sec) mysql> select * from tag; mysql> alter table `tag` add index name_index(tag_name); Query OK, 0 rows affected (4 min 18.75 sec) Records: 0 Duplicates: 0 Warnings: 0
在阻塞时通过查看processlist可以发现 Waiting for table metadata lock
,这表示这个session在等待MDL锁
1 2 3 4 5 6 7 8 9 mysql> SHOW PROCESSLIST; + | Id | User | Host | db | Command | Time | State | Info | + | 8 | root | localhost:54227 | fs_demo | Query | 0 | starting | SHOW PROCESSLIST | | 9 | root | localhost:54230 | fs_demo | Query | 153 | Waiting for table metadata lock | alter table `tag` add index name_index(tag_name) | | 10 | root | localhost:54321 | information_schema | Sleep | 11 | | NULL | + 3 rows in set (0.00 sec)
这种阻塞会严重影响线上的项目使用,修改线上数据库表结构时尽量避免使用高峰期,同时应该避免长事务。
如何安全的修改线上的表结构
生产环境的任何大表或频繁操作的小表,ddl都要非常慎重,最好在业务低峰期执行。
设计上要尽可能避免大事务,大事务不仅仅会带来各种锁问题,还会引起复制延迟/回滚空间爆满等各类问题。
设置参数 lock_wait_timeout 为较小值,使被阻塞端主动停止。
增强监控告警,及时发现 MDL 锁。
或许这样操作也是一种好办法:按新结构创建新表 -> 将旧表数据迁移至新表 -> 重命名两个表(三步都通过编写sql语句完成,比手动操作快,第二步的数据迁移操作视情况而定)。过程中最好在没人用的时候操作
操作ddl之前,先用以下语句查一下有没有长事务:
1 SELECT * FROM information_schema.INNODB_TRX;
多副本(主从、集群)下可以做热更新。
意向锁(Intention Locks) 假设这时候你需要对一张表加写锁,但是写锁不能和其他锁冲突,所以你需要检查表中每一行数据是否加锁,难道加个锁也要去遍历表中的记录吗?明显这样的效率比较低。
意向共享锁(IS Lock)
意向排他锁(IX Lock)
表级意向锁与行级锁的兼容性表
如果需要对记录R上X锁,那么需要对记录R的表先上IX锁 ,再对记录R上X锁 。如果表上有IX锁则表明此时表内有X锁。
意向锁的作用就是在上表锁的时候可以快速判断是否可以上锁,而不需要遍历表中的记录。
三种行锁 Record Lock 单个记录上的锁,锁住索引记录,如果表没有设置索引则会使用隐藏的主键来进行锁定
Gap Lock 间隙锁,锁定一个范围,但是不包含自身
Next-Key Lock Gap Lock+ Record Lock 锁定一个范围,并且锁定记录本身,Next-Key Lock 的锁定技术被称为 Next-Key Locking,这种技术主要是为了解决Phantom Problem,
复现间隙锁
对tag表中tag_value列增加唯一索引 ,然后在事务一中对tag_value=30 加X锁,在事务二中执行插入tag_value=20的数据行,不会阻塞 ,此时Next-Key Lock降级为Record Lock,提高系统并发性,这种降级只会发生在查询的列是唯一索引的情况下。
对tag表中tag_value列增加普通索引 ,然后在事务一中对tag_value=30 加X锁,在事务二中执行插入tag_value=20的数据行,发生阻塞 。阻塞的范围是(10,30)、(30,+∞)在可以看到最后只有tag_value =5 插入成功。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 mysql> begin ; Query OK, 0 rows affected (0.00 sec) mysql> select * from tag; + | id | tag_name | tag_value | create_time | update_time | enable | + | 1 | tt | 10 | 2024 -06 -20 20 :33 :08 | 2024 -06 -20 20 :33 :09 | 1 | | 2 | sss | 30 | 2024 -06 -20 20 :33 :32 | 2024 -06 -20 20 :33 :33 | 1 | + mysql> select * from tag where tag_value = 30 for update ; mysql> begin ; Query OK, 0 rows affected (0.00 sec) mysql> INSERT INTO `fs_demo`.`tag` (`id`, `tag_name`, `tag_value`) VALUES (3 , 'e\' e\'e' , 20 ); ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction mysql> INSERT INTO `fs_demo`.`tag` (`id`, `tag_name`, `tag_value`) VALUES (3 , 'e\' e\'e' , 31 ); ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction mysql> INSERT INTO `fs_demo`.`tag` (`id`, `tag_name`, `tag_value`) VALUES (3 , 'e\' e\'e' , 5 ); Query OK, 1 row affected (0.00 sec)
通过查看information_schema.INNODB_LOCKS;
可以发现阻塞的lock_mode中有间隙锁GAP
1 2 3 4 5 6 7 8 mysql> SELECT * FROM information_schema.INNODB_LOCKS; + | lock_id | lock_trx_id | lock_mode | lock_type | lock_table | lock_index | lock_space | lock_page | lock_rec | lock_data | + | 7880 :68 :4 :5 | 7880 | X,GAP | RECORD | `fs_demo`.`tag` | tag_value | 68 | 4 | 5 | 30 , 2 | | 7879 :68 :4 :5 | 7879 | X | RECORD | `fs_demo`.`tag` | tag_value | 68 | 4 | 5 | 30 , 2 | + 2 rows in set , 1 warning (0.00 sec)
总结 这里介绍了两种用来控制数据行的标准锁:排他锁和共享锁,还介绍了避免DDL和DML冲突的元数据锁MDL,优化加锁流程的意向锁,后续又介绍了行锁的三种算法Record Lock、Gap Lock、Next-Key Lock,事务的隔离性其实就是通过组合各种锁来实现的,其实数据库的外键和自增长也用到了锁,下一篇总结一下事务的隔离级别的实现和MVCC原理。
mysql创建索引导致死锁,数据库崩溃,mysql的表级锁之【元数据锁(meta data lock,MDL)】全解_51CTO博客_数据库表锁死原因
12 | 本地事务如何实现隔离性?-周志明的软件架构课-极客时间 (geekbang.org)
《MySQL技术内幕(InnoDB存储引擎)》