用 Redis + Redisson 实现分布式锁:从踩坑到生产可用

用 Redis + Redisson 实现分布式锁:从踩坑到生产可用

单机部署时,一个 synchronized 就能锁住临界区。但服务一旦扩到多实例,JVM 级别的锁就完全失效了------两个实例各自持有自己的锁,谁也拦不住谁,超卖、重复下单立刻就来了。这时候就需要一把所有实例都认的「分布式锁」。

Redis 是最常见的实现载体。很多人上手直接写 SETNX,结果在生产上锁死、锁被别人释放、锁提前过期各种问题轮番上演。这篇我们从最朴素的写法一路踩坑,最后落到用 Redisson 的生产级方案。

朴素写法:SETNX 为什么不够

先看最容易想到的写法:

java 复制代码
// 反例:问题一大堆,别在生产用
public boolean tryLock(String key, String value) {
    // setIfAbsent 就是 SETNX
    Boolean ok = redisTemplate.opsForValue().setIfAbsent(key, value);
    return Boolean.TRUE.equals(ok);
}

public void unlock(String key) {
    redisTemplate.delete(key);
}

这段代码至少有三个致命问题:

  1. 没有过期时间:如果加锁的实例在释放前崩溃了,这个 key 永远留在 Redis 里,后面所有人都拿不到锁------死锁。
  2. 加锁和设过期不是原子的 :就算你补一句 expire,setIfAbsent 成功后、expire 执行前进程挂掉,还是死锁。
  3. 误删别人的锁 :A 的锁因为业务超时自动过期了,B 拿到了锁;这时 A 的业务执行完调 unlock,把 B 的锁删掉了。

修正第一步:原子加锁 + 校验持有者

前两个问题好解决,SET key value NX PX 一条命令原子完成加锁和设过期:

java 复制代码
public boolean tryLock(String key, String value, long expireMs) {
    // NX + 过期时间一步到位,不会出现加锁成功但没设过期的窗口
    Boolean ok = redisTemplate.opsForValue()
            .setIfAbsent(key, value, Duration.ofMillis(expireMs));
    return Boolean.TRUE.equals(ok);
}

第三个问题(误删)要靠「value 存唯一标识 + 释放时校验」。但校验和删除必须原子,否则「判断是我的锁」和「删除」之间锁又过期了,还是会误删。用 Lua 脚本保证原子性:

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

public boolean unlock(String key, String value) {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_LUA, Long.class);
    Long res = redisTemplate.execute(
            script, Collections.singletonList(key), value);
    return Long.valueOf(1).equals(res);
}

valueUUID + 线程 ID 之类的唯一值。现在锁基本能用了,但还有个绕不开的坑:锁过期时间怎么定?

设短了,业务没跑完锁就没了;设长了,实例崩溃后别人要等很久。这就是「锁续期」问题------手写很难做好,该上轮子了。

生产方案:Redisson

Redisson 把上面这些坑全填了,还自带看门狗(watchdog)自动续期:只要业务没结束,后台线程每隔 1/3 过期时间就把锁续到满,业务跑多久锁就撑多久;一旦持锁进程挂掉,续期停止,锁到期自动释放。

引入依赖:

xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.27.2</version>
</dependency>

配置一个客户端:

java 复制代码
@Configuration
public class RedissonConfig {
    @Bean(destroyMethod = "shutdown")
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://127.0.0.1:6379")
              .setDatabase(0);
        return Redisson.create(config);
    }
}

用起来就非常干净了:

java 复制代码
@Service
public class OrderService {

    @Autowired
    private RedissonClient redisson;

    public void deductStock(long skuId) {
        RLock lock = redisson.getLock("stock:lock:" + skuId);
        // 不传租约时间 -> 启用看门狗自动续期(默认 30s,每 10s 续一次)
        // waitTime=5s:拿不到锁最多等 5 秒
        try {
            boolean locked = lock.tryLock(5, TimeUnit.SECONDS);
            if (!locked) {
                throw new RuntimeException("系统繁忙,请重试");
            }
            // ---- 临界区:真正的扣减库存逻辑 ----
            doDeduct(skuId);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取锁被中断", e);
        } finally {
            // isHeldByCurrentThread 防止误释放别的线程/续期后的锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

几个关键点讲清楚:

  • tryLock(waitTime, unit) 不传 leaseTime ,才会启用看门狗。如果你写成 tryLock(5, 30, TimeUnit.SECONDS)(带 leaseTime),看门狗不生效,30 秒后锁硬过期,长事务会中招。这是最常见的误用。
  • finally 里判断 isHeldByCurrentThread():避免锁已因异常提前释放,又 unlock 一次报错。
  • Redisson 的锁是可重入 的,底层用 Hash 存重入次数,同一线程多次 lock 不会自锁死。

一个容易忽略的坑:主从切换丢锁

单机 Redis 用上面的方案已经很稳。但如果是主从架构,存在一个理论风险:客户端在 master 上加锁成功,数据还没同步到 slave,master 就宕机了,slave 提升为新 master------这把锁就凭空消失了,另一个客户端能再次加锁,互斥被打破。

对一致性要求极高的场景(如金融扣款),Redisson 提供 RedLock 思路的联锁,或直接换用 getFairLock 配合多独立 Redis 节点。但 RedLock 本身在业界有争议(Martin Kleppmann 与 antirez 的著名辩论),复杂度也高。多数业务场景下,单机/哨兵 + Redisson 看门狗已经够用,不要为极小概率的边界过度设计。

如果确实要求强一致,更稳妥的其实是用支持共识的存储(如 ZooKeeper、etcd)做锁,而不是硬磕 Redis 的边界。

小结

  • SETNX 手写锁的三大坑:无过期(死锁)、加锁与过期非原子、释放误删别人的锁。
  • 修正方向:SET NX PX 原子加锁 + value 存唯一标识 + Lua 脚本原子校验删除。
  • 锁续期手写很难,生产直接上 Redisson :tryLock(waitTime) 不带 leaseTime 触发看门狗自动续期,业务跑多久锁撑多久,进程挂了自动释放。
  • finally 里必须 isHeldByCurrentThread()unlock;别误用带 leaseTime 的重载把看门狗关掉。
  • 主从切换丢锁是理论风险,一般业务不必过度设计;强一致场景考虑 etcd/ZooKeeper。

一句话记忆:手写锁重点是「原子」,生产用锁重点是「续期」------前者靠 Lua,后者靠 Redisson 看门狗。

相关推荐
谙忆1 小时前
图片CDN与边缘优化实战:缓存策略、图片处理型CDN与动态裁剪
java·spring·缓存
ltqvibe1 小时前
怎么让本体语义变成可执行的智能体
java·人工智能·本体语义
qiu_lovejun9981 小时前
linux安装docker和redis和rabbitmq和nginx和rocketmq和kafka
linux·redis·docker·kafka·rabbitmq·rocketmq
javajenius2 小时前
Masslynx 4.1安装包免费下载及详细安装教程
java·服务器·前端·其他
程序员吕洞宾2 小时前
开源多维表格SmartTable V1.6.1:工作流画布,让自动化工作流配置更直观、更方便
java·开源·自动化
董可伦2 小时前
Spark 源码 | SparkSubmitArguments 参数解析(三)
大数据·分布式·spark
我登哥MVP2 小时前
走进 Gang of Four 设计模式:访问者模式
java·设计模式·访问者模式·原型模式
雪碧聊技术2 小时前
一文深入理解 Java 函数式编程思想
java·函数式编程
AIGS0012 小时前
突破语义鸿沟:从向量空间JBoltAI看企业大脑构建逻辑
java·大数据·人工智能·人工智能ai大模型应用