【转】MySQL 的表锁和行锁

转自:
https://mp.weixin.qq.com/s/8LrPHG7XtsvNJJs58yK-0g

锁是计算机协调多个进程或者纯线程并发访问某一资源的机制。

相对于其他数据库而言,MySQL 的锁机制比较简单,其最显著的特点是不同的存储引擎支持不同的锁机制。

MySQL 大致可归纳为以下 3 种锁:

  • 表级锁:开销小,加锁快,不会出现死锁,锁定粒度大,发生锁冲突的概率最高,并发度最低
  • 行级锁:开销大,加锁慢,会出现死锁,锁定粒度最小,发生锁冲突的概率最低,并发度也最高
  • 页面锁:开销和加锁时间界于表锁和行锁之间,会出现死锁,锁定粒度界于表锁和行锁之间,并发度一般

MySQL 表级锁的锁模式(MyISAM)

MySQL 表级锁有两种模式:表共享读锁(Table Read Lock)和表独占写锁(Table Write Lock)。

  • 对 MyISAM 的读操作,不会阻塞其他用户对同一表的读请求,但是会阻塞对同一表的写请求
  • 对 MyISAM 的写操作,会阻塞其他用户对同一表的读和写操作
  • MyISAM 表的读操作和写操作之间,以及写操作之间是串行的

当一个线程获得对一个表的写锁后,只有持有锁的线程可以对表进行更新操作。其他线程的读写操作都会等待,直到锁被释放为止。

MySQL 中的表锁兼容性

【转】MySQL 的表锁和行锁_第1张图片

如何加表锁?

MyISAM 在执行 select 查询语句前,会自动给涉及到的所有表加读锁,在执行更新操作(update、delete、insert 等)前,会自动给涉及到的表加写锁,这个过程并不需要用户干预,因此用户一般不需要直接用 LOCK TABLES 命令给 MyISAM 表显式加锁。

给 MyISAM 表显示加锁,一般是为了在一定程度上模拟事务操作,实现在某一时间点对多个表的一致性读取。

例如,有一个订单表 orders,其中记录有订单的总金额 total,同时还有一个订单明细表 order_detail,其中记录有订单中每件产品的金额小计 subtotal,假设我们需要检查这两个表的金额合计是否相等。

SELECT SUM(total) FROM orders;
SELECT SUM(subtotal) FROM order_detail;

这时,如果不先给这两个表加锁,就有可能产生错误的结果,因为在第一条语句的执行过程中,order_detail 表可能已经发生了改变。

LOCK tables orders read local, order_detail read local;
SELECT SUM(total) FROM orders;
SELECT SUM(subtotal) FROM order_detail;
Unlock tables;

特别说明以下两点内容:

  • 上面的例子中在 LOCK TABLES 时加了 local 选项,其作用是为了在满足 MyISAM 表并发插入条件的情况下,允许其他用户在表尾插入记录
  • 在用 LOCK TABLES 给表显式加表锁时,必须同时取得所有涉及表的锁,并且 MySQL 支持锁升级。也就是说,在执行 LOCK TABLES 后,只能访问显式加锁的这些表,不能访问未加锁的表。同时,如果加的是读锁,那么只能执行查询操作,而不能执行更新操作。在自动加锁的情况下也基本如此,MySQL 会一次性获得 SQL 语句所需要的全部锁,这也正是 MyISAM 表不会出现死锁(Deadlock Free)的原因。

一个 Session 使用 LOCK TABLES 命令给表加了读锁,那么这个 Session 可以查询锁定表中的记录,但是更新或者访问其他表都会提示错误。同时,另外一个 Session 可以查询表中的记录,但是更新就会出现锁等待。

使用 LOCK TABLES 时,不仅需要一次性锁定用到的所有表,而且同一个表在 SQL 语句中出现了多少次,就要通过与 SQL 语句中相同的别名锁多少次,否则也会出错!

并发锁

在一定条件下,MyISAM 也支持查询和操作的并发进行。

MyISAM 有一个系统变量 concurrent_insert,专门用来控制其并发插入的行为,其值可以为 0、1 或 2:

  • 当 concurrent_insert 设置为 0 时,不允许并发插入
  • 当 concurrent_insert 设置为 1 时,MyISAM 允许一个进程在读表的同时,另一个进程从表尾插入记录,这也是 MySQL 的默认设置
  • 当 concurrent_insert 设置为 2 时,无论 MyISAM 表中有没有空洞,都允许在表尾并发插入记录

我们可以利用 MyISAM 的并发插入特性,来解决应用中对同一表查询和插入的锁争用。

例如,将 concurrent_insert 系统变量设置为 2,总是允许并发插入。同时,通过定期在系统空闲时段执行 OPTIONMIZE TABLE 语句来整理空间碎片,回收因删除记录而产生的中间空洞。

MyISAM 的锁调度

前面讲过,MyISAM 的读锁和写锁是互斥的,读操作是串行的。

那么,当一个进程请求某个 MyISAM 表的读锁,同时另一个进程也请求同一表的写锁时,MySQL 会如何处理呢?

答案是写进程先获得锁。

不仅如此,即使读进程先请求,先到锁等待队列,写请求后到,写请求也会插到读请求之前!

这是因为 MySQL 认为写请求一般比读请求更重要。这也正是 MyISAM 表不太适合有大量更新操作和查询操作应用的原因,因为大量的更新操作会造成查询操作很难获得读锁,从而可能永远阻塞。

我们也可以通过一些设置来调节 MyISAM 的调度行为:

  • 通过指定启动参数 low-priority-updates,使 MyISAM 默认给予读请求以优先的权利
  • 通过执行命令SET LOW_PRIORITY_UPDATES = 1,使该连接发出的更新请求优先级降低
  • 通过指定 insert、update、delete 语句的 LOW_PRIORITY 属性,降低该语句的优先级

另外,MySQL 也提供了一种折中的办法来调节读写冲突,即给系统参数 max_write_lock_count 设置一个合适的值,当一个表的读锁达到这个值后,MySQL 便暂时将写请求的优先级降低,给读进程获得锁的机会。

这里还要强调一点:一些需要长时间运行的查询操作,也会使写进程“饿死”!

因此,应用中应尽量避免出现长时间运行的查询操作,不要总想用一条 select 语句来解决问题。因为这种看似巧妙的 SQL 语句,往往比较复杂,执行时间较长。

可以通过使用中间表等措施对 SQL 语句做一定的“分解”,使每一步查询都能在较短的时间内完成,从而减少锁冲突。如果复杂查询不可避免,则应尽量安排在数据库空闲时段执行,比如将一些定期统计安排在夜间执行。

InnoDB 锁问题

InnoDB 与 MyISAM 最大的不同有两点:

  • 一是支持事务(Transaction)
  • 二是采用了行级锁

行级锁和表级锁本来就有许多不同之处,另外事务的引入也带来了一些新问题。

事务(Transaction)及其 ACID 属性

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

  • 原子性(Actomicity):事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行
  • 一致性(Consistent):在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以操持完整性。事务结束时,所有的内部数据结构(如 B 树索引或者双向链表)也都必须是正确的
  • 隔离性(Isolation):数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境内执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然
  • 持久性(Durable):在事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持

并发事务带来的问题

相对于串行处理来说,并发事务处理能大大增加对数据库资源的利用率,提高数据库系统的事务吞吐量,从而可以支持更多的用户。

但并发事务处理也会带来一些问题,主要包括以下几种情况:

  • 更新丢失(Lost Update):当两个或多个事务选择了同一行,然后都基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在,就会发生更新丢失问题,最后的更新覆盖了其他事务所做的更新。例如,两个编辑人员制作了同一文档的电子副本,每个编辑人员独立地更改其副本,然后保存更改后的副本,这样就覆盖了原始文档。最后保存其更改副本的编辑人员覆盖了另一个编辑人员所做的修改。如果在一个编辑人员完成并提交事务之前,另一个编辑人员不能访问同一文件,则可避免此问题
  • 脏读(Dirty Reads):一个事务正在对一条记录做修改,在这个事务提交前,这条记录的数据就处于不一致状态。这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”的数据,并据此做进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象地叫做“脏读”
  • 不可重复读(Non-Repeatable Reads):一个事务在读取某些数据时,数据已经发生了改变或者某些记录已经被删除了,这种现象叫做“不可重复读”
  • 幻读(Phantom Reads):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”

事务隔离级别

在并发事务处理带来的问题中,“更新丢失”通常应该是要完全避免的。

但防止更新丢失,并不能单靠数据库事务控制器来解决,还需要应用程序对要更新的数据加必要的锁来解决,因此,防止更新丢失应该是应用的责任。

“脏读”、“不可重复读”和“幻读”,本质上都是数据库读一致性的问题,必须由数据库提供一定的事务隔离机制来解决。

数据库实现事务隔离的方式,基本可以分为以下两种:

  • 一种是在读取数据前,对其加锁,阻止其他事务对数据进行修改
  • 另一种是不加任何锁,通过一定的机制生成一个数据请求时间节点的一致性数据快照(Snapshot),并利用这个快照来提供一定级别(语句级或事务级)的一致性读取。从用户的角度看,就好像是数据库可以提供同一份数据的多个版本,因此,这种技术叫做数据多版本并发控制(MultiVersion Concurrency Control,简称 MVCC 或 MCC),也经常称为多版本数据库。

数据库的事务隔离级别越严格,并发的副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读”和“幻读”并不敏感,可能更关心数据并发访问的能力。

应用可以根据自己的实际业务逻辑需求,通过选择不同的隔离级别来平衡“隔离”与“并发”的矛盾。

事务的4种隔离级别

【转】MySQL 的表锁和行锁_第2张图片

InnoDB 的行锁模式以及加锁方法

InnoDB 实现了以下两种类型的行锁:

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

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

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

InnoDB 行锁模式兼容性列表

【转】MySQL 的表锁和行锁_第3张图片

如果一个事务请求的锁模式与当前的锁兼容,InnoDB 就把请求的锁授予该事务;反之如果两者不兼容,该事务就要等待锁释放。

意向锁是 InnoDB 自动加的,不需要用户干预。

对于 update、delete 和 insert 语句,InnoDB 会自动给涉及到的数据集加排他锁;对于普通的 select 语句,InnoDB 不会加任何锁。

事务可以通过以下语句显式地给记录集加共享锁或排他锁:

  • 共享锁:SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
  • 排他锁:SELECT * FROM table_name WHERE ... FOR UPDATE

SELECT .. LOCK IN SHARE MODE获得共享锁,主要是用在需要数据依存关系时,确认某行记录是否存在,并确保没有人对这个记录进行 update 或者 delete 操作。

但是如果当前事务也需要对该记录进行更新操作,则很有可能会造成死锁,对于锁定行记录后需要进行更新操作的应用,应该使用SELECT ... FOR UPDATE方式获得排他锁。

InnoDB 行锁的实现方式

InnoDB 行锁是通过索引上的索引项来实现的,这就意味着:只有通过索引条件检索数据时,InnoDB 才会使用行级锁,否则 InnoDB 将使用表级锁!

在实际应用中,要特别注意 InnoDB 行锁的这一特性,不然可能会导致大量的锁冲突,从而影响并发性能。

间隙锁(Next-Key 锁)

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

假如 emp 表中只有 101 条记录,其 empid 的值分别是 1、2 … 100、101。

SELECT * FROM emp WHERE empid > 100 FOR UPDATE;

这是一个范围条件的检索,InnoDB 不仅会对符合条件的 empid 值为101的记录加锁,同时也会对 empid 大于101(这些记录并不存在)的“间隙”加锁。

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

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

因此,在实际开发中,尤其是在并发插入比较多的应用中,我们要尽量优化业务逻辑,尽量使用相等条件来访问更新数据,避免使用范围条件。

什么时候使用表锁?

对于 InnoDB 表,在绝大部分情况下都应该使用行级锁,因为事务和行锁往往是我们选择 InnoDB 表的理由。

但在个别的特殊事务中,也可以考虑使用表级锁:

  • 事务需要更新大部分或者全部数据,表又比较大,如果使用默认的行锁,不仅这个事务的执行效率低,而且还可能会造成其他事务长时间的锁等待和锁冲突,这种情况下可以考虑使用表锁来提高该事务的执行速度
  • 事务涉及多个表,比较复杂,很可能引起死锁,造成大量的事务回滚。这种情况也可以考虑一次性锁定事务涉及的所有表,从而避免死锁、减少数据库因事务回滚带来的开销。

当然,应用中这两种事务不能太多,否则就应该考虑使用 MyISAM 表。

在 InnoDB 下使用表锁要注意以下两点:

  • 使用 LOCK TALBES 虽然可以给 InnoDB 加表级锁,但是表锁不是由 InnoDB 存储引擎层管理的,而是由其上一层的 MySQL Server 负责的。仅当 autocommit = 0、innodb_table_lock = 1(默认设置)时,InnoDB 层才能知道 MySQL 加的表锁,MySQL Server 才能感知到 InnoDB 加的行锁。在这种情况下,InnoDB 才能自动识别涉及表级锁的死锁,否则 InnoDB 将无法自动检测并处理这种死锁
  • 使用 LOCAK TABLES 对 InnoDB 加表级锁时,要注意将 AUTOCOMMIT 设为 0,否则 MySQL 不会给表加锁。事务结束之前,不要用 UNLOCAK TABLES 释放表锁,因为 UNLOCK TABLES 会隐含地提交事务。COMMIT 或 ROLLBACK 不能释放用 LOCAK TABLES 加的表级锁,必须用 UNLOCK TABLES 释放表锁。

例如需要写表 t1,并从表 t2 读。

SET AUTOCOMMIT = 0;
LOCAK TABLES t1 WRITE, t2 READ, ...;
// do something with tables t1...
COMMIT;
UNLOCK TABLES;

关于死锁

MyISAM 表锁是 Deadlock free 的,这是因为 MyISAM 总是一次性获得所需的全部锁,要么全部满足,要么等待,因此不会出现死锁。但是在 InnoDB 中,除了单个 SQL 组成的事务外,锁是逐步获得的,这就意味着 InnoDB 是有可能发生死锁的。

发生死锁后,InnoDB 一般都能自动检测到,并使一个事务释放锁并退回,另一个事务获得锁,继续完成事务。

但在涉及外部锁或者涉及表锁的情况下,InnoDB 并不能完全自动检测到死锁,这需要通过设置锁等待超时参数 innodb_lock_wait_timeout 来解决。

需要说明的是,这个参数并不是只用来解决死锁问题,在并发访问比较高的情况下,如果大量的事务因无法立即获取所需的锁而挂起,会占用大量的计算机资源,从而造成严重的性能问题,甚至拖垮数据库。我们可以通过设置合适的锁等待超时阈值,来避免这种情况的发生。

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

如果出现死锁,可以用 SHOW INNODB STATUS 命令来确定最后一个死锁产生的原因和改进措施。

你可能感兴趣的:(MySQL,数据库)