分布式锁(Distributed Lock)详解

分布式锁(Distributed Lock)详解

一、要解决的核心问题

先看一个场景:同一笔订单,用户快速点了两次"支付",两个请求几乎同时到达两台服务器。如果不加控制,两台机器会同时执行扣款逻辑 → 重复扣款。

这就是并发竞争 问题。本质是:多个执行流同时操作同一份共享资源,导致数据错乱。

单机环境下,用 JVM 的锁(synchronized、ReentrantLock)就能解决。但在分布式/微服务 环境下,服务部署了多个实例(多个 JVM、多台机器),JVM 锁只能锁住本进程内 的线程,锁不住其他机器上 的线程------这时就需要分布式锁。

复制代码
单机锁(synchronized):只能管住机器A内部的线程
                     ↓ 机器B的线程照样闯进来
分布式锁:在所有机器之外设一个"公共的锁",谁都要先去那里抢

注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

二、核心概念

分布式锁 :借助一个所有服务实例都能访问的外部存储(Redis、ZooKeeper、数据库等)作为"公共裁判",让分布在不同机器上的执行流在操作共享资源前,先去这个外部存储抢占同一把锁,抢到的才能执行,没抢到的等待或放弃。

三个关键角色:

  • 锁的 key :标识"锁的是哪个资源",必须和业务资源一一对应(如 order_lock_{订单id})
  • 锁的持有者:抢到锁的那个执行流
  • 外部存储:Redis / ZooKeeper / DB,充当公共裁判

三、锁的 key 设计(最关键的一点)

分布式锁锁的是具体某个资源 ,不是整段代码。所以 key 要带上资源的唯一标识:

java 复制代码
// ✅ 正确:按资源维度加锁,不同订单互不阻塞,只拦截"同一订单"的并发
String lockKey = "order_pay_lock_" + orderId;

// ❌ 错误:固定key,会把所有订单的支付串行化,性能灾难
String lockKey = "order_pay_lock";

原则 :锁的粒度要刚好覆盖冲突范围。

  • 太粗(固定 key):不相关的操作也被串行化,性能差
  • 太细(key 没覆盖冲突资源):锁不住真正的并发

四、分布式锁必须具备的三个特性

特性 含义 不满足会怎样
互斥性 同一时刻只有一个持有者 锁失效,并发问题依旧
防死锁 持有者宕机也能自动释放(靠超时) 锁永远占着,资源再也用不了
可用性 加锁/解锁本身高性能、不单点 锁服务成为瓶颈或故障点

进阶特性:可重入 (同一线程可重复加同一把锁)、锁续期(任务没做完锁自动延期,防止提前释放)。

五、通用示例代码

5.1 封装式写法(try-with-resources)

用 DistributedLockProvider 对分布式锁封装,用 try-with-resources 自动释放,是推荐的写法:

java 复制代码
public void doBusinessWithLock(Long resourceId) {
    // key 带资源id,只锁同一资源
    String key = "biz_lock_" + resourceId;

    // try-with-resources:lock 实现 AutoCloseable,代码块结束自动释放锁
    try (DistributedLock lock = distributedLockProvider.getLock(key, TimeUnit.MINUTES, 10)) {
        // 参数1: 锁最长持有时间(租期)10分钟 ------ 防死锁:到期自动释放
        // 尝试加锁,最多等5分钟
        if (lock.tryLock(TimeUnit.MINUTES, 5)) {
            // ===== 抢到锁,执行临界区(操作共享资源) =====
            doRealBusiness(resourceId);
            // ===== 临界区结束 =====
        } else {
            // 等待超时仍没抢到锁 ------ 说明有并发在处理,放弃或抛异常
            log.warn("获取锁失败,resourceId={}", resourceId);
        }
    } catch (Exception e) {
        log.warn("业务执行异常,resourceId={}", resourceId, e);
        throw e;
    }
    // 退出 try 块时,DistributedLock.close() 被自动调用 → 释放锁
}

三个时间参数的含义(容易混淆):

  • 租期(lease time):锁最长持有多久,到期自动释放(防死锁),上例 10 分钟
  • 等待时间(wait time):抢不到锁时愿意等多久,上例 5 分钟
  • 关系:租期要 > 业务最长执行时间,否则业务没跑完锁就被自动释放了

5.2 基于 Redis 的原生实现原理(理解底层)

抛开封装,分布式锁最常见的是 Redis 实现。核心是一条原子命令:

复制代码
SET lockKey 唯一值 NX PX 30000
  • NX:key 不存在才设置成功(保证互斥------只有第一个能设成功)
  • PX 30000:30 秒后自动过期(防死锁------持有者宕机也会到期释放)
  • 唯一值:标识持有者身份(防误删------只能删自己加的锁)

伪代码:

java 复制代码
public boolean tryLock(String key, String uniqueValue, long expireMillis) {
    // SET key value NX PX expire ------ 原子操作,保证互斥
    return redis.set(key, uniqueValue, "NX", "PX", expireMillis);
}

public void unlock(String key, String uniqueValue) {
    // 用Lua脚本保证"判断是不是自己的锁 + 删除"的原子性
    String lua =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "   return redis.call('del', KEYS[1]) " +
        "else return 0 end";
    redis.eval(lua, Collections.singletonList(key),
               Collections.singletonList(uniqueValue));
}

为什么解锁要用 Lua 脚本? 因为"先判断是不是自己的锁,再删除"是两步操作,中间可能被打断(比如判断完锁刚好到期、被别人抢走,你再删就误删了别人的锁)。Lua 脚本在 Redis 里原子执行,杜绝这个竞态。

为什么解锁要校验唯一值? 防止 A 的锁到期自动释放后 B 抢到了锁,A 的业务跑完来解锁时误删了 B 的锁。

5.3 对比:单机锁(仅供理解差异,分布式环境不可用)

java 复制代码
// 单机锁:只在当前JVM有效,多实例部署下锁不住其他机器
private final Object lock = new Object();
public void doBusiness(Long id) {
    synchronized (lock) {   // 只能管住本进程的线程
        doRealBusiness(id);
    }
}

六、主流实现方案对比

方案 代表 优点 缺点
Redis Redisson、原生 SET NX 性能高、使用广 极端场景(主从切换)可能锁失效,需 RedLock 等增强
ZooKeeper Curator 强一致、天然防死锁(临时节点) 性能略低、运维成本高
数据库 唯一索引 / select for update 无需额外中间件 性能差、易成瓶颈,不推荐高并发
etcd --- 强一致,云原生场景常用 生态相对小众

工程中通常用成熟封装(如 Redisson),它内置了可重入、锁续期(Watchdog 看门狗自动续期)、RedLock 等能力,不建议自己手撸原生实现。

七、关键设计要点与坑

1. 租期 vs 业务时长的矛盾

  • 租期设太短:业务没跑完锁就被自动释放,别人进来 → 并发问题重现
  • 租期设太长:持有者真宕机了,别人要等很久
  • 解法:用锁续期(Watchdog)------业务没结束就自动延长租期,Redisson 默认支持

2. 一定要防死锁(锁必须能自动释放)

  • 加锁必须带超时/租期,否则持有者宕机 → 锁永久占用 → 该资源彻底卡死

3. 解锁要防误删

  • 只能删"自己加的锁"(校验唯一值 + Lua 原子解锁)

4. 加锁失败的处理策略

  • 快速失败:抢不到立刻返回/抛异常(适合"重复提交"场景,本需求即如此)
  • 阻塞等待:等一段时间(适合"必须执行、可以排队"场景)
  • 根据业务选择,别无脑死等

5. 锁 ≠ 事务

  • 分布式锁保证"同一时刻只有一个在执行",但不保证数据库操作的原子性
  • 通常锁 + 事务配合:锁保证串行进入,事务保证进入后的数据一致性

6. 释放锁要在 finally / try-with-resources

  • 业务异常也必须释放锁,否则其他请求被卡住。封装的 AutoCloseable 是最省心的方式。

八、分布式锁 vs 其他防重手段

分布式锁不是唯一方案,根据场景选择:

手段 适用场景
分布式锁 需要严格串行执行临界区,防并发写冲突
数据库唯一索引 防重复插入(如防重复下单),简单直接
乐观锁(版本号/CAS) 冲突概率低,update ... where version=?
幂等设计(唯一业务号) 防重复请求产生重复结果,常与锁配合
状态机校验 如"已审核的不能再审核",用状态流转天然防重

实际中常组合使用 :如本需求既有分布式锁(防并发),又有审核状态校验(checkAuditStatus,防重复审核),双重保险。

九、常见误区

  1. 用固定 key:把所有资源的操作串行化,性能灾难。key 必须带资源标识。
  2. 加锁不设超时:持有者宕机导致死锁,资源永久卡住。
  3. 解锁不校验持有者:误删别人的锁。
  4. 租期短于业务时长:锁提前释放,并发问题重现(需锁续期)。
  5. 以为加了分布式锁就不用事务:锁管并发顺序,事务管数据一致,二者职责不同。
  6. 异常路径不释放锁:必须 finally/try-with-resources 释放。
  7. 过度依赖锁:能用数据库唯一索引/幂等/乐观锁解决的轻量场景,不必上分布式锁。

十、一句话总结

分布式锁用一个所有实例共享的外部裁判 (Redis/ZooKeeper 等),让跨机器的执行流在操作同一资源前先抢占按资源 id 设计的 key ,保证互斥 ;同时必须带超时租期防死锁 、唯一值防误删 、异常也能释放。它解决的是"多实例并发操作同一资源"的问题,常与事务、状态校验、幂等设计配合使用。

相关推荐
j7~2 小时前
【仿 RabbitMQ 项目】发布订阅消息队列 (篇九):队列数据管理,内存镜像 + SQLite 持久化实现
数据库·c++·分布式·sqlite·rabbitmq·队列数据管理
做一个AK梦2 小时前
分布式CAP理论
分布式
此时不提桶,更待何时14 小时前
06-23-A-RabbitMQ生态集成详解
分布式·rabbitmq
m0_6464299720 小时前
RabbitMQ 核心知识总结(生产实战版)
分布式·rabbitmq
DBA_G1 天前
GBase 8c分布式数据库客户端工具 gsql
数据库·分布式
chunyublog1 天前
Spark 词频统计练习
大数据·分布式·spark
acrel71 天前
高速公路分布式光伏并网电能质量与安全保护方案应用
分布式·安全
IT小小猪1 天前
# Hadoop 3.3.6 完全分布式集群部署知识库
hadoop·分布式·eclipse
此时不提桶,更待何时1 天前
06-24-B-RabbitMQ面试与生产事故实战
分布式·面试·rabbitmq