01阿里云一面

根据口述整理,补充了面试中适合展开的回答口径。

面试问题#

  1. 发件箱模式 Outbox
  2. 复合游标分页
  3. JWT 健全自愈机制:Redis 故障降级 MySQL
  4. GMP 中 G 阻塞在系统调用时,M 和 P 会怎么样
  5. Go GC 的 STW 发生在哪些阶段

参考答案(AI 生成)#

以下答案由 AI 生成,仅供面试复盘参考。

项目#

发件箱模式 Outbox#

可直接说: 发件箱模式解决的是“业务数据落库成功”和“消息最终发出去”之间的一致性问题。我把业务表和 outbox_messages 表放在同一个本地事务里提交,事务提交成功后,由 worker 异步扫描 outbox 表,把消息投递到 MQ 或下游服务。

核心表可以这样设计:

CREATE TABLE outbox_messages (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  message_id VARCHAR(64) NOT NULL,
  aggregate_type VARCHAR(64) NOT NULL,
  aggregate_id VARCHAR(64) NOT NULL,
  event_type VARCHAR(64) NOT NULL,
  payload JSON NOT NULL,
  status VARCHAR(16) NOT NULL,
  retry_count INT NOT NULL DEFAULT 0,
  next_retry_at DATETIME NOT NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  UNIQUE KEY uk_message_id (message_id),
  KEY idx_status_next_id (status, next_retry_at, id)
);

事务边界这样划分:

  1. 接口层开启 MySQL 事务。
  2. 在同一个事务里写业务表,比如订单、通知、审核记录。
  3. 在同一个事务里写 outbox_messages,初始状态为 pending。
  4. 提交事务后返回业务结果。
  5. worker 在事务外投递消息,投递成功后把状态改为 sent,失败后更新 retry_count 和 next_retry_at。

这样设计的重点是:MySQL 本地事务只覆盖业务数据和 outbox 记录,消息投递由 worker 保证最终完成。数据库提交成功时,消息记录一定存在;worker 异常退出时,下一轮扫描还能继续处理。

worker 轮询策略:

  • 正常轮询频率:1s 一次,适合中小流量项目;空轮询时指数退避到 2s、5s,降低空转。
  • 批量大小:每次取 50~200 条,根据 MQ 吞吐和数据库压力调整。
  • 并发领取:MySQL 8 可用 SELECT ... FOR UPDATE SKIP LOCKED,多个 worker 能并发抢任务。
  • 重试节奏:失败后用指数退避,例如 1s、5s、30s、2min、10min,超过最大次数进入死信状态。
  • 观测指标:积压量、最老 pending 延迟、投递成功率、重试次数、死信数量。

去重分两层:

  • 生产侧:message_id 做唯一索引,接口重试或事务重放时复用同一个业务幂等 key。
  • 消费侧:消费者维护 processed_messages(message_id, consumer) 唯一索引,先插入处理记录,再执行业务逻辑;重复消息命中唯一约束后直接返回成功。

面试追问可以补一句:这个方案提供的是“至少一次投递 + 消费端幂等”,实际业务效果可以做到只生效一次。

复合游标分页#

可直接说: 我用的是基于排序字段和唯一主键的复合游标,例如 (created_at, id) 或 (score, id)。第一页按稳定排序取 pageSize + 1 条,下一页把最后一条的排序字段和 id 编码成 cursor,后续查询用 cursor 继续向后扫。

典型 SQL:

SELECT id, title, created_at
FROM posts
WHERE user_id = ?
  AND (
    created_at < ?
    OR (created_at = ? AND id < ?)
  )
ORDER BY created_at DESC, id DESC
LIMIT ?;

cursor 可以编码成:

{
  "created_at": "2026-04-30T10:00:00Z",
  "id": 12345
}

选择复合游标的原因:

  1. 深分页性能稳定:offset 越大,数据库需要扫描并丢弃的行越多;游标分页通过索引从上次位置继续走。
  2. 结果顺序稳定:单用时间字段会遇到同一时间多条数据,追加 id 作为唯一排序键可以稳定定位。
  3. 动态数据体验更好:列表新增、删除、更新时,游标能减少重复项和跳项。
  4. 索引匹配清晰:常见索引是 (user_id, created_at DESC, id DESC),查询条件和排序方向一致。

这里的 LIMIT 仍然保留,它负责控制页大小;被替换的是深分页里的大 OFFSET。

JWT 健全自愈机制:Redis 故障降级 MySQL#

可直接说: JWT 校验分两层:本地校验签名和过期时间,远端校验 token 版本、会话状态、黑名单。Redis 是远端校验的主路径,MySQL 是权威存储。Redis 出现故障时,鉴权链路短时间降级读 MySQL,并用本地短 TTL 缓存保护 MySQL。

数据模型可以这样设计:

  • JWT payload:user_id、session_id、token_version、exp。
  • Redis:session:{session_id}、user_token_version:{user_id}、jwt_blacklist:{jti}。
  • MySQL:user_sessions、user_token_versions、revoked_tokens。

降级判断条件:

  • Redis 命令连续超时,例如连续 3 次超过 50ms~100ms。
  • 滑动窗口内错误率超过阈值,例如 10s 内错误率超过 30%。
  • Redis 连接池等待时间持续升高,说明请求堆积。
  • 主动探测命令失败,探测用 PING 加轻量级 GET/SETEX,因为 TCP 可连只说明进程还活着。
  • 熔断器进入 open 状态,后续请求直接走降级路径;冷却一段时间后进入 half-open,用少量请求试探恢复。

Redis 进程还在但请求卡住时,应用侧以“命令耗时”和“成功率”判断。每个 Redis 命令都带 deadline,例如 50ms;超过 deadline 立即失败并计入熔断统计。后台探针持续执行真实读写命令,结合连接池等待时间、慢命令数量、P99 延迟判断服务健康。

MySQL 压力控制:

  • 本地 LRU 缓存鉴权结果,TTL 设为 30s~120s,同一 token 重复请求命中本地。
  • singleflight 合并同一 token 的并发回源请求。
  • MySQL 回源使用独立连接池和并发信号量,例如最多 50 个并发鉴权查询。
  • 只查覆盖索引字段,例如 (session_id, status, token_version, expires_at)。
  • 降级窗口限时,Redis 恢复后异步回填热点 session。
  • 对高频低优先级接口做限流,把数据库资源留给登录、支付、写操作等核心链路。

面试官问“会把 MySQL 打爆吗”,可以直接答:会有这个风险,所以降级通道一定要限流、缓存、合并请求、隔离连接池,并且降级只作为短时间兜底路径。

八股#

GMP 中 G 阻塞在系统调用时,M 和 P 会怎么样#

可直接说: G 进入阻塞系统调用后,会跟当前 M 一起阻塞;P 会从这个 M 上解绑,交给其他 M 继续执行可运行的 G。系统调用返回后,原来的 M 会尝试重新获取 P,获取成功就继续运行这个 G;获取失败时,G 会进入可运行队列,M 进入休眠或寻找其他 P。

补充点:

  • M 是内核线程,真正阻塞在系统调用里的是 M。
  • P 代表调度资源,运行时会尽量把 P 转移出去,避免其他 goroutine 被这个 syscall 拖住。
  • 网络 I/O 常走 netpoll,goroutine 挂起等待事件,M 和 P 可以继续调度其他 G。

Go GC 的 STW 发生在哪些阶段#

可直接说: Go GC 主要是并发标记清扫,STW 时间很短,常见发生在三个位置:sweep termination、mark setup、mark termination。

三个阶段:

  1. sweep termination:开始新一轮 GC 前,短暂停止所有 goroutine,确保上一轮清扫完成。
  2. mark setup:开启写屏障,进入标记阶段前需要一次短 STW。
  3. mark termination:并发标记完成后,短暂停顿,完成最终标记收尾,关闭写屏障并切换到清扫阶段。

面试里可以继续补充:耗时主要来自并发标记和用户 goroutine 的标记辅助,STW 目标是控制在很短时间内,Go 通过三色标记、混合写屏障和并发清扫降低暂停时间。

场景题:用 Go 写一个 RAG 服务#

可直接说: 我会把 RAG 服务拆成“离线索引链路”和“在线问答链路”。离线负责文档解析、切分、embedding、入库;在线负责问题改写、向量召回、重排、prompt 构造、调用大模型和流式返回。

整体模块:

  1. API 层:鉴权、限流、参数校验、SSE 流式响应。
  2. Query Orchestrator:编排一次问答流程,管理超时和降级。
  3. Embedding Client:把用户问题转成向量,带缓存和批处理。
  4. Retriever:向量检索 + 关键词检索 + 元数据过滤。
  5. Reranker:对召回文档重排,提升相关性。
  6. Prompt Builder:按 token 预算拼接上下文、引用来源和用户问题。
  7. LLM Client:调用大模型,处理超时、重试、熔断、模型切换。
  8. Ingestion Pipeline:文档解析、清洗、chunk、embedding、写向量库和元数据表。
  9. Observability:记录召回耗时、重排耗时、LLM 首 token 延迟、总耗时、错误率。

在线链路:

用户问题
 -> 参数校验/鉴权/限流
 -> query rewrite
 -> embedding
 -> 向量库召回 topK
 -> 关键词召回/BM25
 -> 合并去重
 -> rerank
 -> prompt builder
 -> LLM 流式生成
 -> 返回答案和引用来源

主要瓶颈:

  • embedding 服务:高并发下 QPS 和延迟会成为入口瓶颈。
  • 向量库:topK 检索、过滤条件、索引参数会影响召回速度。
  • reranker:交叉编码器重排效果好,CPU/GPU 成本高。
  • LLM:首 token 延迟和总生成时间通常是最大延迟来源。
  • prompt 拼接:上下文过长会增加 token 成本和模型延迟。
  • 文档更新:索引和原文需要版本号,避免召回到旧 chunk。

大模型服务故障时的稳定性方案:

  • 每次调用设置超时,例如首 token 3s~5s,总超时 30s~60s。
  • 对 5xx、超时做有限重试,带 jitter,避免瞬时放大流量。
  • 熔断大模型供应商,短时间内直接走备用模型或小模型。
  • 对热门问题缓存最终答案和引用来源。
  • 返回检索结果摘要或引用文档列表,让服务保持可用体验。
  • 限制单用户并发和全局并发,用队列或令牌桶保护后端。
  • LLM Client 单独连接池、单独熔断器,避免拖垮检索链路。

算法题:实现一个简易 DAG 执行器#

可直接说: DAG 的核心是节点、依赖关系和入度。先根据依赖计算每个节点的入度,入度为 0 的节点可以执行;节点执行完成后,把它指向的后继节点入度减 1,减到 0 就进入 ready 队列。这样能保证依赖执行完后再执行下游节点,同时天然支持同层并行。

数据结构:

type Task func(ctx context.Context) error

type Node struct {
	ID   string
	Deps []string
	Run  Task
}

type DAG struct {
	Nodes map[string]*Node
}

一个简易并发执行版本:

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
)

type Task func(ctx context.Context) error

type Node struct {
	ID   string
	Deps []string
	Run  Task
}

type DAG struct {
	Nodes map[string]*Node
}

func (d *DAG) Execute(ctx context.Context) error {
	if len(d.Nodes) == 0 {
		return nil
	}

	children := make(map[string][]string)
	indegree := make(map[string]int)
	for id := range d.Nodes {
		indegree[id] = 0
	}

	for id, node := range d.Nodes {
		for _, dep := range node.Deps {
			if _, ok := d.Nodes[dep]; !ok {
				return fmt.Errorf("missing dependency: %s -> %s", id, dep)
			}
			children[dep] = append(children[dep], id)
			indegree[id]++
		}
	}

	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	var mu sync.Mutex
	var wg sync.WaitGroup
	var firstErr error
	doneCount := 0

	var runNode func(id string)
	runNode = func(id string) {
		wg.Add(1)
		go func() {
			defer wg.Done()

			node := d.Nodes[id]
			if err := node.Run(ctx); err != nil {
				mu.Lock()
				if firstErr == nil {
					firstErr = err
					cancel()
				}
				mu.Unlock()
				return
			}

			mu.Lock()
			doneCount++
			for _, child := range children[id] {
				indegree[child]--
				if indegree[child] == 0 && firstErr == nil {
					runNode(child)
				}
			}
			mu.Unlock()
		}()
	}

	for id, deg := range indegree {
		if deg == 0 {
			runNode(id)
		}
	}

	wg.Wait()

	if firstErr != nil {
		return firstErr
	}
	if doneCount != len(d.Nodes) {
		return errors.New("cycle detected")
	}
	return nil
}

面试讲解重点:

  • 有序性靠入度控制,依赖完成后下游才会入队。
  • 并行性靠同一时刻多个入度为 0 的节点同时执行。
  • 环检测靠 doneCount != len(nodes) 判断,说明有节点始终保持正入度。
  • 失败处理用 context cancel 取消后续任务,生产环境还可以加重试、超时、最大并发和任务状态记录。
内容反馈

发现错误或想补充内容?

GoClub 是静态知识库,评论功能不再加载第三方脚本。你可以在 GitHub 仓库提交 Issue,反馈会直接进入项目协作流程。

提交 GitHub 反馈