Раздел 34 · MySQL с нуля

ALTER TABLE и нормализация

junior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы навешивали на таблицу правила при создании. Но таблица живёт годами, и требования меняются: зрителям понадобились бонусные баллы, телефону не хватает длины, город решили искать быстрее. Пересоздавать таблицу с данными ради этого никто не будет. Сегодня меняем живые таблицы командой ALTER TABLE, а во второй половине урока перестраиваем films: выносим режиссёров в отдельную таблицу.

Ремонт без переезда

Когда в квартире нужна ещё одна розетка, ты не переезжаешь в новый дом. Мастер просверлит стену, и жильцы даже не заметят. А вот заменить все трубы в доме можно, только перекрыв воду и вскрыв каждую квартиру.

ALTER TABLE бывает и таким, и таким. Одни изменения MySQL делает мгновенно, поправив только описание таблицы. Для других он строит новую копию таблицы и переписывает в неё каждую строку. На шести зрителях разницы не видно, а на ста миллионах строк копирование идёт часами. Различать их научимся по ответу сервера.

ADD COLUMN

Добавим зрителям дату рождения сразу после имени и бонусный счёт:

ALTER TABLE viewers ADD COLUMN birth_date DATE AFTER name;
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0
ALTER TABLE viewers ADD COLUMN bonus INT NOT NULL DEFAULT 0;
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

Без AFTER колонка встаёт в конец. Обрати внимание на 0 rows affected. Это и есть мгновенное изменение, INSTANT: InnoDB записал новую колонку в описание таблицы и не тронул ни одной строки. Значения при этом есть у всех:

SELECT id, name, birth_date, bonus FROM viewers;
+----+---------------------------+------------+-------+
| id | name                      | birth_date | bonus |
+----+---------------------------+------------+-------+
|  1 | Анна Ковалёва             | NULL       |     0 |
|  2 | Борис Орлов               | NULL       |     0 |
|  3 | Вера Ильина               | NULL       |     0 |
|  4 | Глеб Соколов              | NULL       |     0 |
|  5 | Дарья Белова              | NULL       |     0 |
|  6 | Егор Ёлкин                | NULL       |     0 |
+----+---------------------------+------------+-------+
6 rows in set (0.00 sec)

Дата рождения пустая, бонус 0 из DEFAULT. Колонку с NOT NULL без DEFAULT в непустую таблицу добавлять не стоит: старым строкам MySQL подставит пустое значение типа (0 или пустую строку), и отличить их от настоящих уже не выйдет.

MODIFY: когда переписывается вся таблица

MODIFY меняет тип колонки. Расширим телефон и город:

ALTER TABLE viewers MODIFY phone VARCHAR(30);
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0
ALTER TABLE viewers MODIFY city VARCHAR(80);
Query OK, 6 rows affected (0.00 sec)
Records: 6  Duplicates: 0  Warnings: 0

Два похожих изменения, а ответы разные. Телефон расширился мгновенно, а город переписал все 6 строк. Причина в том, как InnoDB хранит VARCHAR. Перед строкой лежит её длина в байтах. Пока колонка вмещает до 255 байт, на длину уходит один байт, дальше два. Буква в utf8mb4 может занимать до 4 байт: VARCHAR(30) это до 120 байт, длина помещается в один байт, менять в строках нечего. VARCHAR(80) это уже до 320 байт, у каждой строки меняется заголовок, и таблицу приходится переписать.

Смена типа числа всегда копирует таблицу:

ALTER TABLE viewers MODIFY bonus BIGINT NOT NULL DEFAULT 0;
Query OK, 6 rows affected (0.00 sec)
Records: 6  Duplicates: 0  Warnings: 0

Правило на будущее: N rows affected у ALTER TABLE означает, что MySQL переписал N строк. На большой таблице в рабочей базе такую команду запускают не посреди дня, а после проверки на копии.

Учти, что MODIFY описывает колонку целиком. Если у колонки были NOT NULL и DEFAULT, а ты написал только тип, они пропадут. Поэтому выше мы повторили NOT NULL DEFAULT 0.

RENAME и DROP

Переименовать колонку и удалить её:

ALTER TABLE viewers RENAME COLUMN bonus TO bonus_points;
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0
ALTER TABLE viewers DROP COLUMN birth_date;
Query OK, 0 rows affected (0.00 sec)
Records: 0  Duplicates: 0  Warnings: 0

Оба мгновенные. Но DROP COLUMN стирает значения колонки безвозвратно, корзины нет. А переименование ломает каждый запрос приложения, где было старое имя.

Когда ALTER отказывает

Сделаем город обязательным:

ALTER TABLE viewers MODIFY city VARCHAR(80) NOT NULL;
ERROR 1138 (22004): Invalid use of NULL value

У Глеба Соколова город не указан, и MySQL не может сделать колонку обязательной, пока в ней есть NULL. Сначала заполни пустые значения через UPDATE, потом меняй колонку.

Сузим имя до пяти символов:

ALTER TABLE viewers MODIFY name VARCHAR(5);
ERROR 1265 (01000): Data truncated for column 'name' at row 1

«Анна Ковалёва» в пять букв не влезает, и строгий режим запретил обрезать. Таблица осталась как была: ALTER TABLE либо проходит целиком, либо не меняет ничего. Опечатка в имени колонки тоже даёт ошибку:

ALTER TABLE viewers DROP COLUMN nosuch;
ERROR 1091 (42000): Can't DROP 'nosuch'; check that column/key exists

ADD INDEX

Через ALTER TABLE добавляют и индексы. Город будем искать часто:

ALTER TABLE viewers ADD INDEX idx_city (city);
DESCRIBE viewers;
+---------------+--------------+------+-----+---------+----------------+
| Field         | Type         | Null | Key | Default | Extra          |
+---------------+--------------+------+-----+---------+----------------+
| id            | int          | NO   | PRI | NULL    | auto_increment |
| name          | varchar(50)  | NO   |     | NULL    |                |
| email         | varchar(100) | NO   | UNI | NULL    |                |
| phone         | varchar(30)  | YES  |     | NULL    |                |
| city          | varchar(80)  | YES  | MUL | NULL    |                |
| registered_at | date         | NO   |     | NULL    |                |
| bonus_points  | bigint       | NO   |     | 0       |                |
+---------------+--------------+------+-----+---------+----------------+
7 rows in set (0.00 sec)

Метка MUL у города значит «обычный индекс, значения могут повторяться». Индекс строится, пока таблица доступна для чтения и записи. Как индексы ускоряют поиск, разберём в уроке про индексы.

Тарковский дважды

Теперь посмотри на фильмы. Режиссёр записан текстом прямо в строке фильма, и у «Сталкера» и «Соляриса» стоит одно и то же «Тарковский». Пока фильмов семь, это мелочь. Но представь тысячу фильмов: кто-то исправит опечатку в фамилии в одной строке и забудет про остальные, и база будет считать, что режиссёров два. Это аномалия обновления. А чтобы хранить про режиссёра что-то ещё, год рождения или страну, пришлось бы повторять это у каждого его фильма.

Лекарство называется нормализацией: каждый факт хранится в одном месте. Режиссёры получат свою таблицу, а фильм будет хранить только номер режиссёра. Теория нормальных форм разобрана в уроке про аномалии и нормальные формы. Здесь проделаем это руками за пять шагов.

Шаг 1. Таблица режиссёров, имя без повторов:

CREATE TABLE directors (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(60) NOT NULL UNIQUE
);
Query OK, 0 rows affected (0.00 sec)

Шаг 2. Заполняем её из фильмов. INSERT ... SELECT вставляет результат запроса, DISTINCT убирает повторы, NULL у «Новых имён» отсекаем:

INSERT INTO directors (name) SELECT DISTINCT director FROM films WHERE director IS NOT NULL;
SELECT * FROM directors;
+----+----------------------+
| id | name                 |
+----+----------------------+
|  1 | Балабанов            |
|  2 | Меньшов              |
|  5 | Норштейн             |
|  3 | Рязанов              |
|  4 | Тарковский           |
+----+----------------------+
5 rows in set (0.00 sec)

Тарковский один. Заметь странный порядок: строки отсортированы по имени, а не по id. Таблица маленькая, и InnoDB прочитал её через индекс UNIQUE по имени, в нём есть всё нужное. Без ORDER BY порядок строк ничем не гарантирован. Нужен порядок, пиши ORDER BY id.

Шаг 3. Колонка для ссылки и её заполнение. UPDATE ... JOIN находит для каждого фильма режиссёра с тем же именем и копирует его номер. Соединения таблиц подробно разберём в уроке про JOIN, пока читай это как «сопоставь по имени»:

ALTER TABLE films ADD COLUMN director_id INT AFTER director;
UPDATE films JOIN directors ON directors.name = films.director SET films.director_id = directors.id;
Query OK, 6 rows affected (0.00 sec)
Rows matched: 6  Changed: 6  Warnings: 0

Шесть из семи: у «Новых имён» режиссёра нет. Сверим старое и новое рядом, прежде чем что-то удалять:

SELECT id, title, director, director_id FROM films;
+----+-------------------------------------------+----------------------+-------------+
| id | title                                     | director             | director_id |
+----+-------------------------------------------+----------------------+-------------+
|  1 | Брат                                      | Балабанов            |           1 |
|  2 | Москва слезам не верит                    | Меньшов              |           2 |
|  3 | Ирония судьбы                             | Рязанов              |           3 |
|  4 | Сталкер                                   | Тарковский           |           4 |
|  5 | Ёжик в тумане                             | Норштейн             |           5 |
|  6 | Новые имена                               | NULL                 |        NULL |
|  7 | Солярис                                   | Тарковский           |           4 |
+----+-------------------------------------------+----------------------+-------------+
7 rows in set (0.00 sec)

Шаг 4. Внешний ключ из урока про внешние ключи, чтобы номер не указывал в пустоту:

ALTER TABLE films ADD FOREIGN KEY (director_id) REFERENCES directors (id);
Query OK, 7 rows affected (0.00 sec)
Records: 7  Duplicates: 0  Warnings: 0

7 rows affected: добавление внешнего ключа переписало таблицу, и заодно MySQL проверил каждую ссылку.

Шаг 5. Старая текстовая колонка больше не нужна:

ALTER TABLE films DROP COLUMN director;
SHOW CREATE TABLE films\G
*************************** 1. row ***************************
       Table: films
Create Table: CREATE TABLE `films` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title` varchar(100) NOT NULL,
  `director_id` int DEFAULT NULL,
  `release_year` smallint NOT NULL,
  `duration_min` smallint unsigned NOT NULL,
  `age_rating` enum('0+','6+','12+','16+','18+') NOT NULL,
  `info` json DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `director_id` (`director_id`),
  CONSTRAINT `films_ibfk_1` FOREIGN KEY (`director_id`) REFERENCES `directors` (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)

Готово. Фамилию Тарковского теперь исправляют в одной строке directors, и оба фильма видят исправление. Порядок шагов важен: сначала новое рядом со старым, проверка, и только потом удаление. На рабочей базе между шагами 4 и 5 ещё обновляют приложение, чтобы оно читало режиссёра по director_id.

Переименовать таблицу

Напоследок RENAME TO меняет имя самой таблицы:

ALTER TABLE films RENAME TO movies;
SHOW TABLES;
+----------------+
| Tables_in_kino |
+----------------+
| directors      |
| halls          |
| movies         |
| screenings     |
| tickets        |
| viewers        |
+----------------+
6 rows in set (0.00 sec)

Внешние ключи сеансов перешли на новое имя сами. Вернём как было: ALTER TABLE movies RENAME TO films. Та же работа в PostgreSQL описана в уроке про ALTER TABLE в PostgreSQL.

Попробуй сам

В песочнице проделаем нормализацию режиссёров по шагам. Удаление старой колонки оставлено тебе:

Удали колонку director и посмотри SHOW CREATE TABLE films\G. Сравни ответы ALTER TABLE viewers MODIFY phone VARCHAR(30) и ALTER TABLE viewers MODIFY phone VARCHAR(100). Попробуй удалить режиссёра Тарковского и прочитай ошибку.

Проверь себя

Задание

Дальше

Таблицы, ключи и ограничения на месте. Пора задавать базе вопросы. В следующем уроке начинаем блок запросов: SELECT, фильтры WHERE, сортировка и LIMIT.

домашка

Домашка