mysql

锁问题

在数据库中,除了传统的计算资源(cpu,RAM,I/O)的争用意外,数据也是一种许多用户共享的资源,如何保证数据并发一致性,有效性是数据库必须解决的一个问题。

Mysql锁概述

mysql的锁针对于不同的引擎的锁机制不同。

其中,MyIsam和MEMORY存储采用的是表级锁。

INNODB采用的是行级锁,也支持表级锁,但是默认的情况下采用的是行级锁。

Mysql的三种锁的的特性可以归纳如下

  • 表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。

  • 行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。

  • 页面锁:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。

MyIsam表锁

查询表级锁争用情况

1
2
3
4
5
6
--可以通过show status like ‘table%’来查看锁争夺情况
show status liketable


Table_locks_immediate
Table_locks_waited //if this variables shows higher, says the crucial exclipit

myIsam存储引擎写阻塞的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
--thread1 op
lock table film_text write;
query Ok
--thread1 op
select file_id, title from film_text where film_id = 1001;
queryOk
--thread2 op
select file_id, title from film_text where film_id = 1001;
wait

unlock tables;

--thread2 op
select file_id, title from film_text where file_id = 1001;
query Ok.

在用lock tables给表进行显式加锁时,必须同时取得所有涉及的表的锁,并且mysql不支持锁升级,也就是执行lock tables时,只能访问显式的这些表,不能访问未加锁的表,同时,如果加的是读锁,那么只能执行查询操作,而不能执行更新操作,这也就是为什么MyIsam不会出现死锁(deadLock free)的原因。

1
2
3
4
5
6
7
8
--thread1 op
lock table film_text read;
--it's ok
select * from film_text where file_id = 1001;
query Ok
select * from file where file_id = 1001;
--table file not locked
--ERROR

MyISAM的锁调度

有这样的一种case,当一个进程对于一张表请求读锁,另一个线程对于同一张表获取写锁,mysql会先写进程获得锁。

同时如果在等待队列中,如果读请求先到,写请求后到,写锁也会插入到读锁之前,这是因为mysql一般认为写请求比读请求重要,这也就是myIsam为什么不适合大量的更新操作和查询操作,因为大量的更新操作会造成查询很难获得锁,导致永远阻塞。

  • 通过指定启动参数low-priority-updates,使MyISAM引擎默认给予读请求以优先的权利。

  • 通过执行命令SET LOW_PRIORITY_UPDATES=1,使该连接发出的更新请求优先级降低。

  • 通过指定INSERT、UPDATE、DELETE语句的LOW_PRIORITY属性,降低该语句的优先级。

尽管以上方法还是要么更新有限,要么查询有限,但是还是用来解决读锁等待的问题。

InnoDB的锁问题

背景知识

  1. 事务以及ACID属性

    事务是由一组 SQL 语句组成的逻辑处理单元,事务具有以下 4 个属性,通常简称为事务的ACID 属性。

    • 原子性(Atomicity):事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。

    • 一致性(Consistent):在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改, 以保持数据的完整性; 事务结束时,所有的内部数据结构(如 B 树索引或双向链表)也都必须是正确的。

    • 隔离性(Isolation):数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然。

    • 持久性(Durable):事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持。

  2. 并发事务带来的问题

    相对于串行处理来说,并发事务处理能大大增加数据库资源的利用率,提高数据库系统的事务吞吐量,从而可以支持更多的用户。但并发事务处理也会带来一些问题,主要包括以下几种情况。

    • 更新丢失(Lost Update):当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题--最后的更新覆盖了由其他事务所做的更新。例如,两个编辑人员制作了同一文档的电子副本。每个编辑人员独立地更改其副本,然后保存更改后的副本,这样就覆盖了原始文档。 最后保存其更改副本的编辑人员覆盖另一个编辑人员所做的更改。如果在一个编辑人员完成并提交事务之前,另一个编辑人员不能访问同一文件,则可避免此问题。

    • 脏读(Dirty Reads):一个事务正在对一条记录做修改,在这个事务完成并提交前, 这条记录的数据就处于不一致状态; 这时, 另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做”脏读”。

    • 不可重复读(Non-Repeatable Reads):一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。

    • 幻读 (Phantom Reads): 一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。

  3. 事务隔离级别

    脏读,不可重复读,幻读都属于数据不一致性问题,必须由数据库提供一种机制来进行解决:

    1. 一种是在读取数据之前,对其进行加锁,阻止其他事务对于数据进行修改。(当前读)

    2. 另外一种是不同加锁,通过一定的机制生成一个数据请求时间点的一致性数据的快照,并用这个快照来提供一定级别的一致性读。

      从用户的角度来看,好像是数据库可以提供同一数据的多个版本,因此这种技术叫做数据多版本并发控制,简称mvcc

      数据的事务的隔离级别越严格,并发副作用越小,同时付出的代价也越大。

获取Innodb的行锁的争用情况

可以通过检查innoDB_row_lock状态变量来分析系统的行锁的争夺情况。

1
show status like 'innodb_row_lock%';

此时query会显示五个指标

其中我们需要重点关注的是innodb_row_lock_waits和innodb_row_lock_avg。如果我们发现这两个指标对的值比较高,我们可以通过设置innodb monitor来进一步观察锁冲突的表,数据行等,并且分析锁争用的原因:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
create Table innodb_monitor(a int) engine=innodb
query Ok
--此时可以通过show innodb status来进行查看
show innodb status
/*
Trx id counter 0 117472192
Purge done for trx's n:o < 0 117472190 undo n:o < 0 0
History list length 17
Total number of lock structs in row lock hash table 0
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 0 117472185, not started, process no 11052, OS thread id 1158191456
MySQL thread id 200610, query id 291197 localhost root
---TRANSACTION 0 117472183, not started, process no 11052, OS thread id 1158723936
MySQL thread id 199285, query id 291199 localhost root
Show innodb status
*/

--could end by this sentence
drop table innodb_monitor

设置监视器后, 在SHOW INNODB STATUS 的显示内容中, 会有详细的当前锁等待的信息,包括表名、锁类型、锁定记录的情况等,便于进行进一步的分析和问题的确定。打开监视器以后,默认情况下每 15 秒会向日志中记录监控的内容,如果长时间打开会导致.err 文件变得非常的巨大,所以用户在确认问题原因之后,要记得删除监控表以关闭监视器,或者通过使用“–console”选项来启动服务器以关闭写日志文件。

innodb的行锁模式以及加锁方式

innodb实现了一下两种类型的行锁:

  1. 共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。

  2. 排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。

另外,为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB 还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁。

  • 意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。

  • 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的 IX 锁。

意向锁是 InnoDB 自动加的, 不需用户干预。 对于 UPDATE、 DELETE 和 INSERT 语句, InnoDB会自动给涉及数据集加排他锁(X);对于普通 SELECT 语句,InnoDB 不会加任何锁;事务可以通过以下语句显示给记录集加共享锁或排他锁。

1
2
3
4
--share lock
select * from table_name where ... lock in share mood
---x lock悲观锁
select * from table_name where ... for update

用 SELECT … IN SHARE MODE 获得共享锁,主要用在需要数据依存关系时来确认某行记录是否存在,并确保没有人对这个记录进行 UPDATE 或者 DELETE 操作。但是如果当前事务也需要对该记录进行更新操作,则很有可能造成死锁,对于锁定行记录后需要进行更新操作的应用,应该使用 SELECT… FOR UPDATE 方式获得排他锁。

间隙锁

当我们用范围条件而不是使用相等条件检索数据,并请求共享或排他锁时,innodb会给符合条件的已有数据记录的索引项加锁;对于键值在这个条件范围内但并不存在的记录,叫做间隙(Gap),innoDB也会对于这个间隙加锁,这种锁机制就是所谓的间隙锁。

举个例子来说,比如emp表中只有101条记录,其对应的empid的值分别是1 - 101,使用下面的sql:

1
select * from emp where empid > 100 for update;

这是一个范围条件的 检索,innoDB不仅会对符合条件的empid值为101的记录加锁,也会对于empid大于101即使不存在的间隙加锁

InnoDB使用间隙锁的目的: 一方面是为了防止幻读,以满足相关的隔离要求,对于上面的例子,要是不适用间隙锁,那么如果其他事务插入了empid大于100的任何记录,那么事务如果再次执行上面的语句,就会发生幻读;

另一方面,是为了满足其恢复和复制的需要。

很显然,在使用范围条件检索并锁定记录时,innoDB这种加锁机制会阻塞符合条件范围内的键值的并发插入,这往往会造成严重的锁等待。

恢复和复制的需要,对InnoDb锁机制的影响

mysql通过binlog录制行成功的insert,update,delete等更新数据的sql语句,并由此实现mysql数据库的恢复和主从复制。mysql的恢复机制有以下的特点

  1. mysql的恢复是sql语句级别的,也就是重新执行binlog中的sql语句,这与oracle数据库不同,oracle是基于数据库文件块儿的。

  2. mysql的binlog是按照事务提交的先后顺序记录的,恢复也是按照这个顺序进行的,这点也是与oracle不同地,oracle是按照系统更新号进行恢复数据的。

从上面的两点我们可以知道,mysql的恢复机制的要求,在一个事务未提交前,其他并发事务不能插入其锁定条件的任何记录,也就是不能出现幻读,实际上是要求事务要串行话,这也是许多情况下,innoDb要用到间隙锁的原因,比如在用范围条件更新记录时候,除了在读未提交的隔离级别下,innoDb都要使用间隙锁。

msyql什么时候使用表锁

对于InnoDb表,在绝大部分情况下都应该使用行级锁,因为事务和行锁才是我们选用InnoDb表的理由,但是在个别的事务中,也可以考虑使用表级锁。

第一种情况是: 事务需要更新大部分或者全部数据,表又比较大如果使用默认的行锁,不仅会导致这个事务执行效率低,而且可能造成其他事务长时间锁等待和冲突。

第二种情况是: 事务设计多个表,比较复杂,可能引起死锁,造成事务大量回滚。这个情况也可以考虑一次性锁定事务涉及的表,从而避免死锁,减少数据库因事务回滚而带来的开销。

关于死锁

上面有记载过,myIsam表是deadlockfree的,因为myIsam表总是一次获得所需的全部锁,要么全部满足,要么等待。

但是在InnoDB中,除了单个SQL组成的事务以外,锁是逐步获得的,这就决定了在InnoDB中发生死锁是可能的。

举一个可能发生死锁的例子

1
2
3
4
5
6
7
8
9
10
11
12
--session1
set autocommit = 0;
select * from table1 where id = 1 for update
--do something
--session2
set autocommit = 0;
select * from table2 where id = 2 for update
--do something
--session1
select * from table2 where id = 1 for update
--session2
select * from table1 where id = 2 for update

下面列举几种常用的避免死锁的方法。

  1. 在应用中,如果不同的程序会并发存取多个表,应该尽量约定以相同的顺序来访问表,这样可以降低产生死锁的机会。

  2. 在程序以批量方式处理数据的时候,如果实现对于数据进行排序,保证每个县城按固定的顺序来处理记录,也可以大大降低出现死锁的可能。

  3. 在事务中,如果要更新记录,应该直接申请足够级别的锁,即拍他锁,而不应该先申请共享锁,更新时在申请排他锁,因为当用户申请排他锁时,其他事务可能又获得了相同记录的共享锁,从而造成了锁冲突。

  4. 在RR隔离级别下,如果两个线程同时对相同条件记录用select for update加排他锁,在没有符合该条件记录情况下,两个线程都会加锁成功。程序发现记录尚且不存在,就试图插入一条新记录,如果两个线程都这么做,就会出现死锁。

  5. 当隔离级别为READ COMMITTED 时,如果两个线程都先执行 SELECT…FOR UPDATE,判断是否存在符合条件的记录,如果没有,就插入记录。此时,只有一个线程能插入成功,另一个线程会出现锁等待,当第 1 个线程提交后,第 2 个线程会因主键重出错,但虽然这个线程出错了,却会获得一个排他锁!这时如果有第 3 个线程又来申请排他锁,也会出现死锁。

    这个问题的解决方法可以直接做插入操作,然后在捕获主键重异常,或者在主键重错误时,总是执行rollback释放获得的排他锁。

尽管上面介绍的设计和sql优化等措施可以减少死锁,但是死锁很难完全避免,因此可以在程序设计中总是捕获并且处理死锁异常。

可以通过

1
show innodb status--来发现相关的死锁事务的相关信息

总结:

(1)共享读锁(S)之间是兼容的,但共享读锁(S)与排他写锁(X)之间,以及排他写锁(X)之间是互斥的,也就是说读和写是串行的。
(2)在一定条件下,MyISAM允许查询和插入并发执行,我们可以利用这一点来解决应用中对同一表查询和插入的锁争用问题。
(3)MyISAM默认的锁调度机制是写优先,这并不一定适合所有用,用户可以通过设置LOW_PRIORITY_UPDATES参数,或在INSERT、UPDATE、DELETE语句中指定LOW_PRIORITY选项来调节读写锁的争用。
(4)由于表锁的锁定粒度大,读写之间又是串行的,因此,如果更新操作较多,MyISAM表可能会出现严重的锁等待,可以考虑采用InnoDB表来减少锁冲突。

减少锁冲突的操作:

  1. 尽量使用较低的隔离级别;

  2. 精心设计索引, 并尽量使用索引访问数据, 使加锁更精确, 从而减少锁冲突的机会;

  3. 选择合理的事务大小,小事务发生锁冲突的几率也更小;

  4. 给记录集显示加锁时,最好一次性请求足够级别的锁。比如要修改数据的话,最好直接申请排他锁,而不是先申请共享锁,修改时再请求排他锁,这样容易产生死锁;

  5. 不同的程序访问一组表时,应尽量约定以相同的顺序访问各表,对一个表而言,尽可能以固定的顺序存取表中的行。这样可以大大减少死锁的机会;

  6. 尽量用相等条件访问数据,这样可以避免间隙锁对并发插入的影响;

  7. 不要申请超过实际需要的锁级别;除非必须,查询时不要显示加锁;

  8. 对于一些特定的事务,可以使用表锁来提高处理速度或减少死锁的可能。