简介:在大数据链路中,数据采集与清洗是决定数据质量的上游环节,直接影响后续分析的准确性。一个健壮的采集系统需要涵盖信源管理、任务调度、消息缓冲和存储落地等多个层次。本文将拆解四层采集架构,重点讨论如何利用Redis实现分布式任务锁与队列去重,通过Kafka削峰填谷解耦采集与存储,再由Spark消费数据并分别写入Elasticsearch与HBase,兼顾查询性能与全量存储。同时,针对调度重复、数据漏采、消息积压等生产高频问题,给出工程化排查思路。文中还包含可参考的Python、Kafka与Spark代码片段,适合正在构建或优化采集系统的开发者参考。
1. 数据采集这关过不去,后面清洗、分析全是白用功
做数据这行的人大概都有过这种体验:辛辛苦苦把模型调好了,结果喂进去的数据有一半是空字段、重复ID、乱码正文,最后得出的结论自己都不敢信。数据采集和清洗在整个大数据链路里属于最不起眼、但翻车率最高的环节。很多团队把精力放在存储、计算、可视化上,却忽略了一个事实:采集端决定了数据的天花板。你后面用再好的清洗规则,也救不回来采集时丢失的字段。这份文档讲的是头条、博客、公众号这类信源的完整采集架构,从采集前置管理到任务调度、再到数据落库和监控,是一套能直接照着搭的工程方案。适合正在做爬虫项目、或者想从零搭一套采集系统的开发者。不是讲概念的,是讲怎么把数据稳稳当当拿回来的。
2. 四层架构拆解:采集前置、采集层、存储层、监控系统各自在扛什么活
完整的大数据采集系统不是写几个爬虫脚本那么简单。文档里的架构图很清晰,整个系统分四层:采集前置负责管信源、采集层负责干活、存储层负责把数据送进仓库、监控系统负责盯着所有环节不出问题。这四层缺一个,系统都会变成黑匣子。
2.1 采集前置:不只是增删改查,是信源的生命周期管理
采集前置本质上是一个信源管理系统。它管的不只是网站列表,还有栏目、关键词、微信公众号、博主主页这些采集源。这个设计有一个很关键的好处:把采集目标和采集逻辑解耦。运营人员不用碰代码,直接在前置系统里加一个网站、配一个栏目,任务就会被调度层拉起来。
我一般会建议在这个环节加上信源状态字段,不只是启用/停用,还要记录最近一次采集时间、最近一次成功时间、最近一次失败原因。为什么?因为信源是会变的,网站改版了、公众号改名了、博主删了旧文章,这些都是常态。如果前置系统里没有这些状态,采集层会一遍又一遍地跑失败任务,浪费资源还查不出原因。
关键词采集是另一个容易被忽视的点。文档里提到关键词用于搜索引擎采集,这块的难点在于关键词的增删和启停必须是实时的。运营今天想追一个热点,加一个关键词,系统得马上开始采,而不是等下一个调度周期。所以关键词配置的变更要直接推到任务队列里,而不是等调度器轮询。这里我踩过坑,当时关键词变更走了配置中心,生效延迟五分钟,热点新闻早就被别人采走了。
2.2 数据采集层:Redis 缓存队列和任务调度中心是怎么配合的
采集层是整个系统的核心执行单元,由 Redis 缓存平台、任务调度中心和多个采集器组成。Redis 在这里不只是缓存,它实际上是任务队列的存储介质。采集前置产生的任务先进入 Redis 队列,调度中心负责按频率把任务分发给采集器。
任务调度的关键在于唯一性控制。文档里明确写着"同一任务同一时间只能被一个采集器处理",这是防止重复采集的基础。实现方式一般是 Redis 的 SETNX 命令,任务 ID 作为 key,谁抢到锁谁执行。还有个细节是任务去重,同一个 URL 如果已经在队列里,就不应该再被添加。这里我一般会用一个已消费 URL 的集合做判断,但这个集合不能无限增长,要注意定期清理。
采集器拿到任务后要做三件事:网页下载、结构化解析、任务状态上报。网页下载要注意超时设置和重试机制,用 requests 或 scrapy 都行,但代理池要做好。结构化解析是技术含量最高的部分,同一篇文章在不同网站可能结构完全不同,所以解析规则最好做成可配置的,而不是硬编码在代码里。
# 采集器任务处理的核心逻辑 def process_task(task): task_id = task["id"] # 用 Redis SETNX 保证同一任务同一时间只被一个采集器处理 lock_acquired = redis_client.set(f"task_lock:{task_id}", "1", nx=True, ex=300) if not lock_acquired: return # 任务已被其他采集器处理,直接跳过 try: # 1. 网页下载,设置超时和重试 html = download_page(task["url"], timeout=10, retries=3) # 2. 结构化解析,提取标题、正文、发布时间 data = parse_content(html, task["parse_rule"]) # 3. 校验数据完整性 if validate_data(data): # 4. 推送到 Kafka,交给存储层处理 producer.send("raw_data", key=task["url"], value=data) # 5. 上报任务状态 task_reporter.success(task_id, len(data["content"])) else: task_reporter.failed(task_id, "数据校验不通过") except Exception as e: # 任务失败要记录原因,方便监控系统反查 task_reporter.failed(task_id, str(e)) finally: # 释放任务锁,让后续任务可以处理 redis_client.delete(f"task_lock:{task_id}")这段逻辑里有几个关键参数要注意。nx=True表示只有当 key 不存在时才能设置成功,这是实现互斥锁的核心。ex=300是锁的超时时间,防止采集器崩溃后锁永远不释放。超时时间取 5 分钟,是因为正常情况下一个采集任务不可能超过这个时长。重试次数设为 3 次比较合理,超过 3 次基本可以断定是信源的问题,应该标记失败而不是继续死磕。
还有一个容易被忽略的点:validate_data 这一步。很多采集系统只做解析不做校验,导致脏数据直接进 Kafka。校验至少包括三个字段:标题不能为空、正文不能为空、发布时间不能为空或离谱(比如 1970 年)。这一步做扎实了,后面存储层的数据质量压力会小很多。
3. 存储链路落地:SpringBoot 微服务推 Kafka,Spark 消费写 HBase 和 ES
采集器解析完数据后,接下来是数据存储层。文档里的链路很清晰:采集器通过统一的 SpringBoot 微服务接口,把数据推送到 Kafka 中间件,Spark 消费 Kafka 中的数据,把标题、时间、正文创建 ES 索引供查询,同时把完整信息存入 HBase。这条链路是大数据采集系统的标准做法。
3.1 为什么中间要隔一层 Kafka,而不是采集器直接写数据库
直接写数据库看起来省事,但后果很严重。采集器的数量是动态的,运营加了一个采集源,可能就有新的采集器被拉起来,瞬间产生大量写入请求。如果这些请求全部直接打到 HBase 上,存储层的压力会非常不稳定,而且在采集高峰期很容易把数据库打挂。
Kafka 在这里起的是削峰填谷的作用。采集器只负责往 Kafka 里推数据,不关心数据最终被谁消费。Spark 消费端可以根据自己的处理能力调整消费速度,数据在 Kafka 里缓冲,不会丢失也不会积压到不可控。这个设计还有一个好处:采集器和存储层之间没有耦合,任何一端升级都不影响另一端。
Kafka 的 topic 设计也有讲究。我一般建议按数据源类型分 topic,比如 news_topic、blog_topic、wechat_topic。为什么?因为不同类型的数据消费逻辑不同,新闻可能需要做关键词标签,公众号文章可能需要做原创检测。分 topic 之后,Spark 消费端可以针对不同类型的消息走不同的处理逻辑,避免一个 topic 里混着各种格式的数据,解析的时候还要先判断类型。
# 创建 Kafka topic,按数据源类型隔离 kafka-topics.sh --create \ --bootstrap-server kafka-01:9092 \ --topic news_topic \ --partitions 6 \ --replication-factor 2分区数设 6 是一个经验值。分区数越多,消费并行度越高,但也不完全是越多越好。分区数超过 Kafka 节点数时,副本分布会让部分分区处于冷状态,反而增加管理开销。一般经验是一个分区对应 1-2 个消费者实例,分区数是节点数的 2-3 倍比较合理。副本因子设 2 是底线,生产环境最好设 3,Kafka 节点挂掉一台不影响数据完整性和消费连续性。
3.2 Spark 消费写双存储的取舍:ES 管查询、HBase 管全量,别搞反
Spark 消费 Kafka 数据后,要做两件事:创建 ES 索引供业务查询,同时把完整信息存入 HBase。这个设计有一个核心考量:ES 擅长全文检索和条件查询,但存储成本高,不适合存全量数据;HBase 擅长海量数据的随机读写,但查询能力弱。所以把需要查询的字段放进 ES,把完整数据存 HBase,各司其职。
这里有一个关键决策点:哪些字段进 ES,哪些字段只进 HBase。我的建议是:标题、发布时间、信源名称、URL 必须进 ES,因为业务查询基本都围绕这几个字段;正文内容要不要进 ES取决于业务需求,如果要做全文检索就进,如果只需要按标题查就只在 HBase 里存。正文是非常占存储空间的,一个 5000 字的网页正文可能就有 10KB 以上,全部进 ES 的成本会很高。
# Spark 消费 Kafka 消息,写 ES 和 HBase from pyspark.sql import SparkSession from pyspark.sql.functions import from_json, col spark = SparkSession.builder \ .appName("data-consumer") \ .config("spark.es.nodes", "es-01") \ .getOrCreate() # 从 Kafka 读取数据流 df = spark.readStream \ .format("kafka") \ .option("kafka.bootstrap.servers", "kafka-01:9092") \ .option("subscribe", "news_topic") \ .option("startingOffsets", "earliest") \ .load() # 解析 JSON 数据 parsed = df.select( from_json(col("value").cast("string"), schema="title STRING, content STRING, publish_time STRING, source STRING, url STRING").alias("data") ).select("data.*") # 清洗:过滤空标题和空正文 cleaned = parsed.filter(col("title") != "" & col("content") != "") # 写入 ES,供业务查询 cleaned.writeStream \ .format("org.elasticsearch.spark.sql") \ .option("es.resource", "news_index") \ .option("checkpointLocation", "/tmp/es-checkpoint") \ .start() # 写入 HBase,保存全量数据 def write_hbase(batch_df, batch_id): batch_df.foreachPartition(write_to_hbase) cleaned.writeStream \ .foreachBatch(write_hbase) \ .option("checkpointLocation", "/tmp/hbase-checkpoint") \ .start()这段代码里有两个 Stream 同时消费同一个 Kafka topic,这在结构上是允许的,但要理解这样做的含义。ES 和 HBase 各有一个独立的消费通道,互不干扰,一个挂了另一个还能继续工作。startingOffsets设为earliest,意味着 Kafka 里积压的数据会被重新消费,这在系统重启后特别重要,能保证数据不丢。
写入 ES 之前一定要做数据清洗,哪怕只是最基础的过滤空标题和空正文。否则 ES 里全是脏数据,业务查询结果没人敢信。清洗规则根据业务需求扩展,比如过滤掉发布时间在未来的数据、过滤掉重复 URL 的数据,这些规则在 Spark 层用 filter 就能实现,不需要单独写清洗服务。
3.3 数据质量校验不能只在存储层做,采集端就要卡一道
文档里提到数据传输时要校验发布时间、标题、正文等解析的准确度,这个校验动作很多人会放在数据入 HBase 之后做,但那时候发现问题已经晚了。数据质量校验的正确姿势是在采集器解析完、推送 Kafka 之前就做一遍初筛,入 HBase 之后再做一遍复核。
我见过最典型的翻车现场是:解析规则把发布时间字段提取错了,把网页的修改时间当成了发布时间,这个错误在采集端完全看不出来,因为字段不为空、格式也对,直到业务方发现媒体文章的时间顺序全乱了才排查出来。这种问题靠校验规则很难发现,所以采集端的数据抽检很有必要。
我在采集器里会加一个抽样逻辑:每采集 100 条数据,随机抽 1 条,把解析后的数据结构和原始网页的关键信息打印出来。这不是校验规则能覆盖的,而是人工确认解析逻辑仍然正确的手段。页面改版是常有的事,解析规则不可能永远适配,抽样检查是发现解析规则失效的最快方式。
4. 任务调度与队列去重:Redis 里最容易踩的三个性能陷阱
采集层的核心是 Redis 缓存平台和任务调度中心,这里也是性能问题最容易爆发的地方。很多人以为 Redis 够快就不会出问题,但快不等于不会错,尤其在队列设计和去重策略上,坑非常多。
4.1 任务队列用 List 还是用 Stream,决定了断点续采能力
任务调度中心要和 Redis 配合,最常见的方式是把 URL 或任务 ID 直接塞进 List。这个方案简单直接,但有一个致命缺陷:如果采集器处理完任务还没来得及删除 List 里的元素就崩溃了,这个任务在重启后永远不会被重新消费。用 LPOP 命令取任务,取走了就没了,不处理也是没了。
Redis 5.0 之后的 Stream 类型就是为解决这个问题设计的。Stream 支持消费者组,可以记录每个消费者的消费进度,崩溃重启后可以从最后一条已消费记录继续消费。对于采集系统这种对数据完整性要求高的场景,用 Stream 比用 List 稳妥得多。我这里有个教训:当时系统里用 List 做任务队列,某天一台采集器 OOM 崩溃,重启后丢失了大约 2000 个任务,这些任务对应的网页没被采集到。后来改成 Stream,类似问题再没出现过。
# 用 Redis Stream 做任务队列,支持断点续采 XGROUP CREATE task_stream task_group 0 MKSTREAM # 采集器消费任务,pending 列表里记录未确认的任务 XREADGROUP GROUP task_group consumer_1 COUNT 10 STREAMS task_stream > # 处理完成后确认 XACK task_stream task_group task_id_123这套方式的关键在于 XACK。采集器从 Stream 里读到任务后,任务会进入 pending 列表,只有调用 XACK 确认处理完成,任务才会从 pending 里移除。如果采集器崩溃,pending 里的任务会一直存在,重启后可以用 XPENDING 查询并重新分配。Kafka 有 offset 概念,Redis Stream 的 pending 列表就相当于一个简化版的 offset。
4.2 任务去重的维度:URL 去重和内容指纹去重是两回事
去重是采集系统中最容易出问题的环节之一。文档里提到任务处理唯一性,很多人理解成按 URL 去重就够了,但实际场景中往往不够。同一个新闻可能被多个网站转载,标题一样、内容一样,URL 完全不同。如果你只按 URL 去重,一篇被转载 50 次的新闻会被采集 50 次,后面清洗阶段还要花大力气做内容去重。
我一般会在采集器解析完成之后,做一次内容指纹去重。最简单的做法是取标题的 MD5 作为内容指纹,这样同一个新闻不管 URL 是什么,指纹都是一样的,在存储层可以直接过滤掉重复数据。不过要注意一点:如果两个网页的标题有细微差别,比如一个加了【转载】前缀,MD5 就会完全不同。所以指纹不能单纯依赖标题,最好结合正文前 100 个字符做组合判断,这样准确率会高很多。
# 内容指纹去重,防止转载文章重复入库 import hashlib def generate_fingerprint(title, content): # 标题和正文前100字组合生成指纹 content_prefix = content[:100].strip() raw = f"{title.strip()}|{content_prefix}" return hashlib.md5(raw.encode("utf-8")).hexdigest() # 存储前检查指纹是否存在 fp = generate_fingerprint(data["title"], data["content"]) if redis_client.sismember("article_fingerprints", fp): return # 重复文章,直接丢弃 redis_client.sadd("article_fingerprints", fp) # 定期清理 30 天前的指纹,避免集合无限膨胀 redis_client.expire("article_fingerprints", 30 * 24 * 3600)指纹集合的过期策略是个容易踩的坑。如果你用的是 sadd 加上不设置过期时间,这个集合会无限增长,Redis 内存早晚被打满。设 30 天过期意味着一个月前的文章即使被转载也不会再采第二次,不过对于绝大多数业务场景已经够用了。如果是做长期的内容追踪,可以考虑把指纹存进 HBase 而不是 Redis,查询效率低一点但存储空间不是问题。
4.3 调度频率设置:固定间隔看起来省事,但容易把目标网站打挂
任务调度中心负责按设置的采集频率调度采集任务,这个频率怎么设很有讲究。很多人的第一反应是固定间隔,比如每 30 分钟采一次。但不同信源的更新频率差别很大:新闻类网站可能每几分钟就有新内容,个人博客可能一个月才更新一次。统一用固定频率,不是采不到数据就是浪费资源。
更合理的做法是按信源类型配置不同的调度频率,而且要加一个自适应调节机制。如果某个网站连续几次采集返回超时,说明对方可能在限流或服务器压力大,这时候要自动降低采集频率。如果某次采集发现内容更新量特别大,比如出现了突发事件,可以临时提高采集频率。这个逻辑在任务调度中心里做成规则就好。
我在实践中会先用一段历史数据分析信源的更新规律,再确定初始调度频率。新接入的信源保守一点,先用 30 分钟间隔跑三天,看数据量再调整。有些网站更新很规律,比如每天上午九点发一批文章,那调度完全可以设置为每天早上九点半采一次,比每隔一小时扫一次更高效。
5. 常见问题与排查:调度重复、数据漏采、平台积压、解析错乱的四个实战定位
这套架构跑起来之后,问题不会少。我整理了几类高频问题,都是生产环境实际遇到过的,每个都按现象、原因、解决的顺序说清楚,方便排查时直接对照。
5.1 调度重复:同一篇文章被采集器重复入库
现象是 HBase 里出现大量标题相同但 rowkey 不同的记录。原因有两个常见来源:一是任务锁没设超时时间,采集器处理任务时崩溃没释放锁,另一个采集器接管后重复执行;二是采集器处理完任务后没有向调度中心上报完成状态,调度中心等了一会儿发现没回应,重新分配任务。
解决方式是双管齐下。任务锁必须设置超时时间,用 Redis SETNX 加 EX 参数,超时时间根据任务预估执行时间设定。同时,调度中心的任务状态上报要实时,不能批量延迟上报,否则状态更新不及时就会误判任务超时。还要提醒一句:从 Kafka 消费消息处理完后,offset 提交要用手动模式,不要用自动提交,否则数据处理完还没提交 offset 就崩溃了,重启后这条消息会再被消费一遍。
5.2 数据漏采:某个栏目的文章总是不全
现象是某个网站的分类页面下文章数量明显少于实际。原因大概率出在栏目页面只展示了最新内容,没有覆盖历史文章。很多列表页是分页加载的,你只采了第一页,后面的全丢了。
解决方式是检查栏目页是否支持分页,以及链接到文章详情页的入口是否完整。还有一个隐蔽的坑:有的网站列表页点击"加载更多"是 AJAX 异步请求,URL 不变化,直接抓列表页只能拿到初始加载的部分内容。这时候要分析 AJAX 接口,直接采集接口返回的 JSON,解析里面的 link 字段就能拿到全部文章链接。排查时先看列表页的 HTML 结构,再决定是模拟点击还是调接口。
5.3 Kafka 消费积压:数据追不上采集速度
现象是 Kafka 里 unconsume 的消息越来越多,消费延迟从几分钟变成几小时。原因有两类:采集器数量加多了,推送速度上来了,但 Spark 消费端没有做水平扩展;或者消费端的某个处理环节出现了瓶颈,比如 HBase 批量写入太慢拖累了整个消费链路。
解决方式要先定位瓶颈在哪个环节。看 Spark UI 的 stage 耗时分布,如果写入 HBase 的耗时占比高,说明是存储层的问题,可以增大 HBase 的批量写入大小,或增加 HBase 的 region server 资源。如果是整体处理能力不够,就增加 Spark 的消费者数量,同时注意一个分区的数据只能被一个消费者处理,要让消费者数量不超过 Kafka 分区数,否则多的消费者会空闲。
5.4 解析错乱:正文混入导航栏或推荐链接
现象是采集到的正文里有大量无关链接和广告文案。原因是解析规则太宽松,正文提取时把 HTML 里的导航、页脚、推荐阅读一起抓进来了。这个问题在采集文章类网站时很常见。
解决方式分两步。解析规则要更精确,先用 CSS 选择器定位正文的容器节点,而不是全文抓取,然后提取正文时过滤掉 script、style、iframe 等标签下的内容。还要在采集器里加一个自己的校验规则,比如正文的最短长度和最长长度范围,超出范围就标记解析异常,交给人工处理而不是直接入库。
5.5 ES 索引数据与 HBase 数据不一致
现象是按标题在 ES 里能查到文章,但 HBase 里没有对应的完整记录。原因是 Spark 写 ES 和写 HBase 分成了两个 Stream,其中一个 Stream 运行异常没有告警,导致只有一边的数据。
解决方式是必须给两边单独加监控指标。数据总量可以不一致,但差值要在可接受范围内。另外,可以在 Spark 里对每条消息做双写的时候加一个日志,记录写 ES 和写 HBase 的结果,如果一边失败需要重试,而不是默默跳过。这个问题的本质是两条消费链路没有原子性,但一般情况下不需要用事务解决,监控数据差值及时发现就能把影响降到最低。
6. 采集策略优化实践:调度频率怎么调,深度和广度怎么平衡
架构搭好了、坑也排过了,接下来是把采集能力调到最优的问题。这里有一个很容易被忽视的原则:采集不是越快越好,更不是越全越好,而是在资源有限的前提下做到性价比最高。
调度频率的调节逻辑我一般做成梯度策略。新信源接入后先用 30 分钟的间隔试采三天,统计每天的更新数量和新内容占比。如果每天采到的有效新内容超过总量的 20%,说明更新频繁,可以提高到 10 分钟采一次;如果三天只采到几条新内容,说明更新很慢,直接降到 2 小时一次,省出的资源给更新快的信源。这个逻辑看起来简单,但效果比固定频率好得多,尤其在信源数量上百之后。
# 梯度调度策略:根据历史采集数据自动调整频率 def get_schedule_interval(source_id, history_data): # 统计最近7天的日均新内容数量 daily_avg = calculate_avg_new_items(history_data) if daily_avg > 100: return 10 # 高频信源,10分钟采一次 elif daily_avg > 20: return 30 # 中频信源,30分钟采一次 elif daily_avg > 5: return 60 # 低频信源,1小时采一次 else: return 120 # 极低频信源,2小时采一次用日均新内容量算出来的调度频率,比拍脑袋定的间隔要靠谱得多。这里的判断阈值可以根据你的实际任务量调整,但核心逻辑是一样的:数据产出多的信源多投入资源,产出少的信源少打扰。
深度和广度的平衡也是采集策略的重头。采集需要决定爬多少层:只采列表页的文章详情,还是连详情页里的相关链接也一起采。我建议默认只采一层,也就是列表页到详情页插入内容加粗部分为止。为什么?因为每多爬一层,采集时间成本和服务器压力都会翻倍,而且相关链接指向的不一定是目标站点的内容。如果要扩大范围,优先增加信源类型而不是增加爬取深度。
从那以后我每次搭建采集系统,都会强制走一遍这套流程:先明确信源类型和更新规律再定调度策略,采集端做初筛、存储端做复核,监控系统四个维度一个不落,最后再用梯度策略持续优化。这套方法帮我撑过了多次采集高峰期,也希望帮到你。
本文还有配套的精品资源,点击获取