系统架构 · 服务发现与配置中心

A 怎么找到 B: 注册是带 TTL 的租约, 发现靠本地缓存兜底, 配置要能热更还能一键回滚

服务发现与配置中心 · 注册租约 → 发现缓存 → Watch 推送 → 动态配置热更 注册中心是货梯, 不是承重墙 — 必须能容忍它挂掉 ① 注册 ② 发现 ④ 配置热更 ③ 客户端负载均衡 · 直连实例 ip1 兜底 兜底链 双保险 现查失败 Provider 提供者 order-service :8080 启动注册 · 心跳续租 Registry 注册中心 Consul · Nacos · etcd · Eureka order-svc → [10.0.4.7, 10.0.4.9] TTL 15s · 过期自动摘除 Watch · 变更推给订阅者 Consumer 消费者 拉实例表 + Watch 订阅 本地缓存快照兜底 Config Center namespace / group 隔离 版本化 · 可回滚 · 灰度 Client-side 客户端发现 拉列表 + 本地 LB, 直连实例 Consul · Nacos · Eureka · Ribbon 少一跳, 但客户端逻辑重 Server-side 服务端发现 LB 在中间: K8s Service / 云 CLB Consumer 只认一个稳定域名 客户端轻, 多一跳 kube-proxy DNS-based 发现 order-svc.ns.svc.cluster.local 解析 A 记录 Headless 直连 Pod, CoreDNS 应答 简单, 但摘流被 DNS 缓存拖慢 事故链 · 注册中心当强依赖, 无本地快照 注册中心失联 Nacos 集群网络抖动 40s Consumer 无实例缓存 每次调用先问注册中心 拿不到列表 → 不敢调用 无快照, 无 fallback Feign 调用当场抛错 IllegalStateException: No instances available for order-service 全站调用方同时找不到下游 P1: 注册中心抖 40 秒, 全站 312 个调用方集体找不到下游 正确路径 · 快照兜底 + 优雅摘流 + 探活到端口 本地实例快照兜底 内存 + 磁盘 naming.json 注册中心挂了照样启动、调用 已知实例继续轮询 order → 10.0.4.7, 10.0.4.9 坏实例靠超时/熔断剔除 优雅上下线 反注册 → 排水 20s → SIGTERM 在途请求处理完才退 僵尸实例防线 TCP check 直探端口, 10s 间隔 心跳在 ≠ 端口活 Consul: 快照落盘; Nacos: 订阅失败降级轮询 — 故障半径 ≈ 0 rose = 事故路径: 把注册中心当强依赖, 它一抖全站瘫痪 emerald = 正确路径: 快照兜底 + 端口级探活 + 优雅摘流, 挂而不倒 读法: 上链 = 注册→发现→调用的完整租约生命周期 (含配置中心); 中排 = 三种发现模式; 下排 = 注册中心宕机瞬间的两种命运

机制视角 — 注册发现全链路

  • • 注册 = 带 TTL 的租约: ephemeral 实例靠心跳续租, 进程暴毙自动摘除
  • • 发现 = 启动全量拉一次 + Watch 增量推送, 本地缓存 + 快照兜底
  • • 三种模式: 客户端发现 / 服务端发现 (K8s Service) / DNS-based
  • • Mesh 的 sidecar 把注册发现 LB 全部下沉, 应用只管本机通信

行为视角 — 挂掉的时候

  • • 注册中心宕机 30s 内: 有快照的继续调, 没快照的全站 503
  • • 僵尸实例: 心跳还在但业务端口死了, 探活必须探到端口级
  • • 优雅下线 = 先摘流量 → 排水在途请求 → 再关进程, 顺序反了必超时
  • • Watch 断线重连必须补一次全量对账, 否则丢事件

生产价值 — 治理清单

  • • namespace 隔离环境, group 隔离产品线, 配错 = 跨环境调用
  • • 配置 = 版本化 + 按实例分组灰度 + 一键回滚, 改崩 30 秒退回
  • • 迁移注册中心走双注册过渡, 读流量切干净再下线旧中心
  • • 服务名/配置 key 进常量统一管理, 不散落硬编码

💡 一句话理解

服务发现就是公司的"前台 + 通讯录": Provider 上班先登记 (注册 = 带 TTL 的租约), Consumer 打电话前先查通讯录 (发现 = 拉列表 + Watch 广播), 通讯录还贴心地留了离线副本 (本地快照)。核心纪律只有一条: 通讯录丢了不能全公司停摆——注册中心必须是"货梯"而不是"承重墙"。

配置中心是同一层思想的另一半: 公告栏上的通知 (Watch 热更) 必须能撕下来 (版本化回滚), 否则一次改错的超时参数, 就能在凌晨演变成全站雪崩。

🧠 必知必会 必考 & 必会

Service Discovery 服务发现
调用方根据"逻辑名"找到可用网络地址的机制。K8s 把它做成了默认能力: Service 名就是域名, 解析即发现, 连 SDK 都不用引。
nslookup order-svc.default.svc.cluster.local
# 关键: K8s 里"发现"就是一次 DNS 查询, Service 名即域名
# → Address: 10.96.12.33 (ClusterIP, 由 kube-proxy 转发)
Registry 注册中心
存储"谁活着"的组件 (服务实例表)。它只做元数据不碰业务流量, 所以设计目标不是性能而是一致性与可用性的取舍 (AP 还是 CP)。
curl -s http://consul:8500/v1/catalog/service/order
# 关键: 注册中心只存"谁活着", 不参与业务流量
# → [{"Node":"n1","ServiceAddress":"10.0.4.7","ServicePort":8080}]
Lease / Ephemeral 租约注册
实例注册时带 TTL, 心跳续租; 进程死了租约到点自动失效。这是注册中心能"自愈"的关键——不依赖任何人记得来注销。
resp, _ := cli.Grant(ctx, 15)  // etcd: 15s TTL 租约
cli.KeepAlive(ctx, resp.ID)     // 心跳自动续期
// 关键: 临时实例 = 租约, kill -9 后 15s 实例自动消失
// → 不留僵尸, 不需要人工摘除
Client-side vs Server-side Discovery
客户端发现: 自己拉列表自己做负载均衡, 少一跳但 SDK 重; 服务端发现: 打到统一的 VIP/域名, LB 在中间层, 客户端轻。K8s Service 是后者的标准实现。
List<ServiceInstance> list = discoveryClient.getInstances("order-svc");
// 关键: 客户端发现 = 拉列表 + 本地 LB; K8s Service = 服务端发现
// → 前者故障排查在客户端, 后者排查在 kube-proxy/CLB
DNS-based + Headless Service
用 DNS 记录做发现: 普通 Service 解析到 ClusterIP, Headless Service 直接解析出全部 Pod IP。优点是零依赖, 缺点是摘流受 DNS 缓存拖累。
dig +short order-svc-headless.default.svc.cluster.local
# 关键: Headless 不分 ClusterIP, DNS 直接给出全部 Pod IP
# → 10.0.4.7 ; 10.0.4.9 (客户端直连 Pod, 少一跳)
Watch 变更推送
客户端在注册/配置中心挂一个监听, 变更发生时服务端推增量。它取代轮询: 实时性接近全量拉取, 开销却只有推送瞬间; 代价是要处理断线与事件丢失。
ch := cli.Watch(ctx, "config/pay/", clientv3.WithPrefix())
for w := range ch {
    for _, e := range w.Events { apply(e.Kv.Key, e.Kv.Value) }
}
// 关键: Watch 是增量推送, 配置热更不靠轮询
Config Center 动态配置
把"改一个参数要发一次版"变成"控制台改完秒级生效"。机制 = 配置存中心 + 客户端监听 + @RefreshScope 重建 bean。代价: 配置成了生产变更, 必须版本化可回滚。
configService.addListener("pay-timeout", "DEFAULT_GROUP", new Listener() {
    public void receiveConfigInfo(String config) {
        timeout = Integer.parseInt(config);  // 关键: 推送到达即刷新, 不重启
    }
    public Executor getExecutor() { return null; }
});
AP vs CP 注册中心
Eureka 选 AP: 分区时宁可返回旧列表也不拒绝服务; ZooKeeper/etcd 选 CP: 分区期间可能不可写。发现场景读多写少、旧列表危害小, 所以 AP 是主流选择。
# Eureka 自我保护: 心跳数骤降时冻结摘除, 宁可给旧列表
eureka.server.enable-self-preservation=true
# 关键: AP 的取舍 = 接受短暂旧数据, 换取分区期间可用
优雅上下线 Graceful Shutdown
下线顺序 = 反注册摘流 → 等下游缓存过期 → 排水在途请求 → 停进程。上线对偶: 就绪探针通过才接流。顺序错了, 停机瞬间必有一波 connection refused。
lifecycle:
  preStop:
    exec: { command: ["sh", "-c", "sleep 20"] }
# 关键: 先 sleep 等 Endpoint 摘除生效, 再收 SIGTERM 排水
僵尸实例 Zombie Instance
心跳正常但服务不可用的实例: 进程活着、业务线程池死锁或端口不接活。防线是健康检查直探业务端口/依赖, 不只探进程存活。
service {
  name = "order"
  port = 8080
  check { tcp = "10.0.4.7:8080" interval = "10s" }  # 关键: 直探端口, 不只探进程
}
Namespace / Group 隔离
配置与注册的"房间号": namespace 切环境 (dev/test/prod), group 切产品线。它是防止"测试调到生产"的第一道闸——配错了没有任何报错, 只有诡异的调用。
spring.cloud.nacos.discovery.namespace=prod-ns-id
spring.cloud.nacos.discovery.group=PAYMENT_GROUP
# 关键: namespace 隔离环境, group 隔离产品线, 配错=跨环境调用
配置版本化与回滚
每条配置变更必须可追溯、可回滚。配置中心的历史版本或 git 都行——回滚 = 把上一版内容原样再发布一次, 这条命令必须提前演练过。
curl -s "$NACOS/v1/cs/history?search=accurate&dataId=pay-timeout&pageNo=1"
# 关键: Nacos 自带配置历史, 上一版可一键恢复
# → 演练目标: 改崩到恢复 < 60s

🏭 生产实战 real world

场景 1 · K8s 原生发现: Service + Headless 直连 Pod 的两套配方

上 K8s 后大部分团队不需要 Consul。普通 Service 走 ClusterIP 做服务端发现; 需要"拿到全部实例"的中间件 (Kafka/RocketMQ 客户端、状态ful 服务) 用 Headless。

apiVersion: v1
kind: Service
metadata: { name: order-svc }
spec:
  selector: { app: order }
  ports: [ { port: 80, targetPort: 8080 } ]   # ClusterIP: 服务端发现
---
apiVersion: v1
kind: Service
metadata: { name: order-svc-headless }
spec:
  clusterIP: None                    # Headless: DNS 直接解析出 Pod IP 列表
  selector: { app: order }
  ports: [ { port: 8080 } ]

应用里不再引任何注册 SDK, 发现能力随平台白送, 发布摘流也由 EndpointSlice 自动完成。

场景 2 · Consul 健康检查自动摘除: TCP 直探端口 + 关键依赖探针

只探进程活着的检查拦不住僵尸实例。Consul 的 check 直探业务端口, 端口不接活 30 秒内自动摘除, 调用方无感。

curl -X PUT http://consul:8500/v1/agent/service/register -d '{
  "name": "order",
  "id": "order-1",
  "address": "10.0.4.7",
  "port": 8080,
  "check": {
    "tcp": "10.0.4.7:8080",
    "interval": "10s",
    "deregister_critical_service_after": "2m"
  }
}'
# 关键: tcp check 探端口; critical 超 2m 直接从目录注销

僵尸实例平均存活时间从 40 分钟降到 30 秒以内。

场景 3 · Nacos namespace 隔离环境: 杜绝"测试调到生产"

namespace 是环境的第一道闸。namespace 必须由启动参数注入, 不许写死在 jar 里——写死的配置终会在某次打包中带错环境。

# 生产 (deploy 传入, 不落代码库)
spring.cloud.nacos.discovery.server-addr=nacos-prod:8848
spring.cloud.nacos.discovery.namespace=${NACOS_NS}
spring.cloud.nacos.config.namespace=${NACOS_NS}
# 测试
#   NACOS_NS=dev-ns-id  → 与生产物理隔离, 名字可以相同
# 关键: 启动时打印 namespace + 随机抽查一条服务名, 错环境当场暴露

配合 CI 校验: 测试环境部署产物若缺 NACOS_NS 直接拦截, 不允许进入公共命名空间。

场景 4 · 配置热更灰度: @RefreshScope 改超时, 先放 2 台实例验证

全量推配置等于全量发版。Nacos 按实例圈灰度, 配合 Spring 的 @RefreshScope, 单条超时参数也能走完"灰度→观察→全量"流程。

@RefreshScope
@RestController
class PayController {
    @Value("${pay.timeout:3000}")
    private int timeout;   # 推送到达后 bean 自动重建, 不重启
}
# 发布顺序: Nacos 控制台 beta 推送到 2 台 (按 IP 圈定)
# → 观察 5 分钟: 该 2 台 P99 与错误率无异常 → 点"发布"全量
# 关键: 灰度必须按实例分组, 否则同一服务两种行为混跑

超时类参数的变更从"发布窗口才能改"变成随时可改, 回滚也只要再点一次。

场景 5 · 自研客户端发现: 本地缓存 + 磁盘快照兜底 (Go)

注册中心失联是常态不是异常。自研发现的铁律: 调用永远只读本地缓存, 后台线程负责刷新, 刷不到就用快照顶住。

// 启动: 先读磁盘快照, 再连注册中心 — 挂了也能启动
func (r *Registry) Init() {
    r.load("/var/lib/app/instances.json")  // 上次的实例表
    go r.refreshLoop()                     // 每 30s 全量刷新 + Watch 增量
}

func (r *Registry) refreshLoop() {
    for {
        if list, err := r.fetch(); err == nil {
            r.swap(list)                                    // 原子替换本地列表
            r.snapshot("/var/lib/app/instances.json")       // 关键: 落盘兜底
        }
        time.Sleep(30 * time.Second)
    }
}

2023 年一次机房网络抖动 6 分钟, 该服务调用成功率保持在 99.9%, 调用方完全无感。

场景 6 · 注册中心迁移: Eureka → Nacos 双注册过渡

换注册中心最忌一步切换。双注册让同一应用同时出现在两套中心, Consumer 按批次迁移读流量, 全程可回退。

# 迁移期应用: 同时注册两套中心 (过渡期 4~6 周)
eureka:
  client:
    service-url: { defaultZone: "http://eureka:8761/eureka" }
spring:
  cloud:
    nacos:
      discovery:
        server-addr: "nacos:8848"
# 关键: 先双注册 → Consumer 分批切到 Nacos 读取 → 读流干净后停 Eureka 注册

迁移期任何一批出问题, 把该批 Consumer 的读开关拨回 Eureka, 秒级回退。

场景 7 · 优雅下线三步: 反注册 → 排水 → 再停机 (正确顺序模板)

直接 kill 的代价是 30 秒的 connection refused。正确顺序是先把实例从发现结果里摘掉, 等调用方缓存过期, 再让进程排完在途请求。

# 1. 置维护态: 从发现结果摘除, 进程还在
curl -X PUT "http://consul:8500/v1/agent/service/maintenance/order-1?enable=true&reason=deploy"
# 2. 等调用方实例缓存过期 (按缓存 TTL 设, 通常 30s)
sleep 30
# 3. SIGTERM 排水: 应用收到后停止接新请求, 处理完存量再退出
kill -TERM "$PID"

发布窗口的用户报障从每次十几个降到零, 该脚本成了所有应用下线的标准前置。

场景 8 · 20% 请求超时: 一台僵尸实例的排查现场

order-service 五台实例, 恰好五分之一的请求超时。现象指向"某一台坏了但没被摘除"——经典僵尸实例。

# 发现列表里 5 台都健康:
curl -s "http://consul:8500/v1/health/service/order?passing=true" | jq '.[].Service.Address'
# → 10.0.4.7 / 10.0.4.9 / 10.0.4.11 / 10.0.4.13 / 10.0.4.7 均返回
# 逐台探端口: telnet 10.0.4.11 8080 不通, 但心跳还在
# 根因: 业务线程池死锁, 进程活着, check 只探了进程
# 修复: check 改 tcp 直探端口 + deregister_critical_service_after=2m

排查 25 分钟定位; 换成端口级 check 后同类问题自动摘除, 无人再值守。

场景 9 · Service Mesh 接管发现: Istio DestinationRule 配置级治理

上 Mesh 后应用不引 SDK, 注册发现、负载均衡、熔断摘除全部由 sidecar 接管, 治理动作从代码变成声明式配置。

apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata: { name: order-svc }
spec:
  host: order-svc
  trafficPolicy:
    loadBalancer: { simple: LEAST_REQUEST }   # 最少请求优先, 代替轮询
    outlierDetection:                        # sidecar 自动熔掉坏实例
      consecutive5xxErrors: 3
      interval: 10s
      baseEjectionTime: 30s

业务代码删掉了 Ribbon/重试逻辑约 800 行, 摘流策略变更不再需要发版。

场景 10 · 配置回滚演练: 90 秒从"改崩"到"恢复"

配置热更是生产变更, 必须演练回滚。预案: 把发布/回滚命令写成脚本, 每季度在预发跑一次真实演练并计时。

# 回滚 = 把上一版内容原样再发布一次 (命令提前录好)
curl -X POST "$NACOS/v1/cs/configs" \
  -d "dataId=pay-timeout&group=DEFAULT_GROUP&type=properties" \
  --data-urlencode "content=pay.timeout=3000"
# 演练剧本: 注入错误配置 (timeout=30ms) → 监控告警 → 执行回滚
# 验收: 5xx 率在回滚后 30s 内回落, 全程 < 90s 记入值班手册

演练暴露了"回滚命令没人对得出参数签名"的问题——真正事故前修好了它。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 注册中心重启, 全站调用跟着失败 — 症状: 注册中心发布重启 2 分钟, 全公司接口 503. 原因: Consumer 无本地实例缓存, 把注册中心当每次调用的强依赖. 正解: 本地缓存 + 磁盘快照兜底。
// 错: 每次调用都现查注册中心, 它一挂全停
// 对: 后台每 30s 刷缓存 + 落盘快照, 失联时继续用旧列表
坑 2 · 五分之一请求超时, 其余正常: 僵尸实例 — 症状: 流量稳定地只超时其中一台. 原因: 心跳线程活着但业务端口死锁, check 只探了进程. 正解: check 直探业务端口 + critical 自动注销。
# 错: check 只看进程: kill -0 $pid → 心跳一直 OK, 坏实例永不被摘
# 对: check tcp: 10.0.4.7:8080, interval 10s → 端口不通自动摘除
坑 3 · 发布瞬间大量 connection refused — 症状: 每次滚动发布, 用户侧报错持续约 30s. 原因: kill -9 直接退, 注册表里实例还在, 流量继续打入已死端口. 正解: 先反注册/置维护态, 等缓存过期再停机。
# 错: kill -9 $pid                # → 注册表 30s 内还有这台, 流量照打
# 对: 先 PUT maintenance?enable=true → sleep 30 → kill -TERM $pid
坑 4 · 跑了 3 天, 同一条配置收到 100 次回调 — 症状: 配置回调日志重复爆炸, 甚至 OOM. 原因: 在请求路径里反复 addListener, 监听器只增不减. 正解: 启动时注册一次, 回调内只做幂等 apply。
// 错: 每次处理请求都 configService.addListener(...) // → 监听器越积越多
// 对: @PostConstruct 注册一次; Listener 内只做幂等的 apply()
坑 5 · 注册中心出口带宽打满, 秒级卡顿 — 症状: 注册中心 RPC 延迟飙高, 所有客户端跟着抖. 原因: 数千实例每 3s 全量拉一次, 拉取风暴挤爆带宽. 正解: Watch 增量推送 + 拉取间隔错峰抖动。
# 错: 3000 实例 × 每 3s 全量拉取 → 注册中心出口带宽打满
# 对: eureka.client.registry-fetch-interval-seconds=30 # 再加 ±20% 抖动
坑 6 · 测试环境调到了生产服务 — 症状: 测试数据"莫名"出现在生产订单里. 原因: 发布模板变量没替换, namespace 为空落进公共命名空间, 两环境服务名相同于是互通. 正解: namespace 由启动参数注入 + 启动自检。
# 错: spring.cloud.nacos.discovery.namespace=           # 空 → 公共空间
# 对: namespace=${NACOS_NS:dev-ns-id}  # 启动打印并抽查服务名
坑 7 · 实例全绿, 业务全 500 — 症状: 健康检查全部通过, 接口却大面积报错. 原因: /healthz 只探进程, DB/MQ 已挂但没有任何探针反映. 正解: readiness 探关键依赖, 分级探活。
# 错: liveness: 探 /healthz 返回 ok     # → DB 挂了照样有流量进来
# 对: readiness 探 /actuator/health/readiness (含 db, redis, mq 分组)
坑 8 · 摘除实例 5 分钟后还有流量进来 — 症状: 已下线的机器持续收到请求. 原因: JVM/系统级 DNS 缓存或解析器 TTL 过长, DNS-based 发现被缓存拖累. 正解: 显式收紧 TTL + 关键路径用实例列表而非域名。
# 错: 依赖 JVM 默认 DNS 缓存 (30s~永久, 视 SecurityManager) → 摘流不可控
# 对: -Dnetworkaddress.cache.ttl=5 显式收紧, CoreDNS 配 ttl 5s
坑 9 · 凌晨改崩配置, 没人记得旧值 — 症状: 超时参数改错引发雪崩, 想回滚却找不到上一个版本. 原因: 控制台直接覆盖保存, 无版本无历史. 正解: 配置版本化 (中心历史/git), 回滚命令提前演练。
# 错: 控制台直接改 value 覆盖保存          # → 出事找不回旧值
# 对: Nacos 历史版本一键恢复, 或配置进 git 由流水线发布
坑 10 · 注册元数据塞大对象, 推送越来越慢 — 症状: 注册中心内存上涨, 变更推送延迟从 100ms 涨到 2s. 原因: metadata 里塞了 200KB 的配置 JSON, 每次续约/变更全网推. 正解: 元数据只放路由要用的短标签。
// 错: reg.Meta["payload"] = string(configJSON)  // → 200KB 随心跳全网推
// 对: reg.Meta["ver"] = "2.3.1"     // 大配置走配置中心, 不走注册表
坑 11 · Eureka 自我保护把死实例一直显示 UP — 症状: 网络抖动后下线的实例长期 UP, 调用持续失败. 原因: 自我保护模式心跳数低于阈值就冻结所有摘除 (AP 取舍). 正解: 测试环境关自我保护; 生产保留但配端口级 check 兜底。
# 错: 测试环境开自我保护 + 60s 摘除  # → 挂了的实例一直 UP
# 对: eureka.server.enable-self-preservation=false (测试环境)
坑 12 · 心跳 goroutine panic 后实例悄悄消失 — 症状: 实例从列表消失但进程还活着, 重启才恢复. 原因: 心跳循环 panic 一次静默退出, 没人续租, 租约过期被摘. 正解: 心跳循环 recover + 失败自动重注册。
// 错: go heartbeat()  // panic 一次就静默退出 → 租约过期被摘
// 对: for { if err := beat(); err != nil { reRegister() }; time.Sleep(5s) }
坑 13 · 下游 P99 从 5ms 涨到 200ms: 每次调用都查注册中心 — 症状: 无任何下游变更, 调用延迟整体翻倍. 原因: 客户端发现实现成"调用前现查", 注册中心 RT 加进每个请求. 正解: 本地缓存 + 后台刷新, 调用路径零网络。
// 错: call() { list = discovery.getInstances(svc); pick(list); } // RT +80ms
// 对: 后台定时刷新本地 list, call() 只做内存取
坑 14 · 服务名大小写/拼写错, 上了 K8s 才炸 — 症状: 本地直连好好的, 一上 K8s 就解析失败. 原因: DNS 全小写且 Service 名大小写敏感, 配置里写了 order-SVC 或拼错字母. 正解: 服务名进常量统一管理, 启动时连通性自检。
# 错: url: http://order-SVC/api        # → K8s DNS 解析失败
# 对: url: http://order-svc/api  (名字进常量/配置中心, 不散落硬编码)
坑 15 · 改了 maximum-pool-size, 连接池纹丝不动 — 症状: 配置中心把池大小从 10 改 30, 监控里还是 10. 原因: 数据源 bean 不在刷新范围, @RefreshScope 不会重建原生连接池. 正解: 用 HikariConfigMXBean 热更, 或改后主动重建池。
// 错: @RefreshScope 改 spring.datasource.hikari.maximum-pool-size → 池不变
// 对: HikariConfigMXBean.setMaximumPoolSize(30)  // 热生效, 无需重建
坑 16 · 灰度配置没圈实例, 全量一起换行为 — 症状: "灰度"一条参数, 20 台同时生效, 炸了就是全炸. 原因: beta 推送没按实例分组, 灰度名存实亡. 正解: 按实例 metadata 圈定灰度组, 观察后再全量。
# 错: 改一条配置全量推送          # → 20 台一起换行为
# 对: 按 metadata(version=beta) 圈 2 台先收新配置, 观察后全量
坑 17 · 配置中心明文存数据库密码 — 症状: 配置中心账号一泄露, 全公司生产库裸奔. 原因: datasource.password 明文入库且权限粗放. 正解: 存密文 + KMS/Vault 运行时解密, 中心只下发加密值。
# 错: password: Prod@123           # → 拿到配置中心 = 拿到生产库
# 对: password: ENC(xxxx) 由 jasypt/KMS 解密, 中心只存密文
坑 18 · 改了配置不生效: 三方同名 key 打架 — 症状: 明明在配置中心改了值, 服务行为没变. 原因: 本地 yml、配置中心、环境变量都定义了同名 key, 优先级没人说清. 正解: 约定优先级 (环境变量 > 远端 > 本地) 并在启动日志打印来源。
# 错: application.yml 和 Nacos 都写了 pay.timeout  # → 到底谁生效?
# 对: 约定: 环境变量 > Nacos > 本地 yml; 启动打印 key 的来源
坑 19 · 注册中心闪断后 Watch 丢事件, 新实例 5 分钟才被发现 — 症状: 扩容的新实例迟迟接不到流量. 原因: Watch 连接断开期间的事件没人补, 重连后只等下一条增量. 正解: 重连成功立即全量对账一次。
// 错: watch ch 断了重连, 只等下一个事件   // → 中间变更永久丢失
// 对: 重连后先 fetch 全量对账, 再挂 watch 增量
坑 20 · 优雅停机顺序反了, 在途请求集体超时 — 症状: 停机瞬间在途请求大面积超时. 原因: ShutdownHook 里先关了注册中心连接/数据库池, 但注册表还没摘, 流量仍在进入. 正解: 顺序 = 反注册摘流 → 排水 → 关资源。
// 错: ShutdownHook 里先关 MQ/DB 连接   // → 在途请求拿不到资源
// 对: 先 deregister+摘流量 → 排水在途 → 再关连接池和容器