你的“会写 CRUD”,可能连第一轮技术面都撑不过去
很多人学 Go 的时候,最有成就感的一瞬间,是写出一个看起来非常“工程化”的接口:
- 分层清晰
gin + service + repocontext也传了err也判断了- 甚至还顺手加了个
goroutine
于是你会产生一种很强的错觉:
“Go 业务代码不就是这些东西吗?接口一写,表一查,任务一丢,完事。”
但真实面试里,面试官根本不关心你会不会搭脚手架。
他更关心的是:
当业务开始进入高并发、超时控制、数据一致性、批量任务、缓存穿透这些真实场景时,你写出来的 Go 代码,到底是‘能跑’,还是‘能上生产’?
这也是为什么很多候选人一开始答得很顺,但一旦进入业务题环节,立刻就暴露出短板:
- 协程是会开,但不会收
channel是会用,但一高并发就泄漏context是会传,但超时和取消根本没打通- 缓存是会加,但一致性和击穿问题一问就沉默
- 代码看着很 Go,实际上全是事故点
今天这篇,我们不讲八股,不讲语法细节。
直接上 3 道在面试里非常常见、而且一旦展开就很容易把候选人“问到现原形”的 Go 业务场景代码题。
如果你能把这 3 题讲明白,基本已经不是“会写 Go”,而是开始具备一点真正的后端工程能力了。
场景一:支付回调重复到达,订单只能处理一次,你怎么写?
这是后端面试里非常经典的一道题。
看起来它像一道“防重”题,实际上它同时在考你:
- 幂等设计
- 并发安全
- 数据库事务
- 分布式场景下的兜底思路
- Go 里上下文和错误处理怎么组织
🪤 大部分人的“标准答案”
面试官问:
“第三方支付平台可能会重复回调,你怎么保证订单不会被重复处理?”
很多人的第一反应是:
“简单啊,先查订单状态,如果已经支付就直接返回;如果没支付就更新成已支付,再做后续发券、积分、库存之类的逻辑。”
然后代码差不多会写成这样:
func (s *OrderService) PayCallback(ctx context.Context, orderID string) error {
order, err := s.repo.GetByID(ctx, orderID)
if err != nil {
return err
}
if order.Status == "paid" {
return nil
}
order.Status = "paid"
if err := s.repo.Update(ctx, order); err != nil {
return err
}
if err := s.couponSvc.SendCoupon(ctx, order.UserID); err != nil {
return err
}
return nil
}
表面上看没毛病。
逻辑完整,代码优雅,甚至还很符合“先查再改”的直觉。
但资深一点的面试官,一般马上就会追问一句:
“如果两个重复回调同时打进来,这段代码会发生什么?”
很多人到这一步就开始慌了。
💥 这段代码真正的问题在哪?
问题就出在这句:
if order.Status == "paid" {
return nil
}
它只是在业务层做了一次判断,但判断和更新并不是原子的。
灾难流程很简单:
- 回调 A 进来,查到状态还是
init - 回调 B 也进来,查到状态还是
init - A 更新成功,状态改成
paid - B 也更新成功
- 两个请求都继续往后执行
- 发券两次、加积分两次、甚至通知下游两次
Boom。
你的代码没有报错,日志甚至看起来一切正常。
但业务已经炸了。
🛡️ 正确思路:别用“先查再改”,要用“条件更新 + 事务 + 事件幂等”
真正靠谱的解法,一般是:
用数据库的条件更新来抢“状态变更权”。
谁先把状态从 init 改成 paid,谁才有资格继续执行后续逻辑。
func (s *OrderService) PayCallback(ctx context.Context, orderID string) error {
affected, err := s.repo.MarkPaidIfInit(ctx, orderID)
if err != nil {
return err
}
// 说明别人已经处理过了,这次回调直接幂等返回
if affected == 0 {
return nil
}
// 只有真正抢到状态流转资格的人,才能继续执行业务
if err := s.couponSvc.SendCoupon(ctx, orderID); err != nil {
return err
}
return nil
}
对应 SQL 一般是:
UPDATE orders
SET status = 'paid', paid_at = NOW()
WHERE order_id = ? AND status = 'init';
这个时候,面试官如果继续追问:
“那如果订单状态更新成功了,但发券失败了怎么办?这不就数据不一致了吗?”
这时候你就不能只停留在“接口逻辑”层了,你得往事务消息 / 本地消息表 / 异步补偿去答。
✅ 更像生产方案的回答方式
真正成熟一点的答法,应该是这样的:
方案一:核心状态和后续动作解耦
- 订单支付成功 是核心事实
- 发券、积分、短信通知这些,都是后置动作
- 先确保核心事实落库成功
- 再通过消息表 / MQ 异步触发后续操作
- 后续动作本身也要做幂等
也就是说:
+ 订单状态流转要幂等
+ 下游事件消费也要幂等
+ 不能指望“接口只进来一次”
🎯 面试里的标准加分话术
你可以这么说:
这个场景我不会用“先查再改”的写法,因为它在并发下不是原子的。更稳妥的方式是用条件更新,让数据库帮我保证只有一个请求能把订单从
init改成paid。至于发券、积分这种后置动作,我会拆成异步事件,并且让消费端也做幂等,比如基于业务唯一键或去重表,避免重复消费。
这一套讲完,面试官会明显感觉到:
你不是在写 demo,你是在写线上系统。
场景二:批量处理 10 万用户数据,要求并发快、失败可控、不能把机器打爆,你怎么写?
这题在 Go 面试里出现频率极高。
因为它几乎是 Go 天然擅长的领域:
- 并发任务
- 协程控制
- 限流
- 超时取消
- 错误收敛
- 资源回收
很多面试官特别喜欢拿这题来判断一个候选人,到底是“会开 goroutine”,还是“真的懂 Go 并发”。
🪤 大部分人的第一反应:一把梭,直接开协程
题目通常长这样:
“现在有 10 万个用户,要并发请求外部服务计算画像标签,要求尽量快,但不能压垮下游,部分失败要可追踪,你怎么写?”
很多候选人条件反射就是:
for _, user := range users {
go func(u User) {
_ = s.tagSvc.BuildTag(context.Background(), u.ID)
}(user)
}
看起来非常 Go。
简洁、并发、高性能,仿佛马上就能起飞。
但面试官只要追问两句,这代码就撑不住了:
- 10 万个 goroutine 一次性打出去,内存怎么办?
- 下游接口 QPS 限制怎么办?
- 某些任务失败了怎么重试?
- 主流程超时了,这些 goroutine 怎么停?
- 错误怎么汇总?
- 结果怎么收集?
- 如果某个协程 panic 了会怎样?
一句话总结:
这不是并发,这是放飞自我。
💥 真正的坑,不在“能不能并发”,而在“怎么收”
Go 新手最容易犯的错误,不是不会开 goroutine,而是:
开得出去,收不回来。
典型事故包括:
- goroutine 泄漏
channel无人消费导致阻塞- 主流程返回了,子任务还在跑
- 下游被瞬时流量打挂
- 大量失败任务没有统一治理
context根本没发挥作用
这也是为什么生产环境里,批量任务绝不会写成“一把梭”模式。
🛡️ 更靠谱的写法:worker pool + errgroup + context + 限流
这类题的核心思路是:
不是追求“并发越多越好”,而是追求“有边界地并发”。
一个更像样的 Go 写法,大概会长这样:
func (s *Service) BatchBuildTags(ctx context.Context, users []User) error {
g, ctx := errgroup.WithContext(ctx)
workerNum := 20
userCh := make(chan User)
// 启动固定数量 worker
for i := 0; i < workerNum; i++ {
g.Go(func() error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case u, ok := <-userCh:
if !ok {
return nil
}
if err := s.tagSvc.BuildTag(ctx, u.ID); err != nil {
// 可以打日志、记失败表、做重试标记
return err
}
}
}
})
}
// 投递任务
g.Go(func() error {
defer close(userCh)
for _, u := range users {
select {
case <-ctx.Done():
return ctx.Err()
case userCh <- u:
}
}
return nil
})
return g.Wait()
}
这段代码不一定是最终生产版本,但它至少具备了几个关键特征:
+ 并发数有上限,不会无限开协程
+ 支持 context 取消
+ 错误可以统一收敛
+ 任务投递和消费有明确边界
⚠️ 但这还不够,真正的难点在“业务语义”
如果你以为写到这一步就结束了,面试官往往还会继续往下压:
“如果 10 万个任务里失败了 300 个,其余都成功了,你是整批失败,还是部分成功?”
这一下,题目就从“并发编程题”,升级成了“业务建模题”。
因为真正的线上系统里,批处理很少是“全有或全无”的。
更常见的是:
- 成功的先落库
- 失败的进入重试队列
- 超过重试次数进入死信或人工介入
- 最终给出一份可审计的执行结果
也就是说,这类题最容易拉开差距的地方,不在语法,而在你有没有任务治理意识。
✅ 生产环境通常怎么答更稳?
比较成熟的答法应该包括这几层:
1. 固定 worker pool 控制并发
避免 goroutine 无限制膨胀。
2. 每个任务都要带 context
主流程超时、任务超时、服务关闭,都能及时停止。
3. 外部依赖前面加限流
比如 rate.Limiter,防止打挂下游服务。
4. 失败任务不能只打印日志
要么入库,要么发消息,要么进补偿队列。
5. 明确结果语义
到底是“部分成功”还是“必须全成功”,这个要根据业务定义。
🎯 面试里的高分话术
你可以这样总结:
这个场景我不会简单地一把梭开 goroutine,而是会用固定大小的 worker pool 控制并发度,再配合 context 做取消传播。如果调用的是外部依赖,我还会加限流,避免把下游打爆。至于错误处理,我会区分是 fail-fast 还是部分成功,如果允许部分成功,我会把失败任务落到补偿系统,而不是让日志里飘过去就算了。
这时候面试官就会知道:
你理解的不只是 Go 并发模型,还有并发背后的业务责任。
场景三:热点缓存失效瞬间,大量请求同时打到数据库,你怎么用 Go 扛住?
这一题也是面试常客。
而且特别有意思:
看起来像缓存题,实际上会一路问到:
- 缓存击穿
- singleflight
- 逻辑过期
- 本地缓存
- 热点隔离
- 缓存一致性
- Go 里的并发控制
很多候选人一听到缓存,立刻回答:
“查 Redis 啊,没有就查数据库,再写回 Redis。”
听上去没错。
但这恰恰是最危险的回答。
🪤 最朴素的缓存代码,往往最容易出事故
比如:
func (s *ProductService) GetProduct(ctx context.Context, id int64) (*Product, error) {
key := fmt.Sprintf("product:%d", id)
val, err := s.redis.Get(ctx, key).Result()
if err == nil {
var p Product
_ = json.Unmarshal([]byte(val), &p)
return &p, nil
}
p, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
data, _ := json.Marshal(p)
_ = s.redis.Set(ctx, key, data, time.Minute*5).Err()
return p, nil
}
在低并发下,它跑得很好。
但面试官只要给你加一个条件:
“这个商品是热点商品,QPS 很高,刚好缓存失效了,会怎么样?”
答案是:
数据库会被瞬间打穿。
因为缓存失效那一瞬间,1000 个请求同时进来,发现 Redis 没数据,就会一起冲向数据库。
这就叫:
缓存击穿。
💥 为什么很多人知道缓存击穿,却还是答不好?
因为很多人只记住了概念:
- 穿透
- 击穿
- 雪崩
但一到 Go 代码层面,就不会落了。
比如面试官接着问:
“那你怎么在 Go 里防止同一个 key 的并发回源?”
很多人又会卡住。
因为真正的问题不是“你知不知道概念”,而是:
你会不会把它写进一个服务里。
🛡️ Go 里很经典的解法:singleflight
Go 在这类场景里有个非常好用的工具:
singleflight
它的核心思想非常朴素:
同一时刻,同一个 key,只允许一个请求去加载数据,其余请求等它结果。
代码大概这样:
type ProductService struct {
repo Repo
redis RedisClient
group singleflight.Group
}
func (s *ProductService) GetProduct(ctx context.Context, id int64) (*Product, error) {
key := fmt.Sprintf("product:%d", id)
// 先查缓存
if val, err := s.redis.Get(ctx, key).Result(); err == nil {
var p Product
if json.Unmarshal([]byte(val), &p) == nil {
return &p, nil
}
}
v, err, _ := s.group.Do(key, func() (interface{}, error) {
// 双检,避免等待期间别人已经写回缓存
if val, err := s.redis.Get(ctx, key).Result(); err == nil {
var p Product
if json.Unmarshal([]byte(val), &p) == nil {
return &p, nil
}
}
p, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
data, _ := json.Marshal(p)
_ = s.redis.Set(ctx, key, data, 5*time.Minute).Err()
return p, nil
})
if err != nil {
return nil, err
}
return v.(*Product), nil
}
这时候你已经比“只会背缓存击穿定义”的候选人强很多了。
但高阶面试官通常还会继续补一刀:
“那如果这个热点 key 一过期,请求全部阻塞在 singleflight 上,不还是会抖一下吗?”
这时候,就轮到真正的进阶点了。
🚀 更进一步:逻辑过期 + 异步刷新
如果某些热点数据特别关键,连瞬时抖动都不想要,可以继续升级方案:
- Redis 不直接依赖物理过期时间
- 数据里额外带一个“逻辑过期时间”
- 读取时即便过期,也先返回旧值
- 后台异步触发刷新
- 用户请求不被阻塞
也就是说:
+ 要的不是“缓存一过期立刻删掉”
+ 而是“数据旧一点可以接受,但服务不能抖”
这已经不是单纯的技术点,而是业务权衡了。
比如:
- 商品详情页:旧几秒通常能接受
- 库存、余额:旧数据可能完全不能接受
所以面试官真正想看的是:
你会不会根据业务类型选方案,而不是逮着一个模式到处套。
✅ 这道题怎么答更完整?
比较完整的思路通常是:
普通数据
- Cache Aside
- 查缓存,miss 再查库回填
热点数据
- singleflight 防并发回源
- 加随机过期时间,避免同一批 key 同时失效
超热点数据
- 本地缓存 + Redis 多级缓存
- 逻辑过期 + 异步刷新
- 必要时做热点隔离
一致性要求高的数据
- 谨慎使用缓存
- 或者缩短 TTL、增加主动失效机制
- 不能只谈性能,不谈正确性
🎯 面试高分话术
你可以这么收尾:
这个场景下我不会只写一个简单的 cache aside。对于高并发热点 key,我会在 Go 里用 singleflight 合并同 key 的回源请求,避免缓存失效瞬间大量请求同时打到数据库。如果业务允许短暂旧数据,我会进一步考虑逻辑过期加异步刷新,优先保证系统稳定性。如果是强一致业务,比如库存或余额,我会更谨慎地使用缓存,避免性能优化反过来伤到正确性。
这一段说完,基本已经不是“知道缓存”了,而是:
你知道缓存什么时候该上,什么时候不能乱上。
✅ 为什么 Go 面试特别爱问这种业务场景题?
因为 Go 这门语言有一个很大的特点:
语法不复杂,框架也不重,大家很容易在表面上“写得都差不多”。
那怎么区分候选人的水平?
最有效的方法,就是看他在真实业务场景里,能不能处理这些问题:
- 并发下的数据争抢
- 大批量任务的边界控制
- 热点流量下的稳定性设计
- 超时、取消、失败、补偿这些“脏活累活”
说白了,Go 面试问到后面,考的根本不是“你会不会这个语法”。
而是:
你有没有线上意识。
最后总结:真正拉开差距的,从来不是语法熟练度
这 3 类题,本质上分别在考你 3 种能力:
1. 幂等与一致性
代表题:支付回调、订单状态推进、重复消息消费
2. 并发治理能力
代表题:批量任务、异步处理、协程池、限流降载
3. 稳定性设计能力
代表题:热点缓存、数据库保护、服务抖动控制
很多候选人面试失败,不是因为 Go 不会写。
而是因为他写出来的代码,只适合在本地跑,不适合在生产里活。
真正优秀的 Go 工程师,不是能把功能写出来的人,而是能在高并发、失败、重试、超时、脏数据、极端流量这些现实条件下,依然把系统稳住的人。
这,才是业务代码题真正想筛出来的东西。
END
写在最后:
最近私信问我面试题的小伙伴实在太多了,一个个回有点回不过来。
我花了两个周末,把星球里大家公认最容易挂的 AI/Go/Java 面试坑点 整理成了一份 PDF 文档。里面不光有题,还有解题思路和避坑指南。
想要的同学,直接关注并私信我 【面试】,我统一发给大家。