原文连接:blog.thinkeridea.com/201907/go/c…html
咱们业务天天须要记录大量的日志数据,且这些数据十分重要,它们是公司收入结算的主要依据,也是数据分析部门主要得数据源,针对这么重要的日志,且高频率的日志,咱们须要一个高性能且安全的日志组件,能保证每行日志格式完整性,咱们设计了一个类 csv 的日志拼接组件,它的代码在这里 datalog。git
它是一个能够保证日志各列完整性且高效拼接字段的组件,支持任意列和行分隔符,并且还支持数组字段,但是实现一对多的日志需求,不用记录多个日志,也不用记录多行。它响应一个 []byte
数据,方便结合其它主键写入数据到日志文件或者网络中。github
NewRecord(len int) Record
建立长度固定的日志记录shell
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 换行符, 使用原地替换会破坏日志字段引用的字符串ide
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)
本文连接: blog.thinkeridea.com/201907/go/c…
版权声明: 本博客全部文章除特别声明外,均采用 CC BY 4.0 CN协议 许可协议。转载请注明出处!