Skip to content

Cut channel concurrency Redis pressure for bounded rollout - #730

Merged
think-back merged 3 commits into
mainfrom
feat/channel-concurrency-redis-optimization
Aug 17, 2026
Merged

Cut channel concurrency Redis pressure for bounded rollout#730
think-back merged 3 commits into
mainfrom
feat/channel-concurrency-redis-optimization

Conversation

@think-back

Copy link
Copy Markdown
Collaborator

背景 / Problem

渠道级最大并发数(#154/#351)已上线,但当前 main 的实现里,渠道选择路径对候选集内每个限并发渠道逐一读 Redis 负载——无缓存、无合并、pipeline 无上界。生产 Redis 是 Basic 1GiB 单节点 Memorystore,与渠道缓存/限流共用;在几十个渠道上大规模开启限并发之前,必须先把读放大压下来(#352/#353 的原始动机,两个 PR 已落后 main 一个多月且保护不全,本 PR 基于最新 main 重做并做全)。

方案:移植 sub2api 的抗压机制 + #353 评审结论

# 优化 来源 收益
1 负载快照 200ms 短 TTL 缓存 sub2api accountLoadCache 同一候选集重复读走内存,Redis 读频率与 QPS 解耦
2 singleflight 合并并发未命中 sub2api accountLoadGroup 突发 N 请求只发 1 次抓取
3 pipeline 按 50 渠道分批 + TIME 单次 + 分离 context sub2api GetAccountsLoadBatch 单 pipeline ≤151 命令;请求取消不拖垮共享抓取
4 冷却检查折入 acquire Lua 脚本 sub2api acquire 单脚本模式 每次抢槽 2 RT→1 RT
5 等待注册 Lua 原子守卫 sub2api incrementWaitScript 注册 1 RT;waiter 突发彻底不超发
6 等待轮询抖动指数退避(×2 至 1s 封顶) 饱和时各实例 waiter 不再 100ms 整点齐射
7 acquire 不确定结果单次分离 ZREM 清理(500ms 上限) #353 结论 客户端超时但脚本已提交时不留 30 分钟幽灵槽;不重试避免故障放大
8 每轮选择抢槽预算(默认 8,可配) #353 结论 全饱和候选集抢槽脚本数有上界,预算尽降级走等待

不变式

  • 缓存只服务排序提示,槽位所有权永远实时问 Redis——超卖不可能由缓存引起
  • max_concurrency <= 0 保持零 Redis 路径(Fix unlimited channel concurrency Redis skip #351 行为不动;bounded 集为空直接短路)
  • 候选集不设总量上限——分批只是命令分片,125 渠道回归确认全部参与选择;预算耗尽降级为等待候选,不砍低优先级回退
  • Redis 出错负载读取降级内存、抢槽 fail-closed,与 main 一致

新配置(channel_concurrency_setting,热更新)

字段 默认 说明
load_cache_enabled true 关掉即恢复直读(运行时回滚开关,无需回滚部署
load_cache_ttl_ms 200 快照 TTL,上限 5000
max_acquire_attempts 8 每轮抢槽脚本预算,上限 100

Redis 命令预算(开启后,单实例)

  • 负载读取:每候选集指纹每 200ms 最多 1 次抓取;50 渠道候选集 ≈ 1005 cmd/s/实例,与 QPS 无关
  • 抢槽:每请求 ≤8 个 EVALSHA,常态命中首个空闲渠道即 1 个
  • 等待:注册 1 + 退避重试 5s 窗口内最多约 7 个 + 释放 1-2

验证

  • go test ./service -run 'Concurrency|CacheGetRandomSatisfiedChannel' 全绿(含 Add sub2-style channel concurrency routing #154/Fix unlimited channel concurrency Redis skip #351 存量回归 + 8 个新增压力语义测试:缓存命中零 Redis 命令 / 16 并发未命中合并为 1 次 TIME / 125 渠道分批不丢 / 冷却拒绝单 RT / 20 waiter 突发恰好注册 maxWaiting / 预算耗尽经等待恢复 / 抖动区间)
  • go test ./middleware ./controller -run 'Concurrency|Distribute'go test ./setting/operation_setting 全绿;go vet 干净;全包编译通过
  • Not-tested:全量 go test ./service 在 RecallEmail 系列超时,已在未改动 origin/main 基线复现同样超时(存量套件交互问题,该测试孤立可过);-race 本机无 gcc 不可用

兼容性

  • 不改 DB schema / 渠道字段 / 429 语义 / 重试链路
  • acquire 脚本 1 key→2 key(并发 + 冷却):单实例 Memorystore 无影响,未来上 Cluster 需 hash tag(脚本内已注释)
  • 等待计数键名与 TTL 语义不变,可与旧实例混跑

上线建议

  1. staging 先开 3-5 个渠道,观察 Redis CPU 告警、选择延迟、429 比例、new-api:channel_concurrency* 键无残留
  2. 生产从低 QPS 视频/图片渠道灰度,逐步扩到目标渠道集
  3. 回滚:load_cache_enabled=false 即恢复 main 现状行为

设计文档:docs/superpowers/specs/2026-08-17-channel-concurrency-redis-pressure-optimization.md

Replaces #352 / #353(基于最新 main 重做,保护集合为两者并集)

Port sub2api's load-read protections onto the channel max-concurrency runtime: a 200ms snapshot cache with singleflight coalescing and 50-channel pipeline batches for load ordering, cooldown folded into the acquire script, an atomic Lua wait-queue guard, jittered exponential wait backoff, a per-pass acquire budget, and a single detached cleanup for uncertain acquire results.

Constraint: Production Redis is a Basic 1GiB Memorystore shared with channel cache and rate limiting; enabling concurrency limits on ~50 channels with per-request per-channel load reads would multiply Redis work by candidate-set size.

Rejected: Caching slot ownership | acquire must stay authoritative in Redis or multi-instance limits oversell.

Rejected: Capping the candidate set | batching bounds pipeline size without hiding lower-priority fallback channels.

Confidence: high

Scope-risk: moderate

Directive: Cache only ranking hints, never slot ownership; keep max_concurrency <= 0 on the zero-Redis path; budget exhaustion degrades to the wait path, never to an error.

Tested: go test ./service -run Concurrency and CacheGetRandomSatisfiedChannel including 8 new pressure-semantics tests (cache hit serves without Redis, 16 concurrent misses coalesce to one fetch, 125-channel batching drops no channel, cooldown rejects in one round trip, 20-waiter burst registers exactly maxWaiting, budget exhaustion recovers via wait, jitter bounds).

Tested: go test ./middleware ./controller -run Concurrency and Distribute; go test ./setting/operation_setting; go vet; go build all packages.

Not-tested: full go test ./service times out in TestRecallEmailRunBatch tests; reproduced identically on unmodified origin/main baseline (pre-existing suite-interaction issue, test passes in isolation).

Not-tested: -race unavailable on this machine (no gcc for cgo).
@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 commit bd57c150 · 共 2 条

service/channel_concurrency.go

  • L342-344: [严重] 这里把包含 CoolingDown/LoadRate 的实时负载快照按候选集缓存,但调用方会用 CoolingDown 做硬过滤;当渠道刚进入或刚退出冷却时,缓存窗口内可能继续选择已冷却渠道,或把已恢复渠道全部过滤掉,导致可用渠道被误判为不可用/并发受限。建议不要缓存 CoolingDown 这类可用性状态(每次实时读取或在 acquire 前后强校验),或将缓存仅用于排序提示字段,并把硬过滤改为强一致读取。
if cached, ok := lookupChannelConcurrencyLoadCache(key, time.Now()); ok {
		// 仅复用排序提示,CoolingDown 等可用性状态需实时校验,避免缓存导致硬过滤误判。
		return cached, nil
	}
  • L380-381: [严重] 回源读取完全脱离请求上下文且没有全局并发上限;不同候选集 fingerprint 的请求即使调用方已取消,也会继续占用最多 3 秒执行 Redis TIME/pipeline。在高基数候选集或大量取消请求下,可能堆积后台 Redis 查询,反而放大 Redis 压力。建议增加全局信号量/限流保护,或使用可被整体服务取消的共享上下文并在启动回源前做 in-flight 上限控制。
fetchCtx, cancel := context.WithTimeout(context.Background(), channelConcurrencyLoadFetchTimeout)
	defer cancel()
	// TODO: 在这里接入全局并发上限/限流,避免高基数候选集产生大量后台 Redis 回源。

Address OCR review on bd57c15: when cached CoolingDown flags filter out every candidate, re-read loads once bypassing the cache so a just-recovered channel is selectable within the cache window (sub2api's Fresh-read fallback); cap concurrent detached load fetches with a 2-slot semaphore so high-fingerprint-cardinality misses cannot pile up background Redis reads during a latency spike, degrading to memory ordering when saturated.

Constraint: Channels entering cooldown were already safe (acquire checks cooldown in-script in real time); only the recovery direction could stall selection for up to the cache TTL.

Rejected: Dropping CoolingDown from the cached snapshot | would force a per-request Redis read back in, recreating the pressure the cache exists to remove; the one-shot fresh fallback only fires in the all-filtered edge.

Confidence: high

Scope-risk: narrow

Directive: Fresh re-read fires at most once per selection and refreshes the shared cache; slot saturation degrades, never blocks.

Tested: go test ./service -run Concurrency, CacheGetRandomSatisfiedChannel, OrderCandidates, FetchLoads including two new regressions (stale cooldown recovers via fresh re-read; saturated fetch slots degrade to memory fallback).

Tested: go build ./service; go vet ./service.
@think-back

Copy link
Copy Markdown
Collaborator Author

跟进 OCR 评审(commit bd57c150 · 2 条),两条均已在 81646df6a 处理:

1. CoolingDown 进缓存导致硬过滤误判 —— 部分成立,已修复真实的那一半。

两个方向拆开看:

  • 刚进入冷却:不构成风险。acquire Lua 脚本每次都实时检查冷却 key(本 PR 把检查折进了脚本里),缓存窗口内即便负载快照还显示可用,抢槽也会被实时拒绝——所有权路径从不信任缓存,这是本 PR 的核心不变式。
  • 刚退出冷却:成立。缓存窗口内 stale 的 CoolingDown=true 会把已恢复渠道过滤掉,全员被过滤时选择会误判为不可用。

修复采用 sub2api 的 Fresh-read 回退模式(GetAccountsLoadBatchFresh 同款):当缓存快照把候选集全部过滤掉时,绕过缓存实时重读一次并刷新共享缓存。只在这个边缘场景多付一次 Redis 读,常态路径压力预算不变。未采纳"不缓存 CoolingDown"的建议——那会把逐请求 Redis 读加回来,正是本缓存要消除的压力源。回归:TestOrderCandidatesRefreshesWhenCachedCooldownFiltersEveryone

2. 回源无全局并发上限 —— 成立,已修复。

加 2 槽信号量(与 #353 压测验证过的 "two Redis fetch slots" 一致):singleflight 已按 fingerprint 合并同候选集调用方,信号量再约束跨 fingerprint 的总回源并发。槽位饱和时在 fetch timeout 内等不到即降级到内存排序回退(与 Redis 报错同路径),不阻塞、不堆积。回归:TestFetchLoadsDegradesWhenFetchSlotsSaturated

验证:go test ./service -run 'Concurrency|CacheGetRandomSatisfiedChannel|OrderCandidates|FetchLoads' -count=1 全绿;go build / go vet 干净。

@KingCesc

Copy link
Copy Markdown

🤖 OpenCodeReview · 评审 增量 bd57c150..81646df6 · 共 2 条

service/channel_concurrency.go

  • L362-369: [严重] Fresh 路径虽然绕过快照缓存,但这里也绕过了 singleflight 合并;当缓存中的 CoolingDown 导致大量相同候选集同时触发 Fresh 回读时,每个请求都会各自发起 Redis pipeline,容易形成读放大并抵消新增的全局 slot 限流(大量请求排队到超时后降级)。建议 Fresh 仍按候选集 key 使用 singleflight 合并,只是不读取旧缓存,fetch 成功后再刷新缓存。
key := channelConcurrencyLoadCacheKey(bounded)
	if !allowCache {
		result, err, _ := channelConcurrencyLoadGroup.Do(key+":fresh", func() (any, error) {
			fetched, err := fetchRedisChannelConcurrencyLoads(bounded)
			if err != nil {
				return nil, err
			}
			storeChannelConcurrencyLoadCache(key, fetched, time.Now().Add(ttl))
			return fetched, nil
		})
		if err != nil {
			return nil, err
		}
		loads, _ := result.(map[int]ChannelConcurrencyLoad)
		if loads == nil {
			return map[int]ChannelConcurrencyLoad{}, nil
		}
		return cloneChannelConcurrencyLoads(loads), nil
	}

service/channel_select.go

  • L493-496: [严重] 这里把“刷新失败”直接返回为错误,会把原本只是想二次确认冷却状态的路径升级成硬失败。当前 GetChannelConcurrencyLoads 已经有内存回退,但这一层 fresh 读取一旦遇到 Redis 抖动/限流就会让整次频道选择中断,可能把本来可用的候选集误判成不可用。建议 fresh 失败时降级继续使用前一次的结果,或改走带内存回退的读取逻辑,避免把选择阶段变成单点故障。
freshLoads, freshErr := GetChannelConcurrencyLoadsFresh(ctx, candidates)
		if freshErr != nil {
			return ordered, nil
		}

Address OCR review on 81646df: fresh load reads now coalesce under their own singleflight key (skipping only the snapshot lookup) and refresh the shared cache, so an all-cooled-down stampede over one candidate set collapses to few fetches instead of one pipeline per caller; a fresh-read failure keeps the cooldown-filtered result instead of failing the whole selection, since a confirmation pass must never be a harder failure than the read it confirms.

Constraint: The fresh path already degrades to memory internally on Redis failure; only the residual error propagation could turn a routine cooldown re-check into a selection outage during Redis jitter.

Confidence: high

Scope-risk: narrow

Tested: go test ./service -run Concurrency and CacheGetRandomSatisfiedChannel full suite plus FreshLoadReadsCoalesce, OrderCandidatesRefreshes, FetchLoadsDegrades regressions.

Tested: go build ./service; go vet ./service; gofmt clean.
@think-back

Copy link
Copy Markdown
Collaborator Author

跟进 OCR 增量评审(bd57c150..81646df6 · 2 条),两条均成立,已在本次提交处理:

1. Fresh 路径绕过 singleflight,回读风暴会读放大 —— 成立,已修复。

按建议把 fresh 读也纳入 singleflight 合并:走独立的 "fresh:"+key 组键(不与缓存路径共键,保证语义上确实绕过快照查找),fetch 成功后刷新共享缓存。同一候选集的 fresh 风暴现在合并为少数几次抓取,2 槽信号量继续约束跨 fingerprint 总量。回归:TestFreshLoadReadsCoalesceUnderSingleflight(16 并发 fresh 读合并后 TIME 调用 ≤3)。

2. Fresh 刷新失败把确认路径升级成硬失败 —— 成立,已修复。

fresh 只是对"缓存把候选集全过滤"这一结论的二次确认;确认动作不应比被确认的读取失败得更狠。现在 fresh 报错时保留原过滤结果(空集)返回,选择走既有的 wait/429 路径,不再中断整次渠道选择。GetChannelConcurrencyLoadsFresh 内部本身已带内存降级,这里兜的是降级后的残余错误。

验证:go test ./service -run 'FreshLoadReadsCoalesce|OrderCandidatesRefreshes|FetchLoadsDegrades|ChannelConcurrencyLoad' 全绿;go build / go vet 干净。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants