Uber Eats 搜索链路重构:如何把端到端延迟降低 50%

2026-10-02 17 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

搜索性能不是某个接口的响应时间,而是用户从发起查询到看到可操作结果所经历的完整等待。Uber 对 Uber Eats 搜索链路的多个关键环节进行了重构,并报告端到端延迟降低了 50%。这次优化没有押注单一“银弹”,而是同时调整了首屏指标、召回工作量、数据补全方式、广告数据模型和基础设施。

从“请求完成”转向“首屏可见”

传统搜索系统常把服务端总响应时间作为核心指标,但用户并不需要等待所有结果、广告和附加字段准备完毕,才开始浏览页面。更贴近体验的指标是 Above-the-Fold:首屏内容何时达到可展示、可交互状态。

这会改变性能优化的优先级。假设一次搜索包含以下阶段:

  1. 查询理解与候选召回;
  2. 排序;
  3. 商家、商品、价格等数据补全;
  4. 广告数据合并;
  5. 返回首屏和后续结果。

如果首屏只展示少量条目,就没有必要让全部候选的补全工作阻塞第一次渲染。工程上应分别记录:

  • 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 与可观测系统也要确认不会缓冲完整响应后才转发。

落地时先检查这六件事

  1. 用真实用户视角定义首屏完成时间,而非只看单服务延迟;
  2. 给召回、排序、补全、广告和网络传输分别建立 trace span;
  3. 优先移除不会影响首屏质量的工作,再考虑扩容;
  4. 并行化独立依赖,同时设置 deadline、并发限制和降级路径;
  5. 同时观察延迟、相关性、广告效果、错误率和基础设施成本;
  6. 对微批处理和流式返回进行端到端实验,避免局部指标变快、用户体验反而变差。

Uber Eats 的案例说明,大幅降低搜索延迟通常来自一组彼此配合的改动:重新定义测量终点,减少不必要的计算,把独立工作并行化,并重新审视广告和基础设施边界。对其他搜索系统而言,最值得复制的不是某个具体实现,而是围绕首屏体验逐段压缩关键路径的方法。


相关推荐