本站怎么用 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 的macrosfeature 没开,代码里也没有一处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 交给 sqlx | sqlx 默认 | 结果 | 讲它的页 |
|---|---|---|---|---|
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_acquire | sea-orm 默认 true | true | 每次借出先 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 改成多少都不起作用,本页的动脑筋就是这个。
connect_timeout0.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::run | sea-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 | 发现不了,只按名字判断有没有跑过 |
| 记账表的 SQL | CREATE 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-null | sqlx 的那句话够不着 | 没有 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 丢掉;一旦发生,之后借到这条连接的请求写入全部悬空,直到它被换掉。
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());
动脑筋答案
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_timeout5 秒、idle_timeout600 秒(等于默认)。connect_timeout和acquire_timeout写同一个字段,前者被覆盖;max_lifetime30 分钟、借出前 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 里,要么本站根本不用那个功能。