WAL(Write Ahead Log)是一种常用的实现crash-safe的技术,通过将随机写转化为顺序写的形式,在保证写入高性能的前提下,把对数据的修改以日志的形式持久化存储在磁盘上。在部分数据库上,WAL也被用于实现数据复制。InfluxDB
的WAL日志设计简单直观,特此整理记录其设计。本文基于InfluxDB v2.0.0-beta.15
的代码实现展开。
日志格式
WAL日志entry格式WAL日志由若干个WriteWALEntry
组成,如图所示。一个entry可以容纳多个value。Type
表示所存储的values类型,可以是如下5种类型:
float64EntryType
integerEntryType
unsignedEntryType
booleanEntryType
stringEntryType
Key Len
表示存储的values所对应key的二进制长度,Key
表示存储的values所对应的key的二进制序列。Count
表示一共存储的value数量。在Count
以后,就是Count
个value构成的列表了。列表每个元素由Time
和Value
两部分构成。分别表示value点对应的unix时间戳(精确到ns)和value本身的二进制表示。
当然,直接序列化成二进制是仅仅不够的。对于序列化后的entry,采用snappy对其进行压缩。
文件命名方式
WAL文件的命名方式采用_[segment_id].WAL
的形式,其中segment_id
是单调递增的文件编号。编号的单调递增特性很重要,便于通过文件名即可得到当前最新的WAL文件。在WAL文件大小不超出限制的条件下,最新的WAL文件就是下一条日志要写入的文件。
日志写入
WAL日志的写入入口函数是WriteMulti
,每次可写入多个key
的数据,每个key
对应的也是一个value列表。
日志的写入流程比较简单清晰:
- 数据编码&压缩。需要注意的是,数据的压缩编码需要频繁的内存申请和释放。因此这里采用了内存池的方式避免大量对象分配。
go
语言作为带gc能力的语言,避免不必要的海量内存分配是提升系统性能的非常重要的手段。 - 日志文件滚动(roll segment)。判断日志是否需要滚动的条件是写入当前文件及其buffer的数据量是否超过阈值10MB。对于需要滚动的场景,WAL旧文件句柄会被关闭,关闭之前buffer中的所有的数据将会flush到磁盘。随后用自增后的文件编号新建新的WAL文件。也就是说,如果当前的WAL文件名是
_00002.WAL
,那么滚动后的WAL文件名是_00003.WAL
。 - 写buffer。每个WAL文件会持有一个写文件buffer,初始大小为16KB。为了提升写WAL的速率,所有的写文件操作都是直接写入buffer,并不会同步flush到磁盘。
- sync数据。如果数据只是单纯写到内存buffer就返回请求,那么必然会导致crash-recovery场景下的数据丢失。但如果每一次写请求都进行一次磁盘flush,数据的安全性得到保证。但是同时也会造成io次数急剧升高,相应的写入性能会大大受到影响。因此,在保证每条日志成功flush到磁盘后写入请求才返回的前提下,增大每次flush磁盘的batch大小同时保证单次请求的响应时间是sync的设计目标。
InfluxDB
的WAL日志刷盘跟rocksdb的WAL的group commit
中的日志写文件的过程类似,一个简化的刷盘流程如下面的代码所示:
func (l *WAL) scheduleSync() {
// 原子变量标志位,任意时刻只允许有一个协程在写WAL文件
// If we're not the first to sync, then another goroutine is fsyncing the wal for us.
if !atomic.CompareAndSwapUint64(&l.syncCount, 0, 1) {
return
}
// Fsync the wal and notify all pending waiters
go func() {
for {
l.mu.Lock()
if len(l.syncWaiters) == 0 {
// 所有的buffer数据写完后,清空标志位然后退出
atomic.StoreUint64(&l.syncCount, 0)
l.mu.Unlock()
return
}
// 将buffer中的数据写入WAL文件
l.sync()
l.mu.Unlock()
}
}()
}
func (l *WAL) sync() {
err := l.currentSegmentWriter.sync()
for len(l.syncWaiters) > 0 {
// 通过channel通知所有的等待写WAL日志成功的请求
errC := <-l.syncWaiters
errC <- err
}
}
所有的写入请求在写入buffer后,会将一个error channel
放入WAL的syncWaiters
队列中。当然,syncWaiter
本身也是一个channel
。每个请求放入的error channel
本质上是为了获取请求自己的entry写入WAL文件的结果。
每个写入请求都会调用sheduleSync
函数,写入请求按照是否成功设置了syncCount
标志位可以分为两类:
- 对于成功设置了标志位的请求,会异步开启一个协程,将buffer中所有的
entry
数据写到文件中,并将写文件的结果(成功/失败)发送到每个entry
对应的error channel
中。在此过程中会对WAL加排他锁,因此不会再有新的entry
进入到buffer中。所有entry
数据写完成后,异步协程重置syncCount
标志位并退出。在异步开启写文件协程后,当前执行协程会阻塞在error channel
上等待entry
写入文件的结果,并将结果返回给上层调用 - 对于未能够设置标志位的请求,通过
error channel
等待其entry
被写入文件
因此,我们可以看到在任意时刻都只会有1个协程在进行写WAL操作(针对单个存储引擎而言),写入请求只需等待异步协程的写入结果。这里的设计与rocksdb
的WAL提交会选取一个leader,leader执行实际的提交操作,确实有几分相似。一方面降低了并发写WAL的锁冲突,另一方面在保证返回时间的前提下也实现了批量写文件,从而提升性能。
网友评论