Rust in Everything
基于 sea-orm 2.0.4

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 输出相同。

📝NOTE
没有蠢问题

问: 两个会话互相等,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 行为相同。

💬 关联讨论

发起讨论
加载中...