ElIauk

Redisson 分布式锁实践与坑

从一段 SETNX 代码说起 很多人第一次写分布式锁是这样:SETNX lock:order 1,成功后再 EXPIRE lock:order 30。这两条命令分开执行,如果进程在中间挂掉,锁就永远不过期;如果业务执行超过 30 秒,锁自动释放,另一个线程进来,原线程执行完再把别人的锁删掉。后来大家

评论
Redisson 分布式锁实践与坑

从一段 SETNX 代码说起

很多人第一次写分布式锁是这样:SETNX lock:order 1,成功后再 EXPIRE lock:order 30。这两条命令分开执行,如果进程在中间挂掉,锁就永远不过期;如果业务执行超过 30 秒,锁自动释放,另一个线程进来,原线程执行完再把别人的锁删掉。后来大家学会把 value 写成 UUID,删除前比对,但“比对 + 删除”又不是原子的,仍然可能误删。

Redisson 把这些细节封装掉了:加锁、续期、可重入、等待唤醒都由 Lua 脚本和 watchdog 完成。但封装得越好,越容易让人忽略它的前提。用错参数、锁和事务的顺序搞反,照样会出并发问题。

Redisson 锁长什么样

Redisson 的 RLock 在 Redis 里是一个 Hash:key 是锁名,field 是“客户端 UUID + 线程 ID”,value 是重入次数。加锁时执行 Lua,判断 field 是否存在,不存在就写入并设置过期时间,存在且是自己的就重入计数加一。其他线程拿不到锁时,不是死循环自旋,而是订阅一个 channel,等释放通知。

watchdog 是 Redisson 的关键设计:调用无参 lock() 时,锁的租约是 30 秒,后台每 10 秒续一次,只要客户端还活着,锁就不会因为业务耗时而过期。一旦显式指定 leaseTime,watchdog 就不再续期,到点自动释放。这个区别是后面很多坑的根源。

代码:库存扣减锁

以 Spring Boot 3.2 + Redisson 3.27 为例,引入 redisson-spring-boot-starter 后,配置 Redis 地址即可:

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
      database: 0
@Service
public class InventoryService {

    private static final Logger log = LoggerFactory.getLogger(InventoryService.class);

    private final RedissonClient redissonClient;

    public InventoryService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    public boolean deduct(Long skuId, int count) {
        RLock lock = redissonClient.getLock("lock:inventory:" + skuId);
        boolean locked = false;
        try {
            locked = lock.tryLock(3, TimeUnit.SECONDS);
            if (!locked) {
                log.warn("库存锁获取失败, skuId={}", skuId);
                return false;
            }
            return doDeduct(skuId, count);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.warn("等待库存锁被中断, skuId={}", skuId);
            return false;
        } finally {
            if (locked && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    private boolean doDeduct(Long skuId, int count) {
        // 这里执行实际的库存判断与扣减
        return true;
    }
}

tryLock(3, TimeUnit.SECONDS) 只指定等待时间,不指定租约,锁会走 watchdog 自动续期。如果写成 tryLock(3, 10, TimeUnit.SECONDS),10 秒后锁一定释放,业务没跑完也不会续。

锁的粒度与等待策略

锁名要带业务主键,比如 lock:inventory:1001,不要用 lock:inventory 把全仓库存串行化。等待时间要设上限,宁可快速失败让上游重试,也不要把 Tomcat 线程堆在等锁上。对公平性有要求时可以用 getFairLock,代价是吞吐下降;读多写少的配置类数据可以用 getReadWriteLock,让读操作并行。

Redisson 还提供 MultiLock、Semaphore、CountDownLatch,但不要因为工具多就到处用。绝大多数业务只需要一把粗粒度的互斥锁,锁越复杂,死锁和超时的组合越多。

常见坑

1. 锁在事务提交前就释放了

这是最隐蔽的一类。方法上标了 @Transactional,在方法内部加锁、扣库存、finally 解锁;方法返回后事务才提交。解锁到提交之间,另一个线程拿到锁读到的还是旧库存,超卖照样发生。

解决办法是把锁放在事务外层:要么让加锁方法不开启事务,锁住之后再调用事务方法;要么在 finally 里等事务结束后再释放。更稳妥的是用 TransactionSynchronization#afterCompletion 释放锁,确保提交或回滚完成才解锁。

2. leaseTime 设得太短

业务里有远程调用、批量写库,执行时间超过租约,锁提前失效。watchdog 只在“不指定 leaseTime”时生效,很多人为了“避免死锁”随手传一个 5 秒,反而制造了并发窗口。除非能确定业务耗时上限,否则用无参 lock() 让 watchdog 兜底。

3. 解锁没判断持有者

直接 lock.unlock(),如果当前线程并没有持有锁,Redisson 会抛 IllegalMonitorStateException。更危险的是业务异常导致锁已被其他线程拿走,仍然执行 unlock。释放前用 isHeldByCurrentThread() 判断,并且放在 finally 里。

4. 把 Redisson 锁当成强一致保证

Redis 主从是异步复制,主节点宕机时,从节点可能还没收到锁数据,故障切换后新主上锁就“消失”了。单 Redis 主从架构下这是概率问题,不是配置能完全消除的。对资金类操作,要么用多主 RedLock(仍有争议),要么在数据库层加唯一约束、版本号做最终防线。

5. 长 GC 或网络抖动导致锁过期

watchdog 续期依赖客户端存活。一次几十秒的 Full GC,或客户端与 Redis 网络分区,续期失败,锁到期后别人就能进。出现这种极端情况,业务侧要有幂等和补偿,不能只靠锁。

排查步骤

怀疑锁没生效时,先看 Redis 里的锁对象和剩余时间:

redis-cli --scan --pattern 'lock:inventory:*'
redis-cli HGETALL lock:inventory:1001
redis-cli TTL lock:inventory:1001

Hash 里的 field 是“客户端 ID:线程 ID”,value 是重入次数,能判断是哪个实例、哪个线程持有。再看应用日志里 tryLock 的等待耗时,以及 jstack 中卡在 RedissonLock.tryLock 的线程数。如果锁频繁超时,优先怀疑业务执行时间过长或锁粒度过粗,而不是继续加大等待时间。

小结

Redisson 解决的是“锁的实现细节”,不解决“锁的边界设计”。用之前先确认三件事:锁住的范围是否覆盖事务提交、租约是否能覆盖最长业务耗时、失败后的兜底是否只依赖这把锁。把这三点想清楚,再谈 RedLock 和 fencing token 才有意义。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者