Dua pembeli, satu barang
Tiga unit sebuah produk ada di satu rak. Dua checkout sampai ke warehouse di milidetik yang sama, masing-masing meminta ketiganya. Warehouse harus menjanjikan setiap unit paling banyak satu kali, dan tidak ada apa pun di kedua request yang memberi tahu bahwa request lainnya ada.
Pelajaran ini mengikuti satu panggilan gRPC, ReserveStock, menembus kode Rails milik warehouse dan
menunjukkan baris yang membuatnya aman — serta apa yang terjadi tanpanya.
Mainkan
Section titled “Mainkan”Jalankan Tanpa FOR UPDATE dulu, lalu pindah ke Dengan FOR UPDATE. Setiap langkah menampilkan SQL yang dikirim tiap order, apa yang sudah dibacanya, dan berapa unit yang ia kira sudah ia pegang.
Diagram interaktif. Gunakan tombol panah kiri dan kanan untuk berpindah langkah, spasi untuk memutar atau menjeda.
belum mulai
- Membaca reserved_quantity
- belum membaca apa pun
- Merasa sudah me-reserve
- 0 unit
bin_inventories · SKU-C - Di rak
- 3
- Ter-reserve, sudah commit
- 0
- Tersedia
- 3
belum mulai
- Membaca reserved_quantity
- belum membaca apa pun
- Merasa sudah me-reserve
- 0 unit
Tiga unit SKU-C ada di satu bin. Order A dan B masing-masing ingin ketiganya dan datang di saat yang sama. Di versi ini baris bin dibaca tanpa lock.
Versi teks diagram ini
Tanpa FOR UPDATE
Tiga unit SKU-C ada di satu bin. Order A dan B masing-masing ingin ketiganya dan datang di saat yang sama. Di versi ini baris bin dibaca tanpa lock.
- Order A
BEGIN; SELECT * FROM bin_inventories WHERE sku = 'SKU-C' ORDER BY id;
A membuka transaksi dan membaca bin. Belum ada yang ter-reserve, jadi ketiga unit terlihat tersedia.
- Order B
BEGIN; SELECT * FROM bin_inventories WHERE sku = 'SKU-C' ORDER BY id;
B melakukan hal yang sama sesaat kemudian. A belum menulis apa pun, jadi B melihat persis apa yang A lihat: tiga unit tersedia.
- Order A
UPDATE bin_inventories SET reserved_quantity = 3 WHERE id = 1;
A me-reserve tiga unitnya. Nilai barunya, 0 + 3, dihitung di aplikasi dari apa yang A baca. Postgres mengambil row lock untuk tulisan ini dan menahannya sampai A commit.
- Order B
UPDATE bin_inventories SET reserved_quantity = 3 WHERE id = 1;
B menulis 0 + 3 miliknya sendiri. Postgres membuat B menunggu row lock milik A, jadi dua tulisan itu tidak bisa saling selip — tapi angka yang akan ditulis B sudah diputuskan dari bacaan yang kini basi.
- Order A
COMMIT;
A commit dan melepas lock. Tulisan B langsung lewat dan menimpa angka 3 milik A dengan angka 3 milik B. Reservasi A hilang dari hitungan, tanpa ada yang diberi tahu.
- Order B
COMMIT;
B commit. Kedua order diberi tahu bahwa mereka memegang tiga unit: enam unit dijanjikan, tiga di rak.
Hasil. Lost update. Baris mencatat tiga unit ter-reserve, tiap order merasa memegang tiga, dan tiga unit oversold. Tidak ada error dan tidak ada constraint yang dilanggar: setiap tulisan menyimpan nilai yang sah.
Dengan FOR UPDATE
Rak yang sama dan dua order yang sama. Kali ini bin dibaca dengan SELECT … FOR UPDATE, persis seperti yang dilakukan warehouse.
- Order A
BEGIN; SELECT * FROM bin_inventories WHERE sku = 'SKU-C' ORDER BY id FOR UPDATE;
A membuka transaksi dan membaca bin dengan FOR UPDATE. Row lock diambil saat membaca, jadi tidak ada yang bisa mengubah baris itu di antara keputusan A dan tulisan A.
- Order B
BEGIN; SELECT * FROM bin_inventories WHERE sku = 'SKU-C' ORDER BY id FOR UPDATE;
B meminta baris yang sama dengan FOR UPDATE dan menunggu. B belum membaca apa pun, jadi tidak ada data basi yang bisa ia pakai. Warehouse membatasi penantian ini dengan lock_timeout yang hanya berlaku untuk transaksi ini.
- Order A
UPDATE bin_inventories SET reserved_quantity = 3 WHERE id = 1;
A me-reserve tiga unitnya, dihitung dari nilai yang dibaca di bawah lock.
- Order A
COMMIT;
A commit dan lock berpindah ke B. Bacaan B baru selesai sekarang dan mengembalikan baris sebagaimana A meninggalkannya: tiga ter-reserve, tidak ada yang tersedia.
- Order B
ROLLBACK;
Tidak ada bin yang bisa menutup tiga unit, jadi warehouse menolak dengan INSUFFICIENT_STOCK dan me-rollback transaksi B, beserta baris ledger milik B.
Hasil. Tiga unit di rak, tiga dijanjikan. B ditolak berdasarkan hitungan yang benar, bukan diberi stok yang sudah tidak ada.
Lebih suka terminal? Reproduksi race di psql menelusuri langkah yang sama secara manual, dengan dua sesi ke PostgreSQL sungguhan.
Yang salah adalah bacaannya, bukan tulisannya
Section titled “Yang salah adalah bacaannya, bukan tulisannya”Me-reserve stok adalah read-modify-write: baca reserved_quantity, putuskan apakah stoknya cukup,
tulis total yang baru. Di warehouse, tulisannya
satu baris:
bin.update!(reserved_quantity: bin.reserved_quantity.to_i + @quantity)Total barunya dihitung di Ruby, dari nilai yang tadi dibaca. Yang sampai ke Postgres adalah
UPDATE bin_inventories SET reserved_quantity = 3 WHERE id = 1 — sebuah konstanta.
Postgres sebenarnya melindungi tulisan itu. UPDATE mengambil lock pada baris, dan UPDATE kedua
pada baris yang sama menunggu sampai transaksi pertama selesai. Itulah sebabnya, di diagram, tulisan
B menunggu. Tapi B menunggu untuk menulis angka yang sudah ia putuskan sebelum penantian dimulai,
dan pada isolasi default PostgreSQL,
Read Committed, tidak
ada yang memberi tahu B bahwa apa yang ia baca sudah berubah.
Itulah lost update, dan ia terjadi diam-diam:
- Kedua transaksi commit. Tidak ada yang melihat error.
- Tidak ada constraint yang dilanggar.
reserved_quantity = 3tidak pernah melebihiquantity = 3, jadi bahkan check constraintreserved_quantity <= quantityakan menerima kedua tulisan.bin_inventoriestidak mendefinisikannya; row lock-lah penjaganya. - Barisnya sendiri terlihat benar. Hanya dua reservasi, masing-masing tiga unit, yang menunjukkan enam unit dijanjikan untuk tiga unit di rak.
Ambil lock saat membaca
Section titled “Ambil lock saat membaca”Warehouse membaca bin dengan locking read:
bins = BinInventory.where(sku: @sku).order(:id).lock("FOR UPDATE").to_aSELECT … FOR UPDATE
mengambil row lock saat baris dibaca, bukan saat ditulis. Sekarang yang menunggu adalah bacaan B,
bukan tulisan B. Ketika A commit, bacaan B selesai dan mengembalikan baris sebagaimana A
meninggalkannya, sehingga B memutuskan berdasarkan hitungan yang benar dan
ditolak dengan INSUFFICIENT_STOCK.
Tiga detail menjaganya tetap benar di bawah trafik sungguhan:
- Semua bin untuk SKU itu, selalu berurutan
id. Dua reservasi untuk SKU yang sama mengunci baris yang sama dengan urutan yang sama, jadi tidak ada yang bisa memegang satu baris sambil menunggu baris yang dipegang lawannya. - Baris ledger sebelum baris bin. Sebelum menyentuh bin, panggilan ini menyisipkan klaim idempotency dan baris ledger reservasi. Release mengambil baris-baris yang sama dengan urutan yang sama. Ketika keduanya mengambil dengan urutan berlawanan, reserve yang balapan dengan release mengalami deadlock; sebuah spec dengan dua koneksi database sungguhan sekarang menjaga urutan itu.
- Satu transaksi membungkus semuanya.
IdempotentOperationmembuka transaksi dan reservasi berjalan di dalamnya. Penolakan mengembalikancommit: false, transaksi di-rollback, dan klaim serta baris ledger yang sempat disisipkan ikut hilang.
Lock mengubah balapan menjadi antrean
Section titled “Lock mengubah balapan menjadi antrean”Antrean butuh batas, atau satu transaksi yang lambat akan menahan semua order untuk produk yang sama.
Warehouse menetapkan lock_timeout
hanya untuk transaksinya sendiri
— nilai true di set_config('lock_timeout', …, true) membatasi pengaturan itu ke transaksi yang
sedang berjalan. Batasnya diambil dari WAREHOUSE_STOCK_LOCK_TIMEOUT_SECONDS, default 15 detik, dan
dijepit antara 1 dan 120.
Ketika penantian habis, tidak ada yang berubah, dan pemanggil diberi tahu baris mana yang sibuk:
| Kode | Yang ditunggu |
|---|---|
RESERVATION_IN_PROGRESS |
Klaim idempotency dari request identik yang masih berjalan |
STOCK_LEDGER_BUSY |
Baris reservasi milik order ini sendiri, dipegang oleh release-nya sendiri |
STOCK_LOCK_TIMEOUT |
Baris bin yang dipegang order lain |
Setiap kode diuji terhadap koneksi kedua yang sungguhan, sehingga perubahan yang menyatukan dua di antaranya akan membuat tepat satu contoh menjadi merah.
Intisari
Section titled “Intisari”- Read-modify-write hanya aman jika tidak ada yang bisa mengubah baris di antara bacaan dan tulisan.
Ambil lock saat membaca dengan
FOR UPDATE, atau buat tulisannya bersyarat — misalnyaSET reserved_quantity = reserved_quantity + 3 WHERE quantity - reserved_quantity >= 3, lalu cek berapa baris yang berubah. Warehouse memakai locking read karena ia harus membandingkan beberapa bin dan memeriksa pemiliknya sebelum menulis. - Kunci baris dengan satu urutan yang konsisten di semua tempat, atau dua transaksi yang sama-sama benar bisa saling deadlock.
- Batasi setiap penantian, dan ketika ada yang habis, sebutkan penantian yang mana.