【Go】使用壓縮檔案優化io (一)
原文連線:https://blog.thinkeridea.com/201906/go/compress_file_io_optimization1.html
最近遇到一個日誌備份 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_await
,w_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協議 許可協議。轉載請註明出處