
凌晨两点半线上告警突然把我和隔壁组的同事从床上拉了起来。核心交易库的读写分离架构里一条写操作刚提交紧接着的读请求因为走了备库查出来的是旧数据。主从同步延迟在这种流量高峰被无限放大业务方看到的是“刚下单的订单竟然消失了”。排查到最后问题不在SQL也不在索引而在路由——ShardingJDBC的自动路由规规矩矩地按读负载均衡把请求送去了备库。那段经历之后我把强制路由Hint路由这个功能彻底研究了一遍。很多同学对ShardingJDBC的印象停留在分库分表、读写分离但真正遇到“路由必须按我的意志走”的场景时强制路由是唯一答案。平时你可能用不到它一旦用到它就是那个能救命的功能。这篇文章不讲泛泛的理论把我实操过的配置、API、生产案例和踩坑记录都放出来希望帮你少走弯路。1. 强制路由是什么绕过解析规则的手动干预机制1.1 从一次线上事故说起正常路由到底怎么走ShardingJDBC最核心的能力是把一条SQL按照分片规则拆到正确的库和表上执行。它的内部工作流程大致是这样SQL进入引擎后解析器先提取分片键的值比如WHERE user_id 1024中的user_id 1024然后路由引擎拿着这个值喂给分片算法算出目标库序号和目标表序号最后执行器把改写后的SQL发到对应的物理数据源。这套流程在绝大多数时候是可靠的但有两个前提第一SQL里必须能解析出分片键第二路由规则必须精确匹配业务意图。如果我们执行的SQL没有分片键条件比如SELECT * FROM t_order WHERE status 0解析器找不到user_id就只能走广播路由把请求打到所有分片节点上。更麻烦的是主从场景——如果读写分离配置了多个备库负载均衡策略默认会把读请求均匀分摊到备库主库刚写完的数据在备库还没同步完成时读出来就是旧的。这不是中间件设计的缺陷而是自动路由在复杂业务场景下的天然盲区。自动路由依赖的输入是“SQL本身的信息”但业务有时候需要的是“这个请求必须去某个地方”。强制路由就是把路由决策权从解析器手里拿回来交到开发者手上。1.2 强制路由的定位解析器之外的第二条路强制路由在ShardingSphere中的官方名称叫Hint路由核心作用就是绕过解析过程由程序员显式指定分片目标。它有几个非常明显的特征这些特征同时是它的优点和约束第一不需要SQL中携带分片键。你完全可以在查询条件里不写user_id只要在代码中用HintManager指定了路由值SQL照样能路由到正确的库表。第二优先级高于普通路由。只要当前线程上下文中设置了Hint路由引擎会优先参考Hint指定的值而不再执行常规的分片算法计算。第三生命周期与线程绑定。Hint的值存放在类似ThreadLocal的机制中这意味着你必须让设置Hint和执行SQL的代码运行在同一个线程里一旦跨线程Hint就失去效果。你可能会问如果分片规则设计得足够好还需要强制路由吗我的答案是规则再完备也总有边角场景。行情数据的分表键是股票代码但运营后台可能需要按照日期范围扫描全表订单表分片键是用户ID但财务对账需要按商户维度批量查主从同步从几百毫秒延迟到几秒总有几个关键写后读场景不能忍受这个时差。这些场景里强制路由不是可选项而是必选项。1.3 哪些场景应该用强制路由结合我接手的项目和同行交流的案例强制路由的使用场景大致可以归为四类写后读一致性事务提交后紧接着需要读到刚写入的数据直接在主库上执行读规避主从同步延迟。非分片键查询查询条件中没有分片键但业务上知道这条数据应该在哪个分片通过Hint指定路由目标避免全库表扫描。批量数据订正定时任务需要跨多个分片逐一分片处理数据这时Hint可以精确控制每次操作的数据源。压测或定向排查需要把测试流量或排查流量定向打到某个特定的库表组合验证分片算法或数据分布。这四类场景我都会在后面给出具体的代码案例。现在先明确一点强制路由是手动档工具它把“怎么走”的控制权给了你但同时把“能走到哪”的责任也交给你。用好它的前提是你对分片规则和数据分布有清晰的认知。2. 核心API与基础配置强制路由工具箱2.1 工程依赖与基础配置样例实战中我用的ShardingSphere版本是5.x系列。先看一下Maven依赖ShardingJDBC在5.x中已经统一到shardingsphere-jdbc-core这个模块下面Spring Boot项目直接加starter就行dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.4.1/version /dependency同时如果你用了Spring Boot 2.x建议确认项目已经正确管理了shardingsphere相关依赖的版本。常见的坑是依赖版本冲突表现为启动时提示找不到某些类这时候先排除项目里其他模块对shardingsphere旧版本的间接依赖。接下来是yml配置。我们用一个简化版的订单库来做演示两个数据库实例每个库两张订单表按user_id的奇偶分库分表。spring: shardingsphere: mode: type: Memory datasource: names: ds-0, ds-1 ds-0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.10:3306/order_db_0?serverTimezoneAsia/Shanghai username: root password: root ds-1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.11:3306/order_db_1?serverTimezoneAsia/Shanghai username: root password: root rules: sharding: tables: t_order: actualDataNodes: ds-$-{0..1}.t_order_$-{0..1} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: db_inline tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: tbl_inline shardingAlgorithms: db_inline: type: INLINE props: algorithm-expression: ds-$-{user_id % 2} tbl_inline: type: INLINE props: algorithm-expression: t_order_$-{user_id % 2}这里有三个关键点逻辑表名t_order是所有SQL操作和Hint方法使用的名字不能写成物理表名t_order_0。actualDataNodes中的$-{0..1}是行表达式语法用于枚举节点不能与普通的${}占位符混淆。分片算法用的是行内联表达式user_id % 2生产环境建议用标准算法并配合分片列的类型信息。2.2 HintManager核心方法逐个拆解ShardingSphere提供操作强制路由的入口就是HintManager类。不需要频繁new它提供了静态工厂方法getInstance()内部也是基于ThreadLocal管理当前线程的Hint状态。我总结了一个实战中常用的方法对照表方便查阅方法作用使用场景HintManager.getInstance()获取当前线程的Hint管理器实例每次使用强制路由前调用addDatabaseShardingValue(logicTable, columnName, value)为指定逻辑表添加数据库分片值需要指定数据源时addTableShardingValue(logicTable, columnName, value)为指定逻辑表添加表分片值需要指定物理表时setDatabaseShardingValue(value)为单分片键场景统一设置库分片值只有库分片不分表时setMainRouteOnly()或setMasterRouteOnly()强制走主库读写分离主从延迟场景close()清理当前线程的Hint状态使用完毕后必须调用先说最常用的addDatabaseShardingValue和addTableShardingValue。这两个方法负责指定逻辑表的库路由值和表路由值。注意参数里的逻辑表名必须是小写的实际SQL表名比如t_order而不是物理表名。分片列名和分片值要和你配置的分片算法对应上。比如我们的演示规则是按user_id分片那么调用方式就是HintManager hintManager HintManager.getInstance(); hintManager.addDatabaseShardingValue(t_order, user_id, 1024L); hintManager.addTableShardingValue(t_order, user_id, 1024L); try { // 这里是你的业务代码执行SQL ListOrder orders orderMapper.selectRecentOrders(); } finally { hintManager.close(); }需要说明的是不同小版本之间方法签名有过微调。有的版本提供addDatabaseShardingValue(String logicTable, Object value)这种省略列名的重载有的版本则是必须在第二个参数传入分片列名。我在5.4.1版本中实测带列名的写法兼容性最好所以建议你的代码里统一使用三参数版本。setDatabaseShardingValue和setTableShardingValue是简化版本适合只有一个数据库分片键的场景。我通常在物理分库规则很清晰、且不需要指定表分片时使用它。它最典型的一个好处是不管当前有多个逻辑表只要这个分片键覆盖了全部表就能让它统一生效不需要一个个表去add。setMasterRouteOnly是读写分离场景的核心方法。它在5.x版本中有时命名为setMasterRouteOnly早期版本可能叫setMasterRouteOnly或setWriteRouteOnly实际生效语义一致强制当前线程的路由目标为主数据源使读请求也走写库从而避免主从延迟。这里还要提醒一个细节如果你在一个方法里同时用了setMasterRouteOnly和addDatabaseShardingValue要理解它们的组合语义。setMasterRouteOnly控制的是读写分离数据源组的内部选择addDatabaseShardingValue控制的是分片库表的选择两者是不同层级的路由干预。在同时配置了读写分离和分片的复杂拓扑里Hints是叠加生效的。2.3 强制路由的线程边界与生命周期管理这部分是新手最容易出错的地方也是线上故障的高发区。HintManager内部使用ThreadLocal保存路由上下文。这意味着什么在一个线程里设置了Hint执行SQL的也必须是同一个线程。Spring的Async异步方法、线程池中的任务、消息队列的消费者回调这些场景下如果在主线程设置Hint然后通过线程池去执行SQLHint是绝对不会生效的。生命周期管理还有一个更隐蔽的坑close()方法必须幂等可靠地调用。我在代码里一直用try-with-resources或者try-finally模式保证不管业务代码是否抛异常Hint状态都会被清理。因为如果close()没有被执行当前线程的ThreadLocal会残留上一次的Hint值而线程池中的线程是复用的下一次任务会被污染的Hint影响出现诡异的路由错乱。这里有一个推荐写法用try-with-resources会非常优雅try (HintManager hintManager HintManager.getInstance()) { hintManager.addDatabaseShardingValue(t_order, user_id, 1024L); hintManager.addTableShardingValue(t_order, user_id, 1024L); return orderMapper.selectRecentOrders(); }注意HintManager实现了AutoCloseable接口所以可以直接放在try的括号里Java编译器和运行时都会保证close()被调用。在线程边界方面我实践中常用的策略是在进入异步任务内部之后再创建HintManager让设置和执行在同一个线程内完成。比如// 错误示范主线程设置Hint线程池中执行 executor.submit(() - { try (HintManager hintManager HintManager.getInstance()) { hintManager.addDatabaseShardingValue(t_order, user_id, userId); // 这里的SQL在同一线程中执行能正常路由 return orderMapper.selectByUserId(userId); } });上面的代码其实是对的因为HintManager和SQL执行都在线程池内部的同一个线程。如果你把HintManager.getInstance()放到submit之前那才是错误示范因为主线程的Hint在线程池线程里根本不可见。3. 三个可以直接复制的实战场景3.1 写后读一致强制主库路由实战先看第一个实战场景这个场景在电商交易系统里尤其常见。用户下单支付后前端立刻向后端发起查询订单状态的请求如果这条查询被路由到了备库而备库还没同步到最新数据用户就会看到“订单不存在”或“支付状态未更新”紧接着就是客诉和工单。思路是这样的对于强一致性的关键查询在Service方法内部设置主库路由让这次读操作直接走主库。ShardingJDBC的读写分离规则中主库是真正的数据权威读取一定是最新的。Service public class OrderQueryService { Autowired private OrderMapper orderMapper; public OrderVO queryOrderAfterWrite(Long orderId, Long userId) { // 写后读场景强制走主库 try (HintManager hintManager HintManager.getInstance()) { hintManager.setMasterRouteOnly(); Order order orderMapper.selectByUserIdAndOrderId(userId, orderId); return convertToVO(order); } } }注意配置这个功能的前提是你的数据源规则中已经配置了读写分离规则否则setMasterRouteOnly()通路不存在ShardingJDBC会忽略这个Hint。配置方式是在rules下加readwrite-splitting规则块rules: readwrite-splitting: >public TradeRecord queryLatestTradeByCardNo(String cardNo) { // 先从关联表获取 merchantId注意这一步自身可能涉及路由这里省略 Long merchantId merchantCardMapper.selectMerchantIdByCardNo(cardNo); if (merchantId null) { return null; } try (HintManager hintManager HintManager.getInstance()) { hintManager.addDatabaseShardingValue(t_trade, merchant_id, merchantId); hintManager.addTableShardingValue(t_trade, merchant_id, merchantId); return tradeMapper.selectLatestByCardNo(cardNo); } }Mapper public interface TradeMapper { // SQL中不包含分片键 merchant_id Select(SELECT * FROM t_trade WHERE card_no #{cardNo} ORDER BY create_time DESC LIMIT 1) TradeRecord selectLatestByCardNo(String cardNo); }注意这里Mapper中的SQL没有任何merchant_id条件但因为我们给t_trade设置了Hint值ShardingJDBC会优先使用Hint中的分片值路由。这个做法的收益非常明显一次原本要跨4个库8张表的广播查询变成了单分片内的精确查询响应时间从几百毫秒降到十几毫秒。这里有一个我踩过的坑Hint指定的逻辑表名必须和SQL中操作的表名完全一致且区分大小写。如果你在Mapper里写了FROM T_TRADEHint设置用t_trade匹配失败后ShardingJDBC会退回广播路由不会报错但性能就完全变了。排查这种问题耗时耗力建议在代码规范里强制规定SQL中一律使用小写的逻辑表名。3.3 批量数据订正任务多分片值逐一执行第三个场景非常适合做定时任务并且直接体现了强制路由对精确性的价值。线上偶尔需要做一些数据订正比如根据某天的对账文件批量更新订单的结算状态但对账文件里只有商户号。由于对账任务是对订单表t_order按商户维度进行更新天然无法通过user_id路由直接用merchant_id更新会导致每一条更新都走广播路由把全部分片都扫一遍。使用强制路由的做法是循环遍历每个可能的分片键值在每个分片内执行订正SQL。关键是HintManager的线程边界与循环的取舍。我最初的实现是把整个循环都包在一个HintManager实例中然后循环里多次执行SQL结果是只有第一次路由正确后续SQL全部错乱。原因很简单HintManager内部对于同一个逻辑表的分片值是按列存储的多次add同一个列的值会覆盖前值并且一个线程的Hint上下文不是递增清单而是既定状态。正确的姿势是每个分片键值创建独立的HintManager执行完一个分片再处理下一个public void fixOrderSettlement(ListLong merchantIds) { for (Long merchantId : merchantIds) { try (HintManager hintManager HintManager.getInstance()) { hintManager.addDatabaseShardingValue(t_order, merchant_id, merchantId); hintManager.addTableShardingValue(t_order, merchant_id, merchantId); orderMapper.updateSettlementByMerchantId(merchantId); } } }这样的循环每次都清空路由上下文重新设置虽然频繁创建HintManager对象但每个对象轻量可以忽略。真正要注意的是每次循环内的SQL必须确实只作用在该分片内否则就可能出现用A分片的Hint去执行了B分片范围的数据更新而这个错误在日志层面很难发现因为中间件不会校验你的Hint值和SQL结果是否一致。另外如果你在事务方法中使用强制路由批量订正事务的传播范围要格外小心。我的经验是保证一个HintManager的作用域与一个事务边界对齐。不要在一个长事务里反复切换Hint值这样会加大事务粒度的混乱度出问题时回滚范围也难控制。4. 高频踩坑与排查指南4.1 强制路由不生效先查这三处强制路由“明明设置了却没生效”是群里面提问率最高的问题。根据我自己和帮别人排查的经验原因基本集中在三个方面。第一线程不匹配。设置Hint的线程和真正执行SQL的线程不是同一个。排查办法很简单在业务代码里打一条日志打印当前线程名再看是同一个还是跨了线程池。第二逻辑表名或者分片列名不匹配。表名大小写、空格、别名都会影响匹配。还有如果你的Mapper方法里SQL用了t_order t这样的别名ShardingJDBC解析后表名还是t_order但某些复杂子查询里表名可能带上了schema前缀这时候Hint表名也必须对应上。第三设置了但被后续的close()意外清空。最常见的是在使用了Transactional的方法里某个Mock方案或者公共切片逻辑偷偷调用了HintManager.clear()或者close()。我遇到过同事在拦截器里统一清理ThreadLocal把别人正在使用的Hint也清掉了。我整理了一张排查速查表你在遇到问题时可以照着顺序排查现象可能原因处理方式完全走了广播路由所有库表都被扫表名或分片列名不匹配核对Mapper中SQL的表名与HintManager中传入的逻辑表名是否一致部分SQL生效部分没生效线程池执行跨线程设置将Hint生命周期移动至与SQL同一线程内第一次生效后面的失效HintManager实例被重复使用或提前close每个业务操作独立创建HintManager用try-with-resources行为时有时无ThreadLocal残留或主从切换排查是否有拦截器清理了Hint查看配置中读写分离规则是否歧义4.2 事务与强制路由的边界强制路由与Spring事务结合时需要清楚事务的边界。Spring默认的事务管理器会在事务开始时获取数据库连接并将这条连接绑定到当前线程。如果你在事务内先执行了一个非强制路由的SQL连接已经被绑定到某个具体数据源再通过HintManager去改变路由新的Hint对它已经没有作用了。所以我的经验是强制路由的HintManager设置必须放在事务内的第一条业务SQL之前。如果你在事务里既需要普通路由又需要强制路由就把它们拆成不同的事务边界。比如Transactional(rollbackFor Exception.class) public void bizMethod(Long userId, Long orderId) { // 先使用强制路由对指定分片的数据做更新 try (HintManager hintManager HintManager.getInstance()) { hintManager.addDatabaseShardingValue(t_order, user_id, userId); hintManager.addTableShardingValue(t_order, user_id, userId); orderMapper.updateOrderStatus(orderId); } }但要注意hintManager.close()执行后线程的Hint状态被清空事务内后续SQL如果还有路由诉求就要重新设置。如果事务连接已经拿到了后续SQL还会走同一个数据源不会自动改成其他分片这既是约束也是安全至少你不会因为Hint变化导致一个事务内的SQL分散到多个物理库表。另外还有一点强制路由本身不做数据校验。也就是说你设置addDatabaseShardingValue(t_order, user_id, 100L)但你的SQL实际查的是user_id 200的数据路由仍会走user_id 100对应的分片结果就是查不到数据或者在错误分片上执行。这一点对于开发来说非常反直觉因为在正常情况下数据库会通过WHERE条件保证数据筛选正确。强制路由把这一层保护也绕过了所以只能依靠开发者的细心来兜底。4.3 我总结的稳定性守则这是我最想分享的部分。强制路由用得越多越要有一套纪律来兜底不然迟早会出事。不要再业务代码里来回切换同一个HintManager实例的路由值。有时候你想着省一点对象创建开销一个方法里对不同的表设置不同的分片值但Hint的叠加和覆盖规则很容易让后面的代码执行到预料之外的节点。强制路由的调用要集中管理。我在项目里封装了一个路由工具类所有设置Hint的入口都走这个类这样后续如果ShardingSphere版本升级导致API变化只需改动一个类。这也是强制路由线上事故溯源的关键防线否则抓日志都不知道谁在什么时候改动了路由上下文。生产环境要开启路由痕迹日志。ShardingJDBC在设置props时可以开启sql-show: true启动后能在日志中看到每条SQL的路由结果。这个开关在排查强制路由问题时非常有用但在压测或全量流量下建议关闭或抽样开启因为日志量会严重影响性能。我一般只在预发环境和阶段性排查时打开确认后立即关闭。对强制路由的依赖不要扩散。如果某个查询频繁需要强制路由才能避免广播扫描说明分片键的设计已经不适应这个业务场景了该考虑二级索引表、数据冗余或者重新设计分片维度。强制路由是应急工具不是设计补丁。最后再分享一个任何人都可能遇到的小坑HintManager的close()方法在线程池环境下必须真的执行。如果你用try-with-resourcesJava会保证它被调用。但如果你为了实现某种“全局强制路由”效果尝试保存一个HintManager实例并反复复用它那不仅是错误用法还会在并发场景下造成严重的线程安全问题。我见过有人想把HintManager的声明提到线程池外面结果导致多个任务的路由互相污染数据更新错乱这个教训非常沉痛。我在项目里给团队立了一条简单的执行规范强制路由的代码只能出现在Service实现类内部并且必须方法级封闭进入到异步或者消息回调后立刻重新获取HintManager实例所有使用增加库表分片值的调用点必须保证后续的SQL带有该分片键的过滤条件。这条规范并不复杂但它帮我们挡住了大多数路由污染和生产故障。强制路由是ShardingJDBC给开发者打开的一扇手动窗。自动路由解决普遍问题强制路由解决特殊问题两者结合才能撑起一个灵活的分布式数据架构。希望这篇文章能帮你把它的用法、边界和坑都看清楚真到线上遇到问题时能多一个从容的应对手段。