В этой статье я поделюсь опытом проектирования идентификаторов для крупной медицинской системы. Мы пройдем путь от простых автоинкрементов до 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+ записей в год
Распределенными филиалами
Высокими требованиями к безопасности
Необходимостью человеко-читаемых идентификаторов
Требования из реального ТЗ:
Распределенный доступ (каждый видит только свои данные)
Защита от несанкционированного доступа (нельзя угадать ID)
Архитектура расширения (новые типы данных)
Интеграции (с 1С, CRM)
Медицинские данные (особая чувствительность)
Мысль 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 до сих пор непобедим по производительности. Строковые ключи создают проблемы:
Фрагментация индексов: случайные UUID разрушают кластеризацию
Размер кэша: меньше записей помещается в памяти
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' => '📅', ], ];
Почему это гениально?
Визуальная типизация: DOC — врач, PAT — пациент
Группировка в БД: все врачи в одном диапазоне
Безопасность через префикс: даже зная ID, не знаешь префикс
Цветовое кодирование: в UI сразу понятно
Масштабирование: диапазон можно расширить
Мысль 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:
Встроенный шардинг: префикс = номер сервера
Временная сортировка: timestamp внутри
Типизация: знаем тип сущности
Простая миграция: меняем префикс и готово
Практическая реализация
Трейт для всех моделей:
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));
Почему этого нет в готовых пакетах?
Вопрос, который вы зададите: "Если это так круто, почему нет готового пакета?"
Ответ:
Универсальность убивает гибкость
Каждый проект требует своей логики префиксов
Разные требования к безопасности
Разные паттерны доступа
Ответственность
Пакет, который ломает ID, ломает бизнес
Никто не хочет поддерживать такое
Эволюция технологий
INT → UUID → ULID → UUID v7 → ?
Индустрия еще ищет идеал
Реальность разработки
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.
