执行计划缓存
TiDB 支持对 Prepare
/ Execute
请求的执行计划缓存。其中包括以下两种形式的预处理语句:
- 使用
COM_STMT_PREPARE
和COM_STMT_EXECUTE
的协议功能; - 执行
Prepare
/Execute
SQL 语句查询;
TiDB 优化器对这两类查询的处理是一样的:Prepare
时将参数化的 SQL 查询解析成 AST(抽象语法树),每次 Execute
时根据保存的 AST 和具体的参数值生成执行计划。
当开启执行计划缓存后,每条 Prepare
语句的第一次 Execute
会检查当前查询是否可以使用执行计划缓存,如果可以则将生成的执行计划放进一个由 LRU 链表构成的缓存中;在后续的 Execute
中,会先从缓存中获取执行计划,并检查是否可用,如果获取和检查成功则跳过生成执行计划这一步,否则重新生成执行计划并放入缓存中。
在当前版本中,当 Prepare
语句符合以下条件任何一条,查询或者计划不会被缓存:
SELECT
、UPDATE
、INSERT
、DELETE
、Union
、Intersect
、Except
以外的 SQL 语句;- 访问分区表、临时表或访问表中包含生成列的查询;
- 包含子查询的查询,如
select * from t where a > (select ...)
; - 包含
ignore_plan_cache
这一 Hint 的查询,例如select /*+ ignore_plan_cache() */ * 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, ?
,此时?
的具体值对查询性能影响较大,故不缓存;?
直接在Order By
后的查询,如Order By ?
,此时?
表示根据Order By
后第几列排序,排序列不同的查询使用同一个计划可能导致错误结果,故不缓存;如果是普通表达式,如Order By a+?
则会缓存;?
紧跟在Group by
后的查询,如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
语句不含参数,则对应的TableDual
计划可以被缓存。
LRU 链表是设计成 session 级别的缓存,因为 Prepare
/ Execute
不能跨 session 执行。LRU 链表的每个元素是一个 key-value 对,value 是执行计划,key 由如下几部分组成:
- 执行
Execute
时所在数据库的名字; Prepare
语句的标识符,即紧跟在PREPARE
关键字后的名字;- 当前的 schema 版本,每条执行成功的 DDL 语句会修改 schema 版本;
- 执行
Execute
时的 SQL Mode; - 当前设置的时区,即系统变量
time_zone
的值; - 系统变量
sql_select_limit
的值;
key 中任何一项变动(如切换数据库,重命名 Prepare
语句,执行 DDL,或修改 SQL Mode / time_zone
的值),或 LRU 淘汰机制触发都会导致 Execute
时无法命中执行计划缓存。
成功从缓存中获取到执行计划后,TiDB 会先检查执行计划是否依然合法,如果当前 Execute
在显式事务里执行,并且引用的表在事务前序语句中被修改,而缓存的执行计划对该表访问不包含 UnionScan
算子,则它不能被执行。
在通过合法性检测后,会根据当前最新参数值,对执行计划的扫描范围做相应调整,再用它执行获取数据。
关于执行计划缓存和查询性能有几点值得注意:
- 不管计划是否已经被缓存,都会受到 SQL Binding 的影响。对于没有被缓存的计划,即在第一次执行
Execute
时,会受到已有 SQL Binding 的影响;而对于已经缓存的计划,如果有新的 SQL Binding 被创建产生,则原有已经被缓存的计划会失效。 - 已经被缓存的计划不会受到统计信息更新、优化规则和表达式下推黑名单更新的影响,仍然会使用已经保存在缓存中的计划。
- 重启 TiDB 实例时(如不停机滚动升级 TiDB 集群),
Prepare
信息会丢失,此时执行execute stmt ...
可能会遇到Prepared Statement not found
的错误,此时需要再执行一次prepare stmt ...
。 - 考虑到不同
Execute
的参数会不同,执行计划缓存为了保证适配性会禁止一些和具体参数值密切相关的激进查询优化手段,导致对特定的一些参数值,查询计划可能不是最优。比如查询的过滤条件为where a > ? and a < ?
,第一次Execute
时参数分别为 2 和 1,考虑到这两个参数下次执行时可能会是 1 和 2,优化器不会生成对当前参数最优的TableDual
执行计划。 - 如果不考虑缓存失效和淘汰,一份执行计划缓存会对应各种不同的参数取值,理论上也会导致某些取值下执行计划非最优。比如查询过滤条件为
where a < ?
,假如第一次执行Execute
时用的参数值为 1,此时优化器生成最优的IndexScan
执行计划放入缓存,在后续执行Exeucte
时参数变为 10000,此时TableScan
可能才是更优执行计划,但由于执行计划缓存,执行时还是会使用先前生成的IndexScan
。因此执行计划缓存更适用于查询较为简单(查询编译耗时占比较高)且执行计划较为固定的业务场景。
目前执行计划缓存功能默认关闭,可以通过打开配置文件中 prepared-plan-cache
项启用这项功能。
在开启了执行计划缓存功能后,可以通过 session 级别的系统变量 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)
-- 第一次 execute 生成执行计划放入缓存
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)
-- 第二次 execute 命中缓存
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
由于执行计划缓存导致了非预期行为,可以通过 SQL Hint ignore_plan_cache()
让该组语句不使用缓存。还是用上述的 stmt
为例:
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)
手动清空计划缓存
通过执行 ADMIN FLUSH [SESSION | INSTANCE] PLAN_CACHE
语句,你可以手动清空计划缓存。
该语句中的作用域 [SESSION | INSTANCE]
用于指定需要清空的缓存级别,可以为 SESSION
或 INSTANCE
。如果不指定作用域,该语句默认清空 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; -- 清空当前 session 的计划缓存
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
级别的计划缓存,即不支持一次性清空整个集群的计划缓存,使用时会报错:
MySQL [test]> admin flush global plan_cache;
ERROR 1105 (HY000): Do not support the 'admin flush global scope.'
忽略 COM_STMT_CLOSE
指令和 DEALLOCATE PREPARE
语句
为了减少每次执行 SQL 语句的语法分析,Prepared Statement 推荐的使用方式是,prepare 一次,然后 execute 多次,最后 deallocate prepare。例如:
MySQL [test]> prepare stmt from '...'; -- prepare 一次
MySQL [test]> execute stmt using ...; -- execute 一次
MySQL [test]> ...
MySQL [test]> execute stmt using ...; -- execute 多次
MySQL [test]> deallocate prepare stmt; -- 使用完成后释放
如果你习惯于在每次 execute 后都立即执行 deallocate prepare,如:
MySQL [test]> prepare stmt from '...'; -- 第一次 prepare
MySQL [test]> execute stmt using ...;
MySQL [test]> deallocate prepare stmt; -- 一次使用后立即释放
MySQL [test]> prepare stmt from '...'; -- 第二次 prepare
MySQL [test]> execute stmt using ...;
MySQL [test]> deallocate prepare stmt; -- 再次释放
这样的使用方式会让第一次执行得到的计划被立即清理,不能在第二次被复用。
为了兼容这样的使用方式,从 v6.0 起,TiDB 支持 tidb_ignore_prepared_cache_close_stmt
变量。打开该变量后,TiDB 会忽略关闭 Prepare Statement 的信号,解决上述问题,如:
mysql> set @@tidb_ignore_prepared_cache_close_stmt=1; -- 打开开关
Query OK, 0 rows affected (0.00 sec)
mysql> prepare stmt from 'select * from t'; -- 第一次 prepare
Query OK, 0 rows affected (0.00 sec)
mysql> execute stmt; -- 第一次 execute
Empty set (0.00 sec)
mysql> deallocate prepare stmt; -- 第一次 execute 后立即释放
Query OK, 0 rows affected (0.00 sec)
mysql> prepare stmt from 'select * from t'; -- 第二次 prepare
Query OK, 0 rows affected (0.00 sec)
mysql> execute stmt; -- 第二次 execute
Empty set (0.00 sec)
mysql> select @@last_plan_from_cache; -- 因为开关打开,第二次依旧能复用上一次的计划
+------------------------+
| @@last_plan_from_cache |
+------------------------+
| 1 |
+------------------------+
1 row in set (0.00 sec)