免费完整指南

Uber (Ride Hailing)

设计Uber (Ride Hailing)。请覆盖Geospatial indexing trade-off: geohash vs quadtree vs S2/H3 cells for storing and querying driver locations, High-frequency driver location ingestion: update interval vs GPS...

00

练习检查点

面试节奏保持精简,让页面能把注意力花在真正的设计决策上。

  1. 01
    澄清范围
  2. 02
    需求 + 规模
  3. 03
    API + 数据模型
  4. 04
    画出架构
  5. 05
    深入探讨
  6. 06
    取舍决策
01

塑造设计的需求

不要只是陈述需求,要主动问出来。每张卡把设计约束和一句你可以在画架构前说出口的澄清问句配成一组。

功能需求

01乘客最先看到什么——在决定之前先看到价格吗?

乘客输入上车点与目的地,在叫车前先得到一个车费估算(价格 + ETA)。

02他们按下叫车之后会怎样——匹配必须多快回来?

乘客以估算的车费叫车,并在大约一分钟内被匹配到附近一位可载客的司机——或得到一个明确的失败。

03司机端要做什么?

司机上线、发送位置 ping、一次收到一笔叫车邀约,接受或拒绝;接受后就导航前往上车点与目的地。

04乘客可以在匹配后取消吗,司机会经历什么?

可以:取消后司机立即恢复可载客、能接新行程,系统会记录是谁在何时取消。

05乘客会实时看着司机接近吗?

会:司机位置只在行程进行中流式传给被匹配的乘客——其他所有人只看到粗略的可用状态,永远拿不到可追踪的轨迹。

06在匹配时间窗内没有司机接受——那该怎么办?

请求会明确失败并附上重试指引——在这一分钟内给一个确定的「不行」,胜过一个永远转不完的圈圈。

范围外评分(乘客与司机) · 预约行程与车型分级(X/XL/Comfort) · 动态加价机制与付款结算

非功能需求

01一位司机有可能同时拿到两趟行程吗?

匹配是强一致的:一位司机同一时间最多持有一笔有效邀约或行程——永远不会重复派遣。

02司机位置需要多新?

司机在线时每隔几秒 ping 一次;邻近搜索看到的数据可能是几秒前的,但绝不会是几分钟前的。

03我们该按什么样的司机位置更新峰值速率来设计?

在一个刻意设定的压力上限:全球 1,000 万名司机在线、每约 5 秒 ping 一次,系统要吸收每秒约 200 万次位置写入。

04当一座体育场散场时会发生什么?

来自单一区域约 10 万笔叫车请求的突发会排队并优雅地消化——邻近区域与其他城市不受影响。

05每一步要感觉多快?

车费估算在数秒内;匹配(或一个明确的失败)端到端在大约一分钟内。

持续追问 —— 面试是一场对话

真实面试探得比一份整齐清单深得多。这些范围问句,区分出真正拷问问题的人和只是背诵的人。

  • 行程历史与司机位置轨迹必须存多久——司机的轨迹必须能按请求删除吗?
  • 我们需要处理 GPS 伪造或司机钻匹配空子吗,还是欺诈在别处过滤?
  • 上线时是一座城市,还是第一天就全球——每个区域的数据必须留在那个区域吗?
  • 有没有一个我们要在合同上兑现的匹配成功率,还是一分钟内给一个明确的失败就可以接受?
  • 动态加价与评分不在范围内吗?
02

逼出架构决策的数字

把每个估算都当成一种压力,用来合理化一个组件:缓存、队列、分片、副本、worker pool 或退路。

01

位置写入洪流

压力上限:全球 1,000 万名司机在线 × 每 5 秒 1 次 ping10,000,000 ÷ 5 ≈ 2M writes/s

没有关系型数据库吸收得了这个——位置进入一个内存内的地理索引(geohash/H3 桶)并以 TTL 逐出。

02

邻近查询成本

上车点落进一个 geohash cell;覆盖搜索半径意味着那个 cell 加上它的 8 个相邻 cell——一个 3 × 3 网格,9 个 cell9 次 cell 读取 × 每次内存查询远低于 1 ms ≈ 个位数毫秒

geohash 把「谁在我附近」变成少数几次 key 查询,而不是一次全表扫描。

03

演唱会突发

约 10 分钟内来自单一街区约 10 万笔请求100,000 ÷ 600 秒 ≈ 单一区域每秒 170 次匹配

按地理区域对匹配队列分片——尖峰只让一个区域的消费者饱和,而不是整座城市。

04

邀约预算

60 秒内匹配;每位被邀约的司机约有 10 秒回应60 秒 ÷ 10 秒 ≈ 在期限前试 5-6 位司机

10 秒的邀约锁限制了预算内能塞进多少候选人——把候选人排好序,别乱撒邀约。

05

幽灵司机清理

位置条目在最后一次 ping 之后 15-30 秒过期——按 5 秒的节奏算,就是漏掉 3-6 次 ping15-30 s ÷ 每次 ping 5 s = 3-6 个沉默间隔 → 条目过期;宕机的司机最坏约 30 秒内就不再收到邀约

宕机或离线的司机会自己从索引中消失——不需清理作业,也不会把邀约发给幽灵。

决策示例

数字

两种非常不同的负载共用这套系统:每秒约 200 万次位置写入从司机端流式涌入,而每一次叫车匹配只需要在一分钟内找到少数几位附近的候选人。

我的选择

我会把司机位置放在一个带短 TTL 的内存内地理索引(geohash/H3 桶)里,这样停止 ping 的司机就直接消失。匹配捞出附近的候选人,并一次把行程邀约给一位司机,把那位司机锁住约 10 秒——接受就赢得行程,超时或拒绝就释放锁,下一位候选人拿到邀约。尖峰期间的叫车请求在一个按区域分片的队列里等候,所以一座体育场散场只会拖慢那个街区,而不是整座城市。

避免

我不会做的事:把每一次位置 ping 都写进主数据库,再扫描它来找附近的司机——每秒 200 万次写入再加上扫描会把它压垮。我也不会在没有锁的情况下把一趟行程同时邀约给多位司机:两个接受同时抵达,两位司机都以为自己接到了活。而且我不会在第一天就对匹配系统分片——先测量每个区域的速率;过早分片只会增加故障模式,却没有增加你真正需要的容量。

何时改变

如果匹配质量比速度更重要——拼车、批处理——我会把请求收集几秒钟,再以小批次一起匹配,而不是一次一笔。

03

架构路径

先看一张完整的图,再把每条路径各自画成一张图 —— 写入路径与读取路径承载不同流量,合理化不同的组件。

完整全貌

总览 —— 每个组件

Rider / DriverAppsAPI GatewayRide ServiceMatching ServiceGeo Index(内存)Ride DB(状态)行程生命周期NotificationService邀约 → 司机Mapping Provider车费 + ETA

司机 ping 直接流进内存内的地理索引;匹配从中读取附近的候选人,并一次一位司机地邀约行程。行程的真相活在数据库里——索引是可丢弃的。

路径 1

请求路径——估算、叫车、匹配

Rider AppRide ServiceMatching ServiceGeo Index(附近司机)Driver Offer(10秒)

先车费,再叫车。匹配捞出少数几位附近的候选人,一次邀约一位司机;这个 10 秒的锁防止重复派遣,并在司机忽略邀约时自动往下走。

路径 2

位置路径——写入洪流

Driver AppAPI GatewayLocation ServiceGeo Index(geohash/H3)TTL Eviction

每秒约 200 万次 ping 落在内存里,分桶进 geohash/H3 cell;条目在 15-30 秒后过期,所以停止 ping 的司机会自己消失。

04

API 与数据模型

在优化之前,先让契约可被检视:端点、实体、所有权、重试与状态。

POST/fare

请求{ pickup, destination }

响应200 { fare_id, estimated_fare, eta }

调用地图供应商取得路线 + ETA;估算会被存起来,让叫车请求可以引用它。

POST/rides

请求{ fare_id }

响应201 { ride_id, state: requested } · 404 fare 已过期

启动匹配;乘客会被推送匹配结果(或自行轮询)。

POST/drivers/location

请求{ lat, lng }

响应200

司机身份来自 session token,绝不来自请求主体。高频写入直接进入地理索引。

PATCH/rides/{ride_id}

请求{ accept | decline }

响应200 ride · 409 邀约已过期

接受会原子性地翻转邀约;拒绝或 10 秒超时会释放司机,下一位候选人拿到邀约。

核心实体

Rider

rider_id (PK) · payment_profile

Driver

driver_id (PK) · vehicle · status: offline/available/offered/on_trip

status 字段就是防重复派遣的守门员:只有 available 的司机能收到邀约,而这个翻转是原子的。

Fare

fare_id (PK) · pickup · destination · estimated_fare · eta

在估算时创建;叫车请求引用 fare_id,让报出的价格不会被悄悄更动。

Ride

ride_id (PK) · rider_id · driver_id · fare_id · state: requested/matched/in_progress/completed

DriverLocation

driver_id · lat/lng · updated_at

活在内存内的地理索引(带 TTL 的 geohash/H3 cell)里,不在关系型存储中——它是可丢弃的数据。

05

深入方向

在面试最后三分之一挑一条路线。每条路线给你主题、它该回答的面试官问题,以及要避免的失败模式。

重点

位置洪流

1,000 万名司机每 5 秒 ping 一次。每秒 200 万次写入去哪里,你又如何回答「谁在这个上车点附近」?

回答

位置 ping 进的是一个内存里的地理索引,绝不是关系型数据库。它扛不住每秒 200 万次写入。把每个位置归到一个 geohash 或 H3 格子里,配一个短 TTL。要找谁在上车点附近,就读那个格子加它的 8 个邻居——九次内存查找,个位数毫秒,而不是全表扫描。

避免

把 ping 写进主数据库再扫描邻近——在这个速率下它会挂掉。

重点

绝不重复派遣同一位司机

两笔叫车请求在同一瞬间都要同一位附近的司机。如何让刚好一个赢?

回答

用一个短的独占锁把司机抢下来。这是一个原子的 compare-and-set,所以恰好一个请求赢,输的那个转向它的下一个候选。真正的行程状态由数据库保管:accept 把司机翻成 on-ride,取消则把他放回 available,不留下过期的占用。

避免

没有锁就同时邀约多位司机——两个接受都以为自己赢了。

重点

司机不回应

被邀约的司机忽略了请求。第 10 秒时发生什么,行程又如何仍在一分钟内匹配成功?

回答

派单是一个 10 秒的锁,不是阻塞式的等待。到第 10 秒它自己过期,匹配转向下一个排名的司机。每个约 10 秒,60 秒的预算里塞得下 5 到 6 个司机。这就是为什么你要仔细给候选排序,而不是把派单到处乱撒。

避免

让一位没回应的司机卡住整趟行程,而不是用一个会往下走的锁 TTL。

重点

一座体育场散场

几分钟内 10 万人从单一街区叫车。你如何让城市的其他部分不受影响?

回答

把匹配队列按地理区域分区。10 分钟 10 万个请求,也就每秒约 170 次匹配。它只会打满那一个区域的 consumer,其他每个区域照常消化。热点区域看到的是老老实实的排队——等久一点,或者一个明确的失败。城市其余部分照跑不误。

避免

单一全局匹配队列——一个局部尖峰变成全市性的故障。

重点

幽灵司机

一个司机 App 在标记为 available 时宕机。他们还能收到邀约多久,又是什么把他们清掉?

回答

每条位置记录在最后一次 ping 之后 15 到 30 秒过期。按 5 秒一次的节奏,也就是漏掉 3 到 6 次 ping,所以一个崩溃的司机会自己从索引里掉出去。不需要清理任务。一个“幽灵”最多也就还能收派单约 30 秒。

避免

位置条目没有 TTL——邀约一直发给一小时前就离开的司机。

准备好练习了吗?

把 Uber (Ride Hailing) 大声讲一遍,让 AI 为你的说明评分。

用 AI 练习这题 →