a2270ee1b2
新手引导视频:运营后台上传 MP4(上限 100MB,魔数校验只认 ISO BMFF), App 端在领券等候浮层前 N 次以引导视频替代广告。新增 guide_video 的 model/schema/repository/router(App 侧 + 后台侧)与播放记录表迁移。 美团券:首页「销量最高 / 智能推荐」两个 tab 改游标分页,配套两条 (city_id, dedup_key, 排序键 DESC) 复合索引,让 Postgres 顺着索引流式 去重,免掉每翻一页重排整城券的开销。美团 CPS client 在 lifespan 预热 并在关闭时释放连接池。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
98 lines
4.2 KiB
Python
98 lines
4.2 KiB
Python
"""美团搜索翻页游标(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)
|