概述
圈子就是交友项目里的朋友圈:发动态、刷好友动态、点赞评论。难点不在接口,在于「刷好友动态」这条查询怎么扛住海量数据,这里拆三种表设计,讲清楚为什么最终选了动态表 + 相册表 + 时间线表这套冗余结构。
纲要
- 圈子功能与数据特点
- 发布动态、好友动态、推荐动态、点赞评论喜欢
- 数据量随用户数膨胀、读多写少、非好友不可见
- 为什么用 MongoDB 而不是 MySQL
- 三种表设计方案的演进与取舍
- 方案一:动态表 + 好友表(简单,但撑不住可见性)
- 方案二:动态表加「可见人列表」数组字段(能实现,但分片下效率低)
- 方案三:动态表 + 相册表 + 好友表 + 时间线表(最终方案)
- 三张核心表的职责与真实实体类
Movement(动态详情)、Album(自己的相册)、MovementTimeLine(好友时间线)- 发布与查询的读写流向(异步写时间线)
- 冗余设计的代价:空间换时间、一致性靠补偿
- 移动端原型与接口速览
圈子功能与数据特点
圈子模块要做的事情列一下:
| 功能 | 说明 |
|---|---|
| 发布动态 | 文字 140 字以内 + 最多 6 张图或 1 个小视频,可带位置 |
| 好友动态 | 按时间倒序刷出好友发布的动态 |
| 推荐动态 | 按问卷与兴趣推荐相似用户的动态 |
| 点赞 / 评论 / 喜欢 | 互动数据,评论分页展示 |
| 我的动态(相册) | 只看自己发过的 |
数据层面的特点很直接:
- 数据量随用户数线性膨胀,日活上去之后单表单库必然撑不住;
- 读远多于写,一个人刷十次朋友圈才发一条动态;
- 数据半结构化,
medias是数组、locationName可能为空,字段还会随业务加; - 不需要跨文档事务,发动态写多张表失败了重试就行。
第 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 | 单分片一次查询 | 每处副本都要同步,最麻烦 |
选中间那行,是空间换时间 + 一致性取最优的折中。
还有两个实测中容易踩的点:
- 异步写时间线失败没有事务兜底。 源码里
catch只打了日志。生产上至少要把失败的movementId落到一个待补偿表里,用定时任务重跑。否则用户会遇到「我发了动态但好友刷不到」。 - 删好友要级联删时间线。 好友关系解除后,
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 Sharding
- MongoDB Multikey Indexes
- MongoDB Shard Keys
- Spring Data MongoDB - Mapping
- Spring Framework - @Async
总结
- 圈子这种内容流,MongoDB 赢在文档模型和原生分片,MySQL 的强 schema 是加字段时的负担。
- 方案二把可见人塞进数组字段看着省事,但数组不能做分片键,多键索引还会把索引体积放大到和「可见人数 × 动态数」同量级。
- 最终方案是加一张冗余的时间线表:写的时候给每个好友写一条,读的时候只打一个分片一次查询,典型的读多写少取舍。
- 相册表和时间线表只存
movementId不存完整动态,把「内容一致性」收敛到movement单表,代价是多一次主键批量回查。 - 冗余带来的两个尾巴别漏:异步写时间线失败要有补偿任务,删除好友要级联清理时间线。