## 支持自动水平拆分的高性能分布式数据库 TDSQL 文 / 张文 随着互联网应用的广泛普及,海量数据的存储和访问成为系统设计的瓶颈问题。对于大型的互联网应用,每天几十亿的 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 分库分表 0~2499 SET1 2500~4999 SET2 5000 ~ 7499 SET3 7500 ~ 9999 SET4 完成集群初始化后,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 在分表环节对语法层做了一些限制,将来我们希望通过对网关解析器的改进,使其能够支持更丰富的语法、词法。