【Unit4 ブログリレー3日目】
こんにちは,エムスリーエンジニアリンググループの榎田です.数学とテレビゲームが好きです.
今回は,Unit4 で運用している "Docpedia" というサービスで実施した SQL チューニングの実例を2つご紹介します.普段の私が意識していなかった, RDBMS の内部機構に関する話が登場して面白かったので,今回の記事を書きました.
なお,本稿で扱う議論はすべて PostgreSQL 11.x 以上を対象としており,特にその他の RDBMS で同様の動作をするかは確認していません.定性的な挙動に共通するものはあるかもしれませんが,ここで述べた話はそのままは通らないであろうことをお断りさせてください*1.
プロダクトについて
Docpedia は「医師が診療で悩んだ時に専門性の高い医師に相談ができるQ&Aサービス」です.詳細な紹介は,例えば以下のブログ記事をご覧ください.
index なしで意外と耐えたが,耐えきれなかった話
ある日のことです.ページの表示までの時間が突然大幅に伸びるようになりました.調べてみたところ,とある SQL クエリが DB(PostgreSQL)に非常に大きな負荷をかけていそうだ,ということがわかりました.その SQL とは,ユーザに「新着お知らせの数」を表示するための SQL です.Twitter*2 の通知タブに出る通知数のバッジを表示するようなもの,というとわかりやすいでしょうか.
実際の SQL とテーブル定義
遅くなってしまった SQL と,関係するテーブルの定義を以下に示します*3.
CREATE TABLE notification ( -- お知らせテーブルの定義 id serial PRIMARY KEY, member_id int NOT NULL, -- お知らせ対象となるユーザの ID notification_timestamp TIMESTAMP WITH TIME ZONE -- お知らせイベントが起きた時刻 ); CREATE INDEX ON notification (member_id); CREATE INDEX ON notification (notification_timestamp); CREATE TABLE notification_last_seen_timestamp ( -- お知らせを最後に見た時刻を管理するためのテーブル member_id int PRIMARY KEY, last_seen_timestamp TIMESTAMP WITH TIME ZONE, -- お知らせを最後に見た時刻.見たことがなければ NULL が入る CONSTRAINT uniq_member_id UNIQUE (member_id) );
SELECT count(id) FROM notification LEFT JOIN notification_last_seen_timestamp on notification.member_id = notification_last_seen_timestamp.member_id WHERE ( notification.member_id = {対象となるユーザのID} AND notification.notification_timestamp IS NOT NULL AND notification.notification_timestamp <= now() ) AND ( notification_last_seen_timestamp.last_seen_timestamp IS NULL OR notification.notification_timestamp >= notification_last_seen_timestamp.last_seen_timestamp );
お知らせテーブル notification
には,今回のクエリで検索条件となっている member_id
ならびに notification_timestamp
に対する単独の(B+ tree)index が貼られているとわかります.また,問題発生時点で notification
テーブルには合計で2000万件近いレコードが挿入されていました.
この SQL の実行計画を確認してみると,次のようになりました.
QUERY PLAN ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Finalize Aggregate (cost=214090.84..214090.85 rows=1 width=8) (actual time=397.463..404.295 rows=1 loops=1) -> Gather (cost=214090.63..214090.84 rows=2 width=8) (actual time=396.880..404.249 rows=3 loops=1) Workers Planned: 2 Workers Launched: 2 -> Partial Aggregate (cost=213090.63..213090.64 rows=1 width=8) (actual time=378.730..378.732 rows=1 loops=3) -> Hash Left Join (cost=2117.33..213056.44 rows=13675 width=4) (actual time=25.818..375.737 rows=33414 loops=3) Hash Cond: (notification.member_id = notification_last_seen_timestamp.member_id) Filter: ((notification_last_seen_timestamp.last_seen_timestamp IS NULL) OR (notification.notification_timestamp >= notification_last_seen_timestamp.last_seen_timestamp)) -> Parallel Bitmap Heap Scan on notification (cost=2115.07..212387.54 rows=41024 width=16) (actual time=25.695..366.034 rows=33414 loops=3) Recheck Cond: (member_id = 10) Rows Removed by Index Recheck: 2015294 Filter: ((notification_timestamp IS NOT NULL) AND (notification_timestamp <= now())) Rows Removed by Filter: 33379 Heap Blocks: exact=20686 lossy=11946 -> Bitmap Index Scan on notification_member_id_idx (cost=0.00..2090.45 rows=196002 width=0) (actual time=24.301..24.301 rows=200380 loops=1) Index Cond: (member_id = 10) -> Hash (cost=2.25..2.25 rows=1 width=12) (actual time=0.031..0.031 rows=1 loops=3) Buckets: 1024 Batches: 1 Memory Usage: 9kB -> Seq Scan on notification_last_seen_timestamp (cost=0.00..2.25 rows=1 width=12) (actual time=0.021..0.025 rows=1 loops=3) Filter: (member_id = 10) Rows Removed by Filter: 99
実行計画の cost
をよく見てみると, Bitmap Heap Scan
に大きな時間がかかっているとわかります.特に Rows Removed by Index Recheck: 2015294
とあるので,どうやらデータを非常に大雑把に取ってきてから検索条件による再検索を実施しているようです.その大雑把さたるや,条件に合致するのはたかだか5万行なのに,手始めに200万行近いレコードを取ってしまっています.どうしてこのようなことになっているのでしょうか.
原因の分析
この挙動を理解しようとあれこれ調べたところ,どうやら "Bitmap Index"というものを理解する必要があるようだ,ということがわかりました.
ここでは Bitmap Index の一般論は紹介せず,今回の例に即してどのような処理が走ると思われるのか,その際に何を "Bitmap Index" と呼ぶのか,についてを推測・解説するに留めることとします*4.参考になりそうな文献は脚注で紹介していますので,ご興味あるかたはどうぞ*5.
member_id, notification_timestamp
を対象とする複合 index が存在しないので,今回のクエリは Index Only Scan にはなりません.他方で,単独 index が存在することを利用して,PostgreSQL のオプティマイザは以下に述べるような仕方で Seq Scan を回避して複合条件での検索を実施してくれます.
まず,各レコード行の位置(PostgreSQL なら TID
になると思います)について member_id
が検索条件と合致するなら1を,しないなら0を格納したベクトルを構成します.例えば,
TID | id | member_id |
---|---|---|
1 | 15747389 | 10 |
2 | 67868 | 8 |
3 | 578139 | 10 |
4 | 114389 | 8 |
... | ... | ... |
19785432 | 6287945 | 10 |
といった状態で, member_id
が 10
を検索条件とした場合,構成されるベクトルは [1,0,1,0,...,1] で,長さは19785432となる,というイメージです.このベクトルのことを "Bitmap Index" と呼びます.なお,この構築は B+ Tree Index を使って行うので,B+ Tree Index がない場合はこの Bitmap Index は構築されず Seq Scan になると思います*6.
これと同様の処理を notification_timestamp
の検索条件についても実施します.先程構築したベクトルと長さは同じで,0か1だけの値が格納されたベクトルができます.この2つのベクトルに対して要素ごとの bit 演算を行うことで,2つの条件を同時に充たすレコードの TID を格納したベクトルが取得できるので,あとはこのベクトルを頭から走査していけば取り出すべきレコードの TID がわかります.これが "Bitmap Heap Scan" の基本的な動作です.
ところで,PostgreSQL はこの Bitmap Index をディスクなどに保持せず,SQL の実行時に動的に構築しています.ということは,レコード数が巨大な場合は作業メモリに乗り切らないことがあり得ます.その際はレコードの位置を正確に保持する(exact モード)のではなく,該当する行がある「ページ」単位で Bitmap Index を構築します(lossy モード).1ページには複数の行が格納されているので,少ないメモリでより多くの行に関する Index を構築することができますが,条件を充たさないレコードが入ってくる可能性もあるので,最後に検索条件での再走査が必要です.それが Index Recheck になります.
今回の例に即して述べると,レコード数が少ない間は Bitmap Index を exact モードで構築できていたが,長年の運用に伴うレコード数の増大に伴って Bitmap Index が作業メモリに乗り切らなくなり lossy モードとなった結果,SQL の実行に時間がかかってアプリケーションの応答遅延に繋がった,と考えられます.
対応策
ここまでの状況をまとめます.
- お知らせテーブルはユーザの行動に伴ってレコードが挿入され巨大化していった
- 今回の SQL の検索条件に対応する複合 B+ Tree Index が存在せず,それぞれの条件に対応する単独 B+ Tree Index が存在した.複合検索する際にオプティマイザが Bitmap Index を構築したのはこれが理由と考えられる
- Bitmap Index が作業メモリに乗り切らなくなったことで lossy モードとなり,Recheck 処理が必要となった結果,クエリの速度が大幅に低下した.作業メモリに乗り切らなくなった原因はテーブルの巨大化によると思われる
このまとめに基づけば,今回のパフォーマンス問題を解消するには次のような方法があり得るでしょう.
- 今回の検索に対応する複合 B+ Tree Index を貼る*7
- 定期的にテーブルのレコード数を削減するなどでサイズを小さくして,構築される Bitmap Index のサイズを小さくし,exact モードで動くようにする
work_mem
の値を大きくすることで当座を凌ぐ*8
実際には,次のような考えから「定期的にテーブルのレコード数を削減する」対応としました.
- 実際のテーブルにはこれ以外にも複数の B+ Tree Index が複数貼られており,これ以上 Index を増やすことで思わぬ悪影響を及ぼす可能性を排除できなかった*9
- 「新着お知らせを保管する」というテーブルの性質上,古いレコードを保持しておく必要性がほとんどない
- 数年間はこのような事態が起きなかったことからも,テーブルのサイズが小さければこの問題が解消する見込みがそれなりにあった
実施後は Bitmap Index が exact モードで構築されるようになり,SQL のパフォーマンスが改善されて事なきを得ました.
SELECT DISTINCT が思ったより重かった話
上掲した件が落ち着いてから数ヶ月ほど経った,ある日のことです.またしても表示速度が非常に遅くなる事態となりました.調べてみたところ,今度は別の SQL が大きな負荷をかけているようで,しかも今回はメモリ不足でフェイルオーバーまで発生してしまいました.問題となっていたクエリは「公開できる回答がすでについているような質問を表示する」ものだと特定できました.
実際の SQL とテーブル定義
遅くなってしまった SQL と,関係するテーブルの定義を以下に示します*10.SQL のコメントにある通り,質問と回答はサイト上で収集した上で,編集部で確認・修正をしてからサイト上に公開の設定をする,という流れを踏んでいます.
CREATE TABLE question ( -- 投稿された質問. id serial PRIMARY KEY, title text NOT NULL, body text NOT NULL, ask_member_id int NOT NULL, ask_timestamp TIMESTAMP WITH TIME ZONE NOT NULL ); CREATE INDEX ON question (ask_member_id); CREATE INDEX ON question (ask_timestamp); CREATE TABLE edited_question ( -- サイトに公開する質問.エムスリー編集部で確認・修正をしてから公開するので,テーブルをわけている id int PRIMARY KEY, title text NOT NULL, body text NOT NULL, is_accepted boolean, -- 公開承認されているかどうか update_timestamp TIMESTAMP WITH TIME ZONE, publish_timestamp TIMESTAMP WITH TIME ZONE ); CREATE INDEX ON edited_question (is_accepted); CREATE INDEX ON edited_question (publish_timestamp); CREATE TABLE answer ( -- 投稿された回答 id serial PRIMARY KEY, question_id int NOT NULL, body text NOT NULL, answer_timestamp TIMESTAMP WITH TIME ZONE NOT NULL ); CREATE INDEX ON answer (question_id); CREATE INDEX ON answer (answer_timestamp); CREATE TABLE edited_answer ( -- サイトに公開する回答.エムスリー編集部で確認・修正をしてから公開するので,テーブルをわけている id int PRIMARY KEY, body text NOT NULL, -- 本文 is_accepted boolean, -- 公開承認されているかどうか is_draft boolean, -- 下書き状態かどうか publish_timestamp TIMESTAMP WITH TIME ZONE ); CREATE INDEX ON edited_answer (is_accepted); CREATE INDEX ON edited_answer (publish_timestamp);
SELECT DISTINCT -- 回答テーブルとの JOIN をとる結果,複数の回答がつくと同じ質問が SELECT 結果に複数回現れるので DISTINCT で重複を排除 question.id, edited_question.title, edited_question.body, question.ask_member_id, question.ask_timestamp, edited_question.update_timestamp, edited_question.publish_timestamp FROM question JOIN edited_question ON question.id = edited_question.id JOIN answer ON question.id = answer.question_id JOIN edited_answer ON answer.id = edited_answer.id WHERE edited_question.is_accepted IS TRUE -- 質問が公開 OK になっていて AND edited_answer.is_accepted IS TRUE -- 回答も公開 OK になっていて AND edited_answer.is_draft IS FALSE -- 回答が下書き状態になっていない AND current_timestamp >= edited_question.publish_timestamp -- 公開日時が現在より後 ORDER BY edited_question.publish_timestamp DESC, -- 公開日時の降順で並べる question.id DESC LIMIT 20;
質問・回答ともに数万件単位のレコードを持つテーブルです.今回取得したいのは(回答がついている)質問だけなのですが,SQL 上で回答テーブルと JOIN した結果,回答が複数個ついていると SELECT 結果に同一の質問が複数個出てしまうので DISTINCT で重複を排除する実装になっています.このクエリの実行計画を確認してみたところ,以下のようになりました.
QUERY PLAN ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ Limit (cost=34198.11..34198.51 rows=20 width=450) (actual time=688.464..691.953 rows=20 loops=1) -> Unique (cost=34198.11..35061.35 rows=43162 width=450) (actual time=688.461..691.919 rows=20 loops=1) -> Sort (cost=34198.11..34306.02 rows=43162 width=450) (actual time=688.458..691.833 rows=67 loops=1) Sort Key: edited_question.publish_timestamp DESC, question.id DESC, edited_question.title, edited_question.body, question.ask_member_id, question.insert_timestamp, edited_question.update_timestamp Sort Method: external merge Disk: 37688kB -> Gather (cost=11833.69..22020.18 rows=43162 width=450) (actual time=157.749..490.214 rows=79476 loops=1) Workers Planned: 2 Workers Launched: 2 -> Parallel Hash Join (cost=10833.69..16703.98 rows=17984 width=450) (actual time=152.951..463.865 rows=26492 loops=3) Hash Cond: (answer.id = edited_answer.id) -> Parallel Hash Join (cost=4790.23..10607.84 rows=20070 width=454) (actual time=67.246..292.621 rows=29172 loops=3) Hash Cond: (edited_question.id = question.id) -> Parallel Hash Join (cost=2312.53..8070.36 rows=22768 width=446) (actual time=31.622..169.983 rows=29172 loops=3) Hash Cond: (answer.question_id = edited_question.id) -> Parallel Seq Scan on answer (cost=0.00..5657.64 rows=38164 width=8) (actual time=0.010..47.723 rows=30595 loops=3) -> Parallel Hash (cost=2180.86..2180.86 rows=10534 width=438) (actual time=30.366..30.369 rows=6883 loops=3) Buckets: 32768 Batches: 1 Memory Usage: 10368kB -> Parallel Seq Scan on edited_question (cost=0.00..2180.86 rows=10534 width=438) (actual time=0.019..11.123 rows=6883 loops=3) Filter: ((accept IS TRUE) AND (CURRENT_TIMESTAMP >= publish_timestamp)) Rows Removed by Filter: 3123 -> Parallel Hash (cost=2227.31..2227.31 rows=20031 width=16) (actual time=35.444..35.446 rows=11380 loops=3) Buckets: 65536 Batches: 1 Memory Usage: 2176kB -> Parallel Seq Scan on question (cost=0.00..2227.31 rows=20031 width=16) (actual time=0.012..17.607 rows=11380 loops=3) -> Parallel Hash (cost=5615.99..5615.99 rows=34198 width=4) (actual time=84.399..84.402 rows=27329 loops=3) Buckets: 131072 Batches: 1 Memory Usage: 4288kB -> Parallel Seq Scan on edited_answer (cost=0.00..5615.99 rows=34198 width=4) (actual time=0.016..47.328 rows=27329 loops=3) Filter: ((accept IS TRUE) AND (draft IS FALSE)) Rows Removed by Filter: 480
実行計画を眺めると,以下のことがわかります.
- index が効きそうな箇所に対しても Seq Scan が実行されている箇所が複数ある
- メモリの使用量が多い箇所がある.PostgreSQL の
work_mem
のデフォルト値 4MB に対し,そのメモリをほぼ使い切るような操作がある external merge
が走っている.ディスクを 37MB 近く消費している
原因の分析
この SQL が遅くなってしまった原因は大きく分けて2つありました.
古い統計情報
PostgreSQL をはじめとして RDBMS は内部的にテーブルの統計情報を持っており,クエリの実行計画はこの統計情報に基づいて組まれます*11.特に,統計情報上でレコード数が少ないことになっているテーブルに対しては,往々にして Index Scan よりも Seq Scan が選択されます.その際,実際のテーブルのレコード数が大きいと,クエリの実行は非常に遅くなってしまいます.
今回調査したところ,何らかの理由でテーブルの統計情報が更新されないままとなっており,質問・回答は統計情報上では数百件しかレコードがないことになっていました.実行計画で Seq Scan が選択されたのはおそらくこれが理由です.
DISTINCT 句の挙動
また,実行計画を見てみると「DISTINCT 句によって重複を排除」する際には SELECT 句に現れるすべてのフィールドを用いて並べ替えを行っているとわかります.これは特に中間結果が巨大になる場合,そして SELECT したいフィールド数が多い場合に並べ替えコストが大きくなると予想されます.特に今回は,統計情報の陳腐化に伴い回答テーブルの Index が一切活用されず Seq Scan が走ったことで,ソート前の中間結果が非常に巨大な(実行計画を確認する限り数万行規模の)ものになっていました*12.その結果,作業メモリをほとんど食いつぶしたり,DISTINCT 直前のソートは作業メモリに乗らずディスク上で実施される,などの結果に繋がったと考えられます*13
対応策
今回の問題点をまとめます.
- 統計情報が古いまま更新されておらず,オプティマイザがその古い統計情報に基づいてクエリの実行内容を決定した結果,複数テーブルへの Seq Scan が走ってしまった
- 古い統計情報に基づいてクエリを実行したところ,中間結果が巨大となり,その結果 DISTINCT 操作が非常に重い操作になってしまった
これらの原因を解消していけば,今回の SQL を速くできそうです.まず,統計情報の更新が必要なことは間違いありません.他方で,次のように考えればクエリの最適化もできます;今回のクエリに回答の詳細な情報は必要なく,「回答のレコードが紐づいているか」だけが必要な情報です.ということは EXISTS 句を用いた副問合せに書き換えれば回答テーブルと JOIN する必要はなさそうで,DISTINCT 句も削れます.この方針に基づけば次のような SQL が書けるでしょう;
SELECT question.id, edited_question.title, edited_question.body, question.ask_member_id, question.ask_timestamp, edited_question.update_timestamp, edited_question.publish_timestamp FROM question JOIN edited_question ON question.id = edited_question.id WHERE edited_question.is_accepted IS TRUE AND current_timestamp >= edited_question.publish_timestamp AND EXISTS ( SELECT 1 FROM answer JOIN edited_answer ON answer.id = edited_answer.id WHERE question.id = answer.question_id AND edited_answer.is_accepted IS TRUE AND edited_answer.is_draft IS FALSE ) ORDER BY edited_question.publish_timestamp DESC, question.id DESC LIMIT 20;
ANALYZE と併せて,実行計画は次のようになりました.ソートに用いるキーの数が減っていること,メモリ使用量が大幅に(数千kb→数十kb)改善していること,ディスクの利用がなくなっていることがわかります.
QUERY PLAN --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- Limit (cost=4.86..79.38 rows=20 width=367) (actual time=0.433..0.439 rows=20 loops=1) -> Incremental Sort (cost=4.86..106809.02 rows=28665 width=367) (actual time=0.432..0.435 rows=20 loops=1) Sort Key: edited_question.publish_timestamp DESC, question.id DESC Presorted Key: edited_question.publish_timestamp Full-sort Groups: 1 Sort Method: quicksort Average Memory: 35kB Peak Memory: 35kB -> Nested Loop Semi Join (cost=1.17..105519.09 rows=28665 width=367) (actual time=0.033..0.413 rows=21 loops=1) -> Nested Loop (cost=0.58..33266.74 rows=39403 width=371) (actual time=0.020..0.141 rows=23 loops=1) -> Index Scan Backward using edited_question_publish_timestamp_idx on edited_question (cost=0.29..11070.59 rows=39403 width=355) (actual time=0.014..0.049 rows=23 loops=1) Index Cond: (publish_timestamp <= CURRENT_TIMESTAMP) Filter: (is_accepted IS TRUE) Rows Removed by Filter: 2 -> Index Scan using question_pkey on question (cost=0.29..0.56 rows=1 width=16) (actual time=0.003..0.003 rows=1 loops=23) Index Cond: (id = edited_question.id) -> Nested Loop (cost=0.58..2.28 rows=2 width=4) (actual time=0.011..0.011 rows=1 loops=23) -> Index Scan using answer_question_id_idx on answer (cost=0.29..0.80 rows=3 width=8) (actual time=0.004..0.004 rows=1 loops=23) Index Cond: (question_id = question.id) -> Index Scan using edited_answer_pkey on edited_answer (cost=0.29..0.49 rows=1 width=4) (actual time=0.005..0.005 rows=1 loops=31) Index Cond: (id = answer.id) Filter: ((is_accepted IS TRUE) AND (is_draft IS FALSE)) Rows Removed by Filter: 0
実際の実行時間も,数百msかかっていたものが1ms程度まで改善しました.
オチ
この DISTINCT 句が書かれていた部分を git blame してみたところ,コミット日時は数年前で,author に私の名前が出てきました.新卒1年目くらいの私が「これでいけるだろ〜」と書いたクエリが数年後に火を吹いた,ということになります.因果応報というものでしょうか*14.
感想
RDBMS はかしこいなぁとおもいました.
We're hiring!
今回挙げた2件はいずれも,プロダクトが長期で運用されデータが蓄積した結果,はじめて問題が顕在化したケースと言えるでしょう.弊社には長期運用されているプロダクトが複数あり,時に大変なこともありますが,一方で多くの学びが得られる場でもあります.ご興味を持たれた方はぜひご応募ください.
*1:可能性は高くないと思うのですが,もしかしたら 11.x 以上の PostgreSQL でもバージョンによってはここで述べた状況とまったく異なる挙動を示すケースもあるかもしれません.そのようなケースを構成できた場合はぜひお知らせください.
*2:最近は「X」と言ったほうがいいんでしょうか.私は未だに「いいね」のことを「ふぁぼ」と呼びそうになりますが……
*3:説明に必要な最小構成を提示しているので,実際にはこれより複雑です.
*4:とくにこの前後で歯切れの悪い物言いになっているのは,私の知っている範囲の知識で諸々を断言するのは浅薄に過ぎる可能性が高いと今回の案件を通じて痛感した,という私の心情に依るところ大です.正直 RDBMS なんもわからん.
*5:Bitmap Index の一般論については Use the Index, Luke! の以下の節,
ならびに上記ページからリンクされているメーリングリストの内容が参考になると思います.
exact/lossy モードの挙動については以下の記事がわかりやすくて良かったです;
*6:また,SET enable_indexonlyscan TO off されていたり,hint で無理やり実行計画を修正していれば B+ Tree Index が使える場合でも Bitmap Index を使っての処理になると思います.
*7:この index を用意すれば,単独 B+ Tree Index の片方は落としても良いでしょう.ただし,以下の資料などに示されている通り PostgreSQL は Index Skip Scan に対応していないことから,単独 index を両方とも落としてしまうとアプリケーションの挙動が変わる可能性が高いです.wiki.postgresql.org
*8:今回のテーブルは性質上何もしなければレコード数が単調に増大していくので,大きくした work_mem の値をこえて巨大な Bitmap Index を構築することになった場合,問題が再発します.この意味で恒久対応にならない可能性は高いです.
*9:index は常に貼っておけば OK というものではありません.例えば以下の資料の41ページを参照してください. speakerdeck.com
*10:こちらについても,説明に必要な最小構成を提示しているので,実際にはこれより複雑です.
*11:PostgreSQL であれば pg_stat_user_tables テーブルで確認可能です.
*12:今回この記事を書くに当たり,手元でこの条件を再現しようとあれこれコードを書いてみたのですが,統計情報が実態と乖離していないと回答テーブルへのスキャンが Index を用いて行われるため,ソート前の中間結果はたかだか数十行に留まりました.なので,今回の事象を完全に再現するにはわざと統計情報を更新しないようにしつつ大規模なデータの更新を行う必要がありそうです.
*13:ディスク上での操作は,今回の実行計画ではコストの支配項にこそなっていませんが,メモリ上での処理よりはずっと低速です.RDBMS の検索インデックスに B+ Tree Index が採用されている理由もここにあります;言い換えれば「高速だが容量の少ないメモリと低速だが大容量なディスクを記憶領域として持つシステム上での検索において,いかにディスクアクセス回数を最小限に抑え込むか」という工学的な課題を解決するための仕組みが B+ Tree Index に他なりません(T. Cormen et al., "Introduction to Algorithms" (MIT Press, 2009) の B-Tree の章の最初などにこの旨の記述がみとめられます).また,PostgreSQL にてディスク上のソート速度が遅いことを実際に検証した記事として,次のものがあります.zatoima.github.io
*14:と言いつつも,新卒1年目でオプティマイザの気持ちをトレースしてクエリを書くなんて無理じゃなかろうか,とも思いますが……いまでも一発でできるとは思えないし……