返回文章列表

Rust 服务到底什么时候该用分布式锁,什么时候不该用

182·2 分钟阅读
Rust微服务

很多并发问题一出现,大家第一反应就是“加个 Redis 锁”。这句话听起来稳,实际经常把问题变得更复杂。

分布式锁不是不能用。它的问题是太容易被滥用。

库存扣减、幂等提交、任务领取、定时任务、缓存回源、支付回调,表面都能套一把锁。但锁本身只回答“此刻谁拿到执行权”,它不回答业务是否幂等、数据库是否有约束、锁过期后正在执行的任务怎么办。

分布式锁是协调工具,不是正确性的根。


能用数据库约束解决的,不要先上锁

重复创建订单这种问题,用锁解决并不优雅。

更硬的防线是唯一约束:

create unique index uniq_order_idempotency
on orders(user_id, idempotency_key);

Rust 侧只需要把结果翻译成业务语义:

#[derive(Debug, PartialEq, Eq)]
enum CreateOrderResult {
    Created,
    AlreadyExists,
}
 
fn map_insert(unique_conflict: bool) -> CreateOrderResult {
    if unique_conflict {
        CreateOrderResult::AlreadyExists
    } else {
        CreateOrderResult::Created
    }
}

这比“先抢锁,再查重,再插入”更可靠。

锁可能过期、可能误释放、可能因为网络抖动让调用方误判。唯一约束不靠进程状态,它直接保护业务不变量。

我的偏好是:唯一性问题优先交给唯一约束,不要交给分布式锁。


分布式锁适合保护“同一时间只跑一个”

分布式锁真正适合的场景,是互斥执行。

比如全局报表生成:

#[derive(Debug)]
struct ReportJob {
    date: String,
}
 
fn lock_key_for_report(job: &ReportJob) -> String {
    format!("lock:report:{}", job.date)
}

同一天的报表不希望多个实例同时跑,因为会浪费资源、重复写文件、冲击数据库。

这类场景的核心不是“数据正确性完全靠锁”,而是“避免重复执行带来的成本”。

如果锁失效导致跑了两次,系统应该仍然能靠幂等写入、文件覆盖策略或任务状态兜住。

这就是边界:锁可以减少重复,不应该成为唯一防线。


Redis 锁必须有 token

最危险的锁释放方式是:

delete lock_key

问题在于:你删掉的可能不是自己的锁。

正确思路是加随机 token。拿锁时写入 token,释放时只删除 token 匹配的锁。

#[derive(Debug, Clone)]
struct LockToken(String);
 
#[derive(Debug)]
struct LockGuard {
    key: String,
    token: LockToken,
}
 
fn owns_lock(guard: &LockGuard, stored: &str) -> bool {
    guard.token.0 == stored
}

真实 Redis 释放要用 Lua 脚本保证“比较 token + 删除”是原子的。

这不是细节洁癖。锁过期后,另一个实例可能已经拿到新锁。如果旧实例执行完成后直接删 key,就会把别人的锁删掉。


TTL 是保护,也是风险

分布式锁必须有 TTL,否则持锁进程崩了,锁会永久卡住。

但 TTL 也会制造另一个问题:任务还没执行完,锁先过期了。

use std::time::Duration;
 
#[derive(Debug)]
struct LockPolicy {
    ttl: Duration,
    renew: bool,
}
 
fn short_job_policy() -> LockPolicy {
    LockPolicy {
        ttl: Duration::from_secs(30),
        renew: false,
    }
}

如果任务耗时可控,TTL 可以略大于预期执行时间。

如果任务耗时不可控,就要考虑续租、任务切片,或者改成数据库任务表领取模式。长时间持有分布式锁,通常说明任务模型需要重新设计。

锁 TTL 不是随便填一个大数字,它是故障恢复和误并发之间的取舍。


任务领取更适合用数据库 lease

后台任务系统里,很多人会用 Redis 锁保护每条任务。

更直接的方式是数据库 lease:

update jobs
set status = 'running',
    locked_until = now() + interval '60 seconds'
where id = $1
  and status = 'pending';

Rust 里可以把它看成领取结果:

#[derive(Debug, PartialEq, Eq)]
enum LeaseResult {
    Acquired,
    AlreadyTaken,
}
 
fn lease_result(rows: u64) -> LeaseResult {
    if rows == 1 {
        LeaseResult::Acquired
    } else {
        LeaseResult::AlreadyTaken
    }
}

任务状态、重试次数、错误原因都在数据库里,领取也让数据库裁决,不需要再额外引入一套锁状态。

Redis 锁适合轻量互斥。任务系统如果已经有任务表,就优先让任务表自己表达并发控制。


Redlock 不是逃避业务幂等的理由

讨论 Redis 分布式锁时,经常会绕到 Redlock。

我的态度比较实用:如果业务正确性完全依赖一个跨节点锁算法,那应该先重新审视业务建模。

比如扣库存:

update stock
set available = available - $1
where sku_id = $2
  and available >= $1;

这条条件更新比“抢到锁再扣库存”更接近业务不变量。

锁算法可以降低并发冲突,但不能替代库存条件更新、流水表、唯一约束和幂等键。

越关键的业务,越不能只靠锁。


结论

分布式锁不是坏东西,坏的是把它当成并发正确性的万能解。

唯一性靠数据库唯一约束;库存余额靠条件更新;任务领取靠状态和 lease;分布式锁只适合降低重复执行成本,不能替代幂等、约束和状态机。

Rust 可以把锁 token、TTL、释放语义封装得很漂亮,但真正决定系统可靠性的,是你有没有把锁放在正确的位置。