在线时间:8:00-16:00
迪恩网络APP
随时随地掌握行业动态
扫描二维码
关注迪恩网络微信公众号
英文原文:Lazy Redis is better Redis 前言 大家都知道 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 会阻塞。 第一次尝试 对于单线程服务器,为了让操作不阻塞,最简单的方式就是用增量的方式一点点来,而不是一下子把整个世界都搞定。例如,如果要释放一个百万级的对象,可以每一个毫秒释放1000个元素,而不是在一个 for() 循环里一次性全做完。CPU 的耗时是差不多的,也许会稍微多一些,因为逻辑更多一些,但是从用户来看延时更少一些。当然也许实际上并没有每毫秒删除1000个元素,这只是个例子。重点是如何避免秒级的阻塞。在 Redis 内部做了很多事情:最显然易见的是 LRU 淘汰机制和 key 的过期,还有其他方面的,例如增量式的对 hash 表进行重排。 刚开始我们是这样尝试的:创建一个新的定时器函数,在里面实现淘汰机制。对象只是被添加到一个链表里,每次定时器调用的时候,会逐步的、增量式的去释放。这需要一些小技巧,例如,那些用哈希表实现的对象,会使用 Redis 的 SCAN 命令里相同的机制去增量式的释放:在字典里设置一个游标来遍历和释放元素。通过这种方式,在每次定时器调用的时候我们不需要释放整个哈希表。在重新进入定时器函数时,游标可以告诉我们上次释放到哪里了。 适配是困难的 你知道这里最困难的部分是哪里吗?这次我们是在增量式的做一件很特别的事情:释放内存。如果内存的释放是增量式的,服务器的内容增长将会非常快,最后为了得到更少的延时,会消耗调无限的内存。这很糟,想象一下,有下面的操作:
如果慢慢的在后台去删除myset,同时SADD调用又在不断的添加大量的元素,内存使用量将会一直增长。 好在经过一段尝试之后,我找到一种可以工作的很好的方式。定时器函数里使用了两个想法来适应内存的压力: 1.检测内存趋势:增加还是减少?以决定释放的力度。 2.同时适配定时器的频率,避免在只有很少需要释放的时候去浪费CPU,不用频繁的去中断事件循环。当确实需要的时候,定时器也可以达到大约300HZ的频率。 /计算内存趋势,只要是上次和这次内存都在增加,就倾向于认为内存趋势 是增加的 */
这是一个小技巧,工作的也很好。不过郁闷的是我们还是不得不在单线程里执行。要做好需要有很多的逻辑,而且当延迟释放(lazy free)周期很繁忙的时候,每秒能完成的操作会降到平时的65%左右。 当然,主线程和延迟释放线程直接对内存分配器的使用肯定会有竞争,不过 Redis 在内存分配上只用到一小部分时间,更多的时间用在I/O、命令分发、缓存失败等等。 但是,嘿,还需要再多说一句的是,如果在 SUNIONSTORE 命令之后重新加载了数据库,对象都取消了共享,内存也会突然回复到最初的状态。这可不太妙。接下来我们发送应答请求给客户端,会怎么样?当对象比较小时,我们实际上是把它们拼接成线性的缓存,要不然进行多次 write() 调用效率是不高的!(友情提示,writev() 并没有帮助)。于是我们大部分情况下是已经复制了数据。对于编程来说,没有用的东西却存在,通常意味着是有问题的。
如果去掉整个 tobj 结构体,把聚合类型转换成 SDS 字符串类型的哈希表(或者跳表)会怎么样?(SDS是Redis内部使用的字符串类型)。 这样做有个问题,假设有个命令:SADD myset myvalue,举个例子来说,我们做不到通过client->argv[2] 来引用用来实现集合的哈希表的某个元素。我们不得不很多次的把值复制出来,即使数据已经在客户端命令解析后创建的参数 vector 里,也没办法去复用。Redis的性能受控于缓存失效,我们也许可以用稍微间接一些的办法来弥补一下。 把客户端的输出缓存由 robj 结构体改成动态字符串。在创建 reply 的时候总是复制值的内容。 最后我把增量式的延迟释放实现从分支里删除,只保留了线程化的实现。 关于 API 的一点备注 不过 API 又怎么样了呢?DEL 命令仍然是阻塞的,默认还跟以前一样,因为在 Redis 中 DEL 命令就意味着释放内存,我并不打算改变这一点。所以现在你可以用新的命令 UNLINK,这个命令更清晰的表明了数据的状态。 UNLINK 是一个聪明的命令:它会计算释放对象的开销,如果开销很小,就会直接按 DEL 做的那样立即释放对象,否则对象会被放到后台队列里进行处理。除此之外,这两个命令在语义上是相同的。 我们也实现了 FLUSHALL/FLUSHDB 的非阻塞版本,不过没有新增的 API,而是增加了一个 LAZY 选项,说明是否更改命令的行为。 不只是延迟释放 — 现在聚合数据类型的值都不再共享了,客户端的输出缓存也不再包含共享对象了,这一点有很多文章可做。例如,现在终于可以在 Redis 里实现线程化的 I/O,从而不同的客户端可以由不同的线程去服务。也就是说,只有访问数据库才需要全局的锁,客户端的读写系统调用,甚至是客户端发送的命令的解析,都可以在线程中去处理。这跟 memcached 的设计理念类似,我比较期待能够被实现和测试。 还有,现在也可以在其他线程实现针对聚合数据类型的特定的慢操作,可以让某些 key 被“阻塞”,但是所有其他的客户端不会被阻塞。这个可以用很类似现在的阻塞操作的方式去完成(参考blocking.c),只是增加一个哈希表保存那些正在处理的 key 和对应的客户端。于是一个客户端请求类似 SMEMBERS 这样的命令,可能只是仅仅阻塞住这一个 key,然后会创建输出缓存处理数据,之后在释放这个 key。只有那些尝试访问相同的 key 的客户端,才会在这个 key 被阻塞的时候被阻塞住。 计划表 我在内部增加了很多东西,明天就上线看上去是不现实的。我的计划是先让3.2版(已经是unstable状态)成为候选版本(RC)状态,然后把我们的分支合并到进入unstable的3.4版本。 不过在合并之前,需要对速度做细致的回归测试,这有不少工作要做。 如果你现在就想尝试的话,可以从Github上下载lazyfree分支。不过要注意的是,当前我并不是很频繁的更新这个分支,所以有些地方可能会不能工作。 |
请发表评论