京东架构师这样讲述:分布式数据库系统演进之路

438 阅读18分钟

关于数据库的使用,在京东有几个趋势,早期在京东主要用SqlServer及Oracle也有少量采用MySQL,随着业务发展技术积累及使用成本等因素,很多业务都开始使用MySQL,包括早期使用SqlServer及Oracle的很多核心业务也都渐渐的开始迁移到MySQL,单机的MySQL往往无法支撑这类业务,需要考分布式的解决方案,另外原本使用MySQL的业务随着数据量及访问量的增加也会遇到瓶颈最终也会考虑采用分布式解决的方案,整个京东发展趋势如图1所示。

图1 业务使用数据库演变趋势

分布式的数据库解决方案有很多种,在各个互联网公司使用得也是非常的普遍,本质上就是将数据拆开存储在多个节点上从而缓解单节点的压力,业务层面也可以根据业务特点自行进行拆分,如图2所示,假设有一张user表,以ID为拆分键,假设拆分成两份,最简单的就是奇数ID的数据落到一个存储节点上,偶数ID的数据落到另外一个存储节点上,实际部署示意图如图3所示。

除了业务层面做拆分,也可以考虑采用较为通用的一些解决方案,主要分为两类,一类是客户端解决方案,这种方案是在业务应用中引入特定的客户端包,通过该客户端包完成数据的拆分查询及结果汇总等操作,这种方案对业务有一定侵入性,随着业务应用实例部署的数量比较大,数据库端可能会面临连接数压力比较大的问题,另外版本升级也比较困难,优点是链路较短,从应用实例直接到数据库。

图2 数据拆分示意图

另一类是中间件的解决方案,这种方案是提供兼容数据库传输协议及语法规范的代理,业务在连接中间件的时候可以直接使用传统的JDBC等客户端,从而大大减轻了业务开发层面的负担,弊端是中间件的开发难度会比客户端方案稍微高一点,另外网络传输链路上多走了一段,理论上对性能略有影响,实际使用环境中这些系统都是在机房内网访问,这种网络上的影响完全可以忽略不计。

图3 系统部署示意图

根据上述分析,为了更好的支撑京东大量的大规模数据量的业务,我们开发了一套兼容MySQL协议的分布式数据库的中间件解决方案,我们称之为JProxy,这套方案经过了多次的演变最终完成并支撑了京东全集团的去Oracle/Sqlserver任务。

JProxy第一个版本如图4所示,每个JProxy都会有一个配置文件,我们会在配置文件中配置相应业务的库表拆分信息及路由信息, JProxy接收到SQL以后会对SQL进行解析再根据路由信息决定SQL是否需要重写及该发往哪些节点,等各节点结果返回以后再将结果汇总按照MySQL传输协议返回给应用。

结合上文的例子,当用户查询user这张表时假设SQL语句是select * from user where id = 1 or id = 2,当收到这条SQL以后,JProxy会将SQL拆分为select * from user where id=1 及select * from user where id = 2, 再分别把这两条sql语句发往后端的节点上,最后将两个节点上获取到的两条记录一并返回给应用。

这种方案在业务库表比较少的时候是可行的,随着业务的发展库表的数量可能会不断增加,尤其是针对去Oracle的业务在切换数据库的时候可能是一次切换几张表,下一次再切换另外几张表,这就要求经常修改配置文件。另外JProxy在部署的时候至少需要部署两份甚至多份,如图5所示,此时面临一个问题是如何保证所有的配置文件在不断修改的过程中是完全一致的。在早期运维过程中,我们靠人工修改完一份配置文件,再将相应的配置文件拷贝给其他的JProxy,确保JProxy配置文件内容一致,这个过程心智负担较重且容易出错。

图4 版本一

图5 配置文件

在之后的版本中我们引入了JManager模块,这个模块负责的工作是管理配置文件中的路由元信息,如图6所示。JProxy的路由元信息都是通过JManager来统一获取,我们只需要通过JManager往元数据库里添加修改路由元数据,操作完成以后通知各个JProxy动态加载路由信息就可以保证每个JProxy的路由信息是完全一致的,从而解决维护路由元信息一致性的痛点。

图6 版本二

在提到分布式数据库解决方案时一定会考虑的一个问题是扩容问题,扩容有两种方式,一种我们称之为re-sharding方案,简单的说就是一片拆两片,两片拆为四片,如图7所示,原本只有一个MySQL实例一个shard,之后拆分成shard1和shard2两个分片,之后再添加新的MySQL实例,将shard1拆分成shard11和shard12两个分片,将shard2拆分成shard21和shard22两个分片放到另外新加的MySQL实例上,这种扩容方式是最理想的,但具体实现的时候会略微麻烦一点,我们短期之内选择了另一种偏保守一点在合理预估前提下足以支撑业务发展的扩容模式,我们称之为pre-sharding方案,这种方案是预先拆分在一定时期内足够用的分片数,在前期数据量较少时这些分片可以放在一个或少量的几个MySQL实例上,等后期数据量增大以后可以往集群中加新的MySQL实例,将原本的分片迁移到新添加的MySQL实例上,如图8所示,我们在一开始就拆分成了shard1、shard2、shard3、shard4四个分片,这四个分片最初是在一个MySQL实例上,数据量增大以后我们可以添加新的MySQL实例,将shard3和shard4迁移新的MySQL实例上,整个集群分片数没有发生变化但是容量已经变成了原来的两倍。

图7 re-sharding方案

图8 pre-sharding方案

Pre-sharding方案相当于通过迁移完成达到扩容的目的,分片位置的变动涉及到数据的迁移验证及路由元数据的变更等一系列变动,所以我们引入了JTransfer系统,如图9所示。JTransfer可以做到在线无缝迁移,迁移扩容时只需提交一条迁移计划,指定将某个分片从哪个源实例迁移到哪个目标实例,可以指定在何时开始迁移任务,等到了时间点系统会自动开始做迁移。整个迁移过程中涉及到迁移基础全量数据和迁移过程中业务访问产生的增量数据,一开始会将基础全量数据从源实例中dump出来到目标实例恢复,确认数据正确以后开始追赶增量数据,当增量数据追赶到一定程度系统预估可以快速追赶结束时,我们会做一个短暂的锁定操作,从而确保将最后的增量全部追赶完成,这个锁定时间也是在提交迁移任务时可以指定的一个参数,比如最多只能锁定20s,如果因为此时访问量突然增大等原因最终剩余的增量没能在20s内追赶完成,整个迁移任务将会放弃,确保对线上访问影响达到最小。迁移完成之后会将路由元信息进行修改,同时将路由元信息推送给所有的JProxy,最后再解除锁定,访问将根据路由打到分片所在的新位置。

图9 版本三

系统在生产环境中使用的时候,除了考虑以上的介绍以外还需要考虑很多部署及运维的事情,首先要考虑的就是系统如何活下来,需要考虑系统的自我保护能力,要确保系统的稳定性,要做到性能能够满足业务需求。

在JProxy内部我们采用了基于事件驱动的网络IO模型同时考虑到多核等特点,将整个系统的性能发挥到极致,在压测时JProxy表现出来的性能随着MySQL实例的增加几乎是呈现线性增长的趋势,而且整个过程中JProxy所在机器毫无压力。

保证性能还不够,还需要考虑控制连接数、控制系统内存等,连接数主要是控制连接的数量这个比较好理解,控制内存主要是指控制系统在使用过程中对内存的需求量,比如在做数据抽数时候,sql语句是类似select * from table这种的全量查询,此时后端所有的MySQL数据会通过多条连接并发地往中间件发送数据,从中间件到应用只有一条连接,如果不对内存进行控制就会造成中间件OOM,在具体实现的时候我们通过将数据压在TCP栈中来控制中间件前后端连接的网络流速从而很好的保证了整个系统的内存是在可控范围内。

另外还需要考虑权限,哪些IP可以访问哪些IP不能访问都需要可以精确的控制,具体到某一张表还需要控制增删改查的权限,我们建议业务在写SQL的时候尽量都带有拆分字段保证SQL都可以落在某个分片上从而保证整个访问是足够的简单可控,我们为之提供了精细的权限控制,可以做到表级别的增删改查权限,包括是否要带有拆分字段,最大程度做到对SQL的控制,保证业务在测试阶段写出不满足期望的SQL都能及时发现,大大降低后期线上运行时的风险。

除了基本的稳定性之外,在整个系统全局上还需要考虑到服务高可用方案。JProxy是无状态的,一个业务在同一个机房内部署至少两个JProxy且必须跨机架的,保证在同一个机房里JProxy是高可用的。在另外的机房会部署再部署两个JProxy,做到跨机房的高可用。除了中间件自身的高可用以外还需要保证数据库层面的高可用,全链路的高可用才是真正的高可用。数据库层面在同一个机房里会按照一主一从部署,在备用机房会再部署一个备,如图10所示。JProxy访问MySQL时通过域名访问,如果MySQL的主出异常数据库会进行相应的主从切换操作,JProxy可以访问到切换以后新的主,如果整个机房的数据库异常可以直接将数据的域名切换到备用机房,保证JProxy可以访问到备用机房的数据库。业务访问JProxy时也是通过域名访问,如果一个机房的JProxy都出现了异常,和数据库类似直接将JProxy前端的域名切换到备用机房,从而保证业务始终都能正常访问JProxy。

数据高可靠也是非常关键的点,我们会这对数据库的数据进行定期备份,将备份数据存储到相应的存储系统中,从而保证数据库中的数据即使被删除依然是可以恢复的。

图10 部署示意图

系统在线上运行时候监控报警是极其重要的,监控可以分多个层次,如图11所示,从主机和操作系统的信息到应用系统的信息到特定系统内部特定的信息的监控等,针对操作系统及主机的监控京东有MJDOS系统可以把系统的内存/cpu/磁/网卡/机器负载等各种信息都纳入监控系统,这些操作系统的基础信息对系统异常的诊断非常关键,比如因为网络丢包等引起的服务异常都可以在这个监控系统中及时找到根源。

京东还有统一的监控报警系统UMP,这个监控系统主要是给所有的应用系统服务,所有的应用系统按照一定的规则暴露接口,在UMP系统中注册以后,UMP系统就可以提供一整套监控报警服务,最基本的比如系统的存活监控以及是否有慢查询等。

除了这两个基本的监控系统以外,我们还针对整套中间件系统开发了定制的监控系统JMonitor,之所以开发这套监控系统是因为我们需要采集更多的定制的监控信息,在系统发生异常时能够第一时间定位问题,举个例子当业务发现TP99下降时往往伴随着有慢SQL,应用从发送SQL到收到结果这个过程中经过了JProxy到MySQL又从MySQL经过JProxy再回到应用,这条链路上任何一个环节都可能慢,不管是哪个阶段耗时,我们需要将这种慢SQL的记录精细化,精细到各个阶段都花了多少时间,做到出现慢SQL时能快速准确的找到问题根源快速解决问题。

另外在配合业务去Oracle/SqlServer时,我们不建议使用跨库的事务,但是会出现有一种情况,同一个事务里的SQL都是带有拆分字段的,每条SQL都是单节点的,同一个事务里有多条这种SQL,结果却出现这个事务是跨库的,这种事务我们都会有详细的记录,业务方可以直接通过JMonitor找到这种事务从而更好的进一步改进。除了这个以外,在测试环境时候业务系统一开始写的SQL没有考虑太多的优化可能会出现比较多的慢SQL,这些慢SQL我们都会统一采集在JMonitor系统上进行分析处理,帮助业务方快速迭代调整SQL语句。

图11 监控体系

业务在使用这套系统的时候 要尽量出现避免跨库的SQL,有一个很重要的原因是当出现跨库SQL的时候会耗费MySQL较多的连接如图12所示,一条不带拆分字段的SQL将会发送到所有的分片上,如果在一个MySQL实例上有64个分片,那一条这样的SQL就会耗费这个MySQL实例上的64个连接,这个资源消耗是非常可观的,如果可以控制SQL落在单个分片上可以大大降低MySQL实例上的连接压力。

图12 连接数

跨库的分布式事务要要尽量避免,一个是基于MySQL的分布式数据库中间件的方案无法保证严格的分布式事务语义,另一个即使可以做到严格的分布式事务语义支持依然是要尽量避免垮库事务的,多个跨库的分布式事务在某个分片上发生死锁将会造成其他分片上的事务也无法继续导致直接引起大面积的死锁,即使是单节点上的事务也要尽量控制事务小一点,降低死锁发生的概率。

具体的路由策略不同的业务可以特殊对待,以京东分拣中心为例,各个分拣中心的大小差异很大,北京上海等大城市的分拣中心数据量很大其他城市的分拣中心相对会小一点, 针对这种特点我们会给其定制路由策略,做到将大的分拣中心的数据落在特定的性能较好的MySQL实例上,其他小的分拣中心的数据可以按照普通的拆分方式处理。

在JProxy系统层面我们可以支持多租户模式,但考虑到去Oracle/SqlServer的业务往往都是非常重要且数据量巨大的业务,所以我们的系统都是不同的业务独立部署一套,在部署层面避免各个业务之间的互相影响。考虑到独立部署会造成一些资源浪费,我们引入了容器系统,将操作系统资源通过容器的方式进行隔离,从而保证系统资源的充分利用。很多问题没必要一定要在代码层面解决,代码层面解决起来比较麻烦或者不能做到百分之百把控的事情可以通过架构层面来解决,架构层面不好解决的事情可以通过部署的层面来解决,部署层面不好解决的事情可以通过产品层面来解决,解决问题的方式各式各样,需要从整个系统全局角度来综合考量,引用邓公的一句话“不管黑猫白猫,能抓老鼠的就是好猫”,同样的道理能支撑住业务发展的系统就是好的系统。

另外再简单讨论一下为什么基于MySQL的分布式数据库中间件系统无法保证严格的分布式事务语义支持。所谓分布式事务语义本质上就是事务的语义,包含了ACID属性,分别是原子性、一致性、持久性、隔离性。

原子性是指一个事务要么成功要么失败,不能存在中间状态。持久性是指一个事务一旦提交成功那么要做到系统崩溃以后再恢复依然是成功的。隔离性是指各个并发事务之间是隔离的,不可见的,在数据库具体实现上可能会分很多个隔离级别。事务的一致性是指要保证系统要处于一个一致的状态,比如从A账户转了500元到B账户,那么从整体系统来看系统的总金额是没有发生变化的,不能出现A的账户已经减去500元但是B账户却没有增加500元的情况。

图13 可串行化调度

事务在数据库系统中执行的时候有一个可串行化调度的问题,假设有T1、T2、T3三个事务,那么这三个事务的执行的效果应该和三个事务串行执行效果一样,也就是最终效果效果应该是{T1/T2/T3, T1/T3/T2, T2/T1/T3, T2/T3/T1, T3/T1/T2, T3/T2/T1}集合中的一个,当涉及到分布式事务时,每个子事务之间的调度要和全局的分布式事务的调度顺序一致才能满足可串行化调度的要求,如图13所示,T1/T2/T3的三个分布式事务,在一个库中的调度顺序是T1/T2/T3和全局的调度顺序一致,在另一个库中的调度顺序变成了T3/T2/T1,此时站在全局的角度来看就打破了可串行化调度,可串行化调度保证了隔离性的实现,当可串行化调度被打破时自然隔离性也就随之打破,在基于MySQL的分布式中间件方案实现上,因为同一个分布式事务的各个子事务的事务ID是在各个MySQL上生成的,并没有提供全局的事务ID来保证各个子事务的调度顺序和全局的分布式事务一致,导致隔离性是无法保证的,所以说当前基于MySQL的分布式事务是无法保证严格的分布式事务语义支持的。当然随着MySQL引入GR可以做到CAP理论中的强一致,再加强中间件的相关功能及定制MySQL相关功能也是有可能做到支持严格的分布式事务的。