Skip to content

设计 Uber 后端

我们来设计一个类似 Uber 的网约车服务,它连接需要乘车的乘客和提供车辆的司机。

类似服务:Lyft、滴滴、Via、Sidecar 等
难度等级:困难
前置知识:设计 Yelp

1. 什么是 Uber?

Uber 允许用户预订司机进行出租车服务。Uber 司机使用自己的私家车载客,乘客和司机通过智能手机上的 Uber 应用程序进行沟通。

2. 系统的需求和目标

我们先构建一个简化版的 Uber。

系统中有两类用户:

  1. 司机
  2. 乘客
  • 司机需要定期向系统报告自己的当前位置及是否可接单。
  • 乘客可以看到附近所有可用的司机。
  • 乘客可以发起打车请求,附近的司机会收到通知,提示有乘客等待接载。
  • 一旦司机和乘客确认行程,他们可以实时查看彼此的位置,直到行程结束。
  • 到达目的地后,司机标记行程完成,进入可接单状态。

3. 规模估算与约束

  • 假设系统有 3 亿乘客100 万司机,其中 每日活跃用户(DAU)100 万乘客50 万司机
  • 每日约有 100 万次行程
  • 所有活跃司机每 3 秒 上报一次当前位置。
  • 乘客发起行程请求后,系统需要实时联系附近的司机

4. 基础系统设计与算法

我们将基于 《设计 Yelp》 中讨论的方案,并对其进行修改,使其适用于上述 Uber 相关的使用场景。

最大的问题在于,我们之前的 四叉树(QuadTree) 设计时,并未考虑到频繁更新的问题。因此,我们的 动态网格(Dynamic Grid) 方案存在两个主要挑战:

  1. 高频位置更新的处理

    • 所有活跃司机每 3 秒 上报一次位置,因此数据结构必须能够高效更新。
    • 如果每次司机位置变化都要更新 四叉树,则计算和资源消耗都会很大。
    • 具体而言,更新司机位置时,需要基于其之前的位置找到对应的网格。如果新位置不属于当前网格,就必须将司机从当前网格移除,并重新插入到正确的网格
    • 如果新网格的司机数量超过上限,还需要对其进行重新分区(repartition),进一步增加计算开销。
  2. 快速传播附近司机的位置信息

    • 需要一种高效机制,能够实时将附近司机的位置信息传递给该区域内的所有活跃乘客。
    • 当行程进行中,系统必须持续通知司机和乘客关于车辆的实时位置

虽然 四叉树(QuadTree) 在查找附近司机时表现优秀,但它无法保证高效的更新,因此需要额外优化。

我们需要在每次司机上报位置时更新四叉树吗?

如果我们不在每次更新时修改四叉树,则四叉树中的部分数据会变旧,无法准确反映司机的当前位置。回顾之前的设计,我们构建 四叉树(QuadTree) 的目的是高效查找附近的司机。但由于所有活跃司机每 3 秒上报一次位置,导致更新操作的频率远高于查询操作

优化方案:使用哈希表存储司机最新位置

  • 我们可以将所有司机的最新位置存储在哈希表中,并降低四叉树的更新频率。
  • 例如,我们可以保证司机的位置在 15 秒内反映到四叉树,而最新的位置信息存储在一个哈希表(称为 DriverLocationHT)。

DriverLocationHT 需要多少内存?

DriverLocationHT 需要存储 DriverID 以及司机的当前和上一位置,每条记录共 35 字节

字段大小
DriverID3 字节(1 百万司机)
旧纬度8 字节
旧经度8 字节
新纬度8 字节
新经度8 字节
合计35 字节

假设系统有 100 万名司机,则所需内存(忽略哈希表开销):
1,000,000 × 35B = 35MB

接收位置更新所需的带宽

  • 每次接收位置更新需要 19 字节DriverID 3B + 位置 16B)。
  • 100 万司机每 3 秒 上报一次:
1 million * 35 bytes => 35 MB

每 3 秒 19MB 的数据流量

是否需要将 DriverLocationHT 分布式存储?

尽管 35MB 的内存占用和 19MB/3s 的带宽需求并不高,可以存储在单台服务器上,但出于可扩展性(scalability)、性能(performance)和容错性(fault tolerance)的考虑,我们应当分布式存储 DriverLocationHT

  • 分片策略

    • 可以基于 DriverID 进行分片,使数据均匀分布。
    • 负责存储司机位置的服务器称为 Driver Location Server
  • Driver Location Server 负责两项任务

    1. 广播司机位置给所有关注该司机的乘客
    2. 通知相应的四叉树服务器(QuadTree Server)刷新司机位置,该操作每 10 秒进行一次

如何高效地将司机位置广播给乘客?

  • 采用推送(Push)模型

    • 服务器将司机位置实时推送给所有相关用户。
    • 通过 通知服务(Notification Service) 进行广播,该服务基于 发布/订阅(Pub/Sub)模型 构建。
  • 实现方式

    • 当乘客打开 Uber APP 查询附近司机时,服务器返回司机列表,并订阅这些司机的位置更新
    • 服务器维护一个订阅列表,存储所有希望接收某司机位置更新的乘客
    • DriverLocationHT 中司机的位置更新时,系统会向所有订阅的乘客推送最新位置

这样可以确保乘客始终看到司机的最新位置。

存储所有订阅信息需要多少内存?

根据前面的估算,我们有 100 万日活乘客50 万日活司机。假设每个司机平均有 5 个乘客订阅,并将订阅信息存储在哈希表中(以便高效更新)。

  • DriverID: 3 字节
  • CustomerID: 8 字节

所需总内存计算如下:

(500K * 3) + (500K * 5 * 8 ) ~= 21 MB

广播司机位置所需的带宽

  • 总订阅数
5 * 500K => 2.5M
  • 每次更新需要传输的数据DriverID 3B + 位置 16B = 19B)。
  • 每秒更新频率
2.5M * 19 bytes => 47.5 MB/s

如何高效实现通知服务?

可以采用以下两种方案:

  1. HTTP 长轮询(Long Polling)

    • 客户端定期向服务器请求更新,并在服务器有新数据时返回。
    • 缺点:会有额外的请求开销,增加服务器负载。
  2. 推送通知(Push Notifications)

    • 服务器主动推送司机位置更新给所有订阅的乘客。
    • 需要高效的 发布/订阅(Pub/Sub) 机制。

新进入的司机如何被现有乘客订阅?

  • 当前订阅逻辑

    • 乘客打开 Uber APP 时,系统会订阅附近司机的位置更新。
    • 但是,如果有新司机进入乘客所在区域,系统如何处理?
  • 方案 1:动态添加订阅

    • 需要跟踪乘客正在查看的区域,当有新司机进入该区域时,服务器自动订阅该司机。
    • 缺点:逻辑复杂,需要持续维护区域状态。
  • 方案 2:客户端主动查询(Pull Model)

    • 乘客定期发送当前位置,服务器返回附近司机列表。
    • 乘客收到更新后,更新界面以反映最新司机位置。
    • 查询间隔:每 5 秒 查询一次,减少服务器压力。
    • 优点:相比推送模式,实现更简单,减少服务器的持续推送负担。

是否需要立即重新划分超载的网格?

如果网格超出最大容量,是否立即分割?

  • 优化方案:允许网格增长/收缩 10% 后再进行拆分/合并。
  • 优势
    • 减少高流量区域的网格分裂频率,降低服务器计算负担。
    • 提升性能,避免过于频繁的分区操作。

图15-1

“请求乘车”用例如何运作?

  1. 乘客发起乘车请求。
  2. 其中一个聚合服务器(Aggregator server)接收该请求,并向四叉树(QuadTree)服务器请求附近的司机信息。
  3. 聚合服务器收集所有结果,并按照司机评分进行排序。
  4. 聚合服务器会同时向评分最高的前三名司机发送通知,最先接受请求的司机将被指派该订单,其余司机会收到取消请求。如果这三名司机都未响应,聚合服务器会继续向排序靠后的三名司机发起请求。
  5. 一旦有司机接受请求,乘客将收到通知。

5. 容错性与数据复制

如果 司机位置服务器通知服务器 宕机,我们需要这些服务器的副本,以便在主服务器故障时,备用服务器可以接管。此外,我们可以将数据存储在 SSD 等高性能持久化存储中,以提供快速 I/O。这可以确保即使主服务器和备用服务器同时宕机,我们仍然可以从持久化存储中恢复数据。

6. 排序机制

如果我们不仅想按距离排序搜索结果,还想按受欢迎程度或相关性排序,该如何处理?
我们可以在数据库和四叉树(QuadTree)中维护每位司机的整体评分,例如,司机的评分可以用 10 分制的星级表示。在搜索给定半径内的前 10 名司机时,我们可以让 四叉树的每个分区 返回其评分最高的 10 名司机。然后,聚合服务器 从所有分区返回的结果中,最终选出评分最高的 10 名司机。

7. 高级问题

  1. 如何处理 网络较慢或不稳定 的客户端?
  2. 如果客户端在行程中 断开连接,如何处理 计费
  3. 如果客户端主动拉取信息,而不是服务器主动推送,系统该如何设计?