☰
WuKongIM Slot Raft 提案准入 QoS 二期:用前台/后台提案分级保住元数据写入的队列预留
2026/10/17 11:13:28 网站建设 项目流程
  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

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

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

本文基于 WuKongIM 仓库中的实施计划 2026-06-21-slot-raft-proposal-admission-qos-phase2.md,完整解读 Slot Raft 提案准入 QoS 二期的设计目标与落地实现:如何在保持Runtime.Propose(ctx, slotID, data)公开签名不变的前提下,通过 context 传递提案准入等级(foreground/background),为后台投影类写入设置独立队列上限,使前台元数据提案始终保有预留容量,并把每一次准入决策变成带类型的错误与低基数指标。读完后你可以掌握 WuKongIM 多 Raft Slot 层的背压(backpressure)机制、跨节点转发时的类编码/解码规则,以及wukongim_slot_proposal_admission_total指标的观测链路,并能用仓库内的测试用例验证整套行为。

一、问题背景:后台投影压力会挤占前台元数据写入

该计划的 Goal 写得很明确:

Add long-term Slot Raft proposal admission classes so background conversation-active projection pressure is bounded and observable while foreground metadata proposals retain reserved queue capacity.

也就是说,WuKongIM 中会话活跃态(conversation-active)这类后台投影会周期性地触发持久化 flush,向 Slot 提交元数据提案。这类写入的特点是可重试、时效要求低;而用户在聊天界面直接产生的元数据写入属于前台,对延迟和可用性敏感。如果两者共用同一个无差别队列,后台投影在高峰期可能把队列打满,把前台请求一并压到背压错误上。

计划的 Architecture 一段给出了总体约束:

  • 保持公开 API 形状稳定:Runtime.Propose(ctx, slotID, data)不变,提案类(proposal class)通过 context 携带,未显式标记的调用者默认归为 foreground,保证存量调用方行为不变;
  • Multi-Raft 层负责准入执行:pkg/slot/multiraft是真正的准入点;
  • propose包负责跨节点传播:本地提案与转发给远端 Leader 的 Slot 提案都携带类标记;
  • 会话活跃态的持久化 flush 被标记为 background;
  • 准入结果显式化为带类型的错误和低基数指标。

Tech Stack 一节列出了涉及范围:pkg/slot/multiraft、propose包、集群层、应用可观测层与 Prometheus 指标。

需要说明的一点是:计划文档中写作pkg/clusterv2/propose、internalv2/infra/cluster等路径,而当前仓库目录结构里对应的代码位于pkg/cluster/propose、internal/infra/cluster、internal/app等位置。本文统一以当前仓库的实际路径为准引用源码。

二、提案类模型:context 携带、未知值归一化为 foreground

2.1 multiraft 层的类型定义

提案类在 pkg/slot/multiraft/types.go 中定义:

// ProposalClass identifies the admission priority of a Slot proposal. type ProposalClass string const ( // ProposalClassForeground is the default class for user-facing metadata writes. ProposalClassForeground ProposalClass = "foreground" // ProposalClassBackground is used for retryable projection and maintenance writes. ProposalClassBackground ProposalClass = "background" ) // ProposalAdmissionObserver receives low-cardinality proposal admission decisions. type ProposalAdmissionObserver interface { ObserveSlotProposalAdmission(slotID SlotID, class ProposalClass, result string) }

注释直接点明了两类的语义边界:foreground 面向用户可见的元数据写入,background 面向可重试的投影与维护写入。ProposalAdmissionObserver接口要求实现是并发安全且非阻塞的,避免观测本身拖慢准入热路径。

context 辅助函数在 pkg/slot/multiraft/stage_observer.go:

// WithProposalClass marks proposal work derived from ctx with an admission class. func WithProposalClass(ctx context.Context, class ProposalClass) context.Context { if ctx == nil { ctx = context.Background() } return context.WithValue(ctx, proposalClassContextKey{}, normalizeProposalClass(class)) } // ProposalClassFromContext returns the proposal admission class carried by ctx. func ProposalClassFromContext(ctx context.Context) ProposalClass { if ctx == nil { return ProposalClassForeground } class, _ := ctx.Value(proposalClassContextKey{}).(ProposalClass) return normalizeProposalClass(class) } func normalizeProposalClass(class ProposalClass) ProposalClass { switch class { case ProposalClassBackground: return ProposalClassBackground default: return ProposalClassForeground } }

三个设计点值得注意:

  1. nil context 安全:两个函数都允许ctx == nil,分别回退到context.Background()与 foreground,与仓库内其它 context 辅助函数的防御风格一致;
  2. 写入即归一化、读取再归一化:normalizeProposalClass把任何未知值(包括未来新增的第三方类)都折叠成 foreground,保证准入判定永远只面对两个合法值;
  3. 默认前台:这与计划的“existing callers default to foreground”约束一一对应——所有存量调用方在没有任何改动时天然获得最高的准入优先级。

对应测试 TestProposalClassContextDefaultsForeground 直接验证了这两条:默认读取是ProposalClassForeground,显式WithProposalClass(..., ProposalClassBackground)后读取是ProposalClassBackground。

2.2 公开 Propose 入口如何“盖章”

pkg/slot/multiraft/api.go 中,Runtime.Propose从 context 取出类标记并写入控制动作:

fut := newFuture(proposalStageObserversFromContext(ctx)) if err := g.enqueueControl(controlAction{ kind: controlPropose, data: append([]byte(nil), data...), proposalClass: ProposalClassFromContext(ctx), future: fut, }); err != nil { return nil, err }

控制动作结构体controlAction在 pkg/slot/multiraft/slot.go 中新增了proposalClass ProposalClass字段,随动作一起排队。注意data在此处做了一次拷贝(append([]byte(nil), data...)),保证提交后调用方复用原切片不会污染队列内容。

三、后台准入记账:两级队列上限与准入规则

3.1 配置项与默认值

计划要求在RaftOptions中新增MaxQueuedBackgroundControls。当前实现在 pkg/slot/multiraft/types.go:

// MaxQueuedRequests bounds inbound Raft messages buffered per Slot before // scheduler processing catches up. Zero uses a conservative default. MaxQueuedRequests int // MaxQueuedControls bounds locally submitted control actions per Slot, // including proposals and membership changes. Zero uses a conservative default. MaxQueuedControls int // MaxQueuedBackgroundControls bounds queued background proposals per Slot. // Zero uses a conservative default derived from MaxQueuedControls. MaxQueuedBackgroundControls int // MaxApplyingTasks bounds async apply tasks accepted per Slot. When the // limit is reached, the Slot applies the current Ready synchronously to // convert overload into backpressure instead of unbounded heap growth. MaxApplyingTasks int

默认值与校验在 pkg/slot/multiraft/config.go:

const ( defaultLogCompactionTriggerEntries = uint64(10000) defaultLogCompactionCheckInterval = 30 * time.Second defaultMaxQueuedRequests = 8192 defaultMaxQueuedControls = 8192 defaultMaxQueuedBackgroundControls = defaultMaxQueuedControls / 2 defaultMaxApplyingTasks = 1024 )

注意一个计划与实现的差异:计划 Task 1 的示例常量写的是10000/10000/1024,但同时注明“Use the actual repository defaults if they already differ”。当前仓库实际取值为8192 / 8192 / 1024,后台上限默认取前台控制队列的一半(4096)。NormalizeRaftOptions(config.go)在零值时填充默认值,ValidateRaftOptions(config.go)拒绝负值;defaultMaxQueuedBackgroundControlsFor在MaxQueuedControls很小(除以 2 为 0)时保底为 1,保证后台队列永远至少容纳 1 条。

这套参数的组合语义是:前台提案最多占用MaxQueuedControls - MaxQueuedBackgroundControls的“预留 + 竞争空间”,后台提案最多占用MaxQueuedBackgroundControls。即便后台被完全打满,前台仍有一半容量的余量可以入队。

3.2 enqueueControl:准入判定的完整顺序

核心判定逻辑在 pkg/slot/multiraft/slot.go 的enqueueControl,按顺序执行:

func (g *slot) enqueueControl(action controlAction) error { g.mu.Lock() if err := g.admissionErrLocked(); err != nil { ... } action.proposalClass = normalizeProposalClass(action.proposalClass) switch action.kind { case controlPropose, controlConfigChange, controlReadBarrier: if g.status.Role != RoleLeader { event := g.proposalAdmissionEventLocked(action, "not_leader") ... return ErrNotLeader } ... } if queueLimitReached(g.maxQueuedControls, len(g.controls)) { event := g.proposalAdmissionEventLocked(action, "busy") ... if action.kind == controlPropose { return fmt.Errorf("%w: %w", ErrProposalBackpressure, ErrSlotBusy) } return ErrSlotBusy } if action.kind == controlPropose && action.proposalClass == ProposalClassBackground && queueLimitReached(g.maxQueuedBackgroundControls, g.queuedBackgroundControls) { event := g.proposalAdmissionEventLocked(action, "throttled") ... return fmt.Errorf("%w: %w", ErrBackgroundProposalThrottled, ErrProposalBackpressure) } if action.kind == controlPropose && action.proposalClass == ProposalClassBackground { g.queuedBackgroundControls++ } g.controls = append(g.controls, action) event := g.proposalAdmissionEventLocked(action, "ok") g.mu.Unlock() event.emit() return nil }

与计划 Step 4 的“Admission rules”片段逐条对应,且补充了几个工程细节:

  • 先总限后类限:先检查每 Slot 总控制队列maxQueuedControls,再对 background 提案检查maxQueuedBackgroundControls。总量满了时所有类一起被拒(ErrProposalBackpressure包裹ErrSlotBusy);总量未满但后台额度用完时,只有 background 被限流,foreground 仍可入队;
  • 记账时机:queuedBackgroundControls只在准入成功之后自增;当控制动作批次被取出处理时(takeControlBatch,slot.go),计数器重置归零,与队列中实际残留的 background 提案保持一致;
  • 观测事件出锁后发射:proposalAdmissionEventLocked在锁内只构造事件对象(见 slot.go),真正的event.emit()放在mu.Unlock()之后,避免 observer 回调持锁执行,这正是计划中“emit admission observations outside locks”的落地;
  • 只统计 controlPropose:proposalAdmissionEventLocked对非提案类控制动作(如controlTransferLeader、controlConfigChange)返回空事件,保证指标只反映提案准入。

准入结果共四种低基数取值:ok、busy、throttled、not_leader,分别对应入队成功、总量背压、后台限流、非 Leader 节点。

3.3 测试锚点:前台预留的回归验证

TestBackgroundProposalAdmissionLeavesForegroundReserve 是该机制的回归核心,场景构造为maxQueuedControls = 4、maxQueuedBackgroundControls = 1:

  1. 第一条 background 提案入队成功;
  2. 第二条 background 提案必须命中ErrBackgroundProposalThrottled;
  3. 紧随其后的 foreground 提案仍须入队成功——这一条断言就是“前台预留”的操作性定义。

TestProposalAdmissionObserverRecordsBackgroundThrottle 则验证观测链路:observer 收到的结果序列必须是["background:ok", "background:throttled"]。而 TestEnqueueControlRejectsWhenQueueLimitReached 保留了 Task 1“既有背压护栏不被破坏”的约束:总队列满时返回ErrSlotBusy。

四、类型化错误:可重试的准入信号

pkg/slot/multiraft/errors.go 定义了准入相关的哨兵错误:

ErrSlotBusy = errors.New("multiraft: slot busy") ErrProposalBackpressure = errors.New("multiraft: proposal backpressure") ErrBackgroundProposalThrottled = errors.New("multiraft: background proposal throttled") ErrApplyBacklogHigh = errors.New("multiraft: apply backlog high")

错误采用fmt.Errorf("%w: %w", ...)的双包裹形式,例如ErrBackgroundProposalThrottled同时errors.Is匹配ErrProposalBackpressure。这样上层可以用一个宽匹配(“任何提案背压”)做统一退避,又能用精确匹配(“后台被限流”)触发针对性策略。从源码结构看,internal/runtime/readavailability/errors.go 将multiraft.ErrProposalBackpressure与ErrApplyBacklogHigh等纳入压力/可重试错误集合,印证了“准入拒绝 = 可重试的路由压力”这一语义:被拒提案不会丢,由调用方在有限窗口内重试。

五、跨节点传播:本地提交与远程转发都携带类标记

一个提案从业务调用到 Slot Raft,可能跨节点:请求落在非 Leader 节点时会经propose包转发给目标 Slot 的 Leader。类标记必须在这条链路上完整传播,计划 Task 3 覆盖了每一跳。

5.1 propose 包:独立的类类型与 context 辅助

pkg/cluster/propose/types.go 中,propose 包维护了自己的ProposalClass(注意类型不同:这里是uint8,0/1 编号制,而非 multiraft 的字符串制),并提供与 multiraft 同名的 context 辅助与归一化函数:

// ErrBackgroundProposalThrottled indicates that background proposal admission is throttled. ErrBackgroundProposalThrottled = errors.New("cluster/propose: background proposal throttled") // ProposalClass identifies the admission priority of a Slot metadata proposal. type ProposalClass uint8 const ( // ProposalClassForeground is the default class for user-facing metadata writes. ProposalClassForeground ProposalClass = iota // ProposalClassBackground is used for retryable projection writes. ProposalClassBackground )

两套类型在集群层做映射(见 5.4),各自在自己的边界内保持低耦合。

5.2 转发请求编码:legacy v1 仍按 foreground 解码

pkg/cluster/propose/codec.go 定义了三个转发版本:

const ( payloadVersion = uint8(1) forwardVersionLegacy = uint8(1) forwardVersionClass = uint8(2) forwardVersion = uint8(3) forwardFlagWantResult = uint8(1) )

对应三种线上字节布局(codec.go):

版本布局说明
legacy v1[version:1][slot_id:4][hash_slot:2][payload_len:4][payload:n]无类字段;解码时固定为 foreground
v2[version:2][class:1][slot_id:4][hash_slot:2][payload_len:4][payload:n]引入类字段的过渡版本
v3(当前编码)[version:3][class:1][flags:1][slot_id:4][hash_slot:2][payload_len:4][payload:n]class +WantResult标志位

计划中给出的 v2 布局([version:1][class:1][slot_id:4][hash_slot:2][payload_len:4][payload:n])与当前 codec 的 v2 分支一致,当前 HEAD 在其上又追加了WantResult标志位成为 v3。关键兼容点在DecodeForwardRequest的 legacy 分支(codec.go):老版本节点发出的请求被解码为Class: ProposalClassForeground,滚动升级期间新旧节点混跑不会出现未知类;编码侧EncodeForwardRequest先执行req.Class = normalizeProposalClass(req.Class),未知值在出站前就被折叠。

5.3 Service 与 ForwardHandler:去程与回程两端的盖章

去程:pkg/cluster/propose/service.go 的proposeToLeaderInternal在构造ForwardRequest时直接从 context 读取类标记:

req := ForwardRequest{ SlotID: route.SlotID, HashSlot: route.HashSlot, Class: ProposalClassFromContext(ctx), WantResult: wantResult, Payload: payload, }

本地 Leader 路径则不经过编解码,直接把携带类的ctx传给slots.Propose(ctx, route.SlotID, payload)。

回程:pkg/cluster/propose/forward.go 的ForwardHandler.HandleRPC解码后重新把类写回 context,再交给本地 Slot 运行时:

ctx = WithProposalClass(ctx, req.Class) if req.WantResult { ... } if err := h.slots.Propose(ctx, req.SlotID, req.Payload); err != nil { ... }

这一步是跨节点 QoS 的闭合点:远端节点在本地 Leader 的enqueueControl里执行准入时,看到的类与请求发起方的意图一致——限流发生在真正持有队列的 Leader 上,而不是发起方本地。

相关测试:TestServiceProposeRemoteLeaderCarriesProposalClass 断言远程转发请求的Class为 background;TestForwardHandlerPassesProposalClassToSlots 断言HandleRPC之后 Slot 收到的 context 类为 background。两者分别锚定去程与回程。

5.4 defaultSlotProposer:两类类型与两类错误的双向映射

pkg/cluster/default_slot_proposer.go 在调用multiraft.Runtime.Propose前完成类映射:

if propose.ProposalClassFromContext(ctx) == propose.ProposalClassBackground { ctx = multiraft.WithProposalClass(ctx, multiraft.ProposalClassBackground) }

返回路径由mapMultiraftProposeError(default_slot_proposer.go)把 multiraft 的类型化错误翻回 propose 包的错误契约:

switch { case errors.Is(err, multiraft.ErrNotLeader): return propose.ErrNotLeader case errors.Is(err, multiraft.ErrBackgroundProposalThrottled): return propose.ErrBackgroundProposalThrottled case errors.Is(err, multiraft.ErrProposalBackpressure): return propose.ErrProposalBackpressure default: return err }

TestDefaultSlotProposerPassesBackgroundProposalClass 回归了这一映射。至此,业务层拿到的永远是propose包的错误语义,与底层 multiraft 实现解耦。

六、准入观测:从 observer 到 Prometheus 指标

6.1 低基数指标设计

计划 Task 5 要求在 pkg/metrics/slot.go 注册:

proposalAdmission: prometheus.NewCounterVec(prometheus.CounterOpts{ Name: "wukongim_slot_proposal_admission_total", Help: "Total Slot proposal admission decisions by class and result.", ConstLabels: labels, }, []string{"class", "result"}),

标签刻意只保留class(foreground/background)与result(ok/busy/throttled/not_leader),不含slot_id——这与SlotMetrics中其它指标(如按slot_id维度的wukongim_slot_proposals_total)形成对比:slotID 作为指标标签会造成高基数,而 slotID 仍然保留在 observer 接口签名里供内存态诊断使用。ObserveProposalAdmission(slot.go)是空指针安全的简单Inc。

6.2 应用层转发链

pkg/slot/multiraft/types.go 的ProposalAdmissionObserver在应用层有两个实现:internal/app/observability.go 的slotMetricsObserver把string(class)与result透传给SlotMetrics.ObserveProposalAdmission;internal/app/observability.go 的multiSlotObserver在多 Slot 场景下把事件路由到对应 Slot 的 admission observer。测试 internal/app/observability_test.go 与 pkg/metrics/registry_test.go 对这条链路做了注册与递增断言。仓库的运维面也消费了这一指标:Grafana 看板 wukongim-runtime-storage.json 与 Web 集群监控页 metric-config.ts 均引用了wukongim_slot_proposal_admission_total。

由此,"background 投影压力"从计划的形容词变成了可查询的量:result="throttled"的速率上升即代表后台额度正在耗尽,而class="foreground"的busy速率应始终保持在接近零的水平——这正是"bounded and observable"两个目标的落地形态。

七、设计边界:本阶段刻意不做什么

计划末尾的 Self-Review 明确了两条边界,值得在引用这套机制时注意:

  1. 本阶段不新增外部配置键。MaxQueuedBackgroundControls走RaftOptions编程接口,零值自动派生自MaxQueuedControls / 2,因此wukongim.toml示例不需要改动;运维层面的调参入口是代码内 options,而非配置文件;
  2. 类型一致性是验收标准:ProposalClassForeground、ProposalClassBackground、ErrProposalBackpressure、ErrBackgroundProposalThrottled在 multiraft、propose、集群映射三层中命名与语义保持一致,跨层只允许"翻译",不允许产生新的语义分支。

此外,从当前仓库快照看,计划 Task 4 中点名的TouchUserConversationActiveAtBatch调用点在当前代码树中已不再以该名称出现(会话投影相关代码经历过重构,计划引用的pkg/clusterv2/internalv2路径也对应到现在的pkg/cluster/internal目录),但支撑该任务的两个机制——context 类标记与类型化背压错误的重试映射——在现行代码中完整可用;可以推断会话活跃态持久化路径如今以重构后的投影结构复用同一准入体系,而非保留旧调用签名。

八、验证方式:仓库内可直接执行的回归命令

计划的 Task 6 给出的验证步骤,在当前仓库中依然可以按包直接复跑(仓库只读,以下命令用于在本地检出中验证,而非修改本仓库):

# 核心准入包(含前台预留、限流、observer 事件测试) go test ./pkg/slot/multiraft -count=1 # propose 传播与集群映射 go test ./pkg/cluster/propose -run 'TestServiceProposeRemoteLeaderCarriesProposalClass|TestForwardHandlerPassesProposalClassToSlots' -count=1 go test ./pkg/cluster -run TestDefaultSlotProposerPassesBackgroundProposalClass -count=1 # 指标与观测 go test ./pkg/metrics -run TestSlotAndTransportMetricsTrackProposalsLeaderChangesAndRPCs -count=1 go test ./internal/app -run TestRuntimePressureAdapterMapsGatewayChannelSlotTransportAndDB -count=1 # 核心包竞态检查 go test -race ./pkg/slot/multiraft -count=1

关键锚点测试一览:

测试位置验证点
TestProposalClassContextDefaultsForegroundstep_test.go默认 foreground、显式 background 可读回
TestBackgroundProposalAdmissionLeavesForegroundReservestep_test.go后台满时前台仍可入队
TestProposalAdmissionObserverRecordsBackgroundThrottlestep_test.goobserver 收到ok→throttled序列
TestEnqueueControlRejectsWhenQueueLimitReachedstep_test.go既有总队列背压护栏不变
TestServiceProposeRemoteLeaderCarriesProposalClasspropose_test.go去程:转发请求携带类
TestForwardHandlerPassesProposalClassToSlotspropose_test.go回程:Handler 把类写回 context
TestDefaultSlotProposerPassesBackgroundProposalClassdefault_slot_proposer_test.go类映射与错误映射

九、小结

Slot Raft 提案准入 QoS 二期用一套非常克制的改动解决了“后台压力不可控”的问题:

  • 模型:ProposalClass两态(foreground/background),context 携带,未知值与缺省一律归一为 foreground(stage_observer.go);
  • 执行:enqueueControl内两级队列判定,后台专用额度MaxQueuedBackgroundControls(默认MaxQueuedControls/2)用满只限流后台,前台保留容量(slot.go、config.go);
  • 信号:ErrBackgroundProposalThrottled/ErrProposalBackpressure双包裹类型化错误,跨层映射语义不变(errors.go、default_slot_proposer.go);
  • 传播:转发编解码三个版本并存,legacy v1 解码为 foreground 保证滚动升级兼容(codec.go);
  • 观测:wukongim_slot_proposal_admission_total{class,result}低基数计数器,observer 事件在锁外发射(metrics/slot.go、observability.go)。

对于需要在这套体系上扩展写入路径的开发者,规则可以概括为一句话:新写入默认前台;凡是可重试、时效不敏感的投影类写入,就WithProposalClass(ctx, background),并把throttled当作正常退避信号处理。

  • 即时通讯
  • 后端

【免费下载链接】WuKongIM

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

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

相关推荐

上一篇:NVIDIA Profile Inspector完整指南:解锁显卡隐藏性能的终极工具
下一篇:戴尔G15散热控制终极指南:如何用开源工具替代臃肿的AWCC软件

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询