A 怎么找到 B: 注册是带 TTL 的租约, 发现靠本地缓存兜底, 配置要能热更还能一键回滚
服务发现就是公司的"前台 + 通讯录": Provider 上班先登记 (注册 = 带 TTL 的租约), Consumer 打电话前先查通讯录 (发现 = 拉列表 + Watch 广播), 通讯录还贴心地留了离线副本 (本地快照)。核心纪律只有一条: 通讯录丢了不能全公司停摆——注册中心必须是"货梯"而不是"承重墙"。
配置中心是同一层思想的另一半: 公告栏上的通知 (Watch 热更) 必须能撕下来 (版本化回滚), 否则一次改错的超时参数, 就能在凌晨演变成全站雪崩。
nslookup order-svc.default.svc.cluster.local # 关键: K8s 里"发现"就是一次 DNS 查询, Service 名即域名 # → Address: 10.96.12.33 (ClusterIP, 由 kube-proxy 转发)
curl -s http://consul:8500/v1/catalog/service/order # 关键: 注册中心只存"谁活着", 不参与业务流量 # → [{"Node":"n1","ServiceAddress":"10.0.4.7","ServicePort":8080}]
resp, _ := cli.Grant(ctx, 15) // etcd: 15s TTL 租约 cli.KeepAlive(ctx, resp.ID) // 心跳自动续期 // 关键: 临时实例 = 租约, kill -9 后 15s 实例自动消失 // → 不留僵尸, 不需要人工摘除
List<ServiceInstance> list = discoveryClient.getInstances("order-svc"); // 关键: 客户端发现 = 拉列表 + 本地 LB; K8s Service = 服务端发现 // → 前者故障排查在客户端, 后者排查在 kube-proxy/CLB
dig +short order-svc-headless.default.svc.cluster.local # 关键: Headless 不分 ClusterIP, DNS 直接给出全部 Pod IP # → 10.0.4.7 ; 10.0.4.9 (客户端直连 Pod, 少一跳)
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 是增量推送, 配置热更不靠轮询
@RefreshScope 重建 bean。代价: 配置成了生产变更, 必须版本化可回滚。 configService.addListener("pay-timeout", "DEFAULT_GROUP", new Listener() { public void receiveConfigInfo(String config) { timeout = Integer.parseInt(config); // 关键: 推送到达即刷新, 不重启 } public Executor getExecutor() { return null; } });
# Eureka 自我保护: 心跳数骤降时冻结摘除, 宁可给旧列表 eureka.server.enable-self-preservation=true # 关键: AP 的取舍 = 接受短暂旧数据, 换取分区期间可用
lifecycle:
preStop:
exec: { command: ["sh", "-c", "sleep 20"] }
# 关键: 先 sleep 等 Endpoint 摘除生效, 再收 SIGTERM 排水service {
name = "order"
port = 8080
check { tcp = "10.0.4.7:8080" interval = "10s" } # 关键: 直探端口, 不只探进程
}spring.cloud.nacos.discovery.namespace=prod-ns-id spring.cloud.nacos.discovery.group=PAYMENT_GROUP # 关键: namespace 隔离环境, group 隔离产品线, 配错=跨环境调用
curl -s "$NACOS/v1/cs/history?search=accurate&dataId=pay-timeout&pageNo=1" # 关键: Nacos 自带配置历史, 上一版可一键恢复 # → 演练目标: 改崩到恢复 < 60s
上 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 自动完成。
只探进程活着的检查拦不住僵尸实例。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 秒以内。
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 直接拦截, 不允许进入公共命名空间。
全量推配置等于全量发版。Nacos 按实例圈灰度, 配合 Spring 的 @RefreshScope, 单条超时参数也能走完"灰度→观察→全量"流程。
@RefreshScope @RestController class PayController { @Value("${pay.timeout:3000}") private int timeout; # 推送到达后 bean 自动重建, 不重启 } # 发布顺序: Nacos 控制台 beta 推送到 2 台 (按 IP 圈定) # → 观察 5 分钟: 该 2 台 P99 与错误率无异常 → 点"发布"全量 # 关键: 灰度必须按实例分组, 否则同一服务两种行为混跑
超时类参数的变更从"发布窗口才能改"变成随时可改, 回滚也只要再点一次。
注册中心失联是常态不是异常。自研发现的铁律: 调用永远只读本地缓存, 后台线程负责刷新, 刷不到就用快照顶住。
// 启动: 先读磁盘快照, 再连注册中心 — 挂了也能启动 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%, 调用方完全无感。
换注册中心最忌一步切换。双注册让同一应用同时出现在两套中心, 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, 秒级回退。
直接 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"
发布窗口的用户报障从每次十几个降到零, 该脚本成了所有应用下线的标准前置。
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 后同类问题自动摘除, 无人再值守。
上 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 行, 摘流策略变更不再需要发版。
配置热更是生产变更, 必须演练回滚。预案: 把发布/回滚命令写成脚本, 每季度在预发跑一次真实演练并计时。
# 回滚 = 把上一版内容原样再发布一次 (命令提前录好) 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 记入值班手册
演练暴露了"回滚命令没人对得出参数签名"的问题——真正事故前修好了它。
// 错: 每次调用都现查注册中心, 它一挂全停 // 对: 后台每 30s 刷缓存 + 落盘快照, 失联时继续用旧列表
# 错: check 只看进程: kill -0 $pid → 心跳一直 OK, 坏实例永不被摘 # 对: check tcp: 10.0.4.7:8080, interval 10s → 端口不通自动摘除
# 错: kill -9 $pid # → 注册表 30s 内还有这台, 流量照打 # 对: 先 PUT maintenance?enable=true → sleep 30 → kill -TERM $pid
addListener, 监听器只增不减. 正解: 启动时注册一次, 回调内只做幂等 apply。 // 错: 每次处理请求都 configService.addListener(...) // → 监听器越积越多 // 对: @PostConstruct 注册一次; Listener 内只做幂等的 apply()
# 错: 3000 实例 × 每 3s 全量拉取 → 注册中心出口带宽打满 # 对: eureka.client.registry-fetch-interval-seconds=30 # 再加 ±20% 抖动
namespace 为空落进公共命名空间, 两环境服务名相同于是互通. 正解: namespace 由启动参数注入 + 启动自检。 # 错: spring.cloud.nacos.discovery.namespace= # 空 → 公共空间 # 对: namespace=${NACOS_NS:dev-ns-id} # 启动打印并抽查服务名
/healthz 只探进程, DB/MQ 已挂但没有任何探针反映. 正解: readiness 探关键依赖, 分级探活。 # 错: liveness: 探 /healthz 返回 ok # → DB 挂了照样有流量进来 # 对: readiness 探 /actuator/health/readiness (含 db, redis, mq 分组)
# 错: 依赖 JVM 默认 DNS 缓存 (30s~永久, 视 SecurityManager) → 摘流不可控 # 对: -Dnetworkaddress.cache.ttl=5 显式收紧, CoreDNS 配 ttl 5s
# 错: 控制台直接改 value 覆盖保存 # → 出事找不回旧值 # 对: Nacos 历史版本一键恢复, 或配置进 git 由流水线发布
// 错: reg.Meta["payload"] = string(configJSON) // → 200KB 随心跳全网推 // 对: reg.Meta["ver"] = "2.3.1" // 大配置走配置中心, 不走注册表
# 错: 测试环境开自我保护 + 60s 摘除 # → 挂了的实例一直 UP # 对: eureka.server.enable-self-preservation=false (测试环境)
// 错: go heartbeat() // panic 一次就静默退出 → 租约过期被摘 // 对: for { if err := beat(); err != nil { reRegister() }; time.Sleep(5s) }
// 错: call() { list = discovery.getInstances(svc); pick(list); } // RT +80ms // 对: 后台定时刷新本地 list, call() 只做内存取
order-SVC 或拼错字母. 正解: 服务名进常量统一管理, 启动时连通性自检。 # 错: url: http://order-SVC/api # → K8s DNS 解析失败 # 对: url: http://order-svc/api (名字进常量/配置中心, 不散落硬编码)
HikariConfigMXBean 热更, 或改后主动重建池。 // 错: @RefreshScope 改 spring.datasource.hikari.maximum-pool-size → 池不变 // 对: HikariConfigMXBean.setMaximumPoolSize(30) // 热生效, 无需重建
# 错: 改一条配置全量推送 # → 20 台一起换行为 # 对: 按 metadata(version=beta) 圈 2 台先收新配置, 观察后全量
datasource.password 明文入库且权限粗放. 正解: 存密文 + KMS/Vault 运行时解密, 中心只下发加密值。 # 错: password: Prod@123 # → 拿到配置中心 = 拿到生产库 # 对: password: ENC(xxxx) 由 jasypt/KMS 解密, 中心只存密文
# 错: application.yml 和 Nacos 都写了 pay.timeout # → 到底谁生效? # 对: 约定: 环境变量 > Nacos > 本地 yml; 启动打印 key 的来源
// 错: watch ch 断了重连, 只等下一个事件 // → 中间变更永久丢失 // 对: 重连后先 fetch 全量对账, 再挂 watch 增量
// 错: ShutdownHook 里先关 MQ/DB 连接 // → 在途请求拿不到资源 // 对: 先 deregister+摘流量 → 排水在途 → 再关连接池和容器