vault backup: 2026-08-08 19:01:04
This commit is contained in:
@@ -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 分布式锁]]
|
||||
- [[幂等设计方案]]
|
||||
@@ -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 倍作为第一道防线,配合监控告警逐步调优。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[服务注册与发现]]
|
||||
- [[负载均衡算法]]
|
||||
- [[配置中心设计]]
|
||||
Reference in New Issue
Block a user