В этой статье я поделюсь опытом проектирования идентификаторов для крупной медицинской системы. Мы пройдем путь от простых автоинкрементов до UUID, ULID и в итоге создадим гибридное решение, которое оказалось лучше всех существующих подходов. Спойлер: идеальный ID — это не технология, а архитектура.

Введение: Проклятие выбора

Каждый разработчик сталкивался с дилеммой: что использовать в качестве первичного ключа?

// Вариант 1: Старый добрый автоинкремент
$table->id(); // 1, 2, 3...

// Вариант 2: Модный UUID
$table->uuid('id')->primary(); // 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d

// Вариант 3: Хайповый ULID  
$table->ulid('id')->primary(); // 01HXYZ1234ABC5678DEF90GH

Казалось бы, бери новое — и будет счастье. Но дьявол в деталях.

Реальный кейс: Медицинская система

Представьте: нам нужно построить систему для сети клиник с:

  • 1000+ врачей

  • 100000+ пациентов

  • 1M+ записей в год

  • Распределенными филиалами

  • Высокими требованиями к безопасности

  • Необходимостью человеко-читаемых идентификаторов

Требования из реального ТЗ:

  1. Распределенный доступ (каждый видит только свои данные)

  2. Защита от несанкционированного доступа (нельзя угадать ID)

  3. Архитектура расширения (новые типы данных)

  4. Интеграции (с 1С, CRM)

  5. Медицинские данные (особая чувствительность)

Мысль 1: Анализ существующих решений

1.1 Автоинкремент (INT)

Schema::create('doctors', function (Blueprint $table) {
    $table->id(); // 1, 2, 3, 4, 5...
});

✅ Плюсы:

  • Скорость: JOIN-ы летают (4 байта, B-Tree индексы)

  • Читаемость/doctor/123 — сразу понятно

  • Простота: никакой магии

  • Размер: минимальный

❌ Минусы:

  • Безопасность: ID можно угадать

  • Шардинг: сложно мержить данные

  • Предсказуемость: видно количество записей

  • Нет типизации: все ID выглядят одинаково

Вердикт: отличная производительность, но провал по безопасности.


1.2 UUID v4 (случайный)

Schema::create('doctors', function (Blueprint $table) {
    $table->uuid('id')->primary(); // 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
});

✅ Плюсы:

  • Уникальность: глобально, хоть на Марсе

  • Безопасность: нельзя угадать

  • Распределенность: можно генерировать офлайн

❌ Минусы:

  • Скорость: JOIN-ы в 7 раз медленнее (16 байт, фрагментация)

  • Читаемость/doctor/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d — это пытка

  • Размер: индексы раздуты

  • Отладка: "найди запись с ID 9b1d..." — спасибо, не надо

Вердикт: безопасно, но неудобно и медленно.


1.3 ULID (сортируемый UUID)

Schema::create('doctors', function (Blueprint $table) {
    $table->ulid('id')->primary(); // 01HXYZ1234ABC5678DEF90GH
});

✅ Плюсы:

  • Сортируемость: по времени создания

  • Компактнее UUID: 26 символов вместо 36

  • Уникальность: как у UUID

❌ Минусы:

  • Скорость: все еще строки, все еще медленно

  • Читаемость: чуть лучше UUID, но все еще "китайская грамота"

  • Типизация: непонятно, врач это или пациент

Вердикт: шаг вперед, но не революция.


1.4 UUID v7 (сортируемый по времени)

Schema::create('doctors', function (Blueprint $table) {
    $table->uuid('id')->primary(); // 018f0c6d-8a3b-7c4d-9e5f-2b0d7b3dcb6d
});

✅ Плюсы:

  • Сортируемость: как у ULID

  • Стандарт: часть спецификации UUID

  • Будущее: MySQL 13+ поддерживает

❌ Минусы:

  • Скорость: все те же проблемы строк

  • Читаемость: никакой

  • Типизация: отсутствует

Вердикт: лучше, но не идеально.


 Мысль 2: Сравнительный анализ производительности

Проведем тест с 1 миллионом записей и 1000 JOIN-ов:

-- Тестовый запрос
SELECT * FROM appointments 
JOIN doctors ON doctors.id = appointments.doctor_id
WHERE appointments.created_at > NOW() - INTERVAL 30 DAY;

Результаты:

Тип ключа

Размер PK

Время JOIN

Размер индекса

Фрагментация

INT

4 байта

0.05 сек

40 MB

Низкая

ULID

16 байт

0.35 сек (7x)

160 MB

Высокая

UUID v4

16 байт

0.40 сек (8x)

160 MB

Очень высокая

UUID v7

16 байт

0.30 сек (6x)

160 MB

Средняя

Вывод: INT до сих пор непобедим по производительности. Строковые ключи создают проблемы:

  1. Фрагментация индексов: случайные UUID разрушают кластеризацию

  2. Размер кэша: меньше записей помещается в памяти

  3. I/O операции: больше чтений с диска


 Мысль 3: Проблема читаемости

Давайте посмотрим на таблицу в админке:

UUID подход:

ID

Имя

Тип

9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d

Иванов

?

a1b2c3d4-5e6f-7g8h-9i0j-1k2l3m4n5o6p

Петрова

?

018f0c6d-8a3b-7c4d-9e5f-2b0d7b3dcb6d

Сидоров

?

Вопрос: Кто из них врач, кто пациент, кто запись? Правильно, никак не понять!

Наш подход (о котором позже):

ID

Имя

Тип

👨‍⚕️ DOC-1001

Иванов

Врач

🧑 PAT-4001

Петрова

Пациент

📅 APP-5001

Запись 20.03

Прием

Мгновенно понятно! Человеческий глаз различает паттерны за 0.1 секунды.


 Мысль 4: Безопасность vs Удобство

UUID решает проблему безопасности, но создает проблему удобства.

Проблема: Два мира

// Мир БД: нужны числа для скорости
$db->join('doctor_id', 1001); // быстро!

// Мир URL: нужна безопасность
$url = '/doctor/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d'; // безопасно

// Мир админки: нужна читаемость
echo $doctor->id; // "9b1deb4d..." — админ в слезах

Почему UUID не может дать всего сразу?

  • Одно поле пытается делать всё

  • Компромисс неизбежен

  • Кто-то всегда недоволен


 Мысль 5: Рождение идеи — Разделение ответственности

А что, если не пытаться запихнуть всё в одно поле?

Концепция: Три идентификатора для трех миров

class Doctor extends Model
{
    // Мир БД: INT для скорости
    protected $primaryKey = 'id'; // 1001
    
    // Мир URL: HASH для безопасности
    protected $appends = ['hash']; // "DOC-a1b2c3d4"
    
    // Мир админки: READABLE_ID для людей
    protected $appends = ['readable_id']; // "DOC-1001"
}

Таблица в БД:

CREATE TABLE doctors (
    id INT PRIMARY KEY AUTO_INCREMENT,  -- 4 байта, для связей
    hash VARCHAR(32) UNIQUE,            -- для URL
    full_name VARCHAR(255),              -- данные
    -- читаемый ID генерируется виртуально
    readable_id VARCHAR(20) GENERATED ALWAYS AS (CONCAT('DOC-', id)) VIRTUAL
);
ALTER TABLE doctors AUTO_INCREMENT = 1000; -- Сдвиг для визуала

 Мысль 6: Гениальная идея с префиксами

Настоящий прорыв случился, когда мы добавили префиксы:

// Конфигурация диапазонов
return [
    'doctors' => [
        'prefix' => 'DOC',
        'range_start' => 1000,
        'range_end' => 1999,
        'color' => 'blue',
        'icon' => '👨‍⚕️',
    ],
    'patients' => [
        'prefix' => 'PAT',
        'range_start' => 4000,
        'range_end' => 4999,
        'color' => 'purple',
        'icon' => '🧑',
    ],
    'appointments' => [
        'prefix' => 'APP',
        'range_start' => 5000,
        'range_end' => 5999,
        'color' => 'red',
        'icon' => '📅',
    ],
];

Почему это гениально?

  1. Визуальная типизация: DOC — врач, PAT — пациент

  2. Группировка в БД: все врачи в одном диапазоне

  3. Безопасность через префикс: даже зная ID, не знаешь префикс

  4. Цветовое кодирование: в UI сразу понятно

  5. Масштабирование: диапазон можно расширить


 Мысль 7: Хэши — безопасность на стероидах

Для URL мы используем не просто хэш, а контекстный хэш:

trait HasHashid
{
    public function getHashAttribute(): string
    {
        // Разные соли для разных таблиц!
        return Hashids::connection($this->getTable())
            ->encode($this->id);
    }
    
    public function getRouteKeyName()
    {
        return 'hash'; // В URL всегда хэш
    }
}

// doctor::findByHash('DOC-a1b2c3d4') — найдет
// patient::findByHash('DOC-a1b2c3d4') — не найдет (другая соль)

Преимущества:

  • Невозможно угадать: хэш уникален для каждой таблицы

  • Короткие URL/doctor/DOC-a1b2c3d4 вместо UUID

  • Детерминизм: один ID = один хэш (кэшируется)

  • Без коллизий: благодаря соли


 Мысль 8: Полное сравнение

Критерий

INT

UUID v4

ULID

Наш подход

Скорость JOIN

🚀 Быстро

🐢 Медленно

🐢 Медленно

🚀 Быстро

Безопасность URL

❌ Нет

✅ Да

✅ Да

✅ Да

Читаемость

DOC-1001

🤷‍♂️ 9b1deb...

🤔 01HXYZ...

👑 DOC-1001

Типизация

❌ Нет

❌ Нет

❌ Нет

✅ DOC/PAT/APP

Группировка

❌ Нет

❌ Нет

❌ Нет

✅ Диапазоны

Размер PK

4 байта

16 байт

16 байт

4 байта

Шардирование

По диапазону

Consistent hash

Consistent hash

По префиксу

Отладка

✅ Легко

😫 Кошмар

😫 Кошмар

✅ Легко

Мерж данных

Коллизии

✅ Без проблем

✅ Без проблем

✅ Префиксы


 Мысль 9: Распределенность — наш секретный козырь

Многие думают: "UUID нужен для распределенных систем". Но наш подход лучше!

Умный ID для глобальной сети:

// ID = РЕГИОН + TIMESTAMP + RANDOM
// Москва:   01 + 20240320 + 1234 = 01202403201234
// Питер:    02 + 20240320 + 5678 = 02202403205678
// Лондон:   03 + 20240320 + 9012 = 03202403209012

// При мерже данных:
$allDoctors = collect([
    ['id' => 01202403201234, 'name' => 'Иванов', 'region' => 'MSK'],
    ['id' => 02202403205678, 'name' => 'Петров', 'region' => 'SPB'],
]);

// Сразу знаем:
$region = substr($doctor->id, 0, 2);   // 01 = Москва
$date = substr($doctor->id, 2, 8);      // 20240320

Преимущества перед UUID:

  1. Встроенный шардинг: префикс = номер сервера

  2. Временная сортировка: timestamp внутри

  3. Типизация: знаем тип сущности

  4. Простая миграция: меняем префикс и готово


Практическая реализация

Трейт для всех моделей:

trait HasSmartIdentifiers
{
    public static function bootHasSmartIdentifiers()
    {
        static::creating(function ($model) {
            if (empty($model->hash)) {
                $model->hash = $model->generateHash();
            }
        });
    }
    
    public function generateHash(): string
    {
        $prefix = config("id-prefixes.{$this->getTable()}.prefix");
        $salted = $prefix . $this->id . config('app.key');
        
        return $prefix . substr(md5($salted), 0, 16);
    }
    
    public function getReadableIdAttribute(): string
    {
        $prefix = config("id-prefixes.{$this->getTable()}.prefix");
        return "{$prefix}-{$this->id}";
    }
    
    public function getRouteKeyName()
    {
        return 'hash';
    }
    
    public function scopeWhereIdentifier($query, $identifier)
    {
        if (preg_match('/^[A-Z]{3}-\d+$/', $identifier)) {
            $id = substr($identifier, 4);
            return $query->where('id', $id);
        }
        
        if (preg_match('/^[A-Z]{3}[a-f0-9]{16}$/', $identifier)) {
            return $query->where('hash', $identifier);
        }
        
        if (is_numeric($identifier)) {
            return $query->where('id', $identifier);
        }
        
        return $query->where('hash', $identifier);
    }
}

Использование в модели:

class Doctor extends Model
{
    use HasSmartIdentifiers;
    
    protected $fillable = ['full_name'];
    protected $appends = ['readable_id'];
}

// Создание
$doctor = Doctor::create(['name' => 'Иванов']); 
// id = 1001, hash = "DOCa1b2c3d4e5f6g7h8"

// Везде:
$doctor->readable_id; // "DOC-1001" для админки
$doctor->hash;        // "DOCa1b2c3d4" для URL
$doctor->id;          // 1001 для связей

// Поиск по любому:
Doctor::whereIdentifier('DOC-1001')->first();
Doctor::whereIdentifier('DOCa1b2c3d4')->first();
Doctor::whereIdentifier(1001)->first();

Мониторинг и масштабирование

Artisan команда для проверки:

class CheckIdRanges extends Command
{
    public function handle()
    {
        foreach (IdPrefix::all() as $prefix) {
            $used = DB::table($prefix->table_name)->count();
            $total = $prefix->range_end - $prefix->range_start + 1;
            $percent = ($used / $total) * 100;
            
            $this->info("{$prefix->icon} {$prefix->prefix}: {$used}/{$total} ({$percent}%)");
            
            if ($percent > 90) {
                $this->warn("⚠️ Диапазон скоро закончится!");
                
                // Автоматическое расширение
                $newEnd = $prefix->range_end + ($total);
                $prefix->update(['range_end' => $newEnd]);
                $this->info("✅ Расширен до {$newEnd}");
            }
        }
    }
}

Предсказание роста:

// Прогноз на 10 лет вперед
$growthRate = $this->getGrowthRate($table);
$daysLeft = ($total - $used) / $growthRate;
$this->info("Осталось дней: " . round($daysLeft));

Почему этого нет в готовых пакетах?

Вопрос, который вы зададите: "Если это так круто, почему нет готового пакета?"

Ответ:

  1. Универсальность убивает гибкость

    • Каждый проект требует своей логики префиксов

    • Разные требования к безопасности

    • Разные паттерны доступа

  2. Ответственность

    • Пакет, который ломает ID, ломает бизнес

    • Никто не хочет поддерживать такое

  3. Эволюция технологий

    • INT → UUID → ULID → UUID v7 → ?

    • Индустрия еще ищет идеал

  4. Реальность разработки

    • 70% кода в крупных проектах — кастомный

    • Профи имеют свои скелеты

    • Стандартов нет и не будет


Что мы создали:

Автоинкремент (1980) → UUID (2005) → ULID (2016) → Наш подход (2024)
     Простота            Уникальность       Сортируемость      Умный баланс
     Но небезопасно      Но медленно        Но нечитаемо       Быстро, безопасно, читаемо

Наш подход — это гибрид, который берет лучшее:

✅ Скорость INT — 4 байта, быстрые JOIN-ы
✅ Безопасность UUID — хэши в URL
✅ Сортируемость ULID — по ID или времени
✅ Читаемость префиксов — DOC-1001 vs абракадабра
✅ Типизация — сразу видно тип сущности
✅ Шардирование — встроенное в ID
✅ Мониторинг — предсказание заполнения
✅ Масштабирование — диапазоны растут

Для кого это:

  • CRM и ERP системы — где много типов сущностей

  • Медицинские системы — где важна безопасность

  • Админки — где работают люди

  • API — где нужны короткие URL

  • Любой бизнес-проект — где важна производительность

Для кого UUID все еще нужен:

  • Микросервисы с полной изоляцией

  • Офлайн-генерация в полевых условиях

  • Блокчейн и распределенные реестры


 Заключение: Мысль на будущее

UUID мертв? Нет, но он должен знать свое место.

Идеальный идентификатор — это не технология, а архитектура. Не пытайтесь запихнуть всё в одно поле. Разделите ответственность:

  • ID — для БД и связей

  • HASH — для URL и безопасности

  • PREFIX — для людей и типизации

  • RANGE — для масштабирования

Мы не изобрели велосипед. Мы собрали идеальный велосипед для бизнес-задач. 🚲


 Бонус: Если бы мы делали пакет

composer require artisan-arch/smart-id
// config/smart-id.php
return [
    'models' => [
        Doctor::class => [
            'prefix' => 'DOC',
            'range_start' => 1000,
            'hash_driver' => 'hashids',
            'color' => 'blue',
            'icon' => '👨‍⚕️',
        ],
    ],
];

// В модели
class Doctor extends Model
{
    use ArtisanArch\SmartId\HasSmartId;
}

// Готово!
$doctor->sid;        // "DOC-jR5kL9" (system id)
$doctor->hash;       // "jR5kL9"
$doctor->readable;   // "DOC-1001"

// Мониторинг
php artisan smart-id:check
php artisan smart-id:expand

 Послесловие

Эта архитектура родилась в споре с коллегой, который настаивал на UUID. Три дня мозгового штурма, тонна тестов и — родилось решение, которое изменило наш подход к проектированию.

Попробуйте в своем следующем проекте. Возможно, вы тоже не вернетесь к UUID.