Перейти к основному содержимому

Autoincrement в YDB. Часть 2

· 5 мин. чтения

В прошлом посте говорил, что в задачах для которых создавалась YDB использование автоинкремента нецелесообразно. Так как он может стать бутылочным горлышком при вставке большого количества данных.

Однако, с развитием YDB, разработчикам стало понятно, что serial типы данных востребованы пользователями. И в версии 24.3 они были добавлены.

Serial типы данных

Давайте обратимся к документации и посмотрим, на особенности реализации serial типов данных в YDB.

Чтобы создать столбец с автоинкрементом, нужно использовать тип данных Serial. Например:

CREATE TABLE users (
id Serial,
name Utf8,
email Utf8,
PRIMARY KEY (id)
);

Далее при вставке данных в таблицу, не нужно указывать значение для столбца id. Оно будет автоматически сгенерировано.

UPSERT INTO users (name, email) VALUES ('Batman', 'bruce@wayne.com');
INSERT INTO users (name, email) VALUES ('Superman', 'clark.kent@dailyplanet.com');
REPLACE INTO users (name, email) VALUES ('The Flash', 'barry.allen@ccpd.gov');
idnameemail
1"Batman""bruce@wayne.com"
2"The Flash""barry.allen@ccpd.gov"
3"Superman""clark.kent@dailyplanet.com"

Для того чтобы узнать какое значение было сгенерировано, можно использовать ключевое слово RETURNING.

INSERT INTO users (name, email) VALUES ('Green Arrow', 'oliver@queen-industries.com') RETURNING *;

В результате запроса вернется строка с данными, которые были вставлены.

idnameemail
4"Green Arrow""oliver@queen-industries.com"

Пример кода на Go

func insert(ctx context.Context, s query.Session) (err error) {
users := []struct {
Name string
Email string
}{
{Name: "Batman", Email: "bruce@wayne.com"},
{Name: "The Flash", Email: "barry.allen@ccpd.gov"},
{Name: "Superman", Email: "clark.kent@dailyplanet.com"},
}

var data []types.Value
for _, user := range users {
data = append(data, types.StructValue(
types.StructFieldValue("name", types.UTF8Value(user.Name)),
types.StructFieldValue("email", types.UTF8Value(user.Email)),
))
}

result, err := s.Query(ctx, fmt.Sprintf(`
DECLARE $data AS List<Struct<
name: Utf8,
email: Utf8
>>;

INSERT INTO %s
SELECT
name,
email
FROM AS_TABLE($data)
RETURNING *;`, "`users`"),
query.WithParameters(
ydb.ParamsBuilder().
Param("$data").
BeginList().AddItems(data...).EndList().
Build(),
),
)
if err != nil {
return err
}

defer func() {
_ = result.Close(ctx)
}()

for {
resultSet, err := result.NextResultSet(ctx)
if err != nil {
if errors.Is(err, io.EOF) {
break
}

return err
}
type info struct {
ID string `sql:"id"`
Name string `sql:"name"`
Email string `sql:"email"`
}
for row, err := range sugar.UnmarshalRows[info](
resultSet.Rows(ctx),
// query.WithScanStructAllowMissingFieldsInStruct(),
) {
if err != nil {
return err
}

log.Printf("id: %v", row)
}
}
return nil
}

Для получения результатов RETURNING нужно прочитать ResultSet, который возвращает метод Query. Для этого используется метод NextResultSet. В примере кода используется sugar.UnmarshalRows, который позволяет преобразовать строки в структуру. Стоит отметить, что структура либо должна содержать все поля, если в RETURNING указано *, либо должна содержать только те поля, которые возвращаются. Если же используется *, но в структуре, например, только поле Id, то нужно использовать опцию query.WithScanStructAllowMissingFieldsInStruct() (строка 62).

Пропуски в Serial

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

Подробнее рассмотрим в примере ниже.

  1. В пустую, только что созданную таблицу users мы вставим строчку с данными про Flash'а c id = 2. Значение Serial это никак не затронет.
func insertFlash(ctx context.Context, s query.Session) error {
users := []struct {
Id int
Name string
Email string
}{
{Id: 2, Name: "The Flash", Email: "barry.allen@ccpd.gov"},
}

var data []types.Value
for _, user := range users {
data = append(data, types.StructValue(
types.StructFieldValue("id", types.Int32Value(int32(user.Id))),
types.StructFieldValue("name", types.UTF8Value(user.Name)),
types.StructFieldValue("email", types.UTF8Value(user.Email)),
))
}

err := s.Exec(ctx, fmt.Sprintf(`
DECLARE $data AS List<Struct<
id: Int32,
name: Utf8,
email: Utf8
>>;

INSERT INTO %s
SELECT
id,
name,
email
FROM AS_TABLE($data)
RETURNING *;`, "`users`"),
query.WithParameters(
ydb.ParamsBuilder().
Param("$data").
BeginList().AddItems(data...).EndList().
Build(),
),
)
return err
}
  1. Затем мы попробуем транзакционно вставить строчки
func insertHeroes(ctx context.Context, s query.Session) (err error) {

users := []struct {
Name string
Email string
}{
{Name: "Batman", Email: "bruce@wayne.com"},
{Name: "Green Arrow", Email: "oliver@queen-industries.com"},
{Name: "Superman", Email: "clark.kent@dailyplanet.com"},
}

var data []types.Value
for _, user := range users {
data = append(data, types.StructValue(
types.StructFieldValue("name", types.UTF8Value(user.Name)),
types.StructFieldValue("email", types.UTF8Value(user.Email)),
))
}

result, err := s.Query(ctx, fmt.Sprintf(`
DECLARE $data AS List<Struct<
name: Utf8,
email: Utf8
>>;

INSERT INTO %s
SELECT
name,
email
FROM AS_TABLE($data)
RETURNING *;`, "`users`"),
query.WithParameters(
ydb.ParamsBuilder().
Param("$data").
BeginList().AddItems(data...).EndList().
Build(),
),
)
if err != nil {
return err
}

defer func() {
_ = result.Close(ctx)
}()

for {
resultSet, err := result.NextResultSet(ctx)
if err != nil {
if errors.Is(err, io.EOF) {
break
}

return err
}
type info struct {
ID string `sql:"id"`
Name string `sql:"name"`
Email string `sql:"email"`
}
for row, err := range sugar.UnmarshalRows[info](
resultSet.Rows(ctx),
query.WithScanStructAllowMissingFieldsInStruct(),
) {
if err != nil {
return err
}

log.Printf("id: %v", row)
}
}

return nil
}

При этом мы получим следующую ошибку:

operation/PRECONDITION_FAILED (code = 400120, address =..., issues = [{#2012 'Conflict with existing key'}])
  1. Для того чтобы вставка всё-таки прошла, нам нужно будет поймать эту ошибку и вместо неё вернуть retry. RetryableError, которую пакет retry умеет обрабатывать. Это приведет к выполнению запроса еще раз.
err = retry.Retry(ctx, func(ctx context.Context) error {
err := qc.Do(ctx, insert)
if err != nil && ydb.IsOperationError(err, Ydb.StatusIds_PRECONDITION_FAILED) {
return retry.RetryableError(err)
}
return nil
})
  1. В результате в таблице users мы увидим следующие данные:
idnameemail
2The Flashbarry.allen@ccpd.gov
4Batmanbruce@wayne.com
5Green Arrowoliver@queen-industries.com
6Supermanclark.kent@dailyplanet.com
к сведению

Можно заметить, что в итоге в таблице users нет строчки с id = 3. Это произошло из-за того, что в транзакции мы пытались вставить строчки с id от 1 до 3. Счетчик Serial был увеличен до этого значения, но вставка не прошла. Поэтому при следующей вставке счетчик увеличился на 1 и в повторной транзакции мы вставили строчки с id от 4 до 6.

Это ожидаемое поведение. Оно описано в документации.

Альтернативы

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

Вычисление максимального значения

SELECT MAX(id) FROM `users`;

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

Брать последнее значение ключа

SELECT id FROM `users` ORDER BY id DESC LIMIT 1;

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

Проблемы с уникальностью

Что я имею в виду под проблемами с уникальностью? Представим себе сценарий, когда в таблицу была вставлена запись.

$id = SELECT id FROM users ORDER BY id DESC LIMIT 1;
INSERT INTO users (id, name, email)
VALUES (if($id is not null, unwrap($id) + 1, 1), 'joker', 'joker@example.com') RETURNING id;

Пусть в ответ нам пришел идентификатор 5.

Идентификатор этой записи был передан какой-то внешней системе. Затем эта запись была удалена.

DELETE FROM users WHERE id = 5;

Если мы возьмем максимальное значение и увеличим его на 1, то мы можем получить идентификатор, который уже был использован.

$id = SELECT id FROM users ORDER BY id DESC LIMIT 1;
INSERT INTO users (id, name, email)
VALUES (if($id is not null, unwrap($id) + 1, 1), 'Harley Queen', 'hq@example.com') RETURNING id;

В результате запроса мы снова получим идентификатор 5, который уже был использован.

Это приведет к ошибкам в работе системы. Так как две различных записи будут иметь одинаковый идентификатор.

Такой подход можно использовать, только если записи не удаляются из таблицы.