【Go】使用壓縮文件優化io (一)

原文連接: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_awaitw_await 都到了一百多,且 iowait 高達 80.88%,幾乎耗盡了所有的 CPU,上傳時 iowait 是可以接受的,因爲只是單純的讀取壓縮文件,且壓縮文件也很小。

分析問題

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

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

  • 讀取原始日誌文件
  • 壓縮數據
  • 寫入 lzo 文件
  • 讀取 lzo 文件
  • http 發送讀取的內容

壓縮時 r_awaitw_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_1lzo1x_999)都提供了接口,貌似 Go 可以直接用了也就這一個庫了。

發現這個庫實現了 io.Readerio.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_awaitw_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協議 許可協議。轉載請註明出處!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章