SChill项目面试题整理
Interaction服务问题1在这个项目中点赞功能使用了Redis Lua脚本来实现请解释1. 为什么需要使用Lua脚本而不是普通的Redis命令2. 这段Lua脚本是如何保证原子性和幂等性的3. 如果Lua脚本执行失败应该如何处理参考答案 1. 为什么使用Lua脚本 - 原子性 Lua脚本在Redis中是原子执行的不会被其他命令打断可以保证多个操作的一致性- 减少网络开销 将多次Redis操作合并到一个脚本中执行减少网络往返时间- 复杂逻辑 可以在脚本中实现更复杂的业务逻辑判断2. 原子性和幂等性保证 -- 检查用户是否已经存在于点赞集合中幂等性关键 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 检查用户是否已点赞避免重复计数3. 失败处理 - Redis客户端会返回错误应用层需要捕获并处理- 可以选择重试、降级到数据库操作、记录日志告警- 确保最终一致性通过消息队列异步补偿或定期对账问题2缓存版本控制策略项目中使用了缓存版本号机制如 PostCacheVersionKey 请解释1. 这种缓存失效策略的原理是什么2. 相比直接删除缓存key这种方式有什么优缺点3. 在什么场景下适合使用这种策略参考答案 1. 原理 // 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 版本号- 下次读取时使用新版本号自然读取不到旧缓存2. 优缺点 优点 - 避免缓存击穿旧缓存key仍然存在直到过期大量并发请求不会同时打到数据库- 实现简单只需要维护一个版本号key- 可以优雅降级即使版本号操作失败仍可使用旧版本缓存- 减少网络开销不需要遍历和删除多个相关缓存key缺点 - 内存占用旧缓存key会在Redis中保留直到过期- 缓存不一致窗口期在版本号更新后旧缓存仍然有效短暂时间- 需要额外存储每个实体需要维护一个版本号key3. 适用场景 - 读多写少的场景- 对一致性要求不是特别严苛接受短暂不一致- 需要防止缓存击穿的热点数据- 缓存key较多失效成本高的场景问题3消息队列的异步处理模式项目中使用Kafka处理各种事件如 PostStarMessage 请分析1. 为什么点赞成功后要发送消息而不是直接更新数据库2. 这种异步模式可能会带来什么问题如何解决3. 消息发送失败怎么办参考答案 1. 为什么使用异步 // 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、通知等2. 异步模式的问题与解决方案 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} 1. 解决的问题 - 原子性 多个 Redis 操作原子执行防止并发问题- 数据一致性 投票状态和计数保持一致- 性能优化 减少网络开销2. 幂等性保证 -- 检查状态是否变化if oldVote newVote then-- 直接返回不执行修改return {likeCount, dislikeCount, newVote}end- 相同请求多次执行结果一致- 不会重复计数3. Redis 宕机恢复策略 - 方案AKafka 事件回溯 当前实现// 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 分页而不是 offset2. 这种实现有什么局限性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, pageSize1) }Cursor 分页优势 - 性能稳定 OFFSET 会扫描前 N 行后丢弃数据量大时很慢- 一致性好 不受数据插入删除影响- 实现简单 Redis ZSet 原生支持Offset 分页问题 -- offset 分页的性能问题 SELECT * FROM comment LIMIT 1000000, 20; -- 需要扫描并扔掉前 1000000 行2. Cursor 分页的局限性 - 无法跳转到任意页 只能翻前/翻后- 不支持页码显示 没有页码概念- 删除的评论会导致结果重复或丢失3. 支持跳转到第 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 处理- 数据不一致 数据库删除但缓存未清理干净- 没有处理子回复 子回复变成孤儿2. 子回复的处理策略 选项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 限制- 没有设备指纹- 没有异常行为检测- 没有滑动窗口限流2. 恶意刷赞防护方案// 多级限流 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 }3. 高级异常行为检测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)这种做法的问题- 缓存雪崩风险 频繁失效缓存会大量查询数据库- 性能差 每次点赞都可能触发重建- 用户体验差 排序更新不及时2. 高效实时排序更新方案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((likeCountint(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)) }3. 防止排序抖动// 方案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服务关注一个人但是同时他注销了怎么办