sqlx 解决什么问题
主线位置:三条主线的由来。本页说明 sqlx 为什么同时有查询主线(自己实现协议、默认走预备语句和缓存)、连接池主线(池在库里)和编译期检查主线(宏连库),和几种常见的选择比,它把哪些事情揽到了自己身上。
flowchart TB need["Rust 里访问 Postgres"] --> drv["只要驱动"] need --> sql["写 SQL 加编译期检查"] need --> dsl["用 Rust 表达查询"] drv --> tp["tokio-postgres"] sql --> sqlx["sqlx"] dsl --> diesel["diesel 的 DSL"] dsl --> orm["sea-orm"] orm -->|"底层用"| sqlx
sqlx 怎么描述自己
README 的第一句是:
SQLx is an async, pure Rust<sub>†</sub> SQL crate featuring compile-time checked queries without a DSL.
(README.md)。后面列了五个要点:Truly Asynchronous、Compile-time checked queries(「if you want」)、Database Agnostic、Pure Rust、Runtime Agnostic。再往下是一张功能清单,和本卷有关的几条:
- 「Built-in connection pooling with
sqlx::Pool.」 - 「Row streaming. Data is read asynchronously from the database and decoded on demand.」
- 「Automatic statement preparation and caching. When using the high-level query API (
sqlx::query), statements are prepared and cached per connection.」 - 「Simple (unprepared) query execution including fetching results into the same
Rowtypes used by the high-level API.」
README 里专门有一节 SQLx is not an ORM!,说编译期检查「does not, however, do this by providing a Rust API or DSL (domain-specific language) for building queries. Instead, it provides macros that take regular SQL as input and ensure that it is valid for your database」,办法是「SQLx connects to your development DB at compile time to have the database itself verify (and return some info on) your SQL queries」。它自己列出两个后果:sqlx 不解析 SQL,所以开发库接受的任何语法都能用(包括扩展加的语法);而能检查到什么程度,取决于数据库在 prepare 时肯透露多少信息。
把这几句拆开,sqlx 一共揽下了四件事:
| 事情 | 在哪条主线上 | 讲它的部分 |
|---|---|---|
| 自己实现数据库的线协议,异步收发 | 查询主线 | 03 Postgres 驱动 |
| 默认把每条查询变成预备语句,按连接缓存 | 查询主线 | 一次查询的全过程 |
| 连接池 | 连接池主线 | 04 连接池 |
| 编译时连库检查 SQL,生成类型 | 编译期检查主线 | 06 编译期宏 |
和 tokio-postgres 比:只有驱动
tokio-postgres 是 Rust 里另一个纯 Rust 的异步 Postgres 驱动。本节对照的是本地 registry 里的 tokio-postgres 0.7.18 源码,只比设计,不比快慢。
连接的形状不同。 tokio_postgres::connect 返回一对 (Client, Connection)。crate 文档的例子说「The connection object performs the actual communication with the database, so spawn it off to run on its own」:Connection 是一个要用户自己 spawn 的 future,负责读写 socket;Client 的查询方法收 &self,把请求通过通道发给它。sqlx 的 PgConnection 自己就是 socket 的持有者,执行器是 &mut PgConnection,一条连接同一时刻只能有一个查询在进行;想并发就从池里借多条连接。
有没有自动缓存。 tokio-postgres 的查询方法收 impl ToStatement,它对 str 的实现(src/to_statement.rs)是:
ToStatementType::Query(s) => prepare::prepare(client, s, &[]).await,
每次传 SQL 字符串都现场 prepare 一条新的命名语句(src/prepare.rs 里 format!("s{}", NEXT_ID.fetch_add(1, Ordering::SeqCst))),用完 Statement 被 drop 时发 Close。想复用,就要自己 client.prepare(sql) 拿到 Statement 并保存。sqlx 在每条连接上按 SQL 原文缓存,用户手里不需要有任何语句对象,这对连接池尤其要紧:同一条 SQL 可能落在池里任何一条连接上,语句必须跟着连接走。
有没有池和宏。 tokio-postgres 的源码里没有连接池(grep -ril pool src/ 没有结果),也没有编译期检查的宏;池要用别的 crate。sqlx 把池放在 sqlx-core 里,和 Executor、事务、迁移一起设计。
实验:同一条 SQL 用 tokio-postgres 跑三次
用一次查询的全过程里的同一个 TCP 代理,同一张表,只是换成 tokio-postgres:
let conf = format!(
"host=127.0.0.1 port={} user=lab password=lab dbname=lab options='-c search_path=intro'",
p.port
);
let (client, connection) = tokio_postgres::connect(&conf, tokio_postgres::NoTls).await?;
// the Connection future does the actual socket I/O; the user must spawn it
tokio::spawn(connection);
p.quiet.store(false, Ordering::SeqCst);
for round in ["第一次", "第二次"] {
println!("--- {round} client.query_one(&str) ---");
let row = client.query_one(SQL, &[&1_i64]).await?;
let (id, title): (i64, String) = (row.get(0), row.get(1));
println!("结果 {:?}", (id, title));
settle().await;
}
println!("--- 同一个 client 上并发两条 ---");
let (a, b) = tokio::join!(client.query_one(SQL, &[&1_i64]), client.query_one(SQL, &[&2_i64]));
println!("结果 {:?} {:?}", a?.get::<_, String>(1), b?.get::<_, String>(1));
settle().await;
SQL 还是 select id, title from posts where id = $1,依赖是 tokio-postgres = "0.7"(锁到 0.7.18)。M2 Max、macOS、rustc 1.99.0、Postgres 16.14 上的输出:
--- 第一次 client.query_one(&str) ---
[conn 1] C->S Parse(s0, "select id, title from posts where id = $1"), Describe(S, s0), Sync
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt s0), Execute, Sync
[conn 1] S->C BindComplete, DataRow(列长 8/10), CommandComplete("SELECT 1"), ReadyForQuery(I)
结果 (1, "hello sqlx")
--- 第二次 client.query_one(&str) ---
[conn 1] C->S Close(S, s0), Sync, Parse(s1, "select id, title from posts where id = $1"), Describe(S, s1), Sync
[conn 1] S->C CloseComplete, ReadyForQuery(I)
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt s1), Execute, Sync
[conn 1] S->C BindComplete, DataRow(列长 8/10), CommandComplete("SELECT 1"), ReadyForQuery(I)
结果 (1, "hello sqlx")
--- 同一个 client 上并发两条 ---
[conn 1] C->S Close(S, s1), Sync, Parse(s2, "select id, title from posts where id = $1"), Describe(S, s2), Sync, Parse(s3, "select id, title from posts where id = $1"), Describe(S, s3), Sync
[conn 1] S->C CloseComplete, ReadyForQuery(I)
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 1] S->C ParseComplete, ParameterDescription, RowDescription, ReadyForQuery(I)
[conn 1] C->S Bind(portal unnamed, stmt s2), Execute, Sync
[conn 1] C->S Bind(portal unnamed, stmt s3), Execute, Sync
[conn 1] S->C BindComplete, DataRow(列长 8/10), CommandComplete("SELECT 1"), ReadyForQuery(I)
[conn 1] S->C BindComplete, DataRow(列长 8/20), CommandComplete("SELECT 1"), ReadyForQuery(I)
结果 "hello sqlx" "hello tokio-postgres"
[conn 1] C->S Close(S, s3), Sync, Close(S, s2), Sync
[conn 1] S->C CloseComplete, ReadyForQuery(I)
[conn 1] S->C CloseComplete, ReadyForQuery(I)
和 sqlx 的抓包对照:
sqlx(query_as + bind,经池) | tokio-postgres(query_one(&str)) | |
|---|---|---|
| 第二次执行 | 不发 Parse,Bind 直接引用 sqlx_s_1 | 先 Close 掉上一次的 s0,再 Parse 一条新的 s1 |
| 一次执行的消息 | Bind Execute Close(portal) Sync | Bind Execute Sync |
| 语句什么时候关 | 留在连接上,LRU 挤出时才 Close | Statement drop 后,Close 跟着下一次请求一起发出 |
| 同一连接上并发两条 | 做不到,&mut PgConnection 独占;要两条连接 | 两条请求的 Parse 写在同一次 flush 里,流水线化 |
| 连接前后的 ping | 池借出和归还时各一次 Sync | 没有池,没有 ping |
两种设计各有道理。tokio-postgres 的 crate 文档第一句就是「An asynchronous, pipelined, PostgreSQL client」,把一条连接上的流水线当作卖点。sqlx 在 run 的注释里提到过同样的可能:「Theoretically, we could send dozens of queries before a [Sync] and postgres can handle that. ... Some kind of builder pattern that is termed batching might suit this.」但 0.9.0 没有做,一条连接一次只跑一个查询,并发靠池。
问: tokio-postgres 也能先 client.prepare(sql) 拿到 Statement,之后反复用,这样不也不重复 Parse 了吗?sqlx 的缓存算什么区别?
答: 区别在「谁来保管语句」。Statement 属于创建它的那条连接,tokio-postgres 让用户自己保管,这在一条长连接上很好做;一旦有了池,用户拿到的连接每次可能不同,就得为池里每条连接各存一份语句,还要处理连接被替换的情况。sqlx 把这件事做进了连接内部:键是 SQL 原文,值存在 PgConnection 自己的 StatementCache 里,连接被关掉,缓存跟着消失。代价是用户看不到也管不了这些语句,SQL 原文差一个空格就是另一条(见一次查询的全过程的动脑筋),而且像 pgbouncer 事务模式这样「连接背后其实换了人」的场景会出问题,FAQ 为此建议 .persistent(false)。
和 diesel、sea-orm 比:谁来写 SQL
本卷没有读 diesel 的源码(它也不在本机的 registry 里),这里只说公认的那一点:diesel 用 Rust 的类型和方法链来表达查询,表结构以 Rust 代码的形式(table! 宏生成的 schema)放在项目里,SQL 由 diesel 生成。sqlx 的 README 没有点名比较任何 ORM 或 DSL,只是反复强调自己「without a DSL」、「SQLx is not an ORM」,并把找 ORM 的读者引到生态 wiki(「If you are looking for an (asynchronous) ORM, you can check out our new Ecosystem wiki page!」),这一节末尾还留着 ormx 和 SeaORM 两个链接定义。
两种做法的分界线是「真相在哪里」:
- DSL 一侧,查询的结构在 Rust 代码里,编译器检查的是 Rust 代码和 Rust 里那份 schema 是否一致;SQL 的写法受 DSL 能表达的范围限制。
- sqlx 一侧,查询就是 SQL 字符串,
query!检查的是 SQL 和开发数据库里真实的 schema 是否一致。FAQ 的「Why can't SQLx just look at my database schema/migrations and parse the SQL itself?」一节解释了为什么不在 Rust 里解析 SQL:那需要为每种数据库重写解析器和类型检查,「This is effectively reimplementing a good chunk of the database server's frontend」。代价是编译依赖一个数据库,或者一份要保持同步的.sqlx缓存,见离线模式与 sqlx.toml。
sea-orm 在另一层:它是建在 sqlx 上的 ORM。本站用的 sea-orm 1.1.20,Cargo.toml 里可选依赖 sqlx = "0.8.4",Postgres 连接器 src/driver/sqlx_postgres.rs 里拿的就是 sqlx::PgPool。也就是说,本站的每一条查询最后都走本卷的查询主线和连接池主线,只是 SQL 由 sea-orm(经 sea-query)生成、经 sqlx::query_with 一类的接口执行。本站具体怎么用,见本站怎么用 sqlx。
这几者的取舍汇总在设计取舍。
纯 Rust 实现协议
README 的「Pure Rust」一条写的是「The Postgres and MySQL/MariaDB drivers are written in pure Rust using zero unsafe code」(去掉了原文的加粗和脚注标记),脚注是「SQLx uses #![forbid(unsafe_code)] unless the sqlite feature is enabled.」源码里这条属性写在 sqlx-core/src/lib.rs;sqlx-postgres 的 lib.rs 没有写它,但 src/ 下也没有一个 unsafe 块。sqlx-postgres 的依赖里没有 libpq,也没有任何 -sys crate(见源码地图):启动消息、SCRAM 认证、每一种前端和后端消息的编解码都在 sqlx-postgres/src/message/ 和 connection/ 里用 Rust 写成。
这样做的直接结果是异步一路到底。打开连接走的是 connect_tcp,在 tokio 下就是 tokio::net::TcpStream::connect;之后的每一次读写都是对这个 socket 的异步读写,等网络时让出线程,不需要为每条连接占一个线程。读写缓冲和消息帧的实现见协议消息,socket 在 tokio 里怎么被唤醒,见 tokio 卷的反应器。代价是协议要自己维护:sqlx-postgres/src/message/ 有 2 314 行,每种消息一个文件。
SQLite 为什么要用线程
同一个 README 的 † 脚注解释了 SQLite 为什么是例外:
† The SQLite driver uses the libsqlite3 C library as SQLite is an embedded database (the only way
we could be pure Rust for SQLite is by porting _all_ of SQLite to Rust).
SQLite 没有网络协议,数据库就是本进程里调用的一个 C 库,sqlite3_step 这类调用会同步地读写文件、等文件锁,没有一个 socket 可以交给异步运行时去等。sqlx-sqlite 的做法写在 sqlx-sqlite/src/connection/worker.rs 开头的注释里:「Each SQLite connection has a dedicated thread.」每条连接用 std::thread::Builder 起一个专用的操作系统线程(不是 tokio 的阻塞线程池),异步一侧通过 flume 通道把命令发给它、再异步地等结果。注释接着说可以改成每个池一组线程,但「Low priority because a high concurrent load for SQLite3 is very unlikely.」
Postgres 和 SQLite 因此是同一组 trait 下的两种实现:前者在异步 socket 上直接说协议,后者把阻塞调用隔离到专用线程里。本卷只讲前者。
小结
- sqlx 的定位:写原生 SQL,在编译期让真实的开发数据库检查它;同时自带纯 Rust 的协议实现、按连接的预备语句缓存和连接池。
- 和 tokio-postgres 比:tokio-postgres 只有驱动,
Client/Connection分开、一条连接上可以流水线,传字符串每次都重新Parse;sqlx 一条连接一次一个查询,靠池并发,靠缓存省掉重复的Parse。 - 和 diesel 比:diesel 用 Rust DSL 表达查询、schema 在 Rust 里;sqlx 查询就是 SQL,检查对的是数据库里的真实 schema,代价是编译要连库或用
.sqlx缓存。 - sea-orm 建在 sqlx 上,本站的查询最终都走 sqlx 0.8.6 的查询主线和连接池主线。
- 纯 Rust 协议让 Postgres 驱动全程异步、没有
unsafe;SQLite 只能调 C 库,所以每条连接一个专用线程。