ORM 陷阱

產品實現中有一類常見的需求是,取出一組數據, 且這組數據中的每一項都攜帶固定數量的關聯數據.php

如取出一組熱門做者及他們最近發表的3篇文章mysql

但在MySQL(ORM)中這種需求並不能比較完美的實現.laravel

你看到這種需求的第一眼可能會想到這麼寫git

users: idgithub

posts: id,user_idsql

user 經過外鍵user_id 一對多關聯 post緩存

$users = \App\Models\User::with(['posts' => function ($query) {
    $query->limit(3);
}])->limit(10)->get();
複製代碼

但這並不能知足該需求,而且這是一種錯誤的寫法. 這裏的指望是10個做者,每一個做者取出3篇帖子,一共取出了30篇帖子,實際生成的sql語句是post

select
  *
from
  `posts`
where
  `posts`.`user_id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
limit
  3
複製代碼

你應該一眼就看出了不可行的緣由.雖然知道了這樣不可行,可是仔細去想卻發現,本身也想不出不出知足這個需求的sql語句,mysql並無相似each_limit的語法.ui

sql大佬請忽略我這句話!!this

問題與陷阱應該闡述的比較清楚了,接下來看看幾個可行的解決方案

PLAN A

使用 N + 1的sql查詢方案.

$users = \App\Models\User::limit(10)->get();

$users = $users->map(function ($user) {
    //能夠考慮$user->id緩存,在保證了速度的同時避免大面積的緩存重建
    $user->posts = $user->posts()->limit(3)->get();
    
    return $user;
});

return $users;
複製代碼

這種作法的一個好處是思路足夠簡單直白,沒有複雜sql.在緩存的加持下能夠避免N+1問題.

PLAN B

對PLAN A稍微修改一下就構成PLAN B, 既UNION ALL解決方案. 經過mysql的UNION ALL將上面須要進行的10次查詢聯合成一次查詢.

$users = \App\Models\User::limit(10)->get();

// 拼接 union all
$posts = $users->map(function ($user) {

    return $user->posts()->limit(3);

})->reduce(function ($carry, $query) {

    return $carry ? $carry->unionAll($query) : $query;

})->get();

// 將posts按照一對多的關係relation到users中
$relation = \App\Models\User::query()->getRelation('posts');
$relation->match(
    $relation->initRelation($users->all(), 'posts'),
    $posts, 'posts'
);

return $users;
複製代碼

sql以下

(
  select
    *
  from
    `posts`
  where
    `user_id` = 355
  limit
    3
)
union all
  (
    select
      *
    from
      `posts`
    where
      `user_id` = 234
    limit
      3
  )
union all
  (
    select
      *
    from
      `posts`
    where
      `user_id` = 232
    limit
      3
  )
...
複製代碼

上面的查詢有效的避免了PHP與MySQL之間的I/O耗時,而且UNION ALL能夠有效的利用索引.

在50w條posts數據時,查詢平均耗時 0.002s,算是不錯的表現了. Explain以下

PLAN C

合理利用MySQL中的變量語法咱們也能夠實現這個需求,只須要用一個變量幫咱們編碼一下便可

SELECT
	posts.*,
	@number := IF (@current_user_id = `user_id`, @number + 1, 1) AS number,
	@current_user_id := `user_id`
FROM
	(select * from `posts` where `posts`.`user_id` IN (572, 822, 911, 103, 234, 11, 999, 333, 121, 122) order by `posts`.`user_id` ASC) AS posts
HAVING
	number <= 3
複製代碼

簡單解析一下這個sql語句.

FORM 爲一個子查詢,初步篩選出咱們須要的做者的全部文章, 且正序排列後生成一個臨時表.

SELECT 爲上面臨時表添加標號,添加的方式以下. (你須要從上往下一行行一行的觀察,與select的執行方式一致便可)

MySQL中調用未定義的變量,其值默認爲null.

id user_id @current_user_id if判斷 @number
1 1 null false, number被賦值爲1 1
2 1 1 true, @number = @number + 1 2
3 1 1 true, @number = @number + 1 3
4 1 1 true, @number = @number + 1 4
5 2 1 false, number被賦值爲1 1
6 2 2 true, @number = @number + 1 2
.. .. .. .. ..

HAVING執行於SELECT以後,其再次篩選上面的臨時表,只取number <= 3的 行.

在執行效率方面,50萬posts數據時,查詢平均耗時在 0.112s,不算太差,也不算太好的一個表現. Explain以下

經過sql語句咱們也能夠預見. from子查詢的結果集的數量,會直接影響後面標號的效率. 在子查詢的結果集很少時,該查詢會有更好的表現.

PLAN C有一個額外的好處是,其有不錯的laravel擴展支持,可以在不影響原有ORM操做的狀況下實現該需求

staudenmeir/eloquent-eager-limit

PLAN D

咱們也能夠添加一張中間表來維持這種關聯關係,好比做者與其最新發表的文章部分.咱們能夠建立一張

user_latest_post:user_id,post_id, 而後在User模型中添加一條多對多的關聯關係

# User.php

public function latestPosts() {
    return $this->belongsToMany(Post::class, 'user_latest_post')
}
複製代碼

使用

$users = \App\Models\User::with(['latestPosts'])->limit(10)->get();
複製代碼

該方案更加的符合ORM的關聯關係模型,查詢的效率上也較高. 可是須要增長對user_latest_post表的維護成本

總結來講上面四種方案都還算不錯,每種都有本身的優點,能夠根據本身的狀況選擇合適的方案. 固然若是你有其餘的解決方案,歡迎留言~

相關文章
相關標籤/搜索