🚗 Uber 系统设计学习平台
从零开始,手把手带你理解百万级 QPS 的乘车系统是如何设计的
不是死记硬背,而是理解每一个决策背后的"为什么"
👋 欢迎来到系统设计课堂
这不是一份设计文档的简单展示,而是一个交互式学习工具。
我会像一个架构师带实习生一样,一步步带你理解:
📖 如何使用这个平台
- 顺序学习: 从第1章开始,按顺序阅读,每章都有前置知识依赖
- 主动思考: 看到"思考题"时,先不要急着看答案,给自己2分钟想一想
- 展开答案: 点击蓝色的"答案框"查看标准讲解
- 关注面试点: 绿色的"面试提示"框标注了高频考点
- 注意警告: 红色的"警告框"是新手最容易踩的坑
🎯 学完后你能收获什么
- ✅ 理解如何从需求推导出架构设计
- ✅ 掌握容量估算的实用方法(不只是算数字)
- ✅ 学会在一致性、性能、可用性之间做权衡
- ✅ 能够在面试中流畅地讲述一个完整的系统设计
- ✅ 知道面试官会在哪里追问,如何应对
💼 面试官视角
系统设计面试不是在考你"背答案",而是在看:
- 你能否理解业务需求并转化为技术问题
- 你能否在有限时间内抓住核心矛盾
- 你能否解释自己的选择(不是"我觉得",而是"因为...所以...")
- 你能否听懂面试官的暗示并调整方向
🚀 准备好了吗?
点击左侧的「第1章:系统设计的本质」开始学习吧!
记住:系统设计没有标准答案,但有正确的思考方式。
第1章:系统设计的本质
在开始设计 Uber 之前,我们需要先理解一件事:系统设计到底在考什么?
很多人以为系统设计就是"画画图,说说技术栈",这是错的。
🎯 本章学习目标
- 理解系统设计面试的真实意图
- 学会从业务问题到技术问题的转化
- 掌握系统设计的基本思维框架
🤔 思考:如果让你设计 Uber,你会从哪里开始?
给自己1分钟,想想看:
- 你会先想什么问题?
- 你会先设计什么部分?
- 你觉得最难的部分是什么?
💡 标准思路
大多数人的第一反应是:
- "用什么数据库存司机位置?"
- "怎么实现地理位置搜索?"
- "用什么消息队列?"
这些都太早了!
正确的起点应该是:
- 明确问题边界: Uber 的核心功能是什么?我们要解决哪些场景?
- 量化规模: 多少用户?多少 QPS?数据量多大?
- 找到瓶颈: 什么地方会成为性能瓶颈?
- 设计方案: 针对瓶颈设计解决方案
技术选型是最后一步,不是第一步。
⚠️ 新手常见错误
- ❌ 一上来就说"用 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% |
能否覆盖监控、安全等 |
⚠️ 面试中的致命错误
- 不问需求就开始设计
- ❌ "我们用微服务架构..."
- ✅ "请问预期的 DAU 是多少?地理范围是全球还是单个城市?"
- 列技术栈不讲原因
- ❌ "用 Redis、Kafka、Cassandra"
- ✅ "因为位置更新频繁,我选择 Redis 作为热数据缓存,TTL 4秒..."
- 陷入细节不能自拔
- ❌ 花20分钟讲数据库分片算法
- ✅ "分片用哈希,具体算法可以用一致性哈希,咱们先看看匹配流程"
💼 时间分配建议(45分钟面试)
- 5分钟: 澄清需求,确认边界
- 5分钟: 容量估算(粗略即可)
- 10分钟: 高层架构 + 核心流程
- 15分钟: 深入1-2个关键问题(如并发匹配)
- 10分钟: 优化、权衡、追问
记住:广度优先,深度按需。先把整体框架讲清楚,再根据面试官兴趣深入。
第2章:从需求到设计边界
现在正式开始设计 Uber。第一步不是画图,而是明确需求。
很多人觉得这一步简单,其实这是最容易拉开差距的地方。
🎯 本章学习目标
- 学会如何拆解功能需求
- 理解非功能需求的优先级
- 掌握"什么该做,什么不做"的边界判断
🤔 思考:Uber 的核心功能是什么?
用一句话概括,最多20个字。
💡 答案
"连接乘客和司机,提供按需乘车服务"
这句话包含三个关键信息:
- 连接 → 需要匹配算法
- 按需 → 实时性要求高
- 乘车服务 → 涉及位置、支付、评价等
但我们不可能在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 × 每用户每秒请求数
具体计算:
- 乘客请求 QPS
- 乘客 DAU:2000 万
- 每乘客每秒请求数:0.02
- 计算:2000 万 × 0.02 = 400k QPS
- 司机位置更新 QPS
- 司机 DAU:300 万
- 每司机每秒更新数:0.25(每 4 秒更新一次)
- 计算:300 万 × 0.25 = 75k QPS
- 其他操作(支付、历史查询等)
- 总峰值 QPS
- 计算:400k + 75k + 275k = 750k QPS
- 加上缓冲(50%):750k × 1.5 = ~1M QPS
读写比例分析:
- 读操作(查询附近司机、查历史):约 50k QPS
- 写操作(位置更新、匹配、支付):约 750k QPS
- 读写比 = 1:15(写操作为主)
💾 第三步:计算存储需求
计算公式:
3 年总行程数 = 每天行程数 × 365 天 × 3 年
具体计算:
- 3 年总行程数
- 计算:2000 万 × 365 × 3 = 21.9 亿次
- 每条行程记录大小
- tripID (UUID):16 B
- riderID、driverID:32 B
- 坐标、时间戳、状态等:100 B
- 总计:约 150 B/条
- Trips 表原始数据大小
- 计算:21.9 亿 × 150 B ÷ 1024³ = 2.2 TB
- 加上索引(约 30%)
- 计算:2.2 TB × 0.3 = 0.66 TB
- 加上副本(2 份备份)
- 计算:(2.2 + 0.66) × 2 = 5.72 TB
- 其他表(Riders、Drivers、DriverLocations)
- 总存储需求
- 计算:5.72 + 3 = 8.72 TB
- 加上 20% 增长缓冲:8.72 × 1.2 = ~10.5 TB
🔀 第四步:计算分片数
核心问题:为什么要分片?
单台数据库无法处理 750k 写 QPS。通过分片,我们将数据分散到多台机器,每台机器只处理一部分数据和请求。
计算公式:
分片数 = 峰值写 QPS ÷ 单分片承载 QPS
具体计算:
- 单分片承载能力
- 假设单台 Cassandra 节点可承载:1k QPS
- 需要的分片数
- 计算:750k ÷ 1k = 750 个分片
- 加上 50% 冗余和高可用:750 × 1.5 = ~1000 个分片
- 每分片的数据量
- 计算:8.72 TB ÷ 1000 = 8.72 GB/分片
🖥️ 第五步:计算硬件需求
计算公式:
服务器数 = 峰值 QPS ÷ 单服务器承载 QPS
具体计算:
- 单服务器承载能力
- 假设单台服务器可承载:1k QPS(在 p99 < 200ms 的延迟下)
- 高峰期服务器需求
- 计算:800k ÷ 1k = 800 台服务器
- 加上 50% 冗余(故障转移、维护):800 × 1.5 = ~1200 台
- 平时服务器需求
- 假设平时 QPS:5k
- 计算:5k ÷ 1k = 5 台服务器
- 加上冗余:5 × 1.5 = ~8 台
💰 第六步:缓存需求
热点数据分析:
- 热门位置(如机场、火车站)占查询的 80%
- 这些热点数据可以缓存在 Redis 中
缓存数据量计算:
- 热门司机位置
- 热门司机数:300 万 × 20% = 60 万
- 每条记录大小:50 B
- 计算:60 万 × 50 B = 30 MB
- 热门查询结果
- 热门查询数:100 万条
- 每条结果大小:100 B
- 计算:100 万 × 100 B = 100 MB
- 总热点数据量
- 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.1 核心实体关系
- Riders(乘客): RiderID (PK, UUID)、name、email、phone、photo
- Drivers(司机): DriverID (PK, UUID)、name、email、phone、vehicleType、rating
- Trips(行程): TripID (PK, UUID)、riderID (FK, UUID)、driverID (FK, UUID)、pickupLat、pickupLong、dropoffLat、dropoffLong、status (pending/accepted/inprogress/completed)、eta、version (乐观锁)
- DriverLocations(司机位置): DriverID (PK, UUID)、lat、long、timestamp
数据模型与分片策略:采用 查询驱动设计 (Query-Driven Design),通过数据冗余满足不同维度的查询需求:
- Trips 表: Partition Key: tripID。用途:根据 ID 查询行程详情、状态流转。
- TripsByRider 表 (或物化视图): Partition Key: riderID。Clustering Key: timestamp (DESC)。用途:乘客查看“我的行程历史”。
- 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:
- 应用写入 Trips 表
- Cassandra 的 CDC 捕获这个变更
- 发布事件到 Kafka
- 后台服务订阅 Kafka,更新 TripsByRider 和 TripsByDriver
权衡分析
| 方案 |
优点 |
缺点 |
单表设计 (只有 Trips) |
数据简洁,无冗余 |
查询乘客历史需要全表扫描,延迟高 |
多表设计 (Trips + TripsByRider + TripsByDriver) |
查询快速,支持多种查询模式 |
数据冗余,需要 CDC 保证一致性 |
我们的选择:多表设计,因为查询性能对用户体验至关重要。
💡 面试技巧
面试官可能会问:"如果 TripsByRider 表和 Trips 表不一致怎么办?"
好的回答:"我们使用 CDC + Kafka 来保证最终一致性。虽然可能存在短暂的不一致(通常 <1 秒),但通过版本号机制和缓存 TTL,我们可以确保用户看到的数据是可接受的。"
第5章:关键流程
整体采用分层微服务架构,支持水平扩展、高可用性和低延迟。客户端请求通过负载均衡器分发到独立微服务,缓存优化读性能,数据库保证一致性,消息队列处理异步任务。微服务间使用 gRPC 通信,外部 API 保持 RESTful。
5.1 位置更新与搜索
- 负载均衡器:AWS ELB,分发流量,支持自动扩缩和故障转移,基于路径的 L 7 路由。
- 微服务: 位置服务:更新并存储司机位置,使用 cell-based spatial index (e.g. S 2 / H 3) 支持地理搜索。匹配服务:POST /ride/request 和 /ride/confirm,使用 Redis 锁 + Cassandra 轻量级事务处理。行程服务:GET /ride/{tripID},优先从 Redis 缓存读取,未命中则访问 Cassandra。ETA 服务:计算实际路网行驶时间。
- 缓存:Redis,存储热门位置和搜索结果,TTL 4~10 秒,命中率预期 95%。
- 数据库: Cassandra:主存储,按 tripID 或 (riderID, timestamp) 分片(Trips),driverID 分片(Drivers),支持轻量级事务和主从复制。Cell-based spatial index:位置索引,动态分区,结合 Redis 哈希表处理高频更新。
- 消息队列:Kafka,用于异步任务(支付确认、通知推送),128 分区,保留 7 天。
- CDN:CloudFront,缓存静态资源(如地图数据),全球分布,延迟 <100 ms。
- 监控: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% 无效请求。
- 虚拟等待队列:Redis ZSET 管理 queue:location:{lat_long},会员优先、随机防刷,POST /queue/join 加入,定时出队(每秒 100 用户)。
- 实时更新:SSE 推送队列进度和匹配状态(Kafka 广播)。
- 扩展性:ELB + Kubernetes Auto Scaling(CPU>80%),Cassandra 按 tripID 或 (riderID, timestamp) 分片(Trips),driverID 分片(Drivers),每分片 44 GB,1 k QPS,2 从扩展读负载。
- 监控:Prometheus 跟踪 QPS、延迟、错误率,动态调整出队速率。
- 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 秒延迟。双写模式若处理不当,会出现写入失败或事务异常导致数据不同步。缓存更新策略不当(过期/主动失效)会产生脏数据或缓存雪崩。
- 数据库与位置同步: 使用 CDC 捕获变更,Kafka 保证事件顺序,延迟 <1 秒。双写模式:仅在原子事务成功后写入 cell-based spatial index,保证最终一致性。版本号机制:在行程或位置记录中维护 version,每次更新递增,消费者通过 version 校验更新,防止旧数据覆盖新数据。
- 缓存一致性策略: Cache Aside(推荐):应用更新数据库后主动删除缓存,下次访问重建。Write Through / Write Behind:适用于写入集中且对延迟容忍高的场景。雪崩防护:热门数据加随机 TTL、限流更新,避免大量缓存同时过期。
- 监控指标设计: 数据延迟监控: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 缓存策略
🤔 核心问题
在高并发下,如何使用缓存来加速查询,同时保证数据一致性?
缓存的三个关键问题
- 缓存穿透:查询不存在的数据,每次都穿透到数据库
- 缓存雪崩:大量缓存同时失效,请求全部打到数据库
- 缓存一致性:缓存和数据库的数据不一致
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 虚拟队列设计
- 异步支付:POST /confirm 创建 PaymentIntent,立即返回“支付处理中”,Webhook 异步回调处理。
- 事务更新:Webhook 验证 tripID,Cassandra 执行 UPDATE IF status=..., 成功发布 Kafka 事件。
- 幂等处理: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 写请求高,多个请求同时访问同一司机记录。单纯数据库事务在高并发下效率低。
- Redis 分布式锁:SETNX 锁司机(TTL 3-5 s),快速过滤并发。
- 轻量级事务:锁成功后,Cassandra 执行 UPDATE IF version=...,失败回滚。
- 异步通知:Kafka 发布匹配成功事件。
- 错误处理:锁失败返回 429,事务冲突返回 409,返回司机状态。
- 监控:锁冲突率 >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 常见追问
- 如何处理高峰流量?
- 一致性如何保证?
- 为什么选择这个数据库/缓存?
- 如果规模翻倍,怎么扩展?
⚠️ 常见坑
不要一上来就列技术栈,要解释原因。广度优先,避免陷入无关细节。