分布式锁方案:原子操作与分布式锁实践

111 阅读10分钟

在高并发场景中,Redis 作为常用缓存和存储中间件,需解决 “多个请求同时操作数据” 的安全问题 —— 比如秒杀库存扣减、订单状态更新,若操作不原子,易出现超卖、数据不一致等问题。本文将从 Redis 原子操作原理出发,逐步拆解分布式锁的实现、风险与优化方案。

一、Redis 原子操作:并发安全的基础

Redis 保证并发安全的核心是 “原子性”—— 要么操作全执行,要么全不执行,不存在中间状态。实现原子操作主要有两种方式:

1. 单命令原子性

Redis 的单个命令本身就是原子操作,无需额外处理。比如incr(自增)、setnx(不存在则设置),即使多个客户端同时调用,Redis 也会串行执行,避免数据竞争。

场景示例:用incr user:id生成全局唯一 ID,无需担心多个请求生成重复 ID。

2. Lua 脚本原子性

若业务需要多个命令组合执行(如 “判断 + 修改 + 删除”),可将这些命令写入 Lua 脚本。Redis 会将整个 Lua 脚本当作一个原子操作执行,期间不中断、不插入其他请求。

优势:避免多命令执行时的 “中间态” 问题,比如 “先查库存是否足够,再扣减库存”,用 Lua 脚本可确保这两步连续执行,不会被其他请求打断。

二、基于单 Redis 节点的分布式锁

当多个服务(或进程)需要竞争同一资源(如共享库存)时,需用分布式锁协调 —— 同一时间只有一个服务能持有锁,操作资源。基于单 Redis 节点的分布式锁,核心依赖setnx、expire、delete三个命令。

1. 基础实现步骤

  • 加锁:setnx lock:key client-id(client-id为客户端唯一标识,如 UUID)。
    • 若返回 1:加锁成功,说明当前无其他客户端持有锁;
    • 若返回 0:加锁失败,需等待或重试。
  • 设超时:expire lock:key 30(给锁设 30 秒超时,避免客户端异常时锁无法释放)。
  • 释放锁:delete lock:key(业务执行完后,删除锁,让其他客户端可竞争)。

2. 核心风险与解决办法

分布式锁的实现若不考虑细节,易出现 “死锁”“误释放” 等问题,需针对性优化:

(1)避免死锁:必须设超时

若客户端加锁后(setnx成功),因代码异常、机器宕机等未执行delete,锁会一直被持有,导致其他客户端无法加锁 ——解决办法:加锁后立即用expire设超时,超时后 Redis 自动删除锁,释放资源。

(2)避免误释放:用客户端唯一标识

若客户端 A 持有锁,客户端 B 误执行delete lock:key,会释放 A 的锁,导致 A 和后续加锁的客户端 C 同时操作资源 ——解决办法:加锁时存入client-id(如setnx lock:key "clientA:123"),释放前先判断锁的持有者是否为自己,确保 “谁加锁谁释放”。

(3)释放锁原子性:用 Lua 脚本

“判断锁持有者 + 删除锁” 是两个命令,若分开执行,可能出现 “判断是自己的锁,但执行delete前锁已超时被自动释放,此时其他客户端已加锁” 的情况 ——解决办法:用 Lua 脚本将两步合并为原子操作:

-- 释放锁的Lua脚本:仅当锁的value是当前客户端ID时,才删除
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

3. 支持重入锁:Lua 脚本扩展

重入锁指 “同一客户端可多次加锁”(如递归调用场景),需记录锁的 “重入次数”,核心逻辑仍用 Lua 脚本实现:

  • 加锁脚本
    1. 若锁不存在:设锁的 value 为 “client-id:1”(1 表示重入次数),并设超时;
    1. 若锁存在且持有者是自己:重入次数 + 1;
    1. 若锁存在但持有者是他人:加锁失败。
  • 解锁脚本
    1. 若锁不存在:直接返回;
    1. 若锁持有者是自己:重入次数 - 1,当次数为 0 时删除锁;
    1. 若锁持有者是他人:直接返回。

三、单节点锁的局限与 Redlock 算法

基于单 Redis 节点的锁存在 “单点故障” 风险:若 Redis 节点宕机,锁会丢失;即使有主从集群,主从复制是异步的 —— 主节点加锁后未同步到从节点就宕机,从节点升级为主节点后,锁会 “消失”,导致多个客户端同时加锁。

Redlock 算法:多节点保障可靠性

Redlock 的核心思路是 “用多个独立 Redis 节点存储锁”,客户端需向超过半数的节点加锁成功,才认为持有锁,即使部分节点宕机,锁仍有效。

执行步骤(以 3 个节点为例):

  1. 记录开始时间:客户端获取当前时间,用于计算加锁总耗时。
  1. 逐个加锁:按顺序向 3 个节点发起加锁请求(每个请求仍用setnx + 客户端ID + 超时),并给每个加锁请求设 “短超时”(如 50ms)—— 避免单个节点无响应时卡住。
  1. 判断加锁成功
    • 成功加锁的节点数 ≥ 2(超过半数);
    • 加锁总耗时 ≤ 锁的有效时间(如 30 秒)。

若满足,计算 “剩余有效时间”(30 秒 - 总耗时);若不满足,向所有节点发起释放锁请求(用 Lua 脚本)。

  1. 释放锁:无论加锁是否成功,最终都要向所有节点释放锁 —— 防止某节点加锁成功但响应丢失,导致锁残留。

Redlock 的风险点

  • 释放锁需覆盖所有节点:即使某个节点加锁时客户端认为 “失败”,也需释放 —— 可能该节点实际加锁成功,但响应包丢失。

四、Redission 框架 —— 简化 Redis 分布式锁实现

Redission 是基于 Redis 的 Java 客户端框架,不仅封装了 Redis 的基础操作,更提供了开箱即用的分布式锁实现,解决了原生 Redis 锁的诸多细节问题(如重入、自动续期、集群适配),是生产环境中常用的 Redis 锁方案。

1. Redission 的核心组成

Redission 的分布式锁能力依赖其内部模块化设计,核心组成包括:

image.png

  • 锁核心模块:提供多种锁类型,如可重入锁(RLock)、公平锁(RFairLock)、读写锁(RReadWriteLock)等,满足不同业务场景;
  • Redis 客户端模块:封装 Redis 连接池、集群 / 哨兵模式适配,自动处理节点连接与故障切换;
  • 监听与续期模块:内置 “看门狗”(Watch Dog)机制,自动为未释放的锁续期,避免锁超时;
  • 序列化模块:支持多种数据序列化方式(如 JSON、ProtoBuf),处理锁标识(客户端 ID、重入次数)的存储与解析。

2. Redission 分布式锁的核心原理(以可重入锁为例)

Redission 的可重入锁实现基于 Redis 的 Hash 结构和 Lua 脚本,核心流程如下:

  • 加锁逻辑
    1. 客户端调用RLock.lock()时,Redission 会生成唯一客户端 ID(如{service-name}:{thread-id});
    1. 执行 Lua 脚本:若锁不存在,创建 Hash 结构(key=lock:key,field=客户端ID,value=1,表示重入次数 1),并设初始超时(默认 30 秒);若锁已存在且 field 为当前客户端 ID,将 value(重入次数)+1;若锁已存在但 field 不是当前客户端 ID,加锁失败,进入等待队列;
    1. 加锁成功后,启动 “看门狗” 线程:每隔 10 秒(超时时间的 1/3)检查锁是否仍被当前客户端持有,若是则将锁超时时间重置为 30 秒,避免业务未执行完锁已过期。
  • 释放锁逻辑
    1. 客户端调用RLock.unlock()时,执行 Lua 脚本:若锁不存在,直接返回;若锁存在且 field 为当前客户端 ID,将重入次数 - 1;当重入次数减为 0 时,删除锁(del lock:key),并唤醒等待队列中的下一个客户端;
    1. 释放成功后,停止 “看门狗” 线程。

3. Redission 的核心优点

  • 无需手动处理细节:自动实现重入、续期、释放锁原子性,开发者无需编写 Lua 脚本或处理超时逻辑;
  • 集群友好:支持 Redis 单机、主从、哨兵、集群模式,自动适配节点故障切换,解决单节点锁的单点问题(无需手动实现 Redlock,框架已优化多节点锁逻辑);
  • 丰富的锁类型:除可重入锁外,还提供公平锁(避免线程饥饿)、读写锁(读多写少场景优化)、联锁(多锁同时持有)等,满足复杂业务需求;
  • 高可用性:内置重试机制(加锁失败时自动重试)、断线重连,保证锁操作的稳定性;
  • 低侵入性:API 设计简洁,如lock()/unlock(),与 Java 原生Lock接口用法类似,易于集成到业务代码中。
  • 避免时钟跳跃:若某节点时钟异常(如时间突然快进),锁会提前过期,可能导致多个客户端同时加锁(比如 3 个节点中,1 个节点锁提前过期,客户端 A 和 B 分别在 2 个节点加锁成功)。

四、ZooKeeper 实现分布式锁

除了 Redis,ZooKeeper 也可实现分布式锁,核心依赖 “临时节点” 和 “顺序节点” 的特性,避免 Redis 锁的部分局限。

1. 两种实现模式

(1)简单模式:临时节点

  • 加锁:客户端尝试创建临时节点(如/lock),创建成功即加锁;失败则注册 “节点删除监听器”,等待锁释放。
  • 释放锁:客户端删除临时节点,或客户端宕机后 ZooKeeper 自动删除临时节点,监听器触发,其他客户端竞争加锁。
  • 局限:存在 “羊群效应”—— 多个客户端监听同一节点,节点删除时所有客户端都会被通知,同时发起创建请求,给 ZooKeeper 造成压力。

(2)优化模式:临时顺序节点

  • 加锁:客户端在/lock父节点下创建 “临时顺序节点”(如/lock/seq-001),然后获取父节点下所有子节点,判断自己是否是 “序号最小的节点”—— 若是则加锁成功,若不是则监听 “前一个节点”(如/lock/seq-000)。
  • 释放锁:客户端删除自己的节点,前一个节点的监听器触发,下一个客户端判断自己是否为最小节点,依次类推。
  • 优势:避免羊群效应,每次仅通知 “下一个客户端”,减轻 ZooKeeper 压力。

2. Curator 框架:简化 ZooKeeper 锁实现

ZooKeeper 原生 API 实现锁较复杂,Curator 框架封装了分布式锁逻辑,提供InterProcessMutex(可重入锁)等实现,核心用法:

  1. 创建 Curator 客户端,连接 ZooKeeper 集群;
  1. 实例化InterProcessMutex,指定锁路径(如/lock);
  1. 调用acquire()加锁,release()释放锁 —— 框架自动处理节点创建、监听、自动释放等逻辑。

3. ZooKeeper 锁的风险:脑裂问题

客户端与 ZooKeeper 集群网络异常时,ZooKeeper 会认为客户端宕机,自动删除其临时节点,释放锁;但客户端可能仍认为自己持有锁,继续操作资源 —— 导致 “双写” 问题。

解决思路:业务层增加 “幂等校验”(如操作前检查数据版本),或给锁设 “合理超时”,减少异常窗口。

总结

Redis 和 ZooKeeper 是实现分布式锁的常用方案,选择需结合场景:

  • 若追求高性能、轻量级:优先用 Redis 锁(单节点满足大部分场景,高可靠性场景用 Redlock);
  • 若需强一致性、避免脑裂:可选择 ZooKeeper 锁(Curator 框架简化开发)。

无论哪种方案,核心都是围绕 “原子操作”“超时释放”“避免单点” 三个关键点,确保并发场景下资源竞争的安全性。