SQL Prepared 执行计划缓存
TiDB 支持对 Prepare 和 Execute 查询的执行计划进行缓存。这包括两种形式的预处理语句:
- 使用
COM_STMT_PREPARE和COM_STMT_EXECUTE协议特性。 - 使用 SQL 语句
PREPARE和EXECUTE。
TiDB 优化器对这两类查询的处理方式相同:在准备阶段,参数化的查询会被解析成 AST(抽象语法树)并缓存;在后续执行时,执行计划会根据存储的 AST 和具体参数值生成。
当启用执行计划缓存时,在第一次执行时,每个 Prepare 语句会检查当前查询是否可以使用执行计划缓存,如果可以,则将生成的执行计划放入由 LRU(最近最少使用)链表实现的缓存中。在随后的 Execute 查询中,会从缓存中获取执行计划并进行可用性检查。如果检查成功,则跳过生成执行计划的步骤;否则,重新生成执行计划并保存到缓存中。
TiDB 还支持对某些非 PREPARE 语句的执行计划缓存,类似于 Prepare/Execute 语句。更多详情请参考 Non-prepared plan cache。
在当前版本的 TiDB 中,如果一个 Prepare 语句满足以下任意条件,则该查询或计划不会被缓存:
- 查询包含
SELECT、UPDATE、INSERT、DELETE、Union、Intersect和Except以外的 SQL 语句。 - 查询访问临时表,或包含生成列的表,或使用静态模式(即
tidb_partition_prune_mode设置为static)访问分区表。 - 查询包含非相关子查询,例如
SELECT * FROM t1 WHERE t1.a > (SELECT 1 FROM t2 WHERE t2.b < 1)。 - 查询包含在执行计划中带有
PhysicalApply操作符的相关子查询,例如SELECT * FROM t1 WHERE t1.a > (SELECT a FROM t2 WHERE t1.b > t2.b)。 - 查询包含
ignore_plan_cache或set_var提示,例如SELECT /*+ ignore_plan_cache() */ * FROM t或SELECT /*+ set_var(max_execution_time=1) */ * FROM t。 - 查询包含除
?以外的变量(包括系统变量或用户定义变量),例如select * from t where a>? and b>@x。 - 查询包含无法缓存的函数:
database()、current_user、current_role、user、connection_id、last_insert_id、row_count、version和like。 - 查询使用变量作为
LIMIT参数(如LIMIT ?和LIMIT 10, ?),且变量值大于 10000。 - 查询在
Order By后包含?,如Order By ?。此类查询根据?指定的列排序。如果针对不同列的查询使用相同的执行计划,结果会出错。因此,这类查询不缓存。但如果是常见的查询,例如Order By a+?,则会缓存。 - 查询在
Group By后包含?,如Group By?。此类查询根据?指定的列分组。如果针对不同列的查询使用相同的执行计划,结果会出错。因此,这类查询不缓存。但如果是常见的查询,例如Group By a+?,则会缓存。 - 查询在定义
Window Frame窗口函数时包含?,如(partition by year order by sale rows ? preceding)。如果?出现在窗口函数的其他位置,查询也会被缓存。 - 查询包含用于比较
int和string的参数,例如c_int >= ?或c_int in (?, ?),其中?表示字符串类型,如set @x='123'。为了确保查询结果与 MySQL 兼容,参数需要在每次查询中调整,因此此类查询不缓存。 - 计划尝试访问
TiFlash。 - 在大多数情况下,包含
TableDual的计划不会被缓存,除非当前的Prepare语句没有参数。 - 查询访问 TiDB 系统视图,如
information_schema.columns。不建议使用Prepare和Execute语句访问系统视图。
TiDB 对查询中的 ? 数量有限制。如果查询中包含超过 65535 个 ?,会报错 Prepared statement contains too many placeholders。
LRU 链表设计为会话级缓存,因为 Prepare/Execute 不能跨会话执行。链表的每个元素是一个键值对,值为执行计划,键由以下部分组成:
- 执行
Execute时所在的数据库名 Prepare语句的标识符,即PREPARE关键字后的名称- 当前的 schema 版本,每次成功执行 DDL 语句后更新
- 执行
Execute时的 SQL 模式 - 当前时区,即
time_zone系统变量的值 sql_select_limit系统变量的值
任何上述信息的变化(例如切换数据库、重命名 Prepare 语句、执行 DDL 语句或修改 SQL 模式/time_zone 的值),或 LRU 缓存的淘汰机制,都会导致执行时的执行计划缓存未命中。
从缓存中获取执行计划后,TiDB 会首先检查执行计划是否仍然有效。如果当前 Execute 语句在显式事务中执行,且引用的表在事务预排序语句中被修改,且缓存的执行计划不包含 UnionScan 操作符,则不能执行。
验证通过后,会根据当前参数值调整执行计划的扫描范围,然后用以执行数据查询。
关于执行计划缓存和查询性能,有几点值得注意:
- 无论执行计划是否被缓存,都受 SQL 绑定的影响。对于未缓存的执行计划(第一次
Execute),会受到现有 SQL 绑定的影响;对于已缓存的执行计划,如果创建了新的 SQL 绑定,这些计划会变得无效。 - 缓存的计划不受统计信息变化、优化规则变化和表达式的阻塞推送影响。
- 考虑到
Execute的参数不同,执行计划缓存禁止某些与特定参数值密切相关的激进查询优化方法,以确保适应性。这可能导致某些参数值下的查询计划不是最优的。例如,查询的过滤条件为where a > ? And a < ?,第一次Execute时参数分别为2和1,考虑到下一次执行时参数可能为1和2,优化器不会生成针对当前参数值的最优TableDual执行计划; - 如果不考虑缓存失效和淘汰,执行计划缓存会对各种参数值应用,理论上也会导致某些值的执行计划不是最优的。例如,过滤条件为
where a < ?,第一次执行时参数为1,优化器会生成最优的IndexScan执行计划并缓存。在后续执行中,如果值变为10000,可能TableScan计划更优。但由于使用了执行计划缓存,之前生成的IndexScan被用来执行。因此,执行计划缓存更适合查询简单(编译比例高)且执行计划相对固定的应用场景。
从 v6.1.0 版本开始,执行计划缓存默认开启。你可以通过系统变量 tidb_enable_prepared_plan_cache 控制预处理计划缓存。
启用执行计划缓存功能后,你可以使用会话级系统变量 last_plan_from_cache 查看上一次 Execute 是否使用了缓存的执行计划,例如:
MySQL [test]> create table t(a int);
Query OK, 0 rows affected (0.00 sec)
MySQL [test]> prepare stmt from 'select * from t where a = ?';
Query OK, 0 rows affected (0.00 sec)
MySQL [test]> set @a = 1;
Query OK, 0 rows affected (0.00 sec)
-- 第一次执行会生成执行计划并保存到缓存中。
MySQL [test]> execute stmt using @a;
Empty set (0.00 sec)
MySQL [test]> select @@last_plan_from_cache;
+------------------------+
| @@last_plan_from_cache |
+------------------------+
| 0 |
+------------------------+
1 row in set (0.00 sec)
-- 第二次执行命中缓存。
MySQL [test]> execute stmt using @a;
Empty set (0.00 sec)
MySQL [test]> select @@last_plan_from_cache;
+------------------------+
| @@last_plan_from_cache |
+------------------------+
| 1 |
+------------------------+
1 row in set (0.00 sec)
如果你发现某组 Prepare/Execute 由于执行计划缓存导致行为异常,可以使用 ignore_plan_cache() SQL 提示跳过当前语句的执行计划缓存。以下为示例:
MySQL [test]> prepare stmt from 'select /*+ ignore_plan_cache() */ * from t where a = ?';
Query OK, 0 rows affected (0.00 sec)
MySQL [test]> set @a = 1;
Query OK, 0 rows affected (0.00 sec)
MySQL [test]> execute stmt using @a;
Empty set (0.00 sec)
MySQL [test]> select @@last_plan_from_cache;
+------------------------+
| @@last_plan_from_cache |
+------------------------+
| 0 |
+------------------------+
1 row in set (0.00 sec)
MySQL [test]> execute stmt using @a;
Empty set (0.00 sec)
MySQL [test]> select @@last_plan_from_cache;
+------------------------+
| @@last_plan_from_cache |
+------------------------+
| 0 |
+------------------------+
1 row in set (0.00 sec)
预处理计划缓存的诊断
使用 SHOW WARNINGS 进行诊断
某些查询或计划无法缓存。你可以使用 SHOW WARNINGS 语句检查查询或计划是否被缓存。如果未缓存,可以在结果中查看失败原因。例如:
mysql> PREPARE st FROM 'SELECT * FROM t WHERE a > (SELECT MAX(a) FROM t)'; -- 查询包含子查询,无法缓存。
Query OK, 0 rows affected, 1 warning (0.01 sec)
mysql> SHOW WARNINGS; -- 检查查询计划无法缓存的原因。
+---------+------+-----------------------------------------------+
| Level | Code | Message |
+---------+------+-----------------------------------------------+
| Warning | 1105 | skip plan-cache: sub-queries are un-cacheable |
+---------+------+-----------------------------------------------+
1 row in set (0.00 sec)
mysql> prepare st from 'select * from t where a<?';
Query OK, 0 rows affected (0.00 sec)
mysql> set @a='1';
Query OK, 0 rows affected (0.00 sec)
mysql> execute st using @a; -- 优化将非 INT 类型转换为 INT 类型,参数变化可能导致执行计划变化,因此不缓存。
Empty set, 1 warning (0.01 sec)
mysql> SHOW WARNINGS;
+---------+------+----------------------------------------------+
| Level | Code | Message |
+---------+------+----------------------------------------------+
| Warning | 1105 | skip plan-cache: '1' may be converted to INT |
+---------+------+----------------------------------------------+
1 row in set (0.00 sec)
使用 Statements Summary 进行诊断
Statements Summary 表中包含两个字段,plan_cache_unqualified 和 plan_cache_unqualified_last_reason,分别表示对应查询无法使用计划缓存的次数和原因。你可以利用这两个字段进行诊断:
mysql> SELECT digest_text, plan_cache_unqualified, plan_cache_unqualified_last_reason FROM information_schema.statements_summary WHERE plan_cache_unqualified > 0 ORDER BY plan_cache_unqualified DESC
LIMIT 10;
+---------------------------------+------------------------+----------------------------------------+
| digest_text | plan_cache_unqualified | plan_cache_unqualified_last_reason |
+---------------------------------+------------------------+----------------------------------------+
| select * from `t` where `a` < ? | 10 | '1' may be converted to INT |
| select * from `t` order by ? | 4 | query has 'order by ?' is un-cacheable |
| select database ( ) from `t` | 2 | query has 'database()' is un-cacheable |
...
+---------------------------------+------------------------+----------------------------------------+
10 row in set (0.01 sec)
内存管理:预处理计划缓存
使用预处理计划缓存会带来一些内存开销。在内部测试中,每个缓存计划平均消耗 100 KiB 内存。由于计划缓存目前处于 SESSION 级别,总内存消耗大约为 会话数 * 每个会话的平均缓存计划数 * 100 KiB。
例如,当前 TiDB 实例有 50 个会话并发,每个会话大约有 100 个缓存计划,总内存消耗大约为 50 * 100 * 100 KiB = 512 MB。
你可以通过配置系统变量 tidb_session_plan_cache_size 来控制每个会话中最大缓存计划数。不同环境建议值如下:
- 当 TiDB 服务器实例的内存阈值 <= 64 GiB 时,设置
tidb_session_plan_cache_size为50。 - 当 TiDB 服务器实例的内存阈值 > 64 GiB 时,设置
tidb_session_plan_cache_size为100。
从 v7.1.0 版本开始,你可以通过配置系统变量 tidb_plan_cache_max_plan_size 来控制可缓存计划的最大大小。默认值为 2 MB。如果计划大小超过此值,则不会缓存。
当 TiDB 服务器的未使用内存低于某个阈值时,会触发计划缓存的内存保护机制,从而驱逐部分缓存计划。
你可以通过配置系统变量 tidb_prepared_plan_cache_memory_guard_ratio 来控制阈值。默认值为 0.1,意味着当 TiDB 服务器的未用内存少于总内存的 10%(即使用了 90% 内存)时,触发内存保护机制。
由于内存限制,计划缓存可能会有未命中的情况。
清除执行计划缓存
你可以通过执行 ADMIN FLUSH [SESSION | INSTANCE] PLAN_CACHE 语句清除执行计划缓存。
在此语句中,[SESSION | INSTANCE] 指定是清除当前会话还是整个 TiDB 实例的计划缓存。如果未指定范围,默认作用于 SESSION 缓存。
以下为清除 SESSION 执行计划缓存的示例:
MySQL [test]> create table t (a int);
Query OK, 0 rows affected (0.00 sec)
MySQL [test]> prepare stmt from 'select * from t';
Query OK, 0 rows affected (0.00 sec)
MySQL [test]> execute stmt;
Empty set (0.00 sec)
MySQL [test]> execute stmt;
Empty set (0.00 sec)
MySQL [test]> select @@last_plan_from_cache; -- 选择缓存的计划
+------------------------+
| @@last_plan_from_cache |
+------------------------+
| 1 |
+------------------------+
1 row in set (0.00 sec)
MySQL [test]> admin flush session plan_cache; -- 清除当前会话的缓存计划
Query OK, 0 rows affected (0.00 sec)
MySQL [test]> execute stmt;
Empty set (0.00 sec)
MySQL [test]> select @@last_plan_from_cache; -- 缓存的计划已被清除,无法再次选择
+------------------------+
| @@last_plan_from_cache |
+------------------------+
| 0 |
+------------------------+
1 row in set (0.00 sec)
目前,TiDB 不支持清除 GLOBAL 执行计划缓存。这意味着你不能清除整个 TiDB 集群的缓存计划。如果尝试清除 GLOBAL 执行计划缓存,会报错:
MySQL [test]> admin flush global plan_cache;
ERROR 1105 (HY000): Do not support the 'admin flush global scope.'
忽略 COM_STMT_CLOSE 命令和 DEALLOCATE PREPARE 语句
为了减少 SQL 语句的语法解析成本,建议你只运行一次 prepare stmt,然后多次运行 execute stmt,最后再运行 deallocate prepare:
MySQL [test]> prepare stmt from '...'; -- 只准备一次
MySQL [test]> execute stmt using ...; -- 只执行一次
MySQL [test]> ...
MySQL [test]> execute stmt using ...; -- 多次执行
MySQL [test]> deallocate prepare stmt; -- 释放预处理语句
在实际操作中,你可能习惯在每次运行 execute stmt 后都执行 deallocate prepare,如下所示:
MySQL [test]> prepare stmt from '...'; -- 只准备一次
MySQL [test]> execute stmt using ...;
MySQL [test]> deallocate prepare stmt; -- 执行后释放
MySQL [test]> prepare stmt from '...'; -- 预处理两次
MySQL [test]> execute stmt using ...;
MySQL [test]> deallocate prepare stmt; -- 释放预处理语句
在这种做法中,第一次执行获得的执行计划不能被第二次执行所复用。
为解决此问题,你可以将系统变量 tidb_ignore_prepared_cache_close_stmt 设置为 ON,让 TiDB 忽略关闭 prepare stmt 的命令:
mysql> set @@tidb_ignore_prepared_cache_close_stmt=1; -- 开启变量
Query OK, 0 rows affected (0.00 sec)
mysql> prepare stmt from 'select * from t'; -- 只准备一次
Query OK, 0 rows affected (0.00 sec)
mysql> execute stmt; -- 只执行一次
Empty set (0.00 sec)
mysql> deallocate prepare stmt; -- 第一次执行后释放
Query OK, 0 rows affected (0.00 sec)
mysql> prepare stmt from 'select * from t'; -- 预处理两次
Query OK, 0 rows affected (0.00 sec)
mysql> execute stmt; -- 预处理两次
Empty set (0.00 sec)
mysql> select @@last_plan_from_cache; -- 复用上次的计划
+------------------------+
| @@last_plan_from_cache |
+------------------------+
| 1 |
+------------------------+
1 row in set (0.00 sec)
监控
在 Monitoring 页面中,可以查看 Queries Using Plan Cache OPS 指标,以获取所有 TiDB 实例中每秒使用或未命中计划缓存的查询数。