Appendix A: 批处理和事务

本站( springdoc.cn )中的内容来源于 spring.io ,原始版权归属于 spring.io。由 springdoc.cn 进行翻译,整理。可供个人学习、研究,未经许可,不得进行任何转载、商用或与之相关的行为。 商标声明:Spring 是 Pivotal Software, Inc. 在美国以及其他国家的商标。

无需重试的简单批处理

下面是一个没有重试的嵌套批处理的简单示例。它展示了批处理的一种常见情况: 输入源一直被处理到耗尽,并在处理 "块" 结束时定期提交。

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) 就会回滚。

典型的重复重试模式

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

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

  1. 抛出异常,在大块级别回滚 TX (2) 事务,允许项目(item)重新提交到输入队列。

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

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

请注意,计划中用于 RETRY (4) 的符号明确显示输入 step (4.1) 是重试的一部分。它还清楚地表明有两条交替处理路径:一条是正常情况,用 PROCESS (5) 表示;另一条是恢复路径,用 RECOVER (6) 表示。这两条备用路径完全不同。在正常情况下,只走一条。

在特殊情况下(如特殊的 TranscationValidException 类型),重试策略可能会确定在 PROCESS (5) 失败后的最后一次尝试中可以采用 RECOVER (6) 路径,而不是等待项目重新提交。这并不是默认行为,因为它需要详细了解 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) 知道每个项目的历史,可以决定是否再次尝试。

异步分块处理

通过将外部批处理配置为使用 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;
|      }
|    }
|
|  }

同样,出于同样的原因,即使 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) 的事务契约。如果 TX (3) 回滚,TX (1) 不一定回滚(但实际上可能回滚,因为重试会抛出回滚异常)。

  • TX (3) 中的 PROPAGATION_NESTED 在重试情况下(以及在有跳转的批次中)可以按照我们的要求运行: TX (3) 可以提交,但随后会被外部事务 TX (1) 回滚。如果 TX (3) 回滚,TX (1) 实际上也会回滚。该选项仅在某些平台上可用,不包括 Hibernate 或 JTA,但它是唯一能持续工作的选项。

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

特例:正交资源和事务

在没有嵌套数据库事务的简单情况下,默认传播总是没有问题的。请看下面的示例,其中 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) 无法提交(例如,由于消息系统不可用)时,会出现重复消息。

无状态重试无法恢复

在前面的典型示例中,无状态重试和有状态重试之间的区别非常重要。实际上,这种区别最终是由事务约束造成的,而这种约束也使区别的存在显而易见。

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

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 中不支持无状态重试恢复。使用前面所示的典型模式,总能达到相同的效果(代价是重复更多的处理)。