4. golang实现redis:AOF重写

1,001 阅读8分钟

我正在参加「掘金·启航计划」。主要内容是对于校招练手项目的解析,促进自己更好地理解项目,理解redis。

AOF持久化

Append Only File 通过保存Redis服务器所执行的写命令来记录数据库状态。服务器启动时,可以通过载入和执行AOF文件中保存的命令来还原服务器关闭之前的数据库状态。

开启AOF需设置配置:appendonly yes。默认保存的AOF文件名是appendonly.aof。

  • 所有写命令会追加到aof_buf
  • aof缓冲区根据对应的策略向硬盘同步
  • AOF文件越来越大,需要对文件进行重写压缩
  • Redis服务器重启后,加载AOF文件数据恢复

实现

命令追加 Append

当AOF持久化功能处于打开状态时,服务器在执行完一个写命令后,会以协议的格式将被执行的写命令追加到服务器状态的aof_buf缓冲区末尾。

AOF命令写入的格式是文本协议格式,具有很好的兼容性。如:客户端向服务器发送SET KEY VALUE,服务器会在执行完SET命令后,将协议内容追加到aof_buf缓冲区的末尾。“3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n”

写入和同步

Redis服务器进程是一个事件循环,循环中的文件事件负责接收客户端的命令请求,并向客户端发送命令回复。

因为服务器在处理文件事件时可能会执行写命令,使一些内容被追加到aof_buf缓冲区里。所以在服务器每次结束一个事件循环之前,都会调用flushAppendOnlyFile,考虑是否需要将aof_buf缓冲区的内容写入和保存到AOF文件。

appendfsync默认为everysec
always将aof_buf缓冲区中所有内容写入,并使用fysnc操作同步到AOF文件
everysec将aof_buf缓冲区中所有内容使用write写入AOF文件,如果上次同步AOF文件的时间距离现在超过1s,再次对AOF文件使用fysnc操作进行同步。这个同步由一个线程专门进行
noaof_buf缓冲区中所有内容使用write写入AOF文件,但不对AOF文件进行同步,何时同步由操作系统决定

用户调用write函数,将一些数据写入到文件时,操作系统通常会把写入数据暂时保存在一个页缓冲区里,当缓冲区空间被填满或超过指定时限后,才真正将缓冲区数据写入磁盘。

这样虽然提高效率,但是为写入数据带来安全问题。如计算机的停机会导致保存在内存缓冲区的写入数据丢失。

因此,系统提供了fsync函数,强制让操作系统立即将缓冲区数据写入硬盘,确保数据的安全性。

重写

随着命令不断写入,AOF文件越来越大。引入AOF重写机制,把Redis进程内的数据转换为写命令同步到新AOF文件。而AOF文件重写后可以变小,是因为

  • 进程内已经超时的数据不再写入文件
  • 重写使用进程内数据直接生成,新的AOF文件只保留最终数据的写入命令
  • 多条写命令可以合并为一个

  • 执行AOF重写请求。

如果当前进程正在执行AOF重写,请求不执行,并报错。如果正在执行bgsave操作,重写命令阻塞,直到bgsave完成后执行。

  • 父进程使用fork操作创建子进程
  • 主进程fork操作完成后,继续响应其他命令。所有写入命令写入aof_buf,并根据appendfsync策略同步磁盘。保证原有AOF机制正确性
    • fork操作使用写时复制技术,子进程只能共享fork操作时的内存数据。由于父进程依旧响应命令,因此redis使用AOF重写缓冲区保存这部分新数据,防止新AOF文件生成期间丢失这部分数据。
  • 新aof文件写入完成后,子进程发送信号给父进程,父进程把aof重写缓冲区的数据写入新的aof文件
  • 新的aof文件替换旧的aof文件,完成aof重写

写时复制技术: copy-on-write 当使用fork操作复制父进程的数据时,先不复制进程的整个地址空间,而是共享数据,父子进程的地址空间指向共同的物理内存页。读取时不影响,当写入时,在对数据进行一次副本拷贝。

实现方式:fork操作后,将父进程所有内存页标记为只读。一旦子进程尝试对父进程的内存页进行修改,会触发异常,陷入内核,内核为尝试修改的内存页进行一次副本拷贝,恢复该页面的可修改权。

载入和数据还原

优先加载AOF持久化文件

  • 创建一个没有网络连接的伪客户端,执行AOF文件保存的写命令。
  • 从AOF文件中分析并读取一条写命令
  • 使用伪客户端执行被读出的写命令
  • 循环执行,直到所有写命令被处理完毕。

AOF文件

AOF持久化是典型的异步任务,主协程可以使用channel将数据发送到异步协程,由异步协程进行持久化操作。

type Handler struct {
	aofChan     chan *payload //该channel将要持久化的命令发送到异步协程
	aofFilename string        //append file 路径
	aofFile     *os.File      //append file 文件描述符

	aofFinished chan struct{} //aof重写时需要的缓冲区
	pausingAof  sync.RWMutex  //必要时使用该字段暂停持久化操作

	currentDB int
}

在异步协程中写入命令

func (handler *Handler) handleAof() {
	handler.currentDB = 0
	for p := range handler.aofChan {
		//使用锁 保证每次都会写入一条完整指令
		handler.pausingAof.RLock()
		//每个客户端都可以选择自己的数据库 payload中保存客户端选择的数据库
		//选择的数据库与aof文件中最新的数据库不一致时,写入一条select命令
		if p.dbIndex != handler.currentDB {
			data := protocol.MakeMultiBulkReply(utils.ToCmdLine("SELECT", strconv.Itoa(p.dbIndex))).ToBytes()
			_, err := handler.aofFile.Write(data)
			if err != nil {
				logger.Warn(err)
				continue
			}
			handler.currentDB = p.dbIndex
		}
		//写入命令内容
		data := protocol.MakeMultiBulkReply(p.cmdLine).ToBytes()
		_, err := handler.aofFile.Write(data)
		if err != nil {
			logger.Warn(err)
		}
		handler.pausingAof.RUnlock()
	}
	//关闭过程中主协程会先关闭handler.aofChan,然后使用<-handler.aofFinished等待缓冲区中的命令
	//通过handler.aofFinished通过主协程aof缓冲区完毕
	handler.aofFinished <- struct{}{}
}

读取时服用协议解析器中的解析器

func (handler *Handler) loadAof(maxBytes int) {
	aofChan := handler.aofChan
	handler.aofChan = nil
	defer func(aofChan chan *payload) {
		handler.aofChan = aofChan
	}(aofChan)

	file, err := os.Open(handler.aofFilename)
	if err != nil {
		if _, ok := err.(*os.PathError); ok {
			return
		}
		logger.Warn(err)
		return
	}
	defer file.Close()

	var reader io.Reader
	if maxBytes > 0 {
		reader = io.LimitReader(file, int64(maxBytes))
	} else {
		reader = file
	}
	ch := parser.ParseStream(reader)
	fakeConn := &connection.FakeChan{}
	for p := range ch {
		if p.Err != nil {
			if p.Err == io.EOF {
				break
			}
			logger.Error("parse error:" + p.Err.Error())
			continue
		}
		if p.Data == nil {
			logger.Error("empty payload")
			continue
		}
		r, ok := p.Data.(*protocol.MultiBulkReply)
		if !ok {
			logger.Error("require multi bulk protocal")
			continue
		}
		ret := handler.db.Exec(fakeConn, r.Args)
		if protocol.IsErrReply(ret) {
			logger.Error("exec err", ret.ToBytes())
		}
	}
}

AOF重写

由于golang不支持fork操作,因此采用读取aof文件生成副本的方式来代替fork

在进行aof重写操作时需满足

  • 若aof重写失败或被中断,aof文件需保持重写之前的状态,不能丢失数据
  • 进行aof重写期间执行的命令必须保存在新的aof文件中,不能丢失

大致流程如下

  1. 暂停AOF写入-》更改状态为重写中-〉准备重写-》恢复aof写入
  2. 重写写成读取aof文件前一部分,并重写到临时文件
  3. 暂停aof文件写入-〉将重写过程中产生的新数据写入临时文件,使用临时文件覆盖旧的aof文件,使用文件系统的mv命令确保安全-》恢复aof写入

准备开始重写

  • 获得重写文件大小
  • fsync进行文件缓冲区数据的同步
  • 对于重写操作使用同步锁
  • 新建临时文件.aof,打开文件进行读写,并返回os.File
  • 返回重写所需的上下文信息
//StartRewrite 准备开始重写
func (handler *Handler) StartRewrite() (*RewriteCtx, error) {
	//使用锁 保证每次会写入一条完整的指令
	handler.pausingAof.Lock()
	defer handler.pausingAof.Unlock()

	//调用fsync将缓冲区的数据进行同步,防止aof文件不完整造成错误
	err := handler.aofFile.Sync()
	if err!=nil{
		logger.Warn("fsync failed")
		return nil, err
	}
	//返回文件描述符
	fileInfo,_:=os.Stat(handler.aofFilename)
	//获得当前aof大小,用来判断哪些数据是aof重写过程中产生
	filesize:=fileInfo.Size()

	file,err:=ioutil.TempFile("",".aof")
	if err!=nil{
		logger.Warn("tmp file create failed")
		return nil,err
	}
	return &RewriteCtx{
		tmpFile: file,
		fileSize: filesize,
		dbIdx: handler.currentDB,
	},nil
}

开始重写

  • 加载需要重写的临时文件,重写的数据库引擎,重写的文件路径,重写的文件大小
    • loadAof()
      • 根据文件路径打开文件,根据传递的文件大小限制文件尺寸,通过channel读取客户端发来的命令,使用exec执行命令并加载(处理aof重写时的指令
  • 选择一个db
    • utils.ToCmdLine()
      • 以Bulk String的格式写入 “SELECT i”以及写入字节(.Write(data))
  • 遍历aof文件中的命令
    • EntityToCmd 根据不同的类型选择不同的处理。如dict.Dict->HMSET key xxx
    i := 0
	hash.ForEach(func(field string, val interface{}) bool {
		bytes, _ := val.([]byte)
		args[2+i*2] = []byte(field)//0->2 1->4
		args[3+i*2] = bytes//0->3 1->5
		i++
		return true
	})
  • 处理已过期的写命令
// DoRewrite 重写aof文件
func (handler *Handler) DoRewrite(ctx *RewriteCtx) error {
	tmpFile := ctx.tmpFile
	// 将重写开始前的数据加载到内存
	tmpAof := handler.newRewriteHandler()
	tmpAof.loadAof(int(ctx.fileSize))

	//将内存中的数据写入临时文件
	for i := 0; i < config.Properties.Databases; i++ {
		// 选择db
		data := protocol.MakeMultiBulkReply(utils.ToCmdLine("SELECT", strconv.Itoa(i))).ToBytes()
		_, err := tmpFile.Write(data)
		if err != nil {
			return err
		}
		tmpAof.db.ForEach(i, func(key string, entity *database.DataEntity, expiration *time.Time) bool {
			cmd := EntityToCmd(key, entity)
			if cmd != nil {
				tmpFile.Write(cmd.ToBytes())
			}
			if expiration != nil {
				cmd = makeExpireCmd(key, *expiration)
				if cmd != nil {
					tmpFile.Write(cmd.ToBytes())
				}
			}
			return true
		})
	}
	return nil
}
//aof/rewrite.go
func (handler *Handler) newRewriteHandler() *Handler {
	h := &Handler{}
	h.aofFilename = handler.aofFilename
	h.db = handler.tmpDBMaker()
	return h
}

完成重写

  • 开启同步锁,此时暂停aof文件的写入
  • 打开线上的aof文件,通过seek定位上次重写的位置
  • 写入select命令,选中重写时线上aof选择的数据库
  • 选中数据库后,可以将重写过程中产生的数据复制到新的aof文件中
  • 使用新的aof文件替代旧的aof文件
  • 重新打开线上aof文件,写入一条select命令,使aof文件与数据库文件保持一致
func (handler *Handler) FinishRewrite(ctx *RewriteCtx) {
    //暂停handlerAof的写入
	handler.pausingAof.Lock()
	defer handler.pausingAof.Unlock()

	tmpFile := ctx.tmpFile
	src, err := os.Open(handler.aofFilename)
	if err != nil {
		logger.Error("open aofFilename failed:" + err.Error())
		return
	}
	defer func() {
		src.Close()
	}()
	_, err = src.Seek(ctx.fileSize, 0)
	if err != nil {
		logger.Error("seek failed:" + err.Error())
		return
	}

	data := protocol.MakeMultiBulkReply(utils.ToCmdLine("SELECT", strconv.Itoa(ctx.dbIdx))).ToBytes()
	_, err = tmpFile.Write(data)
	if err != nil {
		logger.Error("tmp file rewrite failed:" + err.Error())
		return
	}
	_, err = io.Copy(tmpFile, src)
	if err != nil {
		logger.Error("copy aof file failed:" + err.Error())
		return
	}
	handler.aofFile.Close()
	os.Rename(tmpFile.Name(), handler.aofFilename)

	var aofFile *os.File
	aofFile, err = os.OpenFile(handler.aofFilename, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0600)
	if err != nil {
		panic(err)
	}
	handler.aofFile = aofFile

	data = protocol.MakeMultiBulkReply(utils.ToCmdLine("SELECT", strconv.Itoa(handler.currentDB))).ToBytes()
	_, err = handler.aofFile.Write(data)
	if err != nil {
		panic(err)
	}

}

参考

imageslr.com/2020/copy-o…

www.cnblogs.com/Finley/p/12…