Repository navigation
perf traffic control logic - #268
TuBoShu-max wants to merge 2 commits into
Conversation
|
吴康亮 seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
| return &experiments.TrafficControlTargetTraffic{ | ||
| ItemOrExpId: key, TrafficControlTaskId: task.TrafficControlTaskId, | ||
| TrafficControlTargetId: id, TargetTraffic: detail.Traffic, | ||
| TaskTraffic: task.ActualTraffic.TaskTraffics[key].Traffic, |
There was a problem hiding this comment.
TaskTraffics[key] 缺少存在性检查 → 会在 L743 产生 Inf/NaN
先澄清一个容易误判的点:这里不会 panic。SDK 中 TaskTraffics 是 map[string]TaskTrafficDetail(值类型,model/model_traffic_ctrl_task.go:170),缺失 key 返回零值结构体,.Traffic 取到 0.0。Data 同为值类型 map[string]TargetTrafficDetail;L1081 那个 targetTraffic == nil 检查是因为 TargetTraffics 才是指针切片 []*TargetTraffic——两个字段类型本来就不同,不构成守卫不对称。
真正的问题是 TaskTraffic = 0 会一路流到 computeMacroTargetAlpha L743 那个没有除零保护的除法:
trafficPercentage := targetTraffic / taskTraffictargetTraffic > 0→+Inf:L233 判定+Inf >= aimValue/100为 true →achieved→ L249 提前返回,该目标静默停止上提(下次有效测量可恢复)。targetTraffic == 0→NaN,这一支不可逆:- L243
currentError = aimValue/100 - NaN = NaN - L291-294 的积分钳位对 NaN 完全失效(NaN 参与的任何比较均为 false)
integralSum全仓库无任何重置点 → 进程生命周期内永久污染- L200
iTerm = ki * NaN→alpha = NaN - L371-374
if alpha == 0 { continue }——NaN == 0为 false,过滤不掉,NaN alpha 会进入上提/打分逻辑污染 item score,影响面溢出到全局排序
- L243
两个加重因素:
- L703-708 的
defer recover()拦不住这个——Inf/NaN 在 Go 中是合法 float64 值,不触发 panic。 IntegralThreshold无代码默认值(recconf.go:308是裸float64,pid_controller.go:60直接透传)。未配置即为0→ L279 的integralThreshold > 0不成立 → 跳过积分分离的意外保护,直接走 L288integralSum += factor * currentError * dt,NaN 落入积分项。反之若配置了IntegralThreshold > 0,math.Abs(NaN) <= integralThreshold为 false 会落入积分分离区,integralSum反而幸免——缺陷严重性依赖配置项,隐蔽性更高。
这不是本 PR 引入的回归:SDK 自己的 experiments/client_traffic_ctrl_tasks.go:274 做的是同样的无守卫索引(taskTraffic := taskTrafficMap[expOrItemId]),L1089 的注释也写明「与 SDK 的线性查找一致」,两边产生记录的前置条件也一致(都要求 Data[key] 存在)。因此这条不阻塞合并,只是本 PR 把这段逻辑收归仓内后,顺手修掉的成本很低。
另外 L725 / L739 已经有 taskTraffic = 1 的除零兜底,说明除零是作者已知的风险,只是漏了 L1099 这条新路径。
建议
1. 源头修复:lookup 在 TaskTraffics[key] 缺失时返回 nil。三个调用点(L1105 / L1110 / L1114)已经都在判 record != nil,无需改动即可正确降级为「无数据」:
lookup := func(key string) *experiments.TrafficControlTargetTraffic {
detail, ok := targetTraffic.Data[key]
if !ok {
return nil
}
// skip the record when the task-level aggregate is absent, otherwise the
// zero TaskTraffic yields Inf/NaN in the percentage computation
taskDetail, ok := task.ActualTraffic.TaskTraffics[key]
if !ok {
return nil
}
return &experiments.TrafficControlTargetTraffic{
ItemOrExpId: key, TrafficControlTaskId: task.TrafficControlTaskId,
TrafficControlTargetId: id, TargetTraffic: detail.Traffic,
TaskTraffic: taskDetail.Traffic,
RecordTime: time.Unix(detail.RecordTime, 0),
}
}2. 纵深防御:L743 前补除零保护,与 L725 / L739 的既有写法保持一致:
if taskTraffic <= 0 {
taskTraffic = 1
}3. 可选:L744 setMeasurement 前加 math.IsNaN / math.IsInf 拦截。sort 包内其他实现已有此惯例(ssd_sort.go:388/424/443/457、embedding_query.go:191),PID 路径是唯一缺口。
exists 判断是无效的——值类型缺失 key 本来就返回 0.0,加守卫后数值完全不变,Inf/NaN 依旧存在。必须同时处理除法侧(方案 2),或让整条记录缺失(方案 1)。
bruceding
left a comment
There was a problem hiding this comment.
Request changes — 1 处阻塞级问题(sort/traffic_control_sort.go:1321-1327,详见该行 inline comment)。
摘要:itemExprProg == nil 的语义由 master 的 return true(匹配全部物品)反转为 return false(匹配零个物品)。已核实 nil 的生产方 GenerateItemExpress 本 PR 一字未改(master/HEAD 函数体 byte-compare 相同),因此这是消费方的单边语义反转,构成本 PR 引入的回归。
影响:物品条件四个字段全空的存量调控目标(仅靠场景/用户/实验限定范围)会在宏观(L350)与微观(L917)两条路径上同时静默失效;无 error 日志、不 panic、配置与 schema 均无变化,属静默的行为语义兼容性破坏。PR 未提供兼容开关,描述为空。
注:作者收紧「解析/编译失败」两种情形的意图是正确的(master 在该情形下 return true 确有 bug),应当保留;建议按 inline comment 中的三态区分方案修复,而非整体回退。
另有一条非阻塞建议已单独评论在 L1099(TaskTraffics[key] → Inf/NaN,经核实为 SDK 既有行为、非本 PR 回归)。
| if controller.itemExprProg == nil { | ||
| return true | ||
| // 物品表达式未编译成功(未配置条件或解析/编译失败)时拦截所有物品, | ||
| // 避免错误配置把全量物品当成调控目标;警告日志每个控制器只打一次。 | ||
| controller.itemExprNilWarnOnce.Do(func() { | ||
| log.Warning(fmt.Sprintf("module=TrafficControlSort\titem expression is not compiled, all items are excluded\t%s", trafficIdentity(controller.task.TrafficControlTaskId, controller.target.TrafficControlTargetId, controller.target.Name))) | ||
| }) | ||
| return false |
There was a problem hiding this comment.
🔴 阻塞:itemExprProg == nil 的语义被反转,构成静默的向后兼容性破坏
这里把 nil 的处理从 master 的 return true(匹配全部物品)改成了 return false(匹配零个物品)。这是本 PR 引入的行为回归,不是既有问题。
1. 纵向不一致(版本间)——兼容性问题的来源
已核对:nil 的生产方 GenerateItemExpress(sort/pid_controller.go:425-467)本 PR 一字未改(master 与 HEAD 的函数体做过 byte-compare,完全相同;pid_controller.go 的 59 增 45 删全部落在 member cache 移除、时区 sync.Once、日志三处)。因此不存在"上游编译逻辑变了、下游必须跟着变"的正当性,这是一次纯粹的消费方单边语义反转。
itemExprProg == nil 实际有三个来源,其中第一个是完全合法的:
| 来源 | 代码位置 | 是否打日志 | 配置是否合法 | master | HEAD | 应有行为 |
|---|---|---|---|---|---|---|
| (a) 未配置物品条件 | pid_controller.go:448 守卫不成立 → 跳过编译 |
无任何日志 | ✅ 合法 | true |
❌ false |
匹配全部 |
| (b) 解析失败 | :430 / :437 early return |
error 日志 | ❌ 错误 | ❌ true |
✅ false |
匹配零个 |
| (c) 编译失败 | :455 / :462 early return |
error 日志 | ❌ 错误 | ❌ true |
✅ false |
匹配零个 |
L1322 的注释「未配置条件或解析/编译失败」表明作者清楚存在两种来源,但选择了一并拦截。
(a) 的成立已逐层验证:ParseExpression("", "")(pid_controller.go:609-657)中 L611 的 conditionArray != "" 为 false 跳过整个循环,L649 的 conditionExpress != "" 也为 false,最终 return "", nil——空字符串且无 error。回到 GenerateItemExpress:440-447,targetExpression 与 taskExpression 均为空,三个分支全不命中,itemExpression 保持 "",于是 L448 守卫不成立,编译被整体跳过,且一条日志都不打。
2. 横向不一致(同版本内)——反驳"这是有意的安全默认"
同一文件内 user 侧保留了相反约定,isControlledByUser(L1235-1238):
if controller.userExprProg == nil {
controllerNewMap[targetId] = controller // nil 用户条件 = 匹配全部用户
continue
}而 GenerateUserExpress(pid_controller.go:469-484,L475-477 if taskUserExpression == "" { return })产生 nil 的结构与 item 侧完全同构(同样是"未配置 / 解析失败 / 编译失败"三来源)。
两侧结构同构但结论相反,说明 match-all 才是本仓库对"未配置条件"的既定约定,item 侧的翻转是孤例。如果"nil → 拒绝"是正确的安全默认,user 侧早该同样处理。
3. 兼容性定性
| 兼容维度 | 是否受影响 |
|---|---|
| API / 编译兼容 | ❌ 否(签名未变,isControlledItem 仍保留) |
| 配置 schema 兼容 | ❌ 否(recconf 相关字段未动,存量配置照常解析,控制台上的任务无需修改) |
| 行为语义兼容 | ✅ 是——同一份配置、同一个调控任务,升级前后行为突变 |
这是最难发现的一类 breaking change,所有常规防线全部失效:
- 配置不报错(schema 未变,运维看不出异常)
- 编译 / 启动不报错,运行不 panic
- 无 error 日志——来源 (a) 走的是"表达式为空"分支,
GenerateItemExpress完全静默 - 唯一信号是
itemExprNilWarnOnce那条每 controller 仅一次的 warning,措辞item expression is not compiled, all items are excluded会被读成"该目标表达式配错了",而该配置完全合法,排查方向从第一步就被带偏;sync.Once意味着日志轮转后基本无法回溯 - 指标不异常、接口正常返回,只是排序结果里该目标的物品不再被上提
最终暴露形式是业务侧的"调控目标达不成量",通常要等业务方看流量占比报表才发现,定位链路很长。
受影响面已核实为全路径:itemExprProg 全仓库只有一个消费者(本函数),而它有两个活跃调用点,正好覆盖宏观与微观两条路径:
- 宏观 L350(
matchMacroTargets并行 worker 内):恒 false →localScores[targetId]永远为 0、matchedTargets永远为空 → 该目标宏观得分恒 0,alpha 计算与上提配额全部不生效 - 微观 L917:恒 false → 直接
continue→localControlNumberMap不计数、controller.compute不被调用、alpha 不施加 → 单品调控完全静止
master 上有 3 个调用点(:269/:358/:684),HEAD 收敛为 2 个,两处共用同一 helper,没有任何路径幸免。
谁会中招(需同时满足,因为 GenerateItemExpress:441-447 对 task/target 取并集,任一非空就会编译):task 级与 target 级的 ItemConditionArray、ItemConditionExpress 四个字段全空。典型形态是不靠物品属性筛选、只靠场景 / 用户 / 实验限定范围的调控目标(例如"某实验桶内全量物品保量")。
4. 本 PR 未提供任何兼容手段
recconf/recconf.go的唯一改动是新增TargetSampleCount(每请求调控目标抽样数),与此无关,没有兼容开关 / feature flag- PR 描述为空(只有标题
perf traffic control logic),无行为变更说明、无 breaking change 声明、无迁移指引
升级方无从得知这一语义变化。
5. 作者的意图是对的,所以不要简单回退
L1323 的「避免错误配置把全量物品当成调控目标」指向的是来源 (b)(c)——master 在这两种真失败下 return true 确实是 bug:一个表达式写错的目标会把全量物品当成调控对象,可能引发大面积错误上提。这个收紧是本 PR 的改进,应当保留。
问题只在手段过宽,把合法的 (a) 一并挡了。正确修法是三态区分:保留对 (b)(c) 的拒绝,恢复对 (a) 的 match-all。
6. 建议修复
推荐加状态位(而非编译恒真表达式)——因为 L350 / L917 都是 per-item × per-controller 的热循环,编译恒真 program 会给每个无条件目标凭空增加一次 VM Run,与本 PR 的 perf 目标相反;状态位方案在热路径上零额外开销(连 VM 都不进):
// PIDController struct
itemExprUnconfigured bool // true when no item condition is configured at all// GenerateItemExpress, in the branch where itemExpression == ""
} else {
// no item condition configured: the target intentionally matches all items,
// which is different from a parse/compile failure and must stay distinguishable
p.itemExprUnconfigured = true
}// isControlledItemFeaturesWithVM
if controller.itemExprProg == nil {
// unconfigured means "match all items" (same convention as isControlledByUser);
// a genuine parse/compile failure still excludes every item
if controller.itemExprUnconfigured {
return true
}
controller.itemExprNilWarnOnce.Do(func() {
log.Warning(fmt.Sprintf("module=TrafficControlSort\titem expression failed to compile, all items are excluded\t%s", trafficIdentity(controller.task.TrafficControlTaskId, controller.target.TrafficControlTargetId, controller.target.Name)))
})
return false
}顺带建议:GenerateUserExpress(pid_controller.go:469-484)同步处理,让两侧约定统一为"nil == 真失败",否则文件内仍是两套约定、只是方向反了过来,后续维护者还会踩同一个坑。同时把 warning 措辞改成明确的"failed to compile",避免继续误导运维。
7. 必须补的回归测试
四类,缺一不可——第 1 与第 4 条期望相反,正是当前实现无法同时满足的约束,也是证明修复正确的最小充分集:
| # | 场景 | 构造 | 期望 |
|---|---|---|---|
| 1 | 未配置任何物品条件 | task 与 target 四个字段全空 | 匹配全部物品(当前实现会失败 ← 本条即回归守卫) |
| 2 | 仅任务级配置条件 | task 有条件、target 为空 | 按 task 条件匹配 |
| 3 | 仅目标级配置条件 | target 有条件、task 为空 | 按 target 条件匹配 |
| 4 | 编译失败 | 非法表达式(如 ItemConditionExpress: "&&&") |
匹配零个物品 + 打出 warning |
isControlledItemFeaturesWithVM 是纯函数(入参 controller + itemId + properties + machine,无 ctx / goroutine / 时钟依赖),这四条都是廉价的确定性单测,不存在"难以测试"的豁免理由。
结论:请在合并前修复,或由维护者明确确认"翻转空物品条件语义"是有意为之、并已评估存量部署影响面(线上有多少 target 的四个物品条件字段全空)。 若确认为有意变更,则至少需要:PR 描述中写明 breaking change、提供兼容开关、并把 warning 措辞改为不再误导。
bruceding
left a comment
There was a problem hiding this comment.
Request changes(第 2 处阻塞) — sort/traffic_control_sort.go:681-686,详见该行 inline comment。
摘要:12ms 超时预算是既有的(master :427 与本 PR :620 完全一致),但超时命中后的行为被本 PR 改坏了——master 是 break Loop 立即返回、不等在飞 goroutine;本 PR 改成 for result := range results 阻塞等待 channel 关闭,而关闭依赖 L661-664 的 wg.Wait(),因此主协程在超时后要等所有 worker 跑完手头 job 才能返回。worker pool 的串行化叠加阻塞等 close,使实际等待时间 ≈ 最慢一个在飞 computeMacroTargetAlpha,12ms 预算失效。
这与本 PR perf traffic control logic 的目标直接冲突,且恰好在超时真正被触发的高负载场景下最严重。
已核实:本 PR 用 L661-664「后台 goroutine 先 wg.Wait 再 close」正确修掉了 master 的 send-on-closed-channel panic(master :425 的 defer close(retCh) 与 :503 的在飞发送存在竞争,靠 :431 的 recover 兜住)。该改动应当保留,修复方案见 inline comment —— 只改超时分支为非阻塞 drain,不动 close 所有权。
两处校正(避免高估):等待幅度有界(非无限);本 PR 给 ctx.LogDebug 加 if ctx.Debug 守卫实际上减少了日志开销,是正面改动。残留的 pid_controller.go:302-310 log.Debug 采样点是 master 既有、非本 PR 引入。
—— 至此本 PR 已记录 2 处阻塞(L1321-1327 语义兼容性、L681-686 超时失效)+ 1 处非阻塞建议(L1099 Inf/NaN)。
| case <-gCtx.Done(): | ||
| if errors.Is(gCtx.Err(), gocontext.DeadlineExceeded) { | ||
| ctx.LogWarning(fmt.Sprintf("module=TrafficControlSort\ttraffic controller timeout: %v", gCtx.Err())) | ||
| timedOut = errors.Is(gCtx.Err(), gocontext.DeadlineExceeded) | ||
| for result := range results { | ||
| collectResult(result) | ||
| } | ||
| break Loop | ||
| break Collect |
There was a problem hiding this comment.
🔴 阻塞:超时分支从「立即返回」变成「阻塞等待所有 worker」,12ms 预算失效
这是本 PR 引入的回归,不是既有行为。12ms 超时预算本身是既有的(master :427 与本 PR :620 的 gocontext.WithTimeout(..., time.Millisecond*12) 一字不差),但超时命中后的行为被改坏了。
1. master 与 HEAD 的直接对比
master(:510-523)——超时即刻跳出,不等任何在飞 goroutine:
Loop:
for range controllerMap {
select {
case pair := <-retCh:
...
case <-gCtx.Done():
if errors.Is(gCtx.Err(), gocontext.DeadlineExceeded) { ctx.LogWarning(...) }
break Loop // ← 立即返回
}
}HEAD(L681-686)——超时后阻塞直到 results 被 close:
case <-gCtx.Done():
timedOut = errors.Is(gCtx.Err(), gocontext.DeadlineExceeded)
for result := range results { // ← 裸 range,无取消出口
collectResult(result)
}
break Collect而 results 只在 L661-664 的 wg.Wait() 返回后才 close:
go func() { wg.Wait(); close(results) }()即 主协程在超时后要等所有 worker 退出才能返回。
2. 并发模型的改变放大了这个回归
| master | HEAD | |
|---|---|---|
| 并发结构 | 一个 target 一个 goroutine(:429),全并发 |
固定 workerCount 的 worker pool + jobs channel |
| 取消粒度 | 每个 goroutine 在自己的发送 select 上独立取消(:498-507) |
worker 必须跑完手头那一个 job(L648)才能回到 L638 检查取消 |
| 结果缓冲 | MinInt(len(controllerMap), 64)(:424) |
仅 workerCount(L619) |
| 超时后主协程 | break Loop 直接走 |
阻塞在 for range results 等 wg.Wait() |
worker pool 的串行化叠加阻塞等 close,使超时后的等待时间 ≈ 最慢那个在飞 computeMacroTargetAlpha 的耗时,而不再是 12ms。
需要说明的是,worker 侧的取消检查点其实设计得比较完备——L638 循环顶部 gCtx.Err()、L642 select 的 <-gCtx.Done()、L654 发送侧的 <-gCtx.Done() 都在。所以 worker 只在正在执行 L648 那一个 job 期间无法被打断;而 computeMacroTargetAlpha 内部的 setMeasurement / compute / 日志都不接受 gCtx,无法中断。
3. 成因:这个回归是为了修 master 的一个真 bug
master 有一个实打实的 send-on-closed-channel 缺陷::425 的 defer close(retCh) 在主协程执行,而 :429 起的 goroutine 在主协程 break Loop 返回后可能仍在 :503 往 retCh 发送 → 向已关闭 channel 发送 → panic。master 靠 :431-436 的 recover 兜住,那条 "traffic control timeout in background" 日志就是这个场景的产物。
HEAD 用 L661-664「专属 goroutine 先 wg.Wait() 再 close(results)」正确修掉了这个 bug——close 的所有权移到了确认无发送者之后。这个改动应当保留。
代价就是主协程为了不去 close 一个还有发送者的 channel,变成了要等 wg.Wait(),把「立即返回」这个特性弄丢了。所以修复必须两者兼得,不能简单回退到 master 写法(那会把 send-on-closed panic 带回来)。
4. 幅度校正:不是无界,且日志部分本 PR 其实是改善
为避免高估,两点校正:
- 等待幅度有界:上限是「最慢一个在飞
computeMacroTargetAlpha」,不是无限等待。正常情况下该函数是纯 CPU 的 PID 计算,微秒级,代价很小。 - 本 PR 实际上减少了日志开销:master 的
compute里ctx.LogDebug(:16/:30/:35/:45/:52/:71/:106)全是无条件调用,本 PR 逐个包进了if ctx.Debug守卫;computeMacroTargetAlpha里的日志同样被包进 L746-748 / L758-760 的 Debug 守卫。这是正面改动。
但风险在高负载下依然真实:pid_controller.go:302-310 的 logProb/log.Debug 采样块(master :302-306 就有,非本 PR 引入)在 logProb = 1.0(非单品粒度)时对宏观目标每次 compute 都打一条 log.Debug,走全局 logger。若部署镜像带 --alsologtostderr(每行日志持全局锁双写终端+文件,是已知的秒级尾延迟来源),这个点恰好落在 worker 无法被取消的那段代码里,使「等待最慢 worker」从理论风险变成实际风险。
关键在于:12ms 超时真正被触发的场景就是高负载场景,而这个回归恰好在高负载下最严重——超时机制在最需要它的时候失效。 这与本 PR perf traffic control logic 的目标直接冲突,且落在在线排序关键路径的尾延迟上,因此定为阻塞。
5. 建议修复
只改超时分支,L661-664 的 close 所有权机制原样保留:
case <-gCtx.Done():
timedOut = errors.Is(gCtx.Err(), gocontext.DeadlineExceeded)
// drain only what is already buffered and return immediately; the background
// goroutine still owns closing results after wg.Wait(), so there is no risk
// of closing a channel with active senders
Drain:
for {
select {
case result, ok := <-results:
if !ok {
break Drain
}
collectResult(result)
default:
break Drain
}
}
break Collect要点:
default分支使其非阻塞——只收已经躺在缓冲区里的结果(缓冲为workerCount,最多这么多条),随即返回。- 不动 L661-664:仍由后台 goroutine 在
wg.Wait()后 close,主协程永不 close,因此不会重现 master 的 send-on-closed panic。 - worker 侧发送 L652-656 已有
<-gCtx.Done()出口,不会因主协程提前离开而永久阻塞泄漏。 - 语义变化:超时后放弃尚未算完的 target 的 alpha(它们本来也赶不上 12ms 预算)。这与 master 的
break Loop行为一致,属于恢复原语义,不是引入新行为。
6. 必须补的测试
构造一个慢 worker(job 内 sleep 超过 12ms),断言 computeMacroControlAlpha 的返回耗时 ≈ 12ms 而非 sleep 时长。当前实现下这个测试会失败,正好作为回归守卫;修复后应当通过。
bruceding
left a comment
There was a problem hiding this comment.
第 3 条评论:sort/traffic_control_sort.go:391-413(selectMacroQuantityUpliftItems),详见 inline comment。
按量宏观目标的上提名额限制是本 PR 新增的(master 宏观循环无 item 级名额,配置注释也由「单品调控时」改写为「单品调控和按量全局调控分别适用」)。当前按原始排名贪心分配,名额可能全部落在已在曝光区内的物品上,导致该目标无法增加曝光、PID 反馈环失去作用能力并引发积分饱和。
考虑到「按排名先到先得」与既有微观路径(L967,继承自 master :734)一致、且作者专门改写了配置注释,这可能是有意的对称设计,因此本条用 COMMENT 而非 request changes,请作者判断。
inline comment 中附了一个净增约 6 行的最小改动修复方案(两趟扫描:优先曝光区外候选,剩余名额回落到区内,保证不减少任何原本会发生的上提)+ 3 条确定性单测 + 明确标注的超范围事项。
若确认是设计意图,建议在 PR 描述中补充说明——当前 PR body 为空,这个从「无限」到「最多 5 个」的能力收窄对使用方不可见。
—— 本 PR 目前累计:2 处阻塞(L1321-1327 语义兼容性、L681-686 超时失效)、1 处待作者决策(本条)、1 处非阻塞建议(L1099 Inf/NaN,经核实为 SDK 既有行为)。
| func selectMacroQuantityUpliftItems(matchedTargets [][]string, activeControllers map[string]*PIDController, targetAlphaMap map[string]float64, limit int) []bool { | ||
| selected := make([]bool, len(matchedTargets)) | ||
| if limit <= 0 { | ||
| return selected | ||
| } | ||
|
|
||
| selectedCount := 0 | ||
| for itemIndex, targetIds := range matchedTargets { | ||
| for _, targetId := range targetIds { | ||
| controller, ok := activeControllers[targetId] | ||
| if !ok || controller.task.ControlType != constants.TrafficControlTaskControlTypeQuantity || targetAlphaMap[targetId] <= 0 { | ||
| continue | ||
| } | ||
| selected[itemIndex] = true | ||
| selectedCount++ | ||
| break | ||
| } | ||
| if selectedCount >= limit { | ||
| break | ||
| } | ||
| } | ||
| return selected | ||
| } |
There was a problem hiding this comment.
按量宏观目标的上提名额可能全部消耗在已曝光物品上 —— 附最小改动修复方案
先说明定性:这是本 PR 新增的行为限制(已确证),但可能是有意的对称设计,因此这条不作为强制阻塞项,而是把问题与一个最小改动的修复方案一并列出,请作者判断是设计意图还是需要调整。
一、这个名额限制是本 PR 新增的(三重证据)
- 配置注释被本 PR 改写:
- master
:1094:// 单品调控时,限制单品调控数量,默认为5 - 本 PR
:1468:// 单品调控**和按量全局调控**分别适用的单次上提物品数上限,默认为5
- master
upliftItemLimit/selectMacroQuantityUpliftItems/shouldApplyMacroTarget三个函数均为新增。- master 的宏观上提循环(
:352-376)完全没有 item 级名额:每个匹配到非零 alpha 的物品都会被上提(finalDeltaRank += deltaRank // 形成合力),且不区分 Quantity / Percent。master 的limitCount只出现在microControl(:734)。
即:按量宏观目标的上提能力从无限收窄为最多 5 个物品(默认值)。
二、具体问题:选择标准与调控目标不一致
matchedTargets 的下标就是原始排名(:138 设 _ORIGIN_POSITION_ = i+1,:352 用同一下标填充),而 L398 按下标升序贪心取前 limit 个。selectMacroQuantityUpliftItems 的签名与函数体内都没有 ctx / Size / pageNo / 曝光边界,结构上无法感知曝光窗口。
举例:ctx.Size = 20、limit = 5,某未达标的按量目标同时匹配原始排名 1-5 与 21-25 的物品:
- 名额全被 1-5 拿走;它们本来就在曝光区内,上提与否不改变曝光数
- 21-25 被 L561 的
shouldApplyMacroTarget跳过,拿不到任何上提 - 结果:该目标进入曝光区的物品数增量恒为 0,即使 alpha 已经很大
两个放大因素:
- 名额是所有按量目标共享的单一预算(L390 注释明写「共享」,L397 只有一个
selectedCount,L406 的break使一个物品即使命中多个按量目标也只占一个名额)。多个按量目标按物品排名先到先得地瓜分 5 个名额,排名靠后的目标可能一个都拿不到,与它欠量多严重无关。 - alpha 的数值大小不参与分配(L401 只用
targetAlphaMap[targetId] <= 0做布尔门控)。PID 的输出无法影响名额归属,于是:名额被已曝光物品占满 → 曝光数无法增加 → error 恒为正 →pid_controller.go:282/288积分持续累积 →:291-292钳到integralMax→ alpha 饱和但完全无效(典型积分饱和)。
三、缓解因素(为什么这条不作为强制阻塞)
- 「按排名先到先得」与既有微观路径一致(L967
upliftCount < limitCount,继承自 master:734),所以宏观沿用同一标准可能是有意的对称设计,而非疏漏。 - 作者显然清楚这个语义扩展(专门改写了配置注释),不是无意引入。
limit可通过PidSingleControlLimitCount调整。
四、最小改动修复方案(净增约 6 行)
改动点
| # | 位置 | 改动 |
|---|---|---|
| 1 | L391 函数签名 | 增加一个参数 exposureBoundary int |
| 2 | L397-411 循环体 | 外层包一趟 for pass := 0; pass < 2,加 3 行区内外过滤;内层目标匹配逻辑一字不改 |
| 3 | L509 调用点 | 末尾多传一个实参 ctx.Size |
| 附 | L389-390 注释 | 更新为英文并说明新语义 |
无结构体变更、无新增依赖、无新增 import、无架构调整。
边界判据直接复用同文件既有约定:微观路径 L972-975 已在用 pos > ctx.Size 判定「在曝光区外」,而 _ORIGIN_POSITION_ = index + 1,故 pos > ctx.Size ⟺ index >= ctx.Size。方案中的 itemIndex >= exposureBoundary(传 ctx.Size)与之完全等价,不需要把 items 传进来读属性,也不引入第二套窗口定义。
核心思路是两趟扫描,而不是跳过已曝光物品:
// selectMacroQuantityUpliftItems allocates the shared per-request uplift quota for
// all quantity-based macro targets. Candidates are scanned in original rank order,
// but items still outside the exposure window are served first: uplifting an item
// that is already exposed cannot add any new exposure for the target, so spending
// the quota on it starves the targets that still have to grow. The second pass
// spends any leftover quota inside the window, so no budget is wasted and no uplift
// that used to happen is dropped. An item matching several positive quantity
// targets still consumes a single slot.
//
// exposureBoundary reuses the convention of the micro control path (an item is
// outside the exposure window when its position is greater than ctx.Size); a
// non-positive value disables the preference and keeps the legacy rank-only order.
func selectMacroQuantityUpliftItems(matchedTargets [][]string, activeControllers map[string]*PIDController, targetAlphaMap map[string]float64, limit, exposureBoundary int) []bool {
selected := make([]bool, len(matchedTargets))
if limit <= 0 {
return selected
}
selectedCount := 0
for pass := 0; pass < 2 && selectedCount < limit; pass++ {
for itemIndex, targetIds := range matchedTargets {
outside := exposureBoundary <= 0 || itemIndex >= exposureBoundary
if (pass == 0) != outside {
continue
}
for _, targetId := range targetIds {
controller, ok := activeControllers[targetId]
if !ok || controller.task.ControlType != constants.TrafficControlTaskControlTypeQuantity || targetAlphaMap[targetId] <= 0 {
continue
}
selected[itemIndex] = true
selectedCount++
break
}
if selectedCount >= limit {
break
}
}
}
return selected
}调用点:
quantityUpliftSelected := selectMacroQuantityUpliftItems(matchedTargets, activeControllers, targetAlphaMap, upliftItemLimit(experimentParams), ctx.Size)为什么是两趟而不是单趟跳过——这是刻意的风险最小化:
| 方案 | 语义 | 风险 |
|---|---|---|
| 两趟(推荐) | 只重排分配顺序 | 任何输入下选中集合的大小与改动前相同(min(limit, 候选数)),是可证明的最小行为变更 |
| 单趟跳过区内 | 区内物品永不获名额 | 若某目标匹配物品全在区内,它从「拿 5 个名额」变成「拿 0 个」——相对 master 的第二次能力收窄,可能把问题修得更糟 |
退化情形已核对:
exposureBoundary <= 0(ctx.Size未设置)→outside恒真 → pass 0 覆盖全部、pass 1 空转 → 与当前实现逐物品等价limit <= 0→ 提前返回全 false,与原逻辑一致- 两趟处理的物品集合互斥(
outside真假二分)→selected不会重复置位、selectedCount不会重复计数
五、配套测试
sort/ 已有 8 个 *_test.go,但没有 traffic_control_sort_test.go。建议新建,三条确定性单测(无 goroutine / 无时钟 / 无 ctx,PIDController 只需填 task.ControlType):
- 区外优先:25 个物品、曝光窗口 20、目标匹配 0-4 与 20-24、
limit = 5→ 断言名额全给 20-24。此条在当前实现下必然失败,正好是回归守卫。 - 剩余名额回落:候选全在区内 → 断言仍获得名额(证明修复没有减少上提)。
- 边界为 0 时保持旧序:断言退化为原始排名先到先得。
go test ./sort/ -run TestSelectMacroQuantityUpliftItems -v
六、明确不在本次最小改动范围内
- 积分抗饱和:本方案恢复反馈环后,第二节的积分饱和会随之消失(曝光数能涨 → error 收敛)。但若某目标匹配物品全部在曝光区内,则确实无物可提,积分仍会累积到
integralMax——这属于「目标已占满窗口」的另一情形,需独立的抗饱和逻辑,建议另开 issue。 - 按 alpha 大小排序分配(让欠量最严重的目标优先):会改变分配的确定性顺序并增加排序开销,超出最小改动边界。
- 名额是否应按目标独立而非共享:属产品语义决策,需业务方确认。
综上:若第二节的行为是设计意图,请在 PR 描述中补充说明(当前 PR body 为空,这个从「无限」到「最多 5 个」的能力收窄对使用方是不可见的);若不是,第四节的方案可以用约 6 行改动修掉,且不减少任何原本会发生的上提。
No description provided.