## 京东分布式数据库系统演进之路 文/张成远 关于数据库的使用,在京东有几个趋势,早期主要用 SQL Server 及 Oracle 也有少量采用 MySQL,考虑到业务发展技术积累及使用成本等因素,很多业务都开始使用 MySQL,包括早期使用 SQL Server 及 Oracle 的很多核心业务也都渐渐开始迁移到 MySQL,单机 MySQL 往往无法支撑这类业务,需要考虑分布式解决方案,另外原本使用 MySQL 的业务随着数据量及访问量的增加也会遇到瓶颈,最终考虑采用分布式解决方案,整个京东随着业务发展采用数据库的趋势如图1所示。 图1  业务使用数据库演变趋势 图1 业务使用数据库演变趋势 分布式数据库解决方案有很多种,在各个互联网公司也是非常普遍,本质上就是将数据拆开存储在多个节点上从而缓解单节点的压力,业务层面也可以根据业务特点自行进行拆分,如图2所示,假设有一张 user 表,以 ID 为拆分键,假设拆分成两份,最简单的就是奇数 ID 的数据落到一个存储节点上,偶数 ID 的数据落到另外一个存储节点上,实际部署示意图如图3所示。 图2  数据拆分示意图 图2 数据拆分示意图 图3  系统部署示意图 图3 系统部署示意图 除了业务层面做拆分,也可以考虑采用较为通用的一些解决方案,主要分为两类,一类是客户端解决方案,这种方案是在业务应用中引入特定的客户端包,通过该客户端包完成数据的拆分查询及结果汇总等操作,这种方案对业务有一定侵入性,随着业务应用实例部署的数量越来越多,数据库端可能会面临连接数据库压力也越来越大的问题,另外版本升级也比较困难,优点是链路较短,从应用实例直接到数据库。 另一类是中间件的解决方案,这种方案是提供兼容数据库传输协议及语法规范的代理,业务在连接中间件的时候可以直接使用传统的 JDBC 等客户端,从而大大减轻业务开发层面的负担,弊端是中间件的开发难度会比客户端方案稍微高一点,另外网络传输链路上多走了一段,理论上对性能略有影响,实际使用环境中这些系统都是在机房内网访问,这种网络上的影响完全可以忽略不计。 根据上述分析,为了更好得支撑京东大量的大规模数据量业务,我们开发了一套兼容 MySQL 协议的分布式数据库的中间件解决方案,称之为 JProxy,这套方案经过了多次演变最终完成并支撑了京东全集团的去 Oracle/SQL Server 任务。 JProxy 第一个版本如图4所示,每个 JProxy 都会有一个配置文件,我们会在其中配置相应业务的库表拆分信息及路由信息,JProxy 接收到 SQL 以后会对 SQL 进行解析再根据路由信息决定 SQL 是否需要重写及该发往哪些节点,等各节点结果返回以后再将结果汇总按照 MySQL 传输协议返回给应用。 图4  JProxy版本一 图4 JProxy 版本一 结合上文的例子,当用户查询 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 配置文件内容一致,这个过程心智负担较重且容易出错。 图5  配置文件 图5 配置文件 在之后的版本中我们引入了 JManager 模块,这个模块负责的工作是管理配置文件中的路由元信息,如图6所示。JProxy 的路由信息都是到JManager统一获取,我们只需要通过 JManager 往元数据库里添加修改路由元数据,操作完成以后通知各个 JProxy 动态加载路由信息就可以保证每个 JProxy 的路由信息是完全一致的,从而解决维护路由元信息一致性的痛点。 图6  JProxy版本二 图6 JProxy 版本二 在提到分布式数据库解决方案时一定会考虑的一个问题是扩容,扩容有两种方式,一种我们称之为 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  Pre- sharding方案 图7 Pre-sharding 方案 图8  Pre- sharding方案 Pre-sharding方案 Pre-sharding方案相当于通过迁移实现扩容的目的,分片位置的变动涉及到数据的迁移验证及路由元数据的变更等一系列变动,所以我们引入了 JTransfer 系统,如图9所示。JTransfer 可以做到在线无缝迁移,迁移扩容时只需提交一条迁移计划,指定将某个分片从哪个源实例迁移到哪个目标实例,可以指定在何时开始迁移任务,等到了时间点系统会自动开始。整个过程中涉及到基础全量数据和迁移过程中业务访问产生的增量数据。一开始会将基础全量数据从源实例中 dump 出来到目标实例恢复,验证数据正确以后开始追赶增量数据,当增量数据追赶到一定程度,系统预估可以快速追赶结束时,我们会做一个短暂的锁定操作,从而确保将最后的增量全部追赶完成。这个锁定时间也是在提交迁移任务时可以指定的一个参数,比如最多只能锁定 20s。如果因为此时访问量突然增大等原因最终剩余的增量没能在 20s 内追赶完成,整个迁移任务将会放弃,确保对线上访问影响达到最小。迁移完成之后会将路由元信息进行修改,同时将路由元信息推送给所有的 JProxy,最后再解除锁定,访问将根据路由打到分片所在的新位置。 图9  JProxy版本三 图9 JProxy 版本三 系统在生产环境中使用的时候,除了考虑以上的介绍以外还需要考虑很多部署及运维的事情,首先要考虑的就是系统如何活下来,需要考虑系统的自我保护能力,要确保系统的稳定性,要做到性能能够满足业务需求。 在 JProxy 内部我们采用了基于事件驱动的网络 I/O 模型,同时考虑到多核等特点,将整个系统的性能发挥到极致,在压测时 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  部署示意图 图10 部署示意图 数据高可靠也是非常关键的点,我们会针对数据进行定期备份到相应的存储系统中,从而保证数据库中的数据即使被删除依然可以恢复。 系统在线上运行时监控报警极其重要。监控可以分多个层次,如图11所示,从主机和操作系统的信息到应用系统的信息到系统内部特定信息的监控等。针对操作系统及主机的监控,京东有 MJDOS 系统可以把系统的内存/CPU/磁/网卡/机器负载等各种信息都纳入监控系统,这些操作系统的基础信息对系统异常的诊断非常关键,比如因为网络丢包等引起的服务异常都可以在这个监控系统中及时找到根源。 图11  监控体系 图11 监控体系 京东还有统一的监控报警系统 UMP,这个监控系统主要是为所有的应用系统服务。所有的应用系统按照一定的规则暴露接口,在 UMP 系统中注册以后,UMP 系统就可以提供一整套监控报警服务,最基本的比如系统的存活监控以及是否有慢查询等。 除了这两个基本的监控系统以外,我们还针对整套中间件系统开发了定制的监控系统 JMonitor。之所以开发这套监控系统是因为我们需要采集更多的定制的监控信息,在系统发生异常时能够第一时间定位问题。举个例子,当业务发现 TP99 下降时往往伴随着有慢 SQL,应用从发送 SQL 到收到结果这个过程中经过了 JProxy 到 MySQL 又从 MySQL 经过 JProxy 再回到应用,这条链路上任何一个环节都可能慢,不管是哪个阶段耗时,我们需要将这种慢 SQL 的记录精细化,精细到各个阶段都花了多少时间,做到出现慢 SQL 时能快速准确的找到问题根源并快速解决问题。 另外在配合业务去 Oracle/SQL Server 时,我们不建议使用跨库的事务,但是会出现有一种情况,同一个事务里的 SQL 都是带有拆分字段的,每条 SQL 都是单节点的,同一个事务里有多条这种 SQL,结果却出现这个事务是跨库的,这种事务我们都会有详细的记录,业务方可以直接通过 JMonitor 找到这种事务从而更好的进一步改进。除了这个以外,业务系统最初写的 SQL 没有考虑太多的优化可能会出现比较多的慢 SQL,这些慢 SQL 我们都会统一采集在 JMonitor 系统上进行分析处理,帮助业务方快速迭代调整 SQL 语句。 业务在使用这套系统的时候要尽量出现避免跨库的 SQL,有一个很重要的原因是当出现跨库 SQL 时会耗费 MySQL 较多的连接,如图12所示。一条不带拆分字段的 SQL 将会发送到所有的分片上,如果在一个 MySQL 实例上有64个分片,那一条这样的 SQL 就会耗费这个 MySQL 实例上的64个连接,这个资源消耗是非常可观的,如果可以控制 SQL 落在单个分片上可以大大降低 MySQL 实例上的连接压力。 图12  连接数 图12 连接数 跨库的分布式事务要尽量避免,一个是基于 MySQL 的分布式数据库中间件的方案无法保证严格的分布式事务语义,另一个即使可以做到严格的分布式事务语义支持依然要尽量避免垮库事务。多个跨库的分布式事务在某个分片上发生死锁将会造成其他分片上的事务也无法继续,从而导致大面积的死锁,即使是单节点上的事务也要尽量控制事务小一点,降低死锁发生的概率。 具体路由策略不同的业务可以特殊对待。以京东分拣中心为例,各个分拣中心的大小差异很大,北京上海等大城市的分拣中心数据量很大,其他城市的分拣中心相对会小一点,针对这种特点我们会给其定制路由策略,做到将大的分拣中心的数据落在特定的性能较好的 MySQL 实例上,其他小的分拣中心的数据可以按照普通的拆分方式处理。 在 JProxy 系统层面我们可以支持多租户模式,但考虑到去 Oracle/SQL Server 的业务往往都是非常重要且数据量巨大的业务,所以我们的系统都是不同的业务独立部署一套,在部署层面避免各个业务之间的互相影响。考虑到独立部署会造成一些资源浪费,我们引入了容器系统,将操作系统资源通过容器的方式进行隔离,从而保证系统资源的充分利用。很多问题没必要一定要在代码层面解决,代码层面解决起来比较麻烦或者不能做到百分之百把控的事情可以通过架构层面来解决,架构层面不好解决的事情可以通过部署的层面来解决,部署层面不好解决的事情可以通过产品层面来解决,解决问题的方式各式各样,需要从整个系统全局角度来综合考量,不管黑猫白猫,能抓老鼠的就是好猫,同样的道理,能支撑住业务发展的系统就是好系统。 另外再简单讨论一下为什么基于 MySQL 的分布式数据库中间件系统无法保证严格的分布式事务语义。所谓分布式事务语义本质上就是事务的语义,包含了 ACID 属性,分别是原子性、一致性、持久性、隔离性。 原子性是指一个事务要么成功要么失败,不能存在中间状态。持久性是指一个事务一旦提交成功那么要做到系统崩溃以后再恢复依然是成功的。隔离性是指各个并发事务之间是隔离的,不可见的,在数据库具体实现上可能会分很多个隔离级别。事务的一致性是指要保证系统要处于一个一致的状态,比如从 A 账户转了500元到 B 账户,那么从整体系统来看系统的总金额是没有发生变化的,不能出现 A 的账户已经减去500元但是 B 账户却没有增加500元的情况。 图13  可串行化调度 图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 相关功能,也是有可能做到支持严格的分布式事务的。