Golang 实现 Redis(4): AOF 持久化与AOF重写

服务器

浏览数:59

2020-6-15

本文是使用 golang 实现 redis 系列的第四篇文章,将介绍如何使用 golang 实现 Append Only File 持久化及 AOF 文件重写。

本文完整源代码在作者GithubHDT3213/godis

AOF 文件

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

在 DB 中定义相关字段:

type DB struct {
    // 主线程使用此channel将要持久化的命令发送到异步协程
    aofChan     chan *reply.MultiBulkReply 
    // append file 文件描述符
    aofFile     *os.File  
    // append file 路径
  aofFilename string 

    // aof 重写需要的缓冲区,将在AOF重写一节详细介绍
    aofRewriteChan chan *reply.MultiBulkReply 
    // 在必要的时候使用此字段暂停持久化操作
  pausingAof     sync.RWMutex 
}

在进行持久化时需要注意两个细节:

  1. get 之类的读命令并不需要进行持久化
  2. expire 命令要用等效的 expireat 命令替换。举例说明,10:00 执行 expire a 3600 表示键 a 在 11:00 过期,在 10:30 载入AOF文件时执行 expire a 3600 就成了 11:30 过期与原数据不符。

我们在命令处理方法中返回 AOF 需要的额外信息:

type extra struct {
    // 表示该命令是否需要持久化
    toPersist  bool 
    // 如上文所述 expire 之类的命令不能直接持久化
    // 若 specialAof == nil 则将命令原样持久化,否则持久化 specialAof 中的指令
  specialAof []*reply.MultiBulkReply 
}

type CmdFunc func(db *DB, args [][]byte) (redis.Reply, *extra)

以 SET 命令为例:

func Set(db *DB, args [][]byte) (redis.Reply, *extra) {
    //....
    var result int
    switch policy {
    case upsertPolicy:
        result = db.Put(key, entity)
    case insertPolicy:
        result = db.PutIfAbsent(key, entity)
    case updatePolicy:
        result = db.PutIfExists(key, entity)
    }
    extra := &extra{toPersist: result > 0} // 若实际写入了数据则toPresist=true, 若因为XX或NX选项没有实际写入数据则toPresist=false
    if result > 0 {
        if ttl != unlimitedTTL { // 使用了 EX 或 NX 选项
            expireTime := time.Now().Add(time.Duration(ttl) * time.Millisecond)
            db.Expire(key, expireTime)
            // 持久化时使用 set key value 和 pexpireat 命令代替 set key value EX ttl 命令
            extra.specialAof = []*reply.MultiBulkReply{ 
                reply.MakeMultiBulkReply([][]byte{
                    []byte("SET"),
                    args[0],
                    args[1],
                }),
                makeExpireCmd(key, expireTime),
            }
        } else {
            db.Persist(key) // override ttl
        }
    }
    return &reply.OkReply{}, extra
}

var pExpireAtCmd = []byte("PEXPIREAT")

func makeExpireCmd(key string, expireAt time.Time) *reply.MultiBulkReply {
  args := make([][]byte, 3)
  args[0] = pExpireAtCmd
  args[1] = []byte(key)
  args[2] = []byte(strconv.FormatInt(expireAt.UnixNano()/1e6, 10))
  return reply.MakeMultiBulkReply(args)
}

在处理命令的调度方法中将 aof 命令发送到 channel:

func (db *DB) Exec(c redis.Client, args [][]byte) (result redis.Reply) {
  // ....
  // normal commands
  var extra *extra
  cmdFunc, ok := router[cmd] // 找到命令对应的处理函数
  if !ok {
    return reply.MakeErrReply("ERR unknown command '" + cmd + "'")
    }
    // 使用处理函数执行命令
  if len(args) > 1 {
    result, extra = cmdFunc(db, args[1:])
  } else {
    result, extra = cmdFunc(db, [][]byte{})
  }

  // AOF 持久化
  if config.Properties.AppendOnly {
    if extra != nil && extra.toPersist {
            // 写入 specialAof
      if extra.specialAof != nil && len(extra.specialAof) > 0 {
        for _, r := range extra.specialAof {
          db.addAof(r)
        }
      } else {
                // 写入原始命令
        r := reply.MakeMultiBulkReply(args)
        db.addAof(r)
      }
    }
  }
  return
}

在异步协程中写入命令:

func (db *DB) handleAof() {
  for cmd := range db.aofChan {
        // 异步协程在持久化之前会尝试获取锁,若其他协程持有锁则会暂停持久化操作
        // 锁也保证了每次写入完整的一条指令不会格式错误
    db.pausingAof.RLock() 
    if db.aofRewriteChan != nil {
      db.aofRewriteChan <- cmd
    }
    _, err := db.aofFile.Write(cmd.ToBytes())
    if err != nil {
      logger.Warn(err)
    }
    db.pausingAof.RUnlock()
  }
}

读取过程与协议解析器一节基本相同,不在正文中赘述:loadAof

AOF 重写

若我们对键a赋值100次会在AOF文件中产生100条指令但只有最后一条指令是有效的,为了减少持久化文件的大小需要进行AOF重写以删除无用的指令。

重写必须在固定不变的数据集上进行,不能直接使用内存中的数据。Redis 重写的实现方式是进行 fork 并在子进程中遍历数据库内的数据重新生成AOF文件。由于 golang 不支持 fork 操作,我们只能采用读取AOF文件生成副本的方式来代替fork。

在进行AOF重写操作时需要满足两个要求:

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

因此我们设计了一套比较复杂的流程:

  1. 暂停AOF写入 -> 更改状态为重写中 -> 准备重写 -> 恢复AOF写入
  2. 在重写过程中,持久化协程在将命令写入文件的同时也将其写入内存中的重写缓存区
  3. 重写协程读取 AOF 文件中的前一部分(重写开始前的数据,不包括读写过程中写入的数据)并重写到临时文件(tmp.aof)中
  4. 暂停AOF写入 -> 将重写缓冲区中的命令写入tmp.aof -> 使用临时文件tmp.aof覆盖AOF文件(使用文件系统的mv命令保证安全)-> 清空重写缓冲区 -> 恢复AOF写入

在不阻塞在线服务的同时进行其它操作是一项必需的能力,AOF重写的思路在解决这类问题时具有重要的参考价值。比如Mysql Online DDL: gh-ost采用了类似的策略保证数据一致。

首先准备开始重写操作:

func (db *DB) startRewrite() (*os.File, int64, error) {
  // 暂停AOF写入, 数据会在 db.aofChan 中暂时堆积
  db.pausingAof.Lock() 
  defer db.pausingAof.Unlock()

  // 创建重写缓冲区  
  db.aofRewriteChan = make(chan *reply.MultiBulkReply, aofQueueSize)

  // 读取当前 aof 文件大小, 不读取重写过程中新写入的内容
  fileInfo, _ := os.Stat(db.aofFilename)
  filesize := fileInfo.Size()

  // 创建临时文件
  file, err := ioutil.TempFile("", "aof")
  if err != nil {
    logger.Warn("tmp file create failed")
    return nil, 0, err
  }
  return file, filesize, nil
}

在重写过程中,持久化协程进行双写:

func (db *DB) handleAof() {
  for cmd := range db.aofChan {
    db.pausingAof.RLock() 
    if db.aofRewriteChan != nil {
      // 数据写入重写缓冲区
      db.aofRewriteChan <- cmd
    }
    _, err := db.aofFile.Write(cmd.ToBytes())
    if err != nil {
      logger.Warn(err)
    }
    db.pausingAof.RUnlock()
  }
}

执行重写:

func (db *DB) aofRewrite() {
  file, fileSize, err := db.startRewrite()
  if err != nil {
    logger.Warn(err)
    return
  }

  // load aof file
  tmpDB := &DB{
    Data:     dict.MakeSimple(),
    TTLMap:   dict.MakeSimple(),
    Locker:   lock.Make(lockerSize),
    interval: 5 * time.Second,

    aofFilename: db.aofFilename,
  }
  // 只读取开始重写前 aof 文件的内容
  tmpDB.loadAof(int(fileSize))

  // rewrite aof file
  tmpDB.Data.ForEach(func(key string, raw interface{}) bool {
    var cmd *reply.MultiBulkReply
    entity, _ := raw.(*DataEntity)
    switch val := entity.Data.(type) {
    case []byte:
      cmd = persistString(key, val)
    case *List.LinkedList:
      cmd = persistList(key, val)
    case *set.Set:
      cmd = persistSet(key, val)
    case dict.Dict:
      cmd = persistHash(key, val)
    case *SortedSet.SortedSet:
      cmd = persistZSet(key, val)

    }
    if cmd != nil {
      _, _ = file.Write(cmd.ToBytes())
    }
    return true
  })
  tmpDB.TTLMap.ForEach(func(key string, raw interface{}) bool {
    expireTime, _ := raw.(time.Time)
    cmd := makeExpireCmd(key, expireTime)
    if cmd != nil {
      _, _ = file.Write(cmd.ToBytes())
    }
    return true
  })

  db.finishRewrite(file)
}

重写完毕后写入缓冲区中的数据并替换正式文件:

func (db *DB) finishRewrite(tmpFile *os.File) {
    // 暂停AOF写入
  db.pausingAof.Lock() 
  defer db.pausingAof.Unlock()


    // 将重写缓冲区内的数据写入临时文件
  // 因为handleAof已被暂停,在遍历期间aofRewriteChan中不会有新数据
    loop:
  for {
    select {
    case cmd := <-db.aofRewriteChan:
      _, err := tmpFile.Write(cmd.ToBytes())
      if err != nil {
        logger.Warn(err)
      }
    default:
      // 只有 channel 为空时才会进入此分支
      break loop
    }
    }
    // 释放重写缓冲区
  close(db.aofRewriteChan)
  db.aofRewriteChan = nil

  // 使用临时文件代替aof文件
  _ = db.aofFile.Close()
  _ = os.Rename(tmpFile.Name(), db.aofFilename)

  // 重新打开文件描述符以保证正常写入
  aofFile, err := os.OpenFile(db.aofFilename, os.O_APPEND|os.O_CREATE|os.O_RDWR, 0600)
  if err != nil {
    panic(err)
  }
  db.aofFile = aofFile
}

作者:-Finley-