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、释放语义封装得很漂亮,但真正决定系统可靠性的,是你有没有把锁放在正确的位置。