1. 这不是简单的容器化部署,而是一次对数据访问层的重新设计
你有没有遇到过这样的场景:一个SpringBoot服务刚上线时响应飞快,QPS轻松破千;半年后,单库单表数据量涨到3000万行,慢查询开始频繁报警,DBA在群里发截图说“连接池快被打满了”,运维同事半夜打电话问“能不能先扩容从库”——但扩容之后,主从延迟又成了新瓶颈。这时候,单纯把应用丢进Docker里打个包、起个容器,已经解决不了问题了。真正卡脖子的,是数据访问层的设计逻辑。
我最近在一个模拟项目X中,就完整走了一遍从“单体数据库直连”到“Docker容器化+ShardingJDBC分库分表+MybatisPlus读写分离”的演进过程。这不是教科书式的Demo跑通,而是真实面对2TB历史订单数据、日均写入80万条记录、读请求占比73%的业务压力下,一步步调参、压测、回滚、再验证的实操记录。整个过程的核心,不是“怎么让Docker跑起来”,而是“如何让ShardingJDBC在容器环境下稳定接管SQL路由,同时不破坏MybatisPlus的动态SQL能力,更不能让读写分离策略在高并发下出现脏读”。
关键词其实就三个:Docker容器编排稳定性、ShardingJDBC分片规则可维护性、MybatisPlus与读写分离的兼容边界。它们共同构成了一条隐性技术链路——容器只是载体,分库分表是手段,读写分离是策略,而最终目标,是让数据访问层具备可伸缩、可观测、可灰度的能力。很多人卡在第一步:以为只要application.yml里配好sharding.jdbc.config.xxx,再docker-compose up -d,就万事大吉。结果上线三天,发现分片键传参丢失、Hint强制路由失效、从库查不到刚写入的数据……这些都不是配置语法错误,而是对ShardingJDBC底层执行器模型、MybatisPlus拦截器链顺序、Docker网络命名空间隔离特性的理解偏差。
所以这篇内容,不讲Docker基础命令,不列ShardingJDBC所有配置项,也不复述MybatisPlus的CRUD写法。我要带你拆解的是:当这三者在Linux容器中真正协同工作时,SQL语句从Mapper接口发出,到最终落在哪个物理库的哪张物理表上,中间到底经历了多少次上下文切换、参数透传和线程绑定?每一次切换,都可能成为性能拐点或一致性断点。接下来的内容,全部基于这个视角展开。
2. Docker环境不是“黑盒”,而是必须显式声明的执行上下文
很多团队把Docker当成“高级zip包”——本地IDE能跑,就直接mvn package生成jar,扔进FROM openjdk:11-jre-slim的镜像里,EXPOSE 8080,CMD ["java","-jar","app.jar"]。这种做法在单体架构下勉强可用,但一旦引入ShardingJDBC,就会暴露三个致命盲区:JVM参数与容器内存限制的错位、时区与字符集的隐式继承、网络DNS解析策略的不可控。
先看第一个问题:JVM堆内存。你在application.yml里写了-Xms2g -Xmx2g,但Docker run时没加--memory=4g,或者用了-m 2g却忘了--memory-swap=2g。结果是什么?JVM看到宿主机有64G内存,自信地把元空间(Metaspace)设到512M,而容器cgroup实际只分配了2G。当ShardingJDBC加载大量分片规则和数据源配置时,元空间快速膨胀,触发Full GC,接着就是STW时间飙升。我们实测过:同样压测脚本,在未限制内存的容器里平均RT 42ms,在正确设置--memory=3g --memory-swap=3g --oom-kill-disable=false的容器里,RT稳定在28ms以内,且无OOMKilled事件。这不是玄学,是cgroup v1/v2对JVM内存可见性的硬约束。
第二个盲区是时区。ShardingJDBC的StandardTimestampKeyGenerator默认用System.currentTimeMillis()生成分片键,而MybatisPlus的@TableField(fill = FieldFill.INSERT)注解在插入时会调用new Date()。如果容器镜像基于alpine:latest构建,其默认时区是UTC,而你的开发机是CST。结果就是:同一毫秒内生成的分片键,在不同节点上被路由到不同库——因为sharding-column是create_time,而create_time字段值在UTC和CST下解析出的日期字符串完全不同。解决方案不是改代码,而是在Dockerfile里显式声明:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone并且在SpringBoot启动参数中追加-Duser.timezone=GMT+8。注意,这两个动作缺一不可:前者影响Linux系统级时间函数,后者影响JVM内部时区感知。
第三个常被忽略的是DNS。ShardingJDBC配置多个数据源时,通常用域名如mysql-master.internal、mysql-slave-01.internal。在Docker Compose默认网络中,这些域名由内置DNS服务解析。但如果你启用了自定义网络驱动(比如macvlan),或者在Kubernetes中使用CoreDNS,解析超时阈值就变得关键。ShardingJDBC初始化数据源时,默认DNS超时是5秒,超时后直接抛SQLException。我们在某次灰度发布中发现,新Pod启动后前3分钟大量报错Cannot resolve hostname mysql-slave-01.internal,根源就是CoreDNS的ndots:5配置导致搜索域过多。最终方案是在docker-compose.yml中为应用服务显式配置:
services: app: dns: - 10.10.10.10 # 内部DNS服务器IP dns_search: - internal并配合ShardingJDBC的props.sql-show=true开启SQL日志,第一时间定位是DNS还是连接池问题。
提示:不要依赖
docker exec -it app sh进去手动改/etc/hosts。容器是不可变基础设施,所有环境变量、时区、DNS配置必须固化在镜像构建阶段或编排文件中。临时修改只会让你在下次重建容器时重复踩坑。
3. ShardingJDBC的分片规则,本质是运行时SQL重写的决策树
ShardingJDBC不是代理层,它工作在JDBC Driver之上、MybatisPlus Executor之下。这意味着它不解析原始SQL文本,而是拦截PreparedStatement#executeQuery()调用,拿到BoundSql对象后,根据分片键值、分片算法、数据源映射关系,动态计算出目标物理库和物理表。这个过程,就是一场精密的“运行时决策树遍历”。
以最常见的order_id分片为例。假设你配置了sharding.jdbc.config.sharding.tables.t_order.actual-data-nodes=ds$->{0..1}.t_order$->{0..3},即2个数据源、每个数据源4张分表。ShardingJDBC会预生成16个物理表路径。但真正决定某条INSERT语句落到t_order_2还是t_order_3的,不是SQL本身,而是order_id参数的取值。这里的关键在于:分片键必须出现在SQL的WHERE条件或INSERT VALUES子句中,且不能被函数包裹。比如WHERE order_id = ?可以路由,WHERE SUBSTR(order_id, 1, 8) = ?则无法识别——因为ShardingJDBC的解析器不支持函数提取。
更隐蔽的问题是分片键的类型转换。MybatisPlus在处理Long类型的order_id时,会自动调用PreparedStatement.setLong(1, value)。但如果前端传参是字符串"1234567890123456789",而你的实体类字段是Long,MybatisPlus会尝试Long.parseLong(),此时若超出Long.MAX_VALUE(9223372036854775807),就会抛NumberFormatException。而这个异常发生在ShardingJDBC分片计算之前,导致整个SQL执行链路中断。我们的解决方案是在Controller层增加DTO校验:
public class OrderDTO { @Min(value = 1L, message = "订单ID不能小于1") @Max(value = 9223372036854775807L, message = "订单ID超出Long范围") private Long orderId; }而不是寄希望于ShardingJDBC兜底。
另一个高频陷阱是IN语句的分片路由。当执行SELECT * FROM t_order WHERE order_id IN (?, ?, ?)时,ShardingJDBC会遍历所有参数,分别计算每个order_id对应的分片位置,然后合并去重,生成多个真实SQL。比如参数是(1001, 2002, 3003),分片算法是order_id % 4,那么会路由到t_order_1,t_order_2,t_order_3三张表。但如果IN列表超过1000个元素,ShardingJDBC默认会拒绝执行(可通过props.sql-show=true观察日志中的Too many parameters in IN clause警告)。此时必须调整配置:
sharding: props: sql-show: true check-table-metadata-enabled: false # 关闭元数据检查,提升IN路由性能并配合业务层做分批查询。
最值得深挖的是Hint强制路由机制。当需要跨分片聚合统计时(比如查全量订单总数),ShardingJDBC提供HintManager:
HintManager hintManager = HintManager.getInstance(); hintManager.addDatabaseShardingValue("t_order", "ds0"); hintManager.addTableShardingValue("t_order", "t_order_0"); // 执行查询 List<Order> orders = orderMapper.selectList(null); hintManager.close();但这里有个致命细节:HintManager是ThreadLocal实现,必须在同一线程内完成add操作和SQL执行。如果你在WebFlux响应式编程中使用Mono.defer(),或者在Dubbo异步回调里调用,Hint就会失效。我们曾因此在线上查出“明明指定了ds0,却返回了ds1的数据”的诡异现象。根本原因是:Dubbo的Consumer端线程池和Provider端线程池不是同一个ThreadLocal上下文。解决方案是改用ShardingHintDataSource,通过JDBC URL参数传递Hint:
jdbc:mysql://localhost:3306/t_order?sharding-hint-database=ds0&sharding-hint-table=t_order_0注意:Hint不是银弹。它绕过了分片规则,意味着你需要自己保证数据一致性。比如在读写分离场景下,Hint强制走主库查询,但此时主库可能还未同步完从库的最新数据,业务上是否能接受这种短暂不一致?这需要和产品团队明确SLA。
4. MybatisPlus与读写分离的共生边界:拦截器链的时序战争
MybatisPlus的AutoFillHandler、MetaObjectHandler、PaginationInterceptor,和ShardingJDBC的ShardingSphereDataSource、MasterSlaveDataSource,共同构成了一个复杂的拦截器链。它们的执行顺序,直接决定了读写分离能否生效、自动填充字段是否正确、分页查询是否跨库。
我们先看最基础的读写分离配置:
sharding: master-slave-rules: ds0: master-data-source-name: ds0_master slave-data-source-names: ds0_slave_0, ds0_slave_1 load-balance-algorithm-type: ROUND_ROBIN这个配置看似简单,但背后隐藏着两个关键判断时机:SQL类型判断和事务上下文判断。
ShardingJDBC判断SQL是否为写操作,依据的是SQLStatement的getType()方法返回值。INSERT/UPDATE/DELETE/REPLACE为写,SELECT为读。但MybatisPlus的LambdaQueryWrapper在构造SELECT语句时,如果调用了.last("FOR UPDATE"),这个FOR UPDATE会被ShardingJDBC的SQL解析器识别为写操作,强制路由到主库。这是正确的,因为SELECT ... FOR UPDATE本质是写锁。但问题来了:如果你在Service层用@Transactional标注了方法,MybatisPlus的PaginationInterceptor会在SQL末尾拼接LIMIT ?, ?,而ShardingJDBC的MasterSlaveDataSource在解析时,会把带LIMIT的SELECT仍视为读操作——除非你显式配置了master-slave-rules.ds0.props.sql-comment-parse-enabled=true,让它能识别/*+ FORCE_MASTER */这类Hint注释。
更棘手的是MetaObjectHandler的执行时机。MybatisPlus在INSERT前会调用insertFill()填充create_time、create_by等字段。这个填充发生在Executor#doUpdate()之前,而ShardingJDBC的分片计算发生在PreparedStatement#executeUpdate()之前。所以create_time的值,会参与分片键计算吗?答案取决于你的分片算法。如果分片键是create_time,那么是的;如果是order_id,则无关。但如果你的MetaObjectHandler里写了metaObject.setValue("create_time", LocalDateTime.now()),而数据库字段是DATETIME类型,MySQL 5.6+默认精度是秒,那么LocalDateTime.now()返回的毫秒级时间,在存入数据库时会被截断。当这个截断后的时间作为分片键时,会导致大量数据集中在同一张分表(因为毫秒被抹平,只剩秒级,哈希碰撞概率激增)。解决方案是统一用Instant.now().truncatedTo(ChronoUnit.SECONDS)生成时间戳,并在分片算法中做相同处理。
PaginationInterceptor的坑在于跨库分页。当执行SELECT * FROM t_order ORDER BY create_time DESC LIMIT 20, 10时,ShardingJDBC会向所有匹配的物理库发送相同SQL,然后在内存中合并结果、排序、截取。如果分片数是8,每库返回30条,内存中就要处理240条数据,再排序取第20-30条。这在数据量大时必然OOM。我们实测过:当单库数据超500万,分页偏移量大于10万时,应用容器RSS内存瞬间突破3G。根本解法是放弃LIMIT,改用游标分页(Cursor Pagination):
// 前一页最后一条记录的create_time QueryWrapper<Order> wrapper = new QueryWrapper<>(); wrapper.gt("create_time", lastCreateTime); wrapper.orderByDesc("create_time"); wrapper.last("LIMIT 10");这样ShardingJDBC只需向每个分片发送1条SQL,结果集天然有序,无需内存合并。
实操心得:在application.yml中开启
sharding.props.sql-show=true和mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl,双日志对照查看。你会清晰看到:MybatisPlus生成的SQL、ShardingJDBC重写后的物理SQL、最终执行的JDBC URL。这是排查读写分离失效的第一现场。
5. 容器化部署的终极验证:用混沌工程检验数据链路韧性
配置写完、镜像打好、docker-compose up起来,只是万里长征第一步。真正的考验,在于当网络抖动、磁盘IO飙升、DNS短暂不可达时,你的分库分表+读写分离链路是否依然可靠?我们采用混沌工程思路,设计了四层验证:
第一层:单点故障注入。用docker pause app暂停应用容器,观察30秒内Nginx是否将流量切到备用节点;恢复后,ShardingJDBC的连接池是否自动重建(需配置maxLifetime=1800000,避免陈旧连接)。
第二层:网络分区模拟。用tc命令在宿主机上对应用容器添加网络延迟:
# 对容器eth0网卡添加100ms延迟,10%丢包 tc qdisc add dev eth0 root netem delay 100ms 20ms distribution normal loss 10%此时观察ShardingJDBC日志:MasterSlaveDataSource是否在3次重试后自动降级到从库(需配置master-slave-rules.ds0.props.max-retry-count=3);MybatisPlus的@Transactional方法是否因超时回滚,而非卡死线程。
第三层:数据源熔断测试。手动停掉mysql-slave-01容器,验证ShardingJDBC是否将读流量100%切到mysql-slave-02;再停掉mysql-master,验证写操作是否抛出No available master data source异常,而非无限重试。
第四层:混合故障压测。用JMeter同时发起:
- 500线程持续INSERT(写主库)
- 1000线程随机SELECT(读从库)
- 50线程执行
SELECT COUNT(*) FROM t_order(跨库聚合)
在上述tc网络故障下,持续运行30分钟。关键指标监控:
- ShardingJDBC的
sharding-sphere-metrics埋点中sharding-sphere.metrics.datasource.active-connections是否稳定 - 应用JVM的
java.lang:type=Threading中ThreadCount是否低于500(避免线程泄漏) - MySQL从库的
Seconds_Behind_Master是否始终<1秒
我们发现一个关键规律:当master-slave-rules.ds0.props.load-balance-algorithm-type=ROUND_ROBIN时,从库负载不均衡(因为ROUND_ROBIN不感知从库当前负载);换成RANDOM后,CPU使用率标准差下降62%。但RANDOM在故障转移时,无法保证流量均匀切走。最终采用自定义算法:
public class WeightedRandomLoadBalanceAlgorithm implements MasterSlaveLoadBalanceAlgorithm { @Override public String getDataSource(String name, String masterDataSourceName, List<String> slaveDataSourceNames) { // 根据slaveDataSourceNames对应的Prometheus指标,选择CPU<60%且延迟<5ms的从库 return selectByMetrics(slaveDataSourceNames); } }最后分享一个血泪教训:不要在Docker Compose中用
restart: always重启应用容器。当ShardingJDBC初始化失败(比如DNS解析超时),容器会无限重启,产生海量日志,迅速占满宿主机磁盘。正确做法是restart: on-failure:3,配合健康检查:
healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] interval: 30s timeout: 10s retries: 3 start_period: 40s6. 生产就绪 checklist:从配置到监控的12个必检项
经过数十次线上迭代,我们沉淀出一份生产就绪清单。它不追求理论完备,只聚焦那些“不检查就一定会出事”的硬性项。每一项都对应过真实故障:
JVM参数与容器内存限制匹配:
-Xms和-Xmx之和 ≤--memory的80%,预留20%给元空间、直接内存、线程栈。例如--memory=4g,则-Xms2g -Xmx2g是安全上限。时区与字符集显式声明:Dockerfile中
ENV TZ=Asia/Shanghai+RUN ln -sf ...+ SpringBoot启动参数-Duser.timezone=GMT+8 -Dfile.encoding=UTF-8,三者缺一不可。ShardingJDBC SQL日志开关:
sharding.props.sql-show=true必须开启,但仅限预发环境。生产环境用sharding.props.sql-simple=true降低日志体积。分片键非空校验:在MybatisPlus的
BaseMapper接口方法上,用@Param明确标注分片键参数名,避免@Select("SELECT * FROM t_order WHERE order_id = #{id}")中id与实体字段名不一致导致路由失败。读写分离的Hint白名单:在
MasterSlaveRuleConfiguration中配置hint-enabled=true,并定义HintManager的白名单方法,防止业务代码误用Hint导致主库压力过大。MybatisPlus分页插件版本锁定:
mybatis-plus-boot-starter必须与sharding-jdbc-spring-boot-starter版本严格匹配。我们踩过坑:MP 3.4.3.4 + ShardingSphere-JDBC 4.1.1,分页SQL被重写两次,生成了非法SQL。连接池最大活跃连接数≤数据库最大连接数×0.7:HikariCP的
maximumPoolSize要留出余量给其他服务。例如MySQLmax_connections=1000,则应用侧设为700。分片算法幂等性验证:编写单元测试,对同一分片键输入,多次调用
shardingAlgorithm.doSharding(),确保返回相同分片值。特别注意负数、NULL、超长字符串的处理。DNS解析超时配置:在
application.yml中显式设置spring.cloud.inetutils.timeout-seconds=3,避免ShardingJDBC初始化时因DNS卡住。健康检查端点暴露:
management.endpoints.web.exposure.include=health,metrics,prometheus,shardingsphere,其中shardingsphere端点可查看实时数据源状态。慢SQL告警阈值:在ShardingJDBC中配置
props.sql-show=true后,用ELK收集ShardingSphere-SQL日志,设置告警规则:duration > 1000ms AND sql LIKE 'SELECT%'。备份与回滚预案:每次上线前,用
mysqldump --single-transaction导出分片元数据(t_order_0到t_order_3的建表语句),并验证sharding-jdbc-config.yaml的YAML格式有效性(用yamllint)。
这份清单不是一成不变的。我们每月更新一次,加入新发现的坑。比如最近新增的第12项,就源于一次配置文件YAML缩进错误,导致ShardingJDBC加载了空分片规则,所有SQL都路由到默认库——而默认库恰好是测试库,结果生产数据被写进了测试库。没有备份,就没有回滚。
我个人在实际操作中的体会是:容器化分库分表项目,80%的精力不在编码,而在验证。每一次
docker-compose down && docker-compose up -d之后,必须执行三件事:查日志确认无ERROR、跑核心接口验证功能、用curl调用/actuator/shardingsphere/datasource确认数据源状态。少一步,就可能埋下线上事故的种子。