vault backup: 2026-08-08 19:01:04

This commit is contained in:
2026-08-08 19:01:04 +08:00
parent 1a4a83bc67
commit e2975eb86b
46 changed files with 9370 additions and 0 deletions
@@ -0,0 +1,258 @@
---
tags: [arch/cicd, kubernetes, rolling-update, canary-deployment, hpa, vpa, prometheus, grafana]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# K8s 发布与可观测性体系
## 概述
Kubernetes 是现代 CI/CD 体系的编排核心,提供从滚动发布、灰度策略到自动扩缩容的完整能力链。配合 Prometheus + Grafana + 日志采集组件,构成生产级别的可观测性基础设施。本文系统梳理这些组件的设计原理与协作方式。
## 核心原理
### 整体架构概览
```mermaid
graph TD
subgraph UserTraffic["用户流量"]
U[用户请求] --> INGRESS[Nginx Ingress Controller]
end
subgraph K8sCluster["K8s 集群"]
SUBNAME["Service Name: user-service"] --> EP[Endpoint: pod IPs]
EP --> P1[Pod A: v2.3.1]
EP --> P2[Pod B: v2.3.1]
EP --> P3[Pod C: v2.4.0-new]
DEPLOY[Deployment] -->|管理| P1
DEPLOY -->|管理| P2
DEPLOY -->|管理| P3
HPA[Horizontal Pod Autoscaler] -->|扩缩容指令| DEPLOY
sub kubeSystem["kube-system 组件"]
APISERVER[API Server]
ETCD[(etcd)]
CONTROLLER[M-controller Manager]
SCHEDULER[Scheduler]
end
P1 -->|心跳 / metrics| PROMETHEUS[Prometheus TSDB]
P2 -->|心跳 / metrics| PROMETHEUS
P3 -->|心跳 / metrics| PROMETHEUS
P1 -.->Fluentd --> ES[ES / Loki]
P2 -.->Fluentd --> ES
P3 -.->Fluentd --> ES
end
sub GrafanaUI["可视化层"]
GRAFANA[Grafana Dashboard] -->|查询| PROMETHEUS
GRAFANA -->|告警规则| ALERTMANAGER[Alertmanager]
end
INGRESS --> SUBNAME
```
### RollingUpdate 策略详解
K8s Deployment 默认使用 `RollingUpdate` 策略,通过两个参数控制滚动速度:
| 参数 | 默认值 | 含义 | 影响 |
|------|-------|------|------|
| maxSurge | 25% | 更新过程中允许超出目标副本数的最大数量 | 控制新 Pod 先启动的数量 |
| maxUnavailable | 25% | 更新过程中允许不可用的最大副本数 | 控制旧 Pod 被终止的速度 |
**两种典型配置对比:**
| 配置 | 行为 | 适用场景 |
|------|-----|---------|
| maxSurge=1, maxUnavailable=0 | 逐台升级,零停机 | 对可用性要求极高的生产环境 |
| maxSurge=25%, maxUnavailable=25% | 批量快速滚动 | 测试环境或无需保持全量的场景 |
以 3 副本升级为 4 副本为例(maxSurge=1, maxUnavailable=0):
```mermaid
gantt
title RollingUpdate 过程 (3 -> 4 pods, surge=1)
dateFormat YYYY-MM-DD
axisFormat %H:%M
section Pod Group
old-1 :done, 1, 2026-08-08T00:00, 2026-08-08T00:05
old-2 :done, 1, 2026-08-08T00:00, 2026-08-08T00:05
old-3 :done, 1, 2026-08-08T00:00, 2026-08-08T00:05
new-1 (surge +1) :active, 2026-08-08T00:01, 2026-08-08T00:05
old-3 terminate :crit, 2026-08-08T00:06, 2026-08-08T00:07
new-2 (surge +1) :active, 2026-08-08T00:07, 2026-08-08T00:11
old-2 terminate :crit, 2026-08-08T00:12, 2026-08-08T00:13
new-3 (surge +1) :active, 2026-08-08T00:13, 2026-08-08T00:17
old-1 terminate :crit, 2026-08-08T00:18, 2026-08-08T00:19
new-4 :active, 2026-08-08T00:19, 2026-08-08T00:23
```
**关键约束:就绪探针(Readiness Probe)**
- 新 Pod 必须通过 Readiness Probe 才会被加入 Service 的 Endpoint 列表。
- 如果 Readiness 失败,即使新 Pod 已运行也不会接收流量。
- livenessProbe 用于检测进程是否存活并可能触发重启;readinessProbe 用于决定"这个 Pod 是否可以接受流量"。
### 蓝绿部署 vs Canary 发布
| 维度 | RollingUpdate | 蓝绿部署 | Canary 发布 |
|------|-------------|---------|------------|
| 资源消耗 | 低(增量替换) | 高(需要双倍实例) | 中(少量 Canary 实例) |
| 回滚速度 | 慢(需要滚动回去) | 秒级(切换流量即可) | 快(减少 Canary 流量比例) |
| 风险 | 低(渐进式) | 中高(全部切到新版本后才发现问题) | 最低(小部分用户先体验) |
| 复杂度 | 低(K8s 原生支持) | 中(需要外部 LB 管理) | 高(需要 Istio 或 Nginx 权重配置) |
| 适用团队 | 所有规模 | 大团队,有独立预发环境 | 有成熟 DevOps 能力的团队 |
Canary 发布的流量权重示例(基于 Istio VirtualService):
```yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: user-service-canary
spec:
hosts: ["user-service.default.svc.cluster.local"]
http:
- route:
- destination:
host: user-service
subset: v1
weight: 90 # 90% 流量走稳定版
- destination:
host: user-service
subset: v2
weight: 10 # 10% 流量进 Canary
```
### HPA — 水平自动扩缩容
HPA 基于指标监控动态调整 Pod 副本数:
```go
// HPA 核心指标类型
type MetricSourceType string
const (
// CPU 利用率(容器内统计)
ContainerCPU MetricSourceType = "ContainerResource"
// 内存利用率
ContainerMemory MetricSourceType = "ContainerResource"
// Prometheus 自定义指标(外部指标)
External MetricSourceType = "External"
// Pods 级别指标(如并发连接数)
Pods MetricSourceType = "Pods"
)
```
HPA 的计算公式:`targetReplicas = ceil(currentReplicas * (currentMetricValue / desiredMetricValue))`
例如 CPU 目标 70%,当前平均 CPU 使用率 91%,当前 5 个副本:
`ceil(5 * 91 / 70) = ceil(6.5) = 7` → 扩容到 7 个副本。
> [!WARNING]
> HPA 不是实时的——它依赖 metrics-server 每 15~60 秒的采集间隔,扩容动作有分钟级延迟。对于需要即时响应的秒杀场景,应该提前设置 minReplicas >= 预期峰值的 80%。
### VPA — 垂直自动扩缩容
VPA 自动调整单个 Pod 的 CPU/Memory requests 和 limits:
| 模式 | 行为 | 说明 |
|------|-----|------|
| Offline | 只推荐不应用 | 人工审核后手动调整 |
| Initial | 在 Pod 创建时设置建议值 | 对新 Pod 生效 |
| Auto | 直接在运行时修改 Pod Spec | 会导致 Pod 被杀掉重建 |
| Recreate | 类似 Auto 但先删除旧的再创建 | 保证平滑过渡 |
VPA 的典型场景:微服务刚上线阶段,运维难以精确预估资源需求,可以让 VPA 学习实际使用模式后给出最优建议。
### Prometheus 指标采集链路
```mermaid
flowchart LR
CLIENT_LIB["Client Library<br/>Go SDK / Java SDK / Python SDK"] -->|暴露 /metrics HTTP endpoint| APP_POD["App Pod"]
APP_POD -->|HTTP GET /metrics| SCRAPE["Prometheus Scrape"]
SCRAPE -->|写入| TSDB["TSDB: Thanos / VictoriaMetrics"]
TSDB --> QUERY["PromQL Query Engine"]
QUERY --> ALERT["Alert Rule Evaluation"]
ALERT -->|告警触发| AM["Alertmanager"]
AM -->|Webhook / 邮件 / 钉钉| NOTIFICATION["通知通道"]
```
**核心概念:**
- **Counter**:单调递增计数器(如总请求数),适合画 Rate 曲线。
- **Gauge**:可上可下的仪表值(如活跃连接数)。
- **Histogram**:分桶统计(如请求延迟分布),自动生成 count + sum + bucket。
- **Summary**:客户端计算分位数(P50/P99),服务器端不做计算但有额外开销。
> [!TIP]
> 面试常考点:为什么 Prometheus 用 Pull 而不是 Push?因为 Prometheus 作为中心化的采集器天然知道所有 target 的地址,Pull 模式下失联的 service 会自动被忽略(不再收到数据即标记为 down)。如果是 Push 模式(如 StatsD),挂了的服务会停止发送数据但采集器不知道它们已经不存在了。
### Grafana Dashboard 模板来源
Grafana 本身不提供指标,它只是可视化层。主要数据来源:
| 数据源 | 采集内容 | 典型用途 |
|--------|---------|---------|
| cAdvisor | 容器级别的 CPU/内存/网络/Disk I/O | 容器资源监控 |
| kube-state-metrics | Pod/Node/Deployment/RS 的状态元数据 | K8s 资源健康度 |
| node-exporter | 宿主机硬件指标(CPU、内存、磁盘、网络) | 节点层面性能分析 |
| cadvisor+prometheus | 组合 cAdvisor 和 Prometheus 的指标导出 | 综合容器监控面板 |
### 日志收集方案
```mermaid
graph LR
PODS["所有 Pod 的 stdout/stderr"] --> FLUENTD["Fluentd / Filebeat DaemonSet"]
FLUENTD -->|"Filesystem tail"| LOG_FILES["文件日志"]
FLUENTD --> ES_ELB["Elasticsearch Cluster"]
FLUENTD --> LOKI["Loki"]
ES_ELB --> KIBANA["Kibana"]
LOKI --> GRAFANA["Grafana Log Panel"]
```
- **ELK 栈(Elasticsearch + Logstash/Filebeat + Kibana)**:功能最强大,支持全文搜索、聚合分析,但存储成本高(JSON 结构化索引非常占空间)。
- **Loki(Grafana 生态)**:不建立全文索引,只索引 Labels。查询时用 label 过滤后再检索日志内容,存储成本极低。适合与 Grafana 深度集成的场景。
> [!NOTE]
> 最佳实践:日志采集应该在 Pod 层面做 sidecar 注入(而非直接写文件),这样即使 Pod 崩溃也能收集到最后一段输出。
## 代码示例
Go 中使用 Prometheus Client Library 暴露自定义指标:
```go
import "github.com/prometheus/client_golang/prometheus"
var requestDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request duration in seconds",
Buckets: prometheus.DefBuckets,
},
[]string{"method", "path"},
)
func init() {
prometheus.MustRegister(requestDuration)
}
```
## 实践场景
**秋招高频问题:**
- "HPA 能基于自定义业务指标扩缩容吗?" — 可以。通过 Custom Metrics API + adapter(如 prometheus-adapter),HPA 可以从 Prometheus 读取任意业务指标。例如根据 QPS、错误率、或者 Kafka 消费 lag 来触发扩缩容。
- "为什么蓝绿部署要双倍资源?" — 蓝绿的本质是同时保留新旧两套完整环境,用负载均衡器切换流量。这保证了随时可以秒级回滚,代价就是付费双倍的生产实例。
- "Prometheus 在什么场景下不适合?" — 海量短生命周期任务(如 Serverless 函数,每分钟创销毁成千上万 Pod)。Prometheus 的拉取模型无法跟得上如此动态的变化。此时更适合用 Pushgateway 或改为 push-based 的方案。
> [!WARNING]
> K8s 的 Ready/NotReady 状态切换不会自动通知 HPA——只有当 Pod 处于 Running + Ready 时才计入副本基数。所以在 deployment.yaml 里一定要配好 readiness probe,否则 HPA 会误判当前副本数从而做出错误的扩缩容决策。
## 关联笔记
- [[负载均衡算法]]
- [[限流熔断降级]]
@@ -0,0 +1,198 @@
---
tags: [arch/distributed, redis-distributed-lock, redlock, redisson, reentrant-lock, watch-dog]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# Redis 分布式锁
## 概述
Redis 分布式锁是最广泛使用的分布式同步原语之一。本文深入分析 SETNX + EXPIRE 的原子性问题、Redlock 算法的设计与争议、Redisson 看门狗续期机制、可重入锁的 hash key 设计,以及各类边界场景的死锁分析与规避方案。
## 核心原理
### SETNX + EXPIRE 的原子性陷阱
最直观的分布式锁实现是两条命令组合:
```java
// ❌ 非原子操作 — 存在竞态条件
redis.set("lock", "client_id", "NX"); // 尝试加锁
redis.expire("lock", 30, "EX"); // 设置过期时间
```
**问题**:如果 `set` 成功但 `expire` 失败(网络抖动、进程崩溃),锁永远不会释放——死锁。
**解决方案**:使用 Redis 2.6.12+ 支持的原子语法 `SET key value NX EX seconds`:
```java
// ✅ 原子操作
String result = redis.set("lock", clientId, "NX", "EX", 30);
if ("OK".equals(result)) {
// 加锁成功
}
```
Redis 将 `SET + NX + EX/PX` 打包成一条指令执行,消除了中间状态的竞态窗口。
> [!WARNING]
> 只支持 `SET ... NX` 而不设过期时间是另一个常见错误。客户端宕机后锁永久不释放,需要人工介入清理。
### Redlock 多实例容错
Redisson 作者 Yonatan Katz 提出的 Redlock 算法,旨在解决单点故障下锁不可用的问题:
```mermaid
sequenceDiagram
participant Client as 锁客户端
participant R1 as Redis Master 1
participant R2 as Redis Master 2
participant R3 as Redis Master 3
participant R4 as Redis Master 4
participant R5 as Redis Master 5
loop 依次向 5 个节点发送 SET NX
Client->>R1: SET lock uuid NX EX 30
R1-->>Client: OK (t1)
Client->>R2: SET lock uuid NX EX 30
R2-->>Client: OK (t2)
Client->>R3: SET lock uuid NX EX 30
R3-->>Client: OK (t3)
Client->>R4: SET lock uuid NX EX 30
R4-->>Client: nil (已超时或未写入)
Client->>R5: SET lock uuid NX EX 30
R5-->>Client: OK (t5)
end
Note over Client: success_count = 4 >= N/2+1 = 3
Note over Client: acquireTime = t5 - t1
Note over Client: effectiveTTL = 30s - acquireTime
Client->>R2: DEL lock (释放)
Client->>R5: DEL lock (释放)
```
**核心规则:**
1. 至少部署 5 个无主从复制的独立 Redis 实例。
2. 客户端按顺序尝试 SET NX,记录总耗时 T。
3. 获得多数派(≥ N/2+1)OK 才算加锁成功,有效 TTL = 设定值 − T。
4. 释放锁时向所有节点发 DEL(即使某节点没拿到锁也没关系)。
**Redlock 的争议(Erik Hellemans 的质疑):**
- 当 clock drift 发生时,一个实例可能已经过期并释放了锁,而客户端持有的旧锁仍然有效,导致两把锁"同时存在"。
- 解决方案是设置足够大的过期时间(如 10s~30s),使 clock drift(通常 < 200ms)的影响可忽略不计。
- Martin Kleppmann 和 Conan Ho 在论文中证明了在特定 clock-drift 和网络延迟条件下,Redlock 确实可能失效,但实际生产中 5 实例 + 大超时 + 业务幂等的组合已经足够可靠。
> [!TIP]
> 面试回答:如果你的业务对锁的正确性是绝对不可妥协的(如金融扣款),不要依赖任何单一的分布式锁实现。应该加上数据库层面的二次校验(唯一约束 + CAS),用"锁 + 数据一致性"双重保障。
### Redisson 看门狗续期机制
Redis 分布式锁的致命问题是:业务逻辑执行时间超过锁的过期时间会怎样?锁自动释放 → 其他客户端进入 → 数据竞争。
Redisson 的方案是**看门狗(Watch Dog)**:
| 步骤 | 行为 | 触发时机 |
|------|-----|---------|
| 加锁时不指定 leaseTime | 启动看门狗后台任务 | 首次获取锁成功 |
| 每 10 秒检查一次锁是否仍持有 | 续期到默认 30 秒 | 定时调度 |
| 业务未结束时重复续期 | 锁不会被误释放 | 只要业务还在运行 |
| 业务完成主动 unlock | 关闭看门狗 | finally 块中 |
| 客户端意外宕机 | 看门狗停止 → 锁自然过期 | Redis 侧 30 秒后自动删除 |
```java
// Redisson 看门狗工作原理伪代码
RLock lock = redisson.getLock("myLock");
lock.lock(); // 没有传入 leaseTime 参数
try {
doBusiness(); // 看门狗每 10s 续期一次,每次续 30s
} finally {
lock.unlock(); // 显式释放,关闭看门狗
}
```
> [!NOTE]
> 如果明确知道业务执行时间上限,建议在 `lock(leaseTime)` 时直接传入精确的到期时间,避免看门狗的额外开销和复杂度。
### 可重入锁设计
可重入意味着同一个线程可以多次获取同一把锁而不被阻塞。Redisson 的实现方式是 **hash key + count 计数器**:
```go
// Lua 脚本:可重入加锁
local key = KEYS[1]
local threadId = ARGV[1]
local lockValue = redis.call('get', key)
if lockValue == false then
redis.call('set', key, threadId, 'EX', ARGV[2])
return 1
end
if redis.call('hexists', key, threadId) == 1 then
redis.call('hincrby', key, threadId, 1)
redis.call('expire', key, ARGV[2])
return 1 // 重入成功
end
return 0 // 被其他线程持有
```
每个锁的 key 是一个 Hash:`{threadID}: 1` 表示第一个线程已获得锁且计数为 1;第二次重入变为 `{threadID}: 2`。unlock 时递减计数,归零才真正删除 key。
### 锁超时时间设定原则
| 因素 | 建议 | 说明 |
|------|-----|------|
| GC 停顿时间 | ≥ 3 × GC 最大停顿 | 避免因 Full GC 导致看门狗错过续期 |
| 网络 RTT 波动 | ≥ 网络 99.9th percentile | P999 的网络延迟才有代表性 |
| 业务执行时间 | 不确定时不设或设大值 | 能用看门狗就让它工作 |
| 最小安全值 | 30 秒起步 | 小于 10 秒的锁几乎必然有问题 |
### 死锁场景分析
| 场景 | 原因 | 解法 |
|------|-----|------|
| 客户端宕机 | 锁未释放 | 过期时间兜底 + 看门狗机制 |
| 并发持有多把锁 | A 持有锁1等锁2,B 持有锁2等锁1(经典死锁) | 全局一致的加锁顺序 |
| 时钟回拨 | NTP 同步异常导致系统时钟倒退 | 检测时钟回拨 > 阈值则拒绝服务 |
| Redis 主从切换 | 主节点加了锁还没来得及复制到从节点就宕机 | Redlock + 业务幂等校验 |
## 代码示例
Go 中使用 go-redis 实现简单的分布式锁:
```go
func tryLock(ctx context.Context, key, val string, ttl time.Duration) bool {
ok, err := rdb.SetNX(ctx, key, val, ttl).Result()
if err != nil || !ok {
return false
}
return true
}
func releaseLock(ctx context.Context, key, val string) bool {
lua := redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end
return 0
`)
return lua.Run(ctx, []string{key}, val).Int() == 1
}
```
> 解锁必须用 Lua 保证"检查 value + 删除"的原子性。否则出现竞态:A 读到自己的 value,在 del 之前 B 重新获得了锁,A 的 del 就把 B 的锁删掉了。
## 实践场景
**秋招高频问题:**
- "为什么 Redis 锁不用 WATCH MULTI/EXEC?" — Watch 是基于 optimistic locking 的 MVCC 思想,适合读多写少的场景。分布式锁需要的是互斥语义而非版本碰撞检测,WATCH 在高并发下冲突率极高,性能远不如 SET NX。
- "Redlock 真的有用吗?什么时候该用/不该用?" — 如果只是微服务内的普通业务锁(如防止用户重复点击),单机 Redis + SET NX 完全够用。只有对可用性要求极高的核心链路(如秒杀库存扣减)才值得上 Redlock。代价是多倍的 Redis 实例和维护复杂度。
- "缓存穿透 + 分布式锁怎么配合?" — 典型模式:先查缓存 → miss → 用分布式锁保护 DB 查询 → 落缓存。关键是用锁保护"查 + 写缓存"这个复合操作,而不是只保护 DB 查询。
## 关联笔记
- [[ZooKeeper 分布式锁]]
- [[限流熔断降级]]
@@ -0,0 +1,175 @@
---
tags: [arch/distributed, zookeeper-distributed-lock, zab-protocol, ephemeral-node, watch-mechanism, cp-system]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# ZooKeeper 分布式锁
## 概述
ZooKeeper 基于 ZAB 协议的强一致性保证,天然适合构建分布式协调服务。本文深入分析 ZK 分布式锁的核心机制——临时顺序节点、Watch 一次性触发、公平锁实现原理,以及与 Redis 锁的系统级对比。
## 核心原理
### 临时顺序节点创建过程
ZK 锁的核心数据结构是 **临时顺序节点(Ephemeral Sequential Node)**:
```mermaid
graph TD
A[客户端连接 ZK] --> B[在 /locks 下创建 EPHEMERAL_SEQUENTIAL 节点]
B --> C["节点名: /locks/lock-0000000001"]
C --> D[获取 /locks 下的所有子节点并排序]
D --> E{自己是最小的节点?}
E -- 是 --> F["加锁成功<br/>lock-0000000001 获得锁"]
E -- 否 --> G["监听比自己小的那个节点<br/>watch(lock-0000000000)"]
G --> H["等待 Watch 通知"]
I[持锁客户端断开连接] --> J[ZK 检测到 session 过期]
J --> K["自动删除自己的 EPHEMERAL 节点"]
K --> L["被监听者收到 NODE_DELETED 事件"]
L --> D
```
**关键语义:**
- **EPHEMERAL(临时性)**:客户端 session 断开(心跳超时),ZK 自动清理节点。不需要手动 unlock,避免死锁。
- **SEQUENTIAL(顺序性)**:每个创建的节点附带单调递增的序号,保证了全局有序性和公平性。
### Watch 机制(一次性触发 + NodeChildrenChanged)
Watch 是 ZK 分布式锁能够工作的核心机制:
| 特性 | 说明 |
|------|------|
| 一次性(One-shot) | 触发后自动注销,必须重新注册。防止重复消费。 |
| WATCHED 状态 | 客户端调用 `exists` / `getChildren` 时设置 watch=true,服务端记录此订阅关系。 |
| EventType | 锁场景使用 `NodeChildrenChanged`——子节点列表变化时通知所有相关监听者。 |
| 网络传输 | ZK 用独立的 watcher event 通道推送,与数据读取通道分离,保证低延迟。 |
```java
// 伪代码:ZK 锁的 watch 逻辑
Stat stat = zk.exists("/locks/" + prevNode, event -> {
if (event.getType() == EventType.NodeDeleted) {
// 上一个锁释放了,重新竞争
tryAcquire();
}
});
if (stat == null) {
// 没有更小的节点,直接获得锁
holdLock();
}
```
> [!NOTE]
> Watch 的一次性特性意味着客户端必须在接收到事件后立刻重新注册。如果客户端处理事件耗时过长而错过了 re-register 窗口,可能永远不再收到下一次通知。Redisson 的 ZK 模块会做这个重试。
### 公平锁的实现
因为节点是 SEQUENTIAL 的,所以按序号大小决定锁的持有权,天然实现了公平性:
```
/zk-lock/lock-0000000001 ← Alice (最小,获得锁)
/zk-lock/lock-0000000002 ← Bob (监听 lock-0000000001)
/zk-lock/lock-0000000003 ← Carol (监听 lock-0000000002)
/zk-lock/lock-0000000004 ← Dave (监听 lock-0000000003)
```
Alice 释放后,Bob 收到 NODE_DELETED → Bob 成为新的最小节点 → 获得锁。整个过程严格遵循 FIFO 顺序。
> [!TIP]
> 面试考点:ZK 的公平性是"最终公平"而非"严格公平"。因为存在网络延迟和序列化差异,实际获得的顺序可能与创建节点的顺序不完全一致(但概率极低)。
### CP 性质保证 — ZAB 协议
Zookeeper 通过 **ZAB(ZooKeeper Atomic Broadcast)协议** 保证 CP(一致性 + 分区容错性):
```mermaid
stateDiagram-v2
[*] --> LOOKING: 节点启动
LOOKING --> LEADING: Leader Election 完成
LOOKING --> OBSERVING: Observer 模式
LEADING --> FOLLOWING: 平滑切换
FOLLOWING --> LEADING: Leader 故障后选举
LEADING --> OBSERVING: 降级为 Observer
FOLLOWING --> OBSERVING: Observer 不选举也不提案
state LEADING {
[*] --> Proposal: 接收客户端写请求
Proposal --> Broadcast: 广播 proposal
Broadcast --> AckCollect: 收集过半 ACK
AckCollect --> Commit: 提交到事务日志
}
```
**ZAB 的两个核心阶段:**
1. **Leader Election**:通过 ZXID(事务 ID)比较选出拥有最大 ZXID 的节点作为 Leader。选举过程中所有写操作排队等待。
2. **Atomic Broadcast**:Leader 将写操作封装为 Proposal,follower 回复 ACK,达到半数后提交并广播给所有节点。
**脑裂问题(Split Brain)讨论:**
当网络分区导致集群分裂为两部分时:
| 场景 | 行为 | 安全性 |
|------|-----|--------|
| 多数派分区(≥ N/2+1) | 继续正常工作,选新 Leader | ✅ 安全 |
| 少数派分区(< N/2+1) | 失去 Quorum,拒绝写操作 | ✅ 安全(CP 优先) |
| N=3, 两个分区各 1 个 | 都无法达成 Quorum,全部停止 | ✅ 安全,但可用性为零 |
N 必须是奇数(3/5/7),这确保任何分区都只有一种结果:要么有 Quorum,要么没有。不会出现两个分区都认为自己有 Quorum 的情况。
### Redis 锁 vs ZK 锁全面对比
| 维度 | Redis 锁 | ZooKeeper 锁 |
|------|---------|-------------|
| 一致性模型 | AP(单实例)/ 不确定(Redlock) | CP(ZAB 强一致) |
| 实现复杂度 | 简单(SET NX EX) | 中等(需理解临时节点和 Watch) |
| 性能 | 极高(内存操作,百万级 QPS) | 较低(需要写事务日志和同步复制) |
| 公平性 | 非公平(先到先得,无顺序保证) | 天然公平(SEQUENTIAL 序号) |
| 看门狗续期 | Redisson 内部定时任务 | 自动(session 保活即可) |
| 宕机安全性 | 依赖过期时间(可能被误删) | 自动清理(EPHEMERAL 节点) |
| 适用规模 | 大规模分布式系统 | 中小规模协调服务 |
| 运维成本 | 低(Redis 集群成熟) | 高(ZK 集群需要维护 odd-numbered nodes) |
> [!WARNING]
> 不要为了"更高级"而去用 ZK 锁。如果你的业务只需要简单的互斥锁,Redis + SET NX 足够好。ZK 的优势在于它提供的不只是锁——还有顺序节点、Watch 通知、配置管理等一系列协调原语。
## 代码示例
Java 中使用 Curator 实现分布式锁(最流行的 ZK Java 客户端):
```java
// Curator 封装了复杂的 ZK API
InterProcessMutex lock = new InterProcessMutex(
curatorFramework, "/locks/resource-a");
try {
if (lock.acquire(10, TimeUnit.SECONDS)) {
doBusiness();
}
} finally {
lock.release(); // 递归释放所有重入计数
}
```
Curator 在底层做的事情:
1. 创建 `/locks/resource-a` 目录(如不存在)。
2. 在目录下创建 EPHEMERAL_SEQUENTIAL 子节点。
3. 获取子节点列表,判断自己是否是最小序号。
4. 如果是 → 加锁成功;否则 → 对前一个节点注册 Watch。
5. Watch 触发 → 回到第 3 步重新判断。
## 实践场景
**秋招高频问题:**
- "ZK 为什么不直接用 EXISTS 命令不断轮询?" — EXISTS 不带 watch 会返回当前状态但不订阅变更。如果用 while(!exists) sleep(10ms) 的方式,不仅浪费 CPU,还可能在两次 exists 之间错过节点删除事件,导致无限等待。
- "ZK 的会话超时怎么设?" — 默认 40 秒太长,建议设为 3~10 秒。短超时能更快感知客户端宕机并释放锁。但要平衡心跳间隔(heartbeat interval = timeout / 3),太短的 heartbeat 会增加不必要的网络开销。
- "ZK 脑裂为什么不会导致两把锁同时存在?" — ZAB 协议要求写操作必须得到多数派确认后才会提交。脑裂时少数派分区无法获得 Quorum,任何写操作都不会被确认,因此不可能出现两个 Leader 各自分配同一个资源的情况。
> [!TIP]
> 实战经验:ZK 锁最适合用于"需要强一致性的少量并发控制"场景——如数据库主从切换中的 Leader 选举、分布式调度器的 Job 唯一绑定。对于高频短锁(如计数器增减),Redis 明显更适合。
## 关联笔记
- [[Redis 分布式锁]]
- [[幂等设计方案]]
+288
View File
@@ -0,0 +1,288 @@
---
tags: [arch/idempotency, unique-index, token-pattern, optimistic-lock, state-machine, request-id]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# 幂等设计方案
## 概述
幂等是指"多次执行同一操作与执行一次的效果相同"。在分布式系统中,网络重试、消息重复投递、用户重复点击都会导致同一个业务请求被执行多次。本文对比六种主流幂等方案的实现原理、适用场景和性能特征。
## 核心原理
### 方案一:数据库唯一索引防重
利用数据库的唯一约束(UNIQUE INDEX)作为最底层的幂保证:
```mermaid
flowchart LR
R[请求到达] --> Q{SELECT count(1) FROM order_idemp WHERE biz_no='xxx'}
Q -- 已存在 --> F[返回已有结果]
Q -- 不存在 --> U[INSERT INTO ...]
U -- 成功 --> S[执行业务逻辑]
U -- Duplicate key error --> F
```
**实现步骤:**
1. 业务流水号(biz_no / trade_no)作为唯一约束字段。
2. INSERT 时如果发生 `Duplicate entry` 错误,说明已被处理过。
3. 查询该笔流水对应的结果直接返回,不执行业务逻辑。
```java
// MyBatis 插入幂等记录
@Insert("INSERT INTO idempotent_record (biz_no, status, result) VALUES (#{bizNo}, 'PROCESSING', null)")
int insertRecord(@Param("bizNo") String bizNo);
try {
int rows = mapper.insertRecord(bizNo);
// 首次执行 — 执行业务逻辑
processOrder(order);
mapper.updateStatus(bizNo, "SUCCESS", resultId);
} catch (DuplicateKeyException e) {
// 重复请求 — 查询已有结果返回
IdempotentRecord record = mapper.selectByBizNo(bizNo);
return new Result(record.getResultId(), record.getStatus());
}
```
| 优点 | 缺点 |
|------|------|
| 零代码侵入(只需要一个唯一索引) | 需要额外的幂等表/列占用存储 |
| 强一致性(数据库事务保证) | 高并发下 INSERT 可能成为瓶颈 |
| 实现简单直观 | 无法区分"处理中"和"已完成" |
> [!TIP]
> 面试常考点:唯一索引的幂等方案只能防止重复写入,不能防止"先查后写"中的 ToCToU 竞态(Time-of-check to Time-of-use)。必须把判断和写入打包成一个原子操作,要么用 `INSERT IGNORE`,要么用悲观锁 `SELECT FOR UPDATE`。
### 方案二:Token 预获取模式
适用于所有不可重入的业务操作(支付、转账、发货):
```mermaid
stateDiagram-v2
[*] --> UNUSABLE: Token 创建 → 标记为不可用
UNUSABLE --> USED: 业务提交成功 → 设为已使用
UNUSABLE --> EXPIRED: 超过 TTL 自动过期
USED --> [*]: 完成
EXPIRED --> [*]: 回收
```
**流程:**
1. 客户端先调用 `/token/generate` 接口获取一个一次性 Token。
2. 服务端将 Token 存入 Redis,状态为 UNUSABLE,设置过期时间(如 5 分钟)。
3. 客户端携带 Token 发起实际业务请求。
4. 服务端用 Lua 脚本原子性地检查并消费 Token:`if redis.call('get', key) == val then redis.call('set', key, 'USED') end`。
```lua
-- Redis Lua 脚本:原子性检查 + 消费 Token
local key = KEYS[1]
local expected = ARGV[1]
if redis.call("GET", key) == expected then
redis.call("SET", key, "USED", "EX", 0)
return 1 -- 允许执行
end
return 0 -- 拒绝:Token 不存在或已被消费
```
| 优点 | 缺点 |
|------|------|
| 强幂等(一次消耗,永不复用) | 多一次 API 调用(先领 token 再提交) |
| 可防止 CSRF(token 绑定会话) | Token 丢失 = 请求失效 |
| 自带时效控制(TTL) | 需要额外管理 Token 的生命周期 |
> [!WARNING]
> Token 模式不适合短延迟批处理。例如批量导入数据,每次操作都去领一个 Token 会严重拖慢用户体验。此时更适合用唯一索引方案。
### 方案三:乐观锁版本号校验
适用于更新类操作的幂等——基于 CAS(Compare-and-Swap)思想:
```sql
UPDATE account SET balance = balance - 100, version = version + 1
WHERE user_id = 123 AND version = 5;
```
如果另一个请求已经将 version 从 5 改为 6,此语句的 `affected_rows = 0`,表示冲突了。
```go
func UpdateWithOptimisticLock(ctx context.Context, sqlxDB *sqlx.DB, userID, amount int, expectedVersion int) error {
query := `UPDATE orders SET status = ?, version = version + 1 WHERE id = ? AND version = ?`
result, err := sqlxDB.ExecContext(ctx, query, "PAID", userID, expectedVersion)
affected, _ := result.RowsAffected()
if err != nil || affected == 0 {
return fmt.Errorf("optimistic lock conflict: expected version %d but got stale data", expectedVersion)
}
return nil
}
```
| 优点 | 缺点 |
|------|------|
| 不需要额外的表或列 | 仅适用于更新操作,不适用于插入 |
| 读多写少时冲突率低 | 高并发下频繁回退,用户体验差 |
| 天然支持重试(换 version 重新提交) | 无法阻止不同请求同时成功 |
### 方案四:状态机校验
适用于有明确生命周期管理的业务流程,如订单、工单、审批流:
```mermaid
flowchart LR
A["CREATED"] -->|"支付"| B["PAID"]
B -->|"发货"| C["SHIPPED"]
C -->|"签收"| D["COMPLETED"]
B -->|"退款"| E["REFUNDING"]
E -->|"退款成功"| F["REFUNDED"]
classDef invalid fill:#f99,color:#fff
A -.->|"不允许直接到 COMPLETED"| G["COMPLETED"]: invalid
G ::: invalid
```
**核心规则:** 每个业务动作只允许从特定前驱状态转换到特定的后继状态。
```java
public enum OrderStatus {
CREATED, PAID, SHIPPED, COMPLETED, REFUNDED;
public static boolean canTransitionFrom(OrderStatus from, OrderStatus to) {
return switch (to) {
case PAID -> from == CREATED;
case SHIPPED -> from == PAID;
case COMPLETED-> from == SHIPPED;
case REFUNDED -> from == REFUNDING;
default -> false;
};
}
}
// 幂等判断
public void payOrder(String orderId) {
Order order = orderRepo.findById(orderId);
if (!OrderStatus.canTransitionFrom(order.getStatus(), OrderStatus.PAID)) {
throw new BusinessException("INVALID_TRANSITION: cannot PAY from " + order.getStatus());
}
// ... 执行业务逻辑
}
```
| 优点 | 缺点 |
|------|------|
| 语义清晰,天然防重 + 业务合规 | 需要在每个接口处做状态检查 |
| 能识别非法请求(不只是重复) | 状态空间爆炸时难以维护 |
| 不需要额外数据结构 | |
> [!NOTE]
> 状态机是最常被忽略的幂等手段。它既是约束也是保护——不仅拦截重复请求,还能拦截非法的请求序列(如未创建就付款)。
### 方案五:网关层 Request ID 去重
适合 API 网关统一处理,作为第一道防线:
```mermaid
sequenceDiagram
participant Client as 客户端
participant GW as API Gateway
participant Redis as 去重缓存
participant Backend as 后端服务
Client->>GW: POST /api/pay {RequestId: uuid-a, Body:{...}}
GW->>Redis: SETNX reqid:uuid-a 1 EX 300s
Redis-->>GW: 1 (首次)
GW->>Backend: 转发请求
Backend-->>GW: 响应
GW-->>Client: 响应
```
后续同一个 RequestId 到达时:
- `SETNX` 返回 0(key 已存在)→ 直接从本地缓存或 DB 中取出之前的响应直接返回。
- 这个方案的局限性在于:如果后端真正执行了但还没返回,网关已经缓存了中间状态。
```java
// 网关层幂等拦截器伪代码
public Response handle(Request req) {
String requestId = req.getHeader("X-Request-ID");
String cacheKey = "reqid:" + requestId;
if (redis.exists(cacheKey)) {
return cachedResponse.get(requestId); // 直接返回之前结果
}
Response resp = forwardToBackend(req);
// 缓存响应,避免重复请求再次走后端
redis.setex(cacheKey, 300, serialize(resp));
return resp;
}
```
| 优点 | 缺点 |
|------|------|
| 无业务侵入,网关层统一处理 | 只保护单次请求,跨重试窗口外的重复无法覆盖 |
| 可以记录请求全链路审计日志 | 缓存响应的大小限制了适用场景 |
| 对后端完全透明 | RequestId 需要客户端配合生成 |
### 各方案综合对比
| 方案 | 一致性级别 | 实现复杂度 | 适用场景 | 性能影响 |
|------|-----------|-----------|---------|---------|
| 唯一索引防重 | 强一致 | 低 | 所有写操作兜底 | 低(DB 唯一约束开销很小) |
| Token 预获取 | 强一致 | 中 | 支付/转账等高风险操作 | 中(多一次 Redis 交互) |
| 乐观锁版本 | 最终一致 | 低 | 计数器更新、库存扣减 | 低(单行 SELECT FOR UPDATE 替代) |
| 状态机校验 | 最终一致 | 中 | 订单、工单等生命周期明确的业务 | 低(内存判断) |
| 网关 Request ID | 弱一致 | 中 | API 入口通用防护 | 低(缓存命中率决定) |
## 代码示例
Go 中使用唯一索引 + 事务的完整幂等 handler:
```go
type IdempotentHandler struct {
db *sql.DB
}
func (h *IdempotentHandler) Handle(bizID string, payload interface{}) (*Result, error) {
tx, err := h.db.BeginTx(context.Background(), nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
var existing string
tx.QueryRow("SELECT status FROM idempotent WHERE biz_id=?", bizID).Scan(&existing)
if existing == "done" {
return h.getCachedResult(bizID), nil // 幂等返回
}
if _, err := tx.Exec("INSERT INTO idempotent(biz_id,status) VALUES($1,'processing')", bizID); err != nil {
if isDuplicate(err) {
return h.getCachedResult(bizID), nil
}
return nil, err
}
result := executeBusiness(payload)
tx.Exec("UPDATE idempotent SET status='done',result=$1 WHERE biz_id=$2", result, bizID)
tx.Commit()
return result, nil
}
```
## 实践场景
**秋招高频问题:**
- "幂等和去重有什么区别?" — 幂等强调语义:多次执行结果相同;去重强调数据:相同的请求只出现一次。去重是手段,幂等是目标。用唯一索引去重是为了达到幂等效果。
- "为什么不建议只用 Redis SETNX 做业务幂等?" — Redis 非持久化,宕机可能丢数据(即使有 AOF fsync=1 也有窗口期)。而且 SETNX 只能保证互斥,不能保证"之前是否已经成功执行过"——除非你在 Redis 里也存一份状态。这本质上是把数据库该做的事搬到了 Redis,增加了复杂度和不一致风险。
- "最佳实践组合是什么?" — 推荐三层防御:① 网关层 Request ID 拦截重复流量 ② Token 模式控制高风险操作 ③ 数据库唯一索引做最后兜底。三道防线互为补充,任何一层被突破都有下一层兜住。
> [!TIP]
> 面试加分回答:幂等的根本原因是分布式系统的不可靠性(网络重试、消息队列至少一次投递)。解决思路不是"消灭重复"(不可能),而是"让重复变得无害"。所有方案的核心设计原则都是:用一个确定的 key(biz_no / token / request_id)来标识一次业务意图,然后用这个 key 做去重判断。
## 关联笔记
- [[Redis 分布式锁]]
- [[ZooKeeper 分布式锁]]
@@ -0,0 +1,193 @@
---
tags: [arch/microservice, service-discovery, eureka, nacos, consul, cap-theory, health-check]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# 服务注册与发现
## 概述
服务注册与发现是微服务架构的基石。当服务实例动态扩缩容时,调用方需要知道"当前谁在提供服务"以及"它们在哪"。本文对比 Eureka、Nacos、Consul 三大主流方案的核心设计差异,分析 CAP 理论在服务注册中心中的取舍,以及健康检查的各种实现方式。
## 核心原理
### 服务注册/注销/续约流程
```mermaid
sequenceDiagram
participant Client as 客户端
participant Server as 服务提供者
participant Registry as 注册中心
participant Discover as 消费方
Client->>Server: 启动服务
Server->>Registry: 1. 注册服务 (POST /register)
Registry-->>Server: 注册成功
Server->>Registry: 2. 心跳续约 (POST /heartbeat)
Registry->>Registry: 更新 lastDirtyTimestamp
loop 每 N 秒
Server->>Registry: 心跳续约
Registry-->>Server: ACK
end
Discover->>Registry: 3. 查询服务列表 (GET /instances)
Registry-->>Discover: 返回健康实例列表
Discover->>Server: 发起 RPC 调用
Note over Server: 服务关闭
Server->>Registry: 4. 主动注销 (DELETE /deregister)
Registry-->>Server: 注销成功
Registry->>Registry: 从服务列表中移除
```
**三步走**:注册 → 续约(或心跳)→ 注销。绝大多数故障场景下,调用方感知到服务宕机会延迟一个续约超时周期(通常 30~90 秒)。
### Eureka — AP 取向的自我修复
Eureka 是 Netflix 开源的方案,设计上严格遵循 **AP(可用性 + 分区容错性)**:
- **双副本架构**:Peer Replication 机制将数据异步同步给所有节点,不同步视为正常而非故障。
- **自我保护机制**:当 15 分钟内超过 85% 实例未发送心跳,Eureka 锁定保护窗口,不再剔除任何实例。这意味着被误判为宕机的实例仍会出现在服务列表中——宁可多调几次再失败,也不主动剔除。
- **客户端缓存**:消费者缓存服务列表(默认每 30 秒刷新),即使注册中心完全不可用,消费方依然能根据本地缓存继续通信。
```java
// Eureka 自我保护机制触发条件
public boolean shouldEnableRenewments() {
double threshold = registry.getRenewalThreshold(); // 85%
if (serverConfig.isSelfPreservationEnabled()
&& registry.getCachedInstanceStatus().equals(HEAVY_OVERLOAD)) {
return true; // 进入自我保护,允许续约
}
return super.shouldEnableRenewments();
}
```
> [!WARNING]
> 自我保护机制在生产环境是个双刃剑:它保证了高可用,但可能导致流量打向已宕机的实例。建议监控 `eureka.server.selfPreservationThreshold` 指标。
### Nacos — CP/AP 可切换的统一平台
Nacos 解决了 Eureka 和 ZooKeeper 只能二选一的问题:
| 模式 | 适用场景 | 一致性协议 | 典型部署形态 |
|------|---------|-----------|------------|
| AP 模式 | 服务注册发现 | Distro 协议(自研,类似 Eureka 的双写) | 集群部署 |
| CP 模式 | 配置管理 | Raft 协议 | 奇数节点集群 |
**统一命名空间管理**:Nacos 通过 `namespace` 实现环境隔离(dev/test/prod),每个命名空间有独立的服务和配置视图。底层基于 PostgreSQL 存储元数据,天然支持持久化。
```go
// Nacos Go SDK 服务订阅示例
client, _ := nacos.NewClient(nacos.Config{
ServerAddr: "127.0.0.1:8848",
NamespaceId: "prod-ns",
})
info, _ := client.Subscribe(&nacos.Subscriber{
ServiceName: "user-service",
Cluster: "DEFAULT",
OnEvent: func(event *nacos.Event) {
for _, inst := range event.Hosts {
fmt.Println(inst.InstanceIp, inst.InstancePort)
}
},
})
```
> [!TIP]
> 面试常考点:为什么 Nacos 能在同一套代码中既做 AP 又做 CP?答:两种模式使用不同的数据存储路径和服务类型——`nacos.v1.auth.user`(CP,Raft)与 `nacos.v1.instance`(AP,Distro)。
### Consul — Serf Gossip + 丰富健康检查
HashiCorp 出品的 Consul 有三个独特优势:
1. **Serf gossip 协议**:基于 WAN Gossip 的成员管理,相比 TCP 直连更容错。节点通过 pull-based push-based 混合机制传播成员变更。
2. **丰富的健康检查**:支持 TCP、HTTP、HTTP Body、docker、脚本五种检查方式。还支持 TTL(手动报告状态)模式。
3. **KV 存储能力**:内嵌 Raft 一致性 KV 存储,可用于配置管理、分布式锁等辅助场景。
```go
// Consul HTTP 健康检查配置
func registerCheck() *api.AgentServiceCheck {
return &api.AgentServiceCheck{
HTTP: "http://localhost:8080/health",
Timeout: "5s",
Interval: "10s",
DeregisterCriticalServiceAfter: "90s",
}
}
```
### CAP 理论在服务注册中心的体现
| 注册中心 | CAP 选择 | 网络分区时的行为 | 数据一致性保证 |
|---------|---------|-----------------|--------------|
| Eureka | AP | 各节点独立响应,可能返回过期数据 | 最终一致 |
| Nacos (AP) | AP | Distro 协议异步复制,容忍短暂不一致 | 最终一致 |
| Nacos (CP) | CP | 选出 Leader 后响应,部分节点不可用时降级 | 强一致 |
| Consul | CP(TCP)/ AP(DNS) | Raft Leader 不可达时无法写入 | 读可能 stale |
> [!NOTE]
> PACELC 补充:CAP 只讨论网络分区场景。PACELC 指出:无分区时,延迟(Latency)与一致性(Consistency)仍需权衡。Nacos AP 模式、Eureka 都是选择了低延迟而非强一致。
### 健康检查方式对比
| 方式 | 原理 | 优点 | 缺点 |
|------|-----|------|------|
| TCP | 尝试建立 TCP 连接 | 轻量、通用 | 无法感知应用层故障 |
| HTTP/HTTPS | 请求指定 endpoint 检查状态码 | 灵活,可携带业务逻辑 | 增加服务器负载 |
| gRPC | gRPC Health Checking Protocol | 适合 gRPC 服务 | 需额外 proto 定义 |
| Script | 执行自定义脚本 | 最灵活,可做深度检查 | 维护成本高 |
| Edge探测 | 模拟真实用户请求 | 最贴近用户体验 | 复杂度高,一般用于 SLA 监控 |
## 代码示例
以下是一个简化版的 Go 服务注册中心心跳续约逻辑:
```go
type Heartbeat struct {
ServiceID string
InstanceID string
LastPingAt time.Time
}
type Registry struct {
instances map[string]*Instance // serviceID -> Instance
mu sync.RWMutex
}
// Register 注册一个新实例
func (r *Registry) Register(svc, instanceID, addr string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.instances[svc]; !ok {
r.instances[svc] = &Instance{Addr: addr}
}
return nil
}
// Renew 续约:更新最后心跳时间
func (r *Registry) Renew(svc, instanceID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
inst, ok := r.instances[svc]
if !ok {
return false
}
inst.LastHeartbeat = time.Now()
return true
}
```
## 实践场景
**秋招高频问题:**
- "Eureka 的自我保护机制什么时候该关?" — 答案:金融类对一致性要求高的场景可以考虑关闭,普通互联网业务不建议关。关了之后可能出现"雪崩式下线"——一批实例同时被认为是故障而全部剔除。
- "Nacos 和 Eureka 有什么本质区别?" — Eureka 只管服务发现,Nacos 是一体化平台(服务发现 + 配置中心)。Nacos 还能按 namespace/group/key 精确分组配置。
- "Consul 的 DNS 接口为什么是 AP 模式?" — DNS 本身是无状态的,Consul 的 DNS API 直接指向随机节点读取本地内存缓存,不经过 Raft 日志,因此速度极快但可能读到旧数据。
**实战建议:** 小团队用一个 Nacos 集群就够用;大规模多机房场景考虑 Consul 的多数据中心能力。
## 关联笔记
- [[负载均衡算法]]
- [[限流熔断降级]]
- [[配置中心设计]]
@@ -0,0 +1,183 @@
---
tags: [arch/microservice, load-balancing, consistent-hashing, spring-cloud-lb, round-robin]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# 负载均衡算法
## 概述
负载均衡将客户端请求分发到多个服务实例,是微服务架构中不可或缺的一环。本文对比客户端 vs 服务端两种负载均衡架构,详细讲解轮询、随机、一致性哈希、最少连接等核心算法,以及 Spring Cloud LoadBalancer 的实现原理。
## 核心原理
### 客户端负载均衡 vs 服务端负载均衡
```mermaid
graph TD
subgraph ClientLB["客户端负载均衡"]
C1[客户端/消费者] --> LB1[客户端负载均衡器]
LB1 --> SI1["实例A: :8081"]
LB1 --> SI2["实例B: :8082"]
LB1 --> SI3["实例C: :8083"]
C1 -.->|自己选实例| LB1
end
subgraph ServerLB["服务端负载均衡"]
C2[客户端/消费者] --> NLB[Nginx/LVS]
NLB --> SNI1["实例A: :8081"]
NLB --> SNI2["实例B: :8082"]
NLB --> SNI3["实例C: :8083"]
end
```
| 对比维度 | 客户端负载均衡 | 服务端负载均衡 |
|---------|--------------|--------------|
| 部署位置 | 与消费者同进程(如 Spring Cloud LoadBalancer) | 独立中间件(Nginx、LVS、F5) |
| 延迟 | 低(多一次网络跳) | 高(所有请求必经 LB 节点) |
| 故障隔离 | 好(失败不影响其他调用链路) | 差(LB 单点故障影响全部) |
| 语言耦合 | 需要为每种语言实现 SDK | 协议无关,通用性强 |
| 适用场景 | JVM 微服务体系 | 跨语言 / 流量入口层 |
> [!TIP]
> 面试高频:Netflix 为什么选择客户端负载均衡?因为他们的服务都是 Java,可以用 Ribbon(现已被 Spring Cloud LoadBalancer 替代)嵌入每个微服务,避免了反向代理成为瓶颈和单点故障。
### 轮询法(Round Robin)
最简单的策略:按顺序依次分发请求。适用于各实例配置相近的场景。
- **简单轮询**:`index = (index + 1) % n`,第 i 个请求发给第 i mod n 个实例。
- **加权轮询**(Weighted Round Robin):为每个实例分配权重 wᵢ,权重高的获得更多请求。Nginx 的 smooth weighted round robin 用 `current_weight += weight`,每次选 current_weight 最大的,然后 `selected.weight -= total_weight`,保证长期均衡。
### 随机法
从健康实例列表中均匀随机选择一个。理论上 N 次请求后分布趋近均匀,但小样本下可能极端不均——例如连续两次命中同一个实例。加权随机在电商大促中常用:给大容量实例更高权重。
### 一致性哈希(Consistent Hashing)
传统哈希 `hash(instance) % n` 的问题在于实例增减时会导致大量请求重新路由(缓存穿透式抖动)。一致性哈希将其缓解到仅影响 `(1/n)` 的数据。
**核心思路**:将 hash 值空间映射到环上(0 ~ 2³²),服务和请求都 hash 到环上的同一点,请求顺时针找到最近的实例。
**虚拟节点解决数据倾斜**:一个物理实例对应环上的 k 个虚拟节点(k 通常取 100~200),使实例分布更均匀。
```mermaid
graph LR
H1[hash key: user_42] -->|"顺时针"| I3[实例C]
H2[hash key: user_99] -->|"顺时针"| I1[实例A]
H3[hash key: session_7] -->|"顺时针"| I2[实例B]
subgraph Ring["哈希环"]
VN1(VN-a1) --- VN2(VN-a2)
VN1 ---|"虚节" |VN2
VN3(VN-b1) --- VN4(VN-b2)
VN5(VN-c1) --- VN6(VN-c2)
end
```
```go
// 一致性哈希简化实现
type ConsistentHash struct {
hashes []uint64 // 虚拟节点的 hash 值排序
map map[uint64]int // hash -> 物理实例索引
k int // 每个实例的虚拟节点数
}
func NewConsistentHash(instances []string, k int) *ConsistentHash {
ch := &ConsistentHash{map: make(map[uint64]int), k: k}
for i, inst := range instances {
for v := 0; v < k; v++ {
h := murmur3.Sum64([]byte(fmt.Sprintf("%s-%d", inst, v)))
ch.hashes = append(ch.hashes, h)
ch.map[h] = i
}
}
sort.Slice(ch.hashes, func(i, j int) bool { return ch.hashes[i] < ch.hashes[j] })
return ch
}
func (ch *ConsistentHash) Get(key string) string {
h := murmur3.Sum64([]byte(key))
idx := sort.Search(len(ch.hashes), func(i int) bool {
return ch.hashes[i] >= h
})
if idx == len(ch.hashes) {
idx = 0 // 回到环首
}
return InstanceList[ch.map[ch.hashes[idx]]]
}
```
### 最少连接数法(Least Connections)
将请求发给当前活跃连接最少的实例,特别适合长连接场景(gRPC、WebSocket)。实现上需维护每个实例的活跃连接计数器,并发安全地读写。
### Spring Cloud LoadBalancer 实现原理
Spring Cloud LoadBalancer 取代了已停止维护的 Ribbon,核心机制:
1. **ServiceInstanceListSupplier**:从注册中心拉取实例列表并缓存。
2. **Reactively reactive**:基于 Reactor 响应式编程,返回 `Flux<ServiceInstance>`。
3. **RoundRobinLocator**:默认轮询策略,内部用 AtomicLong 做递增索引取模。
```java
// Spring Cloud LoadBalancer 核心接口
public interface LoadBalancerClient<S extends ServiceInstance> {
<T> Mono<T> execute(String serviceId, LoadBalancerRequest<T>, InstanceChooser);
ServiceInstance choose(String serviceId);
<T> Flux<T> getLazyLoadBalancerClient(String serviceId);
}
```
> [!NOTE]
> Spring Cloud LoadBalancer 不提供重试和熔断,这些能力由 Resilience4j 等库处理。它只管"选哪个实例"这一个动作。
## 代码示例
Go 中的加权轮询实现:
```go
type WeightedRR struct {
servers []*Server
totalW int
curIndex int
}
type Server struct {
Addr string
Weight int
CurWeight int
}
func (w *WeightedRR) Next() string {
maxW, best := -1, ""
for _, s := range w.servers {
s.CurWeight += s.Weight
if s.CurWeight > maxW {
maxW = s.CurWeight
best = s.Addr
}
}
for _, s := range w.servers {
s.CurWeight -= w.totalW
}
return best
}
```
## 实践场景
**秋招高频问题:**
- "一致性哈希为什么能减少数据迁移?" — 只有失效实例及其顺时针下一个实例之间的数据段会迁移。移除一个实例只影响其相邻的两个虚拟节点范围,而非全部数据。
- "什么时候不能用轮询?" — 实例规格差异大(如混部有强机和弱机),必须加权;或者存在长连接状态绑定(如 WebSocket 会话),需要一致性哈希来确保同一用户落到同一实例。
- "Spring Cloud 和 Nginx 的负载均衡有什么区别?" — Spring Cloud 在应用层做选择,感知注册中心变更(秒级);Nginx 依赖 upstream 配置或 DNS 解析更新,变更延迟较大。
> [!WARNING]
> 一致性哈希不适合频繁增删实例的场景。如果实例每几分钟就变一次,virtual nodes 数量要调得更大(500+)才能维持稳定性,这增加了内存开销。
## 关联笔记
- [[服务注册与发现]]
- [[限流熔断降级]]
@@ -0,0 +1,198 @@
---
tags: [arch/microservice, config-center, apollo, nacos-config, hot-update, distributed-config]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# 配置中心设计
## 概述
配置中心解决了微服务架构中"配置散落在各处导致难以统一管理"的问题。本文将深入对比 Apollo 和 Nacos Config 两大主流方案的设计差异,剖析配置热更新的实现原理、版本管理回滚机制,以及长轮询如何做到秒级推送。
## 核心原理
### Apollo — 三级 Namespace 模型
Apollo(携程开源)的配置模型采用**分环境/应用/集群**三层结构:
```mermaid
graph TD
Env["Environment<br/>dev/test/prod"] --> AppA["App A"]
Env --> AppB["App B"]
AppA --> Cluster1["Cluster: DEFAULT"]
AppA --> Cluster2["Cluster: mq-cluster"]
Cluster1 --> NS1["Namespace: application"]
Cluster1 --> NS2["Namespace: db.properties"]
Cluster2 --> NS3["Namespace: mq.json"]
```
| 层级 | 粒度 | 示例值 | 作用 |
|------|-----|--------|------|
| Environment | 部署环境 | dev, test, staging, prod | 隔离不同环境的配置 |
| Application | 业务应用 | user-service, order-service | 每个微服务独立一份配置 |
| Cluster | 逻辑分组 | DEFAULT, mq-cluster | 同一应用的多个实例组共用一份配置 |
| Namespace | 配置类型 | application, redis.json, mysql.yaml | 按功能域拆分配置文件 |
**灰度发布流程:**
1. 编辑配置后进入"审核"状态,不生效。
2. 审核通过后发布为 `Release`,Apollo Client 在下一个拉取周期(默认 5s)发现新版本并热加载。
3. 灰度规则可指定特定 IP 先接收新配置,验证无误后再全量发布。
> [!TIP]
> Apollo 的 namespace 是强类型概念——每个 namespace 有独立的修改权限和审批流。例如 `db.properties` 只能由 DBA 团队修改,`mq.json` 由消息队列团队修改。
### Nacos Config — 长轮询推送
Nacos Config 的核心优势在于**秒级推送**,底层基于 HTTP 长轮询(Long Polling):
```mermaid
sequenceDiagram
participant Client as Apollo Client / SDK
participant Server as Apollo Config Service
participant Admin as 运维控制台
Admin->>Server: 发布配置 Release
Note over Server: 记录 releaseKey (version)
loop 每 5 秒
Client->>Server: GET /notifications/v2?key=user-service&ip=10.0.0.1
Server-->>Client: empty response (无变更)
end
Admin->>Server: 修改并发布配置
Server->>Server: 生成新的 releaseKey
loop 检测到 releaseKey 变化
Client->>Server: GET /notifications/v2?...
Server-->>Client: [{"key":"user-service","notificationId":42}]
Note over Client: 收到通知,立即拉取最新配置
Client->>Server: GET /configs/user-service?group=DEFAULT_GROUP
Server-->>Client: {key:"db.url", value:"jdbc:mysql://new"}
Client->>Client: 触发 ConfigChange 事件回调
end
```
**长轮询 vs 短轮询的权衡:**
| 方式 | 延迟 | 服务器压力 | 实现复杂度 |
|------|-----|-----------|-----------|
| 短轮询(固定间隔) | 高(等于轮询间隔) | 低(每次请求返回空或数据) | 简单 |
| 长轮询 | 低(秒级) | 中(连接保持期间占用内存) | 中等(需维护挂起列表) |
| WebSocket | 最低(毫秒级) | 中 | 复杂(跨 NAT/防火墙困难) |
| MQTT/publish-subscribe | 最低 | 低 | 中 |
Nacos 的长轮询实现要点:
- 客户端发起一个 HTTP 请求后,服务端将其加入 `waitList`(Map<String, Future>),挂起不返回。
- 当配置发生变化时,遍历 `waitList`,找到匹配的监听者,写入 notificationId 后立即返回。
- 如果 30 秒内无变更,服务端超时返回,客户端重新发起新的长轮询请求(防止连接无限期挂起)。
### 配置热更新实现原理
配置变更后如何实时通知到 running 的应用?常见方案:
**1. Java BeanPostProcessor + 反射**
Spring 容器启动时通过 `BeanDefinitionRegistryPostProcessor` 扫描带 `@Value` 注解的字段,注入时通过反射直接修改对象的私有字段值。Apollo 客户端还封装了 `ApolloAnnotationPostProcessor` 来处理 `@ApolloConfigChangeListener`。
**2. Proxy 动态代理**
对于 getter/setter 模式的属性访问,可以使用 CGLIB 或 JDK 动态代理,将 getter 方法拦截并在每次调用时检查缓存是否过期,若已过期则从配置中心拉取最新值。
**3. ConfigurationProperties + RelaxedBinding**
Spring Boot 的 `@ConfigurationProperties` 配合 Binder 类,可以在运行时重建绑定映射。Apollo 的做法是将原始配置缓存为一个 `Properties` 对象,变更时合并旧值+新值,然后通过 Spring 的事件机制通知所有注册的 `EventListener`。
```go
// Go 中的配置热更新 - 反射模式简化示例
func hotReload(config *Config, key string, newValue interface{}) error {
v := reflect.ValueOf(config).Elem()
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
tag := t.Field(i).Tag.Get("apollo")
if tag == key && field.CanSet() {
// 解析 JSON/YAML 并赋值
data := []byte(fmt.Sprintf("%v", newValue))
json.Unmarshal(data, field.Addr().Interface())
return nil
}
}
return fmt.Errorf("key %s not found", key)
}
```
> [!WARNING]
> 反射修改 final 字段需要 JVM flag `--add-opens java.base/java.lang=ALL-UNNAMED`,在生产环境中可能受限。更安全的方式是使用 volatile 引用变量指向一个新的 immutable 配置对象。
### 配置版本管理和回滚
Apollo 的发布记录保存在 `Release` 表中,每次发布都保留完整的配置快照:
```mermaid
graph LR
R1[Release V1<br/>db.url=old] -->|"新增 key"| R2[Release V2<br/>db.url=old,<br/>cache.enabled=true]
R2 -->|"修改 value"| R3[Release V3<br/>db.url=new,<br/>cache.enabled=true]
R3 -->|"删除 key"| R4[Release V4<br/>db.url=new]
R3 -. "回滚到 V2" .-> R2b[Release V5 (回滚)<br/>db.url=old,<br/>cache.enabled=true]
```
回滚的本质不是真正的"倒退版本",而是用当前最新状态 + 差异计算生成一个新版本。这样保证了发布记录永远单调递增,不会产生环形依赖。
Nacos Config 的灰度规则同样支持配置回滚到任意历史版本,且支持多环境一键同步(将 test 环境的正确配置一键推送到 prod)。
## 代码示例
Go 中使用 Apollo Client 订阅配置变更:
```go
// Apollo 客户端订阅配置变更
client, _ := apollo.NewClient(apollo.Config{
Apis: "http://127.0.0.1:8080",
AppID: "user-service",
Cluster: "DEFAULT",
Namespace: "application",
})
client.AddListener(func(namespace string, change *apollo.ConfigChange) {
for k, newVal := range change.ChangedKeys {
oldVal := change.OldValue(k)
log.Printf("config changed: %s = %s -> %s", k, oldVal, newVal)
// 此处刷新本地配置或重连数据库
}
})
```
Java 中使用 @RefreshScope:
```java
@RestController
@RefreshScope // Spring Cloud 自动刷新生成代理 Bean
public class UserController {
@Value("${greeting.message:Hello}")
private String greeting;
@GetMapping("/hello")
public String hello() {
return greeting; // 配置变更后下一次请求即生效
}
}
```
## 实践场景
**秋招高频问题:**
- "为什么 Nacos Config 用长轮询而不是 WebSocket?" — 长轮询兼容所有 HTTP 中间件(网关、CDN、负载均衡器),而 WebSocket 需要全链路升级。在微服务架构中,请求路径上通常有 Spring Cloud Gateway 等 HTTP-only 代理,WebSocket 升级失败会导致连接断开。
- "Apollo 的 Namespace 和 K8s ConfigMap 的区别是什么?" — Namespace 是 Apollo 的顶层配置类型,支持更细粒度的权限控制、版本历史和灰度发布;ConfigMap 是 K8s 的原生资源,只支持键值对映射且更新后需要手动重启 Pod 或使用 sidecar 注入 reload。
- "热更新最大的风险是什么?" — 配置变更导致服务行为突变。Apollo 的解决方案是"审核+灰度",先让少量实例生效观察指标;最大风险是无法回滚的脏数据写入。因此建议所有关键配置变更都做审计日志记录。
> [!NOTE]
> 生产最佳实践:核心配置(如数据库连接串、密钥)变更应该走独立的审批流程,不能与常规业务配置混在一起。可以用 Apollo 的不同 Namespace 来做物理隔离。
## 关联笔记
- [[服务注册与发现]]
- [[限流熔断降级]]
@@ -0,0 +1,188 @@
---
tags: [arch/microservice, rate-limiting, circuit-breaker, sentinel, resilience4j, token-bucket, leaky-bucket]
create time: 2026-08-08 18:00
update time: 2026-08-08 18:00
---
# 限流熔断降级
## 概述
限流、熔断、降级是微服务稳定性的三道防线。限流保护系统不被过量请求打垮,熔断在下游故障时快速失败避免级联雪崩,降级在服务不可用时返回兜底数据。三者层层递进,构成完整的容错体系。
## 核心原理
### 限流算法对比
```mermaid
graph LR
subgraph Counter["计数器限流"]
A[请求到达] --> B{本窗口内计数 < 阈值?}
B -- 是 --> C[允许通过]
B -- 否 --> D[拒绝]
end
subgraph TokenBucket["令牌桶"]
E[请求到达] --> F{桶中有足够令牌?}
F -- 是 --> G[消费令牌放行]
F -- 否 --> H[拒绝/等待]
I[匀速生成器] -->|"每秒 N 个"| J[(令牌桶)]
end
subgraph LeakyBucket["漏桶"]
K[请求到达] --> L[(漏桶)]
L --> M[固定速率流出处理]
L -->|"溢出"| N[拒绝]
end
```
| 算法 | 核心机制 | 突发能力 | 延迟特性 | 适用场景 |
|------|---------|---------|---------|---------|
| 滑动窗口计数器 | 时间窗内计数,超过阈值则拒绝 | 差(窗口切换时有"双倍突发") | 低开销 | 简单 API QPS 限制 |
| 令牌桶 | 以恒定速率向桶中添加令牌,请求需消耗令牌后才能通过 | 强(桶满时可突发消费存量令牌) | 中 | CDN 加速、消息队列限速 |
| 漏桶 | 请求进入桶中,以固定速率流出处理 | 无(强制匀速) | 高(有排队延迟) | 需要平滑流量的网关层 |
**滑动窗口的改进版——Leak Bucket vs Token Bucket:**
- 令牌桶适合保护生产者:允许突发流量,但长期受限于平均速率。
- 漏桶适合保护消费者:无论输入多猛,输出永远匀速,但延迟不确定。
> [!NOTE]
> Redisson 的限流器使用令牌桶算法实现 `rate(10, RateInterval.SECONDS, RateLimitRate.SEGMENTED)`,支持分布式环境下跨节点的精确计数。
### 熔断三态转换
```mermaid
stateDiagram-v2
[*] --> Closed: 初始状态
Closed --> Open: 失败率超过阈值 (如 >50%)
Open --> HalfOpen: 等待探测期超时 (如 30s)
HalfOpen --> Closed: 探测请求全部成功
HalfOpen --> Open: 探测请求仍有失败
Open --> Closed: 直接硬切回 (应急手段)
state Closed {
[*] --> Monitoring: 持续统计失败比例
}
state Open {
[*] --> RejectAll: 所有请求直接拒绝
}
state HalfOpen {
[*] --> ProbeLimited: 有限探针进入
}
```
**各状态的详细行为:**
| 状态 | 请求处理方式 | 统计维度 | 自动恢复? |
|------|------------|---------|----------|
| Closed(关闭) | 正常转发 | 统计最近 N 秒的请求成功/失败率 | 当失败率低于阈值且连续 M 次成功 |
| Open(打开) | 直接拒绝(FastFail),不调用下游 | 不统计(因为根本不发请求) | 等待 ProbeTimeout 进入 HalfOpen |
| HalfOpen(半开) | 发出少量探测请求(通常 1~3 个) | 只统计这几次探测结果 | 全成功 → Closed;任一失败 → Open |
**半开探测策略:**
- 探测请求数量通常是固定的(如 Sentinel 默认 1 个),而非渐进式增加(像 Hystrix 那样)。
- 探测期间原有业务请求仍然被熔断拦截,避免探测还没结束就压力暴增。
- 如果探测成功立即转入 Closed,否则重新进入 Open 并重置倒计时。
### Sentinel — 规则推送 + 指标统计
阿里巴巴开源的流量控制组件,核心特性:
1. **双链路指标采集**:滑动时间窗口 + 并发线程数两种维度。
2. **规则动态推送**:支持本地文件、Apollo、Nacos 等多种数据源,规则变更无需重启。
3. **多级限流粒度**:API 级别、方法级别、URL 级别、资源级别。
4. **系统自适应保护**:根据 Load、CPU 使用率、总 RPS 等自动调整限流阈值。
```go
// Sentinel Go SDK - 规则定义
import "github.com/alibaba/sentinel-golang/api"
import "github.com/alibaba/sentinel-golang/core/slot"
rule := &flow.Rule{
Resource: "user-query",
ThresholdType: flow.QPS,
Threshold: 100,
ControlBehavior: flow.Reject,
}
api.AddFlowRules([]*flow.Rule{rule})
```
### Resilience4j — 函数式 API
与 Spring Cloud CircuitBreaker 深度集成,采用装饰器模式:
```java
// Resilience4j 熔断器 + 重试组合
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofMillis(1000))
.slidingWindowSize(10)
.build();
io.github.resilience4j.circuitbreaker.CircuitBreaker cb =
CircuitBreaker.of("user-service", config);
Retry retry = Retry.of("user-service", RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(100))
.build());
Decorators.ofSupplier(() -> userService.getUser(id))
.retry(retry)
.circuitBreaker(cb)
.fallback(e -> new User(-1, "default"))
.get();
```
> [!TIP]
> 面试常考点:Resilience4j 和 Hystrix 的核心区别?Hystrix 基于线程池隔离(重),Resilience4j 基于信号量隔离(轻),且两者都是函数式设计而非继承 Thread。
### 降级的兜底思路
| 降级方式 | 示例 | 适合场景 |
|---------|------|---------|
| 返回缓存 | 用户信息从 DB 查不到时返回 Redis 旧值 | 对实时性要求不高的读接口 |
| 返回默认值 | 推荐接口超时返回热门商品列表 | 非核心业务流程 |
| 折叠请求 | 多个相同请求合并为一个真实请求 | 热点 Key 防护 |
| Mock 数据 | 开发环境/预发布时直接返回预设 JSON | CI/CD 阶段 |
## 代码示例
Go 中使用信号量实现简单的并发限流:
```go
type SemaphoreLimit struct {
sem chan struct{}
}
func NewSemaphoreLimit(maxConcurrent int) *SemaphoreLimit {
return &SemaphoreLimit{sem: make(chan struct{}, maxConcurrent)}
}
func (s *SemaphoreLimit) Allow() bool {
select {
case s.sem <- struct{}{}:
defer func() { <-s.sem }()
return true // 放行
default:
return false // 拒绝
}
}
```
## 实践场景
**秋招高频问题:**
- "Sentinel 的滑动窗口和传统的固定窗口有什么区别?" — 固定窗口在边界处会漏算(上一个窗口的尾巴 + 下一个窗口的头同时超限);Sentinel 将窗口细分为更小的 tick,在每个 tick 内独立计数再聚合,精度由 tickSize 决定。
- "为什么熔断要从 Open 到 HalfOpen 而不是直接回 Closed?" — HalfOpen 是一个安全阀:如果下游还在恢复中,直接进入 Closed 会让流量瞬间全部灌入,可能导致二次雪崩。有限的探测请求可以验证下游是否真的恢复了。
- "令牌桶和漏桶选型建议?" — 网关层对外暴露 API 时用令牌桶(允许客户端合理突发);内部服务间调用时用漏桶(保护消费方不被打爆)。
> [!WARNING]
> 不要把限流的阈值设得太激进。建议先观察线上 P99/RPS 基线,然后设为基线的 1.5~2 倍作为第一道防线,配合监控告警逐步调优。
## 关联笔记
- [[服务注册与发现]]
- [[负载均衡算法]]
- [[配置中心设计]]