前期故事回顾
前段时间搞出了个小故障,开发人员 A 调用了一个我这边提供的接口,该个接口在前几天的一篇文章我把 PageHelper 的代码删了之后,性能提升了 20 倍 中有说,因为 count(*)
和翻页的问题,导致这个接口比较慢,需要1~3s
,之前这个接口都是后台调用,也没出现大面积的慢 sql
,所以也没着手优化。所以我和 A 说你加下缓存吧,我这边今天就先不改了。把困难留给别人,把简单留给自己的基本原则,见笑了。
上线后发现慢 sql
出现了堆积,我连夜修复并发布了,修复记录在这里,否则就要执行提桶跑路计划了。
其实热点数据不是很多,如果 A 调用的时候,对缓存数据的回源加了锁的话,问题不会这么大,所以呢,一方面修复了自己的问题,另一方面也聊聊缓存击穿的问题。
比较热门的方案都是说用 redis 的 setnx 方案,但是仅仅是这个是不足的,如果只是用来搞抢购,没有抢到锁的用户直接提示稍后重试,比如我给我妈抢号,手机屏幕都要戳破了,还是没抢到
计划明天用代理下手机抓包之后,不停的用脚本循环重试。
读写锁的使用场景
如果业务上不是抢购,用户刷新了10几次都是稍后再试,这是无法接受的。阻塞等待可能更好一些,那技术实现上的区别就是,让阻塞的线程等待持锁线程的通知,这个就比较麻烦,可以自己用 while
循环尝试,这是最 low 的方案,比较成熟的方案是使用redisson
来完成这个工作,但是目前来根据我的应用场景和服务器数量,分布式锁和本地锁没有太大的优势,本地锁的性能肯定要比分布式锁要好一些。
那么并发包里的可重入读写锁 ReentrantReadWriteLock
就比较适合这个场景了,多个线程间写写互斥,读写互斥,读读共享,简单理解,当 a 线程的写锁释放后,阻塞在获取读锁的 b 线程会收到操作系统通知,继续往下执行。
展开来说,ReentrantReadWriteLock
,它表示两个锁,一个是读操作相关的锁,称为共享锁;一个是写相关的锁,称为排他锁。
主要解决对共享资源有读和写的操作,且写操作没有读操作那么频繁。在没有写操作的时候,多个线程同时读一个资源没有任何问题,所以应该允许多个线程同时读取共享资源;但是如果一个线程想去写这些共享资源,就不应该允许其他线程对该资源进行读和写的操作了。
实例测试
下面的代码来自官方文档
class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// Must release read lock before acquiring write lock
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// Recheck state because another thread might have
// acquired write lock and changed state before we did.
if (!cacheValid) {
data = ...
cacheValid = true;
}
// Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // Unlock write, still hold read
}
}
try {
use(data);
} finally {
rwl.readLock().unlock();
}
}
}
高效的在 ide 里看文档,可以参考我前面的文章
private final ReentrantReadWriteLock xxReadWriteLock = new ReentrantReadWriteLock();
private XxDTO getXxMetaInfo(Integer id) {
XxDTO xxDTO;
xxReadWriteLock.readLock().lock();
try {
xxDTO = cache.get(XxDTO.class, CacheKeyPrefixEnum.DETAIL_CACHE, id);
if (null != xxDTO) {
return xxDTO;
}
} finally {
xxReadWriteLock.readLock().unlock();
}
try {
xxReadWriteLock.writeLock().lock();
XxDO xxDO = db.get(id);
XxTransfer transfer = new XxTransfer();
xxDTO = transfer.toDTO(xxDO);
cache.set(xxDTO, UccLiveCacheKeyPrefixEnum.DETAIL_CACHE, id);
xxReadWriteLock.readLock().lock();
} finally {
xxReadWriteLock.writeLock().unlock();
xxReadWriteLock.readLock().unlock();
}
return xxDTO;
}
实际业务加上读写锁之后,压测性能前后对比
第三列是接口的 TPS 第四列是平均 RT,上面的两行是加锁前,下面两行是加锁后分别进行了100并发3分钟和500并发3分钟的持续压测的结果。因为99.99%都有缓存的,读锁是共享的,所以没有明显的性能损耗,可以放行使用。高端的食材往往只需要最朴素的烹饪方式,量体裁衣,未尝不可。