仿小红书源码中的个人主页,通常需要展示头像、昵称、简介、主页封面、作品数量、获赞数、关注数、粉丝数,以及图文和短视频组成的双列瀑布流。
进入自己的个人中心后,查询内容还会扩展到作品管理、收藏管理、浏览记录、关注列表、粉丝列表和账号设置。页面结构看起来并不复杂,后台却可能同时访问用户表、内容表、媒体表、点赞表、收藏表、浏览记录表和关注关系表。
如果每个区域都单独查询,并在循环中继续补充封面、话题和互动状态,一次主页请求很容易产生几十条 SQL。更合理的方式是先拆分查询职责,再通过复合索引、游标分页和批量组装控制数据库访问,最后结合 Druid 观察 SQL 执行时间与连接占用。

个人主页请求应拆成固定查询步骤
一次主页请求大致包含四类数据:
javascript
用户基础资料
↓
作品、获赞、关注和粉丝统计
↓
公开作品分页数据
↓
当前访问者的关注、点赞和收藏状态
用户资料和统计数据只需读取一次,作品列表通过索引分页获取,封面、话题和互动状态则根据作品 ID 批量补充。
宠友社区这种方式虽然不是只执行一条 SQL,但查询次数相对固定,不会随着作品数量不断增加。
相反,如果把用户、内容、媒体、话题、点赞和收藏全部写进一条关联 SQL,一篇包含多张图片和多个话题的作品可能产生大量重复行,还会影响分页与排序。
内容表复合索引要贴合查询条件
个人主页作品列表通常按照作者、内容状态和发布时间筛选:
javascript
user_id = 主页作者
status = PUBLISHED
publish_time = 倒序
如果只为这些字段分别建立单列索引,MySQL 不一定能同时高效利用。对于固定的高频查询,可以建立复合索引:
javascript
CREATE INDEX idx_content_user_status_time
ON content (
user_id,
status,
publish_time DESC,
id DESC
);
SELECT
id,
content_type,
title,
cover_url,
cover_width,
cover_height,
like_count,
comment_count,
collect_count,
publish_time
FROM content
WHERE user_id = :profileUserId
AND status = 'PUBLISHED'
AND (
:cursorTime IS NULL
OR publish_time < :cursorTime
OR (
publish_time = :cursorTime
AND id < :cursorId
)
)
ORDER BY publish_time DESC, id DESC
LIMIT :pageSize;
这是第一处代码示例。
user_id 和 status 负责缩小查询范围,publish_time 和 id 负责排序与分页。ID 作为第二排序字段,可以避免多篇作品发布时间相同时出现顺序不稳定。
复合索引并不是字段越多越好。个人主页、内容分类页和话题页的筛选条件不同,应分别根据实际查询建立索引,而不是让一条索引承担所有列表接口。

游标分页比深分页更适合作品列表
传统分页通常使用:
javascript
LIMIT offset, pageSize
页码增加后,数据库需要跳过越来越多的数据。例如查询 LIMIT 20000, 20 时,前面的记录虽然不会返回,但仍需要被定位。
仿小红书源码中的作品列表、收藏记录、浏览记录和粉丝列表更适合游标分页。
客户端第一次请求不传游标,接口返回当前批次数据和下一页位置:
javascript
cursorTime = 2026-07-18 10:15:30
cursorId = 28610
后续查询从该记录之后继续读取,不再依赖持续增大的页码偏移量。
不同列表可以采用不同游标:
- 作品列表使用发布时间和内容 ID;
- 收藏列表使用收藏时间和记录 ID;
- 浏览记录使用最近浏览时间和记录 ID;
- 粉丝列表使用关注时间和关系 ID。
接口返回结构可以统一为 records、nextCursor 和 hasMore。
批量组装解决N+1查询
作品列表查询到二十条内容后,还需要补充媒体封面、话题标签、点赞状态和收藏状态。
如果在 Java 循环中逐条查询,调用过程可能变成:
javascript
1次作品查询
20次媒体查询
20次话题查询
20次点赞状态查询
20次收藏状态查询
这类 N+1 查询会快速占用数据库连接。更合适的方式是先收集作品 ID,再按照数据类型批量读取。
javascript
public List<ContentCardVO> assembleCards(
List<Content> contents,
Long currentUserId) {
if (contents == null || contents.isEmpty()) {
return Collections.emptyList();
}
List<Long> contentIds = contents.stream()
.map(Content::getId)
.toList();
Map<Long, ContentMedia> coverMap =
contentMediaService.queryCoverMap(contentIds);
Map<Long, List<String>> topicMap =
contentTopicService.queryTopicNameMap(contentIds);
Set<Long> likedIds =
contentLikeService.queryLikedContentIds(
currentUserId,
contentIds
);
Set<Long> collectedIds =
contentCollectService.queryCollectedContentIds(
currentUserId,
contentIds
);
return contents.stream().map(content -> {
ContentCardVO card = new ContentCardVO();
card.setContentId(content.getId());
card.setTitle(content.getTitle());
card.setContentType(content.getContentType());
card.setLikeCount(content.getLikeCount());
card.setCommentCount(content.getCommentCount());
card.setCollectCount(content.getCollectCount());
card.setCover(coverMap.get(content.getId()));
card.setTopicNames(
topicMap.getOrDefault(
content.getId(),
Collections.emptyList()
)
);
card.setLiked(likedIds.contains(content.getId()));
card.setCollected(
collectedIds.contains(content.getId())
);
return card;
}).toList();
}
这是第二处代码示例。
批量查询并不是把所有数据关联到一条 SQL,而是让每类数据只读取一次。无论一页返回十条还是二十条,SQL 数量都能保持稳定。
批量查询也要控制 IN 条件长度。列表接口按照正常分页大小处理即可,大规模数据修复和定时任务则应分批执行。

双列瀑布流需要返回封面宽高
个人主页作品通常采用双列瀑布流。图片比例不同,卡片高度也不同。
如果后台只返回图片地址,客户端必须等图片加载完成后才能确定高度,容易出现卡片跳动和页面重新排版。
媒体表建议保存:
javascript
file_url
width
height
media_type
sort_number
cover_flag
图文内容可以选取第一张图片作为封面,短视频则使用独立封面。客户端根据列宽与原始比例提前计算展示高度:
javascript
展示高度 = 卡片宽度 × 原图高度 ÷ 原图宽度
多图内容还可以返回图片数量,短视频返回播放时长。完整媒体列表只在内容详情页加载,主页瀑布流不需要返回全部图片。
用户统计不应每次实时计算
个人主页中的作品数、获赞数、关注数和粉丝数,如果每次都执行 COUNT 或 SUM,会频繁扫描内容表和关系表。
可以维护独立的用户统计表:
| 字段 | 含义 |
|---|---|
| content_count | 公开作品数 |
| received_like_count | 累计获赞数 |
| following_count | 关注数 |
| follower_count | 粉丝数 |
| collect_count | 收藏数 |
发布、隐藏、点赞、取消点赞、关注和取消关注成功后,更新对应统计字段。
统计表只负责提高主页查询效率,内容表、点赞表和关注表仍然保存真实关系。Quartz 可以定期分批核对统计值,修正少量异常。
统计更新还需要避免并发覆盖。例如多个点赞请求同时读取旧值再写回,可能出现数量丢失。更适合直接执行数据库原子更新:
javascript
UPDATE user_statistics
SET received_like_count = received_like_count + 1
WHERE user_id = ?

关注与粉丝可以共用一张关系表
关注和粉丝只是同一条关系的两个方向。
关系表可以保存:
javascript
user_id 发起关注用户
target_id 被关注用户
status
create_time
id
查询关注列表时使用 user_id,查询粉丝列表时使用 target_id,并分别建立复合索引:
javascript
(user_id, status, create_time, id)
(target_id, status, create_time, id)
不需要额外维护一张粉丝表,否则关注和取消关注时需要同步两份数据。
同一用户不能重复关注同一目标,可以增加唯一约束:
javascript
UNIQUE KEY uk_user_target (user_id, target_id)
应用层可以提前判断关系是否存在,数据库唯一约束则负责处理并发请求下的重复写入。
粉丝列表需要展示是否已经回关时,可以先查询当前页粉丝 ID,再批量判断反向关注关系,不能逐条执行查询。
收藏与内容状态必须分开判断
收藏记录表示用户收藏过某篇作品,并不代表作品始终公开。
内容可能被作者隐藏、删除或后台下架。收藏关系可以继续保留,但列表返回时要检查内容当前状态,可以过滤失效记录,也可以显示为不可访问状态。
收藏列表可以建立:
javascript
(user_id, status, collect_time, id)
用户执行收藏时,后台还要确认作品仍处于公开状态。已删除或下架内容不能新增收藏关系。
点赞、评论和回复也应遵循同样规则,不能只根据前端页面是否显示按钮决定能否操作。
浏览记录避免重复插入
用户可能多次打开同一篇图文或短视频。
如果每次访问都新增一行,浏览记录表会快速增长,个人历史列表也会出现大量重复内容。
可以为 user_id + content_id 建立唯一约束,重复浏览时更新访问次数和最近浏览时间:
javascript
INSERT INTO content_browse_record (
user_id,
content_id,
browse_count,
last_browse_time
)
VALUES (
:userId,
:contentId,
1,
NOW()
)
ON DUPLICATE KEY UPDATE
browse_count = browse_count + 1,
last_browse_time = NOW();
这是第三处代码示例。
个人浏览记录按照 last_browse_time 倒序展示。内容推荐可以读取近期浏览分类和话题,但浏览历史仍然作为独立用户数据保存。

自己的作品管理与公开主页查询不同
访问他人主页时,只能查询公开作品:
javascript
user_id = 目标用户
status = PUBLISHED
进入自己的作品管理时,才可以读取草稿、审核中、已隐藏或未通过内容。
这种数据范围必须放在服务端查询条件中,不能只依赖前端隐藏页面。
Shiro 可以控制用户是否具有作品管理权限,业务层仍要确认作品作者与当前登录用户一致。收藏管理、浏览记录、资料编辑和账号设置也只能操作当前用户的数据。
内容搜索不需要复用主页SQL
个人主页已经明确知道作者 ID,使用 MySQL 复合索引即可。
用户输入关键词搜索标题、正文、话题或商品时,更适合交给 EasyES。两类查询的职责应保持清晰:
|---------|-----------|
| 查询场景 | 处理方式 |
| 作者公开作品 | MySQL复合索引 |
| 个人作品管理 | MySQL状态索引 |
| 收藏与浏览记录 | MySQL关系表 |
| 关注与粉丝列表 | MySQL关系索引 |
| 内容关键词搜索 | EasyES |
| 商品关键词搜索 | EasyES |
搜索结果返回内容 ID 后,仍然需要检查数据库中的内容状态和访问权限。搜索索引不能代替数据库中的最终业务判断。

Redis缓存公共数据,不混入个性化状态
用户资料、主页统计和公开作品卡片属于公共数据,可以使用 Redis 缓存。
但以下状态与当前访问者有关:
- 是否关注主页作者;
- 是否点赞某篇作品;
- 是否收藏某篇作品。
这些字段不能直接写进公共主页缓存,否则不同用户可能读取到错误状态。
更适合的组装方式是:
javascript
公共主页缓存
+
当前用户关系状态
=
最终返回数据
缓存用于降低重复读取,不能代替复合索引和批量查询。SQL 本身存在全表扫描或 N+1 查询时,增加缓存只能暂时掩盖问题。
Druid需要同时观察速度和查询数量
Druid 可以记录 SQL 执行时间、调用次数、连接等待和活跃连接数量。
个人主页链路可以重点关注:
- 单条 SQL 平均耗时;
- 单次请求执行的 SQL 数量;
- 是否出现全表扫描;
- 是否使用临时表和文件排序;
- 连接是否及时归还;
- 事务持续时间;
- 相同 SQL 的调用频率。
有些接口没有明显慢 SQL,但一次请求执行几十条短 SQL,总耗时仍然较高。
因此,排查时应同时确认:
javascript
SQL是否命中索引
SQL数量是否合理
数据库连接是否及时释放
连接池最大连接数也不能盲目调大。若接口存在长事务或循环查询,扩大连接池只会让数据库同时处理更多低效请求。

优化后的个人主页执行轨迹
经过索引、分页和数据组装调整后,一次主页请求可以按照以下顺序运行:
javascript
读取用户资料
↓
读取用户统计表
↓
判断当前访问者关注状态
↓
通过复合索引查询作品列表
↓
批量查询封面与话题
↓
批量查询点赞和收藏状态
↓
组装双列瀑布流卡片
↓
返回下一页游标
个人主页、作品管理、收藏列表、浏览记录、关注列表和粉丝列表虽然展示内容不同,但数据库层遵循相似原则:先确定数据范围,再建立匹配查询条件的复合索引;先分页获取主数据,再批量补充关联信息;公共内容可以缓存,个性化关系单独查询。
MySQL保存作品、用户与互动关系,Redis缓存高频公共数据,EasyES处理关键词搜索,Druid观察 SQL 和连接状态,Quartz负责统计校验,Shiro控制接口权限与数据归属。职责划分清楚后,仿小红书源码中的个人主页查询链路才能在作品、收藏和粉丝数据持续增长时保持稳定。