朕喜技术(ZhenxiJS)
2026.10.17
1 分钟阅读

平滑加权轮询(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)要做四件事:

  1. 从成员池里按健康 + 非摘流筛出候选集;
  2. 按**平滑加权轮询(Smooth Weighted Round-Robin, SWRR)**逐请求挑一个代理;
  3. 每条请求有独立的耗时预算(SELECT_BUDGET_MS),超了就放弃那次尝试;
  4. 写出报告 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 而非 idPool.candidates()
2total_weight 用 min(TOTAL_CAP, MAX_WEIGHT),把「单成员上限」当「总和上限」Pool.total_weight()
3book() 返回候选集首元素,挑选与登记分离成两条腿SmoothWrr.book()
4预算账本建在循环外,整轮共享gateway.run()
5pool_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 里最常见的「监控看起来全绿,故障时一无所知」。

一句话:自述字段和恒真的检查器都不是证据;只有「按契约独立重算 + 逐字段对照 + 判不了就说判不了」才是。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-17-1600-swrr

分享这篇文章