大家好,我是地鼠哥。
就在这几天,腾讯26届校招终于是启动了,从8月份开始,已经有越来越多的大厂比如阿里巴巴、字节、百度、京东、美团等等都正式开启了秋招。
在招聘介绍界面的开头部分就可以看到“腾讯正全力投入AI领域”这句话,这说明腾讯这类大厂都已经开始全面拥抱AI了,学习AI是大势所趋。
我们训练营内部最近也在全力投入到AI项目的开发,现在去找工作,AI一定是一个大大的加分项。
下面就来分享一下腾讯的Go校招一面和二面面经,考察的内容主要就是Go、MySQL、Redis、Kafka、操作系统、计网这些。
腾讯校招 \ 一面
1. defer 语句在函数返回时的执行顺序是怎样的?多个 defer 之间如何排序?
简单说,defer 语句会在函数返回前执行,但多个 defer 之间是 “后进先出” 的顺序,就像栈一样。比如函数里先写了 defer A,再写 defer B,那函数返回时会先执行 B,再执行 A。这是因为 defer 注册的时候是按顺序入栈,执行时就从栈顶开始依次出栈。
2. select 语句的执行规则是什么?当多个 case 同时就绪会怎样?
select 语句主要用来监听多个 channel 的操作。执行规则是:先检查所有 case 对应的 channel 是否就绪(比如有数据可读,或可写入)。
- 如果有一个 case 就绪,就执行这个 case 里的代码;
- 如果没有 case 就绪,就会阻塞,除非有 default 分支,这时会直接执行 default;
- 当多个 case 同时就绪时,Go 会随机选一个执行,这样能避免某些 case 一直被忽略(饥饿问题)。
3. 哪些情况下会发生内存泄漏?如何排查?
内存泄漏常见情况有几种:
- goroutine 泄漏:比如 goroutine 里有无限循环,又没退出条件,或者等待一个永远不会发送数据的 channel,导致 goroutine 一直占用资源;
- 未关闭的资源:比如打开的文件、数据库连接、网络连接没及时关闭,这些资源不会被 GC 回收;
- 全局 map 持续增长:如果一直往全局 map 里塞数据,又不清理,会导致 map 占用内存越来越大。
排查的话,Go 自带的 pprof 工具很好用。可以通过 net/http/pprof 暴露接口,然后用 go tool pprof 分析堆内存(heap)、goroutine 数量等,看哪些对象没被释放,或者哪些 goroutine 一直在运行。
4. 类型断言失败会发生什么?
类型断言有两种写法:
- 如果用
value, ok := x.(T) 这种带 ok 的,失败时 ok 会是 false,value 是 T 类型的零值,不会 panic; - 如果直接写
value := x.(T),断言失败就会直接 panic。 所以实际开发中,一般推荐带 ok 的写法,更安全。
5. 如何实现两个 goroutine 之间的同步?
常用的有几种方式:
- channel:比如用一个无缓冲 channel,一个 goroutine 发送信号,另一个接收,就能实现 “等对方执行完再继续”;
- sync.WaitGroup:比如让一个 goroutine 调用
wg.Wait() 等待,另一个执行完调用 wg.Done(),适合 “等对方做完某件事” 的场景; - sync.Mutex:用互斥锁,一个 goroutine 加锁后,另一个会阻塞到解锁,适合共享资源的同步。 我平时用 channel 比较多,感觉更符合 Go 的风格。
6. 说说 go 中的内存屏障
内存屏障(Memory Barrier)主要是防止 CPU 指令重排,保证内存操作的顺序性。Go 里的内存屏障是 runtime 内部实现的,开发者一般不用直接操作。 比如有四种常见的屏障:LoadLoad(保证读操作顺序)、StoreStore(保证写操作顺序)、LoadStore(读之后不能重排写)、StoreLoad(写之后不能重排读)。 GC 和并发操作时会用到,比如标记对象时,确保对对象的修改能被正确感知,避免出现 “漏标” 或 “错标”。
7. channel 的关闭需要注意什么?关闭后的 channel 读取和写入会发生什么?
关闭 channel 要注意两点:
- 不能重复关闭:重复关闭会直接 panic;
- 关闭后不能写入:写入会 panic,但可以读取。
关闭后的 channel 读取规则:如果 channel 里还有数据,会先读数据;数据读完后,再读就会返回对应类型的零值,同时第二个返回值(ok)是 false。 所以一般关闭前要确保没有 goroutine 再写入,比如用 sync.Once 保证只关一次。
8. map 的 key 可以是哪些类型?
map 的 key 必须是 “可比较的类型”,也就是能用来做 == 或 != 比较的类型。 比如 int、string、bool、指针、结构体(如果结构体的所有字段都可比较)、数组(元素可比较)等。 像 slice、map、function 这些类型不能做 key,因为它们不可比较,用了会编译报错。
9. GMP 模型中 M 和 P 的关系是怎样的?P 的数量由什么决定?
GMP 里,M 是操作系统的线程,P 是逻辑处理器(可以理解为 “执行资源”),G 是 goroutine。 M 和 P 的关系是:M 必须绑定一个 P 才能执行 G,一个 P 同一时间只能被一个 M 绑定,但 M 可以切换绑定不同的 P(比如 M 被阻塞时,会释放 P 给其他 M 用)。
P 的数量默认是CPU 核心数,可以通过 runtime.GOMAXPROCS(n) 来设置,一般不建议改,让 Go 自己调优更合理。
10. go 的 gc 中 mark 和 sweep 阶段分别做什么?
Go 的 GC 用的是 “三色标记法”,mark 和 sweep 是主要阶段:
- mark(标记):从根对象(比如全局变量、栈上的变量)开始,遍历所有能访问到的对象,标记为 “存活”;
- sweep(清扫):遍历堆内存,回收所有没被标记的对象(垃圾),把它们的内存归还给空闲列表,供后续分配使用。
现在 Go 的 GC 是并发的,mark 和 sweep 阶段不会完全阻塞用户代码,性能比较好。
11. gc 触发的条件有哪些?
主要有三个条件:
- 堆内存达到阈值:默认是上一次 GC 完成后,堆内存增长了 2 倍(这个比例可以调);
- 手动触发:调用
runtime.GC() 函数,一般测试时用得多; - 定时触发:如果一直没达到内存阈值,默认每 2 分钟会强制触发一次,避免内存泄漏一直不被处理。
12. mysql 中的联合索引遵循什么原则?
最核心的是 “最左前缀匹配原则”。比如建了一个联合索引 (a, b, c),查询时:
- 用 a 作为条件,能用到索引;
- 用 a + b 作为条件,能用到索引;
- 用 a + b + c 作为条件,能用到索引; 但如果直接用 b 或 b + c 作为条件,就没法用到这个联合索引,因为不符合 “最左前缀”。 所以创建联合索引时,要把最常用、区分度高的字段放左边。
13. 聚簇索引和非聚簇索引的区别
最主要的区别在叶子节点存什么:
- 聚簇索引:叶子节点直接存的是完整的数据行,整个表的数据就是按聚簇索引的顺序存储的。一个表只能有一个聚簇索引,一般是主键索引;
- 非聚簇索引:叶子节点存的是 “索引键 + 主键值”,查数据时需要先通过非聚簇索引找到主键,再回表查聚簇索引拿完整数据(覆盖索引除外,覆盖索引能直接从非聚簇索引拿到所有需要的字段)。
14. redis 的 zset 是如何实现的?
zset 是有序集合,底层用了两种数据结构配合:
- 哈希表(hash):存 member 到 score 的映射,这样查某个 member 的 score 能做到 O (1);
- 跳表(skiplist):按 score 排序存储所有 member,跳表支持快速插入、删除和范围查询(比如查 top N),时间复杂度接近 O (log n)。 这两种结构配合,既保证了按 member 查 score 快,又保证了按 score 排序和范围查询快。
15. 讲一下 redis 的持久化机制?
redis 有两种主要的持久化方式:
- RDB:快照形式,在指定时间间隔内,把内存中的数据全量 dump 到磁盘(.rdb 文件)。优点是文件小、恢复快;缺点是可能丢数据(比如快照之间宕机,这段时间的数据没保存)。
- AOF:日志形式,把所有写命令追加到 .aof 文件,重启时重放命令恢复数据。优点是数据更安全(可以配置每秒刷盘或每次写都刷盘);缺点是文件大,恢复慢。
现在 redis 支持 “混合持久化”,AOF 文件里既包含 RDB 快照,又包含后续的命令,兼顾了两者的优点。
16. redis 的集群模式有哪些?各自的特点是什么?
主要有三种:
- 主从复制:一个主节点(写),多个从节点(读)。主节点数据同步到从节点,实现读写分离和备份。但故障转移需要手动,不支持自动切换。
- 哨兵(Sentinel):在主从复制基础上,增加哨兵节点监控主从状态,主节点挂了会自动选一个从节点升为主节点,实现高可用。但数据还是存在一个主节点,存储容量有限。
- Redis Cluster:分片集群,数据按哈希分到多个主节点(每个主节点有从节点),支持水平扩展(加节点扩容),自带故障转移。适合数据量大、需要高可用的场景。
腾讯校招 \ 二面
1. 进程的状态有哪些?如何转换?
进程主要有这几种状态:
- 就绪:进程已经准备好,等 CPU 调度;
- 运行:正在 CPU 上执行;
- 阻塞:等待资源(比如 I/O 完成、锁释放),暂时没法运行;
- 终止:进程执行完或被杀死。
转换关系:
- 就绪 → 运行:CPU 调度器选中该进程;
- 运行 → 就绪:时间片用完,或被更高优先级进程抢占;
- 运行 → 阻塞:进程发起 I/O 请求,或等待锁;
- 阻塞 → 就绪:等待的资源可用了(比如 I/O 完成)。
2. 线程的上下文切换和进程的上下文切换有什么区别?
最大的区别在开销和切换内容:
- 进程上下文切换:要切换页表(虚拟内存到物理内存的映射)、进程控制块(PCB)等,这些是进程私有的资源,切换时需要保存和恢复的东西多,开销大;
- 线程上下文切换:线程共享进程的资源(虚拟内存、文件描述符等),切换时只需要保存线程的私有数据(比如寄存器、栈、线程控制块),开销小很多。
简单说,进程切换是 “换家”,线程切换是 “换房间”,前者麻烦多了。
3. kafka 的分区策略有哪些?如何选择合适的分区策略?
常见的分区策略:
- 轮询策略:消息按顺序依次发到每个分区,保证数据在分区均匀分布,适合负载均衡的场景;
- 按 key 哈希策略:消息的 key 经过哈希后,决定发到哪个分区,这样相同 key 的消息会在同一个分区,保证顺序性,适合需要按 key 顺序消费的场景(比如同一用户的操作);
- 自定义策略:自己实现分区逻辑,满足特殊需求(比如按地理位置分区)。
选择的话,追求均衡用轮询,需要顺序用 key 哈希,特殊场景用自定义。
4. tcp 的三次握手和四次挥手过程中,各阶段的报文状态是什么?
三次握手(建立连接):
- 客户端发 SYN 报文,状态变成 SYN_SENT;
- 服务端收到 SYN,回 SYN+ACK 报文,状态变成 SYN_RCVD;
- 客户端收到 SYN+ACK,回 ACK 报文,状态变成 ESTABLISHED;服务端收到 ACK 后,也变成 ESTABLISHED,连接建立。
四次挥手(断开连接):
- 主动方发 FIN 报文,状态变成 FIN_WAIT_1;
- 被动方收到 FIN,回 ACK 报文,状态变成 CLOSE_WAIT;主动方收到 ACK 后,状态变成 FIN_WAIT_2;
- 被动方准备好后,发 FIN 报文,状态变成 LAST_ACK;
- 主动方收到 FIN,回 ACK 报文,状态变成 TIME_WAIT(等 2MSL 确保被动方收到 ACK),之后变成 CLOSED;被动方收到 ACK 后,变成 CLOSED。
5. tcp 的超时重传机制是如何实现的?
简单说,tcp 会给每个发送的报文设置一个超时重传时间(RTO),如果在 RTO 内没收到确认(ACK),就重传这个报文。 RTO 不是固定的,是根据往返时间(RTT) 动态计算的(比如 RTT 是报文发出去到收到 ACK 的时间)。一开始会估算一个 RTO,然后根据实际 RTT 不断调整,让 RTO 更合理。 重传次数也有限制(比如默认 12 次),超过次数就会认为连接断了,放弃重传。
6. http 1.0、1.1、2.0 之间有什么区别?
主要区别在性能和功能:
- HTTP 1.0:默认短连接,每次请求都要新建连接,效率低;不支持管道化。
- HTTP 1.1:支持长连接(keep-alive),一个连接可以发多个请求;支持管道化(一个连接里同时发多个请求);增加了 Host 头(一台服务器可以托管多个域名)。
- HTTP 2.0:用二进制帧(1.x 是文本),解析更快;支持多路复用(一个连接里并发处理多个请求,不用等前一个完成);头部压缩(减少传输量);支持服务器推送(主动给客户端发数据)。 简单说,2.0 比 1.1 快很多,1.1 比 1.0 更高效。
7. 什么是 HTTPS?它的加密过程是怎样的?
HTTPS 就是HTTP + SSL/TLS,在 HTTP 基础上增加了加密层,保证数据传输安全。 加密过程大概是:
- 客户端向服务器发起请求,告诉服务器自己支持的加密套件;
- 服务器发一个证书(包含公钥)给客户端;
- 客户端验证证书合法性(比如看是不是权威机构颁发的);
- 客户端生成一个对称加密密钥,用服务器的公钥加密后发给服务器;
- 服务器用自己的私钥解密,得到对称密钥;
- 之后双方就用这个对称密钥来加密传输所有数据(对称加密效率高,适合大量数据)。
8. 缓存预热的方法有哪些?
缓存预热就是提前把热点数据加载到缓存,避免刚启动时缓存空了,所有请求都打到数据库(缓存雪崩)。常用方法:
- 写脚本批量加载:比如启动后,用脚本查数据库的热点数据,批量写入缓存;
- 启动时加载:在服务启动的初始化阶段,主动加载必要的数据到缓存;
- 增量预热:刚启动时缓存空,第一次访问时加载,但限制并发量(比如用互斥锁),避免同时打数据库;
- 利用从库预热:从数据库从库查数据加载到缓存,不影响主库。
9. map 怎么去做并发安全
Go 的 map 本身不是并发安全的,多 goroutine 同时读写会 panic。实现并发安全有两种常见方式:
- 加锁:用
sync.Mutex 或 sync.RWMutex。RWMutex 更好,因为读操作可以并发(多个读锁同时持有),写操作才需要独占,效率更高; - 用 sync.Map:Go 1.9 后自带的并发安全 map,适合 “读多写少” 的场景,内部用了原子操作,不用手动加锁。
如果读写都频繁,一般用 RWMutex;读特别多,写很少,用 sync.Map 更合适。
10. 外层的协程能捕获子协程的 panic 吗?
不能直接捕获。子协程的 panic 如果没在自己内部处理,会直接导致整个程序崩溃。 要处理的话,必须在子协程内部用 recover 捕获 panic,然后通过 channel 把错误信息传给外层协程。比如:
errCh := make(chan error)
go func() {
defer func() {
if err := recover(); err != nil {
errCh <- fmt.Errorf("子协程 panic: %v", err)
}
}()
// 子协程逻辑
}()
// 外层协程从 errCh 取错误
11. panic 都会被捕获吗?哪些 panic 不会捕获?
不是所有 panic 都能被捕获。以下情况的 panic 可能无法捕获:
- recover 调用在 panic 之前:比如 defer 里的 recover 执行时,还没发生 panic,就没用;
- 在 init 函数里发生的 panic:init 函数是初始化阶段,这时候的 panic 可能导致程序直接退出,没法处理;
- 系统级别的错误:比如内存溢出(out of memory),这种属于致命错误,Go runtime 会直接终止程序,不给 recover 的机会;
- 多个 panic 嵌套:比如一个 defer 里发生 panic,同时又有其他 panic 没处理,可能导致 recover 失效。
12. channel 有缓冲和无缓冲的区别
核心区别在是否需要 “同步”:
- 无缓冲 channel:发送(send)和接收(recv)必须 “同时就绪”。发送方会阻塞到有接收方接收,接收方也会阻塞到有发送方发送,就像 “一手交钱一手交货”;
- 有缓冲 channel:缓冲区有容量。发送时,如果缓冲区没满,就直接把数据放进缓冲区,不阻塞;满了才阻塞。接收时,如果缓冲区有数据,就直接取,不阻塞;空了才阻塞。
简单说,无缓冲是 “即时同步”,有缓冲是 “异步缓冲”,适合不同的并发场景。
早日上岸!