
在早期为了快速尝试新业务,系统一般是单体服务,数据库一般是单库单表(MySQL)。当业务成长到一定规模,随着业务逻辑越来越复杂,单体服务会被拆分成微服务;随着流量和数据量越来越大,数据库会面临存储容量和性能瓶颈,单库单表会被升级为分库分表。
分库分表的实现方式,按切入的层次可分为以下5类(https://mp.weixin.qq.com/s/U_pEF9sfnXeZ7RnhGFnqyg):
| 编码层 | 业务方硬编码,直接根据条件在代码中解析并路由,Spring中有动态切换数据源的抽象类,具体参见AbstractRoutingDataSource。 |
| 框架层 | 修改或增强现有ORM框架的功能,通过实现一些拦截器(比如Mybatis的Interceptor接口),增加一些自定义解析并路由。 |
| 驱动层 | 重新编写一个JDBC的驱动,在内存中维护一个路由列表,将请求解析并转发到真正的数据库中。 |
| 代理层 | 将自己伪装成一个数据库,接受业务端的链接,然后负载业务端的请求,解析并转发到真正的数据库中。 |
| 实现层 | SQL特殊版本支持,如Mysql cluster本身就支持各种特性,Greenplum支持分片等。 |
业界的数据库中间件,主要集中在驱动层和代码层去实现分库分表,以下产品排名不分先后:
Atlas、Kingshard、DBProxy、mysql router、MaxScale、58 Oceanus、ArkProxy、Ctrip DAL、Tsharding、Youtube vitess、网易DDB、Heisenberg、proxysql、Mango、DDAL、Datahekr、MTAtlas、MTDDL、Zebra、Cobar、MyCat、Apache ShardingSphere
团队之前使用的分库分表中间件是前辈基于Cobar改编的。随着团队人员的更迭,自运维这个分库分表中间件越来越困难,且其开源版本也很久没有更新。经过一番技术调研后,最终选择迁移到ShardingSphere-JDBC。
Apache ShardingSphere(https://github.com/apache/shardingsphere) 是一套开源的分布式数据库解决方案组成的生态圈,它由JDBC、Proxy和Sidecar这3款既能够独立部署,又支持混合部署配合使用的产品组成。其中ShardingSphere-JDBC定位为轻量级Java框架,在Java的JDBC层提供的额外服务。它使用客户端直连数据库,以jar包形式提供服务,无需额外部署和依赖,可理解为增强版的JDBC驱动,完全兼容JDBC和各种ORM框架。

1. 迁移
ShardingSphere-JDBC的执行流程如下:SQL解析 ----> 路由分库 ----> 路由分表(改写SQL)----> 执行SQL ----> 归并结果。
下面列举一个具体的例子:
百库:db_{00..99},分库策略:{00..99}取user_id的后三位的前两位
千表:order_{00..99}_{0..9},分表策略:{00..99}_{0..9}取user_id的后三位
分片键:user_id,查询SQL:select * from order where user_id in ('abc000', 'abc011')
| SQL解析 | 解析SQL,提取关键信息,表名:order;条件:user_id;值:'abc000','abc111' |
| 路由分库 | 根据user_id的后三位的前两位(00、01),计算出分库路由:db_00、db_01 |
| 路由分表(改写SQL) | 遍历分库,根据user_id的后三位(000、011),计算出分表路由并改写SQL:
|
| 执行SQL | 分别执行两条SQL |
| 归并结果 | 合并两条SQL的执行结果 |
理想中的迁移步骤:业务方引入ShardingSphere-JDBC的jar包,配置自定义的分库分表策略,修改数据源配置即可。但实际的迁移过程并不是一帆风顺的,下面讲一下在迁移过程中遇到的一些问题,以及对应的解决方案。

2. 数据库连接数
首先遇到的是数据库连接数暴增的问题。
ShardingSphere-JDBC使用数据节点作为数据分片的最小单元。由数据源名称和数据表组成,数据结构为数据源名称+真实表名称,如:db_00 + order_00_0。在业务方百库千表的场景中,一个MySQL物理集群有100个逻辑库,迁移到ShardingSphere-JDBC后需要配置100个数据源,假设每个数据源的连接池大小为5,一个在线服务有10个实例,那么就需要100 * 5 * 10 = 5000个数据库连接。占用较多的数据库连接,是驱动层分库分表中间件都会面临的问题,逻辑分库会放大这个问题。
考虑到业务方的百库都在同一个物理集群,只是逻辑库名不一样。我们可以让这100个逻辑库共用同一个数据源,同时在路由分表(改写SQL)时通过ShardingSphere-JDBC提供的复合分片算法机制追加上库名,从而收敛数据库连接数(5 * 10 = 50,减少100倍)。
public class Db100Tb10ShardingAlgorithm implements ComplexKeysShardingAlgorithm {private String dbPrefix = "db_";@Overridepublic Collection<String> doSharding(Collection availableTargetNames, ComplexKeysShardingValue shardingValue) {Set<String> shardingResultSet = Sets.newLinkedHashSet();String logicTableName = shardingValue.getLogicTableName();for (Map.Entry<String, Collection> entry : (Map<String, Collection) shardingValue.getColumnNameAndShardingValuesMap().entrySet()) {for (Object object : entry.getValue()) {String partition = (String) object;// 改写表名为 库名_xx.表名_xx_xString dbIndex = partition.substring(partition.length() - 3, partition.length() - 1); // 后三位de前两位String tableIndex = partition.substring(partition.length() - 1); // 最后一位String dbName = dbPrefix + dbIndex;shardingResultSet.add(dbName + "." + logicTableName + "_" + dbIndex + "_" + tableIndex);}}return shardingResultSet;}}

3. 非标准SQL语义
之前的中间件在全扫描和伪分片键场景下的实现不符合标准的SQL语义,为了让业务方平滑迁移,我们修改了ShardingSphere-JDBC的功能实现。
全扫描
在线服务的SQL请求都有分片键路由到具体的一个分库分表,但是离线跑批会有一些SQL请求没有分片键,需要扫描所有的分库分表,俗称“全扫描”。比如:select * from order limit 10, 10;
ShardingSphere-JDBC本身是支持全扫描的,但是每个库都需要配置独立的数据源,与收敛数据库连接数的方案冲突。我们通过反射将TableRule修改成单数据源到百库千表的映射关系来解决这个问题。
public class ShardingDataSourceFactory {public static ShardingDataSource buildShardingDataSource(Map<String, DataSource> dataSourceMap,ShardingRuleConfiguration shardingRuleConfig,String dbPrefix)throws SQLException {ShardingRule shardingRule = new ShardingRule(shardingRuleConfig, dataSourceMap.keySet());// 替换为百库千表replaceTableRule(dbPrefix, shardingRule);return new ShardingDataSource(dataSourceMap, shardingRule, new Properties());}private static void replaceTableRule(String dbPrefix, ShardingRule shardingRule) {String dataSource = shardingRule.getShardingDataSourceNames().getDataSourceNames().stream().findFirst().get();for (TableRuleConfiguration config : shardingRule.getRuleConfiguration().getTableRuleConfigs()) {TableRule tableRule = shardingRule.getTableRule(config.getLogicTable());tableRule.getActualDataNodes().clear();tableRule.getDatasourceToTablesMap().clear();Set<String> actualTables = (Set<String>) ReflectUtil.getFieldValue(tableRule, "actualTables");actualTables.clear();// 将数据节点的结构改为<数据源名称+真实库名>.<真实表名>。for (int i = 0; i < 100; i++) {for (int tableIndex = 0; tableIndex < 10; tableIndex++) {String dbIndex = i < 10 ? "0" + i : i + "";String tableName = dbPrefix + dbIndex + "." + tableRule.getLogicTable() + "_" + dbIndex + "_" + tableIndex;DataNode dataNode = new DataNode(dataSource, tableName);tableRule.getActualDataNodes().add(dataNode);tableRule.getDatasourceToTablesMap().computeIfAbsent(dataSource, v -> new ArrayList<>(1000)).add(tableName);actualTables.add(dataNode.getTableName());}}}}}
伪分片键
SQL中有分片键,但在OR语句中,俗称“伪分片键”。比如:select * from order where 1=1 or user_id='999999999_000' limit 10, 10;
之前的中间件会根据user_id路由到db_00.order_00_0,业务方通过指定一个不存在的user_id达到扫描某一指定分库分表的目的。而ShardingSphere-JDBC正确实现了该SQL语义,会进行全扫描。为了让业务方平滑迁移,我们通过ShardingSphere-JDBC提供的SPI机制替换了原有的实现,从而适配了之前错误的功能实现。

4. 加载库表元数据
有一个离线跑批迁移后发现服务启动变慢了几分钟,且启动时对数据库造成了很大压力。经排查后发现,ShardingSphere-JDBC在启动时会加载数据库所有表的元数据,而该服务用到的一个库有1500+张表(按日分表),所以对DB造成了很大压力且启动速度变慢。
ShardingSphere在5.0.0-beta版本中对元数据加载做了性能优化(https://blog.csdn.net/SphereEX/article/details/119322237),当前使用的是4.1.0,如果升级版本的话,上文中适配全扫描和伪分片键功能的改动不确定是否依然生效。
那有没有办法让ShardingSphere-JDBC不去加载元数据呢?答案是有,查看源码发现,只有在配置了默认数据源或者只配置了一个数据源时,ShardingSphere-JDBC才会加载该数据源的库表元数据。那么配置两个逻辑数据源(指向同一个物理数据源)即可绕过数据库表元数据加载(https://blog.csdn.net/qq_41056506/article/details/119795229)。上线后发现,虽然解决了元数据加载的问题,但是引入了一个新的问题:死锁
2021-09-24 08:52:48,021 ERROR util.JdbcUtils - close connection errorjava.sql.SQLException: Lock wait timeout exceeded; try restarting transactionat com.mysql.jdbc.SQLError.createSQLException(SQLError.java:998) ~[mysql-connector-java-5.1.35.jar:5.1.35]at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3835) ~[mysql-connector-java-5.1.35.jar:5.1.35]at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3771) ~[mysql-connector-java-5.1.35.jar:5.1.35]at com.mysql.jdbc.MysqlIO.sendCommand(MysqlIO.java:2435) ~[mysql-connector-java-5.1.35.jar:5.1.35]at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2582) ~[mysql-connector-java-5.1.35.jar:5.1.35]at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2531) ~[mysql-connector-java-5.1.35.jar:5.1.35]at com.mysql.jdbc.ConnectionImpl.rollbackNoChecks(ConnectionImpl.java:4708) ~[mysql-connector-java-5.1.35.jar:5.1.35]at com.mysql.jdbc.ConnectionImpl.rollback(ConnectionImpl.java:4600) ~[mysql-connector-java-5.1.35.jar:5.1.35]at com.mysql.jdbc.ConnectionImpl.realClose(ConnectionImpl.java:4241) ~[mysql-connector-java-5.1.35.jar:5.1.35]at com.mysql.jdbc.ConnectionImpl.close(ConnectionImpl.java:1515) ~[mysql-connector-java-5.1.35.jar:5.1.35]
排查后发现,业务方在一个大事务中对同一张单表的同一条记录进行了多次更新操作,因为配置了两个数据源且没有默认数据源,所以每一次更新操作都是随机选取一个数据源。如果后一次更新操作恰好选取了不同的数据源,那么就会开启一个新事务,然后因为获取不到锁导致异常。
我们选择了一个不优雅的解决方案:初始化数据源前配置两个逻辑数据源绕过元数据加载,初始化数据源后将shardingRule.getShardingDataSourceNames().getDataSourceNames()重设为一个逻辑数据源。

5. SPI机制
上文中两次提到使用ShardingSphere-JDBC提供的SPI机制去替换原有的实现,下面具体介绍一下。
SPI ,全称为 Service Provider Interface,是一种服务发现机制。它基于一个约定来发现服务:即当服务的提供者,提供了服务接口的一种实现之后,需要在ClassPath路径下的META-INF/services目录里同时创建一个以服务接口命名的文件。基于这样一个约定,JDK提供服务实现查找的一个工具类:java.util.ServiceLoader来加载已配置的服务。
这一机制为很多框架扩展提供了可能,比如在Dubbo、JDBC中都使用到了SPI机制。ShardingSphere-JDBC也使用SPI机制来实现框架扩展或组件替换,除了默认的实现外,让用户可以将自定义的实现类加载其中,从而在保持架构完整性与功能稳定性的情况下,满足用户不同场景的实际需求。ShardingSphere-JDBC也基于SPI机制实现了数据脱敏和影子库相关的能力。
为了适配之前的全扫描和伪分片键功能,我们自定义了下面这些类:
ForbidLimitShardingSQLRewriteContextDecorator,禁用分页的改写引擎,替换ShardingSQLRewriteContextDecorator
ForbidLimitPagingShardingResultMergerEngine,禁用分页的归并引擎,替换ShardingResultMergerEngine
ForbidOrWhereClauseShardingConditionEngine,禁用OR,替换WhereClauseShardingConditionEngine
ForbidOrShardingRouteDecorator,禁用OR,替换ShardingRouteDecorator,代码中调用ForbidOrWhereClauseShardingConditionEngine

End
当业务成长到一定规模,随着流量和数据量越来越大,数据库会面临存储容量和性能瓶颈,这时候就要考虑分库分表了。团队之前使用的分库分表中间件自运维困难,于是决定迁移到活跃的开源项目ShardingSphere-JDBC。在迁移的过程中,陆续遇到了数据库连接数暴增、全扫描和伪分片键场景功能不兼容、元数据加载性能差等问题,为了让业务方平滑迁移,我们通过一些不太优雅的方案解决了这些问题。




