输入操作 (3.1) 可以是基于消息的接收(如从 JMS),也可以是基于文件的读取,但要恢复和继续处理并有机会完成整个作业,它必须是事务性的。这同样适用于 3.2 中的操作。它必须是事务性的或幂等的。

如果 REPEAT (3) 处的数据块因 3.2 处的数据库异常而失败,则 TX (2) 必须回滚整个数据块。

简单的无状态重试

对于非事务性操作,如调用 web 服务或其他远程资源,重试也很有用,如下例所示:

0   |  TX {
1   |    input;
1.1 |    output;
2   |    RETRY {
2.1 |      remote access;
|    }

这实际上是重试最有用的应用之一,因为远程调用比数据库更新更容易失败和重试。只要远程访问 (2.1) 最终成功,事务 TX (0) 就会提交。如果远程访问 (2.1) 最终失败,事务 TX (0) 就会回滚。

典型的重复重试模式

最典型的批处理模式是在分块的内部块中添加重试,如下例所示:

1   |  REPEAT(until=exhausted, exception=not critical) {
2   |    TX {
3   |      REPEAT(size=5) {
4   |        RETRY(stateful, exception=deadlock loser) {
4.1 |          input;
5   |        } PROCESS {
5.1 |          output;
6   |        } SKIP and RECOVER {
|          notify;
|        }
|      }
|    }

内部 RETRY (4) 块标记为 "有状态"。有关有状态重试的描述,请参阅 典型用例。这意味着,如果重试 PROCESS (5) 块失败,RETRY (4) 的行为如下:

请注意,计划中用于 RETRY (4) 的符号明确显示输入 step (4.1) 是重试的一部分。它还清楚地表明有两条交替处理路径:一条是正常情况,用 PROCESS (5) 表示;另一条是恢复路径,用 RECOVER (6) 表示。这两条备用路径完全不同。在正常情况下,只走一条。

在特殊情况下(如特殊的 TranscationValidException 类型),重试策略可能会确定在 PROCESS (5) 失败后的最后一次尝试中可以采用 RECOVER (6) 路径,而不是等待项目重新提交。这并不是默认行为,因为它需要详细了解 PROCESS (5) 块内部发生了什么,而这通常是不可能的。例如,如果输出在失败前包含写访问,则应重新抛出异常,以确保事务完整性。

外层 REPEAT (1) 中的完成策略对计划的成功至关重要。如果输出 (5.1) 失败,它可能会抛出异常(如上所述,它通常会抛出异常),在这种情况下,事务 TX (2) 会失败,异常可能会通过外层批处理 REPEAT (1) 向上传播。我们不希望整个批处理停止,因为如果我们再试一次,RETRY (4) 可能仍然会成功,所以我们在外部 REPEAT (1) 中添加了 exception=not critical

但是,请注意,如果 TX (2) 失败,我们再次尝试,根据外部完成策略,在内部 REPEAT (3) 中下一步处理的项目并不能保证就是刚刚失败的那个。有可能是,但这取决于输入 (4.1) 的实现。因此,输出 (5.1) 可能会在新项目或旧项目上再次失败。批处理的客户端不应假定每次 RETRY (4) 都会处理与上次失败相同的项目。例如,如果 REPEAT (1) 的终止策略是尝试 10 次后失败,那么它就会在连续尝试 10 次后失败,但不一定是在同一个项目上。这与整个重试策略是一致的。内部 RETRY (4) 知道每个项目的历史,可以决定是否再次尝试。

异步分块处理

通过将外部批处理配置为使用 AsyncTaskExecutor,可以并发执行 典型示例 中的内部批处理或块。外部批处理在完成前会等待所有分块完成。下面的示例展示了异步分块处理:

1   |  REPEAT(until=exhausted, concurrent, exception=not critical) {
2   |    TX {
3   |      REPEAT(size=5) {
4   |        RETRY(stateful, exception=deadlock loser) {
4.1 |          input;
5   |        } PROCESS {
|          output;
6   |        } RECOVER {
|          recover;
|        }
|      }
|    }
1   |  REPEAT(until=exhausted, exception=not critical) {
2   |    REPEAT(size=5, concurrent) {
3   |      TX {
4   |        RETRY(stateful, exception=deadlock loser) {
4.1 |          input;
5   |        } PROCESS {
|          output;
6   |        } RECOVER {
|          recover;
|        }
|      }
|    }

该计划牺牲了简单计划的优化优势,即把所有事务资源集中在一起。只有当处理成本(5)远高于事务管理成本(3)时,该计划才会有用。

批处理与事务传播之间的相互作用

批量重试和事务管理之间的耦合比我们理想的要紧密。特别是,无状态重试不能用于重试不支持 NESTED 传播的事务管理器的数据库操作。

下面的示例使用了不重复的重试:

1   |  TX {
1.1 |    input;
2.2 |    database access;
2   |    RETRY {
3   |      TX {
3.1 |        database access;
|      }
|    }

同样,出于同样的原因,即使 RETRY (2) 最终成功,内部事务 TX (3) 也会导致外部事务 TX (1) 失败。

不幸的是,正如下面的示例所示,同样的影响也会从重试块扩散到周围的重复批处理(如果有的话):

1   |  TX {
2   |    REPEAT(size=5) {
2.1 |      input;
2.2 |      database access;
3   |      RETRY {
4   |        TX {
4.1 |          database access;
|        }
|      }
|    }

现在,如果 TX (3) 回滚,就会污染 TX (1) 的整个批处理,并迫使它在最后回滚。

非默认传播怎么办?

在前面的示例中,如果两个事务最终都成功了,那么 TX (3) 的 PROPAGATION_REQUIRES_NEW 可以防止外部 TX (1) 被污染。但如果 TX (3) 提交,而 TX (1) 回滚,则 TX (3) 保持提交,因此我们违反了 TX (1) 的事务契约。如果 TX (3) 回滚,TX (1) 不一定回滚(但实际上可能回滚,因为重试会抛出回滚异常)。

TX (3) 中的 PROPAGATION_NESTED 在重试情况下(以及在有跳转的批次中)可以按照我们的要求运行: TX (3) 可以提交,但随后会被外部事务 TX (1) 回滚。如果 TX (3) 回滚,TX (1) 实际上也会回滚。该选项仅在某些平台上可用,不包括 Hibernate 或 JTA,但它是唯一能持续工作的选项。

上例显示的是一个无状态的 RETRY (3),在最后一次尝试失败后会启动 RECOVER (5) 路径。stateless 标签的意思是,在某个限制范围内,重复执行该代码块时不会再抛出任何异常。只有当事务 TX (4) 嵌套了传播时,这种方法才有效。

如果内部 TX (4) 具有默认传播属性并回滚,则会污染外部 TX (1)。事务管理器会认为内部事务已损坏事务资源,因此无法再次使用。

对嵌套传播的支持非常罕见,因此我们选择在当前版本的 Spring Batch 中不支持无状态重试恢复。使用前面所示的典型模式,总能达到相同的效果(代价是重复更多的处理)。