Pada Update Anomaly, selain Lost Updates (Single Anti-Dependency Cycle) juga ada Write Skew. Write Skew adalah anomaly yang terjadi ketika keputusan untuk mengubah data diambil dari pembacaan dan perubahan sebelumnya yang dilakukan secara serentak oleh lebih dari satu sesi sehingga antar sesi tidak mengetahuinya dan terjadi hal yang tidak diinginkan pada data yang disimpan. Ini juga disebut sebagai WAR (Write After Read) Dependency problems. Write Skew disebabkan oleh 2 hal, yaitu Disjoint Read & Predicate Read. Sebelumnya tulisan ini gw gabung dengan tulisan Lost Updates, tapi setelah gw pikir-pikir alasan yang gw tulis di tulisan itu kurang kuat sehingga gw putuskan untuk mengganti contohnya agar lebih masuk akal dan memisahkan bahasan tentang Write Skew secara lebih mendalam di sini. Agar lebih jelas kita langsung ke contohnya:
- Terdapat tabel “orders” untuk menyimpan data pesanan, dan tabel “delivery” untuk menyimpan data pengiriman;
- Satu pengiriman bisa terdapat lebih dari satu pesanan;
- Saat pesanan dalam perjalanan maka status pada orders adalah “shipping” dan status pada delivery adalah “on delivery”;
- Saat salah satu pesanan terkirim maka status pesanannya jadi “received”, sedangkan status pengiriman akan tetap “on delivery” hingga semua pesanan terkirim;
- Saat semua status pesanan sudah “received”, transaksi pesanan terakhir akan mengubah status pengiriman jadi “completed”;
- Saat status pengiriman sudah “completed” berarti semua pesanan statusnya “received”;
Tabel delivery
delivery_no | status | driver | version |
---|---|---|---|
999 | on delivery | juing | 1 |
Tabel orders
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | shipped | 999 | 1 |
ORD-321 | pisang | shipped | 999 | 1 |
Di sini kita memiliki satu pengiriman oleh driver juing dengan nomor pengiriman 999 yang sedang mengantarkan dua pesanan, yaitu apel dengan nomor pesanan ORD-123 dan pisang dengan nomor pesanan ORD-321. Sekarang kita cobain skenario Write Skew😎. Oh ya, kalau ingin mempraktekkan skenario berikut, matikan auto-commit pada tools yang digunakan ya.
Write Skew on Disjoint Read
Write Skew on Disjoint Read adalah Write Skew yang terjadi karena pembacaan satu kelompok data yang sama secara terpisah oleh lebih dari satu sesi, lalu saling mengubah salah satu data yang berbeda dan saling mengambil keputusan secara serentak. Kondisi ini juga disebut sebagai Item Anti-Dependency Cycles. Misalkan pengiriman kedua pesanan di atas dilakukan secara serentak oleh driver, kira-kira begini alurnya pada aplikasi:
Sesi A
BEGIN
;
SELECT *
FROM orders
WHERE
order_no = 'ORD-123'
;
UPDATE orders
SET status = 'received', version = version + 1
WHERE
order_no = 'ORD-123' AND
version = 1
;
Sesi A mengubah status data ORD-123 menjadi “received”.
Sesi B
BEGIN
;
SELECT *
FROM orders
WHERE
order_no = 'ORD-321'
;
UPDATE orders
SET status = 'received', version = version + 1
WHERE
order_no = 'ORD-321' AND
version = 1
;
Sesi B juga mengubah status ORD-321 menjadi “received”.
Sesi A
SELECT *
FROM orders
WHERE
delivery_no = 999
;
Lalu sesi A akan mengecek status pesanan lain terhadap nomor pengiriman yang sama untuk memutuskan apakah pengiriman ini sudah selesai semua atau belum. Karena perubahan sesi B belum di-commit, maka sesi A mendapati status ORD-321 masih “shipped”.
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | received | 999 | 2 |
ORD-321 | pisang | shipped | 999 | 1 |
Sesi B
SELECT *
FROM orders
WHERE
delivery_no = 999
;
Sesi B juga akan mengecek status pesanan lain terhadap nomor pengiriman yang sama. Karena perubahan pada sesi A juga belum di-commit, maka sesi B mendapati status ORD-123 masih “shipped”.
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | shipped | 999 | 1 |
ORD-321 | pisang | received | 999 | 2 |
Sesi A
COMMIT
;
Sesi B
COMMIT
;
Lalu sesi A dan sesi B commit berbarengan dan tidak melakukan update delivery status jadi “completed” karena sama-sama menemukan bahwa masih ada pesanan lain yang masih “shipped”. Hasilnya seperti ini:
Tabel orders
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | received | 999 | 2 |
ORD-321 | pisang | received | 999 | 2 |
Tabel delivery
delivery_no | status | driver | version |
---|---|---|---|
999 | on delivery | juing | 1 |
Data di atas tidak sesuai requirement karena semua pesanan sudah “received” tapi pengirimannya masih “on delivery”. Aplikasinya nge-bug😥. Optimistic Locking bukan pilihan yang tepat karena itu hanya mengunci data yang sedang diubah saja oleh sesi tersebut. Sedangkan di sini data yang diubah antar sesi adalah data yang berbeda. Lalu bagaimana solusinya?
Select for Update Nowait
Sebenarnya pakai Select for Update aja juga bisa, tapi kelemahannya adalah bakal jadi bottleneck terhadap transaksi pada sesi lain. Misalnya ada 10 sesi bersamaan, berarti hanya 1 yang diproses, 9 sisanya akan ngantri satu-persatu. Untungnya jaman sekarang ada opsi Nowait
. Select for Update Nowait adalah Pessimistic Locking yang mengunci data yang di-select agar tidak ada sesi lain yang bisa mengubah data tersebut hingga transaksinya selesai, jika ada sesi lain yang juga mengunci data tersebut maka akan langsung dibatalkan saat itu juga. Langsung aja kita coba.
Sesi A
BEGIN
;
SELECT *
FROM orders
WHERE
delivery_no = 999 FOR UPDATE NOWAIT
;
Alurnya harus diganti, kita akan mengunci semua data pesanan dengan nomor pengiriman 999 dari awal.
Sesi B
BEGIN
;
SELECT *
FROM orders
WHERE
delivery_no = 999 FOR UPDATE NOWAIT
;
Lalu sesi B juga melakukan hal yang sama. Di sini sesi B akan gagal transaksinya karena datanya udah dikunci oleh sesi A. Kita bisa mengulang transaksi pada sesi B secara otomatis atau menginfokan ke user untuk mencoba beberapa saat lagi karena pesanannya sedang dimodifikasi.
Sesi A
UPDATE orders
SET status = 'received', version = version + 1
WHERE
order_no = 'ORD-123' AND
version = 1
;
COMMIT
;
Sekarang sesi A bisa melanjutkan transaksinya hingga commit.
Sesi B
BEGIN
;
SELECT *
FROM orders
WHERE
delivery_no = 999 FOR UPDATE NOWAIT
;
UPDATE orders
SET status = 'received', version = version + 1
WHERE
order_no = 'ORD-321' AND
version = 1
;
SELECT *
FROM delivery
WHERE
delivery_no = 999 FOR UPDATE NOWAIT
;
UPDATE delivery
SET status = 'completed', version = version + 1
WHERE
delivery_no = 999 AND
version = 1
;
COMMIT
;
Setelah transaksi sesi A selesai barulah sesi B bisa mengulangi transaksinya dari awal. Setelah update status pesanan jadi “received” sesi B akan menemukan semua pesanan juga “received” sehingga sesi B akan mengubah status pengiriman jadi “completed” sesuai requirement. Dengan begini transaksinya sudah aman dari Write Skew😎.
Tabel orders
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | received | 999 | 2 |
ORD-321 | pisang | received | 999 | 2 |
Tabel delivery
delivery_no | status | driver | version |
---|---|---|---|
999 | completed | juing | 2 |
Write Skew on Predicate Read
Predicate adalah boolean condition pada where clause. Write Skew on Predicate Read adalah Write Skew yang terjadi karena proses perubahannya mengacu pada data berdasarkan suatu Predicate, lalu ada penambahan atau pengurangan data terkait Predicate tersebut oleh sesi lain setelah pembacaan data sehingga ada data yang terlewat saat pengambilan keputusan. Sebagian expert menyebutnya sebagai Phantom Write karena ada data “hantu” yang ga kehitung pada query sebelumnya👻. Kondisi ini juga dikenal dengan Anti-Dependency Cycles. Misalkan skenarionya begini:
- Driver sudah berhasil mengantarkan pesanan apel;
- Selanjutnya driver akan mengantarkan pesanan pisang;
- Di saat bersamaan, admin aplikasi boleh menambahkan pesanan yang tertinggal kepada driver tersebut dengan nomor pengiriman yang sama asalkan status pengirimannya belum “completed”;
- Jika pesanan tambahan itu berhasil masuk, maka setelah driver mengantarkan pesanan pisang delivery status pada pengiriman nomor 999 tetap “on delivery”, bukan “received” karena masih ada sisa tambahan satu pesanan tersebut;
- Jika pesanan tambahan itu ga berhasil masuk, maka setelah driver mengantarkan pesanan pisang pengiriman nomor 999 statusnya “completed” karena pesanan apel dan pisang statusnya “received” semua;
Tabel orders
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | received | 999 | 2 |
ORD-321 | pisang | shipped | 999 | 1 |
Tabel delivery
delivery_no | status | driver | version |
---|---|---|---|
999 | on delivery | juing | 1 |
Sesi A
BEGIN
;
SELECT *
FROM orders
WHERE
delivery_no = 999 FOR UPDATE NOWAIT
;
UPDATE orders
SET status = 'received', version = version + 1
WHERE
order_no = 'ORD-321' AND
version = 1
;
Sesi A melakukan Select for Update Nowait untuk mencegah Disjoint Read sebelumnya dan mengubah status pada ORD-321 menjadi “received”.
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | received | 999 | 2 |
ORD-321 | pisang | received | 999 | 2 |
Sesi B
BEGIN
;
SELECT *
FROM delivery
WHERE
delivery_no = 999
;
Di saat bersamaan sesi B mengecek pengiriman pada nomor 999. Ditemukan statusnya masih “on delivery”, itu artinya masih boleh nambah pesanan yang tertinggal sesuai requirement.
delivery_no | status | driver | version |
---|---|---|---|
999 | on delivery | juing | 1 |
Sesi A
SELECT *
FROM delivery
WHERE
delivery_no = 999 FOR UPDATE NOWAIT
;
UPDATE delivery
SET status = 'completed', version = version + 1
WHERE
delivery_no = 999 AND
version = 1
;
COMMIT
;
Berdasarkan hasil query sebelumnya, semua pesanan untuk nomor pengiriman 99 berarti sudah selesai. Selanjutnya sesi A mengganti status pengiriman jadi “completed” dan commit.
delivery_no | status | driver | version |
---|---|---|---|
999 | completed | juing | 2 |
Sesi B
INSERT INTO orders
VALUES ('ORD-456', 'jeruk', 'ordered', 999, 0)
;
COMMIT
;
Berdasarkan hasil query sebelumnya, sesi B mengira pengirimannya masih “on delivery” sehingga boleh menambahkan pesanan baru, yaitu jeruk untuk nomor pengiriman tersebut. Hasilnya status pengiriman jadi “completed” tapi masih ada satu pesanan yang statusnya bukan “received”. Ini tentu akan jadi masalah karena si driver merasa pesanannya sudah dikirim semua tapi ternyata menyisakan satu pesanan lagi😥.
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | received | 999 | 2 |
ORD-321 | pisang | received | 999 | 2 |
ORD-456 | jeruk | ordered | 999 | 0 |
Serializable Isolation
Select for Update ga bisa dipakai di sini karena hanya mengunci update saja, bukan insert. Serializable adalah level isolation tertinggi yang paling aman dari segala anomaly. Ini adalah raja terakhir secara teknis untuk mencegah anomaly yang membandel👑. Untuk penjelasan mengenai Serializable bisa dibaca lagi tulisan gw sebelumnya. Langsung aja kita coba skenario di atas pakai Serializable😎.
Sebelum memulai transaksi kita harus mengganti isolation level ke Serializable pada masing-masing sesi. Contohnya ada pada tulisan tentang Isolation, tinggal disesuaikan saja tergantung database yang digunakan.
Sesi A
BEGIN
;
SELECT *
FROM orders
WHERE
delivery_no = 999
;
UPDATE orders
SET status = 'received', version = version + 1
WHERE
order_no = 'ORD-321' AND
version = 1
;
Sesi A mengubah status ORD-321 menjadi “received”. Suffix FOR UPDATE
sudah tidak diperlukan lagi karena kita menggunakan Serializable.
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | received | 999 | 2 |
ORD-321 | pisang | received | 999 | 2 |
Sesi B
BEGIN
;
SELECT *
FROM delivery
WHERE
delivery_no = 999
;
Di saat bersamaan sesi B melihat status pada nomor pengiriman 999 masih “on delivery”.
delivery_no | status | driver | version |
---|---|---|---|
999 | on delivery | juing | 1 |
Sesi A
SELECT *
FROM delivery
WHERE
delivery_no = 999
;
UPDATE delivery
SET status = 'completed', version = version + 1
WHERE
delivery_no = 999 AND
version = 1
;
COMMIT
;
Berdasarkan hasil query sebelumnya, sesi A mendapati bahwa semua pesanan sudah “received” sehingga status pengiriman akan diubah jadi “completed”.
Sesi B
INSERT INTO orders
VALUES ('ORD-456', 'jeruk', 'ordered', 999, 0)
;
COMMIT
;
Berdasarkan hasil query sebelumnya, sesi B mendapati bahwa status pengiriman masih “on delivery” sehingga masih boleh menambahkan satu pesanan lagi.
Perlu diketahui, masing-masing database memiliki implementasi Serializable yang berbeda. Pada MySql yang menerapkan 2 Phase Locking, saat melakukan select orders pada sesi A otomatis akan dikunci datanya terhadap sesi lain. Begitu juga saat melakukan select delivery pada sesi B otomatis akan dikunci juga datanya terhadap sesi lain. Ketika sesi A mau mengubah delivery, maka perubahannya terkunci oleh sesi B dan harus menunggu sesi B selesai. Begitu juga sesi B yang mau menambah orders yang dikunci oleh sesi A sehingga terjadi deadlock. Penambahan orders oleh sesi B akan digagalkan oleh system dan penguncian data delivery oleh sesi B tadi akan terlepas. Lalu sesi A dapat melanjutkan transaksinya hingga commit tanpa hambatan.
Sedangkan Postgresql menerapkan Serializable Snapshot Isolation (SSI). SSI memonitor transaksi yang inkonsisten lewat Predicate yang digunakan. Proses monitoring ini tidak menghambat transaksi pada sesi lain. Jadi ketika ada konflik transaksinya tidak harus ngantri dan menunggu seperti pada MySql. Sesi yang berkonflik yang tercatat melakukan commit duluan akan selamat, sedangkan sesi yang berkonflik lainnya akan error dan harus mengulangi transaksi dari awal. Dalam kasus di atas kebetulan yang commit duluan adalah sesi A, jadi perubahan pada sesi A tercatat oleh system sedangkan penambahan orders pada sesi B akan digagalkan oleh system.
Hasil akhir data yang tersimpan seperti ini:
Tabel orders
order_no | product | status | delivery_no | version |
---|---|---|---|---|
ORD-123 | apel | received | 999 | 2 |
ORD-321 | pisang | received | 999 | 2 |
Tabel delivery
delivery_no | status | driver | version |
---|---|---|---|
999 | completed | juing | 2 |
Meskipun implementasi Serializable masing-masing database berbeda, tapi tujuannya sama. Keduanya sama-sama terbebas dari Write Skew😎.
Verdict
Itulah macam-macam Write Skew anomaly pada database. Write Skew merupakan anomaly paling kompleks dibanding anomaly lainnya. Write Skew on Disjoint Read terjadi karena ada data yang berubah setelah dibaca dan sebelum mengambil keputusan oleh lebih dari satu sesi. Write Skew on Disjoint Read bisa dicegah menggunakan Select for Update Nowait. Ini lebih baik dibandingkan Select for Update biasa karena akan langsung error tanpa harus disuruh nunggu dan mengantri sehingga dapat menghemat resource yang aktif di database. Sayangnya, ga semua database memiliki fitur Select for Update Nowait seperti MySql 5 ke bawah. Kalau masih pakai database yang belum support Nowait bisa langsung menggunakan Serializable atau Select for Update biasa dengan konsekuensi prosesnya lebih lambat. Namun sebagian besar database yang lebih modern seperti MySql 8 ke atas, Postgresql, dan Oracle sudah ada fitur ini. Kalau database lain gw kurang tau. Sedangkan Write Skew on Predicate Read terjadi karena ada data yang bertambah atau berkurang berdasarkan Predicate setelah dibaca dan sebelum mengambil keputusan. Untuk Write Skew on Predicate Read bisa dicegah menggunakan Serializable Isolation karena ini adalah raja terakhir yang menaklukkan segala jenis anomaly😎.