附录 A:批处理和事务

无需重试的简单批处理

考虑以下没有重试的嵌套批处理的简单示例。它显示了批处理的常见场景:处理输入源直到耗尽,并且我们在处理“块”结束时定期提交。

1 | 重复(直到=用尽){
|
2 | 发送{
3 | 重复(大小=5){
3.1 | 输入;
3.2 | 输出;
| }
| }
|
| }

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

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

简单的无状态重试

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

0 | 发送{
1 | 输入;
1.1 | 输出;
2 | 重试 {
2.1 | 远程访问;
| }
| }

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

典型的重复重试模式

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

1 | 重复(直到=用尽,异常=不重要){
|
2 | 发送{
3 | 重复(大小=5){
|
4 | 重试(有状态,异常=死锁失败者){
4.1 | 输入;
5 | } 过程 {
5.1 | 输出;
6 | } 跳过和恢复 {
| 通知;
| }
|
| }
| }
|
| }

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

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

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

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

请注意,上述计划中用于RETRY(4) 的符号明确表明输入步骤 (4.1) 是重试的一部分。它还清楚地表明,有两种可供选择的处理路径:正常情况,如 (5) 所示,以及恢复路径,如(6)PROCESS在单独的块中所示。RECOVER两条备用路径完全不同。在正常情况下只拍过一张。

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

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

但是请注意,如果TX(2) 失败并且我们再次尝试,则凭借外部完成策略,内部REPEAT(3) 中下一个处理的项目不能保证是刚刚失败的项目。可能是,但这取决于输入(4.1)的实现。因此,输出 (5.1) 可能在新项目或旧项目上再次失败。该批次的客户不应假设每RETRY(4) 次尝试都将处理与最后一次失败的相同项目。例如,如果REPEAT(1) 的终止策略是在 10 次尝试后失败,则它会在连续尝试 10 次后失败,但不一定在同一项目上。这与整体重试策略一致。内在RETRY(4) 了解每个项目的历史,并可以决定是否对其进行另一次尝试。

异步块处理

典型示例中的内部批次或块可以通过将外部批次配置为使用AsyncTaskExecutor. 外部批次在完成之前等待所有块完成。以下示例显示了异步块处理:

1 | 重复(直到=用尽,并发,异常=不重要){
|
2 | 发送{
3 | 重复(大小=5){
|
4 | 重试(有状态,异常=死锁失败者){
4.1 | 输入;
5 | } 过程 {
| 输出;
6 | } 恢复 {
| 恢复;
| }
|
| }
| }
|
| }

异步项目处理

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

1 | 重复(直到=用尽,异常=不重要){
|
2 | 重复(大小=5,并发){
|
3 | 发送{
4 | 重试(有状态,异常=死锁失败者){
4.1 | 输入;
5 | } 过程 {
| 输出;
6 | } 恢复 {
| 恢复;
| }
| }
|
| }
|
| }

该计划牺牲了简单计划所具有的优化优势,即将所有事务资源分块在一起。它仅在处理成本 (5) 远高于事务管理成本 (3) 时才有用。

批处理和事务传播之间的交互

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

以下示例使用重试而不重复:

1 | 发送{
|
1.1 | 输入;
2.2 | 数据库访问;
2 | 重试 {
3 | 发送{
3.1 | 数据库访问;
| }
| }
|
| }

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

不幸的是,相同的效果会从重试块渗透到周围的重复批次(如果有的话),如以下示例所示:

1 | 发送{
|
2 | 重复(大小=5){
2.1 | 输入;
2.2 | 数据库访问;
3 | 重试 {
4 | 发送{
4.1 | 数据库访问;
| }
| }
| }
|
| }

现在,如果 TX (3) 回滚,它会污染 TX (1) 处的整个批次,并在最后强制它回滚。

非默认传播呢?

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

  • PROPAGATION_NESTEDat TX(3) 在重试情况下(以及对于带有跳过的批处理)按我们的要求工作:TX(3) 可以提交但随后被外部事务回滚,TX(1). 如果TX(3) 回滚,则TX(1) 在实践中回滚。此选项仅在某些平台上可用,不包括 Hibernate 或 JTA,但它是唯一始终有效的平台。

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

特例:正交资源的交易

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

0 | 会议 {
1 | 输入;
2 | 重试 {
3 | 发送{
3.1 | 数据库访问;
| }
| }
| }

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

无状态重试无法恢复

上面典型示例中无状态重试和有状态重试之间的区别很重要。实际上,最终是一个事务约束强制区分,而这种约束也使区分存在的原因变得显而易见。

我们首先观察到,除非我们将项目处理包装在事务中,否则无法跳过失败的项目并成功提交块的其余部分。因此,我们将典型的批处理执行计划简化如下:

0 | 重复(直到=用尽){
|
1 | 发送{
2 | 重复(大小=5){
|
3 | 重试(无状态){
4 | 发送{
4.1 | 输入;
4.2 | 数据库访问;
| }
5 | } 恢复 {
5.1 | 跳过;
| }
|
| }
| }
|
| }

前面的示例显示了一个无状态RETRY(3) 和RECOVER(5) 路径,该路径在最终尝试失败后启动。该stateless标签意味着该块被重复而不重新抛出任何异常,直到某个限制。这仅在事务 TX(4) 具有传播嵌套的情况下才有效。

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

对 NESTED 传播的支持非常罕见,以至于我们选择不支持在当前版本的 Spring Batch 中使用无状态重试进行恢复。使用上面的典型模式总是可以达到相同的效果(以重复更多处理为代价)。


1. see XML Configuration