惰性 Redis 才是更好的 Redis
原文由 Salvatore Sanfilippo 于 发布,订阅该博客
人人都知道 Redis 是单线程的。更了解内情的人会告诉你,其实 Redis 算是“某种意义上”的单线程,因为它也会用一些线程来执行某些在磁盘上较慢的操作。迄今为止,这些线程化的操作都集中在 I/O 上,以至于我们那个在另一个线程上执行异步任务的小库就直接叫 bio.c:基本上就是 Background I/O,后台 I/O。
不过前段时间我提了一个 issue,承诺要做一个包括我在内的许多人都期待的新特性,叫作“惰性释放(lazy free)”。原始 issue 在这里:https://github.com/antirez/redis/issues/1748。
这个 issue 的核心在于,Redis 的 DEL 操作通常是阻塞式的,所以如果你向 Redis 发送“DEL mykey”,而这个键恰好包含了 5000 万个对象,服务器就会阻塞数秒,期间什么请求都处理不了。历史上,这一点大多被当作 Redis 设计的副作用而被接受,但在某些使用场景下,它就成了一个限制。DEL 并不是唯一会阻塞的命令,但它比较特殊,因为我们通常会说:只要你用的是 O(1) 和 O(log N) 命令,Redis 就非常快。你当然也可以用 O(N) 命令,但要清楚那不是我们重点优化的场景,要做好出现延迟毛刺的准备。
这话听起来很合理,但与此同时,即便是通过快速操作创建的对象,最终也需要被删除。而在这种情况下,Redis 就会阻塞。
第一次尝试
在单线程服务器里,让操作不阻塞的简单办法就是增量执行,而不是让整个世界都停下来。举个例子,如果要释放 100 万次分配,与其在一个 for 循环里一下子全部释放而阻塞所有请求,不如每毫秒释放 1000 个元素。占用的 CPU 时间其实是一样的,甚至还略多一点,因为多了一些逻辑,但从用户的角度看,延迟体验要好得多。也许那每毫秒释放 1000 个元素所用的周期本来就是空闲的。关键在于避免长达数秒的阻塞。Redis 内部的很多机制就是这么做的:LRU 淘汰和过期键的删除就是两个明显的例子,但还有更多,比如哈希表的增量重哈希。
所以这就是我最初尝试的方案:新建一个定时器函数,把回收工作放在里面做。对象只是被放进一个链表里排队,等到每次调用定时器函数时再慢慢地、增量地回收。要让它正常工作,需要一些技巧。例如,对于用哈希表实现的对象,回收时也会增量进行,复用了 Redis SCAN 命令里用到的那套机制:在字典里持有一个游标,逐个遍历并释放元素。这样,在每次定时器调用中,我们就不必一次性释放整个哈希表。游标会告诉我们下次再进入定时器函数时该从哪里继续。
自适应很难
知道这里最难的是什么吗?这一次,我们增量执行的是一项非常特殊的任务:释放内存。所以如果在我们增量释放内存的同时,服务器内存又在快速上涨,为了保证低延迟,我们可能会无限制地消耗内存。这非常糟糕。想象一下这样的场景:
WHILE 1
SADD myset element1 element2 … many many many elements
DEL myset
END如果后台删除 myset 的速度比我们每次 SADD 调用往里添加大量元素的速度要慢,内存占用就会永远增长下去。
不过在做了一些实验之后,我找到了一种效果很好的办法。定时器函数用了两个思路来适应内存压力:
- 检查内存趋势:是在上涨还是在下降?以此来调整释放的激进程度。
- 同时也根据“1”来调整定时器本身的触发频率,这样在没什么可释放时就不会因频繁打断事件循环而浪费 CPU 时间。而在真正需要时,定时器的频率可以达到约 300 HZ。
下面是一小段代码,来自那个现已不存在、实现了上述思路的函数:
/* Compute the memory trend, biased towards thinking memory is raising
* for a few calls every time previous and current memory raise. */
if (prev_mem < mem) mem_trend = 1;
mem_trend *= 0.9; /* Make it slowly forget. */
int mem_is_raising = mem_trend > .1;
/* Free a few items. */
size_t workdone = lazyfreeStep(LAZYFREE_STEP_SLOW);
/* Adjust this timer call frequency according to the current state. */
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 */
}这是个不错的技巧,而且效果很好。但即便如此,不得不在单线程里做这件事还是让人有点沮丧。要处理好它需要大量逻辑,而且当惰性释放周期非常繁忙时,每秒操作数会降到正常水平的约 65%。
如果把对象的释放放到另一个线程里,就会简单得多:只要有一个线程专职做释放,释放几乎总是比向数据集中添加新值更快。当然,主线程调用分配器和惰性释放线程做同样的事之间肯定会有一些竞争,但 Redis 花在分配上的时间只占一小部分,更多时间都花在了 I/O、命令分发、缓存未命中等等上面。
然而,用线程来实现惰性释放有一个很大的障碍:Redis 自身。它的内部设计完全偏向于到处共享对象。毕竟对象都是带引用计数的,对吧?那为什么不尽量共享呢?既能省内存又能省时间。举几个例子:如果你执行 SUNIONSTORE,目标集合里最终会是共享的对象。同样,客户端输出缓冲区里也存着要通过套接字发回的对象列表,所以在执行像 SMEMBERS 这样的调用时,集合的所有成员都可能在输出缓冲区的列表里被共享。所以共享对象听起来非常有用、美好、奇妙、超级酷。
但是,嘿,这里还有另一面。如果在执行 SUNIONSTORE 之后我重新加载数据库,对象就会变成非共享的,内存可能会突然比之前涨出一大截。这可不好。而且当我们向客户端发送回复时,实际上会把小的对象“拼接”成普通的缓冲区,因为否则做很多次 write() 调用效率并不高!(免费提示,writev() 也帮不上忙)。所以我们本来就在做大量拷贝。而在编程中,如果某个东西没什么用却还存在,它很可能就是个问题。
事实上,每次你要访问一个包含聚合数据类型的键中的某个值时,都要经过这样一串跳转:
key -> value_obj -> hash table -> robj -> sds_string那么,如果彻底去掉“robj”结构,把聚合类型的值改成直接由 SDS 字符串组成的哈希表(或跳表)会怎么样?(SDS 是我们在 Redis 内部用来处理字符串的库)。这样做有个问题。想象一下像 SADD myset myvalue 这样的命令。我们不能直接拿 client->argv[2] 这样的参数,就把它引用到实现集合的哈希表里。有时候我们必须*复制*值,不能复用命令解析时在客户端参数向量里已经创建好的那个。不过 Redis 的性能瓶颈主要是缓存未命中,所以也许少一次间接跳转就能抵消这个代价?
于是我开始在这个新的 lazyfree 分支上动手,并在 Twitter 上不带任何上下文地发推谈论它,搞得大家都以为我是不是绝望了或者疯了(后来确实有几个人跑来问这所谓的 lazyfree 到底是什么)。那么我到底做了什么呢?
- 把客户端输出缓冲区改成只使用动态字符串,而不是 robj 结构。需要生成回复时,值一律拷贝。
- 把所有 Redis 数据类型改成使用 SDS 字符串,而不是共享的 robj 结构。听起来简单?在几周时间里改了约 800 行对 bug 极其敏感的代码。但现在所有测试都通过了。
- 把 lazyfree 重写为线程化实现。
结果是,Redis 现在内存效率更高了,因为在数据结构的实现中不再到处都是 robj 结构(但在存在大量共享的代码路径中,比如命令分发和复制时,仍然会用到它们)。线程化的惰性释放效果很好,回收内存的速度比增量式更快,即便增量式的实现我本人也很喜欢,而且相比线程化也并没有差得那么离谱。但现在,你可以删除一个巨大的键,性能下降却微乎其微,这非常有用。不过,最有意思的是,到目前为止我测试过的所有操作,Redis 都变快了。少一次间接跳转确实是个赢家。甚至在一些不相关的基准测试中也变快了,仅仅是因为客户端输出缓冲区现在更简单、更快了。最后,我把增量式惰性释放的实现从分支里删掉了,只保留了线程化的版本。
关于 API 的说明
那 API 方面呢?我们仍然保留了阻塞式的 DEL,默认行为不变,因为在 Redis 里 DEL 的语义就是:立即回收内存。我不想改变这一点。所以现在你有了一个新命令叫 UNLINK,它更清楚地表达了值身上发生了什么。
UNLINK 是一个聪明的命令:它会计算对象的释放代价,如果代价非常小,就会像 DEL 该做的那样尽快释放对象。否则,对象会被送到后台队列去处理。除此之外,这两个命令在键空间语义上是完全一致的。
FLUSHALL / FLUSHDB 的非阻塞变体也已经实现了,只是在 API 层面还没暴露,它们会接受一个 LAZY 选项,如果给出该选项,就会改变其行为。
不只是惰性释放
现在聚合数据类型的值已经完全不再共享,客户端输出缓冲区也不再包含共享对象,有很多事情可以做了。例如,终于可以在 Redis 中实现线程化 I/O 了,让不同的客户端由不同的线程来服务。这意味着只有在访问数据库时才需要全局锁,而客户端的读/写系统调用,甚至客户端发送命令的解析,都可以在不同的线程中进行。这是一种类似于 memcached 的设计,也是我期待去实现和测试的。
此外,现在还可以把聚合数据类型上的某些慢操作放到另一个线程中去执行,做法是只有少数键会被“阻塞”,而所有其他客户端都可以继续执行。这可以用一种与我们目前处理阻塞操作非常相似的方式来实现(见 blocking.c),再加上一个哈希表来记录哪些键当前正忙、被哪个客户端占用。这样,如果某个客户端请求类似 SMEMBERS 这样的操作,就可以只锁定这个键,处理请求并生成输出缓冲区,之后再释放该键。只有试图访问同一个被阻塞键的客户端才会被阻塞。
所有这些都需要更大幅度的内部改动,但这里的底线是,我们少了一个禁忌。我们可以用更少的缓存未命中和聚合数据类型更小的内存占用来抵消对象拷贝的时间,所以现在我们可以自由地以无共享(share-nothing)设计来思考线程化的 Redis,而这也是唯一能够轻松超越我们单线程模型的设计。过去,线程化的 Redis 如果被设想为在数据结构和对象上加一堆互斥锁来实现并发访问,总被认为是馊主意,但幸运的是,还有其他办法可以兼得两者的好处。而且如果我们愿意,仍然可以像过去一样,让所有快速操作都在主线程中执行。至少在性能上应该只有收益,代价只是一些可控的复杂性。
预计上线时间
我动了大量内部代码,这不是明天就能上线的东西。所以我的计划是,把目前 unstable 分支已有的内容作为 3.2 版本,先把它推到发布候选(Release Candidate)状态,然后再把这个分支合并到 unstable 中,作为 3.4 的目标。
不过在合并之前,应该对是否存在速度回退做一次非常细致的检查。肯定还有更多工作要做。
如果你想试试,可以去 GitHub 上查看“lazyfree”分支。顺便提醒一下,目前我还在非常活跃地开发它,所以某些时候某些功能可能会完全不可用。
随机一篇博客
评论
登录后参与讨论