原文地址:https://vladmihalcea.com/postgresql-plan-cache-mode/
本文阐述了PostgreSQL对于prepared statement预处理语句生成执行计划的规则,原文中并没有提及测试环境的PostgreSQL版本,笔者在PostgreSQL 16下测试,遇到了一些与原文不一致的地方,文末有详细的测试和验证。
以下为译文。
您是否正在为Spring、Jakarta EE或Java EE应用的性能问题所困扰?
想象一下,如果有款工具能自动检测JPA和Hibernate数据访问层的性能瓶颈根源,会怎样?
若能在开发阶段就借助这样的工具监控应用性能,在问题影响生产系统之前防患于未然,岂非绝佳之选?
Hypersistence Optimizer(译者注:原文作者推广的一款插件)正是这样的神器!它完美兼容Spring Boot、Spring Framework、Jakarta EE、Java EE、Quarkus、Micronaut及Play Framework。
与其在周六深夜紧急修复生产系统性能问题,不如使用Hypersistence Optimizer主动预防隐患,把宝贵时间留给真正热爱的事物!
Introduction
在本文中,我们将深入分析PostgreSQL的plan_cache_mode配置参数,并探讨何时适合采用force_custom_plan策略来替换generic plan通用执行计划。
Domain Model
我们假设有如下一个表结构
其中post_status 字段是一个如下的枚举类型:
- CREATE TYPE post_status AS ENUM (
- 'PENDING',
- 'APPROVED',
- 'SPAM'
- )
复制代码 假设该表中中有10万条贴文记录,但这些记录的status状态值分布极不均匀(存在严重的数据倾斜)。
例如,执行如下查询- SELECT
- a.*,
- matching_records::float/row_count AS selectivity
- FROM (
- SELECT
- status,
- COUNT(<em>) AS matching_records
- FROM post
- GROUP BY status
- ) a
- CROSS JOIN LATERAL (
- SELECT COUNT(</em>) AS row_count
- FROM post
- ) b
- ORDER BY selectivity DESC
复制代码 可以得到如下结果:
- | status | matching_records | selectivity |
- | -------- | ---------------- | ----------- |
- | APPROVED | 95000 | 0.95 |
- | PENDING | 4000 | 0.04 |
- | SPAM | 1000 | 0.01 |
复制代码 95% 的帖子是APPROVED状态.
4% 的帖子是PENDING状态.
1% 的帖子是spam状态.
基于status字段的筛选 Filtering by status
现在有基于status字段的这么一个查询,如下:- SELECT id, title, status
- FROM post
- WHERE status = ?
复制代码 为了避免全表扫描,大多数情况下,你可能会想到在status字段上创建一个如下的索引
CREATE INDEX idx_post_status ON post (status)
尽管如此,基于status字段的索引的选择性(selectivity),上面的查询的执行的时候,PostgreSQL不一定会用到idx_post_status这个索引
case 1
比如,当用status为PENDING为过滤条件的时候,PostgreSQL的执行计划如下- Bitmap Heap Scan on post
- (cost=46.44..1231.07 rows=3890 width=57)
- (actual time=0.233..1.132 rows=4000 loops=1)
- Recheck Cond: (status = 'PENDING'::post_status)
- Heap Blocks: exact=46
- -> Bitmap Index Scan on idx_post_status
- (cost=0.00..45.47 rows=3890 width=0)
- (actual time=0.211..0.212 rows=4000 loops=1)
- Index Cond: (status = 'PENDING'::post_status)
复制代码 执行计划首先扫描idx_post_status索引,然后通过bitmap在内存页面中标记处目标数据库所在的数据页,随后,位图堆扫描(Bitmap Heap Scan) 会根据位图中的页面标识符,到 post 表的数据页中定位具体记录。
case 2
当根据status字段为SPAM的条件过滤的时候,PostgreSQL将会使用index scan替代bitmap index scan。- Index Scan using idx_post_status on post
- (cost=0.29..1089.83 rows=963 width=57)
- (actual time=0.059..0.500 rows=1000 loops=1)
- Index Cond: (status = 'SPAM'::post_status)
复制代码 case 3
然而,如果我们通过status为APPROVED的条件进行过滤,PostgreSQL将会忽略idx_post_status索引,并在Post表上使用full-table scan。- Seq Scan on post
- (cost=0.00..2386.00 rows=95147 width=57)
- (actual time=0.034..36.765 rows=95000 loops=1)
- Filter: (status = 'APPROVED'::post_status)
- Rows Removed by Filter: 5000
复制代码 当需要扫描表中 95% 的数据时,使用索引的成本反而远高于全表扫描, 这就是为什么按 APPROVED 状态过滤时会选择顺序扫描。
正如前文中所述,由于状态值分布极不均匀,我们只需为选择性高的值(如 PENDING 和 SPAM)建立索引才具有实际意义。
因此,既然 idx_post_status 索引无法用于 APPROVED 状态值,我们可以将其修改为排除 APPROVED值的部分索引:
CREATE INDEX idx_post_status ON post (status) WHERE (status 'APPROVED')
默认的PostgreSQL plan_cache_mode
正如在这篇文章里提到的,PostgreSQL对于预处理语句(prepared statement),执行的前4次,生成定制化执行计划(custom execution plan)。从第5次开始,PostgreSQL将会生成一个通用执行计划(generic plan)来替代定制化执行计划。
译者注:这里并不清楚原文作者测试的PostgreSQL版本,测试中提到“前4次执行并未在服务器端预编译,从第5次开始,预处理语句就使用了在当前连接期间缓存的通用执行计划。”,这一点于笔者在PostgreSQL 16中的测试并不相符,详见本文末尾的测试。
我们可以使用如下的测试案例来验证这种行为:- executeStatement(
- connection,
- "LOAD 'auto_explain'",
- "SET auto_explain.log_analyze=true",
- "SET auto_explain.log_min_duration=0"
- );
- LOGGER.info(
- "Plan cache mode: {}",
- selectColumn(
- connection,
- "SHOW plan_cache_mode",
- String.class
- )
- );
- try (PreparedStatement statement = connection.prepareStatement("""
- SELECT id, title, status
- FROM post
- WHERE status = ?
- """)) {
- for (int i = 1; i <= 10; i++) {
- executeStatementWithStatus(statement, PostStatus.APPROVED);
- }
- executeStatementWithStatus(statement, PostStatus.SPAM);
- }
复制代码 太棒了,对吧?
结论
当执行预处理语句时,PostgreSQL 可能采用定制执行计划(custom plan)或通用执行计划(generic plan)。
若发现 PostgreSQL 选择的通用执行计划不适用于当前绑定参数值,则可采用 force_custom_plan 策略,强制 PostgreSQL 跳过通用计划,改为每次查询都生成定制执行计划。
笔者验证测试于脚本
文中并没有提及测试环境的PostgreSQL版本,笔者在PostgreSQL 16下测试,遇到了一些与原文不一致的地方,也就是说,在预处理语句会针对不同的参数值生成不同的执行计划,执行超过5次之后,会生成通用执行计划,但是该通用执行计划并不是非常规参数的值对应的执行计划,而是基于一个常规值,比如前5次用一个分布站总行数95%的值做查询,执行计划是seq table scan, 最后生成generic plan的时候,并非基于该倾斜值生成的,而是基于一个一般值,生成一个index scan的执行计划缓存。- protected int executeStatementWithStatus(
- PreparedStatement statement,
- PostStatus status) throws SQLException {
- LOGGER.info(
- "Statement is {}prepared on the server",
- PostgreSQLQueries.isUseServerPrepare(statement) ?
- "" :
- "not "
- );
- int rowCount = 0;
- statement.setObject(
- 1,
- PostgreSQLQueries.toEnum(status, "post_status"),
- Types.OTHER
- );
- try(ResultSet resultSet = statement.executeQuery()) {
- while (resultSet.next()) {
- rowCount++;
- }
- }
- return rowCount;
- }
复制代码
笔者的测试环境是PostgreSQL 16,于原文中的测试有一些出入,总结起来就是:PostgreSQL在为prepared statement生成执行generic plan计划的时候,并不会为倾斜值(分布多,唯一性很低,默认情况下会做seq scan)生成执行计划,而是基于一个常规值生成执行计划(常规值是唯一性高,选择性高),这一点可以认为是PostgreSQL改进的一个特性。
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |