为什么Redis的RDB备份不用多线程实现CopyOnWrite?

白昼怎懂夜的黑 提交于 2020-04-29 11:02:25

前言

这篇文章源于我昨天看到的一个有意思的问题。


快照持久化是个很耗时间的操作,而Redis采用fork一个子进程出来进行持久化。理论而言,fork出来的子进程会拷贝父进程所有的数据,这样当Redis要持久化2G的内存数据的时候,子进程也会占据几乎2G的内存。那么此时Redis相关的进程内存占用就会达到4G左右。这在数据体量比较小的时候还不严重,但是比如你的电脑内存是8G,目前备份快照数据本身体积是5G,那么按照上面的计算备份一定是无法进行的。所幸在Unix类操作系统上面做了如下的优化:在刚开始的时候父子进程共享相同的内存,直到父进程或者子进程进行内存的写入后,对被写入的内存共享才结束。这样就会减少快照持久化时对内存的消耗。这就是COW技术,减少了快照生成时候的内存使用的同时节省了不少时间。而备份期间多用的内存正比于在此期间接收到的数据更改请求数目。

更具体地讲,我们知道每个进程的虚拟空间是被划分成正文段,数据段,堆,栈这四个部分,同时对应于每一个部分,操作系统会为之分配真实物理块。当我们从父进程P1中fork出一个子进程P2时:

  • 在没有CopyOnWrite之前,我们要给子进程生成虚拟空间,并为虚拟空间地每一个部分分配对应地物理空间,接着要把父进程对应部分地物理空间地内容复制到子进程的空间中。这实际上是个既耗时又耗费空间地操作。
  • 有了COW之后, fork子进程时,我们只为其生成虚拟空间,但是并不先为每个部分分配真实的物理空间,而是让每个虚拟空间部分仍然指向父进程的物理空间。只有当父进程或子进程修改相应的共享内存空间时,才会为子进程分配物理空间并把父进程的物理空间内容进行复制。这就是所谓的写时复制,即把内存的复制延迟到了内存写入的时刻

同时需要注意地是,父子进程共享的空间粒度是页(在Linux中,页的大小为4KB),父/子进程修改某个页时,该页的共享才结束,同时子进程分配该页大小的物理空间复制父进程对应页的内容。这样,如果当子进程运行期间,父子进程都没有修改数据,那么操作系统就节省了大量的内存复制时间和占用空间。

上面讲的CopyOnWrite是操作系统在fork子进程时实现的。而题主问的是,我们能不能用多线程来实现COW进而来实现RDB生成呢?在回答这个问题之前,为了让大家更明白多线程实现COW的事情,我们先以Java中的CopyOnWriteArrayList为例进行来看多线程实现COW是个什么操作。

首先我们看这么一段代码。这段代码在多线程下肯定是不安全的,为了让它变得更安全,一个简单的方法就是读取和写入时都加锁,即同时要有读锁和写锁。但是我们都知道锁是非常影响性能的,为了减少锁的消耗,Java便推出了CopyOnWriteArrayList。

public static Object getLast(Vector list){
    int lastIndex = list.size() - 1;
    return list.get(lastIndex);
}

public static void deleteLast(Vector list){
    int lastIndex = list.size() - 1;
    return list.remove(lastIndex);
}

CopyOnWriteArrayList 相对于 ArrayList 线程安全,底层通过复制数组的方式来实现,其核心概念就是: 数据读取时直接读取,不需要锁,数据写入时,需要锁,且对副本进行操作。那么当数据的操作以读取为主时,我们便可以省去大量的读锁带来的消耗。同时为了能让多线程操作List时,一个线程的修改能被另一个线程立马发现,CopyOnWriteList采用了Volatile关键词来进行修饰,即每次数据读取不从缓存里面读取,而是直接从数据的内存地址中读取。

我们以CopyOnWriteArrayList 的add()操作为例来看。

  // 这个数组是核心的,因为用volatile修饰了
  // 只要把最新的数组对他赋值,其他线程立马可以看到最新的数组
  private transient volatile Object[] array;

  public boolean add(E e) {

      final ReentrantLock lock = this.lock;
      lock.lock();

      try {
          Object[] elements = getArray();
          int len = elements.length;

          // 对数组拷贝一个副本出来
          Object[] newElements = Arrays.copyOf(elements, len + 1);

          // 对副本数组进行修改,比如在里面加入一个元素
          newElements[len] = e;

          // 然后把副本数组赋值给volatile修饰的变量
          setArray(newElements);
          return true;


      } finally {
          lock.unlock();
      }
  }
 

总结而言,多线程实现COW实际上就是以空间换取时间使得数据读取时不需要锁。只是减少了读锁的开销,但与常规的多线程操作共享数据的本质没有什么区别。

好,最后我们回到题主的问题,使用多线程实现COW来实现RDB生成这个问题可以规约成使用多线程实现RDB生成问题。所以我们的问题核心在于解决能不能使用多线程来实现RDB生成。如果要这么做我们需要做出哪些额外的操作?

大家肯定会想RDB的生成过程本质不就是把内存中的数据序列化到硬盘文件中么?RDB生成时,子线程只需要进行数据读取,主线程修改时加锁修改。并且为了避免常规操作时锁的过多开销,我们可以只需要在RDB生成期间再加锁,常规期间写操作不需要加锁。这样总体而言带来的开销不会多很多,因为毕竟RDB生成是个低频的操作。

但这里面其实有个很重要的概念就是”SnapShot“, 即RDB是Redis内存的某一个时刻的快照。比如,我6:15分开始生成RDB, 那么这个RDB保存的数据就是当时那一刻整个Redis内存中的数据状态。使用多进程我们是很容易保证这一点的,但是使用多线程,我们是很难保证这个性质的。因为你可能在DUMP的过程中,主线程又修改了你还没读取的数据,又或者主线程修改了你刚刚已经序列化到文件中的某个数据。也就是说使用多线程进行生成RDB的时候,你并不知道自己生成的数据是到底哪个时刻的数据。你也并不知道修改期间哪些主线程的命令已经体现在了RDB文件中。

这个会产生大的影响么?单机版的Redis也许不大会,但是Redis集群中涉及到主从复制的时候就会产生很大的影响。

单机版Redis生成RDB无非就是想留个档,那么具体RDB是哪一个时刻的,可能没那么重要。更重要的是要生成RDB。而且这个RDB显然越新越好,因为越新,Redis重启后丢失的数据就越少。那么从这个角度而言,甚至说用多线程反而可能更好,因为多线程时可以让一些生成RDB期间被修改的数据也体现在RDB中。

但是涉及到主从复制时就不可以了。主从复制时,Redis主节点会生成当时时刻的内存快照RDB文件,同时把RDB期间的所有的命令写到缓存repl_backlog中,等从节点从主节点的RDB文件恢复数据之后,便从主节点的命令缓存中读取所有的命令再进行执行一遍,以达到和主节点相同的状态。那么用多线程生成RDB时,如果当主线程执行某个写入命令时,从线程还未DUMP该数据,那么从线程生成的RDB就包含了该命令的执行结果。而子节点又恢复了数据之后,相当于子节点已经执行过了这个命令。那么当子节点从主节点的命令缓存中拉取命令来再执行一遍后,有些命令就会被重复执行。

看完觉得对你有帮助的话,那就记得关注我的专栏!

一亩三分地zhuanlan.zhihu.com图标

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!