Today I Learned: Row Locking Transaction pada PostgreSQL

Bagaimana saya melakukan Row-Level Locking Transaction di PostgreSQL.

Iman Tumorang
Jan 7, 2020 · 6 min read
Photo by The Framed Bear on Unsplash

Hari ini, setelah bergelut selama 2 hari kurang beberapa jam. Saya menemukan kasus yang cukup membuat saya pusing dan bingung. Hingga pada akhirnya, saya berhasil menemukan solusinya. Dan solusinya ternyata sangat sederhana.

Sedikit cerita, saat ini, saya lagi mengerjakan proses pengembangan core-payment di Xendit, salah satu perusahaan payment-gateway di Indonesia, tempat saya bekerja saat ini. Saya baru bergabung sekitar 6 bulan yang lalu. Dan kebetulan saya mendapat kepercayaan untuk ikut serta dalam proyek me-refactor core system kami.

Nah, isu yang baru saya alami ini berkaitan dengan pengembangan sistem baru dari core payment kami. Isu tersebut berkaitan dengan concurrency dan transaction di database, di salah satu service kami, atau sebut saja service Payment Code Generator.

Seperti namanya, service ini akan menerima request dari user dan responsenya adalah sebuah kode pembayaran yang dapat digunakan user untuk melakukan pembayaran.

Service ini, secara sederhana tanpa mencakup bisnis logic, bisa saya sebut hanya terdiri dari 2 table. Sebut saja: master_counter dan payment_code. Kedua table ini saling berkaitan yang akan saya jelaskan nanti dibawah.

Untuk schema databasenya, bisa dilihat sebagai berikut:

  • Schema master_counter
  • Dan schema payment_code

Jika teman-teman lihat, di master_counter, terdapat kolom counter. Kolom ini akan bertambah berdasarkan request yang dilakukan oleh user. Jadi jika nilai awalnya adalah 0, lalu user melakukan request baru, maka valuenya akan di update menjadi 1.

Secara sederhana (tanpa bisnis logic yang rumit), flow dari sistem ini ketika user melakukan request payment code yang baru dapat dilihat sebagai berikut.

Flow the Generating Payment Code
  • Pertama, ketika user request payment code baru, kita akan melakukan query nilai dari counter user tersebut dari database.
  • Lalu, counter yang kita query tersebut, kita tambah satu di aplikasi. Lalu kita melakukan proses hash (apply hashing logic) yang dimana hasil hashnya adalah payment code yang akan dipakai user.
  • Lalu, hasil hash counter tersebut, kita simpan ke table payment_code. Dan karena payment_code sifatnya pasti unik. Sehingga jika ada payment_code yang sama, maka databasenya akan berteriak error.
  • Setelah payment_code nya kita simpan, nilai counter yang terbaru tadi, kita simpan kembali ke table master_counter
  • Terakhir, payment_code yang sudah kita generate (nilai counter yang di hash) kita kembalikan sebagai response ke user.

Ya kurang-lebih, keseluruhan proses yang terjadi seperti itulah. Meski masih banyak “logic-logic” aneh lainnya, tetapi secara garis besar, cuma sesederhana itu saja.

Problems Started

Jika saya buat ke dalam bentuk SQL query, keseluruhan prosesnya bisa kita tuliskan seperti berikut.

Dan karena saya pakai Golang untuk pengaplikasikannya, hal ini menjadi lebih mudah karena di Golang sendiri proses transaksi pada database sudah di abstraksi.

Berikut pseudo-code proses saya di aplikasi.

Nah, ketika melihat keseluruhan proses ini, seharusnya tidak ada masalah. Dan benar saja, ketika saya coba dengan melakukan call API menggunakan Postman, semua berjalan sesuai ekspektasi.

Kemudian ketika saya mencoba dengan concurrent request, saya mencoba melakukan load testing ke aplikasi tersebut. Tidak banyak, saya hanya menggunakan 2 concurent request. Lalu benih-benih errornya pun muncul.

Dari errornya, disebutkan bahwa terjadi pelanggaran constraint pada unique value. Ini terjadi di table payment_code.

Asumsi saya adalah, jika terjadi error duplikat pada payment_code, maka terjadi race condition disini. Dimana terdapat 2 atau lebih request saling lomba-lomba melakukan insert data.

Dan ini terjadi karena, payment_code yang diinsert adalah hasil dari algoritma hashing melakukan hashing dengan nilai counter yang sama. Sehingga saat disimpan ke table, datanya pun melanggar constraint dari unique key.

First Attempt! — Using Isolation Level

Mendapati kasus ini, saya pun mulai bingung. Karena setahu saya, saya sudah menggunakan transaction di query saya.

Dan setelah menghabiskan waktu seharian untuk searching dan exploring. Saya menemukan 3 artikel yang membuat saya paham kondisi saya.

Saya menemukan buzzword baru, yakni isolation pada database.

Dari artikel tersebut, saya pun menyimpulkan bahwa isolation level yang saya gunakan pada database saya belum cukup ketat untuk proses read-write-update secara bersamaan.

Lalu saya pun mencoba menambah isolation level pada query saya.

Dan saya coba jalanin ulang load testnya.

Saya pun menemukan error baru. Disini errornya mengatakan bahwa database saya memblock request yang melakukan update secara bersamaan. Memang sih, ini sudah cukup menghindari race-condition. Tetapi, dari 2 request yang bersamaan, salah satu request akan mendapat error. Yang kita inginkan adalah, kedua request tersebut harus sukses. Jika salah satu sedang melakukan operasi transaksi, maka yang lain harus menunggu sampai selesai, lalu berjalan lagi.

Tidak puas dengan hasil yang saya dapat, saya pun mencoba bertanya kepada orang lain. Tidak hanya teman dari kantor saya, saya bahkan tanya ke orang-orang dari luar kantor saya.

Dan dari semua jawaban yang saya dapat baik dari teman kantor, atau teman di kantor lain, saya pun menyimpulkan, bahwa yang saya butuhkan sebenarnya adalah row-level locking pada postgres. Karena yang saya butuhkan adalah, read dan update pada setiap row di kolom counter, sehingga saya hanya butuh pada row level locking.

Menggunakan isolation, scopenya diaplikasikan keseluruhan table. Sehingga jika terjadi 2 read yang bersamaan, maka salah satunya akan di block dan mendapat error.

Second Attempt! — Using SELECT FOR UPDATE

Percobaan kedua saya, adalah sebagai berikut

  • Membuat isolation level: READ COMMITTED (Postgres default isolation level)
  • Menggunakan row-level locking: SELECT FOR UPDATE

Jadi dari code original saya, perubahan yang perlu saya lakukan hanya sebatas menambah query FOR UDPATE di query read counter yang saya miliki.

Sehingga perubahannya dari query pertama saya sebelumnya adalah seperti berikut.

Yap, saya hanya menambah itu saja. Cuma 2 kata FOR UDPATE. Dan itu sudah menyelesaikan permasalahan saya. Meski sebenarnya, saya tidak terlalu yakin ini solusi yang tepat, tetapi untuk saat ini, itu sudah cukup memenuhi kebutuhan saya.

Dan ketika saya lakukan load test lagi. Semua berjalan lancar, tidak ada issue ataupun error baru. Tidak ada terjadi race condition. Aplikasinya berjalan seperti yang saya harapkan. Jadi dari sini saja, saya sudah asumsikan bahwa solusi ini adalah solusi yang tepat.

Namun jika teman-teman, tertarik untuk explore lebih lengkap pada query SELECT ... FOR UPDATE, mungkin artikel ini, Selecting for Share and Update in PostgreSQL bisa lebih menjelaskan lebih lengkap, sampai kasus-kasus yang dicakup setiap querynya.

Tetapi karena dengan SELECT ... FOR UDPATE saja sudah menyelesaikan problem saya. Saya akan eksplore lagi kebutuhannya dilain kesempatan 😉.

Easyread

Easy read, easy understanding.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store