news 2026/10/16 1:36:28

WuKongIM Delivery Tag 投递标签机制:leader 权威的频道订阅者分片快照设计与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
WuKongIM Delivery Tag 投递标签机制:leader 权威的频道订阅者分片快照设计与实现
  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

More than just IM 不只是即时通讯(IM)

项目地址:https://gitcode.com/gh_mirrors/wu/WuKongIM
点击查看免费下载

Delivery Tag 是 WuKongIM 为全频道实时投递设计的一套统一成员快照机制:由频道 leader 节点创建并维护"按目标节点分片的订阅者快照",其他节点只能按tagKey拉取自己的分片并缓存,从而避免每条消息都分页扫描订阅者存储。本文以 delivery-tag 实现计划 与配套的 delivery-tag 设计文档 为主体,结合当前仓库的源码证据,完整讲解该机制的背景、数据模型、投递与失效流程、缓存一致性策略,以及六阶段实施步骤与验证命令,读完即可掌握该方案的设计全貌与落地方法。


一、背景:为什么要引入 Delivery Tag

当前实时投递链路已经解决了"群消息按订阅者写多份消息"的落盘问题,但所有频道在实时投递时仍然存在两个热路径成本:

  1. 每条消息都需要分页读取订阅者,并按 UID 查询 presence 路由;
  2. 如果尝试维护channel -> online routes索引,又会把在线/离线高频变化扩散到每个频道或每个节点,节点状态和实时更新成本都很高。

因此,方案参考旧版 WuKongIM 的 tag 机制,把频道成员集合统一做成由频道 leader 管理的投递快照。注意一个关键边界:tag 只描述"频道订阅者按目标节点的分片",不描述实时在线连接;在线状态仍由投递时的 presence / 本地会话状态判断。

该机制适用于所有频道——小频道、普通群、临时频道、cmd 频道和大频道都在覆盖范围内。10 万人频道只是最需要它降低重复扫描成本的高压代表场景,用来验证方案上限和压力保护,而不是唯一适用范围(参见 100k 频道支持风险分析)。

二、核心结论与关键约束

Delivery Tag 的定义:频道 leader 创建和更新的、按节点分片的订阅者快照;其他节点只能按 tagKey 拉取自己的分片并缓存。

实施计划中明确列出的约束(引自 实施计划 的 References And Constraints):

  • Delivery tags 对所有频道都是 leader-authoritative,不局限于 10k/100k 大频道;
  • 普通成员变化保持tagKey稳定、只推进tagVersion;
  • 非 leader 缓存只能包含本地分片,绝不能包含完整订阅者集合;
  • 旧请求绝不能驱逐新的本地 tag ref;
  • SubscriberMutationVersion必须持久化且权威,缓存只消费它、不自行生成;
  • PartitionTopologyVersion必须比较完整的{HashSlotTableVersion, SlotAuthorityRefs[]}形状,而不是取单个 max version;
  • cmd / 派生投递 tag 也必须携带SourceChannelKey和SourceSubscriberMutationVersion,当源频道前进时必须变 stale;
  • leader incarnation 变化、leader 重启或冷缓存重建可以铸造新的tagKey;只有普通成员变更必须保持当前tagKey稳定。

三、数据模型设计

3.1 核心结构

设计文档给出了建议的逻辑结构(命名可在实现阶段调整):

// DeliveryTag is a leader-owned subscriber snapshot for one channel. type DeliveryTag struct { Key string ChannelID string ChannelType uint8 Nodes []DeliveryTagNode CreatedAt time.Time LastAccess time.Time TagVersion uint64 SubscriberMutationVersion uint64 PartitionTopologyVersion PartitionTopologyVersion } // DeliveryTagNode stores the subscriber partition that one node should process. type DeliveryTagNode struct { NodeID uint64 UIDs []string SlotIDs []uint32 } // PartitionTopologyVersion describes the cluster-controlled partitioning state // that affects UID ownership. type PartitionTopologyVersion struct { HashSlotTableVersion uint64 SlotAuthorityRefs []SlotAuthorityRef } // SlotAuthorityRef records the authoritative owner/version for one UID slot // represented by this tag. type SlotAuthorityRef struct { SlotID uint64 LeaderNodeID uint64 ConfigEpoch uint64 BalanceVersion uint64 }

3.2 tagKey 与 tagVersion 的语义分工

  • tagKey是tag incarnation ID,不是 channel ID。首次创建 tag 时由频道 leader 生成;普通成员 Add/Remove/Reset/RemoveAll不改变 tagKey。
  • tagVersion是同一个 tag incarnation 的公开防护版本。普通成员变化、SubscriberMutationVersion前进、或PartitionTopologyVersion变化导致的重建,都必须递增tagVersion;只有当前tagVersion才能代表当前完整的 tag materialization。
  • leader incarnation 变化、leader 重启导致内存 tag 丢失、TTL 后冷重建等场景可以生成新的 tagKey,避免新 leader 用相同 key 和较小 version 命中 follower 旧缓存。
  • SubscriberMutationVersion是成员存储的权威变更版本;tag 复用前必须先比较它是否落后于存储侧的最新版本。
  • follower 缓存命中必须同时匹配tagKey、tagVersion和PartitionTopologyVersion;SubscriberMutationVersion只用于判断快照是否已经过时。

3.3 SubscriberSource:不同频道的订阅者来源

Delivery Tag 适用于所有频道,但不同频道的订阅者来源不同,构建 tag 时不能把所有频道都简单地读 subscriber store。设计文档将解析逻辑抽象成SubscriberSource小接口:

频道类型订阅者来源
个人频道(Person)从 channelID 派生双方 UID,必要时先去掉 cmd 包装,再解析原频道的两个 UID
Agent 频道从 channelID 派生用户 UID 和 Agent UID,与个人频道同属 ID 派生型
群 / 社区 / 话题 / 数据 / 直播 / AgentGroup分页读取 ordinary subscriber store
Info分页读取 ordinary subscriber store,并合并当前生效的临时订阅者 overlay
Temp读取临时频道订阅者来源;若是在线命令类临时投递,则使用请求中明确给出的 UID 集合作为一次性、消息作用域的输入,不写入 channel-level tag cache,也不跨请求复用
Visitors / CustomerService分页读取 customer-service 订阅者来源,并把 visitor UID 从 channelID 中补入结果;CustomerService 是旧兼容路径
cmd / derived 频道先解析原频道,再用原频道的 SubscriberSource 生成 tag;cmd 频道本身不是独立成员集合

抽象成小接口的目的是避免在 access 层或 tag manager 中写死入口协议逻辑;对于实现尚未显式覆盖的 overlay,必须在 source 层补齐,而不是在投递路径末尾临时拼接。

3.4 leader 缓存与 follower 缓存

  • 频道 leader 节点缓存完整 tag:tagKey -> DeliveryTag与channelKey -> currentTagRef。currentTagRef至少包含{tagKey, tagVersion, SubscriberMutationVersion, PartitionTopologyVersion},用于判断一次投递请求比本地缓存更新还是更旧。leader 需要完整 tag,因为它要知道分发到哪些目标节点,也要响应 follower 的GetTag(tagKey, tagVersion, targetNodeID)请求。
  • 非 leader 节点只缓存本节点分片,语义上是 local partition tag,不是频道全量 tag:tagKey -> {tagVersion, SubscriberMutationVersion, PartitionTopologyVersion, local UIDs}与channelKey -> currentTagRef。这样任何频道都不会在每个节点复制全量 UID 快照,大频道收益最明显。

3.5 分片维度

分片建议沿用旧 WuKongIM 思路:按 UID 所属的权威节点分片(如 UID slot leader / presence authority owner)。原因:tag 是成员集合缓存而非在线路由缓存;用户连接实际在哪个接入节点仍由 presence 判断;按 UID 权威节点分片后,每个节点只处理自己负责的 UID 子集,避免所有节点遍历全量成员。

四、投递流程

4.1 频道 leader 获取或创建 tag

消息提交后进入实时投递,频道 leader 执行:

  1. 根据(channelID, channelType)查channel -> currentTagRef;
  2. 若存在 tagKey,读取tagKey -> DeliveryTag并校验tagVersion、SubscriberMutationVersion、PartitionTopologyVersion;
  3. 若 tag 不存在、过期、SubscriberMutationVersion落后,或PartitionTopologyVersion任一分量落后,则 leader 通过 SubscriberSource 解析订阅者并重建 tag;
  4. 重建时按 UID 权威节点聚合,得到DeliveryTag.Nodes;
  5. leader 写入本地缓存;首次创建时生成 tagKey,后续成员变更只递增 tagVersion;
  6. leader 把tagKey和tagVersion附到本次投递事件或节点间投递请求中。

只有这一步允许创建频道 tag。

4.2 leader 按 tag 分发到目标节点,ACK/retry 所有权下沉

leader 不再为远端节点回显所有 routes,只需要根据 tag 的节点分片把消息、tagKey、tagVersion分发到对应节点。tagKey + tagVersion是 leader -> target 的公开 fence;SubscriberMutationVersion和PartitionTopologyVersion只存在于 tag 内部 currentTagRef 中,任何会改变它们的重建都必须同时推进tagVersion,避免外部 wire contract 变成四字段协议。

每个目标节点收到的数据尽量精简为:channelID、channelType、tagKey、tagVersion、消息 frame / metadata、必要的 trace / retry metadata。若目标节点就是 leader 本地节点,则直接进入本地分片投递。

关键设计:ACK / retry 所有权从频道 leader 下沉到目标分片节点。

  • leader 只负责把消息、tagKey、tagVersion 和目标分片任务交给对应节点,不维护该分片内每条 route 的 ACK/retry 状态;
  • 目标分片节点获取本地 UID 分片后解析在线 routes,并作为这些 routes 的 delivery owner 维护 ACK、retry、session close 和过期清理;
  • 如果实际连接在其他 gateway 节点,目标分片节点继续使用现有 route-baseddelivery_pushRPC 转发;该 RPC 仍使用accepted count + retryable/dropped 精确 routes;
  • leader -> 目标分片节点这一层只返回分片任务状态,如accepted、retryable、tag_mismatch、not_leader,不要求目标节点回传所有 accepted routes;
  • 分片任务按(channelID, channelType, messageID, targetNodeID, tagKey, tagVersion)幂等处理,避免 leader 重试或 committed replay 产生不可控重复投递。

4.3 follower 获取本地分片并缓存

目标节点收到带 tagKey / tagVersion 的投递请求后:

  1. 查本地channel -> currentTagRef;
  2. 若请求与本地 current ref 完全一致且本地 tag body 存在、未过期,直接复用;
  3. 若 tagKey 相同但请求 tagVersion小于本地 tagVersion,当作 stale event 处理,不允许删除或降级本地新缓存;
  4. 若 tagKey 相同但请求 tagVersion 更新,或SubscriberMutationVersion/PartitionTopologyVersion不一致,说明 local partition 失效,需向当前频道 leader 拉取;
  5. 若 tagKey 不一致,follower 不能根据随机 key 本地判断新旧,也不能先删除 current ref,必须向当前频道 leader 确认当前 ref;
  6. 拉取请求为GetTag(channelID, channelType, tagKey, tagVersion, targetNodeID=localNodeID);leader 必须返回当前 channel ref 或明确返回tag_not_current/not_leader;
  7. leader 只返回当前请求节点分片的 UIDs 和当前 version;若该节点没有分片,返回空集合也可以缓存,结果不能被解释为频道全量订阅者;
  8. follower 只在 leader 确认返回 ref 是当前 channel ref 时才更新本地 cache;旧响应必须丢弃。

follower 在任何情况下都不能通过本地订阅者 store 自行创建频道 tag,也不能基于本地 tag 对外提供"频道全量订阅者"语义。

4.4 目标节点执行本地实时投递

目标节点拿到本地 UIDs 后:按 UID slot / presence authority 分组后批量查询 presence / 本地会话(不能假设一个本地分片只包含单个 slot);过滤离线用户、发送者、黑名单等规则;对在线 endpoint 写连接或转发到实际接入节点;对 accepted routes 建立 ACK / retry 状态。这一层仍可能面对大量在线连接,因此仍需保留 inflight、ACK、retry、RPC chunk 等既有保护。

五、成员变更流程

成员变更必须收敛到频道 leader 更新 tag。

5.1 Add / Remove

  1. API / usecase 把成员变更提交给权威 subscriber store;subscriber rows 写入和SubscriberMutationVersion递增必须在同一个 slot Raft command / 同一权威存储事务中完成;
  2. store 返回本次逻辑变更后的SubscriberMutationVersion;tag manager只能消费这个版本,不能自己生成;
  3. 找到频道 leader;请求在 leader 本地则直接更新,否则通过节点 RPC 请求 leader 更新;
  4. 若 leader 上不存在当前 channel tag,记录本次成员变更已影响 tag,下一条消息按最新 SubscriberSource 重建;
  5. 若 leader 上存在当前 tag:add 把新增 UID 合并到对应节点分片,remove 从对应节点分片删除 UID,使用 store 返回的SubscriberMutationVersion并递增一次 tagVersion,更新channel -> currentTagRef;旧 version 的本地分片缓存等待目标节点收到新 version 后失效或由 TTL 清理。

5.2 Reset / RemoveAll

Reset / RemoveAll不建议在 tag 中做逐 UID 删除——频道越大,逐项删除越容易形成 CPU 和锁持有尖峰。推荐策略:

  • leader 上存在当前 tag:直接重置/清空 tag 的节点分片并递增 tagVersion,不生成新的 tagKey;
  • leader 上不存在当前 tag:不为了 Reset/RemoveAll 单独创建 tag,下一条消息首次构建 tag 时再生成 tagKey;
  • Reset + Add 等复合操作按一个逻辑成员操作发布一个SubscriberMutationVersion和一个 tagVersion:存储层分 chunk 写入时不应每个 chunk 暴露一个新 version,避免投递看到中间态。

5.3 成员变更失败补偿

  • SubscriberMutationVersion必须持久化在权威 slot store 的频道成员状态中,leader 重启或切换后仍能读取;
  • 成员变更请求到达 leader 后,leader 应先标记该 channel tag dirty,再提交权威 subscriber store mutation;成功后用返回版本更新 tag,更新完成清除 dirty;
  • 非 leader 节点的 RPC 更新失败时,可记录 pending invalidate / retry 任务,但它只是加速信号,不能作为唯一一致性保障;
  • leader 处理下一条消息前必须先比较权威SubscriberMutationVersion,落后则从 SubscriberSource 重建并把新版本写回缓存;
  • 分 chunk 写入必须共享同一个逻辑SubscriberMutationVersion;
  • 不允许仅依赖 TTL 修复成员变更后的旧完整 tag,TTL 只能作为兜底资源回收。

5.4 cmd / 临时频道

  • 其 tag 创建权属于对应频道 leader,其他节点只能请求和缓存;
  • cmd / derived tag 必须记录SourceChannelKey和SourceSubscriberMutationVersion;
  • derived 频道 leader 每次使用 tag 前都要读取源频道当前SubscriberMutationVersion,版本不同则直接判定 stale 并重建;
  • 正确性由源版本比较保证,初版不依赖持久反向索引;可维护本地sourceChannelKey -> derivedTagKey set作为主动清理优化,但 leader 切换或索引丢失后仍靠源版本比较兜底;
  • 若源频道 leader 与 derived 频道 leader 不在同一节点,源成员变更只需保证权威源版本已递增;主动 invalidate RPC 是加速路径而非正确性前提。

六、缓存一致性策略

6.1 tagKey + tagVersion 是主要一致性信号

目标节点收到 leader 分发的消息后,必须用请求里的tagKey + tagVersion与本地缓存做双校验:

  • 请求比本地 current ref 旧:判定为 stale event,不能触发本地 current ref 的删除或回退;
  • tagKey 不一致:只能按当前请求对应的 key 单独拉取或等待 leader 指示,不能无条件覆盖 channel 维度 current ref(通常只发生在首次创建、leader incarnation 变化、TTL 清理后冷重建等场景);
  • tagKey 一致但 tagVersion 不一致:本地缓存已过期,只有请求比本地 current ref 更新时才允许替换,旧请求不能驱逐新缓存;
  • tagKey、tagVersion、SubscriberMutationVersion、PartitionTopologyVersion都一致:才允许直接使用本地缓存的 UID 分片;
  • 旧投递事件携带旧 tagVersion 可继续尽力投递;若 leader 已无法返回该版本,按 retry/fallback 处理。

普通成员变化必须保持 tagKey 稳定、只递增 tagVersion,确保目标节点通过 version mismatch 识别缓存失效并重新拉取。

6.2 PartitionTopologyVersion 处理分片拓扑变化

tag 记录创建时的PartitionTopologyVersion,它不是单一本地计数,而是由控制面/集群运行时暴露的权威引用集合:

  • HashSlotTableVersion:全局 hash-slot table 版本,来源是现有cluster.HashSlotTableVersion();
  • SlotAuthorityRefs:本 tag 中所有 UID 所属 slot 的有序列表,每项{SlotID, LeaderNodeID, ConfigEpoch, BalanceVersion};
  • LeaderNodeID来源是cluster.LeaderOf(slotID);presence authority RPC 按 UID slot leader 路由,因此 presence authority owner 被包含在该字段中;
  • ConfigEpoch/BalanceVersion来源是 controller 的SlotAssignment;某 slot 取不到 assignment 版本时不能把 0 当作命中,只能保守重建或返回 retryable。

tag 覆盖多个 UID slot 时不能只取 max version,必须记录排序后的SlotAuthorityRefs,比较时要求HashSlotTableVersion相等、slot 集合相等、每个 slot 的LeaderNodeID/ConfigEpoch/BalanceVersion都相等。tag 构建必须从单个权威控制面快照读取这些字段,不能混用"旧 hash-slot table version + 新 assignments"的分段读取结果;读不到自洽快照就必须重试或返回 retryable。

6.3 TTL 处理冷频道和漏清理

每个 tag 维护LastAccess:后台 bucket 定期扫描;超过WK_DELIVERY_TAG_EXPIRE后删除 tag;若channel -> tagKey指向的 tag 已不存在,也删除该 channel 映射。TTL 只负责资源回收,不保证业务实时一致性。

6.4 leader 切换

频道 leader 切换后,旧 leader 上的 tag 不再权威。新 leader 首次处理该频道消息时重新创建 tag incarnation 并生成新的 tagKey;若权威 store 上的SubscriberMutationVersion已更高,直接以 store 版本重建,不沿用旧缓存。节点请求 tag 时若发现目标不是当前 leader,返回not_leader或由调用方重新定位 leader。follower 缓存中的旧 tag 可因 tagKey 不匹配、tagVersion 不匹配、SubscriberMutationVersion落后、PartitionTopologyVersion变化、TTL 或请求失败而失效。

七、失败处理

follower 获取 tag 失败

对实时投递返回retryable,而不是本地扫描订阅者创建 tag;delivery runtime 按已有重试机制退避重试;多次失败后可降级为本次实时投递 dropped,但消息本身仍已在 channel log 中持久化。

leader 上 tagKey 或 version 不存在

可能原因:leader TTL 清理了旧 tag;leader 切换后旧 tagKey 不存在或新 leader 只保留同一 tagKey 的更新版本;follower 收到的是旧事件;tagKey 存在但请求 version 与 leader 当前 version 不一致。处理策略:leader 不应强行用旧 key/version 创建旧快照;返回tag_not_found或tag_version_mismatch,由调用方重试并重新获取当前 channel tag;若权威SubscriberMutationVersion已前进而请求携带旧版本,leader 应优先返回当前版本信息,而不是把旧请求写回 current ref。

成员变更时 leader 不可用

成员存储变更成功但 leader tag 更新失败时:不回滚订阅者存储;不允许只删除本地可见 tag 映射后结束;必须依赖权威存储里已递增的SubscriberMutationVersion作为最终失效凭据;pending invalidate / retry 只负责尽快把新版本送到 leader。leader 恢复后,下一条消息使用 tag 前必须先比较权威SubscriberMutationVersion:能增量更新则递增 tagVersion,无法确认增量完整性则从 SubscriberSource 重建。

八、与在线索引方案的对比

优点

  • 不需要在线/离线时更新大量频道索引;不需要每个节点维护全频道在线成员集合;不把连接 churn 放大成频道维度的状态 churn;
  • 关注点清晰:tag 管成员快照,presence 管在线状态;
  • leader-only 创建,语义清晰,避免多节点并发创建不一致快照;
  • follower 只缓存本地分片,内存不会按"节点数 × 全量成员"放大;
  • 成员快照可跨多条消息复用,显著减少热门频道重复扫描订阅者存储;
  • tagVersion 递增做缓存失效、tagKey 保持稳定,目标节点只需按 version mismatch 重新拉取;
  • 可与现有 ACK / retry / RPC chunk / subscriber 分页能力兼容演进(ACK/retry owner 明确下沉到目标分片节点)。

缺点与风险

  • 每条消息仍需要对 tag 分片内 UID 做在线判断;频道成员全部离线时仍可能遍历该频道的 UID 内存分片(只是避免了存储扫描和跨节点全量 presence RPC);
  • tag 创建和 miss 请求会集中到频道 leader,热点或大频道 leader 压力更高;
  • 成员频繁变化时 tagVersion 频繁递增,follower cache 命中率下降;
  • 成员极多但消息很少的频道,首次投递有一次明显的 tag 构建成本;
  • tag 是最终一致缓存,不保证成员变化后所有旧事件立刻停止使用旧成员快照;
  • tag 只优化成员解析,不解决大量在线连接的写放大、ACK 索引和慢连接问题;
  • UID 分片节点与实际连接节点不同时,目标分片节点仍需按 presence authority 分组查询并二次转发。

九、六阶段实施计划(关联文档主体)

实现计划以internal/app/deliveryrouting.go为编排点,但不再把订阅者列表当作每条消息的全频道扫描:新增internal/runtime/deliverytag缓存负责 tag incarnation、tagVersion、TTL 与 stale-safe 失效;internal/usecase/delivery把各频道订阅者来源转成 leader 构建或拉取的 tag 分片;权威订阅者存储在频道记录上持久化SubscriberMutationVersion,使 tag 失效由存储状态围栏(fence),而非缓存本地启发式。技术栈为 Go、testing+testify、现有pkg/slotRaft/meta 栈、pkg/cluster、internal/access/node、internal/runtime/delivery、internal/usecase/delivery。同时遵循 AGENTS.md 的分层边界:不新增大而全的 service 层,关键导出类型和方法补简洁英文注释。

Task 1:在 slot meta 中持久化 SubscriberMutationVersion

修改pkg/slot/meta/catalog.go、channel.go、codec.go、batch.go、pkg/slot/fsm/command.go、command_inspection.go、pkg/slot/proxy/store.go,配套测试channel_test.go、codec_test.go、state_machine_test.go、subscriber_command_limits_test.go。

先写失败测试证明:metadb.Channel可往返持久化SubscriberMutationVersion;订阅者 add/remove/reset 路径保持版本单调递增;分块逻辑订阅者变更只向调用方暴露一个版本围栏;既有订阅者命令大小限制仍拒绝超长 UID 集合。示例测试名:

func TestChannelSubscriberMutationVersionRoundTrip(t *testing.T) { /* ... */ } func TestSubscriberMutationVersionAdvancesMonotonically(t *testing.T) { /* ... */ }

运行:

go test ./pkg/slot/meta ./pkg/slot/fsm -run 'Test.*SubscriberMutationVersion|Test.*SubscriberCommandLimits' -count=1

随后实现最小元数据变更:给 channel 记录增加SubscriberMutationVersion,在 value codec 中编解码,slot FSM/store 写路径在普通 channel 更新时保留它、仅在订阅者成员变更被持久化应用时推进它。

Task 2:构建 Delivery Tag 运行时缓存

新建internal/runtime/deliverytag/types.go、cache.go、manager.go、topology.go与manager_test.go。测试证明:leader 构建的 tag 在普通 Add/Remove 下保持同一tagKey、只递增tagVersion;follower 只缓存本地节点分片;旧 version 的 stale 请求永不驱逐新缓存条目;PartitionTopologyVersion不匹配强制 refetch/rebuild;TTL 清理冷 tag 与失效 channel ref;cmd/derived tag 在源频道SubscriberMutationVersion前进时变 stale;leader incarnation 变化或冷重建可铸造新tagKey且不与旧 follower 缓存条目冲突。示例:

func TestTagVersionIncrementsWithoutChangingKey(t *testing.T) { /* ... */ } func TestStaleRequestDoesNotEvictNewerLocalTag(t *testing.T) { /* ... */ } func TestLeaderIncarnationCanMintFreshTagKey(t *testing.T) { /* ... */ }
go test ./internal/runtime/deliverytag -run 'Test.*Tag|Test.*Topology' -count=1

节点内 manager 职责:跟踪tagKey -> tag body、channelKey -> current ref;follower 只存本地分片;复用前比较完整分区拓扑引用;用 stale-safe 替换规则保证旧查找不覆盖新 ref;cmd/derived tag 携带SourceChannelKey与SourceSubscriberMutationVersion并在源版本前进时标记 stale;允许新 leader incarnation 或冷重建铸造全新tagKey。

Task 3:让投递订阅者认识 tag source 与特殊频道类型

修改internal/usecase/delivery/subscriber.go、新建source.go、修改subscriber_test.go、internal/usecase/channel/app.go,必要时修改internal/usecase/user/legacy.go。测试证明 subscriber source 能解析:派生 ID 的个人频道;派生 ID 的 Agent 频道;普通群/社区/话题/数据/直播/AgentGroup 的 paged subscriber 存储;带临时 overlay 合并的 Info 频道;不把一次性请求 UID 变成可复用 channel 级 tag 状态的 Temp 频道;Visitors 与客服频道;cmd/derived 频道通过解析源频道并复用其 source;cmd/derived tag 在源频道订阅者版本前进时变 stale。示例:

func TestSubscriberSourceResolvesPersonChannel(t *testing.T) { /* ... */ } func TestSubscriberSourceRejectsReusableTempRequestSnapshot(t *testing.T) { /* ... */ }
go test ./internal/usecase/delivery -run 'Test.*Subscriber|Test.*Snapshot' -count=1

实现上把 resolver 拆成三块:store-backed 订阅者的通用分页接口;derived / temporary / overlay / legacy 频道的按类型小适配器(含显式的 person 与 agent ID 派生 resolver);一条能不暴露 access 层细节就把 source 交给 delivery tag manager 的路径。

Task 4:新增 leader-only 的 Delivery Tag RPC 并完成 wire contract

修改internal/access/node/options.go、service_ids.go、client.go、delivery_push_codec.go、delivery_push_rpc.go,新建delivery_tag_codec.go、delivery_tag_rpc.go与delivery_tag_rpc_test.go。测试证明:只有频道 leader 能创建/更新 tag;follower 只能为某个 tagKey 拉取自己的本地分片;响应携带tagKey + tagVersion作为公开 fence;不匹配或 stale 的响应返回可重试状态而不是覆盖新缓存;delivery-push 响应仍精确报告retryable/dropped路由,accepted在批量路径上可以是计数;拓扑快照获取在无法一次性读到自洽PartitionTopologyVersion时 fail-closed 返回 retryable;leader incarnation 变化或冷重建可返回新tagKey,客户端必须把旧 key 视为 stale 而非强制复用。示例:

func TestDeliveryTagRPCRejectsNonLeaderUpdates(t *testing.T) { /* ... */ } func TestDeliveryPushBatchCanReturnAcceptedCount(t *testing.T) { /* ... */ }
go test ./internal/access/node -run 'Test.*DeliveryTag|Test.*DeliveryPush' -count=1

RPC contract 使用显式结果名:ok、not_leader、tag_not_current、tag_version_mismatch、retryable。新增独立 tag RPC service ID,在options.go注册 handler,加 client helper;codec 只编解码 leader fetch/update 与本地分片缓存刷新所需数据;保持旧 delivery-push codec 兼容,新批量路径使用accepted count + retryable/dropped 精确 routes。

Task 5:重接投递路由,消费 tag 并交接所有权

修改internal/app/build.go、app.go、deliveryrouting.go、presenceauthority.go,测试deliveryrouting_test.go、multinode_integration_test.go、deliveryrouting_benchmark_test.go。测试证明:消息通过 tag 分片解析而非每次扫描全量订阅者;leader 只发送tagKey + tagVersion加目标分片任务;目标分片节点拥有本地 route 的 ACK/retry;远端推送仍对大路由列表分块并保留精确retryable/dropped反馈;stale tag 响应强制 refetch 而不是污染新缓存;分片交接任务在(channelID, channelType, messageID, targetNodeID, tagKey, tagVersion)上幂等,重试与 committed replay 不重复投递所有权。示例:

func TestDeliveryRoutingUsesTagPartition(t *testing.T) { /* ... */ } func TestDeliveryRoutingRejectsStaleTagResponse(t *testing.T) { /* ... */ }
go test ./internal/app -run 'Test.*DeliveryRouting|Test.*Tag' -count=1

实现要点:在build.go构造 tag manager 并注入投递 subscriber 路径;通过presenceauthority.go或小 helper 注入权威拓扑读取器,使 manager 能比较完整分区版本而不本地猜测;deliveryrouting.go一次解析 tag、按目标节点分发分片、保留远端delivery_pushroute 反馈契约、保留大路由分块保护,并按六元组对分片交接工作去重。

Task 6:完成频道管理失效与端到端回归

修改internal/usecase/channel/app.go、internal/app/channel_management_integration_test.go,必要时修改user_management_integration_test.go。测试证明:Add/Remove/Reset/RemoveAll 整个操作只推进一个逻辑订阅者变更围栏;leader 在持久化变更路径之前/同时把 tag 标记 dirty 或失效;后续投递请求在持久化版本前进后必须 refetch;旧版 HTTP channel-management 端点仍可用;dirty 生命周期显式:写前标记 dirty,持久化版本成功后清除或刷新 ref,绝不只靠 TTL。示例:

func TestChannelManagementInvalidatesDeliveryTagOncePerLogicalMutation(t *testing.T) { /* ... */ } func TestChannelManagementLegacyEndpointsStillWork(t *testing.T) { /* ... */ }
go test ./internal/app -run 'Test.*ChannelManagement|Test.*Legacy.*Management' -count=1

实现要点:频道管理 usecase 把分块写入视为一次逻辑变更,只推进一次持久化订阅者变更围栏;写前在 leader 侧标记 tag dirty,持久化成功后清除或刷新 ref;若实现过程中出现新的持久化不变量,才更新 PROJECT_KNOWLEDGE.md。

十、源码证据:当前仓库中的落地痕迹

实施计划 Task 1 的目标——"让 SubscriberMutationVersion 持久化于权威存储"——在当前仓库中可以找到对应实现:

  • pkg/db/meta/table_channel.go 的Channel结构体声明了SubscriberMutationVersion uint64,注释明确"tracks subscriber list mutation ordering";
  • pkg/db/meta/table_channel.go 的encodeChannelValue与 decodeChannelValue 将该版本字段纳入 channel value 的编解码,保证其可持久化、可往返;
  • pkg/db/meta/subscriber_test.go 的TestSubscriberMutationVersionAdvancesAndInvalidatesChannelCache验证了:低版本订阅者写入不会回退已有版本、高版本写入会推进版本、订阅者变更会清空频道缓存——与计划 Task 1"版本单调、缓存失效"的测试意图一致;
  • pkg/db/meta/batch.go 在批量合并时取max(next.SubscriberMutationVersion, existing.SubscriberMutationVersion),保证版本单调不回退;
  • pkg/slot/fsm/command.go 定义了tagSubscriberMutationVersion uint8 = 4的 TLV 字段标签,并在 command.go 与 command.go 的编码/解码路径中使用,说明 slot FSM 命令层面已具备持久化该版本的通道;
  • pkg/db/meta/inspect.go 将该字段暴露为subscriber_mutation_version供检查视图使用。

另外,计划与设计文档引用了旧版 WuKongIM 的 tag 机制实现作为行为参考(learn_project/WuKongIM/internal/channel/handler/event_distribute.go、internal/manager/manager_tag.go、internal/manager/bucket.go等),本方案在旧实现基础上做了三点演进:成员变化不再RenameTag生成新 key,改为保持 tagKey 稳定、递增 tagVersion;旧NodeVersion扩展为完整PartitionTopologyVersion;非 leader 节点依然只拉取本地 UID 分片而非全量 tag。

十一、最终验证与仍需保留的保护

最终验证

实施计划给出的最终验证命令:

go test ./pkg/slot/meta ./pkg/slot/fsm ./internal/runtime/deliverytag ./internal/usecase/delivery ./internal/access/node ./internal/app -count=1
  • 若 tag RPC 或 app 装配依赖多节点行为,用-tags=integration跑最小相关集成子集,而不是全仓库;
  • 结束前验证git diff --check通过;
  • 计划记录显示 Task 6 的文档级 app 正则匹配不到单元测试(其端点回归测试带 integration tag),因此用如下命令验证最小相关子集:
go test -tags=integration ./internal/app -run 'TestAppChannelManagementLegacyEndpointsPersistThroughStore|TestAppLegacyUserManagementEndpointsPersistAndQueryRuntime' -count=1

仍需保留的保护

即使引入 tag,以下保护必须继续保留或加强:单频道 inflight route 上限;ACK binding 上限与超时清理;远端 push chunk;目标分片节点到实际连接节点的 retryable / dropped 精确路由反馈;committed dispatcher 积压观测;大频道 fanout 限流或降级策略;leader -> 目标分片节点的分片任务幂等与重试上限。

结语

Delivery Tag 方案是 WuKongIM 所有频道实时投递的统一成员快照机制:它不追求维护精确在线索引,而是把频道成员集合变成leader 权威、可复用、可失效的分片快照。该方案统一了小频道、普通群和大频道的投递成员解析路径,显著减少热门频道重复读取订阅者存储和跨节点成员规划的成本,同时避免每个节点维护大量在线订阅者索引。但它不能消除最终连接写入、ACK、慢客户端和 leader 热点问题,因此应与现有投递背压、RPC chunk、ACK 清理以及后续 fanout job 方案配合使用。

  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

More than just IM 不只是即时通讯(IM)

项目地址:https://gitcode.com/gh_mirrors/wu/WuKongIM
点击查看免费下载

相关推荐

上一篇:Home Assistant 前端 UI/UX 就绪判定指南:基于 ha-frontend-ux-readiness 技能的评审路由方法
下一篇:使用显式组件变体替代布尔属性:next-shadcn-dashboard-starter 中的 React 组合模式实践

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 1:35:17

无需写代码:用可视化工具抓取滚动加载页面的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:34:49

Jev:TypeSafe AI的行为契约与可信执行基座

最近全网刷屏的“Jev”这个词,几乎一夜之间出现在技术社区、AI工具评测、开发者群聊和招聘JD里——有人把它比作“TypeSafe时代的Copilot”,也有人称它是“System One Model落地的第一块真实拼图”。但翻遍主流技术媒体、GitHub趋势榜和Hugging Face模型…

作者头像 李华
网站建设 2026/10/16 1:34:21

Vue3+TypeScript全栈项目实战:工程化、类型与状态管理指南

时下很多前端同学刚接触全栈项目时,第一个念头是“我用 Vue3 写页面,后端找一个框架,接口串起来不就行了”。结果真正动手,两天就发现事情没这么简单:项目结构怎么组织、类型要不要专门建一层、接口返回不一致怎么处理…

作者头像 李华
网站建设 2026/10/16 1:33:31

OpCore Simplify 六步菜单流:从硬件报告到可启动的 OpenCore EFI

OpCore Simplify 六步菜单流:从硬件报告到可启动的 OpenCore EFI 【免费下载链接】OpCore-Simplify A tool designed to simplify the creation of OpenCore EFI 项目地址: https://gitcode.com/GitHub_Trending/op/OpCore-Simplify OpCore Simplify 是 Open…

作者头像 李华