格蓝若 C++软件开发 二面 面经

1. 先简单介绍一下你自己,重点说说你最擅长的技术领域和最有成就感的项目。

面试官您好,我是XXX。我最擅长的是C++后端开发和系统编程,对高性能服务器、分布式系统比较有研究。最有成就感的项目是我做的分布式缓存系统,从零开始设计实现,经历了性能优化、架构演进的完整过程。最初QPS只有几万,通过内存池、无锁队列、零拷贝等优化,最终达到了20万QPS。这个项目让我深入理解了高性能系统的设计原理,也锻炼了解决复杂问题的能力。除此之外,我对Linux系统编程、网络编程、多线程编程都有比较深入的实践。

2. 详细说说你的缓存系统项目,从系统设计的角度介绍架构、技术选型、关键设计决策。

这个缓存系统的设计目标是高性能、高可用、可扩展。

整体架构采用分层设计。最底层是存储引擎,实现了多种数据结构(string、list、hash、set、zset),使用跳表、哈希表等高效数据结构。中间层是网络层,使用epoll实现高并发IO,Reactor模式处理请求。上层是协议层,实现了类Redis的文本协议,简单易用。

技术选型方面,编程语言选择C++,因为性能要求高,需要精细控制内存。网络库自己实现,基于epoll,比现成的库更轻量。数据结构也是自己实现,针对缓存场景优化。持久化支持RDB和AOF两种方式,RDB快照适合备份,AOF日志适合恢复。

关键设计决策包括:一是单线程处理请求,避免锁竞争,简化设计。虽然是单线程,但通过epoll可以处理大量并发连接。二是内存管理使用内存池,预分配大块内存,按需分配小块,避免频繁malloc导致的碎片和性能问题。三是持久化异步进行,fork子进程处理,不阻塞主进程。四是使用LRU淘汰策略,内存不足时自动淘汰最少使用的数据。

为了高可用,实现了主从复制。主节点接收写请求,异步复制到从节点。从节点可以处理读请求,分担主节点压力。主节点故障时,可以手动切换到从节点。

为了可扩展,实现了客户端分片。客户端根据key的哈希值选择服务器节点,使用一致性哈希算法,增删节点时只影响部分数据。

3. 你提到使用了零拷贝技术,具体是如何实现的?带来了多大的性能提升?

零拷贝主要用在网络数据传输上。传统的数据传输需要多次拷贝:从网卡到内核缓冲区,从内核缓冲区到用户空间,从用户空间到内核缓冲区,从内核缓冲区到网卡。每次拷贝都消耗CPU和内存带宽。

我使用了几种零拷贝技术。一是sendfile系统调用,可以直接在内核空间传输数据,不需要拷贝到用户空间。适合文件传输场景,比如发送静态文件。但我们的场景是内存数据,不能直接用sendfile。

二是使用mmap内存映射。将文件映射到内存,读写文件就像读写内存,减少了一次拷贝。我在持久化时使用了mmap,将数据文件映射到内存,写入数据时直接写内存,由操作系统负责刷盘。

三是使用writev和readv,可以一次读写多个缓冲区,减少系统调用次数。在发送响应时,协议头和数据体分别在不同的缓冲区,使用writev一次发送,避免了拷贝合并。

四是使用splice和tee系统调用,可以在两个文件描述符之间直接传输数据,完全不经过用户空间。在代理场景下很有用,但我们的场景用不上。

最重要的优化是减少数据拷贝次数。在处理请求时,解析协议时直接在接收缓冲区上操作,不拷贝数据。查询数据时,直接返回数据的指针,不拷贝数据。只有在需要修改数据时才拷贝。使用string_view代替string,避免不必要的拷贝。

通过这些优化,CPU占用降低了30%左右,吞吐量提升了40%。特别是在大value场景下,效果更明显。

4. 如何设计一个分布式锁?需要考虑哪些问题?如何保证正确性?

分布式锁的核心是保证在分布式环境下,同一时刻只有一个客户端能持有锁。

基于Redis实现分布式锁,最简单的方式是使用SETNX命令。SETNX key value,如果key不存在则设置成功返回1,存在则失败返回0。设置成功的客户端获得锁,其他客户端获取失败。使用完后DELETE key释放锁。

但这个方案有问题。如果客户端获取锁后崩溃,锁永远不会释放,导致死锁。解决方法是设置过期时间,使用SET key value EX seconds NX,原子地设置值和过期时间。即使客户端崩溃,锁也会自动释放。

但又有新问题。如果客户端A获取锁,执行时间超过了过期时间,锁自动释放。此时客户端B获取了锁。然后客户端A执行完,删除了锁,实际上删除的是客户端B的锁。解决方法是设置唯一的value,比如UUID,删除时检查value是否匹配,只删除自己的锁。使用Lua脚本保证检查和删除的原子性。

还有一个问题是锁的续期。如果任务执行时间不确定,可能超过过期时间。解决方法是启动一个守护线程,定期检查锁是否还持有,如果持有就延长过期时间。这叫看门狗机制。

对于高可用场景,单个Redis节点不够可靠。可以使用Redlock算法,在多个独立的Redis节点上获取锁,只有在大多数节点上获取成功才算成功。这样即使部分节点故障,锁仍然可用。

还需要考虑的问题包括:锁的粒度,粒度太大影响并发,太小增加开销。锁的超时时间,太短容易误释放,太长影响可用性。锁的重入,同一个客户端能否多次获取同一个锁。锁的公平性,是否按请求顺序获取锁。

在我的项目中,实现了基于Redis的分布式锁,使用了唯一value和Lua脚本保证正确性,使用了看门狗机制自动续期。在关键业务逻辑中使用分布式锁,保证了数据一致性。

5. 说说你对数据库索引的理解,B+树和哈希索引有什么区别?为什么MySQL使用B+树?

数据库索引是用来加速查询的数据结构。没有索引时,查询需要全表扫描,时间复杂度O(n)。有了索引,可以快速定位数据,时间复杂度O(log n)。

B+树是最常用的索引结构。B+树是多路平衡搜索树,每个节点可以有多个子节点。B+树的特点是,所有数据都在叶子节点,叶子节点之间有指针连接,形成有序链表。非叶子节点只存储键值,不存储数据,可以存储更多的键,减少树的高度。

B+树的优点是,范围查询效率高,因为叶子节点是有序链表,可以顺序扫描。树的高度低,通常3-4层就能存储百万级数据,IO次数少。支持顺序访问和随机访问。适合磁盘存储,因为每个节点对应一个磁盘块,减少磁盘IO。

哈希索引使用哈希表实现。通过哈希函数将键映射到桶,每个桶存储数据的位置。哈希索引的优点是等值查询非常快,时间复杂度O(1)。缺点是不支持范围查询,不支持排序,不支持最左前缀匹配。哈希冲突会影响性能。

MySQL的InnoDB引擎使用B+树索引,原因有几个。一是支持范围查询,SQL中经常有BETWEEN、>、<等范围条件。二是支持排序,ORDER BY可以利用索引的有序性。三是支持覆盖索引,查询的列都在索引中,不需要回表。四是适合磁盘存储,B+树的节点大小通常是磁盘块大小,减少IO。五是支持前缀索引,可以只索引字符串的前几个字符。

InnoDB的主键索引是聚簇索引,叶子节点存储完整的行数据。二级索引的叶子节点存储主键值,查询时需要回表,先通过二级索引找到主键,再通过主键索引找到数据。

在我的项目中,设计数据库表时,会根据查询模式创建合适的索引。对于等值查询的列创建普通索引,对于范围查询的列创建B+树索引。对于组合查询,创建联合索引,注意最左前缀原则。通过EXPLAIN分析查询计划,优化索引使用。

6. 如何设计一个高性能的消息队列?需要考虑哪些关键问题?

设计高性能消息队列需要考虑几个关键问题。

首先是消息的存储。内存队列速度快,但容量有限,重启会丢失数据。磁盘队列可靠,但速度慢。可以采用混合方案,消息先写入内存,异步刷盘。使用顺序写入,充分利用磁盘的顺序IO性能。使用mmap内存映射文件,减少拷贝。使用零拷贝技术,sendfile直接传输文件。

其次是消息的可靠性。消息不能丢失,需要持久化。可以使用WAL(Write-Ahead Log),先写日志再处理。消息需要确认机制,消费者处理完后发送ACK,队列才删除消息。如果消费者崩溃,消息重新投递。需要处理重复消息,消费者要实现幂等性。

第三是消息的顺序性。如果需要保证顺序,可以使用单个队列,但并发度低。可以使用分区,相同key的消息发送到同一个分区,分区内保证顺序。不同分区可以并发消费。

第四是消息的延迟。需要低延迟,可以使用批量发送,减少网络往返。使用异步发送,不等待响应。使用零拷贝,减少数据拷贝。使用高效的序列化,比如Protobuf。

第五是消息的吞吐量。需要高吞吐,可以使用批量处理,一次处理多条消息。使用多线程或多进程,并发处理。使用分区,水平扩展。使用压缩,减少网络传输和磁盘占用。

第六是消息的可用性。需要高可用,可以使用主从复制,主节点故障时切换到从节点。使用集群,多个节点提供服务。使用分区,部分节点故障不影响其他分区。

参考Kafka的设计,使用分区和副本保证可用性和扩展性。使用顺序写入和零拷贝保证性能。使用消费者组实现负载均衡。使用offset记录消费位置,支持重复消费。

在我的项目中,实现了一个简单的消息队列,使用环形缓冲区存储消息,使用CAS实现无锁队列。生产者和消费者并发操作,性能很好。使用文件持久化,定期刷盘。实现了消息确认机制,保证消息不丢失。

7. 说说C++的内存模型,什么是内存序?如何使用atomi

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

C++八股文全集 文章被收录于专栏

本专栏系统梳理C++技术面试核心考点,涵盖语言基础、面向对象、内存管理、STL容器、模板编程及经典算法。从引用指针、虚函数表、智能指针等底层原理,到继承多态、运算符重载等OOP特性从const、static、inline等关键字辨析,到动态规划、KMP算法、并查集等手写实现。每个知识点以面试答题形式呈现,注重原理阐述而非冗长代码,帮助你快速构建完整知识体系,从容应对面试官提问,顺利拿下offer。

全部评论

相关推荐

03-08 13:26
已编辑
东北大学 Java
树🌲&nbsp;35.二叉树的中序遍历:递归/非递归前序:根左右&nbsp;中序:左根右&nbsp;后序:左右根36.二叉树的最大深度:递归37.翻转二叉树:递归&nbsp;交换38.对称二叉树:递归&nbsp;判断左右子树是否互为镜像39.二叉树直径:链:node子树中叶子节点到node路径,拼node左右两条链长最大值为直径。DP40.二叉树的层序遍历:Queue&nbsp;BFS41.有序数组转换为二叉搜索树:二分得到两个小数组&nbsp;&nbsp;递归42.验证二叉搜索树:前序/中序遍历43.二叉搜索树中第k小的元素:中序遍历找第k个节点44.二叉树的右视图:先递归右子树,再递归左子树45.二叉树转为链表:头插法&nbsp;按右子树-左子树-根顺序DFS树46.前序&nbsp;中序&nbsp;数组构造二叉树:前序:根左右&nbsp;中序:左根右&nbsp;分左右子树递归47.路径总和III:哈希表统计根节点开始的路径和的出现次数,计算起点的次数48.二叉树最近的公共次数:当前节点&nbsp;null&nbsp;p&nbsp;q&nbsp;/&nbsp;左右子树是否为空49.二叉树中的最大路径和:DP&nbsp;链&nbsp;直径图50.岛屿数量:递归遍历上下左右并标记51.腐烂的橘子:多源BFS&nbsp;Queue&nbsp;四方向遍历52.课程表:(有向图是否有环)建图&nbsp;三色标记法53.实现Trie(前缀树):构造26叉树(包含长26的子节点,和布尔值end/flag&nbsp;标识变量判断是否为终止节点)。回溯选与不选:子序列排列型回溯:枚举选哪个54.全排列:boolean&nbsp;[]onPath标记选过没&nbsp;再枚举path[i]填哪个的数55.子集:选与不选56.电话号码的字母组合:DFS&nbsp;+&nbsp;枚举回溯先写数字对应电话键盘字母常量数组&nbsp;&nbsp;&nbsp;&nbsp;获取当前数字对应的字母串&nbsp;当前数字对应的字母串57.组合总数:选与不选&nbsp;dfs中target&nbsp;==&nbsp;0&nbsp;说明找到了一个合法组合&nbsp;加入path58.括号生成:')'&nbsp;数量==n&nbsp;填完&nbsp;'('&nbsp;&lt;&nbsp;n&nbsp;填&nbsp;'('')'&nbsp;&lt;&nbsp;'('&nbsp;填')'59.单词搜索:DFS&nbsp;+&nbsp;回溯&nbsp;&nbsp;上下左右&nbsp;优化:可行性剪枝+顺序剪枝60.分割回文串:DFS&nbsp;+&nbsp;回溯枚举字符串中所有可能的分割点(选&nbsp;/&nbsp;不选&nbsp;i&nbsp;和&nbsp;i+1&nbsp;之间的逗号),只保留&nbsp;所有分割出的子串都是回文61.N皇后:DFS&nbsp;+&nbsp;回溯&nbsp;逐行放置皇后正斜线diag1[2&nbsp;*&nbsp;n&nbsp;-&nbsp;1]&nbsp;&nbsp;r&nbsp;+&nbsp;c反斜线diag2[2&nbsp;*&nbsp;n&nbsp;-&nbsp;1]&nbsp;r&nbsp;-&nbsp;c&nbsp;+&nbsp;n&nbsp;-&nbsp;1queens[r]&nbsp;=&nbsp;c&nbsp;表示第&nbsp;r&nbsp;行的皇后放在第&nbsp;c&nbsp;列col[c]&nbsp;=&nbsp;true&nbsp;表示第&nbsp;c&nbsp;列已被皇后占用二分62.搜索插入位置:二分63.搜索二维矩阵:从第一行最后一列排除法64.在排序数组中查找元素的第一个和最后一个位置:二分找第一个位置再找元素值+1的第一个位置下标再&nbsp;-1&nbsp;&nbsp;要判断能否找到65.搜索旋转排序数组:先找到旋转数组的最小值下标(旋转点),将数组拆分为两个升序的子数组判断&nbsp;target&nbsp;属于哪个升序子数组,在该子数组中用二分查找找目标值。66.寻找旋转排序数组中的最小值:二分中点值与最后一位值(nums[n-1])比较67.寻找两个正序数组中位数:将两个数组整体划分为左右两部分,左部分的所有数&nbsp;≤&nbsp;右部分的所有数;a/b&nbsp;对&nbsp;nums1/nums2&nbsp;的&nbsp;“哨兵扩展数组”:头部加(-∞)、尾部加&nbsp;(+∞),避免处理数组边界越界。a[i]&nbsp;≤&nbsp;b[j+1]&nbsp;&amp;&amp;&nbsp;b[j]&nbsp;≤&nbsp;a[i+1]左部分的元素个数&nbsp;=&nbsp;(m+n+1)/2(保证奇数长度时左部分多一个,直接取左部分最大值为中位数);枚举&nbsp;nums1&nbsp;的分割点,推导&nbsp;nums2&nbsp;的分割点,找到满足&nbsp;“左≤右”&nbsp;的分割点后,计算中位数。
点赞 评论 收藏
分享
评论
点赞
6
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务