CakePHP4で参照クエリをReplicaに分散する

## イントロダクション

ランサーズでは、現在CakePHP2 → CakePHP4へのバージョンアップを行っています。

CakePHP1.3 → 2へのバージョンアップと比べ、単純な手順で移行するわけにはいきません。
CakePHP → 3 or 4へのバージョンアップは、非常に変更が多く、ほぼ作り直しに等しい工数がかかります。

作り直しになるのであれば、このタイミングで今までの負債を解消してしまおうという方針で、バッチと管理画面を別リポジトリに分離して再構築中です。

https://speakerdeck.com/ykanazawa/ransazufalse-cakephp4yi-xing-nituite?slide=9
![スライド9.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/38162/bd323944-3607-187f-7f09-b79b674ebff9.png)

また、この過程でCakePHP4以降のノウハウを貯めておき、後のlancers本体のCakePHP4移行で応用するという方針です。

## 参照クエリのReplica分散

大抵のWebサービスは、更新よりも圧倒的に参照クエリの方が多いと思います。
DBの特にCPU負荷に関しては、スケールアウトしてReplicaに分散することが有効な対策の1つになります。

![DB負荷分散.PNG](https://qiita-image-store.s3.amazonaws.com/0/38162/51d8f6c6-1583-5ec1-3d75-9bee15adbecb.png)

参考：【SRE】成長するサービスとDB負荷との闘い
https://qiita.com/yKanazawa/items/4514eb2d17761a0da48e

## cakephp-master-replicaプラグイン

ランサーズでは、参照クエリのReplica分散処理を独自に実装していましたが、CakePHP4に移行中のプロジェクトでは、以下のを利用して分散しています。

cakephp-master-replicaプラグイン
https://packagist.org/packages/connehito/cakephp-master-replica

このプラグインはCakePHP3でも利用可能で、CakePHP2用にも以下の形で用意されています。
https://packagist.org/packages/connehito/cakephp2-master-replica

## 参照SQLを自動的にReplicaに振り分ける

このプラグインを使えば、手軽にReplicaへの負荷分散が可能ですが、参照SQLの実行前にReplicaに切り替え、実行後にMasterに戻す処理をその都度入れるのは面倒です。

そこで、Tableクラスを継承したBaseTableクラスを作成し、参照SQLを発行する関数を実行する前にReplicaに切り替え、実行後に戻すという処理を実装しました。

### 参照SQL発行直前にReplicaに切り替え、実行直後にmasterに戻す

初めに実装していたのでこの方法です。

```php

abstract class BaseTable extends Table
{
    public function __call($method, $args)
    {
        if (preg_match('/^find(?:\w+)?By/', $method) > 0) {
            $this->getConnection()->switchRole('replica');
            $result = parent::__call($method, $args);
            $this->getConnection()->switchRole('master');
            return $result;
        }
        return parent::__call($method, $args);
    }

    public function find(string $type = 'all', array $options = []): Query
    {
        $this->getConnection()->switchRole('replica');
        $result = parent::find($type, $options);
        $this->getConnection()->switchRole('master');
        return $result;
    }
…
```

ところが、この方法だと、状況によってはReplicaに切り替わらないことがわかりました。

※原因は調査中ですが、switchRole関数で頻繁にReplica、Masterの切り替え行うと実行されないことがあるようです。

（クエリログを見る限りは、switchRole関数で切り替えたときに、必ずしも切り替わるわけではないらしいことまでは確認しました。）

### 参照SQL発行前にReplicaに切り替え、更新SQL実行前にmasterに切り替え

そこで、参照SQLを発行する際にReplicaに切り替え、更新時にMasterに切り替えるという処理にしました。

この方法であれば、switchRole関数の発行回数が大幅に減ります。
ただし、更新クエリを発行する関数全てにMasterに切り替える処理を入れないとエラーとなってしまうため注意が必要です。

現在、この方法を採用し、どんな状況でもReplicaに切り替わることを確認しています。（確認した限りですが）

```php
abstract class BaseTable extends Table
{
...
    public function __call($method, $args)
    {
        if (preg_match('/^find(?:\w+)?By/', $method) > 0) {
            $this->_switchReplica();
            return parent::__call($method, $args);
        }
        $this->_switchMaster();
        return parent::__call($method, $args);
    }
...
    public function query(): Query
    {
        $this->_switchMaster();
        return parent::query();
    }

    public function updateAll($fields, $conditions): int
    {
        $this->_switchMaster();
        return parent::updateAll($fields, $conditions);
    }
...
```

### トランザクション実行時はswitchRoleを行わない

トランザクションを実行中にswitchRoleを行うと、トランザクションが切れてしまうため、トランザクション実行中は切り替えないように実装します。

```php
    private function _switchMaster(): void
    {
        $conn = $this->getConnection();
        if ($conn->inTransaction()) {
            return;
        }
        $conn->switchRole('master');
    }

    private function _switchReplica(): void
    {
        $conn = $this->getConnection();
        if ($conn->inTransaction()) {
            return;
        }
        $conn->switchRole('replica');
    }
```

そして、トランザクションを実行する直前に、ここだけmasterに手動切り替えが必要になります。

```php
    $conn = $this->Users->getConnection();
    $conn->switchRole('master');
    $conn->transactional($saveProcess);
```

## 実装したコード

上記を踏まえて実装したコードが以下になります。

※このソースコードはGithubでも公開しています。
https://github.com/LancersDevTeam/PHP_versionup/blob/master/CakePHP4/src/Model/Table/BaseTable.php

src/Model?table?Basetable.php

```php
<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\Datasource\EntityInterface;
use Cake\ORM\Query;
use Cake\ORM\Table;

/**
 * @SuppressWarnings(PHPMD)
 */
abstract class BaseTable extends Table
{
    private function _switchMaster(): void
    {
        $conn = $this->getConnection();
        if ($conn->inTransaction()) {
            return;
        }
        $conn->switchRole('master');
    }

    private function _switchReplica(): void
    {
        $conn = $this->getConnection();
        if ($conn->inTransaction()) {
            return;
        }
        $conn->switchRole('replica');
    }

    public function __call($method, $args)
    {
        if (preg_match('/^find(?:\w+)?By/', $method) > 0) {
            $this->_switchReplica();
            return parent::__call($method, $args);
        }
        $this->_switchMaster();
        return parent::__call($method, $args);
    }

    public function find(string $type = 'all', array $options = []): Query
    {
        $this->_switchReplica();
        return parent::find($type, $options);
    }

    public function findAll(Query $query, array $options): Query
    {
        $this->_switchReplica();
        return parent::findAll($query, $options);
    }

    public function findList(Query $query, array $options): Query
    {
        $this->_switchReplica();
        return parent::findList($query, $options);
    }

    public function findThreaded(Query $query, array $options): Query
    {
        $this->_switchReplica();
        return parent::findThreaded($query, $options);
    }

    public function get($primaryKey, $options = []): EntityInterface
    {
        $this->_switchReplica();
        return parent::get($primaryKey, $options);
    }

    public function findOrCreate($search, ?callable $callback = null, $options = []): EntityInterface
    {
        $this->_switchMaster();
        return parent::findOrCreate($search, $callback, $options);
    }

    public function query(): Query
    {
        $this->_switchMaster();
        return parent::query();
    }

    public function updateAll($fields, $conditions): int
    {
        $this->_switchMaster();
        return parent::updateAll($fields, $conditions);
    }

    public function deleteAll($conditions): int
    {
        $this->_switchMaster();
        return parent::deleteAll($conditions);
    }

    public function exists($conditions): bool
    {
        $this->_switchReplica();
        return parent::exists($conditions);
    }

    public function save(EntityInterface $entity, $options = [])
    {
        $this->_switchMaster();
        return parent::save($entity, $options);
    }

    public function saveOrFail(EntityInterface $entity, $options = []): EntityInterface
    {
        $this->_switchMaster();
        return parent::saveOrFail($entity, $options);
    }

    public function saveMany(iterable $entities, $options = [])
    {
        $this->_switchMaster();
        return parent::saveMany($entities, $options);
    }

    public function saveManyOrFail(iterable $entities, $options = []): iterable
    {
        $this->_switchMaster();
        return parent::saveManyOrFail($entities, $options);
    }

    public function delete(EntityInterface $entity, $options = []): bool
    {
        $this->_switchMaster();
        return parent::delete($entity, $options);
    }

    public function deleteMany(iterable $entities, $options = [])
    {
        $this->_switchMaster();
        return parent::deleteMany($entities, $options);
    }

    public function deleteManyOrFail(iterable $entities, $options = []): iterable
    {
        $this->_switchMaster();
        return parent::deleteManyOrFail($entities, $options);
    }

    public function deleteOrFail(EntityInterface $entity, $options = []): bool
    {
        $this->_switchMaster();
        return parent::deleteOrFail($entity, $options);
    }
}
```