Rust in Everything
基于 sqlx 0.9.0

设计取舍

主线位置:三条主线的汇总。查询主线上的取舍是「写 SQL」「纯 Rust 协议」「默认预备语句和缓存」「SqlSafeStr」;连接池主线上的是「一条连接一个查询」「Drop 里排队」「连接错误重试到超时」;编译期检查主线上的是「编译时连库」;「关联类型」贯穿三条。每一条只说收益和代价,机制都在链接指向的页面里。

flowchart TB
  q[查询主线] --> t1["写 SQL"]
  q --> t3["纯 Rust 协议"]
  q --> t4["预备语句加缓存"]
  q --> t9["SqlSafeStr"]
  p[连接池主线] --> t5["一条连接一个查询"]
  p --> t7["Drop 里排队"]
  p --> t8["连接错误重试"]
  c[编译期检查主线] --> t2["编译时连库"]
  t6["关联类型"] --> q
  t6 --> p
  t6 --> c
  t10["迁移加锁"] --> p

先列一张表,下面逐条展开。「证据」一栏是本卷里能看到这个代价的实验所在的页面。

取舍换来的付出的证据
1. 写 SQL,不写 DSL执行的就是你写的 SQL,数据库的全部语法都能用动态结构要 QueryBuilder 或数组参数,SQL 文本一变就是一条新的缓存语句Query 与 SqlSafeStr、in-list
2. 编译时连库检查对的是真实 schema,不用在 Rust 里重写一个 SQL 前端编译要连库或维护 .sqlx;可空性靠 EXPLAIN 猜describe、unexpected-null
3. 纯 Rust 实现协议全程异步,没有 libpq,没有 unsafe协议的每个细节自己写、自己修消息与读写缓冲、建立连接
4. 默认预备语句,按连接缓存重复执行省掉 Parse 来回,值永远是绑定参数cached plan、服务端常驻语句;文档建议 pgbouncer 一类的代理下关掉缓存扩展查询、cached-plan
5. 一条连接一次一个查询&mut PgConnection 独占,状态好推理并发全靠池,池子的上限要和服务端一起算sqlx 解决什么问题、too-many-clients
6. 关联类型,不用 trait 对象单态化,零开销,每个驱动有自己的类型泛型函数约束长,运行时选库只有 Any 的十种值Database 与关联类型
7. Drop 里只能排队用户不用手动还连接、不用手动回滚归还在后台,回滚要等下一次用连接;取消落在半路会留下错位的状态归还与维护、事务
8. 连接错误重试到超时数据库还没起来时应用不会立刻失败连不上库、连接数满、池被占满都只报 PoolTimedOut获取连接、pool-timed-out
9. SqlSafeStrformat! 进 SQL 要在代码里写出 AssertSqlSafe所有动态 SQL 都要包一层;它只是标记,不检查sql-safe-str
10. 迁移加锁、记校验和多实例同时启动不会互相踩,改了旧迁移会被发现出错时锁留在连接上;改校验和规则会让所有旧迁移报错迁移、migration-modified

1. 写 SQL,不写 DSL

sqlx 的查询就是一段 SQL 字符串,库不解析它,原样交给服务端(sqlx 解决什么问题)。好处是数据库接受的语法都能用,执行的就是你写的那一句,没有生成器在中间改写。

代价落在「结构本身是动态的」查询上。可选的 WHERE 条件、不定长的 IN 列表、批量插入,SQL 文本要在运行时拼。sqlx 给的是 QueryBuilder:push 拼结构、push_bind 放值,占位符的编号由同一个 Arguments 的计数保证(Query、QueryAs 与 SqlSafeStr)。拼出来的 SQL 不过 query!,没有编译期检查。IN 列表在 Postgres 上有更好的写法:= ANY($1) 绑一个数组,一个参数、一条固定的 SQL,IN ($1) 绑 Vec 则报 operator does not exist: bigint = bigint[](in-list)。

另一个不太显眼的代价和第 4 条连在一起:语句缓存的键是 SQL 原文。多一个空格就是另一条语句(一次查询的全过程的动脑筋),push_values 每批行数不同就是不同的 SQL,各占一个缓存位置。批量插入超过 65535 个参数时,run 在发任何消息之前就报 too many arguments for query(too-many-arguments),只能分批或改成按列绑数组再 UNNEST。本站用的 sea-query 是同一个问题的另一种答案:它在 Rust 里拼 SQL,is_in 展开成 IN ($1, $2, ...),每种长度一条新语句(本站怎么用 sqlx)。

2. 编译时连库检查

query! 在编译时连上开发库,让数据库自己 Parse + Describe 这条 SQL,拿回参数和列的类型(query! 的流水线)。检查的是库里真实的表结构,不是 Rust 里另写的一份;FAQ 说在 Rust 里解析 SQL 等于「reimplementing a good chunk of the database server's frontend」。

代价有三层。

  • 编译依赖一个库。 没有 DATABASE_URL 又没有对应的 .sqlx 文件,宏直接报错(database-url-not-set)。离线模式用 cargo sqlx prepare 把 describe 结果存成 JSON,可它记的是生成那一刻的 schema:schema 变了而 SQL 没变时,离线构建照样通过,只有 prepare --check 能发现(离线模式与 sqlx.toml)。
  • 每条宏都是几个来回。 一条 LEFT JOIN 的 describe 是六个来回:prepare、查列来源、查 attnotnull、EXPLAIN(describe 与可空性推断)。连接按 URL 缓存,describe 的结果不缓存。
  • 可空性是猜的。 列来自表时看 attnotnull,外连接靠解析 EXPLAIN 的计划树找 Inner 那一侧。计划随统计信息变:空表上规划器把 LEFT JOIN 改写成 Hash Right Join,推断正好反过来,编译通过的 String 字段运行时报 unexpected null;根节点是 Limit 或 Sort 时不往下走。0.9 在宏的连接上强制 generic plan(#3541),修的是参数填 NULL 时连接被折叠掉的那一种(同上页)。推断不出时宏宁可给 Option:多一层 Option 只是麻烦,少一层是运行时错误。

3. 纯 Rust 实现协议

sqlx-postgres 没有 libpq,也没有任何 -sys crate,src/ 里一个 unsafe 块都没有(源码地图)。连接从 tokio::net::TcpStream::connect 开始,之后每一次读写都是异步的,不需要为一条连接占一个线程;SQLite 是反例,它只能调 C 库,于是每条连接一个专用线程(sqlx 解决什么问题)。整个协议也小到可以自己写一遍,迷你 Postgres 客户端约 570 行就能和 sqlx 得到相同的结果。

代价是协议的每一个细节都要自己实现、自己修,而且出错的地方往往很深:

  • message/ 有 2314 行,每种消息一个文件。回填消息长度时的一个 as i32 截断,在 0.8.1 之前是一个能让服务端把后半段字节当成新命令执行的漏洞(RUSTSEC-2024-0363);recv 分两次 .await 读头和体,在 0.8.2 之前被 select! 取消在中间就会把消息体当成消息头(#3419)。两个冷知识都在消息与读写缓冲。
  • 有些功能没有实现:BackendKeyData 存了但不用,sqlx 不发 CancelRequest,drop 掉的 fetch 流照样把整个结果集读完,下一条无关的查询替它付时间(扩展查询协议与语句缓存的实验里 183 毫秒)。maybe_upgrade 里 sslmode=allow 带着一行 FIXME: Implement ALLOW,等于 disable;prefer 和 require 都不校验证书(sslmode-require)。
  • 认证也在异步代码里:SCRAM-SHA-256 的 4096 轮 HMAC 是纯 CPU 计算,0.9 起每 5 轮 yield_now 一次才不卡住 worker(建立连接的冷知识)。

4. 默认预备语句,按连接缓存

query() 一开始就带着一个空的 Arguments,所以哪怕一个参数都不绑,也走扩展查询协议、进语句缓存;只有直接交给执行器的 &str 和 raw_sql 走简单查询(Executor 与 Acquire)。换来两件事:值永远通过 Bind 以二进制传过去,不和 SQL 拼在一起;同一条连接上再执行同一条 SQL,get_or_prepare 命中缓存,省掉 Parse + Describe 那一个来回。经池执行时调用方等待的来回从 3 个变成 2 个,直接在连接上执行从 2 个变成 1 个(一次查询的全过程的往返表)。语句跟着连接走,用户手里不需要有语句对象,这对连接池尤其重要:tokio-postgres 传字符串时每次都重新 Parse 一条新语句,要复用就得自己为每条连接保存 Statement。

📝NOTE
没有蠢问题

问: 先 Parse 再 Describe 再 Bind,第一次执行比简单查询还多一个来回;迷你客户端把 Parse / Bind / Execute / Sync 一次写出,也拿到了结果。sqlx 为什么非要单独等 Describe?

答: 因为 sqlx 要在解码之前知道每一列的类型:try_get 拿 RowDescription 里的 OID 做 compatible 检查(Row 与 FromRow),Json<T> 这样的参数还要按 ParameterDescription 报回来的类型改写已经编好的字节(Postgres 类型的补丁一节)。迷你版不 Describe,就不知道列类型,只能由调用方自己认定。这个来回只在缓存没命中时付一次,之后的执行只剩 Bind 那一组;所以默认开缓存,这一个来回就摊薄了。

代价都来自「服务端上有一条长期存在、带着计划的语句」:

  • 表结构变了,缓存的语句会坏。 select * 缓存之后给表加一列,同一条连接上这条 SQL 每次都报 cached plan must not change result type,不会自己恢复,persistent(false) 也救不回来,要 clear_cached_statements 或者等连接被回收(cached-plan)。写明列名能躲开加列,躲不开改列类型(本站经 sea-orm 复现过,见本站怎么用 sqlx)。按名字解析的自定义类型删掉重建后,连接上缓存的旧 OID 也一样失效(stale-type-oid)。
  • 服务端常驻语句。 每条连接最多 100 条命名语句,LRU 挤掉一条就单独发一次 Close,客户端和服务端一一对应(扩展查询协议与语句缓存)。想关掉缓存时别用 statement_cache_capacity(0):0.9.0 里它每次执行都在服务端留下一条没人关的命名语句(#4328,修复在 0.9.0 之后);正确的开关是 persistent(false),它用未命名语句(#3863,0.8.6 起)。
  • 连接背后换了人。 缓存假定「这条连接后面一直是同一个服务端会话」。pgbouncer 的事务模式打破了这个假定,query() 的文档和 FAQ 都建议这种场合用 persistent(false)(Query 与 SqlSafeStr)。本卷的实验环境里没有 pgbouncer,这一点没有复现。

5. 一条连接一次一个查询,并发靠池

执行器是 &mut PgConnection,同一时刻一条连接上只有一个查询在跑。run 的注释承认可以在一个 Sync 前塞几十条查询,「Some kind of builder pattern that is termed batching might suit this」,但 0.9.0 没有做(sqlx 解决什么问题)。tokio-postgres 走的是另一条路:Client 收 &self,同一条连接上两个查询的 Parse 写在同一次 flush 里。

独占的好处是状态好推理:欠了服务端几个 ReadyForQuery、缓存里有哪些语句、事务开了几层,都是这条连接自己的字段,不需要锁(扩展查询协议与语句缓存)。代价是并发全部转嫁给连接池:

  • 要并发,就要多条连接,服务端的 max_connections 是所有实例的池加起来的上限。超了,直连报 53300 sorry, too many clients already,经池只看到 PoolTimedOut(too-many-clients)。
  • 同一个任务手里占着一条连接(事务、PoolConnection、没读完的流)再拿 &pool 查询,池满时等的是自己(pool-timed-out)。
  • 借出和归还各一次 ping,借出那一次可以用 test_before_acquire(false) 关掉,归还那一次关不掉(获取连接)。

6. 关联类型,不用 trait 对象

一个数据库就是 Database 里的一张关联类型表,Pool<Postgres> 上的调用单态化成直接调 PgConnection 的代码,没有虚表,每个驱动可以有自己的行、参数和类型信息(Database 与关联类型)。

代价写在同一页的实验里:一个对任意数据库都成立的 select $1 + 1 要四行 where 约束,其中 IntoArguments 那条是因为「lack of lazy normalization」没法写成 blanket impl;Transaction 和 PoolConnection 不能是 Executor,于是有了 &mut *tx(Executor 与 Acquire的冷知识)。0.9 把 Connection 的方法改成返回 impl Future,省了每次调用的装箱,Connection 也就不再能做成 trait 对象。运行时才知道数据库时只能用 Any:AnyConnection 里是一个 Box<dyn AnyConnectionBackend>,值收窄到 AnyValueKind 的十种,select now() 连取行都失败。

7. Drop 里只能排队

Drop::drop 是同步的,不能 await,而归还连接、回滚事务都要做 I/O。sqlx 的做法是在 Drop 里只做不需要等网络的那一半:

收益是用户不用手动还连接,也不用在每条错误路径上写 rollback。代价是这些动作都推迟了,推迟就有窗口:

  • 归还在后台,current_thread 运行时上刚 drop 完 try_acquire 拿不到连接,必须让出一次归还任务才跑得动。
  • 裸 PgConnection 上 drop 掉事务,ROLLBACK 要等下一次使用才发出去;实验里 500 毫秒内服务端一直处在事务中,行锁一直不放(事务)。没提交的事务被 drop 不报任何错(forgot-commit)。
  • 排队的东西没有计入状态时,连接就错位:PgCopyIn drop 之后,每个查询拿到上一个查询的结果(copy-in-dropped)。
  • 取消落在半路时,守卫看到的状态和服务端不一致:begin() 在 BEGIN 来回途中被取消,深度还是 0,两层守卫什么都不排,连接带着服务端事务回到池里(cancelled-begin);0.9.0 里在事务内调 begin_with 会把外层事务回滚掉(begin-with-in-transaction)。

归还时那次无条件的 ping 就是为这些窗口兜底的:它把排队的 ROLLBACK 冲出去、把没读完的消息读掉,读不完就关掉连接。return_to_pool 的注释管它叫「a band-aid」,原文是「this is simply a band-aid as SQLx-next connections should be able to recover from cancellations」。

8. 连接错误重试到超时

PoolInner::connect 把 ConnectionRefused 当作「the system starting up」,把 53300、57P03 当作暂时错误(is_transient_in_connect_phase),从 10 毫秒开始指数退避,一直重试到 acquire_timeout(获取连接)。收益是应用和数据库一起启动时,数据库晚起来几秒,应用不会立刻失败。

代价是原因被丢掉。实验里关着的端口重试满 1 秒、57P03 的假服务端被连了 9 次,最后报的都是 pool timed out while waiting for an open connection,和「池被自己占满了」是同一句话;只有认证失败这类不会自愈的错误原样报出来。排查时只能靠 pool.size() 和 max_connections 的关系、服务端的 pg_stat_activity 去分辨(pool-timed-out、too-many-clients)。

9. SqlSafeStr:一道减速带

0.9 的 query() 系列只收 SqlSafeStr:&'static str 直接通过,format! 出来的 String 报一段专门的 E0277,要包 AssertSqlSafe 才行(Query、QueryAs 与 SqlSafeStr)。文档说这对组合「intentionally analogous to std::panic::UnwindSafe and std::panic::AssertUnwindSafe」:把「拿动态字符串当 SQL」这件事写成一个代码审查时能搜到的词。0.8 的拦截只靠「要多写一个 &」。同一组改动去掉了 Query 对 SQL 文本的借用,Query<'static, ..> 可以从函数里返回。

代价和局限:

  • 所有合法的动态 SQL 都要包一层,sqlx 自己生成保存点语句时也写 AssertSqlSafe(format!(..));本站要升级的 sea-orm 2 也是在每条 sea-query 生成的 SQL 外面包一层(升级到 0.9)。
  • AssertSqlSafe 不转义也不检查,包住 format! 的注入照样有效(sql-safe-str)。
  • 防线有缺口:QueryBuilder::new 和 push 收的是 impl Into<String> / impl Display,最后在内部包 AssertSqlSafe 交出去;copy_in_raw 的 statement 是普通的 &str(LISTEN/NOTIFY 与 COPY)。

10. 迁移:advisory lock 和校验和

Migrator::run 先拿一把由库名算出来的 advisory lock,再比对每个已应用迁移的 SHA-384 校验和,每个迁移和它的记账行在同一个事务里(迁移)。换来的是:几个实例同时启动时一个等另一个跑完;改了已经上线的迁移会被发现,报 migration 1 was previously applied but has been modified(migration-modified)。

代价:

  • 锁是会话级的,run_direct 中途出错直接 ? 返回,走不到 unlock,池里那条连接带着锁回去,别的实例的迁移一直等到它被关掉。键只由库名决定,同一个库上所有 Migrator 抢同一把锁,table-name 改不了这一点。
  • 校验和是整个文件的字节,CRLF、空白都算;ignored-chars 能放宽,但对已部署的库打开它,所有已应用的迁移都会报 VersionMismatch。改了旧迁移之后,排在它后面的新迁移一个也不跑。
  • -- no-transaction 必须是文件的字面开头,脚本作为一条 Query 发出,Postgres 仍把多条语句放进隐式事务,CREATE INDEX CONCURRENTLY 一个迁移只能放一条。

本站用的 sea-orm-migration 1.1.20 做了相反的选择:不加锁、不存校验和,所有待执行的迁移包在一个事务里。实测两个实例同时迁移一个空库,其中一个因为撞上系统表的唯一索引而失败(本站怎么用 sqlx)。

小结

  • 写 SQL、编译时连库是 sqlx 的定位:执行的就是你写的那一句,检查对的是真实 schema;代价是动态 SQL 没有编译期检查、编译依赖一个库、可空性靠 EXPLAIN 猜。
  • 纯 Rust 协议换来全程异步和零 unsafe,代价是长度截断、取消安全这类深层 bug 要自己修,CancelRequest、sslmode=allow、证书校验这些功能缺着。
  • 默认预备语句加按连接 LRU 缓存,省一个来回、值永远是参数;代价是 cached plan、服务端常驻语句,和在连接背后换会话的代理不兼容。关缓存用 persistent(false),别用容量 0。
  • 一条连接一次一个查询让状态好推理,并发和它的全部问题都交给池;关联类型零开销,代价是长约束和功能有限的 Any。
  • Drop 不能 await,归还、回滚、解锁都只能排队,取消落在半路就留下错位的状态,归还时的 ping 是兜底。
  • 连接池把连不上当作暂时故障,换来启动韧性,丢掉了原因;SqlSafeStr 是能被搜到的标记,不是防线;迁移的锁和校验和保护多实例和已上线的迁移,出错时锁留在连接上。

💬 关联讨论

发起讨论
加载中...