蚁阅性能优化记录
source link: https://blog.guyskk.com/notes/%E8%9A%81%E9%98%85%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96%E8%AE%B0%E5%BD%95
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
蚁阅性能优化记录
2020年, 4月5日蚁阅 是一个 RSS 阅读服务,使用 Python 实现, 已经上线运行近一年了。
前期主要在做功能开发,没有太多时间去研究性能问题,最近终于有时间做了一次性能优化。
这是优化前的状况:
CONTAINER ID NAME CPU % MEM USAGE / LIMIT MEM % NET I/O BLOCK I/O PIDS
c27c40739bbe rssant-web 0.00% 1.887MiB / 200MiB 0.94% 360MB / 365MB 13.2MB / 4.1kB 2
69beb84e30cc rssant-async-api 0.04% 86.95MiB / 750MiB 11.59% 160MB / 150MB 913kB / 0B 4
a1cade903c76 rssant-api 0.03% 385.5MiB / 500MiB 77.11% 214MB / 209MB 13MB / 0B 504
1dd2be833b3b rssant-worker-2 78.59% 1227.8MiB / 1.465GiB 61.85% 24.3GB / 12.7GB 24.8MB / 0B 33
924b7a6cec7d rssant-worker-1 75.07% 1256.8MiB / 1.465GiB 63.79% 24.2GB / 12.7GB 1.36MB / 0B 33
4caec7b41dbf rssant-harbor-2 13.60% 465.2MiB / 750MiB 62.03% 56.1GB / 7.56GB 102kB / 0B 33
ede36c43decd rssant-harbor-1 15.77% 437MiB / 750MiB 58.27% 56.8GB / 7.62GB 171MB / 20.5kB 33
ea467498ede5 rssant-scheduler 2.57% 87.75MiB / 500MiB 17.55% 328MB / 2.78GB 5.85MB / 0B 33
3a617ac14ad3 rssant-postgres 1.18% 39.31MiB / 500MiB 7.86% 19.8GB / 377GB 2.47TB / 89.2GB 24
6cb8c642ad1e rssant-prometheus 0.05% 47.02MiB / 200MiB 23.51% 559GB / 6.07GB 190GB / 19.9GB 11
3ea7a652f116 rssant-grafana 0.03% 15.09MiB / 200MiB 7.54% 289MB / 187MB 812MB / 515MB 15
整个系统最繁忙的是 rssant-worker 这个组件,即耗CPU又吃内存,运行几周后偶尔还会OOM,本次优化的就是这个组件。 这个性能问题由来已久,从最早基于 Celery 的实现到现在自己做的 Actor 系统都有一样的问题。
相关业务和组件
各组件功能如下:
- rssant-web: 流量入口,使用Nginx承载静态文件和转发API请求。
- rssant-api: 后端API,基本是Django做增删改查。
- rssant-async-api: 图片代理API,使用aiohttp实现。
- rssant-scheduler: 服务发现和注册,以及调度定时任务。
- rssant-harbor: 执行定时任务,以及将RSS解析结果入库。
- rssant-worker: 爬取和解析RSS订阅,并把结果发送给 rssant-harbor。
用户添加订阅之后,订阅信息会存到数据库,然后整个系统会定期检查并更新订阅。流程如下:
- scheduler 有个定时器,每分钟触发一次,触发后发消息让 harbor 检查订阅。
- harbor 收到消息后,去数据库中查询更新时间距现在超过1小时的订阅,把这些订阅的状态设为【更新中】, 然后将订阅的 ID 和 URL 发送给 worker,让 worker 去执行检查。
- worker 收到消息,把订阅内容抓下来,解析得到订阅基本信息和所有文章,把结果发给 harbor。
- harbor 收到结果,将每篇文章的链接及哈希值和数据库记录比对,将有变化的文章入库。
Worker 内部的流程:
- 使用 requests 请求订阅内容,会带上 ETag, Last-Modified 请求头以利用 HTTP 协商缓存。
- 订阅抓取之后,使用 feedparser 解析,得到初步结果。
- 对结果中的每篇文章内容,用 lxml 做清洗和图片链接替换,用 readability 提取摘要。
- 得到干净的内容之后,发送给 harbor 进行入库。
- 对一些只有摘要的文章,worker 还会用 aiohttp 重新抓取全文,结果也用 lxml 和 readability 处理。
- 对于文章中的图片链接,worker 会用 aiohttp 批量检测防盗链,如有防盗链则做链接替换。
内存性能优化
我很早就关注到内存的问题,也尝试使用 tracemalloc 和 objgraph 等工具去分析,没有太大收获。 结果类似下面这样,有个 FeedParserDict
类型有时增长很多,但很快会被 GC 掉,其他都是内置类型:
>>> objgraph.show_most_common_types(shortnames=False)
builtins.function 27203
builtins.dict 22782
builtins.tuple 16488
builtins.weakref 6806
builtins.list 6505
builtins.cell 5979
builtins.type 3749
builtins.getset_descriptor 3183
builtins.builtin_function_or_method 2524
builtins.property 1852
>>> objgraph.show_growth(shortnames=False)
builtins.list 8170 +967
builtins.dict 24400 +625
builtins.tuple 16926 +213
builtins.method 1904 +140
feedparser.util.FeedParserDict 208 +139
builtins.Context 757 +98
builtins.frame 198 +87
builtins.coroutine 109 +75
collections.deque 1030 +60
builtins.weakref 6955 +50
Worker 中用到了很多库,首先就怀疑 feedparser
有内存泄漏,但我没有证据,本地测试显示内存都回收干净了。
然后将其他模块中也频繁使用的库排除掉,主要有 Django 和 Actor 系统,requests 和 aiohttp 在其他模块也有用到,但使用没那么频繁。而底层的 ssl 因为出现过比较多内存泄漏问题,所有对它特别关注。
最终确定了 6 个嫌疑人: feedparser, lxml, readability, requests, aiohttp, ssl
Worker 中也有不少代码,这些代码也有嫌疑,因此我并不直接测试三方库,而是测 Worker 中与三方库相关的代码。如果测出问题,再排查是使用姿势不对还是三方库自身有问题。
测试步骤:
- 从蚁阅数据库导出 10K 个订阅链接,然后开线程池把内容下载保存到磁盘。
- 开线程池测试 requests,aiohttp 分别下载这些订阅。每秒钟记录一次内存占用。
- 开线程池测试 feedparser,lxml,readability,用本地磁盘的订阅内容作为输入。每秒钟记录一次内存占用。
测试跑完之后,用 Pandas 和 Matplotlib 分析内存占用,结果如下图(横轴时间,纵轴内存占用MB):
先看 3 个订阅解析相关的库,feedparser 有很多毛刺,但整体还是稳定的。
再看网络请求的库,requests 内存占用非常大,简直要上天。
检查一番代码以及 Google 搜了一圈之后,我将用到的 Session 和 Response 对象用完都手动 close 掉,结果如下。
内存大幅下降,而且也保持稳定了。看来是 Requests 这个库使用姿势不太对。
修复代码,上线。我期待着内存明显下降,然而并没有,监控图表显示内存只降了一点。
我仔细排查代码,想到搜索 Celery Memory Leak 时看到的文章提到:
Python不会主动把内存还给操作系统,如果程序执行使用了大量内存,待程序空闲之后 Python 会保留这部分释放的内存,预留给下次使用,所以程序的内存占用就是峰值时的内存占用。
这个表述不完全准确,有些情况 Python 还是会把内存还给操作系统, 这涉及到 Python 的内存管理和内存碎片问题,比较复杂。
对于 Celery,它有个 CELERYD_MAX_TASKS_PER_CHILD
配置项,让程序处理完指定数量的任务后就重启, 这个选项就是为了解决内存占用太大,但实际并没有内存泄漏,只是 Python 预留了内存导致看起来像内存泄漏。
所以要降低内存占用,就需要降低峰值时的内存占用。
我排查代码之后,做了一个优化,在一个大函数里提前释放一些内存:
del parsed, response, parsed_feed # release memory
... # 下面是一些耗时操作
上线之后,内存又下降了一些,但降的不多。
总结下来,降低内存占用有两个要点:
- 减少内存分配,对于大文本,字节流等要避免复制,尽可能 Zero-Copy。
- 内存用完尽快回收,缩短对象的生命周期。
蚁阅中大部分内存都在三方库中分配和复制,目前能做的优化不多。
CPU性能优化
Worker 的 CPU 占用居高不下,主要是解析和处理订阅内容比较耗 CPU。
测试步骤:
- 使用 feedparser 解析 10K 个订阅,统计 feedparser 执行时间 (feedparser)。
- 使用 lxml 和 readability 处理文章内容,统计执行时间 (parse_found)。
测试结果如下,length
为订阅的大小,单位KB,另两列为执行时间,单位毫秒:
>>> print(df.quantile([0.5, 0.9, 0.99, 0.999]))
length feedparser parse_found
0.500 39.429199 80.527544 39.163351
0.900 300.510742 599.925160 288.799524
0.990 1409.133887 3455.563748 1493.881977
0.999 4687.004390 9793.667774 4234.304533
其中 feedparser 占用时间最多,lxml + readability 时间也不少。 订阅体积越大,执行时间也越长。
接着分析 feedparser 的性能,用 pyinstrument 得到一个很直观的图,时间主要花在 resolve_relative_uris
和 _sanitize_html
这两个函数上。
经过一番搜索,发现 feedparser 有参数可以禁用这两个函数。
feedparser.RESOLVE_RELATIVE_URIS = False
feedparser.SANITIZE_HTML = False
因为链接处理和 HTML 清洗在 parse_found 中也会做一遍(feedparser 做的不够好), 所以不需要 feedparser 做处理。
改完参数再测一遍,执行时间明显下降:
>>> print(df.quantile([0.5, 0.9, 0.99, 0.999]))
length feedparser parse_found
0.500 39.273438 35.867929 37.057638
0.900 300.033984 135.883141 283.440208
0.990 1401.178672 915.256548 1466.076121
0.999 4695.528279 2557.008664 4030.596158
接下来分析 parse_found 的性能,这个函数会对每篇文章都做 HTML 清洗,链接处理,以及文本摘要。 执行时间 = 文章数量 * 每篇文章处理时间,文章数量在 10 到 100 之间,处理基本是调用 lxml API。
分析发现 lxml 的 fromstring
和 tostring
方法在每一步清洗都会调用,耗时较多。 实际上 fromstring
只需要调用一次,得到 DOM 对象,然后各个清洗步骤都对 DOM 对象操作, 最后用 tostring
将 DOM 对象转成字符串。
另一方面,因为订阅的大部分文章都已经解析过,存在蚁阅数据库了,每次只会有少量文章需要更新。 如果比较哈希值,可以快速跳过没有变化的文章,大幅降低处理时间。
粗略估计,做完这两个优化可以把 parse_found P99 时间降到 100~200ms,而 feedparser 仍然会是瓶颈。
是否可以把 feedparser P99 时间也降到 100~200ms 呢?
花了一些时间把 feedparser 的代码过了一遍,感觉是很难:
- feedparser 底层使用 sgmllib 和 xml.sax,通过回调函数(各种 handler)处理 XML 标签。
- feedparser 是纯 Python 实现,而 sgmllib 是 C 拓展,这意味着需要频繁在 Python 和 C 之间状态切换。
- feedparser 和 sgmllib 都很老了,包含很多兼容性代码,对 Python 3 也没有做优化。
如果用 Golang 会怎么样呢?
我找到了 gofeed,然后选了一个 5MB 的超大订阅测试一下, 发现只要 100 多毫秒! 意味着 P999 是 100 多毫秒,P99 就更低了!
PS: 目前正在学习 Golang,等学好了再来更新!
业务逻辑优化
到目前为止,内存占用降低约 10%,CPU 处理时间降低约 50%。接下来考虑在业务上做优化了。
在前面的分析中可以看到,极少数(1%)订阅消耗了大量资源,可以降低这些超大订阅的检查更新频率。
>>> print(df.quantile([0.5, 0.9, 0.99, 0.999]))
length feedparser parse_found
0.500 39.273438 35.867929 37.057638
0.900 300.033984 135.883141 283.440208
0.990 1401.178672 915.256548 1466.076121
0.999 4695.528279 2557.008664 4030.596158
另外对停更的订阅,更新不频繁的订阅,没人看的订阅,都可以降低检查更新频率。
这就是蚁阅的订阅冻结功能,动态调整检查更新频率,下面是目前的规则:
+------------+----------+------------+----------+
| 冻结时间 | 300k以下 | 300k~1500k | 1500k以上 |
+------------+----------+------------+----------+
| 资讯新闻 | 1H | 1H | 3H |
| 周更博客 | 1H | 2H | 9H |
| 月更博客 | 4H | 8H | 9H |
+------------+----------+------------+----------+
CPU性能优化:订阅处理时间缩短了一半。
业务优化:需要检查更新的订阅少了一半。
意外收获:因为业务优化和CPU性能优化,内存分配更少,回收更快,内存占用也大幅下降了。
Grafana 的图表,大约 20:00 上线的:
阿里云的监控图表:
注:凌晨4点的峰值是因为在定时做数据库备份。
(全文完)
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK