目录
一、数据库的锁机制
什么是锁?为何要加入锁机制?
锁是计算机协调多个进程或线程并发访问某一资源的机制,那为何要加入锁机制呢?
因为在数据库中,除了传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供需要用户共享的资源。
当并发事务同时访问一个共享的资源时,有可能导致数据不一致、数据无效等问题。
例如我们在数据库的读现象中介绍过,在并发访问情况下,可能会出现脏读、不可重复读和幻读等读现象
为了应对这些问题,主流数据库都提供了锁机制,以及事务隔离级别的概念,而锁机制可以将并发的数据访问顺序化,以保证数据库中数据的一致性与有效性此外,锁冲突也是影响数据库并发访问性能的一个重要因素,锁对数据库而言显得尤其重要,也更加复杂。
并发控制
在计算机科学,特别是程序设计、操作系统、多处理机和数据库等领域,并发控制(Concurrency control)是确保及时纠正由并发操作导致的错误的一种机制。
数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性。下面举例说明并发操作带来的数据不一致性问题:
现有两处火车票售票点,同时读取某一趟列车车票数据库中车票余额为 X。两处售票点同时卖出一张车票,同时修改余额为 X -1写回数据库,这样就造成了实际卖出两张火车票而数据库中的记录却只少了一张。 产生这种情况的原因是因为两个事务读入同一数据并同时修改,其中一个事务提交的结果破坏了另一个事务提交的结果,导致其数据的修改被丢失,破坏了事务的隔离性。并发控制要解决的就是这类问题。
封锁、时间戳、乐观并发控制(乐观锁)和悲观并发控制(悲观锁)是并发控制主要采用的技术手段。
二、锁的分类
一、按锁的粒度划分,可分为行级锁、表级锁、页级锁。(mysql支持)
二、按锁级别划分,可分为共享锁、排他锁
三、按使用方式划分,可分为乐观锁、悲观锁
四、按加锁方式划分,可分为自动锁、显式锁
五、按操作划分,可分为DML锁、DDL锁
三、MySQL中的行级锁,表级锁,页级锁(粒度)
在DBMS中,可以按照锁的粒度把数据库锁分为行级锁(INNODB引擎)、表级锁(MYISAM引擎)和页级锁(BDB引擎 )。
1、行级锁
行级锁是Mysql中锁定粒度最细的一种锁,表示只针对当前操作的行进行加锁。行级锁能大大减少数据库操作的冲突。其加锁粒度最小,但加锁的开销也最大。行级锁分为共享锁和排他锁。
-
特点:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
-
支持引擎:InnoDB
-
行级锁定分为行共享读锁(共享锁)与行独占写锁(排他锁)
共享锁(S):SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
排他锁(X): SELECT * FROM table_name WHERE ... FOR UPDATE
1、对于insert、update、delete语句,InnoDB会自动给涉及的数据加锁,而且是排他锁(X)。
2、对于普通的select语句,InnoDB不会加任何锁,需要我们手动自己加,可以加两种类型的锁。
2、表级锁
表级锁是MySQL中锁定粒度最大的一种锁,表示对当前操作的整张表加锁,它实现简单,资源消耗较少,被大部分MySQL引擎支持。最常使用的MYISAM与INNODB都支持表级锁定。表级锁定分为表共享读锁(共享锁)与表独占写锁(排他锁)。
-
特点:开销小,加锁快;不会出现死锁;锁定粒度大,发出锁冲突的概率最高,并发度最低。
-
支持引擎:MyISAM、MEMORY、InNoDB
-
分类:表级锁定分为表共享读锁(共享锁)与表独占写锁(排他锁),如下所示
lock table 表名 read(write),表名 read(write),.....;
//给表加读锁或者写锁,例如
mysql> lock table employee write;
Query OK, 0 rows affected (0.00 sec)
mysql> show open tables where in_use>= 1;
+----------+----------+--------+-------------+
| Database | Table | In_use | Name_locked |
+----------+----------+--------+-------------+
| ttt | employee | 1 | 0 |
+----------+----------+--------+-------------+
1 row in set (0.00 sec)
mysql> unlock tables; -- UNLOCK TABLES释放被当前会话持有的任何锁
Query OK, 0 rows affected (0.00 sec)mysql> show open tables where in_use>= 1;
Empty set (0.00 sec)mysql>
3、页级锁
页级锁是MySQL中锁定粒度介于行级锁和表级锁中间的一种锁。表级锁速度快,但冲突多,行级冲突少,但速度慢。所以取了折衷的页级,一次锁定相邻的一组记录。
特点:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
四、行级锁之共享锁与排他锁以及死锁
行级锁分为共享锁和排他锁两种。
1、共享锁
共享锁又称为读锁,简称S锁,顾名思义,共享锁就是多个事务对于同一数据可以共享一把锁,都能访问到数据,但是只能读不能修改。
现在我们对id=1的数据行排他查询,这里会使用begin开启事务,而不会看见我关闭事务,这样做是用来测试,因为提交事务或回滚事务就会释放锁。
事务一 | 事务二 | |
---|---|---|
步骤1 | -- 开启事务、加共享锁,start transaction; | -- 开启事务、加共享锁,锁住id<3的所有行start transaction;select * from s1 where id < 3 lock in share mode; |
步骤2 | -- 加排他锁,会阻塞在原地select * from s1 where id = 1 for update;-- 加共享锁,可以查出结果,不会阻塞在原地select * from s1 where id = 1 lock in share mode;-- 不加锁,必然也可以查出结果,不会阻塞在原地select name from s1 where id = 1; | |
步骤3 | -- 提交一下事务,不要影响下一次实验commit; | -- 提交一下事务,不要影响下一次实验commit; |
注:在其他事务里也只能加共享锁或不加锁。
2、排它锁
排他锁又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他锁并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。
事务一 | 事务二 | |
---|---|---|
步骤1 | -- 开启事务 start transaction; | -- 开启事务start transaction; |
步骤2 | -- 加排他锁,锁住id<3的所有行select * from s1 where id < 3 for update; | |
步骤3 | -- 阻塞在原地select * from s1 where id = 1 for update;-- 阻塞在原地select * from s1 where id = 1 lock in share mode;-- 我们看到开了排他锁查询和共享锁查询都会处于阻塞状态-- 因为id=1的数据已经被加上了排他锁,此处阻塞是等待排他锁释放。 | |
步骤4 | -- ctrl+c终止步骤3的阻塞状态-- 注意:-- 下述实验遇到阻塞都可以用采用ctrl+c的方式结束,或者等待锁超时 | |
步骤5 | -- 如果我们直接使用以下查询,即便id<3的行都被事务二锁住了-- 但此处仍可以查看到数据-- 证明普通select查询没有任何锁机制select name from s1 where id = 1; | |
步骤6 | -- 提交一下事务,不要影响下一次实验commit; | -- 提交一下事务,不要影响下一次实验commit; |
3、死锁
两个事务都持有对方需要的锁,并且在等待对方释放,并且双方都不会释放自己的锁。
所谓死锁:是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。表级锁不会产生死锁.所以解决死锁主要还是针对于最常用的InnoDB。
事务一 | 事务二 | |
---|---|---|
步骤1 | -- 开启事务 start transaction; | -- 开启事务start transaction; |
步骤2 | -- 查询id=1这条数据时,增加排它锁select * from s1 where id = 1 for update; | |
步骤3 | -- 在事务二中删除一条数据,增加了排它锁delete from s1 where id = 5; | |
步骤4 | -- 修改事务二中的数据,发现出现了阻塞,这是因为id=5这条数据在步骤3中已经被锁定了update s1 set name = "ShanHe" where id = 5; | |
步骤5 | -- 在事务二中删除事务一中之前增加的锁的数据delete from s1 where id = 1; | |
步骤6 | -- 随即,事务一中的命令出现死锁错误ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction | |
步骤7 | -- 提交一下事务,不要影响下一次实验commit; | -- 提交一下事务,不要影响下一次实验commit; |
为何会出现死锁?
因为事务二中,步骤4的update语句是想获取互斥锁,会阻塞在原地,需要等待事务一先释放共享锁。 而事务一执行下述了下述delete语句同样是想获取互斥锁, 同样需要等事务二先释放共享锁,至此双方互相锁死。事务一在抛出死锁异常之后,会被强行终止,只剩事务二自己,这个时候事务二就可以得到他所需要的锁, 于是事务二的sql不存在锁争抢问题,会立即执行成功。
五、Innodb存储引擎的锁机制
MyISAM和MEMORY采用表级锁(table-level locking)。
BDB采用页面锁(page-level locking)或表级锁,默认为页面锁。
InnoDB支持行级锁(row-level locking)和表级锁,默认为行级锁(偏向于写)。
InnoDB的锁定模式实际上可以分为四种:共享锁(S),排他锁(X),意向共享锁(IS)和意向排他锁(IX),我们可以通过以下表格来总结上面这四种所的共存逻辑关系:
如果一个事务请求的锁模式与当前的锁兼容,InnoDB就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。
1、行级锁与表级锁的使用区分
MyISAM 操作数据都是使用表级锁,MyISAM总是一次性获得所需的全部锁,要么全部满足,要么全部等待。所以不会产生死锁,但是由于每操作一条记录就要锁定整个表,导致性能较低,并发不高。
InnoDB 与 MyISAM 的最大不同有两点:一是 InnoDB 支持事务;二是 InnoDB 采用了行级锁。也就是你需要修改哪行,就可以只锁定哪行。
在Mysql中,行级锁并不是直接锁记录,而是锁索引。InnoDB 行锁是通过给索引项加锁实现的,而索引分为主键索引和非主键索引两种:
-
如果一条sql 语句操作了主键索引,Mysql 就会锁定这条语句命中的主键索引(或称聚簇索引)。
-
如果一条语句操作了非主键索引(或称辅助索引),MySQL会先锁定该非主键索引,再锁定相关的主键索引。
-
如果没有索引,InnoDB 会通过隐藏的聚簇索引来对记录加锁。也就是说:如果不通过索引条件检索数据,那么InnoDB将对表中所有数据加锁,实际效果跟表级锁一样。
在实际应用中,要特别注意InnoDB行锁的这一特性,不然的话,可能导致大量的锁冲突,从而影响并发性能。
-
在不通过索引条件查询的时候,InnoDB 的效果就相当于表锁
-
当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,无论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。
-
由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以即便你的sql语句访问的是不同的记录行,但如果命中的是相同的被锁住的索引键,也还是会出现锁冲突的。
-
即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同 执行计划的代价来决定的,如果 MySQL 认为全表扫 效率更高,比如对一些很小的表,它 就不会使用索引,这种情况下 InnoDB 将锁住所有行,相当于表锁。因此,在分析锁冲突时, 别忘了检查 SQL 的执行计划,以确认是否真正使用了索引。
1、验证未命中索引引发表锁
事务一 | 事务二 | |
---|---|---|
步骤1 | -- 开启事务 start transaction; | -- 开启事务start transaction; |
步骤2 | -- 查询时未命中索引,从而引发表锁select * from s1 where email like "%xxx" for update; | |
步骤3 | -- 在事务二中查询数据阻塞select * from s1 where id = 2 for update ;-- 在事务二中查询数据阻塞select * from s1 where id = 3 for update ;-- 在事务二中查询数据阻塞select * from s1 where id = 4 for update ; | |
步骤4 | -- 提交一下事务,不要影响下一次实验commit; | -- 提交一下事务,不要影响下一次实验commit; |
2、验证命中索引则锁行
事务一 | 事务二 | |
---|---|---|
步骤1 | -- 开启事务 start transaction; | -- 开启事务start transaction; |
步骤2 | -- 查询时未命中索引,从而引发表锁select * from s1 where email like "%xxx" for update; | |
步骤3 | -- 在事务二中查询数据正常select * from s1 where id = 2 for update ;-- 在事务二中查询数据正常select * from s1 where id = 3 for update ;-- 在事务二中查询数据正常select * from s1 where id = 4 for update ; | |
步骤4 | -- 提交一下事务,不要影响下一次实验commit; | -- 提交一下事务,不要影响下一次实验commit; |
2、三种行锁的算法
InnoDB有三种行锁的算法,都属于排他锁:
-
Record Lock:单个行记录上的锁。
-
Gap Lock:间隙锁,锁定一个范围,但不包括记录本身。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。
当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;
对于键值在条件范围内但并不存在的记录,叫做"间隙(GAP)",InnoDB也会对这个"间隙"加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。例如
例:假如employee表中只有101条记录,其depid的值分别是 1,2,...,100,101,下面的SQL:
mysql> select * from emp where depid > 100 for update;是一个范围条件的检索,并且命中了索引,InnoDB不仅会对符合条件的empid值为101的记录加锁,也会对empid大于101(这些记录并不存在)的"间隙"加锁。 -
Next-Key Lock:等于Record Lock结合Gap Lock,也就说Next-Key Lock既锁定记录本身也锁定一个范围,特别需要注意的是,InnoDB存储引擎还会对辅助索引下一个键值加上gap lock。
对于行查询,innodb采用的都是Next-Key Lock,主要目的是解决幻读的问题,以满足相关隔离级别以及恢复和复制的需要。
# 准备数据
mysql> create table t1(id int,key idx_id(id))engine=innodb;
Query OK, 0 rows affected (0.03 sec)
mysql> insert t1 values (1),(5),(7),(11);
Query OK, 4 rows affected (0.00 sec)
Records: 4 Duplicates: 0 Warnings: 0
mysql> explain select * from t1 where id=7 for update\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t1
partitions: NULL
type: ref
possible_keys: idx_id
key: idx_id
key_len: 5
ref: const
rows: 1
filtered: 100.00
Extra: Using index
1 row in set, 1 warning (0.00 sec)
实验测试
事务一 | 事务二 | |
---|---|---|
步骤1 | -- 开启事务start transaction; | -- 开启事务start transaction; |
步骤2 | -- 加排他锁select * from t1 where id=7 for update;-- 须知-- 1、上述语句命中了索引,所以加的是行锁-- 2、InnoDB对于行的查询都是采用了Next-Key Lock的算法,锁定的不是单个值,而是一个范围(GAP)表记录的索引值为1,5,7,11,其记录的GAP区间如下:(-∞,1],(1,5],(5,7],(7,11],(11,+∞)因为记录行默认就是按照主键自增的,所以是一个左开右闭的区间其中上述查询条件id=7处于区间(5,7]中,所以Next-Key lock会锁定该区间的记录,但是还没完-- 3、InnoDB存储引擎还会对辅助索引下一个键值加上gap lock。区间(5,7]的下一个Gap是(7,11],所以(7,11]也会被锁定综上所述,最终确定5-11之间的值都会被锁定 | |
步骤3 | -- 下述sql全都会阻塞在原地insert t1 values(5);insert t1 values(6);insert t1 values(7);insert t1 values(8);insert t1 values(9);insert t1 values(10);-- 下述等sql均不会阻塞insert t1 values(11); insert t1 values(1); insert t1 values(2);insert t1 values(3);insert t1 values(4); | |
步骤4 | -- 提交一下事务,不要影响下一次实验commit; | -- 提交一下事务,不要影响下一次实验commit; |
插入超时失败后,会怎么样?
超时时间的参数:innodb_lock_wait_timeout ,默认是50秒。
超时是否回滚参数:innodb_rollback_on_timeout 默认是OFF。
3、什么时候使用表锁
绝大部分情况使用行锁,但在个别特殊事务中,也可以考虑使用表锁
- 事务需要更新大部分数据,表又较大
若使用默认的行锁,不仅该事务执行效率低(因为需要对较多行加锁,加锁是需要耗时的); 而且可能造成其他事务长时间锁等待和锁冲突; 这种情况下可以考虑使用表锁来提高该事务的执行速度。
- 事务涉及多个表,较复杂,很可能引起死锁,造成大量事务回滚
这种情况也可以考虑一次性锁定事务涉及的表,从而避免死锁、减少数据库因事务回滚带来的开销当然,应用中这两种事务不能太多,否则,就应该考虑使用MyISAM。
4、行锁优化建议
通过检查InnoDB_row_lock状态变量来分析系统上的行锁的争夺情况,在着手根据状态量来分析改善。
mysql> show status like 'innodb_row_lock%';
+-------------------------------+--------+
| Variable_name | Value |
+-------------------------------+--------+
| Innodb_row_lock_current_waits | 0 |
| Innodb_row_lock_time | 114092 |
| Innodb_row_lock_time_avg | 7606 |
| Innodb_row_lock_time_max | 50683 |
| Innodb_row_lock_waits | 15 |
+-------------------------------+--------+
5 rows in set (0.00 sec)
-
尽可能让所有数据检索都通过索引来完成, 从而避免无索引行锁升级为表锁
-
合理设计索引,尽量缩小锁的范围
-
尽可能减少检索条件,避免间隙锁
-
尽量控制事务大小,减少锁定资源量和时间长度
-
尽可能低级别事务隔离
六、乐观锁与悲观锁
数据库管理系统(DBMS)中的并发控制的任务是确保在多个事务同时存取数据库中同一数据时不破坏事务的隔离性和统一性以及数据库的统一性。
乐观并发控制(乐观锁)和悲观并发控制(悲观锁)是并发控制主要采用的技术手段。
无论是悲观锁还是乐观锁,都是人们定义出来的概念,可以认为是一种思想。其实不仅仅是关系型数据库系统中有乐观锁和悲观锁的概念,像memcache、hibernate、tair等都有类似的概念。
针对于不同的业务场景,应该选用不同的并发控制方式。所以,不要把乐观并发控制和悲观并发控制狭义的理解为DBMS中的概念,更不要把他们和数据中提供的锁机制(行锁、表锁、排他锁、共享锁)混为一谈。其实,在DBMS中,悲观锁正是利用数据库本身提供的锁机制来实现的。
下面来分别学习一下悲观锁和乐观锁。
1、悲观锁
当我们要对一个数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。
这种借助数据库锁机制在修改数据之前先锁定,再修改的方式被称之为悲观并发控制(又名"悲观锁",Pessimistic Concurrency Control,缩写"PCC")。
案例:假设商品表中有一个字段quantity表示当前该商品的库存量。假设有一件Dulex套套,其id为100,quantity=8个;如果不使用锁,那么操作方法:
//step1: 查出商品状态
select quantity from items where id=100 for update;
//step2: 根据商品信息生成订单
insert into orders(id,item_id) values(null,100);
//step3: 修改商品的库存
update Items set quantity=quantity-2 where id=100;
select...for update是MySQL提供的实现悲观锁的方式。此时在items表中,id为100的那条数据就被我们锁定了,其它的要执行select quantity from items where id=100 for update的事务必须等本次事务提交之后才能执行。这样我们可以保证当前的数据不会被其它事务修改。
悲观并发控制主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。
-
优点:
-
- 悲观并发控制实际上是"先取锁再访问"的保守策略,为数据处理的安全提供了保证。
-
缺点:
-
- 在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;
- 在只读型事务处理中由于不会产生冲突,也没必要使用锁,这样做只能增加系统负载;还有会降低了并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数
- 在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;
2、乐观锁
乐观锁( Optimistic Locking ) 相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本。
案例
在使用mysql数据库存储数据的前提下,有一个抢任务系统,一个任务只能分配给n个人,如果有高并发请求,如何保证数据完整性?
步骤
在不考虑到数据是否完整的情况下,我们一般只会按照以下思维开发:
- 用户请求抢任务接口
- 读取数据库剩余数量
- 如果大于0,剩余数量减1,更新数据库剩余数量(update task set count=count-1 where id='任务id')
- 返回数据
以上SQL其实还是有一定的问题的,就是一旦发上高并发的时候,就只有一个线程可以修改成功,那么就会存在大量的失败。
对于像淘宝这样的电商网站,高并发是常有的事,总让用户感知到失败显然是不合理的。所以,还是要想办法减少乐观锁的粒度的。
有一条比较好的建议,可以减小乐观锁力度,最大程度的提升吞吐率,提高并发能力!如下:
//修改商品库存
update task set count=count-1 where id='任务id' and count='读取到的剩余数量' and count-1 >= 0;
以上SQL语句中,通过count-1>0的方式进行乐观锁控制,商品个数count至少要有1件才可以。
以上update语句,在执行过程中,会在一次原子操作中自己查询一遍count的值,并将其扣减掉1。
没错!你参加过的天猫、淘宝秒杀、聚划算,跑的就是上述这条SQL,通过挑选乐观锁,可以减小锁力度,从而提升吞吐~
优点与不足
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以不会产生任何锁和死锁。
如何选择
在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。
- 乐观锁并未真正加锁,效率高。一旦锁的粒度掌握不好,更新失败的概率就会比较高,容易发生业务失败
- 悲观锁依赖数据库锁,效率低。更新失败的概率比较低。
随着互联网三高架构(高并发、高性能、高可用)的提出,悲观锁已经越来越少的被使用到生产环境中了,尤其是并发量比较大的业务场景。