先说一个我自己的切身体会:爬虫写完能跑通和数据真敢交给下游用,这两件事之间隔着一条很深的沟。我之前就吃过一次大亏——某批量采集任务跑了十几个小时,入库十万条记录,结果第二天业务方反馈“两万多条价格是空的,还有三万条标题字段是空字符串”。我当时的直觉是网站结构变了,但回头排查才发现是某个页面模板在部分分页上渲染超时,解析器全部空手而归。问题最可怕的地方在于:整个任务是从头到尾成功跑完的,报错一个都没有,数据库写入全部成功。
所以后来我花了很大精力,把“数据采集质量验收(QA)抽样系统”这一套补进了爬虫流水线里。它的作用说人话就是:在整批数据入库前,按统计学方法抽出一部分样本,对完整性、有效性、时效性、唯一性等维度做自动化校验,算出一个可量化的质量分。分数低于阈值就自动熔断,宁可让这批数据“晚到”,也不能让坏数据“先到”。这篇文章我就把这套系统和盘托出,从为什么需要它,到样本量怎么定,再到核心代码、编排回采、以及我实跑大半年踩过的各种坑,全讲一遍。适合正在做规模化采集、或者发现自己数据经常被下游质疑的开发者参考。
1. 先搞清楚一个问题:爬完入库不等于数据合格,质量维度到底怎么定义
1.1 “入库成功”和“数据合格”之间,差了一条质量红线
很多团队对采集任务的评判标准就是“跑完没跑完、入了几万条、有没有报错”。但爬虫场景有一个特殊性:一个页面的采集结果缺失,并不会产生传统意义上的异常。价格没抓到,HTTP状态码照样是200;标题为空,JSON照样能被解析成功。采集器能感知到的“异常”和业务意义上的“缺陷”之间存在巨大的信号盲区。
这也是为什么,质检不能只在写代码阶段嘴上说说,必须在流水线上有一个正式环节,用一套明确定义的规则去扫描数据本身。举一个最简单的例子:采集商品列表,如果 SKU 字段缺失率达到 3%,下游去重、关联、同步都会出问题;如果价格字段一个批次里空了 5%,数据分析出来的均值就完全失真。没有质量红线,你根本不知道这批数据是不是一堆“精致垃圾”。
我把这个环节称为“出厂检验”。它不关心爬虫写了多少行代码、用不用无头浏览器,只关心最终落库的数据长什么样、够不够格进下一环节。这其实就是制造业已经用了很多年的思路:全检成本太高,抽检加统计推断才是工程化的做法。
1.2 五个高频暴露缺陷的检查维度
我根据自己的实践经验,把采集数据的质检收敛成五个维度。没有必要一上来就做几十个规则,先把这五个做好,就能拦住绝大部分问题。
| 维度 | 检查口径 | 典型脏数据样例 |
|---|---|---|
| 完整性 | 必填字段是否缺失,是否为空字符串 | 价格字段为 null,title 为 "",SKU 丢失 |
| 有效性 | 字段类型、格式、取值范围是否符合定义 | 价格写成 "1.5K",日期写成 "2024/1/1" 但下游要求YYYY-MM-DD |
| 时效性 | 抓取时间与数据发布时间是否差距过大 | 商品页显示更新时间是上个月,采集时间却是今天,说明可能拿到缓存页 |
| 唯一性 | URL 或业务主键是否有重复 | 同一个详情页被采集了 3 次,但只是 URL query 参数不同 |
| 一致性 | 关联字段之间是否匹配 | 类目 ID 存在,但对应类目名称却是空;国家编码是 CN,货币却是 USD |
每个维度单独看都不是什么高深概念,但合在一起,就能构造出“数据可信度”的立体评价。比如完整性 99%,有效性 98%,时效性只有 80%,那这批次数据虽然能用来做总量统计,但绝不适合做需要准实时性的推荐召回。这也是 QA 系统真正的价值:它不告诉你“对或错”,它告诉你“这批数据能达到什么使用等级”。
1.3 验收动作放在入库前,还是入库后
我的建议是:入库前抽检、入库后按批次打标签,两个动作都做。入库前的质检相当于代码里的单元测试,不合格直接拒绝写入;入库后的批次标签则是为了后续数据回补和追责方便。
实际操作中,入库前抽检不适合把所有字段都校验一遍,否则耗时太长。更合理的做法是,抽取样本后先做“快速门禁”——比如必填字段检查、重复率检查、格式检查,这些单条记录毫秒级能完成。耗时更重的语义校验、跨字段一致性校验,可以放在入库后的异步任务里跑,发现问题再标记这个批次为“待修复”。这样既能守住下限,又不会拖慢主流程。
2. 抽样方案决定验收下限:样本量计算与分层取样的取舍
2.1 全量校验为什么不可持续
很多新人听到 QA 的第一反应是“那我写个脚本把整批数据全量校验一遍不就行了吗”。如果你只有几千条数据,全量校验确实没问题。但一旦批次规模到了几十万条,全量校验的成本就变得很难看了:要么是重复请求详情页把自家采集器变成半个攻击源,要么是校验的耗时比采集本身还长。
更重要的是,大规模爬虫采集时,很多缺陷是“局部聚集”的。某个分页翻页逻辑坏了,影响的是那一页之后的所有数据;某个分类模板改了 class 名,影响的只是那个分类下的记录。全量校验在这种场景下其实是一种浪费,因为你需要做的不是“把每条数据都查一遍”,而是“以足够置信度判断整批数据能不能用”。
2.2 用置信区间倒推最小抽样量,再落到一张可执行表
抽样量的计算,我习惯用最经典的简单随机抽样公式:
n = Z² × p × (1 - p) / e²
其中 Z 是置信水平对应的分位数,95% 置信度下取 1.96;p 是预估的缺陷率,最保守情况下取 0.5(这时方差最大,算出来的样本量最保险);e 是允许误差,通常取 5%。代入计算:n = 1.96² × 0.5 × 0.5 / 0.05² ≈ 385。也就是说,在不做任何先验假设的情况下,抽 385 条样本就能以 95% 的置信度把整批数据缺陷率估计到 ±5% 以内。
实际工程中我不会每次都套公式,我更常用 AQL(可接受质量水平)的思路做一张简易抽查表:
| 批次规模 N | 建议抽样数 n | 允许不合格数 c |
|---|---|---|
| ≤ 500 | 50 | 0 |
| 501 - 3000 | 125 | 0 |
| 3001 - 10000 | 200 | 2 |
| 10001 - 50000 | 315 | 3 |
| 50000 以上 | 500 | 5 |
这张表不是死标准,但它背后有个很实用的逻辑:批次越大,抽样比例可以越小,但绝对样本数不能太小。一万条数据和五万条数据相比,样本量不可能涨五倍,否则就没法工程化了。
提示:如果你对缺陷率特别敏感,比如下游是金融对账,那建议把 e 从 0.05 收紧到 0.02,n 会涨到 2401 条,成本虽然高不少,但这个代价在那些场景里是值得的。
2.3 分层抽样挡住“幸运样本”:按来源和时间拆权重
纯随机抽样最大的风险是“抽到的都是好数据”。比如一万条数据里只有某个时间段的数据是坏的,占比 5%,纯随机抽样可能恰好没抽到。解决这个问题的标准做法是分层抽样。
我通常按两个维度分层:采集来源(哪个解析器、哪个站点模板)和时间窗口(哪一小时、哪一分页批次)。思路很简单——如果某一层是坏的,它的坏数据必须反映到样本里,而不是被其他层稀释掉。按每层在整体中的占比分配抽样配额,然后每层内部做随机抽样,这就是谢弗(最简单的比例分配)分层抽样。
分层抽样的额外好处是,当某一层质量不合格时,你能精准知道“烂在哪一层”,回采的时候不用把整批重跑一遍。这一点在第四节我会详细讲。
2.4 批次元数据是抽样系统的前提
没有好的批次元数据,抽样器就是无源之水。我要求每条采集记录至少带上这几个字段,它们既是抽样的分层依据,也是回采的定位依据:
| 字段 | 示例 | 作用 |
|---|---|---|
| batch_id | crawl_20240101_0800 | 标识归属批次 |
| source | detail_parser_v3 | 标识采集来源/解析器版本 |
| layer_tag | category:electronics_page:12 | 分层标签,便于定向回采 |
| crawled_at | 2024-01-01T08:00:12+00:00 | 采样时间窗口 |
| track_id | uuid 或自增 id | 样本级唯一标识,用于报告里定位记录 |
我在实际项目里会在爬虫侧把这些字段统一放进记录里,由采集框架统一打点。如果没有这一步,后端的 QA 系统做得再漂亮也接不进去——这属于典型的“前置要求,不然后面全白做”。
3. 核心代码手写:抽样器、校验规则引擎与质量打分报告
写这套代码的时候,我给自己定的目标不是“写一个高大上的框架”,而是让团队里任何一个人都能看懂、能改、能加规则。下面这些代码块是可以直接拼起来跑通的最小闭环。
3.1 抽样器:带配额的分层随机
import random from collections import defaultdict def split_layers(records, layer_specs): """按多个维度分层,返回 {层key: [(索引, 记录)]}""" groups = defaultdict(list) for idx, rec in enumerate(records): key = [] for _, fn in layer_specs: try: key.append(str(fn(rec))) except Exception: key.append("unknown") groups[tuple(key)].append((idx, rec)) return groups def stratified_sample(records, total_n, layer_specs, rng=None): """按每层比例分配配额的分层随机抽样""" rng = rng or random.Random() groups = split_layers(records, layer_specs) groups = {k: v for k, v in groups.items() if v} total = len(records) if total == 0: return [] picked_idx = set() picked = [] # 第一轮:按层占比分配配额 quotas = {} for key, items in groups.items(): share = len(items) / total q = max(1, int(share * total_n)) quotas[key] = min(q, len(items)) # 配额不能超过该层容量 for key, items in groups.items(): q = quotas[key] chosen = items if len(items) <= q else rng.sample(items, q) for idx, rec in chosen: if idx not in picked_idx: picked_idx.add(idx) picked.append(rec) # 第二轮:如果配额总和还不够 total_n,从全量剩余记录里补足 if len(picked) < total_n: rest = [(idx, rec) for idx, rec in enumerate(records) if idx not in picked_idx] rng.shuffle(rest) for idx, rec in rest[: total_n - len(picked)]: picked.append(rec) picked_idx.add(idx) return picked调用方式:
sample_records = stratified_sample( all_records, total_n=200, layer_specs=[ ("source", lambda r: r.get("source", "unknown")), ("hour", lambda r: r.get("crawled_at", "")[:13]), ], )分层字段选择是个关键经验:不要选那种每个记录都不一样的字段,比如 track_id,那样分层就退化成纯随机,没有意义。选“类别、来源、时间桶、页码段”这种能区分风险域的字段才有价值。
3.2 校验规则引擎:把业务要求写成可解释规则
规则引擎我不用复杂的 DSL,直接用一个字典列表描述规则,这样新增规则时不会动到引擎代码。毕竟 QA 系统最大的维护成本在未来新增需求,而不是规则解析。
import re from datetime import datetime VALIDATION_RULES = [ {"name": "sku_present", "kind": "required", "field": "sku"}, {"name": "title_present", "kind": "required", "field": "title"}, {"name": "price_type", "kind": "type", "field": "price", "field_type": "float"}, {"name": "price_range", "kind": "range", "field": "price", "min": 0.01, "max": 1000000}, {"name": "date_format", "kind": "regex", "field": "publish_date", "pattern": r"^\d{4}-\d{2}-\d{2}$"}, {"name": "url_scheme", "kind": "regex", "field": "url", "pattern": r"^https?://"}, {"name": "crawl_fresh", "kind": "freshness", "field": "crawled_at", "max_minutes": 1440}, ]对应的校验函数:
def _parse_time(value): """容忍 ISO 字符串里的 Z 后缀和时区缺失""" text = str(value).strip().replace("Z", "+00:00") return datetime.fromisoformat(text) def check_one(rule, rec): field = rule["field"] kind = rule["kind"] value = rec.get(field) if kind == "required": return value not in (None, "") # 注意:None 和空字符串都算缺失 if kind == "type": if value in (None, ""): return True # 缺失交给 required 规则判定,这里不重复计 try: if rule["field_type"] == "float": float(value) elif rule["field_type"] == "int": int(value) return True except (ValueError, TypeError): return False if kind == "regex": if value in (None, ""): return True return bool(re.match(rule["pattern"], str(value))) if kind == "range": if value in (None, ""): return True try: num = float(value) return rule["min"] <= num <= rule["max"] except (ValueError, TypeError): return False if kind == "freshness": if value in (None, ""): return False try: crawl_time = _parse_time(value) age_minutes = (datetime.now(crawl_time.tzinfo) - crawl_time).total_seconds() / 60 return age_minutes <= rule.get("max_minutes", 60) except (ValueError, TypeError): return False return True有一点我要特别提醒:空值字段在非 required 规则里不要再判一次失败。否则同一个 null 值会被完整性规则打一次分、被格式规则又打一次分,双倍扣分会导致质量分失真。这也是很多 QA 系统评分不准的常见原因。
3.3 时效性与唯一性:两个容易漏的专项
唯一性检查我单独拎出来写,因为它不适合放在单记录规则里,必须在样本集合层面做。而且这里有个细节:URL 去重之前要先做规范化,否则?from=feed和?from=search这类跟踪参数会导致同一个详情页被重复计数。
from urllib.parse import urlparse, parse_qsl, urlencode from collections import Counter def cannonical_url(url): parsed = urlparse(url) keep_params = [(k, v) for k, v in parse_qsl(parsed.query) if k not in ("from", "spm", "utm_source", "utm_medium")] return parsed._replace(query=urlencode(keep_params)).geturl() def check_uniqueness(rule, sample_records): field = rule["field"] seen = Counter() for rec in sample_records: raw = rec.get(field) if raw is None: continue if rule.get("normalize") == "url": raw = cannonical_url(str(raw)) seen[raw] += 1 dup_items = [(k, v) for k, v in seen.items() if v > 1] dup_count = sum(v - 1 for _, v in dup_items) return dup_count时效性检查前面已经写在规则引擎里了。这个检查要真的有意义,前提是采集的时候带上了服务器响应时间或页面自带的时间戳。我一般会在解析器里抓取两个时间:一个是采集系统打点时间,一个是业务字段里的更新时间。两者相差太大,说明可能拿到了缓存页或页面本身失效。
3.4 质量打分与报告落地
质量分我用加权通过率合成。权重建议按这个表格配置,你也可以按业务微调:
| 规则 | 默认权重 |
|---|---|
| sku_present | 0.2 |
| title_present | 0.1 |
| price_type | 0.2 |
| price_range | 0.1 |
| date_format | 0.1 |
| url_scheme | 0.1 |
| crawl_fresh | 0.05 |
| uniqueness | 0.15 |
评分和报告生成函数:
def evaluate_batch(sample_records, rules): total = len(sample_records) if total == 0: return {"total": 0, "quality_score": 0.0, "rules": {}} report = {"total": total, "rules": {}} seen_track_ids = set() for rule in rules: name = rule["name"] if rule["kind"] == "uniqueness": dup_count = check_uniqueness(rule, sample_records) report["rules"][name] = { "kind": rule["kind"], "field": rule["field"], "dup_count": dup_count, "pass_rate": round(1 - dup_count / total, 4), } continue failed_tracks = [] for rec in sample_records: if not check_one(rule, rec): failed_tracks.append(rec.get("_track_id")) report["rules"][name] = { "kind": rule["kind"], "field": rule["field"], "failed": failed_tracks[:100], "failed_count": len(failed_tracks), "pass_rate": round(1 - len(failed_tracks) / total, 4), } weights = { "sku_present": 0.2, "title_present": 0.1, "price_type": 0.2, "price_range": 0.1, "date_format": 0.1, "url_scheme": 0.1, "crawl_fresh": 0.05, "uniqueness": 0.15, } score = sum( report["rules"][name]["pass_rate"] * weights.get(name, 0) for name in report["rules"] ) report["quality_score"] = round(score, 4) return report输出报告我会直接落成一个 JSON 文件,并且同步写进结果表。为什么不用打印日志?因为下游自动化流程要解析 JSON,日志文本可读但不适合程序消费。报告里带上 failed 的 track_id,这样回采时可以直接定位到具体记录。
3.5 模拟一批数据跑一遍完整验收
为了验证这套逻辑,我做了一个带缺陷率的模拟批次,里面故意生成 3% 的空标题或空价格,然后抽样 200 条跑验收:
def make_mock_batch(n=3000, defect_rate=0.03): rng = random.Random(42) records = [] sources = ["detail_parser_v3", "detail_parser_v4", "list_parser_v2"] for i in range(n): source = sources[i % 3] title = f"商品-{i}" price = 10 + i * 0.5 if rng.random() < defect_rate: if rng.random() < 0.5: price = None else: title = "" records.append({ "_track_id": f"mock_{i:06d}", "sku": f"SKU-{i:06d}", "title": title, "price": price, "publish_date": "2024-01-01", "url": f"https://example.com/detail/{i}", "crawled_at": "2024-01-01T08:00:00+00:00", "source": source, }) return records batch = make_mock_batch() sample = stratified_sample( batch, total_n=200, layer_specs=[ ("source", lambda r: r.get("source", "unknown")), ("hour", lambda r: r.get("crawled_at", "")[:13]), ], ) report = evaluate_batch(sample, VALIDATION_RULES) print(report["quality_score"], report["rules"]["price_type"]["pass_rate"])我实际跑出来的质量分大概在 0.96~0.97 左右。缺陷率 3% 时,200 个样本里一定会看到一些空值,但加权平均后被 sku、url 这些高通过率规则托住,总分还比较好看。这里也引出一个结论:质量分应该跟着业务风险走。如果你最在意价格,那 price_type 权重就应该拉高;如果你最在意去重,uniqueness 权重就应该拉高。权重表就是你对业务优先级的声明。
4. 把验收接回采集流程:不合格批次自动熔断与定向回采
4.1 验收结果先落库,再用状态机调度
QA 不只是“产出分数”,它的价值在“根据分数做决策”。我在流程里为每个采集批次定义了一个状态机:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| PENDING | 采集完成,待抽检 | 采集任务写入批次记录 |
| SAMPLING | 正在抽样 | 调度器启动抽样任务 |
| VERIFYING | 正在校验 | 抽样完成后启动规则校验 |
| PASSED | 验收通过 | 质量分 ≥ 阈值 |
| NEED_REVIEW | 待人工复核 | 质量分在“预警区间” |
| FAILED | 验收不合格 | 质量分低于阈值,需要回采 |
| REPULLED | 已回采完成,待二次验收 | 定向回采任务执行完毕 |
批次状态我建议放到数据库或 Redis 里面,而不是只靠文件名标识。因为后续回采、告警、统计趋势都需要按状态查询。
调度器侧只保留一个决策函数,输入是 QA 报告,输出是动作:
QA_THRESHOLD = 0.95 # 低于则批次不合格 REVIEW_THRESHOLD = 0.90 # 低于则直接判 FAILED def decide_action(report): score = report.get("quality_score", 0) if score >= QA_THRESHOLD: return "pass" if score >= REVIEW_THRESHOLD: return "need_review" return "repull"这套决策逻辑简单、透明,团队成员可以快速对齐:“低于 0.95 不发布,低于 0.90 必须回采”。比看着报表人肉判断靠谱得多。
4.2 定向回采而不是全量重跑
不合格之后最忌讳的就是“一键重跑整个批”。全量重跑不仅慢,而且可能撞上同一个坏模板,重新产出同一种坏数据。正确做法是先把问题定位到层,再按层回采。这一步依赖前面抽样器里的分层能力:某个 layer_tag 的通过率低,就只回采该 layer_tag 下对应时间段的数据。
实现上,我会在决策函数里解析报告中的“薄弱维度”,再把这些维度映射到回采条件:
def build_repull_conditions(report): conditions = {} for rule_name, detail in report.get("rules", {}).items(): if detail["pass_rate"] < 0.95: conditions[rule_name] = detail.get("field", rule_name) return conditions比如 report 里 price_type 通过率只有 0.8,回采任务就仅针对“该批次中 price 字段校验不通过的 source/layer_tag”发起。在采集方看来,就是多了一个“定向补充采集任务”,把坏的、缺的记录补回来,而不是把十万条数据再来一遍。
4.3 告警分级与人工复核
告警我分两级。第一级是“预警”,分数在 0.90~0.95 之间,不发紧急通知,只在 QA 看板上置黄;第二级是“熔断”,分数低于 0.90,直接触发机器人消息,把批次号、质量分、失败规则、抽样样本里前 20 条失败 track_id 一起发到工作群。
人工复核的场景我建议保留,不要一上来就追求全自动。原因很简单:规则引擎永远会漏掉业务语义。比如某个商品确实下架了,价格字段为空是正确业务结果,不是采集缺陷。这种时候需要人看一眼报告再决定是否直接放行。我见过不少团队因为过度自动化把 QA 系统干成了“只会报警的累赘”,最后大家都不看告警了。所以自动判断之外,一定要留一个人工出口。
5. 实跑大半年踩过的坑:误报、漂移与抽样偏差
5.1 空字符串不等于缺失:异步渲染带来的误报链
最典型的误报场景发生在采集 JS 渲染页面。某次 QA 系统突然大面积告警,title 字段必填检查 pass_rate 只有 0.4。我第一反应是网站改版了,结果手工打开页面一看,标题清清楚楚。再抓 HTML 源码,发现标题压根不在初始 HTML 里,而是异步接口返回后由 JS 渲染进去的。也就是说,采集器拿到的是“空壳页”,标题字段为空是采集端逻辑的问题,但 QA 只能看到“数据空了”。
排查链路是这样的:先看 HTTP 状态码,全部 200,排除网络重试;再看解析器版本,发现历史版本一直没问题;最后把单个页面的源码拉下来比对,才发现真实数据来源是一个 XHR 接口。修复方案有两个方向:一是采集端改为直接请求那个数据接口,二是 QA 规则里给动态字段加“延迟重试后再校验”的逻辑。
我后来在规则引擎里增加了一个标签机制:字段配置{"field": "title", "dynamic": true},动态字段首次校验失败不直接判死,触发一次“二次数值补采”,二次拉取仍为空才判失败。这样既保留了对异步渲染的兼容,也没有放松最终数据质量的要求。
5.2 自动可修复的缺陷,不应该被记成丢分
另一类容易误判的是“可自愈缺陷”。最典型的是 http 和 https 混用。某个采集源页面里的链接有一小部分是以http://开头,但我自定义规则要求 URL 必须是https://。结果这些链接实际上会被服务端 301 跳转,浏览器也能正常打开,用户根本感知不到问题。如果 QA 把它判成缺陷,就会无谓地触发回采。
我学到的经验是:规则要区分“不可接受缺陷”和“可自动修复的偏差”。价格字段写成1.5K,这是可修复的,可以在采集清洗阶段统一转成 1500;URL 用 http,可以在入库时统一升级为 https。可修复项应该在采集清洗层解决,而不是留给 QA 去判失败。当然,如果清洗层已经修好了,QA 校验的数据就应该以“清洗后结果”为准,避免用原始数据打分数。
5.3 规则漂移和采集缺陷怎么区分
规则漂移是本系统运行半年后我遇到的最大麻烦。网站前端是持续迭代的,某个字段从sku改名成product_id,或者某个日期格式从2024-01-01改成2024/01/01。这时候 QA 会看到某个字段大面积失败,而且症状和“采集逻辑坏了”几乎一模一样。如果你不假思索直接触发回采,回采大概率还是会失败,白白消耗资源。
我的处理办法是给规则上“版本号”。每个解析器版本和每套校验规则版本绑定,页面模板升级时,先人工确认新结构,再发布新版本的校验规则。这样 QA 告警出来时,第一件事不是回采,而是对照“当前解析器版本 vs 当前规则版本”是否匹配。不匹配时,自动把告警降级为“规则待更新”,而不是“数据不合格”。
另外我也会统计同一个规则的历史通过率。如果一个规则过去 30 天平均通过率是 99%,某天突然变成 60%,且失败记录的字段值并不是空而是“另一种格式”,那大概率是规则过期,而不是采集器坏了。这个“横向对比历史基线”的习惯,比任何单批次阈值都有用。
5.4 抽样恰好命中“异常页”时的二次抽检策略
抽样天然有随机性,所以还有一类情况是“运气不好”:样本里恰好命中了一批重试失败的页面,导致通过率被拉低。比如整批数据 97% 都是好的,但抽样时抽到了 30 个首次请求失败的页面,分数立刻跌破阈值。
针对这个问题,我在流程里加了一个“二次抽检”机制:首次验收不合格且质量分不低于 0.85 时,不直接判 FAILED,而是重新分两个新样本再跑一次验收,每个新样本量至少 100 条。如果两次复验结果都低于阈值,才判不合格。这样做一遍,虽然多花一点时间,但能显著减少“样本偏差导致的误熔断”。我自己经历过三次误熔断之后,就把这个“偶数次复验”写成了默认策略。
最后说一个我自己的使用习惯
QA 系统跑稳之后,除了做当日批次验收,我还把每次报告按批次号、日期、来源积累成了一个“质量时序表”。时间长了以后,你会发现很多问题是慢慢变坏的,而不是突然崩掉的。比如某个解析器的通过率从 0.985 跌到 0.972,再跌到 0.955,单看任何一个批次都还不到熔断线,但趋势线已经很吓人了。我就是靠这个习惯,提前几天发现过两次页面模板的边缘改版,避免了两次大规模脏数据入库。所以如果你准备上这套系统,我建议从一开始就把“历史报告留档”这件事设计进去,别等跑了一百个批次才开始后悔。