从手写 Redis 锁到 Redisson:我对分布式锁安全性的理解

摘要

最近在学习分布式锁的时候,我一开始的想法其实很简单:单机环境下可以用 synchronizedReentrantLock 控制并发,那到了分布式环境里,是不是只要把"锁"放到 Redis 里就行了?

但真正往下看之后,我发现 Redis 分布式锁并没有表面上那么简单。它确实能解决一人一单、库存扣减、定时任务防重复执行这类问题,但如果实现不严谨,也会带来锁误删、锁提前过期、主从切换后锁失效等风险。

这篇文章主要是结合我自己的学习过程,梳理几个问题:Redis 分布式锁到底是怎么实现的?为什么很多人手写的锁并不安全?Redisson 为什么更适合在 Java 项目里使用?它的看门狗机制又到底解决了什么问题?


一、为什么在分布式系统里需要锁?

在单体项目里,如果多个线程同时访问同一份共享资源,我们通常会想到 Java 本地锁。比如加 synchronized,或者使用 ReentrantLock,这样在一个 JVM 内部就能完成并发控制。

但系统一旦部署成集群,问题就变了。

因为这时候一个请求可能落到服务器 A,另一个请求可能落到服务器 B。两台机器上的线程虽然操作的是同一份业务资源,但它们根本不在同一个 JVM 里,所以本地锁只能锁住自己,锁不住别人。

这就意味着,像下面这些场景都可能出问题:

  • 同一个用户重复下单
  • 多个请求同时扣减同一份库存
  • 同一个定时任务被多台机器重复执行

这些问题看起来不同,本质上都在解决同一件事:如何让分布式环境下的多个请求,对同一个共享资源实现互斥访问。

Redis 之所以常被用来实现分布式锁,就是因为它天然是一个共享存储,各个服务实例都能访问到它,所以非常适合作为"锁状态"的载体。


二、Redis 分布式锁的底层思路是什么?

Redis 分布式锁最核心的思想其实不复杂:

谁先在 Redis 中成功创建某个 key,谁就获得这把锁;其他线程只能等待或者获取失败。

通常我们会看到这样一条命令:

bash 复制代码
SET resource_name my_random_value NX PX 30000

刚开始学的时候,我只觉得这是一条"加锁命令",后来才发现里面每一部分都很重要。

NX 表示只有 key 不存在时才设置成功,也就是"抢锁"。
PX 30000 表示给锁设置过期时间,避免服务异常退出后产生死锁。
my_random_value 不是随便写的值,而是当前持锁者的唯一标识,用来保证解锁时不会误删别人的锁。

所以 Redis 分布式锁真正要满足的,不只是"能抢到锁",还包括下面几个条件:

  • 加锁必须原子完成
  • 锁必须带过期时间
  • 锁值必须能标识持锁者
  • 解锁时必须确认删的是不是自己的锁

也正因为这些条件都要同时满足,我才越来越觉得:Redis 分布式锁看上去简单,真正想写稳其实并不容易。


三、为什么很多人手写的 Redis 锁并不安全?

  1. setnx和``expire 分开写,会留下隐患

很多人刚开始会这么写:

java 复制代码
if (setnx(lockKey, "1")) {
    expire(lockKey, 30);
}

表面上看好像没问题:先抢锁,抢到了再设置过期时间。

但问题在于,这两步不是原子操作。

如果 setnx 成功后,服务还没来得及执行 expire 就挂了,这把锁就没有过期时间,最后会变成永久锁。

所以更合理的做法,是直接使用一条命令同时完成"加锁 + 设置过期时间",而不是拆成两步。


2. 直接 del 解锁,可能误删别人的锁

这是 Redis 分布式锁里最经典的坑。

假设线程 A 拿到锁之后,业务执行时间太长,锁先过期了;随后线程 B 又拿到了同一个锁。

这时候线程 A 执行完再去 del,删掉的就不是自己的锁,而是线程 B 的锁。

所以 Redis 分布式锁在解锁时,不能直接删除,而是要先校验当前锁的 value 是否还是自己当初写进去的那个唯一标识。通常会用 Lua 脚本把"判断"和"删除"做成原子操作:

java 复制代码
String script =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "   return redis.call('del', KEYS[1]) " +
        "else " +
        "   return 0 " +
        "end";

这段脚本的意思就是:只有锁值匹配时,才允许删除锁,避免旧线程把新线程的锁误删掉。


3. 锁过期了,但业务还没执行完

这一点也是我后来才真正意识到的。

很多人觉得,只要锁设置了 TTL 就安全了,但实际上 Redis 锁的互斥性只在锁有效期内成立。

如果你给锁设置了 10 秒过期,但业务跑了 15 秒,那么后面那 5 秒里,其他线程就可能重新拿到这把锁。

这时候系统里就可能出现这样的情况:

  • 旧线程还在执行临界区代码
  • 新线程已经重新拿到锁并进入临界区

所以 Redis 锁很多时候实现的是"限时互斥",而不是无限期绝对互斥。


4. 主从切换场景下,锁也可能失效

Redis 分布式锁真正让我开始重视"边界"的地方,是主从切换这个问题。

如果客户端 A 在主节点上拿到了锁,但这条写入还没同步到从节点,主节点就挂了;随后从节点升级为新的主节点,这时候客户端 B 又能在新主节点上拿到同一把锁。结果就是,A 和 B 都认为自己持有锁,互斥性被破坏。

这也说明一件事:普通 Redis 主从复制架构下的分布式锁,并不能简单理解成绝对安全。


四、为什么很多 Java 项目更喜欢用 Redisson?

学到这里我最大的感受是:Redis 原生命令确实给了我们实现分布式锁的能力,但如果真的在 Java 项目里从零手写一套可用的锁逻辑,要考虑的边界太多了。

而 Redisson 的价值,就在于它把 Redis 锁封装成了更接近 Java Lock 的使用方式。

它提供的 RLock 本质上是一个分布式可重入锁,使用习惯和 ReentrantLock 很像。也就是说,它不是单纯帮我们往 Redis 里存一个 key,而是把很多原本需要自己处理的细节做了统一封装,比如:

  • 可重入
  • 自动过期
  • 线程持有者判断
  • 解锁约束
  • 等待获取锁的封装

所以我后来越来越能理解,为什么很多项目里更愿意直接用 Redisson。

它真正帮我们解决的,不只是"少写几行代码",而是把一个很容易写残的分布式锁问题,尽可能标准化成了一套更规范的 Java 用法。


五、Redisson 该怎么使用?

这一部分我想写得细一点,因为它是最容易直接落到项目里的。

1. 先创建 RedissonClient

在 Spring Boot 项目里,一般会先把 RedissonClient 配置成一个 Bean:

java 复制代码
@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://127.0.0.1:6379")
              .setPassword("123456");

    return Redisson.create(config);
    }
}

如果你的 Redis 是哨兵模式、集群模式或者主从模式,也可以切换到对应配置。这里我主要想说明:后续所有分布式锁操作,都是基于 RedissonClient 来完成的。


2. 获取一把锁

java 复制代码
RLock lock = redissonClient.getLock("lock:order:" + userId);

这行代码本身很简单,但我觉得真正值得注意的是:锁 key 的设计决定了锁的粒度。

比如:

  • 一人一单场景,可以按用户 id 加锁
  • 库存扣减场景,可以按商品 id 加锁
  • 定时任务场景,可以按任务名加锁

也就是说,锁 key 本质上定义了"谁和谁会互相竞争"。

如果粒度太粗,会把本来不冲突的请求也串行化;如果粒度太细,又可能锁不住真正的共享资源。


3. 最常见的写法:tryLock

java 复制代码
RLock lock = redissonClient.getLock("lock:order:" + userId);

boolean locked = false;
try {
    locked = lock.tryLock(1, 10, TimeUnit.SECONDS);
    if (!locked) {
        throw new RuntimeException("当前请求过多,请稍后再试");
    }

    // 业务逻辑
    // 查询订单 -> 判断是否已下单 -> 创建订单

} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    throw new RuntimeException("获取锁被中断", e);
} finally {
    if (locked && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

我对这段代码的理解是:

  • 第一个参数 1:最多等待 1 秒去获取锁
  • 第二个参数 10:拿到锁后,10 秒后自动释放
  • finally 里解锁:保证业务异常时也能释放锁
  • isHeldByCurrentThread():防止当前线程没有持有锁却去解锁

这种写法比较适合执行时间短、而且耗时容易预估的业务。

比如"一人一单"这种逻辑,整个临界区通常不会太长,用固定 leaseTime 往往就够了。


4. lock()tryLock() 该怎么选?

这是我学习 Redisson 时觉得特别需要分清楚的一点。

tryLock()

适合用户请求类场景。

因为这类接口通常不适合一直等待,拿不到锁就应该尽快失败,返回"请求频繁,请稍后重试"之类的信息。对用户请求来说,快速失败往往比长时间阻塞更合理。

java 复制代码
RLock lock = redissonClient.getLock("lock:task:" + taskId);

lock.lock();
try {
    // 执行业务
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

这种方式会一直等待,直到获取到锁为止。

如果没有显式传 leaseTime,Redisson 还会启用看门狗机制。

所以我现在更倾向于这样理解:

  • 用户请求型接口:优先考虑 tryLock
  • 必须串行执行的后台任务:更适合 lock

六、Redisson 为什么能比手写 Redis 锁更稳一些?

我觉得核心原因有三点。

1. 它支持可重入

RLock 是可重入锁。

也就是说,同一个线程在已经持有这把锁的情况下,可以再次进入这把锁保护的代码,而不会把自己锁死。

这件事的价值在于,复杂业务里经常会出现方法嵌套调用。

如果外层已经加了锁,内层又尝试获取同一把锁,没有可重入能力就很容易出问题。


2. 它对解锁做了更严格的约束

在 Redisson 里,解锁不是谁都能调。

一般来说,只有真正持有锁的线程,才能安全地执行 unlock()

所以我们在写代码时,通常也会先判断:

java 复制代码
if (lock.isHeldByCurrentThread()) {
    lock.unlock();
}

这一步虽然看起来只是一个细节,但其实是在避免"没持有锁却误解锁"的问题。


3. 它提供了看门狗机制

这一点是我觉得 Redisson 最值得学懂的地方,也是它和手写 Redis 锁差别最大的地方之一。


七、Redisson 的看门狗机制到底解决了什么问题?

如果锁是在没有显式指定**leaseTime** 的情况下获取的,那么 Redisson 会启用看门狗机制。

我一开始看这个机制时,觉得它解决的是一个很现实的矛盾:

  • 锁时间设置太短,业务还没执行完,锁就提前过期了
  • 锁时间设置太长,服务挂了以后,别人又得等很久

看门狗的思路其实很直接:

  • 客户端活着时,自动续期,尽量避免锁提前失效
  • 客户端挂了时,停止续期,让锁自然过期,避免永久锁死

比如下面这种写法:

java 复制代码
RLock lock = redissonClient.getLock("lock:report:" + reportId);

lock.lock();
try {
    // 执行一个耗时不可预估的任务
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

因为这里没有显式传 leaseTime,所以就会交给看门狗来管理。

我后来对这个机制的理解是:

它并不是让锁永远有效,而是在"业务没跑完"和"服务已经挂掉"这两种情况之间做平衡。

所以现在我自己的使用习惯是:

  • 短任务、耗时可控:tryLock(waitTime, leaseTime, unit)
  • 长任务、耗时不可预估:lock(),交给 watchdog 自动续期

八、一个更完整的业务示例

如果把它放到一人一单场景里,我会更倾向于这样写:

java 复制代码
@Service
public class OrderService {

    @Resource
    private RedissonClient redissonClient;

    public void createOrder(Long userId, Long voucherId) {
        String lockKey = "lock:order:" + userId;
        RLock lock = redissonClient.getLock(lockKey);

        boolean locked = false;
        try {
            locked = lock.tryLock(1, 10, TimeUnit.SECONDS);
            if (!locked) {
                throw new RuntimeException("请求过于频繁,请稍后再试");
            }

            // 1. 查询该用户是否已经下过单
            // 2. 如果下过单,直接返回
            // 3. 如果没有下过单,创建订单
            // 4. 扣减库存

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取锁被中断", e);
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

不过这里我也越来越觉得一个点很重要:

不要把分布式锁当成最终正确性的唯一保障。

如果是一人一单场景,我更倾向于这样设计:

  • Redisson 分布式锁:拦住大部分并发请求
  • 数据库唯一索引:作为最终兜底

因为锁更像第一道防线,数据库约束才是最后一道底线。


九、那 Redis 分布式锁真的安全吗?

如果只是问 Redis 分布式锁能不能在项目里用,我的答案是:能,而且很常用。

但如果问:只要用了 Redis 分布式锁,业务数据就绝对不会出错吗?

我的答案是:不能这么理解。

因为 Redis 锁本质上解决的是"分布式互斥访问"问题,不是万能的一致性方案。

Redisson 虽然通过可重入、自动续期、线程持有者判断等能力,帮我们把很多工程细节封装好了,但它也不等于可以单独兜住所有业务正确性。

所以我现在更认可的一种说法是:

Redis 分布式锁可以作为高性能并发控制手段,但不应该被当成系统正确性的唯一保障。

真正成熟的系统设计,往往还要再叠加这些手段:

  • 业务幂等
  • 数据库唯一约束
  • 补偿机制
  • 重试与告警

十、总结

这次学习 Redis 分布式锁,我最大的收获不是"会写一段加锁代码了",而是开始真正理解它的边界。

一开始我以为,分布式锁无非就是在 Redis 里加一个 key。

后来才慢慢意识到,真正可靠的实现至少要考虑这些问题:

  • 加锁是不是原子的
  • 解锁会不会误删别人的锁
  • 业务没跑完时锁会不会提前失效
  • 服务挂了以后锁会不会永久不释放
  • 集群故障切换时互斥性会不会被破坏

也是在这个过程中,我才更能理解为什么很多 Java 项目会直接选择 Redisson。它不是单纯让我们少写几行代码,而是在可重入、自动续期、线程持有者约束这些方面,把 Redis 锁真正封装成了更接近生产可用的方案。

相关推荐
oh LAN2 小时前
Windows 下 Redis 开机自启
数据库·windows·redis
iiiiyu2 小时前
常用API(Object类 & Objects类)
java·开发语言
2301_817672262 小时前
mysql如何批量增加表的字段_脚本化DDL操作实践
jvm·数据库·python
小碗羊肉2 小时前
【从零开始学Java | 第三十六篇】字符流
java·开发语言
专注VB编程开发20年2 小时前
万能数据库格式转换,导入导出表格,主键索引
数据库
DaqunChen2 小时前
mysql存储引擎性能基准测试_InnoDB与MyISAM对比指南
jvm·数据库·python
2301_782659182 小时前
CSS Flex布局中如何实现导航栏与Logo的左右分布_利用justify-content- space-between
jvm·数据库·python
海寻山2 小时前
Java枚举(Enum):基础语法+高级用法+实战场景+面试避坑
java·开发语言·面试
InfinteJustice2 小时前
CSS如何创建响应式导航栏菜单_结合Flexbox与媒体查询
jvm·数据库·python