【IT168 技术】本文根据丁俊老师于第十届中国系统架构师大会(SACC 2018)的现场演讲《JD K-V存储产品演化之路》内容整理而成。
讲师介绍:
丁俊,京东商城在线存储部负责人,主要负责分布式存储系统、分布式消息系统、分布式服务框架等产品的开发和维护。
正文:
大家好,我今天讲的题目是京东KV存储产品的演进之路。我不会具体讲每一个产品是怎么做的,而是重点去讲我们在开发中碰到的一些问题,以及我们是怎么解决的。当然这些问题的解决方法不一定最优,也不一定完全适合大家。大家可以共同探讨,作为一种解决思路。
我今天主要讲三个部分,一个部分是内存存储(jimdb),另一块是持久化存储(sharkstore),还有一块就是我们目前想要做的混合存储。
内存针对的是一个高吞吐、低延迟的场景;持续化存储更强调的是可靠性和容量上面。我们后面想做一些混合存储,主要是因为在业务发展过程中有很多数据慢慢从内存里面沉淀下来,可能不常使用,而因为业务、成本等各方面的考虑,没有将其从内存中挪出来,我们就想能不能通过平台统一把这些事情处理一下。
下面我大概介绍一下我们各个产品的时间节点。2014年我们内存存储产品的第一个版本上线,当时是公司内部有各种各样的一些开源产品的使用,每个小组也有自己的维护系统,包括开发一些管理界面,维护工具等等,公司决定把它统一起来,由一个独立的团队去提供这样一个产品。
2014年上线以后,业务量增长比较大,在2015年的时候,我们就面临了一些新的挑战。随着业务数据的增长,比如在内存里面,有一些业务数据可能今年的增长量是前面所有年份的总和,甚至还要多。2015年数据的扩容,包括我们服务的产品越来越多,搭建的集群越来越多,它的故障恢复能力都给我们带来了一些挑战。第二个版本重点解决这些问题。
2017年,我们有了异地的机房,就需要支持中间件的异地多活。现在的这个产品,我们也做了异地多活的解决方案。2018年内存方面的大部分事情,主要的矛盾解决差不多了,我们就做了持久化的存储,去解决一些成本上的问题,包括和一些厂商去探索新的存储,比如这个介质是不是可以节省一些内存,从降低成本上去进行一个考量。2018年当我们的存储系统上线以后,就在这个基础上,为一些业务提供了分布式锁,包括配置分发的功能。再后面就是我们正在研发中的一个事情,希望能够把混合存储提上去。
内存存储的实践与挑战
现在讲第一部分,内存存储。这里先讲运营数据,当然不是为了说明这个系统有多复杂,其实系统不是特别复杂,主要是我觉得一个系统碰到的主要问题和解决问题的思路,可能是随着系统的数据量访问压力来的,所以说大家在挑选方案的时候,还是要结合自己的业务特点,从成本、实现的难度,维护人员的数量、紧急度等等一些方面去考量。平常我们整个平台大概每秒钟可能有上亿次的访问,内存的进程数大概在10万级以上。
下图是我们内存存储的架构图。
该架构的主要特点包括:高吞吐、低延迟,能够自动故障恢复,可以在线伸缩,能够进行广域复制等等。
下面我主要讲一下故障检测。说到故障检测,大家在部署的系统当中其实都会面临这个问题,都需要容灾,对硬件、网络的故障等进行检测,让你的业务避免受到这些故障的影响,提升可用率。我们在做故障检测的时候,如果说我本来就没几个实例,可能就是部署一个哨兵,或者有一些别的方案去探测。检测一个服务是否存活,可能常用这几种方法:一种是主动探测,另一块就是由本身提供服务的人,去上报它的状态是不是好的。
如果故障检测没有做好,会有什么问题?其实如果说你是一个没有状态的服务,探测错了也不会引起太多的问题,最多可能就是导致性能的偶尔波动。作为一个有状态的服务,如果检测错了,很可能导致的后果就是,可能你的数据会写丢。比如说你检测到一个master,它是一个写入点,你认为它是故障的,有一部分节点还在往这个节点上写入,另一部分节点认为它故障以后,可能会选出一个新的写入点。如果你的业务同时往两个写入点进行写入,势必就会导致数据丢失。
导致这种检测误判的主要原因有哪些呢?目前我们主要面临的一些问题,一个就是网络的分割——当然如果部署的节点比较少,本身可能就这一个网络、一个机架里面或者一台交换机下面,这种故障的可能性会小一些。但是当你的服务部署在整个机房的各个角落,包括甚至可能在同城的多个机房里面,这种网络的故障出现的概率就会大很多。另外其实我们还碰到一个问题,就是一个长任务的执行导致系统阻塞,探测的时候,可能会探测到它不是存活的,因为它没有在一定的时间内给你一个响应。比如说你认为多少秒以后它就“死亡”了,如果说任务执行的时间超过这个时间,你也可能会认为它是死亡的。
大家可以认为一个长期阻塞的任务是“死的”,当然也可以认为它是“活的”——因为本身进程还在,这个要看你的业务特点。但是我们认为,这种场景尽量不要把它“判死”,为什么?我们发现往往你把它“判死”、“杀掉”、在别的地方恢复起来以后,业务往往还会执行相同的事情。相当于不停地“杀掉”-起来、“杀掉”-起来,进入这样一个恶性循环中。
对于这些问题,我们主要有这几种解决办法。第一个,我们把探测点部署在机房的各个角落里,分布在不同的机架和交换机下面,他们组成一组探测服务,共同去投票决定这个服务是“死亡”的、还是存活状态。这是一点,解决网络的问题。第二就是,对于任务阻塞的情况,我们是在服务器上面部署一个agent,因为本身服务器上有agent去做一些指标采集等,也会做一个判断进程是否存在的服务,结合这两点去防止误判。
下面说一下故障的自动恢复。恢复其实很简单,就是你探测到它不存活以后,可能通过一系列的手段,比如说slave“死了”,副本“死掉”以后,再加一个副本就可以了。如果说你的写入节点master“死掉”了,从现有的副本里面、slave上面去提升一个,提升为master就可以了。我觉得在做业务系统的时候,为了提升使用率不一定要完全去依赖这种探测的服务,你还应该有一些别的手段,在客户端可以做一些容错的策略,比如说如果你是读写分离的,那能不能够在slave“死掉”以后自动去读一下master,恢复以后再去读slave。比如说,如果我在同城有别的机房,有两个对等的集群,那我能不能在这一个集群访问不了的时候,业务客户端去访问另一个集群。在这种规模比较小的时候,可能大家实际开发成本都是相对比较低的。
还有一个问题就是,是不是所有的故障都一定要去恢复。这个场景其实不太经常碰到,但是如果碰到了,确实需要注意一下。因为我这么多年做这种服务,偶尔也有碰到过那么一两次。就是说我发现一个大面积的故障,比如探测到某一个机房断电了,那要不要去做故障恢复?也许这种场景下去做故障恢复带来的后果可能更大,可能你还没有完全把数据都自动恢复好的时候,机房可能已经帮你把电也已经通上了。而在自动恢复时,会占用大量内部网络的流量,对现有业务产生影响。所以在这里我主要想提一句,有一些场景可能需要你去做一个系统的决策,做一个智能的判断,但有时也不一定要做的这么复杂,如果系统简单,那做一个开关就好了。
下面说一下迁移的过程。当系统的数据量越来越大时,原来分配的空间可能不够了,需要扩容,系统需要升级,这时你也需要做数据迁移,因为数据在内存里面,没有办法进行原地升级。我们知道在数据迁移中,通常第一步可能就做一个快照,做完快照以后再补增量。在这里要提一句,就是说我们在补增量当中,实现完第一版以后,发现系统有时会卡顿一段时间。因为首先每个业务写入的流量大小不一样,有的业务可能刚好碰到写入量比较大的那段时间,你会发现增量的数据比较多,这个时候就可能阻塞住。
在线迁移流程图
为了保证前后、新旧服务的数据一致性,你可能需要把老的写入给停了。后面发现一个业务特点就是,读比较多一些。我们可以把读给放开,让它在迁移的过程当中,只阻止写——也就是变化的地方。
下面说一下内存存储的广域复制的问题。我可能在华东、华北有两个机房,我的服务部署在华北,有一个master,也有一个slave。现在我可能需要在华东也提供一个服务,常用的就是把一个数据复制过去就可以了。这里有一个前提,因为它是在内存里面,所有的数据都在内存里面,我能不能够直接在华东挂一个副本到我的master去。
个人认为在业务量比较小、数据比较少的时候,这种方案是可以的。但是大家都知道,广域网络上面可能网络延迟比较高,网络的质量比较差一些。这就带来一个问题,可能会有中断。那我要缓存的、要同步的数据,可能在内存里面会积攒比较长的一段。这样,当我数据量比较少的时候,能不能把缓冲区直接调大,就可以缓存更多的增量数据,避免这种存量。不过平台、服务的集群很多的时候,如果把每一个实例内存都调大,可能浪费的资源就比较大了。
我们采用的方案如下图,在华北新建一个同步的模块去模拟slave,把master的数据同步下来,保存在本地的机房,再把数据发送给华东的集群。这里我们会有两个master,意味着就可以接受两个地方写入,目前我们线上也有这样的一些服务在跑。比如,大部分是华北作为一个主要的集群进行写入,华东是不接受写的,只有读,但也有一些业务可能需要在两地同时多写,我们也提供在华东接受写入。
这里可能会面临一个问题,就是华北写入的key复制到了华东,如果华东也要往回复制的话,同一个key是不是就在这里“转圈”?其实我们在key里面打了一个标,它从哪个地方写入就打哪里的标,当sync服务在复制这些数据的时候,会跳过这些key,这样就实现了两个集群之间的相互复制。当然这里也有一个问题是没有解决的,需要业务去解决,比如说华东和华北同时写同一个key,这可能就面临一些问题。第一,复制是延迟的,第二就是一致性等问题。我们这里其实没有去解决,还是靠上层的业务去规避。就是说在华东写入的key不会在华北写入,华北写入的key不会在华东写入。或者业务上能够接受这种混写,就是说以谁为准都可以。
持久化存储的实践与挑战
下面讲一下持久化存储。其实持久化的KV存储其实有很多开源的实现,大家的一些实现思路也都大同小异。我们也一样,选用了一些常用的开源组件,在这基础之上进行开发。自己开发这个系统的原因,一方面是为了更好地和内存存储的API等等兼容,还有一些自己的业务特性在里面。
持久化存储的特点包括:1、分布式强一致;2、支持在线分裂、自动故障恢复;3、支持schema,海量数据;4、支持范围查询,单表操作。
下图是持久化存储的逻辑视图,其实KV的存储里面,大家的key和value不一定是列的,那我们选用这样一种方案,一方面为了方便,比如说后面我们要兼容MySQL的协议,还有就是从我们自己的业务特点来看,目前这种跟MySQL一样的这种结构可能对我们来说已经能够满足需求。相对来讲,我觉得这种方案可能还有一定的灵活性,也有一定的约束,比较折中一点。然后key是可以由多列去组成的。
下图是持久化存储的结构图,其实这种持久化存储,包括对象存储等等,大家的结构可能都差不多,有一个接入层,有一个master去管理元数据,有保存数据的地方。
这里可能需要介绍一下,我们的data servers是基于rocksdb去实现的。其实大家上网去查这种持久化存储,可能首屈一指的就是rocksdb,它各方面性能,写、读都非常优秀,所以我们也选择了这么一种方案。但后面我们在一个场景上测试的时候就碰到了一个问题,主要是rocksdb compact带来的影响。这里我简单介绍一下compact产生的原因,对于rocksdb来讲,它就是一个基于日志结构的合并树。假设我的左手边是一棵无序写入的、按写入顺序进行保存的树,而我的右手边是一颗有序的树,然后不停地把无序写入的数据往右手这边的有序树上去合并。一个是为了保证读的性能,另外一块就是说它有一个特点,比如我的删除,其实在无序的树里面去写入一个Key,做一个标记它是删除的,并没有真正在我的右手边有序的树里面去删除,只是打了个标,然后通过后台的GC把这些数据给清理掉。基于这两点,它需要不停地把无序的树和有序的树进行一个文件的合并,合并的动作就是从无序的树里面挑一个文件,看Key的分布,在有序的树里面也挑选相同或者一定跨度范围内的Key的一个文件进行合并。
另一块就是说因为每一层数据不能够保证Key写得太大,它会一层一层往下写,下面的虽然说每一层不是有序的,为了查找的效率,包括GC掉一些删除的Key等等,它会往下进行合并,每一层也会合并。这里面其实大家就看到一个问题,比如说我有一个Key,从写入以后就再也没有改变过,也没有删除,在这个过程当中,可能我就会被来回搬运很多次,被写入很多次,这就导致了一个写的放大。大概就是这么一个流程。
我们会发现在一段时间内,当我一个rocksdb的进程、数据量在100多近200G左右的时候,它其实性能还维持的比较好(这里提一下,我们使用的是基于NVMe的SSD)。当它超过一定的量以后,你会发现写入的性能就有一些“尖刺”了,有的可能就直接掉到0。作为一个在线服务来讲,这种问题是我们无法接受的。举个例子,大家作为消费者,可能都不希望在大促销、“秒杀”的那一刻,服务如果出现延迟,那么可能促销的时间点已经过去了。当然如果是一个离线服务,我觉得是可以接受这种短暂波动的。
下面是系统长时间跑的一个图,大家可以看到,越到后面波动就越大。
针对这个问题,我们目前采用了一个Key-value分离的方案。我们是基于rocksdb里面blobdb功能的完善和改造。这里简单介绍一下Key-value分离是怎么实现的,就是说我有一个Key写进来以后,还是和原来一样保存到rocksdb这一套结构里面,同时我把我的value写在一个顺序追加的文件当中,然后把这个顺序追加的文件的位置,比如文件编号、基于这个文件的偏移量等等,把它写在一个索引里面,把索引信息和Key保存在一起,索引信息作为一个原来Key对应的value保存起来,相当于中间加了一层。这样做的一个好处就是说我在做一个有序整理的时候,就是排序合并的时候,不用先去搬弄我的value。
其实这里面也隐藏了一个问题,就是说它并没有解决所有的场景,可能只对Key-value比例比较大的场景比较合适。比如说key可能就几十个字节,而value可能上千或更大,这种场景其实是非常合适的。如果value本身就比较小,可能几十个字节,和key差不多,还多出个索引来,其实这种场景也是解决不了的。
然后,如果说你的比例能达到一比几十的话,你一块盘几个T的容量,内存里面保留几百G的key的数据,也许就能够很好的去解决这个问题。包括你这对一台物理机进行多个实例的部署,也可以缓解这些问题。当然业界也有一些别的探讨,比如说结合SSD磁盘的一些特性,SSD磁盘本身也会在后台做一个GC,也许就可以和rocksdb的GC合并起来。
下面就是我们改动以后业务测试的一个性能表现,相对来讲就比原来平滑很多。
讲完存储那一块碰到的问题以后,下面就说一下raft成员变更。因为我们的数据,包括云数据、业务数据和元数据都是基于raft复制的,它可以在线扩容,也可以进行一些故障恢复,势必就会涉及raft成员的变更。当你在做数据迁移负载均衡的时候,成员就需要变更。比如说一个磁盘快满了,我需要把一个副本从A机器搬到B机器去,我们的做法就是在B机器上面加一个节点,加上以后再把A机器上的副本删掉。
这里面就碰到一个问题,如果这个时候刚好A机器有故障,你就会发现raft就没办法正常工作了,因为它现在的成员是四个,新加入的成员和有故障A镜像的副本同时不能工作,这个时候其实你是有两个节点是坏的,两个节点是好的,它没有办法保证大多数的成功。
大家可能就问,那我能不能先把A机器上的节点删了,让我的成员从三个变成两个,然后再把一个别的节点加进来,是不是就能解决这个问题?其实在一定程度上是能缓解这个问题,但这里面可能也会碰到新的问题,就是说当你刚好把A镜像的副本给删掉以后,三个节点删了一个还有两个,如果再有个节点出现故障的话,你整个数据可能都没有办法自己去恢复了,你就需要强行去干预它,这可能就涉及数据的一个安全。
那么我们现有的方案是怎么解决这个问题的?其实我们大概思路就是,首先让新增的raft成员只复制数据,不参与到投票里面,同时也不会发起Leader选举,让它作为一个“学习节点”。当这一个新增的节点复制完数据以后,我的Leader会知道它复制到哪了,就认为它已经跟上进度了,再把它提交到成员里面去,同时把另一个节点删掉,这样就能够保证这个工作比较顺利地进行。如果这个时候即使有一个新的节点有故障的话,也能保证有两个节点在。
这里提到其实它有个特点,就是新增一个成员的时候,是会重新发起Leader选举的。还有一个就是,如果我这个节点(follower)数据落后了很多,断开网络以后重新加进来,也会发起一次Leader的选举。那Leader的这种选举、切换,其实是需要时间的,对性能会有干扰。
当然raft的作者也有提到怎么解决这些问题。就是新增的节点加进raft组以后,先询问一下,跟现实中的选举一样,你在正式选举之前,可能需要去各个社区拜个票,后面真的选举了,让人家选你。它也一样,就是说加进来以后,先不发起Leader选举,而是先去拜个票,跟每一个成员沟通一下,能不能给我一个机会当Leader。如果大多数的人反馈你可以,那就进行选举。如果说现有的Leader挺好的,我不能让你当,或者是说你的数据落后于我,你不会成为Leader,那就没必要再发起leader选举了。作者提出了Pre-Candidate算法,在发起之前就先进行一次预选举。如果预选举时能得到大多数的投票,再增加term,进行正常的选举。这样就是大大的降低了因为一些网络,包括数据的迁移平衡等因素导致的性能波动。
前面有提到,我们在持久化化存储的基础之上实现了分布式锁和配置服务。企业当中或多或少都有这种需求,最初如果说Redis/Memcached没有升级之前,可能更多基于数据库去做;或者说我的场景比较简单,就基于数据库去做。我在做一个任务的时候,先把这个任务打个标,在数据库里面标识这个任务是分配状态或执行状态。你做完任务后,你可能就去把它解锁,把任务标识为执行完成,打上标的数据不会被别的服务抢到。
这里其实可能就带来一个问题,假设打上标以后你的服务就挂了,谁来解?可能你会引入比如超时时间,一个定时的服务,去检测这些长时间在执行中的任务,将其解锁,让它重新可以交给别人去执行。另一种方案就是基于Redis/Memcached这种服务,如果你只部署了一个节点,我们知道进入Redis/Memcached都是异步的复制,如果你的锁服务加上以后刚好碰到了你的master“死”掉了,这把锁是不是意味着就丢失了?redis作者提出了redlock算法,通过去多个redis里面同时写个锁,类似于这种分布式的协商一样。这里面其实有一个问题是什么?如果说写入以后,你的服务“死”了,可能你就需要根据你的业务给它设一个超时时间。对大部分的业务来讲,其实我觉得是能满足的。当然一些比较严格的服务里面,可能会面临一个问题,就是我们这个时间设多长合适?如果你设的时间不是很合适,可能到这个时间以后,服务还没有执行完就过期了。
分布式锁流程
另外一种就是基于Etcd/zk的方案,它有心跳机制。这里面有一个场景,就是网络中断以后,临时节点不存在了,但是服务本身还在跑,那么它要不要中断?因为你没有终止的话,还是有可能发生重复执行的现象。这里我们提供了一些业务可选的方案,根据场景去配置。比如,我们可以是合理的超时时间+心跳机制,让这个时间可以续期,如果服务没有“死”,还在执行,就可以加时间;还有一个就是分布安全超时时间+报警检测,设比较长的时间,我发现一段时间以后没有执行完,就给那个业务去报警;还有一种就是心跳过期+回调,是保证你的任务是能够取消掉的。
然后是配置服务,基于刚才讲的,我们KV存储是多range的,一台服务器、一个进程有多个range服务,意味着它是多点的写入。在range分裂的时候,树型结构的深度是有限制的,防止分离时group分组被割裂。
混合存储的实践与挑战
下面给讲一下混合存储。我们希望能做到冷、温、热的三层存储,冷数据保存在持久化急群众。其中冷和温数据在同一个进程里面去解决,这可能就涉及到一个Key的淘汰问题,将不常访问的数据淘汰到磁盘。淘汰有很多种算法,比如LRU、HIT DENSITY等等,这时又面临一个问题,Key要不要从内存里删除?其实内存本身就是需要低延迟的场景,这里面又关联到第二个问题,如果Key删掉以后,在访问Key的时候需要去磁盘访问,这样会不会阻塞掉内存访问的其他客户端的请求,拖慢整体性能?还有一个问题就是,内存里面可能有一些大的数据结构,要不要把它淘汰到磁盘上去?淘汰以后可能就意味着一些功能的阉割,作为一个平台,可能有些功能没有办法阉割的,需要进行一个取舍。
我们目前的做法是,作为一个冷数据,是采用异步去访问的(如下图)。比如说我有A、B、C三个客户端,A客户端访问到的是一个冷数据,key保存在内存里打个标,然后它需要访问磁盘,我们暂时把这个请求再丢给一个队列,把这一个CPU线程给释放出来,去处理别的客户端的请求。如果在我们以前其实没有按这种方式,就会导致就是说我访问磁盘,比如说要50毫秒,可能整个后面的任务都被阻塞50毫秒。
下图这是一个冷热分离的访问流程,没有太复杂的事情。
还有一个就是混合存储,如下,这一块我们还在实践当中。
今天我大概就讲了这三部分的内容,内存存储、持久化存储和混合存储,希望能够给大家带来一些共鸣,谢谢。