批處理和事務

無重試的簡單批處理

考慮以下一個沒有重試的巢狀批處理的簡單示例。它展示了批處理的常見場景:一個輸入源被處理直到耗盡,並在處理的“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 中,我們選擇不支援無狀態重試的恢復。透過使用前面所示的典型模式,始終可以達到相同的效果(代價是重複更多處理)。