Devo usar UUID e ID

11

Estou usando UUIDs em meus sistemas há um tempo por vários motivos, desde o log até a correlação atrasada. Os formatos que usei mudaram à medida que me tornei menos ingênuo de:

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

Foi quando cheguei ao final BINARY(16)que comecei a comparar o desempenho com o número inteiro básico de auto incremento. O teste e os resultados são mostrados abaixo, mas se você deseja apenas o resumo, isso indica que o desempenho é idêntico INT AUTOINCREMENTe os BINARY(16) RANDOMdados variam de até 200.000 (o banco de dados foi preenchido previamente antes dos testes).

Inicialmente, fiquei cético em relação ao uso de UUIDs como chaves primárias e, de fato, ainda o sou, porém vejo potencial aqui para criar um banco de dados flexível que possa usar ambos. Enquanto muitas pessoas enfatizam as vantagens de qualquer uma delas, quais são as desvantagens canceladas usando os dois tipos de dados?

  • PRIMARY INT
  • UNIQUE BINARY(16)

O caso de uso desse tipo de configuração seria a chave primária tradicional para relacionamentos entre tabelas, com um identificador exclusivo usado para relacionamentos entre sistemas.

O que estou tentando essencialmente descobrir é a diferença de eficiência entre as duas abordagens. Além do espaço em disco quádruplo usado, que pode ser amplamente desprezível após a adição de dados adicionais, eles me parecem iguais.

Esquema:

-- 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 */;

Inserir referência:

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;
}

Selecionar referência:

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;
}

Testes:

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

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

Resultados:

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

SELECTION
=============================
INC ID:     5
GUID:       5
SPLIT GUID: 6
Flosculus
fonte

Respostas:

10

UUIDs são um desastre de desempenho para tabelas muito grandes. (200K linhas não são "muito grandes".)

Seu # 3 é realmente ruim quando o CHARCTER SETutf8 CHAR(36)ocupa 108 bytes! Atualização: Existem ROW_FORMATspara os quais isso permanecerá 36.

UUIDs (GUIDs) são muito "aleatórios". Usá-los como uma chave UNIQUE ou PRIMARY em tabelas grandes é muito ineficiente. Isso ocorre devido à necessidade de pular a tabela / índice sempre que INSERTum novo UUID ou SELECTpor UUID. Quando a tabela / índice é muito grande para caber no cache (consulte innodb_buffer_pool_size, que deve ser menor que a RAM, geralmente 70%), o UUID 'próximo' pode não ser armazenado em cache, portanto, um disco lento é atingido. Quando a tabela / índice é 20 vezes maior que o cache, apenas 1/20 (5%) das ocorrências são armazenadas em cache - você está vinculado à E / S. Generalização: A ineficiência se aplica a qualquer acesso "aleatório" - UUID / MD5 / RAND () / etc

Portanto, não use UUIDs, a menos que

  • você tem tabelas "pequenas" ou
  • você realmente precisa deles por causa da geração de IDs únicos de lugares diferentes (e ainda não descobriu outra maneira de fazer isso).

Mais sobre UUIDs: http://mysql.rjweb.org/doc.php/uuid (Inclui funções para a conversão entre o padrão 36-char UUIDse BINARY(16).) Atualização: O MySQL 8.0 possui uma função interna para isso.

Ter um UNIQUE AUTO_INCREMENTe um UNIQUEUUID na mesma tabela é um desperdício.

  • Quando INSERTocorre, todas as chaves exclusivas / primárias devem ser verificadas quanto a duplicatas.
  • Qualquer chave exclusiva é suficiente para o requisito do InnoDB de ter um PRIMARY KEY.
  • BINARY(16) (16 bytes) é um pouco volumoso (um argumento contra torná-lo PK), mas não é tão ruim assim.
  • O volume é importante quando você possui chaves secundárias. O InnoDB coloca a PK silenciosamente no final de cada chave secundária. A principal lição aqui é minimizar o número de chaves secundárias, especialmente para tabelas muito grandes. Elaboração: para uma chave secundária, o debate sobre volume geralmente termina em empate. Para 2 ou mais chaves secundárias, uma PK mais gorda geralmente leva a um espaço maior em disco para a tabela, incluindo seus índices.

Para comparação: INT UNSIGNEDé de 4 bytes com intervalo de 0,4 bilhões. BIGINTé de 8 bytes.

As atualizações em itálico / etc foram adicionadas em setembro de 2017; nada crítico mudou.

Rick James
fonte
Obrigado por sua resposta, eu estava menos consciente da perda de otimização de cache. Eu estava menos preocupado com chaves estrangeiras volumosas, mas vejo como isso acabaria se tornando um problema. No entanto, estou relutante em remover totalmente o uso deles, pois eles são muito úteis para a interação entre sistemas. BINARY(16)Acho que nós dois concordamos que é a maneira mais eficiente de armazenar um UUID, mas com relação ao UNIQUEíndice, devo simplesmente usar um índice regular? Os bytes são gerados usando RNGs criptograficamente seguros, então devo depender inteiramente da aleatoriedade e renunciar às verificações?
Flosculus 27/10/2015
Um índice não exclusivo ajudaria no desempenho de alguns, mas mesmo um índice regular precisará ser atualizado eventualmente. Qual é o tamanho da sua mesa projetada? Eventualmente, será muito grande para armazenar em cache? Um valor sugerido para innodb_buffer_pool_sizeé 70% da memória RAM disponível.
Rick James
Seu banco de dados é de 1,2 GB, após 2 meses, a maior tabela é de 300 MB, mas os dados nunca desaparecem, por mais que durem, talvez 10 anos. Concedido que menos da metade das tabelas precisará de UUIDs, portanto, eu as removerei dos casos de uso mais superficiais. O que deixa o que precisará deles atualmente em 50.000 linhas e 250 MB, ou 30 a 100 GB em 10 anos.
Flosculus 30/10
2
Em 10 anos, você não poderá comprar uma máquina com apenas 100 GB de RAM. Você sempre caberá na RAM, portanto meus comentários provavelmente não se aplicarão ao seu caso.
Rick James
11
@a_horse_with_no_name - Nas versões mais antigas, era sempre 3x. Apenas versões mais recentes ficaram espertas quanto a isso. Talvez fosse 5.1.24; provavelmente é velho o suficiente para eu esquecer.
Rick James
2

'Rick James' disse em resposta aceita: "Ter um AUTO_INCREMENT ÚNICO e um UUID ÚNICO na mesma tabela é um desperdício". Mas este teste (eu fiz na minha máquina) mostra fatos diferentes.

Por exemplo: com o teste (T2), faço a tabela com (INT AUTOINCREMENT) PRIMARY e UNIQUE BINARY (16) e outro campo como título, depois insiro mais de 1,6 milhão de linhas com desempenho muito bom, mas com outro teste (T3) Fiz o mesmo, mas o resultado é lento depois de inserir apenas 300.000 linhas.

Este é o meu resultado de teste:

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
+++++*

Portanto, binário (16) UNIQUE com incremento automático int_id é melhor que binário (16) UNIQUE sem incremento automático int_id.

Atualizar:

Faço o mesmo teste novamente e registro mais detalhes. essa é uma comparação completa de código e resultados entre (T2) e (T3), conforme explicado acima.

(T2) crie 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) crie 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;

Este é um código de teste completo, está inserindo 600.000 registros no tbl2 ou tbl3 (código 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

O resultado para (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.

O resultado para (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
fonte
2
Por favor, explique como sua resposta é mais do que apenas executar a referência em sua máquina pessoal. Idealmente, uma resposta discutirá algumas das compensações envolvidas, em vez de apenas resultados de referência.
Erik
11
Alguns esclarecimentos, por favor. O que foi innodb_buffer_pool_size? De onde veio o "tamanho da mesa"?
Rick James
11
Execute novamente, usando 1000 para o tamanho da transação - isso pode eliminar os soluços estranhos em tbl2 e tbl3. Além disso, imprima o tempo após COMMIT, e não antes. Isso pode eliminar algumas outras anomalias.
Rick James
11
Não estou familiarizado com o idioma que você está usando, mas vejo como diferentes valores de @rec_ide @src_idestão sendo gerados e aplicados a cada linha. Imprimir algumas INSERTdeclarações pode me satisfazer.
Rick James
11
Além disso, continue passando dos 600K. Em algum momento (parcialmente dependente de quão grande é o rec_title), t2também cairá de um penhasco. Ele pode até mesmo ir mais lento do que t3; Não tenho certeza. Sua referência está em um "buraco de rosca", onde t3é temporariamente mais lento.
Rick James