Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «ACID - consistency». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**ACID Consistency (узгодженість)** - гарантія, що кожна транзакція переводить БД з одного валідного стану в інший, дотримуючись усіх обмежень. ```sql CREATE TABLE accounts ( balance NUMERIC(12,2) NOT NULL CHECK (balance >= 0) ); BEGIN; UPDATE accounts SET balance = balance - 150 WHERE owner = 'Alice'; -- спроба -50 COMMIT; -- авто-ROLLBACK: CHECK порушено, баланс залишається 100 ``` **Ключове:** DB-рівневі constraints перевіряють узгодженість атомарно на COMMIT. Валідація в додатку програє в гонці.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**ACID Consistency (узгодженість)** - гарантія бази даних, що кожна транзакція переводить дані з одного валідного стану в інший, дотримуючись усіх правил і обмежень. ## Теорія ### TL;DR - Уяви правило банківського сейфа: або обидва рахунки оновлюються повністю під час переказу, або нічого не змінюється. - БД перевіряє всі constraints у момент COMMIT. Будь-яке порушення - автоматичний ROLLBACK. - Consistency = твої бізнес-правила (balance >= 0). Isolation - окрема концепція, захист від одночасних змін. - Правила переноси на рівень БД (CHECK, FK, тригери). Логіку в додатку - тільки для складних мульти-БД сценаріїв. ### Швидкий приклад ```sql -- PostgreSQL: enforce balance >= 0 на рівні БД CREATE TABLE accounts ( id SERIAL PRIMARY KEY, owner TEXT NOT NULL, balance NUMERIC(12,2) NOT NULL CHECK (balance >= 0) ); INSERT INTO accounts (owner, balance) VALUES ('Alice', 100); BEGIN; UPDATE accounts SET balance = balance - 150 WHERE owner = 'Alice'; -- спроба -50 COMMIT; -- FAILS: CHECK constraint, авто-ROLLBACK SELECT balance FROM accounts WHERE owner = 'Alice'; -- все ще 100 ``` Баланс Alice залишається 100. Транзакція відкотилась автоматично, бо `-50` порушує CHECK. Жодного проміжного стану, жодного негативного балансу. ### Ключова відмінність Consistency у ACID відрізняється від звичайної валідації тим, що прив'язана до транзакції атомарно. Перевірка в коді додатку може програти в гонці або впасти між читанням і записом - це TOCTOU (time-of-check-to-time-of-update). Обмеження на рівні БД блокують COMMIT доти, поки всі правила не виконані по всій транзакції. Це виключає ситуації, де часткове оновлення ламає інваріанти системи. ### Коли що використовувати - **Balance >= 0, NOT NULL, формати полів** - CHECK constraints у БД (швидко, безпечно при конкурентному доступі) - **Правила між таблицями (загальний борг <= кредитний ліміт)** - тригери або stored procedures - **Кілька БД або зовнішні системи** - saga pattern або 2PC у додатку - **High-scale NoSQL** - логіка в додатку + eventual consistency (компроміс BASE) ### Як PostgreSQL перевіряє узгодженість PostgreSQL перевіряє узгодженість у момент коміту транзакції. БД блокує рядки, що змінились, перевіряє всі constraints і тригери проти фінального набору записів і тільки якщо все пройшло - записує в WAL. Якщо будь-який CHECK, FK або UNIQUE впав, вся транзакція відкочується через MVCC. Перевірка constraints відбувається після всіх записів, але до коміту. Відкладені constraints (`SET CONSTRAINTS ALL DEFERRED`) чекають до COMMIT - це потрібно для кругових FK або багатокрокових переказів, де проміжний стан тимчасово невалідний. Після краша WAL replay відновлює закомічені транзакції в порядку коміту і відкочує незавершені. Жоден частковий стан не пережить відновлення. ### Типові помилки 1. **Валідація тільки в коді додатку** ```sql -- WRONG: додаток читає баланс, потім окремо оновлює IF (SELECT balance FROM accounts WHERE id = 1) > 100 THEN UPDATE accounts SET balance = balance - 100 WHERE id = 1; END IF; -- Конкурентна транзакція може списати кошти між перевіркою і UPDATE ``` Дві транзакції можуть пройти перевірку одночасно і обидві списати гроші. CHECK constraint у БД закриває це, перевіряючи в момент коміту з правильними блокуваннями. 2. **READ COMMITTED там, де потрібна строга узгодженість** READ COMMITTED допускає non-repeatable reads. Транзакція може прочитати той самий рядок двічі і отримати різні значення, якщо між читаннями зафіксувалась інша транзакція. Для інваріантів, що охоплюють кілька читань (наприклад, перевірка унікальності email перед вставкою), використовуй SERIALIZABLE. 3. **Immediate constraints при багатокрокових операціях** ```sql -- WRONG для двоетапного переказу: SET CONSTRAINTS ALL IMMEDIATE; -- Дебет спрацює CHECK до того, як відбудеться кредит. Впаде на проміжному стані. -- RIGHT: SET CONSTRAINTS ALL DEFERRED; -- CHECK перевіряється тільки на COMMIT, після всіх записів. ``` 4. **Тригери без BEFORE контексту** `RAISE EXCEPTION` в AFTER тригері в деяких сценаріях спрацьовує запізно. Для логіки обмежень використовуй BEFORE тригери, щоб відкат стався до запису рядка. ### Де зустрічається на практиці - PostgreSQL + Rails: ActiveRecord огортає моделі в транзакції і використовує CHECK/FK. Shopify побудований на цьому підході. - CockroachDB: distributed SQL забезпечує serializable consistency через total order broadcast. - Google Spanner: TrueTime API для зовнішньої узгодженості в гео-розподілених транзакціях. - MySQL InnoDB: gap locks блокують phantom reads у REPEATABLE READ режимі. - Компроміс NoSQL: DynamoDB і Cassandra обирають доступність (BASE). ACID потрібен для грошей та інвентарю, eventual consistency підходить для логів і стрічок новин. ### Питання на співбесіді **Q:** Яка різниця між Consistency і Isolation у ACID? **A:** Consistency перевіряє бізнес-правила (інваріанти). Isolation приховує конкурентні зміни від поточної транзакції. Consistency падає через погані дані, Isolation - через гонки. **Q:** Як працює consistency в розподіленій системі типу Google Spanner? **A:** Spanner використовує TrueTime для зовнішньої узгодженості. Коміти отримують мітки часу так, що якщо Tx1 завершився до початку Tx2, Tx2 гарантовано бачить результати Tx1. 2PC не потрібен. **Q:** Чому транзакція може пройти всі constraints локально, але впасти на коміті? **A:** Відкладені constraints і тригери перевіряються після всіх записів, але до коміту. Конкурентна транзакція може видалити FK-ціль або порушити UNIQUE у вікні між твоїми записами і комітом. **Q:** Розкажи, як WAL replay забезпечує узгодженість після краша в PostgreSQL. **A:** WAL зберігає повні образи транзакцій. При відновленні replay застосовує закомічені транзакції в порядку коміту і відкочує незавершені. Жоден частковий стан не може пережити цей процес. **Q:** Що таке TOCTOU-гонка і чому вона ламає узгодженість на рівні додатку? **A:** TOCTOU - проміжок між читанням значення і записом на його основі. Конкурентна транзакція може змінити значення в цьому проміжку. DB constraints усувають це, перевіряючи в момент коміту під блокуваннями. ## Приклади ### Базовий: constraint блокує невалідну транзакцію ```sql CREATE TABLE accounts ( id SERIAL PRIMARY KEY, owner TEXT NOT NULL, balance NUMERIC(12,2) NOT NULL CHECK (balance >= 0) ); INSERT INTO accounts (owner, balance) VALUES ('Alice', 100), ('Bob', 10); BEGIN; UPDATE accounts SET balance = balance - 150 WHERE owner = 'Alice'; -- -50 COMMIT; -- ERROR: new row violates check constraint "accounts_balance_check" -- Баланс Alice все ще 100 ``` Транзакція ніколи не закомітилась. PostgreSQL знайшов порушення обмеження і відкотив усе автоматично. Код додатку нічого не робив. ### Середній: атомарний переказ коштів ```sql BEGIN; UPDATE accounts SET balance = balance - 100 WHERE owner = 'Alice' RETURNING balance; -- 0 після цього UPDATE accounts SET balance = balance + 100 WHERE owner = 'Bob' RETURNING balance; -- 110 після цього COMMIT; -- Результат: Alice = 0, Bob = 110 -- Якщо будь-який UPDATE впав, обидва відкочуються ``` Це паттерн, який Stripe використовує для коригування балансів разом з idempotency keys для безпечних повторних спроб. Або обидва записи проходять, або жоден. Ніколи не буде стану, де Alice втратила гроші, а Bob їх не отримав. ### Складний: багаторівнева узгодженість з тригерами ```sql -- Оптимістичне блокування через версіонування CREATE TABLE wallets ( id SERIAL PRIMARY KEY, balance NUMERIC CHECK (balance >= 0), version INT DEFAULT 0 ); CREATE FUNCTION check_version() RETURNS TRIGGER AS $$ BEGIN IF NEW.version != OLD.version THEN RAISE EXCEPTION 'Concurrent update detected'; END IF; NEW.version := OLD.version + 1; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER version_check BEFORE UPDATE ON wallets FOR EACH ROW EXECUTE FUNCTION check_version(); ``` Якщо дві транзакції прочитали version = 0 і обидві намагаються оновити, одна впаде на перевірці версії. Якщо інша намагається списати більше, ніж є на балансі, CHECK зупинить її. Два незалежних шари: тригер версії блокує конкурентні записи, CHECK блокує невалідний стан. Я бачив, як розробники прибирали CHECK, думаючи що тригер вистачить. Це не так. Тригер закриває гонки, CHECK закриває погані дані. Потрібні обидва.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.