翻译自《build your own database from scratch》, 转代码为C++
01. 文件VS数据库 使用B-树构建一个简单的持久键值存储。
本章展示了简单地将数据转储到文件的局限性以及数据库解决的问题。
1.1 将数据持久化到文件 假设您有一些数据需要持久化到一个文件;这是一种典型的方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #include <iostream> #include <fstream> #include <vector> bool SaveData1 (const std::string& path, const std::vector<uint8_t >& data) { std::ofstream file (path, std::ios::binary | std::ios::out) ; if (!file.is_open ()) { return false ; } file.write (reinterpret_cast <const char *>(data.data ()), data.size ()); return !file.bad (); }
这种天真的方法有一些缺点:
它会在更新文件之前截断文件。如果需要同时读取该文件,该怎么办?
将数据写入文件可能不是原子的,这取决于写入的大小。并发读可能会得到不完整的数据。
数据实际何时持久化到磁盘?写入系统调用返回后,数据可能仍在操作系统的页面缓存中。当系统崩溃并重新启动时,文件的状态是什么?
1.2 原子重命名 为了解决这些问题,让我们提出一个更好的方法:
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 #include <iostream> #include <fstream> #include <vector> #include <cstdlib> #include <ctime> #include <cstdio> int RandomInt () { std::srand (static_cast <unsigned int >(std::time (nullptr ))); return std::rand (); }bool SaveData2 (const std::string& path, const std::vector<uint8_t >& data) { std::string tmp = path + ".tmp." + std::to_string (RandomInt ()); std::ofstream file (tmp, std::ios::binary | std::ios::out | std::ios::trunc) ; if (!file.is_open ()) { return false ; } file.write (reinterpret_cast <const char *>(data.data ()), data.size ()); if (file.bad ()) { std::remove (tmp.c_str ()); return false ; } file.close (); if (std::rename (tmp.c_str (), path.c_str ()) != 0 ) { std::remove (tmp.c_str ()); return false ; } return true ; }
这种方法稍微复杂一些,它首先将数据转储到一个临时文件,然后将临时文件重命名为目标文件。这似乎没有直接更新文件的非原子性问题——重命名操作是原子性的。如果系统在重命名前崩溃,则原始文件保持不变,应用程序同时读取文件也没有问题。然而,这仍然是有问题的,因为它无法控制数据何时持久化到磁盘,并且元数据(文件大小)可能在数据之前持久化到硬盘,从而可能在系统崩溃后损坏文件。(您可能已经注意到,一些日志文件在断电后会出现零值,这是文件损坏的迹象。)
1.3 fsync 要解决这个问题,我们必须先将数据刷新到磁盘,然后再重命名它。Linux对此的系统调用是“fsync”。
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 #include <iostream> #include <fstream> #include <vector> #include <cstdlib> #include <ctime> #include <cstdio> #include <string> int RandomInt () { std::srand (static_cast <unsigned int >(std::time (nullptr ))); return std::rand (); }bool SaveData3 (const std::string& path, const std::vector<uint8_t >& data) { std::string tmp = path + ".tmp." + std::to_string (RandomInt ()); std::ofstream file (tmp, std::ios::binary | std::ios::out | std::ios::trunc) ; if (!file.is_open ()) { return false ; } file.write (reinterpret_cast <const char *>(data.data ()), data.size ()); if (file.bad ()) { std::remove (tmp.c_str ()); return false ; } file.flush (); if (!file.good ()) { std::remove (tmp.c_str ()); return false ; } file.close (); if (std::rename (tmp.c_str (), path.c_str ()) != 0 ) { std::remove (tmp.c_str ()); return false ; } return true ; }int main () { std::vector<uint8_t > data = { 0 ,1 ,2 ,3 ,4 ,5 }; std::string path = "data.bin" ; if (SaveData3 (path, data)) { std::cout << "Data saved successfully." << std::endl; } else { std::cout << "Failed to save data." << std::endl; } return 0 ; }
我们完成了吗?答案是否定的。我们已经将数据刷新到磁盘,但元数据呢?我们是否也应该在包含该文件的目录上调用fsync?这个问题很复杂,这就是为什么数据库比文件更适合将数据持久化到磁盘的原因。
1.4 仅追加日志 在某些用例中,使用仅追加日志持久化数据是有意义的。
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 #include <iostream> #include <fstream> #include <string> std::ofstream LogCreate (const std::string& path) { std::ofstream file (path, std::ios::out | std::ios::app) ; if (!file.is_open ()) { throw std::runtime_error ("Failed to open or create file." ); } return file; }bool LogAppend (std::ofstream& file, const std::string& line) { file << line << "\n" ; if (!file.good ()) { return false ; } file.flush (); if (!file.good ()) { return false ; } return true ; }int main () { try { std::string path = "logfile.txt" ; std::ofstream file = LogCreate (path); if (LogAppend (file, "This is a log line." )) { std::cout << "Log line appended successfully." << std::endl; } else { std::cout << "Failed to append log line." << std::endl; } file.close (); } catch (const std::exception& e) { std::cerr << "Error: " << e.what () << std::endl; } return 0 ; }
仅追加日志的好处在于它不修改现有数据,它是否处理重命名操作,使其更耐损坏。但是,仅靠日志是不足以建立数据库的。
数据库使用额外的“索引”来高效地查询数据。现在只有暴力的方法查询一堆随机顺序的记录。
日志如何处理删除的数据?它们不可能永远增长。
我们已经看到了一些我们必须处理的问题。让我们从下一章的索引开始。