Sentinel 管不到 SQL 这一层——我写了个 MyBatis SQL 熔断器

Sentinel 管不到 SQL 这一层------于是我写了个 MyBatis SQL 熔断器 Starter

一个慢 SQL 能在几十秒内打垮整个服务。本文从一次生产雪崩讲起,聊聊我是怎么在 MyBatis 拦截器层做了一个"按 SQL 类型 + SQL 指纹"熔断的 Spring Boot Starter,以及里面几个值得说道的设计取舍。SDK 已开源、发布到 Maven Central,接入只要一个依赖 + 一段 YAML。

🔗 源码GitHub · Gitee(觉得有用点个 Star ⭐)

一、先讲个故事:一条慢 SQL 是怎么把服务搞挂的

某天晚高峰,订单服务接口大面积超时、告警炸了。排查下来根因很简单:一个列表查询的 SQL 因为某个新加的查询条件没走索引,变成了全表扫描,单次执行 30 秒+。

问题是,它不是慢一次就完事

  1. 一个慢查询占着一条数据库连接 30 秒不放;
  2. 高并发下,同类请求源源不断进来,连接被一条条占满;
  3. 连接池耗尽 → 后面所有 SQL(包括完全健康的)都拿不到连接、排队、超时;
  4. 上游线程全部阻塞在等连接上 → 接口超时 → 调用方重试 → 雪上加霜。

一条慢 SQL,最终拖垮的是整个库、整个服务

事后复盘,大家的第一反应是:"我们不是有 Sentinel / Hystrix 吗?"

二、为什么现成的熔断框架不够用

Sentinel、Resilience4j 这些熔断框架很成熟,但它们的工作层面是 接口 / RPC / 方法调用 这一级。它们能告诉你"这个接口失败率高了,熔断它",但它们不认识 SQL

  • 它们不知道是哪条 SQL 慢,只能把整个接口熔断掉------可这个接口里可能 90% 的 SQL 都是健康的;
  • 它们按"接口"聚合,而真正的故障源是"某一类 SQL"。同一个接口里,select * from order where ... 慢了,不代表 insert 也有问题;
  • 它们拦不到那些不在显式接口边界上的 SQL(定时任务、内部调用链深处的 Mapper 调用)。

我想要的熔断,粒度应该正好落在"SQL 类型 + SQL 结构"这一层

哪一类 SQL 慢了,就只快速失败这一类,别的 SQL 该跑跑、别误伤;而且熔断要发生在真正发请求到 DB 之前,把连接池保护住。

MyBatis / MyBatis-Plus 的 Interceptor(插件)正好是这个切面:所有 CRUD 都从这里过,能拿到 SQL、能拿到执行耗时、能在执行前拦截。于是有了这个 SDK。

三、它长什么样:接入只要两步

先给结论,剩下的都是原理。接入对业务代码零侵入

1. 引依赖 (Spring Boot 2.x,3.x 换 -spring-boot3-starter):

xml 复制代码
<dependency>
    <groupId>io.github.showingdata.starter.framework</groupId>
    <artifactId>sql-circuit-breaker-spring-boot-starter</artifactId>
    <version>2.1.5</version>
</dependency>

2. 配 YAML(按 SQL 类型分别配,四类都必填):

yaml 复制代码
sql-circuit-breaker:
  enabled: true
  select:
    timeout-ms: 10000          # SELECT 超时阈值
    failure-threshold: 3       # 连续超时 3 次触发熔断
    circuit-open-ms: 60000     # 熔断持续 60s
    cache-max-size: 10000      # 熔断状态缓存上限
  insert:    { timeout-ms: 5000, failure-threshold: 1, circuit-open-ms: 30000, cache-max-size: 5000 }
  update:    { timeout-ms: 5000, failure-threshold: 1, circuit-open-ms: 30000, cache-max-size: 5000 }
  delete:    { timeout-ms: 5000, failure-threshold: 1, circuit-open-ms: 30000, cache-max-size: 5000 }

重启即生效,不用改一行业务代码。之后某类 SQL 连续超时达到阈值,就会进入熔断:熔断期间这类 SQL 在本地直接快速失败、压根不发到 DB,给数据库腾出喘息空间;到期自动恢复。

为什么 SELECT 和 DML 要分开配?因为它们的风险画像完全不同:DML 持锁、影响面大,往往"1 次超时就该熔断";SELECT 种类多、容忍度高,可以"连续 3 次再熔断"。一刀切的阈值是不合理的。

四、核心设计,挑几个值得聊的

4.1 熔断的匹配单位:SQL 指纹,不是完整 SQL

如果按完整 SQL 做 Key,那 where user_id = 123where user_id = 456 会被当成两条不同的 SQL,各自独立计数------这显然不对:它俩结构一样,慢是因为这类查询有问题,跟具体参数无关。

所以熔断的匹配单位是 SQL 指纹:把参数占位符归一化掉,只保留结构。

sql 复制代码
-- 原始(两次调用参数不同)
SELECT * FROM order WHERE user_id = 123 AND status = 1
SELECT * FROM order WHERE user_id = 456 AND status = 2

-- 指纹(相同)
select * from order where user_id = ? and status = ?

生成规则就是:参数占位符(? / #{xxx})统一替换成 ?、合并空白、转小写,取 MD5 当 Key。

这样熔断一次,就把这一整类 SQL 都保护起来了,而不是被不同参数值"稀释"掉计数、永远攒不到阈值。

最终的熔断 Key 设计成:

sql 复制代码
datasource_id : sql_type : fingerprint_md5
例:default:SELECT:a3f2c1...

前缀的数据源标识是给多数据源场景用的(后面讲),中间是 SQL 类型,后面是指纹 MD5(避免超长 Key)。

4.2 状态机:只要两态,不要 half-open

很多熔断器是三态:CLOSED(正常)→ OPEN(熔断)→ HALF_OPEN(半开试探)。我这里故意只做两态

markdown 复制代码
            连续超时 >= failureThreshold
  CLOSED ──────────────────────────────→ OPEN
    ↑                                      │
    └──────── circuitOpenMs 到期自动重置 ───┘

为什么砍掉 half-open?

  • half-open 要管"试探额度"(放几个请求去探路)、要管并发下的额度回滚,复杂度不低;
  • 而 SQL 熔断的 failure-threshold 本来就很小(默认 3,DML 甚至 1)。OPEN 到期后直接全量放行,即便故障还没好,几次失败内就会重新熔断------快速再熔断的代价完全可以接受;
  • 两态语义简单、运维心智负担小:"要么在熔断、要么没熔断",没有中间态的玄学。

工程上的一个小细节:OPEN 期间,那些"在 OPEN 之前就已经发出、姗姗来迟才超时返回"的在途请求,它们的迟到超时不再累加、也不刷新窗口 ------保证 OPEN 窗口严格等于 circuit-open-ms,不会被在途请求悄悄延长。

4.3 一个容易误配的点:缓存的"访问过期"我不开放配置

每条 SQL 指纹的熔断状态,存在 Guava Cache 里(按 SQL 类型分了 4 个独立 Cache)。Cache 有两道驱逐:

驱逐策略 来源 作用
LRU 容量上限 cache-max-size(按类型配) 内存硬上界,防无限增长
访问过期 expireAfterAccess circuit-open-ms 推导(取 20 倍且 ≥ 5 分钟),不开放配置 清理长期不活跃的 SQL

重点是第二行:访问过期我故意不让你配

原因是它和熔断窗口有个强约束:访问过期时间必须显著大于 circuit-open-ms。否则会出现一个很隐蔽的 bug------某条 SQL 熔断后正处于 OPEN,但这段时间它没新请求进来(因为在快速失败、调用方可能也退避了),结果它的状态被"访问过期"提前驱逐了,下次请求一来发现没状态、当成全新的 CLOSED 放行......保护就这么被悄悄削弱了。

把它做成由 circuit-open-ms 推导,就从根上杜绝了"两个值配反"的误操作。内存上界仍然由 cache-max-size 兜着,访问过期只负责清理空闲项。能用约束消灭的误配,就不要留给配置项。

4.4 只对超时熔断,不对异常熔断

这是个刻意的边界:SQL 执行抛出的连接异常、语法错误、约束冲突等等,一律不计入熔断。只有"执行耗时超过阈值"才算一次失败。

为什么?因为熔断器要保护的是"慢 SQL 拖垮连接池"这个特定故障模式。语法错误是业务 bug、约束冲突是数据问题,它们不会占着连接不放、不会拖垮 DB,把它们计入熔断只会误伤(一段时间报错的 SQL 被熔断掉,反而掩盖了真正的业务问题)。职责单一,才不会乱。

4.5 高并发下的一个性能细节:异常不填栈

熔断快速失败时抛的 SqlCircuitBreakerException,重写了 fillInStackTrace() 直接跳过堆栈填充。

因为高并发熔断期间,这个异常可能每秒抛成千上万次,而 fillInStackTrace 是 JVM 里相当贵的操作(要遍历整个调用栈)。跳过它能省掉大量 CPU / 内存开销。

但这带来一个必须知道的坑(见下一节)。

4.6 踩坑预警:你 catch 不到这个熔断异常

承接上面------因为异常本身不带堆栈,而且 MyBatis 在往外抛的时候,会用 MyBatisSystemException 二次包装它。所以:

java 复制代码
// ❌ 你在 Service / Controller 里这么写,捕获不到!
try {
    orderMapper.queryByUser(param);
} catch (SqlCircuitBreakerException e) {   // 实际抛出的是 MyBatisSystemException,instanceof 不匹配
    ...
}

正确姿势是用全局异常处理统一接住,并且------划重点------用那个包装异常 MyBatisSystemException 来打日志,因为它的堆栈里才有完整的业务调用链(Controller → Service → Mapper):

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MyBatisSystemException.class)
    public ResponseEntity<?> handle(MyBatisSystemException ex) {
        SqlCircuitBreakerException cb = findCircuitBreaker(ex);   // 沿 cause 链找
        if (cb != null) {
            // 关键:用包装异常 ex 打栈,里面有业务行号
            log.error("[SQL熔断] 快速失败 | key={} | 业务调用栈见下方", cb.getCircuitKey(), ex);
            return ResponseEntity.status(503).body(...);
        }
        ...
    }
}

这样既享受了"不填栈"的性能,又能在出问题时定位到具体是哪个 Service / Controller 触发的。鱼和熊掌兼得,但前提是你得知道这个机制。

4.7 配置优先级:从一刀切到精细化

支持四层配置,从高到低覆盖:

复制代码
ThreadLocal 编程式  >  方法级注解  >  接口级注解  >  全局 YAML
  • 全局 YAML 是唯一能"按 SQL 类型分别配"的入口(细粒度);
  • 注解 / ThreadLocal 是粗粒度覆盖------对标注的 Mapper / 方法下所有 SQL 类型统一生效。

典型用法:

java 复制代码
@SqlCircuitBreaker(timeoutMs = 5000)                       // 接口级:本 Mapper 统一 5s
public interface OrderMapper extends BaseMapper<Order> {

    @SqlCircuitBreaker(timeoutMs = 2000, circuitOpenMs = 30000)  // 方法级覆盖
    List<Order> complexQuery(QueryParam param);

    @SqlCircuitBreaker(disableCircuitBreaker = true)            // 管理查询,跳过熔断
    List<Order> adminQuery(AdminParam param);
}

而 ThreadLocal 适合"当前这次请求临时放宽/关闭"的场景,比如定时任务做数据修复,明知会慢、又不想触发熔断:

java 复制代码
try {
    SqlCircuitBreakerContext.disableCircuitBreaker();
    orderMapper.batchFixData(ids);
} finally {
    SqlCircuitBreakerContext.clear();   // 必须 clear,否则线程池复用会污染下一个请求
}

这里有个设计取舍:拦截器不做兜底 clear 。因为我们希望 Service 层 set 一次,能对它接下来调用的多条 Mapper SQL 统一生效;如果拦截器执行完第一条 SQL 就清了,从第二条起就失效了。代价是把 clear() 的责任交给调用方(finally 里调),换来"一次设置、整段生效"的语义。

五、生产必备:可观测性

熔断器最怕"它默默在工作,但你不知道"。引入 spring-boot-actuator 后,SDK 自动暴露 5 项 Micrometer 指标,零额外配置

指标 类型 说明
sql.circuit.breaker.intercept.total Counter 拦截的 SQL 总数
sql.circuit.breaker.timeout Counter 超时次数
sql.circuit.breaker.open Counter 熔断开启次数(CLOSED→OPEN)
sql.circuit.breaker.fast.fail Counter 快速失败次数
sql.circuit.breaker.open.count Gauge 当前处于 OPEN 的熔断器数量(实时)

最实用的是最后那个 Gauge。配一条告警就够了:

promql 复制代码
# 当前有熔断器处于 OPEN,立刻告警;归零说明已全部自动恢复
sql_circuit_breaker_open_count > 0

这里还藏着一个大规模下才会暴露的坑timeout / open / fast.fail 默认带 mapper_id 标签,方便定位到具体 Mapper。但标签会爆时间序列------单服务的 series 数 ≈ Mapper 方法数 × 4(类型) × 3(指标)。几百个 Mapper × 多副本,Prometheus 直接被打爆(按 series 计费的后端还烧钱)。

所以留了个开关,一行关掉 mapper_id 标签,series 从 N×12 收敛到固定 12,定位具体 Mapper 改看日志:

yaml 复制代码
sql-circuit-breaker:
  metrics:
    include-mapper-id: false   # 规模大或对基数敏感时关掉

做基础设施型组件,一定要替使用方想到"规模上来之后"的事。默认带标签是为了开箱即用的体验好,但必须给一个"规模大了能退化"的逃生口。

六、还顺手做了哪些"生产级"细节

罗列几个,都是被生产环境磨出来的:

  • 消息通知只发一次 :实现 MessageCenterClient 接口就能把熔断事件推到钉钉 / 企业微信。但通知只在熔断首次打开时发,快速失败路径不发------否则高并发下一秒几千条快速失败,消息直接风暴。
  • 多数据源隔离 :熔断 Key 带数据源标识,A 库的慢 SQL 不会熔断到 B 库。用 dynamic-datasource 这种运行时切换框架的,实现一个 DataSourceKeyResolver 返回当前数据源 key 即可;单数据源零配置。
  • SELECT ... FOR UPDATE 的误判 :MyBatis 按 XML 标签判类型,SELECT ... FOR UPDATE 会被当成 SELECT 走宽松阈值,但它其实在持锁、风险更接近 DML。建议对这类方法单独加注解收紧。
  • 配置启动即校验 :四类 SQL 块缺一不可、字段非法(如 timeout-ms <= 0)直接启动失败------把错误暴露在启动期,而不是等线上某条 SQL 跑到才发现。
  • 日志统一前缀 [SqlCircuitBreaker] :方便 ELK 过滤,也给了独立 Appender 的配置范例(additivity="false" 别忘了,否则等于没隔离)。

七、它的边界:要知道它不是什么

诚实地说清楚边界,比吹功能更重要:

  1. 它是"事后统计型"熔断,不中断在途 SQL 。一条已经发到 DB、正在跑的慢 SQL,熔断器不会去打断它(那得靠 JDBC / 驱动 / 连接池的超时设置)。熔断器拦的是后续的同类请求,不让它们继续涌进去补刀。
  2. 状态在各实例内存里,实例间不共享。多副本部署时各算各的,配置阈值要理解成"单实例阈值"。流量不均时可以适当调低,让单实例更快收敛。
  3. 只认超时,不认其他异常(前面讲过,刻意的)。

这些不是缺陷,是明确的设计边界------一个组件清楚自己"不做什么",才不会在错误的期待下被误用。

八、小结

回到最初那个问题:一条慢 SQL 不该有能力打垮整个服务。

这个 SDK 的思路其实很朴素------把熔断下沉到 MyBatis 拦截器层,用 SQL 指纹做匹配单位,按 SQL 类型差异化配置,在请求真正发到 DB 之前快速失败。它补的正是 Sentinel / Hystrix 这类框架够不到的"SQL 这一层"。

但真正决定一个组件能不能在生产用的,往往不是核心算法,而是那些细节取舍:

  • 为什么只两态、不做 half-open;
  • 为什么访问过期不让配、而是推导出来;
  • 为什么只对超时熔断、不对异常;
  • 为什么异常不填栈、以及随之而来的"catch 不到"的坑;
  • 为什么 metrics 要留一个关标签的逃生口。

好的基础组件,是把"踩过的坑"和"想清楚的边界"沉淀成默认行为和约束,让使用方少踩坑。 希望这篇能给你做类似中间件时一点参考。


SDK 已开源并发布到 Maven Central,Spring Boot 2.x / 3.x 双版本支持,已在多个生产系统稳定运行。接入只要一个依赖 + 一段 YAML,欢迎试用、拍砖、提 issue。

📦 项目地址:

(如果这篇对你有帮助,点个赞、给个 Star,让更多被慢 SQL 折磨过的同行看到 🙌)

相关推荐
慧一居士1 小时前
SpringCloud 微服务Feigin 用的完整调用端和被调用的示例
java·spring cloud
CodeStats2 小时前
【虚拟机】 从 CPU 指令到虚拟机隔离:虚拟机就是一个“模拟了完整硬件的普通进程”
java·docker
我命由我123452 小时前
Jetpack Room - Room 查询返回列表无需判空、LIKE 关键字
android·java·开发语言·java-ee·android jetpack·android-studio·android runtime
平安的平安2 小时前
传统Java工程师第一次用飞算JavaAI生成SpringBoot项目
java
csjane10792 小时前
Redisson 限流原理
java·redis
一个做软件开发的牛马2 小时前
MyBatis 从零实战:完整搭建可运行 Demo,注解与 XML 双模式开发详解
java·后端
用户298698530142 小时前
Java 实践:查找与提取 Word 文档超链接
java·后端
Flittly2 小时前
【AgentScope Java新手村系列】(9)SpringBoot集成
java·spring boot·spring
星环科技2 小时前
数据标准Agent ,让企业数据说同一种语言
java·开发语言·前端