MyLSM实操
一、环境配置
本项目基于容器进行
1 | docker pull ubuntu:22.04 |
无法下载解决办法:
1 |
|
vscode插件
xmake clangd CodeLLDB
安装clangd插件时候,如果开发环境采用的是容器,那么也需要在容器里安装一个插件(通过vscode扩展)
关闭spglog
1 | 1. 关闭日志输出(设置 off 级别) |
二、跳表
跳表服务于memtable
SStable不需要跳表,而是block和index
2.1 随机数
头文件
1 | //头文件 |
一种方式:定义
1 | // 创建随机数引擎并设置种子 |
另一种方式:先声明,后赋值
1 | std::uniform_int_distribution<> dis_01; |
random_device是非确定性随机数生成器类,位于 <random> 头文件中。它的主要作用是生成高质量的随机种子,确保伪随机数序列的不可预测性。
random_device() 是构造函数创建临时对象。
random_device()()是调用临时对象的运算符()重载,生成随机数种子。
std::mt19937(std::random_device()())是调用mt19913类的构造函数,用random_device()()初始化,创建的临时对象。然后临时对象赋值给gen变量。
2.2 前缀++和后缀++重载
在 C++ 中,++i(前缀递增)和i++(后缀递增)是两种不同的操作,它们的重载方式和返回值类型有明确区别。
1. 前缀递增 (++i) 的特点与实现
1 | BaseIterator& SkipListIterator::operator++() { |
- 返回类型:
BaseIterator&(引用) - 操作行为:先修改对象状态(将
current指向下一个节点),再返回修改后的对象本身 - 设计意图:支持链式调用(如
++i++j)
2. 后缀递增 (i++) 的正确实现方式
后缀递增需要不同的语法和返回值类型:
1 | BaseIterator SkipListIterator::operator++(int) { |
- 参数特点:后缀递增有一个额外的
int参数(仅用于区分前缀 / 后缀,实际不使用) - 返回类型:
BaseIterator(值类型) - 操作行为:先保存旧状态,再修改对象,最后返回旧状态
3. 前缀与后缀的核心区别
| 特性 | 前缀递增 ++i |
后缀递增 i++ |
|---|---|---|
| 参数 | 无参数 | 隐含int参数(仅作标记) |
| 返回值类型 | 引用(T&),便于链式调用 |
值(T),返回旧状态 |
| 执行顺序 | 先修改,再返回 | 先保存旧值,再修改,最后返回 |
| 效率 | 更高(无需创建临时对象) | 可能产生临时对象 |
- 为什么你的代码只能实现
++i?
返回类型是引用:前缀递增返回引用,支持链式操作(++++i);后缀递增必须返回值(旧状态)。
没有int参数:后缀递增的重载需要一个int参数来与前缀版本区分,你的代码缺少这个参数。
语义不同:前缀递增的语义是 “先递增,再使用”,后缀是 “先使用,再递增”,两者的实现逻辑不同。
- 为什么优先使用前缀递增?
效率更高:前缀递增无需创建临时对象,尤其对自定义类型更明显。
语义更清晰:++i明确表示 “先递增”,符合多数场景的需求。
兼容性更好:标准库迭代器和 STL 算法都优先使用前缀递增。
6. for`循环中的常见场景:效果相同
- 循环条件仅依赖迭代器移动
当for循环的结构为:
1 | for (auto it = container.begin(); it != container.end(); ++it) { |
或
1 | for (auto it = container.begin(); it != container.end(); it++) { |
两者效果完全相同,因为:
++it:先移动迭代器,再判断条件(符合逻辑)it++:先记录旧迭代器用于判断条件,再移动迭代器
(本质上也是移动后再判断,因为it++的返回值会被丢弃)
- 原理:返回值被忽略
for循环的第三个表达式(迭代器更新)的返回值会被直接丢弃,因此:
++it的返回值(新迭代器引用)被丢弃it++的返回值(旧迭代器副本)也被丢弃
最终都会让迭代器移动到下一个位置,不影响循环逻辑。
7.特殊场景:后缀递增可能引发问题
- 迭代器移动与返回值同时使用
如果在迭代器递增的同时使用其返回值,可能导致逻辑错误:
1 | // 错误示例(不建议在for循环中这样写) |
此时it++的返回值(旧迭代器)和it本身(新迭代器)会产生语义差异,可能引发 bug。
- 自定义迭代器的异常行为
如果迭代器的operator++(int)实现有误(如未正确保存旧状态),使用it++可能导致:
- 迭代器跳过元素
- 循环条件判断错误
- 甚至程序崩溃
(但正规容器的迭代器不会有这种问题)
8. 性能差异:前缀递增更优
虽然功能相同,但前缀递增的性能通常更好,原因如下:
- 内置类型的性能差异
对于int等基本类型:
++i:直接修改值并返回引用(无额外开销)i++:需要创建临时副本保存旧值,再返回副本(有拷贝开销)1
2
3int i = 0;
++i; // 直接修改i,无副本
i++; // 创建临时变量保存0,再将i改为1,返回临时变量
- 自定义迭代器的性能差异
对于自定义迭代器(如std::vector<int>::iterator):
++it:直接移动迭代器并返回自身(无拷贝)it++:需要创建迭代器副本保存旧状态,再返回副本
(尤其是当迭代器是复杂类时,拷贝开销更明显)
- 编译器优化的局限性
虽然现代编译器可能优化i++的拷贝开销,但:
- 前缀递增的逻辑更直接,优化空间更大
- 对于不支持拷贝优化的场景(如
const迭代器),it++的开销无法避免
9.最佳实践:优先使用前缀递增
理由
- 符合 “先修改后使用” 的语义,逻辑更清晰
- 避免潜在的性能开销(尤其是自定义迭代器)
- 与 STL 标准库的推荐一致(如C++ Core Guidelines建议优先用
++i)
示例对比
1 | // 推荐写法(前缀递增) |
2.3 跳表get操作

非常经典
1 | auto current=head; |
以查找key=13为例,current>forward_[2]=1 1<13
1 | xmake clean |
2.4 谓词查询
三、memtable
3.1加锁插入
1 | void MemTable::put(const std::string &key, const std::string &value, |
std::unique_lock<std::shared_mutex> lock1(cur_mtx);
std::shared_mutex
- 特性:一种读写锁(Read-Write Lock),允许多个线程同时获取读锁(共享访问),但写锁必须独占(互斥访问)。
- 适用场景:适合 “多读少写” 的场景,如缓存系统、数据库索引等。
std::unique_lock
- 特性:一个模板类,提供对互斥锁的高级管理。与
std::lock_guard相比,它更灵活,可以手动控制锁的获取和释放。 - 作用:
- 构造时:自动调用
cur_mtx.lock()获取写锁(独占访问)。 - 析构时:自动调用
cur_mtx.unlock()释放锁,避免死锁。
- 构造时:自动调用
std::shared_lock
1 | // 独占锁(写锁) |
1 | // 共享锁(读锁) |
| 当前状态 | 读锁请求(共享锁) | 写锁请求(独占锁) |
|---|---|---|
| 无锁 | 立即获取 | 立即获取 |
| 已有读锁(N 个) | 第 N+1 个读锁立即获取 | 阻塞,需等待所有读锁释放 |
| 已有写锁 | 阻塞,需等待写锁释放 | 阻塞,需等待写锁释放 |
3.2 活跃表刷写为冻结表
frozen_tables.push_front(std::move(current_table));
std::move 将左值转换为右值引用,从而允许资源的所有权转移,避免不必要的深拷贝。
- 避免不必要的拷贝
current_table 是一个 std::shared_ptr<SkipList>,它是一个智能指针,管理着堆上的 SkipList 对象。如果直接使用 push_front(current_table),会触发 shared_ptr 的拷贝构造函数:
1 | // 拷贝构造:引用计数+1,复制指针 |
这会增加引用计数,并创建一个新的 shared_ptr 对象,指向同一个 SkipList。虽然 shared_ptr 的拷贝操作本身很快(只是指针复制和引用计数更新),但对于频繁的内存表切换操作,这种开销仍然是不必要的。
- 实现所有权转移
通过使用 std::move,我们将 current_table 转换为右值引用,触发 shared_ptr 的移动构造函数:
1 | // 移动构造:引用计数不变,转移所有权 |
移动构造后,current_table 会变成一个空指针(nullptr),而 frozen_tables 中的新元素将接管原来 current_table 指向的 SkipList 对象的所有权。这样可以避免引用计数的增加和减少操作,提高效率。
- 内存管理的正确性
在这个场景中,current_table 是一个临时对象,即将被替换为新的 SkipList。通过 std::move 转移所有权后,current_table 会被安全地重置为空指针,避免了资源泄漏。
- 符合代码逻辑
从业务逻辑上看,当我们将当前内存表(current_table)冻结并加入到 frozen_tables 中时,实际上是在转移所有权。current_table 不应该再被使用,因为它已经被冻结并等待刷新到磁盘。使用 std::move 清晰地表达了这种所有权的转移。
不使用 std::move:
1 | // 引用计数从1变为2 |
使用 std::move:
1 | // 所有权转移,引用计数保持为1 |
3.3 迭代器
迭代器是一种抽象的访问手段,用来遍历容器中的元素,而不需要关心底层结构(数组?链表?红黑树?)。
3.3.1 标准容器迭代器
std::vector
1 | // 使用迭代器遍历 |
3.3.2 自定义迭代器
迭代器定义在类中
1 | class MyContainer { |
1 | int main() { |
迭代器定义在类外
1 | class MyContainer { |
迭代器抽象类
1 | // 抽象迭代器接口(类似 std::iterator) |
1 | class ArrayIterator : public IteratorBase { |
自定义迭代器“必须”实现的内容(基本功能)
| 操作符 | 功能 | 示例 |
|---|---|---|
operator*() |
解引用,访问当前元素 | *it |
operator++()(前缀) |
移动到下一个元素 | ++it |
operator!=() |
判断是否结束 | it != end() |
只要这 3 个有了,就能支持 C++ 的范围 for 循环:
1 | for (auto it = container.begin(); it != container.end(); ++it) { |
如果还希望支持 range-based for(现代语法)
则你的容器类还必须提供:
1 | Iterator begin(); |
可以扩充的功能(增强型迭代器)
这些是可选的,但能显著增强迭代器的能力,使它表现得像 STL 中的强大迭代器类型(比如 vector、list、map)。
| 操作符 / 方法 | 作用 | 用于支持 |
|---|---|---|
operator==() |
判断两个迭代器是否相等 | 替代 != |
operator--() |
支持向后遍历 | 双向迭代器 |
operator++(int) |
后缀递增 | it++ |
operator--(int) |
后缀递减 | it-- |
operator+=() / operator-= |
随机访问前进/后退 N 步 | 随机访问迭代器 |
operator+() / operator-() |
支持偏移位置计算 | it + 3, it - 2 |
operator[] |
支持下标访问 | it[5] |
difference_type |
两迭代器间距离 | it2 - it1 |
iterator_category |
告诉 STL:你是哪个种类 | 与标准算法兼容 |
dynamic_cast
dynamic_cast是 C++ 中的一种类型转换操作符,主要用于在继承层次结构中进行安全的向下转型(downcasting)或跨转型(crosscasting)。
1 | auto other2 = dynamic_cast<const HeapIterator &>(other); |
这行代码将other对象动态转换为const HeapIterator&类型的引用。下面详细解释其含义和作用:
- 运行时类型检查:与
static_cast不同,dynamic_cast会在运行时检查类型转换的有效性。如果转换失败:- 对于指针类型,返回
nullptr - 对于引用类型,抛出
std::bad_cast异常
- 对于指针类型,返回
- 主要应用场景:
- ==向下转型:从基类指针 / 引用转换为派生类指针 / 引用==
- 跨转型:在多继承体系中,将指针 / 引用从一个分支转换到另一个分支
- 必要条件:
- 基类必须包含至少一个虚函数(即基类必须是多态类型)
- 转换的目标类型必须是指针或引用
引用类型的 dynamic_cast:
1 | const HeapIterator & other2 = dynamic_cast<const HeapIterator &>(other); |
这意味着:
- 如果
other确实引用一个HeapIterator对象(或其派生类对象),转换成功 - 如果
other引用的不是HeapIterator类型的对象,会抛出std::bad_cast异常
3.3.3 memtable迭代器
在 LSM 树中,当需要修改数据时,不会直接覆盖旧值,而是:
- 更新操作:插入一个新的键值对(相同 key,不同 value)。
- 删除操作:插入一个特殊的删除标记(如
("k4", "")),称为 Tombstone。
当遍历多个 SkipList 时,如果简单地按顺序拼接结果,会导致:
- 旧版本可见:新写入的记录可能被旧版本覆盖。
- 已删除数据出现:标记为删除的键可能仍然被返回。
解决方案:基于优先队列的合并迭代器
使用 优先队列(最小堆)来合并多个 SkipList 的迭代器,确保:
- 按 key 排序:每次取出最小的 key。
- 按 SkipList ID 排序:相同 key 时,优先取 ID 较小的 SkipList(表示更新的数据)。
工作原理:
- 初始化:将每个 SkipList 的第一个元素放入堆中。
- 迭代过程:
- 取出堆顶元素(当前最小 key)。
- 如果该元素是删除标记(Tombstone),则跳过该 key。
- 如果不是删除标记,则返回该元素作为结果。
- 从该元素所在的 SkipList 中取下一个元素放入堆中。
- 重复:直到堆为空。
处理相同 key 的情况:
- 如果堆中存在多个相同 key 的元素,按 SkipList ID 排序后,ID 较小的元素(新版本)会先被处理。
- 处理完一个 key 后,立即跳过所有相同 key 的元素(无论是否为删除标记),确保每个 key 只返回一次。
示例分析
假设有两个 SkipList:
1 | SkipList0 (ID=0): ("k1", "v1") -> ("k4", "") -> ("k5", "v3") |
迭代器工作流程:
初始堆:
[("k1", "v1", 0), ("k2", "v2", 1), ("k3", "v3", 1), ("k4", "", 0), ("k4", "v4", 1), ("k5", "v3", 0)]处理顺序:
- 取出堆顶
("k1", "v1", 0)→ 返回("k1", "v1")。 - 取出堆顶
("k2", "v2", 1)→ 返回("k2", "v2")。 - 取出堆顶
("k3", "v3", 1)→ 返回("k3", "v3")。 - 取出堆顶
("k4", "", 0)(删除标记)→ 跳过所有k4(包括("k4", "v4", 1))。 - 取出堆顶
("k5", "v3", 0)→ 返回("k5", "v3")。
- 取出堆顶
最终结果:[("k1", "v1"), ("k2", "v2"), ("k3", "v3"), ("k5", "v3")]
3.3.4 SearchIterator
cpp中堆采用优先队列实现,优先队列默认大根堆,memtable要改成小根堆。
std::priority_queue 内部用的是“谁不满足比较器,就把谁放在前面”。所以当比较器是 cmp(a,b)=b < a,就能让小的元素在前面,从而构成小顶堆。
1 | std::priority_queue< |
greater标准库定义如下:
1 | template<typename T> |
可以看到标准库的greater仿函数 采用的是小于符号,且**==b<a==** 所以应该重载 <
less标准库定义如下:
1 | template <typename T> |
我们希望保留最新的数据,旧的数据被丢弃,最新的定义如下:
- 更大的
tranc_id→ 更新事务更晚; - 更小的
level→ 更靠近写入端(活跃 MemTable); - 更小的
idx_→ 更早插入的(table表的序号)。
所以重载< 为:
1 | bool operator<(const SearchItem &a, const SearchItem &b) { |
3.3.5 HeapIterator
3.3.6 MemTableIterator
MemTableIterator没有固定实现,是利用的HeapIterator迭代器,封装到类的成员函数。
1.emplace_back和push_back的区别
emplace_back 是 C++ 标准库中 std::vector 容器的一个成员函数,用于在容器的尾部直接构造一个新元素。它是 C++11 引入的特性,相比传统的 push_back,它能更高效地添加元素,尤其是对于需要复杂构造的对象。
基本功能
emplace_back 的作用是在 vector 的末尾就地构造一个元素,无需进行额外的拷贝或移动操作。
语法
1 | template< class... Args > |
Args&&... args:传递给元素构造函数的参数包(可变参数)- 这些参数会被转发给元素类型的构造函数
与 push_back 的对比
push_back 的两种重载形式
1 | // 重载1:接受左值引用(拷贝) |
核心区别
| 操作 | 步骤 |
|---|---|
push_back(obj) |
1. 创建一个 obj 的副本 2. 将副本移动(或拷贝)到 vector 中 |
emplace_back(...) |
1. 直接在 vector 的内存空间中调用构造函数 2. 使用参数 ... 初始化对象 |
示例对比
假设我们有一个类 Person:
1 | class Person { |
使用 push_back:
1 | std::vector<Person> people; |
使用 emplace_back:
1 | std::vector<Person> people; |
emplace_back 的优势
效率更高
- 避免了临时对象的创建和移动 / 拷贝操作
- 对于构造代价高的对象(如包含动态资源的类),性能提升显著
支持无法拷贝 / 移动的类型
可以直接构造那些删除了拷贝 / 移动构造函数的对象:
1 | class NonCopyable { |
语法更简洁
对于需要多个参数构造的对象,无需显式创建临时对象:
1 | // 无需写 people.push_back(Person("Alice", 25)); |
使用场景
- 添加复杂对象:当元素构造需要多个参数时
- 性能敏感的代码:避免不必要的拷贝 / 移动操作
- 添加不可拷贝的对象:如包含锁、文件句柄等资源的对象
注意事项
内存重新分配:如果
vector需要扩容,emplace_back仍可能触发元素的移动(与push_back相同)。构造函数匹配:确保传递给
emplace_back的参数能正确匹配元素类型的构造函数,否则会导致编译错误:1
2std::vector<int> nums;
nums.emplace_back("hello"); // 错误:无法将 const char* 转换为 int与初始化列表的区别:
1
2
3
4
5// 使用初始化列表(创建临时对象,再移动)
people.push_back({"Alice", 25}); // 调用 push_back(Person({"Alice", 25}))
// 直接构造(更高效)
people.emplace_back("Alice", 25);
3.4 补充知识
3.4.1 运算符重载的实现方式
- 成员函数:作为结构体的成员函数
1 | struct SearchItem { |
- 非成员函数:作为全局函数或命名空间内的函数
1 | struct SearchItem { |
3.4.2 std::pair 简介
std::pair 是 C++ 标准库中的一个模板类,用于将两个值(可能是不同类型)组合成一个单元。它不是传统意义上的容器(如 vector 或 list),而是一个简单的数据结构,主要用于存储一对相关的值。
1 |
|
主要用途
- 在函数中返回多个值
- 在关联容器(如
map、unordered_map)中存储键值对 - 临时组合两个值,避免创建专门的结构体
创建与初始化
- 直接构造
1 | std::pair<std::string, int> p1("Alice", 25); // 显式类型 |
- 使用
make_pair
1 | auto p3 = std::make_pair("Charlie", 35); // 自动推导类型 |
- 列表初始化(C++11 及以后)
1 | std::pair<std::string, int> p4{"David", 40}; // 统一初始化语法 |
访问与修改成员
- 通过
.first和.second
1 | std::pair<std::string, int> person("Alice", 25); |
- 结构化绑定(C++17 及以后)
1 | auto [name, age] = person; // 直接解包到变量 |
常用操作
- 比较运算符
pair 支持所有比较运算符(==, !=, <, >, <=, >=),按字典序比较:
1 | std::pair<int, char> p1(1, 'a'); |
- 交换内容
1 | std::pair<int, std::string> p1(1, "one"); |
- 作为容器的元素
1 |
|
- 关联容器的底层实现
std::map 和 std::unordered_map 的元素类型是 std::pair<const Key, T>:
1 |
|
3.4.2 make_optional
返回值解析:std::optional<std::pair<SkipListIterator, SkipListIterator>>
这个返回值类型组合了三个 C++ 特性:std::optional、std::pair 和 迭代器(SkipListIterator),其含义是:
函数可能返回一对迭代器(表示一个范围),也可能没有有效结果(用 std::nullopt 表示)。这在查找操作中很常见,例如:
- 当查询成功时,返回一个包含起始迭代器和结束迭代器的
pair - 当查询失败时,返回
std::nullopt(空值)
std::optional 是 C++17 引入的一个模板类,用于表示一个可能存在的值。它是对 “值可能缺失” 场景的类型安全封装,替代了传统的空指针或特殊值标记(如 -1 表示失败)。std::make_optional 则是一个辅助函数,用于便捷地创建 std::optional 对象。
一、std::optional 的基本概念
- 核心作用
- 显式表示一个值可能存在或不存在
- 避免使用空指针(
nullptr)或魔数(如-1) - 提供类型安全的空值处理机制
- 简单示例
1 |
|
二、std::optional 的常用操作
- 创建
optional对象
1 | // 1. 直接构造 |
- 检查是否有值
1 | if (opt.has_value()) { /* ... */ } // 显式检查 |
- 获取值
1 | // 1. 安全获取(带检查) |
- 修改值
1 | opt = 100; // 赋值(若原无值,创建值;若原有值,替换值) |
三、std::make_optional 的作用
std::make_optional 是一个辅助函数,用于自动推导并创建 std::optional 对象,其优势在于:
- 自动类型推导:无需显式指定模板参数
- 避免拷贝:使用完美转发,效率更高
- 统一语法:与
std::make_pair、std::make_shared等保持一致
示例对比
1 | // 不使用 make_optional |
四、为什么需要 std::optional?
- 替代空指针的问题
传统使用指针表示可能缺失的值:
1 | // 危险:需手动检查 nullptr |
- 替代魔数的问题
使用特殊值(如 -1)表示失败:
1 | // 不直观:-1 是魔法数字,语义不明确 |
std::optional的优势
- 类型安全:编译器强制处理空值情况
- 语义明确:从类型定义即可看出 “可能无值”
- 资源安全:无需手动管理内存(如
new/delete)
五、进阶用法
- 与函数式编程结合
1 | // 链式调用(C++20 及以后) |
- 作为函数参数
1 | void processValue(std::optional<int> opt) { |
六、注意事项
- 性能开销
std::optional通常使用一个字节的标志位来表示有无值,因此空间开销极小- 但对于本身就是可空的类型(如指针),使用
optional可能增加不必要的开销
- 兼容性
- C++17 及以后版本直接支持
std::optional - C++14 及以前可使用
std::experimental::optional(需包含<experimental/optional>) - 若需兼容更旧的编译器,可使用第三方库(如 Boost.Optional)
- C++17 及以后版本直接支持
- 避免过度使用
- 仅在 “值缺失是合理情况” 时使用
optional - 不要用
optional替代正常的错误处理(如异常或错误码)
- 仅在 “值缺失是合理情况” 时使用
四、SST
4.1 Block
4.1.1 编码和解码
分为两个步骤。编码(encoded)和解码(decoded)。
编码就是构建block,编码单个类实例形成一段字节数组(Block)。
解码就是读取一段字节数组,返回一个类实例,目的是将Block读取到内存,为上层组件提供查询功能。
4.1.2 add_entry
非强制写:
判断添加后的block大小和Blokc的capacity容量比较,如果大于,则返回false,添加条目失败。
添加后大小=cur_size +key_size + value_size+ 3*uint16_t (键大小,值大小,偏移量)+uint64_t (事务id)
4.1.3 直接赋值和返回this
1 | class Block : public std::enable_shared_from_this<Block> { |
当你已经持有一个 shared_ptr<Block> 对象(如 block)时,直接赋值是最简单的方式,这种方式完全正确且高效,无需额外的 getSharedPtr() 方法。
1 | auto block = std::make_shared<Block>(); // 外部创建的 shared_ptr |
当你需要从对象内部获取自身的 shared_ptr 时,直接赋值无法实现,必须通过 shared_from_this():
1 | class Block : public std::enable_shared_from_this<Block> { |
关键区别:
- 直接赋值:外部已经持有
shared_ptr,只需复制该指针。 getSharedPtr():在对象内部(如成员函数),需要将this转换为shared_ptr。
为什么不能在对象内部直接使用 this?
- 类型不匹配
this 是裸指针(Block*),而 registerCallback 期望的是 shared_ptr<Block>:
1 | // 错误:无法将 Block* 隐式转换为 shared_ptr<Block> |
- 生命周期管理风险
即使强制转换,也会导致双重释放:
1 | // 致命错误:创建了独立的 shared_ptr,与外部的 shared_ptr 无关 |
getSharedPtr() 的典型应用场景
- 注册回调函数
将自身的 shared_ptr 传递给其他组件,确保回调时对象存活:
1 | class EventLoop { |
- 链式调用返回自身
1 | class Builder : public std::enable_shared_from_this<Builder> { |
4.2 SST Builder
4.3 file
4.3.1 std_file
1 | std::fstream file_ |
std::fstream 是 C++ 标准库中用于文件输入输出的类,它继承自std::iostream,结合了std::ifstream(输入文件流) 和std::ofstream(输出文件流) 的功能。主要特点:
模式标志:打开文件时需指定模式:
std::ios::in- 读模式std::ios::out- 写模式std::ios::app- 追加模式std::ios::trunc- 截断文件std::ios::binary- 二进制模式
常用操作:
1
2
3
4
5
6
7file_.open("example.txt", std::ios::in | std::ios::out);
if(file_.is_open()) {
file_ << "写入数据"; // 写操作
std::string line;
std::getline(file_, line); // 读操作
file_.close();
}状态检查:
1
2
3if(file_.good()) // 所有操作正常
if(file_.eof()) // 到达文件末尾
if(file_.fail()) // 操作失败文件定位:
1
2file_.seekg(0, std::ios::beg); // 移动读指针到文件开头
file_.seekp(10, std::ios::cur); // 移动写指针到当前位置+10
1 | std::filesystem::path filename_ |
std::filesystem::path 是 C++17 引入的文件系统库的核心类,用于表示和操作文件路径。主要特点:
平台无关性:
- 自动处理不同操作系统的路径分隔符 (Windows 的
\和 Unix 的/) - 支持 Unicode 路径名
- 自动处理不同操作系统的路径分隔符 (Windows 的
路径操作:
1
2
3filename_ = "data";
filename_ /= "subdir"; // 追加路径组件
filename_ /= "file.txt"; // 结果: "data/subdir/file.txt"路径分解:
1
2
3
4std::cout << filename_.parent_path(); // "data/subdir"
std::cout << filename_.filename(); // "file.txt"
std::cout << filename_.stem(); // "file"
std::cout << filename_.extension(); // ".txt"查询操作:
1
2if(filename_.is_absolute()) // 是否绝对路径
std::cout << filename_.string(); // 转换为字符串与
fstream结合1
file_.open(filename_, std::ios::in);
std::fstream file_
std::fstream 是 C++ 标准库中用于文件输入输出的类,它继承自std::iostream,结合了std::ifstream(输入文件流) 和std::ofstream(输出文件流) 的功能。主要特点:
模式标志:打开文件时需指定模式:
std::ios::in- 读模式std::ios::out- 写模式std::ios::app- 追加模式std::ios::trunc- 截断文件std::ios::binary- 二进制模式
常用操作:
1
2
3
4
5
6
7file_.open("example.txt", std::ios::in | std::ios::out);
if(file_.is_open()) {
file_ << "写入数据"; // 写操作
std::string line;
std::getline(file_, line); // 读操作
file_.close();
}状态检查:
1
2
3if(file_.good()) // 所有操作正常
if(file_.eof()) // 到达文件末尾
if(file_.fail()) // 操作失败文件定位:
1
2file_.seekg(0, std::ios::beg); // 移动读指针到文件开头
file_.seekp(10, std::ios::cur); // 移动写指针到当前位置+10
std::filesystem::path filename_
std::filesystem::path 是 C++17 引入的文件系统库的核心类,用于表示和操作文件路径。主要特点:
平台无关性:
- 自动处理不同操作系统的路径分隔符 (Windows 的
\和 Unix 的/) - 支持 Unicode 路径名
- 自动处理不同操作系统的路径分隔符 (Windows 的
路径操作:
1
2
3filename_ = "data";
filename_ /= "subdir"; // 追加路径组件
filename_ /= "file.txt"; // 结果: "data/subdir/file.txt"路径分解:
1
2
3
4std::cout << filename_.parent_path(); // "data/subdir"
std::cout << filename_.filename(); // "file.txt"
std::cout << filename_.stem(); // "file"
std::cout << filename_.extension(); // ".txt"查询操作:
1
2if(filename_.is_absolute()) // 是否绝对路径
std::cout << filename_.string(); // 转换为字符串与
fstream结合:1
file_.open(filename_, std::ios::in);
最佳实践建议
异常安全:
1
2
3
4
5
6
7try {
if(std::filesystem::exists(filename_)) {
// 文件存在处理逻辑
}
} catch(const std::filesystem::filesystem_error& e) {
std::cerr << "文件系统错误: " << e.what() << '\n';
}RAII 特性:
std::fstream遵循 RAII 原则,对象销毁时自动关闭文件,无需手动调用close()错误处理:
1
2
3
4file_.open(filename_, std::ios::out);
if(!file_.is_open()) {
std::cerr << "无法打开文件: " << filename_.string() << '\n';
}路径规范:
1
auto canonical_path = std::filesystem::canonical(filename_);
1 | std::vector<uint8_t> StdFile::read(size_t offset, size_t length) { |
定位文件指针
1
file_.seekg(offset, std::ios::beg);
seekg是std::fstream的成员函数,用于设置输入(读取)指针位置offset:偏移量std::ios::beg:参考点为文件开头
读取数据:
1
file_.read(reinterpret_cast<char*>(buf.data()), length);
read函数从文件流读取length字节到指定内存地址buf.data()返回指向vector内部数组的指针reinterpret_cast<char*>将uint8_t*转换为char*以匹配read参数要求
4.3.2 MmapFile
这个 MmapFile 类是对操作系统提供的 内存映射文件机制(Memory-Mapped File, mmap) 的封装,主要目的是:将文件直接映射到内存中,从而能像操作内存一样高效读写文件内容。
| 特点 | StdFile |
MmapFile |
|---|---|---|
| 操作方式 | 使用 std::fstream(传统文件流) |
使用 mmap 系统调用映射文件到内存 |
| 读写方式 | 每次读取或写入都调用文件 API | 可以直接访问内存区域,更高效 |
| 使用场景 | 适合普通文件读写 | 适合频繁访问大文件或性能要求高的场景,如数据库、缓存系统等 |
类成员详解
成员变量(私有)
int fd_: 文件描述符,用于 Unix/Linux 系统的底层文件操作(如open())。void *mapped_data_: 文件被映射到的内存地址。size_t file_size_: 文件的大小。std::string filename_: 保存文件名。
成员函数(公开)
MmapFile()构造函数,初始化成员变量。
~MmapFile()析构函数,自动关闭文件和解除映射,防止资源泄漏。
文件操作 API
bool open(const std::string &filename, bool create = false);
- 打开文件并映射到内存,
create = true时如果文件不存在则创建。
bool create(const std::string &filename, std::vector<uint8_t> &buf);
- 创建新文件,并用给定的数据初始化文件内容,同时映射到内存。
void close();
- 关闭文件,并取消内存映射。
数据操作 API
size_t size() const
- 返回文件大小。
bool write(size_t offset, const void *data, size_t size);
- 把数据写入映射的内存区域。非常快,相当于直接修改内存。
std::vector<uint8_t> read(size_t offset, size_t length);
- 从映射的内存区域中读取数据,不需要文件系统调用。
bool sync();
- 将映射内存中的更改同步回磁盘(
msync()系统调用),确保数据持久化。
禁止拷贝
1 | MmapFile(const MmapFile &) = delete; |
- 防止不小心复制对象,导致两个对象指向相同的文件映射区域,造成潜在的内存和资源错误。
4.3.3 FileObj
本质理解:这是一个“文件工具类”或“文件对象封装类” ,它包装了一个 StdFile(通过智能指针管理),并对外提供了一组统一的接口:读取、写入、同步、追加、删除等。这使得上层逻辑用起来更加简单、统一、现代(移动语义、安全)。
类内部组成详解
成员变量
1 | std::unique_ptr<StdFile> m_file; // 指向 StdFile 的智能指针(用来操作文件) |
- 用智能指针管理文件对象,自动析构,防止内存泄漏。
m_size保存当前文件大小,用于写入/追加等操作中方便计算偏移。
类方法一览(配合前面的 StdFile)
文件生命周期管理
| 方法 | 说明 |
|---|---|
FileObj() / ~FileObj() |
构造/析构函数 |
FileObj(const FileObj&) = delete |
禁止拷贝(避免误用共享文件资源) |
FileObj(FileObj &&other) |
允许移动 |
operator=(FileObj&&) |
移动赋值 |
文件大小接口
1 | size_t size() const; |
size()返回当前文件大小。set_size()设置文件大小(用于手动更新,比如 append 后更新)。
文件创建 / 打开 / 删除
1 | static FileObj create_and_write(const std::string &path, std::vector<uint8_t> buf); |
- 静态函数:直接创建对象并打开或创建文件,写入初始化数据。
del_file()删除底层文件(其实是调用StdFile::remove())。
读取接口
1 | std::vector<uint8_t> read_to_slice(size_t offset, size_t length); |
这些方法从文件中指定 offset 读取数据(整段或者具体类型),是典型的底层数据格式解析场景。
比如你存储了一个结构体到文件,现在就能逐个字段读取回来。
写入接口
1 | bool write(size_t offset, std::vector<uint8_t> &buf); |
write(...):写入到指定位置(偏移量)append(...):追加数据到底部,自动更新m_size,用作日志、块追加场景。
同步接口
1 | bool sync(); |
- 调用底层
StdFile::sync(),将数据刷新到磁盘,确保不会因为内核缓存丢失。
总结
| 功能 | 对应 |
|---|---|
| 资源管理 | unique_ptr<StdFile> 自动关闭文件、避免拷贝 |
| 文件创建、删除 | create_and_write(),del_file() |
| 文件读写 | 读取各种类型(uint8_t / uint16_t / …) |
| 文件同步 | sync() 保证持久化 |
| 简洁封装 | 用更高层的接口屏蔽 StdFile 复杂逻辑 |
4.4 补充知识
4.4.1 size_t和uint8_t
| 特性 | size_t |
uint8_t |
|---|---|---|
| 类型本质 | 无符号整数,用于表示对象大小或内存空间 | 无符号 8 位整数(精确宽度类型) |
| 头文件 | <cstddef>, <iostream>, 等 |
<cstdint> |
| 常见用途 | 数组索引、容器大小、内存操作 | 字节数据、二进制编码、位操作 |
| 平台相关性 | 宽度可变(32/64 位系统) | 固定 8 位(跨平台一致) |
4.4.2 vector::data()
vector::data()是 C++ 标准库提供的成员函数,返回指向向量底层数组的裸指针(int*)。- 该指针保证指向连续内存,与
&vector[0]等价。
4.4.3 std::enable_shared_from_this
std::enable_shared_from_this 是 C++ 标准库中的一个模板类(定义于 <memory>),用于让类的成员函数能够安全地获取指向当前对象的 std::shared_ptr。这在需要将当前对象的引用传递给其他函数或对象时非常有用。
1 | class Block : public std::enable_shared_from_this<Block> |
如果一个类直接在成员函数中返回 this 指针,外部代码可能会创建多个独立的 std::shared_ptr 管理同一个对象,导致对象被多次删除(内存泄漏或崩溃):
1 | class Block { |
当使用 std::make_shared<Block>() 创建智能指针时:
- 同时分配对象内存和控制块
- 控制块中的引用计数初始化为 1
当通过 shared_ptr<Block>(this) 从原始指针创建智能指针时:
- 仅分配新的控制块(对象内存已存在)
- 新控制块的引用计数初始化为 1
- 两个控制块相互独立,不知道对方的存在
正确方式:通过 shared_from_this()
std::enable_shared_from_this 提供了 shared_from_this() 成员函数,它返回一个与已有 std::shared_ptr 共享所有权的新指针:
1 | class Block : public std::enable_shared_from_this<Block> { |
std::enable_shared_from_this 的工作原理
1. 内部机制
- 当一个类继承
std::enable_shared_from_this<T>时,基类会暗中跟踪管理该对象的std::shared_ptr。 - 第一次创建
std::shared_ptr<Block>时,智能指针会自动注册到基类的隐藏数据结构中。 - 调用
shared_from_this()时,直接从这个隐藏数据结构中获取已存在的std::shared_ptr,避免创建新的独立指针。
2. 继承要求
- 必须通过
public继承std::enable_shared_from_this。 - 类的模板参数必须是该类本身(如
class Block : public std::enable_shared_from_this<Block>)。
使用场景
- 向异步操作传递自身引用
1 | class Block : public std::enable_shared_from_this<Block> { |
- 注册回调函数
1 | class Block : public std::enable_shared_from_this<Block> { |
- 返回自身的引用
1 | class Block : public std::enable_shared_from_this<Block> { |
注意事项
- 必须通过
shared_ptr创建对象
- 只有当对象已经被
std::shared_ptr管理时,调用shared_from_this()才是安全的。 - 如果对象是通过
new直接创建的(或栈上创建),调用shared_from_this()会抛出std::bad_weak_ptr异常:
1 | Block* rawPtr = new Block(); |
- 构造函数和析构函数中禁止使用
- 在构造函数中,对象尚未被
std::shared_ptr管理,调用shared_from_this()会失败。 - 在析构函数中,
std::shared_ptr可能已经释放了对象的所有权,调用也会失败。
- 避免循环引用
- 如果
A和B互相持有对方的shared_ptr,会导致内存泄漏。此时应使用std::weak_ptr打破循环:
1 | class A : public std::enable_shared_from_this<A> { |
| 操作方式 | 底层实现 | 引用计数变化 | 控制块数量 |
|---|---|---|---|
ptr2 = ptr1 |
拷贝ptr1的控制块指针,引用计数加 1。 |
1→2 | 1 个 |
ptr2 = shared_from_this() |
通过类内部存储的weak_ptr创建shared_ptr,引用计数加 1。 |
1→2 | 1 个 |
ptr2 = std::shared_ptr<Block>(this) |
错误方式:创建新的控制块,与原ptr1的控制块无关,引用计数独立为 1。 |
各为 1 | 2 个 |
4.4.4 hash
1 | uint32_t compute_hash =std::hash<std:: string_view>{}( |
std::hash 是 C++ 标准库(<functional> 头文件)中用于生成哈希值的函数对象模板,广泛应用于哈希表(如 unordered_map、unordered_set)、哈希集合等数据结构中。它通过将输入数据映射为一个 size_t 类型的整数值(哈希值),实现高效的数据检索和存储。
核心特性
- 函数对象模板
std::hash<T>是针对类型T定义的模板,不同类型需要特化以提供哈希计算逻辑。例如:std::hash<int>:整数哈希std::hash<std::string>:字符串哈希std::hash<double>:浮点数哈希(需注意精度问题)
- 返回值类型
始终返回size_t类型(无符号整数,通常与平台指针长度一致,如 64 位系统为 64 位)。 - 哈希特性
- 确定性:相同输入必定生成相同哈希值。
- 高效性:计算复杂度通常为 O (1) 或 O (n)(n 为数据长度,如字符串)。
- 分布性:理想情况下,不同输入的哈希值应均匀分布,减少哈希冲突。
标准库预定义的特化
C++ 标准库为常见类型提供了 std::hash 的特化实现:
| 类型 | 哈希计算方式 |
|---|---|
bool, char, int, long 等基础类型 |
直接将数值转换为 size_t(如 int 的值直接作为哈希值)。 |
float, double |
将二进制位 reinterpret 为 uint32_t 或 uint64_t 后计算哈希。 |
std::string |
对字符串每个字符的 ASCII 码进行迭代计算(通常使用类似 DJB2 或 FNV 算法)。 |
std::wstring, std::u16string, std::u32string |
类似 std::string,按字符类型处理。 |
std::shared_ptr<T>, std::unique_ptr<T> |
基于指针地址计算哈希。 |
std::pair<T1, T2>, std::tuple<T...> |
组合各个元素的哈希值(如 hash(pair) = hash(first) ^ (hash(second) << 1))。 |
使用示例
基础类型哈希
1
2
3
4
5std::hash<int> hash_int;
std::cout << hash_int(42) << std::endl; // 输出 42 对应的哈希值
std::hash<std::string> hash_str;
std::cout << hash_str("hello") << std::endl; // 输出字符串的哈希值在容器中使用
1
2
3std::unordered_map<std::string, int> map;
// 内部自动使用 std::hash<std::string> 计算键的哈希值
map["key"] = 100;自定义类型的哈希
若要对自定义类型(如结构体)使用哈希,需特化std::hash1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17struct Person {
std::string name;
int age;
};
// 为 Person 特化 std::hash
namespace std {
template<>
struct hash<Person> {
size_t operator()(const Person& p) const {
return std::hash<std::string>{}(p.name) ^ (std::hash<int>{}(p.age) << 1);
}
};
}
// 使用示例
std::unordered_set<Person> people;
注意事项
- 哈希冲突
不同输入可能生成相同哈希值(冲突),标准库容器会通过链表或开放寻址法处理冲突,但良好的哈希函数可减少冲突概率。 - 浮点数哈希的不确定性
NaN的哈希值未定义,不同NaN可能生成不同哈希值。- 精度损失可能导致不同数值生成相同哈希值(如
1.0和1.0000000001)。
- 自定义哈希的要求
自定义类型的哈希函数需满足:- 若
a == b,则hash(a) == hash(b)(一致性)。 - 尽量保证不同值的哈希值分布均匀。
- 若
- C++17 新增特性
std::hash<std::string_view>:高效处理字符串视图的哈希。std::hash支持更多标准类型(如std::optional、std::variant)。
与其他哈希函数的对比
- 与
boost::hash的区别boost::hash提供更强大的组合哈希功能(如直接支持元组),而std::hash是标准库的一部分,兼容性更好。 - 与具体哈希算法(如 MD5、SHA-1)的区别
std::hash侧重于高效映射(速度优先),而 MD5 等加密哈希算法侧重抗碰撞性(安全性优先),两者应用场景不同。
4.4.5 std::string_view
轻量级字符串引用:不拥有字符串数据,仅指向现有字符数组(如
std::string、const char*)。零拷贝:避免字符串复制,提升性能。
用法示例:
1
2std::string str = "hello world";
std::string_view view(str.data(), 5); // 指向 "hello"
std::hash<std::string_view> 的优势
- 高效性
直接处理内存区域:无需构造完整的
std::string,直接对视图内的字符计算哈希。示例对比:
1
2
3
4
5// 传统方法(需构造临时 string)
std::hash<std::string>{}(std::string(data, length)); // 涉及内存分配与复制
// 使用 string_view(零拷贝)
std::hash<std::string_view>{}(std::string_view(data, length)); // 直接计算
- 兼容性
与
std::string哈希兼容:相同内容的string和string_view生成相同哈希值。1
2
3std::string str = "hello";
std::string_view view = str;
assert(std::hash<std::string>{}(str) == std::hash<std::string_view>{}(view));
- 减少内存分配
适用于临时字符串:如解析文本时的子串,避免频繁创建
std::string1
2
3
4
5void processSubstring(const char* data, size_t len) {
std::string_view view(data, len);
size_t hash = std::hash<std::string_view>{}(view); // 无内存分配
// ...
}
底层实现原理
std::hash<std::string_view> 的实现通常基于以下原则:
遍历字符计算哈希
对视图内的每个字符进行迭代,使用如 FNV-1a、MurmurHash 等算法生成哈希值。
示例伪代码:
1
2
3
4
5
6
7size_t operator()(std::string_view s) const {
size_t hash = 0;
for (char c : s) {
hash = (hash * 101) ^ static_cast<unsigned char>(c); // 简化版
}
return hash;
}
优化处理长字符串
- 某些实现可能跳过部分字符(如每隔几个字符处理一次),在速度和分布性之间权衡。
应用场景
- 解析协议 / 文件
示例:解析 HTTP 请求头时,快速哈希路径部分:
1
2
3
4
5
6
7// 请求行: "GET /api/users HTTP/1.1"
const char* data = ...; // 请求数据
size_t path_start = 4;
size_t path_length = 10; // 假设已解析出路径长度
std::string_view path(data + path_start, path_length);
size_t hash = std::hash<std::string_view>{}(path); // 直接哈希,无需构造 string
- 缓存键优化
场景:使用字符串片段作为缓存键,避免复制:
1
2
3
4
5std::unordered_map<std::string_view, CacheValue, std::hash<std::string_view>> cache;
void addToCache(std::string_view key, CacheValue value) {
cache[key] = value; // 注意:需确保 key 的生命周期覆盖缓存使用期
}
- 字符串去重
需求:快速判断重复子串(如编译器中的标识符):
1
2
3
4
5
6
7
8
9std::unordered_set<std::string_view, std::hash<std::string_view>> seen;
void processToken(std::string_view token) {
if (seen.count(token)) {
// 重复处理
} else {
seen.insert(token);
}
}
4.4.6 reinterpret_cast
reinterpret_cast<const char *>(encoded.data()) 是一个 C++ 类型转换表达式,其作用是将 encoded.data() 返回的指针强制转换为 const char* 类型。
reinterpret_cast<const char *>(...)
- 强制类型转换:
reinterpret_cast是 C++ 中最底层的类型转换操作符,用于将一种指针类型转换为另一种不相关的指针类型(如uint8_t*→const char*)。 - 内存视角:该转换不改变指针的值(即内存地址),仅改变编译器对该内存的解释方式:
- 原内存被视为
uint8_t数组(每个元素为 1 字节无符号整数)。 - 转换后被视为
const char数组(字符串或字符流)。
- 原内存被视为
4.4.7 **vector::reserve **
std::vector::reserve(size_t n)会为向量预分配至少n个元素的内存空间,但不改变向量的大小(size()仍为 0)。- 目的是减少后续插入操作时的内存重新分配次数,提高性能。
4.4.8 vector::assign
std::vector::assign(first, last)会清空向量并复制[first, last)区间内的元素到向量中。
内存分配与复制过程
- 由于之前已调用
reserve(offset_section_start),block->data的容量(capacity())已足够存储offset_section_start个元素,因此assign不会触发额外的内存分配。 - 数据从
encoded复制到block->data,覆盖block->data原有内容(若有)。
4.4.9 string构造重载
1 | std::string::string( |
4.4.10 string::compare
1 | int compare(const std::string& str) const noexcept; |
- 功能:将当前字符串与
str进行逐字符比较。 - 返回值:
- 负数:当前字符串 <
str - 0:当前字符串 ==
str - 正数:当前字符串 >
str
- 负数:当前字符串 <
1 | key.compare(target); |
4.4.11 share_ptr
推荐方式:
std::make_shared优点:一次分配同时创建对象和控制块,避免内存碎片
语法:
std::make_shared<T>(args...)示例:
1
auto ptr = std::make_shared<std::string>("hello");
原始指针构造(谨慎使用)
直接从裸指针构造,但需注意所有权转移:
1
2
3int* raw = new int(10);
std::shared_ptr<int> sp1(raw); // sp1获取raw所有权
std::shared_ptr<int> sp2(raw); // 危险!重复释放禁止将同一裸指针传入多个
shared_ptr,会导致双重释放。
从其他智能指针转换
从
unique_ptr转移所有权:1
2std::unique_ptr<int> up(new int(20));
std::shared_ptr<int> sp(std::move(up)); // up失去所有权
4.4.12 mutable
在 C++ 中,mutable 是一个关键字,用于修饰类的成员变量。当一个成员变量被声明为 mutable 时,它表示这个变量即使在 const 成员函数中也可以被修改。这一特性在实现缓存、懒加载或线程同步等场景中非常有用。
mutable 的核心作用
- 突破
const限制
在 const 成员函数中,默认所有非 mutable 的成员变量都不能被修改。但 mutable 变量是个例外:
1 | class Example { |
- 保持逻辑常量性
mutable 允许在不影响对象逻辑状态的前提下修改某些内部状态。例如:
- 缓存计算结果(如你提到的
cached_value) - 记录函数调用次数
- 实现线程安全的锁机制
在缓存场景中的应用
- 缓存值的更新
在你的代码中,cached_value 用于缓存当前迭代器的值。即使在 const 成员函数中(如 operator*()),也需要更新这个缓存:
1 | class BlockIterator { |
mutable std::optional<value_type> cached_value 的作用是:
允许在 const 成员函数中更新缓存值,同时保持迭代器的逻辑常量性。
这种设计使得迭代器的 const 接口(如 operator*())可以安全地使用缓存优化,而不违反 const 的语义。在现代 C++ 中,这是实现高效且安全的缓存机制的常见做法。
4.4.13 匿名函数
1 | auto func = [&preffix](const std::string &key) { |
这是一种 C++ 中的 Lambda 表达式(匿名函数),很多人第一次看会觉得“怪”,但其实它是非常实用的临时函数对象写法。
C++ 的 lambda 表达式完整结构如下:
1 | [捕获列表](参数列表) -> 返回类型 { 函数体 } |
你这个语句:
1 | auto func = [&preffix](const std::string &key) { |
对照结构就是:
| 部分 | 含义 |
|---|---|
[&preffix] |
捕获外部变量 preffix(以引用的方式) |
(const std::string &key) |
这是 lambda 的参数,接受一个 key |
{ ... } |
是函数体,返回一个 int |
auto func = ... |
把 lambda 函数赋值给变量 func,以后就可以当成函数用了 |
这段 lambda 定义了一个字符串前缀比较函数,它的逻辑是:
1 | return -key.compare(0, preffix.size(), preffix); |
它比较:key 的前 preffix.size() 个字符是否等于 preffix。并且它把 std::string::compare() 的结果取反,让它的返回值满足 get_monotony_predicate_iters 所要求的“方向语义”。
4.4.14 lambda 表达式
普通函数的写法:
1 | bool is_even(int x) { |
用 lambda 表达式写:
1 | auto is_even = [](int x) { |
和普通函数作用一模一样,只不过是匿名函数,直接用变量保存。
捕获列表
它是让 lambda 可以访问外部变量 的机制!
不捕获变量(只能用参数):
1 | auto f = [](int x) { |
捕获变量(让函数体能访问外部变量):
1 | int base = 10; |
[] 中的 base 表示:把外部的 base 变量值复制进来(值捕获)
捕获方式详解
| 写法 | 意义 |
|---|---|
[base] |
只捕获 base(值捕获,复制一份) |
[&base] |
捕获 base 的引用(改外部也会生效) |
[=] |
把用到的所有变量都值捕获(拷贝) |
[&] |
把用到的所有变量都引用捕获 |
1 | int a = 3, b = 4; |
4.4.15 string::assign
std::string::assign 是 C++ 标准库中用于修改字符串内容的成员函数,它提供了灵活的方式来替换当前字符串的内容。与赋值运算符 operator= 相比,assign 支持更多的参数形式(如子串、迭代器范围等),且允许链式调用。
一、函数原型与重载形式
- 用另一个字符串赋值
1 | string& assign(const string& str); // 拷贝赋值 |
- 用 C 风格字符串赋值
1 | string& assign(const char* s); // 以 '\0' 结尾的字符串 |
- 用重复字符赋值
1 | string& assign(size_t n, char c); // 重复 n 次字符 c |
- 用迭代器范围赋值
1 | template <class InputIt> |
- 用初始化列表赋值
1 | string& assign(initializer_list<char> il); // C++11 起,用花括号初始化列表 |
二、典型使用场景
- 替换为另一个字符串的子串
1 | std::string str = "hello"; |
- 用重复字符填充字符串
1 | std::string padding; |
- 从迭代器范围赋值
1 | std::vector<char> chars = {'a', 'b', 'c', 'd'}; |
- 链式调用
1 | std::string str; |
三、与赋值运算符 operator= 的对比
| 操作符 / 函数 | 特点 | 示例 |
|---|---|---|
str = other |
简洁,仅支持完整赋值 | str = "abc"; |
str.assign(...) |
灵活,支持子串、迭代器等形式 | str.assign("abc", 2); |
四、性能考虑
- 内存优化:若新字符串长度小于当前容量,
assign通常不会重新分配内存。 - 移动语义:使用右值引用参数时(如
assign(std::move(other))),避免深拷贝,仅转移资源所有权。
4.4.16 FileObj::create_and_write
1 | FileObj file=FileObj::create_and_write(path, file_content); |
一、代码逐段解析
FileObj::create_and_write(...)
- 静态工厂方法:
create_and_write是FileObj类的==静态成员函数==,无需创建对象即可调用。 - 功能推测:该方法负责创建文件、写入内容,并返回一个
FileObj对象。
- 返回值类型
FileObj::create_and_write的返回类型是FileObj,通过值返回一个新对象。
- 对象初始化
- 现代 C++ 优化:编译器通常会通过 RVO(返回值优化) 或 NRVO(具名返回值优化) 避免实际的拷贝或移动操作。
二、静态工厂方法的优势
- 封装构造逻辑
1 | class FileObj { |
- 控制对象创建
- 可返回子类对象(如
EncryptedFileObj)。 - 实现单例模式或对象池。
- 提供语义化接口
- 方法名(如
create_and_write)比构造函数更直观。
4.4.17 FileObj::read_to_slice
FileObj::read_to_slice 是一个典型的静态成员函数,用于从文件读取数据并填充到指定的内存区域(slice)。这种设计在文件处理、数据库系统和网络编程中非常常见。下面从功能、实现和使用场景三个方面详细解析:
- 函数定位
- 静态工厂方法:通过类名直接调用(如
FileObj::read_to_slice(...))。 - 数据传输:将文件内容读取到预先分配的内存缓冲区(slice)。
- 典型参数与返回值
1 | static bool read_to_slice( |
4.4.18 二分查找范围
一般情况下,二分查找的范围都是闭区间
标准写法(左右闭区间):
1 | int binary_search(const vector<int>& arr, int target) { |
这是“左右闭区间”的典型写法,合法范围为
[left, right],right = size() - 1,while (left <= right)。
左闭右开区间 [left, right) 的二分变种,这是在很多 STL 风格代码里也常见:
1 | size_t left = 0; |
这个写法在某些需要上界(upper_bound)、下界(lower_bound)功能的时候更方便,不用担心 right = size() 越界的问题。
总结:两种写法对比
| 区间类型 | 条件 | 初始化 right | 优点 |
|---|---|---|---|
| 左闭右闭 | left <= right |
right = size - 1 |
更直观、教学常见 |
| 左闭右开 | left < right |
right = size |
STL 风格、可避免越界 |
每一轮,区间都会被一分为二,保留一半,继续查找。
| 模式 | 区间表示方式 | 循环条件 | 每轮查找时区间内容 |
|---|---|---|---|
| 闭区间 | [left, right] |
left <= right |
包含 left 和 right |
| 开区间 | [left, right) |
left < right |
包含 left,不包含 right |
[left, right) 表示:我们要找的答案一定在 [left, right-1] 中。为什么 left < right 作为循环条件?因为当 left == right 时,这个区间变成了空区间(没有任何数字)!此时:区间 [left, right) 变成了 [x, x),里面没有数。所以不需要继续查找了,该退出了。
五、LSMEngine
5.1 engine
5.2 compact&&Iterator
level0阈值达到后就要合并:
- 首先,
Level 0的SST之间存在key的重叠, 需要进行去重, 这里我们可以复用已有的HeapIterator, 见Lab 2.2 迭代器 - 其次就是我们将要实现的
ConcactIterator, 这个迭代器会串联Level 1的所有SST形成层间迭代器 - 最后就是之后小节实现的
TwoMergeIterator, 这个迭代器会串联HeapIterator和ConcactIterator, 按照优先级对迭代器进行输出, 遍历TwoMergeIterator即可构建新的Level 1的SST
5.2.1 Concact Iterator
这个迭代器的作用是会串联Level 1的所有SST形成层间迭代器。
5.2.2TwoMergeIterator
整合两个优先级不同的迭代器(HeapIterator和ConcactIterator), 生成一个新的迭代器, 按照优先级对迭代器进行输出
遍历TwoMergeIterator即可构建新的Level 1的SST
5.2.3 Size-Tiered Compaction
Size-Tiered Compaction 按照「大小」和「层级」进行文件合并。
- 每层允许多个 SSTable(大致等大小)并存。
- 如果一层的 SSTable 数量超过阈值,就触发合并。
- 合并后的文件推送到下一层。
流程举例,初始状态(假设每层最多允许 3 个 SSTable):
1 | Level 0: [SST1] [SST2] [SST3] |
- Level 0 现在有了 3 个 SSTable,达到了阈值。
- 所以触发一次合并,把
[SST1] [SST2] [SST3]合并成一个新文件,比如SST7。 - 合并后,这个文件就被推到 Level 1。
合并后状态:
1 | Level 0: [] |
如果 Level 1 的文件数量也超过了阈值,那么这层也会触发合并,递归向下推送。
| 优点 | 说明 |
|---|---|
| 写入速度快 | 写完数据就 flush 成新 SSTable,后续再慢慢合并 |
| 实现简单 | 合并逻辑比较直观:到数量阈值就合并 |
| 缺点 | 说明 |
| 读放大严重 | 一个 key 可能出现在多个层,甚至多个 SSTable,读时需要查很多文件 |
| 延迟不可控 | 合并时需要读取多个大文件再写入,对系统资源要求高 |
读放大(Read Amplification):一次查询实际涉及的数据读取量远超你真正要的数据。
你要查 key=”user123”,但是:
- 它可能在 Level 0 的某个表
- 也可能在 Level 1 的某个表
- 也可能在 Level 2 的表……
你就要在多个 SSTable 中找,很浪费资源。
Size-Tiered 适用场景:
- 写入压力很大、但查询频率低的系统。
- 例如日志数据库、大数据离线处理(HBase 的默认策略就是 Size-Tiered)。
5.2.4 Leveling Compaction
Leveling 是另一种在 LSM 树中使用的压缩策略,常用于 读性能优先 的数据库(比如 RocksDB)。
它的核心思想是:
- 每一层(Level)中 SSTable 的 key 范围是互不重叠的。
- 一旦 Level L 的某个 SSTable 与 Level L+1 的范围冲突,就会触发合并,将其写入到 L+1。
- 每一层的空间大小按指数级增长,比如:
| Level | 容量(举例) |
|---|---|
| L0 | 20MB |
| L1 | 100MB |
| L2 | 500MB |
| L3 | 2GB |
| 维度 | Size-Tiered Compaction (STC) | Leveling Compaction |
|---|---|---|
| 合并触发 | 文件数超过阈值就合并 | 空间或范围冲突时合并 |
| 同层 key 是否重叠 | ✅ 允许重叠 | ❌ 不允许重叠 |
| 合并粒度 | 多文件合成一个大文件 | 一个文件与多个冲突文件合并 |
| 写放大 | 较小(写入快) | 较高(频繁写入下层) |
| 读放大 | 较大(需查多个文件) | 较小(只查一个 SSTable) |
| 适用场景 | 写多读少,如日志 | 读多读快,如数据库 OLTP |
Leveling 的流程示意
初始状态:
1 | Level 0: [SST1] [SST2] |
如果 SST2 的 key 范围是 e~m,则与 Level 1 的 SST3 和 SST4 都有交集。
压缩时:
- 会将
SST2与冲突的SST3和SST4合并,生成新的SST5: key=a~z。 - 替换掉
SST3/SST4。
合并后状态:
1 | Level 0: [SST1] |
| 优点 | 说明 |
|---|---|
| 读性能好 | 同一层不重叠,查 key 时只需在每层查一个文件 |
| 空间效率好 | 不会重复存多个版本,删除/覆盖旧值更及时 |
| 快速过期旧数据 | 很容易定位和清理 TTL 数据 |
| 缺点 | 说明 |
| 写放大高 | 数据会不断从 L0 → L1 → L2 递推,写入下层 |
| Compaction 更频繁 | L0 和 L1 之间合并密集,需要 CPU 和 IO 资源 |
Leveling vs Size-Tiered
1 | Leveling: |
一个混合策略:Universal Compaction(混合)
很多现代 LSM 引擎(如 RocksDB)支持一种 混合策略:
- 写入初期采用 Size-Tiered,提高吞吐;
- 数据量变大后切换为 Leveling,提高查询性能。
5.2.5 缓存池
缓存池只是通过智能指针把block对象留在内存里,不释放block对象
5.2.6 布隆过滤器
布隆过滤器测试代码修改位置。
LSMEngine::flush()
1 | // 3. 准备 SSTBuilder |
LSMEngine::full_common_compact
1 | auto new_sst_builder = |
补充知识
5.1.1 map
1 | std::map<size_t, std::deque<size_t>> level_sst_ids; |
这个变量表示每层的SST
std::map<KeyType, ValueType> 是一个将 Key 映射到 Value 的容器,Key 是唯一的,并自动排序。
初始化:
1 | level_sst_ids[0] = {1, 2}; |
map::find
find 是 map 提供的一个函数,用于查找某个 key 是否存在。
- 如果找到,返回指向该元素的迭代器(即
map<Key, Value>::iterator) - 如果没找到,返回
map.end()(末尾迭代器)
插入方式:
1 | age["Tom"] = 20; // 推荐,简洁且自动插入 |
访问元素
1 | cout << age["Tom"]; // 若 Tom 不存在,会创建默认值 0 |
要避免默认插入,可以先用 find:
1 | if (age.find("Lucy") != age.end()) |
std::map
- 使用红黑树(一种自平衡的二叉查找树)
- key 会自动排序
- 所以支持有序遍历、范围查找
1 | cpp复制编辑std::map<int, std::string> m; |
std::unordered_map
- 使用哈希表
- key 不排序,插入顺序不可预测
- 查找效率更快(理想情况下 O(1))
1 | cpp复制编辑std::unordered_map<int, std::string> m; |
5.1.2 is_regular_file
is_regular_file() 是 C++17 标准库 <filesystem> 中的一个成员函数,用于判断某个目录项(directory_entry 对象)是否是一个普通文件(regular file)。
在操作系统中,regular file(常规文件)指的是最普通的文件类型:
- 不是目录(directory)
- 不是符号链接(symlink)
- 不是块设备、字符设备、FIFO、socket 等特殊文件
- 它包含的是普通的用户数据(文本文件、二进制文件、图像文件等)
🌟 举例
假设你使用 std::filesystem 来遍历一个文件夹:
1 | #include <iostream> |
这段代码只会输出“普通文件”的路径,跳过目录、符号链接等其他类型。
📌 相关函数还有:
entry.is_directory():是否是目录entry.is_symlink():是否是符号链接entry.is_block_file():是否是块设备entry.is_character_file():是否是字符设备entry.is_fifo():是否是 FIFO(管道)entry.is_socket():是否是 socket
如果你在用的是 Linux、macOS 或支持 POSIX 的系统,这些类型在 ls -l 命令中也能看到,例如 -rw-r--r-- 前缀的就是 regular file。
5.1.3 std::stoull()
把字符串 str 转换成一个无符号整数(unsigned long long)
5.1.4 std::unordered_map
std::unordered_map 是 C++ 标准库中提供的一种 哈希表(Hash Table)容器,属于 <unordered_map> 头文件。
它是一个 键值对(key → value)映射表,并且相较于 std::map:
- 访问速度更快(平均 O(1))
- 内部元素无序(不是按 key 排序的)
std::unordered_map<Key, Value>是一个 基于哈希表实现的字典型容器,允许你通过 key 快速存取、插入、删除 value。
1 |
|
自定义哈希:
1 | // 自定义哈希函数 |
为什么要写 pair_hash 和 pair_equal?
默认情况下,std::unordered_map 只能对 内置类型(如 int, string 等)做哈希。而 std::pair<int, int> 是复合类型,标准库默认没有哈希支持。
所以需要自己定义:
- 哈希函数(告诉哈希表怎么计算 key 的哈希值)
- 相等比较器(告诉哈希表怎么判断两个 key 相等)
| 参数位置 | 类型 | 含义 |
|---|---|---|
| 1 | std::pair<int, int> |
键类型,复合 key |
| 2 | std::list<CacheItem>::iterator |
值类型,通常是某个缓存结构中 list 的迭代器 |
| 3 | pair_hash |
自定义哈希函数(告诉哈希表如何哈希 pair) |
| 4 | pair_equal |
自定义相等函数(告诉哈希表如何比较 pair) |
哈希函数 pair_hash 怎么工作?
1 | struct pair_hash { |
- 使用标准库已有的
std::hash<T>对pair的两个成员分别计算哈希 - 用
^(按位异或)合并成一个整体哈希值
相等函数 pair_equal 怎么工作?
1 | struct pair_equal { |
- 判断两个 pair 是否“相等”:两个元素分别都相等才算相等
为什么你需要这样用?(应用场景)
如果你想把一个二元 key(如 LRU-K 中的 (key, cache_id))作为哈希表的索引:
1 | std::unordered_map<std::pair<int, int>, ListIterator, pair_hash, pair_equal> |
就必须让哈希表“知道”怎么处理 pair<int, int>。
标准库不知道 pair 应该怎么哈希,但你可以教它。
当你想把
std::pair、std::tuple或自定义类当作 unordered_map 的 key,用法是:
1 | std::unordered_map<KeyType, ValueType, HashFunc, EqualFunc> |
必须给它提供:
HashFunc: 计算 key 的哈希值(即在哪个桶)EqualFunc: 判断两个 key 是否“相同”(即是否放进同一桶)
5.1.5 std::list::splice()
splice() 是 std::list 的特有成员函数,用于**“无拷贝地”把元素从一个 list 移动到另一个 list**,或在同一个 list 内部移动元素位置。
它的原型有几种重载,项目里用的是这一个:
1 | void splice(iterator pos, list& other, iterator it); |
1 | cache_list_less_k.splice(cache_list_less_k.begin(), cache_list_less_k, it); |
| 参数位置 | 含义 |
|---|---|
第1个参数 cache_list_less_k.begin() |
要插入的位置:把 it 插入到链表头部 |
第2个参数 cache_list_less_k |
要提取元素的源链表(这里是自己) |
第3个参数 it |
要移动的元素的位置(一个迭代器) |
所以这句话做的事是:
在
cache_list_less_k自己内部,把元素it移动到头部(begin())的位置。
- 不拷贝,不新建对象
- 只是改变链表节点的前后指针关系(
O(1)) - 是 LRU 更新位置的标准写法
假设原来链表是这样:
1 | cache_list_less_k: [A, B, C, D] |
执行:
1 | cache_list_less_k.splice(cache_list_less_k.begin(), cache_list_less_k, it); |
结果:
1 | cache_list_less_k: [C, A, B, D] |
C原地移动到了头部(begin())it指向的那个元素被挪到了第一个位置- 所有指针仍然有效
为什么不用 erase() + insert()?
因为:
erase()+insert()会导致 对象销毁再重建,效率低,还会破坏shared_ptr引用splice()是 真正的原地移动,对 LRU 这种场景非常高效且安全
六、事务
6.1 事务管理器
6.1.1 atomic
atomic 是 C++11 引入的 原子操作类型(std::atomic),用于在多线程环境下保证数据操作的原子性,避免竞态条件(race condition)。
普通遍历实现事务 ID 分配的问题:
1 | uint64_t next_txn_id = 0; |
这在单线程下没问题,但多线程时可能发生如下问题:
- 线程 A 读到的是
100 - 线程 B 也同时读到
100 - 两人都写入
101
事务 ID 重复,系统崩了!
使用 std::atomic 保证线程安全:
1 | #include <atomic> |
这段代码的意思是:
- 多个线程同时调用
getNextTxnId()时 fetch_add(1)是一个原子操作,只会让其中一个线程得到100,下一个是101,再下一个102……- 这样就保证了每个事务都有唯一 ID
6.2 Wal
6.2.1 std::thread
std::thread 是 C++11 引入的标准库类,用于创建和管理线程。它属于 <thread> 头文件,是现代 C++ 中多线程编程的核心组件之一。
std::thread是一个 轻量级封装的线程对象类,可以让你并行执行一个函数、lambda 或成员函数,并对该线程进行控制(启动、等待、分离等)。
创建线程
1 |
|
使用 lambda 创建线程
1 | std::thread t([] { |
常见成员函数
| 函数 | 说明 |
|---|---|
join() |
阻塞当前线程,直到该线程执行完 |
detach() |
分离线程,使其在后台运行(不能再 join) |
joinable() |
判断线程是否可以 join |
get_id() |
获取线程 ID |
native_handle() |
获取底层的线程句柄(如 pthread handle) |
线程函数类型支持
- 普通函数(如
void func()) - Lambda 表达式
- 函数对象(仿函数)
- 类的成员函数(需要传对象指针)
1 | class Worker { |
后台线程(如 WAL cleaner)
1 | void cleaner() { |
常见注意点
| 项目 | 说明 |
|---|---|
| 必须 join 或 detach | 否则程序结束时会 terminate |
不可复制 std::thread |
只能移动 std::move(thread) |
| 加锁同步 | 多线程间访问共享变量需要互斥锁(如 std::mutex) |
七、兼容Redis
7.1 Resp 协议简介
Resp(REdis Serialization Protocol,Redis 序列化协议)是 Redis 服务器与客户端之间进行数据交换的专用应用层协议,由 Redis 团队设计,核心目标是在 “简单易实现” 与 “高效传输” 之间取得平衡,确保客户端(如 Python 的redis-py、Java 的Jedis)能轻松与 Redis 服务器通信,同时最小化数据传输开销。
7.1.1Resp 协议的核心设计目标
Resp 的设计围绕以下 3 个核心需求展开,这也是其被广泛应用于 Redis 生态的关键原因:
- 简单易实现:协议格式直观,仅通过少量特殊字符(如
$、*、+等)标识数据类型,开发者无需复杂解析逻辑即可编写客户端; - 高效紧凑:数据格式无冗余字段,仅包含 “类型标识 + 数据长度 + 数据内容”,减少网络传输的字节数;
- 人类可读:协议的文本化特性(非二进制)使其支持通过
telnet等工具直接调试,便于问题排查(例如通过telnet 127.0.0.1 6379手动发送 Resp 命令)。
7.1.2 Resp 协议的核心数据类型(Resp 2.0)
Resp 协议通过首字符标识数据类型,不同类型对应不同的格式规则。目前 Redis 主要使用 Resp 2.0(Resp 3.0 为扩展版本,支持更多复杂类型,尚未完全普及),其核心数据类型如下表所示:
| 数据类型 | 首字符 | 格式说明 | 示例(客户端→服务器 / 服务器→客户端) |
|---|---|---|---|
| 简单字符串 | + |
+ + 字符串内容 + \r\n(结尾固定为回车换行,下同) |
服务器返回成功:+OK\r\n |
| 错误信息 | - |
- + 错误类型 + 错误描述 + \r\n(客户端需捕获并处理) |
键不存在:-ERR no such key\r\n |
| 整数 | : |
: + 整数数值 + \r\n(用于返回计数、状态码等,如LLEN的结果) |
LLEN list1的结果::5\r\n(表示列表长度为 5) |
| 批量字符串 | $ |
$ + 字符串长度(字节数) + \r\n + 字符串内容 + \r\n(支持空字符串) |
客户端发送键"name":$4\r\nname\r\n;空字符串:$0\r\n\r\n |
| 数组 | * |
* + 数组元素个数 + \r\n + 每个元素(任意 Resp 类型) |
客户端发送SET name Alice:*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nAlice\r\n |
| 空值 | $ |
特殊的批量字符串,格式为$-1\r\n(表示 “无数据”,如GET 不存在的键) |
服务器返回空值:$-1\r\n |
7.1.3 Resp 协议的通信流程(以 “客户端发命令 + 服务器回结果” 为例)
Resp 的通信逻辑完全基于 “请求 - 响应” 模型,流程如下:
- 客户端构造请求(数组类型):
Redis 命令(如SET、GET、HGETALL)本质是 “命令名 + 参数” 的集合,客户端需将其封装为 Resp 数组:- 首字符
*后接 “元素个数”(命令名 + 参数的总数量); - 每个元素(命令名、参数)用 “批量字符串” 表示(需指定长度)。
示例:发送GET user:100(命令名GET,参数user:100),请求格式为:*2\r\n$3\r\nGET\r\n$7\r\nuser:100\r\n
- 首字符
- 服务器解析请求并处理:
Redis 服务器接收字节流后,按 Resp 规则解析:- 先读取首字符
*,确定为数组类型,再读取2(元素个数); - 依次解析 2 个批量字符串:第一个是
GET(长度 3),第二个是user:100(长度 7); - 执行
GET user:100命令,获取结果(如用户信息"Alice")。
- 先读取首字符
- 服务器构造响应(对应 Resp 类型):
服务器根据命令执行结果,选择对应的 Resp 类型返回:- 若结果为字符串(如
GET成功):返回批量字符串,格式为$5\r\nAlice\r\n; - 若结果为整数(如
INCR count):返回整数,格式为:10\r\n; - 若命令无返回值(如
SET成功):返回简单字符串,格式为+OK\r\n; - 若命令执行失败(如键不存在):返回错误信息,格式为
-ERR no such key\r\n。
- 若结果为字符串(如
- 客户端解析响应并展示:
客户端读取服务器返回的字节流,按 Resp 规则解析为本地数据类型(如 Python 的str、int,Java 的String、Long),最终呈现给用户。
7.1.4 Resp 3.0:Resp 2.0 的扩展(可选了解)
Resp 2.0 仅支持上述 6 种基础类型,无法满足复杂场景(如区分 “字符串” 和 “数字”、“布尔值”)。Resp 3.0 在兼容 2.0 的基础上,新增了更多类型,例如:
- 布尔值:首字符
#,#t\r\n表示true,#f\r\n表示false; - 浮点数:首字符
,,如,3.14\r\n; - 有序映射:首字符
%,用于返回键值对(如HGETALL的结果可直接用映射表示,无需解析数组); - 大整数:首字符
(,用于超出 64 位的整数。
目前 Redis 6.0 + 已部分支持 Resp 3.0,但多数客户端仍以兼容 Resp 2.0 为主,如需使用高级特性需确认客户端支持度(如redis-py 4.0 + 支持 Resp 3.0)。
7.1.5 Resp 协议的优势与局限性
优势
- 低门槛:文本化格式 + 简单规则,新手可快速编写基础客户端(如用 Python 的
socket直接发送 Resp 格式的字节流); - 高性能:无冗余字段,数据紧凑,减少网络 IO 耗时(Redis 的高性能部分依赖于 Resp 的高效性);
- 跨语言兼容:仅依赖 “字节流解析”,无关编程语言(C、Java、Python、Go 等均有成熟客户端);
- 可调试性:支持
telnet或nc(nc 127.0.0.1 6379)手动发送命令,便于定位通信问题。
局限性
- 仅适用于 Redis:Resp 是 Redis 专用协议,无法用于其他数据库(如 MySQL、MongoDB);
- 文本格式的局限:相比二进制协议(如 Protocol Buffers),文本格式在传输超大二进制数据(如图片)时,效率略低(需额外传输长度字段的字符);
- Resp 2.0 类型不足:需通过 “约定” 区分数据语义(如用字符串存储 JSON,客户端需自行解析),Resp 3.0 虽缓解此问题,但普及度有限。
7.2 字符串类型
7.3 哈希类型
对于hash类型 重点实现4个函数hset 、hget、hdel、hkeys
对于hset 先检查是否过期,如果key 过期 就释放读锁 加写锁 如何key没过期,就直接操作(field_key,value )插入lsm,同时对key的value部分进行字段更新,如果存在就不做操作,不存在。就插入。
对于hget 先检查是否过期,如果key 过期 就返回
$-1\r\n否则查询对于hdel(删除一个哈希类型的
key的某个字段) 先检查key是否过期 ,如果过期返回:0\r\n;否则删除key_field然后更新字段列表。对于hkeys 查询是否过期 如果过期返回
*0\r\n否则返回拼接的字符串"$" + std::to_string(field.size()) + "\r\n" + field + "\r\n";
7.4 set类型
对于set类型我们不把所有Set中所有的字符串都编码或者拼接后存储在单个键值对的value中, 因为通常的业务中, 一个Set中的字符串数量是远大于哈希结构的filed的, 这里若还采用整体编码存储在单个键值对中, 在CRUD的过程中, 编解码会消耗大量的时间。
我们直接将字符串当作有前缀的 key插入lsm
例如 命令 sadd
1 | sadd nums 1 2 3 4 5 |
此时key为nums ,然后将字符串改变为 nums_1 nums_2 nums_3 nums_4 nums_5 ,然后按照(nums_1 ,1).。。。(nums,5)插入LSM。 get时候根据前缀和进行查询
7.5 RedisSever
使用moduo网络框架进行服务端搭建
7.5.1 bind
muduo 作为 Reactor 模型的网络库,针对 TCP 连接的不同事件,设计了不同类型的回调函数,每种回调的参数列表是库预先定义好的(这是 “函数签名” 的要求,必须匹配才能调用)。
| 回调类型 | 注册函数 | 作用场景 | 要求的函数签名(参数列表) | 你的例子对应 |
|---|---|---|---|---|
| 连接回调 | setConnectionCallback |
处理 “连接建立 / 断开” 事件 | 仅 1 个参数:const TcpConnectionPtr& |
你之前写的 onConnection |
| 消息回调 | setMessageCallback |
处理 “客户端发送数据到达” 事件 | 3 个参数:const TcpConnectionPtr&、Buffer*、Timestamp |
现在的 onMessage |
简单说:
- 当客户端刚连上来 / 刚断开时,muduo 会调用「连接回调」,此时只需要知道 “是哪个连接发生了事件”(所以 1 个参数足够);
- 当客户端发来了数据时,muduo 会调用「消息回调」,此时需要知道 “哪个连接”“数据存在哪里”“数据是什么时候到的”(所以需要 3 个参数)。
“只能有一个参数”,是特指「连接回调」,而 onMessage 是「消息回调」,参数数量自然不同 —— 这是库的设计决定的,不是矛盾。
第二步:为什么 “对象的成员函数” 需要用 std::bind?
你之前的疑问 “为啥不能传入对象的函数,只能 bind 绑定”,核心原因是:对象的成员函数比普通函数多一个 “隐藏参数”——this 指针。
我们用你的 onMessage 举例子:
假设你把 onMessage 定义为某个类(比如 EchoServer)的成员函数:
1 | class EchoServer { |
此时,EchoServer::onMessage 的真实函数签名是:void (EchoServer*, const TcpConnectionPtr&, Buffer*, Timestamp)
(第一个参数是 this,指向调用该函数的 EchoServer 对象)。
但 muduo 的 setMessageCallback 要求的函数签名是:void (const TcpConnectionPtr&, Buffer*, Timestamp)
(没有 EchoServer* 这个参数)。
两者签名不匹配,直接传 &EchoServer::onMessage 会编译报错 —— 因为 muduo 不知道要传给 this 指针哪个对象。
而 std::bind 的作用,就是把 “成员函数” 和 “它所属的对象” 绑定起来,消去 this 这个隐藏参数,让绑定后的函数签名和 muduo 要求的一致。
比如:
1 | // 1. 创建一个 EchoServer 对象 |
绑定后,当 muduo 调用 msgCallback(conn, buf, time) 时,底层会自动转为 server.onMessage(conn, buf, time)——this 指针已经通过 &server 确定了。
总结
- 参数数量差异的原因:你看到的
onMessage是「消息回调」(3 个参数),之前的onConnection是「连接回调」(1 个参数),两者是 muduo 为不同事件设计的不同回调,规则独立,不矛盾; std::bind的作用:对象的成员函数有隐藏的this指针,导致函数签名和 muduo 要求的不匹配;std::bind能绑定对象地址(消去this),让签名匹配,从而正常传入。
五、问题记录
5.1 Memtable友元类
Memtable的友元类为什么要有HeapIterator等这两个类,HeapIterator访问了Memtable里的成员吗?
1 | class MemTable { |
5.2 Block::add_entry
1 | if(!force_write && (cur_size()+key.size()+value.size()+3*sizeof(uint16_t)+sizeof(uint64_t)>capacity) && !offsets.empty()){ |
上述代码中的!offsets.empty() 设计的意义是什么,为空 肯定能插入,就不考虑超过块大小? 不为空才考虑是否超过块大小吗? 那么不应该写在前面吗 ,写后面起不到作用呢?