字节飞书后端一面凉经
字节飞书后端一面凉经
(其实这是我第一次的面试,说出来都有点让人发笑,前面从来没有面试过)一共面了40分钟左右,感觉自从那个项目了里面的没回答好,我就感觉要寄了
一、项目深挖:RAG知识库系统架构、流程、踩坑问题
- 整体架构与运行流程
我的 RAG 系统分为 文档接入层、文本处理层、向量存储层、缓存加速层、检索问答层 五层:
- 文档接入:支持多格式文档上传,解析 PDF、Word、TXT 文本内容;
- 文本预处理:清洗无效字符、分段、分块切片,避免上下文过长;
- 向量化:调用 Embedding 模型将文本块转为向量;
- 持久化存储:向量存入向量数据库,原始文档信息、文档状态、热点检索数据缓存到 Redis;
- 检索问答:用户提问 -> 问题向量化 -> 相似度检索 TopK -> 拼接上下文 -> LLM 生成答案返回。
- 核心踩坑问题(多文档 覆盖问题)
(我本来要说的遇到的问题,脑子一短路说到redis覆盖了,我真是一个sn)
问题现象:
多文档连续上传,新文档缓存直接覆盖旧文档缓存,导致旧文档检索失效、问答只能命中最新一篇文档。
问题根因:
原代码:
1 | func ImportDoc(vs store.Store, content string) (int, error) { |
改后代码:
1 | func ImportDoc(vs store.Store, filename string, content string) (int, error) { |
原因
1 | chunker.SplitText 每个文档独立编号,都是从 0 开始。Qdrant 里 id 是 point 的唯一标识。 |
二、Redis & MySQL 相关八股
(然后换方向了不问项目了 (看我卡太久了))
1. Redis 和 MySQL 的区别
- 定位不同:MySQL 是磁盘持久化数据库,做主存储;Redis 是内存缓存,做加速、临时存储、计数器、分布式锁。
- 速度不同:Redis 纯内存操作,百万 QPS;MySQL 磁盘 IO,速度慢几个量级。
- 持久化:MySQL 强持久;Redis 默认内存丢失,可 RDB/AOF 持久化。
- 数据结构:MySQL 关系型表结构;Redis 丰富五大数据结构,适合各类场景。
- 事务:MySQL 支持完整 ACID 事务;Redis 仅支持单命令原子,不支持复杂事务。
- 使用场景:MySQL 存业务核心数据;Redis 做缓存、限流、分布式锁、队列、排行榜。
2. Redis 基本数据类型
5 大基础类型:String、List、Hash、Set、ZSet
- String:字符串、计数器、缓存、分布式锁
- List:双向链表,消息队列、栈
- Hash:用户信息、对象缓存
- Set:去重、交集、差集、好友列表
- ZSet:有序集合、排行榜、延时任务
3. Redis ZSet 底层实现
ZSet 底层由 dict 哈希表 + skiplist 跳表 双结构组成:
- dict(哈希表):key 是 member 元素,value 是 score,提供 O(1) 判重、查分数、去重能力;
- skiplist(跳表):存储 score+member 有序节点,提供排序、区间查询、排名、分页能力。
配合关系:
写操作同步更新两张结构,读操作按需选择:查分数走哈希,排序区间走跳表,互补短板。
4. 跳表的实现原理
跳表是 多层稀疏索引的有序双向链表,用来替代平衡树,实现 O(logn) 增删查:
- 第 0 层是完整有序双向链表,存全部数据;
- 上层每一层都是下层的稀疏索引,节点逐层减半;
- 插入节点时通过 1/2 概率随机生成层高,天然概率平衡;
- 查询从最高层向下跳,大幅跳过无效节点,不用遍历全链表。
5. 跳表时间复杂度为什么是 O(logn)?(你当时没说清的核心)
标准满分推导:
- 跳表每层节点数量近似下层的 1/2,是等比数列;
- 总层数 h 满足:n / 2^h = 1,推导出 h = log₂n;
- 查询、插入、删除时,每层最多走常数步,仅逐层下沉;
- 总操作次数和层数正相关,所以平均时间复杂度为 O(logn)。
补充:最坏概率极低退化为 O(n),工程忽略,平均稳定 logn。
6. 缓存击穿:原理、解决方案、加锁方式(高频考点)
一、什么是缓存击穿
缓存击穿:某个热点 key 过期的一瞬间,大量并发请求同时打到数据库,数据库压力瞬间飙升。
🥚 鸡蛋的例子:
早餐店的茶叶蛋放在保温箱(Redis)里,顾客随到随拿。某天保温箱定时器坏了,蛋被清空(缓存过期),门口 100 个人同时冲进后厨(数据库)喊着”给我煮蛋!”。后厨只有一口锅,直接被挤爆。
正确的做法:只让一个人进去煮,煮完放回保温箱,其他人等着拿就行 —— 这就是加锁的思路。
二、解决方案
| 方案 | 思路 | 优缺点 |
|---|---|---|
| 互斥锁 | 只让一个请求查 DB 回写缓存,其余等待 | 简单可靠,有等待开销 |
| 逻辑过期 | 缓存永不过期,值里存过期时间戳,异步重建 | 无需等待,实现复杂 |
| 永不过期 | 热点 key 不设 TTL,业务层主动更新 | 彻底杜绝,占内存 |
三、加锁代码(核心逻辑)
1 | func GetData(key string) (string, error) { |
四、锁的三个关键点
- **
SET NX EX**:NX保证互斥(只有不存在的 key 才能 set 成功),EX设过期时间防止死锁。 - Double Check:抢到锁后再查一次缓存,防止前面的人刚写完缓存你就又去查 DB。
- 释放锁用 Lua 脚本:先 GET 判断 value 是不是自己的,再 DEL,保证原子性。否则锁刚好过期、删了别人的锁。
五、总结
缓存击穿 = 热点过期 + 高并发 → 加分布式锁,只让一个人重建缓存,其他人等着拿。
三、Go Channel 问答
- Channel 的类型
- 按方向:只读 chan<-、只写 <-chan、读写 chan
- 按缓冲:无缓冲通道、有缓冲通道
- 有缓冲、无缓冲 Channel 区别 & 应用场景
无缓冲 chan
- 特点:收发必须配对,同步阻塞;发送必等接收,接收必等发送
- 场景:同步通信、协程通知、信号等待(一次性任务通知)
有缓冲 chan - 特点:缓冲区未满不阻塞,满了才阻塞;异步解耦
- 场景:生产者消费者、协程池、流量削峰、批量任务
四、算法原题:零钱兑换(完全背包 DP)
题目描述
给你一个整数数组 coins 表示不同面额硬币、整数 amount 表示总金额。每种硬币数量无限,求凑成总金额的最少硬币个数,无法凑成返回 -1。
解题思路
经典 完全背包动态规划
- 状态定义:dp[i] 凑金额 i 的最少硬币数
- 初始化:dp[0]=0,其余初始为无穷大(不可达)
- 状态转移:dp[j] = min(dp[j], dp[j-coin]+1)
- 最后判断:dp[amount] 若仍为无穷大返回 -1,否则返回答案
C++ 满分可运行代码
1 | #include <vector> |
复杂度
- 时间:O(amount * n)
- 空间:O(amount)
五、本次面试复盘 & 优化总结
- 本次暴露的问题
- 项目问题表达不熟练:踩坑问题明明准备过,临场逻辑混乱、说不清楚根因和方案;
- 底层原理口述薄弱:跳表原理、复杂度推导无法简洁清晰输出;
- 心态紧张:首面紧张导致卡顿,平时会的题临场卡壳。
- 改进方案
- 所有项目踩坑必须固化成 现象-根因-方案-收获 四段式话术;
- 所有数据结构、八股必须会 口述原理+复杂度推导,不能只看得懂;
- 算法题保证中等DP、滑动窗口、链表、哈希熟练手写,零卡顿。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 乃敢与君绝!


