资讯详情

资讯详情

Docker+ShardingJDBC+MybatisPlus分库分表实战

1. 这不是“配个Dockerfile”就完事的项目它解决的是高并发场景下数据库的生死线问题你有没有遇到过这样的情况SpringBoot服务刚上线时响应飞快QPS轻松破千可半年后单表数据量涨到2000万行一个简单分页查询从50ms飙到3秒DBA半夜打电话说主库CPU持续98%从库延迟拉到15分钟这不是代码写得烂是架构没扛住——当业务增长撞上单库单表的物理天花板分库分表和读写分离就不再是“可选项”而是“保命项”。而今天这个标题里提到的组合Docker SpringBoot ShardingJDBC MybatisPlus恰恰是一套在生产环境被反复验证、兼顾开发效率与运维弹性的轻量级落地方案。它不依赖昂贵的中间件集群不强制重构所有DAO层更不用动不动就上K8s Operator——而是用容器化封装复杂性用ShardingJDBC做智能路由用MybatisPlus保持CRUD手感让团队在两周内就能把一个单体数据库平稳切分成多个物理库、多个逻辑表同时把写操作锁死在主库、读请求自动打到从库。我带过的三个项目都走这条路某电商订单中心日均写入80万单某SaaS平台租户数据隔离要求严格某IoT设备管理后台每秒接收2万条心跳上报。它们没用TiDB也没上OceanBase就是靠这套组合拳活了下来。如果你正卡在“数据量一上来就崩”的临界点又不想推倒重来那这篇内容就是为你写的——它不讲抽象理论只拆解真实部署中每个环节的取舍、参数背后的血泪教训以及那些文档里绝不会写的“为什么必须这样配”。2. 整体设计思路为什么选这个技术栈而不是其他方案2.1 不是为炫技而是为“可控性”和“渐进式改造”让路很多人一提分库分表就想到Mycat或ShardingSphere-Proxy但实际落地时你会发现Proxy层虽然对应用透明却成了新的单点瓶颈和黑盒。我们曾在一个金融类项目里试过Proxy结果发现SQL解析耗时占到整个请求的40%慢查询日志根本看不出是应用层还是Proxy层的问题排查一次线上抖动要拉上DBA、中间件组、应用组三方开会。而ShardingJDBC作为JDBC驱动层的增强直接嵌在应用进程里所有SQL路由、改写、归并都在内存完成零网络开销。更重要的是——它支持分片策略热插拔。比如初期按用户ID取模分4库8表后期发现热点用户集中可以无缝切换成按用户ID哈希时间范围双维度分片只需改配置、重启服务不用动一行SQL。这种灵活性是任何代理层方案都给不了的。2.2 Docker在这里不是“锦上添花”而是解决环境一致性这个老大难想象一下开发在Mac上用HikariCP连接本地MySQL 8.0测试在CentOS7跑MariaDB 10.3预发环境用Docker Compose启了3个MySQL实例模拟主从生产却是阿里云RDS主从只读地址。光驱动版本兼容性就够喝一壶——MySQL Connector/J 8.0.28在Mac上正常到了CentOS7却报Unknown system variable query_cache_size。而Docker的价值在于把数据库拓扑结构变成代码。我们定义一个docker-compose.yml里面明确声明mysql-master基于mysql:8.0.33镜像挂载自定义my.cnf开启binlog、设置server-id1mysql-slave1同样镜像server-id2replicate-do-dborder_dbsharding-proxy备用虽然本次不用但预留端口方便后续灰度app-serverSpringBoot应用镜像构建时注入application-sharding.yml。这样从开发写代码的第一天起他面对的就是和生产完全一致的数据库拓扑。CI流水线里跑集成测试用的也是同一份docker-compose.yml启动的环境。去年我们有个项目因测试环境没配GTID导致主从切换后数据不一致回滚花了6小时——这种坑Docker能帮你提前踩平。2.3 MybatisPlus不是“为了用而用”它解决了分库分表中最痛的“分页与聚合”问题ShardingJDBC官方文档里有一句大实话“分页查询需谨慎”。为什么因为LIMIT 10000,20这种偏移分页在分库分表下会变成每个分片查10020条再合并后取20条性能灾难。而MybatisPlus的PageT对象配合IPage接口天然支持流式分页和优化分页插件。我们在MybatisPlusConfig里注册PaginationInnerInterceptor它会自动将ORDER BY create_time LIMIT 10000,20重写为WHERE create_time 2023-01-01 LIMIT 20前提是你的分页条件里有索引字段。更关键的是MybatisPlus的LambdaQueryWrapper能保证分片键如user_id在WHERE条件里被正确识别避免全分片广播查询。这点原生Mybatis的XML写法很难做到——你得手动在每个if testuserId ! nullAND user_id #{userId}/if里确保分片键存在稍有遗漏就是性能雪崩。3. 核心细节解析配置文件里的每一行都是踩过坑才定下来的3.1 ShardingJDBC分片规则别迷信“一致性哈希”先搞懂你的数据分布我们常看到教程里直接用ModShardingAlgorithm取模算法但实际业务中取模最大的问题是扩容痛苦。比如按user_id % 4分4库数据均匀但要扩到8库一半数据得迁移。而HashShardingAlgorithm哈希虽能缓解但无法解决热点问题。我们最终采用的是复合分片策略库分片用user_id的MD5值前4位转十进制再对库数取模。例如MD5(100001) b9a1...取b9a1转十进制为47521若4库则47521 % 4 1路由到ds_1。表分片用order_id的最后两位如order_id202310010001取01对表数取模。为什么这么设计因为user_id是强业务主键几乎每个查询都带而order_id是全局唯一但查询频次低。这样按用户查订单时库路由精准只查1个库表路由也精准只查1个表而按订单号查时虽要查全部库但只查1个表IO压力可控。计算过程如下// 库分片逻辑简化版 public class UserIdDatabaseShardingAlgorithm implements StandardShardingAlgorithmLong { Override public String doSharding(CollectionString availableTargetNames, PreciseShardingValueLong shardingValue) { String userIdStr String.valueOf(shardingValue.getValue()); String md5 DigestUtils.md5Hex(userIdStr); int hash Integer.parseInt(md5.substring(0, 4), 16); // 取前4字符转10进制 int dbIndex hash % availableTargetNames.size(); // 假设availableTargetNames[ds_0,ds_1,ds_2,ds_3] return ds_ dbIndex; } }提示MD5前4位足够分散实测100万用户ID哈希后标准差5%比单纯取模更均衡。但千万别用UUID.randomUUID().toString()做分片键——字符串太长哈希碰撞概率飙升。3.2 读写分离配置不是配个master-slave就完事得防“从库延迟陷阱”ShardingJDBC的MasterSlaveDataSource默认策略是“写走主库读走从库”但问题来了如果从库延迟10秒用户刚下单成功立刻刷新订单列表却看不到新订单体验极差。我们的解法是强制读主库的三种场景事务内所有读操作Spring的Transactional注解标记的方法无论是否只读一律走主库。配置里加transactional-read-write-splittingtrue特定方法名前缀如get*ByUserId、list*Latest这类语义上要求强一致的查询在HintManager里手动指定master从库延迟阈值监控通过SHOW SLAVE STATUS查Seconds_Behind_Master当3秒时自动将该从库权重降为0。这需要自定义MasterSlaveLoadBalanceAlgorithm代码里定期调用JDBC查从库状态并更新内存权重表。实际配置片段spring: shardingsphere: rules: - !MASTER_SLAVE name: ds_ms_0 master-data-source-name: ds_0_master slave-data-source-names: [ds_0_slave1, ds_0_slave2] load-balancer-name: round_robin # 但实际用自定义算法 transactional-read-write-splitting: true # 关键3.3 Docker网络与存储别让容器重启后数据库变“裸奔”新手常犯的错docker run -d mysql:8.0结果容器一重启所有数据全丢。我们必须做三件事数据卷持久化为每个MySQL实例创建独立命名卷如docker volume create mysql-master-data挂载到容器内/var/lib/mysql配置文件外置my.cnf不能写在镜像里用-v /host/path/my.cnf:/etc/mysql/conf.d/my.cnf:ro方式挂载且my.cnf里必须显式设置[mysqld] server-id 1 log-bin mysql-bin binlog-format ROW expire_logs_days 7 max_binlog_size 100M容器间网络隔离用docker network create sharding-net创建自定义桥接网络所有MySQL和App容器都连此网络并用服务名如mysql-master互相访问。这样App里JDBC URL写jdbc:mysql://mysql-master:3306/order_db而非172.17.0.2这种易变IP。注意MySQL 8.0默认启用caching_sha2_password认证插件而旧版JDBC驱动不支持。务必在my.cnf里加default_authentication_pluginmysql_native_password否则SpringBoot连不上会报Public Key Retrieval is not allowed。4. 实操过程从零开始搭建可运行的分库分表环境4.1 环境准备5分钟搞定本地验证环境我们不需要先搭生产级集群先用Docker Compose在本机跑通核心链路。创建docker-compose.ymlversion: 3.8 services: mysql-master: image: mysql:8.0.33 container_name: mysql-master restart: always environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: order_db volumes: - ./mysql/master/conf:/etc/mysql/conf.d:ro - ./mysql/master/data:/var/lib/mysql ports: - 3307:3306 networks: - sharding-net mysql-slave1: image: mysql:8.0.33 container_name: mysql-slave1 restart: always environment: MYSQL_ROOT_PASSWORD: root123 volumes: - ./mysql/slave1/conf:/etc/mysql/conf.d:ro - ./mysql/slave1/data:/var/lib/mysql ports: - 3308:3306 depends_on: - mysql-master networks: - sharding-net app-server: build: . container_name: app-server restart: always environment: SPRING_PROFILES_ACTIVE: sharding ports: - 8080:8080 depends_on: - mysql-master - mysql-slave1 networks: - sharding-net networks: sharding-net: driver: bridge关键点depends_on只控制启动顺序不保证MySQL服务就绪。所以App启动类里加PostConstruct方法循环检测mysql-master:3306端口是否通不通则Thread.sleep(1000)重试最多30次./mysql/master/conf/my.cnf和./mysql/slave1/conf/my.cnf内容不同主库开log-bin从库关log-bin但开relay-logApp的Dockerfile里COPY target/*.jar app.jar后加RUN chmod x app.jar否则容器启动报权限错误。4.2 SpringBoot核心配置application-sharding.yml逐行解读这是整个项目的灵魂我们拆解最易错的几处spring: datasource: dynamic: primary: master # 默认数据源名 strict: false # 防止找不到数据源时报错 datasource: master: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://mysql-master:3306/order_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: root123 slave1: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://mysql-slave1:3306/order_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: root123 shardingsphere: props: sql-show: true # 开发期打开看SQL路由日志 query-with-cipher-column: false rules: - !SHARDING tables: t_order: actual-data-nodes: ds_${0..1}.t_order_${0..3} # 2库×4表8个物理表 table-strategy: standard: sharding-column: order_id sharding-algorithm-name: t_order_table_inline database-strategy: standard: sharding-column: user_id sharding-algorithm-name: t_order_database_inline default-database-strategy: none: # 全局默认不分库仅t_order表分 default-table-strategy: none: sharding-algorithms: t_order_database_inline: type: CLASS_BASED props: strategy: standard algorithmClassName: com.example.sharding.UserIdDatabaseShardingAlgorithm t_order_table_inline: type: INLINE props: algorithm-expression: t_order_${order_id % 4} # 简单取模表数固定为4 key-generators: snowflake: type: SNOWFLAKE props: worker-id: 123重点说明actual-data-nodes: ds_${0..1}.t_order_${0..3}这是ShardingJDBC的EL表达式生成8个节点ds_0.t_order_0,ds_0.t_order_1...ds_1.t_order_3。注意${0..1}是闭区间不是{0,1}table-strategy和database-strategy必须同时存在否则分片失效key-generators里worker-id不能为0否则Snowflake生成ID全为0。我们用123是因测试环境单机生产会按机器IP哈希算sql-show: true在开发期必开它会在日志里打印Actual SQL: ds_0 ::: SELECT * FROM t_order_2 WHERE user_id ?这是验证分片是否生效的唯一证据。4.3 MybatisPlus集成让分页和批量插入不翻车MybatisPlus的IService接口默认方法如list()、page()能自动适配分片但有两个深坑批量插入saveBatch(list, 1000)默认会把1000条数据全发到同一个分片造成单点写入瓶颈。必须用InsertBatchSomeColumn自定义插入器按user_id分组每组发往对应分片。代码示例Bean public MybatisPlusPropertiesCustomizer mybatisPlusPropertiesCustomizer() { return properties - { properties.getGlobalConfig().getDbConfig().setInsertStrategy(FieldStrategy.NOT_NULL); }; } // 在Service实现类里 public boolean saveOrderBatch(ListOrder orders) { // 按user_id分组 MapLong, ListOrder grouped orders.stream() .collect(Collectors.groupingBy(Order::getUserId)); for (ListOrder group : grouped.values()) { baseMapper.insertBatch(group); // 此时group内所有order.userId相同路由到同库同表 } return true; }分页查询page(new Page(1, 10), wrapper)在分库分表下会触发COUNT(*)广播查询极慢。我们改用IPageOrder page baseMapper.selectPage(page, wrapper)并在wrapper里强制加上orderBy(create_time, false)利用索引覆盖避免全表扫描。实测1000万数据COUNT(*)要8秒加索引后SELECT只要120ms。4.4 启动与验证三步确认分片真正生效启动容器docker-compose up -d等所有容器状态为healthy插入测试数据用Postman调用POST http://localhost:8080/order/create传{userId:100001,orderId:202310010001,amount:99.9}验证分片效果进入mysql-master容器docker exec -it mysql-master mysql -uroot -proot123 order_db查t_order_1表因202310010001 % 4 1SELECT * FROM t_order_1;—— 应有数据查t_order_0表SELECT * FROM t_order_0;—— 应为空查日志docker logs app-server | grep Actual SQL应看到ds_0 ::: INSERT INTO t_order_1 (...)模拟从库延迟在mysql-slave1里执行STOP SLAVE;然后调用查询接口观察是否自动切到主库日志里应出现master ::: SELECT。实操心得第一次验证时90%的人卡在Actual SQL没打印出来。原因八成是sql-show: true没写在shardingsphere节点下而是错写在spring根节点或者application-sharding.yml没被Spring Profiles激活检查启动日志是否有The following profiles are active: sharding。5. 常见问题与排查技巧实录那些让老手也挠头的“幽灵Bug”5.1 分片键未命中查询变全库扫描CPU瞬间100%现象日志里大量Broadcast SQL: SELECT * FROM t_order WHERE status ?QPS超500时MySQL CPU飙到95%。排查步骤检查wrapper构造QueryWrapperOrder w new QueryWrapper(); w.eq(status, paid);—— 错没带分片键user_id正确写法w.eq(user_id, 100001).eq(status, paid);或用ShardingKey注解标记DTO字段更彻底的方案在ShardingSphere配置里加default-database-strategy: none强制所有表必须显式指定分片策略否则启动报错。根治技巧写个单元测试用ShardingSphereTestUtil模拟SQL解析Test public void testShardingRoute() { String sql SELECT * FROM t_order WHERE user_id 100001 AND status paid; CollectionRouteUnit routeUnits shardingSphereDataSource.getRouteResult(sql); assertEquals(1, routeUnits.size()); // 必须只路由到1个节点 }5.2 时间类型字段乱码create_time存进去是2023-10-01 00:00:00查出来变1970-01-01 08:00:00原因MySQL 8.0的TIMESTAMP类型受time_zone影响而Docker容器默认UTC时区SpringBoot JDBC URL里serverTimezoneAsia/Shanghai只影响JDBC层MySQL服务本身仍是UTC。解决方案在my.cnf里加[mysqld] default-time-zone 08:00在SpringBoot的application-sharding.yml里JDBC URL补充useTimezonetrueurl: jdbc:mysql://mysql-master:3306/order_db?useSSLfalseserverTimezoneAsia/ShanghaiuseTimezonetrueJava实体类用LocalDateTime而非DateMybatisPlus自动处理时区转换。注意useTimezonetrue必须和serverTimezone配对否则报错The server time zone value UTC is unrecognized。5.3 Docker容器内存溢出app-server频繁OOMKilled现象docker ps看到app-server状态为Up 2 minutes (unhealthy)docker logs app-server末尾是Killed process 123 (java) total-vm:4567890kB, anon-rss:1234567kB, file-rss:0kB。根因Java进程在容器里无法感知cgroup内存限制默认按宿主机内存设堆大小。一台32G宿主机Java-Xmx可能设到16G但容器只分配2G必然OOM。解决启动命令加JVM参数java -XX:UseContainerSupport -XX:MaxRAMPercentage75.0 -jar app.jar或在Dockerfile里ENV JAVA_OPTS-XX:UseContainerSupport -XX:MaxRAMPercentage75.0 CMD java $JAVA_OPTS -jar app.jar验证容器内执行java -XX:PrintFlagsFinal -version | grep MaxRAMPercentage应显示75.0。5.4 从库延迟导致数据不一致用户下单后查不到客服电话被打爆现象mysql-slave1的Seconds_Behind_Master长期5秒app-server日志里仍有slave1 ::: SELECT。排查检查MasterSlaveLoadBalanceAlgorithm是否生效在算法类里加log.info(Current slave weight: {}, weights)确认权重没被设为0检查transactional-read-write-splitting是否为true且方法确实被Transactional包裹最致命的坑Spring的Transactional默认只对public方法生效。如果createOrder()是private或package-private事务不开启读操作仍走从库。终极方案在application-sharding.yml里加熔断配置spring: shardingsphere: props: sql-show: false max-connections-size-per-query: 1 # 单查询最大连接数防从库打满 rules: - !MASTER_SLAVE name: ds_ms_0 master-data-source-name: ds_0_master slave-data-source-names: [ds_0_slave1] load-balancer-name: round_robin transactional-read-write-splitting: true # 新增熔断 circuit-breaker: enabled: true max-failures: 3 failure-threshold: 0.5 delay: 60000这样当从库连续3次查询失败如超时自动熔断1分钟所有读请求切主库。5.5 分布式ID重复两个用户同时下单生成了相同的order_id现象数据库唯一索引报Duplicate entry 202310010001 for key PRIMARY。原因Snowflake算法依赖机器时间Docker容器重启后系统时间可能回拨或多个容器worker-id相同。解决方案强制worker-id唯一在Docker Compose里为每个App容器设不同环境变量app-server-1: environment: WORKER_ID: 1 app-server-2: environment: WORKER_ID: 2Java代码里读取Value(${worker-id:1}) private long workerId; Bean public KeyGenerator snowflakeKeyGenerator() { SnowflakeKeyGenerateAlgorithm algorithm new SnowflakeKeyGenerateAlgorithm(); Properties props new Properties(); props.setProperty(worker-id, String.valueOf(workerId)); algorithm.setProps(props); return algorithm; }兜底方案在MySQL表里加UNIQUE KEY idx_order_no (order_no)应用层捕获SQLIntegrityConstraintViolationException重试生成新ID。实操心得我们在线上加了监控告警——当INSERT返回affectedRows0即唯一键冲突时立即推送企业微信告警。三个月内只触发过2次都是因运维误操作导致worker-id重复。6. 性能压测与调优从500QPS到5000QPS的实测记录6.1 基准测试单库单表 vs 分库分表的真实差距我们用JMeter对同一业务接口根据user_id查订单列表做对比场景数据量并发用户平均响应时间95%响应时间错误率单库单表t_order500万行200128ms210ms0%分库分表2库×4表500万行均分20042ms68ms0%分库分表2库×4表500万行1000185ms320ms0.3%关键发现分库分表在200并发时优势明显响应时间降67%但到1000并发时错误率上升——查日志发现是MySQL连接池耗尽。6.2 连接池调优HikariCP的5个致命参数默认HikariCP配置maximum-pool-size10在分库分表下是灾难。因为每个分片都要独立连接池2库×4表8个数据源总连接数达80远超MySQL默认max_connections151。我们最终配置spring: datasource: dynamic: hikari: maximum-pool-size: 20 # 每个数据源最多20连接 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 leak-detection-threshold: 60000 # 60秒未关闭连接即告警maximum-pool-size20经压测20是单数据源最优值再高MySQL线程调度开销剧增leak-detection-threshold60000必须开我们曾因MybatisPlus的LambdaQueryWrapper未关闭Stream导致连接泄漏3小时后连接池耗尽max-lifetime180000030分钟防止MySQL的wait_timeout默认8小时杀死空闲连接造成Connection reset异常。6.3 SQL优化分库分表后索引策略必须重写分库分表后EXPLAIN结果和单库完全不同。我们发现两个高频问题跨分片ORDER BY失效SELECT * FROM t_order ORDER BY create_time DESC LIMIT 10ShardingJDBC会查所有分片再内存排序。解决方案强制加WHERE user_id IN (100001,100002)限定分片或改用create_time分片联合索引失效单库时INDEX idx_user_status (user_id, status)高效但分库后user_id是分片键status是普通字段查询WHERE statuspaid仍要全分片扫描。对策建INDEX idx_status (status)虽不能消除广播但减少IO。提示用shardingsphere-sql-parser工具解析SQL路由路径比肉眼猜准得多。命令java -jar sql-parser-cli.jar SELECT * FROM t_order WHERE user_id100001输出[ds_0.t_order_1]即正确。6.4 容器资源分配CPU和内存的黄金比例在4核8G的测试服务器上我们尝试不同分配app-server:--cpus2 --memory4g→ QPS 3200CPU使用率78%app-server:--cpus3 --memory6g→ QPS 3800但MySQL从库CPU达92%成为瓶颈最终方案app-server:--cpus2.5 --memory4.5gmysql-master:--cpus1.5 --memory3gmysql-slave1:--cpus1 --memory2g。理由Java应用是CPU密集型MySQL是IO密集型资源要按实际负载配。用docker stats实时监控确保无容器%MEM持续90%。7. 生产上线 checklist一份让运维同事对你刮目相看的清单7.1 上线前必须验证的10件事✅分片键覆盖率检查所有查询接口的WHERE条件确保95%以上带user_id✅从库延迟监控Prometheus已接入mysql_slave_status_seconds_behind_master指标告警阈值设为10秒✅连接池健康检查/actuator/health返回hikari: { status: UP, pool: HikariPool-1 }✅SQL路由日志grep Actual SQL logs/app.log | head -20确认无Broadcast SQL✅分布式ID唯一性用SELECT COUNT(*), order_id FROM t_order GROUP BY order_id HAVING COUNT(*) 1查全库结果为空✅事务一致性模拟主库宕机执行INSERT后立刻SELECT验证是否自动切从库且数据可见需transactional-read-write-splittingfalse测试✅Docker重启恢复docker restart app-server mysql-master mysql-slave15分钟内服务自动恢复✅备份策略mysqldump脚本已配置--single-transaction --routines --triggers每天凌晨2点全量备份✅回滚预案application-sharding.yml备份为application-sharding-bak.yml一键切回单库模式✅压测报告JMeter报告已邮件发送至CTO、DBA、运维负责人QPS达标且错误率0.1%。7.2 上线后第一周的盯盘重点第1天紧盯docker stats确认无OOMKilled查app.log确认Actual SQL路由正常第3天分析MySQL慢查询日志pt-query-digest生成报告重点关注Rows_examined1000的SQL第5天用shardingsphere-agent采集SQL执行路径导出route_trace.json验证分片策略无
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →