Простой вопрос, который разделяет инженеров и «операторов фреймворков»

Я провёл больше ста технических собеседований. Разных: на джуна, мидла, сеньора, системных аналитиков, бэкендеров, фуллстеков.

И у меня есть один вопрос. Простой до неприличия. Без алгоритмов, без паттернов проектирования, без «реализуй красно-чёрное дерево на доске».

Один вопрос, который с точностью хирургического скальпеля отделяет людей, которые думают о данных, от тех, кто просто заполняет формочки.

Вот он:

Вы проектируете базу данных. В ней нужно хранить серию и номер российского паспорта. Какой тип данных выберете? База – SQL.

Всё. Больше никаких условий. Можно уточнять, можно думать вслух.

Прежде чем читать дальше – ответьте себе честно. Прямо сейчас. Какой тип?


Что отвечают 8 из 10

Большинство кандидатов с ожиданиями от 150к рублей отвечают примерно так:

«Ну... это же цифры. Значит, INTEGER. Или BIGINT, если много данных».

Иногда добавляют:

«Можно NUMBER(10), чтобы ограничить длину».

Редкий кандидат скажет NUMERIC. Это звучит умнее, но суть та же.

Один PHP-разработчик с опытом больше пяти лет тоже уверенно ответил NUMBER. А когда я намекнул, что есть подвох, начал выкручиваться: «Ну, можно при чтении проверять длину числа, и если она меньше четырёх – дописывать ноль спереди». Костыль в бизнес-логике вместо правильного типа данных – и он настаивал, что «зато так база работает быстрее». Человек готов жонглировать строками в коде, лишь бы не пересмотреть выбор типа.

И все они ошибаются. Причём ошибка не в синтаксисе и не в незнании конкретной СУБД. Ошибка – в способе мышления.

Давайте разберём, почему.


Проблема первая: ведущий ноль

Да, многие из вас уже догадались, в чём подвох. Задача элементарная – и тем обиднее, что на ней спотыкаются снова и снова. Не джуны на первом собеседовании, а опытные разработчики с годами коммерческой практики.

Серия российского паспорта – это четыре цифры. Например: 0306.

Что произойдёт, если вы сохраните 0306 в поле типа INTEGER?

INSERT INTO persons (passport_series) VALUES (0306);
SELECT passport_series FROM persons; -- вернёт 306

Ведущий ноль исчезнет. Молча и без ошибки. База данных просто сделает своё дело: сохранит число триста шесть.

Паспорт серии 0306 и паспорт серии 306 – это юридически разные документы. Но в вашей базе они станут неотличимы.

Вы только что сломали идентификацию человека. Тихо, незаметно, без единого исключения в логах.

Масштабируем: в России живёт 146 миллионов человек. У скольких серия паспорта начинается с нуля? У миллионов. Поздравляю, вы тол��ко что помножили миллионы людей на тот самый ноль – в буквальном смысле. Их паспорта перестали существовать в корректном виде.


Проблема вторая: семантика

Но даже если бы ведущих нулей не существовало – тип INTEGER всё равно был бы неправильным ответом.

Спросите себя: что вы делаете с числами?

Вы складываете их. Вычитаете. Находите среднее. Делите. Сортируете по возрастанию, чтобы найти «наибольший» или «наименьший».

Теперь вопрос: когда вы в последний раз складывали серию паспорта с номером дома? Вычисляли среднее арифметическое паспортов в базе? Искали паспорт с «наибольшим» значением серии?

Никогда. Потому что это бессмысленно.

Паспорт – это идентификатор. Это метка, уникально указывающая на документ. Над идентификаторами не производят математических операций. Их сравнивают на равенство, ищут по ним, передают – и всё.

Это принципиально другая семантика. И тип данных должен эту семантику отражать.

Правильный ответ: VARCHAR (или CHAR, если длина фиксирована, что здесь как раз так).

-- Серия: ровно 4 символа
passport_series CHAR(4) NOT NULL,

-- Номер: ровно 6 символов  
passport_number CHAR(6) NOT NULL

Иногда серию и номер хранят в одном поле – тогда CHAR(10) или VARCHAR(10):

-- Серия + номер: 10 символов без пробела
passport_full CHAR(10) NOT NULL  -- '0306123456'

Смысл не меняется: это строка. Не число. Точка.


«Но ведь строки занимают больше места!»

Это возражение я слышу часто. И оно технически верное, но практически бессмысленное.

CHAR(4) для цифр занимает те же 4 байта, что и INTEGER (для однобайтовых символов, а цифры в любой кодировке – один байт). В данном случае разницы нет вообще.

Но даже если бы VARCHAR занимал в два раза больше – это не имело бы значения. Вы храните паспорт человека, а не оптимизируете хранилище для задачи уровня FAANG. Экономия нескольких байт на строке не стоит сломанных данных.


«А что если мне нужно быстро искать по паспорту?»

Поставьте индекс. Индекс на VARCHAR работает отлично. Поиск по точному совпадению строки через B-tree индекс – это O(log n), ровно как и для числа. (Да, hash-индекс даёт O(1) – но B-tree в большинстве СУБД стоит по умолчанию, и для наших объёмов разница неощутима.)

Если кто-то говорит, что «числовые индексы быстрее строковых» – пусть покажет бенчмарк на реальных данных. Для CHAR фиксированной длины разница практически нулевая.


Почему это важно прямо сейчас

Вы можете подумать: «Ну, это академический вопрос. На практике такое не встречается».

Встречается и постоянно.

Пару лет назад моя команда разрабатывала сервис для оформления заявок на парковочные абонементы. Один начинающий специалист спроектировал таблицу, в которой поле паспорта имело тип NUMBER. Форма прошла код-ревью, прошла тестирование, прошла приёмку – и благополучно ушла в прод. Работала без нареканий. Ровно до того момента, пока заявку не подал я сам.

У меня серия паспорта – 0306. Система проглотила ведущий ноль, сохранила 306, и заявка тихо сломалась. Не с ошибкой, не с исключением – просто данные стали невалидными. Паспорт в базе и паспорт на бумаге перестали совпадать.

С тех пор этот вопрос – один из моих любимых на собеседованиях. Не потому что он сложный, а потому что он настоящий. За ним стоит конкретный баг, конкретная сломанная заявка и конкретный урок: типы данных – это не синтаксис, это проектное решение.

Эту ситуацию можно было исключить одним правильным решением в начале.


Бонус: а что со СНИЛС и ИНН?

Раз уж мы разобрались с паспортом, давайте добьём тему.

СНИЛС – 11 цифр, формат XXX-XXX-XXX XX. Тип? VARCHAR(14) или CHAR(11) после очистки от дефисов. Не BIGINT. Никогда.

ИНН физлица – 12 цифр. Может начинаться с нуля? Да: коды регионов 01–09 (Адыгея, Башкортостан, Бурятия и т.д.) дают ИНН, начинающийся с нуля. Так что аргумент «ведущий ноль» здесь работает в полную силу. Но даже без него хранить в VARCHAR(12) – это пр��вильная привычка, потому что ИНН – идентификатор, а не число.

Номер телефона+7 (903) 123-45-67. Тип? Только VARCHAR. Да, + можно отрезать и хранить чистые цифры 79031234567 – но даже в этом случае вы не будете складывать телефоны между собой. Телефон – идентификатор: его ищут, сравнивают и передают. Арифметика над ним бессмысленна. А если завтра потребуется хранить международный формат с + – придётся менять схему. VARCHAR(20) и никаких компромиссов.

Почтовый индекс – 6 цифр. Российские индексы начинаются с цифр от 1 до 6 (минимальный – 101000, Москва), с нуля не начинается ни один. Но для международных индексов (Великобритания, Канада) формат буквенно-цифровой, и главное – индекс всё равно не число. CHAR(6).

Паттерн везде один: если вы никогда не будете делать арифметику с этим полем – это не число.


Что на самом деле проверяет этот вопрос

Этот вопрос – не ловушка. Это не попытка поймать вас на незнании какой-то синтаксической особенности.

Он проверяет одну вещь: думаете ли вы о природе данных, прежде чем выбрать инструмент для их хранения.

Плохой инженер смотрит на данные и думает: «Там цифры – значит число».

Хороший инженер задаёт три вопроса:

  1. Какова семантика этих данных? (Что они означают?)

  2. Какие операции над ними будут производиться?

  3. Какие ограничения накладывает предметная область?

Только ответив на них, он выбирает тип.

Это и есть разница между человеком, который пишет код, и человеком, который решает задачи.


Полная таблица: идентификаторы в российских системах

Данные

Формат

Правильный тип

Частая ошибка

Что случится, если ошибётесь

Серия паспорта

4 цифры

CHAR(4)

INTEGER

Серия 0306306, документ невалиден

Номер паспорта

6 цифр

CHAR(6)

INTEGER

Потеря ведущего нуля, сломанная идентификация

Паспорт (серия + номер)

10 цифр

CHAR(10)

BIGINT

Потеря до двух ведущих нулей

СНИЛС

11 цифр

CHAR(11) или VARCHAR(14)

BIGINT

ИНН/СНИЛС регионов 01–09 обрезаются

ИНН физлица

12 цифр

CHAR(12)

BIGINT

ИНН с кодом 01–09 теряет разряд

ИНН юрлица

10 цифр

CHAR(10)

BIGINT

Аналогично: ведущий ноль → невалидный ИНН

Телефон

+7...

VARCHAR(20)

BIGINT

Невозможно хранить +, ломается формат

Почтовый индекс

6 цифр

CHAR(6)

INTEGER

Международный индекс не влезет

Банковский счёт

20 цифр

CHAR(20)

Нет подходящего INT

Переполнение, потеря денег

БИК

9 цифр

CHAR(9)

INTEGER

Обрезка при нестандартном коде

Вместо вывода

Если вы начинающий специалист – запомните эту таблицу. Она сэкономит вам часы отладки и сохранит репутацию.

Если вы сеньор – задайте этот вопрос на следующем собеседовании. Ответ скажет вам о кандидате больше, чем час разговора про паттерны.

Если вы тимлид – проверьте свою текущую схему БД. Прямо сегодня. Найдите поле с серией паспорта или СНИЛС. Посмотрите на его тип.

Иногда самые дорогие баги прячутся в самых скучных местах.


Эта статья – адаптированный фрагмент из моей книги «Поколение JSON: хроники Client-Side Testing». Книга о том, как индустрия свернула не туда. Бесплатная глава – на json-book.ru