概述:
锁最要是用来实现MySQL的隔离性。我们都知道事务有四大特性分别是:原子性、一致性、隔离性、持久性,即所说的ACID。
一、什么是ACID
1、原子性:事务中包含有很多操作,这些操作要么全部执行,要么全部不执行,所以支持回滚操作。
2、一致性:系统从一种一致性到另一种一致性状态。事物的一致性决定了一个系统设计和实现的复杂度。事务可以具有不同程度的一致性。
- 强一致性:读操作可以立即读到操作的更新操作。
- 弱一致性:提交的更新操作不一定立即会被读操作读到,这种情况会存在一个不一致窗口,指的是读操作可以读到最新值的一段时间。
- 最终一致性:(弱一致性的一种特殊情况)最终所有的事务都会读到之前事务更新的最新值。
3、隔离性:有上面四种隔离性。
4、持久性:事务的操作对数据库的影响时持久的。
二、事务有四种隔离级别
事务的隔离性指的是:
当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。
1、Read uncommitted(读未提交)
在该级别,所有的事务都可以看到其他未提交事务的执行结果,本隔离级别很少用于实际应用,因为它的性能不比其他级别好多少。读取未提交的数据,也称之为脏读。
2、Read committed (读提交内容):
这是大多数数据库系统的默认隔离级别(但不是MYSQL默认的),它满足了隔离的简单定义:一个事务只能看见已提交事务所做的改变。也支持所谓的不可重复读。
3、Repeatable read 可重复读:
是MYSQL默认的,确保统一事务的多个实例在并发读取数据时,会看到同样的数据行。
4、Serializable 串行化
这是最高的隔离级别,他通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简而言之,他是在每个读的数据行上加上共享锁。在这个级别可能导致大量的超时现象和锁竞争。
5、隔离级别总结
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交(read-uncommitted) | 是 | 是 | 是 |
读提交(read-committed) | 否 | 是 | 是 |
可重读(repeatable-read) | 否 | 否 | 是 |
可串行化(serializable) | 否 | 否 | 否 |
5.1、脏读:
又称无效数据的读出,是指在数据库访问中,事务T1将某一值修改但是还未提交,然后事务T2读取该值,此后T1因为某种原因撤销对该值的修改并提交,这就导致了T2所读取到的数据是无效的,值得注意的是,脏读一般是针对于update操作的。
事例:老板要给程序员发工资,程序员的工资是3.6万/月。但是发工资时老板不小心按错了数字,按成3.9万/月,该钱已经打到程序员的户口,但是事务还没有提交,就在这时,程序员去查看自己这个月的工资,发现比往常多了3千元,以为涨工资了非常高兴。但是老板及时发现了不对,马上回滚差点就提交了的事务,将数字改成3.6万再提交。
分析:实际程序员这个月的工资还是3.6万,但是程序员看到的是3.9万。他看到的是老板还没提交事务时的数据。这就是脏读。因此,在这种隔离级别下,查询是不会加锁的,也由于查询的不加锁,所以这种隔离级别的一致性是最差的,可能会产生“脏读”、“不可重复读”、“幻读”。如无特殊情况,基本是不会使用这种隔离级别的。
5.2、不可重复读:
是指在数据库访问中,一个事务范围内两个相同的查询却返回了不同数据。在一个事务内,多次读同一个数据。在这个事务还没有结束时,另一个事务也访问该同一数据并修改数据。那么,在第一个事务的两次读数据之间。由于另一个事务的修改,那么第一个事务两次读到的数据可能不一样,这样就发生了在一个事务内两次读到的数据是不一样的,因此称为不可重复读,即原始读取不可重复。
事例:程序员拿着信用卡去享受生活(卡里当然是只有3.6万),当他埋单时(程序员事务开启),收费系统事先检测到他的卡里有3.6万,就在这个时候!!程序员的妻子要把钱全部转出充当家用,并提交。当收费系统准备扣款时,再检测卡里的金额,发现已经没钱了(第二次检测金额当然要等待妻子转出金额事务提交完)。程序员就会很郁闷,明明卡里是有钱的…
分析:这就是读提交,若有事务对数据进行更新(UPDATE)操作时,读操作事务要等待这个更新操作事务提交后才能读取数据,可以解决脏读问题。但在这个事例中,出现了一个事务范围内两个相同的查询却返回了不同数据,这就是不可重复读。
5.3、幻读:
事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B 新插入的数据称为幻读。
事例:程序员某一天去消费,花了2千元,然后他的妻子去查看他今天的消费记录(全表扫描FTS,妻子事务开启),看到确实是花了2千元,就在这个时候,程序员花了1万买了一部电脑,即新增INSERT了一条消费记录,并提交。当妻子打印程序员的消费记录清单时(妻子事务提交),发现花了1.2万元,似乎出现了幻觉,这就是幻读。
在这个级别下,普通的查询同样是使用的“快照读”,但是,和“读提交”不同的是,当事务启动时,就不允许进行“修改操作(Update)”了,而“不可重复读”恰恰是因为两次读取之间进行了数据的修改,因此,“可重复读”能够有效的避免“不可重复读”,但却避免不了“幻读”,因为幻读是由于“插入或者删除操作(Insert or Delete)”而产生的。
不可重复读侧重于修改,幻读侧重于新增或删除。解决不可重复读的问题只需锁住满足条件的行,解决幻读需要锁表
三、并发访问数据的情况分为:
1、读-读
即并发事务相继读取相同的记录,因为没涉及到数据的更改,所以不会有并发安全问题,允许这种情况发生。
2、写-写
即并发事务对相同记录进行修改,会出现脏写问题,因为任何一种隔离级别都不允许发生脏写,所以多个未提交的事务对同一个记录修改时需要加锁,保证它们是顺序执行的。
锁内存中的结构,当事务想对某条数据进行更改时,首先会查看该记录有没有与之关联的锁结构,有的话则等待它的事务被提交,锁被释放;反之没有锁则生成锁结构与该记录继续关联。
3、写-读或读-写
即一个事务进行读取操作,另一个进行改动操作。这种情况下可能发生脏读 、不可重复读 、幻读的问题。可以使用两种方式解决(都离不开锁):
- 读写都采用加锁的方式,读写也需要排队执行,性能较差
- 写操作加锁,读操作利用MVVC多版本并发控制,读取历史记录,性能更高
四、锁的分类
1.从数据操作的类型分类
共享锁(S锁):也称读锁,允许事务对某些数据进行读取。多个事务的读操作不会相互影响,也不会相互阻塞。
排他锁(X锁):也称写锁,允许事务对某些数据进行删除或更新。如果当前操作还没完成,其他事务的S和X锁是会被阻塞的,确保在多个事务中,对同一资源,只有一个事务能写入,并防止其他用户读取正在写入的资源。
1.1锁定读
共享锁称为读锁,但不是读一定获取共享锁。正常情况下,select某一条记录时,只需要获取该记录的共享锁。但是,在有些情况下可能select记录时就获取记录的排他锁,来禁止别的事务来读取该记录,为此,MySQL提供了两种特殊的select语句:
- 对读取的记录加共享锁:
SELECT ... LOCK IN SHARE MODE;
--或者
SELECT ... FOR SHARE [NOWAIT|SKIP LOCKED];
-- 8.0新特性,NOWAIT表示不等待直接报错,
-- SKIP LOCKED表示立即返回,但返回的结果不包含被锁定的行
加S锁,此时允许其他事务读取该记录(给该记录加S锁),但是不允许其他事物给该记录加X锁,需要阻塞等待当前事务提交后获取锁。
- 对读取的记录加排他锁
SELECT ... FOR UPDATE;
该select语句会被视为获取X锁,如果当前事务执行了该语句,会给记录加上X锁,不允许其他事务获取该记录的S锁和X锁。
1.2 锁定写
写操作一定是要获取排它锁的。
- DELETE操作:底层是先获取X锁,再执行删除操作的。
- UPDATE操作:
①如果不是修改主键且修改后数据占用空间不变,则获取X锁,然后直接修改即可
②如果是修改主键或者是记录修改后占用空间发生变化,则先获取X锁,再删除记录,最后重新插入新的记录- INSERT操作:新插入记录加不了锁,但MySQL会通过建立隐式锁保护这个新插入的记录不被别的事务访问。
2.从锁的粒度分类
从锁的粒度划分可分为:表锁、全局锁以及行锁(锁的粒度越小并发性越好)。目前只有InnDB支持行锁。
2.1 表锁(Table Lock)
锁定整张表。表锁又可分为:表级别的S锁和X锁、意向锁、元数据锁、自增锁
2.11 表级别的S锁和X锁
一般情况下,不会使用到InnoDB中提供的表级别的S锁和X锁,只会在一些特殊情况下,比方说崩溃恢复过程中用到;而在MyISM比较常用。
LOCK TABLES t READ:对表t加表级别的共享锁
LOCK TABLES t WRITE:对表t加表级别的排他锁
解锁使用UNLOCK TABLES;
应尽量避免在InnoDB存储引擎的表上使用 LOCK TABLES 这样的手动锁表语句
使用SHOW OPEN TABLES WHERE in_use>0;可查看加锁状况
2.1.2 意向锁
假如有事务T1和T2,T1获取了某表中最后一行记录的行锁(S锁),此时T2想加表锁(X锁),这是不允许的(S锁和X锁互斥),但是T2并不知道该表有没有加过行锁,需要一行一行的去检查,直到最后一行,效率非常低。但是如果有意向锁的话,T1获取行锁时,会额外加上表级别的意向锁,告诉其他事务该表已经有人加过锁了。此时T2只需要检查该表上是否有意向锁即可。
意向锁的作用就是加快表锁的检查过程。
意向锁是由存储引擎自己维护的 ,用户无法手动获取,在为数据行加共享/排他锁之前,InooDB会先获取该数据所在表的对应意向锁。意向锁可分为:
- 意向共享锁(IS):事务有意向对表中的某些行加共享锁(S锁),会自动加上意向共享锁
-- 事务要获取某些行的 S 锁,必须先获得表的 IS 锁。
SELECT column FROM table ... LOCK IN SHARE MODE;
- 意向排他锁(IX):事务有意向对表中的某些行加排他锁(X锁),会自动加上意向排它锁
--- 事务要获取某些行的 X 锁,必须先获得表的 IX 锁。
SELECT column FROM table ... FOR UPDATE;
由于InnoDB存储引擎支持的是行级别的锁,因此意向锁不会阻塞除全表扫描以外的任何请求,它们的主要目的是为了表示是否有人请求锁定表中的某一行数据。
2.1.3 自增锁
表中有自增列时,插入记录会使用到自增锁,一个事务持有自增锁时,其他事务的插入语句会被阻塞。了解即可。
2.1.4 元数据锁
在对某个表执行一些诸如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;
2.2 行锁
添加学生表
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);
对应数据的简图为:
2.21 记录锁(Record Locks)
记录锁就是行级别的X锁和S锁,仅仅锁住一行记录,分S型记录锁和X型记录锁,和前面的规则一样,官方的类型名称为: LOCK_REC_NOT_GAP。
演示:
需要特别注意的是加锁的执行过程中所有扫描到的行都会被锁上,因此必须确定条件使用了索引,这样才能精准锁定,而如果没有索引,会进行全表扫描,那么就会锁住无关紧要的数据。
如:
2.22 间隙锁(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锁的提出仅仅是为了防止插入幻影记录而提出的,没有额外其他功能。
间隙锁可能会发生死锁:
事务1和事务2都有某个记录的间隙锁,此时事务2因为插入记录而被阻塞(阻塞原因是事务1的间隙锁),所以事务2需要等待事务1提交,然而事务1试图插入记录,插入的记录在事务2中被间隙锁锁住了,所以事务1会去等待事务2提交,这也就出现了死锁,互相持有对方的锁。
select必须要加锁(for share、for update)才能解决幻读问题
2.23 临键锁(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;
如何理解InnoDB默认的锁就是Next-Key locks?
在可重复读隔离级别下默认加的行锁就是临键锁,防止幻读。但是有些时候InnoDB会将它优化为记录锁或间隙锁:
- 以唯一索引作为等值查询的条件,给存在的记录加锁时,会优化为行锁。
- 以唯一索引作为等值查询的条件,给不存在的记录加锁时,会优化为间隙锁(对应前面的例子)。
- 索引上的等值查询,向右遍历时且最后一个不满足等值条件,会将临键锁优化为间隙锁。
- 以普通索引作为等值查询的条件,且存在记录,那么会给这些记录加临键锁,还要在右边加上间隙锁(因为普通索引不唯一,还会向右扫描,根据前面所说的,扫描到的都会加临键锁)。因为普通索引可- - 以插入多个记录,为了防止幻读,该记录的左右两边都不能插入数据,都要有间隙锁。为什么右边是间隙锁?因为第三点已经将临键优化为间隙。
- 索引上的范围查询(唯一索引),会访问到不满足条件的第一个值为止。
- 以唯一\普通索引做范围查询时,扫描到的都会加临键锁,注意不会优化为间隙锁。
2.24 插入意向锁(Insert Intention Locks)
一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了间隙锁,如果有的话,插入操作需要等待,直到有间隙锁的那个事务提交。但是InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。InnoDB就把这种类型的锁命名为插入意向锁 。插入意向锁是一种Gap锁,不是意向锁,在insert操作时产生。
插入意向锁是在插入一条记录行前,由INSERT操作产生的一种间隙锁 。 事实上插入意向锁并不会阻止别的事务继续获取该记录上任何类型的锁。
2.3全局锁
全局锁就是对整个数据库实例加锁。当你需要让整个库处于只读状态的时候,可以使用这个命令,主要是做全库逻辑备份
备份时应该锁定整个库,保证数据的完整性。
--加全局锁的命令:
FLUSH tables with read lock;
-- 解锁
unlock tables;
3.从锁的态度分类
分为悲观锁和乐观锁。需要注意的是,乐观锁和悲观锁并不是锁,而是锁的设计思想 。
1.悲观锁(Pessimistic Locking)
假设最坏的情况,每次操作数据都会加上锁,如行锁、表锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。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中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住。
悲观锁开销较大,特别是长事务。
2.乐观锁(Optimistic Locking)
乐观锁认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,它不采用数据库自身的锁机制,而是通过程序来实现。
在程序上,我们可以采用版本号机制或者CAS机制实现。乐观锁适用于多读和冲突不激烈的应用类型,这样可以提高吞吐量。在Java中通过CAS实现的。
乐观锁机制
在表中增加一个版本字段version,对数据进行更新时会执行UPDATE … SET version=version+1 WHERE version=xx。如果已经有事务对这条数据进行了更新,则不会成功。
示例:
其中第二个事务更新失败,应该在程序里再循环执行(查询库存和版本号->更新),第二次发现库存已经为0了,才退出。
4.死锁
当出现死锁以后,有两种策略:
- 直接进入等待,直到超时。这个超时时间可以通过参数innodb_lock_wait_timeout来设置。
- 发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数innodb_deadlock_detect设置为on ,表示开启这个逻辑。