受检 / 非受检 · 栈轨迹 · try-with-resources · suppressed · Class · Method.invoke · inflation · setAccessible · @Retention · RuntimeVisibleAnnotations · Proxy · InvocationHandler · $Proxy0 · CGLIB

异常、反射、注解与动态代理:Spring 下面那一层

Spring 的 @Transactional 只是把四样 JDK 自带的东西拼在一起:动态代理生成一个替身类,把每次调用都交给一个 InvocationHandler;handler 用反射读方法上的注解,再用反射调真正的对象;真正的对象抛出异常,handler 按异常类型决定提交还是回滚。这一页不碰 Spring,用 JDK 自己手搭一遍:替身类里到底写了什么,反射为什么前 15 次慢,注解对象为什么也是个代理,异常为什么贵在“填栈”。

全景:一个订单对象的一生 · 第 2、3、7、10 站地图:J01 · 异常、反射、注解与动态代理(下一圈)上一圈(Spring 层):Spring AOP 代理原理、Spring AOP 与事务实验:JDK 17.0.20 实测

①为什么需要:第 3 站那个替身,自己能不能造一个

在全景第 3 站,这一步要解决的是:控制器手里的 orderService 其实是 Spring 生成的替身 OrderService$$SpringCGLIB$$0,它在 place() 前后开事务、提交或回滚,业务代码一行没改。上一圈的 Spring 页讲了 Spring 怎么挑替身、怎么排拦截器。这一圈往下走一层:不用 Spring,只用 JDK,能不能自己造出这样一个替身?造的时候要用到的反射、注解、异常,各自在底下是怎么工作的?第 2、10 站 Jackson 读 record 和 @JsonIgnore、第 7 站库存不足抛异常回滚,用的也是这同一套。

调用方proxy.place(…) 代理类 $Proxy2动态代理:运行时生成 InvocationHandler读 @Audit(注解 + 反射)、开事务 Method.invoke反射调真正的对象 OrderServiceImplplace() 抛 OutOfStockException(异常) handler 看异常类型:RuntimeException → 回滚;受检异常 → 照样提交。然后把原异常抛回给调用方。
需求懂了这一层,Spring 的“事务失效 10 种情况”就不用背了,都能推出来;面试问“动态代理怎么实现”“反射为什么慢”“受检异常和非受检异常怎么选”,也都在这里。

②没有它会怎样:每个方法里手写事务和审计

手写 每个方法都重复一遍
public BigDecimal place(long uid, long sku, int qty) {
    audit.log("下单", uid);
    Connection c = ds.getConnection();
    c.setAutoCommit(false);
    try {
        … 真正的业务 6 行 …
        c.commit();
    } catch (RuntimeException e) {
        c.rollback();
        throw e;
    } finally {
        c.close();
    }
}
// cancel()、pay()、refund() … 再写 20 遍

疼在哪

  • 业务 6 行,样板 12 行,20 个方法就是 240 行一模一样的代码。
  • 规则改了(比如“受检异常也要回滚”),要改 20 处,漏一处就是数据不一致。
  • 写静态代理类(OrderServiceTxProxy implements OrderService)能把样板挪出去,但每个接口都要写一个代理类,接口加个方法,代理类也要跟着改。
  • 缺的那一块:运行时自动生成代理类(动态代理),加上在运行时读到“这个方法要什么处理”(注解 + 反射)。

不懂精华时的典型误用:在 handler 里 catch 了 InvocationTargetException 直接往外抛,调用方收到的是一个包装异常,catch (OutOfStockException e) 永远接不住(⑥ 坑 4)。

③相邻方案:替身有几种造法

造法什么时候生成能代理什么谁在用
静态代理你手写,编译期任何东西少量、固定的包装
JDK 动态代理运行时,Proxy.newProxyInstance只能是接口Spring(有接口时可选)、MyBatis 的 Mapper、Feign、注解对象本身
CGLIB / ByteBuddy 子类运行时生成目标类的子类类(非 final,方法非 final / private)Spring Boot 默认($$SpringCGLIB$$)、Hibernate 懒加载、Mockito
AspectJ 织入编译期或类加载期改字节码任何方法,包括自调用、private需要连自调用都拦住时

本页用 JDK 动态代理手搭一遍,因为它是 JDK 自带、最小、能直接看到生成的类。CGLIB 的原理一样(生成子类,覆盖方法,转给拦截器),在上一圈的 Spring AOP 代理原理里已经画过。

④原理:四样东西各自怎么工作

先知道这些
  • Class 对象:每个加载进 JVM 的类,都有一个 java.lang.Class 对象描述它:有哪些字段、方法、注解。反射就是通过它“看”和“调”。
  • 调用栈:每调一层方法压一个栈帧,异常的栈轨迹就是把这些栈帧抄一遍。
  • invokeinterface 和方法表:见面向对象那页④-2;invokedynamic:见 Lambda 那页④-1。

④-1异常:一个对象,加上创建那一刻抄下来的栈

两类异常编译器管不管

  • 受检异常(继承 Exception,不是 RuntimeException):编译器强制调用方 catch 或在签名里 throws。适合“调用方能处理的、预期内的失败”:风控超时、文件不存在。
  • 非受检异常(RuntimeException、Error):不强制。适合“程序错误”或“没法就地处理的业务失败”:库存不足、参数非法。
  • 受检异常和 Lambda 合不来:Function.apply 签名里没有 throws,Lambda 里没法直接抛受检异常。这是今天大多数框架偏向非受检异常的一个原因。

贵在哪不是 throw,是 new

  • 异常的构造器会调 fillInStackTrace(),把当前整条调用栈抄一遍。栈越深越贵。
  • 实测 调用深度 10 / 100 / 1000 时,new 一个带栈的异常 1.3 / 5.8 / 43 µs,不填栈的 0.07 / 0.19 / 1.3 µs。
  • 所以“用异常做流程控制”慢,慢在每次都抄栈;真要这么用,就用不填栈的异常(构造参数 writableStackTrace = false)。
  • JIT 还会对同一处反复抛出的 NPE 等异常换成一个预先分配的、没有栈的对象:实测 第 115741 次起栈轨迹变空(⑥ 坑 3)。

④-2反射:拿着 Class 对象去找方法,再“间接地”调它

method.invoke(t, args)检查访问权限、参数个数 MethodAccessor委托给谁,会换 第 1~16 次:NativeMethodAccessorImpl.invoke0JNI 进 JVM 调方法:不用生成类,启动快;每次调用慢,JIT 内联不进去 第 17 次起:GeneratedMethodAccessor1.invoke第 16 次时现场生成一个类,里面就是一条普通的方法调用,JIT 能优化 这叫 inflation(膨胀):只调几次的方法不值得生成类,调得多了再“膨胀”成字节码。阈值 inflationThreshold = 15(JDK 17 源码)。 实测 用反射调一个打印调用方的方法:第 1 次的调用方是 NativeMethodAccessorImpl.invoke0,第 17 次变成 GeneratedMethodAccessor1.invoke。 JDK 18 起(JEP 416),核心反射改用 MethodHandle 实现(DirectMethodHandleAccessor,JDK 21 源码里有),不再有这两段式。

④-3注解:存在 class 文件的属性里,运行时读出来的是一个代理对象

④-4动态代理:生成的 $Proxy 类里到底写了什么

final class $Proxy2 extends Proxy implements OrderService { private static final Method m0, m1, m2; // hashCode equals toString private static final Method m3, m4; // find place public $Proxy2(InvocationHandler h) { super(h); } public final BigDecimal place(long u, long s, int q) throws RiskCheckTimeoutException { return (BigDecimal) h.invoke(this, m4, new Object[]{ Long.valueOf(u), Long.valueOf(s), Integer.valueOf(q) }); } // find()、equals() … 都是同一个样子 } 这个类说明了什么 · extends Proxy:Java 单继承,父类已经 占了,所以只能 implements 接口。 · 每个方法都一样:参数装箱 → h.invoke。 替身自己不知道要做什么,全交给 h。 · 异常表:声明过的异常、RuntimeException、 Error 原样抛;其他受检异常包成 UndeclaredThrowableException。 · equals / hashCode / toString 也被代理了, handler 里要小心处理。 实测 -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true 存下来 javap
上面是把 实测 javap 输出的字节码还原成 Java 的样子。类名:接口是 public 的,代理类放进 JDK 16 起新建的动态模块 jdk.proxyN(实测 @Retention 的代理是 jdk.proxy1.$Proxy0);接口不是 public 的,代理类必须和它在同一个包(实测 labs.….$Proxy2)。

JDK 动态代理继承 Proxy,实现接口

  • JDK 自带,不依赖第三方
  • 代理的是“接口”这份契约,和实现类无关
  • 没有接口就不行:实测 传类进去抛 OrderServiceImpl is not an interface
  • 每次调用都走 h.invoke + 反射,参数要装箱

CGLIB / ByteBuddy生成目标类的子类

  • 没有接口也能代理,Spring Boot 默认就用它(proxyTargetClass = true)
  • 可以生成直接调用父类方法的代码,不必反射
  • final 类、final 方法、private 方法覆盖不了,所以拦不住
  • 子类构造时要调父类构造器(Spring 用 Objenesis 绕开)
两种做法共同的边界:替身只能拦住“从外面经过替身”的调用。目标对象内部的 this.find() 用的是真正的对象,不经过替身:实测 下单时 place 里调的 find 没打印审计。这就是 Spring“自调用事务失效”的根,它不是 Spring 的 bug,是“替身”这种做法本身的边界;要连自调用都拦,只能用 AspectJ 改目标类自己的字节码。

④-5跟着一次调用走:四样东西在哪一步上场

    前三个场景和 ⑤ 的 JDK 17 实测输出一致;第四个是按生成类的异常表推出来的(handler 里抛一个 place 没声明的受检异常),没有单独跑。模拟逻辑在 node 里跑过。

    最少语法

    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
    @interface Audit { String value(); }
    
    OrderService proxy = (OrderService) Proxy.newProxyInstance(
        OrderService.class.getClassLoader(), new Class<?>[]{ OrderService.class },
        (p, method, args) -> {
            Audit a = method.getAnnotation(Audit.class);
            try { return method.invoke(target, args); }
            catch (InvocationTargetException e) { throw e.getCause(); }   // 拆开再抛
        });
    try (Conn c = ds.open()) { … }                                     // 自动 close,异常挂在 suppressed
    class OutOfStockException extends RuntimeException { … }

    ⑤亲眼看到:不用 Spring,给订单服务套一个替身

    四个文件:Model(OrderService 接口、@Audit、@BuildOnly、两种异常、OrderServiceImpl)、TxAuditHandler(手写的事务 + 审计拦截器)、Demo(5 段)。加上 -Djdk.proxy.ProxyGenerator.saveGeneratedFiles=true,生成的代理类会存成 class 文件,可以 javap。完整代码:study-labs/java/javase/exception-reflection-proxy →

    1TxAuditHandler.java
    class TxAuditHandler implements InvocationHandler {
      private final Object target;
    
      public Object invoke(Object proxy, Method method,
                           Object[] args) throws Throwable {
        Audit audit = method.getAnnotation(Audit.class);
        String name = audit == null
            ? method.getName() : audit.value();
        System.out.println("[代理] 开事务,审计:" + name);
        try {
          Object r = method.invoke(target, args);
          System.out.println("[代理] 提交");
          return r;
        } catch (InvocationTargetException e) {
          Throwable cause = e.getCause();
          boolean rollback = cause instanceof RuntimeException
                          || cause instanceof Error;
          System.out.println("[代理] 捕获 " + … + (rollback
              ? "回滚" : "受检异常,照样提交"));
          throw cause;
        }
      }
    }
    
    // Demo 里
    OrderService proxy = (OrderService) Proxy.newProxyInstance(
        OrderService.class.getClassLoader(),
        new Class<?>[]{ OrderService.class },
        new TxAuditHandler(impl));
    proxy.place(1024, 88, 2);     // 正常
    proxy.place(1024, 88, 100);   // 库存不足
    proxy.place(4040, 88, 1);     // 风控超时(受检)
    2运行 JDK 17.0.20,2 核 ARM
    == 1. 异常 ==
      主异常:库存不足:sku 88 要 100 件
      被压住的:连接 B 关闭失败
      被压住的:连接 A 关闭失败
      finally 里的 return 吞掉了异常
    == 2. 异常的成本 ==
      深度   10:带栈  1287 ns,不填栈   70 ns
      深度  100:带栈  5793 ns,不填栈  190 ns
      深度 1000:带栈 43399 ns,不填栈 1317 ns
      同一处 NPE 连抛:第 115741 次起栈轨迹变空
    == 3. 反射 ==
      第 1 次 invoke:调用方是 NativeMethodAccessorImpl.invoke0
      第 17 次 invoke:调用方是 GeneratedMethodAccessor1.invoke
      setAccessible(HashMap.table) → InaccessibleObjectException
      直接调用 2000 万次:464.7 ms;Method.invoke:603.3 ms
    == 4. 注解 ==
      @Audit(RUNTIME):@…Audit("查订单")
      @BuildOnly(CLASS):null
      读到的注解对象的类:…$Proxy1,是 Proxy 吗?true
    == 5. 动态代理 ==
      代理类:…$Proxy2,父类 java.lang.reflect.Proxy,final=true
      正常下单 2 件:
        [代理] 开事务,审计:下单
        [代理] 提交
        → 598.00(place 里 this.find() 没有打印审计)
      买 100 件:
        [代理] 捕获 OutOfStockException → 回滚
      用户 4040 风控超时:
        [代理] 捕获 RiskCheckTimeoutException → 照样提交
      拿类去做 JDK 代理 → OrderServiceImpl is not an interface

    包名前缀略去了,注解值原样输出时是 \u 转义。第 2、3 段的耗时每次运行会变,只看量级。

    到这里已经能交差了:你用 20 行 JDK 代码造出了一个 @Transactional 的雏形,亲眼看到了替身类的样子、受检异常为什么不回滚、自调用为什么拦不住。
    已知还没处理的细节:handler 里没拆 InvocationTargetException(⑥-1)、吞异常(⑥-1)、生产日志里 NPE 没有栈(⑥-1)、反射对象没缓存(⑥-3)。

    ⑥细节·规范·优化

    第一圈

    注意事项:不知道就会出事故

    坑 1受检异常不回滚,钱扣了单没建← ④-1 两类异常
    现象
    风控超时抛受检异常,事务照样提交,前面已经扣的库存不回来。⑤ 用户 4040
    根源
    拦截器按异常类型决定回滚:默认只认 RuntimeException 和 Error(Spring 的 @Transactional 也是这条规则,实测的手写 handler 照抄了它)。
    做法
    业务失败用非受检异常;或者 @Transactional(rollbackFor = Exception.class)。
    坑 2自调用绕过替身← ④-4 替身只拦外部调用
    现象
    place 里调 this.find(),find 上的审计、事务、缓存注解全部不生效。⑤ 实测
    根源
    this 是真正的对象,不是替身;替身只是“包在外面”。
    做法
    把要被拦截的方法挪到另一个 Bean;或者注入自己的代理(@Lazy 注入 self);实在不行用 AspectJ。
    坑 3线上日志里只有一行 NullPointerException,没有栈← ④-1 JIT 换成预分配异常
    现象
    服务跑了一阵以后,日志里的 NPE 只剩类名,at … 一行都没有,定位不了。⑤ 实测第 115741 次起
    根源
    HotSpot 的 OmitStackTraceInFastThrow(默认开):同一处反复抛 NPE、ArithmeticException、ArrayIndexOutOfBounds 等,JIT 编译后直接抛一个预先分配好的、没有栈的异常,省掉抄栈。
    做法
    往日志前面翻,找最早那几次(还有栈);排障时加 -XX:-OmitStackTraceInFastThrow。
    坑 4handler 里没拆 InvocationTargetException← ④-2 反射把目标异常包一层
    现象
    调用方 catch (OutOfStockException e) 接不住,日志里是 UndeclaredThrowableException 套 InvocationTargetException 套 OutOfStockException。
    根源
    Method.invoke 把目标方法抛的任何异常都包成 InvocationTargetException(受检);handler 原样抛出去,代理类的异常表又发现它没声明,再包一层 UndeclaredThrowableException(④-4)。
    做法
    catch InvocationTargetException,throw e.getCause()(⑤ 的写法)。
    坑 5吞异常:catch 了只打日志,或者 finally 里 return← ④-1 finally 的 return
    现象
    库存扣失败了,接口却返回成功;外层的事务拦截器看不到异常,照样提交。⑤ 实测 finally return
    根源
    异常是“沿调用栈往上冒”的信号,在半路被接住不再抛,上层所有依赖它的逻辑(回滚、重试、告警)都失效。
    做法
    处理不了就别 catch,要 catch 就包一层再抛(throw new OrderException("…", e),保留 cause);finally 里只做清理,不 return。
    坑 6注解读不到← ④-3 保留策略、读的位置
    现象
    自定义注解在运行时 getAnnotation 返回 null;或者注解写在实现类方法上,用接口的 Method 去读,读不到。
    根源
    没写 @Retention(RUNTIME) 时默认是 CLASS(实测 null);注解属于它所在的那个方法,接口方法和实现类方法是两个 Method 对象,方法上的注解也不会被继承。
    做法
    自定义注解一律写清 Retention 和 Target;读的时候想清楚读谁(Spring 用 AnnotationUtils.findAnnotation 会往接口、父类上找)。
    第二圈

    使用规范

    业务异常用非受检,带上下文

    OutOfStockException(skuId, qty) 比 RuntimeException("error") 好:类型可以单独 catch、可以映射成 HTTP 状态码,消息里有能排查的数据。

    “出错了”却不知道是哪一单、哪个商品。

    包装异常时保留 cause

    throw new OrderException("下单失败 " + orderId, e),不要只传 e.getMessage()。

    丢掉原始栈,查不到根因。

    资源一律 try-with-resources

    连接、流、锁的包装都实现 AutoCloseable,用 try (…)。

    异常路径上漏关,以及 close 的异常盖掉真正的异常。

    反射拿到的 Method / Field 缓存起来

    放在 static final 字段或 ConcurrentHashMap 里,只查一次。

    每次 getMethod 的查找开销,和每个 Method 对象各自重新 inflation。

    不要拿异常做正常的流程控制

    “用户不存在”返回 Optional,别 throw 再 catch;真要用异常传递,用不填栈的。

    每次 new 都抄一遍栈(实测深度 100 时 5.8 µs)。

    自定义注解写全元注解

    @Retention(RUNTIME)、@Target,需要时 @Inherited(只对类上的注解、只沿父类继承有效)。

    坑 6。

    第三圈

    深处

    框架为什么都缓存反射对象

    每次 getMethod 都要按名字和参数类型在类的方法列表里查找,还会复制出一个新的 Method 对象;新对象的调用计数从 0 开始,又要走一遍前 15 次 native。所以 Jackson 给每个类建一份“怎么序列化”的描述并缓存,Spring 把每个 Bean 方法的拦截器链缓存在 methodCache 里,MyBatis 的 Reflector 缓存每个类的 getter / setter。全景第 2 站第一单 67 ms、之后 0.9 ms,差的就是这些“第一次”。

    JDK 18 起的反射:基于 MethodHandle

    JEP 416 把 Method.invoke、Constructor.newInstance、Field.get 的实现换成了 MethodHandle(JDK 21 源码里的 DirectMethodHandleAccessor),去掉了 native + 生成字节码的两段式和 inflation 阈值。好处是只维护一套机制、JIT 能更好地优化;老参数 -Dsun.reflect.noInflation 在 18 之后基本不起作用了。靶场用 JDK 21,所以全景里的 Jackson、Spring 走的是新路。(JDK 18 这一条按 JEP 416 写,没在本机跑 18。)

    受检异常之争

    Java 是少数有受检异常的主流语言。初衷是让编译器逼你处理“可预期的失败”。实践中的问题:一层层 throws 往上传,接口签名被底层实现细节污染(换个存储,异常类型就变);和 Lambda、Stream 不兼容;大家为了编译通过写 catch (Exception e) {},比不处理还糟。所以 Spring 把 JDBC 的 SQLException 统一翻译成非受检的 DataAccessException,Kotlin 直接取消了受检异常。业务代码的主流做法:对外的业务失败用非受检异常加全局异常处理器,受检异常只留在边界上(IO、远程调用)就地处理或转换。

    版本差异

    JDK 1.3java.lang.reflect.Proxy 动态代理
    JDK 5注解
    JDK 7try-with-resources、suppressed、multi-catch
    JDK 9模块系统;try-with-resources 可以直接用外面的 effectively final 变量
    JDK 14NPE 消息精确到“哪个变量是 null”(JEP 358,15 起默认开)
    JDK 16强封装 JDK 内部(JEP 396),代理类放进 jdk.proxyN 动态模块
    JDK 18反射改用 MethodHandle 实现(JEP 416)

    JDK 1.3、5、7、9、14、18 几行按文档写;JDK 16 的两项在本机 17 上看到了结果(InaccessibleObjectException、jdk.proxy1)。

    面试官会怎么问
    JDK 动态代理和 CGLIB 有什么区别?

    JDK 代理生成 extends Proxy implements 接口 的类,只能代理接口,每次调用经 InvocationHandler 和反射;CGLIB 生成目标类的子类,能代理类,覆盖不了 final / private 方法。两者都拦不住自调用(④-4)。

    反射为什么慢?怎么优化?

    慢在第一次查找和生成访问器、每次调用的参数装箱和访问检查,以及 JDK 17 及以前前 15 次走 native;优化是缓存 Method、用 MethodHandle、或者干脆生成字节码(④-2、⑥-3)。

    受检异常和非受检异常有什么区别?业务里怎么选?

    编译器是否强制处理。业务失败一般用非受检加全局处理;受检异常留在 IO 和远程调用边界。别忘了事务默认只对非受检异常回滚(④-1、坑 1)。

    注解是怎么生效的?

    注解本身只是元数据,RUNTIME 保留的写进 class 文件的 RuntimeVisibleAnnotations 属性;框架用反射读出来(读到的是 JDK 动态代理生成的对象),再决定做什么(④-3)。

    try-with-resources 里业务异常和 close 异常同时发生,抛哪个?

    抛业务异常,close 的异常作为 suppressed 挂在它上面,可以用 getSuppressed 取出(④-1)。

    下一圈

    ↺回到⑤:改造你的替身

    1. 让受检异常也回滚

    给 @Audit 加一个属性 Class<? extends Throwable>[] rollbackFor() default {},在 handler 里读它,命中就回滚。用户 4040 那次应该打印“回滚”。

    用到:坑 1、④-3

    2. 缓存注解和 Method

    在 handler 里加一个 ConcurrentHashMap<Method, String>,审计名只读一次;对比加缓存前后 100 万次代理调用的耗时。

    用到:⑥-3 缓存

    3. 复现坑 4

    把 handler 的 catch 去掉、改成 throws Throwable 直接让 InvocationTargetException 冒出去,看调用方拿到什么;再改回来。

    用到:坑 4、④-4 异常表

    自测

    1. 用一句话说出这四样怎么拼成 @Transactional。

    动态代理生成替身类把调用交给 handler;handler 用反射读注解、用反射调目标;目标抛异常时按异常类型决定回滚还是提交。

    2. 不用这些,手写事务最疼在哪?

    每个方法都重复十几行样板,规则一改要改几十处;静态代理又要每个接口写一个代理类(②)。

    3. 自调用事务失效,根源是哪条原理?

    替身只是包在外面的另一个对象,方法内部的 this 是真正的对象,调用不经过替身(④-4)。

    4. 预测:把 OrderService 接口删掉,让 Demo 直接代理 OrderServiceImpl,会怎样?Spring Boot 里为什么不会出这个问题?

    JDK 代理会抛 IllegalArgumentException “not an interface”(实测)。Spring Boot 默认 proxyTargetClass = true,用 CGLIB 生成子类,不需要接口;但如果 OrderServiceImpl 是 final 类,CGLIB 也会失败(④-4)。

    5. 预测:在 handler 里 catch InvocationTargetException 后 throw new IOException(cause),调用方拿到什么?

    IOException 是受检异常,place 的签名只声明了 RiskCheckTimeoutException,代理类的异常表不放行它,会包成 UndeclaredThrowableException 抛给调用方(④-4 异常表,坑 4)。

    6. 放回全景:第 3 站前后各发生了什么?这一页讲的东西坏了,整条链路会怎样?

    前面是第 2 站 Jackson 用反射和注解把 JSON 变成 PlaceOrderCmd,后面是第 4 站查商品、第 7 站扣库存。如果 OutOfStockException 改成受检异常,第 7 站抛出后第 3 站的替身照样提交,多商品订单会只扣了一部分库存;如果 place 里自调用了另一个带 @Transactional(REQUIRES_NEW) 的方法,那个“新事务”根本不会开;如果某个注解漏写了 RUNTIME,第 10 站 Jackson 读不到 @JsonIgnore,internalNote 就被发给了前端;如果全局异常处理器把异常吞了只返回 200,用户会看到下单成功,库存却没扣。