朋友圈模块表面上是发布动态、点赞和评论,但一旦把好友关系、可见性、时间线聚合和消息触发放到一起,它就是一个典型的读写模型。很多团队第一版用几张关联表循环查询,在好友数和动态量不大时可以运行,可用户量上来后接口开始出现慢查询、深分页和计数不准。本文从数据模型、时间线方案、点赞评论幂等和高并发推送四个角度拆解一条朋友圈从发布到被好友点赞评论的完整链路,并给出可以直接参考的SQL和后台代码。

一、数据模型:先把动态、好友关系和可见性拆清楚
朋友圈的数据不能只围绕动态表展开。一个能支撑后续扩展的模型,至少要把用户、好友关系、动态、可见性规则、点赞记录和评论记录分开。动态表负责内容主体,不直接冗余点赞数和评论数,否则高并发更新会导致行锁竞争和计数丢失。好友关系表要表达双向好友还是单向关注,朋友圈通常基于双向好友,但可以保留状态字段方便以后扩展关注场景。可见性单靠一个visibility字段不够,因为存在公开、好友可见、部分可见、不给谁看等多种规则,并且要区分进主页看的场景和朋友圈时间线场景。
下面是核心表的简化结构,索引设计以高频查询为主,尤其要避免在时间线聚合时对动态表做随机深分页。
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `nickname` varchar(64) NOT NULL, `avatar` varchar(255) DEFAULT NULL, `create_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `friendship` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `friend_id` bigint NOT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1正常 2拉黑', `create_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (`id`), UNIQUE KEY `uk_user_friend` (`user_id`,`friend_id`), KEY `idx_friend_user` (`friend_id`,`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `post` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL, `content` varchar(2000) DEFAULT NULL, `media` json DEFAULT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1正常 2删除', `create_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`,`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `visibility_rule` ( `id` bigint NOT NULL AUTO_INCREMENT, `post_id` bigint NOT NULL, `rule_type` tinyint NOT NULL COMMENT '1公开 2好友可见 3指定好友 4不给谁看', `target_user_id` bigint DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_post` (`post_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `post_like` ( `id` bigint NOT NULL AUTO_INCREMENT, `post_id` bigint NOT NULL, `user_id` bigint NOT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1点赞 0取消', `create_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), `update_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), PRIMARY KEY (`id`), UNIQUE KEY `uk_post_user` (`post_id`,`user_id`), KEY `idx_user_time` (`user_id`,`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `post_comment` ( `id` bigint NOT NULL AUTO_INCREMENT, `post_id` bigint NOT NULL, `user_id` bigint NOT NULL, `reply_to_user_id` bigint DEFAULT NULL, `root_comment_id` bigint DEFAULT NULL, `content` varchar(500) NOT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1正常 2删除', `create_time` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (`id`), KEY `idx_post_time` (`post_id`,`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
为什么点赞表和评论表不放在post表里做计数?因为朋友圈的时间线读取频率远高于发布频率,如果每次点赞都更新post表,容易造成热点行的写锁竞争。把计数逻辑拆到Redis或独立的计数表,主流程只做必要的状态变更,读取侧再通过缓存或异步汇总得到计数,整体扩展性会更好。
二、发布与时间线聚合:读扩散为主,写扩散补充活跃用户
时间线有两种经典实现。读扩散是用户打开朋友圈时,实时查询所有好友近期动态,再合并排序。优点是好友关系变更后即时生效,存储成本低;缺点是好友太多时查询压力会明显上升。写扩散是发布时把动态ID写入每个好友的收件箱,读取时只需要查自己的收件箱,速度快,但发布一次要写几十甚至几百份数据,存储和写放大会很高。
朋友圈的通用做法是读扩散为主、写扩散补充。普通用户的好友规模在百人级别,读时聚合完全没问题;对好友数量大或者更新频繁的用户,可以异步把动态推送到部分活跃粉丝的收件箱。发布接口的核心步骤是写动态表、写可见性规则、更新个人时间线缓存,再根据用户等级判断是否需要异步推送收件箱。
func PublishPost(ctx context.Context, userID int64, content string, media []string, visibility int, targets []int64) error {
postID, err := insertPost(ctx, userID, content, media)
if err != nil {
return err
}
if err := insertVisibilityRule(ctx, postID, visibility, targets); err != nil {
return err
}
if err := addToUserTimeline(ctx, userID, postID); err != nil {
return err
}
if isHighFrequencyUser(userID) {
go pushToActiveFriendInbox(ctx, userID, postID)
}
return nil
}
func addToUserTimeline(ctx context.Context, userID, postID int64) error {
key := fmt.Sprintf("timeline:user:%d", userID)
score := float64(time.Now().UnixMilli())
return redis.ZAdd(ctx, key, &redis.Z{Score: score, Member: postID}).Err()
}
读取朋友圈时,先从好友列表拿到一批好友ID,再逐个读取它们的个人时间线缓存,获取近期的post_id,最后按时间倒序合并分页。为了避免每个好友发起一次Redis请求,可以将好友分批后用pipeline合并查询。个人时间线缓存可以用Redis的Sorted Set,score存时间戳,member存动态ID,控制每个用户最多保存最近500条动态,超过时用ZREMRANGEBYRANK修剪。这样查询复杂度从SQL聚合多表降低为缓存读取加批量回源。
好友关系变更对这个模型影响很大。新增好友后,对方历史动态需要能被看到,因此个人时间线缓存不能只靠发布时写入,还应支持按需回源。删除好友或拉黑时,时间线查询必须实时校验好友关系,不能只依赖缓存中的旧数据。通常可以在合并结果前,用一次批量查询过滤掉已经解除关系的好友ID,再从缓存中删除对应内容。
三、点赞和评论:用唯一索引和Redis解决幂等与计数
点赞接口最怕重复点击和并发点击。同一个用户在短时间内连续点两次,如果代码先查再改,两个请求都查不到已赞状态,就会产生两条点赞记录或计数加两次。正确的做法是在数据库层用唯一索引兜底,在缓存层用Set或Lua脚本保证原子性。评论虽然没有重复点击问题,但计数同样需要避免直接更新post表,并且要支持楼中楼回复。
点赞表上的联合唯一索引(post_id, user_id)是最后一道防线。即使缓存判断失效,数据库也不会允许重复插入。取消点赞时,不推荐物理删除后立刻重建,因为频繁删除会放大索引抖动,可以保留status字段,用软删除方式记录状态变化。点赞状态和数量可以通过Redis维护,key设计为like:post:{postId},Set存点赞用户ID,SCARD直接取数量。接口先操作Redis,再异步同步到MySQL,保证主流程快速返回。
func ToggleLike(ctx context.Context, postID, userID int64) (bool, int64, error) {
key := fmt.Sprintf("like:post:%d", postID)
liked, err := redis.SIsMember(ctx, key, userID).Result()
if err != nil {
return false, 0, err
}
if liked {
if err := redis.SRem(ctx, key, userID).Err(); err != nil {
return false, 0, err
}
} else {
if err := redis.SAdd(ctx, key, userID).Err(); err != nil {
return false, 0, err
}
}
count, err := redis.SCard(ctx, key).Result()
if err != nil {
return false, 0, err
}
go syncLikeToDB(postID, userID, liked == false)
return !liked, count, nil
}
上面的代码在Redis层面判断并切换点赞状态,MySQL落库放在goroutine中异步进行。如果Redis操作成功但异步落库失败,会出现短暂不一致,可以通过定时对账任务扫描数据库和Redis的差异来补偿。评论接口则不同,评论内容需要立刻写入数据库,计数可以缓存。评论表用post_id加create_time做联合索引,支持按时间倒序分页。回复评论时记录root_comment_id和reply_to_user_id,前端可以按根评论聚合展示。
评论接口还要做内容校验、限流和审核。用户连续刷评论会打爆数据库,可以在接口层用令牌桶或滑动窗口限制每个用户每分钟评论次数。对敏感词先本地过滤,再异步送审,命中风险后先隐藏评论。评论数同样用Redis的String或Hash维护,新增评论时INCR,删除评论时DECR,异步回写评论表或计数表。
四、高并发下的推送与未读数:异步化、聚合和防打扰
点赞和评论会触发消息通知,但主流程不能等待推送完成。常见的做法是把通知事件写入消息队列,由消费者异步推送。点赞事件比评论事件更频繁,如果每点一个赞都推一条,用户会很烦,推送服务压力也大。可以按动态维度做短时聚合,例如一个动态在1分钟内收到多个点赞,只推送一次,文案合并为“张三、李四等5人赞了你的动态”。评论则按条推送,但同样要做限流和去重。
未读数维护在Redis Hash中,key设计为unread:{userId},field可以用动态ID或者消息类型,value存未读数量。收到点赞或评论事件时,先对事件指纹做去重,再HINCRBY增加未读数。用户打开消息中心或查看动态详情时,通过Lua脚本清零对应field。不要每次未读数变化都写数据库,Redis就能支撑高并发的未读读写。
func ConsumeNotification(ctx context.Context, msg Message) error {
if !dedupeExists(msg.EventID) {
return nil
}
field := fmt.Sprintf("post:%d", msg.PostID)
if err := redis.HIncrBy(ctx, fmt.Sprintf("unread:%d", msg.ToUserID), field, 1).Err(); err != nil {
return err
}
text := aggregateLikeText(msg.PostID, msg.ActorID)
return pushToUser(msg.ToUserID, text)
}
消费者处理时要考虑重复消费和顺序。消息队列通常有至少一次投递语义,所以事件指纹去重很重要。对于同一个动态的点赞聚合,可以先把事件暂存到Redis List或滑动窗口,由定时任务批量生成推送。未被查看的未读数在用户下次打开朋友圈时汇总展示,如果用户已经在线,可以通过长连接实时下发,离线用户则依赖厂商推送通道。
热点动态要单独设计。一个高粉用户的动态发布后,点赞事件可能瞬间达到每秒几万次,Redis Set会变得非常大。此时不应继续存全量点赞用户,可以只保留Redis counter,判断当前用户是否点赞再走数据库查询。评论列表也要做热点缓存,把前几页评论缓存起来,减少数据库压力。对特别热的动态,可以用singleflight或互斥锁避免缓存击穿,配合限流保护下游。
朋友圈发布、点赞和评论模块如果一开始就把数据模型、缓存结构和异步链路划分清楚,后续接入图片视频、广告推荐和多端同步时会从容很多。工程上真正的难点不在单条SQL写得多漂亮,而在于读扩散的时间线怎样控制延迟,点赞评论怎样做到幂等和最终一致,以及通知推送怎样在用户体验和系统压力之间取得平衡。