信源到质量校验:可交付的数据采集清洗系统设计

简介:围绕数据清洗与采集流程的论文文档,面向大数据入门学习者、课程论文写作者及需要搭建采集方案的技术人员,重点梳理数据采集在数据分析前端的作用、网络爬虫的分类与适用场景,以及采集系统从信源管理、任务调度、缓存队列到存储与监控的整体架构。文档还展开大数据采集技术架构中的数据源、批处理与流处理工具、分布式存储及分析系统等环节,并讨论数据质量校验、异常排查与架构优化思路,可作为课程作业、方案参考和知识梳理材料。内容以论文式结构组织,先讲概念与工具,再拆解采集前置、采集层、存储层与辅助监控模块,便于按章节复习和引用。包内共1个docx文件,约313KB,篇幅集中、结构完整。已有66人学习下载,适合希望快速建立数据采集与清洗知识框架、了解工程化采集系统组成的读者。

1. 从一份课程设计到一套能交付的采集清洗方案:先想清楚要解决什么

数据清洗与采集这个题目,最容易被做成“能跑但说不清”的样子:一个脚本抓到几万条数据存成 CSV,一问信源怎么管、重复数据怎么判、漏采了怎么发现,就答不上来。真正要交付的不是脚本,而是从信源登记、任务调度、网页下载、结构化解析、传输落库到质量校验的一条完整链路,外加能证明它在跑的监控口径。下面按一套可落地的采集清洗系统展开:四层架构怎么划,Redis 和 Kafka 各自站在哪个位置,标题与发布时间怎么校验,答辩时拿什么证明数据真的干净。适合正在做数据类毕设、课设的人照着搭结构,也适合要把采集方案写成文档交付的从业者参考。

2. 需求与技术选型:采集前置、队列与消息中间件为什么要这样组合

一套采集系统最先要定的不是用什么语言写爬虫,而是“谁来决定采什么”。很多方案写到选型就变成工具罗列:Redis、Kafka、HBase、Spark、ES 全写上,看着齐全,但每个组件对应哪条约束说不清。我的习惯是从需求反推:采集源会持续增删、采集频率要能在线调、同一条任务不能被两台采集器同时处理、解析出来的数据要先验后存。这四条需求各自对应一个组件,选型就立住了。

2.1 采集对象与信源模型怎么定

采集源不能笼统写“网页”,要拆成可管理的实体。按常见做法,信源分成网站、栏目、关键词、公众号、博主以及其他来源(电子期刊、客户端内容等)六类。网站和栏目是“定点采集”,靠入口 URL 加翻页规则拿到列表;关键词是“搜索式采集”,本质是把关键词提交给搜索引擎结果页再解析;公众号和博主属于“主体跟踪”,盯的是一个账号的更新流,抓取节奏和页面结构都和站点完全不同。

这六类如果混在一张表里只存一个 URL,运维人员后续没法做增删改查。合格的建模是把“信源”和“采集任务”分开:信源回答“从哪里采”,任务回答“怎么采、多久采一次、失败几次停”。一个信源可以派生多条任务,比如同一个栏目,一条任务采列表页全量,另一条只采带特定关键词的详情页。

字段类型作用常见错误
source_typeTINYINT区分六类信源,决定调度策略只存“网站”,公众号混在里面
entry_urlVARCHAR(512)采集入口存了带跟踪参数的 URL,去重失效
parse_templateVARCHAR(128)绑定解析模板标识模板写死在代码里,改版只能发版
url_patternVARCHAR(512)列表页到详情页的正则正则过宽,把导航页也当详情页
modeTINYINT批量 / 流式全站统一批量,时效要求高的源跟不上
statusTINYINT启用 / 停用 / 异常异常源仍在跑,白白消耗采集器

解析模板用标识而不是写死 XPath,是这套模型里最关键的一处设计。站点改版是常态,模板版本化之后,改版只需要新增一个模板版本、把信源指向新版本,跑一轮对比新旧版本的字段抽取率,确认无误再全量切换,出问题能一键回滚到上一版。这一条在后面对“解析失败”的排查里会反复用到。

2.2 批处理与流处理的分界线

批处理适合非实时、批量拉取,流处理适合实时性要求高的源,这个划分原则不难,难的是具体到某个信源该走哪条路。我一般按两个维度定:信源本身的更新频率,以及业务对数据到达时间的容忍度。日报类站点一天更新十几条,业务侧 T+1 能用,走定时批量完全够;公众号和突发事件类信源,业务要的是分钟级发现,就必须常驻采集器轮询或者接推送。

信源类型更新频率时效要求采集模式
日报 / 周报类网站低T+1批量,按 cron 定时
新闻栏目列表高分钟级流式,常驻轮询
关键词搜索中小时级批量,按关键词分组轮转
公众号 / 博主中分钟级流式,按账号轮询
电子期刊低天级批量,人工触发补采

两种模式共存于一条链路里,调度上的处理方式不一样:批量任务由调度中心按 cron 生成实例,流式任务由常驻采集器自己维持循环,但两类任务写进同一个 Redis 队列,下游消费逻辑完全一致。这样做的好处是扩展时不用改两套代码,坏处是队列里的任务优先级要显式设计,否则流式任务会把批量任务饿死。

提示:批量和流式不要用两套数据结构。队列元素统一成任务 JSON,里面带 mode 字段和优先级,调度端按优先级取,消费端不关心它是哪种模式生成的任务。

2.3 技术栈选型的四条硬约束

选型要能回答“为什么非得是它”。原文给的四条约束是可靠性、可用性、可扩展性、易用可维护,落到具体组件上要能说清替代成本。

Redis 承担的是任务队列与过程状态缓存。采集任务的状态变化极其频繁——入队、领取、下载中、解析中、成功、失败重试,一台采集器每分钟可能更新几百次,这些写操作如果压到关系库上,锁竞争会直接拖垮调度。Redis 的 List 和 SET 提供原子操作,抢任务、更新状态都是一次命令,这是它不可替代的地方。

Kafka 承担的是削峰和解耦。采集器是突发性的,一个栏目翻页可能瞬间推出几千条;下游 Spark 建索引、写 HBase 是批量的。中间加一层消息中间件,采集端不必等下游处理完,下游挂了也不丢数据,重启后从位点继续消费。同时 ES 和 HBase 是两个下游,一条消息两边消费,不用在采集端写两遍推送。

HBase 存完整原文,ES 建标题、时间、正文的索引供业务查询,Spark 负责批量清洗和索引构建。这三个组件的分工不是随便定的:ES 不适合存大段原文,存储成本高且更新代价大;HBase 按 rowkey 查询快,但不支持全文检索;Spark 站在中间做转换,正好把两者的短板补上。

SpringBoot 微服务接口是统一入库入口。所有采集器解析完不直接写库,而是调同一个接口,接口里做质量校验再投 Kafka。这一步多了一次网络开销,换来的是校验规则只有一份,改规则不用改几十个采集器。

3. 总体架构设计:四层结构与一次采集任务的完整流转

架构图在很多方案里画得很漂亮,但落到文字就只剩“采集层、存储层、应用层”这种空壳。真正要交代清楚的是:一条数据从进入系统到能被查询,中间经过哪些组件、每个组件失败时会发生什么、状态存在哪里。下面按四层拆开讲,每一层都给到能直接照着建的东西。

3.1 四层结构与一次采集任务的完整数据流

采集前置是信源管理系统,运维在这里登记网站、栏目、关键词、公众号、博主,配置解析模板、采集周期、启停状态,并实时看每个信源的健康状态。它不参与实际抓取,只负责“配置下发”和“状态回显”。

数据采集层是干活的部分,由 Redis 队列、调度中心、若干采集器组成。调度中心按信源配置生成任务实例写入队列,采集器从队列里抢任务,抢到之后做网页下载、结构化解析、字段抽取,然后把结构化结果推给存储层。

数据存储层分两段:数据传输段由 SpringBoot 微服务接口承担,做质量校验、打标签、投递 Kafka;大数据平台段由 Kafka、Spark、HBase、ES 组成,Spark 消费消息,把标题、时间、正文建成 ES 索引,把完整内容写进 HBase。

辅助监控系统横向切过前三层,包含信源监控、采集监控、服务器监控、数据质量校验四个子系统。

一次完整流转是这样的:

  1. 调度中心读取信源配置,判断是否到达采集时间,生成任务写入 Redis 队列。
  2. 采集器轮询队列,用原子操作领取任务并加锁,锁的过期时间大于单任务最长耗时。
  3. 采集器下载页面,交给对应版本的解析模板抽取标题、发布时间、正文、来源 URL。
  4. 结构化结果调用 SpringBoot 接口,接口执行质量校验,不通过则落本地待复核,通过则投 Kafka。
  5. Spark 消费 Kafka,做二次清洗和打标签,写 ES 索引与 HBase 明细。
  6. 监控系统从各环节采集指标,信源异常、任务积压、字段缺失率超标时告警。

这套流转里有一处容易被忽略:第 4 步校验不通过的数据不能直接丢。丢了就没人知道是解析模板坏了还是源站改版了,事后排查只能靠猜。落到本地待复核文件并计数,是这个方案里成本最低的一层保障。

3.2 采集前置:信源配置表与状态位设计

配置表是整个系统的源头,字段设计直接决定后面能不能做自动化的健康判断。下面这张表是在常见做法基础上补全的版本,可以直接建。

CREATE TABLE t_source ( id BIGINT PRIMARY KEY AUTO_INCREMENT, source_type TINYINT NOT NULL COMMENT '1网站 2栏目 3关键词 4公众号 5博主 6其他', source_name VARCHAR(128) NOT NULL COMMENT '信源名称,运维可读', entry_url VARCHAR(512) NOT NULL COMMENT '入口地址,入库前必须归一化', charset VARCHAR(16) DEFAULT 'utf-8' COMMENT '页面编码,缺失时按响应头兜底', parse_template VARCHAR(128) NOT NULL COMMENT '解析模板标识+版本,如 news_v3', url_pattern VARCHAR(512) DEFAULT NULL COMMENT '列表页到详情页的正则', collect_cron VARCHAR(64) DEFAULT NULL COMMENT '批量模式的采集周期', mode TINYINT NOT NULL DEFAULT 1 COMMENT '1批量 2流式', status TINYINT NOT NULL DEFAULT 1 COMMENT '0停用 1启用 2异常待人工确认', fail_count INT NOT NULL DEFAULT 0 COMMENT '连续失败次数,超阈值置为异常', priority TINYINT NOT NULL DEFAULT 5 COMMENT '1最高,数字越大优先级越低', last_collect_time DATETIME DEFAULT NULL COMMENT '最后一次成功采集时间', update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_mode (status, mode) ) COMMENT='采集信源配置表';

几个字段值得单独说。fail_count是连续失败计数,成功一次清零,超过阈值把status置为 2,调度中心不再给它派任务,同时触发告警。这样做避免了坏源反复重试拖慢整体。priority让调度中心在队列积压时优先派高优先级任务,比如突发热点监控的源优先级给 1,历史期刊补采给 9。last_collect_time只记录成功时间,监控端用它算“该采而未采”的间隔,比单纯看任务失败数更能发现漏采。

entry_url入库前必须归一化——去掉跟踪参数、统一协议与末尾斜杠、统一大小写,否则同一个站点被登记两次,后面所有去重都会失效。这一步我一般放在接口层做,而不是让运维手工保证。

3.3 采集层:Redis 队列与任务唯一性的实现

“同一任务同一时间只能被一个采集器处理”这条约束,靠数据库行锁也能做,但代价太大。Redis 的SET NX EX是原子操作,天然适合这个场景。

import redis import uuid r = redis.Redis(host="127.0.0.1", port=6379, db=0, decode_responses=True) LOCK_TTL = 300 # 秒,必须大于单任务最长处理耗时,否则锁提前释放会重复采集 RETRY_TIMES = 3 # 单任务最大重试次数,超过转入失败队列 def acquire_task(task_id: str, worker_id: str) -> bool: """抢占任务锁,成功返回 True。worker_id 用主机名+进程号,便于排查是谁在跑""" key = f"collect:lock:{task_id}" # nx=True 表示键不存在才写入,ex 保证采集器宕机后锁能自动释放 return bool(r.set(key, worker_id, nx=True, ex=LOCK_TTL)) def release_task(task_id: str, worker_id: str) -> None: """释放锁,只允许持有者删除,防止误删其他采集器的锁""" key = f"collect:lock:{task_id}" script = """ if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end """ r.eval(script, 1, key, worker_id) def pop_task(queue: str = "collect:queue") -> str | None: """从队列取任务,LPOP 本身是原子操作,多采集器并发不会取到同一条""" return r.lpop(queue)

这段代码里有三处是踩过坑之后才加上去的。LOCK_TTL取 300 秒而不是 60 秒,是因为详情页下载加重试可能超过一分钟,锁提前过期会导致两个采集器同时处理同一条任务,表现为重复入库。释放锁用 Lua 脚本做比较再删除,是因为采集器处理超时后锁可能已经被别人持有,直接DEL会误删。worker_id带进程号,是为了在监控里能定位到具体是哪台机器在跑,只写主机名时同一台机器起多进程就分不清了。

任务的失败处理不能只靠锁过期。失败队列单独用一个 List,元素里带失败原因和重试次数,重试超过RETRY_TIMES转入死信队列,由运维在信源管理界面手工处理。这样“失败三次的源”不会无限循环,也不会被静默丢弃。

3.4 存储层:微服务接口、Kafka 与 Spark 的落库路径

采集器解析完不直接写库,统一调接口。接口里做两件事:质量校验和投递。

@PostMapping("/collect/push") public Result push(@RequestBody @Valid ArticleDTO dto) { // 校验标题、发布时间、正文的解析准确度,不合格数据不进入 Kafka QualityReport report = qualityChecker.check(dto); if (!report.isPass()) { // 落到待复核文件并计数,便于反查是模板问题还是源站改版 reviewStore.save(dto, report.reason()); return Result.fail(report.reason()); } // 以 sourceId 作为分区键,保证同一信源的数据落在同一分区,方便下游按源聚合 kafkaTemplate.send("collect-article", dto.getSourceId(), JSON.toJSONString(dto)); return Result.ok(); }

sourceId作分区键是有意为之。Kafka 只保证分区内有序,用信源 ID 做键,同一信源的多次采集结果在消费端是有序的,后续判断“这条是不是比库里那条新”时不会因为乱序出错。如果换成随机分区,同一篇文章的两个版本可能被乱序消费,ES 里最终留下的是旧版本。

Spark 侧的消费逻辑分两条支线:一条把标题、发布时间、正文、来源、标签写成 ES 文档,一条把完整结构化记录写 HBase。ES 的_id用 URL 归一化后的哈希,这样重复消费时是覆盖而不是新增,天然幂等。HBase 的 rowkey 用“信源 ID + 发布时间倒序 + URL 哈希”,前缀设计让按信源和时间范围扫描变成顺序读,比全表扫快一个量级。

4. 关键模块实现:从页面下载到干净数据的完整链路

架构定完,真正决定数据能不能用的是解析和清洗这两段。这两段的代码量不大,但规则密度高,写得太松会漏脏数据,写得太紧会把正文整段干掉。下面按下载、抽取、清洗、校验四步展开,每步给出可复现的实现和参数取值的依据。

4.1 下载器与解析器的职责切分

下载器只干一件事:把页面内容拿回来,并记录这次请求的元信息(状态码、耗时、最终 URL、响应大小)。它不关心页面里有什么。解析器只干一件事:拿 HTML 和解析模板,抽字段。两者之间用一个中间结构传递,下载器失败不影响解析器逻辑,解析器改版不影响下载策略。

下载器要有三样东西:请求间隔控制、失败重试、超时。请求间隔按域名维护,同一个域名两次请求之间至少间隔固定秒数,这个值存在 Redis 里,多个采集器共享,避免十个采集器同时打一个站把对方打挂、自己也被限流。重试采用指数退避,第一次失败等 2 秒,第二次等 8 秒,第三次直接进失败队列,不再无限重试。超时按连接超时和读取超时分开设,连接超时短一些,读取超时长一些,因为大页面的正文可能传得慢。

解析模板的版本化在这一层体现得最明显。模板存成一个配置结构:列表页用什么规则取详情链接、详情页用什么规则取标题、时间、正文、作者。规则本身可以是 XPath、CSS 选择器或正则,但对外统一成模板标识。切换版本时新旧模板并行跑一批样本,比对字段抽取率,抽取率没下降才全量切。

4.2 三要素抽取:标题、发布时间、正文

三个字段里最容易出错的是发布时间,因为它有十几种写法:标准时间戳、2024-03-05 10:30、2024年3月5日、3小时前、昨天 14:20。正文抽取的难点在于去噪,很多页面的正文容器里混着推荐阅读、版权声明、扫码引导。

import re from datetime import datetime, timedelta TITLE_CLEAN = re.compile(r"[\s\u3000]+") REL_TIME = re.compile(r"(\d+)\s*(分钟|小时|天)前") def extract_title(raw: str) -> str: """标题去多余空白,长度超过 100 字视为抽错(多半抽到了整段摘要)""" title = TITLE_CLEAN.sub(" ", raw or "").strip() return title if 0 < len(title) <= 100 else "" def normalize_time(raw: str, collect_time: datetime) -> tuple[str, int]: """ 返回 (标准时间字符串, 置信度) 置信度 2=绝对时间可直接用,1=相对时间换算,0=解析失败置空 """ raw = (raw or "").strip() if not raw: return "", 0 m = REL_TIME.search(raw) if m: num, unit = int(m.group(1)), m.group(2) delta = {"分钟": timedelta(minutes=num), "小时": timedelta(hours=num), "天": timedelta(days=num)}[unit] # 相对时间以采集时间为基准换算,跨天误差在分钟级,业务侧可接受 return (collect_time - delta).strftime("%Y-%m-%d %H:%M:%S"), 1 for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y年%m月%d日 %H:%M", "%Y-%m-%d"): try: return datetime.strptime(raw, fmt).strftime("%Y-%m-%d %H:%M:%S"), 2 except ValueError: continue return "", 0

标题长度上限设 100 字不是拍脑袋。正常新闻标题很少超过 40 字,超过 100 字基本都是解析规则把摘要段一起抓进来了,这类脏标题进 ES 会污染检索结果。相对时间换算成绝对时间时必须带置信度标记,因为“3小时前”是以采集时刻为基准推的,如果采集延迟了半小时,换算结果就偏了。置信度标成 1,下游做时间排序时可以降权或者只用于展示。解析失败返回空串而不是当前时间,是为了让质量校验能识别出来——把解析失败的时间填成当前时间,是数据质量里最隐蔽的一类污染。

4.3 清洗规则链:去噪、去重、时间归一、长度过滤

清洗不是一次函数调用,而是一条有序的规则链。顺序很重要:先去标签和噪声段,再归一时间,最后做长度与重复判断。顺序反了会出现“去重时用的还是带噪声的正文”这类问题。

import re import hashlib # 噪声段:版权声明、引导关注、推荐阅读,命中即整段删除 NOISE_PATTERN = re.compile( r"(责任编辑|免责声明|扫码关注|点击查看|原标题|来源[::]).*$", re.M ) def clean_body(raw: str) -> str: text = re.sub(r"<script[\s\S]*?</script>", "", raw or "", flags=re.I) text = re.sub(r"<[^>]+>", "", text) # 去残留标签 text = NOISE_PATTERN.sub("", text) # 去噪声段 text = re.sub(r"[ \t\u3000]+", " ", text) # 全角空格一并归一 text = re.sub(r"\n{3,}", "\n\n", text) # 连续空行压缩 return text.strip() def body_quality(text: str) -> float: """正文质量分:长度与标点密度双指标,低于阈值判为抽取失败""" if not text: return 0.0 length_score = min(len(text) / 300, 1.0) # 300 字以上给满分 punct = sum(text.count(p) for p in ",。!?;:") punct_score = min(punct / max(len(text) / 50, 1), 1.0) # 每 50 字至少 1 个标点 return round(0.6 * length_score + 0.4 * punct_score, 3) def dedup_key(url_norm: str, title: str, publish_time: str) -> str: """两级去重键:URL 归一化哈希 + 标题时间指纹""" raw = f"{url_norm}|{title}|{publish_time[:10]}" return hashlib.md5(raw.encode("utf-8")).hexdigest()

body_quality这两个指标是有分工的。长度分能挡住“只抓到标题没抓到正文”的情况,标点密度分能挡住“抓到了导航栏和评论区”的情况——导航栏是短词组,标点极少,密度明显低于正常正文。分数低于 0.5 的进待复核,不直接入库。这个阈值我一般在样本上先跑两百条人工核对一遍再定,不同站点类型差异不小,新闻类和博客类要分开调。

dedup_key把日期截到天,是因为同一篇文章在不同栏目下重复发布时,URL 不同、标题一致、发布时间可能差几分钟。只按 URL 去重挡不住这种情况,加上标题和日期做指纹,重复率能压下来一大截。代价是同一天发布的同标题系列文章(比如“午间快讯”每天一篇)会被误判,所以指纹里保留了 URL 归一化哈希的一部分,只有标题和日期完全一致且 URL 高度相似时才判重。

4.4 质量校验与打标签

质量校验放在微服务接口里,规则要能配置,不能写死在代码。下面这组规则是常见做法的集合,可以按项目情况删减。

校验项判定条件处理方式
标题非空长度 1 到 100不通过则拒绝入库
发布时间可解析置信度大于 0置信度为 0 时允许入库但标记待补
正文长度大于 200 字不通过则进待复核
正文质量分大于 0.5不通过则进待复核
URL 归一化无跟踪参数、协议统一不通过则就地修正
重复判定指纹未命中命中则丢弃并计数

打标签这一步很多人当成可选项,其实它是把原始数据变成可用数据的关键。标签分两类:规则标签和信源标签。规则标签靠关键词命中,比如正文里出现特定行业的词就打成对应分类;信源标签来自信源配置,比如某个栏目本身定位就是财经,那么这个栏目下的数据全部带财经标签,不用逐条判断。两类标签合并后写进 ES 的tags字段,检索时能直接按标签过滤。

标签和校验的关系要说清楚:校验决定数据能不能进库,标签决定进库之后怎么被查出来。校验失败的进待复核,标签算错的不影响入库,但需要在监控里盯住各类标签的数量分布——某一类标签突然归零,多半是关键词表被改坏了或者解析模板抽错了字段。

5. 避坑与排查:上线后最容易翻车的五个点

系统跑起来之后,问题基本不会以“报错”的形式出现,而是以“数据少了”“数据重了”“数据脏了”的形式出现。这三种现象对应的排查路径完全不同,下面按现象、原因、解决三段式写清楚,遇到时可以直接对照。

5.1 采集侧:漏采与重复采集

现象一:任务状态显示成功,但一个栏目一天只采到三五条,源站明显不止这些。

原因是翻页终止条件写错了。常见写法是“翻到某一页拿不到详情链接就停止”,遇到中间某页返回空列表(比如临时限流返回了空壳页面),采集器就认为到底了,后面的页全部漏掉。另一种写法是分页参数写死成固定页码上限,源站改版后每页条数变少,实际能采到的总量就少了。

解决方式是把“终止条件”和“异常判断”拆开:只有连续两页返回空列表才终止;单页返回空也要记一条异常日志,连续三次空页触发信源健康检查。同时在监控里加一条“每日新增条数环比”,跌幅超过 30% 就告警。人数最容易被忽略的是这条环比曲线,它比任何单点日志都更早发现问题。

现象二:同一篇文章在 ES 里出现多份,标题一样,URL 差一个参数。

原因是去重只按 URL 原文做,没有归一化。带utm_source、from、spm这类跟踪参数的链接,同一个页面能生成十几个不同 URL;http和https两个版本也会被当成两个页面。还有一种情况是列表页的链接带分页参数,归一化时没剔掉。

解决分两层:入口 URL 和详情 URL 在入库前统一走一遍归一化函数,去掉跟踪参数、统一协议、去掉末尾斜杠、统一大小写;第二层用标题加日期的指纹做兜底,指纹命中的直接丢弃并计数。指纹命中的计数要单独监控,这个数字突然变大,说明归一化规则漏了新的参数名。

现象三:采集器大批量失败,日志里全是超时和限流响应。

原因是请求频率没做全局控制。每个采集器各自限速,十个采集器同时打一个站,实际频率是单机的十倍。对方限流之后返回错误页,采集器把它当正常页面解析,抽出来的字段全是空的,看起来像解析失败,实际是请求被挡了。

解决方式是把请求间隔做到 Redis 里按域名共享,所有采集器共用一个计数器;同时在解析前先判断页面特征,命中限流页特征的直接标记为“请求受限”,进重试队列而不是进解析流程。这两步做完再去看解析失败率,数字会明显下降——很多“解析问题”本质是请求问题。

5.2 解析与监控侧:脏数据与假健康

现象四:正文里混进大段导航、推荐阅读、评论区,质量分却还是合格。

原因是解析模板绑死了单个 XPath 或 CSS 选择器,站点改版后选择器还能命中,但命中的是外层容器,把整块内容都装进来了。这种情况最麻烦的地方在于它不报错,字段都有值,长度也够,只有人肉抽查才能发现。

解决要靠三重判断:正文质量分(前面提过)、正文段落数、以及新旧模板的字段差异对比。模板上线前拿同一个页面用新旧两版各抽一次,正文相似度低于 80% 就不允许切换。平时在监控里看字段缺失率和正文平均长度两条曲线,平均长度突然翻倍基本就是抽到了外层容器。

现象五:服务器 CPU、内存、磁盘全部正常,采集监控全绿,但业务方说数据不对。

原因是监控只监控了机器,没监控数据。CPU 正常不代表采集器在干活,任务队列可能已经空了;采集器在跑也不代表数据对,字段可能全是空的。这类“假健康”是最难排查的,因为所有仪表盘都是绿的。

解决是加一组数据侧指标:每小时新增条数、各字段缺失率、重复率、各标签数量分布、待复核文件增长速度。这五个指标里任意一个越界都告警,告警信息里带上具体是哪个信源、哪个字段。我的习惯是把这组指标放在监控首页最上面,机器指标放下面,因为业务方只看数据,运维只看机器,两边都要能在同一屏上看到对方的关注点。

注意:告警阈值不要照抄别人的数字。不同信源的更新频率差几个数量级,固定阈值一定误报。按每个信源自己的历史均值加减合理区间来设,新信源先跑一周观察再设阈值。

6. 验收与答辩:怎么证明这套系统真的跑通了

答辩现场最常被追问的不是架构图画得对不对,而是“你怎么知道数据是干净的”。这个问题答不好,前面讲得再完整也会被打折。我的做法是准备一张验收清单,每一项都能现场演示,而不是口头描述。

验收项验收方式合格标准
信源可管理现场新增一个信源并启用无需改代码,配置后任务自动生成
任务不重复同时启两个采集器跑同一批任务每条任务只被处理一次,日志可查 worker_id
解析正确率随机抽 100 条比对原页面标题、时间、正文三要素抽取正确率不低于 95%
清洗有效性查看待复核文件与剔除记录每类剔除都有原因码,可反查原始页面
数据不重复查询 ES 中重复 URL 数量重复率为 0,指纹命中计数可查
监控可告警人为停掉一个采集器5 分钟内出现任务积压告警
链路可追溯从 ES 一条数据反查能查到采集时间、解析模板版本、任务 ID

其中“链路可追溯”这一项最能体现方案的完整度。做法是在每条数据上带三个字段:task_id、template_version、collect_time。答辩时现场从 ES 里随便挑一条,用task_id去 Redis 和日志里反查这次采集的完整过程,包括什么时候入队、哪个采集器处理的、用了哪个模板版本、校验结果是什么。这条链路一通,答辩老师基本不会再追问“数据是不是编的”。

演示脚本我一般写成一条命令跑完,避免现场手忙脚乱:

# 一次验收自检:统计近一小时的采集量、字段缺失率、重复率 # 三个数字直接对应验收清单里的解析正确率、清洗有效性、数据不重复 curl -s "http://localhost:8080/collect/stat?minutes=60" \ | python3 -c " import json, sys d = json.load(sys.stdin) print('新增条数:', d['total']) print('标题缺失率: %.2f%%' % (d['missing_title'] / d['total'] * 100)) print('时间缺失率: %.2f%%' % (d['missing_time'] / d['total'] * 100)) print('正文缺失率: %.2f%%' % (d['missing_body'] / d['total'] * 100)) print('重复条数:', d['dup_count']) "

这段脚本的价值在于它把定性描述变成了三个可比的数字。缺失率超过 2% 就要查解析模板,重复条数不为零就要查归一化规则,新增条数明显低于历史均值就要查调度和信源状态。答辩时不用解释逻辑,直接把数字放出来,再挑一条反查链路,比讲十分钟架构有效得多。

讲的时候还有一个技巧:先讲问题再讲方案。比如先说“我最初只按 URL 去重,跑了一周发现重复率 18%”,再说归一化和指纹两层去重怎么把重复率压到零,最后给出监控里那条曲线。老师听到的是踩坑和修正的完整过程,比一上来就讲四层架构有说服力。我见过不少同学把方案讲成产品说明书,每层都提到了,但没有一处能说清“为什么是这样”。

从那以后我每次做数据类的项目,验收清单一律提前写,写在动手之前,而不是做完再补。写清单的过程会逼着我想清楚“干净”到底怎么定义——是字段不为空,还是字段和原页面对得上,还是重复率为零。这三个定义对应的实现完全不一样。希望这套结构和清单能帮到你,照着自己的信源改字段和阈值,跑通一遍再动笔写文档,会比先写文档再补代码顺得多。

本文还有配套的精品资源,点击获取