对于最新稳定版本,请使用 Spring Batch 文档 6.0.3spring-doc.cadn.net.cn

批处理与事务

无重试的简单批处理

考虑以下一个简单的无重试嵌套批处理示例。它展示了批处理的常见场景:输入源会被持续处理直至耗尽,并且在每个处理“块”结束时定期提交。spring-doc.cadn.net.cn

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

输入操作 (3.1) 可以是基于消息的接收(例如来自 JMS)或基于文件的读取,但为了恢复并继续处理,从而有机会完成整个作业,它必须是事务性的。同样的原则也适用于 3.2 处的操作。该操作要么是事务性的,要么是幂等的。spring-doc.cadn.net.cn

如果位于 REPEAT(值为 3)的数据块因在 3.2 处发生数据库异常而失败,则 TX(值为 2)必须回滚整个数据块。spring-doc.cadn.net.cn

无状态重试

对于非事务性操作(例如调用 Web 服务或其他远程资源),使用重试机制也非常有用,如下例所示:spring-doc.cadn.net.cn

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

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

典型的重复 - 重试模式

最典型的批处理模式是在 chunk 的内部块中添加重试机制,如下例所示:spring-doc.cadn.net.cn

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) 的行为如下:spring-doc.cadn.net.cn

  1. 抛出异常,回滚事务,在块(chunk)级别标记为 TX(2),并允许该项目重新进入输入队列。spring-doc.cadn.net.cn

  2. 当项目重新出现时,根据当前配置的重试策略,它可能会被重试,并再次执行 PROCESS (5)。第二次及后续的尝试可能会再次失败并重新抛出异常。spring-doc.cadn.net.cn

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

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

在特殊情况下(例如特殊的 TranscationValidException 类型),重试策略可能能够确定,在 PROCESS (5) 刚刚失败后,可以在最后一次尝试时直接采取 RECOVER (6) 路径,而无需等待项目重新提交。这不是默认行为,因为它需要详细了解 PROCESS (5) 块内部发生的情况,而这些信息通常不可用。例如,如果在失败之前输出包含了写访问操作,则应重新抛出异常以确保事务完整性。spring-doc.cadn.net.cn

外部 REPEAT (1) 中的完成策略对计划的成功至关重要。如果输出 (5.1) 失败,它可能会抛出异常(通常如此,如所述),在这种情况下,事务 TX (2) 将失败,并且该异常可能向上传播到外部批处理 REPEAT (1)。我们不希望整个批处理停止,因为如果重试,RETRY (4) 仍可能成功,因此我们将 exception=not critical 添加到外部 REPEAT (1) 中。spring-doc.cadn.net.cn

然而,请注意,如果 TX (2) 失败并且我们确实重试,根据外部完成策略,接下来在内部 REPEAT (3) 中处理的项目并不保证是刚刚失败的那个。它可能是,但这取决于输入的实现 (4.1)。因此,输出 (5.1) 可能会在新项目或旧项目上再次失败。批处理的客户端不应假设每次 RETRY (4) 尝试都会处理与上次失败时相同的项目。例如,如果 REPEAT (1) 的终止策略是在 10 次尝试后失败,那么它会在连续 10 次尝试后失败,但不一定是在同一个项目上。这与整体重试策略是一致的。内部 RETRY (4) 了解每个项目的历史记录,并可以决定是否再对该项目进行尝试。spring-doc.cadn.net.cn

异步块处理

典型示例中的内部批次或块可以通过配置外部批次使用AsyncTaskExecutor来并发执行。外部批次会等待所有块完成后再结束。以下示例展示了异步块处理:spring-doc.cadn.net.cn

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

异步项处理

典型示例中块内的各个项原则上也可以并发处理。在这种情况下,事务边界必须移动到单个项的级别,以便每个事务都在一个单独的线程上,如下例所示:spring-doc.cadn.net.cn

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)时,该方案才有用。spring-doc.cadn.net.cn

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

批处理重试与事务管理之间的耦合度比理想情况下更紧密。特别是,对于不支持 NESTED 传播机制的事务管理器,无法使用无状态重试来重试数据库操作。spring-doc.cadn.net.cn

以下示例使用了不带重试的重复机制:spring-doc.cadn.net.cn

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

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

不幸的是,如果存在外部的重复批处理块,重试块中的相同效果会向上传播到该重复批处理块,如下例所示:spring-doc.cadn.net.cn

1   |  TX {
|
2   |    REPEAT(size=5) {
2.1 |      input;
2.2 |      database access;
3   |      RETRY {
4   |        TX {
4.1 |          database access;
|        }
|      }
|    }
|
|  }

现在,如果事务 (3) 回滚,它可能会污染整个批次在事务 (1) 中的数据,并强制其在最后回滚。spring-doc.cadn.net.cn

非默认传播机制是怎样的?spring-doc.cadn.net.cn

  • 在前面的示例中,PROPAGATION_REQUIRES_NEW 位于 TX (3) 处,可防止外部 TX (1) 在两个事务最终都成功时受到污染。但如果 TX (3) 提交而 TX (1) 回滚,则 TX (3) 仍保持提交状态,从而违反了 TX (1) 的事务契约。如果 TX (3) 回滚,TX (1) 不一定回滚(但在实践中通常会回滚,因为重试会抛出回滚异常)。spring-doc.cadn.net.cn

  • PROPAGATION_NESTEDTX(3)中按我们的要求在重试场景下工作(对于带有跳过的批处理也是如此):TX(3)可以提交,但随后可能被外部事务回滚,TX(1)。如果 TX(3)回滚,实际上 TX(1)也会回滚。此选项仅在某些平台上可用,不包括 Hibernate 或 JTA,但它是唯一能始终正常工作的选项。spring-doc.cadn.net.cn

因此,如果重试块包含任何数据库访问,NESTED 模式是最佳选择。spring-doc.cadn.net.cn

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

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

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)无法提交时(例如,由于消息系统不可用),会出现重复消息。spring-doc.cadn.net.cn

无状态重试无法恢复

在前述典型示例中,无状态重试与有状态重试之间的区别至关重要。实际上,最终是事务性约束迫使这种区分成为必要,而这一约束也清晰地解释了为何需要这种区分。spring-doc.cadn.net.cn

我们首先观察到,除非将项处理包裹在事务中,否则无法跳过失败的项并成功提交剩余的分块。因此,我们将典型的批执行计划简化如下:spring-doc.cadn.net.cn

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)时才有效。spring-doc.cadn.net.cn

如果内部 TX (4) 具有默认传播属性并发生回滚,它将污染外部 TX (1)。事务管理器会认为内部事务已损坏了事务资源,因此该资源无法再次使用。spring-doc.cadn.net.cn

对嵌套传播的支持非常罕见,因此我们决定在当前版本的 Spring Batch 中不支持通过无状态重试进行恢复。始终可以通过使用前面展示的典型模式来实现相同的效果(代价是重复执行更多的处理)。spring-doc.cadn.net.cn