面灵AI→

得物 Golang 一面:并发 map、超时控制与 Agent 设计

轮次
一面
时间
2026-10
来源
牛客网

《面试题目》

  1. 请介绍你的实习经历:主要做了什么、承担了哪些工作?
  2. Go 里的 goroutine 和操作系统的线程是什么关系?
  3. 多个 goroutine 并发去更新一个普通的 map,会有安全问题吗?
  4. 为什么会出现这种崩溃?原因是什么?
  5. 如果把 map 里那个标志位的校验逻辑去掉,会有什么影响?
  6. 如果两个 goroutine 写的是不同的 key,还会有这个问题吗?
  7. 如果刚好不用扩容呢?还会有问题吗?
  8. 在代码里怎么避免并发更新 map 的安全问题?
  9. 场景题:实现一个接口,并发去查三个外部数据(商品信息、库存、价格),整条接口的总耗时预算是 800 毫秒,你怎么设置超时并把它传给下游?
  10. 怎么实现超时时三个 goroutine 都能被 cancel 掉?
  11. 触发取消的时候,goroutine 会自动被杀掉吗?
  12. 如果一次要查 100 个商品,还能不加限制地启动所有查询吗?
  13. 创建一个订单并写入 MySQL,客户端请求后没收到响应又重发了一次同样的请求,怎么避免生成两次订单?
  14. 加唯一索引能解决,那在数据库前面加一层 Redis 分布式锁,能代替数据库的唯一索引吗?
  15. 两个请求都先查到数据库库存还有 1,再各自做扣减,为什么还会出现卖超?
  16. Agent 设计题:做一个客服助手,要能回答退货政策、也要能查当前用户的订单,政策数据在文档库、订单在业务系统,它该怎么同时查这两类信息?
  17. 如果模型生成的工具参数是一个可以正常解析的 JSON,格式没问题,可以直接去执行吗?
  18. 智能助手往后迭代,工具会越来越多(从 510 个涨到 100200 个),这时候怎么优化?
  19. 一个 Agent 开发完成之后,怎么评测它的回答质量?

《参考解析》

goroutine 和线程:多路复用,不是一个量级的东西。 线程是内核调度实体,创建要陷入内核、栈通常是 MB 级,切换要保存寄存器并刷 TLB,成本在微秒以上;goroutine 是 Go runtime 调度的用户态协程,初始栈只有 2KB 且可增长,创建与切换大部分在用户态完成,成本低一到两个数量级。二者的关系靠 G-M-P 模型表达:G 是 goroutine,M 是内核线程,P 是持有可运行队列和调度上下文的处理器,默认数量等于 GOMAXPROCS;M 必须绑定 P 才能执行 G,因此任意时刻真正并行执行用户代码的 goroutine 最多是 P 个,其余在各自的运行队列里等待。被问到「关系」时最好补两条运行时行为:一是 goroutine 执行阻塞式系统调用时,runtime 会把 M 与 P 解绑(handoff),把 P 让给其他 M 继续跑,避免一个阻塞调用拖住整个 P;网络 IO 走 netpoller,不占用线程等待;二是 1.14 之后有基于信号的异步抢占,长时间不让出的死循环也能被调度器打断。再补一句工程含义:goroutine 泄漏是真实风险,因为它是协作式的,没人取消就永远挂着。

并发写 map 的崩溃:运行时主动检出的「早失败」。 Go 的原生 map 明确不支持并发使用。runtime 在写路径(mapassign、mapdelete)进入时会把自己置的 hashWriting 标志写进 h.flags,读路径(mapaccess)先检查这个标志,发现有人在写就抛 fatal error: concurrent map read and map write;两个写者互相撞上则是 fatal error: concurrent map writes。关键点是它属于 runtime 的 throw,不是普通 panic,recover 拦不住,进程直接终止,所以线上遇到它只能靠重启和日志排查,别指望兜底。

去掉标志位校验会怎样。 崩溃不会消失,只是从「确定性地立刻失败」变成「随机地在别处坏掉」。那几行检查的作用只是检测,它并不提供任何互斥保护:没有它,两个 goroutine 依然会同时修改同一张哈希表的桶、tophash、计数器,出现丢更新(两次写只留下一份)、读到写到一半的桶、count 与实际元素数不一致。这些损坏最终通常在扩容搬迁、遍历或 GC 扫描阶段以更难定位的形式爆出来(指针指向已释放对象、map 状态自相矛盾)。所以结论是:标志位是安全网而不是锁,去掉它只是把可复现的崩溃换成不可复现的数据损坏。

不同 key、不扩容,依然有问题。 runtime 的检查是map 粒度的,不区分 key,所以写不同 key 照样触发 fatal error。即使假设绕过了检测,两个写者也可能落在同一个桶里(哈希低位相同),或者其中一个触发了扩容——扩容要为整张表建新桶、搬迁旧桶、改 h.oldbuckets 指针,这时另一个写者正在按旧桶地址写,直接写丢。至于「刚好不用扩容」:不扩容只是没有搬迁这一步,桶指针数组、tophash、计数器仍然是共享可变状态,读写并发依旧构成数据竞争,检测也与是否扩容无关——触发条件只是「有写者持着 hashWriting 标志时另一个 goroutine 碰了这张 map」。这道连环追问的考点就是:并发安全问题是 map 级别的结构问题,不是某几个 key 的冲突问题。

怎么避免:按读写比选工具。 写法要能说出取舍。通用解法是 sync.Mutex 或 sync.RWMutex 把 map 包起来,读多写少时用 RWMutex;但锁本身会成为热点,于是有分片锁——按 key 的哈希把一个大 map 拆成 N 个小 map、各自加锁,把竞争摊开(很多缓存库就是这么做的)。sync.Map 适合两类特定场景:key 集合基本稳定、读远多于写(缓存、注册表),内部用 read/dirty 两份结构做无锁读,但持续写入时会退化成加锁且内存开销更大,别当成通用替代品。还有一种思路是把所有权收归一處:所有读写都通过 channel 交给单一 goroutine 处理(actor 模式),或者干脆把这份共享状态外置到 Redis,进程内不再持有。答题时按「场景 → 选型 → 代价」讲,比背 API 有用。

800ms 预算下怎么设超时并向下游传递。 核心原则是预算逐层递减、超时只有一份。整体预算 800ms,先给自身和序列化留出余量,在入口用 context.WithTimeout(ctx, 700*time.Millisecond) 生成一个带 deadline 的 ctx,再用 errgroup.WithContext 把这一个 ctx 派生给三个并发任务,三个下游调用都用同一个 ctx(HTTP 用 req.WithContext、gRPC 直接透传、数据库用 QueryContext)。任一任务失败或超时,errgroup 会立刻 cancel 这个 ctx,其余任务随之收到 Done 信号。同时要求每个下游客户端自己的超时也小于上一级预算,避免出现「上游已放弃、下游还在跑并写库」的情况。还要明确降级语义:三个数据源里价格和库存查不到时接口是返回部分数据还是直接失败,这属于业务定义,不能留给超时机制决定;并且日志要记清是哪个下游超时,否则线上只能看到一个笼统的 504。

cancel 是协作式的,goroutine 不会自动被杀。 Go 没有从外部强杀一个 goroutine 的机制,context 取消的本质是关闭 Done channel,只有主动监听它的代码才会退出。所以每个 goroutine 都要在 select { case <-ctx.Done(): return ...; default: } 这类位置监听,并且把 ctx 一路传给它发起的下游 IO——如果子任务内部用 context.Background() 发请求,父层取消对它无效,它会一直跑到自己的超时,这就是典型的 goroutine 与连接泄漏。实践上还要处理「取消后已拿到的部分结果」:超出 deadline 就丢弃并统一返回超时错误,别把半份数据当成功返回。被追问「select 里 ctx.Done 和数据 channel 同时就绪怎么办」时,答案是再检查一次 ctx 状态或用带优先级的写法,避免把已过期结果当成有效结果。

100 个商品不能无限制并发。 无限制会瞬间起上百个连接,打爆下游连接池与配额、触发限流,内存与 goroutine 数暴涨,结果是整体尾延迟更差、还容易被判成攻击流量。正确做法是给并发加一个上限:用带缓冲 channel 当信号量、errgroup.SetLimit、或固定大小的 worker pool 消费任务队列,并行度按下游承受能力与自身资源(连接池大小、QPS 配额)来定。再配几条工程习惯:能批量查就合并成一次请求,能缓存就缓存,失败要区分「可重试」与「不该重试」,整体仍受同一个总预算约束,超时后不再补发。

订单重复写入:幂等键加唯一索引。 客户端超时重试时,服务端无法从请求内容判断这是新请求还是重试,所以必须由客户端带一个业务幂等键(订单号、请求 ID),服务端拿它去抢占:建一张带唯一索引的订单表或去重表,INSERT 成功才继续后续流程,唯一键冲突就说明已经处理过,直接返回已存在订单(或查询后返回)。关键是别用「先查再插」,那两步之间存在竞态,并发下照样写两份。再往上还应该做状态机约束(同一订单的后续操作按状态条件更新)和重试语义约定(同键同参才视为重试)。

Redis 分布式锁不能代替唯一索引。 两者的角色不同:锁解决的是「同一时刻只让一个请求进来」,幂等要回答的是「这个请求以前到底成功过没有」,后者必须有一条持久化、且带唯一性约束的事实记录。锁的失效模式正好覆盖不住这个问题——锁可能因为持有者长时间 STW、续期失败、主从切换或网络分区而出现两个持有者;持锁进程在提交前崩溃后重试时,锁早就释放了但订单可能已经落库;锁的 TTL 到期与业务事务提交之间也没有原子关系。唯一索引是数据库层的最后防线,无论上游怎么漏都能兜住,所以正确的组合是「幂等键 + 唯一索引」作为强约束,Redis 锁(或去重缓存)只用于挡掉重复流量、减少无效写。

为什么会卖超:判断与扣减不在一个原子操作里。 「先查库存 = 1,再扣减」是两个独立步骤,两个请求可以都读到 1,然后各自执行扣减,最终库存变成 -1。这是典型的 check-then-act 竞态。修法有几种,按代价从低到高:把判断写进更新条件——UPDATE stock SET num = num - 1 WHERE id = ? AND num >= 1,看影响行数是否为 0 来判断失败;用悲观锁 SELECT ... FOR UPDATE 把行锁住,但要注意事务边界、命中索引,否则会退化成锁表或大量间隙锁;用乐观锁版本号加失败重试;高并发场景用 Redis 预扣库存再异步落库,并把「库存不为负」的约束在数据库里也留一道(条件更新或 CHECK 约束),因为缓存层的扣减同样可能出错。

客服 Agent 同时取政策和订单:两条链路,一套编排。 政策是非结构化知识,走检索(切片、向量/关键词混合召回、带引用);订单是实时结构化数据,走业务 API 工具(参数校验、鉴权、结构化返回)。设计上把两者都注册成工具交给模型编排,并明确它们的依赖关系:像「我这个订单能不能退」这种问题必须先拿订单(状态、下单时间、商品类目),再用订单字段去检索政策(把订单状态拼进检索 query,或按类目做元数据过滤),最后把两条证据一起放进上下文,回答里给出政策条款出处,并对不确定情况转人工。还要加两道硬约束:一是身份不能由模型自由填——用户 ID 必须由服务端从会话注入订单工具,否则模型传任意 ID 就是越权查他人订单;二是写操作(改地址、提交退款)与只读查询分开处理,写操作要二次确认并留审计。

工具参数 JSON 合法就能执行吗?不能。 JSON 能被解析只说明语法正确,离「可以执行」还差几层校验:先按 schema 校验类型、必填字段与枚举取值;再做业务校验(订单号是否存在、是否属于当前用户、日期与金额是否在允许范围);然后做权限与身份校验,用户的身份只能来自服务端上下文;有副作用的工具还要走确认或审批;同一次调用的重试要按 tool_call id 去重,保证幂等。工程上更稳的做法是「先 dry-run 再执行」——让工具先返回将要执行的动作让上层确认,再真正提交;并且以工具真实返回结果为准来构造观测,而不是相信模型自己宣称的调用成功。

工具涨到 200 个怎么优化。 全量把工具描述塞进上下文既贵又会让模型选错。可行的方向有几条:分组与命名空间(政策、订单、售后各成一组,先选组再选工具),检索式工具选择(用 query 向量从工具库里召回最相关的若干个工具描述再交给模型),合并与精简(把五个相似的查询接口合成一个带过滤参数的工具,统一命名与描述规范,返回结果做裁剪),分层 Agent(一个路由 Agent 只负责分发到带小工具集的领域子 Agent),以及缓存与并行调用。描述层面的改进往往最便宜:说清什么时候该用、什么时候不该用、参数示例、失败返回什么。无论怎么优化,评测里都要专门有一项「工具选择准确率」,否则改完只看到总体分数、不知道是选错工具还是答错话。

Agent 回答质量怎么评测。 先建评测集:从真实日志里采样问题,标注期望答案、期望调用的工具与关键参数、以及评分标准,离线跑回归。指标分层看才有诊断价值——任务完成率与答案正确性(可用模型打分加人工抽检校准)、忠实度与幻觉率(回答是否被检索到的证据支撑、有没有编造条款)、工具调用正确率(选对工具、参数正确、无多余调用)、以及成本与延迟(token 消耗、步数、P95 延迟)。线上再看行为信号:点赞与点踩、答案被采纳率、追问率、转人工率、会话解决率,并用灰度或 A/B 与人工评测对齐。因为 Agent 是概率性的,评测必须固定温度与种子、跑多轮取分布,不能只看一次结果;改一次提示词或换一个模型就重跑一遍,看的是通过率和成本的联合变化。