CakePHP2でデータベースを振り分けたらいろいろおかしくなった話 〜私の中の未解決問題〜

この記事はだいぶ前に書かれたものなので情報が古いかもしれません
読み込みはこっち書き込みはあっち

この記事を三行にまとめると

データベースを二台に分けて負荷を分散させることにしました
これが良いやり方なのかどうかは自分でもよく分かりません
さらにゴリ押さなきゃいけなくなるという悪循環に陥るわけですね
以下の構成で運用しているサービスがあるんですが、データベースの負荷が増大してきたので二台に分けて負荷を分散させることにしました。

・データベースはAWSのRDS
・フレームワークはCakePHP2系

そこでデータベースをRDSのMySQLからAuroraに切り替えて読み込みと書き込みで接続先を分けてみたんですけど、いくつか解決できない問題が発生してしまったので今日はその話をしようかなと思います。

使用しているフレームワークがちょい古めな上に結局解決できずじまいという内容なんですが、良かったらおつき合いください。



AWSのAuroraに切り替えた

先述の通り、データベースを二台に分散するにあたってまずはRDSのMySQLからAuroraに乗り換えました。

Auroraは書き込み用、読み込み用のエンドポイントがそれぞれ用意されています。

エンドポイント

こんな感じです。この2つの接続先につながるようにしておけば、例えば読み込み用のDB(リードレプリカ)を二台、三台と増やしていってもAurora側で上手いこと接続を振り分けてくれるので便利です。通常のMySQLとかだとデータベースの数だけ自分で接続を振り分けないといけないと思う。やったことないんでたぶんそうだろうという話なんですけど。

さらにはエンドポイントをカスタマイズできるみたいなので「特定の処理だけはこっちに接続したい」みたいなことも可能なようです。



書き込みと読み込みを分ける

ほんじゃあ実際にCakePHP側で接続先を振り分けてみましょう。接続先の設定はConfigフォルダの中にあるdatabase.phpで行います。

class DATABASE_CONFIG {
  public $default = array(
    'datasource' => 'Database/Mysql',
    'persistent' => false,
    'host' => 'sample-db.cluster-sample12345.ap-northeast-1.rds.amazonaws.com',
    'login' => 'user',
    'password' => 'password',
    'database' => 'sample',
    'encoding' => 'utf8',
    'prefix' => '',
  );

  public $read = array(
    'datasource' => 'Database/Mysql',
    'persistent' => false,
    'host' => 'sample-db.cluster-ro-sample12345.ap-northeast-1.rds.amazonaws.com',
    'login' => 'user',
    'password' => 'password',
    'database' => 'sample',
    'encoding' => 'utf8',
    'prefix' => '',
  );
}

これでOKです。デフォルトの設定は書き込み用のエンドポイントに接続するようにして、新たに読み込み用のエンドポイントへの接続設定を追加しています($readの方)

今回はデータの取得(SELECT)の時だけreadに接続して、それ以外の登録(INSERT)や更新(UPDATE)、削除(DELETE)は今まで通りdefaultに接続するという風に分けたいと思います。



SELECTの時だけ接続先を変える場合はモデルのbeforeFindに処理を書けばOKです。今回は全てのテーブルで接続先を変えたいのでAppModelに書いちゃいます。

class AppModel extends Model {
  public function beforeFind($queryData) {
    $this->useDbConfig = 'read';
    return $queryData;
  }

  public function afterFind($results, $primary = false) {
    $this->useDbConfig = 'default';
    return $results;
  }
}

こんな感じですね。デフォルトだとuseDbConfigの値が「default」になっているのでそれを「read」に変えてやるだけです。このreadはdatabase.phpで作った「$read」の変数名です。もしdatabase.phpで「$mario」とかいう変数で設定を書いたのならuseDbConfigには「mario」を入れてください。

念のためデータを取得し終わった後にuseDbConfigにdefaultを入れて接続先をデフォルトに戻しています。データを取得した後そのまま更新処理などを行う場合、こうしておかないと接続先がreadのまま書き込み処理を行うことになっていろいろと不都合が生じると思うので。



一応これで接続先の振り分けは完了です……が、これだけだといろいろと予期せぬ不具合が出る可能性がある。

というわけでここからは僕の環境で実際に起きた不具合や、不具合とは言わないまでも、もっとこうしたいみたいな事柄についていくつか見ていきたいと思います。



アソシエーション先の接続がreadにならない

サービスを作り込んでいくとテーブル同士をbelongsToやらhasManyやらでアソシエーションすることがあると思うのですが、上記の書き方だとアソシエーション先の接続先はdefaultのままになってしまうらしく、JOINしてデータを取ろうとするとエラーが出たり欲しいデータが取れなかったりします。

なのでアソシエーション先も同時にread側に接続してくれるようにbeforeFindやafterFindの中身をこんな風に書き換えてみます。

class AppModel extends Model {
  public function beforeFind($queryData) {
    $this->useDbConfig = 'read';

    foreach($this->belongsTo as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }

    foreach($this->hasOne as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }

    foreach($this->hasMany as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }

    foreach($this->hasAndBelongsToMany as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }
    return $queryData;
  }

  public function afterFind($results, $primary = false) {
    $this->useDbConfig = 'default';

    foreach($this->belongsTo as $name => $model) {
      $this->{$name}->useDbConfig = 'default';
    }

    foreach($this->hasOne as $name => $model) {
      $this->{$name}->useDbConfig = 'default';
    }

    foreach($this->hasMany as $name => $model) {
      $this->{$name}->useDbConfig = 'default';
    }

    foreach($this->hasAndBelongsToMany as $name => $model) {
      $this->{$name}->useDbConfig = 'default';
    }
    return $results;
  }
}

難しいことは考えずにとりあえず全部まるっと接続先を変えています。これでbelongsToやhasManyのモデルもSELECT時に接続先がreadに変わります。



セッションが上手く機能しない(ことがある)

僕はセッションをデータベースに保存する設定にしているのですが、上記の振り分け設定を行ったらなぜかセッションが上手く機能しなくなってしまいまして……CakePHPでセッションをデータベースに保存する方法は今日の本題からずれてしまうので割愛しますが、実際に何が起きたのかというと、例えばログインしたり商品購入ページでカートに商品を入れたりした時に、その情報が上手くセッションに書き込まれていないという状態が発生しました。

ログを取ったりしていろいろと動きを見てみたんですが、どうやら書き込みに失敗しているわけではなく、いったんは正常に書き込まれるんですけど、その後すぐに前の状態にもう一度上書きされるみたいな感じでした。セッションが二回上書きされるみたいな。だから結果的にセッション情報が更新されず、カートの中身が変わっていないみたいな状態になっていました。

じゃあ二回書き込まれないようにすれば良いんじゃねーのって話なんですが、それをどうやるかがよく分からなくてですね……そもそも何で二回上書きされるのかもよく分かりませんでした。しかも100%この現象が発生するわけじゃなくて、正しくセッション情報が更新されることもあるので、なおさらわけわかめでした。

というわけでめっちゃ強引なんですけど、とりあえずセッションだけ接続を振り分けないようにbeforeFindを書き換えてみます。

public function beforeFind($queryData) {
  if($this->name != 'Session') {
    $this->useDbConfig = 'read';

    foreach($this->belongsTo as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }

    foreach($this->hasOne as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }

    foreach($this->hasMany as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }

    foreach($this->hasAndBelongsToMany as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }
  }
  return $queryData;
}

ちょっとダサいですけど、これでセッションテーブルだけは常にdefault側に接続されるようになるので不具合は発生しなくなります。



セッション以外も上手く機能しないことがある

セッションだけ振り分けないようしたら全て解決かというとそんなことはなく、むしろセッション以外のテーブルでも正しい動きにならないことがありました。簡単にいうとデータの取得件数が正しくないみたいな状態が発生しました。

どんな場合かっていうのを言葉で説明するのが難しいので具体例をあげると、例えば会社情報とその会社に所属する社員情報を持つ二つのテーブルがあるとします。

会社情報テーブル(companies)
+----+--------+-------+
| id |  name  | count |
+----+--------+-------+
|  1 | Anazon |   3   | 
|  2 | Moogle |   2   | 
+----+--------+-------+

社員情報テーブル(members)
+----+--------------+------+
| id |  company_id  | name |
+----+--------------+------+
|  1 |      1       | John |
|  2 |      1       | Adam |
|  3 |      1       | Anna |
|  4 |      2       | Taro |
|  5 |      2       | Hana |
+----+--------------+------+

すごい適当ですがこんな感じでデータが入ってると思ってください。会社情報テーブルのcountは社員数のデータです。

ここで社員の登録や削除を行った時、同時に会社情報テーブルの社員数を更新するという処理があったとします。

//社員データの登録
$data = array(
  'company_id' => 1,
  'name' => 'Nancy',
);
$this->Member->create($data);
$this->Member->save();

//社員数の更新
$params = array('conditions' => array('company_id' => 1));
$count = $this->Member->find('count');
$this->Company->set('id', 1);
$this->Company->saveField('count', $count);

社員が新規で登録されたら社員情報テーブルから該当のcompany_idを持つレコードの数を取得して、それを会社情報テーブルのcountに入れるという処理を行っています。

上記の場合、company_idが1の社員は現在3人いてそこに新たに一人加わったので、$countの中身は4になるはずです。でもこんな感じの処理を実際にやってみたら$countは4にならず3のままでした。ようするに今登録した分のデータが取得件数に含まれていないという状態になりました。

一人ずつ追加する場合はこんな処理を書かずに単純にプラス1する処理にすれば問題ないんですが、新入社員を一括で大量に登録したり退職者のデータをまとめて消したりした時は、上記のように現時点での社員数を取得して更新するみたいなこともあると思います。

トランザクションの設定を正しく行えていないせいなのかなーとも最初は思ったんですが、いろいろコードをいじくり回しても残念ながら解決できませんでした。

レプリケーションのタイムラグのせいなんですかね? Auroraのレプリカラグって数十ミリ秒程度しかないらしいのでほとんどラグはないに等しい気もするんですが、でも試しに二つの処理の間を1秒開けたら正しい値が取れたので、もしかしたらそういうことなのかも。

//社員データの登録
$data = array(
  'company_id' => 1,
  'name' => 'Nancy',
);
$this->Member->create($data);
$this->Member->save();

sleep(1);//sleepで1秒開ける

//社員数の更新
$params = array('conditions' => array('company_id' => 1));
$count = $this->Member->find('count');

$this->Company->set('id', 1);
$this->Company->saveField('count', $count);

こんな感じでsleepを挟んだらちゃんと$countが4になりました。

だからこれで解決としても良いっちゃ良いんですが、でもせっかくMySQLの5倍の性能を持つと言われるAuroraに乗り換えたのに、sleepしまくったせいで「なーんかデータの登録や削除の処理が前より遅くなってね?」ってユーザーさんに思われるのも心外なので、これも微妙な解決方法ではあるのですが、この手の処理を行う時だけはread側に接続しないようにしました。

//AppModel
public function beforeFind($queryData) {
  if(empty($this->useDefaultDb)) {
    $this->useDbConfig = 'read';

    foreach($this->belongsTo as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }

    foreach($this->hasOne as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }

    foreach($this->hasMany as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }

    foreach($this->hasAndBelongsToMany as $name => $model) {
      $this->{$name}->useDbConfig = 'raed';
    }
  }
  return $queryData;
}

//社員データの登録
$data = array(
  'company_id' => 1,
  'name' => 'Nancy',
);
$this->Member->create($data);
$this->Member->save();

//社員数の更新
$this->Member->useDefaultDb = true;
$params = array('conditions' => array('company_id' => 1));
$count = $this->Member->find('count');
$this->Member->useDefaultDb = false;

$this->Company->set('id', 1);
$this->Company->saveField('count', $count);

「useDefaultDb」という変数を用意して、こいつがtrueの時は接続先をreadに切り替えずdefaultのままにするという処理を入れました。これが良いやり方なのかどうかは自分でもよく分かりませんが、一応これで正しい処理結果が得られるようになりました。

ちなみにこの書き方をする場合、セッションモデルは常にuseDefaultDbをtrueにしておけば良いので、先ほどの「if($this->name != ‘Session’)」のif文は不要になります。

//Sessionモデル
class Session extends AppModel {
  public $useDefaultDb = true;
}

こうですね。



callbacksをfalseにしている場合

これは不具合ではないので別に対応しなくても良いんですが、findメソッドを使うときにcallbacksをfalseにしているとbeforeFindを呼び出さないので、接続先の切り替えが発生しません。

なのでそういう時は個別にuseDbConfigを書き換える必要があります。

$this->useDbConfig = 'read';
$data = $this->Model->find('all', array('callbacks' => false));
$this->useDbConfig = 'default';

こんな感じっすね。






接続先を分けたらセッションが動かなくなったとかレプリカラグのせいで正しい件数が取れないみたいな情報って検索しても出てこないので、もしかしたら普通はこんな問題は起きないのかもしれません。そもそも本当にレプリカラグのせいなのか、CakePHP側の問題なのかもはっきりしていないですしね。何か別の部分で致命的に間違った設定をやっちゃってるだけかもしれないし。

今回問題が発生したシステムはもう十年近く運用しているサービスでして、どこをどういじったのか細かいとこまでは追いきれないくらい規模もでかくなってしまってるんで、よく分からない設定をしちゃってる部分も少なくないんですよ。だからこんなゴリ押しで解決することもまれによくあります。そしてこんなゴリ押しをしているせいで後々新たな問題が発生した時にさらにゴリ押さなきゃいけなくなるという悪循環に陥るわけですね。マジで半年くらいかけて一から作り直してーよ(泣)

しかしまあ、すっきりしない部分はあるにせよ、DBの振り分けというのは一度試してみたいとは思っていたので、今回それが実践できたのは良かったと思います。あとAuroraへの乗り換えもできたのが良かった。実は二年くらい前から乗り換えた方が良いんじゃないかな〜とずっと思ってたんですけど、データの量が多すぎて移行は無理だとあきらめてたもんで……。

ちなみに大量のデータ移行はAWSのDMSという機能を使いました。
AWSのDMSを使ってデータを移行してみた

あとこの記事を読んで「せっかく書き込みと読み込みで分けようとしたのに中途半端じゃね?」って思うかもですが、データベースは基本的に読み込みの方が圧倒的に負荷が高いので、なるべく負荷を読み込み側だけに偏らせないようにしたいのなら、むしろ完全に分けないである程度はSELECTの処理もデフォルト側に行くようにしても良いのかもしれません。実際、何日か運用してみた結果、書き込み側は安定してCPU使用率が低いのに対し、読み込み側はちょいちょい負荷が高くなるという状況が発生しました。まあその辺は様子を見ながら、書き込み側に負荷を分けるのか、読み込み側だけインスタンスのスペックを上げるのか、あるいはリードレプリカをさらに増やすのか調整すれば良いのかなと思います。
 もしかしたら何か関連しているかも? 
 質問や感想などお気軽にコメントしてください