概述
交友项目里的「Tinder 式快配」功能:推荐用户以卡片形式随机展现,右滑喜欢、左滑不感兴趣,双向右滑自动结为好友。这里聚焦它的需求拆解与数据设计,滑到的人记在哪、推荐时怎么排除、配对怎么判定。
纲要
- 业务场景:项目功能的产品价值
- 卡片式浏览,比「进主页再操作」的转化路径更短
- 右滑喜欢 / 左滑不感兴趣 / 双向喜欢即配对
- 交互与数据设计
- 两个 Redis Set:
USER_LIKE_SET_{userId}与USER_NOT_LIKE_SET_{userId} - 为什么用 Set:高频写、天然去重、O(1) 判存
- 持久化取舍:Redis + MongoDB 双写
- 推荐列表与排除逻辑
recommend_user集合:项目卡片、今日佳人、推荐好友列表共用一份数据源- 卡片列表排除已滑过的用户,随机取样
- 配对机制:
SISMEMBER检查对方是否也喜欢过我 - 接口设计:
/mf/cards、/mf/{id}/love、/mf/{id}/unlove - 原型线框图、异常与边界、验证要点
业务场景:为什么要有项目
推荐体系(今日佳人、推荐好友列表)解决的是「把对的人摆到你面前」,但用户的下一步动作成本不低,要点进主页、看完资料、再点关注。项目把这一步压缩成一次滑动:卡片上已经把关键信息(头像、昵称、缘分值、单身/学历/年龄标签)铺好了,用户凭第一印象右滑或左滑,整个过程不到一秒。
这背后是交友类产品的经典设计:用最低成本表达意向。滑动是廉价的,用户不会心疼;而大量滑动产生的数据反过来又筛选出了高意向用户。双方都右滑时,「我喜欢你」和「你也喜欢我」同时成立,系统直接帮两人结为好友,不需要任何一方再发起关注请求。
讲义里的需求描述拆开是四条:
- 推荐好友以卡片形式随机展现
- 左滑 = 不喜欢,右滑 = 喜欢
- 双方互相喜欢,自动成为好友(记录到环信 + 自己的数据库)
- 已经喜欢或不喜欢的用户,之后不再出现在卡片列表里
前两条是交互,第三条是配对,第四条是排除,这里的重点在后三条的数据设计上。
交互与数据设计:滑动记录放在哪
为什么选 Redis Set
滑动的数据特征决定了存储选型:
| 特征 | 含义 | 对存储的要求 |
|---|---|---|
| 高频写 | 用户刷卡片时每次滑动都产生一次写入 | 写入要快、成本低 |
| 单用户重复 | 同一个人可能被滑到多次(默认兜底、卡片重现) | 天然去重,重复滑动幂等 |
| 判存查询 | 推荐时要排除已滑过的、配对时要查对方是否喜欢我 | O(1) 成员判断 |
| 个性化扩展 | 「共同喜欢的人」这类需求(你和张三都喜欢李四) | 交集、并集运算 |
逐条对照下来,Redis Set 是最贴合的:
SADD是 O(1) 写入,重复SADD同一成员不会产生冗余数据,重复滑动同一人天然幂等SISMEMBER是 O(1) 判存,推荐排除、配对判定都依赖它SINTER/SUNION直接支持交集并集,「共同喜欢」这种后续需求不用改表
工程里的 key 定义在 mf-commons 的 Constants.java(实读第 46、49 行):
java
package com.mf.commons.utils;
//常量定义
public class Constants {
//用户喜欢Redis的key
public static final String USER_LIKE_KEY="USER_LIKE_SET_";
//用户不喜欢Redis的key
public static final String USER_NOT_LIKE_KEY="USER_NOT_LIKE_SET_";
}
key 的设计规则:
USER_LIKE_SET_{userId}:value 部分存「我喜欢的用户 id」,一个用户一个 SetUSER_NOT_LIKE_SET_{userId}:存「我不感兴趣的用户 id」- 成员是用户 id 的字符串形式(
106),不是 Long,Redis 字符串值统一按 String 存,Java 侧读写都要toString()/ 转换,这是容易踩的点 - 喜欢和不感兴趣分两个 Set 而不是用一个 Set 加标记位,因为两者的查询语义不同:配对只查 LIKE Set,排除要查两个 Set 的并集
滑动的读写流程
右滑(喜欢)的写操作在 mfService.likeUser(实读原文):
java
//项目喜欢 106 - 2
public void likeUser(Long likeUserId) {
//1、调用API,保存喜欢数据(保存到MongoDB中)
Boolean save = userLikeApi.saveOrUpdate(UserHolder.getUserId(),likeUserId,true);
if(!save) {
//失败
throw new BusinessException(ErrorResult.error());
}
//2、操作redis,写入喜欢的数据,删除不喜欢的数据 (喜欢的集合,不喜欢的集合)
redisTemplate.opsForSet().remove(Constants.USER_NOT_LIKE_KEY+UserHolder.getUserId(),likeUserId.toString());
redisTemplate.opsForSet().add(Constants.USER_LIKE_KEY+UserHolder.getUserId(),likeUserId.toString());
//3、判断是否双向喜欢
if(isLike(likeUserId,UserHolder.getUserId())) {
//4、添加好友
messagesService.contacts(likeUserId);
}
}
左滑(不感兴趣)是对称操作:
java
//不喜欢
public void notLikeUser(Long likeUserId) {
//1、调用API,保存喜欢数据(保存到MongoDB中)
Boolean save = userLikeApi.saveOrUpdate(UserHolder.getUserId(),likeUserId,false);
if(!save) {
//失败
throw new BusinessException(ErrorResult.error());
}
//2、操作redis,写入不喜欢的数据,删除喜欢的数据
redisTemplate.opsForSet().add(Constants.USER_NOT_LIKE_KEY+UserHolder.getUserId(),likeUserId.toString());
redisTemplate.opsForSet().remove(Constants.USER_LIKE_KEY+UserHolder.getUserId(),likeUserId.toString());
//3、判断是否双向喜欢,删除好友(各位自行实现)
}
这里有个容易忽略的细节:右滑时先 SREM 不感兴趣 Set、再 SADD 喜欢 Set。因为态度可以反转,之前左滑过的人如果通过兜底推荐又刷到了,这次右滑必须把旧记录翻转过来。两个 Set 之间是互斥的,同一个人只会存在于其中一个里。
持久化取舍:Redis 之外还要不要落库
单看滑动数据,它是典型的「低价值高频写」:一次左滑本身没有太多业务价值,用户也不会回看「我上个月滑过谁」。按这个逻辑只存 Redis 似乎够了,但工程里实际做了双写,每次滑动先调 Dubbo 接口落 MongoDB,再操作 Redis:
MongoDB 侧的 UserLike 实体(mf-model 模块,com.mf.model.mongo.UserLike):
java
package com.mf.model.mongo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "user_like")
public class UserLike implements java.io.Serializable {
private static final long serialVersionUID = 6739966698394686523L;
private ObjectId id;
@Indexed
private Long userId; //用户id,自己
@Indexed
private Long likeUserId; //喜欢的用户id,对方
private Boolean isLike; // 是否喜欢
private Long created; //创建时间
private Long updated; // 更新时间
}
对应的 UserLikeApiImpl.saveOrUpdate(实读):查 userId + likeUserId,不存在就插入,存在就只更新 isLike 和 updated,再次保证了重复滑动的幂等:
java
@DubboService
public class UserLikeApiImpl implements UserLikeApi {
@Autowired
private MongoTemplate mongoTemplate;
@Override
public Boolean saveOrUpdate(Long userId, Long likeUserId, boolean isLike) {
try {
//1、查询数据
Query query = Query.query(Criteria.where("userId").is(userId).and("likeUserId").is(likeUserId));
UserLike userLike = mongoTemplate.findOne(query, UserLike.class);
//2、如果不存在,保存
if(userLike == null) {
userLike = new UserLike();
userLike.setUserId(userId);
userLike.setLikeUserId(likeUserId);
userLike.setCreated(System.currentTimeMillis());
userLike.setUpdated(System.currentTimeMillis());
userLike.setIsLike(isLike);
mongoTemplate.save(userLike);
}else {
//3、更新
Update update = Update.update("isLike", isLike)
.set("updated",System.currentTimeMillis());
mongoTemplate.updateFirst(query,update,UserLike.class);
}
return true;
} catch (Exception e) {
e.printStackTrace();
return false;
}
}
}
为什么数据价值低还要落库?两个原因:
- Redis 挂了滑动记录不能全丢。喜欢记录是配对判定的依据,A 右滑 B 这件事如果因为 Redis 重启消失,B 后来右滑 A 时配对就漏了。这类「错过」对交友产品是实打实的损失,所以 MongoDB 兜底是必要的。
- 复杂查询要靠 MongoDB。Redis Set 擅长判存和集合运算,但「统计某用户一段时间的喜欢行为」「分析右滑转化率」这类需求,还是得查持久化数据。
注意一个不对称:写是双写,读却各有分工。配对判定走 Redis(快),推荐排除走 MongoDB(见下一节)。Redis 里的数据可以视为 MongoDB 的读优化缓存。
推荐列表:数据源与排除逻辑
与今日佳人、推荐好友列表的关系
实读代码确认,项目卡片列表和今日佳人、推荐好友列表共用同一份数据源 ,MongoDB 的 recommend_user 集合(推荐系统算好的缘分值数据),只是查询方式不同:
| 入口 | 方法 | 查询方式 |
|---|---|---|
今日佳人 /mf/todayBest |
queryWithMaxScore |
按 score 倒序取第一条 |
推荐好友列表 /mf/recommendation |
queryRecommendUserList |
按 score 倒序分页 |
项目卡片 /mf/cards |
queryCardsList |
排除已滑过 + 随机取样 |
三者返回的都是 TodayBest VO,前端卡片能直接复用缘分值字段。今日佳人是「缘分值最高的那一个」,项目卡片则是「从剩下的里面随机来一批」,产品语义上,最佳推荐单独放大展示,其余的进快配池。
排除已滑过 + 随机取样
RecommendUserApiImpl.queryCardsList 的实现(实读):
java
/**
* 1、排除喜欢,不喜欢的用户
* 2、随机展示
* 3、指定数量
*/
public List<RecommendUser> queryCardsList(Long userId, int counts) {
//1、查询喜欢不喜欢的用户ID
List<UserLike> likeList = mongoTemplate.find(Query.query(Criteria.where("userId").is(userId)), UserLike.class);
List<Long> likeUserIdS = CollUtil.getFieldValues(likeList, "likeUserId", Long.class);
//2、构造查询推荐用户的条件
Criteria criteria = Criteria.where("toUserId").is(userId).and("userId").nin(likeUserIdS);
//3、使用统计函数,随机获取推荐的用户列表
TypedAggregation<RecommendUser> newAggregation = TypedAggregation.newAggregation(RecommendUser.class,
Aggregation.match(criteria),//指定查询条件
Aggregation.sample(counts)
);
AggregationResults<RecommendUser> results = mongoTemplate.aggregate(newAggregation, RecommendUser.class);
//4、构造返回
return results.getMappedResults();
}
三步:先从 MongoDB 的 user_like 集合把该用户所有滑过的人查出来,再用 $nin 从 recommend_user 里排除,最后用 $sample 随机取 counts 条。注意排除依据是 MongoDB 而不是 Redis,虽然 Redis Set 里同样有这份数据。
用 Redis 做排除在思路上也成立,可以作为优化方向:
- 逐个判存 :推荐结果取回来后,对每个候选 id 执行
SISMEMBER USER_LIKE_SET_{userId} id和SISMEMBER USER_NOT_LIKE_SET_{userId} id,过滤掉命中的。候选只有 10 个左右时开销可以忽略。 - SDIFF 一步排除 :如果推荐 id 全集能先取出来,
SDIFF candidate_set USER_LIKE_SET_{userId}再和 NOT_LIKE Set 差一次,两次集合运算直接得到排除后的结果。数据量大时比逐个判存更划算。
教学工程选择 Mongo 排除,好处是实现简单、排除逻辑与持久化数据强一致;代价是每次请求都要把该用户的全部滑动记录捞一遍,滑动次数多了以后这个 find 会变重。生产实现更可能把排除条件下推到 Redis,或者把 nin 换成预先维护的排除集合。
兜底数据
推荐数据不存在时(新用户或推荐系统没跑),返回配置文件里的默认推荐用户:
yaml
#默认推荐列表
mf:
default:
recommend:
users: 2,3,8,10,18,20,24,29,27,32,36,37,56,64,75,88
mfService.queryCardsList 里把这份配置拆成 id 列表,随机给 60~90 之间的缘分值,保证页面永远有卡片可滑:
java
//项目-推荐用户列表
public List<TodayBest> queryCardsList() {
//1、调用推荐API查询数据列表(排除喜欢/不喜欢的用户,数量限制)
List<RecommendUser> users = recommendUserApi.queryCardsList(UserHolder.getUserId(),10);
//2、判断数据是否存在,如果不存在,构造默认数据 1,2,3
if(CollUtil.isEmpty(users)) {
users = new ArrayList<>();
String[] userIdS = recommendUser.split(",");
for (String userId : userIdS) {
RecommendUser recommendUser = new RecommendUser();
recommendUser.setUserId(Convert.toLong(userId));
recommendUser.setToUserId(UserHolder.getUserId());
recommendUser.setScore(RandomUtil.randomDouble(60, 90));
users.add(recommendUser);
}
}
//3、构造VO
List<Long> ids = CollUtil.getFieldValues(users, "userId", Long.class);
Map<Long, UserInfo> infoMap = userInfoApi.findByIds(ids, null);
List<TodayBest> vos = new ArrayList<>();
for (RecommendUser user : users) {
UserInfo userInfo = infoMap.get(user.getUserId());
if(userInfo != null) {
TodayBest vo = TodayBest.init(userInfo, user);
vos.add(vo);
}
}
return vos;
}
配对机制:双向喜欢自动成为好友
配对判定的核心一行在 mfService.isLike,检查对方的 LIKE Set 里有没有我:
java
//判断是否双向喜欢
public Boolean isLike(Long userId, Long likeUserId) {
String key = Constants.USER_LIKE_KEY + userId;
return redisTemplate.opsForSet().isMember(key, likeUserId.toString());
}
调用时参数是翻转的:isLike(likeUserId, UserHolder.getUserId()),key 用的是对方 的 USER_LIKE_SET_,成员查的是我的 id。语义就是「A 右滑 B 时,看 B 是不是早就右滑过 A」。
命中后走 messagesService.contacts(实读),做两件事:先把好友关系注册到环信(IM 通道),再把好友关系落到 MongoDB 的 Friend 集合:
java
public void contacts(Long friendId) {
//1、将好友关系注册到环信
Boolean aBoolean = huanXinTemplate.addContact(Constants.HX_USER_PREFIX + UserHolder.getUserId(),
Constants.HX_USER_PREFIX + friendId);
if(!aBoolean) {
throw new BusinessException(ErrorResult.error());
}
//2、如果注册成功,记录好友关系到mongodb
friendApi.save(UserHolder.getUserId(),friendId);
}
完整时序:
用户B(早已右滑过A) MongoDB(dubbo) Redis mf-app-server 用户A(右滑) 用户B(早已右滑过A) MongoDB(dubbo) Redis mf-app-server 用户A(右滑) #mermaid-svg-1bxaUgAsl97FVE4u{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1bxaUgAsl97FVE4u .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1bxaUgAsl97FVE4u .error-icon{fill:#552222;}#mermaid-svg-1bxaUgAsl97FVE4u .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1bxaUgAsl97FVE4u .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1bxaUgAsl97FVE4u .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1bxaUgAsl97FVE4u .marker.cross{stroke:#333333;}#mermaid-svg-1bxaUgAsl97FVE4u svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1bxaUgAsl97FVE4u p{margin:0;}#mermaid-svg-1bxaUgAsl97FVE4u .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1bxaUgAsl97FVE4u text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1bxaUgAsl97FVE4u .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-1bxaUgAsl97FVE4u .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-1bxaUgAsl97FVE4u .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-1bxaUgAsl97FVE4u #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-1bxaUgAsl97FVE4u .sequenceNumber{fill:white;}#mermaid-svg-1bxaUgAsl97FVE4u #sequencenumber{fill:#333;}#mermaid-svg-1bxaUgAsl97FVE4u #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-1bxaUgAsl97FVE4u .messageText{fill:#333;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1bxaUgAsl97FVE4u .labelText,#mermaid-svg-1bxaUgAsl97FVE4u .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .loopText,#mermaid-svg-1bxaUgAsl97FVE4u .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1bxaUgAsl97FVE4u .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-1bxaUgAsl97FVE4u .noteText,#mermaid-svg-1bxaUgAsl97FVE4u .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-1bxaUgAsl97FVE4u .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1bxaUgAsl97FVE4u .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1bxaUgAsl97FVE4u .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1bxaUgAsl97FVE4u .actorPopupMenu{position:absolute;}#mermaid-svg-1bxaUgAsl97FVE4u .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-1bxaUgAsl97FVE4u .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1bxaUgAsl97FVE4u .actor-man circle,#mermaid-svg-1bxaUgAsl97FVE4u line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-1bxaUgAsl97FVE4u :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 环信注册好友 + Friend 落库 alt B 也喜欢 A B 未滑过 A GET /mf/{B}/love saveOrUpdate(A, B, isLike=true) true SREM USER_NOT_LIKE_SET_A B SADD USER_LIKE_SET_A B SISMEMBER USER_LIKE_SET_B A true contacts(B) 配对成功, 双方收到通知 false 右滑成功, 静默等待
配对的时机在「后滑的一方」这里:A 晚于 B 右滑,配对由 A 这次请求触发,B 是被动收到好友关系的一方。这意味着 B 的请求早已结束,通知 B 只能靠 IM 通道或推送,不能靠 B 的 HTTP 响应。
接口设计
| 接口 | 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|---|
| 项目推荐列表 | GET | /mf/cards |
无 | 随机取 10 张卡片,排除已滑过 |
| 喜欢(右滑) | GET | /mf/{id}/love |
路径参数 likeUserId | 双向喜欢时自动结为好友 |
| 不喜欢(左滑) | GET | /mf/{id}/unlove |
路径参数 likeUserId | 记录不感兴趣,排除出列表 |
Controller 层(mfController 实读):
java
/**
* 项目-推荐用户列表
*/
@GetMapping("/cards")
public ResponseEntity queryCardsList() {
List<TodayBest> list = this.mfService.queryCardsList();
return ResponseEntity.ok(list);
}
/**
* 喜欢
*/
@GetMapping("{id}/love")
public ResponseEntity<Void> likeUser(@PathVariable("id") Long likeUserId) {
this.mfService.likeUser(likeUserId);
return ResponseEntity.ok(null);
}
/**
* 不喜欢
*/
@GetMapping("{id}/unlove")
public ResponseEntity<Void> notLikeUser(@PathVariable("id") Long likeUserId) {
this.mfService.notLikeUser(likeUserId);
return ResponseEntity.ok(null);
}
卡片列表返回的 JSON 结构(TodayBest 序列化后):
json
[
{
"id": "2",
"nickname": "小美",
"avatar": "https://mf.cn/avatar/2.png",
"age": "25",
"gender": "woman",
"tags": "单身,本科,年龄相仿",
"fateValue": 89
}
]
滑动接口无响应体,成败由 HTTP 状态码表达;配对成功与否前端不在这次响应里拿,而是通过消息通道获得。
原型:项目卡片页
交友首页入口与项目卡片页(文案取自原型原文):
doc
+--------------------------------------+
| 交友 |
| |
| +--------------------------------+ |
| | [头像] 缘分值89 | |
| | | |
| | 小美 | |
| | 单身 本科 年龄相仿 | |
| | | |
| | << 左滑: 不喜欢 | |
| | 右滑: 喜欢 >> | |
| | | |
| | [聊一下] [喜欢] | |
| +--------------------------------+ |
| |
| [今日佳人] [推荐] |
| |
+--------------------------------------+
| 交友 | 消息 | 我的 |
+--------------------------------------+
要点:
- 卡片是核心控件,一张一屏,展示头像、昵称(小美)、缘分值 89、标签组合「单身 本科 年龄相仿」
- 左滑/右滑是手势交互,右滑 = 喜欢,左滑 = 不喜欢;卡片下方保留「聊一下」「喜欢」两个按钮,等价于手势的显式操作,降低交互门槛
- 缘分值来自
recommend_user.score,即今日佳人同款字段 - 底部 Tab(交友/消息/我的)表明项目是交友首页的一个入口,与今日佳人、推荐同层
异常与边界
| 场景 | 处理方式 | 说明 |
|---|---|---|
| 滑动自己 | 不允许 | 卡片数据源是推荐系统给「别人」,正常流程滑不到自己;接口层可加 likeUserId == userId 校验兜底 |
| 重复滑动同一人 | 幂等 | Redis SADD 重复成员无副作用;MongoDB 侧 saveOrUpdate 走更新分支,不会产生重复文档 |
| 态度翻转 | 先删后加 | 右滑先 SREM NOT_LIKE 再 SADD LIKE,两个 Set 互斥,保证一人只在一个 Set |
| 已配对后再次滑动 | 重复触发 contacts | 现实现会重复调环信加好友,生产上应先查 Friend 关系存在则跳过 |
| 已配对后又左滑 | 讲义要求解除好友 | 工程注释「各位自行实现」,需同时删环信好友和 MongoDB Friend 记录 |
| MongoDB 写入失败 | 抛 BusinessException | saveOrUpdate 返回 false 直接中断,Redis 不写,保证双写不至于半截 |
| Redis 不可用 | 滑动失败 | MongoDB 已写成功但 Redis 操作抛异常,出现「落库成功、缓存缺失」,需要补偿任务对账 |
| 默认推荐重复出现 | 靠 Set 幂等 | 兜底用户是固定几个,反复滑到不产生脏数据,但已滑过的人仍可能经兜底通道重现 |
验证要点
功能验证按数据流走一遍:
- 用户 106 调
GET /mf/2/love,用redis-cli查SMEMBERS USER_LIKE_SET_106,应包含2;USER_NOT_LIKE_SET_106不含2 - MongoDB
user_like集合应出现{userId: 106, likeUserId: 2, isLike: true}的文档 - 模拟用户 2 先前已右滑 106(
USER_LIKE_SET_2含106),第 1 步执行后应触发 contacts,环信后台出现好友关系,MongoDBfriend集合新增文档 - 反向场景:
USER_LIKE_SET_2不含106时右滑,不产生好友关系 - 滑动后调
GET /mf/cards,连续请求多次,返回的卡片列表中不再出现已滑过的用户 id - 态度翻转:先
unlove再love同一人,确认两个 Set 状态正确翻转,MongoDB 中同一条文档的isLike变为 true 而不是新增一条
官方文档
常见误解澄清
| 误解 | 实际情况 |
|---|---|
| 推荐排除是查 Redis Set 过滤的 | 实读代码:排除依据是 MongoDB user_like 集合 + $nin,Redis 只服务配对判定 |
| 滑动记录只存 Redis,重启就丢 | 工程是 Redis + MongoDB 双写,MongoDB 持久化兜底,Redis 可视为读缓存 |
| 配对判定要查 MongoDB | 不用,SISMEMBER USER_LIKE_SET_{对方} 我 一次 Redis 判存完成,O(1) |
| 左滑右滑应该用 POST | 工程用的 GET({id}/love),语义上不标准(有副作用用 GET),但读接口文档时别看错 |
| 两个 Set 可以合并成一个加标记 | 合并后配对判定、「共同喜欢」交集运算都要逐成员解析标记,Set 的集合运算优势全丢了 |
| 默认推荐用户的缘分值是真实分 | 是 RandomUtil.randomDouble(60, 90) 随机造的,只为保证页面有数据 |
总结
- 项目用一次滑动替代「进主页再关注」,右滑喜欢、左滑不感兴趣,双向右滑自动配对,产品价值在于把表达意向的成本压到最低
- 滑动记录用两个 Redis Set(
USER_LIKE_SET_/USER_NOT_LIKE_SET_):Set 的去重让重复滑动幂等,SISMEMBER的 O(1) 判存支撑配对判定,SINTER留住了「共同喜欢」这类扩展空间 - 持久化是双写:MongoDB
user_like兜底保数据不丢,Redis 承担高速判存;但推荐排除走的是 MongoDB$nin+$sample,两边的读分工不同 - 配对由「后滑的一方」触发,好友关系要同时写环信和 MongoDB,通知被动一方只能走 IM/推送
- 遗留点:左滑解除好友、配对后重复滑动的防护,工程里留作练习,生产实现需要补齐
下一篇项目左滑右滑:思路分析会展开滑动接口的实现思路与代码步骤。