Rust in Everything
基于 sqlx 0.9.0

写一个迷你 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。

📝NOTE
没有蠢问题

问: 迷你版在 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。
❗IMPORTANT
动脑筋:用 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.rs 127 行。
  • 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 相同。

💬 关联讨论

发起讨论
加载中...