Dois-je utiliser l'UUID ainsi que l'ID

11

J'utilise les UUID dans mes systèmes depuis un certain temps maintenant pour diverses raisons allant de la journalisation à la corrélation retardée. Les formats que j'ai utilisés ont changé à mesure que je devenais moins naïf:

  1. VARCHAR(255)
  2. VARCHAR(36)
  3. CHAR(36)
  4. BINARY(16)

C'est lorsque j'ai atteint le dernier BINARY(16)que j'ai commencé à comparer les performances avec un entier à incrémentation automatique de base. Le test et les résultats sont présentés ci-dessous, mais si vous voulez juste le résumé, il indique que INT AUTOINCREMENTet BINARY(16) RANDOMont des performances identiques sur des plages de données allant jusqu'à 200 000 (la base de données a été pré-remplie avant les tests).

J'étais initialement sceptique quant à l'utilisation des UUID comme clés primaires, et je le suis toujours, mais je vois un potentiel ici pour créer une base de données flexible qui peut utiliser les deux. Alors que de nombreuses personnes soulignent les avantages de l'une ou l'autre, quels sont les inconvénients annulés en utilisant les deux types de données?

  • PRIMARY INT
  • UNIQUE BINARY(16)

Le cas d'utilisation pour ce type de configuration serait la clé primaire traditionnelle pour les relations inter-tables, avec un identifiant unique utilisé pour les relations inter-systèmes.

Ce que j'essaie essentiellement de découvrir, c'est la différence d'efficacité entre les deux approches. Outre l'espace disque quadruple utilisé, qui peut être largement négligeable après l'ajout de données supplémentaires, ils me semblent être les mêmes.

Schéma:

-- phpMyAdmin SQL Dump
-- version 4.0.10deb1
-- http://www.phpmyadmin.net
--
-- Host: localhost
-- Generation Time: Sep 22, 2015 at 10:54 AM
-- Server version: 5.5.44-0ubuntu0.14.04.1
-- PHP Version: 5.5.29-1+deb.sury.org~trusty+3

SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";


/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;

--
-- Database: `test`
--

-- --------------------------------------------------------

--
-- Table structure for table `with_2id`
--

CREATE TABLE `with_2id` (
  `guidl` bigint(20) NOT NULL,
  `guidr` bigint(20) NOT NULL,
  `data` varchar(255) NOT NULL,
  PRIMARY KEY (`guidl`,`guidr`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

-- --------------------------------------------------------

--
-- Table structure for table `with_guid`
--

CREATE TABLE `with_guid` (
  `guid` binary(16) NOT NULL,
  `data` varchar(255) NOT NULL,
  PRIMARY KEY (`guid`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

-- --------------------------------------------------------

--
-- Table structure for table `with_id`
--

CREATE TABLE `with_id` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `data` varchar(255) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB  DEFAULT CHARSET=latin1 AUTO_INCREMENT=197687 ;

/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;

Insérer un repère:

function benchmark_insert(PDO $pdo, $runs)
{
    $data = 'Sample Data';

    $insert1 = $pdo->prepare("INSERT INTO with_id (data) VALUES (:data)");
    $insert1->bindParam(':data', $data);

    $insert2 = $pdo->prepare("INSERT INTO with_guid (guid, data) VALUES (:guid, :data)");
    $insert2->bindParam(':guid', $guid);
    $insert2->bindParam(':data', $data);

    $insert3 = $pdo->prepare("INSERT INTO with_2id (guidl, guidr, data) VALUES (:guidl, :guidr, :data)");
    $insert3->bindParam(':guidl', $guidl);
    $insert3->bindParam(':guidr', $guidr);
    $insert3->bindParam(':data',  $data);

    $benchmark = array();

    $time = time();
    for ($i = 0; $i < $runs; $i++) {
        $insert1->execute();
    }
    $benchmark[1] = 'INC ID:     ' . (time() - $time);

    $time = time();
    for ($i = 0; $i < $runs; $i++) {
        $guid  = openssl_random_pseudo_bytes(16);

        $insert2->execute();
    }
    $benchmark[2] = 'GUID:       ' . (time() - $time);

    $time = time();
    for ($i = 0; $i < $runs; $i++) {
        $guid  = openssl_random_pseudo_bytes(16);
        $guidl = unpack('q', substr($guid, 0, 8))[1];
        $guidr = unpack('q', substr($guid, 8, 8))[1];

        $insert3->execute();
    }
    $benchmark[3] = 'SPLIT GUID: ' . (time() - $time);

    echo 'INSERTION' . PHP_EOL;
    echo '=============================' . PHP_EOL;
    echo $benchmark[1] . PHP_EOL;
    echo $benchmark[2] . PHP_EOL;
    echo $benchmark[3] . PHP_EOL . PHP_EOL;
}

Sélectionnez une référence:

function benchmark_select(PDO $pdo, $runs) {
    $select1 = $pdo->prepare("SELECT * FROM with_id WHERE id = :id");
    $select1->bindParam(':id', $id);

    $select2 = $pdo->prepare("SELECT * FROM with_guid WHERE guid = :guid");
    $select2->bindParam(':guid', $guid);

    $select3 = $pdo->prepare("SELECT * FROM with_2id WHERE guidl = :guidl AND guidr = :guidr");
    $select3->bindParam(':guidl', $guidl);
    $select3->bindParam(':guidr', $guidr);

    $keys = array();

    for ($i = 0; $i < $runs; $i++) {
        $kguid  = openssl_random_pseudo_bytes(16);
        $kguidl = unpack('q', substr($kguid, 0, 8))[1];
        $kguidr = unpack('q', substr($kguid, 8, 8))[1];
        $kid = mt_rand(0, $runs);

        $keys[] = array(
            'guid'  => $kguid,
            'guidl' => $kguidl,
            'guidr' => $kguidr,
            'id'    => $kid
        );
    }

    $benchmark = array();

    $time = time();
    foreach ($keys as $key) {
        $id = $key['id'];
        $select1->execute();
        $row = $select1->fetch(PDO::FETCH_ASSOC);
    }
    $benchmark[1] = 'INC ID:     ' . (time() - $time);


    $time = time();
    foreach ($keys as $key) {
        $guid = $key['guid'];
        $select2->execute();
        $row = $select2->fetch(PDO::FETCH_ASSOC);
    }
    $benchmark[2] = 'GUID:       ' . (time() - $time);

    $time = time();
    foreach ($keys as $key) {
        $guidl = $key['guidl'];
        $guidr = $key['guidr'];
        $select3->execute();
        $row = $select3->fetch(PDO::FETCH_ASSOC);
    }
    $benchmark[3] = 'SPLIT GUID: ' . (time() - $time);

    echo 'SELECTION' . PHP_EOL;
    echo '=============================' . PHP_EOL;
    echo $benchmark[1] . PHP_EOL;
    echo $benchmark[2] . PHP_EOL;
    echo $benchmark[3] . PHP_EOL . PHP_EOL;
}

Tests:

$pdo = new PDO('mysql:host=localhost;dbname=test', 'root', '');

benchmark_insert($pdo, 1000);
benchmark_select($pdo, 100000);

Résultats:

INSERTION
=============================
INC ID:     3
GUID:       2
SPLIT GUID: 3

SELECTION
=============================
INC ID:     5
GUID:       5
SPLIT GUID: 6
Flosculus
la source

Réponses:

10

Les UUID sont une perte de performances pour les très grandes tables. (200 000 lignes ne sont pas «très grandes».)

Votre n ° 3 est vraiment mauvais quand il CHARCTER SETest utf8 - CHAR(36)occupe 108 octets! Mise à jour: il y en ROW_FORMATsaura 36 qui resteront.

Les UUID (GUID) sont très "aléatoires". Les utiliser comme clé UNIQUE ou PRIMARY sur de grandes tables est très inefficace. Cela est dû au fait de devoir parcourir la table / l'index chaque fois que vous INSERTutilisez un nouvel UUID ou SELECTpar UUID. Lorsque la table / l'index est trop volumineux pour tenir dans le cache (voir innodb_buffer_pool_size, qui doit être plus petit que la RAM, généralement 70%), l'UUID `` suivant '' peut ne pas être mis en cache, d'où un hit de disque lent. Lorsque la table / l'index est 20 fois plus grande que le cache, seulement 1 / 20e (5%) des accès sont mis en cache - vous êtes lié aux E / S. Généralisation: l'inefficacité s'applique à tout accès "aléatoire" - UUID / MD5 / RAND () / etc

Donc, n'utilisez pas d'UUID à moins que

  • vous avez de "petites" tables, ou
  • vous en avez vraiment besoin, car vous générez des identifiants uniques à partir de différents endroits (et vous n'avez pas trouvé d'autre moyen de le faire).

En savoir plus sur les UUID: http://mysql.rjweb.org/doc.php/uuid (Il comprend des fonctions de conversion entre le standard de 36 caractères UUIDset BINARY(16).) Mise à jour: MySQL 8.0 a une fonction intégrée pour cela.

Avoir à la fois un UNIQUE AUTO_INCREMENTet un UNIQUEUUID dans la même table est un gaspillage.

  • Lorsqu'un événement INSERTse produit, toutes les clés uniques / primaires doivent être vérifiées pour les doublons.
  • L'une ou l'autre clé unique suffit pour que InnoDB ait un PRIMARY KEY.
  • BINARY(16) (16 octets) est un peu volumineux (un argument contre le fait de le rendre PK), mais pas si mal.
  • L'encombrement est important lorsque vous avez des clés secondaires. InnoDB colle silencieusement le PK à la fin de chaque clé secondaire. La leçon principale ici est de minimiser le nombre de clés secondaires, en particulier pour les très grandes tables. Élaboration: pour une clé secondaire, le débat sur le volume se termine généralement par un match nul. Pour 2 clés secondaires ou plus, un PK plus gros entraîne généralement une plus grande empreinte de disque pour la table, y compris ses index.

Pour comparaison: INT UNSIGNEDest de 4 octets avec une plage de 0 à 4 milliards. BIGINTest de 8 octets.

Des mises à jour en italique / etc ont été ajoutées en septembre 2017; rien de critique n'a changé.

Rick James
la source
Merci pour votre réponse, j'étais moins conscient de la perte d'optimisation du cache. J'étais moins préoccupé par les clés étrangères volumineuses, mais je vois comment cela pourrait éventuellement devenir un problème. Cependant, j'hésite à supprimer complètement leur utilisation car ils s'avèrent très utiles pour l'interaction entre systèmes. BINARY(16)Je pense que nous convenons tous les deux que c'est le moyen le plus efficace de stocker un UUID, mais en ce qui concerne l' UNIQUEindex, dois-je simplement utiliser un index régulier? Les octets sont générés à l'aide de RNG cryptographiquement sécurisés, dois-je donc dépendre entièrement du caractère aléatoire et renoncer aux contrôles?
Flosculus
Un index non unique aiderait certaines performances, mais même un index régulier doit éventuellement être mis à jour. Quelle est la taille de votre table projetée? Sera-t-il finalement trop volumineux pour être mis en cache? Une valeur suggérée pour innodb_buffer_pool_size70% du bélier disponible.
Rick James
Sa base de données de 1,2 Go après 2 mois, la plus grande table est de 300 Mo, mais les données ne disparaîtront jamais, donc aussi longtemps qu'elles dureront, 10 ans peut-être. Étant donné que moins de la moitié des tables auront même besoin d'UUID, je les supprimerai donc des cas d'utilisation les plus superficiels. Ce qui laisse celui qui en aura besoin actuellement à 50 000 lignes et 250 Mo, soit 30 à 100 Go en 10 ans.
Flosculus
2
Dans 10 ans, vous ne pourrez pas acheter une machine avec seulement 100 Go de RAM. Vous aurez toujours la place dans la RAM, donc mes commentaires ne s'appliqueront probablement pas à votre cas.
Rick James
1
@a_horse_with_no_name - Dans les anciennes versions, c'était toujours 3x. Seules les versions les plus récentes ont été intelligentes à ce sujet. C'était peut-être le 5.1.24; c'est probablement assez vieux pour que je l'oublie.
Rick James
2

'Rick James' a dit dans la réponse acceptée: "Avoir à la fois un AUTO_INCREMENT UNIQUE et un UUID UNIQUE dans la même table est un gaspillage". Mais ce test (je l'ai fait sur ma machine) montre des faits différents.

Par exemple: avec le test (T2) je fais un tableau avec (INT AUTOINCREMENT) PRIMARY et UNIQUE BINARY (16) et un autre champ comme titre, puis j'insère plus de 1,6M de lignes avec de très bonnes performances, mais avec un autre test (T3) J'ai fait la même chose mais le résultat est lent après avoir inséré 300 000 lignes seulement.

Voici mon résultat de test:

T1:
char(32) UNIQUE with auto increment int_id
after: 1,600,000
10 sec for inserting 1000 rows
select + (4.0)
size:500mb

T2:
binary(16) UNIQUE with auto increment int_id
after: 1,600,000
1 sec for inserting 1000 rows
select +++ (0.4)
size:350mb

T3:
binary(16) UNIQUE without auto increment int_id
after: 350,000
5 sec for inserting 1000 rows
select ++ (0.3)
size:118mb (~ for 1,600,000 will be 530mb)

T4:
auto increment int_id without binary(16) UNIQUE
++++

T5:
uuid_short() int_id without binary(16) UNIQUE
+++++*

Donc binaire (16) UNIQUE avec incrémentation automatique int_id est meilleur que binaire (16) UNIQUE sans incrémentation automatique int_id.

Mettre à jour:

Je refais le même test et j'enregistre plus de détails. il s'agit du code complet et de la comparaison des résultats entre (T2) et (T3) comme expliqué ci-dessus.

(T2) créez tbl2 (mysql):

CREATE TABLE test.tbl2 (
  int_id INT(11) NOT NULL AUTO_INCREMENT,
  rec_id BINARY(16) NOT NULL,
  src_id BINARY(16) DEFAULT NULL,
  rec_title VARCHAR(255) DEFAULT NULL,
  PRIMARY KEY (int_id),
  INDEX IDX_tbl1_src_id (src_id),
  UNIQUE INDEX rec_id (rec_id)
)
ENGINE = INNODB
CHARACTER SET utf8
COLLATE utf8_general_ci;

(T3) créez tbl3 (mysql):

CREATE TABLE test.tbl3 (
  rec_id BINARY(16) NOT NULL,
  src_id BINARY(16) DEFAULT NULL,
  rec_title VARCHAR(255) DEFAULT NULL,
  PRIMARY KEY (rec_id),
  INDEX IDX_tbl1_src_id (src_id)
)
ENGINE = INNODB
CHARACTER SET utf8
COLLATE utf8_general_ci;

Ceci est un code de test complet, il insère 600 000 enregistrements dans tbl2 ou tbl3 (code vb.net):

Public Class Form1

    Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click
        Dim res As String = ""
        Dim i As Integer = 0
        Dim ii As Integer = 0
        Dim iii As Integer = 0

        Using cn As New SqlClient.SqlConnection
            cn.ConnectionString = "Data Source=.\sql2008;Integrated Security=True;User Instance=False;MultipleActiveResultSets=True;Initial Catalog=sourcedb;"
            cn.Open()
            Using cmd As New SqlClient.SqlCommand
                cmd.Connection = cn
                cmd.CommandTimeout = 0
                cmd.CommandText = "select recID, srcID, rectitle from textstbl order by ID ASC"

                Using dr As SqlClient.SqlDataReader = cmd.ExecuteReader

                    Using mysqlcn As New MySql.Data.MySqlClient.MySqlConnection
                        mysqlcn.ConnectionString = "User Id=root;Host=localhost;Character Set=utf8;Pwd=1111;Database=test"
                        mysqlcn.Open()

                        Using MyCommand As New MySql.Data.MySqlClient.MySqlCommand
                            MyCommand.Connection = mysqlcn

                            MyCommand.CommandText = "insert into tbl3 (rec_id, src_id, rec_title) values (UNHEX(@rec_id), UNHEX(@src_id), @rec_title);"
                            Dim MParm1(2) As MySql.Data.MySqlClient.MySqlParameter
                            MParm1(0) = New MySql.Data.MySqlClient.MySqlParameter("@rec_id", MySql.Data.MySqlClient.MySqlDbType.String)
                            MParm1(1) = New MySql.Data.MySqlClient.MySqlParameter("@src_id", MySql.Data.MySqlClient.MySqlDbType.String)
                            MParm1(2) = New MySql.Data.MySqlClient.MySqlParameter("@rec_title", MySql.Data.MySqlClient.MySqlDbType.VarChar)

                            MyCommand.Parameters.AddRange(MParm1)
                            MyCommand.CommandTimeout = 0

                            Dim mytransaction As MySql.Data.MySqlClient.MySqlTransaction = mysqlcn.BeginTransaction()
                            MyCommand.Transaction = mytransaction

                            Dim sw As New Stopwatch
                            sw.Start()

                            While dr.Read
                                MParm1(0).Value = dr.GetValue(0).ToString.Replace("-", "")
                                MParm1(1).Value = EmptyStringToNullValue(dr.GetValue(1).ToString.Replace("-", ""))
                                MParm1(2).Value = gettitle(dr.GetValue(2).ToString)

                                MyCommand.ExecuteNonQuery()

                                i += 1
                                ii += 1
                                iii += 1

                                If i >= 1000 Then
                                    i = 0

                                    Dim ts As TimeSpan = sw.Elapsed
                                    Me.Text = ii.ToString & " / " & ts.TotalSeconds

                                    Select Case ii
                                        Case 10000, 50000, 100000, 200000, 300000, 400000, 500000, 600000, 700000, 800000, 900000, 1000000
                                            res &= "On " & FormatNumber(ii, 0) & ": last inserting 1000 records take: " & ts.TotalSeconds.ToString & " second." & vbCrLf
                                    End Select

                                    If ii >= 600000 Then GoTo 100
                                    sw.Restart()
                                End If
                                If iii >= 5000 Then
                                    iii = 0

                                    mytransaction.Commit()
                                    mytransaction = mysqlcn.BeginTransaction()

                                    sw.Restart()
                                End If
                            End While
100:
                            mytransaction.Commit()

                        End Using
                    End Using
                End Using
            End Using
        End Using

        TextBox1.Text = res
        MsgBox("Ok!")
    End Sub

    Public Function EmptyStringToNullValue(MyValue As Object) As Object
        'On Error Resume Next
        If MyValue Is Nothing Then Return DBNull.Value
        If String.IsNullOrEmpty(MyValue.ToString.Trim) Then
            Return DBNull.Value
        Else
            Return MyValue
        End If
    End Function

    Private Function gettitle(p1 As String) As String
        If p1.Length > 255 Then
            Return p1.Substring(0, 255)
        Else
            Return p1
        End If
    End Function

End Class

Le résultat pour (T2):

On 10,000: last inserting 1000 records take: 0.13709 second.
On 50,000: last inserting 1000 records take: 0.1772109 second.
On 100,000: last inserting 1000 records take: 0.1291394 second.
On 200,000: last inserting 1000 records take: 0.5793488 second.
On 300,000: last inserting 1000 records take: 0.1296427 second.
On 400,000: last inserting 1000 records take: 0.6938583 second.
On 500,000: last inserting 1000 records take: 0.2317799 second.
On 600,000: last inserting 1000 records take: 0.1271072 second.

~3 Minutes ONLY! to insert 600,000 records.
table size: 128 mb.

Le résultat pour (T3):

On 10,000: last inserting 1000 records take: 0.1669595 second.
On 50,000: last inserting 1000 records take: 0.4198369 second.
On 100,000: last inserting 1000 records take: 0.1318155 second.
On 200,000: last inserting 1000 records take: 0.1979358 second.
On 300,000: last inserting 1000 records take: 1.5127482 second.
On 400,000: last inserting 1000 records take: 7.2757161 second.
On 500,000: last inserting 1000 records take: 14.3960671 second.
On 600,000: last inserting 1000 records take: 14.9412401 second.

~40 Minutes! to insert 600,000 records.
table size: 164 mb.
user2241289
la source
2
Veuillez expliquer en quoi votre réponse va au-delà de l'exécution de leur référence sur votre ordinateur personnel. Idéalement, une réponse discuterait de certains des compromis impliqués au lieu de se limiter aux résultats de référence.
Erik
1
Quelques précisions, s'il vous plaît. C'était quoi innodb_buffer_pool_size? D'où vient la "taille de la table"?
Rick James
1
Veuillez réexécuter, en utilisant 1000 pour la taille de la transaction - cela peut éliminer les hoquets étranges dans les deux tbl2 et tbl3. Imprimez également le calendrier après le COMMIT, pas avant. Cela peut éliminer certaines autres anomalies.
Rick James
1
Je ne connais pas la langue que vous utilisez, mais je vois comment différentes valeurs de @rec_idet @src_idsont générées et appliquées à chaque ligne. L'impression de quelques INSERTdéclarations pourrait me satisfaire.
Rick James
1
Continuez également à dépasser 600K. À un moment donné (en partie dépend de la taille du rec_title), t2tombera également d'une falaise. Cela peut même aller plus lentement que t3; Je ne suis pas sûr. Votre point de repère est dans un «trou de beignet» où il t3est temporairement plus lent.
Rick James