PostgreSQLのFOREIGN KEY制約でINDEXが設定されていないものを抽出する
FOREIGN KEY制約(外部キー制約)と同じカラムにINDEXが設定されていないと、親(参照先)のテーブルが削除された際に、FOREIGN KEYで設定したカラムを指定してのチェックや削除(ON DELETE CASCADE)が自動的に行われる際に、INDEXが効かずにパフォーマンスが大きく劣化するといったことが発生する場合があります。
PostgreSQL公式のドキュメントでは、下記のように記載されています。
被参照テーブルからの行のDELETEや被参照行のUPDATEは、古い値と一致する行に対して参照テーブルのスキャンが必要となるので、参照行にもインデックスを付けるのは大抵は良い考えです。 これは常に必要という訳ではなく、また、インデックスの方法には多くの選択肢がありますので、外部キー制約の宣言では参照列のインデックスが自動的に作られるということはありません。
PostgreSQL 5.4. 制約 - 5.4.5. 外部キー
常に必須ではなく、また他のINDEXによってまかなえる可能性があるため、自動的には作成しないといったスタンスのようです。
なお、MySQLだと、自動的にINDEXが作成されます。
MySQL では、外部キーカラムにインデックスを付ける必要があります。外部キー制約はあるが所定のカラムのインデックスがないテーブルを作成する場合、インデックスが作成されます。
MySQL :: MySQL 8.0 リファレンスマニュアル :: 1.7.3.2 FOREIGN KEY の制約
FOREIGN KEY(以降FK)と同じカラムにINDEXが設定されているかを目視でチェックするのは大変なので、本記事ではSQLで実施する方法を考えていきます。
動作確認に利用したPostgreSQLのバージョンは、最新(2023年6月7日時点)の15.3です。
実際にテーブルがあった方がわかりやすいので、サンプルのDBとして下記を利用します。
SQLでFKの一覧を抽出する
まずはFKの一覧をSQLで抽出します。
INDEXと比較するために必要な情報は下記になります。
- 名前空間
- 制約名
- テーブル名
- FKとなるカラム一覧(FKで指定した順で)
下記のシステムカタログとなるテーブルを利用して、この情報を収集します。
- pg_constraint
- 制約に関する情報が格納されるカタログ
- https://www.postgresql.jp/document/15/html/catalog-pg-constraint.html
- pg_namespace
- 名前空間の情報が格納されるカタログ
- https://www.postgresql.jp/document/15/html/catalog-pg-namespace.html
- pg_class
- テーブル、インデックス、ビューなどの情報が格納されるカタログ
- https://www.postgresql.jp/document/15/html/catalog-pg-class.html
- pg_attribute
- テーブルの列情報が格納されるカタログ
- https://www.postgresql.jp/document/15/html/catalog-pg-attribute.html
SQLは下記の通りです。
カラムの情報がpg_constraint
時点ではidの配列となっており、これを名前の配列に変えたいがためunnest
+generate_subscripts
で順序付きで行に変換し、その後再度array_agg
で配列に変換するような形をとっています。
SELECT
base.namespace_name
, base.constraint_name
, base.table_name
, array_agg(pg_attribute.attname ORDER BY base.order) AS column_names
FROM (
SELECT
pg_constraint.conname AS constraint_name
, pg_constraint.conrelid
, pg_namespace.nspname AS namespace_name
, pg_constraint.contype
, pg_class.relname AS table_name
, unnest(pg_constraint.conkey) AS conkey
, generate_subscripts(pg_constraint.conkey, 1) AS order
FROM
pg_constraint
INNER JOIN pg_class
ON (pg_constraint.conrelid = pg_class.oid)
INNER JOIN pg_namespace
ON (pg_class.relnamespace = pg_namespace.oid)
) base
INNER JOIN pg_attribute
ON (base.conrelid = pg_attribute.attrelid AND base.conkey = pg_attribute.attnum)
WHERE
base.contype = 'f' -- FKで絞る
GROUP BY
base.namespace_name
, base.constraint_name
, base.table_name
;
上記を実行すると、下記のような情報が取得できます。
namespace_name | constraint_name | table_name | column_names
----------------+--------------------------------+---------------+--------------------
public | customer_address_id_fkey | customer | {address_id}
public | film_actor_actor_id_fkey | film_actor | {actor_id}
public | film_actor_film_id_fkey | film_actor | {film_id}
public | film_category_category_id_fkey | film_category | {category_id}
public | film_category_film_id_fkey | film_category | {film_id}
public | film_language_id_fkey | film | {language_id}
public | fk_address_city | address | {city_id}
public | fk_city | city | {country_id}
public | inventory_film_id_fkey | inventory | {film_id}
public | payment_customer_id_fkey | payment | {customer_id}
public | payment_rental_id_fkey | payment | {rental_id}
public | payment_staff_id_fkey | payment | {staff_id}
public | rental_customer_id_fkey | rental | {customer_id}
public | rental_inventory_id_fkey | rental | {inventory_id}
public | rental_staff_id_key | rental | {staff_id}
public | staff_address_id_fkey | staff | {address_id}
public | store_address_id_fkey | store | {address_id}
public | store_manager_staff_id_fkey | store | {manager_staff_id}
(18 rows)
SQLでINDEXの一覧を抽出する
FKと比較するためにINDEXの情報として下記を抽出します。
- 名前空間
- インデックス名
- テーブル名
- カラム一覧(INDEXで指定した順で)
下記のシステムカタログとなるテーブルを利用して、この情報を収集します。
(基準となるカタログが異なるだけで、FKの時と同じ考え方です)
- pg_index
- インデックスに関する情報が格納されるカタログ
- https://www.postgresql.jp/document/15/html/catalog-pg-index.html
- pg_namespace
- 名前空間の情報が格納されるカタログ
- https://www.postgresql.jp/document/15/html/catalog-pg-namespace.html
- pg_class
- テーブル、インデックス、ビューなどの情報が格納されるカタログ
- https://www.postgresql.jp/document/15/html/catalog-pg-class.html
- pg_attribute
- テーブルの列情報が格納されるカタログ
- https://www.postgresql.jp/document/15/html/catalog-pg-attribute.html
SQLは下記の通りです。
ここでもFKと同じようにカラムの名前一覧を作るためにunnest
+generate_subscripts
で順序付きで行に変換し、その後再度array_agg
で配列に変換するような形をとっています。
SELECT
base.namespace_name
, base.index_name
, base.table_name
, array_agg(pg_attribute.attname ORDER BY base.order) AS column_names
-- , base.is_functional
-- , base.is_partial
-- , pg_get_indexdef(base.indexrelid) AS index_def
FROM (
SELECT
pg_index.indexrelid
, index_class.relname AS index_name
, pg_index.indrelid
, pg_namespace.nspname AS namespace_name
, table_class.relname AS table_name
, unnest(pg_index.indkey) AS indkey
, generate_subscripts(pg_index.indkey, 1) AS order
, pg_index.indexprs IS NOT NULL AS is_functional -- 関数INDEX
, pg_index.indpred IS NOT NULL AS is_partial -- 部分INDEX
FROM
pg_index
INNER JOIN pg_class index_class
ON (pg_index.indexrelid = index_class.oid)
INNER JOIN pg_class table_class
ON (pg_index.indrelid = table_class.oid)
INNER JOIN pg_namespace
ON (index_class.relnamespace = pg_namespace.oid)
) base
INNER JOIN pg_attribute
ON (base.indrelid = pg_attribute.attrelid AND base.indkey = pg_attribute.attnum)
GROUP BY
base.namespace_name
, base.index_name
, base.table_name
-- , base.is_functional
-- , base.is_partial
-- , base.indexrelid
;
上記を実行すると、下記のような情報が取得できます。
(数が多くなるので名前空間としてpublic
に絞りました)
namespace_name | index_name | table_name | column_names
----------------+-----------------------------------------------------+---------------+----------------------------------------
public | actor_pkey | actor | {actor_id}
public | address_pkey | address | {address_id}
public | category_pkey | category | {category_id}
public | city_pkey | city | {city_id}
public | country_pkey | country | {country_id}
public | customer_pkey | customer | {customer_id}
public | film_actor_pkey | film_actor | {actor_id,film_id}
public | film_category_pkey | film_category | {film_id,category_id}
public | film_fulltext_idx | film | {fulltext}
public | film_pkey | film | {film_id}
public | idx_actor_last_name | actor | {last_name}
public | idx_fk_address_id | customer | {address_id}
public | idx_fk_city_id | address | {city_id}
public | idx_fk_country_id | city | {country_id}
public | idx_fk_customer_id | payment | {customer_id}
public | idx_fk_film_id | film_actor | {film_id}
public | idx_fk_inventory_id | rental | {inventory_id}
public | idx_fk_language_id | film | {language_id}
public | idx_fk_rental_id | payment | {rental_id}
public | idx_fk_staff_id | payment | {staff_id}
public | idx_fk_store_id | customer | {store_id}
public | idx_last_name | customer | {last_name}
public | idx_store_id_film_id | inventory | {store_id,film_id}
public | idx_title | film | {title}
public | idx_unq_manager_staff_id | store | {manager_staff_id}
public | idx_unq_rental_rental_date_inventory_id_customer_id | rental | {rental_date,inventory_id,customer_id}
public | inventory_pkey | inventory | {inventory_id}
public | language_pkey | language | {language_id}
public | payment_pkey | payment | {payment_id}
public | rental_pkey | rental | {rental_id}
public | staff_pkey | staff | {staff_id}
public | store_pkey | store | {store_id}
(32 rows)
SQLでFKとINDEXを比較する
今まで作ったSQLを利用して、FKとINDEXを比較します。
大きなサブクエリがFROM句で連続すると読みずらいので、FKとINDEXの一覧を取得するクエリは、WITH句で共通テーブルとして定義します。
FKとINDEXのテーブルとカラム一覧が一致するかでチェックすることになるのですが、INDEXのカラム一覧の先頭の方で一致していればINDEXとして利用されるので、完全一致ではなく、前方一致で比較するようにしています。
たとえば、FKのカラムがa
だった場合、INDEXのカラムとしてa
b
の順番のものがあれば、a
で検索時のINDEXの候補となるためです。
WITH fk AS (
SELECT
base.namespace_name
, base.constraint_name
, base.table_name
, array_agg(pg_attribute.attname ORDER BY base.order) AS column_names
FROM (
SELECT
pg_constraint.conname AS constraint_name
, pg_constraint.conrelid
, pg_namespace.nspname AS namespace_name
, pg_constraint.contype
, pg_class.relname AS table_name
, unnest(pg_constraint.conkey) AS conkey
, generate_subscripts(pg_constraint.conkey, 1) AS order
FROM
pg_constraint
INNER JOIN pg_class
ON (pg_constraint.conrelid = pg_class.oid)
INNER JOIN pg_namespace
ON (pg_class.relnamespace = pg_namespace.oid)
) base
INNER JOIN pg_attribute
ON (base.conrelid = pg_attribute.attrelid AND base.conkey = pg_attribute.attnum)
WHERE
base.contype = 'f' -- FKで絞る
GROUP BY
base.namespace_name
, base.constraint_name
, base.table_name
), indx AS (
SELECT
base.namespace_name
, base.index_name
, base.table_name
, array_agg(pg_attribute.attname ORDER BY base.order) AS column_names
, pg_get_indexdef(base.indexrelid) AS index_def
FROM (
SELECT
pg_index.indexrelid
, index_class.relname AS index_name
, pg_index.indrelid
, pg_namespace.nspname AS namespace_name
, table_class.relname AS table_name
, unnest(pg_index.indkey) AS indkey
, generate_subscripts(pg_index.indkey, 1) AS order
, pg_index.indexprs IS NOT NULL AS is_functional -- 関数INDEX
, pg_index.indpred IS NOT NULL AS is_partial -- 部分INDEX
FROM
pg_index
INNER JOIN pg_class index_class
ON (pg_index.indexrelid = index_class.oid)
INNER JOIN pg_class table_class
ON (pg_index.indrelid = table_class.oid)
INNER JOIN pg_namespace
ON (index_class.relnamespace = pg_namespace.oid)
) base
INNER JOIN pg_attribute
ON (base.indrelid = pg_attribute.attrelid AND base.indkey = pg_attribute.attnum)
WHERE
-- 関数INDEXと部分INDEXは除外
base.is_functional = FALSE
AND base.is_partial = FALSE
GROUP BY
base.namespace_name
, base.index_name
, base.table_name
, base.is_functional
, base.is_partial
, base.indexrelid
)
SELECT
fk.*
, indx.index_name
, indx.column_names AS index_column_names
-- INDEXのDDL文自体を確認したい場合には、下記コメントを外す
-- , indx.index_def
FROM
fk
LEFT JOIN indx
ON(
fk.namespace_name = indx.namespace_name
AND fk.table_name = indx.table_name
-- FKのカラムと同じ数にしてINDEXと一致するか
-- (FKのカラムと前方一致であれば、そのINDEXが候補となりえるので)
AND fk.column_names = indx.column_names[1:cardinality(fk.column_names)]
)
ORDER BY
fk.namespace_name
, fk.table_name
, fk.constraint_name
;
結果は下記のようになります。
index_name
が空欄になっている=対応するINDEXが存在しなかったものになります。
namespace_name | constraint_name | table_name | column_names | index_name | index_column_names
----------------+--------------------------------+---------------+--------------------+--------------------------+-----------------------
public | fk_address_city | address | {city_id} | idx_fk_city_id | {city_id}
public | fk_city | city | {country_id} | idx_fk_country_id | {country_id}
public | customer_address_id_fkey | customer | {address_id} | idx_fk_address_id | {address_id}
public | film_language_id_fkey | film | {language_id} | idx_fk_language_id | {language_id}
public | film_actor_actor_id_fkey | film_actor | {actor_id} | film_actor_pkey | {actor_id,film_id}
public | film_actor_film_id_fkey | film_actor | {film_id} | idx_fk_film_id | {film_id}
public | film_category_category_id_fkey | film_category | {category_id} | |
public | film_category_film_id_fkey | film_category | {film_id} | film_category_pkey | {film_id,category_id}
public | inventory_film_id_fkey | inventory | {film_id} | |
public | payment_customer_id_fkey | payment | {customer_id} | idx_fk_customer_id | {customer_id}
public | payment_rental_id_fkey | payment | {rental_id} | idx_fk_rental_id | {rental_id}
public | payment_staff_id_fkey | payment | {staff_id} | idx_fk_staff_id | {staff_id}
public | rental_customer_id_fkey | rental | {customer_id} | |
public | rental_inventory_id_fkey | rental | {inventory_id} | idx_fk_inventory_id | {inventory_id}
public | rental_staff_id_key | rental | {staff_id} | |
public | staff_address_id_fkey | staff | {address_id} | |
public | store_address_id_fkey | store | {address_id} | |
public | store_manager_staff_id_fkey | store | {manager_staff_id} | idx_unq_manager_staff_id | {manager_staff_id}
(18 rows)
試しにstore
テーブルを確認してみたところ、FKとしてaddress_id
が指定されていますが、INDEXとしてaddress_id
が無いことがわかります。
dvdrental=# \d store
Table "public.store"
Column | Type | Collation | Nullable | Default
------------------+-----------------------------+-----------+----------+-----------------------------------------
store_id | integer | | not null | nextval('store_store_id_seq'::regclass)
manager_staff_id | smallint | | not null |
address_id | smallint | | not null |
last_update | timestamp without time zone | | not null | now()
Indexes:
"store_pkey" PRIMARY KEY, btree (store_id)
"idx_unq_manager_staff_id" UNIQUE, btree (manager_staff_id)
Foreign-key constraints:
"store_address_id_fkey" FOREIGN KEY (address_id) REFERENCES address(address_id) ON UPDATE CASCADE ON DELETE RESTRICT
"store_manager_staff_id_fkey" FOREIGN KEY (manager_staff_id) REFERENCES staff(staff_id) ON UPDATE CASCADE ON DELETE RESTRICT
Triggers:
last_updated BEFORE UPDATE ON store FOR EACH ROW EXECUTE FUNCTION last_updated()
定期的に実行する
FKにINDEXが無いことによる問題は、レコード数が少ないと問題になりずらいため、どうしても気が付くのが遅れがちです。
そのため、定期的に確認することが、重要になってくるのではと思います。
先ほどのSQLをちょっと変えて、対応するINDEXが無いFKを返却するSQLに変えます。
WITH fk AS (
SELECT
base.namespace_name
, base.constraint_name
, base.table_name
, array_agg(pg_attribute.attname ORDER BY base.order) AS column_names
FROM (
SELECT
pg_constraint.conname AS constraint_name
, pg_constraint.conrelid
, pg_namespace.nspname AS namespace_name
, pg_constraint.contype
, pg_class.relname AS table_name
, unnest(pg_constraint.conkey) AS conkey
, generate_subscripts(pg_constraint.conkey, 1) AS order
FROM
pg_constraint
INNER JOIN pg_class
ON (pg_constraint.conrelid = pg_class.oid)
INNER JOIN pg_namespace
ON (pg_class.relnamespace = pg_namespace.oid)
) base
INNER JOIN pg_attribute
ON (base.conrelid = pg_attribute.attrelid AND base.conkey = pg_attribute.attnum)
WHERE
base.contype = 'f' -- FKで絞る
GROUP BY
base.namespace_name
, base.constraint_name
, base.table_name
), indx AS (
SELECT
base.namespace_name
, base.index_name
, base.table_name
, array_agg(pg_attribute.attname ORDER BY base.order) AS column_names
, pg_get_indexdef(base.indexrelid) AS index_def
FROM (
SELECT
pg_index.indexrelid
, index_class.relname AS index_name
, pg_index.indrelid
, pg_namespace.nspname AS namespace_name
, table_class.relname AS table_name
, unnest(pg_index.indkey) AS indkey
, generate_subscripts(pg_index.indkey, 1) AS order
, pg_index.indexprs IS NOT NULL AS is_functional -- 関数INDEX
, pg_index.indpred IS NOT NULL AS is_partial -- 部分INDEX
FROM
pg_index
INNER JOIN pg_class index_class
ON (pg_index.indexrelid = index_class.oid)
INNER JOIN pg_class table_class
ON (pg_index.indrelid = table_class.oid)
INNER JOIN pg_namespace
ON (index_class.relnamespace = pg_namespace.oid)
) base
INNER JOIN pg_attribute
ON (base.indrelid = pg_attribute.attrelid AND base.indkey = pg_attribute.attnum)
WHERE
-- 関数INDEXと部分INDEXは除外
base.is_functional = FALSE
AND base.is_partial = FALSE
GROUP BY
base.namespace_name
, base.index_name
, base.table_name
, base.is_functional
, base.is_partial
, base.indexrelid
)
SELECT
fk.*
FROM
fk
LEFT JOIN indx
ON(
fk.namespace_name = indx.namespace_name
AND fk.table_name = indx.table_name
-- FKのカラムと同じ数にしてINDEXと一致するか
-- (FKのカラムと前方一致であれば、そのINDEXが候補となりえるので)
AND fk.column_names = indx.column_names[1:cardinality(fk.column_names)]
)
WHERE
-- 対応するINDEXが無いものに絞る
index_name IS NULL
ORDER BY
fk.namespace_name
, fk.table_name
, fk.constraint_name
;
このSQLをユニットテストなどで実行し、レコードが存在した場合にエラーとするようにすれば、INDEX漏れにすぐ気が付けるようになると思います。
SQL自体でエラーを返す
FKでINDEXが設定されていないものがあった場合に、エラーを返すSQLを作ることも出来ます。
下記のような感じで、対象レコードがあった場合に RAISE EXCEPTION
でエラーを発生させます。
DO $$
DECLARE
messages text[];
BEGIN
WITH fk AS (
SELECT
base.namespace_name
, base.constraint_name
, base.table_name
, array_agg(pg_attribute.attname ORDER BY base.order) AS column_names
FROM (
SELECT
pg_constraint.conname AS constraint_name
, pg_constraint.conrelid
, pg_namespace.nspname AS namespace_name
, pg_constraint.contype
, pg_class.relname AS table_name
, unnest(pg_constraint.conkey) AS conkey
, generate_subscripts(pg_constraint.conkey, 1) AS order
FROM
pg_constraint
INNER JOIN pg_class
ON (pg_constraint.conrelid = pg_class.oid)
INNER JOIN pg_namespace
ON (pg_class.relnamespace = pg_namespace.oid)
) base
INNER JOIN pg_attribute
ON (base.conrelid = pg_attribute.attrelid AND base.conkey = pg_attribute.attnum)
WHERE
base.contype = 'f' -- FKで絞る
GROUP BY
base.namespace_name
, base.constraint_name
, base.table_name
), indx AS (
SELECT
base.namespace_name
, base.index_name
, base.table_name
, array_agg(pg_attribute.attname ORDER BY base.order) AS column_names
, pg_get_indexdef(base.indexrelid) AS index_def
FROM (
SELECT
pg_index.indexrelid
, index_class.relname AS index_name
, pg_index.indrelid
, pg_namespace.nspname AS namespace_name
, table_class.relname AS table_name
, unnest(pg_index.indkey) AS indkey
, generate_subscripts(pg_index.indkey, 1) AS order
, pg_index.indexprs IS NOT NULL AS is_functional -- 関数INDEX
, pg_index.indpred IS NOT NULL AS is_partial -- 部分INDEX
FROM
pg_index
INNER JOIN pg_class index_class
ON (pg_index.indexrelid = index_class.oid)
INNER JOIN pg_class table_class
ON (pg_index.indrelid = table_class.oid)
INNER JOIN pg_namespace
ON (index_class.relnamespace = pg_namespace.oid)
) base
INNER JOIN pg_attribute
ON (base.indrelid = pg_attribute.attrelid AND base.indkey = pg_attribute.attnum)
WHERE
-- 関数INDEXと部分INDEXは除外
base.is_functional = FALSE
AND base.is_partial = FALSE
GROUP BY
base.namespace_name
, base.index_name
, base.table_name
, base.is_functional
, base.is_partial
, base.indexrelid
)
SELECT
array_agg(
(fk.constraint_name || ' : ' || fk.namespace_name || '.' || fk.table_name || '(' || array_to_string(fk.column_names, ', ') || ')')
ORDER BY fk.constraint_name
) INTO messages
FROM
fk
LEFT JOIN indx
ON(
fk.namespace_name = indx.namespace_name
AND fk.table_name = indx.table_name
-- FKのカラムと同じ数にしてINDEXと一致するか
-- (FKのカラムと前方一致であれば、そのINDEXが候補となりえるので)
AND fk.column_names = indx.column_names[1:cardinality(fk.column_names)]
)
WHERE
-- 対応するINDEXが無いものに絞る
index_name IS NULL
;
IF cardinality(messages) > 0 THEN
RAISE EXCEPTION E'INDEX is not set on the FOREIGN KEY columns.\n%', array_to_string(messages, E'\n');
END IF;
END
$$;
下記のようなエラーを返すようになります。
ERROR: INDEX is not set on the FOREIGN KEY columns.
film_category_category_id_fkey : public.film_category(category_id)
inventory_film_id_fkey : public.inventory(film_id)
rental_customer_id_fkey : public.rental(customer_id)
rental_staff_id_key : public.rental(staff_id)
staff_address_id_fkey : public.staff(address_id)
store_address_id_fkey : public.store(address_id)
CONTEXT: PL/pgSQL function inline_code_block line 96 at RAISE
このSQLをDBのマイグレーションの際に常に実行するようにすると、INDEX未設定の際にマイグレーション自体が失敗してすぐに気が付くことができます。
たとえばFlywayではマイグレーション実行時に必ず実行するSQLを設定できる(CallbacksのafterMigrate
など)ので、簡単に設定することができます。
Discussion