返回目录

MySQL 锁操作

MySQL 为我们提供了行锁、表锁、页锁三种级别的锁,其中表锁开销小,加锁快;不会出现死锁;锁定力度大,发生锁冲突概率高,并发度最低。行锁开销大,加锁慢;会出现死锁;锁定粒度小,发生锁冲突的概率低,并发度高;页锁开销和加锁速度介于表锁和行锁之间;会出现死锁;锁定粒度介于表锁和行锁之间,并发度一般。每个存储引擎都可以有自己的锁策略,例如 MyISAM 引擎仅支持表级锁,而 InnoDB 引擎除了支持表级锁外,也支持行级锁(默认)。

行锁

表锁

页锁

MyISAM

BDB

InnoDB

MySQL 5.5 中,information_schema 库中新增了三个关于锁的表,亦即 innodb_trx、innodb_locks 和 innodb_lock_waits。其中 innodb_trx 表记录当前运行的所有事务,innodb_locks 表记录当前出现的锁,innodb_lock_waits 表记录锁等待的对应关系。

共享锁与排他锁

从锁的策略上来看,InnoDB 还实现了共享锁(S)与排他锁(X)。

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

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

Innodb 引擎中,对于 insert、update、delete,InnoDB 会自动给涉及的数据加排他锁(X);对于一般的 Select 语句,InnoDB 不会加任何锁。事务可以通过以下语句给显示加共享锁或排他锁:

SELECT ... LOCK IN SHARE MODE; -- 共享锁
SELECT ... FOR UPDATE; -- 排他锁

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

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

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

mysql> set autocommit = 0;
/* 共享锁 */
-- 当前 session 对 actor_id=178 的记录加 share mode 的共享锁:
mysql> select actor_id,first_name,last_name from actor where actor_id = 178 lock in share mode;
-- 当前session对锁定的记录进行更新操作,等待锁:
mysql> update actor set last_name = 'MONROE T' where actor_id = 178;
-- 其他session仍然可以查询记录,并也可以对该记录加share mode的共享锁:
mysql> select actor_id,first_name,last_name from actor where actor_id = 178lock in share mode;
-- 其他session也对该记录进行更新操作,则会导致死锁退出:
mysql> update actor set last_name = 'MONROE T' where actor_id = 178;
-- ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
-- 获得锁后,可以成功更新:
-- Query OK, 1 row affected (17.67 sec)
/* 排他锁 */
-- 当前session对actor_id=178的记录加for update的排它锁:
mysql> select actor_id,first_name,last_name from actor where actor_id = 178 for update;
-- 其他session可以查询该记录,但是不能对该记录加共享锁,会等待获得锁:
mysql> select actor_id,first_name,last_name from actor where actor_id = 178 for update;
-- 当前session可以对锁定的记录进行更新操作,更新后释放锁:
mysql> update actor set last_name = 'MONROE T' where actor_id = 178;
mysql> commit;
-- 其他session获得锁,得到其他session提交的记录:
mysql> select actor_id,first_name,last_name from actor where actor_id = 178 for update;

值得一提的是,普通的查询操作都是非锁定读,会基于 MVCC 来实现多版本并发控制。如果存在事务冲突,会利用 Undo Log 获取新事务操作之前的镜像返回,在读已提交的隔离级别下,会获取新事务修改前的最新的一份已经提交的数据,而在可重复读的隔离级别下,会读取该事务开始时的数据版本。当有多个事务并发操作同一行记录时,该记录会同时存在多个 Undo Log。

而加锁之后就变成了锁定读,所有的锁定读都是当前读,也就是读取当前记录的最新版本,不会利用 Undo Log 读取镜像。另外所有的 insert、update、delete 操作也是当前读,update、delete 会在更新之前进行一次当前读,然后加锁,而 insert 因为会触发唯一索引检测,也会包含一个当前读。

行锁与表锁

InnoDB 行锁是通过给索引上的索引项加锁来实现的,这一点 MySQL 与 Oracle 不同,后者是通过在数据块中对相应数据行加锁来实现的。InnoDB 这种行锁实现特点意味着:只有通过索引条件检索数据,InnoDB 才使用行级锁,否则,InnoDB 将使用表锁,同样地,当 for update 的记录不存在会导致锁住全表。当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引或普通索引,InnoDB 都会使用行锁来对数据加锁。

mysql> select * from tab_no_index where id = 1 for update;
--- 添加索引
mysql> alter table tab_with_index add index id(id);
--- 在没有索引的情况下,InnoDB 只能使用表锁,会导致下述查询语句等待
--- 当我们给其增加一个索引后,InnoDB 就只锁定了符合条件的行,不会出现锁等待
mysql> select * from tab_no_index where id = 2 for update;

由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。譬如当 id 字段有索引,name 字段没有索引:

mysql> select * from tab_with_index where id = 1 and name = '1' for update;
--- 虽然 session_2 访问的是和 session_1 不同的记录,但是因为使用了相同的索引,所以需要等待锁
mysql> select * from tab_with_index where id = 1 and name = '4' for update;

即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。因此,在分析锁冲突时,别忘了检查 SQL 的执行计划,以确认是否真正使用了索引。

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

  • 事务需要更新大部分或全部数据,表又比较大,如果使用默认的行锁,不仅这个事务执行效率低,而且可能造成其他事务长时间锁等待和锁冲突,这种情况下可以考虑使用表锁来提高该事务的执行速度。

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

--- 写表t1并从表t读
SET AUTOCOMMIT=0;
LOCK TABLES t1 WRITE, t2 READ, ...;
[do something with tables t1 and t2 here];
COMMIT;
UNLOCK TABLES;

死锁

死锁是指两个或两个以上事务因争夺资源而互相等待的情况,MyISAM 表锁是 deadlock free 的,这是因为 MyISAM 总是一次获得所需的全部锁,要么全部满足,要么等待,因此不会出现死锁。但在 InnoDB 中,除单个 SQL 组成的事务外,锁是逐步获得的,这就决定了在 InnoDB 中发生死锁是可能的。

--- session 1 获取 table_1 中排他锁
mysql> set autocommit = 0;
mysql> select * from table_1 where where id=1 for update;
--- session 2 获取 table_2 中排他锁
mysql> select * from table_2 where id=1 for update;
--- session 1 申请 table_2 锁,等待 session_2 完毕
mysql> select * from table_2 where id =1 for update;
--- session 2 申请 table_1 锁,等待 session_1 完毕
mysql> select * from table_1 where where id=1 for update;

发生死锁后,InnoDB 一般都能自动检测到,并使一个事务释放锁并回退,另一个事务获得锁,继续完成事务。但在涉及外部锁,或涉及表锁的情况下,InnoDB 并不能完全自动检测到死锁,这需要通过设置锁等待超时参数 innodb_lock_wait_timeout 来解决。需要说明的是,这个参数并不是只用来解决死锁问题,在并发访问比较高的情况下,如果大量事务因无法立即获得所需的锁而挂起,会占用大量计算机资源,造成严重性能问题,甚至拖跨数据库。我们通过设置合适的锁等待超时阈值,可以避免这种情况发生。

死锁都是应用设计的问题,通过调整业务流程、数据库对象设计、事务大小,以及访问数据库的 SQL 语句,绝大部分死锁都可以避免。

  • 在应用中,如果不同的程序会并发存取多个表,应尽量约定以相同的顺序来访问表,这样可以大大降低产生死锁的机会。例如将热点账户拆分成若个个同样功能的账户,万一发生高并发,建议在应用层做限流或者排队,当然也可以在数据库层做排队,这个需要修改数据库源码。

  • 在程序以批量方式处理数据的时候,如果事先对数据排序,保证每个线程按固定的顺序来处理记录,也可以大大降低出现死锁的可能。

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

间隙锁

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

举例来说,假如 emp 表中只有 101 条记录,其 empid 的值分别是 1,2,...,100,101,mysql> select * from emp where empid > 100 for update;就是一个范围条件的检索,InnoDB 不仅会对符合条件的 empid 值为 101 的记录加锁,也会对 empid 大于 101 这些记录并不存在的间隙加锁。

InnoDB 使用间隙锁的目的,一方面是为了防止幻读,以满足相关隔离级别的要求,对于上面的例子,要是不使用间隙锁,如果其他事务插入了 empid 大于 100 的任何记录,那么本事务如果再次执行上述语句,就会发生幻读;另外一方面,是为了满足其恢复和复制的需要。有关其恢复和复制对锁机制的影响,以及不同隔离级别下 InnoDB 使用间隙锁的情况,在后续的章节中会做进一步介绍。

很显然,在使用范围条件检索并锁定记录时,InnoDB 这种加锁机制会阻塞符合条件范围内键值的并发插入,这往往会造成严重的锁等待。因此,在实际应用开发中,尤其是并发插入比较多的应用,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。还要特别说明的是,InnoDB 除了通过范围条件加锁时使用间隙锁外,如果使用相等条件请求给一个不存在的记录加锁,InnoDB 也会使用间隙锁。

--- 当前session对不存在的记录加for update的锁:
mysql> select * from emp where empid = 102 for update;
--- 这时,如果其他session插入empid为102的记录(注意:这条记录并不存在),也会出现锁等待:
mysql>insert into emp(empid,...) values(102,...);
--- session 1 执行 rollback:
mysql> rollback;
--- 由于其他session_1回退后释放了Next-Key锁,当前session可以获得锁并成功插入记录:
mysql>insert into emp(empid,...) values(102,...);

加锁流程浅析

InnoDB 的加锁过程比较复杂,将所有扫描到的记录都加锁,范围查询会加间隙锁,然后加锁过程按照两阶段锁 2PL 来实现,也就是先加锁,然后所有的锁在事物提交的时候释放。加锁的策略会和数据库的隔离级别有关,在默认的可重复读的隔离级别的情况下,加锁的流程还会和查询条件中是否包含索引,是主键索引还是普通索引,是否是唯一索引等有关。

譬如对于 select * from o_order where order_sn = '201912102322' for update; 这条 SQL 语句,在不同的索引情况下其加锁策略也不一致:

  • order_sn 是主键索引,这种情况将在主键索引上的 order_sn = 201912102322 这条记录上加排他锁。

  • order_sn 是普通索引,并且是唯一索引,将会对普通索引上对应的一条记录加排他锁,对主键索引上对应的记录加排他锁。

  • order_sn 是普通索引,并且不是唯一索引,将会对普通索引上 order_sn = 201912102322 一条或者多条记录加锁,并且对这些记录对应的主键索引上的记录加锁。这里除了加上行锁外,还会加上间隙锁,防止其他事务插入 order_sn = 201912102322 的记录,然而如果是唯一索引就不需要间隙锁,行锁就可以。

  • order_sn 上没有索引,innoDB 将会在主键索引上全表扫描,这里并没有加表锁,而是将所有的记录都会加上行级排他锁,而实际上 innoDB 内部做了优化,当扫描到一行记录后发现不匹配就会把锁给释放,当然这个违背了 2PL 原则在事务提交的时候释放。这里除了对记录进行加锁,还会对每两个记录之间的间隙加锁,所以最终将会保存所有的间隙锁和 order_sn = 201912102322 的行锁。

  • order_sn = 201912102322 这条记录不存在的情况下,如果 order_sn 是主键索引,则会加一个间隙锁,而这个间隙是主键索引中 order_sn 小于 201912102322 的第一条记录到大于 201912102322 的第一条记录。试想一下如果不加间隙锁,如果其他事物插入了一条 order_sn = 201912102322 的记录,由于 select for update 是当前读,即使上面那个事物没有提交,如果在该事物中重新查询一次就会发生幻读。

  • 如果没有索引,则对扫描到的所有记录和间隙都加锁,如果不匹配行锁将会释放只剩下间隙锁。回忆一下上面讲的数据页的结果中又一个最大记录和最小记录,Infimum 和 Supremum Record,这两个记录在加间隙锁的时候就会用到。