Rill:Go語言中併發+事務的批處理開源專案

banq發表於2024-05-05


Rill(名詞:小流)是一個用於流式傳輸、並行處理和管道構建的綜合 Go 工具包。它旨在減少樣板檔案並簡化使用,使開發人員能夠專注於核心邏輯,而不會因併發的複雜性而陷入困境。

透過通道轉換、型別安全、批處理和錯誤處理實現併發。

設計理念
rill 的核心在於一個簡單而強大的概念:在由Try結構封裝的包裝值通道上進行操作。

  • 此類通道可以手動建立,
  • 也可以透過FromSlice或FromChan等實用程式建立,
  • 然後透過Map、Filter、FlatMap等操作進行轉換。
  • 最後,當所有處理階段完成後,可以透過ForEach、ToSlice或透過迭代結果通道來手動消耗資料 。

背景上下文
需要執行任何特殊操作即可使用 Go 併發性。大多數人編寫 HTTP 服務,每個請求都已經在單獨的 goroutine 中處理。您只需要確保您使用的所有其他庫都是執行緒安全的,並且在大多數情況下,它們都是執行緒安全的。

當需要實現批處理以減少資料庫負載時,高階併發問題就發生了:

  • 批次 DB 插入:多個執行程式處理 DB 插入。將記錄收集到一個通道,然後處理該通道,分批插入。
  • 批處理資料庫更新:與插入類似,但用於更新,如 UPDATE users SET last_active_at=NOW() WHERE id IN(?,?,?,?..)
  • 批次處理佇列資訊:收集一批訊息,提取 ID,並執行一次資料庫查詢 WHERE id IN (...) ,然後將訊息標記為已處理。

還有更多情況,不僅與 DB 有關。我很快意識到,我不想一遍又一遍地重複同樣的程式碼,所以我做了一個泛型批處理函式。那時候 Go 還沒有泛型,所以我的泛型函式實際上是基於反射的。

  • 編寫基本的並行迴圈讓我瞭解了 WaitGroups,
  • 而與錯誤處理相關的一些 bug 和 goroutine 洩露則讓我瞭解了 ErrGroup。

有一次,我需要一個可以容納多達 N 個唯一鍵的對映。嘗試插入 N+1 個鍵時會阻塞,直到另一個鍵被刪除。為了實現這個目標,我學習了 sync.Condition。

我還需要下載許多巨大的 CSV 檔案,每個檔案都包含特定日期的交易列表。之後,我需要解析和比較連續幾天的 CSV。為了加快速度,我需要同時下載它們。這就是我的 Rill 模組中 Ordered* 函式的誕生過程。

同步包並沒有錯,在某些情況下,同步/原子是解決問題的最佳方法。

Rill主要特徵

  • 輕量級:快速且模組化,可以輕鬆整合到現有專案中
  • 易於使用:管理 goroutine、等待組和錯誤處理的複雜性被抽象化了
  • Concurrent:控制所有操作的併發級別
  • 批處理:提供了一種簡單的方式來批次組織和處理資料
  • 錯誤處理:提供一種結構化的方法來處理併發應用程式中的錯誤
  • 流:以最小的記憶體佔用處理實時資料流或大型資料集
  • 順序保留:提供保留資料原始順序的功能,同時仍然允許併發處理
  • 高效的資源使用:goroutines 的數量和分配不取決於資料大小
  • 通用:所有操作都是型別安全的,可以與任何資料型別一起使用
  • 函數語言程式設計:基於函數語言程式設計概念,使map、filter、flatMap等操作可用於基於通道的工作流程

用法示例
考慮一個從多個 URL 獲取鍵、從鍵值資料庫批次檢索其值並列印它們的應用程式。此示例展示了該庫在處理併發任務、錯誤傳播、批處理和資料流方面的優勢,同時保持簡單性和效率。


func main() {
    urls := rill.FromSlice([]string{
        <font>"https://example.com/file1.txt",
       
"https://example.com/file2.txt",
       
"https://example.com/file3.txt",
       
"https://example.com/file4.txt",
    }, nil)

   
// 從每個 URL 獲取鍵值,並將其扁平化為單一流<i>
    keys := rill.FlatMap(urls, 3, func(url string) <-chan rill.Try[string] {
        return streamFileLines(url)
    })

   
// 從資料流中排除任何空鍵<i>
    keys = rill.Filter(keys, 3, func(key string) (bool, error) {
        return key !=
"", nil
    })

   
//將金鑰key整理成易於管理的批次,每批 10 個,以便批次操作<i>
    keyBatches := rill.Batch(keys, 10, 1*time.Second)

   
// 從資料庫中獲取每批鍵的值<i>
    resultBatches := rill.Map(keyBatches, 3, func(keys []string) ([]KV, error) {
        values, err := kvMultiGet(keys...)
        if err != nil {
            return nil, err
        }

        results := make([]KV, len(keys))
        for i, key := range keys {
            results[i] = KV{Key: key, Value: values[i]}
        }

        return results, nil
    })

   
//將批次轉換回單個專案進行最終處理<i>
    results := rill.Unbatch(resultBatches)

   
//從資料流中排除任何空值<i>
    results = rill.Filter(results, 3, func(kv KV) (bool, error) {
        return kv.Value !=
"<nil>", nil
    })

   
//遍歷每個鍵值對並列印<i>
    cnt := 0
    err := rill.ForEach(results, 1, func(kv KV) error {
        fmt.Println(kv.Key,
"=>", kv.Value)
        cnt++
        return nil
    })
    if err != nil {
        fmt.Println(
"Error:", err)
    }

    fmt.Println(
"Total keys:", cnt)
}

// streamFileLines 可以從 URL 逐行流式傳輸檔案、<i>
func streamFileLines(url string) <-chan rill.Try[string] {
   
// ...<i>
}

// kvMultiGet 從鍵值資料庫中進行批次讀取、<i>
func kvMultiGet(keys ...string) ([]string, error) {
   
// ...<i>
}


批處理
批處理是併發處理中的常見模式,尤其是在處理外部服務或資料庫時。 Rill 提供了Batch函式,可將專案流組織成指定大小的批次。還可以指定一個超時,之後即使批次未滿,也會發出該批次。當輸入流緩慢或稀疏時,這對於保持應用程式的反應非常有用。

扇入和扇出
提供了扇入和扇出資料流的機制。扇入是透過合併功能完成的,該功能將多個資料流合併到一個統一的通道中。扇出是透過Split2函式完成的,該函式將單個輸入流劃分為兩個不同的輸出通道。這種劃分基於鑑別器函式,允許基於資料特徵的並行處理路徑。

錯誤處理
錯誤是使用ForEach處理的,這對於大多數用例都有好處。 ForEach在出現第一個錯誤時停止處理並返回該錯誤。如果您需要在管道中間處理錯誤,和/或在發生錯誤後繼續處理,可以使用Catch函式。

終止和資源洩漏
在 Go 併發應用程式中,如果一個通道沒有讀者,那麼寫者就會被卡住,從而導致潛在的程式和記憶體洩漏。

這個問題也會延伸到基於 Go 通道構建的 rill 管道;如果管道中的任何階段缺少消費者,上游的整個生產者鏈都可能被阻塞。

因此,確保管道被完全消費至關重要,尤其是在錯誤導致提前終止的情況下。

下面的示例演示了一種情況,即最後處理階段在第一次遇到錯誤時退出,從而導致管道處於阻塞狀態。

func doWork(ctx context.Context) error {
    <font>// 初始化管道的第一階段<i>
    ids := streamIDs(ctx)
    
    
// Define other pipeline stages...<i>
    
    
// 最後階段處理<i>
    for value := range results {
        
// Process value...<i>
        if someCondition {
            return fmt.Errorf(
"some error") // Early exit on error<i>
        }
    }
    return nil
}

為防止出現此類問題,建議確保在出現錯誤時排空結果通道。一種直接的方法是使用 defer 來呼叫 DrainNB:

func doWork(ctx context.Context) error {
    <font>// Initialize the first stage of the pipeline<i>
    ids := streamIDs(ctx)
    
   
// Define other pipeline stages...<i>
    
   
// 確保在發生故障時排空管道<i>
    defer rill.DrainNB(results)
    
   
// Final stage processing<i>
    for value := range results {
       
// Process value...<i>
        if someCondition {
            return fmt.Errorf(
"some error") // Early exit on error<i>
        }
    }
    return nil
}

利用 ForEach 或 ToSlice 等內建排水機制的函式,可以簡化程式碼並提高可讀性:

func doWork(ctx context.Context) error {
    <font>// Initialize the first stage of the pipeline<i>
    ids := streamIDs(ctx)
    
   
// Define other pipeline stages...<i>

   
// Final stage processing<i>
    return rill.ForEach(results, 5, func(value string) error {
       
// Process value...<i>
        if someCondition {
            return fmt.Errorf(
"some error") // 出錯時提前退出,自動排水<i>
        }
        return nil
    })
}

雖然這些措施能有效防止洩漏,但只要初始階段產生數值,管道就可能繼續在後臺消耗數值。

最佳做法是使用上下文管理第一階段(以及可能的其他階段),從而實現受控關閉:

func doWork(ctx context.Context) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel() <font>// 確保功能退出時取消第一階段<i>

   
// Initialize the first stage of the pipeline<i>
    ids := streamIDs(ctx)

   
// Define other pipeline stages...<i>

   
// Final stage processing<i>
    return rill.ForEach(results, 5, func(value string) error {
       
// Process value<i>
        if someCondition {
            return fmt.Errorf(
"some error") // Early exit on error, with automatic draining<i>
        }
        return nil
    })
}

順序保持
在併發環境中,由於並行執行的特性,保持已處理專案的原始順序具有挑戰性。當數值從輸入通道讀取、透過函式 f 處理並寫入輸出通道時,它們的順序可能與輸入順序不一致。為了解決這個問題,rill 為其核心函式(如 OrderedMap、OrderedFilter 等)提供了有序版本。這些函式確保,如果輸入通道中的值 x 在值 y 之前,那麼輸出中的 f(x) 就會在 f(y) 之前,從而保持原來的順序。值得注意的是,與無序函式相比,這些有序函式會產生少量開銷,這是因為需要額外的邏輯來保持順序。

在資料順序會影響結果的情況下,保持順序至關重要。例如,一個應用程式需要檢索特定時間段內的每日溫度測量值,並計算一天到第二天的溫度變化。雖然並行獲取資料可以提高效率,但按照原始順序處理資料對於準確計算溫度變化至關重要。

type Measurement struct {
    Date time.Time
    Temp float64
}

func main() {
    city := <font>"New York"
    endDate := time.Now()
    startDate := endDate.AddDate(0, 0, -30)

   
// 建立一個通道,傳送開始日期和結束日期之間的所有天數<i>
    days := make(chan rill.Try[time.Time])
    go func() {
        defer close(days)
        for date := startDate; date.Before(endDate); date = date.AddDate(0, 0, 1) {
            days <- rill.Wrap(date, nil)
        }
    }()

   
// 同時下載每天的溫度<i>
    measurements := rill.OrderedMap(days, 10, func(date time.Time) (Measurement, error) {
        temp, err := getTemperature(city, date)
        return Measurement{Date: date, Temp: temp}, err
    })

   
// 迭代測量結果,計算並列印變化。使用單個 goroutine<i>
    prev := Measurement{Temp: math.NaN()}
    err := rill.ForEach(measurements, 1, func(m Measurement) error {
        change := m.Temp - prev.Temp
        prev = m

        fmt.Printf(
"%s: %.1f°C (change %+.1f°C)\n", m.Date.Format("2006-01-02"), m.Temp, change)
        return nil
    })
    if err != nil {
        fmt.Println(
"Error:", err)
    }
}

// getTemperature 獲取城市和日期的溫度讀數<i>
func getTemperature(city string, date time.Time) (float64, error) {
   
// ...<i>
}

 

相關文章