改进动态设置query cache导致额外锁开销的问题分析及解决方法-mysql 5.5 以上版本

改进动态设置query cache导致额外锁开销的问题分析及解决方法

关键字:dynamic switch for query cache,  lock overhead for query cache

背景

Query Cache是MySQL Server层的一个非常好的特性,对于小数据集或访问量非常集中的应用场景,有非常好的性能提升,内部细节可以参考1,在此处不打算展开Query Cache的一些应用特性。

Query Cache引入了一新的问题, 即如果你不想要Query Cache的功能(彻底地不要执行任何query cache的任何代码),只能在编译时就指定 –without-query-cache,也就是用宏开关把相关代码不编译。
为什么要这么做,原因就是一个社区里的Known Issue: 如果用户在运行时动态关闭query cache, 会导致额外CPU的开销,即对query cache加解锁操作。在负载非常高的MySQL服务器上,这个问题变得尤为突出。

在Oracle MySQL版本中,此问题一直无人解决,在MySQL 5.6中亦如此,在MariaDB中也一样。 Percona对此有提出自己的解决方案,不需要在编译时指定,可以在MySQL启动时指定–query_cache_type=0。这确实已经是一个大的进步,至少用户在想用时不要重新编译。但需要指出的是,Percona版本为些付出的代价是,用户不能再动态地将uery_cache_type从0改为其它值:

SET GLOBAL query_cache_type=ON;
ERROR 1651(HY000): Query cache is disabled; restart the server with query_cache_type=1 to enable it

问题分析

首先看下,Percona的优化为什么必须要在启动时配置–query_cache_type=0才能达到和编译时指定–without-query-cache一样的效果。

对query cache典型的操作,例如在select时要insert cache, 在更新时需要invalidate cache, 判断的依据就是
2307 if (is_disabled())
2308 DBUG_VOID_RETURN;

而实质动作就是返回私有成员变量:m_query_cache_is_disabled
class Query_cache
{
public:
bool is_disabled(void) { return m_query_cache_is_disabled; }
}

这个变量则是在Query_cache构造函数中初始化,Query_cache::init中根据系统变量query_cache_type来设置:
2618 if (global_system_variables.query_cache_type == 0)
2619 query_cache.disable_query_cache();

class Query_cache
{
public:
void disable_query_cache(void) { m_query_cache_is_disabled= TRUE; }
}

这个逻辑就意味着这个值后面不可能被再次更改。
那么,它是如何做到没有调用额外锁开销的呢?

在5.5中,启动时–query_cache_type=0可以完全不用query cache, 以及操作query cache的锁。以PS5.5.18为例来分析其代码逻辑:

2307 if (is_disabled())
2308 DBUG_VOID_RETURN;
….

2326 invalidate_table(thd, tables_used); // 以下为此函数的实现部分代码
3312 lock();
3316 if (query_cache_size > 0)
3317 invalidate_table_internal(thd, key, key_length);
3319 unlock();

这个代码片断已经可以看出其原因了,因为is_disabled()返回false, 此函数直接return。
同时我们也可以看出,如果用户启动时指定query cache,即–query_cache_type=1,那么后面如果不想用query cache时,必须还要将query_cache_size设置为0,否则还是要调用invalidate_table_internal()。但整个过程 还是是调用lock()/unlock()。当然这是针对Oracle MySQL或MariaDB,因为Percona无法动态将0改为1或2。

解决方法

依照上面的分析,需要将is_disabled进行扩展,先看下核心代码片断:

+bool Query_cache::is_disabled_ext(void)
+{
+ /* disabled -> enabled */
+ if (is_trace_disabled() && global_system_variables.query_cache_type !=0)
+ {
+ query_cache.update_query_cache_trace(true);
+ return false;
+ }
+
+ /* enable -> disable */
+ if (!is_trace_disabled() && global_system_variables.query_cache_type ==0)
+ {
+ query_cache.update_query_cache_trace(false);
+ query_cache.flush();
+ return true;
+ }
+
+ return is_trace_disabled();
+}

is_trace_disabled就是判断上次是否设置为disable query cache:
+ bool is_trace_disabled(void) { return !m_query_cache_trace; }
update_query_cache_trace就是更新m_query_cache_trace:
+ void update_query_cache_trace(bool new_flag) { m_query_cache_trace = new_flag; }

其实上面代码应该是比较清楚的,但我还是稍微解释下is_disabled_ext的逻辑,这个含有3个节点的状态机还是比较简易的,用例子说明:

  1. 如果用户启动时为0,此时is_trace_disabled()为true(init中设置为flag为false),并且global_system_variables.query_cache_type为0,那么逻辑是走第三个return返回true,即query cache不可用。
  2. 如果用户启动时为1,此时is_trace_disabled()为false(构造函数中初始化成员列表时被置为true),并且global_system_variables.query_cache_type为1,那么逻辑是走第三个return返回true,即query cache可用。
  3. 如果用户想将query cache从1变为2, 那么is_trace_disabled()返回false(因为曾经是1),并且global_system_variables.query_cache_type为2,那么逻辑是走第三retrun返回false,调用者得知,当前query cache可用(正常的锁开销)
  4. 如果用户想将query cache从0变为1, 那么is_trace_disabled()返回true(因为曾经是0),并且global_system_variables.query_cache_type为1,那么逻辑是走第一个if返回false,调用者得知,当前query cache可用!(正常的锁开销,实现Percona版本存在的缺陷:动态从0到1的效果)下次再判断时,is_trace_disabled()为false,并且global_system_variables.query_cache_type为1, 那么第三个return返回false,即query cache仍可用。
  5. 如果用户想将query cache从1变为0, 那么is_trace_disabled()返回false(因为曾经是1),并且global_system_variables.query_cache_type为0,那么逻辑是走第二个if返回true,调用者得知,当前query cache被disable!(不会走锁开销逻辑,达到启动时设置一样的效果)下次再判断时,is_trace_disabled()为true,并且global_system_variables.query_cache_type为0, 那么第三个return返回true,即query cache仍不可用。

其中1->2和2->1, 0->1和0->2,1->0和2->0逻辑一样。2无非是一种增强约束条件的1而已。

可能的风险

反思为什么各大公司版本中一直没有改动?从上面的过程看,彻底解决此问题是可行的。那不是太难的问题,为什么各大公司的没有去解决呢?我想可能有如下几个因素需要考虑:

  • 并发是否会有读脏数据问题

如果一个用户正在设置query cache开关, 而其它线程可能正在读取query cache的状态,此时,会不会有什么问题?

  1. 如果用户设置0->1, 此时is_disabled_ext()可能返回query cache仍不可用,此处竞争不会导致问题。
  2. 如果用户设置1->0, 此时is_disabled_ext()可能返回query cache仍可用,然后读取其内容。而此时恰有事务修改此记录,这和正常情况一样,读取仍是此事务之前的最新记录值。

另外,query_cache.flush() 会加锁,所以也不用担心这个问题。

  • 并发更新状态值问题

如果多个用户同时更新query cache的开关,即is_trace_disabled()和global_system_variables.query_cache_type的判断不是一个原子操作, 那么这个极低的复杂场景会有风险嘛?答案是不会,因为最多导致用户去读空缓存的瞬间。读取不到仍然会去MySQL层正常执行。

  • net_real_write->query_cache_insert->Query_cache::insert会不会带来数据只写入部分到query cache中的问题。

目前所有的MySQL版本和分支,都不支持动态修改0->1这种模式,可能一个主要的原因是担心部分query cache写入的问题发生。
典型的例子,一个查询结果集大于query_cache_min_res_unit 时,需要多次分配内存给query cache的结果,多次写入中间可能用户有对query_cache_type的改变动作。
为此,我们加一个限制条件,在将query_cache生效之前,将query_cache_type设置为0,然后将query_cache开关打开之后再设置其query_cache_size的值。

测试数据

这个场景的测试数据单纯用TPS或QPS来衡量都意义不太大,我们在下面实验中,主要做三个方面的事情:

  • 原版本不能从OFF->ON
  • 补丁版本解决OFF->ON问题
  • 补丁版本解决额外的LOCK/UNLOCK调用

#########################

#Percona-5.5.18版本

# 启动MySQL,默认情况下不开启Query Cache
/tmp/dbg –defaults-file=/u01/mysqld/my.cnf

# 查询sbtest表中某条记录,连接3次时间相差无几
root@(none) 05:24:29>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (8.96 sec)

root@(none) 05:24:41>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (8.88 sec)

root@(none) 05:24:52>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (9.04 sec)

从上面的查询时间可以看出,query cache确实没有开启。

# 无法再更改query cache type
root@(none) 07:03:40>set global query_cache_type=ON;
ERROR 1651 (HY000): Query cache is disabled; restart the server with query_cache_type=1 to enable it

作为对比,下面是开启query cache的方式来测试:

# 启动MySQL,开启Query Cache
/tmp/dbg –defaults-file=/u01/mysqld/my.cnf –query_cache_type=on –query_cache_size=102400 –query_cache_limit=10240

# 查询sbtest表中某条记录,第一次耗时非常大
root@(none) 05:22:01>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (23.61 sec)

root@(none) 05:22:28>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (0.00 sec)

root@(none) 05:28:48>SHOW STATUS LIKE ‘Qcache_hits’;
+—————+——-+
| Variable_name | Value |
+—————+——-+
| Qcache_hits | 1 |
+—————+——-+
1 row in set (0.01 sec)

#########################

#Percona-5.5.18版本的补丁版本, 查看是否可以动态改变query cache类型

# 启动MySQL,默认关闭Query Cache
/tmp/qc –defaults-file=/u01/mysqld/my.cnf

# 查询sbtest表中某条记录,后三次查询耗时都非常大

root@(none) 05:33:57>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (16.08 sec)

root@(none) 05:34:16>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (2.99 sec)

root@(none) 05:34:44>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (3.01 sec)

root@(none) 05:34:49>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (3.04 sec)

root@(none) 05:35:47>SHOW STATUS LIKE ‘Qcache_hits’;
+—————+——-+
| Variable_name | Value |
+—————+——-+
| Qcache_hits | 0 |
+—————+——-+
1 row in set (0.00 sec)
# 动态改变query cache, OFF -> ON

root@(none) 05:35:54>set global query_cache_type=ON;
Query OK, 0 rows affected (0.00 sec)

root@(none) 05:36:40>set global query_cache_limit=10240;
Query OK, 0 rows affected (0.00 sec)

root@(none) 05:36:46>set global query_cache_size=102400;
Query OK, 0 rows affected (0.00 sec)
root@(none) 05:37:30>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (3.00 sec)

root@(none) 05:37:34>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (0.00 sec)

root@(none) 05:37:35>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (0.01 sec)
# 动态改变query cache, ON -> OFF

root@(none) 05:37:38>set global query_cache_type=OFF;
Query OK, 0 rows affected (0.00 sec)

root@(none) 05:38:19>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (3.02 sec)

root@(none) 05:38:25>select * from sbtest1.sbtest1 where pad=’43131080328-59298′;
Empty set (2.90 sec)

#########################

#Percona-5.5.18版本的补丁版本, 查看是否调用额外的LOCK/UNLOCK

不重启上面开启的MySQLD,继续测试

$ cat qc.test
sql=”select * from sbtest1.sbtest1 where pad=’43131080328-59298′;”
while [ 1 ]
do
mysql -uroot -S run/mysql.sock sbtest -e “$ sql” > /dev/null 2>&1 &
mysql -uroot -S run/mysql.sock sbtest -e “$ sql” > /dev/null 2>&1 &
mysql -uroot -S run/mysql.sock sbtest -e “$ sql” > /dev/null 2>&1 &
mysql -uroot -S run/mysql.sock sbtest -e “$ sql” > /dev/null 2>&1 &
mysql -uroot -S run/mysql.sock sbtest -e “$ sql” > /dev/null 2>&1 &
#sleep 1
done
# 再次开启Query Cache, OFF->ON
root@(none) 05:48:11>set global query_cache_type=ON;
Query OK, 0 rows affected (0.00 sec)

# 启动并发查询,查看processlist是否有LOCK/UNLOCK
sh qc.test
root@(none) 05:49:03>show processlist;
| 2279 | root | localhost | sbtest | Query | 3 | Sending data | select * from sbt|
| 2280 | root | localhost | sbtest | Query | 3 | Sending data | select * from sbt|
| 2281 | root | localhost | sbtest | Query | 3 | Sending data | select * from sbt|
| 2282 | root | localhost | sbtest | Query | 3 | Sending data | select * from sbt|
| 2283 | root | localhost | sbtest | Query | 3 | Sending data | select * from sbt|
| 2284 | root | localhost | sbtest | Query | 3 | Sending data | select * from sbt|
| 2285 | root | localhost | sbtest | Query | 3 | Sending data | select * from sbt|
| 2286 | root | localhost | sbtest | Query | 2 | Sending data | select * from sbt|
| 2287 | root | localhost | sbtest | Query | 2 | Sending data | select * from sbt|

对比未打补丁版本,开启mysqld后再更改query cache, 即ON -> OFF, processlist中有明显的”Waiting for query cache lock“

/tmp/dbg –defaults-file=/u01/mysqld/my.cnf –query_cache_type=on –query_cache_size=102400 –query_cache_limit=10240

root@(none) 06:16:49>set global query_cache_type=OFF;
Query OK, 0 rows affected (0.00 sec)

root@(none) 06:16:51>set global query_cache_size=0;
Query OK, 0 rows affected (0.00 sec)

# 启动并发查询,查看processlist是否有LOCK/UNLOCK
sh qc.test

show processlist的部分结果为:

| 872 | root | localhost | sbtest | Query | 0 | Sending data | select * from sbte|
| 873 | root | localhost | sbtest | Query | 0 | Waiting for query cache lock | select * from sbte|
| 874 | root | localhost | sbtest | Query | 1 | Sending data | select * from sbte|
| 875 | root | localhost | sbtest | Query | 0 | NULL | select * from sbte|
| 876 | root | localhost | sbtest | Query | 0 | Opening tables | select * from sbte|
| 877 | root | localhost | sbtest | Query | 0 | Opening tables | select * from sbte|
| 878 | root | localhost | sbtest | Query | 0 | NULL | select * from sbte|
| 879 | root | localhost | sbtest | Query | 0 | NULL | select * from sbte|
| 880 | root | localhost | sbtest | Query | 0 | Waiting for query cache lock | select * from sbte|
| 881 | root | localhost | sbtest | Query | 0 | NULL | select * from sbte|
| 882 | root | localhost | sbtest | Query | 0 | Opening tables | select * from sbte|

社区的反馈

目前此patch已经被MariaDB的Sergei进行Code Review进一步完善,后续可能会在Percona新版本中(5.5版本)解决(MariaDB更专注于Server层,而Percona更专注于Storage Engine,特别是XtraDB存储引擎层,两者定期会Merge,这点上回在Sergei在ADC:Alibaba Developers Conference 时到阿里巴巴交流时谈到)。

参考
http://www.mysqlperformanceblog.com/2006/07/27/mysql-query-cache/
http://www.percona.com/doc/percona-server/5.5/performance/query_cache_enhance.html?id=percona-server:features:query_cache_enhance#disabling_the_cache_completely
https://bugs.launchpad.net/percona-server/5.5/+bug/1021131/+attachment/3229396/+files/qc.patch

上一篇:Git与SVN的区别(面试常问)


下一篇:Linux中切换用户变成-bash4.1-$的解决方法