🐘

PostgreSQLのFOREIGN KEY制約でINDEXが設定されていないものを抽出する

2023/06/10に公開

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で指定した順で)

下記のシステムカタログとなるテーブルを利用して、この情報を収集します。

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の時と同じ考え方です)

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