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

原文鏈接: https://blog.thinkeridea.com/...

上一篇文章《使用壓縮文件優化io (一)》中記錄了日誌備份 io 優化方案,使用文件流數據壓縮方案優化 io 性能,效果十分顯著。這篇文章記錄數據分析前置清洗、格式化數據的 io 優化方案,我們有一臺專用的日誌前置處理服務器,所有業務日誌通過這臺機器從 OSS 拉取回來清洗、格式化,最後進入到數據倉儲中便於後續的分析。

隨着業務擴展這臺服務器壓力越來越大,高峯時數據延遲越來越厲害,早期也是使用 Python 腳本 + awk 以及一些 shell 命令完成相關工作,在數據集不是很大的時候這種方案很好,效率也很高,隨着數據集變大,發現服務器負載很高,經過分析是還是 io 阻塞,依舊採用對數據流進行處理的方案優化io,以下記錄優化的過程。

背景介紹

服務器配置:4 核 8G; 磁盤:1T

分析前置服務會根據業務不同分爲十分鐘、一小時兩個階段拉取分析日誌,每隔一個階段會去 OSS 拉取日誌回到服務器進行處理,處理過程因 io 阻塞,導致 CPU 和 load 異常高,且處理效率嚴重下降,這次優化主要就是降低 io 阻塞,提升 CPU 利用率 (處理業務邏輯而不是等待 io) 和處理效率。

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

  • 文件數量:432個
  • 壓縮文件:17G
  • 解壓後文件:63G
  • 壓縮方案:lzo
  • Goroutine 數量:20

優化前

優化前日誌處理流程:

  • 獲取待處理文件列表
  • 拉取 OSS 日誌到本地磁盤 (壓縮文件)
  • 解壓縮日誌文件
  • 讀取日誌數據
  • 業務處理……
  • 導入到數據倉儲中

導致 io 阻塞的部分主要是: 拉取 OSS 日誌、解壓縮日誌文件及讀取日誌數據,優化也主要從這三塊着手。

這裏有一段公共的日誌讀取方法,該方法接收一個 io.Reader, 並按行讀取日誌,並簡單切分日誌字段,並沒有實質的處理日誌數據,後面的優化方案也將使用這個方法讀取日誌。

package main

import (
    "bufio"
    "bytes"
    "io"

    "github.com/thinkeridea/go-extend/exbytes"
)

func Read(r io.Reader) {
    rawBuffer := make([]byte, 512)
    buf := bufio.NewReader(r)
    for {
        line, ok, err := readLine(buf, rawBuffer)
        if err == io.EOF {
            return
        }

        if err != nil {
            panic(nil)
        }

        if ok {
            rawBuffer = line
        }

        c := bytes.Count(line, []byte{'\x01'})
        if c != 65 {
            panic("無效的行")
        }
    }
}

func readLine(r *bufio.Reader, rawBuffer []byte) ([]byte, bool, error) {
    var ok bool
    line, err := r.ReadSlice('\n')
    if (err == bufio.ErrBufferFull || len(line) < 3 || exbytes.ToString(line[len(line)-3:]) != "\r\r\n") && err != io.EOF {
        rawBuffer = append(rawBuffer[:0], line...)
        for (err == bufio.ErrBufferFull || len(line) < 3 || exbytes.ToString(line[len(line)-3:]) != "\r\r\n") && err != io.EOF {
            line, err = r.ReadSlice('\n')
            rawBuffer = append(rawBuffer, line...)
        }
        line = rawBuffer
        ok = true
    }

    if len(line) > 0 && err == io.EOF {
        err = nil
    }

    return line, ok, err
}

日誌按 \r\r\n 分隔行,使用 \x01 切分字段,讀取方法使用 bufio.ReadSlice 方法,避免內存分配,且當 bufio 緩衝區滿之後使用 rwaBuffer 作爲本地可擴展緩衝,每次擴展之後會保留最大的擴展空間,因爲業務日誌每行大小差不多,這樣可以極大的減少內存分配,效率是 bufio.ReadLine 方法的好幾倍。

package main

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

    ".../pkg/aliyun_oss" // 虛假的包
)

func main() {
    var oss *aliyun_oss.AliyunOSS // 對接阿里雲 OSS,需要自行封裝
    files := list()               // 這是一個虛構的方法,用來獲取待處理的文件列表

    fmt.Printf("待處理文件數量:%d\n", len(files))

    start := time.Now()
    defer func(t time.Time) {
        fmt.Printf("共耗時:%0.6f\n", time.Now().Sub(t).Seconds())
    }(start)

    // 下載日誌文件
    n := 20
    var wg sync.WaitGroup
    c := make(chan string)
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            for {
                f, ok := <-c
                if !ok {
                    return
                }

                if _, err := os.Stat(f); err == nil {
                    return
                } else if !os.IsNotExist(err) {
                    panic(err)
                }

                dir := filepath.Dir(f)
                err := os.MkdirAll(dir, 0755)
                if err != nil {
                    panic(err)
                }

                err = oss.GetObjectToFile(f, f)
                if err != nil {
                    panic(err)
                }
            }
        }()
    }

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

    close(c)
    wg.Wait()

    fmt.Printf("下載文件耗時:%0.6f\n", time.Now().Sub(start).Seconds())

    // 解壓日誌文件
    start = time.Now()
    shell := exec.Command("/bin/bash", "-c", "lzop -df logs/*/*/*/*/*/*.lzo")
    err := shell.Run()
    if err != nil {
        panic(err)
    }

    fmt.Printf("解壓文件耗時:%0.6f\n", time.Now().Sub(start).Seconds())

    // 讀取日誌文件
    start = time.Now()
    c = make(chan string)
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            for {
                file, ok := <-c
                if !ok {
                    return
                }
                f, err := os.Open(file)
                if err != nil {
                    panic(err)
                }

                Read(f)
                f.Close()
            }
        }()
    }

    for _, f := range files {
        c <- strings.TrimRight(f, ".lzo")
    }

    close(c)
    wg.Wait()

    fmt.Printf("讀取文件耗時:%0.6f\n", time.Now().Sub(start).Seconds())
}

運行程序輸出如下:

待處理文件數量:432
下載文件耗時:303.562865
解壓文件耗時:611.236232
讀取文件耗時:460.371245
共耗時:1375.187261

通過 iostat -m -x 5 10000 分析各個階段結果如下:

  • 下載時:
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           7.85    0.00   16.44   11.24    0.00   64.48

Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.00   80.40    7.80     8.98     0.04   209.36     0.40    4.57    4.64    3.77   0.50   4.44
vdb               1.40   761.20  247.60  264.00    14.70    60.92   302.72     9.17   17.92   10.36   25.00   0.52  26.52
  • 解壓時:
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           8.54    0.00    8.33   68.39    0.00   14.74

Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     1.20    3.40   11.80     0.01     0.05     8.95     0.30   20.03    0.41   25.68   0.55   0.84
vdb               0.00 22037.80  107.80  243.20    26.45   107.01   778.71    83.52  236.68   74.31  308.65   2.52  88.54
  • 讀取時:
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           2.74    0.00    5.07   92.19    0.00    0.00

Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     2.40    3.80   23.60     0.01     0.14    11.85     0.12    4.48    1.95    4.89   0.33   0.90
vdb               1.80     4.60  347.20    6.20   139.97     0.08   811.60   126.62  358.04  360.79  203.48   2.83 100.00

通過 iostat 結果可以看出,在解壓和讀取日誌時 io 阻塞比較嚴重,且運行時間較長,下載時 io 阻塞也存在,但還可以接受,通過下面兩個方案逐漸消除掉 io

優化方案一

優化前的方案反應出在解壓和讀取日誌時 io 阻塞比較嚴重,那麼是否可以通過讀取 lzo 壓縮文件,以此來消除解壓縮日誌耗時太大、io 太高的問題呢?並且讀取 lzo 壓縮文件遠比解壓後文件小,來降低讀取日誌耗時太大、io 太高的問題呢?

優化後日志處理流程:

  • 獲取待處理文件列表
  • 拉取 OSS 日誌到本地磁盤 (壓縮文件)
  • 讀取壓縮日誌數據
  • 業務處理……
  • 導入到數據倉儲中
package main

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

    ".../pkg/aliyun_oss" // 虛假的包
    "github.com/cyberdelia/lzo"
)

func main() {
    var oss *aliyun_oss.AliyunOSS // 對接阿里雲 OSS,需要自行封裝
    files := list()               // 這是一個虛構的方法,用來獲取待處理的文件列表

    fmt.Printf("待處理文件數量:%d\n", len(files))
    start := time.Now()
    defer func(t time.Time) {
        fmt.Printf("共耗時:%0.6f\n", time.Now().Sub(t).Seconds())
    }(start)

    // 下載日誌文件
    n := 20
    var wg sync.WaitGroup
    c := make(chan string)
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            for {
                f, ok := <-c
                if !ok {
                    return
                }

                if _, err := os.Stat(f); err == nil {
                    return
                } else if !os.IsNotExist(err) {
                    panic(err)
                }

                dir := filepath.Dir(f)
                err := os.MkdirAll(dir, 0755)
                if err != nil {
                    panic(err)
                }

                err = oss.GetObjectToFile(f, f)
                if err != nil {
                    panic(err)
                }
            }
        }()
    }

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

    close(c)
    wg.Wait()

    fmt.Printf("下載文件耗時:%0.6f\n", time.Now().Sub(start).Seconds())

    start = time.Now()
    c = make(chan string)
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            for {
                file, ok := <-c
                if !ok {
                    return
                }
                f, err := os.Open(file)
                if err != nil {
                    panic(err)
                }

                r, err := lzo.NewReader(f)
                if err != nil {
                    panic(err)
                }

                Read(r)
                r.Close()
                f.Close()
            }
        }()
    }

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

    close(c)
    wg.Wait()

    fmt.Printf("讀取文件耗時:%0.6f\n", time.Now().Sub(start).Seconds())
}

這個方案消除了解壓縮日誌,並且直接讀取壓縮日誌,使用 github.com/cyberdelia/lzo 包對壓縮文件數據流進行邊讀取邊解壓,這次不用單獨封裝新的方法了,直接使用 lzo 包中的接口即可。

程序運行結果如下:

待處理文件數量:432
下載文件耗時:286.146603
讀取文件耗時:132.787345
共耗時:418.942862    

這個方案效果非常明顯,總耗時從 1375.187261 降低到 418.942862 提升了 3 倍的效率,不僅消除了壓縮的時間,還大大縮短了讀取文件耗時,成果顯著。

通過 iostat -m -x 5 10000 分析各個階段結果如下:

下載時:

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           5.08    0.00   13.24   29.34    0.00   52.33

Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     2.80    1.40   11.80     0.01     0.07    12.00     0.02    1.85    1.14    1.93   0.18   0.24
vdb               0.00 17207.60    0.60  212.40     0.00    75.06   721.74    55.81  236.34   84.33  236.77   2.49  53.14

讀取時:

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          80.66    0.00    4.83   14.50    0.00    0.00

Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     0.00    6.20    0.20     0.06     0.00    20.00     0.01    1.69    1.71    1.00   0.62   0.40
vdb               0.00     6.80  390.40   19.20   118.78     0.23   595.04    74.87  190.55  197.95   40.08   1.85  75.90

通過 iostat 結果分析,下載時 io 阻塞和優化前波動不是很大,讀取時的 io 優化已經非常好了,iowait92.19% 降低到 14.5% ,CPU 更多的任務用來處理解壓縮日誌,而不是處理 io 阻塞。

優化方案二

本來優化到上面的效果已經非常滿意了,不過既然開始做優化就不能草草結束了,仔細思考業務場景,需要 本地 lzo 文件?重新處理日誌的頻率高嗎?本地 lzo 日誌清理方便嗎?

通過上面的幾個問題發現,除非程序出現問題或者數據存儲出現故障,否者極少需要重新處理日誌,一年裏面這種情況也是極少的,甚至不會發生。

那麼思考一下,不下載日誌,直接讀取網絡數據流,實現邊下邊解壓邊讀取,這樣豈不是沒有 io 了嗎?

優化後日志處理流程:

  • 獲取待處理文件列表
  • 拉取 OSS 日誌,在內存中解壓並讀取分析日誌
  • 業務處理……
  • 導入到數據倉儲中

具體實現如下:

package main

import (
    "fmt"
    "sync"
    "time"

    ".../pkg/aliyun_oss" // 虛假的包
    "github.com/cyberdelia/lzo"
)

func main() {
    var oss *aliyun_oss.AliyunOSS // 對接阿里雲 OSS,需要自行封裝
    files := list()               // 這是一個虛構的方法,用來獲取待處理的文件列表

    fmt.Printf("待處理文件數量:%d\n", len(files))

    start := time.Now()
    defer func(t time.Time) {
        fmt.Printf("共耗時:%0.6f\n", time.Now().Sub(t).Seconds())
    }(start)

    n := 20
    var wg sync.WaitGroup
    c := make(chan string)
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            for {
                f, ok := <-c
                if !ok {
                    return
                }

                r1, err := oss.GetObject(f)
                if err != nil {
                    panic(err)
                }

                r, err := lzo.NewReader(r1)
                if err != nil {
                    panic(err)
                }

                Read(r)
                r.Close()
                r1.Close()
            }
        }()
    }

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

    close(c)
    wg.Wait()

    fmt.Printf("讀取文件耗時:%0.6f\n", time.Now().Sub(start).Seconds())
}

優化後只有一個流程了,代碼簡潔了不少,看看效率如何吧!

程序運行結果如下:

待處理文件數量:432
讀取文件耗時:285.993661
共耗時:285.993717

天啊發生了什麼,我使勁擦了擦眼睛,太不可思議了,居然只消耗了下載日誌的耗時,較上一個方案總耗時從 418.942862 降低到 285.993717,提升了近 2 倍的效率,讓我們看看上個方案下載文件耗時 286.146603 ,而新方案總耗時是 285.993717 居然只用了上個優化版本的下載時間,究竟發生了什麼?

通過 iostat -m -x 5 10000 分析結果如下:

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
          43.73    0.00    9.64    0.31    0.00   46.32

Device:         rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda               0.00     1.20    4.40    3.80     0.02     0.03    10.93     0.01    1.49    0.59    2.53   0.71   0.58
vdb               0.00     6.80    0.00   24.60     0.00     0.27    22.83     0.02    0.84    0.00    0.84   0.28   0.68

通過 iostat 結果分析,在程序運行期間沒有任何 io 開銷,CPU 居然還有一半的空閒,前面兩個版本 CPU 是沒有空閒的啊,由此看來之前 CPU 更多的消耗在 io 阻塞上了,並沒有用來處理業務邏輯。

由此來看也就不足爲奇了,爲啥優化後只需要下載日誌的時間就能處理完所有日誌了,沒有了 io 阻塞,CPU 更多了用來處理業務,把之前下載時寫文件 io 的耗時,用來解壓縮數據,讀取數據,且還有更多的空閒,跑出這樣的結果也就很正常了。

總結

從優化前耗時 1375.187261 秒到 285.993717 秒,性能提升 80%, 從 iowait 92.19%0.31% 提升近 100%,從沒有任何 CPU 空閒到有一半空閒,這個過程中有很多值得總結的事情。

io 對性能的影響非常大,對 CPU 佔用非常嚴重,導致 CPU 處理業務邏輯的時間片非常少。從 io 轉移到 CPU 對性能提升非常明顯。CPU 計算效率十分的高,從 io 密集到密集計算,只要符合業務場景,往往能給我們帶來意想不到的效果。

往往優化業務並不需要十分高大上的技術,只是轉變一下思路,不僅代碼更少,且程序更簡短、好維護、邏輯更清晰。

一定要結合實際業務場景進行思考,減少理所當然和業務無關的中間環節,往往就可以極大的提升程序效率。

轉載:

本文作者: 戚銀(thinkeridea

本文鏈接: https://blog.thinkeridea.com/201907/go/compress_file_io_optimization2.html

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

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