Di tulisan Isolation pada ACID kita membahas tentang Read Phenomena, kali ini pembahasannya tentang Update Anomaly. Read Phenomena adalah fenomena selisih pembacaan data pada suatu sesi karena ada perubahan oleh sesi lain secara bersamaan. Sedangkan Update Anomaly adalah anomaly 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 (Database Race Condition). Secara umum Update Anomaly terbagi 2, yaitu Write Skew dan Lost Updates. Tapi yang akan dibahas di sini hanyalah Lost Updates (Single Anti-Dependency Cycles), untuk Write Skew nanti akan dibahas terpisah. Kita akan memakai contoh data berikut:
Table fruit
id | name | order_status | due_date | quantity |
---|---|---|---|---|
1 | Apel | pre-ordered | 2023-01-31 | 20 |
2 | Pisang | confirmed | 2023-01-30 | 1 |
- Kita memiliki tabel pemesanan buah-buahan;
- Tabel tersebut memiliki status "pre-ordered" untuk yang memesan tapi belum dikonfirmasi, "confirmed" untuk yang sudah dikonfirmasi, dan "expired" untuk yang belum dikonfirmasi hingga masa tenggangnya sudah habis;
- Jika sampai pada tanggal due_date belum dikonfirmasi, maka pemesanannya akan di-set "expired" oleh admin;
- Pesanan yang sudah expired tidak bisa lagi dikonfirmasi;
Lost Updates
Lost Updates adalah kondisi ketika terjadi perubahan pada data yang sama yang dilakukan oleh lebih dari satu sesi secara bersamaan sehingga perubahan tersebut menimpa perubahan dari sesi lain dan tidak diketahui oleh sesi lain. Contohnya seperti ini:
User A
BEGIN;
SELECT *
FROM fruit
WHERE id = 1;
User A melakukan selection pada buah dengan id = 1 yaitu Apel untuk mengecek status order buah apel.
id | name | order_status | due_date | quantity |
---|---|---|---|---|
1 | Apel | pre-ordered | 2023-01-31 | 20 |
Admin B
BEGIN;
SELECT *
FROM fruit
WHERE id = 1;
UPDATE fruit
SET order_status = 'expired'
WHERE id = 1;
COMMIT;
Di saat bersamaan Admin B juga mengecek buah dengan id = 1 dan menemukan buah tersebut masih belum dikonfirmasi. Admin B kemudian mengganti order status menjadi expired karena telah melewati due date, lalu di-commit.
id | name | order_status | due_date | quantity |
---|---|---|---|---|
1 | Apel | expired | 2023-01-31 | 20 |
User A
UPDATE fruit
SET order_status = 'confirmed'
WHERE id = 1;
COMMIT;
User A yang ga tau telah terjadi perubahan mengonfirmasi pesanan pada id = 1 dengan mengganti order status menjadi confirmed, lalu di-commit.
id | name | order_status | due_date | quantity |
---|---|---|---|---|
1 | Apel | confirmed | 2023-01-31 | 20 |
Karena kondisi tersebut terjadi di waktu bersamaan, perubahan oleh Admin B jadi seperti ga pernah terjadi. Inilah yang disebut Lost Updates. User A ga tau bahwa perubahannya bakal menimpa perubahan dari Admin B. Admin B tentu akan bingung karena pesanan yang tadinya sudah expired tiba-tiba terkonfirmasi🤔.
Stateless Lost Updates
Stateless Lost Updates artinya perubahan tersebut hilang karena data yang lama diganti dengan data yang baru berdasarkan value dari data yang lama secara bersamaan. Misalkan ketika mengurangi stok dari jumlah stok sebelumnya dan stok tidak boleh bernilai minus. Lalu ada 2 admin yang secara bersamaan mengurangi stok yang ada saat ini. Contohnya seperti ini:
Tabel inventory
id | name | stock |
---|---|---|
1 | Apel | 10 |
2 | Pisang | 1 |
User A
BEGIN;
SELECT *
FROM inventory
WHERE id = 2;
User A melakukan query selection pada buah dengan id = 2 yaitu Pisang dengan stok 1 buah.
id | name | stock |
---|---|---|
2 | Pisang | 1 |
User B
BEGIN;
SELECT *
FROM inventory
WHERE id = 2;
UPDATE inventory
SET stock = stock - 1
WHERE id = 2;
COMMIT;
Secara bersamaan User B juga melihat bahwa stok buah dengan id = 2 masih sisa 1 dan mengurangi stok sebanyak 1 buah lalu di-commit sehingga stoknya sekarang jadi 0.
id | name | stock |
---|---|---|
2 | Pisang | 0 |
User A
UPDATE inventory
SET stock = stock - 1
WHERE id = 2;
SELECT *
FROM inventory
WHERE id = 2;
User A yang masih mengira stoknya masih sisa 1 juga mengurangi stok pada buah tersebut sebanyak 1 buah. Lalu mengecek kembali hasil perubahannya. Ekspektasinya adalah stok Pisang berkurang menjadi 0 karena sebelumnya stoknya berjumlah 1. Tapi ternyata stoknya malah -1.
id | name | stock |
---|---|---|
2 | Pisang | -1 |
User A kehilangan jejak perubahan yang dilakukan oleh User B. Kondisi tersebut bukan Dirty Read karena perubahan oleh User B sudah di-commit. Tapi stoknya berubah jadi minus dan membuat aplikasi menjadi buggy karena secara bisnis ga mungkin stok bernilai minus. Kedua anomaly di atas dapat dicegah menggunakan Pessimistic Locking maupun Optimistic Locking. Untuk memperpendek tulisan gw hanya mempraktekkan locking menggunakan kasus Stateless Lost Updates saja karena penerapannya sama🙏.
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 pada satu serial eksekusi yang sama terhadap sesi lain sehingga tidak terjadi tumpang tindih perubahan data. Ini dapat mencegah 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:
User A
BEGIN;
SELECT *
FROM inventory
WHERE id = 2 FOR UPDATE;
User A melakukan selection pada buah dengan id = 2 yaitu Pisang dengan stok 1 buah tapi dengan query Select For Update.
id | name | stock |
---|---|---|
2 | Pisang | 1 |
User B
BEGIN;
UPDATE inventory
SET stock = stock - 1
WHERE id = 2;
COMMIT;
Di saat bersamaan User B melakukan perubahan dengan mengurangi stok pada id = 2 sebanyak 1 buah. Di sini perubahan oleh User B akan diblok karena harus menunggu User A menyelesaikan transaksinya.
Sesi A
UPDATE inventory
SET stock = stock - 1
WHERE id = 2;
SELECT *
FROM inventory
WHERE id = 2;
User 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 |
---|---|---|
2 | Pisang | 0 |
Sekarang User A sudah terhindar dari Lost Updates😎. Ketika perubahan oleh User A di-commit, User B dapat melanjutkan perubahannya. Masalahnya sekarang User B juga akan mengurangi stok buah dengan id = 2 sebanyak 1 buah sehingga stoknya akan menjadi -1. Lost Updates sekarang justru terjadi pada User B🤭. Untuk menghindari anomaly pada kedua sesi, kita juga harus konsisten pada sesi User B untuk melakukan Select For Update
sebelum update agar sesi B ga bisa membaca dan mengubah data hingga sesi A selesai. Selain itu pada beberapa database terdapat fitur Nowait dengan menambahkan suffix FOR UPDATE NOWAIT
pada akhir selection agar ketika terjadi anomaly langsung error tanpa harus nunggu sehingga perubahannya langsung dibatalkan dan semua sesi terhindar dari Lost Updates.
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:
User A
BEGIN;
SELECT *
FROM inventory
WHERE id = 2;
User A melakukan selection pada buah dengan id = 2 yaitu Pisang dengan stok 1 buah.
id | name | stock |
---|---|---|
2 | Pisang | 1 |
User B
BEGIN;
SELECT *
FROM inventory
WHERE id = 2;
UPDATE inventory
SET stock = stock - 1
WHERE id = 2 AND
stock = 1 AND
name ='Pisang';
COMMIT;
Di saat bersamaan User B mengurangi stock sebanyak 1 buah pada buah dengan id = 2 lalu di-commit.
id | name | stock |
---|---|---|
2 | Pisang | 0 |
User A
UPDATE inventory
SET stock = stock - 1
WHERE id = 2 AND
stock = 1 AND
name ='Pisang';
User A juga mengurangi stok Pisang sebanyak 1 buah dengan menambahkan where clause semua kolom dengan value yang di-select sebelumnya, yaitu id, name, dan stock. Karena pada User B stoknya sudah berubah jadi 0, maka proses update pada User A tidak dapat dilakukan karena datanya sudah tidak ditemukan. Sekarang data tersebut tidak dapat lagi diubah secara serentak dan terhindar dari Lost Updates😎.
Version Column
Version Column adalah cara yang 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 | version |
---|---|---|---|
1 | Apel | 20 | 1 |
2 | Pisang | 1 | 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 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:
User A
BEGIN;
SELECT *
FROM inventory
WHERE id = 2;
User A melakukan query selection pada buah dengan id = 2.
id | name | stock | version |
---|---|---|---|
2 | Pisang | 1 | 1 |
User B
BEGIN;
SELECT *
FROM inventory
WHERE id = 2;
UPDATE inventory
SET stock = stock - 1,
version = version + 1
WHERE id = 2 AND
version = 1;
COMMIT;
Di saat bersamaan User B melakukan perubahan dengan mengurangi stok buah pada id = 2. 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 | version |
---|---|---|---|
2 | Pisang | 0 | 2 |
User A
UPDATE inventory
SET stock = stock - 1,
version = version + 1
WHERE id = 2 AND
version = 1;
Kemudian User 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 User B telah melakukan update dan mengganti versionnya menjadi 2, maka proses update pada User A tidak dapat dilakukan sebab datanya sudah tidak ditemukan. Sekarang data tersebut tidak dapat lagi diubah secara serentak dan juga terhindar dari Lost Updates😎.
Optimistic Locking Handling
Setelah menerapkan Optimistic Locking lewat salah satu cara di atas, Update Anomaly dapat dihindari, baik pada User A maupun User 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. Contohnya pada kasus di atas kita dapat mengulangi kembali sesi User 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 sebelum update untuk mendapatkan value sebelumnya pada sesi tersebut yang hasilnya nanti akan digunakan sebagai where clause saat update. Optimistic Locking juga hanya bisa di-handle lewat aplikasi, kalau ada yang melakukan update langsung ke database server, anomaly tetap bisa terjadi. Sedangkan Pessimistic Locking bisa di-handle baik lewat aplikasi maupun update lewat database server langsung. Jadi sebenarnya ga ada solusi yang maha sempurna😅.
Verdict
Kita telah mempraktekkan cara mencegah Update Anomaly Lost Updates menggunakan Pessimistic Locking & Optimistic Locking. Pessimistic Locking adalah mengunci menggunakan fitur di database. Sedangkan Optimistic Locking adalah 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 dan kita dapat tracking berapa kali data tersebut telah diganti. 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 transaksi sesi lain selesai. Hal itu bisa saja tidak sesuai dengan requirement bisnis. Penggunaan Pessimistic Locking dengan cara menunggu juga menyebabkan bottleneck sehingga aplikasi jadi sangat lambat bila di-update oleh banyak request secara bersamaan karena semua request harus ngantri satu-persatu. Oleh karena itu, Optimistic Locking dianggap paling cocok untuk sebagian besar kasus karena meskipun nantinya kita migrasi ke database lain behaviornya ga akan berubah sebab 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 tanpa logic 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 maintenance. 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 yang cocok aja😀.