Files
cs-note/hzh/TEST/MicroService.md
T
2026-05-24 11:42:38 +08:00

46 KiB
Raw Blame History

tags, create time, update time, status
tags create time update time status
后端
微服务
测试
自我考察
2026-05-06 12:00 2026-05-06 reviewed

微服务架构自测题

概述

本文档是一份微服务架构知识的自测题库,覆盖从基础概念到部署运维的全链路知识点。包含选择题、填空题、代码补全和主观题四种题型,附带详细参考答案与解析,适合用于自我评估或团队内部考核。

正文

使用说明

本试卷覆盖 基础概念 → 服务治理 → 数据一致性 → 可观测性 → 部署运维 全链路知识点。共四部分:选择题、填空题、代码补全、主观题。

建议用时: 60 分钟 | 及格线: 70 / 100

做完后对照下方「参考答案」自评,错的地方回到对应笔记重新理解。


一、选择题(每题 4.5 分,共 45 分)

每题只有一个正确答案。

Q1.【微服务的拆分原则】

关于 DDD(领域驱动设计)中"限界上下文"的微服务拆分原则,以下说法正确的是:

A. 应该按技术层拆分——一个服务管数据库,一个服务管业务逻辑,一个服务管 Web 接口
B. 每个微服务应围绕一个业务能力边界独立工作,各自拥有独立的数据库
C. 为了减少跨服务调用,多个相关表可以放在同一个微服务共享数据库中
D. 单体应用越大越好,因为可以避免分布式系统的复杂度

[!tip]- Q1 答案 B — 每个微服务应围绕业务能力边界独立工作,且拥有独立数据库

解析: DDD 的核心理念是通过统一语言定义业务边界,将系统拆分为相互协作的限界上下文(Bounded Context)。核心原则:

  • 独立数据库:每个服务独占数据存储,禁止跨服务直接访问 DB
  • 能力边界:按业务域而非技术层拆分——"订单管理"是一个上下文,"用户认证"是另一个
  • 松耦合:服务间通过明确的 API 通信,内部实现互不透明

为什么其他选项错误:

  • ❌ A:按技术层拆分会导致"分布式单体"——所有功能紧密耦合,修改一处牵动全局
  • ❌ C:共享数据库违反了微服务的自治原则,是典型的反模式
  • ❌ D:微服务解决了单体的可扩展性和团队并行开发问题,但会引入分布式复杂度,需要权衡
graph LR
    BAD["❌ 反模式:共享数据库"] --> S1["Order Service"]
    BAD --> S2["Payment Service"]
    S1 --> SHARED[(共享 DB)]
    S2 --> SHARED

    GOOD["✅ 正模式:独立数据库"] --> O1[Order Service]
    GOOD --> O2[Payment Service]
    O1 --> DB1[(Order DB)]
    O2 --> DB2[(Payment DB)]

💡 记忆要点: "一个服务 = 一个业务域 + 一个数据库 + 一套团队",三者不可分割。


Q2.【服务发现的注册方式】

客户端发现模式 vs 服务端发现模式中,以下对比最准确的是:

A. 客户端发现需要额外部署负载均衡器组件,服务端发现不需要
B. 客户端发现将服务列表缓存到客户端,服务端发现由中间代理转发请求
C. 两者没有本质区别,只是实现上的差异
D. 服务端发现性能更高,因为减少了网络跳数

[!tip]- Q2 答案 B — 客户端自行查注册中心 + 缓存;服务端由代理中转

解析:

维度 客户端发现 服务端发现
工作方式 客户端查注册中心获取实例列表,自己选实例 客户端请求代理/网关,代理查注册中心并转发
代表工具 Consul + Serf、Nacos 客户端、Eureka Kubernetes Service、Envoy、Nginx
部署成本 客户端库内置,无需额外组件 需部署负载均衡层(Sidecar 或独立 LB)
灵活性 客户端自定义策略(加权、地域等) 对应用透明,开发简单但控制力弱
性能 更直接,少一跳 多一层代理转发

逐项分析:

  • ❌ A:说反了——客户端发现不需要额外 LB 组件,服务端才需要
  • ✅ B:完全正确描述了两种模式的核心区别
  • ❌ C:有本质区别,涉及架构决策和部署拓扑变化
  • ❌ D:相反,服务端发现多了一层代理跳数,理论上延迟更高
graph LR
    Client["Client App"] -.查注册中心.-> RC[Registry]
    Client -->|自选实例| Inst1[Instance A]
    Client -->|自选实例| Inst2[Instance B]
    style Client fill:#cfe2f3

vs

graph LR
    Client["Client App"] --> Proxy["Load Balancer / Sidecar"]
    Proxy -.查注册中心.-> RC[Registry]
    Proxy --> Inst1[Instance A]
    Proxy --> Inst2[Instance B]

💡 Kubernetes 场景: K8s Service 就是典型的服务端发现模式,Service IP 作为虚拟入口,kube-proxy/IPVS 负责负载均衡。


Q3.【熔断器状态转换】

一个熔断器的配置如下:连续失败 5 次打开,恢复超时 30 秒。当熔断器从 Open 进入 Half-Open 后,放行了一个探测请求,该请求成功。熔断器接下来会怎样?

A. 继续保持 Half-Open,再观察几个请求
B. 回到 Open 状态,因为刚恢复不能轻信
C. 回到 Closed 状态,恢复正常放行全部流量
D. 关闭整个服务,防止不稳定

[!tip]- Q3 答案 C — Half-Open 探测成功则回到 Closed

解析: 熔断器的三态转换规则:

graph LR
    C["Closed"] -->|连续失败超过阈值| O["Open"]
    O -->|recoveryTimeout 到期| H["Half-Open"]
    H -->|探测成功| C
    H -->|探测失败| O
    style C fill:#e8f5e9
    style O fill:#ffebee
    style H fill:#fff3e0

半开状态的本质是一次"试探":如果下游恢复了,就大胆地回到 Closed;如果还是不行,迅速再次打开,避免反复横跳消耗资源。

💡 工程经验: 很多团队把 recoveryTimeout 设得太短(如 5s),导致下游还没恢复好就已经回 Closed,形成"熔断振荡"。通常建议至少 30s~1m。


Q4.【Outbox 模式的工作原理】

关于 Outbox 模式保证消息投递的一致性,以下说法正确的是:

A. 先写业务库再发消息,利用事务的回滚机制确保一致性
B. 业务数据和消息写入同一个本地事务的 outbox 表,然后后台轮询发送
C. 使用两阶段提交(2PC)协议同时提交业务数据和消息
D. 在 MQ 层面设置事务消息即可,不需要额外的 outbox 表

[!tip]- Q4 答案 B — 同事务写业务表和 outbox 表,后台定时任务投递

解析: Outbox 模式的核心思想:把"发MQ消息"这件事降级为"写DB一行",从而利用本地事务保证原子性。

关键步骤:

  1. 业务操作与写入 outbox 表在同一个 BEGIN...COMMIT 中完成
  2. 出事务后,后台 worker 定时扫描 outbox 表中 status='pending' 的记录
  3. 投递成功后更新 status 为 'sent';失败则标记 'failed' 等待重试
// 事务内同时写业务数据和 outbox
tx.Exec("INSERT INTO orders (...) VALUES ...")
tx.Exec("INSERT INTO outbox (topic, payload, status) VALUES ('order.created', $1, 'pending')", payload)
tx.Commit() // 要么都成功,要么都回滚

逐项分析:

  • ❌ A:先写业务再发消息时,如果发消息失败,业务已 commit,无法回滚——这就是"可靠事件发布"要解决的问题
  • ❌ C:2PC 太重了,不是 Outbox 的设计目标
  • ❌ D:RocketMQ 事务消息确实可以不用 Outbox 表,但这说的是 RocketMQ 原生方案,不是 Outbox 模式本身

💡 优化技巧: outbox 表的查询索引一定要带上 (status, created_at),否则大规模下定时任务会成为性能瓶颈。


Q5.【幂等性设计】

在消息队列消费场景中,以下哪种方案不能完全保障消费幂等性?

A. 用 msg_id 做数据库 UNIQUE 约束,INSERT ... ON CONFLICT DO NOTHING
B. Redis SET key NX EX 去重键
C. 乐观锁版本控制:UPDATE table SET count = count + N WHERE version = V
D. 消费者收到消息后 sleep 100ms 再处理

[!tip]- Q5 答案 D — sleep 不能替代真正的幂等机制

解析: 幂等的本质是:多次执行同一操作的结果相同。sleep 只是一个延迟手段,无法防止重复消费。

方案 可靠性 适用场景
UNIQUE 约束 ⭐⭐⭐⭐⭐ 最可靠,强烈推荐
Redis NX ⭐⭐⭐⭐ 高吞吐但极端情况下可能有窗口期
乐观锁版本 ⭐⭐⭐⭐ 适合金额、库存等计数类操作
sleep ❌ 无效 与幂等无关

为什么 B 也有风险? Redis 存在 SET NX 检查和实际执行之间的极小时间窗,但如果用 Redis Lua 脚本可以做到原子性,所以实践中可用。

// 正确的幂等检查顺序:
// 1. 先从 outbox 或结果表查是否已处理
// 2. 如果未处理,执行业务逻辑
// 3. 记录处理结果到 outbox/result 表

💡 核心认知: 幂等性必须依靠状态判断 + 条件写入,而不是任何规避策略(sleep、随机等待等)。


Q6.【分布式 ID — 雪花算法】

标准 Snowflake 算法中 64 位 ID 的分布为:1 位符号位 + 41 位时间戳 + 10 位 WorkerId + 12 位序列号。以下说法错误的是:

A. 41 位时间戳可以支撑约 69 年(2^{41} \text{ ms} ≈ 69.7 年)
B. 12 位序列号意味着单台机器每毫秒最多生成 4096 个 ID
C. 10 位 WorkerId 支持最大 1024 个节点
D. 时钟回拨时应该立即抛出 panic 终止服务运行

[!tip]- Q6 答案 D — 时钟回滑应有更优雅的处理策略,而不是一律 panic

解析:

位段 位数 含义
符号位 1 固定为 0,保证 ID 为正数
时间戳 41 相对当前纪元的毫秒偏移,约 69 年
WorkerId 10 机器编号,支持 1024 台
序列号 12 同毫秒内的递增序号,每毫秒最多 4096

时钟回滑处理方案:

  • 等待策略:阻塞直到时间追上来(可能影响可用性)
  • 备用 WorkerId:切换到预分配的备用 ID 继续生成
  • panic:最简单但也最粗暴,适合容忍短时的不可用
if now < sf.lastTime {
    // 推荐:等待回拨期间再继续,而不是 panic
    time.Sleep(time.Until(sf.lastTime + 1))
}

为什么 D 错误: panic 是一种极端手段。在生产环境中,短暂的时钟同步偏差(NTP 调整)不应导致整个服务崩溃。更好的做法是等待或者用日志记录告警。

💡 演进: UUID v7 就是为了解决 Snowflake 的 WorkerId 分配问题和时钟回滑问题而设计的标准方案。


Q7.【可观测性三大支柱】

关于 Metrics、Logging、Tracing 三种可观测性能力的区别,以下哪项描述最准确?

A. Metrics 回答"发生了什么",Logging 回答"出事了没",Tracing 回答"在哪一步出的事"
B. Metrics 回答"系统现在健康吗",Logging 回答"具体发生了什么",Tracing 回答"请求在哪步慢了/失败了"
C. 三者都是记录历史数据的,用途基本一样
D. Tracing 只能用于排查慢请求,不能用于监控系统健康度

[!tip]- Q7 答案 B — 三个支柱各司其职,解决不同维度的问题

解析:

支柱 核心问题 数据类型 典型工具
Metrics "现在健康吗?" 数值趋势(Counter/Gauge/Histogram) Prometheus + Grafana
Logging "具体发生了什么?" 文本/结构化记录 ELK / Loki
Tracing "在哪一步出了问题?" 时序链路图(span tree) Jaeger / Tempo

类比理解:

  • Metrics 像仪表盘📊:告诉你的速度、油耗、水温是否正常
  • Logging 像行车记录仪📹:回放事故前后的详细情况
  • Tracing 像GPS 路线图🗺️:显示经过哪些路口、在每个地方花了多久
graph TD
    Problem["用户报告页面加载慢"]
    Problem --> M{"看 Metrics"}
    M -->|P99 延迟飙升| T["追踪 Tracing 定位到哪个 Span 慢"]
    T --> L["查看该 Span 对应的 Logs 找根因"]
    style M fill:#e8f5e9
    style T fill:#fff3e0
    style L fill:#fce4ec

💡 RED 方法: 对服务级监控关注 Rate(请求率)、Error(错误率)、Duration(持续时间);对基础设施关注 Utilization(利用率)、Saturation(饱和度)、Errors(错误)。


Q8.【K8s Probe 类型】

在 Kubernetes 中,Liveness Probe 和 Readiness Probe 的行为差异是:

A. Liveness 挂了重启 Pod,Readiness 挂了暂停接收流量但不重启
B. Liveness 暂停接收流量,Readiness 挂了重启 Pod
C. 两者作用完全相同,都是健康检查
D. Liveness 只检查 HTTP 状态码,Readiness 只检查 TCP 连接

[!tip]- Q8 答案 A — Liveness = 进程生死判断;Readiness = 流量开关

解析:

探针类型 判断什么 失败行为 典型用途
Liveness "进程还活着吗?" 重启容器(kubectl exec / Docker stop) 死锁、goroutine leak 等不可恢复故障
Readiness "能接收流量了吗?" 从 Endpoint 中移除,不再转发流量 启动中、依赖 DB 不可用、预热未完成
Startup "应用开始响应了吗?" 根据配置可以选择不复活或直接杀掉 冷启动极慢的应用

关键点: 很多时候Readiness 比 Liveness 更重要。比如数据库连接池耗尽时,进程是"活着"的,但不应该再接收流量——此时 Liveness probe 通过(不会重启),Readiness probe 失败(停止接流量)。

# K8s manifest 示例
livenessProbe:
  httpGet:
    path: /healthz
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5

readinessProbe:
  httpGet:
    path: /ready
    port: 8080
  initialDelaySeconds: 5
  periodSeconds: 3

💡 常见陷阱: Liveness probe 太激进(initialDelaySeconds 太小)会在启动过程中误杀正在初始化的容器。


Q9.【Saga 补偿设计】

在使用 Saga 模式处理"下单 → 扣库存 → 扣款"流程时,如果"扣款"步骤失败,正确的补偿操作链是:

A. 退款 → 释放库存 → 取消订单
B. 取消订单 → 释放库存 → 退款
C. 只需退款,其他步骤自动回滚
D. 重新尝试"扣款",无限重试直到成功

[!tip]- Q9 答案 A — 按正向操作的逆序依次执行补偿

解析: Saga 的补偿遵循逆序原则(Reverse Order Compensation):

正向链路:CreateOrder → ReserveStock → ChargePayment
          ↓              ↓               ↓
逆向补偿:CancelOrder ← ReleaseStock ← RefundPayment
                   (扣款失败触发补偿链,从右往左走)

为什么必须逆序? 因为后续步骤的执行依赖前置步骤的状态。比如你不能先取消订单再释放库存——订单已经没了,库存管理器不知道要释放谁的库存。

flowchart LR
    Fail["扣款失败"]
    Fail --> Refund["① 退款"]
    Refund --> Release["② 释放库存"]
    Release --> Cancel["③ 取消订单"]
    Cancel --> Done["✅ 全部补偿完成"]
    style Fail fill:#ffebee
    style Done fill:#e8f5e9

补偿操作的幂等性同样重要——CancelOrder 可能被触发多次,需要用订单状态机来保证(只有 PENDING 才能转 CANCELLED)。

💡 TCC 对比: TCC 的好处是在 Try 阶段就冻结了资源,Confirm 时才真正占用,避免了中间态的数据可见性问题。代价是每个业务方法都要实现三个接口。


Q10.【CI/CD 中的 GitOps 流程】

GitOps 与传统 CI/CD Pipeline 的核心区别在于:

A. GitOps 不使用自动化流水线,全靠人工操作
B. GitOps 以 Git 仓库为唯一事实源,控制器持续将集群状态同步到期望状态
C. GitOps 只能用 GitHub Actions,不能用 Jenkins
D. GitOps 不需要容器镜像,直接在 Git 里存储二进制文件

[!tip]- Q10 答案 B — Git 仓库即唯一真相来源,控制器持续 reconcile

解析:

graph LR
    Dev["开发者提交 Manifest 到 Git"] --> Controller["ArgoCD / Flux Controller"]
    Controller -->|Pull 并 diff| Cluster["K8s Cluster"]
    Controller -->|Apply 变更| Cluster
    Cluster -->|实时 sync 状态| Controller
    style Dev fill:#cfe2f3
    style Controller fill:#fff3e0
    style Cluster fill:#e8f5e9

传统 CI/CD: 推送构建产物 → 部署脚本报名 → 直接 push 到集群

GitOps: 改 Manifest → PR/Merge → 控制器自动检测到变更 → 自动 apply

维度 传统 CI/CD GitOps
事实源 部署日志 + CI 产物 Git 仓库中的 Manifest
驱动方向 Push(主动推送到集群) Pull(控制器拉取并同步)
漂移检测 无或手动比对 自动实时检测
回滚方式 重新部署旧版本 Git revert + merge

💡 核心理念: "声明式基础设施"——你只需要描述想要的最终状态(YAML),剩下的交给控制器去 reconcile。


二、填空题(每题 3 分,共 24 分)

根据知识填写空缺的概念或代码。

Q11.【API Gateway 职责分类】

以下操作中,哪些适合放在 API Gateway(G),哪些不应该(O,下沉到业务服务)?

操作 位置
HTTPS 终结 (空1)
创建订单(业务逻辑) (空2)
JWT Token 校验 (空3)
邮件发送通知 (空4)

[!tip]- Q11 答案 G ; O ; G ; O

解析:

操作 位置 理由
HTTPS 终结 G 网关的统一入口职责,TLS 卸载
创建订单 O 业务逻辑应下沉,网关保持瘦
JWT Token 校验 G 统一的鉴权前置条件
邮件发送通知 O 异步非关键路径,应在业务服务内处理
graph LR
    Client --> GW["API Gateway<br/>HTTPS/JWT/限流/路由"]
    GW --> Svc["业务服务<br/>订单/支付/邮件..."]
    style GW fill:#cfe2f3
    style Svc fill:#e8f5e9

💡 黄金法则: 网关是 traffic cop,不是 warehouse manager。任何需要"知道业务状态"的操作都不该放在网关。


Q12.【指数退避计算】

某系统配置重试 3 次,基础等待时间为 1s,Jitter 范围为 ±100ms。第 2 次重试的理论等待时间范围是 _____ s 到 _____ s。

[!tip]- Q12 答案 1.9 ; 2.1(或 2.0±0.1)

解析:

指数退避公式:wait = base × 2^(attempt-1) + jitter

重试次数 基础等待 Jitter 总等待
第 1 次 1 × 2^0 = 1s ±100ms 0.9 ~ 1.1s
第 2 次 1 × 2^1 = 2s ±100ms 1.9 ~ 2.1s
第 3 次 1 × 2^2 = 4s ±100ms 3.9 ~ 4.1s

💡 Jitter 的意义: 如果没有 jitter,所有客户端同时重试会产生"retry storm"——像多米诺骨牌一样放大下游压力。


Q13.【Prometheus 指标类型匹配】

请将 Prometheus 的四种基础指标类型与其用途连线:

选项 指标类型 用途描述
A Counter 单调递增,只能增加或重置为零(如总请求数)
B Summary 采样+分桶,客户端计算百分位(SDK 内部聚合)
C Histogram 采样+分桶,服务端用 PromQL 计算百分位(如请求耗时分布)
D Gauge 可上可下,表示瞬时值(如内存使用量、在线人数)

[!tip]- Q13 答案 Counter = A(单调递增);Gauge = D(可上可下);Histogram = C(分布统计);Summary = B(客户端计算分位数)

解析:

类型 特征 示例
Counter 只能增加(或重置为零) 总请求数、错误计数
Gauge 可上可下,表示瞬时值 当前在线人数、内存使用量
Histogram 采样+分桶,服务端算百分位 请求耗时分布
Summary 采样+分桶,客户端算百分位 SDK 内部聚合的分位数
// Go 中 prometheus 库的使用
requestsTotal := promauto.NewCounterVec(
    prom.CounterOpts{Name: "http_requests_total"},
    []string{"method", "status"},
)
// CounterVec 带 label,可按 method/status 维度统计

💡 Histogram vs Summary: 大多数场景用 Histogram 就够了,因为它可以在服务端通过 PromQL 灵活合并多个实例的数据。Summary 的 pre-computed quantiles 无法在服务端聚合。


Q14.【gRPC 方法类型】

gRPC 支持四种 RPC 方法类型,请写出另外三种的名称:

[!tip]- Q14 答案 Unary(普通 RPC);Server Stream(服务端流式);Client Stream(客户端流式);Bidirectional Stream(双向流式)

解析:

// Proto3 定义的四种 RPC 模式
service WeatherService {
    // 1. Unary —— 单次请求一次响应
    rpc GetWeather(GetWeatherRequest) returns (WeatherResponse);

    // 2. Server Stream —— 一次请求,服务端返回数据流
    rpc ListForecasts(ForecastRequest) returns (stream ForecastResponse);

    // 3. Client Stream —— 客户端发数据流,服务端最后返回一次响应
    rpc AggregateTemperatures(stream TemperatureReading) returns (TemperatureReport);

    // 4. Bidirectional Stream —— 双方都在发数据流
    rpc Chat(stream ChatMessage) returns (stream ChatMessage);
}
类型 请求 响应 应用场景
Unary 1 → 1 1 → 1 最常见,CRUD
Server Stream 1 → N 1 → ∞ 实时订阅、列表推送
Client Stream ∞ → 1 N → 1 大数据上传、批量读取
Bidirectional ∞ → ∞ ∞ → ∞ WebSocket 替代、游戏服务器

💡 gRPC Streaming 优势: 基于 HTTP/2 多路复用,可以同时维持多个独立的流,不需要建立多个 TCP 连接。


Q15.【容器化最佳实践】

Dockerfile 最佳实践中,以下三个做法分别对应了什么目的?

# (1) FROM golang:1.23-alpine AS builder
# (2) COPY --from=builder /app/server .
# (3) USER nobody

(空1) → 减小镜像体积 (空2) → 安全隔离 (空3) → 多阶段构建

[!tip]- Q15 答案 (3) / USER nobody → 减小镜像体积;(2) / COPY --from=builder → 安全隔离;(1) / FROM ... AS builder → 多阶段构建

解析:

多阶段构建的目的: 编译环境(包含 go toolchain、依赖安装器等)和运行环境(仅运行时二进制)分离,最终镜像不包含编译工具。

USER 的目的: 不以 root 身份运行容器进程。即使镜像有漏洞,攻击者也无法获得 root 权限。

# 完整的多阶段构建示例
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server .

FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /app/server .
USER nobody
ENTRYPOINT ["./server"]

💡 distroless: 进一步用 Google distroless 镜像替代 alpine,镜像更小且不包含 shell,安全性更高。


Q16.【W3C Trace Context 传播】

HTTP 请求头 Traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01 中,各字段的含义分别是:

_version_ - _trace_id (16 bytes hex)_ - _span_id (16 bytes hex)_ - _flags_

其中 01(十六进制)对应的标志位含义是 _________。

[!tip]- Q16 答案 1(表示 Trace 被采样/记录,flag=00 表示不采样)

解析: W3C Trace Context 标准的请求头格式:

Traceparent: version-trace_id-span_id-trace_flags
             │        │         │       └── 1字节标志位
             │        │         └────────── 16字节 Span ID
             │        └──────────────────── 32字节 Trace ID
             └───────────────────────────── 版本号(目前固定为 00)
  • trace_id (32 hex chars = 16 bytes):标识一次完整的请求链路,同一链路的所有 span 共享同一个 trace_id
  • span_id (16 hex chars = 8 bytes):标识链路中的一个单独操作,每次 RPC 调用都会产生新的 span_id
  • trace_flags (1 byte):01 = 已采样( sampled),00 = 未采样
// Go 中通过 Context 传播 trace context
ctx, _ := trace.StartSpan(ctx, "UserService.GetUserInfo",
    trace.WithSpanKind(trace.SpanKindServer),
)
defer span.End()

💡 OpenTelemetry 的优势: OTel 统一了 tracer provider、meter provider、logger provider 的 SDK 规范,支持 auto-instrumentation,一个 agent 搞定所有信号采集。


Q17.【蓝绿 vs 金丝雀发布】

蓝绿发布(Blue-Green)和金丝雀发布(Canary)的核心区别是:蓝绿发布切换流量时使用 ___________,金丝雀发布时使用 ___________。

[!tip]- Q17 答案 完全切换(全部流量从蓝切到绿) ; 按权重/比例逐步切换(如 5% → 20% → 50% → 100%)

解析:

维度 蓝绿发布 金丝雀发布
流量切换 一次性 100% 切换 渐进式(灰度放量)
资源占用 需要两套完整环境 只需增量一份新实例
风险 切换后发现严重 bug,所有用户受影响 小流量先行验证,发现问题影响面小
回滚速度 瞬间(切回另一套环境即可) 快速(降权新版本即可)
典型工具 K8s Deployment 切换 Service selector Istio VirtualService weight、Nginx 权重
# Istio VirtualService 金丝雀示例
route:
  - destination:
      host: app-v1
      weight: 90          # 90% 流量走 v1
  - destination:
      host: app-v2
      weight: 10          # 10% 流量走 v2(灰度)

💡 选择策略: 小型改动用蓝绿,敏感业务上线用金丝雀。


Q18.【分布式事务选择策略】

根据业务需求选择合适的分布式事务方案:

场景 推荐方案
创建订单后发送通知,允许短暂不一致 (空1)
资金转账,需要强一致性保障 (空2)
不想改造现有业务代码,想自动管理分布式事务 (空3)

[!tip]- Q18 答案 本地事务 + MQ(Outbox 模式) ; TCC ; AT 模式(Seata)

解析:

三种方案的权衡矩阵:

方案 一致性 性能 侵入性 一句话总结
Outbox + MQ 最终一致 高 低 默认首选,覆盖 80% 场景
TCC 准强一致 中 高 每个业务方法写 Three 个接口
AT 模式 伪强一致 中 极低 Seata 自动生成 undo_log
flowchart TD
    Req["你需要分布式事务吗?"]
    Req -->|"否:最终一致即可"| Outbox["Outbox + MQ ✅ 默认选这个"]
    Req -->|"是:强一致性要求"| Choice{"能否改造业务代码?"}
    Choice -->|"能"| TCC["TCC ✅ 一致性最强"]
    Choice -->|"不能 | 不能接受高侵入"| AT["AT 模式 ✅ 零代码改动"]
    style Outbox fill:#e8f5e9
    style TCC fill:#fff3e0
    style AT fill:#fce4ec

💡 经验法则: 能用异步事件解决的就不用 Saga/TCC/AT——它们都有各自的复杂度和 trade-off。


三、代码补全(每题 6 分,共 30 分)

补充代码中空缺的部分。有些题目有多个空。

Q19.【熔断器封装】

func callWithCircuitBreaker(cb *breaker.CircuitBreaker, timeout time.Duration, fn func() error) error {
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()

    errChan := make(chan error, 1)
    go func() {
        errChan <- fn()  // 在 goroutine 中执行远程调用
    }()

    select {
    case err := <-errChan:
        return err
    case <-ctx.Done():
        return _________  // 空1:返回超时报错
    }
}

// 使用熔断器包裹调用
result := cb.Execute(func() (any, error) {
    return callWithCircuitBreaker(cb, 3*time.Second, func() error {
        return client.CallService(context.Background())
    })
})
if result.Err != nil {
    log.Warn("服务调用失败,已短路", result.Err)
    return fallbackResponse()
}

[!tip]- Q19 答案 fmt.Errorf("call timed out: %w", ctx.Err())

解析:

超时处理的核心模式:

goroutine 执行调用
    ├── 正常完成 → errChan 返回结果
    └── 超时 → ctx.Done() 先触发 → 返回错误
空 填空 作用
空1 fmt.Errorf("call timed out: %w", ctx.Err()) 保留原始 context 错误语义,支持 errors.Is 判断

关键点:

  • context.DeadlineExceeded 是最常见的超时报错,可以用 errors.Is(err, context.DeadlineExceeded) 精确判断
  • 配合熔断器使用时,超时也应该计入熔断器统计——超时也是一种"失败"
if errors.Is(result.Err, context.DeadlineExceeded) {
    // 超时 → 可能是下游负载过高 → 熔断器会增加失败计数
}

💡 超时传递: 子调用的超时时间必须是父调用剩余时间的子集。如果网关给订单服务分配了 200ms,订单服务调用库存服务就不能也给 200ms——至少要留出 50~100ms 余量给上游。


Q20.【服务发现客户端实现】

type DiscoveryClient struct {
    registry map[string][]string // service -> instances
    mu       sync.RWMutex
}

func (dc *DiscoveryClient) Refresh() error {
    // 从注册中心拉取最新的实例列表
    instances, err := fetchFromRegistry()
    if err != nil {
        return err
    }

    dc.mu.Lock()
    defer dc.mu.Unlock()

    newMap := make(map[string][]string)
    for _, inst := range instances {
        newMap[inst.ServiceName] = append(newMap[inst.ServiceName], inst.Addr)
    }
    dc.registry = newMap  // 替换为新列表
    return nil
}

// GetInstances 获取指定服务的可用实例列表
func (dc *DiscoveryClient) GetInstances(service string) ([]string, error) {
    dc.mu._________  // 空1:使用读锁保护
    instances, ok := dc.registry[service]
    if !ok {
        return nil, fmt.Errorf("service %s not found", _________)  // 空2
    }
    return instances, nil
}

[!tip]- Q20 答案 RLock() ; service

解析:

读写锁的正确使用场景:

  • Lock()(写锁):Refresh 时替换整个 registry map——并发频率低
  • RLock()(读锁):GetInstances 时读取实例列表——并发频率高,多个请求同时查找不影响

为什么不用互斥锁? 服务发现的读远多于写。如果用 Mutex,每次 GetInstances 都会串行化,在高并发下成为瓶颈。RWMutex 允许多个读者同时执行,大幅提升吞吐量。

操作 频率 锁类型
Refresh(从注册中心拉取) 低频(几十秒一次) Write Lock
GetInstances(路由选择) 高频(每次 RPC 调用) Read Lock
// RWMutex 特性
reader1 ---||------- Reader A (并行)
reader2 ---||------- Reader B (并行)
writer   --|X|------- 排他,读写互斥

💡 生产实践: Nacos/Consul 客户端内部都会维护一个 instance cache,定时刷新(watch/polling),GetInstances 只读缓存——避免每次调用都直接查注册中心造成雪崩。


Q21.【分布式追踪 Span 埋点】

import "go.opentelemetry.io/otel/trace"

func (s *OrderService) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.Order, error) {
    // 空1:开启一个新的 Span
    ctx, span := _________

    defer span.End()
    span.SetAttributes(attribute.String("user.id", req.UserId))
    span.SetAttributes(attribute.Int64("order.total", req.TotalAmount))

    // 调用库存服务
    // 空2:注入 span context 到 gRPC metadata 以便下游继承
    md := metadata.Pairs(trace.HeaderKey, _________)
    ctx = metadata.NewOutgoingContext(ctx, md)

    stockResp, err := s.stockClient.ReserveStock(ctx, &pb.ReserveRequest{...})
    // ...
    return order, nil
}

[!tip]- Q21 答案 tracer.Start(ctx, "OrderService.CreateOrder") ; span.TraceContext()

解析:

OTel Span 的标准操作流程:

// 1. 初始化 tracer(通常在服务启动时做一次)
var tracer = otel.Tracer("my-service/order")

// 2. 每个 handler/rpc 开始时创建 span
ctx, span := tracer.Start(ctx, "OpName")
defer span.End()  // 确保 span 结束

// 3. 记录属性和事件
span.SetAttributes(attribute.String("key", "value"))
span.AddEvent("step completed", trace.WithAttributes(...))

// 4. 传播 context 到下游
// HTTP: otelhttp 会自动注入 Traceparent header
// gRPC: 需要手动注入 metadata
空 填空 说明
空1 tracer.Start(ctx, "OrderService.CreateOrder") 创建 Span 并返回增强后的 context
空2 span.TraceContext() 提取 Trace Context 信息用于注入到下游请求
// 更简洁的 gRPC 注入方式(推荐)
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc"
// 使用 grpcbinjector 自动注入,无需手动处理 metadata

💡 采样策略: 不是所有请求都需要记录完整的 trace。常用的采样策略有 AlwaysOn(开发)、AlwaysOff(生产全关)、ProbabilityBased(按比例)和 Adaptive(动态调整)。生产环境建议用概率采样,避免 trace 数据爆炸。


Q22.【K8s Deployment 资源配置】

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: order-service
        image: myrepo/order:v1.2.3
        ports:
        - containerPort: 8080
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: ___   # 空1:给应用足够启动时间
          periodSeconds: ___          # 空2:健康检查间隔不宜过短
        readinessProbe:
          httpGet:
            path: /ready
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 3

[!tip]- Q22 答案 10~30(或合理大整数) ; 5~10

解析:

Resource Request vs Limit:

参数 含义 K8s 调度影响 OOM 影响
requests 保底资源,用于调度决策 调度器只在节点有 ≥ request 的资源时才安排 Pod CPU 不会被限制(但可能竞争),内存超过不会 OOM Kill
limits 硬上限 不参与调度计算 CPU 超限被 throttling,内存超限被 OOM Kill

Probes 参数建议:

参数 推荐值 原因
initialDelaySeconds (liveness) 10~30 让应用在检测前有时间完成初始化
periodSeconds (liveness) 5~10 太频繁浪费资源,太长延迟发现故障
failureThreshold 3 连续失败 3 次才判定为死亡
// Go 应用中实现 /ready 端点
var (
    dbConnected  bool
    cacheConnected bool
)

func readyHandler(w http.ResponseWriter, r *http.Request) {
    if !dbConnected || !cacheConnected {
        http.Error(w, "not ready", http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
}

💡 HPA 联动: Resource requests/limits 是 HPA(Horizontal Pod Autoscaler)基于 CPU/Memory 自动扩缩容的基础——没有 resource metrics,基于资源的 HPA 无法工作。


Q23.【ConfigMap 动态刷新】

package config

import (
    "sync"
)

type Config struct {
    mu          sync.RWMutex
    MaxRetries  int
    Timeout     time.Duration
    FeatureFlag bool
}

var globalCfg = &Config{}

func Load(cfg *Config) {
    globalCfg.mu.Lock()
    defer globalCfg.mu.Unlock()
    globalCfg.MaxRetries = cfg.MaxRetries
    globalCfg.Timeout = cfg.Timeout
    globalCfg.FeatureFlag = cfg.FeatureFlag
}

// GetMaxRetries 线程安全地读取配置
func GetMaxRetries() int {
    globalCfg.mu._______   // 空1:使用读锁
    defer globalCfg.mu._______  // 空2
    return globalCfg.MaxRetries
}

// 定期从配置中心(如 Nacos Config)拉取最新配置
func WatchConfigChange(ctx context.Context, interval time.Duration) {
    ticker := time.NewTicker(interval)
    defer ticker.Stop()
    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            remoteCfg, _ := FetchFromConfigCenter()
            Load(remoteCfg)  // 热更新配置
        }
    }
}

[!tip]- Q23 答案 RLock() ; RUnlock()

解析:

配置热更新的关键点:

  1. 原子更新:通过整块赋值(Load 函数)保证读到的是完整一致的配置快照
  2. 读写锁:读取用 RLock(可并发),写入用 Lock(排他)
  3. 防惊群:配置中心通常提供 watch/watch 长轮询机制,避免频繁 polling
// 进阶:使用 RWMutex + 指针交换实现零锁读取
type Config struct {
    data atomic.Value  // 存储 *internalConfig
}

func (c *Config) Reload(newCfg *internalConfig) {
    c.data.Store(newCfg)  // 原子替换指针,O(1) 更新
}

func (c *Config) GetMaxRetries() int {
    cfg := c.data.Load().(*internalConfig)  // 无锁读取
    return cfg.MaxRetries
}
空 填空 作用
空1 RLock() 多个 GetMaxRetries 调用可以并行执行
空2 RUnlock() 配对释放读锁

💡 Apollo 实现原理: Apollo 的客户端会在本地保存一份配置文件缓存,长轮询检测到变更后更新本地缓存,应用程序通过监听回调或主动读取来获取最新配置。核心也是"缓存 + 异步更新"。


四、主观题(每题 4 分,共 16 分)

简要回答即可,不需要长篇大论。关键是说出你的理解。

Q24.【为什么微服务需要独立数据库?】

如果两个微服务共享一个数据库,会带来哪些问题?请至少列举三点,并说明为什么这是"分布式单体"的反模式。

[!tip]- Q24 参考答案

  1. 耦合度高:两个服务的表结构互相依赖,修改一张表会影响两个服务。这违背了微服务"独立演化"的目标,变成用网络调用来通信的单体。
  2. 扩展性差:如果只有订单模块增长快,却无法只扩展订单数据库——因为两个服务共用同一套 DB 实例。独立数据库可以实现按模块水平扩展。
  3. 技术锁定:一个服务可能需要 MySQL,另一个可能需要 MongoDB。共享数据库迫使大家使用同一种数据库类型,牺牲了 polyglot persistence 的优势。
  4. 团队冲突:两个团队的部署节奏不同,但数据库 schema 变更需要协调,容易出现"我改了字段你没改"的线上事故。

这就是"分布式单体": 表面上有多组服务,但它们共享同一份数据层,任意修改都需要跨团队协作——本质上和单体的紧耦合一样,只不过加了网络和调用的复杂度。


Q25.【重试风暴分析】

一个电商系统中有三个服务链:API Gateway → Order Service → Payment Service。当 Payment Service 出现短暂不可用时,Order Service 启动了 3 次重试,Gateway 也对 Order Service 启动了 3 次重试。请问这可能引发什么问题?如何设计才能避免?

[!tip]- Q25 参考答案

问题: 重试风暴(Retry Storm)。Gateway 的重试 × Order Service 的重试会产生组合放大效应。假设 1000 个并发请求,Gateway 重试 3 次,Order 又重试 3 次——相当于对 Payment Service 产生了 1000 \times 3 \times 3 = 9000 次请求!这会把原本只是短暂抖动的服务彻底压垮,延长恢复时间。

避免策略:

  1. Jitter:重试时加随机延迟,避免所有请求在同一时刻重试
  2. 只对瞬态错误重试:4xx 和业务错误不应该重试,只重试 5xx、超时、连接重置
  3. 熔断器:当 Payment 失败率过高时,熔断器直接短路,不发起重试
  4. 重试预算递减:Gateway 不重试或只重试 1 次,由 Order Service 决定重试策略
  5. 指数退避:重试间隔逐次翻倍,给下游更多恢复时间

💡 核心认知: 重试的目的是应对偶发瞬态故障,不是应对系统性故障。系统性故障需要熔断和降级,重试只会雪上加霜。


Q26.【服务雪崩防控方案】

一个秒杀系统中,用户请求到达 API Gateway 后,依次经过商品服务 → 库存服务 → 支付服务。如果库存服务的 DB 连接池被打满(所有连接被慢查询占满),请求会从库存服务蔓延到商品服务和 Gateway,最终导致整个系统不可用。请设计一套完整的雪崩防控方案。

[!tip]- Q26 参考答案

多层次防护:

  1. 超时控制:每个 RPC 调用设置合理的超时时间,逐层递减。库存服务超时设为 500ms,商品服务调用库存的超时设为 300ms。
  2. 熔断器:库存服务的调用方设置熔断器,失败率超过阈值(如 50%)时快速失败,不调用下游。
  3. 舱壁隔离:商品服务对不同下游服务使用独立的线程池/连接池。库存服务的问题不会占满商品服务所有的连接。
  4. 限流:网关层对秒杀接口限流,只放行合理数量的请求。例如每秒限 1000 个请求。
  5. 降级策略:库存不可用时返回"暂时无法确认库存,请稍后再试",而不是返回 500 错误。对于非核心功能直接跳过。
  6. 快速失败:在入口处就拒绝超额请求,不让无效请求进入下游链路。
graph TB
    U["用户请求"] --> GW["L1: 网关限流 1000/s"]
    GW --> CS["商品服务"]
    CS --> IS["库存服务"]
    IS --> DB[(DB)]
    CS -.熔断.-> CB1["熔断器:失败率高 → 快速失败"]
    IS -.熔断.-> CB2["熔断器:DB 打满 → 短路"]
    GW -.降级.-> DG["降级:返回兜底页"]
    style U fill:#cfe2f3
    style GW fill:#fff3e0
    style CS fill:#e8f5e9
    style IS fill:#ffebee

💡 防御哲学: 不要指望单个防护措施能挡住所有问题,要靠纵深防御——每一层都减轻上一层的压力,形成瀑布式的缓冲效果。


Q27.【可观测性落地优先级】

假设你刚接手一个全新的微服务项目(5 个服务,团队 3 人),需要在 3 个月内建立可观测性体系。你觉得应该按照什么优先级推进 Metrics → Logging → Tracing?为什么?

[!tip]- Q27 参考答案

推荐顺序:Metrics → Logging → Tracing

理由:

  1. 先 Metrics(第 1 个月):最快见效,投入最小。只要接入 Prometheus + Grafana,配置 RED 方法的 exporter,就能立刻看到服务的 QPS、错误率和延迟趋势。有了 baseline 才知道什么是"正常",告警才有意义。
  2. 再 Logging(第 2 个月):Metrics 告诉你"出事了",Logging 告诉你"发生了什么"。集中收集 JSON 结构化日志(ELK 或 Loki),加上 trace_id 关联,就能在不装链路追踪的情况下定位大部分问题。
  3. 最后 Tracing(第 3 个月):链路追踪最有价值但在初期成本最高——需要 SDK 集成、上下文传播、采样策略配置、可视化平台搭建。当服务数量增多、问题开始在服务间流转难以定位时,Tracing 的价值才真正体现出来。
graph LR
    Base["Metrics\n⏱ 最快上线 · 看趋势"] --> Struct["Logging\n📝 结构化 · 看细节"]
    Struct --> Trace["Tracing\n🔗 看链路 · 最贵但最深"]
    style Base fill:#e8f5e9
    style Struct fill:#fff3e0
    style Trace fill:#fce4ec

底线: 每个阶段都要保持一致性——日志里必须有 trace_id,Metrics 要有足够的维度(service/method/status),这样三个阶段的数据才能真正串联起来。

💡 快速起步建议: 先用 OpenTelemetry auto-instrumentation(如 Java Agent 或 Go OTel contrib)自动采集 Metrics 和 Spans,然后再补充自定义的业务埋点和日志结构化。


评分参考

题目类型 满分 权重
选择题(Q1-Q10) 45 分 45%
填空题(Q11-Q18) 24 分 24%
代码补全(Q19-Q23) 30 分 30%
主观题(Q24-Q27) 16 分 16%
总计 100 分 100%

及格线: ≥70 分
优秀线: ≥85 分