多数据源、读写分离、分片与 Schema 迁移:路由和变更如何保持正确
把第二个 DataSource 注册进容器很容易,难的是回答一条 SQL 实际去了哪里、刚提交的数据何时可读、分片算法升级后旧数据在哪里,以及新旧应用同时运行时哪套 Schema 都能工作。路由和迁移必须是可审计协议,不能是 ThreadLocal 与启动脚本的偶然组合。
Flyway 的 Schema History 记录迁移版本、校验和与状态;它是变更证据,不是数据库真实结构的唯一证明。
路由必须在取得连接之前稳定
AbstractRoutingDataSource 一类实现通常在 getConnection 时读取线程或请求上下文选择目标 DataSource。事务已经绑定连接后再切换路由键,不会把当前事务搬到另一个库,反而让日志显示的逻辑路由与真实连接不一致。路由上下文必须用受控作用域建立并在 finally 清除;异步任务要显式传递快照,不能继承线程池里遗留的 ThreadLocal。
读写分离改变的是一致性承诺。主库提交成功不意味着副本立即可见;创建后立刻查询、鉴权变更、余额与幂等记录不能随意落到副本。常见策略是在事务内和写后窗口粘主、携带复制位点等待、或让调用方显式选择一致性级别。固定睡眠既浪费正常请求,也无法覆盖长尾延迟。
分片键决定数据归属与可路由查询。哈希取模简单,却让扩容搬迁大量数据;一致性哈希缓解节点变化,但关系查询与事务边界仍需业务设计;范围分片支持区间查询,却容易形成热点。路由结果必须包含算法版本,双路由迁移期间记录旧新结果和回填水位,不能只替换一个 hash 函数后期待历史数据自动移动。
跨数据源事务不是把两个 DataSource 放进同一个方法。单库事务管理器只能原子控制一个资源;XA 需要驱动、资源管理器与恢复日志共同参与,并带来 prepare、启发式结果和恢复复杂度。多数服务更适合把单库提交作为本地事实,再通过 outbox/事件与幂等补偿收敛;完整分布式事务留给专门知识域。
Schema 迁移是部署协议。版本化 migration 一旦在共享环境应用就不可原地修改,Flyway validate 会比较已应用记录与本地 migration 的名称、类型和 checksum。repair 改的是历史表认知,不会自动证明数据库对象正确;它必须经过变更审查和目标库核验。生产变更优先 expand/contract:先加兼容列或表,发布双读/双写或回填,再切换读取,最后删除旧结构。
DDL 是否事务化、是否持有元数据锁以及在线算法能力取决于数据库。大表加列、建索引和回填要拆开评估锁、日志、复制延迟与磁盘,应用启动时自动 migrate 也要保证只有受控执行者拥有 DDL 权限。多实例同时启动不能把“抢到迁移锁”当发布编排,失败实例和旧版本兼容窗口必须预先设计。
多数据源把连接池容量和健康语义一起放大
每个逻辑数据源通常拥有独立连接池。十个租户库乘每池十条连接,应用实例还未接流量就可能占用百条数据库连接;实例扩容会进一步相乘。动态租户数据源需要惰性创建、全局连接预算、闲置回收和并发初始化锁,不能为每个租户复制默认最大池。健康检查也要分层:核心写库不可用影响 readiness,单个低优先级租户或只读副本故障通常只应降级对应路由。
路由观测不能把租户 ID 或分片键直接作为指标标签。指标聚合到数据源角色、分片组和结果类别,具体租户进入脱敏日志或 trace。每次连接借出记录最终 route、算法版本和 consistency level,出现数据缺失时才能判断是路由错误、复制延迟、尚未回填还是查询条件错误。
Schema 迁移失败后先冻结自动重试。若数据库支持事务型 DDL,失败可能整体回滚;不支持时可能留下部分对象,而 history 表记录 failed 或缺失。恢复前比较真实 Schema、迁移脚本和 history 状态,决定前向修复还是受控 repair。直接删除历史记录再重跑,可能对已存在对象执行非幂等 DDL。
回填是数据迁移,不是普通 UPDATE。它需要稳定主键游标、有界批次、速率限制、断点状态、影响行数和校验查询;与在线写入并行时还要防止旧值覆盖新值。切换读取前比较新旧列或新旧分片的计数、摘要和业务不变量,差异稳定收敛后再停止双写。删除旧结构是最后一步,并且必须晚于所有旧应用退出兼容窗口。
迁移验收至少在空库、完整历史库、落后一版库和带真实规模副本上各跑一次 validate/migrate。应用回滚测试验证旧代码能读取扩展后的 Schema,新代码回滚后不会继续写只有新版本理解的状态。这样 Schema 才是版本化契约,而不是部署脚本的副作用。
切换窗口要用状态机而不是发布顺序口令
一次跨库或分片迁移至少经历 OLD_ONLY → DUAL_WRITE → BACKFILL → VERIFY → NEW_READ → NEW_ONLY。状态保存在受审计的控制表或配置版本中,每次请求记录采用的阶段;不能靠“先发 A 再发 B”口头约定。DUAL_WRITE 中任一侧失败都要留下可重放任务和幂等键,不能在请求线程无限重试。BACKFILL 只补历史缺口,不覆盖双写产生的更新版本。
双写不是原子提交时,读路径必须知道如何处理分叉。可以以旧库为权威并异步修复新库,也可以提交本地事实后用 outbox 驱动目标库;选择哪一种取决于哪侧能提供幂等写、版本比较和恢复查询。没有权威侧、没有版本号的双写只会把偶发失败变成静默数据分裂。
多数据源集成测试要真实创建两个独立数据库或 Schema,并在同一用例中验证:事务开始前路由生效,事务中修改路由不会换连接,异常后 ThreadLocal 清理,异步任务没有继承旧租户,主库写后强一致读取不走副本。mock DataSource 只能验证方法调用,不能证明连接绑定与提交行为。
最终下线旧库前,除了业务计数和摘要,还要检查没有旧版本实例、没有旧路由流量、没有落后回填任务、没有依赖旧 Schema 的报表与脚本,并冻结回退点。删除是一项独立变更;把切读与删表放在同一次发布里,会让任何未发现的兼容问题失去可逆路径。
用两个状态模型拆掉框架错觉
下面两个 Java 17 程序只保留本篇最关键的状态与分支。它们不连接真实数据库,因此不能证明驱动或数据库的厂商行为;它们用来证明调用方必须维持的不变量,真实集成测试再负责验证 SQL、锁和网络。
javac --release 17 -Xlint:all -Werror examples/backend-development/data-access/multi-datasource-sharding-migration/RoutingConsistencyDemo.java examples/backend-development/data-access/multi-datasource-sharding-migration/MigrationChecksumDemo.java
java -cp examples/backend-development/data-access/multi-datasource-sharding-migration RoutingConsistencyDemo
java -cp examples/backend-development/data-access/multi-datasource-sharding-migration MigrationChecksumDemoreadAfterWriteRoute=primary shard=2 routingContextCleared=true
applied=2 resolved=2 checksumMatch=false migrationBlocked=true输出的价值在于固定中间状态,而不是展示 API 能运行。修改实现后,如果资源没有复位、冲突被误报为成功、缓存跨越了更新边界或调用身份发生变化,模型应先失败,随后真实数据库测试再给出厂商级证据。
从“SQL 慢”退回第一处状态偏差
把正确性做成跨层验收
回滚也分层处理。代码回滚不能假设 Schema 自动倒退;Schema 变更要优先使用 expand/contract,让新旧代码在过渡窗口同时工作。数据写入一旦提交,恢复依赖补偿、备份或前向修复,不能靠重新部署抹掉。只有当连接所有权、事务结束、对象状态、Schema 版本和观测证据都能闭合,数据访问层才真正完成了一次调用。
