build-your-own database from scratch-0~1

70 阅读6分钟

Introduction

Although the book is short and the implementation is minimal, it aims to cover three important topics:

  1. Persistence.  How not to lose or corrupt your data. Recovering from a crash.
  2. Indexing.  Efficiently querying and manipulating your data. (B-tree).
  3. Concurrency.  How to handle multiple (large number of) clients. And transactions.

Topic One: Persistence

为什么需要数据库?

为什么不直接将数据转储到文件中?

假设你的进程在写入文件时中途崩溃,或者你断电了,文件的状态是什么?

  • 文件是否只是丢失了最后一次写入?
  • 或者最终得到一个写了一半的文件?
  • 或者最终陷入更加糟糕的状态?

任何结果都有可能,当我们只是写入文件时,不能保证您的数据在磁盘上持久存储。

这是数据库解决的一个问题,因为数据库在意外关闭后重新启动时,仍将恢复到可用状态。

我们可以在不使用数据库的情况下实现持久化吗?有一种方法:

  • 将整个更新后的数据集写入新文件
  • 在新文件上调用 fsync
  • 将新文件重命名为旧文件,从而覆盖旧文件,文件系统保证这种覆盖是原子性的

这只有在数据集很小的情况下才能接受,像 SQLite 这样的数据库可以进行增量更新。

Topic Two: Indexing

有两种不同类型的数据库查询:分析型 (OLAP) 和事务型 (OLTP)。

分析 (OLAP) 查询通常涉及大量数据,并具有聚合、分组或连接操作。 相比之下,事务性 (OLTP) 查询通常只涉及少量索引数据,最常见的查询类型是索引点查询和索引范围查询(indexed point queries and indexed range queries)。

请注意,“事务性”一词与您可能知道的数据库事务无关,计算机行话常常被赋予不同的含义,本书只关注 OLTP 技术。

虽然许多应用程序不是实时系统,但大多数面向用户的软件应该在合理(少量)的时间内做出响应,并使用合理数量的资源(内存、IO)。

这属于 OLTP 类别,当数据集很大时,我们如何快速找到数据(在 O(log(n)) 时间内)?这就是我们需要索引的原因。

如果我们忽略持久性的问题,假设数据集在内存中,那么快速查找数据就是数据结构的问题了。在数据库系统中,在磁盘上持久存在以查找数据的数据结构被称为 "索引"

数据库索引可能比内存还要大,俗话说:如果你的问题适合在内存中解决,那它就是一个简单的问题。用于索引的常见数据结构包括 B 树和 LSM 树。

Topic Three: Concurrency

现代应用程序不仅仅按顺序执行所有操作,往往存在不同级别的并发,数据库也是如此:

  • concurrency between readers
  • concurrency between readers and writes,do writers need exclusive access to the database?

读读不互斥,读写、写写都互斥

即使是基于文件的 SQLite 也支持一定的并发性。但在进程中并发更容易,这就是为什么大多数数据库系统只能通过 "服务器 "访问。

随着并发性的增加,应用程序通常需要原子地执行操作,例如读取-修改-写入操作。这给数据库增加了一个新概念:事务。

Files vs. Databases

本章展示了简单地将数据转储到文件的局限性以及数据库解决的问题。

Persisting Data to Files

假设有一些数据需要持久化到文件中,下面一种典型的方法:

func SaveData1(path string, data []byte) error {
    fp, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
    if err != nil {
        return err
    }
    defer fp.Close()
    
    _, err = fp.Write(data)
    return err
}

这种简单的方法有一些缺点:

  • 它会在更新文件之前将其截断。如果需要并发读取文件,该怎么办?
  • 将数据写入文件可能不是原子的,具体取决于写入的大小。并发读取可能会获得不完整的数据。
  • 数据什么时候真正保存在磁盘上?在 Write SYSCALL 返回后,数据可能仍在操作系统的页面缓存中。当系统崩溃并重新启动时,文件的状态是什么?

Atomic Renaming

为了解决其中一些问题,我们提出一个更好的方法:

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 fp.Close()
    
    _, err = fp.Write(data)
    if err != nil {
        os.Remove(tmp)
        return err
    }
    
    return os.Rename(tmp, path)
}

这种方法稍微复杂一些,它首先将数据转储到临时文件,然后将临时文件重命名为目标文件。这似乎没有直接更新文件的非原子问题--重命名操作是原子的。如果系统在重命名前崩溃,原始文件将保持不变,应用程序并发读取该文件也不会出现问题。

但是,这仍然存在问题,因为它无法控制数据何时持久化到磁盘,并且元数据(文件的大小)可能会在数据之前持久化到磁盘,从而可能在系统崩溃后损坏文件。 (您可能已经注意到,断电后某些日志文件中的内容为零,这是文件损坏的迹象。)

fsync

要解决该问题,必须在重命名数据之前将数据刷新到磁盘。Linux 提供的系统调用是“fsync”。

func SaveData3(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 fp.Close()
    
    _, err = fp.Write(data)
    if err != nil {
        os.Remove(tmp)
        return err
    }
    
    // fsync syscall
    err = fp.Sync()
    if err != nil {
        os.Remove(tmp)
        return err
    }
    return os.Rename(tmp, path)
}

我们完成了吗?答案是否定的。我们已经将数据刷新到磁盘,但是元数据呢?我们还应该在包含该文件的目录上调用 fsync 吗?

这个 "兔子洞" 相当深,这就是为什么数据库比文件更适合将数据持久化到磁盘上。

Append-Only Logs

在某些用例中,使用只追加日志来持久化数据是有意义的(Redis AOF Log)

func LogCreate(path string) (*os.File, error) {
    return os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0664)
}

func LogAppend(fp *os.File, line string) error {
    buf := []byte(line)
    // 添加换行符
    buf = append(buf, '\n')
    _, err := fp.Write(buf)
    if err != nil {
        return err
    }
    // fsync 保证 LogAppend 返回前已经将数据持久化到磁盘了
    return fp.Sync()
}

仅附加日志的好处是,它不会修改现有数据,也不会处理重命名操作,因此更不易损坏。但要建立数据库,仅有日志是不够的。

  • 数据库使用额外的 "索引 "来高效查询数据。要查询一堆任意顺序的记录,只能用蛮力。
  • 日志如何处理删除的数据?日志不可能永远增长。

我们已经看到了一些我们必须处理的问题。下一章我们首先从索引开始。