概述
动态能发、能刷之后,产品还差一口气,用户看到一条动态,除了「看」,还得能「反应」。点赞、评论、喜欢这三个动作就是圈子模块的互动层,这一章把它们的全景摊开:有哪些互动动作、数据落在哪、计数怎么维护、技术选型怎么从业务特点里推导出来、本章要一口气做完哪些接口。
业务背景:互动是社交产品的活性来源
圈子的内容链路上一章已经打通:发动态、刷好友动态、刷推荐动态、查单条动态。但只有「发布」没有「反应」的动态流是死的,发布解决的是内容供给,互动解决的是反馈循环。一条动态发出去没人点赞、没人评论,发布者就失去继续发的动力;浏览者刷到的全是零互动的内容,也提不起参与欲。社交产品的活性,本质上是靠互动数据转起来的。
互动还有一个容易被忽略的作用:它是推荐系统的原料。动态表里专门留了一个 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 个。后续从「发布评论」开始逐个落地这些接口。