本記事はエムスリー Advent Calendar 2020 の9日目の記事です。
エムスリーエンジニアリンググループの木村です。
今回は今年遭遇したエラーの中で面白かったPostgreSQLのターゲット上限値エラーの話をします。
我々のチームで開発・運用しているアンケートシステムではデータベースにCloudSQL(PostgreSQL)を利用しています。
アンケートシステムでは、ユーザのアンケートの回答を集計するために様々なクエリを駆使します。 その時、発行しようとしたクエリは下記のような結果を得るものでした。
具体的なクエリは以下のようなものになります(あくまでイメージ。実際はもう少し複雑です)。
SELECT user_id , question1_answer , question2_answer , question3_answer , (略) FROM user u JOIN question q ON u.id = q.user_id GROUP BY u.id
縦方向にユーザIDを並べて、横方向にアンケート質問に対するユーザの回答を並べています。
アンケートシステムをリリースした直後、ユーザに提供する質問の数が少ない間はこのクエリは正常に実行されていました。
ところがある日、このクエリがエラーを吐くようになります。下記のようなエラーです。
Error: target lists can have at most 1664 entries
原因を調査したところSELECT句に指定しているターゲットの数が多すぎた(1664個を超えた)ことが原因でした。 アンケートシステムが軌道に乗り、質問の数が増えたことで横方向に並べる質問の数が多くなってしまったことが良くなかったのですね。
このエラーを受けて集計手段を変更し、横方向に質問を並べる方法を回避したことでエラーは解消されました。めでたしめでたし。
ここで気になるのはターゲットの数の上限値1664
です。
2のN乗というわけでもありませんし、あまり切りの良い数値ではありません。
何故上限値は1664
なのでしょうか。
PostgreSQLのソースコードを読む
数値の謎に迫るべくPostgresSQLのソースコードを読みました。PostgreSQLのバージョンは14です (実際に運用しているバージョンはもう少し古いものなのですが、今回はあまり関係ありませんでした)。
grepコマンドで問題の値を問題の値はPostgreSQLのソースコード内のsrc/include/access/htup_details.h
に定義されていました。
$ grep define src/ | grep 1664 src/include/access/htup_dtails.h:33:#define MaxTupleAttributeNumber 1664 /* 8 * 208 */
どうやら値1664
というのは8 * 208
の結果のようです。
またこの値は稼働しているデータベース中のinformation_schema
スキーマのsql_sizing
テーブルにも格納されています
#=> SELECT * FROM information_schema.sql_sizing; sizing_id | sizing_name | supported_value | comments (略) 100 | MAXIMUM COLUMNS IN SELECT | 1664 | NULL (略)
さて、ファイルsrc/include/access/htup_details.h
のこの定義の上の行には、数値の理由についてコメントで詳細が書いてあります。
下記はそれを意訳したものです。
この数値の制限は、タプル構造体の固定オーバヘッドのサイズと NULLビットマップのサイズとMAXALIGNによるアライメントを足した値が uint8型である`t_hoff`に収まらなければならないためです。 またこの`MaxTupleAttributeNumber`のために丸められた値を使用して `HeapTupleHeaderData`のレイアウトが変更された場合でも、 最大列数が変更されてないようにしています。
このコメント中に出てくるHeapTupleHeaderData
というのは構造体で、
同じsrc/include/access/htup_details.h
ファイルに以下のように定義されています(一部抜粋)。
struct HeapTupleHeaderData { (略) unit8 t_hoff; /* sizeof header incl. bitmap, padding */ /* ^ - 23 bytes - ^ */ bits8 t_bits[FLEXIBLE_ARRAY_MEMBER]; /* bitmap of NULLs */ }
構造体の中のメンバーとして、コメントでも触れられていたunit8
型のt_hoff
とNULLビットマップが出てきています。
またコメントにより構造体の先頭からt_hoff
までのサイズが23bytesであることも分かります。
先程のコメントと照らし合わせると、この23bytesがタプル固定のオーバヘッドのサイズのように思えます。
とここまでは良いのですが、先程のコメントにもある通りHeapTupleHeaderData
のレイアウトは変更されます。
変更の理由として考えられるのは、
動作環境のOSが32bitか64bitかによって構造体のメンバのビット幅が変わるであったり、
メンバーの型が拡張されるなどでしょうか。
これを考慮してタプル固定のオーバヘッドサイズをPostgreSQLでは23bytesの倍、46bytesと見積もっています。 予めオーバヘッドサイズを大きく見積もることでレイアウトに変更があった場合でもその変更が他の値に影響を与えないようにしています。
更にこのサイズにはMAXALIGN
によるアライメントが効いてきます。
MAXALIGN
はマクロで定義されており、指定されたサイズを8の倍数のメモリにとって切りの良い数値にアライメントするものです。
こういったビットレベルでのレイアウトを気にするソフトウェアにありがちですが、 46bytesなどの切りが悪い数値は効率的なメモリ操作が難しいため8の倍数にパディングアライメントされます。 46bytesの場合は48bytesにアライメントされることになります。
ここまでを整理すると、タプル構造体の固定オーバヘッドのサイズが46bytes、MAXALIGN
アライメントで2bytesの使用されることになります。
unit8
(符号なし8bits整数)で表せるのは2 ^ 8 = 256
までの数値なので、残りは256 - (46 + 2) = 208
です。
この208
がNULLビットマップのバイトサイズということになります。
ここでNULLビットマップについて説明しておくと、これはSELECT句のターゲット1つひとつについて、その値がNULLであるか否かを管理しているものです。 1bitがSELECT句のターゲット1つを表しており、ビットマップの値が1であればNULL、0であれば非NULLとなります。
ということはNULLビットマップで表せる個数の上限値がSELECT句のターゲットの上限値であると言うことができます。
ターゲットがこれ以上増えるとNULL/非NULLの管理をするビットマップ領域が足りなくなるためです。
先程計算したとおり、NULLビットマップのサイズは余った208bytesですから、208 * 8(bits) = 1664
がSELECT句のターゲット数上限と言うことになりますね。
まとめ
PostgreSQLのSELECT句ターゲットの上限値について見てきました。
上限値がNULLビットマップのサイズに依存しているというのはあまり知られていない面白い話なのではないでしょうか。
We are hiring!
エムスリーでは PostgreSQL を使った開発に興味のあるエンジニアを募集しています。社員とカジュアルにお話することもできますので、興味を持たれた方は下記よりお問い合わせください。