Ідемпотентні вставки в базу даних: як зробити все правильно

Ідемпотентність — це критично важливе поняття в програмних системах, яке гарантує, що певну операцію можна виконувати кілька разів без зміни стану системи після початкового застосування. Це особливо важливо для операцій з базами даних, де дублювання вставок може завдати шкоди цілісності даних і призвести до важких для відлагодження помилок. Якщо зробити все правильно, ідемпотентні вставки забезпечують відмовостійкість і послідовність під час повторних спроб, помилок і складнощів розподілених систем.

Загальне рішення

Загальне рішення для ідемпотентних вставок у базу даних виглядає наступним чином, використовуючи PostgreSQL як вибір бази даних:

CREATE TABLE idempotent_table (  
 id SERIAL PRIMARY KEY,  
 data VARCHAR NOT NULL,  
 idempotency_key VARCHAR UNIQUE NOT NULL,  
);  

-- Вставка рядка  
INSERT INTO idempotent_table (data, idempotency_key)  
 VALUES ('foo', 'action1')  
 ON CONFLICT (idempotency_key) DO NOTHING;  

-- Повторна спроба операції вище  
INSERT INTO idempotent_table (data, idempotency_key)  
 VALUES ('foo', 'action1')  
 ON CONFLICT (idempotency_key) DO NOTHING;

Ми створюємо таблицю і додаємо унікальний непустий стовпець idempotency_key. При записі даних у таблицю ми перевіряємо, чи не виникає конфлікт на унікальному обмеженні idempotency_key, і в такому випадку нічого не робимо.

З таким підходом ми підвищуємо стійкість системи, дозволяючи повторно спробувати певну дію. Але давайте заглянемо глибше в крайні випадки і поведінку запропонованого рішення.

Недолік

Уявімо таку ситуацію. Спочатку запис додається в базу даних з ідемпотентним ключем action1:

INSERT INTO idempotent_table (data, idempotency_key)  
 VALUES ('foo', 'action1')  
 ON CONFLICT (idempotency_key) DO NOTHING;

І одразу після цього має бути вставлений новий запис, але через помилку використовується той самий значення idempotency_key.

INSERT INTO idempotent_table (data, idempotency_key)  
 VALUES ('bar', 'action1')  
 ON CONFLICT (idempotency_key) DO NOTHING;

Результат виконання цих двох запитів буде таким: тільки перший запит буде збережений у базі даних, а другий запит успішно виконається, але не запише другого рядка.

Недолік полягає в тому, що ми припускаємо, що наявність ідемпотентного ключа в базі даних гарантує правильність відповідних даних. Це спрощення може призвести до систем, які безшумно накопичують застарілі, неповні або навіть неправильні записи без нашої уваги. І як відомо, припущення часто призводить до помилок.

Як це виправити? — Покращене рішення

Рішення проблеми досить просте: не припускайтесь, що запис, який ви хочете вставити, і запис, що вже існує в базі, є однаковими, а скоріше перевіряйте, чи вони дійсно однакові. Це можна досягти за допомогою утиліти, яка виконуватиме відповідну перевірку.
Приклад буде використовувати Python та SQLAlchemy, але це можна адаптувати до вашого технологічного стеку.

async def idempotent_insert(con: AsyncConnection, insert_statement: Insert, idempotency_key_column: Column) -> Row:  
 # Виконати вставку  
 cursor = await con.execute(  
 insert_statement.on_conflict_do_nothing(index_elements=[idempotency_key_column])  
 )  
 # Якщо запис було вставлено, це новий запис, і можна зупинитись тут  
 if cursor.rowcount:  
 return  

 values_to_insert: dict = {k: v.value for k, v in insert_statement._values.items()}  
 # Отримати існуючий запис з ідемпотентним ключем з таблиці  
 idempotency_key_value = values_to_insert.get(idempotency_key_column.name)  
 cursor = await con.execute(  
 select(insert_statement.table).where(idempotency_key_column == idempotency_key_value)  
 )  
 existing_record: dict = cursor.fetchone()._mapping  
 # Порівняти запис у базі даних з тим, який ми хочемо вставити,  
 # якщо значення колонок відрізняються, вони заповнять змінну not_matching_columns з набором кортежів (колонка, значення)  
 if not_matching_columns := set(values_to_insert.items()) - set(  
 existing_record.items()  
 ):  
 raise IdempotencyConflictError(  
 f"Запис, що вставляється, не відповідає існуючому запису з ідемпотентним ключем "  
 f"'{idempotency_key_value}'. Різні колонки: {', '.join(map(lambda x: x[0], not_matching_columns))}"  
 )

З таким підходом друге використання того ж ідемпотентного ключа призводить до повної перевірки того, що вставлений запис є рівним запису, що існує в таблиці бази даних:

async with db_connection_pool.begin() as con:  
 await idempotent_insert(  
 con,  
 Insert(idempotent_table).values(idempotency_key='action', data='foo'),  
 idempotent_table.c.idempotency_key  
 )  

 # викличе IdempotencyConflictError, вказуючи, що записи відрізняються за колонкою data  
 await idempotent_insert(  
 con,  
 Insert(idempotent_table).values(idempotency_key='action', data='bar'),  
 idempotent_table.c.idempotency_key  
 )

Підводні камені

Це рішення не є панацеєю і має кілька підводних каменів, на які я хочу звернути увагу.

Альтернативне рішення: Проблему можна також вирішити за допомогою реалізації механізму узгодження, який перевіряє, що елемент таблиці для певного процесу А має правильну форму в базі даних. Однак я вважаю, що реалізація утиліти, яка перевіряє консистентність безпосередньо під час виконання, є простішою і швидше виявляє проблему, ніж реалізація узгодження.

Запропоноване рішення найкраще працює для таблиць, що використовуються лише для вставок. Як тільки в гру вступають модифікації, наприклад, оновлення, потрібно виключати оновлені колонки при порівнянні запису, що вставляється, з існуючим записом у таблиці.

Оригінал опубліковано на моєму блозі dnnsthnnr.com

Перекладено з: Idempotent database inserts: Getting it right

Leave a Reply

Your email address will not be published. Required fields are marked *