面试知识

Spring(Java 应用框架)事务传播、失效与一致性边界

22-Spring生态与后端工程 面试知识整理。

Spring(Java 应用框架)事务传播、失效与一致性边界

知识图谱编号:2.3.3。本文消费 AOP(面向切面编程)代理链与扩展机制 的代理结论,产出后续请求事务、MyBatis(持久层框架)会话以及项目事故共同使用的本地事务模型。

核心结论:@Transactional(事务注解)控制的是“经过代理的方法调用 + 当前线程 + 某个事务管理器所管理的资源”。它不是代码块魔法,不会自动覆盖新线程、另一个数据源、MQ(消息队列)、支付渠道或物流平台。

阅读地图、学习目标与版本边界

面试回答事务问题时,应始终沿着五层证据向下讲:调用是否经过代理;事务属性如何解析;事务管理器创建、加入或挂起了哪个事务;当前线程绑定了哪些资源;数据库最终提交、回滚还是只回到保存点。跨出数据库后,再用 Outbox(发件箱)、幂等、查单、补偿和对账闭环。

层级核心问题关键证据常见误判
调用层是否经过代理对象对象类型、调用栈、断点看见注解就认定生效
拦截层匹配了什么事务属性方法与类注解、回滚规则只看方法签名
管理层新建、加入、挂起还是保存点事务状态、事务管理器日志把逻辑事务等同物理事务
资源层哪个连接绑定到哪个线程连接标识、自动提交、线程名新线程会继承连接
业务层最终事实是否一致状态流水、唯一键、Outbox(发件箱)、对账应用异常等于数据库回滚
flowchart LR
    A["外部调用"] --> B["事务代理"]
    B --> C["事务属性解析"]
    C --> D["TransactionInterceptor(事务拦截器)"]
    D --> E["PlatformTransactionManager(平台事务管理器)"]
    E --> F["TransactionSynchronizationManager(事务同步管理器)"]
    F --> G["线程绑定连接"]
    G --> H{"方法结果"}
    H -->|"成功且无仅回滚标记"| I["提交"]
    H -->|"异常或仅回滚标记"| J["回滚"]
    I --> K["清理并归还连接"]
    J --> K
  • 节点表示从代理入口到数据库终局的责任层;箭头表示调用和资源控制方向。
  • 前提是调用进入容器生成的代理,并由指定事务管理器管理当前资源。
  • 正常路径解析属性、绑定连接、执行目标、提交并清理;失败路径按规则回滚。
  • 若同类自调用、新线程或错误数据源绕过任一节点,事务保证会收缩。
  • 业务结论是先验证路径和资源,再讨论注解配置。

第一部分:本地事务执行机制

1. TransactionInterceptor(事务拦截器)与完整代理调用链

TransactionInterceptor(事务拦截器)本质上是方法拦截器。代理接到外部调用后,先由事务属性源查找目标方法、目标类及接口上的事务元数据,再选择 PlatformTransactionManager(平台事务管理器)。invokeWithinTransaction 根据传播行为调用 getTransaction:可能创建物理事务,也可能加入现有事务,或只创建一个没有资源事务的空状态。随后调用目标方法;目标正常返回时尝试提交,抛出异常时依据回滚规则提交或回滚,最后无论结果如何都清理线程资源。

提交并不等于直接执行数据库 commit。事务管理器先检查本地或全局 rollback-only(仅回滚标记),再触发事务同步的提交前、完成前回调,执行资源提交,之后触发提交后与完成后回调。任何“注解存在但没有代理”“代理存在但管理器选错”“管理器正确但写操作不使用绑定连接”都会让链路断开。

事务代理、自调用、挂起与恢复正式时序图

正式图中节点包括调用方、代理、事务拦截器、事务管理器、线程资源绑定、连接池和目标对象;箭头区分外部代理调用、this 旁路、REQUIRES_NEW(总是新事务)挂起与恢复。前提是对象由容器管理且调用从代理外部进入。正常路径用连接 C1 提交;独立事务临时使用 C2;失败路径按异常或 rollback-only(仅回滚标记)回滚。业务结论是“方法之间的调用路径”决定事务边界,而不是源码中两个方法距离多近。

数据演绎 1:一次代理事务的资源状态。 请求线程 http-17 调用订单方法,连接池借出连接 C17,初始 autoCommit=true。事务开始后变为 false,线程资源映射记录 DataSource-A -> C17。方法更新订单 O1001 并插入一条金额 ¥399.00 的流水;正常返回后执行一次提交,清理映射,恢复连接属性并归还。若第二条写入抛出回滚异常,两个写入都不可见;若写入实际走 DataSource-B 的新连接,则它不受 C17 控制。

热门面试题

  1. 问题(基础题)@Transactional(事务注解)从方法调用到提交经历哪些关键步骤?

    • 考点:代理、属性解析、事务管理器、资源绑定、清理。
    • 回答思路:按“代理前、目标方法、提交或回滚、清理”四段回答。
    • 详细答案:外部调用先进入事务代理,TransactionInterceptor(事务拦截器)解析方法和类上的事务属性,选择 PlatformTransactionManager(平台事务管理器)并按传播行为取得事务状态。数据源事务管理器借连接、关闭自动提交并通过 TransactionSynchronizationManager(事务同步管理器)绑定到当前线程。目标方法中的持久层代码复用该连接;正常返回且没有 rollback-only(仅回滚标记)时提交,匹配回滚规则的异常则回滚,最后恢复连接属性、解绑资源并归还连接池。
    • 进阶追问:为什么提交阶段仍可能改为回滚?
    • 进阶回答:内层逻辑事务可能已经把共享物理事务标记为 rollback-only(仅回滚标记),外层虽然正常返回,事务管理器在提交检查时仍只能回滚,并可能抛出 UnexpectedRollbackException(意外回滚异常)。
  2. 问题(原理题):TransactionInterceptor(事务拦截器)为什么不直接操作数据库连接?

    • 考点:策略抽象、不同资源、职责分离。
    • 回答思路:区分通用拦截流程与具体资源管理策略。
    • 详细答案:事务拦截器负责通用的属性解析、目标调用和异常判断,而连接如何取得、挂起、保存点、提交与清理由 PlatformTransactionManager(平台事务管理器)的具体实现负责。这样同一个注解模型可以适配 JDBC(Java 数据库连接)、JPA(Java 持久化接口)或其他本地资源。若业务中存在多个事务管理器,必须明确限定符,否则通用拦截器可能选择到不能管理当前数据源的实现。
    • 进阶追问:只要选对事务管理器就一定生效吗?
    • 进阶回答:还要保证持久层从框架工具取得当前线程绑定的资源;若手工创建新连接、使用未代理的数据源或把任务切到新线程,写入仍可能脱离事务。
  3. 问题(项目题):支付回调日志显示进入事务方法,为什么订单仍出现部分提交?

    • 考点:证据链、多个数据源、异常规则、代理旁路。
    • 回答思路:不要先猜框架缺陷,逐层核对调用、资源与终局。
    • 详细答案:我会先按支付单号查询订单、支付流水和 Outbox(发件箱)的真实数据库状态,再确认入口对象是否为代理、异常是否传播到代理边界、实际事务管理器和数据源是否匹配。随后记录线程名、连接标识和自动提交状态,排查是否手工开连接、同类自调用或异步线程写库。若异常被捕获后正常返回,或受检异常没有配置回滚,代理会合法提交。最终用故障注入复现第二步失败,验证三张表是否共同回滚。
    • 进阶追问:为什么不能只看应用日志里的“rollback(回滚)”字样?
    • 进阶回答:日志可能属于另一个事务、另一个数据源或仅表示逻辑标记;必须用业务流水和连接级证据确认目标数据库的最终提交事实。

2. PlatformTransactionManager(平台事务管理器)与线程资源绑定

PlatformTransactionManager(平台事务管理器)对外只有取得事务、提交和回滚三个核心动作,内部模板把资源差异封装在开始、挂起、恢复、提交、回滚和清理钩子中。以 DataSourceTransactionManager(数据源事务管理器)为例,它通过 DataSourceUtils(数据源工具)取得连接,将 ConnectionHolder(连接持有器)绑定到当前线程。MyBatis-Spring(持久层框架集成模块)或 JdbcTemplate(数据库访问模板)也从同一工具链取连接,因此能共享物理事务。

TransactionSynchronizationManager(事务同步管理器)通常利用 ThreadLocal(线程本地变量)维护“资源工厂到资源持有器”的映射,以及同步器列表、事务名称、只读标识和隔离属性。它是线程局部上下文,不是跨线程容器:提交、回滚和回调必须在拥有该绑定的线程完成。把工作提交到线程池后,新线程没有原映射;即使复制普通日志上下文,也不应复制活跃数据库连接。

对象保存内容生命周期错误使用后果
事务状态新事务、保存点、仅回滚标记、挂起资源一次拦截调用错判提交或重复清理
连接持有器连接、引用计数、同步状态物理事务泄漏、串事务、属性污染
线程资源映射数据源到连接持有器当前线程事务窗口新线程取新连接
同步器列表提交前后与完成回调当前逻辑事务回调遗漏或重复执行
flowchart TD
    T1["线程 http-17"] --> M1["资源映射"]
    M1 --> D1["DataSource-A"]
    D1 --> C1["连接 C17"]
    T1 --> S1["同步回调列表"]
    T2["线程 async-9"] --> M2["空资源映射"]
    M2 --> D2["DataSource-A"]
    D2 --> C2["另借连接 C88"]
    C1 --> DB["同一数据库"]
    C2 --> DB
  • 节点展示两个线程各自的资源映射;箭头表示数据源查找连接的过程。
  • 前提是异步线程未显式开启自己的事务。
  • 正常路径中同一线程内的持久层调用复用 C17
  • 失败路径中异步线程借出 C88 并独立提交,外层回滚无法撤销它。
  • 业务结论是事务上下文不能像日志标识一样随意复制。

数据演绎 2:线程切换导致局部提交。 线程 http-17 在连接 C17 中把订单 O1002 改为“已支付”,随后把记账任务交给 async-9。异步线程从池中借出 C88,插入金额 ¥500.00 的账务流水并提交。之后外层抛异常,C17 回滚,订单恢复“待支付”,但 C88 已提交,形成有流水无订单状态。正确做法是把同库必须原子的写入留在同线程本地事务,异步只处理提交后的可重试事件。

热门面试题

  1. 问题(基础题):TransactionSynchronizationManager(事务同步管理器)绑定了什么?

    • 考点:线程局部资源、同步器、事务元数据。
    • 回答思路:不要只回答“绑定连接”,还要说资源键、回调和边界。
    • 详细答案:它在当前线程维护资源工厂到资源持有器的映射,例如数据源到 ConnectionHolder(连接持有器),并维护事务同步器列表、事务名称、只读和隔离等上下文。持久层通过同一数据源键取得绑定连接,保证多个操作参加同一物理事务。事务结束后必须解绑资源、清空同步器并恢复连接属性,否则线程池复用会污染下一次请求。
    • 进阶追问:同一个线程能绑定两个数据源吗?
    • 进阶回答:可以分别以两个数据源为键绑定资源,但一个本地事务管理器通常只原子管理自己的资源;两个绑定不自动形成跨库原子事务。
  2. 问题(原理题):为什么异步方法不能自然继承外层事务?

    • 考点:ThreadLocal(线程本地变量)、连接线程安全、事务终局所有权。
    • 回答思路:从上下文存放位置与资源生命周期解释,而不是只说“线程不同”。
    • 详细答案:外层事务的连接和同步器绑定在调用线程的 ThreadLocal(线程本地变量)中,新线程没有该映射,会另取连接。强行复制连接既可能并发访问非线程安全资源,也会让谁提交、谁回滚、谁清理变得不确定。若异步工作必须写库,应让它开启独立事务,并通过提交后事件、Outbox(发件箱)或队列把一致性关系显式化。
    • 进阶追问:使用可传递线程本地变量能解决吗?
    • 进阶回答:不应传递活跃事务资源;可传递追踪标识或租户信息,但数据库连接与同步器必须由执行线程自己的事务边界管理。
  3. 问题(项目题):如何证明 MyBatis(持久层框架)操作使用了事务绑定连接?

    • 考点:会话集成、连接证据、集成验证。
    • 回答思路:同时看框架集成方式、运行日志与最终状态。
    • 详细答案:先确认使用 MyBatis-Spring(持久层框架集成模块)管理的 SqlSessionTemplate(会话模板)和同一个数据源,而不是手工创建 SqlSession(数据库会话)。在测试环境记录线程名、连接代理标识和自动提交状态,核对多个 Mapper(映射器)调用是否复用连接。再在第二个写入后抛异常,验证第一个写入不可见。若日志提示会话未注册同步或连接不由框架管理,就要检查数据源代理和事务管理器配置。
    • 进阶追问:看见同一个连接标识就足够吗?
    • 进阶回答:还要确认连接没有中途提交、事务管理器确实管理该数据源,并用最终数据回滚验证排除日志误导。

3. 逻辑事务、物理事务与 REQUIRED(需要事务)

逻辑事务是每个被事务拦截的方法边界;物理事务是底层资源真实的一次开始到提交或回滚。外层 REQUIRED(需要事务)新建物理事务,内层 REQUIRED(需要事务)通常加入同一连接,但仍拥有自己的逻辑事务状态。内层匹配回滚规则的异常即使被外层捕获,也可能把共享物理事务标记为 rollback-only(仅回滚标记)。外层最后“提交”时发现标记,只能回滚并用 UnexpectedRollbackException(意外回滚异常)告诉调用方结果没有按表面流程提交。

SUPPORTS(支持事务传播)有事务就加入,没有就非事务执行;MANDATORY(强制事务传播)要求调用前已经存在事务,否则立即失败;NEVER(禁止事务传播)要求当前没有事务,有事务则失败;NOT_SUPPORTED(不支持事务传播)会挂起当前事务并以非事务方式执行。这四种行为主要表达参与约束,不提供新的独立原子边界。

传播行为外层有事务外层无事务物理资源典型用途与风险
REQUIRED(需要事务)加入新建通常共享连接默认选择;内层可标仅回滚
SUPPORTS(支持事务传播)加入非事务可能有连接查询;行为随调用环境变化
MANDATORY(强制事务传播)加入抛异常共享连接强制由上层编排事务
NEVER(禁止事务传播)抛异常非事务无事务连接明确禁止长事务进入
NOT_SUPPORTED(不支持事务传播)挂起后非事务非事务原连接挂起,另用连接非关键慢查询;外层回滚不覆盖
flowchart TD
    A["进入内层方法"] --> B{"当前有事务?"}
    B -->|"是"| R["REQUIRED:加入"]
    B -->|"否"| RN["REQUIRED:新建"]
    B -->|"是"| S["SUPPORTS:加入"]
    B -->|"否"| SN["SUPPORTS:非事务"]
    B -->|"是"| M["MANDATORY:加入"]
    B -->|"否"| ME["MANDATORY:异常"]
    B -->|"是"| N["NEVER:异常"]
    B -->|"否"| NN["NEVER:非事务"]
    B -->|"是"| NS["NOT_SUPPORTED:挂起后非事务"]
  • 节点按“是否已有事务”划分传播决策;箭头给出行为结果。
  • 前提是调用经过代理,传播属性才会被解析。
  • 正常路径按契约加入、新建或非事务执行。
  • 失败路径是强制约束不满足,或非事务写入在外层回滚后仍保留。
  • 业务结论是传播行为是一份调用契约,不是越多越安全。

数据演绎 3:共享事务的仅回滚标记。 外层创建物理事务 P1,订单余额原值 1000。外层减 100,内层 REQUIRED(需要事务)再写流水但因唯一键冲突抛运行时异常,事务状态标记为 rollback-only(仅回滚标记)。外层捕获异常后继续把订单状态设为“完成”并正常返回。提交阶段检测标记,P1 全部回滚,余额仍为 1000、状态仍为“待处理”,外层收到 UnexpectedRollbackException(意外回滚异常)。若业务希望内层失败不影响外层,应先审视语义,再考虑 REQUIRES_NEW(总是新事务)或保存点。

热门面试题

  1. 问题(基础题):逻辑事务与物理事务有什么区别?

    • 考点:方法边界、连接边界、共享状态。
    • 回答思路:用外层与内层 REQUIRED(需要事务)举例。
    • 详细答案:每次被拦截的方法都形成逻辑事务边界,可以有自己的属性和异常判断;底层数据库可能只有一个物理事务和一条连接。内层 REQUIRED(需要事务)加入外层时,两层逻辑边界共享物理提交结果,任一层设置 rollback-only(仅回滚标记)都会影响整体。只有新建独立事务或保存点时,资源行为才发生变化。
    • 进阶追问:内层正常提交是否意味着数据库已经提交?
    • 进阶回答:加入外层时通常只是完成内层逻辑范围,真正数据库提交要等最外层物理事务结束。
  2. 问题(原理题):为什么外层捕获内层异常仍会得到 UnexpectedRollbackException(意外回滚异常)?

    • 考点:仅回滚标记、调用方知情、共享物理事务。
    • 回答思路:按“内层决定、外层表象、提交检查”解释。
    • 详细答案:内层 REQUIRED(需要事务)遇到符合规则的异常后把共享事务标为 rollback-only(仅回滚标记)。外层捕获只改变 Java(编程语言)异常传播,并不能撤销事务状态。外层正常结束时发起提交,管理器发现整体只能回滚;为了避免调用方误以为提交成功,会抛 UnexpectedRollbackException(意外回滚异常)。修复应调整异常语义或边界,而不是简单忽略异常。
    • 进阶追问:能否手工清除仅回滚标记?
    • 进阶回答:通常不应这样做,因为导致标记的部分操作已不满足原子语义;应改为独立事务、保存点或把可容忍错误移出事务。
  3. 问题(项目题):MANDATORY(强制事务传播)适合什么项目场景?

    • 考点:事务契约、误用防护、服务分层。
    • 回答思路:说明它用于防止关键写操作脱离编排事务。
    • 详细答案:例如库存领域内部的“扣减余额并写预占流水”方法不允许被控制器直接单独调用,可以用 MANDATORY(强制事务传播)要求上层应用服务先建立事务。无事务调用立即失败,能在开发阶段暴露错误入口。但它只验证线程上存在事务,不验证事务管理器是否对应正确数据源,也不适合被远程服务直接理解为分布式原子保证。
    • 进阶追问:为什么不让内部方法都使用 REQUIRED(需要事务)?
    • 进阶回答:REQUIRED(需要事务)在误调用时会自行新建事务,可能只提交半个业务用例;MANDATORY(强制事务传播)让边界违规尽早失败。

4. REQUIRES_NEW(总是新事务)与 NESTED(嵌套事务)

REQUIRES_NEW(总是新事务)挂起外层事务及其线程资源,再借一条连接建立独立物理事务。内层提交后即使外层回滚也不会撤销,内层回滚通常也不会自动标记外层。适合必须独立留痕且能接受外层失败后仍存在的记录,例如受控的审计尝试;不适合为了“让异常不影响主流程”而滥用,因为它增加连接占用,也可能留下业务孤儿。

NESTED(嵌套事务)通常在同一物理连接上创建保存点。内层失败可回滚到保存点,释放保存点之后外层继续;但最终提交仍由外层决定,外层回滚会撤销内层所有结果。它依赖事务管理器与数据库保存点能力,在某些持久化技术或全局事务环境中不等同可用。保存点只控制数据库写入,不撤销已经发送的远程请求、文件或消息。

维度REQUIRED(需要事务)REQUIRES_NEW(总是新事务)NESTED(嵌套事务)
物理事务与外层共享独立新建与外层共享
连接同一条额外借一条同一条
外层回滚影响全部回滚内层已提交不回滚全部回滚
内层失败常标记整体回滚只回滚内层回到保存点
主要风险意外回滚连接耗尽、孤儿记录能力差异、外部副作用不可撤销
sequenceDiagram
    participant O as 外层事务 P1 / 连接 C1
    participant M as 事务管理器
    participant I as 内层方法
    O->>M: 进入 REQUIRES_NEW(总是新事务)
    M->>M: 挂起 P1 与 C1
    M->>I: 新建 P2 / 连接 C2
    I-->>M: 提交或回滚 P2
    M->>M: 释放 C2,恢复 P1 / C1
    O->>M: 进入 NESTED(嵌套事务)
    M->>M: 在 C1 创建保存点 S1
    alt 内层失败
        M->>M: 回滚到 S1
    else 内层成功
        M->>M: 释放 S1
    end
    O-->>M: 最终提交或回滚 P1
  • 节点区分外层物理事务、独立事务与保存点;箭头表示挂起、恢复和局部回滚。
  • 前提是连接池有余量,且数据库和事务管理器支持保存点。
  • 正常路径中独立事务先终结,嵌套事务等待外层终结。
  • 失败路径中连接不足会阻塞,保存点外副作用无法撤销。
  • 业务结论是两者隔离失败的方式不同,不能互换使用。

数据演绎 4:独立审计与保存点。 外层订单事务 P1 将状态从“新建”改为“审核中”。内层 REQUIRES_NEW(总是新事务)用 P2 写审计号 A9001 并提交;随后外层失败,订单回滚为“新建”,审计记录仍存在,因此审计内容必须表达“尝试失败”而非“订单已完成”。若改用 NESTED(嵌套事务),保存点后的审计写入最终会随外层一起回滚,不能满足独立留痕。

热门面试题

  1. 问题(基础题):REQUIRES_NEW(总是新事务)与 NESTED(嵌套事务)的核心区别是什么?

    • 考点:物理事务、连接、保存点、最终提交权。
    • 回答思路:用“独立连接独立提交”和“同连接保存点”对比。
    • 详细答案:REQUIRES_NEW(总是新事务)挂起外层并新建独立物理事务,通常额外占一条连接,内层提交不受外层回滚影响。NESTED(嵌套事务)在外层物理事务内创建保存点,内层可局部回滚,但最终仍受外层提交或回滚控制。前者提供提交独立性,后者只提供同一物理事务内的局部恢复点。
    • 进阶追问:两者都能让内层异常后外层继续吗?
    • 进阶回答:在正确捕获和配置下可以继续,但资源结果不同;独立事务已产生永久结果,保存点结果仍可能被外层整体回滚。
  2. 问题(原理题):为什么 REQUIRES_NEW(总是新事务)会导致连接池耗尽?

    • 考点:挂起资源、嵌套借连接、并发等待。
    • 回答思路:画出每个并发请求先占外层连接再等待内层连接的闭环。
    • 详细答案:外层事务开始后每个请求已经占用一条连接,进入 REQUIRES_NEW(总是新事务)时不能复用挂起连接,必须再借一条。若连接池容量恰好等于外层并发数,所有连接都被外层持有,所有线程又同时等待内层连接,没有线程能完成并归还外层连接,最终超时。解决重点是缩小事务、降低并发嵌套或重新设计边界,不只是盲目扩池。
    • 进阶追问:连接池设置为并发数两倍就永久安全了吗?
    • 进阶回答:不一定,多层嵌套、慢查询、远程调用和恢复流量都会改变峰值占用,容量要结合最坏嵌套深度、等待时间和数据库承载测算。
  3. 问题(项目题):批量导入时能否用 NESTED(嵌套事务)跳过单条坏数据?

    • 考点:保存点成本、批量语义、失败隔离。
    • 回答思路:先确认每行失败是否可容忍,再评估保存点和批处理行为。
    • 详细答案:若业务允许部分成功,可为每行或小批建立保存点,失败回滚到保存点并记录错误,最后提交成功项。但必须确认事务管理器支持保存点、驱动批处理不会在提交前隐藏错误,并控制保存点数量与事务时长。大文件更适合分块独立事务和可重试检查点,避免一个超长物理事务持锁、占日志并让失败恢复成本过高。
    • 进阶追问:单条错误记录能用 REQUIRES_NEW(总是新事务)写吗?
    • 进阶回答:可以独立留痕,但每行都开独立事务会放大连接和日志开销;通常按批写错误清单或在主事务外汇总更稳妥。

第二部分:属性、回滚与失效边界

5. 隔离级别映射、只读属性与事务超时

Spring(Java 应用框架)事务隔离属性是对底层资源的声明,事务管理器通常把非默认值映射到 JDBC(Java 数据库连接)的连接隔离级别;真正能否生效取决于数据库、驱动、连接池和既有事务。内层加入现有物理事务时,不能随意把已开始连接改成另一个隔离级别;若需要严格校验,应开启现有事务属性验证并在不兼容时失败。数据库的 MVCC(多版本并发控制)、锁和日志机制应继续参考 MySQL(关系型数据库)事务与锁模块,本章只解释框架如何传递边界。

readOnly(只读事务属性)通常是优化提示而非安全权限。事务管理器可能把连接设置为只读,持久化框架也可能调整刷新策略,但数据库和驱动未必拒绝所有写入。timeout(超时)限制的是事务允许存活的时间或相关语句等待预算,无法撤销已经成功的远程支付请求;超时异常只有传播到代理并匹配规则时,本地资源才回滚。

属性框架动作可靠边界常见误区
isolation(隔离级别)新事务时设置连接属性受数据库与驱动支持约束内层加入后仍能改变隔离
readOnly(只读事务属性)设置提示或刷新策略主要用于优化和意图表达等于数据库写权限控制
timeout(超时)设置截止时间或语句超时只覆盖受管本地资源能取消外部渠道副作用
name(事务名称)标识当前逻辑事务便于日志与观测影响数据库原子性
flowchart TD
    A["解析事务属性"] --> B{"是否新物理事务?"}
    B -->|"是"| C["设置隔离、只读、超时"]
    B -->|"否,加入已有事务"| D["沿用外层资源属性"]
    D --> E{"属性验证开启且不兼容?"}
    E -->|"是"| F["立即失败"]
    E -->|"否"| G["继续执行"]
    C --> H["数据库与驱动实际执行"]
    H --> I{"本地超时?"}
    I -->|"是"| J["标记回滚或抛异常"]
    I -->|"否"| K["正常终结"]
  • 节点表示属性从注解到连接的映射;箭头表示新事务和参与事务的差异。
  • 前提是事务管理器支持并实际应用对应属性。
  • 正常路径在新物理事务开始前设置属性。
  • 失败路径包括属性不兼容、数据库不支持或超时后外部副作用仍存在。
  • 业务结论是属性必须用数据库证据验证,不能按注解名字推断保证。

数据演绎 5:超时不等于外部撤销。 本地事务超时设置 2s(秒),先更新订单后调用支付渠道。渠道在 1.8s(秒) 扣款成功,但网络响应到 2.4s(秒) 才返回,框架检测本地事务超时并回滚订单。最终渠道已扣 ¥299.00,本地仍是“待支付”。正确策略是不要在长本地事务中等待渠道;以稳定支付号发起请求,超时进入“结果未知”,主动查单后用独立本地事务推进或退款。

热门面试题

  1. 问题(基础题):事务隔离级别在 Spring(Java 应用框架)中如何生效?

    • 考点:属性映射、物理事务、数据库能力。
    • 回答思路:说明注解只是声明,真正由事务管理器在新连接事务上设置。
    • 详细答案:代理解析隔离属性后把它交给事务管理器。新建物理事务时,数据源事务管理器尝试在连接上设置对应 JDBC(Java 数据库连接)隔离级别,事务完成后恢复原属性。若方法加入外层事务,通常沿用外层连接的隔离级别。最终并发现象仍由数据库实现决定,所以要用两个真实会话验证可见性、锁等待和提交结果。
    • 进阶追问:内层声明更高隔离级别为什么可能无效?
    • 进阶回答:物理事务已经在外层连接上开始,内层只是逻辑参与者;中途改变隔离会破坏统一语义,框架通常沿用外层或在严格验证下报错。
  2. 问题(原理题):readOnly(只读事务属性)为什么不能当作写保护?

    • 考点:提示语义、驱动差异、权限边界。
    • 回答思路:区分性能提示、框架刷新策略和数据库权限。
    • 详细答案:readOnly(只读事务属性)可能让连接或持久化上下文采用只读优化,但不同驱动和数据库对写语句的拒绝程度不同,甚至某些写入仍能执行。真正的写保护应使用数据库只读账号、权限、只读副本和架构约束。该属性适合表达查询意图并减少无谓刷新,不能作为安全控制或业务不变量。
    • 进阶追问:只读事务中查询一定更快吗?
    • 进阶回答:不一定,收益依赖持久化框架、数据库和查询模式;索引、执行计划、网络和结果集大小往往更关键。
  3. 问题(项目题):如何为 WMS(仓储管理系统)批量分配库存设置事务超时?

    • 考点:容量预算、锁时间、分块、恢复。
    • 回答思路:先拆短事务,再根据数据库等待和服务目标定预算。
    • 详细答案:我会把大批次按仓库、货主和固定行数拆成可重试小事务,每批记录检查点。超时预算覆盖正常锁等待和数据库执行的高分位,但小于上游请求总时限,并为重试留余量。超时后按批次号查询已提交检查点,不能整批盲重放;远程物流和文件操作移出本地事务。监控每批耗时、锁等待、回滚量和最老检查点来调整批大小。
    • 进阶追问:把超时调得很大是否能减少失败?
    • 进阶回答:会让连接和锁被更久占用,故障时拖垮更多请求;应先定位慢点并缩小事务,而不是掩盖超时。

6. 异常规则、rollback-only(仅回滚标记)与 UnexpectedRollbackException(意外回滚异常)

默认规则通常对 RuntimeException(运行时异常)和 Error(严重错误)回滚,对受检异常提交;这是约定,不代表受检异常“安全”。rollbackFornoRollbackFor 应围绕业务语义配置:导致本地不变量未完成的异常必须回滚,可容忍且已经转换为明确业务结果的异常才可能提交。按异常类匹配比字符串模式更安全,过宽模式可能误匹配名字相近的异常。

异常是否到达事务代理同样重要。目标方法捕获异常并正常返回,代理无法知道内部失败;手工调用 setRollbackOnly 能要求回滚,但会使控制流和事务结果分离,外层必须明确收到失败结果。若 finally(最终清理)中的新异常覆盖原异常,回滚可能仍发生,但根因证据丢失。异步返回类型和响应式事务另有完成语义,不能套用普通同步方法结论。

场景默认结果隐患推荐处理
运行时异常越过代理回滚上层误重试非幂等操作分类异常并稳定业务键
受检异常越过代理可能提交部分写入明确 rollbackFor 或转换异常
内部捕获后正常返回提交失败被吞回滚后重抛或返回明确失败且不写半成品
内层标仅回滚、外层正常整体回滚UnexpectedRollbackException(意外回滚异常)重构边界或显式传播失败
stateDiagram-v2
    [*] --> 活跃
    活跃 --> 仅回滚: 内层失败或手工标记
    活跃 --> 提交检查: 外层正常返回
    活跃 --> 回滚: 异常匹配回滚规则
    仅回滚 --> 回滚: 外层请求提交
    回滚 --> 意外回滚异常: 外层原以为可提交
    提交检查 --> 已提交: 无仅回滚标记
    已提交 --> [*]
    意外回滚异常 --> [*]
  • 节点表示事务状态而非 Java(编程语言)调用状态;箭头表示异常和提交检查触发迁移。
  • 前提是多个逻辑事务共享同一物理事务。
  • 正常路径无仅回滚标记时提交。
  • 失败路径中异常被捕获,但仅回滚标记仍保留,最终发生意外回滚。
  • 业务结论是“没有异常返回”不等于“事务可以提交”。

数据演绎 6:受检异常造成部分业务提交。 方法先把退款单 R7001 状态改为“处理中”,再因文件读取失败抛出 IOException(输入输出异常)。若没有配置回滚规则,代理可能提交状态变更,调用方却收到失败并重试,形成重复处理。配置该异常回滚后状态保持“待处理”;更稳妥的是把文件读取放在事务前,事务只负责校验后的短数据库写入。

热门面试题

  1. 问题(基础题):Spring(Java 应用框架)事务默认对哪些异常回滚?

    • 考点:默认规则、业务语义、配置方式。
    • 回答思路:先给默认,再强调不能按异常类别机械设计业务。
    • 详细答案:常见同步代理事务默认对 RuntimeException(运行时异常)及其子类和 Error(严重错误)回滚,对受检异常默认提交。可以通过类型安全的 rollbackFornoRollbackFor 调整。真正标准应是异常发生后本地不变量是否完整:若订单状态、流水和事件必须共同成功,任何打断该组合的异常都应回滚。
    • 进阶追问:是否建议全局配置所有 Exception(异常)都回滚?
    • 进阶回答:可以作为保守基线,但仍要识别可提交的业务拒绝、重试语义和异常转换,避免把正常校验失败包装成昂贵数据库回滚。
  2. 问题(原理题):rollback-only(仅回滚标记)有什么设计价值?

    • 考点:共享物理事务、延迟终局、调用嵌套。
    • 回答思路:说明内层没有物理提交权,只能标记整体不能提交。
    • 详细答案:内层逻辑事务加入外层物理事务后,不能立刻回滚并结束外层连接,因为外层调用栈还在执行。它通过 rollback-only(仅回滚标记)记录“这个物理事务已经不满足提交条件”。最外层到达终局时统一检查并回滚。这样既保留嵌套调用结构,又保证共享资源不会在已失败后被错误提交。
    • 进阶追问:为什么还要抛 UnexpectedRollbackException(意外回滚异常)?
    • 进阶回答:外层正常返回会让调用方误以为提交成功,异常用于显式暴露实际终局是回滚,促使上层按失败处理。
  3. 问题(项目题):业务代码必须捕获异常做降级时,怎样保证事务结果清楚?

    • 考点:异常转换、回滚标记、返回契约、可观测性。
    • 回答思路:区分事务内可容忍失败和必须回滚失败。
    • 详细答案:可容忍失败应在写入前完成,或把结果建模为合法状态并完整提交;必须回滚的失败则捕获后记录必要上下文,再抛出统一运行时业务异常,让代理回滚。若框架边界要求返回固定响应,可把事务方法拆到另一个对象,由外层控制器捕获事务方法异常并转换响应。避免在同一事务方法里吞异常后继续写“成功”状态。
    • 进阶追问:手工设置仅回滚标记后返回成功响应可以吗?
    • 进阶回答:不应返回成功;数据库会回滚而客户端认为成功,必须返回明确失败或处理中,并保留可查询业务号。

7. 同类自调用、不可代理方法与新线程失效矩阵

代理式事务的第一条件是调用穿过代理。对象内部 this.inner() 直接调用目标实例方法,不会重新进入代理,所以内层新增的传播、隔离和回滚规则不被解析。若外层已有事务,内层只是沿用它;若外层没有事务,内层注解不会凭空启动事务。较清晰的修复是把事务边界拆到职责独立的 Bean(对象实例)中,由外部对象调用;暴露当前代理或自注入会增加耦合,只适合作为受控过渡。

private(私有访问控制)方法无法成为外部代理入口;final(最终关键字)类或方法会限制基于子类的代理覆盖;直接 new 创建对象、初始化回调中尚未通过最终代理调用、构造器内调用、错误包扫描、重复代理和方法可见性配置也会造成差异。新线程、定时任务和异步执行器需要自己的事务。数据库表若使用不支持事务的存储能力,即使代理链正确也无法提供预期回滚。

失效类型运行证据数据结果推荐修复回归用例
同类自调用调用栈无代理内层属性未应用拆分职责对象外层无事务时内层是否回滚
private(私有访问控制)/final(最终关键字)方法不可拦截注解被忽略调整入口与代理方式启动期代理检查
异常被吞代理收到正常返回可能提交重抛或拆外层转换第二写失败后查最终状态
新线程线程名与连接变化独立提交事件化并新开事务外层回滚后查异步数据
错误事务管理器管理器与数据源不匹配自动提交或另一库回滚显式限定管理器双数据源故障注入
非事务资源数据库能力不支持回滚无效更换引擎或重建设计真实引擎回滚验证
sequenceDiagram
    participant C as 调用方
    participant P as 代理对象
    participant T as 目标对象
    participant M as 事务管理器
    C->>P: outer()
    P->>M: 开启外层事务
    P->>T: outer()
    T->>T: this.inner()
    Note right of T: 直接调用,不回到代理<br/>inner() 的新属性不生效
    T-->>P: 返回或异常
    P->>M: 按外层属性提交或回滚
  • 节点区分调用方、代理和目标实例;箭头显示 this 调用停留在目标对象内部。
  • 前提是使用代理式而非编译期织入式事务。
  • 正常代理路径由外部调用触发事务。
  • 旁路路径不会解析内层事务注解,新增传播语义失效。
  • 业务结论是重构调用方向比堆叠注解更可靠。

数据演绎 7:自调用导致退款单误提交。 控制器直接调用无事务的 batch(),内部 this.refundOne() 标注事务。refundOne() 先更新退款单 R7002 为“成功”,再插入流水时冲突。因为自调用没有开启事务,第一条更新已经自动提交,第二条失败,最终出现成功状态但无流水。拆分 RefundUnitService(退款单元服务)并从批处理对象外部调用代理后,两步共同回滚。

热门面试题

  1. 问题(基础题):为什么同类自调用会让事务注解失效?

    • 考点:代理入口、目标对象、方法分派。
    • 回答思路:画出外部代理路径和 this 直接路径。
    • 详细答案:容器返回的是代理对象,只有调用先到代理,TransactionInterceptor(事务拦截器)才能解析事务属性。目标方法内部的 this.inner() 在同一个目标实例上直接分派,不会回到外层代理,因此 inner() 上新声明的传播、隔离和回滚规则不会执行。若外层已有事务,它只是普通方法并沿用现有上下文;若外层无事务,它也保持无事务。
    • 进阶追问:自调用的方法里数据库操作一定无事务吗?
    • 进阶回答:不一定,若外层入口已经开启事务,内部操作仍参加外层事务;失效的是内层注解想新增或改变的语义。
  2. 问题(原理题):为什么不推荐通过获取当前代理解决所有自调用?

    • 考点:框架耦合、调用透明性、测试性、职责边界。
    • 回答思路:说明技术上可行但会隐藏依赖方向。
    • 详细答案:获取当前代理要求暴露代理上下文,把领域代码绑定到 AOP(面向切面编程)实现,还容易在非代理调用、测试或线程切换中失败。调用者看源码难以判断一次内部调用会不会重新拦截。将独立事务职责拆到另一个 Bean(对象实例)后,调用方向、传播契约和测试边界都更明确,也能减少一个类承担编排与原子操作两种责任。
    • 进阶追问:自注入本对象是否更好?
    • 进阶回答:仍然形成隐式循环和代理耦合,可作为遗留系统过渡,但长期应通过职责拆分或显式委托消除。
  3. 问题(项目题):如何系统排查“事务偶发失效”?

    • 考点:调用路径、线程、异常、管理器、资源终局。
    • 回答思路:以业务号建立一次请求的完整证据链。
    • 详细答案:先查最终数据和唯一流水,确定是未回滚、未提交还是写错库。再记录入口对象代理类型、目标方法、线程名、事务活动标志、事务管理器、数据源和连接标识。检查是否只有某条分支走同类自调用、异步执行、异常吞掉或错误数据源。最后在相同分支注入异常,验证数据库状态,而不是只做注解单元测试。偶发通常意味着调用路径或数据源选择随条件变化。
    • 进阶追问:生产环境能直接打开全量事务调试日志吗?
    • 进阶回答:高流量下不宜长期全开,应按业务号采样、动态提高特定包日志并结合指标和数据库会话证据,避免日志本身放大故障。

8. 多事务管理器与 TransactionSynchronization(事务同步)回调

多数据源系统中,每个本地事务管理器通常只负责一个资源工厂。方法未显式指定管理器时,框架会按配置、名称或默认规则选择;选择错误可能出现“事务日志正常,但目标库自动提交”。把两个本地事务管理器顺序调用不等于原子提交:第一个提交成功、第二个提交失败时仍会部分成功。真正跨资源一致性要回到 分布式事务、补偿与消息一致性 的资源前提与失败恢复。

TransactionSynchronization(事务同步)允许在提交前、提交后和完成后执行回调。它适合清理线程资源、提交后唤醒本地发布器或记录观测,不适合把“提交后回调执行成功”当作业务原子保证:数据库已提交后,回调进程可能崩溃,异常也无法让数据库倒退。关键事件应在本地事务中先持久化为 Outbox(发件箱),回调只作为加速通知,后台扫描提供恢复兜底。

回调阶段数据库状态可做事项禁止假设
beforeCommit(提交前)尚未提交刷新、轻量校验外部副作用可随库回滚
beforeCompletion(完成前)尚未终局资源准备一定会提交
afterCommit(提交后)已提交唤醒发布器、刷新提示回调一定执行完
afterCompletion(完成后)已提交或回滚清理、指标、记录终局可再改变原事务
sequenceDiagram
    participant B as 业务方法
    participant T as 事务管理器
    participant S as 事务同步回调
    participant D as 数据库
    B-->>T: 正常返回
    T->>S: beforeCommit(提交前)
    T->>S: beforeCompletion(完成前)
    T->>D: commit(提交)
    D-->>T: 成功
    T->>S: afterCommit(提交后)
    alt 进程在回调中崩溃
        S--xT: 通知未完成
    else 正常
        T->>S: afterCompletion(完成后)
    end
  • 节点表示业务、事务管理器、同步器和数据库;箭头按真实终局顺序排列。
  • 前提是同步器注册在当前线程事务中。
  • 正常路径数据库提交后执行提交后回调。
  • 失败路径表明数据库已提交而回调可丢失。
  • 业务结论是回调只能优化时效,持久化意图才负责可靠恢复。

数据演绎 8:提交后回调丢失。 支付单 P8001 与金额 ¥128.00 的 Outbox(发件箱)事件 E8001 在同一事务提交。afterCommit(提交后)本应通知发布线程,但进程在提交后 5ms(毫秒) 崩溃。虽然即时通知丢失,重启扫描器在 30s(秒) 内发现 E8001 并发送。若只在回调里构造消息而不落事件,支付成功事实会永久缺少下游通知。

热门面试题

  1. 问题(基础题):多个事务管理器如何选择?

    • 考点:默认管理器、限定符、数据源对应关系。
    • 回答思路:说明方法事务属性必须落到具体资源管理器。
    • 详细答案:项目应为每个数据源建立明确事务管理器,并在事务注解或配置中通过限定名称选择;默认管理器只适合主数据源。排查时核对管理器实例、数据源对象和持久层实际使用的数据源是否同一身份。方法同时写两个本地数据源时,即使两个管理器都存在,也不会自动形成共同原子提交。
    • 进阶追问:链式事务管理器能保证两个库原子吗?
    • 进阶回答:顺序提交仍有第二个失败的窗口,只能降低某些概率,不能提供严格原子性;必须设计补偿、消息一致性或使用满足前提的全局协议。
  2. 问题(原理题):afterCommit(提交后)为什么不能替代 Outbox(发件箱)?

    • 考点:提交后崩溃窗口、可恢复性、持久化意图。
    • 回答思路:把数据库提交与回调完成分成两个不可原子事件。
    • 详细答案:数据库提交一旦成功就不能因后续回调失败而回滚。进程可能在提交返回后、回调开始前或执行中崩溃;回调内容若只在内存中,就没有可扫描证据。Outbox(发件箱)把事件意图与业务事实同事务持久化,提交后回调只负责快速唤醒,周期扫描和幂等发布负责恢复。
    • 进阶追问:afterCommit(提交后)里重新开事务更新发送状态安全吗?
    • 进阶回答:它是新的独立事务,失败不影响原业务提交;可以更新辅助状态,但仍需重试、幂等和扫描兜底。
  3. 问题(项目题):跨境物流项目同时写订单库和轨迹库,怎样设计?

    • 考点:数据所有权、跨库边界、事件恢复。
    • 回答思路:每个库守住本地事实,通过事件和幂等同步。
    • 详细答案:订单库事务只提交订单状态、版本和 Outbox(发件箱)事件;轨迹服务消费事件,在自己的事务中以事件号或订单状态版本幂等插入轨迹。发布失败可扫描,消费失败可重试,乱序按版本拒绝或暂存。若业务要求同步返回,可返回订单已受理而不是伪装双库同时完成,并用延迟、积压和对账差异告警。
    • 进阶追问:轨迹库失败时能回滚订单库吗?
    • 进阶回答:数据库提交后不能时间倒流,应让轨迹重试收敛;只有业务允许时才用新动作把订单转异常或取消。

第三部分:容量、一致性与事故证据

9. REQUIRES_NEW(总是新事务)连接池耗尽与事务内远程调用

连接池容量不是简单等于接口并发数。一个外层事务占用连接 C1 后,REQUIRES_NEW(总是新事务)挂起 C1 并再借 C2;若每个并发线程都走相同路径,瞬时需求接近“外层并发 × 每线程同时占用连接数”。池中恰有 100 条连接、100 个请求都先占住外层连接,再同时等待内层连接时,会形成资源闭环:没有空闲连接,线程无法完成内层,也就无法归还外层。

远程调用放在事务内会把连接和数据库锁的占用时间从本地毫秒级拉长到网络超时级。远端响应超时只表示结果未知,本地回滚也不能撤销远端已经完成的扣款、下单或发货。更合理的设计是事务前完成无副作用查询,事务内只做短数据库变更,提交后通过稳定业务号和事件驱动远端;确实需要同步远端时,应先落“处理中”事实,远端超时后查单收敛。

容量参数示例值计算含义风险信号
外层并发100同时持有 100 条连接池已无余量
独立事务深度1每线程再需 1 条连接理论峰值接近 200
远程高分位耗时1.5s(秒)外层连接至少被占这么久获取连接等待上升
连接获取超时2s(秒)线程失败前等待窗口大量超时与回滚风暴
数据库安全连接预算150不能只按应用需要扩池扩到 200 可能压垮数据库
flowchart TD
    P["连接池 100"] --> O["100 个外层事务各持有 C1"]
    O --> N["100 个线程进入 REQUIRES_NEW(总是新事务)"]
    N --> W["每个线程等待第二条连接 C2"]
    W --> X{"是否有线程能完成并归还 C1?"}
    X -->|"否"| D["连接获取超时与回滚风暴"]
    X -->|"限流或预留容量"| C["内层完成,恢复外层"]
    D --> R["缩短事务、移除远程调用、重构独立事务"]
  • 节点表示连接池、外层占用、内层等待和恢复;箭头形成资源等待闭环。
  • 前提是全部并发同时进入独立事务且外层连接保持挂起。
  • 正常路径需要有剩余连接或入口限流让部分线程完成。
  • 失败路径是全池被外层占满,扩大超时只会延长拥塞。
  • 业务结论是先消除嵌套和长事务,再讨论扩池。

数据演绎 9:连接池饥饿。 应用池大小 100,入口并发 100,每个外层事务先更新订单并持有连接,再调用一个 REQUIRES_NEW(总是新事务)的审计方法。100 条连接全部被外层占用,内层等待上限 2s(秒) 后同时失败,随后外层回滚并产生重试流量。将审计改为与业务同事务写 Outbox(发件箱),提交后异步记录,单请求峰值连接从 2 降为 1;配合入口并发 80,为运维与恢复保留 20 条连接余量。

热门面试题

  1. 问题(基础题):如何估算 REQUIRES_NEW(总是新事务)的连接需求?

    • 考点:并发、嵌套深度、同时占用、数据库上限。
    • 回答思路:先算每线程同时持有连接数,再受数据库预算约束。
    • 详细答案:单层外部事务加一层独立事务时,每个活跃线程可能同时占两条连接,因为外层连接只是挂起并未归还。粗略峰值是并发数乘以同时占用连接数,还要加入后台任务、恢复流量和管理余量。但应用池不能无限扩到计算值,数据库最大会话、内存和执行能力是上限,因此更应缩小事务、限流并减少嵌套。
    • 进阶追问:监控中哪些指标能提前发现饥饿?
    • 进阶回答:活跃连接接近上限、等待线程持续增加、连接获取高分位上升、事务时长和 REQUIRES_NEW(总是新事务)调用量同步增长,都是闭环形成前的信号。
  2. 问题(原理题):事务内远程调用为什么同时损害性能和一致性?

    • 考点:锁与连接时间、未知结果、不可回滚副作用。
    • 回答思路:分别从本地资源占用和跨系统失败语义回答。
    • 详细答案:本地事务在等待网络时仍占连接并可能持锁,远端高延迟和重试会放大数据库排队。更严重的是远端成功但响应丢失时,本地只看到超时并回滚,远端副作用不会跟着撤销,形成两边状态相反。应以稳定业务号查单,将外部结果建模为成功、失败、未知,并通过状态机、补偿和对账收敛。
    • 进阶追问:缩短远程超时能彻底解决吗?
    • 进阶回答:只能缩短占用,无法消除远端成功但本地超时的窗口;一致性仍需查单和幂等。
  3. 问题(项目题):支付接口高峰连接池耗尽时如何止血和定位?

    • 考点:运行止血、证据保全、根因修复。
    • 回答思路:先限制新增压力,再按线程、连接和事务调用树定位。
    • 详细答案:立即对非核心入口限流、暂停高并发补偿任务,保护支付查询和回调容量;不要先盲目扩池。保存线程栈、池活跃与等待、数据库会话、慢事务和调用链,确认是否大量线程持外层连接等待内层或远端。按业务号核对未知支付,避免自动重试重复扣款。根因修复是把远程调用移出事务、合并不必要的独立事务并按数据库安全预算重设池和入口并发。
    • 进阶追问:为什么暂停补偿任务可能比扩池有效?
    • 进阶回答:补偿通常也占连接并与实时请求争抢;暂停或降速能立即释放恢复容量,而扩池可能把压力直接转移到已经过载的数据库。

10. 支付回调 T1(时间点一)至 T5(时间点五)与 Outbox(发件箱)

支付回调必须先验签、防重放,再按渠道交易号和商户支付号建立幂等。渠道回调可能重复、乱序或在主动查单之后迟到,因此状态迁移要检查前置状态,金额和币种必须与本地支付意图一致。本地原子边界只包含支付单、必要账务记录和 Outbox(发件箱)事件;响应渠道、发布 MQ(消息队列)和下游履约都在提交之后。

时刻事务 TX-P9001 内外状态事件 E-P9001 状态失败窗口与恢复守恒结论
T1(时间点一)锁定支付单 P9001,校验渠道号 CH7788、金额 ¥699.00、币种 CNY尚不存在验签失败或金额不符,拒绝且不改状态外部成功不能越过本地校验
T2(时间点二)支付单从“处理中”条件更新为“成功”同事务插入“待发布”任一写失败,整个事务回滚支付事实与事件意图共同成败
T3(时间点三)本地事务提交E-P9001 对扫描器可见提交响应丢失时按支付号查询,不能重建第二笔数据库事实已经确定
T4(时间点四)发布器读取已提交事件发送成功后可能尚未来得及标记允许重复发布;消费者按事件号幂等至少一次不等于重复副作用
T5(时间点五)下游在独立事务推进订单或账务标记已发送,消费流水已记录消费失败重试,长期失败进入死信与对账最终一致且全链路可审计
sequenceDiagram
    participant C as 支付渠道
    participant P as 支付回调服务
    participant D as 支付数据库
    participant O as Outbox(发件箱)发布器
    participant Q as MQ(消息队列)
    participant S as 下游服务
    C->>P: T1(时间点一)重复回调 CH7788
    P->>P: 验签、防重放、金额币种校验
    P->>D: T2(时间点二)条件更新 P9001 + 插入 E-P9001
    D-->>P: T3(时间点三)本地提交
    P-->>C: 成功响应
    O->>D: 扫描待发布 E-P9001
    O->>Q: T4(时间点四)发送事件
    Q->>S: 可能重复投递
    S->>S: 事件号与业务状态幂等
    S-->>Q: T5(时间点五)确认消费
    O->>D: 标记事件已发送
  • 节点包括渠道、回调服务、本地库、发布器、队列与下游;箭头对应唯一的 T1T5 演绎。
  • 前提是支付状态和 Outbox(发件箱)记录位于同一数据库事务。
  • 正常路径从校验到提交、发布、幂等消费和标记完成。
  • 失败路径包括提交响应丢失、发布成功后标记失败、重复消费和下游永久失败。
  • 业务结论是不追求虚假的跨系统一次性原子,而用持久化意图和对账证明收敛。
stateDiagram-v2
    [*] --> 处理中
    处理中 --> 成功: 回调或查单确认成功
    处理中 --> 失败: 渠道明确失败
    处理中 --> 未知: 超时且无法确认
    未知 --> 成功: 主动查单成功
    未知 --> 失败: 主动查单明确失败
    成功 --> 成功: 重复成功回调幂等
    失败 --> 人工核验: 迟到成功与本地冲突
    成功 --> 退款中: 业务发起新补偿

数据演绎 10:发布成功但标记失败。 E-P9001 第一次发送成功后,发布器在更新状态前崩溃。重启后它再次发送同一事件,消费者先插入事件号 E-P9001 的 Inbox(收件箱)记录并推进订单;第二次插入命中唯一约束,直接返回首次结果,订单只推进一次。每日对账用渠道交易 CH7788=¥699.00、本地支付单、账务分录和订单履约四方核对,差异进入人工队列。

热门面试题

  1. 问题(基础题):为什么支付状态与 Outbox(发件箱)必须同事务写入?

    • 考点:数据库与消息双写、持久化意图、恢复。
    • 回答思路:分别说明先消息后数据库和先数据库后消息的失败窗口。
    • 详细答案:先发消息再提交数据库,数据库回滚时下游已看到成功;先提交数据库再临时发送,进程可能在两步之间崩溃,导致成功事实没有事件。把支付状态和待发布事件放入同一本地事务,要么都可见要么都不可见。提交后发布器可以反复扫描,消息重复由事件号和业务状态机吸收,从而把不可恢复窗口改成可重试积压。
    • 进阶追问:Outbox(发件箱)能保证只发送一次吗?
    • 进阶回答:不能也不必强求,发送成功后标记前崩溃会重复;应保证事件至少可达,并让消费副作用幂等。
  2. 问题(原理题):支付回调幂等为什么不能只用 Redis(远程字典服务)锁?

    • 考点:租约与故障、数据库唯一事实、状态机。
    • 回答思路:锁用于削峰,最终正确性由持久化约束保证。
    • 详细答案:Redis(远程字典服务)锁可能租约过期、主从切换或客户端暂停,两个请求仍可能先后进入。支付最终事实应由渠道交易号、商户支付号和事件号的数据库唯一约束保证,状态更新检查前置状态与版本;锁可减少热点竞争,但不能替代幂等流水。重复回调读取首次结果并返回渠道要求的成功响应。
    • 进阶追问:金额不一致但交易号相同怎么办?
    • 进阶回答:不能按重复成功处理,应冻结推进、记录安全告警并查渠道原始订单,避免把伪造或错配回调写成成功。
  3. 问题(项目题):下游消费支付成功事件失败,怎样保证资金与履约最终一致?

    • 考点:至少一次、幂等、重试、死信、对账。
    • 回答思路:区分支付权威事实和下游推进任务。
    • 详细答案:支付成功事实保持不变,事件按同一标识重试;下游在本地事务中先去重,再按订单前置状态推进并写消费流水。瞬时失败退避重试,确定性数据错误进入隔离或死信,最老未处理事件和金额总量触发告警。对账按支付单、事件、消费流水和履约状态找差异,支持补发或人工推进,不能通过把支付改回失败掩盖下游故障。
    • 进阶追问:消费者处理成功但确认消息失败怎么办?
    • 进阶回答:消息会再次投递,Inbox(收件箱)唯一键和状态条件返回首次结果,不重复履约。

11. 本地事务、MQ(消息队列)、支付幂等与分布式事务边界

本地事务保证一个事务管理器所管理资源的原子性;MQ(消息队列)可靠性负责消息存储、确认、重试和死信;业务幂等负责重复请求不重复产生副作用;分布式事务方案负责多个受控参与者的协调或补偿。四者互相衔接但不能互相冒充。完整消息机制参考 MQ(消息队列)可靠性与幂等,跨服务方案参考 分布式事务、补偿与消息一致性

支付渠道是外部权威参与者,本地 @Transactional(事务注解)无法让渠道扣款回滚。接口超时要保留“未知”状态并用同一商户单号查单;确认成功后推进,明确失败后才安全重试,长期未知进入对账。退款是新的业务动作,有独立退款号、状态和流水,不是对原扣款做数据库回滚。订单、库存和履约同样应各自维护权威状态,通过事件和补偿收敛。

能力能保证什么不能保证什么必备兜底
本地事务单资源原子提交外部支付、消息同时提交唯一键、状态流水
Outbox(发件箱)业务事实与事件意图同提交只发送一次扫描、幂等、对账
MQ(消息队列)可持久化投递和重试消费副作用天然幂等Inbox(收件箱)、状态机
分布式事务特定参与者下的协调或补偿任意外部系统时间倒流查单、补偿、人工闭环
支付对账发现权威事实差异自动解释所有业务原因差错分类、审批修复
flowchart LR
    A["支付本地事务"] --> B["支付单 + Outbox(发件箱)"]
    B --> C["MQ(消息队列)至少一次投递"]
    C --> D["订单 Inbox(收件箱)+ 状态机"]
    D --> E["库存或履约本地事务"]
    E --> F{"外部结果未知?"}
    F -->|"否"| G["继续流程"]
    F -->|"是"| H["稳定业务号查单"]
    H --> I["补偿、重试或人工"]
    I --> J["多方对账归零"]
  • 节点表示每个本地权威事实和跨系统传递方式;箭头表示事件与查单推进。
  • 前提是每个参与者都有稳定业务号、幂等约束和状态机。
  • 正常路径以多个本地事务逐步收敛。
  • 失败路径将未知结果显式保留,并通过查单、补偿与人工处理。
  • 业务结论是最终一致必须有时限、指标和人工出口,不是无限等待。

数据演绎 11:支付超时的三态处理。 商户支付号 M20260714001 发起 ¥1200.00 扣款,客户端在 3s(秒) 超时。本地不写“失败”,而是保持“未知”。第一次查单返回处理中,30s(秒) 后第二次查到成功,独立本地事务把支付单改成功并写事件。若客户端因超时重新生成新商户单号,会产生双扣;复用原业务号才能让渠道和本地都重放首次结果。

热门面试题

  1. 问题(基础题):本地事务与分布式事务的边界怎么判断?

    • 考点:资源管理器、参与者控制权、失败恢复。
    • 回答思路:先找必须原子的不变量,再看是否同一受管资源。
    • 详细答案:若订单状态、流水和 Outbox(发件箱)都在同一数据库并由同一事务管理器控制,应优先本地事务。跨到另一个数据库、消息系统、支付渠道或物流后,已经超出本地提交边界,需要根据参与者能力选择全局协议、消息最终一致或语义补偿。外部渠道通常只能用稳定业务号、查单、退款和对账,不能套用本地回滚。
    • 进阶追问:同一个服务里的两个数据库算本地事务吗?
    • 进阶回答:从进程看在本地,从资源原子性看仍是两个资源;两个普通事务管理器不能天然共同提交。
  2. 问题(原理题):为什么“最终一致”必须配合状态机和对账?

    • 考点:重复、乱序、永久失败、可证明收敛。
    • 回答思路:说明重试只能提高概率,不能定义合法结果。
    • 详细答案:消息会重复、乱序或长期失败,外部调用还可能结果未知。状态机限制哪些前置状态可以迁移,幂等约束吸收重复,对账把多个权威清单重新比较,发现漏发、漏消费和金额差异。没有状态机,重试可能把终态覆盖;没有对账,永久积压或人工误操作无法被证明已收敛。
    • 进阶追问:对账发现差异后能直接改数据库吗?
    • 进阶回答:应生成补发、补记、冲正或退款流水,保留原因和审批;直接改终值会破坏审计链。
  3. 问题(项目题):订单已履约但支付对账发现失败,如何处理?

    • 考点:不可逆副作用、权威事实、业务补偿。
    • 回答思路:先冻结继续损失,再按实物和资金事实选择新动作。
    • 详细答案:先停止该订单后续结算和重复履约,保留支付、订单、库存、面单和轨迹证据,向渠道按原单号查明最终资金事实。若确实未支付但货物已出库,不能把数据库状态简单回滚,应进入催收、拦截物流、退货或坏账审批;若渠道成功只是本地漏记,则补记支付与账务事件。全程用独立差错号和修复流水,并在对账差额归零后闭环。
    • 进阶追问:为什么不能自动重扣?
    • 进阶回答:客户授权、金额和渠道规则可能已变化,盲目重扣有合规与双扣风险,必须按支付协议和人工审批处理。

12. 源码关键方法、事故排查路径与设计思想

源码阅读应围绕状态转移,不必背所有类。事务代理入口关注 invokeinvokeWithinTransaction;属性解析关注事务属性源;管理器模板关注 getTransactionhandleExistingTransactionsuspendresumeprocessCommitprocessRollbackcleanupAfterCompletion;数据源实现关注 doBegindoSuspenddoResumedoCommitdoRollbackdoCleanupAfterCompletion;线程上下文关注资源绑定、解绑和同步回调触发。阅读时始终把方法映射到“代理、逻辑状态、物理资源、终局、清理”五列。

事故排查先保护业务,再保留现场。支付场景优先限流非核心写入、暂停可能重复扣款的自动重试,保留业务号并启动查单;数据库场景保存线程栈、连接池、慢事务、锁等待和事务日志。随后用最小业务号核对代理对象、线程、管理器、连接与最终数据,区分注解未拦截、异常规则错误、连接池耗尽、跨库部分提交和外部未知。修复后必须做故障注入和数据终局回归。

排查阶段要回答的问题证据错误动作
影响与止血是否继续扩大资金或库存差异错误率、差额、最老未知单立即全量重试
调用入口是否经过正确代理对象类型、调用栈、切面日志继续堆注解
事务状态新建、加入、挂起还是标仅回滚传播日志、事务状态只看异常是否抛出
资源绑定线程、管理器、数据源、连接是否一致线程名、连接号、自动提交强传连接到异步线程
最终事实哪些库、消息和外部动作已完成流水、唯一键、查单、对账直接改终态掩盖差异
回归相同失败窗口是否可恢复故障注入、守恒式、告警只验证接口返回码
flowchart TD
    A["发现事务或资金异常"] --> B["限流、停自动重试、保护查单"]
    B --> C["按业务号确认数据库与外部最终事实"]
    C --> D{"调用经过代理?"}
    D -->|"否"| E["修复自调用、对象创建或可见性"]
    D -->|"是"| F{"线程、管理器、数据源、连接一致?"}
    F -->|"否"| G["修复异步或多数据源边界"]
    F -->|"是"| H{"异常与传播是否符合设计?"}
    H -->|"否"| I["调整回滚规则和逻辑/物理边界"]
    H -->|"是"| J["检查连接池、锁、超时和外部未知"]
    E --> K["故障注入与最终状态回归"]
    G --> K
    I --> K
    J --> K
    K --> L["补监控、对账和人工闭环"]
  • 节点覆盖止血、事实、代理、资源、属性和容量;箭头表示证据收敛顺序。
  • 前提是先用稳定业务号固定影响范围。
  • 正常路径逐层排除而不是先猜某个注解。
  • 失败路径在跨系统未知时保留中间态,不做盲目回滚或重试。
  • 业务结论是事务排障的终点是业务事实恢复,不是日志不再报错。

数据演绎 12:一次事务事故复盘。 高峰期支付回调错误率从 0.2% 升到 18%,连接池 active=100/100、等待线程 86。线程栈显示外层支付事务持连接等待渠道,部分路径又调用 REQUIRES_NEW(总是新事务)审计。止血后把入口并发降到 60,暂停非关键审计并按原支付号查单;37 笔未知中 31 笔成功、6 笔失败。修复将渠道调用移到本地事务外,支付状态与 Outbox(发件箱)同事务,审计由事件异步写入;故障注入验证提交后崩溃仍可扫描恢复。

热门面试题

  1. 问题(基础题):阅读 Spring(Java 应用框架)事务源码最重要的主线是什么?

    • 考点:关键方法、状态机、资源清理。
    • 回答思路:以一次完整调用追踪,而不是背类名。
    • 详细答案:从事务代理进入 invokeWithinTransaction,看事务属性和管理器选择;进入 getTransaction 判断新建、参与、挂起或保存点;目标方法结束后进入提交或回滚流程,检查 rollback-only(仅回滚标记)与同步回调;最后跟踪清理和恢复连接属性。每一步都记录逻辑事务状态与物理连接变化,源码就能与线上证据对应。
    • 进阶追问:为什么清理流程与开始流程同样重要?
    • 进阶回答:连接属性、线程绑定或同步器未清理会污染线程池和连接池中的下一次请求,造成更隐蔽的串事务与只读状态残留。
  2. 问题(原理题):事务抽象背后的设计思想有哪些?

    • 考点:代理、模板方法、策略、线程上下文、组合边界。
    • 回答思路:从横切关注点和资源差异的解耦回答。
    • 详细答案:代理把事务从业务方法中分离;事务属性是声明式策略;抽象事务管理器用模板方法固定传播、提交、回滚和清理骨架,具体管理器实现资源细节;线程局部上下文让同线程多个组件共享资源;同步回调提供终局扩展。代价是调用必须经过代理、资源不能随意跨线程,抽象保证也严格受管理器边界限制。
    • 进阶追问:这种设计为什么容易被误用?
    • 进阶回答:注解把复杂机制压缩成一行,调用路径和物理资源变得不可见;需要用架构边界、集成测试和运行指标重新显式化。
  3. 问题(项目题):如何把事务事故经验讲成高级开发面试话术?

    • 考点:背景、影响、证据、止血、根因、修复、结果。
    • 回答思路:用可量化事实串联技术机制和业务结果。
    • 详细答案:先说明支付高峰连接池耗尽和未知单影响,再讲我如何限流、暂停非关键任务并按业务号查单保护资金;随后用线程栈、池指标和连接证据定位为事务内远程调用叠加独立事务。修复是缩短本地事务、状态与 Outbox(发件箱)同提交、外部结果查单、审计异步化。最后给出连接等待、未知单收敛时间和对账差额归零,并说明故障注入防复发。
    • 进阶追问:面试官追问“为什么一开始没发现”怎样回答?
    • 进阶回答:如实说明原压测未覆盖外层连接已占满时的嵌套路径,之后补最坏并发模型、连接等待告警和提交后断网演练,体现系统性改进而非推责。

项目落地话术

支付资金一致性话术:“我把本地原子边界收敛到支付单、必要账务记录和 Outbox(发件箱)。回调先验签、防重放,再按渠道交易号和商户支付号幂等,金额币种不一致直接冻结推进。数据库提交后由发布器至少一次发送,消费者用 Inbox(收件箱)和状态机幂等。渠道超时不写失败,而是保留未知并复用原单号查单;最后用渠道、本地支付、账务和订单四方对账。这样我能明确回答每个失败窗口由谁恢复,而不是用一个事务注解承诺跨系统原子。”

事务失效排查话术:“我排查事务不会先加注解,而是沿代理、属性、管理器、线程资源和数据库终局五层取证。先按业务号查真实状态,再确认入口对象是否为代理、异常是否越过代理、传播是否产生仅回滚标记、线程和连接是否变化、管理器是否匹配数据源。涉及外部支付时先查单止血,涉及连接池时先限流保护恢复容量。修复后在第二写失败、提交后断网和异步线程三个窗口做故障注入。”

复习清单

  • 能从 invokeWithinTransaction 口述到提交、回滚、同步回调与资源清理。
  • 能区分逻辑事务、物理事务、保存点和 rollback-only(仅回滚标记)。
  • 能逐一解释七种传播行为在“有外层/无外层”时的结果。
  • 能用 100 并发、100 连接解释 REQUIRES_NEW(总是新事务)饥饿闭环。
  • 能解释同类自调用、private(私有访问控制)、final(最终关键字)、新线程和错误管理器为何失效。
  • 能复述支付回调 T1T5 的唯一数据演绎和全部失败窗口。
  • 能说明 afterCommit(提交后)只是加速器,不能替代 Outbox(发件箱)。
  • 能把本地事务、MQ(消息队列)、支付查单和分布式补偿的责任边界分开。
  • 能按止血、事实、代理、资源、属性、容量、回归顺序排查事故。

知识节收口

前 12 节已经形成从代理入口、线程资源、传播与回滚,到连接容量、支付一致性和事故排查的完整证据链。本小节只用于终止知识节审计范围,不新增知识标记或六字段题;下面的综合题把这些机制转换为可连续口述、可追问、可落到项目数据的答案。

综合面试题库

  1. 问题(综合题):请完整讲解 Spring(Java 应用框架)声明式事务的执行链。

    • 口述答案:我会从调用入口开始讲,而不是从注解开始讲。容器为符合条件的 Bean(对象实例)创建代理,外部调用到达代理后,TransactionInterceptor(事务拦截器)先通过事务属性源解析目标方法和目标类上的传播、隔离、超时、只读与回滚规则,再选择 PlatformTransactionManager(平台事务管理器)。事务管理器执行 getTransaction,根据当前线程是否已有事务决定新建、加入、挂起、非事务执行或创建保存点。以数据源事务为例,新建时从连接池取得连接、关闭自动提交,并由 TransactionSynchronizationManager(事务同步管理器)把数据源到连接持有器的映射绑定到当前线程,持久层随后复用这条连接。

      目标方法正常返回后,拦截器不是无条件提交,而是先检查本地和全局 rollback-only(仅回滚标记),执行提交前同步回调,再由管理器提交物理资源;异常越过代理时,按异常类型和自定义规则决定回滚还是提交。提交或回滚之后还要执行完成回调、解绑线程资源、恢复连接的自动提交、隔离和只读属性并归还连接池。内层 REQUIRED(需要事务)可能只是一个逻辑事务,真正提交由最外层物理事务决定。

      这条链也解释了所有常见失效:同类 this 调用没有再次经过代理;新线程没有原线程的资源映射;选错事务管理器时管理的是另一数据源;手工创建连接不会复用绑定连接;异常被吞后代理看到的是正常返回。项目中我会用业务号、代理类型、线程名、管理器、数据源、连接标识和数据库最终流水逐层验证,而不会只看是否写了注解。跨到 MQ(消息队列)或支付渠道后,本地链路到此为止,后续必须依靠 Outbox(发件箱)、幂等、查单和对账。

    • 追问 1:事务属性从接口还是实现类读取?

    • 直接回答:属性源会按具体配置查找方法与类,工程中应把边界放在明确的实现入口并用集成测试验证,避免依赖代理类型和元数据查找差异。

    • 追问 2:目标方法返回后数据库一定已经提交吗?

    • 直接回答:加入外层事务时并没有,只有最外层物理事务终结时才真正提交;还可能因仅回滚标记改为回滚。

    • 追问 3:连接清理遗漏会怎样?

    • 直接回答:线程池和连接池复用会把旧事务资源、只读或隔离属性带给下一请求,形成串事务、连接泄漏或行为漂移。

    • 关联知识:事务代理调用链

  2. 问题(综合题):逻辑事务与物理事务为什么必须分开理解?

    • 口述答案:逻辑事务对应一次被事务拦截的方法范围,物理事务对应数据库连接从开始到提交或回滚的真实资源生命周期。外层方法使用 REQUIRED(需要事务)时会创建物理事务 P1,内层另一个 REQUIRED(需要事务)方法进入时通常只创建自己的逻辑状态并加入 P1,两者共享同一连接、锁和最终提交点。内层方法正常结束并不代表数据库已提交,它只是退出自己的逻辑范围;最外层还可能继续写入,最终由外层统一提交。

      分开理解后,UnexpectedRollbackException(意外回滚异常)就很自然。内层逻辑事务遇到满足回滚规则的异常时,没有权力单独结束共享连接,只能把 P1 标记为 rollback-only(仅回滚标记)。外层即使捕获异常并正常返回,也只是改变 Java(编程语言)控制流,标记仍然存在。外层最后请求提交时,事务管理器发现物理事务已经不能安全提交,只能整体回滚,并抛异常防止调用方误以为成功。相反,REQUIRES_NEW(总是新事务)会挂起 P1 并创建独立物理事务 P2;NESTED(嵌套事务)则仍在 P1 上使用保存点。

      项目设计时,我用逻辑边界表达方法契约,用物理边界检查资源成本和原子范围。订单编排方法可以有多个逻辑步骤,但同库必须共同成败的更新应共享一个短物理事务;独立审计若使用新事务,要接受外层失败后审计仍存在;保存点只能回退数据库写入,不能撤销已经发送的支付或物流请求。排查时必须同时记录事务状态和连接标识,否则只看到两个注解,容易错误地认为有两个数据库事务。

    • 追问 1:内层 REQUIRED(需要事务)可以使用不同超时吗?

    • 直接回答:加入既有物理事务时通常受外层已建立的资源属性约束,不能把连接重新变成另一套独立超时与隔离语义。

    • 追问 2:保存点算一个新物理事务吗?

    • 直接回答:不算,它是同一连接和物理事务内的局部恢复位置,最终仍服从外层提交或回滚。

    • 追问 3:为什么面试中要强调连接标识?

    • 直接回答:连接能把抽象传播行为落到真实资源上,证明两个逻辑范围究竟共享、独立还是已经脱离事务。

    • 关联知识:逻辑事务与物理资源

  3. 问题(综合题):请系统比较七种事务传播行为及其选型原则。

    • 口述答案:我先按“参与现有事务、创建独立边界、要求无事务”三组理解。REQUIRED(需要事务)有事务就加入、没有就新建,是同库业务用例默认选择;SUPPORTS(支持事务传播)有事务就加入、没有就非事务,适合可参与但不强制的查询,不过行为会随调用环境变化;MANDATORY(强制事务传播)要求上层已建立事务,否则立即失败,适合防止领域内部关键写方法被单独调用。NEVER(禁止事务传播)要求没有事务,NOT_SUPPORTED(不支持事务传播)则在有事务时先挂起再非事务执行,二者都不能让其写入随外层回滚。

      REQUIRES_NEW(总是新事务)无论外层是否存在都创建独立物理事务;有外层时挂起其连接,再借第二条连接。内层提交后不受外层回滚影响,适合必须独立留痕且业务允许孤立结果的场景,但会增加连接占用。NESTED(嵌套事务)通常在同一物理事务创建保存点,内层失败可回到保存点,成功结果最终仍随外层终结。它依赖事务管理器和数据库保存点能力,也不能撤销数据库之外的副作用。

      选型时先问不变量,而不是先选枚举:必须共同成败就用同一 REQUIRED(需要事务)物理事务;内部方法必须由编排层控制就用 MANDATORY(强制事务传播);局部数据库失败可容忍且外层仍可提交时评估保存点;确实需要独立提交且有容量预算才用 REQUIRES_NEW(总是新事务)。查询通常没有必要为了形式开启事务,但要考虑一致性快照。任何传播行为只有调用经过代理才生效,也都不能把本地事务扩展到支付渠道或 MQ(消息队列)。

    • 追问 1:NOT_SUPPORTED(不支持事务传播)适合执行慢查询吗?

    • 直接回答:可避免占用外层事务连接和锁,但查询会脱离外层一致性视图,且挂起恢复仍有成本,应按业务一致性判断。

    • 追问 2:NEVER(禁止事务传播)与 NOT_SUPPORTED(不支持事务传播)区别是什么?

    • 直接回答:前者发现已有事务就失败,强调禁止契约;后者会挂起已有事务后继续非事务执行。

    • 追问 3:传播行为能解决分布式事务吗?

    • 直接回答:不能,它只组织当前进程中受指定事务管理器控制的资源,跨服务仍需消息、协调、补偿和对账。

    • 关联知识:七种传播行为

  4. 问题(综合题):请解释 rollback-only(仅回滚标记)和 UnexpectedRollbackException(意外回滚异常)的完整因果链。

    • 口述答案:典型场景是外层 REQUIRED(需要事务)开启物理事务,内层 REQUIRED(需要事务)加入同一个连接。内层插入流水时发生唯一键冲突,异常符合回滚规则,于是内层逻辑事务不能提交。但因为物理连接属于整个调用链,内层不能直接结束它,只能把事务标记为 rollback-only(仅回滚标记)。外层捕获异常后继续执行并正常返回,代理在外层看见的是“请求提交”,但提交流程检查到该标记后只能执行物理回滚。

      如果事务管理器只是悄悄回滚,调用方会把正常返回解释为数据库已经提交,继续向客户返回成功或发送后续动作,因此框架抛 UnexpectedRollbackException(意外回滚异常)显式暴露终局与表面控制流不一致。外层捕获异常并不会清除标记;手工清除也不是合理修复,因为内层失败通常意味着本地不变量已被破坏。需要重新判断业务语义:内层失败必须让整体失败,就不要吞异常;内层失败可容忍,可以在写入前校验、把结果建模为合法状态、使用保存点局部回退,或在真正需要独立提交时拆成 REQUIRES_NEW(总是新事务)。

      排查时我会记录内外层事务是否新建、是否参与、何时设置仅回滚标记、最终连接执行了什么动作,并查询数据库事实。常见错误是日志中外层打印“处理完成”,团队就认定提交成功;事实上那只代表业务方法返回。支付和库存项目里不能忽略该异常后仍返回成功,否则客户端重试与本地回滚状态会产生更大差异。正确回归用例是在内层故障后同时断言外层状态、流水和事件全部未提交,并断言调用方收到明确失败。

    • 追问 1:内层异常使用 noRollbackFor(不回滚异常规则)能避免吗?

    • 直接回答:只有业务允许该异常发生后仍提交完整不变量时才可以,不能为了消除异常而掩盖失败。

    • 追问 2:外层能通过新事务隔离内层失败吗?

    • 直接回答:REQUIRES_NEW(总是新事务)可以隔离物理终局,但会留下独立提交结果并增加连接成本,必须符合业务语义。

    • 追问 3:为什么异常名称叫“意外”?

    • 直接回答:对外层调用者而言方法正常结束却未提交是意外;对事务状态机而言,检测到仅回滚标记后回滚是确定行为。

    • 关联知识:仅回滚状态机

  5. 问题(综合题):REQUIRES_NEW(总是新事务)和 NESTED(嵌套事务)应该如何选择?

    • 口述答案:两者都可能让内层失败后外层继续,但底层保证完全不同。REQUIRES_NEW(总是新事务)会挂起外层事务和连接,借新连接开启独立物理事务。内层提交后成为永久事实,外层随后回滚也不会撤销;内层失败通常只回滚自己。它适合确实需要独立终局的动作,例如记录“某次支付尝试失败”的审计流水,但审计语义必须写成尝试,而不能在外层失败后仍声称业务完成。它还会让单线程同时占两条连接,批量和高并发下容易造成连接池饥饿。

      NESTED(嵌套事务)通常复用外层连接并建立保存点。内层失败时可以回滚到保存点,外层继续;内层成功只是释放或越过保存点,最终仍由外层统一提交。它适合单数据库内局部失败可容忍的处理,例如小批量导入中跳过坏行,但依赖数据库、驱动和事务管理器支持保存点。保存点只能撤销该连接上的数据库写入,不能撤销已经调用的 MQ(消息队列)、支付渠道、文件系统或外部服务。

      我会先问:内层结果是否应该在外层失败后继续存在?若是,再评估独立事务和连接容量;若否,只需局部回退,可评估保存点;若所有操作本就必须共同成败,继续用 REQUIRED(需要事务)并正确传播异常。项目上线前要故障注入内层提交后外层失败、保存点回滚后外层提交、连接池接近满载三种情况,核对最终数据和资源占用,而不能只做正常路径测试。还要把独立提交产生的孤立记录纳入对账,确认它有清晰状态、来源业务号和可执行的补偿出口。

    • 追问 1:独立审计一定要用 REQUIRES_NEW(总是新事务)吗?

    • 直接回答:不一定,可把审计意图与业务同事务写入 Outbox(发件箱),提交后异步处理,通常更节省连接且语义清晰。

    • 追问 2:NESTED(嵌套事务)在所有持久化框架中都可用吗?

    • 直接回答:不能假设,必须核对具体事务管理器、数据库与驱动的保存点支持并做真实回滚验证。

    • 追问 3:内层新事务提交后外层失败怎么补偿?

    • 直接回答:若独立结果不再合法,要发起新的幂等补偿流水,不能指望外层回滚自动撤销。

    • 关联知识:独立事务与保存点

  6. 问题(综合题):同类自调用为什么会导致事务失效,怎样修复最合理?

    • 口述答案:声明式事务通常通过代理实现。容器对目标对象包装后,外部持有的是代理;调用到达代理,TransactionInterceptor(事务拦截器)才有机会解析方法属性并管理事务。目标方法内部执行 this.inner() 时,Java(编程语言)直接在当前目标实例上分派调用,不会重新回到代理,所以 inner() 上新增的传播、隔离、超时或回滚规则没有机会执行。若外层已经有事务,内部数据库操作仍可能沿用外层连接;若外层没有事务,内部注解不会自行开事务。因此“失效”准确地说是内层声明未被应用,不是内部代码必然没有任何事务。

      最合理修复是调整职责和调用方向。把需要独立事务语义的原子操作放到另一个 Bean(对象实例),由编排对象通过其代理调用;这样代码结构直接表达边界,也便于单独做集成测试。也可以显式委托给一个事务模板,让开始、提交和异常处理可见。自注入当前对象或从上下文获取当前代理技术上可用,但会产生隐式循环、框架耦合和测试差异;暴露当前代理更不应成为默认方案。编译期织入能覆盖更广连接点,但引入构建、调试和团队认知成本。

      排查时不能只搜索注解。我会打印入口对象实际类型,抓调用栈确认是否出现代理拦截器,记录进入内层前后事务活动标志与连接标识,再在内层第二条写入处故障注入。比如批处理对象内部自调用单笔退款方法,第一条状态更新自动提交、第二条流水失败,就会部分成功;拆出单笔服务后,两条写入由代理开启的同一事务共同回滚。修复验收还要覆盖控制器外部调用、同类内部调用、定时任务和测试直接 new 对象四条路径。

    • 追问 1:外层也有事务时需要修复自调用吗?

    • 直接回答:若内层只想沿用外层可不依赖内层注解;若它声明了不同传播或回滚语义,则仍需修复并明确边界。

    • 追问 2:private(私有访问控制)方法上的事务注解为什么危险?

    • 直接回答:它不能作为普通外部代理入口,团队容易误以为有独立边界;应把入口放在可代理且职责明确的方法上。

    • 追问 3:单元测试为什么容易漏掉这个问题?

    • 直接回答:直接实例化目标对象没有容器代理,或测试只断言异常而不查数据库终局,无法验证真实拦截路径。

    • 关联知识:自调用失效矩阵

  7. 问题(综合题):请说明事务异常回滚规则的设计与常见陷阱。

    • 口述答案:常见同步代理事务默认对 RuntimeException(运行时异常)和 Error(严重错误)回滚,对受检异常默认提交,但这只是框架约定,不是业务正确性的判断。设计回滚规则时,我先定义本地不变量:支付单状态、账务记录和 Outbox(发件箱)是否必须共同成功;库存余额、预占流水和幂等记录是否必须共同成功。任何导致这组写入不完整的异常,无论受检还是运行时,都应让事务回滚。可容忍的业务拒绝最好在写入前校验,或建模为一个完整合法状态,而不是依赖异常类型偶然提交。

      常见陷阱有四类。第一,方法捕获异常后只记录日志并正常返回,代理看不到失败,会按成功提交;第二,受检异常没有配置 rollbackFor,调用方收到失败但数据库已提交;第三,noRollbackFor 范围过宽,把本应回滚的数据错误也提交;第四,内层参与事务设置 rollback-only(仅回滚标记),外层吞异常后在提交点得到 UnexpectedRollbackException(意外回滚异常)。字符串异常模式还可能误匹配名字相似的类,类型规则更稳妥。

      实践中我把事务方法放在内部应用服务,控制器或消息监听器在外层捕获统一业务异常并转换协议响应,避免同一方法既决定数据库终局又吞异常做展示。对确实要降级的步骤,先区分是否有数据库副作用;可选通知失败可以记录待重试事件,不应阻断核心提交。回归测试必须在每个写入阶段注入受检异常、运行时异常和返回失败,逐表验证最终状态,并检查客户端看到的结果与数据库终局一致。

    • 追问 1:全部异常都回滚是不是最稳妥?

    • 直接回答:是保守起点,但业务拒绝、重复请求等可能是合法结果;应避免用异常承载所有分支,并明确哪些状态可以提交。

    • 追问 2:捕获后调用 setRollbackOnly(设置仅回滚)可以吗?

    • 直接回答:可以要求回滚,但必须向上返回明确失败,不能让调用方误以为成功;通常重抛业务异常更清晰。

    • 追问 3:finally(最终清理)抛新异常有什么风险?

    • 直接回答:可能覆盖原始根因,虽然事务仍可能回滚,但排障和重试分类会被误导,应保留原异常并谨慎处理清理失败。

    • 关联知识:异常与仅回滚标记

  8. 问题(综合题):隔离级别、readOnly(只读事务属性)和 timeout(超时)在框架层有什么真实边界?

    • 口述答案:这些属性是代理解析后交给事务管理器的声明,真正效果取决于物理事务和底层资源。隔离级别通常只在新建物理事务时映射到 JDBC(Java 数据库连接)的连接属性;内层方法加入外层事务时,连接已经开始,通常沿用外层隔离。若团队要求内层声明与现有事务严格一致,可以开启验证,在不兼容时尽早失败。具体快照、锁和可见性仍由数据库实现,框架属性不能替代对 MySQL(关系型数据库)MVCC(多版本并发控制)和锁行为的验证。

      readOnly(只读事务属性)通常是意图和优化提示,管理器可能设置连接只读,持久化框架可能减少刷新,但不同驱动与数据库不一定拒绝所有写语句。因此它不能代替只读账号、数据库权限或只读副本路由,也不能作为安全边界。timeout(超时)是本地事务或语句的时间预算,超时后在受管资源上触发异常或仅回滚标记;它无法撤销已经成功的支付请求、消息投递或文件写入。把远程调用放在两秒事务中,即使本地超时回滚,渠道仍可能已扣款。

      项目中我先缩短事务,再配置属性。批量库存任务按小批独立提交,超时覆盖正常数据库高分位并小于上游总预算;读接口在只读事务中还要验证是否路由到合适数据源;需要更高隔离的关键写入用两个真实连接演练并发现象。上线后观察事务时长、锁等待、超时回滚和连接占用,而不是看到注解就认定优化成功。跨系统操作始终使用处理中状态、稳定业务号和查单收敛。

    • 追问 1:内层声明 SERIALIZABLE(可串行化隔离)能升级外层隔离吗?

    • 直接回答:加入现有物理事务时通常不能中途升级,应该由外层统一定义或拆成确有必要的独立事务。

    • 追问 2:只读事务能防止开发误写吗?

    • 直接回答:不能作为可靠防线,应配合只读权限、代码审查、路由与集成测试。

    • 追问 3:事务超时后一定立即释放数据库锁吗?

    • 直接回答:要看异常何时被检测并完成回滚;线程卡在不可中断调用时,资源可能继续占用,所以还需数据库和网络超时协同。

    • 关联知识:事务属性边界

  9. 问题(综合题):请用定量模型解释 REQUIRES_NEW(总是新事务)如何耗尽连接池。

    • 口述答案:假设连接池上限是 100,入口恰有 100 个并发请求。每个请求先进入外层 REQUIRED(需要事务),借一条连接并更新订单,此时 100 条连接全部被外层事务持有。随后每个线程都调用 REQUIRES_NEW(总是新事务)方法,事务管理器只能挂起外层连接,不能归还,因为外层稍后还要继续;内层需要再借一条新连接。但池中已经没有空闲连接,于是 100 个线程全部等待,任何线程都无法完成内层并回到外层,也就没有连接被归还,形成资源闭环。

      等到连接获取超时,内层批量失败,外层回滚,客户端重试又制造第二波压力。简单把池扩到 200 并不一定安全,因为数据库的会话、内存和执行能力可能只允许 150,后台任务与故障恢复也需要余量;多层独立事务还会继续放大需求。更可靠的修复是审视业务语义:审计能否与业务事实同事务写事件,提交后异步处理;内层是否真的必须独立;远程调用和大循环是否能移出事务;入口并发是否可限制到数据库安全预算内。

      监控上我同时看活跃连接、空闲连接、等待线程、连接获取高分位、事务时长和独立事务调用量。事故时先限流和暂停非核心补偿任务,保护查询与恢复容量,保存线程栈验证大量线程是否“持外层连接等内层连接”。修复验证要在最坏并发而不是平均流量下执行,并预留管理和恢复连接。这个例子说明事务传播不仅是正确性配置,也是容量模型的一部分。

    • 追问 1:池大小设置为并发数乘二是否足够?

    • 直接回答:只能覆盖单层理想模型,还要考虑嵌套深度、后台任务、慢事务和数据库安全上限,不能机械乘二。

    • 追问 2:连接获取超时调长有什么效果?

    • 直接回答:只会让等待闭环维持更久并占住更多线程,若没有线程能释放资源,调长不能创造连接。

    • 追问 3:为什么外层连接不能先归还?

    • 直接回答:外层物理事务尚未终结,连接上保留未提交写入和会话状态,归还会破坏事务隔离并污染其他请求。

    • 关联知识:连接池饥饿模型

  10. 问题(综合题):为什么不能在本地事务中长时间调用支付或物流接口?

  • 口述答案:第一层问题是资源占用。本地事务开始后会持有数据库连接,写操作还可能持行锁;远程接口的网络延迟、重试和限流把原本几十毫秒的事务拉长到数秒。连接池吞吐近似受占用时间反向影响,热点行锁也会让其他订单排队。若外层还调用 REQUIRES_NEW(总是新事务),每个请求同时占多条连接,故障会从一个渠道扩散到整个数据库入口。

    第二层问题是一致性语义。支付渠道在 1.8s(秒) 已扣款,但响应在 2.4s(秒) 到达,本地事务超时后回滚订单,并不能让渠道扣款倒退。网络超时只表示调用方没有拿到确定结果,不代表远端失败。物流建单、短信、文件和设备控制同样不是当前事务管理器的资源。若代码捕获超时后自动换新业务号重试,还可能产生双扣或重复面单。

    我的设计是先在短本地事务中建立支付意图或履约任务,使用稳定商户单号和明确状态机;事务外调用渠道,确定成功或失败后用另一个短本地事务推进,超时则标记“未知”并复用原号主动查单。必须向下游传播时,同事务写 Outbox(发件箱),提交后发布。对账比较渠道、本地支付、账务和订单,长期未知进入人工。若业务必须同步等待,也要在等待前释放不必要的数据库资源,设置分层超时、限流和熔断,并证明失败窗口能恢复。容量验收还要模拟渠道高分位延迟与查单流量同时出现,确认实时请求、恢复任务和人工查询各有独立预算,不会在故障时互相挤占。

  • 追问 1:先调用渠道再开启本地事务就完全安全吗?

  • 直接回答:仍有渠道成功后本地写库失败的窗口,需要稳定业务号查单、幂等推进、补偿和对账,只是避免长时间占连接与锁。

  • 追问 2:本地回滚后能自动发退款吗?

  • 直接回答:先查明渠道确实成功,再以独立退款号发起新业务动作;未知时盲目退款可能失败或与迟到结果冲突。

  • 追问 3:物流建单超时如何避免重复面单?

  • 直接回答:使用稳定请求号向物流方查单和幂等重放,不能每次超时都生成新请求号。

  • 关联知识:事务内远程调用风险

  1. 问题(综合题):多数据源场景怎样正确选择事务管理器,为什么顺序提交不等于原子?
  • 口述答案:每个本地事务管理器通常只认识一个资源工厂。主订单库、账务库和轨迹库各自有数据源时,应为它们建立明确管理器,并在事务边界通过限定名称选择。排查不能只看管理器的 Bean(对象实例)名称,还要确认持久层最终取得的数据源对象与该管理器管理的是同一身份。如果订单方法使用了主库管理器,但某个 Mapper(映射器)实际路由到账务数据源,主库事务日志即使显示提交或回滚,也不能控制账务连接的自动提交。

    把两个事务管理器依次开始、依次提交仍不是严格原子。假设先提交订单库成功,再提交账务库时网络断开或数据库拒绝,订单已经不可逆地对外可见,第二个提交失败;反过来提交只会改变哪一边先暴露。所谓链式管理器可以协调调用顺序和异常,但没有共同准备日志与统一决定,无法消除最后一次提交失败窗口。若所有资源支持统一协议且业务能承受阻塞,可以评估全局事务;更多业务场景应让每个领域守住本地事实,通过 Outbox(发件箱)、幂等和补偿收敛。

    项目中订单库提交订单版本与事件,账务服务消费事件后在自己的本地事务中写唯一分录;重复消息命中支付单、方向和版本的唯一键。轨迹同样按事件号和状态版本幂等。接口返回“已受理”而不是伪装三库瞬时完成,监控事件年龄、消费失败和对账差异。测试要在第一库提交后、第二库提交前强制断网,证明系统能识别差异并恢复,而不是只验证两个库都正常时的顺序调用。

  • 追问 1:同一进程内写两个库为什么仍是分布式问题?

  • 直接回答:原子性边界取决于资源管理器和提交协议,不取决于代码是否在同一个 Java(编程语言)进程。

  • 追问 2:使用同一个事务注解能同时管理两个库吗?

  • 直接回答:普通本地管理器只能控制自己资源,除非采用明确的全局协议并满足资源前提,否则不能。

  • 追问 3:如何发现路由到了错误数据源?

  • 直接回答:记录管理器、数据源身份、连接标识和数据库地址,并通过故障注入检查目标库是否随事务回滚。

  • 关联知识:多管理器边界

  1. 问题(综合题):TransactionSynchronization(事务同步)回调能做什么,为什么不能替代可靠消息?
  • 口述答案:事务同步回调提供围绕物理事务终局的扩展点。提交前可以做持久化上下文刷新或轻量一致性校验,完成前适合资源准备;数据库提交成功后触发 afterCommit(提交后),最终无论提交还是回滚都会进入 afterCompletion(完成后)。它们与当前线程事务绑定,适合清理资源、记录指标、刷新本地缓存提示,或在提交后唤醒一个发布线程。关键是理解回调触发时数据库处于什么状态,而不是把所有后续动作都塞进去。

    afterCommit(提交后)执行时数据库已经提交,回调抛异常不能让数据库时间倒流。进程可能在数据库返回成功后、回调开始前崩溃,也可能在消息发送成功后、回调记录完成前崩溃。如果事件只存在内存,第一种窗口会永久丢消息;若重复执行没有幂等,第二种窗口会产生重复副作用。因此关键业务必须在本地事务中把事件意图持久化到 Outbox(发件箱),回调只作为低延迟唤醒,周期扫描器按状态和版本兜底。

    例如支付单与事件 E-P9001 同事务提交,afterCommit(提交后)唤醒发布器。进程在唤醒前崩溃,重启扫描仍能找到待发布事件;发送成功后标记失败,扫描会再次发送,消费者以事件号和业务状态幂等。监控要覆盖待发布总量、最老年龄、重复率和消费结果。这样回调承担性能优化,持久化事件承担可靠性,两者职责清楚,也不会误把本地事务扩展成数据库与 MQ(消息队列)的原子提交。

  • 追问 1:beforeCommit(提交前)可以发送消息吗?

  • 直接回答:不应发送不可撤销消息,因为后续数据库仍可能回滚,消费者会看到不存在的业务事实。

  • 追问 2:afterCommit(提交后)里写库属于原事务吗?

  • 直接回答:原事务已经提交,新写入需要明确的新事务;失败不能影响原结果,必须单独恢复。

  • 追问 3:只使用定时扫描是否可以?

  • 直接回答:可以保证恢复但时延较高,常用回调或变更通知加速、扫描兜底,两者都基于同一持久化事件。

  • 关联知识:事务同步回调

  1. 问题(综合题):请按 T1(时间点一)至 T5(时间点五)讲清支付回调的一致性设计。
  • 口述答案:T1(时间点一)收到渠道回调后,先验签、防重放,按渠道交易号和商户支付号锁定本地支付意图,并核对金额、币种和商户。重复回调不能直接再次执行业务,金额或币种冲突也不能按幂等成功处理。T2(时间点二)在同一本地事务中,用前置状态条件把支付单从处理中改为成功,写必要账务记录,并插入唯一事件 E-P9001 到 Outbox(发件箱)。任一步失败,三者共同回滚。

    T3(时间点三)数据库提交后才向渠道返回成功。若提交成功但响应丢失,渠道会重复回调,本地按支付号读取首次成功结果;不能创建第二笔支付。T4(时间点四)发布器扫描已提交事件并发送到 MQ(消息队列)。发送成功后更新事件状态可能失败,因此允许同一事件重复发送。T5(时间点五)消费者在自己的本地事务中先写 Inbox(收件箱)去重记录,再按订单前置状态推进并写消费流水,完成后才确认消息;重复投递返回首次结果。

    失败恢复分四类:本地提交前失败直接回滚;提交后发布前崩溃由扫描恢复;发布后标记前崩溃通过消费者幂等吸收;消费者永久失败进入隔离、死信和人工队列。每日按渠道交易、支付单、账务分录、事件和订单履约对账。渠道超时则保持未知并复用原商户单号查单,不能写失败或换号重试。这个设计不宣称瞬时原子,而是确保每个失败窗口都有持久化证据、自动恢复时限和人工出口。

  • 追问 1:为什么在提交后才响应渠道?

  • 直接回答:若数据库尚未提交就响应成功,渠道可能停止重试,而本地失败后失去自然恢复入口。

  • 追问 2:重复回调是否每次都要锁数据库?

  • 直接回答:可先按唯一键快速查询终态,涉及状态迁移时再使用条件更新或必要锁,最终仍由持久化约束兜底。

  • 追问 3:T5(时间点五)失败能把支付改回失败吗?

  • 直接回答:不能,支付是权威成功事实;应重试或补偿下游履约,而不是篡改资金事实。

  • 关联知识:支付回调 T1 至 T5

  1. 问题(综合题):支付回调如何同时实现验签、防重放、幂等和状态机?
  • 口述答案:四项能力解决不同问题。验签证明回调内容来自持有渠道密钥的一方,并保证关键字段未被篡改;防重放检查时间戳、随机数或渠道事件标识的有效窗口,降低旧请求被再次利用;幂等保证同一合法业务请求重复到达不会重复扣账或推进订单;状态机限制从当前状态到目标状态是否合法。只做其中一项都不完整,例如签名正确的回调仍可能重复,Redis(远程字典服务)锁成功也不能证明金额和币种正确。

    落地时先用原始请求体和渠道规则验签,再校验商户、时间窗口、金额和币种。数据库以渠道交易号、商户支付号建立唯一约束,支付状态更新携带前置状态和版本;同一键同一参数重复到达,读取首次结果并按渠道协议返回成功。同一交易号却金额不同,不能视为重复成功,应冻结推进并触发安全告警。支付成功通常是不可逆终态,退款使用独立退款号和新状态机,不允许迟到失败回调覆盖成功。

    并发回调可以用短锁降低竞争,但最终正确性由唯一键与条件更新保证,因为分布式锁会过期或在故障转移时产生边界。每次状态迁移记录来源、渠道事件号、原状态、新状态、金额、时间和请求摘要。对账时把渠道清单与本地支付、账务和订单比对。故障测试覆盖两个节点并发处理同一回调、成功后响应丢失、乱序失败回调和金额冲突,验证业务副作用只发生一次且冲突有审计证据。上线后还要统计重复命中率、参数冲突量和非法状态迁移,区分渠道正常重试与攻击或接入错误。

  • 追问 1:只使用数据库唯一索引是否足够?

  • 直接回答:唯一索引解决重复创建,还需状态条件、参数一致性校验和合法迁移,才能防止乱序覆盖与错单。

  • 追问 2:防重放窗口过短有什么风险?

  • 直接回答:渠道正常重试或网络延迟可能被误拒绝,窗口应结合渠道协议,且业务幂等仍需长期有效。

  • 追问 3:退款为什么不能复用支付幂等键?

  • 直接回答:退款是独立业务动作,可能部分退款或多次退款,需要自己的请求号、金额守恒和状态流水。

  • 关联知识:支付幂等边界

  1. 问题(综合题):Outbox(发件箱)如何解决数据库与 MQ(消息队列)的双写问题,又有哪些剩余风险?
  • 口述答案:Outbox(发件箱)的核心不是让数据库和 MQ(消息队列)瞬间原子,而是把“业务事实”和“必须发布的事件意图”放进同一个本地事务。库存预占成功时,同时更新余额、写预占流水和待发布事件;支付成功时,同时更新支付单、账务记录和事件。事务回滚时两者都不可见,事务提交后即使应用立即崩溃,扫描器仍能从数据库找到待发布事件,因此消除了“数据库已提交但内存中的发送动作丢失”的不可恢复窗口。

    发布器读取待发布记录,按事件标识发送,成功后更新状态。发送成功与状态更新仍不是原子操作:若发送后崩溃,事件会再次发送。因此 Outbox(发件箱)通常提供至少一次,不提供绝对只发一次。消费者必须用 Inbox(收件箱)唯一键、业务唯一约束和状态机幂等;事件顺序要带聚合标识与版本,乱序时拒绝、暂存或重拉快照。发布器多实例并发扫描还需使用状态抢占、跳过锁定或分片,防止长期重复竞争。

    剩余风险包括事件表积压拖慢业务库、毒事件无限重试、发布成功但消费者长期失败、事件结构升级不兼容和人为直接修改状态。需要监控待发布数量、最老年龄、发送重试、重复率、消费失败和业务差异;已发送数据按审计周期归档,永久失败进入隔离与人工。对账从业务事实反向检查是否存在事件,从消费结果反向检查是否有对应业务。这样 Outbox(发件箱)是可恢复双写方案,不是“用了事件表就永不丢”的口号。

  • 追问 1:事件表和业务表不在同一库怎么办?

  • 直接回答:就失去同一本地事务优势,应调整数据所有权,或选择满足资源前提的其他一致性方案并保留补偿。

  • 追问 2:发布器可以删除已发送事件吗?

  • 直接回答:可按审计与重放周期归档后清理,不能立即删除到无法对账和追踪。

  • 追问 3:消费者幂等只记录事件号够吗?

  • 直接回答:还要把去重记录与业务更新同事务提交,并校验业务键、版本和参数摘要,防止同号不同内容。

  • 关联知识:Outbox(发件箱)与本地事务

  1. 问题(综合题):异步线程、定时任务和线程池中的事务应该怎样设计?
  • 口述答案:TransactionSynchronizationManager(事务同步管理器)把连接和同步器保存在当前线程的 ThreadLocal(线程本地变量)中。外层请求把任务提交到线程池后,执行线程没有原来的资源映射,会从连接池取得另一条连接;外层随后回滚,无法撤销异步线程已经提交的写入。强行把活跃连接跨线程传递也不安全:连接和事务状态通常不是为并发使用设计,谁负责提交、回滚和清理会变得不确定,线程池复用还可能造成资源泄漏。

    正确做法是让异步工作拥有自己的明确边界。若数据库写入必须与外层共同成败,就不要异步,留在同线程短本地事务中;若可以提交后执行,在外层事务中写 Outbox(发件箱)或任务表,提交后由异步执行器领取,执行器以任务号幂等并为每个小批开启独立事务。定时任务同样按检查点和租约领取任务,不依赖触发线程存在外层事务。失败分类为可重试、确定性错误和需人工三类,避免无限重试。

    日志追踪标识、租户和安全上下文可以通过受控包装传递,但不能把数据库事务上下文照搬。项目中异步导出先提交任务记录,执行器分块读取和写文件,每块记录检查点;库存延迟释放按预占号条件更新;支付查单复用原商户单号。监控任务年龄、重试次数、处理中租约和连接占用。测试要让外层提交前崩溃、任务执行中崩溃、同一任务被两个节点抢到,验证没有无来源任务、重复副作用和永久处理中状态。

  • 追问 1:异步方法上再加事务注解有用吗?

  • 直接回答:若异步调用经过相应代理,它会在执行线程开启自己的事务,但与调用线程事务独立,不会共同提交。

  • 追问 2:提交后事件是否一定异步执行?

  • 直接回答:事务同步回调本身可在原线程执行,真正异步需要独立执行器;可靠性仍应由持久化任务或事件保证。

  • 追问 3:为什么任务表要有租约?

  • 直接回答:执行节点崩溃后,租约到期允许其他节点接管,同时版本条件避免两个节点同时更新结果。

  • 关联知识:线程资源绑定

  1. 问题(综合题):跨库部分提交事故应怎样止血、修复和防复发?
  • 口述答案:首先承认部分提交已经超出单个本地事务可自动恢复的范围。止血时按业务键暂停继续履约、结算或库存释放,限制自动重试,避免相反状态继续扩散。保留两个数据库的业务流水、事务日志、应用版本、管理器与数据源配置、线程和连接证据,确认哪一库已经提交、哪一库失败以及是否存在外部支付或物流副作用。不能直接把“少数一方”覆盖成“多数一方”,要以业务权威和客户承诺判断合法终态。

    修复时先建立独立差错号和审批记录。如果订单已提交而账务未写,按原支付单和方向幂等补记账务;如果账务已记但订单失败,可能需要冲正、退款或恢复订单,取决于资金和实物事实。所有修复都是新流水,不修改或删除原事实。然后重跑订单、支付、账务、库存和外部清单对账,差额归零、未知态清空并完成审计后才闭环。若外部结果未知,先查单,不能盲目重放。

    防复发要缩小跨库同步写入。让每个领域只写自己的库,在本地事务中写 Outbox(发件箱),下游按事件与版本幂等提交;对确实需要强协调的短事务,先验证资源协议、阻塞成本和恢复能力。配置层明确每个方法使用的事务管理器,启动时检查数据源身份;测试在第一库提交后强制让第二库失败,验证补偿和告警。线上以跨库差异、事件最老年龄和人工量作为完成指标,而不是只看接口成功率。恢复工具必须先展示影响订单、金额和预计动作,再通过版本条件执行,避免修复脚本成为第二次事故来源。

  • 追问 1:能否通过数据库主从复制解决两个业务库原子性?

  • 直接回答:复制解决同一数据库体系的数据副本,不会让两个独立业务资源获得共同提交决定。

  • 追问 2:为什么修复不用直接更新最终状态?

  • 直接回答:直接改值无法证明原事实、修复原因和金额数量守恒,新流水才能审计、重试和对账。

  • 追问 3:什么时候考虑全局事务?

  • 直接回答:参与者受控、资源支持协议、事务很短、能接受阻塞并具备协调日志恢复时才评估。

  • 关联知识:多数据源与分布式边界

  1. 问题(综合题):线上出现“事务不生效”,你会怎样建立证据链?
  • 口述答案:我先定义“不生效”是哪种现象:异常后部分数据仍提交、正常返回却全部回滚、写到了错误数据库、异步数据没有回滚,还是消息与数据库不一致。按业务号查询最终状态、流水、唯一键和事件,比应用日志更先确定事实。若涉及支付,立即暂停换号重试并按原单号查渠道,避免把未知结果扩大成双扣;若连接池接近满载,先限流非核心入口保护查询和恢复容量。

    第二层检查代理入口。记录对象实际类型、目标方法可见性和调用栈,确认是外部调用还是同类 this 旁路,是否由容器取得对象而非直接 new。第三层检查事务属性和异常:传播行为、新事务标志、rollback-only(仅回滚标记)、异常是否被吞、受检异常是否配置回滚。第四层检查资源:线程名是否切换,事务管理器与数据源是否匹配,持久层是否使用绑定连接,连接标识和自动提交是否变化。最后查数据库锁、超时、存储能力和真实提交日志。

    修复后不能只看日志不报错。我会在第一个写入后、第二个写入前注入运行时异常和受检异常,验证所有相关表的最终状态;对自调用和异步分别走真实入口;对多数据源在其中一库断网;对支付在渠道成功后丢响应。回归还要检查连接和线程资源被清理、告警能发现最老未知单和事件积压。这样的证据链能区分代理问题、资源问题与跨系统边界问题,避免反复添加注解却制造更多隐藏事务。

  • 追问 1:事务调试日志应该长期全开吗?

  • 直接回答:高流量环境不宜全开,应按业务号和包动态采样,配合池指标、连接标识与数据库证据。

  • 追问 2:为什么先查最终数据而不是先看异常栈?

  • 直接回答:异常可能发生在提交后或另一个资源中,只有最终事实能确定需要补做、回滚还是查单。

  • 追问 3:怎样证明修复没有只覆盖一条调用路径?

  • 直接回答:建立外部代理、自调用、异步、定时任务和多数据源的路径矩阵,逐条故障注入并核对终局。

  • 关联知识:事故排查路径

  1. 问题(综合题):如果面试官让你从源码角度解释事务,你会抓哪些关键方法?
  • 口述答案:我不会背完整继承树,而是沿一次状态变化追踪。代理侧从 TransactionInterceptor(事务拦截器)的 invoke 进入事务切面支持类的 invokeWithinTransaction,这里完成目标类判断、事务属性解析、事务管理器选择和目标方法回调。接着看抽象事务管理器的 getTransaction,它根据当前资源是否已有事务进入 handleExistingTransaction,在这里实现 REQUIRED(需要事务)、REQUIRES_NEW(总是新事务)、NESTED(嵌套事务)、挂起和无事务约束。

    资源侧看具体数据源管理器的 doBegin:取得连接、设置自动提交、隔离与只读,并通过 TransactionSynchronizationManager(事务同步管理器)绑定资源。REQUIRES_NEW(总是新事务)会经过 suspendresume,保存点由事务状态和资源能力协作。目标方法结束后,正常路径进入 commitprocessCommit,异常路径进入 rollbackprocessRollback;两条路径都要检查 rollback-only(仅回滚标记)并触发同步回调。最后 cleanupAfterCompletion 和具体 doCleanupAfterCompletion 负责解绑、恢复连接属性和归还池。

    阅读时我画五列表:当前调用方法、逻辑事务状态、线程资源映射、物理连接动作、异常或回调。然后用一个外层 REQUIRED(需要事务)、内层 REQUIRES_NEW(总是新事务)和一个自调用旁路走断点,观察连接 C1 被挂起、C2 新建、C1 恢复,以及旁路没有进入拦截器。这样源码知识直接服务于线上排查;具体类名会随版本演进,但代理、策略、模板方法、线程绑定和资源终局这条设计主线稳定。

  • 追问 1:为什么先看抽象管理器而不是具体数据库实现?

  • 直接回答:传播、仅回滚、同步和清理骨架在抽象模板中,具体实现只填资源开始、提交与回滚细节。

  • 追问 2:源码断点最值得观察什么变量?

  • 直接回答:事务是否新建、是否有保存点、仅回滚标记、挂起资源、当前线程资源映射和实际连接标识。

  • 追问 3:版本升级时哪些结论需要重新核对?

  • 直接回答:默认代理方式、方法可见性支持、回滚规则扩展、管理器实现与配置键需要按项目实际小版本验证。

  • 关联知识:源码关键方法

  1. 问题(综合题):请讲一次支付连接池耗尽与未知结果事故的完整处理过程。
  • 口述答案:事故背景是支付回调高峰错误率从 0.2% 升到 18%,连接池 100 条全部活跃,等待线程 86,同时出现渠道已成功但本地仍处理中的未知单。第一步止血不是扩池,而是限制非核心支付入口并暂停高并发审计和补偿任务,为回调、查单和人工处理保留容量;所有自动重试必须复用原商户支付号,禁止新号重扣。保存线程栈、连接池指标、数据库会话、慢事务和业务号清单。

    证据显示每个回调先在外层事务更新支付意图,持有一条连接,再在事务内等待渠道查询,随后调用 REQUIRES_NEW(总是新事务)的审计方法申请第二条连接。高峰时外层占满全部连接,内层形成等待闭环;远程响应延迟又把连接占用拉到秒级。对 37 笔未知单按原号查单,确认 31 笔成功、6 笔失败,分别用幂等本地事务推进,未发生盲目重扣。

    修复把渠道调用移出长本地事务,先建立稳定支付意图,渠道结果用短事务更新支付单、账务和 Outbox(发件箱);审计由事件异步处理,不再每次新开独立事务。入口并发按数据库安全预算限制,连接池为查询与恢复保留余量。回归在渠道成功后丢响应、数据库提交后进程崩溃、发布成功后标记失败和消费者重复四个窗口注入故障。最终连接等待高分位恢复、未知单在服务目标内收敛、渠道与本地对账差额归零,并补上最老未知单和连接等待告警。复盘还把最坏嵌套连接模型加入压测门禁,避免正常流量均值再次掩盖峰值闭环。

  • 追问 1:为什么事故时不能直接把连接池扩到 200

  • 直接回答:数据库安全会话预算不足,扩池可能把应用等待转成数据库过载,且不消除嵌套连接和远程长事务根因。

  • 追问 2:未知单为什么不能先返回失败?

  • 直接回答:渠道可能已成功,返回失败会诱发换号重试和双扣;应返回处理中并主动查单。

  • 追问 3:如何证明事故真正闭环?

  • 直接回答:不仅错误率下降,还要未知单清零、资金对账归零、连接指标恢复、故障演练通过并有持续告警。

  • 关联知识:事务事故数据演绎

  1. 问题(综合题):WMS(仓储管理系统)库存预占怎样设计本地事务边界?
  • 口述答案:我先定义单库必须原子成立的不变量:可售数量不能小于零,同一订单行只能生成一次预占,余额变化必须有对应预占流水,成功预占必须有待传播事件。一个短本地事务内执行带余量条件的原子更新,例如“可售量大于等于申请量时减去申请量”,检查受影响行数;随后以订单行号和动作建立唯一预占流水,并写 Outbox(发件箱)事件。任一步失败共同回滚。这样即使进程在提交后、响应前崩溃,也能按订单行号查询并重放首次结果。

    我不会用“先查库存再更新”的普通读写,因为 50 个并发都可能看到旧余额;也不会只依赖 Redis(远程字典服务)锁,租约和故障切换后仍需数据库条件与唯一约束兜底。库存释放使用原预占号和前置状态条件,重复释放返回首次结果;已扣减、已释放和已过期是状态机,不允许迟到消息覆盖终态。需要批量预占时按仓库和货主固定排序,缩短锁范围并减少死锁,事务中不调用物流或支付。

    跨服务部分由事件推进。订单服务消费预占结果时以事件号和订单版本幂等;发布失败由扫描恢复,消费失败重试并告警最老年龄。对账用库存余额、冻结、扣减和释放流水验证数量守恒。线上排查先查业务唯一键和受影响行数,再查锁等待、事务连接与事件。故障测试覆盖并发 60 个请求争抢 100 件库存、提交后断进程、重复释放和乱序确认,证明最多合法数量成功且所有余额变化可追溯。

  • 追问 1:库存预占需要 SERIALIZABLE(可串行化隔离)吗?

  • 直接回答:通常先用原子条件更新、唯一约束和合适行锁表达不变量,更高隔离可能增加等待,需按实际冲突验证。

  • 追问 2:预占成功后事件没发出怎么办?

  • 直接回答:Outbox(发件箱)与预占同事务,扫描器可恢复发送,消费者按事件号幂等。

  • 追问 3:释放任务能按时间直接加回库存吗?

  • 直接回答:不能,必须按原预占号、当前状态和版本条件迁移,避免已确认或重复释放导致超卖。

  • 关联知识:本地事务与跨系统边界

  1. 问题(综合题):批量导入如何在部分成功、事务时长和可恢复性之间取舍?
  • 口述答案:先明确业务语义。如果整份文件必须共同成败,可以使用一个事务,但大文件会长时间占连接、锁和事务日志,任一错误都导致巨大回滚,通常不适合生产。更常见的是按固定行数或业务分区拆成小批,每批一个独立物理事务,记录文件号、批次号、偏移量、成功数、失败数和检查点。进程崩溃后从最后已提交检查点继续,同一业务键依靠唯一约束和参数摘要幂等。

    单个小批内部若允许跳过少量坏行,可以评估 NESTED(嵌套事务)保存点:处理一行前建保存点,确定性数据错误回滚到保存点并记录错误,其他行继续;数据库故障、连接异常和系统性错误则回滚整个小批。必须验证具体事务管理器、驱动和批处理方式支持保存点,因为驱动可能在批量刷新时才暴露错误。每行使用 REQUIRES_NEW(总是新事务)虽然隔离终局,却会产生大量事务日志和连接借还,通常成本更高。

    文件解析、远程校验和对象存储写入放在数据库事务外;事务内只保留校验后的短写入。错误清单可在批次提交后独立写入或作为事件持久化,不能因为写错误报告失败而丢失已提交检查点。监控每批耗时、锁等待、回滚行数、检查点年龄和内存。测试覆盖第 5001 行错误、数据库在第 8 批提交后断开、任务重复领取和同一文件重新上传,验证成功项不重复、失败项可定位、恢复不会从头制造副作用。发布前还要用最大文件和高错误率压测,确认错误记录本身不会拖垮主事务与恢复队列。

  • 追问 1:保存点越细越好吗?

  • 直接回答:不是,过多保存点增加数据库与框架开销,应按错误容忍度和批大小权衡。

  • 追问 2:为什么文件解析应在事务外?

  • 直接回答:解析耗时且没有数据库原子价值,放入事务只会延长连接和锁占用。

  • 追问 3:任务重试怎样识别同一文件?

  • 直接回答:使用文件摘要、业务批次号和行级业务键,参数不一致时拒绝复用同一幂等键。

  • 关联知识:保存点与独立事务

  1. 问题(综合题):怎样测试事务,才能证明的是数据库终局而不是注解存在?
  • 口述答案:事务测试的对象是最终状态和失败窗口,不是反射读取注解。首先通过真实容器取得代理对象,使用与生产相同的事务管理器、数据源代理和持久层集成;不要直接 new 服务。为一个业务用例准备可识别的订单、流水和事件数据,在第一个写入后、第二个写入前注入运行时异常,断言三张表都没有提交;再注入受检异常,验证自定义回滚规则;让内层 REQUIRED(需要事务)失败并被外层捕获,验证仅回滚标记和 UnexpectedRollbackException(意外回滚异常)。

    其次建立调用路径矩阵。外部代理调用应生效,同类 this 调用应暴露内层属性未应用,新线程应取得不同连接,private(私有访问控制)与 final(最终关键字)边界按项目代理方式验证。多数据源测试在其中一库断网,确认本地管理器不会被误认为跨库原子;REQUIRES_NEW(总是新事务)测试内层提交后外层失败的孤立结果;NESTED(嵌套事务)测试回滚到保存点后外层提交。记录线程、连接和事务状态帮助解释结果。

    最后覆盖跨系统窗口:数据库提交后强制退出,验证 Outbox(发件箱)可扫描;消息发送后不更新状态,验证重复发布被消费幂等;支付渠道返回成功前断连接,验证本地保持未知并按原号查单。测试数据用唯一业务号清理,不能让测试框架自身外层回滚掩盖被测提交。验收标准是每个失败位置都有确定数据库状态、可恢复路径和告警,而不是“测试方法通过”。

  • 追问 1:为什么测试方法自带事务可能误导?

  • 直接回答:测试框架可能在末尾统一回滚,掩盖服务方法是否真的创建、提交或独立提交了事务,应显式验证数据库可见性。

  • 追问 2:只使用模拟数据库够吗?

  • 直接回答:不够,隔离、保存点、驱动属性和锁行为依赖真实数据库,关键边界需集成环境验证。

  • 追问 3:如何测试连接池饥饿?

  • 直接回答:使用受控小池和并发屏障,让所有外层事务先占连接后同时进入独立事务,观察等待与超时闭环。

  • 关联知识:失效矩阵与回归

  1. 问题(综合题):从设计思想看,Spring(Java 应用框架)事务抽象解决了什么,又引入了什么认知成本?
  • 口述答案:它首先用代理把事务这一横切关注点从业务代码分离,业务方法声明传播、隔离和回滚策略,不必反复手写开始、提交、回滚与清理。TransactionInterceptor(事务拦截器)固定调用骨架,PlatformTransactionManager(平台事务管理器)作为策略封装不同资源,抽象管理器用模板方法统一传播、挂起、同步和终局,具体实现只处理连接或持久化上下文。TransactionSynchronizationManager(事务同步管理器)通过线程局部上下文,让同线程多个基础设施组件透明共享物理资源。

    代价是关键机制被注解隐藏。开发者容易把源码中的方法范围误认为真实事务范围,把逻辑事务误认为物理事务,把线程内上下文误认为跨线程上下文,把一个资源管理器误认为整个分布式系统。代理还带来自调用、可见性、对象创建方式和代理类型边界;线程绑定带来异步隔离;策略抽象带来多管理器选择问题。注解越简洁,架构与测试越需要把调用路径、资源和失败终局重新显式化。

    我的设计原则是“声明式管理机制,显式设计边界”。应用服务方法对应一个清晰业务用例,同库不变量放在短本地事务;远程调用、文件和长计算移出;独立事务和保存点只在语义确实需要时使用;跨系统通过持久化事件、幂等、状态机和对账。代码评审不只看注解,还画调用方向和资源表;观测记录事务时长、连接等待和未知业务量;集成测试在失败窗口验证最终数据。这样既享受抽象带来的复用,又避免把抽象误当成无限保证。

  • 追问 1:为什么说注解不是事务边界的全部?

  • 直接回答:真实边界还取决于代理调用、线程、事务管理器、物理资源和异常终局,注解只描述策略。

  • 追问 2:事务模板是否比注解更好?

  • 直接回答:不是绝对;模板适合需要显式局部边界的流程,注解适合稳定应用服务入口,关键是边界清晰可测。

  • 追问 3:如何降低团队认知成本?

  • 直接回答:统一分层和事务入口、禁止事务内远程调用、建立传播选型表、故障测试模板与运行指标。

  • 关联知识:源码与设计思想

  1. 问题(综合题):一个复杂业务流程中,怎样决定事务应该放在哪一层?
  • 口述答案:事务边界应围绕业务用例和数据所有权,而不是机械放在控制器、Mapper(映射器)或所有方法上。控制器负责协议转换,不应持有跨远程调用的长事务;Mapper(映射器)只执行单条持久化,无法表达订单、流水和事件共同成败。应用服务最适合编排一个本地业务用例:验证输入和权限后,在短事务中调用领域对象与持久层,提交同库不变量。领域内部关键写方法可以使用 MANDATORY(强制事务传播)要求由应用服务提供事务,避免被单独误调用。

    我先列出必须原子成立的表和约束。如果都属于同一数据库和事务管理器,放在一个 REQUIRED(需要事务)边界;若步骤失败可局部容忍,评估保存点或拆小批;若结果必须独立留痕,才评估 REQUIRES_NEW(总是新事务)并明确孤立结果。跨到订单、库存、支付或物流其他所有者后,不扩大本地事务,而是在本地提交状态与 Outbox(发件箱),由事件、幂等和补偿推进。远程查询也尽量在事务前完成,但涉及并发决策时仍要在事务内用版本或条件更新重新校验。

    事务边界还受容量约束。方法中有网络调用、大循环、文件解析或用户交互时,应拆成“准备、短提交、提交后处理”三段。评审时画出每段连接、锁和外部副作用;测试在每个边界后崩溃,检查是否有可查询检查点。以支付为例,回调协议处理在外层,短事务只更新支付、账务和事件,响应与发布在提交后。这样的层次既表达业务原子性,又让连接成本和失败恢复可控。

  • 追问 1:事务放在控制器有什么问题?

  • 直接回答:容易把参数解析、远程调用和响应构造都包进事务,扩大连接与锁时间,也让复用入口语义不清。

  • 追问 2:领域服务能自己开事务吗?

  • 直接回答:可以但要明确是否完整业务用例;常见做法由应用服务编排,领域内部用强制参与契约保持边界统一。

  • 追问 3:查询后更新如何防止事务外数据变化?

  • 直接回答:在最终短事务中用版本号、前置状态或条件更新重新校验,而不是依赖早先查询结果。

  • 关联知识:事务传播契约

  1. 问题(综合题):支付、订单、库存出现未知状态时,怎样设计可收敛状态机?
  • 口述答案:未知不是异常文本,而是明确业务状态,表示调用方没有足够证据判断成功或失败。支付请求超时后保留原商户单号和“未知”,禁止换号重扣;主动查渠道,明确成功才推进支付与订单,明确失败才允许安全重试,持续处理中按退避再次查询。订单和库存也记录各自权威状态,流程服务只记录推进进度,不能因为本地超时直接改写其他领域终态。

    状态机为每个迁移定义前置状态、触发来源、稳定业务号、版本和幂等结果。例如支付“处理中或未知 -> 成功”要求渠道权威成功且金额币种一致;“成功 -> 失败”禁止,退款用“成功 -> 退款中 -> 已退款”的新动作。库存“已预占 -> 已确认或已释放”只能走一条,迟到释放不能覆盖已确认。消息重复按事件号返回首次结果,乱序按状态版本拒绝或暂存。每次迁移与业务流水、Outbox(发件箱)同事务提交。

    收敛还需要时间和人工出口。为未知状态设置首次时间、下次查询、重试次数和最晚服务目标;监控未知总金额、最老年龄、查单成功率和人工量。超过自动预算进入差错队列,保留渠道请求响应、订单、库存和账务证据,由受控工具执行补记、退款、释放或人工履约。每日对账验证多方清单差额归零。故障演练让渠道成功后丢响应、事件乱序和查单接口长期不可用,确认系统不会猜测终态,也不会永久静默停留。状态机版本升级还要保证旧流程继续识别原状态,避免发布后把历史未知单变成无法迁移的孤岛。

  • 追问 1:未知状态会不会让用户体验变差?

  • 直接回答:比错误地宣称失败或成功更安全,可向用户展示处理中并提供查询,后台加速查单与告警。

  • 追问 2:自动查单可以无限重试吗?

  • 直接回答:不能,应退避、限流并设置自动预算,长期未知转人工,避免故障时压垮渠道。

  • 追问 3:如何防止人工与自动任务同时修复?

  • 直接回答:使用差错单状态、版本条件和操作租约,人工接管后自动流程停止,并保留审批与结果流水。

  • 关联知识:跨系统状态边界

  1. 问题(综合题):事务体系需要哪些可观测性指标和告警,才能支持生产排障?
  • 口述答案:我把指标分成资源、事务、业务一致性和恢复四层。资源层监控连接池活跃、空闲、等待线程、获取连接耗时、数据库会话和锁等待;事务层监控事务数量、时长高分位、提交、回滚、超时、UnexpectedRollbackException(意外回滚异常)和 REQUIRES_NEW(总是新事务)调用量。两层关联后才能判断是数据库慢、事务内远程调用,还是嵌套连接导致饥饿。线程栈和采样调用链用于定位持连接时正在等待什么。

    业务一致性层不能只看技术错误率。支付要看未知单数量与金额、最老未知年龄、回调重复和金额冲突;库存看余额守恒、冻结最老年龄和重复释放冲突;Outbox(发件箱)看待发布数量、最老事件、发送重试与重复率;消费者看 Inbox(收件箱)冲突、消费失败、死信和状态版本拒绝。恢复层监控查单成功率、自动补偿成功率、人工差错量、对账差额与闭环时长。

    告警应基于影响和趋势,而不是每次回滚都报警。连接等待和未知金额同时上升应高优先级;单个可重试失败可聚合。日志按业务号、事件号、事务名称、线程、管理器、数据源和连接标识关联,但高流量下动态采样,避免全量调试日志反噬。看板必须能从业务差异下钻到技术资源,再回到修复结果。每次事故复盘新增的不是一条模糊日志,而是能提前发现同类失败窗口的指标、阈值和演练。

  • 追问 1:为什么事务回滚率高不一定是事故?

  • 直接回答:并发冲突或合法业务拒绝也会回滚,要结合错误类型、耗时、重试收益和业务差额判断。

  • 追问 2:最重要的业务告警是什么?

  • 直接回答:资金与库存差额、最老未知状态和最老未发布事件,它们直接反映是否无法在目标时间内收敛。

  • 追问 3:连接池告警只看活跃百分比够吗?

  • 直接回答:不够,还要看等待线程、获取耗时、事务时长和数据库承载,短暂满载与持续饥饿含义不同。

  • 关联知识:事故排查证据

  1. 问题(综合题):请用五分钟总结你对 Spring(Java 应用框架)事务的精通级理解。
  • 口述答案:我把 Spring(Java 应用框架)事务理解为代理驱动的本地资源协调。外部调用经过代理,TransactionInterceptor(事务拦截器)解析属性并选择 PlatformTransactionManager(平台事务管理器);管理器按传播行为创建、加入、挂起或建立保存点,通过 TransactionSynchronizationManager(事务同步管理器)把连接和同步器绑定到当前线程。目标方法结束后,根据异常规则与 rollback-only(仅回滚标记)提交或回滚,最后清理线程资源和连接属性。逻辑方法边界与物理连接边界必须分开,内层 REQUIRED(需要事务)可能只参与外层,REQUIRES_NEW(总是新事务)才是独立物理事务,NESTED(嵌套事务)通常只是保存点。

    我也明确它的失效边界:同类 this 调用不重新经过代理,private(私有访问控制)和 final(最终关键字)受代理方式限制,新线程没有原事务上下文,异常被吞或受检异常规则错误会导致提交,错误事务管理器和手工连接会管理错资源。独立事务会额外占连接,事务内远程调用会放大锁和连接时长,并产生远端成功、本地回滚的未知结果。隔离、只读和超时只是对底层资源的声明,必须结合数据库与驱动验证。

    在项目中,我让同库不变量在短本地事务中共同提交,例如库存余额、预占流水和事件,支付单、账务与 Outbox(发件箱)。跨到 MQ(消息队列)、支付和物流后,不宣称本地原子,而用稳定业务号、状态机、Inbox(收件箱)、重试、查单、补偿和对账收敛。排障按业务事实、代理、属性、管理器、线程连接和数据库终局取证;容量上监控事务时长与连接等待;验收在提交后崩溃、重复消息、渠道超时和多库部分提交处故障注入。精通不是背七个枚举,而是能说明每个失败窗口的事实、恢复责任和业务结果。

  • 追问 1:事务设计最重要的一条原则是什么?

  • 直接回答:把必须原子成立的不变量放进最小、可证明的本地资源边界,并显式设计边界之外的恢复。

  • 追问 2:最容易被忽略的性能风险是什么?

  • 直接回答:事务内网络等待和嵌套独立事务会同时拉长连接占用并增加每线程连接数,容易形成资源闭环。

  • 追问 3:如何证明最终一致真的完成?

  • 直接回答:有明确收敛时限、状态机与幂等证据,自动和人工出口可执行,最终多方对账差额归零。

  • 关联知识:本章完整机制