功能:新手引导视频 + 美团券首页分页索引 (#167)
Co-authored-by: guke <guke@wonderable.ai> Co-authored-by: 左辰勇 <exinglang@gmail.com> Reviewed-on: #167 Co-authored-by: zuochenyong <zuochenyong@wonderable.ai> Co-committed-by: zuochenyong <zuochenyong@wonderable.ai>
This commit was merged in pull request #167.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"""美团搜索翻页游标(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)
|
||||
Reference in New Issue
Block a user