背景
前情提要
作为一名实习生,在此之前,我只在蒋德钧的 《redis 核心技术与实战》 中了解过基于 SSD 的大容量 Redis,当时他是以 Pika 这一开源项目举例讲解的基于 SSD 的大容量 redis。在我实习的公司内部,也有相应的组件,并且另外做了一些优化,可以基于多种引擎(不过主要也是基于 RocksDB),并且在我所在的业务中大量使用。因此,本文将综合我见过的相关资料,对对应的大容量 redis 做一些分析和积累沉淀。这里也主要参考了蒋德钧的 《redis 核心技术与实战》的文章和公司内部相关组件的一些科普。
为什么要有基于 SSD 实现的 redis
在业务中,很可能一开始的技术架构并不面向巨大的数据量,但是随着数据规模的增加,我们需要 Redis 这种可以存储更多的数据。我们当然可以采用横向扩展的方式,通过增加实例来实现。但是正如老生常谈的一样,我们再考虑分片的时候,需要考虑实例数量和实例内存大小之间的平衡。如果内存大小太大,就会在 RDB 恢复和主从备份的时候带来内存容量有限、单线程阻塞、启动恢复时间长、内存硬件成本贵、缓冲区容易写满、一主多从故障时切换代价大等问题;但是如果集群数目太多,我们又很难以运维,成本也很高。 那么我们可以怎么选择呢,当然,我们可以采用其他数据存储方案去完成这个任务,但是很多时候,更换数据存储方案就意味着 api 的变更。考虑到人员开发的学习成本和开发成本,我们当然是希望新的方案兼容 redis,方便其平滑过渡到我们的新的方案了。 这时候,我们就可以基于 RocksDB 去完成基于 redis 的任务了。因为 SSD 的成本相较于内存明显存在优势,因此,在大规模数据量的场景下,我们往往采用 SSD 的 K-V 存储。但是这种 K-V 存储是怎么解决一主多从和备份的问题的呢?我们可以从 RocksDB 和 binlog 的原理等来分析,我们基于 SSD 实现的 redis 是如何解决这个问题的。
基于 pika 对基于 SSD 实现的 redis 的分析
pika 架构介绍
pika 分成了五个模块,底层的 RocksDB 存储引擎和 binlog 机制,Nemo 存储模块,再上一层的 Pika 线程模块,和最上层的网络框架。最上层的网络模块相当于对内核的网络函数做了一层封装,然后是线程模块,线程模块和 redis 略有不同,redis 是采用了多路复用的 epoll,并且是单线程的。pika 则更接近主从多线程的 Reactor 模型,有一个 DispatchThread 去监听用户的连接请求,然后会把连接先给到我们的 WorkThread(WorkThread 是有多个的),当用户发起命令的时候,会先找到对应的 WorkThread,最后再将我们的命令封装成 Task 去交给线程池解决。Nemo 则是为了兼容 redis 的协议设计的模块。
如何解决上面大内存实例存在的问题
那么,既然是基于 SSD,就不需要内存快照来恢复我们的内容,此外,主从同步则通过 binlog 得到了解决。 首先介绍 pika 的 RocksDB 是怎么对 SSD 进行读写,从而实现我们的持久化存储的。当 Pika 需要保存数据的时候,会使用两个不同的内存区域,称之为 memtable,来存储我们的数据,当一个区域写满了以后,我们会交替使用另一个区域写入,并且将这个区域写到 SSD 中。这样,我们只需要有两个 memtable 的空间就可以存储我们足够多的数据了,内存占用不会很高。 接下来介绍 Binlog 的机制,我们知道 binlog 是用来记录 DDL 和 DML 机制的,如果在 redis 中,就像 AOF 一样。采用这种机制和使用 AOF 的优点相似,但是 binlog 是写在 SSD 中的,这就有效地减少了缓存区溢出的问题,binlog 是保存在 SSD 上的文件,文件大小不像缓冲区,不会占用寸土寸金的内存。而且,当 binlog 文件增大后,还可以把旧的 binlog 文件独立保存,再生成新的文件。根据这两点,我们就可以利用 RocksDB 和 binlog 机制,解决 redis 大内存实例存在的问题。
如何解决和 redis 兼容的问题
上面提到 Nemo 模块可以解决我们 redis 和 pika 的兼容问题,下面我们看一下是如何做到和 redis 兼容的。如果是单值的 List 和 Set,那么我们很好处理,对于无序的 Set,我们只需要直接映射,在 key 前加上 size 字段就可以了,对于有序的 List,我们只需要把对应的 key 加上自身 sequence 字段和 size,value 加上 preseq 和 nextseq,就可以保证其有序。当然 Set 和 List 的 key 前还有对应的首字母作为保证。对于 hash 这个数据结构,我们只需要增加 field 在 key 中就可以。zset 则更接近于 List,只不过在转换的时候要保证其有序。
用 SSD 还有什么优势和劣势
用 SSD 的优势和劣势也很明显,剩下的优势首先是重启快,因为数据是持久化存储的,不用每次重新恢复。而劣势首先是使用 SSD 不可避免的性能下降, 如果我们对去请求的时延有严格的要求,我们还是应当使用 redis 或者 memcache 这种内存的 k-v 存储。
延时优化-公司内部组件对内核参数和 RocksDB 引擎等的调优处理
实习的公司内部也有对应的在 SSD 基础上的兼容 redis 协议的存储方案,并列出了一部分调优处理,这里学习和沉淀其中一部分,像读写分离之类的,这里不再列出。
vm.min_free_kbytes
vm.min_free_kbytes
决定我们内存预留给内核的大小,我们又知道内核进行内存回收的几个阈值,也就是 pages_high
,pages_low
和 pages_min
这三个字段的值。如果是 pages_low
我们就会触发 kswapd
的后台内存回收,如果到达 pages_min
则会触发直接内存回收,直接内存回收不再是异步的,而是在前台的阻塞的,这样的话,而 vm.min_free_kbytes
就会决定我们 pages_min
的大小。我们将这个值调大,就可以减少我们的直接内存回收,也就会减少直接内存回收的开销,从而做到性能的提升。如果要用到我们自己的数据库中,我们可以调大这个参数以后,利用 sar -B 1
这个命令去每一秒更新一次分页统计信息,从而得到我们进行直接内存回收的次数,从直接内存回收的次数,我们就可以感知到这个参数对我们服务的影响了。
NUMA 和 vm.zone_reclaim_mode
在现代的处理器中,我们会有 NUMA 的机制,也就是非统一内存访问,它决定了我们会先访问和利用 CPU 临近的内存(实际上是将某个内存附近的核心视作一个整体)。有的镜像版本中,vm.zone_reclaim_mode
的值默认是 1。这个值会带来哪些影响呢?他会导致我们优先回收 PageCache 而不是优先使用其它 NUMA 上的内存。其具体意义如下: 0 (默认值): Allocate from all nodes before reclaiming memory 当设置为 0 时,系统会尝试从所有可用的内存节点分配内存,而不会特别优先考虑本地节点(与当前 CPU 相邻的内存节点)。这意味着系统在考虑进行内存回收之前,会先尝试从任何可用的节点分配内存。 1: Reclaim memory from local node vs allocating from next node 当设置为 1 时,系统在分配内存时会优先考虑当前 CPU 的本地节点。如果本地节点的内存不足,系统会先尝试回收本地节点的内存,而不是从其他节点分配内存。这可以减少内存访问的延迟,但可能增加了本地节点的内存压力。 2: Zone reclaim writes dirty pages out 当设置为 2 时,系统在进行区域回收(zone reclaim)时会尝试写出脏页(已修改但还未写回硬盘的内存页)。这可以帮助释放内存,但可能会导致增加 I/O 操作。 4: Zone reclaim swaps pages 当设置为 4 时,系统在进行区域回收时会尝试交换(swap)内存页到交换空间(swap space)。这同样可以帮助释放内存,但可能会影响性能,因为交换操作通常比直接内存访问慢。 我们优先进行 PageCache 回收,那么实际上用到的只有本 NUMA 内的内存,会引发更多的 direct reclaim,从而导致性能达不到预期。 这里有一个 bug,zhuanlan.zhihu.com/p/387117470 这篇文章介绍了这个 bug 的影响,2014 年前只要我们使用了 NUMA 就会导致这个参数为 1,也就是会引发大量的内存回收,当时很多数据库选择关闭了 NUMA。但是修复这个 Bug 后,开启 NUMA 将 OS 可以感知 CPU 的物理架构,也就可以就近分配内存,从而尽可能让性能变得最优。当然,既然要开启 NUMA ,我们肯定要将 vm.zone_reclaim_mode
设为 0。
RocksDB 的参数调优
公司的组件还将 use_direct_io_for_flush_and_compaction
设置为 true
,这决定了我们的 RocksDB 在进行 Flush 和 Compaction 操作时将直接与磁盘交互,绕过操作系统的页面缓存。这意味着数据不会被缓存到操作系统的内存中,而是直接从磁盘读取或写入,这就会降低我们的 IO 延迟,在高 IO 场景就会得到更好的效果,内存的占用也会更低;同时,一致性也可以得到更好的保证。当然,少了一层缓存,也就少了很多聚合操作,对磁盘的压力会增大,但是大多数场景下是值得的。 公司的组件还分离了 RocksDB Flush 和 Compaction 的线程池,将对应的线程数设置的尽量少,从而可以减少抖动。
Work Stealing
"Work Stealing" 是一种并行计算和多线程程序设计中常用的负载均衡策略,它主要用于动态分配工作负载。在 Work Stealing 策略中,每个处理器(或线程)都有自己的工作队列。这些处理器独立地执行自己队列中的任务。当一个处理器完成了自己队列中的所有任务,而其他处理器的队列仍然有未完成的任务时,这个空闲的处理器会从其他处理器的队列中"偷取"任务来执行。这种策略的关键在于,处理器尝试在本地队列中找工作,只有在本地没有工作时,才会去"偷"别的处理器的工作。 另外,这里介绍一下The power of two random choices
这一负载均衡策略,它结合了随机和动态负载均衡策略两种思想,核心思路是随机挑选两个实例,然后将任务交给其中负载较轻的一个。公司内部的组件就是利用这个策略 + MPMC 队列去实现我们线程池中每一个 Worker 线程,从而更好的支持 WorkStealing。据分析,我们的 RocksDB 读操作链路很长,用 WorkStealing 有很大的提升。