OrioleDB のファイル格納とブロックレベル圧縮

引き続き、[OrioleDB]() の記事を投稿していきます。これまでの記事は、[OrioleDBタグで検索](https://qiita.com/tags/orioledb)してください。

本稿では [OrioleDB](https://github.com/orioledb/orioledb) のデータ圧縮機能（ブロックレベル圧縮）を見ていきます。それに付随して、テーブルに対応したファイルがどこにどう格納されているか、という点も確認していきます。これがわからないと圧縮の効果を調べることができませんので。

今回は パッチ適用済み PostgreSQL 17_5 と orioledb beta9 を使って検証しました。

## OrioleDB のファイル格納

Access method を orioledb としたテーブルは、データベースクラスタディレクトリ($PGDATA)下の orioledb_data ディレクトリのデータベースの OID番号のディレクトリ中のファイルとして格納されます。ls で眺めるとファイルノード番号のファイルと .map ファイルができています。

```console
[postgres@rocky9 ~]$ ls $PGDATA/orioledb_data/16384
25823         25827         25837         25848         25859
25823-69.map  25827-69.map  25837-77.map  25848-78.map  25859-80.map
25824         25836         25847         25858
25824-69.map  25836-77.map  25847-78.map  25858-80.map
```

OrioleDB のテーブルは、テーブル本体が主キーをキーとした B-treeインデックスになっています。MySQL の innoDB と同じ具合です。これは primary と呼ばれます。
主キー以外のインデックスは二次インデックスとして別ファイルになります。
また、1フィールドのサイズが大きい列データは toast としてやはり別ファイルに格納されます。役割としてはネイティブ PostgreSQL の TOAST と同じです。

実際にテーブルを作って対応付けを確認してみます。テーブル tbl1 に各々3000バイトのランダムなバイト列を含む 100行のデータを投入します。

```console
db1=# CREATE TABLE tbl1 (id int primary key, c1 int, ts timestamp, lob bytea);
CREATE TABLE
db1=# CREATE INDEX idx_tbl1_ts ON tbl1 (ts);
CREATE INDEX
db1=# INSERT INTO tbl1 SELECT g, (random()*10)::int, now() + (g || 'min')::interval,  pg_read_binary_file('/dev/urandom', 0, 3000) FROM generate_series(1, 100) g;
INSERT 0 100

db1=# SELECT oid, relname, relfilenode, reltoastrelid FROM pg_class WHERE relname ~ 'tbl1';
  oid  |   relname   | relfilenode | reltoastrelid
-------+-------------+-------------+---------------
 25821 | tbl1_pkey   |       25824 |             0
 25816 | tbl1        |       25826 |         25819
 25827 | idx_tbl1_ts |       25827 |             0
(3 rows)

db1=# SELECT oid, relname, relfilenode, reltoastrelid FROM pg_class WHERE oid = 25819;
  oid  |    relname     | relfilenode | reltoastrelid
-------+----------------+-------------+---------------
 25819 | pg_toast_25816 |       25823 |             0
(1 row)

db1=# CHECKPOINT;
CHECKPOINT
```

ファイルとの対応付けは pg_class の relfilenode でファイルノード番号を調べればわかります。メモリ上にあって未だファイルができていないことがあるので、CHECKPOINT を実行してファイルに書き出させます。

以下のように、pg_toast_25816、tbl1_pkey、idx_tbl1_ts について、それぞれファイルができています。tbl1 (25826) についてはファイルがありません。主キーインデックスこそがテーブルデータの本体だからです。

```console
[postgres@rocky9 ~]$ ls -l $PGDATA/orioledb_data/16384/
（中略）
-rw-------. 1 postgres postgres   417792 Mar 19 09:34 25823
-rw-------. 1 postgres postgres       40 Mar 19 09:34 25823-69.map
　→ pg_toast_25816（tbl1 の TOASTテーブル、408KB）

-rw-------. 1 postgres postgres     8192 Mar 19 09:34 25824
-rw-------. 1 postgres postgres       40 Mar 19 09:34 25824-69.map
　→ tbl1_pkey (テーブル本体インデックス、8KB）

-rw-------. 1 postgres postgres     8192 Mar 19 09:34 25827
-rw-------. 1 postgres postgres       40 Mar 19 09:34 25827-69.map
　→ idx_tbl1_ts（二次インデックス、8KB） 
```

### orioledb_relation_size関数

OrioleDB のテーブルは pg_relation_size関数ではサイズを調べることができません。0 や明らかに違った値が返ります。orioledb拡張には orioledb_relation_size(oid) という関数が含まれていて、これを使えばいいのか、と思うのですが・・・

```console
db1=# SELECT pg_relation_size('tbl1'::regclass);
 pg_relation_size
------------------
                0
(1 row)

db1=# SELECT orioledb_relation_size('tbl1'::regclass);
 orioledb_relation_size
------------------------
                 425984
(1 row)
```

上記の結果は大体合っているのですが、ちょっと変な値です。primary と toast と二次インデックスを全部足すと、 417792 + 8192 + 8192 = 434176 で、もう 8KB 大きいはず。かといって、二次インデックスを追加すると orioledb_relation_size の結果値は増えて、DROP INDEX で二次インデックス除くと減りますので、二次インデックスは含まれているようです。primary を含まないというのも変です。何かバグがあるようです。

### toast はいつ使われる？

PostgreSQL では 1行のデータサイズが 2KB を超えると、1列のデータが大きい列について TOAST 格納が適用されました。OrioleDB で toast が使われるのはどこからでしょうか？

今度は主キーと 1000バイトのバイナリ列だけのテーブルを作ってみます。

```
db1=# CREATE TABLE tbl2 (id int primary key, dat bytea);
CREATE TABLE
db1=# INSERT INTO tbl2 SELECT g, pg_read_binary_file('/dev/urandom', 0, 1000) FROM generate_series(1, 100) g;
INSERT 0 100

db1=# SELECT oid, relname, relfilenode, reltoastrelid FROM pg_class WHERE relname ~ 'tbl2' OR oid = 25832;
  oid  |    relname     | relfilenode | reltoastrelid
-------+----------------+-------------+---------------
 25832 | pg_toast_25829 |       25836 |             0
 25834 | tbl2_pkey      |       25837 |             0
 25829 | tbl2           |       25839 |         25832
(3 rows)

db1=# CHECKPOINT ;
CHECKPOINT
db1=# \q
[postgres@rocky9 ~]$ ls -l $PGDATA/orioledb_data/16384/
（中略）
-rw-------. 1 postgres postgres   131072 Mar 19 10:53 25837
-rw-------. 1 postgres postgres       40 Mar 19 10:53 25837-74.map
→ tbl2_pkey のみ 128KB、toastファイル無し
```

今度は toast のファイルは作られませんでした。（整数 4バイト + 1000バイト + メタデータ）× 100行ですので、128KB は順当なサイズです。

これに 2100バイトのバイト列のデータを 100行追加します。

```console
db1=# INSERT INTO tbl2 SELECT g, pg_read_binary_file('/dev/urandom', 0, 2100) FROM generate_series(101, 200) g;
INSERT 0 100
```

まだ toast ファイルは現れません。

```console
[postgres@rocky9 ~]$ ls -l $PGDATA/orioledb_data/16384/
（中略）
-rw-------. 1 postgres postgres   417792 Mar 19 11:03 25837
-rw-------. 1 postgres postgres       40 Mar 19 10:53 25837-74.map
-rw-------. 1 postgres postgres       48 Mar 19 11:03 25837-76.map
-rw-------. 1 postgres postgres        8 Mar 19 11:03 25837-76.tmp
```

さらに 3000バイトのバイト列のデータを足すと・・・

```console
db1=# INSERT INTO tbl2 SELECT g, pg_read_binary_file('/dev/urandom', 0, 3000) FROM generate_series(201, 300) g;
INSERT 0 100
```

今度は toastファイルが出現しました。データサイズから推測すると、新たに投入された行だけ toast ファイルに格納されているように見えます。

```console
[postgres@rocky9 ~]$ ls -l $PGDATA/orioledb_data/16384/
（中略）
-rw-------. 1 postgres postgres   417792 Mar 19 11:05 25836
-rw-------. 1 postgres postgres       40 Mar 19 11:05 25836-77.map
-rw-------. 1 postgres postgres   425984 Mar 19 11:05 25837
-rw-------. 1 postgres postgres       40 Mar 19 10:53 25837-74.map
-rw-------. 1 postgres postgres        8 Mar 19 11:03 25837-76.tmp
-rw-------. 1 postgres postgres       48 Mar 19 11:05 25837-77.map
-rw-------. 1 postgres postgres        8 Mar 19 11:05 25837-77.tmp
```

この動作確認の結果からはtoast使用の閾値は 2100バイト～3000バイトの間くらいということになります。ソースコードを眺めたところでは、インデックスに格納できる1エントリサイズ（O_BTREE_MAX_TUPLE_SIZE = (1ページ 8KB - ヘッダサイズ）÷ 3 = 2.6KB くらい）が閾値のようです。


## データ圧縮を使ってみる

いよいよデータ圧縮を使ってみます。
データ圧縮を有効にするには postgresql.conf で以下の設定パラメータを有効にして 1以上の値を指定する方法と CREATE TABLE および二次インデックスの CREATE INDEX でストレージパラメータ compress、 toast_compress、 primary_compress を指定する方法があります。デフォルトは -1 で圧縮無効を意味します。

```conf
orioledb.default_compress = -1
orioledb.default_primary_compress = -1
orioledb.default_toast_compress = -1
```

指定する値は zstd 圧縮ライブラリの圧縮レベル値です。圧縮レベルとしては 1 から 22 が指定可能です。0 は zstdライブラリのデフォルトの圧縮レベルという意味になります。

3つの設定項目のうち、compress は primary と toast の両方についての指定を意味します。primary と toast で別の指定を与えたいときには compress は指定せず、primary_compress、toast_compress の指定を使用してください。

さて、OrioleDB beta9 で手元で検証しました結果、設定ファイルによる指定はうまく働かないことがあるようで、いまひとつ信用できません。ここでは、ストレージパラメータで指定することにします。tbl1 と同定義の tbl3 に「 WITH (compress = 1)」指定を付けて 、同データを投入します。これまで同様にテーブルのファイルサイズを調べてみます。

```console
db1=# CREATE TABLE tbl3 (id int primary key, c1 int, ts timestamp, lob bytea) WITH (compress = 1);
CREATE TABLE
db1=# INSERT INTO tbl3 SELECT g, (random()*10)::int, now() + (g || 'min')::interval,  pg_read_binary_file('/dev/urandom', 0, 3000) FROM generate_series(1, 100) g;
INSERT 0 100
```

これまで同様に対応するファイルを調べて、CHECKPOINTをかけて、ファイルサイズを調べます。同様に圧縮レベル 20 でもやってみます。ファイルサイズは以下のようになりました。

|圧縮レベル|primaryサイズ(byte)| toastサイズ(byte)|
|---------|----|------|
|-1       |8,192|417,792|
|1        |1,536|333,312|
|20       |1,536|333,312|

連番整数とランダムバイトの列からなるテーブルについて、圧縮レベル 1 でも相当程度圧縮されて、そこから圧縮レベルを上げてもこれ以上改善しない、という結果です。

### 圧縮指定の確認

テーブルが圧縮されているかどうかは、OrioleDB固有のシステムビュー orioledb_table から確認できます。

```console
db1=# SELECT * FROM orioledb_table WHERE reloid = 'tbl3'::regclass;
 datoid | reloid | relnode |                                     description
--------+--------+---------+--------------------------------------------------------------------------------------
  16384 |  25840 |   25850 | Compress = 1, Primary compress = 1, TOAST compress = 1                              +
        |        |         |  Column |                        Type | Collation | Nullable | Droped | Compression +
        |        |         |      id |                     integer |    (null) |    false |  false |             +
        |        |         |      c1 |                     integer |    (null) |     true |  false |             +
        |        |         |      ts | timestamp without time zone |    (null) |     true |  false |             +
        |        |         |     lob |                       bytea |    (null) |     true |  false |             +
        |        |         |
(1 row)
```

### pgbench のテーブルでデータ圧縮を使うと？

これは結果だけ紹介します。標準シナリオでスケール -s 10 相当のテーブル群を、テーブル作成時にストレージオプションを与えるために自前の初期化スクリプトで作成して、「pgbench -t 1000 -c 256 -n db1」で実行しました。転送データ量に影響があるかを調べるため、非同期ストリーミングレプリケーションも有効にしています。

|圧縮レベル|TPS|ファイルサイズ|
|---------|-----|--------------------|
|-1       |311.1| 290MB |
|3        |302.8|  48MB |
|10       |267.0|  51MB |

ファイルサイズは pgbench実行後の pgbench_accounts テーブルの primaryファイルのサイズです。本テーブルには toast は作られませんでした。

ファイルサイズについては前節の検証と傾向が同じです。低圧縮レベルで相当に圧縮されて、それを更に高圧縮レベルに変えても効果は小さいです。pgbench の pgbench_accounts テーブルには空白文字だけの char(84) 型の列があるため、いかにも圧縮が効きそうなデータです。

トランザクション性能への影響は、高圧縮レベル(10)にすると -15％ ほど劣化しました。低圧縮レベル(3)では -3％くらいです。圧縮が効きそうなテーブルについて、低い圧縮レベルを指定しておくのがリーズナブルであるように見えます。

なお、ストリーミングレプリケーションのデータ転送量は圧縮の有無に影響を受けませんでした。圧縮はテーブルのデータをファイルに読み書きするとき適用されるのみでした。WAL 書き込み内容には影響しません。

## まとめ

OrioleDB のファイル格納とブロックレベル圧縮機能を見てきました。
zstdライブラリによる圧縮は、データサイズを小さく保つのに有益で、小さめの圧縮レベルを指定しておけば損になることはなさそうです。
一方、細かいところにバグと思われる挙動がいくつかありました。