gunhawk

gunhawk

Frontend Developer

Coding is part of my life, 加藤恵は大好き=。=

解决mikan爬虫在512M的vps上内存溢出的二三事

作者: gunhawk时间: 2017-12-25python

关于mikan爬虫策略

先简单描述下mikan爬虫的执行过程:

  1. 扫描首页, 收集到番组的年份和对应的季度
  2. 获取某个年份某个季度的所有番组
  3. 获取每个番组下的字幕组, 并获取相应字幕组包含的作品
  4. 最终按年份输出其对应的所有数据

内存溢出的原因

最初始的方案是把收集的数据按年份key键存储到一个全局字典里, 每个年份收集完毕后输出一个文件, 输出后手动释放存储年份的key. 这种方案在单线程下没有问题, 但是用了多线程后内存不到一会就直接飚满爆掉了. 因此本质上应该是内存存储的对象太多, 导致溢出

按年-季碎片化

参考了一些python前辈的建议, 应该在年份文件输出前碎片化切割若干个文件, 最后再通过这些碎片化文件组合起来. 于是我按年份季度优先输出${year}__${season}.json, 最后通过标识符yearseason组装数据. 这种方案本来是运行良好的, 直到我更换了爬虫策略, 字幕组作品的数据量一下子就更多了. 据我观察, 这种方案只要爬取完一个年份后, 即使手动释放也只能让内存暂时缓冲一下, 下一个年份的数据一来, 剩余内存就慢慢被蚕食殆尽了

按年-季-星期碎片化

自从换了爬虫策略后, 年-季碎片化的方案一直优化无果, 于是我也只能继续从年-季里继续碎片化了

def find_type_container(self, soup, year, season, season_data):
    for key, value in self.type_mapper.items():
        attrs = {}
        attrs['data-dayofweek'] = key
        container = soup.find(attrs=attrs)
        season_data[value] = []
        if container:
            self.find_bangumi_collections(container, season_data[value])
        self.output_temp_file(season_data, year, season, value)

一个季度里包含星期一 ~ 星期日 + 剧场版 + ova, 每爬完一个就写一个临时文件(${year}__${season}__${day}.json), 在所有数据都收集完毕后, 最后一次性输出所有年份的数据

def output(self):
    files = pydash.filter_(os.listdir(self.temp_path), lambda el: el.find('.json') > -1)
    year_group = pydash.arrays.sorted_uniq(pydash.map_(files, lambda el: el.split('__')[0]))
    for year in year_group:
        season_arr = []
        year_files = pydash.filter_(files, lambda el: el.find(year) > -1)
        season_group = pydash.arrays.sorted_uniq(pydash.map_(year_files, lambda el: el.split('__')[1]))
        for season in season_group:
            day_arr = []
            files_read = pydash.filter_(year_files, lambda el: el.find(season) > -1)
            for file_read in files_read:
                with open(self.temp_path + file_read, 'r') as f:
                    day = file_read.split('__')[2].replace('.json' , '')
                    day_arr.append('"' + day + '":' + f.read())
            season_arr.append('"' + season + '":{' + ','.join(day_arr) + '}') 
        with open(spider_path() + 'mikan__' + year + '.json', 'w') as f:
            f.write('{' + ','.join(season_arr) + '}')
            f.flush()

我在年份文件输出的前是直接按字符串拼接, 应该能节省不少内存

这种方案临时文件挺多, 但是内存控制得非常好, 内存占用只在40% ~ 50%之间. 总算是能完整跑完了

临时文件

nodejs的奇怪现象

每次爬虫执行完毕后, 我会用node来读取年份文件来对比并更新数据源, 过滤出更新的番组和字幕组. 工程目录如下

工程目录

dist-builder.js主流程如下

fs.readdir(path.join(__dirname), (err, files) => {
  if (err) return writeLog(`Read dir error: ${err.message}`)
  files = files.filter(file => file.indexOf('.json') !== -1)
  const tasks = files.map((file, i) => {
    return new Promise((resolve, reject) => {
      readMikan(file)
        .then(result => Promise.all([writeBangumi.apply(null, result), writeSub.apply(null, result)]))
        .then(result => {
          // result[0] -> 更新的番组
          // result[1] -> 更新的字幕组 
          resolve(result)
        })
        .catch(err => { reject(err) })
    })
  })
  Promise.all(tasks)
    .then(result => {
      const socket = net.connect({ port: config.socketPort }, () => {
        writeLog('connected socket server!')
        socket.write(JSON.stringify({
          bangumi: _.flattenDeep(result.map(elem => elem[0])),
          torrent: _.flattenDeep(result.map(elem => elem[1]))
        }))
        socket.destroy()
      })
    })
    .catch(err => {
      writeLog(err.message)
    })
})

爬虫执行完毕会调用dist-builder.js, 结果如下

输出异常

有些文件竟然是0 byte!! 但是如果我是让python直接调用或是命令行执行dist-builder.js, 是能正常输出的. What the hell????????

我发现node在写入文件的后竟然不会执行回调函数, 但事实上ta是写入了文件了. 由于没有执行回调函数, 我后续的promise铁定也不回执行了. 但是整个过程是没有报错的, 太奇怪了! 当且仅当在这台vps上走爬虫的case才会出现!! 我快要放弃治疗了=. =#########

最后我尝试了用同步的方法来实现上面的那段逻辑

const files = fs.readdirSync(path.join(__dirname)).filter(file => file.indexOf('.json') > -1)
const queue = (function (files) {
  const _count = files.length
  let _updates = {
    bangumi: [],
    torrent: []
  }
  let _index = 0
  return function () {
    if (_index >= _count) {
      const socket = net.connect({ port: config.socketPort }, () => {
        writeLog('connected socket server!')
        socket.write(JSON.stringify(_updates))
        socket.destroy()
      })
    } else {
      readMikan(files[_index])
        .then(result => Promise.all([writeBangumi.apply(null, result), writeSub.apply(null, result)]))
        .then(result => {
          // result[0] -> 更新的番组
          // result[1] -> 更新的字幕组 
          _updates.bangumi = _updates.bangumi.concat(result[0])
          _updates.torrent = _updates.torrent.concat(result[1])
          queue(++_index)
        })
        .catch(err => { writeLog(err.message) })
    }
  }
})(files)
queue()

总算能成功输出了. 那么问题来了, 到底是为什么呢???? 来一个TODO先记录着

shadowsocks服务器被kill

这个跟本文无关, 只是爬虫服务运行后, ssserver进程可能会被kill掉, 这里mark一下, 日后解决233333