Rust in Everything
基于 sea-orm 2.0.4

incorrect binary data format in bind parameter 1

主线位置:查询主线里 sqlx::query_with 之后的那一段,语句在连接上 Parse / Bind 的地方。sea-query 生成的 SQL 只取决于语句的形状,不取决于值的类型;sqlx 按 SQL 文本缓存预备语句,参数类型在第一次 Parse 时就定下了。两者叠在一起,就是本页的坑。机制在绑到 sqlx 的最后一节,缓存的结构见 sqlx 卷的扩展查询协议与语句缓存。

flowchart TB
  q1["eq 1_i32"] --> sql["同一段 SQL 文本"]
  q2["eq 1_i64"] --> sql
  sql --> hit{"这条连接缓存过吗"}
  hit -->|没有| parse["Parse 声明 $1 类型"]
  hit -->|有| bind["直接 Bind"]
  parse --> cache["进缓存"]
  bind --> mis{"类型一致吗"}
  mis -->|"缓存 int4, 绑 8 字节"| e1["22P03"]
  mis -->|"缓存 int8, 绑 4 字节"| e2["08P01"]

本页实验在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上运行,依赖 sea-orm = "=2.0.4"(sqlx 0.9.0),对照组是本站用的 sea-orm = "=1.1.20"(sqlx 0.8.6)。表和实体:

CREATE TABLE item (id int4 PRIMARY KEY, name text NOT NULL);
INSERT INTO item VALUES (1, 'one');

实体 item 的 id 是 i32。连接池 max_connections(1),保证每条语句都落在同一条连接上。

现象

src/bin/samesql.rs:

async fn by_id(db: &DatabaseConnection, v: impl Into<Value>) -> String {
  match item::Entity::find().filter(item::Column::Id.eq(v)).all(db).await {
    Ok(rows) => format!("Ok({} rows)", rows.len()),
    Err(e) => e.to_string(),
  }
}

// ...
  let db = Database::connect(options(no_cache)).await?;
  println!("i32: {}", by_id(&db, 1_i32).await);
  println!("i64: {}", by_id(&db, 1_i64).await);
  println!("i64: {}", by_id(&db, 1_i64).await);
  println!("i32: {}", by_id(&db, 1_i32).await);
  db.close().await?;

  let db = Database::connect(options(no_cache)).await?;
  println!("i64: {}", by_id(&db, 1_i64).await);
  println!("i32: {}", by_id(&db, 1_i32).await);
--- max_connections(1)
i32: Ok(1 rows)
i64: Query Error: error returned from database: incorrect binary data format in bind parameter 1 at line 1951
i64: Query Error: error returned from database: incorrect binary data format in bind parameter 1 at line 1951
i32: Ok(1 rows)
i64: Ok(1 rows)
i32: Query Error: error returned from database: insufficient data left in message at line 534

Debug 形式(同一个程序开头单独打印的一次):

Debug: Err(Query(SqlxError(Database(PgDatabaseError { severity: Error, code: "22P03", message: "incorrect binary data format in bind parameter 1", detail: None, hint: None, position: None, where: Some("unnamed portal parameter $1"), schema: None, table: None, column: None, data_type: None, constraint: None, file: Some("postgres.c"), line: Some(1951), routine: Some("exec_bind_message") }))))
  • 两条查询的 SQL 都是 SELECT "item"."id", "item"."name" FROM "item" WHERE "item"."id" = $1,只是 Value 的分支不同(Int 和 BigInt)。
  • 第一段:先 i32,之后的 i64 报 22P03,而且每次都报,不会自己恢复;i32 照常。
  • 第二段换一条新连接、先 i64:这次 i64 成功了(int4 = int8 在 Postgres 里是合法的比较),之后的 i32 报 08P01 insufficient data left in message。
  • 哪种写法会坏,取决于这条连接上谁先执行。池里有多条连接时,每条连接各自缓存,同一个调用有时成功有时失败。

本站用的 1.1.20(exp-seaorm-pit1-old/src/bin/samesql_old.rs,同样两段):

i32: Ok(1 rows)
i64: Query Error: error returned from database: incorrect binary data format in bind parameter 1
i64: Ok(1 rows)
i32: Query Error: error returned from database: insufficient data left in message

报错一样,只是 sqlx 0.8.6 的 Display 末尾没有 at line N。

出处

sea-orm 这一侧没有分支:sqlx_query 对每个 Statement 都是 sqlx::query_with(sqlx::AssertSqlSafe(stmt.sql.as_str()), SqlxValues(values)),用 sqlx 的默认值 persistent(true),Statement 上也没有关掉它的开关。

问题在 sqlx。get_or_prepare 收到了这次的参数类型 parameters: &[PgTypeInfo],查缓存时却只用 SQL 文本:

        if let Some(statement) = self.inner.cache_statement.get_mut(sql) {
            return Ok((*statement).clone());
        }

命中就跳过 Parse,直接用第一次 Parse 时声明的参数类型去 Bind。第一次是 int4,这次按 i64 编码成 8 字节,服务端按 4 字节的 int4 读,报 22P03;反过来缓存的是 int8,这次只给了 4 字节,服务端读不够 8 字节,报 08P01。同样的事换成 Int 和 String 也会发生,见绑到 sqlx。

不经过 sea-orm,直接用 sqlx 0.9.0 也一样(src/bin/samesql_sqlx.rs,PgPoolOptions::new().max_connections(1)):

const SQL: &str = "SELECT id FROM item WHERE id = $1";
// ...
  let p = pool().await?;
  fetch("i32                   ", sqlx::query(SQL).bind(1_i32), &p).await;
  fetch("i64                   ", sqlx::query(SQL).bind(1_i64), &p).await;
  fetch("i64, persistent(false)", sqlx::query(SQL).bind(1_i64).persistent(false), &p).await;
  p.close().await;

  let p = pool().await?;
  fetch("i32, persistent(false)", sqlx::query(SQL).bind(1_i32).persistent(false), &p).await;
  fetch("i64, persistent(false)", sqlx::query(SQL).bind(1_i64).persistent(false), &p).await;
--- sqlx 0.9.0, one connection
i32                   : Ok(1 rows)
i64                   : error returned from database: incorrect binary data format in bind parameter 1 at line 1951
i64, persistent(false): error returned from database: incorrect binary data format in bind parameter 1 at line 1951
--- new pool, every call persistent(false)
i32, persistent(false): Ok(1 rows)
i64, persistent(false): Ok(1 rows)

这是上游的 transact-rs/sqlx#2885(「Postgres: changing parameter types does not invalidate cached prepared statement」),2023 年提出,到 0.9.0 仍然开着。维护者在里面的分析是 Parse 时告诉服务端的参数类型把语句「lock」住了,修法应当是「include parameter types as part of the cache key」。sea-orm 和 sea-query 的 issue 里没有找到报这个现象的。

📝NOTE
没有蠢问题

问: issue 里给的临时办法是 .persistent(false),上面第一段为什么不管用?

答: get_or_prepare 先查缓存,命中就返回,persistent 只在没命中、准备写缓存的时候才看(if persistent && self.inner.cache_statement.is_enabled())。这条 SQL 已经被一次默认的 persistent(true) 放进了缓存,之后再写 persistent(false) 照样拿到旧语句。只有从这条连接上的第一次起每次都 persistent(false),才不会撞上,就是上面的第二段。sea-orm 发出的语句全是默认的 persistent(true),而且 sqlx_query 是 pub(crate),用户改不了。

修法

一列只用一种 Rust 类型。 这是唯一干净的修法。id 是 i32 的实体,过滤、is_in、Loader 的键都传 i32。Entity::find_by_id 的参数类型就是主键的 ValueType,天然一致;容易出错的是 Column::X.eq(v),它收任何 Into<Value>。dense 格式的强类型列 COLUMN.id.eq(..) 也帮不上忙,它只检查数值这个类别,COLUMN.id.eq(2_i64) 照样能编译(见列与值的「检查的是类别,不是宽度」)。值从别处来(解析出的 i64、JSON 里的数字)时,在调用前 i32::try_from(v) 转成列的类型,越界的值本来也查不到行。

关掉连接上的语句缓存,sea-orm 里能做到的只有这一个开关:ConnectOptions::map_sqlx_postgres_opts 把 sqlx 的 PgConnectOptions 交给你改。同一个程序的第二轮:

fn options(no_cache: bool) -> ConnectOptions {
  let mut opt = exp_seaorm_pit1::options(false, 1);
  if no_cache {
    opt.map_sqlx_postgres_opts(|o| o.statement_cache_capacity(0));
  }
  opt
}
--- max_connections(1), statement_cache_capacity(0)
i32: Ok(1 rows)
i64: Ok(1 rows)
i64: Ok(1 rows)
i32: Ok(1 rows)
i64: Ok(1 rows)
i32: Ok(1 rows)
after 100 more: pg_prepared_statements = 103

报错没了,代价在最后一行:再执行 100 次之后,这条连接的服务端会话里留着 103 条预备语句(默认配置下同样的操作之后是 2 条)。sqlx 0.9.0 在容量为 0 时不写缓存,但给语句起名只看 persistent,每次执行都建一条新的命名语句、没人关它,这是 sqlx 的 #4328,修复在 0.9.0 之后(见 sqlx 卷扩展查询协议与语句缓存的「statement_cache_capacity(0)」一节)。长寿的连接会让服务端进程的内存一直涨,所以在 sqlx 0.9.0 上这不是可以长期开着的修法,最多在排查时用来确认是不是这个坑。

在 SQL 里加类型转换也不解决问题:SQL 文本仍然和值的类型无关,还是同一个缓存键。src/bin/samesql_cast.rs 把条件写成 Column::Id.into_expr().eq(Expr::val(v).cast_as("int4")),在一条新连接上先 i64 后 i32:

SELECT "item"."id", "item"."name" FROM "item" WHERE "item"."id" = CAST($1 AS int4)
BigInt(Some(1)): Ok(1 rows)
Int(Some(1)): Query Error: error returned from database: insufficient data left in message at line 534

$1 的类型仍然由 sqlx 在 Parse 里按第一次的值声明成 int8,CAST 是在那之后才做的。

怎么确认没中招

  • 跑集成测试时把池设成 max_connections(1)。所有语句落在同一条连接上,一个查询在不同调用处传了不同宽度的整数,第二次就必然报错,不再是线上的「偶发」。
  • 线上看到 22P03 或 08P01,而且只有部分请求失败、同一个接口重试有时成功,基本就是它;22P03 的 where 字段是 unnamed portal parameter $N,N 指出是哪个参数。
  • 审代码时找同一个列的所有过滤点(Column::X.eq、is_in、gt 等),看传进去的值的类型是不是都和实体字段一致;同一个查询函数被不同的调用方用不同的整数类型调用,最容易出问题。

小结

  • sea-query 生成的 SQL 和值的类型无关;sqlx 按 SQL 文本缓存预备语句,参数类型在这条连接上第一次 Parse 时就定了。同一段 SQL 先 i32 后 i64 报 22P03,先 i64 后 i32 报 08P01,2.0.4 和 1.1.20 都一样。
  • 根子在 sqlx(#2885,到 0.9.0 仍开着),直接用 sqlx 也能复现;缓存命中之后 persistent(false) 也绕不过去,而 sea-orm 发出的语句全是 persistent(true)。
  • 修法:一列只用一种 Rust 类型,和实体字段一致。statement_cache_capacity(0) 能让报错消失,但在 sqlx 0.9.0 上每次执行泄漏一条服务端预备语句。
  • 测试里用一条连接的池,能把这个「偶发」错误变成必现。

💬 关联讨论

发起讨论
加载中...