我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [服务注册了却调不通:注册中心与心跳的 3 个时间参数](#服务注册了却调不通:注册中心与心跳的 3 个时间参数)
-
- 一、现象:控制台看得见,就是调不通
- 二、三个时间参数
-
- [参数 1:心跳间隔(heartbeat interval)](#参数 1:心跳间隔(heartbeat interval))
- [参数 2:过期时间(expiration / TTL)](#参数 2:过期时间(expiration / TTL))
- [参数 3:客户端缓存刷新间隔(registry fetch interval)](#参数 3:客户端缓存刷新间隔(registry fetch interval))
- 三、三类"注册了却调不通"
-
- [类型 1:实例已死但还在列表里(服务端没及时剔除)](#类型 1:实例已死但还在列表里(服务端没及时剔除))
- [类型 2:实例活着但调用方不知道(客户端缓存)](#类型 2:实例活着但调用方不知道(客户端缓存))
- [类型 3:健康检查通过但实际不可用](#类型 3:健康检查通过但实际不可用)
- 四、发布过程中的"流量打到已下线实例"
- 五、心跳参数怎么配
- 六、带走物
服务注册了却调不通:注册中心与心跳的 3 个时间参数
一、现象:控制台看得见,就是调不通
典型场景:
- 打开注册中心控制台,实例确实在列表里,状态显示健康;
- 但调用方报
no available instance/connection refused/ 超时; - 重启调用方或者等一会儿就好了;
- 发布过程中尤其明显,摘掉一个实例后流量还在往它打。
这类问题的根因几乎都在服务发现的时间差上------注册中心的三个时间参数没配合好。
二、三个时间参数
参数 1:心跳间隔(heartbeat interval)
服务实例定期告诉注册中心"我还活着"。
| 注册中心 | 心跳间隔 | 默认 |
|---|---|---|
| Nacos | 客户端 beatInterval |
5 秒 |
| Eureka | eureka.instance.lease-renewal-interval-in-seconds |
30 秒 |
| Consul | TTL check | 取决于配置 |
| etcd | lease TTL | 需要自己续约 |
参数 2:过期时间(expiration / TTL)
注册中心多久没收到心跳就把实例标记为不健康/剔除。
| 注册中心 | 参数 | 默认 |
|---|---|---|
| Nacos | 服务端判断(约 15 秒不健康,30 秒剔除) | 15s / 30s |
| Eureka | lease-expiration-duration-in-seconds |
90 秒 |
| etcd | lease TTL | 自己定 |
关键原则:过期时间必须显著大于心跳间隔,否则网络抖一下就误剔。
text
经验值:过期时间 ≥ 心跳间隔 × 3
参数 3:客户端缓存刷新间隔(registry fetch interval)
这是最容易被忽略的一个。 调用方不是每次调用都去问注册中心的,它本地缓存了一份服务列表,定期去拉。
| 注册中心 | 参数 | 默认 |
|---|---|---|
| Nacos | 客户端订阅推送 + 本地缓存 | 推送为主 |
| Eureka | registry-fetch-interval-seconds |
30 秒 |
| 自研/etcd | 取决于实现 | 常是 30~60 秒 |
这意味着:即使注册中心已经把坏实例摘掉了,调用方最长要等一个刷新周期才知道。
Eureka 默认 30 秒 ------ 所以"实例已经下线了但还在被调用"最长可以持续 30 秒。
三、三类"注册了却调不通"
类型 1:实例已死但还在列表里(服务端没及时剔除)
场景 :Pod 被 K8s 杀了(OOM、节点驱逐),但没有走优雅退出,注册中心没收到"下线"请求。
此时注册中心只能靠心跳超时来判断。在超时之前,这个实例还好好地待在服务列表里 ,流量会继续打过去 → connection refused。
时间账:
text
Pod 被杀
→ 心跳停止
→ 注册中心等 N 秒(Nacos 约 15s 标记不健康、30s 剔除;Eureka 90s)
→ 从列表移除
→ 调用方最长再等一个刷新周期(Eureka 30s)才感知
→ 总计可能 30~120 秒的"打向死实例"
解法:
- 必须做优雅退出:收到 SIGTERM 时先向注册中心反注册,再停止服务。这是最根本的解法------把"被动超时"变成"主动下线"。
go
func main() {
// 注册
register(serverID)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
<-sig
deregister(serverID) // 1. 先从注册中心摘掉
time.Sleep(3 * time.Second) // 2. 等调用方感知(配合刷新间隔)
server.Shutdown(ctx) // 3. 再停服务
}
- 缩短心跳间隔 + 过期时间(治标,会让注册中心压力变大)。
类型 2:实例活着但调用方不知道(客户端缓存)
场景 :新扩容了一个实例,注册中心里已经有了,但调用方的本地缓存还没刷新 → 新实例收不到流量(这倒是没故障),或者反过来------实例已经摘掉了但缓存里还有(这就是故障)。
解法:
- 缩短
registry-fetch-interval-seconds(比如 5~10 秒),代价是注册中心压力增大; - 优先用"推送"模式的注册中心(Nacos 的 UDP 推送、Consul 的 blocking query),而不是纯轮询;
- 客户端必须有失败重试 + 实例剔除:调用失败后把这个实例临时标记为不可用(这就是客户端负载均衡器的作用)。
go
// gRPC 客户端侧的 health check 能快速剔除坏实例
conn, _ := grpc.Dial(
"consul://localhost:8500/order-service",
grpc.WithDefaultServiceConfig(`{"loadBalancingPolicy":"round_robin"}`),
)
关键认知 :服务发现本身做不到实时,所以调用方必须自己兜底(失败快速换实例)。把可靠性寄托在注册中心的实时性上,是设计错误。
类型 3:健康检查通过但实际不可用
场景 :实例在注册中心里显示"健康",但它的某个下游依赖挂了,实际处理不了请求。
注册中心的健康检查通常是:
- 客户端心跳(只证明进程活着);
- 或者 HTTP 探活(通常只检查
/health,不查依赖)。
这两种都证明不了"这个实例能正常处理业务"。
解法:
- 健康检查要检查关键依赖(但要注意:这会导致依赖抖动时整个实例被摘,要权衡);
- 更推荐:让调用方感知------实例不可用时返回特定错误码,调用方换实例重试;
- 熔断:调用方对某个实例连续失败就熔断它。
四、发布过程中的"流量打到已下线实例"
这是最典型的组合问题。看这个时间线:
text
t=0 K8s 发 SIGTERM 给 Pod
t=0 Pod 开始优雅退出
t=0 但调用方的本地缓存里还有这个实例
t=0~30s 调用方持续往这个正在关闭的 Pod 发请求 → 失败
关键在于:从注册中心摘除 ≠ 调用方立刻感知。
完整的正确顺序:
text
1. 收到 SIGTERM
2. 向注册中心反注册(主动下线)
3. 等待一个"调用方刷新周期 + 一点余量"(比如 10 秒)
4. 停止接收新请求(HTTP server.Shutdown)
5. 处理完在手的请求
6. 退出
配合 K8s 的 preStop:
yaml
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"] # 给注册中心 + 调用方留出同步时间
terminationGracePeriodSeconds: 45
preStop 里的 sleep 不是"拖延时间",是在等两件事:
- 注册中心把实例摘掉;
- kubelet 把 Pod 从 Service 的 Endpoint 里摘掉(这两条路径是独立的,都要等)。
五、心跳参数怎么配
一张参考表:
| 场景 | 心跳间隔 | 过期时间 | 缓存刷新 |
|---|---|---|---|
| 默认(保守) | 5s | 15~30s | 30s |
| 追求快速发现 | 3s | 9~10s | 5s |
| 大规模集群(减轻注册中心压力) | 10s | 30s | 30~60s |
权衡点:
- 心跳越快 → 故障发现越快,但注册中心压力和客户端开销越大;
- 过期时间太短 → 网络抖动时误剔除(这比晚发现更糟);
- 缓存刷新太快 → 注册中心被打爆。
我的建议:
text
心跳 5s + 过期 15s + 优先用推送模式 + 调用方必须有失败换实例重试
最后一条最重要:不要指望注册中心的实时性,调用方的容错才是可靠性的最后一道防线。
六、带走物
排查顺序:
bash
# 1. 注册中心里实例是不是真的健康
# 控制台看状态 + 看最后心跳时间
# 2. 直接用 Pod IP / 实例地址调用,绕过服务发现
curl http://<pod-ip>:<port>/healthz
# 3. 在调用方 Pod 里看它本地缓存的服务列表(很多框架支持 dump)
curl http://<caller>:<admin-port>/registry # 或看日志里的实例列表
# 4. 对比三者的时间差
# 心跳间隔 / 过期时间 / 客户端刷新间隔
三类问题的判据:
| 现象 | 根因 | 解法 |
|---|---|---|
| 实例已死但还在列表 | 没走优雅退出,等心跳超时 | 优雅退出里主动反注册 |
| 注册中心已摘但调用方还在打 | 客户端缓存没刷新 | 缩短刷新间隔 / 用推送 / 调用方失败换实例 |
| 显示健康但处理不了 | 健康检查不查依赖 | 健康检查加依赖 或 调用方熔断 |
一句话结论:"注册了却调不通"基本都是时间差问题------注册中心摘除、调用方缓存刷新、实例真正停止,这三件事不是同时发生的;优雅退出 + preStop sleep 就是为了把这段时间差补上。