--- tags: - 后端 - 微服务 - 测试 - 自我考察 create time: 2026-05-06 12:00 update time: 2026-05-06 status: 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:微服务解决了单体的可扩展性和团队并行开发问题,但会引入分布式复杂度,需要权衡 > > ```mermaid > 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:相反,服务端发现多了一层代理跳数,理论上延迟更高 > > ```mermaid > graph LR > Client["Client App"] -.查注册中心.-> RC[Registry] > Client -->|自选实例| Inst1[Instance A] > Client -->|自选实例| Inst2[Instance B] > style Client fill:#cfe2f3 > ``` > > vs > > ```mermaid > 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** > > **解析:** > 熔断器的三态转换规则: > > ```mermaid > 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'` 等待重试 > > ```go > // 事务内同时写业务数据和 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 脚本可以做到原子性,所以实践中可用。 > > ```go > // 正确的幂等检查顺序: > // 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**:最简单但也最粗暴,适合容忍短时的不可用 > > ```go > 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 路线图**🗺️:显示经过哪些路口、在每个地方花了多久 > > ```mermaid > 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 方法:** 对服务级监控关注 **R**ate(请求率)、**E**rror(错误率)、**D**uration(持续时间);对基础设施关注 **U**tilization(利用率)、**S**aturation(饱和度)、**E**rrors(错误)。 --- ### 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 失败(停止接流量)。 > > ```yaml > # 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 > (扣款失败触发补偿链,从右往左走) > ``` > > **为什么必须逆序?** 因为后续步骤的执行依赖前置步骤的状态。比如你不能先取消订单再释放库存——订单已经没了,库存管理器不知道要释放谁的库存。 > > ```mermaid > 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** > > **解析:** > > ```mermaid > 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 | 异步非关键路径,应在业务服务内处理 | > > ```mermaid > graph LR > Client --> GW["API Gateway
HTTPS/JWT/限流/路由"] > GW --> Svc["业务服务
订单/支付/邮件..."] > 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 > // 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(双向流式)** > > **解析:** > > ```proto > // 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 最佳实践中,以下三个做法分别对应了什么目的? ```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 权限。 > > ```dockerfile > # 完整的多阶段构建示例 > 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 > // 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 权重 | > > ```yaml > # 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 | > > ```mermaid > 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.【熔断器封装】 ```go 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)` 精确判断 > - 配合熔断器使用时,超时也应该计入熔断器统计——超时也是一种"失败" > > ```go > if errors.Is(result.Err, context.DeadlineExceeded) { > // 超时 → 可能是下游负载过高 → 熔断器会增加失败计数 > } > ``` > > 💡 **超时传递:** 子调用的超时时间必须是父调用剩余时间的子集。如果网关给订单服务分配了 200ms,订单服务调用库存服务就不能也给 200ms——至少要留出 50~100ms 余量给上游。 --- ### Q20.【服务发现客户端实现】 ```go 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 | > > ```go > // RWMutex 特性 > reader1 ---||------- Reader A (并行) > reader2 ---||------- Reader B (并行) > writer --|X|------- 排他,读写互斥 > ``` > > 💡 **生产实践:** Nacos/Consul 客户端内部都会维护一个 instance cache,定时刷新(watch/polling),GetInstances 只读缓存——避免每次调用都直接查注册中心造成雪崩。 --- ### Q21.【分布式追踪 Span 埋点】 ```go 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 的标准操作流程:** > > ```go > // 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 信息用于注入到下游请求 | > > ```go > // 更简洁的 gRPC 注入方式(推荐) > import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc" > // 使用 grpcbinjector 自动注入,无需手动处理 metadata > ``` > > 💡 **采样策略:** 不是所有请求都需要记录完整的 trace。常用的采样策略有 AlwaysOn(开发)、AlwaysOff(生产全关)、ProbabilityBased(按比例)和 Adaptive(动态调整)。生产环境建议用概率采样,避免 trace 数据爆炸。 --- ### Q22.【K8s Deployment 资源配置】 ```yaml 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 > // 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 动态刷新】 ```go 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 > > ```go > // 进阶:使用 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. **快速失败**:在入口处就拒绝超额请求,不让无效请求进入下游链路。 > > ```mermaid > 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 的价值才真正体现出来。 > > ```mermaid > 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 分