1. 背景
在撸代码时,利用局部性原理对数据做缓存是一种常用的性能优化手段。
要做缓存,离不开的就是缓存组件。ccache就是一个很优秀的lru缓存组件,其做了很多很巧妙的优化策略来降低锁冲突,实现高性能。
降低锁冲突的策略有
- 一个元素在累计被访问多次后才做提权(提权指将元素移动到lru链的头部)
- 将提权操作放到一个队列中,由一个单独的线程做处理
- 在同一个线程中做垃圾回收操作
下面看下具体是怎么实现的。
2. lru cache
在分析源代码前,先简单了解下lru cache是做什么的。
lru为least recently used的缩写,顾名思义,lru cache在缓存满后,再缓存新内容需先淘汰最久未访问的内容。
要实现lru策略,一般是用hashtable和list数据结构来实现,hashtable支持通过key快速检索到对应的value,list用来记录元素的访问时间序,支持淘汰最久未访问的内容。如下图
在hashtable中,key对应的内容包含两部分,第一部分为实际要存储的内容,这里定义为value,第二部分是一个指针,指向对应在list中的节点,将其定义为element。
在list中,每个节点也包含两个部分,第一个部分是一个指针,指向hashtable中对应的value,这里定义为node,第二部分是next指针,用来串起来整个链表。
若我们执行get(key2)操作,会先通过key2找到value2和element2,通过element2又能找到node2,然后将node2移动到list队首,所以执行完get(key2)后,上图会变为
这时假如又有一个set(key5, value5)操作,而我们的cache最多只能缓存4条数据,会怎么处理呢。首先会在hashtable中插入key5和value5,并且在list的队首插入node5,然后取出list队尾的元素,这里是node4,将其删除,同时删除node4对应的在hashtable中的数据。执行完上图会变成
通过上述流程,可以很好的实现lru策略。但是因hashtable和list这两种数据结构都不是线程安全的,若要在多线程环境下使用,无论set操作还是get操作都需要加锁,这样就会很影响性能,特别是现在的服务器cpu核心数量越来越多,加锁对性能的损耗是非常大的。
3. ccache优化策略
针对上面的问题,ccache采用了下面几种优化策略,都非常的巧妙。
3.1 对hashtable做分片
这是个很常见的策略。
将一个hashtable根据key拆分成多个hashtable,每个hashtable对应一个锁,锁粒度更细,冲突的概率也就更低了。
如图所示,一个hashtable根据key拆分成三个hashtable,锁也变成了三个。这样当并发访问hashtable1和hashtable2时,就不会冲突了。
3.2 累计访问多次才做提权
value中新增一个访问计数,每次get操作时,计数+1。当计数达到阈值时,才将其移动到list的队首,同时将计数重置为0。
如阈值是3,那么对list的写操作就会降低3倍,锁冲突的概率也会减少3倍。
这是一个有损的策略,会使list的顺序不完全等同于访问时间序。但考虑到lru cache的get操作频率很高,这种策略对命中率的损失应该是可以忽略的。
3.3 单开一个线程更新list
在get和set操作时,都需要更新记录访问时间序的list,但更新操作只需要在下次set操作前完成就可以,并不需要实时更新。基于这一点,可以单独开一个更新线程对list做更新。get和set时,提交更新任务到队列中,更新线程不停从队列中取任务做更新。
这样做有两个好处
- list不存在多线程访问,不需加锁
- 操作完hashtable直接返回,异步更新list,函数相应速度更快
这样会带来一个问题,当cpu核心很多,get和set的qps很高时,这个更新线程可能成为瓶颈。不过考虑到list的操作是非常轻量的,再加上服务不可能全部资源都放到读写cache上,这点也是可以忽略的。
3.4 批量淘汰
当缓存满了后,一次淘汰一批元素。优化在缓存满了的时候,每次set新元素都会触发淘汰的问题。
3.5 整体流程
在实现完上述策略后,整体流程大致是这样的