IlmHamroh
JavaScript Full-stack/26-qism. SQL va PostgreSQL49/64-dars25 daqiqa
Mundarija (31)

Tranzaksiya izolyatsiyasi: read phenomena va izolyatsiya darajalari

Qisqacha: Ikki tranzaksiya bir vaqtda ishlasa, biri ikkinchisining o'zgarishini qachon ko'rishini izolyatsiya darajasi belgilaydi. PostgreSQL'da uchta haqiqiy daraja bor. READ COMMITTED (sukut) — har so'rov yangi holatni ko'radi. REPEATABLE READ — tranzaksiya boshidagi "surat"ni ko'radi. SERIALIZABLE — natija xuddi tranzaksiyalar navbat bilan bajarilgandek bo'ladi, aks holda biri 40001 xatosi bilan yiqiladi. Sukut darajasida "lost update" jim sodir bo'ladi — buni darsda ko'zingiz bilan ko'rasiz.

Bu darsda

  • Besh xil "read phenomena" ni (dirty read, non-repeatable read, phantom, lost update, write skew) ikki sessiyali misol bilan tushuntira olasiz.
  • Postgres'dagi uch izolyatsiya darajasi nimani kafolatlashini va nimani kafolatlamasligini bilasiz.
  • BEGIN ISOLATION LEVEL … va SET TRANSACTION bilan darajani tanlaysiz.
  • Lost update'ni atomik UPDATE bilan oldini olasiz.
  • 40001 xatosi nima uchun "normal" ekanini va unga ilova qanday javob berishini tushunasiz.

Oldin bilishingiz kerak: Tranzaksiya asoslari, ACID, BASE, CAP va PACELC, UPDATE va DELETE xavfsiz.

1. Nega bu kerak?

Tranzaksiya asoslari darsida bitta tranzaksiyani ko'rdik: BEGIN, bir nechta o'zgarish, COMMIT — yo hammasi, yo hech biri. Bu ACID'dagi A (atomiklik). Endi savol boshqacha: bir vaqtda ikkita tranzaksiya ishlasa-chi?

Server bir soniyada yuzlab so'rovni bajaradi. Ular bir xil qatorlarga tegadi. Malika hamyonidagi 100 000 so'mga bir vaqtda ikki to'lov tushdi: 10 000 va 20 000. Ikki so'rov ham "balans 100 000" ni o'qidi. Biri 110 000 yozdi, ikkinchisi 120 000. Natijada 10 000 so'm hech qayerda yo'q — u jim yo'qoldi. Bazada xato ham chiqmadi.

Bu ACID'dagi I — izolyatsiya (isolation): parallel tranzaksiyalar bir-biriga qanchalik "aralasha oladi". To'liq izolyatsiya qimmat — tranzaksiyalar bir-birini kutadi yoki qayta urinadi. Shuning uchun SQL standarti bir nechta daraja beradi: siz tezlik va xavfsizlik orasida tanlaysiz.

Hayotiy o'xshatish: kutubxonadagi umumiy jurnal. Kimdir unga yozib turibdi. Siz jurnalni o'qiyapsiz. Siz yozilib turgan, hali tugallanmagan qatorni ko'rasizmi? Ikki marta varaqlaganingizda bir xil narsani ko'rasizmi? Ikki kishi bir vaqtda bitta qatorni o'chirib yozsa, kimniki qoladi? Izolyatsiya darajasi — shu savollarga javoblar to'plami.

Maslahat: Bu darsdagi har misolni o'zingiz takrorlang. Ikkita terminal oching va har birida psql bilan bitta bazaga ulaning (psql chuqur). Birinchisini A, ikkinchisini B deb ataymiz. Har qadamni chapdan o'ngga emas, aynan yozilgan tartibda bajaring.

2. Tajriba maydoni: ikki sessiya

2.1 Jadval

Hamma misollar uchun kichik jadval — ikki kishining hamyoni (summalar ming so'mda):

sql
CREATE TABLE hisob (
  id int PRIMARY KEY,
  egasi text NOT NULL,
  balans int NOT NULL
);
INSERT INTO hisob VALUES (1, 'Malika', 100), (2, 'Jasur', 100);

Har misoldan oldin jadvalni shu holatga qaytaramiz (o'chirib, qayta yaratamiz). Shunda har natija toza boshlanadi.

2.2 Natijani qanday o'qish kerak

Pastdagi natijalarni biz o'zimiz ishga tushirdik: PostgreSQL 17.6, ikkita haqiqiy psql sessiyasi. Natija qatorlarida A=> va B=> — qaysi terminalda nima yozilgani, ostida — o'sha terminal javobi. Kanon (PostgreSQL 18.6) xuddi shu tajribalarni Node'dagi ikki ulanish bilan bajargan va aynan shu natijalarni olgan (t49-izolyatsiya.txt) — bu mavzuda 17 va 18 farq qilmaydi.

Bitta muhim kuzatuv: agar B ning javobi uning buyrug'idan keyin darhol chiqmasa, demak u kutib qolgan. Javob keyinroq, boshqa sessiyaning qatoridan keyin paydo bo'ladi.

3. Besh xil "read phenomena"

Read phenomena (o'qish anomaliyalari) — parallel tranzaksiyalarda paydo bo'ladigan g'alati holatlar. SQL standarti ularning uchtasini nomlaydi: dirty read, non-repeatable read, phantom. Amalda yana ikkitasi muhim: lost update va write skew. Keling, har birini ko'zimiz bilan ko'ramiz.

3.1 Dirty read — tugallanmagan o'zgarishni o'qish

Dirty read ("iflos o'qish") — boshqa tranzaksiya hali COMMIT qilmagan o'zgarishni ko'rish. Xavfi: u tranzaksiya keyin ROLLBACK qilsa, siz hech qachon bo'lmagan qiymatga tayangan bo'lasiz.

Standartda eng past daraja — READ UNCOMMITTED — dirty read'ga ruxsat beradi. Postgres'da-chi?

sql
-- B
BEGIN;
UPDATE hisob SET balans = 0 WHERE id = 1;
-- A
BEGIN ISOLATION LEVEL READ UNCOMMITTED;
SELECT balans FROM hisob WHERE id = 1;
COMMIT;
-- B
ROLLBACK;

Natija:

text
B=> BEGIN;
B   BEGIN
B=> UPDATE hisob SET balans = 0 WHERE id = 1;
B   UPDATE 1
A=> BEGIN ISOLATION LEVEL READ UNCOMMITTED;
A   BEGIN
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A       100
A   (1 row)
A=> COMMIT;
A   COMMIT
B=> ROLLBACK;
B   ROLLBACK

A hatto READ UNCOMMITTED so'raganda ham 100 ni ko'rdi, 0 ni emas. Postgres bu darajani qabul qiladi, lekin uni READ COMMITTED kabi bajaradi. Demak Postgres'da dirty read umuman yo'q. Sababi — u qator versiyalarini saqlaydi: tugallanmagan o'zgarish yangi versiya bo'lib turadi, boshqalar esa eskisini ko'radi. Buni keyingi darsda, MVCC da ichkaridan ochamiz.

3.2 Non-repeatable read — bir qatorni ikki marta o'qish

Non-repeatable read ("takrorlanmaydigan o'qish") — bitta tranzaksiya ichida bir qatorni ikki marta o'qisangiz, har xil qiymat chiqadi. Orada boshqa tranzaksiya uni o'zgartirib, COMMIT qilgan.

sql
-- A
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balans FROM hisob WHERE id = 1;
-- B (autocommit — o'zi darhol COMMIT bo'ladi)
UPDATE hisob SET balans = 50 WHERE id = 1;
-- A
SELECT balans FROM hisob WHERE id = 1;
COMMIT;

Natija:

text
A=> BEGIN ISOLATION LEVEL READ COMMITTED;
A   BEGIN
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A       100
A   (1 row)
B=> UPDATE hisob SET balans = 50 WHERE id = 1;
B   UPDATE 1
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A        50
A   (1 row)
A=> COMMIT;
A   COMMIT

Bitta tranzaksiya ichida 100, keyin 50. READ COMMITTED'da har so'rov o'zi boshlangan paytdagi tasdiqlangan holatni ko'radi. Ko'pincha bu yaxshi — siz eng yangi ma'lumot bilan ishlaysiz. Lekin hisobot tuzayotgan bo'lsangiz-chi? Birinchi qatorda "Malika: 100", oxirida "jami: 150" — hisobot o'zi bilan zid chiqadi.

Endi xuddi shu tajriba, faqat REPEATABLE READ bilan:

sql
-- A
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balans FROM hisob WHERE id = 1;
-- B
UPDATE hisob SET balans = 50 WHERE id = 1;
-- A
SELECT balans FROM hisob WHERE id = 1;
COMMIT;

Natija:

text
A=> BEGIN ISOLATION LEVEL REPEATABLE READ;
A   BEGIN
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A       100
A   (1 row)
B=> UPDATE hisob SET balans = 50 WHERE id = 1;
B   UPDATE 1
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A       100
A   (1 row)
A=> COMMIT;
A   COMMIT

Ikkala o'qish ham 100. B'ning o'zgarishi bazaga yozildi — lekin A uni ko'rmaydi. REPEATABLE READ'da tranzaksiya bitta snapshot (qotgan surat) bilan ishlaydi: bazaning bir lahzadagi tasdiqlangan holati. Tranzaksiya oxirigacha shu suratga qaraydi.

3.3 Phantom — paydo bo'ladigan "arvoh" qatorlar

Phantom ("arvoh") — non-repeatable read'ning to'plam ko'rinishi. Siz bir shart bilan qatorlarni sanadingiz. Orada boshqa tranzaksiya shu shartga mos yangi qator qo'shdi. Qayta sanasangiz — son boshqacha.

sql
-- A
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT count(*) FROM hisob WHERE balans >= 100;
-- B
INSERT INTO hisob VALUES (3, 'Dilnoza', 300);
-- A
SELECT count(*) FROM hisob WHERE balans >= 100;
COMMIT;

Natija:

text
A=> BEGIN ISOLATION LEVEL READ COMMITTED;
A   BEGIN
A=> SELECT count(*) FROM hisob WHERE balans >= 100;
A    count
A   -------
A        2
A   (1 row)
B=> INSERT INTO hisob VALUES (3, 'Dilnoza', 300);
B   INSERT 0 1
A=> SELECT count(*) FROM hisob WHERE balans >= 100;
A    count
A   -------
A        3
A   (1 row)
A=> COMMIT;
A   COMMIT

READ COMMITTED: 2 → 3. Xuddi shu tajribani REPEATABLE READ bilan bajarganimizda ikkala count ham 2 chiqdi (kanon natijasi ham: phantom (REPEATABLE READ): 2 → 2).

Bu yerda Postgres standartdan qattiqroq. SQL standarti REPEATABLE READ'da phantom'ga ruxsat beradi. Postgres'da esa snapshot butun bazaga tegishli, yangi qatorlar ham unga kirmaydi. Bu fakt Postgres hujjatidagi jadvalda "Allowed, but not in PG" deb yozilgan.

Tekshirib ko'ring: READ COMMITTED'da bitta SELECT bajarilayotganda (masalan 3 soniya davom etadigan katta so'rov) boshqa tranzaksiya qator o'zgartirib, COMMIT qildi. Shu SELECT natijasida yangi qiymat ko'rinadimi?

Javob

Yo'q. READ COMMITTED'da snapshot har so'rov boshida olinadi va so'rov oxirigacha o'zgarmaydi. Shuning uchun bitta SELECT doim o'zi bilan mos natija beradi — 3 soniyaning yarmida qiymat almashib qolmaydi. Yangi qiymatni faqat keyingi so'rov ko'radi. Non-repeatable read — so'rovlar orasidagi farq, bitta so'rov ichidagi emas.

3.4 Lost update — yo'qolgan yangilanish

Eng xavflisi va eng ko'p uchraydigani. Lost update ("yo'qolgan yangilanish") — ikki tranzaksiya bitta qiymatni o'qiydi, ilova kodida yangisini hisoblaydi va yozadi. Ikkinchisi birinchisining natijasini ustidan yozib yuboradi.

"Nega bu kerak?" bo'limidagi voqeani bajaramiz: A Malika balansiga 10 qo'shmoqchi, B — 20. Kutilgan natija: 130. Qadamlar tartibi:

Qadam A sessiya B sessiya Natija
1 BEGIN ISOLATION LEVEL READ COMMITTED; xuddi shu ikkalasi tranzaksiyada
2 SELECT balans FROM hisob WHERE id = 1; A: 100
3 xuddi shu SELECT B: 100
4 UPDATE hisob SET balans = 110 WHERE id = 1; (ilova 100 + 10 ni hisobladi) A qatorni qulfladi
5 UPDATE hisob SET balans = 120 WHERE id = 1; (100 + 20) B kutadi
6 COMMIT; B ning UPDATE'i o'tadi
7 COMMIT; balans — ?

Oxirgi qatordagi savolga javobni psql transkripti beradi:

text
A=> BEGIN ISOLATION LEVEL READ COMMITTED;
A   BEGIN
B=> BEGIN ISOLATION LEVEL READ COMMITTED;
B   BEGIN
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A       100
A   (1 row)
B=> SELECT balans FROM hisob WHERE id = 1;
B    balans
B   --------
B       100
B   (1 row)
A=> UPDATE hisob SET balans = 110 WHERE id = 1;
A   UPDATE 1
B=> UPDATE hisob SET balans = 120 WHERE id = 1;
A=> COMMIT;
A   COMMIT
B   UPDATE 1
B=> COMMIT;
B   COMMIT
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A       120
A   (1 row)

Natija 120. A qo'shgan 10 yo'qoldi — hech qanday xatosiz. Kanon ham xuddi shuni oldi: kutilgan 130, natija 120; B: yozildi.

Diqqat qiling: B'ning UPDATEi javobsiz qoldi va UPDATE 1 faqat A'ning COMMITidan keyin chiqdi. B kutdi. A o'zgartirayotgan qatorni Postgres qulflaydi va boshqa yozuvchi navbatda turadi. Lekin A tugagach, B o'zining 120 ini yozdi. U 100 ni o'qigan paytda hisoblagan qiymatni yozdi — yangi 110 ga qaramasdan.

sequenceDiagram
  participant A
  participant DB as Postgres
  participant B
  A->>DB: SELECT balans → 100
  B->>DB: SELECT balans → 100
  A->>DB: UPDATE balans = 110
  B->>DB: UPDATE balans = 120 (kutadi)
  A->>DB: COMMIT
  DB-->>B: qulf bo'shadi, UPDATE 1
  B->>DB: COMMIT → 120, A ning 10 i yo'q

Endi REPEATABLE READ bilan:

sql
-- A va B
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- qolgan qadamlar — yuqoridagidek

Natija:

text
A=> UPDATE hisob SET balans = 110 WHERE id = 1;
A   UPDATE 1
B=> UPDATE hisob SET balans = 120 WHERE id = 1;
A=> COMMIT;
A   COMMIT
B   ERROR:  could not serialize access due to concurrent update
B=> COMMIT;
B   ROLLBACK
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A       110
A   (1 row)

(Boshidagi BEGIN va ikkala SELECT — avvalgidek, 100; joyni tejash uchun ularni tushirdik.)

Endi B yiqildi: could not serialize access due to concurrent update — "parallel yangilanish tufayli kirishni ketma-ketlashtirib bo'lmadi". Ya'ni: "sen o'qigan qatorni sendan keyin boshqa birov o'zgartirdi; sen eskirgan surat asosida yozyapsan". Xato kodi — 40001 (serialization_failure). B'ning COMMITi esa ROLLBACK ga aylandi: xatodan keyin tranzaksiya faqat bekor bo'lishi mumkin.

Balans 110 — noto'g'ri, lekin ochiq noto'g'ri. B bilmay qolmadi, u xato oldi. Endi ilova butun tranzaksiyani boshidan qaytarsa (yangi snapshot, yangi SELECT → 110), natija 130 bo'ladi. Qayta urinishni Deadlock va serialization failure darsida to'liq yozamiz.

3.5 Lost update'ning eng sodda davosi: atomik UPDATE

Ko'p hollarda daraja almashtirish shart emas. Muammo — o'qish va yozish ikki alohida qadamda, orada ilova hisoblaydi. Hisoblashni bazaning o'ziga bersak-chi?

sql
-- A va B
BEGIN;
-- A
UPDATE hisob SET balans = balans + 10 WHERE id = 1;
-- B
UPDATE hisob SET balans = balans + 20 WHERE id = 1;
-- A
COMMIT;
-- B
COMMIT;

Natija:

text
A=> BEGIN;
A   BEGIN
B=> BEGIN;
B   BEGIN
A=> UPDATE hisob SET balans = balans + 10 WHERE id = 1;
A   UPDATE 1
B=> UPDATE hisob SET balans = balans + 20 WHERE id = 1;
A=> COMMIT;
A   COMMIT
B   UPDATE 1
B=> COMMIT;
B   COMMIT
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A       130
A   (1 row)

130 — to'g'ri, va sukut READ COMMITTED'da. Nega? READ COMMITTED'da qulf kutib qolgan UPDATE qulf bo'shagach qatorning eng yangi versiyasini qayta o'qiydi (110) va balans + 20 ni shunga qo'llaydi. "O'qi → hisobla → yoz" ning hammasi bitta buyruq ichida, qator qulfi ostida bo'ldi.

Qoida: qiymatni o'zgartirishda uni ilovaga olib chiqmang — SET x = x + 1, SET qoldiq = qoldiq - 1 WHERE qoldiq > 0. Bu naqshni Race condition'larni hal qilish amaliyotida ombor qoldig'i bilan ishlatamiz.

Tekshirib ko'ring: READ COMMITTED'da quyidagi ikki usuldan qaysi biri lost update'dan himoyalangan: (a) SELECT balans → JS'da balans + 10 → UPDATE … SET balans = $1; (b) UPDATE … SET balans = balans + $1?

Javob

Faqat (b). (a) da o'qish va yozish orasida boshqa tranzaksiya qiymatni o'zgartira oladi, (a) esa eski qiymat asosida hisoblangan sonni yozadi. (b) da yangi qiymat qator qulfi ostida, eng yangi versiyadan hisoblanadi. (a) ni himoyalashning boshqa yo'llari ham bor: REPEATABLE READ + qayta urinish, SELECT … FOR UPDATE (Qulflar) yoki versiya ustuni (Optimistic concurrency).

3.6 Write skew — har biri to'g'ri, birgalikda noto'g'ri

Eng nozik anomaliya. Kasalxonada qoida bor: kechasi kamida bitta shifokor navbatda bo'lishi shart. Hozir ikkalasi navbatda: Aliyev va Karimova. Ikkalasi ham charchagan va bir vaqtda ilovada "uyga ketaman" tugmasini bosdi. Ilova har biri uchun tekshiradi: "navbatda 2 kishi bormi? Bor — demak bittasi keta oladi".

sql
CREATE TABLE navbatchi (
  shifokor text PRIMARY KEY,
  navbatda boolean NOT NULL
);
INSERT INTO navbatchi VALUES ('Aliyev', true), ('Karimova', true);

REPEATABLE READ'da qadamlar:

Qadam A sessiya (Aliyev) B sessiya (Karimova) Natija
1 BEGIN ISOLATION LEVEL REPEATABLE READ; xuddi shu ikkalasi tranzaksiyada
2 SELECT count(*) FROM navbatchi WHERE navbatda; xuddi shu ikkalasi: 2
3 UPDATE navbatchi SET navbatda = false WHERE shifokor = 'Aliyev'; "2 kishi bor, men ketaman"
4 xuddi shu, 'Karimova' uchun B kutmaydi — boshqa qator
5 COMMIT; COMMIT; navbatda — ?

Natija:

text
A=> SELECT count(*) FROM navbatchi WHERE navbatda;
A    count
A   -------
A        2
A   (1 row)
B=> SELECT count(*) FROM navbatchi WHERE navbatda;
B    count
B   -------
B        2
B   (1 row)
A=> UPDATE navbatchi SET navbatda = false WHERE shifokor = 'Aliyev';
A   UPDATE 1
B=> UPDATE navbatchi SET navbatda = false WHERE shifokor = 'Karimova';
B   UPDATE 1
A=> COMMIT;
A   COMMIT
B=> COMMIT;
B   COMMIT
A=> SELECT count(*) FROM navbatchi WHERE navbatda;
A    count
A   -------
A        0
A   (1 row)

Navbatda 0 kishi. Ikkala tranzaksiya ham COMMIT bo'ldi, xato yo'q. Har biri o'z suratida to'g'ri qaror qildi. Ular har xil qatorlarni o'zgartirdi — shuning uchun qator qulflari to'qnashmadi va REPEATABLE READ'ning "parallel yangilanish" tekshiruvi ishlamadi. Lekin birgalikda qoida buzildi. Bu — write skew ("qiyshiq yozish").

Endi SERIALIZABLE bilan (qadamlar aynan bir xil):

text
A=> UPDATE navbatchi SET navbatda = false WHERE shifokor = 'Aliyev';
A   UPDATE 1
B=> UPDATE navbatchi SET navbatda = false WHERE shifokor = 'Karimova';
B   UPDATE 1
A=> COMMIT;
A   COMMIT
B=> COMMIT;
B   ERROR:  could not serialize access due to read/write dependencies among transactions
B   DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
B   HINT:  The transaction might succeed if retried.
A=> SELECT count(*) FROM navbatchi WHERE navbatda;
A    count
A   -------
A        1
A   (1 row)

B COMMIT paytida yiqildi: "tranzaksiyalar orasidagi o'qish/yozish bog'liqliklari tufayli ketma-ketlashtirib bo'lmadi". Kod yana 40001. HINT ochiq aytadi: "qayta urinsangiz, muvaffaqiyatli bo'lishi mumkin". Qayta urinishda B yangi suratda count = 1 ni ko'radi va ketmaydi. Navbatda bitta shifokor qoldi — qoida saqlandi.

SERIALIZABLE qanday sezdi? Postgres (9.1 dan beri) SSI (Serializable Snapshot Isolation) usulini ishlatadi. U tranzaksiyalar nimani o'qiganini ham kuzatadi. A o'qigan qatorlarni B o'zgartirdi, B o'qiganlarini — A. Bu "aylana" bog'liqlik: ularni hech qanday ketma-ket tartibda bajarib bunday natija chiqmaydi. Shuning uchun biri bekor qilinadi.

4. Darajalar jadvali

Endi hammasini bir joyga yig'amiz. Postgres'da (hujjat: "Transaction Isolation", postgresql.org/docs/18):

Daraja Dirty read Non-repeatable, phantom Lost update, write skew
READ UNCOMMITTED yo'q (RC kabi) bor bor
READ COMMITTED (sukut) yo'q bor bor
REPEATABLE READ yo'q yo'q lost update — 40001; write skew — bor
SERIALIZABLE yo'q yo'q yo'q — 40001 bilan

Standart va Postgres orasidagi ikki farq — esda tutish uchun:

  • Standartda READ UNCOMMITTED dirty read'ga ruxsat beradi; Postgres'da u READ COMMITTED bilan bir xil.
  • Standartda REPEATABLE READ phantom'ga ruxsat beradi; Postgres'da phantom yo'q.

Boshqa bazalar (MySQL, SQL Server) bu nomlar ostida boshqacha xatti-harakat qiladi. Masalan, MySQL InnoDB'ning sukut darajasi — REPEATABLE READ, lekin qulflash usuli boshqa. Bu kursda sinalmagan — faqat Postgres haqida gapiramiz va boshqa bazaga o'tsangiz, uning hujjatini o'qing.

Qayerda nima ishlatiladi:

  • READ COMMITTED — ilovalarning 90 %i shu bilan yashaydi. Atomik UPDATE, unique cheklovlar va kerak bo'lsa qatorni qulflash bilan to'ldiriladi.
  • REPEATABLE READ — bir-biriga mos bir nechta o'qish: hisobot, eksport, zaxira (masalan, pg_dump bitta surat bilan ishlaydi).
  • SERIALIZABLE — murakkab qoidalar ("kamida bitta navbatchi", "byudjetdan oshmasin") va ularni qo'lda qulflash qiyin bo'lganda. Narxi: ilova 40001 ni ushlab, qayta urinishi shart.

5. Darajani qanday tanlash

5.1 Uch yo'l

sql
-- 1) BEGIN bilan birga
BEGIN ISOLATION LEVEL SERIALIZABLE;

-- 2) BEGIN dan keyin, birinchi so'rovdan OLDIN
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- 3) Sessiya yoki baza uchun sukut
SET default_transaction_isolation = 'repeatable read';

Uchinchi yo'l butun ilovani o'zgartiradi — odatda kerak emas. Sukutni joyida qoldirib, kerakli tranzaksiyalarda darajani aniq ko'rsatgan yaxshi.

5.2 Snapshot qachon olinadi

Quyidagi tajriba bir nozik joyni ko'rsatadi:

sql
-- A
SHOW default_transaction_isolation;
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SHOW transaction_isolation;
-- B
UPDATE hisob SET balans = 70 WHERE id = 1;
-- A
SELECT balans FROM hisob WHERE id = 1;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
ROLLBACK;

Natija:

text
A=> SHOW default_transaction_isolation;
A    default_transaction_isolation
A   -------------------------------
A    read committed
A   (1 row)
A=> BEGIN;
A   BEGIN
A=> SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
A   SET
A=> SHOW transaction_isolation;
A    transaction_isolation
A   -----------------------
A    repeatable read
A   (1 row)
B=> UPDATE hisob SET balans = 70 WHERE id = 1;
B   UPDATE 1
A=> SELECT balans FROM hisob WHERE id = 1;
A    balans
A   --------
A        70
A   (1 row)
A=> SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
A   ERROR:  SET TRANSACTION ISOLATION LEVEL must be called before any query

Uch narsaga qarang:

  1. Sukut — read committed.
  2. A REPEATABLE READ'da, lekin B'ning 70 ini ko'rdi. Chunki snapshot BEGIN paytida emas, tranzaksiyadagi birinchi so'rovda olinadi (SHOW hisobga kirmaydi). B undan oldin COMMIT qilgan edi.
  3. Birinchi so'rovdan keyin darajani o'zgartirib bo'lmaydi: "SET TRANSACTION ISOLATION LEVEL har qanday so'rovdan oldin chaqirilishi kerak".

Diqqat: Node'da pg bilan darajani o'rnatish — client.query("BEGIN ISOLATION LEVEL SERIALIZABLE"). Bu buyruq va keyingi hamma so'rovlar bitta client'da bo'lishi shart. pool.query("BEGIN …") qilsangiz, keyingi so'rov boshqa ulanishga tushishi mumkin — bu Tranzaksiya asoslari darsidagi pool.query('BEGIN') tuzog'i.

6. Node'da: ikki ulanish bilan tajriba

psql dagi qadamlarni Node'da ham takrorlash mumkin. Ikki sessiya — ikki alohida pg.Client. Kanon skriptining lost update qismi (qisqartirilgan; to'liq varianti — 2-mashq yechimida):

js
const daraja = "READ COMMITTED";
await A.query(`BEGIN ISOLATION LEVEL ${daraja}`);
await B.query(`BEGIN ISOLATION LEVEL ${daraja}`);
const sql = "SELECT balans FROM t49_hisob WHERE id = 1";
const a = (await A.query(sql)).rows[0].balans;
const b = (await B.query(sql)).rows[0].balans;
const yoz = "UPDATE t49_hisob SET balans = $1 WHERE id = 1";
await A.query(yoz, [a + 10]);
// B kutadi — await qilmaymiz, aks holda skript osilib qoladi
const bYozuv = B.query(yoz, [b + 20]);
await new Promise((r) => setTimeout(r, 200));
await A.query("COMMIT");
await bYozuv;
await B.query("COMMIT");

Muhim joy — bYozuv ni darhol await qilmaslik. B qulf kutyapti, qulfni esa A ning COMMITi bo'shatadi. await B.query(...) desak, kod A ga hech qachon yetib bormaydi — skript abadiy kutadi. Bu Node'dagi "o'zimizni o'zimiz qulflash" — bazada emas, bizning kodimizda.

Kanon natijasi (t49-izolyatsiya.txt, PostgreSQL 18.6, Node 24.21.0, pg 8.23.1):

text
1) dirty read (READ UNCOMMITTED): A ko'rdi [ { balans: 100 } ]
2) non-repeatable read (READ COMMITTED): 100 → 50
2) non-repeatable read (REPEATABLE READ): 100 → 100
3) phantom (READ COMMITTED): 2 → 3
3) phantom (REPEATABLE READ): 2 → 2
4) lost update (READ COMMITTED): kutilgan 130, natija 120; B: yozildi
4) lost update (REPEATABLE READ): kutilgan 130, natija 110; B: XATO 40001 could not serialize access due to concurrent update
4) atomik balans = balans + x (READ COMMITTED): natija 130
5) write skew (REPEATABLE READ): navbatda qoldi 0; A commit: ok; B commit: ok
5) write skew (SERIALIZABLE): navbatda qoldi 1; A commit: ok; B commit: XATO 40001 could not serialize access due to read/write dependencies among transactions
sukut: { default_transaction_isolation: 'read committed' }

psql dagi natijalarimiz bilan bir xil. Xato obyektida e.code === "40001" — ilova shu kod bo'yicha qayta urinishni hal qiladi.

7. Node qadami

Bu darsdan Amaliyot: race condition'larni hal qilish gacha bo'lgan oltita dars vazifalar-api ning bitta muammosini hal qiladi: parallel yozuvlar. Natija — bitta holat (holat 53) va bitta commit, u amaliyot darsida. Bu darsning ulushi — qaror: Postgres ombori (PgOmbori) qaysi izolyatsiya darajasida ishlaydi?

Kanon qarori: sukut READ COMMITTED qoladi, anomaliyalarga qarshi esa aniq vositalar:

Anomaliya vazifalar da Vosita Qaysi darsda
hisoblagich (izohlar_soni) trigger ichida atomik SET izohlar_soni = izohlar_soni + 1 Denormalizatsiya
ikki oynadan bitta vazifani tahrirlash (lost update) versiya ustuni, WHERE versiya = $n Optimistic concurrency
bir egasining parallel yozuvlari egasi qatorini FOR UPDATE bilan qulflash Qulflar
bir xil matnli ikki vazifa noyoblik cheklovi — ikkinchisi xato oladi, API 409 Deadlock va serialization failure

Nega SERIALIZABLE emas? U har yozuvchi joyda 40001 ni ushlab qayta urinishni talab qiladi, vazifalar da esa "birgalikda" qoidalar (write skew'ga moyil) deyarli yo'q. Bitta istisno kanon o'lchovida chiqdi. 013-sxemadagi tartib trigger'i yangi vazifaga max(tartib) + 1 ni beradi. Bu — "o'qi → hisobla → yoz", xuddi lost update. READ COMMITTED'da parallel tranzaksiyalar bir-birining hali COMMIT bo'lmagan qatorini ko'rmaydi. Kanon o'lchovi (olchov/raqobat-olchov.ts, holat 48, PostgreSQL 18.6):

text
(1) 50 parallel yarat: har xil tartib 8/50, eng katta 8

50 ta yangi vazifa — atigi 8 xil tartib raqami: ko'pi bir xil raqam oldi. Davosi SERIALIZABLE emas, bir egasining yozuvlarini navbatga qo'yuvchi qulf — Qulflar darsining «Node qadami». Qulfdan keyin xuddi shu o'lchov 50/50 beradi.

8. Ko'p uchraydigan xatolar

8.1 "Tranzaksiya ichidaman — demak xavfsizman"

BEGIN … COMMIT atomiklikni beradi, izolyatsiyani emas. READ COMMITTED'dagi tranzaksiya lost update'dan himoya qilmaydi. Atomik UPDATE, versiya ustuni yoki qat'iyroq daraja.

8.2 SERIALIZABLE'ni yoqib, qayta urinishni yozmaslik

Natijada foydalanuvchilar tez-tez could not serialize access … xatosini ko'radi. SERIALIZABLE — "xatosiz" emas, "noto'g'ri natija o'rniga xato" degani. 40001 ni ushlab, butun tranzaksiyani boshidan qaytaring (Deadlock va serialization failure).

8.3 Xatodan keyin faqat oxirgi so'rovni qaytarish

40001 dan keyin tranzaksiya bekor. Undagi hamma SELECT natijalari ham eskirgan. Faqat yiqilgan UPDATE ni emas — BEGIN dan boshlab hammasini qaytaring.

8.4 Darajani birinchi so'rovdan keyin o'zgartirish

ERROR: SET TRANSACTION ISOLATION LEVEL must be called before any query. BEGIN ISOLATION LEVEL … — bitta buyruqda.

8.5 Boshqa bazaning qoidasini Postgres'ga ko'chirish

"REPEATABLE READ'da phantom bor" (standart) yoki "READ UNCOMMITTED dirty read beradi" — Postgres'da bunday emas. Har da'voni shu bazaning hujjatidan tekshiring.

9. Mashqlar

1-mashq (oson): Natijani ayting

Sukut darajasida A tranzaksiya ochdi va SELECT balans FROM hisob WHERE id = 2 qildi (100). B autocommit bilan UPDATE hisob SET balans = 0 WHERE id = 2 qildi. A yana o'qidi. A nimani ko'radi? Agar A BEGIN ISOLATION LEVEL REPEATABLE READ bilan boshlagan bo'lsa-chi?

Yechim

Sukut — READ COMMITTED: ikkinchi o'qish 0 (non-repeatable read). REPEATABLE READ'da — 100: A birinchi so'rovdagi suratni ko'radi. Ikkala holatda ham B'ning o'zgarishi bazada bor; farq faqat A ning nimani ko'rishida.

2-mashq (o'rta): Lost update'ni Node'da takrorlang

Ikki pg.Client bilan lost update tajribasini yozing va uni READ COMMITTED hamda REPEATABLE READ'da bajaring. Har daraja uchun bitta qator chiqarsin: READ COMMITTED: B yozildi, balans 120. Jadval m49_hisob ni skript o'zi yaratsin. Eslatma: B ning UPDATEini darhol await qilmang («Node'da: ikki ulanish bilan tajriba» bo'limi); pg xatosida e.code bor (node-postgres asoslari).

Yechim
js
import pg from "pg";

const url = process.env.DATABASE_URL;
const A = new pg.Client({ connectionString: url });
const B = new pg.Client({ connectionString: url });
await A.connect();
await B.connect();
const kut = (ms) => new Promise((r) => setTimeout(r, ms));

async function tayyorla() {
  await A.query("DROP TABLE IF EXISTS m49_hisob");
  await A.query(
    `CREATE TABLE m49_hisob
     (id int PRIMARY KEY, balans int NOT NULL)`);
  await A.query("INSERT INTO m49_hisob VALUES (1, 100)");
}

async function sina(daraja) {
  await tayyorla();
  await A.query(`BEGIN ISOLATION LEVEL ${daraja}`);
  await B.query(`BEGIN ISOLATION LEVEL ${daraja}`);
  const sql = "SELECT balans FROM m49_hisob WHERE id = 1";
  const a = (await A.query(sql)).rows[0].balans;
  const b = (await B.query(sql)).rows[0].balans;
  const yoz = "UPDATE m49_hisob SET balans = $1 WHERE id = 1";
  await A.query(yoz, [a + 10]);
  // B kutib qoladi: qatorni A qulflagan
  const bYozdi = B.query(yoz, [b + 20]).then(
    () => "yozildi",
    (e) => `xato ${e.code}`,
  );
  await kut(200);
  await A.query("COMMIT");
  const bNatija = await bYozdi;
  await B.query(bNatija === "yozildi" ? "COMMIT" : "ROLLBACK");
  const oxir = (await A.query(sql)).rows[0].balans;
  console.log(`${daraja}: B ${bNatija}, balans ${oxir}`);
}

await sina("READ COMMITTED");
await sina("REPEATABLE READ");
await A.query("DROP TABLE m49_hisob");
await A.end();
await B.end();

Biz ishga tushirdik (PostgreSQL 17.6, Node 24.21.0, pg 8.23.1):

text
READ COMMITTED: B yozildi, balans 120
REPEATABLE READ: B xato 40001, balans 110

.then(ok, xato) — B ning natijasini satrga aylantiradi, shunda await xato tashlamaydi. kut(200) — B haqiqatan kutishga o'tishi uchun vaqt. Xatodan keyin COMMIT emas, ROLLBACK — aslida COMMIT ham ROLLBACK qilardi, lekin niyatni ochiq yozgan yaxshi.

3-mashq (qiyin): Write skew va bitta qayta urinish

Navbatchi shifokorlar misolini (m49_navbatchi) Node'da SERIALIZABLE bilan yozing. ketishgaUrin(client, shifokor) funksiyasi: navbatda kamida 2 kishi bo'lsa, shu shifokorni navbatdan chiqarsin va "ketdi" qaytarsin, aks holda "qoldi (yolg'iz)". B ning COMMITi 40001 bilan yiqilsa — tranzaksiyani boshidan bir marta qaytaring. Oxirida navbatdagilar sonini chiqaring. Eslatma: SERIALIZABLE xatosi COMMIT paytida ham chiqishi mumkin (write skew bo'limi).

Yechim
js
import pg from "pg";

const url = process.env.DATABASE_URL;
const A = new pg.Client({ connectionString: url });
const B = new pg.Client({ connectionString: url });
await A.connect();
await B.connect();

await A.query("DROP TABLE IF EXISTS m49_navbatchi");
await A.query(`CREATE TABLE m49_navbatchi (
  shifokor text PRIMARY KEY, navbatda boolean NOT NULL)`);
await A.query(`INSERT INTO m49_navbatchi
  VALUES ('Aliyev', true), ('Karimova', true)`);

// Bitta "uyga ketish" urinishi: kamida 2 kishi bo'lsa, ketadi
async function ketishgaUrin(c, shifokor) {
  const { rows } = await c.query(
    "SELECT count(*)::int AS n FROM m49_navbatchi WHERE navbatda");
  if (rows[0].n < 2) return "qoldi (yolg'iz)";
  await c.query(
    `UPDATE m49_navbatchi SET navbatda = false
     WHERE shifokor = $1`,
    [shifokor]);
  return "ketdi";
}

const boshla = "BEGIN ISOLATION LEVEL SERIALIZABLE";
await A.query(boshla);
await B.query(boshla);
const a = await ketishgaUrin(A, "Aliyev");
const b = await ketishgaUrin(B, "Karimova");
await A.query("COMMIT");
console.log(`Aliyev: ${a}, COMMIT ok`);
try {
  await B.query("COMMIT");
  console.log(`Karimova: ${b}, COMMIT ok`);
} catch (e) {
  console.log(`Karimova: COMMIT xato ${e.code}`);
  // Butun tranzaksiya qaytadan — yangi snapshot bilan
  await B.query(boshla);
  const qayta = await ketishgaUrin(B, "Karimova");
  await B.query("COMMIT");
  console.log(`Karimova (qayta urinish): ${qayta}`);
}
const { rows } = await A.query(
  "SELECT count(*)::int AS n FROM m49_navbatchi WHERE navbatda");
console.log(`navbatda: ${rows[0].n}`);
await A.query("DROP TABLE m49_navbatchi");
await A.end();
await B.end();

Biz ishga tushirdik (PostgreSQL 17.6):

text
Aliyev: ketdi, COMMIT ok
Karimova: COMMIT xato 40001
Karimova (qayta urinish): qoldi (yolg'iz)
navbatda: 1

Qayta urinishda B yangi snapshot oldi, count = 1 ni ko'rdi va qoldi. Agar faqat COMMIT ni qaytarganingizda — tranzaksiya allaqachon bekor bo'lgani uchun hech narsa yozilmasdi. count(*)::int — pg count ni satr qaytaradi (node-postgres asoslari), ::int uni songa aylantiradi. Haqiqiy loyihada bitta qayta urinish yetmaydi — bir necha urinish va pauza bilan wrapper kerak.

10. Real ishda

  • To'lov va balans (Click, Payme, bank ilovalari): pul harakati — atomik UPDATE yoki qat'iy daraja + qayta urinish. Lost update bu yerda to'g'ridan-to'g'ri pul yo'qotish.
  • Hisobotlar: buxgalteriya eksporti REPEATABLE READ'da — hamma jadvallar bitta lahzadagi holatda.
  • ORM'lar: Prisma'da $transaction(..., { isolationLevel: "Serializable" }) kabi parametr bor — ichkarida xuddi shu BEGIN ISOLATION LEVEL (27-qism).
  • Intervyu: "Izolyatsiya darajalarini sanang", "Lost update nima va qanday oldini olasiz?", "Write skew'ga misol keltiring" — backend intervyularining klassik savollari.

Xulosa

  • Izolyatsiya darajasi — parallel tranzaksiya boshqasining o'zgarishini qachon ko'rishi.
  • Postgres'da dirty read yo'q; READ UNCOMMITTED = READ COMMITTED.
  • READ COMMITTED (sukut): har so'rov yangi surat — non-repeatable read, phantom va lost update bo'ladi.
  • REPEATABLE READ: tranzaksiya birinchi so'rovidagi surat; lost update o'rniga 40001; write skew hali bor.
  • SERIALIZABLE: natija navbat bilan bajarilgandek; to'qnashuvda 40001 — ilova butun tranzaksiyani qaytaradi.
  • Ko'p hollarda eng arzon himoya — atomik UPDATE … SET x = x + 1.

Keyingi dars: MVCC: Postgres ichida versiyalar — snapshot qanday ishlashini va nega o'quvchi yozuvchini kutmasligini qator versiyalari darajasida ko'ramiz.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!
Tranzaksiya izolyatsiyasi: read phenomena va izolyatsiya darajalari — IlmHamroh