☰
Tinder滑动匹配源码:候选池、事务匹配与实时会话闭环
2026/10/27 12:35:00 网站建设 项目流程

简介:Tinder-1.2.2源码包面向Java开发者以及希望研究开源工具实现原理的学习者,适合具备一定Java基础、希望从成品代码中提炼实现思路的读者,侧重从代码层面理解模块化设计、数据处理逻辑与工具组件的组织方式。压缩包共53个文件,约127KB,以50个Java源文件为主体,辅以HTML说明页面、XML配置和序列化数据,体量轻便,适合快速导入阅读与调试。目前已有1636人学习,说明其在源码研读场景中具有一定参考价值。包内保留src、test、main等典型目录与pom.xml构建配置,读者可据此梳理依赖关系、类职责划分和测试组织方式,并观察版本迭代中功能增强与问题修复的痕迹。对于想积累工程实践、学习异常处理与可维护性设计的开发者,这份材料可作为小型案例对照研读,也便于按模块拆解后用于自己的工具开发与代码评审。

1. 项目定位:一套能直接跑的滑动匹配源码,省掉从零搭骨架的两周

去年帮一个朋友看他做的社交产品,UI 已经画得很漂亮,但一登录就露馅:卡片划了三张就“没有更多了”,划过的用户第二天又冒出来,两边互相喜欢了却谁也收不到提示。问题不在设计,在于滑动匹配这条链路——候选池、手势、结果落库、匹配判定、实时会话,少一环都跑不通。这套tinder源码干的就是这件事:把卡片滑动匹配(swipe)的完整闭环做成了一个可直接启动的项目,前端手势动画、后端推荐队列、匹配幂等写入、WebSocket 会话都齐了。适合两类人:想快速验证社交产品想法的独立开发者,以及需要一份能改的骨架做二次开发的小团队。它不是玩具 Demo,数据库事务、分页游标、断线重连这些真正会翻车的地方都写了。

2. 技术栈与目录结构:从一次卡片滑动看清前后端的分层

先给结论:这套源码走的是“瘦前端 + 明确分层后端”的路子,没有上微服务,也没有塞一堆中间件。对一份要拿来改的源码来说,这是对的——你打开编辑器能在十分钟内找到滑动逻辑写在哪一行。技术选型上,前端用 React + TypeScript + Vite,手势和飞出动画交给 framer-motion;后端 Node.js + Express + TypeScript,ORM 用 Prisma,主库 PostgreSQL,Redis 只承担候选池缓存和在线状态;实时消息用 Socket.IO,图片走对象存储的预签名上传。

2.1 技术选型与依赖清单

为什么不选更“潮”的组合?滑动卡片这个交互对手势的跟手程度要求极高,用 CSS transition 手写拖拽,在低端安卓机上会明显掉帧,framer-motion 的 spring 动效至少能把这部分玄学问题挡在框架层。后端选 Prisma 而不是裸写 SQL,主要图它的迁移和类型生成,swipe、match、message三张表的关系能被类型系统兜住,改字段时不会漏。

核心依赖大致是这样:

层依赖用途
前端react / react-dom视图层
前端framer-motion卡片拖拽、飞出动画
前端@tanstack/react-query候选池分页与缓存
前端socket.io-client匹配与消息推送
后端express + zod路由与入参校验
后端prisma / @prisma/client数据访问与迁移
后端ioredis候选池缓存、在线状态
后端socket.io房间与消息广播
后端jsonwebtoken会话鉴权

依赖版本不在这里写死,源码里的package.json是准的,你按 lock 文件装就行,别手动升级大版本——Prisma 和 Socket.IO 跨大版本升级的 breaking change 足够让你调一晚上。

2.2 目录结构与模块职责

源码的目录切得很直白,前后端各占一个包,共享类型单独抽出来:

tinder/ ├── apps/ │ ├── web/ # React 前端 │ │ ├── src/ │ │ │ ├── components/ │ │ │ │ └── SwipeCard.tsx # 单张卡片:手势 + 动画 │ │ │ ├── pages/ │ │ │ │ ├── Deck.tsx # 卡片堆叠页 │ │ │ │ └── Matches.tsx # 匹配列表与会话入口 │ │ │ ├── hooks/ │ │ │ │ ├── useDeck.ts # 候选池拉取与预取 │ │ │ │ └── useSocket.ts # 连接、重连、事件订阅 │ │ │ └── api/ # 请求封装 │ └── server/ # Express 后端 │ ├── src/ │ │ ├── routes/ │ │ │ ├── deck.ts # GET /api/deck 候选池 │ │ │ ├── swipe.ts # POST /api/swipe 滑动结果 │ │ │ └── chat.ts # 会话与历史消息 │ │ ├── services/ │ │ │ ├── recommend.ts # 推荐排序与过滤 │ │ │ └── match.ts # 匹配判定(事务内) │ │ ├── realtime/ │ │ │ └── gateway.ts # Socket.IO 握手鉴权与房间 │ │ └── lib/ │ │ ├── prisma.ts │ │ └── redis.ts │ └── prisma/ │ └── schema.prisma # 数据模型与索引 └── packages/ └── shared/ # 前后端共用类型 └── src/types.ts

这个结构里最值得看的是services/recommend.ts和services/match.ts的分离:前者只负责“给谁看”,后者只负责“滑完之后怎么算”。把这两件事揉在一个函数里是常见的新手写法,等到要换推荐策略时就会发现自己动不了手脚。

2.3 本地启动:三条命令与两个服务

环境准备比想象中简单,PostgreSQL 和 Redis 起来之后,剩下的交给 Prisma:

# 1. 装依赖(仓库用 pnpm workspace 管理多包) pnpm install # 2. 配置环境变量,复制模板后填数据库和 Redis 地址 cp .env.example .env # DATABASE_URL / REDIS_URL / JWT_SECRET / S3_* 这几项必填 # 3. 建表并灌入种子数据(种子里有几十个测试用户和头像) pnpm --filter server prisma migrate dev pnpm --filter server prisma db seed # 4. 同时起前后端 pnpm dev

pnpm dev会并行拉起 Vite(默认 5173)和 Express(默认 3001),前端通过 Vite 的 proxy 把/api转发到后端,省掉跨域配置。第一次跑最容易卡住的是种子数据里的头像地址——如果你的对象存储凭证没配,图片会 403,但不影响滑动逻辑,先用默认占位图也能把流程走完。

提示:.env里的JWT_SECRET不要用模板里的默认值,种子脚本会用它签发测试账号的 token,改完之后要重新跑一次 seed,否则登录会 401。

3. 核心模块拆解:候选池、卡片手势与滑动结果落库

这条链路是一次滑动的完整生命周期:前端从候选池拿一批人 → 渲染成卡片堆 → 用户拖动松手 → 结果写到后端 → 后端判断是否互相喜欢 → 推事件给双方。四段里任何一段写得糙,表现出来都是“滑动没反应”或“匹配了但没提示”,而这两种现象的原因完全不在同一层,所以拆开看很有必要。

3.1 卡片手势:拖拽阈值与飞出动画

卡片的交互要求是“跟手”,也就是手指移动多少像素,卡片就转多少度、偏多少距离;松手时按位移和速度两个维度决定是回弹还是飞出。源码里这段逻辑全在SwipeCard.tsx:

// SwipeCard.tsx import { motion, useMotionValue, useTransform } from "framer-motion"; // 松手时判定为“滑走”的水平位移阈值(像素) const SWIPE_THRESHOLD = 120; // 速度阈值:即使位移不够,快速甩出也算滑走 const VELOCITY_THRESHOLD = 500; export function SwipeCard({ user, onSwipe, onEmpty }) { const x = useMotionValue(0); // 位移映射成旋转角度,-1 到 1 对应 -12deg 到 12deg const rotate = useTransform(x, [-300, 300], [-12, 12]); const likeOpacity = useTransform(x, [0, 120], [0, 1]); const nopeOpacity = useTransform(x, [-120, 0], [1, 0]); const handleDragEnd = (_e, info) => { const passed = Math.abs(info.offset.x) > SWIPE_THRESHOLD || Math.abs(info.velocity.x) > VELOCITY_THRESHOLD; if (!passed) { // 没到阈值,弹回原位 x.set(0); return; } const direction = info.offset.x > 0 ? "like" : "pass"; // 先乐观更新 UI,请求失败由上层回滚 onSwipe({ targetId: user.id, direction }); }; return ( <motion.div drag="x" style={{ x, rotate }} dragConstraints={{ left: 0, right: 0 }} dragElastic={0.7} onDragEnd={handleDragEnd} > {/* 卡片内容,叠加 LIKE / NOPE 水印 */} <motion.span style={{ opacity: likeOpacity }}>LIKE</motion.span> <motion.span style={{ opacity: nopeOpacity }}>NOPE</motion.span> </motion.div> ); }

逻辑上就三件事:dragConstraints把左右约束设成 0,等于告诉 framer-motion“允许拖但松手后自己归位”;dragElastic控制超出边界时的橡皮筋阻尼;onDragEnd里用位移或速度任一达标来判定方向。参数上,SWIPE_THRESHOLD调到 60 以下会变得过于灵敏,用户想横向看细节图时容易误滑,调到 200 以上又滑不动,120 是反复试出来的手感值。info.velocity.x这个判断是血泪经验——很多人只判断位移,结果快速甩出时卡片弹回原位,用户会觉得“卡住了”。

onSwipe是乐观更新:先把卡片从堆里移走再发请求,失败了再插回去。这一步不做的话,弱网环境下每滑一张都要等 300ms,体验会很糟。

3.2 候选池:游标分页与 Redis 缓存

候选池的难点从来不是“查用户”,而是“别把人查重复、别越翻越慢”。用OFFSET分页在数据量大时会崩,源码用的是基于lastSeenId的游标分页,并先把候选 ID 列表缓存在 Redis 里:

// services/recommend.ts export async function buildDeck(userId: string, cursor?: string, size = 20) { const cacheKey = `deck:${userId}`; // 命中缓存直接切片返回,避免每次都扫库 const cached = await redis.lrange(cacheKey, 0, -1); if (cached.length > 0) { const start = cursor ? cached.indexOf(cursor) + 1 : 0; const ids = cached.slice(start, start + size); return { ids, nextCursor: ids.at(-1) ?? null }; } // 未命中:只挑我还没滑过、性别偏好匹配、且有头像的用户 const rows = await prisma.$queryRaw<{ id: string }[]>` SELECT u.id FROM "User" u WHERE u.id <> ${userId} AND u."avatarUrl" IS NOT NULL AND u."gender" = ANY(${preferredGenders(userId)}) AND NOT EXISTS ( SELECT 1 FROM "Swipe" s WHERE s."swiperId" = ${userId} AND s."targetId" = u.id ) ORDER BY u."lastActiveAt" DESC LIMIT ${size * 5}`; const ids = rows.map((r) => r.id); // 缓存 10 分钟,滑走的人下次重建时自然被 NOT EXISTS 过滤掉 await redis.del(cacheKey); if (ids.length) await redis.rpush(cacheKey, ...ids); await redis.expire(cacheKey, 600); const batch = ids.slice(0, size); return { ids: batch, nextCursor: batch.at(-1) ?? null }; }

NOT EXISTS子查询是这里的关键,它把“已经滑过的人”挡在候选池外,比LEFT JOIN ... WHERE s.id IS NULL在 PostgreSQL 上更容易走索引,前提是Swipe(swiperId, targetId)上有联合唯一索引——schema.prisma里确实建了。size * 5的放大取数是为了应对缓存重建时的过滤损耗,一次多取一些,前端预取的几屏就够用了。

cursor参数用的是上一批最后一个 ID,前端把它原样带回就翻下一页。这里不传 offset 的原因是:用户滑走一批人之后,候选池集合变了,offset 分页会漏人或重复。这个坑我在别的项目里踩过,表现是“划着划着又出现刚划过的人”,排查了半天以为是缓存没失效。

3.3 滑动结果落库:一个事务里做完三件事

POST /api/swipe看起来只是插一条记录,实际要在同一个事务里完成“写滑动记录 → 查对方是否喜欢过我 → 是则建匹配 → 推事件”。任何一步脱离事务,都可能出现重复匹配或者匹配丢失:

// services/match.ts export async function recordSwipe(swiperId: string, targetId: string, direction: "like" | "pass") { return prisma.$transaction(async (tx) => { // 1. upsert:同一对用户重复请求不会报错,方向以最后一次为准 const swipe = await tx.swipe.upsert({ where: { swiperId_targetId: { swiperId, targetId } }, create: { swiperId, targetId, direction }, update: { direction }, }); if (direction !== "like") return { matched: false }; // 2. 查对方是否已经 like 过我 const reverse = await tx.swipe.findUnique({ where: { swiperId_targetId: { swiperId: targetId, targetId: swiperId } }, }); if (reverse?.direction !== "like") return { matched: false }; // 3. 建匹配,用唯一索引兜住并发下的重复插入 const [a, b] = [swiperId, targetId].sort(); try { await tx.match.create({ data: { userAId: a, userBId: b } }); } catch (e) { // P2002 = 唯一约束冲突,说明另一路请求已经建好了,直接吞掉 if ((e as any).code !== "P2002") throw e; } return { matched: true, matchId: `${a}_${b}` }; }); }

upsert用的是swiperId_targetId联合唯一约束,这个约束在 schema 里必须存在,否则并发下会插出两条方向相反但都属于同一对用户的记录。第三步[swiperId, targetId].sort()把两个 ID 排序后再写userAId/userBId,是为了让“A 匹配 B”和“B 匹配 A”落到同一条记录上,配合唯一索引就实现了幂等。捕获P2002不是偷懒,是并发场景下的正常路径,抛出去反而会让用户看到一个本不该出现的 500。

返回值里的matched决定前端要不要弹匹配成功页,matchId则是后续建会话的房间号。

4. 匹配与实时会话:双向喜欢之后的三段式处理

匹配成功不等于双方就知道,还得把事件推出去、把会话建起来、把历史消息存住。这三件事在源码里分别落在事件广播、房间管理和消息落库上,任何一处没做好,用户看到的就是“匹配页闪了一下,然后聊天列表里什么都没有”。

4.1 事件推送:按用户维度而不是按房间

匹配成功后,服务端要通知双方。源码没有在 HTTP 响应里等,而是在事务提交后抛一个领域事件,由实时层订阅:

// routes/swipe.ts const result = await recordSwipe(user.id, targetId, direction); if (result.matched) { // 事务已提交,这里推事件是安全的 io.to(`user:${user.id}`).to(`user:${targetId}`).emit("match:new", { matchId: result.matchId, peer: await loadPeerCard(targetId, user.id), }); } res.json({ matched: result.matched });

注意房间名是user:${id}而不是match:${id}。按用户维度建房间的好处是:无论这个用户有几条匹配、在哪个页面,都能收到通知;如果按匹配建房间,用户必须先进入某个匹配详情页才订阅得到,列表页就漏了。loadPeerCard单独查一次而不是从候选池里拿,是因为对方可能在你的缓存之外——比如你滑过一轮之后对方才注册。

4.2 消息落库与已读位点

消息不落库只广播,是这类项目最常见的设计缺陷,一刷新就全没了。源码的做法是先写库再广播,并且用一个游标记录每个会话的已读位点:

// realtime/gateway.ts socket.on("chat:send", async (payload, ack) => { const { matchId, content } = payload; // 校验发送者确实属于这个匹配 const match = await prisma.match.findUnique({ where: { id: matchId } }); if (!match || ![match.userAId, match.userBId].includes(socket.data.userId)) { return ack({ ok: false, code: "FORBIDDEN" }); } const msg = await prisma.message.create({ data: { matchId, senderId: socket.data.userId, content, createdAt: new Date() }, }); // 先落库拿到自增 ID,再广播,保证两端顺序一致 io.to(`match:${matchId}`).emit("chat:message", msg); ack({ ok: true, id: msg.id }); });

ack回调是 Socket.IO 自带的能力,前端发消息时传一个回调,拿到{ ok: true, id }之后再把这条消息标记为“已送达”,否则显示发送中。这个细节不做,弱网下用户会看到消息重复发两遍。

注意:io.to(room).to(room2).emit()是并集广播,同一个 socket 如果在两个房间里只会收到一次,但在匹配场景下不要写成io.to(a).emit(); io.to(b).emit();两条,那样自己会收到两份。

4.3 握手鉴权与断线重连

Socket.IO 的鉴权放在握手阶段,而不是连上之后再发一个auth事件,否则中间有个窗口期能收到不该收的消息:

// realtime/gateway.ts io.use(async (socket, next) => { const token = socket.handshake.auth?.token; try { const payload = jwt.verify(token, process.env.JWT_SECRET!); socket.data.userId = payload.sub; // 握手成功即入个人房间,后续匹配通知不用再订阅 socket.join(`user:${payload.sub}`); next(); } catch { next(new Error("UNAUTHORIZED")); } });

前端侧的重连策略是reconnectionAttempts: Infinity配合指数退避,关键是重连成功之后要重新拉一次增量消息——从本地记录的lastMessageId之后拉,而不是拉全量。这一步漏了,用户在电梯里断网三十秒出来,中间的消息就永久丢失了。

5. 避坑与排查:本地能跑、线上翻车的五处高频问题

这份源码本地跑通大概半小时,但真要放到有真实用户的场景里,下面这几处几乎一定会遇到。每条按现象、原因、解决三段记录,都是我或者身边人实际碰过的。

现象一:手机上滑卡片,页面跟着上下滚动。原因:touch-action没设,浏览器把横向拖拽也当成滚动手势处理,React 的onDragEnd根本不触发。解决:在卡片容器上加touch-action: pan-y,只允许纵向滚动穿透,横向交给组件;同时给motion.div加dragDirectionLock,防止斜着拖时方向判定抖动。

现象二:滑了十几张之后,后端接口越来越慢。原因:候选池用了OFFSET分页,每翻一页数据库都要扫描并丢弃前面所有行,数据量到十万级时肉眼可见地卡。解决:换成游标分页,把lastSeenId或lastActiveAt作为游标条件传回去,同时在User(lastActiveAt)上建索引。这类问题在本地小数据集上完全复现不出来。

现象三:两个人都右滑了,匹配列表里出现两条重复记录。原因:双方几乎同时发请求,两个事务各自查到对方已经 like,各自插了一条match。解决:match表上(userAId, userBId)建唯一索引,插入前把两个 ID 排序,捕获P2002当作已存在处理。这个后悔药必须提前吃,等脏数据产生了再清洗会很麻烦。

现象四:断网重连后,刚发的消息消失了。原因:前端只做了 socket 重连,没有做消息补拉,重连期间对方发的消息服务端已经广播过,客户端错过了就永久丢失。解决:每次connect事件触发时,用本地lastMessageId拉一次GET /api/chat/:matchId/messages?after=,把缺口补上,再把 UI 里的“发送中”状态重放一遍。

现象五:头像图片在 Safari 上全部裂开。原因:对象存储返回的预签名 URL 带了过短的过期时间,页面停留超过 5 分钟后再滚动到新卡片就 403。解决:把预签名有效期调到与页面会话时长匹配(比如 1 小时),并且前端在请求候选池时让后端直接返回签名后的 URL,而不是返回 object key 让前端自己拼。

提示:这五条里,第一条和第三条最难自查,因为本地开发环境要么用鼠标(没有 touch 事件),要么单用户测试(没有并发)。上真机、开两个浏览器窗口,是最低成本的两个验证动作。

6. 二次开发:把推荐排序换成自己的算法并验证效果

源码默认的排序是“最近活跃优先”,够用但没什么业务味道。真要落地,第一个要动的就是recommend.ts里的排序。我的做法是先抽成一个可插拔的评分函数,而不是直接改 SQL 的ORDER BY——SQL 里写死排序规则,后面加权重、做灰度都会很痛苦。

// services/scoring.ts type Candidate = { id: string; lastActiveAt: Date; distanceKm: number; commonTags: number; popularity: number; // 被喜欢次数归一化后的值 }; // 权重集中在配置里,方便做 A/B export const WEIGHTS = { active: 0.4, distance: 0.3, tags: 0.2, popularity: 0.1 }; export function score(c: Candidate): number { // 活跃度:24 小时内线性衰减到 0 const hours = (Date.now() - c.lastActiveAt.getTime()) / 36e5; const activeScore = Math.max(0, 1 - hours / 24); // 距离:50km 内线性给分,超出记 0 const distanceScore = Math.max(0, 1 - c.distanceKm / 50); // 共同标签:3 个封顶 const tagScore = Math.min(c.commonTags / 3, 1); return ( WEIGHTS.active * activeScore + WEIGHTS.distance * distanceScore + WEIGHTS.tags * tagScore + WEIGHTS.popularity * c.popularity ); }

改造时把原来的ORDER BY u."lastActiveAt" DESC去掉,改为先查出候选集(数量控制在 200 左右),在 Node 层用score()排序再裁到 20 条。多了一次内存排序,但换来的是权重可以随时调、可以按用户分桶。注意别把候选集放大到几千条,那点排序开销会把分页响应从 40ms 拖到 300ms 以上。

改完怎么验证?我一般会做两层检查。第一层是离线回放:把线上过去一周的滑动记录导一份,用新旧两套排序各跑一遍,看 Top 20 的重合度和右滑率差异,重合度低于 40% 说明改动幅度过大,要谨慎放量。第二层是灰度:按用户 ID 哈希分 10% 流量走新算法,观察swipe_like_rate和match_rate两个指标,如果右滑率涨了但匹配率没动,说明只是让人更容易滑到好看的,而不是更容易匹配上,这时候该调的是匹配后的推荐而不是排序权重。

还有一个容易被忽略的点:评分函数里用到了commonTags,这个字段需要提前在候选池查询时算好,不要在排序循环里对每个人再发一次查询——那就是标准的 N+1,二十个候选能给你干出二十次数据库往返。我习惯在 SQL 里用JOIN tags一次聚合出来,或者在 Redis 里用SINTER求交集。

这套源码我前前后后改过三版,最大的教训是:每次动推荐逻辑之前,先把候选池的快照存一份。没有快照,你改完之后效果变差,根本说不清是算法的问题还是当天数据变了。从那以后我每次改排序、改权重、改过滤条件,都强制走一遍“存快照 → 跑对比 → 看指标”的流程,哪怕只是把权重从 0.4 调到 0.45。希望这套东西到手之后,你能少走我走过的弯路,直接在自己的场景里把推荐这套跑起来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询