Pull to refresh

Шардинг MySQL на Yii Framework

Yii *
Начну с того, что наш проект находится на начальной стадии развития, а его запуск планируется на 1е ноября. И, чтобы сразу отсечь всю возможную критику касаемо преждевременной оптимизации, скажу, что перед командой была поставлена задача разработать приложение, справляющееся с резкими скачками нагрузки (от 1000 до 50000 и т. п.). В связи с этим было решено закладывать хорошо масштабируемую архитектуру, позволяющую легко и быстро увеличивать производительность системы за счет аппаратной части (по принципу scale-out).






Платформа

Платформу мы выбрали быстро — решено было разрабатывать проект на базе Yii Framework. Кратко могу сказать, что на его изучение года полтора назад меня вдохновили отзывы знакомых разработчиков, а выбор yii, как платформы для разработки данного проекта, был сделан благодаря длительному гуглению, тестам и сравнительным характеристикам (типа www.yiiframework.com/performance)

Решено было не отказываться от использования Yii ActiveRecord в качестве ORM, ибо это очень удобно в плане разработки, а так же всегда существует возможность в последствии переработать узкие места на использование прямых запросов к БД. На начальном этапе самым узким местом нам казалась сама структура хранения даных, т. к. поставленные требования намекали нам на то, что к MySQL (выбранная для хранения данных) в скором времени пойдет очень большой поток запросов, с которым один сервер справиться не сможет.

Репликация

Репликация, как решение, отпала сразу. На начальном этапе развития (первый год), она позволила бы нам забыть о проблемах с нагрузкой, но в дальнейшем пришлось бы решать вновь вставшую проблему постоянно загруженного мастера (кстати, решаются эти проблемы очень мутными способами типа прокси для мастера, слейв как мастер для слейвов и т. п.). Да и постоянная работа админов по поддержанию актуальных данных в базах и тасканию логов могла привести к плачевным последствиям, т. к. человеческий фактор никто не отменял.

Шардинг

Выбор пал на горизонтальное масштабирование (шардинг) mysql. Вдаваться в подробности такого принципа размещения данных не буду, скажу только, что экземпляры одной сущности (строки одной таблицы) разносятся по разным серверам (подробнее описали уже здесь). Таблицы, разбиваемые на шарды, выбираются по принципу «самые часто посещаемые», т. е. те, к которым идет очень много запросов (не важно, какого типа).

В нашем случае были выбраны несколько основных таблиц, которые мы решили распластать горизонтально между серверами. Расскажу на примере таблицы пользователей, которых ожидается у нас несколько миллионов. Итак, у нас есть таблица пользователей (tbUser), разнесенная по нескольким серверам, и для каждого запрашиваемого пользователя нам теперь необходимо так же знать, на каком сервере он находится. Существует множество решений такой ситуации, когда предлагается, например, брать остаток от деления UID на число серверов и т. п. Но в таком случае возникает проблема при добавлении новых серверов и нужно писать дополнительные скрипты переноса пользователей, блокирующие таблицу или еще что-то. Поэтому для определения сервера, на котором хранится пользователь, мы выбрали вариант стороннего хранилища данных типа UID=>SERVER_ID, а в качестве самого хранилища взяли Redis.

Настройка Rediska

Для взаимодействия с Redis'ом установили библиотеку Rediska. Интегрировать ее с Yii не составляет труда — необходимо скачать библиотеку, расположить ее, например, в /protected/vendor/rediska и обязательно прописать в заголовке Rediska.php следующее:

<?php

spl_autoload_unregister(array('YiiBase','autoload'));

require_once dirname(__FILE__) . '/Rediska/Autoloader.php';
Rediska_Autoloader::register();

spl_autoload_register(array('YiiBase', 'autoload'));

class Rediska extends Rediska_Options


Данное решение на время отключает autoload Yii и включает Редисковский (иначе просто автолоады законфликтуют).

В директории /protected/components/ создали свой компонент, использующий данную библиотеку:

<?php

require_once dirname(__FILE__).'/../vendor/rediska/Rediska.php';

class RediskaConnection
{
        public $options = array();
        
        private $_rediska;
        
        
        public function init()
        {
                $this->_rediska = new Rediska($this->options);                
        }
        
        
        public function getConnection()
        {
                return $this->_rediska;         
        }
}


… и подключили его в main.php

'RediskaConnection'=>array(
                    'class'=>'application.components.RediskaConnection',
                    'options'=>array(
                        'servers' => array(
                            'server1' => array(
                                'host'=>'192.168.0.131',
                                'port'=>'6379',
                                'timeout'=>'3', // in seconds
                                'readTimeout'=>'3', // in seconds
                            ),
                        ),
                        'serializerAdapter'=>'json',     
                    ),
                ), 



Все настройки Rediska описаны на оф. Сайте. После конфигурирования нового компонента, его достаточно проверить get/set'ами

Yii::app()->RediskaConnection->getConnection()->set('key', 'value');
echo Yii::app()->RediskaConnection->getConnection()->get('key');


Подготовка баз данных

Когда все мелкие заморочки с Redis закончились, мы перешли к созданию парочки MySQL инстансов с одноименными базами данных:

		'shard1' => array(
			'class' => 'CDbConnection',
			'connectionString' => 'mysql:host=192.168.0.131;dbname=dbshard',
			'username' => 'dbuser',
			'password' => 'dbpass',
			'autoConnect' => false,
			'charset' => 'utf8',
		),
		'shard2' => array(
			'class' => 'CDbConnection',
			'connectionString' => 'mysql:host=192.168.0.61;dbname=dbshard',
			'username' => 'dbuser',
			'password' => 'dbpass',
			'autoConnect' => false,
			'charset' => 'utf8',
		),


Программная реализация шардинга

На этих базах мы расположили только таблицы, подвергающиеся шардингу (в т.ч. tbUser), дабы самим потом не запутаться. Теперь оставалось продумать механизм распределения данных по полученным серверам. Для этого решено было создать класс CShardedActiveRecord, который по сути расширяет CActiveRecord и переопределяет нужные нам методы. Принцип работы заключается в следующем: мы переопределили beforeSave(), который теперь определяет сервер для сохранения новой записи (тот же остаток от деления UID на SERVER_QUANTITY) и сохраняет посчитанное значение в Redis, а id записи для сохранения формируется при помощи инкрементного счетчика, реализованного при помощи Redis increment() (это позволяет иметь сквозную нумерацию id во всех базах); переопределили findByPk(), который теперь еще и получает номер сервера, по которому будет происходить выборка. Весь код приведен ниже:

<?php

/**
 * Sharded active record
 */
class CShardedActiveRecord extends CActiveRecord
{
	/**
	 * Used in find by PK and 
	 * @var integer
	 */
	private $_pk = null;
	
	/**
	 * Used connection
	 * @var CDbConnection
	 */
	private $_connection = null;
	
	
	/**
	 * @see db/ar/CActiveRecord#getDbConnection()
	 */
	public function getDbConnection()
	{
		
		if (!is_null($this->_connection)) 
			return $this->_connection;
		if (is_null($this->_pk)) {
			$serverName = Yii::app()->params->servers['serverNames'][0];
		} else { 
			$serverId = $this->getServerId($this->_pk);
			$serverName =empty(Yii::app()->params->servers['serverNames'][$serverId]) ? 
				Yii::app()->params->servers['serverNames'][0] 
				: Yii::app()->params->servers['serverNames'][$serverId];
		}
		$this->_connection = Yii::app()->{$serverName};
		return $this->_connection;
	}
	
	private function removeConnection()
	{
		$this->_connection = null;
	}
	
	private function getRedisKey($key)
	{
		return $this->tableName() . '_' . $key;
	}
	
	/**
	 * @return server id or false, for null $pk
	 */
	private function getServerId($pk)
	{
		if (is_null($pk))
			return false;
		$serverId = Yii::app()->RediskaConnection->getConnection()->get($this->getRedisKey($pk));
		return $serverId;
	}
	
	public function findByPk($pk, $condition = '', $params = array())
	{
		if (!is_integer($pk))
			throw new Exception ('primary key must be integer');
		$this->_pk = $pk;
		$this->removeConnection();
		return parent::findByPk($pk, $condition, $params);
	}
	
	/**
	 * Set unique id for new record
	 * @return boolean
	 */
	protected function beforeSave()
	{
		if (!parent::beforeSave())
			return false;
		if ($this->getIsNewRecord()) {
			$key = $this->tableName().'_counter';
			$this->id = $this->_pk = Yii::app()->RediskaConnection->getConnection()->increment($key);
			$serverId = $this->id % Yii::app()->params->servers['serverCount'];
			$result = Yii::app()->RediskaConnection->getConnection()->set($this->getRedisKey($this->id), $serverId);
			$this->removeConnection();
		}
		return true;
	}
}


Итог

Таким образом был получен собственный ActiveRecord для шардинг-таблиц, и теперь нам оставалось только создать модели, наследующие функционал уже не от CActiveRecord, а от CShardedActiveRecord. Переопределенных методов нам хватает, т.к. выборки из базы у нас происходят в большинстве своем по PK, а поиск по базе реализован при помощи Sphinx (для которого мы даже написали свой DataProvider, дабы можно было использовать интегрированные в Yii виджеты, работающие только с DataProvider). Далее, путем нехитрых вставок и выборок в таблицу мы протестировали работу нашего компонента и остались довольны результатом своей работы.
Tags:
Hubs:
Total votes 56: ↑53 and ↓3 +50
Views 20K
Comments 35
Comments Comments 35