写一个迷你 Postgres 客户端
主线位置:把查询主线和连接池主线各从零实现一遍。查询一侧从建连接、认证,到一条带参数的查询在线上变成
Parse/Bind/Execute/Sync,再把DataRow解成 Rust 值;连接池一侧从acquire拿许可到Drop时归还。编译期检查主线不实现。
sequenceDiagram participant C as 迷你客户端 participant S as Postgres C->>S: StartupMessage S-->>C: AuthenticationSASL C->>S: SASLInitialResponse S-->>C: SASLContinue 盐和次数 C->>S: SASLResponse 带证明 S-->>C: SASLFinal 等到 ReadyForQuery C->>S: Parse Bind Execute Sync S-->>C: ParseComplete 到 ReadyForQuery
范围
客户端只依赖 tokio、bytes、sha2、hmac、pbkdf2、base64、rand,不碰 sqlx 的任何 crate。每一块对应 sqlx 里讲它的那一页:
| 迷你版 | 做到哪一步 | sqlx 里的对应 |
|---|---|---|
wire.rs(127 行) | 消息的切分和缓冲,ErrorResponse 的 V / C / M 三个字段 | 消息与读写缓冲 |
scram.rs(98 行) | SCRAM-SHA-256,不做通道绑定,不做 SASLprep | 连接选项与建立连接 |
conn.rs(246 行) | StartupMessage;简单查询收文本;扩展查询只收一个 int8 参数,结果全按二进制 int8 解 | 扩展查询协议与语句缓存 |
pool.rs(89 行) | Semaphore 限数,Vec 放空闲连接,acquire 带超时,Drop 时归还 | 获取连接、归还、Drop 与维护任务 |
下文只贴关键片段,都是 2 空格缩进的自写代码;引用 sqlx 源码的地方保留原样的 4 空格。所有实验都在 M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上跑,连实验库 sqlx-lab,表建在 handson schema 里。
消息:类型字节加长度
除了第一条 StartupMessage,每条消息都是一个类型字节、一个 4 字节大端长度(包括长度自己)、然后是消息体。写的一侧只往缓冲区里追加,长度先占位,消息体写完再回填;flush 才真正发出去:
// Append one message to the write buffer; nothing is sent until flush().
// `ty: None` is the StartupMessage, the only message without a type byte.
pub fn put(&mut self, ty: Option<u8>, body: impl FnOnce(&mut BytesMut)) {
if let Some(ty) = ty {
self.write_buf.put_u8(ty);
}
let at = self.write_buf.len();
self.write_buf.put_u32(0);
body(&mut self.write_buf);
let len = (self.write_buf.len() - at) as u32;
self.write_buf[at..at + 4].copy_from_slice(&len.to_be_bytes());
}
读的一侧攒够一整条才交出去,消息体是从 BytesMut 上 split_to 再 freeze 出来的 Bytes,不复制:
pub async fn recv(&mut self) -> Result<(u8, Bytes), Error> {
loop {
if let [ty, a, b, c, d, ..] = self.read_buf[..] {
let len = u32::from_be_bytes([a, b, c, d]) as usize;
// ... len < 4 报协议错误
if self.read_buf.len() > len {
let mut msg = self.read_buf.split_to(1 + len).freeze();
msg.advance(5);
return Ok((ty, msg));
}
}
if self.stream.read_buf(&mut self.read_buf).await? == 0 {
return protocol("server closed the connection");
}
}
}
sqlx 的形状一样:write_msg 只进缓冲、flush 才发,读的一侧也是先攒够长度。区别在 recv 之上:sqlx 的 PgStream::recv 把 ErrorResponse 直接变成 Err 返回、把 NoticeResponse 和 ParameterStatus 吞掉,迷你版把这些都留给每个调用方自己 match。消息体解析用 bytes 的 try_get_i32 一类方法,消息短了得到 Err 而不是 panic。两边各自的类型字节有重名:前端的 S 是 Sync、E 是 Execute、D 是 Describe,后端的 S 是 ParameterStatus、E 是 ErrorResponse、D 是 DataRow,所以解析时必须知道方向。
启动和 SCRAM-SHA-256
StartupMessage 是协议版本 196608(3 << 16,即 3.0)加若干对以 NUL 结尾的键值,最后一个 0 字节。user、database 之外的键,服务端当作这个会话的运行时参数,所以 search_path 可以直接放进去:
wire.put(None, |b| {
b.put_i32(196_608); // protocol 3.0
for (k, v) in [("user", &cfg.user), ("database", &cfg.database)] {
put_cstr(b, k);
put_cstr(b, v);
}
for (k, v) in &cfg.params {
put_cstr(b, k);
put_cstr(b, v);
}
b.put_u8(0);
});
wire.flush().await?;
let mut backend_pid = 0;
loop {
let (ty, mut body) = wire.recv().await?;
match ty {
b'R' => match body.try_get_i32()? {
0 => {} // AuthenticationOk
10 => scram::authenticate(&mut wire, &cfg.user, &cfg.password, body).await?,
n => return protocol(format!("unsupported authentication method {n}")),
},
b'K' => backend_pid = body.try_get_i32()?,
b'S' | b'N' => {} // ParameterStatus, NoticeResponse
b'E' => return Err(parse_error(body)),
b'Z' => return Ok(Conn { wire, backend_pid, busy: false, tx_status: body.try_get_u8()? }),
// ...
}
}
这个循环和 sqlx 的 PgConnection::establish 一一对应,只少了明文和 MD5 两种认证、SSLRequest 和 TLS。sqlx 在启动参数里固定写上 DateStyle、client_encoding=UTF8、TimeZone=UTC,迷你版什么都不写,靠的是实验库的默认编码正好是 UTF8;下面把文本值按 UTF-8 解,就建立在这个前提上。BackendKeyData 里的进程号留下来,后面用它分辨池里是哪条连接。
认证码 10 是 AuthenticationSASL,消息体是服务端支持的机制列表。SCRAM 的计算和 sasl::authenticate 照 RFC 5802 的公式一步步来,核心是这一段:
// SaltedPassword := Hi(password, salt, i), Hi is PBKDF2-HMAC-SHA256
let mut salted = [0u8; 32];
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, iterations, &mut salted);
let client_key = hmac(&salted, b"Client Key")?;
let stored_key = Sha256::digest(client_key);
let without_proof = format!("c=biws,r={nonce}"); // biws = base64("n,,")
let auth_message = format!("{client_first_bare},{server_first},{without_proof}");
let client_signature = hmac(&stored_key, auth_message.as_bytes())?;
let proof: Vec<u8> = client_key.iter().zip(client_signature).map(|(k, s)| k ^ s).collect();
let client_final = format!("{without_proof},p={}", BASE64.encode(proof));
wire.put(Some(b'p'), |b| b.put_slice(client_final.as_bytes()));
wire.flush().await?;
// server-final-message: v=<ServerSignature>; checking it proves the server knows the password too
let server_final = recv_sasl(wire, AUTH_SASL_FINAL).await?;
let server_key = hmac(&salted, b"Server Key")?;
let expected = BASE64.encode(hmac(&server_key, auth_message.as_bytes())?);
if server_final.strip_prefix("v=") != Some(expected.as_str()) {
return protocol("server signature mismatch");
}
在这之前,客户端发 n,,n=lab,r=<nonce>(n,, 表示不做通道绑定),服务端回 r=<客户端 nonce 接上服务端的部分>,s=<盐>,i=4096;迷你版检查服务端的 nonce 确实以自己的开头。客户端 nonce 是 18 个随机字节的 base64,24 个字符,base64 字母表里没有逗号,满足 RFC 的要求;sqlx 的 gen_nonce 生成 64 到 127 个可打印字符。每一步为什么这样算,连接选项与建立连接那页按 RFC 讲过,这里不重复。
迷你版的 pbkdf2_hmac 是一次同步调用,4096 轮一口气算完,期间占着运行时的工作线程。实测(src/bin/pbkdf2_time.rs):
debug
4096 rounds: 34.19925ms
4096 rounds: 35.401167ms
4096 rounds: 34.959834ms
release
4096 rounds: 1.451708ms
4096 rounds: 1.454417ms
4096 rounds: 1.446417ms
debug 构建下一次 35 毫秒,下面 connected ... 39.6ms 的大头就是它。sqlx 的 hi 0.9.0 起每算 5 轮 yield_now 一次,原因和来历见连接那页;在 async 里做 CPU 计算的一般问题见 tokio 卷的在 async 里阻塞。
简单查询
一条 Query 消息,里面可以有多条语句,以分号隔开。每条语句的结果是可选的 RowDescription、若干 DataRow、一条 CommandComplete;全部做完,服务端最后回一个 ReadyForQuery。值全是文本:
pub async fn simple_query(&mut self, sql: &str) -> Result<Vec<SimpleResult>, Error> {
self.busy = true;
self.wire.put(Some(b'Q'), |b| put_cstr(b, sql));
self.wire.flush().await?;
let mut results = Vec::new();
let mut current = SimpleResult::default();
let mut error = None;
loop {
let (ty, mut body) = self.wire.recv().await?;
match ty {
b'T' => current.columns = parse_row_description(body)?,
b'D' => { /* 每列 i32 长度加字节,-1 是 NULL,按 UTF-8 转成 String */ }
b'C' => {
current.tag = get_cstr(&mut body)?;
results.push(std::mem::take(&mut current));
}
b'I' => {} // EmptyQueryResponse
b'E' => error = Some(parse_error(body)),
b'S' | b'N' => {}
b'Z' => {
self.busy = false;
self.tx_status = body.try_get_u8()?;
return error.map_or(Ok(results), Err);
}
// ...
}
}
}
RowDescription 每列给名字、所属表的 OID、列号、类型 OID、长度、修饰符、格式码,迷你版只留名字和类型 OID。sqlx 只有在 Execute::take_arguments() 返回 Ok(None) 时才走这条路(直接把 &str 交给 Executor,或者用 raw_sql),见扩展查询协议与语句缓存的「简单查询的分支」。busy 和 tx_status 两个字段留到取消和连接池那两节讲。
带一个参数的扩展查询
扩展查询把一次查询拆成几条消息:Parse 建语句、Bind 把参数绑进一个 portal、Execute 执行、Sync 结束这一组。迷你版一次写完四条,一次 flush:
self.wire.put(Some(b'P'), |b| {
put_cstr(b, ""); // unnamed statement
put_cstr(b, sql);
b.put_i16(1); // one parameter type
b.put_u32(INT8_OID);
});
self.wire.put(Some(b'B'), |b| {
put_cstr(b, ""); // unnamed portal
put_cstr(b, ""); // unnamed statement
b.put_i16(1); // one parameter format: binary
b.put_i16(BINARY);
b.put_i16(1); // one parameter value
b.put_i32(8);
b.put_i64(param);
b.put_i16(1); // one result format, applies to all columns: binary
b.put_i16(BINARY);
});
self.wire.put(Some(b'E'), |b| {
put_cstr(b, "");
b.put_i32(0); // no row limit
});
self.wire.put(Some(b'S'), |_| {});
self.wire.flush().await?;
Parse 里带上参数类型 OID 20(int8),服务端就不用猜 $1 的类型;Bind 的结果格式只给一个码,按协议它适用于所有列。int8 的二进制格式就是 8 字节大端,解码是一个切片模式:
match value.as_deref() {
None => row.push(None),
Some(&[a, b, c, d, e, f, g, h]) => row.push(Some(i64::from_be_bytes([a, b, c, d, e, f, g, h]))),
// keep reading up to ReadyForQuery so the connection stays usable
Some(v) => {
let msg = format!("column {i}: {} bytes is not an int8", v.len());
error.get_or_insert(Error::Decode(msg));
}
}
这里少了 sqlx 最重要的一步。sqlx 的 prepare 先发 Parse + Describe + Sync 并等回应,拿到 ParameterDescription 和 RowDescription,才知道每一列是什么类型,Decode 之前可以检查 Rust 类型和列类型是否兼容;语句还有名字(sqlx_s_1),进 get_or_prepare 管的缓存,第二次执行只发 Bind 以后的部分。迷你版用未命名语句、不 Describe,类型是调用方许诺的,只能从字节长度上发现许诺不对:select $1::int4 的列是 4 字节,报 Decode。下面的对照里,sqlx 对同一条 SQL 报的是 mismatched types,因为它从 Describe 知道那一列是 INT4。
Execute 的 limit: 0 表示不限行数,sqlx 的 Execute 也是 0,为什么不能改见扩展查询那页。sqlx 在 Execute 和 Sync 之间还多发一条 Close 关掉未命名 portal:未命名 portal 本来在事务结束或下一次 Bind 时就会被销毁,显式事务里却会一直活到事务结束,Close 让它提前释放。迷你版没发。
ErrorResponse:读到 ReadyForQuery 为止
出错时服务端发 ErrorResponse,字段是一串「字段字节 + C 字符串」,迷你版只留 V(不随语言变化的严重级别)、C(SQLSTATE)和 M(消息):
pub fn parse_error(mut body: Bytes) -> Error {
let (mut severity, mut code, mut message) = (String::new(), String::new(), String::new());
while let Ok(field) = body.try_get_u8() {
let Ok(value) = get_cstr(&mut body) else { break };
match field {
b'V' => severity = value,
b'C' => code = value,
b'M' => message = value,
_ => {}
}
}
Error::Db { severity, code, message }
}
关键在调用方:看到 E 不立刻返回,先记下错误,继续读到 ReadyForQuery 才返回。扩展查询里出错之后,服务端丢掉直到 Sync 的所有消息,再回 ReadyForQuery;读到它,连接就回到了「下一条消息是新请求的回应」的状态,可以接着用。解码错误也照这个办法处理:记下来,读完再报。sqlx 的 recv 直接把 ErrorResponse 变成 Err 返回,欠下的 ReadyForQuery 记在 pending_ready_for_query_count 上,下一次查询前由 wait_until_ready 读掉,见消息与读写缓冲的「ErrorResponse 之后,连接还能用」。两种做法都保证了同一件事,只是迷你版在当次就读完,sqlx 延到下一次。
连接池:一个信号量加一个 Vec
许可数等于最大连接数,借出的连接各占一个许可,空闲连接不占。acquire 先拿许可,再从 Vec 里弹一条空闲连接,没有就新建;整个过程包在一个超时里:
pub async fn acquire(&self) -> Result<PooledConn, Error> {
let shared = &self.0;
let attempt = async {
// Semaphore is FIFO: waiters are served in arrival order
let permit = shared.permits.clone().acquire_owned().await.map_err(|_| Error::PoolTimedOut)?;
let idle = shared.idle.lock().map(|mut idle| idle.pop()).unwrap_or(None);
let conn = match idle {
Some(conn) => conn,
None => Conn::connect(&shared.config).await?,
};
Ok(PooledConn { conn: Some(conn), pool: shared.clone(), _permit: permit })
};
tokio::time::timeout(shared.acquire_timeout, attempt).await.map_err(|_| Error::PoolTimedOut)?
}
这是 sqlx PoolInner::acquire 的骨架:也是一个 acquire_timeout 包住「拿许可 → 弹空闲 → 否则新建」,超时一律报 PoolTimedOut。tokio 下 sqlx 的 AsyncSemaphore 就是 tokio::sync::Semaphore,先到先得的保证来自它,见 tokio 卷的 batch_semaphore。sqlx 多出来的部分:借出前 ping、before_acquire 回调、size 计数和 try_increment_size、连接失败的退避重试,见获取连接。迷你版用 Vec::pop 取最近还回来的那条,sqlx 的 ArrayQueue 是先进先出。总连接数不会超过上限:只有空闲队列为空时才新建,此时打开的连接数等于借出数,不超过许可数。
归还在 Drop 里:
impl Drop for PooledConn {
fn drop(&mut self) {
// a busy connection or one inside a transaction is dropped (closing the socket);
// the permit still goes back, so the next acquire opens a new one
if let Some(conn) = self.conn.take().filter(|c| c.reusable())
&& let Ok(mut idle) = self.pool.idle.lock()
{
idle.push(conn);
}
}
}
许可 _permit 是结构体字段,在 drop 方法体跑完之后才被 drop,所以顺序必然是「先推回空闲队列,再放许可」。被唤醒的等待者拿到许可时,连接已经在 Vec 里了。sqlx 的 release 为同一件事写了一句注释:
// NOTE: we need to make sure we drop the permit *after* we push to the idle queue
// don't decrease the size
guard.release_permit();
reusable 是 !self.busy && self.tx_status == b'I':没有读到一半的回应,最后一个 ReadyForQuery 的状态字节是 I(不在事务里)。不满足的连接直接丢掉,TcpStream 一关,服务端那边的会话结束,没提交的事务随之回滚。锁用 std::sync::Mutex:临界区只是一次 push / pop,不跨 await,Drop 里也只能用同步锁,见 tokio 卷的 tokio::sync::Mutex 还是 std::sync::Mutex。
问: 迷你版在 Drop 里同步归还,sqlx 的 PoolConnection 的 Drop 却要 spawn 一个任务。sqlx 为什么不也同步做?
答: 因为 sqlx 归还时要做 I/O。return_to_pool 要跑 after_release,还要 ping 一次:这个 Sync 来回顺带读掉欠下的回应、把 Transaction::drop 排进去的 ROLLBACK 发出去,然后连接才能回队列。Drop 不能 await,只能交给一个任务。迷你版归还时不做任何 I/O,所以能同步;代价是一条有问题的连接没法修好再用,只能丢掉重连,而 sqlx 能把它救回来(下面动脑筋答案里有对照)。
跑起来
src/bin/mini_demo.rs:建连接,用一条四语句的 Query 建表,跑一次简单查询、一次扩展查询,再试三种错误,最后试连接池。mini_demo 用的 Config 里加了 search_path=handson 和 application_name:
--- connect ---
connected, backend pid 735, 39.628042ms
--- simple query: setup (4 statements in one Query) ---
"CREATE SCHEMA" columns []
"DROP TABLE" columns []
"CREATE TABLE" columns []
"INSERT 0 2" columns []
--- simple query: select ---
"SELECT 2" columns [id:20, title:25, note:25]
[Some("1"), Some("hello sqlx"), None]
[Some("2"), Some("mini client"), Some("no sqlx")]
--- extended query: one int8 parameter, binary result ---
[[Some(1), Some(10)], [Some(2), Some(20)]]
--- errors ---
simple: ERROR 42601: syntax error at or near "selec"
extended: ERROR 22012: division by zero
decode: decode: column 0: 4 bytes is not an int8
same connection afterwards: [[Some(42)]]
wrong password: FATAL 28P01: password authentication failed for user "lab"
--- pool: max 2, acquire timeout 300ms ---
holding pids 737 and 738
third acquire: pool timed out after 302ms
dropped pid 737; waiting acquire got pid 737 after 101ms
idle now 2
--- pool: 20 tasks, max 2 ---
queries per backend pid: {"737": 10, "738": 10}
idle now 2
- 一条
Query里的四条语句各有一条CommandComplete,标签依次是CREATE SCHEMA、DROP TABLE、CREATE TABLE、INSERT 0 2;只有select带RowDescription。类型 OID 20 是int8,25 是text。NULL在DataRow里是长度 -1,解成None。 - 扩展查询的
$1是 10,两列都按二进制int8解出来。 - 三种错误之后,同一条连接接着跑
select $1 + 1得到 42:ErrorResponse和解码错误都没有让连接错位。密码错时服务端在SASLResponse之后回FATAL 28P01,在recv_sasl里被当作错误返回。 - 池满时第三个
acquire等满 300 毫秒报超时;另一个任务在等的时候,100 毫秒后 drop 一条连接,等待者立刻拿到它(进程号相同),因为归还是同步的。20 个任务共用两条连接,各跑 10 次。
和 sqlx 对照
src/bin/compare.rs 在同一个库上分别用迷你版和 sqlx 0.9.0 跑同样的查询,各自格式化成一行一行的字符串,逐行比较。sqlx 一侧:简单查询用 raw_sql(也是文本格式),按 try_get_raw 取原始文本;扩展查询用 query(...).bind(10_i64),解成 Option<i64>;错误从 PgDatabaseError 取 severity / code / message:
sqlx: select $1::int4 as i64 -> error occurred while decoding column 0: mismatched types; Rust type `i64` (as SQL type `INT8`) is not compatible with SQL type `INT4`
sqlx: column type from Describe: INT4
same simple columns [id:20, title:25, note:25]
same simple row [Some("1"), Some("hello sqlx"), None]
same simple row [Some("2"), Some("mini client"), Some("no sqlx")]
same extended [[Some(1), Some(10)], [Some(2), Some(20)]]
same error ERROR 42601: syntax error at or near "selec"
same error ERROR 22012: division by zero
same error FATAL 28P01: password authentication failed for user "lab"
lines: mini 7, sqlx 7
七行全部相同。前两行是 sqlx 独有的:同一条 select $1::int4,sqlx 从 Describe 拿到列类型 INT4,解码前就拒绝了 i64;迷你版只能事后从 4 字节的长度上发现。
再让两边都经过一次查询的全过程里的代理(复制到本 crate 的 src/proxy.rs,sslmode=disable),跑同一条 select id, id * $1 from posts order by id(src/bin/trace.rs):
=== mini: connect ===
[conn 1] C->S StartupMessage(v3.0)
[conn 1] S->C Authentication
[conn 1] C->S Password/SASL
[conn 1] S->C Authentication
[conn 1] C->S Password/SASL
[conn 1] S->C Authentication, Authentication, ParameterStatus, ... BackendKeyData, ReadyForQuery(I)
=== mini: simple query ===
[conn 1] C->S Query("select id, title from posts where id = 1")
[conn 1] S->C RowDescription, DataRow(列长 1/10), CommandComplete("SELECT 1"), ReadyForQuery(I)
=== mini: extended query ===
[conn 1] C->S Parse(unnamed, "select id, id * $1 from posts order by id"), Bind(portal unnamed, stmt unnamed), Execute, Sync
[conn 1] S->C ParseComplete, BindComplete, DataRow(列长 8/8), DataRow(列长 8/8), CommandComplete("SELECT 2"), ReadyForQuery(I)
[[Some(1), Some(10)], [Some(2), Some(20)]]
=== sqlx: connect ===
[conn 2] C->S StartupMessage(v3.0)
[conn 2] S->C Authentication
[conn 2] C->S Password/SASL
[conn 2] S->C Authentication
[conn 2] C->S Password/SASL
[conn 2] S->C Authentication, Authentication, ParameterStatus, ... BackendKeyData, ReadyForQuery(I)
=== sqlx: query + bind, first ===
[conn 2] C->S Parse(sqlx_s_1, "select id, id * $1 from posts order by id"), Describe(S, sqlx_s_1), Sync
[conn 2] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 2] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
[conn 2] S->C BindComplete, DataRow(列长 8/8), DataRow(列长 8/8), CommandComplete("SELECT 2"), CloseComplete, ReadyForQuery(I)
[(1, 10), (2, 20)]
=== sqlx: query + bind, second ===
[conn 2] C->S Bind(portal unnamed, stmt sqlx_s_1), Execute, Close(P, unnamed), Sync
[conn 2] S->C BindComplete, DataRow(列长 8/8), DataRow(列长 8/8), CommandComplete("SELECT 2"), CloseComplete, ReadyForQuery(I)
[(1, 10), (2, 20)]
[conn 2] C->S Terminate
(两段 ParameterStatus 各 14 条,这里用 ... 省掉了。)
- 握手的消息序列一样:一个启动来回、两个 SASL 来回,最后一次读里一口气收到 SASL 签名、
AuthenticationOk、ParameterStatus、BackendKeyData和ReadyForQuery。 - 扩展查询,迷你版一个来回,sqlx 第一次两个来回:多出来的是
Parse+Describe+Sync,换来列类型和一条命名语句。第二次 sqlx 命中语句缓存,也只剩一个来回,和迷你版一样,只多一条Close。 - 结尾 sqlx 的
close()发Terminate;迷你版的连接 drop 时直接关 socket,什么也不发。 - 简单查询里
id的文本是1,长度 1;扩展查询里同一列是二进制,长度 8。
tokio::time::timeout 给一次 query_i64 限时 50 毫秒,查询本身要 200 毫秒(select $1 from pg_sleep(0.2),$1 是 7)。超时之后连接回到池里(池上限 1 条),下一个任务借出连接跑 select $1 + 1($1 是 41),会得到什么?如果把 reusable 里的 !self.busy 删掉呢?换成 sqlx 的池呢?平台:M2 Max、macOS、rustc 1.99.0、Postgres 16.14。答案在页末。没写的部分
| 没写 | 迷你版的表现 | sqlx 里讲它的页 |
|---|---|---|
SSLRequest 和 TLS | 只能明文连接 | 连接选项与建立连接 |
| 明文、MD5 认证,SASLprep | 只有 SCRAM-SHA-256,密码按原样的字节算 | 同上 |
Describe 和列类型 | 调用方许诺类型,错了只能从长度上发现 | describe 与可空性推断 |
| 命名语句和语句缓存 | 每次都 Parse 未命名语句 | 扩展查询协议与语句缓存 |
Type / Encode / Decode | 只认 text(文本)和 int8(二进制) | 类型:Type、Encode、Decode、Postgres 类型 |
| 流式读取 | 整个结果读进 Vec 才返回 | 一次查询的全过程 |
| 欠账计数 | 取消到一半就丢掉连接 | 扩展查询协议与语句缓存 |
借出时 ping、max_lifetime、维护任务 | 坏掉的空闲连接要到用时才发现 | 获取连接、归还、Drop 与维护任务 |
| 事务 | 事务里的连接归还时直接丢掉 | 事务 |
完整代码、类型系统(把 Type / Encode / Decode 加到这个客户端上)、语句缓存、编译期宏和练习题放在课程里。
动脑筋答案
src/bin/cancel.rs,迷你版的池,上限 1 条,后半段再试一次「begin 之后不提交就 drop」:
pid 856: start a 200ms query, give up after 50ms
timeout: true
idle after drop: 0
pid 857: select 41 + 1 -> [[Some(42)]]
pid 857: select 42 + 1 -> [[Some(43)]]
pid 857: begin, then drop without commit
idle after drop: 0
next acquire: [Some("858"), Some("t")]
- 超时把
query_i64的 future drop 掉时,四条消息已经发出去,回应还没读,busy停在true。Drop看到reusable()为假,丢掉连接,空闲数是 0;下一次acquire新建了 857 号连接,结果是正确的 42。 begin之后ReadyForQuery的状态字节是T,同样被丢掉,下一次拿到的是新的 858 号连接。
把 reusable 里的 !self.busy 删掉再跑:
pid 866: start a 200ms query, give up after 50ms
timeout: true
idle after drop: 1
pid 866: select 41 + 1 -> [[Some(7)]]
pid 866: select 42 + 1 -> [[Some(42)]]
Error: Protocol("unexpected message '1'")
- 连接回到了池里。
select 41 + 1读到的是上一次被取消的查询的回应:ParseComplete、BindComplete、值为 7 的DataRow、ReadyForQuery,所以得到 7。 - 下一次
select 42 + 1拿到的是select 41 + 1的回应 42。整条连接从此错位一条回应,直到simple_query("begin")收到一条它不认识的ParseComplete('1'),才以协议错误暴露出来。前两次都没有报错,结果是错的。PgCopyIn不收尾就 drop 在 sqlx 里造成的也是这种错位。
src/bin/cancel_sqlx.rs 用 sqlx 的池(max_connections(1))做同样的事:
pid 810: start a 200ms query, give up after 50ms
timeout: true
idle after drop: 1
pid 810: select 41 + 1 -> 42
pid 810: select 42 + 1 -> 43
- 同一条 810 号连接回到池里,下一次查询的结果是对的。sqlx 发
Sync时把pending_ready_for_query_count加一,读到ReadyForQuery才减一;被取消的那次查询欠下的回应,由归还时的ping里那次wait_until_ready读掉。迷你版的busy只能回答「有没有欠」,sqlx 的计数还能回答「欠几条」,所以能把它们读完,留下连接。tokio 这边 future 被 drop 时发生了什么,见 select! 的取消安全。
小结
- 线协议的骨架很小:类型字节加长度的切分、一条没有类型字节的
StartupMessage、按类型字节分派的读循环,wire.rs127 行。 - SCRAM-SHA-256 是两个 SASL 来回,最后校验服务端签名;
pbkdf2_hmac同步算 4096 轮,debug 构建下 35 毫秒,release 下 1.5 毫秒。 - 简单查询一条
Query可以带多条语句,值是文本;扩展查询把Parse/Bind/Execute/Sync一次写出,结果是二进制。迷你版不Describe,比 sqlx 首次执行少一个来回,代价是不知道列类型、没有语句缓存。 ErrorResponse之后读到ReadyForQuery为止,连接就还能用。没读完就被取消的连接会错位一条回应:迷你版用busy标记把它丢掉,sqlx 用pending_ready_for_query_count把它读完。- 连接池的核心是一个
Semaphore、一个空闲队列和「先入队,再放许可」的顺序。迷你版归还不做 I/O,所以能在Drop里同步做;sqlx 归还要ping、回滚,所以要spawn。 - 同样的查询,七行结果和 sqlx 0.9.0 相同。