任何使用锁来控制资源共享的系统,锁的竞争问题都不好调试。当我们给某个表增加一列新字段,或者只是进行査询,就有可能发现其他请求锁住了操作的表或者行。此时,通常你所想做的事就是找出査询阻塞的原因,从而知道该杀死哪个进程。这个附录显示了如何达到这两个目标。
MySQL服务器本身使用了几种类型的锁。如果査询正在等待一个服务器级别的锁,那么可以在SHOW PROCESSLIST的输出中看到蛛丝马迹。除了服务器级別的锁,任何支持行级别锁的存储引擎,例如InnoDB,都实现了自己的锁。在MySQL5.0和更早版本中,服务器层无法主动识别这些锁,它们往往对用户和数据库管理员不可见。在MySQL5.1和后续版本中可见性有了提高。
锁等待可能发生在服务器级别或存储引擎级别。(应用程序级别的锁可能也是一个问题,但我们在此只关注MySQL。)下面是MySQL服务器使用的几种类型的锁。
表锁
表可以被显式的读锁和写锁进行锁定。这些锁有许多的变种,例如本地读锁。你可以在MySQL手册LOCK TABLES部分了解到这些变种。除了这些显式的锁外,查询过程中还有隐式的锁。
全局锁
可以通过FLUSH TABLES WITH READ LOCK或设置read_only=1来获取单个全局读锁。
它与任何表锁都冲突。
命名锁
命名锁是表锁的一种,服务器在重命名或删除一个表时创建。
字符锁
你可以用GET_LOCK()及其相关函数在服务器级别内锁住和释放任意一个字符串。在接下来的章节中我们将更详细地査看每种类型的锁。
表锁
表锁既可以是显式的也可以是隐式的。显式的锁用LOCK TABLES创建。例如,如果在mysql会话中执行下列命令,将在sakila.film上获得一个显式的锁。
mysql> LOCK TABLES sakila.film READ;
如果再在另外一个会话中执行如下的命令,査询会挂起并且不会完成。
mysql> LOCK TABLES sakila.film WRITE;
你可以在第一个连接中看到等待线程。
mysql> SHOW PROCESSLIST\G *************************** 1. row *************************** Id: 7 User: baron Host: localhost db: NULL Command: Query Time: 0 State: NULL Info: SHOW PROCESSLIST *************************** 2. row *************************** Id: 11 User: baron Host: localhost db: NULL Command: Query Time: 4 State: Locked Info: LOCK TABLES sakila.film WRITE 2 rows in set (0.01 sec)
可以注意到线程11的状态是Locked。在MySQL服务器代码中只有一个线程会进入此状态:当一个线程持有该锁后,其他线程只能不断尝试获取。因而,如果看到这样的信息,你就知道线程在等待一个MySQL服务器中的锁,而不是存储引擎的。
然而,显式锁并不是阻塞这样一个操作的唯一类型的锁。我们前面也提到,服务器在査询过程中会隐式地锁住表。用一个长时间运行的査询可以很容易地展示这一点,长时间査询可以通过SLEEP()函数轻松创建。
mysql> SELECT SLEEP(30) FROM sakila.film LIMIT 1;
当这个査询运行时,如果你再次尝试锁sakila.film,操作会因隐式锁而挂起,就如同有显式锁一样。你会在进程列表中看到和之前一样的效果:
mysql> SHOW PROCESSLIST\G *************************** 1. row *************************** Id: 7 User: baron Host: localhost db: NULL Command: Query Time: 12 State: Sending data Info: SELECT SLEEP(30) FROM sakila.film LIMIT 1 *************************** 2. row *************************** Id: 11 User: baron Host: localhost db: NULL Command: Query Time: 9 State: Locked Info: LOCK TABLES sakila.film WRITE
在本例中,SELECT査询的隐式读锁阻塞了LOCK TABLES中所请求的显式写锁。另外,隐式锁也会相互阻塞。
你可能想知道关于隐式锁和显式锁的差异。从内部来说,它们有相同的结构,由相同的MySQL服务器代码来控制。从外部来说,你可以通过LOCK TABLES和UNLOCK TABLES来控制显式锁。
然而,当涉及非MyISAM存储引擎时,它们之间有一个非常重要的区别。当创建显式锁时,它会按你的指令来做,但隐式锁就比较隐蔽并“有魔幻性”。服务器会在需要时自动地创建和释放隐式锁,并将它们传递给存储引擎。存储引擎感知到后,可能会“转换”这些锁。例如,InnoDB有这样的相关规则:对一个给定的服务器级别的表锁,InnoDB应该为其创建特定类型的InnoDB表锁。这也使得操作人很难理解InnoDB幕后到底做了什么。
找出谁持有锁
如果你看到许多的进程处于Locked状态,问题可能出在对MyISAM或者其他类似存储引擎的髙并发访问。这会阻止你执行人工操作,例如给表增加索引等。如果一个UPDATE査询进入队列并等待MyISAM的表锁,此时就连SELECT也不会被允许运行。(关于MySQL锁队列和优先级,可以在MySQL用户手册中査到更多。)
在某些场景下,可以清楚地看到几个连接长时间持有某个锁,此时需要将它们杀死(或需要劝告用户不要阻挡这些连接的工作!)但是如何找出那个连接呢?
目前没有SQL命令可以显示哪个线程持有阻塞你的査询的表锁。如果运行SHOW PROCESSLIST,你会看到等待锁的进程,而不是哪个进程持有这些锁。幸运的是,有一个debug命令可以打印关于锁的信息到服务器的错误日志中,你可以使用mysqladmin工具来运行这个命令:
$ mysqladmin debug
在错误日志的输出中包括了许多的调试信息,在接近尾部可以看到像下面的一些信息。我们是这样创建这些输出的:在一个连接中锁住表,然后在另外一个连接中尝试再次对它加锁。
Thread database.table_name Locked/Waiting Lock_type 7 sakila.film Locked - read Read lock without concurrent inserts 8 sakila.film Waiting - write Highest priority write lock
可以看到线程8正在等待线程7持有的锁。
MySQL服务器还实现了一个全局读锁,可以如下获取该锁。
mysql> FLUSH TABLES WITH READ LOCK;
如果此时在另外一个会话中尝试再锁这个表,结果会像之前一样挂起。
mysql> LOCK TABLES sakila.film WRITE;
如何判断这个査询正在等待全局读锁而不是一个表级别的锁?请看SHOW PROCESSLIST的输出。
mysql> SHOW PROCESSLIST\G ... *************************** 2. row *************************** Id: 22 User: baron Host: localhost db: NULL Command: Query Time: 9 State: Waiting for release of readlock Info: LOCK TABLES sakila.film WRITE
注意,査询的状态是Waiting for release of readlock。这就是说査询正在等待一个全局读锁而不是表级别锁。
MySQL没有提供査出谁持有全局读锁的方法。
命名锁
命名锁是一种表锁:服务器在重命名或删除一个表时创建。命名锁与普通的表锁相冲突,无论是隐式的还是显式的。例如,如果和之前一样使用LOCK TABLES,然后在另外一个会话中尝试对此表重命名,査询会挂起,但这次不是处于Locked状态。
mysql> RENAME TABLE sakila.film2 TO sakila.film;
和前面一样,从进程列表找到获得锁的进程,其状态是Waiting for table。
mysql> SHOW PROCESSLIST\G ... *************************** 2. row *************************** Id: 27 User: baron Host: localhost db: NULL Command: Query Time: 3 State: Waiting for table Info: rename table sakila.film to sakila.film 2
也可以在SHOW OPEN TABLES输出中看到命名锁的影响。
mysql> SHOW OPEN TABLES; +----------+-----------+--------+-------------+ | Database | Table | In_use | Name_locked | +----------+-----------+--------+-------------+ | sakila | film_text | 3 | 0 | | sakila | film | 2 | 1 | | sakila | film2 | 1 | 1 | +----------+-----------+--------+-------------+ 3 rows in set (0.00 sec)
注意,两个名字(原奏和新名)都被锁住了。sakila_film_text因sakila.film上有个指向它的触发器而被锁,这也解释了另外一种锁方式,它们可以暗地里将自己放置到预期之外的地方。査询sakila.film,触发器会使你悄悄地接触sakila.film_text,因而隐式地锁住它。触发器实际不需要因重命名触发,确实如此,因此从技术上讲并不需要锁,但事实是:MySQL的锁有时可能并不具有你所期望的细粒度。
MySQL并没有提供任何一种方法来査明谁拥有命名锁,但这通常并不是问题,因为它们一般持有非常短的一段时间。当有冲突时,一般是由于命名锁在等待一个普通的表锁,而这通过先前展示的mysqladmin debug可以看到。
在服务器中实现的最后一种锁是用户锁,它基本是一个命名互斥量。你需要指定锁的名称字符串,以及等待超时秒数。
mysql> SELECT GET_LOCK('my lock', 100); +--------------------------+ | GET_LOCK('my lock', 100) | +--------------------------+ | 1 | +--------------------------+ 1 row in set (0.00 sec)
指令成功返回,这个线程就在命名互斥量上持有了一把锁。如果另外一个线程此时尝试锁相同的字符串,它将会挂起直到超时。这次进程列表显示了一个不同的进程状态。
mysql> SHOW PROCESSLIST\G *************************** 1. row *************************** Id: 22 User: baron Host: localhost db: NULL Command: Query Time: 9 State: User lock Info: SELECT GET_LOCK('my lock', 100)
User lock状态是这种类型的锁独有的。MySQL没有提供査明谁拥有用户锁的方法。
服务器级的锁要比存储引擎中的锁容易调试得多。各个存储引擎的锁互不相同,并且存储引擎可能不提供任何方法来査看内部的锁。本附录主要关注InnoDB。
InnoDB在SHOW INNODB STATUS的输出中显露了一些锁信息。如果事务正在等待某个锁,这个锁会显示在SHOW INNODB STATUS输出的TRANSACTIONS部分中。例如,如果在一个会话中执行下面的命令,将需要表中第一行的写锁。
mysql> SET AUTOCOMMIT=0; mysql> BEGIN; mysql> SELECT film_id FROM sakila.film LIMIT 1 FOR UPDATE;
如果在另外一个会话中运行相同的命令,査询将会因第一个会话中在那一行获取的锁而阻塞。可以在SHOW INNODB STATUS中看到影响(为了简洁起见我们对结果有所删减)。
1 LOCK WAIT 2 lock struct(s), heap size 1216 2 MySQL thread id 8, query id 89 localhost baron Sending data 3 SELECT film_id FROM sakila.film LIMIT 1 FOR UPDATE 4 ------- TRX HAS BEEN WAITING 9 SEC FOR THIS LOCK TO BE GRANTED: 5 RECORD LOCKS space id 0 page no 194 n bits 1072 index `idx_fk_language_id` of table `sakila/film` trx id 0 61714 lock_mode X waiting
最后一行显示査询在等待该表的idx_fk_language_id索引的194页上一个排他锁(lock_mode X)。最终,锁等待超时,査询返回一个错误。
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
不幸的是,由于看不到谁拥有锁,因此很难确定哪个事务导致这个问题。不过往往可以通过査看哪个事务打开了非常长的一段时间来有根据地猜测;还有另外一种方法,可以激活InnoDB锁监控器,它最多可以显示每个事务中拥有的10把锁。为了激活该监控器,需要在InnoDB存储引擎中创建一个特殊名字的表。
mysql> CREATE TABLE innodb_lock_monitor(a int) ENGINE=INNODB;
发起这个査询后,InnoDB开始定时地(这个间隔时间可以变化,但通常是每分钟几次)打印SHOW INNODB STATUS的一个略微加强的版本的输出到标准输出中。在大多数系统中,这个输出被重定向到服务器的错误日志中;你可以检査它以査看哪个事务应该拥有那把锁。若想停掉锁监控器,删除这个表即可。
下面是锁监控器输出的相关例子。
1 ---TRANSACTION 0 61717, ACTIVE 3 sec, process no 5102, OS thread id 1141152080 2 3 lock struct(s), heap size 1216 3 MySQL thread id 11, query id 108 localhost baron 4 show innodb status 5 TABLE LOCK table `sakila/film` trx id 0 61717 lock mode IX 6 RECORD LOCKS space id 0 page no 194 n bits 1072 index `idx_fk_language_id` of table `sakila/film` trx id 0 61717 lock_mode X 7 Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 8 ... omitted ... 9 10 RECORD LOCKS space id 0 page no 231 n bits 168 index `PRIMARY` of table `sakila/film` trx id 0 61717 lock_mode X locks rec but not gap 11 Record lock, heap no 2 PHYSICAL RECORD: n_fields 15; compact format; info bits 0 12 ... omitted ...
请注意,第3行显示的MySQL线程ID,跟进程列表里ID列的值是一样的。第5行显示了该事务在表里有一个显式的独占表锁(IX)。第6〜8行显示了索引里的锁。我们删除了第8行的信息,是因为它导出了这个锁定的记录,显得非常累赘。第9〜11行显示了主键上相应的锁(FOR UPDATE锁必须锁住整行,而不仅仅是索引)。
当锁监控器被激活的时候,SHOW INMODB STATUS里也会有额外的信息,因此,实际上无须检査服务器的错误日志,就可以査看锁信息。
出于种种原因,锁监控器并不是最理想的。它的主要问题是锁信息非常冗长,因为导出了被锁定记录的十六进制格式和ASCII格式。它会填满错误日志,并且还会很轻易地溢出固定长度的SHOW INNODB STATUS输出结果。这意味着你可能无法査看到在那一段之后的其他输出信息。InnoDB对每个事务打印锁的数量有硬编码限制,即每个事务只能打印出10个持有的锁,超过10个就无法输出,这意味着你可能看不到需要的锁信息。这还不算完,即使要找的东西确实在里面,也难以把它从所有锁的输出信息里定位出来。(只需在一个繁忙的系统上试一下,你就会体会到这一点。)
有两样东西能够使锁的输出信息更加有用。第一样是本书作者之一为InnoDB和MySQL服务器编写的一个补丁,包含在PerconaServer和MariaDB中。这个补丁会移除输出结果里那些冗长的记录导出信息,默认会把锁信息包含到SHOW INNODB STATUS的输出中(因而锁监控器就无须激活了),还会增加动态可设置服务器变量来控制冗长的输出信息,以及每个事务能打印出的锁信息的个数。
第二个可选用的方法是使用innotop来解析和格式化输出结果。它的Lock模式能够显示锁信息,并通过连接和表优美地聚合在一起,因而能很快地看出哪一个事务持有指定表的锁。但是,这也并非是万无一失的方法,因为它是通过检査所有被锁定记录的导出信息来精确地找出那个被锁定记录。无论怎样,这还是要比常用的方法好很多,对于大多数用途都足够好了。
使用SHOW INNODB STATUS来査看锁绝对是老派做法,现在InnoDB有INFORMATION SCHEMA来显露它的事务和锁。
如果你看不到这个表,说明你使用的InnoDB版本还不够新。至少需要MySQL5.1和InnoDB插件。如果你正在使用MySQL5.1,但没有看到INN0DB_LOCKS表,请用SHOW VARIABLES检査innodb_version变量。如果没有看到这个变量,说明你还没有使用InnoDB插件,你需要它!如果看到了这个变量但没有那些表,那么你需要确保服务器配置文件的plugin_load设置中明确包括了那些表。详情请査阅MySQL用户手册。
幸运的是,MySQL5.5中不需要担心这些,InnoDB的高级版本已经将它编译到服务器中。
对这些表可使用的査询,MySQL和InnoDB手册都有样例,在此不再重复,但我们要增加两个自己的例子。例如,下面是一个显示谁阻塞和谁在等待,以及等待多久的査询。
SELECT r.trx_id AS waiting_trx_id, r.trx_mysql_thread_id AS waiting_thread, TIMESTAMPDIFF(SECOND, r.trx_wait_started, CURRENT_TIMESTAMP) AS wait_time, r.trx_query AS waiting_query, l.lock_table AS waiting_table_lock, b.trx_id AS blocking_trx_id, b.trx_mysql_thread_id AS blocking_thread, SUBSTRING(p.host, 1, INSTR(p.host, ':') - 1) AS blocking_host, SUBSTRING(p.host, INSTR(p.host, ':') +1) AS blocking_port, IF(p.command = "Sleep", p.time, 0) AS idle_in_trx, b.trx_query AS blocking_query FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS AS w INNER JOIN INFORMATION_SCHEMA.INNODB_TRX AS b ON b.trx_id = w.blocking_trx_id INNER JOIN INFORMATION_SCHEMA.INNODB_TRX AS r ON r.trx_id = w.requesting_trx_id INNER JOIN INFORMATION_SCHEMA.INNODB_LOCKS AS l ON w.requested_lock_id = l.lock_id LEFT JOIN INFORMATION_SCHEMA.PROCESSLIST AS p ON p.id = b.trx_mysql_thread_id ORDER BY wait_time DESC\G *************************** 1. row *************************** waiting_trx_id: 5D03 waiting_thread: 3 wait_time: 6 waiting_query: select * from store limit 1 for update waiting_table_lock: `sakila`.`store` blocking_trx_id: 5D02 blocking_thread: 2 blocking_host: localhost blocking_port: 40298 idle_in_trx: 8 blocking_query: NULL
结果显示线程3已经等待store表中的锁达6s。它在线程2上被阻塞,而该线程已经空闲了8s。
如果你因为线程在一个事务中空闲而正在遭受大量的锁操作,下面的这个变种査询可以告诉你有多少査询被哪些线程阻塞,而没有多余的无用信息。
SELECT CONCAT('thread ', b.trx_mysql_thread_id, ' from ', p.host) AS who_blocks, IF(p.command = "Sleep", p.time, 0) AS idle_in_trx, MAX(TIMESTAMPDIFF(SECOND, r.trx_wait_started, NOW())) AS max_wait_time, COUNT(*) AS num_waiters FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS AS w INNER JOIN INFORMATION_SCHEMA.INNODB_TRX AS b ON b.trx_id = w.blocking_trx_id INNER JOIN INFORMATION_SCHEMA.INNODB_TRX AS r ON r.trx_id = w.requesting_trx_id LEFT JOIN INFORMATION_SCHEMA.PROCESSLIST AS p ON p.id = b.trx_mysql_thread_id GROUP BY who_blocks ORDER BY num_waiters DESC\G *************************** 1. row *************************** who_blocks: thread 2 from localhost:40298 idle_in_trx: 1016 max_wait_time: 37 num_waiters: 8
结果显示线程2已经空闲了更长的一段时间,并且至少有一个线程已经等待它释放它的锁长达37s。有8个线程在等待线程2完成它的工作并提交。
我们发现idle-in-transaction锁操作是常见锁故障的一种起因,并且有时候很难诊断。Percona Toolkit中pt-kill可以配置用来杀死长时间运行的空闲事务以阻止这个场景。Percona Server本身也支持一个空闲事务超时参数来完成相同的事情。