【Go】類似csv的資料日誌元件設計

qiyin發表於2019-07-21

原文連結: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 列的資料,但是在日誌中它仍然有一個佔位符,如果 usernilLogUidLogUserName 不需要特殊處理,也不需要寫入資料,它依然佔據自己的位置,不用擔心日誌因此而錯亂。

使用 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'

這樣在解析時可以把某一欄位當做陣列解析,這極大的極大的提高了資料日誌的靈活性, 但是並不建議使用過多的層級,資料日誌應當清晰簡潔,但是有些特殊場景可以使用一層巢狀。

最佳實踐

使用 ToBytesArrayFieldJoin 時會把資料欄位中的連線字串替換一個空字串,所以在 datalog 裡面定義了4個分隔符,它們都是不可見字元,極少會出現在資料中,但是我們還需要替換資料中的這些連線字元,避免破壞日誌結構。

雖然元件支援各種連線符,但是為了避免資料被破壞,我們應該選擇一些不可見且少見的單位元組字元作為分隔符。換行符比較特殊,因為大多數日誌讀取元件都是用 \n 作為行分隔符,如果資料中極少出現 \n 那就可以使用 \ndatalog 中定義 \x03\n 作為換行符,它相容一般的日誌讀取元件,只需要我們做少量的工作就可以正確的解析日誌了。

UnsafeToBytesUnsafeArrayFieldJoin 效能會更好,和它們的名字一樣,他們並不安全,因為它們使用 exbytes.Replace 做原地替換分隔符,這會破壞資料所指向的原始字串。除非我們日誌資料中會出現極多的分隔符需要替換,否者並不建議使用它們,因為它們只在替換時提升效能。

我在服務中大量使用 UnsafeToBytesUnsafeArrayFieldJoin ,我總是在一個請求結束時記錄日誌,我確保所有相關的資料不會再使用,所以不用擔心原地替換導致其它資料被無感知改變的問題,這也許是一個很好的實踐,但是我仍然不推薦使用它們。

設計講解

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)
}

UnsafeToBytesToBytes 相似只是沒有分割符檢查,因為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 進行連線字串,https://godoc.org/github.com/t ... 23Join)">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協議 許可協議。轉載請註明出處!

相關文章