"叮......", 美好的周六就这么被一阵钉钉消息吵醒了.
业务组的同学告诉我说很多用户的帐号今天被强制下线. 我们的帐号系统正常的逻辑是用户登录一次后, token 的有效期可以维持一天的时间. 现在的问题是用户大概每 10 分钟左右就需要重新登录一次. 这种情况一般有两种原因: 1,token 生成时出问题. 2, 验证 token 时出现问题.
通过检查日志, 我发现是验证 token 时, Redis 中已经没有对应的 token 了. 并且确定了生成新的 token 时, set 到 Redis 中的有效期是正确的, 那么就基本可以确定是 Redis 的问题了.
于是又去检查了 Redis 的监控, 发现在那段时间 Redis 由于内存占用过高强制清理了几次 key. 但从日志上来看, 这段时间并没有出现流量暴涨的情况, 而且 Redis 中 key 的数量也没有显著增加. 那是什么原因导致 Redis 内存占用过高呢? 确定了 Redis 内存升高不是我们造成的之后, 我们又联系了业务组的同学协助他们, 他们表示最近确实有上线, 并且新上线的功能有使用到 Redis. 但我仍然感觉很奇怪, 为什么 Redis 中的 key 没有增多, 并且没看到有其他业务的 key. 经过一番询问, 才了解到, 业务组同学使用的是这个 Redis 的 db1, 而我用的 (和刚查的) 是 db0. 这里确实是我在排查问题时出现了疏忽.
那么 Redis 的不同 db 之间会互相影响吗? 通常情况下, 我们使用不同的 db 进行数据隔离, 这没问题. 但 Redis 进行清理时, 并不是只清理数据量占用最大的那个 db, 而是会对所有的 db 进行清理. 在这之前我并不是很了解这方面知识, 这里也只是根据现象进行的猜测.
好奇心驱使我来验证一下这个想法. 于是我决定直接来看 Redis 的源码. 清理 key 相关的代码在文件中.
Redis 中会保存一个 "过期 key 池", 这个池子中存放了一些可能会被清理的 key. 其中保存的数据结构如下:
- struct evictionPoolEntry {
- unsigned long long idle; /* Object idle time (inverse frequency for LFU) */
- sds key; /* Key name. */
- sds cached; /* Cached SDS object for key name. */
- int dbid; /* Key DB number. */
- };
其中 idle 是对象空闲时间, 在 Reids 中, key 的过期算法有两种: 一种是近似 LRU, 一种是 LFU. 默认使用的是近似 LRU.
近似 LRU
在解释近似 LRU 之前, 先来简单了解一下 LRU. 当 Redis 的内存占用超过我们设置的 maxmemory 时, 会把长时间没有使用的 key 清理掉. 按照 LRU 算法, 我们需要对所有 key(也可以设置成只淘汰有过期时间的 key)按照空闲时间进行排序, 然后淘汰掉空闲时间最大的那部分数据, 使得 Redis 的内存占用降到一个合理的值.
LRU 算法的缺点是, 我们需要维护一个全部(或只有过期时间)key 的列表, 还要按照最近使用时间排序. 这会消耗大量内存, 并且每次使用 key 时更新排序也会占用额外的 CPU 资源. 对于 Redis 这样对性能要求很高的系统来说是不被允许的.
因此, Redis 采用了一种近似 LRU 的算法. 当 Redis 接收到新的写入命令, 而内存又不够时, 就会触发近似 LRU 算法来强制清理一些 key. 具体清理的步骤是, Redis 会对 key 进行采样, 通常是取 5 个, 然后会把过期的 key 放到我们上面说的 "过期池" 中, 过期池中的 key 是按照空闲时间来排序的, Redis 会优先清理掉空闲时间最长的 key, 直到内存小于 maxmemory.
近似 LRU 算法的清理效果图如图(图片来自 Redis 官方文档)
这么说可能不够清楚, 我们直接上代码.
源码分析
上图展示了代码中近似 LRU 算法的主要逻辑调用路径.
其中主要逻辑是在 freeMemoryIfNeeded 函数中
首先调用 getMaxmemoryState 函数判断当前内存的状态
- int getMaxmemoryState(size_t *total, size_t *logical, size_t *tofree, float *level) {
- size_t mem_reported, mem_used, mem_tofree;
- mem_reported = zmalloc_used_memory();
- if (total) *total = mem_reported;
- int return_ok_asap = !server.maxmemory || mem_reported <= server.maxmemory;
- if (return_ok_asap && !level) return C_OK;
- mem_used = mem_reported;
- size_t overhead = freeMemoryGetNotCountedMemory();
- mem_used = (mem_used> overhead) ? mem_used-overhead : 0;
- if (level) {
- if (!server.maxmemory) {
- *level = 0;
- } else {
- *level = (float)mem_used / (float)server.maxmemory;
- }
- }
- if (return_ok_asap) return C_OK;
- if (mem_used <= server.maxmemory) return C_OK;
- mem_tofree = mem_used - server.maxmemory;
- if (logical) *logical = mem_used;
- if (tofree) *tofree = mem_tofree;
- return C_ERR;
- }
如果使用内存低于 maxmemory 的话, 就返回 C_OK, 否则返回 C_ERR. 另外, 这个函数还通过传递指针型的参数来返回一些额外的信息.
total: 已使用的字节总数, 无论是 C_OK 还是 C_ERR 都有效.
logical: 已使用的内存减去 slave 或 AOF 缓冲区后的大小, 只有返回 C_ERR 时有效.
tofree: 需要释放的内存大小, 只有返回 C_ERR 时有效.
level: 已使用内存的比例, 通常是 0 到 1 之间, 当超出内存限制时, 就大于 1. 无论是 C_OK 还是 C_ERR 都有效.
判断完内存状态以后, 如果内存没有超过使用限制就会直接返回, 否则就继续向下执行. 此时我们已经知道需要释放多少内存空间了, 下面就开始进行释放内存的操作了. 每次释放内存都会记录释放内存的大小, 直到释放的内存不小于 tofree.
首先根据 maxmemory_policy 进行判断, 对于不同的清除策略有不同的实现方法, 我们来看 LRU 的具体实现.
- for (i = 0; i <server.dbnum; i++) {
- db = server.db+i;
- dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?
- db->dict : db->expires;
- if ((keys = dictSize(dict)) != 0) {
- evictionPoolPopulate(i, dict, db->dict, pool);
- total_keys += keys;
- }
- }
首先是填充 "过期池", 这里遍历了每一个 db(验证了我最开始的想法), 调用 evictionPoolPopulate 函数进行填充.
- void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
- int j, k, count;
- dictEntry *samples[server.maxmemory_samples];
- count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples);
- for (j = 0; j <count; j++) {
- unsigned long long idle;
- sds key;
- robj *o;
- dictEntry *de;
- de = samples[j];
- key = dictGetKey(de);
- /* some code */
- if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
- idle = estimateObjectIdleTime(o);
- }
- /* some code */
- k = 0;
- while (k < EVPOOL_SIZE &&
- pool[k].key &&
- pool[k].idle < idle) k++;
- if (k == 0 && pool[EVPOOL_SIZE-1].key != NULL) {
- continue;
- } else if (k < EVPOOL_SIZE && pool[k].key == NULL) {
- } else {
- if (pool[EVPOOL_SIZE-1].key == NULL) {
- sds cached = pool[EVPOOL_SIZE-1].cached;
- memmove(pool+k+1,pool+k,
- sizeof(pool[0])*(EVPOOL_SIZE-k-1));
- pool[k].cached = cached;
- } else {
- k--;
- sds cached = pool[0].cached; /* Save SDS before overwriting. */
- if (pool[0].key != pool[0].cached) sdsfree(pool[0].key);
- memmove(pool,pool+1,sizeof(pool[0])*k);
- pool[k].cached = cached;
- }
- }
- /* some code */
- }
- }
由于篇幅原因, 我截取了部分代码, 通过这段代码我们可以看到, Redis 首先是采样了一部分 key, 这里采样数量 maxmemory_samples 通常是 5, 我们也可以自己设置, 采样数量越大, 结果就越接近 LRU 算法的结果, 带来的影响是性能随之变差.
采样之后我们需要获得每个 key 的空闲时间, 然后将其填充到 "过期池" 中的指定位置. 这里 "过期池" 是按照空闲时间从小到大排序的, 也就是说, idle 大大 key 排在最右边.
填充完 "过期池" 之后, 会从后向前获取到最适合清理的 key.
- /* Go backward from best to worst element to evict. */
- for (k = EVPOOL_SIZE-1; k>= 0; k--) {
- if (pool[k].key == NULL) continue;
- bestdbid = pool[k].dbid;
- if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
- de = dictFind(server.db[pool[k].dbid].dict,
- pool[k].key);
- } else {
- de = dictFind(server.db[pool[k].dbid].expires,
- pool[k].key);
- }
- /* some code */
- if (de) {
- bestkey = dictGetKey(de);
- break;
- }
- }
找到需要删除的 key 后, 就需要根据设置清理策略进行同步 / 异步清理.
- if (server.lazyfree_lazy_eviction)
- dbAsyncDelete(db,keyobj);
- else
- dbSyncDelete(db,keyobj)
最后记下本次清理的空间大小, 用来在循环条件判断是否要继续清理.
- delta -= (long long) zmalloc_used_memory();
- mem_freed += delta;
清理策略
最后我们来看一下 Redis 支持的几种清理策略
noeviction: 不会继续处理写请求(DEL 可以继续处理).
allkeys-lru: 对所有 key 的近似 LRU
volatile-lru: 使用近似 LRU 算法淘汰设置了过期时间的 key
allkeys-random: 从所有 key 中随机淘汰一些 key
volatile-random: 对所有设置了过期时间的 key 随机淘汰
volatile-ttl: 淘汰有效期最短的一部分 key
Redis4.0 开始支持了 LFU 策略, 和 LRU 类似, 它分为两种:
volatile-lfu: 使用 LFU 算法淘汰设置了过期时间的 key
allkeys-lfu: 从全部 key 中进行淘汰, 使用 LFU
写在最后
现在我知道了 Redis 在内存达到上限时做了哪些事了. 以后出问题时也就不会只检查自己的 db 了.
关于这次事故的后续处理, 我首先是让业务同学回滚了代码, 然后让他们使用一个单独的 Redis, 这样业务再出现类似问题就不会影响到我们的帐号服务了, 整体的影响范围也会变得更加可控.
来源: https://www.cnblogs.com/Jackeyzhe/p/12616624.html