wtorek, 31 grudnia 2013

Wartość NULL w bazach danych

Pracując z bazami danych, każdy z nas spotkał się kiedyś z wartością NULL, która reprezentuje tak zwaną wartość nieznaną. Posiada ona specjalne wymagania przy
  • złączeniach
  • funkcjach agregujących
  • operatorach grupowania

Warto zapoznać się głębiej z tymi cechami bo czasami mogą nas zaskoczyć.

Operator porównywania dla wartości NULL

exmaples=# \pset null 'NULL'
Null display is "NULL".
exmaples=# select 
 1 = NULL AS "1 = NULL", 
 true OR NULL as "true OR NULL", 
 true AND NULL as "true AND NULL", 
 false OR NULL as "false OR NULL", 
 false AND NULL as "false AND NULL", 
 NULL = NULL as "NULL = NULL", 
 NULL IS NULL as "NULL IS NULL";

-[ RECORD 1 ]--+-----
1 = NULL       | NULL
true OR NULL   | t
true AND NULL  | NULL
false OR NULL  | NULL
false AND NULL | f
NULL = NULL    | NULL
NULL IS NULL   | t

  • Wyrażenie A = B mówi, że A "equal to" B. Porównanie wartości A do NULL wraca NULL nawet w przypadku gdy porównujemy dwie wartości NULL (NULL jest wartością NIEZNANĄ!)
  • Operator IS jako argument przyjmuje wartości logiczne. Wartość logiczna z NULL odpowiada wartości nieznanej. Dlatego zwraca zawsze wartość boolean: true lub false

Wyszukiwanie wartości NULL i NOT NULL

Dla celów testowych stworzyłam tabelę o nazwie test_null, która przyjmuje dla niektórych wierszy w kolumnie a i b, wartości NULL. Poniżej zobaczycie podstawowe zapytania wyszukiwania wartości NULL.
exmaples=# select * from test_null;
  a   |  b   | c 
------+------+---
 NULL |    1 | f
    1 |    2 | f
 NULL |    3 | f
    2 |    3 | f
 NULL | NULL | f
    3 |    2 | t
   39 |    3 | t
   39 | NULL | t
   37 |    2 | t
(9 rows)

1. Warunek NULL na kolumnie a
exmaples=# select * from test_null where a is NULL;
  a   |  b   | c 
------+------+---
 NULL |    1 | f
 NULL |    3 | f
 NULL | NULL | f
(3 rows)

2. Warunek NOT NULL na wszystkich wierszach:
exmaples=# select t.* from test_null t where t.* is NOT NULL;
 a  | b | c 
----+---+---
  1 | 2 | f
  2 | 3 | f
  3 | 2 | t
 39 | 3 | t
 37 | 2 | t
(5 rows)

Polecenie SELECT z warunkiem IN i NOT IN

Dla powyższej tabeli chcielibyśmy wyciągnąć wiersze gdzie b jest równe 2 lub jest wartością NULL. Kiedy jednak wykonujemy poniższe zapytanie za pomocą operatora IN, wynik może nas zaskoczyć.
Zmieniam warunek zapytania i chcę jednak dostać wszystkie wiersze, gdzie kolumna b nie przyjmuje wartości 1 i 3. Wynik jest taki sam.
exmaples=# select * from test_null where b IN (2,NULL);
 a  | b | c 
----+---+---
  1 | 2 | f
  3 | 2 | t
 37 | 2 | t
(3 rows)

exmaples=# select * from test_null where b NOT IN (1,3);
 a  | b | c 
----+---+---
  1 | 2 | f
  3 | 2 | t
 37 | 2 | t
(3 rows)
Jeśli jednak w warunku NOT IN pojawi się NULL, nie otrzymujemy już żadnych wierszy:
exmaples=# select * from test_null where b NOT IN (2,NULL);
 a | b | c 
---+---+---
(0 rows)

A IN (B) jest traktowane jako A =ANY B, a A NOT IN B jako NOT(A IN B).

Jednak A=ANY B wraca false jeśli A=b dla wszystkich b, które są w zbiorze B, a ponieważ A=NULL zwraca wartość nieznaną a nie false, to NOT IN nie może zwrócić wartości true.

Aby zabezpieczyć się przed takim scenariuszem, a nie możemy zmienić konstrukcji zapytania, musimy dodać warunek IS NOT NULL w podzapytaniu. (Pamiętajmy o tym gdy w warunku IN podstawiamy subselect)

Funkcje agregujące i grupowanie

Dla tej samem tabeli testowej wyciągniemy podstawowe informację o ilości wierszy i sumie wartości kolumny b:
exmaples=# select count(*), count(b), sum(b) from test_null;
 count | count | sum 
-------+-------+-----
     9 |     7 |  16
(1 row)
Grupowanie
exmaples=# select sum(a), b from test_null group by b having sum(a) < 40;
 sum |  b   
-----+------
  39 | NULL
(1 row)
Wartości NULL nie są brane pod uwagę dla działania funkcji agregujących. Warunek Having działa tak samo jak dla operatory porównywania.

Warto wiedzieć o specyficznych zachowaniach wartości NULL gdy mamy podejrzenie, że zwracany wynik naszego zapytania SELECT może być nieprawidłowy.
Powodzenia :-)

piątek, 27 grudnia 2013

Perl-owy skrypt do znalezienia rożnic miedzy dwoma mysqlowymi bazami

Jakiś czas temu stanęłam przed zadaniem znalezienia różnic między dwoma bazami danych, między którymi niegdyś działała replikacja master-master. Aby wyłapać wszystkie różnice, napisała mały skrypt perl-owy, którego zadaniem w pierwszej kolejności będzie sprawdzenie schematu bazy danych (tabel i kolumn), a następnie analizować wiersze w tabelach.
Główne ciało skryptu: 

#!/usr/bin/perl -w
# sudo perl -MCPAN -e 'install Date::Calc'

use strict;
use warnings;
use English;
use DBI;
use Date::Simple;
use List::MoreUtils qw/ any /;
use List::Compare;

# disabling output buffering

$| = 1;

##########################################
# DATABASE connection 
########################################## 
#$dbConnConditionFilePath = $ARGV[0] || "-";


our $server01Host = '127.0.0.1'; 
our $server01Port = '3306';
our $server01User = 'root'; 
our $server01Pass = 'root'; 
our $server01Instance = 'database01'; 


our $server02Host = '127.0.0.1'; 
our $server02Port = '3306'; 
our $server02User = 'root'; 
our $server02Pass = 'root'; 
our $server02Instance = 'database02'; 


our $checkDataForMaxRows = 1000; 
our $showFullDiffreneces = 1; 


our $server01Dbh = DBI->connect("DBI:mysql:dbname=$server01Instance;host=$server01Host;port=$server01Port", $server01User, $server01Pass, {'RaiseError' => 1});
our $server02Dbh = DBI->connect("DBI:mysql:dbname=$server02Instance;host=$server02Host;port=$server02Port", $server02User, $server02Pass, {'RaiseError' => 1}); 

our %serversData = (
'server01' => [$server01Instance, $server01Dbh],
'server02' => [$server02Instance, $server02Dbh]
);
our @tablesToVerify = ();
@tablesToVerify = dbSchemaVerification(); 
dbTablesVerification(@tablesToVerify); 
dbCheckValues(@tablesToVerify); 
$server01Dbh->disconnect(); 
$server02Dbh->disconnect();
exit;



Sprawdzenie schematu bazy danych i opcji tabel następuje poprzez sprawdzenie/wyciągnięcie informacji z tabeli information_schema.tables. Sprawdzane są następujące informację:
  • nazwa tabeli 
  • engine 
  • collation 
  • row_format
Zapytanie:
SELECT 
 TABLE_NAME, 
 TABLE_TYPE, 
 coalesce(ENGINE,''), 
 coalesce(ROW_FORMAT,''), 
 coalesce(TABLE_COLLATION,'') 
from 
 information_schema.tables 
where 
 TABLE_SCHEMA = '$dbName' 
order by TABLE_NAME ASC;

# 
# Get informationa about all tables from information_schema.tables
#
sub dbSchemaVerification {
 print "######################################\n";
 print "Table Schema verification: START\n";
 my %tables = (
  'server01' => {}, 
  'server02' => {}
 );
 my @allUniqueTablesOnServers=();
 my @tableToCheck = ();
 
 
 while( my ($serverName,@serverData) = each(%serversData)) {
  my $dbName = $serverData[0][0];
  my $dbDbh = $serverData[0][1];
  my $query = "SELECT TABLE_NAME, TABLE_TYPE, coalesce(ENGINE,''), coalesce(ROW_FORMAT,''), coalesce(TABLE_COLLATION,'') from information_schema.tables where TABLE_SCHEMA = '$dbName' order by TABLE_NAME ASC;";
  
  my $statement = $dbDbh->prepare($query);
  $statement->execute();
  my $statement_ref = $statement->fetchall_arrayref();
  foreach my $row (@$statement_ref) {
   my ($tableName, $tableType, $engine, $rowFormat, $tableCollation) = @$row;
   $tables{$serverName}{$tableName} = ([$tableName, $tableType, $engine, $rowFormat, $tableCollation]);
   if (!(any { $_ eq $tableName} @allUniqueTablesOnServers)) {
    push(@allUniqueTablesOnServers, $tableName);
   } 
  }
 }

 if(length $tables{'server01'} != length $tables{'server02'}) {
  print "There is diffrent between database schemas ! \n";
 }
 
 # check tables with them options
 for my $i (0..$#allUniqueTablesOnServers) {
  my $tableName = $allUniqueTablesOnServers[$i];
  
  # check server01
  my @server01ForTable = @{$tables{'server01'}{$tableName}};
  
  # check server02
  my @server02ForTable = @{$tables{'server02'}{$tableName}};
  
  if(!@server01ForTable) {
   print "ERROR: Table $tableName desn't exist on server01!\n";
  } elsif(!@server01ForTable) {
   print "ERROR: Table $tableName desn't exist on server02!\n";
  } else {
   my $tablePropertisServer01 = join(", ", @server01ForTable);
   #print "$tablePropertisServer01\n";
   my $tablePropertisServer02 = join(", ", @server02ForTable);
   if($tablePropertisServer01 ne $tablePropertisServer02) {
    print "ERROR: Check table schemas on servers, difference was detected:\n";
    print "Compared data: TABLE_NAME, TABLE_TYPE, ENGINE, ROW_FORMAT, TABLE_COLLATION\n";
    print "Server 01: $tablePropertisServer01\n";
    print "Server 02: $tablePropertisServer02\n";
   } else {
    print "Check for $tableName: OK! \n";
   }
   
   # store information about tables to check columns
   push(@tableToCheck, $tableName);
  }
 }
 print "Table Schema verification: DONE\n";
 #print join(",", @tableToCheck)."\n";
 return @tableToCheck;
}

W następnej funkcji sprawdzane są opcje kolumn wszystkich tabel. Te informacje są trzymane w tabeli: information_schema.columns.
Aby wyciągnąć informację na temat kolumn tabeli, wykonuję następujące zapytanie:

SELECT 
  COLUMN_NAME, 
  ORDINAL_POSITION, 
  coalesce(COLUMN_DEFAULT,''), 
  IS_NULLABLE, 
  DATA_TYPE, 
  coalesce(CHARACTER_MAXIMUM_LENGTH, ''), 
  COLUMN_TYPE, 
  COLUMN_KEY, 
   EXTRA 
 from information_schema.columns 
 where TABLE_SCHEMA = '$dbName' AND TABLE_NAME = '$tableName' 
 order by ORDINAL_POSITION ASC;

Dzięki temu będę wiedziała między innymi:
  • czy kolumny są ustawione na tych samym pozycjach
  • typy kolumn są takie same
  • czy moją zawierać wartości null-owe
#
# check if columns are the same with the same options
#
sub dbTablesVerification {
 my @tableList = @_;
 
 print "######################################\n";
 print "Columns Schema verification: START\n";
 
 my %columns = (
  'server01' => {}, 
  'server02' => {}
 );
 
 for my $i (0..$#tableList) {
  my $tableName = $tableList[$i];
  while( my ($serverName,@serverData) = each(%serversData)) {
   my $dbName = $serverData[0][0];
   my $dbDbh = $serverData[0][1];
  
   my $query = "SELECT COLUMN_NAME, ORDINAL_POSITION, coalesce(COLUMN_DEFAULT,''), IS_NULLABLE, DATA_TYPE, coalesce(CHARACTER_MAXIMUM_LENGTH, ''), COLUMN_TYPE, COLUMN_KEY, EXTRA ".
   "from information_schema.columns ".
   "where TABLE_SCHEMA = '$dbName' AND TABLE_NAME = '$tableName' ".
   "order by ORDINAL_POSITION ASC;";
  
   my $statement = $dbDbh->prepare($query);
   $statement->execute();
   my $statement_ref = $statement->fetchall_arrayref();
   foreach my $row (@$statement_ref) {
    my ($columnName, $ordinalPosition, $columnDefault, $isNullable, $dataType, $character, $columnType, $columnKey, $extra) = @$row;
    $columns{$serverName}{$tableName}{$columnName} = ($columnName." | ".$ordinalPosition." | ".$columnDefault." | ".$isNullable." | ".$dataType." | ".$character." | ".$columnType." | ".$columnKey." | ".$extra);
   }
  }
  my $tableVeryficationServer01 = "";
  foreach my $key (keys %{$columns{'server01'}{$tableName}}) {
   $tableVeryficationServer01 .= $columns{'server01'}{$tableName}{$key};
  }
  my $tableVeryficationServer02 = "";
  foreach my $key (keys %{$columns{'server02'}{$tableName}}) {
   $tableVeryficationServer02 .= $columns{'server02'}{$tableName}{$key};
  }
  if($tableVeryficationServer01 ne $tableVeryficationServer02) {
   print "ERROR: The difference from $tableName was detected:\n";
   print "COLUMN_NAME | ORDINAL_POSITION | COLUMN_DEFAULT | IS_NULLABLE | DATA_TYPE | CHARACTER_MAXIMUM_LENGTH | COLUMN_TYPE | COLUMN_KEY | EXTRA\n";
   print "Server 01:\n";
   foreach my $key (keys %{$columns{'server01'}{$tableName}}) {
    print $columns{'server01'}{$tableName}{$key}."\n";
   }
   print "\nServer 02:\n";
   foreach my $key (keys %{$columns{'server02'}{$tableName}}) {
    print $columns{'server02'}{$tableName}{$key}."\n";
   }
   print "\n";
  } else {
   print "CHECK for $tableName: OK! \n";
  }
 }
 
 print "Columns Schema verification: DONE\n";
}


Ostatnią rzeczą jest sprawdzenie wartości w wierszach. Jeśli w tabelach przechowujemy bardzo dużą ilość danych, weryfikacja może być bardzo obciążająca dla bazy. Dlatego warto założyć jakieś ograniczenia. Ja przyjęłam, że to w ostatnich rekordach można znaleźć różnice. Dlatego przed analizą danych w tabelach, wyciągnęłam klucze główne dla każdej z nich przy pomocy zapytania:
SELECT 
  table_name, 
  COLUMN_NAME 
FROM information_schema.columns 
WHERE 
 TABLE_SCHEMA = '$dbName' and COLUMN_KEY = 'PRI';

Dodatkowo najlepiej jest porównywać ze sobą jak najmniejszą ilość danych dlatego cała zawartość wiersza zapisuje jako wartość funkcji MD5. W ten sposób ograniczy się przesyłanie zbędnej ilości danych pomiędzy serwerem a klientem.

Dla każdej tabeli powtarzam następujące kroki:
  1. Sprawdzam ilość rekordów 
  2. Wyciągam ostatnie 1000 wierszy (ta wartość jest konfigurowana na początku skryptu) w postaci pary: klucz główny i MD5-ka ze wszystkich danych w kolumnach. Każda wartość z każdej kolumny jest sprawdzana przez funkcję COALESCE na wypadek gdyby zawierała wartość null (MD5 z null daje null). 
  3. Porównuje różnice pomiędzy wynikami drugiego kroku obydwu baz. Dodatkowo na wyjściu otrzymam także plik z danymi, dla każdej tabeli osobno, jeśli różnice istnieją oczywiście. 

sub dbCheckValues {
 my @tableList = @_;
 my %tablesWithPrimaryKeyColumns = ();
 
 print "######################################\n";
 print "Data value verification: START\n";
 
 # get pxrimary_key of table from first server
 my @server01ConnData = @{$serversData{'server01'}};
 my $server01Conn = $server01ConnData[1];
 my @server02ConnData = @{$serversData{'server02'}};
 my $server02Conn = $server02ConnData[1];
 
 my $dbName = $server01ConnData[0];
 my $query = "select table_name, COLUMN_NAME from information_schema.columns where TABLE_SCHEMA = '$dbName' and COLUMN_KEY = 'PRI';";
 my $statement = $server01Conn->prepare($query);
 $statement->execute();
 my $statement_ref = $statement->fetchall_arrayref();
 foreach my $row (@$statement_ref) {
  my ($tableName, $columnName) = @$row;
  $tablesWithPrimaryKeyColumns{$tableName} = $columnName;
 }
 
 # estimate how many rows can be in table
 # if more then 500 - check only a news rows
 # if less then 500 - check all rows
 for my $i (0..$#tableList) {
  print "\n#####################################################\n";
  my $tableName = $tableList[$i];
  print "TABLE NAME: $tableName \n";
  my $columnName = $tablesWithPrimaryKeyColumns{$tableName};
  my $query = "";
  if($columnName) {
   $query = "select count(*) from (select * from $tableName order by $columnName desc limit $checkDataForMaxRows) foo;";
  } else {
   $query = "select count(*) from (select * from $tableName limit $checkDataForMaxRows) foo;";
  }
  my $server01Count = runCountQueryOnConn($query, $server01Conn);
  my $server02Count = runCountQueryOnConn($query, $server02Conn);
  
  my @columnsToSelectArray = getArrayBySql("select column_name from information_schema.columns where table_name = '$tableName' AND TABLE_SCHEMA='$dbName'",$server01Conn);
  for my $c (0..$#columnsToSelectArray) {
   $columnsToSelectArray[$c] = "COALESCE(".$columnsToSelectArray[$c].",'')";
  }
  my $columnsToSelect = join(",", @columnsToSelectArray);
  $query = "SELECT concat($columnName,'-',md5(CONCAT($columnsToSelect))) as c FROM $tableName ORDER BY $columnName DESC";
  
  if($server01Count != $server02Count) {
   print "ERROR: Detected a difference in the number of rows in the table: $tableName: Server 01: $server01Count rows, Server 02: $server02Count rows\n";
   $query .= "";
  }elsif( ($server01Count == $server02Count) && ($server01Count > $checkDataForMaxRows) ) {
   print "WARNINGS: In the table $tableName is more then $checkDataForMaxRows rows\n";
   $query .= " LIMIT $checkDataForMaxRows";
  }elsif( ($server01Count == $server02Count) && ($server01Count <= $checkDataForMaxRows) ) 

{
   $query .= "";
  }
  #print $query."\n";
  my @dataServer01 = getArrayBySql($query, $server01Conn);
  my @dataServer02 = getArrayBySql($query, $server02Conn);
  
  my $lc = List::Compare->new(\@dataServer01, \@dataServer02);
  my @recordIdsWithDiff = ();
  my @server01Only = $lc->get_unique;
  my @server02Only = $lc->get_complement;
  my @LorRonly = $lc->get_symmetric_difference;
  if(scalar(@server01Only) > 0) {
   print "------------------------------------------------------\n";
   print "Element which exists in server01 but not in server02: \n";
   for my $e (@server01Only) {
    my $rowIdValue = substr($e, 0, index($e, '-'));
    if (!(any { $_ eq $rowIdValue} @recordIdsWithDiff)) {
     push(@recordIdsWithDiff, $rowIdValue);
    }
   }
   my $recordIdsWithDiffStr = join(",", @recordIdsWithDiff);
   print "Column name: $columnName, values: $recordIdsWithDiffStr\n";
  }
  @recordIdsWithDiff = ();
  if(scalar(@server02Only) > 0) {
   print "------------------------------------------------------\n";
   print "Element which exists in server02 but not in server01: \n";
   for my $e (@server02Only) {
    my $rowIdValue = substr($e, 0, index($e, '-'));
    if (!(any { $_ eq $rowIdValue} @recordIdsWithDiff)) {
     push(@recordIdsWithDiff, $rowIdValue);
    }
   }
   my $recordIdsWithDiffStr = join(",", @recordIdsWithDiff);
   print "Column name: $columnName, values: $recordIdsWithDiffStr\n";
  }
  @recordIdsWithDiff = ();
  if(scalar(@LorRonly) > 0) {
   print "------------------------------------------------------\n";
   print "Rows which appear at least once in either the first or the second list, but not both: \n";
   for my $e (@LorRonly) {
    my $rowIdValue = substr($e, 0, index($e, '-'));
    if (!(any { $_ eq $rowIdValue} @recordIdsWithDiff)) {
     push(@recordIdsWithDiff, $rowIdValue);
    }
   }
   my $recordIdsWithDiffStr = join(",", @recordIdsWithDiff);
   print "Column name: $columnName, values: $recordIdsWithDiffStr\n";

     my $csvFileNameS01 = "$server01Instance"."_s01_for_$tableName.csv";
     my $csvFileNameS02 = "$server02Instance"."_s02_for_$tableName.csv";
     print "GENERATE CSV file with differences. Files: $csvFileNameS01 and $csvFileNameS02\n";
     $query = "SELECT * FROM $tableName WHERE $columnName IN \($recordIdsWithDiffStr\)";
     #print $query."\n";
     system("echo '$query' |mysql -h$server01Host -p$server01Pass -u$server01User -P$server01Port -D$server01Instance > $csvFileNameS01");
     system("echo '$query' |mysql -h$server02Host -p$server02Pass -u$server02User -P$server02Port -D$server02Instance > $csvFileNameS02");
  }
  
 }
 
 print "Data value verification: DONE\n";
}

Przykładowy wynik działania skryptu:

∴ perl mysql_replication_check.pl 
######################################
Table Schema verification: START
Check for t1: OK! 
Table Schema verification: DONE
######################################
Columns Schema verification: START
CHECK for t1: OK! 
Columns Schema verification: DONE
######################################
Data value verification: START

#####################################################
TABLE NAME: t1 
ERROR: Detected a difference in the number of rows in the table: t1: Server 01: 2 rows, 

Server 02: 3 rows
------------------------------------------------------
Element which exists in server02 but not in server01: 
Column name: c1, values: 3
------------------------------------------------------
Rows which appear at least once in either the first or the second list, but not both: 
Column name: c1, values: 3
GENERATE CSV file with differences. Files: test1_s01_for_t1.csv and test2_s02_for_t1.csv
Data value verification: DONE

Skrypt wychwycił, że na serwerze 01 jest mniej wierszy niż na 02. Wszystkie różnice zostały dodatkowo przekierowane do plików:
∴ ll
total 28
drwxrwxr-x  2 ela ela  4096 Dec 23 18:23 ./
drwxr-xr-x 38 ela ela  4096 Dec 23 17:17 ../
-rw-rw-r--  1 ela ela 12133 Dec 23 18:20 mysql_replication_check.pl
-rw-rw-r--  1 ela ela     0 Dec 23 18:23 test1_s01_for_t1.csv
-rw-rw-r--  1 ela ela    38 Dec 23 18:23 test2_s02_for_t1.csv
-rw-rw-r--  1 ela ela  2009 Dec 23 18:18 test.dump


W pliku test2_s02_for_t1.csv jest zrzucony dodatkowy wiersz, którego nie ma na drugiej bazie. W ten sposób łatwo jest wykryć różnice w danych przy pomocy polecenia diff. W przypadku gdy pod tym samą wartością klucza głównego istnieją różne dane, to już my musimy zdecydować, które dane są najbardziej aktualne albo czy nie doszło do konfliktów wartości kluczy głównych. W takim przypadku wymagane będzie update kluczy głównych dla tych wierszy na jednym z serwerów, później export, a następnie import do drugiej bazy danych.


Skrypt zawiera jeszcze dwie funkcje pomocnicze, które używane są w powyższych funkcjach: 
sub runCountQueryOnConn {
  my $sql = shift;
  my $connection = shift;
  
  my ($result) = $connection->selectrow_array($sql);
  return $result;
}

sub getArrayBySql {
  my $sql = shift;
  my $connection = shift;
  my @result = ();
  
  #print $sql."\n";
  my $statement = $connection->prepare($sql);
  $statement->execute();
  my $statement_ref = $statement->fetchall_arrayref();
  foreach my $row (@$statement_ref) {
    my ($r) = @$row;
    push(@result, $r);
  }
  return @result;
}


To na pewno ułatwi znalezienie/wyrównanie danych pomiędzy dwoma bazami danych.



poniedziałek, 23 grudnia 2013

Migracja projektu bazodanowego z PostgreSQL'a do MySQL'a


Decydując się na przejście z PostgreSQL do MySQL trzeba pamiętać o wielu rzeczach. Nie wystarczy dostosować schematu bazy danych, bo jak już nasza aplikacja zacznie działać, możemy się niemiło zaskoczyć.

Dlaczego decydujemy się przenieść naszą bazę danych?

MySQL posiada jedną ogromna przewagę nad PostgreSQL: replikacja (od wersji 9.0 już tylko master-master). Był to jedyny powód, dla którego podjęta została decyzja o migracji projektu. W poniższej wyszczególniłam jedne z najważniejszych cech bazodanowych, dla których podejmowane były ważne decyzje/kroki:

  1. Replikacja
    • MySQL: master-master, master-slave
    • PostgreSQL: master-slave (od v.9.2)
  2. Typ engine'u:
    • MySQL: InnoDB dla transakcyjność, MyISAM i wiele innych
    • PostgreSQL: -
  3. Milisekundy w timestampach:
    • MySQL: nie, w timestamp-ach przechowywane są tylko informacje co do sekundy
    • PostgreSQL: tak
  4. Znaki specjalne:
    • MySQL: od wersji 5.5 dostępny jest charset utf8mb4, który przechowuje znaki w 4 bajtach na znak.
    • PostgreSQL: tak

Zmiany bazodanowe

Migracja schematu jest pracą łatwą ale czasochłonną. Aby pomóc sobie w tej pracy można się posłużyć aplikacjami windowsowymi, które dla wybranych tabel wykonają tą pracę za nas, ale dobrze wiedzieć jak zrobić to samemu.

Definicja tabelki w PostgreSQL:

CREATE TABLE a (
    id integer NOT NULL,
    "timestamp" timestamp without time zone NOT NULL,
    n character varying(255) NOT NULL,
    p character varying(255) NOT NULL,
    pp smallint,
    s character varying(255) NOT NULL,
    ss integer,
    deleted boolean,
    r character varying(255),
    e character varying(20),
    dcs character varying(5)
);


ma swój odpowiednik w MySQL-u:

CREATE TABLE `a` (
  `id` int(11) NOT NULL,
  `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `n` varchar(255) NOT NULL,
  `p` varchar(255) NOT NULL,
  `pp` smallint(6) DEFAULT NULL,
  `s` varchar(255) NOT NULL,
  `ss` int(11) DEFAULT NULL,
  `deleted` tinyint(1) DEFAULT NULL,
  `r` varchar(255) DEFAULT NULL,
  `e` varchar(20) DEFAULT NULL,
  `dcs` varchar(5) DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;


Warto pamiętać, że:
  • timestamp domyślnie jest NOT NULL (http://dev.mysql.com/doc/refman/5.0/en)/timestamp-initialization.html). Musisz sprecyzować czy kolumna może przyjmować wartości NULL-owe i jaka jest wartość domyślna.
mysql (dbu@test)> create table test (c1 integer primary key, c2 varchar(255), c3 timestamp, c4 timestamp, c5 timestamp, c6 timestamp null default null);
Query OK, 0 rows affected (0.22 sec)

mysql (dbu@test)> show create table test \G
*************************** 1. row ***************************
       Table: test
Create Table: CREATE TABLE `test` (
  `c1` int(11) NOT NULL,
  `c2` varchar(255) DEFAULT NULL,
  `c3` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `c4` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
  `c5` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
  `c6` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`c1`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.00 sec)
  •  zakres danych dla timestamp'a jest inny. W MySQL minimalna wartość to '1970-01-19 00:00:01' UTC a maksymalną '2038-01-19 03:14:07' UTC. Dla PostgreSQL minimalną wartością jest '4714-01-01 BC', a maksymalną: '5874897-12-31 00:00:00'
  •  w MySQL boolean, bool to synonimy do tinyint(1). Wartość 0 jest traktowana jako false, a wszystkie inne jako true
  • w MySQL'u, wzory SQL nie uwzględniają wielkości liter
mysql (dbu@test)> select * from test;
+----+-------+---------------------+
| c1 | c2    | c3                  
|+----+-------+---------------------+
|  1 | test  | 2013-12-23 14:37:21 |
|  2 |  test | 2013-12-23 14:37:27 |
|  3 | test  | 2013-12-23 14:37:33 |
|  4 | TEST  | 2013-12-23 14:37:39 |
+----+-------+---------------------+
4 rows in set (0.00 sec)

mysql (dbu@test)> select *, length(c2) from test where c2 = 'TEST';
+----+-------+---------------------+------------+
| c1 | c2    | c3                  | length(c2) |
+----+-------+---------------------+------------+
|  1 | test  | 2013-12-23 14:37:21 |          4 |
|  3 | test  | 2013-12-23 14:37:33 |          5 |
|  4 | TEST  | 2013-12-23 14:37:39 |          4 |
+----+-------+---------------------+------------+
3 rows in set (0.00 sec)

mysql (dbu@test)> select *, length(c2) from test where c2 like 'TEST';
+----+------+---------------------+------------+
| c1 | c2   | c3                  | length(c2) |
+----+------+---------------------+------------+
|  1 | test | 2013-12-23 14:37:21 |          4 |
|  4 | TEST | 2013-12-23 14:37:39 |          4 |
+----+------+---------------------+------------+
2 rows in set (0.00 sec)


Zmiany w aplikacji

Przeważnie, migrując aplikację do innego silnika bazodanowego, jesteśmy zmuszeni tylko do zmiany biblioteki klienta odpowiedzialnego za połączenia.
Dla przykładu w projektach Ruby on Rails jest to zamiana konfiguracji połączenia bazodanowego (config/database.yml) z:

 development:
  adapter: postgresql
  encoding: unicode
  database: db_name
  username: db_user_name
  password: db_user_password
  host: db_host
  port: db_port

na:
development:
  adapter: mysql
  encoding:utf8
  database: db_name
  username: db_user_name
  password: db_user_password
  host: db_host
  port: db_port


Jeśli jednak zapytania tworzyliśmy sami, wymagane jest sprawdzenie i/lub zmiana ich konstrukcji. Dobrym przykładem jest polecenie UPDATE. W PostgreSQL wykonując update rekordów tabeli, odwołując się do innej tabeli wykonujemy następująco:

UPDATE aggregated_services ags, services s
SET
         accumulated_a = (
           SELECT count(*)
           FROM sn
           WHERE s.template_id = sn.template_id
             AND s.template_info_id = sn.template_info_id
             AND sn.appeared_at < ags.end_time
         ),
        ags.recalculate_failed_optins = false
      WHERE s.id = ags.service_id
        AND ags.end_time = '2013-01-01'
        AND ags.recalculate_failed_optins
;

Natomiast w MySQL to samo zapytanie będzie wyglądało tak:
UPDATE aggregated_services
SET
         accumulated_a = (
           SELECT count(*)
           FROM sn
           WHERE s.template_id = sn.template_id
             AND s.template_info_id = sn.template_info_id
             AND sn.appeared_at < aggregated_services.end_time
),
recalculate_failed_optins = false
      FROM  services s
      WHERE s.id = aggregated_services.service_id
        AND end_time = '2013-01-01'
        AND recalculate_failed_optins
;


Dobrą praktyką jest przetestowanie naszej aplikacji pod kątem wydajności. Zapytania, które dobrze działały w PostgreSQL, nie koniecznie będą dobrze działać pod MySQL. Na przykład zapytanie, które liczy unikalność wierszy w kilku kolumnach z warunkami:

SELECT count(*) AS count FROM (
  SELECT DISTINCT templateid, templateinfoid, msgstate, gw_out_time 
  FROM event
) as event 
WHERE msgstate NOT IN (-4,-5,-6) 
  AND gw_out_time >= '2013-01-01' 
  AND gw_out_time < '2013-01-02' 
  AND templateid = 1
  AND templateinfoid = 10;

Jak się okazuje, PostgreSQL w pierwszej kolejności wybiera dane, a później selekcjonuje unikalność i robi agregatę.

explain SELECT count(*) AS count FROM (SELECT DISTINCT templateid,templateinfoid,msgstate, gw_out_time FROM event) as event WHERE msgstate NOT IN (-4,-5,-6) AND gw_out_time >= '2013-01-01' AND gw_out_time < '2013-01-02' AND templateid = 1 AND templateinfoid = 10;
                                                                                                           QUERY PLAN                                                                                                          
(cost=31974.24..31974.25 rows=1 width=0)
   ->  Unique  (cost=31744.01..31859.13 rows=9209 width=28)
         ->  Sort  (cost=31744.01..31767.04 rows=9209 width=28)
               Sort Key: public.event.templateid, public.event.templateinfoid, public.event.msgstate, public.event.gw_out_time
               ->  Index Scan using idx_event_1_tid_tinfoid_outtime on event  (cost=0.00..31137.65 rows=9209 width=28)
                     Index Cond: ((templateinfoid = 10) AND (templateid = 1) AND (gw_out_time >= '2013-01-01 00:00:00'::timestamp without time zone) AND (gw_out_time < '2013-01-02 00:00:00'::timestamp without time zone))
                     Filter: (msgstate <> ALL ('{-4,-5,-6}'::integer[]))
(7 rows)


Dla tego samego zapytania w MySQL wygląda to zupełnie inaczej, bo w pierwszej kolejności wykonywany jest podselect (SELECT DISTINCT templateid, templateinfoid, msgstate, gw_out_time FROM event). Jeśli tabela jest bardzo duża, na wynik explain-a możemy się nie doczekać (bo aby zobaczyć wynik explain-a, MySQL musi wykonać zapytanie). Aby jednak otrzymać wynik z MySQL trzeba przenieść warunki wyszukiwania do podzapytania tak aby zapytanie główne wykonywało tylko agregatę:

mysql> explain SELECT count(*) AS count FROM (SELECT DISTINCT templateid,templateinfoid,msgstate, gw_out_time FROM event WHERE msgstate NOT IN (-4,-5,-6) AND gw_out_time >= '2013-01-01' AND gw_out_time < '2013-01-02' AND templateid = 1 AND templateinfoid = 10) event \G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: NULL
         type: NULL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: NULL
        Extra: Select tables optimized away
*************************** 2. row ***************************
           id: 2
  select_type: DERIVED
        table: event
         type: range
possible_keys: idx_event_gw_out_time,idx_event_tid_tinfoid_outtime
          key: idx_event_tid_tinfoid_outtime
      key_len: 23
          ref: NULL
         rows: 20970
        Extra: Using where; Using temporary
2 rows in set (0.06 sec)

Trudność w realizacji tego zadania jest mocno uzależniona od złożoności naszego projektu. Warto przetestować go na realnych danych aby upewnić się, że wszystkie prace optymalizacyjne zostały zakończone sukcesem.

Powodzenia