背景

书接上回,话说在上一篇文章中我讲述了对于nutsdb重启速度的优化历程,最后是引入了bufio.Reader来在重启时候读取数据,因为他会在程序和磁盘之间加一层缓存,起到减少系统调用的作用。做完之后提交代码发文章,正是春风得意之时,第二周的nutsdb周会(组织一般每周都会开周会,讨论一些事情的进度),佳军和我说这个东西好像有点问题,他用了一些case测试了一下,会报一个CRC校验的异常。

​ 什么是CRC的异常呢,因为磁盘虽然是持久化存储,但是也会有数据失真的风险。在我们写数据到磁盘之前会做顺便生成一个数据的校验值,也一起存进去。把数据取出来的时候也会用同样的方法生成一个校验值来和读出来的校验值做比对,如果校验值比对不上了就可以说明拿出来的数据和存进去的数据不一致。

​ 所以如果看到了CRC的异常,大概率是读取的代码有问题,因为磁盘出问题是小概率事件。所以究竟是什么神奇的魔法呢?让我们来一探究竟。

分析问题

​ 当我们拿到问题的时候当然是想着复现问题啦,能复现的问题都不是大问题。所以我拿到了佳军的测试代码之后在我本地跑了起来,刚开始的时候还抱着侥幸心理,觉得可能是电脑之间的差异?在我本地跑就不会有事了。不过事实很快就打脸了。

​ 打个断点在报错的地方,看看报错那一刻的上下文是怎么样的。查看一个读取出来的数据,如下图所示。我们可以看到在这个buffer后面存在大量的空数据。也就是说很多数据没有被读出来。那么为什么会这样子呢?我看了一眼代码,其实我只是简简单单的调用bufio.Reader提供的Read方法去读取数据,那么要继续深入探究这个问题很明显就需要深入到bufio.Reader的源码层面了。

type Reader struct {
    buf          []byte
    rd           io.Reader // reader provided by the client
    r, w         int       // buf read and write positions
    err          error
    lastByte     int // last byte read for UnreadByte; -1 means invalid
    lastRuneSize int // size of last rune read for UnreadRune; -1 means invalid
}

func (b *Reader) Read(p []byte) (n int, err error) {
    n = len(p)
    if n == 0 {
        if b.Buffered() > 0 {
            return 0, nil
        }
        return 0, b.readErr()
    }
    if b.r == b.w {
        if b.err != nil {
            return 0, b.readErr()
        }
        if len(p) >= len(b.buf) {
            n, b.err = b.rd.Read(p)
            if n < 0 {
                panic(errNegativeRead)
            }
            if n > 0 {
                b.lastByte = int(p[n-1])
                b.lastRuneSize = -1
            }
            return n, b.readErr()
        }
        b.r = 0
        b.w = 0
        n, b.err = b.rd.Read(b.buf)
        if n < 0 {
            panic(errNegativeRead)
        }
        if n == 0 {
            return 0, b.readErr()
        }
        b.w += n
    }
    n = copy(p, b.buf[b.r:b.w])
    b.r += n
    b.lastByte = int(b.buf[b.r-1])
    b.lastRuneSize = -1
    return n, nil
}

从代码中可以看出,其实Reader是自己先读一个比较大的东西存进Buffer里面,然后我们调用Read读取他再从Buffer里面拿。Read的运作流程是怎样的呢?流程是这样的。

  1. 如果buffer中的缓存已经被读取完毕,那么

    1. 如果要读取的数据大小大于buffer的大小,那么从数据来源直接读取,没有中间商赚差价。这个其实比较好理解啦。
    2. 如果要读取的数据小于buffer的大小,那么会读取数据进buffer,然后从buffer中copy数据出去。
  2. 如果buffer中还有缓存数据未被读取,那么会直接从缓存中copy数据返回。

​ 不难发现其实这里是一个状态机,我们理性分析一下这个状态机的几种可能以及他对应的处理。

  1. 如果缓存数据已经读完,并且要读取的数据量大于缓存的大小。会直接从数据来源处读取数据,不走缓存。没有问题。
  2. 如果缓存数据已经读完,并且要读取的数据量小于缓存的大小。会从数据来源处尝试读取缓存大小的数据,后续从缓存中copy数据返回。没有问题。
  3. 如果缓存数据没有被读完,并且要读取的数据量小于剩余缓存数据量。会从缓存中copy数据,并且数据缓存还会有剩余。没有问题。
  4. 如果缓存数据没有被读完,并且要读的数据量大于剩余缓存数据量,和3是一样的,会从缓存中copy数据,嗯????问题这不就找到了吗?当我的程序遇到这种情况的时候,由于只copy了一部分数据出来,所以就会看到上图中的数据。后面全是空的。
func TestBufioReader(t *testing.T) {
   fd, err := os.OpenFile("test.txt", os.O_CREATE|os.O_RDWR, os.ModePerm)
   if err != nil {
      t.Fatal(err)
   }
   dataSize := 5000
   data := make([]byte, dataSize)
   for i := 0; i < dataSize; i++ {
      data[i] = byte(rand.Intn(100))
   }
   _, err = fd.Write(data)
   if err != nil {
      return
   }

   fd2, err := os.OpenFile("test.txt", os.O_RDWR, os.ModePerm)
   if err != nil {
      t.Fatal(err)
   }
   reader := bufio.NewReader(fd2)
   block1 := make([]byte, 3000)
   block2 := make([]byte, 2000)
   read, err := reader.Read(block1)
   if err != nil {
      return
   }
   fmt.Println(read) //3000
   read, err = reader.Read(block2)
   if err != nil {
      return
   }
   fmt.Println(read) // 1096
}

在这段代码中,我们往文件里写入了大小为5000的数据量,然后读取一次3000,一次2000的数据,在我们看来合起来读取5000其实没什么问题,因为我们是知道他就有那么多的。不过很遗憾,第一次拿到了3000的数据量,但是第二次是1096,因为bufio.Reader的buffer默认大小是4096。

​ 所以在使用bufio.Reader的时候,需要注意的是读取出来的数据未必有我们预期的那么多。其实是我在写代码的时候犯了一个错,read会返回复制的数据量,不过我没有处理,理所应当的觉得我要多少他就会给多少。这里其实让我想到了,在我的编程习惯里面,都会选择性忽略这个东西。实际上这样是不对的,拿到数据之后需要判断一下拿出来的数据和想要的有没有出入,有的话就取有效数据来使用。我们可以看以下的例子。

func TestRead(t *testing.T) {
   fd, err := os.OpenFile("test.txt", os.O_CREATE|os.O_RDWR, os.ModePerm)
   if err != nil {
      t.Fatal(err)
   }
   data := []byte("aaaaaaaa")
   write, err := fd.Write(data)
   if err != nil {
      return
   }
   if write != len(data) {
      t.Fatal("write data length unexpected")
   }

   fd2, err := os.OpenFile("test.txt", os.O_RDWR, os.ModePerm)
   readData := make([]byte, 4096)
   read, err := fd2.Read(readData)
   if err != nil {
      t.Fatal(err)
   }
   fmt.Println(read)
}

在这个例子中我们往一个文件中写入8个字节的数据,但是在后面我们想要读取4096个字节,实际上文件里没有那么多数据,所以这个时候其实是有多少就给你多少,输出的read的值就是8,也就是说readData这个数组里面前8个是有内容的,但是后面全是零值,如果我们判断一下read,实际上后续用readData数据和我们想象中的是不一样的。

解决方法

既然一次读取解决不了问题,那么就再读一次,可以把剩余没读出来的数据再读一次。读取数据的代码我修改成了下面这样的方式。如果返回的数据没有我要拿的多,说明我命中了缓存中数据不足的场景,那么再读一次的话此时回走到 r==w 的代码分支里面,这时候有两种可能:

  1. 剩余所需数据比缓存数据的size大,那么会直接读取文件,而不会读数据到缓存
  2. 剩余所需数据比缓存数据的size小,这时候会读取数据到缓存,然后copy我所需要的数据出来。

到这里其实逻辑已经闭环了。

// readData will read a byte array from disk by given size, and if the byte size less than given size in the first time it will read twice for the rest data.
func (fr *fileRecovery) readData(size uint32) (data []byte, err error) {
   data = make([]byte, size)
   if n, err := fr.reader.Read(data); err != nil {
      return nil, err
   } else {
      if uint32(n) < size {
         _, err := fr.reader.Read(data[n:])
         if err != nil {
            return nil, err
         }
      }
   }
   return data, nil
}

不过仍然有更好的方式,因为Go的标准库给我们提供了相应的Api,那就是io.ReadFull函数。下面我们来看看ReadFull函数。

func ReadFull(r Reader, buf []byte) (n int, err error) {
    return ReadAtLeast(r, buf, len(buf))
}

func ReadAtLeast(r Reader, buf []byte, min int) (n int, err error) {
    if len(buf) < min {
        return 0, ErrShortBuffer
    }
    for n < min && err == nil {
        var nn int
        nn, err = r.Read(buf[n:])
        n += nn
    }
    if n >= min {
        err = nil
    } else if n > 0 && err == EOF {
        err = ErrUnexpectedEOF
    }
    return
}

我们可以看到,每次读取的时候会记录读出来的数据量,如果不足的话,会继续读下去,直到读满为止。所以这个场景下面,用ReadFull就完事了。

总结

​ 在使用这些读写操作相关API的时候,往往API会返回成功写入或读取的字节数量,这里其实是需要注意判断一下这个数量和我们预期中的是否相符,这里我其实做了一个错误的示范。感觉我应该不是唯一一个不注意这个问题的,所以简单写篇文章总结记录一下,引起读者朋友们的注意。

本文参与了思否技术征文,欢迎正在阅读的你也加入。


表哥的技术之旅
6 声望0 粉丝

喜欢钻研Golang源码和存储相关的开源项目。