搜索性能不是某个接口的响应时间,而是用户从发起查询到看到可操作结果所经历的完整等待。Uber 对 Uber Eats 搜索链路的多个关键环节进行了重构,并报告端到端延迟降低了 50%。这次优化没有押注单一“银弹”,而是同时调整了首屏指标、召回工作量、数据补全方式、广告数据模型和基础设施。
从“请求完成”转向“首屏可见”
传统搜索系统常把服务端总响应时间作为核心指标,但用户并不需要等待所有结果、广告和附加字段准备完毕,才开始浏览页面。更贴近体验的指标是 Above-the-Fold:首屏内容何时达到可展示、可交互状态。
这会改变性能优化的优先级。假设一次搜索包含以下阶段:
- 查询理解与候选召回;
- 排序;
- 商家、商品、价格等数据补全;
- 广告数据合并;
- 返回首屏和后续结果。
如果首屏只展示少量条目,就没有必要让全部候选的补全工作阻塞第一次渲染。工程上应分别记录:
time_to_first_result:第一个结果可发送的时间;above_the_fold_ready:首屏所需结果全部就绪的时间;full_response_complete:完整响应结束时间;- 各阶段的 p50、p95 和 p99,而不只是平均值。
端到端指标还必须从客户端或边缘入口观察。只测搜索服务本身,可能遗漏网络传输、网关排队、广告依赖以及前端解析造成的延迟。
少做无效召回,并让补全任务并行
摘要提到的两项关键变化是减少 retrieval 工作量和 parallel hydration。它们分别处理“做了太多工作”和“本可并行却串行执行”这两个常见问题。
减少召回工作量并不等于简单缩小结果数量。更合理的做法包括:
- 尽早过滤明显不符合地理位置、营业状态或配送条件的候选;
- 根据首屏容量设置分阶段召回预算;
- 避免为最终不会进入排序前列的候选读取昂贵特征;
- 用离线相关性评估和线上实验确认裁剪没有损害结果质量。
Hydration 则是在候选 ID 确定后,补充名称、图片、价格、配送时间或库存等展示数据。如果多个补全请求互不依赖,串行执行会把延迟相加,并行执行则更接近其中最慢请求的耗时。
下面是一个可直接运行的 Python 示例,用固定延迟模拟三个补全服务,并比较串行与并行执行。它是用于理解模式的简化实践示例,并非 Uber 的生产代码。
import asyncio
import time
async def fetch_profile():
await asyncio.sleep(0.12)
return {"name": "Example Restaurant"}
async def fetch_menu():
await asyncio.sleep(0.18)
return {"items": ["Noodles", "Rice"]}
async def fetch_delivery_eta():
await asyncio.sleep(0.09)
return {"eta_minutes": 25}
async def hydrate_sequentially():
profile = await fetch_profile()
menu = await fetch_menu()
delivery = await fetch_delivery_eta()
return {**profile, **menu, **delivery}
async def hydrate_in_parallel():
profile, menu, delivery = await asyncio.gather(
fetch_profile(),
fetch_menu(),
fetch_delivery_eta(),
)
return {**profile, **menu, **delivery}
async def benchmark(name, operation):
started = time.perf_counter()
result = await operation()
elapsed_ms = (time.perf_counter() - started) * 1000
print(f"{name}: {elapsed_ms:.1f} ms -> {result}")
async def main():
await benchmark("sequential", hydrate_sequentially)
await benchmark("parallel", hydrate_in_parallel)
if __name__ == "__main__":
asyncio.run(main())
保存为 hydration_demo.py 后运行:
python hydration_demo.py
理论上,串行版本约需 390 毫秒,而并行版本接近最慢依赖的 180 毫秒。生产环境还需要增加超时、并发上限、熔断和降级策略,否则并行化可能把压力同时推向多个下游服务。
广告与基础设施不能留在关键路径之外考虑
搜索结果中的广告通常需要额外的候选、预算、资格和排序数据。如果广告数据模型要求跨多个服务串行拼接,即使自然搜索已经很快,整体首屏仍会被广告链路拖慢。Uber 的改造包含广告数据重设计,这提醒团队:广告不是页面完成后的附加模块,而是端到端关键路径的一部分。
设计时可以把首屏数据分为三类:
- 必须就绪:没有它就无法正确展示或交互的数据;
- 允许降级:超时后可以使用缓存、默认值或减少字段的数据;
- 允许延后:可在首屏显示后继续加载的数据。
基础设施优化同样重要,但应建立在链路画像之上。连接复用、序列化格式、线程池配置、跨区域访问和缓存命中率都可能产生收益;如果主要耗时来自过量召回,单纯增加机器只会提高成本。
摘要还提到 agentic coding workflow。代理式编码可以帮助生成基准测试、批量修改调用方式、补充埋点或检查回归,但性能变更仍需由可重复的压测和线上指标验证。尤其要警惕 AI 生成的并发代码遗漏超时、取消传播、资源释放和错误隔离。
下一步方向:微批处理、按商品召回与流式返回
Uber 还在探索 microbatching、product-based retrieval 和 HTTP multipart streaming。这三种方向解决不同层面的问题:
- 微批处理把短时间窗口内的小请求合并,提升模型推理或后端读取效率,但会引入等待批次形成的额外延迟;
- 按商品召回让搜索直接面向用户想买的商品,而不只依赖商家级匹配,但索引规模、库存新鲜度和排序特征会更复杂;
- HTTP multipart streaming允许服务端先发送已准备好的首屏内容,再持续发送剩余分片,从而把“首个可见结果”和“完整响应完成”解耦。
流式返回并不会自动降低后端总计算量。客户端必须能增量解析、稳定合并结果,并处理分片失败、顺序变化和连接中断。网关、CDN 与可观测系统也要确认不会缓冲完整响应后才转发。
落地时先检查这六件事
- 用真实用户视角定义首屏完成时间,而非只看单服务延迟;
- 给召回、排序、补全、广告和网络传输分别建立 trace span;
- 优先移除不会影响首屏质量的工作,再考虑扩容;
- 并行化独立依赖,同时设置 deadline、并发限制和降级路径;
- 同时观察延迟、相关性、广告效果、错误率和基础设施成本;
- 对微批处理和流式返回进行端到端实验,避免局部指标变快、用户体验反而变差。
Uber Eats 的案例说明,大幅降低搜索延迟通常来自一组彼此配合的改动:重新定义测量终点,减少不必要的计算,把独立工作并行化,并重新审视广告和基础设施边界。对其他搜索系统而言,最值得复制的不是某个具体实现,而是围绕首屏体验逐段压缩关键路径的方法。