SChill项目面试题整理

Interaction服务

问题1:在这个项目中,点赞功能使用了Redis Lua脚本来实现,请解释:

  1. 为什么需要使用Lua脚本而不是普通的Redis命令?

  2. 这段Lua脚本是如何保证原子性和幂等性的?

  3. 如果Lua脚本执行失败,应该如何处理?

参考答案 :

  1. 为什么使用Lua脚本 :
  • 原子性 :Lua脚本在Redis中是原子执行的,不会被其他命令打断,可以保证多个操作的一致性

  • 减少网络开销 :将多次Redis操作合并到一个脚本中执行,减少网络往返时间

  • 复杂逻辑 :可以在脚本中实现更复杂的业务逻辑判断

  1. 原子性和幂等性保证 :
Lua 复制代码
-- 检查用户是否已经存在于点赞集合中(幂等性关键)
local is_member = redis.call('SISMEMBER', KEYS[1], ARGV[1])

-- 如果用户已经点赞,直接返回当前计数(不执行写操作)
if is_member == 1 then
    local current_count = redis.call('GET', KEYS[2])
    return {1, tonumber(current_count or 0)}
end

-- 执行点赞操作(原子性)
redis.call('SADD', KEYS[1], ARGV[1])  -- 加入集合
local new_count = redis.call('INCR', KEYS[2])  -- 计数器+1

return {0, new_count}

原子性 :整个Lua脚本在Redis中作为一个整体执行,中间不会插入其他命令 幂等性 :通过 SISMEMBER 检查用户是否已点赞,避免重复计数

  1. 失败处理 :
  • Redis客户端会返回错误,应用层需要捕获并处理

  • 可以选择:重试、降级到数据库操作、记录日志告警

  • 确保最终一致性:通过消息队列异步补偿或定期对账

问题2:缓存版本控制策略

项目中使用了缓存版本号机制(如 PostCacheVersionKey ),请解释:

  1. 这种缓存失效策略的原理是什么?

  2. 相比直接删除缓存key,这种方式有什么优缺点?

  3. 在什么场景下适合使用这种策略?

参考答案 :

  1. 原理 :
Go 复制代码
// common/redis/keys.go:16
PostCacheVersionKey = KeyPrefix + "post:cache_version:"

// service/content/rpc/internal/logic/cache_helper.go:34-40
func buildPostDetailCacheKey(ctx context.Context, svcCtx *svc.ServiceContext, postID uint64) string {
    return fmt.Sprintf("%s%s:v%s", redis.PostInfoKey, strconv.FormatUint(postID, 10), 
        getPostCacheVersion(ctx, svcCtx, postID))
}

// 失效缓存时,只需要增加版本号
func invalidatePostCaches(ctx context.Context, svcCtx *svc.ServiceContext, postID uint64) {
    _, _ = svcCtx.Redis.Incr(ctx, fmt.Sprintf("%s%d", redis.PostCacheVersionKey, postID))
}

工作流程 :

  • 缓存key中包含版本号: schill:post:info:{id}:v{version}

  • 需要失效缓存时,不删除key,而是 INCR 版本号

  • 下次读取时使用新版本号,自然读取不到旧缓存

  1. 优缺点 :

优点 :

  • 避免缓存击穿:旧缓存key仍然存在,直到过期,大量并发请求不会同时打到数据库

  • 实现简单:只需要维护一个版本号key

  • 可以优雅降级:即使版本号操作失败,仍可使用旧版本缓存

  • 减少网络开销:不需要遍历和删除多个相关缓存key

缺点 :

  • 内存占用:旧缓存key会在Redis中保留直到过期

  • 缓存不一致窗口期:在版本号更新后,旧缓存仍然有效(短暂时间)

  • 需要额外存储:每个实体需要维护一个版本号key

  1. 适用场景 :
  • 读多写少的场景

  • 对一致性要求不是特别严苛(接受短暂不一致)

  • 需要防止缓存击穿的热点数据

  • 缓存key较多,失效成本高的场景

问题3:消息队列的异步处理模式

项目中使用Kafka处理各种事件(如 PostStarMessage ),请分析:

  1. 为什么点赞成功后要发送消息而不是直接更新数据库?

  2. 这种异步模式可能会带来什么问题?如何解决?

  3. 消息发送失败怎么办?

参考答案 :

  1. 为什么使用异步 :
Go 复制代码
// service/interaction/rpc/internal/logic/starpostlogic.go:59-78
if status == 0 {
    go func() {
        msg := mq.PostStarMessage{
            PostID:    in.PostId,
            UserID:    in.UserId,
            Timestamp: timestamp,
        }
        if err := l.svcCtx.KafkaProducer.SendEvent(...); err != nil {
            logx.Errorf("send post star event failed: %v", err)
        }
    }()
}

原因 :

  • 性能优化 :Redis操作很快,用户可以立即得到响应,数据库更新异步进行

  • 解耦 :互动服务不需要直接依赖内容服务的数据库更新逻辑

  • 削峰填谷 :高并发点赞时,消息队列可以缓冲请求

  • 扩展性 :可以有多个消费者处理同一事件(如更新计数、生成Feed、通知等)

  1. 异步模式的问题与解决方案 :

comment服务

问题:评论投票 Lua 脚本原子性实现

问题 :分析 votecommentlogic.go 中的 Lua 脚本:

  1. 这个脚本解决了什么问题?

  2. 如何保证幂等性?

  3. 如果 Redis 宕机,数据如何恢复?

    const voteScript = `
    local voteKey = KEYS[1]
    local infoKey = KEYS[2]
    local newVote = ARGV[1]
    local expire = ARGV[2]

    -- 获取旧的投票状态
    local oldVote = redis.call('get', voteKey)
    if not oldVote then
    oldVote = '0'
    end

    -- 如果状态没有变化,直接返回当前计数
    if oldVote == newVote then
    local likeCount = redis.call('hget', infoKey, 'like_count') or '0'
    local dislikeCount = redis.call('hget', infoKey, 'dislike_count') or '0'
    return {likeCount, dislikeCount, newVote}
    end

    -- 计算计数变化
    local likeDelta = 0
    local dislikeDelta = 0

    -- 减去旧状态的计数
    if oldVote == '1' then
    likeDelta = -1
    elseif oldVote == '2' then
    dislikeDelta = -1
    end

    -- 加上新状态的计数
    if newVote == '1' then
    likeDelta = likeDelta + 1
    elseif newVote == '2' then
    dislikeDelta = dislikeDelta + 1
    end

    -- 应用修改
    if newVote == '0' then
    redis.call('del', voteKey)
    else
    redis.call('set', voteKey, newVote)
    redis.call('expire', voteKey, expire)
    end

    if likeDelta ~= 0 then
    redis.call('hincrby', infoKey, 'like_count', likeDelta)
    end
    if dislikeDelta ~= 0 then
    redis.call('hincrby', infoKey, 'dislike_count', dislikeDelta)
    end

    -- 返回最新的计数
    local likeCount = redis.call('hget', infoKey, 'like_count') or '0'
    local dislikeCount = redis.call('hget', infoKey, 'dislike_count') or '0'

    return {likeCount, dislikeCount, newVote}
    `

  4. 解决的问题 :

  • 原子性 :多个 Redis 操作原子执行,防止并发问题

  • 数据一致性 :投票状态和计数保持一致

  • 性能优化 :减少网络开销

  1. 幂等性保证 :

```

-- 检查状态是否变化

if oldVote == newVote then

-- 直接返回,不执行修改

return {likeCount, dislikeCount, newVote}

end

```

  • 相同请求多次执行结果一致

  • 不会重复计数

  1. Redis 宕机恢复策略 :
  • 方案A:Kafka 事件回溯 (当前实现)

    // 1. 投票先写 Redis,然后发送 Kafka
    go func() {
    if err := l.svcCtx.KafkaProducer.SendMessage(
    l.svcCtx.Config.KqProducerConf.TopicCommentVote,
    voteEvent); err != nil {
    logx.Errorf("发送投票事件消息失败: %v", err)
    }
    }()

    // 2. Consumer 消费消息落库
    func (c *CommentConsumer) handleVoteEvent(event mq.VoteEvent) error {
    // 数据库持久化投票记录
    // 更新计数
    }

方案B:定期对账

复制代码
func reconcileVoteCounts() {
    // 1. 扫描数据库最近1小时的投票记录
    // 2. 与 Redis 中的计数对比
    // 3. 不一致时以数据库为准
}

方案C:降级处理

复制代码
// 当前代码已实现降级
if err != nil {
    logx.Errorf("执行投票Lua脚本失败: %v", err)
    // 降级到数据库处理
    return l.voteCommentDB(in)
}

问题:游标分页 vs Offset 分页对比

问题 :看 comment 服务的分页实现:

  1. 为什么使用 cursor 分页而不是 offset?

  2. 这种实现有什么局限性?

  3. 如何支持跳转到第 N 页?

参考答案 :

  1. Cursor 分页的优势

    // GetCommentList 使用游标分页
    func (l *GetCommentListLogic) GetCommentList(in *pb.GetCommentListReq) (*pb.GetCommentListResp, error) {
    // 使用 ZRangeByScore 游标查询
    maxScore := "+inf"
    minScore := "-inf"
    if cursor > 0 {
    maxScore = fmt.Sprintf("(%d", cursor)
    }

    复制代码
     idStrings, err := l.svcCtx.Redis.ZRevRangeByScore(
         ctx, key, minScore, maxScore, 0, pageSize+1)

    }

Cursor 分页优势 :

  • 性能稳定 :OFFSET 会扫描前 N 行后丢弃,数据量大时很慢

  • 一致性好 :不受数据插入删除影响

  • 实现简单 :Redis ZSet 原生支持

Offset 分页问题 :

复制代码
-- offset 分页的性能问题
SELECT * FROM comment LIMIT 1000000, 20; 
-- 需要扫描并扔掉前 1000000 行
  1. Cursor 分页的局限性 :
  • 无法跳转到任意页 :只能翻前/翻后

  • 不支持页码显示 :没有页码概念

  • 删除的评论会导致结果重复或丢失

  1. 支持跳转到第 N 页的方案

    // 方案A:混合方案
    func getCommentListHybrid(in *pb.GetCommentListReq) (*pb.GetCommentListResp, error) {
    if in.Cursor > 0 {
    // 使用游标分页
    return getCommentListByCursor(in)
    }

    复制代码
     if in.Page > 0 && in.Page <= 10 {
         // 前10页支持 offset 分页,预加载
         return getCommentListByOffset(in)
     }
     
     // 超过10页引导用户使用搜索
     return nil, errors.New("请使用搜索或缩小范围")

    }

    // 方案B:构建页码索引(适合静态内容)
    func buildPageIndex(postID uint64, sortType string) {
    // 预计算每页的第一个评论ID
    key := buildCommentListKey(postID, sortType)
    commentIDs, _ := redis.ZRange(ctx, key, 0, -1)

    复制代码
     for i := 0; i < len(commentIDs); i += pageSize {
         pageIndexKey := fmt.Sprintf("comment:page_index:%s:%s:%d", 
             postID, sortType, i/pageSize + 1)
         redis.Set(ctx, pageIndexKey, commentIDs[i], 1*time.Hour)
     }

    }

问题 :

  • 用户体验差 :删除成功但评论还在列表里(直到 Consumer 处理)

  • 数据不一致 :数据库删除但缓存未清理干净

  • 没有处理子回复 :子回复变成孤儿

  1. 子回复的处理策略 :

选项A:级联软删除 (推荐)

复制代码
func deleteCommentWithReplies(commentID uint64) error {
    // 找到所有子回复
    var allReplyIDs []uint64
    var queue = []uint64{commentID}
    
    for len(queue) > 0 {
        currentID := queue[0]
        queue = queue[1:]
        allReplyIDs = append(allReplyIDs, currentID)
        
        var replies []*model.Comment
        db.Where("parent_id = ? AND deleted_at IS NULL", currentID).Find(&replies)
        
        for _, r := range replies {
            queue = append(queue, r.ID)
        }
    }
    
    // 批量软删除
    now := time.Now()
    db.Model(&model.Comment{}).Where("id IN ?", allReplyIDs).
        Updates(map[string]interface{}{
            "deleted_at": &now,
            "status": 3,
        })
    
    // 批量清理缓存
    for _, id := range allReplyIDs {
        redis.Del(ctx, fmt.Sprintf("%s%d", redis.CommentInfoKey, id))
        redis.Del(ctx, fmt.Sprintf("%s%d", redis.CommentContentKey, id))
    }
}

选项B:显示"已删除"占位符

复制代码
// 不删除子回复,只标记父评论
// 前端显示:此评论已删除

问题:评论限流与防刷机制

问题 :看投票逻辑中的限流:

  1. 当前的防刷机制够吗?

  2. 如何防止恶意刷赞?

  3. 如何实现基于 IP 和设备的复杂限流?

参考答案 :

  1. 当前实现分析

    // votecommentlogic.go
    date := time.Now().Format("20060102")
    userVoteKey := fmt.Sprintf("%s%d:%s", redis.UserVoteCountKey, in.UserId, date)
    voteCount, err := l.svcCtx.Redis.Incr(l.ctx, userVoteKey)
    if err != nil {
    logx.Errorf("用户投票计数失败: %v", err)
    // 不影响主流程
    } else {
    // 设置过期时间
    if voteCount == 1 {
    l.svcCtx.Redis.Expire(l.ctx, userVoteKey, time.Hour*24)
    }
    // 每日最大 200 次
    if voteCount > 200 {
    return nil, errutil.RpcBusinessError(errutil.ErrTooManyRequests)
    }
    }

当前的不足 :

  • 只限制了用户级别的频率

  • 没有 IP 限制

  • 没有设备指纹

  • 没有异常行为检测

  • 没有滑动窗口限流

  1. 恶意刷赞防护方案

    // 多级限流
    func checkRateLimit(userID uint64, commentID uint64, ip string) error {
    ctx := context.Background()

    复制代码
     // 1. 同一用户对同一评论限制
     key1 := fmt.Sprintf("vote:limit:user:%d:%d", userID, commentID)
     if cnt, _ := redis.Incr(ctx, key1); cnt > 1 {
         return errors.New("不能重复投票")
     }
     redis.Expire(ctx, key1, 24*time.Hour)
     
     // 2. 用户频率限制(滑动窗口)
     key2 := fmt.Sprintf("vote:limit:user:%d", userID)
     now := time.Now().Unix()
     redis.ZAdd(ctx, key2, redis.Z{Score: float64(now), Member: strconv.FormatInt(now, 10)})
     redis.ZRemRangeByScore(ctx, key2, "0", fmt.Sprintf("%d", now-3600)) // 只保留1小时内
     if cnt, _ := redis.ZCard(ctx, key2); cnt > 100 {
         return errors.New("投票太频繁")
     }
     redis.Expire(ctx, key2, 2*time.Hour)
     
     // 3. IP 限制
     key3 := fmt.Sprintf("vote:limit:ip:%s", ip)
     if cnt, _ := redis.Incr(ctx, key3); cnt > 500 {
         return errors.New("IP 受限")
     }
     redis.Expire(ctx, key3, time.Hour)
     
     // 4. 同一评论短时间内大量投票
     key4 := fmt.Sprintf("vote:limit:comment:%d", commentID)
     windowStart := now - 60 // 1分钟窗口
     redis.ZAdd(ctx, key4, redis.Z{Score: float64(now), Member: strconv.FormatUint(userID, 10)})
     redis.ZRemRangeByScore(ctx, key4, "0", fmt.Sprintf("%d", windowStart))
     if cnt, _ := redis.ZCard(ctx, key4); cnt > 50 {
         return errors.New("评论投票异常")
     }
     redis.Expire(ctx, key4, 5*time.Minute)
     
     return nil

    }

  2. 高级异常行为检测

    func detectAbnormalBehavior(userID uint64) {
    // 1. 检查用户是否在短时间内对大量不同评论投票
    recentVotes := getRecentVotes(userID, time.Hour)
    if len(recentVotes) > 200 {
    flagUser(userID, "high_frequency_voter")
    }

    复制代码
     // 2. 检查用户投票模式是否异常(只点赞,或只点踩)
     likeRatio := calculateLikeRatio(recentVotes)
     if likeRatio > 0.95 || likeRatio < 0.05 {
         flagUser(userID, "suspicious_voting_pattern")
     }
     
     // 3. 检查新账号异常活跃
     userProfile := getUserProfile(userID)
     if userProfile.CreatedAt.After(time.Now().Add(-24*time.Hour)) && len(recentVotes) > 50 {
         flagUser(userID, "new_account_abnormal_activity")
     }

    }

问题:评论排序热更新问题

问题 :当一个评论的点赞数变化后,如何更新 Redis 中的排序?

  1. 当前实现如何处理?

  2. 有什么更高效的方案?

  3. 如何防止排序抖动?

参考答案 :

  1. 当前实现分析

当前代码中,投票后只是让缓存失效:

复制代码
invalidatePostCommentListCache(l.ctx, l.svcCtx, comment.PostID)

这种做法的问题:

  • 缓存雪崩风险 :频繁失效缓存会大量查询数据库

  • 性能差 :每次点赞都可能触发重建

  • 用户体验差 :排序更新不及时

  1. 高效实时排序更新方案

    func updateCommentScoreRealTime(postID uint64, commentID uint64, deltaLikes int32) {
    ctx := context.Background()
    key := fmt.Sprintf("%s%d:hot", redis.PostCommentsKey, postID)

    复制代码
     // 1. 获取评论当前信息
     info, err := redis.HGetAll(ctx, fmt.Sprintf("%s%d", redis.CommentInfoKey, commentID)).Result()
     if err != nil || len(info) == 0 {
         return // 缓存未命中,等待重建
     }
     
     // 2. 计算新分数
     likeCount, _ := strconv.Atoi(info["like_count"])
     replyCount, _ := strconv.Atoi(info["reply_count"])
     createdAt, _ := strconv.ParseInt(info["created_at"], 10, 64)
     
     // 新分数(包含 delta)
     newScore := float64((likeCount+int(deltaLikes)) + replyCount*3)
     newScore -= float64(time.Now().Unix()-createdAt) / 3600
     
     // 3. 更新分数
     redis.ZAdd(ctx, key, redis.Z{Score: newScore, Member: commentID})
     
     // 4. 同时更新 info 缓存
     redis.HIncrBy(ctx, fmt.Sprintf("%s%d", redis.CommentInfoKey, commentID), 
         "like_count", int64(deltaLikes))

    }

  2. 防止排序抖动

    // 方案A:延迟更新 + 批量合并
    type pendingScoreUpdate struct {
    commentID uint64
    delta int32
    }

    var pendingUpdates = make(chan pendingScoreUpdate, 1000)

    func batchUpdateScores() {
    ticker := time.NewTicker(5 * time.Second)
    defer ticker.Stop()

    复制代码
     buffer := make(map[uint64]int32)
     
     for {
         select {
         case update := <-pendingUpdates:
             buffer[update.commentID] += update.delta
             
             // 缓冲区满或到时间,批量刷新
             if len(buffer) >= 100 {
                 flushBuffer(buffer)
                 buffer = make(map[uint64]int32)
             }
             
         case <-ticker.C:
             if len(buffer) > 0 {
                 flushBuffer(buffer)
                 buffer = make(map[uint64]int32)
             }
         }
     }

    }

    func flushBuffer(buffer map[uint64]int32) {
    // 按帖子分组,批量更新
    posts := groupByPost(buffer)
    for postID, comments := range posts {
    updatePostScores(postID, comments)
    }
    }

    // 方案B:阈值更新(只有变化足够大时才重新排序)
    func shouldUpdateScore(oldLikeCount int32, newLikeCount int32) bool {
    threshold := 0.1 // 10%的变化
    if oldLikeCount == 0 {
    return newLikeCount > 0
    }
    change := math.Abs(float64(newLikeCount-oldLikeCount)) / float64(oldLikeCount)
    return change > threshold
    }

relation服务

关注一个人但是同时他注销了怎么办?