跳到主要内容

批处理与事务

QWen Plus 中英对照 Batch Processing and Transactions

简单的不带重试的批处理

考虑以下没有重试机制的嵌套批处理的简单示例。它展示了一个常见的批处理处理场景:输入源会被持续处理,直到耗尽,并且会在每个“块”处理结束时定期提交。

1   |  REPEAT(until=exhausted) {
|
2 | TX {
3 | REPEAT(size=5) {
3.1 | input;
3.2 | output;
| }
| }
|
| }

输入操作(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)的行为如下:

  1. 抛出异常,回滚事务 TX(2),在块级别,并允许该项重新提交到输入队列。

  2. 当该项重新出现时,根据现有的重试策略,可能会对其进行重试,并再次执行 PROCESS(5)。第二次及后续尝试可能会再次失败并重新抛出异常。

  3. 最终,该项最后一次重新出现。重试策略不允许再进行尝试,因此 PROCESS(5)不会被执行。在这种情况下,我们遵循 RECOVER(6)路径,有效地“跳过”正在接收和处理的该项。

请注意,计划中用于 RETRY (4) 的表示法明确显示输入步骤 (4.1) 是重试的一部分。它还清楚地表明有两种 alternate 处理路径:正常情况下的路径,由 PROCESS (5) 表示,以及恢复路径,由单独块中的 RECOVER (6) 表示。这两种 alternate 路径是完全独立的。在正常情况下,只会执行其中一条路径。

在特殊情况下(例如特殊的 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 传播的事务管理器的数据库操作。

以下示例使用了 retry 但没有使用 repeat:

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

同样,由于同样的原因,内部事务 TX (3) 即使 RETRY (2) 最终成功,也可能导致外部事务 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,但它是一致工作的唯一选项。

因此,如果重试块包含任何数据库访问,NESTED 模式是最好的选择。

特殊情况:带有正交资源的事务

默认传播在没有嵌套数据库事务的简单情况下总是可行的。考虑以下示例,其中 SESSIONTX 不是全局 XA 资源,因此它们的资源是正交的:

0   |  SESSION {
1 | input;
2 | RETRY {
3 | TX {
3.1 | database access;
| }
| }
| }

这里存在一个事务性消息,SESSION (0),但它不参与与其他使用 PlatformTransactionManager 的事务,因此当 TX (3) 启动时,它不会传播。在 RETRY (2) 块之外没有数据库访问。如果 TX (3) 失败,然后在重试时最终成功,SESSION (0) 可以提交(独立于 TX 块)。这类似于普通的“尽力而为的单阶段提交”场景。最坏的情况是在 RETRY (2) 成功但 SESSION (0) 无法提交时(例如,因为消息系统不可用)出现重复消息。

无状态重试无法恢复

在前面显示的典型示例中,无状态重试和有状态重试之间的区别非常重要。实际上,最终是事务性约束迫使做出了这种区分,而这一约束也清楚地说明了为什么存在这种区别。

我们从以下观察开始:除非我们将项目处理包装在事务中,否则无法跳过失败的项目并成功提交剩余的批处理部分。因此,我们将典型的批处理执行计划简化为如下所示:

0   |  REPEAT(until=exhausted) {
|
1 | TX {
2 | REPEAT(size=5) {
|
3 | RETRY(stateless) {
4 | TX {
4.1 | input;
4.2 | database access;
| }
5 | } RECOVER {
5.1 | skip;
| }
|
| }
| }
|
| }

前面的例子展示了一个无状态的 RETRY(3),它在最终尝试失败后会触发 RECOVER(5)路径。stateless 标签表示该块会在不重新抛出任何异常的情况下重复执行,直到某个限制为止。这只有在事务 TX(4)具有嵌套传播时才有效。

如果内部的 TX(4)具有默认的传播属性并回滚,则会污染外部的 TX(1)。事务管理器认为内部事务已经破坏了事务资源,因此它不能再被使用。

对嵌套传播的支持非常少,因此我们选择不在当前版本的 Spring Batch 中支持使用无状态重试的恢复功能。通过使用前面展示的典型模式(以重复更多处理为代价),始终可以达到相同的效果。