Хочу рассказать о том, как создать сквозной монотонно возрастающий  целочисленный id в MySQL (это называется sequence в других БД). Зачем это нужно? Например, в системе есть два вида задач для сотрудников, у каждого вида задач есть свой набор атрибутов и свой жизненный цикл (набор статусов). Для того чтобы не городить огород в коде приложения для корректного чтения разных сущностей из БД и не плодить колонки с NULL лучше разнести сущности по разным таблицам.

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

Но при всем при этом хочется иметь возможность эти задачи уникально идентифицировать в рамках системы. Например, в сервисах "одно окно" в одном разделе приложения представлено много схожих, но разных по 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)


  1. ivankudryavtsev
    21.09.2021 12:53

    Все же это не масштабируемый ID, который может стать узким местом. Это его основной недостаток.

    Кроме того, надо понять насколько хорошо это будет работать в транзакциях. На мой взгяд, UUID или Time-based UUID все же лучше.


  1. petropavel
    21.09.2021 13:39
    +2

    А в MariaDB можно просто написать CREATE SEQUENCE и не изобретать велосипед.

    https://mariadb.com/kb/en/create-sequence/


  1. oxidmod
    21.09.2021 13:52
    +1

    Еще можно использовать Class Table Inheritance и получить сквозной идентификатор для всех типов задач без СМС и триггеров


  1. gleb_l
    21.09.2021 14:18

    А как потом по Id узнать, в какой таблице экземпляр сущности? Делать выборку из всех с union?


    1. mitya_k Автор
      21.09.2021 14:43
      -1

      Да, можно через union. Но если такая задача стоит на постоянной основе, а не в рамках дебага сложного кейса, то стоит иметь таблицу связи: тип сущности и id. Но с uuid будут те же проблемы.
      Вообще я редко сталкивался с необходимостью определить, откуда взялся id ибо если REST "/user/123/purchase/567", то довольно просто понять в какие таблицы смотреть.


      1. gleb_l
        21.09.2021 15:30

        Если и так понятно, в какие таблицы смотреть, тогда зачем шарить множество ID по таблицам? Значит, все-таки существует кейз, когда непонятно? И что мешает, например, запустить identity-генератор в первой таблице с 1, во второй - скажем с миллиарда, а в третьей - с двух?


        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 зацикливать.


  1. garex
    21.09.2021 15:04

    Как уже выше написали про "Class Table Inheritance" -- отработанный вариант, который любая ОРМка поддерживает. В предложенном вами варианте проглядывается велосипед и программирование на стороне БД.


    1. gleb_l
      21.09.2021 15:26
      +2

      Факт программирования на стороне БД в отрыве от контекста задачи не может быть отрицательным ярлыком. Есть огромное множество задач, в которых оно исключительно полезно для эффективности и целостности данных. Существование и, главное, развитие средств внутрибазного программирования по мере увеличения номера версии БД-движка является доказательством их необходимости.

      По поводу же велосипедов - тот же паттерн clas table inheritance при массовой вставке записей скорее всего будет нереализуем ни средствами ORM (она не догадается смапить множество новых ID), ни данным решением.(last_identity + 1 не даст гарантии на уникальный новый ID, если сиквенс параллельно дернет кто-то еще)


      1. garex
        21.09.2021 15:50
        -2

        Существование и, главное, развитие средств внутрибазного программирования по мере увеличения номера версии БД-движка является доказательством их необходимости

        Это скорее следствие легаси, но это не значит, что это хорошо. СУБД прежде всего query, а уже во вторую очередь language. Но это холивар.

        скорее всего будет нереализуем ни средствами ORM

        Тогда это не ОРМ. Изучите как оно работает. Внутри транзакции в табличке документов будет хоть 100500 автоинкрементов генериться, даже если паралельно это ещё 100 потоков делает. Единственно они не будут друг за другом. Но уникальность будет.


        1. gleb_l
          22.09.2021 14:22
          +2

          "Это скорее следствие легаси" - нет, к счастью. Если бы это было только необходимостью поддержания легаси, то множество новых фич, доступных только из-под кода БД, стремилось бы к нулю. Поскольку это не так, значит, существует способ работы с БД, отличный от ORM, для которого эти фичи разрабатываются. А поскольку разработка фичей стоит денег (и немалых), то за счет этих фичей производитель движка БД надеется получить конкурентное преимущество перед теми, у кого их нет. А раз эти фичи невозможно использовать посредством ORM, следовательно существует альтернативный способ работы с данными, используя эти фичи. Альтернативный ORM способ - это inner-base programmability (IBP).

          "Изучите, как оно работает" - спасибо, я в курсе "thread-safe" поведения секвенсеров и identity :). Я имею в виду операции типа массовой вставки и получения обратно множества ключей, чтобы потом по ним связать и иерархические сущности. Эта задача нетривиальна даже для внутрибазного программирования, и скорее всего не реализована ни в одной ORM.

          Говоря в более общем виде, в отличие от средств IBP, ORM не обладает полным информационным контекстом, и поэтому не может быть эффективна там, где обладание им критично по бизнес-логике или по эффективности (например, когерентные массовые апдейты)


  1. 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


  1. hungry_forester
    21.09.2021 16:23
    +1

    То, что у разных задач разный маршрут в "документообороте" и разный набор атрибутов, отнюдь не запрещает им быть в одной таблице. Ну это так, к слову.

    А вот "иметь одну запись, в которой указан последний выданный id " - проверенный практикой рациональный вариант. Он приближает вас к решению задачи обеспечения уникального сиквенса в рамках распределенной по нескольким серверам системы (с возможностью автономной работы/с не гарантированно работающими каналами связи).

    Ну и если забыть про существование триггеров и тому подобных измышлений больного ума, станете ближе к кроссплатформенности в смысле СУБД.


  1. symbix
    21.09.2021 17:38

    uuid плохо влияет на работу 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.


    что решает проблему. Для старых версий можно написать идентичные функции в виде хранимок.


  1. nordfox
    21.09.2021 19:04

    Ни разу в жизни не нужно было делать как написано в пункте {Подготовка}. А вот один ид для многих таблиц постоянно требуется. По сути, описанное вымышленная задача не имеющая практического применения.

    Можете минусовать, но переубедить не получится.


  1. nokimaro
    22.09.2021 13:21

    Всё уже давно придумано 10 лет назад https://code.flickr.net/2010/02/08/ticket-servers-distributed-unique-primary-keys-on-the-cheap/