bigywater.tech
爬虫工程 · 48

大页面解析:一次性解析与 iterparse 的内存

一个几十上百 MB 的列表页或数据文件,整棵树解析会把内存吃到文件的十几倍。lxml 的 iterparse 可以边读边处理边丢弃,但丢弃的写法有讲究。这篇在 5、25、100 MiB 的文件上量清楚内存、耗时和需要注意的坑。

1.1 对 11–19 倍
100 MiB 文件:iterparse 清理并删前面的兄弟,与整棵树解析,各自的净增内存相对文件大小的倍数
22.63 秒
iterparse 但不清理时解析 100 MiB 文件的耗时(清理后 3.04 秒)
Â¥
文件没声明编码、iterparse 又没传 encoding 时,“¥”读出来的样子

内存与耗时

实测 · 本机执行输出
  --- 文件 5 MiB(磁盘上实际 5.0 MiB),42,762 行 <tr> ---
  方式                                                耗时      峰值内存       净增   净增 / 文件大小
  lxml.html.fromstring(整个文件读进内存再解析)         0.12 秒     118 MiB     97 MiB      19.4 倍
  lxml.html.parse(文件路径)                     0.11 秒     113 MiB     92 MiB      18.4 倍
  iterparse(tag="tr"),不清理                   0.18 秒     116 MiB     95 MiB      19.0 倍
  iterparse + el.clear()                    0.15 秒      34 MiB     13 MiB       2.7 倍
  iterparse + el.clear() + 删掉前面的兄弟          0.16 秒      29 MiB      8 MiB       1.7 倍
  selectolax Lexbor(整个文件读进内存)               0.48 秒     157 MiB    136 MiB      27.2 倍
  标准库 html.parser,按 1 MiB 分块 feed           0.87 秒      40 MiB     19 MiB       3.8 倍
  --- 文件 25 MiB(磁盘上实际 25.0 MiB),211,278 行 <tr> ---
  方式                                                耗时      峰值内存       净增   净增 / 文件大小
  lxml.html.fromstring(整个文件读进内存再解析)         0.66 秒     481 MiB    459 MiB      18.4 倍
  lxml.html.parse(文件路径)                     0.60 秒     475 MiB    454 MiB      18.2 倍
  iterparse(tag="tr"),不清理                   1.86 秒     482 MiB    461 MiB      18.4 倍
  iterparse + el.clear()                    0.91 秒      78 MiB     56 MiB       2.3 倍
  iterparse + el.clear() + 删掉前面的兄弟          0.77 秒      52 MiB     30 MiB       1.2 倍
  selectolax Lexbor(整个文件读进内存)               0.33 秒     681 MiB    660 MiB      26.4 倍
  标准库 html.parser,按 1 MiB 分块 feed           3.99 秒      43 MiB     22 MiB       0.9 倍
  --- 文件 100 MiB(磁盘上实际 100.0 MiB),839,781 行 <tr> ---
  方式                                                耗时      峰值内存       净增   净增 / 文件大小
  lxml.html.fromstring(整个文件读进内存再解析)         3.05 秒    1144 MiB   1122 MiB      11.2 倍
  lxml.html.parse(文件路径)                     2.61 秒    1670 MiB   1649 MiB      16.5 倍
  iterparse(tag="tr"),不清理                  22.63 秒    1620 MiB   1599 MiB      16.0 倍
  iterparse + el.clear()                    6.51 秒     238 MiB    217 MiB       2.2 倍
  iterparse + el.clear() + 删掉前面的兄弟          3.04 秒     135 MiB    114 MiB       1.1 倍
  selectolax Lexbor(整个文件读进内存)               1.59 秒    1061 MiB   1040 MiB      10.4 倍
  (html.parser 在 100 MiB 的文件上没有跑,太慢)

整棵树的内存大约是文件的 11–19 倍(这个数字随结构变化,我没有解释 100 MiB 文件上 fromstring 与 parse 差别为什么有 11.2 对 16.5 倍)。iterparse 如果不清理,元素都还留在树里,和整棵树一样占内存,在 100 MiB 的文件上还慢到 22.63 秒;只 el.clear() 清空了元素内容,空壳仍留在父节点里,净增 217 MiB;再把前面的兄弟从父节点里删掉才只剩 114 MiB。

其它选项

实测 · 本机执行输出
  不传 encoding(libxml2 把没声明编码的 HTML 当 Latin-1,"¥" 读成 "Â¥"):ERR ValueError invalid literal for int() with base 10: 'Â¥0'
  传 encoding="utf-8":提取 211278 行,价格合计 10140834,1.17 秒;代码要点:events=("end",)、tag="tr"、html=True,处理完一行就 clear() 并删除前面的兄弟

selectolax 的 Lexbor 解析器很快,但要把整个文件读进内存,净增是文件的 10–27 倍。标准库 html.parser 可以分块 feed,内存很低,但在这个测试里慢得多(25 MiB 要 3.99 秒)。

编码与正确性

实测 · 本机执行输出
  整棵树:211278 行,价格合计 10140834;流式:211278 行,价格合计 10140834;两者一致:True

流式解析的 iterparse 对没有声明编码的 HTML 同样把它当 Latin-1,所以要显式传 encoding="utf-8"(第 16 篇讲过类似的问题)。传了之后,流式提取的行数和价格合计与整棵树完全一致。

分步演示

大页面流式解析
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。合成的表格 HTML,每行约 100 字节,5、25、100 MiB 三个文件;每种方式在独立子进程里跑,峰值内存取 resource.getrusage 的 ru_maxrss,净增=峰值减去刚导入库之后的内存;lxml 6.1.1,selectolax 1.0.0(Lexbor 后端,pip 装进临时目录),标准库 html.parser;每项只跑一次,耗时和内存供参考
    • 没有验证:真实网页(结构更复杂、不规则)的内存倍数;嵌套更深或 不是直接重复单元时 iterparse 的清理写法;HTTP 下载流直接喂给 iterparse(边下边解析);Windows 或 Linux 上的 ru_maxrss 单位与表现;libxml2 的 push 解析器(etree.HTMLPullParser 等)与 iterparse 的区别;100 MiB 文件上 fromstring 与 parse 内存倍数不同的原因
    京ICP备2026067502号-1京公网安备11010502063487号