一文解读 MySQL Query Cache 使用与实现

本文分享自华为云社区《【华为云MySQL技术专栏】MySQL Query Cache使用与实现》,作者:GaussDB 数据库。
一、背景介绍
查询缓存(Query cache,简称 QC)是一种数据库优化技术,用于存储查询结果,以便在相同查询再次执行时能够快速返回结果,而无需重新执行查询。MySQL 也有 QC 对应实现,但因其实现存在并发性能差、缓存命中率低等问题,该特性在 MySQL 5.7.20 标记为不推荐使用,在 MySQL 8.0.3 里被删除。
QC 对于特定场景可以显著提升性能,TaurusDB 保留 QC 并对其并发性能进行了优化。本文基于 MySQL 8.0.2 代码对 QC 的使用与实现进行分享,介绍 TaurusDB 如何进行优化。
二、Query Cache 使用
在使用 QC 特性后,MySQL 服务端会缓存符合条件(见文末链接)的查询与查询结果。
接收查询后,不会立刻解析和执行,而是优先从 QC 中检索结果返回。
QC 是全局的,不同 session 可以共享 QC 功能,更详细介绍参考文末官网链接。

QC 相关系统参数
相关参数都包含 query_cache 关键字,用如下 SQL 查询。

QC 功能开启前提 query_cache_type!=off 且 query_cache_size!=0,具体参数使用见官网说明。https://dev.mysql.com/doc/refman/5.7/en/query-cache-configuration.html
QC 相关统计参数
相关参数都包含 Qcache 关键字,用如下 SQL 查询。

QC 底层是一个内存池,所以包含内存池状态参数。上述参数反应当前 QC 的使用状态,以供使用者调整优化。如:
(1)Qcache_hits/(Qcache_hits+Qcache_inserts)可视作缓存命中率,过低说明 QC 功能对当前业务作用不大,可考虑关闭 QC。
(2)Qcache_free_block/Qcache_total_block 可视作缓存碎片率,过高可使用 flush query cache 进行碎片整理或根据业务适当减小 query_cache_min_res_unit。
三、源码分析
先对 QC 业务常见场景进行概览,分为以下三种:
第一种,缓存查询,将查询结果存储到 QC 中。

第二种,查询命中,从 QC 中直接返回查询结果。

第三种,查询失效。发生影响缓存结果正确性的操作时(如相关表 DDL、DML),对应缓存失效。

QC 的业务实现和内存池有着密切的关系。为了更好理解 QC 实现,后文将从内存池设计和 QC 业务实现两方面进行介绍。需要注意,两者在代码层面不是割离的,都在一个全局对象 Query_cache 中实现。在设计层面,内存池的方案选择也会受到 QC 业务影响。
内存池设计
内存池的实现,会先申请一整块连续内存,但在处理申请、释放内存的请求后,整块内存逐渐会被分割为多个内存块(block)。根据内存块是否正在使用,可分为空闲内存块(free block)与非空闲内存块(used block)。

通过图 7 可以发现,内存池核心接口是内存的申请与释放,并且在内存池使用过程中会出现内存碎片化问题。
那么申请内存时,如何找到内存池中不小于所需大小的空闲块?如何知道各内存块的大小和空闲状态?如果有多个合适的空闲块,如何选择?如果选中的空闲块比申请的大,多余的空间(内部碎片)怎么处理?释放内存时,又该如何处理释放的内存块?
下面将分析 QC 如何解决上述问题,同时缓解内存的碎片化。
问题一:空闲内存块管理

如图 8 所示,QC 内存池将内存块大小、类型等元信息存储在块头部。用数组(bins)根据块大小对空闲内存块进行管理。
数组长度与成员赋值根据内存池大小经过内部算法确定,算法有如下特征:

(1)数组成员下标越小管理的空闲内存块大小越大。如图 9 左边所示,bins[0]管理空闲内存块大小大于 bin[1]管理的。
(2)数组成员下标越大管理的空闲内存块大小范围越小。如图 9 左边所示,bins[1]管理空闲内存块是 5-8M,范围是 3M,而 bins[3]管理空闲内存块是 1-2M,范围是 1M)。
考虑到小对象往往会分配更频繁,越可能是性能瓶颈,所以算法会生成更多 bin 元素对小内存块进行更加细致的管理。
设当前内存池大小为 128M,经过一段时间运行后,如图 9 右边所示(链表首尾相连,图中未画)。
问题二:空闲内存块选择
若申请大小为 6M,当前有多个内存块符合申请大小。如图 10 所示,通常有如下选择策略:

第一种策略,first fit。
在物理上进行遍历,返回第一个不小于申请大小 free block(10M)。此策略寻找空闲内存块时间比较短,但块大小可能超出 6M 较多,易造成内存碎片。
第二种策略,best fit。
best fit 返回最小不小于申请大小 free block,可利用 bins 数组查找:
(1)根据申请大小找到对应的 bin 元素(6M 对应 bins[1])。
(2)遍历 bin 中 free block 链表,因链表有序,返回第一个不小于申请大小的块(8M)。
初看 best fit 是一个不错的方案,但如果 free block 链表长度过长,时间会线性增加。因此,QC 在 best fit 基础上进行优化,将第二步的链表遍历修改为:从链表头向后(从小到大)遍历最多 5 次,若找到符合要求内存块返回,否则从链表尾向前(从大到小)遍历最多 5 次,返回这五个内存块中最小符合要求的内存块。
问题三:内部碎片处理
若申请 8M 内存块,但写入数据只有 6M,则会出现了 2M 的内部碎片,如图 9 所示。若不处理,将影响内存利用率。

QC 的处理方式:如果内部碎片不大于 512B 不进行处理,否则将内部碎片从内存块中分离,视作新增的 free block 纳入 bins 中管理。
问题四:处理释放的内存
内存释放除了需要重置内存块后纳入对应 bin 管理,还需合并相邻的空闲内存块,以减少内存碎片。

如图 12 中所示,若释放内存块大小为 7M,物理相邻内存块也是空闲,从左到右即为内存池的变化过程。
QC 业务设计
关键数据结构
关键数据结构就是前文常提的抽象概念内存块,对应代码中的 Query_cache_block。

图 13 中,元信息中 Query_cache_block_table 数组,作用是维护 block 之间关联关系,具体用法后文分析。
根据 data 存储内容不同,block 主要被分为三种类型 query,table,result block。
我们用一个简单 SQL 的执行作为例子,如查询:select * from t1 join t2 where t1.a = t2.a,来探究一个查询在内存池中对应哪些 block。
query block

图 14 中,一个查询对应一个 query block,data 记录查询 SQL 与查询状态(影响输出参数)。
query block 中有 t1、t2 的 Query_cache_block_table 链表节点,与 t1、t2 的 table block 关联(table block 中解释关联作用)。
Query_cache_query 记录元信息,如查询对应的 result block 指针。
table block

图 15 中,一张表对应一个 table block,data 记录数据库名和表名。
table block 有 Query_cache_block_table 链表头节点与相关的 query block 相连,方便后续通过库表名找到相关 query block。
result block

图 16 中,result block 的 data 记录 MySQL 返回客户端的 packet 内容.
因为 packet 的发送可能分为多次,所以 result block 在初次申请内存时,无法确定最终大小,这会导致一个查询可能对应的多个 result block 的情况,同一个查询的 result block 之间使用指针维护一个链表。
当查询一对应 2 个 result block 时,table,query,result block 之间对应的关系图如图 17 所示。

QC 场景场景实现

缓存查询实现
对应图 18 中,若 QC 未命中分支,主要有 2 个步骤:
步骤一,QC 存储查询内容。
通过调用 Query_cache::store_query,创建 query,table block,并维护对应的哈希表和链表。
步骤二,QC 存储查询结果。
通过调用 query_cache_insert,基于 MySQL 向客户端返回的 packet 内容生成 result block,可参考查询结果写入流程图(图 16)。
缓存命中实现
对应图 18 中,若 QC 命中分支,主要有 1 个步骤,即 QC 命中判断:在解析前调用 Query_cache::send_result_to_client,使用 query block 哈希表来判断查询是否命中。若命中则直接调用函数 net_write_packet,对客户端发送缓存结果。
缓存失效实现
缓存失效场景,相关表发生 ddl、dml、内存池不足导致缓存失效等。参考图 17(table,,query,result block 关系图),dml 的缓存失效流程如下:
(1)通过库表名在 table block 的哈希表中找到 table block;
(2) 通过 table block 中 Query_cache_block_table 链表头找到与此表相关 query block;
(3)根据 query block 的 result block 指针,找到对应的 result block;
(4)对上述 block 进行释放。
其他设计
内存池整理命令
QC 提供了命令 FLUSH QUERY CACHE,可手动触发内存池空间整理。分为两个步骤:
第一,pack cache,将 free block 移动到内存池底部。
第二,join result,将属于同一查询的多个 result block(result1.1,result1.2)进行合并,此操作将导致内存碎片重新出现,需再进行一次 pack cache。

如图 19 所示,整理后内存池只有 1 个 free block,也对 result block 进行了整合。
并发处理
为了应对 MySQL 的并发查询,QC 内存池需设计并发策略来保证 QC 业务的正确性。
QC 锁。
要求所有对内存池的操作(插入、删除、查询)需提前持有 QC 锁。其实质是将并发操作序列化,以保证正确性;
查询结果写入持锁优化
根据图 16(查询结果写入流程图),对查询结果写入的持锁范围进行了优化——不需全程持锁,在进行 result block 写入时持锁即可。

写入流程不需全程持锁,只需对内存池真正操作的 Query_cache::insert 函数中持锁即可。但此优化引入一个新问题:如果同时有两个会话进行相同的查询,可能会导致两个线程对查询结果共同写入的情况,造成缓存结果错误。
此优化引入一个新问题:同时有两个会话进行相同查询,造成两个线程对同一查询结果同时写入的情况,造成缓存结果错误。
解决方案:
在 query block 中新增成员变量 writer,创建 query block 时,writer 记录线程号,后续 result block 只能由该线程写入。这样就可以确保每个查询结果,只能由一个线程写入,从而避免缓存结果的错误。
事务场景
1)多语句事务:
单语句事务和多语句事务中,相同查询对应不同的缓存。
2)行锁写锁:
表有行写锁时,不会进行缓存。
3)RC 场景:
DML、DDL 操作会对相关查询缓存失效,所以,QC 中查询缓存都是最新提交的结果,符合读已提交的定义,RC 可以正常使用 QC 特性。
4)RR 场景:
为了适配 RR 隔离级别,在表元数据中新增变量(query_cache_inv_id)记录对表的最新操作的事务 ID(trx_id)。如果 RR 事务.trx_id< query_cache_inv_id,则说明该表有当前 RR 事务看不到的新操作,故该表 QC 缓存不适用此事务。
本章对内存池设计和 QC 业务设计的原理进行了分析,并总结了各自的优缺点。
内存池设计优点:
利用数组(bins)、链表结构,以内存块大小管理空闲内存,申请内存高效;
内部碎片及时处理、释放内存时将物理相邻 free block 合并,自动优化内存池;
提供 FLUSH QUERY CACHE 命令可手动触发整理内存池。
内存池设计缺点:
内存池大小无法动态调整,调整大小会重新初始化,丢失缓存数据;
不支持并发操作内存池,使用 QC 锁使操作序列化,高并发场景性能下降严重。
查询业务设计优点:
将 block 分类,利用链表、哈希表等结构,简洁实现查询缓存、命中、失效等功能;
查询结果可分在多个 result block 存储,提升内存利用率;
查询结果写入流程中减少 QC 锁持有的时间,提升性能;
适配数据库的事务场景,支持 RC/RR 隔离级别使用。
查询业务设计缺点:
缓存失效方式粗粒度,对表进行任何 DML\DDL 操作,相关 QC 都将失效。
四、TaurusDB 优化
如上文所示,社区 MySQL 实现 QC 内存池不支持并发操作。面对并发查询场景时,性能不仅没有得到优化,反而可能会下降。TaurusDB 对此新增了参数 query_cache_instances,如图 21 所示。

该参数可以指定 QC 内存池个数,将内存池一分为多。QC 业务会根据查询的哈希值到指定的内存池进行对应操作,以此减少并发冲突。
为了评估`query_cache_instances` 参数对性能的影响,进行只读场景测试,测试 query_cache_instantces 参数(0、1、16、64)在不同并发数下 QPS 变化。
注:测试环境是笔者开发机,绝对值无参考意义,关注变化趋势。

可以看到,虽然优化原理简单,但是对性能提升是立竿见影的。
五、总结
查询缓存(QC)像一把双刃剑。在读多写少、重复查询多的场景中,QC 能显著提升性能。然而,在写多读少的场景中,QC 不仅消耗内存资源,还会因为查询缓存和缓存命中判断等额外步骤导致性能下降。
总体来看,QC 是一个使用难度较高的特性。用户需要了解其工作原理,并根据自身业务特点,通过调整相关系统参数、统计参数和优化命令来进行监控与调整。
MySQL 社区版没有解决并发查询性能差的问题,而 TaurusDB 新增的 `query_cache_instances` 参数,则能够在高并发场景下提供更好的性能优化,使用户能够更充分地利用 QC 的优势。
MySQL QC 官网介绍,https://dev.mysql.com/doc/refman/5.7/en/query-cache.html
MySQL 服务端会缓存符合条件的查询与查询结果,https://dev.mysql.com/doc/refman/5.7/en/query-cache-operation.html
评论