Pelajari transaksi Postgres untuk alur kerja multi-langkah: cara mengelompokkan pembaruan dengan aman, mencegah penulisan parsial, menangani retry, dan menjaga konsistensi data.

Kebanyakan fitur nyata bukan hanya satu pembaruan basis data. Mereka adalah rangkaian singkat: menyisipkan baris, memperbarui saldo, menandai status, menulis catatan audit, mungkin mengantri pekerjaan. Penulisan parsial terjadi ketika hanya sebagian langkah tersebut yang sampai ke basis data.
Ini muncul ketika sesuatu menginterupsi rangkaian: error server, timeout antara aplikasi dan Postgres, crash setelah langkah ke-2, atau retry yang menjalankan ulang langkah 1. Setiap pernyataan terlihat baik sendiri. Alur kerja rusak ketika berhenti di tengah.
Biasanya Anda bisa melihatnya dengan cepat:
Contoh konkret: upgrade plan memperbarui plan pelanggan, menambahkan catatan pembayaran, dan menambah kredit tersedia. Jika aplikasi crash setelah menyimpan pembayaran tapi sebelum menambah kredit, tim support melihat "dibayar" di satu tabel dan "tidak ada kredit" di tabel lain. Jika klien retry, Anda bahkan bisa merekam pembayaran dua kali.
Tujuannya sederhana: perlakukan alur kerja seperti satu saklar. Entah semua langkah berhasil, atau tidak satupun, sehingga Anda tidak pernah menyimpan pekerjaan setengah jadi.
Transaksi adalah cara basis data mengatakan: perlakukan langkah-langkah ini sebagai satu unit kerja. Entah semua perubahan terjadi, atau tidak sama sekali. Ini penting kapan pun alur kerja Anda membutuhkan lebih dari satu pembaruan, seperti membuat baris, memperbarui saldo, dan menulis catatan audit.
Bayangkan memindahkan uang antar dua akun. Anda harus mengurangi dari Akun A dan menambahkan ke Akun B. Jika aplikasi crash setelah langkah pertama, Anda tidak ingin sistem "mengingat" hanya pengurangan.
Saat Anda commit, Anda memberitahu Postgres: simpan semua yang saya lakukan dalam transaksi ini. Semua perubahan menjadi permanen dan terlihat oleh sesi lain.
Saat Anda rollback, Anda memberitahu Postgres: lupakan semua yang saya lakukan dalam transaksi ini. Postgres membatalkan perubahan seolah transaksi tidak pernah terjadi.
Di dalam transaksi, Postgres menjamin Anda tidak akan mengekspos hasil setengah jadi ke sesi lain sebelum Anda commit. Jika sesuatu gagal dan Anda rollback, basis data membersihkan semua tulis dari transaksi itu.
Transaksi tidak memperbaiki desain alur kerja yang buruk. Jika Anda mengurangi jumlah yang salah, menggunakan user ID yang keliru, atau melewatkan pengecekan yang diperlukan, Postgres akan dengan setia commit hasil yang salah. Transaksi juga tidak otomatis mencegah semua konflik level bisnis (seperti overselling inventori) kecuali Anda mengombinasikannya dengan constraint, lock, atau level isolasi yang tepat.
Kapan pun Anda memperbarui lebih dari satu tabel (atau lebih dari satu baris) untuk menyelesaikan satu aksi dunia nyata, itu kandidat untuk transaksi. Intinya tetap: entah semuanya selesai, atau tidak ada.
Alur order adalah kasus klasik. Anda mungkin membuat baris order, mereservasi inventori, mengambil pembayaran, lalu menandai order sebagai terbayar. Jika pembayaran berhasil tapi pembaruan status gagal, Anda punya uang yang tertangkap tetapi order tetap terlihat belum dibayar. Jika baris order dibuat tapi stok tidak direservasi, Anda bisa menjual barang yang sebenarnya tidak ada.
Onboarding user juga diam-diam rusak dengan cara yang sama. Membuat user, menyisipkan record profil, menetapkan peran, dan mencatat bahwa email sambutan harus dikirim adalah satu aksi logis. Tanpa pengelompokan, Anda bisa berujung pada user yang bisa masuk tapi tidak punya izin, atau profil yang ada tanpa user.
Tindakan back-office sering membutuhkan perilaku "jejak kertas + perubahan status" yang ketat. Menyetujui permintaan, menulis entri audit, dan memperbarui saldo harus berhasil bersama. Jika saldo berubah tapi log audit hilang, Anda kehilangan bukti siapa yang mengubah apa dan mengapa.
Pekerjaan background juga mendapat manfaat, terutama saat Anda memproses item kerja dengan beberapa langkah: klaim item sehingga dua worker tidak mengerjakannya, terapkan pembaruan bisnis, catat hasil untuk pelaporan dan retry, lalu tandai item selesai (atau gagal dengan alasan). Jika langkah-langkah itu terpisah, retry dan concurrency membuat kekacauan.
Fitur multi-langkah rusak ketika Anda memperlakukannya seperti tumpukan pembaruan independen. Sebelum membuka klien basis data, tulis alur kerja sebagai cerita pendek dengan satu garis finish yang jelas: apa yang tepatnya dihitung sebagai "selesai" untuk pengguna?
Mulai dengan daftar langkah dalam bahasa biasa, lalu definisikan satu kondisi sukses. Contoh: "Order dibuat, inventori direservasi, dan pengguna melihat nomor konfirmasi order." Apa pun di bawah itu bukan sukses, meskipun beberapa tabel diperbarui.
Selanjutnya, gambarkan batas tegas antara pekerjaan basis data dan pekerjaan eksternal. Langkah basis data adalah yang bisa Anda lindungi dengan transaksi. Panggilan eksternal seperti pembayaran kartu, pengiriman email, atau pemanggilan API pihak ketiga bisa gagal dengan cara lambat dan tak terduga, dan biasanya tidak bisa Anda rollback.
Pendekatan perencanaan sederhana: pisahkan langkah menjadi (1) harus all-or-nothing, (2) bisa terjadi setelah commit.
Di dalam transaksi, simpan hanya langkah yang harus konsisten bersama:
Pindahkan efek samping ke luar. Misalnya, commit order dulu, lalu kirim email konfirmasi berdasarkan catatan outbox.
Untuk setiap langkah, tulis apa yang harus terjadi jika langkah berikutnya gagal. "Rollback" bisa berarti rollback basis data, atau bisa berarti aksi kompensasi.
Contoh: jika pembayaran berhasil tapi reservasi inventori gagal, putuskan sebelumnya apakah Anda akan langsung refund, atau menandai order sebagai "pembayaran tercapture, menunggu stok" dan menanganinya secara asinkron.
Transaksi memberi tahu Postgres: perlakukan langkah-langkah ini sebagai satu unit. Entah semua terjadi, atau tidak sama sekali. Itulah cara paling sederhana untuk mencegah penulisan parsial.
Gunakan satu koneksi basis data (satu sesi) dari awal sampai akhir. Jika Anda menyebarkan langkah ke koneksi berbeda, Postgres tidak bisa menjamin hasil all-or-nothing.
Runtutan sederhana: begin, jalankan baca dan tulis yang diperlukan, commit jika semuanya sukses, kalau tidak rollback dan kembalikan error yang jelas.
Berikut contoh minimal dalam SQL:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
Transaksi menahan lock selama berjalan. Semakin lama Anda membiarkannya terbuka, semakin Anda memblokir pekerjaan lain dan semakin mungkin menghadapi timeout atau deadlock. Lakukan yang penting di dalam transaksi, dan pindahkan tugas lambat (mengirim email, memanggil provider pembayaran, menghasilkan PDF) ke luar.
Saat sesuatu gagal, log konteks yang cukup untuk mereproduksi masalah tanpa membocorkan data sensitif: nama alur kerja, order_id atau user_id, parameter kunci (jumlah, mata uang), dan kode error Postgres. Hindari melog payload penuh, data kartu, atau detail pribadi.
Konkurensi hanyalah dua hal yang terjadi pada saat bersamaan. Bayangkan dua pelanggan mencoba membeli tiket konser terakhir. Kedua layar menunjukkan "1 tersisa," keduanya klik Bayar, dan sekarang aplikasi Anda harus memutuskan siapa yang mendapatkannya.
Tanpa perlindungan, kedua permintaan bisa membaca nilai lama yang sama dan keduanya menulis pembaruan. Begitulah Anda berakhir dengan inventori negatif, reservasi duplikat, atau pembayaran tanpa order.
Lock baris adalah pengaman paling sederhana. Anda mengunci baris spesifik yang akan diubah, melakukan pengecekan, lalu memperbaruinya. Transaksi lain yang menyentuh baris sama harus menunggu sampai Anda commit atau rollback, yang mencegah pembaruan ganda.
Pola umum: mulai transaksi, pilih baris inventori dengan FOR UPDATE, verifikasi ada stok, kurangi jumlahnya, lalu insert order. Itu seperti "menahan pintu" saat Anda menyelesaikan langkah kritis.
Level isolasi mengatur seberapa banyak keanehan dari transaksi paralel yang Anda izinkan. Trade-off biasanya antara keamanan dan kecepatan:
Jaga lock tetap singkat. Jika transaksi dibiarkan terbuka saat Anda memanggil API eksternal atau menunggu aksi pengguna, Anda akan menyebabkan tungguan panjang dan timeout. Lebih baik punya jalur kegagalan yang jelas: set lock timeout, tangkap error, dan kembalikan "silakan ulangi" daripada membiarkan permintaan menggantung.
Jika Anda perlu melakukan kerja di luar basis data (seperti mengenakan biaya kartu), pisahkan alur: reservasi cepat, commit, lalu lakukan bagian lambat, dan finalisasi dengan transaksi pendek lain.
Retry adalah hal yang normal di aplikasi yang memakai Postgres. Permintaan bisa gagal meskipun kode Anda benar: deadlock, statement timeout, putus jaringan singkat, atau serialization error di level isolasi lebih tinggi. Jika Anda hanya menjalankan ulang handler yang sama, Anda berisiko membuat order kedua, mengenakan biaya dua kali, atau menyisipkan baris "event" duplikat.
Solusinya adalah idempotensi: operasi harus aman dijalankan dua kali dengan input yang sama. Basis data harus bisa mengenali "ini permintaan yang sama" dan merespons konsisten.
Pola praktis adalah menambahkan idempotency key (sering request_id yang dibuat klien) ke setiap alur multi-langkah dan menyimpannya di record utama, lalu menambahkan unique constraint pada key itu.
Misalnya: di checkout, buat request_id saat pengguna klik Bayar, lalu insert order dengan request_id itu. Jika retry terjadi, upaya kedua melanggar constraint unik dan Anda mengembalikan order yang sudah ada alih-alih membuat yang baru.
Yang biasanya penting:
Jaga loop retry di luar transaksi. Setiap upaya harus memulai transaksi baru dan menjalankan ulang unit kerja dari atas. Retry di dalam transaksi yang gagal tidak membantu karena Postgres menandai transaksi sebagai aborted.
Satu contoh kecil: aplikasi Anda mencoba membuat order dan mereservasi inventori, tetapi mengalami timeout tepat setelah COMMIT. Klien retry. Dengan idempotency key, permintaan kedua mengembalikan order yang sudah dibuat dan melewatkan reservasi kedua alih-alih menggandakan pekerjaan.
Transaksi menjaga alur kerja multi-langkah bersama, tapi mereka tidak otomatis membuat data benar. Cara kuat untuk menghindari dampak penulisan parsial adalah membuat keadaan "salah" menjadi sulit atau tidak mungkin di basis data, bahkan jika ada bug di kode aplikasi.
Mulai dengan pengaman dasar. Foreign key memastikan referensi nyata (order line tidak bisa menunjuk ke order yang hilang). NOT NULL menghentikan baris setengah terisi. CHECK constraint menangkap nilai yang tidak masuk akal (misalnya quantity > 0, total_cents >= 0). Aturan-aturan ini berjalan di setiap tulis, tidak peduli layanan atau skrip mana yang menyentuh basis data.
Untuk alur kerja yang lebih panjang, modelkan perubahan status secara eksplisit. Daripada banyak flag boolean, gunakan satu kolom status (pending, paid, shipped, canceled) dan hanya izinkan transisi valid. Anda bisa menegakkannya dengan constraint atau trigger sehingga basis data menolak lompatan ilegal seperti shipped -> pending.
Unikitas adalah bentuk kebenaran lain. Tambahkan constraint unik di tempat duplikat akan merusak alur Anda: order_number, invoice_number, atau idempotency_key yang digunakan untuk retry. Lalu, jika aplikasi Anda mengulangi permintaan yang sama, Postgres memblok insert kedua dan Anda bisa aman mengembalikan "sudah diproses" daripada membuat order duplikat.
Saat Anda membutuhkan keterlacakan, simpan itu secara eksplisit. Tabel audit (atau history) yang merekam siapa mengubah apa, dan kapan, mengubah "update misterius" menjadi fakta yang bisa Anda query saat insiden.
Kebanyakan penulisan parsial bukan disebabkan oleh "SQL yang buruk." Mereka datang dari keputusan alur kerja yang membuatnya mudah untuk commit hanya setengah cerita.
accounts lalu orders, tapi lainnya memperbarui orders lalu accounts, Anda meningkatkan kemungkinan deadlock saat beban tinggi.Contoh konkret: di checkout, Anda mereservasi inventori, membuat order, lalu mengenakan biaya kartu. Jika Anda mengenakan biaya di dalam transaksi yang sama, Anda mungkin menahan lock inventori sambil menunggu jaringan. Jika biaya berhasil tapi transaksi Anda kemudian rollback, Anda sudah mengenakan biaya pelanggan tanpa order.
Pola yang lebih aman: fokuskan transaksi pada state basis data (reserve inventori, buat order, catat payment pending), commit, lalu panggil API eksternal, lalu tulis hasilnya di transaksi pendek baru. Banyak tim mengimplementasikannya dengan status pending sederhana dan pekerjaan background.
Saat sebuah alur kerja punya banyak langkah (insert, update, charge, send), tujuannya sederhana: entah semuanya tercatat, atau tidak sama sekali.
Simpan semua penulisan basis data yang diperlukan dalam satu transaksi. Jika satu langkah gagal, rollback dan kembalikan data persis seperti semula.
Buat kondisi sukses eksplisit. Contoh: "Order dibuat, stok direservasi, dan status pembayaran tercatat." Apa pun selain itu adalah jalur kegagalan yang harus membatalkan transaksi.
BEGIN ... COMMIT.ROLLBACK, dan pemanggil mendapat hasil kegagalan yang jelas.Asumsikan permintaan yang sama bisa di-retry. Basis data harus membantu menegakkan aturan hanya-sekali.
Lakukan pekerjaan minimal di dalam transaksi, dan hindari menunggu panggilan jaringan sambil menahan lock.
Jika Anda tidak bisa melihat di mana ia rusak, Anda akan terus menebak.
Checkout memiliki beberapa langkah yang harus bergerak bersama: buat order, reservasi inventori, catat percobaan pembayaran, lalu tandai status order.
Bayangkan pengguna klik Beli untuk 1 item.
Di dalam satu transaksi, lakukan hanya perubahan basis data:
orders dengan status pending_payment.inventory.available atau buat baris reservations).payment_intents dengan idempotency_key yang disediakan klien (unik).outbox seperti "order_created".Jika salah satu pernyataan gagal (kehabisan stok, error constraint, crash), Postgres akan rollback seluruh transaksi. Anda tidak akan berakhir dengan order tanpa reservasi, atau reservasi tanpa order.
Provider pembayaran ada di luar basis data, jadi perlakukan itu sebagai langkah terpisah.
Jika panggilan provider gagal sebelum Anda commit, batalkan transaksi dan tidak ada yang tertulis. Jika panggilan provider gagal setelah Anda commit, jalankan transaksi baru yang menandai percobaan pembayaran sebagai gagal, melepaskan reservasi, dan mengatur status order menjadi canceled.
Minta klien mengirim idempotency_key per upaya checkout. Tegakkan dengan indeks unik pada payment_intents(idempotency_key) (atau pada orders jika Anda lebih suka). Saat retry, kode Anda mencari baris yang ada dan melanjutkan alih-alih menyisipkan order baru.
Jangan kirim email di dalam transaksi. Tuliskan record outbox dalam transaksi yang sama, lalu biarkan worker background mengirim email setelah commit. Dengan begitu Anda tidak pernah mengirim email untuk order yang kemudian di-rollback.
Pilih satu alur kerja yang menyentuh lebih dari satu tabel: signup + enqueue email sambutan, checkout + inventori, invoice + entri ledger, atau buat proyek + pengaturan default.
Tulis langkah-langkahnya dulu, lalu tulis aturan yang harus selalu benar (invariant). Contoh: "Order semua-terbayar dan tereservasi, atau tidak terbayar dan tidak tereservasi. Jangan pernah setengah tereservasi." Ubah aturan itu menjadi unit all-or-nothing.
Rencana sederhana:
Lalu uji kasus buruk dengan sengaja. Simulasikan crash setelah langkah ke-2, timeout tepat sebelum commit, dan double-submit dari UI. Tujuannya adalah hasil yang membosankan: tidak ada baris yatim, tidak ada double charge, tidak ada status pending selamanya.
Jika Anda sedang membuat prototipe cepat, membantu untuk menggambar alur di alat perencanaan sebelum menghasilkan handler dan skema. Misalnya, Koder.ai (koder.ai) memiliki Planning Mode dan mendukung snapshot serta rollback, yang bisa berguna saat Anda iterasi batas transaksi dan constraint.
Lakukan ini untuk satu alur minggu ini. Yang kedua akan jauh lebih cepat.