cached plan must not change result type
主线位置:查询主线的
get_or_prepare。同一条 SQL 第二次执行时直接命中连接上的语句缓存,跳过Parse,只发Bind/Execute;服务端重新规划时发现结果的列变了,拒绝执行。语句缓存的结构见扩展查询协议与语句缓存。
flowchart TB first["select * 第一次"] --> cache["sqlx_s_1 进缓存"] cache --> ddl["别处 ALTER TABLE 加列"] ddl --> again["同一条 SQL 再执行"] again --> hit["缓存命中 只发 Bind"] hit --> err["ErrorResponse 0A000"] err --> again clear["clear_cached_statements"] --> reparse["重新 Parse"]
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0"。
现象
应用开着,另一个进程(另一个服务、一次手工迁移、新版本的滚动发布)给表加了一列。之后应用里一部分查询开始报:
error returned from database: cached plan must not change result type at line 722
错误码 0A000(feature_not_supported)。末尾的 at line 722 是服务端 C 源码的行号(见错误)。重试不会好,同一条连接上同一条 SQL 永远报这个错。
最小复现
src/bin/cached_plan.rs:一条连接,三条查询各执行一次;然后在另一条连接上 alter table users add column email text,再在原来的连接上执行:
const STAR: &str = "select * from users where id = $1";
const COLS: &str = "select id, name from users where id = $1";
const STAR_NP: &str = "select *, 1 as one from users where id = $1";
// run() = sqlx::query(sql).bind(1_i64).persistent(p).fetch_one(&mut *conn)
run(&mut conn, "select * ", STAR, true).await;
run(&mut conn, "select id, name ", COLS, true).await;
run(&mut conn, "select * (np) ", STAR_NP, false).await;
STAR_NP 是一条从第一次起就用 persistent(false) 的 select *。只在出错那一步打开了打印协议消息的代理(一次查询的全过程 里的那个):
--- ALTER 之前 ---
select * : Ok,2 列
select id, name : Ok,2 列
select * (np) : Ok,3 列
缓存里的语句数 = 2
--- 另一条连接: alter table users add column email text ---
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
[conn 1] S->C ErrorResponse("cached plan must not change result type"), ReadyForQuery(I)
select * : Err error returned from database: cached plan must not change result type at line 722
code = Some("0A000")
select * 再来一次 : Err error returned from database: cached plan must not change result type at line 722
code = Some("0A000")
select * persistent(false): Err error returned from database: cached plan must not change result type at line 722
code = Some("0A000")
select id, name : Ok,2 列
select * (np) : Ok,4 列
- 出错的那次没有
Parse,直接Bind到缓存里的sqlx_s_1。 - 第二次照样报,连接不会自己恢复。
- 对已经在缓存里的 SQL 改用
persistent(false)没用,它照样用缓存的命名语句。 - 写明列名的
select id, name不受影响:加列不改变它的结果类型。 - 从一开始就
persistent(false)的那条每次都重新Parse,加列之后直接拿到 4 列。
出处
报错来自 Postgres,不是 sqlx。命名预备语句在服务端记着第一次 Parse 时的结果行类型;表结构变了,服务端重新规划时发现新计划返回的列和原来的不一样,就拒绝执行。sqlx 这边的原因是 get_or_prepare 按 SQL 原文查缓存,命中就直接返回:
if let Some(statement) = self.inner.cache_statement.get_mut(sql) {
return Ok((*statement).clone());
}
缓存里还是那个 sqlx_s_1,0.9.0 收到这个错误后什么也不做,只把它交给调用方,缓存条目原样留着,下一次又命中它。persistent(false) 为什么救不回来也在这里:查缓存在看 persistent 之前(见扩展查询协议与语句缓存的 persistent(false) 一节)。
连接池让范围更大:池里每条执行过这条 SQL 的连接各自缓存了一份,每条都会报,直到连接被 idle_timeout 或 max_lifetime 回收(默认 10 分钟和 30 分钟,见 PoolOptions 与默认值)。
在事务里撞上它更麻烦,错误把整个事务打成 aborted,之后这个事务里的每条语句都报 25P02:
--- 事务里 ---
select * : Err error returned from database: cached plan must not change result type at line 722
code = Some("0A000")
select 1 as x : Err error returned from database: current transaction is aborted, commands ignored until end of transaction block at line 1498
code = Some("25P02")
上游的记录是 #1250(「ERROR: cached plan must not change result type」,2021 年开的)。修复 #4375(「[PostgreSQL] Invalided stale prepared statements」)在 2026-09-09 合并,晚于 0.9.0,写这一页时还没有带它的发布版本。它的做法是在 run 里认出这个错误,把这条 SQL 的缓存条目作废后重新准备、重试一次;但在事务里不重试,代码注释写着「If we were in transaction mode we can't retry statement, so we can immediately return err」。所以即使升级到带修复的版本,事务里的 select * 照样会报一次。
修法
写明列名。 这是唯一不依赖时机的修法:加列不改变 select id, name 的结果类型,上面的输出里它一直是 Ok。returning * 同理,写成 returning id, name。
做完 DDL 之后清缓存。 clear_cached_statements 给缓存里每条语句发一个 Close,之后同一条 SQL 重新 Parse:
conn.clear_cached_statements().await?;
--- clear_cached_statements ---
缓存里的语句数 = 0
select * : Ok,3 列
它是 Connection 上的方法,一次只管一条连接。Pool 没有「对每条连接执行一次」的接口,DDL 由别的进程做的时候应用也不知道该什么时候清。实际能做的是 DDL 之后重建池(pool.close().await 再新建)或重启应用。
从第一次起就 persistent(false)。 只对很少执行、又必须写 select * 的语句才划算:每次都多一个 Parse 来回。不要用 statement_cache_capacity(0) 来达到同样的目的,它在 0.9.0 里每次执行都在服务端留下一条不关闭的命名语句(见扩展查询协议与语句缓存)。
问: 我的迁移是 migrate! 嵌在二进制里、启动时跑的,也会遇到吗?
答: 启动时先迁移、再处理请求,那次迁移之前连接上没有缓存过业务查询,不会撞上。会撞上的是「DDL 发生时还有旧连接活着」的情形:多个实例滚动发布时,新实例的迁移给表加了列,旧实例的连接池里还缓存着 select *;或者另一个服务、一个运维脚本改了表。#1250 的评论里描述的就是这类场景。
怎么确认没中招
- 搜代码里的
select *、returning *,尤其是经query_as/FromRow读结构体的那些:结构体只读几个字段,SQL 却取了全部列。 - 在预发环境开着应用跑一次加列的迁移,然后打一遍流量,看有没有
0A000。 - 日志里按错误码
0A000或原文搜。
小结
cached plan must not change result type(0A000)是 Postgres 拒绝执行一条结果列已经变了的命名预备语句;sqlx 0.9.0 按 SQL 原文命中缓存,只发Bind,所以表加列之后select *在每条缓存过它的连接上都报,直到连接被回收。- 对已缓存的 SQL 改用
persistent(false)无效;事务里撞上会把事务打成 aborted(25P02)。 - 修法:写明列名;DDL 之后
clear_cached_statements或重建池;极少执行的语句从一开始就persistent(false)。不要用statement_cache_capacity(0)。 - 上游 #1250,自动作废并重试的修复 #4375 合并于 0.9.0 之后,事务里仍然不重试。