批处理和事务

无重试的简单批处理

考虑以下一个没有重试的嵌套批处理的简单示例。它展示了批处理的常见场景:一个输入源被处理直到耗尽,并在处理的“chunk”结束时定期提交。

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

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

如果 REPEAT (3) 中的 Chunk 因为 3.2 的数据库异常而失败,那么 TX (2) 必须回滚整个 Chunk。

简单的无状态重试

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

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

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

典型的 Repeat-Retry 模式

最典型的批处理模式是在 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) 的行为如下

  1. 抛出异常,在 Chunk 级别回滚事务 TX (2),并允许将 Item 重新呈现给输入队列。

  2. 当 Item 重新出现时,它可能会被重试,具体取决于现有的重试策略,并再次执行 PROCESS (5)。第二次及随后的尝试可能会再次失败并重新抛出异常。

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

请注意,计划中用于 RETRY (4) 的标记明确显示输入步骤 (4.1) 是重试的一部分。它还清楚地表明存在两条备用处理路径:正常情况,由 PROCESS (5) 表示,以及恢复路径,在单独的块中由 RECOVER (6) 表示。这两条备用路径完全不同。正常情况下只取其中一条。

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

外部 REPEAT (1) 中的完成策略对于计划的成功至关重要。如果输出 (5.1) 失败,它可能会抛出异常(通常会如此,如前所述),在这种情况下,事务 TX (2) 失败,异常可能会通过外部 Batch REPEAT (1) 向上传播。我们不希望整个 Batch 停止,因为如果再次尝试,RETRY (4) 可能仍然成功,因此我们在外部 REPEAT (1) 中添加 exception=not critical

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

异步 Chunk 处理

典型示例中的内部 Batch 或 Chunk 可以通过配置外部 Batch 使用 AsyncTaskExecutor 来并发执行。外部 Batch 会等待所有 Chunk 完成后才完成。以下示例显示了异步 Chunk 处理

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

异步 Item 处理

典型示例中 Chunk 中的单个 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) 的成本远高于事务管理 (3) 的成本时才有用。

Batching 和事务传播之间的交互

Batch 重试与事务管理之间的耦合比我们理想中期望的更紧密。特别是,对于不支持 NESTED 传播的事务管理器,不能使用无状态重试来重试数据库操作。

以下示例使用不带 Repeat 的重试

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

同样,由于相同的原因,内部事务 TX (3) 可能导致外部事务 TX (1) 失败,即使 RETRY (2) 最终成功。

不幸的是,如果存在周围的 Repeat Batch,同样的效果也会从重试块向上渗透到它,如下例所示

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) 处的整个 Batch,并强制其在结束时回滚。

非默认传播呢?

  • 在前面的示例中,TX (3) 处的 PROPAGATION_REQUIRES_NEW 可以防止外部 TX (1) 在两个事务最终都成功时被污染。但是如果 TX (3) 提交而 TX (1) 回滚,TX (3) 仍然保持提交状态,因此我们违反了 TX (1) 的事务契约。如果 TX (3) 回滚,TX (1) 不一定回滚(但在实践中很可能回滚,因为重试会抛出回滚异常)。

  • 在重试情况下(以及带有 Skip 的 Batch),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) 无法提交(例如,因为消息系统不可用)时出现重复消息。

无状态重试无法恢复

前面典型示例中无状态重试和有状态重试的区别很重要。它实际上最终是强制区分的事务约束,并且此约束也使得这种区分存在的原因显而易见。

我们从这样一个观察开始:除非我们将 Item 处理包装在事务中,否则无法跳过失败的 Item 并成功提交 Chunk 的其余部分。因此,我们将典型的 Batch 执行计划简化如下

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 中,我们选择不支持无状态重试的恢复。通过使用前面所示的典型模式,始终可以达到相同的效果(代价是重复更多处理)。