当前位置:   article > 正文

线上redis ttl时间未到缺key却被删除排查录_redis中存储了一些key,设置了ttl,突然某一天,这些key 还没有到ttl就丢失了,会怎么

redis中存储了一些key,设置了ttl,突然某一天,这些key 还没有到ttl就丢失了,会怎么

引言:

以下内容直接从我球的docs粘贴:https://xueqiu.com/

欢迎简历投递:hekuangsheng@xueqiu.com

问题:

目前悬赏贴悬赏有效期为5天,余额只存储在redis中,集群为status集群,key:st:offer:balance:{statusId},缓存有效期为6天。当进行分配赏金时,会查询该贴的余额。有问题的悬赏贴,相应的redis key被删除。

分析:

首先,大致看了下业务代码和 redis 组件相关的实现,以及 24/25 号的日志,没有发现值得怀疑的点;然后,根据上述的时间范围,去监控面板找异常,还真找到了。。。 24/25号前后 redis 内存占用不正常(具体原因需要业务排查),下图是过去 10 天的一个占用走势

对比下图的 redis 最大内存配置,会发现 8 个分片的主库内存在 2020.06.24 12:00 - 2020.06.25 03:00 期间都触达了最大值

内存淘汰策略相当于清除掉那些占用内存并且使用不太频繁的数据,淘汰掉这些不活跃数据来清理内存

  我们知道,redis设置配置文件的maxmemory参数,可以控制其最大可用内存大小(字节)。

那么当所需内存,超过maxmemory怎么办?

这个时候就该配置文件中的maxmemory-policy出场了。

其默认值是noeviction。

下面我将列出当可用内存不足时,删除redis键具有的淘汰规则。

就会根据配置的清理策略,redis开始干活了

  • volatile-lru

使用LRU算法,从设置了过期时间的key中选择删除

  • allkeys-lru

使用LRU算法,从所有key中选择删除

  • volatile-random

从设置了过期时间的key中随机删除

  • allkeys-random

从所有的key中随机删除

  • volatile-ttl

从设置了过期时间的key中选择最先过期的删除

  • noeviction

不处理,当有写操作时,直接返回错误

LRU算法,least RecentlyUsed,最近最少使用算法。也就是说默认删除最近最少使用的键。

但是一定要注意一点!redis中并不会准确的删除所有键中最近最少使用的键,而是随机抽取5个键,删除这五个键中最近最少使用的键。

那么5这个数字也是可以设置的,对应位置是配置文件中的maxmeory-samples.

https://redis.io/topics/faq

redis 统计还提供了另一个数据,叫 evicted_keys,可以看到分片强制淘汰了多少个 key,其他分片大致类似

改进

redis的内存占用本来就是件应当关注的事情

1.增加redis内存监控报警

2.加强使用人员对redis使用的方式了解,结合自身场景选择合适的配置


相关总结

DEL 和 UNLINK区别?同步还是异步?

使用DEL命令会触发「同步删除」,如果Key是一个有很多元素的复杂类型,这个过程可能会堵塞一下Redis服务自身,从而影响用户的访问。

使用UNLINK命令,Redis服务会先计算删除Key的成本,从而更智能地做出「同步删除」或「异步删除」的选择。

成本计算:

对于list,hash,set,zset的对象类型,如果长度大于64(由宏LAZYFREE_THRESHOLD定义),才会采用异步删除的手段,从当前db先释放该key,再由另外一个线程做异步删除。对于长度不大于64的复杂类型,异步删除比同步删除还多了一些函数调用与多线程同步的代价,所以同步删除更好。对于string对象,底层的数据结构sds是一份连续的内存,内存分配器回收这块内存的复杂度是O(1),所以采用同步删除也不会堵塞服务。

总的来说,我们作为用户,都能用UNLINK替代DEL。

驱逐策略

Redis通过参数maxmemory来选择不同的驱逐策略:

  • volatile-random 从已设置过期时间的数据集(server.db[i].expires)中任意选择数据驱逐;volatile-lru 从数据集(server.db[i].dict)中挑选最近最少使用的数据驱逐(2.8默认);

  • volatile-ttl 从已设置过期时间的数据集(server.db[i].expires)中寻找最近即将过期(ttl最小)的key来驱逐;

  • allkeys-random 从数据集(server.db[i].dict)中任意选择数据驱逐;

  • allkeys-lru 从数据集(server.db[i].dict)中挑选最近最少使用的数据驱逐;

  • noeviction 禁止驱逐数据,永远不驱逐,仅对写操作返回一个错误(4.0默认);

在4.0版本后,还增加了以下两种驱逐策略。

  • volatile-lfu在过期集合中使用LFU链来驱逐数据;

  • allkeys-lfu 从数据集(server.db[i].dict)使用LFU算法来驱逐数据;

4.0后,在返回写入失败前,还会先检测lazyfree线程是否还有待删除的Key,没有才会给用户返回写入失败。

在4.0或以上的版本,Key的驱逐会基于参数lazyfree_lazy_eviction,来决定采用unlink还是del。在2.8版本,则只会用del。

 

Key的访问淘汰

对于Slave节点,访问到了已过期的Key,Slave节点会返回该Key不存在,但不会主动删除该Key。删除的动作,还是会从Master上同步过来。

对于Master节点,在4.0或以上的版本,会根据参数lazyfree-lazy-expire,来决定用DEL还是UNLINK。对于2.8版本,则只能用DEL了。这些删除的动作,都会同步到Slave与AOF文件中。

Key的定时淘汰

多久会执行一次定时调度呢?

redis服务的参数hz能控制定时淘汰的频率,hz默认是10,即每秒能调度100次。

定时淘汰一定是在master上发生的吗?

 

有些时候,用户会把Slave节点设置成可写,那么Slave上写的带有过期时间的Key,因为Master是不知道的,就一直不会淘汰掉。所以在版本4.0以后,Redis增加了单独的逻辑,在定时淘汰中删除这些在slave节点上写入的过期Key。

“FAST淘汰”和“SLOW淘汰”?

前者每次淘汰只能花1毫秒,不能花更多了,后者是以hz=10为例,每次调度的总时间是100ms,这里调度不会25%的cpu时间,即25ms。

如果每淘汰1个Key就检测一次,无疑代价太大。从源码上看,定时淘汰会尝试遍历每个db,遍历完了或者时间到了就退出循环。第一层循环是遍历各个db,第二层循环是遍历db里面的一批批key,一批key是20个,如果第三层循环结束后有大于5个key是成功淘汰的(说明这个db很多淘汰key),那么二层就继续循环,如果小于等于5个key,说明这个db没有很多key需要淘汰,则退出二层循环,第三层循环是一批key里面逐个key进行淘汰。即最多320个key进行判断后,就会看看是否已经超过cpu占用时间。

在4.0或以上的版本,会根据参数lazyfree-lazy-expire(默认no)来做DEL还是UNLINK。

 

总结

 

1.驱逐策略的选择,往往与业务特点、使用场景紧密相关。不当的选择,可能会让用户丢失不想丢失的数据,或者导致较差的驱逐效率;

 

2.已过期的Key往往不会立刻被删除,用户在导出快照与建立主从时,会疑惑主从之间的Key数量不一致,我们都需要了解这一点;

 

3.驱逐与淘汰都有可能影响服务,在新版本下,最好都开启unlink代替del。

 


 

扩展unlink和异步线程

 

单线程为什么还能这么快?

1 基于内存操作:Redis 的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高;
2 数据结构简单:Redis 的数据结构比较简单,是为 Redis 专门设计的,而这些简单的数据结构的查找和操作的时间复杂度都是 O(1),因此性能比较高;
3 多路复用和非阻塞 I/O:Redis 使用 I/O 多路复用功能来监听多个 socket 连接客户端,这样就可以使用一个线程连接来处理多个请求,减少线程切换带来的开销,同时也避免了 I/O 阻塞操作,从而大大提高了 Redis 的性能;
4 避免上下文切换:因为是单线程模型,因此就避免了不必要的上下文切换和多线程竞争,这就省去了多线程切换带来的时间和性能上的消耗,而且单线程不会导致死锁问题的发生。

 

redis是单进程单线程模式吗?

在不同的 Redis 版本下答案是不同的,在 Redis 4.0 之前,Redis 是单线程运行的,但单线程并不意味着性能低,类似单线程的程序还有 Nginx 和 NodeJs 他们都是单线程程序,但是效率并不低。 Redis 的 FAQ(Frequently Asked Questions,常见问题)也回到过这个问题
参见:https://redis.io/topics/faq
大体意思是说 Redis 是基于内存操作的,因此他的瓶颈可能是机器的内存或者网络带宽而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了,况且使用多线程比较麻烦。但是在 Redis 4.0 中开始支持多线程了,例如后台删除等功能。

简单来说 Redis 之所以在 4.0 之前一直采用单线程的模式是因为以下三个原因:

1 使用单线程模型是 Redis 的开发和维护更简单,因为单线程模型方便开发和调试;
2 即使使用单线程模型也并发的处理多客户端的请求,主要使用的是多路复用(详见本文下半部分);
3 对于 Redis 系统来说,主要的性能瓶颈是内存或者网络带宽而并非 CPU。

 

Redis 6.0 多线程

1 单线程的机制导致 Redis 的 QPS(Query Per Second,每秒查询率)很难得到有效的提高。
2 Redis 4.0 版本中虽然引入了多线程,但此版本中的多线程只能用于大数据量的异步删除,然而对于非删除操作的意义并不是很大。

feature:使用多线程就可以分摊 Redis 同步读写 I/O 的压力,以及充分的利用多核 CPU 的资源,并且可以有效的提升 Redis 的 QPS。在 Redis 中虽然使用了 I/O 多路复用,并且是基于非阻塞 I/O 进行操作的,但 I/O 的读和写本身是堵塞的,比如当 socket 中有数据时,Redis 会通过调用先将数据从内核态空间拷贝到用户态空间,再交给 Redis 调用,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的。
fix:因此在 Redis 6.0 中新增了多线程的功能来提高 I/O 的读写性能,他的主要实现思路是将主线程的 IO 读写任务拆分给一组独立的线程去执行,这样就可以使多个 socket 的读写可以并行化了,但 Redis 的命令依旧是由主线程串行执行的。

Redis 6.0 默认是禁用多线程的,可以通过修改 Redis 的配置文件 redis.conf 中的 io-threads-do-reads 等于 true 来开启多线程,完整配置为 io-threads-do-reads true,除此之外我们还需要设置线程的数量才能正确的开启多线程的功能,同样是修改 Redis 的配置,例如设置 io-threads 4 表示开启 4 个线程。
antirez 在 RedisConf 2019 分享时曾提到,Redis 6 引入的多线程 I/O 特性对性能提升至少是一倍以上。

 

redis访问过期key删除时在主线程里吗?

redis增加了意不删除命令unlink,防止删除大key阻塞主线程:原理是执行unlink时会将需要删除的数据挂到一个链表中,由专门的线程负责删除
通常情况下使用 del 指令可以很快的删除数据,而当被删除的 key 是一个非常大的对象时,例如时包含了成千上万个元素的 hash 集合时,那么 del 指令就会造成 Redis 主线程卡顿,因此使用惰性删除可以有效的避免 Redis 卡顿的问题。
例如 unlink key / flushdb async / flushall async 等命令

 

redis渐进式删除

参见:http://antirez.com/news/93
大意是:把要删除的对象放到一个链表中,起一个定期任务(作者设计一种自适应的删除,通过判断内存是增加还是减少,来动态调整删除任务执行频率),每次只删除其中一部分

 

redis异步线程

首先消灭redis之前版本中的共享对象(解决这种问题修改了保存结构,详细看源码),这种共享数据越多多线程的竞争越大,越影响性性能

 

多线程在内存分配上的冲突

参见:https://software.intel.com/content/www/cn/zh/develop/articles/avoiding-heap-contention-among-threads.html
antirez的意思是redis在内存分配上使用的时间极少,可以忽略


 

redis key过期源码分析

redis的所有数据结构都可以设置过期时间,当key过期后再查询该key返回null
redis4.0版本引入了异步删除的机制,对于删除对象大小大于64字节的key,先通过Unlink方法软删除后放入回收队列中,由其他线程异步回收内存空间,减少主线程的在内存回收上消耗的时间。

 

主动删除

对key删改查操作时先判断该key是否已过期,如过期则删除回收该key
在redis主从架构下,从节点不处理过期机制,通过等待主节点的指令直接删除对应的key。因此当网络延迟较大时,存在主节点中已过期的key能在从节点查询出来的问题。

 

  1. robj *lookupKeyRead(redisDb *db, robj *key) {
  2. // 查询操作
  3. return lookupKeyReadWithFlags(db,key,LOOKUP_NONE);
  4. }
  5. robj *lookupKeyReadWithFlags(redisDb *db, robj *key, int flags) {
  6. robj *val;
  7. // 查询key的值前先检查该key是否已过期
  8. // 如果key过期则删除回收该key
  9. if (expireIfNeeded(db,key) == 1) {
  10. if (server.masterhost == NULL) {
  11. server.stat_keyspace_misses++;
  12. notifyKeyspaceEvent(NOTIFY_KEY_MISS, "keymiss", key, db->id);
  13. return NULL;
  14. }
  15. if (server.current_client &&
  16. server.current_client != server.master &&
  17. server.current_client->cmd &&
  18. server.current_client->cmd->flags & CMD_READONLY)
  19. {
  20. server.stat_keyspace_misses++;
  21. notifyKeyspaceEvent(NOTIFY_KEY_MISS, "keymiss", key, db->id);
  22. return NULL;
  23. }
  24. }
  25. // key未过期,查询key对应的值
  26. val = lookupKey(db,key,flags);
  27. if (val == NULL) {
  28. server.stat_keyspace_misses++;
  29. notifyKeyspaceEvent(NOTIFY_KEY_MISS, "keymiss", key, db->id);
  30. }
  31. else
  32. server.stat_keyspace_hits++;
  33. return val;
  34. }
  35. int expireIfNeeded(redisDb *db, robj *key) {
  36. // 查询redisDb.expires判断该key是否过期
  37. if (!keyIsExpired(db,key)) return 0;
  38. // 从节点不处理过期机制,等待主节点的指令直接删除对应的key
  39. // 因此存在网络延迟大时,已过期的key能在从节点查询出来的问题
  40. if (server.masterhost != NULL) return 1;
  41. server.stat_expiredkeys++;
  42. propagateExpire(db,key,server.lazyfree_lazy_expire);
  43. notifyKeyspaceEvent(NOTIFY_EXPIRED,
  44. "expired",key,db->id);
  45. // 默认使用dbAsyncDelete异步删除回收
  46. return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
  47. dbSyncDelete(db,key);
  48. }

 

定时删除

主线程每100毫秒从redisDb.expires中随机选择20个key,删除其中已过期的key。如果过期的key的比例超过1/4则重复该操作直到达到时间上限(默认25毫秒)

databasesCron方法由主线程每100毫秒调用一次,其中的activeExpireCycle方法则是删除回收过期key的关键方法,其入参有两个值:ACTIVE_EXPIRE_CYCLE_FAST和ACTIVE_EXPIRE_CYCLE_SLOW:

1 ACTIVE_EXPIRE_CYCLE_FAST:表示快速删除回收过期key,该场景下每次删除回收的时间上限为1毫秒,当主线程处理完外部请求后等待新请求前阻塞时会使用该参数;
2 ACTIVE_EXPIRE_CYCLE_SLOW:表示慢删除回收过期key,该场景下每次删除回收的时间上限为25毫秒,当主线程每100毫秒执行定时任务时使用该参数;
由于主线程每100毫秒会调用一次activeExpireCycle方法回收过期key,因此存在极端情况下同一时刻redis中大量数据同时过期,会导致每100毫秒一次的定时任务activeExpireCycle需要花费25毫秒的时间删除回收过期key,从而出现客户端请求等待阻塞的情况。

 

  1. void databasesCron(void) {
  2. if (server.active_expire_enabled) {
  3. if (server.masterhost == NULL) {
  4. // 定时任务使用慢扫描回收,扫描回收的时间上限为25毫秒
  5. activeExpireCycle(ACTIVE_EXPIRE_CYCLE_SLOW);
  6. } else {
  7. expireSlaveKeys();
  8. }
  9. }
  10. ...
  11. }
  12. #define ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 20
  13. void activeExpireCycle(int type) {
  14. ...
  15. // timelimit = 1000000*25/10/100 = 25_000微妙 = 25毫秒
  16. timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;
  17. timelimit_exit = 0;
  18. if (timelimit <= 0) timelimit = 1;
  19. // 快速扫描回收,扫描回收时间上限为1毫秒
  20. // 慢扫描回收,扫描回收时间上限默认timelimit = 25毫秒
  21. if (type == ACTIVE_EXPIRE_CYCLE_FAST)
  22. timelimit = ACTIVE_EXPIRE_CYCLE_FASTf_DURATION; /* in microseconds. */
  23. long total_sampled = 0;
  24. long total_expired = 0;
  25. for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
  26. int expired;
  27. redisDb *db = server.db+(current_db % server.dbnum);
  28. current_db++;
  29. do {
  30. unsigned long num, slots;
  31. long long now, ttl_sum;
  32. int ttl_samples;
  33. iteration++;
  34. if ((num = dictSize(db->expires)) == 0) {
  35. db->avg_ttl = 0;
  36. break;
  37. }
  38. slots = dictSlots(db->expires);
  39. now = mstime();
  40. if (num && slots > DICT_HT_INITIAL_SIZE &&
  41. (num*100/slots < 1)) break;
  42. expired = 0;
  43. ttl_sum = 0;
  44. ttl_samples = 0;
  45. // 每次从redisDb.expires随机获取20key
  46. if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
  47. num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;
  48. while (num--) {
  49. dictEntry *de;
  50. long long ttl;
  51. // 从redisDb.expires随机获取一个key
  52. if ((de = dictGetRandomKey(db->expires)) == NULL) break;
  53. ttl = dictGetSignedIntegerVal(de)-now;
  54. // 如果key已过期,默认调用dbAsyncDelete异步删除回收,并累计过期的key数量
  55. if (activeExpireCycleTryExpire(db,de,now)) expired++;
  56. if (ttl > 0) {
  57. ttl_sum += ttl;
  58. ttl_samples++;
  59. }
  60. total_sampled++;
  61. }
  62. total_expired += expired;
  63. if (ttl_samples) {
  64. long long avg_ttl = ttl_sum/ttl_samples;
  65. if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;
  66. db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);
  67. }
  68. // 如果过期key的扫描回收时间达到了上限,则结束此次扫描回收操作
  69. if ((iteration & 0xf) == 0) {
  70. elapsed = ustime()-start;
  71. if (elapsed > timelimit) {
  72. timelimit_exit = 1;
  73. server.stat_expired_time_cap_reached_count++;
  74. break;
  75. }
  76. }
  77. // 每次随机获取的20key如果超过1/4已经过期,则重复操作删除回收过期的key
  78. } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);
  79. }
  80. ...
  81. }

 

异步删除

在redis4.0版本默认调用dbAsyncDelete方法删除回收key,但实际上dbAsyncDelete会判断该key的对象大小,如果key的对象大小超过64字节时才会真正使用异步删除逻辑,将该key放入BIO队列由其他线程删除回收内存空间;否则仍然使用同步删除逻辑直接回收内存空间。

 

  1. #define LAZYFREE_THRESHOLD 64
  2. int dbAsyncDelete(redisDb *db, robj *key) {
  3. if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
  4. dictEntry *de = dictUnlink(db->dict,key->ptr);
  5. if (de) {
  6. robj *val = dictGetVal(de);
  7. size_t free_effort = lazyfreeGetFreeEffort(val);
  8. // 如果待删除key的长度超过64字节,异步删除
  9. if (free_effort > LAZYFREE_THRESHOLD && val->refcount == 1) {
  10. atomicIncr(lazyfree_objects,1);
  11. // 放入BIO队列中由其他进程回收内存空间
  12. bioCreateBackgroundJob(BIO_LAZY_FREE,val,NULL,NULL);
  13. dictSetVal(db->dict,de,NULL);
  14. }
  15. }
  16. // 待删除key长度不超过64字节,依然使用同步删除
  17. if (de) {
  18. dictFreeUnlinkedEntry(db->dict,de); // 同步回收内存空间
  19. if (server.cluster_enabled) slotToKeyDel(key);
  20. return 1;
  21. } else {
  22. return 0;
  23. }
  24. }
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/AllinToyou/article/detail/421171
推荐阅读
相关标签
  

闽ICP备14008679号