先講結論:在爬蟲管線裡,SQLAlchemy 不是負責抓取的那一段,而是讓你不必再抓一次的那一段。只把真正需要的紀錄建模、把連線池壓在資料庫本身上限之內、每一列都透過 ON CONFLICT DO UPDATE 寫入讓重試零成本,並且重試整個交易而不是單一敘述。以下內容全部針對 SQLAlchemy 2.0 — 2.0.52,2026 年 8 月 11 日發布 — 並直接引用官方預設值,而不是憑印象推測。
本文重點
- 儲存層首先是成本控制裝置:代理流量按 GB 計費,所以最便宜的請求就是資料庫已經回答過的那一個。
- 在 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;每多開一個 worker 行程,前兩項就會對資料庫連線上限產生乘法效果。
- 冪等性來自針對自然鍵的 on_conflict_do_update(),再加上一段跳過內容雜湊未變動列的 WHERE 條件,讓沒有變化的重爬完全不寫入。
- 在 asyncio 下的鐵則有兩條:每個 task 一個 AsyncSession,以及 expire_on_commit=False;共用 session 或不小心觸發延遲載入,就是你會在半夜兩點追的 MissingGreenlet。
- 連線一斷,交易就一起消失;官方給的復原方式是從頭重跑整個操作,而這之所以安全,正是因為寫入是冪等的。
資料庫在爬蟲管線裡的位置
正式環境的管線有五個階段:URL 收集、抓取、解析、驗證、儲存。完整脈絡在我們的網頁爬蟲完整指南裡,而其中只有一個階段是按位元組計費的。住宅代理流量以 GB 計價,所以整條管線的經濟性取決於「同一頁需要抓第二次的頻率」;網頁爬蟲成本試算把這件事換算成了實際數字。
這會重新定義儲存層的角色。它的工作不是「把列放到某個地方」,而是用夠低的成本回答三個問題,好讓抓取端永遠不必自己去問:
- 這筆紀錄我看過了嗎,而且它變了嗎? 如果答案是沒變,這次爬取就省下一次要付費的請求。
- 當機之後留下什麼? 在批次中途死掉的 worker,不能留下半頁已提交、另外半頁遺失的狀態。
- 爬蟲在寫的時候,分析師能讀嗎? 一個長時間持有鎖的交易,會讓資料倉儲與爬蟲互相為敵。
SQLAlchemy 三個問題都能回答,但要分清楚兩個層次。Core 是 SQL 表達式語言:Table、select()、insert()、引擎與連線池。ORM 在其上加了對映類別、identity map,以及作為工作單元的 Session。對爬蟲最有生產力的分工,是用 ORM 宣告式模型定義結構(Python 型別與 DDL 就只有一份真實來源),批次寫入則用 Core 風格的敘述,因為插入一萬列是集合運算,不是一萬次物件變更。
建模你要的紀錄,而不是你抓到的頁面
爬蟲最昂貴的建模錯誤,是儲存產物而不是事實。原始 HTML 體積龐大、會因為你不在乎的理由而變動,而且無法查詢。要存的是抽取後的紀錄,加上足以判斷「是不是新的」的中繼資料。
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")
其中三個細節承擔了大部分工作。
Mapped[] 決定可空性。 官方規則是 Mapped[str] 產生 NOT NULL,Mapped[Optional[str]](或 Mapped[str | None])產生 NULL,而 primary_key=True 無論如何都隱含 NOT NULL。你仍然可以用 mapped_column(nullable=...) 覆寫,但維持預設就代表型別檢查器與結構定義讀的是同一個事實。
type_annotation_map 集中管理 Python 對 SQL 的對映。 SQLAlchemy 預設已經把 int 對到 Integer、str 對到 String、datetime.datetime 對到 DateTime、uuid.UUID 對到 Uuid 等等。在基底類別覆寫這張表,就是套用全專案政策的方式,例如帶時區的時間戳、用 JSONB 取代通用 JSON,而不必在 40 個欄位上重寫一遍。
自然鍵是真正的約束,不是慣例。 source 加上 external_id 才讓同一個商品頁在多次爬取之間可被辨識。單靠代理主鍵 id 無法表達這件事,而下一節的一切都建立在「資料庫自己能偵測到衝突」之上。
引擎與連線池就是你的併發預算
create_engine() 是工廠而不是連線:它持有一個延遲建立且共用的連線池。除了記憶體內的 SQLite 之外,預設連線池是 QueuePool,而它的官方預設值就是最先咬你的那組數字。
| 參數 | 預設值 | 實際決定什麼 |
|---|---|---|
pool_size | 5 | 每個引擎、每個行程常態保持的連線數 |
max_overflow | 10 | 尖峰時允許額外開啟、用完即丟的連線數 |
pool_timeout | 30 | 等待空閒連線多少秒後拋出例外 |
pool_recycle | -1(關閉) | 取用時超過幾秒就換掉這條連線 |
pool_pre_ping | False | 取用時是否以輕量查詢確認連線存活 |
insertmanyvalues_page_size | 1000 | 每一次多列 INSERT 所含的列數 |
真正重要的算式是 worker 數 ×(pool_size + max_overflow)≤ 伺服器連線上限 − 餘裕。維持預設值時,八個爬蟲行程可能對一個總共只允許 100 條連線的資料庫要求 120 條,症狀是 worker 端的 pool_timeout 逾時,同時遷移作業根本連不上。請刻意決定這個上限:
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 是官方所說的悲觀斷線策略:SQLAlchemy 在取用連線時送出方言專屬的 ping(通常是 SELECT 1),失敗就丟棄該連線,並讓池中所有早於當下時刻的連線失效。代價是每次取用多一次往返,換來的是徹底消除「閒置一段時間後第一個查詢失敗」這整類故障。pool_recycle 則是搭配那些會按計時器關閉閒置連線的後端使用;官方文件把它列為 MySQL server has gone away 的立即解法,而同樣的道理也適用於任何位於連線代理之後的受管 PostgreSQL。
fork 是連線池出事的地方
多行程爬蟲會踩到一個官方明載的特定風險:池中的連線不會分享給 fork 出來的子行程。結果是兩個行程對同一個 socket 寫入,症狀不是乾淨的錯誤,而是協定狀態損毀。官方對策是在子行程裡丟棄繼承而來的連線池,但不關閉父行程的 socket:
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)
每個工作單元一個 session
Session 在官方文件裡被描述為「a mutable, stateful object that represents a single database transaction」,而且「cannot be shared among concurrent threads or asyncio tasks without careful synchronization」。請把它當設計規則而不是警語來讀:session 的生命週期就是交易的生命週期,而那就是你持有鎖的時間。
sessionmaker 在模組層級建立一次,session 則依工作單元開啟:
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 把去重搬進資料庫,在那裡它是原子操作。
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,也就是 sqlalchemy.dialects.postgresql.insert,而不是通用版本。SQLite 提供同樣拼法的 on_conflict_do_update(),MySQL 與 MariaDB 提供 on_duplicate_key_update(),三者之間沒有可移植的統一寫法。這是「用與上線相同的引擎開發」的實在理由。
where 條件的取捨值得誠實說明。跳過更新,代表 last_changed_at 記錄的是內容最後一次改變的時間,這正是變更偵測管線要的;但你也就不再記錄「最後一次確認」的時間。如果兩者都需要,請把有守衛的更新留給紀錄本身,另外把便宜的「最後看見」時間戳寫進一張沒人會 join 的窄表。
回報是一份免費的變更饋送。因為沒變動的列會被跳過,RETURNING 剛好回傳有動過的列:
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%」之間的差別。在百萬頁規模上,這是幾分鐘完成的工作與跑整晚的工作之間的差別。
批次寫入:把整併交給 insertmanyvalues
SQLAlchemy 2.0 接受字典串列作為 insert() 的參數集,而且 ORM 會把鍵解讀為屬性名稱而非欄位名稱,這是 2.0 的刻意變更,一旦對映屬性與欄位拼法不同就會產生差別。
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,該值「defaults to 1000, but may also be subject to dialect-specific limiting factors」。當每列都帶著龐大的 JSON 內容時請調小:好處來自往返次數變少,而一個大到超出伺服器參數上限的敘述會把這份好處原封不動還回去。
需要產生的主鍵時,請要求依參數順序回傳:
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()
有兩件事不要做。不要對一萬個物件迴圈呼叫 session.add() 然後一次提交,那等於為沒人會變更的列付出完整的工作單元記帳成本。也不要為了「避開 ORM 額外負擔」自己拼接 SQL 字串;參數化的路徑正是讓爬取文字這種依定義不可信的輸入,無法進入 SQL 語法的關鍵。
非同步管線:每個 task 一個 AsyncSession
如果你的抓取端本來就是 asyncio — 現代實作大多如此,這種形態為何普及可以參考我們的AI 代理人用代理服務 — 那麼讓資料庫跑在同一個事件迴圈上就能省下一次執行緒切換。規則很窄,而且沒有商量餘地。
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 文件給了三種解法,依我會嘗試的順序是:在查詢裡用 selectinload() 預先載入、在基底類別加上 AsyncAttrs mixin 後改寫成 await obj.awaitable_attrs.things、或是用 session.run_sync() 落回一段普通的同步 ORM 程式碼。
關於容量規劃還有一個提醒:非同步不會提高資料庫的連線上限,它只是讓你更容易撞到。一千個併發 task 對上 pool_size=10 不會死鎖,但第十個之後全部排隊,而 pool_timeout 決定這個佇列是明確失敗,還是悄悄變成你的延遲。
讀回資料而不觸發 N+1
讀取路徑是爬取結果被匯出的地方,也是 ORM 經典效能缺陷所在:一邊走訪父物件、一邊逐一存取關聯,就會依父物件數量發出等量查詢。SQLAlchemy 的答案是在查詢裡明確指定載入策略。
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() 是更好的形狀,因為 join 只是多加一組欄位,而不是多一次往返。官方明確點出的唯一陷阱是:對集合使用 joinedload() 會讓列數倍增,所以在計數之前必須用 .unique() 去除重複。
被低估的是 raiseload("*")。在測試與任何批次匯出中打開它,每一次意外的延遲載入都會變成開發階段大聲且可定位的失敗,而不是正式環境裡一個跑一小時的謎團。
可觀測性:測量 SQL,而不是憑感覺
echo=True 是開發用開關。在正式環境開啟它會記錄每一個敘述,包含繫結參數;在爬蟲裡那就是爬取到的內容,而在連線本身則是憑證。請改用事件,並且只記錄敘述、不記錄參數。
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)
事件簽章是固定的,兩個 hook 都是 (conn, cursor, statement, parameters, context, executemany),而放在 conn.info 上的堆疊確保巢狀執行能正確配對。值得從這裡輸出的指標有三個:依操作類型分的敘述耗時、executemany 的比例(批次生效時會上升),以及連線池取用等待時間,最後這個數字會告訴你爬蟲變慢是因為目標網站,還是因為你自己的 pool_size。
重試交易,永遠不要重試單一敘述
官方文件講得很直白:「when a connection is lost, the entire transaction is lost. There is no useful way that the database can reconnect and retry and continue where it left off.」建議的做法是「retry the entire operation from the start of the transaction」。
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「is not intended to be perfect」,而且「it is always necessary to manually review and correct the candidate migrations that autogenerate produces」。請照字面理解。
alembic revision --autogenerate -m "add price_observation"
alembic upgrade head
它能可靠偵測的是資料表與欄位的新增與移除、可空性變更、索引與具名唯一約束的基本變更、外鍵的基本變更,以及具名 CHECK 約束的新增與移除。它無法偵測資料表更名與欄位更名,兩者都會呈現為一刪一增;在已爬取的資料集上,那意味著把欄位刪掉再空著重建。匿名命名的約束它更是完全看不見。這就是第一個程式碼區塊要把 naming_convention 放在 MetaData 上的原因:少了它,autogenerate 沒有依據去比對約束。
站得住腳的流程是:產生、逐行閱讀檔案、把一刪一增的組合改寫成 op.alter_column(..., new_column_name=...),並且在遷移接近正式環境之前,先對還原自正式環境的副本執行一次。
測試:用回滾取代清理
爬蟲測試需要一個行為與正式環境相同的資料庫,以及一個不留殘渣的 fixture。SQLAlchemy 2.0 把這個模式寫成了官方做法:在連線上開啟外層交易、以 savepoint 模式把 session 綁到該連線,測試結束時回滾外層交易。
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 排序全都不同;正式環境跑 Postgres 卻在 SQLite 上通過的測試套件,測的是另一個程式。
我一再在爬蟲資料庫裡看到的五個失敗
| 症狀 | 根本原因 | 對策 |
|---|---|---|
| 每次重試都多出重複列 | 自然鍵上沒有唯一約束,ON CONFLICT 沒有可偵測的對象 | 先加約束再改用 upsert;既有重複資料一次性清理 |
| 列數持平但資料表不斷膨脹 | 沒有守衛的 upsert 每次爬取都改寫每一列 | 在 where 條件裡加上 content_hash 守衛 |
| worker 卡住但資料庫看起來很閒 | pool_size × worker 數超過伺服器上限,所有人都卡在 pool_timeout | 刻意設定上限;把連線池取用等待時間輸出成指標 |
| 安靜一段時間後第一個查詢失敗 | 伺服器或連線代理關閉了閒置連線 | pool_pre_ping=True,並將 pool_recycle 設在閒置上限之下 |
| 夜間匯出要跑好幾小時 | 在迴圈裡延遲載入關聯 | 查詢加上 selectinload(),並以 raiseload("*") 維持現狀 |
這些都不是罕見狀況。每次都是同樣這五個,而且五個裡有四個是設定問題而不是程式問題。
這對抓取端帶來什麼
這樣打造的儲存層,會改變爬蟲被允許做的事。因為寫入是冪等的,worker 可以當機後直接重啟,不需要對帳。因為 upsert 會回報什麼改變了,排程器可以對變動頻繁的頁面提高頻率、對安靜的頁面降低頻率,這是代理支出上最大的槓桿,而且與如何避免被封鎖裡談的做法直接契合。也因為變更饋送本身存在,價格監控這類下游工作可以消費串流,而不必重讀整張表。
抓取階段之所以受到關注,是因為封鎖與帳單都發生在那裡。但決定你到底要為多少抓取付錢的,是儲存階段。