2012年12月17日

PostgreSQLのストレージアーキテクチャ(Page Pruning編)

PostgreSQL Advent Calendar 2012(全部俺)のDay 17です。

昨日のエントリでは、「ストレージアーキテクチャ(基本編)」ということで、PostgreSQLの内部でディスクがどのように使われているのか、その基本を解説しました。そして、PostgreSQLの更新処理で「新しいレコードを追記し、古いレコードからチェーンのようにつなぐ」という処理をしている様子を実際に観察しました。

今回は、この「追記型のストレージアーキテクチャ」の持つ課題を克服するために、どのような工夫がされているのかを実際の動作を見ながら解説します。

■追記型ストレージにおけるファイルの肥大化とその抑制


PostgreSQLに対する指摘として、「この追記型の更新処理がデメリットである。更新が増えるとレコードがどんどん増えていくのが弱点である」という指摘があります。

原則論としては、確かに更新処理を連続して行うとレコードが追記されてデータのサイズが大きくなっていくのですが、実際の実装はそんなに単純なものではなく、データサイズが増えないように随所に工夫がされています。

今回は、その中でも「Page Pruning」と呼ばれる処理について解説します。

■「Page Pruning」とは


「Page Pruning」とは、ページ内に未使用領域が少なくなった場合に行われる処理です。

ページ内に未使用領域が無い場合、更新処理を行う、つまり新しいレコード(タプル)を追記するためには、他のページブロックを使うしかないと思われがちです。

が、実際には他のブロックを使う前に、今現在タプルが存在しているページが本当に使えないのか、再度整理して確認する、という処理が内部で自動的に行われています。これが「Page Pruning」と呼ばれる処理です。

簡単に言うと、そのページ内だけVACUUMをかけて不要領域を削除している様子をイメージしてもらえれば良いと思います。

このPage Pruning処理があることによって、特定のレコードに対する更新処理が連続的に行われているような場合に、無闇に使用するブロック数が増えるような事態を抑制することができているのです。

それでは実際にページ内部の状態を具体的に見てみましょう。

■更新処理とブロック内の未使用領域の減少


まず前回と同じように更新処理をするためのテーブルを作成し、1件のレコードを作成、そのレコードを連続的に更新していきます。分かりやすくするために、ここでは少し大きめのレコードを使います。
testdb=# CREATE TABLE t1 ( uid INTEGER PRIMARY KEY, uname TEXT NOT NULL );
NOTICE:  CREATE TABLE / PRIMARY KEY will create implicit index "t1_pkey" for table "t1"
testdb=# INSERT INTO t1 VALUES ( 101, 'insert 1-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------' );
INSERT 0 1
testdb=# SELECT lp,lp_off,lp_flags,lp_len,t_xmin,t_xmax FROM heap_page_items(get_raw_page('t1', 0));
 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax
----+--------+----------+--------+--------+--------
  1 |   7880 |        1 |    309 |  39586 |      0
(1 row)

testdb=# UPDATE t1 SET uname = 'update 0-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------' WHERE uid=101;
UPDATE 1
testdb=# SELECT lp,lp_off,lp_flags,lp_len,t_xmin,t_xmax FROM heap_page_items(get_raw_page('t1', 0));
 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax
----+--------+----------+--------+--------+--------
  1 |   7880 |        1 |    309 |  39586 |  39587
  2 |   7568 |        1 |    309 |  39587 |      0
(2 rows)

testdb=# UPDATE t1 SET uname = 'update 1-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------' WHERE uid=101;
UPDATE 1
testdb=# SELECT lp,lp_off,lp_flags,lp_len,t_xmin,t_xmax FROM heap_page_items(get_raw_page('t1', 0));
 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax
----+--------+----------+--------+--------+--------
  1 |   7880 |        1 |    309 |  39586 |  39587
  2 |   7568 |        1 |    309 |  39587 |  39588
  3 |   7256 |        1 |    309 |  39588 |      0
(3 rows)

testdb=# SELECT pg_relation_size('t1');
 pg_relation_size
------------------
             8192
(1 row)

testdb=#
このように更新処理を行っていくと、前回見た通り、古いレコードのt_xmaxと新しいレコードのt_xminとの間でチェーンの構造ができていきます。

このUPDATEをブロックの空き領域が無くなるまで続けるとどうなるのでしょうか。

以下が、UPDATEを続けたブロック内部の状態です。
 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax
----+--------+----------+--------+--------+--------
  1 |   7880 |        1 |    309 |  39586 |  39587
  2 |   7568 |        1 |    309 |  39587 |  39588
  3 |   7256 |        1 |    309 |  39588 |  39589
  4 |   6944 |        1 |    309 |  39589 |  39590
  5 |   6632 |        1 |    309 |  39590 |  39591
  6 |   6320 |        1 |    309 |  39591 |  39592
  7 |   6008 |        1 |    309 |  39592 |  39593
  8 |   5696 |        1 |    309 |  39593 |  39594
  9 |   5384 |        1 |    309 |  39594 |  39595
 10 |   5072 |        1 |    309 |  39595 |  39596
 11 |   4760 |        1 |    309 |  39596 |  39597
 12 |   4448 |        1 |    310 |  39597 |  39598
 13 |   4136 |        1 |    310 |  39598 |  39599
 14 |   3824 |        1 |    310 |  39599 |  39600
 15 |   3512 |        1 |    310 |  39600 |  39601
 16 |   3200 |        1 |    310 |  39601 |  39602
 17 |   2888 |        1 |    310 |  39602 |  39603
 18 |   2576 |        1 |    310 |  39603 |  39604
 19 |   2264 |        1 |    310 |  39604 |  39605
 20 |   1952 |        1 |    310 |  39605 |  39606
 21 |   1640 |        1 |    310 |  39606 |  39607
 22 |   1328 |        1 |    310 |  39607 |  39608
 23 |   1016 |        1 |    310 |  39608 |  39609
 24 |    704 |        1 |    310 |  39609 |      0
(24 rows)
最新のタプルが1件(lp==24)、および更新前の古いタプル(lp==1~23)でブロック内部がほぼ一杯になっています。

また、lp==24のタプルのオフセットを見ると、かなりブロックの前方まで埋まってきていることが分かります。(前回解説した通り、ページブロックは後ろの方から使われます。)

■Page Pruning処理の実施


ここまでの状態で再度UPDATEを行ってみます。UPDATEが繰り返され、ブロック内が古いタプル(レコード)で一杯になっている状態で、再度UPDATEを行うとどうなるのでしょうか。

以下は、次のUPDATE処理を行った直後のページブロック内部の状態です。
 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax
----+--------+----------+--------+--------+--------
  1 |     24 |        2 |      0 |        |
  2 |   7568 |        1 |    310 |  39610 |      0
  3 |      0 |        0 |      0 |        |
  4 |      0 |        0 |      0 |        |
  5 |      0 |        0 |      0 |        |
  6 |      0 |        0 |      0 |        |
  7 |      0 |        0 |      0 |        |
  8 |      0 |        0 |      0 |        |
  9 |      0 |        0 |      0 |        |
 10 |      0 |        0 |      0 |        |
 11 |      0 |        0 |      0 |        |
 12 |      0 |        0 |      0 |        |
 13 |      0 |        0 |      0 |        |
 14 |      0 |        0 |      0 |        |
 15 |      0 |        0 |      0 |        |
 16 |      0 |        0 |      0 |        |
 17 |      0 |        0 |      0 |        |
 18 |      0 |        0 |      0 |        |
 19 |      0 |        0 |      0 |        |
 20 |      0 |        0 |      0 |        |
 21 |      0 |        0 |      0 |        |
 22 |      0 |        0 |      0 |        |
 23 |      0 |        0 |      0 |        |
 24 |   7880 |        1 |    310 |  39609 |  39610
(24 rows)
前回のエントリで見たように、lp_off==0の領域は未使用領域として解放されたことを表していますので、このブロックには空き領域がたくさんできていることが分かります。これは、PostgreSQLが「もはや必要とされない削除済みタプル」を判別して、自動的にページ内を整理したためです。

ですので、先ほどまで不要領域(削除済みレコード)で一杯だったこのページは、明示的にVACUUMを実施していないにも関わらず、自動的に不要領域が解放されて未使用領域を確保できたことになります。

もう少し詳細に見てみると、最後に残ったタプルは2つあり、lp==24のタプルの次にlp==2のタプルがつながっていて、lp==2のタプルが最新の生きているタプル(t_xmax==0)であることが分かります。

この時、テーブルファイルのサイズを見ると、最初の8kBのままです。
testdb=# SELECT pg_relation_size('t1');
 pg_relation_size
------------------
             8192
(1 row)

testdb=# 
つまり、ページがほぼ一杯になってしまったにも関わらず、自動的にそのページ内部を整理することによって、他のブロックを使わずに当該ページの中だけで更新処理を行うことができたことになります。

このように、PostgreSQLでは更新処理を行う際にページブロック内が一杯になっていると、「他のブロックに書き込みに行く前」に、そのページブロック内を整理して再利用できる領域が無いかどうかを確認します。この処理を行うことによって、やみくもに他のブロックを読み書きしないようにしているのです。この処理を内部的には「Page Pruning」と呼びます。

このPage Pruningの処理によって、更新が多い場合でも更新処理を単一のページブロック内で完結するようになっています。

なお、Page Pruningの処理はソースコード内では src/backend/access/heap/pruneheap.c で実装されています。興味のある方はこの辺りを参照してください。

■まとめ


今回は、追記型のストレージ構造を持つPostgreSQLにおいて、更新処理によるブロック数の増加をどのような方法で抑制しているのか、その工夫のひとつを、実際のページの内部の状態を追いかけながら解説しました。

確かにPostgreSQLは追記型のストレージアーキテクチャを持つため、更新処理を行うことによってファイルサイズが肥大化していくと思われがちですが、実際にはこのようにファイルの肥大化(=ページブロックの増加)を防ぐ工夫が実装されていることがお分かりいただけたかと思います。

次回は、このようなテーブル内部におけるタプルの移動の処理とインデックスとの関係について解説してみようと思います。

では、また。

0 件のコメント:

コメントを投稿