Redisson分布式锁使用指南

Redisson 分布式锁使用指南

本文档介绍 Redisson 中最常用的两种分布式锁:可重入锁(Reentrant Lock)读写锁(ReadWrite Lock),包含原理、使用方式、典型场景及最佳实践。


一、可重入锁(Reentrant Lock)

1.1 原理简介

可重入锁是 Redisson 默认提供的分布式锁,基于 Redis 实现,核心特性:

  • 可重入:同一线程可多次获取同一把锁,不会自我阻塞(通过 Hash 结构记录线程 ID 和重入次数)
  • 自动续期:内置看门狗(Watchdog)机制,默认每 10 秒续期一次,避免业务未执行完锁就过期
  • 原子性:加锁、解锁均通过 Lua 脚本保证原子性
  • 防误删:通过唯一线程标识,只有持有者才能释放锁

1.2 底层结构

Redis 中存储结构为 Hash:

Key:   锁名称(如 "lock:order:1001")
Field: 线程标识(UUID:threadId)
Value: 重入次数

加锁 Lua 脚本简化逻辑:

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1);    -- 首次加锁
    redis.call('pexpire', KEYS[1], ARGV[1]);     -- 设置过期时间
    return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);  -- 重入次数 +1
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
end;
return redis.call('pttl', KEYS[1]);  -- 锁已被其他线程持有,返回剩余过期时间

1.3 基本用法

方式一:阻塞式加锁(推荐)

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    @Autowired
    private RedissonClient redissonClient;

    public void createOrder(String userId) {
        RLock lock = redissonClient.getLock("lock:order:" + userId);
        lock.lock();  // 阻塞直到获取锁,默认 30 秒过期,看门狗自动续期
        try {
            // 业务逻辑:扣库存、生成订单、扣款
            doCreateOrder(userId);
        } finally {
            // 注意:必须判断是否持有锁,避免解锁别人的锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    private void doCreateOrder(String userId) {
        // 实际业务逻辑
    }
}

方式二:带超时时间的加锁

public void createOrderWithWait(String userId) {
    RLock lock = redissonClient.getLock("lock:order:" + userId);
    try {
        // tryLock(等待时间, 持有时间, 时间单位)
        // 等待 3 秒获取锁,获取后锁持有 10 秒(不会自动续期)
        boolean acquired = lock.tryLock(3, 10, TimeUnit.SECONDS);
        if (acquired) {
            try {
                doCreateOrder(userId);
            } finally {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        } else {
            // 获取锁失败的处理
            throw new RuntimeException("系统繁忙,请稍后重试");
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("加锁被中断", e);
    }
}

方式三:可重入演示

public void reentrantDemo() {
    RLock lock = redissonClient.getLock("lock:demo");
    lock.lock();
    try {
        System.out.println("第一次加锁");
        methodA();  // 内部再次加锁,不会阻塞(可重入)
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

private void methodA() {
    RLock lock = redisson.getLock("lock:demo");  // 同一把锁
    lock.lock();
    try {
        System.out.println("第二次加锁(重入)");
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

1.4 可重入的价值:为什么需要它

核心区别

可重入锁与普通锁的唯一核心区别:同一线程能否重复获取同一把锁。

对比项 普通锁(不可重入) 可重入锁
同线程二次加锁 ❌ 死锁 ✅ 成功(计数 +1)
多线程互斥

普通锁的死锁后果

如果用普通锁发生嵌套加锁,会引发连锁反应:

  1. 当前线程永久阻塞:卡在内部的 lock(),永远等不到锁释放
  2. 锁永不释放:外层 unlock() 永远执行不到
  3. 其他线程全部阻塞:所有请求这把锁的线程排队等死
  4. 线程池耗尽:Tomcat 线程池被占满,新请求直接拒绝
  5. 服务整体不可用:整个接口/服务崩溃

本质:一个线程卡死,整条链路报废。

为什么会嵌套加锁

嵌套加锁不是刻意为之,而是每个层级各自保证安全,叠加后自然形成:

每个方法独立设计时都要保证线程安全
↓
方法 A 加锁 → 调用方法 B
方法 B 自己也加锁(因为 B 也可能被单独调用)
↓
自然形成嵌套加锁

典型嵌套场景

场景 说明
方法复用 updateProduct() 既能单独调用,也能被 batchUpdate() 批量调用
AOP 切面叠加 日志切面、缓存切面、事务切面各自加锁,叠加后多层嵌套
框架拦截器 Spring 事务拦截器 + 业务代码加锁
递归调用 树形结构递归处理,每层都加锁

可重入带来的好处

  1. 方法自由组合:不用关心外层是否已加锁,该加就加
  2. 代码结构清晰:每个方法职责单一,不用把所有逻辑塞一个方法里
  3. 与框架安全共存:和 Spring 事务、AOP 切面叠加不冲突
  4. 递归安全:递归调用不用额外处理

一句话总结:可重入锁的核心能力只有一个(同线程可重复加锁),但它解放了代码设计——写代码时不用时刻惦记"我现在有没有锁"。

1.5 看门狗机制说明

加锁方式 是否触发看门狗 过期时间
lock.lock() 默认 30 秒,自动续期
lock.lock(10, SECONDS) 固定 10 秒,不续期
tryLock(3, 10, SECONDS) 固定 10 秒,不续期
tryLock(3, -1, SECONDS) -1 表示触发看门狗

看门狗工作机制

  1. 加锁成功后,启动定时任务(每隔 锁过期时间 / 3 执行一次,默认 10 秒)
  2. 检查当前线程是否仍持有锁
  3. 如果持有,重新设置过期时间为 30 秒
  4. 业务执行完成调用 unlock() 后,定时任务自动停止

注意:只有在不指定 leaseTimeleaseTime = -1 时才会启用看门狗。

1.6 典型应用场景

场景 锁 Key 设计 说明
防止订单重复提交 lock:submit:{userId} 同一用户短时间内只能提交一次
扣减库存 lock:stock:{skuId} 防止超卖
账户余额变更 lock:account:{accountId} 防止并发修改余额
定时任务执行 lock:job:{jobName} 集群环境下保证任务只执行一次
缓存重建 lock:cache:{cacheKey} 防止缓存击穿,只允许一个线程重建

1.7 最佳实践

/**
 * 可重入锁标准模板
 */
public <T> T executeWithLock(String lockKey, long waitTime, long leaseTime, Supplier<T> supplier) {
    RLock lock = redissonClient.getLock(lockKey);
    boolean acquired = false;
    try {
        acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
        if (!acquired) {
            throw new RuntimeException("获取锁失败: " + lockKey);
        }
        return supplier.get();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("加锁被中断", e);
    } finally {
        if (acquired && lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}

注意事项

  1. unlock() 前必须判断 isHeldByCurrentThread(),否则可能抛 IllegalMonitorStateException
  2. 业务执行时间不确定时,使用 lock.lock() 触发看门狗自动续期
  3. 业务执行时间可控时,使用 tryLock(wait, lease, unit) 指定固定过期时间,避免看门狗开销
  4. 锁的粒度要细,例如按 userIdorderId 加锁,避免锁住所有用户

二、读写锁(ReadWrite Lock)

2.1 原理简介

读写锁将锁分为读锁(共享锁)写锁(排他锁)

  • 读锁:多个线程可同时持有,互不阻塞
  • 写锁:独占锁,只有一个线程能持有
  • 互斥规则
    • 读-读:不互斥 ✅
    • 读-写:互斥 ❌
    • 写-写:互斥 ❌

适用场景:读多写少,通过读锁共享提升并发性能。

2.2 底层结构

读写锁在 Redis 中使用两个 Key:

Key1: 锁名称(存储写锁信息,Hash 结构)
Key2: 锁名称 + ":rw_lock:"(存储读锁计数)

读锁加锁简化逻辑:

-- 如果存在写锁,则获取失败
if (redis.call('exists', KEYS[1]) == 1) then
    return redis.call('pttl', KEYS[1]);
end;
-- 读锁计数 +1
redis.call('hincrby', KEYS[2], ARGV[2], 1);
redis.call('pexpire', KEYS[2], ARGV[1]);
return nil;

2.3 基本用法

import org.redisson.api.RLock;
import org.redisson.api.RReadWriteLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

    @Autowired
    private RedissonClient redissonClient;

    /**
     * 读商品(加读锁,允许多线程并发读)
     */
    public Product getProduct(String productId) {
        RReadWriteLock rwLock = redissonClient.getReadWriteLock("rwlock:product:" + productId);
        RLock readLock = rwLock.readLock();
        readLock.lock();
        try {
            // 先查缓存
            Product product = getFromCache(productId);
            if (product != null) {
                return product;
            }
            // 缓存未命中查 DB(此时其他线程可能也在查,可接受)
            product = getFromDB(productId);
            saveToCache(productId, product);
            return product;
        } finally {
            if (readLock.isHeldByCurrentThread()) {
                readLock.unlock();
            }
        }
    }

    /**
     * 更新商品(加写锁,阻塞所有读和写)
     */
    public void updateProduct(String productId, Product newProduct) {
        RReadWriteLock rwLock = redissonClient.getReadWriteLock("rwlock:product:" + productId);
        RLock writeLock = rwLock.writeLock();
        writeLock.lock();
        try {
            // 先更新 DB
            updateDB(productId, newProduct);
            // 再更新缓存
            saveToCache(productId, newProduct);
        } finally {
            if (writeLock.isHeldByCurrentThread()) {
                writeLock.unlock();
            }
        }
    }

    // 省略 getFromCache / getFromDB / updateDB / saveToCache 方法
    private Product getFromCache(String id) { return null; }
    private Product getFromDB(String id) { return new Product(); }
    private void saveToCache(String id, Product p) { }
    private void updateDB(String id, Product p) { }
}

class Product { }

2.4 带超时的读写锁

public Product getProductWithWait(String productId) {
    RReadWriteLock rwLock = redissonClient.getReadWriteLock("rwlock:product:" + productId);
    RLock readLock = rwLock.readLock();
    try {
        // 最多等待 3 秒获取读锁
        boolean acquired = readLock.tryLock(3, TimeUnit.SECONDS);
        if (acquired) {
            try {
                return getFromDB(productId);
            } finally {
                if (readLock.isHeldByCurrentThread()) {
                    readLock.unlock();
                }
            }
        }
        throw new RuntimeException("获取读锁超时");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new RuntimeException("加锁被中断", e);
    }
}

2.5 读写锁的互斥矩阵

读锁请求 写锁请求
当前持有读锁 ✅ 成功(共享) ❌ 阻塞(需等所有读锁释放)
当前持有写锁 ❌ 阻塞 ❌ 阻塞
无锁 ✅ 成功 ✅ 成功

2.6 典型应用场景

场景 读锁 写锁 说明
缓存重建 查询时加读锁 重建缓存时加写锁 防止缓存击穿
配置中心 读取配置时加读锁 更新配置时加写锁 配置热更新时阻塞读
报表生成 查询报表时加读锁 生成报表时加写锁 生成过程中阻塞查询
商品详情 查看商品加读锁 修改商品加写锁 提升查询并发度
数据字典 查询字典加读锁 刷新字典加写锁 字典更新时阻塞读

2.7 最佳实践

  1. 读多写少才用:如果读写频率接近,读写锁比可重入锁更慢(维护读写状态有开销)
  2. 写锁要及时释放:写锁会阻塞所有读操作,持有时间应尽量短
  3. 避免锁升级:不要在读锁中尝试获取写锁,会导致死锁
  4. 读锁内不要修改数据:读锁是共享的,多个线程同时执行,修改数据会有并发问题

三、两种锁对比

对比项 可重入锁 读写锁
互斥性 完全互斥 读-读不互斥
并发性能 高(读场景)
使用复杂度 简单 略复杂
重入支持 支持 支持
看门狗 支持 支持
适用场景 写多读少 / 通用互斥 读多写少
Redis 开销 一个 Key 两个 Key

四、选型建议

业务场景
├── 读多写少(查询多、更新少)→ 读写锁 ✅
├── 写多读少 / 读写均衡 → 可重入锁 ✅
├── 通用互斥场景 → 可重入锁 ✅
├── 防止超卖 / 防重复提交 → 可重入锁 ✅
├── 缓存击穿防护 → 读写锁 ✅
└── 配置热更新 → 读写锁 ✅

核心原则

  • 默认用可重入锁,满足 80% 场景
  • 读多写少时用读写锁,提升并发性能
  • 不要过度设计,简单场景不要用复杂锁

五、是否需要读写互斥的强约束

5.1 判断流程图

业务场景
├── 数据一致性要求高?
│   ├── 是 → 使用写锁(读写互斥)✅
│   └── 否 → 使用读锁(读-读共享)或不加锁
│
├── 写操作期间允许读?
│   ├── 否 → 使用写锁 ✅
│   └── 是 → 使用读锁或不加锁
│
├── 读操作依赖最新数据?
│   ├── 是 → 使用写锁 ✅
│   └── 否 → 使用读锁或不加锁
│
└── 允许脏读?
    ├── 否 → 使用写锁 ✅
    └── 是 → 使用读锁或不加锁

5.2 核心判断标准

  1. 数据一致性要求:金融交易、库存扣减等必须强一致
  2. 业务正确性:不允许基于过期数据做决策
  3. 用户体验:不允许用户看到中间状态

满足以上任意一条,就需要读写互斥的强约束。


六、分布式环境下的常见坑与避坑指南

6.1 共同的坑(两种锁都有)

坑 1:锁过期了业务还没执行完

lock.tryLock(3, 10, TimeUnit.SECONDS);  // 锁只持 10 秒
try {
    Thread.sleep(15000);  // 业务执行了 15 秒,锁已经过期
} finally {
    lock.unlock();  // 解锁的是别人刚加的锁!
}

后果:锁自动释放后其他线程也能获取锁,引发并发问题;当前线程 unlock() 可能误删别人的锁。

避坑

  • 业务时间不确定 → 用 lock.lock() 触发看门狗自动续期
  • 业务时间可控 → 才用固定 leaseTime

坑 2:释放了别人的锁

场景:锁过期后当前线程才执行完,unlock() 删掉了别人刚加上的锁。

避坑:解锁前必须判断:

if (lock.isHeldByCurrentThread()) {
    lock.unlock();
}

坑 3:finally 里没解锁

lock.lock();
doSomething();  // 抛异常,unlock() 走不到
lock.unlock();

后果:锁只能等过期自动释放,期间所有线程阻塞。

避坑:标准模板:

lock.lock();
try {
    doSomething();
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

坑 4:Redis 主从切换导致锁丢失

Master(锁在这里)
   ↓ 主从同步有延迟
Slave

Master 挂了 → Slave 提升为 Master
→ Slave 上可能还没同步到锁
→ 新 Master 上锁不存在了!

后果:两个节点都认为自己拿到了锁。

避坑

  • 对一致性要求极高 → RedLock / Zookeeper 锁
  • 普通业务 → 可接受极低概率的锁丢失

坑 5:锁粒度过大

// 全商品共用一把锁 → 所有操作串行
RLock lock = redisson.getLock("lock:product");

避坑:按业务维度细分:

// 只锁单个商品
RLock lock = redisson.getLock("lock:product:" + productId);

坑 6:多锁死锁

线程 A:先锁账户 1 → 再锁账户 2
线程 B:先锁账户 2 → 再锁账户 1
→ 循环等待,死锁

避坑

  • 按固定顺序加锁(如按 ID 排序,小的先加)
  • tryLock 加超时,获取失败就释放已持有的锁
  • 用 Redisson 的 MultiLock

坑 7:业务异常回滚了,锁正常释放

锁只保证"同一时间只有一个线程执行",不保证"执行结果一定成功"。数据一致性靠数据库事务,锁只解决并发问题。

坑 8:网络延迟导致锁竞争不均

网络好的节点更容易抢到锁,造成不公平。对公平性有要求用 getFairLock() 公平锁,但性能有损失。

6.2 读写锁的特有坑

坑 1:写锁饥饿

读锁-读锁-读锁-... 源源不断的读
        ↓
    写锁永远等不到

后果:写操作一直阻塞,请求超时堆积。

避坑

  • 控制读锁持有时间,尽量短
  • 写操作优先级高的场景,考虑用可重入锁替代

坑 2:读锁里修改数据

读锁只保证写锁不进来,但多个读锁是同时存在的。在读锁里修改数据等于没有保护。

避坑:读锁里只读,写操作必须用写锁。

坑 3:锁升级(读锁升级为写锁)

readLock.lock();
try {
    writeLock.lock();  // ❌ 死锁!持有读锁又请求写锁
} finally {
    readLock.unlock();
}

后果:自己持有读锁,又请求写锁 → 写锁要等所有读锁释放 → 自己等自己 → 死锁。

避坑:Redisson 不支持锁升级。先释放读锁,再获取写锁。

坑 4:写操作很慢,读请求洪峰涌入

写锁期间读全部阻塞,写完后读请求同时涌入打满系统。

避坑

  • 优化写操作,缩短持有时间
  • 考虑双缓存策略

坑 5:读写频率差不多,用读写锁反而更慢

读写比至少 5:1 以上,读写锁的收益才大于开销。读写均衡时直接用可重入锁。

6.3 避坑速查表

严重程度 避坑方法
锁过期业务没跑完 ⭐⭐⭐⭐⭐ 用看门狗(lock.lock() 或 leaseTime=-1)
释放了别人的锁 ⭐⭐⭐⭐⭐ unlock() 前判断 isHeldByCurrentThread()
finally 没解锁 ⭐⭐⭐⭐⭐ 标准模板:lock → try → finally unlock
主从切换锁丢失 ⭐⭐⭐⭐ 高一致用 RedLock / ZK,普通业务可接受
锁粒度过大 ⭐⭐⭐ 按业务 ID 细分锁
多锁死锁 ⭐⭐⭐ 固定顺序加锁 / tryLock 超时 / MultiLock
写锁饥饿 ⭐⭐⭐⭐ 缩短读锁时间 / 考虑用可重入锁
锁升级死锁 ⭐⭐⭐⭐ 不支持升级,先释放读锁再获取写锁
读锁里写数据 ⭐⭐⭐⭐ 读锁只读,写操作必须用写锁
读写差不多还用读写锁 ⭐⭐ 读写比 5:1 以上再考虑
posted @ 2026-07-19 10:16  SeiunSky  阅读(15)  评论(0)    收藏  举报