凌晨两点半,线上告警突然把我和隔壁组的同事从床上拉了起来。核心交易库的读写分离架构里,一条写操作刚提交,紧接着的读请求因为走了备库,查出来的是旧数据。主从同步延迟在这种流量高峰被无限放大,业务方看到的是“刚下单的订单竟然消失了”。排查到最后,问题不在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> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.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?serverTimezone=Asia/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?serverTimezone=Asia/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 List<Order> 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,然后通过线程池去执行SQL,Hint是绝对不会生效的。
生命周期管理还有一个更隐蔽的坑: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_TRADE,Hint设置用t_trade,匹配失败后ShardingJDBC会退回广播路由,不会报错,但性能就完全变了。排查这种问题耗时耗力,建议在代码规范里强制规定:SQL中一律使用小写的逻辑表名。
3.3 批量数据订正任务:多分片值逐一执行
第三个场景非常适合做定时任务,并且直接体现了强制路由对精确性的价值。线上偶尔需要做一些数据订正,比如根据某天的对账文件批量更新订单的结算状态,但对账文件里只有商户号。由于对账任务是对订单表t_order按商户维度进行更新,天然无法通过user_id路由,直接用merchant_id更新会导致每一条更新都走广播路由,把全部分片都扫一遍。
使用强制路由的做法是:循环遍历每个可能的分片键值,在每个分片内执行订正SQL。关键是HintManager的线程边界与循环的取舍。我最初的实现是把整个循环都包在一个HintManager实例中,然后循环里多次执行SQL,结果是只有第一次路由正确,后续SQL全部错乱。原因很简单:HintManager内部对于同一个逻辑表的分片值是按列存储的,多次add同一个列的值会覆盖前值,并且一个线程的Hint上下文不是递增清单,而是既定状态。
正确的姿势是每个分片键值创建独立的HintManager,执行完一个分片再处理下一个:
public void fixOrderSettlement(List<Long> 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-resources,Java会保证它被调用。但如果你为了实现某种“全局强制路由”效果,尝试保存一个HintManager实例并反复复用它,那不仅是错误用法,还会在并发场景下造成严重的线程安全问题。我见过有人想把HintManager的声明提到线程池外面,结果导致多个任务的路由互相污染,数据更新错乱,这个教训非常沉痛。
我在项目里给团队立了一条简单的执行规范:强制路由的代码只能出现在Service实现类内部,并且必须方法级封闭;进入到异步或者消息回调后,立刻重新获取HintManager实例;所有使用增加库表分片值的调用点,必须保证后续的SQL带有该分片键的过滤条件。这条规范并不复杂,但它帮我们挡住了大多数路由污染和生产故障。
强制路由是ShardingJDBC给开发者打开的一扇手动窗。自动路由解决普遍问题,强制路由解决特殊问题,两者结合才能撑起一个灵活的分布式数据架构。希望这篇文章能帮你把它的用法、边界和坑都看清楚,真到线上遇到问题时,能多一个从容的应对手段。