當排序悄悄地將您的檔案重新綁定至錯誤的紀錄
原地排序加上從索引衍生的檔名,會產生可順利建置、載入與驗證的輸出,但同時卻將每筆紀錄指向錯誤的檔案。
一個管線以群組方式處理項目,將它們扁平化成一個切片以進行共享的轉換步驟,並使用迴圈索引將每個輸出檔案寫為 item-%04d.ext。然後有人要求將輸出按擁有者重新分組。那個顯而易見的變更產生的檔案確實存在、大小正確、通過了所有綱要檢查,但每筆紀錄都包含錯誤的資料。
沒有擲出任何錯誤。建置通過了,測試通過了,而且產物在下游工具中以正確的結構開啟。只有酬載是錯的,而酬載恰恰是自動化檢查通常不會檢驗的東西。
造成這種情況的設置
三個普通的決定結合起來變成一個陷阱。每一個決定單獨來看都是站得住腳的。
首先,一個共用的處理步驟接收一個扁平化的切片,因為一次性批次下載和轉換項目比按群組處理的成本更低。
其次,該步驟會進行原地排序。當下游工作假設資料有序時,按照時間軸位置、鍵值或任何東西排序是很常見的。排序是正確的,而且解釋它的註解也是準確的。
第三,輸出檔案的檔名來自寫入時的迴圈索引。
for i := range items {
items[i].LocalPath = filepath.Join(dir, fmt.Sprintf("item-%04d.src", i))
}
sortItems(items) // in-place, reorders everything
for i := range items {
out := filepath.Join(dir, fmt.Sprintf("item-%04d.out", i))
convert(items[i].LocalPath, out)
os.Remove(items[i].LocalPath) // source deleted, field not updated
}
請仔細閱讀。輸入是根據排序前的索引命名。輸出是根據排序後的索引命名。LocalPath 欄位永遠不會更新為輸出,因此在轉換後,它會指向一個已不存在的檔案。記錄與其轉換後檔案之間唯一倖存的連結是其在排序後切片中的位置。
只要一個迴圈寫入資訊清單,這種方式就能運作:
for i, item := range items {
manifest = append(manifest, Entry{Path: fmt.Sprintf("item-%04d.out", i), ...})
}
相同的切片、相同的順序、相同的索引。因巧合而正確。
導致故障的變更
現在,將輸出依擁有者分組。自然的實作方式是遍歷每個群組,並為其檔案編號:
idx := 0
for _, group := range groups {
for _, item := range group.Items {
group.Entries = append(group.Entries, Entry{
Path: fmt.Sprintf("item-%04d.out", idx), // fresh numbering per group
})
idx++
}
}
資訊清單中的每個路徑都存在於磁碟上。每個路徑都是獨一無二的。每個檔案都有合理內容,類型正確,大小也大致相符。而且每個檔案都屬於不同的記錄,因為扁平化切片是排序過的,而群組遍歷並非依序進行。
這就是值得一提的失敗模式:**該錯誤不會產生遺失的檔案或損壞的檔案。它會產生一個內容錯誤的有效檔案。**存在性檢查會通過。大小檢查會通過。格式驗證會通過。結構驗證會通過。下游的消費者會順利地開啟它。
在音訊管線中,這意味著音訊片段會出現在正確的時間軸位置,有著正確的長度,但聲音卻是錯的。在文件管線中,這意味著頁數正確,但頁面內容卻被調換了。在影像管線中,尺寸正確,但主體卻是錯的。症狀始終是語意上的,而非結構上的,這就是為什麼它能通過你所有的結構性檢查。
為何明顯的修復方法還不夠
第一個直覺是新增一個 owner 欄位,這樣成員資格就能在排序後保留下來:
type Item struct {
// ...
GroupIdx int // survives the in-place sort
}
那是必要的,而且它確實還原了分組。但它並未修復檔案綁定,因為綁定從未依賴於分組,而是依賴於位置。加入 GroupIdx 讓你可以重建群組,然後在群組內部重新生成位置,這正是產生交換的程式碼路徑。
我在第一次處理時犯了這個錯誤。欄位加進去了,分組也正常運作,而一位審核者指出 manifest 建構器仍然從一個計數器衍生路徑。真正有效的修復方法是完全停止衍生路徑:
// conversion updates the record to point at its own output
for i := start; i < end; i++ {
os.Remove(items[i].LocalPath)
items[i].LocalPath = outputs[i-start] // the record carries its file
}
// manifest reads the field instead of computing an index
for _, item := range items {
entries[item.GroupIdx] = append(entries[item.GroupIdx], Entry{
Path: item.LocalPath,
})
}
現在,記錄本身攜帶著自己的產物參考。重新排序、重新分組、篩選以及平行處理全都變得無害,因為沒有任何東西會重新計算記錄已經知道的內容。
一般規則是:**當一筆記錄及其產物必須保持在一起時,就將參考儲存在該記錄上。**索引是一個位置,而位置是任何排序、篩選或分割操作最先會破壞的東西。一旦兩個不同的迴圈都計算「第 N 個檔案」,你就得到了一個無人強制執行的不變性。
測試這件事比看起來還難
我為這個錯誤寫了一個迴歸測試。它設定了兩個群組,其排序鍵交錯排列,執行了管線階段,並斷言每個條目都指向其自身記錄的檔案。它通過了。
在我還原修正後,它也通過了。
該測試在測試檔案中重新實作了排序和資訊清單的建構,因為產品函式被埋在一個需要網路呼叫和子處理程序的較大常式中。測試邏輯的複本可以驗證該複本是正確的。這對於交付的程式碼毫無說明。
修正方法是提取出純粹的部分,以便測試可以直接呼叫它們:
func flattenWithGroupIdx(groups []Group) []Item // tag membership
func sortItems(items []Item) // the actual sort, now callable
func groupIntoEntries(groups []Group, items []Item) []GroupEntries
三次小規模的擷取,無行為變更,且測試現在會執行到產品程式碼。然後我驗證了當錯誤存在時,測試確實會失敗:
$ # revert manifest to index-derived paths
$ go test -run TestKeepsBinding ./...
--- FAIL: TestKeepsBinding
record a2 bound to wrong file: got item-0000.out, want /tmp/x/item-0000.out
record a1 bound to wrong file: got item-0001.out, want /tmp/x/item-0001.out
... (5 records)
FAIL
那兩分鐘的檢查正是重點所在。一個你從未見過其失敗的回歸測試,只是一種假設,而非保證。如果還原該修復後測試仍為綠燈,那它測試的就是別的東西,而那個「別的東西」通常是你原意要保護的邏輯的複本。
捕捉語意交換的斷言
結構性檢查的設計本身就無法偵測到這類錯誤,因此斷言必須比較的是身分,而非形狀。
在重新排序發生前,先擷取預期的對應關係,之後再比較:
want := map[string]string{}
for _, item := range items {
want[item.ID] = item.LocalPath // snapshot before regrouping
}
// ... build manifest ...
for _, e := range entries {
if e.Path != want[e.ID] {
t.Errorf("%s bound to wrong file: got %s, want %s", e.ID, e.Path, want[e.ID])
}
}
**明確斷言唯一性。**兩個條目指向同一個產物,是逐項檢查無法察覺的一種交換:
seen := map[string]string{}
for _, e := range entries {
if prev, dup := seen[e.Path]; dup {
t.Errorf("path %s shared by %s and %s", e.Path, prev, e.ID)
}
seen[e.Path] = e.ID
}
**使用能實際重新排序的排序鍵。**一個項目已排序的 fixture 無法證明任何事。交錯排列群組,讓排序能將記錄跨越群組邊界移動,這正是觸發錯誤的條件。
**檢查副檔名,而不僅是路徑。**在原始程式碼中,一個未轉換的 LocalPath 仍然指向一個已刪除的來源檔案。斷言副檔名可以捕捉到一整類的「欄位在轉換後未更新」的錯誤。
這種模式還隱藏在何處
我是在音訊管線中遇到它的,但其樣貌是通用的。只要這三者同時出現,就該留意它的蹤跡:
- 一個「扁平化-處理-重新分組」的序列,這在批次 API 比逐組呼叫更便宜時很常見。
- 在中間某處進行原地排序或篩選,包括隱藏在非你所寫的輔助函式中的情況。
- 成品名稱是從迴圈計數器衍生而來,而不是儲存在紀錄本身。
具體出現的地方有:批次處理圖片然後按相簿重新分組的縮圖產生;一次性渲染頁面然後按章節組合的報告產生器;大量擷取資料列、按時間戳排序以進行視窗化、然後按租戶分區的 ETL 工作;任何呼叫外部工具並採用 --output-%d 模式的東西。
跡象是像「索引符合排序後的順序」或「同一個迴圈,同一個索引」這樣的註解。那段註解正在記錄一個沒有強制執行的不變性。它在寫下時是成立的,並在下一次重構後默默地失效,而你的測試套件中沒有任何東西會注意到。
常見的異議
「不要原地排序就好。」 這很合理,但你通常無法控制排序。在這裡,它存在於一個共享的輔助函式中,另一個依賴此順序的管線會使用它。將其更改為回傳一個複本會是個影響範圍更廣的變更,且有其自身的風險,而且這也無法解決基於位置衍生名稱的根本問題。
「使用以 ID 為鍵的 map,而不是 slice。」 這方法可行,且完全消除了位置的影響。但它也帶來了記憶體配置和順序控制的成本,當下一個階段以排序順序進行串流處理時,這點很重要。將路徑儲存在記錄上可以獲得同樣的安全性,同時保留 slice。
「事後驗證輸出。」 你可以這麼做,但驗證語意等同性通常意味著要解碼產出物並比較內容,這成本很高,而且在單元測試中通常不可能做到。讓綁定在結構上不可能被破壞,比偵測到破壞更便宜。
「我們的型別是不可變的,所以我們無法儲存路徑。」 那麼就回傳一個將記錄與產出物配對的平行結構,並傳遞該結構,而不是傳遞兩個你獨立重新索引的 slice。原則不變:一個值承載兩半部分。
重點摘要
- 在「名稱輸入」和「名稱輸出」之間進行原地排序,會將由索引衍生的檔案名稱變成一個隱性的正確性錯誤。
- 此錯誤會產生內容互換的有效產物,因此存在性、大小、格式和結構檢查都會通過。
- 新增一個所有權欄位可以恢復分組,但無法恢復綁定。應將產物參考儲存在紀錄上。
- 將純粹函式提取出來,以便測試能呼叫正式程式碼,然後還原一次修復以確認測試會失敗。
- 斷言身分和唯一性,而非形狀,並使用排序順序會實際移動紀錄的測試資料。
這一切中最經濟實惠的版本就是還原檢查。如果你這一季只寫一個迴歸測試,那就寫一個你看過它失敗的測試。
常見問答
我要如何知道我的管線目前是否有這個錯誤? 找出所有使用迴圈索引來格式化檔名的地方。對於每一個地方,都要問問自己,在該行與讀回檔案的那行之間,切片(slice)是否可能被重新排序。如果中間有排序(sort)、篩選(filter)、去重(dedupe)或平行散布(parallel scatter)的操作,那麼你就具備了觸發條件。然後撰寫上述的對應關係斷言(mapping assertion),看看它是否能通過。
靜態分析器能抓到這個錯誤嗎? 不行。每一行程式碼本身都是正確的。這個錯誤存在於兩個迴圈之間的關係中,而類型檢查器沒有理由將它們連結起來。這就是為什麼緩解措施是結構性的,而不是一個程式碼風格規則(lint rule)。
這和差一錯誤(off-by-one)一樣嗎? 不一樣,而且這個差異對於偵錯很重要。差一錯誤通常會在邊界處崩潰,或產生一個明顯錯誤的元素。這個錯誤會產生一個完整的排列(permutation):每個元素都是錯的,沒有任何元素遺失,而且總數是正確的。如果你正在追查一個「所有東西都有細微的錯誤,但沒有東西壞掉」的回報,那麼「排列」會是比「算術」更好的假設。
這是否也適用於檔案管線之外的情況? 是的。任何時候你透過位置來維持兩個集合之間的對應關係,重新排序都會破壞它。平行陣列、以索引為鍵的快取,以及批次 API 客戶端中「第 N 個回應對應第 N 個請求」的假設,都具有相同的形式。修正方法也一樣:將兩半配對成一個值。