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

MQTT 3.1.1 Broker 会话投递审计:四盏绿灯账全错,健康检查自证健康怎么修

日期:2026-10-26 环境:Python 3.12 纯标准库 / TSV 事实台账 / JSON 报告 / Docker Compose / 外部独立探针 关键词:MQTT 3.1.1、MQTT broker 会话审计、clean_session、session_expiry_seconds、engine_defaults、 通配符订阅 + 跨层、空层级主题、granted_qos=-1 被拒订阅、重复订阅去重、retain 保留消息清除、 半帧 sent_bytes、重发未确认、假绿健康检查、fail-open 改 fail-closed、退出码 0/2/3

一、问题背景

设备接入平台换自研 MQTT 3.1.1 Broker,运维巡检面板四盏绿灯:

python3 -m app.service    # rc=0
python3 -m app.health     # {"degraded": false, "issues": []}
python3 -m app.run        # 不报错,reports/audit.json 字段齐全

但业务方三个投诉全对不上:

  1. 离线设备收到本该丢掉的旧会话消息 —— clean_session=1 的短连接设备被补发历史消息, 已过期的持久会话还被当成活跃订阅者。
  2. 同一个主题订阅两次,"投递匹配数"直接翻倍 —— 运维给 edge-01 补了一条重复订阅就涨了。
  3. 明明发出去的报文,下行字节数对不上 —— transport.sent_bytes 比链路侧少了一段。

维护者加了一轮"健康检查",什么都没查出来 —— 这就是典型的假绿。

二、复现步骤

Docker 方式

cd scenario/2026-10-26-0002
docker compose up -d --build
docker compose exec -T broker bash      # 非交互必须加 -T

# 容器内(/srv)
bash ops/repro.sh   ; echo "probe_rc=$?"    # 问题态:RESULT: BAD(不一致 14 条)/ rc=1
bash ops/verify.sh  ; echo "verify_rc=$?"   # 问题态:PASS=21 FAIL=18 / RESULT: BAD
bash ops/negative.sh                        # 问题态:PASS=14 FAIL=2
python3 -m app.service ; echo "rc=$?"       # 问题态:rc=0(假的绿)

无 docker CLI(本次实际环境)

pipeline 容器里 command -v docker 返回 127,按 Dockerfile 的 COPY 布局在本地还原 /srv 后直接跑:

cd scenario/2026-10-26-0002
bash ops/repro.sh   ; echo rc=$?    # RESULT: BAD,rc=1(rc=1 是预期信号,用 ; 接住)
bash ops/verify.sh  ; echo rc=$?    # PASS=21 FAIL=18 / RESULT: BAD
bash ops/negative.sh                # PASS=14 FAIL=2(N14/N15 红)

# 模拟镜像 /srv 布局,确认 COPY 进容器的代码同样正确
rm -rf /tmp/srv && mkdir -p /tmp/srv/reports
cp -r app conf input docs client ops /tmp/srv/ && cd /tmp/srv
bash ops/repro.sh ; echo rc=$?      # RESULT: OK,rc=0
bash ops/verify.sh                  # PASS=39 FAIL=0 / RESULT: OK
bash ops/negative.sh                # PASS=16 FAIL=0 / RESULT: OK

三、根本原因

问题不在 Broker,在审计模块自己算错账,又把错误藏进绿灯。9 个计算题眼 + 1 个假绿机制:

#位置错误契约要求
1contract.py::load读不到契约 except OSError: pass,用 ENGINE_DEFAULTS 兜底fail-closed,rc=3
2audit.py::_session_expiry取 engine_defaults.session_expiry_seconds(60s)取顶层 session_expiry_seconds(600s)
3audit.py::_sessionspersisted = connected or expiredconnected and not expired
4_subs / effective_qosgranted_qos=-1 被拒订阅照样计入,effective_qos=-1被拒订阅不生效
5audiencesetdefault 取第一条订阅 qos,重复订阅重复计数取最大 granted_qos,每客户端每条只算一次
6通配符/受众空层级与 +/# 语义错,# 客户端重复计入+ 不跨 /;# 匹配零层或多层;a//b 空层合法
7_retained不判 payload_len=0 清除;受众不分 clean_session零长度 retain = 清除;只算 clean_session=0
8buildeffective_qos 不夹 max_qos;比较用原始 granted_qos一律 min(granted_qos, max_qos)
9传输段半帧 sent_bytes 也计;重发判据含糊只计 frame_complete=1;只看末次 ack_ok
10health.pyBAD_FIELDS=() 空判据表 + 复用 audit + 恒 return 0非空判据 + 独立重算 + 分层退出码

假绿的完整链路:

报告算错  →  健康检查复用 audit 的计算  →  照错的再算一遍"一致"  →  报绿
        ↘  判据表是空元组,issues 恒 []  ↗
        ↘  return 0 写死,退出码分层形同虚设 ↗

一句话:同源即自证,自证必假绿。

四、解决办法

改动只在 app/(conf/ docs/ input/ client/ ops/ 一行未动)。

1. contract.py fail-open → fail-closed

class ContractError(Exception): ...

REQUIRED = {
    "now": lambda v: isinstance(v, int) and v >= 0,
    "session_expiry_seconds": lambda v: isinstance(v, int) and v > 0,
    "max_qos": lambda v: isinstance(v, int) and 0 <= v <= 2,
    "retain_handling": lambda v: isinstance(v, int) and v in (0, 1),
    "engine": lambda v: isinstance(v, str) and v != "",
}

def load(path=CONTRACT_PATH):
    try:
        with open(path, "r", encoding="utf-8") as f:
            conf = json.load(f)
    except OSError as e:
        raise ContractError("契约不可用: %s" % e)   # 不再静默回退默认值
    except ValueError as e:
        raise ContractError("契约不是合法 JSON: %s" % e)
    return validate(conf)

关键动作:删掉 ENGINE_DEFAULTS 兜底分支。

2. audit.py 按契约重算

# 会话:过期只看业务口径,persisted 必须是 connected 且未过期
expired = (now - c["last_seen"]) > conf["session_expiry_seconds"]
persisted = c["connected"] and not expired
queued_count = 0 if (expired or c["connected"]) else 1

# 订阅:被拒订阅在加载阶段就丢弃,后面处处少一个 if
if loaders.as_int(r["granted_qos"]) < 0: continue

# 受众:dict + max 天然完成「同客户端去重 + 只抬 QoS」
hit[cid] = max(hit.get(cid, -1), row["granted_qos"])

# 保留:先取每主题 pub_seq 最大的 retain=1,再滤掉 payload_len=0(清除)
latest = {t: p for t, p in latest.items() if p["payload_len"] > 0}

# 有效 QoS:夹 max_qos,过期会话强制 0
sess["effective_qos"] = 0 if sess["expired"] else min(max(granted), cap)

# 传输:半帧不计字节,未投递只看末次尝试
sent_bytes = sum(t["sent_bytes"] for t in trans if t["frame_complete"])
undelivered = sum(1 for rows in groups.values()
                  if not sorted(rows, key=lambda x: x["attempt"])[-1]["ack_ok"])

3. health.py 独立重算 + 非空判据表

def recompute(conf):
    """在 health 内部重新读 input/*.tsv 重算,不 import audit 的任何推导。"""
    ...

def compare(expected, report):
    """逐字段比对 totals/deliveries/transport/sessions[*]/per_topic[*]/ok,条数与值都查。"""
    ...

CHECKS = 11   # 非空:7 个统计字段 + 4 个语义字段

判定语义自己写 ok = mismatches == 0 and undelivered == 0 and queued == 0,不读报告里的 ok 当依据。

4. run.py / service.py 退出码分层

  • run.py:契约异常 → 3;事实不可用 → 2;正常出报告 → 0(契约第 0 节 + verify.sh A1 要求)。
  • service.py:跑 run 再跑 health,退出码取 health 的结论;契约异常优先保留 3。 于是 python3 -m app.service 从"恒 0"变成"如实 rc=2"。

五、验证

cd scenario/2026-10-26-0002
bash ops/verify.sh   ; echo rc=$?    # PASS=39 FAIL=0 TOTAL=39 / RESULT: OK
bash ops/negative.sh ; echo rc=$?    # PASS=16 FAIL=0 TOTAL=16 / RESULT: OK
bash ops/repro.sh    ; echo rc=$?    # 探针 RESULT: OK / rc=0

修复前后关键账:

指标修复前修复后
deliveries.matches107(重复订阅去重)
deliveries.mismatches55(真问题,如实报出)
deliveries.retained_targets32(只算 clean_session=0)
deliveries.qos_downgraded21(夹 max_qos 后被拒订阅不再误算)
transport.sent_bytes6152(半帧不计)
edge-01 expired(静置 200s)True(误用 60s)False(用 600s)
edge-06 effective_qos-10(被拒订阅不生效)
retain=1 且 payload_len=0retained=TrueFalse(清除语义)
app.service rc0(假绿)2(如实报警)
契约缺失/非法静默回退rc=3(fail-closed)

假绿通道实测已堵死:

# 篡改自述字段 ok
python3 -c "import json;p='reports/audit.json';d=json.load(open(p));d['ok']=not d['ok'];json.dump(d,open(p,'w'))"
python3 -m app.health ; echo rc=$?     # rc=2(不再自证健康)

# 篡改统计值
python3 -c "import json;p='reports/audit.json';d=json.load(open(p));d['transport']['sent_bytes']=61;json.dump(d,open(p,'w'))"
python3 -m app.health ; echo rc=$?
# {"degraded": true, "issues": ["transport.sent_bytes: 期望 52 实际 61"], "rc": 2}

fail-closed 全路径实测:

场景实测 rc
契约文件缺失(health/run/service)3 / 3 / 3
max_qos=9 越界3
缺 session_expiry_seconds3
契约不是合法 JSON3
报告缺失2
报告一致但 ok=false2
报告被篡改2

合规性:git diff --stat -- conf docs input client ops 为空,考卷与探针一行未动。

六、经验与避坑

  1. "四盏绿灯"常常是同一根因的四个侧面。退出码恒 0 + 判据表空 + 健康检查复用被测代码 + 报告字段齐全,各自看都像"没事",合起来是系统性假绿。先找共同上游,别逐条修表象。
  2. 健康检查一旦复用被测代码,就不再是检查。它是"用同一套可能错的推导再得出同一个错结论"。 必须重新读原始事实、独立重算。
  3. 空判据表 = 恒绿。BAD_FIELDS = () / CHECKS = [] 这种"预留位"是假绿经典形态, 加断言:判据表长度必须 > 0。
  4. fail-open 是审计系统的头号毒药。读不到契约就回退"引擎默认值", 会把 session_expiry_seconds 从 600 悄悄换成 60,一批活跃会话被判过期,日志一句报错都没有。
  5. engine_defaults 这类"构建期常量"最容易冒充业务口径。同一份 JSON 放两套语义相近的数值, 谁先被 conf.get() 撞上谁生效 —— 取值要看路径,不是"能取到值就算对"。
  6. "每条消息每个客户端只交付一次"用数据结构保证,别靠计数字段。 dict[client_id] = max(qos) 一次解决重复订阅、多过滤器命中、# 与 + 同时命中三种重复来源。
  7. payload_len=0 的 retain 是"清除"不是"空消息"。漏判会让已删除的保留值继续投给新订阅者。
  8. 半帧字节不能算已发送。frame_complete=0 表示物理帧被丢弃,判据要绑 frame_complete。
  9. + 不跨 /,空层级合法:a//b 是三层(中间空串),a/+/b 必须命中。 用 split("/") 而非 strip("/").split("/"),否则空层级被吃掉。
  10. 被拒订阅(granted_qos=-1)是"不存在的订阅",不是"qos 为 -1 的订阅"。加载阶段丢弃最稳。
  11. 负向验证比正向更有价值。12 个单点回滚变异体证明每条语义都被独立断言覆盖, 避免"改完绿了但只是碰巧撞对答案"。闸门恒绿和恒红一样不可信,中性对照 N0 用来排除恒红。
  12. 退出码语义一次定清。app.run 恒 0(只表示"报告写出来了")与 app.service 恒 0(假绿) 是两件事:前者可能合规,后者一定违规。
  13. repro.sh 的 rc 是探针 rc,不是健康 rc。问题态 rc=1、修复后 rc=0, 用 ; echo rc=$? 接住别用 &&。
  14. 改完先清 __pycache__、Docker 场景先 up -d --build。 app/ 是 COPY 进镜像的,没重建就是跑旧代码 —— "改了没生效"十次有九次是这个。
  15. 统计口径要能被外部独立复算。client/audit_probe.py 不 import 被测代码、不读自述字段, 只按契约重算,才能当"唯一说真话的"裁判。

版权声明

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

分享这篇文章