• 记录在一次bufio.Reader的错误使用中引起的思考


    背景

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

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

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

    分析问题

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

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

    image-20221022040614420

    话不多说,直接上代码(幸好代码也不是很复杂)。

    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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49

    ​ 从代码中可以看出,其实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
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22
      • 23
      • 24
      • 25
      • 26
      • 27
      • 28
      • 29
      • 30
      • 31
      • 32
      • 33

      ​ 在这段代码中,我们往文件里写入了大小为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)
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      • 16
      • 17
      • 18
      • 19
      • 20
      • 21
      • 22

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

    解决方法

    既然一次读取解决不了问题,那么就再读一次,可以把剩余没读出来的数据再读一次。读取数据的代码我修改成了下面这样的方式,其实第二次读取数据如果还是相同的问题,说明文件中已经没有那么多数据了,但是我们的程序还是需要这么多,这个时候CRC校验也过不去,可以在上层报错返回。

    // 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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    总结

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

  • 相关阅读:
    redis五大常见数据结构的操作命令(string, hash, list, set和zset)
    Windows11大变天!桌面或被Copilot接管!
    测试工具之压测工具JMeter(一)
    213. 打家劫舍 II
    微积分小感——3.简单积分
    前端如何实现一个滚动的文本字幕
    数据结构分类总结[多达80种,offer收割机]
    结构型模式-享元模式
    Python系列:python中split如何使用
    应用计量经济学问题~
  • 原文地址:https://blog.csdn.net/LuciferMS/article/details/127460532