В этой статье я поделюсь опытом проектирования идентификаторов для крупной медицинской системы. Мы пройдем путь от простых автоинкрементов до 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.-Источник