MySQL基础篇-全局锁、表锁及行锁

全局锁和表锁:给表加个字段怎么这么多阻碍?

数据库锁设计的初衷是处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。

根据加锁的范围,MySQL里面的锁大致可以分成全局锁,表级锁和行锁三类。

全局锁

全局锁就是对整个数据库实例加锁,MySQL提供了一个加全局读锁的方法,命令是Flush tables with read lock(FTWRL)。当你需要让整个数据库处于只读状态的时候,可以使用这个命令,之后其他线程下的语句会被阻塞:数据的增删改、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都select出来存成文本。

整库只读,是非常危险的:

  • 如果在主库上备份,那么在备份期间都不能执行更新,业务基本是那个就要停摆;

  • 如果你在从库备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。

  • 如果不加锁的化,备份系统备份得到的库不是一个逻辑时间点,这个视图逻辑是不一致的。

    回忆一可重复读的隔离级别,在事务启动的时候,创建一个视图,保证视图内数据的一致性。

官方自带的逻辑备份工具mysqldump。当mysqldump使用参数-single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是可以正常更新的。

一致性读是好的,但是前提是引擎支持这个隔离级别,所以single-transaction方法只适用于所有的表使用事务引擎的库。 如果有的表使用了不支持事务的引擎,那么备份就只能通过FTWRL。

既然要全库只读,为什么不实用 set global readonly = true 的方式呢?

  • 在有些系统中,readonly的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改global变量的方式影响面更大。
  • 在异常处理机制上有差异。如果执行FTWRL命令之后由于客户端发生异常断开,那么MySQL会自动释放这个全局锁,整个库回到可以正常跟新的状态。

表级锁

MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)

表锁的语法是 lock tables ··· read/write。与FTWRL类似,可以用unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。

需要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象

举例:如果在某个线程A中执行lock tables t1 read,t2 write;这个语句,则其他线程写t1、读写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1
,读写t2的操作。自然也不能访问其他表。

另一类表级的锁是MDL(metadata lock)

MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。

在MySQL5.5版本中引入MDL,当对一个表做增删改查操作的时候,加MDL读锁;当要对表结构变更操作的事耦,加MDL写锁。

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
  • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

假设表t是一个小表,给小表加一个字段:

我们可以看到sessionA先启动,这时候会对表t加一个MDL读锁。由于sessionB需要的也是MDL读锁,因此可以正常执行。

之后sessionC会被blocked,是因为sessionA的NDL读锁还没有释放,而sessionC需要MDL写锁,因此会被阻塞。

如果只有sessionC自己被阻塞还没关系,但是之后所有要在表t上新申请MDL读锁的请求也会被sessionC阻塞。所有对表的增删改查操作都需要先申请MDL读锁,就都被锁住,等于这个表在完全不可读写的状态了。

如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新session在请求的话,这个库的线程很快就会爆满。

事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而是会在整个事务提交之后再释放。

如何安全地给小表加字段

首先,要解决长事务,事务不提交,就会一直占着MDL锁。

理想的处理机制,在alter table 语句里面设定等待时间,如果在这个指定等待时间里面能够拿到MDL写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。

MariaDB 已经合并了AliSQL的这个功能,所以这两个开源分支目前都支持DDL NOWAIT/WAIT n 这个语法。

1
2
3

ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ...

行锁功过:怎么减少行锁对性能的影响

MySQL的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,MyISAM引擎就是不支持行锁。不支持行锁意味这并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这会影响到业务并发度。InnoDB是支持行锁的。

两阶段锁

🌰:事务B的update语句执行时会什么现象?假设字段id是表t的主键。

这个问题的结论取决与事务A在执行完两条update语句后,持有哪些锁,以及在什么时候释放锁。

实际上事务B的update语句会被阻塞,直到事务A执行commit之后,事务B才能继续执行。

也就是说,在InnoDB事务中,行锁是在需要的时候才加上,但并不是不需要了就立刻释放,而是等到事务结束时才释放。这个就是两阶段锁协议。

如果你的事务中需要锁多行,要把最可能造成锁冲突、做可能印象并发度的锁尽量往后放。

🌰:假设实现一个电影票在线交易业务,顾客A要在影院B购买电影票。我们简化一点,这个业务需要涉及到一下操作:

  1. 从顾客A账户余额中扣除电影票价;
  2. 给电影院B的账户余额增加这张电影票价;
  3. 记录一条交易日志。

也就是说,要完成这个交易,我们需要update两条记录,并insert一条记录。为了保证交易原子性,我们要把这三个操作放在一个事务中。

试想如果同时有另外一个顾客C要在影院B买票,那么这两个事务冲突的部分就是语句2了。因为需要更新同一个影院账户的余额,需要修改统一行数据。

根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放,所以,如果把语句2安排到最后,按照3、1、2这样的顺序,那么影院账户余额这行的锁时间最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。

死锁和死锁检测

当并发系统中不通线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这个几个线程进入无线等待的状态,称为死锁。

🌰:

这个时候,事务A在等待事务B释放id=2的行锁,而事务B在等待事务A释放id=1的行锁。事务A和事务B在互相等待对方的资源释放,就进入了死锁状态。

当出现死锁后有两种策略:

  • 直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout来设置。(默认50s)
  • 发起死锁检测,发现思索后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数innodb_deadlock_detect 设置为on,表示开启这个逻辑。

由于MySQL默认的死锁超时时间是50s,这样效率太低,如果设置的时间很小,又会将普通的锁等待时间打乱。

所以一般情况下采用第二种策略:主动死锁检测,而且innodb_deadlock_detect的默认值本身就是on。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是又额外负担的。

怎么解决由热点行更新导致的性能问题呢?

问题在于,死锁检查要耗费大量的CPU资源。

一种头疼医头的方法,即使如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。
但是这种操作本身有一定的风险,因为业务涉及的时候一般不会把死锁当作一个严重错误,毕竟出现了死锁,就回滚,然后通过业务充实一般就没问题了,这是业务无损的。而关掉死锁检测意味可能会出现大量的超时,这是业务有损的。

另一种思路是控制并发度根据上面的分析,如果能够控制并发,比如同一行同时更新最多只有是个线程,那么死锁检测的成本很低,就不会出现这个问题,一个直接的想法就是,在客户端做并发控制,但是,你很快就会发现这个方法不太可行,因为客户端很多。多个客户端累计之后数量很多。

因此,并发控制要做在数据库服务端。基本思路就是,对于相同行的更新,在进入引擎之前排队。这样在InnoDB内部就不会有大量的死锁检测工作。

在设计上的优化

可以考虑通过将一行改成逻辑上的多行来减少冲突。还是以影院账户为例,可以考虑放在多条记录上,比如10个记录,影院的账户总额等于这是个记录的值的总和,这样每次要给影院账户加金额的时候,随机选其中一个记录来加。