免费完整指南

Tinder

设计Tinder。请覆盖Swipe ingestion as a high-volume, append-only event write path (like/pass events) decoupled from match detection, Match detection: checking whether A likes B AND B likes A, where that...

00

练习检查点

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

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

塑造设计的需求

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

功能需求

01是什么决定谁出现在我的牌堆里——偏好、距离,还是两者都要?

用户设置偏好(年龄范围、兴趣)和一个最大距离;牌堆只包含同时满足两者的候选人。

02滑动是一次一张吗,一个资料有可能再回来吗?

用户一次一张、对一个资料往左或往右滑,而滑过的资料永远不会再出现——重复出现的资料读起来就像坏掉了。

03在互相往右滑的那一刻会发生什么?

两位用户都立即收到匹配通知——就在第二个往右滑落地的那一刻,而不是几分钟后。

04用户可以撤销一次往左滑(rewind)吗?

在一个短窗口内可以——而且被 rewind 的资料可以再次出现在牌堆里。

05取消匹配做了什么?

取消匹配把匹配移除、把聊天关闭,是同一个动作——一个撤销到一半的匹配(聊天还活着、匹配没了)是一个信任 bug。

06用户在中途改了偏好——他们的牌堆会怎样?

新偏好立即生效——下一张展示的资料就必须满足更新后的过滤条件,而不是旧的。

范围外照片上传管线 · 匹配之后的消息 · 付费功能(super swipe、boost)

非功能需求

01两个人几乎在同一瞬间互相往右滑——能保证恰好一个匹配、永远不会是零个或两个吗?

恰好一个匹配,并立即检测——永远不会是零个、也不会是两个,无论时间靠得多近。

02我们是为多大的滑动量做规划?

2000 万日活跃用户(DAU)× 每人约 100 次滑动 ≈ 每日 20 亿次滑动——平均约每秒 23K 次滑动。

03牌堆必须多快出现?

300 ms 以内。

04「绝不重复出现」这条规则有多硬?

很硬:它必须跨 session、跨设备、跨重装都成立——把一个资料显示两次,比悄悄跳过一个候选人更糟。

05其他用户到底能看到什么位置数据?

只给一个粗略的距离桶(「约 5 km 外」)——原始 lat/lng 在任何 payload 里都永远不离开后端。

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

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

  • 滑动历史必须留多久——永远,还是几年前的滑动可以过期?
  • 机器人账号与假资料的滑动在范围内吗,还是由上游的信任与安全系统过滤?
  • 如果有人拉黑或举报一位用户,两人必须立即从彼此的牌堆里消失吗?
  • 有没有市场施加数据驻留规则——用户的位置数据必须留在区域内吗?
  • super-like/谁按过我 在范围内吗?它们会改变读取模式。
02

逼出架构决策的数字

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

01

滑动写入率

2000 万日活跃用户(DAU)× 每人每日约 100 次滑动2B ÷ 86,400 s ≈ 23K swipes/s 平均,晚上 ×3-5 ≈ 100K/s 峰值

滑动需要一个写入优化的存储;在这个速率下,互相检查每次滑动必须维持 O(1)。

02

看过的资料内存

假设一个重度用户一生滑过约 5 万个资料Bloom filter 在 1% false positive 下 ≈ 每条 10 bits → 50K × 10 b ≈ 每位用户 62 KB

整个「绝不重复出现」的防护每位用户只要几 KB——精确的滑动历史留在磁盘上。

03

牌堆预计算成本

假设每位活跃用户约 200 个候选人的牌堆,剩不多时刷新20M users × 200 IDs × 8 B ≈ 32 GB

为每位活跃用户预先算好的牌堆放得进一层缓存——这正是让 <300 ms 可行的原因。

04

为什么不实时查询

一座密集的城市可以把 2000 万 DAU 的约 1%——约 20 万名用户——放进同一个最大距离圆内,每一位都需要 geo+年龄+偏好+「没看过」的过滤。20 万个候选人 × 每个约 1-2 µs 做过滤交集和看过检查 ≈ 每次请求 200-400 ms——排名还没开始,整个 300 ms 预算就烧光了

feed 预算逼出预计算加补满;实时查询在后台跑,不在请求路径上。

05

匹配检查成本

每次往右滑都检查反方向每次滑动 1 次原子的 read-modify-write ≈ 内存中亚毫秒

把这一对的滑动状态放在一起(同一个 key)正是让检查维持一个操作的关键。

决策示例

数字

峰值每秒十万次滑动,而那个绝不能出错的一刻,是两个人同时对彼此往右滑。

我的选择

我会把每一对的滑动状态放在同一个 key 下(两个 user ID,较小的在前),并把滑动加互相检查当成一个原子操作来跑——Redis 里的一段 Lua script 一步完成 read-modify-write,持久副本在它背后写进 swipe store。牌堆每位用户预先算好,由后台 geo 查询补满;每位用户「看过的资料」存在一个 Bloom filter 里,所以「绝不重复出现」只花几 KB,而不是一次历史扫描。

避免

我不会做的事:分成两个独立步骤记录滑动并检查反向——两次同时的往右滑会各自漏掉对方,匹配永远不触发。那个 check-then-act 的空隙,就是把演唱会座位重复卖掉的同一种竞态;检查和写入必须是一个操作。我也不会把 feed 做成每次请求一次实时 geo 查询——300 ms 预算在索引交集里就死掉了。

何时改变

如果 Redis 操作变成瓶颈、或集群 failover 的复杂度开始变得棘手,我会把原子性移进存储层:一个复合分区 key(smaller_id:larger_id)让两次滑动落在同一个分区,那里一个轻量事务(数据库自己的 compare-and-set——比较慢,但内建)就涵盖了这一对——每次滑动慢一点,少一套活动系统。

03

架构路径

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

完整全貌

总览 —— 每个组件

ClientAPI GatewaySwipe ServicePair State(Redis, atomic)Swipe Store(durable)MatchNotifications互相往右滑 → 两位用户Deck Service +Cache预先算好的 feedBloom Filters(seen)绝不重复出现

滑动路径是一致性关键的脊柱;feed 路径是读取优化且预先算好的。虚线 = 从持久滑动异步维护,缓存丢失后可重建。

路径 1

滑动路径——原子性地记录并匹配

ClientSwipe ServicePair Key (Lua,one op)Swipe StoreMatchNotification

一个原子操作记录滑动并检查反向。持久写入随后;一个 Redis 节点失效会从持久存储重放,最多失去最后那一瞬间未刷入的滑动——为了滑动路径的速度而接受的取舍。

路径 2

feed 路径——预先算好的牌堆,随时补满

ClientDeck CacheDeck ServiceGeo + PreferenceQueryBloom Filter(exclude seen)

请求路径只读牌堆。当它剩不多时,后台查询把它补满,通过 Bloom filter 排除看过的资料——false positive 会跳过一个候选人,但绝不重复一个。

04

API 与数据模型

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

POST/profile

请求{ age_min, age_max, distance_km, interested_in }

响应200 profile

身份来自 session token。偏好变更会让预先算好的牌堆失效。

GET/feed

响应200 User[] (next slice of the deck)

服务预先算好的牌堆;当它剩不多时,一次新的 geo+偏好查询把它补满。位置来自 session context,不是查询参数。

POST/swipe/{target_user_id}

请求{ decision: yes | no }

响应200 { matched: boolean } — matched:true fires both notifications

那个原子步骤:把滑动记录下来,并在同一个操作里检查反向的滑动。

核心实体

User

user_id (PK) · profile · preferences · geo_cell (coarse)

位置以一个粗略的 geo cell 存储以供匹配;精确坐标永远不会被服务给其他客户端。

Swipe

swiping_user · target_user · decision: yes/no · swiped_at

持久写入 swipe store;同时折进滑动者「看过的资料」Bloom filter。

Match

match_id (PK) · user_a · user_b · matched_at

05

深入方向

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

重点

同时的往右滑

两位用户在同一个 10 ms 内对彼此往右滑。走一遍产生一个匹配、而非零个的确切操作。

回答

让它成为一个原子操作。把两个用户的 swipe 状态存在同一个 key 下,两个 ID 按从小到大排。在一个 Redis Lua 脚本里记录这次 swipe、同时检查反向的那次 swipe。第二次 swipe 总是能看到第一次,所以恰好触发一次 match。

避免

分两步 read-then-write——两次滑动各自漏掉对方,匹配就悄悄永不发生。

重点

绝不重复出现

你如何在不扫描历史的前提下,把 5 万个滑过的资料从每次牌堆补满中排除?

回答

给每个用户一个 Bloom filter,装下他 swipe 过的每一个 profile。5 万条大约 62 KB。拿它筛每个候选牌;一次误判顶多跳过一个候选,绝不会重复推同一个。Bloom filter 没法“忘掉”,所以把最近几次 swipe 留在一个小的精确 buffer 里——rewind 就是靠它撑起来的。

避免

把 Bloom false positive 当成 bug——跳过一个候选人是设计好的成本;重复一个才是失败。

重点

牌堆见底

一位活跃用户把整个预先算好的牌堆都滑完了。什么把它补满、它有多新鲜、他们又看到什么延迟?

回答

每个请求都从预算好的 deck 缓存里回答。到达低水位时异步触发补充,这样一个后台的地理加偏好查询会在用户翻到底之前,把这副 200 张的候选牌重建好。正是这个预计算,把实时的多条件查询挡在 300 ms 路径之外。偏好一改,就丢掉这副牌,用同样的方式补新的。

避免

在空牌堆的请求上同步补满——300 ms 预算在查询开始前就没了。

重点

有位置却不泄漏它

匹配需要距离,用户却绝不能看到坐标。精度在哪里被丢掉,API 实际返回什么?

回答

精确坐标只留在服务端,用来选候选。后端算出距离,先把它归到一个粗略的桶里,比如“约 5 公里外”,然后才放进任何响应。任何 payload 的任何字段里都不带 lat/lng。在客户端做四舍五入是做样子——payload 本身才是泄漏点。

避免

把原始 lat/lng 送给客户端、再在 UI 里四舍五入——payload 本身就是那个泄漏。

重点

所有人都滑同一个人

一个非常热门的资料出现在数百万个牌堆里。什么先变成热点,你又如何让曝光保持均衡?

回答

最先出现热点的是这个 profile 的配对 key 和 swipe 分区,所以把这部分状态分片或做副本。然后给这个 profile 一个曝光额度:限制它同时能占据多少副在用的牌,随着被 swipe 消耗再补回来。这样一个热门 profile 就没法塞满附近每一副牌,把别人能看到的机会都饿死。

避免

忽略曝光偏斜——没有上限,热门资料会霸占每一个牌堆,互动就崩了。

准备好练习了吗?

把 Tinder 大声讲一遍,让 AI 为你的说明评分。

用 AI 练习这题 →