美文网首页
InfluxDB的WAL设计

InfluxDB的WAL设计

作者: rickif | 来源:发表于2020-08-05 16:52 被阅读0次

    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构成的列表了。列表每个元素由TimeValue两部分构成。分别表示value点对应的unix时间戳(精确到ns)和value本身的二进制表示。
    当然,直接序列化成二进制是仅仅不够的。对于序列化后的entry,采用snappy对其进行压缩。

    文件命名方式

    WAL文件的命名方式采用_[segment_id].WAL的形式,其中segment_id是单调递增的文件编号。编号的单调递增特性很重要,便于通过文件名即可得到当前最新的WAL文件。在WAL文件大小不超出限制的条件下,最新的WAL文件就是下一条日志要写入的文件。

    日志写入

    WAL日志的写入入口函数是WriteMulti,每次可写入多个key的数据,每个key对应的也是一个value列表。

    WAL写入流程

    日志的写入流程比较简单清晰:

    • 数据编码&压缩。需要注意的是,数据的压缩编码需要频繁的内存申请和释放。因此这里采用了内存池的方式避免大量对象分配。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标志位可以分为两类:

    1. 对于成功设置了标志位的请求,会异步开启一个协程,将buffer中所有的entry数据写到文件中,并将写文件的结果(成功/失败)发送到每个entry对应的error channel中。在此过程中会对WAL加排他锁,因此不会再有新的entry进入到buffer中。所有entry数据写完成后,异步协程重置syncCount标志位并退出。在异步开启写文件协程后,当前执行协程会阻塞在error channel上等待entry写入文件的结果,并将结果返回给上层调用
    2. 对于未能够设置标志位的请求,通过error channel等待其entry被写入文件

    因此,我们可以看到在任意时刻都只会有1个协程在进行写WAL操作(针对单个存储引擎而言),写入请求只需等待异步协程的写入结果。这里的设计与rocksdb的WAL提交会选取一个leader,leader执行实际的提交操作,确实有几分相似。一方面降低了并发写WAL的锁冲突,另一方面在保证返回时间的前提下也实现了批量写文件,从而提升性能。

    相关文章

      网友评论

          本文标题:InfluxDB的WAL设计

          本文链接:https://www.haomeiwen.com/subject/imgkrktx.html