☰
ShardingSphere分库分表实战:连接池、事务与SQL路由避坑指南
2026/10/27 13:01:29 网站建设 项目流程

简介:本资源是一套基于Spring Boot的多数据源与分库分表实战项目,面向Java后端开发者及数据库架构进阶学习者,聚焦高并发场景下数据库水平扩展的核心痛点。项目完整集成Dynamic-Datasource实现多数据源动态路由,采用Sharding-JDBC完成逻辑表到物理分片的透明映射,并通过Druid连接池统一管理数据库连接;同时引入MyBatis-Plus简化CRUD、Lombok减少模板代码,辅以JUnit单元测试保障核心逻辑可靠性。压缩包共162个文件,含115个XML映射文件(定义SQL与分片规则)、14个Java业务类(如OrderController、UserTestServiceImpl等)、6个YML配置(含多源与分片策略)、以及日志、测试脚本和构建文件,整体仅164KB,轻量易读。已有624人学习下载,提供开箱即用的可运行工程结构、清晰的分层模块设计、典型分表场景(如订单、用户)的完整实现链路,以及可直接复用的数据源切换与分片键配置范例。

1. 多数据源 + 数据库分库分表:不是加个注解就能跑通的“高可用幻觉”,而是必须亲手拆开连接池、事务边界和 SQL 路由逻辑的硬核工程

你是不是也见过这样的场景:Spring Boot 项目刚上线时单库扛得住,QPS 300;半年后订单量翻 5 倍,慢 SQL 报警天天弹窗,DBA 在群里发截图说“主库 CPU 98% 持续 2 小时”;技术方案会上有人脱口而出:“上分库分表吧!”——结果一落地,分布式事务报错、跨库 JOIN 直接失效、MyBatis 的@SelectProvider生成的 SQL 在分片键缺失时默默查全库……这不是架构升级,这是把单点故障扩散成多点雪崩。这份资源不是讲 ShardingSphere 官方文档的搬运工,而是一线工程师在模拟项目X中真实踩坑、反复回滚、最终稳定支撑日均 800 万订单写入的完整实践包:它包含可直接运行的 Spring Boot 2.7.x + ShardingSphere-JDBC 5.3.2 多数据源配置模板、基于真实业务字段(user_id + order_no)的分片策略代码、绕过@Transactional传播陷阱的手动事务管理封装、以及最关键的——一份能定位“为什么这条 SQL 没走分片路由”的日志增强补丁。适合正在评估分库分表可行性、已卡在事务一致性或查询路由失败阶段的后端开发者,尤其适合没有 DBA 配合、需自主掌控数据流向的中小团队。


2. 分库分表不是选型游戏,是连接池、SQL 解析与路由规则的三重校准

2.1 为什么必须放弃 Druid + MyBatis 原生组合?连接池与分片代理的底层冲突

ShardingSphere-JDBC 是以 JDBC Driver 形式嵌入应用的轻量级分片中间件,它要求所有数据库连接必须通过其代理 Driver(org.apache.shardingsphere.driver.jdbc.core.datasource.ShardingSphereDataSource)创建。而传统 Druid 连接池(com.alibaba.druid.pool.DruidDataSource)会直接向物理数据库发起连接请求,完全绕过 ShardingSphere 的 SQL 解析与路由逻辑。现象是:你的application.yml里明明配置了sharding: default-database-strategy: ...,但实际执行的 SQL 却直连到ds_0库,且不按分片键路由。

提示:ShardingSphere-JDBC 5.x 后不再兼容 Druid 1.x 的init阶段连接预热,若强行注入 Druid DataSource,会导致ShardingSphereDataSource初始化失败,抛出NullPointerExceptionatDataSourceManager.createDataSource()。

正确做法是彻底弃用 Druid,改用 ShardingSphere 自带的数据源工厂。它内部封装了 HikariCP(默认),并确保每个物理数据源都经过ShardingSphereDataSource统一管理:

# application.yml spring: shardingsphere: mode: Standalone datasource: ds_0: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db_0?useSSL=false&serverTimezone=UTC username: root password: 123456 ds_1: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://127.0.0.1:3306/order_db_1?useSSL=false&serverTimezone=UTC username: root password: 123456 rules: - !SHARDING tables: t_order: actual-data-nodes: ds_${0..1}.t_order_${0..3} table-strategy: standard: sharding-column: order_no sharding-algorithm-name: t_order_table_inline database-strategy: standard: sharding-column: user_id sharding-algorithm-name: t_order_database_inline sharding-algorithms: t_order_database_inline: type: INLINE props: algorithm-expression: ds_${user_id % 2} t_order_table_inline: type: INLINE props: algorithm-expression: t_order_${order_no % 4}

这段配置声明了 2 个物理数据源(ds_0,ds_1),t_order表按user_id分库(模 2)、按order_no分表(模 4),共生成 2×4=8 个实际表节点。关键点在于:所有@Mapper接口的操作,都必须通过ShardingSphereDataSource获取的 Connection 执行。Spring Boot 自动装配会将该数据源注册为DataSourceBean,MyBatis-Spring-Boot-Starter 会自动识别并使用它,无需额外配置SqlSessionFactoryBean.setDataSource()。

2.2 分片键不是随便挑一个字段:从 SQL 解析器视角看user_id和order_no的不可替代性

ShardingSphere 的 SQL 解析器(基于 Antlr4)在执行前会对 SQL 进行词法分析,提取WHERE、INSERT INTO ... VALUES (...)、UPDATE ... SET ... WHERE ...中的分片键值。若分片键未出现在解析上下文中,路由将退化为广播(broadcast)或报错。我们曾用create_time作为分表键,结果发现SELECT * FROM t_order WHERE create_time > '2024-01-01'无法路由——因为create_time是范围查询,ShardingSphere 5.3.2 默认不支持范围分片(需自定义ComplexKeysShardingAlgorithm,复杂度陡增)。

而user_id和order_no是理想候选:

  • user_id是等值查询高频字段(如WHERE user_id = ?),且业务上天然具备数据隔离性(一个用户的所有订单应落在同一库);
  • order_no是全局唯一、无业务含义的字符串(如ORD20240520123456789),可通过截取后缀数字做模运算,避免热点(如Long.parseLong(order_no.substring(10)) % 4)。

在t_order_database_inline算法中,user_id % 2必须保证user_id是数值类型。若数据库中user_id是 VARCHAR,需在分片算法中显式转换:

public final class UserIdDatabaseShardingAlgorithm implements StandardShardingAlgorithm<Comparable<?>> { @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Comparable<?>> shardingValue) { // 强制转 long,避免 NumberFormatException long userId = Long.parseLong(shardingValue.getValue().toString()); return "ds_" + (userId % 2); } // ... 其他方法省略 }

此处availableTargetNames是["ds_0", "ds_1"],shardingValue.getValue()是传入的user_id参数值。若传入null或非数字字符串,Long.parseLong会抛异常,导致整个 SQL 执行失败——这正是我们需要的强校验,而非静默路由到错误库。

2.3 为什么@Transactional在跨库场景下是“定时炸弹”?手动事务管理的必要性

Spring 的@Transactional默认使用DataSourceTransactionManager,它依赖单一DataSource的Connection对象。当 ShardingSphere 路由到多个物理库(如ds_0.t_order_1和ds_1.t_order_2)时,事务管理器只能绑定其中一个Connection,另一个库的操作将脱离事务控制,形成“半提交”状态。

典型翻车场景:下单接口需同时插入t_order和t_order_item,后者按order_id分片。若t_order路由到ds_0,t_order_item路由到ds_1,@Transactional仅对ds_0生效,ds_1的插入成功但ds_0因校验失败回滚,最终产生脏数据。

解决方案是放弃声明式事务,改用 ShardingSphere 提供的ShardingTransactionManager(需启用 XA 或 Seata),或更轻量的“本地事务+补偿”模式。本资源采用后者:封装TransactionTemplate,在业务方法内显式控制每个分片键对应的事务:

@Service public class OrderService { @Resource private JdbcTemplate jdbcTemplate; // 使用 ShardingSphere 注入的 JdbcTemplate public void createOrder(Order order, List<OrderItem> items) { // 步骤1:插入主订单,获取分片后的实际库表名 String dbNode = "ds_" + (order.getUserId() % 2); String tableNode = "t_order_" + (Long.parseLong(order.getOrderNo().substring(10)) % 4); // 步骤2:手动开启事务(针对 ds_0 或 ds_1) TransactionStatus status = transactionManager.getTransaction( new DefaultTransactionDefinition(TransactionDefinition.PROPAGATION_REQUIRED) ); try { // 插入 t_order(路由由 ShardingSphere 自动完成) jdbcTemplate.update("INSERT INTO t_order (...) VALUES (...)", order.getUserId(), order.getOrderNo(), ...); // 插入 t_order_item(需确保 order_id 与 t_order 同库) jdbcTemplate.update("INSERT INTO t_order_item (...) VALUES (...)", order.getOrderId(), ...); transactionManager.commit(status); } catch (Exception e) { transactionManager.rollback(status); throw new RuntimeException("Order creation failed", e); } } }

注意:JdbcTemplate必须是 Spring 容器注入的、由ShardingSphereDataSource创建的实例,否则update()调用不会触发分片路由。transactionManager需配置为DataSourceTransactionManager,且setDataSource()指向ShardingSphereDataSource。


3. 分片策略不是写死的模运算,是业务增长曲线与数据倾斜的动态平衡

3.1 从 2 库 4 表到 4 库 8 表:不停机扩容的三步迁移法

当ds_0库磁盘使用率达 90%,你不能简单地把user_id % 2改成user_id % 4——历史数据仍在原库,新数据却写入新库,查询时user_id=1001(原属ds_0)会被路由到ds_1,查不到记录。真实扩容必须分三阶段:

  1. 双写阶段(Write Both):新老分片规则并存,所有写操作同时发往旧库(ds_0/1)和新库(ds_2/3)。通过 AOP 切面拦截insert方法,根据user_id计算新旧库目标,用JdbcTemplate分别执行。
  2. 数据迁移阶段(Migrate):用 Flink CDC 或自研脚本,将ds_0中user_id % 4 == 2的数据迁至ds_2,ds_1中user_id % 4 == 3的数据迁至ds_3。迁移期间持续双写,确保增量不丢。
  3. 读切换阶段(Read Switch):修改分片算法为user_id % 4,但先将读流量切 10% 到新库,验证查询正确性;无误后逐步提升至 100%,最后停掉双写逻辑。

本资源提供DualWriteAspect.java切面代码,通过@Around("@annotation(org.springframework.transaction.annotation.Transactional)")拦截带事务注解的方法,解析 SQL 中的user_id值,调用shardingAlgorithm.calculateNewTarget()获取新库名,执行二次插入。关键参数:dual-write.enabled=true控制开关,dual-write.delay-millis=100避免主库压力过大。

3.2 分表键选择失误导致的“热点库”:如何用t_order_no替代create_time做冷热分离

某次大促前,运营同学要求“查最近 1 小时订单”,开发同学将create_time设为分表键,并用HOUR(create_time)函数路由。结果大促开始,所有订单集中在t_order_0(对应 HOUR=20),该表锁表超时,QPS 断崖下跌。根本原因是:create_time是时间序列,天然存在写热点。

正确做法是引入业务维度的“冷热分离”。t_order_no作为分表键,本身无热点;再配合 TTL(Time-To-Live)机制,将 3 个月前的旧表归档到历史库。具体实现:

  • 在t_order表增加archive_flag TINYINT DEFAULT 0字段;
  • 每日凌晨执行存储过程,将create_time < DATE_SUB(NOW(), INTERVAL 3 MONTH)的数据更新archive_flag=1,并同步删除物理表;
  • 查询时,应用层判断archive_flag=1则走历史库history_ds,否则走分片库。

本资源的ArchiveScheduler.java包含 Quartz 定时任务,每 5 分钟扫描一次t_order,批量更新archive_flag,并通过ShardingSphere的HintManager强制路由到历史库:

public void archiveOldOrders() { HintManager hintManager = HintManager.getInstance(); hintManager.setDatabaseShardingValue("history_ds"); // 强制指定库 hintManager.setTableShardingValue("t_order_history"); // 强制指定表 try { jdbcTemplate.update("UPDATE t_order SET archive_flag = 1 WHERE create_time < ?", DateUtils.addMonths(new Date(), -3)); } finally { hintManager.close(); // 必须关闭,否则影响后续请求 } }

HintManager是 ShardingSphere 提供的强制路由工具,适用于 DML 和 DDL,但需注意:它基于 ThreadLocal,必须在同一线程内close(),否则会导致后续请求被错误路由。

3.3 分布式 ID 不是雪花算法就万事大吉:order_no的全局唯一与可分片性矛盾解法

雪花算法(Snowflake)生成的order_no是 64 位 long,可直接用于order_no % 4计算。但业务方要求order_no是字符串(如ORD20240520123456789),且前缀ORD+ 时间戳部分需保留可读性。问题来了:字符串无法直接模运算,若用hashCode(),不同 JVM 实例结果不一致,导致同一order_no在不同节点路由到不同表。

解法是提取字符串中的数字部分做哈希。本资源采用MurmurHash3(比String.hashCode()更均匀):

public class OrderNoShardingAlgorithm implements StandardShardingAlgorithm<String> { @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<String> shardingValue) { String orderNo = shardingValue.getValue(); // 提取纯数字:ORD20240520123456789 → 20240520123456789 String digits = orderNo.replaceAll("\\D+", ""); if (digits.isEmpty()) { throw new IllegalArgumentException("Invalid order_no: " + orderNo); } // MurmurHash3 生成 64 位 hash,取低 32 位避免负数 int hash = (int) MurmurHash3.murmurhash3_x86_32(digits.getBytes(), 0, digits.length(), 0); int tableIndex = Math.abs(hash) % 4; // 4 张分表 return "t_order_" + tableIndex; } }

MurmurHash3是 ShardingSphere 内置的哈希算法,保证相同字符串在任何环境生成相同 hash 值。Math.abs(hash) % 4避免负数取模结果为负(Java 中-5 % 4 == -1),确保表名始终为t_order_0~t_order_3。


4. 避坑:那些让团队加班到凌晨三点的分库分表血泪现场

4.1 现象:SQL 执行成功,但日志显示Actual SQL: ds_0.t_order_0,而预期是ds_1.t_order_2

原因:分片键未参与 SQL 构建。例如 MyBatis 的@Select("SELECT * FROM t_order")未带WHERE条件,或@Insert的#{userId}参数名与分片策略中配置的sharding-column: user_id不一致(少了个下划线)。ShardingSphere 在无分片键时默认路由到第一个数据源(ds_0)的第一个表(t_order_0)。
解决:开启 ShardingSphere SQL 日志,设置props.sql-show=true,检查日志中Logic SQL是否包含分片键条件;确认@Param("userId")与 XML 中parameterType="map"的 key 名严格匹配。

4.2 现象:SELECT COUNT(*) FROM t_order WHERE user_id = ?返回结果比实际少

原因:COUNT(*)是聚合函数,ShardingSphere 需将各分片结果汇总。若未配置sql-show=true,日志中看不到Actual SQL的广播执行过程;更隐蔽的是,当某个分片库宕机,ShardingSphere 默认跳过该分片,只返回其他分片的COUNT和,导致总数偏小。
解决:在application.yml中添加props.check-table-metadata-enabled=true,启动时校验所有分片表结构一致性;生产环境必须配置props.executor-size=16(线程池大小),避免因线程不足导致分片查询超时被跳过。

4.3 现象:@Transactional方法内调用另一个@Transactional方法,子方法事务不生效

原因:Spring 事务基于代理(Proxy),内部方法调用(this.methodB())不经过代理对象,事务注解失效。当methodA()插入t_order,methodB()插入t_order_item,两者分片不同,methodB()的操作脱离事务。
解决:将methodB()提取为独立 Service,并通过@Autowired注入自身,用this.serviceB.methodB()调用;或改用TransactionTemplate手动控制子事务边界。

4.4 现象:INSERT INTO t_order SELECT ... FROM t_order_item报错Cannot route any table for table 't_order_item'

原因:ShardingSphere 5.3.2 不支持INSERT ... SELECT跨分片表。SELECT的t_order_item表按order_id分片,但INSERT的t_order按user_id分片,两者的分片键不匹配,SQL 解析器无法确定目标分片。
解决:拆分为两步:先SELECT获取数据到内存,再循环INSERT。虽牺牲性能,但保证数据一致性。本资源的BatchOrderImporter.java提供分页SELECT+ 批量INSERT模板,每批 100 条,避免 OOM。

4.5 现象:应用启动时报java.lang.NoClassDefFoundError: org/apache/shardingsphere/infra/route/context/RouteContext

原因:ShardingSphere-JDBC 5.3.2 与 Spring Boot 2.7.x 的spring-boot-starter-jdbc版本冲突。ShardingSphere 依赖shardingsphere-jdbc-core-spring-boot-starter,若项目中又引入了spring-boot-starter-data-jpa,其传递依赖的hibernate-core会加载旧版javax.persistence,与 ShardingSphere 的jakarta.persistence冲突。
解决:在pom.xml中排除hibernate-core的javax.persistence依赖,并强制指定shardingsphere-jdbc-core-spring-boot-starter版本:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> <exclusions> <exclusion> <groupId>org.hibernate</groupId> <artifactId>hibernate-core</artifactId> </exclusion> </exclusions> </dependency> <dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.3.2</version> </dependency>

5. 日志即真相:用三行配置打开 ShardingSphere 的黑匣子,让每次 SQL 路由都可追溯

5.1 开启 SQL 解析与路由全链路日志:不只是Logic SQL,更要Actual SQL和Route Units

ShardingSphere 默认日志级别为INFO,仅输出Logic SQL(逻辑 SQL)和简单路由结果。要真正定位问题,必须开启DEBUG级别,并配置sql-show和executor相关属性。本资源的logback-spring.xml已预置关键配置:

<!-- logback-spring.xml --> <logger name="org.apache.shardingsphere" level="DEBUG" additivity="false"> <appender-ref ref="CONSOLE"/> </logger> <logger name="org.apache.shardingsphere.infra.route.engine.impl.StandardRoutingEngine" level="DEBUG" additivity="false"> <appender-ref ref="CONSOLE"/> </logger>

同时在application.yml中启用:

spring: shardingsphere: props: sql-show: true # 显示 Logic SQL 和 Actual SQL executor-size: 16 # 设置线程池大小,避免路由超时 check-table-metadata-enabled: true # 启动时校验分片表结构

开启后,控制台将输出类似内容:

[INFO] Logic SQL: SELECT * FROM t_order WHERE user_id = ? AND order_no = ? [INFO] SQLStatement: SelectStatementContext [DEBUG] Route context: RouteContext{routeUnits=[RouteUnit{dataSourceName='ds_1', tableUnit=TableUnit{logicTableName='t_order', actualTableName='t_order_2'}}]} [INFO] Actual SQL: ds_1 ::: SELECT * FROM t_order_2 WHERE user_id = 1001 AND order_no = 'ORD20240520123456789'

这里RouteContext明确告诉你:user_id=1001路由到ds_1,order_no的哈希值决定表为t_order_2。若RouteUnits为空,则说明分片键未被识别;若dataSourceName与预期不符,检查user_id % 2计算是否正确。

5.2 自定义日志增强:给每条 SQL 打上“请求 ID”和“分片键值”水印

默认日志无法关联业务请求。我们在ShardingSphere的SQLExecutionHook扩展点注入自定义钩子,在 SQL 执行前后打印上下文:

@Component public class TraceableSQLExecutionHook implements SQLExecutionHook { private static final Logger LOGGER = LoggerFactory.getLogger(TraceableSQLExecutionHook.class); @Override public void beforeExecute(String sql, List<Object> parameters, String dataSourceName, String actualTableName) { String traceId = MDC.get("traceId"); String shardKey = getShardKeyFromParameters(parameters); // 从 parameters 提取 user_id/order_no LOGGER.debug("[TRACE {}] Before execute: {} | Params: {} | Target: {}.{}", traceId, sql, parameters, dataSourceName, actualTableName); } private String getShardKeyFromParameters(List<Object> params) { if (params.size() >= 2 && params.get(0) instanceof Number) { return "user_id=" + params.get(0); } return "unknown"; } }

配合 Spring Cloud Sleuth 的MDC,每条 SQL 日志自动携带traceId,可在 ELK 中按traceId聚合整个请求的全部 SQL 路由路径,快速定位“为什么这个请求查不到数据”。

5.3 用HintManager做灰度验证:不改代码,临时强制路由到新分片

上线新分片策略前,需小流量验证。HintManager可在不修改业务代码的情况下,通过 HTTP Header 注入路由指令。我们在网关层(如 Spring Cloud Gateway)添加过滤器:

@Component public class HintRoutingFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String hintDb = exchange.getRequest().getHeaders().getFirst("X-Sharding-DB"); String hintTable = exchange.getRequest().getHeaders().getFirst("X-Sharding-TABLE"); if (hintDb != null || hintTable != null) { HintManager hintManager = HintManager.getInstance(); if (hintDb != null) hintManager.setDatabaseShardingValue(hintDb); if (hintTable != null) hintManager.setTableShardingValue(hintTable); } return chain.filter(exchange); } }

测试时发送请求:curl -H "X-Sharding-DB: ds_2" -H "X-Sharding-TABLE: t_order_5" http://api/order/1001,即可强制将user_id=1001的请求路由到ds_2.t_order_5,验证新分片逻辑是否正确,无需发布新版本。

从那以后我每次上线分片策略变更,都强制走一遍HintManager灰度验证 + 全链路日志比对,哪怕多花 2 小时。因为线上修一个分片路由 bug 的成本,远高于提前 2 小时发现它。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询