平滑加权轮询(SWRR)挑选器四连坑:draining 成员混进候选集、proxy 与 address 两条腿、逐请求预算被全轮共享、假绿健康检查
关键词:平滑加权轮询 SWRR、加权负载均衡、draining 摘流、候选集过滤、 权重上限(单成员 vs 总和)、逐请求预算、token bucket、健康检查假绿、 fail-closed、退出码分层、出站代理挑选、egress proxy selector、Python
技术栈:Python 3.11 / Docker Compose / 纯标准库。
仓库:scenario/2026-10-17-1600/(第 131 题)。
问题背景
一个出站代理挑选器(egress proxy selector)要做四件事:
- 从成员池里按健康 + 非摘流筛出候选集;
- 按**平滑加权轮询(Smooth Weighted Round-Robin, SWRR)**逐请求挑一个代理;
- 每条请求有独立的耗时预算(
SELECT_BUDGET_MS),超了就放弃那次尝试; - 写出报告
state/selection.json,再由健康检查按契约独立重算验收。
线上却出现四个绿灯同时亮着、但事实全错:
{"counts": {"failed": 0, "ok": 8, "total": 8}, "ok": true,
"pool": {"candidates": ["proxy-d","proxy-c","proxy-b","proxy-a"],
"healthy_members": 4, "total_weight": 5}, "pool_health": "healthy"}
plans.proxy: ['proxy-d'] * 8 # 全是 draining 的成员
- 8 条请求全记成
proxy-d,而它的state是draining(正在摘流),按契约根本不该进候选集; req-002记成proxy-b,但address是proxy-c的 —— 同一条计划里两个字段来自不同成员;- 后 4 条请求
latency_ms全是0,报告照样判ok; python3 -m app.healthmain输出{"degraded": false, "issues": []},退出码0—— 它一条判据都没跑。
外部独立探针给了真相:RESULT: BAD(不一致 23 条)。
复现步骤
cd scenario/2026-10-17-1600
# 1) 起环境
docker compose up -d --build
docker compose exec -T selector bash
# 2) 跑一轮 + 外部探针核对(退出码 = 探针退出码,问题态 1 是预期信号)
bash ops/repro.sh ; echo "repro rc=$?"
# 3) 逐条验收(期望值写死在脚本里,口径来自 docs/selector-contract.md)
bash ops/verify.sh ; echo "verify rc=$?"
# 4) 健康检查自述
python3 -m app.healthmain ; echo "health rc=$?"
问题态输出:
RESULT: BAD(不一致 23 条) # repro rc=1
PASS=8 FAIL=19 / RESULT: BAD # verify rc=1
{"degraded": false, "issues": []} # health rc=0 ← 假绿
提示:
repro.sh的退出码就是探针退出码,问题态返回 1。 一定用; echo rc=$?接住,用&&会让后续步骤全被中断。
契约数值(conf/selector.env):BUDGET_MS=100、MAX_RETRIES=3、
MAX_WEIGHT=5、TOTAL_CAP=7、MIN_HEALTHY=2。
成员池(pool/members.json):
proxy-a 10.20.0.11:8080 weight=1 healthy active
proxy-b 10.20.0.12:8080 weight=3 healthy active
proxy-c 10.20.0.13:8080 weight=6 healthy active # 6 > MAX_WEIGHT=5
proxy-d 10.20.0.14:8080 weight=40 healthy draining # 不该进候选集
proxy-e 10.20.0.15:8080 weight=2 unhealthy active
排查过程
1)先看候选集,而不是看权重
candidates=["proxy-d","proxy-c","proxy-b","proxy-a"]、healthy_members=4
就说明候选集本身就错了:draining 没被排除。同时排序是 -weight,
违反契约要求的「id 升序」,导致 SWRR 并列时「取谁」随字典/文件顺序漂移。
2)proxy 和 address 对不上 = 两条腿
req-002 的 proxy=proxy-b 但 address=10.20.0.13:8080(proxy-c 的地址)。
看代码:swrr.book(pick) 返回 self.cands[0]["id"](权重最高者),
而 address = p.address_of(pick["id"]) 用的是真正的胜者 —— 挑中的和登记的不是同一个。
3)latency_ms=0:不是「跑得快」,是「没额度了」
ReqBudget 在 for req in reqs: 循环外只 new 一次,8 条请求共用一个 spent。
前 2 条吃满 100ms,后面每条 can_spend() 立刻为假 → spent=0,还被判 ok。
4)total_weight=5:单成员上限冒充了总和上限
return min(self.conf["SELECT_TOTAL_CAP"], self.conf["SELECT_MAX_WEIGHT"]) # 恒为 5
MAX_WEIGHT 是单成员权重能生效的上限,TOTAL_CAP 是所有成员之和的上限,
两个上限作用在不同对象上,不能互相替换。
5)健康检查的 for 循环体执行了几次
BAD_FIELDS = () —— 空元组。for field in BAD_FIELDS: 一次都不循环,
issues 恒空 → degraded: false。空判据表 = 假绿。
6)契约读不到时在干什么
contract.py 里有 FALLBACK 默认值 + except OSError: pass:
契约文件丢了就 silently 用默认值(MAX_WEIGHT=10、TOTAL_CAP=26)继续派发。
口径已经不可信,还敢继续跑,这是最危险的一种。
根本原因
| # | 根因 | 代码位置 |
|---|---|---|
| 1 | 候选集只筛 health 没筛 state;排序用 -weight 而非 id | Pool.candidates() |
| 2 | total_weight 用 min(TOTAL_CAP, MAX_WEIGHT),把「单成员上限」当「总和上限」 | Pool.total_weight() |
| 3 | book() 返回候选集首元素,挑选与登记分离成两条腿 | SmoothWrr.book() |
| 4 | 预算账本建在循环外,整轮共享 | gateway.run() |
| 5 | pool_health 写死 "healthy",与健康成员数无关 | gateway.run() |
| 6 | 判据表为空 → 空循环 → 恒真;契约不可用时用默认值继续跑 | health.BAD_FIELDS / contract.FALLBACK |
共同病根:用「自述字段」和「恒真的检查器」冒充判定。
ok / pool_health / degraded 都是实现自己说的,没有一个被独立验证过。
解决办法
1. 候选集:健康 + 非摘流 + id 升序
EXCLUDED_STATES = ("draining", "down", "")
def candidates(self):
out = []
for m in self.members:
if m.get("health") != "healthy":
continue
if (m.get("state") or "") in EXCLUDED_STATES: # draining/down/空 一律不进
continue
if self.weight_of(m) <= 0:
continue
out.append(m)
out.sort(key=lambda m: m["id"]) # id 升序,不能按权重
return out
2. 权重:先逐成员压顶 → 求和 → 再用总和上限压顶
def weight_of(self, member): # 单成员上限
return min(int(member.get("weight", 0)), self.conf["SELECT_MAX_WEIGHT"])
def total_weight(self, cands): # 总和上限
acc = 0
for m in cands:
acc += self.weight_of(m)
return min(acc, self.conf["SELECT_TOTAL_CAP"])
1/3/5(proxy-c 的 6→5)求和 9,再被 TOTAL_CAP=7 压成 7。
3. SWRR:挑中的就是登记的那个
def pick(self):
best = None
for m in self.cands:
self.cur[m["id"]] += self.pool.weight_of(m)
if best is None or self.cur[m["id"]] > self.cur[best["id"]]:
best = m # 严格大于 → 并列取 id 升序第一个
if best is None:
return None
self.cur[best["id"]] -= self.total # 只给胜者回退 total_weight
return best
def book(self, member):
return member["id"] # 不再返回 cands[0]
4. 逐请求预算:每条请求一个独立实例
def spend_request(steps, budget_ms):
budget = ReqBudget(budget_ms) # 逐请求独立,绝不跨请求复用
dropped = False
for step in steps:
if not budget.can_spend(step):
dropped = True # 被放弃的尝试不计入 latency_ms
continue
budget.spend(step)
return budget.spent, dropped
5. pool_health 按健康成员数
def pool_health(cands, conf):
return "healthy" if len(cands) >= conf["SELECT_MIN_HEALTHY"] else "degraded"
与本轮挑中过谁、被挑中几次完全无关。
6. 契约 fail closed
class ContractError(Exception): ...
def load_contract(conf_dir=None):
path = os.path.join(conf_dir or os.environ.get("CONF_DIR", "/srv/conf"), "selector.env")
if not os.path.isfile(path):
raise ContractError("找不到 " + path) # 缺文件 → 拒跑,不是用默认值继续
return validate(parse_env_file(path))
7. 健康检查:判据表有货 + 独立重算 + 退出码分层
CHECKS = ("report_readable", "budget_matches", "candidates_match",
"total_weight_matches", "healthy_members_match", "pool_health_matches",
"plans_unique_per_request", "proxy_and_address_consistent",
"picks_match", "per_request_budget", "counts_match")
def run_all(self):
if not CHECKS:
return ["no_checks: 判据表为空,无法判定"] # 判不了 ≠ 没问题
...
退出码:
| 情况 | rc |
|---|---|
| 干净一轮 | 0 |
| 有真问题 / 拿不到报告(判不了) | 2 |
| 契约不可用 / 口径非法 | 3 |
验证
docker compose up -d --build # 改完必须重建:容器里是 COPY 进去的副本
docker compose exec -T selector bash
bash ops/repro.sh ; echo "repro rc=$?" # RESULT: OK, rc=0
bash ops/verify.sh ; echo "verify rc=$?" # PASS=27 FAIL=0, RESULT: OK, rc=0
python3 -m app.healthmain # {"degraded": false, "issues": []}, rc=0
修复后报告:
candidates : ["proxy-a","proxy-b","proxy-c"] healthy_members=3 total_weight=7
picks : proxy-c,proxy-b,proxy-c,proxy-c,proxy-b,proxy-c,proxy-a,proxy-c
latency_ms : 22, 18, 75, 40, 70, 15, 80, 5
counts : {"total": 8, "ok": 8, "failed": 0}
负向验证(闸门有牙):一题眼一次回滚,每个都让 verify 转红。
docker compose exec -T selector bash /srv/ops-extra/negative-per-bug.sh
| 回滚的题眼 | verify |
|---|---|
draining 进候选集 | rc=1(FAIL A2/A4/B2/B3) |
| 登记候选最高权重者 | rc=1(FAIL B1..B4) |
| 整轮共享账本 | rc=1(FAIL D2..D5) |
| 总和被单成员上限封顶 | rc=1(FAIL A3,7→5) |
| 判据表退化成空 | rc=1(FAIL G3,期望 0 实际 2) |
经验与避坑
1)over_budget 的判定口径:放弃的尝试不计入耗时
我第一版写成「有尝试被放弃就判 over_budget」,于是 req-005(70+45)和
req-007(80+40)被误判,verify 卡在 PASS=23 FAIL=4。
正确口径:被放弃的那次尝试不计入 latency_ms,最终看累计耗时是否仍在预算内。
70≤100、80≤100 都是 ok。SELECT_BUDGET_MS 是「单条请求的累计耗时上限」,
不是「所有尝试之和必须塞进预算」。
2)「单成员上限」和「总和上限」长得几乎一样
min(TOTAL_CAP, MAX_WEIGHT) 和 min(sum, TOTAL_CAP) 都只有一行,
但前者在 TOTAL_CAP>MAX_WEIGHT 时恒等于 MAX_WEIGHT,是个看起来合理的常数。
见过一次就记住:两个容量参数作用在不同聚合层级上时,先确定聚合顺序,再写 min。
3)空集合上的 for 循环是最隐蔽的假绿
BAD_FIELDS = () → for 循环体 0 次 → issues 恒空 → degraded: false。
任何「遍历判据表」的检查器都要对「判据表为空」显式报错,
否则删掉判据表就等于让检查器永远通过。
4)「判不了」必须和「没问题」用不同退出码
拿不到报告、契约读不到、口径非法 —— 这些情况下正确答案不是 0。
分层退出码(0 干净 / 2 真问题或判不了 / 3 契约不可用)让 CI 和告警能区分
「真的没事」和「根本没检查」。degraded: false 加 rc=0 这种组合,
在契约文件丢失时会安静地骗过所有人。
5)容器里改代码必须重新 build
Dockerfile 是 COPY app/ /srv/app/,改了宿主机文件但容器里还是旧副本。
每次改完跑 docker compose up -d --build,否则你会对着旧结果怀疑人生。
6)负向测试的注入点别绑死在代码结构上
官方 ops/negative.sh 用「按行删除含某关键字的行」和「字符串替换挪动某一行」来注入 bug。
一旦实现结构变了(哪怕变得更正确),注入就落空 → 脚本报 FAIL 但代码根本没坏。
这类测试的可靠做法是按语义注入(改行为、加模块级共享、清空判据表),
然后用验收脚本的退出码判定,而不是靠 grep 某个输出串。
7)篡改顺序会影响测试结果
测「健康检查能不能抓住被篡改的报告」时,顺序必须是:
先清空判据表 → 再跑一轮 → 再篡改报告 → 再检查。
如果先篡改再跑一轮,run_round 会把报告覆盖成正确的,篡改被冲掉,测试就假阴性了。
同类影响面
这套坑在下列场景里几乎一模一样地复现:
- Nginx / Envoy / HAProxy 的 upstream 摘流:
draining/weight=0/down必须从 lb 候选集剔除,不能只依赖 health check(state与health是两个维度)。 - Kubernetes Service Endpoints:
Terminating的 Pod、NotReady的 Pod 都要出 Endpoints, 只看 readinessProbe 会让摘流中的 Pod 继续吃流量。 - 客户端侧负载均衡(gRPC / Dubbo / Spring Cloud LoadBalancer):SWRR 的
current_w += weight全量累加、只有胜者回退total_weight,这两步错一步序列就漂。 - 限流 / 配额(token bucket、rate limiter):把 per-request 的桶建成全局单例, 就是本题的「前 2 条吃满、后面全是 0」。
- 健康检查 / 自检脚本:判据表为空、
except: pass、拿不到数据就返回成功, 是 SRE 里最常见的「监控看起来全绿,故障时一无所知」。
一句话:自述字段和恒真的检查器都不是证据;只有「按契约独立重算 + 逐字段对照 + 判不了就说判不了」才是。
