主题
服务治理:让请求找到合适的实例,并在变化时有序退出
订单服务从两台扩到十台,地址会变,实例会重启,新版本还要逐步接流量。客户端不能把所有地址写死,也不能认为“注册中心说在线”就等于“这台机器现在接得住请求”。服务治理要把这些变化接到真实请求路径上。
这篇按一次调用的顺序展开:发现可用端点,挑选一个实例,应用配置和调用预算,最后让旧实例安全退出。API 网关和服务网格放在这个路径里比较,避免每解决一个问题就多加一套组件。
先画请求路径,再画管理路径
正在绘制图示…
查看图源代码
flowchart LR
R[注册与健康信息] --> C[控制面:发现、配置、策略]
C -. 带版本的端点与策略更新 .-> P[客户端库或代理]
U[调用方] --> P
P --> A[实例 A]
P --> B[实例 B]
A -. 续约与状态上报 .-> R
B -. 续约与状态上报 .-> R
实线上的请求转发是数据面;改变端点和策略的路径是控制面。注册中心、配置发布系统主要影响控制面,客户端负载均衡器或代理实际决定请求去哪。Envoy 的 xDS 就是控制面向数据面下发配置的一组 API,而不是用户业务请求协议。[1]
这个区分直接影响故障判断。注册中心短时不可用时,已有代理若保留最后一份有效快照,现有流量可能继续走;新实例却可能无法被发现。代理进程崩溃,则可能马上影响经它转发的流量。不能把两种故障都缩成“服务发现挂了”。
| 要解决的事 | 放在哪里做 | 对调用方可见的结果 |
|---|---|---|
| 实例地址变化 | 注册、发现、DNS、平台端点管理 | 更新候选端点列表 |
| 分配请求 | 客户端或代理负载均衡器 | 每次调用选择实例或连接 |
| 配置更新 | 配置发布、校验、订阅、本地生效机制 | 超时、路由、开关等变更 |
| 外部入口管理 | API 网关 | 统一入口、认证、路由、限流等 |
| 服务间通用通信策略 | 服务网格的数据面和控制面 | 服务间流量、安全策略与可观测性 |
注册与发现:先得到快照,再持续追踪变化
注册通常写入服务名、实例 ID、地址、端口、版本、机房和健康状态。实例可以主动登记并续约,也可以由编排平台观察工作负载后生成端点。例如 Kubernetes 的 Service 定义服务入口,EndpointSlice 记录对应端点及状态;并不要求每个业务进程再向独立注册中心自注册。[2][3]
发现有两种常见落点:客户端拿到地址列表后自己选;客户端只访问代理或虚拟入口,让基础设施发现并转发。前者减少一层转发,但每种语言、每个 SDK 都要维护正确的发现逻辑;后者更容易统一策略,同时增加代理或平台依赖。
一份端点列表为什么会突然退回旧版本?
订阅连接可能断开,旧响应也可能晚于新响应到达。如果新快照 revision 10 已生效,又被迟到的 revision 9 覆盖,客户端可能重新连接已经下线的实例。处理更新时需要明确版本语义和恢复流程。
以 etcd 为例,可以先读某 revision 的快照,再从其后的 revision 追踪变更;watch 断开后可以在历史窗口内恢复,历史已被压缩则重新取快照。watch 本身不提供线性一致读取的承诺,也不能把本地缓存称为实时真相。[4]
我们的本地模型只演示“整份快照校验成功后替换,版本不得倒退”:
go
fmt.Println("old update:", r.Publish(Snapshot{9, nil}),
"kept:", r.Read().Revision)
fmt.Println("bad update:", r.Publish(Snapshot{11, []Endpoint{{Name: "C"}}}),
"kept:", r.Read().Revision)实际输出:
text
old update: stale revision kept: 10
bad update: invalid endpoint kept: 10第二次更新里的 C 没有合法权重,整份快照被拒绝,旧配置继续服务。模型的“旧版本拒绝”和“无效配置拒绝”是两种独立检查,不能只比较 revision 就相信内容。
实际订阅还需要重新连接、退避、鉴权、初次没有快照时的处理,以及更新失败告警。这里的整数 revision 是本地模型的协议约定,不表示所有注册中心都共享 etcd 的版本规则。
在线、就绪、可接新请求,不是一件事
一个进程能回应健康探测,可能还在加载大缓存;一个正在终止的实例,可能仍有请求需要执行完。Kubernetes EndpointSlice 分别表达 ready、serving、terminating 等条件,正是为了区分这些状态。[3]
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Starting
Starting --> Ready: 依赖与预热完成
Ready --> Draining: 停止接收新请求
Draining --> Stopped: 在途完成或截止时间到
Ready --> Unhealthy: 探测或请求失败
Unhealthy --> Ready: 满足恢复条件
Stopped --> [*]
优雅退出的一种安排是:先进入 draining 并从新流量候选中移除,再等待发现传播、连接排空和在途请求,最后关闭。不同平台的信号和摘流顺序不同,要按它的生命周期验证。只执行“先退出进程,再删除地址”,会让客户端在缓存更新前不断撞上一个死地址。
健康检查也不是越深越好。把一个公共下游的短暂故障当成所有实例自身不健康,可能同时摘掉全部实例。应分别设计存活检查、就绪检查和调用级失败处理,避免健康探测制造更大的故障。
负载均衡:平均分配什么?
轮询平均的是选择次数;最少请求观察的是当前并发;一致性哈希关心同一键尽量落到同一处。它们优化的目标不同。[5]
| 策略 | 怎么选 | 适合的假设 | 代价与失败点 |
|---|---|---|---|
| 轮询 | 在候选实例间依次轮转 | 容量相近、请求成本接近 | 次数相同不代表 CPU、并发或响应时间相同 |
| 加权轮询 | 按权重分配选择次数,平滑实现减少突发 | 容量差异已知,权重能及时调整 | 静态权重过时;高权重慢节点仍会被分配较多请求 |
| 一致性哈希 | 对业务键映射到哈希环等稳定结构 | 缓存局部性、会话亲和或同键路由有价值 | 热点键仍集中;端点变化会搬迁部分键;不等于数据迁移 |
| P2C,两次随机选择 | 随机抽两个候选,选观测负载较低者 | 有可信的负载信号,希望避免每次扫描全体 | 负载采样滞后;只有本客户端视角;请求成本差异可能失真 |
“一致性哈希”的一致性指成员变化时映射尽量稳定,与副本数据的线性一致性无关。哈希规则、种子、权重或节点标识在不同客户端不一致,同一个键也会路由到不同实例。对于一台机器处理不了的热点键,再多虚拟节点都不能自动把该键的请求拆开。
还要问均衡发生在连接层还是请求层。HTTP/2 或 gRPC 的长连接可以承载很多并发请求;仅靠建立连接时的轮询,不能保证后续每个请求平均分到全部实例。选型时需要核对客户端、代理以及传输层各自的负载均衡行为,而不只看配置里的策略名称。
P2C 的比较值要先说清
模型为 A、B 显式提供两个候选,分别是 active=8, weight=1 和 active=6, weight=2。这里定义比较分数为“在途请求数 / 权重”,所以 B 为 3,A 为 8,选择 B:
go
name, err := p2c(a, b)
fmt.Println("P2C:", name, err)
a.Draining = true
b.Draining = true
_, err = p2c(a, b)
fmt.Println("all draining:", err)实际输出:
text
P2C: B <nil>
all draining: no ready endpoint这只是一个带权评分的教学实现,不是 Envoy 的 least-request 算法复刻。Envoy 在等权和不等权情况下的实际算法、可配置 choice count 和 active request bias,应以固定版本文档为准。[5] 模型没有随机抽样、并发预占计数、请求完成回减或 EWMA 延迟估计;这些遗漏不能被测试通过掩盖。
全体不可用时,模型返回错误。有些代理还支持 panic mode 等策略,在健康候选不足时扩大选择范围。它可能提高接通概率,也可能把请求打到已知不健康端点;应当作为明确的降级决策,不能悄悄把它等同于健康路由。
配置中心:发布成功以后,还差“安全生效”
集中存一份配置,只解决了在哪里管理。客户端仍要知道如何拉取、校验、应用和回滚。一次超时变更如果分多次写入字段,读请求可能看到“新重试次数配旧总预算”,落到从未被设计过的组合。
| 环节 | 该做的检查 | 不做会怎样 |
|---|---|---|
| 发布前 | 类型、范围、跨字段约束、目标环境 | 合法 JSON 也可能是无效业务配置 |
| 本地接收 | 版本、完整性、来源与权限 | 旧配置覆盖新配置,或错误环境串用 |
| 生效 | 构造完整不可变快照,再一次替换引用或在锁内替换 | 读到半新半旧状态;共享 slice/map 被意外修改 |
| 灰度 | 先少量实例,观察具体指标 | 一次错误推送影响所有实例 |
| 回滚 | 使用明确的新发布版本携带旧配置内容 | 简单拒绝低版本的客户端可能拒绝“直接退版本号” |
| 失败处理 | 保留最后有效配置并告警;明确最长允许陈旧时间 | 程序静默使用过期配置很久 |
模型的 Publish 和 Read 都复制端点切片,防止调用方修改已经生效的快照。实际输出里 snapshot isolated: A 表示调用方把副本中的名字改成 changed,注册表仍保存 A。复杂嵌套配置需要更完整的深拷贝或严格不可变约定;复制最外层 struct 不足以隔离内部 map。
涉及密钥、身份和授权策略的配置,风险高于普通超时参数,应使用合适的机密管理、访问控制、审计和轮换流程。不能因为配置中心支持字符串,就把所有秘密长期塞进一份普通配置文件。
API 网关与服务网格:放到流量边界上比较
API 网关通常面向外部客户端到内部服务的入口,处理路由、认证、协议转换、配额、访问日志等。服务网格通常把服务间通用通信策略放入独立数据面,并由控制面统一配置。Istio 的传统 sidecar 模式和 ambient 模式的数据面形态不同,不能把“每个 Pod 一个 sidecar”写成所有网格的定义。[6]
正在绘制图示…
查看图源代码
flowchart LR
E[外部客户端] --> G[API 网关]
G --> O[订单服务]
O --> D1[服务间数据面]
D1 --> I[库存服务]
CP[网格控制面] -. 路由、身份和策略 .-> D1
| 放置方式 | 收益 | 需要承担的成本 |
|---|---|---|
| Go 客户端治理库 | 直接拿到业务请求上下文;少一层本地代理 | 各语言实现和升级不同步,业务进程承担复杂度 |
| API 网关 | 入口规则集中,外部 API 的统一管理更容易 | 多一跳和入口容量要求;不能替所有内部跳数治理 |
| 服务网格 | 多语言服务可共享身份、流量和观测策略 | 控制面、数据面、证书、排障与升级成本;仍需业务懂超时和幂等 |
把认证放在网关以后,仍应约束内部服务可以信任哪些身份和头部。若客户端能绕过网关直接访问服务并伪造 X-User-ID,入口认证本身不足以保护业务。网格的 mTLS 能认证工作负载及保护链路,但用户是否能退款、能退多少,仍是应用授权问题。
最容易反复叠加的是重试
假设用户只发一次请求,网关最多尝试 3 次,服务 SDK 对每次调用又最多尝试 3 次,最坏可能向下游发出 9 次尝试。若再有一层重复同样策略,放大还会继续。这个算式只是无抑制、各层都耗尽尝试预算的上界场景,不是所有请求的实际次数。
gRPC 的重试策略包含可重试状态、尝试次数、退避等参数;调用 deadline 则限制整体等待预算,不能把每次重试都重新赠送一个完整超时。[7][8] 非幂等写入还必须考虑“前一次已经成功但响应丢失”。
一个可操作的安排是:为端到端请求设预算,在调用链向下传播;指定哪一层拥有业务重试;限制尝试次数和并发;使用退避与抖动;把重试次数和最终状态记入指标。熔断、限流和隔离分别解决故障扩散、流入速率及资源竞争,不能把它们统称成重试的附件。
上线前用故障问句检查治理是否接完整
- 新实例需要多久才会收到请求?这段时间谁负责预热?
- 旧实例摘流后,长连接和在途请求多久结束?超时后会发生什么?
- 注册中心不可用时使用哪一版快照?缓存陈旧到什么程度必须停止?
- 配置发错后是否能保持最后有效值、告警并回滚?
- 两个客户端的负载视图不同时,会不会同时涌向同一个“最空”节点?
- 请求失败时能否看到它经过的实例、配置版本、尝试次数和总预算?
这里没有一个必须全部购买的组件清单。服务数量少、语言统一、平台已经提供发现和入口时,把现有路径处理正确,往往比再加一层代理更有价值;跨语言、安全策略和运维规模增加后,统一数据面的收益才更容易抵过成本。应当用实际故障演练和延迟预算验证这个判断。
依据与验证范围
- Envoy v1.35.0 xDS 协议:配置版本、发现响应与确认机制。
- Kubernetes Service:服务入口与发现。
- Kubernetes EndpointSlices:端点及 ready、serving、terminating 状态。
- etcd v3.6 API guarantees:watch 的顺序、恢复窗口及非线性一致边界。
- Envoy v1.35.0 Supported load balancers:轮询、加权、least-request、ring hash 和 Maglev 的具体机制。
- Istio 架构:控制面与数据面、sidecar/ambient。此页是滚动文档,2026-10-13 查阅;部署时应锁定具体版本。
- gRPC Retry:策略与重试行为。
- gRPC Deadlines:截止时间传播及取消。
Go 1.27.2、Linux/amd64 下,go run、go test、go test -race、go vet 均通过。模型实际覆盖旧版本拒绝、非法端点拒绝、切片隔离、并发读副本,以及两个指定候选的选择和全体排空拒绝。没有真实注册中心、随机抽样器、代理或网格;没有验证生产摘流时间、跨客户端公平性、网络分区、HTTP/2 均衡效果或这些产品的性能。
完整可复现实验
把以下三个文件放在同一空目录。只依赖标准库,不需要外部服务。
sh
go run .
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...go.mod
mod
module example.com/distributed/governance
go 1.27.2main.go
代码块 · 83 行 · 点击收起 / 展开
go
package main
import (
"errors"
"fmt"
"sync"
)
type Endpoint struct {
Name string
Weight, Active int
Ready, Draining bool
}
type Snapshot struct {
Revision int
Endpoints []Endpoint
}
type Registry struct {
mu sync.RWMutex
s Snapshot
}
func (r *Registry) Publish(s Snapshot) error {
r.mu.Lock()
defer r.mu.Unlock()
if s.Revision <= r.s.Revision {
return errors.New("stale revision")
}
seen := map[string]bool{}
for _, e := range s.Endpoints {
if e.Name == "" || e.Weight <= 0 || e.Weight > 1000 || e.Active < 0 || e.Active > 1000000 || seen[e.Name] {
return errors.New("invalid endpoint")
}
seen[e.Name] = true
}
r.s = Snapshot{s.Revision, append([]Endpoint(nil), s.Endpoints...)}
return nil
}
func (r *Registry) Read() Snapshot {
r.mu.RLock()
defer r.mu.RUnlock()
return Snapshot{r.s.Revision, append([]Endpoint(nil), r.s.Endpoints...)}
}
// Demo's two choices are supplied explicitly to keep its output reproducible.
func p2c(a, b Endpoint) (string, error) {
usable := func(e Endpoint) bool { return e.Ready && !e.Draining && e.Weight > 0 }
if !usable(a) && !usable(b) {
return "", errors.New("no ready endpoint")
}
if !usable(a) {
return b.Name, nil
}
if !usable(b) {
return a.Name, nil
}
if int64(a.Active)*int64(b.Weight) <= int64(b.Active)*int64(a.Weight) {
return a.Name, nil
}
return b.Name, nil
}
func main() {
r := &Registry{}
a := Endpoint{"A", 1, 8, true, false}
b := Endpoint{"B", 2, 6, true, false}
must(r.Publish(Snapshot{10, []Endpoint{a, b}}))
name, err := p2c(a, b)
fmt.Println("P2C:", name, err)
a.Draining = true
b.Draining = true
_, err = p2c(a, b)
fmt.Println("all draining:", err)
fmt.Println("old update:", r.Publish(Snapshot{9, nil}), "kept:", r.Read().Revision)
fmt.Println("bad update:", r.Publish(Snapshot{11, []Endpoint{{Name: "C"}}}), "kept:", r.Read().Revision)
copy := r.Read()
copy.Endpoints[0].Name = "changed"
fmt.Println("snapshot isolated:", r.Read().Endpoints[0].Name)
}
func must(err error) {
if err != nil {
panic(err)
}
}main_test.go
代码块 · 46 行 · 点击收起 / 展开
go
package main
import (
"sync"
"testing"
)
func TestSnapshots(t *testing.T) {
r := &Registry{}
s := Snapshot{1, []Endpoint{{"A", 1, 0, true, false}}}
if err := r.Publish(s); err != nil {
t.Fatal(err)
}
s.Endpoints[0].Name = "caller changed"
if r.Read().Endpoints[0].Name != "A" {
t.Fatal("input alias")
}
if r.Publish(Snapshot{1, nil}) == nil || r.Publish(Snapshot{2, []Endpoint{{Name: "B"}}}) == nil {
t.Fatal("accepted invalid update")
}
var wg sync.WaitGroup
for range 8 {
wg.Add(1)
go func() {
defer wg.Done()
for range 50 {
s := r.Read()
s.Endpoints[0].Name = "local"
}
}()
}
wg.Wait()
}
func TestPicker(t *testing.T) {
a := Endpoint{"A", 1, 8, true, false}
b := Endpoint{"B", 2, 6, true, false}
n, e := p2c(a, b)
if e != nil || n != "B" {
t.Fatal(n, e)
}
a.Draining = true
b.Ready = false
if _, e = p2c(a, b); e == nil {
t.Fatal("routed to unhealthy")
}
}