本文へスキップ

SQLAlchemyでスクレイピングデータを保存する本番設計【2026年版】

HT

友田陽大

Webエンジニア・独立系レビュアー

約40分で読めます

先に結論を書きます。スクレイピングパイプラインにおけるSQLAlchemyは「取得する側」ではなく、「二度と取得しなくて済むようにする側」です。必要なレコードだけをモデル化し、コネクションプールをデータベース自身の上限より内側に抑え、すべての行をON CONFLICT DO UPDATEで書き込んでリトライのコストをゼロにし、文ではなくトランザクションごと再実行する。以下はすべてSQLAlchemy 2.0 — 2.0.52、2026年8月11日リリース — を対象に、既定値を推測ではなく公式の記述どおりに示します。

この記事の要点

  • ストレージ層は何よりもまずコスト管理装置である。プロキシ通信はギガバイト単位の課金なので、最も安いリクエストは「データベースがすでに答えを持っているリクエスト」になる。
  • SQLAlchemy 2.0では型注釈がそのままスキーマになる。Mapped[str]はNOT NULL、Mapped[str | None]はNULLなので、型チェッカーとDDLが食い違う余地がない。
  • 公式の既定値はpool_sizeが5、max_overflowが10、pool_timeoutが30、pool_recycleが-1、pool_pre_pingがFalse。ワーカープロセスを増やすたびに、前2つがデータベースの接続上限に対して掛け算で効いてくる。
  • 冪等性は自然キーに対するon_conflict_do_update()と、コンテンツハッシュが変わっていない行を除外するWHERE句から生まれる。変化のない再クロールは1行も書き込まない。
  • asyncioでの鉄則はタスクごとに1つのAsyncSessionと、expire_on_commit=Falseの2点。セッションの共有や不用意な遅延ロードが、深夜に追いかけることになるMissingGreenletの正体である。
  • 接続が切れた時点でトランザクションも失われる。公式が示す回復手段は処理を最初からやり直すことであり、それが安全なのは書き込みが冪等だからにほかならない。

スクレイピングパイプラインの中でデータベースが担う位置

本番のパイプラインはURL収集、取得、解析、検証、保存という5段階で構成されます。全体像はWebスクレイピング完全ガイドで扱っていますが、このうちバイト単位で課金されるのは1段階だけです。レジデンシャルプロキシの通信量はギガバイト課金なので、パイプラインの経済性は「同じページを二度取得する頻度」で決まります。実際の金額感はWebスクレイピングのコスト試算で数字にしています。

この視点に立つと、ストレージ層の役割は変わります。「行をどこかに保管する」ことではありません。取得側が動かなくて済むよう、次の3つの問いに十分安く答えることです。

  1. このレコードはすでに見たか、そして変化したか。 答えが「変化なし」なら、クロールは課金対象のリクエストを1回省けます。
  2. クラッシュ時に何が残るか。 バッチの途中で落ちたワーカーが、ページの半分だけコミットして残り半分を失う状態を作ってはいけません。
  3. クローラーが書いている最中にアナリストは読めるか。 ロックを抱えたまま走り続ける長大なトランザクションは、データウェアハウスとクローラーを敵対させます。

SQLAlchemyはこの3つすべてに答えますが、2つの層を混同しないことが重要です。CoreはSQL式言語で、Tableselect()insert()、エンジン、コネクションプールを担当します。ORMはその上にマップドクラス、アイデンティティマップ、ユニットオブワークとしてのSessionを載せます。スクレイパーで生産的な分担は、スキーマをORMの宣言的モデルで定義し(Pythonの型とDDLの真実が1か所になるため)、書き込みはCore寄りの文で行うことです。1万行の投入はオブジェクトの変更を1万回積み上げる作業ではなく、集合演算だからです。

取得したページではなく、必要なレコードをモデル化する

スクレイパーで最も高くつくモデリングの誤りは、事実ではなく成果物を保存することです。生HTMLは巨大で、どうでもいい理由で変化し、クエリもできません。保存すべきは抽出済みのレコードと、「新しいかどうか」を判定できるだけのメタデータです。

Python
from __future__ import annotations

import datetime as dt
from typing import Annotated, Any

from sqlalchemy import (
    BigInteger,
    ForeignKey,
    Index,
    MetaData,
    String,
    TIMESTAMP,
    UniqueConstraint,
    func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

# Column recipes declared once and annotated everywhere. Changing the policy
# for every timestamp in the schema is then a one-line edit, not a sweep.
bigint_pk = Annotated[int, mapped_column(BigInteger, primary_key=True)]
utc_now = Annotated[dt.datetime, mapped_column(server_default=func.now())]

# Every constraint gets a deterministic name. Alembic cannot generate a
# migration for a constraint the database named anonymously, so this is what
# makes autogenerate usable later.
NAMING_CONVENTION = {
    "ix": "ix_%(column_0_label)s",
    "uq": "uq_%(table_name)s_%(column_0_name)s",
    "ck": "ck_%(table_name)s_%(constraint_name)s",
    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
    "pk": "pk_%(table_name)s",
}


class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=NAMING_CONVENTION)

    # A naive timestamp is the classic silent data loss in a crawler that runs
    # in more than one region. Fix it once, for every model in the schema.
    type_annotation_map = {
        dt.datetime: TIMESTAMP(timezone=True),
        dict[str, Any]: JSONB,
    }


class ScrapedPage(Base):
    __tablename__ = "scraped_page"

    id: Mapped[bigint_pk]
    source: Mapped[str] = mapped_column(String(64))
    external_id: Mapped[str] = mapped_column(String(256))
    url: Mapped[str] = mapped_column(String(2048))
    # Mapped[str] is NOT NULL; Mapped[str | None] is NULL. The annotation IS
    # the schema, so mypy and the DDL cannot disagree about optionality.
    title: Mapped[str | None]
    payload: Mapped[dict[str, Any]]
    # Hash what you EXTRACTED, never the raw HTML: hash the page and every
    # rotating ad slot and CSRF token reads as a content change.
    content_hash: Mapped[str] = mapped_column(String(64))
    first_seen_at: Mapped[utc_now]
    last_changed_at: Mapped[utc_now]

    observations: Mapped[list[PriceObservation]] = relationship(
        back_populates="page", cascade="all, delete-orphan"
    )

    __table_args__ = (
        # The natural key: what "the same page, seen again" means. Without it
        # the upsert in the next section has nothing to conflict on.
        UniqueConstraint("source", "external_id", name="uq_scraped_page_natural_key"),
        Index("ix_scraped_page_changed", "source", "last_changed_at"),
    )


class PriceObservation(Base):
    __tablename__ = "price_observation"

    id: Mapped[bigint_pk]
    page_id: Mapped[int] = mapped_column(ForeignKey("scraped_page.id", ondelete="CASCADE"))
    observed_at: Mapped[utc_now]
    # Money as integer minor units. A float price silently loses cents, and
    # you will not notice until someone reconciles a year of history.
    amount_minor: Mapped[int]
    currency: Mapped[str] = mapped_column(String(3))

    page: Mapped[ScrapedPage] = relationship(back_populates="observations")

このコードで効いているのは主に3点です。

Mapped[]がNULL可否を決めます。 公式の規則ではMapped[str]NOT NULLMapped[Optional[str]](またはMapped[str | None])がNULLを生成し、primary_key=Trueは注釈にかかわらずNOT NULLを意味します。mapped_column(nullable=...)で上書きもできますが、既定のままなら型チェッカーとスキーマが同じ真実を読んでいることになります。

type_annotation_mapがPythonとSQLの対応を集約します。 SQLAlchemyはintIntegerstrStringdatetime.datetimeDateTimeuuid.UUIDUuidという具合に既定でマッピングします。ベースクラスでこのマップを上書きすることが、タイムゾーン付きタイムスタンプや汎用JSONではなくJSONBといったプロジェクト全体の方針を、40本のカラムに書き写さずに適用する方法です。

自然キーは慣習ではなく実在の制約です。 sourceexternal_idの組が、クロールをまたいで同じ商品ページを識別可能にします。代理キーのidだけではこれを表現できませんし、次章の内容はすべて「衝突をデータベース自身が検知できること」に依存しています。

エンジンとプールが同時実行の予算になる

create_engine()は接続ではなくファクトリです。遅延生成され共有されるコネクションプールを保持します。SQLiteのインメモリ以外では既定のプールはQueuePoolで、その公式の既定値が最初に牙をむく数字になります。

パラメータ既定値実際に決まること
pool_size5エンジン1つ・プロセス1つあたりの常設接続数
max_overflow10バースト時に追加を許す接続数。使用後は破棄される
pool_timeout30空き接続を待って例外を投げるまでの秒数
pool_recycle-1(無効)チェックアウト時に接続を張り替える経過秒数
pool_pre_pingFalseチェックアウト時に軽いクエリで生存確認するか
insertmanyvalues_page_size1000複数行INSERT 1回あたりの行数

意味を持つ計算はワーカー数 ×(pool_size + max_overflow)≤ サーバー接続上限 − 余裕です。既定のままだと8つのクローラープロセスが、全体で100接続しか許さないデータベースに対して120接続を要求しかねません。症状はワーカー側のpool_timeout超過として現れ、同時にマイグレーションはそもそも接続できなくなります。上限は意識的に決めてください。

Python
import os

from sqlalchemy import create_engine

engine = create_engine(
    # Never a literal DSN. Credentials belong in the environment or a secrets
    # manager, and the URL carries the driver: postgresql+psycopg for sync.
    os.environ["SCRAPER_DATABASE_URL"],
    pool_size=5,
    max_overflow=5,          # hard ceiling of 10 per process, not 15
    pool_timeout=10,         # fail fast; a queued worker is a stalled worker
    pool_recycle=1800,       # under a proxy or a managed DB, assume idle kills
    pool_pre_ping=True,      # one cheap round trip beats one lost batch
    insertmanyvalues_page_size=500,
)

pool_pre_ping=Trueは公式が言う悲観的な切断対策です。チェックアウト時にダイアレクト固有のping(多くはSELECT 1)を送り、失敗したらその接続を破棄したうえで、現在時刻より古いプール内の接続をすべて無効化します。チェックアウトごとに1往復のコストがかかる代わりに、「アイドル後の最初のクエリが失敗する」という障害クラスを丸ごと消せます。pool_recycleは、タイマーでアイドル接続を切るバックエンド向けの相棒です。公式ドキュメントはMySQLのserver has gone awayに対する即効薬としてこれを挙げていますが、コネクションプロキシの背後にあるマネージドPostgreSQLでも理屈は同じです。

フォークはプールが壊れる場所

マルチプロセスのクローラーは、公式に明記された固有の落とし穴を踏みます。プール内の接続はフォークした子プロセスと共有されません。結果として2つのプロセスが同じソケットに書き込み、症状はきれいなエラーではなくプロトコル状態の破損として現れます。公式の対処は、親のソケットを閉じずに、子側で継承したプールを破棄することです。

Python
from multiprocessing import Pool


def init_worker() -> None:
    # close=False drops the inherited connection references WITHOUT closing
    # them — the parent still owns those sockets. The child then opens its own.
    engine.dispose(close=False)


with Pool(processes=8, initializer=init_worker) as pool:
    pool.map(scrape_one, urls)

ユニットオブワークごとに1つのセッション

Sessionは「単一のデータベーストランザクションを表す、可変で状態を持つオブジェクト」であり、「注意深い同期なしに複数のスレッドやasyncioタスクで共有することはできない」と公式に記述されています。これは注意書きではなく設計規則として読んでください。セッションの寿命はそのままトランザクションの寿命であり、それはロックを保持する時間そのものです。

sessionmakerはモジュールスコープで一度だけ作り、セッションは処理の単位ごとに開きます。

Python
from sqlalchemy.orm import sessionmaker

# Once, at import time. The factory is cheap and shareable; sessions are not.
SessionFactory = sessionmaker(engine)


def store_batch(rows: list[dict[str, object]]) -> None:
    # One transaction per BATCH. Per row, commit overhead dominates the run;
    # per crawl, one bad page rolls back an hour of work while every row you
    # did write stays locked away from readers.
    with SessionFactory() as session, session.begin():
        session.execute(upsert_pages(rows))

session.begin()をコンテキストマネージャとして使うと、成功時にコミット、例外時にロールバックされるため、書きかけのバッチが外に漏れる経路が存在しなくなります。バッチサイズは200〜1000行あたりが定番です。往復のオーバーヘッドが支配的でなくなる程度には大きく、ロールバックが安く、ロック保持時間が数十ミリ秒に収まる程度には小さい範囲です。

すべての書き込みを冪等にする

この記事の核心です。クローラーは必ず再実行される仕組みです。リトライ、バックフィル、運用者が昨日のジョブを再起動するといった出来事が日常的に起きます。同じページを二度書いたときに重複が生まれたり、正しいデータが壊れたりするなら、そのすべてが障害になります。INSERT ... ON CONFLICT DO UPDATEは、重複排除をアトミックに扱えるデータベース側へ移す手段です。

Python
from sqlalchemy import func
from sqlalchemy.dialects.postgresql import insert


def upsert_pages(rows: list[dict[str, object]]):
    stmt = insert(ScrapedPage).values(rows)
    return stmt.on_conflict_do_update(
        # Infers the unique index behind the natural key.
        index_elements=[ScrapedPage.source, ScrapedPage.external_id],
        # `excluded` is the row PostgreSQL tried to insert — the fresh scrape.
        set_={
            "url": stmt.excluded.url,
            "title": stmt.excluded.title,
            "payload": stmt.excluded.payload,
            "content_hash": stmt.excluded.content_hash,
            "last_changed_at": func.now(),
        },
        # The line that pays for itself: a re-crawl that found nothing new
        # writes no row at all. No dead tuple, no WAL, no index churn.
        where=ScrapedPage.content_hash != stmt.excluded.content_hash,
    )

インポート元に注目してください。UPSERTは汎用のinsertではなく、ダイアレクト固有insert、つまりsqlalchemy.dialects.postgresql.insertにあります。SQLiteも同じon_conflict_do_update()という綴りを提供し、MySQLとMariaDBはon_duplicate_key_update()を提供しますが、3者を覆う移植可能な構文はありません。本番と同じエンジンで開発すべき、れっきとした理由です。

where句のトレードオフは正直に書いておきます。更新を飛ばすということは、last_changed_atが「コンテンツが最後に変わった時刻」を表すということです。変更検知のパイプラインが欲しいのはまさにそれですが、同時に「最後に確認した時刻」は記録されなくなります。両方必要なら、レコード本体にはガード付き更新を残したまま、安価な「確認時刻」だけをアナリストが結合しない別の細いテーブルに書き出してください。

見返りとして、変更フィードが無料で手に入ります。変化のない行はスキップされるので、RETURNINGは動いた行だけを返します。

Python
changed = session.scalars(
    upsert_pages(rows).returning(ScrapedPage),
    # Refreshes any of these objects already living in the session's identity
    # map — unlike a plain INSERT, some of these rows already existed.
    execution_options={"populate_existing": True},
).all()

for page in changed:
    enqueue_downstream(page.id)

これは「毎晩すべて再インデックスする」と「変化した0.4%だけ再インデックスする」の違いです。100万ページの集合では、数分で終わるジョブと一晩かかるジョブの差になります。

一括書き込みはinsertmanyvaluesに任せる

SQLAlchemy 2.0はinsert()のパラメータセットとして辞書のリストを受け取り、ORMはそのキーを列名ではなく属性名として解釈します。これは2.0での意図的な変更で、マップされた属性と列の綴りが異なる瞬間から効いてきます。

Python
from sqlalchemy import insert

session.execute(
    # render_nulls keeps every row in one batch. Scraped records are ragged by
    # nature — an optional field that is None would otherwise split the run
    # into several statements at exactly the rows you have most of.
    insert(ScrapedPage).execution_options(render_nulls=True),
    rows,
)

内部ではinsertmanyvalues機能が、これを複数行のINSERT ... VALUESにまとめ直します。PostgreSQL、MySQL、SQLite、SQL Server、Oracleで既定で有効になっており、バッチサイズはinsertmanyvalues_page_sizeに従います。この値は「既定で1000だが、ダイアレクト固有の制限を受けることもある」とされています。行が大きなJSONペイロードを抱える場合は下げてください。利益は往復回数の削減から来るので、サーバーのパラメータ上限を超える巨大な文を作れば、その利益をそのまま返上することになります。

生成された主キーが必要なときは、パラメータ順で返すよう指定します。

Python
page_ids = session.scalars(
    # sort_by_parameter_order guarantees the returned ids line up with the
    # input rows, which is what lets you attach child records without a
    # second SELECT. Added in SQLAlchemy 2.0.10.
    insert(ScrapedPage).returning(ScrapedPage.id, sort_by_parameter_order=True),
    rows,
).all()

やってはいけないことが2つあります。1万個のオブジェクトにsession.add()をループで呼んで最後にコミットしないこと。誰も変更しない行に対して、ユニットオブワークの管理コストを丸ごと払うことになります。もう1つは「ORMのオーバーヘッドを避ける」ためにSQL文字列を自分で組み立てないこと。プレースホルダ経由の経路こそが、定義上信頼できない入力であるスクレイピング結果をSQL構文から締め出しています。

非同期パイプラインではタスクごとに1つのAsyncSession

取得側がすでにasyncioなら — 最近のものはたいていそうで、その形が広がった理由はAIエージェント向けプロキシの解説で触れています — 同じイベントループでデータベースを扱えばスレッド往復を避けられます。守るべき規則は狭く、そして例外を許しません。

Python
import asyncio
import os

from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine

# The URL carries the async driver: postgresql+asyncpg://...
engine = create_async_engine(os.environ["SCRAPER_DATABASE_URL"], pool_size=10, max_overflow=0)

# expire_on_commit=False so attributes stay readable after commit. The default
# would expire them, and refreshing an expired attribute is implicit IO —
# exactly what asyncio cannot do behind your back.
AsyncSessionFactory = async_sessionmaker(engine, expire_on_commit=False)


async def store(rows: list[dict[str, object]], gate: asyncio.Semaphore) -> None:
    # One AsyncSession per task: a single instance is explicitly documented as
    # unsafe in concurrent tasks. The semaphore keeps the task count from
    # outrunning the pool, which is the other half of the same budget.
    async with gate, AsyncSessionFactory() as session, session.begin():
        await session.execute(upsert_pages(rows))


async def main(batches: list[list[dict[str, object]]]) -> None:
    gate = asyncio.Semaphore(10)
    try:
        await asyncio.gather(*(store(batch, gate) for batch in batches))
    finally:
        # Closes the pool. Skip it and the loop shuts down under open sockets.
        await engine.dispose()

間違えたときに出会うエラーはMissingGreenletで、その原因はほぼ常に非同期コンテキスト外で発火した遅延ロードです。asyncioのドキュメントは3つの対処を挙げており、私が試す順序もこのとおりです。クエリ側でselectinload()を使って先読みする、ベースクラスにAsyncAttrsミックスインを足してawait obj.awaitable_attrs.thingsと書く、session.run_sync()で同期ORMコードのブロックに落とす、の3つです。

サイジングについて1点だけ注意を。非同期はデータベースの接続上限を引き上げません。上限に到達しやすくするだけです。pool_size=10に対して1000個の同時タスクを走らせてもデッドロックにはなりませんが、11個目以降はすべて待ち行列に入り、pool_timeoutがその待ち行列を明示的な失敗にするか、静かなレイテンシに変えるかを決めます。

N+1を起こさずに読み戻す

読み取り経路はスクレイピング結果を書き出す場所であり、ORMの典型的な性能バグが棲む場所でもあります。親を反復しながら1件ずつリレーションに触れると、親の数だけクエリが発行されます。SQLAlchemyの答えは、ロード戦略をクエリに明記することです。

Python
from sqlalchemy import select
from sqlalchemy.orm import raiseload, selectinload

stmt = (
    select(ScrapedPage)
    .options(
        # selectinload is the documented default for COLLECTIONS: a second
        # SELECT with an IN clause, so no join multiplies the parent rows.
        selectinload(ScrapedPage.observations),
        # Everything not eager-loaded above now RAISES instead of quietly
        # emitting a query. An N+1 becomes a test failure, not a slow export.
        raiseload("*"),
    )
    .where(ScrapedPage.source == "example-retailer")
    .order_by(ScrapedPage.last_changed_at.desc())
    .limit(500)
)
pages = session.scalars(stmt).all()

観測値から親ページへ辿るようなスカラーの多対一では、joinedload()のほうが適します。2回目の往復ではなく列の集合を1つ足すだけで済むからです。公式が明示している唯一の罠は、コレクションに対するjoinedload()は行を増殖させるため、件数を数える前に.unique()で重複を除去する必要がある点です。

過小評価されているのはraiseload("*")です。テストとバッチ書き出しで有効にしておけば、意図しない遅延ロードはすべて、本番で1時間かかる謎のジョブではなく、開発中の明確で場所の特定できる失敗になります。

観測性:雰囲気ではなくSQLを測る

echo=Trueは開発用のスイッチです。本番で有効にすると、バインドパラメータを含むすべての文がログに出ます。スクレイパーではそれはスクレイピングした内容そのものであり、接続時には資格情報でもあります。代わりにイベントを使い、パラメータを外して文だけを記録してください。

Python
import logging
import time

from sqlalchemy import event

log = logging.getLogger("scraper.sql")
SLOW_QUERY_SECONDS = 0.5


@event.listens_for(engine, "before_cursor_execute")
def _start_timer(conn, cursor, statement, parameters, context, executemany):
    conn.info.setdefault("query_start", []).append(time.perf_counter())


@event.listens_for(engine, "after_cursor_execute")
def _record_duration(conn, cursor, statement, parameters, context, executemany):
    elapsed = time.perf_counter() - conn.info["query_start"].pop()
    if elapsed >= SLOW_QUERY_SECONDS:
        # The statement, never `parameters`: that tuple is the payload.
        log.warning("slow sql %.3fs executemany=%s %s", elapsed, executemany, statement)

イベントのシグネチャは両フックとも(conn, cursor, statement, parameters, context, executemany)で固定されており、conn.info上のスタックが入れ子の実行を正しく対応付けます。ここから書き出す価値がある指標は3つです。操作種別ごとの実行時間、executemanyの比率(バッチ化が効いていれば上がります)、そしてプールのチェックアウト待ち時間です。最後の1つが、クローラーが遅い理由は対象サイトなのか自分のpool_sizeなのかを教えてくれます。

リトライは文ではなくトランザクションに対して行う

公式ドキュメントの記述は率直です。「接続が失われたとき、トランザクション全体が失われる。データベースが再接続して再試行し、中断地点から続行するような有用な方法は存在しない」。推奨される形は「トランザクションの開始から処理全体をやり直す」ことです。

Python
import random
import time
from collections.abc import Callable
from typing import TypeVar

from sqlalchemy.exc import DBAPIError

T = TypeVar("T")
# Serialization failure and deadlock detected: transient by definition.
RETRYABLE_SQLSTATES = frozenset({"40001", "40P01"})


def _is_retryable(error: DBAPIError) -> bool:
    if error.connection_invalidated:
        return True
    orig = error.orig
    return getattr(orig, "sqlstate", None) in RETRYABLE_SQLSTATES


def with_retry(work: Callable[[], T], attempts: int = 5) -> T:
    """Re-run a whole unit of work, from BEGIN, on a transient failure."""
    for attempt in range(1, attempts + 1):
        try:
            return work()
        except DBAPIError as error:
            if attempt == attempts or not _is_retryable(error):
                raise
            # Full jitter. A fleet that backs off on one schedule reconnects
            # as a thundering herd and knocks the database over a second time.
            time.sleep(random.uniform(0, min(2**attempt * 0.1, 5.0)))
    raise AssertionError("unreachable")


with_retry(lambda: store_batch(rows))

これが安全なのは、書き込みがUPSERTだからです。曖昧な失敗のあとに素のINSERTをやり直せば重複が生まれますが、ON CONFLICT DO UPDATEは何度実行しても同じ行に収束します。ここでの冪等性は「あれば嬉しい性質」ではなく、自動復旧を成立させる前提条件です。

pool_pre_pingとこのデコレータは、問題の別々の半分を解いています。pre-pingはプール内でアイドル中に死んだ接続を、作業を始める前に捕まえます。リトライはトランザクションの途中で死んだ接続、つまり救い出せるものが何もない状況を引き受けます。

スキーマ変更:autogenerateは下書きであってマイグレーションではない

Alembicは同じプロジェクトのマイグレーションツールで、その公式ドキュメント自身がautogenerateについて「完璧であることを意図していない」「autogenerateが生成した候補マイグレーションは常に人手でレビューし修正する必要がある」と述べています。文字どおり受け取ってください。

Shell
alembic revision --autogenerate -m "add price_observation"
alembic upgrade head

確実に検出されるのは、テーブルと列の追加・削除、NULL可否の変更、インデックスと名前付きユニーク制約の基本的な変更、外部キーの基本的な変更、名前付きCHECK制約の追加と削除です。検出されないのはテーブル名の変更と列名の変更で、いずれも削除+追加として現れます。スクレイピング済みの集合においてそれは、列を消して空のまま作り直すことを意味します。さらに、匿名の名前を持つ制約はそもそも認識できません。最初のコードブロックでnaming_conventionMetaDataに載せているのはそのためです。これがないと、autogenerateは制約を突き合わせる手がかりを持ちません。

破綻しない手順はこうです。生成する、ファイルを1行ずつ読む、削除+追加の組をop.alter_column(..., new_column_name=...)に書き換える、そして本番に近づける前に本番からリストアした複製に対して実行する。

テスト:後片付けではなくロールバックする

スクレイパーのテストには、本番と同じ挙動をするデータベースと、痕跡を残さないフィクスチャが要ります。SQLAlchemy 2.0はその手順を公式化しています。接続上で外側のトランザクションを開き、セッションをセーブポイントモードでその接続に束ね、テスト終了時に外側をロールバックする、という流れです。

Python
import pytest
from sqlalchemy.orm import Session


@pytest.fixture()
def session(engine):
    connection = engine.connect()
    transaction = connection.begin()
    # join_transaction_mode="create_savepoint" lets the CODE UNDER TEST call
    # session.commit() for real — the commit lands on a SAVEPOINT inside the
    # outer transaction, which the fixture then throws away wholesale.
    db = Session(bind=connection, join_transaction_mode="create_savepoint")
    try:
        yield db
    finally:
        db.close()
        transaction.rollback()
        connection.close()

これを本番と同じエンジンに対して実行してください。SQLiteは純粋なPython部分のテスト先としては十分ですが、ON CONFLICTの意味論、JSONBの演算子、NULLの並び順はいずれも異なります。本番がPostgreSQLなのにSQLiteで通るテストスイートは、別のプログラムをテストしています。

スクレイパーのデータベースで繰り返し見つかる5つの失敗

症状根本原因対処
リトライのたびに重複行が増える自然キーにユニーク制約がなく、ON CONFLICTが検知する対象を持たない先に制約を追加してからUPSERTへ。既存の重複は一度だけ整理する
行数は横ばいなのにテーブルだけ肥大するガードのないUPSERTが毎回すべての行を書き直しているwhere句にcontent_hashのガードを追加する
ワーカーが止まるのにデータベースは暇そうpool_size×ワーカー数がサーバー上限を超え、全員がpool_timeoutで待たされている上限を意識的に設定し、プールのチェックアウト待ちを指標として書き出す
静かな時間帯のあと最初のクエリが失敗するサーバーまたはコネクションプロキシがアイドル接続を切っているpool_pre_ping=Trueと、サーバーのアイドル上限より短いpool_recycle
夜間の書き出しに何時間もかかるループの中でリレーションを遅延ロードしているクエリにselectinload()、維持のためにraiseload("*")

どれも珍しい話ではありません。毎回同じ5つで、しかも5つのうち4つはコードではなく設定の問題です。

これが取得側にもたらすもの

こうして組んだストレージ層は、クローラーに許される振る舞いを変えます。書き込みが冪等なので、ワーカーは落ちても照合作業なしに再起動できます。UPSERTが何が変わったかを報告するので、スケジューラは変化の速いページを高頻度に、静かなページを低頻度にクロールできます。これはプロキシ支出に対する最大の梃子であり、ブロックされないための実践で扱った作法とそのまま噛み合います。そして変更フィードが存在するからこそ、価格モニタリングのような下流ジョブはテーブルを読み直すのではなくストリームを消費できます。

注目が集まるのはブロックと請求書が発生する取得段階です。しかし「どれだけ取得に払うか」を決めているのは、保存段階のほうです。

よくある質問

経路を分けて両方使うのが実務解です。スキーマ定義はORMの宣言的モデルで行い、Pythonの型注釈とDDLの真実を1か所に集約します。書き込みはSQLAlchemy 2.0が辞書のリストを受け取れるCore寄りのinsert()構文で行います。人間が読むコードは型付き、データベースが実行するコードは集合演算という分担になります。
総合ガイドに戻る

関連記事