Rails 带分页的复杂群组筛选问题咨询
群组推荐查询优化落地方案
现有实现的核心问题根因
- 所有过滤逻辑均在Ruby内存层完成:每次请求需要执行5-6次全表JOIN、GROUP BY查询,把符合/不符合的群组ID全部加载到内存后拼数组做
where.not,数据量上升后数据库CPU、内存占用会随用户量线性暴涨 - 分页逻辑割裂:将「符合偏好」「不符合偏好」两批数据作为完全独立的集合做分页,没有统一的排序优先级。当第一集合的剩余数据不足整页时,会直接切换到第二集合的首页数据,出现页码与实际内容错位、跳过展示的问题
- 随机排序无固定锚点:直接用
RANDOM()排序时,每次数据库查询返回的结果顺序完全随机,分页过程中翻到下一页时会出现数据重复、漏数据的问题
优化后实现逻辑
1. 所有筛选逻辑下沉到数据库层,彻底消除重复pluck查询
把原来需要加载到内存计算的过滤规则,全部改成单条SQL内的关联子查询,不再反复查询全量ID列表:
- 群成员数量匹配校验:直接通过
user_groups关联统计每个群的joined状态成员数,和用户偏好值做比对,不再单独查询不符合数量的群组ID - 群成员性别匹配校验:直接关联用户表判断群内所有joined状态用户的性别是否符合用户偏好,不再单独查询含不符合性别的群组ID
- 屏蔽关联群过滤:直接用
EXISTS子查询判断当前群是否包含已被当前用户屏蔽的成员,不再先查被屏蔽用户ID、再查用户所在群ID - 已操作群过滤:直接关联
matches表判断当前用户是否对该群有点赞、点踩、匹配、屏蔽等操作,不再加载所有已操作的群组ID到内存
2. 统一结果集的优先级排序,解决分页跳页问题
把「符合偏好」「不符合偏好」的群组合并到同一个查询结果集里,在SQL层给每个群组打优先级标记:
- 满足所有偏好规则(成员数匹配、性别匹配、在用户设定的距离范围内)的群组,优先级标记为
0 - 不满足上述规则的群组,优先级标记为
1
排序时先按优先级升序排列,保证所有符合偏好的群组排在结果集最前面,后面衔接不符合偏好的群组。整个结果集是连续的、顺序固定的,分页时不管翻到第几页,顺序都不会乱,从根源上避免跳页。
排序补充优化:如果需要同优先级下做随机排序,不要每次查询用随机的排序值。可以在用户第一次加载推荐时生成一个固定的随机种子(比如1-1000000的整数),后续所有分页请求都携带这个种子,SQL里用
RANDOM(种子值)做排序,保证同一次浏览会话下的结果顺序完全固定,不会出现随机排序导致的分页错乱。
3. 核心实现代码参考
Group模型里新增可复用的scope
scope :recommend_for, ->(current_user, user_setting) { joined_user_status = UserGroup.statuses[:joined] blocked_friend_status = Friendship.statuses[:blocked] interacted_statuses = Match.statuses.values_at(:like, :match, :unlike, :block, :blocked, :report, :reported) # 基础过滤:活跃状态、非置顶群、无屏蔽关联用户、无用户已操作记录 base = active .where(spot_light_enabled: false) .where.not( "EXISTS ( SELECT 1 FROM user_groups ug INNER JOIN friendships f ON ug.user_id = f.friend_id WHERE ug.group_id = groups.id AND ug.status = ? AND f.user_id = ? AND f.status = ? )", joined_user_status, current_user.id, blocked_friend_status ) .where.not( "EXISTS ( SELECT 1 FROM matches m WHERE m.matcher_id = groups.id AND m.user_id = ? AND m.status IN (?) )", current_user.id, interacted_statuses ) # 计算每个群组的推荐优先级 base.select( "groups.*, CASE WHEN # 校验群成员数匹配 EXISTS ( SELECT 1 FROM user_groups ug WHERE ug.group_id = groups.id AND ug.status = #{joined_user_status} GROUP BY ug.group_id HAVING COUNT(ug.id) = #{user_setting.group_member_count} ) # 校验距离范围(在用户设定的偏好距离内) AND groups.latitude BETWEEN ? AND ? AND groups.longitude BETWEEN ? AND ? # 校验性别偏好匹配 AND ( NOT user_setting.male_only OR EXISTS ( SELECT 1 FROM user_groups ug INNER JOIN users u ON ug.user_id = u.id WHERE ug.group_id = groups.id AND ug.status = #{joined_user_status} GROUP BY ug.group_id HAVING COUNT(DISTINCT u.gender) = 1 AND MAX(u.gender) = #{User.genders[:male]} ) ) AND ( NOT user_setting.female_only OR EXISTS ( SELECT 1 FROM user_groups ug INNER JOIN users u ON ug.user_id = u.id WHERE ug.group_id = groups.id AND ug.status = #{joined_user_status} GROUP BY ug.group_id HAVING COUNT(DISTINCT u.gender) = 1 AND MAX(u.gender) = #{User.genders[:female]} ) ) THEN 0 ELSE 1 END AS recommend_priority" ).order(recommend_priority: :asc) }
控制器里的分页逻辑
def index user = Current.user setting = user.setting # 首次加载时生成固定随机种子,后续分页透传 random_seed = params[:random_seed] || rand(1..1000000) # 统一的推荐查询,自动按照优先级排序,自动衔接偏好范围内/外的群组 recommended = Group.recommend_for(user, setting) .includes(:group_status, :users) .near( [user.latitude, user.longitude], setting.max_distance, order: "RANDOM(#{random_seed})" ) .page(params[:page]) .per(params[:per_page]) # 分页元数据直接基于当前查询对象生成,不需要单独重复查询 pagination = pagination_dict(recommended) render json: { groups: recommended, pagination: pagination.merge(random_seed: random_seed) } end
性能配套优化
需要给对应表加复合索引,避免全表扫描:
user_groups表:复合索引(group_id, status, user_id),覆盖成员数统计、关联用户查询matches表:复合索引(user_id, matcher_id, status),覆盖已操作群组的排除查询friendships表:复合索引(user_id, friend_id, status)、(friend_id, user_id, status),覆盖屏蔽用户关联查询groups表:复合索引(status, spot_light_enabled, latitude, longitude),覆盖基础筛选和距离范围查询
后续需要新增「排除已展示群组」「排除用户已加入的群组」等校验规则时,直接在这个统一的查询里追加对应子查询即可,不会额外增加多次pluck查询的性能开销。
内容的提问来源于stack exchange,提问作者Muhammad Faisal Iqbal
相关产品推荐
相关产品推荐

