一场面试 11 连问,背八股很容易当场露馅
刚开始做 Go 微服务那会儿,我以为面试就两类题:写算法、背八股。
后来才发现,很多面试官真正喜欢的,是这种“连环追问”:
你会用就行?那你说说为什么这么用;你说它快?那你说说快在哪;你说系统稳定?那你说说极端情况下会发生什么。
下面这篇面经不讲经历,只给你面试题 + 示例回答(并附带常见追问)。
如果你能把这些回答“讲顺”,大概率能过掉后端/微服务方向的核心拷问。
🪤 1)context 怎么用的?
面试官:context 你项目里怎么用?
很多人的“标准答案”是:context 就是用来传参的,或者加个超时。
但面试官真正想听的是:你是否理解它的边界和传播链路。
示例回答(面试版):
context主要解决 3 件事:取消(cancel)、超时/截止时间(deadline)、跨边界的请求级元数据(value)。- 我会把
ctx作为函数的第一个参数,沿调用链往下传到所有可能阻塞的地方:DB/Redis/HTTP/gRPC/MQ publish 等。 - 在入口(HTTP/gRPC handler)会拿到一个“请求根 ctx”,然后在内部需要更强约束的地方派生子 ctx:
WithTimeout/WithCancel。 WithValue只放请求范围、必须跨 API 边界的元数据(比如 traceId、userId),不把业务参数塞进去,更不会把 ctx 存到 struct 里长期持有。
func (s *Service) CreateOrder(ctx context.Context, req *CreateOrderReq) error {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// 1) 传给 DB / RPC / Redis
if err := s.repo.InsertOrder(ctx, req); err != nil {
return err
}
// 2) 自己的 goroutine 也要“跟着 ctx 退出”
go func() {
select {
case <-ctx.Done():
return
case <-time.After(200 * time.Millisecond):
_ = s.metrics.Report(req.OrderID) // 示例:真正实现里也应传 ctx
}
}()
return nil
}
常见追问(别踩坑):
- “
context里为什么不建议传业务参数?”——因为可读性差、无类型约束、容易滥用;更重要的是会让ctx的语义从“控制与元信息”变成“万能背包”。 - “如果下游不支持 ctx 怎么办?”——包一层适配,或者保证取消时能主动关闭连接/停止消费,至少让 goroutine 能退出。
⚡ 2)为什么选 ES?ES 为什么快?
面试官:你们为什么用 Elasticsearch?它为什么快?
如果你只说“全文检索、分词”,基本等于没答。
示例回答(面试版):
- 选 ES 是因为我们需要:全文检索 + 相关性排序 + 聚合分析 + 横向扩展,而这些在 MySQL 上靠
LIKE '%xx%'或冗余字段很难做,且代价很高。 - ES 快主要来自 Lucene 的索引结构与查询执行方式:
- 倒排索引:从“词 -> 文档列表”,检索是集合运算,不是全表扫。
- 段(segment)+ 不可变:写入先 refresh 成段,查询直接命中段文件,读路径更稳定。
- 列式存储(doc values)与缓存:聚合/排序更高效;filter 走缓存、bitmap 等结构,速度明显。
- 分片并行:一个 query 可以在多个 shard 上并行执行,再汇总结果。
- 我们会把 ES 的角色定位成“检索与分析引擎”,主数据仍以 DB 为准,ES 通过增量同步/异步消费来更新,接受一定的最终一致。
常见追问(面试官爱追):
- “ES 写入也很快吗?”——写入是吞吐型,refresh/merge 有成本;高写入要控制 refresh interval、合理分片、避免频繁更新同一文档导致段合并压力。
- “为什么不用 PG 的全文检索?”——可以用,但在相关性、生态、横向扩展、聚合性能上各有取舍;我们偏向 ES 的成熟度与运维经验(按你项目实际说)。
🔌 3)模块之间的通信怎么做的?
面试官:你们服务/模块之间怎么通信?
示例回答(面试版):
- 同步链路用 gRPC:IDL 清晰、性能好、统一错误码与超时控制,配合拦截器做 logging/trace/metrics。
- 异步链路用 MQ 事件通知:把“必须立即返回”的路径做短,把“最终一致即可”的动作下沉到异步(比如发券、写日志、触发索引更新)。
- 关键点是把工程问题讲清楚:超时/重试/幂等/顺序性/重复消费/可观测性。
同步(gRPC)适合:
+ 强依赖、必须拿到结果(比如查库存、查配置)
异步(MQ)适合:
+ 最终一致、允许延迟(比如通知、异步落库、索引更新)
常见追问:
- “既然用 MQ,怎么保证不丢?”——生产端 confirm、持久化;消费端手动 ack;失败重试 + 死信;业务幂等兜底。
🐇 4)为什么选 RabbitMQ?
面试官:你们为什么是 RabbitMQ,不是 Kafka?
示例回答(面试版)(按你项目取舍讲):
- 我们更看重低延迟、灵活路由、可靠投递、易运维:
- Exchange(direct/topic/fanout)路由能力强,适合业务事件分发。
- 支持 ack、重回队列、死信队列、延迟队列(常见实现)等,做“业务兜底”很方便。
- 对“任务队列/事件通知”这种模式很顺手。
- Kafka 更偏“高吞吐日志流”,我们这类“业务事件 + 路由 + 消费确认”场景更贴 RabbitMQ 的模型。
常见追问:
- “RabbitMQ 性能不如 Kafka 你怎么看?”——是的,吞吐上 Kafka 更强,但选型要看指标:我们更在意路由与投递语义;并且我们当前量级 RabbitMQ 足够,后续量级变化再做架构演进。
🆚 5)RabbitMQ 和 Kafka 的主要区别?
这题建议你直接给“对比维度”,面试官听得最舒服。
示例回答(面试版):
| 维度 | RabbitMQ | Kafka |
|---|---|---|
| 模型 | 队列/交换机路由 | 分区日志(log) |
| 消费 | 以投递/确认为核心 | 以 offset/重放为核心 |
| 历史消息 | 通常消费即删除(按队列语义) | 按保留策略长期保存,可重放 |
| 吞吐 | 中高(偏低延迟业务) | 很高(大吞吐流式) |
| 顺序性 | 单队列可保证 | 分区内有序 |
| 典型场景 | 业务事件、任务队列、复杂路由 | 埋点日志、流式计算、CDC、事件总线 |
补一句更像“项目经验”的话:
我们用 RabbitMQ 做“业务事件通知”,用 Kafka(如果有)做“日志/埋点/CDC 流”,这样职责更清晰。
🌳 6)B+ 树的本质是什么?
面试官:B+ 树你怎么理解?“本质”是什么?
示例回答(面试版):
- B+ 树的本质是:为磁盘/页存储设计的多路平衡搜索树,目标是用更大的扇出(fan-out)降低树高,从而减少 I/O 次数。
- 它把数据(或主键)集中在叶子节点,内部节点只存索引键;叶子节点通过链表相连,因此:
- 等值查询:从根到叶,I/O 次数可控;
- 范围查询:定位到起点叶子后顺序扫,顺序 I/O 更友好。
- 这也是为什么像 InnoDB 这类存储引擎会基于 B+ 树做索引:既能查得快,又能把范围与排序做得更自然。
常见追问:
- “B 树和 B+ 树差在哪?”——B 树数据可在内部节点;B+ 树数据都在叶子,范围扫描更稳。
📮 7)channel 了解多少?
面试官:Go 的 channel 你了解多少?有缓冲/无缓冲差别?
示例回答(面试版):
chan是 Go 的并发通信原语,本质是“同步/队列化的通信通道”,用来在 goroutine 之间安全传递数据。- 无缓冲 channel:发送与接收必须同时准备好,天然同步(更像 rendezvous)。
- 有缓冲 channel:缓冲没满时发送不阻塞;缓冲为空时接收阻塞;常用于削峰或做 worker pool。
- 关闭语义:
- 关闭后仍可接收(收到零值 +
ok=false),但再发送会 panic; - 通常由发送方关闭,接收方不要随便 close(除非你能保证只有你在发送)。
- 关闭后仍可接收(收到零值 +
select {
case v := <-ch:
_ = v
case <-ctx.Done():
return ctx.Err()
}
常见追问(高频坑):
nilchannel:收发都会永久阻塞,常用于在select里动态开关分支。- “怎么避免 goroutine 因 channel 卡住?”——配合
context、超时、或明确关闭通道;不要让for { <-ch }在没有退出条件的情况下跑。
⚙️ 8)GMP 模型:如果阻塞了会怎么样?
面试官:讲讲 GMP,如果 goroutine 阻塞了会怎样?
示例回答(面试版):
- G = goroutine,M = OS thread,P = 调度器的逻辑处理器(持有本地队列与运行时资源)。
- 正常情况下:P 把可运行的 G 放到本地队列,绑定到某个 M 上执行。
- 阻塞分两类(这是加分点):
- 网络 I/O:Go 有 netpoll,通常会把 G 挂起(park),让 M 继续拿 P 跑别的 G,不会“卡死整个线程”。
- 系统调用/长时间阻塞:M 可能被内核阻塞,运行时会把 P 从这个 M 上“摘”下来,交给别的 M 继续跑,必要时会创建新的 M 顶上。
- 如果阻塞点没有退出条件(比如一直等 channel/锁、ctx 不取消),就可能出现协程泄漏:G 一直挂着,资源慢慢被吃光。
常见追问:
- “Go 不是有抢占吗?”——是的,新版本支持异步抢占,但它解决的是“长时间计算不让出 CPU”的问题;对“等待某个永远不会发生的事件”无能为力,所以还是要设计退出机制。
🧩 9)为什么要有 P?
面试官:有了 G 和 M,为什么还要 P?
示例回答(面试版):
- P 的作用是把调度从“全局抢锁”变成“本地队列优先”,降低竞争:每个 P 有自己的 run queue,大多数调度都在本地发生。
- P 也承担了运行时的一些资源绑定(比如某些缓存/状态),并通过
GOMAXPROCS控制并行度:最多同时有多少个 P 在跑,也就最多同时跑多少个 goroutine(严格说是同时执行的 G 的数量上限)。 - 没有 P 的话,所有 M 都去抢全局队列锁,调度开销会显著上升,尤其在高并发下。
🕳️ 10)什么情况下会协程泄漏?
面试官:协程泄漏你遇到过吗?一般怎么发生?
示例回答(面试版):
- channel 永久阻塞:发送方没人接、接收方没人发;或者
for range ch等不到 close。 - 忘记取消/超时:内部起 goroutine 做重试/轮询,但 ctx 用了
context.Background()或者忘了cancel()。 - ticker/timer 没停:
time.NewTicker没有Stop(),goroutine 一直被唤醒做无意义工作。 - 资源未关闭导致阻塞:网络连接、文件句柄不关,读写 goroutine 卡在 I/O 上。
- fan-out 没收敛:每个请求开 N 个 goroutine,缺少并发上限/缺少
errgroup统一回收。
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return s.callA(ctx) })
g.Go(func() error { return s.callB(ctx) })
return g.Wait()
面试官喜欢听到的“工程化收尾”:
我会给所有后台 goroutine 一个明确的退出条件(ctx / close channel / done),并且在压测和线上用 pprof 看 goroutine 数是否稳定。
🐳 11)讲一下 Docker 和 K8s
面试官:Docker 和 K8s 你怎么理解?区别是什么?
示例回答(面试版):
- Docker 解决的是“怎么把应用和依赖打包并一致运行”:镜像(image)是交付物,容器(container)是运行态。
- K8s 解决的是“一堆容器怎么在集群里自动化运维”:调度、扩缩容、滚动升级、自愈、服务发现、配置管理。
- 常用对象我能讲清楚:
Deployment:无状态应用的发布与滚动升级Service:稳定访问入口(负载均衡/服务发现)Ingress:HTTP 路由入口(配合 Ingress Controller)ConfigMap/Secret:配置与密钥HPA:按指标自动扩缩容StatefulSet:有状态服务(按需)
一句话总结(很好用):
Docker 更像“把程序装进标准集装箱”,K8s 更像“港口调度系统”,负责把集装箱放到合适的船上、坏了自动换、忙了自动加船。
✅ 总结:面试官其实在考什么?
当这些题连着问的时候,面试官通常不是要你背概念,而是看你有没有三种能力:
- 能否把“会用”讲成“为什么这么用”(context、channel)
- 能否把“选型”讲成“约束 + 取舍”(ES、RabbitMQ vs Kafka)
- 能否把“并发”讲成“极端情况 + 退出机制”(GMP、协程泄漏)
你不需要把每个点都讲到论文级别,但你需要在关键处“说出底层原因”,并且能落到工程实践的兜底方案上。
END
写在最后:
最近私信问我面试题的小伙伴实在太多了,一个个回有点回不过来。
我大家公认最容易挂的 AI/Go/Java 面试坑点 整理成了一份 PDF 文档。里面不光有题,还有解题思路和避坑指南。
想要的同学,直接关注并私信我 【面试】,我统一发给大家。