cache lookup failed for type N
主线位置:查询主线上
prepare发Parse之前的类型解析(resolve_types),以及已缓存语句的Bind。名字到 OID 的解析和缓存见Postgres 类型;本页讲这份缓存过期以后的现象和修法。
flowchart TB first["第一次绑定 Mood"] --> cache["连接缓存 mood 到 OID"] cache --> ddl["别处 DROP 再 CREATE TYPE"] ddl --> newoid["服务端 mood 换了 OID"] newoid --> q["老连接再绑定 Mood"] q --> hit["缓存命中旧 OID"] hit --> err["XX000 cache lookup failed"]
本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sqlx = "=0.9.0";换成 =0.8.6 除了错误里 0.9 才有的 at line N 之外,结果相同。
现象
一次迁移把 enum 删掉重建了(DROP TYPE mood CASCADE; CREATE TYPE mood AS ENUM (...)),应用没有重启。之后所有绑定这个类型参数的查询都失败:
cache lookup failed for type 17552 at line 2546
错误码 XX000(internal_error),17552 是旧的 OID;at line 2546 是服务端 C 源码的行号(见错误)。新建的连接没事,所以这个错误常常时有时无,取决于请求落在哪条连接上。
最小复现
src/bin/stale_oid.rs。Mood 是 #[derive(sqlx::Type)] 的 enum,type_name = "mood";setup 是另一条连接,负责删掉重建类型:
const RECREATE: &str = "drop type if exists mood cascade; create type mood as enum ('happy', 'sad');";
async fn bind_mood<'e, E: Executor<'e, Database = sqlx::Postgres>>(e: E, sql: &'static str) -> Result<String, sqlx::Error> {
sqlx::query_scalar(sql).bind(Mood::Happy).fetch_one(e).await
}
let mut conn = PgConnection::connect_with(&opts).await?;
show("第一次 S1", bind_mood(&mut conn, "select $1::text").await);
sqlx::raw_sql(RECREATE).execute(&mut setup).await?;
show("同一条 S1(语句已缓存)", bind_mood(&mut conn, "select $1::text").await);
show("新的 S2", bind_mood(&mut conn, "select $1::text || '!'").await);
let r: Result<Mood, _> = sqlx::query_scalar("select 'happy'::mood").fetch_one(&mut conn).await;
show("结果列是 mood", r);
conn.clear_cached_statements().await?;
show("clear_cached_statements 之后 S2", bind_mood(&mut conn, "select $1::text || '!'").await);
OID 用 select 'mood'::regtype::oid 查(persistent(false),否则这条查询自己的缓存计划也会给出旧值):
mood 的 OID = 17552
--- 删掉重建 ---
第一次 S1 -> Ok("happy")
重建之后 mood 的 OID = 17558
同一条 S1(语句已缓存) -> [XX000] cache lookup failed for type 17552 at line 2943
新的 S2 -> [XX000] cache lookup failed for type 17552 at line 2546
结果列是 mood -> Ok(Happy)
clear_cached_statements 之后 S2 -> Ok("happy!")
参数一侧两种都坏:已缓存的语句 S1 坏在服务端(预备语句的参数类型是旧 OID,报错的行号是 2943),新 SQL 的 S2 坏在客户端(Parse 带着旧 OID 发出去,行号 2546)。结果一侧没事:RowDescription 里是新 OID,客户端按 OID 反查时缓存里没有,会重新查,再按名字和 Mood 比较。
连接池里每条连接各有一份缓存。两条连接都绑定过 Mood 的池,重建之后:
--- 连接池:两条老连接 ---
pool 第 1 次 S2 -> [XX000] cache lookup failed for type 17566 at line 2546
pool 第 2 次 S2 -> [XX000] cache lookup failed for type 17566 at line 2546
pool 第 3 次 S2 -> [XX000] cache lookup failed for type 17566 at line 2546
原因
自定义类型的 OID 每次 CREATE TYPE 都会变,所以 #[derive(sqlx::Type)] 只声明名字(DeclareWithName)。resolve_types 在发 Parse 之前把名字换成 OID,先走 try_type_to_oid:
match &ty.0 {
PgType::DeclareWithName(name) => self.inner.cache_type_oid.get(name).copied(),
// ...
}
cache_type_oid 是连接上的一个 HashMap<UStr, Oid>,命中就直接返回,没有过期、没有校验,服务端也不会通知客户端「这个类型没了」。唯一清它的地方是 clear_cached_statements,第一行 self.inner.cache_type_oid.clear(),接着把所有缓存的预备语句 Close 掉。不调它,缓存就和连接同生同死;池里的连接要等 max_lifetime(默认 30 分钟)或 idle_timeout 被换掉。
0.8.6 用的是另一套解析代码(逐个类型发预备语句查),名字到 OID 的缓存一样没有失效机制,结果相同。用 gh search issues -R transact-rs/sqlx 搜 cache lookup failed、cache lookup failed for type、recreate enum、enum oid、custom type oid,未找到上游 issue;相关的只有一个开放的提议 #1797(重构 Postgres 类型解析,其中一节是让用户能控制类型缓存)。
问: 问题出在客户端的 OID 缓存,那已经缓存的预备语句 S1 为什么也报错?只清 cache_type_oid 够不够?
答: 不够。S1 是服务端的命名预备语句,Parse 时参数类型就定成了旧 OID,之后每次只发 Bind,服务端按旧 OID 找类型,所以报错来自服务端(行号 2943,不是 2546)。sqlx 也没有公开「只清类型缓存」的方法,clear_cached_statements 两样一起清:类型缓存清空,所有缓存语句 Close 掉,下一次重新 Parse、重新解析名字。
修法
能不删就不删:用 ALTER TYPE。 加值、改值名都不换 OID,老连接照常工作:
sqlx::raw_sql("alter type mood add value 'meh'; alter type mood rename value 'sad' to 'blue'").execute(&mut setup).await?;
--- 改用 ALTER TYPE ---
第一次 S1 -> Ok("happy")
ALTER 之后 mood 的 OID = 17558
同一条 S1 -> Ok("happy")
新的 S2 -> Ok("happy!")
删值、调整顺序这类 ALTER TYPE 做不到的改动只能重建,那就用下面两种之一。
重建类型之后,换掉所有老连接。 重启进程,或者 pool.close().await 之后建一个新池。迁移和应用在同一个进程里时,在迁移完成之后再建应用用的池。
出错时清缓存重试一次。 只认这一种错误,避免把别的错误也重试掉:
async fn with_retry(pool: &PgPool) -> Result<String, sqlx::Error> {
let mut conn = pool.acquire().await?;
match bind_mood(&mut *conn, "select $1::text || '?'").await {
Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("XX000") && e.message().starts_with("cache lookup failed for type") => {
println!(" {},清缓存重试", e.message());
conn.clear_cached_statements().await?;
bind_mood(&mut *conn, "select $1::text || '?'").await
}
r => r,
}
}
--- 连接池:失败时清缓存重试 ---
cache lookup failed for type 17566,清缓存重试
with_retry 第 1 次 -> Ok("happy?")
cache lookup failed for type 17566,清缓存重试
with_retry 第 2 次 -> Ok("happy?")
with_retry 第 3 次 -> Ok("happy?")
两条老连接各重试一次,各自恢复;第三次落在已经清过的连接上,直接成功。代价是每次清缓存都把这条连接上的预备语句全部关掉。在事务里时不能这样重试:XX000 已经让事务进入失败状态,要回滚整个事务再重来。
怎么确认自己没中招
- 查迁移脚本里有没有
DROP TYPE(以及会连带删类型的DROP ... CASCADE),和应用里#[derive(sqlx::Type)]声明的名字对一下。 - 线上出现
cache lookup failed for type而且只有部分请求失败,就是老连接;select '<类型名>'::regtype::oid(persistent(false))查到的 OID 和报错里的不一样,就能确认。
小结
- 自定义类型的名字到 OID 的映射缓存在每条连接上(
cache_type_oid),没有失效机制;类型删掉重建后,绑定它的查询报XX000 cache lookup failed for type <旧 OID>,已缓存和新写的 SQL 都一样,结果列不受影响。 - 唯一的清理入口是
clear_cached_statements,它同时关掉所有缓存语句;否则要等连接被换掉。0.8.6 和 0.9.0 都是这样。 - 修法:优先
ALTER TYPE(OID 不变);必须重建时换掉所有老连接,或者只对这一种错误清缓存重试。