目录
概述
WAL
(Write Ahead Log)很久就听说过,但是一直不理解怎么实现的,搜索的时候翻到一个写数据库的damo程序,看了一下,总结下WAL
的实现。参考的文章为Writing a Database: Learning By Doing,这个项目的代码地址为https://github.com/danchia/ddb.
WAL
的大致思想是在将数据写到数据库之前,先将原始数据(raw record)写到磁盘上,防止在数据落盘之前系统重启了。一般在数据在写到数据库之前需要进行额外处理,比如压缩、格式化等。应用在处理数据之前,先放到内存,然后就给客户端返回成功。如果只是这样,系统一旦重启,内存里的数据就没了,这样数据就丢失了,这个时候就需要WAL,在写内存之前,先将请求的裸数据(全部请求信息),直接写到磁盘普通文件上,基本不做什么处理,然后再写入内存,内存里的数据经处理后,写入数据库,这个时候对应记录的wal就可以删除了。
系统在启动的时候,首先检查WAL,如果WAL存在比数据库新的内容,则将WAL的数据放入内存,并进行处理,然后同步到数据库。
大概画一下这个这个项目Writing a Database: Learning By Doing涉及到的几个模块,以及工作流程。
- memtable,数据写入数据库之前的内存缓存,积攒到一定量才写入数据库
- wal,wal模块,将写请求、删除请求记录成一个record,然后写入磁盘文件。
- sst,大概就是数据库实现(这个我没有细看,本文只是记录wal思想)
整个流程就如下所示了,逻辑还是比较简单。但是细节我没有注意,应该还是要注意的。
写入wal
写入的代码如下,LogRecord
是原始数据,cb func(error)
是写入WAL之后的回调函数,其中formRecord
是根据原始数据生成一个record日志,然后写入recordCh
通道,这个是此通道生产者。通道的消费者就从通道中取出消息,然后写入文件(另外需要注意,写文件的时候一定要flush
,避免只写入buffer cache),
func (w *Writer) Append(l *pb.LogRecord, cb func(error)) {
glog.V(2).Infof("wal.Append %v", l)
w.mu.Lock()
defer w.mu.Unlock()
r, err := w.formRecord(l)
if err != nil {
cb(err)
}
r.cb = cb
w.recordCh <- r
}
通道的消费者从通道读数据,并写入磁盘,如果写入磁盘出问题,则通过回调函数返回,否则,执行回调函数。
select {
case r := <-w.recordCh:
if err := w.writeRawRecord(r); err != nil {
r.cb(err)
} else {
callbacks = append(callbacks, r.cb)
}
case <-w.closeCh:
break Main
}
回调函数定义如下,apply是写入内存
,maybeTriggerFlush
是看看内存写了多少了,多的话就写入数据库。
func(err error) {
trace.FromContext(ctx).Annotate(nil, "appending log done")
if err != nil {
ch <- err
return
}
d.mu.Lock()
d.apply(l)
d.maybeTriggerFlush()
d.mu.Unlock()
ch <- nil
}
恢复
启动时,从数据库拿最新的序号,跟wal的序号做对比,如果wal的序号比数据库的序号新,则将wal写入内存。
lastAppliedSeqNo := int64(0)
for _, sstMeta := range descriptor.Current.SstMeta {
if sstMeta.AppliedUntil > lastAppliedSeqNo {
lastAppliedSeqNo = sstMeta.AppliedUntil
}
sstReader, err := sst.NewReader(filepath.Join(opts.SstDir, sstMeta.Filename), db.blockCache)
if err != nil {
glog.Fatalf("Error while opening SST: %v", err)
}
db.ssts = append(db.ssts, sstReader)
}
db.memtable = memtable.New(lastAppliedSeqNo)
nextSeq, err := db.recoverLog(lastAppliedSeqNo)
if err != nil {
glog.Fatalf("Failed to recover log file: %v", err)
}
校验
校验是将待写入磁盘的数据进行hash,并将hash结果同样写入磁盘(写入头8个字节),进行读操作,或者传输时,把数据hash,然后与保存在头部的8个字节比较。
var scratch [8]byte
// 长度写在前4个字节
binary.LittleEndian.PutUint32(scratch[:4], uint32(dataLen))
// crc写在后4个字节
binary.LittleEndian.PutUint32(scratch[4:], crc)
curFn := d.filenameFor(d.version)
d.version++
nextFn := d.filenameFor(d.version)
f, err := os.Create(nextFn)
if err != nil {
return err
}
w := bufio.NewWriter(f)
// 先写校验,再写数据
if _, err := w.Write(scratch[:]); err != nil {
f.Close()
return err
}
if _, err := w.Write(data[:]); err != nil {
f.Close()
return err
}
wal 的维基百科提到了一个 ARIES
算法,有空可以研究一下
大多数数据恢复均采用 ARIES 算法。该算法的三个基本原理:
-
WAL(Write-ahead logging) 写优先日志法 对于数据库的修改永远先写LOG然后再修改数据库。因此,修改数据的流程如下(假设内存中没有需要修改的数据) 从硬盘读取数据到内存中 ——〉写日志(更新记录的日志) ——〉修改内存中对应数据
该原理能确保数据不丢失,已经保证读一致性。大多数数据库都是采用这个原理。 之后,我会介绍oralce是如何使用WAL保证系统崩溃时数据库不丢失。
-
重做时重复历史 数据库会从某一时间点开始(经过分析后确定该时间点),根据该点之后所有事务日志恢复所有事务,而不是仅仅恢复提交的事务。 在我看来,重做某一时间点之后的所有事务,与只重做提交事务相比较,前者对于恢复进程而言更加简单减少判断,其他优点我暂时没有想出来。
-
恢复修改记录 反做(撤消)没有提交的事务,这点能保证数据库的正确性。
原文: http://qtchina.tk/?q=node/321