关于go:记录一次bufioReader遇到问题后的思考

9次阅读

共计 4609 个字符,预计需要花费 12 分钟才能阅读完成。

背景

书接上回,话说在上一篇文章中我讲述了对于 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 会返回胜利写入或读取的字节数量,这里其实是须要留神判断一下这个数量和咱们预期中的是否相符,这里我其实做了一个谬误的示范。感觉我应该不是惟一一个不留神这个问题的,所以简略写篇文章总结记录一下,引起读者敌人们的留神。

本文参加了思否技术征文,欢送正在浏览的你也退出。

正文完
 0