# 批处理和交易

# 附录 A:批处理和事务

# 不需要重试的简单批处理

考虑以下简单的嵌套批处理示例,该批处理不需要重试。它展示了批处理的一个常见场景:一个输入源被处理到耗尽,并且我们在处理的“块”结束时定期提交。

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),并允许将项重新呈现到输入队列中。

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

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

请注意,上面的计划中用于RETRY(4)的符号显式地显示了输入步骤(4.1)是重试的一部分。它还清楚地表明,有两种可供选择的处理路径:正常情况(用PROCESS(5)表示),以及恢复路径(在单独的块中用RECOVER(6)表示)。这两条可供选择的道路是完全不同的。在正常情况下只有一次。

在特殊情况下(例如特殊的TransactionValidException类型),重试策略可能能够确定RECOVER(6)路径可以在PROCESS(5)刚刚失败之后的最后一次尝试中使用,而不是等待项目被重新呈现。这不是默认的行为,因为它需要详细了解PROCESS(5)块内部发生了什么,而这通常是不可用的。例如,如果输出包括在失败之前的写访问,那么应该重新抛出异常,以确保事务 Integrity。

外部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)的成本时,它才是有用的。

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

批处理重试和事务管理之间的耦合比我们理想的更紧密。特别是,无状态重试不能用于使用不支持嵌套传播的事务管理器重试数据库操作。

下面的示例使用重试而不重复:

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

现在,如果 TX(3)回滚,它可能会污染 TX(1)处的整个批次,并迫使它在最后回滚。

那么非默认传播呢?

  • 在前面的示例中,如果两个事务最终都成功,PROPAGATION_REQUIRES_NEWatTX(3)可以防止外部TX(1)被污染。但是如果TX(3)提交并且TX(1)回滚,那么TX(3)保持提交,因此我们违反了TX(1)的交易契约。如果TX(3)回滚,TX(1)不一定(但在实践中可能会这样做,因为重试会抛出一个回滚异常)。

  • PROPAGATION_NESTEDatTX(3)在重试情况下(对于具有跳过的批处理),按照我们的要求工作:TX(3)可以提交,但随后由外部事务回滚,TX(1)。如果TX(3)回滚,则TX(1)在实践中回滚。此选项仅在某些平台上可用,不包括 Hibernate 或 JTA,但它是唯一一个始终有效的选项。

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

# 特殊情况:使用正交资源的事务

对于没有嵌套数据库事务的简单情况,默认传播总是 OK 的。考虑以下示例,其中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;
|        }
|
|      }
|    }
|
|  }

前面的示例显示了一个带有RECOVER(5)路径的无状态RETRY(3),该路径在最后一次尝试失败后启动。stateless标签意味着可以重复该块,而不会将任何异常重新抛出到某个限制。这仅在事务TX(4)具有传播嵌套时才有效。

如果内部TX(4)具有默认的传播属性并回滚,则会污染外部TX(1)。事务管理器假定内部事务已经损坏了事务资源,因此不能再次使用它。

对嵌套传播的支持非常少,因此我们选择在 Spring 批处理的当前版本中不支持使用无状态重试的恢复。通过使用上面的典型模式,总是可以实现相同的效果(以重复更多处理为代价)。