Problem
İki dəstək əməkdaşı eyni anda eyni hesabın balansını açır. Hər ikisi 100 oxuyur, biri 30 əlavə edir, digəri 20 çıxır və hər ikisi nəticəni yazır. Zamanlamadan asılı olaraq son balans 130 və ya 80 olur, düzgün 110 isə heç vaxt. Hər transaction tək-tək düzgün idi, birlikdə isə update itirdilər. İsolation səviyyələri bu növbələşmələrdən hansını müşahidə edə biləcəyinizə dair bazanın müqaviləsidir.
Anomaliyalar sadə dildə
- Dirty read: başqa transaction-ın yazdığı, amma commit etmədiyi datanı oxumaq. PostgreSQL bunu heç bir səviyyədə icazə vermir.
- Non-repeatable read: bir transaction daxilində sətri iki dəfə oxuyursunuz və arada başqa transaction dəyişikliyi commit etdiyi üçün fərqli dəyər alırsınız.
- Phantom read: eyni aralıq sorğusunu iki dəfə işlədirsiniz və kimsə uyğun sətirlər əlavə etdiyi və ya sildiyi üçün sətir çoxluğu fərqlənir.
- Lost update: iki transaction eyni dəyəri oxuyur, yenisini hesablayır və sonrakı yazı əvvəlkini görmədən üzərinə yazır.
- Write skew: iki transaction üst-üstə düşən datanı oxuyur, hər biri oxuduğuna əsasən fərqli sətri yeniləyir və birlikdə invariantı pozurlar. Klassik misal: iki həkim hər ikisi digərinin növbədə qaldığını görüb növbədən çıxır.
PostgreSQL hər səviyyədə nə edir
PostgreSQL multi-version concurrency control (MVCC) istifadə edir: oxuyanlar yazanları, yazanlar oxuyanları bloklamır. Standart adlar bu davranışlara uyğundur:
| Səviyyə | Snapshot | Non-repeatable read | Phantom | Write skew |
|---|---|---|---|---|
| READ COMMITTED (default) | hər statement üçün | mümkündür | mümkündür | mümkündür |
| REPEATABLE READ | hər transaction üçün | qarşısı alınır | praktikada qarşısı alınır | mümkündür |
| SERIALIZABLE | transaction + konflikt izləmə | qarşısı alınır | qarşısı alınır | qarşısı alınır |
READ COMMITTED hər statement-ə commit olunmuş datanın təzə snapshot-unu verir. Bir transaction daxilində iki eyni SELECT fərqli nəticə qaytara bilər. Bu, bir çox request-response handler-ləri üçün kifayətdir, amma read-modify-write dövrlərinin səssizcə update itirdiyi səviyyə məhz budur.
REPEATABLE READ bütün transaction üçün ilk statement-də götürülən tək snapshot istifadə edir. Bu snapshot isolation olduğu üçün SQL standartı bu səviyyədə phantom-lara icazə versə də, PostgreSQL-də phantom görünmür. Transaction-ınız snapshot-dan sonra paralel transaction-ın dəyişdiyi sətri yeniləməyə çalışsa, PostgreSQL sizinkini serialization failure ilə (SQLSTATE 40001) dayandırır. Bütün anomaliyaların qarşısını almır: write skew hələ mümkündür.
SERIALIZABLE Serializable Snapshot Isolation (SSI) əlavə edir. PostgreSQL paralel transaction-lar arasında oxu-yazı asılılıqlarını izləyir və heç bir ardıcıl sırada baş verə bilməyən nümunə aşkar etdikdə birini dayandırır. Tətbiq 40001 (deadlock üçün 40P01) almağa hazır olmalı və bütün transaction-ı təkrar etməlidir. Yalnız uğursuz statement-i təkrarlamaq səhvdir.
Lost update-in qarşısını almaq
Variant 1: update-i SQL-də atomar edin ki, Java-da read-modify-write olmasın:
UPDATE account SET balance = balance + 30 WHERE id = 7;Variant 2: oxuduğunuz sətri SELECT ... FOR UPDATE ilə kilidləyin (pessimistic locking):
BEGIN;SELECT balance FROM account WHERE id = 7 FOR UPDATE;-- tətbiqdə hesablayınUPDATE account SET balance = 110 WHERE id = 7;COMMIT;Eyni SELECT ... FOR UPDATE-i işlədən ikinci transaction birincinin commit etməsini gözləyir, sonra READ COMMITTED altında yeni dəyəri görür. Bu transaction-ları qısa saxlayın və deadlock-dan qaçmaq üçün sətirləri həmişə eyni sırada kilidləyin. FOR UPDATE SKIP LOCKED job queue-lar üçün faydalıdır: worker-lər başqalarının kilidlədiyi sətirləri keçir.
Variant 3: version sütunu ilə optimistic locking. JPA-da:
@Entityclass Account { @Id Long id; BigDecimal balance; @Version long version;}Hibernate update-ə where id = ? and version = ? əlavə edir və versiyanı artırır. Heç bir sətir uyğun gəlmirsə, başqası sətri dəyişib və Spring ObjectOptimisticLockingFailureException atır. Onu məntiqli sərhəddə tutun, yenidən yükləyib təkrarlayın və ya müştəriyə 409 Conflict qaytarın. Optimistic locking az rəqabətli data üçün uyğundur, çünki istifadəçi düşünərkən heç nə kilidlənmir; güclü rəqabətdə çoxlu təkrar yaradır və pessimistic kilid daha sadədir.
Spring-də səviyyə seçimi
@Transactional(isolation = Isolation.REPEATABLE_READ)public Report buildReport() { /* bir-biri ilə uyğun olmalı bir neçə oxu */ }Hesabat kimi bir-biri ilə uyğun olmalı çoxsorğulu oxumalar üçün REPEATABLE READ istifadə edin. İnvariant bir neçə sətri əhatə edirsə və onu constraint ya da tək kilidli sətir ilə ifadə edə bilmirsinizsə, SERIALIZABLE seçin və çağırışı retry mexanizminə bükün. Bir çox invariant üçün unique, check və ya exclusion constraint yüksək isolation səviyyəsindən həm sadə, həm də güclüdür.
Tipik səhvlər
- Təkcə
@Transactional-ın lost update-in qarşısını aldığına inanmaq. READ COMMITTED-də almır. - SERIALIZABLE seçib retry-ı unutmaq; belə olduqda ilk serialization failure istifadəçiyə çatır.
- Uğursuz statement-i dayandırılmış transaction daxilində təkrarlamaq, yeni transaction başlatmaq əvəzinə.
- Başqa servisə HTTP çağırışı zamanı transaction-ı (və kilidləri) açıq saxlamaq.
- Hər bazanın PostgreSQL kimi davrandığını fərz etmək. MySQL InnoDB-nin default-u fərqli kilid davranışı ilə REPEATABLE READ-dir.
Güzəştlər
Yüksək isolation adi halda oxumaları yavaşlatmır, daha çox dayandırılmış transaction və retry kodu deməkdir. Kilidlər proqnozlaşdırıla bilən davranış verir, amma paralelliyi azaldır və deadlock yarada bilər. Konfliktlər nadirdirsə optimistic yoxlamalar yaxşı miqyaslanır. Həqiqətən sahib olduğunuz invariantı qoruyan ən zəif mexanizmi seçin və bunu sübut edən paralel test yazın.
Paralel testlə sübut etmək
İsolation barədə düşüncə yalnız onu sınadıqda dəyərlidir. İki thread-in eyni sətri eyni anda yenilədiyi test yazın: hər biri balansı oxuyur, CountDownLatch ilə birlikdə başlayır, yeni dəyəri yazır. Qoruma olmadıqda nəticə səhv olacaq; @Version, FOR UPDATE və ya atomar UPDATE əlavə etdikdən sonra isə ya düzgün cəm, ya da gözlənilən ObjectOptimisticLockingFailureException almalısınız.
Bu testi real PostgreSQL ilə işlədin. H2 kimi alternativlər kilidləmə və snapshot davranışında fərqlənir, ona görə də sizi yanlış əminliyə sala bilər. Testi saxlayın: kimsə sonradan atomar UPDATE-i "sadələşdirib" read-modify-write-a qaytarsa, test bunu tutacaq.
Hansı səviyyəni nə vaxt seçməli
Praktik başlanğıc nöqtəsi belədir. Adi CRUD handler-lərdə READ COMMITTED saxlayın, amma dəyişdirilən entity-lərə @Version əlavə edin. Bir neçə sorğunun eyni anlıq görüntüdən oxumalı olduğu hesabatlarda REPEATABLE READ və readOnly = true istifadə edin. Çox sətirli invariantda əvvəlcə constraint ilə ifadə etməyə çalışın; alınmırsa, SERIALIZABLE və retry seçin. Hər seçimi komanda üçün sənədləşdirin, çünki səviyyənin səbəbi sonradan unudulur və "sadələşdirmə" adı ilə geri qaytarıla bilər.
Yoxlama siyahısı
- Default-u (READ COMMITTED) və nəyə icazə verdiyini bilin.
- Mümkün olduqda read-modify-write-ı atomar SQL ilə əvəz edin.
- Bir neçə istifadəçinin redaktə etdiyi entity-lər üçün
@Versionvə yaFOR UPDATEistifadə edin. 40001və40P01zamanı bütün transaction-ı təkrarlayın.- İnvariantlar üçün verilənlər bazası constraint-lərinə üstünlük verin.
Növbəti addım
Proxy-nin transaction sərhədlərini necə təyin etdiyini Spring transaction tələləri məqaləsində təkrar edin. Retry yalnız əməliyyatlar təkrarlana bildikdə təhlükəsizdir, bax: idempotency pattern-ləri. Sonra anlayışınızı bacarıq qiymətləndirməsi ilə yoxlayın.