Web Artisan
Beranda

Progress belajar

Modul 72 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 9 · Scaling

Order dan Inventory Konsisten
di High Traffic

Cegah overselling saat ratusan user checkout produk skincare yang stoknya terbatas.

Bahasa: Go 1.26.xPostgreSQL 18~75 menit baca
01

Masalah Konsistensi di Checkout

Traffic tinggi membuat bug kecil di inventory menjadi kerugian nyata

Di online shop skincare, flash sale serum populer bisa membuat ratusan checkout menabrak stok yang sama dalam detik yang sama.

Di React atau Laravel, kamu mungkin sering berpikir checkout sebagai satu request biasa: validasi cart, hitung total, simpan order, lalu arahkan user ke pembayaran. Di high traffic, cara berpikir itu belum cukup. Dua request bisa membaca stok yang sama, sama-sama merasa aman, lalu sama-sama membuat order.

overselling

Overselling adalah kondisi ketika sistem menerima lebih banyak order daripada stok fisik yang benar-benar tersedia, biasanya karena operasi baca stok dan kurangi stok tidak dilindungi secara atomik.

race condition

Race condition terjadi ketika hasil akhir bergantung pada urutan eksekusi beberapa operasi concurrent, misalnya dua checkout yang sama-sama membaca stok 1 sebelum salah satunya menulis perubahan.

🌉Jembatan: dari Laravel transaction ke Go

Di Laravel kamu memakai DB::transaction() untuk membungkus beberapa query. Di Go dengan pgx, kita tetap memakai transaksi PostgreSQL, tetapi error handling, context, commit, dan rollback harus ditulis eksplisit supaya alur konsistensi terlihat jelas.

Modul ini fokus ke satu target: ketika stok tersedia 1 dan ada 100 user checkout bersamaan, maksimal hanya 1 order yang berhasil mereservasi stok. Yang lain harus mendapat respons yang jujur, misalnya 409 Conflict atau pesan stok habis.

Sumber resmi yang relevan, semua mengacu ke versi mutakhir per Juni 2026 (Go 1.26.x, pgx v5.10.0, PostgreSQL 18): Go 1.26 release notes, pgxpool, pgconn PgError, pgerrcode, PostgreSQL SELECT, PostgreSQL explicit locking, PostgreSQL transaction isolation, SQS delay queues, dan Grafana k6 docs.

02

Race Condition Saat Stok Terakhir

Bug muncul ketika check dan update dipisah tanpa guard atomik

Race paling umum adalah read-check-write: baca stok, cek cukup, lalu update di query terpisah.

Contoh buruknya terlihat aman saat diuji manual, tetapi gagal saat request berjalan paralel. Masalahnya bukan Go atau PostgreSQL yang lambat. Masalahnya adalah operasi bisnis tidak dibuat atomik.

sequenceDiagram
  participant U1 as User A
  participant U2 as User B
  participant API as Go API
  participant DB as PostgreSQL
  U1->>API: Checkout serum qty 1
  U2->>API: Checkout serum qty 1
  API->>DB: SELECT available_stock = 1
  API->>DB: SELECT available_stock = 1
  API->>DB: INSERT order A
  API->>DB: INSERT order B
  API->>DB: UPDATE stock menjadi 0
  API->>DB: UPDATE stock menjadi 0
  API-->>U1: 201 Created
  API-->>U2: 201 Created

Gambar 1. Dua checkout membaca stok yang sama sebelum update, hasilnya dua order untuk satu barang.

Query seperti ini wajib dihindari pada checkout production.

contoh-buruk.sql
SELECT available_stock FROM inventories WHERE product_id = $1; -- Aplikasi mengecek stok di memory. -- Kalau cukup, baru update. UPDATE inventories SET available_stock = available_stock - $2 WHERE product_id = $1;
⚠️Jebakan: cek stok di aplikasi saja tidak cukup

Validasi if stock >= qty di Go hanya valid untuk snapshot yang baru dibaca. Saat request lain mengubah database sebelum update kamu, keputusan itu bisa basi.

Solusinya adalah membuat keputusan dan perubahan stok terjadi dalam satu operasi database yang aman, atau mengunci baris inventory selama transaksi checkout berlangsung.

03

Model Data Inventory dan Reservation

Pisahkan stok tersedia, stok tertahan, dan status order

Inventory yang scalable tidak hanya punya angka stok, tetapi juga status reservasi yang bisa expired.

Untuk proyek skincare, kita akan memakai tiga konsep utama. available_stock adalah stok yang masih bisa direbut checkout baru. reserved_stock adalah stok yang sudah dikunci untuk order pending payment. inventory_reservations adalah catatan reservasi per order agar bisa di-cancel atau di-consume dengan aman.

File yang disentuh modul ini
  • internal/
  • checkout/
  • service.go orchestration checkout dalam transaksi
  • handler.go map error domain ke status HTTP (409/422/410/500)
  • inventory/
  • repository.go reserve optimistic, snapshot
  • pessimistic.go reserve dengan FOR NO KEY UPDATE
  • advisory.go advisory lock per produk
  • order/
  • repository.go create order dan update status
  • postgres/
  • dbtx.go interface transaksi
  • retry.go retry 40001 dan 40P01 via pgerrcode
  • worker/
  • expire_orders.go worker atau cron untuk payment timeout
  • migrations/
  • 0905_inventory_consistency.sql
  • loadtest/
  • checkout.js simulasi concurrent checkout dengan k6
migrations/0905_inventory_consistency.sql
CREATE TABLE inventories ( product_id BIGINT PRIMARY KEY REFERENCES products(id), available_stock INTEGER NOT NULL CHECK (available_stock >= 0), reserved_stock INTEGER NOT NULL DEFAULT 0 CHECK (reserved_stock >= 0), version BIGINT NOT NULL DEFAULT 1, updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE inventory_reservations ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id), product_id BIGINT NOT NULL REFERENCES products(id), qty INTEGER NOT NULL CHECK (qty > 0), status TEXT NOT NULL CHECK (status IN ('active', 'consumed', 'expired', 'cancelled')), expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (order_id, product_id) ); CREATE INDEX idx_inventory_reservations_expiry ON inventory_reservations (expires_at) WHERE status = 'active'; ALTER TABLE orders ADD COLUMN IF NOT EXISTS payment_expires_at TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS cancel_reason TEXT;
💡Kenapa ada reserved_stock?

available_stock turun saat checkout dibuat, bukan saat payment sukses. Dengan begitu user lain tidak bisa mengambil stok yang sedang menunggu pembayaran.

JS/PHP sederhana
  • Cart dibuat di session atau cache.
  • Stok sering dicek saat user klik bayar.
  • Race jarang terlihat di local development.
Go + PostgreSQL production
  • Stok dikurangi dalam transaksi database.
  • Reservasi punya masa berlaku.
  • Payment timeout mengembalikan stok secara eksplisit.
04

Isolation Level dan Snapshot

Memahami snapshot transaksi sebelum memilih strategi locking

Sebelum bicara optimistic atau pessimistic, kamu perlu tahu apa yang dilihat satu transaksi saat transaksi lain bergerak bersamaan. Itulah isolation level.

Default pgx dan PostgreSQL adalah READ COMMITTED. Artinya tiap statement di dalam transaksi melihat snapshot terbaru yang sudah commit saat statement itu mulai. Ini penting: dua SELECT di transaksi yang sama bisa mengembalikan stok berbeda jika ada commit di antaranya. Snapshot bisa basi tepat di celah antara baca dan tulis, dan di situlah race condition lahir.

isolation level

Aturan seberapa terisolasi satu transaksi dari perubahan transaksi lain yang berjalan bersamaan. PostgreSQL mendukung READ COMMITTED (default), REPEATABLE READ, dan SERIALIZABLE.

🌉Jembatan: dari Prisma/Knex ke pgx

Di Node dengan Prisma atau Knex kamu menulis isolationLevel: 'Serializable' lalu wajib me-retry error write conflict (Prisma P2034). Di Go dengan pgx sama persis: ini properti PostgreSQL, bukan keistimewaan satu ORM. Yang berubah hanya cara kamu mendeteksi dan me-retry errornya.

READ COMMITTED

Default. Tiap statement lihat snapshot terbaru. Murah, tetapi pola read-check-write tetap rawan tanpa guard atau lock di query update.

REPEATABLE READ

Snapshot dibekukan sejak statement pertama transaksi. Baca konsisten, tetapi update yang bentrok bisa gagal dengan error 40001 saat dieksekusi.

SERIALIZABLE

Seolah transaksi berjalan satu per satu. Paling aman, tetapi konflik sering muncul sebagai error 40001 justru saat COMMIT, bukan saat UPDATE.

Fakta yang kontra-intuitif bagi pembaca dari Laravel atau JS: pada REPEATABLE READ dan SERIALIZABLE, error serialization failure (SQLSTATE 40001) sering muncul saat COMMIT, bukan saat UPDATE. Maka retry loop tidak boleh hanya membungkus statement, tetapi seluruh transaksi termasuk commit-nya.

internal/postgres/retry.go
package postgres import ( "context" "errors" "time" "github.com/jackc/pgerrcode" "github.com/jackc/pgx/v5/pgconn" ) // IsRetryable mendeteksi error konkuren yang aman untuk diulang. // Pakai konstanta pgerrcode, bukan string literal "40001". func IsRetryable(err error) bool { var pgErr *pgconn.PgError if !errors.As(err, &pgErr) { return false } switch pgErr.Code { case pgerrcode.SerializationFailure, // 40001 pgerrcode.DeadlockDetected: // 40P01 return true default: return false } } // RunSerializable membungkus seluruh transaksi (termasuk Commit) dalam retry // berbatas dengan backoff. Korban serialization failure cukup diulang dari awal. func RunSerializable(ctx context.Context, fn func() error) error { const maxAttempts = 5 var err error for attempt := 1; attempt <= maxAttempts; attempt++ { err = fn() if err == nil { return nil } if !IsRetryable(err) { return err } // Backoff sederhana; produksi sebaiknya pakai jitter. select { case <-ctx.Done(): return ctx.Err() case <-time.After(time.Duration(attempt) * 10 * time.Millisecond): } } return err }
sequenceDiagram
  participant App as Go service
  participant DB as PostgreSQL (Serializable)
  App->>DB: BEGIN ISOLATION LEVEL SERIALIZABLE
  App->>DB: UPDATE inventories ... (sukses, belum commit)
  App->>DB: COMMIT
  DB-->>App: ERROR 40001 serialization_failure
  Note over App: IsRetryable true, backoff
  App->>DB: BEGIN ISOLATION LEVEL SERIALIZABLE (retry)
  App->>DB: UPDATE inventories ...
  App->>DB: COMMIT
  DB-->>App: COMMIT OK

Gambar 2. Pada SERIALIZABLE, konflik sering terdeteksi saat COMMIT. Korban di-retry dari awal dengan backoff, bukan dianggap error fatal.

📝Kapan menaikkan isolation

Untuk checkout skincare kita tetap di READ COMMITTED dan mengandalkan guard di query plus row lock. SERIALIZABLE+retry adalah alternatif sah untuk logika multi-baris yang sulit dikunci manual, tetapi harganya adalah retry yang wajib kamu siapkan.

05

Optimistic Locking

Cocok ketika konflik jarang, tetapi tetap harus aman

Optimistic locking mengasumsikan konflik jarang terjadi, lalu mendeteksi konflik dengan kolom version saat update.

Di frontend, konsep ini punya dua padanan yang sangat dekat. Pertama, optimistic UI update di React: kamu menerapkan perubahan di layar lebih dulu, lalu rollback bila server menolak. Kedua, HTTP ETag plus header If-Match: server menolak PUT/PATCH dengan 412 Precondition Failed bila resource sudah berubah. Mekanisme keduanya identik dengan optimistic locking DB, hanya beda lapisan.

optimistic locking

Strategi concurrency yang tidak mengunci baris saat membaca, tetapi menolak update jika data sudah berubah sejak dibaca.

🌉Jembatan: ETag/If-Match, optimistic UI, dan kolom version

Tiga hal ini saudara kandung. Di HTTP, ETag (versi resource) dikirim balik lewat If-Match dan gagal jadi 412/409. Di React, optimistic update rollback bila server menolak. Di DB, kolom version di-cek lewat WHERE version = $3 dan gagal jadi 409 Conflict. Pola pikirnya sama: bawa versi yang kamu baca, tolak bila sudah berubah.

SQL intinya seperti ini.

sql/reserve_optimistic.sql
UPDATE inventories SET available_stock = available_stock - $2, reserved_stock = reserved_stock + $2, version = version + 1, updated_at = now() WHERE product_id = $1 AND available_stock >= $2 AND version = $3 RETURNING version;

Kalau tidak ada row yang kembali, ada dua kemungkinan: stok tidak cukup, atau version sudah berubah karena request lain menang lebih dulu. Untuk user, dua kondisi ini bisa dipetakan ke pesan yang sama: stok baru saja habis, silakan refresh cart.

internal/inventory/repository.go
package inventory import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/kamu/skincare-backend/internal/postgres" ) var ErrStockConflict = errors.New("inventory conflict or insufficient stock") type Repository struct{} type Snapshot struct { ProductID int64 AvailableStock int ReservedStock int Version int64 } func (r Repository) GetSnapshot(ctx context.Context, db postgres.DBTX, productID int64) (Snapshot, error) { const q = ` SELECT product_id, available_stock, reserved_stock, version FROM inventories WHERE product_id = $1` var s Snapshot err := db.QueryRow(ctx, q, productID).Scan( &s.ProductID, &s.AvailableStock, &s.ReservedStock, &s.Version, ) if err != nil { return Snapshot{}, fmt.Errorf("get inventory snapshot: %w", err) } return s, nil } func (r Repository) ReserveOptimistic( ctx context.Context, db postgres.DBTX, orderID int64, productID int64, qty int, expectedVersion int64, ) error { const updateInventory = ` UPDATE inventories SET available_stock = available_stock - $2, reserved_stock = reserved_stock + $2, version = version + 1, updated_at = now() WHERE product_id = $1 AND available_stock >= $2 AND version = $3 RETURNING version` var newVersion int64 err := db.QueryRow(ctx, updateInventory, productID, qty, expectedVersion).Scan(&newVersion) if errors.Is(err, pgx.ErrNoRows) { return ErrStockConflict } if err != nil { return fmt.Errorf("reserve inventory optimistically: %w", err) } // expires_at dihitung di DB (now() + interval), bukan jam aplikasi, // agar konsisten dengan worker expiry yang juga memakai now() server DB. const insertReservation = ` INSERT INTO inventory_reservations (order_id, product_id, qty, status, expires_at) VALUES ($1, $2, $3, 'active', now() + interval '30 minutes')` if _, err := db.Exec(ctx, insertReservation, orderID, productID, qty); err != nil { return fmt.Errorf("insert inventory reservation: %w", err) } return nil }
📝Catatan pgx

pgxpool adalah connection pool concurrency-safe (rilis stabil pgx v5.10.0 per Juni 2026), sedangkan transaksi checkout tetap dijalankan lewat satu pgx.Tx agar semua perubahan commit atau rollback bersama.

💡Pecah metric konflik untuk observability

Memetakan dua sebab kegagalan (version mismatch vs stok kurang) ke satu ErrStockConflict itu rapi untuk user, tetapi membutakan observability. Bila ingin stock_conflict_total terpecah, jalankan satu SELECT available_stock ringan setelah konflik untuk membedakan keduanya. Trade-off-nya satu query ekstra hanya di jalur gagal, yang frekuensinya seharusnya rendah pada optimistic.

Optimistic locking cocok untuk katalog normal, misalnya moisturizer dengan stok ratusan dan traffic stabil. Saat konflik terjadi, aplikasi bisa retry sekali atau langsung meminta user refresh cart. Jangan retry tanpa batas karena akan memperparah tekanan ke database.

06

Pessimistic Locking

Cocok untuk flash sale dan stok sangat terbatas

Pessimistic locking mengunci row inventory selama transaksi, sehingga request lain harus menunggu giliran sebelum mengevaluasi stok terbaru.

PostgreSQL mendukung row-level lock dengan SELECT ... FOR UPDATE. Untuk reservasi inventory, lock yang lebih tepat justru SELECT ... FOR NO KEY UPDATE. Dokumentasi PostgreSQL menyarankan: selama kamu tidak menghapus row atau mengubah kolom kunci, selalu pakai FOR NO KEY UPDATE karena lock-nya lebih ringan dan tidak memblok FOR KEY SHARE (pengecekan foreign key). Reservasi kita hanya mengubah available_stock dan reserved_stock, dua kolom non-key, jadi FOR NO KEY UPDATE pas.

pessimistic locking

Strategi concurrency yang mengunci data lebih dulu karena konflik dianggap mungkin terjadi, lalu update dilakukan saat lock masih dipegang oleh transaksi.

⚠️Koreksi penting: jangan pakai SKIP LOCKED untuk checkout

SKIP LOCKED MELEWATI row yang sedang dikunci transaksi lain, bukan menunggu. Untuk merebut satu row inventory spesifik, ini fatal: saat pemenang sah memegang lock, checkout lain langsung melihat 0 row dan kamu salah menyimpulkan stok habis, padahal stok masih ada. Akibatnya bukan mencegah oversell, melainkan under-sell (penolakan palsu) yang merusak konversi flash sale. Dokumentasi PostgreSQL 18 menyebut SKIP LOCKED memberi view tidak konsisten, cocok untuk tabel mirip antrian, BUKAN untuk mengunci resource tertentu.

Pola yang benar untuk flash sale stok tipis adalah pembeli MENUNGGU giliran lock (FOR NO KEY UPDATE), lalu mengevaluasi stok terbaru. Selama stok masih ada, mereka tetap bisa menang. SKIP LOCKED baru tepat dipakai di sweeper worker (lihat Section 08), bukan di sini.

Default: single product, satu UPDATE atomik

Untuk checkout satu produk (kasus mayoritas flash sale serum), kamu tidak butuh SELECT terpisah sama sekali. Satu UPDATE dengan guard available_stock >= qty sudah atomik dan mengunci row yang sama.

sql/reserve_single.sql
UPDATE inventories SET available_stock = available_stock - $2, reserved_stock = reserved_stock + $2, version = version + 1, updated_at = now() WHERE product_id = $1 AND available_stock >= $2 RETURNING available_stock;

Bila tidak ada row yang kembali (pgx.ErrNoRows), berarti stok tidak cukup. Tidak ada race: UPDATE mengunci row dan mengevaluasi available_stock >= $2 di bawah lock yang sama.

Multi item: lock dulu dengan urutan deterministik

Untuk checkout berisi beberapa produk yang butuh evaluasi lintas baris, barulah kita lock eksplisit. Kuncinya: urutkan id sebelum mengunci DAN sebelum menulis, supaya semua transaksi mengunci dalam urutan yang sama dan deadlock dihindari.

sql/reserve_pessimistic.sql
-- Lock semua row inventory dalam urutan product_id yang sama untuk semua transaksi. -- FOR NO KEY UPDATE: menunggu giliran lock, lebih ringan dari FOR UPDATE. -- Tambahkan NOWAIT bila ingin fail-fast (error 55P03) alih-alih menunggu. SELECT product_id, available_stock FROM inventories WHERE product_id = ANY($1::bigint[]) ORDER BY product_id FOR NO KEY UPDATE;
internal/inventory/pessimistic.go
package inventory import ( "context" "fmt" "sort" "github.com/kamu/skincare-backend/internal/postgres" ) type ReserveItem struct { ProductID int64 Qty int } func (r Repository) ReservePessimistic( ctx context.Context, db postgres.DBTX, orderID int64, items []ReserveItem, ) error { qtyByProduct := make(map[int64]int, len(items)) for _, item := range items { qtyByProduct[item.ProductID] += item.Qty } // Iterasi map di Go ACAK. Untuk anti-deadlock, urutkan id lebih dulu // dan pakai urutan itu untuk SEMUA operasi tulis, bukan hanya SELECT. productIDs := make([]int64, 0, len(qtyByProduct)) for id := range qtyByProduct { productIDs = append(productIDs, id) } sort.Slice(productIDs, func(i, j int) bool { return productIDs[i] < productIDs[j] }) const lockRows = ` SELECT product_id, available_stock FROM inventories WHERE product_id = ANY($1::bigint[]) ORDER BY product_id FOR NO KEY UPDATE` rows, err := db.Query(ctx, lockRows, productIDs) if err != nil { return fmt.Errorf("lock inventory rows: %w", err) } defer rows.Close() lockedStock := make(map[int64]int, len(productIDs)) for rows.Next() { var productID int64 var availableStock int if err := rows.Scan(&productID, &availableStock); err != nil { return fmt.Errorf("scan locked inventory row: %w", err) } lockedStock[productID] = availableStock } if err := rows.Err(); err != nil { return fmt.Errorf("iterate locked inventory rows: %w", err) } // Dengan FOR NO KEY UPDATE (bukan SKIP LOCKED), semua row yang ada PASTI // terkunci untuk kita. Jumlah kurang berarti ada product_id yang memang // tidak punya baris inventory. if len(lockedStock) != len(qtyByProduct) { return ErrStockConflict } // Tulis dalam urutan id yang sama dengan urutan lock. for _, productID := range productIDs { qty := qtyByProduct[productID] if lockedStock[productID] < qty { return ErrStockConflict } const updateInventory = ` UPDATE inventories SET available_stock = available_stock - $2, reserved_stock = reserved_stock + $2, version = version + 1, updated_at = now() WHERE product_id = $1` if _, err := db.Exec(ctx, updateInventory, productID, qty); err != nil { return fmt.Errorf("update reserved inventory: %w", err) } const insertReservation = ` INSERT INTO inventory_reservations (order_id, product_id, qty, status, expires_at) VALUES ($1, $2, $3, 'active', now() + interval '30 minutes')` if _, err := db.Exec(ctx, insertReservation, orderID, productID, qty); err != nil { return fmt.Errorf("insert inventory reservation: %w", err) } } return nil }

Perhatikan dua perbaikan kecil yang besar dampaknya. Pertama, productIDs di-sort sehingga urutan lock dan urutan tulis identik di semua transaksi, mematikan kelas deadlock multi item. Kedua, expires_at dihitung di DB lewat now() + interval '30 minutes', bukan jam aplikasi, sehingga argumen expiresAt time.Time dihapus dari signature. Satu sumber waktu (jam server DB) dipakai konsisten oleh checkout dan worker expiry, mencegah drift clock antara aplikasi dan database.

sequenceDiagram
  participant A as User A
  participant B as User B
  participant DB as PostgreSQL
  A->>DB: BEGIN; SELECT ... FOR NO KEY UPDATE (stok 1)
  B->>DB: BEGIN; SELECT ... FOR NO KEY UPDATE
  Note over B,DB: B MENUNGGU lock A, tidak dilewati
  A->>DB: UPDATE available_stock 1 to 0; COMMIT
  DB-->>B: lock dilepas, B dapat giliran
  B->>DB: baca available_stock 0
  Note over B: stok kurang, ROLLBACK
  DB-->>B: ditolak 409 Conflict

Gambar 3. Solusi anti-oversell yang benar: B menunggu lock A (bukan dilewati SKIP LOCKED), lalu membaca stok 0 dan ditolak 409. Bandingkan dengan race di Gambar 1.

🌉Jembatan: lockForUpdate() di Laravel

Di Laravel kamu menulis Inventory::where('product_id',$id)->lockForUpdate()->first() di dalam DB::transaction(). Itu persis SELECT ... FOR UPDATE yang MENUNGGU giliran. Eloquent tidak punya SKIP LOCKED bawaan, jadi instingmu dari Laravel sudah benar: untuk checkout, lock berbasis-tunggu adalah default yang tepat. Yang ditambahkan Go hanya pilihan FOR NO KEY UPDATE yang lebih ringan.

Untuk flash sale, pola ini mudah diprediksi: pemenang mendapat lock, request lain menunggu sebentar lalu mengevaluasi stok terbaru. Selama stok ada, mereka tetap bisa menang. Bila ingin fail-fast tanpa menunggu lama, tambahkan NOWAIT agar transaksi yang gagal mendapat lock langsung melempar error 55P03 (lock_not_available) untuk dipetakan ke 409.

07

Memilih Strategi Locking

Optimistic dan pessimistic sama-sama benar jika dipakai pada konteks yang tepat

Tidak ada satu locking strategy yang selalu menang. Pilih berdasarkan tingkat konflik, stok, dan pengalaman user.

Optimistic

Cocok untuk produk normal dengan stok cukup dan konflik rendah. Query cepat, lock eksplisit minim, tetapi butuh handling conflict.

Pessimistic

Cocok untuk flash sale, stok tipis, atau produk viral. Lebih ketat, tetapi transaksi harus pendek agar lock tidak menumpuk.

Hybrid

Gunakan optimistic sebagai default, lalu aktifkan pessimistic untuk campaign tertentu lewat feature flag atau field sale_mode.

Optimistic locking
  • Konflik dianggap jarang.
  • Gagal saat version berubah.
  • Baik untuk traffic normal.
  • Butuh retry atau pesan stok berubah.
Pessimistic locking
  • Konflik dianggap sering.
  • Row dikunci selama transaksi.
  • Baik untuk flash sale.
  • Butuh transaksi sangat pendek.

Peta dari konsep generik ke mekanisme PostgreSQL

Istilah optimistic dan pessimistic itu generik. Di PostgreSQL keduanya punya wujud konkret yang sering tercampur. Pessimistic = lock-based (FOR UPDATE / FOR NO KEY UPDATE / advisory lock). Optimistic = conflict-detection, dan ini tidak hanya berarti kolom version: SERIALIZABLE juga optimistic karena tidak mengunci, melainkan mendeteksi konflik di akhir lalu menolak lewat error 40001.

PendekatanWujud di PostgreSQLCara gagalKapan dipakai
Optimistic (versi)Kolom version + WHERE version = $n0 row terupdate, dipetakan ke 409Katalog normal, konflik rendah
Optimistic (conflict-detection)SERIALIZABLE + retryError 40001, sering saat COMMITLogika multi-baris yang sulit dikunci manual
Pessimistic (row lock)FOR NO KEY UPDATE (wait) atau NOWAITMenunggu, lalu evaluasi, atau 55P03Flash sale, stok tipis, row sudah ada
Pessimistic (advisory)pg_advisory_xact_lock(key)Serialize per key, lepas di akhir txRow belum ada, atau gembok per-produk
quadrantChart
  title Memilih strategi consistency
  x-axis Konflik rendah --> Konflik tinggi
  y-axis Murah menunggu --> Mahal menunggu
  quadrant-1 Pakai version atau SERIALIZABLE
  quadrant-2 Optimistic dengan retry
  quadrant-3 Optimistic version default
  quadrant-4 Row lock FOR NO KEY UPDATE
  Optimistic version: [0.2, 0.25]
  SERIALIZABLE retry: [0.35, 0.8]
  FOR NO KEY UPDATE wait: [0.75, 0.3]
  NOWAIT atau SKIP queue: [0.85, 0.75]
  Advisory lock: [0.6, 0.55]

Gambar 4. Sumbu konflik (kiri-kanan) dan biaya menunggu (bawah-atas) memetakan strategi. Konflik rendah cenderung optimistic, konflik tinggi cenderung row lock.

Advisory lock: gembok per produk yang lepas otomatis

Kadang row inventory belum ada (produk baru pertama kali di-stok), atau kamu ingin men-serialize seluruh checkout per produk tanpa bergantung pada keberadaan row tertentu. PostgreSQL menyediakan advisory lock: gembok berbasis angka yang kamu definisikan sendiri. Varian pg_advisory_xact_lock otomatis lepas saat transaksi selesai, jadi tidak ada risiko lupa unlock.

internal/inventory/advisory.go
package inventory import ( "context" "fmt" "github.com/kamu/skincare-backend/internal/postgres" ) // LockProduct menahan gembok per-produk selama transaksi berjalan. // Cocok saat row inventory mungkin belum ada, atau ingin serialize // seluruh logika checkout untuk satu product_id. func (r Repository) LockProduct(ctx context.Context, db postgres.DBTX, productID int64) error { const q = `SELECT pg_advisory_xact_lock($1)` if _, err := db.Exec(ctx, q, productID); err != nil { return fmt.Errorf("acquire advisory lock for product %d: %w", productID, err) } return nil }
📝Row lock dulu, advisory belakangan

Untuk skincare yang row inventory-nya selalu ada, FOR NO KEY UPDATE lebih natural karena langsung mengunci data yang akan diubah. Advisory lock berguna untuk kasus tepi: row belum ada, atau koordinasi lintas tabel per produk. Jangan pakai keduanya bersamaan untuk resource yang sama tanpa alasan kuat.

Aturan praktis untuk proyek skincare:

Produk katalog biasa

Pakai optimistic locking. Konflik rendah, throughput bagus, dan failure bisa ditangani dengan pesan stok berubah.

Produk stok rendah

Pakai optimistic dengan retry satu kali, tetapi jangan retry tanpa batas. Kalau conflict rate naik, pindahkan ke pessimistic.

Flash sale atau bundle viral

Pakai pessimistic locking FOR NO KEY UPDATE dengan transaksi pendek, id ter-sort saat multi item, dan NOWAIT bila ingin respons cepat ketika lock tidak didapat.

💡Gunakan data, bukan feeling

Catat metric stock_conflict_total, checkout_latency_ms, dan reservation_expired_total. Strategi locking sebaiknya berubah karena data production, bukan karena preferensi pribadi.

08

Reservation Expiry

Stok yang tidak dibayar harus kembali otomatis

Reservasi stok tanpa expiry akan mengunci barang selamanya saat user meninggalkan halaman pembayaran.

Saat checkout berhasil dibuat, order masuk status pending_payment dan semua reservation punya expires_at, dihitung di DB sebagai now() + interval '30 minutes'. Jika payment tidak sukses sampai batas itu, reservation berubah menjadi expired dan stok dikembalikan.

Reservasi punya state machine sendiri, terpisah dari state order. Memahami transisi ini penting karena sweeper, checkout, dan webhook payment sama-sama menyentuhnya.

stateDiagram-v2
  [*] --> active: checkout sukses
  active --> consumed: payment success
  active --> expired: sweeper di expires_at
  active --> cancelled: user batal
  consumed --> [*]
  expired --> [*]
  cancelled --> [*]

Gambar 5. Lifecycle satu reservation. Dari active hanya ada tiga jalan keluar, dan semuanya final. Tidak ada jalan kembali ke active.

📝SQS delay 30 menit

Amazon SQS delay queues dan message timers hanya mendukung delay sampai 15 menit (default 0). Untuk expiry 30 menit, gunakan cron scanner berbasis database atau EventBridge Scheduler yang mendukung one-time schedule tanpa batas tersebut.

🌉Jembatan: dari delay() Laravel ke SQS

Di Laravel kamu menulis SomeJob::dispatch()->delay(now()->addMinutes(30)) dan terasa tak terbatas, karena driver database atau Redis tidak punya plafon. Begitu job itu dipindah ke driver SQS, plafon 15 menit muncul mendadak. Jadi jangan kaget: yang berubah bukan Laravel-nya, melainkan kapasitas SQS. Untuk timeout 30 menit, sweeper berbasis DB atau EventBridge Scheduler adalah jawabannya.

Cron scanner sederhana lebih mudah untuk modular monolith. Worker berjalan tiap 1 menit, mencari reservation aktif yang sudah expired, mengubah status order, lalu mengembalikan stok dalam satu transaksi.

sql/expire_reservations.sql
WITH expired_reservations AS ( UPDATE inventory_reservations SET status = 'expired', updated_at = now() WHERE status = 'active' AND expires_at <= now() RETURNING order_id, product_id, qty ), totals AS ( SELECT product_id, SUM(qty)::INTEGER AS qty FROM expired_reservations GROUP BY product_id ), expired_orders AS ( SELECT DISTINCT order_id FROM expired_reservations ) UPDATE inventories AS i SET available_stock = i.available_stock + totals.qty, reserved_stock = GREATEST(i.reserved_stock - totals.qty, 0), version = i.version + 1, updated_at = now() FROM totals WHERE i.product_id = totals.product_id; UPDATE orders SET status = 'cancelled', cancel_reason = 'payment_timeout', updated_at = now() WHERE status = 'pending_payment' AND payment_expires_at <= now();

Versi Go worker-nya menjaga semua operasi di satu transaksi.

internal/worker/expire_orders.go
package worker import ( "context" "fmt" "github.com/jackc/pgx/v5/pgxpool" ) type ExpireOrdersWorker struct { pool *pgxpool.Pool } func NewExpireOrdersWorker(pool *pgxpool.Pool) ExpireOrdersWorker { return ExpireOrdersWorker{pool: pool} } func (w ExpireOrdersWorker) RunOnce(ctx context.Context) error { tx, err := w.pool.Begin(ctx) if err != nil { return fmt.Errorf("begin expire orders transaction: %w", err) } defer tx.Rollback(ctx) const releaseExpiredStock = ` WITH expired_reservations AS ( UPDATE inventory_reservations SET status = 'expired', updated_at = now() WHERE status = 'active' AND expires_at <= now() RETURNING product_id, qty ), totals AS ( SELECT product_id, SUM(qty)::INTEGER AS qty FROM expired_reservations GROUP BY product_id ) UPDATE inventories AS i SET available_stock = i.available_stock + totals.qty, reserved_stock = GREATEST(i.reserved_stock - totals.qty, 0), version = i.version + 1, updated_at = now() FROM totals WHERE i.product_id = totals.product_id` if _, err := tx.Exec(ctx, releaseExpiredStock); err != nil { return fmt.Errorf("release expired stock: %w", err) } const cancelExpiredOrders = ` UPDATE orders SET status = 'cancelled', cancel_reason = 'payment_timeout', updated_at = now() WHERE status = 'pending_payment' AND payment_expires_at <= now()` if _, err := tx.Exec(ctx, cancelExpiredOrders); err != nil { return fmt.Errorf("cancel expired orders: %w", err) } if err := tx.Commit(ctx); err != nil { return fmt.Errorf("commit expire orders transaction: %w", err) } return nil }
⚠️Jangan release stok dua kali

Semua query release harus memfilter status = 'active' dan mengubah status menjadi final. Ini membuat proses expiry aman dijalankan berulang.

💡Kenapa race release vs consume aman

Bayangkan user membayar tepat di detik expiry: query consume dan query release berlomba untuk reservation yang sama. Keduanya memfilter WHERE status = 'active', dan UPDATE bersifat atomik di tingkat baris. Hanya SATU yang berhasil memindahkan baris dari active ke status final, yang lain melihat 0 row terupdate. Jadi reservation tidak mungkin di-consume sekaligus di-expire. Inilah jaminan yang membuat sweeper aman berjalan bersama checkout.

Sweeper yang scalable: di SINI SKIP LOCKED benar

Worker satu-transaksi-untuk-semua di atas sederhana, tetapi pada volume tinggi ia mengunci banyak baris reservation lama sekaligus dan hanya bisa berjalan satu instance. Di sinilah pelajaran emasnya: inventory_reservations yang expired adalah tabel mirip antrian, dan FOR UPDATE SKIP LOCKED justru TEPAT di sini. Beberapa instance worker bisa memungut batch reservation berbeda tanpa saling memblok, karena melewati baris yang sudah dipegang worker lain memang yang kita inginkan.

sql/sweep_expired_batch.sql
-- Pola QUEUE: ambil batch reservation expired yang BEBAS, lewati yang -- sedang diproses worker lain. SKIP LOCKED tepat justru di sini. WITH batch AS ( SELECT id FROM inventory_reservations WHERE status = 'active' AND expires_at <= now() ORDER BY expires_at LIMIT 200 FOR UPDATE SKIP LOCKED ) UPDATE inventory_reservations AS r SET status = 'expired', updated_at = now() FROM batch WHERE r.id = batch.id RETURNING r.product_id, r.qty;
⚠️Kontras yang wajib menempel

SKIP LOCKED SALAH di checkout (merebut row spesifik, lihat Section 06) tetapi BENAR di sweeper (memungut pekerjaan apa pun yang bebas). Bedanya bukan teknologinya, melainkan polanya: checkout butuh row tertentu, sweeper butuh row apa saja. Ironi yang sering terjadi di kode produksi adalah memakai SKIP LOCKED di checkout dan tidak memakainya di sweeper, persis terbalik.

09

Payment Timeout

Order dan inventory harus bergerak sebagai satu state machine

Payment timeout bukan hanya urusan payment gateway, tetapi juga lifecycle stok yang sedang di-reserve.

Saat payment sukses, reservation tidak dikembalikan ke available_stock. Stok tersebut sudah dibeli. Yang dilakukan sistem adalah mengubah reservation menjadi consumed, mengurangi reserved_stock, lalu mengubah order menjadi paid.

stateDiagram-v2
  [*] --> PendingPayment: checkout sukses
  PendingPayment --> Paid: PaymentSucceeded
  PendingPayment --> Cancelled: payment timeout
  PendingPayment --> Cancelled: user cancel
  Paid --> Fulfillment: siap diproses gudang
  Cancelled --> [*]

Gambar 6. Order pending payment harus berakhir ke paid atau cancelled, tidak boleh menggantung.

Reservation expiry (Section 08) dan payment timeout sebenarnya satu cerita dilihat dari sudut berbeda. Timeline berikut menyatukannya.

flowchart LR
  T0["t0: checkout
reserved, expires_at = t0 + 30m"] --> Q{Bayar sebelum t0 + 30m?}
  Q -->|Ya| C["consume_reservation
reservation consumed, order paid"]
  Q -->|Tidak| S["sweeper di t > expires_at
reservation expired, stok kembali, order cancelled"]

Gambar 7. Satu cabang waktu menentukan nasib reservation: dibayar tepat waktu jadi consumed, lewat tenggat jadi expired oleh sweeper.

sql/consume_reservation.sql
WITH target_order AS ( UPDATE orders SET status = 'paid', updated_at = now() WHERE id = $1 AND status = 'pending_payment' AND payment_expires_at > now() RETURNING id ), consumed AS ( UPDATE inventory_reservations AS r SET status = 'consumed', updated_at = now() FROM target_order WHERE r.order_id = target_order.id AND r.status = 'active' RETURNING r.product_id, r.qty ), totals AS ( SELECT product_id, SUM(qty)::INTEGER AS qty FROM consumed GROUP BY product_id ) UPDATE inventories AS i SET reserved_stock = GREATEST(i.reserved_stock - totals.qty, 0), version = i.version + 1, updated_at = now() FROM totals WHERE i.product_id = totals.product_id;
🌉Jembatan: dari webhook Laravel ke Go worker idempotent

Di Laravel, webhook sering langsung update order di controller. Di Go, jadikan webhook tipis: verifikasi signature, simpan event, lalu service idempotent mengubah order dan reservation dalam transaksi. Idempotency itu kuncinya, karena gateway sering mengirim event yang sama dua kali.

Idempotency di Go tidak butuh framework khusus. Simpan event_id dari gateway ke tabel processed_events dengan UNIQUE constraint, lalu skip bila sudah ada. Ini padanan langsung dari job ShouldBeUnique di Laravel: keduanya memastikan satu event hanya diproses sekali.

sql/processed_events.sql
CREATE TABLE processed_events ( event_id TEXT PRIMARY KEY, processed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Di awal transaksi consume: gagal diam-diam bila event sudah pernah diproses. INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT (event_id) DO NOTHING RETURNING event_id;

Bila RETURNING tidak mengembalikan baris, event sudah pernah diproses, jadi service cukup berhenti tanpa menyentuh stok lagi. Untuk timeout, jangan hanya mengandalkan frontend. Browser bisa ditutup, jaringan user bisa putus, dan webhook gateway bisa terlambat. Database tetap harus menjadi sumber kebenaran status order.

10

Checkout Flow yang Konsisten

Semua perubahan inti terjadi dalam transaksi pendek

Checkout yang aman membungkus create order, reserve inventory, dan create payment attempt dalam satu transaksi pendek.

Endpoint utama tetap sederhana dari sudut pandang HTTP.

POST /v1/checkout Buat order dari cart dan reserve stok selama 30 menit
POST /v1/webhooks/payment Terima event payment, consume reservation, dan ubah order menjadi paid
flowchart TD
  A[POST /v1/checkout] --> B[BEGIN transaction]
  B --> C[Create order pending_payment]
  C --> D{Inventory strategy}
  D -->|Normal catalog| E[Optimistic update dengan version]
  D -->|Flash sale| F[SELECT FOR NO KEY UPDATE wait]
  E --> G[Insert inventory_reservations]
  F --> G
  G --> H[Create payment attempt]
  H --> I[COMMIT]
  I --> J[Return payment URL]
  E -->|conflict| K[ROLLBACK dan 409 Conflict]
  F -->|stok kurang| K

Gambar 8. Core checkout harus commit cepat, side effect seperti email dan shipping diproses setelah state inti aman.

Contoh service berikut memakai strategi pessimistic untuk campaign flash sale. Untuk katalog normal, ganti pemanggilan ReservePessimistic dengan ReserveOptimistic setelah membaca snapshot.

internal/checkout/service.go
package checkout import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5/pgxpool" "github.com/kamu/skincare-backend/internal/inventory" "github.com/kamu/skincare-backend/internal/postgres" ) var ( ErrCheckoutConflict = errors.New("some products are no longer available") ErrProductNotFound = errors.New("product not found") ErrCartExpired = errors.New("cart expired") ) // Repository menghitung expiry dengan now() + interval '30 minutes' DI DB, // bukan jam aplikasi. Satu sumber waktu (jam server DB) dipakai konsisten // oleh checkout, sweeper, dan consume agar tidak ada drift clock. type OrderRepository interface { CreatePending(ctx context.Context, db postgres.DBTX, input CreateOrderInput) (int64, error) AttachPaymentExpiry(ctx context.Context, db postgres.DBTX, orderID int64) error } type InventoryRepository interface { ReservePessimistic(ctx context.Context, db postgres.DBTX, orderID int64, items []inventory.ReserveItem) error } type PaymentRepository interface { CreateAttempt(ctx context.Context, db postgres.DBTX, orderID int64) error } type Service struct { pool *pgxpool.Pool orders OrderRepository inventories InventoryRepository payments PaymentRepository } type CreateOrderInput struct { CustomerID int64 Items []inventory.ReserveItem } func (s Service) Checkout(ctx context.Context, input CreateOrderInput) (int64, error) { tx, err := s.pool.Begin(ctx) if err != nil { return 0, fmt.Errorf("begin checkout transaction: %w", err) } defer tx.Rollback(ctx) orderID, err := s.orders.CreatePending(ctx, tx, input) if err != nil { return 0, fmt.Errorf("create pending order: %w", err) } if err := s.inventories.ReservePessimistic(ctx, tx, orderID, input.Items); err != nil { if errors.Is(err, inventory.ErrStockConflict) { return 0, ErrCheckoutConflict } return 0, fmt.Errorf("reserve inventory: %w", err) } if err := s.orders.AttachPaymentExpiry(ctx, tx, orderID); err != nil { return 0, fmt.Errorf("attach payment expiry: %w", err) } if err := s.payments.CreateAttempt(ctx, tx, orderID); err != nil { return 0, fmt.Errorf("create payment attempt: %w", err) } if err := tx.Commit(ctx); err != nil { return 0, fmt.Errorf("commit checkout transaction: %w", err) } return orderID, nil }

Karena time tidak lagi dipakai di service (expiry dihitung di DB), hapus importnya agar gofmt dan go vet bersih. Inilah konsekuensi memindahkan satu sumber waktu ke server database.

💡Interface kecil untuk transaksi

Repository menerima postgres.DBTX, bukan *pgxpool.Pool. Dengan begitu service bisa mengirim pgx.Tx saat checkout, tetapi repository tetap mudah dites.

Boundary repository menerima interface kecil, sehingga service bisa mengontrol transaksi dan test bisa memberi fake database jika diperlukan.

internal/postgres/dbtx.go
package postgres import ( "context" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) type DBTX interface { Exec(ctx context.Context, sql string, arguments ...any) (pgconn.CommandTag, error) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row }
📝Kontrak: repository tidak commit

DBTX sengaja tidak mengekspos Begin, Commit, atau Rollback. Kontraknya tegas: repository hanya mengeksekusi statement di transaksi yang DIBERIKAN service. Commit dan rollback adalah tanggung jawab service yang memegang pgx.Tx. Memisahkan ini mencegah repository diam-diam menutup transaksi yang masih dipakai pemanggil.

Memetakan error domain ke status HTTP

Target modul ini, mencegah oversell saat checkout konkuren, baru lengkap ketika handler HTTP menerjemahkan error domain dengan benar. 409 Conflict untuk konflik stok, 500 hanya untuk error tak terduga. Jangan sampai konflik stok bocor sebagai 500, karena itu menyembunyikan masalah konsistensi di balik error generik.

internal/checkout/handler.go
package checkout import ( "encoding/json" "errors" "net/http" ) type Handler struct { service Service } func (h Handler) Create(w http.ResponseWriter, r *http.Request) { var input CreateOrderInput if err := json.NewDecoder(r.Body).Decode(&input); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } orderID, err := h.service.Checkout(r.Context(), input) if err != nil { writeCheckoutError(w, err) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]int64{"order_id": orderID}) } // writeCheckoutError memetakan tiap error domain ke status yang jujur. func writeCheckoutError(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrCheckoutConflict): // Stok direbut transaksi lain: konflik, bukan kesalahan server. writeError(w, http.StatusConflict, "some products are no longer available") case errors.Is(err, ErrProductNotFound): writeError(w, http.StatusUnprocessableEntity, "product not found") case errors.Is(err, ErrCartExpired): writeError(w, http.StatusGone, "cart expired, please rebuild") default: // Error tak terduga: jangan bocorkan detail, log di middleware. writeError(w, http.StatusInternalServerError, "internal error") } } func writeError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) }

Perhatikan pemetaan yang membedakan tiga jenis penolakan: 409 untuk konflik stok (resource sedang diperebutkan), 422 untuk produk tidak valid (request bisa diproses tetapi datanya salah), dan 410 untuk cart yang sudah kedaluwarsa. Membedakan ini membantu frontend memberi pesan yang tepat: refresh cart untuk 409, perbaiki item untuk 422, mulai ulang untuk 410.

💡Transaksi harus pendek

Jangan panggil payment gateway, email provider, atau API shipping saat transaksi inventory masih memegang lock. Simpan state inti dulu, commit, lalu side effect berjalan lewat event atau worker.

11

Load Test Concurrent Checkout

Buktikan tidak oversell dengan simulasi paralel

Konsistensi tidak cukup dibuktikan dengan unit test. Kamu perlu simulasi request concurrent terhadap stok kecil.

Target test: set stok produk menjadi 1, kirim 100 request checkout bersamaan, lalu pastikan hanya 1 yang mendapat 201 Created. Sisanya harus 409 Conflict, bukan 500, bukan order sukses ganda.

Seed stok rendah

loadtest/seed_flash_sale.sql
UPDATE inventories SET available_stock = 1, reserved_stock = 0, version = version + 1, updated_at = now() WHERE product_id = 101; DELETE FROM inventory_reservations WHERE product_id = 101;

Opsi cepat dengan hey

hey cocok untuk smoke load test sederhana. Repository rakyll/hey mendeskripsikannya sebagai HTTP load generator yang menjalankan sejumlah request pada tingkat concurrency tertentu.

Terminal
go install github.com/rakyll/hey@latest hey \ -n 100 \ -c 100 \ -m POST \ -T 'application/json' \ -H 'Authorization: Bearer test-customer-token' \ -d '{"items":[{"product_id":101,"qty":1}]}' \ http://localhost:8080/v1/checkout

Opsi lebih realistis dengan k6

k6 lebih nyaman untuk skenario bertahap, threshold, dan script yang bisa masuk CI. Simpan file berikut, lalu jalankan k6 run loadtest/checkout.js.

Ada jebakan halus di test consistency dengan k6: secara default k6 menandai status 4xx/5xx sebagai failed. Untuk stok 1 dan 100 request, 99 di antaranya membalas 409, sehingga 99 persen request terhitung gagal. Cara yang benar BUKAN melonggarkan threshold ke rate<0.99 (itu menyesatkan), melainkan memberi tahu k6 bahwa 409 memang status yang DIHARAPKAN lewat http.expectedStatuses, lalu menghitung 201 vs 409 dengan Counter kustom.

loadtest/checkout.js
import http from 'k6/http'; import { check } from 'k6'; import { Counter } from 'k6/metrics'; // 409 adalah hasil yang DIHARAPKAN di test ini, bukan kegagalan. http.setResponseCallback(http.expectedStatuses(201, 409)); const createdCount = new Counter('checkout_created'); const conflictCount = new Counter('checkout_conflict'); export const options = { vus: 100, iterations: 100, thresholds: { // Sekarang 'failed' = benar-benar error (5xx, koneksi gagal), jadi ambang // ketat kembali masuk akal. Jangan jadikan rate<0.99 template produksi. http_req_failed: ['rate<0.01'], http_req_duration: ['p(95)<1000'], checkout_created: ['count<=1'], }, }; export default function () { const payload = JSON.stringify({ items: [{ product_id: 101, qty: 1 }], }); const params = { headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-customer-token', }, }; const res = http.post('http://localhost:8080/v1/checkout', payload, params); if (res.status === 201) { createdCount.add(1); } else if (res.status === 409) { conflictCount.add(1); } check(res, { 'created or conflict': (r) => r.status === 201 || r.status === 409, 'no server error': (r) => r.status < 500, }); }
⚠️Jangan jadikan rate<0.99 template threshold

Threshold http_req_failed: ['rate<0.99'] hanya masuk akal jika kamu sengaja mengharapkan banyak 409. Untuk endpoint produksi biasa, ambang lazimnya rate<0.01. Dengan expectedStatuses di atas, 409 tidak lagi terhitung gagal, jadi kamu bisa kembali ke ambang ketat dan tetap memantau p(95) durasi sebagai sinyal latency.

Verifikasi database setelah test

loadtest/assert_no_oversell.sql
SELECT available_stock, reserved_stock FROM inventories WHERE product_id = 101; SELECT status, COUNT(*) FROM orders WHERE created_at >= now() - interval '5 minutes' GROUP BY status ORDER BY status; SELECT status, COUNT(*) FROM inventory_reservations WHERE product_id = 101 AND created_at >= now() - interval '5 minutes' GROUP BY status ORDER BY status;
📝Interpretasi hasil

Kalau ada lebih dari satu order pending atau paid untuk stok 1, locking kamu bocor. Kalau banyak 500, error mapping kamu belum production-ready. Kalau latency tinggi, transaksi mungkin terlalu panjang.

12

Jebakan Umum

Masalah consistency biasanya muncul dari detail kecil

Kesalahan paling berbahaya adalah kode yang terlihat benar di local tetapi tidak aman saat concurrent.

Read lalu update tanpa guard

Query SELECT stock, validasi di Go, lalu UPDATE stock tanpa available_stock >= qty membuka race condition.

SKIP LOCKED di checkout

Memakai SKIP LOCKED untuk merebut row inventory spesifik menyebabkan penolakan palsu (under-sell), bukan mencegah oversell. Pakai FOR NO KEY UPDATE.

Transaksi terlalu panjang

Memanggil payment gateway saat row terkunci membuat request lain menunggu dan memperbesar timeout.

Lock multi item tanpa urutan

Dua checkout dengan item A dan B bisa deadlock (error 40P01) jika transaksi mengunci dalam urutan berbeda. Korban harus di-retry.

Iterasi map untuk menulis

Urutan iterasi range map di Go acak, jadi urutan UPDATE jadi acak dan strategi anti-deadlock rusak. Selalu sort id dulu.

Expiry tidak idempotent

Worker yang bisa release stok dua kali akan membuat stok tersedia lebih tinggi daripada stok fisik.

Retry liar

Retry tanpa backoff dan batas akan mengubah conflict kecil menjadi badai query ke database.

Status order menggantung

Order pending_payment tanpa payment_expires_at membuat reserved stock tidak pernah kembali.

⚠️Deadlock 40P01: deteksi otomatis, korban di-retry

Bila dua checkout mengunci produk A dan B dalam urutan terbalik, PostgreSQL mendeteksi deadlock dan otomatis membatalkan salah satu transaksi dengan error SQLSTATE 40P01 (deadlock_detected). Korban tidak crash database, ia hanya gagal dan WAJIB di-retry, sama seperti serialization failure 40001. Deteksi pakai errors.As ke *pgconn.PgError lalu bandingkan pgErr.Code dengan pgerrcode.DeadlockDetected, bukan string "40P01". Pencegahan terbaik tetap mengunci dalam urutan id yang sudah di-sort.

🌉Jembatan: optimistic UI React vs optimistic locking DB

Di React, optimistic update menerapkan perubahan di layar lalu rollback bila server menolak. Di DB, optimistic locking menerapkan UPDATE lalu menolak bila version berubah. Istilahnya mirip karena mekanismenya memang sama: bertindak dulu dengan asumsi sukses, sediakan jalan mundur saat konflik. Stale state di React selesai dengan refetch, stale state di checkout harus dicegah di database karena uang dan stok bergantung padanya.

Checklist minimal sebelum production:

Guard di query update

Pastikan update stok punya kondisi available_stock >= qty atau row lock yang sudah diverifikasi.

Lock berbasis tunggu, id ter-sort

Pakai FOR NO KEY UPDATE (bukan SKIP LOCKED) untuk checkout, dan sort id sebelum mengunci serta menulis.

Retry error konkuren

Tangani 40001 dan 40P01 lewat pgerrcode dengan retry berbatas plus backoff, jangan anggap fatal.

Transaksi pendek

Simpan order dan reservation saja di transaksi inti. Side effect berjalan setelah commit.

Expiry idempotent

Worker hanya memproses reservation active, lalu mengubahnya ke status final sebelum stok dikembalikan.

Load test stok kecil

Simulasikan 100 checkout untuk stok 1 dan cek hasil database, bukan hanya status HTTP.

13

Ringkasan & Poin Penting

Konsistensi inventory adalah fondasi scaling checkout

Di modul ini, kita mengubah checkout dari flow CRUD biasa menjadi flow konsisten yang tahan traffic tinggi.

Yang Wajib Menempel

  • Overselling terjadi saat cek stok dan update stok tidak atomik.
  • Default isolation pgx/PostgreSQL adalah READ COMMITTED, jadi snapshot bisa basi di celah baca-tulis.
  • Optimistic locking memakai version, cocok untuk konflik rendah dan katalog normal. SERIALIZABLE adalah optimistic lewat conflict-detection (error 40001 sering muncul saat COMMIT).
  • Pessimistic locking untuk checkout memakai SELECT ... FOR NO KEY UPDATE yang MENUNGGU giliran lock, BUKAN SKIP LOCKED. SKIP LOCKED akan menyebabkan penolakan palsu (under-sell).
  • SKIP LOCKED justru benar di sweeper worker (pola queue), tempat beberapa instance memungut reservation expired berbeda tanpa saling memblok.
  • Error konkuren 40001 (serialization) dan 40P01 (deadlock) dideteksi via pgconn.PgError + pgerrcode, lalu di-retry berbatas dengan backoff.
  • Reservasi stok harus punya expiry, dihitung di DB now() + interval '30 minutes', satu sumber waktu agar tidak drift.
  • SQS delay biasa hanya sampai 15 menit, jadi expiry 30 menit lebih aman memakai cron scanner atau EventBridge Scheduler.
  • Payment success mengubah reservation menjadi consumed, sedangkan payment timeout mengubah reservation menjadi expired dan mengembalikan stok; webhook harus idempotent.
  • Handler memetakan ErrCheckoutConflict ke 409, dan error tak terduga ke 500. Konflik stok tidak boleh bocor sebagai 500.
  • Load test dengan k6 harus membuktikan hanya satu checkout menang saat stok tinggal satu, dengan 409 ditandai sebagai status yang diharapkan.

Dalam proyek online shop skincare, modul ini membuat checkout siap menghadapi campaign besar. User boleh datang bersamaan, payment boleh terlambat, dan worker boleh berjalan berulang, tetapi database tetap menjaga fakta utama: stok tidak boleh negatif dan order tidak boleh melebihi barang yang tersedia.

Langkah berikutnya di Roadmap 9 adalah membawa pola consistency ini ke skala yang lebih luas: observability khusus untuk conflict rate, tuning connection pool saat flash sale, dan keputusan kapan inventory perlu dipisah menjadi service sendiri.

Progress disimpan lokal di browser ini.