前言
去年面蚂蚁的时候,二面提到自己项目里使用了guava cache来缓存一些高频读的配置文件,被问到了它的实现以及并发场景下可能存在什么问题,因为没有深入了解过,所以没回答上来。现在想来除了技术上的问题之外,也反映了当时我个人的思考不够的问题:
1 在对基础组件原理完全不了解的情况下就盲目使用,是否会因此带来一些隐藏的坑;
2 为什么没有抽时间去对用到的技术栈做一下简单的了解和调研,做出这样的技术选型的背景和理由根本讲不出来。
常常说要积累技术的广度和深度,就是应该要在这些地方多思考才行。想起之前一个组长说过的话,即使这些思考方式都是套路,都是所谓的方法论,但确实也是有所帮助的。
于是,终于在前段时间有空了解了一下,发现里面确实有很多东西值得学习,当初自己挂得也不算冤,于是打算写出来记录一下。
初步了解
篇幅问题这篇文章可能不会对guava cache整体实现的细节描写太多。所以这里只是简单介绍一下。
功能
guava cache提供的功能还是挺多的,参考美团技术博客里提到的:
- 自动将entry节点加载进缓存结构中;
- 当缓存的数据超过设置的最大值时,使用LRU算法移除;
- 具备根据entry节点上次被访问或者写入时间计算它的过期机制;
- 缓存的key被封装在WeakReference引用内;
- 缓存的Value被封装在WeakReference或SoftReference引用内;
- 统计缓存使用过程中命中率、异常率、未命中率等统计数据。
这几点功能在这篇文章里也都大概的会提到一些。
数据结构
看guava cache的代码可以发现,他的实现是和java老版本的concurrentHashMap有点相似的。
在localcache这个类中,实现了核心的数据结构和算法。继承abstractMap,实现concurrentMap接口,持有一个segment数组,segment继承自reentrantLock,有keyRederenceQueue队列,valueReferenceQueue队列,用于快速回收。recencyQueue用于记录访问了哪些节点来更新访问列表的顺序,在大于清除阈值或segment写时被清理;还有writeQueue按照写入时间排序,在写时添加到队尾;accessQueue按照访问时间排序,在访问时添加到队尾
ReferenceEntry数据结构用来记录节点数据,这种数据结构可以支持按照强引用或弱引用来生成key;recency队列用于实现lru算法;write和access这两个队列都是双向连表,用于实现最近最少使用算法。
使用方式
guava cache在创建时提供了两种创建方式
Loading cache
可以理解为在build cache的时候就定义好了缓存CacheLoader里的load方法,缓存中不存在时会去调用这个load方法加载数据
Callable cache
和loading cache不同的是,没有在build时传入CacheLoader,而是在使用get的时候传入一个callable对象来获取数据
这两种使用方式的原则都是在get操作时优先从缓存里读取数据,当数据不存在时通过cacheloader尝试加载数据写入缓存,callable cache的方式更灵活,loading cache的方式写起来更简单。
就不写示例代码来撑篇幅了。
使用场景
回收触发时机,缓存过期
按照以往对一些其他数据结构的了解,缓存过期策略的实现不外乎应该也是在读时判断是否超时,或者定时清理,鉴于这是个基础工具,应该不会去做定时清理这种重量级操作,所以应该还是在get的时候去判断是否超时,至于是根据写入时间还是根据读取时间,在这个框架里也是可以配置的,通过expireAfterWrite
和expireAfterAccess
通过看代码发现,这其实是比较简单的,entry节点记录了accessTime
和writeTime
,在根据key get value的时候,实现了一个getLiveValue方法
,在这个方法里判断过期策略,以及对比当前时间和节点过期时间,而这个时间分别是在写和读节点时通过recordWrite
和recordRead
方法写入的。
并发问题
总算到了重点,那么并发情况下会有什么问题呢。cache本身的实现是线程安全的,那么并发下存在的问题可能就是加锁导致的阻塞了。
当尝试通过get获取缓存值的时候,如果不存在key或者key已经过期,这时候需要去执行load方法加载数据,为了保证线程安全,这个方法会对segment加锁,在这种锁粒度下,其他尝试获取这个key的线程就会被waitForValue方法阻塞,如果加载速度过慢就会有大量的线程被阻塞。
解决方式
其实关于并发问题的解决是有通用方法的,当看到guava cache解决这些问题的方法的时候,不自主的就想到了之前看过的一些其他代码和解决方案。读多写少,热key问题,这几个关键词就自己浮现了出来。随便提一个,并发安全的读多写少的数据结构还有一种是copyonwrite,通过读写分离来实现,写操作完成前读使用旧值。
在guava cache下,可以通过创建时配置refreshAfterWrite
这个参数,定义在写入多久后刷新数据,在满足条件时只阻塞加载数据的线程,其余线程直接返回旧数据。
缓存雪崩
即使使用refreshAfterWrite
这种配置方式,当多个缓存到达刷新条件时,如果同时对这些key进行请求,依然会有多个线程会被阻塞,造成大量线程阻塞,并且他们会同时去调用load方法请求资源,造成服务端的压力。
这时可以通过重写实现CacheLoader的reload
方法,把加载数据的任务提交到线程池,这时用户请求不会被阻塞,会直接返回旧值,执行完成后而且线程池能够控制对资源的访问,不会对数据库等造成过大的压力。
结语
去年的这段时间是一段跌宕起伏的时间,去年一整年又是拥抱了巨大变化的一年,换了公司,换了城市,所以去年下半年的计划更多的是尝试稳定。不过已经到了新一年,希望自己在新的一年,能够对于周围的东西有更多的探索和思考,能够有所成长。