Batch Processing and Transactions

Simple Batching with No Retry

考虑以下一个简单的嵌套批处理示例,它没有重试。它展示了批处理的常见场景:一个输入源被处理直到耗尽,并且在处理的“块”的末尾周期性地提交。

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

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

如果 3.2 处的数据库异常导致 REPEAT (3) 处的块失败,那么 TX (2) 必须回滚整个块。

Simple Stateless Retry

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

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

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

Typical Repeat-Retry Pattern

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

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) 块标记为 “stateful”。有关有状态重试的说明,请参见 the typical use case。这意味着,如果重试 PROCESS (5) 块失败,RETRY (4) 的行为如下:

  1. 抛出异常,回滚事务, TX (2),在块级别,并将项目重新表示给输入队列。

  2. 当项目重新出现时,可能会重新尝试,具体取决于实施的重试策略,并执行 PROCESS (5) 。第二次和随后的尝试可能会再次失败,并重新抛出异常。

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

请注意,在计划中用于 RETRY (4) 的标记明确地表明输入步骤 (4.1) 是重试的一部分。它还明确地表明处理有两个备用路径:由 PROCESS (5) 表示的正常情况,以及由单独的 RECOVER (6) 块表示的恢复路径。这两个备用路径是完全不同的。在正常情况下,只选择一个。

在特殊情况下(例如特殊的 TranscationValidException 类型),重试策略也许能够确定 RECOVER (6) 路径可以在 PROCESS (5) 刚刚失败后的最后尝试中执行,而不是等待重新呈现该项。这不是默认行为,因为它需要详细了解 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) 了解每个项的历史记录,并且可以决定是否再次尝试。

Asynchronous Chunk Processing

typical example 中的内部批处理或块可以通过将外部批处理配置为使用 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;
|        }
|
|      }
|    }
|
|  }

Asynchronous Item Processing

原则上,typical example 中块中的各个项也可以并发处理。在这种情况下,事务边界必须移动到各个项的级别,以便每个事务都在单个线程上,如下例所示:

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) 时才有用。

Interactions Between Batching and Transaction Propagation

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

以下示例使用不带重复的重试:

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

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

不幸的是,如果存在,相同的效果会从重试块渗透到周围的重复批次,如下面的示例所示:

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) 处的整个批次并强制它在最后回滚。

非默认传播如何?

  • 在前面的示例中, PROPAGATION_REQUIRES_NEWTX (3) 处可以防止外部 TX (1) 在两个事务最终都成功的情况下受到污染。但是,如果 TX(3) 提交并且 TX (1) 回滚,则 TX (3) 保持提交,因此我们违反了 TX (1) 的事务合约。如果 TX (3) 回滚,则 TX (1) 不一定会回滚(但实际上可能发生回滚,因为重试会抛出回滚异常)。

  • PROPAGATION_NESTEDTX (3) 处在重试情况下(以及对于包含跳过的批处理)按要求工作: TX (3) 可以提交,但随后会被外部事务 TX (1) 回滚。如果 TX (3) 回滚,则 TX (1) 实际上会回滚。这一选项仅在某些平台上可用,不包括 Hibernate 或 JTA,但它是唯一一致工作的选项。

因此,如果重试块包含任何数据库访问,则 NESTED 模式最佳。

Special Case: Transactions with Orthogonal Resources

对于没有嵌套数据库事务的简单案例,默认传播始终可以接受。考虑以下示例,其中 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) 无法提交时(例如,因为消息系统不可用)发生重复消息。

Stateless Retry Cannot Recover

前面所示的典型示例中,无状态重试和有状态重试之间的区别非常重要。实际上最终是一个事务约束迫使该区别,并且此约束也使该区别的存在原因很明显。

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

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 中不支持无状态重试恢复。通过使用前面显示的典型模式,始终可以实现相同的效果(以重复更多处理为代价)。