仅作为个人笔记,不保证完全的准确性和正确性,请自行甄别

顺序写

文件IO中的“顺序写”通常指的是对文件进行连续写入操作的过程,即从文件的一个位置开始,依次向后写入数据,即追加。可以提高写入效率,尤其是在传统的机械硬盘(HDD)上,因为不需要频繁地在不同的位置之间切换,减少了寻道时间和旋转延迟。

需要注意的是,“顺序写”并不直接等同于物理磁盘上的连续空间写入。虽然理想情况下,操作系统和文件系统会尽量将文件的数据块分配到物理上连续的存储空间中,以提高读写性能,但实际上由于多种因素(如文件系统的碎片、先前删除文件留下的空洞、以及其他文件的存在等),很难保证文件的所有部分都能被分配到完全连续的物理空间中。因此,通常说的“顺序写”,更多是指逻辑上的连续写入,即按照文件内部的偏移量顺序写入数据,而不是指物理磁盘上的连续写入。

即,“顺序写”主要关注的是逻辑层面的连续性,而物理层面的连续性则是文件系统和操作系统尽力优化的结果。

示例

顺序写:

 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
50
51
52
53
54
55
56
57
58
59
60
61
package main

import (
  "fmt"
  "os"
)

func main() {
  fileName := "t.bin"
  fileSize := 1 << 30 // 1GB  1073741824
  // fileSize := 1073741825 // 测试不按照块的倍数写

  // 创建并截断文件(直接分配空间)
  file, err := os.Create(fileName)
  if err != nil {
    fmt.Println("Error creating file:", err)
    return
  }
  defer file.Close()

  fInfo, err := file.Stat()
  if err != nil {
    fmt.Println("Error getting file size:", err)
  }
  fmt.Println("create: ", fInfo.Size())

  err = file.Truncate(int64(fileSize))
  if err != nil {
    fmt.Println("Error truncating file:", err)
    return
  }
  fInfo, _ = file.Stat()
  fmt.Println("truncate: ", fInfo.Size())

  randomBlock := make([]byte, 0x1<<10<<2) // 4kB
  for i := 0; i < len(randomBlock); i++ {
    randomBlock[i] = 0x0
  }

  bytesWritten := 0
  for i := 0; i < fileSize; i += len(randomBlock) {
    n, err := file.Write(randomBlock)
    if err != nil {
      fmt.Println("Error writing to file:", err)
    }
    fmt.Println(n)
    bytesWritten += n
  }
  file.Sync()
  fmt.Println("written: ", bytesWritten)
  fInfo, err = file.Stat()
  if err != nil {
    fmt.Println("Error getting file size:", err)
  }
  fmt.Println("finally: ", fInfo.Size())

  // 文件指针在末尾
  n, err := file.Read(randomBlock)
  fmt.Println("read nums: ", n)
  fmt.Println("err: ", err)
}

输出:

1
2
3
4
5
6
create:  0
truncate:  1073741825
written:  1073745920
finally:  1073745920
read nums:  0
err:  EOF

随机写,理解为逻辑上的不连续性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
  for _, entry := range data {
    // 移动文件指针到指定位置
    _, err := file.Seek(entry.Offset, os.SEEK_SET)
    if err != nil {
      fmt.Println("Error seeking in file:", err)
      return
    }

    // 写入数据
    _, err = file.WriteString(entry.Content)
    if err != nil {
      fmt.Println("Error writing to file:", err)
      return
    }
  }

}

应用

  • 常见的场景有MQ的存储

直接IO

系统调用文档:https://man7.org/linux/man-pages/man2/open.2.html

常规IO数据落盘是有操作系统缓存参与的,是大多数操作系统默认启用的IO模式,缓存IO操作中,数据会被先写或读到内核的缓存区page cache,然后进行后续的操作。Direct IO最小化系统层缓存的影响(注意是最小化,并不是完全没有任何缓存),直接从应用层buffer到磁盘完成IO操作,绕开了内核的页缓存,因此减少了上下文切换和数据拷贝的开销,还允许应用程序对I/O操作有更精细的控制,比如确保数据立即写入磁盘,但是由于系统层面基本不缓存数据,失去了内核缓存提供的诸如预读取、顺序读取等优化功能,如果使用不合理反而会导致性能变差,通常需要应用有自己的缓存机制,另外在使用时需要注意必须进行缓冲区对齐,这是因为Direct IO直接与磁盘的块存储结构交互,需要满足磁盘存储的对齐要求,以确保数据的正确读写。对齐限制因文件系统和内核版本而异,在Linux2.4中,缓冲区的大小必须是文件系统块大小的整数倍,数据传输的起始位置(偏移量)也必须是块大小的整数倍,Linux2.6.0中,这一限制被放宽到扇区大小。

参考实现:

https://github.com/ncw/directio

缓冲区对齐

文档资料 https://github.com/facebook/rocksdb/wiki/Direct-IO

直接I/O要求缓冲区必须与底层存储的逻辑扇区大小对齐:

  1. buffer大小是逻辑扇区倍数
  2. 偏移量是逻辑扇区倍数
  3. buffer缓冲区指针与逻辑扇区边界对齐

手动实现对齐实现

以块大小4096为例:

对齐即能被AlignSize 4096整除,判断是否对齐将[]byte的起始地址对块大小求余为0即对齐return int(uintptr(unsafe.Pointer(&block[0])) % uintptr(AlignSize)) == 0

AlignSize为2的倍数时,可以将上面求余运算转为与运算提高效率 return int(uintptr(unsafe.Pointer(&block[0])) & uintptr(AlignSize-1)) == 0

在二进制表示中,一个整数除以 2 的幂次方取余,本质上是取该整数二进制表示中低若干位的值。

以 2 的 1 次方(即 2)为例,一个数除以 2 取余,实际上就是判断这个数的二进制表示的最低位是 0 还是 1。若最低位是 0,则余数为 0;若最低位是 1,则余数为 1。

对于 2 的 n 次方,其对应的二进制形式是 1 后面跟着 n 个 0。比如 2 的 3 次方是 8,二进制表示为 1000。当一个数对 8 取余时,实际上是取这个数二进制表示的低 3 位。

而与运算(&)的规则是:两个对应的二进制位都为 1 时,结果位才为 1,否则为 0。如果用一个数和 2^n - 1 进行与运算,2^n - 1 的二进制形式是 n 个 1,这样就可以提取出该数二进制表示的低 n 位,这和对 2 的 n 次方取余的结果是一致的。

我们创建一个buffer,它的起始地址A是没法确定的,如何找到对齐的地址呢?设B为上面的余数,为0时恰好对齐,否则需要的偏移量offset为4096-B,起始对齐地址是A+(4096-B),即 AlignSize - int(uintptr(unsafe.Pointer(&block[0])) & uintptr(AlignSize-1)),用切片表示的话就是 buffer[offset : offset+BufferSize]

使用场景

  • 数据库等,自己实现了缓存系统,能够更好地控制哪些数据应该保留在内存中,哪些应该被换出,不需要操作系统层的二次缓存处理
  • 事务性应用:对于数据库和其他要求高数据一致性的应用,Direct I/O 可以确保写入的数据立即持久化到磁盘,减少因系统崩溃或断电导致的数据丢失风险
  • 当涉及到大量数据的连续传输时,Direct I/O 可以减少 I/O 操作的延迟,尤其在大块数据传输时,能够提高性能

内存映射文件(Memory-Mapped Files)

https://man7.org/linux/man-pages/man2/mmap.2.html

系统调用mmap可以将文件直接映射到进程的虚拟地址空间,像访问普通内存一样访问文件。当使用 mmap 将文件映射到内存时,实际上并没有立即将文件的所有内容加载到物理内存中,相反,操作系统只是创建了虚拟地址空间中的映射,并在需要时通过按需分页(demand paging)的方式加载数据,当然映射的文件大小不能超过系统的虚拟地址空间。映射后读写数据时,无需显式调用 readwrite 函数,可以直接对内存进行操作,减少了系统调用的次数。

Go语言中可以使用mmap函数来实现内存映射,接上面代码,添加下面mmap测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    // 获取文件描述符
    fd := int(file.Fd())
    // 映射文件到内存
    mapped, err := syscall.Mmap(fd, 0, int(fileSize), syscall.PROT_READ|syscall.PROT_WRITE, syscall.MAP_SHARED)
    if err != nil {
        fmt.Println("Error mapping file:", err)
        return
    }
    defer syscall.Munmap(mapped)

    // 使用映射的内存
    fmt.Println(len(mapped))
    copy(mapped, "didi")
    fmt.Println(mapped[:10])

输出:

1
2
3
4
5
6
7
8
9
create:  0
truncate:  1048576
written:  1048576
finally:  1048576
read nums:  0
err:  EOF
1048576
[100 105 100 105 0 0 0 0 0 0]
 

相关内核参数

1. /proc/sys/vm/max_map_count 设置一个进程中可以创建的最大虚拟内存映射区域数量

2. /proc/sys/vm/overcommit_memory控制内核如何处理内存过量分配(overcommit)

  • 0:默认行为,内核会尝试估计是否有足够的内存来满足请求
  • 1:总是允许过量分配,即使没有足够的物理内存。这可能导致 OOM(Out of Memory)杀手程序终止进程
  • 2:严格检查,只有在有足够的物理内存时才允许分配。这可以防止 OOM 杀手程序运行,但可能导致分配失败

3. /proc/sys/vm/overcommit_ratio overcommit_memory 设置为 2 时,该参数定义了系统可以超过物理内存和交换空间总和的比例。例如,如果设置为 50,则系统允许的最大虚拟内存是物理内存和交换空间总和的 1.5 倍

4. /proc/sys/vm/swappiness 控制内核将内存页面交换到磁盘的倾向。值越高,内核越倾向于将不活跃的页面交换到磁盘;值越低,内核越倾向于保留这些页面在内存中。降低 swappiness 可以减少不必要的磁盘 I/O,提高性能,尤其是在内存充足的情况下。

5. /proc/sys/vm/drop_caches手动清除缓存。可以通过写入不同的值来清除不同类型的缓存:

  • 1:清除页缓存
  • 2:清除 dentry 和 inode 缓存
  • 3:清除所有缓存
  • 默认值:0(不自动清除)

使用场景

  • 多个进程可以通过 mmap 共享同一个文件的内容,用于进程间通信,但是要注意引发竞态条件
  • 对于非常大的文件,传统的 readwrite 操作可能会导致大量的数据拷贝,而 mmap 可以减少这些不必要的拷贝,从而提高效率。此外,mmap 还可以在不完全加载整个文件的情况下访问其部分内容,这对于处理超过物理内存大小的文件尤为重要

性能优化

应用层面的优化

1. 合理使用缓存

尽量减少小规模的读写操作,改为批量处理,减少I/O开销,减少系统调用的次数

2. 文件预分配

在写入大量数据之前,预分配足够的磁盘空间可以减少因频繁分配新空间而引起的碎片化(RocketMQ的队列存储文件默认预分配1G)。通过一次性请求较大的连续空间,应用程序可以在已分配的空间内顺序地写入数据,从而提高写入效率

 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
package main

import (
  "fmt"
  "os"
  "syscall"
)

func main() {
  fileName := "preallocated_file.txt"

  fd, err := syscall.Open(fileName, syscall.O_CREAT|syscall.O_WRONLY, 0666)
  if err != nil {
    fmt.Println("Error opening file:", err)
    return
  }
  defer syscall.Close(fd)

  // 预分配文件空间
  fileSize := int64(1024 * 1024 * 10) // 10MB
  err = syscall.Ftruncate(fd, fileSize)
  if err != nil {
    fmt.Println("Error preallocating file space:", err)
    return
  }

  fmt.Println("File preallocation completed.")
}

3. 合适的IO模型

其他层面

不做过多描述了

  • 文件系统
  • 硬件
  • 日常维护

实践

猜想:基于文件预分配的顺序写及其内容索引实现