Cut channel concurrency Redis pressure for bounded rollout - #730
Conversation
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).
|
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.
|
跟进 OCR 评审(commit 1. CoolingDown 进缓存导致硬过滤误判 —— 部分成立,已修复真实的那一半。 两个方向拆开看:
修复采用 sub2api 的 Fresh-read 回退模式( 2. 回源无全局并发上限 —— 成立,已修复。 加 2 槽信号量(与 #353 压测验证过的 "two Redis fetch slots" 一致):singleflight 已按 fingerprint 合并同候选集调用方,信号量再约束跨 fingerprint 的总回源并发。槽位饱和时在 fetch timeout 内等不到即降级到内存排序回退(与 Redis 报错同路径),不阻塞、不堆积。回归: 验证: |
|
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.
|
跟进 OCR 增量评审( 1. Fresh 路径绕过 singleflight,回读风暴会读放大 —— 成立,已修复。 按建议把 fresh 读也纳入 singleflight 合并:走独立的 2. Fresh 刷新失败把确认路径升级成硬失败 —— 成立,已修复。 fresh 只是对"缓存把候选集全过滤"这一结论的二次确认;确认动作不应比被确认的读取失败得更狠。现在 fresh 报错时保留原过滤结果(空集)返回,选择走既有的 wait/429 路径,不再中断整次渠道选择。 验证: |
背景 / Problem
渠道级最大并发数(#154/#351)已上线,但当前 main 的实现里,渠道选择路径对候选集内每个限并发渠道逐一读 Redis 负载——无缓存、无合并、pipeline 无上界。生产 Redis 是 Basic 1GiB 单节点 Memorystore,与渠道缓存/限流共用;在几十个渠道上大规模开启限并发之前,必须先把读放大压下来(#352/#353 的原始动机,两个 PR 已落后 main 一个多月且保护不全,本 PR 基于最新 main 重做并做全)。
方案:移植 sub2api 的抗压机制 + #353 评审结论
accountLoadCacheaccountLoadGroupGetAccountsLoadBatchincrementWaitScript不变式
max_concurrency <= 0保持零 Redis 路径(Fix unlimited channel concurrency Redis skip #351 行为不动;bounded 集为空直接短路)新配置(channel_concurrency_setting,热更新)
load_cache_enabledload_cache_ttl_msmax_acquire_attemptsRedis 命令预算(开启后,单实例)
验证
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干净;全包编译通过go test ./service在 RecallEmail 系列超时,已在未改动 origin/main 基线复现同样超时(存量套件交互问题,该测试孤立可过);-race本机无 gcc 不可用兼容性
上线建议
new-api:channel_concurrency*键无残留load_cache_enabled=false即恢复 main 现状行为设计文档:
docs/superpowers/specs/2026-08-17-channel-concurrency-redis-pressure-optimization.mdReplaces #352 / #353(基于最新 main 重做,保护集合为两者并集)