原文:F6 汽车科技基于 Apache ShardingSphere 的核心业务分库分表实践

1

业务背景
F6 汽车科技,是一家专注于汽车后市场信息化建设的互联网平台公司,为维修企业开发智慧管理平台。各个维修企业(后文简称商户)之间的数据是相互隔离的,不同商户之间的数据理论上可以存储在不同库不同表。随着公司业务发展迅速,有些表的数据量增长迅速,单表总的数据量达到千万甚至亿的级别,系统越来越难以满足业务的高速发展。另外随着业务发展公司也在对系统进行拆分,按照不同域不同业务拆分成许多微服务,随之也就垂直拆分成了不同的业务库。
2

技术背景
关系型数据库由于单机存储容量、连接数、处理能力都有限,容易成为系统瓶颈。从性能上看,当单表的数据量达到千万以后,由于查询维度较多,即使添加从库、优化索引,做很多操作时性能仍下降严重。此时就要考虑对其进行切分了,切分的目的就在于减少数据库的负担,缩短查询时间。另外单库的连接数有限,如果数据库查询的 QPS 过高,那么就需要通过分库来分担单个数据库的连接压力。从可用性上看,单个数据库如果发生意外,很可能会丢失所有数据,影响所有业务,分库可以隔离故障,减少业务的影响范围。通常,表的体积大于 2G 或者行数大于 1000w,并且数据快速增长,这个时候就要考虑进行分库分表。
3

分库分表介绍
根据业界经验,分库分表有以下四种常见形式:

  • 垂直分表 —— 大表拆小表,基于字段进行,将一些不常使用或长度较大字段拆分成扩展表;
  • 垂直分库 —— 基于业务边界进行数据库层面的拆分,解决单库的性能瓶颈;
  • 水平分表 —— 横向分表,将表中的数据行按照一定规律分布到不同的表中,降低单表数据量,优化查询性能,但是库级别还存在瓶颈;
  • 水平分库分表 —— 在水平分表基础上,将数据进一步分布到不同的库中,有效缓解单机和单库的性能瓶颈和压力,突破 IO、连接数、硬件资源的瓶颈;

业界常用的分库分表解决方案
1、Sharding-JDBC (ShardingSphere)
优点:

  • 社区活跃,目前已经升级至 5.0 版本,仍处于快速迭代中;
  • 成功应用案例多,京东、当当等大公司广泛应用;
  • 使用简单,Sharding-JDBC 快速集成项目,不需要部署额外服务;
  • 兼容性好,路由至单数据节点,SQL 100% 支持;
  • 性能优异,损耗低,官网有测试结果。

缺点:

  • 增加运维成本,分表后修改字段,增加索引繁琐 —— 可以部署 Sharding-Proxy 解决,支持异构语言,对运维友好;
  • 尚未支持分片数据动态迁移,需要开发功能实现。

2、MyCat
优点:

  • MyCat 是介于应用与数据库之间,进行数据处理与交互的中间服务,对研发无感知,接入成本低
  • 支持 JDBC 连接 ORACLE、DB2、SQL Server、MYSQL 等数据库
  • 支持多语言、跨平台,部署和实施简单
  • 高可用,故障自动切换

缺点:

  • 运维成本高,得配置 Mycat 的一系列参数以及高可用负载均衡的配置
  • 需要单独部署一个服务,增加了系统风险

其他类似的解决方案如:Cobar、Zebra、MTDDL、tidb 就没有过多的去研究了,有兴趣的可以自己去研究下。结合公司实际情况我们选择了 ShardingSphere。
4

总体方案
按照公司现有业务逻辑,因此采用商户 ID 作为分片键(ShardingKey),保证一个商户的工单数据,分配在同一个库同一个单表中,避免了多表查询关联的性能损耗,后期分片到多个库时,可以避免跨库事务及跨库 JOIN。
商户 ID 数据库中的类型为 BIGINT (20),为了保证后续分库扩容的需求,采用基因法,选取商户 ID 最后两位作为分库基因,按照双倍扩容的规则,可以最大扩展到 64 个库中。剩余位数的值用于分表,分片到 32 个分表中。
规则如下(10545055917999668983 为某商户 ID):
105450559179996689 83
分表基因值 % 32 分库基因值 % 1
最后两位 83 用于分库,暂时数据只分片到 f6xxx 一个库中,因此余数为 0,后期数据量增加,扩展至多个库。剩余数值 105450559179996689 用于分表,首次分为 32 个单表,取模余数对应具体的分表下标,为 0~31。
sharding 应用实例 - 图1
按照公司系统现状及开发功能能快速迭代分步上线的背景,因此我们制定了先分表再拆库的方案,考虑到分库分表改动影响比较大,因此需要灰度上线,一旦有问题需要快速回滚,不能影响正常业务。具体实施步骤如下:
分表
(1)工程从 jdbc 切到 Sharding-JDBC 数据源连接方式
(2)写库业务解耦和代码迁移
(3)历史数据和增量数据同步
(4)切分表
分库
(1)读库业务迁移
(2)数据迁移
(3)切读库
(4)切写库
5

分表
分表个数选择
根据业界经验,单个表的数据尽量控制在 500 万条以内,分表的数量为 2 的 n 次方,方便扩展;根据业务的发展速度和未来的数据增长量,并结合未来的数据归档方案计算出分表个数。分表个数和分片算法定义好之后,就可以评估已有数据划分到每个分表之后的数据量。

准备工作

数据库表的自增 id 替换
由于分表之后依赖数据库的自增 id 不能再用了,需要考虑使用其他方案,可行性方案有:
1、用其他主键【比如 snowflake】
2、自己实现步长(数据库、redis)

|

方案 1 方案 2
优点 1. 长期方案,一步到位 1. 数据库字段类型不用变,工作量较小
缺点 1. 主键类型需要更改
2. 要改动代码
3. 要接入新的服务
4. 老的大表也要扩字段
1. 实现步长组件
2. 改代码接入组件
3. 后生成的 id 可能比之前生成的要小,双向同步时新表到老表插入性能降低
开发成本
运维成本

通过对比上述方案和考虑现状,我们采用了方案 2,共同提供了表级别的全局自增 id 实现方案。
排查所有请求是否带上分片键
现有的微服务流量入口

  • http
  • dubbo
  • xxljob 定时任务
  • mq 消息

分表之后,为了能快速定位数据在哪一个分片,需要所有请求都带上分片键。
业务解耦
1、各个域之间业务解耦,读写数据均通过接口交互
2、去除直接表 join 的场景,改走接口
业务解耦带来的最大问题就是分布式事务问题,如何保证数据的一致性,业界通常的做法就是引入分布式事务组件保证事务一致性或者使用补偿等机制保证数据的最终一致性。
灰度切换方案
为了保证新功能在出问题时能够快速回滚,所有线上修改都按照商户逐步切换原则进行上线。我们考虑的灰度方案如下:
1、维护两套 mapper 接口,一套使用 Sharding-JDBC 数据源连接 db,另一套 mapper 使用 jdbc 数据源连接 db,在 service 层按照灰度开关判断选择那一套接口,流程图如下:
sharding 应用实例 - 图2
上述实现方案会导致所有访问 mapper 层的代码都有一个 if else 分支,改动比较多,对业务代码有侵入,代码也难以维护,因此我们考虑了如下的方案二。
2、自适应选择 mapper 方案:一套 mapper 接口对应两个数据源两套实现,不同商户请求会根据灰度配置走不同的 mapper 实现,一个 service 对应两个数据源两套事务管理器,不同商户请求会根据灰度配置走不同的事务管理器。根据上述思路我们利用 mybatis 的多个 mapperScanner 完成多个 mapperInterface 的生成,同时再次生成一个 mapperInterface 完成包装,包装类里面支持 hintManger 自动选择多种 mapper;事务管理器类似也是生成一个包装类,包装类里面支持 hintManger 自动选择多种事务管理器管理事务。这个方案避免了对业务代码的入侵,对 service 层业务代码来说,就只有一套 mapper 接口。
sharding 应用实例 - 图3
业务切数据源连接
Sharding-JDBC 除了官网给定的一些不支持的语法,我们在开发当中也发现了下列一些 sql 语法 Sharding-JDBC 解析出来会有问题:

  • 子查询不带分片键
  • insert 语句 values 里带 cast ifnull now 等函数时不支持
  • 不支持 ON DUPLICATE KEY UPDATE
  • select for update 默认走从库(4.0.0.RC3 版本修复)
  • Sharding-JDBC 不支持 MySqlMapper(乐观锁查询版本号)中 ResultSet.first () 语法
  • 批量更新语法不支持
  • UNION ALL 不支持按照之前设计的灰度上线方案,我们只需复制一套 mapper.xml,然后按照 Sharding-JDBC 的语法修改好即可上线。sharding 应用实例 - 图4

历史数据同步
DataX 是阿里巴巴集团内被广泛使用的离线数据同步工具 / 平台,实现包括 MySQL、Oracle、SqlServer、Postgre、HDFS、Hive、ADS、HBase、TableStore (OTS)、MaxCompute (ODPS)、DRDS 等各种异构数据源之间高效的数据同步功能。
DataX 本身作为数据同步框架,将不同数据源的同步抽象为从源头数据源读取数据的 Reader 插件,以及向目标端写入数据的 Writer 插件,理论上 DataX 框架可以支持任意数据源类型的数据同步工作。同时 DataX 插件体系作为一套生态系统,每接入一套新数据源该新加入的数据源即可实现和现有的数据源互通。
sharding 应用实例 - 图5
同步数据校验

  • 使用定时任务对比原表和分表的数据条数
  • 使用定时任务对比关键字段的值

业务读写切换分表
在业务正式读写分表之前,我们需要配置好增量数据的同步。
增量数据同步
使用 otter 工具进行同步,otter 基于数据库增量日志解析,准实时同步到本机房或异地机房的 mysql/oracle 数据库。一个分布式数据库同步系统(https://github.com/alibaba/otter),使用过程中需要注意以下事项:

  • mysql 数据库必须开启 binlog,且模式为 ROW;同步的表必须有主键;
  • 用户必须要有 binlog 的查询权限,申请单独的 otter 用户
  • 目前 dms 数据库的 binlog 只保存 3 天,otter 可以自定义 binlog 同步的起始位置,增量同步的起点,查看方法:sql 平台上选择 slave-testDb,用 sql 语句 “show master status” 即可查询,特别注意下:master 和 slave 的 “show master status” 执行结果不一样,如果设置的话需要拿主库的执行结果。这个功能特别好用,当我们再用 otter 同步数据出错的时候,我们可以重设点位,从头开始在同步一次。
  • otter 停用会自动记录同步的最后点位信息,下次会继续从这个点位开始同步。
  • otter 支持业务自定义处理过程,比如我们控制商户同步数据的方向分表到总表还是总表到分表,数据路由规则等
  • otter 停用启动不会使 otter 自定义处理过程里面定义的缓存失效,解决办法是:修改代码注释重新保存

读写切分表
灰度切换方案如下:
sharding 应用实例 - 图6
由于是灰度切换,需要保障分表和原表的数据都是实时更新的,所以数据需要双向同步,商户灰度开关开的门店,读写走分表,数据通过 otter 实时同步到原表。商户灰度开关关闭的门店,读写走原表,数据通过 otter 实时同步到分表。
sharding 应用实例 - 图7
6

分库
准备工作
主键
主键需要为自增类型(分表需要全局自增)或者接入全局唯一递增主键发号服务(不依赖 DB 的 server_id),像表自增生成主键的方法或者使用 uuid_short 生成主键的方法需要切换。
存储过程、函数、触发器、EVENT
如果有,尽量先安排去掉;如果下不掉,提前在新数据库新建好
数据同步
数据同步采用的 DTS 或者 sqldump(历史数据)+otter(增量数据)进行同步
切库步骤
为避免可能的性能问题和兼容性问题,切库方案必须满足两个准则

  • 灰度切换:流量逐步切换到 rds(阿里云关系型数据库,简称 RDS),随时观察数据库性能
  • 快速回滚:出现问题能快速切换,不影响用户使用

现状:四个应用实例 +db 一主两从
sharding 应用实例 - 图8
步骤一:新增加一个应用实例,切读到 rds 库,写还是走 dms master 库,dms master 的数据会通过 otter 实时同步到 rds
sharding 应用实例 - 图9
步骤二:再增加到 3 个应用实例,切 50% 流量的读到 rds 库
sharding 应用实例 - 图10
步骤三:摘掉原来的四个实例流量,全部读切到 rds 实例,写还是走 dms master 库
sharding 应用实例 - 图11
步骤四:切主库到 rds,为了方便切流量回滚,此时 rds 的数据会通过 otter 反向同步到 dms master 库
sharding 应用实例 - 图12
步骤五:完成
sharding 应用实例 - 图13
上述每一个步骤都可以通过切换流量快速进行回滚,保障系统的可用性和稳定性。
7

分库分表扩容
当单库的性能达到瓶颈时,我们可以通过修改分库路由算法和迁移数据来达到库的扩容。
sharding 应用实例 - 图14
当单表的容量达到瓶颈时,我们可以通过修改分表路由算法和迁移数据来达到分表的扩容。
sharding 应用实例 - 图15
8

FAQ
1、otter 接收到 binlog 数据之后再去反查数据库偶尔查不到数据?
答:我们 MySQL 为了兼容其它非事务引擎的复制,在 server 层面引入了 binlog, 它可以记录所有引擎中的修改操作,因而可以对所有的引擎使用复制功能;然而这种情况会导致 redo log 与 binlog 的一致性问题;MySQL 通过内部 XA 机制解决这种一致性的问题。
第一阶段:InnoDB prepare, write/sync redo log;binlog 不作任何操作;
第二阶段:包含两步,1> write/sync Binlog;2> InnoDB commit (commit in memory);
当然在 5.6 之后引入了组提交的概念,可以在 IO 性能上进行一些提升,但总体的执行顺序不会改变。
当第二阶段的第 1 步执行完成之后,binlog 已经写入,MySQL 会认为事务已经提交并持久化了 (在这一步 binlog 就已经 ready 并且可以发送给订阅者了)。在这个时刻,就算数据库发生了崩溃,那么重启 MySQL 之后依然能正确恢复该事务。在这一步之前包含这一步任何操作的失败都会引起事务的 rollback。
第二阶段的第 2 大部分都是内存操作,比如释放锁,释放 mvcc 相关的 read view 等等。MySQL 认为这一步不会发生任何错误,一旦发生了错误那就是数据库的崩溃,MySQL 自身无法处理。这个阶段没有任何导致事务 rollback 的逻辑。在程序运行层面,只有这一步完成之后,事务导致变更才能通过 API 或者客户端查询体现出来。
binlog 发送在前,db commit 在后,所以才出现上述问题。我们解决此问题是通过查询重试解决。
2、同一个查询请求查询不同表时,偶尔部分表查不到数据?
答:Sharding-JDBC 主从路由策略如下:
选择走主库的场景:

  • 包含 lock 语句的 sql,比如 select for update(4.0.0.RC3 版本)
  • 非 select 语句
  • 同一个线程前面已经走过主库了
  • 代码指定走主库

多个从库选择算法:

  • 轮询
  • 负载均衡策略

当没有配置默认的负载均衡策略的时候就默认使用轮询策略。同一个查询可能走不同的从库,也有可能走主库和从库,当主从延迟或者多个从库延迟时间不一样时就会发生此问题。
3、流量怎么摘除?
答:1) http 流量通过 nginx 摘除 upstream 去掉;
2) dubbo 通过其内部提供的 qos 模块执行 offline /online 命令摘除流量;
3) xxljob 通过手动录入执行器的执行 ip 指定具体的实例执行;
4) MQ 通过阿里云提供的 api 启动或者关闭消费者 bean。