"""增量数据同步管道:第三方接口(限流 + 不稳定)→ 校验 → 幂等写入 → 数据质量检查 → 告警。 只用标准库(sqlite3 代替真实数据库)。`python pipeline.py` 直接运行演示,test_pipeline.py 是测试。 要点: - 水位线(watermark):记录已同步到的 (updated_at, id),下次从这里继续,可断点续跑 - 限流:令牌桶控制调用频率,遇到 429 按 Retry-After 等待 - 重试:只重试可重试的错误,指数退避 + 抖动,有次数上限 - 幂等:按主键 upsert,并且只接受更新的版本,重复跑结果一样 - 坏数据进入死信表,不阻塞整批;质量检查不通过就告警并且不推进水位线 """ from __future__ import annotations import random import sqlite3 import time from dataclasses import dataclass, field from typing import Callable, Iterable class RetryableError(Exception): """网络超时、5xx、429 等,可以重试。""" def __init__(self, msg: str, retry_after: float | None = None): super().__init__(msg) self.retry_after = retry_after class FatalError(Exception): """401、参数错误等,重试没有意义。""" class QualityError(Exception): """数据质量检查不通过。""" @dataclass class TokenBucket: rate: float # 每秒补充的令牌数 capacity: float now: Callable[[], float] = time.monotonic sleep: Callable[[float], None] = time.sleep tokens: float = field(init=False) last: float = field(init=False) def __post_init__(self) -> None: self.tokens = self.capacity self.last = self.now() def acquire(self) -> None: while True: t = self.now() self.tokens = min(self.capacity, self.tokens + (t - self.last) * self.rate) self.last = t if self.tokens >= 1: self.tokens -= 1 return self.sleep((1 - self.tokens) / self.rate) def with_retry(fn: Callable[[], list[dict]], *, attempts: int = 5, base: float = 0.2, cap: float = 5.0, sleep: Callable[[float], None] = time.sleep, rng: random.Random | None = None) -> list[dict]: rng = rng or random.Random() for i in range(attempts): try: return fn() except RetryableError as e: if i == attempts - 1: raise delay = e.retry_after if e.retry_after is not None else rng.uniform(0, min(cap, base * 2 ** i)) # 全抖动 sleep(delay) raise AssertionError("unreachable") SCHEMA = """ CREATE TABLE IF NOT EXISTS orders (id TEXT PRIMARY KEY, amount_cents INTEGER NOT NULL, status TEXT NOT NULL, updated_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS dead_letters (source_id TEXT, reason TEXT, payload TEXT, at TEXT DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS watermarks (job TEXT PRIMARY KEY, updated_at TEXT NOT NULL, last_id TEXT NOT NULL); """ VALID_STATUS = {"paid", "refunded", "cancelled"} def validate(row: dict) -> str | None: if not row.get("id"): return "缺少 id" if not isinstance(row.get("amount_cents"), int) or row["amount_cents"] < 0: return "金额非法" if row.get("status") not in VALID_STATUS: return f"未知状态 {row.get('status')}" if not row.get("updated_at"): return "缺少 updated_at" return None @dataclass class RunResult: fetched: int = 0 written: int = 0 dead: int = 0 alerts: list[str] = field(default_factory=list) committed: bool = False def run_sync(conn: sqlite3.Connection, fetch_page: Callable[[str, str, int], list[dict]], *, job: str = "orders", page_size: int = 100, bucket: TokenBucket | None = None, max_dead_ratio: float = 0.05, alert: Callable[[str], None] = print, sleep: Callable[[float], None] = time.sleep) -> RunResult: """从水位线开始分页拉取。fetch_page(after_updated_at, after_id, limit) 返回按 (updated_at, id) 升序的数据。""" conn.executescript(SCHEMA) wm = conn.execute("SELECT updated_at, last_id FROM watermarks WHERE job = ?", (job,)).fetchone() after_ts, after_id = wm if wm else ("", "") res = RunResult() try: with conn: # 整次运行一个事务:质量检查不过就整体回滚,水位线不前进 while True: if bucket: bucket.acquire() page = with_retry(lambda: fetch_page(after_ts, after_id, page_size), sleep=sleep) if not page: break for row in page: res.fetched += 1 reason = validate(row) if reason: res.dead += 1 conn.execute("INSERT INTO dead_letters (source_id, reason, payload) VALUES (?, ?, ?)", (str(row.get("id")), reason, repr(row))) continue # 幂等 upsert:只有来源版本更新时才覆盖,乱序或重复的数据不会把新值改回旧值 conn.execute( """INSERT INTO orders (id, amount_cents, status, updated_at) VALUES (:id, :amount_cents, :status, :updated_at) ON CONFLICT(id) DO UPDATE SET amount_cents = excluded.amount_cents, status = excluded.status, updated_at = excluded.updated_at WHERE excluded.updated_at > orders.updated_at""", row) res.written += 1 last = page[-1] after_ts, after_id = last.get("updated_at") or after_ts, str(last.get("id") or after_id) if len(page) < page_size: break # 数据质量检查 if res.fetched and res.dead / res.fetched > max_dead_ratio: raise QualityError(f"坏数据比例 {res.dead}/{res.fetched} 超过阈值 {max_dead_ratio:.0%}") conn.execute("INSERT INTO watermarks (job, updated_at, last_id) VALUES (?, ?, ?) " "ON CONFLICT(job) DO UPDATE SET updated_at = excluded.updated_at, last_id = excluded.last_id", (job, after_ts, after_id)) res.committed = True except QualityError as e: res.alerts.append(str(e)) alert(f"[告警] 任务 {job}:{e},本次结果已回滚,水位线未推进") return res class FakeSource: """模拟第三方接口:按 (updated_at, id) 分页;可以注入 429 / 超时。""" def __init__(self, rows: list[dict], failures: Iterable[Exception] = ()): self.rows = sorted(rows, key=lambda r: (r["updated_at"], r["id"])) self.failures = list(failures) self.calls = 0 def __call__(self, after_ts: str, after_id: str, limit: int) -> list[dict]: self.calls += 1 if self.failures: raise self.failures.pop(0) out = [r for r in self.rows if (r["updated_at"], r["id"]) > (after_ts, after_id)] return [dict(r) for r in out[:limit]] if __name__ == "__main__": rows = [{"id": f"o{i:03d}", "amount_cents": 100 * i, "status": "paid", "updated_at": f"2026-10-23T10:{i // 60:02d}:{i % 60:02d}"} for i in range(250)] rows.append({"id": "bad1", "amount_cents": -5, "status": "paid", "updated_at": "2026-10-23T11:00:00"}) src = FakeSource(rows, failures=[RetryableError("429 Too Many Requests", retry_after=0.01), RetryableError("timeout")]) conn = sqlite3.connect(":memory:") r1 = run_sync(conn, src, page_size=100, bucket=TokenBucket(rate=50, capacity=5), sleep=lambda s: None) print(f"第一次:拉取 {r1.fetched},写入 {r1.written},死信 {r1.dead},接口调用 {src.calls} 次(含 2 次失败重试)") r2 = run_sync(conn, src, page_size=100, sleep=lambda s: None) print(f"第二次(没有新数据):拉取 {r2.fetched}") total = conn.execute("SELECT COUNT(*) FROM orders").fetchone()[0] print(f"orders 表 {total} 行,水位线 {conn.execute('SELECT updated_at, last_id FROM watermarks').fetchone()}") assert (r1.written, r1.dead, r2.fetched, total) == (250, 1, 0, 250)