Jika pada tulisan Isolation pada ACID kita membahas tentang Read Phenomena, maka kali ini pembahasannya tentang Update Anomaly. Read Phenomena adalah fenomena selisih pembacaan data pada sebagian data pada suatu sesi karena ada perubahan oleh sesi lain secara bersamaan. Sedangkan Update Anomaly adalah fenomena selisih hasil perubahan data yang dilakukan pada suatu sesi karena sesi lain juga melakukan commit perubahan pada data yang sama secara bersamaan sehingga hasil perubahannya tumpang-tindih. Secara umum Update Anomaly terbagi 2, yaitu Write Skew dan Lost Updates. Kita akan memakai contoh data berikut:
Table fruit
id | name | stock | expiring_date |
---|---|---|---|
1 | Apel | 20 | 2023-01-31 |
2 | Pisang | 1 | 2023-01-30 |
Write Skew
Write Skew artinya terjadi perubahan data yang sama pada kolom yang berbeda secara serentak oleh lebih dari satu sesi sehingga mendapatkan hasil perubahan yang tidak sesuai dengan command update yang dieksekusi. Contohnya seperti berikut:
Sesi A
BEGIN;
SELECT *
FROM fruit
WHERE id = 1;
Sesi A dimulai dengan melakukan selection pada fruit dengan id = 1 yaitu Apel dengan stok 20 buah.
id | name | stock | expiring_date |
---|---|---|---|
1 | Apel | 20 | 2023-01-31 |
Sesi B
BEGIN;
UPDATE fruit
SET name = 'Semangka'
WHERE id = 1;
COMMIT;
Di saat bersamaan, sesi B mengganti nama buah menjadi Semangka lalu di-commit.
id | name | stock | expiring_date |
---|---|---|---|
1 | Semangka | 20 | 2023-01-31 |
Sesi A
UPDATE fruit
SET stock = stock - 1
WHERE id = 1;
SELECT *
FROM fruit
WHERE id = 1;
Sesi A kemudian melakukan perubahan pada id = 1 dengan mengurangi stok 1 buah. Lalu Sesi A melakukan query kembali untuk mengecek perubahannya. Ekspektasi sesi A adalah buah Apel dengan id = 1 stoknya berkurang menjadi 19. Tapi yang terjadi adalah, stoknya berkurang menjadi 19 dan nama buahnya berubah menjadi Semangka.
id | name | stock | expiring_date |
---|---|---|---|
1 | Semangka | 19 | 2023-01-31 |
Kondisi tersebut tentu membuat user pada Sesi A bingung karena buahnya berubah menjadi Semangka, padahal dia hanya mengubah stock.
Lost Updates
Lost Updates adalah kondisi ketika perubahan pada data dan kolom yang sama yang dilakukan lebih dari satu sesi secara bersamaan sehingga perubahan tersebut tidak diketahui oleh sesi lain. Contohnya seperti ini:
Sesi A
BEGIN;
SELECT *
FROM fruit
WHERE id = 2;
Sesi A melakukan query selection pada buah dengan id = 2 yaitu Pisang dengan stok 1 buah.
id | name | stock | expiring_date |
---|---|---|---|
2 | Pisang | 1 | 2023-01-30 |
Sesi B
BEGIN;
UPDATE fruit
SET stock = stock - 1
WHERE id = 2;
COMMIT;
Secara bersamaan Sesi B mengurangi stok pada buah dengan id = 2 sebanyak 1 buah dan di-commit sehingga stoknya sekarang jadi 0.
id | name | stock | expiring_date |
---|---|---|---|
2 | Pisang | 0 | 2023-01-30 |
Sesi A
UPDATE fruit
SET stock = stock - 1
WHERE id = 2;
SELECT *
FROM fruit
WHERE id = 2;
Sesi A juga mengurangi stok pada buah tersebut sebanyak 1 buah. Lalu mengecek kembali hasil perubahannya. Ekspektasinya adalah stok Pisang pada Sesi A berkurang menjadi 0 karena sebelumnya stoknya berjumlah 1. Tapi ternyata stoknya malah -1.
id | name | stock | expiring_date |
---|---|---|---|
2 | Pisang | -1 | 2023-01-30 |
Sesi A kehilangan jejak akibat perubahan yang dilakukan oleh Sesi B. Kondisi tersebut sebenarnya sah-sah saja dan bukan Dirty Read karena perubahan pada Sesi B sudah di-commit. Tapi stoknya berubah jadi minus dan membuat aplikasi menjadi buggy karena secara bisnis ga mungkin stok bernilai minus. Anomaly di atas dapat dicegah menggunakan Pessimistic Locking maupun Optimistic Locking.
Pessimistic Locking
Pessimistic Locking adalah proses penguncian data terhadap perubahan dari sesi lain yang dilakukan dari sisi Database.
Serializable Isolation
Seperti yang sudah gw jelaskan sebelumnya, Serializable Isolation terhindar dari Phenomena apapun, termasuk Update Anomaly. Serializable Isolation memblok transaksi data yang sama yang dilakukan oleh sesi lain sehingga tidak terjadi tumpang tindih perubahan data. Ini dapat mencegah Write Skew maupun Lost Updates. Kekurangannya adalah performa database akan jadi lebih berat. Untuk lebih jelasnya bisa dicek kembali tulisan gw tentang Serializable Isolation karena gw males nulis ulang disini😝.
Select For Update
Ini adalah salah satu fitur dari Database yang dapat mengunci data yang di-select agar data tersebut tidak bisa diubah oleh sesi lain sampai transaksi tersebut selesai. Caranya adalah dengan menambahkan suffix FOR UPDATE
pada akhir query selection seperti ini:
Sesi A
BEGIN;
SELECT *
FROM fruit
WHERE id = 2 FOR UPDATE;
Sesi A melakukan selection pada buah dengan id = 2 yaitu Pisang dengan stok 1 buah tapi dengan query Select For Update.
id | name | stock | expiring_date |
---|---|---|---|
2 | Pisang | 1 | 2023-01-30 |
Sesi B
BEGIN;
UPDATE fruit
SET stock = stock - 1,
name = 'Alpukat'
WHERE id = 2;
COMMIT;
Di saat bersamaan Sesi B melakukan perubahan dengan mengganti nama buah pada id = 2 menjadi Alpukat dan stoknya berkurang 1. Di sini perubahan pada Sesi B akan diblok karena harus menunggu Sesi A menyelesaikan transaksinya.
Sesi A
UPDATE fruit
SET stock = stock - 1
WHERE id = 2;
SELECT *
FROM fruit
WHERE id = 2;
Sesi A kemudian mengurangi stok Pisang sebanyak 1 buah dan mengecek hasil perubahannya. Hasilnya adalah muncul data Pisang dengan stok yang berkurang 1 menjadi 0 sesuai ekspektasi.
id | name | stock | expiring_date |
---|---|---|---|
2 | Pisang | 0 | 2023-01-30 |
Sekarang Sesi A sudah terhindar dari Write Skew & Lost Updates😎. Ketika perubahan pada Sesi A di-commit, Sesi B dapat melanjutkan perubahannya. Masalahnya adalah Sesi B juga akan mengurangi stok buah dengan id = 2 tersebut sebanyak 1 buah sehingga stoknya akan menjadi -1. Lost Updates sekarang justru terjadi pada Sesi B🤭.
Optimistic Locking
Optimistic Locking adalah proses penguncian data terhadap perubahan dari sesi lain yang dilakukan dari sisi algoritma aplikasi. Jadi sebenarnya ga ada “penguncian” secara fisik disini, hanya menambahkan sedikit logika update secara konsisten. Secara umum Optimistic Locking ada 2, yaitu All Column dan Version Column.
All Column
Caranya adalah dengan menambahkan semua kolom pada where clause dengan value sebelumnya saat melakukan update. Biar ga bingung bisa liat contoh berikut:
Sesi A
BEGIN;
SELECT *
FROM fruit
WHERE id = 2;
Sesi A melakukan selection pada buah dengan id = 2 yaitu Pisang dengan stok 1 buah.
id | name | stock | expiring_date |
---|---|---|---|
2 | Pisang | 1 | 2023-01-30 |
Sesi B
BEGIN;
SELECT *
FROM fruit
WHERE id = 2;
UPDATE fruit
SET stock = stock - 1,
name = 'Alpukat'
WHERE id = 2 AND
stock = 1 AND
name ='Pisang' AND
expiring_date = '2023-01-30';
COMMIT;
Di saat bersamaan Sesi B melakukan perubahan dengan mengganti nama buah pada id = 2 menjadi Alpukat dan stoknya dikurangi 1 lalu di-commit.
id | name | stock | expiring_date |
---|---|---|---|
2 | Alpukat | 0 | 2023-01-30 |
Sesi A
UPDATE fruit
SET stock = stock - 1
WHERE id = 2 AND
stock = 1 AND
name ='Pisang' AND
expiring_date = '2023-01-30';
Sesi A lalu mengurangi stok Pisang sebanyak 1 buah dengan menambahkan where clause semua kolom dengan value yang di-select sebelumnya, yaitu id, name, stock, dan expiring_date. Karena pada Sesi B stoknya sudah berubah jadi 0 dan namanya berubah jadi Alpukat, maka proses update pada Sesi A tidak dapat dilakukan karena datanya sudah tidak ditemukan. Sekarang data tersebut tidak dapat lagi diubah secara serentak dan terhindar dari Write Skew & Lost Updates😎.
Version Column
Ini adalah cara paling umum diterapkan karena hanya butuh minimal dua kolom where clause saat update, yaitu id dan version. Kita harus menambahkan kolom baru yaitu version
pada table. Tabelnya jadi seperti berikut:
id | name | stock | expiring_date | version |
---|---|---|---|---|
1 | Apel | 20 | 2023-01-31 | 1 |
2 | Pisang | 1 | 2023-01-30 | 1 |
Algoritmanya adalah ketika melakukan insertion, kita akan mengisi kolom version dengan angka 0. Lalu setiap terjadi perubahan, kolom version itu akan ikut di-update secara incremental. Misalnya versionnya sekarang 1, lalu setelah dilakukan update sekali, versionnya ikut di-update jadi 2, begitu seterusnya setiap terjadi update. Saat melakukan update, kita tidak perlu memasukkan semua kolom sebagai where clause seperti All Column, melainkan hanya memasukkan id dan value version yang lama. Command updatenya jadi lebih simple dibanding All Column. Contohnya seperti berikut:
Sesi A
BEGIN;
SELECT *
FROM fruit
WHERE id = 2;
Sesi A melakukan query selection pada buah dengan id = 2.
id | name | stock | expiring_date | version |
---|---|---|---|---|
2 | Pisang | 1 | 2023-01-30 | 1 |
Sesi B
BEGIN;
SELECT *
FROM fruit
WHERE id = 2;
UPDATE fruit
SET stock = stock - 1,
name = 'Alpukat',
version = version + 1
WHERE id = 2 AND
version = 1;
COMMIT;
Di saat bersamaan Sesi B melakukan perubahan dengan mengganti nama buah pada id = 2 menjadi Alpukat dan stoknya berkurang 1. Di dalam command update tersebut kita juga menambahkan incremental update pada kolom version, sesuai algoritma di atas dan di-commit sehingga versionnya sekarang adalah 2.
id | name | stock | expiring_date | version |
---|---|---|---|---|
2 | Alpukat | 0 | 2023-01-30 | 2 |
Sesi A
UPDATE fruit
SET stock = stock - 1,
version = version + 1
WHERE id = 2 AND
version = 1;
Kemudian Sesi A mengurangi stok Pisang sebanyak 1 buah dengan menambahkan where clause id dan version dengan value yang di-select sebelumnya, yaitu 1. Karena pada data tersebut Sesi B telah melakukan update dan mengganti versionnya menjadi 2, maka proses update pada Sesi A tidak dapat dilakukan sebab datanya sudah tidak ditemukan. Sekarang data tersebut tidak dapat lagi diubah secara serentak dan juga terhindar dari Write Skew & Lost Updates😎.
Optimistic Locking Handling
Setelah menerapkan Optimistic Locking lewat salah satu cara di atas, Update Anomaly dapat dihindari, baik pada Sesi A maupun Sesi B. Kita dapat menentukan algoritma selanjutnya pada aplikasi. Kita bisa melakukan throw Error ketika datanya tidak ditemukan karena valuenya sudah berubah untuk memberitahu user bahwa telah terjadi perubahan pada data yang sama secara bersamaan. Atau bisa juga dengan mengulang kembali sesi tersebut dan melakukan command update yang sama. Semuanya dikembalikan lagi pada masing-masing requirement bisnis tersebut. Contohnya pada data di atas kita dapat mengulangi kembali Sesi A dan melakukan validasi, jika stoknya 0 maka akan throw Error sehingga kita dapat mencegah jumlah stok menjadi minus.
Perlu diperhatikan bahwa Optimistic Locking ada performance cost karena mewajibkan kita melakukan query select before update untuk mendapatkan value sebelumnya pada sesi tersebut yang hasilnya nanti akan digunakan sebagai where clause saat update. Optimistic Locking menggunakan Version juga hanya bisa di-handle lewat aplikasi. Kalau ada yang melakukan update langsung ke database server tanpa melakukan increment version, tentu ga bakal kedetek perubahannya oleh aplikasi di saat bersamaan. Sedangkan Pessimistic Locking bisa di-handle baik lewat aplikasi maupun lewat database server langsung. Jadi sebenarnya ga ada solusi yang maha sempurna😅.
Verdict
Kita telah mempraktekkan cara mencegah Update Anomaly (Write Skew & Lost Updates) menggunakan Pessimistic Locking & Optimistic Locking. Pessimistic Locking itu mengunci menggunakan fitur di database. Sedangkan Optimistic Locking itu mengunci menggunakan algoritma di aplikasi. Ketika menggunakan Pessimistic Locking database akan menghandlenya dengan cara memblok proses transaksi pada sesi lain sesuai fitur dari database yang digunakan. Sedangkan pada Optimistic Locking kita bebas menentukan cara handlenya, entah itu akan throw Error atau mengulangi kembali sesinya dengan command yang sama. Optimistic Locking menggunakan Version Column adalah metode yang paling populer karena command updatenya lebih simple. Selain itu, implementasi fitur masing-masing database itu berbeda. Kalau kita mempercayakan proses locking pada fitur database, aplikasi jadi tidak tahan perubahan. Misalkan awalnya kita menggunakan Serializable Isolation pada PostgreSql yang menerapkan Snapshot Serializable. Lalu, kita migrasi ke MySql yang menerapkan 2 Phase Locking Serializable. Behaviornya jadi berbeda, yang sebelumnya mengunci dengan memblok update pada sesi lain berubah menjadi mengunci dengan cara menunggu sesi lain selesai. Hal itu bisa saja tidak sesuai dengan requirement bisnis. Oleh karena itu, Optimistic Locking dianggap paling cocok untuk sebagian besar kasus karena meskipun nantinya kita migrasi ke database lain behaviornya ga akan berubah karena dihandle oleh algoritma yang kita tulis di aplikasi. Jika menggunakan Hibernate pada Java, kita bisa menerapkan Optimistic Locking dengan mudah menggunakan annotasi @OptimisticLocking
pada class entity dengan value type ALL
untuk metode All Column. Atau menambahkan annotasi @Version
pada property version
di class entity untuk metode Version Column. Jadi ga perlu repot-repot bikin algoritma manual. Oh ya, ini bukan berarti Optimistic Locking merupakan solusi paling sempurna, karena Optimistic Locking tidak bisa dilakukan tanpa selection terlebih dahulu pada sesi tersebut. Tentu itu akan sedikit berdampak pada performance. Selain itu Optimistic Locking hanya bisa di-handle lewat aplikasi. Sehingga kalau ada yang melakukan update langsung di database server tentu ga ngaruh pengunciannya. Beda halnya dengan Pessimistic Locking yang bisa di-handle lewat aplikasi maupun lewat database server langsung. Tapi menurut gw Optimistic Locking masih worth it dibanding Pessimistic Locking, baik dari segi performance maupun dari segi maintanance. Semuanya dikembalikan lagi ke masing-masing engineer dan tergantung kasus yang dihadapi. Seperti yang pernah gw bilang, ga ada yang namanya silver bullet dalam pemrograman, semuanya ada cost dan benefitnya. Tinggal cari jalan tengah yang cocok aja😀.