|
对于最新稳定版本,请使用 Spring Batch 文档 6.0.3! |
批处理与事务
无重试的简单批处理
考虑以下一个简单的无重试嵌套批处理示例。它展示了批处理的常见场景:输入源会被持续处理直至耗尽,并且在每个处理“块”结束时定期提交。
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) 会回滚。
典型的重复 - 重试模式
最典型的批处理模式是在 chunk 的内部块中添加重试机制,如下例所示:
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) 的行为如下:
-
抛出异常,回滚事务,在块(chunk)级别标记为
TX(2),并允许该项目重新进入输入队列。 -
当项目重新出现时,根据当前配置的重试策略,它可能会被重试,并再次执行
PROCESS(5)。第二次及后续的尝试可能会再次失败并重新抛出异常。 -
最终,该条目最后一次重新出现。重试策略不允许再次尝试,因此
PROCESS(5) 永远不会执行。在这种情况下,我们遵循RECOVER(6) 路径,有效地“跳过”已接收并正在处理的条目。
请注意,计划中用于 RETRY (4) 的表示法明确显示输入步骤 (4.1) 属于重试部分。它还清楚地表明存在两条备选处理路径:由 PROCESS (5) 表示的正常情况,以及在独立块中由 RECOVER (6) 表示的恢复路径。这两条备选路径完全独立,在正常情况下只会执行其中一条。
在特殊情况下(例如特殊的 TranscationValidException 类型),重试策略可能能够确定,在 PROCESS (5) 刚刚失败后,可以在最后一次尝试时直接采取 RECOVER (6) 路径,而无需等待项目重新提交。这不是默认行为,因为它需要详细了解 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 | 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;
| }
| }
|
| }
同样,出于相同的原因,内部事务 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;
| }
| }
| }
|
| }
现在,如果事务 (3) 回滚,它可能会污染整个批次在事务 (1) 中的数据,并强制其在最后回滚。
非默认传播机制是怎样的?
-
在前面的示例中,
PROPAGATION_REQUIRES_NEW位于TX(3) 处,可防止外部TX(1) 在两个事务最终都成功时受到污染。但如果TX(3) 提交而TX(1) 回滚,则TX(3) 仍保持提交状态,从而违反了TX(1) 的事务契约。如果TX(3) 回滚,TX(1) 不一定回滚(但在实践中通常会回滚,因为重试会抛出回滚异常)。 -
PROPAGATION_NESTED在TX(3)中按我们的要求在重试场景下工作(对于带有跳过的批处理也是如此):TX(3)可以提交,但随后可能被外部事务回滚,TX(1)。如果TX(3)回滚,实际上TX(1)也会回滚。此选项仅在某些平台上可用,不包括 Hibernate 或 JTA,但它是唯一能始终正常工作的选项。
因此,如果重试块包含任何数据库访问,NESTED 模式是最佳选择。
特殊情况:具有正交资源的事务
对于没有嵌套数据库事务的简单情况,默认传播行为总是可行的。考虑以下示例,其中 SESSION 和 TX 不是全局 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)的传播行为设置为嵌套(nested)时才有效。
如果内部 TX (4) 具有默认传播属性并发生回滚,它将污染外部 TX (1)。事务管理器会认为内部事务已损坏了事务资源,因此该资源无法再次使用。
对嵌套传播的支持非常罕见,因此我们决定在当前版本的 Spring Batch 中不支持通过无状态重试进行恢复。始终可以通过使用前面展示的典型模式来实现相同的效果(代价是重复执行更多的处理)。