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