🚗 Uber 系统设计学习平台

从零开始,手把手带你理解百万级 QPS 的乘车系统是如何设计的

不是死记硬背,而是理解每一个决策背后的"为什么"

👋 欢迎来到系统设计课堂

这不是一份设计文档的简单展示,而是一个交互式学习工具

我会像一个架构师带实习生一样,一步步带你理解:

🤔 为什么这样设计

每个技术选型背后的原因

⚖️ 如何做权衡

没有完美方案,只有最适合的

💡 面试怎么讲

45分钟内清晰表达你的思路

🔍 深度与广度

知道何时展开,何时收敛

📖 如何使用这个平台

  1. 顺序学习: 从第1章开始,按顺序阅读,每章都有前置知识依赖
  2. 主动思考: 看到"思考题"时,先不要急着看答案,给自己2分钟想一想
  3. 展开答案: 点击蓝色的"答案框"查看标准讲解
  4. 关注面试点: 绿色的"面试提示"框标注了高频考点
  5. 注意警告: 红色的"警告框"是新手最容易踩的坑

🎯 学完后你能收获什么

  • ✅ 理解如何从需求推导出架构设计
  • ✅ 掌握容量估算的实用方法(不只是算数字)
  • ✅ 学会在一致性、性能、可用性之间做权衡
  • ✅ 能够在面试中流畅地讲述一个完整的系统设计
  • ✅ 知道面试官会在哪里追问,如何应对

💼 面试官视角

系统设计面试不是在考你"背答案",而是在看:

  • 你能否理解业务需求并转化为技术问题
  • 你能否在有限时间内抓住核心矛盾
  • 你能否解释自己的选择(不是"我觉得",而是"因为...所以...")
  • 你能否听懂面试官的暗示并调整方向

🚀 准备好了吗?

点击左侧的「第1章:系统设计的本质」开始学习吧!

记住:系统设计没有标准答案,但有正确的思考方式。

第1章:系统设计的本质

在开始设计 Uber 之前,我们需要先理解一件事:系统设计到底在考什么?

很多人以为系统设计就是"画画图,说说技术栈",这是错的。

🎯 本章学习目标

  • 理解系统设计面试的真实意图
  • 学会从业务问题到技术问题的转化
  • 掌握系统设计的基本思维框架

🤔 思考:如果让你设计 Uber,你会从哪里开始?

给自己1分钟,想想看:

  • 你会先想什么问题?
  • 你会先设计什么部分?
  • 你觉得最难的部分是什么?

💡 标准思路

大多数人的第一反应是:

  • "用什么数据库存司机位置?"
  • "怎么实现地理位置搜索?"
  • "用什么消息队列?"

这些都太早了!

正确的起点应该是:

  1. 明确问题边界: Uber 的核心功能是什么?我们要解决哪些场景?
  2. 量化规模: 多少用户?多少 QPS?数据量多大?
  3. 找到瓶颈: 什么地方会成为性能瓶颈?
  4. 设计方案: 针对瓶颈设计解决方案

技术选型是最后一步,不是第一步。

⚠️ 新手常见错误

  • ❌ 一上来就说"用 Redis 缓存"→ 为什么要缓存?缓存什么?
  • ❌ 直接画微服务架构图 → 为什么要拆服务?拆几个?
  • ❌ 说"用 Kafka 做消息队列"→ 什么场景需要异步?

记住:先问题,后方案

1.1 为什么选 Uber 作为案例?

Uber 是一个经典的分布式系统设计案例,因为它几乎覆盖了所有核心考点:

📍 地理位置

如何高效存储和查询数百万司机的实时位置?

⚡ 高并发

百万级 QPS 下如何保证系统不崩溃?

🔒 一致性

如何防止一个司机被匹配给多个乘客?

🌊 峰值流量

演唱会散场时如何应对流量暴增?

🤔 思考:Uber 和电商系统有什么不同?

提示:想想数据的特点

💡 关键差异

维度 电商系统 Uber 系统
数据特点 商品信息相对静态 司机位置高频变化(每4秒)
查询模式 按 ID/类别查询 地理位置范围查询
一致性要求 库存扣减需要强一致 位置可最终一致,匹配需强一致
峰值特点 双11 可预测 演唱会散场不可预测

核心挑战: Uber 是一个时空数据 + 高并发写的系统,这决定了架构必须针对性优化。

💼 面试中如何回答

如果面试官问:"为什么 Uber 的设计和电商不一样?"

好的回答:

"Uber 的核心挑战在于实时位置数据的高频更新。司机每4秒上报一次位置,300万司机意味着每秒75万次写入。这和电商的'读多写少'完全相反,所以我们需要针对写密集 + 地理查询优化架构。"

1.2 这不是编程题

系统设计面试和算法题最大的区别是:没有标准答案

面试官不是在找"正确的架构图",而是在看你的思考过程

📊 系统设计的评分维度

维度 权重 考察点
需求理解 20% 能否问出关键问题,明确边界
权衡能力 30% 能否解释为什么选A不选B
深度 25% 关键路径能否讲清楚细节
沟通 15% 能否清晰表达,听懂暗示
广度 10% 能否覆盖监控、安全等

⚠️ 面试中的致命错误

  1. 不问需求就开始设计
    • ❌ "我们用微服务架构..."
    • ✅ "请问预期的 DAU 是多少?地理范围是全球还是单个城市?"
  2. 列技术栈不讲原因
    • ❌ "用 Redis、Kafka、Cassandra"
    • ✅ "因为位置更新频繁,我选择 Redis 作为热数据缓存,TTL 4秒..."
  3. 陷入细节不能自拔
    • ❌ 花20分钟讲数据库分片算法
    • ✅ "分片用哈希,具体算法可以用一致性哈希,咱们先看看匹配流程"

💼 时间分配建议(45分钟面试)

  • 5分钟: 澄清需求,确认边界
  • 5分钟: 容量估算(粗略即可)
  • 10分钟: 高层架构 + 核心流程
  • 15分钟: 深入1-2个关键问题(如并发匹配)
  • 10分钟: 优化、权衡、追问

记住:广度优先,深度按需。先把整体框架讲清楚,再根据面试官兴趣深入。

第2章:从需求到设计边界

现在正式开始设计 Uber。第一步不是画图,而是明确需求

很多人觉得这一步简单,其实这是最容易拉开差距的地方。

🎯 本章学习目标

  • 学会如何拆解功能需求
  • 理解非功能需求的优先级
  • 掌握"什么该做,什么不做"的边界判断

🤔 思考:Uber 的核心功能是什么?

用一句话概括,最多20个字。

💡 答案

"连接乘客和司机,提供按需乘车服务"

这句话包含三个关键信息:

  1. 连接 → 需要匹配算法
  2. 按需 → 实时性要求高
  3. 乘车服务 → 涉及位置、支付、评价等

但我们不可能在45分钟内设计所有功能,所以需要划定范围

2.1 功能需求拆解

Uber 是一个连接乘客和司机,提供按需乘车服务的全球平台,支持移动端请求乘车、匹配司机、实时跟踪及支付。本文档以结构化方法设计一个高可扩展、高并发的乘车系统,涵盖需求分析、容量估算、数据模型、架构组件及优化策略。设计重点包括一致性、可扩展性和优化用户体验。

  • 更新司机位置: 司机定期发送当前位置(每 4 秒)。系统更新位置信息,并用于匹配乘客。
  • 查找附近的司机: 乘客按位置搜索附近可用司机。返回列表,包含司机基本信息(ID、姓名、车辆类型、评分)。
  • 请求乘车: 匹配:乘客输入下车地点后,系统匹配附近司机。确认:司机接受后,行程开始,乘客看到预计到达时间(ETA)。
  • 扩展功能(可选,暂不讨论): 查看行程历史、评价司机/乘客、提前预约行程。

2.2 非功能需求的权衡

  • 可扩展性: 支持高峰流量,如特殊活动期间的百万级并发请求。
  • 可用性: 匹配和跟踪功能保持 99.99% 正常运行。
  • 性能: 响应时间 <1 秒,匹配响应 <500 ms。
  • 可靠性: 数据不丢失,故障恢复时间 <1 分钟。
  • 安全性(可选,暂不讨论): 防止刷单(速率限制、CAPTCHA)。遵守数据保护规范(加密、用户同意)。
  • 优先级: Availability-first at system level, with CP guarantees for critical paths (matching & payment)。

第3章:容量估算

目标:量化记录规模、存储量、分片数和缓存需求,为后续设计提供依据。

3.1 为什么要算容量?

容量估算是系统设计的基础,它帮助我们理解系统的规模、潜在瓶颈,并指导硬件和架构选择。没有容量估算,设计容易脱离实际,导致过度或不足优化。

🤔 思考:为什么容量估算不是简单算数字?

提示:想想它如何影响后续决策。

💡 答案

容量估算不仅仅是计算数字,它帮助识别瓶颈(如存储 vs 计算),指导分片、缓存策略,并评估成本。忽略它可能导致系统在高峰崩溃或资源浪费。

3.2 从 DAU 到服务器

容量估算的目的是量化系统的规模需求,为技术选型提供依据。我们从 DAU(日活跃用户)开始,逐步推导出 QPS、存储、分片数和硬件需求。

📊 第一步:基础假设

我们需要假设什么?

任何估算都基于一些合理的假设。这些假设越准确,估算结果越可靠。

指标 数值 说明
日活跃用户 (DAU) 2000 万乘客 + 300 万司机 全球主要城市的活跃用户数
每用户每秒请求数 0.02 req/s(峰值) 平时低于此值,高峰期达到此值
每天行程数 2000 万次 乘客请求乘车的总次数

📈 第二步:计算峰值 QPS

🔍 核心问题:为什么要计算 QPS?

QPS(每秒查询数)直接决定了系统需要多少台服务器、什么样的数据库。如果估算错误,可能导致:

  • 估算过低 → 服务器不足,系统崩溃
  • 估算过高 → 浪费资源,成本上升

计算公式:

峰值 QPS = DAU × 每用户每秒请求数

具体计算:

  1. 乘客请求 QPS
    • 乘客 DAU:2000 万
    • 每乘客每秒请求数:0.02
    • 计算:2000 万 × 0.02 = 400k QPS
  2. 司机位置更新 QPS
    • 司机 DAU:300 万
    • 每司机每秒更新数:0.25(每 4 秒更新一次)
    • 计算:300 万 × 0.25 = 75k QPS
  3. 其他操作(支付、历史查询等)
    • 估计:275k QPS
  4. 总峰值 QPS
    • 计算:400k + 75k + 275k = 750k QPS
    • 加上缓冲(50%):750k × 1.5 = ~1M QPS

读写比例分析:

  • 读操作(查询附近司机、查历史):约 50k QPS
  • 写操作(位置更新、匹配、支付):约 750k QPS
  • 读写比 = 1:15(写操作为主)

💾 第三步:计算存储需求

计算公式:

3 年总行程数 = 每天行程数 × 365 天 × 3 年

具体计算:

  1. 3 年总行程数
    • 计算:2000 万 × 365 × 3 = 21.9 亿次
  2. 每条行程记录大小
    • tripID (UUID):16 B
    • riderID、driverID:32 B
    • 坐标、时间戳、状态等:100 B
    • 总计:约 150 B/条
  3. Trips 表原始数据大小
    • 计算:21.9 亿 × 150 B ÷ 1024³ = 2.2 TB
  4. 加上索引(约 30%)
    • 计算:2.2 TB × 0.3 = 0.66 TB
  5. 加上副本(2 份备份)
    • 计算:(2.2 + 0.66) × 2 = 5.72 TB
  6. 其他表(Riders、Drivers、DriverLocations)
    • 估计:3 TB
  7. 总存储需求
    • 计算:5.72 + 3 = 8.72 TB
    • 加上 20% 增长缓冲:8.72 × 1.2 = ~10.5 TB

🔀 第四步:计算分片数

核心问题:为什么要分片?

单台数据库无法处理 750k 写 QPS。通过分片,我们将数据分散到多台机器,每台机器只处理一部分数据和请求。

计算公式:

分片数 = 峰值写 QPS ÷ 单分片承载 QPS

具体计算:

  1. 单分片承载能力
    • 假设单台 Cassandra 节点可承载:1k QPS
  2. 需要的分片数
    • 计算:750k ÷ 1k = 750 个分片
    • 加上 50% 冗余和高可用:750 × 1.5 = ~1000 个分片
  3. 每分片的数据量
    • 计算:8.72 TB ÷ 1000 = 8.72 GB/分片

🖥️ 第五步:计算硬件需求

计算公式:

服务器数 = 峰值 QPS ÷ 单服务器承载 QPS

具体计算:

  1. 单服务器承载能力
    • 假设单台服务器可承载:1k QPS(在 p99 < 200ms 的延迟下)
  2. 高峰期服务器需求
    • 计算:800k ÷ 1k = 800 台服务器
    • 加上 50% 冗余(故障转移、维护):800 × 1.5 = ~1200 台
  3. 平时服务器需求
    • 假设平时 QPS:5k
    • 计算:5k ÷ 1k = 5 台服务器
    • 加上冗余:5 × 1.5 = ~8 台

💰 第六步:缓存需求

热点数据分析:

  • 热门位置(如机场、火车站)占查询的 80%
  • 这些热点数据可以缓存在 Redis 中

缓存数据量计算:

  1. 热门司机位置
    • 热门司机数:300 万 × 20% = 60 万
    • 每条记录大小:50 B
    • 计算:60 万 × 50 B = 30 MB
  2. 热门查询结果
    • 热门查询数:100 万条
    • 每条结果大小:100 B
    • 计算:100 万 × 100 B = 100 MB
  3. 总热点数据量
    • 计算:30 + 100 = 130 MB
  4. Redis 内存规划
    • 考虑 HA(主从复制)、内存碎片、写放大
    • 规划:16-32 GB

📋 总结表

指标 数值 说明
峰值 QPS 800k-1M 系统必须支持的最大并发
读写比 1:15 写操作为主,需要高写入能力的数据库
3 年存储 8.72 TB 包括主数据、索引、副本
分片数 ~1000 每分片 8.72 GB,承载 1k QPS
高峰服务器 ~1200 台 包括冗余和故障转移
Redis 缓存 16-32 GB 缓存命中率 95%

💡 面试技巧

在面试中,不要只说数字,要说出推导过程。面试官想看的是你的思维方式,而不是记忆力。

例如,不要说"我们需要 1000 台服务器",而要说"根据估算,峰值 QPS 是 800k,单台服务器可承载 1k QPS,所以需要 800 台,加上 50% 冗余,大约 1200 台。"

第4章:数据模型设计

核心实体与 API 设计。

4.1 核心实体关系

  1. Riders(乘客): RiderID (PK, UUID)、name、email、phone、photo
  2. Drivers(司机): DriverID (PK, UUID)、name、email、phone、vehicleType、rating
  3. Trips(行程): TripID (PK, UUID)、riderID (FK, UUID)、driverID (FK, UUID)、pickupLat、pickupLong、dropoffLat、dropoffLong、status (pending/accepted/inprogress/completed)、eta、version (乐观锁)
  4. DriverLocations(司机位置): DriverID (PK, UUID)、lat、long、timestamp

数据模型与分片策略:采用 查询驱动设计 (Query-Driven Design),通过数据冗余满足不同维度的查询需求:

  1. Trips 表: Partition Key: tripID。用途:根据 ID 查询行程详情、状态流转。
  2. TripsByRider 表 (或物化视图): Partition Key: riderID。Clustering Key: timestamp (DESC)。用途:乘客查看“我的行程历史”。
  3. TripsByDriver 表 (或物化视图): Partition Key: driverID。Clustering Key: timestamp (DESC)。用途:司机查看“接单记录”。

Driver State Machine:IDLE → OFFERED → ACCEPTED → IN_TRIP → IDLE ↘ timeout / reject ↗ ACCEPTED → CANCELLED_BY_RIDER → IDLE (with penalty cooling) IN_TRIP → INCIDENT → SUPPORT_HANDLING → IDLE IN_TRIP → timeout (2 hrs) → FORCE_COMPLETE → REVIEW

实体关系:Riders 一对多 Trips(requests)。Drivers 一对多 Trips(accepts)。Drivers 一对一 DriverLocations(has)。

erDiagram Riders ||--o{ Trips : requests Drivers ||--o{ Trips : accepts Drivers ||--|| DriverLocations : has Riders { string riderID PK string name string email string phone blob photo } Drivers { string driverID PK string name string email string phone string vehicleType float rating } Trips { string tripID PK string riderID FK string driverID FK float pickupLat float pickupLong float dropoffLat float dropoffLong enum status timestamp eta int version } DriverLocations { string driverID PK float lat float long timestamp timestamp }

4.2 查询驱动设计

🤔 核心问题

如何设计数据模型,才能同时满足"按 ID 查行程"和"按用户查历史"这两种查询?

关键概念:查询驱动设计

在传统关系型数据库中,我们追求范式化(消除冗余)。但在 NoSQL 中,我们追求反范式化(接受冗余),以满足不同的查询模式。

为什么需要多个表

场景 1:根据 tripID 查询行程详情

  • 如果只有一个 Trips 表,按 tripID 查询很快(O(1))

场景 2:乘客查看"我的行程历史"

  • 如果只有一个 Trips 表(按 tripID 分片),查询某个乘客的所有行程需要全表扫描(O(n)),导致延迟爆炸

解决方案:创建多个表

  • Trips:按 tripID 分片,用于根据 ID 查询
  • TripsByRider:按 riderID 分片,用于乘客查历史
  • TripsByDriver:按 driverID 分片,用于司机查历史

这样做的代价是数据冗余,但收益是查询性能的提升。

数据模型详解

表 1:Trips(主表)

用途:根据 tripID 快速查询行程详情

分片键:tripID


{
  "tripID": "uuid-123",
  "riderID": "rider-456",
  "driverID": "driver-789",
  "pickupLat": 37.7749,
  "pickupLong": -122.4194,
  "dropoffLat": 37.8044,
  "dropoffLong": -122.2712,
  "status": "completed",
  "eta": 1200,
  "fare": 25.50,
  "rating": 4.8,
  "timestamp": "2024-01-01T10:30:00Z",
  "version": 3
}
                

表 2:TripsByRider(查询表)

用途:乘客查看自己的行程历史

分片键:riderID

排序键:timestamp(降序,最新的行程在前)


{
  "riderID": "rider-456",
  "timestamp": "2024-01-01T10:30:00Z",
  "tripID": "uuid-123",
  "driverID": "driver-789",
  "pickupLocation": "Airport Terminal 1",
  "dropoffLocation": "Downtown Hotel",
  "fare": 25.50,
  "status": "completed"
}
                

表 3:TripsByDriver(查询表)

用途:司机查看自己的行程历史

分片键:driverID

排序键:timestamp(降序)


{
  "driverID": "driver-789",
  "timestamp": "2024-01-01T10:30:00Z",
  "tripID": "uuid-123",
  "riderID": "rider-456",
  "pickupLocation": "Airport Terminal 1",
  "dropoffLocation": "Downtown Hotel",
  "fare": 25.50,
  "earnings": 18.00,
  "status": "completed"
}
                

数据同步机制

当一个行程被创建或更新时,需要同时更新三个表。为了保证一致性,我们使用 CDC(Change Data Capture)+ Kafka

  1. 应用写入 Trips 表
  2. Cassandra 的 CDC 捕获这个变更
  3. 发布事件到 Kafka
  4. 后台服务订阅 Kafka,更新 TripsByRider 和 TripsByDriver

权衡分析

方案 优点 缺点
单表设计
(只有 Trips)
数据简洁,无冗余 查询乘客历史需要全表扫描,延迟高
多表设计
(Trips + TripsByRider + TripsByDriver)
查询快速,支持多种查询模式 数据冗余,需要 CDC 保证一致性

我们的选择:多表设计,因为查询性能对用户体验至关重要。

💡 面试技巧

面试官可能会问:"如果 TripsByRider 表和 Trips 表不一致怎么办?"

好的回答:"我们使用 CDC + Kafka 来保证最终一致性。虽然可能存在短暂的不一致(通常 <1 秒),但通过版本号机制和缓存 TTL,我们可以确保用户看到的数据是可接受的。"

第5章:关键流程

整体采用分层微服务架构,支持水平扩展、高可用性和低延迟。客户端请求通过负载均衡器分发到独立微服务,缓存优化读性能,数据库保证一致性,消息队列处理异步任务。微服务间使用 gRPC 通信,外部 API 保持 RESTful。

5.1 位置更新与搜索

  1. 负载均衡器:AWS ELB,分发流量,支持自动扩缩和故障转移,基于路径的 L 7 路由。
  2. 微服务: 位置服务:更新并存储司机位置,使用 cell-based spatial index (e.g. S 2 / H 3) 支持地理搜索。匹配服务:POST /ride/request 和 /ride/confirm,使用 Redis 锁 + Cassandra 轻量级事务处理。行程服务:GET /ride/{tripID},优先从 Redis 缓存读取,未命中则访问 Cassandra。ETA 服务:计算实际路网行驶时间。
  3. 缓存:Redis,存储热门位置和搜索结果,TTL 4~10 秒,命中率预期 95%。
  4. 数据库: Cassandra:主存储,按 tripID 或 (riderID, timestamp) 分片(Trips),driverID 分片(Drivers),支持轻量级事务和主从复制。Cell-based spatial index:位置索引,动态分区,结合 Redis 哈希表处理高频更新。
  5. 消息队列:Kafka,用于异步任务(支付确认、通知推送),128 分区,保留 7 天。
  6. CDN:CloudFront,缓存静态资源(如地图数据),全球分布,延迟 <100 ms。
  7. 监控:Prometheus + Grafana,跟踪 QPS、延迟和错误率,触发 Auto Scaling。

We treat driver location as ephemeral data: real-time reads go to memory, persistence is asynchronous and lossy by design. 实时位置 → Redis / in-memory geo store,Kafka 作为 buffer + async archive,Cassandra 只存 sampled / trip-scoped trajectory。Real-time search paths never depend on database replication or CDC pipelines. Persistence is asynchronous and does not block matching。

graph TD RiderApp[乘客 App] -->|HTTPS| LB[负载均衡器 AWS ELB] DriverApp[司机 App] -->|WebSocket| LB LB -->|gRPC/REST| Location[位置服务 /driver/location] LB -->|gRPC/REST| Matching[匹配服务 /ride/request] LB -->|gRPC/REST| Trip[行程服务 /ride/ID] LB -->|gRPC/REST| ETA[ETA 服务] Location --> SpatialIndex[cell-based spatial index 位置索引] Trip --> Redis[Redis 缓存] Trip --> Cassandra[Cassandra 数据库] Matching --> Redis[Redis 分布式锁] Matching --> Cassandra[事务] Matching --> Kafka[Kafka 异步队列] Cassandra -->|主从复制| Replica[Cassandra 副本] SpatialIndex -->|Async / Best-effort| Cassandra RiderApp -->|静态资源| CDN[CloudFront CDN]

存储热门位置结果(key=SHA 256 (lat+long+radius)),TTL 4 秒。命中率 95%,80% 查询来自 20% 热门位置。扩展性:Cell-based spatial index 动态分区,每分区 2 k QPS。Prometheus 监控延迟 >500 ms,触发扩容或分区优化。GDPR 合规:位置日志匿名化,用户敏感信息加密。

sequenceDiagram participant 客户端 participant 位置服务 participant Redis participant SpatialIndex participant ETA participant 监控 客户端->>位置服务: GET /drivers/nearby?lat=37.78&long=-122.41&radius=5km 位置服务->>Redis: GET cache:location:{hash(lat+long+radius)} alt 缓存命中 Redis-->>位置服务: JSON 列表 else 缓存未命中 位置服务->>SpatialIndex: geo query {must: available, filter: radius} SpatialIndex-->>位置服务: 50 个候选司机 位置服务->>ETA: Batch Call ETA 计算 ETA-->>位置服务: 路网时间 位置服务->>Redis: SETEX cache:location:{hash} 4 JSON end 位置服务->>监控: 记录延迟 位置服务-->>客户端: {drivers: [], meta: {total}}

5.2 匹配与确认

问题:高峰热门位置 QPS 过载。现象:特殊活动时,QPS >800 k,数据库过载,匹配延迟,用户重复请求导致流失。原因:Trips 表 21.9 亿行,读延迟 >1 秒。位置状态频繁变化,传统 API 返回静态数据,用户手动刷新造成 30% 无效请求。

  1. 虚拟等待队列:Redis ZSET 管理 queue:location:{lat_long},会员优先、随机防刷,POST /queue/join 加入,定时出队(每秒 100 用户)。
  2. 实时更新:SSE 推送队列进度和匹配状态(Kafka 广播)。
  3. 扩展性:ELB + Kubernetes Auto Scaling(CPU>80%),Cassandra 按 tripID 或 (riderID, timestamp) 分片(Trips),driverID 分片(Drivers),每分片 44 GB,1 k QPS,2 从扩展读负载。
  4. 监控:Prometheus 跟踪 QPS、延迟、错误率,动态调整出队速率。
  5. CDN 缓存:CloudFront 缓存地图数据(TTL 1 min),延迟 <100 ms。

权衡:虚拟队列 vs 速率限制:队列公平、实时反馈,降低用户焦虑;速率限制简单但不透明,易增加 QPS。SSE vs 长轮询 vs WebSocket:SSE 单向、低开销,适合实时位置更新;WebSocket 内存消耗大;长轮询延迟高。选择:队列 + SSE,优先用户体验和可扩展性。

第6章:架构权衡

方案 优点 缺点
QuadTree 支持复杂地理匹配、距离排序、聚合,水平扩展,适合大规模(300 万司机,20 亿行程) 更新频繁时分区开销大
S 2/H 3 固定单元格,查询均匀,适合全球覆盖 边界查询需处理邻近单元格
Redis 缓存 命中率 95%,响应 <50 ms,降低 80% DB 负载 需维护失效策略
无缓存 简单 查询延迟 200-500 ms,高峰 QPS 过载

选择:S 2/H 3 + Redis,优先性能和可扩展性,接受运维复杂性。

原因:简单查询 O (n) 随司机增长线性上升,高峰 QPS 50,000 导致 CPU >90%,用户体验差。Cell-based spatial index 分区降低至 O (log n),Redis 缓存热门查询(80% 流量)提速 10 倍,总体延迟 <200 ms。实例:Uber 使用 cell-based spatial index 支撑百万 QPS,崩溃率降 90%。

6.1 CAP 与一致性

问题:数据一致性保证。问题:数据库(Cassandra)与位置索引(cell-based spatial index)、缓存(Redis)之间存在数据同步延迟或不一致风险。高峰期用户查询和匹配操作可能看到过期数据,导致匹配错误或搜索结果不准确。

原因:Cassandra → cell-based spatial index 使用 CDC(Debezium)同步,存在 ~1 秒延迟。双写模式若处理不当,会出现写入失败或事务异常导致数据不同步。缓存更新策略不当(过期/主动失效)会产生脏数据或缓存雪崩。

  1. 数据库与位置同步: 使用 CDC 捕获变更,Kafka 保证事件顺序,延迟 <1 秒。双写模式:仅在原子事务成功后写入 cell-based spatial index,保证最终一致性。版本号机制:在行程或位置记录中维护 version,每次更新递增,消费者通过 version 校验更新,防止旧数据覆盖新数据。
  2. 缓存一致性策略: Cache Aside(推荐):应用更新数据库后主动删除缓存,下次访问重建。Write Through / Write Behind:适用于写入集中且对延迟容忍高的场景。雪崩防护:热门数据加随机 TTL、限流更新,避免大量缓存同时过期。
  3. 监控指标设计: 数据延迟监控:Cassandra → cell-based spatial index 延迟、Redis 缓存命中率。一致性校验:定期对比主库和位置索引数据,发现差异报警。告警阈值设置:延迟 >2 秒或一致性错误 >0.1% 触发通知。

权衡:CDC vs 双写:CDC 延迟可接受(<1 秒),系统解耦;双写强一致性,但实现复杂、易出错。缓存策略:Cache Aside 灵活、可控;Write Through 写入延迟低,但数据库压力高。选择:CDC + Cache Aside + 版本号机制,兼顾性能、可扩展性和一致性。

原因:高峰期用户查询和匹配并发高,若数据不同步或缓存过期,会导致匹配错误、搜索结果不准确和用户体验下降。通过 CDC + 版本号 + Cache Aside,可确保最终一致性,延迟 <1 秒,缓存命中率 >95%,整体系统稳定性和用户体验提升。

sequenceDiagram participant App as 应用服务 participant DB as Cassandra participant Kafka participant SI as cell-based spatial index participant Redis App->>DB: 更新行程或位置状态 DB-->>Kafka: CDC event {tripID, version, data} Kafka->>SI: 更新索引(version 校验) App->>Redis: DEL cache:trip:{ID} 或 SETEX 新数据 App->>Redis: GET 缓存查询 SI-->>App: 返回搜索结果

6.2 缓存策略

🤔 核心问题

在高并发下,如何使用缓存来加速查询,同时保证数据一致性?

缓存的三个关键问题

  1. 缓存穿透:查询不存在的数据,每次都穿透到数据库
  2. 缓存雪崩:大量缓存同时失效,请求全部打到数据库
  3. 缓存一致性:缓存和数据库的数据不一致

Uber 的缓存架构

缓存层次:

  • L1 缓存:应用本地缓存(Guava Cache)- 延迟 <1ms
  • L2 缓存:Redis 分布式缓存 - 延迟 <10ms
  • L3 缓存:Cassandra 数据库 - 延迟 <100ms

缓存策略对比

策略 工作流程 优点 缺点
Cache Aside
(旁路缓存)
应用先查缓存
→ 缓存未命中
→ 查数据库
→ 更新缓存
灵活、可控
缓存失效不影响数据库
初次查询延迟高
可能出现缓存穿透
Write Through
(写穿)
应用写缓存
→ 缓存写数据库
→ 返回结果
数据一致性强
不会出现缓存穿透
写入延迟高
数据库压力大
Write Behind
(写回)
应用写缓存
→ 立即返回
→ 异步写数据库
写入延迟低
吞吐量高
数据一致性弱
可能丢失数据

Uber 的选择:Cache Aside + 版本号机制

Cache Aside 详解

读流程


1. GET cache:trip:{tripID}
   ├─ 命中 → 返回数据(延迟 <10ms)
   └─ 未命中 → 继续
   
2. SELECT * FROM Trips WHERE tripID = X
   ├─ 查询成功 → 继续
   └─ 查询失败 → 返回错误
   
3. SET cache:trip:{tripID} data EX 300
   └─ 设置 TTL 5 分钟
   
4. 返回数据给应用
                

写流程


1. UPDATE Trips SET status = 'completed', version = version + 1 WHERE tripID = X
   └─ 更新成功 → 继续
   
2. DEL cache:trip:{tripID}
   └─ 删除缓存(强制下次查询从数据库读取)
   
3. 发布 CDC 事件到 Kafka
   └─ 其他服务订阅并更新自己的缓存
                

缓存穿透解决方案

问题:查询一个不存在的 tripID,每次都穿透到数据库,浪费资源。

解决方案 1:缓存空值


// 查询结果为空时,也缓存一个特殊值
SET cache:trip:{tripID} "NOT_FOUND" EX 60
// TTL 设置较短(60 秒),避免长期占用内存
                

解决方案 2:布隆过滤器

  • 在 Redis 中维护一个布隆过滤器,包含所有有效的 tripID
  • 查询前先检查布隆过滤器,不存在则直接返回错误
  • 优点:节省内存,查询快速

缓存雪崩解决方案

问题:大量缓存同时失效(如 Redis 重启),请求全部打到数据库,导致数据库宕机。

解决方案 1:随机 TTL


// 不使用固定 TTL,而是随机 TTL
base_ttl = 300  // 5 分钟
random_ttl = base_ttl + random(0, 100)  // 5-6.67 分钟
SET cache:trip:{tripID} data EX random_ttl
                

解决方案 2:热点数据永不过期

  • 对于热点数据(如机场附近的司机),设置 TTL 为无穷大
  • 通过后台任务定期刷新这些数据

解决方案 3:缓存预热

  • 系统启动时,预先加载热点数据到缓存
  • 避免启动后的初期流量全部打到数据库

缓存一致性保证

使用版本号机制:


// 缓存中存储版本号
cache:trip:{tripID} = {
  "data": {...},
  "version": 3
}

// 查询时检查版本号
GET cache:trip:{tripID}
if (cache.version < db.version) {
  // 缓存版本过旧,删除并重新加载
  DEL cache:trip:{tripID}
  // 重新从数据库加载
}
                

性能指标

指标 数值 说明
缓存命中率 >95% 热门位置占查询 80%,缓存命中率很高
缓存延迟 <10ms Redis 的单次查询延迟
数据库延迟 <100ms 缓存未命中时的延迟
总体延迟 <50ms 95% 命中率下的平均延迟

权衡总结

为什么选择 Cache Aside + 版本号?

CDC vs 双写:

  • CDC 延迟可接受(<1 秒),系统解耦
  • 双写强一致性,但实现复杂、易出错
  • 我们选择 CDC

Cache Aside vs Write Through:

  • Cache Aside 灵活、可控,缓存失效不影响数据库
  • Write Through 一致性强,但写入延迟高
  • 我们选择 Cache Aside,因为读操作远多于写操作

结论:通过 CDC + Cache Aside + 版本号机制,我们可以在高并发下保证数据的最终一致性,同时保持良好的性能。

💡 面试技巧

面试官可能会问:"如果缓存和数据库的数据不一致,用户会看到什么?"

好的回答:"由于我们使用 Cache Aside 策略,缓存的数据最多延迟 TTL 时间(通常 5 分钟)。对于关键数据(如行程状态),我们使用版本号机制来检测不一致,并主动刷新缓存。这样可以确保用户看到的数据最多延迟 1 秒。"

第7章:高峰场景

问题:高峰热门位置 QPS 过载。问题:支付集成与异步处理。现象:同步支付高峰期延迟秒级,阻塞匹配服务,用户流失。原因:Stripe 等第三方支付响应慢(网络/验证 200 ms–2 s),高峰写 QPS 100,同步模式放大延迟。

7.1 虚拟队列设计

  1. 异步支付:POST /confirm 创建 PaymentIntent,立即返回“支付处理中”,Webhook 异步回调处理。
  2. 事务更新:Webhook 验证 tripID,Cassandra 执行 UPDATE IF status=..., 成功发布 Kafka 事件。
  3. 幂等处理:Webhook 重试,事务检查 status=completed 避免重复。

Payment is async and out-of-critical-path。

权衡:Webhook vs 同步:Webhook 异步、延迟低、解耦服务;同步简单但高峰阻塞。Stripe vs 自建支付:Stripe 高可用、快速集成、合规,费用高;自建灵活但开发复杂。选择:Stripe + Webhook + Kafka,优先可靠性和快速部署。

sequenceDiagram participant Client participant MS as Matching Service participant Stripe participant DB as Cassandra participant Kafka participant Prometheus Client->>MS: POST /ride/confirm {tripID, paymentDetails} MS->>DB: SELECT tripID, status, version FROM Trips alt Trip valid MS->>Stripe: Create PaymentIntent {amount, currency, metadata: tripID} Stripe-->>MS: PaymentIntent ID MS-->>Client: {status: "pending"} Stripe->>MS: Webhook: payment_intent.succeeded MS->>DB: UPDATE Trips SET status='completed', version=version+1 IF version=... alt Update success MS->>Kafka: Publish payment confirmation {tripID} MS->>Client: SSE: {tripID, status: "completed"} else Version conflict/expired MS-->>Client: 409 Conflict end else Invalid/expired MS-->>Client: 409 Conflict end MS->>Prometheus: Record payment latency, failure rate

7.2 并发一致性

问题:并发匹配一致性。现象:高峰期一个司机匹配多次(overmatching),行程错误,用户投诉。原因:分布式高峰 QPS 写请求高,多个请求同时访问同一司机记录。单纯数据库事务在高并发下效率低。

  1. Redis 分布式锁:SETNX 锁司机(TTL 3-5 s),快速过滤并发。
  2. 轻量级事务:锁成功后,Cassandra 执行 UPDATE IF version=...,失败回滚。
  3. 异步通知:Kafka 发布匹配成功事件。
  4. 错误处理:锁失败返回 429,事务冲突返回 409,返回司机状态。
  5. 监控:锁冲突率 >1%,事务重试率 >5%,可调整 TTL 或分片策略。

权衡:SETNX + 轻量级事务:降低 90% 并发冲突,延迟 <100 ms,需管理锁 TTL。纯轻量级事务:简单,但高并发重试率高,延迟 >500 ms。悲观锁:强一致性,单分片 QPS <1 k,高峰易卡顿。选择:SETNX + 轻量级事务,兼顾一致性和性能。

sequenceDiagram participant Client participant MS as Matching Service participant Redis participant DB as Cassandra participant Kafka participant Prometheus Client->>MS: POST /ride/request {pickup, dropoff} MS->>Redis: SETNX lock:driver:{ID} tripID (TTL=3-5s) alt Lock acquired MS->>DB: SELECT status, version FROM Drivers WHERE driverID=... DB-->>MS: status=available, version=0 MS->>DB: UPDATE Drivers SET status='assigned', version=1 IF version=0 alt Update success MS->>Kafka: Publish matching event MS->>Redis: DEL lock:driver:{ID} MS-->>Client: {tripID, eta=300} else Version conflict MS->>Redis: DEL lock:driver:{ID} MS-->>Client: 409 Conflict {details: driver status} end else Lock failed MS-->>Client: 429 Too Many Requests end MS->>Prometheus: Record lock conflicts, retry rate

第8章:面试策略

整体设计围绕 高并发、高可用、良好用户体验 展开。通过 cell-based spatial index 位置优化、Redis 缓存与队列、分布式锁、异步支付 等手段,系统在百万级 QPS 下依然保持稳定。微服务架构和消息队列保证了 扩展性和解耦性,同时监控和自动伸缩机制让系统在高峰期依然可靠。

简而言之,这套方案 既解决性能瓶颈,又确保用户体验和运维可控,适合大规模乘车平台的生产环境。

8.1 如何讲述设计

在面试中,先明确需求边界,然后量化规模,找到瓶颈,最后针对性设计方案。解释每个选择的“为什么”。

💼 时间分配建议

  • 5分钟: 澄清需求
  • 5分钟: 容量估算
  • 10分钟: 高层架构
  • 15分钟: 深入关键问题
  • 10分钟: 优化与权衡

8.2 常见追问

  • 如何处理高峰流量?
  • 一致性如何保证?
  • 为什么选择这个数据库/缓存?
  • 如果规模翻倍,怎么扩展?

⚠️ 常见坑

不要一上来就列技术栈,要解释原因。广度优先,避免陷入无关细节。