Чи слід використовувати UUID, а також ідентифікатор


11

Я вже деякий час використовую UUID в моїх системах з різних причин, починаючи від реєстрації даних до затримки кореляції. Формати, які я використав, змінилися, коли я став менш наївним:

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

Саме тоді, коли я дійшов до остаточного BINARY(16), я почав порівнювати продуктивність з базовим цілим числом автоматичного збільшення. Тест та результати показані нижче, але якщо ви просто хочете резюме, воно вказує на це INT AUTOINCREMENTта BINARY(16) RANDOMмає однакову ефективність у діапазоні даних до 200 000 (база даних була попередньо заповнена перед тестами).

Я спочатку скептично ставився до використання UUID в якості первинних ключів, і я справді все ще є, проте тут бачу потенціал створити гнучку базу даних, яка може використовувати обидва. Тоді як багато людей наголошують на перевагах будь-якого, які недоліки скасовуються за допомогою обох типів даних?

  • PRIMARY INT
  • UNIQUE BINARY(16)

Прикладом використання для цього типу налаштування буде традиційний первинний ключ для взаємозв'язків між таблицями, з унікальним ідентифікатором, який використовується для міжсистемних відносин.

Що я, по суті, намагаюся виявити, - це різниця в ефективності між двома підходами. Окрім використовуваного чотириразового дискового простору, який може бути значно незначним після додавання додаткових даних, вони здаються мені однаковими.

Схема:

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

Вставити орієнтир:

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

Виберіть орієнтир:

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

Тести:

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

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

Результати:

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

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

Відповіді:


10

UUID - це катастрофа ефективності роботи дуже великих таблиць. (200К рядків не "дуже великі".)

Ваш номер 3 насправді поганий, коли значення CHARCTER SETutf8 - CHAR(36)займає 108 байт! Оновлення: Є те, ROW_FORMATsдля чого це залишиться 36.

UUID (GUID) дуже "випадкові". Використання їх як УНІКАЛЬНОГО або ПРИМІТНОГО клавіші на великих столах дуже неефективно. Це пов’язано з тим, що вам доведеться стрибати навколо таблиці / індексу кожного разу, коли ви INSERTстворюєте новий UUID або SELECTUUID. Коли таблиця / індекс занадто велика, щоб вмістити кеш (див. innodb_buffer_pool_size, Який повинен бути меншим, ніж оперативна пам'ять, як правило, 70%), "наступний" UUID може бути не кешований, отже, повільний диск. Коли таблиця / індекс у 20 разів більша, ніж кеш, кешується лише 1/20 (5%) звернень - ви пов'язані введенням / виводом. Узагальнення: неефективність застосовується до будь-якого "випадкового" доступу - UUID / MD5 / RAND () / тощо

Таким чином, не використовуйте UUID, якщо це не так

  • у вас є "маленькі" таблиці, або
  • вони вам справді потрібні через генерування унікальних ідентифікаторів з різних місць (і не знайшли іншого способу зробити це).

Більше про UUID: http://mysql.rjweb.org/doc.php/uuid (Він включає функції для перетворення між стандартними 36-знаковими UUIDsта BINARY(16).) Оновлення: MySQL 8.0 має вбудовану функцію для таких.

Бути і УНІКАЛЬНИМ, AUTO_INCREMENTі UNIQUEUUID в одній таблиці - це марно.

  • Коли INSERTвідбувається, все унікальні / первинні ключі повинні бути перевірені на наявність дублікатів.
  • Будь-якого унікального ключа достатньо для вимоги InnoDB мати a PRIMARY KEY.
  • BINARY(16) (16 байт) дещо громіздкий (аргумент проти того, щоб зробити його ПК), але не так вже й погано.
  • Об'ємність має значення, коли у вас є вторинні ключі. InnoDB мовчки торкається ПК до кінця кожної вторинної клавіші. Основний урок тут - мінімізувати кількість вторинних ключів, особливо для дуже великих таблиць. Розробка: Для одного другорядного ключа, дискусія про об'ємність зазвичай закінчується внічию. Для 2 або більше вторинних ключів більш товстий ПК звичайно призводить до збільшення розміру диска для таблиці, включаючи його індекси.

Для порівняння: INT UNSIGNEDце 4 байти з діапазоном 0..4 мільярда. BIGINTстановить 8 байт.

Оновлені курсивом / тощо додано вересень 2017 року; нічого критичного не змінилося.


Дякую за вашу відповідь, я менше усвідомлював втрати оптимізації кешу. Мене менше хвилювали громіздкі іноземні ключі, але я бачу, як це врешті-решт стане проблемою. Але я не хочу повністю їх використовувати, проте вони виявляються дуже корисними для міжсистемної взаємодії. BINARY(16)Я думаю, що ми обидва згодні - це найефективніший спосіб зберігання UUID, але щодо UNIQUEіндексу я повинен просто використовувати звичайний індекс? Байти генеруються за допомогою криптографічно захищених RNG, тож я повинен повністю залежати від випадковості і відмовитись від перевірок?
Flosculus

Неповторний індекс допоможе досягти ефективності, але навіть звичайний індекс з часом потребує оновлення. Який розмір прогнозованого столу? Чи буде він з часом занадто великий для кешування? Пропоноване значення innodb_buffer_pool_sizeстановить 70% наявного барана.
Рік Джеймс

Його база даних 1,2 Гб через 2 місяці, найбільша таблиця - 300 МБ, але дані ніколи не зникнуть, тому як би довго це не тривало, можливо, 10 років. Надані менше половини таблиць навіть потребуватимуть UUID, тому я вилучу їх із самих випадкових випадків використання. Котрий залишає той, який їм знадобиться в даний час на 50 000 рядків і 250 МБ, або 30 - 100 ГБ за 10 років.
Flosculus

2
Через 10 років ви не зможете придбати машину з лише 100 ГБ оперативної пам’яті. Ви завжди будете вміщуватися в оперативній пам’яті, тому мої коментарі, ймовірно, не стосуватимуться вашої справи.
Рік Джеймс

1
@a_horse_with_no_name - У старих версіях вона завжди була в 3 рази. Тільки новіші версії розібралися з цим. Можливо, це було 5.1.24; це, мабуть, досить для мене, щоб забути про це.
Рік Джеймс

2

"Рік Джеймс" сказав у прийнятій відповіді: "Мати як УНІКАЛЬНУ АВТОМОБІЛЬНИЙ І УНІКАЛЬНИЙ UUID в одній таблиці - це марно". Але цей тест (я це робив на своїй машині) показує різні факти.

Наприклад: за допомогою тесту (T2) я складаю таблицю з (INT AUTOINCREMENT) PRIMARY та UNIQUE BINARY (16) та іншим полем як заголовком, тоді я вставляю більше 1,6 М рядків з дуже хорошою продуктивністю, але з іншим тестуванням (T3) Я робив те ж саме, але результат уповільнений після вставки лише 300 000 рядків.

Це мій результат тестування:

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

Отже, двійковий (16) УНІКАЛЬНИЙ з автоматичним збільшенням int_id краще, ніж двійковий (16) УНІКАЛЬНИЙ без автоматичного збільшення інт_id.

Оновлення:

Я знову роблю той же тест і записую більше деталей. це повний код та порівняння результатів між (T2) та (T3), як пояснено вище.

(T2) створити 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) створити 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;

Це повний тестовий код, він вставляє 600000 записів у tbl2 або tbl3 (код 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

Результат (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.

Результат (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.

2
Поясніть, будь ласка, як ваша відповідь - це більше, ніж просто тестування їх еталону на вашій персональній машині. В ідеалі у відповіді було б обговорюватись частину компромісів, а не лише базові результати.
Ерік

1
Деякі роз’яснення, будь ласка. Що було innodb_buffer_pool_size? Звідки взявся "розмір столу"?
Рік Джеймс

1
Повторіться, використовуючи 1000 для розміру транзакції - це може усунути дивні гикавки як у tbl2, так і в tbl3. Крім того, роздрукуйте терміни після COMMIT, не раніше. Це може усунути деякі інші аномалії.
Рік Джеймс

1
Я не знайомий з мовою , який ви використовуєте, але я бачу , як різні значення @rec_idі @src_idгенеруються і застосовуються до кожного рядка. Друк декількох INSERTтверджень може мене задовольнити.
Рік Джеймс

1
Крім того, продовжуйте проходити 600K. У якийсь момент (частково залежить від того, наскільки великий прямокутник), t2також впаде скеля. Це може йти навіть повільніше, ніж t3; Я не впевнений. Ваш орієнтир знаходиться в «дірці від бублика» , де t3знаходиться тимчасово повільніше.
Рік Джеймс
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.