概述
项目的滑动交互(右滑喜欢、左滑不喜欢)在数据上只落一条记录,靠 isLike 字段区分态度,这里把这条「一条记录两种态度」的链路从接口参数到配对建友拆开讲,先讲思路再落到完整可运行代码。
项目功能整体分两件事:先把推荐用户列表查出来(/mf/cards,一次十条,且要排除已滑过的用户 ,已在 user_like 里留痕的人不该再出现在卡片里),然后才是对卡片做喜欢/不喜欢的滑动。推荐列表的查询细节已在项目推荐列表篇展开,这里聚焦第二件事:滑动本身怎么落数据、怎么判配对。
业务场景与动作定义
项目功能的主界面是一叠推荐用户卡片。用户对每张卡片有两个动作,映射到接口和数据上是这样的:
| 动作 | 语义 | 接口 | isLike |
Redis 写 LIKE Set | Redis 写 NOT_LIKE Set |
|---|---|---|---|---|---|
| 右滑 | 喜欢 | GET /mf/{id}/love |
true |
SADD | SREM |
| 左滑 | 不感兴趣 | GET /mf/{id}/unlove |
false |
SREM | SADD |
这两个动作看着轻巧,背后要做的事情不少:
- 记录必须落库 。喜欢也好、不喜欢也好,都要写进 MongoDB 的
user_like集合。它是后续所有功能的依据,推荐列表要靠它排除已经划过的人,「我的喜欢」统计、双向喜欢判定也都从这里来。 - 同一个人只保留一条记录 。对同一个目标用户,
user_like里只有一条文档。之前是喜欢,现在改成不喜欢,走的不是「再插一条」,而是把那条记录的isLike更新为false;如果从来没划过这个人,库里没有数据,才执行新增。一句话:保存或更新(saveOrUpdate),不是无脑插入。 - Redis 也要同步写。滑动之后马上会发生的下一个请求是「翻下一张卡片」,推荐列表要实时排除这个人。这种高频判存如果每次都查 MongoDB,太重,所以用两个 Redis Set 来扛。
- 双向喜欢要触发加好友。我喜欢对方,一查发现对方之前也喜欢过我,这就是配对成功,系统要自动把两人变成好友,包括交友系统自己的好友关系和环信 IM 的好友关系。
把这几件事抽象出来,喜欢功能的思路就是九个字:先保存,再判断,后加好友。
落 MongoDB 时,两个动作共用同一条记录 。user_like 集合的文档结构如下(实体类 UserLike,@Document(collection = "user_like")):
java
private ObjectId id;
@Indexed
private Long userId; // 滑动发起人,自己
@Indexed
private Long likeUserId; // 被滑动的人,对方
private Boolean isLike; // 态度:右滑 true / 左滑 false
private Long created; // 首次滑动时间
private Long updated; // 最近一次滑动时间
(userId, likeUserId) 这一对字段唯一定位一条记录,isLike 是这条记录上可翻转的态度位。先右滑、后悔了再左滑,不会产生第二条数据,只是把 isLike 从 true 改成 false,并刷新 updated。
为什么不「喜欢一条记录、不喜欢另一条记录」
另一种直觉做法是喜欢、不喜欢各存各的(比如只维护两个 Set、或 Mongo 里分两个集合),滑动翻转时删一条、加一条。对比一下:
| 维度 | 单条记录 + isLike 字段 | 两条记录(分集合/双 Set 直存) |
|---|---|---|
| 态度翻转 | updateFirst 改一个字段,O(1) |
删旧记录 + 插新记录,两步写 |
| 语义清晰度 | 一行读出「我对 TA 的当前态度」,无歧义 | 「两边都没有」是合法中间态还是脏数据?说不清 |
| 幂等性 | saveOrUpdate 天然幂等(见下节) | 重复滑动依赖删除/插入的幂等组合,容易出重复行 |
| 历史追溯 | 只保留最新态度,靠 updated 看翻转时间 |
同样只看最新,反而多一次 join |
核心差异在语义:一个人的态度在同一时刻只有一个,用一个字段表达「单值状态」最自然;两条记录把一个单值状态拆成了两个布尔位,翻转时必须保证「一增一删」同时成立,一致性负担反而更重。这也是后面 Redis 双 Set 设计中要重点防的坑。
存储设计:MongoDB 与 Redis 的分工
滑动记录的权威存储是 user_like。实体类在 mf-model 的 mongo 包下:
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; // 更新时间
}
几个设计点:
userId和likeUserId都加了@Indexed,因为无论保存/更新(按userId + likeUserId定位)还是推荐列表排除(按userId拉出整组划过的人),查询条件都是这两个字段。- 一条文档描述「我对某人的当前态度」,
isLike是唯一的态度字段,喜欢转不喜欢是 update 而不是 insert。 - 时间字段区分
created与updated:新增时两者相同,更新时只有updated变,能据此判断态度是否被改过。
写入路径的另一个关键角色是 Redis 双 Set。mf-commons 的 Constants 中定义了两个 key 前缀:
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 的组织方式是 前缀 + 我的用户ID,value 是对方的用户ID:
| key | 类型 | 成员 | 示例 |
|---|---|---|---|
USER_LIKE_SET_106 |
Set | 106 号用户喜欢的所有人 | 1,2,3,4,5,6 |
USER_NOT_LIKE_SET_106 |
Set | 106 号用户不喜欢的所有人 | 7,8 |
用 Set 而不是 List 是有原因的:判存(「我是否喜欢过这个人」)和去重都是 Set 的天然能力,SISMEMBER 是 O(1)。
态度切换时两边要联动。比如 106 号用户之前不喜欢 7 号,现在改主意喜欢了,Redis 上要做两步:
text
SREM USER_NOT_LIKE_SET_106 7 // 从不喜欢的集合中移除
SADD USER_LIKE_SET_106 7 // 向喜欢的集合中添加
漏掉任何一步,Redis 里就会出现「同一个人既在喜欢集合又在不喜欢集合」的脏状态,推荐列表的排除逻辑会跟着出错。
两者的分工如下:
| 维度 | MongoDB user_like |
Redis Set |
|---|---|---|
| 角色 | 权威存储,可回溯、可统计 | 读写缓存,扛高频判存 |
| 粒度 | 一条文档带 isLike + 时间戳 | 只存「谁被我划过、划成什么」 |
| 用途 | 历史记录、喜欢统计、报表类查询 | 推荐列表排除、双向喜欢判定 |
| 一致性 | 准 | 跟随 Mongo 写入后同步更新 |
为什么不全用 Redis?Set 里只有成员,没有时间戳,也没有 isLike 之外的扩展空间;为什么不全用 Mongo?推荐列表每次翻卡片都要判存,Redis 的内存读比 Mongo 的磁盘查询快得多。两者是「权威 + 缓存」的关系,不是二选一。
接口设计
喜欢与不喜欢是两个独立接口,讲义对应的 YAPI 编号分别是 api/112 和 api/109:
| 接口 | 方法 | 路径 | 路径参数 | 响应 |
|---|---|---|---|---|
| 项目喜欢 | GET | /mf/{id}/love |
id:被喜欢用户的 ID |
200 |
| 项目不喜欢 | GET | /mf/{id}/unlove |
id:被不喜欢用户的 ID |
200 |
响应体没有业务数据,直接返回状态码 200 表示成功,前端滑动后只关心「成没成」,不需要回显内容。例如 1 号用户喜欢 106 号用户,请求就是 GET /mf/106/love。
两个接口的处理逻辑互为镜像:
text
喜欢:
1. 调用 dubbo 服务,saveOrUpdate 到 MongoDB(isLike = true)
2. Redis:SADD 喜欢集合,SREM 不喜欢集合
3. 判断是否双向喜欢,若是则自动成为好友
不喜欢:
1. 调用 dubbo 服务,saveOrUpdate 到 MongoDB(isLike = false)
2. Redis:SADD 不喜欢集合,SREM 喜欢集合
3. 判断双向关系,若是双向喜欢则取消好友关系(项目好友 + 环信好友)
注意第 3 步在不喜欢场景里是取消好友:我喜欢过对方、对方也喜欢过我、两人已经是好友,此时我把对方划成不喜欢,好友关系要拆掉,否则会出现「不是好友却互相喜欢」的矛盾状态。工程实读(见下文)里这个删好友逻辑是留白,没有实现。
接口文档对应 YAPI 上的两条:项目喜欢 api/112、项目不喜欢 api/109。两个接口都是 GET + 路径参数,这在语义上有争议(滑动会改变数据,理应 POST),但教学工程为了省事用了 GET,联调时别配错请求方式。
实现链路拆解
整条链路涉及的角色先交代清楚(下面步骤里的类名都来自工程实读):
tree
mf
├── mf-app-server
│ └── src/main/java/com/mf/server
│ ├── controller/mfController.java # love/unlove 接口入口
│ ├── service/mfService.java # likeUser / notLikeUser / isLike 滑动主链路
│ ├── service/MessagesService.java # contacts:环信 addContact + Friend 落库
│ ├── interceptor/UserHolder.java # ThreadLocal 取当前登录用户
│ └── exception/BusinessException.java
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── com/mf/dubbo/api/UserLikeApi.java # saveOrUpdate 接口定义
│ └── mf-dubbo-mongo
│ ├── com/mf/dubbo/api/UserLikeApiImpl.java # saveOrUpdate 的 Mongo 实现
│ └── com/mf/dubbo/api/FriendApiImpl.java # 好友关系 save(exists 判重)
├── mf-model
│ └── com/mf/model/mongo/UserLike.java # user_like 集合实体
│ └── com/mf/model/mongo/Friend.java # friend 集合实体
└── mf-commons
└── com/mf/commons/utils/Constants.java # USER_LIKE_SET_ / USER_NOT_LIKE_SET_ key 前缀
五步总览:
| 步骤 | 做什么 | 关键点 |
|---|---|---|
| ① 接收滑动参数 | 目标用户 id + 喜欢/不喜欢(两个接口区分) | 登录态从拦截器 ThreadLocal(UserHolder)取 |
| ② MongoDB 落库 | user_like 集合 saveOrUpdate |
存在则只更新 isLike,天然幂等 |
| ③ Redis 双 Set 同步 | SADD 态度 Set + SREM 反向 Set | 两个 Set 必须互斥,翻转时一增一删 |
| ④ 配对检查 | SISMEMBER 查对方的 LIKE Set | 双向喜欢才建好友 |
| ⑤ 返回 | 是否配对成功的标识 | 产品上要立即提示「配对成功,开始聊天吧」 |
① 接收滑动参数
右滑和不喜欢是两个独立接口,路径变量携带目标用户 id,当前登录用户从拦截器写入的 UserHolder 拿:
java
// mfController.java
@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);
}
伪代码层面,两个入口收敛到同一个处理流程,只差一个态度参数:
text
滑动(当前用户 me, 目标用户 target, 态度 attitude):
attitude = 右滑 ? true : false
走统一链路: 落库 → 同步 Set → 配对检查 → 返回
② MongoDB 落库:saveOrUpdate 为什么天然幂等
落库动作交给 Dubbo 服务 UserLikeApi#saveOrUpdate(me, target, isLike),实现里先按 (userId, likeUserId) 查一次:
java
// UserLikeApiImpl.java
Query query = Query.query(Criteria.where("userId").is(userId)
.and("likeUserId").is(likeUserId));
UserLike userLike = mongoTemplate.findOne(query, UserLike.class);
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 {
// 已有记录:只翻转态度位
Update update = Update.update("isLike", isLike)
.set("updated", System.currentTimeMillis());
mongoTemplate.updateFirst(query, update, UserLike.class);
}
幂等推导 :幂等的意思是同一操作执行一次和执行多次,终态相同。这里的终态由 (userId, likeUserId) 唯一锚定,
- 记录不存在时:插入一行
isLike = 态度。第二次再滑,记录已存在,走更新分支,不会出现第二行。 - 记录已存在时:
updateFirst把isLike写成参数值。重复滑同一态度,写的是同一个值,状态纹丝不动,只有updated时间戳前进。 - 态度翻转时:写的是新值,终态就是「最后一次滑动的态度」,与中间滑了多少次无关。
所以无论调用几次,user_like 里这一对的最终状态恒等于「最后一次滑动的态度」,即 f(f(x)) = f(x)。落库失败(异常被 catch 后返回 false)则直接抛 BusinessException 中断链路,保证 Mongo 写成功是后续所有动作的前置条件,这是 Mongo 作为事实源的地位决定的。
并发双插隐患 :注意「天然幂等」依赖单线程假设:findOne 和 save 之间不是原子的,并发下可能双插。updateFirst 而不是 updateMulti,也是基于「组合最多一条」的约定。工程里 userId 和 likeUserId 各自是单字段索引,没有组合唯一索引 ,所以要在并发首次滑动防双插,应加 @CompoundIndex(def = "{'userId':1,'likeUserId':1}", unique = true),插入捕获 DuplicateKeyException 后转 update。
③ Redis 双 Set 同步:互斥性是生命线
每个用户维护两个 Set:USER_LIKE_SET_{userId} 存「我右滑过的人」,USER_NOT_LIKE_SET_{userId} 存「我左滑过的人」。右滑时把目标从左边挪到右边:
java
// mfService.likeUser 中(注意顺序:先 SREM 后 SADD)
redisTemplate.opsForSet().remove(Constants.USER_NOT_LIKE_KEY + UserHolder.getUserId(), likeUserId.toString());
redisTemplate.opsForSet().add(Constants.USER_LIKE_KEY + UserHolder.getUserId(), likeUserId.toString());
Constants 里两个前缀(实读值):
java
public static final String USER_LIKE_KEY = "USER_LIKE_SET_";
public static final String USER_NOT_LIKE_KEY = "USER_NOT_LIKE_SET_";
错误推演:只 SADD 不 SREM 会怎样。 假设右滑时偷懒只写 SADD USER_LIKE_SET_106 107:
- 用户 106 先左滑了 107 →
NOT_LIKE_106 = {107}; - 反悔右滑 107 → 只 SADD →
LIKE_106 = {107},NOT_LIKE_106里 107 还赖着不走; - 同一个人同时出现在「喜欢」和「不喜欢」两个集合里。
后果分两头看。若推荐排除用 Set 实现(课程设计的初衷),排除逻辑对两个 Set 取并集时侥幸不出错,两边都有照样排除;但凡有代码只查 LIKE_106 判断「是否可再次推荐」,这个已滑过的人会重新冒出来。更隐蔽的是态度语义被污染:从此「107 在 NOT_LIKE 里」不再等价于「106 不喜欢 107」,所有基于「成员即态度」的判断(SISMEMBER 判配对就是典型)都建立在互斥前提上,一个脏成员就能造成错误配对。所以翻转态度时,SREM 和 SADD 必须成对出现,缺一半就是在给未来埋雷。
④ 配对检查:SISMEMBER 查的是对方的 Set
右滑的收尾是判断「双向喜欢」:我右滑了 TA,TA 之前是否也右滑过我?
java
// mfService.java
// 调用:isLike(likeUserId, UserHolder.getUserId())
public Boolean isLike(Long userId, Long likeUserId) {
String key = Constants.USER_LIKE_KEY + userId;
return redisTemplate.opsForSet().isMember(key, likeUserId.toString());
}
注意参数方向:isLike(对方id, 我的id),即拿 USER_LIKE_SET_对方 这个 key,SISMEMBER 查成员「我」。查的是对方的 LIKE Set,不是自己的。 命中说明对方先右滑过我,此刻双向成立,走配对建友:
java
// MessagesService.contacts ------ 配对成功后的两步
// 1. 先在环信注册好友关系(IM 侧)
Boolean aBoolean = huanXinTemplate.addContact(
Constants.HX_USER_PREFIX + UserHolder.getUserId(),
Constants.HX_USER_PREFIX + friendId);
if (!aBoolean) {
throw new BusinessException(ErrorResult.error());
}
// 2. 环信成功后才把好友关系落到 MongoDB friend 集合
friendApi.save(UserHolder.getUserId(), friendId);
顺序有讲究:先环信后 Friend 落库。环信失败就抛异常整体中断,不会出现「Mongo 里有好友、环信里聊不了」的单边关系;环信成功而 Mongo 落库失败的概率被压到最小窗口。这也是一条「外部系统优先、本地兜底随后」的通用次序,外部系统没有事务可回滚,先把不可控的那步走掉。
⑤ 返回配对标识
产品体验上,配对成功是一个强提示时刻(「你们互相喜欢,开始聊天吧」),前端需要在滑动动作的响应里立刻知道结果。但实读工程的接口返回 ResponseEntity<Void>,前端拿到的永远是空响应体,配对这件事发生在服务端内部,前端毫不知情。这个落差属于实现层的取舍,下文专门收口讨论。
滑动全链路时序图(思路层)
环信 Redis MongoDB(UserLikeApi) mfService 客户端 环信 Redis MongoDB(UserLikeApi) mfService 客户端 #mermaid-svg-7Yn6MNpBDiDKdQSV{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-7Yn6MNpBDiDKdQSV .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7Yn6MNpBDiDKdQSV .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7Yn6MNpBDiDKdQSV .error-icon{fill:#552222;}#mermaid-svg-7Yn6MNpBDiDKdQSV .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7Yn6MNpBDiDKdQSV .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7Yn6MNpBDiDKdQSV .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7Yn6MNpBDiDKdQSV .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7Yn6MNpBDiDKdQSV .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7Yn6MNpBDiDKdQSV .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7Yn6MNpBDiDKdQSV .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7Yn6MNpBDiDKdQSV .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7Yn6MNpBDiDKdQSV .marker.cross{stroke:#333333;}#mermaid-svg-7Yn6MNpBDiDKdQSV svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7Yn6MNpBDiDKdQSV p{margin:0;}#mermaid-svg-7Yn6MNpBDiDKdQSV .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7Yn6MNpBDiDKdQSV text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-7Yn6MNpBDiDKdQSV .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7Yn6MNpBDiDKdQSV .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-7Yn6MNpBDiDKdQSV .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-7Yn6MNpBDiDKdQSV .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-7Yn6MNpBDiDKdQSV #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-7Yn6MNpBDiDKdQSV .sequenceNumber{fill:white;}#mermaid-svg-7Yn6MNpBDiDKdQSV #sequencenumber{fill:#333;}#mermaid-svg-7Yn6MNpBDiDKdQSV #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-7Yn6MNpBDiDKdQSV .messageText{fill:#333;stroke:none;}#mermaid-svg-7Yn6MNpBDiDKdQSV .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7Yn6MNpBDiDKdQSV .labelText,#mermaid-svg-7Yn6MNpBDiDKdQSV .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-7Yn6MNpBDiDKdQSV .loopText,#mermaid-svg-7Yn6MNpBDiDKdQSV .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-7Yn6MNpBDiDKdQSV .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-7Yn6MNpBDiDKdQSV .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-7Yn6MNpBDiDKdQSV .noteText,#mermaid-svg-7Yn6MNpBDiDKdQSV .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-7Yn6MNpBDiDKdQSV .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7Yn6MNpBDiDKdQSV .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7Yn6MNpBDiDKdQSV .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7Yn6MNpBDiDKdQSV .actorPopupMenu{position:absolute;}#mermaid-svg-7Yn6MNpBDiDKdQSV .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-7Yn6MNpBDiDKdQSV .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7Yn6MNpBDiDKdQSV .actor-man circle,#mermaid-svg-7Yn6MNpBDiDKdQSV line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-7Yn6MNpBDiDKdQSV :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 双向喜欢 GET /mf/{id}/love(右滑) saveOrUpdate(me, target, isLike=true) 落库成功(存在则更新 isLike) SREM USER_NOT_LIKE_SET_me target SADD USER_LIKE_SET_me target SISMEMBER USER_LIKE_SET_target me addContact(me, target) 成功 friendApi.save(me, target) 返回(是否配对成功)
完整代码实现
左右滑动的代码横跨四个工程模块,调用关系是 mf-app-server 通过 Dubbo 调 mf-dubbo-mongo:
| 模块 | 文件 | 职责 |
|---|---|---|
| mf-app-server | mfController |
定义 {id}/love、{id}/unlove 两个 GET 接口 |
| mf-app-server | mfService |
核心业务四步:落 Mongo、操作双 Set、判双向、建好友 |
| mf-app-server | MessagesService |
contacts:先环信加联系人,再落 friend 集合 |
| mf-dubbo-interface | UserLikeApi |
Dubbo 接口,saveOrUpdate(userId, likeUserId, isLike) |
| mf-dubbo-mongo | UserLikeApiImpl |
接口实现,findOne → save / updateFirst |
| mf-model | UserLike |
user_like 集合实体,userId、likeUserId 带 @Indexed |
| mf-commons | Constants |
Set key 前缀 USER_LIKE_SET_ / USER_NOT_LIKE_SET_、环信前缀 hx |
tree
mf
├── mf-app-server
│ └── src/main/java/com/mf/server
│ ├── controller/mfController.java # love/unlove 接口
│ ├── service/mfService.java # likeUser/notLikeUser/isLike
│ ├── service/MessagesService.java # contacts:环信 + Friend 落库
│ ├── interceptor/UserHolder.java # ThreadLocal 取当前登录用户
│ └── exception/BusinessException.java
├── mf-dubbo
│ ├── mf-dubbo-interface
│ │ └── src/main/java/com/mf/dubbo/api/UserLikeApi.java
│ └── mf-dubbo-mongo
│ └── src/main/java/com/mf/dubbo/api
│ ├── UserLikeApiImpl.java # saveOrUpdate 落 user_like
│ └── FriendApiImpl.java # 好友关系 save(exists 判重)
├── mf-model
│ └── src/main/java/com/mf/model/mongo/UserLike.java
└── mf-commons
└── src/main/java/com/mf/commons/utils/Constants.java
第一步:Controller 定义两个滑动接口
实读工程 mfController.java 81-94 行,两个接口返回的都是 ResponseEntity<Void>,这一点先记下,文末会专门讨论它和产品设计之间的落差。
java
package com.mf.server.controller;
import com.mf.model.vo.TodayBest;
import com.mf.server.service.mfService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/mf")
public class mfController {
@Autowired
private mfService mfService;
/**
* 喜欢(右滑)
* 接口文档:/project/19/interface/api/112
*/
@GetMapping("{id}/love")
public ResponseEntity<Void> likeUser(@PathVariable("id") Long likeUserId) {
this.mfService.likeUser(likeUserId);
return ResponseEntity.ok(null);
}
/**
* 不喜欢(左滑)
* 接口文档:/project/19/interface/api/109
*/
@GetMapping("{id}/unlove")
public ResponseEntity<Void> notLikeUser(@PathVariable("id") Long likeUserId) {
this.mfService.notLikeUser(likeUserId);
return ResponseEntity.ok(null);
}
}
两个方法体几乎是复制的:调 Service、返回 ok(null)。当前登录用户 id 不需要前端传,mfService 内部从 UserHolder(基于 ThreadLocal 的登录上下文)里取,路径上的 {id} 只表示「被滑动的那个人」。
第二步:Service 核心四步
mfService.java 213-247 行是这里的主战场。likeUser 的四步各自独立出问题:
java
package com.mf.server.service;
import cn.hutool.core.collection.CollUtil;
import com.mf.commons.utils.Constants;
import com.mf.dubbo.api.UserLikeApi;
import com.mf.model.vo.ErrorResult;
import com.mf.server.exception.BusinessException;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class mfService {
@DubboReference
private UserLikeApi userLikeApi;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private MessagesService messagesService;
//项目喜欢
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);
}
}
//不喜欢
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、判断是否双向喜欢,删除好友(各位自行实现)
}
//判断是否双向喜欢
public Boolean isLike(Long userId, Long likeUserId) {
String key = Constants.USER_LIKE_KEY + userId;
return redisTemplate.opsForSet().isMember(key, likeUserId.toString());
}
}
几个容易看岔的细节:
Constants.USER_LIKE_KEY的值是字符串"USER_LIKE_SET_",USER_NOT_LIKE_KEY的值是"USER_NOT_LIKE_SET_"。所以 Redis 里真正的 key 是USER_LIKE_SET_106这种形式,常量名叫 KEY 但内容是前缀,查数据时别按常量名去猜 key。isLike(likeUserId, UserHolder.getUserId())的参数顺序:key 拼的是第一个参数 (对方)的喜欢集合,判断成员是第二个参数(我自己)。语义是「对方是否喜欢我」,而不是「我是否喜欢对方」。这个方向搞反了,配对判定就永远不成立或永远成立。notLikeUser的第三步讲义里明确写了「删除好友(各位自行实现)」,实读工程里也没有实现,也就是取消好友关系的逻辑(交友系统 + 环信系统两头都要删)是作业,线上一旦做左滑翻转,好友关系并不会自动解除,这是这个工程快照的已知缺口。- 两处 Redis 写操作不在一个事务里。Mongo 写成功、Redis 写失败时,
user_like表和 Set 会出现短暂不一致,靠下一次滑动覆盖修正。
第三步:UserLikeApiImpl.saveOrUpdate 与幂等推导
Dubbo 接口只有一条定义:
java
package com.mf.dubbo.api;
public interface UserLikeApi {
/**
* 保存或者更新
* 左滑右滑(喜欢/不喜欢)
*/
Boolean saveOrUpdate(Long userId, Long likeUserId, boolean isLike);
}
实现是典型的「查一下,没有就插,有就改」:
java
package com.mf.dubbo.api;
import com.mf.model.mongo.UserLike;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import org.springframework.data.mongodb.core.query.Update;
@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、更新:只改 isLike 和 updated,created 保留首次时间
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;
}
}
}
幂等性是这么推导出来的:同一个 (userId, likeUserId) 组合在 user_like 表里最多一条 记录,重复右滑走 updateFirst,只更新 isLike 和 updated,不会插新行;左滑再右滑是翻转,同样落在更新分支。所以这个接口天然可重放,前端重复上报滑动、消息重试,数据终态都是对的。
但注意「天然幂等」依赖单线程假设:findOne 和 save 之间不是原子的,并发下可能双插,坑表里细说。updateFirst 而不是 updateMulti,也是基于「组合最多一条」的约定。
第四步: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; // 更新时间
}
userId 和 likeUserId 各自是单字段索引,查询条件 userId + likeUserId 的组合过滤靠这两个索引做交集。isLike 是布尔,true 喜欢、false 不喜欢,推荐用户列表(queryCardsList)正是查这张表把「喜欢过和不喜欢的」从卡片里 nin 掉。字段 created 在更新分支不会被覆盖,所以它记录的是「第一次对这个人的表态时间」,updated 才是最近一次。
第五步:配对命中后的 contacts
isLike 命中后走 messagesService.contacts,实读代码就三步,但顺序有讲究,先环信、后落库:
java
package com.mf.server.service;
import com.mf.autoconfig.template.HuanXinTemplate;
import com.mf.commons.utils.Constants;
import com.mf.dubbo.api.FriendApi;
import com.mf.model.vo.ErrorResult;
import com.mf.server.exception.BusinessException;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class MessagesService {
@Autowired
private HuanXinTemplate huanXinTemplate;
@DubboReference
private FriendApi friendApi;
//添加好友(配对成功时调用)
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);
}
}
先调环信是因为它可能失败:环信加联系人失败就抛异常,Mongo 里不会留下一条「环信里根本没有的好友关系」。反过来如果先落库后调环信,环信挂了就会出现两边状态脱节,修数据比防着写麻烦得多。环信的 id 都带 hx 前缀(Constants.HX_USER_PREFIX = "hx")。
friendApi.save 里对两个方向各做一次 exists 检查再插入,本意是防止重复建交:
java
package com.mf.dubbo.api;
import com.mf.model.mongo.Friend;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.query.Criteria;
import org.springframework.data.mongodb.core.query.Query;
import java.util.List;
@DubboService
public class FriendApiImpl implements FriendApi {
@Autowired
private MongoTemplate mongoTemplate;
@Override
public void save(Long userId, Long friendId) {
//1、保存自己的好友数据
Query query1 = Query.query(Criteria.where("userId").is(userId).and("frinedId").is(friendId));
//1.1 判断好友关系是否存在
if (!mongoTemplate.exists(query1, Friend.class)) {
//1.2 如果不存在,保存
Friend friend1 = new Friend();
friend1.setUserId(userId);
friend1.setFriendId(friendId);
friend1.setCreated(System.currentTimeMillis());
mongoTemplate.save(friend1);
}
//2、保存好友的数据(逻辑对称,略)
Query query2 = Query.query(Criteria.where("userId").is(friendId).and("frinedId").is(userId));
if (!mongoTemplate.exists(query2, Friend.class)) {
Friend friend1 = new Friend();
friend1.setUserId(friendId);
friend1.setFriendId(userId);
friend1.setCreated(System.currentTimeMillis());
mongoTemplate.save(friend1);
}
}
}
这里藏着一个真实的坑:查询条件写的是 "frinedId"(少了个 d 的顺序),而 Friend 实体字段是 friendId。MongoDB 对不存在的字段查询不报错、只返回空,所以 exists 永远是 false,判重形同虚设 。正常单次配对里两次调用各插一条方向相反的记录没问题,但配合下文的「配对重复触发」场景,重复记录是插得进去的。排查手法:直接 db.friend.find({userId: 106}) 数记录数,对数对不上号就往这查。
时序图:一次右滑的完整链路(代码层)
环信服务器 MessagesService Redis UserLikeApi(Dubbo) mfService mfController 前端(右滑) 环信服务器 MessagesService Redis UserLikeApi(Dubbo) mfService mfController 前端(右滑) #mermaid-svg-6Imx4bGtwgOc5bxn{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-6Imx4bGtwgOc5bxn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6Imx4bGtwgOc5bxn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6Imx4bGtwgOc5bxn .error-icon{fill:#552222;}#mermaid-svg-6Imx4bGtwgOc5bxn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6Imx4bGtwgOc5bxn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6Imx4bGtwgOc5bxn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6Imx4bGtwgOc5bxn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6Imx4bGtwgOc5bxn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6Imx4bGtwgOc5bxn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6Imx4bGtwgOc5bxn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6Imx4bGtwgOc5bxn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6Imx4bGtwgOc5bxn .marker.cross{stroke:#333333;}#mermaid-svg-6Imx4bGtwgOc5bxn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6Imx4bGtwgOc5bxn p{margin:0;}#mermaid-svg-6Imx4bGtwgOc5bxn .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6Imx4bGtwgOc5bxn text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-6Imx4bGtwgOc5bxn .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-6Imx4bGtwgOc5bxn .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-6Imx4bGtwgOc5bxn .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-6Imx4bGtwgOc5bxn .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-6Imx4bGtwgOc5bxn #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-6Imx4bGtwgOc5bxn .sequenceNumber{fill:white;}#mermaid-svg-6Imx4bGtwgOc5bxn #sequencenumber{fill:#333;}#mermaid-svg-6Imx4bGtwgOc5bxn #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-6Imx4bGtwgOc5bxn .messageText{fill:#333;stroke:none;}#mermaid-svg-6Imx4bGtwgOc5bxn .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6Imx4bGtwgOc5bxn .labelText,#mermaid-svg-6Imx4bGtwgOc5bxn .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-6Imx4bGtwgOc5bxn .loopText,#mermaid-svg-6Imx4bGtwgOc5bxn .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-6Imx4bGtwgOc5bxn .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-6Imx4bGtwgOc5bxn .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-6Imx4bGtwgOc5bxn .noteText,#mermaid-svg-6Imx4bGtwgOc5bxn .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-6Imx4bGtwgOc5bxn .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6Imx4bGtwgOc5bxn .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6Imx4bGtwgOc5bxn .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-6Imx4bGtwgOc5bxn .actorPopupMenu{position:absolute;}#mermaid-svg-6Imx4bGtwgOc5bxn .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-6Imx4bGtwgOc5bxn .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-6Imx4bGtwgOc5bxn .actor-man circle,#mermaid-svg-6Imx4bGtwgOc5bxn line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-6Imx4bGtwgOc5bxn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt 对方也喜欢我(双向命中) GET /mf/{id}/love likeUser(likeUserId) saveOrUpdate(我, 对方, true) findOne(user_like) → 无则 save / 有则 updateFirst true SREM USER_NOT_LIKE_SET_{我} {对方} SADD USER_LIKE_SET_{我} {对方} SISMEMBER USER_LIKE_SET_{对方} {我} contacts(对方) addContact(hx我, hx对方) friendApi.save → friend 表插两条方向相反的记录 void 200(ResponseEntity<Void>,无配对标识)
设计落差收口:返回 Void vs 配对标识
前面思路分析留的问题:项目产品的交互设计里,右滑命中双向喜欢时应该立即弹「配对成功」 ;但实读工程的接口返回 ResponseEntity<Void>,前端拿到的永远是空响应体,配对这件事发生在服务端内部,前端毫不知情。这是设计和实现之间实打实的落差,三个补法:
| 方案 | 做法 | 优点 | 缺点 | 适用 |
|---|---|---|---|---|
| ① 前端二次查询 | 右滑返回后,前端再调一个「是否配对」查询接口 | 服务端零改动;查询接口还能复用到聊天页 | 多一次往返;两次请求之间有窗口,极端时序下查早了漏提示 | 已上线的存量前端,不方便改协议 |
| ② 返回 boolean | love 返回 ResponseEntity<Boolean>,配对命中为 true |
改动最小:likeUser 返回值透传一层;一次请求自洽 |
破坏现有接口契约,已发布的 App 老版本解析不了;只能表达「配对了」,带不出对方信息 | 还没发版,或前后端同步升级 |
| ③ 推送通知 | 配对命中时服务端通过环信发系统消息,前端长连接收到弹窗 | 不依赖接口协议;对方不在线也能离线补发;能携带昵称头像 | 依赖环信可用性;要做消息去重;实现链路最长 | 推荐做法,项目后续章节实际走的就是环信消息 |
工程现状(返回 Void)最贴近的推荐路径是 ③ 为主、① 兜底 :contacts 建交成功后顺手用已有的 HuanXinTemplate 发一条系统消息,前端在聊天长连接上收到即弹窗;老版本客户端没接长连接弹窗逻辑,靠下次进 App 时调配对查询接口补一次提示。方案 ② 看似最「正确」,但对一个已按 Void 联调过的接口是破坏性变更,收益配不上兼容成本。
落到代码上,改造点只在 mfService.likeUser 配对分支内加一行发送,接口签名完全不用动:
java
//4、添加好友
messagesService.contacts(likeUserId);
//5、配对成功,通过环信给对方发系统消息(offline 时环信自动存离线)
Map<String, Object> msg = new HashMap<>();
msg.put("type", "pairSuccess");
msg.put("userId", UserHolder.getUserId());
template.sendMsg(Constants.HX_USER_PREFIX + likeUserId, JSON.toJSONString(msg));
两个 Set 的同步顺序:为什么是先 SREM 后 SADD
likeUser 里是先 SREM USER_NOT_LIKE_SET_ 再 SADD USER_LIKE_SET_,notLikeUser 对称地先 SADD USER_NOT_LIKE_SET_ 再 SREM USER_LIKE_SET_。两条路径都是「先移除、后添加」。这个顺序决定了翻转瞬间的中间态:
| 时机 | 先 SREM 后 SADD(工程现状) | 先 SADD 后 SREM |
|---|---|---|
| 翻转中间窗口 | 该用户两个 Set 都不在 | 该用户两个 Set 都在 |
| 读取方按「在 LIKE 即喜欢」判断 | 无影响(SADD 完成后才算喜欢) | 提前被判为喜欢,窗口期误判 |
| 读取方按「不在 NOT_LIKE 即未表态」判断 | 已表态的人短暂回到推荐池,可能多推一次卡片 | 无影响 |
| 读取方按「LIKE 与 NOT_LIKE 都查再合并」判断 | 无影响 | 无影响 |
教学工程里读取方只有两类:配对判定只查 LIKE Set,推荐排除走 Mongo 不走 Redis,所以现状顺序是安全的。统一「先移除后添加」还有一个隐含好处:幂等重放收敛,同一条滑动消息重放两次,Set 终态一致,不会因为顺序不定出现两边 Set 各残留一份的脏状态。如果后续有人写「根据 NOT_LIKE 是否为空做推荐」之类的取反逻辑,这 10 毫秒级的中间窗口才会真的咬人,届时应该改用 Lua 脚本把两条命令原子化,而不是调整顺序。
两个 Set 的一致性:非原子的代价
SREM 和 SADD 是两次独立的 Redis 命令,中间没有事务包裹。进程在两步之间宕机,就会出现撕裂态:
| 宕机位置 | 撕裂态 | 直接后果 |
|---|---|---|
| 右滑:SREM NOT_LIKE 后、SADD LIKE 前 | 两边都没有 | 对方右滑我时 SISMEMBER 查不到 → 该成的配对漏判 |
| 左滑:SADD NOT_LIKE 后、SREM LIKE 前 | 两边都有 | 对方右滑我时 SISMEMBER 仍命中 → 已被拒绝的人被错误配对 |
取舍分析,为什么不为此上 Lua 脚本或分布式锁:
- Set 的角色是标记缓存 :用于 SISMEMBER 的 O(1) 配对判存、以及(设计初衷上的)推荐排除。撕裂窗口是毫秒级宕机概率,且用户重滑一次就会走完整链路把 Set 重新对齐,自愈成本几乎为零。
- 推荐排除这条路径,实读工程里根本没有依赖 Set:
RecommendUserApiImpl#queryCardsList是直接查 Mongo,把user_like里该用户的全部记录(不分喜欢不喜欢)nin掉:
java
// RecommendUserApiImpl.queryCardsList ------ 排除逻辑读的是 Mongo,不是 Set
List<UserLike> likeList = mongoTemplate.find(
Query.query(Criteria.where("userId").is(userId)), UserLike.class);
List<Long> likeUserIdS = CollUtil.getFieldValues(likeList, "likeUserId", Long.class);
Criteria criteria = Criteria.where("toUserId").is(userId).and("userId").nin(likeUserIdS);
这恰好印证了取舍结论:Mongo 单记录是事实源,Set 只是可重建的标记 。哪怕 Set 全部丢失,用 user_like 全量回灌(遍历记录,按 isLike 分别 SADD 到两个 Set)就能恢复,短暂不一致不伤及根本。
- 彻底要消除撕裂态,可以把两步合并为一段 Lua 脚本原子执行;但对这个场景,收益(消除毫秒级窗口的漏判/错判)配不上复杂度,不如把「重滑自愈 + Set 可重建」的补偿能力留好。
一句话收束:标记只是缓存,Mongo 才是底线 。判断哪份数据可以「坏了再说」,看的是它能不能从事实源重建,Redis Set 能,所以容许短暂不一致;user_like 不能,所以它必须先写、写失败就中断。
异常与边界
| 边界场景 | 现状与处理 | 思路层面的正确姿势 |
|---|---|---|
| 滑动自己 | 工程未显式拦截;推荐接口已用 nin 排除自己,正常流到不了这里 |
入口校验 likeUserId != userId,防御性拒绝 |
| 目标用户不存在 | saveOrUpdate 不校验对方存在性,会落一条指向幽灵用户的记录 | 落库前调 UserInfoApi 验存在,验不过直接报错 |
| 重复滑动同一态度 | saveOrUpdate 走更新分支写同值,天然幂等;Set 的 SADD/SREM 对重复成员也幂等 | 无需额外处理,这正是「单记录 + saveOrUpdate」设计的红利 |
| 态度翻转(右→左) | 更新 isLike 字段 + 两 Set 互斥转移 |
必须成对执行 SREM/SADD,防「两边都有」 |
| Redis 不可用(Mongo 已落库、Set 未同步) | 配对检查会漏判;推荐不重复(排除读 Mongo) | Mongo 已是事实源,滑动主流程不中断;补偿:定时任务比对 user_like 与两 Set,差异回灌;或对 SISMEMBER 失败降级查 Mongo |
| 环信 addContact 失败 | 抛异常中断,Friend 不落库 | 保持「外部系统先行」次序,避免单边好友 |
| 左滑已有好友的人 | 当前工程不解除好友(留白) | 产品定义决定:若需撤回,对称做「环信删好友 + Friend 删除」 |
验证要点
按这条顺序手工走一遍,链路是否正确一目了然:
- 用户 106 右滑 107:查 Mongo
db.user_like.find({userId: NumberLong(106)})应出现一条isLike: true的文档;SMEMBERS USER_LIKE_SET_106含107,SMEMBERS USER_NOT_LIKE_SET_106不含。 - 重复右滑 107:
user_like中仍只有一条文档,updated变化,created不变,验证幂等。 - 用户 107 右滑 106:触发配对,查
friend集合应出现好友记录,环信侧两人互为联系人。 - 106 左滑翻转(先右滑过的对象):
LIKE_106中成员移入NOT_LIKE_106,两个 Set 同时核对 ,确认互斥;Mongo 中该文档isLike变false。 - 故意 kill 掉服务模拟「落库后、同步 Set 前」宕机:重启后重滑一次,确认 Set 自愈。
可复现的端到端脚本(用两个 Postman 会话分别持有两人的 token):
bash
# 106 右滑 1
curl "http://localhost:18080/mf/1/love" -H "Authorization: Bearer <106的token>"
# 查 Mongo:一条 isLike=true 的记录
mongosh --quiet --eval 'db.user_like.find({userId: NumberLong(106)})'
# { userId: 106, likeUserId: 1, isLike: true, created: ..., updated: ... }
# 查 Redis 双 Set:LIKE 有 1,NOT_LIKE 无 1
redis-cli SMEMBERS USER_LIKE_SET_106
# 1) "1"
redis-cli SMEMBERS USER_NOT_LIKE_SET_106
# (empty array)
bash
# 对方(1)也右滑 106 → 触发双向命中,自动建好友
curl "http://localhost:18080/mf/106/love" -H "Authorization: Bearer <1的token>"
# 验证配对结果
mongosh --quiet --eval 'db.friend.find({$or: [{userId: NumberLong(106)}, {userId: NumberLong(1)}]})'
# 两条记录:106→1 和 1→106(注意 frinedId 字段名笔误导致判重失效,别重复执行太多次)
# 环信侧:用 REST 查 hx1 的好友列表,应包含 hx106
curl -i "https://a1.easemob.com/{org}/{app}/users/hx1/contacts/users" -H "Authorization: Bearer <环信token>"
bash
# 翻转验证:106 改左滑 1
curl "http://localhost:18080/mf/1/unlove" -H "Authorization: Bearer <106的token>"
# Mongo:同一条记录,isLike 翻为 false,created 不变、updated 刷新
mongosh --quiet --eval 'db.user_like.find({userId: NumberLong(106), likeUserId: NumberLong(1)})'
# Redis:LIKE 出、NOT_LIKE 进
redis-cli SMEMBERS USER_LIKE_SET_106
redis-cli SMEMBERS USER_NOT_LIKE_SET_106
# 1) "1"
翻转后 friend 表和环信联系人不会被动清理(notLikeUser 第三步是留白作业),如果测试要重置配对,得手工 db.friend.remove(...) 并调环信 REST 删联系人。
坑表
| 坑 | 现象 | 原因 | 规避 |
|---|---|---|---|
| SREM/SADD 顺序 | 取反型读取逻辑下翻转瞬间误判 | 两条命令非原子,存在中间窗口 | 保持「先移除后添加」的统一顺序;要求严格时用 Lua 原子化 |
| saveOrUpdate 并发双插 | user_like 同一对 userId 出现两条记录 |
findOne 与 save 非原子;实体只有两个单字段 @Indexed,没有组合唯一索引 |
加 @CompoundIndex(def = "{'userId':1,'likeUserId':1}", unique = true),插入捕获 DuplicateKeyException 后转 update |
| SISMEMBER 的 key 拼错 | 配对永远不成立(或不成立却成立) | USER_LIKE_SET_ 与 USER_NOT_LIKE_SET_ 前缀写混;或 isLike 两个参数顺序传反 |
key 拼装封装成一个私有方法;isLike 语义注释写清「key=第一参数,成员=第二参数」 |
| 配对重复触发 | friend 表重复记录、环信重复加联系人 |
双方几乎同时右滑,两次请求各自 SISMEMBER 都命中 | friendApi.save 的 exists 是兜底,但实读代码查询字段写成 "frinedId",判重实际失效,修正字段名,并给 friend 表加组合唯一索引 |
| Redis 不可用 | Mongo 落库成功但 Set 没写上,抛异常后前端重试 | Redis 写操作无降级 | 短暂不一致可接受(下次滑动覆盖修正);配对判定强依赖 Redis,不可用时熔断返回明确错误而非 500 |
| GET 做写操作 | 代理/网关可能缓存 GET,滑动「丢了」 | {id}/love 用了 GET |
教学工程保持现状,生产改 POST + body |
常见误解澄清
| 误解 | 实情 |
|---|---|
| 左滑、右滑要存两条记录 | 只存一条,isLike 字段区分态度,翻转即改字段 |
isLike=false 的记录是无用数据,可以不存 |
左滑记录同样是推荐排除的依据,不存会导致左滑过的人再次出现在卡片里 |
| 配对检查查的是自己的 LIKE Set | 查对方 的 Set:isLike(对方id, 我的id),即「TA 是否右滑过我」 |
saveOrUpdate 重复调用会产生重复文档 |
按 (userId, likeUserId) 先查后写,重复调用只更新,天然幂等 |
| Redis Set 挂了滑动功能就废了 | Mongo 先行落库,主流程可用;推荐排除读 Mongo 不受影响;Set 可从 user_like 重建 |
| SADD 完就完事了,SREM 可省 | 态度翻转必须两 Set 互斥转移,漏 SREM 会出现「两边都有」的脏成员,污染配对判断 |
isLike(a, b) 判断的是 a 是否喜欢 b |
对,key 拼第一参数的 LIKE Set、查第二参数是否为成员。配对判定传的是 isLike(对方, 我),即「对方喜欢我吗」,别想当然传反 |
| 左滑会解除好友关系 | 实读工程没有实现,讲义标注「自行实现」。当前快照里左滑只改 Mongo 的 isLike 和 Redis 双 Set,好友与环信联系人原样保留 |
@Indexed 加在 userId 和 likeUserId 上就是唯一约束 |
不是。单字段普通索引,既不唯一也不组合,防不了并发双插,甚至防不了重复数据 |
saveOrUpdate 返回 false 说明数据没保存 |
只代表捕获到了异常(当前实现是 printStackTrace 后返回 false),具体原因要看服务端日志 |
| 配对成功双方都能收到提示 | 按现有接口做不到:返回 Void,服务端配对后也没有主动推送,提示逻辑是缺失的(见上文三方案) |
重复右滑会在 user_like 插多条 |
不会,findOne 命中后走 updateFirst;只有并发首次滑动才可能双插 |
API 速览
| 接口 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 喜欢(右滑) | GET | /mf/{id}/love |
落库 + Set 同步 + 配对检查 |
| 不喜欢(左滑) | GET | /mf/{id}/unlove |
落库 + Set 反向同步 |
| 推荐卡片列表 | GET | /mf/cards |
一次十条,排除已滑过的用户 |
| 接口 | 方法 | 参数 | 返回 | 说明 |
|---|---|---|---|---|
/mf/{id}/love |
GET | 路径 id:对方用户 id |
ResponseEntity<Void> |
右滑喜欢;双向命中自动建好友 |
/mf/{id}/unlove |
GET | 路径 id:对方用户 id |
ResponseEntity<Void> |
左滑不喜欢;删好友逻辑留白 |
Dubbo UserLikeApi.saveOrUpdate |
--- | userId、likeUserId、isLike | Boolean | 有则更新 isLike,无则插入;异常返回 false |
官方文档
- Redis Set(SADD/SREM/SISMEMBER)
- Spring Data MongoDB - Indexes(@Indexed/@CompoundIndex)
- 环信即时通讯 REST API - 好友关系
总结
- 左滑右滑在数据模型上是一条
user_like记录 + 一个可翻转的isLike位,单值状态用单字段表达,翻转与幂等都因此变简单。 - 实现链路五步走:收参数 → Mongo saveOrUpdate(先查后写,天然幂等)→ Redis 双 Set 互斥同步 → SISMEMBER 查对方 Set 判配对 → 返回配对标识;配对后先环信 addContact 再 Friend 落库。
- 工程返回
ResponseEntity<Void>与产品「配对立即提示」存在落差,收口结论:环信系统消息推送为主、前端配对查询兜底,避免改动已联调的接口契约。 - 双 Set 的 SREM/SADD 非原子,存在毫秒级撕裂窗口,靠「Mongo 事实源 + Set 可重建 + 重滑自愈」兜底,不值得为此引入分布式事务。实读工程里推荐排除直接查 Mongo 而非 Set,这就是「标记只是缓存、Mongo 是底线」的落地印证。
- 两份存储各有一个硬伤要补:
user_like缺组合唯一索引防并发双插;friend的 exists 判重因frinedId字段笔误失效。都是加一个组合唯一索引能根治的问题。 - 左滑的删好友逻辑是留白作业,翻转数据但不解除关系,测试配对翻转时要记得手工清理脏数据。