搜索性能优化很少靠某个“神奇缓存”一招制胜。Uber Eats 对搜索链路的改造覆盖了指标定义、召回规模、数据补全、广告数据、基础设施和研发流程,并报告端到端延迟降低了 50%。这次优化最值得借鉴的地方,是团队没有只盯着服务端总耗时,而是围绕用户真正看到首屏结果的时刻重新拆解整条链路。
先测用户看到什么,而不只是接口何时结束
传统搜索监控通常记录接口从收到请求到返回完整响应的时间。但对外卖搜索而言,用户更关心的是首屏商品何时可见、是否可以开始滚动和点击。
Uber Eats 引入了 Above-the-Fold(ATF,首屏区域)测量。这个指标改变了优化方向:系统不一定要先准备好所有候选、广告和非首屏字段,而应优先让首屏结果达到可展示状态。
实践中可以把一次搜索拆成几类时间:
- 检索耗时:找到候选商品或商家 ID。
- 首屏补全耗时:补齐首屏所需的名称、图片、价格和可售状态。
- ATF Ready:客户端真正完成首屏渲染的时间。
- 完整响应耗时:非首屏结果、广告或附加字段全部就绪的时间。
服务端的“首屏数据已返回”不等于用户已经看到首屏。更可靠的做法是给请求分配 trace ID,由客户端在首屏组件完成渲染后上报 ATF 指标,再与网关、检索和补全阶段的 trace 关联。
少做检索工作,比让检索代码跑快更直接
来源摘要提到,改造包括减少 retrieval work,也就是减少召回阶段必须完成的工作量。这通常比单纯优化某个循环更有效。
可以从几个问题入手:
- 首屏真的需要当前数量的候选吗?
- 是否对最终不会进入排序阶段的候选执行了昂贵特征查询?
- 商品、商家和广告是否重复读取了相同数据?
- 首屏之外的字段能否延迟加载?
- 召回数量能否根据查询类型动态调整,而不是使用统一上限?
减少召回量不能只看延迟。候选变少可能降低相关性、覆盖率和广告填充率,因此需要同时观察零结果率、点击率、转化率和排序质量。合理策略往往是“先用较小预算生成首屏,再按需扩展”,而不是简单砍掉一半候选。
并行补全与广告数据解耦
搜索系统先召回 ID,再补齐图片、价格、库存、配送信息等字段,这一步通常称为 hydration。若商品补全、商家补全和广告数据依次执行,总延迟会接近各阶段耗时之和;没有依赖关系的任务并行执行后,关键路径则更接近最慢任务的耗时。
Uber Eats 的改造包含并行 hydration 和广告数据重新设计。摘要没有披露具体接口,下面给出一个可运行的简化示例,用来演示如何让广告加载与商品召回并行,并并发补全候选。它不是 Uber 的实际实现。
将以下内容保存为 app.py:
import asyncio
import time
from fastapi import FastAPI, Response
app = FastAPI()
async def retrieve(query: str, limit: int) -> list[int]:
await asyncio.sleep(0.08) # 模拟检索服务
return list(range(1, limit + 1))
async def hydrate_product(product_id: int) -> dict:
await asyncio.sleep(0.06) # 模拟并行访问商品或库存服务
return {
'id': product_id,
'name': f'{product_id} 号商品',
'price': 9.9 + product_id,
'available': True,
}
async def load_ads(query: str) -> list[dict]:
await asyncio.sleep(0.05) # 模拟独立广告数据源
return [{'campaign_id': 'demo-1', 'query': query}]
@app.get('/search')
async def search(q: str, response: Response):
started = time.perf_counter()
# 广告数据不依赖自然结果,可以提前并行启动。
ads_task = asyncio.create_task(load_ads(q))
retrieval_started = time.perf_counter()
product_ids = await retrieve(q, limit=8)
retrieval_ms = (time.perf_counter() - retrieval_started) * 1000
hydration_started = time.perf_counter()
products = await asyncio.gather(
*(hydrate_product(product_id) for product_id in product_ids)
)
hydration_ms = (time.perf_counter() - hydration_started) * 1000
# 假设前 4 个商品足以组成首屏。
atf_ready_ms = (time.perf_counter() - started) * 1000
ads = await ads_task
total_ms = (time.perf_counter() - started) * 1000
response.headers['Server-Timing'] = (
f'retrieval;dur={retrieval_ms:.1f}, '
f'hydration;dur={hydration_ms:.1f}, '
f'atf;dur={atf_ready_ms:.1f}, total;dur={total_ms:.1f}'
)
return {
'above_the_fold': products[:4],
'remaining': products[4:],
'ads': ads,
}
启动并测试:
python -m venv .venv
source .venv/bin/activate
pip install fastapi 'uvicorn[standard]'
uvicorn app:app --reload
curl -i 'http://127.0.0.1:8000/search?q=pizza'
这个示例适合验证并发结构,但生产环境还要补上超时、连接池、并发上限、取消传播和降级逻辑。例如广告服务超时时,可以返回自然结果而不是拖住整次搜索;库存服务失败时,则要明确是否隐藏商品,避免展示不可售结果。
基础设施优化和 Agentic Coding 应该怎样落地
来源还提到基础设施优化与 agentic coding workflow。前者不应只理解为扩容:跨可用区调用、连接重复建立、序列化成本、线程池排队和缓存热点,都可能进入搜索关键路径。建议按 trace 中的实际耗时排序,再决定优化网络、计算资源还是数据布局。
Agentic coding 更适合处理边界清晰、反馈快速的性能任务,例如:
- 为已有阶段补充 benchmark 和 trace 埋点。
- 批量迁移串行调用,并通过测试验证并发语义。
- 找出重复 RPC、未使用字段和可以合并的数据读取。
- 根据固定压测脚本生成改动前后的延迟报告。
不要让编码代理只追求“平均耗时下降”。提示词或任务说明中应写明正确性约束、P95/P99 目标、最大并发数、超时策略和回滚条件;生成的修改仍需人工审查,并在影子流量或小比例灰度中验证。
下一步:微批处理、按商品召回与流式返回
Uber Eats 还在探索 microbatching、product-based retrieval 和 HTTP multipart streaming。这三个方向分别针对不同瓶颈:
- 微批处理可以合并短时间窗口内的数据访问,提高吞吐量,但会引入等待窗口,批次过大会伤害尾延迟。
- 按商品召回可能让系统更直接地返回用户想买的对象,但需要处理商家可售性、履约范围和同品多店等约束。
- HTTP multipart streaming可以先发送首屏部分,再继续传输剩余结果,不过客户端、网关、缓存和可观测系统都必须正确支持流式响应。
落地时可以遵循一份简短清单:先定义并打通 ATF 指标;找出关键路径中的无效工作;并行化真正独立的任务;为广告和非关键数据设置降级;用 P50、P95、P99 和业务质量共同评估;最后再考虑流式传输等协议级改造。
这次重构传递出的核心经验不是某项特定技术,而是一种优化顺序:先以用户可见结果定义目标,再减少工作、重排依赖、并行执行,最后才用基础设施和协议优化压缩剩余延迟。