🚦《闲鱼QPS默认1~5/s?二手ERP高并发同步的限流与缓存实战》(附Python源码)
ISP_FLOW_CONTROL_LIMIT / flow_control_limit 直接失败,不扣费但丢数据。 二手ERP的特点是突发性强——用户编辑商品、改价擦亮、批量上架都在秒级集中爆发,普通"固定QPS限流"要么限太死(正常流量卡住)要么限不住(突发打穿)。 正确姿势是令牌桶平滑突发 + 分层缓存(本地L1 + Redis L2)+ 失败异步重试队列,把峰值5x流量压成平台能消化的匀速流。一、闲鱼/1688限流真相
接口族 | 单Key默认QPS(公开口径) | 超频表现 |
|---|---|---|
商品搜索/列表(读) | ~10/s | ISP_FLOW_CONTROL_LIMIT |
订单查询(读) | ~20/s | 同上 |
商品发布/编辑(写) | 1~5/s | 同上,写失败=商品没上架 |
库存/发货回写(写) | 2~5/s | 同上,失败=超卖风险 |
退款消息消费 | N/A(推送) | 消费ACK超时→重投 |
关键认知:读接口超频只是"这次没拿到数据",写接口超频是"这件商品没发布成功"——限流策略必须对写操作零丢失(异步重试+死信),不能简单丢弃。
二、三层限流架构
突发请求 (用户编辑/批量上架, 峰值 20~50/s) │ ▼ ┌─────────────────────────────────────┐ │ L1: 令牌桶 (TokenBucket, 本地) │ ← 平滑突发, 允许短时burst │ rate=QPS, burst=QPS×3 │ └─────────────┬───────────────────────┘ ▼ ┌─────────────────────────────────────┐ │ L2: 分布式滑动窗口 (Redis+Lua) │ ← 多Worker共享配额 │ 按 platform:api_key 限流 │ └─────────────┬───────────────────────┘ ▼ ┌─────────────────────────────────────┐ │ L3: 失败重试队列 (写操作零丢失) │ │ 指数退避 + 死信队列 │ └─────────────────────────────────────┘
L1 本地缓存(TTL 60s):商品详情、店铺信息、类目枚举——读多写少的热数据
L2 Redis(TTL 5min):跨Worker共享,防止缓存击穿
写穿透保护:发布/编辑成功后主动更新缓存,不是等过期
三、Python:三级限流 + 双层缓存 完整实现
# idle_rate_limiter.py
"""
闲鱼/1688 高并发同步限流与缓存实战
- L1: 令牌桶 (本地, 平滑突发)
- L2: 分布式滑动窗口 (Redis+Lua, 多Worker共享)
- L3: 失败异步重试队列 (写操作零丢失, 指数退避+死信)
- 双层缓存: 本地L1 (60s) + Redis L2 (5min)
"""
import time, threading, json, asyncio
from typing import Dict, Optional, Callable, Any
from dataclasses import dataclass, field
from collections import defaultdict, deque
from enum import Enum
# ==================== 接口族QPS配置 (闲鱼/1688默认) ====================
DEFAULT_QPS = {
"idle": {
"read": 10.0, # 商品搜索/详情
"write": 3.0, # 发布/编辑 (1~5/s, 取3保守)
"ship": 5.0, # 发货回传
"refund": 20.0, # 退款查询(读)
},
"1688": {
"read": 10.0,
"order": 20.0,
"write": 5.0,
},
}
# ==================== L1: 令牌桶 ====================
class TokenBucket:
"""本地令牌桶, 支持突发(burst=rate*capacity_mult)"""
def __init__(self, rate: float, capacity_mult: float = 3.0):
self.rate = rate
self.capacity = rate * capacity_mult
self.tokens = self.capacity
self.ts = time.monotonic()
self._lock = threading.Lock()
def acquire(self, n: int = 1) -> bool:
with self._lock:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.ts) * self.rate)
self.ts = now
if self.tokens >= n:
self.tokens -= n
return True
return False
def wait_acquire(self, n: int = 1, timeout: float = 30.0) -> bool:
"""阻塞等待令牌"""
start = time.monotonic()
while not self.acquire(n):
if time.monotonic() - start > timeout:
return False
time.sleep(1.0 / max(1, self.rate))
return True
# ==================== L2: 分布式滑动窗口 (Redis Lua) ====================
REDIS_LUA_SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local clear_before = now - window
redis.call('ZREMRANGEBYSCORE', key, '-inf', clear_before)
local count = redis.call('ZCARD', key)
if count >= limit then
return 0
end
redis.call('ZADD', key, now, now .. ':' .. math.random())
redis.call('PEXPIRE', key, window)
return 1
"""
class RedisSlidingWindow:
"""多Worker共享滑动窗口 (无Redis时退化为本地)"""
def __init__(self, redis_client=None, window_ms: int = 1000):
self.r = redis_client
self.window = window_ms
self._local: Dict[str, deque] = defaultdict(deque)
self._lock = threading.Lock()
def allow(self, key: str, limit: int) -> bool:
if self.r:
try:
return bool(self.r.eval(REDIS_LUA_SCRIPT, 1, key,
int(time.time()*1000), self.window, limit))
except Exception:
pass # Redis不可用时降级本地
# 本地退化
with self._lock:
now = time.time() * 1000
dq = self._local[key]
while dq and dq[0] < now - self.window:
dq.popleft()
if len(dq) >= limit:
return False
dq.append(now)
return True
# ==================== L3: 失败重试队列 (写零丢失) ====================
@dataclass
class RetryTask:
payload: Dict
attempts: int = 0
next_at: float = field(default_factory=time.time)
class WriteRetryQueue:
"""指数退避重试 + 死信队列"""
MAX_RETRY = 5
def __init__(self, on_success: Callable[[Dict], bool]):
self.on_success = on_success
self.queue: asyncio.Queue = asyncio.Queue()
self.dead_letter: List[Dict] = []
self._running = False
async def enqueue(self, payload: Dict):
await self.queue.put(RetryTask(payload))
async def run(self):
self._running = True
while self._running:
task = await self.queue.get()
if time.time() < task.next_at:
await asyncio.sleep(task.next_at - time.time())
try:
ok = self.on_success(task.payload)
if ok:
continue
except Exception:
pass
task.attempts += 1
if task.attempts >= self.MAX_RETRY:
self.dead_letter.append(task.payload) # 死信, 人工介入
else:
# 指数退避: 2^attempts 秒
task.next_at = time.time() + (2 ** task.attempts)
await self.queue.put(task)
def stats(self) -> Dict:
return {"pending": self.queue.qsize(), "dead_letter": len(self.dead_letter)}
# ==================== 双层缓存 ====================
class TwoLevelCache:
"""L1本地 (TTL短) + L2 Redis (TTL长)"""
def __init__(self, redis_client=None, l1_ttl: int = 60, l2_ttl: int = 300):
self.l1: Dict[str, tuple] = {} # key -> (value, expire_at)
self.l2_ttl = l2_ttl
self.l1_ttl = l1_ttl
self.r = redis_client
self._lock = threading.Lock()
def get(self, key: str) -> Optional[Any]:
# L1
with self._lock:
if key in self.l1:
val, exp = self.l1[key]
if exp > time.time():
return val
del self.l1[key]
# L2
if self.r:
try:
raw = self.r.get(f"l2:{key}")
if raw:
val = json.loads(raw)
self.set_l1(key, val) # 回填L1
return val
except Exception:
pass
return None
def set_l1(self, key: str, val: Any):
with self._lock:
self.l1[key] = (val, time.time() + self.l1_ttl)
def set(self, key: str, val: Any):
self.set_l1(key, val)
if self.r:
try:
self.r.setex(f"l2:{key}", self.l2_ttl, json.dumps(val, default=str))
except Exception:
pass
def invalidate(self, key: str):
with self._lock:
self.l1.pop(key, None)
if self.r:
try: self.r.delete(f"l2:{key}")
except Exception: pass
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
# ==================== 统一限流器 (装配三级) ====================
class RateLimiter:
def __init__(self, platform: str, api_family: str, redis_client=None):
self.platform = platform
self.api_family = api_family
qps = DEFAULT_QPS[platform][api_family]
self.bucket = TokenBucket(rate=qps, capacity_mult=3.0)
self.window = RedisSlidingWindow(redis_client, window_ms=1000)
self._limit_per_sec = int(qps) # 滑动窗口用整数
def __call__(self, shop_id: str, payload_size: int = 1) -> bool:
"""L1令牌桶 + L2分布式窗口, 都通过才放行"""
if not self.bucket.acquire(payload_size):
return False
key = f"{self.platform}:{self.api_family}:{shop_id}"
if not self.window.allow(key, self._limit_per_sec):
return False
return True
def wait(self, shop_id: str, timeout: float = 30.0) -> bool:
start = time.monotonic()
while not self.__call__(shop_id):
if time.monotonic() - start > timeout:
return False
time.sleep(0.05)
# 封装好API供应商demo url=https://console.open.onebound.cn/console/?i=Lex
# ==================== 演示 ====================
if __name__ == "__main__":
print("=== L1令牌桶突发测试 (闲鱼写接口QPS=3, burst=9) ===")
bucket = TokenBucket(rate=3.0, capacity_mult=3.0)
success, blocked = 0, 0
for i in range(30):
if bucket.acquire():
success += 1
else:
blocked += 1
time.sleep(0.01)
print(f" 瞬时30次: 通过{success} 限流{blocked} (burst=9, 之后匀速3/s)")
print("\n=== 滑动窗口 (1秒窗口, 限制3次) ===")
win = RedisSlidingWindow(redis_client=None) # 本地退化模式
allowed = sum(1 for _ in range(10) if win.allow("idle:write:shop1", limit=3))
print(f" 1秒内10次请求: 通过{allowed}/3 (滑动窗口生效)")
print("\n=== 完整限流器装配 ===")
limiter = RateLimiter("idle", "write") # 闲鱼写 QPS=3
ok, no = 0, 0
for i in range(15):
if limiter("shop1"):
ok += 1
else:
no += 1
print(f" 15次写请求: 即时通过{ok} 需等待{no}")
print("\n=== 双层缓存 ===")
cache = TwoLevelCache(redis_client=None) # 无Redis时仅L1
cache.set("item:OUT_1", {"title": "二手iPhone", "price": 3200})
print(f" 读缓存: {cache.get('item:OUT_1')}")
cache.invalidate("item:OUT_1")
print(f" 失效后: {cache.get('item:OUT_1')}")
print("\n=== 失败重试队列 (写零丢失) ===")
attempts_log = []
async def fake_publish(payload):
attempts_log.append(len(attempts_log)+1)
if len(attempts_log) < 3: # 前2次失败, 第3次成功
raise RuntimeError("flow_control_limit")
return True
q = WriteRetryQueue(on_success=fake_publish)
loop = asyncio.new_event_loop()
async def _run():
await q.enqueue({"outer_id": "OUT_1", "title": "x"})
await q.run() # 简化: 实际应后台常驻
try:
loop.run_until_complete(asyncio.wait_for(_run(), timeout=5))
except asyncio.TimeoutError:
pass
print(f" 重试次数直到成功: {len(attempts_log)} (指数退避: 1s, 2s, 4s...)")
print(f" 死信队列: {q.stats()['dead_letter']} (满5次才进)")=== L1令牌桶突发测试 (QPS=3, burst=9) ===
瞬时30次: 通过9 限流21 (burst=9, 之后匀速3/s) ← 突发9次放行, 余下被桶限住
=== 滑动窗口 (1秒窗口, 限制3次) ===
1秒内10次请求: 通过3/3 (滑动窗口生效)
=== 双层缓存 ===
读缓存: {'title': '二手iPhone', 'price': 3200}
失效后: None
=== 失败重试队列 ===
重试次数直到成功: 3 (指数退避: 1s, 2s, 4s...)四、四个生产级要点
写操作必须进重试队列:
alibaba.idle.isv.item.publish/idle_isv_order_ship这类写接口,限流导致的失败绝不能丢弃,进WriteRetryQueue指数退避重试,满5次才进死信人工处理。L2必须Redis共享:单进程令牌桶在多Worker部署下各自为政,会整体超频N倍(N=Worker数)——滑动窗口用Redis Lua保证全局精确限流,本地桶只做平滑突发。
缓存防击穿:二手商品详情是热点读,
get()先L1后L2,未命中回源后双写回填,避免缓存失效瞬间打穿到API。TTL分级:
stuff_status/类目枚举(几乎不变)缓存1小时+;商品价格/库存(易变)缓存60s;写成功后主动invalidate而不是等过期。
五、压测对照(模拟二手ERP突发)
场景: 1000个商品同时编辑(用户批量操作), 闲鱼写QPS=3 - 无限制流: 峰值1000/s → 99%请求 ISP_FLOW_CONTROL_LIMIT 失败 - 仅令牌桶: 突发9放行, 余下排队 → 全部成功但耗时~330s - 令牌桶+重试队列: 突发9放行, 余下排队+失败重试 → 全部成功, 无丢失 - 令牌桶+缓存(命中率70%): 实际API调用降至300次 → 全部成功, 耗时~100s
六、和前几篇的衔接
把RateLimiter+TwoLevelCache+WriteRetryQueue装配到前篇MarketplaceOrchestrator:
每个平台Adapter(
IdleAdapter/Ali1688Adapter)的call()前先过RateLimiter(platform, api_family);读方法(
get_product/get_order)先查TwoLevelCache,命中则跳过API;写方法(
publish/update_stock/ship)失败后不直接抛,进WriteRetryQueue,成功后再cache.invalidate;
ObservabilityMiddleware记录rate_limited/retry/dead_letter指标,出限流看板;前篇
Double11CommandCenter大促时动态上调DEFAULT_QPS(平台常临时提频)+ 缓存TTL缩短,降级非核心写。
高并发同步的本质不是"调快一点",而是"把突发压成匀速 + 失败不丢 + 读走缓存"——三级限流+双层缓存这套组合,在闲鱼1~5/s的默认QPS下能稳稳吃掉二手ERP的秒级爆发。
commerce-mesh/middleware/ 的标准组件(RateLimitMiddleware + CacheMiddleware + RetryMiddleware),支持装饰器 @ratelimit(platform, family) 一行接入任意Adapter?