Failed to acquire connection from pool: Connection pool timed out
主线位置:查询主线的
acquire那一格。&db上的每条语句先pool.acquire()借一条连接(一次 find 的全过程),&txn上的语句锁住事务自己那条连接,不经过池(事务)。本页是两者混用:事务占着一条,事务里的语句又拿&db去池里要一条。
flowchart TB beg["db.begin()"] --> hold["占住一条连接"] hold --> ins["insert(&txn)"] ins --> cnt["count(&db)"] cnt --> acq["pool.acquire()"] acq -->|池里没有空闲| wait["等 acquire_timeout"] wait --> err["ConnectionAcquire(Timeout)"] err --> rb["txn 被 drop 回滚"] rb --> back["连接回池"]
本页实验在 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,本站在用的版本)各跑一遍。
现象
开发环境一切正常,压测或流量一高,部分请求在正好 acquire_timeout 之后失败:
Failed to acquire connection from pool: Connection pool timed out
失败的请求里事务写的数据没了;成功的请求里,事务内读到的行数偶尔「少一行」。
最小复现
src/bin/db_in_txn.rs。一个「请求」开事务、插一行,然后统计行数,统计时用错了执行器:
async fn request(db: &DatabaseConnection, use_txn: bool, n: i32) -> Result<u64, DbErr> {
let txn = db.begin().await?;
item::ActiveModel { n: Set(n), ..Default::default() }.insert(&txn).await?;
tokio::time::sleep(Duration::from_millis(50)).await;
let c = if use_txn {
item::Entity::find().count(&txn).await?
} else {
item::Entity::find().count(db).await?
};
txn.commit().await?;
Ok(c)
}
A 段 max_connections(1),单个请求;B 段 max_connections(4),四个请求同时 tokio::spawn;acquire_timeout 都是 1 秒:
--- A: max_connections(1),事务开着时用 &db ---
count(&db) = Err(ConnectionAcquire(Timeout)),用时 1.0s
Display: Failed to acquire connection from pool: Connection pool timed out
--- B: max_connections(4),4 个请求同时进来,事务里用 &db ---
请求 0: Err(Failed to acquire connection from pool: Connection pool timed out)
请求 1: Ok(count = 0)
请求 2: Ok(count = 0)
请求 3: Ok(count = 0)
用时 1.1s
- A:唯一的连接被事务占着,
&db等的就是自己手里那条,一直等到超时。 - B:池有 4 条,可 4 个请求各占一条之后都要第二条,谁也拿不到。先超时的那个请求返回
Err,它的txn被 drop、回滚,连接回池,其余三个依次拿到、完成。哪个请求失败每次不同;count是另一条连接上的读,看不到自己事务里没提交的那一行,所以是 0(偶尔是 1 或 2:别的请求先提交了)。 - 池调大只是把门槛抬高:只要同时开着事务的请求数到了
max_connections,就会卡满一个acquire_timeout。
1.1.20 上同一个程序的 A、B 两段逐行相同,只有 count 的值随调度变化。
出处
&db 走 SqlxPostgresPoolConnection::query_one,第一步就是借连接:
let mut conn = self.pool.acquire().await.map_err(sqlx_conn_acquire_err)?;
sqlx 的 PoolTimedOut 在 sqlx_conn_acquire_err 里变成 DbErr::ConnectionAcquire(ConnAcquireErr::Timeout),DbErr::ConnectionAcquire 的 Display 是 Failed to acquire connection from pool: {0},里面那层是 Connection pool timed out。同一个 sqlx 错误在 Database::connect 阶段会包成别的分支、显示 sqlx 原文,见错误。
&txn 走 impl ConnectionTrait for DatabaseTransaction,锁的是事务自己的 Arc<Mutex<InnerConnection>>,不碰池。两者都实现 ConnectionTrait,函数收 C: ConnectionTrait 时传哪个都能编译。sqlx 里同一类问题见 pool timed out。
另一种:同一行上自己等自己
池够大时 &db 能拿到连接,但如果它去改事务刚改过的行,就换成在服务端等行锁(D 段,max_connections(2),先 update(&txn) 把 id 1 改成 100,再 update(&db) 改成 200,外面套 3 秒 timeout):
--- D: max_connections(2),&db 去改 &txn 改过的行 ---
update(&db) = Err(Elapsed(())),用时 3.0s
txn 提交之后 item.n: [200]
没有 timeout 就永远等下去。timeout 丢掉的只是客户端的 future,服务端那条 UPDATE 还在排队,事务提交、锁释放后它照样执行,最后的值是被「取消」的 200。1.1.20 输出相同。
问: 两个会话互相等,Postgres 不是有死锁检测吗,为什么不报 40P01?
答: 死锁检测只看服务端的等待图。这里只有一条边:&db 的会话在等 &txn 的会话持有的行锁;&txn 的会话处于 idle in transaction,它不在等任何锁,而是在等客户端发下一条语句。客户端那边的等待(同一个任务卡在 &db 的 await 上)服务端看不见,所以在它眼里这只是一次普通的锁等待。
修法
事务里的每条语句都传 &txn。C 段把 B 段的 count(db) 换成 count(&txn),其余不变:
--- C: 同上,事务里用 &txn ---
请求 0: Ok(count = 2)
请求 1: Ok(count = 1)
请求 2: Ok(count = 3)
请求 3: Ok(count = 3)
用时 0.1s
四个请求都成功,每个都看得到自己插的那一行。被调用的函数签名写成 C: ConnectionTrait,调用方传 &txn;需要原子性的函数可以收 C: TransactionTrait 自己 begin,传进来的是 &txn 时它开的是保存点(事务的「TransactionTrait」一节)。
怎么确认没中招
- 跑测试时把池设成
max_connections(1)(本站是环境变量DB_MAX_CONN=1):事务里任何一条用了&db的语句都会像 A 段那样稳定地卡满acquire_timeout后失败,不再依赖并发碰运气。 - 在
let txn = db.begin()和txn.commit()之间搜&db、db.clone(),以及收&DatabaseConnection的辅助函数。本站三处begin(auth/mod.rs、forum/src/server.rs、admin/src/server.rs)在提交前都用的是&txn。 - 线上看到这句报错时查
pg_stat_activity:同一个应用有一批state = 'idle in transaction'的会话,同时还有请求在超时,基本就是这个原因。行锁那种情形长这样(src/bin/lock_wait.rs,D 段的写法,等待 0.5 秒时从第三条连接查application_name相同的会话):
state=active wait_event_type=Lock wait_event=transactionid query=UPDATE "item" SET "n" = $1 WHERE "item".
state=idle in transaction wait_event_type=Client wait_event=ClientRead query=UPDATE "item" SET "n" = $1 WHERE "item".
提交后等待中的 update(&db) = Ok(1)
持锁的一方在 ClientRead,也就是在等应用发下一条语句,这正是上面那个问答里服务端看不到的那条边。
小结
- 事务占着一条连接,事务里的
&db语句再去池里借一条:池只剩这一条时等满acquire_timeout,报ConnectionAcquire(Timeout)。 - 池大也不安全:同时开着事务的请求数一到
max_connections,所有请求一起卡住,先超时的那个回滚、放出连接。 &db碰到事务改过的行时变成服务端行锁等待,没有超时就一直等;被取消的语句在提交后仍会执行。- 修法是传
&txn;测试时用一条连接的池能让问题稳定复现。2.0.4 和 1.1.20 行为相同。