多个服务如何避免同时做同一件事
在单机程序里,多个 goroutine 同时修改一份数据时,可以用 sync.Mutex 加锁。
var mu sync.Mutex
func updateStock() {
mu.Lock()
defer mu.Unlock()
// 修改库存
}但如果服务部署了多个实例,例如两台机器都运行着同一个 Go 服务,sync.Mutex 就不够用了。因为每个进程都有自己独立的内存和锁,A 服务加锁后,B 服务并不知道。
这时就需要一个所有服务都能访问到的“公共锁”。Redis 分布式锁就是一种常见方案。
什么是 Redis 锁
Redis 锁的核心思路很简单:
谁先在 Redis 中成功写入一个锁标识,谁就获得锁;
没获得锁的请求等待、重试,或者直接返回“操作频繁”;
持有锁的服务完成任务后删除这个锁;
锁需要设置过期时间,避免服务异常时锁永远不释放。
例如用户抢购商品时,多个请求可能同时扣减库存。为了避免超卖,可以针对某个商品加锁:
lock:product:1001拿到锁的请求才能继续扣库存,其他请求暂时不能进入这段逻辑。
最基础的 Redis 锁
Redis 中常用下面这条命令加锁:
SET lock:product:1001 unique-value NX EX 30含义是:
lock:product:1001:锁的 key;unique-value:锁的唯一标识;NX:只有 key 不存在时才写入;EX 30:锁 30 秒后自动过期。
如果命令返回 OK,说明加锁成功;如果返回空值,说明锁已经被其他服务持有。
这里的 NX 和 EX 必须放在同一条命令中。这样“判断锁是否存在、创建锁、设置过期时间”是原子操作,可以避免并发时出现两个服务同时获得锁,或者锁创建后还没来得及设置过期时间就发生宕机。
为什么锁的值必须唯一
如果这样写:
SET lock:product:1001 locked NX EX 30加锁本身可以正常工作,但释放锁时可能出问题。
设想下面的情况:
服务 A 获得锁,锁值为
locked,过期时间 30 秒;服务 A 执行太久,30 秒后锁自动过期;
服务 B 获得了同一个锁,锁值也是
locked;服务 A 终于执行完成,执行
DEL lock:product:1001;服务 A 删除了服务 B 的锁。
这样服务 B 虽然还在处理业务,却失去了锁,其他请求可能同时进入临界区。
因此,锁的 value 应该是每一次获取锁时生成的唯一值,例如 UUID:
lock:product:1001 = 550e8400-e29b-41d4-a716-446655440000释放锁时,必须先确认 Redis 中的 value 仍然是自己的,再删除。
为什么要使用 Lua 脚本
如果把“比较 value”和“删除 key”拆成两步,也会有并发风险:
1. GET lock:product:1001
2. 判断 value 是否是自己的
3. DEL lock:product:1001问题在于:执行完第 2 步后,锁可能刚好过期并被其他服务重新获取。此时再执行第 3 步,仍然可能删掉别人的锁。
因此,需要让“判断 + 删除”成为原子操作。Redis 的 Lua 脚本可以做到这一点:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end它的意思是:只有当锁的 value 和当前服务持有的唯一标识相同时,才删除锁。
锁过期时间该设置多久
锁的过期时间不能随便设置。
如果设置太短,业务还没完成,锁就过期了,其他服务可能进入临界区;如果设置太长,服务异常后其他请求要等待很久。
一个简单做法是,根据正常业务耗时设置一个略长的过期时间。例如业务通常在 1 秒内完成,可以设置 10 秒或 30 秒。
但对于耗时不确定的任务,例如大文件处理、批量同步、复杂报表生成,仅靠固定过期时间并不可靠。此时可以考虑:
拆分任务,让临界区尽可能短;
使用续期机制,在任务仍在正常执行时延长锁;
使用成熟的分布式锁实现,例如 Redisson 的 watchdog 机制;
重新评估是否真的需要用锁,能否通过数据库唯一约束、事务或消息队列保证一致性。
所以Redis 锁不是万能的。它适合控制并发进入某段逻辑,但库存、订单等核心数据的一致性,最终仍应由数据库事务、条件更新或唯一约束兜底。
总结
我对 Redis 分布式锁的理解可以概括为:
Redis 锁解决的是“多台机器、多个进程如何协调执行”的问题,但正确实现比“写一个 key”复杂得多。
一个相对可靠的 Redis 锁至少应该做到:
用
SET key value NX EX原子加锁;每次加锁使用唯一 token;
用 Lua 脚本原子地校验并释放锁;
设置合理的过期时间;
对核心数据继续依赖数据库事务、唯一约束或条件更新兜底。
真正做项目时,能不用分布式锁就不要滥用;但当多个服务确实需要协调访问同一份资源时,理解这些细节能避免很多隐蔽的并发问题