千万知乎数据爬取心路历程

前言

本文是偏技术类文章,非计算机相关专业可直接看这篇数据分析

由于近期工作上比较闲,看了一些计算机网络,然后看完一本《计算机操作系统》,对计算机有了更进一步的了解(都怪大学没好好学)。因此想着搞个小项目练练手,想起之前看过“想练手就去写爬虫”的话,便决定写个爬虫程序。这便是本次数据爬取的动机。

本文具有时效性,数据抓取日期为 2019年7月

  • 数据量: 700W条
  • 爬取时长:约110小时
  • 使用语言:Go
  • 数据存取:ElasticSearch
  • 数据可视化:Kibana
  • 配置: I7四核+16G内存+SSD
  • 带宽:1M

迭代过程

v0.0.1

由于是首次写爬虫,想着先做一个最小可用版本,故 v0.0.1 版本内容是爬取阮一峰博客的周刊(几十篇),并保存为本地 markdown 格式。主要涉及网络请求,HTML节点解析,文件写入,内容比较简单,此处略去不表。

v0.0.2

有了上个版本作为基础,第二个版本计划是爬取并分析知乎大V的关注者,主要分析:间接关注者,关注者回答,关注者文章,关注者性别分布等。此分析可以判断大V关注者活跃度,帮助广告投放者分析大V的投放价值。

此版本在上个版本的基础上,加入了并发访问,错误日志。由于数据量级较大,需要开多个协程(Go语言的并发使用协程)请求数据。

我们以用户知乎日报为例,该用户有370W+的关注者。通过遍历他的关注者得出数据如下:

{
  "IndirectFollower": {
    "Num": 40229883,
    "Avg": 10.844373753789679,
    "Max": 2213973,
    "Token": "ding-xiang-yi-sheng"
  },
  "FollowerAnswer": {
    "Num": 2025325,
    "Avg": 0.5459469338475104,
    "Max": 7793,
    "Token": "luo-wei-zi"
  },
  "FollowerArticle": {
    "Num": 161834,
    "Avg": 0.043623999156815814,
    "Max": 40043,
    "Token": "tai-ping-yang-dian-nao-wang"
  },
  "FollowerVipNum": 21805,
  "FollowerGenderMap": {
    "-1": 3481376,
    "0": 112704,
    "1": 115347
  },
  "IndirectFollowerNumMap": {
    "0": 3473167,
    "1": 170128
  },
  "FollowerAnswerNumMap": {
    "0": 3438212,
    "1": 122315
  },
  "FollowerArticleNumMap": {
    "0": 3687135,
    "1": 14052
  }
}

关注者中,男性115347,女性112704,未知3481376(-1:未知,0:女,1:男),可以看出大部分知乎用户是没用填写性别这一选项的。

  • 在他的关注者中,平均每个用户有 10.84 个关注,其中最大数量是2213973。
  • 间接关注者的平均回答数量是0.55, 平均文章数量0.04,关注者中有21805是VIP。
  • 另外由于篇幅有限,略去了数量分布的部分,只保留数量为0,1的。
    其实这里我们大概可以得出结论,知乎上大部分都是像我一样的不活跃用户,关注者在10人以下(PS:顺便吐槽一下,坚持了五六年每天看知乎日报瞎扯专栏,在这个夏天也没有了)。

重点来了 v0.0.3

经过前面两个版本的铺垫,我已经学会了如何获取知乎用户详情,以及抓取一个用户的所有关注者。有了这两个技能,我们就可以不断爬取用户数据了(这里我们爬取的用户必须是有被人关注或者有关注者的,我们通过用户的关注者不断爬取更多用户,如果一个用户没有关注其他人,我们是爬取不到他的数据的)。

此版本新增内容:网络连接池,EasticSearch,Kibana,Go pprof 性能分析。

这个版本对于我这种没有写过爬虫的来说算比较复杂的版本,不过写完后回头看也就觉得简单了,当然中间有一些曲折。

大方向比较简单,通过关注者,不断获取新的用户,然后从再从新用户获取其关注者... 这个思路应该适用于所有网状关系的社交平台。

不过这里很快出现了第一个问题,为了优化效率,不重复爬取用户数据,我们需要做去重处理。
首先想到的是使用map字典保存使用过的key,那么能存多少个,会不会爆内存呢?我还特意试了一下,16G内存,大概可以支持上亿个key数据。
为此我小纠结了一下,因为百度了一下知乎号称有2亿用户,我们还需维护待爬取列表等,这内存不够用啊!现在回头看,真是太天真,按照每天爬200w数据,不考虑其它限制,2亿得爬个100天=.= (如果真要爬取上亿条数据,这里目前我想到的方案是,采用分布式爬取,节点无状态,不使用内存去重,可以考虑 Redis 保存使用过的key等。)

纠结过后并没有想到解决方案,那就退而求其次,把目标定在百万级别。

动手写代码

首先我们要维护的数据有:

  • 已获取id,待爬取详情的用户id队列,用于爬取详情
  • 所有已获取的id存一个map字典,用于去重


    爬取维护列表.png

这里有两种方案

  1. 使用Go语言自带的 chan 管道作为协程间数据传输工具。
  2. 自己维护一个数据结构,该结构包含一个先进先出列表以及两个指针(如上图)

这里我两个方案都试过,而且都遇到了问题。使用方案1无法确定通道缓冲区的容量大小,由于消费者同时也是生产者,每消费一个用户,可能产生N个新用户,刚开始没办法估计要设置多大的缓冲区,如果生成的新用户超过缓冲容量,会造成协程堵塞。由于这个问题没有想到好的方案(到最后也没解决,希望有兴趣的大神一起讨论),便想着自己写一个数据结构。

其实方案2也存在同样的问题,如果每个用户产生的新关注用户过多,同样容易造成队列长度过长。不过好处是Go的切边是自动扩容的,我不用去纠结一开始要设置多大的队列长度。
于是写了以下数据结构,userTokenList就是那个列表,由于是并发访问,这里对切片的操作需要加锁。这个版本的部分代码贴在下边,每个方法都加了注释,有兴趣的可以了解一下。

后面实际跑的过程中,发现一启动CPU就100%占满了。用pprof工具分析后,发现是因为大部分协程都在等待锁,瓶颈在于队列操作以及锁的效率,所有实际的并发效率很低,带宽占用也不高。后面跟邻座大神讨论后,他说 “你这不就是消息队列吗,Go的chan管道做的事情跟你这个差不多,你这个用chan不就可以了,为什么还要自己实现”,他还打开Go chan的源码,问我有没有很熟悉。原来自己不知不觉就在做一件了不起的事,思路是对了,只是效率太低,哈哈哈。

type CrawlZhiHuUser struct {
    userTokenList []string
    userList      []orm.People
    lock          sync.RWMutex
    infoIndex     int             // 记录当前爬取用户信息的指针
    followerIndex int             // 记录当前爬取关注者的指针
    inList        map[string]bool // 记录已经在列表中的用户
    isEnd         int32
    file          *os.File
    network       *network.Network
}

// 往队列中加入新用户
func (c *CrawlZhiHuUser) Add(userToken string) {
    c.lock.Lock()
    defer c.lock.Unlock()
    if c.inList[userToken] {
        return
    }
    c.userTokenList = append(c.userTokenList, userToken)
    c.inList[userToken] = true
}

// 获取一个用户,然后爬取该用户的关注者,将新的关注者加入队列
func (c *CrawlZhiHuUser) GetFollowerToken() (string, bool) {
    c.lock.RLock()
    defer c.lock.RUnlock()
    if c.followerIndex >= len(c.userTokenList) {
        atomic.AddInt32(&(c.isEnd), 1)
        return "", false
    }
    rtn := c.userTokenList[c.followerIndex]
    c.followerIndex ++
    return rtn, true
}

// 获取一个用户,然后爬取该用户详情
func (c *CrawlZhiHuUser) GetInfoToken() (string, bool) {
    c.lock.RLock()
    defer c.lock.RUnlock()
    if c.infoIndex >= len(c.userTokenList) {
        return "", false
    }
    rtn := c.userTokenList[c.infoIndex]
    c.infoIndex ++
    return rtn, true
}

// 判断是否结束
func (c *CrawlZhiHuUser) IsEnd() bool {
    a := atomic.LoadInt32(&(c.isEnd))
    if a > 0 {
        return true
    }
    return false
}

// 写入错误数据
func (c *CrawlZhiHuUser) WriteData(data []byte) {
    _, err := c.file.Write(data)
    if err != nil {
        myLog.log(err.Error())
    }
}

重新考虑使用chan,这也是我目前使用的方案,用两个 chan 替换上述的队列,followerChan 用于缓存待获取关注者的用户,并设置了较大的缓冲值。detailChan 为无缓冲的用户详情通道,每获取一个新用户,除了加入待获取关注者列表,同时放入detailChan。我们另外开了一部分协程,不断拿 detailChan 中的用户,爬取其数据。
这里我分别开了200个协程爬取新用户,200个协程用于爬取用户的详情。另外开一个协程用于每秒判断一次是否退出,当爬取数量大于目标数量,退出程序。

详细代码后续整理完会给出链接
此处应有源码链接
此处应有数据源链接

总结

其中还有许多曲折,有些忘记了,有些不值得拿出来回味,就不再啰嗦了。

大概一周时间,经过多个版本的迭代,解决了遇到的一个个问题,从无到有爬取了这么多数据。如果没有几个中间版本的迭代,一开始肯定无法想象可以达到最终的结果。
通过快速迭代,每次前进一小步,快速看到成绩,不至于被太大的困难吓倒。这也验证了我们公司的口号:迭代不息,学习不止。

通过这次数据爬取,自己对爬虫的有了新的理解。因为所有代码都是自己造,没有用到任何爬虫框架,也没有参考别人代码,也不清楚自己到底算不算入了门。
不过总归收获满满,当然懂的越多,也认识到自己更多的不足。
留下几个问题以待来日探索:

  1. 学习 Go chan 源码实现机制。
  2. 思考本次自己写的爬虫程序能不能抽象出一个通用的框架?然后比较和网上别人造的轮子有什么区别。
  3. 考虑分布式的爬虫框架该如何搭建。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 210,914评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,935评论 2 383
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,531评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,309评论 1 282
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,381评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,730评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,882评论 3 404
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,643评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,095评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,448评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,566评论 1 339
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,253评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,829评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,715评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,945评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,248评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,440评论 2 348