目录
二面情况
- 面试时间:2025.12.05,一面后马上约了三天后的二面;
- 时长:50min左右。
自我介绍
正常模板,感觉再多几次面试,应该能背下来了(but整体面试表现的非常差吧,只能说是)。
问题
1. 简单介绍一下在浏览器中输入URL到页面显示,都发生了什么?
非常经典的一个计网问题,这里回答的非常不好,面试官说不用回答的这么细致,主要从web开发的角度回答,这里应该从大的方向回答,理清楚思路,不要急。
首先进行域名解析,拿到URL对应的服务器IP地址;
进行TCP三次握手与服务器建立TCP连接;
TCP三次握手建立可靠连接之后,发送HTTP请求,服务器返回HTTP响应;
浏览器通过内置的渲染相关技术,解析并且显示请求资源的内容;
如果没有继续请求资源的需求,通过四次挥手断开TCP连接(这里有TCP长连接和TCP短连接的概念)。
2. 如果你发现自己的后端程序性能比较慢,从服务器开发的角度来看,你会怎么分析你的程序性能瓶颈出现在哪里?
这里回答的也非常不好啊,我说从我的分布式实时消息推送系统来看,从两个角度分析,第一个是网络层,测试其在高并发下,是否能够稳定的支持TCP连接;一个是业务逻辑层,耗时的主要问题还是出现在数据库的操作中,因为存在基于磁盘的IO操作会非常耗时,我会分析是否使用了Redis这样的缓存技术,如果已经使用了Redis,且这段业务逻辑还是非常耗时,分析是否出现了缓存雪崩,击穿和穿透。
从四个角度来回答,我觉得这里AI总结的还不错,简化版就是:全局监控 ==> 链路追踪 ==> 代码及剖析。
1. 全局监控,主要是CPU,内存和IO
CPU指标有
us表示用户态CPU、sy表示内核态CPU,这些指标可以在Linux中通过top, htop, vmstat命令查看。
- 高
us:说明应用代码本身占用了大量CPU,可能是计算密集型的业务逻辑出了问题,这时需要进入代码级剖析;- 高
sy:说明系统调用频繁,这里可以结合一下我的KV存储项目,说Redis是基于单线程模型的,最开始KV存储是基于线程池实现的,发现QPS不够高,线程池存在线程上下文切换,导致sy过高,后面将KV存储优化成了单线程模型。内存指标有可用内存、Swap使用率、缺页中断。
内存不足:内存不足会导致Swap,急剧增加磁盘I/O,缺页中断频繁;
内存充足:注意内存泄露,使用Cpp进行后端项目的开发,容易出现内存泄漏,可以用
valgrind或gperftools来检测。I/O指标分磁盘和网络,这些指标可以在Linux中通过
iftop, nethogs命令查看。
- 磁盘I/O:
iowait高、磁盘使用率100%,表示可能存在大量的MySQL读写操作,随机IO还是顺序IO,考虑加缓存Redis或者MySQL优化;- 网络I/O:带宽打满、连接数过高、丢包重传,结合项目,在我的分布式实时消息推送系统Comet层,需要管理大量WebSocket长连接,必须监控连接数、网络带宽,考虑在Comet层增加节点。
2. 链路追踪
当全局监控发现某个资源异常后,需要定位到是哪些具体的请求导致了问题,通过链路追踪,可以清晰看到时间主要消耗在哪个环节。
- 这里可以结合项目聊,在我的分布式实时消息推送系统项目中,用户发送HTTP请求进行协议升级为Websocket连接后,以一个用户在房间内发送消息为例,请求链路为
Comet层 ==> 请求转发到Logic层 ==> 生产消息到Kafka 层 ==> Job层消费消息 ==> Job层通过gRPC远程调用Comet层WS帧发送逻辑 ==> Comet层 ==> 用户浏览器;- 灵活回答解决方案,如果知道链路追踪工具,就说可以使用分布式追踪系统,为每个请求生成一个唯一
TraceID,记录经过每个服务、每个数据库查询的耗时;如果不知道,可以说在每一层输出对应业务执行时间的日志,然后统计分析。3. 代码及剖析
当定位到某个进程或函数的耗时,可以使用相关工具分析。
从CPU角度分析业务代码耗时,使用
perf, gperftools分析哪些函数占用了最多的CPU时间,可以结合项目回答,在我的KV存储演示系统项目中,如果发现QPS不达预期,用perf record和perf report查看热点是在HTTP协议解析上,还是在KV存储引擎的查找逻辑上,或者是在锁的竞争上;从内存角度分析是否出现内存泄漏,使用
valgrind --tool=massif, gperftools分析是否存在内存泄露。
3. 基于上一个问题的深入,你说到了MySQL的查询非常耗时,那你知道MySQL支持的最大数据量是多少吗,你接触过的最大数据量是多少?
这里直接懵了啊,我直接回答说应该最多支持百万级的数据量(我猜的),然后说我接触到的最大数据量是十万级,面试官又反问到是百万级吗,这个不能靠猜吧,场面瞬间尴尬ing。
这里可以拓展一下,在InnoDB存储引擎中,对于一张表的最大数据量限制,可以从页编号的寻址范围和页框大小来计算,比如页编号是4字节,页框大小是16KB,这样的话,一张表,也就是一个主键索引支撑的最大数据量是64TB。
其实,MySQL中对于最大数据量的限制,没有特别的规定,这依赖于数据库服务器的硬件配置、数据库架构等的影响,从数据量来拓展回答:
百万级以下的数据是MySQL的舒适区,通过良好的索引设计和规范的SQL查询语句,一般性能不会差;
千万级到亿级别的数据选择分表和主从数据库服务器节点的机制;
亿级以上的数据选择分库分表,使用雪花算法生成消息ID,保证了全局唯一和趋势递增,便于后续按时间或ID范围进行分片;
更高级别的数据量级选择使用搜索引擎(ElasticSearch),这个技术也是一二三面全部提到的,可以去了解一下。
4. Redis中有哪些最基本的数据类型?
我回答了String, List, Hash, Set, ZSet, Stream五种常见的数据类型,这里面试官反问到什么是Stream,我说Stream是支持消息队列的一种Redis数据结构,支持发布订阅模式,然后说它的键值格式是基于时间戳+id的,id表示可订阅的主题号。
这里回答没什么问题,当面试官一问这个问题的时候,我就意识到我要完了,因为我不知道这五种基本数据类型的底层实现,而且正常来说肯定要追问底层数据结构实现的。
5. 你说到了Redis的String,可以说说Redis里面的String和Cpp中的String实现有什么区别吗?
这里我直接懵了,不深入了解Redis的下场,真的是稍微深入的问题都回答不上来。
从三个方面来比较其实现的区别,分别是内存基本布局,扩容机制和存储数字编码。
1. 内存基本布局
Redis SDS(Simple Dynamic String)简化版
c++/* 1. 头部与数据分离:SDS结构体头部存储元数据(len, alloc, flags),后面紧跟数据区; 2. 二进制安全:通过 len确定字符串边界,buf可以包含任意二进制数据(包括 \0)。 */ struct __attribute__ ((__packed__)) sdshdr { uint64_t len; // 已用字节数 uint64_t alloc; // 总分配容量(不包括头和结束符) unsigned char flags; // 类型标志 char buf[]; // 柔性数组,存储实际数据 }; // 内存布局示意图 [ 8字节 len ][ 8字节 alloc ][ 1字节 flags ][ 数据区... ][ 1字节 \0 ]C++ std::String SSO(Small String Optimization)简化版
c++/* SSO优化的内存布局(libc++等实现) 1. 当字符串较短时(通常≤15字节),直接存储在栈上的对象内部,避免堆分配。 */ union { struct { char* _M_data; // 长字符串:堆指针 size_t _M_size; // 大小 size_t _M_capacity; // 容量 } _M_long; struct { char _M_data[sizeof(_M_long)]; // 短字符串:本地缓冲区 unsigned char _M_size; // 大小存储在缓冲区的最后一个字节 } _M_short; };2. 扩容机制
Redis SDS(Simple Dynamic String)智能预分配
c++/* Redis的sds扩容策略(sds.c):小字符串加倍,大字符串线性增长,避免内存浪费。 */ sds sdsMakeRoomFor(sds s, size_t addlen) { size_t len = sdslen(s); size_t newlen = (len + addlen); if (newlen < SDS_MAX_PREALLOC) // SDS_MAX_PREALLOC = 1MB newlen *= 2; // 小于1MB时,加倍 else newlen += SDS_MAX_PREALLOC; // 大于1MB时,每次加1MB // ... 执行扩容 }C++ std::String SSO(Small String Optimization)指数增长
c++// libstdc++的指数增长策略(简化) void _M_mutate(size_type __pos, size_type __len1, size_type __len2) { const size_type __old_size = this->size(); const size_type __new_size = __old_size + __len2 - __len1; if (__new_size > this->capacity()) { const size_type __len = std::max(__old_size + __old_size / 2, __new_size); // 1.5倍增长 // ... 重新分配 } }3. 存储编码
Redis的String存储数字或其它内容时,支持三种编码方式。
- INT编码:存储的整数值在LONG_MIN到LONG_MAX范围内;
- EMBSTR编码:存储小于等于44字节的短字符串;
- RAW编码:存储长字符串或无法解析为整数的数字字符串。
C++ std::String 使用统一的字符序列存储。
总结
关键点可以回答扩容机制和存储编码方式的不同。
6. 顺便说说List, Hash, Set, Zset在Redis中具体是怎么实现的?
Redis为每种数据类型设计了多种底层数据结构,适配不同使用场景和数据规模,需要明确一个点,基本数据类型,针对的是Redis对象,也就是value有哪些数据类型。
注:
OBJECT ENCODING key命令可以查看键值对当前使用的底层编码。
List的底层实现:基于双向链表和压缩列表;
c++/* 1. 工作原理: - QuickList是一个双向链表,每个节点指向一个ziplist; - 每个ziplist存储多个列表元素(默认最多8KB); - 支持从头部/尾部快速插入删除(链表特性); - 每个ziplist内部元素紧凑存储(节省内存)。 2. 优势: - 平衡了内存效率(ziplist)和修改性能(链表)。 */ // QuickList 结构(简化) typedef struct quicklist { quicklistNode *head; // 头节点 quicklistNode *tail; // 尾节点 unsigned long count; // 所有ziplist中的元素总数 unsigned long len; // quicklist节点数 int fill : 16; // 单个ziplist的大小限制 unsigned int compress : 16; // 压缩深度 } quicklist; // QuickList节点 typedef struct quicklistNode { struct quicklistNode *prev; struct quicklistNode *next; unsigned char *zl; // 指向ziplist的指针 unsigned int sz; // ziplist的字节大小 unsigned int count : 16; // ziplist中的元素个数 unsigned int encoding : 2; // 编码:RAW==1 or LZF==2 unsigned int container : 2; // 容器:NONE==1 or ZIPLIST==2 unsigned int recompress : 1; // 是否被压缩过 } quicklistNode;
Hash的底层实现:基于压缩列表和哈希表,压缩列表和哈希表的转换可以在配置文件中配置,hash-max-ziplist-entries 512, hash-max-ziplist-value 64,分别是字段数量阈值和字段值最大字节数;
c++/* 1. 压缩列表:当字段数量少,字段值短时使用压缩列表存储,这个时候查找的时间复杂度不会很高(常数级); 2. 命令:HSET user:1001 name "Alice" age 25 city "SZ"; 3. 内存布局:[name, Alice, age, 25, city, SZ](紧凑排列) */ /* 哈希表实现 */ // Redis的字典结构 typedef struct dict { dictType *type; // 类型特定函数 void *privdata; // 私有数据 dictht ht[2]; // 两个哈希表(用于渐进式rehash) long rehashidx; // rehash进度,-1表示未进行 } dict; // 哈希表 typedef struct dictht { dictEntry **table; // 哈希表数组 unsigned long size; // 表大小 unsigned long sizemask; // 大小掩码,用于计算索引值 unsigned long used; // 已有节点数量 } dictht;
Set的底层实现:基于整数集合和哈希表实现,整数集合和哈希表的转换可以在配置文件中配置set-max-intset-entries 512,即集合中元素个数;
c++/* 1. 整数集合特点: - 所有元素都是整数时使用; - 有序数组存储,支持二分查找; - 自动升级:当加入大整数时,自动扩展为更大的整数类型。 */ typedef struct intset { uint32_t encoding; // 编码方式:int16, int32, int64 uint32_t length; // 元素个数 int8_t contents[]; // 整数数组 } intset; /* 1. 哈希表集合特点: - 当包含非整数元素时使用; - 只有键,值为NULL(节省内存)。 */
Zset的底层实现:基于压缩列表和跳跃表&字典的方式实现;
C++/* 1. 压缩列表存储方式:[member1, score1, member2, score2, ...] 2. 命令:ZADD leaderboard 100 "Alice" 200 "Bob" # 按score排序存储 */ // 跳跃表&字典存储方式,跳跃表实现高效范围查询,字典实现O(1)的时间复杂度查找 // 跳跃表节点 typedef struct zskiplistNode { sds ele; // 成员 double score; // 分值 struct zskiplistNode *backward; // 后退指针 struct zskiplistLevel { struct zskiplistNode *forward; // 前进指针 unsigned long span; // 跨度 } level[]; // 层级数组 } zskiplistNode; // 跳跃表 typedef struct zskiplist { struct zskiplistNode *header, *tail; unsigned long length; // 节点数量 int level; // 最大层数 } zskiplist; // 有序集合 typedef struct zset { dict *dict; // 字典:member -> score zskiplist *zsl; // 跳跃表:按score排序 } zset;
7. 拓展:既然说到了Redis对象的数据类型,那么Redis的KV组织是基于什么数据结构的?
Redis中,key和value的组织形式是基于全局哈希表实现的,在用户程序中执行类似Get命令时,可以在O(1)的时间复杂度内查找到
RedisObject。
8. 说一说什么是红黑树吧?
这个问题问到了挺多次的,面试时从平衡二叉树谈起,说红黑树树是一种二叉搜索树,由于平衡二叉树性质要求左右子树高度差不超过1,所以进行节点的插入和删除的时候,调整树的结构需要较多时间开销,因此衍生出了红黑树,红黑树进行节点的插入和删除,调整树的时间开销较小。
红黑树概念口诀,左根右,根叶黑,不红红,黑路同。
左根右:二叉搜索树有序的特性;
根叶黑:根节点和NIL叶子节点(空节点,不存储数据)为黑;
不红红:父节点和子节点不能是连续红色;
黑路同:从任意节点到其所有后代NIL叶子的路径,包含的黑色节点数量相同。
9. 说一说什么是跳跃表?
这里回答的不是很好,但是也回答了一点,我说是为了在链表中更加高效的查找,进行空间换时间的一种数据结构,它会在链表的基础上进行节点拓展多层,每拓展一层,节点数减半。
这里可以深入说跳跃表是有序的多层双向链表,是Redis中
Zset实现范围查找的一种数据结构,查找时间复杂度为O(logn),对比红黑树,插入删除节点更加方便,没有繁琐的左旋右旋和染色操作,且支撑范围查询。
10. 你说你对数据结构比较熟悉,那可以讲一下什么是压缩列表吗?
G了,这里直接说不会,面试官笑着反问,那你简历上还说自己对数据结构比较熟悉🤣🤣🤣,在我尴尬的笑了一下后,面试环境突然就安静了几秒钟,哈哈~~
压缩列表是早期Redis为了实现时间换空间的一种数据结构编码方式,在数据量较小时,
List, Hash, ZSet底层编码使用的就是压缩列表实现,压缩列表存储的数据在内存中是字节连续存储的,压缩列表的详细结构如下:
c++// [ZLBYTES][ZLTAIL][ZLLEN][ENTRY1][ENTRY2]...[ENTRYN][ZLEND] // ENTRYN: [Previous_entry_length] [Encoding] [Content]
- 字段
ZLBYTES占4字节,记录整个压缩列表占用的总内存字节数;- 字段
ZLTAIL占4字节,记录最后一个节点的起始地址距离列表起始地址的偏移量,有了它,对列表进行尾部插入或获取最后一个操作时,无需遍历整个列表;- 字段
ZLLEN占2字节,记录列表中的节点数量;- 字段
ENTRYN表示列表节点,每个节点存储的数据;- 字段
ZLEND占1字节,通常值是常量0xFF,标记压缩列表的结束。使用压缩列表可以带来很好的内存效率,避免指针开销,但是因为是内存连续的,和数组一样,如果进行内容更新时,可能会带来内存重新分配等问题。
手撕环节
求两个整数集合的交集 ==> 使用set直接秒了(其实这里也很好奇,为什么会出这么简单的手撕题)。
侧面说明,面试真的很需要一点点运气,有时候即使面试过程中,回答的非常不好也可能给过,相反,回答的很好也可能挂。
总结
二面注重点还是在大后端的中间件多一些,Redis是一点不会┭┮﹏┭┮,真的需要复盘。