Autoincrement в YDB. Часть 2
В прошлом посте говорил, что в задачах для которых создавалась 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');
id | name | |
---|---|---|
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 *;
В результате запроса вернется строка с данными, которые были вставлены.
id | name | |
---|---|---|
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
, когда
счетчик дойдет до вставленного значения.
Подробнее рассмотрим в примере ниже.
- В пустую, только что созданную таблицу
users
мы вставим строчку с данными про Flash'а cid = 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
}
- Затем мы попробуем транзакционно вставить строчки
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'}])
- Для того чтобы вставка всё-таки прошла, нам нужно будет поймать эту ошибку и вместо неё вернуть
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
})
- В результате в таблице
users
мы увидим следующие данные:
id | name | |
---|---|---|
2 | The Flash | barry.allen@ccpd.gov |
4 | Batman | bruce@wayne.com |
5 | Green Arrow | oliver@queen-industries.com |
6 | Superman | clark.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
, который уже был использован.
Это приведет к ошибкам в работе системы. Так как две различных записи будут иметь одинаковый идентификатор.
Такой подход можно использовать, только если записи не удаляются из таблицы.