项目定位与整体设计思路1.1 从“雷达”这个命名说起PLFM_RADAR不是什么飞机上的雷达而是一套做平台数据情报采集与实时监控的轻量级闭环系统。为什么叫RADAR因为我一直觉得信息采集系统的核心工作和雷达在本质上是一样的不断向外发射探测信号接收回波再从回波里分辨出哪些是需要关注的目标、哪些是背景噪音。做平台监控也是如此——十几个目标源每时每刻都在产生新内容你不可能全部靠人工盯完尤其是当内容量上来之后“看完所有更新”这件事本身就变成了不可能完成的任务。这个项目最初落地要解决的实际问题非常朴素团队需要同时关注多个外部平台的公开展示内容包括技术社区的新帖、开源项目的动态、行业资讯站点的关键文章每当出现特定类型的内容时需要在最短时间内感知到并触发后续的入库、标注、通知流程。人工巡检最大的问题不是慢而是不稳定——今天心情好可能每小时刷一次明天忙起来可能半天都不看一眼。PLFM_RADAR要替代的就是这双不太靠谱的眼睛把“周期性巡检”变成“事件驱动式感知”一旦平台出现了符合条件的动态系统能在分钟级时间内完成采集、解析、评分和通知。如果你正在做的项目也是“帮我盯住若干个平台的新内容”或者你想把散落的数据收集任务收拢成一个工程化的闭环这篇文章的思路可以直接参考。我会把架构、关键代码、踩过的坑都拆开来讲。1.2 系统架构分层与选型取舍PLFM_RADAR整体上分成四层采集层、解析加工层、分析决策层、联动服务层。这个分层思路不是拍脑袋定的而是我在经历过“一个脚本什么都干”的混乱之后才得出的。早期版本里采集、解析、去重、通知全写在一个Python文件里看起来简单但每当新增数据源都要动核心代码改一次崩一次后来老老实实拆了层。先看各层职责采集层负责和上游平台打交道。这一层只做一件事——把目标平台的新内容批量取回来以标准化消息的形式往后抛。平台协议差异、反爬策略适配、分页游标处理都在这一层消化掉。解析加工层负责把采集到的原始数据转换成统一结构。不同平台返回的数据格式千差万别有的是JSON API有的是HTML页面有的带奇怪的Unicode转义。这一层做格式规整、字段抽取、清洗过滤。分析决策层负责判断“这条内容值不值得关注”。通过去重、权重评分、关键词匹配等手段把海量采集结果压缩成少量高质量事件。联动服务层负责事件出来之后的动作比如写入存储、触发告警回调、推送到群机器人等。这样的分层带来最直接的好处就是解耦。你想加一个新平台只需要写一个新的采集器和对应的解析器其他层完全不用动。你想调整告警规则也只需要改分析决策层的规则配置采集不重跑。我见过很多系统把这些逻辑揉成一团最后想改一个阈值都得全局搜索哪里写了阈值那种感觉非常痛苦。技术选型上我用了比较常规的Python体系FastAPI做管理和回调接口Celery处理异步任务Redis做任务队列、布隆过滤器和短时间窗口计数PostgreSQL作为最终存储。这套组合的好处是生态成熟、资料多、团队招人也容易。有人可能觉得用Go性能更好但考虑到采集类任务大部分瓶颈在网络IO而不是CPU计算Python完全扛得住而且开发效率高很多。真正的高频路径上如果有性能问题再单独用异步优化或者局部替换也不迟不要一开始就为了性能牺牲开发速度。核心细节解析与实操要点2.1 平台适配采集器生命周期设计整个系统里最容易出问题的就是采集层因为你要跟外部不确定性打交道。每个平台的接口行为都不一样有的返回标准JSON有的是诡异的HTML结构有的接口不定时变更字段名有的直接对高频访问做限流。我设计了一套标准的BaseCollector接口让所有平台采集器都遵循相同的生命周期。生命周期只有四个阶段初始化、抓取、校验、提交。初始化阶段做的事情包括读取平台配置、准备认证信息、实例化HTTP客户端。这里有个容易忽略的细节一定要给每个平台单独维护一份配置绝对不要把多个平台的配置混在一个全局字典里。平台A的请求频率限制是每5秒一次平台B是每分钟30次混在一起之后你只能用最低的共同标准资源利用率会非常难看。抓取阶段是实际请求目标接口的过程。这个阶段最需要注意的是错误分类。超时、限流、解析异常、认证失效这些错误类型你应该分别统计而不是一把抓到一个通用异常里。我见过很多系统崩溃后只知道“采集失败了”至于为什么失败完全不知道排查的时候就只能靠猜。校验阶段是廉价但有效的保护。拿到数据后先验证关键字段是否齐全判断数据结构是否符合预期。比如一个社区帖子你需要的就是标题、链接、发布时间、作者、正文摘要这五个字段只要缺一个就丢到错误队列里供人工复查。不要尝试在解析层处理脏数据在采集层就把不合格的数据拦截掉能省很多后续麻烦。提交阶段就是把标准化消息推到加工队列。这里要注意消息体必须全链路可序列化时间字段统一用UTC时间戳或者ISO8601字符串千万别用“2025-04-10 15:22”这种不带时区的格式很容易在后续聚合统计时出现偏移问题。2.2 数据质量去重、入库与时序处理的三个坑做信息采集系统时间长了你会发现真正的难点从来不是怎么把数据拿回来而是怎么在大量数据里得到高质量的数据集。第一大类问题就是重复。同一个内容可能在搜索接口里出现一次又在最新列表接口里出现一次如果你不处理后面评分和通知就会对同一条内容重复执行。去重我用的方案是内容指纹加布隆过滤器。对每条内容生成一个稳定指纹最简单的做法是正文归一化之后做哈希。归一化这一步值得多说两句转小写、去掉所有空白字符、去掉HTML标签为纯文本这些操作都要做否则同一个内容的两种排版就会生成两个不同的指纹。宽度字号、链接参数这些噪声也要在归一化之前清洗掉。布隆过滤器对于去重场景非常合适——它有极低的内存占用能快速判断“这条内容是否大概率见过”代价是它有一定的误判率把没见过的内容误判为见过。对于去重场景来说这个误判完全可以接受万分之一的内容被误判漏掉了问题不大但换来的内存收益是巨大的。用Redis的bitmap实现一个简单的布隆过滤器几十行代码非常好用。第二类问题是入库时序。多个采集器同时跑内容到达库里的时候顺序是乱掉的。如果你想基于时间线展示内容入库的时候一定同时记录两个时间上游发布时间和本地采集时间。很多问题排查都要靠这两个时间戳的对比如果一条内容的上游发布时间是前天本地采集时间却刚刚才生成那说明你采集延迟了反过来如果上游发布时间是未来时间就得关注是不是平台返回的时间格式有问题。第三类问题是增量采集游标的管理。不同平台的增量标记不一样有的用自增ID有的用时间戳有的用offset。统一的方法是给每个采集器记录“上次成功采集到的最新游标”每次采集都从游标开始。游标更新的时机也很关键——必须是整批采集任务全部成功之后再更新游标不能边采边更新不然中途失败会导致数据空洞而且这个空洞没办法通过游标找回。2.3 动态评分让“优先级”自己浮动采集回来的原始数据必须经过评分才能决定是否值得触发后续动作。初始版本我用的是一套规则包含关键词A加10分包含关键词B减5分超过一定阈值就告警。用了一段时间发现问题很大——规则是静态的平台内容的热度分布一直在变化同一个关键词在昨天可能意味着重要消息今天就变成了人人都在刷屏的梗。评分模型的演进方向是动态权重。对每个平台设置一个基线流量值计算当前候选内容相对基线的偏离程度。举例来说平台A平时每小时产生5条新内容突然一个小时内产生了30条这本身就是值得关注的信号——不需要具体读到每条内容都能知道“这个平台出现了异常活跃”。再把关键词命中得分和流量偏离得分加权汇总得到一个综合评分。我在实际配置中会把评分分成三级低优先只入库不通知、中优先入库并推送摘要、高优先入库、推送摘要、额外触发回调通知。三个阈值通过历史数据校准刚开始你可以拍脑袋给一版跑上一周之后回看历史记录看“当时评分超过阈值的那些内容是否真的是值得关注的”再做回调。这一步一定要做不然阈值永远停留在拍脑袋水平。动态评分的另一层好处是能适应平台内容的自然涨落。节假日内容量下降、大型发布会内容量激增静态阈值在这两种情况下都会误判——前者以为是死水一潭后者会触发告警风暴。动态基线天然把这个因素消解掉了。实操过程从零搭一套PLFM_RADAR闭环3.1 第一步采集层骨架代码我把采集层的核心抽象成了BaseCollector类各平台通过继承和覆写来实现差异化逻辑。基础骨架代码看起来是这样import abc import time import logging from dataclasses import dataclass, field from typing import Any, Dict, List, Optional dataclass class RawItem: platform: str item_id: str title: str link: str published_at: str raw_content: str extra: Dict[str, Any] field(default_factorydict) class BaseCollector(abc.ABC): def __init__(self, config: Dict[str, Any]): self.config config self.logger logging.getLogger(f{__name__}.{self.__class__.__name__}) self.session self._build_session() abc.abstractmethod def _build_session(self): 构建请求会话包括超时、重试策略、公共请求头。 pass abc.abstractmethod def fetch_latest(self, cursor: Optional[str]) - List[RawItem]: 从上游平台获取最新内容返回统一结构的RawItem列表。 pass abc.abstractmethod def normalize(self, raw: Dict[str, Any]) - RawItem: 将平台原始数据转换成统一的RawItem结构。 pass def validate(self, item: RawItem) - bool: required_fields [platform, item_id, title, link, published_at] for field_name in required_fields: if not getattr(item, field_name): self.logger.warning(item %s missing field: %s, item.item_id, field_name) return False return True def run(self, cursor: Optional[str]) - List[RawItem]: 标准生命周期入口抓取 - 校验 - 提交。 start time.time() try: items self.fetch_latest(cursor) valid_items [self.normalize(item) for item in items if isinstance(item, dict)] valid_items [item for item in valid_items if self.validate(item)] self.logger.info( collector %s fetched %s raw items, %s valid, cost %.2fs, self.__class__.__name__, len(items), len(valid_items), time.time() - start, ) return valid_items except Exception: self.logger.exception(collector %s run failed, self.__class__.__name__) raise这段代码看起来简单但里面有几个值得注意的点。RawItem用了dataclass不是为了好看而是为了后续在哨兵任务里方便类型校验。某个字段类型不对直接就能在日志里看出来。normalize返回的是RawItem即使上游返回的原始数据缺失字段你也能在这一步决定是填充默认值还是抛异常而不是在下游parse的时候才意外碰到KeyError。run方法里的生命周期兜底非常重要。如果fetch_latest抛出异常上游平台已经在接口侧挂了你不需要在这里拼命重试正确的做法是抛出异常让上层的Celery任务去处理重试策略。不要把重试逻辑写死在采集器内部否则采集任务会互相挤占时间片。我在最初的版本犯过这个错采集器自己在内部重试三次结果十个采集器同时触发重试时把Redis连接池打满了。3.2 第二步Redis里的布隆过滤器与滑动窗口去重逻辑我放在了加工管线的入口处。每条RawItem进来之后先算指纹再把指纹丢进布隆过滤器判断是否见过。这段逻辑的优化点在于一定要在指纹计算之后立刻做去重不要先入PostgreSQL再做查询去重。数据库查询去重在每天百万级数据量下会拖垮主库而布隆过滤器即使承载十亿级别的判断也只需要极少的计算和内存。简单版本的去重判断可以这样实现import hashlib import re import redis class BloomFilter: def __init__(self, redis_client: redis.Redis, key: str, capacity: int 1_000_000): self.redis redis_client self.key key self.bits capacity * 10 # 布隆过滤器位数组长度约为容量的10倍 self.seeds [3, 5, 7, 11, 13, 17] # 多个哈希种子降低碰撞概率 staticmethod def content_fingerprint(content: str) - str: normalized re.sub(r\s, , content.lower()) return hashlib.blake2b(normalized.encode(utf-8), digest_size16).hexdigest() def add(self, fingerprint: str) - None: for seed in self.seeds: hash_val int(hashlib.md5(f{seed}{fingerprint}.encode()).hexdigest(), 16) bit_index hash_val % self.bits self.redis.setbit(self.key, bit_index, 1) def contains(self, fingerprint: str) - bool: for seed in self.seeds: hash_val int(hashlib.md5(f{seed}{fingerprint}.encode()).hexdigest(), 16) bit_index hash_val % self.bits if self.redis.getbit(self.key, bit_index) 0: return False return True你可能注意到我这里没有直接用redis-py的pipeline因为示例代码尽量保持直白。生产环境里布隆过滤器的add和contains两次操作应该放在一个pipeline里批量执行减少一次RTT。另外布隆过滤器不能删除元素所以你需要给Redis key加一个过期时间或者定期重建。我的做法是每天凌晨用cron任务重建一个带日期的布隆key同时保留前一天的key作为冷备份。重建之后如果有重复内容穿越了旧的布隆进入系统他还会跟PostgreSQL里最近7天的fingerprint索引做一次二级校验保证绝对唯一。滑动窗口计数器在Redis里的实现也很有讲究。常见做法是ZSET以事件发生的时间戳作为score每次新事件到达时把现在时间往前推N分钟的成员全部移除然后统计剩余成员数量。这个做法有两个致命问题一是高频场景下ZSET内存增长极快二是清理过期成员的复杂性容易写错。我后来切成了多桶方案——每个窗口桶Redis key对应一个较短的时间段比如一分钟一个桶查询最近五分钟的热度时直接MGET最近5个桶然后求和。因为这个项目对实时性的要求没有被精确到秒级一分钟一个桶完全够用。3.3 第三步告警管线与回调解耦分析决策层输出高分事件之后真正要做的动作是通知联动。我见过的很多系统把通知逻辑和业务逻辑直接耦合在一起比如“评分够了就调用企业微信机器人API”。看起来简单一旦目标回调服务抖动整个评分管线都被阻塞。PLFM_RADAR把告警和回调完全解耦。高分事件产生后首先写入告警事件表状态为pending。后台有一个独立的Dispatcher服务以较低频率扫表把pending状态的事件按批次通过Webhook推送给下游消费者。消费者返回200则标记done返回非200或者超时则保留pending状态进入下一轮重试。为什么要这么做因为回调是不可靠的但你的核心流程不能因为外部不可靠而阻塞。被打入消息队列的话消费方宕机消息也会堆积在队列里不会丢失而且重试机制可以做得更精细。如果你用小任务队列重试时只能重新入队用事件表加扫描方式重试时可以拿到上下文记录每次重试的响应体便于排障。高优先事件的回调策略我设置为指数退避重试第一次失败后1分钟重试第二次5分钟第三次30分钟超过三次就降级到“仅通知不回调”同时给管理员发送一条提示消息让人工介入。这套机制看起来复杂但非常值得。试想一下一个外部回调因为发布窗口重启连续失败6次如果你的系统一直用固定间隔重试这6次全挤在同一段故障时间内全部失败后续也不会再触发等恢复时发现该通知的早就过了时效。指数退避则会把重试时间错开故障恢复后系统很快就能把漏掉的事件补推上去。常见问题与排查技巧实录4.1 问题速查表跑了一段时间之后我把遇到的高频问题整理成了一个速查表贴在项目文档里。后面团队其他人接手维护的时候都是先查这张表再动手。现象可能原因排查命令 / 验证方法某个平台采集量为0上游接口改版或返回结构变更手动curl目标接口查看返回体比对解析字段采集延迟越来越大任务队列积压消费者处理速度跟不上查看Redis队列长度对比历史曲线同一内容重复通知布隆过滤器key过期或误删检查布隆key是否存在确认TLL设置告警风暴大量中优先告警评分阈值过低或动态基线偏移查看近一周高分事件分布手动校准阈值回调下游偶发超时Webhook端点性能抖动在回调消费者侧加访问日志定位耗时阶段入库内容比平台显示延迟十几分钟增量游标推进异常比较采集时间和上游发布时间之差数据库连接池打满并发任务数超出连接池上限调整连接池大小给采集任务加并发信号量内容指纹大量冲突归一化没做干净例如没去HTML标签抽查指纹相同但内容不同的样本观察差异字段这张表里最容易被忽视的是“内容指纹大量冲突”。指纹冲突往往是归一化逻辑写得不够彻底而不是哈希算法出了问题。我遇到过一个真实案例两个不同的帖子一个来自移动端接口一个来自桌面端接口同一个标题一个带了细小的HTML注释另一个没有归一化的时候没有去掉这个注释导致后续去重失效——直到有人抽查指纹相同的两条内容分别打开才发现是同一个内容被不同接口重复采集了。4.2 两个我反复踩的坑时区与重试风暴时区问题几乎每个采集系统都会踩PLFM_RADAR也不例外。上游平台有的返回08:00格式带时区偏移的时间有的返回纯UTC还有的返回unix时间戳。如果入库前没有统一转换后续“按小时统计产出”这类报表就会在一段时间内出现偏差。我的建议是所有平台数据进raw表之前一律转成UTC时间展示层需要什么时区再在查询时转换——不要在存储层跟随业务时区后患无穷。重试风暴是我踩过的另一个大坑它的触发场景是这样的某个上游平台连续十分钟不可达导致那个平台的采集任务全部抛异常每个异常都触发了Celery默认的重试机制重试任务又全部堆到一个队列里。由于重试任务和正常任务共享同一个队列正常平台的采集也被活活饿死了整个系统的采集吞吐量在十分钟内跌到不到平时的三分之一。这个问题的解法是多队列隔离。至少把队列拆成正常采集队列和重试队列重试队列的消费者数量更少且优先级更低。这样上游平台故障时重试风暴只会挤占重试队列不会影响其他正常平台的采集。另外一个建议是重试需要有一个全局的最大并发数限制就算有20个任务同时失败同一时刻真正在重试的也不能超过3个剩下的先躺在队列里等。4.3 合规红线与采集礼仪聊完技术细节必须说说合规这个问题。做平台监控采集边界感一定要清晰。PLFM_RADAR从一开始就立了几条红线只采集目标平台公开展示、且在其robots协议允许范围内抓取的内容不做任何绕过访问控制的行为不对数据做二次售卖或公开展示采集频率遵循“够用就好”的原则不要以最大抓取速率去骚扰目标平台。有人可能会觉得“我都已经写爬虫了还谈什么合规”但这两者不矛盾。写采集器本身是中性技能关键看用在哪里。我的建议是每个采集器配置里都写清楚最小采集间隔和满载间隔默认启动时数据源识别先请求一次/robots.txt确认路径允许。这个逻辑实现起来成本很低但能代表一个工程团队的基本素养。万一将来你真跟上游平台产生争议这些“从设计开始就考虑边界”的工程决策也是摆得出来的事实依据。另一个实际建议是给采集器加一个“礼貌系数”。默认1.0如果检测到响应变慢、接口返回错误码增多自动把采集频率降到原来的1/3连续几次失败再降到1/10。你会发现这样一个简单的自适应机制几乎不会让采集任务因为限流而彻底失败反而比那种“猛冲猛打撞到墙再停下来”的策略稳定得多。落地验收与后续扩展这套系统做完之后我最满意的地方不是某个惊艳的功能而是整个链路的可观测性。每一条采集任务都有ID从上游请求到指纹去重到评分结果到告警推送每个环节都有日志记录。出了问题直接拿着任务ID沿着日志链路查定位时间通常不超过十几分钟。对于一个以“持续运行”为第一要务的系统来说这份可观测性比任何花哨算法都保命。PLFM_RADAR后续扩展的主要方向有两个。一是引入更丰富的语义评分比如用文本相似度做聚类把同一事件在不同平台的报告自动归并成一条“事件流”而不是看到三条相似内容就通知三次。二是在评分模型里引入用户反馈闭环——告警之后用户点了“这个不相关”记录这个负反馈定期回填到评分模型的样本集里去微调权重。有了用户标记数据这套系统就不只是一个被动采集工具而是越用越懂你需求的“雷达”。如果你也在做类似的方向欢迎按我上面这套架构先搭一个最小闭环把脏活累活留给稳定管道把判断力留给规则和数据迭代。