01阿里云一面
根据口述整理,补充了面试中适合展开的回答口径。
面试问题#
- 发件箱模式 Outbox
- 复合游标分页
- JWT 健全自愈机制:Redis 故障降级 MySQL
- GMP 中 G 阻塞在系统调用时,M 和 P 会怎么样
- 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)
);事务边界这样划分:
- 接口层开启 MySQL 事务。
- 在同一个事务里写业务表,比如订单、通知、审核记录。
- 在同一个事务里写
outbox_messages,初始状态为pending。 - 提交事务后返回业务结果。
- 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
}选择复合游标的原因:
- 深分页性能稳定:
offset越大,数据库需要扫描并丢弃的行越多;游标分页通过索引从上次位置继续走。 - 结果顺序稳定:单用时间字段会遇到同一时间多条数据,追加
id作为唯一排序键可以稳定定位。 - 动态数据体验更好:列表新增、删除、更新时,游标能减少重复项和跳项。
- 索引匹配清晰:常见索引是
(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。
三个阶段:
sweep termination:开始新一轮 GC 前,短暂停止所有 goroutine,确保上一轮清扫完成。mark setup:开启写屏障,进入标记阶段前需要一次短 STW。mark termination:并发标记完成后,短暂停顿,完成最终标记收尾,关闭写屏障并切换到清扫阶段。
面试里可以继续补充:耗时主要来自并发标记和用户 goroutine 的标记辅助,STW 目标是控制在很短时间内,Go 通过三色标记、混合写屏障和并发清扫降低暂停时间。
场景题:用 Go 写一个 RAG 服务#
可直接说: 我会把 RAG 服务拆成“离线索引链路”和“在线问答链路”。离线负责文档解析、切分、embedding、入库;在线负责问题改写、向量召回、重排、prompt 构造、调用大模型和流式返回。
整体模块:
- API 层:鉴权、限流、参数校验、SSE 流式响应。
- Query Orchestrator:编排一次问答流程,管理超时和降级。
- Embedding Client:把用户问题转成向量,带缓存和批处理。
- Retriever:向量检索 + 关键词检索 + 元数据过滤。
- Reranker:对召回文档重排,提升相关性。
- Prompt Builder:按 token 预算拼接上下文、引用来源和用户问题。
- LLM Client:调用大模型,处理超时、重试、熔断、模型切换。
- Ingestion Pipeline:文档解析、清洗、chunk、embedding、写向量库和元数据表。
- 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取消后续任务,生产环境还可以加重试、超时、最大并发和任务状态记录。