ある日突然SQLが遅くなった(サロゲートキーがある中間テーブルに複数列インデックスを作成していなかった)

ある日、以下の SQL を実行した際、campaign_game テーブルに campaign = '7588' に該当する行がない場合、約 300ms もの時間がかかる現象 が発生しました。
SELECT
    cg.game,
    cg.content,
    cg.url,
    g.gamename,
    g.median
FROM
    campaign_game cg
    INNER JOIN gamelist g
    ON cg.game = g.id
WHERE
    cg.campaign = '7588'
ORDER BY
    g.median DESC,
    g.count2 DESC,
    g.id
LIMIT 1;
campaign_game テーブルは、あるキャンペーンにどのゲームが紐づいているかを格納する中間テーブルです。
テーブルの定義は以下のとおりです。
# \d campaign_game
                             Table "public.campaign_game"
  Column		|  Type   | Collation | Nullable |                  Default
-------------------+---------+------------+------------+-------------------------------------------
 id			| integer |                | not null   | nextval('campaign_game_id_seq'::regclass)
 campaign	| integer |                | not null   |
 game		| integer |                | not null   |
 content		| text      |                |                |
 url			| text      |                |                |
Indexes:
    "campaign_game_pkey" PRIMARY KEY, btree (id)
    "campaign_game_campaign_index" btree (campaign)
    "campaign_game_game_index" btree (game)
Foreign-key constraints:
    "campaign_game_campaign_fkey" FOREIGN KEY (campaign) REFERENCES campaignlist(id) ON UPDATE CASCADE ON DELETE CASCADE
    "campaign_game_game_fkey" FOREIGN KEY (game) REFERENCES gamelist(id) ON UPDATE CASCADE ON DELETE CASCADE
結論として、campaign_game テーブルに複数列インデックス
CREATE INDEX campaign_game_campaign_game_index ON campaign_game (campaign, game);
を作成していなかったことが原因でした。

事象の分析

gamelist テーブルはゲームの情報を格納するテーブルです。
今回のクエリは campaign = 7588 のキャンペーン対象のゲームを取得するものですが、campaign_game には campaign = 7588 のデータが存在しません
したがって、campaign_gamecampaign 列を campaign_game_campaign_index で検索すれば、すぐに 0 行であると判明し、速やかに応答が返るはずでした。

しかし、事象発生時の EXPLAIN ANALYZE の出力は以下のようになっていました。

 Limit  (cost=868.30..1355.74 rows=1 width=164) (actual time=327.409..327.409 rows=0 loops=1)
   ->  Incremental Sort  (cost=868.30..87631.79 rows=178 width=164) (actual time=327.408..327.408 rows=0 loops=1)
         Sort Key: g.median DESC, g.count2 DESC, cg.game
         Presorted Key: g.median
         Full-sort Groups: 1  Sort Method: quicksort  Average Memory: 25kB  Peak Memory: 25kB
         ->  Nested Loop  (cost=0.71..87625.98 rows=178 width=164) (actual time=327.404..327.405 rows=0 loops=1)
               ->  Index Scan Backward using tokuten_index on gamelist g  (cost=0.29..15344.82 rows=32376 width=107) (actual time=0.007..24.140 rows=32376 loops=1)
               ->  Index Scan using campaign_game_game_index on campaign_game cg  (cost=0.42..2.22 rows=1 width=57) (actual time=0.009..0.009 rows=0 loops=32376)
                     Index Cond: (game = g.id)
                     Filter: (campaign = 7588)
                     Rows Removed by Filter: 19
 Planning Time: 0.230 ms
 Execution Time: 327.437 ms
この結果を分析したところ、以下の問題点が判明しました。

campaign_game 側のフィルタが結合後に実行されている
Index Scan using campaign_game_game_index on campaign_game cg
Index Cond: (game = g.id)
Filter: (campaign = 7588)
gamelist32376 行それぞれに対して、campaign_game を結合した後に campaign = 7588 のフィルタを適用しているため、無駄なループ (loops=32376) が発生。
このため、本来 campaign = 7588 の行がないとすぐに終了すべきクエリが、すべての gamelist のデータを走査してしまい、遅くなっていました

対応策

campaign_game テーブルに (campaign, game) の複合インデックスを作成。

CREATE INDEX idx_campaign_game ON campaign_game (campaign, game);
この結果、EXPLAIN ANALYZE は以下のように改善されました。

 Limit  (cost=562.42..879.79 rows=1 width=164) (actual time=52.224..52.225 rows=0 loops=1)
   ->  Incremental Sort  (cost=562.42..56737.69 rows=177 width=164) (actual time=52.224..52.224 rows=0 loops=1)
         Sort Key: g.median DESC, g.count2 DESC, cg.game
         Presorted Key: g.median
         Full-sort Groups: 1  Sort Method: quicksort  Average Memory: 25kB  Peak Memory: 25kB
         ->  Nested Loop  (cost=0.71..56731.90 rows=177 width=164) (actual time=52.220..52.220 rows=0 loops=1)
               ->  Index Scan Backward using tokuten_index on gamelist g  (cost=0.29..15344.82 rows=32376 width=107) (actual
 time=0.006..19.325 rows=32381 loops=1)
               ->  Index Scan using campaign_game_campaign_game_index on campaign_game cg  (cost=0.42..1.27 rows=1 width=57)
 (actual time=0.001..0.001 rows=0 loops=32381)
                     Index Cond: ((campaign = 7588) AND (game = g.id))
 Planning Time: 0.425 ms
 Execution Time: 52.252 ms
campaign_gamecampaign = 7588 で事前にフィルタリングされ、Nested Loop の無駄な繰り返しがなくなりました。

教訓

サロゲートキーのある中間テーブルでは、複数列インデックスの作成を考慮する

補足

今回のSQLは、ある日突然遅くなりました。つまり、それ以前は素早い応答を返していたはずです。
そこで、作成した campaign_game_campaign_game_index を削除し、SQL を実行してみました。

 Limit  (cost=27.87..107.02 rows=1 width=160) (actual time=0.010..0.011 rows=0 loops=1)
   ->  Merge Join  (cost=27.87..14038.89 rows=177 width=160) (actual time=0.010..0.010 rows=0 loops=1)
         Merge Cond: (g.id = cg.game)
         ->  Index Scan using gamelist_pkey on gamelist g  (cost=0.29..14296.29 rows=32376 width=103) (actual time=0.004..0.004 rows=1 loops=1)
         ->  Sort  (cost=26.25..26.69 rows=177 width=57) (actual time=0.005..0.005 rows=0 loops=1)
               Sort Key: cg.game
               Sort Method: quicksort  Memory: 25kB
               ->  Index Scan using campaign_game_campaign_index on campaign_game cg  (cost=0.42..19.64 rows=177 width=57) (actual time=0.004..0.004 rows=0 loops=1)
                     Index Cond: (campaign = 7588)
 Planning Time: 0.194 ms
 Execution Time: 0.027 ms

実行結果を見ると、campaign_game テーブルを campaign = 7588 で絞り込んでいるため、爆速で結果が返ってきています。
もともとこのクエリで動いていたのに、実行計画が変わったことでクエリの処理が遅くなったのではないかと考えられます。

その後、再び campaign_game_campaign_game_index を追加し、削除してみたところ、上記の実行計画にはならなくなりました。
今回の案件は campaign_game_campaign_game_index を追加することで対応完了とします。

専用サーバーのローカルネットワーク経由で他のユーザーのサーバーからアクセスされる

ErogameScapeで借用している専用サーバーの1つには、ネットワークポートが2つあります。
1つはグローバルIPアドレスが設定されており、もう1つはローカルIPアドレスが設定されています。
ローカルIPアドレスが設定されているのは、サーバーの監視用です。

毎日PostgreSQLのログを確認しているのですが、以下のようなログが記録されていました。

no pg_hba.conf entry for host "172.22.182.49", user "postgres", database "postgres", no encryption

あれ?ローカルIPアドレスだ。


ErogameScapeの専用サーバーはインターネット側からPostgreSQLにアクセスできるようにポートを開けているため、不正アクセスを試みるIPアドレスからの接続がログに記録されることはよくあります。
しかし、ローカルネットワークからのアクセスは初めて見ました。

誤って接続を試みてしまったのか、意図的に行ったのかは不明ですが、nftablesでブロックしました。

必要なサービスだけを起動し、必要な接続元からのアクセスだけを許可するという原則を守っていれば問題はないのですが、設定を確認したところ、自宅サーバーで運用していたころの設定である「192.168.0.0/24」からの接続許可が残っていました。
ローカルネットワークについての設定も気をつけなければならないと感じました。

PostgreSQLで実行中のトランザクションを強制的に解放する

FANZA同人の男性向け作品一覧の大幅割引作品の検索サイトは、以下のようなSQLでデータを流し込んでいます。
PREPARE fanza_doujin_sales_plan (
    text
  , text
  , integer
  , text
  , integer
  , integer
  , text
  , text
  , integer
  , timestamp with time zone
  , date
  , integer
) AS
INSERT INTO fanza_doujin_sales (
    cid
  , title
  , maker_id
  , maker_name
  , price
  , discount_rate
  , img
  , genre
  , number_of_sales
  , campaign_due_date
  , delivery_start_date
  , number_of_pages
) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 )
ON CONFLICT (cid, campaign_due_date)
DO UPDATE SET price = $5, discount_rate = $6
RETURNING cid, title, (xmax = 0) AS inserted;
;
EXECUTE fanza_doujin_sales_plan(
    'd_243614'
  , '使用済みの姪っ子を俺の女に染めるまで'
  , 75096
  , 'あまいちご'
  , 192
  , 75
  , 'https://doujin-assets.dmm.co.jp/digital/comic/d_243614/d_243614pl.jpg'
  , 'コミック'
  , 85465
  , '2023/10/16'
  , '2022/09/30'
  , 24
);
ある日、BEGINを実行した後に上記SQLを流し込んでいる最中にターミナルのセッションが切れてしまいました。
そこで新たにターミナルを立ち上げて上記SQLを流し込もうとしたところトランザクションがロールバックされていないため、テーブルにロックが入ってSQLが実行待ちになってしまいました。
あー、ロックを強制的に外さないと駄目かなあ…、そもそもロックの確認方法を忘れている…といろいろ調べているうちに自然にロックが解除され(セッションが切れてから一定時間立つとロールバックされるのかな…)実行待ち状態は解除されました。

ロックの確認とロックしているプロセスの解放方法の確認に時間がかかったのでメモしておきます。

ロックの確認方法
SELECT
    l.relation::regclass,
    l.locktype,
    l.mode,
    l.granted,
    a.pid,
    a.query
FROM
    pg_locks l
JOIN
    pg_stat_activity a
ON
    l.pid = a.pid
WHERE
    l.relation = 'fanza_doujin_sales'::regclass;
ap2=# SELECT
    l.relation::regclass,
    l.locktype,
    l.mode,
    l.granted,
    a.pid,
    a.query
FROM
    pg_locks l
JOIN
    pg_stat_activity a
ON
    l.pid = a.pid
WHERE
    l.relation = 'fanza_doujin_sales'::regclass;
      relation      | locktype |       mode       | granted |   pid   |                                    query
--------------------+----------+------------------+---------+---------+-----------------------------------------------------------------------------
 fanza_doujin_sales | relation | RowExclusiveLock | t       | 3190875 | EXECUTE fanza_doujin_sales_plan(                                           +
                    |          |                  |         |         |     'd_243614'                                                             +
                    |          |                  |         |         |   , '使用済みの姪っ子を俺の女に染めるまで'                                 +
                    |          |                  |         |         |   , 75096                                                                  +
                    |          |                  |         |         |   , 'あまいちご'                                                           +
                    |          |                  |         |         |   , 192                                                                    +
                    |          |                  |         |         |   , 75                                                                     +
                    |          |                  |         |         |   , 'https://doujin-assets.dmm.co.jp/digital/comic/d_243614/d_243614pl.jpg'+
                    |          |                  |         |         |   , 'コミック'                                                             +
                    |          |                  |         |         |   , 85465                                                                  +
                    |          |                  |         |         |   , '2023/10/16'                                                           +
                    |          |                  |         |         |   , '2022/09/30'                                                           +
                    |          |                  |         |         |   , 24                                                                     +
                    |          |                  |         |         | );
(1 row)
プロセスの強制終了
ap2=# select pg_terminate_backend(3190875) ;
 pg_terminate_backend
----------------------
 t
(1 row)

ap2=# SELECT
    l.relation::regclass,
    l.locktype,
    l.mode,
    l.granted,
    a.pid,
    a.query
FROM
    pg_locks l
JOIN
    pg_stat_activity a
ON
    l.pid = a.pid
WHERE
    l.relation = 'fanza_doujin_sales'::regclass;
 relation | locktype | mode | granted | pid | query
----------+----------+------+---------+-----+-------
(0 rows)
pg_cancel_backendだとプロセスを殺すことができませんでした。
ap2=# select pg_cancel_backend(3191875) ;
 pg_cancel_backend
-------------------
 t
(1 row)

ap2=# SELECT
    l.relation::regclass,
    l.locktype,
    l.mode,
    l.granted,
    a.pid,
    a.query
FROM
    pg_locks l
JOIN
    pg_stat_activity a
ON
    l.pid = a.pid
WHERE
    l.relation = 'fanza_doujin_sales'::regclass;
      relation      | locktype |       mode       | granted |   pid   |                                    query
--------------------+----------+------------------+---------+---------+-----------------------------------------------------------------------------
 fanza_doujin_sales | relation | RowExclusiveLock | t       | 3191875 | EXECUTE fanza_doujin_sales_plan(                                           +
                    |          |                  |         |         |     'd_243614'                                                             +
                    |          |                  |         |         |   , '使用済みの姪っ子を俺の女に染めるまで'                                 +
                    |          |                  |         |         |   , 75096                                                                  +
                    |          |                  |         |         |   , 'あまいちご'                                                           +
                    |          |                  |         |         |   , 192                                                                    +
                    |          |                  |         |         |   , 75                                                                     +
                    |          |                  |         |         |   , 'https://doujin-assets.dmm.co.jp/digital/comic/d_243614/d_243614pl.jpg'+
                    |          |                  |         |         |   , 'コミック'                                                             +
                    |          |                  |         |         |   , 85465                                                                  +
                    |          |                  |         |         |   , '2023/10/16'                                                           +
                    |          |                  |         |         |   , '2022/09/30'                                                           +
                    |          |                  |         |         |   , 24                                                                     +
                    |          |                  |         |         | );
(1 row)

[感想]テキストマイニングで探るエロゲー批評の昔と今

C102に参加された皆様お疲れさまでした。
参加してみてコロナ前の活気が戻っているんじゃないかなあ…と思いました。コミケに行くと、「ああ、自分はこのままでいいんだな」、って思えます。

コミケで本を2冊買ったのですが、そのうちの1冊が「テキストマイニングで探るエロゲー批評の昔と今」です。本書のサンプルと購入できるところは以下のとおりです。
ErogameScapeを作った理由の1つに、このような本が作りたい!というモチベーションがあったのですが、すでにエロゲーをまったくやっていない…というかErogameScapeを作ってから3年後にはエロゲーをやらなくなっているので、2003年までのことしか分からない自分には夢物語でして、ErogameScapeのデータを使って統計本を出して頂けるのは感謝でしかないです。

さて、「テキストマイニングで探るエロゲー批評の昔と今」は2部構成になっています。1部はレビューに関する集計で、メインは各年ごとに、その年に書かれたレビュー数順にゲームが一覧で見られることでしょう。
つばめさんも「ぜひこのランキングを眺めながら同好の士と酒のあてにでもしてください」と書かれていますが、これを見ながらあーだーこーだー話すのは楽しんじゃないでしょうか。
統計って結果を見てどう解釈をするかをあーだーこーだー話すのが楽しいんです、たぶん。自分はそう思います。
ちなみにAIRって、現在、1046件の一言感想が登録されているのですが、2001年には11件、2002年には48件、2003年には122件、2004年には163件の登録になってます。これだけだとまだ3割程度なので、2005年以降現在までで残りの7割の一言感想が登録されたわけで、すごいぞAIR、って思います。

2部はレビューのテキストにでてくる語句をMeCabを使って形態素解析にかけて、どのような言葉が年度ごとに使われているかを分析しています。
MeCabってエロ単語が登録されていないので使えなかった、というのが20年くらい前の自分の認識だったのですが、ちゃんと出来てる!という感想を持ちました。
主成分分析について説明がほとんどないので、思い出すのに時間がかかりましたが、なんとか思い出して、例えば「萌え」という言葉が絶滅危惧種であることが表から分かってすごいなって思います。

本書について、たまのすさんが書いた感想を見つけましたのでURLをはります。
【レビュー?】テキストマイニングで探る エロゲー批評と昔の今【というよりは茶々】

ErogameScapeのデータはどなたでも見られるようになっておりますので、データを使った何かを作成したい!等ございましたら、お気軽に相談ください。
相談いただかなくても大抵エロゲーマーのためのSQLでなんとかなると思いますが…相談頂いた方が早いこともあります、たぶん。

[感想]セガハード戦記

セガハード戦記
奥成洋輔
白夜書房
2023-07-03

自分は、東京に出た時、本屋さんによって、今、どんな本が売られているのかを見るのを楽しみます。
いつも通り本屋さんをブラブラしていたら上記の本が売っていました。
目次を見ましたら、Beepに言及しておりました。
Beep、懐かしい…
自分はかつてメガドライバー※1のはしくれだったのですが、メガドライバーだったら、おそらくBeepメガドライブの読者レースを毎月楽しみに見ていたはず、だと思います。
高得点のゲームに注目するのはもちろんですが、最下位にソード・オブ・ソダンがあるのに安心するみたいな、なんかそんな感じだったと思います。
BeepはBeepメガドライブの前身の雑誌でして、本書はBeepに10ページも割いて説明がされていて、自分が当時知らなかったBeepの様子が書かれていて、なんと今、noteでBeep21として記事が連載されているそうで、マジか!と思いました。
ということで、いつもは「いつかは買って読みたい本リスト」に追加して買わないのですが、今回はそのままレジへ向かって帰途でセガハード戦記を読むことになりました。

批評空間はその前身としてエロゲー評価統計情報というサイトがありました。
2000年は、レビューサイトがいっぱいありまして、エロゲを買うのにレビューサイトを全部回らないと情報が集まらない!※2という状態でした。
「自分でレビューサイトを巡回してつけられている評価を100点満点に換算して集計し掲載する」というサイトを作ったりまして、それがエロゲー評価統計情報でした。Excelにちまちまとデータを蓄積して計算していました。
当時のデータを見つけたので画像をはっておきます。レビューサイト名が懐かしい…
エロゲー評価統計情報

Beepメガドライブの愛読者だった私は、エロゲーにもBeepメガドライブのような読者レースがあればいいのに…と思っていました。そんなわけで他の要因もあったのですが、CinemaScapeのデザインを真似てErogameScapeを作りました。
Beepメガドライブがなければ、ErogameScapeがなかったかもしれない…は言い過ぎかもしれないですが、少なくともErogameScapeの年刊/月刊エロゲー統計表はBeepメガドライブの読者レースみたいのを作りたかったから存在してます。

さて、話を本に戻しまして、この本はSG-1000からドリームキャストまでを扱っています。自分はメガドライブとセガサターンしか実機に触れていないのですが、メガドライブとセガサターンだけで本の半分を占めるので十分楽しめました。
またあれは今でいうと同人誌なのだろうか…高校の文芸部の雑誌にセガサターンとプレイステーションの戦略だったかな…についてを書いていたこともあって、セガサターンとプレイステーションの戦いの様子を読んで、あーそんな感じだったなあ…と感慨深かったです。

メガドライブとサターンで何をやったかなあ…と思い出すと…そんなになくて…1日1回シルフィードをクリアする、1日1回ガンスターヒーローズをクリアする、学校帰りに友達の家※3によってぷよぷよで対戦しまくる、アドバンスド大戦略がいつまでたっても終わらない、ゴールデンアックスを弟と2人でよくやってた、バーチャファイター2はバーチャスティックプロを買ってやってた。ヴァンパイアセイヴァーとサムライスピリッツ 天草降臨もやってた。ルナ ザ・シルバースター、ランドストーカー、ファンタシースター 千年紀の終りに、ルナ エターナルブルーはやったけど内容を覚えてない…そんな感じです。

そんな自分でも本書は楽しめましたので、自分よりどっぷりセガにはまっていた方々は面白く楽しく懐かしく読めると思います。

※1 メガドライバーをぐぐったら、そんな言葉がなかったような感じなのですが…メガドライブを嗜んでいる人のことを当時はメガドライバーと言っていた、はず…

※2 どちらかというと、当時はエロゲを買うためにレビューサイトを回るのではなくて、プレイしたエロゲのレビューを読んでにやにやするためにレビューサイトを回っていたと思います。いや、本当に当時はそれが楽しくてたまらなかったです。AIRの考察とか、もう忘れちゃったけど、いろいろ読んで回ったなあ…

※3 この友達の家にPC9801があり、そこで見たかわいい女の子のCGに心を奪われた結果、エロゲーに傾倒していくことになり、コンシュマーゲームからは足が遠のいていきました。

記事検索