仿小红书源码中的个人主页,通常需要展示头像、昵称、简介、主页封面、作品数量、获赞数、关注数、粉丝数,以及图文和短视频组成的双列瀑布流。
进入自己的个人中心后,查询内容还会扩展到作品管理、收藏管理、浏览记录、关注列表、粉丝列表和账号设置。页面结构看起来并不复杂,后台却可能同时访问用户表、内容表、媒体表、点赞表、收藏表、浏览记录表和关注关系表。
如果每个区域都单独查询,并在循环中继续补充封面、话题和互动状态,一次主页请求很容易产生几十条 SQL。更合理的方式是先拆分查询职责,再通过复合索引、游标分页和批量组装控制数据库访问,最后结合 Druid 观察 SQL 执行时间与连接占用。
个人主页请求应拆成固定查询步骤
一次主页请求大致包含四类数据:
用户基础资料 ↓ 作品、获赞、关注和粉丝统计 ↓ 公开作品分页数据 ↓ 当前访问者的关注、点赞和收藏状态用户资料和统计数据只需读取一次,作品列表通过索引分页获取,封面、话题和互动状态则根据作品 ID 批量补充。
宠友社区这种方式虽然不是只执行一条 SQL,但查询次数相对固定,不会随着作品数量不断增加。
相反,如果把用户、内容、媒体、话题、点赞和收藏全部写进一条关联 SQL,一篇包含多张图片和多个话题的作品可能产生大量重复行,还会影响分页与排序。
内容表复合索引要贴合查询条件
个人主页作品列表通常按照作者、内容状态和发布时间筛选:
user_id = 主页作者 status = PUBLISHED publish_time = 倒序如果只为这些字段分别建立单列索引,MySQL 不一定能同时高效利用。对于固定的高频查询,可以建立复合索引:
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 作为第二排序字段,可以避免多篇作品发布时间相同时出现顺序不稳定。
复合索引并不是字段越多越好。个人主页、内容分类页和话题页的筛选条件不同,应分别根据实际查询建立索引,而不是让一条索引承担所有列表接口。
游标分页比深分页更适合作品列表
传统分页通常使用:
LIMIT offset, pageSize页码增加后,数据库需要跳过越来越多的数据。例如查询LIMIT 20000, 20时,前面的记录虽然不会返回,但仍需要被定位。
仿小红书源码中的作品列表、收藏记录、浏览记录和粉丝列表更适合游标分页。
客户端第一次请求不传游标,接口返回当前批次数据和下一页位置:
cursorTime = 2026-07-18 10:15:30 cursorId = 28610后续查询从该记录之后继续读取,不再依赖持续增大的页码偏移量。
不同列表可以采用不同游标:
- 作品列表使用发布时间和内容 ID;
- 收藏列表使用收藏时间和记录 ID;
- 浏览记录使用最近浏览时间和记录 ID;
- 粉丝列表使用关注时间和关系 ID。
接口返回结构可以统一为records、nextCursor和hasMore。
批量组装解决N+1查询
作品列表查询到二十条内容后,还需要补充媒体封面、话题标签、点赞状态和收藏状态。
如果在 Java 循环中逐条查询,调用过程可能变成:
1次作品查询 20次媒体查询 20次话题查询 20次点赞状态查询 20次收藏状态查询这类 N+1 查询会快速占用数据库连接。更合适的方式是先收集作品 ID,再按照数据类型批量读取。
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条件长度。列表接口按照正常分页大小处理即可,大规模数据修复和定时任务则应分批执行。
双列瀑布流需要返回封面宽高
个人主页作品通常采用双列瀑布流。图片比例不同,卡片高度也不同。
如果后台只返回图片地址,客户端必须等图片加载完成后才能确定高度,容易出现卡片跳动和页面重新排版。
媒体表建议保存:
file_url width height media_type sort_number cover_flag图文内容可以选取第一张图片作为封面,短视频则使用独立封面。客户端根据列宽与原始比例提前计算展示高度:
展示高度 = 卡片宽度 × 原图高度 ÷ 原图宽度多图内容还可以返回图片数量,短视频返回播放时长。完整媒体列表只在内容详情页加载,主页瀑布流不需要返回全部图片。
用户统计不应每次实时计算
个人主页中的作品数、获赞数、关注数和粉丝数,如果每次都执行COUNT或SUM,会频繁扫描内容表和关系表。
可以维护独立的用户统计表:
| 字段 | 含义 |
|---|---|
| content_count | 公开作品数 |
| received_like_count | 累计获赞数 |
| following_count | 关注数 |
| follower_count | 粉丝数 |
| collect_count | 收藏数 |
发布、隐藏、点赞、取消点赞、关注和取消关注成功后,更新对应统计字段。
统计表只负责提高主页查询效率,内容表、点赞表和关注表仍然保存真实关系。Quartz 可以定期分批核对统计值,修正少量异常。
统计更新还需要避免并发覆盖。例如多个点赞请求同时读取旧值再写回,可能出现数量丢失。更适合直接执行数据库原子更新:
UPDATE user_statistics SET received_like_count = received_like_count + 1 WHERE user_id = ?关注与粉丝可以共用一张关系表
关注和粉丝只是同一条关系的两个方向。
关系表可以保存:
user_id 发起关注用户 target_id 被关注用户 status create_time id查询关注列表时使用user_id,查询粉丝列表时使用target_id,并分别建立复合索引:
(user_id, status, create_time, id) (target_id, status, create_time, id)不需要额外维护一张粉丝表,否则关注和取消关注时需要同步两份数据。
同一用户不能重复关注同一目标,可以增加唯一约束:
UNIQUE KEY uk_user_target (user_id, target_id)应用层可以提前判断关系是否存在,数据库唯一约束则负责处理并发请求下的重复写入。
粉丝列表需要展示是否已经回关时,可以先查询当前页粉丝 ID,再批量判断反向关注关系,不能逐条执行查询。
收藏与内容状态必须分开判断
收藏记录表示用户收藏过某篇作品,并不代表作品始终公开。
内容可能被作者隐藏、删除或后台下架。收藏关系可以继续保留,但列表返回时要检查内容当前状态,可以过滤失效记录,也可以显示为不可访问状态。
收藏列表可以建立:
(user_id, status, collect_time, id)用户执行收藏时,后台还要确认作品仍处于公开状态。已删除或下架内容不能新增收藏关系。
点赞、评论和回复也应遵循同样规则,不能只根据前端页面是否显示按钮决定能否操作。
浏览记录避免重复插入
用户可能多次打开同一篇图文或短视频。
如果每次访问都新增一行,浏览记录表会快速增长,个人历史列表也会出现大量重复内容。
可以为user_id + content_id建立唯一约束,重复浏览时更新访问次数和最近浏览时间:
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倒序展示。内容推荐可以读取近期浏览分类和话题,但浏览历史仍然作为独立用户数据保存。
自己的作品管理与公开主页查询不同
访问他人主页时,只能查询公开作品:
user_id = 目标用户 status = PUBLISHED进入自己的作品管理时,才可以读取草稿、审核中、已隐藏或未通过内容。
这种数据范围必须放在服务端查询条件中,不能只依赖前端隐藏页面。
Shiro 可以控制用户是否具有作品管理权限,业务层仍要确认作品作者与当前登录用户一致。收藏管理、浏览记录、资料编辑和账号设置也只能操作当前用户的数据。
内容搜索不需要复用主页SQL
个人主页已经明确知道作者 ID,使用 MySQL 复合索引即可。
用户输入关键词搜索标题、正文、话题或商品时,更适合交给 EasyES。两类查询的职责应保持清晰:
| 查询场景 | 处理方式 |
| 作者公开作品 | MySQL复合索引 |
| 个人作品管理 | MySQL状态索引 |
| 收藏与浏览记录 | MySQL关系表 |
| 关注与粉丝列表 | MySQL关系索引 |
| 内容关键词搜索 | EasyES |
| 商品关键词搜索 | EasyES |
搜索结果返回内容 ID 后,仍然需要检查数据库中的内容状态和访问权限。搜索索引不能代替数据库中的最终业务判断。
Redis缓存公共数据,不混入个性化状态
用户资料、主页统计和公开作品卡片属于公共数据,可以使用 Redis 缓存。
但以下状态与当前访问者有关:
- 是否关注主页作者;
- 是否点赞某篇作品;
- 是否收藏某篇作品。
这些字段不能直接写进公共主页缓存,否则不同用户可能读取到错误状态。
更适合的组装方式是:
公共主页缓存 + 当前用户关系状态 = 最终返回数据缓存用于降低重复读取,不能代替复合索引和批量查询。SQL 本身存在全表扫描或 N+1 查询时,增加缓存只能暂时掩盖问题。
Druid需要同时观察速度和查询数量
Druid 可以记录 SQL 执行时间、调用次数、连接等待和活跃连接数量。
个人主页链路可以重点关注:
- 单条 SQL 平均耗时;
- 单次请求执行的 SQL 数量;
- 是否出现全表扫描;
- 是否使用临时表和文件排序;
- 连接是否及时归还;
- 事务持续时间;
- 相同 SQL 的调用频率。
有些接口没有明显慢 SQL,但一次请求执行几十条短 SQL,总耗时仍然较高。
因此,排查时应同时确认:
SQL是否命中索引 SQL数量是否合理 数据库连接是否及时释放连接池最大连接数也不能盲目调大。若接口存在长事务或循环查询,扩大连接池只会让数据库同时处理更多低效请求。
优化后的个人主页执行轨迹
经过索引、分页和数据组装调整后,一次主页请求可以按照以下顺序运行:
读取用户资料 ↓ 读取用户统计表 ↓ 判断当前访问者关注状态 ↓ 通过复合索引查询作品列表 ↓ 批量查询封面与话题 ↓ 批量查询点赞和收藏状态 ↓ 组装双列瀑布流卡片 ↓ 返回下一页游标个人主页、作品管理、收藏列表、浏览记录、关注列表和粉丝列表虽然展示内容不同,但数据库层遵循相似原则:先确定数据范围,再建立匹配查询条件的复合索引;先分页获取主数据,再批量补充关联信息;公共内容可以缓存,个性化关系单独查询。
MySQL保存作品、用户与互动关系,Redis缓存高频公共数据,EasyES处理关键词搜索,Druid观察 SQL 和连接状态,Quartz负责统计校验,Shiro控制接口权限与数据归属。职责划分清楚后,仿小红书源码中的个人主页查询链路才能在作品、收藏和粉丝数据持续增长时保持稳定。
技术实现思路的简要归纳湖南宠友信息技术有限公司是一家专注社区交友类产品、企业即时通信软件开发,为企业提供即时通信工具、垂直类内容圈子,自主研发的业界知名友猫产品拥有广大的企业用户群体https://chongyou.info/1/product/xhs.html