锁的基本概念
锁是计算机协调多个进程或线程并发访问某一资源的机制。
相对其他数据库而言,MySQL的锁机制比较简单,其最显著的特点是不同的存储引擎支持不同的锁机制。比如,MyISAM存储引擎采用的是表级锁(table-level locking);BDB存储引擎采用的是页面锁(page-level locking),但也支持表级锁(已过时);InnoDB存储引擎既支持行级锁(row-level locking),也支持表级锁,但默认情况下是采用行级锁。
MySQL这3种锁的特性可大致归纳如下。
开销、加锁速度、死锁、粒度、并发性能
- 表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
- 行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
- 页面锁:开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
表级锁:一次性插入和更新较多数据时,当很多操作都是读表时可以选择。但当select语句时间过长或者update和delete语句短而且次数多时,不适用,会各种锁冲突。
行级锁:在很多线程请求不同记录时减少冲突锁。事务回滚时减少改变数据。使长时间对单独的一行记录加锁成为可能。比页级锁和表级锁消耗更多的内存。当需要频繁对大部分数据做 GROUP BY 操作或者需要频繁扫描整个表时不适合。
MyISAM表锁
MyISAM存储引擎只支持表锁,不支持事务安全。
查询表级锁争用情况
mysql> show status like 'table%';
MySQL表级锁的锁模式
MySQL的表级锁有两种模式:表共享读锁(Table Read Lock)和表独占写锁(Table Write Lock)。
对MyISAM表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求;对 MyISAM表的写操作,则会阻塞其他用户对同一表的读和写操作;MyISAM表的读操作与写操作之间,以及写操作之间是串行的!
MyISAM在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁,在执行更新操作(UPDATE、DELETE、INSERT等)前,会自动给涉及的表加写锁,这个过程并不需要用户干预,因此,用户一般不需要直接用LOCK TABLE命令给MyISAM表显式加锁。
在用LOCK TABLES给表显式加表锁时,必须同时取得所有涉及到表的锁,并且MySQL不支持锁升级。也就是说,在执行LOCK TABLES后,只能访问显式加锁的这些表,不能访问未加锁的表;同时,如果加的是读锁,那么只能执行查询操作,而不能执行更新操作。其实,在自动加锁的情况下也基本如此,MyISAM总是一次获得SQL语句所需要的全部锁。这也正是MyISAM表不会出现死锁(Deadlock Free)的原因。
当使用LOCK TABLES时,不仅需要一次锁定用到的所有表,别名也要锁定,否则也会出错!
比如锁定actor表,他有两个别名:
mysql> lock table actor as a read,actor as b read;
并发插入(Concurrent Inserts)
上文提到过MyISAM表的读和写是串行的,但这是就总体而言的。在一定条件下,MyISAM表也支持查询和插入操作的并发进行。
MyISAM存储引擎有一个系统变量concurrent_insert,专门用以控制其并发插入的行为,其值分别可以为0、1或2。
- 当concurrent_insert设置为0时,不允许并发插入。
- 当concurrent_insert设置为1时,如果MyISAM表中没有空洞(即表的中间没有被删除的行),MyISAM允许在一个进程读表的同时,另一个进程从表尾插入记录。这也是MySQL的默认设置。
当concurrent_insert设置为2时,无论MyISAM表中有没有空洞,都允许在表尾并发插入记录。
默认情况下:
session_1获得了一个表的READ LOCAL锁,该线程可以对表进行查询操作,但不能对表进行更新操作;其他的线程(session_2),虽然不能对表进行删除和更新操作,但却可以对该表进行并发插入操作,等session_1解锁后再执行更新删除操作MyISAM的锁调度
前面讲过,MyISAM存储引擎的读锁和写锁是互斥的,读写操作是串行的。那么,一个进程请求某个 MyISAM表的读锁,同时另一个进程也请求同一表的写锁,MySQL如何处理呢?答案是写进程先获得锁。不仅如此,即使读请求先到锁等待队列,写请求后到,写锁也会插到读锁请求之前!这是因为MySQL认为写请求一般比读请求要重要。这也正是MyISAM表不太适合于有大量更新操作和查询操作应用的原因,因为,大量的更新操作会造成查询操作很难获得读锁,从而可能永远阻塞。这种情况有时可能会变得非常糟糕!幸好我们可以通过一些优先级设置来调节MyISAM 的调度行为,对不同的应用设定读优先或者写优先。
InnoDB锁问题
InnoDB与MyISAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁。行级锁与表级锁本来就有许多不同之处,另外,事务的引入也带来了一些新问题。下面我们先介绍一点背景知识,然后详细讨论InnoDB的锁问题。
事务
事务处理可以用来维护数据库的完整性,保证成批的 SQL 语句要么全部执行,要么全部不执行。
事务用来管理 insert,update,delete 语句
一般来说,事务是必须满足4个条件(ACID): Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)
1、原子性:一组事务,要么全部成功;要么撤回。
2、一致性 :满足模式锁指定的约束,比如银行转账前后总金额应该不变。事务结束时,所有的内部数据结构(如B树索引)也都必须是正确的。
3、隔离性:事务独立运行。一个事务所做的修改在最终提交之前,对其它事务是不可见的。事务的100%隔离,需要牺牲速度。
4、持久性:软、硬件崩溃后,InnoDB数据表驱动会利用日志文件重构修改,或者通过数据库备份和恢复来保证。
注意几点
- 在默认情况下,MySQL每执行一条SQL语句,都是一个单独的事务。
- 在 MySQL 命令行的默认设置下,事务都是自动提交的,即执行 SQL 语句后就会马上执行 COMMIT 操作。即对于独立的每条sql语句,mysql会自动提交或者回滚。
- 如果要执行多条SQL语句组成的事务,可以显式地使用命令 BEGIN 或 START TRANSACTION开启事务,使用commit或rollback结束事务。(或者执行命令 SET AUTOCOMMIT=0,用来禁止当前会话使用自动提交,但是这种方式不推荐。)
- 在InnoDB的事务中,对于UPDATE、DELETE和INSERT语句,InnoDB会自动给涉及数据行加排他锁(X);对于普通SELECT语句,InnoDB不会加任何锁(通过多版本并发控制实现,同时有效的解决了幻读的问题)。
- 折返点:比如设置折返点:
SAVEPOINT adqoo_1
,然后利用ROLLBACK TO SAVEPOINT adqoo_1
实现发生在折返点 adqoo_1 之前的事务被提交,之后的被忽略。 - 乐观锁,即通过version来实现当并发冲突发生时,冲突的SQL语句会执行失败,然后重试或者放弃或者交给用户处理,但不能保证事务的ACID特性。(注意:
update seckill set number = number -1 where seckill_id = 1000 lock in share mode;
是错误的,lock in share mode和for update只能用在select语句中) - InnoDB采用的是两阶段锁定协议。在事务执行过程中,随时都可以执行锁定,锁只有在执行COMMIT或者ROLLBACK时才会释放,并且所有的锁是在同一时刻被释放。所以在一个事务中,推荐最后执行需要独占(获得读锁)的行,尽量减少行锁持有的时间。
- InnoDB目前处理死锁的方法是:将持有最少行级排它锁的事务回滚。如果是因为死锁引起的回滚,可以考虑在应用程序中重新执行。
- 在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应先申请共享锁,更新时再申请排他锁,因为当用户申请排他锁时,其他事务可能又已经获得了相同记录的共享锁,从而造成锁冲突,甚至死锁。
并发事务处理带来的问题
相对于串行处理来说,并发事务处理能大大增加数据库资源的利用率,提高数据库系统的事务吞吐量,从而可以支持更多的用户。但并发事务处理也会带来一些问题,主要包括以下几种情况。
- 更新丢失(Lost Update):当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生丢失更新问题--最后的更新覆盖了由其他事务所做的更新。例如,两个编辑人员制作了同一文档的电子副本。每个编辑人员独立地更改其副本,然后保存更改后的副本,这样就覆盖了原始文档。最后保存其更改副本的编辑人员覆盖另一个编辑人员所做的更改。如果在一个编辑人员完成并提交事务之前,另一个编辑人员不能访问同一文件,则可避免此问题。“更新丢失”通常是应该完全避免的。但防止更新丢失,并不能单靠数据库事务控制器来解决,需要应用程序对要更新的数据加必要的锁来解决,因此,防止更新丢失应该是应用的责任。
- 脏读(Dirty Reads):一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做"脏读"。
- 不可重复读(Non-Repeatable Reads):一个事务读取某些数据,在它结束读取之前,另一个事务可能完成了对数据行的更改。当第一个事务试图再次执行同一个查询,服务器就会返回不同的结果。
- 幻读(Phantom Reads):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。
“脏读”、“不可重复读”和“幻读”,其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决。
数据库实现事务隔离的方式,基本上可分为以下两种。
- 一种是在读取数据前,对其加锁,阻止其他事务对数据进行修改。
- 另一种是不用加任何锁,通过一定机制生成一个数据请求时间点的一致性数据快照(Snapshot),并用这个快照来提供一定级别(语句级或事务级)的一致性读取。从用户的角度来看,好像是数据库可以提供同一数据的多个版本,因此,这种技术叫做数据多版本并发控制(MultiVersion Concurrency Control,简称MVCC或MCC),也经常称为多版本数据库。
数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上 “串行化”进行,这显然与“并发”是矛盾的。同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读”和“幻读”并不敏感,可能更关心数据并发访问的能力。
为了解决“隔离”与“并发”的矛盾,ISO/ANSI SQL92定义了4个事务隔离级别
4种事务隔离级别
隔离级别就是对对事务并发控制的等级
SET [SESSION|GLOBAL] TRANSACTION ISOLATION LEVEL
READ UNCOMMITTED | READ COMMITTED | REPEATABLE READ | SERIALIZABLE
1、不带SESSION、GLOBAL的SET命令
只对下一个事务有效
2、SET SESSION
为当前会话设置隔离模式
3、SET GLOBAL
为以后新建的所有MYSQL连接设置隔离模式(当前连接不包括在内)
read uncommitted不提交的读:
即脏读,一个事务修改了一行,另一个事务也可以读到该行。
如果第一个事务执行了回滚,那么第二个事务读取的就是从来没有正式出现过的值。read committed提交的读
即不可重复读,试图通过只读取提交的值的方式来解决脏读的问题,但是这又引起了不可重复读取的问题。
事务1在两次查询的过程中,事务2对该表进行了插入、删除操作,从而事务1第二次查询的结果发生了变化。(未加锁)repeatable read(默认)
即可重复读,在一个事务对数据行执行读取或写入操作时锁定了这些数据行。
但是这种方式又引发了幻读的问题。
因为只能锁定读取或写入的行,不能阻止另一个事务插入数据,后期执行同样的查询会产生更多的结果。
- serializable可串行化
事务被强制为依次执行。这是 SQL 标准建议的默认行为。
大多数的数据库系统的默认事务隔离级别都是:Read committed
而MySQL的默认事务隔离级别是:Repeatable Read
事务锁定模式
获取InnoDB行锁争用情况
mysql> show status like 'innodb_row_lock%';
如果发现锁争用比较严重,如InnoDB_row_lock_waits和InnoDB_row_lock_time_avg的值比较高,还可以通过设置InnoDB Monitors来进一步观察发生锁冲突的表、数据行等,并分析锁争用的原因。
具体方法如下:
mysql> CREATE TABLE innodb_monitor(a INT) ENGINE=INNODB;
然后就可以用下面的语句来进行查看:
mysql> Show innodb status\G;
监视器可以通过发出下列语句来停止查看:
mysql> DROP TABLE innodb_monitor;
InnoDB实现了以下两种类型的行锁。
- 共享锁(S):又称读锁,若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
- 排他锁(X):又称写锁。若事务T对数据对象A加上X锁,事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。
另外,为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁。
- 意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
- 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。
注意:
意向锁仅仅用于表锁和行锁的共存使用。如果我们的操作仅仅涉及行锁,那么意向锁不会对我们的操作产生任何影响。在任一操作给表A的一行记录加锁前,首先要给该表加意向锁,如果获得了意向锁,然后才会加行锁,并在加行锁时判断是否冲突。如果现在有一个操作要获得表A的表锁,由于意向锁的存在,表锁获取会失败(如果没有意向锁的存在,加表锁之前可能要遍历整个聚簇索引,判断是否有行锁存在,如果没有行锁才能加表锁)。
同理,如果某一操作已经获得了表A的表锁,那么另一操作获得行锁之前,首先会检查是否可以获得意向锁,并在获得意向锁失败后,等待表锁操作的完成。也就是说:1.意向锁是表级锁,但是却表示事务正在读或写某一行记录;2.意向锁之间不会冲突, 因为意向锁仅仅代表要对某行记录进行操作,在加行锁时,会判断是否冲突;3.意向锁是InnoDB自动加的,不需用户干预。
下面列出上述锁模式的兼容情况:
| IS | IX | S | X | |
---|---|---|---|---|
IS | + | + | + | – |
IX | + | + | – | – |
S | + | – | + | – |
X | – | – | – | – |
其中:+表示兼容;–表示不兼容。如果一个事务请求的锁模式与当前的锁兼容,InnoDB就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。
事务可以通过以下语句显式的给记录集加共享锁或排他锁:
共享锁(S):SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE。
排他锁(X):SELECT * FROM table_name WHERE ... FOR UPDATE。
InnoDB行锁实现方式
InnoDB行锁是通过给索引上的索引项加锁来实现的,这一点MySQL与Oracle不同,后者是通过在数据块中对相应数据行加锁来实现的。InnoDB这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB才使用行级锁,否则,InnoDB将使用表锁!
当访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。
- create table tab_with_index(id int,name varchar(10)) engine=innodb;
- alter table tab_with_index add index id(id);
- alter table tab_with_index drop index name;
- insert into tab_with_index values(1,'1');
- insert into tab_with_index values(1,'4');
此时若
select * from tab_with_index where id = 1 and name = '1' for update;
会对两行数据都加排它锁。
当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引或普通索引,InnoDB都会使用行锁来对数据加锁。
间隙锁(Next-Key锁)
当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁(Next-Key锁)。
举例来说,假如emp表中只有101条记录,其empid的值分别是 1,2,...,100,101,下面的SQL:
Select * from emp where empid > 100 for update;
是一个范围条件的检索,InnoDB不仅会对符合条件的empid值为101的记录加锁,也会对empid大于101(这些记录并不存在)的“间隙”加锁。
InnoDB使用间隙锁的目的,一方面是为了防止幻读,以满足相关隔离级别的要求,对于上面的例子,要是不使用间隙锁,如果其他事务插入了empid大于100的任何记录,那么本事务如果再次执行上述语句,就会发生幻读;另外一方面,是为了满足其恢复和复制的需要(这一点涉及事物的回滚)。
很显然,在使用范围条件检索并锁定记录时,InnoDB这种加锁机制会阻塞符合条件范围内键值的并发插入,这往往会造成严重的锁等待。因此,在实际应用开发中,尤其是并发插入比较多的应用,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。
还要特别说明的是,InnoDB除了通过范围条件加锁时使用间隙锁外,如果使用相等条件请求给一个不存在的记录加锁,InnoDB也会使用间隙锁!
事务回滚的实现
MySQL:是SQL语句级的,在执行事务中的SQL语句前,需要先在日志缓冲写日志,记录该事务的日志序列号和执行的SQL语句。当事务提交时,必须将存储引擎的日志缓冲写入磁盘(通过innodb_flush_log_at_trx_commit来控制)。如果回滚,不是物理恢复,是逻辑恢复,因为它是通过执行相反的dml语句来实现的。而且不会回收因为insert和upate而新增加的page页的。即insert变成delete,update变成相反的update。
Oracle是基于数据库文件块的。
从上面两点可知,MySQL的恢复机制要求:在一个事务未提交前,其他并发事务不能插入满足其锁定条件的任何记录(比如上面的>100都会锁定的例子,只要不插入>100的数,就不会幻读),也就是不允许出现幻读,这已经超过了ISO/ANSI SQL92“可重复读”隔离级别的要求,实际上是要求事务要串行化。
多版本并发控制MVCC
MVCC (Multiversion Concurrency Control),即多版本并发控制技术,它使得大部分支持行锁的事务引擎,不再单纯的使用行锁来进行数据库的并发控制,取而代之的是,把数据库的行锁与行的多个版本结合起来,只需要很小的开销,就可以实现一致性非锁定读,从而大大提高数据库系统的并发性能。
为了实现MVCC,InnoDB对每一行都加上了两个隐藏的列,其中一列存储行被创建的”时间”,另外一列存储行被删除的”时间”。当然InnoDB存储的并不是绝对的时间,而是系统版本号,即记录创建版本号和删除版本号。每当一个事务开始的时候,InnoDB都会给这个事务分配一个递增的版本号,事务开始时的系统版本号会作为事务的版本号。下面在repeatable read隔离级别下,说明MVCC的具体操作:
1) SELECT
对于select语句,只有同时满足了下面两个条件的行,才能被返回:
- 创建版本号小于或者等于当前事务版本号 ,就是说记录创建是在事务中(等于的情况)或者事务启动之前。
- 行的删除版本号要么没有被定义,要么大于当前事务的版本号:行的删除版本号如果没有被定义,说明该行没有被删除过;如果删除版本号大于当前事务的版本号,说明该行是被该事务后面启动的事务删除的,由于是repeatable read隔离级别,后开始的事务对数据的影响不应该被先开始的事务看见,所以该行可能被返回。
2) INSERT
在插入操作时,记录的创建版本号改为当前事务版本号。
3) DELETE
在删除操作时,记录的删除版本号改为当前事务版本号,相当于标记为删除,而不是实际删除。
4) UPDATE
在更新操作的时候,采用的是先标记旧的那行记录为已删除,并且删除版本号改为当前事务版本号,然后插入一行新的记录。
上述策略的结果就是,在读取数据的时候,InnoDB几乎不用获得任何锁,每个查询都通过版本检查,只获得自己需要的数据版本,从而大大提高了系统的并发度。
这种策略的缺点是,每行记录都需要额外的存储空间,更多的行检查工作和一些额外的维护工作。
另外,只有read-committed和 repeatable-read 两种事务隔离级别才能使用MVCC,read-uncommited由于是读到未提交的,所以不存在版本的问题。而serializable 则会对所有读取的行加锁。
在某些情况下,用户需要显式地对数据库读取操作进行加锁以保证数据逻辑的一致性。事务可以通过以下语句显式的给记录集加共享锁或排他锁:
共享锁(S):SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE。
排他锁(X):SELECT * FROM table_name WHERE ... FOR UPDATE。
Optimize Table
是mysql中一个可以回收更多的空间、减少“碎片”(defragment)的命令。当表上的数据行被删除时,所占据的磁盘空间并没有立即被回收,使用了OPTIMIZE TABLE命令后这些空间将被回收,并且对磁盘上的数据行进行重排(注意:是磁盘上,而非数据库)。
多数时间并不需要运行OPTIMIZE TABLE,只需在批量删除数据行之后,或定期(每周一次或每月一次)进行一次数据表优化操作即可,只对那些特定的表运行。
关于死锁
MyISAM表锁不会出现死锁,这是因为MyISAM总是一次获得所需的全部锁,要么全部满足,要么等待,因此不会出现死锁。但在InnoDB中,除单个SQL组成的事务外,锁是逐步获得的,这就决定了在InnoDB中发生死锁是可能的。
两个事务都需要获得对方持有的排他锁才能继续完成事务,这种循环锁等待就是典型的死锁。
InnoDB目前处理死锁的方法是:将持有最少行级排它锁的事务回滚。如果是因为死锁引起的回滚,可以考虑在应用程序中重新执行。但在涉及外部锁,或涉及表锁的情况下,InnoDB并不能完全自动检测到死锁,这需要通过设置锁等待超时参数 innodb_lock_wait_timeout来解决。需要说明的是,这个参数并不是只用来解决死锁问题,在并发访问比较高的情况下,如果大量事务因无法立即获得所需的锁而挂起,会占用大量计算机资源,造成严重性能问题,甚至拖跨数据库。我们通过设置合适的锁等待超时阈值,可以避免这种情况发生。
通常来说,死锁都是应用设计的问题,通过调整业务流程、数据库对象设计、事务大小,以及访问数据库的SQL语句,绝大部分死锁都可以避免。介绍几种避免死锁的常用方法。
(1)在应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序来访问表,这样可以大大降低产生死锁的机会。
(2)在程序以批量方式处理数据的时候,如果事先对数据排序,保证每个线程按固定的顺序来处理记录,也可以大大降低出现死锁的可能。比如两个会话读取前十个用户的信息,每次读取一个,那么我们可以规定他们从第一个用户开始读,而不是倒序,这样不会死锁。
(3)在事务中,如果要更新记录,应该直接申请足够级别的锁,即排他锁,而不应先申请共享锁,更新时再申请排他锁,因为当用户申请排他锁时,其他事务可能又已经获得了相同记录的共享锁,从而造成锁冲突,甚至死锁。
(4) 选择合理的事务大小,小事务发生锁冲突的几率也更小;
如果出现死锁,可以用SHOW INNODB STATUS命令来确定最后一个死锁产生的原因。返回结果中包括死锁相关事务的详细信息,如引发死锁的SQL语句,事务已经获得的锁,正在等待什么锁,以及被回滚的事务等。