设计取舍
主线位置:三条主线的汇总。查询主线上的取舍是「写 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. SqlSafeStr | format! 进 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。
问: 先 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 里只做不需要等网络的那一半:
PoolConnection的Drop只spawn一个归还任务,任务里查寿命、跑after_release、ping、先入队后放许可(归还、Drop 与维护任务)。Transaction没提交就被 drop,start_rollback只把ROLLBACK写进缓冲、把深度减一(事务)。PgAdvisoryLockGuard的Drop排一条pg_advisory_unlock,PgCopyIn的Drop排一条CopyFail(LISTEN/NOTIFY 与 COPY)。
收益是用户不用手动还连接,也不用在每条错误路径上写 rollback。代价是这些动作都推迟了,推迟就有窗口:
- 归还在后台,
current_thread运行时上刚 drop 完try_acquire拿不到连接,必须让出一次归还任务才跑得动。 - 裸
PgConnection上 drop 掉事务,ROLLBACK要等下一次使用才发出去;实验里 500 毫秒内服务端一直处在事务中,行锁一直不放(事务)。没提交的事务被 drop 不报任何错(forgot-commit)。 - 排队的东西没有计入状态时,连接就错位:
PgCopyIndrop 之后,每个查询拿到上一个查询的结果(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是能被搜到的标记,不是防线;迁移的锁和校验和保护多实例和已上线的迁移,出错时锁留在连接上。