職業プログラマの休日出勤

職業プログラマによる日曜自宅プログラミングや思考実験の成果たち。リアル休日出勤が発生すると更新が滞りがちになる。記事の内容は個人の意見であり、所属している(いた)組織の意見ではない。

CakePHP から味わう データベース・レプリケーション

この記事は CakePHP Advent Calendar 2019 の7日目の記事です。

今年はCakeFestが東京で開催され、筆者もそこで少しばかりお話をさせて頂いたのですが、この内容は英語でしか発信していませんでした。どこかの機会で日本語でも喋ろうと思っていたのですがタイミングも合わなかったので、ここで日本語のブログ記事に起こしておきます。

レプリケーションとは何か?

この文脈では、レプリケーションとは、あるデータベースサーバに書き込まれたデータを、他のデータベースサーバにもコピーして書き込むこと、です。こうすることで可能になることが、いくつかあります。

  • データが2以上の箇所に保管されるので、HDD/SSD等のストレージが吹っ飛んだとしてもデータは消えない。
  • 一部のクエリは、コピー元だけでなくコピー先でも実行できる。つまり、負荷分散ができる。

もっとも、世の中には「マルチマスタ・レプリケーション」のような仕組みもありますが、これを「まともに」動かすには考えることが結構ありますので、ここでは特に紹介したりはしません。これに対して、この記事で書かれているレプリケーションのことを「シングルマスタ・レプリケーション」とも言います。

さて、文字だけで伝えるのは色々と難しいので画像も貼り付けておきましょう。
f:id:t_motooka:20191207192234p:plain
↑のように、INSERT / UPDATE / DELETE 等によってmasterに書き込まれたデータは、replicaにコピーされます。
f:id:t_motooka:20191207192334p:plain
データは両方のサーバに保管されていますので、↑のように両方のサーバから取り出すことができます。

f:id:t_motooka:20191207192415p:plain
↑のように、replicaのサーバのデータを更新しようとすると、(バグっていなければ😇)きちんとエラーになります。

どうやって使うのか?

このようにレプリケーションが設定されたデータベースを(負荷分散の恩恵に与りながら)使う方法は、大きく分けて2つのものがあります。

f:id:t_motooka:20191207192815p:plain
一つ目は、プロクシサーバをデータベースサーバとアプリケーションサーバとの間に設置する方式です。こういったことを実現するプロクシは、各RDBMSについて開発されています。

  • メリット
    • 後述の方法のような感じの、アプリケーションのコードはレプリケーションについて意識する必要が無いので、コードがシンプルに保たれる。
    • (これは登壇時には言及しなかったが)レプリカのサーバを更に増やしたときの負荷分散の効果を得やすい(アプリ側でも実現できるけど、かなり面倒)。
  • デメリット
    • 雑に作ると、このプロクシサーバが新たなSPOF(Single Point of Failure, 単一障害点)になってしまう。
    • 丁寧に作ったとしても、プロクシサーバが落ちたときの接続切り替えについて、アプリケーションサーバが意識しなければならない。
    • プロクシサーバのソフトウェアを各アプリケーションサーバにインストールしておくことで、串がSPOFになることを回避できる。但し、今度は串の設定等を変更する操作がアプリケーションのデプロイと似た仕組みが必要になってしまう。
アプリケーションで頑張る💪

f:id:t_motooka:20191207192825p:plain
二つ目は、masterにつなぐのかreplicaにつなぐのか、アプリケーションのコードで判断させます。

  • メリット
    • SPOFが増えにくい。
  • デメリット
    • コードの書き手に求めるスキルが上がる。レプリケーションの仕組みや動作について知らない人間を(教育や厳重なレビュー無しには)プロジェクトに参画させてはいけないようになる。

これら2つの方法はどちらを採用しても良いのですが、一旦採用した方針を後から変えることはとても大変です。この記事では後者、アプリケーションで頑張る方法について紹介します。

デフォルトはどっち?

アプリケーションで接続を切り替える役割を担うとき、決めておかなければならないことがいくつかあります。その最初の判断は「デフォルトで接続されるのはどちらのサーバなのか?」です。

  • デフォルトでmasterに接続
    • 重たいSELECT文だけをreplicaで実行する、ような方針をとるときに便利。
    • 既存のアプリケーションについて新たにレプリケーションを活用させるとき、移行し易い。
  • デフォルトでreplicaに接続
    • データを更新等する場合のみmasterに切り替える。
    • ほとんどのSELECT文をreplicaで実行できるようになるので、負荷分散効果が高い。
    • 後述するラグの問題に悩まされる頻度が高まる。

ラグの問題は本当に悩ましいもので、筆者としては、多くのアプリケーションでは デフォルトでmasterに接続する モデルを推したいと考えています。

接続切り替えコード、どこに書く?

意思決定しなければならないものは、もう一つあります。接続切り替えのためのコードをどこに書くのか?です。
接続を切り替えるときにメソッドを沢山読んだりするのは嫌なので、一つにまとまっていると良いでしょう。なので、例えば changeConnectionToMasterchangeConnectionToReadReplica といったメソッドを用意することになります。これらを、どこに実装しましょう?
候補は2箇所あります。1つは Table クラスのabstractサブクラスを用意する方法えす。これは、CakePHP 2.x に慣れた皆さんは AppModel を想起してもらえると理解が早いでしょう。Table名前空間の各クラスはTableではなくて、この抽象サブクラスを継承すれば良いのです。ご存知のようにPHPでは多重継承はできませんので、こうすることで他の方向性での拡張の余地を阻害することにつながるので、気を付けて下さい。一方でPhpStorm等のIDEのコード補完等の支援を受け易い点は非常に大きなメリットです。
もう一つの実装方法は、Behaviorとして実装する方法です。実態はTraitですね。これならコードの再利用等も簡単です。

他には例えば、ControllerやComponentにて同様のことをすることも考えられますが、これではCLI起動をさせようとした瞬間に難易度が上昇してしまうので、やめた方が良いでしょう。

待ち構える落とし穴たち

実現するにあたっては、いくつか注意事項がありますので、紹介しておきます。

トランザクション
  • トランザクションを開始するときはmasterに接続されているべきです。また、トランザクションが終了するまでは接続先を変えてはいけません。さもなくば、commitやrollbackが意図せず呼ばれたりするかもしれません。
  • SELECT文も、トランザクションの中ではmasterで実行されるべきです。何故なら一般的にトランザクションの中では何らかのロックを獲得していたり、まだコミットされていないデータ(自分のスレッドで書き込もうとしているデータ)が存在したりするから、です。replicaでSELECT文を実行すると、予期しない結果が返されることもあるでしょう。
Migration

もしも デフォルトでreplicaに接続 の方針を取った場合、migrationのコマンドは少し変わります。

bin/cake migrations migrate --connection master

このように、master(実際は app.php 等で指定した値)に接続することを明示的に指示する必要があります。
デフォルトがmasterのときは、特に気にする必要はありません🥳

Replication Lag

レプリケーションラグとは、masterのサーバにデータがcommitされてから、replicaのサーバに反映されるまでにかかる時間のことです。通常は1秒以内でしょうけれども、これは負荷などの影響を受けて長くなることがあります。
データベースサーバの設定によっては「同期レプリケーション」(replicaへの反映が終わって初めてcommit完了とする)も可能ですが、これは性能への悪影響があります。

さて、ラグがあって困る事例を紹介しましょう。以下はControllerのコードの例です。

public function add()
{
    $article = $this->Articles->newEntity();
    if ($this->request->is('post')) {
        $article = $this->Articles->patchEntity($article, $this->request->getData());
        if ($this->Articles->save($article)) {
            $this->Flash->success(__('Your article has been saved.'));
            return $this->redirect(['action' => 'index']);
        }
    ....
}

public function index()
{
    // use ReadReplica for this action
    $this->Articles->changeConnectionToReadReplica();
    
    $articles = $this->Paginator->paginate($this->Articles->find());
    $this->set(compact('articles'));
}

addアクションが呼ばれてデータがmasterデータベースに書き込まれた後、このアプリケーションはブラウザに対して indexへリダイレクトするように指示しています。これを受け取ったブラウザはすぐに index にリクエストを投げます。しかし、この時点では、先ほどcommitされたデータは未だreplicaに反映されていない可能性があり、ユーザは登録したばかりのデータを見ることができないかもしれません。
なので、次のような対策のうちの1つ以上が必要になるでしょう。

  • 故意にどこかでブラウザの挙動を遅らせたり、サーバサイドでsleepを入れたりする。(あまりオススメしない😅)
  • indexのページのどこかに 登録直後のデータは反映されていないことがあります などと但し書きを入れる。

ローカル開発環境

理想としては、2台のデータベースサーバを用意するのが良いです。MySQLの場合は Delayed Replication という機能があって、レプリケーションラグをシミュレートさせることも可能です。
現実には、このような環境を用意するのは大変であることがとても多いので、簡易的な方策を取ることになるでしょう。read-onlyなユーザをデータベース上に用意すること、が簡易的な対策として有益でしょう。レプリケーションの絡むアプリケーションで避けたいバグの一つに「replicaサーバに対してデータの更新をかけてしまう」というものがありますが、read-onlyなユーザでDBに接続していれば、このバグを開発時に即座に検出できます。この方法では残念ながらラグに関するテストをすることができませんが、それは別途頑張りましょう😅

単体テスト

単体テストにおいても、DB接続先が2つ定義されている必要があります。
また、気にしなければならないこととして、CI等のサーバで単体テストを実行する際に、そこでレプリケーションを仕込むことができるか?ということがあります。もしもサポートされていないようであれば、上記のローカル開発環境のように、read-onlyなユーザを用意するのが良いかもしれません。

参考実装

レプリケーションの考慮を入れながらCakePHPチュートリアルを実装したのが、このリポジトリです。
github.com
docker-composeさえ動けば、きっとすぐに稼働させることができるでしょう。そうでなくとも、コードは参考になるかと思います。

参考文献

スライド

speakerdeck.com
※全編、英語です。

さいごに

読んでくれて、どうもありがとう。
私はあなたたちと同じくらい、冗長化されたデータ・ストレージが大好きです😘
(この記事を書いている途中で ojichatパーカー が我が家に届きました。)