Why'sTheDesign(Why'sTHEDesign)是一系列关于计算领域编程决策的文章。在本系列的每篇文章中,我们都会提出一个特定的问题,并从不同的角度讨论设计。优缺点,影响具体实施。如果大家有什么想了解的问题,可以在文章下方留言。虽然我们经常把Redis当做一个纯内存的key-value存储系统,但是我们也用到了它的持久化功能。RDB和AOF是Redis提供的两种持久化工具,其中RDB是Redis的数据快照,本文想分析为什么Redis在快照和持久化数据时需要使用子进程,而不是直接将数据结构导出到内存中到磁盘进行存储。概述在详细分析今天的问题之前,我们首先需要了解Redis的持久化存储机制RDB是什么。RDB会每隔一段时间对Redis服务中的当前数据集进行一次快照,除了Redis的配置文件可以进行快照设置之外,Redis客户端还提供了两条生成RDB存储文件的命令,即保存和BGSAVE。我们可以通过命令的名称来猜测这两个命令的区别。其中,SAVE命令在执行时会直接阻塞当前线程。由于Redis是单线程的,SAVE命令会直接阻塞客户端的所有其他请求。这对于需要提供强可用性保证的Redis服务来说往往是不可能的。公认。我们经常需要BGSAVE命令在后台生成所有Redis数据对应的RDB文件。当我们使用BGSAVE命令时,Redis会立即fork一个子进程,子进程会执行“将内存中的数据以RDB格式保存到磁盘”这个过程,而Redis服务在BGSAVE工作期间仍然可以处理来自客户端的请求。rdbSaveBackground是一个用来后台保存数据到磁盘的函数:intrdbSaveBackground(char*filename,rdbSaveInfo*rsi){pid_tchildpid;if(hasActiveChildProcess())returnC_ERR;...if((childpid=redisFork())==0){intretval;/*Child*/redisSetProcTitle("redis-rdb-bgsave");retval=rdbSave(filename,rsi);if(retval==C_OK){sendChildCOWInfo(CHILD_INFO_TYPE_RDB,"RDB");}exitFromChild((retval==C_OK)?0:1);}else{/*Parent*/...}...}Redis服务器会在触发BGSAVE时调用redisFork函数创建子进程,并在子进程中调用rdbSaveprocess对于数据持久化,虽然我们这里省略了函数中的一些内容,但是整体结构还是很清晰的。感兴趣的读者可以点击上面的链接了解整个功能的实现。使用fork的目的最终肯定是在不阻塞主进程的情况下提高Redis服务的可用性,但是在这里我们其实可以发现两个问题:为什么fork之后的子进程可以获取到父进程内存中的数据?fork函数是否会带来额外的性能开销,我们如何避免这些开销?既然Redis选择使用fork的方式来解决快照持久化的问题,那么也就意味着这两个问题已经有了答案。首先,fork之后的子进程可以获得父进程内存中的数据,fork带来的数据与阻塞主线程相比,额外的性能开销必须是可以接受的。只有同时满足这两点,Redis才会最终选择这样的方案。设计为了分析上一节提出的两个问题,这里我们需要了解以下内容,这是Redis服务器使用fork功能的前提,也是最终促使它选择这个实现的关键method:由fork生成,父子进程会共享包括内存空间在内的资源;fork函数不会带来显着的性能开销,特别是对于大量的内存拷贝,可以使用copy-on-write将拷贝内存的工作推迟到真正需要的时候;子进程在计算机编程领域,特别是在Unix和类Unix系统中,fork是进程用来创建自己的副本的操作。它往往是操作系统内核实现的系统调用,也是操作系统在*nix中实现的系统调用,是在系统中创建新进程的主要方法。程序调用fork方法后,我们可以通过fork的返回值来判断父子进程,进行不同的操作:fork函数返回0时,表示当前进程是子进程;当fork函数返回非零时,表示当前进程为父进程,返回值为子进程的pid;intmain(){if(fork()==0){//childprocess}else{//parentprocess}}在fork手册中,我们会发现调用fork后的父子进程会运行在不同的内存空间。当发生fork时,这两个内存空间的内容是完全一样的。内存的写入和修改与文件的映射是独立的,两个过程不会相互影响。子进程和父进程运行在不同的内存空间。在fork()时,两个内存空间具有相同的内容。其中一个进程执行的内存写入、文件映射(mmap(2))和取消映射(munmap(2))不会影响其他进程。另外,子进程几乎是父进程的一个完整副本(Exactduplicate),但是,这两个进程在以下几个方面会有细微差别:子进程用于独立性和唯一的进程ID;子进程的父进程ID与父进程ID完全一致;子进程不会继承父进程的内存锁;子进程将重置进程资源利用率和CPU计时器;...最关键的一点是fork时父子进程的内存是完全一样的,fork后的写入和修改不会互相影响。这其实完美解决了快照场景的问题——只需要下载内存,而父进程可以继续修改自己的内存,既不会被阻塞,也不会影响生成的快照。Copy-on-write由于父进程和子进程拥有完全相同的内存空间并且两者对内存的写入不会相互影响,是不是意味着子进程需要对内存进行全量拷贝父进程什么时候fork?假设子进程需要拷贝父进程的内存,这对Redis服务来说基本上是灾难性的,尤其是以下两种场景:内存中存放了大量数据,fork时拷贝内存空间会消耗很多程序的时间和资源会导致程序在一段时间内不可用;Redis占用10G内存,物理机或者虚拟机的资源限制只有16G。这时候我们不能持久化Redis中的数据,也就是说Redis对机器上内存资源的最大使用率不能超过50%;如果不能解决以上两个问题,那么使用fork生成内存镜像的方法就无法真正实现,也不是真正可以在项目中使用的方法。即使没有Redis的场景,fork时复制全量内存也是不能接受的。假设我们需要在命令行中执行一个命令。我们需要通过fork创建一个新的进程,然后通过exec执行程序。fork复制的大量内存空间对于子进程来说可能完全没有用,但是却引入了巨大的开销。写时复制(Copy-on-Write)的出现就是为了解决这个问题。正如我们在本节开头所介绍的,copy-on-write的主要作用是将复制推迟到写操作真正发生时,这也是避免了很多无意义的复制操作。在一些早期的*nix系统上,系统调用fork确实会立即复制父进程的内存空间,但在今天的大多数系统中,fork并不会立即触发这个过程:当调用fork函数时,父进程子进程和子进程会被Kernel分配到不同的虚拟内存空间,所以在两个进程看来访问的是不同的内存:当实际访问虚拟内存空间时,Kernel会将虚拟内存映射到物理内存,所以父子进程共享物理内存空间;当父进程或子进程修改共享内存时,共享内存会以页为单位进行复制,父进程会保留原来的物理空间,子进程会使用复制后的新物理空间;在Redis服务中,子进程只会读取共享内存中的数据,不会进行任何写操作,只有父进程在写的时候才会触发这个机制,而对于大多数Redis服务或者数据库来说,写请求是往往比读请求小很多,所以使用fork加上写时复制机制可以带来非常好的性能,也使得BGSAVE的实现变得非常简单。综上所述,Redis实现后台快照的方式非常巧妙。这个功能很容易通过操作系统提供的fork和copy-on-write特性来实现。由此可见,作者对操作系统知识的掌握是扎实的。当人们面对类似的场景时,可能会想到手动实现类似“copy-on-write”的特性。但是,这样不仅增加了工作量,也增加了程序出问题的可能性。至此,简单总结一下Redis在使用RDB做快照时为什么要通过子进程来实现:fork创建的子进程可以获得和父进程完全一样的内存空间,父进程对内存的修改会影响子进程是不可见的,两者不会相互影响;通过fork创建子进程时,不会立即触发大量的内存拷贝,而当修改内存时,会以页为单位进行拷贝,也避免了大量的内存拷贝带出以上两个原因,一个是为子进程访问父进程提供了支持,另一个是为减少额外的开销提供了支持,两者缺一不可,共同成为Redis使用子进程实现快照持久化的原因。最后,我们来看一些开放式的相关问题。有兴趣的读者可以仔细思考以下问题:Nginx的主进程在运行时会fork出一组子进程,这些子进程可以单独处理请求,还有哪些服务会使用这个特性?Copy-on-write其实是一种比较常见的机制。它在Redis之外的其他地方使用?如果您对文章内容有任何疑问,或者想更多地了解软件工程中一些设计决策背后的原因,可以在博客下留言,作者会及时回复与本文相关的问题并选择合适的话题作为后续内容。参考RedisPersistenceUnderstandingRedisBackgroundMemoryUsageFAQRedisCopy-on-writerdbSaveBackgroundRedisFork(systemcall)内核中的哪个文件指定fork(),vfork()…使用sys_clone()系统调用试图理解fork()和Copy写时(COW)
