Rust in Everything
基于 sqlx 0.9.0

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 里每次执行都在服务端留下一条不关闭的命名语句(见扩展查询协议与语句缓存)。

📝NOTE
没有蠢问题

问: 我的迁移是 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 之后,事务里仍然不重试。

💬 关联讨论

发起讨论
加载中...