8
4

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-12-21

イントロダクション

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

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

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

https://speakerdeck.com/ykanazawa/ransazufalse-cakephp4yi-xing-nituite?slide=9
スライド9.PNG

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

参照クエリのReplica分散

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

DB負荷分散.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に戻す

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


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に切り替わることを確認しています。(確認した限りですが)

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を行うと、トランザクションが切れてしまうため、トランザクション実行中は切り替えないように実装します。

    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に手動切り替えが必要になります。

    $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
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);
    }
}
8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4