返回文章列表

Rust 后端别只会写事务,先想清楚隔离级别

254·2 分钟阅读
Rust微服务

transaction().await? 很容易写,难的是知道这段事务到底挡住了什么,又挡不住什么。

Rust 后端项目里,数据库事务经常被当成一个“安全按钮”。代码包进事务,心里就踏实了:

begin
查库存
创建订单
扣库存
commit

但事务不等于串行执行,READ COMMITTEDREPEATABLE READSERIALIZABLE 也不是性能开关那么简单。很多并发 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 负责把边界写清楚,数据库负责裁决并发。