Хочу рассказать о том, как создать сквозной монотонно возрастающий целочисленный id в MySQL (это называется sequence
в других БД). Зачем это нужно? Например, в системе есть два вида задач для сотрудников, у каждого вида задач есть свой набор атрибутов и свой жизненный цикл (набор статусов). Для того чтобы не городить огород в коде приложения для корректного чтения разных сущностей из БД и не плодить колонки с NULL лучше разнести сущности по разным таблицам.
Но при всем при этом хочется иметь возможность эти задачи уникально идентифицировать в рамках системы. Например, в сервисах "одно окно" в одном разделе приложения представлено много схожих, но разных по workflow сущностей, как в примере выше. Но проблема в том, что если сущности лежат в разных таблицах, то у каждой из них свой autoincrement, и следовательно, нумерация будет пересекаться.
Один из вариантов решения это использование идентификации с помощью id + type
. Но это несколько усложняет архитектуру, ведь об этом придется помнить как на backend, так и на frontend. И возникает возможность случайно совершить действие с другой задачей, просто-напросто перепутав id.
Альтернативное решение это использовать uuid, но он имеет ряд недостатков:
uuid плохо влияет на работу primary key и не очень хорошо дружит с Btree индексом
uuid разумеется занимает гораздо больше места, чем integer
И пожалуй самый главный недостаток это то, что с ним не очень удобно работать, особенно когда он присутствует в ссылках на ресурс. Отсутствует «человекочитаемость». Например,
ff86e090-0625-11ea-9f44-8c16456798f1
невозможно сходу запомнить в отличие от числа1256
. Особенно на это жалуются тестировщики и отдел сопровождения, когда пользователь приложил скриншот, на котором url c несколькими uuid, и им приходится аккуратно посимвольно переписывать их со скриншота.
Эмулировать sequence
в MySQL довольно просто. Итак, начнем.
Подготовка
Для примера создадим две таблицы:
CREATE TABLE task_1 (
id INT UNSIGNED NOT NULL DEFAULT 0,
name VARCHAR(255) NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE task_2 (
id INT UNSIGNED NOT NULL DEFAULT 0,
name VARCHAR(255) NOT NULL,
PRIMARY KEY (id)
);
Для поля id у нас нет AUTOINCREMENT, потому что мы будем брать значение из нашего будущего sequence, DEFAULT 0 - потому что поле с primary key не может быть null, а следовательно, нужно иметь какое-то значение по умолчанию. Создадим так же таблицу, где будем хранить наш сквозной id.
CREATE TABLE sequence (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY NOT NULL
);
#1 Через INSERT
Будем реализовывать автоматическую выдачу нового id через триггеры для каждой из таблиц.
CREATE TRIGGER task_1_before_insert
BEFORE INSERT ON
task_1
FOR EACH ROW
BEGIN
INSERT INTO sequence SET id=DEFAULT;
SET NEW.id = LAST_INSERT_ID();
END;
CREATE TRIGGER task_2_before_insert
BEFORE INSERT ON
task_2
FOR EACH ROW
BEGIN
INSERT INTO sequence SET id=DEFAULT;
SET NEW.id = LAST_INSERT_ID();
END;
Данные триггеры будут срабатывать при каждом INSERT в таблицы task_1
и task_2
. В начале, мы делаем вставку в таблицу sequence
, а затем получаем последний id (LAST_INSERT_ID) и используем его для вставки в целевую таблицу (с помощью переменной NEW). Протестируем наше решение путем создания задач в каждой из таблиц.
INSERT INTO task_1 SET name = 'example 1';
INSERT INTO task_2 SET name = 'example 2';
В итоге таблицы будут содержать следующие данные:
task_1:
id |
name |
1 |
example 1 |
task_2:
id |
name |
2 |
example 2 |
sequence:
id |
1 |
2 |
Но получается, что у нас в таблице sequence
под каждый выданный id имеется запись. Не очень красиво, хотя и не критично... Хотелось бы просто иметь одну запись, в которой указан последний выданный id.
#2 Через UPDATE
Заново создадим таблицу sequence
, но без AUTO_INCREMENT и проинициализируем наш сквозной id.
CREATE TABLE sequence (
id INT UNSIGNED PRIMARY KEY NOT NULL
);
INSERT INTO sequence VALUES (0);
А затем перепишем триггеры:
CREATE TRIGGER task_1_before_insert
BEFORE INSERT ON
task_1
FOR EACH ROW
BEGIN
UPDATE sequence SET id = LAST_INSERT_ID(id+1), NEW.id = LAST_INSERT_ID();
END;
CREATE TRIGGER task_2_before_insert
BEFORE INSERT ON
task_2
FOR EACH ROW
BEGIN
UPDATE sequence SET id = LAST_INSERT_ID(id+1), NEW.id = LAST_INSERT_ID();
END;
Согласно документации LAST_INSERT_ID() может принимать выражение, результат которого он запомнит и при следующем вызове его вернет. Мы сначала увеличиваем id в таблице sequence
и кэшируем результат, а затем обращаемся к закэшированному значению и передаем его в переменную NEW.
Теперь, если снова сделать вставки в таблицы task_1
и task_2
, то sequence
будет содержать одну единственную запись с номером последнего выданного id.
sequence:
id |
2 |
В некоторых ситуациях требуется сделать запись в другую таблицу, используя id созданной сущности, например, связать созданную задачу и пользователей. Но есть одна проблемка, если мы захотим в коде нашего серверного приложения (PHP, NodeJS, Python и т.д.) получить id, только что выданный при вставке в таблицу задач, то мы получим 0.
INSERT INTO task_1 SET name = 'example 3';
SELECT LAST_INSERT_ID(); -- Result: 0
Это связано с тем, что триггер не изменяет поведение LAST_INSERT_ID() для нашей таблицы, а значение по умолчанию для колонки id в таблице task_1
является 0.
При этом в INSERT мы также не можем передать конкретный id, ведь в коде триггера мы перезаписываем его, беря из sequence
. Исправляется эта проблема довольно легко:
CREATE TRIGGER task_1_before_insert
BEFORE INSERT ON
task_1
FOR EACH ROW
BEGIN
IF NEW.id IS NULL OR NEW.id = 0 THEN
UPDATE sequence SET id = LAST_INSERT_ID(id+1), NEW.id = LAST_INSERT_ID();
END IF;
END
Мы просто проверим, а есть ли id и не равен ли он дефолтному значению(0) и только в этом случае будем использовать наш sequence
.
UPDATE sequence SET id = LAST_INSERT_ID(id+1); --- вручную выделили новый id
INSERT INTO task_1 SET id = LAST_INSERT_ID(), name = 'example 4';
INSERT INTO task_for_user SET task_id = LAST_INSERT_ID(), user_id = 1;
Мы вручную увеличили наш sequence
, использовали полученный id для создания задачи, а также для записи информации о созданной задаче в другую таблицу. При желании инкрементирование sequence
можно завернуть в функцию, чтобы не писать каждый раз подобный запрос. Этим мы и займемся в следующем разделе.
#3 Через nextval
Сделаем имитацию функции nextval
как в других БД, типа Oracle.
CREATE TABLE sequence (
id INT UNSIGNED PRIMARY KEY NOT NULL,
-- с помощью name мы можем хранить множество sequence в одной таблице.
name VARCHAR(255) NOT NULL
);
INSERT INTO sequence SET id = 0, name = 'my_sequence';
Дальше создадим функцию nextval
, которая принимает на вход имя sequence и возвращает новый id.
CREATE FUNCTION `nextval`(`seq_name` VARCHAR(255)) RETURNS INT UNSIGNED
MODIFIES SQL DATA
DETERMINISTIC
BEGIN
UPDATE sequence SET id = LAST_INSERT_ID(id+1) WHERE name = seq_name;
RETURN LAST_INSERT_ID();
END
Теперь мы можем получать новый id с помощью следующего запроса:
SELECT nextval('my_sequence'); -- 1
SELECT nextval('my_sequence'); -- 2
Можем теперь снова переписать наш триггер c использованием nextval
CREATE TRIGGER task_1_before_insert
BEFORE INSERT ON
task_1
FOR EACH ROW
BEGIN
IF NEW.id IS NULL OR NEW.id = 0 THEN
SET NEW.id = (SELECT nextval('my_sequence'));
END IF;
END
При желании nextval
можно кастомизировать, например, вторым параметром указывать на сколько инкрементировать (nextval('my_sequence', 10)
) sequence, чтобы можно было создать множество id, или сделать, чтобы sequence
выдавал только четные id, в рамках какого-то диапазона (например, от 0...1000), или даже можно зациклить его.
Итог
Мы реализовали тремя несложными способами создание уникального сквозного id. Использовать sequence
бездумно не стоит, ведь в большинстве случаев обычного autoincrement хватит за глаза.
Но он очень удобен, если:
необходимо сущности уникально проидентифицировать в рамках одной БД, но при этом у каждой из этих сущностей свой набор атрибутов и их невозможно хранить в одной таблице.
нам нужно в коде приложения до INSERT самой сущности создать Value Object и нам необходим id чтобы полноценно работать с ней, вместо uuid можно воспользоваться
sequence
.с помощью зацикливания
sequence
можно генерить номер заказа как в Макдональс: при достижении максимального значения сбрасывается значениеsequence
на начальное значение.
Комментарии (16)
petropavel
21.09.2021 13:39+2А в MariaDB можно просто написать
CREATE SEQUENCE
и не изобретать велосипед.https://mariadb.com/kb/en/create-sequence/
oxidmod
21.09.2021 13:52+1Еще можно использовать Class Table Inheritance и получить сквозной идентификатор для всех типов задач без СМС и триггеров
gleb_l
21.09.2021 14:18А как потом по Id узнать, в какой таблице экземпляр сущности? Делать выборку из всех с union?
mitya_k Автор
21.09.2021 14:43-1Да, можно через union. Но если такая задача стоит на постоянной основе, а не в рамках дебага сложного кейса, то стоит иметь таблицу связи: тип сущности и id. Но с uuid будут те же проблемы.
Вообще я редко сталкивался с необходимостью определить, откуда взялся id ибо если REST "/user/123/purchase/567", то довольно просто понять в какие таблицы смотреть.gleb_l
21.09.2021 15:30Если и так понятно, в какие таблицы смотреть, тогда зачем шарить множество ID по таблицам? Значит, все-таки существует кейз, когда непонятно? И что мешает, например, запустить identity-генератор в первой таблице с 1, во второй - скажем с миллиарда, а в третьей - с двух?
mitya_k Автор
21.09.2021 16:21Если и так понятно, в какие таблицы смотреть, тогда зачем шарить множество ID по таблицам?
Например, реальный кейс есть urls:
/schedule_task/:id, /manual_task/:id, /osa_task/:id
. Как написано в статье у них разный workflow, но при этом они в пользовательском приложении находятся в одном разделе. Например, достаточно дернуть url c неверным id, например, вместо/schedule_task/1 /osa_task/1
и мы получим битые данные(для sql запросов аналогично). С sequence такое провернуть нельзя.Значит, все-таки существует кейз, когда непонятно?
Да, был кейс в legacy системе, в которой был один sequence вообще на все таблицы в БД, а вместо REST был RPC с невнятным наименование методов. Но как правило в бизнес-логике мы работаем с конкретной таблицей/таблицами и нам не особо требуется выяснять откуда взялся id из sequence или auto_increment.
И что мешает, например, запустить identity-генератор в первой таблице с 1, во второй - скажем с миллиарда, а в третьей - с двух?
Да, вообщем-то ничего, просто sequence поудобнее. Ведь придется диапазоны подгадывать или менять их при добавлении новых таблиц, плюс не все id будут заюзаны - в одной таблице 100 000 сущностей и больше не добавляются, а второй и миллиарда не хватит. Плюс можно sequence зацикливать.
garex
21.09.2021 15:04Как уже выше написали про "Class Table Inheritance" -- отработанный вариант, который любая ОРМка поддерживает. В предложенном вами варианте проглядывается велосипед и программирование на стороне БД.
gleb_l
21.09.2021 15:26+2Факт программирования на стороне БД в отрыве от контекста задачи не может быть отрицательным ярлыком. Есть огромное множество задач, в которых оно исключительно полезно для эффективности и целостности данных. Существование и, главное, развитие средств внутрибазного программирования по мере увеличения номера версии БД-движка является доказательством их необходимости.
По поводу же велосипедов - тот же паттерн clas table inheritance при массовой вставке записей скорее всего будет нереализуем ни средствами ORM (она не догадается смапить множество новых ID), ни данным решением.(last_identity + 1 не даст гарантии на уникальный новый ID, если сиквенс параллельно дернет кто-то еще)
garex
21.09.2021 15:50-2Существование и, главное, развитие средств внутрибазного программирования по мере увеличения номера версии БД-движка является доказательством их необходимости
Это скорее следствие легаси, но это не значит, что это хорошо. СУБД прежде всего query, а уже во вторую очередь language. Но это холивар.
скорее всего будет нереализуем ни средствами ORM
Тогда это не ОРМ. Изучите как оно работает. Внутри транзакции в табличке документов будет хоть 100500 автоинкрементов генериться, даже если паралельно это ещё 100 потоков делает. Единственно они не будут друг за другом. Но уникальность будет.
gleb_l
22.09.2021 14:22+2"Это скорее следствие легаси" - нет, к счастью. Если бы это было только необходимостью поддержания легаси, то множество новых фич, доступных только из-под кода БД, стремилось бы к нулю. Поскольку это не так, значит, существует способ работы с БД, отличный от ORM, для которого эти фичи разрабатываются. А поскольку разработка фичей стоит денег (и немалых), то за счет этих фичей производитель движка БД надеется получить конкурентное преимущество перед теми, у кого их нет. А раз эти фичи невозможно использовать посредством ORM, следовательно существует альтернативный способ работы с данными, используя эти фичи. Альтернативный ORM способ - это inner-base programmability (IBP).
"Изучите, как оно работает" - спасибо, я в курсе "thread-safe" поведения секвенсеров и identity :). Я имею в виду операции типа массовой вставки и получения обратно множества ключей, чтобы потом по ним связать и иерархические сущности. Эта задача нетривиальна даже для внутрибазного программирования, и скорее всего не реализована ни в одной ORM.
Говоря в более общем виде, в отличие от средств IBP, ORM не обладает полным информационным контекстом, и поэтому не может быть эффективна там, где обладание им критично по бизнес-логике или по эффективности (например, когерентные массовые апдейты)
Akina
21.09.2021 15:41Но получается, что у нас в таблице
sequence
под каждый выданный id имеется запись. Не очень красиво, хотя и не критично... Хотелось бы просто иметь одну запись, в которой указан последний выданный id.Что мешает в том же триггере удалить из таблицы все записи (а по факту почти всегда - одну запись), в которых id меньше свежесгенерённого?
BEGIN INSERT INTO sequence SET id=DEFAULT; SET NEW.id = LAST_INSERT_ID(); DELETE FROM sequence WHERE id < NEW.id; END
https://dbfiddle.uk/?rdbms=mysql_8.0&fiddle=49ca20f3f01eb27d52c127beda86bd4c
hungry_forester
21.09.2021 16:23+1То, что у разных задач разный маршрут в "документообороте" и разный набор атрибутов, отнюдь не запрещает им быть в одной таблице. Ну это так, к слову.
А вот "иметь одну запись, в которой указан последний выданный id " - проверенный практикой рациональный вариант. Он приближает вас к решению задачи обеспечения уникального сиквенса в рамках распределенной по нескольким серверам системы (с возможностью автономной работы/с не гарантированно работающими каналами связи).
Ну и если забыть про существование триггеров и тому подобных измышлений больного ума, станете ближе к кроссплатформенности в смысле СУБД.
symbix
21.09.2021 17:38uuid плохо влияет на работу primary key и не очень хорошо дружит с Btree индексом
Хранить uuid как char — это ещё догадаться надо. В MySQL 8+:
UUID_TO_BIN(string_uuid), UUID_TO_BIN(string_uuid, swap_flag)
If swap_flag is 1, the format of the return value differs: The time-low and time-high parts (the first and third groups of hexadecimal digits, respectively) are swapped. This moves the more rapidly varying part to the right and can improve indexing efficiency if the result is stored in an indexed column.
что решает проблему. Для старых версий можно написать идентичные функции в виде хранимок.
nordfox
21.09.2021 19:04Ни разу в жизни не нужно было делать как написано в пункте {Подготовка}. А вот один ид для многих таблиц постоянно требуется. По сути, описанное вымышленная задача не имеющая практического применения.
Можете минусовать, но переубедить не получится.
nokimaro
22.09.2021 13:21Всё уже давно придумано 10 лет назад https://code.flickr.net/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/
ivankudryavtsev
Все же это не масштабируемый ID, который может стать узким местом. Это его основной недостаток.
Кроме того, надо понять насколько хорошо это будет работать в транзакциях. На мой взгяд, UUID или Time-based UUID все же лучше.