一、Go 语言核心语法与陷阱#
Q1:defer 的执行顺序是什么?如果 defer 后面跟着 panic,执行过程是怎样的?
defer 是 LIFO(后进先出) 的栈结构,最后注册的 defer 最先执行。panic 触发后,程序不会立即退出,而是逆序执行当前 goroutine 已注册的所有 defer;如果某个 defer 中调用了 recover(),panic 会被捕获并停止向上传播;否则所有 defer 执行完毕后,panic 继续向上层 goroutine 传播。
Q2:map 是并发安全的吗?并发读写会怎样?如何实现并发安全?
map 不是并发安全的。Go 1.6 之后,运行时检测到并发读写会直接抛出 fatal error: concurrent map read and map write,无法通过 recover 捕获。原因是 map 底层涉及哈希桶、扩容搬迁等复杂操作,加全局锁会严重拖累性能,因此 Go 把同步责任交给开发者。并发安全的替代方案:sync.RWMutex + 原生 map(读多写少)或 sync.Map(读多写少、key 相对稳定的场景)。
Q3:Go 的 map 扩容机制是怎样的?
有两种扩容策略:翻倍扩容(装载因子超过 6.5,桶数翻倍)和 等量扩容(overflow 链表过长但元素总数不多,重新排列使数据紧凑)。扩容是 渐进式 的,不是一次性搬完,每次写操作搬迁少量 bucket,避免大数据量下造成长时间卡顿。
Q4:闭包在循环中有什么常见陷阱?Go 1.22 做了什么改变?
经典陷阱:在 for 循环中启动 goroutine 或创建闭包时,如果直接捕获循环变量,所有闭包可能共享同一个变量,最终都看到最后一次迭代的值。Go 1.22 之前需要 i := i 显式拷贝。Go 1.22 起,循环变量每次迭代都是新的实例,该陷阱在语言层面被修复。
二、运行时机制:GMP 与 GC#
Q5:请描述 GMP 调度模型的核心组件和调度流程。
- G(Goroutine) :任务单元,包含栈和状态。
- M(Machine) :内核线程,真正执行代码的实体。
- P(Processor) :逻辑处理器,持有本地 G 队列(LRQ),数量由
GOMAXPROCS决定,默认等于 CPU 核数。
调度流程:P 优先从自己的 LRQ 取 G 执行;LRQ 为空时,从全局队列(GRQ)取一批;GRQ 也为空时,触发 work stealing——随机选择一个其他 P,偷取其 LRQ 中一半的 G,实现负载均衡。
Q6:G 执行阻塞系统调用时,GMP 是如何处理的?
G 进入阻塞系统调用后,P 会与当前 M 解绑,P 转而绑定一个空闲 M(或新建 M)继续调度其他 G,避免 CPU 空转。这个机制叫 hand off。系统调用完成后,G 被唤醒,尝试重新获取一个 P 继续执行。网络 I/O 则走 netpoller(基于 epoll/kqueue),G 不占用 M,数据就绪后由 netpoller 唤醒并放回 P 的队列,效率更高。
Q7:协程能不能无限创建?为什么?
不能。 虽然单个 goroutine 初始栈仅约 2KB,但理由有三:(1)内存总量有限,量大照样撑爆;(2)调度有成本,G 过多会导致队列积压和切换开销;(3)M 和系统资源有限,大量 G 阻塞会拖垮整体调度。工程上需要用 worker pool、信号量或限流来控制并发。
Q8:Go GC 的三色标记法原理是什么?“混合写屏障”解决什么问题?
三色标记将对象分为白(未访问,待回收)、灰(已访问但子对象未扫描)、黑(自身及子对象均已扫描)。GC 从根对象出发,将可达对象逐步染灰、染黑,最终白色对象被回收。并发标记期间,如果用户程序修改了指针,可能导致“黑对象引用白对象”而白对象被错误回收。写屏障在指针写入时插入检查,保证三色不变式不被破坏;混合写屏障(Go 1.8+)结合了插入写屏障和删除写屏障的优点,大幅缩短了 STW 时间。
三、并发编程:Channel 与 Context#
Q9:有缓冲 channel 和无缓冲 channel 的区别是什么?分别适合什么场景?
无缓冲 channel 要求发送和接收 同时就绪,否则发送方阻塞,同步性强,适合严格的同步点或顺序保证。有缓冲 channel 在缓冲未满时发送不阻塞、缓冲有数据时接收不阻塞,生产消费解耦,适合任务队列、限流等场景。无缓冲的死锁风险更高(无接收方则永久阻塞),有缓冲在缓冲满时同样会阻塞。
Q10:如何优雅关闭 channel?有哪些方式?
核心原则:谁发送,谁关闭,接收方不应关闭 channel。常见方式:(1)发送方唯一时,直接 close(ch);(2)多发送方场景,用额外的 done channel 广播关闭信号;(3)结合 context 取消,适用于带超时或请求级的场景,是现代 Go 的推荐写法。
Q11:select 的 default 和 nil channel 行为是什么?
多个 case 同时就绪时,select 随机选择一个执行(公平性)。所有 case 都不就绪时,有 default 则执行 default,无 default 则阻塞。nil channel 上的 case 永远不就绪,常用于动态禁用某个分支(例如初始化未完成的 channel)。
Q12:context 的四大作用是什么?Background() 和 TODO() 有什么区别?
四大作用:传递取消信号、传递截止时间/超时、传递请求域值(WithValue,慎用)、链式派生(父 ctx 取消自动传播到子 ctx)。Background() 是根上下文,永不取消、无值、无截止时间,作为起点使用;TODO() 是语义上的占位符,表示“暂未确定用什么 context,后续替换”。
Q13:context.WithTimeout 创建子 context 后,为什么必须调用 cancel()?
即使超时未触发,WithTimeout 内部会启动定时器并持有父 context 的引用。不调用 cancel() 会导致子 context 的生命周期超出必要范围,造成 goroutine 和内存泄漏。标准写法是创建后立即 defer cancel()。
四、内存与性能#
Q14:什么是内存逃逸?常见触发场景有哪些?
内存逃逸指编译器分析后发现变量的生命周期可能超出当前函数栈帧,将其分配到 堆 上。常见场景:函数返回局部变量指针;将指针或含指针的值发送到 channel;切片中存放指针对象;slice 扩容后底层数组重新分配;在 interface 上调用方法(真实类型运行时才确定)。
Q15:逃逸越多越好还是越少越好?
不是越少越好,但过多有害。 逃逸是编译器为保证正确性做出的决策。关键是要意识到:逃逸过多会增加 GC 压力,在性能敏感路径(高频分配的热点代码)上应尽量减少不必要的逃逸;非热点路径的少量逃逸无需过度优化。
五、MySQL#
Q16:为什么 InnoDB 使用 B+ 树作为索引结构?
回答及格线:树高可控(3~4 层可支撑千万级数据,磁盘 I/O 次数少)、范围查询快(叶子节点有有序链表)、非叶子节点不存数据因此单页能容纳更多索引项。加分项:聚簇索引叶子节点存整行数据,二级索引叶子存主键值,查询非覆盖列需要 回表。
Q17:什么情况下索引会失效?
常见失效场景包括:对索引列使用函数或表达式(如 WHERE YEAR(created_at) = 2025)、隐式类型转换(字符串列传数字)、LIKE 以通配符开头(%abc)、OR 条件中部分列无索引、不满足最左前缀原则的联合索引查询。排查手段是 EXPLAIN 查看 type 和 rows。
Q18:MySQL 的 RR 隔离级别是如何避免不可重复读的?
RR(可重复读)通过 MVCC 实现:每行记录有隐藏的 DB_TRX_ID 和 DB_ROLL_PTR,事务在第一次快照读时生成一致性视图(Read View),后续读操作基于该视图判断数据版本是否可见,从而保证同一事务内多次读取同一行结果一致。同时,InnoDB 在 RR 下对当前读使用 间隙锁 + 行锁(Next-Key Lock)来防止幻读。
六、Redis 与分布式#
Q19:如何用 Redis 实现分布式锁?需要注意什么?
核心命令:SET key value NX EX seconds,原子性地完成“不存在则设置 + 设置过期时间”,避免分开执行时在两步之间宕机导致死锁。释放锁时,需要先比较 value(通常是唯一请求标识)再删除,并用 Lua 脚本保证比较和删除的原子性,防止误删其他客户端持有的锁。
Q20:缓存与数据库的一致性如何保证?“先删缓存再更新库”和“先更新库再删缓存”哪个更好?
业界更常用的是 先更新数据库,再删除缓存(Cache-Aside 的变体) 。“先删缓存再更新库”在并发读场景下更容易出现旧数据被回填缓存的问题。即便如此,先更新库再删缓存仍存在极小的不一致窗口(读请求在删缓存前读到旧值并写回)。更严格的方案包括:延迟双删(更新后延迟一段时间再删一次)、binlog 订阅 + 消息队列异步淘汰缓存,以及本地缓存场景下通过版本号或消息通知各节点主动淘汰。
Q21:分布式系统如何保证最终一致性?
典型框架:事务消息(或本地消息表)+ 重试机制 + 幂等性。核心思路是:业务操作与消息发送在同一个本地事务中落库,由后台任务或消息队列保证消息至少投递一次,消费方通过幂等键(业务唯一 ID)去重,从而实现跨服务的最终一致。适用于订单支付后扣库存、加积分、写日志等场景。
七、工程实践与线上问题#
Q22:高并发下用锁导致 panic、服务直接退出,排查思路是什么?
先看 panic 类型和堆栈。常见 Go 后端 panic 来源:nil 指针解引用、切片越界、map 并发读写、重复 close channel、类型断言失败、资源关闭后继续调用。定位后,检查锁的使用是否正确保护了共享资源,是否存在未加锁路径;map 并发读写需要替换为 sync.RWMutex 或 sync.Map;channel 重复关闭需要审视关闭责任方是否唯一。长期方案是引入 race detector(go test -race / 编译时 -race)在测试阶段暴露竞态。
Q23:有一个微服务场景,需要并发调用多个下游服务聚合数据,总超时不超过 200ms,用 Go 怎么做?
使用 context.WithTimeout 创建总超时 context,配合 errgroup.WithContext(或 sync.WaitGroup + channel)并发发起调用。每个下游调用都使用该 ctx,任一调用超时或返回 error 时,errgroup 会取消整个 ctx,其他 goroutine 收到取消信号后应立即退出,避免资源浪费。关键点:ctx 必须穿透到每个下游调用,并且聚合逻辑要处理部分失败(返回降级数据或错误)。
面试侧重点观察#
从近期面经来看,不同公司的考察重心有明显差异:字节跳动更侧重并发编程和内存模型(goroutine、channel、逃逸分析),贴合高并发业务场景;腾讯偏重基础语法和工程规范(defer、接口、类型断言、init 顺序),关注线上稳定性;阿里会延伸到泛型和 Go 1.22+ 新特性,匹配中间件和框架开发需求;华为更关注底层原理和内存安全(unsafe、内存对齐)。准备时可以根据目标公司的业务方向有所侧重,但 GMP、channel/context、map 并发安全、内存逃逸、MySQL 索引与事务、Redis 分布式锁与缓存一致性这几块,几乎是所有后端岗位的“交集考点”,建议优先吃透。