LISTEN/NOTIFY 与 COPY
主线位置:三条主线之外的旁支。它们用的还是查询主线上的零件:
PgListener借连接池主线的acquire管理那一条连接,COPY和 advisory lock 直接操作PgConnection的消息流和pending_ready_for_query_count。消息流本身(PgStream、recv怎么处理异步到达的消息)见消息与读写缓冲。
flowchart TB
tr["try_recv"] --> buf{"缓冲里有通知"}
buf -->|有| ret["返回 Some"]
buf -->|没有| conn["必要时重连"]
conn --> rd["recv_unchecked"]
rd -->|NotificationResponse| ret
rd -->|ReadyForQuery| cnt["计数减一"]
cnt --> rd
rd -->|"Io 错误"| lost["丢掉连接"]
lost --> eager{"eager_reconnect"}
eager -->|true| re["重连并 LISTEN"]
re --> none["返回 None"]
eager -->|false| none
本页实验都在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上跑,依赖 sqlx = "=0.9.0",协议消息用一次查询的全过程里的 TCP 代理看(这一页给它加上了 NotificationResponse、CopyInResponse、CopyData 等消息的名字)。
PgListener:一个只有一条连接的池
Postgres 的通知是按会话订阅的:一个后端执行过 LISTEN ch,之后别人 NOTIFY ch 时,服务端往这个后端的连接上推一条 NotificationResponse,不管客户端此刻在不在等。PgListener 要做的就是一直占着一条连接读这种消息。它的字段:
pub struct PgListener {
pool: Pool<Postgres>,
connection: Option<PoolConnection<Postgres>>,
buffer_rx: mpsc::UnboundedReceiver<Notification>,
buffer_tx: Option<mpsc::UnboundedSender<Notification>>,
channels: Vec<String>,
ignore_close_event: bool,
eager_reconnect: bool,
}
连接不是自己建的,而是从池里借的。PgListener::connect 专门建一个池:
// Create a pool of 1 without timeouts (as they don't apply here)
// We only use the pool to handle re-connections
let pool = PoolOptions::<Postgres>::new()
.max_connections(1)
.max_lifetime(None)
.idle_timeout(None)
.connect(url)
.await?;
max_lifetime 和 idle_timeout 都关掉,因为这条连接要一直借着不还,池的维护任务不该去回收它(这些选项的默认值见 PoolOptions 与默认值)。池在这里只负责「再要一条」:断线后 acquire 一条新的,建连接的重试、超时都是池现成的。也可以用 connect_with(&pool) 从应用自己的池里借一条,代价是那个池少一条可用连接。
connect_with 拿到连接后,把一个无界 mpsc 的发送端塞进连接的 PgStream:connection.inner.stream.notifications = Some(sender)。PgStream::recv 读到 NotificationResponse 而 notifications 不为空时,把它解码后发进这个通道,然后接着读下一条消息(PgStream::recv)。所以监听连接在跑普通查询的时候到达的通知不会打乱查询,也不会丢,而是进了缓冲,try_recv 第一步就先看缓冲,next_buffered 只看缓冲、不等。
listen 执行 LISTEN "{}",频道名先过 ident(截掉 \0、双写 "),加双引号保住大小写,再记进 channels,重连时要用。
实验:收一条、缓冲一条
src/bin/listen.rs 让 PgListener 走代理,另开一条直连的 notifier 发通知。PgListener 自己也实现了 Executor,可以在它的连接上查自己的 pg_backend_pid():
let mut listener = PgListener::connect_with(&pool).await?; // pool: max 1, 无 lifetime / idle 超时
listener.listen("pg2_ch").await?;
let pid: i32 = sqlx::query_scalar("select pg_backend_pid()").fetch_one(&mut listener).await?;
notifier.execute("notify pg2_ch, 'one'").await?;
let n = listener.recv().await?;
println!("recv {:?} from pid {}", n.payload(), n.process_id());
// 通知在 listener 自己跑查询时到达
listener.execute("notify pg2_ch, 'self'").await?;
println!("next_buffered {:?}", listener.next_buffered().map(|n| n.payload().to_string()));
[conn 1] C->S Query("LISTEN \"pg2_ch\"")
[conn 1] S->C CommandComplete("LISTEN"), ReadyForQuery(I)
...
listener pid 393
--- 1. 普通的一条 ---
[conn 1] S->C NotificationResponse
recv "one" from pid 392
--- 2. 通知在 listener 自己跑查询时到达 ---
[conn 1] C->S Query("notify pg2_ch, 'self'")
[conn 1] S->C CommandComplete("NOTIFY"), NotificationResponse, ReadyForQuery(I)
next_buffered Some("self")
第一段里,NotificationResponse 是服务端主动推的,前面没有任何请求。process_id 是发通知那个后端的 pid(notifier 的 392)。第二段里,通知夹在 CommandComplete 和 ReadyForQuery 之间到达,execute 照常结束,通知留在缓冲里。
try_recv 和断线
try_recv 先清缓冲,再在一个循环里用 recv_unchecked 读原始消息。用 recv_unchecked 而不是 recv,是因为这里要自己处理 NotificationResponse:交给 recv 的话它会被塞进缓冲。读出错时:
let message = match res {
Ok(message) => message,
// The connection is dead, ensure that it is dropped,
// update self state, and loop to try again.
Err(Error::Io(err))
if matches!(
err.kind(),
io::ErrorKind::ConnectionAborted |
io::ErrorKind::UnexpectedEof |
// see ERRORS section in tcp(7) man page (https://man7.org/linux/man-pages/man7/tcp.7.html)
io::ErrorKind::TimedOut |
io::ErrorKind::BrokenPipe
) =>
{
if let Some(mut conn) = self.connection.take() {
self.buffer_tx = conn.inner.stream.notifications.take();
// Close the connection in a background task, so we can continue.
conn.close_on_drop();
}
if self.eager_reconnect {
self.connect_if_needed().await?;
}
// lost connection
return Ok(None);
}
四种 IO 错误被当作「连接断了」:把通道的发送端从死连接上取回来,连接标成 close_on_drop(见归还、Drop 与维护任务),然后返回 Ok(None)。connect_if_needed 从池里借一条新连接,装回发送端,用一条简单查询把 channels 里的频道一次全 LISTEN 上。别的错误原样返回。recv 只是循环调 try_recv,跳过 None,所以断线对它的调用方完全不可见。
实验:杀掉自己的后端
接着上面的程序:先发一条 before-kill,然后从 notifier 用 pg_terminate_backend 杀掉 listener 自己那条连接的后端(pid 是上面在 listener 的会话里查到的),再发一条 while-down,最后才去读:
notifier.execute("notify pg2_ch, 'before-kill'").await?;
let killed: bool = sqlx::query_scalar("select pg_terminate_backend($1)").bind(pid).fetch_one(&mut notifier).await?;
tokio::time::sleep(Duration::from_millis(200)).await;
notifier.execute("notify pg2_ch, 'while-down'").await?;
tokio::time::sleep(Duration::from_millis(200)).await;
for _ in 0..2 {
let r = listener.try_recv().await?;
println!("try_recv {:?}", r.map(|n| n.payload().to_string()));
}
notifier.execute("notify pg2_ch, 'after'").await?;
let r = listener.try_recv().await?;
println!("try_recv {:?}", r.map(|n| n.payload().to_string()));
--- 3. 杀掉 listener 自己的后端 ---
[conn 1] S->C NotificationResponse
pg_terminate_backend(393) = true
[conn 1] S->C ErrorResponse
try_recv Some("before-kill")
[conn 1] C->S Terminate
[conn 2] C->S StartupMessage(v3.0)
...
[conn 2] C->S Query("LISTEN \"pg2_ch\";")
[conn 2] S->C CommandComplete("LISTEN"), ReadyForQuery(I)
try_recv None
[conn 2] S->C NotificationResponse
try_recv Some("after")
...
listener pid 394
before-kill在后端被杀之前已经写进了 socket,try_recv照样读到它。- 后端退出前发了一条
ErrorResponse。recv_unchecked不把它变成错误,try_recv的match也不认识它,落进_ => {}被忽略。再往下读,连接已经关了,这次的 IO 错误落在那四种之内(try_recv返回的是None而不是错误),走上面那个分支:Terminate、新连接(conn 2)、LISTEN "pg2_ch";,返回None。 while-down发出时 393 已经不在,394 还没有LISTEN,服务端没有任何会话在听这个频道,这条通知就没了,后面再也读不到。recv的文档写的就是这个:As notifications are transient, any received while the connection was lost, will not be returned.
所以 try_recv 返回 None 是一个信号:中间可能丢过通知。依赖通知做缓存失效之类的应用,拿到 None 时应该重新读一遍状态。
None 之前先重连eager_reconnect 是 #3585 加的,首次发布于 0.8.3。之前 try_recv 发现断线只是返回 None,到下一次调用才重连。PR 说明指出了这个顺序的问题:if the user code uses another database connection to do so then there is a period between when the state is reloaded and PgListener's connection is re-established where notifications are lost without any indication that this has happened. 也就是说,应用拿到 None 后用别的连接重读状态,重读完到下一次 try_recv 重连之间的通知会无声地丢掉。现在默认先 connect_if_needed 再返回 None(上面代码里的 if self.eager_reconnect),恢复逻辑跑完之后到达的通知都在新连接上等着;想要旧行为可以 eager_reconnect(false)。PR 说明把旧行为称为 a bit of a footgun。
PgListener 被 drop 时,Drop spawn 一个任务,先执行 UNLISTEN * 再把连接还给池:连接如果来自应用的池,就不能带着订阅回去。
COPY:绕开 run 的两条路
COPY ... FROM STDIN 和 COPY ... TO STDOUT 是 Postgres 批量导入导出的协议:一条命令之后,双方用 CopyData 消息流式地传原始数据(文本、CSV 或二进制格式)。PgConnection 上的入口是 copy_in_raw 和 copy_out_raw,池上是 PgPoolCopyExt 的同名方法(借一条连接用到底)。raw 的意思是 sqlx 不管数据格式,给什么发什么。参数 statement 是普通的 &str,不要求 SqlSafeStr。
两条路都不走 run,而是直接对 PgStream 收发。PgCopyIn::begin:
async fn begin(mut conn: C, statement: &str) -> Result<Self> {
conn.wait_until_ready().await?;
conn.inner.stream.send(Query(statement)).await?;
let response = match conn.inner.stream.recv_expect::<CopyInResponse>().await {
Ok(res) => res.0,
Err(e) => {
conn.inner.stream.recv().await?;
return Err(e);
}
};
Ok(PgCopyIn {
conn: Some(conn),
response,
})
}
COPY 走简单查询协议(Query 消息),服务端回 CopyInResponse,里面有整体格式和每列的格式码(is_textual、num_columns、column_is_textual 读的就是它)。之后 send 把数据按 PG_COPY_MAX_DATA_LEN(0x3fffffff - 1 - 4,一条消息的上限减去类型字节和长度)切块,每块一条 CopyData;read_from 直接从一个 AsyncRead 读进写缓冲。最后必须调 finish(发 CopyDone,收 CommandComplete 拿行数,再收 ReadyForQuery)或 abort(发 CopyFail,服务端回错误码 57014 的 ErrorResponse,abort 把它当作成功)。导出一侧的 pg_begin_copy_out 返回一个 Bytes 的流,每条 CopyData 产出一项,读到 CopyDone 后再收掉 CommandComplete 和 ReadyForQuery。
实验:导入、导出、放弃
src/bin/copy.rs:
let mut copy = conn.copy_in_raw("copy item from stdin (format csv)").await?;
println!("is_textual {} num_columns {}", copy.is_textual(), copy.num_columns());
copy.send(b"1,apple\n2,banana\n".as_slice()).await?;
copy.send(b"3,cherry\n".as_slice()).await?;
let rows = copy.finish().await?;
let chunks: Vec<_> = conn.copy_out_raw("copy item to stdout (format csv)").await?.try_collect().await?;
let mut copy = conn.copy_in_raw("copy item from stdin (format csv)").await?;
copy.send(b"4,durian\n".as_slice()).await?;
copy.abort("changed my mind").await?;
--- COPY FROM STDIN ---
[conn 1] C->S Query("copy item from stdin (format csv)")
[conn 1] S->C CopyInResponse
is_textual true num_columns 2
[conn 1] C->S CopyData(17 字节)
[conn 1] C->S CopyData(9 字节), CopyDone
[conn 1] S->C CommandComplete("COPY 3"), ReadyForQuery(I)
finish -> 3 行
--- COPY TO STDOUT ---
[conn 1] C->S Query("copy item to stdout (format csv)")
[conn 1] S->C CopyOutResponse, CopyData(8 字节), CopyData(9 字节), CopyData(9 字节), CopyDone, CommandComplete("COPY 3"), ReadyForQuery(I)
chunk "1,apple\n"
chunk "2,banana\n"
chunk "3,cherry\n"
--- abort ---
[conn 1] C->S Query("copy item from stdin (format csv)")
[conn 1] S->C CopyInResponse
[conn 1] C->S CopyData(9 字节)
[conn 1] C->S CopyFail
[conn 1] S->C ErrorResponse
[conn 1] S->C ReadyForQuery(I)
abort -> Ok
导入时 send 一次一条 CopyData,切分完全由调用方决定,服务端自己按行解析,CopyData 的边界不必和行对齐。导出时服务端每行一条 CopyData,流也就每行产出一个 Bytes。
注意这些路径都没有碰 pending_ready_for_query_count。begin 用 send(Query(..)),不是会给计数加一的 queue_simple_query;finish 和 abort 用 recv_expect::<ReadyForQuery>() 自己把那个 ReadyForQuery 收掉。只要走完 finish 或 abort,计数和消息流就是对得上的。PgCopyIn 的 Drop 不一样:
impl<C: DerefMut<Target = PgConnection>> Drop for PgCopyIn<C> {
fn drop(&mut self) {
if let Some(mut conn) = self.conn.take() {
conn.inner
.stream
.write_msg(CopyFail::new(
"PgCopyIn dropped without calling finish() or fail()",
))
.expect("BUG: PgCopyIn abort message should not be too large");
}
}
}
Drop 里不能 await,只能把 CopyFail 写进缓冲,等下一次 wait_until_ready 把它 flush 出去。服务端对它的回应(一条 ErrorResponse 加一条 ReadyForQuery)没有人在等。文档对此的警告是 connection will error on next use,类型上也标了 #[must_use]。
let mut copy = conn.copy_in_raw("copy item from stdin (format csv)").await?;
copy.send(b"5,elderberry\n".as_slice()).await?;
drop(copy);
for i in 1..=4_i64 {
let r: Result<i64, _> = sqlx::query_scalar("select $1 * 100").bind(i).fetch_one(&mut conn).await;
// 打印 Ok(v),或者错误
}
文档说第一次查询会报错。第三次(i = 3)打印的是什么?
PgAdvisoryLock
Advisory lock 是 Postgres 提供给应用自己定义含义的锁:pg_advisory_lock(key) 以一个 bigint(或两个 int)为键,在会话级别持有,直到 pg_advisory_unlock 或会话结束。PgAdvisoryLock 是对这组函数的一层包装:
new(key_string)把一个字符串用 HKDF-SHA256(info 串是SQLx (Rust) Postgres advisory lock)派生成 8 个字节,i64::from_le_bytes得到键,同一个字符串在任何机器上都得到同一个键;with_key直接给键。acquire(conn)先prepare一条SELECT pg_advisory_lock($1),先把连接包进PgAdvisoryLockGuard,再执行。顺序是 #4199(0.9.0)改的,PR 标题是 make advisory lock cancel safe:acquire的 future 在等锁时被取消,guard 的Drop也会排队释放。try_acquire(conn)用pg_try_advisory_lock,拿不到时返回Either::Right(conn),把连接原样还给你。- guard 的
Drop调queue_simple_query排一条SELECT pg_advisory_unlock(<key>),下次用这条连接时才发;release_now是立即释放的 async 版本。
src/bin/advisory.rs 用连接 A(走代理)加锁,连接 B 去 try_acquire:
key BigInt(5367643390023655937)
--- A acquire ---
[conn 1] C->S Parse(sqlx_s_1, "SELECT pg_advisory_lock($1)"), 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; [8] 4a 7d b3 39 67 55 62 01), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, DataRow(列长 0), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
B try_acquire -> 没拿到
--- drop(guard) ---
B try_acquire -> 没拿到
--- A 再用一次 ---
[conn 1] C->S Query("SELECT pg_advisory_unlock(5367643390023655937)")
[conn 1] S->C RowDescription, DataRow(列长 1), CommandComplete("SELECT 1"), ReadyForQuery(I)
[conn 1] C->S Query("select 1")
[conn 1] S->C RowDescription, DataRow(列长 1), CommandComplete("SELECT 1"), ReadyForQuery(I)
B try_acquire -> 拿到
drop(guard) 之后 A 上什么都没发,锁还在,B 仍然拿不到;直到 A 再执行一条语句,wait_until_ready 先把排着的 unlock 发出去、等到它的 ReadyForQuery,然后才发 select 1。和事务的 Drop 只把 ROLLBACK 排进队列是同一种做法,同样的后果:直接持有的 PgConnection 在 drop 了 guard 之后如果一直闲着,锁就一直在。迁移用的锁不走这个类型,Migrator 直接执行 pg_advisory_lock($1),见迁移。
动脑筋答案
--- 既不 finish 也不 abort,直接 drop ---
[conn 1] C->S Query("copy item from stdin (format csv)")
[conn 1] S->C CopyInResponse
[conn 1] C->S CopyData(13 字节)
[conn 1] C->S CopyFail
[conn 1] C->S Parse(sqlx_s_1, "select $1 * 100"), Describe(S, sqlx_s_1), Sync
[conn 1] S->C ErrorResponse
[conn 1] S->C ReadyForQuery(I)
drop 之后第 1 次查询 -> Database(Some("57014"), COPY from stdin failed: PgCopyIn dropped without calling finish() or fail())
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 1] C->S Parse(sqlx_s_2, "select $1 * 100"), Describe(S, sqlx_s_2), Sync
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_2; [8] 00 00 00 00 00 00 00 02), Execute, Close(P, unnamed), Sync
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
drop 之后第 2 次查询 -> RowNotFound
[conn 1] S->C BindComplete, DataRow(列长 8), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
drop 之后第 3 次查询 -> Ok(200)
[conn 1] C->S Bind(portal unnamed, stmt sqlx_s_2; [8] 00 00 00 00 00 00 00 03), Execute, Close(P, unnamed), Sync
[conn 1] S->C BindComplete, DataRow(列长 8), CommandComplete("SELECT 1"), CloseComplete, ReadyForQuery(I)
drop 之后第 4 次查询 -> Ok(300)
第三次查询 i = 3,拿到的是 200,第二次查询的结果;第四次拿到 300。从第二次开始,每条查询读到的都是上一条的回应。一步步看计数:
- 第 1 次:
run开头的wait_until_ready看到写缓冲不空,先把CopyFailflush 出去(计数是 0,不等回应),接着prepare发Parse。prepare等ParseComplete,先来的是CopyFail引出的ErrorResponse,报错返回。这时计数是 1(Parse后面那个Sync),而线上还有两组没读的回应:COPY的ReadyForQuery,和Parse那一组。 - 第 2 次:
wait_until_ready读到第一个ReadyForQuery(COPY的那个)就把计数减到 0,停下。随后prepare把第 1 次Parse的那一组当成自己的回应;Bind之后,run的循环读到的是这一次Parse的回应,遇到它的ReadyForQuery就结束了,一行都没读到,fetch_one报RowNotFound。 - 第 3 次:语句缓存已经命中,只发
Bind。线上排在最前面的是第 2 次Bind的回应,DataRow里是200。 - 此后一直错开一组,连接不会自己恢复,而且错位后的结果是
Ok,看起来完全正常。
0.8.6 跑同一段,四行输出相同。所以 finish / abort 的那条规则不只是「下一次会报错」:报完错之后,这条连接上的结果都不可信了,只能关掉它。拿不准时,在 PgCopyIn 所在的作用域里保证每条路径都调到 finish 或 abort。
小结
PgListener自己建一个max_connections(1)、不超时的池,只为了断线后再要一条连接;通知经PgStream里的mpsc发送端进缓冲,监听连接上跑查询时到达的通知不会丢。try_recv把四种 IO 错误当作断线:丢掉连接、(默认)立即重连并重新LISTEN、返回None。断线期间发出的通知丢失,None就是「可能丢过」的信号;recv把None吞掉。COPY用简单查询协议发命令,然后直接收发CopyData,不经过run;finish/abort自己收掉ReadyForQuery。PgCopyIn不finish也不abort就 drop,Drop只能排一条CopyFail,它的ReadyForQuery没有计入pending_ready_for_query_count:第一次查询报错,之后每条查询拿到上一条的结果(0.9.0 和 0.8.6 都是这样)。PgAdvisoryLock把字符串用 HKDF 派生成bigint键;guard 的Drop只排队pg_advisory_unlock,下次用连接时才发;0.9 起acquire被取消也会释放。