為什麼你不該用 WordPress 的 Meta Query 來做查詢

透過自訂資料表的方式來儲存自訂欄位,對於減輕資料庫的負擔有非常大的幫助,如果是使用 ACF 建立欄位的話市面上也有幾款外掛可以做到一鍵拆分資料表並且相容 ACF API。

前陣子在大中的 Facebook 上看到一篇貼文,有一款佈景主題用了 attachment_url_to_postid() 從附件的 URL 來取得所屬文章 ID:

仔細看 attachment_url_to_postid() 的寫法,他直接去資料表去 SELECT wp_postmeta 裡面的 post_id 與 meta_value,這也是大中提到當 postmeta 資料量大的時候這樣去做查詢會造成讀取緩慢:

事實上這問題我自己也常常遇到,當要查詢符合特定自訂欄位的文章時,我會使用 WP_Query 裡面的 meta_query,而我的 PHP_Sniffer 也會自動跳出警告「 Detected usage of meta_query, possible slow query. 」說會造成資料讀取緩慢,因此我一直在研究到底該怎麼做會比較好?

Meta Query 的問題在哪?

在尋找解決方法前,我們先來理解一下為什麼 wp_postmeta 會爆肥以及該資料表欄位的格式問題。首先,因為 WordPress 是以文章為主體的內容管理系統,不管是文章、頁面、附件、WooCommerce 商品、訂單全都會寫在 wp_posts 資料表裡面。

而紀錄文章的自訂欄位是寫在 wp_postmeta , 因此當文章類型與數量一多, wp_postmeta 絕對是肥到不要不要的,這也是為什麼 WooCommerce 持續在進行 postmeta 的拆分作業,此外自訂欄位的外掛像是 ACF 也都是把資料寫進 wp_postmeta,想讓它瘦身更是難上加難。

另一方面 wp_postmeta 裡面存放資料的欄位叫做 meta_value,它的格式是 longtext,可以存放的資料量是 4,294,967,295 bytes,大約等於 4GB,這對於純文字的寫入量來說幾乎等於沒有限制,有不少外掛會將資料用 serialize 序列化或是 json encode 的方法來儲存,如果存的文字太多就很有可能造成單一 postmeta 容量破表。

這時候再用 SELECT 或是 meta_query 來取得所有 postmeta 來進行資料比對,資料庫絕對會在你眼前直接爆炸,根本的解決之道就是只能進行資料表的拆分,讓不同類型的 post 有自己的 meta 資料表會是比較好的作法。

自訂 Post Meta 資料表

這邊我引用 Edward Bock 的文章來介紹如何新增資料表並且整合到 WP_Query 裡面,就能在不改變使用習慣的前提下進行自訂資料表的查詢。假設我們的文章有一個欄位叫做文章字數,我想要在一個叫做「長文」的頁面中撈出所有多於 2000 字的文章,通常我會在 save_post 這個勾點來寫入文章欄位的資料:

function save_post_words( $post_id ){
    $post_words = 2000; // 字數計算結果
    update_post_meta( $post_id, 'post_words', $post_words );
}
add_action( 'save_post', 'save_post_words', 10, 1 );

接著在長文頁面進行以下查詢:

$query = new WP_Query(
    array(
        'post_type' => 'post',
        'posts_per_page' => 10,
        'meta_query' => array(
            array(
                'key'     => 'post_words',
                'compare' => '>=',
                'value'   => '2000',
                'type'    => 'NUMERIC',
            ),
        ),
    )
);

這樣就能順利撈出字數大於 2000 字的文章了,但如同上面所提到的問題,當網站內容一多連帶也會增加 postmeta 的量,因此這樣的寫法能避免就避免,解決的辦法是我們新增自己的資料表來存放文章的字數,並且設計資料寫入與讀取的方法。

首先建立 TablePostsWords 類別並在建構式裡面定義資料表名稱為 wp_posts_words

class TablePostsWords {
    public function __construct(){
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->table = $this->wpdb->prefix."posts_words";
    }
}

接下來建立 create_table() 方法,使用 dbDelta 來執行 SQL 語法,這張表我們只需要兩個欄位,一個是 post_id 另一個是 post_words:

class TablePostsWords {

    // 略...

    public function create_table(){
        require_once( ABSPATH . 'wp-admin/includes/upgrade.php' );
        \dbDelta("CREATE TABLE IF NOT EXISTS {$this->table}
            (
             post_id bigint(20) unsigned NOT NULL,
             post_words int(3) unsigned NOT NULL,
             primary key  (post_id),
             key (reading_time),
             key post_reading_time (post_id, reading_time)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;");
    }
}

最後則是模擬 update_post_meta() 以及 get_post_meta() 來寫入與取得對應文章字數的 set 與 get 方法:

class TablePostsWords {

    // 略...

    public function set( $post_id, $post_words ) {
        return $this->wpdb->replace(
            $this->table,
            array(
                'post_id'    => $post_id,
                'post_words' => $post_words,
            ),
            array(
                '%d',
                '%d',
            )
        );
    }

    public function get( $post_id ) {
        return intval(
            $this->wpdb->get_var(
                $this->wpdb->prepare(
                    "SELECT post_words FROM {$this->table} WHERE post_id = %d",
                    $post_id
                )
            )
        );
    }
}

這樣就可以用 TablePostsWords->get( $post_id ) 取得字數以及 TablePostsWords->set( $post_id, '2000' ) 寫入該文章的字數,這邊可以看到同樣是使用 SELECT 對資料庫的負擔就小了超多,再也不用去查詢恐怖的 wp_postmeta

完整的程式碼如下:

class TablePostsWords {

    public function __construct(){
        global $wpdb;
        $this->wpdb = $wpdb;
        $this->table = $this->wpdb->prefix."posts_words";
    }

    public function create_table() {
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        \dbDelta(
            "CREATE TABLE IF NOT EXISTS {$this->table}
            (
             post_id bigint(20) unsigned NOT NULL,
             post_words int(3) unsigned NOT NULL,
             primary key (post_id),
             key (post_words),
             key post_words (post_id, post_words)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"
        );
    }

    public function set( $post_id, $post_words ) {
        return $this->wpdb->replace(
            $this->table,
            array(
                'post_id'    => $post_id,
                'post_words' => $post_words,
            ),
            array(
                '%d',
                '%d',
            )
        );
    }

    public function get( $post_id ) {
        return intval(
            $this->wpdb->get_var(
                $this->wpdb->prepare(
                    "SELECT post_words FROM {$this->table} WHERE post_id = %d",
                    $post_id
                )
            )
        );
    }
}

如果是要透過外掛來新增資料表的話,記得透過函式 register_activation_hook 呼叫 TablePostsWords 類別的 create_table() 方法,這樣就會在外掛啟用時自動建立我們的資料表:

$table_posts_words = new TablePostsWords();
register_activation_hook( __FILE__, array( $table_posts_words, 'create_table' ) );

當外掛啟用後就可以看到 wp_post_words 資料表:

整合 WP Query

當建立好我們自己的資料表後,接下來就把它整合到 WP_Query 之中讓它用起來就跟 meta_query 一樣,這邊會用到一個叫做 posts_where 的勾點,這個勾點會在使用 WP_Query 時觸發,主要作用是來修改查詢條件,剛好可以用來設計我們自己的 meta_query 參數。

我們的目標是新增一個 post_words 參數來取得字數大於 2000 字的文章,用法如下:

$args_post = array(
    'posts_per_page' => '9',
    'post_type'      => 'post',
    'post_words'     => array(
        'compare' => '>=',
        'value'   => 2000,
    ),
);
$query_post = new WP_Query( $args_post );

記得先用 TablePostsWordsset() 方法去新增一些測試資料,接下來就可以實作 posts_where

function add_post_words_query( $where, $wp_query ) {
    $post_words = $wp_query->get( 'post_words', false );
    if ( is_array( $post_words ) && isset( $post_words['compare'] ) && isset( $post_words['value'] ) ) {
        $db           = new TablePostsWords();
        $value        = intval( $post_words['value'] );
        $compare      = $post_words['compare']; // should be sanitized
        $post_ids_query = "SELECT post_id FROM {$db->table} WHERE post_words {$compare} {$value}";
        $posts_table   = $db->wpdb->posts;
        $where       .= " AND {$posts_table}.ID IN ( $post_ids_query ) ";
    }
    return $where;
}

add_filter( 'posts_where', 'add_post_words_query', 10, 2 );

posts_where 帶入兩個參數,一個是 SQL 語法的 WHERE,另一個是 WP_Query 物件。首先我們用 WP_Queryget 方法來取得要查詢參數也就是 post_words,然後判斷該參數是否為陣列、是否帶有 compare 以及 value,有的話才會觸發 WHERE 條件的修改。

接下來先取得 post_words 參數裡面的 compare 以及 value,再透過 SELECT 來組成 SQL 語法來取得對應文章的字數,最關鍵的就是 $where 變數透過比對 wp_posts 裡面的 ID 欄位來對應到 wp_posts_words 資料表裡面 post_words 的值,帶入實際資料的 SQL 語法範例如下:

AND wp_posts.ID in ( SELECT post_id from mydb WHERE post_words >= 2000 )

最後返回 $where 就能在既有的查詢條件加入我們所建立的參數 post_words。根據 Edward 的實驗結果使用自訂資料表的查詢速度比 meta_query 快上約 10 倍,大大提升了資料庫的讀取效能。

那麼 ACF 該怎麼辦呢?

如果你跟我一樣習慣使用 Advance Custom Field 來建立自訂欄位,還有辦法把已經寫入 wp_postmeta 的資料獨立成新的資料表嗎?目前我查到幾個解決方案,一個是由 Hookturn 所開發的付費外掛「ACF Custom Database Tables」,免費外掛有兩款,分別是「ACF to Custom Database Tables」與「ACF: Fields in Custom Table」。

我測試了 ACF to Custom Database Tables,設定方法非常簡單,就跟平常一樣建立欄位群組,啟用該外掛後就可以在設定畫面中多看到一個「ACF To Custom Database Tables」的區塊,只要勾選 Custom Table 以及幫自訂資料表命名即可:

發佈後就會在最上面看到提示說是否要建立該資料表,點選 Go to Manage Tables 前往設定頁面,頁面中直接會帶入要執行的 SQL 語法,點擊 Create Table ㄧ鍵搞定:

這時候回到資料庫就會看到剛剛新增的 wp_post_newmeta 資料表,不管是之前建立的或是新增的欄位群組都適用,最棒的地方在於我一樣可以使用 ACF 的 API 來修改或取得資料,之前寫好的 get_field()update_field() 都不用修改,使用習慣完全不用改變。

另外 ACF to Custom Database Tables 在預設的情況下依舊會把資料寫進 wp_postmeta,主要是為了可以相容其他外掛,讓它們依舊可以透過 get_post_meta() 來取得資料,如果你確認這些自訂欄位只會透過 ACF 的 get_field() 來取得,那麼就可以使用下面這個勾點來避免多存放一份資料在 wp_postmeta 中:

add_filter( 'acf_ct/settings/store_acf_values_in_post_meta', '__return_false' );

但有一個比較棘手的問題是如果將已經建立好的欄位群組拆分成 Custom Table,那麼原有的自訂欄位資料不會自動寫入新建好的資料表,必須要手動再更新一次文章才會寫入,而且已經存在 wp_postmeta 裡面的資料也不會清除,必須要手動處理,資料多的話這個工程有點浩大…

該外掛有付費版本,差別在於多支援了 ACF Pro 的 Gallery、Repeater 以及 Flexible Content 這三個欄位,售價是 99 美金折合台幣約三千元,相較於 Hookturn ACF Custom Database Tables 的 138 澳元( 約兩千八台幣) 貴上一百多塊,但不知道後者是否能解決自動寫入與清除的問題,再找時間來開箱測試了~

結論

當遇到要查詢資料庫文章的自訂欄位時,先評估該站的 wp_postmeta 資料表規模,謹慎的使用 meta_query,如果預期資料量會非常龐大,最好的做法還是建立自己的資料表來做查詢比較保險,你都是怎麼處理的呢?有更好的作法記得留言跟我說~

參考資料

補充資料

根據呂信翰大大的回報,Hookturn 的 ACF Custom Database Tables 也無法自動寫入與清除存在,原因是會讓做搜尋功能的外掛無法使用,而他們所支援的欄位並沒有 Flexible Content,上述這些都在它們預計增加的功能之中。

文章標籤ACFPost Meta

目錄

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *

這個網站採用 Akismet 服務減少垃圾留言。進一步了解 Akismet 如何處理網站訪客的留言資料

賴俊吾 / Oberon Lai
賴俊吾 / Oberon Lai

現為全職 WordPress 工程師,網站開發經歷 11 年,專攻前端工程與 WordPress 佈景主題、外掛客製化開發

訂閱電子報

Hi,我是 Oberon,我會固定在每週五早上發送接案心得以及與 WordPress 相關的電子報,同時也會分享一些實用的開發知識,讓你在 WordPress 的接案路上不孤單!

專注於分享 WordPress 開發、接案技巧、專案管理等自由工作者必備知識與心得

© 2024 想點創意科技有限公司

想點創意科技有限公司 | 統一編號 90516823
Designed by Hend Design | 隱私權政策

訂閱電子報

Hi,我是 Oberon,我會固定在每週五早上發送接案心得以及與 WordPress 相關的電子報,同時也會分享一些實用的開發知識,讓你在 WordPress 的接案路上不孤單!