I am working on an SMS marketing project based on Yii2 Framework (PHP 7.3 & MariaDb (innodb engine)) where we have to move the logs from different tables to the archive db, which is total a different DB server from the live one.
The log tables keep growing and we had to setup an automated job which runs 3 times a week at midnight and will keep filtering out the logs that are older than 45 days and move them to another database.
I decided to use the Range partitioning and then use EXCHANGE PARTITION
to move all the related data to a separate new table so that i keep the main/live table locked for partition process only and keep the copying/moving process that involves Select
operation on a different table.
So I divided the whole process into 2 different processes.
- Partitioning
- Archiving
The partition process is further divided into
- Drop any Previously created Backup tables
- Create a new backup table like live table
- Create Partition on live table
- Exchange the partition with the backup table
- Remove the partition from the live table.
My main focus is on the partition process if it can be improved to work more efficiently and less time. Currently I have the following stats for the partition process; I am only adding for one of the large tables
Transaction History table stats
Rows : total 172,899,990 rows approx
Time to Partition : 1472.429115057 secs(24.54048525095 Mins) with total rows in the partition (12,937,902)
Exchange partition : 0.062991857528687 secs
Removed Partition : 1293.8012390137 secs.(21.56335398356167 Mins)
Transaction History Schema
CREATE TABLE `transaction_history` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`amount` decimal(19,6) NOT NULL,
`description` varchar(150) COLLATE utf8_unicode_ci DEFAULT NULL,
`transaction_type` varchar(30) COLLATE utf8_unicode_ci NOT NULL DEFAULT 'blast',
`remaining_balance` decimal(19,6) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
PRIMARY KEY (`id`,`created_at`),
KEY `transaction_type` (`transaction_type`),
KEY `user_id_transaction_type` (`user_id`,`transaction_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
The code is for the Yii2 console app.
<?php
namespace console\controllers;
use Yii;
use DateTime;
use Exception;
use DateInterval;
use yii\helpers\Console;
use yii\console\Controller;
use yii\helpers\ArrayHelper;
use console\controllers\traits\ArchiveTraits;
class PartitionController extends Controller
{
use ArchiveTraits;
/**
* Contains array of tables and their range column to be used for partition
* in the key=>value format like [table_name=>range_column_name]
*
* Note: before you add any new table to the below list make sure you have
* added the range column to unique index or part of the primary key
*/
const BACKUP_TABLES = [
'message_inbound' => 'created_at',
'transaction_history' => 'created_at',
'sms_api_log' => 'created_at',
'balance_history' => 'created_on',
'buy_number_logs' => 'created_at',
'destination_delivery' => 'created_at',
'message_delivery' => 'created_at',
'email_delivery_logs' => 'created_at',
'responder_keywords_logs' => 'created_at',
'sms_alert_logs' => 'created_at',
'suppression_message_logs' => 'created_at',
];
private $_date = null;
/**
* @var batch size for the
* inserts in the database
*/
const BATCH_SIZE = 10000;
/**
* @var limit for the rows to be migrated to the
* database in one iteration
*/
const MIGRATE_LIMIT = 50000;
public function actionIndex($date = null)
{
$this->_date = $date;
$this->startNotification("Partition Process started", 'partition-start');
ini_set("memory_limit", 0);
$this->stdout("Starting Partition Process.\n");
$this->startPartition();
$this->stdout("Completed Partition Process.\n");
$date = date("Y-m-d");
$this->sendSummaryReport("Partitioning Process Complete for {$date}", "partition-complete", "partition");
}
/**
* @param int $start the start timestamp
*/
public function end($start)
{
return microtime(true) - $start;
}
public function start()
{
return microtime(true);
}
/**
* Starts the partitioning process for the live DB log tables
*
* @return null
* @throws Exception
*/
protected function startPartition()
{
foreach (self::BACKUP_TABLES as $tableName => $rangeColumn) {
try {
$this->partitionNow($tableName, $rangeColumn);
$this->stdout("\n");
} catch (Exception $e) {
$this->sendExceptionEmail($tableName, $e, "Exception on Partitioning table", "partition-exception");
$this->stdout("There was an error while trying to archive the {$tableName} .\n");
$this->stdout($e->getMessage() . "\n===============\n");
$this->stdout("Continuing to archive the next table.\n");
}
}
}
/**
* Creates the backup for the specified table and the range column
* by creating a partition and then exchanging the old data
* partition with the backup table and then move the data to the
* archive database
*
* @param string $tableName the name of the table to backup data from live DB
* @param string $rangeColumn the name of the column used for the range partition
*
* @return null
*/
protected function partitionNow($tableName, $rangeColumn = 'created_at')
{
$rangeOldPartition = $this->rangeOldPartition();
$backupTableName = $this->generateBackupTableName($tableName);
$dbLive = self::_getDsnAttribute('dbname');
//drop backup table if exists
$this->dropBackupTables($tableName);
$this->stdout("Started Partitioning {$tableName}\n");
$startTime = $this->start();
try {
$sql = <<<SQL
-- create the backup table and remove partitioning from the backup table
CREATE TABLE `{$dbLive}`.{{%{$backupTableName}}} LIKE `{$dbLive}`.{{%{$tableName}}};
-- start partitioning the source table
ALTER TABLE `{$dbLive}`.{{%{$tableName}}}
PARTITION BY RANGE(UNIX_TIMESTAMP({$rangeColumn}))
(
PARTITION oldPt VALUES LESS THAN (UNIX_TIMESTAMP("{$rangeOldPartition}")),
PARTITION activePt VALUES LESS THAN (MAXVALUE)
);
SQL;
$command = Yii::$app->db->createCommand($sql);
$command->execute();
//necessary to catch exceptions or errors when
// using multiple SQL statements with createcommand
while ($command->pdoStatement->nextRowSet()) {
//leave blank do nothing
}
$this->stdout("Partitioned table in {$this->end($startTime)} secs.\n", Console::FG_GREEN);
$startTime = $this->start();
$sql = <<<SQL
-- exchange the partition with the backup table
ALTER TABLE `{$dbLive}`.{{%{$tableName}}}
EXCHANGE PARTITION oldPt WITH TABLE `{$dbLive}`.{{%{$backupTableName}}};
SQL;
$command = Yii::$app->db->createCommand($sql);
$command->execute();
$this->stdout("Completed Exchange partition {$this->end($startTime)} secs\n");
$startTime = $this->start();
$sql = <<<SQL
-- remove partition from the source table once data moved to separate table
ALTER TABLE `{$dbLive}`.{{%{$tableName}}} REMOVE PARTITIONING;
SQL;
$command = Yii::$app->db->createCommand($sql);
$command->execute();
$this->stdout("Removed Partition in {$this->end($startTime)} secs.\n");
$this->stdout("Filterd out data from live table.\n");
} catch (Exception $e) {
throw $e;
}
}
/**
* Takes the source table name ad
*
* @param $tableName
*/
protected function dropBackupTables($tableName)
{
$backupTableName = $this->generateBackupTableName($tableName);
$sql = <<<SQL
DROP TABLE IF EXISTS {{%{$backupTableName}}};
SQL;
Yii::$app->db->createCommand($sql)->execute();
}
/**
* Generates the backup table name from the source table name
*
* @param string $tableName the source table to mock the backup table from
*
* @return string $backupTableName the name of the backup table
*/
protected function generateBackupTableName($tableName)
{
$backupTableAlias = 'bkp_' . date('Ymd') . '_';
return "{$backupTableAlias}{$tableName}";
}
/**
* Returns the create table command for the given table
* @param $tableName
*/
protected function getCreateTable($tableName)
{
$data = Yii::$app->db->createCommand("show create table {{%{$tableName}}}")->queryOne();
return str_replace('CREATE TABLE', 'CREATE TABLE IF NOT EXISTS', $data['Create Table']);
}
/**
* @param $tableName
*/
protected function getPageData($tableName, $offset = 0)
{
$limit = self::MIGRATE_LIMIT;
$sql = <<<SQL
SELECT * FROM {{%{$tableName}}}
order by id
LIMIT {$offset},{$limit}
SQL;
return Yii::$app->db->createCommand($sql)->queryAll();
}
/**
* @param $tableName
*/
protected function getRowCount($tableName)
{
$sql = <<<SQL
SELECT COUNT(*) as total FROM {{%{$tableName}}}
SQL;
$data = Yii::$app->db->createCommand($sql)->queryOne();
$this->stdout("Found {$data['total']} records.");
return $data['total'];
}
/**
* Returns the columns names for a table
*
* @param string $db the database name
* @param string $tableName the table name
*
* @return mixed
*/
protected function getTableColumns($db, $tableName)
{
$sql = <<<SQL
select
COLUMN_NAME
from
information_schema.columns
where
table_schema = "{$db}" and table_name="{$tableName}"
order by
table_name,ordinal_position;
SQL;
return ArrayHelper::getColumn(
Yii::$app->db->createCommand($sql)->queryAll(),
'COLUMN_NAME'
);
}
/**
* Returns the date for the specified interval
* to backup default interval is 45 days.
*
* @return mixed
*/
protected function rangeOldPartition()
{
$date = new DateTime();
$date->sub(new DateInterval('P0M45D'));
return $date->format("Y-m-d");
}
/**
* Returns the database name after extracting the
* specific string from the Dsn property
*
* @param string $name the name of the property in dsn string.
* @param string $target the target connection of the database live|archive, default "live"
*
* @return mixed
*/
private static function _getDsnAttribute($name, $target = 'live')
{
if ($target === 'live') {
if (preg_match("/{$name}=([^;]*)/", Yii::$app->getDb()->dsn, $match)) {
return $match[1];
}
} else {
if (preg_match("/{$name}=([^;]*)/", Yii::$app->db_backup->dsn, $match)) {
return $match[1];
}
}
throw new Exception("Unable to extract the db Name");
}
}
partitionNow()
andgenerateBackupTableName()
\$\endgroup\$partitionNow()
is the first method listed in the code, and thegenerateBackupTableName()
is only generating the table name for the bakcup i have added it too \$\endgroup\$ALTER TABLE
is fast forADD PARTITION
, and may be OK for `REORGANIZE PARTITION if only one partition is involved. \$\endgroup\$