2026年10月7日時点で、ShifterはOxylabsやBright Dataを上回る、最大規模の米国ライブIPプールを保有しており、しかもコストはその一部で済みます。Shifterは現在、稼働中の米国IPの中で最大のプールを保有しています

ベンチマークを見る

スクレイピング

あなたのスクレイパーが遅いのは、おそらくプロキシのせいではない

レジデンシャルプロキシ経由で動作するスクレイパーを、プロキシに手を加えることなく7.5倍高速化しました。時間が本当にどこで失われているかを見つけ、修正する方法を紹介します。

Matt Brown

2026年10月8日 · 4 分で読める

「プロキシが遅い」というのは、顧客からよく届くメッセージのひとつです。それが事実であることもあります。しかし、実際にスクレイパーを一緒に確認してみると、多くの場合、時間はスクレイパー自身が行っていることに費やされています。ページごとに新しい接続を開く、遅いライブラリでパースする、一度に1件ずつリクエストを待つ、といったことです。

それがどれほど重要かを示すために、単純なスクレイパーを用意し、レジデンシャルプロキシを通して実行しながら、一度にひとつずつ変更を加えていきました。同じ120ページの処理が298秒から40秒へと短縮され、プロキシ自体には何も変更を加えていないにもかかわらず、7.5倍の高速化となりました。このガイドでは、そのテストの内容、遅い部分を見つけるために使ったツール、そして自分のスクレイパーを診断するためのチェックリストを紹介します。

Key takeaways

  • 何かを変える前に計測する。 フェッチ、パース、書き込みにかかる時間を記録するステージタイマーがあれば、数分で時間がどこに使われているかがわかります。
  • 接続を再利用する。 プロキシ経由で毎回新しい接続を開くと、テストではフェッチ1回あたりの時間が3倍以上になりました。1つのSessionでこれは解決しました。
  • パースはフェッチと同じくらいコストがかかることがある。 HTMLパーサーを切り替えたところ、パース時間が半分以下になりました。
  • 並行処理は最大の効果をもたらすが、次のボトルネックを露呈させる。 ワーカーを8つにすると実行時間が半分になり、CPU負荷の高いパース処理が新たな限界になったことが明らかになりました。
  • 遅いものすべてを修正する価値があるわけではない。 データベースへの書き込みは298秒中0.3秒だったため、バッチ化しても何も変わりませんでした。

The test

ヨーロッパにある1台のマシンから、Shifterのレジデンシャルプロキシ(米国の出口)を通じて、英語版Wikipediaの記事120本を収集しました。Wikipediaの記事は大きなページで、HTMLが数百キロバイトに及ぶことも珍しくありません。各ページについて、スクレイパーはHTMLを取得し、Beautiful Soupでパースしてタイトルを読み取り、リンク数を数え、SQLiteに1行を書き込みます。

同じ処理を5回実行し、毎回ひとつずつ変更を加えました。スクレイパーはWikipediaが要求する説明的なUser-Agentを名乗り(Wikipediaのサーバーはデフォルトのpython User-Agentを拒否します)、リクエストが失敗した場合は最大2回まで再試行しました。

VersionChangeTotal timeFetch per pageParse per page
1. BaselineNew connection per request, html.parser, one at a time298 s1,906 ms577 ms
2. Reuse connectionsOne requests.Session125 s568 ms473 ms
3. Faster parserlxml instead of html.parser76 s426 ms205 ms
4. Concurrency8 worker threads40 s551 ms2,024 ms
5. Batch writesOne transaction instead of one per row42 s579 ms2,141 ms

レジデンシャル出口経由のフェッチ時間は、実行ごとに100ミリ秒程度のばらつきがありますが、上記の差はそれよりもはるかに大きいものです。

Step one: measure where the time goes

このツールは、各ステージの経過時間(wall-clock time)をスレッド間でも安全に合計する、小さなタイマーです。

import time
from collections import defaultdict
from contextlib import contextmanager
from threading import Lock


class StageTimer:
    """Adds up wall-clock time per pipeline stage, safely across threads."""

    def __init__(self):
        self.totals = defaultdict(float)
        self.counts = defaultdict(int)
        self._lock = Lock()

    @contextmanager
    def stage(self, name):
        start = time.perf_counter()
        try:
            yield
        finally:
            elapsed = time.perf_counter() - start
            with self._lock:
                self.totals[name] += elapsed
                self.counts[name] += 1

    def report(self, wall_seconds):
        busy = sum(self.totals.values())
        print(f"wall {wall_seconds:.1f}s, stage time {busy:.1f}s")
        for name, total in sorted(self.totals.items(), key=lambda kv: -kv[1]):
            print(f"{name:>8}: {total:7.1f}s  {100 * total / busy:5.1f}%  "
                  f"{1000 * total / self.counts[name]:7.1f} ms per call")

スクレイパーの各ステージをwith timer.stage("fetch"):、with timer.stage("parse"):のように囲み、最後にレポートを出力します。実行コストはほとんどかからず、「スクレイパーが遅い」という曖昧な話を具体的な答えに変えてくれます。

ここに、タイマーを組み込んだ完全なテストスクレイパーを示します。run()関数は、変更した4つの設定を引数として受け取ります。

import os
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor

import requests
from bs4 import BeautifulSoup

from stagetimer import StageTimer
from urls import URLS

user, password = os.environ["PROXY_USER"], os.environ["PROXY_PASS"]
PROXY = f"http://{user}-country-us:{password}@p.shifter.io:443"
PROXIES = {"http": PROXY, "https": PROXY}
HEADERS = {"User-Agent": "ExampleScraper/1.0 (+https://example.com/bot)"}  # name your scraper


def scrape(url, session, parser, timer):
    with timer.stage("fetch"):
        for attempt in range(3):
            try:
                if session is None:               # a new connection for every request
                    response = requests.get(url, headers=HEADERS, proxies=PROXIES, timeout=20)
                else:                             # reuse connections through one Session
                    response = session.get(url, timeout=20)
                response.raise_for_status()
                break
            except requests.RequestException:
                if attempt == 2:
                    raise
        html = response.text
    with timer.stage("parse"):
        soup = BeautifulSoup(html, parser)
        title = soup.find("h1").get_text(strip=True)
        links = len(soup.select("a[href]"))
    return url, title, links


def run(reuse=False, parser="html.parser", workers=1, batch_writes=False):
    timer = StageTimer()
    session = None
    if reuse:
        session = requests.Session()
        session.proxies = PROXIES
        session.headers.update(HEADERS)
        adapter = requests.adapters.HTTPAdapter(pool_connections=workers, pool_maxsize=workers)
        session.mount("https://", adapter)
    db = sqlite3.connect("pages.db", check_same_thread=False)
    db.execute("CREATE TABLE IF NOT EXISTS pages (url TEXT, title TEXT, links INTEGER)")
    db.execute("DELETE FROM pages")
    db.commit()

    start = time.perf_counter()
    with ThreadPoolExecutor(workers) as pool:
        rows = pool.map(lambda u: scrape(u, session, parser, timer), URLS)
        if batch_writes:
            rows = list(rows)
            with timer.stage("write"):
                db.executemany("INSERT INTO pages VALUES (?, ?, ?)", rows)
                db.commit()
        else:
            for row in rows:
                with timer.stage("write"):
                    db.execute("INSERT INTO pages VALUES (?, ?, ?)", row)
                    db.commit()                   # one transaction per row
    wall = time.perf_counter() - start
    timer.report(wall)
    return wall


if __name__ == "__main__":
    steps = [
        ("baseline", {}),
        ("reuse connections", {"reuse": True}),
        ("faster parser", {"reuse": True, "parser": "lxml"}),
        ("8 workers", {"reuse": True, "parser": "lxml", "workers": 8}),
        ("batch writes", {"reuse": True, "parser": "lxml", "workers": 8, "batch_writes": True}),
    ]
    for name, config in steps:
        print(f"\n== {name}")
        run(**config)

urls.pyには120本の記事URLのリストが入っています。実行すると、次のように出力されました。

== baseline
wall 298.2s, stage time 298.1s
   fetch:   228.7s   76.7%   1905.7 ms per call
   parse:    69.2s   23.2%    576.6 ms per call
   write:     0.3s    0.1%      2.2 ms per call

== reuse connections
wall 125.0s, stage time 125.2s
   fetch:    68.2s   54.5%    568.3 ms per call
   parse:    56.7s   45.3%    472.7 ms per call
   write:     0.3s    0.2%      2.1 ms per call

== faster parser
wall 75.7s, stage time 75.9s
   fetch:    51.1s   67.4%    425.8 ms per call
   parse:    24.6s   32.4%    204.8 ms per call
   write:     0.2s    0.3%      1.6 ms per call

== 8 workers
wall 39.7s, stage time 309.8s
   parse:   242.8s   78.4%   2023.6 ms per call
   fetch:    66.1s   21.3%    551.0 ms per call
   write:     0.8s    0.3%      6.9 ms per call

== batch writes
wall 42.1s, stage time 326.4s
   parse:   256.9s   78.7%   2141.0 ms per call
   fetch:    69.5s   21.3%    579.0 ms per call
   write:     0.0s    0.0%      1.8 ms per call

What each change did

接続の再利用により、フェッチ時間が70%短縮されました。 ベースラインでは、requests.get()を呼び出すたびに新しい接続が開かれます。プロキシ経由の場合、これはプロキシへのTCP接続、トンネルリクエスト、そして出口を通じた対象サーバーとの完全なTLSハンドシェイクを意味し、ページの最初の1バイトが届く前にこれらすべてが発生します。requests.Sessionは接続を開いたまま再利用するため、ほとんどのリクエストはこの準備段階を省略できます。ページあたりのフェッチ時間は1.9秒から0.57秒に短縮されました。ほぼすべてのHTTPライブラリには同等の仕組み、つまり一度作成して共有するクライアントオブジェクトがあります。

より高速なパーサーにより、パース時間が半分になりました。 Beautiful Soupは複数のパーサーを使用できます。組み込みのhtml.parserは純粋なPythonで書かれており、lxmlはCで書かれています。これらの大きなページでは、引数を1つ変更するだけで、パース時間がページあたり473ミリ秒から205ミリ秒に短縮されました。必要なフィールドが少数であれば、lxmlで直接選択するか、埋め込まれたJSONを抽出する方がさらに高速です。

並行処理により、合計時間が半分になり、ボトルネックが移動しました。 フェッチの大部分はネットワークの待機であり、スレッドは並行して待機できます。ワーカーを8つにすると、実行時間は76秒から40秒に短縮されました。しかしパースの列を見ると、ページあたり205ミリ秒だったものが2秒を超えています。CPythonではスレッドが1つのインタープリターロックを共有するため、パースのようなCPU負荷の高い処理は並行実行されません。8つのスレッドが同じコアを奪い合うことになります。ステージタイマーはこれを可視化します。パースはステージ時間の3分の1からほぼ80%にまで増加しました。この時点でスクレイパーはCPUによって制限されており、次のステップは別プロセスでパースするか、パースする量を減らすことです。

**バッチ書き込みは何も変えませんでした。**なぜなら、書き込みはそもそも問題ではなかったからです。ベースラインでは298秒中0.3秒にすぎません。バージョン4と5のわずかな差は、実行ごとのノイズです。これが、まず計測することの意味です。データベースの書き込みをバッチ化すること自体は一般的には良いアドバイスですが、ここでは無駄な労力だったことになります。

A checklist for a slow scraper

What you seeLikely causeWhat to check
すべてのリクエストが遅く、CPUはアイドル状態一度に1件ずつのリクエスト並行処理を追加する。ほとんどのスクレイパーは待機に時間を費やしている
各ホストへの最初のリクエストだけ、他よりずっと遅い接続の確立セッションやクライアントを再利用する。keep-aliveが無効化されていないか確認する
1つのコアでCPUが100%になっているパースなどCPU負荷の高い処理より高速なパーサーを使う、抽出するデータを減らす、またはパース処理を別プロセスに移す
並行度を上げるとスループットが下がる対象サイトによるスロットリング、または自分のマシンが飽和している対象ごとのステータスコードとレスポンス時間を監視する。リクエスト量と並行度の計画に関するガイドを参照
ときどきリクエストが20から30秒かかるタイムアウトと再試行明示的なタイムアウトを設定し、再試行回数の上限を決め、ログに記録する。Webスクレイパーの再試行とバックオフを参照
スクレイパーが数時間経つと遅くなるキューの増大、メモリ圧迫、無制限のバッファバックプレッシャーを追加する。バックプレッシャーとフロー制御を参照
ページが遅く、重いHTMLだけで済むのにブラウザをレンダリングしているHTMLを直接取得する、または画像・メディア・フォントをブロックする

When it really is the proxy

プロキシがボトルネックになっていることもあり、その見分け方を知っておく価値があります。

  • 直接アクセスのベースラインと比較する。 同じマシンから、プロキシなしで同じリクエストの時間を計測します。両方とも遅い場合、問題は対象サイトか自分のコードにあります。
  • 接続再利用後のフェッチ時間を確認する。 セッションを再利用してもフェッチが遅く、対象サイトに直接アクセスすると速い場合、プロキシを経由する経路が時間を追加しています。
  • ターゲティングを確認する。 特定の都市といった絞り込んだフィルターは、選べる出口の数を減らします。対象から遠い出口は、すべてのリクエストに往復時間を追加します。
  • 平均ではなくパーセンタイルを見る。 一部の非常に遅い出口が、ほとんどのリクエストは問題ないにもかかわらず平均値を押し上げることがあります。

レジデンシャルプロキシを使用する際のレイテンシ低減に関するガイドでは、プロキシ側の設定について詳しく解説しています。計測した結果、数値がプロキシを示している場合は、サポートチームに送ってください。上記のようなステージごとの計測値があれば、プロキシが遅いという報告の調査がはるかに速く進みます。

FAQ

プロキシを使うとスクレイパーが遅くなるのはなぜですか?

最も多い原因はスクレイパー自体にあります。リクエストごとに新しい接続を開くこと、一度に1ページずつ取得すること、パースが遅いことです。このテストでは、これらを修正することで、プロキシを変えずに同じ処理が7.5倍速くなりました。

接続の再利用はプロキシ経由でも機能しますか?

はい。プロキシを設定したrequests.Sessionは接続を開いたままにするため、リクエストごとの接続確立とTLSのセットアップを省くことができます。このテストでは、ページあたりのフェッチ時間が1.9秒から0.57秒に短縮されました。

スクレイパーはいくつのスレッドを使うべきですか?

対象サイトと自分のプランが許す範囲で、ネットワークを稼働させ続けられるだけの数です。増やしながら計測してください。ページあたりのパース時間やエラー率が上がり始めたら、そこが限界です。パースのようなCPU負荷の高い処理は、CPythonではスレッドを増やしても速くなりません。

BeautifulSoupは遅いのですか?

Beautiful Soup自体は便利なレイヤーであり、その速度の大部分は内部で使われるパーサーに依存します。組み込みのhtml.parserの代わりにlxmlを使うと、このテストではパースが2倍以上速くなりました。

The bottom line

プロキシを責める前に、スクレイパーを計測してください。ステージタイマーを使えば、時間がフェッチ、パース、書き込みのどこに使われているかが数分でわかります。このテストでは、接続の再利用、パーサーの切り替え、並行処理の追加により、同じプロキシのまま同じ処理が7.5倍速くなりました。まず最も大きなステージを修正し、再度計測し、残りの時間が本当にネットワークによるものになったら、そこで止めてください。

始める準備はできていますか?

Shifterのレジデンシャルプロキシをお試しください。IP 205M+件、195+カ国、$0.10/GBから。

始める