【Go】使用壓縮檔案優化 io (一)

qiyin發表於2019-07-01

原文連線:https://blog.thinkeridea.com/...

最近遇到一個日誌備份 io 過高的問題,業務日誌每十分鐘備份一次,本來是用 Python 寫一個根據規則掃描備份日誌問題不大,但是隨著業務越來越多,單機上的日誌檔案越來越大,檔案數量也越來越多,導致每每備份的瞬間 io 阻塞嚴重, CPU 和 load 異常的高,好在備份速度很快,對業務影響不是很大,這個問題會隨著業務增長,越來越明顯,這段時間抽空對備份方式做了優化,效果十分顯著,整理篇文章記錄一下。

背景說明

伺服器配置:4 核 8G; 磁碟:500G

每十分鐘需要上傳:18 個檔案,高峰時期約 10 G 左右

業務日誌為了保證可靠性,會先寫入磁碟檔案,每 10 分鐘切分日誌檔案,然後在下十分鐘第一分時備份日誌到 OSS,資料分析服務會從在備份完成後拉取日誌進行分析,日誌備份需要高效快速,在最短的時間內備份完,一般備份均能在幾十秒內完成。

備份的速度和效率並不是問題,足夠的快,但是在備份時 io 阻塞嚴重導致的 CPU 和 load 異常,成為業務服務的瓶頸,在高峰期業務服務僅消耗一半的系統資源,但是備份時 CPU 經常 100%,且 iowait 可以達到 70 多,空閒資源非常少,這樣隨著業務擴充套件,日誌備份雖然時間很短,卻成為了系統的瓶頸。

後文中會詳細描述優化前後的方案,並用 go 編寫測試,使用一臺 2 核 4G 的伺服器進行測試,測試資料集大小為:

  • 檔案數:336
  • 原始檔案:96G
  • 壓縮檔案:24G
  • 壓縮方案:lzo
  • Goroutine 數量:4

優化前

優化前日誌備份流程:

  • 根據備份規則掃描需要備份的檔案
  • 使用 lzop 命令壓縮日誌
  • 上傳壓縮後的日誌到 OSS

下面是程式碼實現,這裡不再包含備份檔案規則,僅演示壓縮上傳邏輯部分,程式接受檔案列表,並對檔案列表壓縮上傳至 OSS 中。

.../pkg/aliyun_oss 是我自己封裝的基於阿里雲 OSS 操作的包,這個路徑是錯誤的,僅做演示,想執行下面的程式碼,OSS 互動這部分需要自己實現。

package main

import (
    "bytes"
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "sync"
    "time"

    ".../pkg/aliyun_oss"
)

func main() {
    var oss *aliyun_oss.AliyunOSS
    files := os.Args[1:]
    if len(files) < 1 {
        fmt.Println("請輸入要上傳的檔案")
        os.Exit(1)
    }

    fmt.Printf("待備份檔案數量:%d\n", len(files))

    startTime := time.Now()
    defer func(startTime time.Time) {
        fmt.Printf("共耗時:%s\n", time.Now().Sub(startTime).String())
    }(startTime)

    var wg sync.WaitGroup
    n := 4
    c := make(chan string)

    // 壓縮日誌
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            for file := range c {
                cmd := exec.Command("lzop", file)
                cmd.Stderr = &bytes.Buffer{}
                err := cmd.Run()
                if err != nil {
                    panic(cmd.Stderr.(*bytes.Buffer).String())
                }
            }
        }()
    }

    for _, file := range files {
        c <- file
    }

    close(c)
    wg.Wait()
    fmt.Printf("壓縮耗時:%s\n", time.Now().Sub(startTime).String())

    // 上傳壓縮日誌
    startTime = time.Now()
    c = make(chan string)
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            for file := range c {
                name := filepath.Base(file)
                err := oss.PutObjectFromFile("tmp/"+name+".lzo", file+".lzo")
                if err != nil {
                    panic(err)
                }
            }
        }()
    }

    for _, file := range files {
        c <- file
    }

    close(c)
    wg.Wait()
    fmt.Printf("上傳耗時:%s\n", time.Now().Sub(startTime).String())
}

程式執行時輸出:

待備份檔案數量:336
壓縮耗時:19m44.125314226s
上傳耗時:6m14.929371103s
共耗時:25m59.118002969s

從執行結果中可以看出壓縮檔案耗時很久,實際通過 iostat 命令分析也發現,壓縮時資源消耗比較高,下面是 iostat -m -x 5 10000 命令採集各個階段資料。

  • 程式執行前
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           2.35    0.00    2.86    0.00    0.00   94.79

Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.00    0.00    0.00     0.00     0.00     0.00     0.00    0.00    0.00    0.00   0.00   0.00
vdb               0.00     0.60    0.00    0.60     0.00     4.80    16.00     0.00    0.67    0.00    0.67   0.67   0.04
  • 壓縮日誌時
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          10.84    0.00    6.85   80.88    0.00    1.43

Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.00    0.60    0.00     2.40     0.00     8.00     0.00    0.67    0.67    0.00   0.67   0.04
vdb              14.80  5113.80 1087.60   60.60 78123.20 20697.60   172.13   123.17  106.45  106.26  109.87   0.87 100.00

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          10.06    0.00    7.19   79.06    0.00    3.70

Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.00    1.60    0.00   103.20     0.00   129.00     0.01    3.62    3.62    0.00   0.50   0.08
vdb              14.20  4981.20  992.80   52.60 79682.40 20135.20   190.97   120.34  112.19  110.60  142.17   0.96 100.00
  • 上傳日誌時
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           6.98    0.00    7.81    7.71    0.00   77.50

Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.00   13.40    0.00   242.40     0.00    36.18     0.02    1.63    1.63    0.00   0.19   0.26
vdb               0.40     2.40  269.60    1.20 67184.80    14.40   496.30     4.58   15.70   15.77    0.33   1.39  37.74

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           7.06    0.00    8.00    4.57    0.00   80.37

Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.00    0.60    0.00    75.20     0.00   250.67     0.00    2.67    2.67    0.00   2.00   0.12
vdb               0.20     0.00  344.80    0.00 65398.40     0.00   379.34     5.66   16.42   16.42    0.00   1.27  43.66

從 iostat 的結果中發現,壓縮時程式 r_await 和 w_await 都到了一百多,且 iowait 高達 80.88%,幾乎耗盡了所有的 CPU,上傳時 iowait 是可以接受的,因為只是單純的讀取壓縮檔案,且壓縮檔案也很小。

分析問題

上述結果中發現程式主要執行消耗在壓縮日誌,那優化也著重日誌壓縮的邏輯上。

壓縮時日誌會先壓縮成 lzo 檔案,然後再上傳 lzo 檔案到阿里雲 OSS 上,這中間發生了幾個過程:

  • 讀取原始日誌檔案
  • 壓縮資料
  • 寫入 lzo 檔案
  • 讀取 lzo 檔案
  • http 傳送讀取的內容

壓縮時 r_await 和 w_await 都很高,主要發生在讀取原始日誌檔案,寫入 lzo 檔案, 怎麼優化呢?

先想一下原始需求,讀取原始檔案 -> 上傳資料。但是直接上傳原始檔案,檔案比較大,網路傳輸慢,而且儲存費用也比較高,怎麼辦呢?

這個時候我們期望可以上傳的是壓縮檔案,所以就有了優化前的邏輯,這裡面產生了一箇中間過程,即使用 lzop 命令壓縮檔案,而且產生了一箇中間檔案 lzo 檔案。

讀取原始檔案和上傳資料是必須的,那麼可以優化的就是壓縮的流程了,所以 r_await 是沒有辦法優化的,那麼只能優化 w_awaitw_await 是怎麼產生的呢,恰恰是寫入lzo 時產生的,可以不要 lzo 檔案嗎?這個檔案有什麼作用?

如果我們壓縮檔案資料流,在 讀取原始檔案 -> 上傳資料 流程中對上傳的資料流進行實時壓縮,把壓縮的內容給上傳了,實現邊讀邊壓縮,對資料流進行處理,像是一箇中介軟體,這樣就不用寫 lzo 檔案了,那麼 w_await 就被完全優化沒了。

lzo 檔案有什麼作用?我想只有在上傳失敗之後可以節省一次檔案壓縮的消耗。上傳失敗的次數多嗎?我用阿里雲 OSS 好幾年了,除了一次內網故障,再也沒有遇到過上傳失敗的檔案,我想是不需要這個檔案的,而且生成 lzo 檔案還需要佔用磁碟空間,定時清理等等,增加了資源消耗和維護成本。

優化後

根據之前的分析看一下優化之後備份檔案需要哪些過程:

  • 讀取原始日誌
  • 在記憶體中壓縮資料流
  • http 傳送壓縮後的內容

這個流程節省了兩個步驟,寫入 lzo 檔案和 讀取 lzo 檔案,不僅沒有 w_await,就連 r_await 也得到了小幅度的優化。

優化方案確定了,可是怎麼實現 lzo 對檔案流進行壓縮呢,去 Github 上找一下看看有沒有 lzo 的壓縮演算法庫,發現 github.com/cyberdelia/lzo ,雖然是引用 C 庫實現的,但是經典的兩個演算法(lzo1x_1 和 lzo1x_999)都提供了介面,貌似 Go 可以直接用了也就這一個庫了。

發現這個庫實現了 io.Reader 和 io.Writer 介面,io.Reader 讀取壓縮檔案流,輸出解壓縮資料,io.Writer 實現輸入原始資料,並寫入到輸入的 io.Writer

想實現壓縮資料流,看來需要使用 io.Writer 介面了,但是這個輸入和輸出都是 io.Writer,這可為難了,因為我們讀取檔案獲得是 io.Reader,http 介面輸入也是 io.Reader,貌似沒有可以直接用的介面,沒有辦法實現了嗎,不會我們自已封裝一下,下面是封裝的 lzo 資料流壓縮方法:

package lzo

import (
    "bytes"
    "io"

    "github.com/cyberdelia/lzo"
)

type Reader struct {
    r    io.Reader
    rb   []byte
    buff *bytes.Buffer
    lzo  *lzo.Writer
    err  error
}

func NewReader(r io.Reader) *Reader {
    z := &Reader{
        r:    r,
        rb:   make([]byte, 256*1024),
        buff: bytes.NewBuffer(make([]byte, 0, 256*1024)),
    }

    z.lzo, _ = lzo.NewWriterLevel(z.buff, lzo.BestSpeed)
    return z
}

func (z *Reader) compress() {
    if z.err != nil {
        return
    }

    var nr, nw int
    nr, z.err = z.r.Read(z.rb)
    if z.err == io.EOF {
        if err := z.lzo.Close(); err != nil {
            z.err = err
        }
    }

    if nr > 0 {
        nw, z.err = z.lzo.Write(z.rb[:nr])
        if z.err == nil && nr != nw {
            z.err = io.ErrShortWrite
        }
    }
}

func (z *Reader) Read(p []byte) (n int, err error) {
    if z.err != nil {
        return 0, z.err
    }

    if z.buff.Len() <= 0 {
        z.compress()
    }

    n, err = z.buff.Read(p)
    if err == io.EOF {
        err = nil
    } else if err != nil {
        z.err = err
    }

    return
}

func (z *Reader) Reset(r io.Reader) {
    z.r = r
    z.buff.Reset()
    z.err = nil
    z.lzo, _ = lzo.NewWriterLevel(z.buff, lzo.BestSpeed)
}

這個庫會固定消耗 512k 記憶體,並不是很大,我們需要建立一個讀取 buf 和一個壓縮緩衝 buf, 都是 256k 的大小,實際壓縮緩衝的 buf 並不需要 256k,畢竟壓縮後資料會比原始資料小,考慮空間並不是很大,直接分配 256k 避免執行時分配。

實現原理當 http 從輸入的 io.Reader (實際就是我們上面封裝的 lzo 庫), 讀取資料時,這個庫檢查壓縮緩衝是否為空,為空的情況會從檔案讀取 256k 資料並壓縮輸入到壓縮緩衝中,然後從壓縮緩衝讀取資料給 http 的 io.Reader,如果壓縮緩衝區有資料就直接從壓縮緩衝區讀取壓縮資料。

這並不是執行緒安全的,並且固定分配 512k 的緩衝,所以也提供了一個 Reset 方法,來複用這個物件,避免重複分配記憶體,但是需要保證一個 lzo 物件例項只能被一個 Goroutine 訪問, 這可以使用 sync.Pool 來保證,下面的程式碼我用另一種方法來保證。

package main

import (
    "fmt"
    "os"
    "path/filepath"
    "sync"
    "time"

    ".../pkg/aliyun_oss"
    ".../pkg/lzo"
)

func main() {
    var oss *aliyun_oss.AliyunOSS
    files := os.Args[1:]
    if len(files) < 1 {
        fmt.Println("請輸入要上傳的檔案")
        os.Exit(1)
    }

    fmt.Printf("待備份檔案數量:%d\n", len(files))

    startTime := time.Now()
    defer func() {
        fmt.Printf("共耗時:%s\n", time.Now().Sub(startTime).String())
    }()

    var wg sync.WaitGroup
    n := 4
    c := make(chan string)

    // 壓縮日誌
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            var compress *lzo.Reader

            for file := range c {
                r, err := os.Open(file)
                if err != nil {
                    panic(err)
                }

                if compress == nil {
                    compress = lzo.NewReader(r)
                } else {
                    compress.Reset(r)
                }

                name := filepath.Base(file)
                err = oss.PutObject("tmp/"+name+"1.lzo", compress)
                r.Close()
                if err != nil {
                    panic(err)
                }
            }
        }()
    }

    for _, file := range files {
        c <- file
    }

    close(c)
    wg.Wait()
}

程式為每個 Goroutine 分配一個固定的 compress ,當需要壓縮檔案的時候判斷是建立還是重置,來達到複用的效果。

該程式執行輸出:

待備份檔案數量:336
共耗時 18m20.162441931s

實際耗時比優化前提升了 28%, 實際通過 iostat 命令分析也發現,資源消耗也有了明顯的改善,下面是 iostat -m -x 5 10000 命令採集各個階段資料。

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          15.72    0.00    6.58   74.10    0.00    3.60

Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.00    0.00    0.00     0.00     0.00     0.00     0.00    0.00    0.00    0.00   0.00   0.00
vdb               3.80     3.40 1374.20    1.20 86484.00    18.40   125.79   121.57   87.24   87.32    1.00   0.73 100.00

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          26.69    0.00    8.42   64.27    0.00    0.62

Device:         rrqm/s   wrqm/s     r/s     w/s    rkB/s    wkB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.20  426.80    0.80  9084.80     4.00    42.51     2.69    6.29    6.30    1.00   0.63  26.92
vdb               1.80     0.00 1092.60    0.00 72306.40     0.00   132.36   122.06  108.45  108.45    0.00   0.92 100.02

通過 iostat 發現只有 r_await, w_await 被完全優化,iowait 有明顯的改善,執行時間更短了,效率更高了,對 io 產生影響的時間也更短了。

優化期間遇到的問題

首先對找到的 lzo 演算法庫進行測試,確保壓縮和解壓縮沒有問題,並且和 lzop 命令相容。

在這期間發現使用壓縮的資料比 lzop 壓縮資料大了很多,之後閱讀了原始碼實現,並沒有發現任何問題,嘗試調整緩衝區大小,發現對生成的壓縮檔案大小有明顯改善。

這個發現讓我也很為難,究竟多大的緩衝區合適呢,只能去看 lzop 的實現了,發現 lzop 預設壓縮塊大小為 256k, 實際 lzo 演算法支援的最大塊大小就是 256k,所以實現 lzo 演算法包裝是建立的是 256k 的緩衝區的,這個緩衝區的大小就是壓縮塊的大小,大家使用的時候建議不要調整了。

總結

這個方案上線之後,由原來需要近半分鐘上傳的,改善到大約只有十秒(Go 語言本身效率也有很大幫助),而且 load 有了明顯的改善。

優化前每當執行日誌備份,CPU 經常爆表,優化後備份時 CPU 增幅 20%,可以從容應對業務擴充套件問題了。

測試是在一臺空閒的機器上進行的,實際生產伺服器本身 w_await 會有 20 左右,如果使用固態硬碟,全雙工模式,讀和寫是分離的,那麼優化掉 w_await 對業務的幫助是非常大的,不會阻塞業務日誌寫通道了。

當然我們伺服器是高速雲盤 (機械盤),由於機械盤物理特徵只能是半雙工,要麼讀、要麼寫,所以優化掉 w_await 確實效率會提升很多,但是依然會對業務服務寫有影響。

轉載:

本文作者: 戚銀(thinkeridea

本文連結: https://blog.thinkeridea.com/201906/go/compress_file_io_optimization1.html

版權宣告: 本部落格所有文章除特別宣告外,均採用 CC BY 4.0 CN 協議 許可協議。轉載請註明出處!

更多原創文章乾貨分享,請關注公眾號
  • 【Go】使用壓縮檔案優化 io (一)
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章