【Go】類似csv的資料日誌元件設計
原文連結:https://blog.thinkeridea.com/201907/go/csv_like_data_logs.html
我們業務每天需要記錄大量的日誌資料,且這些資料十分重要,它們是公司收入結算的主要依據,也是資料分析部門主要得資料來源,針對這麼重要的日誌,且高頻率的日誌,我們需要一個高效能且安全的日誌元件,能保證每行日誌格式完整性,我們設計了一個類 csv 的日誌拼接元件,它的程式碼在這裡 datalog。
它是一個可以保證日誌各列完整性且高效拼接欄位的元件,支援任意列和行分隔符,而且還支援陣列欄位,可是實現一對多的日誌需求,不用記錄多個日誌,也不用記錄多行。它響應一個 []byte
資料,方便結合其它主鍵寫入資料到日誌檔案或者網路中。
使用說明
API 列表
NewRecord(len int) Record
建立長度固定的日誌記錄NewRecordPool(len int) *sync.Pool
建立長度固定的日誌記錄快取池ToBytes(sep, newline string) []byte
使用 sep 連線 Record,並在末尾新增 newline 換行符ArrayJoin(sep string) string
使用 sep 連線 Record,其結果作為陣列欄位的值ArrayFieldJoin(fieldSep, arraySep string) string
使用 fieldSep 連線 Record,其結果作為一個數組的單元Clean()
清空 Record 中的所有元素,如果使用 sync.Pool 在放回 Pool 之前應該清空 Record,避免記憶體洩漏UnsafeToBytes(sep, newline string) []byte
使用 sep 連線 Record,並在末尾新增 newline 換行符, 使用原地替換會破壞日誌欄位引用的字串UnsafeArrayFieldJoin(fieldSep, arraySep string) string
使用 fieldSep 連線 Record,其結果作為一個數組的單元, 使用原地替換會破壞日誌欄位引用的字串
底層使用 type Record []string
NewRecord(len int) Record
或者 NewRecordPool(len int) *sync.Pool
建立元件,我建議每個日誌使用 NewRecordPool
在程式初始化時建立一個快取池,程式執行時從快取次獲取 Record
將會更加高效,但是每次放回 Pool
時需要呼叫 Clean
清空 Record
避免引用字串無法被回收,而導致記憶體洩漏。
實踐
我們需要保證日誌每列資料的含義一至,我們建立了定長的 Record
,但是如何保證每列資料一致性,利用go 的常量列舉可以很好的保證,例如我們定義日誌列常量:
const (
LogVersion = "v1.0.0"
)
const (
LogVer = iota
LogTime
LogUid
LogUserName
LogFriends
LogFieldNumber
)
LogFieldNumber
就是日誌的列數量,也就是 Record
的長度,之後使用 NewRecordPool
建立快取池,然後使用常量名稱作為下標記錄日誌,這樣就不用擔心因為檢查或者疏乎導致日誌列錯亂的問題了。
var w bytes.Buffer // 一個日誌寫元件
var pool = datalog.NewRecordPool(LogFieldNumber) // 建立一個快取池
func main() {
r := pool.Get().(datalog.Record)
r[LogVer] = LogVersion
r[LogTime] = time.Now().Format("2006-01-02 15:04:05")
// 檢查使用者資料是否存在
//if user !=nil{
r[LogUid] = "Uid"
r[LogUserName] = "UserNmae"
//}
// 拼接一行日誌資料
data := r.Join(datalog.FieldSep, datalog.NewLine)
r.Clean() // 清空 Record
pool.Put(r) // 放回到快取池
// 寫入到日誌中
if _, err := w.Write(data); err != nil {
panic(err)
}
// 打印出日誌資料
fmt.Println("'" + w.String() + "'")
}
以上程式執行會輸出:
因為分隔符是不可見字元,下面使用,代替欄位分隔符,使用;\n代替換行符, 使用/代替陣列欄位分隔符,是-代替陣列分隔符。
'v1.0.0,2019-07-18,11:39:09,Uid,UserNmae,;\n'
即使我們沒有記錄 LogFriends
列的資料,但是在日誌中它仍然有一個佔位符,如果 user
是 nil
,LogUid
和 LogUserName
不需要特殊處理,也不需要寫入資料,它依然佔據自己的位置,不用擔心日誌因此而錯亂。
使用 pool 可以很好的利用記憶體,不會帶來過多的記憶體分配,而且 Record 的每個欄位值都是字串,簡單的賦值並不會帶來太大的開銷,它會指向字串本身的資料,不會有額外的記憶體分配,詳細參見string 優化誤區及建議。
使用 Record.Join
可以高效的連線一行日誌記錄,便於我們快速的寫入的日誌檔案中,後面設計講解部分會詳細介紹 Join
的設計。
包含陣列的日誌
有時候也並非都是記錄一些單一的值,比如上面 LogFriends 會記錄當前記錄相關的朋友資訊,這可能是一組資料,datalog 也提供了一些簡單的輔助函式,可以結合下面的例項實現:
// 定義 LogFriends 陣列各列的資料
const (
LogFriendUid = iota
LogFriendUserName
LogFriendFieldNumber
)
var w bytes.Buffer // 一個日誌寫元件
var pool = datalog.NewRecordPool(LogFieldNumber) // 每行日誌的 pool
var frPool = datalog.NewRecordPool(LogFriendFieldNumber) // LogFriends 陣列欄位的 pool
func main(){
// 程式執行時
r := pool.Get().(datalog.Record)
r[LogVer] = LogVersion
r[LogTime] = time.Now().Format("2006-01-02 15:04:05")
// 檢查使用者資料是否存在
//if user !=nil{
r[LogUid] = "Uid"
r[LogUserName] = "UserNmae"
//}
// 拼接一個數組欄位,其長度是不固定的
r[LogFriends] = GetLogFriends(rand.Intn(3))
// 拼接一行日誌資料
data := r.Join(datalog.FieldSep, datalog.NewLine)
r.Clean() // 清空 Record
pool.Put(r) // 放回到快取池
// 寫入到日誌中
if _, err := w.Write(data); err != nil {
panic(err)
}
// 打印出日誌資料
fmt.Println("'" + w.String() + "'")
}
// 定義一個函式來拼接 LogFriends
func GetLogFriends(friendNum int) string {
// 根據陣列長度建立一個 Record,陣列的個數往往是不固定的,它整體作為一行日誌的一個欄位,所以並不會破壞資料
fs := datalog.NewRecord(friendNum)
// 這裡只需要中 pool 中獲取一個例項,它可以反覆複用
fr := frPool.Get().(datalog.Record)
for i := 0; i < friendNum; i++ {
// fr.Clean() 如果不是每個欄位都賦值,應該在使用前或者使用後清空它們便於後面複用
fr[LogFriendUid] = "FUid"
fr[LogFriendUserName] = "FUserName"
// 連線一個數組中各個欄位,作為一個數組單元
fs[i] = fr.ArrayFieldJoin(datalog.ArrayFieldSep, datalog.ArraySep)
}
fr.Clean() // 清空 Record
frPool.Put(fr) // 放回到快取池
// 連線陣列的各個單元,返回一個字串作為一行日誌的一列
return fs.ArrayJoin(datalog.ArraySep)
}
以上程式執行會輸出:
因為分隔符是不可見字元,下面使用,代替欄位分隔符,使用;\n代替換行符, 使用/代替陣列欄位分隔符,是-代替陣列分隔符。
'v1.0.0,2019-07-18,11:39:09,Uid,UserNmae,FUid/FUserName-FUid/FUserName;\n'
這樣在解析時可以把某一欄位當做陣列解析,這極大的極大的提高了資料日誌的靈活性,
但是並不建議使用過多的層級,資料日誌應當清晰簡潔,但是有些特殊場景可以使用一層巢狀。
最佳實踐
使用 ToBytes
和 ArrayFieldJoin
時會把資料欄位中的連線字串替換一個空字串,所以在 datalog 裡面定義了4個分隔符,它們都是不可見字元,極少會出現在資料中,但是我們還需要替換資料中的這些連線字元,避免破壞日誌結構。
雖然元件支援各種連線符,但是為了避免資料被破壞,我們應該選擇一些不可見且少見的單位元組字元作為分隔符。換行符比較特殊,因為大多數日誌讀取元件都是用 \n
作為行分隔符,如果資料中極少出現 \n
那就可以使用 \n
, datalog 中定義 \x03\n
作為換行符,它相容一般的日誌讀取元件,只需要我們做少量的工作就可以正確的解析日誌了。
UnsafeToBytes
和 UnsafeArrayFieldJoin
效能會更好,和它們的名字一樣,他們並不安全,因為它們使用 exbytes.Replace 做原地替換分隔符,這會破壞資料所指向的原始字串。除非我們日誌資料中會出現極多的分隔符需要替換,否者並不建議使用它們,因為它們只在替換時提升效能。
我在服務中大量使用 UnsafeToBytes
和 UnsafeArrayFieldJoin
,我總是在一個請求結束時記錄日誌,我確保所有相關的資料不會再使用,所以不用擔心原地替換導致其它資料被無感知改變的問題,這也許是一個很好的實踐,但是我仍然不推薦使用它們。
設計講解
datalog 並沒有提供太多的約束很功能,它僅僅包含一種實踐和一組輔助工具,在使用它之前,我們需要了解這些實踐。
它幫我們建立一個定長的日誌行或者一個sync.Pool
,我們需要結合常量列舉記錄資料,它幫我們把各列資料連線成記錄日誌需要的資料格式。
它所提供的輔助方法都經過實際專案的考驗,考量諸多細節,以高效能為核心目標所設計,使用它可以極大的降低相關元件的開發成本,接下來這節將分析它的各個部分。
我認為值得說道的是它提供的一個 Join
方法,相對於 strings.Join
可以節省兩次的記憶體分配,現從它開始分析。
// Join 使用 sep 連線 Record, 並在末尾追加 suffix
// 這個類似 strings.Join 方法,但是避免了連線後追加字尾(往往是換行符)導致的記憶體分配
// 這個方法直接返回需要的 []byte 型別, 可以減少型別轉換,降低記憶體分配導致的效能問題
func (l Record) Join(sep, suffix string) []byte {
if len(l) == 0 {
return []byte(suffix)
}
n := len(sep) * (len(l) - 1)
for i := 0; i < len(l); i++ {
n += len(l[i])
}
n += len(suffix)
b := make([]byte, n)
bp := copy(b, l[0])
for i := 1; i < len(l); i++ {
bp += copy(b[bp:], sep)
bp += copy(b[bp:], l[i])
}
copy(b[bp:], suffix)
return b
}
日誌元件往往輸入的引數是 []byte
型別,所以它直接返回一個 []byte
,而不像 strings.Join
響應一個字串,在末尾是需要對內部的 buf
進行型別轉換,導致額外的記憶體開銷。我們每行日誌不僅需要使用分隔符連線各列,還需要一個行分隔符作為結尾,它提供一個字尾 suffix
,不用我們之後在 Join
結果後再次拼接行分隔符,這樣也能減少一個額外的記憶體分配。
這恰恰是 datalog 設計的精髓,它並沒有大量使用標準庫的方法,而是設計更符合該場景的方法,以此來獲得更高的效能和更好的使用體驗。
// ToBytes 使用 sep 連線 Record,並在末尾新增 newline 換行符
// 注意:這個方法會替換 sep 與 newline 為空字串
func (l Record) ToBytes(sep, newline string) []byte {
for i := len(l) - 1; i >= 0; i-- {
// 提前檢查是否包含特殊字元,以便跳過字串替換
if strings.Index(l[i], sep) < 0 && strings.Index(l[i], newline) < 0 {
continue
}
b := []byte(l[i]) // 這會重新分配記憶體,避免原地替換導致引用字串被修改
b = exbytes.Replace(b, exstrings.UnsafeToBytes(sep), []byte{' '}, -1)
b = exbytes.Replace(b, exstrings.UnsafeToBytes(newline), []byte{' '}, -1)
l[i] = exbytes.ToString(b)
}
return l.Join(sep, newline)
}
ToBytes
作為很重要的互動函式,也是該元件使用頻率最高的函式,它在連線各個欄位之前替換每個欄位中的欄位和行分隔符,這裡提前做了一個檢查欄位中是否包含分隔符,如果包含使用 []byte(l[i])
拷貝該列的資料,然後使用 exbytes.Replace 提供高效能的原地替換,因為輸入資料是拷貝重新分配的,所以不用擔心原地替換會影響其它資料。
之後使用之前介紹的 Join
方法連線各列資料,如果使用 strings.Join
將會是 []byte(strings.Join([]string(l), sep) + newline)
這其中會增加很多次記憶體分配,該元件通過巧妙的設計規避這些額外的開銷,以提升效能。
// UnsafeToBytes 使用 sep 連線 Record,並在末尾新增 newline 換行符
// 注意:這個方法會替換 sep 與 newline 為空字串,替換採用原地替換,這會導致所有引用字串被修改
// 必須明白其作用,否者將會導致意想不到的結果。但是這會大幅度減少記憶體分配,提升程式效能
// 我在專案中大量使用,我總是在請求最後記錄日誌,這樣我不會再訪問引用的字串
func (l Record) UnsafeToBytes(sep, newline string) []byte {
for i := len(l) - 1; i >= 0; i-- {
b := exstrings.UnsafeToBytes(l[i])
b = exbytes.Replace(b, exstrings.UnsafeToBytes(sep), []byte{' '}, -1)
b = exbytes.Replace(b, exstrings.UnsafeToBytes(newline), []byte{' '}, -1)
l[i] = exbytes.ToString(b)
}
return l.Join(sep, newline)
}
UnsafeToBytes
和 ToBytes
相似只是沒有分割符檢查,因為exbytes.Replace 中已經包含了檢查,而且直接使用 exstrings.UnsafeToBytes 把字串轉成 []byte
這不會發生資料拷貝,非常的高效,但是它不支援字面量字串,不過我相信日誌中的資料均來自執行時分配,如果不幸包含字面量字串,也不用太過擔心,只要使用一個特殊的字元作為分隔符,往往我們程式設計字面量字串並不會包含這些字元,執行 exbytes.Replace 沒有發生替換也是安全的。
// Clean 清空 Record 中的所有元素,如果使用 sync.Pool 在放回 Pool 之前應該清空 Record,避免記憶體洩漏
// 該方法沒有太多的開銷,可以放心的使用,只是為 Record 中的欄位賦值為空字串,空字串會在編譯時處理,沒有額外的記憶體分配
func (l Record) Clean() {
for i := len(l) - 1; i >= 0; i-- {
l[i] = ""
}
}
Clean
方法更簡單,它只是把各個列的資料替換為空字串,空字串做為一個特殊的字元,會在編譯時處理,並不會有額外的開銷,它們都指向同一塊記憶體。
// ArrayJoin 使用 sep 連線 Record,其結果作為陣列欄位的值
func (l Record) ArrayJoin(sep string) string {
return exstrings.Join(l, sep)
}
// ArrayFieldJoin 使用 fieldSep 連線 Record,其結果作為一個數組的單元
// 注意:這個方法會替換 fieldSep 與 arraySep 為空字串,替換採用原地替換
func (l Record) ArrayFieldJoin(fieldSep, arraySep string) string {
for i := len(l) - 1; i >= 0; i-- {
// 提前檢查是否包含特殊字元,以便跳過字串替換
if strings.Index(l[i], fieldSep) < 0 && strings.Index(l[i], arraySep) < 0 {
continue
}
b := []byte(l[i]) // 這會重新分配記憶體,避免原地替換導致引用字串被修改
b = exbytes.Replace(b, exstrings.UnsafeToBytes(fieldSep), []byte{' '}, -1)
b = exbytes.Replace(b, exstrings.UnsafeToBytes(arraySep), []byte{' '}, -1)
l[i] = exbytes.ToString(b)
}
return exstrings.Join(l, fieldSep)
}
ArrayFieldJoin
在連線各個字串時會直接替換陣列單元分隔符,之後直接使用 exstrings.Join 進行連線字串,exstrings.Join 相對 strings.Join
的一個改進函式,因為它只有一次記憶體分配,較 strings.Join
節省一次,有興趣可以去看它的原始碼實現。
總結
datalog 提供了一種實踐以及一些輔助工具,可以幫助我們快速的記錄資料日誌,更關心資料本身。具體程式效能可以交給 datalog 來實現,它保證程式的效能。
後期我會計劃提供一個高效的日誌讀取元件,以便於讀取解析資料日誌,它較與一般檔案讀取會更加高效且便捷,有針對性的優化日誌解析效率,敬請關注吧。
轉載:
本文作者: 戚銀(thinkeridea)
本文連結: https://blog.thinkeridea.com/201907/go/csv_like_data_logs.html
版權宣告: 本部落格所有文章除特別宣告外,均採用 CC BY 4.0 CN協議 許可協議。轉載請註明出處