
这两年做性能测试我越来越觉得“慢查询日志”这四个字有迷惑性。很多团队一遇到接口响应慢第一反应就是打开MySQL的慢查询日志结果翻了大半天日志干净得像刚擦过的黑板一条超过阈值的SQL都没有。可接口就是慢慢得离谱。后来我换了个思路不在数据库层面死磕而是把探针插到应用代码里用插桩技术重新做慢查询测试整个定位路径一下子就通了。这篇文章把这条思路的来龙去脉、落地代码和踩过的坑完整分享一下适合正在被“接口慢但SQL不慢”这类问题折磨的测试开发、后端开发和性能测试工程师。1. 慢查询日志的边界数据库侧看不到的盲区远比你想象的多先说清楚一个前提慢查询日志不是没用它是数据库侧的诊断工具负责回答“哪条SQL在数据库里执行得久”。可现实中的慢接口大量问题根本不出在SQL执行本身。1.1 单条SQL不慢但调用次数爆炸最典型的场景就是N1查询。前端请求一个订单列表接口接口先查一次订单主表拿到100条订单然后在一个for循环里逐条去查订单明细表。单看每条明细SQL执行时间都是0.5毫秒在慢查询日志里连影子都见不到。但100条加起来就是50毫秒再加上网络往返、ORM映射、事务开销一个接口轻松突破300毫秒。这种问题慢查询日志是永远发现不了的因为它的统计单位是“单条SQL”而不是“一次完整调用里所有SQL的累计耗时”。我自己就遇到过这样的线上事故。接口平时30毫秒某天突然变成800毫秒慢查询日志零记录。最后是抓应用线程dump才发现线程全部卡在循环查询明细表的代码上。那次之后我就明白了一个道理慢查询测试不能只看数据库这一层必须能看到“业务方法调用了多少次SQL、每一次各花了多久”。1.2 连接池等待耗时不会进入慢查询日志第二个盲区是连接池。假设数据库连接池最大连接数是20某个瞬间有30个线程同时在请求连接剩下10个线程就得排队。排队时间是纯等待压根没有SQL在执行自然不会有任何慢SQL记录。但用户感知到的接口耗时是“排队时间 执行时间”可能已经超过1秒了。连接池的问题数据库侧诊断工具很难发现因为从数据库的视角看连接一旦建立SQL执行速度都正常。只有从应用侧出发统计“从租借连接开始到归还连接结束的整体耗时”才能暴露这个瓶颈。1.3 锁等待与事务相互干扰这时候有人会说InnoDB的行锁信息、锁等待超时日志总能看出来吧能但那是另一个层面的事了。比如一个事务A更新了某行但不提交事务B也去更新同一行B会被阻塞到事务A提交为止。B阻塞期间没有新增慢SQL数据库整体负载可能也不高但是接口响应时间已经飙升。更隐蔽的是死锁检测后的事务回滚或者一个长事务里多个普通SQL相互拖累。这些问题的共同点是慢的根源在“事务边界”和“锁的竞争关系”不在单条SQL。你只有站在应用代码的调用层往下看才能把事务里所有SQL拧成一个整体来分析。1.4 业务代码耗时是“隐形大户”还有一类更气人的场景SQL慢查询日志看起来人畜无害业务代码却在疯狂消耗CPU。我见过一个导出报表接口慢查询日志没有任何异常线程dump一抓全在Java的字符串拼接和容器JSON序列化上。插桩统计显示这个接口里SQL部分只占100毫秒但数据加工、格式转换、内存拷贝花了700毫秒。这类问题跟数据库完全无关但用户感知到的就是“接口慢”。慢查询日志作为数据库层的工具对这个场景是绝缘的。所以测试慢查询本质上测的是“一个业务请求端到端的耗时分布”而不是仅仅测数据库。1.5 缓存穿透与多级缓存的“伪慢查询”最后提一个跟“缓存”相爱相杀的场景。Redis缓存命中率本来挺高接口很快。结果某个热点Key过期或者布隆过滤器误判增多大量请求直接穿透缓存打到底层DB。DB压力上升后一些原本1毫秒的SQL膨胀到50毫秒从而触发慢查询日志告警。日志告诉你有慢SQL但真正的根因是缓存设计修复方式是加缓存预热或改造缓存策略而不是去优化SQL。这类问题的定位路径往往是先看到慢SQL告警然后追查请求链路的完整耗时分布最后才发现缓存的锅。而完整的请求链路耗时分布恰恰是应用层插桩最擅长输出的东西。慢查询日志不是不需要而是它只能回答“数据库内部发生了什么”。想回答“用户的请求时间到底花在哪了”必须引入应用层的观察手段。插桩技术解决的正是这个问题。2. 应用层插桩为什么说它是慢查询测试的“全景显微镜”既然要看“一次请求的完整耗时分布”就得在代码执行的各个关键节点打上探针这就叫插桩。插桩技术的基本原理不复杂在不侵入或尽量少侵入业务代码的前提下往方法执行前、执行后注入统计逻辑拿到每次调用的耗时、参数、调用来源然后聚合成测试结论。2.1 三个插桩粒度的分工我习惯把插桩分成三个粒度测试慢查询时三个粒度配合着看才能还原全貌。接口层插桩统计整个HTTP/RPC接口的耗时、入参、出参大小回答“慢不慢”。服务方法层插桩统计Service、核心业务方法内部执行耗时回答“慢在业务的哪一段”。数据访问层插桩统计SQL的执行耗时、参数、批量大小回答“SQL本身和调用次数是否有问题”。从下往上数据访问层的耗时最准服务方法层的耗时可以定位到代码位置接口层的耗时直接对应测试指标。三层数据对不上号的地方往往就是最有问题的地方。例如SQL所有整体耗时加起来只有200毫秒但Service方法测出来是800毫秒那中间600毫秒肯定消耗在非数据库操作上顺着这个线索就能逮到那个在循环里做复杂计算的坏味道方法。2.2 三种主流实现方式的取舍插桩具体怎么做业界常见的路线有三条按侵入性和灵活度排队。实现方式侵入性粒度学习成本适合场景Java Agent字节码增强Byte Buddy/ASM无侵入运行时修改字节码任意方法最细高需要对JVM和字节码有认识全链路追踪、生产环境灰度观察、通用性能测试平台Spring AOP注解切面低侵入只需加注解方法级及自定义切入点中等懂Spring即可Service层、Controller层耗时统计日常测试环境首选MyBatis/ORM拦截器低侵入无需改业务代码SQL执行前后中等了解拦截器机制即可SQL耗时、参数统计、N1问题排查三条路线不是互斥的。我在测试环境最常做的组合是MyBatis拦截器查SQL耗时Spring AOP切面查Service方法耗时再加个HandlerInterceptor查接口层耗时。这套组合几乎能覆盖60%到70%的慢查询测试场景而且完全不需要上线Agent不用改现有业务代码。2.3 什么时候才需要上Java AgentJava Agent是字节码层面的“终极形态”在JVM加载类的时候直接改字节码可以在不改源码、不接受Spring容器管理的类上插桩。如果被测系统包含大量工具类、静态方法、非Spring组件或者要在生产环境对任意方法做采样那就只有Java Agent能做到。但代价很实在类加载机制复杂JVM参数要调整对CRUD后测试人员的排查不友好而且误改字节码可能导致线上类加载失败。我的建议是测试阶段先别急着上Agent把AOP和拦截器的方案跑通拿到你需要的数据如果后面发现确实需要对JDK类库或第三方SDK内部方法插桩再考虑Agent。没必要一上来就把复杂度拉满。3. 一套可落地的慢查询插桩统计从SQL到接口的完整链路理论说再多不如直接上能跑的代码。下面这套是我在项目中沉淀下来的基础版本测试环境直接可以复用。它分成三层每一层解决一个具体问题。3.1 MyBatis拦截器统计SQL耗时、参数与最外层调用入口MyBatis拦截器实现起来非常顺手核心是实现Interceptor接口在invoke方法里拿到StatementHandler就能读取SQL语句和参数再统计执行前后的耗时。Component Intercepts({ Signature(type StatementHandler.class, method query, args {Statement.class, ResultHandler.class}), Signature(type StatementHandler.class, method update, args {Statement.class}) }) public class SlowSqlInterceptor implements Interceptor { private static final long SLOW_THRESHOLD_MS 200L; Override public Object intercept(Invocation invocation) throws Throwable { long start System.nanoTime(); try { return invocation.proceed(); } finally { long costMs (System.nanoTime() - start) / 1_000_000; String sql extractSql(invocation); String stackTrace buildCompactStack(); if (costMs SLOW_THRESHOLD_MS || isSqlCalledTooManyTimes()) { log.warn(slow-sql|cost{}ms|sql{}|stack{}, costMs, sql, stackTrace); } else { log.debug(normal-sql|cost{}ms|sql{}, costMs, sql); } } } private String extractSql(Invocation invocation) { StatementHandler handler (StatementHandler) invocation.getTarget(); BoundSql boundSql handler.getBoundSql(); return boundSql.getSql().replaceAll(\\s, ).trim(); } private String buildCompactStack() { StackTraceElement[] stack Thread.currentThread().getStackTrace(); StringBuilder sb new StringBuilder(); int depth 0; for (StackTraceElement element : stack) { String className element.getClassName(); if (className.startsWith(com.yourcompany.biz) depth 10) { sb.append(className).append(#).append(element.getMethodName()).append(-); depth; } } return sb.length() 0 ? sb.toString() : unknown; } }有几个细节值得说。打印SQL的时候一定要把参数打出来否则你只看到“查询很慢”却不知道查的是哪个ID的明细。推荐在日志里输出实际参数比如where order_id ?要变成where order_id 123456。留意上面extractSql只拿了个原始的BoundSql想输出完整可执行SQL还得自己遍历ParameterHandler拿到参数建议大家在工程里把这一步补全。两个关键地方请大家留意其中的重要性。调用栈需要做截断处理。我上面设置成只抓com.yourcompany.biz包下的调用栈且最多10层。不然每条SQL都把完整线程栈打出来日志量巨大而且大量无关的框架栈帧会淹没真实原因。只保留业务包路径定位时一眼就能看到入口Service。慢SQL的阈值不建议统一写死。200毫秒是我在单体应用里的默认值如果你的系统本身很快可以降到50毫秒如果你的系统接口基准就要500毫秒太低的阈值只会刷屏反而没人看日志。3.2 Spring AOP切面统计Service方法耗时并聚合定位有了SQL耗时下一个问题是“这些SQL是从哪个方法发起的”。用Spring AOP切面统统计所有带ApiOperation或自定义SlowQueryProbe注解的方法把方法名、耗时、创建子调用次数串联起来。Aspect Component public class ServiceCostAspect { private final ConcurrentHashMapString, MethodMetric metricMap new ConcurrentHashMap(); Around(annotation(com.yourcompany.framework.annotation.SlowQueryProbe)) public Object around(ProceedingJoinPoint pjp) throws Throwable { String methodKey pjp.getSignature().getDeclaringType().getSimpleName() # pjp.getSignature().getName(); long start System.nanoTime(); try { return pjp.proceed(); } finally { long costMs (System.nanoTime() - start) / 1_000_000; MethodMetric metric metricMap.computeIfAbsent(methodKey, k - new MethodMetric()); metric.add(costMs); if (costMs 500) { log.warn(slow-method|method{}|cost{}ms|params{}, methodKey, costMs, truncateParams(pjp.getArgs())); } } } static class MethodMetric { AtomicLong count new AtomicLong(); AtomicLong totalCost new AtomicLong(); void add(long cost) { count.incrementAndGet(); totalCost.addAndGet(cost); } } // 通过一个HTTP接口或定时任务输出聚合结果 public MapString, Object snapshot() { MapString, Object result new HashMap(); metricMap.forEach((method, metric) - { long count metric.count.get(); long total metric.totalCost.get(); result.put(method, Map.of(count, count, avg, total / Math.max(count, 1), total, total)); }); return result; } }这个切面最大的用处不是“抓到一次慢方法”而是metricMap里积累的聚合数据。当测试跑完一轮回归你能直接输出一张方法耗时排行榜哪个Service方法调用次数最多、平均耗时多少、总耗时多少。配合上面的SQL拦截器把methodKey和SQL日志里的栈链对上就组成了“每个方法的SQL/业务耗时总和”这一步已经能覆盖大多数慢查询测试场景了。3.3 接口层拦截器串起完整请求维度最后再加一层接口耗时统计。如果你用Spring MVC用HandlerInterceptor的preHandle和afterCompletion统计整个HTTP请求耗时即可如果你内部走Dubbo或者gRPC就在RPC Filter层做同样的事。接口层数据主要用于对外输出测试报告比如“接口login耗时分布总体780msService层660msDB层430ms”产品经理和开发负责人看这种数据最直观。这一层还要顺便统计出参大小。接口慢有时候是因为返回了超大的JSON结构前端解析慢但后端CPU和SQL都还好这种“响应体胖慢”在接口层插桩里会原形毕露。Component public class ApiCostInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { request.setAttribute(_api_start, System.nanoTime()); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { Long start (Long) request.getAttribute(_api_start); if (start null) { return; } long costMs (System.nanoTime() - start) / 1_000_000; String path request.getRequestURI(); int respSize response.getContentLength(); log.info(api-cost|path{}|cost{}ms|respSize{}, path, costMs, respSize); } }3.4 与自动化测试框架的联动这一层插桩做好了可以把统计结果暴露成一个专门的HTTP端点比如/internal/metrics/snapshot测试脚本里直接断言。如果你用pytest做自动化测试可以这样写def test_order_detail_slow_query(): # 先调用被测接口 client.get(/order/detail?orderId123456) # 再拉取插桩聚合数据 metrics client.get(/internal/metrics/snapshot).json() order_service_avg metrics[OrderServiceImpl#queryDetail][avg] assert order_service_avg 300, fOrderServiceImpl#queryDetail avg cost too high: {order_service_avg}这种“先跑业务再查插桩数据”的闭环方式比单纯断言HTTP响应时间更稳定。因为再好的性能测试也躲不开宿主机偶发的资源抖动和第二轮垃圾回收Stop-The-World但插桩聚合数据看的是多次调用的平均分布对偶发抖动天然免疫。4. 一个真实案例接口慢得像蜗牛慢查询日志却“很干净”下面用一条实际排查链路完整演示插桩技术怎么定位慢查询问题。案例背景是一个用户中心的“消息列表接口”测试环境响应时间基线是150毫秒某天突然掉到1.2秒开发第一反应是查MySQL慢查询日志结果一条记录都没有。4.1 第一轮排查为什么扑空一开始大家顺着SQL优化思路走了一遍检查索引、看执行计划、看锁等待数据库侧一切正常。然后怀疑网络抖动ping、tcpdump都做了一遍也没有异常。最后是压上插桩工具才把这个问题带到阳光下。4.2 测试脚本侧的压力回放我先用一条测试脚本把线上读流量回放到测试环境跑了两分钟立刻去看插桩聚合数据。AOP切面的方法排行榜上第一行也不是什么SQL相关方法而是一个看起来不起眼的UserMsgUrlDecorator#batchDecorate总耗时占了这个接口端到端耗时的62%。进一步观察发现这个方法的调用次数达到846次平均耗时0.9毫秒。单看0.9毫秒肯定不慢但846次累计就是761毫秒。4.3 插桩数据带来的关键转机这个batchDecorate方法名太普通了没有插桩的话根本不会有人注意到它。顺着SQL拦截器输出的调用栈往下一看最外层调用入口是MessageServiceImpl#queryPage栈链下面跟着846次单条SELECT ... FROM message_extra WHERE msg_id ?。我截取了一段日志长这样slow-sql|cost2ms|sqlSELECT * FROM message_extra WHERE msg_id 234567|stackMessageServiceImpl#queryPage-UserMsgUrlDecorator#batchDecorate-MessageController#pageList ... slow-sql|cost1ms|sqlSELECT * FROM message_extra WHERE msg_id 234568|stackMessageServiceImpl#queryPage-UserMsgUrlDecorator#batchDecorate-MessageController#pageList第二次调用栈虽然长但一眼能定位到调用关系。这就是插桩比慢查询日志狠的地方不只告诉你“这条SQL花了几毫秒”还告诉你“这段SQL是被谁调起来的、同一条链路里调了多少次”。4.4 根因确认与修复真相大白了这是一个教科书级的N1查询。列表接口查出当前页的20条消息MessageController调MessageServiceImpl#queryPage只执行1条分页SQL但随后在装饰器batchDecorate里为了拼上每条消息的扩展字段逐条执行了SELECT * FROM message_extra WHERE msg_id ?。如果当前页是20条消息就是一个SQL执行20次我这边压了846次说明测试脚本分页翻了好几十页每一页都在重复这个循环。修复方案也很简单把装饰器的单查改成批量传入20个msg_id执行一次WHERE msg_id IN (...) OR查询然后把结果按msg_id做本地Map映射。改完之后重新跑同一轮压测MessageServiceImpl#queryPage的耗时从761毫秒降到12毫秒接口总耗时回到160毫秒附近。如果没有插桩技术没有那行精确到业务方法名的调用栈这轮排查大概率还要耗上一整天。4.5 这个案例给测试工作的启示案例里最值得记住的一个点单条慢SQL阈值再低也逮不住0.9毫秒的SQL。真正制造问题的是“调用模式”是代码写了一个糟糕的循环。插桩技术恰恰能在方法级查看调用关系和累计耗时这是慢查询日志、APM工具、线程dump都无法单独替代的能力。5. 插桩技术在慢查询测试中的进阶玩法除了“发现问题”插桩技术在慢查询测试里还有几个很强的玩法能让测试更主动而不是被动等故障发生。5.1 注入延迟主动制造“慢查询故障”插桩除了统计耗时之外还可以加入一个过滤器当某个方法名或某个SQL的WHERE条件命中测试规则时主动在方法内Sleep一段指定时间。我在故障演练里用过这个方案配合Chaos工具可以模拟一个SQL突然从1毫秒退化到500毫秒的效果然后观察接口熔断、降级、连接池放量这些行为是否正常。实现方式就是在拦截器的intercept方法里加一段判断逻辑比如通过配置中心拿到规则Map命中规则就Thread.sleep(500)。这样做的好处是故障可控、可回放、可递增比等着线上自然出问题高效太多了。5.2 与全链路追踪打通把插桩日志挂到traceId下插桩统计的数据要落地到排障流程里最好和全链路追踪产品做一次关联。做法很简单在入口拦截器里拿到traceId放到ThreadLocal或MDC里然后MyBatis拦截器、AOP切面打印日志时把traceId一起带上。这样以后上游调接口变慢测试同学在链路追踪平台上点开一次完整Trace每个SQL、每个方法耗时全都按时间轴排列排查效率提升不是一星半点。5.3 把插桩统计做成性能测试平台的“雷达图”我在团队里把/internal/metrics/snapshot接口的JSON输出接入了内部的性能测试平台每次回归都自动拉一次数据然后生成“方法耗时分布雷达图”。它会标记出TOP10方法、累计调用次数、平均耗时和90分位耗时。测试负责人打开平台就能看到到底是哪个方法本次迭代引入了额外SQL还是某个上游服务超时导致下游调用次数暴增。这套机制跑了几轮迭代之后我们甚至能在代码评审阶段就发现潜在的N1问题因为在评审前冒烟测试的插桩数据已经把关键路径方法扫描过一遍了。5.4 动态开关与采样率避免插桩自身成为瓶颈任何时候都要意识到插桩本身是有开销的。每次拦截都会多几次方法调用、日志输出、字符串拼接吞吐量一高这点开销就可能被放大。所以我的工程里会做一个全局开关和一个采样开关slow-query: enabled: true sample-rate: 1.0 slow-threshold-ms: 200 max-stack-depth: 10 exclude-packages: - com.alibaba.fastjson - org.apache.commons压测冒烟时采样率1.0全量采集长时间稳定性测试时采样率调到0.1或0.01只取一部分数据评估趋势。开关用ConditionalOnProperty控制测试环境打开生产环境默认关闭需要时才打开。6. 插桩使用的避坑经验这些坑我踩过你就不用踩了最后按老规矩把实操中踩过的坑集中说一遍。这些细节看起来小但能直接影响插桩方案能不能真正落地。6.1 日志输出会反向拖垮被测系统刚开始做SQL插桩时我理所当然地每条SQL都打一行日志结果一轮压测下来被测接口比原来慢了30%还多。罪魁祸首就是日志刷得太猛同步磁盘IO都被打满了。后来我改成两层日志策略慢SQL日志走WARN级别正常SQL的耗时统计只更新内存指标不落盘。等一轮测试结束后再按需输出聚合报告。性能测试是拿数据喂给被测系统前提是探针本身不能喧宾夺主。6.2 调用栈聚合别设太深有的系统入口在Controller层中间调了3个Service再往下走MyBatis执行SQL。如果你把插桩的调用栈打印设成20层、30层每一层都是框架代码看起来密密麻麻实际有用的就那几行业务包路径。做测试的同学不要追求完整的线程栈我建议把max-stack-depth设成5到10并且只保留你自己的业务包。否则两分钟压测下来日志文件能膨胀到GB级。6.3 批量SQL与异步线程的统计偏差插桩统计有一个天然偏差要心里有数。MyBatis批量执行SQL比如ExecutorType.BATCH或者配合分页插件插入LIMIT 10,20拦截器统计的时间往往不是一个单条SQL的真实耗时而是批量操作的总耗时。如果你拿着批量SQL的单次耗时去和一些单查SQL对比容易得出错误的优化结论把原本正常的批量逻辑误判成慢查询。另外异步线程和消息队列消费链路耗时统计不能和请求线程直接挂钩。我在项目里用Async的线程池处理过一条定时任务链路AOP切面统计到的耗时是任务的总耗时不是单次用户请求的耗时。所以看插桩数据时先搞清楚这段代码是在同步请求链路里执行还是在异步线程里执行两者的优化目标和评价标准并不一样。6.4 耗时阈值不要拍脑袋定慢SQL阈值设多少慢方法阈值设多少一定要从被测系统的基线数据里反推。我一般的做法是先用插桩方案跑一轮无压力基线比如50TPS小流量压10分钟把各方法P90、P99耗时的数据收集出来然后以P90耗时的1.5倍作为告警阈值。没有基线数据就直接定一个“0.5毫秒压测阈值”只会让日志天天刷屏最后谁也不看。6.5 环境隔离插桩代码从测试环境到生产的风险边界插桩代码能不能进生产环境这个话题很敏感。我的原则是统计型插桩可以做但要能满足两个条件。第一必须有动态开关默认关闭第二日志写异步绝不能干扰正常业务线程。而故障注入型插桩只在独立的生产演练环境或者熔断演习时才打开常规生产流量一律关闭。最稳妥的做法是抽一个独立分支把插桩代码用独立的包层级隔离测试环境用这个分支部署主分支不合并插桩逻辑从物理上把风险隔离开。最后再分享一个我自己的习惯。不管用哪种插桩方案我都在测试环境默认开启同时只对关键业务接口打开完整调用栈其余接口只做聚合统计。这样既能保证日常回归有数据可看又不至于被海量日志淹没。慢查询测试这件事本质上不是找一个“银弹工具”而是建立一套能穿透应用层、数据库层、中间件层的观察能力。插桩技术是这套观察能力的地基希望能给同样被困于慢查询日志盲区的你一点启发。