Java 项目实战:交友项目 - 交友左滑右滑

概述

项目的滑动交互(右滑喜欢、左滑不喜欢)在数据上只落一条记录,靠 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

这两个动作看着轻巧,背后要做的事情不少:

  1. 记录必须落库 。喜欢也好、不喜欢也好,都要写进 MongoDB 的 user_like 集合。它是后续所有功能的依据,推荐列表要靠它排除已经划过的人,「我的喜欢」统计、双向喜欢判定也都从这里来。
  2. 同一个人只保留一条记录 。对同一个目标用户,user_like 里只有一条文档。之前是喜欢,现在改成不喜欢,走的不是「再插一条」,而是把那条记录的 isLike 更新为 false;如果从来没划过这个人,库里没有数据,才执行新增。一句话:保存或更新(saveOrUpdate),不是无脑插入。
  3. Redis 也要同步写。滑动之后马上会发生的下一个请求是「翻下一张卡片」,推荐列表要实时排除这个人。这种高频判存如果每次都查 MongoDB,太重,所以用两个 Redis Set 来扛。
  4. 双向喜欢要触发加好友。我喜欢对方,一查发现对方之前也喜欢过我,这就是配对成功,系统要自动把两人变成好友,包括交友系统自己的好友关系和环信 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:

  1. 用户 106 先左滑了 107 → NOT_LIKE_106 = {107};
  2. 反悔右滑 107 → 只 SADD → LIKE_106 = {107},NOT_LIKE_106 里 107 还赖着不走;
  3. 同一个人同时出现在「喜欢」和「不喜欢」两个集合里。

后果分两头看。若推荐排除用 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 删除」

验证要点

按这条顺序手工走一遍,链路是否正确一目了然:

  1. 用户 106 右滑 107:查 Mongo db.user_like.find({userId: NumberLong(106)}) 应出现一条 isLike: true 的文档;SMEMBERS USER_LIKE_SET_106 含 107,SMEMBERS USER_NOT_LIKE_SET_106 不含。
  2. 重复右滑 107:user_like 中仍只有一条文档,updated 变化,created 不变,验证幂等。
  3. 用户 107 右滑 106:触发配对,查 friend 集合应出现好友记录,环信侧两人互为联系人。
  4. 106 左滑翻转(先右滑过的对象):LIKE_106 中成员移入 NOT_LIKE_106,两个 Set 同时核对 ,确认互斥;Mongo 中该文档 isLike 变 false。
  5. 故意 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

官方文档

总结

  • 左滑右滑在数据模型上是一条 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 字段笔误失效。都是加一个组合唯一索引能根治的问题。
  • 左滑的删好友逻辑是留白作业,翻转数据但不解除关系,测试配对翻转时要记得手工清理脏数据。
相关推荐
测试盐1 小时前
spark作业日志定位过程
java·hadoop·spark
ly76891 小时前
从 OpenAPI 到多语言 SDK 生成:Java 服务端契约漂移与生成器版本治理
java·开发语言·openapi·java 21·版本兼容·契约漂移·sdk 生成
lqbzlll1 小时前
CVE-2026-91403 排查指南:Java JSSE 信任链漏洞的根因、影响范围和修复步骤
java·开发语言·信任链
CarIise1 小时前
AOP 概念的引入
java·开发语言
重生之我是Java开发战士1 小时前
【Redis】Java & JavaSpring 客户端
java·redis·bootstrap
郭小李2 小时前
mybatis的 Mapper.xml语法
xml·java·mybatis
小蒜学长2 小时前
基于SpringBoot 框架的自媒体新闻系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·mysql数据库·自媒体新闻管理系统
9624562 小时前
补打一条卡,为什么要从前一天重新算:人工补录与实时重算业务复盘
java·mysql·kafka
Java后端的Ai之路2 小时前
01、Java并发编程:学习攻略如何才能学好并发编程?
java·开发语言·学习