
简介面向需要应对大数据量存储瓶颈的Java开发者这份RAR压缩包提供了一个基于Sharding-JDBC的分库分表与多数据源切换示例工程聚焦数据路由、分片规则与分布式事务等关键实践。包内共73个文件以Java源码、XML配置、class编译文件为主同时包含JSP页面、SQL脚本及Eclipse工程配置压缩包整体仅66KB便于快速下载与本地运行。已有2068人学习过该资源。通过SSMDemo示例读者可以直观理解多数据源配置、精确与范围等分片策略以及Sharding-JDBC如何通过透明化路由在业务层无感访问不同库表工程内的pom.xml、mybatistest.sql与项目结构也为动手复现和二次改造提供了基础。对正在调研分库分表方案或希望优化数据库架构的中高级开发者具有直接的参考与借鉴价值。1. 分库分表先别急着上中间件多数据源切换才是基本功做后端开发的应该都有这种感觉业务还没火先把分库分表的坑踩了个遍。某公司核心交易系统上线没多久单库单表就扛不住了每天凌晨的批量任务跑完需要将近三个小时主库 CPU 在高峰期长期 90% 以上。当时团队里讨论要不要直接上分布式中间件但考虑到团队规模、运维成本和排障难度最终选择了另一条路:用多数据源切换 分库分表方案先撑住业务增长。这套方案的核心不是中间件而是基于 Spring 的动态数据源路由机制配合分片策略和 MyBatis 的拦截器实现。整套拆下来发现真正考验人的不是分片算法而是数据源切换的边界条件和事务一致性。对正在做分库分表选型或者已经被多数据源搞到头大的从业者来说这篇笔记里的思路、配置和踩坑记录应该能帮你少走不少弯路。2. 动态数据源路由从 ThreadLocal 到 AbstractRoutingDataSource 的完整链路2.1 选型逻辑为什么不用 MyCat 而用应用层路由分库分表市面上主流方案分两派中间件代理层如各类 Proxy 方案和应用层集成方案。我这边最终选择应用层方案的核心原因有两个。第一项目里已有大量基于 MyBatis 的 DAO 层代码迁移到代理层中间件意味着 SQL 语法、事务管理都要适配一遍风险不可控。第二应用层路由可以用 Spring 自带的扩展点实现不需要额外部署独立服务对运维团队来说几乎零负担。这套方案的关键组件就是 AbstractRoutingDataSource它是 Spring 提供的一个抽象类允许我们在运行时动态决定当前线程使用哪个数据源。这里需要先理解 Spring 管理数据源的基本模型。常规的单数据源场景下SqlSessionFactory 绑定一个固定的 DataSource所有的数据库操作都走这同一个连接。AbstractRoutingDataSource 的做法是在这中间加了一层路由它本身是一个代理 DataSource持有多个目标数据源的引用每次获取连接时通过 determineCurrentLookupKey() 方法返回一个 key根据这个 key 从 targetDataSources 中取出真正的 DataSource。这个方法默认返回 null我们要做的就是重写它让它从 ThreadLocal 里取当前线程设定的路由 key。2.2 动手实现自定义注解 AOP 切面 路由数据源三件套最常见的实现方式是三件套组合一个自定义注解用来标记方法需要走哪个库一个 AOP 切面拦截带有注解的方法并往 ThreadLocal 里写入路由 key最后是继承 AbstractRoutingDataSource 的路由数据源。先看路由数据源的实现public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DataSourceContextHolder.getDataSourceKey(); } }DataSourceContextHolder 是一个 ThreadLocal 封装getDataSourceKey() 方法返回的是当前线程绑定的数据源标识。这个标识用什么类型都行一般用字符串。核心逻辑就这么短但注意 key 只能在当前线程内生效异步调用、跨线程池传递时需要特别注意后面避坑章节会专门讲。再写自定义注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DS { String value() default master; }然后是对应的 AOP 切面Aspect Component public class DataSourceAspect { Around(annotation(ds)) public Object switchDataSource(ProceedingJoinPoint joinPoint, DS ds) throws Throwable { String beforeKey DataSourceContextHolder.getDataSourceKey(); try { DataSourceContextHolder.setDataSourceKey(ds.value()); return joinPoint.proceed(); } finally { DataSourceContextHolder.setDataSourceKey(beforeKey); } } }这里有个细节值得讲透finally 块里恢复的不是清除而是恢复成进入切面之前的值。这样做的好处是支持嵌套切换比如 Service A 切到从库调用 Service B 时 B 又切到某个分片库B 结束之后还能回到 A 设定的路由不会因为内层方法的注解把外层路由覆盖成默认值。代码逻辑说明完毕参数层面注解的 value 属性就是路由 key必须和后面配置的数据源标识一致。实际项目中很多人容易把 master 写错成 primary导致切过去找不到数据源直接报错回头查配置才发现大小写不一致这种问题排查起来还挺虐的。2.3 初始化数据源配置类与连接池参数路由数据源本身不持有真实连接它需要知道去哪找真实数据源。这步在配置类里完成Configuration public class DataSourceConfig { Bean Primary public DataSource dynamicDataSource() { MapObject, Object targetDataSources new HashMap(); targetDataSources.put(master, masterDataSource()); targetDataSources.put(slave01, slaveDataSource()); return new DynamicDataSource(); } }这里省略了 masterDataSource() 的具体实现常见做法是单独建一个配置类生产 HikariCP 或者 Druid 的连接池 DataSource。连接池参数不能拍脑袋我一般习惯这样配参数名推荐值说明initialSize5初始化连接数峰值 QPS 不高时够用maxActive50最大连接数按单连接处理 50 QPS 估算minIdle5最小空闲连接防止突发流量时频繁建连maxWait60000获取连接超时时间50ms 之内拿不到基本可以认定池子满了validationQuerySELECT 1心跳检测每次借出连接前校验可用性这个配置在双数据源场景下要预留 30% 的余量。之前有次流量翻倍主库连接池 50 个上限被打满从库链接全部被慢查询拖住整体吞掉了整个应用那之后配连接池我都会先按峰值流量倒推而不是用默认值。3. 分库分表落地分片策略、路由规则和一组见过血的 SQL 写法3.1 分库 vs 分表什么数据进什么库很多刚接触分库分表的人会把这两个概念混为一谈。分库是把不同业务域的数据拆到不同的物理数据库比如把订单库和用户库分开分表是把同一张表的数据按规则拆到多个物理表比如把订单表按用户 ID 哈希到 16 张表里。一般先分库再分表顺序反了会导致一张表散了但库还是单点瓶颈。从实践角度看判断标准很简单连接数是否达到上限、磁盘 IO 是否饱和、单表数据量是否超过 2000 万行。前两个指标对应分库第三个指标对应分表。MySQL 单表超过 2000 万行之后 B 树深度增加查询性能明显劣化这个数字结合索引大小综合来看。项目里订单表在 3000 万行左右明显变慢拆成 16 张表后单表只有 200 万行左右查询响应从 800 毫秒降到 120 毫秒左右效果还是相当明显的。3.2 分片策略设计hash 取模与日期分片的取舍分片策略决定了数据怎么分布也直接决定了查询能不能命中单表。常见的分片策略有两种hash 取模和日期范围分片。订单场景适合用 hash 取模因为订单查询总是带着用户 ID流水日志适合用日期分片因为查询总是带时间范围。hash 取模要特别注意分片数不能随便变。用 user_id 对 16 取模一旦把 16 改 32所有历史数据的取模结果全部变化数据迁移工作量巨大。我一般会建议分片数选 2 的幂方便后面翻倍扩容配合一致性哈希算法里的虚拟节点思路迁移的数据量能控制在一半左右。日期分片则不用考虑这个问题按月建表就行但跨月查询会落到多张表SQL 层面要做 union 或者业务层聚合。实际项目里还混合用了两级路由先按 user_id 哈希选库再按 order_id 哈希选表。这样设计的好处是用户维度聚合查询能路由到同一个库订单维度查询能精准命中单表。代价是写入时要同时计算两个哈希值如果表名和库名的映射关系复杂配置维护成本直线上升这部分后面讲配置时会看到。3.3 路由规则实现拦截器里完成表名替换纯 MyBatis 使用下表名替换可以用 SQL 拦截器实现。这段代码是整套方案里我自己觉得最实用的部分Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class ShardingInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler handler (StatementHandler) invocation.getTarget(); BoundSql boundSql handler.getBoundSql(); String sql boundSql.getSql(); String shardKey ShardingContext.getShardKey(); if (sql.contains(t_order) shardKey ! null) { int tableIndex Math.abs(shardKey.hashCode()) % 16; String newSql sql.replace(t_order, t_order_ tableIndex); // 反射修改 BoundSql 中的 SQL 字段 Field field BoundSql.class.getDeclaredField(sql); field.setAccessible(true); field.set(boundSql, newSql); } return invocation.proceed(); } }拦截器里最难搞的一点是修改 BoundSql 的 SQL 字段只能通过反射因为底层很多属性没有 setter 方法。这段反射代码在不同 MyBatis 版本上略有差异我用的是 3.5.x 系列字段名是 sql。另外注意拦截的是 StatementHandler 的 prepare 方法这是 SQL 编译前最后一个能改 SQL 的机会在它之前改会被参数处理器覆盖。这里的 shardKey 同样来自 ThreadLocal和前面多数据源切换用的不是同一个 ThreadLocal需要单独维护。参数化查询场景下不要用字符串拼接方式去改 SQL拦截器强制替换效率更高同时避免注入风险。这里改的是执行计划层面的 SQL原始 mapper XML 里的语句保持干净可读性和可维护性都更好。4. 多库多表配置管理YAML 结构、数据源分组和灰度切流经验4.1 配置文件的层级划分配置管理的核心思路是把数据源元信息和业务路由规则分开。数据源元信息归 Spring 管路由规则归业务代码管两者不要混在同一个文件里。常见做法是拆两个配置块spring: datasource: dynamic: primary: master datasource: master: url: jdbc:mysql://192.168.1.10:3306/order_db username: readonly_user password: driver-class-name: com.mysql.cj.jdbc.Driver slave01: url: jdbc:mysql://192.168.1.11:3306/order_db username: readonly_user password: driver-class-name: com.mysql.cj.jdbc.Driver配置里的 username 建议单独建只读账号和读写账号避免普通查询误操作线上数据。邮件提醒和监控告警我一般配置成线上账号密码变更时触发通知。密码这里我习惯用环境变量引用而不是写明文${DB_PASSWORD} 这种表达方式在本地调试和线上部署之间可以平滑切换。4.2 分表规则配置化分表规则理应做成可配置的不能因为加一张分表就发一次版本。项目中用了一个简单配置表存分表元数据CREATE TABLE t_shard_config ( id INT PRIMARY KEY AUTO_INCREMENT, table_name VARCHAR(64) NOT NULL COMMENT 逻辑表名, shard_count INT NOT NULL COMMENT 分片数, shard_column VARCHAR(32) NOT NULL COMMENT 分片列, shard_type TINYINT NOT NULL COMMENT 1:hash, 2:date, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );拦截器启动时加载这份配置到本地缓存分片算法根据配置动态计算表名而不写死在代码里。这个设计带来的一个直接好处是扩容时只需更新配置表和迁移数据不需要重启应用。配置变更后通过刷新接口重新加载缓存平滑过渡。当然如果用的是现成的分库分表组件这部分配置会在组件自己的规则文件里配置原理是一样的只是格式不同。4.3 灰度切换数据源的经验刚切多数据源时最忌讳一把梭全量切换。我的习惯是先拿一个小流量的读接口做验证比如某个报表查询接口只读从库观察 CPU、连接数、耗时三个指标。一周没问题再放开写接口。切换动作本身用开关控制配置中心里加一个 route.forceSlave 的布尔开关灰度期间可以随时关掉相当于一颗后悔药。但这颗后悔药也有条件事务内的路由切换在事务开启之后改变路由不会生效所以切换动作必须落在事务边界之外这个细节极容易被忽略踩到的人不在少数。5. 多数据源避坑指南四个翻车现场和对应的排查思路5.1 事务内切换数据源不生效现象Service A 方法加了 Transactional对主库写数据事务内调用另一个 Mapper 查询从库数据从库读到的还是旧数据主从延迟超过 5 秒也读不到新数据。原因Spring 事务管理器在事务开启时已经拿到主库连接整个事务期间复用这个连接路由 key 的改变不会触发重新获取连接。AOP 切面虽然在方法执行前设置了 ThreadLocal但事务拦截器先于切面生效连接早已绑定。解决事务内不要做数据源切换。如果必须切换将读操作拆到事务之外使用 REQUIRES_NEW 传播行为单独开启新事务。还有一种做法是用编程式事务把写操作和查从库的操作彻底分开但代价是放弃声明式事务的简便性。5.2 动态数据源初始化失败但应用启动不报错现象启动日志显示数据源创建成功但第一个请求进来就报 CannotGetJdbcConnectionException提示连接池初始化失败。原因AbstractRoutingDataSource 的 afterPropertiesSet() 方法只校验 key 对应的数据源是否注册不会真实建立连接。数据源本身配置错了比如密码错误、IP 不通在启动阶段感知不到直到第一次真正拿连接才暴露。解决自定义一个 DataSource 初始化检查器在 Bean 初始化完成后主动从每个数据源获取一次连接并执行 SELECT 1校验不过直接 fail-fast。检查器代码很简单一个 ApplicationRunner 实现类里遍历所有数据源做探活失败就抛异常终止启动。这套探活逻辑另外还能顺带监控数据源的连通性为后续告警提供基础数据。5.3 ThreadLocal 忘记清理导致路由串库现象偶发性地出现 A 用户请求写到 B 用户的分片库日志里路由 key 是错的但代码逻辑里根本没有设置过这个 key。原因使用 ThreadLocal 存储路由 key 后请求处理完成没有调用 remove()Tomcat 工作线程被复用后下一个请求读到了残留的路由 key。这种情况最阴的地方在于不是必现只有线程被复用且恰好前一个请求设置了路由才触发。解决切面的 finally 块里调用 DataSourceContextHolder.clear()而不是恢复成 beforeKey。嵌套切换场景用之前写的恢复方式但要保证最外层必须 clear。另外在拦截器入口处先清一次 ThreadLocal 也是好习惯防止脏数据进去。这问题排查时看线程 ID 和路由 key 的对应关系能很快定位。5.4 分片键不在 SQL 的 WHERE 条件里导致全表扫描现象某分表的查询接口响应很慢日志显示单个 SQL 跑了 3 秒以上数据量才几十万行。原因分片键没有出现在 SQL 的 WHERE 条件中拦截器无法判断该路由到哪张分表只能把所有分表都查一遍再合并。这种查询是分库分表方案里最怕遇到的全表扫描 16 张分表的时间远大于单表查询。解决业务 SQL 必须强制带上分片键。如果某些查询确实无法带分片键需要单独建一张映射表例如根据订单号映射用户 ID。映射表本身也要分片通常使用订单号做分片键查询时先查映射表定位用户 ID再按用户 ID 找分表。整个过程会多一次查询但整体耗时可控。开发规范里约束一下所有分表的 Mapper 方法入参必须包含分片键没有分片键的接口不允许上线强制手段比口头要求更管用。6. 压测验证与动态切换的最后一道防线分库分表方案上线前最需要做的一步不是功能测试而是压测。我用的是 JMeter 加自定义 SQL 脚本的方式模拟真实读写比例 7:3压测时长 30 分钟。重点看三个指标P99 延迟、数据库连接数变化、慢查询数量。分库分表之后 P99 如果反而升高多半是路由算法出了问题或者 SQL 没有命中分片键这时候优先检查慢查询日志里的 SQL 语句。连接池参数和索引是压测中最容出问题的两个地方。线程数从 50 加到 200连接池的 maxActive 要同步调整否则等待获取连接的线程会让整个应用的响应时间直线上升。索引方面分片键上的索引肯定不能少但要注意复合索引的顺序比如分片键是 user_id查询条件还有 create_time索引最好是 (user_id, create_time) 而不是反过来因为 MySQL 的索引最左前缀原则决定了后者用不上 user_id 这部分。压测通过后还有最后一道防线需要确认数据源切换的监控。我在关键切换点埋了 Micrometer 指标HttpClient 请求到达时记录当前数据源 key上报到监控系统。这样上线后切到从库的请求量、各分片的请求分布都能实时看到。有一次监控发现 slave01 的 QPS 占比异常高查下去才发现某条 SQL 因为分片键为 null 被路由到了默认数据源而这个默认数据源配置成了 slave01。从那以后我每次上线分库分表相关的改动都会强制走一遍完整的验证流程先检查所有 SQL 是否带分片键再启动时探活所有数据源接着压测看 P99 和连接数曲线最后观察监控上报的路由分布数据四步缺一步都不行。分库分表说难不难说简单也真不简单尤其是多数据源切换这种坑藏得很深的问题靠感觉没有用把每一步做成可验证的才算落地。希望这些经验能帮到你让你在这条路上少踩几个我踩过的坑。本文还有配套的精品资源点击获取