WordPress 投稿のクエリーでカスタムフィールドの絞り込みにNOT EXISTSを使ってDBが死んだ話（失敗と解決法）

要件定義の仕様とソースコードには問題なかったけど、パフォーマンスに問題があってやらかした話とその解決方法。

# 前提条件と要件

- 投稿に対して「最新記事に表示しない」チェックボックスを作成して、チェックが入っている記事はHOMEにおいて表示させない。
- ただし、記事そのものおよびカテゴリーアーカイブなどでは表示させる
- **既に1000オーバーで投稿が存在している**

# 仕様

- 投稿編集画面に「最新記事に表示しない」チェックボックスの追加（add_meta_box）
- チェックされた投稿はカスタムフィールド（post_view_check）に 1 で保存される
- HOMEの投稿ループでカスタムフィールド（post_view_check）の値が1の投稿**以外**が表示されるよう、`pre_get_posts` フックでメインクエリーを改変する

# 組んだソースのメインクエリーを改変部分

```php
function my_home_posts_modify_query( $query ) {
	if ( is_admin() || ! $query->is_main_query() )
		return;

	// HOMEでチェックが入っている記事を除外
	if ( $query->is_home() ) {
		$meta_arg = array(
			'relation' => 'OR',
			array(
				'key'     => 'post_view_check',
				'value'   => '0',
			),
			array(
				'key'     => 'post_view_check',
				'compare' => 'NOT EXISTS',
			),
		);
		$query->set( 'meta_query', $meta_arg );
		return;
	}

}
 add_action( 'pre_get_posts','my_home_posts_modify_query' );
```

カスタムフィールド「post_view_check」の値が1 **以外** というのがポイント。
通常なら演算子 `!=` を使って簡単にできそうですが、前提条件にもあるように既に存在する投稿があるのが曲者。
既に存在する投稿に対して、**後から追加したカスタムフィールドは自動で紐付けられず**いわゆるカスタムフィールドが**存在しない**状態になります。（投稿を保存しなおせばカスタムフィールド自体も保存されるけど、記事数が多いと無理ゲー）

ということで`NOT EXISTS`を使ったのですが、これがアカンパターンでした。

# DBに負荷がかかった原因

よく聞く「WordPressのカスタムフィールドは検索に向いていない」がそもそもの原因。
この「検索に向いていない」理由なのですが、  **「データベースのカスタムフィールドのテーブル（wp_postmeta）で meta_value はインデックスされていない」** これに付きます。

先のソースで言うと最終的に発行されるSQL文に問題がなくてもその実行に負荷がかかった。というオチです。

参照

- [Database Description | WordPress Codex](https://codex.wordpress.org/Database_Description#Table:_wp_postmeta)
- [WordPressで大規模データを扱う場合のTips](http://takahashifumiki.com/web/programing/1645/)
- [Advanced Custom Fieldsプラグインを使う際の注意点など](http://notnil-creative.com/blog/archives/1752)

# どう解決したか

カスタムフィールドにしてる部分が原因だったので、カスタムタクソノミーでの実装に変更。
管理画面でメニューが出てしまうのはしょうがない上に運用でカバーの部分だけど、DBパフォーマンスには勝てない。

```php
function my_home_posts_modify_query( $query ) {
	if ( is_admin() || ! $query->is_main_query() )
		return;

	// HOMEで newsタクソノミーのスラッグnonewsの記事を除外
	if ( $query->is_home() ) {
		$term_arg = array(
			'taxonomy' => 'news',
			'field'    => 'slug',
			'terms'    => 'nonews',
			'operator' => 'NOT IN',
		);
		$query->set( 'tax_query', $term_arg );
		return;
	}

}
 add_action( 'pre_get_posts','my_home_posts_modify_query' );
```
なおタクソノミーは public パラメーターを変更することで、表側に影響を出さないようにすることもできる。

```php
$args = array(
	'labels'                     => $labels, // ここ省略
	'hierarchical'               => true,
	'public'                     => false,
	'show_ui'                    => true,
	'show_admin_column'          => false,
	'show_in_nav_menus'          => false,
	'show_tagcloud'              => false,
);
register_taxonomy( 'news', array( 'post' ), $args );
```

# 学習したこと

- メタクエリーの「NOT EXISTS」（今回はチェック入れたのを出さないという処理）はフルスキャンになるのでDBに優しくない
- 逆の「チェック入れたのを出す」ならインデックスされてるのでDBには優しいが、過去記事がある場合運用が面倒
- カスタムタクソノミーでタームクエリーでやるとインデックスされてるのでDBに優しい

# 2017.3.6 追記 解決策の別案

カスタムタクソノミーじゃなくて当初のカスタムフィールドでやりたいって場合の別案

## 仕様
- 投稿に対して「最新記事に表示しない」チェックボックスを作成して、チェックが入っている記事はHOMEにおいて表示させない。

## ソース
```php
function my_home_posts_modify_query( $query ) {
	if ( is_admin() || ! $query->is_main_query() )
		return;

	// HOMEでチェックが入っている記事を除外
	if ( $query->is_home() ) {
		// 一旦除外する記事のIDを配列で取得する
		$ex_posts = get_posts(array(
			'fields'         => 'ids',
			'posts_per_page' => -1,
			'meta_query'     => array(
				array(
					'key'     => 'post_view_check',
					'compare' => '==',
					'value'   => '1'
				)
			),
		));
		// 除外するIDの配列があったら post__not_in に
		if ( ! empty( $ex_posts ) ) {
			$query->set( 'post__not_in', $ex_posts );
		}
		return;
	}

}
 add_action( 'pre_get_posts','my_home_posts_modify_query' );
```

`get_posts() ` あるいは `WP_Query`クラスは　`'fields' => 'ids'` とすることで、条件にマッチした投稿をオブジェクトではなく投稿IDの配列で受け取ることができるのがミソ。

https://codex.wordpress.org/Class_Reference/WP_Query#Filters