防止缓存击穿的土方子 - 读写锁

梦康 2021-08-31 00:00:00 927

前期故事回顾

前段时间搞出了个小故障,开发人员 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%都有缓存的,读锁是共享的,所以没有明显的性能损耗,可以放行使用。高端的食材往往只需要最朴素的烹饪方式,量体裁衣,未尝不可。