附录 C.批处理和事务

C.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)处的操作-它必须是事务性的或幂等的。

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

C.2 简单 Stateless 重试

将重试用于非事务性操作(例如对 Web 服务或其他远程资源的调用)也很有用。例如:

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

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

C.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)块被标记为“有状态”-有关有状态重试的说明,请参见典型用例。这意味着,如果 retry 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类型),重试策略可能能够确定 RECOVER(6)路径可以在 PROCESS(5)刚刚失败之后的最后一次尝试中采用,而不必 await 该 Item 被重新呈现。这不是默认行为,因为它需要详细了解 PROCESS(5)块内发生的情况,而这通常是不可用的,例如如果输出在失败之前包含写访问权限,则应重新抛出异常以确保事务完整性。

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

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

C.4 异步块处理

通过将外部批处理配置为使用AsyncTaskExecutor,可以同时执行上述典型示例中的内部批处理或块。外部批处理在完成之前 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;
|        }
|
|      }
|    }
|
|  }

C.5 异步 Item 处理

典型情况下,典型情况下大块中的单个 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)的成本高得多时,它才有用。

C.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)不一定(但实际上可能会这样做,因为重试将抛出回滚异常)。

  • TX(3)的 PROPAGATION_NESTED 可以按照我们在重试情况下(对于具有跳过的批处理)的要求进行工作:TX(3)可以提交,但随后由外部事务 TX(1)回滚。如果 TX(3)回滚,实际上 TX(1)也会回滚。此选项仅在某些平台上可用,例如不是 Hibernate 或 JTA,但它是唯一能够持续工作的软件。

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

C.7 特例:具有正交资源的事务

对于没有嵌套数据库事务的简单情况,默认传播总是可以的。考虑一下(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)无法提交(例如,失败)时出现重复消息。因为消息系统不可用。

C.8Stateless 重试无法恢复

在上述典型示例中,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(3)具有默认的传播属性并且回滚,它将污染外部 TX(1)。事务 Management 器认为内部事务破坏了事务资源,因此无法再次使用它。

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