¿Debo usar UUID e ID?

11

He estado usando UUID en mis sistemas desde hace un tiempo por una variedad de razones que van desde el registro hasta la correlación retrasada. Los formatos que utilicé cambiaron a medida que me volví menos ingenuo de:

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

Fue cuando llegué al último BINARY(16)cuando comencé a comparar el rendimiento con un entero de incremento automático básico. La prueba y los resultados se muestran a continuación, pero si solo desea el resumen, lo indica INT AUTOINCREMENTy BINARY(16) RANDOMtiene un rendimiento idéntico en rangos de datos de hasta 200,000 (la base de datos se rellenó previamente antes de las pruebas).

Inicialmente era escéptico sobre el uso de UUID como claves principales, y de hecho todavía lo soy, sin embargo, veo potencial aquí para crear una base de datos flexible que pueda usar ambos. Mientras que muchas personas enfatizan las ventajas de cualquiera de ellos, ¿cuáles son las desventajas canceladas al usar ambos tipos de datos?

  • PRIMARY INT
  • UNIQUE BINARY(16)

El caso de uso para este tipo de configuración sería la clave primaria tradicional para las relaciones entre tablas, con un identificador único utilizado para las relaciones entre sistemas.

Lo que esencialmente intento descubrir es la diferencia de eficiencia entre los dos enfoques. Además del cuádruple espacio en disco utilizado, que puede ser en gran medida insignificante después de agregar datos adicionales, me parecen iguales.

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

Insertar punto de referencia:

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

Seleccionar punto de referencia:

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

Pruebas:

$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
Flosculo
fuente

Respuestas:

10

Los UUID son un desastre de rendimiento para tablas muy grandes. (200K filas no son "muy grandes").

Tu # 3 es realmente malo cuando CHARCTER SETes utf8: ¡ CHAR(36)ocupa 108 bytes! Actualización: Hay ROW_FORMATspara que esto se quedará 36.

Los UUID (GUID) son muy "aleatorios". Usarlos como clave ÚNICA o PRIMARIA en tablas grandes es muy ineficiente. Esto se debe a que tiene que saltar alrededor de la tabla / índice cada vez que tiene INSERTun nuevo UUID o SELECTpor UUID. Cuando la tabla / índice es demasiado grande para caber en la memoria caché (vea innodb_buffer_pool_size, que debe ser más pequeña que la RAM, típicamente 70%), el 'siguiente' UUID no se puede almacenar en caché, por lo tanto, un golpe de disco lento. Cuando la tabla / índice es 20 veces más grande que la memoria caché, solo se almacena en caché 1/20 (5%) de los aciertos: está vinculado a E / S. Generalización: la ineficiencia se aplica a cualquier acceso "aleatorio": UUID / MD5 / RAND () / etc.

Por lo tanto, no use UUID a menos que

  • tienes mesas "pequeñas", o
  • realmente los necesita porque genera identificadores únicos desde diferentes lugares (y no ha descubierto otra forma de hacerlo).

Más sobre UUID: http://mysql.rjweb.org/doc.php/uuid (Incluye funciones para convertir entre 36-char estándar UUIDsy BINARY(16).) Actualización: MySQL 8.0 tiene una función incorporada para tal.

Tener un UNICO AUTO_INCREMENTy un UNIQUEUUID en la misma tabla es un desperdicio.

  • Cuando se INSERTproduce una , todas las claves únicas / primarias deben verificarse en busca de duplicados.
  • Cualquiera de las claves únicas es suficiente para el requisito de InnoDB de tener un PRIMARY KEY.
  • BINARY(16) (16 bytes) es algo voluminoso (un argumento en contra de convertirlo en PK), pero no está tan mal.
  • El volumen es importante cuando tienes claves secundarias. InnoDB pega silenciosamente la PK al final de cada clave secundaria. La lección principal aquí es minimizar el número de claves secundarias, especialmente para tablas muy grandes. Elaboración: para una clave secundaria, el debate sobre el volumen generalmente termina en un empate. Para 2 o más claves secundarias, una PK más gruesa generalmente conduce a una huella de disco más grande para la tabla, incluidos sus índices.

A modo de comparación: INT UNSIGNEDes de 4 bytes con un rango de 0..4 mil millones. BIGINTes de 8 bytes.

Las actualizaciones en cursiva / etc se agregaron en septiembre de 2017; Nada crítico cambió.

Rick James
fuente
Gracias por su respuesta, era menos consciente de la pérdida de optimización de caché. Estaba menos preocupado por las claves foráneas voluminosas, pero veo cómo eventualmente se convertiría en un problema. Sin embargo, soy reacio a eliminar su uso por completo, ya que son muy útiles para la interacción entre sistemas. BINARY(16)Creo que ambos estamos de acuerdo en que es la forma más eficiente de almacenar un UUID, pero con respecto al UNIQUEíndice, ¿debería simplemente usar un índice regular? Los bytes se generan utilizando RNG criptográficamente seguros, por lo que ¿dependeré completamente de la aleatoriedad y renunciaré a las verificaciones?
Flosculus
Un índice no único ayudaría a mejorar el rendimiento, pero incluso un índice regular debe actualizarse eventualmente. ¿Cuál es el tamaño de su mesa proyectada? ¿Eventualmente será demasiado grande para almacenar en caché? Un valor sugerido para innodb_buffer_pool_sizees el 70% de la memoria RAM disponible.
Rick James
Su base de datos de 1,2 GB después de 2 meses, la tabla más grande es de 300 MB, pero los datos nunca desaparecerán, así que por mucho tiempo que dure, tal vez 10 años. Por supuesto, menos de la mitad de las tablas necesitarán UUID, por lo que las eliminaré de los casos de uso más superficiales. Lo que deja el que los necesitará actualmente en 50,000 filas y 250MB, o 30 - 100 GB en 10 años.
Flosculus
2
En 10 años, no podrá comprar una máquina con solo 100 GB de RAM. Siempre cabe en la RAM, por lo que mis comentarios probablemente no se apliquen a su caso.
Rick James
1
@a_horse_with_no_name: en versiones anteriores, siempre era 3x. Solo las versiones más nuevas se volvieron inteligentes al respecto. Quizás eso fue 5.1.24; probablemente sea lo suficientemente mayor para que me olvide de eso.
Rick James
2

'Rick James' dijo en respuesta aceptada: "Tener un AUTO_INCREMENTO ÚNICO y un UUID ÚNICO en la misma tabla es un desperdicio". Pero esta prueba (la hice en mi máquina) muestra hechos diferentes.

Por ejemplo: con la prueba (T2) hago una tabla con (INT AUTOINCREMENT) PRIMARY and UNIQUE BINARY (16) y otro campo como título, luego inserto más de 1.6M filas con muy buen rendimiento, pero con otra prueba (T3) Hice lo mismo pero el resultado es lento después de insertar solo 300,000 filas.

Este es el resultado de mi prueba:

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

Por lo tanto, binary (16) UNIQUE con incremento automático int_id es mejor que binary (16) UNIQUE sin incremento automático int_id.

Actualizar:

Hago la misma prueba nuevamente y grabo más detalles. Este es el código completo y la comparación de resultados entre (T2) y (T3) como se explicó anteriormente.

(T2) crear 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) crear 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 es un código de prueba completo, está insertando 600,000 registros en tbl2 o 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

El 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.

El 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.
usuario2241289
fuente
2
Explique cómo su respuesta es más que simplemente ejecutar su punto de referencia en su máquina personal. Idealmente, una respuesta discutiría algunas de las compensaciones involucradas en lugar de solo resultados de referencia.
Erik
1
Algunas aclaraciones, por favor. ¿Qué fue innodb_buffer_pool_size? ¿De dónde vino el "tamaño de la mesa"?
Rick James
1
Vuelva a ejecutar, utilizando 1000 para el tamaño de la transacción; esto puede eliminar los extraños inconvenientes tanto en tbl2 como en tbl3. Además, imprima el tiempo después del COMMIT, no antes. Esto puede eliminar algunas otras anomalías.
Rick James
1
No estoy familiarizado con el lenguaje que está utilizando, pero yo no veo cómo los diferentes valores de @rec_idy @src_idestán siendo generados y aplicados a cada fila. Imprimir un par de INSERTdeclaraciones podría satisfacerme.
Rick James
1
Además, sigue pasando 600K. En algún momento (parcialmente dependiente de cuán grande sea rec_title), t2también caerá por un precipicio. Incluso puede ir más lento que t3; No estoy seguro. Su punto de referencia está en un "agujero de rosquilla" donde t3es temporalmente más lento
Rick James