LRU淘汰算法优化
这个项目中,为了提高缓存命中率,我需要一个算法
这个算法的触发时机:当缓存超出阈值后,进行淘汰,淘汰时,脏数据先刷盘落盘,干净数据直接清缓存(假设磁盘已有副本)
在数据集中,热门数据会被经常访问,应当长期存放于内存中,不应该被淘汰,而冷门数据由于长期未被访问,所以在淘汰过程中应该被放进磁盘,清理缓存中的这个数据
这时候,我们就可以用LRU(最近最少使用算法),根据最近访问次序,在链表中有序排列,然后再淘汰的时候淘汰掉前n个最近未访问的数据
最久没用的先淘汰
具体实现方式可以参考力扣题目 LRU缓存
局限性
这个算法根据次序进行排列,然后删除最近未访问的数据
但如果考虑每个数据的数据大小和缓存容量,那就会体现这个算法的局限性
- 大对象只要保持不是最久没用,就永远不被淘汰 ,这会导致在多轮淘汰后,缓存的数据可能会被大数据占满,数据量少,导致淘汰频繁,命中率低
- 假设用户访问一个大数据(未在缓存),会在磁盘中读取出来,然后这个数据导致缓存超出阈值了,使得其他很多热门小对象被淘汰,而此后这个大数据就没被访问了,造成浪费
-
举个例子:
一个 1MB 的数据 2 小时没用, 而 100 个 1KB 的 1 天没用, LRU 会先淘汰那 100 个小数据(腾 100KB),1MB 的继续占着(占 1000 倍内存)
大对象写入缓存后造成了污染 ,如果那100个小数据全是热门数据(如1分钟内访问),由于大对象是最新访问,就丢失了100个小数据 如果再访问这些数据,就要进行大量的磁盘IO,且由于大对象会占用比较多的缓存空间,导致比较频繁的淘汰策略,从而导致大量的淘汰和磁盘IO
而如果对象的大小比较均衡,这个算法还是可以的
如果运行一段时间,这个缓存中间件的缓存中,主要以热门大数据为缓存,导致了缓存的数据量很少,而其他众多的热门小数据可能会进行大量的淘汰和磁盘IO
分析需求
因此,我需要同时考虑数据大小和使用频率这两个维度,但仔细思考会发现,使用频率在某些场合似乎依旧不合适
热门数据是有时效性的,假设一个数据A在某一个小时内被访问了100w次(如某些秒杀场景),而在之后的一周内,数据A均未被访问过,但是它的访问频率依旧非常高,造成缓存污染
而正常情况下,热点数据过了就应该被清掉,而不是留在缓存中
因此,当时在分析的时候,我还是决定用数据大小和最近时间间隔这两个维度,但是我们需要做一些处理,使得结果尽可能拟合实际
且时间间隔已经在一定程度上包含了频率这个信息,因为高频的数据时间间隔也会比较小,低频数据时间间隔就长,而且我用的是最近使用间隔,避免了刚刚的秒杀造成的缓存污染
使用数据大小和频率其实也可以,不过也要进行一些处理,这里我用时间间隔并进行一些处理
这个存储引擎还需要存储自定义数据类型 ,这可能会导致存储的数据大小差异会比较大,因此要考虑数据大小
总结一下,淘汰中我们需要考以下因素:
- 缓存阈值
- 数据大小
- 最近时间间隔
淘汰设计
现在我们需要借助这几个因素想办法如何淘汰淘汰性价比最高的数据 ,这里我用分数去表示性价比,而分数需要由数据大小和最近时间间隔这两个维度考虑
分数越大,越需要被淘汰
原因如下:
- 如果淘汰比较大的数据,那么能更快处理淘汰,缓存更快的低于阈值,且保证缓存中的热点数据比较多
- 如果淘汰最近时间间隔比较长的数据,目的和LRU一样
我们需要设计一个二元函数,去通过这个两个维度计算分数
先看下这个分数怎么用
为了尽快处理淘汰,少影响性能,我参考了Redis的思路:随机采样淘汰,并结合LRU,做了个新的使用方法
- 和标准的LRU一样(力扣语义),维护一个
最近最少使用链表,头是最近使用,尾是最久未使用,淘汰时,从尾部往回采样n个数据(最久未用区域),然后逐个算分数,淘汰分数最大的
看起来这个淘汰策略还是比较直观的,链表的尾部区域就是冷门数据比例比较多的地方,比Redis的全局随机采样淘汰理论上会好点
然后我们看看分数怎么计算
scss
分数 = f(数据大小) + g(最近间隔)
数据大小:dataSize
最近时间间隔:interval
时间模块在C++中用std::chrono::time_point,一般转换后数值比较大
数据大小也是,大对象可能会有几百MB,转换为B后数值也很大,而小对象却很小
这会造成量纲失衡,我们需要压缩量纲,适当减少数量级的差异
这里我对dataSize和interval取ln,这样大数据在取对数后就很小了
scss
分数 = ln(interval) + ln(dataSize)
但这又有个问题,log(时间间隔)把量级都压缩了(一天和一小时的差距太小):
- 场景:如果有一个很大的数据经常被访问(时间间隔接近0),而其他数据一天都没被访问,就会把这个大的数据给淘汰 因为时间差距太小了,导致这时候以数据大小为评判标准,把热门数据踢出了
于是,我又想到在ln取对数后,在前面加上系数来平衡
经过AI的帮忙,进行一顿计算,最后发现interval和dataSize取对数后,系数比为3:1比较合适,以时间为主,数据大小微调
这个比例,能够解决上述场景了
但如果我们换一个场景,这里我用了AI造的一组数据
3:1 下"时间为主"是软性的(权重 3 倍),不是硬隔离。时间差 e 倍 = 3 分,大小差 e³ = 20 倍 = 3 分 ------ 大 20 倍可以抵消"老 2.7 倍" 。举个实际例子(score = 3·ln(interval) + ln(size)):
- 100KB,3 小时没用:3×23.1 + 11.5 = 80.8
- 1MB,2.7 小时没用:3×23.0 + 13.8 = 82.8
- 1MB 的只新了 20 分钟,就因为大 10 倍反超,先被淘汰。 最初担心的"大数据侵蚀时间排序"只是被压住,没有消除 ------ 大小差异越大,这个反超越频繁。
问题: 这个比例是静态的,如果我们换一个场景,可能这个系数比例就不合适了,甚至淘汰策略会很烂,更不用说一些在动态变化的业务场景
我们还需要让这个公式具备一定的动态性
可能会问:那么,把系数弄成动态的不就行了,根据实际场景动态创造系数,然后执行函数计算分数,比如,我们根据当前缓存数据量,缓存大小来决定系数比?
但实际上,由于每次CRUD都需要对访问的数据进行更新,每次都要计算分数,但到了淘汰的时候,由于每个分数的值都是由不同的临时创造的函数计算得来,这导致每个分数的依据也不一样(缓存数据量,缓存大小不一样),
因为刚刚的实际场景是动态变化的,那么这个函数也会动态变化,分数的依据也会动态变化
- 这导致淘汰没有一个统一的标准,淘汰没有意义
那么还能怎么解决
最终公式设计
我最终想到了根据interval进行分段硬处理,
计算公式:
scss
score = 时间档位 * 档距 + ln(dataSize)
时间档位(建议静态常量表):<1min / <1h / <1d / <7d / ≥7d 五档,也可以根据具体业务调整,通过interval进行量级选择
档距取 30
ln(dataSize) 最大约 ln(memoSize) ≈ 20~25(10GB 经计算也就 23),档距30 保证跨档绝对无法依靠dataSize翻盘
也就是说,依靠LRU随机采样后,优先删除挡位靠后的,相同挡位的话比较dataSize
这样也减少了一个ln计算
这个公式既兼顾了interval,也兼顾了dataSize,且不需要调参:档位边界是自然时间锚点(1min/1h/1d/7d),任何业务都直观;极端场景(数据集中在档位临界)也只需调整档位表
这个算法还有一点小问题,经过资料的查阅,说是QPS在百万以上的话,log计算可能会成为性能消耗,不过一般情况下也不会出现这类问题,如果确实需要,我们可以把ln更换为近似计算,减少性能消耗
算法总结
-
LRU算法优化,缓存数据淘汰策略: 维度有两个:数据大小dataSize和最近使用间隔interval动态维护一个哈希链表LRU,和力扣的最近最少使用算法一样 分数计算公式:bashscore = 时间档位 * 档距 + ln(dataSize) log:ln 档位(建议静态常量表):<1min / <1h / <1d / <7d / ≥7d 五档,也可以根据具体业务调整 档距取 30:ln(dataSize) 最大约 ln(memoSize) ≈ 20~25(10GB 也就 23),30 保证跨档绝对无法翻盘 由于log会导致量纲过度压缩,一些数量级的差异不明显(比如一天未使用的小数据未被淘汰,而把经常使用的大数据淘汰了,这是非常影响性能的) 而如果对ln(dataSize),ln(dataSize)两个分别设置系数的话,由于是静态的系数,很难取到一个很好的值来满足大部分业务 分档加档距去做能符合大部分业务场景,极端情况就是很多数据都集中在挡位临界上,但这种情况非常少。但即使出现这种情况,也只需重新分档,调档距即可然后进行尾部采样(比如15个为一组),然后算分数值,淘汰掉分数最大的,淘汰n次,直到缓存大小低于阈值
- 先删除过期数据,再进行淘汰
淘汰的瓶颈不在计算log上 ,所以
dataSize的处理暂时先用ln,如果后续测试发现确实在log上,再重新设计dataSize的处理比如,可以用近似计算ln,换一种量纲压缩工具
-
由于该项目需要存储自定义数据类型,对象大小差异一般比较大,所以考虑了数据大小这个维度
比较(借助AI辅助了一下)
| 对比项 | 标准 LRU(力扣精确版) | Redis 近似 LRU | 本算法 |
|---|---|---|---|
| 淘汰依据 | 单维:最久未使用 | 单维:最久未使用(近似) | 双维:时间间隔分档 + 数据大小 |
| 淘汰对象选择 | 精确:直接删链表尾 | 全局随机采样 n 个(默认 5),删最久未用的 | 链表尾部(最久未用区域)采样 15 个,算分数删最大 |
| 容量管理 | 按条数 | 按字节(maxmemory) | 按字节(curSize vs memoSize) |
| 数据大小 | 不区分,每条等权 | 不区分,每条等权 | ln(dataSize) 加权,同档先淘汰大的 |
| 时间区分 | 连续精确 | 连续(采样近似) | 档位硬隔离(1min/1h/1d/7d,跨档绝对优先) |
| 时间复杂度 | O(1) | O(samples) | O(15) = O(1) |
| 淘汰精度 | 精确 | 近似(采样 5~10,越调大越接近精确) | 近似(采样 15,且采样区集中于高分区域,理论上优于全局随机) |
| 参数 | 无 | maxmemory-samples 可调 | 档位表(自然时间锚点,通常不用调) |
| 核心弱点 | 大对象占满缓存、单次访问大对象污染缓存 | 不感知大小,大对象同样污染 | 档位临界处近似(极少见,调档位表即可) |
一句话对比
- 标准 LRU:精确、O(1)、无参数,但只看时间不看大小,大对象污染缓存
- Redis 近似 LRU :用随机采样换掉精确排序(O(samples)),按字节管容量,但仍然不看数据大小 ------ 大对象和小对象等权,大对象污染问题没解决
- 本算法 :保留 Redis 的采样思路,但把采样区从"全局随机"改为"链表尾部"(更聚焦高分区域),并加上大小维度 + 时间档位硬隔离 ------ 解决大对象污染,同时"刚使用的大数据"受档位保护不被误淘汰
本质:标准 LRU 管"谁最久没用",Redis 管"谁大概率最久没用",本算法管"谁最该走"------久不用的大对象先走,刚用的多大都留下。