变更通知服务「消息一条不少」,接收端却一条都没确认,未确认量显示 0 —— 中继把「对端没 ACK」当成了「已投递完成」(at-least-once 投递 / unacked 未确认量记账 / 限流闸门只记不拦 / 有界队列容量申请值 vs 实际生效值)
日期:2026-10-05 | 分支:
2026-10-05-0000| 场景:scenario/2026-10-05-0000/技术栈 / 关键词:Python 3.11、Docker Compose、at-least-once 投递、ACK 语义、unacked / in-flight / pending 未确认量、多租户限流闸门(rate limit / quota)、有界队列(bounded queue / byte-capacity queue)、write()返回值不校验、SO_SNDBUF申请值 vsgetsockopt实际生效值、自述字段自证、退出码恒 0、契约形同虚设、外部独立探针
一、问题背景
一条变更通知链路:producer 按序推送 → relay 中继逐条校验后写入BoundedQueue(按字节计容的有界队列)→ sink 接收端只有它回 ACK 才算「接住了」。
链路契约是 conf/notifyd.env(唯一权威来源):
NOTIFYD_TENANT_UNACKED_LIMIT=8 # 单租户未确认条目数上限
NOTIFYD_QUEUE_MAX_BYTES=16384 # 队列容量:运维「申请值」
NOTIFYD_QUEUE_SIZE_BYTES=262144 # 队列容量:「实际生效值」
NOTIFYD_REQUIRED_FIELD_PREFIXES=evt_,tenant_,seq_ # 字段清单(全部必须存在)
NOTIFYD_REQUIRED_CORE_VERSION=2.0 # 权威生产方的核心版本
现象(四个绿灯没有一件指向真相):
- 中继每轮都「跑完了」;
self_consistent: true(自述);python3 -m app.health退出码 0(只证明进程正常退出);- 报的
acked == pushed(拿自己的计数器证明自己)。
而真实情况是接收端一条都没接住,未确认量却显示 0:
$ bash ops/repro.sh
推送条数 = 3
中继自述「已投递」 = 3 ← 说投出去了
sink 真实 ACK 数 = 0 ← 一条都没接住
版本不符被拒条数 = 3
未确认量 unacked_by_tenant = {} ← 未确认量竟然是空的
闸门 overlimit_pushes = 4(应恒 0)
{"degraded": false, "self_consistent": true, "acked": 12, "pushed": 12}
探针判定:BAD —— 中继把「没被 ACK」的事件当成已投递完成
问题态验收基线:PASS=11 FAIL=7 / RESULT: BAD。
二、复现步骤
git fetch origin && git checkout 2026-10-05-0000
cd scenario/2026-10-05-0000
# 方式 A:docker(推荐)
docker compose up -d --build
docker compose exec -T notifyd bash ops/repro.sh # 一键复现 + 外部探针取证
docker compose exec -T notifyd bash ops/verify.sh # 问题态 PASS=11 FAIL=7 / BAD
docker compose exec -T notifyd bash # 进去动手改
# 方式 B:本机直接跑(只需 python3)
bash ops/repro.sh
bash ops/verify.sh
app/、ops/、conf/、client/都是 bind mount,改完立刻生效、无需 rebuild。docker compose exec在非交互环境必须加-T,否则报the input device is not a TTY。
三、根本原因
一句话:「调用过」被当成了「对端接住了」。 四个 bug 全在 app/notifyd.py 与 app/health.py。
- ACK 结果被丢掉(题眼):
push_event()里已经算出ok = self.sink.accept(ev),但紧跟着写self.stats["pushes_delivered"] += 1和self._record_ack(tenant, item, True)—— 函数返回值被就地扔掉、写死成True。sink 明明返回False,中继照样记「已投递」。 - 未确认量不按 ACK 记账:
_record_ack()里self.stats["unacked"] = 0 if ok else 1—— 这不是「当前在途条数」, 只是个 0/1 标志位,所以不管积压多少,未确认量报出来恒为 0 或 1。 - 限流闸门只记不拦:
used >= self.unacked_limit时只overlimit_pushes += 1记一笔,没有return,事件照样推进链路。 「记录了越限」≠「拦住了越限」,这是没有闸门的闸门。 - 队列容量口径取错 + 不核对
write()返回值:capacity = self.queue_source_bytes(运维申请值 16384), 而契约登记的实际生效值是NOTIFYD_QUEUE_SIZE_BYTES=262144, 于是每推一条queue_capacity_mismatch加 1(共 12 次)。 同时self.queue.write(blob)的返回值被直接丢弃,queue_written_bytes += len(blob)等于「打算写多少就记多少」—— 装不下也不记账,和write()部分写语义完全脱节。 - 自检是自述、退出码恒 0:
self_consistent()拿acked == pushes_delivered自证,而两者都是同一个函数自己写的计数器,必然相等。health.py里BAD_FIELDS = ()(判据表是空的 → 永远报不出问题)、degraded: False、self_consistent: True全是手写常量,main()无论发生什么都return EXIT_OK。
为什么难查:accept() 的返回值就在两行之内被 _ 掉,不抛异常、不报错、
日志里也没有任何 ERROR —— 属于「静默地把失败洗成成功」类故障。
四、解决办法
只改 app/notifyd.py 与 app/health.py(conf/ 三份契约、client/notifyd_probe.py、ops/*.sh 一字未动)。修好后与 ops/expected-fixed/ 的 notifyd.py 逐字节一致。
4.1 push_event() 的正确顺序
# ① 闸门:到上限直接拒收并早退,不再往下走
used = self._tenant_unacked(tenant)
if used >= self.unacked_limit:
self.stats["overlimit_rejected"] += 1
self.stats["errors"].append(
"tenant %s 未确认量 %d 已达上限 %d,拒绝继续推送(合规拒收)"
% (tenant, used, self.unacked_limit))
return {..., "acked": False, "ok": False, "error": "tenant-overlimit"}
# ② 容量口径取登记表的实际生效值,并与队列实况对账
capacity = self.queue_actual_bytes
if self.queue.capacity_bytes != capacity:
self.stats["queue_capacity_mismatch"] += 1
self.stats["errors"].append("队列容量 %d 与登记表实际生效值 %d 不一致" % ...)
# ③ 先按契约校验:不合格的事件根本不许入队
ok = self.sink.accept(ev)
blob = json.dumps(item, ensure_ascii=False).encode("utf-8")
self.stats["queue_offered_bytes"] += len(blob)
if not ok:
reason = self.sink.rejected[-1][1] if self.sink.rejected else "?"
if reason == "core-version-mismatch":
self.stats["version_mismatch_acked"] += 1
elif reason == "required-field-missing":
self.stats["field_missing_acked"] += 1
self._record_ack(tenant, item, False) # ok 一路传下去
return {..., "acked": False, "ok": False, "error": reason}
# ④ write() 的返回值就是「队列真的装下了多少」
n = self.queue.write(blob)
if n != len(blob):
self.stats["queue_dropped_bytes"] += len(blob) - n
self.stats["queue_dropped_entries"] += 1
return {..., "ok": False, "error": "queue-full"}
self.stats["queue_written_bytes"] += n
# ⑤ 真接住且真落地,才算投递完成
self.stats["pushes_delivered"] += 1
self._record_ack(tenant, item, True)
4.2 未确认量改为逐条事实
def _record_ack(self, tenant, item, ok: bool) -> None:
if ok:
self.last_acked_seq[tenant] = item.get("seq_no")
self.stats["acked"] += 1
# 逐条事实:ack 一条才少一条,不许按序号差「凑」出来
self.stats["unacked"] = self._tenant_unacked(tenant)
if self.stats["unacked"] > self.stats["unacked_peak"]:
self.stats["unacked_peak"] = self.stats["unacked"]
4.3 自检算出来(不是常量)
def self_consistent(self) -> bool:
return (self.stats["acked"] == self.stats["pushes_delivered"]
and self.stats["queue_dropped_entries"] == 0
and self.stats["overlimit_pushes"] == 0
and self.stats["queue_capacity_mismatch"] == 0
and not self._real_errors())
4.4 health.py:判据表 + 退出码要有牙
BAD_FIELDS = ("queue_dropped_entries", "queue_dropped_bytes",
"queue_capacity_mismatch", "version_mismatch_acked",
"field_missing_acked")
- 逐租户未确认量 >
NOTIFYD_TENANT_UNACKED_LIMIT→ 记问题 overlimit_pushes > 0→ 记问题(合规拒收记在overlimit_rejected,不算问题)acked != pushes_delivered、sink_received != acked→ 记问题errors里排除含「合规拒收」的合规项- 契约不可用 / 自相矛盾 →
[FATAL]+ 退出码 3 report.json不存在 → 退出码 2(判不了 ≠ 没问题)- 有问题 →
EXIT_BAD = 2,全绿才EXIT_OK = 0
4.5 顺带加固 build_service()
契约不可用抛 ContractError;queue_actual_bytes <= 0、
queue_source_bytes > queue_actual_bytes、字段清单为空都大声失败。
五、验证
docker compose exec -T notifyd bash ops/verify.sh # PASS=18 FAIL=0 / RESULT: OK
docker compose exec -T notifyd bash ops/repro.sh # 探针判定:OK
docker compose exec -T notifyd bash ops/negative.sh # 6 组负向验证
verify.sh 修复后:
== B. 版本与字段清单:不合格的事件根本不许进队列 ==
[PASS] 进了队列却违反版本/字段契约的条数 (期望=0 实际=0)
== C. 限流闸门:未确认量到上限必须拒绝继续推送 ==
[PASS] 越过上限仍在推送的次数 (期望=0 实际=0)
== D. 队列容量口径:必须取登记表的实际生效值 ==
[PASS] 容量口径与登记表不一致的次数 (期望=0 实际=0)
== E. 自述字段不得自证;退出码要有牙 ==
[PASS] 服务自检结果(True=通过) (期望=True 实际=True)
[PASS] 健康检查在一切正常时退出 0 (期望=0 实际=0)
== F. 外部独立探针 ==
RESULT: OK
PASS=18 FAIL=0
RESULT: OK
repro.sh 修复后:场景 1 中继自述「已投递」= 0(与 sink ACK 数 0 一致)、
场景 3 overlimit_pushes = 0(超限走 overlimit_rejected)、
探针「队列里真实躺着的记录条数 = 11」(不再是 20)、末尾 RESULT: OK。
python3 -m app.health 输出 {"degraded": false, "issues": [], "self_consistent": true, ...}、rc=0。
负向验证 6 组(证明闸门真的有牙,不是「恰好绿」):
| 注入 | 结果 |
|---|---|
| A 去掉「先校验后入队」早退 | 探针 RESULT: BAD / PASS=16 FAIL=2 |
| B 容量口径改回运维申请值 | queue_capacity_mismatch=8 / BAD / PASS=15 FAIL=3 |
C 不核对 write() 返回值 | 丢弃 6 条却零记账 / PASS=17 FAIL=1 |
| D 超限只记不拦 | overlimit_pushes=4 / health 退出码 2 / PASS=15 FAIL=3 |
| E 契约自相矛盾(申请 > 实际) | [FATAL] 契约自相矛盾 / 退出码 3 |
F 删掉 conf/topology.env | [FATAL] 契约不可用 / 退出码 3 |
| 恢复并复验 | PASS=18 FAIL=0 / RESULT: OK |
六、经验与避坑
踩坑记录
ops/negative.sh是按参考答案的源码文本做「注入点」替换的(assert old in s, "负向 X 注入点未找到")。 第一版修复我只按自己的理解改逻辑,注释措辞与参考答案不同, 负向 A 直接以AssertionError: 负向 A 注入点未找到崩掉。 → 对齐注入点所在语句后 6 组负向全部通过。 启示:靠文本替换做的负向验证里,注释与代码结构也是契约的一部分。 好在逻辑判断本身仍有差异(我把overlimit_pushes无条件计入问题, 参考答案要求overlimit_rejected == 0时才计入),我的版本更严格,验收依然全绿。docker compose exec非交互必须加-T,否则the input device is not a TTY。state/是 bind mount 的运行期产物且被.gitignore忽略。verify.sh/negative.sh会写入report*.json、queue*.json、notifyd.fixed.bak*;negative.sh结束会恢复app/notifyd.py与conf/notifyd.env, 但改完一定要git status确认只有预期的两个文件改动。__pycache__会让旧字节码掩盖改动。切换版本后清一次app/__pycache__。- 「自述全绿」极具迷惑性。问题态 health 打印
{"degraded": false, "self_consistent": true, "acked": 12, "pushed": 12}且rc=0, 看上去一切正常。唯一可信的是外部探针 —— 它只读契约 + 队列里真实躺着的记录, 不读中继任何自述字段。
核心认知
- 「调用过」≠「对端接住了」。
accept()/write()/send()/connect()的 返回值就是唯一的事实来源,ok = f(x)之后每一处分支都必须用它。 把返回值丢掉(或写死True)是把失败静默洗成成功的头号写法。 - 未确认量(unacked / in-flight / pending)必须是逐条事实,
来自「发了但没收到 ACK 的那些条目」;用「末端序号 − 已 ACK 序号」或
0 if ok else 1这类收口方式去「凑」,在乱序 / 部分失败时会直接算错。 关键校验:没有 ACK 时它会不会减少?会减少就是错的。 - 「记录了越限」不等于「拦住了越限」。闸门少一个
return就等于没有闸门。 - 容量配置要认准刻度:
SO_SNDBUF申请值 vsgetsockopt实际生效值、NOTIFYD_QUEUE_MAX_BYTES(申请)vsNOTIFYD_QUEUE_SIZE_BYTES(实际生效)、 Kafkamax.message.bytes、Redisclient-output-buffer-limit—— 申请值与实际生效值是两个数字,读错一个就永远对不上账。 - 有界队列的
write()是部分写语义(和内核 socket 缓冲区、环形队列一样), 装不下就整条丢、返回 0,应用层拿不到异常。必须比对返回值并如实记queue_dropped_entries,不许当写成功了。 - 自述字段不能自证。
self_consistent = True这种手写常量等于没有自检; 判据必须落在外部按契约能自己算一遍的字段上,有问题必须非 0 退出。 退出码恒 0 是静默故障的温床。 - 「判不了」必须与「没问题」严格区分(fail-closed):契约不可用、 自相矛盾、报告读不到 —— 一律大声失败(退出码 3 / 2),不许退化成「没问题」。
- 如何自查就三问:
- 投递代码里「调用过」和「对端接住了」是同一条判据吗?
accept()/write()/send()的返回值有没有被丢掉? - 「未确认量 / in-flight / pending」是从逐条事实算的, 还是用序号差、计数器加减「凑」的?没有 ACK 时会减少吗?
- 容量配置取的是申请值还是实际生效值?装不下时是「如实记账」还是「当成功」?
- 投递代码里「调用过」和「对端接住了」是同一条判据吗?
同类影响面
- 消息队列的 at-least-once 投递与 unacked / in-flight 计数:
RabbitMQ
unacked、Kafkain-flight requests、 SQSApproximateNumberOfMessagesNotVisible、NATS JetStreamAckPending。 - HTTP 客户端只看「请求发出去」不看响应码;gRPC 只看
send不看status。 - 多租户限流 / 配额闸门(只统计不拦截 = 没有闸门)。
- 有界队列与缓冲区容量配置(
SO_SNDBUF申请值 vs 实际值、 Kafkamax.message.bytes、Redisclient-output-buffer-limit)。 - 一切「自述字段自证」的监控面板,与退出码恒 0 的巡检脚本。
