先说个实务场景。线上有个查询接口时不时超时,业务方连续反馈了三天,日志里却只有我们应用层打印的“请求已发出”,至于第三方 SDK 到底把请求发出去没有、回包是什么、卡在哪一步,全都是黑的。这个 SDK 就是典型黑盒:你调它的方法,它内部自己组包、签名、发请求、解析响应,每一步都可能出问题,但每一步都不留痕迹。最初想的是把二十多处调用点全部手工包一层日志,可 SDK 升级后方法名一变,整片代码又要重写,实在忍不了。后来我试了两套基于 ByteBuddy 的方案:一套是动态代理,适合能替换 SDK 客户端实例的场景;另一套是字节码增强(Java Agent),能做到真正无侵入拦截。这篇文章把完整实现和几个把生产环境搞挂过的坑一起写出来,适合正要接第三方 SDK 的 Java 后端开发,也适合对字节码埋点感兴趣的人。
1. 从一次“调了但没日志”的线上事故说起
1.1 事故还原:SDK 内部完全是个黑盒
当时现象很典型:某个定时任务每隔一段时间调用 SDK 的查询方法,偶发返回超时异常,但异常堆栈只到 SDK 内部某个HttpClient.execute,参数和响应报文一概看不到。推断是网络抖动,可没有日志背书,谁也不敢拍板。后来我们尝试在调用点加log.info("req={}", request),发现真正问题不在调用点,而在 SDK 内部做了好几层封装——某个超时配置在内部组件里被静默覆盖了。这件事让我意识到:第三方 SDK 接进来之后,第一步不是跑通业务,而是先想办法让它“说话”。
给 SDK 加日志这件事,难点不是“加”,而是“加在哪”。你只能看到自己写的调用代码,SDK 内部几十个类之间的互调完全无感。如果 SDK 还用了独立线程、异步回调、内部缓存,那问题就更麻烦,因为你根本不知道程序实际执行路径长什么样。
1.2 手工埋点、JDK 动态代理、CGLIB,为什么都不顺手
先列一下当时我考虑过的三个路线,以及它们各自的死穴:
| 方案 | 需要改业务代码 | 能拦 SDK 内部自调用 | 能拦静态方法 | 典型限制 |
|---|---|---|---|---|
| 手工封装门面 | 是 | 否 | 只能重新封装 | 工作量大,SDK 升级后要同步改 |
| JDK 动态代理 | 是,替换实例 | 否 | 否 | 只支持接口,没法拦类 |
| CGLIB 子类代理 | 是,替换实例 | 否 | 否 | final 类、final 方法不行 |
| ByteBuddy 动态代理 | 是,替换实例 | 否 | 否 | 比 CGLIB 灵活,但同样覆盖不了内部自调用 |
| ByteBuddy Agent | 否 | 是 | 是 | 需要理解字节码增强和类加载机制 |
手工门面最土,但最可控,缺点是一旦 SDK 升级或者新增方法,门面类就得跟着改,维护成本很高。JDK 动态代理只能对接口生成代理,很多 SDK 直接给实现类甚至抽象类,这条路直接堵死。CGLIB 能对类做子类代理,但 SDK 的客户端类一加 final 就完蛋。ByteBuddy 在动态代理这一层几乎兼容了前面两者的能力,还能优雅处理构造方法、静态方法——当然,这需要切到 Agent 路线才能真正发挥出来。
正是因为这些限制,我最后选择了 ByteBuddy 作为统一方案:能替换实例的场景用动态代理,不能替换实例或要拦截内部调用的场景直接上 Java Agent。两种模式可以互补,共用同一套日志记录逻辑。
2. ByteBuddy 动态代理:适合你能替换 SDK 客户端实例的场景
2.1 先分清 ByteBuddy 的“动态代理”到底做了什么
很多人一说到动态代理就想到java.lang.reflect.Proxy。ByteBuddy 做的其实是更底层的事情:在运行时生成一个目标类的子类(或者目标接口的实现类),然后把拦截逻辑通过MethodDelegation织入到指定方法上。和 JDK 代理相比,它不要求目标必须是接口,返回值类型推断也更灵活,代码写起来也更像“在一个类上定义拦截器”。
这种方案的使用前提是:SDK 允许你拿到它的客户端实例,并且你可以把它注册回去。比如很多 SDK 都是工厂模式创建一个 client,然后由你手动注入到业务服务里。这时候你就有机会在注入前替换成 ByteBuddy 生成的代理对象。只要外部调用都走这个代理,就能记录完整调用日志。
2.2 完整代码:生成代理、委托调用、记录日志
Maven 依赖先加上,版本选一个比较新的稳定版:
<dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy</artifactId> <version>1.14.17</version> </dependency>这里只需要byte-buddy,如果后面要动态 attach 再加byte-buddy-agent。
假设 SDK 给了这么个接口:
public interface DemoPayClient { PayResult createOrder(CreateOrderRequest request); QueryResult queryOrder(String orderId); }我们生成一个代理实现,把所有方法调用委托给原始实例,同时记录日志:
import net.bytebuddy.ByteBuddy; import net.bytebuddy.implementation.MethodDelegation; import net.bytebuddy.matcher.ElementMatchers; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class ByteBuddyProxyFactory { /** * 为接口生成代理,所有方法调用都会被 DelegateInterceptor 记录。 */ public static <T> T wrap(Class<T> iface, T delegate) { Class<? extends T> proxyType = new ByteBuddy() .subclass(iface) .method(ElementMatchers.isDeclaredBy(iface)) .intercept(MethodDelegation.to(new DelegateInterceptor<>(delegate))) .make() .load(iface.getClassLoader()) .getLoaded(); try { return proxyType.getDeclaredConstructor().newInstance(); } catch (ReflectiveOperationException e) { throw new IllegalStateException("生成动态代理失败", e); } } }拦截器核心逻辑:
import net.bytebuddy.implementation.bind.annotation.AllArguments; import net.bytebuddy.implementation.bind.annotation.Origin; import net.bytebuddy.implementation.bind.annotation.RuntimeType; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; public class DelegateInterceptor<T> { private final T delegate; public DelegateInterceptor(T delegate) { this.delegate = delegate; } @RuntimeType public Object intercept(@Origin Method method, @AllArguments Object[] args) throws Exception { long start = System.currentTimeMillis(); try { Object result = method.invoke(delegate, args); SdkCallLogger.log("proxy", method.getName(), args, result, System.currentTimeMillis() - start, null); return result; } catch (InvocationTargetException e) { SdkCallLogger.log("proxy", method.getName(), args, null, System.currentTimeMillis() - start, e.getCause()); throw e.getCause(); } } }而SdkCallLogger在第四章会专门讲。这套代码跑起来之后,调用方拿到的DemoPayClient其实是代理对象,业务代码完全无感。日志里可以看到每次调用的方法名、参数、返回结果、耗时和异常信息。
2.3 为什么这个方案没法用在最脏的地方
动态代理看起来很美好,但它有三个硬伤。
第一,SDK 内部如果存在自调用,比如createOrder内部又调用了自己的queryOrder,这个内部调用不会经过代理对象,日志会漏掉一层。因为代理对象只是一个实现了同一接口的新对象,SDK 内部持有的引用还是原始实例或者它自己创建的对象,你根本替换不了。
第二,如果 SDK 的关键能力是通过静态方法暴露的,比如DemoSdk.sign(data),动态代理完全拦不了。静态方法不依赖实例,没有“替换实例”这个操作空间。
第三,有些 SDK 客户端类是 final 的,或者构造方法私有。subclass会生成子类,遇到 final 类直接失败;遇到私有构造方法,你得额外配置构造方法的拦截逻辑,代码复杂度上一个台阶。
所以动态代理方案我只推荐在“客户端实例允许自己注入”且“主要调用路径都走这个实例”的场景用。如果 SDK 内部黑得厉害,直接跳到第三章的 Java Agent 方案,那才是真正意义上的兜底。
3. 核心方案:用 ByteBuddy Agent 做字节码级拦截
3.1 Java Agent 的原理一句话讲清楚
Java Agent 本质上就是一个带premain或agentmain方法的普通 jar,JVM 启动时通过-javaagent参数加载它,然后用Instrumentation把字节码转换器登记进去。ByteBuddy 的AgentBuilder封装了这套流程,我们只需要声明“哪些类的哪些方法要改”和“改成什么样”。关键点在于:增强发生在类加载阶段,SDK 内部后续无论怎么自调用,只要它加载的是被修改过的类,拦截就会生效。这是动态代理方案没法做到的。
这也是为什么我说它是“兜底方案”:你不需要替换任何实例,不需要改业务代码,只要目标类被加载,逻辑就被织入。
3.2 Agent 工程的依赖与打包注意事项
Agent 不能直接塞进业务工程里,一般单独抽成一个模块,最终打出一个可独立执行的 jar。Maven 依赖要同时引入 ByteBuddy 和它自带的 agent 工具:
<dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy</artifactId> <version>1.14.17</version> </dependency> <dependency> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy-agent</artifactId> <version>1.14.17</version> </dependency>打包时要在 Manifest 里指定Premain-Class(如果需要动态附加,再加Agent-Class),我用maven-shade-plugin最省事,它会自动把依赖合并进同一个 jar:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.2</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <manifestEntries> <Premain-Class>com.demo.logagent.SdkLogAgent</Premain-Class> <Agent-Class>com.demo.logagent.SdkLogAgent</Agent-Class> <Can-Redefine-Classes>true</Can-Redefine-Classes> <Can-Retransform-Classes>true</Can-Retransform-Classes> </manifestEntries> </transformer> </transformers> </configuration> </execution> </executions> </plugin>一个需要注意的打包细节:Agent jar 里尽量别依赖宿主应用自己的日志框架实现。原因在第六章类加载器那个坑里细说,简单讲就是“不要让你的拦截代码去引用宿主里才有的类”。
3.3 拦截规则与 Advice 代码
核心类长这样:
import net.bytebuddy.agent.builder.AgentBuilder; import net.bytebuddy.asm.Advice; import net.bytebuddy.matcher.ElementMatchers; import java.lang.instrument.Instrumentation; public class SdkLogAgent { public static void premain(String agentArgs, Instrumentation inst) { String[] parts = agentArgs == null ? new String[0] : agentArgs.split(","); final String typePattern = parts.length > 0 ? parts[0] : "com.example.sdk"; final String methodPattern = parts.length > 1 ? parts[1] : "pay"; new AgentBuilder.Default() .disableClassFormatChanges() .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) .type(ElementMatchers.nameStartsWith(typePattern)) .transform((builder, typeDescription, classLoader, module, protectionDomain) -> builder.method(ElementMatchers.nameStartsWith(methodPattern)) .intercept(Advice.to(SdkAdvice.class))) .installOn(inst); } }Agent 的入口参数我用逗号分隔:第一个是类名前缀,第二个是方法名前缀。比如启动时传-javaagent:agent.jar=com.example.sdk,pay,就会拦截com.example.sdk包下所有以pay开头的方法。你可以改成精确匹配ElementMatchers.named("xxx"),或者用正则ElementMatchers.matches(".*(pay|query).*"),但匹配范围越宽,日志量和风险越大,后面会专门讲。
SdkAdvice用 ByteBuddy 的 Advice 模式织入方法入口和出口:
import net.bytebuddy.asm.Advice; public class SdkAdvice { @Advice.OnMethodEnter public static long enter(@Advice.Origin("#t.#m") String method, @Advice.AllArguments Object[] args) { SdkCallLogger.logBefore(method, args); return System.currentTimeMillis(); } @Advice.OnMethodExit(onThrowable = Throwable.class) public static void exit(@Advice.Origin("#t.#m") String method, @Advice.Enter long startTime, @Advice.Return Object result, @Advice.Thrown Throwable throwable) { SdkCallLogger.logAfter(method, result, throwable, System.currentTimeMillis() - startTime); } }@Advice.Origin("#t.#m")会把被拦截方法的类名和方法名拼成字符串,日志里直接能看到来源。@Advice.Enter把enter方法返回的毫秒时间戳传给exit,这是 Advice 里跨方法传值的标准姿势。
3.4 为什么不直接用 MethodDelegation,而选 Advice
动态代理那节用的是MethodDelegation,它是把方法调用“委托”给某个拦截器对象;而Advice是直接把字节码以类似 try-finally 的方式内联到目标方法里。区别很关键:
MethodDelegation会生成一个具体的处理对象,调用时数值跳转到该对象的方法上,逻辑清晰,但性能略多一层调用。Advice则把enter / exit的代码直接复制到目标方法的入口和出口,没有额外对象和调用链,性能开销极低,适合高并发场景。
另外一个原因是栈信息。MethodDelegation会在异常堆栈里多插入一个拦截器方法的栈帧,线上看日志时容易干扰判断;Advice是内联的,堆栈几乎不会被“污染”。所以 Agent 场景我统一用Advice,动态代理场景才用MethodDelegation。
4. 日志记录的细节比拦截方法本身更重要
4.1 日志结构设计
拦截到方法调用之后,日志记录才是真正影响排查体验的地方。我最终把每条调用日志都做成一行 JSON,字段包含:方法名、入参、出参、耗时、异常类、异常信息、采样标记、时间戳。这样后续不管是写到文件、推到日志平台还是拿 grep 检索,都方便。
SdkCallLogger的核心实现:
import java.util.Arrays; import java.util.stream.Collectors; public final class SdkCallLogger { private static final ThreadLocal<Boolean> GUARD = ThreadLocal.withInitial(() -> Boolean.FALSE); private SdkCallLogger() { } public static void logBefore(String method, Object[] args) { if (GUARD.get()) return; GUARD.set(Boolean.TRUE); try { String argStr = Arrays.stream(args) .map(SdkCallLogger::toJson) .collect(Collectors.joining(" | ")); System.out.println("[SDK-LOG] enter method=" + method + " args=" + truncate(argStr, 2000)); } finally { GUARD.remove(); } } public static void logAfter(String method, Object result, Throwable throwable, long costMs) { if (GUARD.get()) return; GUARD.set(Boolean.TRUE); try { String line = String.format( "[SDK-LOG] exit method=%s cost=%dms result=%s throwable=%s", method, costMs, toJson(result), throwable == null ? "null" : throwable.getClass().getName() + ":" + throwable.getMessage()); System.out.println(truncate(line, 4000)); } finally { GUARD.remove(); } } }toJson 我这里用的是 Jackson 序列化,代理/Advice 代码里只依赖 JDK 的类(System.out、String.format),具体序列化工具在 agent jar 里一起打进去。这里先不展开 toJson 的细节,因为序列化本身有一个大坑,放到第六章。
强调一下GUARD这个 ThreadLocal。它在logBefore开始处置为 TRUE,logAfter结束处 remove。这个设计是为了防止无限递归——比如 SDK 方法内部又调用了另一个被拦截的 SDK 方法,或者序列化入参时恰好触发了某个被拦截的方法。有了守卫,日志模块自身产生的任何调用都不会再次进入日志记录逻辑。
4.2 脱敏:签名、密钥、令牌不能落到明文日志
给 SDK 打日志很容易好心办坏事。很多 SDK 方法入参里带着签名、appSecret、token 这类字段,直接序列化成 JSON 写进日志,等于把密钥泄露到了日志系统里。日志平台权限一旦没控好,后果很严重。所以在 toJson 之后还要过一层脱敏:
import java.util.regex.Pattern; public final class MaskUtils { private static final Pattern SENSITIVE_FIELD = Pattern.compile( "\"((?i)sign|signature|secret|token|password|key)\"\\s*:\\s*\"[^\"]*\"" ); private MaskUtils() { } public static String mask(String json) { return SENSITIVE_FIELD.matcher(json) .replaceAll("\"$1\":\"***\""); } }这个正则是示例级的,覆盖不了所有嵌套结构,但足以处理大多数 SDK 请求体的一层 JSON。如果你的 SDK 参数结构更深,可以换成基于字段名的递归脱敏,或者直接用 Jackson 的@JsonIgnore那是另一套思路了。我这里的原则是:宁可把字段整个打码,也不要赌日志平台没有外部访问风险。
4.3 截断、采样与 begin/end 对齐
日志过长会拖垮磁盘和检索。入参出参我在输出前统一截断,truncate实现大概是:
public static String truncate(String text, int maxLen) { if (text == null) return "null"; if (text.length() <= maxLen) return text; return text.substring(0, maxLen) + "...(" + text.length() + " chars omitted)"; }采样这件事特别容易踩坑。如果一个方法调用量是每秒上万次,每行日志都完整写下来,日志文件一天几个 GB 很正常。所以我在logBefore里加了采样开关,但注意采样必须在 begin/end 两端保持一致。我的做法是:在logBefore里用 ThreadLocal 记录本次是否需要记完整日志,logAfter读取同一个标记决定是否输出。否则就会出现“只有 enter 没有 exit”的半截日志,排查时反而更困惑。
private static final double SAMPLE_RATE = Double.parseDouble(System.getProperty("sdk.log.sampleRate", "1.0")); private static final ThreadLocal<Boolean> SHOULD_LOG = ThreadLocal.withInitial(() -> Boolean.FALSE); public static void logBefore(String method, Object[] args) { if (ThreadLocalRandom.current().nextDouble() < SAMPLE_RATE) { SHOULD_LOG.set(Boolean.TRUE); // 正常记录 } else { SHOULD_LOG.set(Boolean.FALSE); } } public static void logAfter(String method, Object result, Throwable throwable, long costMs) { if (!Boolean.TRUE.equals(SHOULD_LOG.get())) { return; } SHOULD_LOG.remove(); // 正常记录 }这样调整之后,sampleRate=0.1时只有 10% 的调用会成对记录,其余直接忽略。排查重大问题时把采样率调回 1.0 即可,方便灵活。
5. 线上服务不想重启?用 agentmain 动态附加
5.1 agentmain + VirtualMachine.attach 组合
有些服务不是你想重启就能重启的。premain 方案只对“启动时就带着 agent”的场景友好,已经运行起来的 JVM 需要另一条路:通过agentmain动态挂载。
Agent 的agentmain入口和premain写在一起,安装逻辑复用:
public static void agentmain(String agentArgs, Instrumentation inst) { String[] parts = agentArgs == null ? new String[0] : agentArgs.split(","); final String typePattern = parts.length > 0 ? parts[0] : "com.example.sdk"; final String methodPattern = parts.length > 1 ? parts[1] : "pay"; new AgentBuilder.Default() .disableClassFormatChanges() .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION) .type(ElementMatchers.nameStartsWith(typePattern)) .transform((builder, typeDescription, classLoader, module, protectionDomain) -> builder.method(ElementMatchers.nameStartsWith(methodPattern)) .intercept(Advice.to(SdkAdvice.class))) .installOn(inst); // 对已经加载的类做一次重转换,让增强立刻生效 for (Class<?> clazz : inst.getAllLoadedClasses()) { if (clazz.getName().startsWith(typePattern) && inst.isModifiableClass(clazz)) { try { inst.retransformClasses(clazz); } catch (UnmodifiableClassException e) { System.err.println("retransform skipped: " + clazz.getName()); } } } }然后外部用VirtualMachine把 agent jar 加载进目标进程:
import com.sun.tools.attach.VirtualMachine; public final class AttachSdkLogger { public static void attach(String pid, String agentJarPath) throws Exception { VirtualMachine vm = VirtualMachine.attach(pid); try { vm.loadAgent(agentJarPath, "com.example.sdk,pay"); } finally { vm.detach(); } } public static void main(String[] args) throws Exception { // 用法:java AttachSdkLogger <pid> <agent.jar> attach(args[0], args[1]); } }JDK 9 以上如果遇到模块相关异常,编译或运行时记得加上--add-modules jdk.attach。正常打包出来的 JDK 自带这个模块,只是默认不在根模块集合里,手动引用时需要显式打开。
5.2 self-attach 的坑和 ByteBuddy 工具简化
一个隐蔽的坑是“给自己挂 agent”。如果业务进程和 attach 工具跑在同一个 JVM 里,比如通过一个运维接口触发动态挂载,JDK 9 以上默认禁止 self-attach,会抛IOException。解决办法是在目标 JVM 启动参数里加-Djdk.attach.allowAttachSelf=true。这个参数不是 JDK 的正式公共参数,不同版本表现不太一样,但在我实测的 JDK 11 上,加了这个才能真正把自己附加上去。
如果不希望手写这些兼容逻辑,byte-buddy-agent里提供了封装好的工具类,底层处理了不同 JDK 的 attach 细节。核心思路和上面的代码一致:找到目标进程、加载 agent jar、传递参数、分离。如果团队里运维同学不想关心这些底层机制,封装成一个命令行工具最稳妥。
动态附加有一个天然约束:只能对“可以被 retransform”的类生效。如果目标类被某个安全框架锁定了,或者 JVM 本身不支持类重转换(启动时没开相关指令),会直接失败。所以我在代码里做了isModifiableClass判断,遇到不可修改的类就跳过并打印警告,不至于让整条附加链路崩溃。
6. 生产环境实测中的 5 个坑(每一个都让程序出过问题)
6.1 无限递归:日志模块自己踩了被拦截的方法
第一次把 agent 装到测试环境,JVM 直接 StackOverflowError。原因很简单:某个 SDK 方法内部调用了另一个 SDK 方法,而我的logBefore在记录方法名时又通过method.toString()间接调用了某些被拦截的路径,日志模块自己再次触发了拦截。这个递归设计得特别隐蔽,不加守卫根本没发现。后来我在SdkCallLogger里加了 ThreadLocal 守卫,任何日志记录内部的调用都直接短路。这也是为什么我在第四章反复强调GUARD不能省。
6.2 重复增强:premain 和 agentmain 都进来了一次
一次线上事故里,同一方法被打印了两份日志,入参时间戳不同但方法名完全一样。原因是服务启动时带了 premain,中途运维又通过 agentmain 附加了一个相同的 agent jar,同一个目标方法被增强了两遍。解决方法是在 Agent 里维护一个全局安装标记,确保同一 JVM 只安装一次:
private static final AtomicBoolean INSTALLED = new AtomicBoolean(false); public static void installOnce(String agentArgs, Instrumentation inst) { if (!INSTALLED.compareAndSet(false, true)) { System.out.println("[SDK-LOG] agent already installed, skip."); return; } // 真正的安装逻辑 }premain 和 agentmain 入口都先走installOnce,重复加载直接被拦下。
6.3 类加载器不一致:Advice 里引用宿主类导致 NoClassDefFoundError
用 Agent 做增强时,目标类可能是由应用程序自己的类加载器加载的,而 agent jar 的类加载路径往往不同。有一次我的SdkAdvice里直接引用了宿主工程的 Logger 工厂类,结果目标类在字节码增强时找不到这个类,直接抛NoClassDefFoundError,把整个调用链打崩。解决原则是:Advice 相关的代码只依赖 JDK 标准库,自己引用的工具类也必须打进 agent jar,并且不要通过宿主的类加载器去拿。日志输出用System.out或者java.util.logging最安全,虽然丑,但稳定。
6.4 序列化把 SDK 内部对象拉开一场连锁反应
这是最阴间的一个坑。我在toJson里对出参对象做反射序列化,结果那个对象内部某个字段是懒加载的,序列化时的getter触发了 SDK 内部的网络请求,而那个请求又恰好匹配了拦截规则。于是日志系统在“记录一次调用”的过程中,间接引发了一次全新的 SDK 调用,整个链路被拉长了好几倍。后来我把“序列化所有字段”改成了“只序列化指定的少量字段”,并且对可疑对象直接走toString摘要而不做全量反射。这里想强调:打日志本身也是有副作用的,不要以为 log 就绝对安全。
6.5 匹配范围过宽导致日志爆炸
刚开始图省事,类型匹配直接写ElementMatchers.nameStartsWith("com.example.sdk"),结果 SDK 包底下负责 HTTP 传输的内部类也被拦了。一个业务请求进来,日志里出现几十行 SDK 内部组包、发请求、解析响应的记录,排障时反而分不清主次。这个问题的教训是:匹配规则要尽量精确。先只针对你关心的入口方法名(比如createOrder、queryOrder),确定有效后再逐步放宽。日志记录也一样,线上环境采样率先调到 0.01 观察,确认没影响再调高,不要一上来就 1.0 全量记录。
最后分享一点我自己的使用习惯
这套 ByteBuddy 埋点方案用了大半年,我现在接任何一个新的第三方 SDK,第一件事不是写业务代码,而是先把 SDK 的调用面理一遍:哪些方法最需要日志、哪些参数必须脱敏、这个方法有没有可能被高频调用。用 Agent 方式拦截时,我通常会先预置一个“只拦入口方法 + 采样率 0.01”的配置上线跑一天,确认稳定后再逐步放开。日志是给人看的,不是给机器刷的,记录得太多跟没记录一样难排查。如果你也想给自己的项目做无侵入调用日志,可以先从动态代理方案写起,跑通之后再切到 Agent,这样理解起来会更顺。