Laravel多对多关联下如何为每个标签单独加载最近4条关联帖子
Eloquent 多对多关联实现“每个标签取最近4条帖子”的正确方案
问题根因
在多对多关联的预加载场景下,直接在with方法的关联闭包里调用take(4)会触发全局结果截断:预加载本身是通过whereIn匹配所有当前查询到的标签ID,批量拉取关联帖子的,此时加的take(4)只会对整个批量查询的结果集做截断,不会按照每个标签分组取数,最终只会返回全量关联结果里的前4条帖子,分配给对应的标签,导致大部分标签拿不到足够的关联数据。如果直接去掉take(4),又会把每个标签下的全部关联帖子拉取出来,造成不必要的内存和带宽浪费。
对应模型关联基础定义
IgAccount与IgPost:一对多关联,IgAccount模型内定义posts()关联方法IgHashtag与IgPost:多对多关联,通过ig_hashtag_ig_post中间表打通关联,IgHashtag模型内定义posts()关联方法
错误写法示例
// 这段代码无法实现“每个标签取最新4条帖子”的预期 $targetAccountId = auth()->user()->igAccount->id; $hashtagList = IgHashtag::query() // 筛选和指定账号下帖子有关联的标签 ->whereHas('posts', function ($query) use ($targetAccountId) { $query->where('ig_account_id', $targetAccountId); }) ->with(['posts' => function ($query) { $query->orderByDesc('published_at') ->take(4); // 这里的限制会作用于整个批量查询,不是每个标签维度的截断 }]) ->withCount('posts') ->orderByDesc('posts_count') ->paginate(20);
这段代码实际执行的预加载SQL逻辑是:
select * from ig_posts where ig_hashtag_id in (1,2,3,4,5...) order by published_at desc limit 4
相当于全量关联帖子只取前4条分配给标签,完全达不到预期。
正确实现方案
方案1:窗口函数实现(性能最优,适配支持窗口函数的数据库)
利用数据库的分组排序能力,在单条SQL里完成每个标签下的帖子分组、排序、取前4条的逻辑,完全避免全局截断问题,性能远优于拆分多次查询:
use Illuminate\Support\Facades\DB; $targetAccountId = auth()->user()->igAccount->id; // 第一步:先完成标签的筛选、总数统计、分页逻辑 $hashtagList = IgHashtag::query() ->whereHas('posts', function ($query) use ($targetAccountId) { $query->where('ig_account_id', $targetAccountId); }) ->withCount('posts') ->orderByDesc('posts_count') ->paginate(20); // 拿到当前分页结果里的所有标签ID $currentPageHashtagIds = $hashtagList->pluck('id'); // 第二步:按标签维度拉取最新4条关联帖子,用窗口函数分组排序 $recentPostsGrouped = IgPost::query() ->whereHas('hashtags', function ($query) use ($currentPageHashtagIds) { $query->whereIn('ig_hashtags.id', $currentPageHashtagIds); }) ->select(DB::raw( 'ig_posts.*, ROW_NUMBER() OVER (PARTITION BY ig_hashtag_ig_post.ig_hashtag_id ORDER BY ig_posts.published_at DESC) as row_num' )) ->having('row_num', '<=', 4) ->get() ->groupBy('pivot.ig_hashtag_id'); // 第三步:把拉取到的关联帖子手动绑定到对应标签模型上 $hashtagList->each(function ($hashtag) use ($recentPostsGrouped) { $hashtag->setRelation('posts', $recentPostsGrouped->get($hashtag->id, collect())); });
方案优势:
- 整个流程只需要执行2次数据库查询,没有N+1问题
- 严格按照每个标签的维度取最新的4条帖子,不存在全局截断的bug
- 排序规则、取数数量完全可控
方案2:分页后单独加载关联(适配老版本数据库,无窗口函数支持)
如果使用的是不支持窗口函数的老版本MySQL,可以调整关联加载的时机,避免全局预加载的截断问题:
$targetAccountId = auth()->user()->igAccount->id; // 第一步:完成标签的筛选、总数统计、分页逻辑 $hashtagList = IgHashtag::query() ->whereHas('posts', function ($query) use ($targetAccountId) { $query->where('ig_account_id', $targetAccountId); }) ->withCount('posts') ->orderByDesc('posts_count') ->paginate(20); // 第二步:对分页后的标签集合单独加载关联帖子,此时每个标签的关联查询是独立执行的,limit限制会按标签维度生效 $hashtagList->load(['posts' => function ($query) { $query->orderByDesc('published_at') ->limit(4); }]);
这里和之前错误写法的核心区别是:把关联加载的时机从主查询的批量预加载,调整为分页拿到标签集合之后的按需加载。此时每个标签对应的关联查询是独立按标签ID执行的,limit(4)的限制就会严格作用在单个标签的关联结果上,完全符合预期。因为分页后单页的标签数量有限(通常每页10-20条),这种写法的性能完全可以接受。
踩坑提醒
- 不要在主查询的批量预加载闭包里直接加
take/limit,除非你的需求就是拉取所有关联结果的前N条 - 绑定关联数据时用模型自带的
setRelation方法,不要直接给模型属性赋值,避免触发模型变更的副作用 - 如果单页标签数量很大(超过50条),优先选择窗口函数方案,避免大量独立查询带来的性能损耗
内容的提问来源于stack exchange,提问作者Martin Szotkovski
相关产品推荐
相关产品推荐

