[C-02][high] 回执没有在途标记:每次推送都重发最早 64 条未确认回执,并发时必然重复,第 65 条起可能永远发不出 #33

Closed
opened 2026-09-30 13:56:58 +08:00 by nixevol · 2 comments
Owner

编号:C-02 严重级:high 工作线:消息核心(internal/app/message、serve 的 messageLoops) 来源:审查 M-04
依赖:C-01 (#32)(在途表挂在每连接 worker 上) 被依赖:D-03 (#29)

结论与统一方案

采用审查 M-04 的方案(测试中观察到的"同一时刻两个 receipt 帧"即此问题):

  1. App 内维护每连接的回执在途表:pushReceipts 只发不在途的回执,数量不超过"窗口减在途数",发布前标记、失败撤销;在途超过重发间隔(建议与确认超时一致)才允许重发。
  2. ReceiptAck 成功后移出在途并唤醒;OnDisconnect 清掉该连接的在途表;OnPublishDropped 收到 receipt 类型时撤销标记。
  3. Ack 只在状态真正改变且确实写了回执时才唤醒发送方;重复 ack 不唤醒。
  4. pushReceipts 改为"先收集结果、关闭 rows、再发布",不在持有读连接时调用 PublishDown(D-03 的前提)。

DEVELOPMENT 6.4"可能重复,SDK 按 receipt_id 去重"仍成立(断线重连后未确认的回执会各重发一次),但不能每秒全量重发。DEVELOPMENT 第 10 节补一句:裸设备要么实现 receipt_ack,要么发送时带 receipt:false。更新 DEVIATIONS M2/M3/M4 第 5 条。

改动文件

internal/app/message/push.go、ack.go、session.go、app.go;docs/DEVELOPMENT.md 第 10 节。

与其他问题的交互 / 冲突说明

  • 依赖 C-01。
  • test/accept/rest_accept_test.go 里为此写的 drainReceipts 绕行需同步调整(T-02 知悉)。

验收与测试

  • 窗口设为 2、造 5 条回执:连续调 3 次推送只发出 2 帧;确认 1 条后再推只新发 1 帧。
  • 10 个协程并发推送:每条回执只发一次。
  • 断线重连后未确认的回执各重发一次。
  • 重复 ack 不触发回执下发;给自己发消息被确认后只出现一个回执帧。

问题明细(各区审查原文,证据含文件与行号)

以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。解决方案以本 issue 上方的"结论与统一方案"为准;原文里的方案与之不一致时,按上方执行。

[M-04] 回执没有在途标记:每次推送都重发最早 64 条未确认回执,并发时必然重复,第 65 条起可能永远发不出

  • 严重级:high
  • 分类:并发 / 协议一致性
  • 现象与影响(回答你点名要核实的问题):
    • 确实会从多个路径并发下发同一回执。 每次 PushPending 结尾都会调 pushReceipts,它只是查 acked=0 ORDER BY receipt_id LIMIT 64,然后全部发出。
    • 调用 PushPending 的路径有:每秒循环;ack 后的 WakePush(发送方);握手;旧连接断开;1 秒重推定时器。
    • WakePush 每次调用都起一个新协程,不合并。
    • 给自己发的消息被确认时,Ack 会对同一个编号 WakePush 两次,两个协程同时下发同一条回执。这就是你在测试里看到的「同一时刻两个 receipt 帧」。
    • 后果:
      • 在收到 receipt_ack 之前,每秒都会重发。SDK 每收到一次重复回执都会再回一次 receipt_ack,写库量随之放大。
      • 裸 MQTT 设备(PRD F20 和 DEVELOPMENT 第 10 节都没要求实现 receipt_ack)会在 7 天里每秒收到同样的 64 条回执,第 65 条起永远发不出去。
      • 群发放大:1000 人群每个成员确认都会触发一次对发送方最多 64 帧的重发,量级可达 6 万帧;超过 mochi 单客户端 inflight 上限 1024 后会被静默丢弃,其中可能包括发送方的 resp。
      • 滥用放大:ack 不计入请求频率,接收方反复 ack 同一条已收下的消息,就能驱动服务端向发送方刷回执。
  • 证据:
	// 简化:未单独记 inflight 回执,按未确认回执取窗口条数
	rows, err := a.db.Read.QueryContext(ctx, `
SELECT receipt_id, msg_id, endpoint_id, state, reason, created_at
FROM receipts
WHERE sender_id = ? AND acked = 0
ORDER BY receipt_id ASC
LIMIT ?`, endpointID, window)
  • push.go:117、push.go:236:每次 PushPending 都会调用它。
  • ack.go:74-75:无条件两次 WakePush,重复 ack 也会触发。
  • ack.go:289-301:ReceiptAck 不唤醒下一批。
  • test/accept/rest_accept_test.go:863 里的 drainReceipts,就是测试为这个行为做的绕行。
  • 文档依据:DEVELOPMENT 6.4「回执也按窗口推送,默认 64」(窗口的含义是未确认满 64 条就停,确认一条再推下一条);TASKS M3「回执写入和推送窗口」;PRD F14。
  • 为何不是故意设计:DEVIATIONS M2/M3/M4 第 5 条只声明「慢确认时可能多推几条」。实际行为是每次推送全量重发、并发重复,而且不确认就饿死后续回执,远超声明的影响范围。
  • 解决方案:
    1. App 内维护 rcptInflight[connID]map[receiptID]推送时刻(加锁)。pushReceipts 只发不在途的回执,数量不超过「窗口减在途数」,发布前就标记(防止并发重复),发布失败时撤销标记。在途超过重发间隔(例如 60 秒,或与确认超时一致)才允许重发。
    2. ReceiptAck 成功后移出在途并唤醒该端;OnDisconnect 清掉该连接的在途表;OnPublishDropped 收到 receipt 类型时撤销标记,1 秒后重推。
    3. Ack 只在状态真正改变、且确实写了回执时才唤醒发送方;重复 ack 不唤醒。
    4. 配合 M-11 的「每端单推送循环」。更新 DEVIATIONS M2/M3/M4 第 5 条;DEVELOPMENT 第 10 节建议补一句:裸设备要么实现 receipt_ack,要么发送时带 receipt:false(文档改动)。
  • 改动文件:internal/app/message/push.go、ack.go、session.go、app.go。
  • 交互/冲突风险:SDK 已按 receipt_id 去重,行为兼容。改后重复会少很多,接受测试里的 drainReceipts 需要跟着调整。
  • 需补测试:
    • 窗口设为 2、造 5 条回执:连续调 3 次 PushPending 只发出 2 帧。
    • 确认 1 条后再推只新发 1 帧。
    • 10 个协程并发 PushPending:每条回执只发一次。
    • 断线重连后未确认的回执各重发一次。
    • 重复 ack 不触发回执下发。
    • 给自己发消息被确认后只出现一个回执帧。
  • 置信度:代码阅读确定(与测试中观察到的现象吻合)。

复审基线:main 4059a15(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。

**编号**:C-02 **严重级**:high **工作线**:消息核心(internal/app/message、serve 的 messageLoops) **来源**:审查 M-04 **依赖**:C-01 (#32)(在途表挂在每连接 worker 上) **被依赖**:D-03 (#29) ### 结论与统一方案 采用审查 M-04 的方案(测试中观察到的"同一时刻两个 receipt 帧"即此问题): 1. App 内维护每连接的回执在途表:`pushReceipts` 只发不在途的回执,数量不超过"窗口减在途数",发布前标记、失败撤销;在途超过重发间隔(建议与确认超时一致)才允许重发。 2. `ReceiptAck` 成功后移出在途并唤醒;`OnDisconnect` 清掉该连接的在途表;`OnPublishDropped` 收到 receipt 类型时撤销标记。 3. `Ack` 只在状态真正改变且确实写了回执时才唤醒发送方;重复 ack 不唤醒。 4. `pushReceipts` 改为"先收集结果、关闭 rows、再发布",不在持有读连接时调用 `PublishDown`(D-03 的前提)。 DEVELOPMENT 6.4"可能重复,SDK 按 receipt_id 去重"仍成立(断线重连后未确认的回执会各重发一次),但不能每秒全量重发。DEVELOPMENT 第 10 节补一句:裸设备要么实现 `receipt_ack`,要么发送时带 `receipt:false`。更新 DEVIATIONS M2/M3/M4 第 5 条。 ### 改动文件 `internal/app/message/push.go`、`ack.go`、`session.go`、`app.go`;`docs/DEVELOPMENT.md` 第 10 节。 ### 与其他问题的交互 / 冲突说明 - 依赖 C-01。 - `test/accept/rest_accept_test.go` 里为此写的 `drainReceipts` 绕行需同步调整(T-02 知悉)。 ### 验收与测试 - 窗口设为 2、造 5 条回执:连续调 3 次推送只发出 2 帧;确认 1 条后再推只新发 1 帧。 - 10 个协程并发推送:每条回执只发一次。 - 断线重连后未确认的回执各重发一次。 - 重复 ack 不触发回执下发;给自己发消息被确认后只出现一个回执帧。 --- ### 问题明细(各区审查原文,证据含文件与行号) > 以下是本次复审各区审查报告的原文段落。A、M、I、P、S 开头的是原始发现编号(A 管理后台与网页、M 消息核心、I 身份认证群在线、P 传输平台部署、S SDK)。**解决方案以本 issue 上方的"结论与统一方案"为准**;原文里的方案与之不一致时,按上方执行。 #### [M-04] 回执没有在途标记:每次推送都重发最早 64 条未确认回执,并发时必然重复,第 65 条起可能永远发不出 - 严重级:high - 分类:并发 / 协议一致性 - 现象与影响(回答你点名要核实的问题): - **确实会从多个路径并发下发同一回执。** 每次 `PushPending` 结尾都会调 `pushReceipts`,它只是查 `acked=0 ORDER BY receipt_id LIMIT 64`,然后全部发出。 - 调用 `PushPending` 的路径有:每秒循环;ack 后的 `WakePush(发送方)`;握手;旧连接断开;1 秒重推定时器。 - `WakePush` 每次调用都起一个新协程,不合并。 - 给自己发的消息被确认时,`Ack` 会对同一个编号 `WakePush` 两次,两个协程同时下发同一条回执。这就是你在测试里看到的「同一时刻两个 receipt 帧」。 - 后果: - 在收到 `receipt_ack` 之前,每秒都会重发。SDK 每收到一次重复回执都会再回一次 `receipt_ack`,写库量随之放大。 - 裸 MQTT 设备(PRD F20 和 DEVELOPMENT 第 10 节都没要求实现 `receipt_ack`)会在 7 天里每秒收到同样的 64 条回执,第 65 条起永远发不出去。 - 群发放大:1000 人群每个成员确认都会触发一次对发送方最多 64 帧的重发,量级可达 6 万帧;超过 mochi 单客户端 inflight 上限 1024 后会被静默丢弃,其中可能包括发送方的 `resp`。 - 滥用放大:ack 不计入请求频率,接收方反复 ack 同一条已收下的消息,就能驱动服务端向发送方刷回执。 - 证据: ```461:467:e:\code\NixMsg\internal\app\message\push.go // 简化:未单独记 inflight 回执,按未确认回执取窗口条数 rows, err := a.db.Read.QueryContext(ctx, ` SELECT receipt_id, msg_id, endpoint_id, state, reason, created_at FROM receipts WHERE sender_id = ? AND acked = 0 ORDER BY receipt_id ASC LIMIT ?`, endpointID, window) ``` - `push.go:117`、`push.go:236`:每次 `PushPending` 都会调用它。 - `ack.go:74-75`:无条件两次 `WakePush`,重复 ack 也会触发。 - `ack.go:289-301`:`ReceiptAck` 不唤醒下一批。 - `test/accept/rest_accept_test.go:863` 里的 `drainReceipts`,就是测试为这个行为做的绕行。 - 文档依据:DEVELOPMENT 6.4「回执也按窗口推送,默认 64」(窗口的含义是未确认满 64 条就停,确认一条再推下一条);TASKS M3「回执写入和推送窗口」;PRD F14。 - 为何不是故意设计:DEVIATIONS M2/M3/M4 第 5 条只声明「慢确认时可能多推几条」。实际行为是每次推送全量重发、并发重复,而且不确认就饿死后续回执,远超声明的影响范围。 - 解决方案: 1. App 内维护 `rcptInflight[connID]map[receiptID]推送时刻`(加锁)。`pushReceipts` 只发不在途的回执,数量不超过「窗口减在途数」,发布前就标记(防止并发重复),发布失败时撤销标记。在途超过重发间隔(例如 60 秒,或与确认超时一致)才允许重发。 2. `ReceiptAck` 成功后移出在途并唤醒该端;`OnDisconnect` 清掉该连接的在途表;`OnPublishDropped` 收到 receipt 类型时撤销标记,1 秒后重推。 3. `Ack` 只在状态真正改变、且确实写了回执时才唤醒发送方;重复 ack 不唤醒。 4. 配合 M-11 的「每端单推送循环」。更新 DEVIATIONS M2/M3/M4 第 5 条;DEVELOPMENT 第 10 节建议补一句:裸设备要么实现 `receipt_ack`,要么发送时带 `receipt:false`(文档改动)。 - 改动文件:`internal/app/message/push.go`、`ack.go`、`session.go`、`app.go`。 - 交互/冲突风险:SDK 已按 `receipt_id` 去重,行为兼容。改后重复会少很多,接受测试里的 `drainReceipts` 需要跟着调整。 - 需补测试: - 窗口设为 2、造 5 条回执:连续调 3 次 `PushPending` 只发出 2 帧。 - 确认 1 条后再推只新发 1 帧。 - 10 个协程并发 `PushPending`:每条回执只发一次。 - 断线重连后未确认的回执各重发一次。 - 重复 ack 不触发回执下发。 - 给自己发消息被确认后只出现一个回执帧。 - 置信度:代码阅读确定(与测试中观察到的现象吻合)。 --- <sub>复审基线:main `4059a15`(2026-09-30)。编号说明、各工作线的合并顺序、共享文件归属见总览 #7。</sub>
nixevol added the P1-highlane/messagereview-2026-09-30 labels 2026-09-30 13:56:58 +08:00
Author
Owner

C-02 已在 feat/fix-message-c01-c03 提交 75f4647cae (#33)

回执按连接去重在途,先收集再发布。未合入 main。

C-02 已在 `feat/fix-message-c01-c03` 提交 https://git.asio.asia/nixevol/NixMsg/commit/75f4647cae77ca1838995a7793f61187d8bd4458 (#33) 回执按连接去重在途,先收集再发布。未合入 main。
Author
Owner

已合入 origin/main 0c9b459。落地提交 bd550f3 fix: 回执按连接去重在途且不改可重复约定 (#33)。

已合入 origin/main `0c9b459`。落地提交 `bd550f3` fix: 回执按连接去重在途且不改可重复约定 (#33)。
Sign in to join this conversation.