Redisson那么多锁,你都了解过吗
前言
上周五下午快下班的时候,实习生小张拿着电脑过来找我。
“哥,有个事儿想问问你。”小张挠挠头,“我在看咱们项目代码,发现到处都是锁,但用的都不一样。订单那块是getLock(),库存那儿又是getReadWriteLock(),转账模块居然还有getMultiLock()……”
我正准备收拾东西回家,听他这么一说倒是来了兴趣:“怎么,觉得奇怪?”
“对啊,为啥不都用一种锁呢?搞这么多花样,看着就头疼。”小张一脸迷茫,“而且我也不知道什么时候该用哪种。”
这问题问得好,我想起自己刚工作那会儿也是这样。那时候就知道加锁解锁,哪管什么类型,反正能跑就行。后来踩了不少坑才明白,不同的场景真的需要不同的锁。
“这样吧,正好也不急着走,我来给你讲讲这些锁都是干嘛用的。”
正文
1. 可重入锁(Reentrant Lock)
先说最简单的,也是用得最多的可重入锁。
为什么叫“可重入”?就是说同一个线程可以多次拿到同一把锁,不会把自己锁死。
这在方法嵌套调用的时候特别有用,比如方法A调用方法B,两个方法都要加锁,用可重入锁就不会出问题。
RLock lock = redissonClient.getLock("myLock");
lock.lock();
try {
// 处理业务
// 如果这里又调用了需要加锁的方法,不会死锁
lock.lock();
try {
// 嵌套的业务处理
} finally {
lock.unlock();
}
} finally {
lock.unlock();
}
这种锁适合什么场景呢?就是普通的分布式锁需求,保证同一时间只有一个节点在执行某段代码。
比如防止重复下单、避免重复处理同一个任务之类的。大部分情况下,用这个就够了。
2. 公平锁(Fair Lock)
公平锁听起来很高大上,其实就是“先来后到”。
普通锁谁抢到算谁的,可能会出现有些请求一直抢不到锁的情况。
公平锁就不一样了,严格按照申请的顺序来分配锁,不会有人饿死。
RLock fairLock = redissonClient.getFairLock("fairLock");
fairLock.lock();
try {
// 业务处理
} finally {
fairLock.unlock();
}
什么时候用?主要是对顺序有要求的场景。比如订单处理,你总不能让后提交的订单先处理吧。或者一些排队系统,得保证公平性。
不过公平锁的性能会差一些,毕竟要维护一个队列。所以如果对顺序没那么严格的要求,还是用普通锁比较好。
3. 联锁(MultiLock)
这个比较有意思,一次锁多个资源。
模拟个转账的场景:从账户A转钱到账户B,你得同时锁住这两个账户吧?如果分开锁,可能会出现死锁——线程1锁了A等B,线程2锁了B等A,谁都动不了。
联锁就是为了解决这个问题的。
RLock lock1 = redissonClient.getLock("lock1");
RLock lock2 = redissonClient.getLock("lock2");
RLock lock3 = redissonClient.getLock("lock3");
RLock multiLock = redissonClient.getMultiLock(lock1, lock2, lock3);
multiLock.lock();
try {
// 这里可以安全地操作多个资源
} finally {
multiLock.unlock();
}
联锁的策略是要么全部锁成功,要么全部失败,不会出现锁了一半的情况。这样就避免了死锁问题。
除了转账,还有什么场景会用到?比如库存扣减时需要同时锁定多个商品、或者需要同时更新多个相关的数据表等等。
4. 红锁(RedLock)
红锁是Redis官方推荐的一种分布式锁算法,听起来挺高大上的。
它的原理是在多个独立的Redis实例上同时加锁,只有大多数实例都加锁成功了,才算获取锁成功。这样即使某个Redis实例挂了,锁依然有效。
RLock lock1 = redissonClient.getLock("lock");
RLock lock2 = redissonClient2.getLock("lock");
RLock lock3 = redissonClient3.getLock("lock");
RLock redLock = redissonClient.getRedLock(lock1, lock2, lock3);
redLock.lock();
try {
// 业务处理
} finally {
redLock.unlock();
}
什么时候用红锁?主要是对可靠性要求特别高的场景。比如金融系统的关键操作,不能因为Redis挂了就出问题。
不过代价也很明显,你得准备多个Redis实例,成本和复杂度都上去了。
所以大部分情况下,普通的分布式锁就够用了,除非你的系统对可靠性要求极高。
5. 读写锁(ReadWrite Lock)
这个锁可能属于比较聪明的那种,分为读锁和写锁。
多个线程可以同时拿读锁,大家一起读没问题。但是写锁是排他的,写的时候别人不能读,读的时候也不能写。这在读多写少的场景下能大大提升性能。
RReadWriteLock rwLock = redissonClient.getReadWriteLock("rwLock");
// 读操作
RLock readLock = rwLock.readLock();
readLock.lock();
try {
// 读取数据
} finally {
readLock.unlock();
}
// 写操作
RLock writeLock = rwLock.writeLock();
writeLock.lock();
try {
// 写入数据
} finally {
writeLock.unlock();
}
典型场景就是缓存更新。大部分时候都是在读缓存,偶尔需要更新一下。用读写锁的话,读操作可以并发进行,只有更新的时候才需要独占。
还有配置文件读取、商品信息查询这些场景,都很适合用读写锁。
6. 信号量(Semaphore)
信号量其实并不是严格意义上的锁,但经常和锁一起用,所以这里也列出来,主要用来限流。
限流就不做过多解释了,最常用的算法就是google guava实现的令牌桶。
RSemaphore semaphore = redissonClient.getSemaphore("semaphore");
semaphore.trySetPermits(3); // 设置3个许可证
semaphore.acquire(); // 拿一个许可证
try {
// 处理业务
} finally {
semaphore.release(); // 还回去
}
什么时候用?主要是控制并发数量的场景。比如你的系统同时只能处理100个请求,或者调用第三方API有频率限制,这时候就可以用信号量来控制。
还有一个常见场景是数据库连接池,控制同时访问数据库的连接数,避免数据库被打垮。
7. 可过期信号量(PermitExpirableSemaphore)
这个是信号量的升级版,解决了一个很实际的问题。
普通信号量有个毛病:如果拿了许可证的线程挂了,许可证就回不来了,久而久之所有许可证都被占着,新的请求就进不来了。
可过期信号量给每个许可证设置了过期时间,时间到了自动释放,这样就不怕许可证泄露了。
RPermitExpirableSemaphore semaphore = redissonClient.getPermitExpirableSemaphore("expirableSemaphore");
semaphore.trySetPermits(3);
String permitId = semaphore.acquire(10, TimeUnit.SECONDS); // 10秒后过期
try {
// 业务处理
} finally {
if (permitId != null) {
semaphore.release(permitId);
}
}
什么时候用这个?主要是那些可能出现异常导致许可证无法正常释放的场景。
比如调用外部服务可能超时、网络可能中断等情况。
说白了,就是一个更稳定的限流器。
8. 闭锁(CountDownLatch)
闭锁是个同步工具,用来等待多个任务都完成。
比如说你要启动一个服务,需要等数据库连接、缓存连接、消息队列连接都初始化完毕才能对外提供服务。这时候就可以用闭锁。
java
RCountDownLatch latch = redissonClient.getCountDownLatch("latch");
latch.trySetCount(3); // 等待3个任务完成
// 在其他线程中,任务完成后
latch.countDown(); // 计数器减1
// 主线程等待
latch.await(); // 等计数器变成0
其实就是个计数器,从N开始往下减,减到0的时候等待的线程就可以继续执行了。
还有个常见场景是分布式任务处理。比如一个大任务被拆分成几个小任务,分别在不同的机器上执行,用闭锁来等待所有小任务都完成,然后汇总结果。
9. 自旋锁(Spin Lock)
自旋锁比较特殊,它不会让线程睡眠等待,而是一直"转圈"尝试获取锁。
普通锁的话,拿不到就睡觉,等别人叫醒。
自旋锁如果拿不到,就在那儿一直问“好了吗?好了吗?”,直到拿到锁为止。
RSpinLock spinLock = redissonClient.getSpinLock("spinLock");
spinLock.lock();
try {
// 业务处理(应该很快完成)
} finally {
spinLock.unlock();
}
什么时候用?主要是锁的持有时间特别短的场景。
因为自旋会一直占用CPU,如果等待时间长了就浪费资源了。
比如某些内存操作、简单的计算等,这些操作很快就能完成,用自旋锁比睡眠唤醒的开销小。
不过说实话,自旋锁在分布式环境下用得不多,因为网络延迟本身就不可控,很难保证锁的持有时间足够短。
组合使用的一些思路
实际工作中,单独用一种锁可能解决不了复杂的问题,这时候就需要组合使用了。
比如说,你有一个资源既要控制并发数量,又要区分读写操作。这时候可以把读写锁和信号量结合起来用:
java
RReadWriteLock rwLock = redissonClient.getReadWriteLock("dataLock");
RSemaphore semaphore = redissonClient.getSemaphore("connectionLimit");
// 读操作:既要获取读锁,也要获取信号量
semaphore.acquire();
rwLock.readLock().lock();
try {
// 读取数据
} finally {
rwLock.readLock().unlock();
semaphore.release();
}
这样既保证了读写的一致性,又限制了并发的数量。
这也是最常见的一种组合。
但组合使用的时候要注意顺序,一般建议先获取限流的锁(信号量),再获取业务锁,释放的时候反过来。这样能避免一些奇怪的问题。
锁的性能对比和选择建议
不同类型的锁在性能上有所差异:
性能排序(从高到低):
- 可重入锁 - 最基础,性能最好
- 自旋锁 - 适合短时间持锁
- 读写锁 - 读操作性能优秀
- 公平锁 - 保证顺序但性能较低
- 联锁/红锁 - 可靠性高但性能开销大
选择建议:
- 普通场景:使用可重入锁
- 读多写少:使用读写锁
- 需要顺序保证:使用公平锁
- 多资源操作:使用联锁
- 高可靠性要求:使用红锁
- 限流需求:使用信号量
- 同步等待:使用闭锁
结尾
说了一大堆,小张在那儿记笔记,时不时点点头。
“现在明白了吗?为什么项目里要用这么多不同的锁?”
“嗯,懂了。”小张合上笔记本,“订单那块业务简单,普通锁就够了;库存查询多,读写锁能提升性能;转账要锁两个账户,所以用联锁...”
“对,就这意思。”我拍拍他肩膀,“用锁这事儿,别想复杂了,就是看你要解决什么问题。简单问题用简单锁,复杂问题才考虑复杂锁。”
小张起身准备走,突然又问:“师兄,有没有什么捷径能快速掌握这些?”
我想了想:“捷径倒是没有,但有个建议——多看看别人怎么用的,然后自己试试。踩几个坑,印象就深刻了。理论看再多,不如实际动手试一遍。”
“好,那我回去就试试。”小张挥挥手走了。
看着他的背影,我想起了几年前的自己。那时候也是一头雾水,分不清什么时候该用什么锁,经常是能跑就行,性能什么的以后再说。现在想想,其实技术这东西,光看不练没用,得在实际业务中慢慢琢磨。
每种锁都有它的道理,关键是理解业务场景。
别为了用而用,也别因为复杂就不用。
合适的就是最好的。