Bustub数据库****
项目一:****
目的:****
为什么要自己写缓冲池管理器?:
1. 直接将硬盘数据运到自建缓冲池给程序用,省去了linux页缓存从内核到用户态的一次多余拷贝,提升了速度的同时降低了空间浪费。
2. 可以自定义缓存淘汰策略,保证正在使用的页不会被刷盘出去。
3. 数据库的自定义页大小不一定是4KB和页缓存大小不一致
4. 控制这个刷盘顺序,必须保证日志(undolog)先落盘然后再刷数据。
实现?****
1.ARC类:维护四个链表和对应的unordered_map用来快速更新位置。分别对应T1,T2,B1,B2。链表数据存的是帧id,从新到旧。再维护一个帧id->帧状态(evictable,status,frame_id,page_Id)的映射。不可驱逐(pincount)的条目会被跳过。如果所需驱逐侧(mru_ / mfu_)的所有条目都被固定,我们会尝试从另一侧(mfu_ / mru_)选择受害者,并将其移动到对应的幽灵列表(mfu_ghost_ / mru_ghost_)。
接口:更新,驱逐,删除,设置是否可驱逐。(LRC,LFU,ARC的优缺点)
2. BufferPoolManager:
存储帧状态和数据的vector,页帧映射的表,空闲帧list,arc类,磁盘调度器类。从磁盘上将数据读取到对应页映射的帧头中,里面由vector数组,直接在这里对页进行修改,实现在内存读磁盘页或暂写磁盘页内容的操作,在换出时再刷到磁盘。
3.disk_schedule:由bpm_创建写入磁盘(如刷盘)或者从磁盘读入对应帧的两种请求,加入任务队列,在这里分了k个任务队列,由k个线程执行自己的队列任务,保证相同页的任务的串行,不同页任务间的并行。(提高性能)
怎么实现性能优化?
1. 缓存命中率上调和磁盘IO次数降低是怎么实现的?:从LRU-K到ARC****
2. 读写延迟降低?:写操作异步执行,提高响应速度。(创建disk_request时,promise和future导致可以后台写,前台继续跑,提高速度);****
3.disk_schedule:由bpm_创建写入磁盘(如刷盘)或者从磁盘读入对应帧的两种请求,加入任务队列,在这里分了k个队列,由k个线程执行自己的队列,保证相同页任务的串行,不同页任务间的并行。(提高性能)****
问题拓展
缓存策略优缺点****
LRU
优点:内存消耗小,实现简单
缺点:全表扫描挤走热点数据,长期热点但是最近冷淡的高频数据页被挤走
场景:浏览器缓存,会话数据
LFU:
优点:全表扫描不会挤走热点数据,保留长期热点长期命中率高。
缺点:实现复杂(堆或有序链表logn),资源消耗高。旧业务接口无法被驱逐,占用缓存,冷启动问题。
场景:金融交易系统,系统核心配置
ARC:
优点:B1,B2避免全表扫描挤走热点数据,T1保留短期热点,T2保留长期热点,自适应强。T2 高频队列采用LRU 时序淘汰,长期不访问的历史热点会逐步后置、优先被清理
缺点:实现复杂,内存开销更大,调试困难
场景:数据库缓冲池,分布式缓存****
项目二****
实现?****
1. 继承page基类实现内部页,叶子页和头部页
2. Context类保留查找过程中的锁的上下文。
3. 实现B+树的查找,插入,删除操作,其自带头部页的页id,bpm_指针
查找:先通过bpm获取头部页读保护,然后获取根节点页id,释放。获取根节点读保护,再二分查找往哪个指针走,先获取子节点读保护再释放当前节点的读保护,采用蟹型锁策略,一路到叶子节点读取。
插入:一路获取查找路径上的写锁并强转成内部页或者叶子页类型,加入context类保存,一直到叶子节点为止。然后进行溢出判断和处理。内部节点逻辑略有不同,旧页保留拆分点左侧指针,新页保留拆分点右侧指针。父节点插入新键,需要处理递归上溢****
删除:加锁逻辑同上,叶子节点删除后若小于下限得处理下溢(叶子做根不用),通过上下文类找到父节点和兄弟节点,先看兄弟节点能不能借,不能就合并到兄弟节点,合并后父节点内部键可能减少,需要处理递归下溢。****
怎么实现性能优化?
每次获取子节点时判断子节点有没有可能发生上溢或者下溢,如果没有可能,就先获取,然后释放路径上的所有读锁。****
将每次获取路径写锁改为每次获取读锁,在最后叶子节点获取写锁,降低并发冲突,当发现需要处理递归上溢,再重滚操作,改为加写锁。****
是否遇到过死锁?遇到过的问题?****
1. b+树迭代器初始实现时把叶子页的保护写死在迭代器中,导致在某些需要获取迭代器的查询中,初始化迭代器后无法正常写迭代器所在页内部的数据。因为写操作需要获取该叶子页的写锁,而他已经被迭代器获取了。
解决:初始化迭代器并不拿锁,在调用迭代器的解引用之后再获取相应页保护。
2. 当B+树只有一个节点的时候,他又是叶子节点也是根节点,如果这个节点满了,那么插入后会发生分裂,变成两个新的叶子节点,此时还会产生上溢,生成一个新的内部节点。此时要从信息页中修改根节点的页id,从原叶子页到新内部页。但是由于原先提到,只要我要修改的页可能会发生溢出,那么我就不会所释放自己的父节点,也就是不会释放信息页。所以我是在同时获取信息页和原根节点页的两个锁的情况下进行分裂上溢的,此时如果要修改根节点页id,需要再次获取信息页的页保护,就会造成循环等待(从相反方向获取同一个锁)。
解决:信息页和根节点页之间不采取先获取子节点后释放父节点的策略。
项目三****
实现?****
1. 查询优化嵌套循环转外部哈希连接:递归提取逻辑表达式和运算符表达式,处理多条件连接的情况。
2. 自定义外部页:先定义一个外部页,用来存放大表连接时内存放不下的分区数据,一个分区可能会有多个磁盘页(数据倾斜,冲突),单个页只存放元组个数和元组序列化后的内容。
3. 分区管理类:记录每个分区管理着哪几个磁盘页,这样在构建分区哈希表的时候便于管理和实现。
4. 外部哈希连接类:两个哈希函数,分区和探测hash,两个分区管理类vector。从左右子计划节点不断调用next()函数获取元组并进行分区。然后对右子计划节点数组按照分区构建探测哈希表。实现next()函数,对每个分区进行探测,批次满则返回,直到没有匹配的。
待优化的地方:
2.当同一个分区的数据很多的时候容易产生哈希冲突,可以考虑递归哈希一遍并做上相应的记号。防止分区内的元组过多而导致的哈希表内存占用巨大。
可扩展的问题:
横向对比索引嵌套循环连接,哈希连接,归并连接:****
索引嵌套循环连接:
外表小,内表大且有索引,内表无需全表扫描,内存低速度快O(nlogn),支持范围查询以及有序输出,大表不合适
哈希连接:
优点:大数据量下效率高O(N+M),适合大表join大表,无需索引
缺点:只支持等值join,消耗大量内存需要缓存整张build表,外部页会产生磁盘临时文件,IO增长。
归并连接:****
优点:有序遍历,顺序IO,输出结果有序
缺点:两张表都要按joinkey排序,复杂度高,适用于两张表都已经有序的情况
MyMuduo
补充:(既可以是性能优化也可以是解决的问题)
1. 为什么会导致数据竞争?因为同一个connection的业务操作可能被不同的工作线程取走并执行,如果在工作线程中执行网络IO,那么可能会有两个工作线程同时要操作connection类的输出缓冲区和write标识。因此会导致数据竞争。
2. 每个connection内部都会持有一个对应loop的指针,因此当工作线程调用send时,send函数内部会触发对应loop的runinloop函数,这个函数就是专门投递发送IO的函数,是eventloop类的成员函数,函数内部加锁投递,并且投递后立马调用wakeup函数唤醒epollwait开始处理任务队列中的发送请求。
优点:
1.做到了IO线程处理所有网络IO,分离了业务处理逻辑,避免阻塞网络IO
2.加上任务队列之后,从需要给每个connection都加上一个锁变为了一个从事件循环一个锁。
Socket层面:
1.SO_REUSEADDR端口可快速重启解决Address already in use
2.SO_REUSEPORT多线程绑定同一端口多核负载均衡,提升并发
3.TCP_NODELAY关闭 Nagle低延迟,数据立刻发送
Rector层面:
1.Reactor 是一种基于事件驱动和IO 多路复用(如 epoll)实现的高性能网络编程模型,它由一个或多个 IO 线程持续循环监听文件描述符上的读写、连接等 IO 事件,当事件触发时同步分发给对应的回调函数处理,全程采用非阻塞 IO,避免了阻塞等待与多线程频繁切换,从而实现高并发、低延迟、低资源开销的网络数据处理。
(重要:当readcallback被调用时:从tcp缓冲区读数据到buffer时会导致最后一次读空,如果是非阻塞IO,本次调用会一直阻塞到有数据再进来,但是如果非阻塞IO就可以直接返回了,意味着本次处理读结束)
3. Reactor 是同步事件分离器,等待 IO 事件就绪后通知应用程序主动进行数据读写;Proactor 是异步事件处理器,由操作系统完成数据拷贝后直接通知应用程序处理结果。Reactor 实现简单、Linux 生态成熟,是高性能服务器主流方案;Proactor 性能理论更高,但实现复杂,Linux 支持不完善。
RPC库
1. 模板元编程支持任意参数传递
首先在路由类定义好一个unordered_map,用于进行外部业务函数注册和存储,主要存储函数名-》一个固定类型(invoke_type)的函数指针bool(*)(对端connection,包含序列化参数列表的buffer)
然后通过reg_handle来进行业务函数注册,将函数绑定到这个map上。在这个函数指针的内部调用一个叫做invoke_callback的模板函数,当然这个模板函数的参数不仅包括invoketype的参数,也包括raghandle的参数(参数名,业务函数指针)。
为什么要这么做呢?****
我的invokes调用是在客户端调用时触发的,也就是那个时候传入conn和buffer,因此invokes_回调函数参数一定要预留一个conn,buffer的参数传递占位符,而我的业务函数指针是注册的时候传入的,可以在reg_handle注册时就传入。
template<typename Function>
void reg_handle(const std::string& name, Function f) {
auto h = hash(name);
invokes_[h] = { name, [f](const std::shared_ptr& conn, const std::string& buffer) {
return invoke_callback<Function, std::nullptr_t>(f, nullptr, conn, func_name, id, buffer);
}
};
}
这样当我们调用这个对应的invoke_[h].second函数时,只需要传入conn和buffer就补齐了内部function执行所需要的参数,最后在invoke——callback内部调用std::apply(tuple,f)即可。
template<typename ReturnType, typename... Args>
struct function_traits<ReturnType(*)(Args...)> {
//以这个形式分离出返回值,可变参数包
static constexpr std::size_t total_argc = sizeof...(Args);
using all_args_tuple = std::tuple<remove_const_reference_t...>;
//用sizeof获取业务参数的具体个数,以及用tuple获取去掉引用和const类型之外的
类型元组。
最后invoke_callback内部做一个统一的apply调用即可
typename FuncTraits::args_tuple args; // 创建一个FuncTraits::args_tuple类型的业务参数元组
nlohmann::json json = decode(id.msg_type, buffer);//从网络流中将参数解析到json中去
nlohmann::json jret;
nlohmann::from_json(json, args);
jret = std::apply(f, std::tuple_cat(std::make_tuple(conn), std::move(args)));
2. ZooKeeper 服务注册发现,实现节点动态感知,避免地址硬编码。
(这是一个让AI帮我扩展的优化思路,整体思路就是,watch /mrpc/节点下的路径有没有变化,有就重新全量拉取并缓存,此后每个节点调用只根据rpc名称调用函数时都先需要遍历这个缓存表,查找是那个节点的函数,获取IP:Port然后再发送,这样可以支持服务水平扩展)
3. promise/future 实现 RPC 异步回调机制,避免同步阻塞空等浪费
template<size_t TIMEOUT, typename RET = void, msg_type_fmt FMT = DEFAULT_MSG_FORMAT, typename... Args>
req_result call(const std::string& rpc_name, Args&&...args) {
auto [req_id, future] = async_call(rpc_name, std::forward(args)...);
LOG_DEBUG("调用服务 {} 方法 {}", rpc_name, typeid(RET).name());
auto status = future.wait_for(std::chrono::milliseconds(TIMEOUT));
if (status == std::future_status::timeout || status == std::future_status::deferred) {
{
std::unique_lockstd::mutex lock(cb_mtx_);
future_map_.erase(req_id);
}
return req_result(408, "Request Timeout");
}
auto [id, msg_body] = future.get();
return req_result(id, msg_body);
}//异步基础上封装的同步,异步回调的话就是将回调函数和req_id绑定就可以了
4. 具体的协议头是怎么设计的?
Msg_type:是一个32位整数。包含类型定义,包括是请求还是响应,异步还是同步,是否需要响应,是否是协程调用,是广播消息还是心跳消息等等。同时还包含序列化格式类型,包括JSON,原始二进制数据,BJSON等。
Msg_id:主要是rpc函数哈希之后对应的id,用来到对端识别要调用的函数。
Req_id:用来唯一标识请求,防止响应乱序或丢失。
Bodylen:就是序列化的参数体长度。
这样设计可以保证每个请求能够应对粘包和拆包的情况。
实习经历
1. 为什么要实现SPSC无锁环形缓冲区?
场景:这个主要是在原来代码的思路上修改的,在那种一定时间窗口内频繁交易的监控中,我们为每一个证券维护一个时间队列,这个时间队列主要用于存放每一次对该券的买入/卖出操作的结构体,包含金额,方向,数量,时间戳。每次有新交易消息进来都会把操作信息加入这个时间队列。同时会遍历一下所以可以执行的监控,然后遍历监控,当检查到包含时间窗口要求的监控时就得对窗口内的操作数,总金额做统计,那么自然要排除过期的数据。所以会有一进一出嘛,但是每次等到进来监控了再开始去排除过期数据会拖慢流程,并且有多个监控,每个监控都需要去去除过期数据。速度慢。
我就想着改造一下这个流程。改为新增一个后台线程,定时处理后期数据。但是这样就又有一个问题,定时时间非常小,并且会极大的加剧数据放入时候的锁竞争。因此不可取。后来我就了解到SPSC无锁缓冲区实际上是解决了这个问题的。
因为SPSC实际上是没有这个锁竞争的问题的。消费者只改变读小标,生产者只改变写下标,因此两者并不冲突,其实完全可以不加锁,但是真正要解决的是编译器优化
1. 因为指令重排。导致一定程度上的乱序,原来是先写数据再移动下标,但可能变成先移动下标再写数据,导致消费者读到垃圾数据。
2. 因为可能会把下标直接优化到寄存器,导致不可以见等
Atomic变量强制刷回内存系统,并且禁止系统乱排序。因此两个线程互相之间可以永远第一时间看到数据的更新,而且因为没有竞争无需加锁,提高了过期数据的淘汰速度,也让监控无序逐个淘汰。缺点可能就是定时时间依旧很短而且会增加cpu的占用。
·当然我们还需要保证读下标和写下标是在不同缓存行的,否则就会触发伪共享导致性能下跌。如果两个变量在不同缓存行,消费者读自己的read下标可以一直从cpu缓存读,只要该缓存行不被换出,就一直不用走内存,速度很快。但是如果两个下标放在了一起,那么当生产者移动了write下标,就会导致整个缓存行变脏包括read,当消费者线程要去读自己的read时,他会发现其他线程修改了该缓存行导致变脏,自己核心中的缓存行失效,需要必须触发缓存一致性协议,让生产者刷回内存后自己再从内存重新读入缓存行。