laravel-mysql读写分离

使用【如果不想了解源代码,直接看3种使用方式就好】

配置

'mysql' => [
    'write' => [
        'host' => '127.0.0.1',
        'port' => 3306
    ],
    'read' => [
        [
            'host' => '127.0.0.1',
            'port' => 3307
        ],
    ],
    'driver' => 'mysql',
    // 'host' => env('DB_HOST', '127.0.0.1'),
    // 'port' => env('DB_PORT', '3306'),
    'database' => env('DB_DATABASE', 'forge'),
    'username' => env('DB_USERNAME', 'forge'),
    'password' => env('DB_PASSWORD', ''),
    'unix_socket' => env('DB_SOCKET', ''),
    'charset' => 'utf8',
    'collation' => 'utf8_unicode_ci',
    // 'charset' => 'utf8mb4',
    // 'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
    'strict' => true,
    'engine' => null,
    // 'sticky'    => true, // laravel 5.5 新增
],

使用写库读数据的三种方式

  • 方法1:
$user = DB::selectFromWriteConnection('select * from users where id=42111');
  • 方法2:
User::onWriteConnection()->find($id);
  • 方法3:

通过配置 'sticky' => true,

laravel读写分离配置+源代码解释

一 配置过程

config/database.php里面配置

'mysql' => [  
    'write'    => [  
        'host' => '192.168.1.180',  
    ],  
    'read'     => [  
        ['host' => '192.168.1.182'],  
        ['host' => '192.168.1.179'],  
    ],  
  'sticky'    => true, // laravel 5.5 新增
  'driver'   => 'mysql',  
  'port' => env('DB_PORT', '3306'),
  'unix_socket' => env('DB_SOCKET', ''),
  'engine' => null,
  'database'  => 'database',
  'username'  => 'root',
  'password'  => '',
  'charset'   => 'utf8',
  'collation' => 'utf8_unicode_ci',
  'prefix'    => '',
]  

加强版,支持多主多从,支持独立用户名和密码,配置如下

'mysql' => [  
    'write'    => [  
        [
            'host' => '192.168.1.180',
            'username'  => '',
            'password'  => '',
        ],  
    ],  
    'read'     => [  
        [
            'host' => '192.168.1.182',
            'username'  => '',
            'password'  => '',
        ],  
        [
            'host' => '192.168.1.179',
            'username'  => '',
            'password'  => '',
        ],  
    ],  
    'driver'    => 'mysql',  
    'database'  => 'database',     
    'charset'   => 'utf8',  
    'collation' => 'utf8_unicode_ci',  
    'prefix'    => '', 
]

设置完毕之后,Laravel5默认将 select 的语句让 read 指定的数据库执行,insert/update/delete 则交给 write 指定的数据库,达到读写分离的作用。

这些设置对原始查询 raw queries,查询生成器 query builder,以及 Eloquent ORM 都生效。

官网解释如下:

Sometimes you may wish to use one database connection for SELECT statements, and another for INSERT, UPDATE, and DELETE statements. Laravel makes this a breeze, and the proper connections will always be used whether you are using raw queries, the query builder, or the Eloquent ORM

验证

开启MySQL的 general-log ,通过 tail -f 的方式监控log变化来确定配置是否生效

注意点1:

sticky 是一个 可选的 选项,它可用于立即读取在当前请求周期内已写入数据库的记录。

如果 sticky 选项被启用,并且在当前的请求周期内在数据库执行过「写入」操作,那么任何「读取」的操作都将使用「写入」连接。这可以确保在请求周期内写入的任何数据可以在同一请求期间立即从数据库读回。这个选项的作用取决于应用程序的需求。【sticky 选项是一个可选的配置值,可用于在当前请求生命周期内允许立即读取写入数据库的记录。如果 sticky 选项被启用并且一个"写"操作在当前生命周期内发生,则后续所有"读"操作都会使用这个"写"连接(前提是同一个请求生命周期内),这样就可以确保同一个请求生命周期内写入的数据都可以立即被读取到,从而避免主从延迟导致的数据不一致,是否启用这一功能取决于你。】

当然,这只是一个针对分布式数据库系统中主从数据同步延迟的一个非常初级的解决方案,访问量不高的中小网站可以这么做,大流量高并发网站肯定不能这么干,主从读写分离本来就是为了解决单点性能问题,这样其实是把问题又引回去了,造成所有读写都集中到写数据库,对于高并发频繁写的场景下,后果可能是不堪设想的,但是话说回来,对于并发量不那么高,写操作不那么频繁的中小型站点来说,sticky 这种方式不失为一个初级的解决方案。

注意点2:

注:目前读写分离仅支持单个写连接。

二 实现原理

Laravel5读写分离主要有两个过程:

第一步,根据 database.php 配置,创建写库和读库的链接 connection

第二步,调用 select 时先判断使用读库还是写库,而 insert/update/delete 统一使用写库

三 源码分析:根据database.php配置,创建写库和读库的链接connection

主要文件:/vendor/laravel/framework/src/Illuminate/Database/Connectors/ConnectionFactory.php 来看看几个重要的函数:

  • 判断 database.php 是否配置了读写分离数据库
    /**
     * Establish a PDO connection based on the configuration.
     *
     * @param  array   $config
     * @param  string  $name
     * @return \Illuminate\Database\Connection
     */
    public function make(array $config, $name = null)
    {
        $config = $this->parseConfig($config, $name);

        // 如果配置了读写分离,则同时创建读库和写库的链接【因为写库也可以读】
        if (isset($config['read'])) {
            return $this->createReadWriteConnection($config);
        }
        
        // 如果没有配置,默认创建单个数据库链接
        return $this->createSingleConnection($config);
    }
  • 创建读库和写库的链接
/**
 * Create a single database connection instance.
 *
 * @param  array  $config
 * @return \Illuminate\Database\Connection
 */
protected function createReadWriteConnection(array $config)
{
    // 获取写库的配置信息,并创建链接
    $connection = $this->createSingleConnection($this->getWriteConfig($config));
    // 创建读库的链接
    return $connection->setReadPdo($this->createReadPdo($config));
}
  • 多个读库会选择哪个呢

旧版本:

/**
 * Get the read configuration for a read / write connection.
 *
 * @param  array  $config
 * @return array
 */
protected function getReadConfig(array $config)
{
    $readConfig = $this->getReadWriteConfig($config, 'read');

    // 如果数组即多个读库,那么通过随机函数array_rand()挑一个,默认取第一个
    if (isset($readConfig['host']) && is_array($readConfig['host'])) {
        $readConfig['host'] = count($readConfig['host']) > 1
            ? $readConfig['host'][array_rand($readConfig['host'])]
            : $readConfig['host'][0];
    }
    return $this->mergeReadWriteConfig($config, $readConfig);
}

新版本:

    /**
     * Get the read configuration for a read / write connection.
     *
     * @param  array  $config
     * @return array
     */
    protected function getReadConfig(array $config)
    {
        return $this->mergeReadWriteConfig(
            $config, $this->getReadWriteConfig($config, 'read')
        );
    }

    /**
     * Merge a configuration for a read / write connection.
     *
     * @param  array  $config
     * @param  array  $merge
     * @return array
     */
    protected function mergeReadWriteConfig(array $config, array $merge)
    {
        return Arr::except(array_merge($config, $merge), ['read', 'write']);
    }

    /**
     * Get a read / write level configuration.
     *
     * @param  array   $config
     * @param  string  $type
     * @return array
     */
    protected function getReadWriteConfig(array $config, $type)
    {
        return isset($config[$type][0])
                        ? Arr::random($config[$type])
                        : $config[$type];
    }
class Arr
{
   ...

    /**
     * Get all of the given array except for a specified array of keys.
     *
     * @param  array  $array
     * @param  array|string  $keys
     * @return array
     */
    public static function except($array, $keys)
    {
        static::forget($array, $keys);

        return $array;
    }

   ...

    /**
     * Remove one or many array items from a given array using "dot" notation.
     *
     * @param  array  $array
     * @param  array|string  $keys
     * @return void
     */
    public static function forget(&$array, $keys)
    {
        $original = &$array;

        $keys = (array) $keys;

        if (count($keys) === 0) {
            return;
        }

        foreach ($keys as $key) {
            // if the exact key exists in the top-level, remove it
            if (static::exists($array, $key)) {
                unset($array[$key]);

                continue;
            }

            $parts = explode('.', $key);

            // clean up before each pass
            $array = &$original;

            while (count($parts) > 1) {
                $part = array_shift($parts);

                if (isset($array[$part]) && is_array($array[$part])) {
                    $array = &$array[$part];
                } else {
                    continue 2;
                }
            }

            unset($array[array_shift($parts)]);
        }
    }

   ...

    /**
     * Get one or a specified number of random values from an array.
     *
     * @param  array  $array
     * @param  int|null  $number
     * @return mixed
     *
     * @throws \InvalidArgumentException
     */
    public static function random($array, $number = null)
    {
        $requested = is_null($number) ? 1 : $number;

        $count = count($array);

        if ($requested > $count) {
            throw new InvalidArgumentException(
                "You requested {$requested} items, but there are only {$count} items available."
            );
        }

        if (is_null($number)) {
            return $array[array_rand($array)];
        }

        if ((int) $number === 0) {
            return [];
        }

        $keys = array_rand($array, $number);

        $results = [];

        foreach ((array) $keys as $key) {
            $results[] = $array[$key];
        }

        return $results;
    }
}
  • 写库也是随机选择的

旧版本:

/**
 * Get a read / write level configuration.
 *
 * @param  array   $config
 * @param  string  $type
 * @return array
 */
protected function getReadWriteConfig(array $config, $type)
{

    // 如果多个,那么通过随机函数array_rand()挑一个
    if (isset($config[$type][0])) {
        return $config[$type][array_rand($config[$type])];
    }
    return $config[$type];
}

新版本:

    /**
     * Get a read / write level configuration.
     *
     * @param  array   $config
     * @param  string  $type
     * @return array
     */
    protected function getReadWriteConfig(array $config, $type)
    {
        return isset($config[$type][0])
                        ? Arr::random($config[$type])
                        : $config[$type];
    }

总结

  • 可以设置多个读库和多个写库,或者不同组合,比如一个写库两个读库

  • 每次只创建一个读库链接和一个写库链接,从多个库中随机选择一个

四 源码分析:调用select时先判断使用读库还是写库,而insert/update/delete统一使用写库

主要文件:/vendor/laravel/framework/src/Illuminate/Database/Connection.php 看看几个重要的函数

  • select 函数根据第三个输入参数判断使用读库还是写库(true使用读库,false使用写库;默认使用读库)
    /**
     * Run a select statement against the database.
     *
     * @param  string  $query
     * @param  array  $bindings
     * @param  bool  $useReadPdo
     * @return array
     */
    public function select($query, $bindings = [], $useReadPdo = true)
    {
        return $this->run($query, $bindings, function ($query, $bindings) use ($useReadPdo) {
            if ($this->pretending()) {
                return [];
            }

            // 根据$useReadPdo参数,判断使用读库还是写库;
            // true使用读库,false使用写库;默认使用读库
            // For select statements, we'll simply execute the query and return an array
            // of the database result set. Each element in the array will be a single
            // row from the database table, and will either be an array or objects.
            $statement = $this->prepared($this->getPdoForSelect($useReadPdo)
                              ->prepare($query));

            $this->bindValues($statement, $this->prepareBindings($bindings));

            $statement->execute();

            return $statement->fetchAll();
        });
    }

    /**
     * Get the PDO connection to use for a select query.
     *
     * @param  bool  $useReadPdo
     * @return \PDO
     */
    protected function getPdoForSelect($useReadPdo = true)
    {
        // 根据$useReadPdo参数,选择PDO即判断使用读库还是写库;
        // true使用读库getReadPdo,false使用写库getPdo; 
        return $useReadPdo ? $this->getReadPdo() : $this->getPdo();
    }
  • insert/update/delete 统一使用写库
    /**
     * Run an insert statement against the database.
     *
     * @param  string  $query
     * @param  array   $bindings
     * @return bool
     */
    public function insert($query, $bindings = [])
    {
        return $this->statement($query, $bindings);
    }

    /**
     * Run an update statement against the database.
     *
     * @param  string  $query
     * @param  array   $bindings
     * @return int
     */
    public function update($query, $bindings = [])
    {
        return $this->affectingStatement($query, $bindings);
    }

    /**
     * Run a delete statement against the database.
     *
     * @param  string  $query
     * @param  array   $bindings
     * @return int
     */
    public function delete($query, $bindings = [])
    {
        return $this->affectingStatement($query, $bindings);
    }

    /**
     * Execute an SQL statement and return the boolean result.
     *
     * @param  string  $query
     * @param  array   $bindings
     * @return bool
     */
    public function statement($query, $bindings = [])
    {
        return $this->run($query, $bindings, function ($query, $bindings) {
            if ($this->pretending()) {
                return true;
            }
            
            // 直接调用写库
            $statement = $this->getPdo()->prepare($query);

            $this->bindValues($statement, $this->prepareBindings($bindings));

            $this->recordsHaveBeenModified();

            return $statement->execute();
        });
    }

    /**
     * Run an SQL statement and get the number of rows affected.
     *
     * @param  string  $query
     * @param  array   $bindings
     * @return int
     */
    public function affectingStatement($query, $bindings = [])
    {
        return $this->run($query, $bindings, function ($query, $bindings) {
            if ($this->pretending()) {
                return 0;
            }

            // 直接调用写库
            // For update or delete statements, we want to get the number of rows affected
            // by the statement and return that back to the developer. We'll first need
            // to execute the statement and then we'll use PDO to fetch the affected.
            $statement = $this->getPdo()->prepare($query);

            $this->bindValues($statement, $this->prepareBindings($bindings));

            $statement->execute();

            $this->recordsHaveBeenModified(
                ($count = $statement->rowCount()) > 0
            );

            return $count;
        });
    }

总结:

  • getReadPdo() 获得读库链接,getPdo() 获得写库链接;

  • select() 函数根据第三个参数判断使用读库还是写库;

五 强制使用写库

有时候,我们需要读写实时一致,写完数据库后,想马上读出来,那么读写都指定一个数据库即可。 虽然Laravel5配置了读写分离,但也提供了另外的方法强制读写使用同一个数据库。

实现原理:上面 $this->select() 时指定使用写库的链接,即第三个参数 useReadPdo 设置为 false 即可。

有几个方法可实现:

  • 调用方法1: DB::table('users')->selectFromWriteConnection('*')->where('id', $id)->first();

$user = DB::selectFromWriteConnection('select * from users where id=42111');

源码解释:通过 selectFromWriteConnection() 函数 主要文件:

/vendor/laravel/framework/src/Illuminate/Database/Connection.php

/**
 * Run a select statement against the database.
 *
 * @param  string  $query
 * @param  array   $bindings
 * @return array
 */
public function selectFromWriteConnection($query, $bindings = [])
{
    // 上面有解释$this->select()函数的第三个参数useReadPdod的意义
    // 第三个参数是 false,所以 select 时会使用写库,而不是读库
    return $this->select($query, $bindings, false);
}
  • 调用方法2: User::onWriteConnection()->find($id);

源码解释:通过 onWriteConnection() 函数 主要文件:

/vendor/laravel/framework/src/Illuminate/Database/Eloquent/Model.php

/**
 * Begin querying the model on the write connection.
 *
 * @return \Illuminate\Database\Query\Builder
 */
public static function onWriteConnection()
{
    $instance = new static;
    // query builder 指定使用写库
    return $instance->newQuery()->useWritePdo();
}

再看看 query builder 如何指定使用写库 主要文件:

/vendor/laravel/framework/src/Illuminate/Database/Query/Builder.php

/**
 * Use the write pdo for query.
 *
 * @return $this
 */
public function useWritePdo()
{
    // 指定使用写库,useWritePdo 为true
    $this->useWritePdo = true;

    return $this;
}

/**
 * Run the query as a "select" statement against the connection.
 *
 * @return array
 */
protected function runSelect()
{
    // 执行select时,useWritePdo原值为true,这里取反,被改成false;
    // 即$this->select()函数第三个参数为false,所以使用写库;
    return $this->connection->select($this->toSql(), $this->getBindings(), ! $this->useWritePdo);
}

开启日志验证

使用mysql general log来验证数据库读写分离

主数据库开启general log

mysql> show global variables like '%general%';
+------------------+------------------------------------------------------+
| Variable_name    | Value                                                |
+------------------+------------------------------------------------------+
| general_log      | OFF                                                  |
| general_log_file | D:\soft\phpstudy\PHPTutorial\MySQL\data\admin-PC.log |
+------------------+------------------------------------------------------+
2 rows in set (0.00 sec)

mysql> set global general_log = on;
Query OK, 0 rows affected (0.05 sec)

从数据库开启general log

mysql> show global variables like '%general%';
+------------------+-----------------------------------------------------------+

| Variable_name    | Value                                                     |

+------------------+-----------------------------------------------------------+

| general_log      | OFF                                                       |

| general_log_file | D:\soft\mysql5.7.24\mysql-5.7.24-winx64\data\admin-PC.log |

+------------------+-----------------------------------------------------------+

2 rows in set, 1 warning (0.00 sec)

mysql> set global general_log = on;
Query OK, 0 rows affected (0.04 sec)

关闭日志

mysql> set global general_log = off;
Query OK, 0 rows affected (0.01 sec)
// 指定log路径
// set global general_log_file='/tmp/general.lg';    #设置路径
// 也可以将日志记录在表中
// set global log_output='table' // 可以在mysql数据库下查找 general_log表

问题

问题1

写入了数据,但查询时却报 No query result ,而且只是偶然性出现,没啥规律。

解决方案

初以为是 prettus/l5-repository 包的缓存引起的,但关掉它的缓存功能后问题依旧。后来好一阵折腾,直到再一次仔细翻看文档, 才发现 Laravel5.5 数据库读写分离配置的部分额外提到了一个 sticky 项,文档里这部分原文如下:

The sticky Option

The sticky option is an optional value that can be used to allow the immediate reading of records that have been written to the database during the current request cycle. If the sticky option is enabled and a "write" operation has been performed against the database during the current request cycle, any further "read" operations will use the "write" connection. This ensures that any data written during the request cycle can be immediately read back from the database during that same request. It is up to you to decide if this is the desired behavior for your application.

在没有启用 sticky 的时候,使用 write 连接写入数据后立即读取,读取时使用的是 read 连接,这样就有可能出问题。将 sticky 设置为 true 后,在与这个写入操作相同的请求周期内的后续读取操作,仍然使用原来的 write 连接。

或者 强制使用写库

参考资料:
Laravel 5 配置读写分离和源码分析
Laravel 5 配置数据库主从读写分离和源码分析

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容