qinghuan
← Java Web / 全部笔记

单元 69 / Java Web

P0 · 必会主线L2 · 进阶

Spring 事务:注解之外,更要看调用边界

先确定哪些数据库修改必须一起提交,再追踪代理调用、异常和传播方式,检查事务到底覆盖了哪里。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能通过失败注入验证多次写入的事务范围,并解释不回滚的具体原因。

学习理由、准备要求与知识点

为什么学它

事务要保护完整业务动作,理解代理、异常和资源范围比只写注解更关键。

先读这些内容

知识点清单

  • 业务原子边界
  • 事务管理器与资源
  • 代理外部调用
  • 同类调用与外层事务
  • 默认回滚异常
  • 自定义回滚规则
  • rollback-only
  • 失败注入与跨系统限制

运行前先确认Java 17、Spring Framework 6.2.11,聚焦默认代理模式与普通 PlatformTransactionManager 路径。算法模拟 REQUIRED/REQUIRES_NEW 的逻辑作用域和回滚标记,不访问数据库,不实现完整 Spring 事务管理器或响应式事务。

01先弄懂它是什么

给方法加事务前,先说清哪些操作必须一起成功。例如新增菜品和口味记录,需要在同一个支持事务的数据库范围里提交或撤销。一个本地数据库事务不能自动撤销已完成的 HTTP 请求、Redis 修改或文件写入;把这些操作写在同一个 Java 方法中,也不会让它们自动拥有共同提交能力。

Spring 声明式事务通常在方法调用经过代理时建立。在默认代理模式下,只有通过代理进入,才会应用被调方法的事务配置;同类内部直接调用不会再次经过代理。不过外层已经开始的事务仍可能有效。因此看到自调用不能直接说“事务全没了”,要先追到外部入口,再看哪些资源已经加入事务。

事务传播回答的是:一个事务方法调用另一个时,后者沿用现有的那笔数据库事务,还是另开一笔。REQUIRED 通常沿用已有事务,没有时才创建;REQUIRES_NEW 会暂停外层的事务关联,创建独立事务,但外层占用的连接等资源可能还没有释放。NESTED 等方式还依赖具体资源是否支持,不能只看名称,就理解成数据库里的多层事务都能各自提交。

文中这几个词,先认一认

逻辑事务作用域
代码中方法声明或调用划出的事务范围;多个这种范围可能共用一笔数据库事务。
物理事务
数据库实际执行提交或回滚的那一笔事务,决定哪些修改一起生效。
rollback-only
事务已被标记为只能回滚;即使后来捕获异常并正常返回,也不能把它恢复成可提交。
代理调用边界
调用进入事务代理、可被拦截并应用配置的地方,与对象内部直接调自己的方法不同。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

普通拦截流程先解析事务属性并确定管理器,必要时创建或加入事务,然后执行业务。正常返回进入提交,异常则依据规则提交或回滚。没有自定义规则时,RuntimeException 和 Error 默认触发回滚,checked 异常默认不触发;局部规则及框架支持的全局设置可改变它,必须按实际配置判断。

要按抛出异常的规则自动回滚,异常需要传到事务拦截器。业务内部捕获异常并返回普通成功值,不能指望拦截器仅凭内部曾出错就认定失败;框架支持的失败返回值和显式回滚标记则另有处理。不过,下层若已把共用事务标成 rollback-only,即只能回滚,外层捕获异常也救不回来。此时尝试提交可能得到 UnexpectedRollbackException,避免调用方误以为数据已成功提交。

事务资源通常绑定在当前线程或对应执行上下文中。另起线程、自行打开其他连接或使用不参与管理器的资源,不会天然继承这个事务。REQUIRES_NEW 虽独立提交,也会增加连接占用;外层持锁后等待内层或远程服务,还可能扩大等待与死锁风险。事务设计应围绕最小必要用例,而不是覆盖越多代码越好。

正在加载流程图…

逻辑方法返回正常不等于物理提交成功;共享的回滚标记可能在外层结束时才暴露。
查看流程图源码(Mermaid)
flowchart TD
 A["经过事务代理调用"] --> B["解析属性并创建或加入事务"]
 B --> C["执行业务方法"]
 C --> D{"正常返回还是异常"}
 D -->|"异常"| E["按回滚规则处理"]
 D -->|"正常"| F{"事务是否只可回滚"}
 F -->|"否"| G["提交物理事务"]
 F -->|"是"| H["回滚并报告提交未成功"]
 E --> I["恢复调用上下文并释放资源"]
 G --> I
 H --> I

按顺序看,每一步做了什么

  1. 写明必须共同成功的数据操作及事务管理器。
  2. 从实际调用入口确认是否经过代理。
  3. 区分逻辑传播、物理连接与异常观察边界。
  4. 用第二步失败、内部捕获和新线程等反例验证真实数据库状态。

03对照实现代码

TransactionAspectSupport 的普通调用环绕流程 (新标签页)↗

Spring Framework v6.2.11 · TransactionAspectSupport#invokeWithinTransaction、completeTransactionAfterThrowing、commitTransactionAfterReturning

真实普通事务路径已核验。下例为原创默认异常规则模型,忽略自定义 rollbackFor、响应式与回调型管理器,并把上下文恢复与事务资源清理明确区分;不是完整生产事务实现。

核心机制代码 · java
class SourceModel {
  interface Work { Object run() throws Throwable; }
  interface Tx {
    void beginOrJoin(); void commit(); void rollback(); void restoreInvocationContext();
  }
  static Object invoke(Tx tx,Work work) throws Throwable {
    tx.beginOrJoin(); Object result;
    try {
      result=work.run(); // 拦截器观察真正穿过边界的返回或异常
    } catch(Throwable failure) {
      if(failure instanceof RuntimeException || failure instanceof Error) tx.rollback();
      else tx.commit(); // 仅展示没有额外规则时的 checked 异常政策
      throw failure;
    } finally {
      tx.restoreInvocationContext(); // 恢复拦截调用信息,不代表提前归还数据库连接
    }
    tx.commit(); // 提交本身也可能失败,不能把方法返回等同于提交完成
    return result;
  }
}

invokeWithinTransaction 先从属性源解析当前方法与目标类的事务配置,再选择管理器。真实实现包含普通、响应式和回调型路径,本篇只沿普通 PlatformTransactionManager 阅读,避免把线程绑定事务的描述无条件套到响应式上下文。

普通路径通过 createTransactionIfNecessary 获取事务信息,随后执行 invocation.proceedWithInvocation。异常分支交给 completeTransactionAfterThrowing,根据属性规则决定动作,而不是任何 Throwable 都必然回滚。默认与自定义规则应从属性解析到实际判定一路核对。

finally 中 cleanupTransactionInfo 恢复的是这次拦截调用的关联信息,不能理解成数据库连接已提前全部关闭。正常返回后还会执行 commitTransactionAfterReturning,真正提交和释放资源由事务管理器负责。几个方法都含清理或提交字样,但处理的是不同层次的东西。

事务拦截器只能管理按要求接入它的资源操作。Mapper 用的是不是同一数据源和事务管理器、连接是不是通过框架正确取得,都会影响操作能否加入这笔事务。看到注解,只能说明代码声明了事务要求;还要在可控制的环境中实际调用,让其中一步失败,再检查数据库结果,才能确认这些操作一起回滚。

代码先调用谁,再调用谁

  1. 代理进入 TransactionInterceptor 及 invokeWithinTransaction。
  2. 解析事务属性并取得或加入事务。
  3. 执行业务,按传播到边界的异常规则处理。
  4. 恢复调用信息,完成实际提交或回滚并由管理器清理资源。

04动手写一个例子

模拟 REQUIRED 与 REQUIRES_NEW 的提交结果

操作序列包含 begin、write 和 finish(success)。REQUIRED 加入当前物理事务,REQUIRES_NEW 总是创建独立物理事务;失败把所属物理事务标为 rollback-only。共享内层失败被外层捕获后,外层正常结束仍抛 UnexpectedRollback。已提交的独立内层结果不会随外层回滚撤销。

输入 1
外层 REQUIRED 写 A,内层 REQUIRED 写 B,均成功
输出 1
提交 [A,B]
输入 2
内层 REQUIRED 失败,外层仍正常结束
输出 2
无提交,UnexpectedRollback
输入 3
内层 REQUIRES_NEW 提交 B,外层回滚 A
输出 3
保留 [B]
输入 4
内层 REQUIRES_NEW 失败被处理,外层提交 A
输出 4
保留 [A]

用栈保存逻辑作用域,每个作用域指向物理事务,并标记自己是否创建它。REQUIRED 在已有作用域内指向同一个对象,REQUIRES_NEW 则创建新对象;栈弹出后自然恢复外层关联。

写入先进入物理事务的待提交列表,失败只标记对应事务。非创建者结束时不提交共享事务;创建者结束才决定整体提交或丢弃。这样能够表达多个方法边界共享一个最终提交决定。

如果创建者宣称正常结束,但物理事务已有回滚标记,模型回滚并报告 UnexpectedRollback。这里没有真实异常捕获和数据库,只用 finish(false) 表示该作用域观察到失败;main 明确验证共享失败、独立提交与无事务写入等情况。

为什么这样写是对的一个物理事务只有创建它的作用域能够最终提交;rollback-only 一旦置位不再清除;独立物理事务的已提交列表不会被外层回滚删除。

时间 每次 begin/finish 为 O(1),提交复制为 O(w)额外空间 O(d+w)

d 为逻辑嵌套深度,w 为待提交与已记录写入数量;每项写入只在最终提交时复制一次。真实数据库与锁成本未包含。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 49 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
  enum Propagation { REQUIRED,REQUIRES_NEW }
  static class UnexpectedRollback extends RuntimeException {}
  static class Physical { boolean rollbackOnly; final List<String> writes=new ArrayList<>(); }
  record Scope(Physical tx,boolean owner) {}
  static class Model {
    final Deque<Scope> scopes=new ArrayDeque<>(); final List<String> committed=new ArrayList<>();
    void begin(Propagation p) {
      Objects.requireNonNull(p);
      if(p==Propagation.REQUIRED && !scopes.isEmpty()) scopes.push(new Scope(scopes.peek().tx(),false));
      else scopes.push(new Scope(new Physical(),true)); // 新物理事务有独立待提交内容
    }
    void write(String value) {
      if(scopes.isEmpty()) throw new IllegalStateException("没有事务");
      scopes.peek().tx().writes.add(Objects.requireNonNull(value));
    }
    void finish(boolean success) {
      if(scopes.isEmpty()) throw new IllegalStateException("没有作用域");
      Scope scope=scopes.pop(); Physical tx=scope.tx();
      if(!success) tx.rollbackOnly=true; // 共享事务上的标记不可被外层 catch 清除
      if(!scope.owner()) return; // 参与者无权提前提交共享事务
      if(tx.rollbackOnly) {
        if(success) throw new UnexpectedRollback();
        return;
      }
      committed.addAll(tx.writes); // 只有物理事务所有者完成时发布写入
    }
  }
  static void eq(List<String> wanted,List<String> actual) { if(!wanted.equals(actual)) throw new AssertionError(actual); }
  public static void main(String[] args) {
    Model joined=new Model(); joined.begin(Propagation.REQUIRED); joined.write("A");
    joined.begin(Propagation.REQUIRED); joined.write("B"); joined.finish(true); joined.finish(true); eq(List.of("A","B"),joined.committed);
    Model marked=new Model(); marked.begin(Propagation.REQUIRED); marked.write("A");
    marked.begin(Propagation.REQUIRED); marked.write("B"); marked.finish(false);
    try { marked.finish(true); throw new AssertionError("应报告意外回滚"); }
    catch(UnexpectedRollback expected) { /* 外层正常返回不能覆盖回滚标记 */ }
    eq(List.of(),marked.committed);
    Model independent=new Model(); independent.begin(Propagation.REQUIRED); independent.write("A");
    independent.begin(Propagation.REQUIRES_NEW); independent.write("B"); independent.finish(true); independent.finish(false); eq(List.of("B"),independent.committed);
    Model innerFailed=new Model(); innerFailed.begin(Propagation.REQUIRED); innerFailed.write("A");
    innerFailed.begin(Propagation.REQUIRES_NEW); innerFailed.write("B"); innerFailed.finish(false); innerFailed.finish(true); eq(List.of("A"),innerFailed.committed);
    Model outerFailed=new Model(); outerFailed.begin(Propagation.REQUIRED); outerFailed.write("A");
    outerFailed.begin(Propagation.REQUIRED); outerFailed.write("B"); outerFailed.finish(true); outerFailed.finish(false); eq(List.of(),outerFailed.committed);
    try { new Model().write("x"); throw new AssertionError("应拒绝无事务写入"); }
    catch(IllegalStateException expected) { /* 错误输入 */ }
    System.out.println("PASS 7 propagation assertions");
  }
}

这些特殊情况也要试一试

  • 参与者成功结束不能提前发布共享事务写入。
  • 独立内层已提交后外层失败,结果可能部分持久化,这是传播选择的代价。
  • 模型不包含 NESTED/savepoint、隔离级别或多个事务管理器。
进阶选读 · 生产拓展:可靠事件与本地事务组合

场景数据库提交后还要通知其他服务,直接调用消息发送可能在两个系统之间失败。

做法把业务变化与待发布事件记录放在同一数据库事务,再由可靠发布器发送,消费端使用稳定事件 ID 幂等处理。明确发布失败重试、永久失败出口与对账,不把 Outbox 称为跨所有系统的瞬时原子提交。

复杂度增加事件表写入、扫描或变更捕获、重试与去重开销;吞吐由数据库、发布器和消费者共同约束。

好处与代价换取可恢复的最终一致性,但引入中间状态、延迟和运维责任。

05项目里怎么用

事务方法围绕一个清晰业务动作组织,避免把用户等待和大量外部调用包在长事务内。隔离级别、索引和访问顺序影响锁行为,应与事务范围一起设计。异常转换时保留因果,不能吞掉失败后返回成功再期待注解替业务做判断。

REQUIRES_NEW 暂停外层逻辑,再用独立资源运行内层事务;外层的连接和锁可能仍占着。很多线程同时这样嵌套,会增加连接需求与等待风险。还要从业务决定是否允许“内层已成功,外层后来失败”,不能把它当成没有成本的通用补救。

验证应实际注入第二次写入失败,查询两张表结果,并覆盖代理调用、自调用、checked 异常政策和新线程等边界。只验证抛出异常,不能证明数据已回滚;只验证测试方法自身回滚,也不一定覆盖服务端另一个事务。

容易踩的坑

  • 把同类自调用解释为外层已有事务也消失。
  • 捕获共享事务失败后,认为正常返回就能恢复提交。
  • 认为新线程或新连接自动加入当前事务。
  • 把本地回滚描述为已经撤销远端系统的操作。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
REQUIRED 共享事务共同业务动作容易形成统一提交。内层失败可能使整个事务只能回滚。必须共同成功的同资源步骤。
REQUIRES_NEW 独立事务内层有独立提交与失败范围。增加连接与部分成功状态,可能扩大等待。业务明确接受独立结果的场景。
本地事务加可靠事件跨系统工作具备恢复线索。需要最终一致性、重试与对账。无法由一个本地事务覆盖的服务协作。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01逻辑事务数量是否等于数据库事务数量?

展开参考答案与推导

不等于。多个 REQUIRED 方法作用域可以共享同一个物理事务,最终只有对应所有者完成提交。REQUIRES_NEW 则建立独立范围。

02为什么 catch 后仍会 UnexpectedRollback?

展开参考答案与推导

被参与者标记为 rollback-only 的物理事务已经不能提交。外层正常结束不能撤销标记,框架需要明确告诉调用方最终提交没有成功。

03默认 checked 异常一定回滚吗?

展开参考答案与推导

在未配置额外规则的常见默认政策下不一定,默认主要对 RuntimeException 和 Error 回滚。应核对方法规则、全局配置与所用版本。

换个情况,还会处理吗

01注解存在但第二张表失败后第一张仍有记录,先查什么?

展开参考答案与推导

确认调用是否经过代理、两次操作是否加入同一管理器和连接、表引擎是否支持事务,以及异常是否被吞掉或不匹配回滚规则,再用实际 SQL 状态验证。

02审计记录使用 REQUIRES_NEW,主业务失败后仍保留是否异常?

展开参考答案与推导

这是独立提交可能产生的预期结果。要判断审计是否应该记录尝试、失败还是成功,不能因代码位于同一个方法调用链就期待共同回滚。

03事务中调用支付成功,随后数据库失败,怎样处理?

展开参考答案与推导

本地回滚不能撤销支付。需要依据支付业务 ID 查询、补偿或恢复,并设计可持久记录中间状态的协作协议。

写代码试一试

01用模型复现共享内层失败被外层捕获。

输入 / 条件
REQUIRED 外层,REQUIRED 内层 finish(false),外层 finish(true)
输出 / 预期
无提交并报告 UnexpectedRollback

提示 作用域指向同一 Physical。

展开参考答案与推导

内层设置共享 rollbackOnly,外层完成时检查同一对象。不要在弹出内层栈后创建一个没有失败标记的新对象。

02验证独立内层提交不会被外层撤销。

输入 / 条件
外层写 A,REQUIRES_NEW 写 B 提交,外层失败
输出 / 预期
只有 B 提交

提示 两个待提交列表必须独立。

展开参考答案与推导

新事务使用独立 Physical,结束后恢复外层引用。外层回滚只丢弃自己的列表,不删除全局已提交内容。

03把 checked 异常加入回滚政策应改哪一层?

输入 / 条件
业务抛 IOException
输出 / 预期
按新政策回滚并传播失败

提示 规则判断与业务代码分离。

展开参考答案与推导

在事务属性或统一政策中定义匹配规则,并测试它确实穿过代理边界;不能仅在业务里把所有异常改名而不核对提交结果。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能通过失败注入验证多次写入的事务范围,并解释不回滚的具体原因。

本篇重点加练

重点增加代理入口、回滚政策与失败注入练习,检验事务保证是否覆盖完整业务链。

  • 代理外部调用
  • 自定义回滚规则
  • 失败注入与跨系统限制

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01设计评审

    创建订单包含订单行、明细行和余额变更;另有邮件服务。划定一次本地数据库事务的业务不变量和资源范围,列出成功响应所需事实。

    提交内容
    事务边界图与成功失败合同。
    怎样算完成
    • 本地资源与外部副作用分开
    • 原子性需求可以验收

    练到的知识点业务原子边界 · 事务管理器与资源

  2. 作业 02代码推演

    服务 A 的 public outer 调用本对象 inner,另一服务 B 也调用 inner。为 outer 有事务和无事务两种配置画出调用链,说明应记录哪些证据判断实际事务。

    提交内容
    四种配置图和观测计划。
    怎样算完成
    • 区分代理入口与方法体调用
    • 不以注解存在直接推断事务

    练到的知识点代理外部调用 · 同类调用与外层事务

综合应用

  1. 作业 03排错实验

    在独立数据库中写入一条练习记录后分别抛出 checked 异常和 unchecked 异常,再改变一条回滚规则。每次运行后用独立查询确认结果,清理仅限本题记录。

    提交内容
    测试工程、自写断言、规则配置及查询证据。
    怎样算完成
    • 异常类型与规则版本明确
    • 观察提交状态而非只看日志

    练到的知识点默认回滚异常 · 自定义回滚规则

  2. 作业 04面试场景

    外层捕获内层服务异常后返回“成功”,最终提交仍报错。制作可验证的候选原因列表,标出传播方式、事务状态和连接资源需要核对的位置。

    提交内容
    诊断时间线与最小复现计划。
    怎样算完成
    • 区分异常捕获与事务状态
    • 覆盖提交阶段失败

    练到的知识点rollback-only · 失败注入与跨系统限制

  3. 作业 05编程实践

    实现一次“创建课程+创建两条课时”的事务操作,在第一条课时之后注入失败;另外测试正常提交和输入被拒绝。测试必须从事务外读取并验证整体业务状态。

    提交内容
    代码、失败注入开关、自写集成测试与运行证据。
    怎样算完成
    • 失败位置固定可重现
    • 所有关联记录一起验证
    • 测试事务不掩盖生产边界

    练到的知识点业务原子边界 · 事务管理器与资源 · 自定义回滚规则 · 失败注入与跨系统限制

  4. 作业 06排错实验

    为同一事务方法设计两条调用入口:容器获取的接口与显式创建对象。注入同一种业务异常,分别记录实际事务状态和练习数据结果。

    提交内容
    最小工程、自写状态/数据断言及运行证据。
    怎样算完成
    • 调用入口可以核实
    • 异常政策与持久化结果对应

    练到的知识点代理外部调用 · 自定义回滚规则

  5. 作业 07编程实践

    实现练习余额充值,业务异常分为拒绝充值与审计写入失败;先制定回滚政策,再在每个写入点之后注入失败,核验余额和审计记录。

    提交内容
    代码、失败矩阵、自写边界测试及事务外查询证据。
    怎样算完成
    • 每种异常的政策显式
    • 核验覆盖所有受影响记录

    练到的知识点自定义回滚规则 · 失败注入与跨系统限制

  6. 作业 08设计评审

    事务方法由定时任务、HTTP 服务和测试三处调用。画出每条调用链的对象来源及代理边界,提出能证明入口一致性的检查。

    提交内容
    调用图和入口验收清单。
    怎样算完成
    • 不同入口均追溯至实际对象
    • 没有用方法注解代替调用证据

    练到的知识点代理外部调用

  7. 作业 09面试场景

    数据库提交返回前连接中断,客户端无法确认订单是否落库。定义未知结果状态的查询、重试和人工处理要求,不把异常直接转成“未创建”。

    提交内容
    故障时间线和恢复合同。
    怎样算完成
    • 未知结果有独立表示
    • 重试与已有业务效果能关联

    练到的知识点失败注入与跨系统限制

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    完成消息队列基础后:支付回调先成功记账,消息发送失败。设计允许重试的最终业务合同和故障恢复过程,说明本地事务能承担的部分。

    提交内容
    状态图、失败窗口和重复事件验收案例。
    怎样算完成
    • 支付和通知状态可分别追踪
    • 不承诺跨系统自动原子

    练到的知识点业务原子边界 · rollback-only · 失败注入与跨系统限制

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 条件变化

    原订单服务中的库存更新移到另一个 HTTP 服务,方法签名保持不变。审查原事务测试哪些结论仍成立,设计新增故障窗口与责任分工。

    提交内容
    变更前后资源图和差异测试矩阵。
    怎样算完成
    • 本地与远程边界明确
    • 结论注明保留与失效的前提

    练到的知识点代理外部调用 · 失败注入与跨系统限制

  2. 作业 12思维拓展 · 反例构造

    检验“只要所有异常都回滚,业务就一定正确”。构造涉及输入被接受或外部副作用的最小场景,给出数据库状态和业务承诺的独立判据。

    提交内容
    业务样例、事件顺序与正确性标准。
    怎样算完成
    • 事务状态不替代全部需求
    • 反例或支持论证能逐步复核

    练到的知识点自定义回滚规则 · 失败注入与跨系统限制

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗