所有 IT 从业者都接触过缓存,一定了解基本工作原理,业界流行一句话: 缓存就是万金油,哪里有问题哪里抹一下 。那他的本质是什么呢?

上图代表从 cpu 到底层硬盘不同层次,不同模块的运行速度,上层多加一层 cache, 就能解决下层的速度慢的问题,这里的慢是指两点:IO 慢和 cpu 重复计算缓存中间结果

但是 cache 受限于成本,cache size 一般都是固定的,所以数据需要淘汰,由此引出一系列其它问题:缓存一致性、击穿、雪崩、污染等等,本文通过阅读 redis 源码,学习主流淘汰算法

如果不是 leetcode 146 LRU [1] 刷题需要,我想大家也不会手写 cache, 简单的实现和工程实践相距十万八千里,真正 production ready 的缓存库非常考验细节

Redis 缓存淘汰配置

一般 redis 不建义当成存储使用,只允许当作 cache, 并设置 max-memory, 当内存使用达到最大值时,redis-server 会根据不同配置开始删除 keys. Redis 从 4.0 版本引进了 LFU [2] , 即 Least Frequently Used ,4.0 以前默认使用 LRU 即 Least Recently Used

  • volatile-lru 只针对设置 expire 过期的 key 进行 lru 淘汰
  • allkeys-lru 对所有的 key 进行 lru 淘汰
  • volatile-lfu 只针对设置 expire 过期的 key 进行 lfu 淘汰
  • allkeys-lfu 对所有的 key 进行 lfu 淘汰
  • volatile-random 只针对设置 expire 过期的进行随机淘汰
  • allkeys-random 所有的 key 随机淘汰
  • volatile-ttl 淘汰 ttl 过期时间最小的 key
  • noeviction 什么都不做,如果此时内存已满,系统无法写入

默认策略是 noeviction , 也就是不驱逐,此时如果写满,系统无法写入,建义设置为 LFU 相关的。 LRU 优先淘汰最近未被使用,无法应对冷数据,比如热 keys 短时间没有访问,就会被只使用一次的冷数据冲掉,无法反应真实的使用情况

LFU 能避免上述情况,但是 朴素 LFU 实现无法应对突发流量,无法驱逐历史热 keys ,所以 redis LFU 实现类似于 W-TinyLFU [3] , 其中 W 是 windows 的意思,即一定时间窗口后对频率进行减半,如果不减的话,cache 就成了对历史数据的统计,而不是缓存

上面还提到突发流量如果应对呢?答案是给新访问的 key 一个初始频率值,不至于由于初始值为 0 无法更新频率

LRU 实现

int processCommand(redisClient *c) {....../* Handle the maxmemory directive.** First we try to free some memory if possible (if there are volatile* keys in the dataset). If there are not the only thing we can do* is returning an error. */if (server.maxmemory) {int retval = freeMemoryIfNeeded();if ((c->cmd->flags & REDIS_CMD_DENYOOM) && retval == REDIS_ERR) {flagTransaction(c);addReply(c, shared.oomerr);return REDIS_OK;}}......
}

在每次处理 client 命令时都会调用 freeMemoryIfNeeded 检查是否有必有驱逐某些 key, 当 redis 实际使用内存达到上限时开始淘汰。但是 redis 做的比较取巧,并没有对所有的 key 做 lru 队列,而是按照 maxmemory_samples 参数进行采样,系统默认是 5 个 key

上面是很经典的一个图,当到达 10 个 key 时效果更接近理论上的 LRU 算法,但是 cpu 消耗会变高,所以系统默认值就够了。

LFU 实现

robj *lookupKey(redisDb *db, robj *key, int flags) {dictEntry *de = dictFind(db->dict,key->ptr);if (de) {robj *val = dictGetVal(de);/* Update the access time for the ageing algorithm.* Don't do it if we have a saving child, as this will trigger* a copy on write madness. */if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {updateLFU(val);} else {val->lru = LRU_CLOCK();}}return val;} else {returnNULL;}
}

当 lookupKey 访问某 key 时,会更新 LRU. 从 redis 4.0 开始逐渐引入了 LFU 算法,由于复用了 LRU 字段,所以只能使用 24 bits

 * We split the 24 bits into two fields:**     16 bits      8 bits* +----------------+--------+* + Last decr time | LOG_C  |* +----------------+--------+

其中低 8 位 counter 用于计数频率,取值为从 0~255, 但是经过取对数的,所以可以表示很大的访问频率

高 16 位 ldt ( Last Decrement Time )表示最后一次访问的 miniutes 时间戳, 用于衰减 counter 值,如果 counter 不衰减的话就变成了对历史 key 访问次数的统计了,而不是 LFU

/* Update LFU when an object is accessed.* Firstly, decrement the counter if the decrement time is reached.* Then logarithmically increment the counter, and update the access time. */
void updateLFU(robj *val) {unsigned long counter = LFUDecrAndReturn(val);counter = LFULogIncr(counter);val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}

LFUDecrAndReturn 将已有的 counter 计数衰减后返回, LFULogIncr 尝试对计数加一(有可能不加)后取对数,最后更新 val-lru

unsigned long LFUTimeElapsed(unsigned long ldt) {unsigned long now = LFUGetTimeInMinutes();if (now >= ldt) return now-ldt;return 65535-ldt+now;
}

注意由于 ldt 只用了 16位计数,最大值 65535,所以会出现回卷 rewind

LFU 获取己有计数

 * counter of the scanned objects if needed. */
unsigned long LFUDecrAndReturn(robj *o) {unsigned long ldt = o->lru >> 8;unsigned long counter = o->lru & 255;unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;if (num_periods)counter = (num_periods > counter) ? 0 : counter - num_periods;return counter;
}

num_periods 代表计算出来的待衰减计数, lfu_decay_time 代表衰减系数,默认值是 1,如果 lfu_decay_time 大于 1 衰减速率会变得很慢

最后返回的计数值为衰减之后的,也有可能是 0

LFU 计数更新并取对数

/* Logarithmically increment a counter. The greater is the current counter value* the less likely is that it gets really implemented. Saturate it at 255. */
uint8_t LFULogIncr(uint8_t counter) {if (counter == 255) return 255;double r = (double)rand()/RAND_MAX;double baseval = counter - LFU_INIT_VAL;if (baseval < 0) baseval = 0;double p = 1.0/(baseval*server.lfu_log_factor+1);if (r < p) counter++;return counter;
}

计数超过 255, 就不用算了,直接返回即可。 LFU_INIT_VAL 是初始值,默认是 5

如果减去初始值后 baseval 小于 0 了,说明快过期了,就更倾向于递增 counter 值

double p = 1.0/(baseval*server.lfu_log_factor+1);

这个概率算法中 lfu_log_factor 是对数的,默认是 10, 当 counter 值较小时自增的概率较大,如果 counter 较大,倾向于不做任何操作

counter 值从 0~255 可以表示很大的访问频率,足够用了

# +--------+------------+------------+------------+------------+------------+
# | factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
# +--------+------------+------------+------------+------------+------------+
# | 0      | 104        | 255        | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 1      | 18         | 49         | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 10     | 10         | 18         | 142        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 100    | 8          | 11         | 49         | 143        | 255        |
# +--------+------------+------------+------------+------------+------------+

基于这个特性,我们就可以用 redis-cli --hotkeys 命令,来查看系统中的最近一段时间的热 key, 非常实用。老版本中是没这个功能的,需要人工统计

$ redis-cli --hotkeys
# Scanning the entire keyspace to find hot keys as well as
# average sizes per key type.  You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).
......
[47.62%] Hot key 'key17' found so far with counter 6
[57.14%] Hot key 'key43' found so far with counter 7
[57.14%] Hot key 'key14' found so far with counter 6
[85.71%] Hot key 'key42' found so far with counter 7
[85.71%] Hot key 'key45' found so far with counter 8
[95.24%] Hot key 'key50' found so far with counter 7-------- summary -------Sampled 105 keys in the keyspace!
hot key found with counter: 7 keyname: key40
hot key found with counter: 7 keyname: key42
hot key found with counter: 7 keyname: key50

谈谈缓存的指标

前面提到的是 redis LFU 实现,这是集中式的缓存,我们还有很多进程的本地缓存。如何评价一个缓存实现的好坏,有好多指标,细节更重要

  • 吞吐量:常说的 QPS, 对标 bucket 实现的 hashmap 复杂度是 O(1), 缓存复杂度要高一些,还有锁竞争要处理,总之缓存库实现的效率要高
  • 缓存命中率:光有吞吐量还不够,缓存命中率也非常关键,命中率越高说明引入缓存作用越大
  • 高级特性:缓存指标统计,如何应对缓存击穿等等

缓存就是万金油,哪里有问题哪里抹一下 。那他的本质是什么呢?相关推荐

  1. 阅读 redis 源码,学习缓存淘汰算法 W-TinyLFU

    所有 IT 从业者都接触过缓存,一定了解基本工作原理,业界流行一句话: 缓存就是万金油,哪里有问题哪里抹一下 .那他的本质是什么呢? 上图代表从 cpu 到底层硬盘不同层次,不同模块的运行速度,上层多 ...

  2. 缓存,你真的用对了么?

    缓存,你真的用对了么? 缓存,是互联网分层架构中,非常重要的一个部分,通常用它来降低数据库压力,提升系统整体性能,缩短访问时间. 有架构师说"缓存是万金油,哪里有问题,加个缓存,就能优化&q ...

  3. 程序员修神之路--缓存架构不够好,系统容易瘫痪

    " 灵魂拷问 缓存能大幅度提高系统性能,也能大幅度提高系统瘫痪几率 怎么样防止缓存系统被穿透? 缓存的雪崩是不是可以完全避免? 前几篇文章我们介绍了缓存的优势以及数据一致性的问题,在一个面临 ...

  4. 缓存架构不够好,系统容易瘫痪

    作者 | 菜v菜 来源 | 架构师修行之路(ID:jiagoushixiuxing) 头图 |  CSDN 下载自东方IC 灵魂拷问 缓存能大幅度提高系统性能,也能大幅度提高系统瘫痪几率 怎么样防止缓 ...

  5. ABP学习实践(十五)--缓存使用总结

    近期在工作过程中对ABP框架的缓存功能又有了深一步的理解,做一个小小的总结. 1.关于缓存 现在相当一部分小伙伴听到缓存立刻想到Redis,反应很快,但容易进入一个误区"处理缓存就要用Red ...

  6. Linux缓存之TLB

    Linux缓存之TLB 1. MMU 2. 页表与 TLB 结构 3. TLB本质 4. TLB表项 5. TLB的特殊 6. TLB的别名问题 7. TLB的歧义问题 8. 如何尽可能的避免flus ...

  7. 8分钟带你学会SpringBoot整合Redis来实现缓存技术

    1.概述 随着互联网技术的发展,对技术要求也越来越高,所以在当期情况下项目的开发中对数据访问的效率也有了很高的要求,所以在项目开发中缓存技术使用的也越来越多,因为它可以极大的提高系统的访问速度,关于缓 ...

  8. 大规模 Node.js 网关架构设计与工程实践

    作者:王伟嘉,腾讯云 CloudBase 前端负责人. 本文是王伟嘉在 GMTC 2021 全球大前端技术大会(深圳站)上的演讲内容:<十亿级 Node.js 网关的架构设计与工程实践>. ...

  9. 腾讯云十亿级 Node.js 网关的架构设计与工程实践

    作者|王伟嘉 编辑|孙瑞瑞 本文由 InfoQ 整理自腾讯云 CloudBase 前端负责人王伟嘉在 GMTC 全球大前端技术大会(深圳站)2021 上的演讲<十亿级 Node.js 网关的架构 ...

最新文章

  1. Go 学习笔记(77)— Go 第三方库之 cronexpr(解析 crontab 表达式,定时任务)
  2. 常用的数据结构-数组
  3. WebSocket 中的Netty
  4. 简单的计数器程序_javaweb
  5. Ogitor的编译配置全过程
  6. mysql 排序_MySql的几种排序方式
  7. SSRS:之为用户“NT AUTHORITY\NETWORK SERVICE”授予的权限不足,无法执行此操作。 (rsAccessDenied)...
  8. pythonchallenge_level2
  9. STM32那点事(5)_ADC(上)
  10. ajax html页面传值乱码,jQuery Ajax传值到Servlet出现乱码问题的解决方法
  11. Linux系统LVM逻辑卷工作原理,必看~
  12. SVN遗漏so文件的解决办法
  13. mysql-innodb-undo和redo
  14. 博弈中的 SaaS 渠道
  15. java swing 飞机大战游戏 github 免费 开源 公开 源码
  16. 批量导入经纬度点到奥维地图中
  17. 小程序连表查询(lookup)
  18. 信息熵:什么是信息熵?
  19. JavaScript系列—Object.assign()介绍以及原理实现
  20. 昱琛航空IPO被终止:曾拟募资5亿 郭峥为大股东

热门文章

  1. 菱形继承中构造函数调用问题
  2. 获得好资料都的渠道,老铁告别坑人的百度吧
  3. 阿里云网站备案申请被驳回的问题解答汇总
  4. 数组_二维数组的初始化方式
  5. 淘宝/天猫邻家好货 API 返回值说明
  6. Android仿京东、淘宝商品详情页上拉查看更多详情
  7. 扎心!原来在月薪3000和30000的差距竟是这样
  8. Jenkins详细邮件配置
  9. 厦大C语言上机 1510 小明的随机数
  10. 物理层设备(中继器和集线器)