加入收藏 | 设为首页 | 会员中心 | 我要投稿 李大同 (https://www.lidatong.com.cn/)- 科技、建站、经验、云计算、5G、大数据,站长网!
当前位置: 首页 > 综合聚焦 > 服务器 > 安全 > 正文

懒 Redis 是更好的 Redis

发布时间:2020-12-16 04:41:16 所属栏目:安全 来源:网络整理
导读:《懒 Redis 是更好的 Redis》要点: 本文介绍了懒 Redis 是更好的 Redis,希望对您有用。如果有疑问,可以联系我们。 大家都知道 Redis 是单线程的.对 Redis 内行 的人会告诉你,Redis 其实也不完全是单线程的,因为还有一些线程在处理特定的慢的磁盘操作.到目

《懒 Redis 是更好的 Redis》要点:
本文介绍了懒 Redis 是更好的 Redis,希望对您有用。如果有疑问,可以联系我们。

大家都知道 Redis 是单线程的.对 Redis 内行 的人会告诉你,Redis 其实也不完全是单线程的,因为还有一些线程在处理特定的慢的磁盘操作.到目前为止,这些线程里的操作都集中在 I/O 上,以至于这些线程用到的库被称为 bio.c,也便是后台 I/O(Background I/O).

懒 Redis 是更好的 Redis

不过之前我提交了一个 issue,承诺给 Redis 新增一个很多人(包含我自己)都想要的特性,被称为延迟释放(Lazy free).可以参考这个 issue:https://github.com/antirez/redis/issues/1748.

这个 issue 的主要描述了,Redis 的 DEL 操作通常是阻塞的,所以如果你发送了“DEL mykey”命令,而你的 key 包括了5千万的对象,那么服务器就会阻塞几秒钟,这段时间不能提供其他服务.以前,这被看作是 Redis 设计上的副作用,是可以接受的,只是在特定场景下是受限制的.DEL不是唯一会阻塞的命令,不过比较特别,因为我们通常会说:Redis 在使用 O(1) 和 O(log_N) 命令的时候是非常快的.你也可以使用 O(N)的命令,不过我们没有为这些命令做优化,性能上可能会有问题.

这貌似合理,不外就算是用快的命令创建的对象,在删除的时候也会让Redis阻塞住.

第一次尝试

对于单线程服务器,为了让操作不阻塞,最简单的方式便是用增量的方式一点点来,而不是一下子把整个世界都搞定.例如,如果要释放一个百万级的对象,可以每一个毫秒释放1000个元素,而不是在一个 for 循环里一次性全做完.CPU 的耗时是差不多的,也许会稍微多一些,因为逻辑更多一些,但是从用户来看延时更少一些.当然也许实际上并没有每毫秒删除1000个元素,这只是个例子.重点是如何避免秒级的阻塞.在 Redis 内部做了很多事情:最显然易见的是 LRU 淘汰机制和 key 的过期,还有其他方面的,例如对 hash 表进行增量式的重排.

刚开始我们是这样尝试的:创建一个新的定时器函数,在里面实现淘汰机制.对象只是被添加到一个链表里,每次定时器调用的时候,会逐步的、增量式的去释放.这必要一些小技巧,例如,那些用哈希表实现的对象,会使用 Redis 的 SCAN 命令里相同的机制去增量式的释放:在字典里设置一个游标来遍历和释放元素.通过这种方式,在每次定时器调用的时候我们不必要释放整个哈希表.在重新进入定时器函数时,游标可以告诉我们上次释放到哪里了.

适配是困难的

你知道这里最困难的部分是哪里吗?这次我们是在增量式的做一件很特其余事情:释放内存.如果内存的释放是增量式的,服务器的内容增长将会非常快,最后为了得到更少的延时,会消耗调无限的内存.这很糟,想象一下,有下面的操作:

WHILE 1????SADD myset element1 element2 … many many many elements? ? DEL mysetEND

如果慢慢的在后台去删除 myset,同时 SADD 调用又在赓续的添加大量的元素,内存使用量将会一直增长.

好在经过一段测验考试之后,我找到一种可以工作的很好的方式.定时器函数里使用了两个想法来适应内存的压力:

  1. 检测内存趋势:增加还是减少?以决定释放的力度.
  2. 同时适配定时器的频率,避免在只有很少必要释放的时候去浪费 CPU,不用频繁的去中断事件循环.当确实必要的时候,定时器也可以达到大约 300Hz 的频率.

这里有一小段代码,不过这个想法现在已经不再实现了:

/* 计算内存趋势,只要是上次和这次内存都在增加,就倾向于认为内存趋势是增加的 */if (prev_mem < mem) mem_trend = 1;mem_trend *= 0.9; /* Make it slowly forget. */int mem_is_raising = mem_trend > .1;/* 释放一些元素 */size_t workdone = lazyfreeStep(LAZYFREE_STEP_SLOW);/* 根据现有状态调整定时器频率 */if (workdone) {    if (timer_period == 1000) timer_period = 20;    if (mem_is_raising && timer_period > 3)        timer_period--; /* 提升调用频率 */else if (!mem_is_raising && timer_period < 20)    timer_period++; /* 降低调用频率 */} else {    timer_period = 1000;    /* 1 HZ */}

还有,现在也可以在其他线程实现针对聚合数据类型的特定的慢操作,可以让某些 key 被“阻塞”,但是所有其他的客户端不会被阻塞.这个可以用很类似现在的阻塞操作的方式去完成(参考 blocking.c),只是增加一个哈希表保存那些正在处理的 key 和对应的客户端.于是一个客户端哀求类似 SMEMBERS 这样的命令,可能只是仅仅阻塞住这一个 key,然后会创建输出缓存处理数据,之后在释放这个 key.只有那些尝试访问相同的 key 的客户端,才会在这个 key 被阻塞的时候被阻塞住.这是一个小技巧,工作的也很好.不过郁闷的是我们还是不得不在单线程里执行.要做好需要有很多的逻辑,而且当延迟释放(lazy free)周期很繁忙的时候,每秒能完成的操作会降到平时的65%左右.

如果是在另一个线程去释放工具,那就简单多了:如果有一个线程只做释放操作的话,释放总是要比在数据集里添加数据来的要快.

当然,主线程和延迟释放线程直接对内存分配器的使用肯定会有竞争,不外 Redis 在内存分配上只用到一小部分时间,更多的时间用在 I/O、命令分发、缓存失败等等.

不过,要实现线程化的延迟释放有一个大问题,那就是 Redis 自身.内部实现完全是追求对象的共享,最终都是些引用计数.干嘛不尽可能的共享呢?这样可以节省内存和时间.例如:SUNIONSTORE 命令最后得到的是目标集合的共享对象.类似的,客户端的输出缓存包括了作为返回结果发送给 socket 的对象的列表,于是在类似 SMEMBERS 这样的命令调用之后,集合的所有成员都有可能最终在输出缓存里被共享.看上去对象共享是那么有效、漂亮、精彩,还特别酷.

但是,嘿,还需要再多说一句的是,如果在 SUNIONSTORE 命令之后重新加载了数据库,对象都取消了共享,内存也会突然回复到最初的状态.这可不太妙.接下来我们发送哀求应答给客户端,会怎么样?当对象比较小时,我们实际上是把它们拼接成线性的缓存,要不然进行多次 write 调用效率是不高的!(友情提示,writev() 对此并无帮助).于是我们大部分情况下是已经复制了数据.对于编程来说,没有用的东西却存在,通常意味着是有问题的.

事实上,拜访一个包含聚合类型数据的key,需要经过下面这些遍历过程:

key -> value_obj -> hash table -> robj -> sds_string

如果去掉整个 tobj 布局体,把聚合类型转换成 SDS 字符串类型的哈希表(或者跳转表)会怎么样?(SDS 是 Redis 内部使用的字符串类型).

这样做有个问题,假设有个命令:SADD myset myvalue,举个例子来说,我们做不到通过 client->argv[2] 来引用某个用来实现集合的哈希表的元素.我们不得不很多次的把值复制出来,即使数据已经在客户端命令解析后创建的参数 vector 里,也没方法去复用.Redis 的性能受控于缓存失效,我们也许可以用稍微间接一些的方法来弥补一下.

于是我在这个 lazyfree 的分支上开始了一项工作,并且在 Twitter 上聊了一下,但是没有颁布上下文的细节,结果所有的人都觉得我像是绝望或者疯狂了(甚至有人喊道 lazyfree 到底是什么玩意).那么,我到底做了什么呢?

  1. 把客户端的输出缓存由 robj 结构体改成动态字符串.在创建 reply 的时候总是复制值的内容.
  2. 把所有的 Redis 数据类型转换成 SDS 字符串,而不是使用共享对象结构.听上去很简单?实际上这花费了数周的时间,涉及到大约800行高风险的代码修改.不过现在全都测试通过了.
  3. 把 lazyfree 重写成线程化的.

结果是 Redis 现在在内存使用上更加高效,因为在数据结构的实现上不再使用 robj 结构体(不过由于某些代码还涉及到大量的共享,所以 robj 依然存在,例如在命令分发和复制部分).线程化的延迟释放工作的很好,比增量的方式更能减少内存的使用,虽然增量方式在实现上与线程化的方式相似,而且也没那么糟糕.现在,你可以删除一个巨大的 key,性能损失可以忽略不计,这非常有用.不过,最有趣的事情是,在我测过的一些操作上,Redis 现在都要更快一些.消除间接引用(Less indirection)最后胜出,即使在不相关的一些测试上也更快一些,还是因为客户端的输出缓存现在更加简单和高效.

最后,我把增量式的延迟释放实现从分支里删除,只保存了线程化的实现.

关于 API 的一点备注

不过 API 又怎么样了呢?DEL 命令仍然是阻塞的,默认还跟以前一样,因为在 Redis 中 DEL 命令就意味着释放内存,我并不打算改变这一点.所以现在你可以用新的命令 UNLINK,这个命令更清晰的注解了数据的状态.

UNLINK 是一个聪明的命令:它会计算释放对象的开销,如果开销很小,就会直接按 DEL 做的那样立即释放对象,不然对象会被放到后台队列里进行处理.除此之外,这两个命令在语义上是相同的.

我们也实现了 FLUSHALL/FLUSHDB 的非阻塞版本,不过没有新增的 API,而是增加了一个 LAZY 选项,说明是否变动命令的行为.

不只是延迟释放

现在聚合数据类型的值都不再共享了,客户端的输出缓存也不再包含共享对象了,这一点有很多文章可做.例如,现在终于可以在 Redis 里实现线程化的 I/O,从而不同的客户端可以由不同的线程去服务.也就是说,只有拜访数据库才需要全局的锁,客户端的读写系统调用,甚至是客户端发送的命令的解析,都可以在线程中去处理.这跟 memcached 的设计理念类似,我比较期待能够被实现和测试.

所有这些需求引起了更激烈的内部变化,但这里的底线我们已很少顾忌.我们可以补偿对象复制时间来减少高速缓存的缺失,以更小的内存占用聚合数据类型,所以我们现在可按照线程化的 Redis 来进行无共享化设计,这一设计,可以很容易超越我们的单线程.在过去,一个线程化的 Redis 看起来总像是一个坏主意,因为为了实现并发访问数据结构和对象其必定是一组互斥锁,但幸运的是还有别的选择获得这两个环境的优势.如果我们想要,我们依然可以选择快速操作服务,就像我们过去在主线程所做的那样.这包含在复杂的代价之上,获取执行智能(performance-wise).

计划表

我在内部增加了很多器械,明天就上线看上去是不现实的.我的计划是先让3.2版(已经是 unstable 状态)成为候选版本(RC)状态,然后把我们的分支合并到进入 unstable 的3.4版本.

不过在合并之前,必要对速度做细致的回归测试,这有不少工作要做.

如果你现在就想尝试的话,可以从 Github 上下载 lazyfree 分支.不外要注意的是,当前我并不是很频繁的更新这个分支,所以有些地方可能会不能工作.

起源:开源中国 原文:http://antirez.com/news/93作者: antirez

编程之家PHP培训学院每天发布《懒 Redis 是更好的 Redis》等实战技能,PHP、MYSQL、LINUX、APP、JS,CSS全面培养人才。

(编辑:李大同)

【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容!

    推荐文章
      热点阅读