微服务架构下 服务发现 的设计与权衡
之前给一个老项目做单体拆微服务,一开始真没把“服务发现”当回事。不就是服务启动以后去注册中心登记一下,调用方查一下地址,然后发 HTTP / RPC 吗?结果线上第一次灰度就给了我一巴掌:某个服务刚启动,JVM 还没预热,依赖的缓存连接池也没建立,注册中心那边已经显示实例健康了,网关啪就把流量打过去,成功率一度难看得要命。
后来慢慢才明白,服务发现这件事,难点不在“能不能注册”,而在“什么时候该被看见”“什么时候该被摘掉”“查到的东西到底还能不能用”。这玩意儿是真的难搞,越往深做越容易碰到一致性、缓存、健康检查、流量控制这些东西,最后发现它根本不是一个组件问题,而是整个调用链的问题。
先想清楚:到底谁来负责“发现”这件事
常见方式大概分两类,一类是客户端发现,调用方自己去找注册中心,比如传统微服务里用 Eureka、Consul,再加上 Ribbon / OpenFeign 或者自己写的负载均衡逻辑。另一类是服务端发现,业务代码不用太关心服务发现,交给网关、K8s Service、Ingress、Service Mesh 去做。
如果你写业务比较多,我个人会倾向让业务少碰基础设施。服务代码就老老实实按名字调用,别自己维护一份实例列表,也别自己处理什么“这个实例上次失败过,我这次跳过”。客户端发现确实灵活,延迟路径可能更短,但问题也很明显:多语言栈一上来,注册中心客户端、缓存策略、健康检查、重试、熔断,每个团队都要搞一遍。最后版本一多,升级起来是真的痛苦,没准儿你今天刚修了一个客户端 bug,下个月又有三个老服务没升级。
服务端发现看着更干净,但也不是没有代价。网关或者 Mesh 变成关键路径,一旦它缓存更新慢,或者 DNS 解析异常,所有服务都跟着抖。所以这里其实是一个很典型的权衡:把复杂度放在业务侧,还是放在基础设施侧。
注册不是关键,摘除才是关键
很多人设计服务发现的时候,第一反应都是“服务启动成功后注册”。这没错,但不够。线上真正容易出事的,常常不是实例没注册,而是实例已经该死了,注册中心还没把它摘掉。
比如一个服务进程已经卡死,端口还开着,TCP 探测还是绿的,但接口已经开始超时。你健康检查做得太粗,就会把这种实例继续留在候选列表里。反过来,服务刚起来,监听端口了,但内部还没 ready,你过早注册,就会把半吊子实例放到线上挨打。
所以我会特别在意 readiness 和 liveness 的区分。别把“进程活着”当成“服务可用”。至少下面这些状态要分开:
liveness: 进程还活着吗?
readiness: 依赖准备好了吗?线程池、连接池、缓存、DB、MQ 都 OK 吗?
traffic_ok: 现在能不能接真实业务流量?预热了吗?
如果平台支持,最好启动时先不要注册,等健康检查真的通过,或者 readiness probe 稳定几秒以后,再把实例放进可调用列表。优雅下线更重要。先停止注册,再摘除流量,再等一段时间处理存量请求,最后退出进程。这个顺序做不对,发布窗口就会看到一堆 Connection reset 或者 5xx。
你看到的实例列表,可能已经是“过期真相”
服务发现系统里,调用方看到的实例列表,通常不是实时从某个中心状态直接读出来的,而是经过了注册、同步、缓存、TTL、客户端刷新、网关路由更新等一系列环节。也就是说,你看到注册中心里有 10 个实例,不代表网关里也有 10 个;网关里有,也不代表每个实例都真的能稳定收请求。
这里最坑的是缓存和一致性的权衡。强一致听起来很美,但分布式系统里,强一致往往意味着更复杂的协调、更长的恢复时间、更差的网络分区容忍性。很多服务发现组件会偏向最终一致,或者干脆提供“自我保存”“宽松健康检查”这类机制,避免网络抖一下就把服务全摘了。
这本身不是坏事,但使用时要清醒:宽松保护会掩盖真实故障。某个机房网络分区了,注册中心觉得“可能是中心自己有问题”,于是继续保留旧实例列表,调用方以为一切正常,实际请求全超时。遇到这种情况,光骂注册中心没用,得看整条链路有没有熔断、超时、重试限制和快速失败。
别把服务发现当负载均衡和容错
服务发现的职责是告诉你“有哪些候选实例”,不是告诉你“这个实例现在一定好用”。很多事故就是因为把这两个概念混在一起了。
调用方拿到实例列表以后,至少还需要下面这些东西:
- 超时:连接超时、读超时、调用总超时,别无限等。
- 重试:只能对幂等接口谨慎使用,非幂等接口随便重试就是在制造重复订单。
- 熔断:实例连续失败,应该短时间避开,而不是每个请求都去撞一下。
- 连接池隔离:别让一个慢服务把公共线程池打满。
- 预热:新实例刚起来,不应该马上承担满额流量。
尤其是重试,线上真的很容易把小故障放大成大故障。某个服务超时,调用方默认重试三次,上游又重试,下游数据库被反复打,最后一个局部延迟演变成雪崩。这个坑踩过一次以后,我现在看任何“自动重试”都先问一句:幂等吗?退避了吗?总次数限制了吗?
多环境、多集群、多标签,别全塞在一个扁平空间里
小规模时候,所有服务都放一个 namespace 或者一个集群里,开发测试生产一套名字,挺省事。等环境一多,麻烦就来了。你到底是调用生产服务,还是预发服务?这个实例是默认流量,还是灰度标签?这个版本是 v1,还是正在做金丝雀的 v2?
服务发现里最好从一开始就把 metadata 用起来,比如环境、版本、机房、灰度标签、路由权重。不然等到要做灰度、蓝绿、按用户路由的时候,再回头补,基本等于重做。
不过这里也有反直觉的地方:标签别设计太多。标签一多,路由规则组合爆炸,最后没人能解释一个请求为什么会落到某个实例上。可观测性也会变差。与其搞一堆细粒度标签,不如先把核心几个字段做稳:环境、版本、机房、权重。
一个相对保守的落地顺序
如果让我重新做一个服务发现方案,我大概不会一上来就追求特别炫的架构。会先保证几件很朴素的事:
- 实例注册前,必须确认业务端口可监听,且 readiness 稳定通过。
- 实例摘除前,先通知负载均衡或停止接收新请求,保留存量请求处理时间。
- 健康检查不要只看 TCP,至少要检查一个轻量业务健康接口。
- 调用方本地缓存要设置 TTL,刷新间隔不能太长,否则摘除生效慢。
- 注册中心要高可用,但高可用不是“永远不要摘”,而是异常时不要误杀。
- 所有失败都必须能被观测到:谁注册了,谁被摘了,谁在缓存里,谁被熔断,谁超时。
最后还有一点,文档和默认值很重要。示例配置写起来往往很美:
health_check:
interval: 5s
timeout: 2s
deregister_critical_service_after: 30s
gateway:
server_cache_ttl: 1s
circuit_breaker:
failure_rate_threshold: 0.5
但实际项目里,这些值基本都得调。有些服务启动慢,5 秒太短;有些服务 GC 抖一下,2 秒超时又误判;有些调用链对延迟敏感,网关缓存 1 秒都嫌慢。配置改来改去,最后很容易变成“测试环境一切正常,生产一压就炸”。所以不如一开始就留好观测入口,别等出事了才去猜到底是注册慢、缓存旧、健康检查误杀,还是服务本身预热不够。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。