Rust in Everything
基于 sqlx 0.9.0

本站怎么用 sqlx

主线位置:查询主线和连接池主线,在 sqlx 0.8.6 上,由 sea-orm 1.1.20 驱动。本站启动时把五个池参数交给 sea-orm,sea-orm 翻成 PoolOptions 和 PgConnectOptions 建池(连接池主线的起点);之后每条 Entity 查询都由 sea-query 生成 SQL,经 sqlx::query_with 走到 PgConnection::run(查询主线)。编译期检查主线在本站不存在:sqlx 的 macros feature 没开,代码里也没有一处 query!。

flowchart TB
  bco["pool.rs 五个参数"] --> spo["sqlx_pool_options"]
  bco --> url["解析 URL"]
  url --> logs["设语句日志"]
  spo --> cw["connect_with"]
  logs --> cw
  cw --> pool["PgPool"]
  mig["Migrator::up"] --> pool
  ent["Entity 查询"] --> sq["sea-query 出 SQL"]
  sq --> qw["query_with"]
  qw --> acq["pool.acquire"]
  pool --> acq
  acq --> run["PgConnection::run"]

本页的实验 crate 依赖 sea-orm = "=1.1.20"(sqlx-postgres、runtime-tokio-rustls、macros,默认 feature 和本站一样)和 sea-orm-migration = "=1.1.20",拷了本站的 Cargo.lock,离线编译,解析出的 sqlx 是 0.8.6。build_connect_options 原样拷进实验 crate,连同读环境变量的两个函数,所以实验能和本站一样用 DB_MAX_CONN 这些变量改参数。平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14(本机实验库,表建在自己的 schema 里)。

sqlx 从哪来,开了哪些 feature

工作区 Cargo.toml 写的是 sea-orm = { version = "1.1.4", features = [ "sqlx-postgres", "runtime-tokio-rustls", "macros" ] },Cargo.lock 解到 1.1.20;sea-orm 1.1.20 的 Cargo.toml 里 sqlx 是 version = "0.8.4"、default-features = false、optional = true,锁到 0.8.6。实际打开的 feature 用 cargo tree --offline --locked -f '{p} [{f}]' -p sqlx@0.8.6 --depth 0 看:

sqlx v0.8.6 [_rt-tokio,bigdecimal,chrono,json,postgres,runtime-tokio,runtime-tokio-rustls,rust_decimal,sqlx-postgres,time,tls-rustls-ring,tls-rustls-ring-webpki,uuid]

runtime-tokio-rustls 是 0.8 里还保留的组合写法,展开成 tokio 运行时加 tls-rustls-ring-webpki;类型 feature 由 sea-orm 的 with-* 默认 feature 带进来。没有 macros,query! 系列在本站编译不出来。也没有 migrate,但 sqlx-core 的 migrate 和 offline 照样是开的:sqlx 0.8.6 门面的 Cargo.toml 把 sqlx-core 写成 features = ["offline", "migrate"],不管下游要不要。本站代码对 sqlx:: 的 git grep 没有结果,下面所有对 sqlx 的调用都发生在 sea-orm 里。

ConnectOptions 怎么变成 PoolOptions

本站的池参数全在 build_connect_options:

fn build_connect_options(url: &str) -> ConnectOptions {
  let mut opt = ConnectOptions::new(url.to_string());
  opt
    .max_connections(read_env_u32("DB_MAX_CONN", DEFAULT_MAX_CONNECTIONS))
    .min_connections(read_env_u32("DB_MIN_CONN", DEFAULT_MIN_CONNECTIONS))
    .connect_timeout(Duration::from_secs(read_env_secs(
      "DB_CONNECT_TIMEOUT_SECS",
      DEFAULT_CONNECT_TIMEOUT_SECS,
    )))
    .acquire_timeout(Duration::from_secs(read_env_secs(
      "DB_ACQUIRE_TIMEOUT_SECS",
      DEFAULT_ACQUIRE_TIMEOUT_SECS,
    )))
    .idle_timeout(Duration::from_secs(read_env_secs(
      "DB_IDLE_TIMEOUT_SECS",
      DEFAULT_IDLE_TIMEOUT_SECS,
    )))
    // sqlx 默认 INFO 太吵;DEBUG 仍能打开 SQL 语句日志
    .sqlx_logging_level(tracing::log::LevelFilter::Debug);
  opt
}

五个常量依次是 32、2、5 秒、5 秒、600 秒。sea-orm 的 ConnectOptions 把这些值存成 Option,ConnectOptions::new 里全是 None,另外 test_before_acquire: true、sqlx_logging_level: Info、sqlx_slow_statements_logging_level: Off(sea-orm-1.1.20/src/database/mod.rs)。翻成 sqlx 的池参数是 src/driver/sqlx_common.rs 里的 sqlx_pool_options:

    pub fn sqlx_pool_options<DB>(self) -> sqlx::pool::PoolOptions<DB>
    where
        DB: sqlx::Database,
    {
        let mut opt = sqlx::pool::PoolOptions::new();
        if let Some(max_connections) = self.max_connections {
            opt = opt.max_connections(max_connections);
        }
        if let Some(min_connections) = self.min_connections {
            opt = opt.min_connections(min_connections);
        }
        if let Some(connect_timeout) = self.connect_timeout {
            opt = opt.acquire_timeout(connect_timeout);
        }
        if let Some(idle_timeout) = self.idle_timeout {
            opt = opt.idle_timeout(Some(idle_timeout));
        }
        if let Some(acquire_timeout) = self.acquire_timeout {
            opt = opt.acquire_timeout(acquire_timeout);
        }
        if let Some(max_lifetime) = self.max_lifetime {
            opt = opt.max_lifetime(Some(max_lifetime));
        }
        opt = opt.test_before_acquire(self.test_before_acquire);
        opt
    }

从 PoolOptions::new 的默认值出发,只改设了值的那几项。connect_timeout 和 acquire_timeout 调的是同一个 setter,后写的赢,而 acquire_timeout 总在后面。用拷过来的 build_connect_options 和两个对照组打出 sqlx 拿到的参数(src/bin/pool_opts.rs):

let opt = build_connect_options(&lab_url("site-pool"));
show("site ", &opt.clone().sqlx_pool_options::<Postgres>());
show("sqlx ", &sea_orm::sqlx::pool::PoolOptions::<Postgres>::new());

let mut only_connect = ConnectOptions::new(lab_url("x"));
only_connect.connect_timeout(Duration::from_secs(1));
show("only connect_timeout(1s)", &only_connect.sqlx_pool_options::<Postgres>());
let mut both = ConnectOptions::new(lab_url("x"));
both.acquire_timeout(Duration::from_secs(5)).connect_timeout(Duration::from_secs(1));
show("acquire_timeout(5s) + connect_timeout(1s)", &both.sqlx_pool_options::<Postgres>());

let db = Database::connect(opt).await?;
let pool = db.get_postgres_connection_pool();
tokio::time::sleep(Duration::from_millis(300)).await;
println!(
  "after Database::connect: size={} num_idle={} backends={:?}",
  pool.size(),
  pool.num_idle(),
  backends(&mut obs, "site-pool").await?
);

show 用 PoolOptions 的 getter 打印;backends 查 pg_stat_activity 里 application_name 等于池名字的后端(实验的 URL 带了 application_name,本站的没有)。

site : max=32 min=2 acquire_timeout=5s idle_timeout=Some(600s) max_lifetime=Some(1800s) test_before_acquire=true slow_threshold=2s
sqlx : max=10 min=0 acquire_timeout=30s idle_timeout=Some(600s) max_lifetime=Some(1800s) test_before_acquire=true slow_threshold=2s
only connect_timeout(1s): max=10 min=0 acquire_timeout=1s idle_timeout=Some(600s) max_lifetime=Some(1800s) test_before_acquire=true slow_threshold=2s
acquire_timeout(5s) + connect_timeout(1s): max=10 min=0 acquire_timeout=5s idle_timeout=Some(600s) max_lifetime=Some(1800s) test_before_acquire=true slow_threshold=2s
after Database::connect: size=2 num_idle=2 backends=[(821, "idle"), (822, "idle")]

逐项对照:

本站设的sea-orm 交给 sqlxsqlx 默认结果讲它的页
max_connections(32)max_connections(32)10信号量 32 个许可PoolOptions 与默认值
min_connections(2)min_connections(2)0建池时先连两条,被回收后补回归还与维护
connect_timeout(5s)acquire_timeout(5s),随即被下一行覆盖—没有单独的效果本页下文
acquire_timeout(5s)acquire_timeout(5s)30 秒借连接、建池最多等 5 秒获取连接
idle_timeout(600s)idle_timeout(Some(600s))10 分钟和默认值相同归还与维护
没设 max_lifetime不调30 分钟30 分钟同上
没设 test_before_acquiresea-orm 默认 truetrue每次借出先 ping获取连接
—sea-orm 没有对应项2 秒、Warn借连接超过 2 秒打 WARN同上
URL 里没有 statement-cache-capacity—100每条连接最多 100 条预备语句扩展查询协议与语句缓存

connect_timeout 这个名字容易让人以为它限制的是一次 TCP 连接或一次握手,sqlx 里没有这样的选项:连接的超时归连接池的 acquire_timeout 管(见连接选项与建立连接)。sea-orm 的 connect_timeout 的文档写的就是「Set the timeout duration when acquiring a connection」,和 acquire_timeout 是同一件事。本站把两个都设成 5 秒,DB_CONNECT_TIMEOUT_SECS 改成多少都不起作用,本页的动脑筋就是这个。

📝NOTE
冷知识:sqlx 的这个字段原来就叫 connect_timeout

0.6.0 之前,sqlx 的 PoolOptions 里是 connect_timeout,文档是「Set the amount of time to attempt connecting to the database. If this timeout elapses, [Pool::acquire] will return an error.」可它管的一直是整个 acquire。PR #1901(「pool fixes and breaking changes」,首次发布于 0.6.0)把它改了名,提交说明和 CHANGELOG 都写着「Renamed PoolOptions::connect_timeout to acquire_timeout for clarity.」字段、setter 和 acquire 外面那层 timeout(self.options.connect_timeout, ..) 是在同一个提交里一起改名的。sea-orm 1.1.20 的 ConnectOptions 两个名字都留着,翻译时都落到 acquire_timeout 上;PoolOptions 的 Debug 到 0.9.0 还把这个字段打印成 connect_timeout(见 PoolOptions 与默认值)。

max_lifetime 本站没设,sea-orm 不调 setter,于是是 sqlx 的 30 分钟。min(idle_timeout, max_lifetime) 是 10 分钟,维护任务每 10 分钟跑一轮(机制见归还、Drop 与维护任务)。

本站的 init_pool 调 Database::connect(build_connect_options(url)),URL 以 postgres:// 开头,转到 sea-orm-1.1.20/src/driver/sqlx_postgres.rs 的 SqlxPostgresConnector::connect:

        let mut sqlx_opts = options
            .url
            .parse::<PgConnectOptions>()
            .map_err(sqlx_error_to_conn_err)?;
        use sqlx::ConnectOptions;
        if !options.sqlx_logging {
            sqlx_opts = sqlx_opts.disable_statement_logging();
        } else {
            sqlx_opts = sqlx_opts.log_statements(options.sqlx_logging_level);
            if options.sqlx_slow_statements_logging_level != LevelFilter::Off {
                sqlx_opts = sqlx_opts.log_slow_statements(
                    options.sqlx_slow_statements_logging_level,
                    options.sqlx_slow_statements_logging_threshold,
                );
            }
        }
        // ...
        let lazy = options.connect_lazy;
        let mut pool_options = options.sqlx_pool_options();
        // ...
        let pool = if lazy {
            pool_options.connect_lazy_with(sqlx_opts)
        } else {
            pool_options
                .connect_with(sqlx_opts)
                .await
                .map_err(sqlx_error_to_conn_err)?
        };

PgConnectOptions 完全来自 URL,sea-orm 只改日志设置。本站没设 schema_search_path,所以没有 after_connect 回调;connect_lazy 默认 false,走 connect_with:建池时补足 min_connections,再借一条 ping 过的连接还回去。上面实验的最后一行就是结果,两条空闲连接。connect_lazy 加 min_connections 不预热的那个坑(connect_lazy 与 min_connections)够不着本站。

日志:sqlx_logging_level 管的是哪一条

sea-orm 只在自己的慢查询级别不是 Off 时才调 log_slow_statements,而它的默认就是 Off,于是 sqlx 自己的慢查询设置原封不动。sqlx 0.8.6 的 LogSettings 默认值:

impl Default for LogSettings {
    fn default() -> Self {
        LogSettings {
            statements_level: LevelFilter::Debug,
            slow_statements_level: LevelFilter::Warn,
            slow_statements_duration: Duration::from_secs(1),
        }
    }
}

所以 pool.rs 那行注释里「sqlx 默认 INFO 太吵」的 INFO 是 sea-orm 的默认(ConnectOptions::new 里的 sqlx_logging_level: log::LevelFilter::Info),sqlx 自己的默认本来就是 Debug。不调 sqlx_logging_level,在本站默认的 RUST_LOG=info 下每条语句都会打一行(src/bin/log_default.rs,ConnectOptions::new(url) 直接建池,执行一条 select 1):

 INFO sqlx::query: summary="select 1" db.statement="" rows_affected=1 rows_returned=1 elapsed=1.208667ms elapsed_secs=0.001208667

本站的订阅者在 main.rs 里:EnvFilter::try_from_default_env(),取不到就是 "info";docker-compose.yml 也给容器设了 RUST_LOG: ${RUST_LOG:-info}。实验程序用同样的订阅者,池参数用 build_connect_options,只把 DB_MAX_CONN 设成 1,让第二个使用者必须等(src/bin/logging.rs):

let db = Database::connect(build_connect_options(&lab_url("site-log"))).await?;
let sql = |s: &str| Statement::from_string(DbBackend::Postgres, s);

println!("--- a fast query ---");
db.execute(sql("select 1")).await?;
println!("--- a query that takes 1.2s ---");
db.execute(sql("select pg_sleep(1.2)")).await?;

println!("--- someone holds the only connection for 2.5s ---");
let txn = db.begin().await?;
tokio::spawn(async move {
  tokio::time::sleep(Duration::from_millis(2500)).await;
  txn.commit().await.unwrap();
});
let t = Instant::now();
db.execute(sql("select 2")).await?;
println!("waited {:?}", t.elapsed());

println!("--- held for longer than acquire_timeout (5s) ---");
let txn = db.begin().await?;
let t = Instant::now();
let err = db.execute(sql("select 3")).await.unwrap_err();
println!("after {:?}: {err}", t.elapsed());
println!("debug: {err:?}");
txn.rollback().await?;

不设 RUST_LOG(等于本站生产的 info):

--- a fast query ---
--- a query that takes 1.2s ---
 WARN sqlx::query: slow statement: execution time exceeded alert threshold summary="select pg_sleep(1.2)" db.statement="" rows_affected=1 rows_returned=1 elapsed=1.21419825s elapsed_secs=1.21419825 slow_threshold=1s
--- someone holds the only connection for 2.5s ---
 WARN sqlx::pool::acquire: acquired connection, but time to acquire exceeded slow threshold aquired_after_secs=2.504606334 slow_acquire_threshold_secs=2.0
waited 2.505586792s
--- held for longer than acquire_timeout (5s) ---
after 5.003468958s: Failed to acquire connection from pool: Connection pool timed out
debug: ConnectionAcquire(Timeout)

RUST_LOG=sqlx=debug(节选):

--- a fast query ---
DEBUG sqlx::query: summary="select 1" db.statement="" rows_affected=1 rows_returned=1 elapsed=559.542µs elapsed_secs=0.000559542
...
--- someone holds the only connection for 2.5s ---
DEBUG sqlx::query: summary="COMMIT" db.statement="" rows_affected=0 rows_returned=0 elapsed=1.136417ms elapsed_secs=0.001136417
 WARN sqlx::pool::acquire: acquired connection, but time to acquire exceeded slow threshold aquired_after_secs=2.505026833 slow_acquire_threshold_secs=2.0
DEBUG sqlx::query: summary="select 2" db.statement="" rows_affected=1 rows_returned=1 elapsed=1.453208ms elapsed_secs=0.001453208
...
  • 生产日志里能看到的 sqlx 输出有两种:超过 1 秒的语句(sqlx::query 的 WARN)和等了 2 秒以上才借到的连接(sqlx::pool::acquire 的 WARN)。前者是 sea-orm 留下的 sqlx 默认,后者 sea-orm 根本没有开关。
  • 慢获取的字段在 0.8.6 里拼成 aquired_after_secs,0.9 改成了 acquired_after_secs(见获取连接的冷知识)。
  • 等满 5 秒,sqlx 的 PoolTimedOut 被 sea-orm 的 sqlx_conn_acquire_err 换成 DbErr::ConnectionAcquire(ConnAcquireErr::Timeout),文本是 Failed to acquire connection from pool: Connection pool timed out。在本站这句话最后进了 ServerFnError。
  • BEGIN 没有 DEBUG 日志,COMMIT 有:BEGIN 走 queue_simple_query 加 wait_until_ready,不经过 run,也就没有 QueryLogger;COMMIT 是 conn.execute(..)(见事务)。

一条 Entity 查询怎么走到 run

sea-orm 的每条查询都先由 sea-query 生成 SQL 和一组 Value,再交给 sqlx_postgres.rs 里的 sqlx_query:

pub(crate) fn sqlx_query(stmt: &Statement) -> sqlx::query::Query<'_, Postgres, SqlxValues> {
    let values = stmt
        .values
        .as_ref()
        .map_or(Values(Vec::new()), |values| values.clone());
    sqlx::query_with(&stmt.sql, SqlxValues(values))
}

query_with 造出的 Query 带着参数、persistent 是默认的 true:一定走预备语句,一定进语句缓存,哪怕没有参数(为什么见 Executor 与 Acquire)。SqlxValues 的 IntoArguments(sea-query-binder-0.7.0/src/sqlx_postgres.rs)把每个 Value 逐个 args.add(..)。执行的几个方法都先显式 self.pool.acquire(),再在 &mut *conn 上调 execute / fetch_one / fetch_all;query_one 把 RowNotFound 变成 Ok(None)。只有 execute_unprepared(sql) 是把 &str 直接交给 conn.execute,走简单查询协议。事务是 DatabaseTransaction,它不经过 sqlx 的 Transaction,而是直接调 <sqlx::Postgres as sqlx::Database>::TransactionManager::begin(c, None),隔离级别在 BEGIN 之后另发 SET TRANSACTION;没提交就 drop 时调 start_rollback,和 sqlx 自己的 Transaction 一样只是排队(sea-orm-1.1.20/src/database/transaction.rs)。

用一个两列的 post 实体经代理跑一遍,池只有一条连接(DB_MAX_CONN=1 DB_MIN_CONN=1),代理代码见一次查询的全过程(src/bin/wire.rs):

for round in 1..=2 {
  println!("--- find_by_id(1).one(), round {round} ---");
  let m = Post::find_by_id(1_i64).one(&db).await?;
  // ...
}
for ids in [vec![1_i64, 2], vec![1, 2, 3]] {
  println!("--- filter(id.is_in({ids:?})).all() ---");
  let n = Post::find().filter(post::Column::Id.is_in(ids)).all(&db).await?.len();
  // ...
}
println!("--- execute_unprepared ---");
db.execute_unprepared("select 1").await?;
println!("--- begin / insert / commit ---");
let txn = db.begin().await?;
post::ActiveModel { id: Set(100), title: Set("in txn".into()) }.insert(&txn).await?;
txn.commit().await?;
// 然后在同一条连接上读 cached_statements_size() 和 pg_prepared_statements
--- find_by_id(1).one(), round 1 ---
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
  [conn 1] C->S Parse(sqlx_s_1, "SELECT \"post\".\"id\", \"post\".\"title\" FROM \"post\" WHERE \"post\".\"id\" = $1 LIMIT $2"), Describe(S, sqlx_s_1), Sync
  [conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, DataRow(列长 8/6), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
=> Some("post 1")
--- find_by_id(1).one(), round 2 ---
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, DataRow(列长 8/6), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
=> Some("post 1")
--- filter(id.is_in([1, 2])).all() ---
  ...
  [conn 1] C->S Parse(sqlx_s_2, "SELECT \"post\".\"id\", \"post\".\"title\" FROM \"post\" WHERE \"post\".\"id\" IN ($1, $2)"), Describe(S, sqlx_s_2), Sync
  ...
=> 2 rows
--- filter(id.is_in([1, 2, 3])).all() ---
  ...
  [conn 1] C->S Parse(sqlx_s_3, "SELECT \"post\".\"id\", \"post\".\"title\" FROM \"post\" WHERE \"post\".\"id\" IN ($1, $2, $3)"), Describe(S, sqlx_s_3), Sync
  ...
=> 3 rows
--- execute_unprepared ---
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
  [conn 1] C->S Query("select 1")
  [conn 1] S->C RowDescription, DataRow(列长 1), CommandComplete("SELECT 1"), ReadyForQuery(I)
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
--- begin / insert / commit ---
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
  [conn 1] C->S Query("BEGIN")
  [conn 1] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
  [conn 1] C->S Parse(sqlx_s_4, "INSERT INTO \"post\" (\"id\", \"title\") VALUES ($1, $2) RETURNING \"id\", \"title\""), Describe(S, sqlx_s_4), Sync
  [conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(T)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_4), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, DataRow(列长 8/6), CommandComplete("INSERT 0 1"), CloseComplete, ReadyForQuery(T)
  [conn 1] C->S Query("COMMIT")
  [conn 1] S->C CommandComplete("COMMIT"), ReadyForQuery(I)
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
cached_statements_size = 4, pg_prepared_statements = ["sqlx_s_1", "sqlx_s_2", "sqlx_s_3", "sqlx_s_4"]
  • 和直接用 sqlx 的抓包(一次查询的全过程)一模一样:借出时一次 ping、第一次 Parse + Describe、Bind + Execute + Close + Sync、归还时一次 ping;第二次 find_by_id 命中 sqlx_s_1,不再 Parse。sea-orm 在 sqlx 上面没有加任何往返。
  • one() 自己加了 LIMIT $2。sqlx 的 fetch_one 会把结果读到 ReadyForQuery 为止,有了 LIMIT 服务端只发一行。
  • is_in 被 sea-query 展开成 IN ($1, $2)、IN ($1, $2, $3):列表多长就有几个占位符,不会出现 IN ($1) 绑数组的错误(in-list 那个坑本站碰不到),代价是每种长度都是一条新的 SQL 文本,在每条连接的语句缓存里各占一个位置。本站 32 条连接、每条最多 100 条,服务端最多常驻 3200 条预备语句;缓存满了按 LRU 关掉旧的(扩展查询协议与语句缓存)。
  • 事务控制语句都是 Query,事务里的 INSERT 照样是缓存的预备语句。

迁移:sea-orm-migration,不是 sqlx::migrate!

本站启动时在 main.rs 先 init_pool,再从同一个池拿出 DatabaseConnection 跑 migration::Migrator::up(&db, None);docker-compose.yml 设了 STRICT_MIGRATION: ${STRICT_MIGRATION:-1},迁移失败就 panic 退出。Migrator 是 sea-orm-migration 1.1.20 的 MigratorTrait,sqlx 的 Migrator、_sqlx_migrations 表、migrate! 都没有出场(sqlx 那一套见迁移)。

sea-orm-migration 1.1.20 的 up 在 Postgres 上是这样执行的(src/migrator.rs):

    let db = db.into_schema_manager_connection();

    match db.get_database_backend() {
        DbBackend::Postgres => {
            let transaction = db.begin().await?;
            let manager = SchemaManager::new(&transaction);
            f(&manager).await?;
            transaction.commit().await
        }
        DbBackend::MySql | DbBackend::Sqlite => {
            let manager = SchemaManager::new(db);
            f(&manager).await
        }
    }

f 是 exec_up:建记账表 seaql_migrations(CREATE TABLE IF NOT EXISTS),读出已应用的版本名,逐个执行未应用的迁移并插一行 (version, applied_at)。用两个最小的迁移(一个用 schema builder 建表,一个 execute_unprepared)经代理跑两次(src/bin/migrate.rs,节选):

--- Migrator::up, first run ---
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
  [conn 1] C->S Query("BEGIN")
  [conn 1] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
  [conn 1] C->S Parse(sqlx_s_1, "CREATE TABLE IF NOT EXISTS \"seaql_migrations\" ( \"version\" varchar NOT NULL PRIMARY KEY, \"applied_at\" bigint NOT NULL )"), Describe(S, sqlx_s_1), Sync
  [conn 1] S->C ParseComplete, ParameterDescription, NoData, ReadyForQuery(T)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, CommandComplete("CREATE TABLE"), CloseComplete, ReadyForQuery(T)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, NoticeResponse, CommandComplete("CREATE TABLE"), CloseComplete, ReadyForQuery(T)
  ...
  [conn 1] C->S Parse(sqlx_s_2, "SELECT \"version\", \"applied_at\" FROM \"seaql_migrations\" ORDER BY \"version\" ASC"), Describe(S, sqlx_s_2), Sync
  ...
  [conn 1] C->S Parse(sqlx_s_3, "CREATE TABLE \"m_a\" ( \"id\" bigint PRIMARY KEY )"), Describe(S, sqlx_s_3), Sync
  ...
  [conn 1] C->S Parse(sqlx_s_4, "INSERT INTO \"seaql_migrations\" (\"version\", \"applied_at\") VALUES ($1, $2) RETURNING \"version\""), Describe(S, sqlx_s_4), Sync
  ...
  [conn 1] C->S Query("create table m_b (id int8 primary key)")
  [conn 1] S->C CommandComplete("CREATE TABLE"), ReadyForQuery(T)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_4), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, DataRow(列长 11), CommandComplete("INSERT 0 1"), CloseComplete, ReadyForQuery(T)
  [conn 1] C->S Query("COMMIT")
  [conn 1] S->C CommandComplete("COMMIT"), ReadyForQuery(I)
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
--- Migrator::up, second run ---
  ...
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_2), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, DataRow(列长 11/8), DataRow(列长 11/8), CommandComplete("SELECT 2"), CloseComplete, ReadyForQuery(T)
  [conn 1] C->S Query("COMMIT")
  [conn 1] S->C CommandComplete("COMMIT"), ReadyForQuery(I)
  ...
seaql_migrations columns [("version", "character varying"), ("applied_at", "bigint")]
rows [("m1_create_a",), ("m2_create_b",)]

和 sqlx 的 Migrator::run 对照:

sqlx Migrator::runsea-orm-migration 1.1.20(Postgres)
并发保护pg_advisory_lock,键由库名算出没有锁,抓包里没有任何 pg_advisory_*
事务每个迁移一个事务(-- no-transaction 除外)所有待执行的迁移在同一个事务里,最后一次 COMMIT
记账版本、描述、成功标记、SHA-384 校验和、耗时只有 version(迁移名)和 applied_at
改了已应用的迁移migration N was previously applied but has been modified发现不了,只按名字判断有没有跑过
记账表的 SQLCREATE TABLE IF NOT EXISTS 走简单查询预备语句,一次 up 里执行四次(exec_up、get_pending_migrations、get_migration_with_status、get_migration_models 各调一次 install),第二次起服务端回 NoticeResponse 说表已存在

一个事务包住全部,意味着第 9 个迁移失败时,同一次启动里一起待执行的第 8 个也回滚,库停在启动前的样子;也意味着本站的迁移里放不了 CREATE INDEX CONCURRENTLY 这类不能进事务块的语句(本站 9 个迁移里没有)。库里已记账、代码里却删掉了的迁移,get_migration_with_status 报「Migration file of version '...' is missing, this migration has been applied but its file is missing」,这一点和 sqlx 的 VersionMissing 相当。

没有锁的后果:两个实例同时对一个空库启动,src/bin/migrate_race.rs 用两个池同时 Migrator::up,连跑三次:

instance a: Err("Execution Error: error returned from database: duplicate key value violates unique constraint \"pg_type_typname_nsp_index\"")
instance b: Ok(())
rows [("m1_create_a",), ("m2_create_b",)]
instance a: Err("Execution Error: error returned from database: duplicate key value violates unique constraint \"pg_type_typname_nsp_index\"")
instance b: Ok(())
rows [("m1_create_a",), ("m2_create_b",)]
instance a: Ok(())
instance b: Err("Execution Error: error returned from database: duplicate key value violates unique constraint \"pg_type_typname_nsp_index\"")
rows [("m1_create_a",), ("m2_create_b",)]

两个事务同时 CREATE TABLE IF NOT EXISTS seaql_migrations,后提交的那个撞上系统表的唯一索引,整个 up 失败;在本站 STRICT_MIGRATION=1 下就是那个实例启动失败。数据没有坏:失败的一方整个回滚,成功的一方记了两行。sqlx 的迁移用 advisory lock 排队,第二个会等第一个跑完。本站的 docker-compose.yml 给 app 写死了 container_name: rie-app,同一台机器上只有一个 app 容器,目前碰不到这种情况。

生产库:100 个连接,本站最多 32

主编在 app 服务器上只读核实过(2026-10-24):生产 Postgres 是 16.15,max_connections = 100;pg_stat_activity 里是 2 条空闲连接(也就是 min_connections 的 2 条)加上查询的那个会话;站点只跑一个 app 实例,池上限 32。

没有流量时池里就是这两条。它们也不是一直不变:维护任务每 10 分钟一轮,空闲超过 idle_timeout 的会被关掉、再补回两条。把 idle_timeout 缩到 1 秒看得清楚(src/bin/idle_recycle.rs,本站的参数,只设 DB_IDLE_TIMEOUT_SECS=1,每 500 毫秒看一次):

    0ms size=2 idle=2 backends=[973, 974]
  511ms size=2 idle=2 backends=[973, 974]
 1014ms size=2 idle=2 backends=[973, 974]
 1519ms size=2 idle=2 backends=[973, 974]
 2024ms size=2 idle=2 backends=[976, 977]
 2527ms size=2 idle=2 backends=[976, 977]
 3031ms size=2 idle=2 backends=[976, 977]
 3537ms size=2 idle=2 backends=[976, 977]
 4045ms size=2 idle=1 backends=[978]

两条一起被换掉,最后一行是又一轮换到一半:size 已经算上了正在建的那条(机制见归还、Drop 与维护任务)。换成生产的 600 秒,空闲的那两条连接每过 10 到 20 分钟换一批,每次都是新建 TCP 连接、重新认证。

服务端的 100 是所有客户端加起来的上限。Postgres 默认还给超级用户留 3 个(superuser_reserved_connections,实验库上 show 出来是 3,16 新加的 reserved_connections 是 0),普通角色能用的是 97 个;生产库上这两个设置和本站角色是不是超级用户,没有核实。本站一个实例最多 32 条,加上偶尔的 psql、备份,离 97 很远:要三个实例同时满载才到 96,四个才会超。超过时服务端回 53300 too_many_connections,sqlx 把它当作可重试的错误,在 acquire_timeout 内退避重连,最后报出来的是 PoolTimedOut(获取连接),到本站这里就是上面那句 Failed to acquire connection from pool: Connection pool timed out,看不到 too many clients。所以排查本站的超时,先看服务端:

select count(*), current_setting('max_connections') from pg_stat_activity;

18 个常见坑,哪些够得着本站

逐个对着 sea-orm 1.1.20 加 sqlx 0.8.6 的用法核对。「够不着」都有依据(git grep 的范围是 89a61b1 的全部 .rs 文件);「够得着」的用实验 crate 经 sea-orm 复现过。

坑本站依据
pool-timed-out够得着上面的 logging.rs:5 秒后 Failed to acquire connection from pool: Connection pool timed out。本站三处 db.begin()(auth/mod.rs 和 admin、forum 各一处)在事务里都用 &txn,没有拿着事务再去 &db 借第二条
unexpected-nullsqlx 的那句话够不着没有 query!;sea-orm 读列时先按 Option 解,NULL 由它自己报错,见下面的 decode.rs
mismatched-types够得着模型字段类型和列类型对不上时,sqlx 的 compatible 检查照常生效,见 decode.rs
sql-safe-str够不着0.9 才有;本站代码不调 sqlx,sea-orm 2 自己包 AssertSqlSafe,见升级到 0.9
database-url-not-set够不着宏才读编译期的 DATABASE_URL,sqlx 的 macros feature 没开
in-list够不着sea-query 把 is_in 展开成 IN ($1, $2, ...),见 wire.rs
too-many-arguments理论上够得着is_in 一个元素一个参数,wire.rs 里 7 万个 id 报 Query Error: encountered unexpected or invalid data: PgConnection::run(): too many arguments for query: 70000 (sqlx_postgres::connection::executor:220)。本站分页列表的 is_in 最多 ADMIN_PAGE_SIZE = 50 个;admin_bulk_approve_moderation 收客户端传来的 Vec<i64>,长度不设限,只有管理员能调
cached-plan改列类型时够得着见下面的 cached_plan.rs。启动时迁移先于一切查询,迁移本身不会让本进程的缓存过期;手工在线 ALTER 会
forgot-commit机制相同DatabaseTransaction 的 Drop 调 sqlx 的 start_rollback;本站三处事务成功路径都 commit,出错时 ? 提前返回,靠这个回滚
begin-with-in-transaction够不着sea-orm 调 TransactionManager::begin(c, None),从不传自定义语句;嵌套事务走 SAVEPOINT
cancelled-begin够得着见下面的 cancel_begin.rs
migration-modified够不着sea-orm-migration 不存校验和,改了旧迁移也不报错(反过来,也不会被发现)
copy-in-dropped够不着没有 COPY;sea-orm 1.1.20 也没有 COPY 的接口
try-from-unprintable够不着没用 sqlx 的 #[derive(FromRow)]
stale-type-oid够不着迁移和实体里没有 CREATE TYPE、domain、DeriveActiveEnum,没有按名字解析的类型
connect-lazy-min-connections够不着connect_lazy 是默认的 false,建池时就有两条(pool_opts.rs,生产也是两条)
sslmode-require没配docker-compose.yml 里的 DATABASE_URL 不带 sslmode,是默认的 prefer:编进了 rustls,先发 SSLRequest,服务端回 N 就在同一条连接上明文继续。实验库同样是 postgres:16-alpine,ssl 是 off;生产库开没开 TLS 没有核实。连接只走 compose 的内部网络
too-many-clients现在够不着一个实例最多 32,生产上限 100,见上一节

解码错误经过 sea-orm 是什么样

src/bin/decode.rs 用 FromQueryResult 的两个结构体:一个把 int8 的 id 声明成 i32,一个把查出来是 NULL 的 title 声明成 String;再插一行主键冲突:

int8 into i32  : Query Error: error occurred while decoding column "id": mismatched types; Rust type `core::option::Option<i32>` (as SQL type `INT4`) is not compatible with SQL type `INT8`
NULL into String: Type Error: A null value was encountered while decoding "title"
unique violation: Execution Error: error returned from database: duplicate key value violates unique constraint "post_pkey"

类型不匹配是 sqlx 的原话,只是 Rust 类型变成了 Option<i32>:sea-orm 读每一列都先 try_get::<Option<T>>,compatible 照常检查(Row 与 FromRow)。NULL 则在 sea-orm 那一层被拦下来,换成它自己的 Type Error,sqlx 的 unexpected null; try decoding as an Option 不会出现。数据库错误前面加了 sea-orm 的 Execution Error: / Query Error: ,后面的部分就是 sqlx 的 Display。

改列类型之后,缓存的语句一直报错

src/bin/cached_plan.rs,池只有一条连接,先查一次让语句进缓存,再在另一个会话里改表:

before        : Some("post 1")
after ADD COLUMN        : Ok(Some("post 1"))
after ALTER COLUMN TYPE : Err("Query Error: error returned from database: cached plan must not change result type")
once more               : Err("Query Error: error returned from database: cached plan must not change result type")

sea-orm 生成的 SELECT 列出了每一列,不写 *,所以加一列不影响已缓存的语句;把 title 从 text 改成 varchar(100) 就让结果类型变了。报错之后语句还在缓存里,同一条连接上再查一次还是这个错。它要等这条连接被换掉才会好:空闲超过 10 分钟的在下一轮维护时被关掉,一直忙着的要活满 30 分钟(max_lifetime),在某次归还时被关掉。生产上在线改列类型,缓存过这条语句的每条连接都要各自报错,持续时间是这个量级;重启应用是最快的修法。机制和其他修法在 cached-plan。

取消 begin:连接带着事务回到池里

src/bin/cancel_begin.rs。池只有一条连接,代理把服务端发回的每次数据都推迟 100 毫秒,再给 db.begin() 套一个 150 毫秒的超时:借出时的 ping 在 100 毫秒回来,BEGIN 刚发出去,超时就到了。之后在同一个池上做一次普通的 insert,从另一个会话去看:

p.delay_ms.store(100, Ordering::SeqCst);
let r = tokio::time::timeout(Duration::from_millis(150), db.begin()).await;
// ...
post::ActiveModel { id: Set(200), title: Set("after cancel".into()) }.insert(&db).await?;
--- timeout(150ms, db.begin()) ---
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(I)
  [conn 1] C->S Query("BEGIN")
=> timed out, begin() future dropped
  [conn 1] C->S Sync
  [conn 1] S->C CommandComplete("BEGIN"), ReadyForQuery(T)
  [conn 1] S->C ReadyForQuery(T)
--- an ordinary insert on the same pool ---
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(T)
  [conn 1] C->S Parse(sqlx_s_1, "INSERT INTO \"post\" (\"id\", \"title\") VALUES ($1, $2) RETURNING \"id\", \"title\""), Describe(S, sqlx_s_1), Sync
  [conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(T)
  [conn 1] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
  [conn 1] S->C BindComplete, DataRow(列长 8/12), CommandComplete("INSERT 0 1"), CloseComplete, ReadyForQuery(T)
  [conn 1] C->S Sync
  [conn 1] S->C ReadyForQuery(T)
other session sees id 200: 0; pool backend: [(944, "idle in transaction")]
after the pool is closed, id 200 rows: 0

insert 返回了 Ok,状态字节却一直是 T,服务端那条后端是 idle in transaction;池关闭时事务被回滚,这一行从来没有落盘。sea-orm 1.1.20 的 DatabaseTransaction::begin 先造出 open: true 的自己,再调 sqlx 0.8.6 的 PgTransactionManager::begin;future 被丢掉时两层守卫都只会调 start_rollback,而深度还是 0,什么都不排。这就是事务里讲过的上游 #4393,修复 #4394 在 0.9.0 之后才合并。本站代码没有给数据库调用套超时或 select!(git grep 没有结果),能触发它的只有外层在 BEGIN 来回途中把整个请求的 future 丢掉;一旦发生,之后借到这条连接的请求写入全部悬空,直到它被换掉。

❗IMPORTANT
动脑筋:把 DB_CONNECT_TIMEOUT_SECS 设成 1,连不上库时多久报错?

src/bin/connect_timeout.rs 用本站的 build_connect_options,URL 指向本机没人监听的 1 号端口(每次都是 ConnectionRefused),环境里只设 DB_CONNECT_TIMEOUT_SECS=1,量 Database::connect 用了多久。平台:M2 Max、macOS、rustc 1.99.0。

let opt = build_connect_options("postgres://lab:lab@127.0.0.1:1/lab");
println!("connect_timeout={:?} acquire_timeout={:?}", opt.get_connect_timeout(), opt.get_acquire_timeout());
let t = Instant::now();
let r = Database::connect(opt).await;
println!("after {:?}: {}", t.elapsed(), r.err().map(|e| e.to_string()).unwrap_or_default());
大约 1 秒、5 秒,还是别的?

动脑筋答案

connect_timeout=Some(1s) acquire_timeout=Some(5s)
after 5.310339s: Connection Error: pool timed out while waiting for an open connection

5.3 秒。

  • connect_timeout(1s) 先把 sqlx 的 acquire_timeout 设成 1 秒,紧接着 acquire_timeout(5s) 又设回 5 秒,1 秒没有留下任何痕迹。
  • 多出来的 0.3 秒来自建池的路径。connect_with 在 min_connections > 0 时先调 try_min_connections,它不像 acquire 那样外面包着 rt::timeout,截止时间只在 connect 每次重试前检查。退避从 10 毫秒翻倍,上限是剩余时间的五分之一,也就是 1 秒:尝试时刻大约是 0、10、30、70、150、310、630、1270、2270、3270、4270 毫秒,最后一次退避睡到约 5270 毫秒才醒,发现过了截止时间,报 PoolTimedOut。
  • sea-orm 把建池时的错误包成 DbErr::Conn,所以前缀是 Connection Error: ,后面是 sqlx 的 pool timed out while waiting for an open connection;运行中借连接超时则是 ConnectionAcquire,文本不同(见日志一节)。启动时连不上库,本站的 init_pool 要等 5 秒多才失败;STRICT_MIGRATION=1 下接着 panic 退出。

小结

  • 本站经 sea-orm 1.1.20 用 sqlx 0.8.6,开的是 postgres、tokio、rustls 和几个类型 feature,没有 macros;代码里没有直接的 sqlx 调用,编译期检查主线不在本站。
  • build_connect_options 的五个参数落到 PoolOptions 上:32 条、最少 2 条、acquire_timeout 5 秒、idle_timeout 600 秒(等于默认)。connect_timeout 和 acquire_timeout 写同一个字段,前者被覆盖;max_lifetime 30 分钟、借出前 ping、慢获取 2 秒、语句缓存 100 条都是 sqlx 默认值。
  • sqlx_logging_level(Debug) 把语句日志从 sea-orm 的 INFO 降回 sqlx 自己的默认 DEBUG;超过 1 秒的语句和超过 2 秒的借连接在 RUST_LOG=info 下仍打 WARN。
  • 每条 Entity 查询都是 query_with:预备语句、进缓存、和直接用 sqlx 的抓包一样;is_in 每种长度一条新语句;只有 execute_unprepared 和事务控制语句走简单查询。
  • 迁移是 sea-orm-migration:全部待执行的迁移一个事务,不加锁、不存校验和;两个实例同时迁移空库时其中一个失败,本站只有一个 app 容器。
  • 生产库 100 个连接,本站一个实例最多 32、空闲时两条(每 10 到 20 分钟换一批);连接数超限在本站只表现为 5 秒后的连接池超时。
  • 18 个坑里,连接池超时、类型不匹配、改列类型后的缓存计划、取消 begin 够得着本站,参数超限理论上够得着;其余要么只在宏和 0.9 里,要么本站根本不用那个功能。

💬 关联讨论

发起讨论
加载中...