Java 项目实战: 交友项目 - 圈子功能的表设计方案

概述

圈子就是交友项目里的朋友圈:发动态、刷好友动态、点赞评论。难点不在接口,在于「刷好友动态」这条查询怎么扛住海量数据,这里拆三种表设计,讲清楚为什么最终选了动态表 + 相册表 + 时间线表这套冗余结构。

纲要

  • 圈子功能与数据特点
  • 发布动态、好友动态、推荐动态、点赞评论喜欢
  • 数据量随用户数膨胀、读多写少、非好友不可见
  • 为什么用 MongoDB 而不是 MySQL
  • 三种表设计方案的演进与取舍
  • 方案一:动态表 + 好友表(简单,但撑不住可见性)
  • 方案二:动态表加「可见人列表」数组字段(能实现,但分片下效率低)
  • 方案三:动态表 + 相册表 + 好友表 + 时间线表(最终方案)
  • 三张核心表的职责与真实实体类
  • Movement(动态详情)、Album(自己的相册)、MovementTimeLine(好友时间线)
  • 发布与查询的读写流向(异步写时间线)
  • 冗余设计的代价:空间换时间、一致性靠补偿
  • 移动端原型与接口速览

圈子功能与数据特点

圈子模块要做的事情列一下:

功能 说明
发布动态 文字 140 字以内 + 最多 6 张图或 1 个小视频,可带位置
好友动态 按时间倒序刷出好友发布的动态
推荐动态 按问卷与兴趣推荐相似用户的动态
点赞 / 评论 / 喜欢 互动数据,评论分页展示
我的动态(相册) 只看自己发过的

数据层面的特点很直接:

  1. 数据量随用户数线性膨胀,日活上去之后单表单库必然撑不住;
  2. 读远多于写,一个人刷十次朋友圈才发一条动态;
  3. 数据半结构化,medias 是数组、locationName 可能为空,字段还会随业务加;
  4. 不需要跨文档事务,发动态写多张表失败了重试就行。

第 3 条是最容易被忽略的。动态这种内容,运营下周想加个话题标签、加个 @ 提醒、加个可见范围,MySQL 每次都要 ALTER TABLE,千万级表加字段的痛谁改过谁知道。

为什么选 MongoDB

维度 MySQL MongoDB
数据模型 强 schema,加字段要 ALTER TABLE 文档模型,字段随业务扩展,新旧文档可共存
数组存储 只能拆表或存 JSON 字符串,查询困难 原生数组字段,支持多键索引(Multikey Index)
水平扩展 分库分表要靠中间件(ShardingSphere 等),应用层改造大 原生分片集群,mongos 路由 + shard + config server
分片键 手工指定路由规则,跨片 join 基本不可用 按分片键路由,单用户数据可落在同一分片
事务 强事务,ACID 完整 4.0 起支持多文档事务,但圈子场景用不上
读性能 单表查询快,多表 join 成本高 反范式冗余设计,一次查询拿一屏数据

不是 MongoDB 什么都好。如果业务里存在「用户余额扣减 + 订单创建」这种强一致场景,还是老老实实回 MySQL。圈子这种内容流,天生适合文档库。

三种表设计,逐个拆

方案一:动态表 + 好友表

最直觉的设计,两张表:

text 复制代码
movement(动态表)          friend(好友表)
+----+--------+--------+  +--------+----------+
| id | userId |  content|  | userId | friendId |
+----+--------+--------+  +--------+----------+
| 1  | 张三   | ...     |  | 王五   | 张三      |
| 2  | 李四   | ...     |  | 王五   | 李四      |
+----+--------+--------+  +--------+----------+

查个人动态:where userId = ? 直接查动态表。

查好友动态:先查好友表拿到张三、李四,再 where userId in (张三, 李四) 查动态表。

问题在于,产品后续要加「指定好友可见 / 不可见」,这套结构没法表达。你只能在查出来之后在应用层过滤,翻页直接崩,第一页过滤掉 8 条,第二页从哪儿开始?另外两个表分属不同存储时,IN 查询在分片下会退化成广播查询。

结论:开发简单、好理解,但对可见性支持差,效率也低。

方案二:动态表加可见人字段

在动态表里塞一个数组字段 seeType / visibleList,MongoDB 支持数组,直接存可见人:

json 复制代码
{
  "_id": "1",
  "userId": "王五",
  "textContent": "...",
  "visibleList": ["张三", "李四"]
}

发布时先查出王五的所有好友,把可见人写进数组;查询好友动态时直接 where visibleList = 王五 就完事,功能全实现了。

这里有个坑,也是这个方案被否掉的原因,分片键选不了。

圈子的数据量是海量的,上分片集群是必然的。分片键只能用 userId(发布人),因为 visibleList 是数组,MongoDB 不支持用数组字段做分片键。于是每个分片存的是不同用户发布的全部动态:
#mermaid-svg-JPJ20HSG4qQj6yEf{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-JPJ20HSG4qQj6yEf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JPJ20HSG4qQj6yEf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JPJ20HSG4qQj6yEf .error-icon{fill:#552222;}#mermaid-svg-JPJ20HSG4qQj6yEf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JPJ20HSG4qQj6yEf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JPJ20HSG4qQj6yEf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JPJ20HSG4qQj6yEf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JPJ20HSG4qQj6yEf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JPJ20HSG4qQj6yEf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JPJ20HSG4qQj6yEf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JPJ20HSG4qQj6yEf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JPJ20HSG4qQj6yEf .marker.cross{stroke:#333333;}#mermaid-svg-JPJ20HSG4qQj6yEf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JPJ20HSG4qQj6yEf p{margin:0;}#mermaid-svg-JPJ20HSG4qQj6yEf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JPJ20HSG4qQj6yEf .cluster-label text{fill:#333;}#mermaid-svg-JPJ20HSG4qQj6yEf .cluster-label span{color:#333;}#mermaid-svg-JPJ20HSG4qQj6yEf .cluster-label span p{background-color:transparent;}#mermaid-svg-JPJ20HSG4qQj6yEf .label text,#mermaid-svg-JPJ20HSG4qQj6yEf span{fill:#333;color:#333;}#mermaid-svg-JPJ20HSG4qQj6yEf .node rect,#mermaid-svg-JPJ20HSG4qQj6yEf .node circle,#mermaid-svg-JPJ20HSG4qQj6yEf .node ellipse,#mermaid-svg-JPJ20HSG4qQj6yEf .node polygon,#mermaid-svg-JPJ20HSG4qQj6yEf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JPJ20HSG4qQj6yEf .rough-node .label text,#mermaid-svg-JPJ20HSG4qQj6yEf .node .label text,#mermaid-svg-JPJ20HSG4qQj6yEf .image-shape .label,#mermaid-svg-JPJ20HSG4qQj6yEf .icon-shape .label{text-anchor:middle;}#mermaid-svg-JPJ20HSG4qQj6yEf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JPJ20HSG4qQj6yEf .rough-node .label,#mermaid-svg-JPJ20HSG4qQj6yEf .node .label,#mermaid-svg-JPJ20HSG4qQj6yEf .image-shape .label,#mermaid-svg-JPJ20HSG4qQj6yEf .icon-shape .label{text-align:center;}#mermaid-svg-JPJ20HSG4qQj6yEf .node.clickable{cursor:pointer;}#mermaid-svg-JPJ20HSG4qQj6yEf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JPJ20HSG4qQj6yEf .arrowheadPath{fill:#333333;}#mermaid-svg-JPJ20HSG4qQj6yEf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JPJ20HSG4qQj6yEf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JPJ20HSG4qQj6yEf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JPJ20HSG4qQj6yEf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JPJ20HSG4qQj6yEf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JPJ20HSG4qQj6yEf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JPJ20HSG4qQj6yEf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JPJ20HSG4qQj6yEf .cluster text{fill:#333;}#mermaid-svg-JPJ20HSG4qQj6yEf .cluster span{color:#333;}#mermaid-svg-JPJ20HSG4qQj6yEf 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-JPJ20HSG4qQj6yEf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JPJ20HSG4qQj6yEf rect.text{fill:none;stroke-width:0;}#mermaid-svg-JPJ20HSG4qQj6yEf .icon-shape,#mermaid-svg-JPJ20HSG4qQj6yEf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JPJ20HSG4qQj6yEf .icon-shape p,#mermaid-svg-JPJ20HSG4qQj6yEf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JPJ20HSG4qQj6yEf .icon-shape .label rect,#mermaid-svg-JPJ20HSG4qQj6yEf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JPJ20HSG4qQj6yEf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JPJ20HSG4qQj6yEf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JPJ20HSG4qQj6yEf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} mongos 路由
shard-1 张三的动态
shard-2 李四的动态
shard-3 某某的动态
汇总排序返回

王五要刷好友动态,得把每个分片都查一遍再汇总,这是典型的 scatter-gather 查询,分片越多越慢。

有人说加索引。索引只能加在 visibleList 上,而 MongoDB 对数组字段建索引是多键索引,数组里每个元素生成一条独立索引项。一条动态如果有一万个可见人,就产生一万条索引记录。索引体积直接爆炸,写入时维护索引的成本也跟着涨。

这个方案对中小型应用其实够用,数据量没到分片那一步时它是最省事的。

方案三:加一张时间线表(最终方案)

把可见性从「数组字段」换成「一张表」,结构变成四张:

表 集合名 职责 分片键
动态表 movement 存动态详情,全局唯一一份 userId
相册表 movement_album 每个用户一张,只存自己发的动态 id userId
好友表 friend 双向好友关系 userId
时间线表 movement_timeline 存「谁可以看哪条动态」 friendId

发布时:写动态表 → 异步给每个好友写一条时间线(相册写入在本文代码示例中暂略,仅保留实体定义,见下方「相册表」段,与第 247 行说明一致)。

查好友动态:只查自己的时间线表(单分片 + 索引),拿到 movementId 列表,再批量取详情。

三张表的关系与读写流向

#mermaid-svg-yASvCLnqr4kaJE4f{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-yASvCLnqr4kaJE4f .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yASvCLnqr4kaJE4f .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yASvCLnqr4kaJE4f .error-icon{fill:#552222;}#mermaid-svg-yASvCLnqr4kaJE4f .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yASvCLnqr4kaJE4f .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yASvCLnqr4kaJE4f .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yASvCLnqr4kaJE4f .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yASvCLnqr4kaJE4f .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yASvCLnqr4kaJE4f .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yASvCLnqr4kaJE4f .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yASvCLnqr4kaJE4f .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yASvCLnqr4kaJE4f .marker.cross{stroke:#333333;}#mermaid-svg-yASvCLnqr4kaJE4f svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yASvCLnqr4kaJE4f p{margin:0;}#mermaid-svg-yASvCLnqr4kaJE4f .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yASvCLnqr4kaJE4f .cluster-label text{fill:#333;}#mermaid-svg-yASvCLnqr4kaJE4f .cluster-label span{color:#333;}#mermaid-svg-yASvCLnqr4kaJE4f .cluster-label span p{background-color:transparent;}#mermaid-svg-yASvCLnqr4kaJE4f .label text,#mermaid-svg-yASvCLnqr4kaJE4f span{fill:#333;color:#333;}#mermaid-svg-yASvCLnqr4kaJE4f .node rect,#mermaid-svg-yASvCLnqr4kaJE4f .node circle,#mermaid-svg-yASvCLnqr4kaJE4f .node ellipse,#mermaid-svg-yASvCLnqr4kaJE4f .node polygon,#mermaid-svg-yASvCLnqr4kaJE4f .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yASvCLnqr4kaJE4f .rough-node .label text,#mermaid-svg-yASvCLnqr4kaJE4f .node .label text,#mermaid-svg-yASvCLnqr4kaJE4f .image-shape .label,#mermaid-svg-yASvCLnqr4kaJE4f .icon-shape .label{text-anchor:middle;}#mermaid-svg-yASvCLnqr4kaJE4f .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yASvCLnqr4kaJE4f .rough-node .label,#mermaid-svg-yASvCLnqr4kaJE4f .node .label,#mermaid-svg-yASvCLnqr4kaJE4f .image-shape .label,#mermaid-svg-yASvCLnqr4kaJE4f .icon-shape .label{text-align:center;}#mermaid-svg-yASvCLnqr4kaJE4f .node.clickable{cursor:pointer;}#mermaid-svg-yASvCLnqr4kaJE4f .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yASvCLnqr4kaJE4f .arrowheadPath{fill:#333333;}#mermaid-svg-yASvCLnqr4kaJE4f .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yASvCLnqr4kaJE4f .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yASvCLnqr4kaJE4f .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yASvCLnqr4kaJE4f .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yASvCLnqr4kaJE4f .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yASvCLnqr4kaJE4f .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yASvCLnqr4kaJE4f .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yASvCLnqr4kaJE4f .cluster text{fill:#333;}#mermaid-svg-yASvCLnqr4kaJE4f .cluster span{color:#333;}#mermaid-svg-yASvCLnqr4kaJE4f 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-yASvCLnqr4kaJE4f .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yASvCLnqr4kaJE4f rect.text{fill:none;stroke-width:0;}#mermaid-svg-yASvCLnqr4kaJE4f .icon-shape,#mermaid-svg-yASvCLnqr4kaJE4f .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yASvCLnqr4kaJE4f .icon-shape p,#mermaid-svg-yASvCLnqr4kaJE4f .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yASvCLnqr4kaJE4f .icon-shape .label rect,#mermaid-svg-yASvCLnqr4kaJE4f .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yASvCLnqr4kaJE4f .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yASvCLnqr4kaJE4f .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yASvCLnqr4kaJE4f :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 读路径 · 三种查询
写路径 · 发布动态
异步
提供好友列表
movementId 批量回查
movementId 批量回查
POST /movements
movement 动态详情表
movement_album 相册表

自己发的
movement_timeline 时间线表

好友可见
friend 好友表
查我的动态 /movements/all
刷好友动态 /movements
推荐动态

关键点:写放大、读收敛。一条动态发布时可能要写 N 条时间线(N = 好友数),但读的时候只打一张表、一个分片。这就是拿写成本换读性能。

真实实体类

源码落库的类名是 Movement,讲义和早期版本里叫 Publish(com.mf.dubbo.server.pojo.Publish),指的是同一个东西。看代码别被这两个名字绕进去。

mf-model/src/main/java/com/mf/model/mongo/Movement.java:

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;

import java.util.List;

/**
 * 动态详情表:全局唯一的动态内容,与朋友圈里的一条动态一一对应
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "movement")
public class Movement implements java.io.Serializable {

    private ObjectId id;          // 主键,MongoDB 默认 _id
    private Long pid;             // 自增 Long 主键,推荐系统模型需要用数值型 id
    private Long created;         // 发布时间戳(毫秒)
    private Long userId;          // 发布人 id,分片键
    private String textContent;   // 动态文字,前端限制 140 字
    private List<String> medias;  // 媒体数据,图片 url 列表或小视频 url
    private String longitude;     // 经度,用于附近的人与距离展示
    private String latitude;      // 纬度
    private String locationName;  // 位置名称,如「中国上海市奉贤区人民路445弄」
    private Integer state = 0;    // 审核状态 0:未审(默认),1:通过,2:驳回
}

MovementTimeLine.java:

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;

/**
 * 好友时间线表:存储好友发布(或推荐)的数据,按 friendId 分片
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "movement_timeline")
public class MovementTimeLine implements java.io.Serializable {

    private static final long serialVersionUID = 9096178416317502524L;

    private ObjectId id;
    private ObjectId movementId;  // 动态 id,指向 movement 表
    private Long userId;          // 发布动态的用户 id
    private Long friendId;        // 可见好友 id,刷朋友圈时的查询条件
    private Long created;         // 发布时间,用于时间线倒序排序
}

Friend.java:

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;

/**
 * 好友表:好友关系表(双向存储,A-B 与 B-A 各一条)
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "friend")
public class Friend implements java.io.Serializable {

    private static final long serialVersionUID = 6003135946820874230L;

    private ObjectId id;
    private Long userId;      // 用户 id
    private Long friendId;    // 好友 id
    private Long created;     // 建立关系的时间
}

相册表在第四章代码里还没落库(那一节只做到发布 + 查个人动态),按讲义「自己时间线:相册是每个用户独立的,记录了该用户所发布的所有内容」补出来是这样:

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;

/**
 * 相册表:每个用户一张,只记录自己发布的动态 id
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "movement_album")
public class Album implements java.io.Serializable {

    private static final long serialVersionUID = 4321830950952167875L;

    private ObjectId id;
    private ObjectId movementId;  // 动态 id,指向 movement 表
    private Long userId;          // 发布人 id,也是分片键
    private Long created;         // 发布时间
}

顺带提一句 pid。MongoDB 的 _id 是 ObjectId,推荐系统那套模型要的是数值型 id,所以额外用 sequence 集合做了一张自增序列表:

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;

/**
 * 序列表:模拟 MySQL 的自增主键,为动态生成 Long 类型的 pid
 */
@Document(collection = "sequence")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Sequence {

    private ObjectId id;
    private long seqId;       // 自增序列值
    private String collName;  // 集合名称,如 movement
}

发布动态的写入流程

MovementApiImpl.publish 里干三件事,第三件丢给异步:

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

import com.mf.dubbo.utils.IdWorker;
import com.mf.dubbo.utils.TimeLineService;
import com.mf.model.mongo.Movement;
import com.mf.model.vo.PageResult;
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 MovementApiImpl implements MovementApi {

    @Autowired
    private MongoTemplate mongoTemplate;

    @Autowired
    private IdWorker idWorker;

    @Autowired
    private TimeLineService timeLineService;

    /**
     * 发布动态:写动态详情 + 异步写好友时间线(相册写入本篇代码略,实体见下方「相册表」段)
     */
    public void publish(Movement movement) {
        try {
            // 1、生成推荐系统需要的 Long 型 pid
            movement.setPid(idWorker.getNextId("movement"));
            // 2、补上发布时间
            movement.setCreated(System.currentTimeMillis());
            // 3、保存动态详情(同步)
            mongoTemplate.save(movement);
            // 4、异步写入好友时间线,不阻塞发布接口响应
            timeLineService.saveTimeLine(movement.getUserId(), movement.getId());
        } catch (Exception e) {
            // 圈子数据不做事务,失败靠日志 + 补偿任务处理
            e.printStackTrace();
        }
    }

    /**
     * 查询个人动态:走相册表语义,这里直接按 userId 查动态表
     */
    public PageResult findByUserId(Long userId, Integer page, Integer pagesize) {
        Criteria criteria = Criteria.where("userId").is(userId);
        Query query = Query.query(criteria)
                .skip((page - 1) * pagesize)
                .limit(pagesize)
                .with(Sort.by(Sort.Order.desc("created")));
        List<Movement> movements = mongoTemplate.find(query, Movement.class);
        return new PageResult(page, pagesize, 0L, movements);
    }
}

异步写时间线的实现:

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

import com.mf.model.mongo.Friend;
import com.mf.model.mongo.MovementTimeLine;
import org.bson.types.ObjectId;
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.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class TimeLineService {

    @Autowired
    private MongoTemplate mongoTemplate;

    /**
     * 给当前用户的每个好友写一条时间线记录
     * userId 为发布人,friendId 为可见好友
     */
    @Async
    public void saveTimeLine(Long userId, ObjectId movementId) {
        // 1、查询发布人的好友列表
        Query query = Query.query(Criteria.where("userId").is(userId));
        List<Friend> friends = mongoTemplate.find(query, Friend.class);
        // 2、循环写入时间线(若发布时指定了某人不可见,这里先过滤)
        for (Friend friend : friends) {
            MovementTimeLine timeLine = new MovementTimeLine();
            timeLine.setMovementId(movementId);
            timeLine.setUserId(friend.getUserId());
            timeLine.setFriendId(friend.getFriendId());
            timeLine.setCreated(System.currentTimeMillis());
            mongoTemplate.save(timeLine);
        }
    }
}

注意 @Async 生效的前提:启动类上要有 @EnableAsync,而且调用方不能和被调用方在同一个类里,自调用会绕过 Spring 代理,注解直接失效。这是本地调试时最常见的「为什么还是同步的」。

时序上整体是这样:
MongoDB mf-dubbo-mongo mf-app-server 客户端 MongoDB mf-dubbo-mongo mf-app-server 客户端 #mermaid-svg-oTGxfxFZtCwL8yYl{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-oTGxfxFZtCwL8yYl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oTGxfxFZtCwL8yYl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oTGxfxFZtCwL8yYl .error-icon{fill:#552222;}#mermaid-svg-oTGxfxFZtCwL8yYl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oTGxfxFZtCwL8yYl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oTGxfxFZtCwL8yYl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oTGxfxFZtCwL8yYl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oTGxfxFZtCwL8yYl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oTGxfxFZtCwL8yYl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oTGxfxFZtCwL8yYl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oTGxfxFZtCwL8yYl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oTGxfxFZtCwL8yYl .marker.cross{stroke:#333333;}#mermaid-svg-oTGxfxFZtCwL8yYl svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oTGxfxFZtCwL8yYl p{margin:0;}#mermaid-svg-oTGxfxFZtCwL8yYl .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oTGxfxFZtCwL8yYl text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-oTGxfxFZtCwL8yYl .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-oTGxfxFZtCwL8yYl .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-oTGxfxFZtCwL8yYl .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-oTGxfxFZtCwL8yYl .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-oTGxfxFZtCwL8yYl #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-oTGxfxFZtCwL8yYl .sequenceNumber{fill:white;}#mermaid-svg-oTGxfxFZtCwL8yYl #sequencenumber{fill:#333;}#mermaid-svg-oTGxfxFZtCwL8yYl #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-oTGxfxFZtCwL8yYl .messageText{fill:#333;stroke:none;}#mermaid-svg-oTGxfxFZtCwL8yYl .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oTGxfxFZtCwL8yYl .labelText,#mermaid-svg-oTGxfxFZtCwL8yYl .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-oTGxfxFZtCwL8yYl .loopText,#mermaid-svg-oTGxfxFZtCwL8yYl .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-oTGxfxFZtCwL8yYl .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-oTGxfxFZtCwL8yYl .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-oTGxfxFZtCwL8yYl .noteText,#mermaid-svg-oTGxfxFZtCwL8yYl .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-oTGxfxFZtCwL8yYl .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oTGxfxFZtCwL8yYl .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oTGxfxFZtCwL8yYl .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-oTGxfxFZtCwL8yYl .actorPopupMenu{position:absolute;}#mermaid-svg-oTGxfxFZtCwL8yYl .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-oTGxfxFZtCwL8yYl .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-oTGxfxFZtCwL8yYl .actor-man circle,#mermaid-svg-oTGxfxFZtCwL8yYl line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-oTGxfxFZtCwL8yYl :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /movements 文字 + 图片 图片上传 OSS,得到 medias url Dubbo publish(movement) save movement 动态详情 save album 自己的相册(本文代码示例未实现,仅设计示意,见 247 行说明) 返回成功(毫秒级) 异步 saveTimeLine 查 friend 表得到好友列表 批量 save movement_timeline GET /movements 刷好友动态 查时间线 where friendId = 我 单分片 + 索引查询 movementId 列表 按 movementId 批量取详情 Movement 列表 MovementsVo 分页结果

为什么相册和时间线只存 id

这是整套设计里最值得讲的一点。

存完整动态行不行? 行,但代价是:动态一旦被作者编辑、或者审核状态变了,你得去 N 个好友的时间线里更新 N 份拷贝。更糟的是 likeCount、commentCount 这种高频变动字段,每点一次赞就要更新所有副本,写放大不可控。

只存 id 的代价 是查询多一次回查:时间线查出 20 个 movementId,再去 movement 表批量取一次。这是 where _id in (...) 的单表主键查询,MongoDB 下非常快,比 scatter-gather 跨分片便宜太多。

对比一下:

设计 写入放大 读取成本 一致性维护
可见人数组字段(方案二) 1 条 跨分片 scatter-gather 改一次动态改一处
时间线表只存 id(方案三) 1 + N 条(N=好友数) 单分片两次查询 动态内容只有一份,天然一致
时间线表存完整动态 1 + N 条,且更新时再乘 N 单分片一次查询 每处副本都要同步,最麻烦

选中间那行,是空间换时间 + 一致性取最优的折中。

还有两个实测中容易踩的点:

  1. 异步写时间线失败没有事务兜底。 源码里 catch 只打了日志。生产上至少要把失败的 movementId 落到一个待补偿表里,用定时任务重跑。否则用户会遇到「我发了动态但好友刷不到」。
  2. 删好友要级联删时间线。 好友关系解除后,movement_timeline 里旧记录不清,对方还能刷到你的历史动态。这个清理也要异步做。

工程结构

text 复制代码
交友项目/04-圈子功能实现/01-代码/mf
├── mf-app-server                                # 面向 App 的 HTTP 服务
│   └── src/main/java/com/mf/server
│       ├── controller/MovementController.java       # /movements 接口入口
│       └── service/MovementService.java             # 上传 OSS、组装 VO
├── mf-dubbo
│   ├── mf-dubbo-interface                       # RPC 接口
│   │   └── api/MovementApi.java
│   └── mf-dubbo-mongo                           # MongoDB 数据访问实现
│       ├── api/MovementApiImpl.java                 # 发布、查个人动态
│       └── utils/TimeLineService.java               # 异步写时间线
└── mf-model                                     # 实体与 VO
    └── src/main/java/com/mf/model
        ├── mongo/Movement.java                      # movement 动态表
        ├── mongo/MovementTimeLine.java              # movement_timeline 时间线表
        ├── mongo/Friend.java                        # friend 好友表
        ├── mongo/Sequence.java                      # sequence 自增序列表
        └── vo/MovementsVo.java                      # 出参:含距离、时间文案、点赞数

移动端原型

圈子首页(推荐频道)

doc 复制代码
+--------------------------------+
|  圈子                    小视频 |
+--------------------------------+
|  [ 推荐 ]      最新            |
+--------------------------------+
| +----+  小美               |
| |头像|  单身  本科  年龄相仿    |
| +----+              [ 关 注 ]  |
|                                |
|  1024程序员节快乐               |
| +-------+ +-------+ +-------+  |
| | 图 1  | | 图 2  | | 图 3  |  |
| +-------+ +-------+ +-------+  |
|                                |
|  10分钟前             20km     |
|  [ 赞 128 ] [ 评 15 ] [ 转 3 ] |
+--------------------------------+
| +----------------------------+ |
| |           banner           | |
| +----------------------------+ |
+--------------------------------+
| +----+  程序员              |
| |头像|  单身  本科  年龄相仿     |
| +----+                         |
|  这就是我的底线                 |
| +-------+ +-------+            |
| | 图 1  | | 图 2  |            |
| +-------+ +-------+            |
|  6分钟前              20km     |
|  [ 赞 66 ] [ 评 2 ] [ 转 0 ]   |
+--------------------------------+
|  圈子    交友    (+)    消息  我的|
+--------------------------------+

要点:

  • 顶部两个 Tab:圈子 / 小视频,圈子下再分「推荐」「最新」两个频道。
  • 推荐频道按问卷与兴趣相似度推荐,展示头像、昵称、性别、年龄、标签、动态正文。
  • 图片最多 6 张,或发布 1 个小视频,二者互斥。
  • 时间显示取整:10 分钟前 / 3 小时前 / 2 天前,不要显示到秒。
  • 同时展示距离(发布地与本地的距离)、浏览量、点赞数、评论数、转发数。
  • 底部固定导航栏,中间是发布入口。

发布动态页

doc 复制代码
+--------------------------------+
|  返回        发动态      发帖  |
+--------------------------------+
|                                |
|  +--------------------------+  |
|  | 请填写动态(140字以内)  |  |
|  |                          |  |
|  |                          |  |
|  +--------------------------+  |
|                                |
|  +--------+  +--------+        |
|  | 图 1  |  | 图 2  |         |
|  |        |  |        |        |
|  +--------+  +--------+        |
|  +--------+  +--------+        |
|  | 图 3  |  | 选择照片 |       |
|  +--------+  +--------+        |
|                                |
|  最多可选6张                   |
|                                |
|  ( 定位 ) 中国上海市奉贤区...  |
|                                |
|            [ 上 传 ]           |
+--------------------------------+

要点:

  • 内容不做强制字数限制,但超过 140 字提交时截断并提示。
  • 一次最多选 6 张照片,超过 6 张后选择按钮置灰不可点。
  • 点击「选择照片」进入相册列表,已选图片可再次点击取消。
  • 定位可选,选了才写入 longitude / latitude / locationName。
  • 上传时图片先走 OSS,拿到 url 列表后再调发布接口,接口入参是 Movement + MultipartFile[]。

动态详情页

doc 复制代码
+--------------------------------+
|  <                        ...  |
+--------------------------------+
| +----+  小美               |
| |头像|  单身  本科  年龄相仿    |
| +----+                         |
|  1024程序员节快乐               |
| +-----------+ +-----------+    |
| |  图 1     | |  图 2     |    |
| +-----------+ +-----------+    |
|  10分钟前             20km     |
+--------------------------------+
|  共15条评论                    |
+--------------------------------+
| +----+  程序员             |
| |头像|  1024程序员节快乐       |
| +----+            6分钟前      |
| +----+  程序员             |
| |头像|  1024程序员节快乐       |
| +----+            6分钟前      |
| +----+  程序员             |
| |头像|  1024程序员节快乐       |
| +----+            6分钟前      |
+--------------------------------+
|  [ 添加评论            ] 发送  |
+--------------------------------+

要点:

  • 详情页展示动态完整内容与全部图片,评论列表分页加载。
  • 评论数、点赞数、喜欢数分别对应互动模块的三张计数,后续落 comment 集合。
  • 「是否点赞 / 是否喜欢」由 hasLiked、hasLoved 返回,前端据此渲染高亮态。
  • 评论输入框常驻底部,发送后走 POST /comments。

API 速览

方法 路径 说明 关键参数
POST /movements 发布动态 textContent、imageContent[]、longitude、latitude、locationName
GET /movements/all 查询我的动态(相册) userId、page(默认 1)、pagesize(默认 10)
GET /movements 查询好友动态(时间线) page、pagesize
GET /movements/recommend 查询推荐动态 page、pagesize
GET /movements/{id} 动态详情 id(ObjectId 十六进制串)

出参 MovementsVo 里的 id 是 ObjectId.toHexString() 的字符串,前端回传时也是这个字符串,服务端要用 new ObjectId(id) 转回去。

官方文档

总结

  • 圈子这种内容流,MongoDB 赢在文档模型和原生分片,MySQL 的强 schema 是加字段时的负担。
  • 方案二把可见人塞进数组字段看着省事,但数组不能做分片键,多键索引还会把索引体积放大到和「可见人数 × 动态数」同量级。
  • 最终方案是加一张冗余的时间线表:写的时候给每个好友写一条,读的时候只打一个分片一次查询,典型的读多写少取舍。
  • 相册表和时间线表只存 movementId 不存完整动态,把「内容一致性」收敛到 movement 单表,代价是多一次主键批量回查。
  • 冗余带来的两个尾巴别漏:异步写时间线失败要有补偿任务,删除好友要级联清理时间线。
相关推荐
小成202303202651 小时前
C++~05异常处理
开发语言·c++
文丑颜不良啊1 小时前
LeetCode601~625题目解析
java·leetcode
文丑颜不良啊1 小时前
LeetCode651~675题目解析
java·leetcode
frjc1 小时前
Spring WebFlux SSE 长连接,可以替换为 Java 21 虚拟线程吗?
java·人工智能·spring·ai·ai编程
轻舟技术A1 小时前
Rust 重写 Pi:会话启动耗时降 91%,就绪内存少 78%
开发语言·后端·rust
Jack N2 小时前
【Strapi 系列之五】前端怎么接?Next.js + Tailwind 拉取 Strapi 数据(含代码)
开发语言·前端·javascript·strapi
知守观2 小时前
从 4 套 PDF 库的混战,聊聊老项目的依赖管理与“静默失败”
java·后端·设计模式