解密京东大规模内存数据库的演进之路

丁俊 2017-01-16 10:23:00

京东商城研发体系基础平台团队:包括大规模容器集群调度、数据库与存储技术、消息系统与服务框架、架构与运维、机器学习与人工智能等技术方向。由京东商城首席架构师刘海锋担任部门负责人。基础平台运营多个数据中心数万台服务器,支撑京东无数在线业务。

 

缓存的大背景

 

缓存在软件应用特别是在互联网应用中无处不在,从数据库到应用服务、再到前端的页面每一层都会使用缓存进行加速,即使是硬件产品比如CPU、磁盘、网卡等也都会有相应的缓存或缓冲区。

 

当一个网页被打开时,为了提供良好的用户体验,提高用户购买的转化率,往往一个纯静态的页面已无法满足业务的需要,后台会有几十上百个服务为这个页面提供动态的个性化的数据。比如根据用户过往的购买记录和上网的浏览信息帮他推荐感兴趣的商品,告诉用户这些商品购买比例如何,好评度怎么样,什么时间段可以送货到家,这个商品有没有促销,能不能用券,如果缺货需要提醒用户这个商品当前是预定状态,还有很多就不一一列举,这么多的服务需要调用,而且要在每秒成千上万次请求的情况下,毫秒级地将结果展示在用户的显示屏中,除了良好的架构方案,缓存的极致使用是必不可少的。

 

前期有些业务团队使用Memcached服务,固定部署几个实例进程,客户端通过hash算法把数据切分几份,由于业务量相对小一些,能应付当时的业务场景。服务端也没做主从热备或者自动故障恢复等方案,服务挂了让运维上去重启一下。当数据内容增多了需要扩容,就找一个业务访问量低的时间段,暂停一小会服务把数据整体重新分布一下,有的小组会写一个迁移工具,有的直接从数据库加载数据重新推送到Memcached中。

 

后来Redis慢慢流行起来,相比Memcached,支持丰富的数据结构,业务使用起来更加方便,大家也就慢慢地把服务从Memecache迁移到了Redis上。但是在用法上并没有什么改变。大家还是按照以前的思路,在客户端做一些封装,服务端地址直接写死在客户端应用的配置文件中,服务端地址改变了就重启一下客户端程序。

 

缓存的规模越来越大,线上故障变得频繁起来,同时客户端实例数也在增加。

 

想象一下,半夜有一台缓存服务器发生硬件故障,负责业务的同学第一时间会收到业务异常或者性能等方面的报警,然后通知运维进行问题排查和确认,确定故障机器后把缓存服务更换到另一台机器上,由于运维可能不太熟悉业务应用的配置文件,又需要让业务人员登陆VPN修改客户端程序的配置文件,再对客户端服务全部重启一遍。整个过程下来短则十多分钟,长的可能需要半个小时或者更久。还得庆幸大家半夜头脑清醒,VPN网络通畅,配置文件没有修改出新的问题。

 

因此,一个具有以下能力的缓存平台对业务来说是非常需要的:能够自动进行故障恢复、可以在线扩容、自动负载均衡等,而且这些过程完全可以在客户端无感知的情况下完成。

 

JIMDB登场

 

1、现有系统的整体架构

 

JIMDB的特性:

 

  • 一键创建集群实例;

  • 在线全自动弹性伸缩;

  • 部分复制扩容,缩短扩容时间;

  • 在线平滑升级;

  • 全自动故障恢复;

  • 支持多语言接入;

  • 支持多种读取策略;

  • 容器化部署,Docker镜像管理;

  • 增量复制。

 

JIMDB架构:

 

用户在使用JIMDB平台时,先在管理端创建一个集群,然后内嵌JIMDB提供的SDK,或者通过支持Redis协议的客户端接入代理层(Access Points)。客户端应用启动后JIMDB的SDK会有后台同步配置信息的线程从配置管理模块(config server)上拉取集群的拓扑信息,业务访问某个KEY时,按照指定的路由算法访问拓扑中的某个节点。当服务端发生扩容或故障切换后,配置管理模块会更新拓扑信息,同时通知客户端更新拓扑信息。

 

JIMDB平台主要包括:web console\config server\scaler\failover\info collector\sentinel\resource manager\ap等模块。

 

  • server:服务端提供KV服务,支持一主多从,读写分离。一般主从之间为异步复制,但是服务端提供了等待从复制完成的命令支持。扩容分裂时以slot为单位进行部分数据迁移。

  • Config server:配置中心负责集群拓扑的维护,拓扑变更后负责通知客户端及时变更拓扑。

  • Sentinel:哨兵用于判断服务端实例存活状态,同机房多实例跨机架部署,避免网络分割。

  • Failover:负责角色切换和故障实例的替换。

  • Scaler:当内存容量或者流量等达到阈值时对分片进行分裂扩容。

  • Info Collector:负责监控数据的采集。

  • ResourceManager:负责物理机资源的管理和容器的创建。

 

第一版

 

JIMDB在这样的一个大背景下被提出来,在2014年初决定做一个缓存的平台,主要解决以下几个问题:

 

  1. 精确的故障检测和自动故障切换;

  2. 无损扩容;

  3. 提供监控和报警等服务。

 

为了满足扩容的需求,客户端采用了一致性哈希算法,预先在客户端对数据划分了一个比较大的slot集合,一段连续的slot对应一个shard,每个shard由一主一从或者一主多从组成,当主发生故障时可以让从提升为主,继续提供服务。

 

故障检测

 

在故障检测和故障切换的方案中,比较容易想到的方案就是引入Zookeeper,通过Zookeeper的临时节点探测不存活的服务,但是由于需要修改服务端代码、不方便跨机房部署、watch数目和连接数过多有性能问题等原因最终没有被采用。决定自己写一个探测程序,这个探测程序主要是检测JIMDB实例的存活状态,但是它需要尽可能地解决由于部分网络不通时导致误判的问题。

 

采用的方案就是,对探测程序部署多个,每个部署在机房的不同机架下。多个探测实例同时对同一个JIMDB实例进行探测,只要一个探测实例检测到服务端实例是存活的,那么该实例就认为是存活状态,当没有人反馈其为存活状态,且超过半数的探测实例认为该实例死亡时,则通知故障恢复程序进行主从切换,变更集群拓扑结构,并把新的拓扑结构通知给所有的客户端。故障检测和恢复的问题算是基本解决了,接下来就是需要解决扩容的问题。

 

无损扩容

 

业务在刚上线阶段,访问量和需要缓存的数据量并不大,提前申请很多的缓存服务会导致资源的浪费,随着业务的增长或者需要搞活动促销,访问量和数据变大,缓存服务端需要扩容。

 

按照一致性hash算法,需要让原来落在同一个shard上的一段slot区间进行分裂,变成两段区间,每段再各自对应一个shard。意味着这个shard上的数据有一部分需要迁移到新的实例上完成扩容。这些slot的信息在服务端并不存在,因此服务端也不知道哪些数据应该进行迁移,在扩容过程中还需要保证客户端能够正常访问。

 

当时采用的方案就是采用代理的方式,当需要对某个shard进行扩容时,先下发一个新的拓扑信息给客户端,让访问该shard的请求全部变更为访问代理服务。代理服务再把请求转发给该shard。当所有客户端都连接上代理程序后,扩容就可以开始了,数据同步程序通过复制协议把数据从原来的实例上把数据复制下来,再过滤出需要迁移的数据转发到新的实例上。当代理程序检测到数据复制快要完成后会挂起所有的请求,等待复制完成,然后按照新的slot分布信息把迁移的KEY请求转发到新的服务器上,这样扩容就完成了最主要的步骤。接下来只要把分裂后的拓扑信息下发给客户端,客户端会重新连接服务端,把连接代理端的链接断开,扩容就算完成了。

 

虽然上面的流程能够完成扩容过程,但是在扩容时需要从服务端拉取全部的数据进行过滤,然后再转发;当服务端数据比较大时,在当时千兆网卡下,为了不影响正常业务需要控制数据过滤迁移的速度,避免把原服务端的网络打满,复制时间会比较长;另外整个流程涉及到的模块比较多,代理层对客户端性能也有一些影响;扩容后有可能存在某些异常情况下,客户端的请求任然会访问老的分片,但是服务端由于没有slot信息,无法判断该KEY是否应该属于自己,导致数据混乱。

 

为了解决以上问题,开始对服务端进行改造,服务端引入slot的概念,数据按照slot进行组织,一个服务端实例服务端可以服务多个slot,客户端中维持一份与服务端一致的slot信息。由于服务端有了slot信息,因此可以判断哪个KEY是否在自己的服务范围内,还是已经迁移出去了,可以避免数据被写错。有了slot,迁移时服务端以slot为单位进行数据迁移,避免了从全部数据中找出需要迁移的数据,另外直接通过待扩容的服务端往新扩容的服务端写数据,避免数据需要通过过滤层的转发,也不需要多次对数据进行序列化和反序列化,大大提高了迁移速度。

 

第二版

 

随着集群规模进一步扩大,使用的业务越来越多,很多以前注意不到的问题逐渐暴露出来。

 

自动弹性调度

 

业务流量突然飙升,容量不足等都需要运维通过管理工具进行扩容增加实例数,另外也有一部分业务申请了集群空间,由于业务调整等原因,访问量变小了或者停用了,平台管理人员比较难发现。为了提高平台自动化的能力,减少运维人员的工作量,需要让平台动起来,弹性伸缩的需求摆在了开发人员的面前。

 

为了让平台弹性伸缩起来,需要对集群的各项指标进行监控,比如OPS、内存使用率、网络流量等进行监控,统计这些指标一段时间内是否达到了设置的阈值,当超过扩容的阈值自动触发扩容,当低于缩容的阈值时自动进行缩容释放资源。

 

缩容的过程和扩容的过程基本一致,扩容是把一个实例上的部分slot迁移到新的实例上,缩容是把一个shard实例上的所有slot迁移到另一个实例上进行合并。

 

扩容时由于需要增加实例,增加的实例应该部署在哪台机器上才合适呢,为了选择出最优的机器,有一个采集程序会定期进行信息收集,然后根据CPU繁忙情况、网络流量、OPS、内存剩余空间、机器上的实例数等进行综合打分,各项指标都比较空闲的得高分,如果有一项指标不符合部署要求则直接淘汰,然后再从得分高的机器中选择一台机器进行部署。由于扩容在集群中是并发进行的,因此有可能会被多个处理线程同时把实例部署到同一台物理机上,当大家部署完成后可能实例数等指标就不符合要求了,因此需要有一个预分配资源的计算,对未使用的资源进行预占并被计算在内,如果部署失败需要把这些资源值做相应的扣除,避免并发部署出现使用资源超限的情况。

 

对同一个集群还需要控制每台物理机上最大可部署的实例数,避免同一个物理机部署实例数过多,当机器故障时对同一个集群影响过大。

 

为了防止同一个机房路由器故障或者断电等情况的出现,同一个shard的主从实例应该跨机架,对有跨机房需求的应用,同一个shard的主从实例还应该部署在不同的机房。

 

服务端升级

 

当一个平台需要维护成千上万个进程,这些进程在升级过程中不能中断服务,而且这些进程还是有状态的,必须保证数据正确无丢失,不能通过简单的流量切换就能解决问题,升级程序成为了一项富有挑战的事情,同一台机器由于服务端升级需要同时运行多个版本,程序运行文件的分发和维护也变得不好管理,容易引起混淆,如果由运维人员手动处理,不但时间会变得非常漫长,而且容易出错。

 

为了解决版本控制和快速创建、销毁服务的问题,引入了Docker,通过Docker的registry管理服务端版本管理和分发。通过调用Docker Deamon进程的接口启动一个Docker容器创建一个JIMDB的服务端实例,销毁时也调用Docker Deamon程序销毁容器。

 

程序升级过程中涉及到数据的拷贝,为了加快升级速度,考虑到升级完成后旧的实例就可以销毁,因此在同一台物理机上创一个新版本的实例,当旧实例销毁后并不会导致物理机上实例数超限等问题,通过数据复制流程把旧实例上的slot全部迁移到新的实例上,由于数据在同一个物理机上流动,速度会比网络传输快很多。多个集群可以同时进行,也不会导致网络流量由于数据的迁移而暴涨。

 

资源隔离

 

不同的业务呈现出不同的业务特点,有的数据量不大但是并发量大,有的数据量比较大但是访问相对不频繁,有的业务写入并发量大读少,有的业务间歇性访问量大,有的持续性访问量大,业务之间的重要程度也不尽相同,对性能的要求也不同。

 

平台在部署集群时需要考虑这些因素,按照业务情况划分不同的资源分区,对性能要求高的,业务重要的集群,其部署的分区需要控制每台物理机部署的实例,单台物理机上的总实例数少一些。对重要程序相对低一些的集群,其部署的分区每台物理机可以多部署一些实例,实例数可以超过CPU的逻辑核数。管理端需要对物理机划分不同的分区,也需要给集群划分分区,在分区上可以设置单台物理机上最大可部署实例数,同一个集群部署在同一物理机上的最大分片数。

 

大KEY扫描

 

由于服务端单进程单线程处理请求的特点,不适合一次处理的数据量过大,占用CPU时间过长的请求,比如频繁读取以M为单位计算的数据量,或者一次从集合中取出成千上万条数据项,任之删除一个特别大的集合都可能导致服务端处理几十毫秒或几秒,从而导致其他客户端的请求被堵塞,TP99性能变差,对性能要求严格的业务直接会导致访问超时。

 

为了协助业务发现这些问题,除了定期地扫描slowlog外,还需要通过外部程序去扫描值比较大或者数据项比较多的集合,然后通过邮件等形式告诉业务负责人,这样业务可以及时地调整,避免在大促时流量增长后才发现问题,调整就已经来不及了。

 

读策略

 

当业务的读请求OPS非常高时,如果简单的通过分裂扩容,降低单个shard服务的slot数量,对资源会有一些浪费,在业务允许有一定写延迟的情况下可以通过添加多个从的方式扩展单个shard读服务的能力。另外对单个KEY读访问量比较大时扩容并不能解决问题,添加多个从,让客户端的访问压力分摊到多个实例上解决热KEY的问题。读取访问可以有多种策略,比如master优先,当master访问异常时再访问slave,也可以轮询多个slave,或者随机访问一个实例。

 

后续可能的工作

 

缓存系统主要应对高性能的场景,把数据都存放在内存中。但是随着业务的发展,系统访问量越来越大,业务系统有更高的诉求。性能高是一个硬性的指标,但是在高性能的前提下,还希望能保障数据高可用,在发生故障时数据不丢失;为了应对大的访问流量,一份数据可以分布在多个节点,节点之间是否能够同步写入没有延迟;当数据分散在不同的分片上,业务需要同时对这些数据进行修改,能否保证修改的一致性,降低业务设计的复杂度。

 

现在的业务系统使用缓存,一般都采用数据库做为数据的来源,然后再把数据直接或者做一定的处理后存放到缓存中,业务系统需要自己去保证数据库和缓存之间的一致性,是否可以把缓存和数据库结合在一起,业务应用不需要关系数据同步的问题。另外现在的缓存一般通过哈希算法进行散列,业务不方便进行范围扫描,如果需要有序的结果集就需要业务维护额外的数据结构或者自己处理。

 

随着硬件技术和材料学的发展,非易失的存储介质访问速度越来越快,不久的将来可能非易失的内存会应用到工业界,原来基于机械磁盘的软件架构和思维模式需要发生大的变化,通过硬件和软件的共同改进推出业务使用更快捷的产品。

 

现阶段,随着SSD硬盘的普及,分布式算法的完善,构建一个高性能的、分布式的、持久化存储的、支持分布式事物的K-V平台已不再是件十分困难的事情。

 

现有系统的完善和改进

 

完善弹性调度

 

当前平台实现了弹性的扩容和缩容,但是相对还比较简单。未来会继续完善,比如对集群的各项指标通过离线计算合理规划一个扩容的时段,而不是当流量已经很大了才去扩容。集群直接的扩容现在是独立控制的,可能出现同一个物理机上的实例在扩容,导致物理机流量大影响正常的业务,需要有程序把整个平台的扩容协调处理。

 

新特性:

 

  1. 虽然现在已经有很丰富的数据结构,但是还是有很多业务希望有更丰富的数据结构可以选择,未来可能会支持比如类似于数据库表一样的结构。

  2. 数据结构支持版本号,让业务控制并发修改更方便等。

  3. 支持hashtag,让业务可以控制KEY的分布情况。

 

丰富的监控和性能统计数据

 

现在平台的监控数据查看还不方便,也缺乏一些性能的指标,当用户反馈访问慢时不能快速地定位是网络问题,还是服务器繁忙,还是有热KEY等其他原因。监控数据的收集和展示需要朝着分析问题和解决问题的方向思考。

 

客户端增加本地缓存功能

 

现在热KEY主要还是通过业务自己进行本地缓存,有一些共同的逻辑其实可以通过平台去支持,另外有时热KEY只有在大促时才表现出来,但是业务程序没有做处理,大促时发现已经来不及解决了,如果平台可以通过管理端几个简单的操作快速启用客户端缓存,将可以缓解这一矛盾。

 

新客户端支持异步发送

 

有一些业务需要更高的发送性能,客户端可以支持异步发送,对发送结果异步回调通知给业务程序,将可以大大提高单客户端的发送性能。

 

大KEY的应急处理

 

当业务出现大KEY,严重阻塞其他命令的处理时,可以对单个大KEY进行迁移,保证其他的访问正常处理。

 

支持KEY按范围扫描

 

现在KEY都是按hash分布,有些业务场景需要KEY有序排列,但是单个实例又不能满足其存储或并发的性能,需要分布在多个实例上,但是业务需要这些KEY有序分布,方便进行范围扫描。

 

新产品的预研

 

移动互联网和物联网的发展,终端数量在快速膨胀,并发访问规模快速增长,对性能的要求也越来越高。现在关系型数据库普遍做法就是分库分表,历史数据再转移到HBase或者归档库等,业务在使用时要么自己处理分库分表的算法,要么接入一个中间代理层,当数据量增长需要扩容时不能很好地对业务进行隔离,需要业务配合一些做变更。

 

如果有一个高性能的、可以持久化存储,支持海量数据,能够灵活查询的,可以处理在线联机事物的半结构化的数据产品,将能够解放一部分业务系统开发的生产力。这个产品本身就能够自动进行shard,对业务系统透明,根据物理机的负载情况可以对shard进行调度均衡。数据有一定的结构,但是不强制要求,业务可以灵活使用,业务修改时,不需要再单独申请对线上数据库表的修改。现在的JIMDB系统的一些实践经验可以很好地帮忙我们开发这样一个新系统。

 

案例分享

 

单次取出集合中全部数据项

 

应用在日常运行过程中单个KEY对应的list列表并不长,开发人员采用“lrange key 0 -1”获取全部的数据项,运行平稳。有一天另外的同事对业务进行了调整,单个key中的数据项暴涨超过了几十万条。单个查询严重堵塞其他命令。

 

建议:如果业务能限制单个KEY对应的长度最好,如果不行需要限制单次取出的数据项数量,分批执行。如果数据项过多KEY过期或主动删除单个KEY也会导致命令堵塞。

 

单个VALUE过大

 

业务在开发时为了使用上简单,可能存放一个比较大的VLAUE,比如几兆。访问频率低时没有发现问题,但是当业务新上线一个程序,增加了对这个KEY的访问次数,结果相应接口的TP99从2毫秒上升到了几秒。

 

建议:业务拆分VALUE。

 

单KEY写操作与业务增长成线性或几何级增长需要警惕

 

统计访问流量,常用的方式就是访问一次调用一次自增。但是当访问量巨大时,导致单个KEY的访问性能根本达不到业务的要求。而且单个KEY写操作频繁无法通过扩容、增加slave等手段应急解决,只能业务修改代码(大促时只能眼看着服务被打死)。

 

建议:缓存操作是快,但是单个实例的服务能力也是有上限的。需要在业务设计时绕开,比如拆成对多个KEY的写,让流量分摊到多个shard上去,读的时候再汇总等。

 

热KEY本地没有做缓存

 

修改不频繁、调用频繁的KEY本地没有缓存,每次都通过远程获取,流量上去后TP99性能变差。

 

建议:对性能要求严苛的应用需要考虑在本地应用中进行缓存。

活动预告