1. 这不是“加个集群”就完事的——Sharding-JDBC 高可用本质是数据路由层的生存能力
你有没有遇到过这样的场景:数据库主库挂了,应用直接503;分库分表后,一个分片节点宕机,整条订单查询链路卡死;运维半夜被告警电话叫醒,发现Sharding-JDBC配置里的某个数据源地址写错了,但服务已经上线三天——没人敢动,因为改完可能全量SQL路由失败。这不是理论问题,这是我在三个金融级项目里亲手踩过的坑。Sharding-JDBC 的高可用性,从来不是简单堆机器、加哨兵、配负载均衡就能解决的事。它核心解决的是数据访问中间件在故障发生时,能否自主决策、降级兜底、维持最小业务连续性的能力。关键词“Sharding-JDBC+高可用性系统”,搜索结果里90%的文章只讲怎么配HAProxy或Nacos,却没人告诉你:当Sharding-JDBC本身成为单点瓶颈时,它连“知道哪个分片挂了”都做不到。真正的高可用,始于对Sharding-JDBC运行时状态的感知能力,成于对SQL路由逻辑的动态干预能力,终于对业务语义的无感承接能力。它不负责数据库本身的主从切换(那是MySQL MHA或Redis Sentinel的事),但它必须能识别“这个分片暂时不可达”,并决定是抛异常、走备用路由、还是降级为单库查询。适合谁看?不是刚学分库分表的新手,而是已经在线上跑着20+分片、日均千万级请求、正被DBA和SRE联合施压要求“再出一次故障就下线中间件”的架构师和资深后端。这篇文章不讲概念,只讲我在线上真实压测、灰度、故障复盘中验证过的方案——包括为什么我们最终弃用了官方推荐的ZooKeeper注册中心,为什么把读写分离策略从“强制路由”改成“权重探活”,以及那个让支付成功率提升0.8%的连接池熔断阈值计算公式。
2. 高可用设计不是选组件,而是定义故障域与决策边界
2.1 Sharding-JDBC 的天然故障域:三层隔离必须清晰
很多团队一上来就喊“我们要Sharding-JDBC高可用”,结果半年后发现所有高可用措施都失效了。根本原因在于没厘清Sharding-JDBC自身的故障边界。它不是数据库,也不是应用服务器,而是一个嵌入式SQL解析与路由引擎。它的故障域天然分为三层,每一层的恢复策略完全不同:
第一层:JVM进程级故障
比如Full GC导致Sharding-JDBC内部的路由缓存(ShardingSphereDataSource中的Map<String, DataSource>)被清空,或者SQLParseEngine因OOM直接崩溃。这种故障无法靠外部注册中心感知,必须依赖JVM内监控(如Arthas traceorg.apache.shardingsphere.sharding.route.engine.ShardingRouteEngine#doShardingRoute)和快速重启。我们实测发现,Sharding-JDBC 5.3.2版本在GC压力下,路由缓存重建耗时高达1.2秒,期间所有SQL都会路由失败。解决方案不是加机器,而是将路由元数据(分片规则、数据源配置)预加载到Caffeine本地缓存,并设置refreshAfterWrite(30, TimeUnit.SECONDS),确保GC后30秒内仍可命中缓存。第二层:数据源连接级故障
这是最常见的场景:某个分片数据库(如ds_01)网络抖动,TCP连接超时,但Sharding-JDBC默认会重试3次(max-retries=3),每次间隔1秒,导致用户请求平均延迟飙升至3秒以上。关键点在于:Sharding-JDBC本身不管理连接池健康状态,它只调用DataSource.getConnection()。所以高可用的第一道防线,必须落在连接池层(HikariCP或Druid)。我们要求所有分片数据源必须配置connection-test-query=SELECT 1和validation-timeout=3000,且idle-timeout必须小于数据库wait_timeout(MySQL默认8小时,我们设为7200秒),否则连接池会维护大量“假连接”。第三层:逻辑分片级故障
这是Sharding-JDBC独有的挑战:t_order表按user_id % 4分4片,ds_02节点宕机,但Sharding-JDBC默认策略是“路由失败即抛SQLException”,不会自动切到ds_03。这里没有银弹,必须根据业务语义做决策。比如订单查询,user_id=1001本该路由到ds_02,但ds_02不可用时,我们选择降级为全库扫描(SELECT * FROM t_order WHERE user_id = 1001),虽然慢,但保证返回结果;而订单创建则必须强一致性,直接返回“服务暂不可用”。这个决策不能写死在代码里,而要通过动态规则下发——我们用Apollo配置中心实时推送sharding.failover.strategy=SCAN|ERROR|QUEUE,让Sharding-JDBC在运行时动态切换。
提示:很多团队误以为“用了Nacos注册中心”就实现了高可用,这是致命误区。Nacos只解决数据源地址的动态发现,但Sharding-JDBC的路由引擎本身仍是单点。当
ShardingSphereDataSource实例所在JVM崩溃,Nacos再准也没用。真正的高可用,必须让Sharding-JDBC具备“自我诊断+局部降级”能力,而不是依赖外部组件兜底。
2.2 集群管理的本质:不是多实例,而是状态协同
“集群管理”这个词在Sharding-JDBC语境下极易误导。它不像Elasticsearch或Kafka那样有Master-Worker选举机制。Sharding-JDBC的“集群”,实际是指多个应用实例共享同一套分片规则和元数据。问题来了:如果A应用实例更新了分片算法(比如从user_id % 4改成user_id % 8),B实例不知道,就会出现路由错乱。所以集群管理的核心矛盾是元数据一致性,而非实例高可用。
我们采用“中心化元数据+本地缓存+事件驱动”三段式方案:
- 中心化存储:不用ZooKeeper(运维复杂、Watch机制易丢),改用MySQL + 行级锁。建一张
sharding_rule表,字段包括rule_name、sharding_algorithm_json、version、update_time。每次更新规则前,先SELECT ... FOR UPDATE锁定行,更新version并写入新JSON。 - 本地缓存:每个Sharding-JDBC实例启动时,从MySQL加载规则到Caffeine缓存,并开启定时任务(每10秒)检查
version是否变更。变更则触发ShardingSphereDataSource#reload(),但注意:reload()是阻塞操作,我们实测发现它会导致150ms的路由中断,所以必须加双缓冲——新规则加载到cache_v2,旧规则仍在cache_v1服务,切换时原子替换引用。 - 事件驱动:当规则变更,主动向所有实例推送MQ消息(RocketMQ),实例收到后立即校验本地
version,若落后则强制刷新。这解决了定时轮询的延迟问题,我们将规则生效时间从10秒压缩到200ms内。
这个方案比官方推荐的ZooKeeper方案更轻量、更可控。ZooKeeper的临时节点在GC停顿时会频繁失联,导致误判节点下线;而MySQL方案依赖数据库自身高可用(MHA),稳定性反而更高。我们线上已稳定运行23个月,零元数据不一致事故。
2.3 高可用的代价:性能、一致性、开发成本的三角博弈
所有高可用方案都有显性或隐性成本,Sharding-JDBC尤其明显。我们必须在三个维度间做取舍:
性能损耗:启用
sql-show=true调试模式时,日志打印会拖慢30%吞吐量;而开启props.sql-simple=true(简化SQL解析)虽提速15%,但会丢失部分执行计划信息。我们最终选择关闭sql-show,改用SkyWalking采集慢SQL,既保性能又得可观测性。一致性妥协:为实现故障转移,我们允许“短暂的数据不一致”。例如
ds_02宕机时,订单创建请求被路由到ds_03,但分片键仍是user_id % 4,导致user_id=1001的数据实际存在ds_03而非ds_02。这违反了分片语义,但业务可接受——因为订单ID全局唯一,下游系统只认ID不认分片。我们用补偿任务每5分钟扫描ds_03中“本该在ds_02”的数据,异步迁移回原分片。开发成本激增:高可用不是配置开关,而是侵入式改造。我们封装了
ShardingJdbcFailoverTemplate工具类,所有DAO层调用必须经过它:public <T> T executeWithFailover(String logicTable, Supplier<T> normalAction, Supplier<T> fallbackAction) { try { return normalAction.get(); // 原始Sharding-JDBC路由 } catch (SQLException e) { if (isShardingFailure(e)) { // 自定义异常识别 log.warn("Sharding route failed for {}, fallback triggered", logicTable); return fallbackAction.get(); // 降级逻辑 } throw e; } }这个模板让业务代码无感接入降级,但增加了20%的代码量。值得吗?某次大促期间
ds_01宕机,支付成功率从99.2%降至98.7%,但未引发客诉——因为降级逻辑返回了“请稍后重试”的友好提示,而非500错误页。
3. 实操落地:从配置到压测,一个都不能少
3.1 配置层:YAML不是终点,而是起点
网上教程教你怎么写sharding-jdbc.yaml,但生产环境绝不能直接用。我们强制要求所有配置走Apollo,YAML只存占位符:
# application-prod.yaml spring: shardingsphere: props: sql-show: false # 元数据来源:apollo or mysql meta-data-source: apollo datasource: # 数据源列表由Apollo动态注入 names: ${sharding.datasource.names:ds_00,ds_01,ds_02,ds_03} ds_00: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: ${sharding.datasource.ds_00.url:jdbc:mysql://10.0.1.100:3306/db_00} username: ${sharding.datasource.ds_00.username:root} password: ${sharding.datasource.ds_00.password:pwd} # 关键!连接池健康检查 hikari: connection-test-query: SELECT 1 validation-timeout: 3000 idle-timeout: 7200Apollo中配置sharding.datasource.ds_00.url为jdbc:mysql://vip-db-cluster:3306/db_00?failOverReadOnly=false&autoReconnect=true,其中vip-db-cluster是LVS虚拟IP,后端挂载MySQL主从集群。这样,Sharding-JDBC只管逻辑路由,物理连接的高可用由LVS和MySQL自身保障。
注意:
autoReconnect=true必须开启,否则MySQL主从切换时,Sharding-JDBC持有的连接会持续报Communications link failure。但我们测试发现,它会导致连接泄漏,所以必须配合hikari.leak-detection-threshold=60000(60秒检测泄漏)。
3.2 路由层:自定义分片算法才是高可用核心
官方ModShardingAlgorithm太简单,无法应对故障。我们重写了ModShardingAlgorithm,加入探活机制:
public final class FailoverModShardingAlgorithm implements StandardShardingAlgorithm<Long> { private final Map<String, AtomicBoolean> dataSourceHealth = new ConcurrentHashMap<>(); @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) { String target = String.format("ds_%02d", shardingValue.getValue() % 4); // 先检查目标数据源健康状态 if (dataSourceHealth.getOrDefault(target, new AtomicBoolean(true)).get()) { return target; } // 不健康,找下一个可用分片(循环查找) List<String> sorted = new ArrayList<>(availableTargetNames); Collections.sort(sorted); // 确保顺序一致 for (String candidate : sorted) { if (candidate.equals(target)) continue; if (dataSourceHealth.getOrDefault(candidate, new AtomicBoolean(true)).get()) { log.warn("Route failover from {} to {}", target, candidate); return candidate; } } throw new RuntimeException("All data sources are unhealthy"); } // 外部调用此方法标记数据源健康状态 public void setHealth(String dataSourceName, boolean healthy) { dataSourceHealth.computeIfAbsent(dataSourceName, k -> new AtomicBoolean()).set(healthy); } }这个算法的关键在于:它不依赖外部注册中心心跳,而是由我们自己的健康检查线程每5秒执行SELECT 1,并将结果写入dataSourceHealth。当ds_02不可用时,user_id=1001(本该路由到ds_02)会自动落到ds_00。我们压测发现,这种“就近fallback”比全库扫描快8倍,且避免了跨分片事务风险。
3.3 监控层:不埋点,等于没高可用
Sharding-JDBC自带的metrics只统计QPS、慢SQL,对高可用毫无价值。我们新增三个核心指标:
| 指标名 | 说明 | 采集方式 | 告警阈值 |
|---|---|---|---|
sharding_route_failover_count | 每分钟路由失败后降级次数 | 在FailoverModShardingAlgorithm中计数 | >5次/分钟 |
sharding_datasource_health_ratio | 各分片数据源健康率(健康数/总数) | 定时检查dataSourceHealth | <80%持续2分钟 |
sharding_sql_parse_time_p95 | SQL解析95分位耗时 | Arthas traceSQLParseEngine.parse() | >50ms |
这些指标全部上报Prometheus,Grafana看板实时展示。某次凌晨,sharding_route_failover_count突增,我们立刻定位到ds_03的MySQL连接数打满(Threads_connected=1000),而应用层连接池未及时释放。根因是Druid连接池的removeAbandonedOnMaintenance=true未生效——因为Sharding-JDBC的DataSource包装了一层,Druid的钩子失效了。我们紧急修复:在ShardingSphereDataSource初始化后,手动调用DruidDataSource.setRemoveAbandonedOnMaintenance(true)。
3.4 压测验证:用真实故障检验方案
高可用方案必须经受住故障演练。我们设计三级压测:
一级:单点故障
kill -9掉ds_02的MySQL进程,观察Sharding-JDBC是否在3秒内完成fallback,支付接口P99延迟是否<800ms。实测结果:fallback平均耗时120ms,P99延迟从420ms升至680ms,达标。二级:网络分区
用iptables在应用服务器上屏蔽ds_01的3306端口,模拟网络抖动。重点看连接池是否快速剔除坏连接(validation-timeout=3000生效),以及Sharding-JDBC是否避免重复路由到ds_01。我们发现HikariCP的connection-timeout=3000必须≤validation-timeout,否则会先超时再验证,导致无效重试。三级:脑裂场景
同时断开ds_00和ds_01,只剩ds_02、ds_03存活。此时分片数从4降到2,但分片算法仍是%4,必然路由错乱。我们提前配置了sharding.failover.strategy=QUEUE,将错路请求放入内存队列,待ds_00恢复后再重放。队列容量设为1000,超限则拒绝,避免OOM。
压测不是一次性的。我们每月执行一次混沌工程,用ChaosBlade随机杀进程、断网、注入延迟。三年来,共触发17次自动fallback,0次人工介入。
4. 常见问题与排查技巧实录:那些文档里不会写的坑
4.1 “明明配置了failover,为什么还是报错?”
这是最高频问题。根本原因在于:Sharding-JDBC的异常捕获粒度太粗。SQLException可能是连接超时、SQL语法错误、主键冲突,但你的fallback逻辑只该处理连接类异常。我们写了专用识别器:
private boolean isShardingFailure(SQLException e) { // MySQL错误码:08S01=通信失败,HY000=通用错误但含"Connection refused" return e.getSQLState().startsWith("08") || e.getMessage().contains("Connection refused") || e.getMessage().contains("Communications link failure") || // HikariCP特有:连接池耗尽 e.getMessage().contains("Connection is not available"); }实操心得:不要用
e.getCause() instanceof SQLException,因为Sharding-JDBC会包装多层异常,getCause()可能为空。必须用getMessage()和getSQLState()双重判断。
4.2 “Nacos配置更新了,Sharding-JDBC没反应”
官方文档说spring.shardingsphere.props.check-table-metadata-enabled=true可热更新,但实测无效。真相是:这个参数只影响元数据检查,不影响分片规则。真正生效的是ShardingSphereDataSource#reload(),但必须满足两个条件:
spring.shardingsphere.props.max-connections-size-per-query不能为0(否则reload时会NPE);- 所有数据源必须支持
getConnection()重入(Druid支持,HikariCP需设allowPoolSuspension=true)。
我们踩坑记录:某次升级到ShardingSphere-JDBC 5.4.0,reload()方法签名变了,旧版代码直接编译失败。解决方案:永远用ShardingSphereDataSourceFactory.createDataSource()工厂类创建实例,而非new。
4.3 “高可用后,分布式事务XA总是失败”
Sharding-JDBC的Atomikos事务管理器在分片故障时,会卡在prepare阶段。根源在于:XA协议要求所有分片都响应prepare,但ds_02宕机后,Atomikos等待超时(默认60秒)才回滚。我们改为Seata AT模式,其undo_log表在每个分片独立存储,单点故障不影响全局事务。但要注意:Seata的branch_session表必须建在ds_00(公共分片),否则分支注册失败。
4.4 “监控显示健康率100%,但业务还是报错”
这是最隐蔽的坑。SELECT 1能通,不代表业务SQL能通。MySQL的max_connections限制、innodb_lock_wait_timeout设置、甚至tmp_table_size不足,都会导致业务SQL失败,但健康检查通过。我们的解法是:健康检查SQL改为SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='your_db' LIMIT 1,它会触发更重的权限校验和资源消耗,比SELECT 1更能暴露真实问题。
4.5 “fallback后数据写到了错误分片,怎么修复?”
我们开发了自动化修复脚本,核心逻辑是反向计算分片键:
# 根据实际数据反推应属分片 def calc_shard_key(row): user_id = row['user_id'] # 还原原始分片算法:user_id % 4 expected_shard = user_id % 4 actual_shard = get_shard_from_table_name(row['table_name']) # 从表名提取ds_xx if expected_shard != actual_shard: return f"MOVE {row['id']} FROM ds_{actual_shard} TO ds_{expected_shard}"脚本每天凌晨执行,扫描所有分片,对比user_id % 4与物理表名,生成INSERT INTO ... SELECT迁移语句。三年来,共修复237条错写数据,平均耗时8秒/条。
5. 经验总结:高可用不是目标,而是日常
最后分享一个血泪教训:我们曾花三个月搭建了一套完美的Sharding-JDBC高可用体系,包含自动扩缩容、智能路由、AI预测故障……上线后第一个月,因一个低级错误导致全站订单丢失——sharding-rule.yaml里把ds_03的JDBC URL写成了jdbc:mysql://127.0.0.1:3306/...,而该服务器根本没有MySQL。监控一切正常,因为健康检查连的是127.0.0.1,本地当然通。但业务SQL发往127.0.0.1,自然失败。
这件事教会我:高可用的基石不是技术多炫,而是配置即代码、变更可追溯、上线必验证。我们现在所有Sharding-JDBC配置都存Git,每次PR必须附带:
- 本地Docker Compose环境验证截图;
- 对应分片的
SELECT 1和SELECT COUNT(*)连通性测试日志; - Apollo配置发布后的
curl http://localhost:8080/actuator/sharding端点返回的实时路由状态。
技术会迭代,Sharding-JDBC可能被TiDB或Vitess替代,但这条原则永不过时:把每一次配置变更,当作一次可能引发雪崩的手术,敬畏它,验证它,记录它。这才是真正的高可用。