SQLx 源码深度解析

第22章 生产实战:连接池调优、PREPARE cache、N+1 诊断

作者 杨艺韬 · 11,368 字 · 发布于 · 更新于

连接池的默认值不是随便定的:max_connections = 10、acquire_timeout = 30s。生产调优的第一步不是把它们调大,而是先算清楚数据库那端的 max_connections 要分给多少个应用实例。

本章要点

  • PoolOptions::new 的默认值 不是随便定的——max_connections = 10(sqlx-core/src/pool/options.rs:151)、min_connections = 0(:152)、acquire_timeout = 30s(:160)、idle_timeout = Some(10min)(:161)、max_lifetime = Some(30min)(:162)、fair = true(:163)——每个数字背后都有取舍,但它们面向的是测试和轻负载场景,生产要按需调整。
  • max_connections 调优原则——DB 端 max_connections 除以应用实例数——再留 10-20% 余地给管理连接/备份/监控——大多数团队设得太高(“越大越好”是反模式)。
  • min_connections 两种场景——0(默认、按需起)和 = max_connections(预热、首次请求不等待)——极端之间很少用中间值。
  • acquire_timeout 是最常见报错源——Error::PoolTimedOut(sqlx-core/src/error.rs:104)——30s 的默认值生产环境可以调到 5-10s、让调用方早失败好过长挂。
  • acquire_slow_threshold 默认 2 秒(:159)——池工作正常时 acquire 应该在 ms 级别、>2s 基本意味着连接耗尽——这是早期预警指标。
  • PREPARE 缓存是 LRU(sqlx-core/src/common/statement_cache.rs:7)—— hashlink::LruCache<String, T>——Postgres 默认容量 100(sqlx-postgres/src/options/mod.rs:88)——超过 100 个不同 SQL 模板会开始被换出、频繁换出 = 严重性能损失。
  • persistent = true 是 PREPARE 缓存的开关(sqlx-postgres/src/connection/executor.rs:183)——query!() 宏默认 persistent、query("...") 默认 persistent—— 手工设成 false 会让 PREPARE 永远不进缓存。
  • pool.size() 和 pool.num_idle()(sqlx-core/src/pool/mod.rs:535-541)——运行时观测池状态的两个 API——exported 成 Prometheus metric 是生产标配。
  • N+1 诊断四步——开 sqlx::query=debug 日志 → 按 db.statement 聚合 → 发现同一模板重复 N 次 → 改用 JOIN 或 = ANY($1)(传数组)批量——这是最常见的生产性能病。
  • fair = true(sqlx-core/src/pool/options.rs:163)让 pool 采用公平队列——先到先得、不会让某个 task 长期饿死。注意它的 setter 是 __fair()、带 #[doc(hidden)]、文档写明 "Currently only exposed for benchmarking"——不是给用户调的;而且在 tokio runtime 下 AsyncSemaphore 直接用 tokio::sync::Semaphore(sqlx-core/src/sync.rs:36 有 debug_assert!(fair)),这个开关根本不生效。

22.1 生产环境和开发环境的五大区别

先框定生产环境的含义——不是"能跑起来"的标准、是"扛住真实流量 + 出错能恢复 + 故障能定位"的标准。

开发和生产的五个差异:

  1. 并发量——开发时往往只有零星请求、生产流量要高出几个数量级——池必须支持高并发 acquire、不能有锁瓶颈。
  2. 长尾延迟——开发只看 p50 好不好、生产看 p99 / p999——偶发慢查询会被放大成可见告警。
  3. 连接抖动——开发 localhost 永不丢连接、生产 TCP 断链/DB failover/network partition 都会发生——池必须自愈。
  4. 错误分布——开发时碰到的多是 SQL 写错(开发者自己调试)、生产上更要防的是资源错(池满、DB 超载、网络慢)——错误处理策略完全不同。
  5. 部署拓扑——开发单实例、生产 N 实例——DB max_connections 是全局资源、每实例必须节制。

PoolOptions 的默认值并不是直接给生产用的——源码文档自己说"The default configuration is mainly suited for testing and light-duty applications",生产至少要调几项(sqlx-core/src/pool/mod.rs:313-314)。本章先从这些默认值入手、逐个拆解。

22.2 PoolOptions 的默认值与取舍

源码(sqlx-core/src/pool/options.rs:143-166):

pub fn new() -> Self {
    Self {
        after_connect: None,
        before_acquire: None,
        after_release: None,
        test_before_acquire: true,
        max_connections: 10,
        min_connections: 0,
        acquire_time_level: LevelFilter::Off,
        acquire_slow_level: LevelFilter::Warn,
        acquire_slow_threshold: Duration::from_secs(2),
        acquire_timeout: Duration::from_secs(30),
        idle_timeout: Some(Duration::from_secs(10 * 60)),
        max_lifetime: Some(Duration::from_secs(30 * 60)),
        fair: true,
        parent_pool: None,
    }
}

每个字段的默认值含义:

字段 默认值 含义
test_before_acquire true 每次从 idle 队列取连接时 conn.ping() 一次(sqlx-core/src/pool/inner.rs:469-477),失败就 close_hard() 丢弃并换一条
max_connections 10 适合小规模测试——生产必须调
min_connections 0 按需建连——connect() 建池时只建 1 条验证连通,之后不够再建
acquire_time_level Off 默认不记录每次 acquire 的耗时日志
acquire_slow_level Warn 慢 acquire 默认记 Warn——生产可见
acquire_slow_threshold 2s 超过 2s 算慢——实际健康池应该 ms 级
acquire_timeout 30s 等不到连接 30s 后抛 PoolTimedOut——实际生产可以调小
idle_timeout 10 min 10 分钟闲置就关掉——避免长期浪费连接
max_lifetime 30 min 连接满 30 分钟后在归还或后台扫描时退休——定期换新、防状态累积
fair true 公平队列——waiters 先到先得

注释里的关键线索(:157-159):

// Fast enough to catch problems (e.g. a full pool); slow enough
// to not flag typical time to add a new connection to a pool.
acquire_slow_threshold: Duration::from_secs(2),

2 秒这个阈值的取舍写在源码注释里——既快到能发现池满、又慢到不会把"往池里新建一条连接"的正常耗时误判为慢。

每个默认值都可以讲成半小时的故事——本章后面几节展开。

22.3 max_connections:大多数团队设得太高

默认 10——对小服务合适(如 CLI、单实例 web demo)——但生产几乎必调。

错误直觉——"我服务 QPS 高、max 设得越大越好"。

实际规则——max_connections 应该等于或略小于 "DB 允许的最大连接数 ÷ 应用实例数":

DB.max_connections = 200   (示例值)
app replicas = 10
per-app max_connections = 200 / 10 - 2 (留给管理/备份) ≈ 18

为什么不能无脑设大:

  • DB 端也有 max_connections 限制——自建 Postgres 默认 100,云托管实例多按内存规格推算默认值——以实例上 SHOW max_connections 的结果为准。
  • 每个连接占 DB 内存——Postgres 是 process-per-connection 模型,每条连接一个后端进程 + 私有的 work_mem——具体数字看你的 work_mem / temp_buffers 配置,查 pg_settings 自己算。
  • 连接多不等于吞吐高——DB CPU 饱和后、更多连接只会让每条 query 更慢——Little's Law 的实际体现。
  • 应用端每连接也占内存——sqlx 每条连接有独立的读写 buffer + statement cache——连接数上去后客户端内存也不容忽视。

一个常被引用的经验公式——PostgreSQL Wiki 的 Number Of Database Connections(HikariCP 的 "About Pool Sizing" 文档也引用它)给出:

connections = ((core_count * 2) + effective_spindle_count)

也就是说总活跃连接大致是 DB 主机核数的两倍加上有效磁盘轴数——比大多数人的直觉小得多。把这个总数除以应用实例数,再留出给备份 / 监控 / 管理连接的余量,就是单实例的 max_connections。

这个方向和直觉相反——少而高效 > 多而拥挤。具体数字请在自己的负载上压测验证,不要照抄。

flowchart LR
    A[业务请求] --> B{池有空闲?}
    B -->|是| C[直接拿]
    B -->|否| D{池未满?}
    D -->|是| E[新建连接]
    D -->|否| F[排队等 acquire_timeout]
    F --> G{超时?}
    G -->|是| H[PoolTimedOut]
    G -->|否| C
    C --> I[执行 query]
    I --> J[归还到池]

22.4 min_connections:0 还是满池

默认 0——按需创建——connect() 建池时只建 1 条验证连通,并发上来、空闲连接不够时才现建——代价是这些请求要多等一次建连(TCP + TLS + 认证)。

两种典型选择:

  • min = 0(默认):适合流量有明显低谷的服务——夜间没流量时池自然收缩到 0、省 DB 连接。
  • min = max(预热):适合低延迟要求的服务——启动时就把池填满、第一个请求就能拿到现成连接。

为什么中间值(比如 min=3, max=10)用得少?

因为 min_connections 的语义——sqlx 会尽力维持至少 min 个连接:idle_timeout / max_lifetime 照样回收它们,但后台维护任务每关掉一条、低于 min 就立刻补一条(sqlx-core/src/pool/inner.rs:544-550)。所以 min=3 意味着"池里始终有约 3 条连接"——在不均匀流量下意义不大:

  • 流量高峰时这 3 个也不够用、池会扩到 max。
  • 流量低谷时这 3 条连接闲着、被回收了又补回来——长期占 DB 资源。

结论——大多数场景是 min = 0 或 min = max—— 二选一、明确优化目标。

min_connections 的语义(sqlx-core/src/pool/options.rs:183-194 的文档注释):

When the pool is built, this many connections will be automatically spun up.

If any connection is reaped by max_lifetime or idle_timeout, or explicitly closed, and it brings the connection count below this amount, a new connection will be opened to replace it.

This is only done on a best-effort basis, however. The routine that maintains this value has a deadline so it doesn't wait forever if the database is being slow or returning errors.

This value is clamped internally to not exceed max_connections.

三条关键信息:建池时就会拉起这么多连接(所以 min = max 才等于"预热";这只对 connect / connect_with 成立——connect_with 返回前直接调 try_min_connections(sqlx-core/src/pool/options.rs:546-550),而 connect_lazy* 在两个超时都开着(默认)时不预建,维护任务只在回收过期连接之后才补齐,见 §14.10)、掉下去会补、只是 best-effort——DB 慢/挂时不会阻塞正常业务等待 maintainer——体现韧性优先哲学。另外 min_connections 内部会被 clamp 到不超过 max_connections,文档还解释了为什么不 assert(宁可 clamp 也不 panic)。

22.5 acquire_timeout:最常见的生产报错

默认 30 秒——等不到连接 30s 后抛 Error::PoolTimedOut。

30s 是保守默认——适合"慢服务也给它机会"——但多数生产应该调小。

原因——早失败好过长挂:

  • HTTP 接口一般有 10-15s 上层 timeout——等不到连接拿去跑 DB、拿到也超时失败——不如在 acquire 环节就早失败。
  • 用户等 30s 拿到数据 vs 5s 收到"DB 繁忙、请重试"——后者体验更好。
  • 长挂放大故障——下游 1 个慢连接、上游 N 个 request 都卡在 acquire——任务和请求堆积。

推荐配置:

let pool = PgPoolOptions::new()
    .max_connections(20)
    .min_connections(0)
    .acquire_timeout(Duration::from_secs(5))   // HTTP 服务
    .idle_timeout(Duration::from_secs(300))    // 5min
    .max_lifetime(Duration::from_secs(1800))   // 30min
    .connect(&database_url).await?;

这套是 Web API 的典型配置——5s 超时匹配 HTTP 层 10s 上限、留 5s 给 query 执行。

批处理任务不同——可以用 30s 甚至 60s—— batch job 不怕等、怕失败。

PoolTimedOut 的处理:

match pool.acquire().await {
    Ok(conn) => { /* ... */ }
    Err(sqlx::Error::PoolTimedOut) => {
        metrics::counter!("pool_timeout_total").increment(1);
        return StatusCode::SERVICE_UNAVAILABLE;
    }
    Err(e) => return internal_error(e),
}

Metric 必加——计数 pool_timeout_total—— 告警阈值 "> 1 次/分钟"—— 池问题早发现。

源码触发点(sqlx-core/src/pool/inner.rs:293):

.await
.map_err(|_| Error::PoolTimedOut)??;

crate::rt::timeout(...) 包裹整个 acquire loop——超过 acquire_timeout 就 map 成 PoolTimedOut;建连阶段(connect,:321-393)用的是同一个 deadline,到点也返回 PoolTimedOut(:384)。注意建连时只有"连接被拒"、Postgres 的 53300 / 57P03 两个错误码、以及 after_connect 回调返回错误这几种情况会在 deadline 内退避重试(:361-377),其余错误(认证失败、TLS 错误等)直接原样返回、不会等满 acquire_timeout。

22.6 idle_timeout 和 max_lifetime 的协作

两个 timeout 目的不同:

  • idle_timeout(默认 10 min)——一条连接空闲超过 10 分钟就关——节省资源、收缩池。
  • max_lifetime(默认 30 min)——一条连接不管用得多频繁、创建满 30 分钟后就退休——在它被归还时(sqlx-core/src/pool/connection.rs:282-287)或后台扫描空闲队列时关掉,正在使用中的连接不会被中途打断——防止单条连接长期持有的资源累积(内存碎片、DB 端 prepared statement 累计、server-side session state)。

为什么需要 max_lifetime 而不只是 idle_timeout?

想象一个总在被使用的连接——idle_timeout 永远不触发——它可能被用了几小时、几天——问题:

  • Postgres server 端 session state——prepared statements、temp tables、session variables——长期累积占 DB 内存。
  • TCP 连接 bitrot——长连接偶尔网络设备重启/NAT 表过期——连接可能进入"半挂"状态(能发数据但服务器收不到)。
  • 客户端库 bug——驱动若有让连接状态错乱的 bug,定期回收能限制它的影响范围——是一种防御。

30 min 是业界比较通用的值——短到能躲过大多数 bitrot、长到不会频繁重连增开销。

组合效果:

场景 idle_timeout 触发 max_lifetime 触发
连接闲置 15 min ✓ 关掉 (还没到 30 min)
连接持续忙 25 min — (还没到 30 min)
连接持续忙 35 min — ✓ 下次归还时关
连接闲置 5 min 后忙 10 min — —

两个 timer 叠加——超龄连接在下一次归还或后台扫描时退休、闲置过久的连接在后台扫描时回收(扫描周期是两者中较短的那个,所以实际关闭时刻会比阈值晚一点)——池动态但有边界。

调优建议:

  • Web 服务:idle_timeout = 300s、max_lifetime = 1800s——较短、保持池健康。
  • 后台 batch:idle_timeout = 600s、max_lifetime = 3600s——连接利用率高、不用频繁重连。

22.7 acquire_slow 观测链

第 21 章讲过 QueryLogger 的 slow query 机制——池层有独立的慢 acquire 机制(sqlx-core/src/pool/inner.rs:295-316):

let acquired_after = acquire_started_at.elapsed();

let acquire_slow_level = self
    .acquire_slow_level
    .filter(|_| acquired_after > self.options.acquire_slow_threshold);

if let Some(level) = acquire_slow_level {
    private_tracing_dynamic_event!(
        target: "sqlx::pool::acquire",
        level,
        aquired_after_secs = acquired_after.as_secs_f64(),
        slow_acquire_threshold_secs = self.options.acquire_slow_threshold.as_secs_f64(),
        "acquired connection, but time to acquire exceeded slow threshold"
    );
} else if let Some(level) = self.acquire_time_level {
    private_tracing_dynamic_event!(
        target: "sqlx::pool::acquire",
        level,
        aquired_after_secs = acquired_after.as_secs_f64(),
        "acquired connection"
    );
}

target = sqlx::pool::acquire——和 sqlx::query 并列的第二个 tracing target。

事件含义:

  • 正常 acquire(<2s)——如果 acquire_time_level 开了(默认 Off)——发一个普通事件。
  • 慢 acquire(>2s)——默认 Warn 级别——生产可见——帮你早发现池耗尽。

生产配置:

use log::LevelFilter;   // 这里的 LevelFilter 是 log crate 的,不是 tracing 的

PgPoolOptions::new()
    .acquire_time_level(LevelFilter::Off)          // 正常 acquire 不记、量太大
    .acquire_slow_level(LevelFilter::Warn)         // 慢 acquire 记 Warn(默认)
    .acquire_slow_threshold(Duration::from_millis(500))  // 500ms 就报慢
    // ...

注意事件字段名:aquired_after_secs(有 typo——"acquired" 少了一个 'c')—— 0.8.6 里就是这个拼写。生产 dashboard 按源码里的拼法写,否则查不到数据。

慢 acquire 的含义:

  • 池已满、在等别的 task 归还——扩 max_connections 或缩短 query。
  • DB 建新连接慢(TCP + TLS + auth)——调大 min_connections 预热、或检查 DB 负载。
  • test_before_acquire = true(默认)在每次从空闲队列取连接时 ping 一次——DB 响应慢时这一步也会被算进 acquired_after。

22.8 spawn_maintenance_tasks:池的后台清洁工

建池时(PoolInner::new_arc 末尾,sqlx-core/src/pool/inner.rs:70;connect* 和 connect_lazy* 都走这里)会启动后台任务——spawn_maintenance_tasks(sqlx-core/src/pool/inner.rs:503-524):

fn spawn_maintenance_tasks<DB: Database>(pool: &Arc<PoolInner<DB>>) {
    let pool_weak = Arc::downgrade(pool);

    let period = match (pool.options.max_lifetime, pool.options.idle_timeout) {
        (Some(it), None) | (None, Some(it)) => it,
        (Some(a), Some(b)) => cmp::min(a, b),
        (None, None) => {
            if pool.options.min_connections > 0 {
                crate::rt::spawn(async move { /* 只做一次 min_connections 维护 */ });
            }
            return;
        }
    };
    // ...
}

关键细节:

  • Arc::downgrade(pool)——维护任务持有 Weak、不持 Arc——这样 Pool 的最后一个用户 drop 时、维护任务不阻止 drop、它发现 Weak::upgrade() 返回 None 就自行退出。这是 Arc 循环引用避免的经典手法。
  • period = min(max_lifetime, idle_timeout)—— 两个 timeout 都有时选较短的——每个 period 就够发现一种 reap 情况。
  • 两个都 None + min_connections > 0——只跑一次初始预热、不起定期任务。
  • 两个都 None + min_connections == 0—— return——不启动任何后台任务——懒得不能再懒。

period 循环里的工作(:544-555):

  1. 扫描所有 idle 连接。
  2. 每条连接 check is_beyond_idle_timeout / is_beyond_max_lifetime——超过就 close。
  3. close 后如果低于 min_connections——立即补。

这套周期性扫描让池自愈——idle/lifetime 时时保持健康——用户代码什么都不用管。

close_event.do_until(...)——task 跟着 close() 事件结束——Pool 关闭时维护任务立即退出——不拖着。

优雅的后台任务管理——很多 Rust 网络服务值得借鉴这个模式。

22.9 pool.size() / pool.num_idle() 运行时指标

池运行时观测 API(sqlx-core/src/pool/mod.rs:535-541):

pub fn size(&self) -> u32 {
    self.0.size()
}

pub fn num_idle(&self) -> usize {
    self.0.num_idle()
}

size——池当前拥有的总连接数(包括正在使用的和空闲的)。

num_idle——空闲(可立即借出)的连接数。

size - num_idle——忙碌的连接数(正在执行 query 或 transaction)。

生产 metric 导出:

use metrics::{gauge, describe_gauge};

describe_gauge!("sqlx_pool_size", "Total connections in pool");
describe_gauge!("sqlx_pool_idle", "Idle connections in pool");
describe_gauge!("sqlx_pool_busy", "Busy connections in pool");

tokio::spawn({
    let pool = pool.clone();
    async move {
        let mut interval = tokio::time::interval(Duration::from_secs(1));
        loop {
            interval.tick().await;
            let size = pool.size();
            let idle = pool.num_idle() as u32;
            gauge!("sqlx_pool_size").set(size as f64);
            gauge!("sqlx_pool_idle").set(idle as f64);
            gauge!("sqlx_pool_busy").set((size - idle) as f64);
        }
    }
});

Grafana 面板要看:

  • sqlx_pool_busy / sqlx_pool_size——使用率——接近 100% 说明快饱和、接近 0% 说明过度配置。
  • sqlx_pool_size / max_connections——池扩展程度——长期 < 50% 说明max 可以调小。
  • 告警:sqlx_pool_busy == sqlx_pool_size 持续 > 30s —— 池饱和中。

实战价值——这两个 API 让 sqlx 池透明——不用看日志抓现场——接到 Prometheus 后永远看得见。

22.10 PREPARE 缓存机制

第 11 章讲过 query!() 宏的编译期 describe——运行时还有一层 PREPARE 缓存(sqlx-core/src/common/statement_cache.rs):

pub struct StatementCache<T> {
    inner: LruCache<String, T>,
}

LRU——基于 hashlink::lru_cache::LruCache(sqlx-core/src/common/statement_cache.rs:1)——Key 是 SQL 字符串、Value 是驱动相关的 statement 句柄:Postgres 是 (StatementId, Arc<PgStatementMetadata>)(sqlx-postgres/src/connection/mod.rs:61)、MySQL 是 (u32, MySqlStatementMetadata)(sqlx-mysql/src/connection/mod.rs:42)。

生命周期:

  1. 连接创建时——StatementCache::new(capacity)(sqlx-postgres/src/connection/establish.rs:147)—— 容量默认 100。
  2. 执行 query 时—— get_or_prepare(sql, ...)—— 先查缓存、命中就复用、未命中就 PREPARE 并插入。
  3. 缓存满时—— insert 返回被换出的 entry、驱动通知 DB 释放该 statement(Postgres 发 Close 消息,sqlx-postgres/src/connection/executor.rs:183-185;MySQL 发 COM_STMT_CLOSE)。

insert 的智能处理(sqlx-core/src/common/statement_cache.rs:27-39):

pub fn insert(&mut self, k: &str, v: T) -> Option<T> {
    let mut lru_item = None;

    if self.capacity() == self.len() && !self.contains_key(k) {
        lru_item = self.remove_lru();
    } else if self.contains_key(k) {
        lru_item = self.inner.remove(k);
    }

    self.inner.insert(k.into(), v);

    lru_item
}

三种情况:

  • 满了 + key 不在——淘汰 LRU item。
  • key 已在——替换(旧的返回)。
  • 其他——直接插入。

返回被换出的 item 给调用方——调用方有责任通知 DB 释放——体现资源管理的明确分工。

22.11 PREPARE 缓存命中率观察

默认容量 100(sqlx-postgres/src/options/mod.rs:88)——适合大多数应用。

什么时候会成瓶颈?

  • 你的服务里有 > 100 个不同的 SQL 模板——比如代码生成器产出的 SQL、或多租户 schema。
  • 每次新模板都触发 PREPARE(一次额外 round-trip + DB 工作)——N 个新模板 = N 次多余 round-trip。
  • LRU 淘汰后、被淘汰的模板下次又来——再 PREPARE——震荡。

怎么观察——第 21 章的 QueryLogger 日志已经给了线索:

  • 按 db.statement 字段(完整 SQL)去重计数——不同模板远多于 100 种、又都在反复出现,缓存就会来回换出。别拿 summary 字段去重:它只保留前 4 个词(§21.7),很多不同的模板会被截成同一个摘要。
  • 可以写自定义 subscriber 把所有 sqlx::query 事件的 SQL 模板去重计数——> 100 说明需要调大缓存。

扩缓存:

let opts = PgConnectOptions::new()
    .host("localhost")
    .statement_cache_capacity(500);   // 放大 5 倍

(sqlx-postgres/src/options/mod.rs:345-346)

代价——缓存是每条连接一份:max_connections × statement_cache_capacity 个 statement 同时存活在客户端和 DB 端(Postgres 端每个 prepared statement 也占 backend 内存)。放大缓存前先把这个乘积算出来——大内存换命中率。

关掉缓存:

.statement_cache_capacity(0)

capacity == 0 时 StatementCache::is_enabled() 返回 false(sqlx-core/src/common/statement_cache.rs:72-74),get_or_prepare 就不会往缓存里塞——极端场景(比如 lambda cold start、连接只活一次 query)可以这样省内存。但 Postgres 下要留意:查询默认 persistent = true,prepare 仍会给它分配一个新的具名语句 ID(sqlx-postgres/src/connection/executor.rs:30-36),而既不进缓存、之后也没有 Close——同一条连接上每执行一次就在服务端多留一个 prepared statement,直到连接关闭(按源码判断,未实跑)。想让服务端不留语句,应该对这些查询调 .persistent(false),走未命名语句。

最优 capacity 的确定方法:

  1. 开 sqlx::query debug 日志、跑 24h。
  2. 按 db.statement 去重计数 SQL 模板总数。
  3. capacity = ceil(总数 × 1.2)——留 20% 余量。

这种数据驱动的调优比瞎猜靠谱多了。

22.12 Pool::close 的优雅关闭

生产服务 SIGTERM—— 需要优雅关闭——等正在执行的 query 完成、再关池。Pool::close 转发到 PoolInner::close(sqlx-core/src/pool/inner.rs:97-115,下面删去了注释):

pub(super) fn close<'a>(self: &'a Arc<Self>) -> impl Future<Output = ()> + 'a {
    self.mark_closed();
    async move {
        for permits in 1..=self.options.max_connections {
            while let Some(idle) = self.idle_conns.pop() {
                let _ = idle.live.float((*self).clone()).close().await;
            }
            if self.size() == 0 { break; }
            let _permits = self.semaphore.acquire(permits).await;
        }
    }
}

算法:

  1. mark_closed 置位 is_closed——后续 acquire() 立即 PoolClosed。
  2. 循环——把 idle queue 里所有连接取出 close。
  3. semaphore.acquire(permits) —— 等信号量里同时空出 permits 个 permit(借出中的连接各占一个,归还后才释放);拿到后 _permits 在本轮结束时就 drop、permit 立即还回去。
  4. 每循环 permits 加一——最多到 max_connections,最后一轮要等全部 permit 空出,也就是借出的连接全部归还。
  5. size() == 0 说明所有连接都关闭——退出。

为什么 permits 递增?

源码没有注释说明。从代码看,效果是每等到一批归还就回头再清一次空闲队列、再查一次 size():连接归还得早,循环可以提前在 size() == 0 处退出;循环最多 max_connections 轮,最后一轮等待的是全部 permit,保证不会在还有连接借出时提前返回。

使用:

// 先停 HTTP:不再接新请求,等在途请求处理完
axum::serve(listener, app)
    .with_graceful_shutdown(shutdown_signal())   // 你自己的 SIGTERM / Ctrl+C future
    .await?;

// serve 返回之后再关池:等所有借出的连接归还并关闭
pool.close().await;

顺序不能反:先 close() 的话,还在处理中的请求再 acquire() 会直接拿到 PoolClosed。完整的启动 + 关闭代码见 §13.18。

优雅关闭的价值——不中途截断 query、在途请求能正常返回、不是"连接突然断开"。

22.13 DecrementSizeGuard:RAII 的另一个应用

Pool 内部还有一个容易忽略的 RAII 结构——DecrementSizeGuard——用来保证失败路径的 size 计数正确。

场景——acquire() 尝试新建连接:

  1. 先拿到信号量 permit,再由 try_increment_size 把 size 原子加 1——permit 和这 1 个计数一起交给 guard。
  2. TCP connect + TLS handshake——可能失败。
  3. 失败时需要把 size 减回去、否则 size 永远比实际多。

原始实现方式——每个失败 branch 手写 size.fetch_sub(1)——容易漏。

sqlx 的方式(sqlx-core/src/pool/inner.rs:577-618)——DecrementSizeGuard { pool: Arc<PoolInner<DB>>, cancelled: bool }——Drop 时如果 cancelled == false,就 pool.size.fetch_sub(1, Ordering::AcqRel) 并且 pool.semaphore.release(1)。成功路径上调用 guard.cancel() 置 cancelled = true——Drop 什么都不做。

这是 RAII 的经典模式——资源获取(size++ 和 permit)和释放绑定在同一个 struct—— 所有路径都正确、即使 panic。注意这个 guard 同时管着 size 计数和 semaphore permit 两件事,release_permit() 则是"还 permit 但不减 size"(连接建成、交给池了)。

和第 21 章的 QueryLogger Drop 一脉相承——sqlx 的 source 遍布 RAII—— Rust 的招牌武器在连接池里反复使用。

22.14 公平队列:一个你多半调不到的开关

PoolOptions 里有个 fair 字段(默认 true,sqlx-core/src/pool/options.rs:163),控制 waiter 调度策略。先说结论:这个开关基本不该由你来动——理由有四条,全部能在源码里读到。

第一,setter 是隐藏的。 它不叫 fair() 而叫 __fair(),带 #[doc(hidden)](sqlx-core/src/pool/options.rs:336-340),文档注释直言:

Currently only exposed for benchmarking; fair = true seems to be the superior option in most cases.

第二,语义不是"FIFO vs LIFO"。 源码注释(sqlx-core/src/pool/options.rs:324-326)写的是:

If set to true, calls to acquire() are fair and connections are issued in first-come-first-serve order. If false, "drive-by" tasks may steal idle connections ahead of tasks that have been waiting.

fair = false 允许路过的 task 插队抢走刚归还的空闲连接——不是"后进先出"这么整齐的模型。

第三,方向和直觉相反。 同一段注释接着说(依据是 sqlx-bench/benches/pg_pool):

this may slightly increase time to acquire() at low pool contention but at very high contention it helps avoid tasks at the head of the waiter queue getting repeatedly preempted by these "drive-by" tasks and tasks further back in the queue timing out because the queue isn't moving.

也就是说:fair = true 是在低竞争时略微增加 acquire 时间、在高竞争时反而救命(防止队首被反复抢占、队尾大面积超时)。所以"高负载下关掉 fair 吞吐更高"是错的。

第四,在 tokio 上它根本不生效。 AsyncSemaphore::new(fair, permits)(sqlx-core/src/sync.rs:26-40)里:

#[cfg(all(feature = "_rt-async-std", not(feature = "_rt-tokio")))]
inner: futures_intrusive::sync::Semaphore::new(fair, permits),
#[cfg(feature = "_rt-tokio")]
inner: {
    debug_assert!(fair, "Tokio only has fair permits");
    tokio::sync::Semaphore::new(permits)
},

启用 runtime-tokio 时 fair 参数被直接丢弃——tokio::sync::Semaphore 永远是公平的;debug 构建里传 false 会触发 debug_assert! 直接 panic,release 构建则静默忽略。只有 async-std 路径(用 futures_intrusive::sync::Semaphore)才真的读这个标志。

结论——把它当成 sqlx 内部的 benchmark 旋钮,不要在生产代码里碰。

22.15 N+1 诊断的系统流程

N+1 是 ORM 诞生以来永远的痛—— sqlx 不是 ORM、但照样会遇到。

症状:一个接口执行时间 = N 次 DB round-trip 的总和——比如 50 个订单 × 20ms = 1 秒——本可 50ms 完成。

诊断四步:

Step 1:开日志

RUST_LOG=info,sqlx::query=debug cargo run

触发一次接口调用、看输出。

Step 2:聚合 db.statement

用 jq + sort | uniq -c 从 JSON 日志(tracing_subscriber::fmt().json())里聚合。字段名就叫 db.statement,放在 fields 对象里;SQL 短到和 summary 一样时这个字段是空串,所以要退回用 summary;它的值前后带换行,先压成一行:

jq -r 'select(.target=="sqlx::query") | .fields
       | (if ."db.statement" != "" then ."db.statement" else .summary end)
       | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ")' app.log \
    | sort | uniq -c | sort -rn | head

(要对静态日志文件跑——tail -f 永远不结束,后面的 sort 就永远不会输出。)

输出:

 50 SELECT * FROM order_items WHERE order_id = $1
 50 SELECT SUM(amount) FROM payments WHERE order_id = $1
  1 SELECT * FROM orders WHERE user_id = $1

同一模板重复 50 次——N+1 铁证。

Step 3:改成 JOIN 或批量查询

// N+1(问题)
let orders = sqlx::query!("SELECT * FROM orders WHERE user_id = $1", uid)
    .fetch_all(&pool).await?;
for o in &orders {
    let items = sqlx::query!("SELECT * FROM order_items WHERE order_id = $1", o.id)
        .fetch_all(&pool).await?;
}

// 改成 ANY() 批量
let order_ids: Vec<i64> = orders.iter().map(|o| o.id).collect();
let all_items = sqlx::query!(
    "SELECT * FROM order_items WHERE order_id = ANY($1)",
    &order_ids
).fetch_all(&pool).await?;
// 在应用层按 order_id 分组

或 JOIN + 聚合。注意聚合列要选得能被 sqlx 解码——array_agg(oi.*) 聚出来的是 order_items 行类型(复合类型)的数组,query! 在编译期就会报 "no built in mapping found for type" (§11.6.1 的 get_column_type)。聚具体的列才行:

let rows = sqlx::query!(
    r#"SELECT o.id, o.user_id,
              array_remove(array_agg(oi.id), NULL) AS "item_ids!: Vec<i64>"
       FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id
       WHERE o.user_id = $1
       GROUP BY o.id, o.user_id"#,
    uid
).fetch_all(&pool).await?;

(LEFT JOIN 没匹配到时 array_agg 会聚出 {NULL},所以外面套一层 array_remove(..., NULL);列别名里的 ! 是第 11 章 §11.8.2 的强制非空 override。)

Step 4:验证

改完后再看日志——应该只有 2-3 条 query——p99 对应降低。

循环里访问 DB 就是 N+1 警报——这是 sqlx 使用的第一条 code review 铁律。

22.16 事务错误处理

第 15 章讲过 Transaction 的 RAII——生产使用有几条规范:

规范 1——事务内不要调用外部 API:

// ❌ 错误
let mut tx = pool.begin().await?;
sqlx::query!("UPDATE orders SET status = 'paid' WHERE id = $1", id)
    .execute(&mut *tx).await?;
http_client.post("https://payment.api/confirm").send().await?;  // ← 外部 API 在事务里
tx.commit().await?;

问题——HTTP call 可能慢几秒、整个事务持有 DB 行锁几秒——锁阻塞其他 request。

正确——在事务外调用、或者用 outbox pattern。

规范 2——Transaction 不跨 await 点传递给无关代码:

// ❌ 错误
async fn handler(pool: &PgPool) -> Result<()> {
    let tx = pool.begin().await?;
    // ... do_part_a(tx)、do_part_b(tx)—— 分散在多个函数
}

sqlx 的 Transaction 被 drop 时并不当场回滚——Postgres / MySQL 只把 ROLLBACK 写进这条连接的写缓冲、等下一次 I/O 才发出去(连接归还池时由归还任务的 ping 带出),SQLite 交给 worker 线程执行(§15.7);传递的层次和 await 点越多,drop 时机越不可控——聚合在一个函数里处理。

规范 3——捕获错误后仍要 commit/rollback:

async fn transfer(pool: &PgPool) -> Result<(), AppError> {
    let mut tx = pool.begin().await?;
    match do_transfer_ops(&mut tx).await {
        Ok(()) => tx.commit().await?,
        Err(e) => {
            tx.rollback().await?;  // 显式 rollback
            return Err(e);
        }
    }
    Ok(())
}

不显式 rollback 最终也会回滚(Drop 会排队一个 ROLLBACK)——但显式好处是 rollback().await 返回时回滚已经在数据库端完成;靠 Drop 的话 ROLLBACK 要等这条连接下一次 I/O 才发出,这段时间里事务持有的锁一直不放。

22.17 错误分类和重试策略

sqlx 的 Error 枚举(sqlx-core/src/error.rs)有二十来个变体——生产按可重试性分类:

错误 可重试? 策略
PoolTimedOut 通常是 退避 + 重试 1-2 次
PoolClosed 否 直接 fail(池已关、重试没意义)
Io(TimedOut) 是 重试
Io(ConnectionReset) 是 重试(TCP 断线)
Database(serialization_failure) 是 退避重试(事务隔离冲突)
Database(deadlock_detected) 是 退避重试
Database(check_constraint) 否 业务错误、返回 400
RowNotFound 否 业务语义、按场景
ColumnDecode { .. } / Decode(...) 否 代码 bug、修 schema
Protocol(...) 可能是 检查 DB/客户端兼容性

重试实现:

use backon::{BackoffBuilder, ExponentialBuilder};

async fn retriable_query(pool: &PgPool, uid: i64) -> Result<Row, Error> {
    let mut backoff = ExponentialBuilder::default()
        .with_min_delay(Duration::from_millis(50))
        .with_max_times(3)
        .build();

    loop {
        match sqlx::query!("...", uid).fetch_one(pool).await {
            Ok(r) => return Ok(r),
            Err(e) if is_retryable(&e) => {
                match backoff.next() {
                    Some(delay) => tokio::time::sleep(delay).await,
                    None => return Err(e),
                }
            }
            Err(e) => return Err(e),
        }
    }
}

fn is_retryable(e: &Error) -> bool {
    matches!(e,
        Error::PoolTimedOut
        | Error::Io(_)
        | Error::Database(_)  // 细分看 code
    )
}

退避——避免所有 client 同时重试把 DB 雪崩——backon 或 tower::retry::Policy 提供。

22.18 before_acquire / after_release / after_connect

PoolOptions 提供三个回调钩子——生产常用:

  • after_connect——新连接建立之后调用一次——初始化 session 参数。
  • before_acquire——每次从 idle queue 取出连接时调用——健康检查 / 状态重置。
  • after_release——每次连接归还池时调用(在归还前的那次 ping 之前)——清理状态。

实战 1:after_connect 设 search_path

use sqlx::Executor;

let pool = PgPoolOptions::new()
    .after_connect(|conn, _meta| Box::pin(async move {
        // 一次 `execute` 发多条语句——上游文档给的正是这种写法
        // (`sqlx-core/src/pool/options.rs:366-370`)。
        conn.execute(
            "SET search_path = 'public', 'myapp_schema'; \
             SET application_name = 'my-service';"
        ).await?;
        Ok(())
    }))
    .connect(&url).await?;

分开写成两次 conn.execute("…").await?; 也能编译——conn 的类型正好是 &mut PgConnection,方法调用会自动重借用、不会把它 move 掉(用 SQLite 等价写法在 0.8.6 上实测编译运行通过);也可以写成 sqlx::query("…").execute(&mut *conn).await?;(第 13 章 §13.11.1 用的就是这种)。

好处:

  • search_path 设置后、SQL 里不用前缀 schema 名——代码干净。
  • application_name 让 DB 的 pg_stat_activity 能看见是哪个服务占的连接——排查 DB 端负载时非常好用。

实战 2:before_acquire 丢弃有问题的连接

.before_acquire(|conn, meta| Box::pin(async move {
    // 连接活得太久、主动丢弃
    if meta.age > Duration::from_secs(3600) {
        return Ok(false);   // 返回 false 让 sqlx 关掉这个连接、再开新的
    }
    Ok(true)
}))

返回 false 让 sqlx 丢弃这条连接、开新的——自定义的 max_lifetime。

实战 3:after_release 重置 session

.after_release(|conn, _meta| Box::pin(async move {
    conn.execute("DISCARD TEMP").await?;   // Postgres:清掉这条连接上的临时表
    Ok(true)
}))

DISCARD TEMP——清掉临时表——适合多租户场景、防止上一个请求留下的临时表污染下一个请求。

别换成 DISCARD ALL——按 Postgres 文档,它包含 DEALLOCATE ALL 和 RESET ALL:前者把服务端的 prepared statement 全部释放,而 sqlx 客户端的 statement cache 并不知情(0.8.6 源码里没有针对 DISCARD 的处理),之后命中缓存的查询会引用服务端已不存在的语句而报错;后者还会把 after_connect 里设的 search_path 等参数一并重置。确实要用,得紧接着调 conn.clear_cached_statements() 清掉客户端缓存(与第 13 章 §13.11.3 的结论一致;未实跑)。

22.19 生产 sqlx checklist

把前面 21 章和本章前面各节的结论汇总成一个上线前 checklist:

连接池:

  • max_connections 按 "DB 上限 ÷ 应用实例数 - 20%" 算(默认只有 10,见 sqlx-0.8.6/sqlx-core/src/pool/options.rs:151;min_connections 默认 0,见 sqlx-0.8.6/sqlx-core/src/pool/options.rs:152)。
  • acquire_timeout ≤ 上游 HTTP timeout 的 50%(默认 30 秒,见 sqlx-0.8.6/sqlx-core/src/pool/options.rs:160——对多数 HTTP 服务来说这个默认值太长了)。
  • idle_timeout = 5-10 min、max_lifetime = 30 min-1 hour(两者的默认值恰好是 Some(10 min) 和 Some(30 min),见 sqlx-0.8.6/sqlx-core/src/pool/options.rs:161-162,都是 Option<Duration>,传 None 表示永不回收)。
  • pool.size() -> u32 / pool.num_idle() -> usize 导出到 metrics(sqlx-0.8.6/sqlx-core/src/pool/mod.rs:535 与 sqlx-0.8.6/sqlx-core/src/pool/mod.rs:540)。

查询:

  • 所有 user input 走 .bind()—— 不拼字符串。
  • 编译期校验的查询用 query!() 宏、不用 query(" ... ") 字符串。
  • 循环里无 DB 调用(N+1 防护)。
  • 批量查询用 = ANY($1) 传数组(§22.15)。

事务:

  • 事务内不调用外部 API、不做长计算。
  • Transaction 生命周期集中在单个函数内。
  • 关键错误显式 .rollback().await。

日志:

  • tracing subscriber 初始化在 main 第一行。
  • 生产用 JSON 输出、接 Loki/ELK。
  • sqlx::query target 的日志纳入告警(慢查询 Warn 级)——这个 target 字符串写死在 sqlx-0.8.6/sqlx-core/src/logger.rs:104-126,log 和 tracing 两条通路用的是同一个名字。
  • business span 包住 sqlx call——链路可追。

PREPARE cache:

  • 统计 SQL 模板总数——决定 statement_cache_capacity。
  • 默认 100(sqlx-0.8.6/sqlx-postgres/src/options/mod.rs:88,setter 在 sqlx-0.8.6/sqlx-postgres/src/options/mod.rs:345)不够就调到 200-500。
  • Lambda 之类短命进程考虑关 cache。

错误处理:

  • PoolTimedOut(sqlx-0.8.6/sqlx-core/src/error.rs:104,是 Error 枚举的无字段变体)有 metric 告警。
  • 可重试错误配退避重试。
  • DB Error code 映射到 HTTP status(4xx vs 5xx)。

迁移:

  • CI 里 sqlx migrate run 作为 deploy 前置步骤。
  • 迁移文件提交后不改(checksum 保护)。
  • 生产 DDL 先在 staging 跑、观察锁表影响。
  • CREATE INDEX CONCURRENTLY 这类不能在事务里跑的迁移,文件第一行写 -- no-transaction(0.8.6 只有 Postgres 驱动认,§20.25)。

编译期:

  • CI 用 SQLX_OFFLINE=true + .sqlx/ 缓存——不需要连 DB 编译(读取顺序是 .env 文件里的 SQLX_OFFLINE_DIR → 本 crate 的 .sqlx → workspace 根的 .sqlx;读缓存时宏只从 .env 取 SQLX_OFFLINE_DIR,进程环境变量里的同名变量只影响写缓存,见 sqlx-0.8.6/sqlx-macros-core/src/query/mod.rs:120、sqlx-0.8.6/sqlx-macros-core/src/query/mod.rs:166-170 与 sqlx-0.8.6/sqlx-macros-core/src/query/mod.rs:344-346;缓存缺失时宏直接报 "SQLX_OFFLINE=true but there is no cached data for this query")。
  • 开发环境 cargo sqlx prepare 更新缓存并提交。

安全:

  • DATABASE_URL 走 secret manager、不写代码。
  • Prod DB 用最小权限账号(SELECT、INSERT、UPDATE、DELETE 按需)。
  • SQL bind 值脱敏原则:密码/token 不传到 DB 明文。

这份 checklist 是一个应用上线前的最后一道防线——前 21 章和本章的内容都在这里收敛成行动清单。

22.20 常见生产事故形态

把生产 sqlx 事故归类——帮未来的你认清问题模式:

事故 1:deploy 后 5xx 风暴

症状——刚部署新版本、5xx 率飙升、日志里 PoolTimedOut。

根因——新版本某个接口加了 N+1 查询、单次请求串行发出几十条查询、反复借还连接,占用连接的总时长被拉长到秒级——并发一上来、池 20 条连接很快耗尽。

定位——本章 §22.15 的 N+1 诊断流程——sqlx::query=debug 日志聚合 db.statement。

修复——改 JOIN 或批量查询。

预防——code review 时问 "这段代码循环里有 DB call 吗"——第一条铁律。

事故 2:DB failover 后连接全挂

症状——RDS failover 切 standby、5 分钟内所有请求 Io(BrokenPipe)。

根因——sqlx 池里的连接指向旧 primary、primary 下线、连接被动断开——新请求 acquire 拿到坏连接。

定位——test_before_acquire = true(默认)本应 ping 后关闭坏连接——但 ping 本身也要 round-trip、大量连接同时 ping 也费时——有穿透窗口。

修复——等池自愈(max_lifetime 30 min 内全部换新)—— 或手动先 pool.close().await 再重建池—— 或调小 max_lifetime。

预防——deploy 前和 failover 演练时观察 PoolTimedOut metric。

事故 3:statement cache 震荡

症状——某服务 CPU 利用率 50% 但 DB CPU 80%、p99 上升——但 QPS 没变。

根因——新版本代码有动态 SQL 拼接(按不同租户 schema 拼不同 SQL)—— 1000 个租户 = 1000 种 SQL 模板——远超 100 的缓存容量——每次都 PREPARE——DB CPU 暴涨。

定位——DB 慢日志看 PREPARE 频率——对照 sqlx 客户端日志。

修复——statement_cache_capacity(2000),或把租户差异从 SQL 文本里拿掉、改成通用 SQL + bind 参数(例如共享表加 tenant_id 列)。注意 schema 名、表名这类标识符不能做 bind 参数——只要租户靠 schema 区分、SQL 里写死 schema 名,模板数就随租户数增长。

预防——动态 SQL 警觉——问自己"这段 SQL 模板数量是否有上限"。

事故 4:事务死锁

症状——日志有大量 deadlock_detected、请求零星失败。

根因——两个事务按相反顺序更新相同两行——Postgres 检测到死锁、回滚一个事务。

定位——DB 端 pg_locks 表 + pg_stat_activity——看两个事务锁了啥。

修复——规定统一的锁获取顺序(如按 id 升序)。

预防——事务内 UPDATE 多行前先 SELECT ... ORDER BY id FOR UPDATE——显式声明锁顺序。

事故 5:migration 在 prod 卡住

症状——deploy 步骤 sqlx migrate run 卡住 > 30 min、block 整个 deploy。

根因——大表 CREATE INDEX 没加 CONCURRENTLY(建索引期间持锁全表扫描、阻塞写入),或 ALTER TABLE 触发了整表重写。

定位——pg_stat_activity 看 query 卡在哪。

修复——立即 cancel、改写迁移使用 CREATE INDEX CONCURRENTLY(它不能在事务里执行,迁移文件第一行必须是 -- no-transaction,见 §20.25)、ALTER TABLE ADD COLUMN 不带默认值(免 rewrite)。

预防——任何大表 DDL 先在 staging 用 production-size 数据跑——时间 > 1 min 就考虑 concurrent 版本。

22.21 connect_lazy:启动时不等 DB

普通 connect(url).await 会等第一个连接建立——DB 未就绪时(连接被拒)它会在 acquire_timeout 内退避重试,到点仍连不上就返回错误,启动时 main 直接 error 退出。

connect_lazy(sqlx-core/src/pool/mod.rs:317-319,内部转给 PoolOptions::connect_lazy;还有一个 connect_lazy_with(options),:330-332)—— 返回一个 Pool、不实际建连接——第一次 acquire() 时才懒建立。设了 min_connections 也不会立刻预建(§22.4)。

let pool = PgPoolOptions::new()
    .max_connections(20)
    .connect_lazy(&database_url)?;  // 立即返回、不等 DB

生产价值:

  • Kubernetes pod startup order——应用 pod 可能在 DB pod 之前就绪——普通 connect 会死循环重启 pod。
  • main 返回前不做 I/O——可测试性提升、单测 main 不需要 mock DB。
  • first request pays——用户第一请求会略慢、但后续正常。

注意—— connect_lazy(url) 仍然会解析 URL(签名是 Result<Pool, Error>),但不会验证凭据、不会碰网络——密码错、DB 不存在这类问题要等第一次 acquire 才暴露。

最佳实践——生产用 connect_lazy + 启动时开一个 readiness probe handler——第一次业务调用前会自动触发连接建立——应用生命周期和 DB 生命周期解耦。

22.22 DATABASE_URL 的安全规范

DATABASE_URL 典型形式:

postgres://user:password@host:5432/dbname?sslmode=require&statement-cache-capacity=200

生产安全规范:

  1. 不写死在代码里——从环境变量读:std::env::var("DATABASE_URL")。
  2. 不进 git——.env 文件加 .gitignore、CI 密钥走 secret store(Vault / AWS Secrets Manager)。
  3. 不进日志——sqlx 的查询日志(sqlx::query)只带 SQL 和统计字段、不含连接串,但你自己的代码可能会打出来——PgConnectOptions 是 #[derive(Debug)](sqlx-postgres/src/options/mod.rs:16),eprintln!("failed: {:?}", opts) 会连密码一起打印——永远不要。
  4. 用最小权限账号——应用账号只有业务表的 DML 权限——不给 DDL、不给 superuser。
  5. sslmode——生产永远 require 或 verify-full——disable 只在 localhost 开发。
  6. 轮换机制——DATABASE_URL 可以定期轮换密码—— sqlx 池重建时用新 URL、旧连接随 max_lifetime 自然过期。

连接相关参数——sslmode、sslkey 路径、application_name——都适合放 DATABASE_URL 里、而不是硬编码 Rust 代码里——配置外置。

22.23 监控和告警模板

汇总推荐告警:

# prometheus alerts
groups:
- name: sqlx
  rules:
  - alert: SqlxPoolSaturated
    expr: sqlx_pool_busy / sqlx_pool_size > 0.9
    for: 2m
    annotations:
      summary: "sqlx 池使用率 > 90%"

  - alert: SqlxPoolTimeout
    expr: increase(pool_timeout_total[1m]) > 1
    for: 2m
    annotations:
      summary: "sqlx 池超时 > 1 次/分钟"

  - alert: SqlxSlowQuery
    expr: rate(sqlx_slow_query_total[5m]) > 1
    for: 5m
    annotations:
      summary: "慢查询 > 1/s 持续 5 min"

  - alert: SqlxSlowAcquire
    expr: rate(sqlx_slow_acquire_total[5m]) > 0.5
    for: 5m
    annotations:
      summary: "慢 acquire > 0.5/s"

这些指标都不是 sqlx 自带的:sqlx_pool_* 来自 §22.9 的轮询导出,pool_timeout_total 来自 §22.5 的计数;sqlx_slow_query_total / sqlx_slow_acquire_total 需要你自己从 sqlx::query 的慢查询 Warn 事件和 sqlx::pool::acquire 事件计数得来(比如写一个 tracing Layer)——sqlx 0.8.6 只发 tracing 事件、不导出 metrics。

每条告警对应一个具体可行动的处置流程——不是 "CPU 高" 这种模糊告警——可操作性是告警设计的核心标准。

22.24 选型时该对照的几条事实

这里只列 sqlx 侧可以从源码核实的定位,其他库请以你使用的那个版本的文档为准(它们都在快速演进,任何二手对比表都容易过期)。

sqlx 在 0.8.6 里的形态:

  • 不是 ORM——没有 Entity / Active Record 层,你写的就是 SQL 字符串或 query!() 宏里的 SQL。
  • async-first——所有执行路径返回 Future / Stream,绑 tokio 或 async-std 运行时(runtime-tokio / runtime-async-std feature)。
  • 自己实现线协议——sqlx-postgres / sqlx-mysql 从零实现 Postgres / MySQL 的线协议(第 16、17 章),不是对别的客户端库的封装;sqlx-sqlite 则通过 libsqlite3-sys 调 SQLite 的 C 库,每条连接配一个 worker 线程把同步调用接到 async 世界(第 18 章)。
  • 连接池、迁移、CLI 都在同一个 workspace 里——不需要额外拼 r2d2 / 独立 migration 工具。
  • 编译期校验靠 query!() 宏连真库 describe(第 11 章),不是靠类型级 DSL 建模 schema。

选型时按这几条对照你的候选库即可:要不要 ORM 层、运行时是什么、协议是自己实现还是包装、池和迁移是否自带、编译期校验的实现路径。

22.25 代码审阅必查 10 条

审阅用 sqlx 的 PR 时、检查这 10 条:

  1. 循环里有无 DB 调用(N+1)。
  2. 所有 user input 走 .bind()(SQL injection)。
  3. query!() 宏优先于 query() 字符串(编译期校验)。
  4. 事务生命周期是否单函数内(resource leak)。
  5. PoolTimedOut 的错误路径有无合理处理(不是直接 unwrap)。
  6. fetch_optional vs fetch_one 用得对不(空结果是 bug 还是预期)。
  7. JOIN 有无 LEFT 等正确 join type(业务语义)。
  8. UPDATE / DELETE 有无 WHERE 条件(全表误操作预防)。
  9. Migration 有无 IF NOT EXISTS / CREATE ... CONCURRENTLY(幂等 + 在线兼容;后者要配首行 -- no-transaction)。
  10. bind 参数类型是否对(i32 vs i64、String vs &str——类型不匹配时 sqlx 编译报错但有些场景编译过 runtime 炸)。

10 条每条背后都是一次真实事故的教训——对应前面章节的具体 trait 设计或执行机制。

22.26 性能调优方法论

sqlx 性能调优的自上而下流程:

  1. 测:先拿到基线—— wrk / vegeta 压测出 p50/p99/throughput。
  2. 看 metric—— sqlx_pool_*、 sqlx_slow_query_total、DB 端 pg_stat_activity。
  3. 看日志——sqlx::query=debug、识别热点 SQL 模板。
  4. 定位瓶颈——p99 卡在 acquire?query?commit?业务代码?
  5. 单点优化:
    • 池饱和 → 调 max_connections / query 时长 / 并发度。
    • query 慢 → EXPLAIN ANALYZE、加索引、改 SQL。
    • N+1 → JOIN 或批量。
    • Commit 慢 → 事务内逻辑瘦身、批量写。
  6. 再测——验证优化效果、看退化指标。

不要乱调——每次调一个——方便归因。调完立即记录——时间、变化、效果。

性能不是一次性工程——日常伴随产品演进——这套测→看→定位→调→测的 loop 在 Rust 服务、Java 服务、Go 服务上都通用——sqlx 只是在每一步提供特定工具。

flowchart TD
    A[压测拿基线] --> B[看 metric 和日志]
    B --> C{瓶颈在哪?}
    C -->|池| D[调 max/acquire_timeout]
    C -->|query| E[EXPLAIN + 加索引]
    C -->|N+1| F[JOIN 或批量]
    C -->|commit| G[事务瘦身]
    D --> H[再压测]
    E --> H
    F --> H
    G --> H
    H --> I{达标?}
    I -->|是| J[结束]
    I -->|否| B

22.27 和前 21 章的索引映射

如果你读到这里——前 21 章看过、本章调优思路也懂——但不是所有知识都能同时记住。留一个索引表方便回查:

生产问题 解决机制 本专栏参考
连接池满 PoolOptions 调优 第 13、14 章 + 本章 §22.3-§22.5
N+1 查询日志聚合 第 21 章 §21.34 + 本章 §22.15
慢查询 log_slow_statements 第 21 章 §21.2、§21.4
事务死锁 serialization_failure 重试 第 15 章 + 本章 §22.17、§22.20
迁移失败 Migrator + checksum 第 20 章 §20.5、§20.10
SQL 注入 .bind() 参数化 第 6、9 章
编译期类型错 query!() 宏 + .sqlx/ 第 11 章
多 DB 切换 Any 驱动 第 19 章
自定义类型 Encode + Decode + Type 第 5 章

出事时——按"症状 → 机制 → 章节"反查——比全文搜索更有效。

22.28 sqlx 未来演进方向

sqlx 0.8 已经非常成熟。下面第一条是源码注释里写过的计划(而且没有按时兑现),其余是社区讨论中的方向、并非路线图承诺:

  • AnyConnectionBackend::get_transaction_depth 变 required method——这个方法是 0.8.4(#3765)以"默认 unimplemented!()"的方式加进来的,注释写着 0.9 起会变成 required method(sqlx-core/src/any/connection/backend.rs:51);但 v0.9.0 tag 里它仍是带默认实现的 provided method、注释原样保留——计划没有在 0.9.0 兑现,见第 19 章 §19.17、§19.20。
  • 更好的 async Drop—— Rust 的 AsyncDrop trait 稳定后、Transaction 的 rollback 可以真正 .await——更干净的 RAII。
  • 更深的 query 校验—— 现在 query!() 校验的是 SQL 能否被数据库 describe、以及参数和结果列的类型,未来可能在编译期检查 WHERE 条件覆盖索引之类更深的性质。
  • 更强的 metrics 集成——内置 Prometheus / OTLP 输出、减少用户 subscriber 代码。
  • Connection-level observability——每个 connection 的 state machine 导出、tokio-console 原生支持。

除第一条外都是愿望清单、源码里没有承诺——sqlx 团队的优先级取决于社区贡献和上游生态的演进。

关注 sqlx 路线图的实用做法——订阅 sqlx GitHub releases、每个 minor 版本的 changelog 看清楚再升级。