前言
本文是偏技术类文章,非计算机相关专业可直接看这篇数据分析
由于近期工作上比较闲,看了一些计算机网络,然后看完一本《计算机操作系统》,对计算机有了更进一步的了解(都怪大学没好好学)。因此想着搞个小项目练练手,想起之前看过“想练手就去写爬虫”的话,便决定写个爬虫程序。这便是本次数据爬取的动机。
本文具有时效性,数据抓取日期为 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字典,用于去重
这里有两种方案
- 使用Go语言自带的 chan 管道作为协程间数据传输工具。
- 自己维护一个数据结构,该结构包含一个先进先出列表以及两个指针(如上图)
这里我两个方案都试过,而且都遇到了问题。使用方案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个协程用于爬取用户的详情。另外开一个协程用于每秒判断一次是否退出,当爬取数量大于目标数量,退出程序。
详细代码后续整理完会给出链接
此处应有源码链接
此处应有数据源链接
总结
其中还有许多曲折,有些忘记了,有些不值得拿出来回味,就不再啰嗦了。
大概一周时间,经过多个版本的迭代,解决了遇到的一个个问题,从无到有爬取了这么多数据。如果没有几个中间版本的迭代,一开始肯定无法想象可以达到最终的结果。
通过快速迭代,每次前进一小步,快速看到成绩,不至于被太大的困难吓倒。这也验证了我们公司的口号:迭代不息,学习不止。
通过这次数据爬取,自己对爬虫的有了新的理解。因为所有代码都是自己造,没有用到任何爬虫框架,也没有参考别人代码,也不清楚自己到底算不算入了门。
不过总归收获满满,当然懂的越多,也认识到自己更多的不足。
留下几个问题以待来日探索:
- 学习 Go chan 源码实现机制。
- 思考本次自己写的爬虫程序能不能抽象出一个通用的框架?然后比较和网上别人造的轮子有什么区别。
- 考虑分布式的爬虫框架该如何搭建。