背景
Rust 写后端服务时,数据库访问通常是绕不开的一层。
很多人刚开始用 sqlx 或 tokio-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 和连接池耗尽经常一起出现
线上一个很常见的现象是:
- 某类查询变慢
- 单个请求占连接时间变长
- 连接池越来越满
- 新请求拿不到连接
- 服务整体延迟全面上升
所以连接池问题,很多时候并不是“池子太小”,而是后面那层 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 写得多漂亮,而在于你有没有尊重数据库这个有限资源。
我更关心这几个问题:
- 连接池上限是多少
- 拿连接能等多久
- 单次查询能跑多久
- 数据库退化时服务怎么自保
这些问题如果不提前设计,等线上流量上来之后,数据库通常会是最早暴露问题的一层。
后端服务里,数据库常常不是最慢的一层,但很容易成为最先被拖垮的一层。