46 KiB
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:#cfe2f3vs
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一行",从而利用本地事务保证原子性。
关键步骤:
- 业务操作与写入 outbox 表在同一个
BEGIN...COMMIT中完成- 出事务后,后台 worker 定时扫描 outbox 表中
status='pending'的记录- 投递成功后更新 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 太频繁浪费资源,太长延迟发现故障 failureThreshold3 连续失败 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()解析:
配置热更新的关键点:
- 原子更新:通过整块赋值(Load 函数)保证读到的是完整一致的配置快照
- 读写锁:读取用 RLock(可并发),写入用 Lock(排他)
- 防惊群:配置中心通常提供 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 参考答案
- 耦合度高:两个服务的表结构互相依赖,修改一张表会影响两个服务。这违背了微服务"独立演化"的目标,变成用网络调用来通信的单体。
- 扩展性差:如果只有订单模块增长快,却无法只扩展订单数据库——因为两个服务共用同一套 DB 实例。独立数据库可以实现按模块水平扩展。
- 技术锁定:一个服务可能需要 MySQL,另一个可能需要 MongoDB。共享数据库迫使大家使用同一种数据库类型,牺牲了 polyglot persistence 的优势。
- 团队冲突:两个团队的部署节奏不同,但数据库 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次请求!这会把原本只是短暂抖动的服务彻底压垮,延长恢复时间。避免策略:
- Jitter:重试时加随机延迟,避免所有请求在同一时刻重试
- 只对瞬态错误重试:4xx 和业务错误不应该重试,只重试 5xx、超时、连接重置
- 熔断器:当 Payment 失败率过高时,熔断器直接短路,不发起重试
- 重试预算递减:Gateway 不重试或只重试 1 次,由 Order Service 决定重试策略
- 指数退避:重试间隔逐次翻倍,给下游更多恢复时间
💡 核心认知: 重试的目的是应对偶发瞬态故障,不是应对系统性故障。系统性故障需要熔断和降级,重试只会雪上加霜。
Q26.【服务雪崩防控方案】
一个秒杀系统中,用户请求到达 API Gateway 后,依次经过商品服务 → 库存服务 → 支付服务。如果库存服务的 DB 连接池被打满(所有连接被慢查询占满),请求会从库存服务蔓延到商品服务和 Gateway,最终导致整个系统不可用。请设计一套完整的雪崩防控方案。
[!tip]- Q26 参考答案
多层次防护:
- 超时控制:每个 RPC 调用设置合理的超时时间,逐层递减。库存服务超时设为 500ms,商品服务调用库存的超时设为 300ms。
- 熔断器:库存服务的调用方设置熔断器,失败率超过阈值(如 50%)时快速失败,不调用下游。
- 舱壁隔离:商品服务对不同下游服务使用独立的线程池/连接池。库存服务的问题不会占满商品服务所有的连接。
- 限流:网关层对秒杀接口限流,只放行合理数量的请求。例如每秒限 1000 个请求。
- 降级策略:库存不可用时返回"暂时无法确认库存,请稍后再试",而不是返回 500 错误。对于非核心功能直接跳过。
- 快速失败:在入口处就拒绝超额请求,不让无效请求进入下游链路。
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
理由:
- 先 Metrics(第 1 个月):最快见效,投入最小。只要接入 Prometheus + Grafana,配置 RED 方法的 exporter,就能立刻看到服务的 QPS、错误率和延迟趋势。有了 baseline 才知道什么是"正常",告警才有意义。
- 再 Logging(第 2 个月):Metrics 告诉你"出事了",Logging 告诉你"发生了什么"。集中收集 JSON 结构化日志(ELK 或 Loki),加上 trace_id 关联,就能在不装链路追踪的情况下定位大部分问题。
- 最后 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 分