一 综述
MySQL 的锁机制相较其他的数据库比较简单,最显著的特点是不同的存储引擎支持不同的锁机制。主要有三种类型的锁
- 表级锁:开销小,加锁快;不会出现死锁;锁定粒度大,发生冲突的概率最高,并发度最低。支持表级锁的代表是 MyISAM 引擎。
- 行级锁:开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度最高。支持行级锁的代表是 InnoDB 引擎。
- 页面锁:开销和加锁时间介于表级锁和行级锁之间;会出现死锁;锁定粒度介于表级锁和行级锁之间。支持页面锁代表是 BDB 引擎。
就锁本身的特点来说,各有优势。所以具体采用什么级别的锁还是要结合实际的应用场景。比如大量查询为主,少量以索引条件的更新数据应用适合表级锁;而又大量按索引执行更新操作的应用则适合行级锁。
参考文章
InnoDB 中事务隔离级别与锁的关系-美团点评技术博客
二 MyISAM 表锁
1 查询表级锁争用情况
show status like 'table%';
如果结果字段 Table_locks_waited 的值比较高,则说明存在比较严重的锁争用。
2 表级锁的锁模式
- 表共享锁(Table Read Lock)
- 表独占写锁(Table Write Lock)
锁之间是兼容情况可以用一句话阐述:共享锁与任何锁兼容,而独占锁与任何锁都不兼容。
因此对 MyISAM 的读操作不会阻塞其他用户对同一张表的读请求,但是会阻塞对同一个表的写请求;对应的写操作会阻塞其他用户对同一张表的读/写操作。
表的读操作与写操作之间,以及写操作与写操作之间都是串行的。 当一个线程获得一个表的写锁之后,只有持有锁的线程可以对表进行更新操作,其他线程的读、写操作都会等待直到锁被释放。
2.1 应用示例
这是一个“写锁阻塞读操作”的例子
session1 中对表 test_lock 加锁并写入一条数据
lock table test_lock write;
insert into test_lock (id, value) VALUES (1, 'fail');
session2 在 session1 操作过程中(此时并没有释放锁)执行一次查询操作
SELECT * FROM test_lock;
结果发现 session2 的操作进入阻塞状态。此时再切换到 session1 将持有的写锁释放
unlock table test_lock;
释放锁后 session2 的查询操作得到结果。
2.2 如何加表锁
实际上MyISAM在执行查询语句的时候会 自动给涉及到的所有表加读锁,在执行更新操作的时候 会自动给涉及的表加写锁。即这个过程并不需要用户干预,因此用户 一般不需要使用前面示例中的语句来显式的加锁。
在一些特殊的情况下需要我们显式加锁,不过这个是为了模拟事务操作。比如两张表 order 与 order_detail 都有一个金额总计字段,现在需要检查匹配两个表的金额是否一致。
lock tables orders read local, order_detail read local;
select sum(total) from orders;
select sum(subtotal) from orders;
unlock tables;
在用 LOCK TABLE 给表显式加锁时,必须同时获取到所有涉及表的锁,并且 MyISAM 不支持锁升级。
另外,锁定一个表,还要顺带锁定这个表的所有别名,否则会报错。
lock table test_lock as a,test_lock as b;
2.3 并发插入
MyISAM表的读和写操作是串行的,这是总体上来看的。加上部分限制,MyISAM也支持在读的同时并发写。
存储引擎有两个系统变量
concurrent_insert
这个值的可选值有0,1,2三个。
- 0 设置为 0 表示 不允许并发插入
- 1 设置为 1 表示 如果表中没有空洞,则允许在一个线程读表的同时,另一个线程从表尾插入。 这也是 MyISAM 的默认选项。
- 2 设置为 2 表示 不论表中有没有空洞,都允许从表尾进行并发插入操作。
这个特性可以用来处理应用中对同一表查询和插入操作的争用。比如将这个系统变量设置为 2 的时候,总是允许并发插入,同时通过定期在系统空闲时执行 OPTIMIZE TABLE 语句来整理空间碎片,收回空洞。
2.4 MyISAM 的锁调度
一个进程在请求某个 MyISAM 表的读锁,另一个线程此时也正在请求同一个表的写锁,这种情况下总是写锁请求先获得锁,哪怕按照排队顺序读锁在写锁之前。这是 MyISAM 表不适合有大量更新操作和查询操作原因。
不过可以通过一些设置来调节这些调度行为
- 指定启动参数 low-priority-updates,让引擎默认给予 读请求优先的权利。
- 执行命令 SET LOW_PRIORITY_UPDATES = 1,使该连接发出的更新其请求优先级降低。
- 指定 INSERT, UPDATE, DELETE 语句的 LOW_PRIORITY 属性,降低该语句的优先级。
这三种办法都是要么更新优先,要么查询优先的方法,但是还是可以用来解决查询相对重要的场景中读锁等待时间过长的问题。
除此之外还有一种调度调节方法
max_write_lock_count
该参数也可以用来调节读写冲突。设置一个值后,MySQL 在一个表的读锁达到这个值后,暂时将写请求的优先级降低,给读进程一定获得锁的机会。
另外,一些需要长时间运行的查询操作应该尽量避免,如果一定要执行,可以安排到数据库压力不那么大的时间短执行,比如半夜。
三 InnoDB 锁问题
首先 InnoDB 与 MyISAM 之间最大的区别是
- 支持事务( TRANSACTION )
- 采用了行级锁
3.1 获取 InnoDB 的行锁争用情况
show status like 'innoDB_row_lock%'
// output
Innodb_row_lock_current_waits 0
Innodb_row_lock_time 181621
Innodb_row_lock_time_avg 321
Innodb_row_lock_time_max 20824
Innodb_row_lock_waits 565
字段名 | 含义 |
---|---|
Innodb_row_lock_current_waits | 当前处于等待状态的锁数量 |
Innodb_row_lock_time | 启动到现在锁定的总时间长度 |
Innodb_row_lock_time_avg | 平均每次锁定的时长(ms) |
Innodb_row_lock_time_max | 最长的一次锁定时间(ms) |
Innodb_row_lock_waits | 启动到现在总计是锁定次数 |
如果锁争用比较严重,那么字段 Innodb_row_lock_current_waits 与 Innodb_row_lock_time_avg 值都会比较高。具体的调优手段和参数这里暂且不表,我们继续了解InnoDB锁与其事务间的关系。
背景知识
MySQL事务简单回顾
3.2 行锁模式以及加锁方法
InnoDB 有以下两种类型的锁
- 共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
- 排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。
除此之外还有两种意向锁
- 意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
- 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。
意向锁是InnoDB自动加的,不需要用户干预。UPDATE/DELETE/INSERT 语句 InnoDB 会自动给涉及的数据集加排他锁。普通的 SELECT 语句 InnoDB 不加锁。不过可以在语句中显式的给数据集加共享锁或者排他锁。
在 RC(read commited)级别中,数据的读取都是不加锁的,但是数据的写入、修改和删除是需要加锁的。
InnoDB 行锁是通过给索引项加锁实现的,如果没有索引,InnoDB 将通过隐藏的聚簇索引来对记录加锁。 InnoDB 行锁有三种情形:
- Record Lock:对索引项加锁
- Gap Lock:对索引之间的“间隙”、第一条记录前的“间隙”或最后一条记录的“间隙”加锁。
- Next-key lock:前两种的组合,对记录及其前面的间隙加锁。
如果不通过索引条件检索数据,那么 InnoDB 将对表中的所有记录加锁,实际效果就和表锁一样了。
如下是一些注意事项及说明
1. 在不通过索引条件查询时,InnoDB 会锁定表中的所有记录
// session1
set autocommit = 0;
select * from test where col1 = 1 for update;
// session2
select * from test where col1 = 2 for update;
// waiting...
这是一个串行操作,在session1给表的col1字段的某条记录加了排他锁,在理想情况下session2的操作应该不会受到影响。
如果col1字段没有设置索引的话,这个阻塞操作就一定会发生。因为检索操作没有走到索引会导致 InnoDB 给所有的记录都加了行锁。这样 session2 的排他锁就无法得到从而进入阻塞状态。
2. 由于 MySQL 的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然访问不同行的记录,如果使用了相同的索引键,一样会出现锁冲突。
record1 | record2 |
---|---|
id=1 | value = 1 |
id=1 | value = 4 |
表中两条记录,两个字段id,value中仅有id字段有索引。
// session1
set autocommit = 0;
select * from table_test where id = 1 and value = '1' for update;
// session2
set autocommit = 0;
select * from table_test where id = 1 and value = '4' for update;
// waiting
虽然session2访问的是和session1不同的记录,但是因为使用了相同的索引,所以也还是需要等待锁。
3. 当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,不论是使用主键索引、唯一索引还是普通索引,InnoDB 都会使用行锁来对数据加锁。
4. 即便在条件中使用了索引字段,但是是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫描效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 也会对所有记录加锁。
3.3 Next-key 锁
当我们使用范围条件而不是相等条件检索数据,请求获得锁,InnoDB 会给符合条件的数据的索引项加锁。
select * from test where id > 100 for update;
这是一个范围检索,InnoDB 不仅会对符合条件的记录加锁,还会对大于100的“间隙(即不存在的记录)”加锁。这个检索执行的时候会阻塞100以后id数据的插入操作。
在使用范围条件检索并锁定记录时,InnoDB 的这种加锁机制会阻塞符合条件范围内键值对的并发插入,会造成严重的锁等待。因此在实际开发中,尤其是并发插入较多的应用,我们要尽量使用相等的条件来访问和更新数据,避免使用范围检索。
四 乐观锁与悲观锁
- 悲观锁:对“数据被修改”这件事情上,持保守态度。所以在处理数据的过程中会对被涉及到的数据加锁。这种思想能够最大程度上保证事务的隔离性。但是这样的思想本身也会降低设计的并发性。典型的有数据库的锁机制;Java代码中的lock与synchronized关键字。
- 乐观锁:与悲观锁相反的认知态度。放宽对数据的加锁机制。这里要提一个典型的机制-MVCC(multiVersion Concurrent Control),基于版本的并发控制。
InnoDB 提供了基于 MVCC 的并发控制机制。通过给数据行附加一个版本号来确保数据对更新的敏感。InnoDB 会在每行数据后添加两个额外的隐藏的值来实现MVCC。在实际操作中,存储的并不是时间,而是事务的版本号,每开启一个新事务,事务的版本号就会递增。
- SELECT时,读取创建版本号<=当前事务版本号,删除版本号为空或>当前事务版本号。
- INSERT时,保存当前事务版本号为行的创建版本号
- DELETE时,保存当前事务版本号为行的删除版本号
- UPDATE时,插入一条新纪录,保存当前事务版本号为行创建版本号,同时保存当前事务版本号到原来删除的行