直播数据全站爬取:从单机脚本到分布式采集的工程实践
做直播数据采集这段时间最容易被一句话带偏的就是“全站爬取”这个词。很多人以为把某鱼直播首页上的直播间循环请求一遍就算全站了可真正开始跑才发现直播平台的数据不是一个静态页面而是由状态快照、实时弹幕、礼物榜单和主播信息组合起来的动态系统。标题里藏着两个难点站点级覆盖和近实时增量。这篇文章记录了我在某鱼直播数据全站爬取这个方向上从单机脚本一路调整到分布式采集的完整过程包括数据模型怎么拆、任务怎么调度、存储怎么选以及最后为什么必须在合规边界上踩刹车。想拿这套思路去搭直播数据仓库的或者正在纠结“为什么自己的采集脚本跑两天就废了”的人应该能从里面找到答案。1. 拆解数据模型全站爬取到底要拿到哪几类数据1.1 直播间状态是爬虫的“地面部队”全站爬取的第一步不是去找弹幕接口而是把直播间的状态数据铺开。一个直播间在一段时间内是什么状态属于哪个分类标题变没变这些数据构成了整个分析体系的地基。我习惯把状态数据分成三层来收集。第一层是房间元信息包括房间 ID、主播称号、房间标题、所属分区。第二层是动态状态也就是当前是否开播、开播时长、当前热度、在线人数、封面图版本。第三层是历史状态比如开播时间点、下播时间点、今日直播时长累计。这三层数据出现的频率完全不一样采集难度也完全不一样不能放在同一个更新周期里处理。实际操作里房间元信息相对稳定变化粒度是小时级甚至天级。动态状态变化非常快热度可能几秒钟就跳动一次开播下播更是在任何时间都可能发生。历史状态通常需要自己根据动态状态的记录去推导平台很少直接给你一个“昨日直播时长”的字段更多时候你得把快照序列拉出来自己算。如果把全站爬取理解成维护一张巨大的“房间在线状态表”那这张表的行数是全站房间数列数就是上面的那些状态字段。真正考验工程能力的是这张表的更新策略全量重建一次的成本有多高增量更新的延迟能不能压到分钟级。1.2 热度与在线人数是一种特殊的时间序列热度值和在线人数在语义上很容易混淆但在数据采集上必须分开处理。热度通常是平台自己定义的一个综合指标由礼物、弹幕、观看时长、互动频次等因子加权计算出来在线人数则更接近真实的同时观看人数。两个字段的更新频率不一样量级也可能差很远混在一个字段里会污染后续所有分析。我踩过的一个典型坑是把“热度”当“在线人数”存又用同一个采集频率去抓。结果热度值在晚高峰的波动幅度大得离谱而在线人数反而平稳最后画出来的曲线像两个完全不同的东西。解决办法也很简单在建表时把两个字段拆开记录各自的采集间隔并且在数据字典里注明来源字段名。时间序列数据还有另一个副作用就是数据量增长非常快。假设全站有几十万个直播间每 30 秒采一次快照一天下来就是几亿行。这个量级如果用普通关系型数据库硬扛写入会成为瓶颈查询也会越来越慢。所以热度类的数据从一开始就应该考虑列式存储或者压缩策略更友好的方案后面第 4 节我会详细说存储选型。1.3 弹幕与礼物事件信息密度最高也最难处理弹幕和礼物都是事件类型的数据它们的共同特点是“只在发生的那一刻有价值”。礼物记录还能通过贡献榜、热门礼物榜间接拿到历史片段弹幕几乎只能靠实时订阅。全站爬取弹幕的难点不在于拿到一条弹幕而在于同时维持大量直播间的长连接并且保证断线重连时不丢窗口。礼物数据的采集又有自己的问题。进入一个直播间时页面上通常只会展示最近一段时间的贡献榜超过窗口期的记录就再也看不到了。这意味着礼物数据不能靠事后补采必须在榜单滚动更新期间频繁轮询把每次看到的榜单位保存下来再做增量对比才能还原出一个接近完整的礼物流。弹幕内容本身还有一个非常敏感的属性它会携带用户的发言内容和用户名有可能是个人信息。所以我现在的做法是要么只统计弹幕条数和互动密度不落库具体内容要么在落库之前强制脱敏把用户名、头像、IP 这类能识别到个人的字段全部去掉。这个习惯一开始就要养成因为数据一旦进了仓库再想起来处理就晚了。2. 采集路径扫列表、轮状态、听事件三条路同时走2.1 全站爬取不是“一个抓取循环”能搞定的很多初学者写爬虫习惯用一个循环去做所有事情请求 URL、解析 HTML、存结果然后再请求下一个 URL。这套逻辑对静态门户站够用但对直播平台完全行不通。直播数据是分层变化的有的信息要扫描有的信息要轮询有的信息要实时监听三种模式的作用域和请求频次完全不同。扫描模式用于发现新直播间和更新房间元信息。典型入口是平台主页的分区列表进入每个分区后继续翻页拿到主播房间列表。这个模式的请求频率可以很低比如每 5 到 10 分钟跑一遍因为新开播不会频繁得离谱。轮询模式用于热度、在线人数、榜单类数据频率要分业务场景定。监听模式用于弹幕和礼物事件走的是长连接或者消息通道不能像 HTTP 请求一样随意控制频率。如果非要用一句话总结全站爬取 低频扫描房间列表 中频轮询状态快照 高频监听事件通道三个子系统之间通过“房间 ID”这个主键串联起来。谁要是把三种数据都放进同一个循环里最后不是漏数据就是把接口打爆。2.2 一个可落地的混合采集伪代码下面这个伪代码并不代表某个平台的具体接口而是说明三种模式在工程上应该怎么组合。我习惯用异步客户端统一处理 HTTP 部分再用一个简单的任务函数把不同采集频率封装起来。import asyncio import httpx import time async def scan_room_list(client, category_id): # 扫描模式低频用于发现房间 rooms [] for page in range(1, 10): resp await client.get( fhttps://api.example.com/{category_id}/rooms?page{page} ) rooms.extend(resp.json().get(rooms, [])) await asyncio.sleep(0.5) return rooms async def poll_room_status(client, room_id): # 轮询模式中频用于更新快照 resp await client.get( fhttps://api.example.com/room/{room_id}/status ) return resp.json() async def listen_danmaku(room_id, callback): # 监听模式高频用事件通道而不是 HTTP # 这里省略具体连接逻辑核心是收到消息后立刻回调 pass async def main(): async with httpx.AsyncClient(timeout10) as client: categories [game, talk, music] all_rooms [] for cate in categories: all_rooms await scan_room_list(client, cate) for room in all_rooms: status await poll_room_status(client, room[room_id]) save_snapshot(status) await asyncio.sleep(1)这段代码里我刻意把三个模式的函数分开不是为了好看而是为了让后续扩展更顺。扫描模式发现新房间轮询模式更新已有房间监听模式处理实时事件三者之间通过数据库或者消息队列解耦而不是在函数内部互相调用。这样后期如果某段逻辑挂了不会影响另外两端。2.3 开播检测到底什么时候去抓一个新房间全站爬取的时效性很大程度取决于“开播检测”做得好不好。新直播间出现时如果没有被及时加入采集队列后续所有相关数据都会延迟。反过来如果扫描太频繁又会对服务端造成无效压力。我的做法是把开播检测拆成两级。第一级是全量低峰扫描比如凌晨 3 点到 6 点之间把所有分区完整跑一遍重建一次房间名单。第二级是增量高峰扫描白天用较短间隔轮询热度高的分区因为新开播的主播往往会集中在活跃分区。两级扫描的结果合并后才进入正式的轮询和监听队列。这里有一个容易忽略的点房间 ID 不会复用但主播可以重新开播。所以判断“要不要采集”时不能只看房间 ID 是否已经存在还要看最近的直播记录是否已经结束。如果上次记录显示该房间下播超过 10 分钟再出现状态更新就要当作一次新的直播会话来处理在数据上打一个新的会话 ID。3. 任务调度与增量更新从单机队列到可控的分布式3.1 直接开几十个线程乱跑是最容易翻车的做法很多人为了追全站速度第一反应是“开线程池每个线程各抓各的”。我也干过这事儿结果是没过多久就收到了一连串的风控提示而且数据出现了大量重复。问题不在并发本身而在于这些线程没有共享状态根本不知道哪些房间已经抓过、哪些接口正在被同一个 IP 高频调用。对机器来说一次请求发出去远比重试一次请求便宜。真正合理的调度不是让所有任务并发执行而是把所有任务放进一个队列由调度器统一控制出队速率。单机场景可以用线程安全的队列加一个简单的限速器分布式场景则要把队列抽出来放到独立的消息中间件里让多个 worker 从同一个源取任务。我最初在单机上用的方案其实很朴素把房间 ID 排成一个列表用一张状态表记录每个房间最近一次成功采集的时间然后每隔固定秒数检查一次看看现在该抓哪些房间。这样每个房间的轮询周期相对稳定不会出现“某个房间 1 秒抓了 10 次另一个房间一整天没被抓过”的尴尬情况。3.2 调度队列里不止有房间 ID还有“温度”队列里的任务如果只有一个房间 ID那么热点房间和冷门房间会被同等对待这是很大的浪费。热度高的直播间需要高频轮询冷门直播间可能一天只需要确认几次状态就可以了。所以我给每个任务加了一个“温度”概念用最近几次快照的热度值来决定下一次采集间隔。举个例子一个热度过万的房间我可能每 20 秒轮询一次热度在一千到一万之间的房间每 60 秒轮询一次热度低于一千的冷门房间每 5 分钟轮询一次。这样看起来像是一个动态优先级队列高热度任务优先低热度任务降频。实现上并不复杂快照入库时带着热度值调度器从数据库里读一下最近一次热度就能算出下一次执行时间。这种“温度调度”还有一个额外的好处当某个直播间因为热门事件突然涌入大量观众时它的热度上升采集频率也会自动跟着上升保证不遗漏关键时段的数据。等热度回落频率再降下来资源就自动释放给其他房间。3.3 调度器需要检查点不怕中途挂掉就怕从头再来任何长期运行的采集任务都一定会遇到进程重启、服务器断电、网络长时间中断的情况。如果调度器没有检查点机制重启后要从头扫一遍全站房间列表效率会非常低而且会给对方服务器造成无谓的重复压力。检查点的核心是把“已经完成的任务状态”持久化下来。我在单机版本里用了一张很小的表字段就三个任务类型、任务 ID、最近完成时间。每次完成一个房间的状态更新就更新这条记录。重启之后调度器先读这张表凡是“最近完成时间”在合理范围内的房间都不用重新扫描直接从之前停留的位置继续。这个方法听着简单但真的能省掉大量无效请求。全站房间数量在几十万这个量级时一次全量重建可能需要好几个小时而有了检查点中途哪怕挂掉十次每次重启都只要几分钟就能回到正确的工作状态长期跑下来的效率差距非常明显。4. 数据落地明细层与聚合层分仓库4.1 明细层保存“发生了什么”方便回溯直播数据采集完成后第一个存放地应该是明细层也就是原始事件库。这里的表结构不能按业务报表来设计而应该按“数据发生的语义”来设计。我常用的三张明细表是直播间状态快照表、礼物榜单变化表、弹幕互动统计表。状态快照表的每一行代表“某个房间在某时刻的状态”字段包括房间 ID、采集时间、开播状态、分类、热度、在线人数。礼物榜单变化表稍微特殊一些因为榜单是变动的我采用“每轮轮询保存整个榜单排名”再加一个“榜单版本号”这样能还原出礼物冲榜过程。弹幕互动统计表的粒度可以压到“房间 x 每分钟弹幕条数”这种粒度既能支撑互动趋势分析又不会把原始文本全部落下来。明细层不要怕数据量大反而应该怕数据被提前聚合。一旦你把原始快照聚合成小时级平均值后面再做精细化分析就没有素材了。所以我始终坚持明细表只做轻加工字段命名统一、时间字段固定为 UTC、把平台返回的原始字段保存在一个备用列里其余尽量保持原样。4.2 明细层建表的几个硬要求通过实践我总结了明细层建表时最需要注意的四个点缺一不可。第一必须有明确的唯一键。状态快照表用room_id event_time唯一标识一行礼物榜单表用room_id round_id rank这样即使采集任务重复写入数据库时也可以靠唯一键去重。第二时间字段必须带时区信息我统一存 UTC展示层再转本地时间。第三尽量使用分区表按天或者按小时分区删除过期数据时直接删分区不用跑 DELETE 语句。第四原始响应要留一份至少是 JSON 字段这样解析出问题时还能回头修数据。下面是一个简化但能直接用的快照表结构CREATE TABLE room_snapshot ( room_id BIGINT NOT NULL, event_time DATETIME NOT NULL, is_living TINYINT NOT NULL DEFAULT 0, category VARCHAR(64) NOT NULL DEFAULT , hot_value BIGINT NOT NULL DEFAULT 0, online_count INT NOT NULL DEFAULT 0, title_hash CHAR(32) NOT NULL DEFAULT , raw_data JSON DEFAULT NULL, PRIMARY KEY (room_id, event_time) ) ENGINEInnoDB PARTITION BY RANGE (TO_DAYS(event_time)) (...);注意我为什么放了一个title_hash而不是直接放 title。标题会反复变化直接把长标题放进来既占空间又不好做比较所以我只存一个哈希值用来判断标题是否发生变化。真要分析标题内容时再单独拉一份标题变更日志不会污染主表。4.3 聚合层解决“查询太慢”的问题明细表存了原始数据之后查询端的噩梦就开始了。一个简单的问题比如“昨晚热度超过十万的房间有多少个”如果直接扫全表可能要把上亿行翻一遍。所以必须有一层提前计算好的聚合表按业务常见维度做汇总。我常用的聚合粒度是“房间 x 分钟”和“房间 x 小时”。分钟级聚合表可以从快照表算出每分钟的平均热度、最大在线人数、弹幕条数小时级聚合表再对分钟级做一次减采样得到更长时间的曲线。聚合任务用定时脚本在后台跑每天凌晨把前一天的数据全部补齐。这里最关键的一点是聚合表不是替代明细表而是明细表的索引。查询端先打聚合表发现粒度不够再用明细表查原始事件。只要这层配合起来即使数据量到了十亿行级别常见的 Top N、趋势图、会话时长这类分析也能在秒级返回。5. 实际踩坑任务积压、重复消费和状态漂移5.1 高峰期请求积压队列反榨了数据库第一次跑全站采集时我以为只要队列够长就没事结果某个晚上热门事件突然爆发直播间数量短时间翻了一倍所有 worker 都在拼命消费写入速度反而跟不上最后数据库连接被耗尽整个任务站点直接卡死。问题的根子不在 worker 速度而在写入能力有限。后来我加了两个机制一是写入端改成批量写攒够 100 条或者攒够 1 秒再统一落库吞吐量提高了一大截二是队列增加反向压力控制当写入端积压超过阈值时调度器会自动降低扫描频率宁可少采一些临时数据也不能把核心链路拖垮。流量高峰时最忌讳的是“全渠道火力全开”。现在我的做法是预设几档流量模式默认模式、高峰模式、降级模式。高峰模式下只保留热度靠前的房间高频采集冷门房间直接降频等高峰期结束再补采。这样不会错过最有价值的头部数据也不会让整个系统冲垮。5.2 幂等写入重复数据是怎么悄无声息出现的全站爬取一定会遇到数据重复。原因很多轮询和监听两条链路可能同时采到同一个事件网络超时导致客户端重试但前一次请求实际已经成功了消息队列在消费端重启后重新投递了未确认的消息。任何一条链路出问题都会往库里塞重复数据。如果表结构从一开始就设计了唯一键重复写入会被数据库挡住但这还不够因为在应用层你可能要做多轮聚合重复数据还是会影响统计。真正的办法是从源头打幂等标签每个事件生成一个唯一的业务 ID比如弹幕用平台返回的消息序号状态快照用room_id 采集时间戳礼物榜单用room_id 榜单版本号。一旦建立幂等键整个数据处理管道就有了约束。后续无论哪一环重复处理只要最终写入时校验唯一键就不会把数据翻倍。这个设计虽然不起眼但省掉了我很多查重复的深夜时间。5.3 状态漂移你以为下播了其实只是网络抖动直播状态字段是最容易误导人的。某个房间因为网络波动热度突然暴跌到接近零如果采集端把这个快照当作“已下播”入库后面的直播时长统计就全错了。这种问题叫状态漂移典型的解决办法是引入“确认窗口”。我的经验是一个房间要连续 N 次快照都显示下播状态才真正标记为下播。N 的大小取决于采集间隔通常 TTL 5 到 10 分钟。同理开播状态也需要连续确认避免把瞬间的接口异常当成开播。处理状态漂移的关键是在入库前过滤而不是入库后再修正。入库前过滤的好处是下游拿到的数据是干净状态不用每个人自己再去判断。如果真要支持更精细的分析我建议在快照表上加一个session_id字段把连续的直播时段归到同一个会话里后续计算时长、场次的时候直接按会话分组就行。6. 边界和底线爬之前必须想清楚的几件事6.1 数据源授权是全站爬取的最大约束写完上面这些工程细节我必须把话拉回到一个经常被刻意回避的问题全站爬取的数据源授权边界在哪。这个问题不是一句“抓公开信息没问题”就能带过去的。公开页面上能看到的数据不等于你有权把这些数据全量搬运、存储、转卖。我在任何采集项目启动前都要先做两件事。第一看平台服务条款和 robots 协议明确哪些路径明令禁止自动化访问。如果平台条款明确不允许批量采集那生产环境跑这种采集就存在违约风险能不做就不做。第二看数据属性。涉及个人信息的字段比如昵称、头像、个人签名能改成哈希值就改成哈希值能删掉就别留。很多团队在初期只顾着追求数据量等用到数据时才发现字段里全是不可碰的内容那时候再洗数据成本和风险都是灾难级的。我在自己的项目里采用“最小化采集”原则只要业务不需要弹幕原文就不采弹幕原文只要业务不需要礼物背后的用户昵称就不采用户昵称。少一个敏感字段就少一个默认风险。6.2 脱敏存储和留存周期要形成制度光说不存没用采集系统要把脱敏规则和留存周期做成代码的一部分。我在明细表的写入链路里加了一道脱敏层所有从网络响应的字段都必须通过字段白名单校验不在白名单里的字段直接丢弃白名单里包含的敏感字段再经过hash处理才允许入库。留存周期问题同样重要。直播热度数据和分析结果通常并不需要永久保留。我一般给原始快照设 30 天的保留期超过 30 天的直接清分区聚合表保留 180 天只有长期分析的统计结果才长期保存。这样既能控制存储成本也减少了因为数据长期滞留而带来的合规风险。6.3 灰度上线先小范围验证再全站铺开最后一个建议听起来很“不极客”但在全站爬取里反而最保命先灰度。不要第一天就上全量任务而是先挑几个分区、几个典型房间跑一跑观察请求成功率、数据完整度、延迟和风控表现。灰度期通常我至少保留三天因为直播平台的数据有很强的周内周期性周一跑出来的情况周六晚上大概率会不一样。灰度通过之后再逐步扩大范围。扩大过程中要持续观察两个指标一个是任务积压量另一个是风控触发的频率。一旦风控触发频率明显上升就要立刻回退采集频次而不是硬冲。全站爬取的长期运营拼的不是某一天抓了多少数据而是能不能稳定地每天把数据完整地补回来。回头看我自己的实践这个项目最后能稳定跑下去靠的不是某一个聪明的算法而是把扫描、轮询、监听三套模式拆开用一致的调度框架管住频率用幂等键和状态确认挡住重复和漂移再在存储层把明细和聚合分开。如果你也要做类似的事情我建议你先从一个小范围开始把调度节奏调顺了再谈全站。多花点时间在设计数据边界上后续踩坑的机会就会小很多。