summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDamien Regad <dregad@mantisbt.org>2020-01-25 22:59:53 +0100
committerDamien Regad <dregad@mantisbt.org>2020-01-25 23:02:13 +0100
commit517c8adc141d9375079729d0cf04deb8c6840113 (patch)
treea19ec9bb07a89ded643144403bdaff220b3aad86
parent633c29fd8356b482d32a90dee1e918bdd97f54f4 (diff)
parent8a4797dc7f0bdaa35b4119a2ce49b4e1110cbda6 (diff)
downloadadodb-517c8adc141d9375079729d0cf04deb8c6840113.tar.gz
adodb-517c8adc141d9375079729d0cf04deb8c6840113.tar.bz2
adodb-517c8adc141d9375079729d0cf04deb8c6840113.zip
New Load Balancer feature
First version of this class that allows the user to do read/write splitting and load balancing across multiple connections. It can handle and load balance any number of master or slaves (multi-master or master/slave), including dealing with connection failures with minimal changes to their existing application and ADOdb itself. Of course once a transaction starts all queries "stick" to that connection until it ends, at which point load balancing/query splitting resumes again. Example usage is in the file itself. Thanks to @Mike-Benoit Fixes #111
-rw-r--r--adodb-loadbalancer.inc.php773
-rw-r--r--docs/changelog.md1
2 files changed, 774 insertions, 0 deletions
diff --git a/adodb-loadbalancer.inc.php b/adodb-loadbalancer.inc.php
new file mode 100644
index 00000000..a4e59912
--- /dev/null
+++ b/adodb-loadbalancer.inc.php
@@ -0,0 +1,773 @@
+<?php
+/**
+ * ADOdb Load Balancer
+ *
+ * ADOdbLoadBalancer is a class that allows the user to do read/write splitting
+ * and load balancing across multiple servers. It can handle and load balance
+ * any number of write capable (AKA: master) or readonly (AKA: slave) connections,
+ * including dealing with connection failures and retrying queries on a different
+ * connection instead.
+ *
+ * Released under both BSD license and Lesser GPL library license.
+ * Whenever there is any discrepancy between the two licenses,
+ * the BSD license will take precedence. See LICENSE.md.
+ *
+ * Latest version is available at https://adodb.org/
+ *
+ * @package ADOdb
+ * @version v5.21.0-dev ??-???-2016
+ * @author Mike Benoit
+ * @copyright (c) 2016 Mike Benoit and the ADOdb community
+ * @license BSD-3-Clause
+ * @license GNU Lesser General Public License (LGPL) v2.1 or later
+ * @link https://adodb.org/
+ * @since v5.21.0
+ */
+
+/**
+ * Class ADOdbLoadBalancer
+ */
+class ADOdbLoadBalancer
+{
+ /**
+ * @var bool Once a write or readonly connection is made, stick to that connection for the entire request.
+ */
+ public $enable_sticky_sessions = true;
+
+
+ /**
+ * @var bool|array All connections to each database.
+ */
+ protected $connections = false;
+
+ /**
+ * @var bool|array Just connections to the write capable database.
+ */
+ protected $connections_write = false;
+
+ /**
+ * @var bool|array Just connections to the readonly database.
+ */
+ protected $connections_readonly = false;
+
+ /**
+ * @var array Counts of all connections and their types.
+ */
+ protected $total_connections = array('all' => 0, 'write' => 0, 'readonly' => 0);
+
+ /**
+ * @var array Weights of all connections for each type.
+ */
+ protected $total_connection_weights = array('all' => 0, 'write' => 0, 'readonly' => 0);
+
+ /**
+ * @var bool When in transactions, always use this connection.
+ */
+ protected $pinned_connection_id = false;
+
+ /**
+ * @var array Last connection_id for each database type.
+ */
+ protected $last_connection_id = array('write' => false, 'readonly' => false, 'all' => false);
+
+ /**
+ * @var bool Session variables that must be maintained across all connections, ie: SET TIME ZONE.
+ */
+ protected $session_variables = false;
+
+ /**
+ * @var bool Called immediately after connecting to any DB.
+ */
+ protected $user_defined_session_init_sql = false;
+
+
+ /**
+ * Defines SQL queries that are executed each time a new database connection is established.
+ *
+ * @param $sql
+ * @return bool
+ */
+ public function setSessionInitSQL($sql)
+ {
+ $this->user_defined_session_init_sql[] = $sql;
+
+ return true;
+ }
+
+ /**
+ * Adds a new database connection to the pool, but no actual connection is made until its needed.
+ *
+ * @param $obj
+ * @return bool
+ * @throws Exception
+ */
+ public function addConnection($obj)
+ {
+ if ($obj instanceof ADOdbLoadBalancerConnection) {
+ $this->connections[] = $obj;
+ end($this->connections);
+ $i = key($this->connections);
+
+ $this->total_connections[$obj->type]++;
+ $this->total_connections['all']++;
+
+ $this->total_connection_weights[$obj->type] += abs($obj->weight);
+ $this->total_connection_weights['all'] += abs($obj->weight);
+
+ if ($obj->type == 'write') {
+ $this->connections_write[] = $i;
+ } else {
+ $this->connections_readonly[] = $i;
+ }
+
+ return true;
+ }
+
+ throw new Exception('Connection object is not an instance of ADOdbLoadBalancerConnection');
+ }
+
+ /**
+ * Removes a database connection from the pool.
+ *
+ * @param $i
+ * @return bool
+ */
+ public function removeConnection($i)
+ {
+ if (isset($this->connections[$i])) {
+ $obj = $this->connections[ $i ];
+
+ $this->total_connections[ $obj->type ]--;
+ $this->total_connections['all']--;
+
+ $this->total_connection_weights[ $obj->type ] -= abs($obj->weight);
+ $this->total_connection_weights['all'] -= abs($obj->weight);
+
+ if ($obj->type == 'write') {
+ unset($this->connections_write[array_search($i, $this->connections_write)]);
+ // Reindex array.
+ $this->connections_write = array_values($this->connections_write);
+ } else {
+ unset($this->connections_readonly[array_search($i, $this->connections_readonly)]);
+ // Reindex array.
+ $this->connections_readonly = array_values($this->connections_readonly);
+ }
+
+ // Remove any sticky connections as well.
+ if ($this->last_connection_id[$obj->type] == $i) {
+ $this->last_connection_id[$obj->type] = false;
+ }
+
+ unset($this->connections[$i]);
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns a database connection of the specified type.
+ *
+ * Takes into account the connection weight for load balancing.
+ *
+ * @param string $type Type of database connection, either: 'write' capable or 'readonly'
+ * @return bool|int|string
+ */
+ private function getConnectionByWeight($type)
+ {
+ if ($type == 'readonly') {
+ $total_weight = $this->total_connection_weights['all'];
+ } else {
+ $total_weight = $this->total_connection_weights['write'];
+ }
+
+ $i = false;
+ if (is_array($this->connections)) {
+ $n = 0;
+ $num = mt_rand(0, $total_weight);
+ foreach ($this->connections as $i => $connection_obj) {
+ if ($connection_obj->weight > 0 && ($type == 'readonly' || $connection_obj->type == 'write')) {
+ $n += $connection_obj->weight;
+ if ($n >= $num) {
+ break;
+ }
+ }
+ }
+ }
+
+ return $i;
+ }
+
+ /**
+ * Returns the proper database connection when taking into account sticky sessions and load balancing.
+ *
+ * @param $type
+ * @return bool|int|mixed|string
+ */
+ public function getLoadBalancedConnection($type)
+ {
+ if ($this->total_connections == 0) {
+ $connection_id = 0;
+ } else {
+ if ($this->enable_sticky_sessions == true && $this->last_connection_id[$type] !== false) {
+ $connection_id = $this->last_connection_id[$type];
+ } else {
+ if ($type == 'write' && $this->total_connections['write'] == 1) {
+ $connection_id = $this->connections_write[0];
+ } else {
+ $connection_id = $this->getConnectionByWeight($type);
+ }
+ }
+ }
+
+ return $connection_id;
+ }
+
+ /**
+ * Returns the ADODB connection object by connection_id.
+ *
+ * Ensures that it's connected and the session variables are executed.
+ *
+ * @param $connection_id
+ * @return bool|ADOConnection
+ * @throws Exception
+ */
+ private function _getConnection($connection_id)
+ {
+ if (isset($this->connections[$connection_id])) {
+ $connection_obj = $this->connections[$connection_id];
+ /** @var ADOConnection $adodb_obj */
+ $adodb_obj = $connection_obj->getADOdbObject();
+ if (is_object($adodb_obj) && $adodb_obj->_connectionID == false) {
+ try {
+ if ($connection_obj->persistent_connection == true) {
+ $adodb_obj->Pconnect(
+ $connection_obj->host,
+ $connection_obj->user,
+ $connection_obj->password,
+ $connection_obj->database
+ );
+ } else {
+ $adodb_obj->Connect(
+ $connection_obj->host,
+ $connection_obj->user,
+ $connection_obj->password,
+ $connection_obj->database
+ );
+ }
+ } catch (Exception $e) {
+ // Connection error, see if there are other connections to try still.
+ throw $e; // No connections left, reThrow exception so application can catch it.
+ }
+
+ if (is_array($this->user_defined_session_init_sql)) {
+ foreach ($this->user_defined_session_init_sql as $session_init_sql) {
+ $adodb_obj->Execute($session_init_sql);
+ }
+ }
+ $this->executeSessionVariables($adodb_obj);
+ }
+
+ return $adodb_obj;
+ } else {
+ throw new Exception('Unable to return Connection object...');
+ }
+ }
+
+ /**
+ * Returns the ADODB connection object by database type.
+ *
+ * Ensures that it's connected and the session variables are executed.
+ *
+ * @param string $type
+ * @param null $pin_connection
+ * @return ADOConnection|bool
+ * @throws Exception
+ */
+ public function getConnection($type = 'write', $pin_connection = null)
+ {
+ while (($type == 'write' && $this->total_connections['write'] > 0)
+ || ($type == 'readonly' && $this->total_connections['all'] > 0)
+ ) {
+ if ($this->pinned_connection_id !== false) {
+ $connection_id = $this->pinned_connection_id;
+ } else {
+ $connection_id = $this->getLoadBalancedConnection($type);
+ }
+
+ if ($connection_id !== false) {
+ try {
+ $adodb_obj = $this->_getConnection($connection_id);
+ // $connection_obj = $this->connections[$connection_id];
+ break;
+ } catch (Exception $e) {
+ // Connection error, see if there are other connections to try still.
+ $this->removeConnection($connection_id);
+ if ( ($type == 'write' && $this->total_connections['write'] == 0)
+ || ($type == 'readonly' && $this->total_connections['all'] == 0)
+ ) {
+ throw $e;
+ }
+ }
+ } else {
+ throw new Exception('Connection ID is invalid!');
+ }
+ }
+
+ $this->last_connection_id[$type] = $connection_id;
+
+ if ($pin_connection === true) {
+ $this->pinned_connection_id = $connection_id;
+ } elseif ($pin_connection === false && $adodb_obj->transOff <= 1) {
+ // UnPin connection only if we are 1 level deep in a transaction.
+ $this->pinned_connection_id = false;
+
+ // When unpinning connection, reset last_connection_id so readonly
+ // queries don't get stuck on the write capable connection.
+ $this->last_connection_id['write'] = false;
+ $this->last_connection_id['readonly'] = false;
+ }
+
+ return $adodb_obj;
+ }
+
+ /**
+ * This is a hack to work around pass by reference error.
+ *
+ * Parameter 1 to ADOConnection::GetInsertSQL() expected to be a reference,
+ * value given in adodb-loadbalancer.inc.php on line 83
+ *
+ * @param $arr
+ * @return array
+ */
+ private function makeValuesReferenced($arr)
+ {
+ $refs = array();
+
+ foreach ($arr as $key => $value) {
+ $refs[$key] = &$arr[$key];
+ }
+
+ return $refs;
+ }
+
+ /**
+ * Allow setting session variables that are maintained across connections.
+ *
+ * Its important that these are set using name/value, so it can determine
+ * if the same variable is set multiple times causing bloat/clutter when
+ * new connections are established. For example if the time_zone is set to
+ * many different ones through the course of a single connection, a new
+ * connection should only set it to the most recent value.
+ *
+ * @param $name
+ * @param $value
+ * @param bool $execute_immediately
+ * @return array|bool|mixed
+ * @throws Exception
+ */
+ public function setSessionVariable($name, $value, $execute_immediately = true)
+ {
+ $this->session_variables[$name] = $value;
+
+ if ($execute_immediately == true) {
+ return $this->executeSessionVariables();
+ } else {
+ return true;
+ }
+ }
+
+ /**
+ * Executes the session variables on a given ADODB object.
+ *
+ * @param ADOConnection|bool $adodb_obj
+ * @return array|bool|mixed
+ * @throws Exception
+ */
+ private function executeSessionVariables($adodb_obj = false)
+ {
+ if (is_array($this->session_variables)) {
+ $sql = '';
+ foreach ($this->session_variables as $name => $value) {
+ // $sql .= 'SET SESSION '. $name .' '. $value;
+ // MySQL uses: SET SESSION foo_bar='foo'
+ // PGSQL uses: SET SESSION foo_bar 'foo'
+ // So leave it up to the user to pass the proper value with '=' if needed.
+ // This may be a candidate to move into ADOdb proper.
+ $sql .= 'SET SESSION ' . $name . ' ' . $value;
+ }
+
+ if ($adodb_obj !== false) {
+ return $adodb_obj->Execute($sql);
+ } else {
+ return $this->ClusterExecute($sql);
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Executes the same SQL QUERY on the entire cluster of connections.
+ * Would be used for things like SET SESSION TIME ZONE calls and such.
+ *
+ * @param $sql
+ * @param bool $inputarr
+ * @param bool $return_all_results
+ * @param bool $existing_connections_only
+ * @return array|bool|mixed
+ * @throws Exception
+ */
+ public function clusterExecute(
+ $sql,
+ $inputarr = false,
+ $return_all_results = false,
+ $existing_connections_only = true
+ ) {
+ if (is_array($this->connections) && count($this->connections) > 0) {
+ foreach ($this->connections as $key => $connection_obj) {
+ if ($existing_connections_only == false
+ || ($existing_connections_only == true
+ && $connection_obj->getADOdbObject()->_connectionID !== false
+ )
+ ) {
+ $adodb_obj = $this->_getConnection($key);
+ if (is_object($adodb_obj)) {
+ $result_arr[] = $adodb_obj->Execute($sql, $inputarr);
+ }
+ }
+ }
+
+ if (isset($result_arr) && $return_all_results == true) {
+ return $result_arr;
+ } else {
+ // Loop through all results checking to see if they match, if they do return the first one
+ // otherwise return an array of all results.
+ if (isset($result_arr)) {
+ foreach ($result_arr as $result) {
+ if ($result == false) {
+ return $result_arr;
+ }
+ }
+
+ return $result_arr[0];
+ } else {
+ // When using lazy connections, there are cases where
+ // setSessionVariable() is called early on, but there are
+ // no connections to execute the queries on yet.
+ // This captures that case and forces a RETURN TRUE to occur.
+ // As likely the queries will be executed as soon as a
+ // connection is established.
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Determines if a SQL query is read-only or not.
+ *
+ * @param string $sql SQL Query to test.
+ * @return bool
+ */
+ public function isReadOnlyQuery($sql)
+ {
+ if ( stripos($sql, 'SELECT') === 0
+ && stripos($sql, 'FOR UPDATE') === false
+ && stripos($sql, ' INTO ') === false
+ && stripos($sql, 'LOCK IN') === false
+ ) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Use this instead of __call() as it significantly reduces the overhead of call_user_func_array().
+ *
+ * @param $sql
+ * @param bool $inputarr
+ * @return array|bool|mixed
+ * @throws Exception
+ */
+ public function execute($sql, $inputarr = false)
+ {
+ $type = 'write';
+ $pin_connection = null;
+
+ // Prevent leading spaces from causing isReadOnlyQuery/stripos from failing.
+ $sql = trim($sql);
+
+ // SELECT queries that can write and therefore must be run on a write capable connection.
+ // SELECT ... FOR UPDATE;
+ // SELECT ... INTO ...
+ // SELECT .. LOCK IN ... (MYSQL)
+ if ($this->isReadOnlyQuery($sql) == true) {
+ $type = 'readonly';
+ } elseif (stripos($sql, 'SET') === 0) {
+ // SET SQL statements should likely use setSessionVariable() instead,
+ // so state is properly maintained across connections, especially when they are lazily created.
+ return $this->ClusterExecute($sql, $inputarr);
+ }
+
+ $adodb_obj = $this->getConnection($type, $pin_connection);
+ if ($adodb_obj !== false) {
+ return $adodb_obj->Execute($sql, $inputarr);
+ }
+
+ return false;
+ }
+
+ /**
+ * Magic method to intercept method and callback to the proper ADODB object for write/readonly connections.
+ *
+ * @param string $method ADODB method to call.
+ * @param array $args Arguments to the ADODB method.
+ * @return bool|mixed
+ * @throws Exception
+ */
+ public function __call($method, $args)
+ {
+ $type = 'write';
+ $pin_connection = null;
+
+ // Intercept specific methods to determine if they are read-only or not.
+ $method = strtolower($method);
+ switch ($method) {
+ // case 'execute': // This is the direct overloaded function above instead.
+ case 'getone':
+ case 'getrow':
+ case 'getall':
+ case 'getcol':
+ case 'getassoc':
+ case 'selectlimit':
+ if ($this->isReadOnlyQuery(trim($args[0])) == true) {
+ $type = 'readonly';
+ }
+ break;
+ case 'cachegetone':
+ case 'cachegetrow':
+ case 'cachegetall':
+ case 'cachegetcol':
+ case 'cachegetassoc':
+ case 'cacheexecute':
+ case 'cacheselect':
+ case 'pageexecute':
+ case 'cachepageexecute':
+ $type = 'readonly';
+ break;
+ // case 'ignoreerrors':
+ // // When ignoreerrors is called, PIN to the connection until its called again.
+ // if (!isset($args[0]) || (isset($args[0]) && $args[0] == FALSE)) {
+ // $pin_connection = TRUE;
+ // } else {
+ // $pin_connection = FALSE;
+ // }
+ // break;
+
+ // Manual transactions
+ case 'begintrans':
+ case 'settransactionmode':
+ $pin_connection = true;
+ break;
+ case 'rollbacktrans':
+ case 'committrans':
+ $pin_connection = false;
+ break;
+ // Smart transactions
+ case 'starttrans':
+ $pin_connection = true;
+ break;
+ case 'completetrans':
+ case 'failtrans':
+ // getConnection() will only unpin the transaction if we're exiting the last nested transaction
+ $pin_connection = false;
+ break;
+
+ // Functions that don't require any connection and therefore
+ // shouldn't force a connection be established before they run.
+ case 'qstr':
+ case 'escape':
+ case 'binddate':
+ case 'bindtimestamp':
+ case 'setfetchmode':
+ $type = false; // No connection necessary.
+ break;
+
+ // Default to assuming write connection is required to be on the safe side.
+ default:
+ break;
+ }
+
+ if ($type === false) {
+ if (is_array($this->connections) && count($this->connections) > 0) {
+ foreach ($this->connections as $key => $connection_obj) {
+ $adodb_obj = $connection_obj->getADOdbObject();
+ return call_user_func_array(array($adodb_obj, $method), $this->makeValuesReferenced($args)); // Just makes the function call on the first object.
+ }
+ }
+ } else {
+ $adodb_obj = $this->getConnection($type, $pin_connection);
+ if (is_object($adodb_obj)) {
+ $result = call_user_func_array(array($adodb_obj, $method), $this->makeValuesReferenced($args));
+
+ return $result;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Magic method to proxy property getter calls back to the proper ADODB object currently in use.
+ *
+ * @param $property
+ * @return mixed
+ * @throws Exception
+ */
+ public function __get($property)
+ {
+ if (is_array($this->connections) && count($this->connections) > 0) {
+ foreach ($this->connections as $key => $connection_obj) {
+ // Just returns the property from the first object.
+ return $connection_obj->getADOdbObject()->$property;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Magic method to proxy property setter calls back to the proper ADODB object currently in use.
+ *
+ * @param $property
+ * @param $value
+ * @return mixed
+ * @throws Exception
+ */
+ public function __set($property, $value)
+ {
+ // Special function to set object properties on all objects
+ // without initiating a connection to the database.
+ if (is_array($this->connections) && count($this->connections) > 0) {
+ foreach ($this->connections as $key => $connection_obj) {
+ $connection_obj->getADOdbObject()->$property = $value;
+ }
+
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Override the __clone() magic method.
+ */
+ private function __clone()
+ {
+ }
+}
+
+/**
+ * Class ADOdbLoadBalancerConnection
+ */
+class ADOdbLoadBalancerConnection
+{
+ /**
+ * @var bool ADOdb drive name.
+ */
+ protected $driver = false;
+
+ /**
+ * @var bool ADODB object.
+ */
+ protected $adodb_obj = false;
+
+ /**
+ * @var string Type of connection, either 'write' capable or 'readonly'
+ */
+ public $type = 'write';
+
+ /**
+ * @var int Weight of connection, lower receives less queries, higher receives more queries.
+ */
+ public $weight = 1;
+
+ /**
+ * @var bool Determines if the connection persistent.
+ */
+ public $persistent_connection = false;
+
+ /**
+ * @var string Database connection host
+ */
+ public $host = '';
+
+ /**
+ * @var string Database connection user
+ */
+ public $user = '';
+
+ /**
+ * @var string Database connection password
+ */
+ public $password = '';
+
+ /**
+ * @var string Database connection database name
+ */
+ public $database = '';
+
+ /**
+ * ADOdbLoadBalancerConnection constructor to setup the ADODB object.
+ *
+ * @param $driver
+ * @param string $type
+ * @param int $weight
+ * @param bool $persistent_connection
+ * @param string $argHostname
+ * @param string $argUsername
+ * @param string $argPassword
+ * @param string $argDatabaseName
+ */
+ public function __construct(
+ $driver,
+ $type = 'write',
+ $weight = 1,
+ $persistent_connection = false,
+ $argHostname = '',
+ $argUsername = '',
+ $argPassword = '',
+ $argDatabaseName = ''
+ ) {
+ if ($type !== 'write' && $type !== 'readonly') {
+ return false;
+ }
+
+ $this->adodb_obj = ADONewConnection($driver);
+
+ $this->type = $type;
+ $this->weight = $weight;
+ $this->persistent_connection = $persistent_connection;
+
+ $this->host = $argHostname;
+ $this->user = $argUsername;
+ $this->password = $argPassword;
+ $this->database = $argDatabaseName;
+
+ return true;
+ }
+
+ /**
+ * Returns the ADODB object for this connection.
+ *
+ * @return bool
+ */
+ public function getADOdbObject()
+ {
+ return $this->adodb_obj;
+ }
+}
diff --git a/docs/changelog.md b/docs/changelog.md
index ff4f24ee..3a0c4166 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -97,6 +97,7 @@ Older changelogs:
- xml: support table 'opt' attribute with mysqli. #267
- xml: add support for 'DESCR' tags for tables/fields. #265
- xml: fix invalid xmlschema03.dtd and descr tag in session schema XML. #595
+- loadbalancer (new feature): read/write splitting and load balancing across multiple connections, thanks to Mike Benoit. #111
## 5.20.16 - 12-Jan-2020