[PostgreSQL]外部キー制約を含むテーブルをDROPするとデッドロックが発生する

PostgreSQLでDROP TABLEを行うときにデッドロックが発生するという事象がありました。私自身が遭遇したわけではないですが、Liquibaseで使用していないテーブルをDROP TABLEしようとしたときに、デッドロックが発生したようです。

そんなわけはないと思いましたが、どうやら発生するケースがあるようです。

以下の記事を参考に調査しました。この記事ではFlywayを使っていますが、Liquibaseでも同様の事象が発生しますし、こういったマイグレーションツールを使っていなくてもBEGINEND内でDROP TABLEするケースでは発生し得る事象になります。

外部キー制約を外すときに参照先のテーブルに対してAccessExclusiveLockを獲得している

問題の根元は外部キー制約を外すときに参照先のテーブルに対してAccessExclusiveLockを獲得している点あるようでした。これを確認するために、以下の2つのテーブルを作成します。

親テーブルであり、外部キー制約における参照先のテーブルにあたるcompaniesテーブルを作成します。

CREATE TABLE companies (
    id SERIAL NOT NULL,
    name VARCHAR,
    PRIMARY KEY (id)
);

次に子テーブルであり、外部キー制約を設定するusersテーブルを作成します。

CREATE TABLE users (
    id SERIAL NOT NULL,
    name VARCHAR,
    company_id INTEGER NOT NULL,
    PRIMARY KEY (id),
    FOREIGN KEY (company_id) REFERENCES companies (id)
);

このDDLによって作成される外部キーの名前を確認します。

SELECT
    table_name, constraint_name
FROM
    information_schema.table_constraints
WHERE
    table_schema = 'public'
    AND
    constraint_type = 'FOREIGN KEY';

このSQLを実行すると、以下のような結果が得られます。

table_nameconstraint_name
usersusers_company_id_fkey

外部キーの名前はあとで外部キー制約をDROP CONSTRAINTするときに使用します。

外部キー制約を外すときにAccessExclusiveLockを獲得していることを確認する

では、外部キー制約を外すときにAccessExclusiveLockを獲得していることを確認しましょう。

確認のために、トランザクション内で実行するようにします。これは、今回遭遇したケースではトランザクション内でDDLを実行していたため、同じようにするためにトランザクションを作成しています。

BEGIN;
SELECT locktype, mode FROM pg_locks WHERE pid = pg_backend_pid() AND relation = 'companies'::regclass;

トランザクションを作成した時点では、親テーブルのcompaniesテーブルに対してロックは獲得されていません。

locktypemode

次に外部キー制約をDROP CONSTRAINTしてからもう一度ロックが獲得されているかを確認してみます。

ALTER TABLE users DROP CONSTRAINT users_company_id_fkey;
SELECT locktype, mode FROM pg_locks WHERE pid = pg_backend_pid() AND relation = 'companies'::regclass;

今度はAccessExclusiveLockが獲得されていることを確認できました。

locktypemode
relationAccessExclusiveLock

公式マニュアルによると、AccessExclusiveLockFOR UPDATEがないSELECT文もブロックするため、オンライン稼働中に外部キーを削除するとデッドロックが発生する場合があります。

例えば、外部キーを2つ持つ以下のテーブルを考えてみます。

CREATE TABLE companies (
    id SERIAL NOT NULL,
    name VARCHAR,
    PRIMARY KEY (id)
);

CREATE TABLE organizations (
    id SERIAL NOT NULL,
    name VARCHAR,
    company_id INTEGER,
    PRIMARY KEY (id),
    FOREIGN KEY (company_id) REFERENCES companies (id)
);

CREATE TABLE users (
    id SERIAL NOT NULL,
    name VARCHAR,
    company_id INTEGER NOT NULL,
    organization_id INTEGER  NOT NULL,
    PRIMARY KEY (id),
    FOREIGN KEY (company_id) REFERENCES companies (id),
    FOREIGN KEY (organization_id) REFERENCES organizations (id)
);

CREATE TABLE products (
    id SERIAL NOT NULL,
    name VARCHAR,
    company_id INTEGER NOT NULL,
    PRIMARY KEY (id),
    FOREIGN KEY (company_id) REFERENCES companies (id)
);

このとき、2つのトランザクションを考えます。

  • トランザクション①
    productsテーブルをDROP TABLEするトランザクションです。一応、この例ではproductsテーブルはまだ使われていないテーブルということで、オンライン稼働中にDROP TABLEしても影響はないだろうという考えのもとでオンライン稼働中に実行しています。
  • トランザクション②
    usersテーブルにcompaniesテーブルとorganizationsテーブルをJOINして検索するトランザクションです。これはオンラインで頻繁に実行されるSQLです。

次のような流れで実行されるとデッドロックが発生します。

  1. トランザクション①でcompaniesテーブルへの外部キーが削除され、companiesテーブルへのAccessExclusiveLockが獲得されます。
  2. トランザクション②でusersテーブルとorganizationsテーブルに対するAccessShareLockが獲得されます。
  3. トランザクション①でorganizationsテーブルへの外部キーを削除しようとしますが、トランザクション②がすでにorganiztionsテーブルに対するAccessShareLockを獲得しているため、トランザクション①はAccessExclusiveLockが獲得できずに待ちになります。
  4. トランザクション②はcompaniesテーブルに対するAccessShareLockを獲得使用としますが、トランザクション①がAccessExclusiveLockを獲得しているため、AccessShareLockが獲得できずに待ちになります。(ここでデッドロックが発生)

発生パターンは他にも考えられますが、使われていないテーブルであっても外部キー制約を持つテーブルの削除を行うだけでデッドロックが発生するという点が最も注意すべき点だと思います。

ではどうすればよいか

参考にした記事とも重複しますが、以下のような対策を行うのがよいでしょう。

  1. 外部キー制約をもったテーブルをDROP TABLEする際には、複数のリソースを扱わないようにトランザクションを分ける(Liquibaseの場合は外部キー制約を解除するDDL(1外部キー制約1changelog)とDROP TABLEをするDDLのchangelogをわける)
  2. DDLとDMLを1つのトランザクション内で実行しない(Liquibaseならchangelogを分ける)
  3. 大量更新(登録・更新・削除)を行うテーブルを参照している場合、その処理が行われているタイミングを避けて実行する

外部キー制約を使っているプロジェクトの場合、使っていないテーブルだからといってテーブル定義を綺麗にするために安易にDROP TABLECREATE TABLEによる再作成を選択しないように慎重に検討すべきです。

どうしてもDROP TABLEをしたい場合は1DDL1トランザクションになるように分けて、先に外部キー制約を解除し、それからテーブルをDROP TABLEするようにした方がよいでしょう。

最後に

最後にデッドロックが発生した場合にどのように対応していくかについて記載しておきます。

以下のSQLを実行すると、ロックが発生しているトランザクション一覧が表示されます。

SELECT
    pg_stat_activity.pid,
    pg_stat_activity.usename,
    pg_stat_activity.query,
    pg_class.relname AS table_name,
    pg_locks.locktype,
    pg_locks.mode,
    pg_locks.granted,
    pg_stat_activity.state,
    pg_stat_activity.application_name
FROM
    pg_stat_activity
JOIN
    pg_locks
    ON pg_stat_activity.pid = pg_locks.pid
LEFT JOIN
    pg_class
    ON pg_class.oid = pg_locks.relation
WHERE
    pg_locks.locktype = 'relation'
    AND
    pg_class.relname NOT LIKE 'pg_%'
ORDER BY
    pg_stat_activity.pid;

application_nameなどをもとに、どのpidが問題になっているかを特定し、

SELECT pg_cancel_backend(<killしたいpid>);

で実行できます。例えば、pidが62なら

SELECT pg_cancel_backend(62);

のように書きます。

これでもkillできない場合は、

SELECT pg_terminate_backend(<killしたいpid>)

でkillします。

ON句に書くのは結合条件であって絞り込み条件ではないことに注意しよう

令和3年秋データベーススペシャリスト試験 午前Ⅱ問8にJOINのに関する問題が出題されていました。

問題

出題されているテーブルと登録されているデータは以下のとおりです。

CREATE TABLE 社員取得資格 (
    社員コード VARCHAR(4) NOT NULL,
    資格 VARCHAR(2)
);

INSERT INTO 社員取得資格 VALUES ('S001', 'FE');
INSERT INTO 社員取得資格 VALUES ('S001', 'AP');
INSERT INTO 社員取得資格 VALUES ('S001', 'DB');
INSERT INTO 社員取得資格 VALUES ('S002', 'FE');
INSERT INTO 社員取得資格 VALUES ('S002', 'SM');
INSERT INTO 社員取得資格 VALUES ('S003', 'FE');
INSERT INTO 社員取得資格 VALUES ('S004', 'AP');
INSERT INTO 社員取得資格 VALUES ('S005', NULL);

INSERTした結果は以下のようになります。問題に記載されている範囲ではPKを持たないテーブルになります。

社員コード資格
S001FE
S001AP
S001DB
S002FE
S002SM
S003FE
S004AP
S005NULL

出題されている問題は、以下の結果が得られるようにするために、

社員コード資格1資格2
S001FEAP
S002FENULL
S003FENULL

[ a ]に入る字句を答える問題になります。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
[ a ]

この問題の正解は、

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE' AND C2.資格 = 'AP'
WHERE
    C1.資格 = 'FE'

になりますが、不正解の選択肢と共通している字句は

    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE' AND C2.資格 = 'AP'

で、自己結合のために使用するC1.社員コード = C2.社員コード以外にC1.資格 = 'FE' AND C2.資格 = 'AP'という条件があるようです。

ON句の条件が結果にどのように影響するのか

ON句に結合条件以外を指定するとどのように振る舞うのかを確認してみたいと思います。

自己結合すると何が得られるのか

まずは、社員コードで自己結合すると何が得られるかを確認してみましょう。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード

このSQLを実行すると、以下のような結果が得られます。

社員コード資格1資格2
S001FEDB
S001FEAP
S001FEFE
S001APDB
S001APAP
S001APFE
S001DBDB
S001DBAP
S001DBFE
S002FESM
S002FEFE
S002SMSM
S002SMFE
S003FEFE
S004APAP
S005NULLNULL

当然ですが、同じ社員コードのレコード同士のすべての掛け合わせが表示されます。この中には自分自身のレコードも含まれています。

ON句にC1.資格 = ‘FE’を指定するとどうなるのか

ON句には結合条件を指定するのですが、ここにC1.資格 = 'FE'を指定するとどうなるのでしょうか?

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE'

C1のうち資格 = 'FE'のみ結合できるようにする条件となるため、FE以外の資格の行は社員コードが一致しても結合されなくなります。

社員コード資格1資格2
S001FEDB
S001FEAP
S001FEFE
S001APNULL
S001DBNULL
S002FESM
S002FEFE
S002SMNULL
S003FEFE
S004APNULL
S005NULLNULL

資格1がFEの行については資格2にすべての組み合わせが設定されていますが、資格1がFE以外の行については資格2がNULLになっていることがわかります。

ONにC2.資格 = ‘AP’を指定するとどうなるのか

一旦、C1.資格 = 'FE'の条件を外して、C2.資格 = 'AP'の条件を指定するとどうなるでしょうか?

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C2.資格 = 'AP'

C2のうち、資格='AP'のみ結合できるようにする条件となるため、AP以外の資格の行は社員コードが一致しても結合されなくなります。結合は左外部結合(LEFT OUTER JOIN)で行われているため、先ほどと異なり、結合相手のC2の行がない資格1の行は1行だけ残り、資格2はNULLになります。

社員コード資格1資格2
S001FEAP
S001APAP
S001DBAP
S002FENULL
S002SMNULL
S003FENULL
S004APAP
S005NULLNULL

APを資格として保有している社員のみ資格1と資格2(AP固定)が表示され、APを資格として保有していない社員は資格1のみ表示されます。

ON句にC1.資格 = ‘FE’ AND C2.資格 = ‘AP’の両方を指定するとどうなるのか

これまでの内容を踏まえると、あくまでも結合対象となる条件を指定しているだけですので、社員コードが一致するC1.資格 = 'FE'の行とC2.資格 = 'AP'が結合されます。このとき、左外部結合(LEFT OUTER JOIN)で結合しているため、C1の行は最低1行は残ります。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE'
      AND C2.資格 = 'AP'

C1の資格がFEでない行はC2が結合されず、C2APでなければC1FEでも結合されないため、資格1がFEでない行は資格2はNULLになり、資格2がAPになっている行は資格1がFEになります。

社員コード資格1資格2
S001FEAP
S001APNULL
S001DBNULL
S002FENULL
S002SMNULL
S003FENULL
S004APNULL
S005NULLNULL

WHERE句でC1.資格 = ‘FE’の行だけフィルタする必要がある 

ここまで見てみると資格1にはFE以外の行があることがわかります。これは左外部結合(LEFT OUTER JOIN)で結合したことでC1の行が消えないためです。得られるべき結果の資格1はFEのみになっているため、FE以外を除外するようにフィルタする必要があります。

これを実現するにはC1.資格 = 'FE'の条件をWHERE句に追加することで実現します。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    LEFT OUTER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE'
      AND C2.資格 = 'AP'
WHERE
    C1.資格 = 'FE'
社員コード資格1資格2
S001FEAP
S002FENULL
S003FENULL

代替案の検討

INNER JOINに変更すればWHERE句は不要か

左外部結合(LEFT OUTER JOIN)によって余計な行が残るという話をしてきましたが、内部結合(INNER JOIN)に変更したらどうなるでしょうか?

WHERE句を含まないINNER JOINで自己結合したSQLを作成して実行してみます。

SELECT
    C1.社員コード, C1.資格 AS 資格1, C2.資格 AS 資格2
FROM
    社員取得資格 C1
    INNER JOIN 社員取得資格 C2
    ON C1.社員コード = C2.社員コード
      AND C1.資格 = 'FE'
      AND C2.資格 = 'AP'

この条件ではFEAPの両方の資格を保有している社員の行のみが表示されるようになります。

社員コード資格1資格2
S001FEAP

結合しないで同じ結果を得ることは可能

左外部結合を使った場合は、FEの資格を保有している社員を表示し、APの資格も保有していたら表示する、という条件をあらわしています。

実務でこのようなSQLがあるかといわれるとそれほど多くないと思いますが、私が書くなら結合せずに書くと思います。なぜなら、ON句内に結合条件以外の条件を書くことは、可読性が高くなく、誤った解釈をするリスクがあるためで、ON句には2つのテーブル結合するためのキー情報のみ書きたいと考えているからです。絞り込み条件に見える条件は極力WHERE句に書くようにすることで、SQLの初心者であっても読み間違えないようにすることが重要だと考えています。

では、どうするかというと結合せずにCASE句を使って資格2の条件を記載します。

SELECT
    C1.社員コード,
    C1.資格 AS 資格1,
    CASE
        WHEN EXISTS (SELECT 1 FROM 社員取得資格 C2 WHERE C1.社員コード = C2.社員コード AND C2.資格 = 'AP') THEN 'AP'
        ELSE NULL
    END AS 資格2
FROM
    社員取得資格 C1
WHERE
    C1.資格 = 'FE'

このSQLでは必須となるFEを保有している社員を単純に抽出し、資格2には同社員がAPを保有していれば'AP'と出力し、保有していなければNULLを出力するようにしています。

あくまでもFEを保有している社員を抽出することが主眼とし、+αの情報としてAPを保有しているかを抽出するようにSQLを書いています。

まとめ

ON句にキーの結合条件以外の条件が出てくるとドキッとしますが、ひとつひとつ丁寧に確認していくことでそれが結合する条件であることがわかってきます。

試験対策としてこのような動作は実機で確認して理解しておくことは重要ですが、実務においては誤解されないかを十分に確認した上で使った方がよいと思います。ON句で書くこととWHERE句で書くことは等価な場合もありますが、等価でない場合もありますので、意図した結果を得られるのかを十分に検証してください。

モバイルバージョンを終了