"""美团搜索翻页游标(searchId)缓存 —— 「距离最近」tab 翻页提速。 **问题**:美团 query_coupon 的搜索结果只能靠 searchId 续页(pageNo 翻不动),而我们的 HTTP 接口 是无状态的、客户端只传页码。原实现因此每次都从第 1 页顺序重放到第 N 页 —— 取第 N 页要向美团发 N 次请求,用户越往下滑越慢(第 5 页 ≈ 第 1 页的 5 倍耗时,且每次调用都可能撞 402 限流)。 **做法**:把「翻到第 n 页所需的 searchId」按 (量化坐标, platform, 关键词) 记下来, 下次请求第 n 页直接一发命中;未命中才从缓存里最深的那一页往后重放,并把沿途游标补进缓存。 稳态下每翻一页恒定 1 次请求。 **坐标量化**到 2 位小数(~1km),与 `utils/meituan_city` 同口径:原始 GPS 每次抖动到小数点后 5~6 位, 不量化几乎不命中缓存;而同一路搜索的排序原点相差 1km 以内,对券列表的实际顺序无感知差别。 **TTL** 取 10 分钟:searchId 属于上游会话态,放太久会翻到过期游标(调用失败 → 调用方回退重放)。 缓存是**进程内**的,多 worker 各存一份、不共享,这没问题:命中率只影响快慢,不影响正确性。 """ from __future__ import annotations import threading import time # 缓存键:(量化纬度, 量化经度, platform, 搜索词) RouteKey = tuple[float, float, int, str] _TTL_SEC = 10 * 60 _MAX_ROUTES = 512 # 最多缓存多少条搜索路线(每条 = 一个坐标×平台×关键词) _MAX_PAGES_PER_ROUTE = 60 # 单条路线最多记多少页游标,防某个坐标被无限翻页撑爆 # {route_key: {page_no: (search_id, 写入时刻)}};page_no 表示「用这个 searchId 能取到第几页」。 # 第 1 页不需要 searchId(直接 pageNo=1),故永远不入表。 _cursors: dict[RouteKey, dict[int, tuple[str, float]]] = {} _lock = threading.Lock() def route_key(latitude: float, longitude: float, platform: int, keyword: str) -> RouteKey: """构造缓存键(坐标量化到 ~1km)。""" return (round(latitude, 2), round(longitude, 2), platform, keyword) def lookup(key: RouteKey, page: int) -> tuple[int, str | None]: """找到能最省调用地拿到第 `page` 页的起点。 返回 (起始页, 该页要用的 search_id): - (page, "xxx") 缓存命中 → 调用方一发直达第 page 页 - (m, "xxx") 命中更浅的第 m 页(1 < m < page)→ 从第 m 页重放到第 page 页 - (1, None) 全未命中 → 从第 1 页(pageNo=1)重放 """ if page <= 1: return 1, None now = time.time() with _lock: pages = _cursors.get(key) if not pages: return 1, None for p in range(page, 1, -1): hit = pages.get(p) if hit is None: continue search_id, wrote_at = hit if now - wrote_at > _TTL_SEC: pages.pop(p, None) # 过期即清,继续往浅了找 continue return p, search_id return 1, None def remember(key: RouteKey, page: int, search_id: str) -> None: """记下「用 search_id 可取到第 page 页」。page<=1 无意义(第 1 页不用游标)。""" if page <= 1 or not search_id: return now = time.time() with _lock: pages = _cursors.get(key) if pages is None: if len(_cursors) >= _MAX_ROUTES: _evict_oldest_route_locked() pages = _cursors[key] = {} pages[page] = (search_id, now) if len(pages) > _MAX_PAGES_PER_ROUTE: # 越浅的页越容易被重新走到,优先丢最深的那些(它们下次多半也过期了) for p in sorted(pages, reverse=True)[: len(pages) - _MAX_PAGES_PER_ROUTE]: pages.pop(p, None) def drop(key: RouteKey) -> None: """整条路线作废。用在「拿缓存游标去请求却失败」时(多半是上游 searchId 过期)。""" with _lock: _cursors.pop(key, None) def _evict_oldest_route_locked() -> None: """淘汰最久没更新过的一条路线(调用方须已持锁)。""" oldest_key = min( _cursors, key=lambda k: max((w for _, w in _cursors[k].values()), default=0.0), ) _cursors.pop(oldest_key, None)