Pgpool-II スナップショットアイソレーションモードの話（１）


本記事は「[PostgreSQL Advent Calendar 2020](https://qiita.com/advent-calendar/2020/postgresql)」の 6日目です。

本記事公開日の10日前、11月 26日に PostgreSQL むけ多機能プロキシソフトウェア [Pgpool-II のバージョン4.2 がリリース](https://www.pgpool.net/blog/pgpool-4-2-0-release/)されました。私は、このリリースの目玉は「スナップショットアイソレーションモード」だと思っているのですが、今の所「実験的な実装」の位置づけとなっていることもあり、さほど宣伝されていません。そこで本稿では、この新機能を紹介します。私自身は関係者の近くにいるものの本機能の開発には関わっていないため、あくまで一（いち）ユーザの立場での見解になります。

## 急いでいる人は

本稿よりも、この辺りを読みましょう。

* [Pgpool-II 4.2 文書 - スナップショットアイソレーションモード](https://www.pgpool.net/docs/42/ja/html/runtime-config-running-mode.html#GUC-SNAPSHOT-ISOLATION-MODE)
* [PostgreSQLカンファレンス2020](https://www.postgresql.jp/jpug-pgcon2020) - [石井さん、彭さんの講演資料 その2] (https://www.sraoss.co.jp/event_seminar/2020/20201113-pgpool2_part2.pdf)

## 背景的なこと

Pgpool-II には昔から（II が付く前から）レプリケーションモードというものがありました。最近はストリーミングレプリケーションと組み合わせて使うモードと区別するため、ネイティブレプリケーションモードと呼びます。これは下図のようにフロントエンドから、DDL（CREATE xxx などの定義命令）や DML（UPDATE などの更新命令）が来たなら、複数のバックエンド PostgreSQL の全てに同じ命令を投げて、そのことによって継続的なデータ複製の維持を実現するというものです。参照命令はどれか一つだけのバックエンドPostgreSQLに投げて済ませば、負荷分散による性能向上も狙えます。

![pgpool.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152412/bdaf1f48-ff56-0afe-27ef-51b834a49592.png)

ネイティブレプリケーションモードの長所は、データ同期の遅延が無いことです。現在主流の構成は、Pgpool-IIで問い合わせの振り分けを行って、データの複製はPostgreSQLのストリーミングレプリケーション機能で実現するというものです。しかし、非同期のストリーミングレプリケーションでは遅延したデータの参照が生じますし、差分の適用までを保証する同期モードにすると今度はスタンバイでの参照処理と差分適用との間のコンフリクトが生じたときに更新が待たされます（さもなくば衝突した参照処理を速やかにエラーにさせる設定を与えるほかありません）。

一方ネイティブレプリケーションの欠点は、問い合わせ結果の一貫性が保証されないことです。さらに問い合わせ結果の基づいて書き込みを行う処理がある場合、書かれたデータにも矛盾とノード間の不一致が生じることになります。

典型的な例は Read Committedトランザクション分離レベルにおける以下図の問い合わせです。ネイティブレプリケーションモードでは各命令はマスタが先、COMMIT命令だけは非マスタノードが先に実行されます。同時進行するフロントエンドからの2つのセッションで同テーブルの INSERT と UPDATE が行われた場合に、ノードによって UPDATE処理に INSERTした行が含まれるかどうかが異なることがあり得ました。

![pgpool_tx.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152412/9d333193-e2cd-7a21-1ede-51bbbade82c5.png)

データ不一致が見つかったら、そのノードを捨ててオンラインリカバリで復旧するというのが一般的な運用ですが「誤っているのはどのノードなのか？」ということすら判断が難しいです。また、アプリケーション記述においてはデータ不一致を生じさせないように単体PostgreSQLむけに記述するよりも明示的なロックを増やす、といった対処が必要でした。

この欠点のためにネイティブレプリケーションは現在では廃れているのですが、それでも遅延のない参照を求めて一部では使われています。

## レプリケーションモード ＋ ノード間の読み取り一貫性

スナップショットアイソレーションモードは、各バックエンド PostgreSQL に同じ更新命令を投げるというネイティブレプリケーションと同様の仕組みにスナップショット制御を加えて、ノード間で読み取り一貫性を実現したものです。希望を込めて言うなら「帰ってきた、使えるレプリケーションモード」です。

スナップショットアイソレーションモードは PostgreSQL のトランザクション隔離レベルが「repeatable read」である場合のみ使用できます。実現の原理については、[Pgpool-II文書]((https://www.pgpool.net/docs/42/ja/html/runtime-config-running-mode.html#GUC-SNAPSHOT-ISOLATION-MODE))に論文のリンクが掲載されています。


## 動かしてみる

それではスナップショットアイソレーションモードを試してみます。

ここで私は、Azure上に仮想マシンと Azure DB for PostgreSQL単体サーバ2台を作ってテストしようと思って気づいたのですが、Azure DB for PostgreSQL は「CREATE ROLE dbuser1」として作ったデータベースユーザでサーバ postgres.database.azure.com に接続するときに接続パラメータとしてはユーザ名に「dbuser1@dbserver1」を指定することを要求するので、Pgpool-II のバックエンドノード用には素直には使えませんでした。Pgpool-IIは各バックエンドノードに同じDBユーザ名で接続しようとするためです。

Pgpool-II に DBユーザ名のサフィックスか対応マップを設定できる修正を提案しようかとも思いましたが、結構大変そうなので、これは次の機会にします。

Pgpool-II 4.2 には スナップショットアイソレーションモード用の設定テンプレートが付属しています。これをコピーして pgpool.conf を作って、backend_hostname0、backend_hostname1 などのバックエンド接続設定を与えるだけで動作します。PostgreSQL側には、同じデータを持つ独立したインスタンスを用意して、postgresql.conf に ```default_transaction_isolation = 'repeatable read'```を設定しておきます。

以下のようにネイティブレプリケーションモードと同様に書き込みがバックエンドノードに反映される動作が確認できました。

```shell-session
[postgres@pgpool1 ~]$ psql -h localhost -p 9999 -c 'SHOW pool_nodes' db1
 node_id | hostname | port | status | lb_weight |  role   | select_cnt | load_balance_node | replication_delay | replication_state | replication_sync_state | last_status_change
---------+----------+------+--------+-----------+---------+------------+-------------------+-------------------+-------------------+------------------------+---------------------
 0       | 10.0.0.5 | 5432 | up     | 0.500000  | main    | 127        | true              | 0                 |                   |                        | 20 20-12-05 14:27:16
 1       | 10.0.0.6 | 5432 | up     | 0.500000  | replica | 5          | false             | 0                 |                   |                        | 20 20-12-05 14:27:16
(2 rows)

[postgres@pgpool1 ~]$ pgbench -h localhost -p 9999 -i db1
dropping old tables...
creating tables...
generating data...
100000 of 100000 tuples (100%) done (elapsed 0.15 s, remaining 0.00 s)
vacuuming...
creating primary keys...
done.

[postgres@pgpool1 ~]$ psql -h 10.0.0.5 db1 <<EOS
> \d
> EOS
              List of relations
 Schema |       Name       | Type  |  Owner
--------+------------------+-------+----------
 public | pgbench_accounts | table | appuser1
 public | pgbench_branches | table | appuser1
 public | pgbench_history  | table | appuser1
 public | pgbench_tellers  | table | appuser1
(4 rows)

[postgres@pgpool1 ~]$ psql -h 10.0.0.6 db1 <<EOS
\d
EOS

              List of relations
 Schema |       Name       | Type  |  Owner
--------+------------------+-------+----------
 public | pgbench_accounts | table | appuser1
 public | pgbench_branches | table | appuser1
 public | pgbench_history  | table | appuser1
 public | pgbench_tellers  | table | appuser1
(4 rows)
```

## Repeatable Read ということ

さて、データ書き込みテストに pgbench を使ったので、pgbenchによるトランザクション実行もやってみたくなりますね。すると直列化失敗エラーに遭遇します。

```shell-session
[postgres@pgpool1 ~]$ pgbench -h localhost -p 9999 -c 10 -t 100 -C db1
starting vacuum...end.
client 1 aborted in command 8 (SQL) of script 0; ERROR:  could not serialize access due to concurrent update

client 2 aborted in command 8 (SQL) of script 0; ERROR:  could not serialize access due to concurrent update

client 3 aborted in command 8 (SQL) of script 0; ERROR:  could not serialize access due to concurrent update

client 4 aborted in command 8 (SQL) of script 0; ERROR:  could not serialize access due to concurrent update

client 5 aborted in command 8 (SQL) of script 0; ERROR:  could not serialize access due to concurrent update

client 7 aborted in command 7 (SQL) of script 0; ERROR:  could not serialize access due to concurrent update

client 9 aborted in command 8 (SQL) of script 0; ERROR:  could not serialize access due to concurrent update

client 0 aborted in command 8 (SQL) of script 0; ERROR:  could not serialize access due to concurrent update

client 8 aborted in command 8 (SQL) of script 0; ERROR:  could not serialize access due to concurrent update

transaction type: <builtin: TPC-B (sort of)>
scaling factor: 1
query mode: simple
number of clients: 10
number of threads: 1
number of transactions per client: 100
number of transactions actually processed: 100/1000
latency average = 540.600 ms
tps = 18.497964 (including connections establishing)
tps = 18.629492 (excluding connections establishing)
```

実は、これは Pgpool-II を通さない単体PostgreSQL に対する実行でも、Repeatable Read トランザクション分離レベルで pgbench の標準シナリオを何並列かで実行すると普通に発生する現象です。

スナップショットアイソレーションモードの Pgpool-II は直列化失敗のエラーに対して、データ不一致を発生させませんし、片方ノードの縮退も起こしません。しかし、該当するトランザクションを実行したクライアントに対してはエラーを返します。この種のエラーを受けたクライアントはトランザクションを再実行して、次に成功すれば、処理は継続できますし、データも健全な状態に保たれます。

しかしながら、皆さんは、PostgreSQLを Repeatable Read や Serializable で使う方式でアプリケーションを書いていますでしょうか？ 私は、仕事でお客様のシステムをみることがありますが、正直あんまり見たことがありません。また、データベースへのSQL発行を補助するライブラリやフレームワークで「直列化失敗したので今実行したトランザクションを再実行する」ということを簡単にできる機能を持つものもあまり無いのではないでしょうか（コメントで教えてくれると嬉しい）。

Pgpool-IIスナップショットアイソレーションモードで複数ノードでの一貫性を手に入れたとしても、活用するためには Repeatable Read 前提でアプリケーションを作るということが必要で、今までそうなっていなかった場合にはちょっとハードルが高いかもしれません。

## まとめのようなもの

本稿ではあまり込み入った検証や解説ができませんでした（Pgpool-II の修正を書こうかなと考えて余計な時間を使ったせいです）。
Pgpool-IIスナップショットアイソレーションモードについては、シリーズにして、しばらく書いていこうかなと思っています。