Czy powinienem używać UUID, jak i identyfikatora

11

Od jakiegoś czasu używam UUID w moich systemach z różnych powodów, od rejestrowania po opóźnioną korelację. Formaty, których użyłem, zmieniły się, kiedy stałem się mniej naiwny z:

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

Kiedy dotarłem do ostatniego BINARY(16), zacząłem porównywać wydajność z podstawową liczbą całkowitą automatycznego przyrostu. Test i wyniki pokazano poniżej, ale jeśli chcesz tylko podsumowania, oznacza to INT AUTOINCREMENTi BINARY(16) RANDOMma identyczną wydajność w zakresach danych do 200 000 (baza danych została wstępnie wypełniona przed testami).

Początkowo byłem sceptycznie nastawiony do używania identyfikatorów UUID jako kluczy podstawowych i nadal tak jest, ale widzę tutaj potencjał do stworzenia elastycznej bazy danych, która mogłaby używać obu. Podczas gdy wiele osób kładzie nacisk na zalety obu tych metod, jakie wady eliminuje się przy użyciu obu typów danych?

  • PRIMARY INT
  • UNIQUE BINARY(16)

Przypadkiem użycia dla tego typu konfiguracji byłby tradycyjny klucz podstawowy dla relacji między tabelami, z unikalnym identyfikatorem używanym dla relacji między systemami.

Zasadniczo próbuję odkryć różnicę w wydajności między tymi dwoma podejściami. Oprócz czterokrotnie użytego miejsca na dysku, które po dodaniu dodatkowych danych mogą być w znacznym stopniu nieistotne, wydaje mi się, że są takie same.

Schemat:

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

Wstaw test porównawczy:

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

Wybierz test:

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

Testy:

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

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

Wyniki:

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

SELECTION
=============================
INC ID:     5
GUID:       5
SPLIT GUID: 6
Flosculus
źródło

Odpowiedzi:

10

Identyfikatory UUID są katastrofą dla bardzo dużych tabel. (200 000 wierszy nie jest „bardzo duże”).

Twoje # 3 jest naprawdę złe, gdy CHARCTER SETjest utf8 - CHAR(36)zajmuje 108 bajtów! Aktualizacja: istnieją, ROW_FORMATsdla których pozostanie 36.

UUID (GUID) są bardzo „losowe”. Używanie ich jako UNIKALNYCH lub PODSTAWOWYCH kluczy w dużych tabelach jest bardzo nieefektywne. Wynika to z konieczności przeskakiwania wokół tabeli / indeksu za każdym razem, gdy masz INSERTnowy UUID lub SELECTwedług UUID. Gdy tabela / indeks jest zbyt duży, aby zmieścić się w pamięci podręcznej (patrz innodb_buffer_pool_size, która musi być mniejsza niż pamięć RAM, zwykle 70%), „następny” identyfikator UUID może nie zostać zbuforowany, stąd powolne uderzenie dysku. Gdy tabela / indeks jest 20 razy większa niż pamięć podręczna, tylko 1/20 (5%) trafień jest buforowanych - jesteś związany z operacjami we / wy. Uogólnienie: Nieefektywność dotyczy każdego „losowego” dostępu - UUID / MD5 / RAND () / itp

Dlatego nie używaj identyfikatorów UUID, chyba że albo

  • masz „małe” stoły lub
  • naprawdę potrzebujesz ich ze względu na generowanie unikalnych identyfikatorów z różnych miejsc (i nie wymyśliłeś innego sposobu na zrobienie tego).

Więcej na temat UUID: http://mysql.rjweb.org/doc.php/uuid (Zawiera funkcje do konwersji pomiędzy standardowymi 36-znakami UUIDsi BINARY(16).) Aktualizacja: MySQL 8.0 ma wbudowaną funkcję do tego.

Posiadanie UNIQUE AUTO_INCREMENTi UNIQUEUUID w tej samej tabeli jest marnotrawstwem.

  • Gdy INSERTwystąpi, wszystkie klucze unikalne / podstawowe należy sprawdzić pod kątem duplikatów.
  • Każdy unikalny klucz jest wystarczający, aby InnoDB wymagał posiadania PRIMARY KEY.
  • BINARY(16) (16 bajtów) jest nieco nieporęczny (argument przemawiający przeciwko PK), ale nie jest taki zły.
  • Luźność ma znaczenie, gdy masz klucze dodatkowe. InnoDB cicho wciska PK na końcu każdego klucza dodatkowego. Główną lekcją tutaj jest zminimalizowanie liczby kluczy pomocniczych, szczególnie w przypadku bardzo dużych tabel. Opracowanie: W przypadku jednego klucza wtórnego debata na temat luzów zwykle kończy się remisem. W przypadku 2 lub więcej kluczy wtórnych grubsze PK zwykle prowadzi do większego miejsca na dysku dla tabeli, w tym jej indeksów.

Dla porównania: INT UNSIGNEDma 4 bajty z zakresem 0..4 miliarda. BIGINTma 8 bajtów.

Aktualizacje kursywą / itp. Dodano wrzesień 2017; nic krytycznego się nie zmieniło.

Rick James
źródło
Dziękuję za odpowiedź, byłem mniej świadomy utraty optymalizacji pamięci podręcznej. Mniej martwiłem się o nieporęczne klucze obce, ale widzę, jak ostatecznie stanie się to problemem. Nie chcę jednak całkowicie usuwać ich użycia, ponieważ okazują się one bardzo przydatne do interakcji między systemami. BINARY(16)Myślę, że oboje zgadzamy się, że jest to najbardziej efektywny sposób przechowywania UUID, ale jeśli chodzi o UNIQUEindeks, czy powinienem po prostu używać zwykłego indeksu? Bajty są generowane przy użyciu kryptograficznie bezpiecznych RNG, więc czy mam całkowicie polegać na losowości i zrezygnować z kontroli?
Flosculus
Nieunikalny indeks pomógłby niektórym poprawić wydajność, ale nawet zwykły indeks musi zostać ostatecznie zaktualizowany. Jaki jest twój przewidywany rozmiar stołu? Czy w końcu będzie zbyt duży, aby buforować? Sugerowana wartość innodb_buffer_pool_sizeto 70% dostępnego pamięci RAM.
Rick James
Baza danych 1,2 GB po 2 miesiącach, największy stół to 300 MB, ale dane nigdy nie znikną, więc jak długo potrwa, może 10 lat. Przyznane, że mniej niż połowa tabel będzie potrzebować nawet UUID, więc usunę je z najbardziej powierzchownych przypadków użycia. Co pozostawia ten, który będzie ich potrzebował w 50 000 wierszy i 250 MB lub 30-100 GB za 10 lat.
Flosculus
2
Za 10 lat nie będzie można kupić maszyny z jedynie 100 GB pamięci RAM. Zawsze zmieścisz się w pamięci RAM, więc moje komentarze prawdopodobnie nie będą dotyczyły twojej sprawy.
Rick James
1
@ a_horse_w_na_nazwie - W starszych wersjach zawsze było 3x. Tylko nowsze wersje są inteligentne. Być może było to 5.1.24; to prawdopodobnie wystarczająco stary, żebym o tym zapomniał.
Rick James
2

„Rick James” powiedział w zaakceptowanej odpowiedzi: „Posiadanie UNIKALNEGO AUTO_INCREMENT i UNIKALNEGO UUIDA w tej samej tabeli jest marnotrawstwem”. Ale ten test (zrobiłem to na moim komputerze) pokazuje inne fakty.

Na przykład: za pomocą testu (T2) tworzę tabelę z (INT INTEGRACJA) PODSTAWOWYM i UNIKALNYM BINARNYM (16) i innym polem jako tytułem, a następnie wstawiam ponad 1,6 mln wierszy o bardzo dobrej wydajności, ale z innym testem (T3) Zrobiłem to samo, ale wynik jest powolny po wstawieniu tylko 300 000 wierszy.

Oto mój wynik testu:

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

Zatem binarne (16) UNIKALNE z automatycznym przyrostem int_id jest lepsze niż binarne (16) UNIKALNE bez automatycznego przyrostu int_id.

Aktualizacja:

Ponownie wykonuję ten sam test i rejestruję więcej szczegółów. jest to pełne porównanie kodu i wyników między (T2) i (T3), jak wyjaśniono powyżej.

(T2) utwórz 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) utwórz 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;

To jest pełny kod testujący, wstawia 600 000 rekordów do tbl2 lub tbl3 (kod 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

Wynik dla (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.

Wynik dla (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.
użytkownik2241289
źródło
2
Wyjaśnij, dlaczego Twoja odpowiedź to coś więcej niż tylko uruchomienie testu porównawczego na komputerze osobistym. Idealnie odpowiedzią byłoby przedyskutowanie niektórych kompromisów zamiast samych wyników testów porównawczych.
Erik,
1
Poproszę o wyjaśnienia. Co to było innodb_buffer_pool_size? Skąd wziął się „rozmiar stołu”?
Rick James
1
Uruchom ponownie, używając 1000 dla wielkości transakcji - może to wyeliminować dziwne czkawki zarówno w tbl2, jak i tbl3. Wydrukuj także czas po COMMIT, a nie wcześniej. Może to wyeliminować niektóre inne anomalie.
Rick James
1
Nie jestem zaznajomiony z językiem, którego używasz, ale ja widzę, jak różne wartości @rec_idi @src_idsą generowane i stosowane do każdego wiersza. Wydrukowanie kilku INSERTwyciągów może mnie zadowolić.
Rick James
1
Idź dalej, przekraczając 600 tys. W pewnym momencie (częściowo zależy od tego, jak duże jest rec_title), t2również spadnie z klifu. To może nawet iść wolniej niż t3; Nie jestem pewien. Twój test porównawczy znajduje się w „dziurze z pączkami”, gdzie t3jest chwilowo wolniejszy.
Rick James,