第 2 卷
复制代码
| 1 | //对每条历史流量回放(复用ch18引擎),统计覆盖率/精确率/误伤率* |
|---|---|
| 2 | *lettotalBad =0 //样本里该拦(异常)的总数* |
| 3 | *lettotalGood =0 //样本里正常的总数* |
| 4 | *letcaughtBad =0 //异常、且被候选命中→拦对的* |
| 5 | *letcaughtGood =0 //正常、却被候选命中→误伤的* |
| 6 | *for(const{ req, isBad }oftraffic) {* |
| 7 | * // isBad:这条请求是不是异常;matched:这条候选命没命中它* |
| 8 | * constmatched =ruleMatches(compiled,sanitizeRequest(req)) // compiled:com |
| 9 | * if(isBad) {* |
| 10 | * totalBad++* |
| 11 | * if(matched) caughtBad++* |
| 12 | * }else{* |
| 13 | * totalGood++* |
| 14 | * if(matched) caughtGood++* |
| 15 | * }* |
| 16 | *}* |
| 17 | *constcoverage = totalBad ===0?0: caughtBad / totalBad //覆盖率=拦住的异常 |
| 18 | *constprecision = caughtBad + caughtGood ===0?0: caughtBad / (caughtBad + ca |
| 19 | *constfpr = totalGood ===0?0: caughtGood / totalGood //误伤率=误伤的正常/ |
例子中这四个计数器的值是 totalBad=9、totalGood=10、caughtBad=2、 caughtGood=0,上面三个式子的分子分⺟,就是它们两两相除的结果。
阴影测试:不影响真实环境
从历史回放直接跳到真实拦截,仍然有些冒险:历史样本是固定的,可能没覆盖真实流量的分布,直接上线就要当场承担误伤代价。有一种经典的过渡式阴影测试(shadow),把规则挂
上真实流量跑,但结果不影响真实环境:我们这一讲是“只记录、不拦截”,也有的做法是复制一份镜像环境来真正拦截。
因为不碰真实环境,所以不会误伤真实请求。它要回答的问题和模拟测试是同一个:拦得好不好(覆盖 / 精确 / 误伤),只是把“历史样本”换成“活的真实流量”,看这三个数在真实流量
上还成不成立。
么)、正常样本数(误伤率分⺟)都照常积累。阴影测试结束时,手里已经有一套“这条规则在真实流量上会怎么表现”的信号,作为下一步进灰度的依据。
灰度测试:小范围真实拦截
阴影测试确认“它会拦什么”之后,才进入灰度测试(canary),小范围真实拦截,验证误伤率。灰度期有两条自动退路:
自动回退:误伤率超阈值,规则就回退、退出拦截;正常样本不足(minGoodTotal,默认 20)时暂不回退,防止仅凭一条噪声样本就把规则误判为回退。回退不可自动恢复,想
重新启用只能由人复核后解除(reinstate)。
老化:连续无命中达到阈值就退场,避免规则库膨胀,看的是连续无命中,不是累计命中,否则命中过一次就永远不会老化。
命中达标(本讲配置为命中 3 次)就转 active(正式全量拦截);自主级别 2(第 19 讲)
且确认通过的候选,直接从这里起步。
这两条退路写在 transition 里,每次请求到来就自动判一次(精简自源码,observe 先更新命中 / 误伤 / 漏拦 / 正常样本这些信号):
复制代码 1private transition(): void {
| 2 | if(isTerminal(this._stage))return | //终态不再转移 |
|---|---|---|
| 3 | //回退与转正式只在真实拦截的灰度/全量期判定;阴影测试只观察,不因模拟误伤就回退 | |
| 4 | if(this._stage ==='canary'||this._stage ==='active') { | |
| 5 | constrate=this._signal.goodTotal ===0?0:this._signal.falsePositives / | |
| 6 | constsufficient=this._stage ==='active'||this._signal.goodTotal >= (thi | |
| 7 | if(rate >this.cfg.fpThreshold && sufficient) { | //误伤率超阈值 |
| 8 | this.setStage('rolled_back','误伤率超阈值') | |
| 9 | return | |
| 10 | } | |
| 11 | if(this._stage ==='canary'&&this._signal.hits >= (this.cfg.promoteHits ?? | |
| 12 | this.setStage('active','灰度达标') |
18}
阶段变更一律走转移表(loop.ts 的 TRANSITIONS):能从哪走到哪是显式写出来的,非法转移直接抛错,而不是静默生效:
复制代码
| 1 | constTRANSITIONS:Record<stage,readonlystage[]> = { |
|---|---|
| 2 | proposed: ['shadow','canary','aged_out'], |
| 3 | shadow: ['canary','aged_out'], |
| 4 | canary: ['active','rolled_back','aged_out'], |
| 5 | active: ['rolled_back','aged_out'], |
| 6 | rolled_back: [], //终态:不可复活 |
| 7 | aged_out: [], //终态 |
| 8 | }</stage,readonlystage[]> |
规则怎样持续进化:闭环
自动回退兜住了“误伤”(拦了正常请求)这一头,误伤率超阈值就当场回退止损;而“漏拦”
(该拦的没拦住)这一头却只被记下,还没接回进化循环,那些漏掉的异常请求不会自动进入下一轮提案。闭环就是把这两头都接上,这一节从漏拦这头切入:
长出候选 → 评测 → 确认 → 上线,上线后撞上真实流量、漏掉新的异常请求;漏掉的喂回证据库 → 下一轮提案,长出覆盖它的候选,再评测、再确认、再上线,又出现新的漏
拦……回到起点,一轮轮转下去。