ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot多数据源与分库分表实战:动态路由、分片策略与分布式ID

Spring Boot多数据源与分库分表实战:动态路由、分片策略与分布式ID 简介这是一套基于Spring Boot搭建的数据库分库分表与多数据源整合示例工程面向需要处理大数据量、优化数据库读写性能的Java开发人员适合作为分库分表入门与进阶的参考模板。项目完整演示了水平分表方案——在同一数据库内按一定规则将同一张表的数据拆分到多个表中多数据源接入采用MyBatis-Plus的dynamic-datasource组件分库分表由Sharding-JDBC实现数据库连接池管理使用Alibaba Druid同时集成MyBatis-Plus与Lombok减少样板代码并内置JUnit测试用例便于一键验证各模块效果控制器、服务、Mapper等分层齐全。资源包共162个文件以Java源码、XML映射文件、YAML与Properties配置文件为主也包含编译后的class文件、日志、Maven运行脚本等压缩包整体仅164KB目录结构清晰、轻量紧凑适合直接导入IDE对照学习。目前已有623人浏览学习对正在研究多数据源切换、分库分表落地实践的中高级Spring Boot开发者具有不错的参考价值。1. 多数据源与分库分表单库扛不住之后你需要一套能落地的拆分方案做订单业务的兄弟大概率都有这种经历表里数据过了千万级之后单库单表的写入和查询开始一起变慢DBA给了两个方向——加索引、做缓存但真正到了业务高峰期连接池先被慢查询打满数据库CPU直接飙红。这时候很多人第一反应是上读写分离可读写分离只解决了读压力写入瓶颈依然卡在原点上。真正能扛住业务增长的做法是把多数据源和分库分表放在一起解决主从负责读写分离业务库按维度水平拆分成多库多表再配上分布式ID和动态路由。这篇文章拆的是一套基于Spring Boot的多数据源数据库分库分表落地资源包含从多数据源路由、分片策略、分布式ID到常见踩坑的完整实现。适合正在做订单、用户、流水这类高增长业务的后端从业者以及已经完成读写分离、正准备做水平拆分的团队。2. 多数据源落地方案从DataSource到动态路由2.1 多数据源不是“多配一个连接池”的事Spring Boot里配置多数据源新手最先想到的是写两个DataSource Bean。这个思路本身没问题但实际落地时会卡在三个环节上MyBatis的自动配置、事务管理器、单元测试。spring-boot-starter-jdbc会自动配置一个DataSource当你自定义两个DataSource后自动配置会被覆盖但MyBatis的SqlSessionFactory又只认一个DataSource结果就是启动了但SQL全打到第一个库上连报错都不给你。我一般会在配置类里显式声明两个数据源并把主库标记为Primary。下面这段代码是基础的多数据源Bean定义Configuration public class DataSourceConfig { Bean(name masterDataSource) ConfigurationProperties(prefix spring.datasource.master) Primary public DataSource masterDataSource() { return DataSourceBuilder.create().build(); } Bean(name slaveDataSource) ConfigurationProperties(prefix spring.datasource.slave) public DataSource slaveDataSource() { return DataSourceBuilder.create().build(); } }这里的ConfigurationProperties会读取application.yml里以spring.datasource.master和spring.datasource.slave开头的配置项DataSourceBuilder负责用HikariCP或Druid创建连接池实例。Primary必须加在主库上不加的话Spring注入DataSource时会出现歧义启动直接失败。但这只是第一步光有这两个Bean还不够调用方需要动态切换数据源才能实现读写分离的效果。动态路由的常规做法是继承AbstractRoutingDataSource在运行时根据上下文决定使用哪个数据源。核心逻辑是重写determineCurrentLookupKey方法返回一个key这个key会和目标数据源Map里的key做匹配。配合ThreadLocal存放当前线程的数据源标识再通过AOP切面在方法执行前设置标识、执行后清理标识。2.2 动态数据源路由ThreadLocal加AOP组合动态路由的实现有一个关键点数据源切换必须做到线程隔离否则线程池复用场景下会出现串库——这次请求拿到了上次请求的数据源。ThreadLocal是标准的解决方案每个线程维护自己的数据源key。下面是路由核心类public class DynamicDataSource extends AbstractRoutingDataSource { Override protected Object determineCurrentLookupKey() { return DynamicDataSourceContextHolder.get(); } } public class DynamicDataSourceContextHolder { private static final ThreadLocalString CONTEXT_HOLDER new ThreadLocal(); public static void set(String dsKey) { CONTEXT_HOLDER.set(dsKey); } public static String get() { return CONTEXT_HOLDER.get(); } public static void clear() { CONTEXT_HOLDER.remove(); } }AbstractRoutingDataSource是Spring提供的一个抽象类它内部维护了targetDataSources和defaultTargetDataSource每次getConnection时都会调用determineCurrentLookupKey去决定取哪个数据源。只要把DynamicDataSource注册成MyBatis的DataSource依赖SQL执行时就会自动走路由逻辑。有了路由类还差一个触发机制。我一般用注解加AOPDS标注在Service方法上切面在方法执行前把value写入ThreadLocal方法结束必须清理不然线程池复用的线程会带着旧key跑到下一个请求里Aspect Component public class DataSourceAspect { Around(annotation(com.example.datasource.DS)) public Object switchDataSource(ProceedingJoinPoint joinPoint) throws Throwable { Method method ((MethodSignature) joinPoint.getSignature()).getMethod(); DS ds method.getAnnotation(DS.class); DynamicDataSourceContextHolder.set(ds.value()); try { return joinPoint.proceed(); } finally { DynamicDataSourceContextHolder.clear(); } } }这里的DS注解带一个value属性对应数据源key。事务场景下需要特别注意DataSourceTransactionManager默认在事务开启时就绑定了连接事务方法内部切数据源不会生效这个问题后面会专门讲到。AOP切面要放在事务外层才有效这也是多数据源落地最容易翻车的地方。3. 分库分表策略分片键、分片算法和分布式ID怎么定3.1 先想清楚是纵切还是横切很多团队一上来就想着怎么分表结果拆完发现业务查询全变成跨库聚合。分库分表之前要先区分两个维度垂直拆分的核心是“按业务归属拆字段或拆表”比如把订单主表里的商品快照、物流快照挪到扩展表水平拆分的核心是“按数据量切行”同一个表结构数据分散到多张表。分片方向 | 适用场景 | 拆分形态 | 主要代价 垂直分库 | 订单、用户、商品互相耦合 | 按业务模块拆成独立库 | join变应用层聚合 垂直分表 | 单表字段多大字段拖慢查询 | 主表加扩展表 | 查询需要关联扩展表 水平分库 | 单库连接数打满或磁盘空间不足 | 按分片键取模打到多个库 | 跨库事务、聚合计算变复杂 水平分表 | 单表行数过千万索引膨胀 | 按分片键拆成多张结构一致的表 | 全表扫描、唯一键冲突等问题会暴露出来垂直拆分其实更适合做业务梳理阶段提前规划水平拆分才是数据量上来之后的必经之路。具体拆多少份通常按未来两年的数据增长估算分片数取2的幂次比较稳妥比如16、64、256。取模算法简单直接但要注意分片数定下来之后扩容时需要迁移数据。按时间分片则更适合流水日志类业务按月分表老表归档。3.2 分片键选不好查询直接退化分片键是分库分表里最敏感的参数。选user_id按用户维度聚合的查询就很快选order_id按订单号查询很快。但实际业务往往是C端用户后台查订单、运营后台查订单、财务按时间查订单三个场景各有各的分片键偏好。我的选择逻辑是主查询链路只能有一个分片键其他查询要么走映射表要么接受广播查询。下面是一个基于ShardingSphere的水平分库分表配置核心是inline表达式spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource jdbc-url: jdbc:mysql://192.168.1.10:3306/order_db_0 username: root password: root ds1: type: com.zaxxer.hikari.HikariDataSource jdbc-url: jdbc:mysql://192.168.1.11:3306/order_db_1 username: root password: root sharding: tables: t_order: actual-data-nodes: ds$-{0..1}.t_order_$-{0..1} database-strategy: inline: sharding-column: user_id algorithm-expression: ds$-{user_id % 2} table-strategy: inline: sharding-column: user_id algorithm-expression: t_order_$-{user_id % 2} key-generator: column: order_id type: SNOWFLAKEactual-data-nodes声明了数据节点分布ds$-{0..1}代表ds0、ds1两个库t_order_$-{0..1}代表t_order_0、t_order_1两张表组合起来就是4个物理表。sharding-column指定分片键algorithm-expression里的user_id % 2决定数据落到哪个库和哪张表。这里有个细节分片键必须出现在SQL的WHERE条件里否则ShardingSphere无法路由会把请求广播到所有分片节点上执行。还有取模算法在分片数从2扩容到4时过去的数据分布在旧规则下新数据可能读写错位所以上线前就要把分片数定好。条件允许的话用一致性哈希比取模更平滑但一致性哈希的缺点是范围查询和处理热点数据都更复杂。3.3 分布式ID分片表不能再用自增主键分片表用数据库自增主键是必踩的坑——两张表各自从1自增主键撞车只是时间问题。常见方案是雪花算法生成一个64位long型ID其中1位符号位、41位毫秒时间戳、10位机器ID、12位序列号一毫秒理论上能生成4096个ID。ShardingSphere内置了SNOWFLAKE生成器配置里key-generator那块就是把雪花ID写进order_id列。如果不想引入ShardingSphere自己实现一个雪花ID也不复杂核心代码是位运算public class SnowflakeIdWorker { private final long workerId; private final long datacenterId; private long sequence 0L; private long lastTimestamp -1L; public synchronized long nextId() { long timestamp System.currentTimeMillis(); if (timestamp lastTimestamp) { throw new RuntimeException(时钟回拨拒绝生成ID); } if (timestamp lastTimestamp) { sequence (sequence 1) 4095; if (sequence 0) { timestamp tilNextMillis(lastTimestamp); } } else { sequence 0L; } lastTimestamp timestamp; return ((timestamp - 1288834974657L) 22) | (datacenterId 17) | (workerId 12) | sequence; } }这段代码里workerId和datacenterId分别占5位合起来可以支持32个数据中心和32个节点。sequence是12位同一毫秒内最多4096个ID。时钟回拨会直接抛异常这是雪花算法的硬边界生产环境建议加一个“时钟回拨优先用Redis发号”的兜底逻辑。用ShardingSphere虽然省事但当你的分片键和主键生成器混在一起时排查问题的难度也会上升看日志要分清是路由问题还是ID生成问题。4. 多数据源与分库分表混用避坑五个我踩过的坑4.1 加了Transactional动态数据源切换失效现象Service方法上既有Transactional又有DS但SQL全部打到主库DS像没生效一样。原因Spring事务拦截器优先级高于AOP数据源切面事务在进入方法体前已经拿好了数据库连接此时再切数据源事务管理器绑定的还是旧连接新key根本不参与路由。这个坑在多数据源和分库分表混用时最容易出现——你既要事务又要路由。解决把DS放在事务方法的外层让数据源切换先于事务开启或者干脆事务中不切换数据源主库只负责写从库和分片库查询不走事务。我后来定的规范是写操作走事务但仍保持在同一个数据源内切换读操作不开事务。如果实在需要在事务里多数据源写入就要用分布式事务方案而不是依赖单机DataSourceTransactionManager。4.2 分片键没进WHERE条件广播查询把数据库打爆现象订单查询接口偶发超时监控里看到某个分片库CPU冲到100%SQL日志显示所有分片表都被查了一遍。原因ShardingSphere等分片框架在缺少分片键条件下无法决定路由目标对多个数据节点做广播查询。表面看是慢查询其实是路由退化成了全扫描。解决在SQL中强制带分片键比如查询订单详情时把user_id作为必传参数拼进SQL。如果实际场景里用户只给了order_id就维护一张“订单号到user_id”的映射表先查一次映射表拿到user_id再走分片查询。注意在代码审查阶段就把这个约束落到接口文档里否则后面加需求的人一不注意就写出不带分片键的查询。4.3 分片表自增主键撞车数据错乱查不出来现象导入数据后多张分片表里出现相同主键按ID查询返回多条记录应用层用Map接收直接抛DuplicateKey。原因每张分片表各自维护auto_increment计数器主键全局不唯一。这个问题在单表阶段根本不存在所以很容易被忽略。解决主键生成器替换为雪花算法或全局发号器。已有存量数据时需要写脚本把旧主键替换成全局唯一ID注意关联表的外键也要同步替换这个操作建议在夜里低峰期做。严格来说分片表的主键设计应该在拆表阶段就定好上线后补救成本很高。4.4 动态数据源下Mapper突然全打主库现象某个接口原来走从库代码合并后莫名走了主库但DS的代码没动过。原因SqlSessionFactory自动配置时没把DynamicDataSource注入MyBatis直接用了默认DataSource。这是典型的“配置注册”问题自定义数据源后MyBatis的配置类需要用Qualifier指定。解决自己声明MyBatisConfig把SqlSessionFactoryBean的dataSource属性明确指向DynamicDataSource同时显式声明SqlSessionTemplate。如果项目用了mybatis-plus推荐直接用dynamic-datasource-spring-boot-starter它的底层已经把AbstractRoutingDataSource和SqlSessionFactory串好了不用重复造轮子。4.5 分库分表后连接池被耗尽现象接口延迟飙升日志出现Connection is not available请求线程等待连接超时按小时计。原因多数据源意味着每个数据源有独立的连接池分片后一次查询可能同时占用多个数据源的连接。加上事务内长查询持有连接不释放连接数比单库时代翻几倍。解决按数据源用途设置maxPoolSize——主库写入并发高但量少从库查询多但单条快分片库则按分片数调大。同时把connectionTimeout从默认30秒调低到10秒让慢SQL尽早暴露而不是占着连接不释放。排查时先看HikariPool的active和idle指标再用Slow SQL日志定位是哪条SQL占住连接最久。5. 集成实战把多数据源、分片和分布式ID串成一个可用项目5.1 依赖选型与整体配置把前面几章的内容组装成一个可运行的项目我推荐先用dynamic-datasource-spring-boot-starter管多数据源路由再用ShardingSphere管分片避免两套系统抢数据源。依赖只加必要的几个dependency groupIdcom.baomidou/groupId artifactIddynamic-datasource-spring-boot-starter/artifactId version3.5.2/version /dependency dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.1.0/version /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependencydynamic-datasource这个starter核心价值在于把数据源路由封装成了一层注解比手写AbstractRoutingDataSource更省心。ShardingSphere 5.1.0的分片配置走YAML和Spring Boot属性绑定天然兼容。mybatis-plus用来减少CRUD模板代码尤其在分片表上写条件构造器比XML手写SQL更直观。application.yml里需要区分两类配置dynamic-datasource管主从库shardingsphere管分片库。为了防止两边的数据源配置互相干扰ShardingSphere的数据源名称要避开primary和slave这套命名spring: datasource: dynamic: primary: master strict: false datasource: master: url: jdbc:mysql://192.168.1.10:3306/user_db username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver slave: url: jdbc:mysql://192.168.1.11:3306/user_db username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver shardingsphere: datasource: names: ds0,ds1 ds0: jdbc-url: jdbc:mysql://192.168.1.10:3306/order_db_0 type: com.zaxxer.hikari.HikariDataSource ds1: jdbc-url: jdbc:mysql://192.168.1.11:3306/order_db_1 type: com.zaxxer.hikari.HikariDataSource rules: sharding: tables: t_order: actual-data-nodes: ds$-{0..1}.t_order_$-{0..1} database-strategy: standard: sharding-column: user_id sharding-algorithm-name: db-inline table-strategy: standard: sharding-column: user_id sharding-algorithm-name: table-inline sharding-algorithms: db-inline: type: INLINE props: algorithm-expression: ds$-{user_id % 2} table-inline: type: INLINE props: algorithm-expression: t_order_$-{user_id % 2}区分核心逻辑dynamic-datasource解决的是主从库之间怎么切换ShardingSphere解决的是分片库和分片表之间怎么路由。两者都配置DataSource时Spring Boot会有多条DataSource候选需要确保ShardingSphere的数据源名称和dynamic-datasource的数据源名称没有重复。上面的配置里dynamic用masterslaveshardingsphere用ds0ds1互不冲突。5.2 核心链路实现写入、查询、分页配置就绪后最核心的是Service层写法。写入时生成雪花ID带上分片键user_id查询时强制把user_id放进查询条件分页时必须注意——跨分片的分页本质上是把每片数据各自limit后再归并深度分页性能和单表差很多建议按时间范围限制查询深度。Service public class OrderService { Autowired private OrderMapper orderMapper; DS(master) Transactional(rollbackFor Exception.class) public void createOrder(Order order) { order.setOrderId(SnowflakeIdWorker.nextId()); order.setCreateTime(new Date()); // 分片键必须一起入库否则后续查询无法路由 orderMapper.insert(order); } DS(slave) public Order queryOrderByUser(Long userId, Long orderId) { return orderMapper.selectOne( new LambdaQueryWrapperOrder() .eq(Order::getUserId, userId) .eq(Order::getOrderId, orderId) .last(limit 1)); } }createOrder方法标注了DS(master)同时也带了Transactional这里有个顺序问题DS在外层切面先执行事务拦截器后执行所以事务能正确绑定到master数据源。queryOrderByUser没有开事务用DS(slave)让查询走从库减轻主库压力。LambdaQueryWrapper里第一个条件直接写了userId这就是ShardingSphere用来做分片路由的依据——如果没有这个条件框架会把查询广播到4张分片表上。分页查询的限制条件要提前和业务方约定。跨分片深度分页的代价会呈指数级上升两年前我在一个后台管理列表上吃过一次亏——第100页的数据每次要扫描前面所有分片接口响应从300ms涨到9秒。后面改成只允许查前100页超过就按时间条件缩小范围才算把性能兜住。6. 验证技巧我怎么确认数据源切换和分片真的生效多数据源和分片配置完之后很多人的第一反应是“看起来能跑”但真正的问题在运行期才暴露路由没切、SQL广播、ID撞车。我后来形成了一套强制验证习惯每一层都用日志或数据说话。打开SQL日志是第一步MyBatis的debug日志会打印完整SQLShardingSphere的info日志会打印实际路由结果logging: level: com.example.order.mapper: debug org.apache.shardingsphere: info配置完后执行一次查询日志里会出现这样的关键字Actual SQL: ds0 ::: SELECT * FROM t_order_0 WHERE user_id ?。前面这个ds0 ::: t_order_0就是最直接的证据——它告诉了你最终路由到哪个库的哪张表。如果日志里出现多条Actual SQL说明这次查询广播到了多个节点你需要回头检查SQL里有没有带上分片键。数据源切换的验证用类似思路。在DS切面里临时加一行日志打印当前线程的数据源key然后分别调写接口和读接口确认写入时输出master、查询时输出slave。如果日志全程都是master优先检查事务拦截器和AOP执行顺序这个我在避坑章节里已经详细说过。数据分布验证要写核对脚本分别统计每张分片表的行数SELECT ds0.t_order_0 AS tbl, COUNT(*) FROM ds0.t_order_0 UNION ALL SELECT ds0.t_order_1, COUNT(*) FROM ds0.t_order_1 UNION ALL SELECT ds1.t_order_0, COUNT(*) FROM ds1.t_order_0 UNION ALL SELECT ds1.t_order_1, COUNT(*) FROM ds1.t_order_1;以user_id % 2作为分片规则预期ds0和ds1的行数比例接近1比1。如果某一分片表行数明显偏少说明分片键在写入时没有被正确赋值或者ShardingSphere的路由表达式写错了。这个脚本在分片上线后第一周每天跑一次确认数据路由稳定后再放宽检查频率。压测是最后一道关。用wrk或JMeter对写入接口和查询接口分别跑一遍同时盯着HikariPool的active连接数。写入接口的TPS峰值会受分片数影响——分片越多单库压力越小但连接池的占用总量会上去。查询接口重点看P99延迟跨分片分页查询在压测时最容易暴露性能问题。从那以后我每次上线多数据源或分片变更都会强制走一遍这三件事先开SQL日志看路由再脚本核对分片数据总量最后压一遍写入看连接池曲线。这个习惯帮我挡掉至少三次上线事故希望帮到你。本文还有配套的精品资源点击获取
返回列表