Go 写业务代码很简单?那 3 个高频场景题你能扛住几个?

22 阅读14分钟

你的“会写 CRUD”,可能连第一轮技术面都撑不过去

很多人学 Go 的时候,最有成就感的一瞬间,是写出一个看起来非常“工程化”的接口:

  • 分层清晰
  • gin + service + repo
  • context 也传了
  • 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
}

它只是在业务层做了一次判断,但判断和更新并不是原子的。

灾难流程很简单:

  1. 回调 A 进来,查到状态还是 init
  2. 回调 B 也进来,查到状态还是 init
  3. A 更新成功,状态改成 paid
  4. B 也更新成功
  5. 两个请求都继续往后执行
  6. 发券两次、加积分两次、甚至通知下游两次

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 文档。里面不光有题,还有解题思路和避坑指南。

想要的同学,直接关注并私信我 【面试】,我统一发给大家。