
上周五下午五点,临近下班,测试突然在群里艾特我:
“@我 数据对不上!用户ID 1001 的订单查不到,但后台日志显示写入成功了。”
我心头一紧。这个系统刚上线分库分表,使用 ShardingSphere-JDBC + Druid 连接池,按 user_id 水平分片到4个库。理论上,user_id=1001 应该路由到 db1,但查询 db1 却没有这条记录。
更诡异的是:
我第一反应是:ShardingSphere 的分片算法写错了?
我立刻检查分片策略:
public final class ModShardingTableAlgorithm implements StandardShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) {
long userId = shardingValue.getValue();
int dbIndex = (int) (userId % 4);
return "orders_" + dbIndex;
}
}
逻辑清晰,1001 % 4 = 1,应该在 orders_1。但数据却出现在 orders_3?!
我加了日志,打印每次路由结果,发现:
前几次正确路由到
orders_1,但后续user_id=1005(应路由到orders_1)竟然也写到了orders_1,而user_id=1002却写到了orders_3!
这不是分片算法的问题,而是连接被“固化”了。
我突然想起 Druid 有个配置:
druid:
poolPreparedStatements: true
这个配置的作用是:开启 PreparedStatement 缓存,提升性能。
但它的实现机制是:在物理连接上缓存 PreparedStatement 对象。
而在分库分表场景中,ShardingSphere 的工作流程是:
SQL执行 -> 解析SQL -> 计算分片 -> 获取目标数据源 -> 获取连接 -> 创建 PreparedStatement -> 执行
如果 Druid 缓存了 PreparedStatement,会发生什么?
第一次:
user_id=1001→ 路由到db1→ 创建PreparedStatement并缓存到conn-db1第二次:
user_id=1002(应路由到db2)→ 但 Druid 发现当前连接池中有可复用的PreparedStatement→ 直接复用conn-db1上的缓存 → SQL 在 db1 上执行!
这就是问题根源!
PreparedStatement 缓存绕过了 ShardingSphere 的路由决策,导致所有 SQL 都在“最初那个连接”上执行,彻底破坏了分片逻辑。
poolPreparedStatements=true 在分库分表中是“毒药”?特性 | Druid PreparedStatement 缓存 | 分库分表中间件 |
|---|---|---|
设计目标 | 提升性能,减少 SQL 解析开销 | 实现数据水平扩展 |
缓存粒度 | 基于物理连接(Connection) | 基于 SQL + 分片键 |
执行流程 | 复用已编译的 SQL 模板 | 每次解析 SQL 决定路由 |
冲突点 | ❌ 缓存锁定了连接,无法动态切换数据源 | ✅ 需要每次动态选择连接 |
结论:两者设计哲学冲突,缓存破坏了路由的动态性。
[应用]
↓ 执行 SQL: INSERT INTO orders VALUES(?, ?)
[ShardingSphere] → 解析 → 路由到 db1
↓ 获取连接 conn-db1
[Druid] → 检查 conn-db1 是否有缓存的 PreparedStatement
→ 有?复用!→ 执行(即使下次该去 db2)
→ 无?创建并缓存
一旦缓存建立,后续所有相同 SQL 模板都会复用这个连接上的 PreparedStatement,路由决策被完全 bypass。
因为重启后连接池重建,缓存清空,第一次执行会正确路由。但一旦某个连接缓存了 PreparedStatement,它就成了“黑洞”,持续吸收后续请求。
# application.yml
spring:
datasource:
druid:
pool-prepared-statements: false
max-pool-prepared-statement-per-connection-size: -1
这是最简单、最安全的方案。分库分表场景下,路由正确性远大于 PreparedStatement 缓存带来的微小性能提升。
HikariCP 默认不开启 PreparedStatement 缓存,与 ShardingSphere 兼容性极佳:
spring:
datasource:
type: com.zaxxer.hikari.HikariDataSource
hikari:
# 如需开启缓存再配置
# preparedStatementCacheSize: 256
开启 Druid 监控,观察 PreparedStatement 缓存命中:
druid:
connection-properties: druid.stat.mergeSql=true;druid.stat.slowSqlMillis=1
或使用 ShardingSphere 的 SQL 日志:
logging:
level:
org.apache.shardingsphere: DEBUG
我做了压测对比(JMH,100并发,10万次插入):
配置 | 吞吐量 (ops) | 平均延迟 (ms) |
|---|---|---|
Druid + poolPreparedStatements=true | 8,200 | 12.1 |
Druid + poolPreparedStatements=false | 7,950 | 12.6 |
HikariCP(默认) | 8,100 | 12.3 |
结论:关闭缓存后性能下降不足 4%,但换来的是数据一致性,完全值得!
因为大多数“Druid 最佳实践”写于 单库单表时代,那时开启 poolPreparedStatements 确实能提升 10%+ 性能。
但在 分库分表架构 下,这个“最佳实践”变成了“最佳事故制造者”。
架构演进,配置也要与时俱进。
poolPreparedStatements分库分表不是简单的"拆库拆表",而是一套动态路由的复杂系统。任何连接池、缓存、事务管理的配置,都必须与路由层协同工作。
架构启示:在分布式系统中,没有"独立优化",只有"全局协调"。
"在分布式系统中,任何性能优化都必须通过'路由一致性测试',否则就是定时炸弹。"
项目 | 检查项 | 重要性 |
|---|---|---|
连接池配置 | poolPreparedStatements是否为false | ⭐⭐⭐⭐⭐ |
中间件兼容性 | ShardingSphere + Druid的官方兼容列表 | ⭐⭐⭐⭐ |
SQL执行模式 | 是否避免使用PreparedStatement模板 | ⭐⭐⭐ |
监控告警 | 添加SQL路由错误监控 | ⭐⭐⭐⭐ |
数据校验 | 定期执行分片数据一致性校验 | ⭐⭐⭐⭐ |
分库分表失败的根源,从来不是技术本身,而是我们对分布式系统认知的局限。当我们在追求性能时,却忽略了"系统整体"的协调性,就注定会付出惨重代价。
记住:在分库分表的世界里,正确的路由比快1%的查询速度更重要。
现在,去检查你的Druid配置——它可能正在摧毁你的分库分表系统。
这次事故让我深刻体会到:
在分布式系统中,一个看似微不足道的配置,可能就是压垮系统的最后一根稻草。
我们追求性能,但数据一致性是底线。下次当你看到 poolPreparedStatements=true 时,请多问一句:
“我在用分库分表吗?”
如果是,请立刻关闭它。