Java 项目实战: 交友项目 - 圈子互动

概述

动态能发、能刷之后,产品还差一口气,用户看到一条动态,除了「看」,还得能「反应」。点赞、评论、喜欢这三个动作就是圈子模块的互动层,这一章把它们的全景摊开:有哪些互动动作、数据落在哪、计数怎么维护、技术选型怎么从业务特点里推导出来、本章要一口气做完哪些接口。

业务背景:互动是社交产品的活性来源

圈子的内容链路上一章已经打通:发动态、刷好友动态、刷推荐动态、查单条动态。但只有「发布」没有「反应」的动态流是死的,发布解决的是内容供给,互动解决的是反馈循环。一条动态发出去没人点赞、没人评论,发布者就失去继续发的动力;浏览者刷到的全是零互动的内容,也提不起参与欲。社交产品的活性,本质上是靠互动数据转起来的。

互动还有一个容易被忽略的作用:它是推荐系统的原料。动态表里专门留了一个 pid 字段(Long 类型自增),就是给推荐系统的模型用的;用户对哪条动态点了赞、评论了什么、喜欢了谁,这些行为数据回流之后,「推荐」频道才能推得更准。后面推荐系统章节会展开这条链路,这里只需记住:你写下的每一次互动记录,都是推荐模型的训练输入。

这套能力并不特殊于交友软件。社交平台、论坛、新闻客户端、视频网站,全都有点赞评论喜欢这一套,只是叫法不同,某些应用还打包成「一键三连」。交友项目这一章做完,后面碰到同类需求可以直接套同一套方案。

互动业务的三个特点

在动手之前先看清这类业务的底细,它决定了后面所有的技术选型。圈子互动(点赞、评论、喜欢)的技术选型不是拍脑袋定的:三个业务特点,数据量极大、查询极其频繁、数据价值低,直接推导出了「MongoDB 存明细 + Redis 存标记 + 动态表冗余计数」这套方案。

特点 原因 技术影响
数据量极大 一条动态可以有几十人点赞、上百条评论;系统内是千万甚至亿级动态,互动数据呈几何倍数增长 存储选 MongoDB 而不是 MySQL,互动表天然适合文档模型
查询极其频繁 每条动态都要展示点赞数、评论数、喜欢数,还要判断「你是否点过赞、是否喜欢过」 计数不能每次实时聚合,点赞状态用 Redis 缓存判断
数据价值低 爆款动态几万条评论,丢一两条对业务系统没有致命影响,只有当事人有感 极端情况下允许一定数据误差,为后续简化一致性方案留了口子

第三个特点常被误解。「数据价值低」不等于可以乱写,而是说这一层不需要像交易流水那样强一致,评论丢一条,系统不会崩,这个容忍度直接影响了计数方案的取舍。还有一句要嘱咐:圈子互动功能看起来不起眼,实现起来并不简单,别掉以轻心。

特点一:数据量极大 → MongoDB 文档存储

先量化一下。一条动态发出去,几十个人点赞、上百人评论是常态;系统里躺着的是千万甚至亿级的动态,互动数据跟着呈几何倍数增长。一条爆款动态的评论数上万、点赞几十万,在项目这类社交产品里不是极端情况。

这个量级下 MySQL 会先在哪出问题?不是存储,是行数和索引。互动记录是一行一行插入的:comment 表最终会有几十亿行,任何针对 publishId 的二级索引都跟着膨胀,B+ 树层级变深,写入和查询的代价同步上涨。而互动数据的访问模式又天然是「按动态聚合读」,取某条动态下的所有点赞、所有评论,这是文档模型的主场,用关系表表达反而要靠 join 或多次回表。

所以选 MongoDB:文档模型天然匹配「一条动态一批互动」的聚合结构,且 MongoDB 对海量数据的水平扩展能力是它相对 MySQL 的核心优势。工程里互动明细落在 comment 集合(@Document(collection = "comment")),点赞、评论、喜欢共用一张表,靠 commentType 字段区分:1-点赞,2-评论,3-喜欢。不拆三张表,因为三种数据的结构几乎一致,拆表只增加维护成本。

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.mapping.Document;

/**
 * 圈子互动表(点赞,评论,喜欢)
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "comment")
public class Comment implements java.io.Serializable {

    private ObjectId id;
    private ObjectId publishId;    //动态id
    private Integer commentType;   //评论类型,1-点赞,2-评论,3-喜欢
    private String content;        //评论内容(仅 commentType=2 时有效)
    private Long userId;           //操作人
    private Long publishUserId;    //被评论人ID(即动态发布人)
    private Long created;          //创建时间
    private Integer likeCount = 0; //当前评论的点赞数

}

一条点赞记录落到这个集合就是一条文档:publishId 指向被互动的动态,commentType=1,content 为空。取消点赞就是删掉这条文档。写路径简单直接。

特点二:查询极其频繁 → Redis Hash 缓存互动标记

这里有个容易想歪的点:互动数据是「读多写少」吗?不是,是读写都频繁 。写侧,点赞高峰期每秒上万次点赞/取消;读侧更狠,用户每刷一屏动态,列表里每一条都要回答四个问题:点赞数多少、评论数多少、喜欢数多少、我点过赞没有、我喜欢过没有。

只靠 MongoDB 能做吗?能,但不划算。翻一页 10 条动态,就要对 MongoDB 发起 10 次甚至 20 次「这条动态里 userId=106 有没有点赞记录」的查询。单次查询不算慢,但每次刷列表都积少成多,MongoDB 的连接和索引扫描资源被这种高频小查询占满,真正重要的评论明细查询反而被拖慢。

所以引入 Redis,专门承接「我是否互动过」这类状态判断。工程里的实现在 CommentsService,点赞成功后往 Redis 写一个标记:

java 复制代码
//动态点赞(CommentsService.likeComment,节选)
//3、调用API保存数据到Mongodb
Comment comment = new Comment();
comment.setPublishId(new ObjectId(movementId));
comment.setCommentType(CommentType.LIKE.getType());
comment.setUserId(UserHolder.getUserId());
comment.setCreated(System.currentTimeMillis());
Integer count = commentApi.save(comment);
//4、拼接redis的key,将用户的点赞状态存入redis
String key = Constants.MOVEMENTS_INTERACT_KEY + movementId;
String hashKey = Constants.MOVEMENT_LIKE_HASHKEY + UserHolder.getUserId();
redisTemplate.opsForHash().put(key, hashKey, "1");
return count;

对应 Constants 里的真实常量定义:

java 复制代码
//圈子互动KEY
public static final String MOVEMENTS_INTERACT_KEY = "MOVEMENTS_INTERACT_";
public static final String MOVEMENT_LIKE_HASHKEY = "MOVEMENT_LIKE_";
public static final String MOVEMENT_LOVE_HASHKEY = "MOVEMENT_LOVE_";

落到 Redis 里的结构是:

tree 复制代码
MOVEMENTS_INTERACT_5fae53947e52992e78a3afa5   (Hash)
├── MOVEMENT_LIKE_106   ->  "1"     # 用户106点过赞
├── MOVEMENT_LIKE_108   ->  "1"     # 用户108点过赞
└── MOVEMENT_LOVE_106   ->  "1"     # 用户106喜欢过

读侧在 MovementService.getPageResult 里,刷圈子列表时对每条动态调一次 hasKey:

java 复制代码
//MovementService.getPageResult(节选)
for (Movement movement : list) {
    UserInfo userInfo = map.get(movement.getUserId());
    if (userInfo != null) {
        MovementsVo vo = MovementsVo.init(userInfo, movement);
        //判断当前用户是否互动过,hashKey存在即标记
        String key = Constants.MOVEMENTS_INTERACT_KEY + movement.getId().toHexString();
        String hashKey = Constants.MOVEMENT_LIKE_HASHKEY + UserHolder.getUserId();
        if (redisTemplate.opsForHash().hasKey(key, hashKey)) {
            vo.setHasLiked(1);
        }
        vos.add(vo);
    }
}

hasKey 是一次 O(1) 的哈希字段查找,不碰 MongoDB。这就是「查询极其频繁」特点的直接回应:高频状态判断全部走内存,MongoDB 只服务低频的明细分页。

顺带说一个实读发现的细节:读侧判断 hasLiked 原本误用 了 MOVEMENT_LOVE_HASHKEY(喜欢标记)而非 MOVEMENT_LIKE_HASHKEY,导致「只喜欢没点赞」的动态也被标成已点赞------点赞状态被喜欢状态污染。这正是课程工程里一个真实 bug(同章 04 已专门分析),本篇代码段已修正为 MOVEMENT_LIKE_HASHKEY。点赞与喜欢是两条独立的 Hash 字段,靠 MOVEMENT_LIKE_/MOVEMENT_LOVE_ 前缀区分:一次点赞写入 LIKE 字段、一次喜欢写入 LOVE 字段,两者互不干扰,但读侧必须各自用对前缀,否则会串味。

特点三:数据价值低 → 允许最终一致、允许丢弃

第三个特点最反直觉:数据价值低。拿订单对比一下,电商系统丢一条订单是生产事故,要查审计日志、要赔付;社交系统丢一个赞,只是计数少了 1,点赞的人自己可能都察觉不到。课程里给的理由很直白:爆款动态几十万条评论,丢一两条对业务系统没有致命影响,极端情况下允许一定的数据误差。

「价值低」在架构上翻译过来就是两个许可:

  • 许可最终一致:Redis 标记和 MongoDB 明细是两次独立写操作,中间没有分布式事务包裹。点赞时先写 Mongo 再写 Redis,两步之间服务宕机,Redis 里就少一个标记,接受,不需要为此引入事务补偿。
  • 许可丢弃 :理论上的激进方案是点赞标记只存 Redis 不落库,进程重启标记消失,计数靠日志重建。项目工程取的是中间态:标记在 Redis(服务高频读)、明细在 Mongo(保住可审计的底线),两侧都存,但不强求实时一致。

注意一个佐证:likeComment 判断「是否重复点赞」时,调的是 commentApi.hasComment(...) 查 MongoDB,而不是只看 Redis 标记。也就是说 Redis 里的标记定位就是缓存,丢了能从 Mongo 重建,不承担唯一数据源的职责。

三特点 → 方案的推导链

把上面三节收拢成一条链,技术选型就不再是几个孤立的组件名:
渲染错误: Mermaid 渲染失败: Parse error on line 5: ...Hash 存互动标记, hasKey O(1) 判断] C数据价值低 -----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'

冗余字段这一步值得单独说:光有 MongoDB 和 Redis 还不够,查询动态列表时若每次都去统计「这条动态的点赞总数」,又是一次额外查询。所以动态表 Movement 里直接冗余了 likeCount、loveCount、commentCount 三个计数列,互动发生时顺带维护。代价是写路径变复杂,点赞一次要写 comment 集合、更新 Movement 计数、写 Redis 标记,三处联动,代码难度上升。这是「查询极其频繁」用写入复杂度换读取效率的典型取舍,三个特点各贡献了一层方案,缺一不可。

互动动作全景

先把本章涉及的全部互动动作列出来,每个动作都回答三个问题:谁触发、作用在什么对象上、产生什么数据。

动作 触发者 作用对象 数据记录 计数影响
点赞 浏览用户 动态 comment 集合插入一条记录,commentType=1 movement 的 likeCount +1,写入 Redis 点赞标记
取消点赞 已点赞用户 动态 删除 comment 集合中对应记录 likeCount -1,删除 Redis 标记
评论 浏览用户 动态 comment 集合插入记录,commentType=2,带 content movement 的 commentCount +1
喜欢 浏览用户 动态 comment 集合插入记录,commentType=3 movement 的 loveCount +1,写入 Redis 喜欢标记
取消喜欢 已喜欢用户 动态 删除 comment 集合中对应记录 loveCount -1,删除 Redis 标记
评论点赞 浏览用户 评论 作用于评论本身(Comment.likeCount 字段已预留) 本章未展开实现
查询评论列表 浏览用户 动态 反向查询:按 publishId + commentType=2 分页查 comment 不影响计数

注意「喜欢」的对象边界:交友项目里喜欢和点赞一样,作用对象是动态 (publishId 指向 movement),不是「喜欢某个人」,对人感兴趣是「交友」模块(心动/匹配)的职责,别混到一起。

整个互动链路的数据流向如下:
#mermaid-svg-F4TYqXPCsviuZWMC{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-F4TYqXPCsviuZWMC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-F4TYqXPCsviuZWMC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-F4TYqXPCsviuZWMC .error-icon{fill:#552222;}#mermaid-svg-F4TYqXPCsviuZWMC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-F4TYqXPCsviuZWMC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-F4TYqXPCsviuZWMC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-F4TYqXPCsviuZWMC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-F4TYqXPCsviuZWMC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-F4TYqXPCsviuZWMC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-F4TYqXPCsviuZWMC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-F4TYqXPCsviuZWMC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-F4TYqXPCsviuZWMC .marker.cross{stroke:#333333;}#mermaid-svg-F4TYqXPCsviuZWMC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-F4TYqXPCsviuZWMC p{margin:0;}#mermaid-svg-F4TYqXPCsviuZWMC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-F4TYqXPCsviuZWMC .cluster-label text{fill:#333;}#mermaid-svg-F4TYqXPCsviuZWMC .cluster-label span{color:#333;}#mermaid-svg-F4TYqXPCsviuZWMC .cluster-label span p{background-color:transparent;}#mermaid-svg-F4TYqXPCsviuZWMC .label text,#mermaid-svg-F4TYqXPCsviuZWMC span{fill:#333;color:#333;}#mermaid-svg-F4TYqXPCsviuZWMC .node rect,#mermaid-svg-F4TYqXPCsviuZWMC .node circle,#mermaid-svg-F4TYqXPCsviuZWMC .node ellipse,#mermaid-svg-F4TYqXPCsviuZWMC .node polygon,#mermaid-svg-F4TYqXPCsviuZWMC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-F4TYqXPCsviuZWMC .rough-node .label text,#mermaid-svg-F4TYqXPCsviuZWMC .node .label text,#mermaid-svg-F4TYqXPCsviuZWMC .image-shape .label,#mermaid-svg-F4TYqXPCsviuZWMC .icon-shape .label{text-anchor:middle;}#mermaid-svg-F4TYqXPCsviuZWMC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-F4TYqXPCsviuZWMC .rough-node .label,#mermaid-svg-F4TYqXPCsviuZWMC .node .label,#mermaid-svg-F4TYqXPCsviuZWMC .image-shape .label,#mermaid-svg-F4TYqXPCsviuZWMC .icon-shape .label{text-align:center;}#mermaid-svg-F4TYqXPCsviuZWMC .node.clickable{cursor:pointer;}#mermaid-svg-F4TYqXPCsviuZWMC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-F4TYqXPCsviuZWMC .arrowheadPath{fill:#333333;}#mermaid-svg-F4TYqXPCsviuZWMC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-F4TYqXPCsviuZWMC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-F4TYqXPCsviuZWMC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-F4TYqXPCsviuZWMC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-F4TYqXPCsviuZWMC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-F4TYqXPCsviuZWMC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-F4TYqXPCsviuZWMC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-F4TYqXPCsviuZWMC .cluster text{fill:#333;}#mermaid-svg-F4TYqXPCsviuZWMC .cluster span{color:#333;}#mermaid-svg-F4TYqXPCsviuZWMC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-F4TYqXPCsviuZWMC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-F4TYqXPCsviuZWMC rect.text{fill:none;stroke-width:0;}#mermaid-svg-F4TYqXPCsviuZWMC .icon-shape,#mermaid-svg-F4TYqXPCsviuZWMC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-F4TYqXPCsviuZWMC .icon-shape p,#mermaid-svg-F4TYqXPCsviuZWMC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-F4TYqXPCsviuZWMC .icon-shape .label rect,#mermaid-svg-F4TYqXPCsviuZWMC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-F4TYqXPCsviuZWMC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-F4TYqXPCsviuZWMC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-F4TYqXPCsviuZWMC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询评论列表
判断是否点过赞
客户端: 点赞/评论/喜欢
mf-app-server

MovementController / CommentsController
mf-dubbo

CommentApi
MongoDB

comment 集合

互动记录
MongoDB

movement 集合

likeCount/commentCount/loveCount
Redis

点赞/喜欢状态标记

三个存储各司其职:comment 集合记「谁对哪条动态做了什么」,movement 集合的计数字段记「这条动态一共被互动了多少次」,Redis 记「我点过赞没有」这种高频判断。

三合一存储:一张 comment 表靠 commentType 区分

本章最核心的设计决策是:点赞、评论、喜欢共用一个 comment 集合,靠 commentType 字段区分类型 ,而不是建三张表。工程实读确认了这一点,Comment 实体(mf-model 模块):

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.mapping.Document;

/**
 * 圈子互动表(点赞,评论,喜欢)
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "comment")
public class Comment implements java.io.Serializable {

    private ObjectId id;
    private ObjectId publishId;    //动态id
    private Integer commentType;   //评论类型,1-点赞,2-评论,3-喜欢
    private String content;        //评论内容
    private Long userId;           //评论人
    private Long publishUserId;    //被评论人ID
    private Long created;          //发表时间
    private Integer likeCount = 0; //当前评论的点赞数

}

对应的类型枚举:

java 复制代码
package com.mf.model.enums;

/**
 * 评论类型:1-点赞,2-评论,3-喜欢
 */
public enum CommentType {

    LIKE(1), COMMENT(2), LOVE(3);

    int type;

    CommentType(int type) {
        this.type = type;
    }

    public int getType() {
        return type;
    }
}

落到 MongoDB 里的一条点赞记录长这样:

json 复制代码
{
    "_id" : ObjectId("5fe7f9263c851428107cd4e8"),
    "publishId" : ObjectId("5fae53947e52992e78a3afa5"),
    "commentType" : 1,
    "userId" : NumberLong(1),
    "publishUserId" : NumberLong(1),
    "created" : NumberLong(1609038118275),
    "likeCount" : 0,
    "_class" : "com.mf.domain.mongo.Comment"
}

为什么三合一?和「一类互动一张表」的方案对比一下:

对比项 三合一(comment + commentType) 分表(三张表)
存储成本 一张表,结构统一 三张表,字段大量重复
查「我对这条动态的全部互动」 一次查询:publishId + userId 三张表各查一次再合并
查单类互动 必须带 type 条件:publishId + commentType=2 直接查,条件更短
新增互动类型 加一个枚举值即可 再建一张表
忘带 type 条件的后果 查出混入的点赞/喜欢数据 无此问题

三合一的收益集中在「跨类型查询」场景:判断一个用户对一条动态的全部互动状态,一次查询搞定。代价是每类查询都必须带类型条件,前一章查询单条动态已经确认过这个语义:查评论列表必须同时给 publishId + commentType=2,漏掉 commentType 就会把这条动态上的点赞、喜欢记录一起捞出来。这是使用这张表最容易踩的坑。

顺带修正一个容易混淆的口径:讲义分析段把这张表称为 quanzi_comment,但实体注解实际绑定的是 @Document(collection = "comment"),以工程代码为准。

计数一致性:计数值与互动记录必须对得上

动态卡片上展示的点赞数、评论数、喜欢数是计数值 ,comment 集合里的记录是明细 ,两者必须一致,计数比明细多一或少一,用户都能直接看出来。为避免每次对 comment 做 count() 聚合(亿级明细下这个查询代价不可接受),项目把计数内嵌在 movement 文档里:

java 复制代码
//动态详情表
@Document(collection = "movement")
public class Movement implements java.io.Serializable {

    // ...省略内容字段...

    private Integer likeCount = 0;    //点赞数
    private Integer commentCount = 0; //评论数
    private Integer loveCount = 0;    //喜欢数

    //根据评论类型,获取对应的互动数量
    public Integer statisCount(Integer commentType) {
        if (commentType == CommentType.LIKE.getType()) {
            return this.likeCount;
        } else if (commentType == CommentType.COMMENT.getType()) {
            return this.commentCount;
        } else {
            return loveCount;
        }
    }
}

statisCount 方法是给「互动后返回最新计数」用的:点赞/评论/喜欢三个入口共用一套 CommentApi.save() 逻辑,方法内部靠 commentType 反查该回写 movement 的哪个计数字段,返回操作后的数量。

这个「记录 + 计数」的双写结构,引出本章的技术主题,计数方案。常见三种做法:

方案 做法 优点 缺点
movement 内嵌计数(本章方案) 互动时同步更新 movement 的计数字段 读动态列表时计数随文档一次带出,零额外查询 写路径要维护两处数据,存在一致性窗口
实时 count 每次展示时对 comment 聚合 永远精确 高频列表查询扛不住,必须给 comment 建组合索引
Redis 计数 计数放 Redis,异步回写 Mongo 读写都快 引入回写与对账逻辑,复杂度高

这里呼应前一章的一个已知问题:动态列表查询时,卡片上的点赞/评论/喜欢计数当时是写死为 0 的,因为互动功能还没做,计数字段没有维护途径。本章正是补上这块的时候:互动接口做完,movement 的三个计数字段才有真实来源。

除了 MongoDB,互动数据还有第二个落点:Redis 状态标记。点赞/喜欢完成后,以动态 id 为 key、用户 id 拼进 hashKey 写入 Hash 结构,查询圈子列表时从这里判断「我是否点过赞/喜欢过」,避免高频回查 Mongo:

text 复制代码
key     = MOVEMENTS_INTERACT_{movementId}
hashKey = MOVEMENT_LIKE_{userId}    # 点赞标记
hashKey = MOVEMENT_LOVE_{userId}    # 喜欢标记
value   = "1"

于是互动数据形成「明细在 Mongo、计数在 movement、状态在 Redis」的三层结构,本章后续每一篇都会在这三层之间往返。

两种读模式与 key 设计

Redis 标记的 key 结构值得琢磨,因为它隐含了读模式的取舍:

读模式 典型场景 命中方式 数据组织
我对这条动态互动过没有 刷圈子列表,逐条显示已点赞按钮 hasKey(MOVEMENTS_INTERACT_{movementId}, MOVEMENT_LIKE_{userId}),O(1) key 按动态分组
这条动态都被谁点过赞 点赞列表页、给点赞人推消息 hgetall(MOVEMENTS_INTERACT_{movementId}) 后解析 hashKey 同样按动态分组,但量随热度膨胀
我点过赞的所有动态 「我的点赞」个人主页 现有 key 无法直接回答,要回 MongoDB 按 userId + commentType 查 key 按动态分组时此模式低效

工程选了「一条动态一个 Hash」的组织方式,因为产品里压倒性的读模式是第一种,刷列表。每次 hasKey 精确命中一个字段,一屏 10 条动态就是 10 次内存操作。如果产品主打的是「我的点赞」页,key 就该反过来按用户分组(INTERACT_USER_{userId} 做 key、movementId 做 hashKey)。key 设计跟着最高频的读模式走,这是缓存设计的一般规律:先明确问题,再定结构。

互动数据分层:低价值 ≠ 不重要

把「数据价值低」理解成「不重要、随便存」是最大的误读。它真正说的是一致性要求低,而且不同互动数据的价值并不相同:点赞计数差 1 无感知,但评论内容丢了是事故,评论是有内容的数据,用户打出来的字没了,这是要被投诉的。所以工程里的策略是分级处理:

数据 价值 一致性要求 存储 丢弃策略
评论内容 高(有用户产出内容) 必须持久化,丢失即事故 MongoDB comment 集合,必保 不允许
点赞/喜欢明细 中(无内容,可重建) 最终一致即可 MongoDB comment 集合 极端情况允许少量误差
互动标记(我是否点过赞) 低(缓存态,可重建) 允许短暂不一致 Redis Hash 丢了从 Mongo 重建,可牺牲
计数(likeCount 等冗余列) 中(展示用) 最终一致,允许短暂偏差 Movement 冗余字段 可由明细重算校准

同一张 comment 集合里的数据,commentType=2 的评论和 commentType=1 的点赞,容错等级完全不同。这也是为什么重复点赞校验、评论保存都走 MongoDB 为准,Redis 只做加速,按数据特征分配可靠性,而不是一刀切。

互动接口总览

本章要完成的接口清单,按工程实读整理(点赞/喜欢挂在 MovementController,评论挂在 CommentsController):

接口 方法 功能 涉及模块
/movements/{id}/like GET 动态点赞,返回最新点赞数 app-server + dubbo + Redis
/movements/{id}/dislike GET 取消点赞,返回最新点赞数 同上
/movements/{id}/love GET 动态喜欢,返回最新喜欢数 同上
/movements/{id}/unlove GET 取消喜欢,返回最新喜欢数 同上
/comments POST 发布评论(movementId + comment) app-server + dubbo
/comments GET 分页查询评论列表(movementId + page + pagesize) app-server + dubbo

服务端代码分布如下,后续每篇都在这个结构里加东西:

tree 复制代码
mf
├── mf-app-server                        # 交互层(controller + service)
│   └── src/main/java/com/mf/server
│       ├── controller
│       │   ├── MovementController.java      # 点赞/取消点赞/喜欢/取消喜欢
│       │   └── CommentsController.java      # 发布评论/评论列表
│       └── service
│           ├── MovementService.java         # 点赞喜欢业务编排 + Redis 标记
│           └── CommentsService.java         # 评论业务编排 + VO 组装
├── mf-dubbo
│   ├── mf-dubbo-interface
│   │   └── src/main/java/com/mf/dubbo/api
│   │       ├── CommentApi.java              # save / findComments / hasComment / delete
│   │       └── MovementApi.java             # 动态数据 Dubbo 接口(不含计数维护)
│   └── mf-dubbo-mongo
│       └── src/main/java/com/mf/dubbo/api
│           ├── CommentApiImpl.java          # MongoDB 读写与计数维护
│           └── MovementApiImpl.java
└── mf-model
    └── src/main/java/com/mf/model
        ├── mongo
        │   ├── Comment.java                 # 互动三合一实体
        │   └── Movement.java                # 动态实体(含三个计数字段)
        ├── enums
        │   └── CommentType.java             # 1-点赞 2-评论 3-喜欢
        └── vo
            └── CommentVo.java               # 评论列表返回对象

CommentApi 的四个方法正好对应互动的四种数据操作,是整章的 dubbo 契约:

java 复制代码
package com.mf.dubbo.api;

import com.mf.model.enums.CommentType;
import com.mf.model.mongo.Comment;

import java.util.List;

public interface CommentApi {

    //发布互动数据,并返回操作后的计数
    Integer save(Comment comment);

    //按类型分页查询互动数据
    List<Comment> findComments(String movementId, CommentType commentType, Integer page, Integer pagesize);

    //判断互动记录是否存在(幂等检查用)
    Boolean hasComment(String movementId, Long userId, CommentType commentType);

    //删除互动记录,并返回操作后的计数
    Integer delete(Comment comment);
}

原型线框

动态卡片底部的互动区,用户从这里发起全部互动动作:

doc 复制代码
+--------------------------------------+
|  o 小美          单身 本科 年龄相仿 |
|    程序员                          |
|  [图片墙/动态内容]                     |
|  程序员编程视频                        |
|  10分钟            20km               |
|                                      |
|  (转发)        (评论)        (赞)     |
|   12             15           1024    |
+--------------------------------------+

要点:

  • 动态卡片底部固定一排三个互动按钮:转发、评论、点赞,各自带计数。
  • 原型明确要求「显示点赞数、评论数 转发数」,这就是 movement 三个计数字段的直接消费方(转发数本章未实现,接口预留)。
  • 点赞后按钮变高亮并显示「已点赞」状态,该状态来自 Redis 标记而非实时查库。

点开评论按钮进入评论面板:

doc 复制代码
+--------------------------------------+
|  < 返回                               |
|--------------------------------------|
|  共15条评论                           |
|--------------------------------------|
|  程序员                           |
|  1024程序员节快乐          6分钟前     |
|--------------------------------------|
|  程序员                           |
|  1024程序员节快乐          6分钟前     |
|--------------------------------------|
|  ...                                 |
|--------------------------------------|
|  [添加评论............]     [发送]     |
+--------------------------------------+

要点:

  • 顶部展示评论总数「共15条评论」,对应 movement 的 commentCount。
  • 评论列表为平铺结构(无楼中楼),每条显示昵称、内容、相对时间。
  • 底部「添加评论」输入框对应 POST /comments,发布后评论数 +1。
  • 评论列表接口 GET /comments 分页加载,对应 page/pagesize 参数。

本章重点与难点预告

难点 问题描述 处理思路
重复互动的幂等 连点两次点赞按钮不能算两次;未点赞时取消点赞也不该成功 每次写入/删除前先 hasComment 做存在性检查,已点赞再点赞直接报错。本质是 publishId + userId + commentType 的唯一性约束,本系列前面吃过没有唯一索引的亏,这里记得给 comment 补上
计数一致性 互动记录写入与 movement 计数更新是两步操作,中断会导致计数与明细不符 互动接口返回操作后的最新计数,由 CommentApi.save/delete 内部统一维护;「允许少量误差」的特性在此兜底
评论的分页与层级 评论量大且持续增长,需分页加载;本项目评论是平铺,无回复嵌套 publishId + commentType=2 按时间倒序分页;楼中楼属于扩展场景,本章不做
「我的互动」反向查询 从「按动态查明细」反转为「按用户查我互动过哪些动态」 同一张 comment 表按 userId + commentType 反查,三合一设计的直接受益者

常见误解澄清

误解 事实
互动数据涉及计数,必须强一致 点赞计数差 1 用户无感,工程全程无分布式事务;真正要强的只有评论内容
Redis 标记可以完全替代 Mongo 明细 不行。重复点赞校验走 hasComment 查 Mongo,标记只是缓存,丢了要能重建
数据价值低就是不用好好存 低价值指的是一致性要求低,评论内容同样出自互动功能,丢失即事故
Mongo + Redis + 冗余字段三套并存是偷懒/过度设计 每一层对应一个业务特点:海量→Mongo、高频读→Redis、列表计数→冗余列,是按数据特征的设计
点赞、评论、喜欢各建一张表 只有一张 comment 集合,commentType 1/2/3 区分三类互动
点赞、评论、喜欢各需要一张表 只有一张 comment 集合,靠 commentType 区分类型,代码里也只有一套保存方法
「喜欢」是喜欢某个人 喜欢的作用对象是动态(publishId 指向 movement),对人的感兴趣属于交友匹配模块
计数每次实时 count() 聚合 计数内嵌在 movement 文档,互动时同步维护,列表查询零聚合开销
点赞状态每次查 MongoDB 高频判断走 Redis Hash 标记,Mongo 只存明细
comment 集合只存评论内容 点赞/喜欢记录也在这里,只是 content 为空
查评论列表只要 publishId 必须加 commentType=2,否则点赞、喜欢记录会混进结果
comment 集合名叫 quanzi_comment 那是讲义里的示意说法,以 @Document(collection = "comment") 注解为准,实际集合名是 comment

官方文档

总结

  • 互动是圈子的反馈循环:发布供给内容,点赞/评论/喜欢供给活性,行为数据同时是推荐系统的输入。三个业务特点各自推出一层方案:数据量极大 → MongoDB 存明细;查询极其频繁 → Redis Hash 存标记;数据价值低 → 放弃强一致,允许两侧双写有短暂偏差。
  • 全部互动共用一张 comment 集合,commentType 区分类型;查任何一类互动都必须带类型条件。Redis 标记的真实结构是 MOVEMENTS_INTERACT_{movementId} 做 key、MOVEMENT_LIKE_/MOVEMENT_LOVE_{userId} 做 hashKey,按动态分组,服务于刷列表时 O(1) 的状态判断。
  • 「数据价值低」的准确翻译是「一致性要求低」,评论内容是这条规律里的例外,必保落库。互动数据形成「明细在 Mongo、计数在 movement、状态在 Redis」的三层结构,计数内嵌在 movement,列表查询零聚合开销。
  • 接口清单:点赞/取消点赞/喜欢/取消喜欢(挂在 /movements/{id} 下)+ 发布评论/评论列表(/comments),共 6 个。后续从「发布评论」开始逐个落地这些接口。
相关推荐
Rain的Java大神之路1 小时前
北漂 13 年,我把 Java 家底钉成了这张知识图谱(全站免费)
java·后端·程序员
jimy11 小时前
构造函数表达式的值类别是:prvalue
开发语言·c++
小嘉丶学长1 小时前
Day06 · 数据类型与类型检测
开发语言·python·学习·1024程序员节
wuminyu1 小时前
JVM规范学习总结-Class文件结构
java·linux·c语言·jvm·c++
多弗朗皮卡丘1 小时前
C++特殊类设计
开发语言·c++·设计模式
七夜zippoe2 小时前
Spring AI + Ollama 本地模型:数据不出内网的合规方案(落地全流程)
java·人工智能·spring·ai·ollama
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 查询单条动态:动态详情页的接口与实现
java·开发语言·交友
yuniko-n2 小时前
【JUC】CAS
java·开发语言
captain3762 小时前
Spring Web MVC(2)
java·前端·spring boot·后端·spring·mvc