3.9. 缓存设计

缓存的重要性无需多言,合理的缓存设计既可以提升系统的性能又兼具一定的防攻击能力。

以作用域为维度缓存可分为本地缓存与分布式缓存,本地缓存常见于Ehcache、Guava Cache,两者都很成熟,Ehcache可以实现数据持久化,也有相应的集群方案,提供与主流框架集成的能力,功能丰富,Guava Cache小巧、简单,轻量化,当然我们也可以自己基于LinkedHashMap实现一个简单的本地缓存。在项目中我使用得更多的是分布式缓存,常见于Memcached、Redis、Hazelcast等,性能上三者都可以满足绝大部分的业务场景,Memcached不支持持久化,数据结构单一,Redis支持多种数据结构4.x版本更是加入了Stream计算能力,Hazelcast虽没有前两者知名,但以它和Ignite为代表的分布式内存计算平台在缓存处理上也有不俗的表现,如果项目既需要分布式缓存又需要一些集群特性(如分布式Map/锁/远程服务调用等)时Hazelcast或Ignite会是不错的选择,另外在一些场景下我们也会选择Elasticsearch充当缓存。

相比本地缓存,分布式缓存在设计使用上需要注意以下几点:

  • 缓存失效 在用缓存时不可避免地会涉及缓存失效,失效有多种原因,比如过期时间设置不合理大量Key同时过期,请求直接访问数据库进而导致数据库压力陡增,再比如批量重建缓存导致缓存雪崩。对这一问题有多种解决方案,就仅针对缓存本身而言需要错开各Key过期时间及分批次刷新
  • 缓存穿透 在缓存失效中更为普遍的是缓存穿透:请求一个数据库不存在的记录,程序上不去缓存这些不存在记录对应的key,进而导致每次请求都去查数据库,这是缓存设计最容易被遗漏的地方。一般情况下不会有问题,不会有大量这种“错误”的请求,但如果被攻击利用就可能是致命的,原本要保护数据库资源的缓存完全失效。知道原理后解决的方案也简单:无论请求条件在数据库中存不存在都加入到缓存即可。当然这样会带来两个问题:1)现在为空不代表以后一直为空,2)可能存在大量值为空的垃圾记录。第一个问题是关于缓存一致性,如果Key为查询条件,需要设置业务上比较合理的过期时间,如果key为Id,只要在持久化到数据库时更新缓存中Id对应的值即可,第二个问题多为攻击情况下会产生很多为空的记录,以Redis为例,常用操作(时间复杂度为O(1))对Key的数量并不敏感,这些记录只会占用一定的空间,对性能影响有限,当然考虑存储的压力我们也可以用BloomFilter或Hyperloglog来避免这一问题,如果是IP路由则本地BloomFilter或Hyperloglog,反之也可以使用分布式方案,分布式BloomFilter可用如Redis的Orestes-Bloomfilter、Rebloom (以Redis模块的方式加载)、Redisson,分布式Hyperloglog为Redis自带的数据结构。缓存穿透还有一种特殊的场景:高并发访问某一新增的、未被缓存的记录,在第一条请求从数据库读取到加入缓存并生效的时间窗口内大量的并发会穿透缓存给数据库带来很大的压力,这也极有可能被用于攻击,解决的方法可以是限流,当然这种做法过于粗暴,限流应该是保障系统可用性的最后几道屏障,是不得以而为之的,可以是数据变更时先写缓存再写数据库,这可能会带来一致性风险,也可以是写完数据库后立刻更新缓存(不等到读取时再更新缓存),但并不是所有记录都需要缓存,这可能会导致大量不必要的缓存开销,还可以是对穿透到数据库查询的代码进行加锁,笔者认为这种方法最为优雅,实现的伪代码如下:
    /**
      * 假定有并发请求1、2、3
      * 请求1先进入synchronized代码块,请求2、3等待
      * 请求1会查询数据库并更新缓存,退出synchronized
      * 请求2、3依次进入synchronized并命中缓存
      * /
    // 先从缓存读取对应id的信息流
    var record = cache.get("feed:" + id)
    if (record == null) {
      // 缓存未命中时加锁,要求串行化执行
      synchronized {
        // 再次尝试从缓存读取对应id的信息流
        record = cache.get("feed:" + id)
        if (record == null) {
          // 从数据库读取并更新缓存
          record = db.feed.get(id)
          cache.put("feed:" + id, record)
        }
      }
    }
    
  • 缓存一致性 缓存与数据库的数据一致性视不同情况而定,大部分场景下都要求是一致的,即数据库数据变更要同步刷新到缓存中,但刷新缓存可能会是一个耗时、耗IO的操作,尤其是数据批量变更时缓存数据的延时不可忽视,另外存在多级缓存时也需要考虑缓存内的数据同步,在缓存设计时数据同步及一致性问题要重点考虑
  • 热点数据 现实场景中我们的数据不大可能做到均匀分布,必定会出现冷热分化,缓存用于存放热点数据,但有一些特殊情况下会出了极热点的数据,这些数据会对分布式缓存的个别节点造成很大的压力,如业务上存在这种场景需要考虑做多级缓存为这些极热点的数据添加前置缓存层或是将Key Hash化以规避数据倾斜

系统架构中必须认真对待缓存的设计,上文只是简要地介绍了分布式缓存设计的几个要点,现实中我们的各类中间件/工具也都会带缓存(比如浏览器、Nginx、Hystrix、MySQL等),缓存设计中也要充分利用这些已有的缓存特性。

下一节:锁是我们经常接触的用于保护资源避免并发竞争的工具,在JVM的发展过程中锁的演化更是历久弥新,当下我们可以用公平锁实现顺序排队用非公平锁实现优先级管理,用可重入锁减少死锁的几率,用读写锁提升读取性能,JVM更是实现了从偏向锁到轻量级锁再到重量级锁的逐渐膨胀优化,更多的情况下我们还可以基于CAS 的原子操作包(java.util.concurrent.atomic)实现无锁(乐观锁)编程。