# 从 3 小时到 3 分钟:三平台商品同步是怎么提速的(附 Python 源码)
> 60 倍提速不是靠某一个技巧,是四个优化叠加。每个贡献多少,本文算给你看。
## 一、一个真实的慢
某个多平台商家,3000 个商品,每天要同步一次三平台的商品数据(标题、价格、库存、状态)。
原来的脚本:串行逐个拉,每个商品调一次详情接口,拉完逐条写库。跑一次 3 小时。
运营的抱怨很直接:「我早上想看昨天的库存,脚本要到中午才跑完,看的是上午的数据。」
更糟的是,**没人说得清 3 小时都花在哪**。有人说接口慢,有人说服务器不行,有人说数据库写不动。都在猜。
## 二、第一步:先测量,别猜
性能优化的第一原则:**先测量,再动手。** 我在这上面吃过亏——曾经花一天优化数据库索引,后来 profile 一跑发现数据库只占 2% 的时间。
给脚本加了个简单的计时器,跑一次全量,结果如下:
| 环节 | 耗时 | 占比 |
| ---------------- | ------ | ---- |
| 调接口等响应(3000 次串行) | 172 分钟 | 96% |
| JSON 解析 + 字段映射 | 4 分钟 | 2% |
| 逐条写数据库 | 3 分钟 | 2% |
| 其他(重试、日志) | 1 分钟 | 0.5% |
结论非常清晰:**96% 的时间在等网络**。
这不是「接口慢」,是**串行等 3000 次**。每个请求平均 3.4 秒,串行乘以 3000,就是 172 分钟。
这个测量直接决定了优化顺序:先解决等待(并发),再减少请求数(增量),写库那 3 分钟排最后。
测量代码很简单,就几十行,但它决定了后面所有力气往哪使:
```python
# timer.py
import time
from collections import defaultdict
class StageTimer:
"""分阶段计时,跑完输出每阶段耗时和占比"""
def __init__(self):
self.spent = defaultdict(float)
self.counts = defaultdict(int)
self._t0 = None
self._current = None
def start(self, stage: str):
self._current = stage
self._t0 = time.perf_counter()
def stop(self):
if self._current and self._t0 is not None:
self.spent[self._current] += time.perf_counter() - self._t0
self.counts[self._current] += 1
self._current = None
def report(self) -> list[tuple[str, float, float]]:
total = sum(self.spent.values()) or 1.0
rows = sorted(self.spent.items(), key=lambda x: -x[1])
return [(name, sec, sec / total) for name, sec in rows]
def print(self):
print(f"\n{'环节':<24}{'次数':>8}{'耗时':>12}{'占比':>8}")
for name, sec, pct in self.report():
print(f"{name:<24}{self.counts[name]:>8}{sec:>10.1f}s{pct:>8.1%}")
```
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
## 三、优化一:并发——最直觉,但有天花板
96% 在等网络,那并发就是第一刀。把 3000 个串行请求改成 20 路并发:
```python
# concurrent.py
import asyncio
import time
class RateGate:
"""
匀速限流闸门:保证全进程 QPS 不超过上限。
比每个任务自己 sleep 更准,因为它是全局视角。
"""
def __init__(self, qps: float):
self.interval = 1.0 / qps
self._next_slot = time.monotonic()
self._lock = asyncio.Lock()
async def wait(self):
async with self._lock:
now = time.monotonic()
wait = self._next_slot - now
if wait > 0:
await asyncio.sleep(wait)
self._next_slot += self.interval
else:
self._next_slot = now + self.interval
async def fetch_all(ids: list[str], fetch_one, concurrency: int = 20,
qps: float = 10.0) -> list:
"""
并发拉取。两个旋钮:
- concurrency:同时在飞的请求数(受限于连接和服务端)
- qps:每秒发起多少个(受限于平台限流)
两个都要设,只设 concurrency 会被限流打回来
"""
gate = RateGate(qps)
sem = asyncio.Semaphore(concurrency)
results = [None] * len(ids)
async def one(idx: int, item_id: str):
async with sem:
await gate.wait()
results[idx] = await fetch_one(item_id)
await asyncio.gather(*[one(i, x) for i, x in enumerate(ids)])
return results
```
注意这段代码里有两个旋钮:`concurrency`(同时在飞的请求数)和 `qps`(每秒发起多少)。
**只设并发不设 QPS,是新手最常见的翻车点。** 20 路并发、每秒发出 20 个请求,直接撞平台限流,然后全部进入重试,反而更慢。
实测:3 小时 → 9 分钟。20 倍。
但天花板立刻出现了:平台限流 QPS 10,3000 个商品理论下限就是 300 秒 = 5 分钟。**并发再高也没用,限流是硬约束。**
所以第二刀必须换思路:不是「把请求发得更快」,而是「发更少的请求」。
## 四、优化二:少发请求——本文最值钱的洞察
重新看需求:3000 个商品每天同步。真的 3000 个都变了吗?
统计了一下:**每天真正变化的商品不到 10%**(改价、改库存、上下架)。90% 的请求在拉「没变的数据」。
那能不能先知道哪些变了,再只拉那些?
能。**商品列表接口一次能返回几十条记录的 `modified` 时间戳**——用 100 次列表请求(每次 30 条)就能拿到全部 3000 个商品的更新时间,替代 3000 次详情请求。
```python
# incremental.py
import json
from datetime import datetime
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
def list_all_modified(client, shop_id: str) -> dict[str, str]:
"""
分页拉全量商品列表,只取 item_id -> modified 映射。
3000 个商品 = 100 次轻量请求,代替 3000 次重量请求
"""
mapping = {}
page = 1
while True:
rows = client.list_products(shop_id, page=page, page_size=30)
if not rows:
break
for r in rows:
mapping[str(r["item_id"])] = r["modified"]
if len(rows) < 30:
break
page += 1
return mapping
def pick_changed(current: dict[str, str],
last_sync: dict[str, str] | None) -> list[str]:
"""
和上次同步记录对比,只返回有变化的 item_id
新商品 + 修改过的商品 + 已删除的商品(单独处理)
"""
last = last_sync or {}
changed = [iid for iid, m in current.items() if last.get(iid) != m]
removed = [iid for iid in last if iid not in current]
if removed:
print(f"[sync] {len(removed)} 个商品已下架,需清理")
return changed
def save_sync_state(state_path: str, current: dict[str, str]):
with open(state_path, "w", encoding="utf-8") as f:
json.dump(current, f, ensure_ascii=False)
def load_sync_state(state_path: str) -> dict[str, str] | None:
try:
with open(state_path, encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return None
```
账算一下:
- 原来:3000 次详情请求(每次约 3.4 秒等效负载)
- 现在:100 次列表请求 + 约 300 次详情请求 = 400 次请求
**请求数降到原来的 13%。** 这比任何并发技巧的收益都大。
这个思路的本质:**用便宜的请求替代昂贵的请求。** 列表接口一次返回 30 条的元数据,单价是详情接口的三十分之一。先便宜地筛一遍,再对筛出来的做贵的操作。
## 五、优化三 + 四:写库和连接
到这一步,网络等待只剩 40 秒左右,剩下的小头也该收一收。
**逐条写库 → 批量写。** SQLite 用 `executemany`,MySQL 用多值 INSERT:
```python
# bulk_write.py
import sqlite3
def bulk_upsert(conn: sqlite3.Connection, rows: list[tuple]):
"""
批量 upsert。3000 条逐条写 = 3000 次事务;
executemany 一批 = 1 次事务,差两个数量级
"""
conn.executemany("""
INSERT INTO products(platform, item_id, title, price, stock, synced_at)
VALUES(?,?,?,?,?,datetime('now'))
ON CONFLICT(platform, item_id) DO UPDATE SET
title=excluded.title, price=excluded.price,
stock=excluded.stock, synced_at=excluded.synced_at
""", rows)
conn.commit()
```
**每次新建连接 → 复用连接。** 这个最隐蔽:每次请求新建 TCP + TLS 握手要 200~~400ms,3000 次就是 10~~20 分钟。`httpx.AsyncClient` 复用连接后,握手只发生一次。
```python
# 复用连接:整个进程共享一个 client
# 错误:每次 fetch_one 里 with httpx.AsyncClient() as c: ...
# 正确:模块级或应用级创建一次
import httpx
client = httpx.AsyncClient(
timeout=10.0,
limits=httpx.Limits(max_connections=20, max_keepalive_connections=20),
)
```
顺带把限流做成自适应(AIMD),限流报错就减半 QPS,平稳运行则缓慢恢复:
```python
# adaptive.py
import random
class AdaptiveRate:
"""
自适应限速(AIMD):
- 撞限流:QPS 减半(Multiplicative Decrease)
- 连续 50 次成功:QPS +10%(Additive Increase)
比写死一个保守值快得多,也比写死激进值安全
"""
def __init__(self, qps: float = 5.0, min_qps: float = 0.5, max_qps: float = 20.0):
self.qps = qps
self.min_qps = min_qps
self.max_qps = max_qps
self._ok_streak = 0
self._next_slot = 0.0
def on_success(self):
self._ok_streak += 1
if self._ok_streak >= 50:
self.qps = min(self.max_qps, self.qps * 1.1)
self._ok_streak = 0
def on_rate_limited(self):
self.qps = max(self.min_qps, self.qps / 2)
self._ok_streak = 0
@property
def interval(self) -> float:
return 1.0 / self.qps
async def wait(self):
"""带抖动的等待,避免多任务同刻发车"""
import asyncio, time
now = time.monotonic()
wait = max(0.0, self._next_slot - now)
if wait:
await asyncio.sleep(wait)
self._next_slot = max(now, self._next_slot) + self.interval * random.uniform(0.8, 1.2)
```
## 六、结果:60 倍是怎么来的
四个优化叠加,每一步的贡献都有数:
| 阶段 | 耗时 | 相比上一步 | 手段 |
| ------------------ | ---------- | -------- | ------------------------------- |
| 原始 | 180 分钟 | — | 串行 + 全量 + 逐条写 |
| + 并发 20 + QPS 限流 | 9 分钟 | **20×** | asyncio 并发,全局 QPS 闸门 |
| + 列表预筛增量 | 约 1.2 分钟 | **7.5×** | 100 次列表替代 2820 次详情 |
| + 批量写 + 连接复用 + 自适应 | **约 1 分钟** | 1.3× | executemany + keep-alive + AIMD |
两个观察值得记住:
1. **最大的收益来自「少做事」(7.5×)而不是「做得快」(20× 有硬顶)。** 并发受限于平台限流,少发请求不受任何限制。
2. **最后一个优化只贡献 1.3×,但它是「从 1 分钟到 40 秒」和「偶尔翻车」的区别。** 连接复用和自适应限速不影响平均值,影响的是稳定性。
## 七、完整主流程
把四个优化串起来:
```python
# sync_main.py
import asyncio
import sqlite3
from timer import StageTimer
from concurrent import fetch_all
from incremental import (list_all_modified, pick_changed,
load_sync_state, save_sync_state)
from bulk_write import bulk_upsert
from adaptive import AdaptiveRate
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
async def run_sync(clients: dict, shop_id: str,
state_path: str = "sync_state.json"):
t = StageTimer()
# 1. 增量预筛:100 次轻量请求拿全量 modified
t.start("列表预筛(3平台)")
current = {}
for platform, client in clients.items():
current[platform] = await list_all_modified(client, shop_id)
t.stop()
# 2. 对比上次状态,只保留有变化的
last = load_sync_state(state_path)
t.start("增量对比")
changed_map = {p: pick_changed(m, last and last.get(p))
for p, m in current.items()}
t.stop()
# 3. 并发拉详情(自适应限速)
t.start("详情并发拉取")
rows = []
for platform, client in clients.items():
ids = changed_map[platform]
if not ids:
continue
rate = AdaptiveRate(qps=5.0)
async def fetch_one(iid):
await rate.wait()
try:
r = await client.get_product_detail(iid)
rate.on_success()
return (platform, iid, r["title"], float(r["price"]),
r["stock"])
except Exception as e:
if "limit" in str(e).lower():
rate.on_rate_limited()
raise
results = await fetch_all(ids, fetch_one,
concurrency=20, qps=10.0)
rows.extend(r for r in results if r)
t.stop()
# 4. 批量写库
t.start("批量写库")
conn = sqlite3.connect("products.db")
bulk_upsert(conn, rows)
conn.close()
t.stop()
# 5. 保存同步状态,供下次增量对比
save_sync_state(state_path, current)
t.print()
return len(rows)
if __name__ == "__main__":
from client import PlatformClient
clients = {
"taobao": PlatformClient("taobao", "key", "secret"),
"alibaba": PlatformClient("alibaba", "key", "secret"),
"jd": PlatformClient("jd", "key", "secret"),
}
asyncio.run(run_sync(clients, "your_shop_id"))
```
## 八、四个坑
1. **并发数和 QPS 是两个旋钮,必须分开调。** 只调并发会被限流打回来进入重试风暴,比串行还慢。
2. **增量依赖 `modified` 字段,但不是所有平台都可靠。** 有的平台批量改价不更新 `modified`。建议每周跑一次全量对账,增量为主、全量兜底——和价格同步那篇的思路一致。
1. **`fetch_one` 里闭包引用循环变量是经典 bug。** 上面代码里 `for platform, client in clients.items()` 里面定义 `fetch_one`,每次循环重新定义,没有引用问题;但如果把函数定义提到循环外再绑定变量,就会全部引用最后一次的 `client`。测试时三个平台的数据混在一起,多半是这个问题。
2. **首次运行没有同步状态,会走全量。** 这是正确行为(第一次本来就该全量),但要提醒使用者:第一次跑慢是正常的,从第二次开始才是 3 分钟。
## 九、最后
回头看这 60 倍:
- 并发(20×):把等待重叠起来,但受限于平台限流
- 增量(7.5×):**用便宜的请求替代昂贵的请求**,收益最大
- 批量写 + 连接复用(1.3×):平均值贡献小,稳定性贡献大
顺序也重要:**先测量再动手**。如果先花一天优化数据库(只占 2%),一天就白费了。
代码在上面,`RateGate`、`AdaptiveRate`、增量预筛这三个组件是通用的,可以拆到任何同步场景里用。
有在做的可以聊聊,你们的同步脚本跑多久,profile 过吗?
---
**标签**:`Python` `性能优化` `电商` `异步编程` `数据同步`