Hight Performance Postgresql for Railsを読んでく

これを読んでく。手元で動かしたり、大事なことをメモっていきたい。
普段mysqlしか使ってないので、postgesの知識は皆無。

- 地味に始めて見たけど、schema.rbをsql方式で管理することもできるらしい。
- https://github.com/andyatkinson/rideshare/blob/main/db/structure.sql
-
config.active_record.schema_format = :sql
で設定できるっぽい。
-
bin/rails db:migrate
→pg_dump
→ db/structre.sqlにダンプの流れらしい。 - postgres appは便利
- postgres用語その1:タプル
- PostgreSQLでの「タプル」というのは、データベースのテーブルにおける一つ一つの行(レコード)くらいの感覚?
- postgresの場合はそのタプルが直接ではなく、バージョンによって管理されているらしい
- postgres用語その2:ページ
- PostgreSQLでは、データはページ(ここではほとんどブロックと相互変更可能)に格納される
あとは1章に関しては、railsとかsqlの簡単な説明だった。クセ強な環境構築だったが無事できたぞ!

- psqlにはメタコマンドがあって便利
- カタログ(多分拡張みたいなイメージ)がある。
SELECT * FROM pg_extension;
で確認できる - pg_stat_activityでいい感じに動いてるプロセス情報が得られる
rideshare_development=> SELECT * FROM pg_stat_activity
rideshare_development-> WHERE pid = (SELECT PG_BACKEND_PID());
-[ RECORD 1 ]----+---------------------------------------
datid | 16396
datname | rideshare_development
pid | 22568
leader_pid |
usesysid | 16390
usename | owner
application_name | psql
client_addr | 127.0.0.1
client_hostname |
client_port | 55625
backend_start | 2024-01-29 22:58:56.428045+09
xact_start | 2024-01-29 23:06:52.481908+09
query_start | 2024-01-29 23:06:52.481908+09
state_change | 2024-01-29 23:06:52.48191+09
wait_event_type |
wait_event |
state | active
backend_xid |
backend_xmin | 863
query_id |
query | SELECT * FROM pg_stat_activity +
| WHERE pid = (SELECT PG_BACKEND_PID());
backend_type | client backend
- postgresは悲観ロック。
-
SELECT * FROM pg_locks;
ロック情報が取れる
-

- データスクラブ
- 不必要なデータを削除しながらデータベースを構築していくらしい。
- 手元で安全に本番同等のパフォーマンステストをするのに有効
- psqlの関数使ってテーブルのサイズを以下のように確認できる
SELECT PG_SIZE_PRETTY(
PG_TOTAL_RELATION_SIZE('rideshare.users')
);
// 結果
pg_size_pretty
----------------
2629 MB
(1 row)
// データめっちゃいれた
SELECT COUNT(*) From users;
count
----------
10020210
- memo: 途中のコードメモ
//userテーブルの統計対象データ数をset
ALTER TABLE users
ALTER COLUMN first_name SET STATISTICS 5_000;
//分析開始
ANALYZE users;
//分析結果出力
SELECT
attname,
n_distinct,
most_common_vals
FROM pg_stats
WHERE schemaname = 'rideshare' AND tablename = 'users'
AND attname = 'first_name';
- PostgreSQLにはデータベースレベルとテーブルレベルのコメントがある
-
CREATE OR REPLACE FUNCTION
でpostgresは関数をつくれそう - fxっていうgemを使うといい感じにmigrationでpostgresの関数を管理できる
-
SET LOCAL statement_timeout = '120s';
で一時的にtimeout引きあげるのが良さそうだ。俺の個人PCがもたねえぞ。。。 - 外部キー制約を見つけるコマンド初めて知った。
rideshare_development=> SELECT
rideshare_development-> conrelid::regclass AS table_name, conname AS foreign_key, PG_GET_CONSTRAINTDEF(oid)
rideshare_development-> FROM pg_constraint
rideshare_development-> WHERE contype = 'f'
rideshare_development-> AND connamespace = 'rideshare'::regnamespace ORDER BY conrelid::regclass::text, contype DESC;
table_name | foreign_key | pg_get_constraintdef
----------------------+---------------------+------------------------------------------------------------
trip_positions | fk_rails_9688ac8706 | FOREIGN KEY (trip_id) REFERENCES trips(id)
trip_requests | fk_rails_fa2679b626 | FOREIGN KEY (start_location_id) REFERENCES locations(id)
trip_requests | fk_rails_c17a139554 | FOREIGN KEY (rider_id) REFERENCES users(id)
trip_requests | fk_rails_3fdebbfaca | FOREIGN KEY (end_location_id) REFERENCES locations(id)
trips | fk_rails_6d92acb430 | FOREIGN KEY (trip_request_id) REFERENCES trip_requests(id)
trips | fk_rails_e7560abc33 | FOREIGN KEY (driver_id) REFERENCES users(id)
vehicle_reservations | fk_rails_7edc8e666a | FOREIGN KEY (vehicle_id) REFERENCES vehicles(id)
vehicle_reservations | fk_rails_59996232fc | FOREIGN KEY (trip_request_id) REFERENCES trip_requests(id)
- テーブルをコピーしたい場合、基本的には大量データを扱うときはクローン/コピーするのが良さそう。ただindexや制約を削除してから、データをコピーして、cloneするのが一番パフォーマンス良い。
- VACUUMのは不要領域の削除
- Autovacuumがバックグラウンドで動いている。大量削除や大量追加氏た場合は手動でやってもよい。
- https://www.postgresql.jp/docs/9.4/sql-vacuum.html
- PL/pgSQL で手続き的にプロシジャを書くことができる

- indexつけるときとかは
CONCURRENTLY
つけるとロックはされないが、総合時間はかかる。 - unique制約をあとから付ける場合にはCTE(共通テーブル)方式というパターンがあるらしい
- check制約がrailsでサポートされたのは6.1からしい
- Check制約は新しい追加更新にだけ適用することができる。それを利用して安全にuniqe制約などを追加していく戦略もある
- 制約の強制を遅らせることもできる(DEFERRABLEオプション)
- トランザクションの中で制約を一時的に遅らせて、SQLを単純化できる
- exclude制約はuniq制約よりも柔軟
- GISTインデックスというのを使ってる
拡張の追加に慣れてきた。確認方法は\dx
、\dn
でschema確認。
rideshare_development=> CREATE EXTENSION IF NOT EXISTS citext;
CREATE EXTENSION
rideshare_development=> \dx
仮想列というのがposgresで使えるらしい。postgresでは生成列と呼ばれている
```rb
create_table 'temp.customers' do |t| t.string :email
t.virtual :email_downcased,
type: :string,
as: 'LOWER(email)', stored: true
end
- postgresには型としてenum型がある
create_enum :vehicle_status, [ VehicleStatus::DRAFT, VehicleStatus::PUBLISHED]
add_column :vehicles, :status, :enum, enum_type: :vehicle_status, default: VehicleStatus::DRAFT, null: false
- Domainというオブジェクト型もあるらしい
- これは指定された集合の中の文字からデータが成っていることとを制約するらしい。
-
active_record_doctor
というgemがある- https://github.com/gregnavis/active_record_doctor
- データベースの型の正当性をチェックする感じかな
実行してみた。
~/Desktop/rideshare main !3 ?7 ❯ bin/rake active_record_doctor Ruby 3.2.2
trip_requests.end_location_id is a foreign key of type integer and references locations.id of type bigint - foreign keys should be of the same type as the referenced column
trip_requests.start_location_id is a foreign key of type integer and references locations.id of type bigint - foreign keys should be of the same type as the referenced column
vehicle_reservations.trip_request_id is a foreign key of type integer and references trip_requests.id of type bigint - foreign keys should be of the same type as the referenced column
vehicle_reservations.vehicle_id is a foreign key of type integer and references vehicles.id of type bigint - foreign keys should be of the same type as the referenced column
use `dependent: :delete_all` or similar on Vehicle.vehicle_reservations - associated model VehicleReservation has no callbacks and can be deleted in bulk
add an index on vehicle_reservations(trip_request_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
add an index on trip_positions(trip_id) - foreign keys are often used in database lookups and should be indexed for performance reasons
SearchResult references a non-existent table or view named search_results
FastSearchResult references a non-existent table or view named fast_search_results
the schema limits locations.state to 2 characters but there's no length validator on Location.state - remove the database limit or add the validator
add a unique index on users(drivers_license_number) - validating uniqueness in Driver without an index can lead to duplicates
add a unique index on trips(trip_request_id) - using `has_one` in TripRequest without an index can lead to duplicates
create a foreign key on trip_requests.rider_id - looks like an association without a foreign key constraint
create a foreign key on trips.driver_id - looks like an association without a foreign key constraint
add a `presence` validator to VehicleReservation.canceled - it's NOT NULL but lacks a validator
- 似たようなgemで
database_consistency
もある

- Multiversion Concurrency Control (MVCC) - 行の変更と同時アクセスを管理するメカニズム
- backfilling - 新しいテーブル設計のために新しい空のカラムを追加すること
- strong_migrationはお世話になってます
- defaultのみは早いが、手をいれると急に遅くなってロックがかかる
// 早い
ALTER TABLE temp.users ADD COLUMN city_id INTEGER
DEFAULT 1;
// 遅い
ALTER TABLE temp.users ADD COLUMN city_id INTEGER
DEFAULT 1 + FLOOR(RANDOM() * 25);
// ほかセッションで実行したらtimeoutになった
rideshare_development=> SELECT * FROM temp.users LIMIT 1;
ERROR: canceling statement due to lock timeout
LINE 1: SELECT * FROM temp.users LIMIT 1;
-
1つの対策が
CONCURRENTLY
のオプション使用- 2倍時間かかるらしいけど。説明はあとででるがindexの追加とかだとほぼ必要。オフライン実行のときは不要。
-
migrationの戦略として大きなテーブルに制約を加える場合
- 1: add_check_constraintをvalidate: falseで実行。新規データのcheckを有効化
- 2: validate_check_constraintで検証。 既存のデータの検証
- というやり方でやるとロックタイムを最小限にできる
-
lock_timeoutを設定すると永遠にトランザクションでロックになるのを防げる
-
log_lock_waits をonにしてdeadlock_timeoutを設定知るとpostgres.logに詳細な情報が記されて、pgbadgerはそれを容易に可視化してくれる
-
statament timeoutはクライアント側から設定する
-
activerecordのキャッシュのせいでアプリケーションエラーになる可能性もあるので、まずはignore_columnをして上げるのが良い。
-
効率の良いbackfill(データを一括で埋めること)の方法を考える必要がある
- ダブルライティング(置き換え前と置き換え後のテーブル両方に書き込み、読み込みは後者にする)
- 場合によっては中間テーブルが必要
-
SET UNLOGGED
にすると書き込みが早くなる。またautovaccumも無効にすると良い。

- 昔はよく使われてたらしい。SQLがどこから発行されたかを追跡できるgemらしい
- https://github.com/basecamp/marginalia
- rails7からはQueryLogsがある
- n+1検出はbullet使ってたけど、他にもたくさんあるなあ
- https://github.com/charkost/prosopite
- こんな感じででる。わかりやすい。
N+1 queries detected:
SELECT COUNT(*) FROM "vehicle_reservations" WHERE "vehicle_reservations"."vehicle_id" = 1 /*application='Rideshare'*/
SELECT COUNT(*) FROM "vehicle_reservations" WHERE "vehicle_reservations"."vehicle_id" = 2 /*application='Rideshare'*/
SELECT COUNT(*) FROM "vehicle_reservations" WHERE "vehicle_reservations"."vehicle_id" = 3 /*application='Rideshare'*/
SELECT COUNT(*) FROM "vehicle_reservations" WHERE "vehicle_reservations"."vehicle_id" = 4 /*application='Rideshare'*/
Call stack:
(irb):9:in `block (2 levels) in <top (required)>'
(irb):8:in `block in <top (required)>'
(irb):8:in `<top (required)>'
- includsは基本書かないで欲しい派です!
- rails6から
strict_loading
というものが使えてN+1を完全に防げる-
.select
で必要なカラムだけselectするとIOの節約に成る - rails7.1からいい感じにhashとか使ってselectできるようになってる
-
- rails7で導入された
load_async
使ってみたいぞ。 - RETURNINGキーワードというのが使えるらしい。postgresだと使える
- insertした行とかが返ってくるらしい。
- CTEは共通テーブル方式
-
.with
を使うとできる。(あらかじめ用意しとくサブクエリ的な)
-
- ActiveRecordではviewはサポートされてないけどgem使えばできる
- database viewはSQLをカプセル化する
- materialized viewは事前に計算し、結果を保存する
- https://github.com/scenic-views/scenic
- クエりchacheはSQLクエリの結果をcontrollerのアクションの間キャッシュする。
- prepared_statement(プリペアドステートメント)は、データベースで使用される概念で、SQL文をあらかじめコンパイルしておき、実行時にパラメータを渡すことで繰り返し効率的に実行できるようにしたものです。activerecordだとデフォルトで有効になっている。
- counter cacheでcountクエリを高速化できる(デメリットもあるが)
- .countではなく.sizeを使うのもパフォーマンス向上の1つ
- 平均計算とかはSQLの組み込み関数を使ったほうが早い

- デフォルトで使用できる
Active Support Notifications
でイベントを発行できる - pgss(pg_stat_statement)はDBの統計情報を確認できるviewを作成する
- PostgreSQLで問い合わせを受け取ると、PGSSはそれらをグループに分類し、グループに識別子を付与し、グループレベルの統計情報を取得します
- pgHeroはパフォーマンスダッシュボード
- ExplainにAnlyze引数をとると実際の実行時間が返される。
- Buffers引数を追加するとフェッチされるデータ数(IO)を把握できる
- indexをサジェストしてくれるgemもいくつかあるそう
- auto_explainモジュールを使うと、自動的な実行計画取得ができる
- dbのcountは時間がかかるがいくつかのトリックで対策できるそう
- countの代わりに推定値をdす。
- gemもある
全然違った。すげえ。統計値更新すればもっと正確になるかも?
irb(main):002> User.fast_count
(38.1ms) SELECT fast_count('users', 100000) /*application='Rideshare'*/
=> 19949796
irb(main):003> User.count
[sql.active_record] 14.766931 SELECT COUNT(*) FROM "users" /*application='Rideshare'*/
User Count (14767.0ms) SELECT COUNT(*) FROM "users" /*application='Rideshare'*/
=> 20040420
irb(main):004>
- FILTER句が使える。スコープ付き集約関数(case文とかで使えそう)
- ActiveRecordには
optimizer_hints
メソッドがあるらしい - Rails7.1からは
User.where("id <= 10").explain(:analyze)
みたいにexplainにanalizeを指定できる - rails-pg-extrasはSQLを分析できるようなCLIを追加してくれるgem

postgresのexplain。mysqlに慣れてたからあれって思ったけど、普通に見やすかった。
rideshare_development=> EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM temp.users WHERE last_name IN ('lname10000', 'lname100000', 'lname1000000');
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------
Index Scan using users_last_name_idx on users (cost=0.43..19.97 rows=3 width=73) (actual time=0.038..0.053 rows=3 loops=1)
Index Cond: (last_name = ANY ('{lname10000,lname100000,lname1000000}'::text[]))
Buffers: shared hit=9 read=6
Planning:
Buffers: shared hit=15 read=1 dirtied=2
Planning Time: 0.278 ms
Execution Time: 0.077 ms
(7 rows)
-
indexについての情報を出力するクエリ
-
multicolumn Indexは冗長になりやすい
-
PostgreSQLはいくつかの操作で個々のインデックススキャン結果を組み合わせることができる(わざわざmultiにする必要がない場合が多い)
-
部分インデックスは、定義に式を組み込んだB-Treeインデックス
-
CREATE INDEX index_name ON table_name(column_name) WHERE condition;
こんなイメージ - bool値でdefalutがtrueの場合だとindexにfalseの条件を仕込めば、少ない容量でindexを有効に使える。これはfalseでの検索が多いときに便利
-
-
式インデックスは、定義に式を持つインデックスで、格納される前に値を変換します。
CREATE UNIQUE INDEX index_temp_users_lower_email_unique ON temp.users (LOWER(email));
- LOWERでメールアドレスを正規化する。
-
jsonとjsonb型がある
-
jsonbのような1カラムに複数ともいえるデータが入るときはgin indexを使うとよいそう。
-
jsonbだとこんな感じの包む
-
包含演算は
@>
で以下のように使う
SELECT * FROM trips
WHERE data @> '{"ride_details":{"water_offered": true}}';
- jsonb_path_opsを指定すると、JSONデータ内の特定のパスやキーを対象としたクエリが高速化されます
- postgres-json-schemaは、JSONスキーマ定義をデータベースに追加することをサポートします。
- ブロック範囲インデックス(BRIN INDEX)というのがあるらしい
- BRINが有効であるためには、物理レイアウトと対象カラムの順序が強く相関しているテーブルが必要である。
btreeよりbrinのほうが軽い
rideshare_development=*> SELECT PG_SIZE_PRETTY( PG_RELATION_SIZE('trip_positions_created_at_btree'));
pg_size_pretty
----------------
113 MB
(1 row)
rideshare_development=*> SELECT PG_SIZE_PRETTY( PG_RELATION_SIZE('trip_positions_created_at_brin'));
pg_size_pretty
----------------
32 kB
(1 row)
- hashインデックスというのもあるらしい
- ハッシュインデックスはカラム値の代わりにソースカラムから計算されたハッシュを格納するため、全体的に使用するスペースが少なくて済む。
- カバレッジ・インデックスとは、インデックス・エントリがクエリに必要なすべてのデータを提供するインデックスの定義を指す

- 3つの重要なメンテナンス操作は、VACUUM、ANALYZE、REINDEXです。この3つをVARと略す
- Automatic Vacuumは、各テーブルに対してVACUUMワーカーを実行するバックグラウンドスケジューラ
- ガベージコレクションみたいなもん
- 行バージョンは以下のような形でみれる(ページ数, タプル番号)
rideshare_development=> SELECT ctid,id FROM users WHERE id = 1;
ctid | id
------------+----
(41443,11) | 1
- updateするとタプルが更新され(バージョンみたいなもん)、ページは変わることもあれば変わらないこともある
- よってupdate等をするとタプルが増えて肥大化し始める。。閾値を修正して対応することができる
- デフォルトの閾値は0.2(ターブルサイズの20%がデッドタプル)
- 下げるとよりはやくvacuumが実行される
- インデックスもデッドタプルのせいで肥大化する
-
VACUUM (ANALYZE, VERBOSE) users;
手動でタプルと統計情報を更新する
最後にanalyzeされたタイミングを知ることもできる
rideshare_development=> SELECT
rideshare_development-> schemaname,
rideshare_development-> relname,
rideshare_development-> last_autoanalyze,
rideshare_development-> last_analyze
rideshare_development-> FROM pg_stat_all_tables WHERE relname = 'vehicles';
schemaname | relname | last_autoanalyze | last_analyze
------------+----------+------------------+-------------------------------
rideshare | vehicles | | 2024-02-03 01:06:07.150831+09
(1 row)
- posgresには未使用indexを追跡できる機能がある
- 同じindex名はつけられないがindex名を変えた同じindexは作れてしまう。(無駄な重複)
- pg_cronを使うと定期的なpostgresコマンドの実行ができる

- active recordがSQLクエリ作成 → postgresqlアダプタがpostgreSQLとの互換性チェック → pg gemがrubyからPostgresSQLへのクライアント接続を管理 → active recordはクライアント側のconnection poolを使用し、pool数を設定→ active recordはクエリを実行時にプールから接続をcheckout, → postgres側で問い合わせを受取処理する。→ active recordはconnectionを閉じる必要がある。(アイドル状態を維持することで既存の接続を再利用することもできる)
pg_stat_activityでコネクション状態を関しできる
-- Use \watch to continually monitor results
SELECT
pid,
datname,
usename, application_name, client_hostname, client_port, backend_start, query_start, query,
state
FROM pg_stat_activity
- idle_session_timeoutでアイドル接続に上限を設けることができる
- コネクション数とpumaやsidekiqのプロセス/スレッド数を対応させる必要がある
- pg bouncerはossの接続プーラー。railsとpostgresの仲介約で、max接続を超えても拒否せず遅延させたり、接続を効率よく管理する。
- 設定周りめっちゃつっかかっちゃった。でもpgbouncerに接続できたぞ!!!
~ ❯ psql -p 6432 -U owner pgbouncer 6s
Password for user owner:
psql (16.1, server 1.22.1/bouncer)
WARNING: psql major version 16, server major version 1.22.
Some psql features might not work.
Type "help" for help.
pgbouncer=# SHOW HELP;
NOTICE: Console usage
DETAIL:
SHOW HELP|CONFIG|DATABASES|POOLS|CLIENTS|SERVERS|USERS|VERSION
SHOW PEERS|PEER_POOLS
SHOW FDS|SOCKETS|ACTIVE_SOCKETS|LISTS|MEM|STATE
SHOW DNS_HOSTS|DNS_ZONES
SHOW STATS|STATS_TOTALS|STATS_AVERAGES|TOTALS
SET key = arg
RELOAD
PAUSE [<db>]
RESUME [<db>]
DISABLE <db>
ENABLE <db>
RECONNECT [<db>]
KILL <db>
SUSPEND
SHUTDOWN
WAIT_CLOSE [<db>]
SHOW
pgbouncer=#
pgbouncer=# SHOW DATABASES;
pgbouncer=# SHOW CLIENTS;
pgbouncer=#
- pgbouncerにはいろいろなpool modeがある
- statement timeout, locktimeoutを設定するとより管理がしやすいかつ安全
- 行ブロックは読み取りもブロックする排他ロック
- lock_timeoutを活用、 no waitキーワードの使用が対策
-
https://www.citusdata.com/blog/2018/02/15/when-postgresql-blocks/
- いい感じになにと何がlockするか書かれている
- pgbadgerでlockに関するログ分析dhができる
- acive record migration などは Advisory Locksが使われる
- 保留中のマイグレーションを単一のプロセスに適用することを制限

- gemを使ったSQLの実行は必ずしも適切なindexが使われているわけではない
- limitとoffsetのページネーションは同時更新などが起こると順番が崩れる可能性がある。また行数が多く、非効率な可能性もある
- ページネーションではCURSORキーワードという手段もある
- ステートフルな設計になるらしい
- キーセットページネーションというのもある

- 読み込みは水平方向にスケールできるが書き込みは垂直方向のみ
-
TOTAL.times.to_a.in_groups_of(BATCH_SIZE)
,d.attributes.symbolize_keys.slice(
ここらへんあんま使わないけど使えそうな処理 - もうupsert/insertがない世界には戻れない
- postgresで競合を解決するのは以下の2つがある
-
ON CONFLICT DO NOTHING
ON CONFLICT DO UPDATE
-
- activerecord-importをあんま使わなくなったなあ
- PostgreSQLはCSVファイルからのデータの一括読み込みをサポートしています。
- PostgreSQLでは、外部データラッパ(FDW)と呼ばれる機能を使用して、外部データソースに接続することができる

- 13章は流し見。複数DB構成や水平シャーディングのお話。いつか手元でやってみる
- 14章も流し見。パーティショニングのお話。
- 15性も流し見。全文検索とかのお話があった。
モチベがだんだん下がっていったので一旦終了。また最後のほうは別途読みたい。シャーディングとかパーティショニングあたりは別途勉強したいなあ