Rust 连接 PostgreSQL
Rust 生态连接 PostgreSQL 的三大主角:tokio-postgres(异步原生驱动,API 贴近 libpq,控制力最强)、sqlx(异步全家桶,以编译期 SQL 校验独树一帜)、deadpool-postgres(基于 tokio-postgres 的成熟连接池)。本页按「生态选型 → tokio-postgres 基础与 COPY → sqlx 池与编译期校验 → deadpool 池 → JSONB 与错误处理」推进,最后落到 sqlx 连接池 + 事务的完整实战。
Rust 驱动层的两个核心观念与别的语言不同:所有权参与连接管理——事务对象借用客户端(&mut client),借用检查器从语言层面杜绝「事务中途复用连接」这类其他语言的经典事故;编译期把该做的事做完——sqlx 的 query! 宏在 cargo build 时连接数据库校验 SQL 的列名与类型,SQL 拼写错误、列改名导致的运行时崩溃在编译阶段就被拦截。
异步运行时统一采用 tokio(两套驱动的事实标准);代码基于 tokio-postgres 0.7 与 sqlx 0.7/0.8,API 细节以 docs.rs 各 crate 文档为准。
前置知识
建议先阅读:Node.js 连接 PostgreSQL
基础概念
| 方案 | 定位 | 特点 |
|---|---|---|
| tokio-postgres | 异步原生驱动 | libpq 风格 API、copy_in/copy_out、NOTIFY |
| sqlx | 异步驱动 + 编译期校验 + 轻量 ORM | query! 宏、离线模式、内建池 |
| deadpool-postgres | 连接池 | 基于 tokio-postgres,回收策略可配 |
| tokio | 异步运行时 | 连接推进任务、超时与取消的载体 |
┌───────────────┐
│ axum/actix │──► deadpool-postgres ──► tokio-postgres ──► PostgreSQL
│ (Web 服务) │
└───────────────┘
┌───────────────┐
│ 业务服务 │──► sqlx::PgPool(自带池 + 编译期校验)──► PostgreSQL
└───────────────┘选型速览:
- 要极致控制与 COPY/NOTIFY 等原生能力 → tokio-postgres + deadpool-postgres;
- 要编译期 SQL 校验、内建池、ORM-lite 体验 → sqlx(两者可以共存:核心链路 sqlx,特殊通道 tokio-postgres);
- 两者的参数占位符都是
$1,Row取值都是「编译期泛型推断 + 运行时类型校验」。
Rust 特有的两条心智模型:
- 连接的推进任务:
tokio_postgres::connect返回(Client, Connection)二元组,Connection必须交给独立任务await(它就是网络读写循环)——忘了 spawn 它,所有查询都会挂起; - 事务借用客户端:
client.transaction()返回的Transaction可变借用 client,事务期间无法用同一连接跑别的查询,借用结束(commit/rollback/drop)后归还——这是类型系统在替你把关事务边界。
语法与用法
tokio-postgres 最小可用程序:
use tokio_postgres::NoTls;
#[tokio::main]
async fn main() -> Result<(), tokio_postgres::Error> {
let (client, connection) = tokio_postgres::connect(
"host=db1 user=app_rw password=secret dbname=appdb sslmode=require",
NoTls,
)
.await?;
// Connection 必须在独立任务里推进,否则查询永远挂起
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("connection error: {e}");
}
});
let rows = client
.query("SELECT id, name FROM users WHERE status = $1", &[&"active"])
.await?;
for row in rows {
let id: i64 = row.get(0); // 按下标
let name: &str = row.get("name"); // 按列名
println!("{id}: {name}");
}
Ok(())
}sqlx 连接池:
use sqlx::postgres::PgPoolOptions;
let pool = PgPoolOptions::new()
.max_connections(20)
.acquire_timeout(std::time::Duration::from_secs(3))
.connect("postgres://app_rw:secret@db1:5432/appdb")
.await?;
let user: Option<(i64, String)> = sqlx::query_as(
"SELECT id, name FROM users WHERE id = $1",
)
.bind(42)
.fetch_optional(&pool)
.await?;事务(sqlx):
let mut tx = pool.begin().await?;
sqlx::query("UPDATE accounts SET balance = balance - $1 WHERE id = $2")
.bind(100i64).bind(from_id)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE accounts SET balance = balance + $1 WHERE id = $2")
.bind(100i64).bind(to_id)
.execute(&mut *tx)
.await?;
tx.commit().await?; // 不 commit(含提前 return/panic)则 drop 时回滚COPY(tokio-postgres):
use futures::SinkExt;
let mut sink = client
.copy_in("COPY stg_items FROM STDIN WITH (FORMAT csv, HEADER true)")
.await?;
sink.send(bytes.as_slice()).await?;
let copied: u64 = sink.finish().await?;详细说明
生态地图:三件套如何组合
tokio-postgres 是「底层引擎」:连接串解析、协议实现、COPY、NOTIFY 都在这里。sqlx 是「自成一体的上层」:它自带 PostgreSQL 协议实现(不依赖 tokio-postgres),外加连接池、查询宏与 FromRow 映射。deadpool-postgres 是「tokio-postgres 的池化外壳」。
方案 A:deadpool-postgres ──► tokio-postgres ──► PostgreSQL
方案 B:sqlx::PgPool ──────────────────────────► PostgreSQL(独立实现)工程上的常见组合:Web 服务用 sqlx 一站式(池、宏校验、事务);数据管道/ETL 用 tokio-postgres(copy_in/copy_out 更顺);大型服务混用(sqlx 管业务查询,tokio-postgres 管特殊通道)。注意混用时两套错误类型、两套池各自独立管理。
tokio-postgres:连接、NoTls 与连接串
tokio_postgres::connect 接受 libpq 风格连接串,TLS 用 NoTls 起步(需要证书校验时用 tokio-postgres-rustls 等社区 TLS 实现,以官方仓库说明为准):
let (client, connection) = tokio_postgres::connect(
"host=db1 port=5432 dbname=appdb user=app_rw password=secret \
sslmode=require connect_timeout=5 application_name=svc-orders",
NoTls,
).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("connection error: {e}");
}
});关键细节再强调一次:connection 必须被 await。它承担全部网络 I/O,通常 tokio::spawn 进后台任务并在错误时打日志。单连接 Client 的查询串行执行——并发查询请上池(deadpool)或多连接。
tokio-postgres:query/query_one/execute 与 Row 取值
// query:返回 Vec<Row>
let rows = client.query("SELECT id, name FROM users WHERE age > $1", &[&18]).await?;
// query_one:恰好一行,否则报错
let row = client.query_one("SELECT id, name FROM users WHERE id = $1", &[&42]).await?;
// query_opt:0 或 1 行,返回 Option<Row>
let maybe = client.query_opt("SELECT id FROM users WHERE email = $1", &[&email]).await?;
// execute:返回受影响行数
let n = client.execute("DELETE FROM sessions WHERE expired_at < now()", &[]).await?;
println!("deleted {n} rows");Row::get 的类型由调用侧标注决定,运行时校验类型匹配(不匹配 panic):
let id: i64 = row.get(0); // bigint
let name: String = row.get("name"); // text(取所有权)
let name: &str = row.get("name"); // text(借用,行还在时有效)
let amount: rust_decimal::Decimal = row.get(2); // numeric(配 decimal crate 适配)
let ts: chrono::DateTime<chrono::Utc> = row.get(3); // timestamptz(配 chrono 特性)i64 对应 int8、f64 对应 float8、bool 对应 bool 是内建映射;numeric/timestamp/uuid/jsonb 等需要启用对应 feature(chrono/uuid/serde_json 等)或使用类型适配 crate。取值类型与列类型不符会在运行时 panic——编译期校验的需求正是 sqlx 宏的价值所在。
tokio-postgres:事务 transaction()
// transaction() 可变借用 client,事务期间独占
let tx = client.transaction().await?;
tx.execute("UPDATE accounts SET balance = balance - $1 WHERE id = $2",
&[&100, &from]).await?;
tx.execute("UPDATE accounts SET balance = balance + $1 WHERE id = $2",
&[&100, &to]).await?;
tx.commit().await?;
// 或 tx.rollback().await?;
// 两者都没调时:Transaction drop 自动触发回滚savepoint 同样是事务对象上的方法(tx.savepoint()),嵌套事务通过在新事务对象上继续操作表达。借用模型的含义:编译期就不可能出现「事务未收尾就复用连接」——这把其他语言靠纪律保证的事,变成了类型系统的保证。
tokio-postgres:copy_in / copy_out 批量通道
copy_in 返回 CopyInSink,实现 Sink<&[u8]>(需要 futures 的 SinkExt 提供 send),另有 finish() 收尾并返回拷贝行数:
use futures::SinkExt;
// 从内存/文件逐块发送
let mut sink = client
.copy_in("COPY stg_events (event_id, user_id, kind, event_time) \
FROM STDIN WITH (FORMAT csv, HEADER true)")
.await?;
for chunk in csv_chunks { // BytesMut / Vec<u8> 均可
sink.send(chunk.as_slice()).await?;
}
let copied = sink.finish().await?;
println!("copied {copied} rows");CopyInSink 还提供 writer() 把自身转成 AsyncWrite,可以无缝接 tokio::io::copy 与文件流(签名细节以 docs.rs 为准):
let mut writer = client
.copy_in("COPY stg_items FROM STDIN WITH (FORMAT csv, HEADER true)")
.await?
.writer();
let mut file = tokio::fs::File::open("items.csv").await?;
tokio::io::copy(&mut file, &mut writer).await?;copy_out 反向导出,实现为 AsyncRead 流。COPY 的一致性与性能语义与其他语言一致:单事务原子、最快写入通道、失败整批回滚、按行数切片分批。
sqlx:PgPoolOptions 与运行时查询
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(4)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(300))
.max_lifetime(Duration::from_secs(1800))
.connect(&url)
.await?;运行时查询 API 的四个入口:
// execute:返回 QueryResult(rows_affected)
sqlx::query("INSERT INTO items (id, name) VALUES ($1, $2)")
.bind(1i64).bind("alpha")
.execute(&pool).await?;
// fetch_one / fetch_optional / fetch_all
let row: (i64, String) = sqlx::query_as("SELECT id, name FROM users WHERE id = $1")
.bind(42).fetch_one(&pool).await?;
let users: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM users")
.fetch_all(&pool).await?;query_as 的目标类型实现 FromRow(#[derive(sqlx::FromRow)] 即可),列名与字段名自动映射;bind 顺序对应 $1、$2,类型经由 Encode trait 校验。
sqlx:编译期校验宏 query!/query_as!
sqlx 的招牌能力——cargo build 时连接数据库解析 SQL,把列类型直接编进代码:
// query!:列名与类型在编译期已知
let row = sqlx::query!(
"SELECT id, name, amount FROM orders WHERE id = $1",
order_id
)
.fetch_one(&pool)
.await?;
println!("{} {} {}", row.id, row.name, row.amount); // 字段访问,类型精确// query_as!:映射到自定义结构体
#[derive(Debug)]
struct OrderSummary {
id: i64,
total: rust_decimal::Decimal,
}
let rows = sqlx::query_as!(
OrderSummary,
r#"SELECT id, sum(amount) AS "total!" FROM orders GROUP BY id"#
)
.fetch_all(&pool)
.await?;宏的工程机制:构建时读取 DATABASE_URL(或环境变量 SQLX_OFFLINE=true 使用离线缓存)连库校验;离线缓存由 cargo sqlx prepare(sqlx-cli)生成(sqlx 0.7+ 存放在项目 .sqlx 目录)。CI 里 SQLX_OFFLINE=true 后,构建不再需要真实数据库。两个实用标注:
AS "col!":声明列非空(服务端校验不到 NOT NULL 表达式列时);AS "col?":声明列可空(如聚合结果)。
代价是 SQL 与 schema 变更会触发重编译,团队要接受「改 SQL 就要重新 prepare」的流程。
deadpool-postgres:tokio-postgres 的池
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod, Runtime};
let mut pg_config = tokio_postgres::Config::new();
pg_config.host = Some("db1".into());
pg_config.user = Some("app_rw".into());
pg_config.password = Some("secret".into());
pg_config.dbname = Some("appdb".into());
let mgr_config = ManagerConfig {
recycling_method: RecyclingMethod::Verified, // 取回连接时校验活性
};
let mgr = Manager::from_config(pg_config, tokio_postgres::NoTls, mgr_config);
let pool = Pool::builder(mgr).max_size(20).build()?;
// 使用:get 出来的是包装对象,Deref 到 tokio_postgres::Client
let client = pool.get().await?;
let rows = client.query("SELECT count(*) FROM users", &[]).await?;RecyclingMethod::Fast(默认,直接复用)与 Verified(复用前跑一次轻量查询)的取舍是本池的核心配置:数据库会主动断开空闲连接的环境(防火墙、PgBouncer idle timeout)用 Verified 防止拿到死连接。pool.get() 超时、max_size 总账等容量语义与前面各语言一致。
JSONB:serde_json::Value 直通
两套驱动的 JSONB 都能直通 serde_json::Value:
// sqlx:开启 "json" 特性后,Value 可直接 bind/取值
let payload = serde_json::json!({"kind": "click", "n": 3});
sqlx::query("INSERT INTO events (payload) VALUES ($1)")
.bind(&payload)
.execute(&pool).await?;
let (val,): (serde_json::Value,) = sqlx::query_as(
"SELECT payload FROM events WHERE id = $1",
).bind(1).fetch_one(&pool).await?;
// tokio-postgres:启用 "with-serde_json-1" 特性后同样直通
let row = client.query_one("SELECT payload FROM events WHERE id = $1", &[&1]).await?;
let val: serde_json::Value = row.get(0);读取目标也可以是自定义结构体(serde::Deserialize),让数据库 JSONB 与 Rust 类型系统直连。写入前 serde_json::to_vec 手动序列化再传 Vec<u8> 的旧式写法在启用特性后不再必要。
常用类型映射速查
| PostgreSQL 类型 | Rust 类型(需 feature) |
|---|---|
int8 / int4 | i64 / i32 |
text / varchar | String / &str |
bool | bool |
float8 | f64 |
numeric | rust_decimal::Decimal |
timestamptz | chrono::DateTime<Utc>(chrono feature) |
uuid | uuid::Uuid(uuid feature) |
jsonb | serde_json::Value(serde_json feature) |
bytea | Vec<u8> |
text[] | Vec<String> |
两套驱动的类型开关位置不同:sqlx 靠 Cargo feature(chrono、uuid、json、rust_decimal 等),tokio-postgres 靠 with-* 特性(如 with-serde_json-1、with-chrono-0_4)。特性没开时编译期就报「type not implemented」——这是 Rust 生态「编译期发现类型缺口」的又一例。
错误处理:SQLSTATE 的 Rust 表达
tokio-postgres 的 Error::code() 返回 Option<&SqlState>,内置常量枚举覆盖全部 SQLSTATE:
use tokio_postgres::error::SqlState;
match client.execute("INSERT INTO users (email) VALUES ($1)", &[&email]).await {
Err(e) if e.code() == Some(&SqlState::UNIQUE_VIOLATION) => {
return Err(AppError::EmailTaken); // 23505
}
Err(e) if e.code() == Some(&SqlState::FOREIGN_KEY_VIOLATION) => {
return Err(AppError::RefMissing); // 23503
}
Err(e) if e.code() == Some(&SqlState::T_R_SERIALIZATION_FAILURE)
|| e.code() == Some(&SqlState::T_R_DEADLOCK_DETECTED) => {
return Err(AppError::Retryable); // 40001 / 40P01
}
Err(e) => return Err(e.into()),
Ok(_) => {}
}sqlx 侧用 DatabaseError::code():
if let Err(e) = query.execute(&pool).await {
if let Some(db_err) = e.as_database_error() {
if db_err.code().as_deref() == Some("23505") {
return Err(AppError::EmailTaken);
}
}
return Err(e.into());
}两套 API 都是「错误对象 → SQLSTATE → 业务错误」的映射套路,db_err.constraint()(约束名)、db_err.message() 提供更细的定位信息。
实战示例
示例一:tokio-postgres 基础 CRUD 与事务
async fn transfer(
client: &tokio_postgres::Client,
from: i64, to: i64, amount: i64,
) -> Result<(), Box<dyn std::error::Error>> {
let tx = client.transaction().await?;
let n = tx.execute(
"UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1",
&[&amount, &from],
).await?;
if n == 0 {
tx.rollback().await?;
return Err("insufficient balance".into());
}
tx.execute(
"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
&[&amount, &to],
).await?;
tx.commit().await?;
Ok(())
}示例二:tokio-postgres copy_in 流式导入
use futures::SinkExt;
use tokio::io::AsyncReadExt;
async fn import_csv(
client: &tokio_postgres::Client,
path: &str,
) -> Result<u64, Box<dyn std::error::Error>> {
let mut file = tokio::fs::File::open(path).await?;
let mut buf = vec![0u8; 64 * 1024];
let mut sink = client
.copy_in("COPY stg_orders FROM STDIN WITH (FORMAT csv, HEADER true)")
.await?;
loop {
let n = file.read(&mut buf).await?;
if n == 0 { break; }
sink.send(&buf[..n]).await?;
}
Ok(sink.finish().await?)
}示例三:sqlx 编译期校验查询
#[derive(Debug, sqlx::FromRow)]
struct UserRow {
id: i64,
name: String,
email: Option<String>,
}
async fn active_users(pool: &sqlx::PgPool) -> Result<Vec<UserRow>, sqlx::Error> {
sqlx::query_as::<_, UserRow>(
"SELECT id, name, email FROM users WHERE status = $1 ORDER BY id",
)
.bind("active")
.fetch_all(pool)
.await
}示例四:sqlx 连接池 + 事务(实战完整形态)
use sqlx::postgres::PgPoolOptions;
use std::time::Duration;
#[derive(Clone)]
pub struct Db(sqlx::PgPool);
impl Db {
pub async fn new(url: &str) -> Result<Self, sqlx::Error> {
let pool = PgPoolOptions::new()
.max_connections(20)
.min_connections(4)
.acquire_timeout(Duration::from_secs(3))
.idle_timeout(Duration::from_secs(300))
.max_lifetime(Duration::from_secs(1800))
.connect(url)
.await?;
Ok(Self(pool))
}
pub async fn archive_orders(&self, before: chrono::DateTime<chrono::Utc>)
-> Result<u64, ArchiveError>
{
let mut tx = self.0.begin().await?;
// 宏查询:列与类型编译期校验
let moved = sqlx::query!(
r#"WITH moved AS (
DELETE FROM orders
WHERE created_at < $1 AND archived = false
RETURNING id, user_id, amount, created_at
)
INSERT INTO orders_archive (id, user_id, amount, created_at)
SELECT id, user_id, amount, created_at FROM moved
RETURNING id"#,
before
)
.fetch_all(&mut *tx)
.await?;
sqlx::query!("UPDATE orders SET archived = true WHERE created_at < $1", before)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(moved.len() as u64)
}
}要点:&mut *tx 把事务句柄以「独占借用」传入每条查询;commit 之前任何 ? 提前返回都会 drop 事务并自动回滚;fetch_all 收集 RETURNING 结果用于统计。
示例五:deadpool-postgres 池化服务层
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
fn build_pool() -> Pool {
let mut cfg = tokio_postgres::Config::new();
cfg.host = Some("db1".into());
cfg.dbname = Some("appdb".into());
cfg.user = Some("app_rw".into());
cfg.password = Some("secret".into());
let mgr = Manager::from_config(
cfg,
tokio_postgres::NoTls,
ManagerConfig { recycling_method: RecyclingMethod::Verified },
);
Pool::builder(mgr).max_size(20).build().unwrap()
}注意事项
Connection 必须被 await(spawn 推进)。 tokio_postgres::connect 返回的 connection 是网络循环本体,忘了 tokio::spawn 它会让所有查询永久挂起——这是 tokio-postgres 的第一坑。
Row::get 类型不匹配是运行时 panic。 按列标注取值类型的正确性由开发者负责;把这类校验前置到编译期的诉求正是 sqlx 宏存在的理由,混合团队建议业务查询一律走宏。
事务借用连接,事务内无法复用连接。 &mut client 的独占借用是安全保证;需要「事务中并行查询」的设计在 PostgreSQL 单连接语义下本就不成立,用 savepoint 或拆事务表达。
copy_in/copy_out 需要 futures 的 Sink/Stream 扩展。 sink.send 来自 futures::SinkExt;copy_out 的读取用 AsyncReadExt。COPY 单事务原子,失败整批回滚。
sqlx 宏依赖离线缓存的维护纪律。 cargo sqlx prepare 的产物(.sqlx)要进版本库并在 schema 变更后更新,否则 CI 的 SQLX_OFFLINE=true 构建会校验失败;这个流程要写进团队工作流。
deadpool 的回收策略决定死连接概率。 有 PgBouncer/防火墙空闲断连的环境用 RecyclingMethod::Verified;纯内网长连接可用默认 Fast。
numeric 与 timestamp 需要类型生态支持。 rust_decimal(numeric)、chrono/time(时间)、uuid、serde_json 都靠驱动 feature 开启,特性没开时的编译错误信息直白但容易遗漏。
连接超时三层防护照常适用。 acquire_timeout(池借出)、connect_timeout(建连)、服务端 statement_timeout(语句),Rust 侧没有隐式默认超时,不配就是无限等待。
两套驱动的 feature 清单要显式核对。 时间/数值/JSON 的类型支持都靠 feature 开启,升级依赖时 feature 名变化(如 with-chrono-0_4)会让编译错误排查变长;把 feature 清单固化在 Cargo.toml 注释或团队文档里。
tokio-postgres 原生没有自动重连。 连接断开就是断开(Connection 返回错误、池里的 deadpool 靠回收策略重建);服务要有「连接失效后重建池或退出交由进程管理器重启」的明确策略,别指望驱动自动复活。
优雅停机要排空在途事务。 SIGTERM 直接退出会让服务器回滚未决事务、掐断 COPY;先停止接新请求、等待在途事务完成(设兜底超时),再让连接自然关闭,比事后补偿便宜。
运行时拼接的 SQL 不享受宏校验。 query! 的编译期保证只覆盖写死的 SQL 文本;动态 SQL 改用 sqlx::query_as::<_, T>(&sql) 时类型校验责任回到开发者,这类函数要重点 review。
NOTIFY 载荷上限 8000 字节。 超长载荷直接报错;通知里只传 ID 与事件类型、订阅方回查详情是跨语言一致的标准做法。
编译期校验依赖的 schema 快照会过期。 .sqlx 缓存对的是「生成那一刻」的库结构,schema 迁移后忘更新缓存,CI 校验通过但运行时的库已经变了——prepare 与迁移放在同一条流水线里是根治法。
最佳实践
- 业务服务首选 sqlx 一站式;ETL/COPY/NOTIFY 通道用 tokio-postgres。 两套池不要同时管同一个库的连接总量,先做连接总账。
- 连接参数显式声明 sslmode、connect_timeout、application_name。 与其他语言完全一致的四件套纪律。
- tokio-postgres 的 connection 永远 spawn 推进并记录错误日志。 把这一步固化成项目模板。
- 事务用「借用 + drop 自动回滚」语义写防御代码。 不 commit 的路径自然回滚,显式 rollback 只用于需要区分错误语义的场合。
- sqlx 宏覆盖常规查询,运行时 API 只留动态 SQL。
query!的编译期校验是团队最重要的防回归资产。 - 离线模式进 CI:SQLX_OFFLINE=true + .sqlx 入库。 构建不依赖真实数据库,同时强制 schema 变更同步 prepare。
- 批量写入十万行以上一律 COPY。
copy_in流式分块发送,配合摘索引、导后重建、ANALYZE 的通用清单。 - 错误处理按 SqlState/SQLSTATE 映射领域错误。 23505/23503/40001/40P01 的分支写进统一错误层,调用方只见业务错误。
- JSONB 直通 serde_json::Value 或自定义结构体。 开启对应 feature,杜绝手工序列化字符串。
- 池参数与 PostgreSQL 侧对账。 max_connections × 实例数 ≤ 服务端余量;多实例用 PgBouncer 收敛并收小应用池。
- 金额 numeric + rust_decimal,时间 timestamptz + chrono/time。 类型生态的对应关系写进项目依赖规范。
- 给每个服务独立 application_name。 pg_stat_activity 溯源在 Rust 服务同样是一排障利器。
- 把「重试哪些 SQLSTATE」写进统一的错误层。 40001/40P01 带退避重试、其余向上抛,重试逻辑集中在最外层而不是散在每个 handler。
下一节
继续学习:分页模式