Skip to content

分布式锁

简介

在单机部署的应用中,我们通常使用 synchronized 或 java.util.concurrent.locks.Lock 等本地锁机制来保证多线程对共享资源的互斥访问,但只在单个 JVM 进程内有效

然而,随着业务的发展,现代大型系统往往采用分布式架构,将应用部署在多个节点上(不同物理机或容器),多个进程独立运行,此时共享资源的竞争就不再局限于单机内部。

例如,在电商秒杀活动中,多个服务节点可能同时处理同一个商品的库存扣减;在分布式任务中,需要确保同一时刻只有一个节点执行某个任务。在这种跨进程、跨网络的场景下,传统的本地锁无法协调不同节点上的线程,因此需要一种新的机制——分布式锁

分布式锁是一种跨进程的互斥原语,它通过在多个进程间协调对共享资源的访问,确保在任意时刻,只有一个客户端(或线程)能够持有锁并执行临界区代码。它本质上是一个在分布式系统中所有节点都能访问的“协调者”,所有需要访问共享资源的节点都必须先向这个协调者请求锁,成功获取锁的节点才能继续操作,操作完成后释放锁,让其他节点竞争。这个“协调者”可以是数据库、缓存中间件(如 Redis)、协调服务(如 ZooKeeper、etcd)等

分布式锁应具备的特性

  • 互斥性(最核心):互斥性是分布式锁最基本的要求,也是分布式锁的根本目的。在任何时刻,绝对只能有一个客户端持有锁,其它客户端必须等待锁释放后才能竞争。锁服务必须提供严格的排他机制,确保多个并发请求中只有一个能成功获得锁。通常通过原子操作(如 Redis 的 SET NX)或临时顺序节点(如 ZooKeeper)来实现。
  • 防死锁(高可用基础):死锁是分布式系统中必须避免的情况。即使持有锁的客户端因崩溃、网络分区或其他故障而无法主动释放锁,锁也应该能被自动清除,以便其他客户端能够继续获取锁。常见的防死锁手段为超时机制:为锁设置一个合理的过期时间(TTL),当客户端未在过期时间内完成操作并释放锁时,锁服务自动将其释放。 持有者标识:释放锁时必须验证当前操作者是否是锁的持有者,防止误释放其它客户端持有的锁(例如因超时释放后,后续操作被另一个客户端获取,原客户端突然恢复并释放了新的锁)。
  • 可重入性(根据需要,可选)可重入性允许同一个客户端在已经持有锁的情况下,再次请求同一把锁而不会被阻塞(类似 Java 的 ReentrantLock)。这对于需要递归调用或多次进入同一同步块的业务逻辑非常有用。实现可重入通常需要锁服务记录持有者的标识(如线程 ID + 机器 ID)以及重入次数。
  • 高性能:分布式锁是许多高频操作的基础组件,其性能直接影响业务响应速度。锁的获取和释放应尽可能轻量,避免过多的网络交互或复杂的计算。例如,基于内存的存储(如 Redis)通常比基于磁盘的存储(如数据库)性能更高。同时,锁的设计要尽量减少对锁服务本身的压力,避免成为系统瓶颈。
  • 高可用:锁服务本身必须具有高可用性。如果提供分布式锁的服务崩溃,那么整个依赖锁的业务系统将无法正常工作。因此,锁服务通常需要采用集群部署,具备数据复制和故障转移能力。例如,Redis 的哨兵或集群模式、ZooKeeper 的多数派机制等,可以保证在部分节点故障时锁服务仍能正常提供。
  • 锁的自动续期:业务逻辑的执行时间可能难以预估,如果仅仅依赖固定的过期时间,容易出现锁提前释放导致并发冲突。因此,很多分布式锁方案引入了“看门狗”机制:在锁获取后,启动一个守护线程定期为锁续期,直到客户端主动释放锁或客户端崩溃。这样既保证了业务有充足的时间执行,又避免了死锁。心跳续期:持有锁的客户端通过后台线程定期续期,避免锁因业务执行时间过长而过期。这种方式可以有效防止因业务耗时过长导致的锁提前释放。
  • 可阻塞与公平性可阻塞:当锁被持有时,后续请求可以阻塞等待,直到锁被释放,而不是立即返回失败。公平性:按照请求锁的顺序分配锁,避免某些请求长期饥饿。实现公平锁通常需要排队机制(如 ZooKeeper 的顺序节点)。
  • 易于理解和监控:一个好的分布式锁组件应该提供清晰的 API 和监控能力,方便开发者集成以及运维人员排查问题。例如,能够查看当前锁的持有者、锁的等待队列、锁的获取次数等指标。

常见实现方式

基于数据库

利用数据库的唯一索引或排他锁来实现。

  • 基于唯一索引:在表中创建锁资源对应的唯一索引,客户端尝试插入一条记录来获取锁,插入成功即获得锁;释放锁时删除该记录。需要配合定时任务清理过期记录以避免死锁。
  • 基于排他锁:使用 SELECT ... FOR UPDATE,在事务中查询锁记录并加上行锁,事务提交后释放锁。

优点:实现简单,无需引入额外中间件。
缺点:性能较差,依赖数据库可用性,可能产生单点问题;锁超时不易控制。

基于 Redis

Redis 凭借其高性能和原子操作,成为实现分布式锁的常用方案。最经典的是 Redlock 算法,但多数场景下使用单节点 Redis + 原子命令即可。

核心命令SET key value NX PX milliseconds

  • NX:仅当 key 不存在时设置,保证互斥。
  • PX:设置过期时间,防止死锁。

释放锁:使用 Lua 脚本确保原子性,先检查锁持有者(value 通常为唯一标识),再删除。

Redisson 是 Redis 官方推荐的 Java 客户端,提供了丰富的锁 API,支持可重入锁、公平锁、读写锁等,并自动续期(看门狗机制)。

优点:性能极高,实现相对简单。
缺点:依赖 Redis 高可用,主从切换可能导致锁丢失(Redlock 试图解决但仍有争议)。

为什么使用set nx 实现分布式锁不安全? 因为使用 set nx时, 必然要执行 delete 命令, 如果在线程任务(方法)未执行到 detele 命令前抛出了异常, 则锁被永远锁住了, 除非手动删除,否则永远释放不了了,造成死锁。

那将delete放到finally里边呢? 能解决上述问题么? 也不行, 首先 finally代码块里的代码,不是一定会执行,最简单的,当执行finally代码块时断电了呢, JVM进程异常了呢等等原因。所以不能完全依靠finally代码块+set nx 来实现分布式锁

那再 set nx 之后,再 set expire 设置过期时间呢? 也不行, 因为 set nx 和 expire 是两条 redis 命令, 不是原子性的,比如set nx设置成功了,当设置expire时服务器宕机了,最终还是会造成死锁,所以不能完全保证。 当然,redis目前支持 set nx expire 当做一条原子性命令来执行,有对应的方法, 或者用 lua 脚本实现也可以,

但是如果过期时间到了,比如expire设置了10秒,但10秒过后业务代码还没有执行完成,结果锁过期了,也可能导致锁错乱。

所以需要实现自己加的锁自己来释放,

java
    @PostMapping("/deduct_stock/setnxexpire")
    public String deductStockWithSetnxExpire() throws Exception {
        // 当前请求的唯一ID
        String clientId = UUID.randomUUID().toString();
        
        Boolean ifAbsent = stringRedisTemplate.opsForValue().setIfAbsent(LOCK_KEY_PRODUCT_1, clientId, 10, TimeUnit.SECONDS);
        if (Boolean.FALSE.equals(ifAbsent)) {
            throw new Exception("系统繁忙,请稍后再试");
        }

        try {
            String redisStock = stringRedisTemplate.opsForValue().get(STOCK_KEY_PRODUCT_1);
            int stock = redisStock == null ? 0 : Integer.parseInt(redisStock);
            System.out.println("当前库存:" + stock);

            if(stock > 0) {
                int realStock = stock - 1;
                stringRedisTemplate.opsForValue().set(STOCK_KEY_PRODUCT_1, realStock + ""); // jedis.set(key, value);
                System.out.println("扣减成功,剩余库存:" + realStock);
                return "扣减成功,剩余库存:" + realStock;
            } else {
                System.out.println("扣件失败,库存不足");
                throw new Exception("扣件失败,库存不足");
            }
        } finally {
            if(clientId.equals(stringRedisTemplate.opsForValue().get(LOCK_KEY_PRODUCT_1))) {
                // 比如线程1执行到该行时,卡顿了,10秒到期后锁已经过期被删除, 而此时线程2已经成功获取了锁

                // 过期后线程1恢复并执行了改行代码,则线程1删除了线程2获取的分布式锁
                stringRedisTemplate.delete(LOCK_KEY_PRODUCT_1);
            }
        }
    }

以上代码依然有问题, 比如线程1执行完if(clientId.equals(stringRedisTemplate.opsForValue().get(LOCK_KEY_PRODUCT_1)))行时,卡顿了,10秒到期后锁已经过期被删除,而此时线程2已经成功获取了锁,过期后线程1恢复并执行了stringRedisTemplate.delete(LOCK_KEY_PRODUCT_1);代码,则线程1删除了线程2获取的分布式锁。当然也可以通过lua脚本来实现原子性。但还是那句话,finally代码块的代码并不一定会执行。

redisson的实现逻辑 lock方法,首先通过lua脚本尝试去获取锁,如果获取成功,则redis.hset lock_name UUID+线程编号,设置为1(hash结构, key是锁的名称,field是UUID_线程编号, value是冲入次数,初始为1),这整体是一个Future方法,然后添加一个监听器,如果成功,则添加一个后台线程(单独的方法)(这个具体的添加逻辑是在过期时间/3秒之后再执行),具体逻辑为判断hash中是否还有该字段,如果有,则过期时间重新设置为初始值。并且重新添加一个后台线程(再次调用这个单独的方法)

在unlock中,也是判断,如果hash中存在field,则del,并在channelName(可以理解为一个topic或者queue, channelName是固定前缀+锁名称,在getLock中实现的),删除后则会往channel放一条消息, 并返回true。

在lock方法中,如果获取锁失败,则会返回当前持有锁的剩余时间,如果剩余时间大于0,则会订阅这个channel(也是Future实现),通过while循环,间歇性的去获取锁,首先获取失败后会再次尝试立刻去获取一次,如果还是获取失败,则获取执行许可(通过Semaphore实现),此时会释放CPU,线程等待,等待时长为当前锁持有的剩余时间。如果获取成功,则解除channel订阅。当然,获取锁成功后持有的默认时长是30秒,但如果10秒已经执行完成, 在unlock中已经往channel放入了一条消息,因为未成功获取的线程订阅了channel,当channel有消息时,会执行LockPubSub的onMessage方法,调用Semaphore的release方法,来激活等待的线程重新去获取锁,所以由此可见redison是非公平的。当前,之所以采用hash结构,大概是因为需要支持可重入吧,每次unlock时,会判断value是否=1,如果是,则del,并发送message,否则仅-1。

基于 ZooKeeper

ZooKeeper 是一种分布式协调服务,利用其临时顺序节点和 Watch 机制可实现分布式锁。

原理

  1. 客户端在锁路径下创建临时顺序节点。
  2. 获取所有子节点,判断自己是否是最小序号节点。
  3. 若是,则获得锁;否则监听前一个节点的删除事件。
  4. 释放锁时删除自身临时节点,后继节点收到通知后尝试获取锁。

优点:天然支持顺序性、Watch 机制可避免轮询;节点临时特性可防止死锁。
缺点:性能低于 Redis(磁盘写、选举),客户端需维持长连接。

实现方案对比

特性数据库RedisZooKeeper
性能
可靠性取决于数据库,可能出现单点取决于主从/集群,可能发生锁丢失高(ZAB 协议保证一致性)
实现复杂度简单中等(需处理原子释放、续期)中等(利用 Curator 简化)
死锁防护需额外定时清理过期时间 + 看门狗临时节点自动清理
可重入需额外字段实现Redisson 支持Curator 支持
适用场景对性能要求不高,不想引入新中间件高并发,可接受极小概率锁丢失对一致性要求极高,如金融、配置中心

代码示例(基于 Redis + Redisson)

java
// 引入 Redisson 依赖(Maven)
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.17.0</version>
</dependency>

// 配置 Redisson 客户端
@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://127.0.0.1:6379");
        return Redisson.create(config);
    }
}

// 使用分布式锁
@Service
public class OrderService {
    @Autowired
    private RedissonClient redissonClient;

    public void createOrder(String orderId) {
        String lockKey = "lock:order:" + orderId;
        RLock lock = redissonClient.getLock(lockKey);
        try {
            // 尝试加锁,最多等待 10 秒,锁自动释放时间为 30 秒
            if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
                // 执行业务逻辑
                System.out.println("处理订单:" + orderId);
                Thread.sleep(5000); // 模拟耗时
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 释放锁(需确保当前线程持有锁)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}