Rust 后端别只会写事务,先想清楚隔离级别
transaction().await?很容易写,难的是知道这段事务到底挡住了什么,又挡不住什么。
Rust 后端项目里,数据库事务经常被当成一个“安全按钮”。代码包进事务,心里就踏实了:
begin
查库存
创建订单
扣库存
commit但事务不等于串行执行,READ COMMITTED、REPEATABLE READ、SERIALIZABLE 也不是性能开关那么简单。很多并发 bug 的根源不是“没开事务”,而是开了事务,却不知道当前隔离级别允许什么现象发生。
这篇不讲 SQL 教科书。只讲 Rust 微服务里最常遇到的几个边界:库存、余额、审批、唯一约束,以及什么时候该用锁,什么时候该用条件更新。
事务不是锁住整个世界
看一个最常见的库存判断:
#[derive(Debug)]
struct Stock {
sku_id: u64,
available: i64,
}
fn can_reserve(stock: &Stock, count: i64) -> bool {
stock.available >= count
}这段函数没问题,问题在它经常被放进错误的流程:
begin
select available from stock where sku_id = 1
if available >= count
update stock set available = available - count
commit如果两个事务同时读到 available = 10,各自都想扣 8,单看代码都通过了校验。事务能不能挡住超卖,取决于后面的 update 怎么写、数据库隔离级别是什么、有没有行锁或条件约束。
所以我不喜欢把“用了事务”当结论。更应该问:
这段事务保护的是哪一行、哪个条件、哪条不变量?
条件更新比“先查再改”更硬
库存扣减的保守写法,是把业务条件放进 update 本身:
update stock
set available = available - $1
where sku_id = $2
and available >= $1;然后看影响行数:
#[derive(Debug, PartialEq, Eq)]
enum ReserveResult {
Reserved,
NotEnoughStock,
}
fn reserve_result(rows_affected: u64) -> ReserveResult {
if rows_affected == 1 {
ReserveResult::Reserved
} else {
ReserveResult::NotEnoughStock
}
}这个模式的好处是:判断和修改在数据库里变成一个原子动作。
你可以在事务里做更多事情,但库存这条不变量不再依赖“我刚才读到的值还有效”。
我个人更偏好这种写法,因为它比 select -> if -> update 更接近业务不变量本身。代码读起来少了一点“过程感”,但并发下更可靠。
READ COMMITTED 能挡住脏读,挡不住很多业务误判
不少数据库默认是 READ COMMITTED。它的直觉含义是:一个事务只能读到别人已经提交的数据。
听起来够用了,但它挡不住这种场景:
事务 A 读取用户余额:100
事务 B 扣减余额:100 -> 20 并提交
事务 A 按旧余额继续判断:可以下单如果 A 后续也是条件更新,问题不大;如果 A 把旧值带回应用层做复杂判断,风险就来了。
Rust 代码里最容易出现这种“旧快照幻觉”:
#[derive(Debug)]
struct Account {
id: u64,
balance: i64,
}
fn can_pay(account: &Account, amount: i64) -> bool {
account.balance >= amount
}函数本身很清楚,但它没有表达一个关键事实:这个 balance 是什么时候读到的?读完之后有没有人改过?
凡是会影响钱、库存、资格、名额的判断,不要只在 Rust 内存里完成。
内存里的判断适合做提前失败,数据库里的约束或条件更新才是裁判。
SELECT FOR UPDATE 适合短事务,不适合慢业务
有时候你确实需要锁住一行,比如状态机流转:
select id, status
from payments
where id = $1
for update;锁住后再判断状态:
#[derive(Debug, PartialEq, Eq)]
enum PaymentStatus {
Created,
Paying,
Paid,
Failed,
}
fn can_mark_paid(status: &PaymentStatus) -> bool {
matches!(status, PaymentStatus::Paying)
}这类锁适合保护短小的状态变更:
读取当前状态
判断能否流转
写入新状态
提交不要在持锁期间调用外部 HTTP、发 MQ、做复杂计算。锁持有时间越长,数据库就越像一个全局排队器。
我对 FOR UPDATE 的判断是:它可以让状态机变简单,但它要求事务足够短。
一旦事务里混入网络 I/O,锁等待、连接池耗尽、超时重试会连在一起,后面会很难收拾。
唯一约束比“查重代码”可靠
创建用户、提交订单、发放奖励时,很多代码会这样写:
select exists
if not exists
insert这个流程在并发下不可靠。两个事务可能同时看到“不存在”,然后一起插入。
正确的防线应该是唯一约束:
create unique index uniq_user_email on users(email);
create unique index uniq_order_idempotency on orders(user_id, idempotency_key);应用层要做的是把数据库错误翻译成业务结果:
#[derive(Debug, PartialEq, Eq)]
enum CreateUserResult {
Created,
EmailAlreadyExists,
}
fn map_unique_violation(constraint: &str) -> Option<CreateUserResult> {
match constraint {
"uniq_user_email" => Some(CreateUserResult::EmailAlreadyExists),
_ => None,
}
}这比在代码里加一堆分布式锁更朴素,也更好维护。
唯一约束不是数据库细节,它是业务规则的一部分。
SERIALIZABLE 不是免费午餐
SERIALIZABLE 很诱人:既然它最严格,那是不是直接全开就好了?
不建议。
严格隔离级别会带来更多冲突和重试。你的代码必须能处理“事务因为并发冲突被数据库拒绝”的情况。
#[derive(Debug, PartialEq, Eq)]
enum TxError {
SerializationFailure,
Deadlock,
Other,
}
fn should_retry_tx(err: &TxError) -> bool {
matches!(err, TxError::SerializationFailure | TxError::Deadlock)
}如果业务没有幂等键,没有重试预算,没有清晰的错误分类,直接上 SERIALIZABLE 只会把问题从“数据偶尔不对”变成“请求偶尔失败且没人知道怎么重试”。
我的偏好是:
- 普通写入用默认隔离级别,加唯一约束和条件更新
- 状态机短事务用
FOR UPDATE - 跨多行复杂不变量,再考虑
SERIALIZABLE - 一旦使用严格隔离,应用层必须有事务重试策略
事务边界不要跨服务
微服务里最危险的冲动,是想把多个服务调用包进一个“大事务”:
订单服务 begin
调用库存服务
调用优惠券服务
调用支付服务
订单服务 commit这不是事务,这是把数据库连接、网络调用和分布式失败模式绑在一起。
跨服务一致性更适合用状态机、Outbox、补偿任务来处理。每个服务维护自己的本地事务,服务之间用事件或命令传递进度。
本地事务解决本地不变量,跨服务流程解决业务状态。
这个边界不划清,Rust 写得再漂亮也没用。
结论
事务不是“写在代码外面的一层保险”。它真正保护的是业务不变量:余额不能扣成负数,库存不能超卖,同一个幂等键不能创建两笔订单,状态不能从 Failed 跳到 Paid。
能用唯一约束表达的规则,就交给唯一约束;能用条件更新表达的并发判断,就不要拆成先查再改;需要锁时让事务足够短;跨服务一致性不要伪装成本地事务。
Rust 负责把边界写清楚,数据库负责裁决并发。