附录 A:批处理和事务

A.1.简单批处理,无需重试

考虑以下不重试的嵌套批处理的简单示例。它显示了批处理的一种常见情况:Importing 源被处理到用尽,然后我们在“大块”处理结束时定期提交。

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

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

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

A.2.简单的 Stateless 重试

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

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

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

A.3.典型的重试模式

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

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)的行为如下:

  • 抛出异常,在块级别回滚事务TX(2),并允许将该 Item 重新呈现到 Importing 队列中。

  • 重新出现该 Item 时,可能会根据适当的重试策略对其进行重试,并再次执行PROCESS(5)。第二次和随后的尝试可能会再次失败并重新引发异常。

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

请注意,以上计划中用于RETRY(4)的符号明确表明 Importing 步骤(4.1)是重试的一部分。还清楚地表明,有两个备用处理路径:用PROCESS(5)表示的正常情况,和用RECOVER(6)单独表示的恢复路径。两条替代路径完全不同。正常情况下只服用一次。

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

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

但是请注意,如果TX(2)失败,并且我们*根据外部完成策略再次尝试,则不能保证在内部REPEAT(3)中下一步处理的 Item 只是失败了可能是,但取决于 Importing(4.1)的实现。因此,新 Item 或旧 Item 的输出(5.1)可能再次失败。批处理的 Client 不应假定每次RETRY(4)尝试都将处理与最后一个失败的 Item 相同的 Item。例如,如果REPEAT(1)的终止策略在 10 次尝试后失败,则在连续 10 次尝试后失败,但不一定在同一 Item 上。这与整体重试策略一致。内部RETRY(4)知道每个 Item 的历史记录,并可以决定是否要对其进行其他尝试。

A.4.异步块处理

通过将外部批处理配置为使用AsyncTaskExecutor,可以并行执行typical example中的内部批处理或块。外部批处理在完成之前 await 所有块完成。以下示例显示了异步块处理:

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;
|        }
|
|      }
|    }
|
|  }

A.5.异步 Item 处理

原则上,也可以同时处理typical example中块中的各个 Item。在这种情况下,事务边界必须移至单个 Item 的级别,以便每个事务都位于单个线程上,如以下示例所示:

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)的成本比事务 Management(3)的成本高得多时,它才有用。

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

重试和事务 Management 之间的耦合比我们理想的紧密。特别是,Stateless 重试不能用于通过不支持 NESTED 传播的事务 Management 器重试数据库操作。

以下示例使用重试,而无需重复:

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)的事务 Contract。如果TX(3)回滚,则TX(1)不一定(但实际上可能会这样做,因为重试会引发回滚异常)。

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

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

A.7.特殊情况:使用正交资源进行事务

对于没有嵌套数据库事务的简单情况,默认传播总是可以的。考虑以下示例,其中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)无法提交(例如,由于消息系统不可用)时,可能发生的最糟糕情况是消息重复。

A.8.Stateless 重试无法恢复

在上述典型示例中,Stateless 重试与有状态重试之间的区别很重要。实际上,最终是强制执行区分的事务性约束,并且该约束也使显而易见为什么存在区分。

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

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;
|        }
|
|      }
|    }
|
|  }

前面的示例显示了 Stateless 的RETRY(3)和RECOVER(5)的路径,该路径在最终尝试失败后立即启动。 stateless标签表示将重复执行该块,而不会将任何异常重新抛出到某个限制。仅当事务TX(4)具有传播嵌套时才有效。

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

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