支持自动水平拆分的高性能分布式数据库TDSQL

作者:张文,北京邮电大学硕士毕业,2015年加入腾讯,目前在TDSQL团队主要从事Linux后台Server开发、MySQL数据库开发。
责编:仲培艺,关注数据库领域,寻求报道或者投稿请发邮件[email protected],或微信:zhongpy_0921。
本文为《程序员》原创文章,未经允许不得转载,更多精彩文章请订阅2017年《程序员》

随着互联网应用的广泛普及,海量数据的存储和访问成为系统设计的瓶颈问题。对于大型的互联网应用,每天几十亿的PV无疑对数据库造成了相当高的负载。给系统的稳定性和扩展性造成了极大的问题。通过数据的切分来提高系统整体性能,扩充系统整体容量,横向扩展数据层已经成为架构研发人员首选的方式。

2004年,腾讯开始逐步上线互联网增值服务,业务量开始第一次爆炸。计费成为所有业务都需要的一个公共服务,不再是某个服务的专属。业务量的爆炸给DB层带来了巨大的压力,原来的单机模式已经无法支撑。伴随计费公共平台的整合建设,在DB层开始引入分库分表机制:针对大的表,按照某个key预先拆成n个子表,分布在不同的机器节点上。逻辑层在访问DB时,自己根据分表逻辑将请求分发到不同的节点。在扩容时,需要手工完成子表数据的搬迁和访问路由的修改。DB层在业务狂潮之下,增加各种工具和补丁来解决容量水平扩展的问题。2012年TDSQL项目立项,目标为金融联机交易数据库。TDSQL(Tencent Distributed MySQL,腾讯分布式MySQL)是针对金融联机交易场景推出的高一致性、分布式数据库解决方案。产品形态为一个数据库集群,底层基于MySQL,对外的功能表现上与MySQL兼容。截至2017年,TDSQL已在公司内部关键数据领域获得广泛应用,其中之一作为Midas(米大师)核心数据库,经受了互联网交易场景的考验。Midas作为腾讯官方唯一数字业务支付平台,为公司移动App(iOS、Android、Win phone等)、PC客户端、Web等不同场景提供一站式计费解决方案。

水平拆分

TDSQL规定shardkey为表拆分的依据,即进行SQL查询时,shardkey作为查询字段指明该SQL发往哪个Set(数据分片)。在分库分表之前需要Schedule初始化集群,我们这里称作一个Group。在初始化Group时要确定最初的分片大小,因而需要确定准备几套Set。例如,我们需要对逻辑表拆分成四张子表,需要我们在初始化集群时准备四个Set,同时指定每个Set的路由信息,并将这些路由信息写入ZK,如图1所示。

图1  TDSQL分库分表

图1 TDSQL分库分表

图片描述

完成集群初始化后,Proxy监控ZooKeeper中的路由节点,当发现新的路由信息后,更新新的路由到本地。当用户通过Proxy创建表时,一个建表语句发给Proxy必须指定shardkey,例如create table test_shard(a int, b int) shardkey=a。然后,Proxy改写SQL,根据路由信息,在最后增加对应的partition clause,然后发到所有的后端Set,如图2所示。

图2  Proxy建表语句

图2 Proxy建表语句

这样,就完成了一次建表任务,用户看到的是一张逻辑表test_shard,但是在后端创建了4个实体表test_shard,后续用户通过网关进行带shardkey的增删改查时,Proxy便会根据shardkey的路由将SQL发往指定Set。

全局自增字段

在单实例MySQL中,用户可以通过auto_increment属性生成一个唯一的值,在分布式数据库下,利用MySQL的自增属性,只能保证在一个后端实例内实现自增和全局唯一,无法保证整个集群的唯一。

为了保证整个集群的唯一性,很显然不能依赖于后端的数据库,而需要Proxy生成对应的值。同时在实际运行中,Proxy可能有多个,并且可能有重启等操作,通过Proxy自身也很难做到全局唯一,因此选用了ZooKeeper作为唯一值的生成工具。

通过ZooKeeper的分布式特性,可以保证即使多个Proxy同时访问,每次只会有一个Proxy能够成功拿到,使得生成的值是全局唯一。从性能上考虑,不可能每次都与ZooKeeper进行交互获取,因此每个Proxy每次都会申请一段值,都用完后才会向ZooKeeper进行申请。

图3  全局唯一字段表创建过程

图3 全局唯一字段表创建过程

这种设计方式实现了分布式环境下的自增属性全局唯一。每个Proxy缓存一定数量的值,并且增加单独线程负责向ZooKeeper申请值,使得性能影响降到最低,同时具有容灾特性,即使Proxy挂了或者重启,都能保证全局唯一。但是缺点是:多个Proxy一起使用的时候,只能保证全局唯一,不能保证单调递增。

全局唯一字段的创建方式和普通的自增字段一样:

create table auto_inc(a int auto_increment,b int) shardkey=b;

使用方式也相同:

insert into shard.auto_inc ( a,b,d,c) values(1,2,3,0),(1,2,3,0);

对应的字段如果赋值为0或者NULL时,由Proxy生成唯一的值,然后修改对应的SQL发送到后端。同时也支持select last_insert_id(),返回上次插入的值,每个线程互相独立。

分布式JOIN

在分布式数据库中,数据根据shardkey拆分到后端多个Set中,每个后端Set保存的都只是一部分数据。我们可以方便地在一个Set内做各种复杂的操作,如JOIN、子查询等。分布式JOIN依赖于网关的语法分析,何为语法分析?简单来说,语法分析主要做两方面的事:判断输入是否满足指定的语法规则,同时生成抽象语法树。对于词法分析以及语法分析,开源有多种现成的工具,不需要从头开始做,Linux下用的比较多的是Flex和Bison。

图4  语法分析过程

图4 语法分析过程

有了语法分析的支持,对于涉及分布式JOIN的查询,例如表t1和t2要做JOIN操作,可能使用不同的字段作为shardkey,这样根据shardkey路由后,相关记录可能分布在两个Set,网关分析后先将数据表t1数据取出,然后再根据t1的shardkey去获取t2的数据,网关在这个过程中先做语法解析再进行数据聚合,最后返回给用户结果集。此外,在实际业务中,有一些特殊的配置表,这些表都比较小,并且变动不多,但是会和很多其他表有关联,对于这类表没必要进行分片,因此支持一种叫做全局表的特殊表。如果用户创建时指定是全局表的话(g1),该表全量存放在后端的所有Set中,查询时随机选择一个Set,修改时修改所有Set。如果对全局表进行JOIN的话,就不需要限制条件,即支持select * from t1 join g1。

分布式事务

针对分布式事务,TDSQL采用两阶段提交算法来实现分布式事务,其中Proxy作为协调者,状态数据持久化到全局事务管理系统中,目前选用的是TDSQL本身的一个InnoDB表来保存(gtid_log);所有的Group作为参与者来负责具体子事务的执行。

图5  分布式事务

图5 分布式事务

Client向Proxy发送事务

Begin;

Statment1;

Statment2;

Proxy为该事务分配一个ID,并将SQL转为:

Xa begin “id”

Statment1;

Statment2;

Client提交事务

Client最终向Proxy发送commit。

Proxy对事务prepare

Proxy向所有参与该事务的Set发送:

  • Xa end “id” 标识该事务的结束;
  • Xa prepare “id” mysql将事务计入Binlog,并通过Binlog传递给Slave,不同于普通事务,写入Binlog之后该事务仍然没有提交;
  • 如果任意Set在 prepare过程中失败或者超时,由于此时还没有写存储引擎日志,MySQL自动rollback这个事务,并向Client返回相应错误信息。

Proxy对事务commit

当Proxy收到所有Set的prepare响应之后,Proxy更新gtid_log表将对应XID的事务置为commit状态;Proxy随后向所有Set发送Xa commit “id”,Set收到该请求之后提交该事务。

Proxy返回Client OK

Proxy等待所有Set的commit响应,当所有Set返回成功,Proxy返回前台成功。若其中一台返回失败(当Set发生重启等故障时,需要等待Agent补提交该事务,因而当前属于未提交状态),Proxy返回前台状态未知,稍后请继续查询事务状态。

当Proxy在第四步写完commit后,开始逐个Set提交事务,当还没有完成所有Set提交时Proxy发生宕机,剩余Set中未提交的事务由Agent来提交,以此来保证事务的一致性。Agent会定期通过命令Xa recover查询MySQL中处于prepare状态的事务,再对照gtid-log表查询该事务是否处于commit状态,如果是则comimt。否则可能由于prepare成功后写gtid_log失败,因而Agent需要将该事务abort。

多种模式的读写分离

TDSQL支持三种模式的读写分离。第一种模式下网关开启语法解析的配置,通过语法解析过滤出用户的select读请求,默认把读请求直接发给备机。这种方案的缺点有两个:1. 网关需要对SQL进行分析,降低整体性能;2. 当主备延迟较大时,直接从备机获取数据可能会得到错误的数据。

除了上述模式,TDSQL支持通过增加Slave注释标记,将指定的SQL发往备机。即在SQL中添加/slave/这样的标记,该SQL会发送给备机,即用户能够根据业务场景可控地选择读写分离,即使主备延迟比较大,用户也能够根据需要灵活选择从主机还是备机读取数据,这种模式下网关不需要进行词法解析,因而相比第一种方案提高了整体性能。但是,这种方案的缺陷是改写了正常SQL,需要调整已有用户代码,成本较高,用户可能不太愿意接受。

表1 三种读写分离模式比较

表1  三种读写分离模式比较

针对前两种读写分离的不足,最新版本的TDSQL增加了基于只读帐号的读写分离模式。这种模式下,由只读帐号发送的请求会根据配置的属性发给备机。有两种可配属性,IDC属性和备机延迟属性。IDC属性可配置三种属性:1. 为同IDC属性,即只读帐号的请求必须发往同IDC的备机;2. 优先发给同IDC的备机,但当同IDC的备机不存在或宕机时,发往不同IDC的备机;3. 如果找不到满足条件的备机,则发往主机。延迟属性:如果延迟超过阀值,认为该备机不可用。只读帐号能够在既不改变原有用户代码,又不影响系统整体性能的前提下,同时提供多种可配参数解决读写分离的问题,更具有灵活性和实用性。

总结

2014年微众银行设立之初,在其分布式的去IOE架构中,TDSQL承担了去O的角色,以TDSQL作为交易的核心DB,承载全行所有OLTP业务。2015年,TDSQL和腾讯云携手,正式启动“TDSQL上云”的工作,接入了一系列传统以及新型金融企业,覆盖保险、证券、理财、咨询等金融行业。目前,分布式TDSQL正作为腾讯日益重要的金融级数据库,搭建着上百个实例,部署于上千台机器,日均产生TB级数据,承载着公司内外各种关键业务。

在未来,TDSQL重点会在数据库性能、分布式事务、语法兼容三个方面做改进。目前TDSQL基于MariaDB 10.1.x、Percona-MySQL 5.7.x两个分支版本,后续我们会紧密跟进社区并及时应用官方补丁,同时不断针对金融场景的特性对数据库内核进行优化,以此来提升数据库性能和稳定性。当前分布式事务处于初级阶段,对ZooKeeper的依赖性较强,后续可能针对分布式事务的可靠性做持续改进。由于TDSQL在分表环节对语法层做了一些限制,将来我们希望通过对网关解析器的改进,使其能够支持更丰富的语法、词法。