首页 TG账号购买平台内容详情

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

2026-04-05 10 纸飞机账号购买

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

有许多人,在学习Go这个语言之际,那最具成就感的某一瞬间,乃是编写成一个看上去极为“工程化”的接口 ,呐!

于是你会产生一种很强的错觉:

编写Go业务代码,不就是弄这些内容嘛,先写接口,再查表格,接着丢任务,然后就完成了呀?句号。

但真实面试里,面试官根本不关心你会不会搭脚手架。

他更关心的是:

当业务着手步入高并发、超时控制、数据一致性以及批量任务、缓存穿透这些实际场景之际,你所编写出来的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';

这个时候,面试官如果继续追问:

那要是订单状态更新达成成功的结果了,然而发券却遭遇失败的状况了该如何处理呢,这难道不会致使数据出现不一致的情形吗?

这当口,你可不能仅仅停留在所谓的“接口逻辑”层面了,你需要朝着事务消息、本地消息表、异步补偿方向去作答。

更像生产方案的回答方式

真正成熟一点的答法,应该是这样的:

方案一:核心状态和后续动作解耦

也就是说:

+ 订单状态流转要幂等
+ 下游事件消费也要幂等
+ 不能指望“接口只进来一次”

面试里的标准加分话术

你可以这么说:

不会采用“先查再改”的写法来处理这个场景,因为处于并发状况下它并非原子性的。让数据库协助确保仅有一个请求能够将订单从 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。

简洁、并发、高性能,仿佛马上就能起飞。

但面试官只要追问两句,这代码就撑不住了:

一句话总结:

这不是并发,这是放飞自我。

真正的坑,不在“能不能并发”,而在“怎么收”

新手在Go语言中,最容易出现的那种错误,并非是不晓得开启goroutine,而是在于:

开得出去,收不回来。

典型事故包括:

这也是为什么生产环境里,批量任务绝不会写成“一把梭”模式。

更可靠的书写方式是: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 取消
+ 错误可以统一收敛
+ 任务投递和消费有明确边界

️ 但这还不够,真正的难点在“业务语义”

如果你以为写到这一步就结束了,面试官往往还会继续往下压:

若在十万个任务当中,有三百个任务失败了,而剩余的全部都成功了,那么你究竟是整批任务都失败了呢,还是部分任务取得了成功呢?

这一下,题目就从“并发编程题”,升级成了“业务建模题”。

因为真正的线上系统里,批处理很少是“全有或全无”的。

更常见的是:

具体来讲,意思就是属于这一类型的题目之中,最能够形成拉开差距状况的方面,并非在于语法范畴,而是依赖于你是否拥有任务治理相关的意识。

生产环境通常怎么答更稳?

比较成熟的答法应该包括这几层:

1. 固定 worker pool 控制并发

避免 goroutine 无限制膨胀。

2. 每个任务都要带 context

主流程超时、任务超时、服务关闭,都能及时停止。

3. 外部依赖前面加限流

比如 rate.Limiter,防止打挂下游服务。

4. 失败任务不能只打印日志

要么入库,要么发消息,要么进补偿队列。

5. 明确结果语义

到底是“部分成功”还是“必须全成功”,这个要根据业务定义。

面试里的高分话术

你可以这样总结:

就这个场景而言,我不会以简单的方式一把开启 goroutine,而是会运用具有固定大小的 worker pool 来对并发度予以控制,并且还要配合 context 去进行取消传播。要是所调用的属于外部依赖,我还将会添加限流措施,以此来防止把下游弄崩溃。至于错误处理方面,我会去区分究竟是 fail-fast 还是部分取得成功。若允许属于部分成功,我会将失败的任务转移到补偿系统,而并非仅仅是让数据在日志里一闪而过就了事咯。

这时候面试官就会知道:

你理解的不只是 Go 并发模型,还有并发背后的业务责任。

场景三:在热点缓存失效的那一瞬间,有大量的请求同时打到了数据库,那你会怎么运用 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
}

这时候你已经比“只会背缓存击穿定义”的候选人强很多了。

但高阶面试官通常还会继续补一刀:

那要是这个热点键一旦过期,请求全都阻塞于singleflight之上,难道不依旧会出现一下抖动的情况吗?

这时候,就轮到真正的进阶点了。

更进一步:逻辑过期 + 异步刷新

要是某些热点数据相当关键,就连瞬间的抖动都不想存在,那就能够持续去升级方案:

也就是说:

+ 要的不是“缓存一过期立刻删掉”
+ 而是“数据旧一点可以接受,但服务不能抖”

这已经不是单纯的技术点,而是业务权衡了。

比如:

所以面试官真正想看的是:

你会不会根据业务类型选方案,而不是逮着一个模式到处套。

这道题怎么答更完整?

比较完整的思路通常是:

普通数据热点数据超热点数据一致性要求高的数据 面试高分话术

你可以这么收尾:

在这个场景当中,我不会单单只去写一个简简单单的 cache aside,对于高并发热点 key 而言,我会于 Go 里运用 singleflight 来合并同 key 的回源请求,以此避免缓存失效的瞬间大量请求一同打到数据库。要是业务允许短暂的旧数据存在,我会进一步去考量逻辑过期加上异步刷新,优先确保系统的稳定性。倘若属于强一致业务,像是库存或者余额,我会更加谨慎地去使用缓存,防止性能优化反而对正确性造成损害。

这一段说完,基本已经不是“知道缓存”了,而是:

你知道缓存什么时候该上,什么时候不能乱上。

为什么 Go 面试特别爱问这种业务场景题?

因为 Go 这门语言有一个很大的特点:

语法并非复杂,框架同样不重,众人极易在表面呈现出“写得皆相差无几”的状况。

那怎么区分候选人的水平?

具备最高效率的办法,便是去观察他于实际业务场景之中,究竟可不可以应对这些问题。

讲得直白一点,在Go面试进行到后面阶段的时候,考查的根本就不是“你对这个语法是否掌握”。

而是:

你有没有线上意识。

最后总结:真正拉开差距的,从来不是语法熟练度

这 3 类题,本质上分别在考你 3 种能力:

1. 幂等与一致性

代表题:支付回调、订单状态推进、重复消息消费

2. 并发治理能力

代表题:批量任务、异步处理、协程池、限流降载

3. 稳定性设计能力

代表题:热点缓存、数据库保护、服务抖动控制

很多候选人面试失败,不是因为 Go 不会写。

而是因为他写出来的代码,只适合在本地跑,不适合在生产里活。

在现实条件里,真正称得上优秀的Go工程师,并非那种仅仅只要能够将功能给写出来的人,而是那种在高并发状况下,在遭遇失败情况时,在面临重试需求时,在出现超时问题时,在碰到脏数据现象时,在应对极端流量场景时,始终都能够让系统保持稳定状态的人。

这,才是业务代码题真正想筛出来的东西。

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

相关标签: # Go # 面试 # 业务场景 # 并发 # 缓存