Rust in Everything
基于 sqlx 0.9.0

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 类型解析,其中一节是让用户能控制类型缓存)。

📝NOTE
没有蠢问题

问: 问题出在客户端的 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 不变);必须重建时换掉所有老连接,或者只对这一种错误清缓存重试。

💬 关联讨论

发起讨论
加载中...