ichuan.net

自信打不死的心态活到老

一个爬虫项目记录

上周自己做了个小项目,爬某个网站上的数据,存入 mysql。

最开始是这么计划的:从一个入口出发(比如分类页面),多线程抓取网页,然后用 lxml 定位 dom,获取想要的部分,入库。

组件

想法是最好利用成熟的组件,这样自己写的代码少,出的问题也少。

线程池:本来公司有成熟的线程池 lib,因为是个人项目,所以没使用。pypi 上找了几个都觉得对这个项目来说有些复杂。后来自己简单实现了一个(simple_threadpool),经过实践检验,够用。

dom 解析:最开始打算用 xpath 来从 html 中取数据,但是觉得语法很晦涩。后来想起以前看到过类似用 css 选择器来查询 html 的,找了下,是 pyquery。经过实践检验,很强大,绝大部分 css 都支持。

orm:决定了数据存入 mysql,就需要个 orm 来操作数据。希望有像 django 中类似的。我比较排斥 sqlalchemy,太复杂,每次查文档都得查半天。后来找到了一个轻量级的:peewee。好用。

http 请求:直接用的 urllib2,可以很简单地设置 ua 和 超时,而且调试方便。

编码,执行

程序和数据库设计好后,就开始编码了。完毕后执行,看日志,停掉,改 bug,清数据,执行,看日志。。。

中间部分网页用了 bigpipe,数据都在放在了 js 中。我只好字串定位到 js 函数入口,把 json 参数抠出来然后反序列化,之后再做 dom 解析,用 pyquery 取数据。

几次迭代下来,代码比较稳定了,数据也在线性增长。很满意。可好景不长,几小时后日志里大量报错,排查后发现对方网站把我 ip 封了。现在连浏览器也打不开那个网站了。

第二版

被封是个原因,速度慢是另一个原因,有的地方几乎抓好几个网页才能拼凑成一份数据入库。考虑到这些,我开始重新编码。

为求效率,不能再老老实实抓网页了。我首先想到了该网站的手机版。手机版一般来说 dom 相对干净简单,好分析。倒是有手机版网页,但只是一些简单数据,不全。然后想到了 app 版本。看看 app 中用的是哪里的数据,方便的话就用它了。

我下来了它 android 版的 apk,unzip 后打开 classes.dex,用 http、api 等关键字搜果然发现了一些 url。看起来像是 api 服务器地址。用 Genymotion 建了个 android 虚拟机,安装 app,并在系统设置里把网络代理设置成本机用 tinyproxy 搭建的一个小 socks5 代理。然后在虚拟机里打开 app,随机浏览,看到 tinyproxy 日志里出现了一些请求。

用浏览器试了试,这些 api 请求居然都没做客户端验证,太省心了。立马把代码中解析网页部分换成了直接抓 api 的。这样带来了几个好处:

  1. 用 api 可以获取大量数据,比以前的方式少了很多请求,入库速度加快
  2. api 返回 json 数据,不需要 pyquery 和 lxml 解析,代码量骤减
  3. api 返回的数据字段非常全,我估计就是他们数据库里的字段了
  4. 好像为了做负载均衡,app 内置好几个 api 节点。其中某个没有做请求数限制,导致我抓了整整一天都没被封

最终按这个方式,用家里小网络一天时间把全站数据都抓下来了。

经验教训

每次编码都会有收获:

  1. urllib2 默认没有超时时间。需要在 urlopen 时手动指定。没注意到这个导致程序后期卡死,查了半天还查不出来什么原因。后来加了超时,做了重试机制,就再没遇到卡死的问题了。
  2. http 响应可能不全:遇到过几次 IncompleteRead 报错,是由于网络不稳定导致的。之前用浏览器下载文件也遇到过。明明几十M的压缩包下下来看到只有1M,还可以正常打开。把这个报错当作异常,用重试机制可以解决。
  3. 要做静态代码检查:以前只道 js 要用 jshint 检查,并没用 pylint 检查 py 文件的习惯。这次真是血淋淋的教训。跑了好几小时的程序后期忽然报个 "local variable referenced before assignment" 或 "unexpected indent" 低级错误,前功尽弃。
  4. 个人私有代码可以放 bitbucket 上:这样即使是小项目、一次性项目,也可以用 git 托管,当作个备份也划算。屌丝就用免费的 bitbucket,有钱推荐用 github pro。
  5. 能快速搭建环境:用 requirements.txt 和 virtualenv,有个简单的 README,再有个 install.sh 就更好了。方便自己和别人在不同机器上快速搭建运行环境。
  6. 日志:很有必要,最好 stdout 和 stderr 写的是不同类型日志,直接输出,执行时分别重定向到日志文件。

一次项目研发记录(mongodb, nodejs)

介绍

最近在做一个项目,过程中遇到了很多坎坷,最终经过一番斗争,大都得以解决。所以把过程分享出来,希望能帮助到别人。

这个项目的需求简单介绍就是,需要整合一大批数据,通过分析得出一个结果集可供查询。因为数据是源源不断的,所以需要增量分析。数据有主动提交过来的,也有被动的需要我们自己处理的。

最初架构

因为需要一个简单查询界面,要做个 UI;主动提交过来的数据也需要 UI 上做个 API 接口;被动的数据要写一些脚本来导入,可以使用之前实现的 API 接口;数据存储我选择了最熟悉的 mongodb,它自带 mapreduce 可做数据分析。所以最初架构如下:

  1. mongodb(单机;数据存储及查询)
  2. express(UI 展示及 API 接口)
  3. forever(nodejs 应用部署)
  4. python 导入脚本(多进程)

最初把 UI 和 mongodb 放在了一台服务器(8G 内存,8 核 cpu)上,并且在 API 入库接口被调用时,插入数据到数据表的同时,读取结果表并作更新(统计分析)。这样实际上入库时实时分析生成了结果表,mongodb 只用来存放历史数据了。

前期编码大部分时间花在了 API 的实现上:

  1. 首先定义好 API 接口(URI、参数、功能),然后用 markdown 写文档(API 接口输入输出数据格式、使用举例)。用 marked 生成文档的 html 版本供开发人员使用
  2. 使用 uuid 生成 api key 写入 keys.json,UI 在启动时载入。并且通过 fs.watchkeys.json有变化时(增删)让 UI 重新载入新 api key 列表
  3. 使用 NODE_ENV 环境变量区分开发和产品环境,然后使用不同数据库等配置
  4. 对 API 接口传过来的数据格式,使用 json schema 校验。这样可以把接口用 json 文件来定义(数据即代码)
  5. 使用 mocha 写单元测试,包括基本的启动无异常测试及各 API 接口状态测试(使用 json schema 校验输入输出格式)

入库脚本上,使用了 python 的 multiprocessing 写了一个多进程调用 API 录入接口的程序。想着可以利用多核资源。

最初遇到的问题及解决

一切搭好后,开始使用入库脚本测试入库。

首先遇到的一个问题是数据库的结果表中出现了很多重复数据。排查后发现是 UI 上实时分析操作(读取-修改-保存)在高并发时导致的,原因很简单,就是没有加锁。但加锁是不应该的,会导致入库效率降低。

为了实现不加锁,我看上了 mongodb 自带的 update operator。然后花了很大功夫把所有修改操作都写成了 update operator,这样 UI 的实时分析操作就变成了一个原子的 upsert 操作。

在接下来的测试中,发现了 mongodb 日志中经常有什么 set too large,这才发现修改后有时 upsert 操作会很大,导致无法成功执行。

最终决定 UI 只负责入库,统计分析功能用一个 mongo js 脚本来实现,离线、增量分析。

这个时候已经意识到要做性能测试了,这样才好对比优化程度。由于想测试所有 API,最初自己写了个 python 脚本来跑,后来发现一个支持自定义 lua 脚本的测试工具:wrk,改成用 wrk 跑 lua 脚本,12 线程 400 并发时可以跑到 1.3k/s 以上的入库速度,是我脚本的好几倍。

最终发现的问题是入库速度还是不理想,这时开始考虑使用 mongodb 集群。

mongodb 集群

单纯录入数据,然后离线跑脚本分析,其实还有一个选择:DSE。不用它是因为:

  1. 列数据库不好满足我 json 文档的存储需求
  2. 分析要用 hadoop,我还是对 mongodb 更熟悉些
  3. 我对 java 也不熟悉,真正调试调优时肯定要花费很大时间和精力
  4. mongodb 文档相对更全面些

mongodb 的话,要提高入库速度,需要 sharding;要提高并发查询能力及容灾能力,需要 replication。按照官方文档中的教程,sharding 节点最好是一个 replica set(RS),这样一台机器当掉了不会影响数据库正常查询。

根据现在资源上限,我计划做两个 shards,每个是个 RS,每个 RS 是两台 mongodb,这样集群需要 4 台机器。官方文档中说必须要有 3 台独立的 config server,我照做了,后来发现 config server 不占资源,于是把它们分布到了集群那 4 台机器上去。入口节点 mongos 我放在了和 UI 同台的服务器上。

后台运行了大半天,我一个失误把一台 primary 节点上 mongodb 数据文件覆盖掉了。想着有 secondary 恢复应该没事,结果半天还不恢复,一查日志发现一直在报错。最终发现,原来一个 RS 中要至少 3 台机器才能实现 failover,而我的只有 primary 和 secondary 两台。按照官方文档,其实可以再加个 Arbiter 节点,不真实存储数据,只用作故障时的仲裁节点,就可以使 primary 故障时让 secondary 成为 primary。

关于 sharding,选择时间等自增字段为 sharding key 时,由于数据分布是按范围定的,会导致大片数据写入一个节点。一般都是先把 _id 做 hashed 索引,然后用这个索引来 sharding,就可以实现数据均匀分布。

其它

关于增量统计分析,原理基本是这样:数据表有个时间字段标识改记录创建时间,我的脚本每次运行时查出当前最晚时间和上次统计分析时统计到的最晚时间,然后对这个区间的数据做分析。这就需要把时间字段做索引。但当数据量很大时,例如 5000 万,单时间字段就需要 6G 多内存占用。在集群中,_id 字段不能用作自增的字段,所以我用 redis 的 inc 指令配合 UI 实现了 _id 为自增整数,然后统计脚本根据 _id 来增量统计,不需要时间字段做索引了。

关于入库脚本,因为之前 wrk 的经验,把 python 脚本由多进程修改为多进程+多线程方式,性能有提高。

偶然遇到了 pm2,因为它可以无缝重新载入代码、内置监控、利用多核,遂把 forever 替换成了它。

经过一系列的优化,最终实际入库时,速度稳定在 900/s。

待续。。。

在 pm2 的 cluster mode 中使用 80 端口的变通方法

pm2 是一个 nodejs 进程管理器,和 forever 类似,但是相比有以下优点:

  1. 内置负载均衡,充分利用多核
  2. 不间断刷新代码
  3. 内置内存和 CPU 监控

最近做的项目中发现一台 expressjs 开发的 API 服务器其它资源都占满,但 CPU 却有大部分闲置。项目使用 forever 部署,于是想切换到 pm2 利用其余 CPU 资源。

切换过去重新启动后,发现总是报错,后来查询发现这是 pm2 的一个局限:其负载均衡是基于 nodejs 的 cluster 模块开发,不支持应用绑定 0 到 1024 间的端口。而我的 API 服务以前是 forever 绑定到 80 端口,由于 forever 使用 fork 方式,所以没有问题。

有两种解决方法,一种是继续使用 pm2,但是使用 fork 模式,这样就和 forever 一样了,但缺失了其那几大优势;另一种是我想到的变通方法:

  1. 修改程序,让其监听在非特权端口,比如 56789:

    //app.set('port', 80);
    app.set('port', 56789);
    
  2. 用 pm2 启动

  3. 用 iptables 把 80 端口的流量导向 56789 端口

    sudo iptables -t nat -A PREROUTING -p tcp --dport 80 -j REDIRECT --to-ports 56789
    

完了后访问下 80 端口,可以看到正确的页面了。问题解决。

Lock 和 Signal

在 unix 中,正在运行的程序收到 signal 后,会暂停在当前执行的代码处,转而执行注册的 signal handler,之后再返回正常执行流程。但如果程序收到信号时正在一个 lock 中呢?看下面的代码:

import signal
from threading import Condition

cond = Condition()

def func():
  print 'waiting...'
  with cond:
    cond.wait()
  print 'got notified'

def sig_handler(signum, frame):
  print 'notifing'
  with cond:
    cond.notify()

signal.signal(signal.SIGUSR1, sig_handler)
func()

首先给 SIGUSR1 信号注册了一个 handler,然后执行 func 时会输出个 waiting... 然后卡住。等我们执行 kill -SIGUSR1 <pid> 时,想象中应该会执行 sig_handler,解除 cond 的 lock,然后 func 函数中的 wait() 得以返回,输出 got notified

但实际测试发现,程序执行后,输出了 waiting...,然后无论发送什么信号都不会再有输出,只能 Ctrl+\ 结束进程。

这是为什么呢?查阅了 unix 的 wiki 后发现:

When a signal is sent, the operating system interrupts the target process's normal flow of execution to deliver the signal. Execution can be interrupted during any non-atomic instruction

原来只有程序出在非原子操作处时操作系统才能暂停程序的继续执行,转而执行 signal handler。而根据原子操作的描述,信号量、lock 等皆是原子操作。查阅了 python threading 模块代码发现,Condition.wait 内部实际调用的正是 lock:

enter image description here

这就解释了上面的例子为什么没能处理发送给它的信号了。

那如果既要用 Condition 又要用 signal 怎么办?通过查阅源码发现,Condition.wait 接受一个数字参数作为超时时间,内部是用 sleep 实现的,没有用 lock。而 unix 的 sleep 操作是在超时后或者收到信号后(参阅 man 3 sleep)返回。所以上例中代码改成如下:

import signal
from threading import Condition

cond = Condition()
got = False

def func():
  print 'waiting...'
  while not got:
    with cond:
      cond.wait(1)
  print 'got notified'

def sig_handler(signum, frame):
  global got
  print 'notifing'
  with cond:
    got = True
    cond.notify()
  print 'notified'

signal.signal(signal.SIGUSR1, sig_handler)
func()

使用了一个全局变量来记录是否成功收到了信号,然后 func 中每隔 1 秒会检测这个全局变量。这次执行后,发送 SIGUSR1 信号,程序依次输出 waiting...notifingnotifiedgot notified 后退出。和预期效果一致。

OfflineDoc: 离线文档生成器

由来

作为一个程序员,每天的工作打交道最多的除了写代码,估计就是查文档了。mongodb 有个 update modifier 又忘记格式要求了,django 的 model 都有哪些 field 类型,都得查文档。要做到高效工作,不需要背下所有知识,只需要知道什么时候该用什么东西,去哪里找需要的东西。

去哪里查文档呢?很多情况下你不会去 google,因为你知道你要找的就在官方文档里。所以遇到一门新语言、一个新库,我推荐的学习方式就是把官方文档大致看一遍,这样 以后遇到问题大概就会知道官方文档里有没有这方面的资料。

官方文档是最全面权威的文档,一般都是在线 HTML 页面方式呈现。我很喜欢浏览 HTML 版本的文档:不需要额外软件或插件,只需要一个浏览器;图文并茂,而且大部分前端的还在文档中自带 demo。我经常沉浸在 python、django、angular.js、pymongo 的在线文档里,收获颇丰。

看起来很好。但是,在线文档有几个致命的缺点:

  1. 离线无法使用。这样你在无网络的环境下就没法查文档写代码了。
  2. 龟速网络导致查文档时间大部分浪费在等待网络 I/O 上了。不可忍。
  3. 大部分只提供最新版本文档,不提供历史文档。维护旧代码时查文档很痛苦。

由此出现了一些专门给程序员看的文档服务,如 http://devdocs.io/https://readthedocs.org/ 。似乎很不错,但是,这两个服务也有缺点:

devdocs.io:

  1. 按自己格式排版了文档。官方文档的排版和内容才是王道。
  2. 支持项目少,版本旧,增加项目困难。由于有一套排版,更新岂能不困难。

readthedocs.org:

  1. 只支持有 sphinx 编写的文档的项目

这两者都有一个致命的缺点:无法灵活添加自有项目(例如公司内部项目);无法添加诸如 amcharts 本身根本不提供文档生成方法的项目。

所以才有了 OfflineDoc 的诞生。OfflineDoc 的目标是用简单的方式去生成、定期更新离线文档,并解决上述难题。

介绍

OfflineDoc(https://github.com/ichuan/OfflineDoc) 支持的项目类型有:

  1. Git 项目
  2. SVN 项目
  3. 其它有 HTML 文档的任意项目

对 Git 和 SVN 项目,OfflineDoc 会取出源码,如果该项目有自身的文档生成方法(Sphinx、jekyl、make、Rake 等),会利用相应方式生成和官方一样的 HTML 文档;对其余不提供文档生成方法的流氓项目,只要其有在线 HTML 文档,就会使用 wget 镜像大法全部抓下来。

使用

首先安装(推荐使用 virtualenv):

mkdir -p ~/envs
virtualenv ~/envs/od
source ~/envs/od/bin/activate
pip install offlinedoc

最终显示 "Successfully installed ..." 之类表示正确安装了。由于内置了二十多个常用项目,在生成它们的文档前需要安装一些库(以 ubuntu 为例):

  1. nodejs:sudo apt-get install nodejs。或者直接去官网下载最新版本。
  2. grunt 和 bower:sudo npm install -g grunt-cli && sudo npm install bower -g
  3. 编译依赖包:sudo apt-get install build-essential python-dev ruby1.9.1-dev git-core default-jre
  4. jekyll:sudo gem install jekyll

安装好之后,在 shell 下输入 od.py 回车看看:

OfflineDoc: Offline documents generating tool
Usage: od.py <action> [arg] ...
  od.py new <dir>                        - create a project and place data into <dir>
  od.py update <dir> [module [version]]  - update a project in <dir> (optional specific module, version)
  od.py index <dir>                      - rebuild index html files in <dir>
  od.py serve <dir>                      - simple http server for <dir> (alias python -m SimpleHTTPServer)
  od.py clear <dir>                      - clear all data in <dir> (alias rm -rf <dir>)
  od.py list [dir]                       - list all modules (optional with custom modules in a dir)
  od.py auth <dir>                       - setup github auth for a project
  od.py version                          - current version
  od.py help                             - prints this info

Turn on debug mode:
  ODDEBUG=1 od.py <action> [arg] ...

普遍使用流程是这样:

  1. 选择一个大分区目录用来存放离线文档,比如 /var/data/od,则使用 od.py new /var/data/od 初始化之。会显示一个 nginx 配置示例,把这块加入 nginx 配置后,就可以网页浏览生成好的离线文档了。当然需要安装 nginx,不过 OfflineDoc 也有自带一个小 http 服务器供文档浏览。
  2. github 认证:od.py auth /var/data/od。由于部分内置项目托管在 github 上,OfflineDoc 有用到 github 的 api 接口,所以需要在该数据目录中保存一个 github 账号信息。可以花一两分钟去创建一个 github 账号。
  3. 开始更新:od.py update /var/data/od。这会把内置的二十多个项目的几乎所有版本的离线文档都生成出来,当然是比较花时间的。可以选择性生成。比如 od.py update /var/data/od bootstrap 会只生成 bootstrap 所有版本的文档,执行 od.py update /var/data/od bootstrap 2.3.2 只会生成 bootstrap 2.3.2 版本的文档。
  4. 生成索引:od.py index /var/data/od。这步不是必须的。当你单独更新了某个项目的文档时,浏览器里没有看到变化,可以执行此命令重新生成所有项目的索引页面。
  5. 浏览文档:od.py serve /var/data/od。这会在本机 8000 端口启动一个 http 服务,访问即可看到生成好的文档。
  6. 查看目前已有项目列表及最新版本:od.py list /var/data/od 不加最后一个参数会只显示内置项目列表,加上会显示在那个数据目录中的版本号,而且该数据目录中的自定义项目。

如果遇到问题,可以把 ODDEBUG=1 加在 od.py 命令前面,例如:ODDEBUG=1 od.py update /var/data/od,这样会输出调试信息,方便排查。错误信息可以贴到 https://github.com/ichuan/OfflineDoc/issues

可以把更新命令放入 crontab,每日更新,这样就能时刻保持文档最新。

这里有个更新完毕后的站点供参考:http://doc.ichuan.net/

扩展

除了这些,OfflineDoc 还支持自定义项目和索引页面主题自定义。

关于自定义项目,可以参考生成的数据目录 /var/data/od 中的 module 目录,其中有四个示例,分别对应 git、svn、单 html 页面个需要 wget 镜像的项目。自定义模块的文件名以下划线开头的话是会被 OfflineDoc 忽略的。

关于自定义主题,可以参考 OfflineDoc 源码,同样在数据目录下的 theme 目录中添加自己的 jinja2 模板文件,OfflineDoc 生成索引时会优先使用此目录的模板文件,而非系统内置。