Write Skew Merusak Invariant pada Snapshot Isolation

Snapshot isolation memberi setiap transaksi tampilan stabil atas data yang sudah commit. Properti ini menghilangkan banyak anomali akibat nilai yang berubah di tengah transaksi. Namun, properti tersebut tidak otomatis membuat setiap eksekusi concurrent setara dengan suatu urutan serial.

Write skew memperlihatkan celah itu dengan jelas. Dua transaksi membaca state valid yang sama, mengambil keputusan dari state tersebut, lalu menulis row yang berbeda. Karena write set keduanya tidak beririsan, kedua commit dapat berhasil meski hasil gabungannya melanggar aturan yang tetap dipenuhi oleh masing-masing transaksi saat berdiri sendiri.

Masalah muncul ketika correctness bergantung pada predicate atau relasi yang mencakup lebih banyak data daripada yang ditulis oleh satu transaksi.

Snapshot valid dapat menghasilkan state yang tidak valid

Bayangkan tabel jadwal on-call dengan dua engineer. Aturan operasional mensyaratkan setidaknya satu engineer tetap on-call.

engineer | on_call
---------+--------
A        | true
B        | true

Dua request datang secara concurrent. Transaksi 1 menangani A yang keluar dari rotasi. Transaksi 2 menangani B yang keluar. Masing-masing memeriksa bahwa masih ada engineer lain sebelum mengubah row miliknya.

Urutan sederhananya:

T1 membaca A=true, B=true
T2 membaca A=true, B=true

T1 menulis A=false
T2 menulis B=false

T1 commit
T2 commit

Masing-masing transaksi melihat state yang memenuhi invariant. Masing-masing hanya mengubah satu row. State akhirnya menjadi:

A=false
B=false

Invariant kini tidak terpenuhi.

Tidak ada konflik write-write langsung. T1 menulis row A; T2 menulis row B. Mekanisme concurrency control yang hanya membatalkan transaksi saat berebut row yang sama tidak memiliki konflik untuk ditolak pada kasus ini.

Snapshot isolation melindungi version, bukan predicate sembarang

Implementasi snapshot isolation lazim memakai multiversion concurrency control. Transaksi membaca snapshot yang terkait dengan satu titik logis dalam riwayat database. Commit concurrent tidak mengganti version yang terlihat di dalam snapshot tersebut.

Saat commit, database dapat menolak transaksi bila transaksi concurrent lain sudah mengubah row dalam write set-nya. Pada implementasi dengan perilaku first-committer-wins, mekanisme ini mencegah lost update untuk write yang beririsan.

Write skew melewati batas yang berbeda. Keputusan dapat bergantung pada predicate seperti:

count(on_call = true) >= 1

Tidak ada transaksi yang menulis predicate itu sebagai satu objek. Masing-masing menulis row berbeda yang nilainya ikut menentukan predicate. Karena itu, deteksi konflik pada level row tidak selalu merepresentasikan dependency yang penting bagi aplikasi.

Pola serupa muncul pada aturan seperti:

setidaknya satu approver aktif tersedia
booking ruangan tidak boleh overlap
kapasitas teralokasi harus tetap di bawah batas
tepat satu record boleh memegang satu peran logis

Schema dapat memiliki banyak row sementara aturan bisnis berlaku atas satu himpunan row.

Anomali membutuhkan siklus read-write dependency

Contoh on-call dapat dinyatakan sebagai dependency antartransaksi.

T1 membaca B=true, kemudian T2 mengubah B menjadi false. Dari arah sebaliknya, T2 membaca A=true, kemudian T1 mengubah A menjadi false.

Secara konseptual:

T1 --read B sebelum write T2--> T2
T2 --read A sebelum write T1--> T1

Dependency yang saling berlawanan itu membentuk siklus. Tidak ada eksekusi serial yang menghasilkan observasi dan write yang sama: jika T1 selesai sepenuhnya sebelum T2, maka T2 akan melihat A=false; jika T2 lebih dahulu, T1 akan melihat B=false.

Di sinilah perbedaan antara snapshot yang konsisten secara internal dan eksekusi yang serializable. Snapshot isolation dapat memberikan yang pertama sambil tetap mengizinkan history yang gagal memenuhi yang kedua.

Lock pada row yang diubah masih dapat kurang luas

Perbaikan yang tampak intuitif adalah mengunci setiap row sebelum mengubahnya:

SELECT * FROM engineers
WHERE id = :engineer_id
FOR UPDATE;

Jika T1 mengunci A dan T2 mengunci B, keduanya tetap tidak saling menunggu. Objek yang dilindungi masih terpisah, sementara invariant mencakup kedua row.

Desain locking harus mencakup data yang menentukan keputusan. Untuk himpunan on-call yang kecil, transaksi dapat mengunci semua row yang berpartisipasi dalam invariant sebelum melakukan pemeriksaan:

SELECT id, on_call
FROM engineers
WHERE team_id = :team_id
FOR UPDATE;

Perubahan concurrent pada himpunan on-call team yang sama kemudian terserialisasi melalui lock tersebut.

Pendekatan ini dapat efektif, tetapi cakupan lock sangat penting. Jika row baru dapat muncul dan mengubah predicate, mengunci row yang saat ini dikembalikan saja belum tentu melindungi dari phantom, kecuali database dan isolation mode menyediakan predicate locking atau range locking yang sesuai.

Guard row dapat mengubah predicate tersebar menjadi satu titik konflik

Sebagian invariant dapat direpresentasikan oleh row stabil yang wajib di-update atau di-lock oleh setiap transaksi terkait.

Contohnya:

team_guard
----------
team_id = 42
revision = 17

Sebelum mengubah keanggotaan on-call, setiap transaksi mengunci guard row untuk team 42. Aplikasi lalu memeriksa row anggota dan menerapkan perubahan selama common lock tersebut masih dipegang.

T1 mengunci guard(42)
T2 menunggu guard(42)
T1 memeriksa invariant, menulis A=false, commit
T2 mendapat guard(42), melihat state terbaru, menolak B=false

Guard tidak harus menyimpan seluruh derived state. Fungsinya adalah menciptakan contention secara sengaja untuk operasi yang harus diurutkan bersama.

Teknik ini menukar sebagian concurrency dengan batas correctness yang lebih sederhana. Pendekatan tersebut cocok saat scope yang dilindungi dapat dipartisi secara alami, misalnya per account, team, tenant, atau inventory bucket. Satu guard global akan menserialisasi pekerjaan yang tidak terkait dan dapat menjadi bottleneck throughput.

Serializable isolation dapat menolak eksekusi berbahaya

Serializable isolation menargetkan perilaku transaksi yang sudah commit agar setara dengan suatu urutan serial, walau database menjalankannya secara concurrent.

Implementasinya berbeda-beda. Sebagian memakai strict two-phase locking. Sebagian melacak read-write dependency dan membatalkan transaksi ketika struktur dependency berbahaya terbentuk. Ada pula yang menggabungkan beberapa mekanisme.

Pada kasus on-call, eksekusi serializable tidak dapat melakukan commit pada kedua transaksi dengan observasi seperti sebelumnya. Satu transaksi harus secara efektif mendahului yang lain. Transaksi berikutnya akan melihat perubahan pertama atau dibatalkan lalu di-retry terhadap state yang lebih baru.

Aplikasi tetap perlu menangani serialization failure dengan benar. Abort dari database merupakan bagian dari kontrak concurrency control, bukan kejadian korupsi yang tidak lazim. Retry harus menjalankan ulang seluruh transaksi agar semua keputusan dihitung kembali dari transactional view yang baru.

Constraint paling kuat ketika invariant cocok dengan schema

Database constraint sering lebih baik daripada pemeriksaan di sisi aplikasi bila aturan dapat dinyatakan secara langsung dan atomik.

Unique constraint adalah contoh umum. Jika dua transaksi mencoba mengambil unique key yang sama, database memiliki objek konkret untuk menerapkan eksklusivitas. Foreign key dan check constraint mencakup kelas aturan lain.

Invariant lintas row lebih sulit. CHECK constraint pada level row umumnya tidak dapat menegaskan aggregate atas sibling row sembarang. Redesign schema kadang dapat membuat invariant menjadi lokal: simpan slot langka sebagai unique row, representasikan kapasitas sebagai unit yang dapat diklaim, atau pindahkan ownership ke row yang dilindungi aturan uniqueness.

Desain seperti itu mengubah predicate implisit menjadi state yang dapat langsung menjadi titik konflik di database. Ketergantungan pada lock luas atau isolation level tinggi dapat berkurang, tetapi representasinya harus tetap cocok dengan aturan domain tanpa menciptakan sumber kebenaran kedua yang tidak tersinkronisasi.

Retry tidak memperbaiki transaksi yang memang diizinkan commit

Retry berguna ketika concurrency control melaporkan konflik. Retry tidak memperbaiki write skew jika isolation level yang dipilih menganggap kedua transaksi valid dan melakukan commit pada keduanya.

Loop seperti:

begin
baca row saat ini
periksa invariant
tulis satu row
commit

dapat berjalan berulang kali dengan benar secara prosedural dan tetap mengizinkan anomali jika tidak ada commit yang gagal.

Mekanisme koreksi harus lebih dahulu membuat history concurrent yang tidak aman menjadi terblokir atau abort. Mekanisme itu dapat berupa serializable isolation, common lock, guard row, database constraint, atau primitive concurrency lain yang terikat pada invariant. Kebijakan retry berada setelah mekanisme tersebut.

Retry juga memerlukan jumlah percobaan terbatas dan backoff yang masuk akal saat contention tinggi. Invariant yang sangat hot dapat mengubah serialization failure menjadi retry storm.

Test perlu jadwal concurrent, bukan hanya kasus sequential

Test sequential dapat memastikan aturan bisnis tetapi melewatkan defect concurrency sepenuhnya.

Regression test yang berguna mengoordinasikan dua transaksi database independen agar keduanya membaca state awal sebelum salah satunya commit. Test kemudian melepaskan kedua write dan memeriksa hasilnya.

Pada implementasi yang terlindungi, hasil yang diterima harus tetap menjaga invariant:

T1 commit, T2 menolak atau abort
atau
T2 commit, T1 menolak atau abort

Test yang sekadar menjalankan dua goroutine atau thread tanpa sinkronisasi mungkin jarang mengenai interleaving kritis. Barrier atau latch membuat jadwal cukup reproducible untuk memicu race tersebut secara sengaja.

Telemetry produksi juga sebaiknya membedakan serialization abort, lock wait, deadlock, dan penolakan invariant pada level aplikasi. Kenaikan abort rate dapat menandakan invariant yang sebelumnya terpartisi dengan baik telah berubah menjadi hotspot contention.

Isolation level merupakan bagian dari data model

Sebuah transaksi dapat memiliki logika lokal yang benar tetapi tetap menghasilkan state global yang tidak valid ketika jaminan isolation tidak mencakup dependency di balik logika tersebut.

Snapshot isolation bernilai karena snapshot stabil dan pemeriksaan write conflict menghilangkan kelas bug concurrency yang penting. Batasnya sama penting dengan kekuatannya. Saat keputusan membaca sekumpulan row dan transaksi concurrent dapat mengubah anggota berbeda dari kumpulan itu, write skew perlu diperlakukan sebagai risiko eksplisit.

Perbaikan yang tahan lama adalah membuat invariant terlihat oleh concurrency control: nyatakan sebagai constraint bila memungkinkan, buat shared conflict point bila praktis, lock seluruh scope keputusan bila sesuai, atau gunakan serializable isolation dan tangani abort-nya. Correctness kemudian bertumpu pada mekanisme yang merepresentasikan dependency sebenarnya, bukan pada timing request concurrent.