想让 AI 改 Bug 快准狠?先给日志加个业务代码坐标
把日志贴给 AI,再补一句:"帮我看看哪里有问题。"
如果 AI 能读项目代码,你大概还会期待它继续往下做:找到调用位置,分析原因,给出修改,补上验证。
但有时候,它连第一步都需要绕一圈。
SQL 找到了,Mapper 找到了,调用这个 Mapper 的 Service 也找到了好几个。这次究竟走的是哪一个?
人查日志时缺少的线索,交给 AI 之后,依然缺。
所以,我给公共组件日志补了一项信息:本次调用对应的业务代码位置。
例如:
text
[a3Kd9xQ] (CouponService.java:118)
OrderMapper.findById 12ms => select id, status from orders where id = 7
AI 可以先从 CouponService.java 第 118 行附近读起,再沿相关逻辑继续分析。
想让它改 Bug 更快、更准,可以先把"该从哪里看起"说明白。
下文用简化的代码和日志说明排查过程,未报告 AI 工具实测结果或 Token 节省比例。
1. 查找引用,找出的是所有可能;你要查的是这一次
先看一个具体场景:一次下单请求里,相同的订单查询出现了两遍。
text
[a3Kd9xQ] OrderMapper.findById => select id, status from orders where id = 7
[a3Kd9xQ] OrderMapper.findById => select id, status from orders where id = 7
你的问题是:"第二次查询是不是多余?能不能复用第一次的结果?"
AI 能从日志里确认发生了两次查询。但要提出可靠修改,它还需要知道两次查询分别来自哪里。
搜索调用关系后,可能会发现:
| 调用者 | 查询订单的用途 |
|---|---|
| OrderCheckService | 校验订单状态 |
| CouponService | 计算优惠金额 |
| PaymentService | 检查支付条件 |
现在有了候选列表,还需要结合入口、条件分支和其他日志,确认本次请求实际执行了哪些位置。
如果日志直接带着 caller,信息会变成:
text
[a3Kd9xQ] (OrderCheckService.java:52) OrderMapper.findById => select id, status from orders where id = 7
[a3Kd9xQ] (CouponService.java:118) OrderMapper.findById => select id, status from orders where id = 7
排查起点就明确了:先看订单校验和优惠券处理这两个位置。
查找引用告诉 AI 哪些代码可能调用;caller 给出了这一次调用对应的代码位置。
原来需要搜索、阅读和推断才能补齐的一部分信息,现在直接出现在日志里。
这也解释了为什么一个行号有用:它能让后续代码检索围绕已发生的调用展开。
2. "快准狠",具体快在哪里、准在哪里?
我希望 AI 辅助排查时做到三件事:
| 目标 | 对应的排查动作 |
|---|---|
| 快 | 尽快找到值得先读的业务代码,减少候选位置之间的搜索 |
| 准 | 把本次运行的日志与对应调用点联系起来,有证据地分析 |
| 狠 | 把修改收敛到明确的问题上,用可验证的小范围改动解决它 |
caller 直接帮助前两步。能否给出正确修改,还取决于业务上下文和验证结果。
例如,看到两次相同 SQL,可以确认重复执行了查询,却不能立刻认定第二次应该删除。
AI 还需要检查:
- 两次读取之间,订单状态是否可能变化?
- 后一次业务是否需要读取最新状态?
- 两个调用的事务边界是什么?
- 复用查询结果后,是否改变了原有行为?
有了代码坐标,这些问题有了更明确的检查入口。它们仍然需要回答。
尤其是"没有抛异常,但行为不符合预期"的问题:重复查询、重复调用外部接口、某个分支读取了错误的缓存键。这时没有异常堆栈可以直接带路,日志里的业务位置就更有帮助。
如果异常栈已经明确指到相关业务代码,或者 AI 已经掌握了调用上下文,增加 caller 的收益会小一些。
3. traceId 找请求,caller 找业务调用点
日志里需要的两类信息,可以放在一起理解:
| 字段 | 提供的信息 | AI 可以据此做什么 |
|---|---|---|
| traceId | 相关日志属于哪次请求或链路 | 筛选同一次请求的上下文 |
| caller | 本次调用按规则定位到哪个业务位置 | 定向查找并阅读源码 |
| Mapper 或组件方法 | 执行了哪个数据访问或公共操作 | 确认操作入口 |
| SQL、参数、耗时 | 这次操作执行了什么、表现如何 | 提出待验证的原因假设 |
这些信息各有作用。MDC 可以同时承载 traceId 和 caller,日志模板负责把它们输出。
项目已经有 traceId 体系时,可以继续沿用,补上 caller 即可。前提是请求上下文已经正确传播,否则光有一个字段名无法串起日志。
我希望交给 AI 的上下文,既有运行时发生的事,也有返回源码的入口。
4. 为什么普通日志里的类名、行号还不够?
假设所有 HTTP 请求都经过一个公共工具类。
日志所在的位置可能是:
text
HttpClientUtil.java:57
它能帮你找到输出日志的代码。但是,几十个业务方法都可能调用这个工具类。
这一次你更关心的,可能是:
text
OrderService.java:86
两者分别指向日志输出位置和业务调用位置。
Logback 已有 %class、%line,也支持通过 %caller 输出多层调用栈。要进一步选出业务位置,需要明确哪些框架、代理和公共组件栈帧应该跳过。
dlz-caller 用到的主要思路,就是按规则过滤调用栈。简化为伪代码:
java
for (StackTraceElement frame : stack) {
if (isInfrastructure(frame)) {
continue;
}
return toLocation(frame);
}
从栈顶向外扫描,跳过配置中的基础设施帧,返回第一个符合定位规则的位置。
这比固定取"第 4 层"更适合封装层数会变化的项目。多一层重试、多一个代理时,需要依靠明确的过滤规则选择位置。
不过,过滤规则决定了结果落在哪一层。
例如业务 Service 调用 Facade,Facade 再调用公共 HTTP 组件。只过滤公共组件时,得到的可能是 Facade 中的调用点;把 Facade 也过滤掉,才会继续向外找。
因此要先确定期望的业务边界,不能承诺所有项目都会自动定位到最外层 Service。
对于前面的重复查询场景,还要检查嵌套作用域策略:如果两次查询都沿用同一个外层 caller,日志就可能只显示共同入口。每次采集是否能保留期望的调用位置,需要用实际输出确认。
5. MyBatis 先接上,看看多出来的坐标有没有用
以 6.7.5 版本为例,引入 MyBatis 集成 starter:
xml
<dependency>
<groupId>top.dlzio</groupId>
<artifactId>dlz-caller-mybatis-spring-boot-starter</artifactId>
<version>6.7.5</version>
</dependency>
开启对应 Logger:
yaml
logging:
level:
dlz-sql: DEBUG
执行:
java
orderMapper.findById(7L);
日志消息体的示例效果:
text
(OrderService.java:86) OrderMapper.findById 12ms => select id, status from orders where id = 7
日期、级别、traceId 等前缀仍由项目的日志模板控制;输出 traceId 时,使用项目对应的 MDC key。
这条消息把业务坐标、Mapper 方法、调用耗时和带参数的 SQL 放在一起。消息体已经包含 caller,统一模板时避免重复输出即可。
这里有两个会影响诊断的细节:
耗时要看计时范围。 示例中的 12ms 是应用侧观测值,不能直接等同于数据库服务端的纯执行时间,也不表示它一定是慢 SQL。
带参 SQL 是诊断文本。 参数填充需要识别引号和注释,避免把字符串里的 ? 当占位符。遇到特殊 JDBC 类型、自定义 TypeHandler 或方言时,仍要结合实际参数绑定行为分析。
如果还接入了分页、租户、数据权限等 SQL 重写插件,用一条真实查询确认日志包含预期条件。采集时机、拦截方法和插件组合都会影响结果。
6. HTTP、Redis 这些公共入口,也可以补上位置
手动接入时,把 caller 作用域放到公共组件入口。下面省略 import 和依赖注入:
java
// 位于 com.example.infra.stock 包
public class StockClient {
public void deduct(Long skuId, int count) {
try (MdcContext ignored = DlzCaller.caller()) {
log.info("库存扣减 sku={} count={}", skuId, count);
// 原有的库存服务调用逻辑
}
}
}
启动时配置需要跳过的公共组件包:
java
DlzCaller.getProperties()
.addIgnoreCallerPackage("com.example.infra");
日志 pattern 增加 MDC 字段:
text
%level [%X{traceId}] [%X{dlz-caller}] %logger{0} - %msg%n
业务侧仍然正常调用:
java
stockClient.deduct(10086L, 2);
假设业务调用位于 OrderService.java 第 42 行,输出可以是:
text
INFO [a3Kd9xQ] [(OrderService.java:42)] StockClient - 库存扣减 sku=10086 count=2
MdcContext 管理作用域内的 MDC 状态,退出时恢复原值或清理,避免后续日志误带上之前的 caller。
这样,新业务调用已经接入的公共入口时,就不需要再额外传递一个来源参数。需要统一维护的是公共组件入口和定位规则。
异步场景则要额外处理上下文传播。工作线程里的调用栈无法还原提交任务前的那段同步栈;想保留提交位置,需要在线程切换前捕获,并在任务执行时正确设置、恢复或清理上下文。
7. 给 AI 的材料,也可以一起整理好
caller 提供位置,问题描述提供排查目标。两者都清楚,代码检索才更容易围绕问题展开。
可以用下面这样的模板交给能访问源码的 AI:
yaml
问题: 同一下单请求重复查询订单,判断是否可以安全复用查询结果
源码版本: "<与运行实例对应的提交号或版本>"
traceId: a3Kd9xQ
日志: |
<粘贴相关脱敏日志,保留 caller、Mapper、耗时和 SQL>
分析要求:
- 先读取 caller 对应的业务方法,再补充必要的调用上下文
- 区分日志已经证实的事实和仍需验证的推测
- 检查事务边界,以及后一次读取最新状态的必要性
- 如需修改,给出最小改动和验证方法
源码版本尤其重要。日志指向的是运行中那份代码的行号,本地已经改过文件时,同一行可能不再对应同一个调用。
如果项目里存在重名文件,还可以考虑给日志补充完整类名、方法名或源码路径。这是面向 AI 消费日志时值得增强的元数据,不代表本文示例已经自动提供了这些字段。
8. 更省 Token,值得期待,也值得测量
业务坐标能让代码检索更有针对性。是否因此减少 Token,以及减少多少,取决于项目规模、原始日志、模型已经掌握的上下文和工具检索方式。
一个 Mapper 只有一处调用时,普通搜索可能已经足够快。公共方法被大量复用、封装较深、运行路径又不明显时,caller 更可能发挥作用。
要验证收益,可以固定问题、源码和模型设置,在独立上下文中分别提供普通日志和带 caller 的日志,多次对照观察:
| 指标 | 用来判断什么 |
|---|---|
| 搜索次数、读取文件数 | 是否减少了为确认调用点所做的检索 |
| 输入与输出 Token 用量 | 总体上下文消耗是否减少 |
| 定位和修复结果 | 是否找对问题,修改是否通过必要验证 |
| 完成时间 | 整体排查过程是否缩短 |
不能只看读文件次数变少。只读一个文件就改错,代价更高。
组件自身同样有运行成本:抓栈、过滤、SQL 格式化和日志输出都需要资源。应按需要启用并在实际负载下评估;SQL 参数也要按项目要求脱敏,尤其在提交给 AI 之前。
让 AI 改 Bug "快准狠",第一步可以很小:先让它知道,本次运行究竟应该从哪段业务代码看起。
相关代码与文档:
机制参考:Logback 日志位置输出、MDC 上下文、MyBatis 插件链实现。
你让 AI 排查问题时,更常遇到哪一种情况:它还在找相关代码,还是已经找到了代码,却对业务意图理解有偏差?
这条 SQL 到底是谁调的?我给 MyBatis 日志加了个业务坐标
SQL 找到了,参数有了,traceId 也有了。
然后,你还是打开了 IDE,开始查调用关系。
比如,排查一个下单接口时,你发现同一个请求里,相同的查询出现了两次。日志简化后像这样:
text
[a3Kd9xQ] OrderMapper.findById => select id, status from orders where id = 7
[a3Kd9xQ] OrderMapper.findById => select id, status from orders where id = 7
是不是重复查询?哪个步骤查过了,后面为什么又查了一次?
你打开 findById,查找引用。订单校验在用,优惠券计算在用,支付处理也在用。中间可能还有一层通用 Service。
查引用能找到"哪些地方可能调用它",但你要确认的是"这一次究竟走了哪些调用点"。
这些信息有时能从上下文推出来,有时还得补日志、重跑请求,或者本地打断点。
如果这两条日志各自带上业务位置,输出就可以是这样:
text
[a3Kd9xQ] (OrderCheckService.java:52) OrderMapper.findById => select id, status from orders where id = 7
[a3Kd9xQ] (CouponService.java:118) OrderMapper.findById => select id, status from orders where id = 7
现在,至少有一件事明确了:一次来自订单校验,一次来自优惠券处理。你可以直接打开这两个位置,判断第二次查询是否必要、结果能否复用。
不用先从所有引用里猜出这次走过的路径,排查可以从具体调用点开始。
上面是用于说明问题的示例。这个能力叫 caller,我把它用在了公共组件日志里。本文讲它怎么接入、怎么找对位置,以及在哪些场景里值得用。
1. traceId 帮你找到这次请求,caller 帮你找到这次调用
没有请求标识时,并发请求的日志交错在一起。时间戳和线程名能提供线索,但线程会复用,请求也可能跨线程,光靠它们很难可靠地还原上下文。
把 traceId 放进 MDC,再通过日志 pattern 输出,是常见的处理方式:
text
INFO [a3Kd9xQ] OrderService - 开始创建订单
DEBUG [a3Kd9xQ] HttpClientUtil - POST /stock/deduct
DEBUG [a3Kd9xQ] RedisClient - GET product:10086
前提是上下文已经正确传播。同一个 traceId,能帮你把相关日志筛出来。
但 HttpClientUtil 和 RedisClient 都是公共组件。看到它们的名字,仍然不知道本次调用来自哪一行业务代码。
这几个字段提供的信息不同:
| 日志信息 | 能回答的问题 |
|---|---|
| traceId | 这条日志属于哪次请求或链路? |
| Logger 名称 | 这条日志由哪个 Logger 输出? |
| 业务 caller | 按定位规则,这次调用来自哪个业务代码位置? |
MDC 可以同时承载 traceId 和 caller。前者关联请求,后者补充代码位置。
如果项目已经接入 APM,继续使用现有链路能力即可。单条日志里的业务坐标,可以作为排查时的一个直接入口。
它的收益也有前提:公共组件有多个调用者,而日志本身没有留下足够的来源信息。只有一处调用,或者异常堆栈已经明确指向业务代码时,增加 caller 的帮助就小得多。
它减少的是反查代码的步骤。为什么查了两次、为什么执行慢,仍然需要继续分析。
2. Logback 已经有行号了,为什么还要补 caller?
熟悉 Logback 的同学可能会问:
配上
%class、%line或%caller,不就知道谁调用了吗?
这里容易混淆两个位置:
- 日志输出位置 :哪一行代码调用了
log.info()。 - 业务调用位置:哪一行业务代码调用了这个公共组件。
假设日志写在 HttpClientUtil 里,输出日志所在的文件和行号,通常会得到:
text
HttpClientUtil.java:57
这个位置当然有用。但公共组件被几十个业务方法复用时,你更想看到的可能是:
text
OrderService.java:86
Logback 的 %caller 也支持输出多层调用栈。不过,中间隔着多少层封装、代理和框架代码,并不固定。把深度调大,可以看到更多帧;要从里面选出一个稳定的业务位置,还需要过滤规则。
要补上的,是"哪些栈帧算基础设施、在哪一层停下来"这套选择逻辑。
3. 把接入放在公共组件入口,业务调用处不用逐个补日志
最直接的做法,是每个调用方自己写一条:
java
log.info("准备扣库存,来源:createOrder");
stockClient.deduct(10086L, 2);
或者,把来源一路传下去:
java
stockClient.deduct("OrderService.createOrder", 10086L, 2);
能用,但维护成本落在了每个调用者身上。
尤其麻烦的是漏加:新增一个调用点,没有来源标识,业务照常运行,测试也可能全部通过。直到那条路径出了问题,你才发现关键位置没有留下来。
在同步调用还没结束时,来源其实就在当前调用栈里。
因此,dlz-caller 把接入位置放到了公共组件入口。以库存客户端为例,下面省略 import 和依赖注入,只看关键代码:
java
// 位于 com.example.infra.stock 包
public class StockClient {
public void deduct(Long skuId, int count) {
try (MdcContext ignored = DlzCaller.caller()) {
log.info("库存扣减 sku={} count={}", skuId, count);
// 原有的库存服务调用逻辑
}
}
}
业务侧继续正常调用:
java
public void createOrder() {
stockClient.deduct(10086L, 2);
}
启动时配置需要跳过的公共组件包:
java
DlzCaller.getProperties()
.addIgnoreCallerPackage("com.example.infra");
日志 pattern 中增加 caller 对应的 MDC 字段:
text
%level [%X{traceId}] [%X{dlz-caller}] %logger{0} - %msg%n
假设上面的业务调用位于 OrderService.java 第 42 行,输出效果就是:
text
INFO [a3Kd9xQ] [(OrderService.java:42)] StockClient - 库存扣减 sku=10086 count=2
这里沿用项目已有的 traceId 字段;如果项目使用其他 MDC key,替换成对应名称即可。
接入完成后,新的业务方法只要调用这个已接入的入口,就能得到同样的来源信息,不需要再传一个 caller 参数。
改动集中在公共组件入口,收益覆盖调用它的业务代码。
准确地说,这是"业务调用处无需逐个埋点"。公共组件仍需统一接入;新建了另一套公共组件,也要接上这套机制。
4. 怎么找对位置?关键在过滤规则
获取调用栈并不复杂,容易出错的是"取哪一帧"。
例如,直接取固定的第 N 层:
java
StackTraceElement caller =
Thread.currentThread().getStackTrace()[4];
在简单示例里可能刚好正确。加一层重试封装,或者多经过一个代理,位置就可能变了。
dlz-caller 的主要思路是按配置过滤栈帧。简化成伪代码,就是:
java
for (StackTraceElement frame : stack) {
if (isInfrastructure(frame)) {
continue;
}
return toLocation(frame);
}
从栈顶向外扫描,跳过框架、代理和已配置的公共组件帧,取第一个符合规则的位置。
这里有个重要细节:过滤规则决定了你最终看见哪一层。
假设一次调用经过这些方法:
| 调用顺序 | 方法 | 角色 |
|---|---|---|
| 1 | OrderService.createOrder | 业务入口 |
| 2 | StockFacade.deduct | 业务封装 |
| 3 | RetryClient.execute | 公共重试组件 |
| 4 | HttpClientUtil.post | 公共 HTTP 组件 |
如果只跳过公共组件,定位结果可能是 StockFacade。如果 StockFacade 也被配置为需要跳过,才会继续找到 OrderService。
所以,它定位的是过滤后最近的调用位置。把基础设施边界配置清楚,结果才有意义。
不要为了"只看业务代码",直接把整个 com.example 都加入忽略列表------业务代码也在里面。
对于开头的重复查询场景,还要关注捕获时机:每次 Mapper 调用,都需要得到适合这次调用的位置。如果整段业务始终沿用同一个外层 caller,两条 SQL 可能只显示同一个入口,区分内部调用点的能力就会减弱。
因此,过滤规则与嵌套作用域策略要一起看,用真实调用验证最终输出。
另一个细节是 MDC 的生命周期。
公共组件调用结束后,本次 caller 应该退出作用域。否则线程复用时,后续日志可能带上前一次调用的位置。
MdcContext 用 try-with-resources 管理这件事:退出作用域时恢复进入前的状态,原来没有值时则清理。这样,即使中途抛出异常,也能执行作用域的收尾逻辑。
5. MyBatis 场景:把坐标、方法、耗时和 SQL 放到一起
SQL 日志特别适合补 caller:Mapper 告诉你执行了哪个数据访问方法,业务坐标告诉你是谁发起了这次调用。
使用 MyBatis 集成 starter,可以把这部分接入交给插件。下面沿用 6.7.5 版本示例:
xml
<dependency>
<groupId>top.dlzio</groupId>
<artifactId>dlz-caller-mybatis-spring-boot-starter</artifactId>
<version>6.7.5</version>
</dependency>
开启对应 Logger:
yaml
logging:
level:
dlz-sql: DEBUG
执行:
java
orderMapper.findById(7L);
日志消息体的效果如下:
text
(OrderService.java:86) OrderMapper.findById 12ms => select id, status from orders where id = 7
一次排查常用的几个信息,放在了同一行:
- 业务坐标:打开哪份源码,从哪一行开始看。
- Mapper 方法:执行了哪个数据访问方法。
- 调用耗时:这个应用侧调用花了多长时间。
- 带参数的 SQL:查询条件究竟是什么。
日期、级别、traceId 等前缀仍由项目的日志模板控制。示例中的消息体已包含 caller,统一模板时注意避免重复输出。
这里的 12ms 只是格式示例,不表示它一定属于慢 SQL;耗时口径也取决于插件的计时范围,不能直接当成数据库服务端的纯执行时间。
填充参数,也不能简单地替换问号
原生 MyBatis 日志通常分别输出预编译 SQL 和参数。排查时把两者放在一起,更方便阅读和复现。
但不能见到 ? 就替换:
sql
select id
from orders
where remark = 'why?'
and id = ? -- 这里的问号也不是参数 ?
这条 SQL 中,需要填充的只有 id = ?。
因此,参数填充要识别引号和注释状态,区分占位符与普通文本。dlz-caller 的 SQL 格式化也做了这类处理。
同时要保留一个边界:日志中的带参 SQL 是用于诊断的文本表示。遇到特殊 JDBC 类型、自定义 TypeHandler、二进制值或数据库方言时,不能保证它与驱动实际绑定参数后的行为完全等价。
对于常见查询,它能减少手工拼参数的步骤;对于复杂类型,仍要结合原始参数和驱动行为判断。
6. 真正接进项目时,别忽略这几个边界
第一,抓栈有成本,不能用"零开销"概括。
调用栈获取、过滤、SQL 格式化和日志 I/O 都有成本。实现上应先判断诊断日志是否需要输出,再做抓栈和格式化。
"关闭对应日志级别时跳过这些工作",与"开启后没有额外成本",是两回事。高频接口要结合实际调用量评估。
第二,跨线程时,要先传播上下文。
Logback 的 MDC 按线程保存上下文。线程池、CompletableFuture 等场景,需要在提交任务时捕获所需上下文,在执行任务时设置,并在结束后恢复或清理。
而且,工作线程里重新抓栈,无法补回提交任务之前的那段调用栈。想保留任务提交位置,就要在切换线程前捕获它。
第三,要确认记录的是 SQL 重写之后的结果。
项目里如果还有分页、租户、数据权限等插件,应检查 SQL 采集的时机。
不能简单地用"某个插件排在最后"概括所有情况。MyBatis 的代理包装顺序、具体拦截方法,以及插件在调用前还是调用后读取 SQL,都会影响结果。
接入时用一条带分页或租户条件的查询,确认日志里出现了实际期望的条件,比只看插件配置顺序更可靠。
第四,参数越完整,越要管理好日志。
按本文版本的使用前提,SQL 参数不会自动脱敏。涉及手机号、证件号、令牌等敏感信息时,需要结合项目要求控制输出内容、访问权限和保留期限。
caller 提供代码入口;业务日志、异常堆栈、执行计划和链路信息,继续提供其他排查证据。
7. 值得先接入哪里?
可以先找一个最常让你"查完日志还要继续找引用"的地方:
- 被多个 Service 复用的 MyBatis 查询。
- 统一封装的 HTTP、RPC 客户端。
- Redis、文件存储等公共访问组件。
验证时,可以让两个不同业务方法调用同一个 Mapper,再检查日志能否区分这两个位置;也可以在已有封装外增加一层重试,看坐标是否仍然指向期望的业务边界。
这样的结果,才说明它确实减少了你项目里的排查步骤。确认一个高频场景有效后,再推广到其他公共入口。
相关基础能力来自 dlz-kit:MdcContext 管理 MDC 作用域,caller 用它承载调用位置。项目已有 traceId 体系时,可以继续沿用,单独补上 caller。
代码和文档:
也可以对照阅读 Logback 的位置输出说明、MDC 上下文说明和 MyBatis 插件链实现。
你排查 SQL、HTTP 或 Redis 日志时,最常卡在哪一步:找不到这次请求的完整上下文,还是找到了日志,却不知道该打开哪一行业务代码?