背景

Rust 写后端服务时,数据库访问通常是绕不开的一层。

很多人刚开始用 sqlxtokio-postgres 时,会把关注点放在:

  • 能不能异步查询
  • 类型映射是否方便
  • 宏检查 SQL 是否好用

这些当然重要,但线上跑起来以后,更现实的问题通常是:

  • 连接池应该开多大
  • 请求等连接要等多久
  • 数据库抖动时怎么避免把整个服务拖死

这些问题不处理好,异步只能让你“更高效地把数据库打爆”。

先建立一个基本事实

异步不是无限并发。

你的 Tokio 任务可以很多,但数据库连接永远是稀缺资源。无论是 PostgreSQL、MySQL 还是其他关系型数据库,都不可能让应用无限开连接而没有代价。

所以数据库访问的第一原则不是“尽快发查询”,而是:

  • 连接数可控
  • 排队时间可控
  • 查询超时可控

以 sqlx 为例初始化连接池

use sqlx::postgres::PgPoolOptions;
use std::time::Duration;

async fn create_pool(database_url: &str) -> Result<sqlx::PgPool, sqlx::Error> {
    PgPoolOptions::new()
        .max_connections(32)
        .min_connections(4)
        .acquire_timeout(Duration::from_secs(2))
        .idle_timeout(Duration::from_secs(300))
        .max_lifetime(Duration::from_secs(1800))
        .connect(database_url)
        .await
}

这几个参数都很关键:

  • max_connections:连接池上限
  • min_connections:最小保活连接数
  • acquire_timeout:拿连接最多等多久
  • max_lifetime:连接多久轮换一次

尤其是 acquire_timeout,它能防止高峰时请求无止境排队。

连接池大小不是越大越好

很多人看到连接池耗尽,就第一时间把池子调大。

这有时能缓一口气,但经常只是把压力继续往数据库推。

连接池大小应该结合几个因素来定:

  • 数据库实例本身最大连接数
  • 单条查询平均耗时
  • 服务实例数量
  • 业务高峰并发

如果你有 10 个服务实例,每个实例都开 max_connections=100,那总连接数就是 1000。很多数据库配置根本扛不住。

所以连接池要按“全局容量预算”来想,而不是只看单个进程舒服不舒服。

每次查询都要带超时

连接池超时只能解决“拿连接太久”的问题,不能解决“查询本身太慢”的问题。

一个更稳妥的做法,是在业务层再包一层超时。

use tokio::time::{timeout, Duration};

async fn get_user(pool: &sqlx::PgPool, user_id: i64) -> anyhow::Result<User> {
    let user = timeout(
        Duration::from_millis(200),
        sqlx::query_as::<_, User>(
            "select id, name, email from users where id = $1"
        )
        .bind(user_id)
        .fetch_one(pool),
    )
    .await??;

    Ok(user)
}

这样你至少能明确一件事:

如果数据库在可接受时间内没有返回,当前请求就该结束了,而不是一直挂着。

慢 SQL 和连接池耗尽经常一起出现

线上一个很常见的现象是:

  1. 某类查询变慢
  2. 单个请求占连接时间变长
  3. 连接池越来越满
  4. 新请求拿不到连接
  5. 服务整体延迟全面上升

所以连接池问题,很多时候并不是“池子太小”,而是后面那层 SQL 或数据库本身已经退化了。

工程上最好同时监控:

  • 连接池当前活跃连接数
  • 获取连接等待时间
  • 查询耗时分布
  • 超时和错误率

只盯住连接数,很容易误判。

事务也要尽量短

事务开启之后,连接通常会一直被占住。事务时间一长,池子压力就会明显上来。

async fn create_order(pool: &sqlx::PgPool, user_id: i64) -> anyhow::Result<()> {
    let mut tx = pool.begin().await?;

    sqlx::query("insert into orders(user_id) values($1)")
        .bind(user_id)
        .execute(&mut *tx)
        .await?;

    sqlx::query("update inventory set stock = stock - 1 where sku = $1")
        .bind("sku-001")
        .execute(&mut *tx)
        .await?;

    tx.commit().await?;
    Ok(())
}

这类事务本身没问题,但要避免把下面这些动作也塞进事务里:

  • 调远程 HTTP 服务
  • 做复杂计算
  • 等待外部回调

事务里持连接做这些事情,连接池很快就会变成系统瓶颈。

失败时要不要重试

数据库错误不是都适合自动重试。

适合考虑重试的通常是:

  • 短暂网络抖动
  • 可确认的瞬时连接失败
  • 明确可幂等的读请求

不适合盲目重试的:

  • 已经超时的慢查询
  • 写事务
  • 锁冲突严重的高压场景

如果数据库本来就在过载,重试往往只会让数据库更忙。

一个简单的数据访问层示意

pub struct UserRepository {
    pool: sqlx::PgPool,
}

impl UserRepository {
    pub fn new(pool: sqlx::PgPool) -> Self {
        Self { pool }
    }

    pub async fn find_by_id(&self, user_id: i64) -> anyhow::Result<User> {
        let user = tokio::time::timeout(
            std::time::Duration::from_millis(200),
            sqlx::query_as::<_, User>(
                "select id, name, email from users where id = $1"
            )
            .bind(user_id)
            .fetch_one(&self.pool),
        )
        .await??;

        Ok(user)
    }
}

这种结构不花哨,但至少把两层边界写清楚了:

  • 连接池控制共享资源
  • 查询超时控制单次等待时间

总结

Rust 异步数据库访问的关键,不在于 async/await 写得多漂亮,而在于你有没有尊重数据库这个有限资源。

我更关心这几个问题:

  • 连接池上限是多少
  • 拿连接能等多久
  • 单次查询能跑多久
  • 数据库退化时服务怎么自保

这些问题如果不提前设计,等线上流量上来之后,数据库通常会是最早暴露问题的一层。


后端服务里,数据库常常不是最慢的一层,但很容易成为最先被拖垮的一层。