八股文 / 腾讯后台
腾讯后台开发八股文
持续更新中。来源:2026 暑期实习真实面经。
Go 语言
Q1:Go 的 GMP 调度模型是什么?
来源:26暑期实习-后台AI开发一面
GMP 是 Go runtime 的协程调度模型,由三个核心角色组成:
| 角色 | 全称 | 说明 |
|---|---|---|
| G | Goroutine | 用户态轻量级线程,创建成本极低(初始栈仅 2KB) |
| M | Machine | 操作系统线程,是实际执行 G 的载体 |
| P | Processor | 逻辑处理器,持有本地运行队列和运行 G 所需的资源 |
调度流程:
- 每个 P 维护一个本地队列(最多 256 个 G);还有一个全局队列作为溢出缓冲
- M 必须绑定一个 P 才能执行 G:
M ← P ← G - M 从绑定的 P 的本地队列取 G 执行;本地队列空了就去全局队列或其他 P 那里偷(work stealing)
- 当 G 发生系统调用(如文件 IO)阻塞了 M 时,P 会与这个 M 解绑(hand off),找另一个空闲 M 或新建 M 继续执行队列中的 G
核心设计思想:P 的引入将”可运行的协程队列”和”操作系统线程”解耦——M 阻塞不影响其他 G 的调度,实现了 M:N 调度(M 个 OS 线程调度 N 个协程)。
Q2:M 和 P 的数量关系是怎样的?
来源:26暑期实习-后台AI开发一面
- P 的数量决定了最大并行度:同一时刻最多有
GOMAXPROCS个 G 在真正并行执行 - M 的数量 $\geq$ P 的数量:因为当某个 M 被系统调用阻塞时,它绑定的 P 会转交给另一个 M,所以活跃 M 数可能超过 P 数
- M 的数量也可以远大于 P:大量 G 同时执行阻塞式系统调用时,每个阻塞的 G 都占一个 M
一句话总结:P 决定并行度上限,M 是按需创建的——阻塞 IO 多时 M 多,纯计算负载时 M ≈ P。
Q3:P 的数量怎么设定?M 创建有没有上限?
来源:26暑期实习-后台AI开发一面
P 的数量:
- 默认值 = CPU 逻辑核心数(
runtime.NumCPU()) - 可通过
runtime.GOMAXPROCS(n)或环境变量GOMAXPROCS=n调整 - 设太小:CPU 核心闲置;设太大:上下文切换开销增加
- 经验法则:CPU 密集型保持默认,IO 密集型可适当调大
M 的上限:
- 默认上限 10000(通过
runtime/debug.SetMaxThreads()可调) - 超过上限程序直接 panic:
thread exhaustion - 实际中如果 M 数接近上限,说明大量协程在做阻塞式系统调用,应该改用非阻塞 IO 或减少并发
Q4:Go 的内存逃逸是什么?
来源:26暑期实习-后台AI开发一面
Go 编译器通过逃逸分析(escape analysis)决定变量分配在栈上还是堆上。如果编译器判断变量的生命周期超出当前函数作用域,就会将其”逃逸”到堆上分配。
常见逃逸场景:
| 场景 | 示例 | 原因 |
|---|---|---|
| 返回局部变量的指针 | return &x |
函数返回后栈帧销毁,必须放堆上 |
| 闭包引用外部变量 | func() { use(x) } |
闭包的生命周期可能长于外部函数 |
| 赋值给接口类型 | var i interface{} = x |
接口的底层实现需要堆分配 |
| 发送到 channel | ch <- &x |
接收方在另一个协程,生命周期不确定 |
| slice/map 动态扩容 | append(s, x) 触发扩容 |
新底层数组无法在栈上分配 |
| 栈空间不足 | 超大数组或深递归 | 编译器判断栈放不下 |
为什么关注逃逸:堆分配 = GC 压力。高频调用的热路径中避免不必要的逃逸,可以显著减少 GC 停顿。
如何检查:go build -gcflags="-m" 会输出逃逸分析报告。
优化手段:
- 尽量用值传递代替指针传递(小结构体)
- 预分配 slice 容量避免扩容:
make([]int, 0, n) - 避免在热路径中使用
interface{}
Redis
Q5:Redis 为什么性能强?Redis 单线程怎么理解?
来源:26暑期实习-后台AI开发一面
Redis 性能强的原因:
| 因素 | 说明 |
|---|---|
| 纯内存操作 | 数据都在内存中,读写在纳秒级,比磁盘快 $10^5$ 倍 |
| IO 多路复用 | 使用 epoll/kqueue 单线程监听上万个连接,不阻塞 |
| 高效数据结构 | SDS(动态字符串)、跳表、压缩列表、整数集合等专门优化的底层结构 |
| 单线程无锁 | 避免了锁竞争、死锁、上下文切换的开销 |
| 请求/响应协议简单 | RESP 协议解析极快,命令执行通常是 $O(1)$ 或 $O(\log n)$ |
“单线程”的准确含义:
Redis 的”单线程”指的是命令执行(核心事件循环)是单线程的,即读取请求 → 解析命令 → 执行 → 返回结果这条主路径是单线程串行的。但 Redis 并不是只有一个线程:
- 6.0 之前:网络 IO(read/write)和命令执行都在主线程
- 6.0+ 引入多线程 IO:网络读写(解包/发包)可以用多个 IO 线程并行处理,但命令执行仍然是单线程的——这保证了不需要加锁
- 后台线程:持久化(RDB/AOF)、异步删除(UNLINK)、关闭文件描述符等任务由后台线程完成
为什么单线程就够了:Redis 的瓶颈不在 CPU 而在网络 IO 和内存带宽。内存中执行一条命令只需微秒级,单线程每秒可以处理 10w+ 请求。
消息队列
Q6:为什么用 RocketMQ?有没有了解过别的消息队列?怎么做的选型?
来源:26暑期实习-后台AI开发一面
消息队列的选型维度:
| 维度 | RabbitMQ | RocketMQ | Kafka |
|---|---|---|---|
| 开发语言 | Erlang | Java | Scala/Java |
| 单机吞吐 | 万级 | 十万级 | 百万级 |
| 延迟消息 | 插件支持 | 原生支持(18个等级) | 不支持 |
| 事务消息 | 不支持 | 支持(半消息机制) | 支持(Exactly Once 语义) |
| 消息过滤 | Routing Key | Tag + SQL92 | 仅 Topic 级别 |
| 消息回溯 | 不支持 | 支持(按时间戳) | 支持(按 offset) |
| 顺序消息 | 不保证 | 支持分区有序 | 支持分区有序 |
| 社区生态 | 成熟但偏小众 | 阿里开源,中文文档好 | 最活跃,大数据生态完善 |
| 适用场景 | 中小规模、复杂路由 | 业务消息(电商/金融) | 日志采集、流处理、大数据 |
选型结论:
- 选 RocketMQ:需要延迟消息(如订单超时取消)、事务消息(如分布式事务最终一致性)、消息过滤、金融级可靠性
- 选 Kafka:需要超高吞吐、日志采集、流计算(Flink/Spark Streaming 集成好)
- 选 RabbitMQ:消息量不大但路由规则复杂、需要多协议支持(AMQP/STOMP/MQTT)
Q7:RocketMQ 和 Kafka 的区别?
来源:26暑期实习-后台AI开发一面
| 对比维度 | RocketMQ | Kafka |
|---|---|---|
| 架构 | NameServer(无状态,轻量) | ZooKeeper/KRaft(有状态,较重) |
| 存储模型 | CommitLog + ConsumeQueue(物理上所有 Topic 共享一个日志文件) | 每个 Partition 独立日志文件 |
| 写入性能 | 顺序写 CommitLog,十万级 TPS | 顺序写 Partition,百万级 TPS |
| 消费模型 | Push + Pull 都支持 | Pull 为主 |
| 延迟消息 | 原生 18 个等级 + 5.0 支持任意时间 | 不支持 |
| 事务消息 | 半消息 → 本地事务 → 确认/回滚,自带回查机制 | 0.11+ 支持 Exactly Once,但实现更复杂 |
| 消息过滤 | Broker 端按 Tag/SQL 过滤,减少网络传输 | 只能 Consumer 端自己过滤 |
| 消息保序 | 同一个 MessageQueue 严格有序 | 同一个 Partition 严格有序 |
| 堆积能力 | 十亿级(CommitLog 顺序写) | 十亿级(Partition 顺序写) |
| 典型场景 | 电商下单(事务消息)、延迟通知、订单状态流转 | 日志聚合、用户行为追踪、实时流处理 |
一句话总结:Kafka 是”高速公路”(吞吐至上),RocketMQ 是”智能物流”(功能丰富、业务友好)。
面试算法题
寻找重复数(LeetCode 287)
来源:26暑期实习-后台AI开发一面
题意:给定一个包含 $n + 1$ 个整数的数组 nums,每个整数在 $[1, n]$ 范围内,只有一个重复的整数,找出这个重复数。要求不修改数组,空间 $O(1)$。
思路 — Floyd 快慢指针判环:
把数组看作一个隐式链表:下标 $i$ 的”下一个节点”是 $\text{nums}[i]$。因为有 $n+1$ 个数但值域只有 $[1, n]$,至少有两个不同下标指向同一个值——这意味着链表中存在环,而环的入口就是重复数。
和链表判环问题完全一样,分两步:
- 快慢指针找相遇点:慢指针每次走一步
slow = nums[slow],快指针每次走两步fast = nums[nums[fast]],在环内相遇 - 找环入口:将其中一个指针重置到起点(下标 0),两个指针各走一步,再次相遇的位置就是环入口 = 重复数
def find_duplicate(nums):
slow = fast = 0
# 第一阶段:快慢指针找环内相遇点
while True:
slow = nums[slow]
fast = nums[nums[fast]]
if slow == fast:
break
# 第二阶段:找环入口
slow = 0
while slow != fast:
slow = nums[slow]
fast = nums[fast]
return slow
# ACM 模式
n = int(input())
nums = list(map(int, input().split()))
print(find_duplicate(nums))
时间复杂度:$O(n)$。空间复杂度:$O(1)$。
为什么有效:环入口一定是重复数,因为只有重复数对应的下标会被多个前驱指向(两个不同的位置 $i, j$ 满足 $\text{nums}[i] = \text{nums}[j]$),形成环的入口。
面经记录
| 日期 | 岗位 | 面次 | 来源 | 考察内容 |
|---|---|---|---|---|
| 2026-04 | 后台AI开发 | 一面 | 牛客 | 项目 + Go(GMP/逃逸) + Redis + RocketMQ + 算法(LC287) |