fix: cninfo 抓取限制浏览器并发,修复整机冻结 (06:00 任务压穿内存)

根因: _render_page 每次新建完整 headless Chromium(实测 857MB/实例),
crawl_watchlist 对 15 只股票全量并发 → 峰值需求 ≈12.5GB ≫ 7.9GB RAM,
2026-09-06/08/10 三次 06:0x 整机冻结(load 65 → 看门狗复位)。

- crawler/cninfo.py: 新增 MAX_RENDER_CONCURRENCY(默认 2,env 可配)
  + 模块级 _render_sem 信号量,渲染全程持槽;顺带清理 3 个死导入
- tests/test_cninfo.py: 新增 8 个(并发上限/串行/异常释放/env 解析)
- 实测: 峰值 1796MB / 22 进程 / load 2.75 / 耗时 441s(上限 900s)
- 全量 291 passed
This commit is contained in:
2026-09-10 20:54:30 +08:00
parent 6dede790c6
commit 2eaea2ee81
4 changed files with 251 additions and 7 deletions
+39 -6
View File
@@ -19,10 +19,8 @@ import hashlib
import json
import os
import re
import time
from datetime import date, datetime, timedelta
from datetime import date, timedelta
from pathlib import Path
from typing import Any
import requests
from bs4 import BeautifulSoup
@@ -48,6 +46,34 @@ MAX_IRM_ITEMS = 20
# 请求间隔(秒)
REQUEST_DELAY = 0.5
# 浏览器渲染并发上限。
# 每次 _render_page 都会启动一个完整的 headless Chromium(实测约 850MB / 10 进程),
# 而 watchlist 是 15 只股票全量并发 → 峰值需求 ≈12.5GB,远超本机 7.9GB RAM,
# 靠 zram swap 侥幸时好时坏,一旦压穿即整机冻结(2026-09-06/08/10 早崩根因)。
# 限流到 2 后峰值 ≈1.7GB。可用 env CNINFO_RENDER_CONCURRENCY 覆盖。
DEFAULT_RENDER_CONCURRENCY = 2
def _resolve_render_concurrency() -> int:
"""解析渲染并发上限(env CNINFO_RENDER_CONCURRENCY 可覆盖,非法值回退默认)。"""
raw = os.environ.get("CNINFO_RENDER_CONCURRENCY", "")
if not raw.strip():
return DEFAULT_RENDER_CONCURRENCY
try:
return max(1, int(raw))
except ValueError:
logger.warning(
"CNINFO_RENDER_CONCURRENCY 非法({!r}),回退默认 {}",
raw, DEFAULT_RENDER_CONCURRENCY,
)
return DEFAULT_RENDER_CONCURRENCY
MAX_RENDER_CONCURRENCY = _resolve_render_concurrency()
# 渲染信号量:限制同时存活的 Chromium 实例数(防内存压穿)
_render_sem = asyncio.Semaphore(MAX_RENDER_CONCURRENCY)
# --------------------------------------------------------------------------- #
# 工具函数
@@ -91,7 +117,11 @@ def _build_stock_url(code: str, org_id: str) -> str:
async def _render_page(url: str, timeout_ms: int = 60000,
delay_ms: int = 20) -> str:
"""用 Crawl4AI 渲染 SPA 页面,返回 HTML 字符串。"""
"""用 Crawl4AI 渲染 SPA 页面,返回 HTML 字符串。
通过模块级信号量 _render_sem 限制并发:每次渲染都会启动一个完整的
headless Chromium(约 850MB),必须限流以免同时驻留过多浏览器压穿内存。
"""
from crawl4ai import AsyncWebCrawler, BrowserConfig, CacheMode, CrawlerRunConfig
bconf = BrowserConfig(headless=True, verbose=False)
@@ -100,8 +130,11 @@ async def _render_page(url: str, timeout_ms: int = 60000,
page_timeout=timeout_ms,
delay_before_return_html=delay_ms,
)
async with AsyncWebCrawler(config=bconf) as c:
result = await c.arun(url=url, config=rconf)
# 限流:等待空闲渲染槽位(槽位内包含浏览器启动→渲染→关闭全过程)
async with _render_sem:
logger.debug("获得渲染槽位(并发上限 {}): {}", MAX_RENDER_CONCURRENCY, url[:70])
async with AsyncWebCrawler(config=bconf) as c:
result = await c.arun(url=url, config=rconf)
return getattr(result, "html", "") or ""