PostgreSQLのロック問題を解決!初心者でもわかるデータベース遅延の原因と対策
生徒
「先生、PostgreSQLのデータベースが急に遅くなることがあるんですけど、何が原因なんですか?」
先生
「その原因の一つに『ロック』というものがあります。ロックは、複数の人が同時に同じデータを変更しようとしたときに、データが壊れないように待ってもらう仕組みです。」
生徒
「ロックって、鍵をかけるってことですか?」
先生
「そうです。例えば、図書館で同じ本を二人が同時に借りようとしたら順番待ちになりますよね。データベースでも同じように、誰かがデータを使っている間は他の人が待つ必要があります。」
生徒
「なるほど。でも、それが遅くなる原因になるんですか?」
先生
「はい。このロックが長時間続いたり、たくさん発生したりすると、データベース全体の処理が遅くなってしまいます。今日は具体的な例を見ながら学んでいきましょう。」
1. PostgreSQLのロックとは何か?
PostgreSQLのロック(Lock)とは、データベースの中で複数の処理が同時に同じデータを変更しようとしたときに、データの整合性を保つための仕組みです。整合性とは、データが矛盾なく正しい状態に保たれることを意味します。
例えば、銀行の口座データを考えてみましょう。AさんとBさんが同時に同じ口座に入金しようとした場合、ロックがないとデータが正しく更新されず、残高がおかしくなってしまいます。これを防ぐために、PostgreSQLは一時的にデータに鍵をかけて、一人ずつ順番に処理するようにします。
ロックには様々な種類があり、読み取りだけのロック(共有ロック)や、書き込みのためのロック(排他ロック)などがあります。この仕組み自体は必要なものですが、使い方を間違えると処理が遅くなる原因になります。
エンジニアの必須スキル「SQL」を、 図解と豊富な練習問題でゼロから体系的に学びたい人へ。 MySQLやPostgreSQLなど、各種データベースに対応した不朽の入門書です。
SQL 第2版 ゼロからはじめるデータベース操作をAmazonで見る※ Amazon広告リンク
2. ロックが原因で遅くなる具体的なケース
ケース1:長時間トランザクションによるロック待ち
トランザクションとは、複数のデータベース操作をひとまとまりとして扱う単位のことです。例えば、商品の注文処理では「在庫を減らす」「注文データを追加する」「売上を記録する」という複数の操作を一つのトランザクションとして実行します。
このトランザクションが長時間続くと、その間ずっとロックがかかったままになり、他の処理が待たされてしまいます。まるで、トイレに誰かが長時間入っていて、外で行列ができているような状態です。
まず、現在の商品テーブルの状態を見てみましょう。
id | product_name | stock | price
---+----------------+-------+-------
1 | ノートパソコン | 10 | 80000
2 | マウス | 50 | 1500
3 | キーボード | 30 | 3000
4 | モニター | 15 | 25000
5 | USBメモリ | 100 | 800
ユーザーAが次のような処理を始めたとします。
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- ここで他の処理を実行中... 時間がかかっている
この処理が終わらないうちに、ユーザーBが同じ商品の在庫を確認しようとします。
SELECT * FROM products WHERE id = 1 FOR UPDATE;
ユーザーBの処理は、ユーザーAのトランザクションが完了するまで待たされることになります。FOR UPDATEは、データを更新する予定があることを示すキーワードで、排他ロックを取得します。この待ち時間が積み重なると、システム全体が遅くなってしまいます。
ケース2:デッドロックによる処理停止
デッドロック(Deadlock)とは、二つ以上のトランザクションがお互いに相手のロック解除を待ち続けて、どちらも進めなくなってしまう状態のことです。日常生活に例えると、狭い廊下で二人が向かい合ってしまい、お互いに「相手が先に譲ってくれるだろう」と待ち続けている状況です。
デッドロックが発生すると、PostgreSQLは自動的に一方のトランザクションを強制終了させますが、その間システムは停止状態になります。
次のような注文テーブルと顧客テーブルがあるとします。
orders テーブル
id | customer_id | amount | status
---+-------------+--------+---------
1 | 101 | 5000 | pending
2 | 102 | 3000 | pending
3 | 103 | 8000 | completed
customers テーブル
id | name | total_purchase
----+------------+---------------
101 | 田中健一 | 50000
102 | 山本美咲 | 30000
103 | 佐々木翔 | 80000
トランザクションAが次の処理を実行します。
BEGIN;
UPDATE orders SET status = 'completed' WHERE id = 1;
UPDATE customers SET total_purchase = total_purchase + 5000 WHERE id = 101;
COMMIT;
同時にトランザクションBが逆の順序でテーブルにアクセスします。
BEGIN;
UPDATE customers SET total_purchase = total_purchase + 3000 WHERE id = 102;
UPDATE orders SET status = 'completed' WHERE id = 2;
COMMIT;
トランザクションAがordersテーブルをロックし、トランザクションBがcustomersテーブルをロックした状態で、お互いが相手のテーブルのロック解除を待ってしまうとデッドロックが発生します。
ケース3:行レベルロックの競合
PostgreSQLでは、テーブル全体ではなく特定の行(レコード)だけをロックすることができます。これを行レベルロックと呼びます。しかし、多くのユーザーが同じ行を同時に更新しようとすると、やはりロック待ちが発生します。
例えば、人気商品の在庫が少なくなってきたとき、多くの顧客が同時に購入ボタンを押すと、その商品の在庫データをロックしようとする処理が集中します。これはまるで、セール開始直後に人気商品に人が殺到して、レジ前に長蛇の列ができる状況に似ています。
3. ロック問題を確認する方法
PostgreSQLでは、現在発生しているロックの状態を確認することができます。これにより、どの処理がロック待ちになっているかを把握できます。
SELECT
l.pid,
l.locktype,
l.relation::regclass AS table_name,
l.mode,
l.granted
FROM pg_locks l
WHERE NOT l.granted
ORDER BY l.pid;
このSQLを実行すると、次のような結果が表示されます。
pid | locktype | table_name | mode | granted
------+----------+------------+------------------+---------
12345 | relation | products | RowExclusiveLock | f
12346 | relation | orders | RowExclusiveLock | f
この結果から、プロセスID(pid)12345と12346がロック待ちの状態(granted = f)であることがわかります。pidはプロセス識別番号で、各データベース接続に割り当てられる番号です。modeはロックの種類を示し、RowExclusiveLockは行を更新するためのロックです。
4. ロック問題を解決する実践的な方法
対策1:トランザクションを短く保つ
トランザクションの実行時間を短くすることで、ロックが保持される時間を減らせます。必要な処理だけをトランザクション内に含め、時間がかかる計算や外部システムへの通信などはトランザクションの外で行うようにします。
対策2:テーブルへのアクセス順序を統一する
デッドロックを防ぐには、複数のテーブルを更新する場合、常に同じ順序でアクセスするようにします。例えば、必ずordersテーブル→customersテーブルの順でアクセスするというルールを決めます。
対策3:適切なインデックスを作成する
インデックスとは、データベースが素早くデータを見つけるための目次のようなものです。適切なインデックスがあると、検索が速くなり、ロックされる時間も短くなります。例えば、よく検索される列にインデックスを作成します。
対策4:ロックタイムアウトを設定する
ロックの待ち時間に上限を設定することで、無限に待ち続けることを防げます。PostgreSQLでは次のように設定できます。
SET lock_timeout = '5s';
この設定により、ロック待ちが5秒を超えるとエラーが発生して処理が中断されます。これにより、システム全体が長時間停止することを防げます。
5. ロック問題を未然に防ぐ設計のポイント
システムを設計する段階から、ロック問題が起きにくい構造にしておくことが重要です。例えば、在庫管理のような競合が発生しやすい処理では、楽観的ロックという手法を使うことができます。
楽観的ロックとは、データを読み取った時点のバージョン番号を記録しておき、更新時に他の人が先に更新していないかをチェックする方法です。もし他の人が先に更新していた場合は、エラーを返して再試行してもらいます。これにより、長時間ロックを保持する必要がなくなります。
また、読み取り専用の処理と書き込み処理でデータベースを分けるという方法もあります。これをレプリケーションと呼び、読み取りが多いシステムでは効果的です。書き込みはマスターデータベースで行い、読み取りはレプリカと呼ばれる複製されたデータベースで行うことで、ロックの競合を減らせます。
さらに、同時実行数を制限することも有効です。例えば、一度に処理できるトランザクション数を制限することで、ロック競合の発生を抑えられます。アプリケーション側でキューイングシステムを導入し、処理を順番待ちさせることで、データベースへの負荷を平準化できます。
ロック問題は、データベースの設計段階から考慮することで、多くの場合未然に防ぐことができます。パフォーマンステストを行い、本番環境での負荷を想定した検証を行うことも大切です。
6. 実際の現場でよくある間違い
初心者がよく陥るロック問題の原因として、トランザクション内でユーザー入力を待つという間違いがあります。例えば、データを更新する前に確認画面を表示して、ユーザーがボタンを押すまで待つような処理です。この間ずっとロックがかかったままになり、他のユーザーが待たされてしまいます。
また、開発環境では問題なく動作していても、本番環境で大量のアクセスがあると突然ロック問題が発生することがあります。これは、開発時には同時実行が少ないため問題が顕在化しないためです。負荷テストを行い、複数のユーザーが同時にアクセスした場合の動作を確認することが重要です。
さらに、エラー処理が不適切な場合も問題になります。トランザクション内でエラーが発生したときに、適切にロールバック(処理の取り消し)を行わないと、ロックが解放されずに残ってしまいます。必ずエラー処理の中でロールバックを実行するようにしましょう。