从头实现数据库1-从文件到数据库

Oct 29, 2023

文件与数据库

本节简单的讨论下数据存储到文件中的局限性以及数据库所解决的问题。

就地更新文件

 
假设有一些数据需要持久化到一个文件中,这是一种典型的方法:
func SaveData1(path string, data []byte) error { fp, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0664) if err != nil { return err } defer fp.Close() _, err = fp.Write(data) if err != nil { return err } return fp.Sync() // fsync }
 
如果文件不存在,此代码将创建该文件,或者在写入内容之前截断现有文件。最重要的是,除非您调用 fsync (Go 中的 fp.Sync() ),否则数据不会持久。
存在的问题:
  1. 内容整体更新;仅适用于微小数据。这就是为什么不能使用 Excel 作为数据库。
  1. 如果需要更新旧文件,必须在内存中读取并修改它,然后覆盖旧文件。可能会在覆盖旧文件时程序崩溃,导致不可预测的问题
  1. 如果需要并发访问数据,不能处理并发的读和写操作。

原子重命名

通过重命名文件自动替换数据:
  1. 如果更新中断,您可以从旧文件中恢复,因为它保持完整。
  1. 并发读操作不会得到一半的写入数据。
func SaveData2(path string, data []byte) error { tmp := fmt.Sprintf("%s.tmp.%d", path, randomInt()) fp, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0664) if err != nil { return err } defer func() { fp.Close() if err != nil { os.Remove(tmp) } }() _, err = fp.Write(data) if err != nil { return err } err = fp.Sync() // fsync if err != nil { return err } return os.Rename(tmp, path) }
 
为什么重命名有效?
  1. 文件系统保留从文件名到文件数据的映射,因此通过重命名替换文件只需将文件名指向新数据,而无需触及旧数据。这就是文件系统中可以进行原子重命名的原因。并且无论数据大小如何,操作成本都是恒定的。
  1. 在 Linux 上,如果读取器仍在打开被替换的旧文件,则该文件可能仍然存在;只是无法通过文件名访问它。读取器可以安全地处理所获得的任何版本的数据,而写入操作不会被读取操作阻止。但是,必须有一种方法来防止并发写入,一般并发级别是多读-单写

 仅追加日志

进行增量更新的一种方法是将更新附加到文件中。只能增加,比就地更新更安全,因为不会覆盖任何数据,崩溃后始终可以恢复旧数据。
读操作在使用日志时必须考虑所有日志条目。例如,这是一个基于日志的 KV,有 4 个条目:
0 1 2 3 | set a=1 | set b=2 | set a=3 | del b |
最终状态是 a=3 。
日志是许多数据库的重要组成部分。然而,日志只是每次更新的描述,这意味着:
  • 日志不是一个索引数据结构;读取操作必须阅读所有条目。
  • 日志无法从已删除的数据中回收空间。
因此仅靠日志不足以构建数据库,必须与其他索引数据结构结合起来。

带校验和的原子日志更新

虽然日志不会损坏旧数据,但如果最后一个条目在崩溃后损坏,您仍然需要处理它。多种可能性:
  1. 最后的追加根本不会发生;日志还是正常的。
  1. 最后一个条目已经写入一半了。
  1. 日志的大小增加了,但最后一个条目不存在。
处理这些情况的方法是向每个日志条目添加校验和。如果校验和错误,则更新不会发生,从而使日志更新成为原子的。
此场景涉及在成功 fsync 之前发生的不完整写入(数据库术语中的撕裂写入)。校验和还可以检测 fsync 之后的其他形式的损坏,但这不是数据库可以恢复的。

 `fsync` 陷阱

  1. 重命名文件或创建新文件后,必须在父目录上调用 fsync 。目录是从文件名到文件的映射,与文件数据一样,除非使用 fsync ,否则它不持久。
  1. fsync 的另一个问题是错误处理。如果 fsync 失败,则数据库更新失败,但是如果之后读取文件怎么办?即使 fsync 失败(由于操作系统页面缓存),也可以获得新数据!此行为依赖于文件系统。

备注:

关于write syscall:

系统调用:
Linux 中,一切皆文件,文件操作在 Linux 中是十分重要,Linux 系统直接提供了一些函数用于对文件和设备进行访问和控制,这些函数被称为系统调用(syscall)。系统调用就是 Linux 内核提供的一组用户进程与内核进行交互的接口。
系统调用工作在内核态,实际上,系统调用是用户空间访问内核空间的唯一手段,系统调用的主要作用如下:
  1. 系统调用为用户空间提供了一种硬件的抽象接口,这样,当需要读写文件时,应用程序就可以不用管磁盘类型和介质,甚至不用去管文件所在的文件系统到底是哪种类型;
  1. 系统调用保证了系统的稳定和安全。作为硬件设备和应用程序之间的中间人,内核可以基于权限、用户类型和其他一些规则对需要进行的访问进行判断;
  1. 系统调用是实现多任务和虚拟内存的前提。
write 系统调用:
系统调用 write 的作用是把缓冲区 buf 的前 nbytes 个字节写入与文件描述符 fildes 关联的文件中。它返回实际写入的字节数。如果文件描述符有错或者底层的设备驱动程序对数据块长度比较敏感,该返回值可能会小于 nbytes。如果函数返回值为 0,就表示没有写入任何数据;如果返回值为 -1,则表明 write 系统调用出现了错误,错误代码保存在全局变量 errno 里。

linux 写文件 fsync 调用

在Linux中,fsync调用只会刷新指定文件的数据到磁盘,不会自动递归刷新整个目录。如果需要递归刷新整个目录的数据到磁盘,可以使用以下方法:
  1. 使用find命令递归查找目录下的所有文件,并对每个文件执行fsync操作:
    1. find /path/to/directory -type f -exec fsync {} \;
  1. 编写一个递归的脚本来遍历目录下的所有文件,并对每个文件执行fsync操作。
  1. 通过编程语言如Python、C等编写一个递归函数来实现递归刷新整个目录的数据到磁盘。
需要注意的是,在执行递归刷新整个目录的数据到磁盘时,要谨慎操作,因为这可能会导致较大的磁盘I/O负载和性能损耗。建议在必要的情况下才进行整个目录的递归刷新操作。

linux读写文件过程

  • 读取文件过程
  1. 进程调用库函数向内核发起读文件请求
  1. 内核通过检查进程的文件描述符定位到虚拟文件系统的已打开文件列表表项
  1. 调用该文件可用的系统调用函数read(),read()函数通过文件表项链接到目录项模块,根据传入的文件路径,在目录项模块中检索,找到该文件的inode
  1. 在inode中,通过文件内容偏移量计算出要读取的页
  1. 通过inode找到文件对应的address_space(连接磁盘inode和内存page的桥梁)
  1. 在address_space中访问该文件的页缓存树(文件在Page Cache中以基数树形式组织),查找对应的页缓存结点:
  • a、如果页缓存命中,那么直接返回文件内容
  • b、如果页缓存缺失,那么产生一个页缺失中断:创建一个页缓存页,同时通过inode找到文件 该页的磁盘地址,读取相应的页填充该缓存页;重新进行第6步查找页缓存;
  1. 内核将读取的数据拷贝到用户空间,文件内容读取成功
了解了文件的读取过程之后,来思考一个问题。读取1字节的文件实际会发生多大的磁盘IO? 这个问题,更加明确的可以分为三个问题:
第一个问题:读取1 个字节的文件是否会导致磁盘 IO ?
根据上述读取文件的流程可知,如果 Page Cache 命中的话,根本就没有磁盘 IO 产生。
第二个问题:假如 Page Cache 没有命中,那么一定会有传动到机械轴上进行磁盘 IO 吗?
其实也不一定,因为现在的磁盘本身就会带一块缓存。另外现在的服务器都会组建磁盘阵列,在磁盘阵列里的核心硬件Raid卡里也会集成RAM作为缓存。只有所有的缓存都不命中的时候,机械轴带着磁头才会真正工作。
第三个问题:如果这些缓存都未命中,发生了磁盘 IO,那发生的是多大的 IO 呢?
整个 IO 过程中涉及到了好几个内核组件,而每个组件之间都是采用不同长度的块来管理磁盘数据的。
  • Page Cache 是以页为单位的,Linux 页大小一般是 4KB
  • 文件系统是以块(block)为单位来管理的,使用 dumpe2fs 可以查看,一般一个块默认是 4KB
  • 通用块层是以段为单位来处理磁盘 IO 请求的,一个段为一个页或者是页的一部分
  • IO 调度程序通过 DMA 方式传输 N 个扇区到内存,扇区一般为 512 字节
  • 硬盘也是采用“扇区”的管理和传输数据的
可以看到,虽然从用户角度确实是只读了 1 个字节,但是在整个内核工作流中,最小的工作单位是磁盘的扇区,为512字节,比1个字节要大的多。另外 block、page cache 等高层组件工作单位更大。其中 Page Cache 的大小是一个内存页 4KB。所以一般一次磁盘IO是多个扇区(512字节)一起进行的。假设通用块层 IO 的段就是一个内存页的话,一次磁盘 IO 就是 4 KB(8 个 512 字节的扇区)一起进行读取。
作者:JayHol链接:https://juejin.cn/post/7162444994853699621来源:稀土掘金著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
 
  • 写文件
  1. 进程调用库函数向内核发起读文件请求
  1. 内核通过检查进程的文件描述符定位到虚拟文件系统的已打开文件列表表项
  1. 调用该文件可用的系统调用函数write(),write()函数通过文件表项链接到目录项模块,根据传入的文件路径,在目录项模块中检索,找到该文件的inode
  1. 在inode中,通过文件内容偏移量计算出要读取的页
  1. 通过inode找到文件对应的address_space
  1. 在address_space中访问该文件的页缓存树,查找对应的页缓存结点:
  • a、如果页缓存命中,直接把文件内容修改更新在页缓存的页中,写文件就结束了,函数直接返回。这时候文件修改位于页缓存,并没有写回到磁盘文件中去
  • b、如果页缓存缺失,那么产生一个页缺失中断:创建一个页缓存页,同时通过inode找到文件 该页的磁盘地址,读取相应的页填充该缓存页;重新进行第6步查找页缓存;
  1. 一个页缓存中的页如果被修改,那么会被标记成脏页。脏页需要写回到磁盘中的文件块,有两种方式可以把脏页写回磁盘:
  • a、手动调用sync()或者fsync()系统调用把脏页写回
  • b、pdflush进程会定时把脏页写回到磁盘
注意:
脏页不能被置换出内存,如果脏页正在被写回,那么会被设置写回标记,这时候该页就被上锁,其他写请求被阻塞直到锁释放。

linux 写文件时,脏页不能被置换出内存

在Linux中,脏页是指已经被修改但尚未写入到磁盘的内存页。当一个进程修改了一个文件的内容时,相应的内存页就会变成脏页。脏页需要被写入到磁盘,以确保数据的持久性。
"脏页不能被置换出内存"意味着在写文件时,如果存在脏页,操作系统会阻止这些脏页被置换(或者说被换出)到交换空间(swap space)或者被覆盖。这是因为脏页包含了尚未写入磁盘的数据,如果被置换出内存,就会导致数据丢失或不一致。因此,操作系统会等待脏页被写入磁盘后才允许这些页被置换出内存。
这种机制是为了保证数据的一致性和持久性,尤其是在写文件时。虽然这可能会导致内存压力,但是保证了数据的完整性。
 

Copyright © 2025 later

logo