Skip to content

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

"In theory, theory and practice are the same. In practice, they are not." —— Yogi Berra

本章要点

  • PoolOptions::new 的默认值 不是随便定的——max_connections = 10sqlx-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::PoolTimedOutsqlx-core/src/error.rs:104)——30s 的默认值生产环境可以调到 5-10s、让调用方早失败好过长挂。
  • acquire_slow_threshold 默认 2 秒(:159)——池工作正常时 acquire 应该在 ms 级别、>2s 基本意味着连接耗尽——这是早期预警指标。
  • PREPARE 缓存是 LRUsqlx-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::int[]) 批量——这是最常见的生产性能病。
  • fair = true 让 pool 采用公平队列——先到先得、不会让某个 task 长期饿死;false 允许抢占、高负载下总吞吐稍高——99% 场景保持默认。

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

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

开发和生产的五个差异:

  1. 并发量——开发 1-10 QPS、生产 1K-10K QPS——池必须支持高并发 acquire、不能有锁瓶颈。
  2. 长尾延迟——开发只看 p50 好不好、生产看 p99 / p999——偶发慢查询会被放大成可见告警
  3. 连接抖动——开发 localhost 永不丢连接、生产 TCP 断链/DB failover/network partition 都会发生——池必须自愈
  4. 错误分布——开发 99% 是 SQL 语法错(开发者自己调试)、生产 99% 是资源错(池满、DB 超载、网络慢)——错误处理策略完全不同。
  5. 部署拓扑——开发单实例、生产 N 实例——DB max_connections 是全局资源、每实例必须节制。

sqlx 是生产级库——PoolOptions 的默认值全部对着"中等规模生产"设计——本章先从这些默认值入手、逐个拆解。

22.2 PoolOptions 的默认值——工业级取舍

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

rust
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_acquiretrue每次 acquire 前 ping 一次连接——保证拿到的连接可用
max_connections10适合小规模测试——生产必须调
min_connections0按需启动——冷启动第一次会等连接建立
acquire_time_levelOff默认不记录每次 acquire 的耗时日志
acquire_slow_levelWarn慢 acquire 默认记 Warn——生产可见
acquire_slow_threshold2s超过 2s 算慢——实际健康池应该 ms 级
acquire_timeout30s等不到连接 30s 后抛 PoolTimedOut——实际生产可以调小
idle_timeout10 min10 分钟闲置就关掉——避免长期浪费连接
max_lifetime30 min每条连接最长活 30 分钟——定期回收防内存泄漏
fairtrue公平队列——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 秒是工业实验值——既快到能发现池满、又慢到不会把"DB 建立一个新连接(慢 DB 可能要 500ms-1s)"误判为慢——精心选择的平衡点

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

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

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

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

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

DB.max_connections = 200   (e.g. RDS Postgres t3.large)
app replicas = 10
per-app max_connections = 200 / 10 - 2 (留给管理/备份) ≈ 18

为什么不能无脑设大

  • DB 端也有 max_connections 限制——Postgres 默认 100、t3.large 大约 200、RDS 大型实例几千。
  • 每个连接占 DB 内存——Postgres 每连接 ~10MB(work_mem + shared_mem 等)——100 连接就是 1GB 永驻。
  • 连接多不等于吞吐高——DB CPU 饱和后、更多连接只会让每条 query 更慢——Little's Law 的实际体现。
  • 应用端每连接也占内存——sqlx 每连接的 buffer + 状态机 ~几十 KB——一万连接也占 GB。

调优方法论(PgBouncer 官方推荐):

  1. CPU core 数——DB 主机有 N 核。
  2. 网络来回次数——大多数 OLTP query 是 1-2 round trip
  3. 每核支持 约 2-4 个并发活跃连接 —— 超过此数 CPU 饱和。
  4. 所以总活跃连接 = 核数 × 2-4—— 比如 4 核机器 8-16 连接就够。
  5. 加上 idle buffer——每实例 max_connections = ceil(max_active / replicas × 1.5)

这套方法在业界 PgBouncer、pgpool、HikariCP 文档里反复强调——和直觉相反——少而高效 > 多而拥挤

22.4 min_connections:0 还是满池

默认 0——按需创建——第一次请求进来时才 connect——代价是首次延迟(TCP + TLS + auth ≈ 30-200ms)。

两种典型选择

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

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

因为 min_connections 的语义——sqlx 会保证至少 min 个连接存活、idle_timeout 不会回收它们。所以 min=3 意味着"永远有 3 个 idle 连接"——在不均匀流量下意义不大:

  • 流量高峰时这 3 个也不够用、池会扩到 max。
  • 流量低谷时这 3 个连接持续 idle 却不会被回收——长期占 DB 资源

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

min_connections 的副作用options.rs:187-202 注释):

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.

best-effort——sqlx 不强保证池里有 min 个连接——DB 慢/挂时不会阻塞正常业务等待 maintainer——体现韧性优先哲学。

22.5 acquire_timeout:最常见的生产报错

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

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

原因——早失败好过长挂

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

推荐配置

rust
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 的处理

rust
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):

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

crate::rt::timeout(...) 包裹整个 acquire loop——超过 acquire_timeout 就 map 成 PoolTimedOut——单一错误源——定位清晰。

22.6 idle_timeout 和 max_lifetime 的协作

两个 timeout 目的不同

  • idle_timeout(默认 10 min)——一条连接空闲超过 10 分钟就关——节省资源、收缩池。
  • max_lifetime(默认 30 min)——一条连接不管有没有用、创建 30 分钟后强制关——防止单条连接长期持有的资源累积(内存碎片、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——sqlx 历史上出现过连接状态不一致的 bug——定期回收是防御

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

组合效果

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

两个 timer 的最小公约数——保证没有连接能活过 max_lifetime没有空闲连接能闲超过 idle_timeout——池动态有边界

调优建议

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

22.7 acquire_slow 观测链

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

rust
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 级别——生产可见——帮你早发现池耗尽。

生产配置

rust
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')—— 历史遗留——sqlx 团队没改以免 break 用户的 Grafana query——API 稳定性的代价。生产 dashboard 用这个字段时注意拼写。

慢 acquire 的含义

  • 池已满、在等别的 task 归还——扩 max_connections缩短 query
  • DB 建新连接慢——调大 min_connections预热、或检查 DB 负载
  • sqlx 内部 waker 乱序——升级 sqlx、通常是过时版本 bug。

22.7a spawn_maintenance_tasks:池的后台清洁工

Pool::new 构建结束时启动一个后台任务——spawn_maintenance_taskssqlx-core/src/pool/inner.rs:503-524):

rust
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.8 pool.size() / pool.num_idle() 运行时指标

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

rust
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 导出

rust
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.9 PREPARE 缓存机制

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

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

LRU——基于 hashlink::LruCache——Key 是 SQL 字符串、Value 是驱动相关的 statement 句柄(Postgres 是 StatementId + metadata、MySQL 是 MySqlStatementMetadata)。

生命周期

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

insert 的智能处理statement_cache.rs:27-39):

rust
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.10 PREPARE 缓存命中率观察

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

什么时候会成瓶颈

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

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

  • 日志里 summary="SELECT * FROM users" 出现频率高、但实际 query 数远大于 100 种——缓存没效
  • 可以写自定义 subscriber 把所有 sqlx::query 事件的 SQL 模板去重计数——> 100 说明需要调大缓存。

扩缓存

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

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

代价——每条连接 500 个 statement handle、每个几百字节——500 connections × 500 stmts ≈ 125MB 客户端 + 类似规模 DB 端——大内存换命中率

关掉缓存

rust
.statement_cache_capacity(0)

极端场景(比如 lambda cold start、连接只活一次 query)——关缓存省内存。

最优 capacity 的确定方法

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

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

22.10a Pool::close 的优雅关闭

生产服务 SIGTERM—— 需要优雅关闭——等正在执行的 query 完成、再关池。sqlx 的 Pool::closesqlx-core/src/pool/inner.rs:97-115):

rust
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 个信号——也就是等待 permits 个连接归还后 release。
  4. 每循环 permits 递增——第一轮等 1 个归还、第二轮等 2 个归还……直到全部归还。
  5. size() == 0 说明所有连接都关闭——退出。

为什么 permits 递增

因为并发—— acquire(1) 拿到意味着 1 个归还——但可能同时更多归还——每次 acquire 都 monotonically increase、配合池尺寸收缩——避免死锁、同时保持简单。

使用

rust
// 接收 SIGTERM 后
pool.close().await;   // 等所有 in-flight query 完成
// 然后才 process::exit(0)

优雅关闭的价值——不中途截断 query、客户端看到的是完整响应或 503、不是"连接突然断开"—— 用户体验 + 数据一致性双保障。

22.10b DecrementSizeGuard:RAII 的另一个应用

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

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

  1. try_increment_sizesize 原子加 1—— 得到 permit。
  2. TCP connect + TLS handshake——可能失败。
  3. 失败时需要把 size 减回去、否则 size 永远比实际多。

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

sqlx 的方式——DecrementSizeGuard { pool, released: bool }——Drop 时如果 released == false——把 size 减 1。成功路径上调用 guard.release()released = true——Drop 不动 size。

这是 RAII 的经典模式——资源获取(size++)和释放(size--)绑定在同一个 struct—— 所有路径都正确、即使 panic。

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

22.10c 公平队列 vs 抢占

PoolOptions::fair(默认 true)控制 waiter 调度策略。

fair = true(默认)——FIFO 队列——第一个等待的 task 最先拿到新归还的连接。

fair = false——LIFO 栈——最后等待的 task 最先拿到——好处是该 task 可能缓存还热

为什么默认 fair = true

  • 避免饥饿——高并发下 LIFO 可能让某些 task 永远拿不到连接(被新来的插队)——生产不能接受。
  • 可预测延迟——FIFO 下等待时间 bounded = 前面 waiter 数 × 平均 query 时长——可算告警阈值。
  • fairness 的代价很小——对高 QPS 服务 fair/unfair 吞吐差别 <5%。

什么时候用 fair = false

  • 超高 QPS(几万 QPS)、队列深度很浅——LIFO 缓存亲和性的收益才体现。
  • 有明显的 batch 作业和 interactive 作业混合——LIFO 让 interactive 响应更快(interactive 总是新来的)。

99% 场景默认 fair = true——改之前先问"我是否真的理解改动的后果"。

22.11 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

sort | uniq -c 或 jq 从 JSON 日志里聚合:

bash
tail -f app.log | jq 'select(.target=="sqlx::query") | .fields.db_statement // .fields.summary' \
    | sort | uniq -c | sort -rn | head

输出:

 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 或批量查询

rust
// 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:

rust
let rows = sqlx::query!(
    "SELECT o.*, array_agg(oi.*) AS items
     FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id
     WHERE o.user_id = $1
     GROUP BY o.id",
    uid
).fetch_all(&pool).await?;

Step 4:验证

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

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

22.12 事务错误处理

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

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

rust
// ❌ 错误
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 点传递给无关代码

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

sqlx 的 Transaction drop 会 rollback、await 点越多 drop 时机越不可控——聚合在一个函数里处理。

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

rust
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)——但显式好处是明确 drop 时机——如果后续还有其他 await 点、避免 tx 持锁等待。

22.13 错误分类和重试策略

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业务语义、按场景
Decode(...)代码 bug、修 schema
Protocol(...)可能是检查 DB/客户端兼容性

重试实现

rust
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 雪崩——backontower::retry::Policy 提供。

22.13a before_acquire / after_release / after_connect

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

  • after_connect——新连接建立之后调用一次——初始化 session 参数
  • before_acquire——每次从 idle queue 取出连接时调用——健康检查 / 状态重置
  • after_release——每次 query 结束归还池时调用——清理状态

实战 1:after_connect 设 search_path

rust
let pool = PgPoolOptions::new()
    .after_connect(|conn, _meta| Box::pin(async move {
        conn.execute("SET search_path = 'public', 'myapp_schema'").await?;
        conn.execute("SET application_name = 'my-service'").await?;
        Ok(())
    }))
    .connect(&url).await?;

好处

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

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

rust
.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

rust
.after_release(|conn, _meta| Box::pin(async move {
    conn.execute("DISCARD ALL").await?;   // Postgres: 清空所有 session state
    Ok(true)
}))

DISCARD ALL——清空 prepared statements、temp tables、session 变量——适合多租户场景、防止一个 tenant 的 session state 污染下一个 tenant。

慎用——DISCARD ALL 会把 sqlx 客户端的 statement cache 也失效—— PREPARE 又得重跑——性能损失。在多租户之间用 DISCARD、同租户之间不用

22.14 生产 sqlx checklist

从前面 21 章 + 本章 13 节、汇总成一个上线前 checklist

连接池

  • [ ] max_connections 按 "DB 上限 ÷ 应用实例数 - 20%" 算。
  • [ ] acquire_timeout ≤ 上游 HTTP timeout 的 50%。
  • [ ] idle_timeout = 5-10 min、max_lifetime = 30 min-1 hour。
  • [ ] pool.size() / pool.num_idle() 导出到 metrics。

查询

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

事务

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

日志

  • [ ] tracing subscriber 初始化在 main 第一行。
  • [ ] 生产用 JSON 输出、接 Loki/ELK。
  • [ ] sqlx::query target 的日志纳入告警(慢查询 Warn 级)。
  • [ ] business span 包住 sqlx call——链路可追。

PREPARE cache

  • [ ] 统计 SQL 模板总数——决定 statement_cache_capacity
  • [ ] 默认 100 不够就调到 200-500。
  • [ ] Lambda 之类短命进程考虑关 cache。

错误处理

  • [ ] PoolTimedOut 有 metric 告警。
  • [ ] 可重试错误配退避重试。
  • [ ] DB Error code 映射到 HTTP status(4xx vs 5xx)。

迁移

  • [ ] CI 里 sqlx migrate run 作为 deploy 前置步骤。
  • [ ] 迁移文件提交后不改(checksum 保护)。
  • [ ] 生产 DDL 先在 staging 跑、观察锁表影响。

编译期

  • [ ] CI 用 SQLX_OFFLINE=true + .sqlx/ 缓存——不需要连 DB 编译。
  • [ ] 开发环境 cargo sqlx prepare 更新缓存并提交。

安全

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

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

22.15 常见生产事故形态

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

事故 1:deploy 后 5xx 风暴

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

根因——新版本某个接口加了 N+1 查询、单次请求占 10 个连接 × 1s——池 20 连接瞬间耗尽。

定位——第 21 章的 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(); pool = reconnect()—— 或调小 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 + bind 参数(把 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。

根因——大表 ALTER TABLE ADD COLUMNCREATE INDEX 没加 CONCURRENTLY——锁表全量扫描。

定位——pg_stat_activity 看 query 卡在哪。

修复——立即 cancel、改写迁移使用 CREATE INDEX CONCURRENTLYALTER TABLE ADD COLUMN ... DEFAULT NULL(无默认值时免 rewrite)。

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

22.15a connect_lazy:启动时不等 DB

普通 connect(url).await等第一个连接建立——如果 DB 未就绪、启动时 main 直接 error 退出。

connect_lazysqlx-core/src/pool/mod.rs:317-318)—— 返回一个 Pool不实际建连接——第一次 acquire() 时才懒建立。

rust
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 不做 connect() 层面的参数校验(比如 URL 格式错、密码错)—— 要第一次 acquire 时才暴露错误。

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

22.15b 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 会在日志里打 DATABASE_URL、但你自己的错误可能会——eprintln!("failed: {:?}", opts) 会打 password——永远不要
  4. 用最小权限账号——应用账号只有业务表的 DML 权限——不给 DDL、不给 superuser。
  5. sslmode——生产永远 requireverify-full——disable 只在 localhost 开发。
  6. 轮换机制——DATABASE_URL 可以定期轮换密码—— sqlx 池重建时用新 URL、旧连接随 max_lifetime 自然过期。

连接字符串里的敏感参数——sslmodesslkey 路径、application_name——都适合放 DATABASE_URL 里、而不是硬编码 Rust 代码里——配置外置

22.16 监控和告警模板

汇总推荐告警

yaml
# 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: rate(pool_timeout_total[1m]) > 0.1
    for: 2m
    annotations:
      summary: "sqlx 池超时率 > 0.1/s"

  - 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"

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

22.17 sqlx 和其他 Rust DB 库的生产对比

sqlx vs diesel

维度sqlxdiesel
async 支持原生sync + 额外 async 层
编译期校验query!()DSL 类型系统
学习曲线写 SQL 的都能上手需掌握 Diesel DSL
migration简单文件 + CLImigration CLI 工具
连接池内置需 r2d2 等
生产成熟度非常成熟非常成熟

选择 sqlx 的理由——async-first 生态(tokio 栈)+ 写原生 SQL 的直观性。

sqlx vs sea-orm

  • sqlx 是、sea-orm 是ORM
  • sea-orm 基于 sqlx——更高层抽象(Entity、Active Model)。
  • 大项目且需要 ORM 的用 sea-orm、需要 raw SQL 控制的直接用 sqlx。

sqlx vs tokio-postgres

  • tokio-postgres 更底层——只支持 Postgres、无宏、无 migration。
  • sqlx 包装 tokio-postgres 风格的协议代码——加编译期校验、migration、多 DB。

推荐路线——新项目用 sqlx、特殊场景(高定制 migration、ORM 便利)再换。

22.18 代码审阅必查 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(幂等 + 在线兼容)。
  10. bind 参数类型是否对(i32 vs i64String vs &str——类型不匹配时 sqlx 编译报错但有些场景编译过 runtime 炸)。

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

22.19 性能调优方法论

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 只是在每一步提供特定工具。

22.19a 和前 21 章的索引映射

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

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

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

22.19b 把 sqlx 用到"刚刚好"的哲学

最后说一条心得——sqlx 给了你很多工具——但好的使用是用得节制

  • 不用所有 trait——只实现业务必要的(Encode 不是每个类型都要写)。
  • 不调每个 PoolOption——大多数默认值已经对。
  • 不写自定义 subscriber 层——tracing_subscriber::fmt().json() 足够大多数场景。
  • 不强塞 Any 驱动——只在真的需要多 DB 时用。
  • 不乱用 #[non_exhaustive]——你的类型可能不需要它。

"用刚刚好" 是 Rust 工程师成熟的标志—— 库提供很多工具、但你只用必要的部分——代码保持简单——未来的你和同事都会感谢这一点。

这也呼应第 2 章讲 sqlx workspace 结构时的观察——sqlx 内部就是用得节制——每 crate 只做本职、不随便扩——传染给用户代码也保持小而美。

22.20 sqlx 未来演进方向

sqlx 0.8 已经非常成熟—— 0.9 / 1.0 可能的方向:

  • 更完善的 Any 驱动—— 第 19 章提到 Any 在 MIGRATE 上支持有限——未来完全统一。
  • 更好的 async Drop—— Rust 的 AsyncDrop trait 稳定后、Transaction 的 rollback 可以真正 .await——更干净的 RAII。
  • 类型级 query 校验—— 现在 query!() 只校验类型、未来可能在编译期检查 WHERE 条件覆盖索引。
  • 更强的 metrics 集成——内置 Prometheus / OTLP 输出、减少用户 subscriber 代码。
  • Connection-level observability——每个 connection 的 state machine 导出、tokio-console 原生支持。

这些都是愿望清单——sqlx 团队的优先级取决于社区贡献和 tokio / tracing 上游的演进。

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

22.21 全书回顾

本书 22 章、从 sqlx 的基础 trait 到生产调优——每一章都围绕一个具体机制

  • 第一部分(1-2 章)—— sqlx 是什么、crate workspace 结构。
  • 第二部分(3-8 章)—— Database / Executor / Encode / Arguments / Row / FromRow trait 家族。
  • 第三部分(9-11 章)—— query / query_as / query_builder / query!() 宏。
  • 第四部分(12-15 章)—— Connection / Pool / Transaction。
  • 第五部分(16-19 章)—— Postgres / MySQL / SQLite / Any 四家驱动。
  • 第六部分(20-21 章)—— Migration / Logging。
  • 第七部分(22 章)—— 生产实战。

整本书的主线—— sqlx 通过精心的 trait 分层(Database 为根、其他 trait 沿不同维度展开)+ 零拷贝异步设计(不持多余缓存、I/O 全部 async)+ RAII 资源管理(Transaction/QueryLogger/Guard)—— 把"高性能异步数据库访问"做到了 Rust 生态的事实标准地位。

读完本书应该具备的四项能力

  • 读懂任何一行 sqlx 源码——知道它在整个设计里的位置。
  • 准确回答"为什么 sqlx 这样设计"——每个决策背后有源头。
  • 合理使用 sqlx 扛生产——池调优、事务规范、migration 纪律。
  • 推广到其他 Rust 生态——trait 分层、GAT 关联类型、RAII、trait object 运行时多态等思想通用。

基于 VitePress 构建