事务的隔离性(隔离级别)是由锁来保证的。
并发访问数据的情况分为:
1.读-读
即并发事务相继读取相同的记录,因为没涉及到数据的更改,所以不会有并发安全问题,允许这种情况发生。
2.写-写
即并发事务对相同记录进行修改,会出现脏写问题,因为任何一种隔离级别都不允许发生脏写,所以多个未提交的事务对同一个记录修改时需要加锁,保证它们是顺序执行的。
锁内存中的结构,当事务想对某条数据进行更改时,首先会查看该记录有没有与之关联的锁结构,有的话则等待它的事务被提交,锁被释放;反之没有锁则生成锁结构与该记录继续关联。
3.写-读或读-写
即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读 、不可重复读 、幻读的问题。可以使用两种方式解决(都离不开锁):
涉及到写操作时,必须有锁
S锁 | X锁 | |
---|---|---|
S锁 | 兼容 | 不兼容 |
X锁 | 不兼容 | 不兼容 |
共享锁称为读锁,但不是读一定获取共享锁。正常情况下,select某一条记录时,只需要获取该记录的共享锁。但是,在有些情况下可能select记录时就获取记录的排他锁,来禁止别的事务来读取该记录,为此,MySQL提供了两种特殊的select语句:
- SELECT ... LOCK IN SHARE MODE;
- --或者
- SELECT ... FOR SHARE [NOWAIT|SKIP LOCKED];
- -- 8.0新特性,NOWAIT表示不等待直接报错,
- -- SKIP LOCKED表示立即返回,但返回的结果不包含被锁定的行
加S锁,此时允许其他事务读取该记录(给该记录加S锁),但是不允许其他事物给该记录加X锁,需要阻塞等待当前事务提交后获取锁。
单纯的select不会加任何锁,它是快照读
SELECT ... FOR UPDATE;
该select语句会被视为获取X锁,如果当前事务执行了该语句,会给记录加上X锁,不允许其他事务获取该记录的S锁和X锁。
写操作一定是要获取排它锁的。
从锁的粒度划分可分为:表锁、全局锁以及行锁(锁的粒度越小并发性越好)。目前只有InnDB支持行锁。
锁定整张表。表锁又可分为:表级别的S锁和X锁、意向锁、元数据锁、自增锁
①表级别的S锁和X锁
一般情况下,不会使用到InnoDB中提供的表级别的S锁和X锁,只会在一些特殊情况下,比方说崩溃恢复过程中用到;而在MyISM比较常用。
为了演示,可以手动获取表t的S锁或者X锁:
解锁使用UNLOCK TABLES;
应尽量避免在InnoDB存储引擎的表上使用 LOCK TABLES 这样的手动锁表语句
演示1:
演示2:
总结:
MylSAM在执行查询语句(SELECT)前,会给涉及的所有表加读锁,在执行增删改操作前,会给涉及的表加写锁。InnoDB存储引擎是不会为这个表添加表级别的读锁或者写锁的。
锁类型 | 当前事务可读 | 当前事务可写 | 当前事务可操作其他表 | 其他事物可读 | 其他事物可写 |
---|---|---|---|---|---|
S锁 | yes | no | no | yes | no |
X锁 | yes | yes | no | no | no |
使用SHOW OPEN TABLES WHERE in_use>0;可查看加锁状况
②意向锁
假如有事务T1和T2,T1获取了某表中最后一行记录的行锁(S锁),此时T2想加表锁(X锁),这是不允许的(S锁和X锁互斥),但是T2并不知道该表有没有加过行锁,需要一行一行的去检查,直到最后一行,效率非常低。但是如果有意向锁的话,T1获取行锁时,会额外加上表级别的意向锁,告诉其他事务该表已经有人加过锁了。此时T2只需要检查该表上是否有意向锁即可。
意向锁的作用就是加快表锁的检查过程。
意向锁是由存储引擎自己维护的 ,用户无法手动获取,在为数据行加共享/排他锁之前,InooDB会先获取该数据所在表的对应意向锁。意向锁可分为:
- -- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
- SELECT column FROM table ... LOCK IN SHARE MODE;
- -- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
- SELECT column FROM table ... FOR UPDATE;
由于InnoDB存储引擎支持的是行级别的锁,因此意向锁不会阻塞除全表扫描以外的任何请求,它们的主要目的是为了表示是否有人请求锁定表中的某一行数据。
演示1:
步骤:事务1首先获取行级别的共享锁(在这之前会先获取该表的意向共享锁),事务2想获取该表的表级别的共享锁,先检查该表的意向锁,发现是意向共享锁,因为是兼容的,所以加表锁成功。
演示2:
步骤:事务1获取行级别的排他锁(在这之前会先获取该表的意向排他锁),事务2想获取该表的表级别的共享锁,先检查该表的意向锁,发现是意向排他锁,读锁和写锁不兼容,所以加表锁失败,阻塞等待。
表级别的S锁 | 表级别的X锁 | |
---|---|---|
IS | 兼容 | 不兼容 |
IX | 不兼容 | 不兼容 |
还有一点是意向锁之间是相互兼容的,很好理解,一张表可以同时存在多个意向锁(包括排他的和共享的),他们都是来自不同的行锁。
- -- 查看意向锁(还有其他类型的锁)
- select OBJECT_SCHEMA,OBJECT_NAME,INDEX_NAME,LOCK_TYPE,LOCK_MODE,LOCK_DATA from performance_schema.data_locks;
③自增锁
表中有自增列时,插入记录会使用到自增锁,一个事务持有自增锁时,其他事务的插入语句会被阻塞。了解即可。
④元数据锁
在对某个表执行一些诸如ALTER TABLE 、DROP TABLE这类的DDL语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE的语句会发生阻塞。同理,某个事务中对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,在其他事务中对这个表执行DDL语句也会发生阻塞。这个过程其实是通过在server层使用一种称之为元数据锁(英文名: Metadata Locks ,简称 MDL)结构来实现的。
MDL主要是为了避免DML和DDL冲突,保证读写的正确性。
- -- 查看元数据锁
- select OBJECT_TYPE,OBJECT_SCHEMA,OBJECT_NAME,LOCK_TYPE, LOCK_DURATION from performance_schema.metadata_locks;
- CREATE TABLE `student` (
- `id` int NOT NULL,
- `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
- `age` int NULL DEFAULT NULL,
- PRIMARY KEY (`id`) USING BTREE
- ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
-
-
- INSERT INTO `student` VALUES (1, '张三', 20);
- INSERT INTO `student` VALUES (5, '李四', 10);
- INSERT INTO `student` VALUES (8, '王五', 15);
- INSERT INTO `student` VALUES (15, '赵六', 21);
- INSERT INTO `student` VALUES (20, '钱七', 20);
对应数据的简图为:
①记录锁(Record Locks)
记录锁就是行级别的X锁和S锁,仅仅锁住一行记录,分S型记录锁和X型记录锁,和前面的规则一样,官方的类型名称为: LOCK_REC_NOT_GAP。
演示:
需要特别注意的是加锁的执行过程中所有扫描到的行都会被锁上,因此必须确定条件使用了索引,这样才能精准锁定,而如果没有索引,会进行全表扫描,那么就会锁住无关紧要的数据。
如:
②间隙锁(Gap Locks)
MySQL在REPEATABLE READ隔离级别下是可以解决幻读问题的,解决方案有两种,可以使用 MVCC方案解决,也可以采用加锁方案解决。官方的类型名称为: LOCK_GAP。
加锁方式有点尴尬,幻影记录还未出现,给谁加锁呢?InnoDB提出了一种称之为Gap Locks的锁。比如,把id值为8的那条记录加一个gap锁的示意图如下。
图中id值为8的记录加了gap锁,意味着不允许别的事务在(5,8)之间插入新记录。比如,有另外一个事务再想插入一条id值为6的新记录,它定位到该条新记录的下一条记录的id值为8,而这条记录上又有一个gap锁,所以就会阻塞插入操作,直到拥有这个gap锁的事务提交了之后,id列的值在区间(5, 8)中的新记录才可以被插入。
gap锁的提出仅仅是为了防止插入幻影记录而提出的,没有额外其他功能。
演示:
注意的是在(5,8)这之间不存在的记录加锁,会给8加间隙锁,而如果给最大值20之后的记录加锁,那么间隙锁会加在一个隐藏的记录中Supremum,它是该页面最大的记录数,所以20之后的依旧不能插入数据。
间隙锁可能会发生死锁:
事务1和事务2都有某个记录的间隙锁,此时事务2因为插入记录而被阻塞(阻塞原因是事务1的间隙锁),所以事务2需要等待事务1提交,然而事务1试图插入记录,插入的记录在事务2中被间隙锁锁住了,所以事务1会去等待事务2提交,这也就出现了死锁,互相持有对方的锁。
select必须要加锁(for share、for update)才能解决幻读问题
③临键锁(Next-Key Locks)
有时候我们既想锁住某条记录 ,又想阻止其他事务在该记录前边的间隙插入新记录,所以InnoDB就提出了一种称之为Next-Key Locks的锁 。临键锁是在存储引擎InnoDB、事务级别在可重复读 的情况下使用的数据库锁, InnoDB默认的锁就是Next-Key locks。官方的类型名称为: LOCK_ORDINARY。
临键锁 = 记录锁 + 间隙锁
如
- -- 给id小于等于8的所有记录加上临键锁
- select * from student where id<=8 for update;
- -- 给id为[6,8]的记录加上临键锁
- select * from student where id<=8 and id>6 for update;
演示1:
演示2:
如何理解InnoDB默认的锁就是Next-Key locks?
在可重复读隔离级别下默认加的行锁就是临键锁,防止幻读。但是有些时候InnoDB会将它优化为记录锁或间隙锁:
查找过程中访问到的对象才会加锁。可能使用覆盖索引的查询可以绕过某些锁,可能就会有点懵了。
④插入意向锁(Insert Intention Locks)
一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了间隙锁,如果有的话,插入操作需要等待,直到有间隙锁的那个事务提交。但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB就把这种类型的锁命名为插入意向锁 。插入意向锁是一种Gap锁,不是意向锁,在insert操作时产生。
插入意向锁是在插入一条记录行前,由INSERT操作产生的一种间隙锁 。 事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
每个层级的锁数量是有限制的,因为锁会占用内存空间, 锁空间的大小是有限的。当某个层级的锁数量超过了这个层级的阈值时,就会进行锁升级。
全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,主要是做全库逻辑备份。
备份时应该锁定整个库,保证数据的完整性。
- --加全局锁的命令:
- FLUSH tables with read lock;
- -- 解锁
- unlock tables;
分为悲观锁和乐观锁。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想 。
假设最坏的情况,每次操作数据都会加上锁,如行锁、表锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。
案例:
商品秒杀过程中,库存数量的减少,避免出现超卖的情况。比如,商品表中有一个字段为quantity表示当前该商品的库存量。如果不使用锁的情况下,操作方法如下所示
- #1.查出商品库存
- select quantity from items where id=1001;
- #2.如果库存大于0,则根据商品信息生成订单
- insert into orders(item_id) values(1001);
- #3.修改商品的库存,num表示购买数量
- update items set quantity=quantity-1 where id=1001;
高并发可能会产生问题:
事务1 | 事务2 | |
---|---|---|
1 | 查询1001商品库存为1 | 查询1001库存为1 |
2 | 生成订单 | 生成订单 |
3 | 库存减掉1,库存为0了 | |
4 | 提交事务 | |
5 | 库存减1,为-1了,超卖了 |
其主要原因是查询时不会加锁,可以同时进行。来做一个模拟,如下:
使用悲观锁来解决问题:当查询库存时就把数据给锁定,保证同时只能有一个事务查询到库存,其他事务必须等他将库存减去后才能查询到库存。
- #读取时需要获取x锁
- select quantity from items where id=1001 for update;
- insert into orders(item_id) values(1001);
- update items set quantity=quantity-1 where id=1001;
注意: select ... for update语句执行过程中所有扫描的行都会被锁上,因此在MySQL中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。
另外,也可以在应用程序方面加锁来解决这种问题。
悲观锁开销较大,特别是长事务。
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,它不采用数据库自身的锁机制,而是通过程序来实现。
在程序上,我们可以采用版本号机制或者CAS机制实现。乐观锁适用于多读和冲突不激烈的应用类型,这样可以提高吞吐量。在Java中通过CAS实现的。
乐观锁机制
在表中增加一个版本字段version,对数据进行更新时会执行UPDATE ... SET version=version+1 WHERE version=xx。如果已经有事务对这条数据进行了更新,则不会成功。
示例:
其中第二个事务更新失败,应该在程序里再循环执行(查询库存和版本号->更新),第二次发现库存已经为0了,才退出。
当出现死锁以后,有两种策略: