教你如何使用理解懒Redis是更好的Redis

英文原文:Lazy Redis is better Redis

永利网址 1

前言

  前言

大家都知道 Redis 是单线程的。真正的内行会告诉你,实际上 Redis
并不是完全单线程,因为在执行磁盘上的特定慢操作时会有多线程。目前为止多线程操作绝大部分集中在
I/O 上以至于在不同线程执行异步任务的小型库被称为 bio.c: 也就是
Background I/O。

  大家都知道 Redis 是单线程的。真正的内行会告诉你,实际上 Redis
并不是完全单线程,因为在执行磁盘上的特定慢操作时会有多线程。目前为止多线程操作绝大部分集中在
I/O 上以至于在不同线程执行异步任务的小型库被称为 bio.c: 也就是
Background I/O。

然而前阵子我提交了一个问题,在问题里我承诺提供一个很多人(包括我自己)都想要的功能,叫做“免费懒加载”。原始的问题在这

  然而前阵子我提交了一个问题,在问题里我承诺提供一个很多人都想要的功能,叫做“免费懒加载”。原始的问题在这

问题的根本在于,Redis 的 DEL 操作通常是阻塞的。因此如果你发送 Redis “DEL
mykey” 命令,碰巧你的 key 有
5000万个对象,那么服务器将会阻塞几秒钟,在此期间服务器不会处理其他请求。历史上这被当做
Redis 设计的副作用而被接受,但是在特定的用例下这是一个局限。DEL
不是唯一的阻塞式命令,却是特殊的一个命令,因为我们认为:Redis
非常快,只要你用复杂度为 O(1) 和 O(log_N) 的命令。你可以自由使用 O(N)
的命令,但是要知道这不是我们优化的用例,你需要做好延迟的准备。

  问题的根本在于,Redis 的 DEL 操作通常是阻塞的。因此如果你发送 Redis
“DEL mykey” 命令,碰巧你的 key 有
5000万个对象,那么服务器将会阻塞几秒钟,在此期间服务器不会处理其他请求。历史上这被当做
Redis 设计的副作用而被接受,但是在特定的用例下这是一个局限。DEL
不是唯一的阻塞式命令,却是特殊的一个命令,因为我们认为:Redis
非常快,只要你用复杂度为 O 和 O 的命令。你可以自由使用 O
的命令,但是要知道这不是我们优化的用例,你需要做好延迟的准备。

这听起来很合理,但是同时即便用快速操作创建的对象也需要被删除。在这种情况下,Redis
会阻塞。

  这听起来很合理,但是同时即便用快速操作创建的对象也需要被删除。在这种情况下,Redis
会阻塞。

第一次尝试

  第一次尝试

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

  —

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

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

适配是困难的

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

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

  适配是困难的

WHILE 1
    SADD myset element1 element2 … many many many elements
    DEL myset
END

  —

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

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

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

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

1.检测内存趋势:增加还是减少?以决定释放的力度。

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

2.同时适配定时器的频率,避免在只有很少需要释放的时候去浪费CPU,不用频繁的去中断事件循环。当确实需要的时候,定时器也可以达到大约300HZ的频率。
这里有一小段代码,不过这个想法现在已经不再实现了:

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

/计算内存趋势,只要是上次和这次内存都在增加,就倾向于认为内存趋势

是增加的 */

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

if (prev_mem < mem) mem_trend = 1;
   
mem_trend *= 0.9; /* 逐渐衰减 */
   
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–; /* Raise call frequency. */
       
    else if (永利网址,!mem_is_raising && timer_period < 20)

        timer_period++; /* Lower call frequency. */
   
} else {
       
  timer_period = 1000;    /* 1 HZ */
   
}

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

这是一个小技巧,工作的也很好。不过郁闷的是我们还是不得不在单线程里执行。要做好需要有很多的逻辑,而且当延迟释放(lazy
free)周期很繁忙的时候,每秒能完成的操作会降到平时的65%左右。
如果是在另一个线程去释放对象,那就简单多了:如果有一个线程只做释放操作的话,释放总是要比在数据集里添加数据来的要快。

  /计算内存趋势,只要是上次和这次内存都在增加,就倾向于认为内存趋势

是增加的 */

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

  if (prev_mem mem) mem_trend = 1;
 mem_trend *= 0.9; /* 逐渐衰减
*/
 int mem_is_raising = mem_trend .1;

 /* 释放一些元素 */

size_t workdone = lazyfreeStep(LAZYFREE_STEP_SLOW);

 /*
根据现有状态调整定时器频率 */
 if {
 if (timer_period == 1000)
timer_period = 20;
 if (mem_is_raising timer_period 3)

timer_period–; /* Raise call frequency. */
 else if
(!mem_is_raising timer_period 20)
 timer_period++; /* Lower call
frequency. */
 } else {
 timer_period = 1000; /* 1 HZ */
 }



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

  这是一个小技巧,工作的也很好。不过郁闷的是我们还是不得不在单线程里执行。要做好需要有很多的逻辑,而且当延迟释放(lazy
free)周期很繁忙的时候,每秒能完成的操作会降到平时的65%左右。

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

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

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

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

这样做有个问题,假设有个命令:SADD myset
myvalue,举个例子来说,我们做不到通过client->argv[2]
来引用用来实现集合的哈希表的某个元素。我们不得不很多次的把值复制出来,即使数据已经在客户端命令解析后创建的参数
vector
里,也没办法去复用。Redis的性能受控于缓存失效,我们也许可以用稍微间接一些的办法来弥补一下。
于是我在这个 lazyfree 的分支上开始了一项工作,并且在 Twitter
上聊了一下,但是没有公布上下文的细节,结果所有的人都觉得我像是绝望或者疯狂了(甚至有人喊道
lazyfree 到底是什么玩意)。那么,我到底做了什么呢?

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

把客户端的输出缓存由 robj 结构体改成动态字符串。在创建 reply
的时候总是复制值的内容。
把所有的 Redis 数据类型转换成 SDS
字符串,而不是使用共享对象结构。听上去很简单?实际上这花费了数周的时间,涉及到大约800行高风险的代码修改。不过现在全都测试通过了。
把 lazyfree 重写成线程化的。
结果是 Redis 现在在内存使用上更加高效,因为在数据结构的实现上不再使用
robj 结构体(不过由于某些代码还涉及到大量的共享,所以 robj
依然存在,例如在命令分发和复制部分)。线程化的延迟释放工作的很好,比增量的方式更能减少内存的使用,虽然增量方式在实现上与线程化的方式相似,并且也没那么糟糕。现在,你可以删除一个巨大的
key,性能损失可以忽略不计,这非常有用。不过,最有趣的事情是,在我测过的一些操作上,Redis
现在都要更快一些。消除间接引用(Less
indirection)最后胜出,即使在不相关的一些测试上也更快一些,还是因为客户端的输出缓存现在更加简单和高效。

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