[PERFORMANCE]INTERMEDIATE

PostgreSQLのIOPSが計算通り出なかった理由がrecordsizeだった話

ワークロードのIOPS要件から逆算してディスク本数を決めたはずのPostgreSQLサーバーで、実際のIOPSが計算値の何分の1も出ていなかったことがあります。犯人はrecordsizeをデフォルトの128KiBのまま放置していたことでした。読み込み増幅の仕組み、なぜ後から変更しても既存データには効かないのか、そして圧縮アルゴリズムの選び方を図解つきで整理します。

以前書いたサイジングの記事で、ワークロードのIOPS要件からディスク本数を逆算する話をしました。その計算通りにディスクを揃えたはずのPostgreSQLサーバーで、実際のIOPSが計算値よりだいぶ低いことに気づいたことがあります。iostatで見ると、8KiBのはずの書き込みが妙に大きなI/Oとして発行されていました。犯人はrecordsizeをデフォルトの128KiBのまま放置していたことです。DBが8KiB単位でランダムに書き込むたびに、128KiB全体を読み込んで書き戻す増幅が起きていました。

この記事では、recordsizeが読み込み増幅にどうつながるのか、なぜ後から変更しても既存データには効かないのか、そして圧縮アルゴリズムをどう選ぶべきかを、図解を交えて整理します。

recordsizeは「1ブロックの最大サイズ」であって固定サイズではない

recordsizeは、ZFSがデータセット上のファイルをブロック(内部的にはレコードと呼ぶ)に分割する際の最大サイズを指定するプロパティです。デフォルトは128KiBです。

zfs set recordsize=16K tank/postgres

小さいファイルであれば、そのファイルサイズに収まる範囲でより小さいレコードとして格納されます。しかしrecordsizeより大きいファイルは、指定サイズの倍数で複数のレコードに分割されます。この「複数レコードへの分割」こそが、ランダムI/Oワークロードでの性能を左右する核心です。

読み込み増幅(read amplification)という問題

ZFSはCopy-on-Write(CoW)方式を採用しており、既存のブロックを直接上書きすることはありません。あるレコードの一部だけを変更したい場合でも、ZFSはそのレコード全体を読み込み、メモリ上で書き換え、新しいブロックとして丸ごと書き直します。これが読み込み増幅です。

8KiBのランダム書き込みが引き起こす読み込み増幅(recordsize別)

recordsize = 128KiB(1レコード = 128KiB全体)16KiB16KiB16KiB8KiB書込み16KiB16KiB16KiB16KiB→ 8KiBだけ変更したいのに、128KiB全体を読み込んで書き戻す(read-modify-write)recordsize = 16KiB(複数の独立したレコードに分割)16KiB16KiB16KiB8KiB書込み16KiB16KiB16KiB16KiB→ 書き換えが必要なのは該当する16KiBレコード1つだけ。他のレコードは無関係(ただしレコード数が増えるため、メタデータ量は相対的に増加する)

冒頭のPostgreSQLサーバーがまさにこれでした。8KiB単位でランダムに書き込みを行うDBがrecordsize=128KiB(デフォルト)のデータセットに乗っていると、8KiBを更新するために128KiB全体を読み込んで書き戻すことになります。これは実に16倍の読み込み増幅です。一方recordsizeをDBのページサイズに近い16KiBや8KiBに合わせておけば、影響範囲はそのレコードだけに抑えられ、増幅は大幅に軽減されます。

多くのDBエンジンが推奨するrecordsize設定は次の通りです。

ワークロード推奨recordsizeの目安
PostgreSQL8K(PostgreSQLのデフォルトページサイズに合わせる)
MySQL(InnoDB)16K(InnoDBのデフォルトページサイズに合わせる)
VMイメージ(zvol以外でファイルとして保存する場合)ゲストOS側のファイルシステムブロックサイズに近い値(16K32Kなど)
一般的なファイルサーバー/大容量順次アクセスデフォルトの128Kのままで問題ないことが多い

zvolを使う場合はrecordsizeではなくvolblocksizeが同等の役割を果たします。こちらはzvol作成時にしか設定できないため、VM用のzvolを作る前に想定ワークロードのI/Oサイズを確認しておく必要があります。

recordsizeを小さくしすぎない理由

recordsizeを小さくすれば読み込み増幅は減りますが、無条件に小さくすればよいわけではありません。同じ量のデータでも、recordsizeが小さいほどブロック数が増え、それを管理するブロックポインタなどのメタデータのオーバーヘッドが相対的に増加します。

recordsizeを小さくするほどメタデータ比率が増える(同一データ量での概念比較)

recordsize = 16KiBブロック数(概念値): 64個 — ブロック数が多く、メタデータ管理コストが相対的に増えるデータ 78%メタデータ 22%recordsize = 128KiB(デフォルト)ブロック数(概念値): 8個 — ブロック数は少なく、メタデータ比率は小さいデータ 94%メタデータ 6%recordsize = 1MiBブロック数(概念値): 1個 — ブロック数は最小だが、小さな書き込みの増幅は最大になるデータ 98%
データ本体 ブロックポインタ等のメタデータ管理コスト

※ 比率・ブロック数は傾向を示す概念値であり実測値ではない。実際の比率はワークロードとデータ構造に依存する。

これは、special vdevの記事で触れた「メタデータ量はデータ量ではなくブロック数に強く依存する」という話と直結します。recordsizeを極端に小さくする設定は、special vdevやARCへのメタデータ負荷を増やす方向に働くため、ワークロードのI/Oサイズに合わせて必要以上に小さくしないのが実務上のバランスです。むやみに4Kまで下げるのではなく、DB側のページサイズという明確な根拠がある値に合わせるのが基本方針になります。

recordsize変更は既存データに遡及しない

これも実務でよく踏む罠です。zfs set recordsize=...は、それ以降に新規作成されるレコードにのみ適用され、既存のファイルの物理的なレコード分割には影響しません。 運用中のデータセットでrecordsizeを変更しても、すでに書き込まれているデータのレイアウトはそのままです。

既存データにも新しいrecordsizeを反映させたい場合は、次のいずれかの方法でデータを書き直す必要があります。

# 方法1: ファイルをコピーし直す(同一データセット内でも、コピーは新しいrecordsizeで書かれる)
cp /tank/postgres/data.file /tank/postgres/data.file.new
mv /tank/postgres/data.file.new /tank/postgres/data.file

# 方法2: send/receiveで新しいデータセットに丸ごと移送する
zfs send tank/postgres@snap1 | zfs recv tank/postgres_new

special vdevのspecial_small_blocksや暗号化の有効化と同じく、「設定を変えた」と「既存データに効いている」はイコールではないという点を、変更作業のたびに確認してください。

lz4とzstd、どちらを選ぶか

recordsizeと並んで見落とされがちなのが圧縮アルゴリズムの選択です。ZFSの圧縮はデフォルトで無効ではなく、多くの環境でlz4が標準的に有効化されています。圧縮は前段のrecordsizeで区切られたレコード単位にかかるため、recordsizeと圧縮は独立した設定でありながら、実際には互いに影響し合う関係にあります(レコードが大きいほど、圧縮アルゴリズムが利用できる冗長性の範囲が広がり、圧縮率が上がりやすくなる傾向があります)。

圧縮アルゴリズムの速度 vs 圧縮率(概念図)

速度(CPUコストの低さ) →圧縮率 →off(無圧縮)lz4zstd(標準レベル)zstd(高レベル)gzip
  • off(無圧縮): CPU負荷は最小だが容量削減効果もゼロ
  • lz4: デフォルト推奨。高速かつ圧縮不能データを即座に見切る
  • zstd(標準レベル): lz4よりCPUコストは高いが圧縮率も高い
  • zstd(高レベル): 圧縮率は最大級だがCPU負荷も最大級
  • gzip: CPUコストの割に圧縮率はzstdに劣ることが多い

※ 位置づけは傾向を示す概念図であり、ベンチマーク実測値ではない。実際の数値はCPU・データの圧縮可能性・zstdのレベル設定に大きく依存するため、必ず自分の環境で計測すること。

  • lz4: 圧縮・展開が非常に高速で、圧縮できないデータ(すでに圧縮済みの動画/画像など)を即座に見切って生データのまま格納する機能を持っています。CPUコストの低さから、多くの環境でのデフォルト推奨です
  • zstd: lz4より高い圧縮率を狙えますが、その分CPUコストも上がります。zstd-1からzstd-19まで圧縮レベルを指定でき、レベルを上げるほど圧縮率は上がりCPUコストも上がるという明確なトレードオフがあります
  • gzip: CPUコストの割に圧縮率がzstdに劣ることが多く、現在新規に選ぶ理由は薄れています
zfs set compression=lz4 tank/general
zfs set compression=zstd-3 tank/archive    # レベルを指定してzstdを使う例

判断の目安としては、CPU負荷に余裕があり容量効率を優先したいデータセット(アーカイブ、バックアップ先など)にはzstdレイテンシに敏感なワークロード(DB、VM)にはlz4、という切り分けが実務上のスタート地点になります。ただし、この図はあくまで概念的な位置づけであり、実際の速度・圧縮率はCPU性能とデータの圧縮可能性に大きく左右されるため、fioなどで自分のワークロードに対して圧縮ON/OFF・アルゴリズム違いを実測してから最終判断することを推奨します。

圧縮も既存データには遡及しない

recordsizeと同様、compressionプロパティの変更も新規書き込みにのみ適用されます。すでに無圧縮で書き込まれているデータを圧縮しても、既存のブロックが自動的に圧縮し直されることはありません。過去データもまとめて圧縮の恩恵を受けたい場合は、recordsize変更時と同じくファイルの書き直しかsend/receiveでのデータ移送が必要です。

あのPostgreSQLサーバーはどうなったか

recordsize=8Kのデータセットを新規に作り、zfs send/recvでデータを移送してから旧データセットを捨てました。切り替え後、iostatのI/Oサイズは狙い通り8KiB前後に落ち着き、計算していたIOPSにようやく近づきました。あのとき最初から確認しておけばよかったのは、結局この点だけです。

  • DB/VMなど小さなランダム書き込みが多いデータセットで、recordsizeをデフォルトの128KiBのままにしていないか
  • recordsizeの変更値は、ワークロード側のページサイズ(PostgreSQLなら8K、InnoDBなら16Kなど)という根拠のある値になっているか
  • zvolを使う場合、volblocksizeは作成時にしか設定できないことを踏まえて事前に決めているか
  • recordsizecompressionを変更した後、既存データには遡及しないことを理解した上で、必要であればsend/receiveでの書き直しを計画しているか
  • 圧縮アルゴリズムを「とりあえずデフォルト」で済ませず、CPU負荷と圧縮率のどちらを優先するかをデータセットごとに判断しているか

special vdevやasync destroyの記事でも触れた「設定変更が既存データには効かない」というパターンは、recordsizeと圧縮でも同じです。プール/データセット設計の初期段階でワークロードのI/O特性を把握しておけば、今回の自分のようなiostatとのにらめっこは避けられます。