├── README.md └── components ├── MDbConnection.php └── MDbSlaveConnection.php /README.md: -------------------------------------------------------------------------------- 1 | # Yii数据库读写分离组件 2 | 3 | 这是一个供Yii Framework(以下统称Yii)使用的数据库读写分离组件,使用此组件只需通过简单的配置,即可使你的应用自动的实现读写分离。 4 | 5 | ## 开始之前 6 | 7 | Yii读写分离包含两个组件: 8 | 9 | 1. `MDbConnection` 读写自动路由组件,使用这个名字更希望应用使用这个组件来替代Yii默认的CDbConnection 10 | 2. `MDbSlaveConnection` 从库(Readonly)组件 11 | 12 | 一般在PHP项目中常用的实现读写分离的方法有两种: 13 | 14 | 1. **自动分离** 由系统来决定读与写应该在哪个数据库上,对工程师透明,正常情况下既有代码无需修改即可使用,可以做到完全的读写分离,而且从库故障时可以自动切换在主库读取,推荐使用; 15 | 2. **手动分离** 由工程师来决定读与写应该在主还是从数据库上,工程师在开发时必须注意着主从的存在及其所带来的影响,但可能出现主库读取过度的问题(根据以往经验很多人会将大部分的读写都放在了主库上)。 16 | 17 | 下面将分别对两种实现读写分离的方法进行说明。 18 | 19 | ## 自动分离 20 | 21 | 自动分离无需指定是使用主库还是从库,并支持在`ActiveRecord`及`QueryBuilder`中的读写自动分离。支持多从库配置,每个请求只会落在随机的一个从库上,该从库为配置里面随机的一个,无需担心配置在前面的从库压力会过大。 22 | 23 | 对于大部分业务来说切换为自动读写分离后不会对既有逻辑产生影响,可以做到平滑的切换,但对于写完立即读可能会存在读不到数据的情况,如有这样的写法请修改,仍然建议使用前Review自己的代码。 24 | 25 | ### 安装步骤 26 | 27 | 安装组件之前当然需要先把组件down一份到你的应用的`components`目录里面去,关于怎么down这个就不做介绍了,下面从放置组件开始。 28 | 29 | #### 放置组件 30 | 31 | 将down下来的组件包中的`MDbConnection.php`、`MDbSlaveConnection.php`复制到你的应用组件目录中,正常来说路径应该在`protected/components`目录中。 32 | 33 | #### 修改Yii应用的配置文件 34 | 35 | 修改Yii应用的配置文件,默认的配置文件为`protected/main.php`,然后在其中找components 部分下的 db 组件的配置,例如: 36 | 37 | ```php 38 | ... 39 | 'db'=>array( 40 | 'connectionString' => 'mysql:host=192.168.10.100;dbname=testDb', 41 | 'username' => 'appuser', 42 | 'password' => 'apppassword', 43 | 'charset' => 'utf8', 44 | 'tablePrefix' => 'app_', 45 | ), 46 | ... 47 | ``` 48 | 49 | 将其修改为: 50 | 51 | ```php 52 | ... 53 | 'db'=>array( 54 | 'class' => 'MDbConnection', // 指定使用读写分离Class 55 | 'connectionString' => 'mysql:host=192.168.10.100;dbname=testDb', // 主库配置 56 | 'username' => 'appuser', 57 | 'password' => 'apppassword', 58 | 'charset' => 'utf8', 59 | 'tablePrefix' => 'app_', 60 | 'timeout' => 3, // 增加数据库连接超时时间,默认3s 61 | 'slaves' => array( 62 | array( 63 | 'connectionString' => 'mysql:host=192.168.10.101;dbname=testDb', 64 | 'username' => 'appuser', 65 | 'password' => 'apppassword', 66 | ), // 从库 1 67 | array( 68 | 'connectionString' => 'mysql:host=192.168.10.102;dbname=testDb', 69 | 'username' => 'appuser', 70 | 'password' => 'apppassword', 71 | ), // 从库 2 72 | ), // 从库配置 73 | ), 74 | ... 75 | ``` 76 | 77 | ***注意:slaves中的配置必须是二维数组,可配置的值为CDbConnection中支持的全部值(属性)。*** 78 | 79 | ### 配置继承 80 | 81 | 为简化应用配置的复杂度、以及结合大部分应用的使用场景,从库配置(部分配置)如果没有设置则会自动继承主库的配置,会继承的配置为: 82 | 83 | * username 84 | * password 85 | * charset 86 | * tablePrefix 87 | * timeout 88 | * emulatePrepare 89 | * enableParamLogging 90 | 91 | 因此配置文件也可以简化为: 92 | 93 | ```php 94 | ... 95 | 'db'=>array( 96 | 'class' => 'MDbConnection', // 指定使用读写分离Class 97 | 'connectionString' => 'mysql:host=192.168.10.100;dbname=testDb', // 主库配置 98 | 'username' => 'appuser', 99 | 'password' => 'apppassword', 100 | 'charset' => 'utf8', 101 | 'tablePrefix' => 'app_', 102 | 'slaves' => array( 103 | array( 104 | 'connectionString' => 'mysql:host=192.168.10.101;dbname=testDb', 105 | ), // 从库 1 106 | array( 107 | 'connectionString' => 'mysql:host=192.168.10.102;dbname=testDb', 108 | ), // 从库 2 109 | ), // 从库配置 110 | ), 111 | ... 112 | ``` 113 | 114 | ### 关闭从库 115 | 116 | 如果需要临时关闭从库查询,或者没有从库只需注释掉slaves部分的配置即可。 117 | 118 | ### 注意 119 | 120 | * 在所有从库无法连接时,读操作会在主库上进行,反之则不会; 121 | * 进行写操作(在主库)后,立即读数据(在从库),可能会存在延时问题,在使用时请避免此类写法。 122 | 123 | ## 手动分离 124 | 125 | 在自动分离中,数据库的读写对工程师是透明的,因此会在开发过程中出现未考虑主从延时的问题,导致一些潜在的漏洞。相比之下手动分离则提醒着工程师时刻注意着主从的存在。 126 | 127 | ### 安装步骤 128 | 129 | 同自动分离部分,你首先也需要down一份组件下来。 130 | 131 | #### 放置组件 132 | 133 | 手动分离只依赖组件包中的`MDbSlaveConnection.php`,将其复制到你的应用组件目录中,正常来说路径应该在`protected/components`目录中。 134 | 135 | #### 修改Yii应用的配置文件 136 | 137 | 修改Yii应用的配置文件,默认的配置文件为`protected/main.php`,然后在 components 中 db 的配置后增加从库组件的配置,例如: 138 | 139 | ```php 140 | ... 141 | 'dbRead'=>array( 142 | 'class' => 'MDbSlaveConnection', 143 | 'connectionString' => 'mysql:host=192.168.10.101;dbname=testDb', 144 | 'username' => 'appuser', 145 | 'password' => 'apppassword', 146 | 'charset' => 'utf8', 147 | 'tablePrefix' => 'app_', 148 | ), 149 | ... 150 | ``` 151 | 152 | **手动分离中,从库目前不会继承主库的配置哦!** 153 | 154 | #### 使用示例 155 | 156 | 在应用中进行读操作 157 | 158 | ```php 159 | Yii::app()->dbRead->createCommand() 160 | ->select('id, username, email') 161 | ->from('{{user}}') 162 | ->where('id=:id', array(':id'=>$id)) 163 | ->queryRow(); 164 | ``` 165 | 166 | 在应用中进行写操作 167 | 168 | ```php 169 | Yii::app()->db->createCommand() 170 | ->insert('{{user}}', array( 171 | 'username' => 'devtoby', 172 | 'email' => 'quflylong@qq.com', 173 | )); 174 | ``` 175 | 176 | ## 反馈问题 177 | 178 | [快来提一个Issue吧。](https://github.com/devtoby/yii-db-read-write-splitting/issues/new) 179 | -------------------------------------------------------------------------------- /components/MDbConnection.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | 12 | class MDbConnection extends CDbConnection 13 | { 14 | /** 15 | * @var int 连接数据库超时时间 16 | */ 17 | public $timeout = 3; 18 | 19 | /** 20 | * @var array 从库配置数组 21 | * @example array( array('connectionString'=>'mysql://'), array('connectionString'=>'mysql://'),...) 22 | */ 23 | public $slaves = array(); 24 | 25 | /** 26 | * @var bool 是否开启从库自动继承主库的部分属性 27 | */ 28 | public $isAutoExtendsProperty = true; 29 | 30 | /** 31 | * @var bool 强制使用主库 32 | */ 33 | private $_forceUseMaster = false; 34 | 35 | /** 36 | * @var MDbConnection 37 | */ 38 | private $_slave; 39 | 40 | /** 41 | * @var array 从库自动继承主库的属性 42 | */ 43 | private $_autoExtendsProperty = array( 44 | 'username', 'password', 'charset', 'tablePrefix', 'timeout', 'emulatePrepare', 'enableParamLogging', 45 | ); 46 | 47 | /** 48 | * @var array 数据库读操作的SQL前缀(前4个字符) 49 | */ 50 | private $_readSqlPrefix = array( 51 | 'SELE', 'DESC', 'SHOW' 52 | ); 53 | 54 | /** 55 | * 创建一个 command. 56 | * 57 | * @param string $sql 58 | * @return CDbCommand 59 | */ 60 | public function createCommand($sql = null) 61 | { 62 | if ( 63 | !$this->_forceUseMaster && $this->slaves && is_string($sql) && !$this->getCurrentTransaction() 64 | && $this->isReadOperation($sql) && ($slave = $this->getSlave()) 65 | ) { 66 | return $slave->createCommand($sql); 67 | } 68 | 69 | return parent::createCommand($sql); 70 | } 71 | 72 | /** 73 | * 强制使用Master,为避免主库过大压力,请随用随关 74 | * 【注意】除非你有足够的理由,否则请勿使用 75 | * 76 | * @param bool $value 77 | */ 78 | public function forceUseMaster($value = false) 79 | { 80 | $this->_forceUseMaster = $value; 81 | } 82 | 83 | /** 84 | * 打开或关闭数据库连接 85 | * 86 | * @param boolean $value whether to open or close DB connection 87 | * @throws CException if connection fails 88 | */ 89 | public function setActive($value) 90 | { 91 | if ($value != $this->getActive() && $value) { 92 | $this->setAttribute(PDO::ATTR_TIMEOUT, $this->timeout); 93 | } 94 | 95 | parent::setActive($value); 96 | } 97 | 98 | /** 99 | * 获取从库连接 100 | * 101 | * @return MDbSlaveConnection 102 | */ 103 | private function getSlave() 104 | { 105 | if (!$this->_slave && $this->slaves && is_array($this->slaves)) { 106 | shuffle($this->slaves); 107 | 108 | foreach ($this->slaves as $slaveConfig) { 109 | if ($this->isAutoExtendsProperty) { 110 | // 自动属性继承 111 | foreach ($this->_autoExtendsProperty as $property) { 112 | isset($slaveConfig[$property]) || $slaveConfig[$property] = $this->$property; 113 | } 114 | } 115 | 116 | $slaveConfig['class'] = 'MDbSlaveConnection'; 117 | $slaveConfig['autoConnect'] = false; 118 | $slaveConfig['isNeedReadCheck'] = false; // 因为在路由时已经检查过了 119 | 120 | try { 121 | $slave = Yii::createComponent($slaveConfig); 122 | 123 | $slave->setAttribute(PDO::ATTR_TIMEOUT, $this->timeout); 124 | $slave->setActive(true); 125 | 126 | $this->_slave = $slave; 127 | break; 128 | } catch (Exception $e) { 129 | Yii::log("Slave database connection failed! Connection string:{$slaveConfig['connectionString']}", 'warning'); 130 | } 131 | } 132 | } 133 | 134 | return $this->_slave; 135 | } 136 | 137 | /** 138 | * 是否为Read操作 139 | * 140 | * @param string $sql SQL语句 141 | * 142 | * @return bool 143 | */ 144 | private function isReadOperation($sql) 145 | { 146 | $sqlPrefix = strtoupper(substr(ltrim($sql), 0, 4)); 147 | foreach ($this->_readSqlPrefix as $prefix) { 148 | if ($sqlPrefix == $prefix) { 149 | return true; 150 | } 151 | } 152 | 153 | return false; 154 | } 155 | } -------------------------------------------------------------------------------- /components/MDbSlaveConnection.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | 12 | class MDbSlaveConnection extends CDbConnection 13 | { 14 | /** 15 | * @var int 连接数据库超时时间 16 | */ 17 | public $timeout = 3; 18 | 19 | /** 20 | * @var int 是否需要读操作检查,开启后会检查sql是否为查询语句 21 | */ 22 | public $isNeedReadCheck = true; 23 | 24 | /** 25 | * @var array 数据库读操作的SQL前缀(前4个字符) 26 | */ 27 | private $_readSqlPrefix = array( 28 | 'SELE', 'DESC', 'SHOW' 29 | ); 30 | 31 | /** 32 | * 创建一个 command. 33 | * 34 | * @param string $sql 35 | * @return CDbCommand 36 | */ 37 | public function createCommand($sql = null) 38 | { 39 | if (is_string($sql) && $this->isNeedReadCheck && !$this->isReadOperation($sql)) { 40 | throw new CDbException('Slave database is readonly! SQL:'.$sql); 41 | } 42 | 43 | return parent::createCommand($sql); 44 | } 45 | 46 | /** 47 | * 打开或关闭数据库连接 48 | * 49 | * @param boolean $value whether to open or close DB connection 50 | * @throws CException if connection fails 51 | */ 52 | public function setActive($value) 53 | { 54 | if ($value != $this->getActive() && $value) { 55 | $this->setAttribute(PDO::ATTR_TIMEOUT, $this->timeout); 56 | } 57 | 58 | parent::setActive($value); 59 | } 60 | 61 | /** 62 | * Returns the currently active transaction. 63 | * 64 | * @return null 65 | */ 66 | public function getCurrentTransaction() 67 | { 68 | return null; 69 | } 70 | 71 | /** 72 | * Starts a transaction. 73 | * 74 | * @throw CDbException 75 | */ 76 | public function beginTransaction() 77 | { 78 | throw new CDbException('Can\'t begin transaction on slave database.'); 79 | } 80 | 81 | /** 82 | * 是否为Read操作 83 | * 84 | * @param string $sql SQL语句 85 | * 86 | * @return bool 87 | */ 88 | private function isReadOperation($sql) 89 | { 90 | $sqlPrefix = strtoupper(substr(ltrim($sql), 0, 4)); 91 | foreach ($this->_readSqlPrefix as $prefix) { 92 | if ($sqlPrefix == $prefix) { 93 | return true; 94 | } 95 | } 96 | 97 | return false; 98 | } 99 | } --------------------------------------------------------------------------------