Optimistic Locking #

Di sistem dengan banyak concurrent user, ada pertanyaan mendasar yang harus dijawab: apa yang terjadi ketika dua request mencoba mengubah data yang sama pada saat yang hampir bersamaan? Jawabannya tergantung strategi concurrency control yang dipakai. Optimistic locking adalah strategi yang berasumsi konflik jarang terjadi — jadi tidak perlu mengunci data saat dibaca, cukup verifikasi bahwa data belum berubah saat akan ditulis. Jika verifikasi gagal, tolak update dan beri sinyal ke client untuk retry. Pendekatan ini jauh lebih ringan dari pessimistic locking untuk sistem read-heavy dengan konflik rendah — tapi jika diimplementasikan dengan cara yang salah, khususnya dengan menggabungkan version check bersama SELECT FOR UPDATE dan logika bisnis panjang di dalam satu transaksi, ia berubah menjadi pessimistic locking yang lebih mahal. Artikel ini membahas cara kerja yang benar, implementasi konkret, pola retry yang aman, dan kapan optimistic locking memang bukan pilihan yang tepat.

Masalah yang Dipecahkan: Lost Update #

Sebelum masuk ke implementasi, penting memahami masalah konkret yang diselesaikan optimistic locking. Masalah ini disebut lost update — update yang hilang karena dua transaksi membaca data yang sama, memodifikasinya, lalu keduanya menulis kembali, dan salah satu penulisan menimpa yang lain.

Skenario Lost Update tanpa concurrency control:
──────────────────────────────────────────────────────────────
  Tabel: products
  Baris: id=42, stock=10

  Waktu  │ Request A (beli 3)      │ Request B (beli 5)
  ───────┼─────────────────────────┼─────────────────────────
  T1     │ SELECT stock FROM ...   │
         │ → stock = 10            │
  T2     │                         │ SELECT stock FROM ...
         │                         │ → stock = 10
  T3     │ stock_baru = 10 - 3 = 7 │
  T4     │                         │ stock_baru = 10 - 5 = 5
  T5     │ UPDATE ... SET stock=7  │
  T6     │                         │ UPDATE ... SET stock=5
  ───────┴─────────────────────────┴─────────────────────────
  Hasil akhir: stock = 5
  Yang seharusnya: stock = 10 - 3 - 5 = 2

  Request A kehilangan updatenya. 3 item "terjual" tapi stok
  tidak berkurang dengan benar → data korup.
──────────────────────────────────────────────────────────────

Optimistic locking mendeteksi kondisi ini dan memastikan salah satu request gagal (yang akan retry atau dilaporkan ke user) daripada membiarkan data korup masuk ke database.


Cara Kerja: Version Column sebagai Penjaga #

Mekanisme inti optimistic locking adalah kolom version (atau updated_at) yang bertindak sebagai “cap waktu” dari state terakhir baris. Setiap kali baris diubah, version-nya naik. Saat melakukan update, client menyertakan version yang ia baca — jika version di database sudah berbeda, berarti ada update lain yang lebih dulu masuk, dan update ditolak.

Skema Database #

-- Tambahkan kolom version ke tabel yang butuh concurrency control
ALTER TABLE products
ADD COLUMN version BIGINT UNSIGNED NOT NULL DEFAULT 1;

-- Atau gunakan updated_at sebagai version (pendekatan alternatif)
ALTER TABLE products
ADD COLUMN updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
    ON UPDATE CURRENT_TIMESTAMP;

-- Index tidak diperlukan di kolom version secara terpisah karena
-- WHERE selalu disertai id (primary key):
-- WHERE id = ? AND version = ?
-- → id sudah index primary, version sebagai filter tambahan setelah PK lookup

Alur Optimistic Locking yang Benar #

Alur optimistic locking yang benar:
──────────────────────────────────────────────────────────────
  1. READ — tanpa lock, tanpa transaksi
     SELECT id, stock, version FROM products WHERE id = 42;
     → Dapat: stock=10, version=7

  2. LOGIC — di application layer, di luar transaksi database
     Validasi stok cukup, hitung pengurangan, dll.
     new_stock = 10 - 3 = 7

  3. WRITE — singkat, atomic, dengan version check
     BEGIN;
     UPDATE products
     SET stock = 7, version = version + 1
     WHERE id = 42 AND version = 7;  ← kunci utama: check version
     COMMIT;

  4. CEK rows_affected
     rows_affected = 1 → sukses, version sekarang = 8
     rows_affected = 0 → konflik! ada update lain yang lebih dulu
                          → retry dari langkah 1, atau return error

──────────────────────────────────────────────────────────────
  Kunci: transaksi database hanya untuk operasi WRITE yang singkat.
  Logic bisnis yang berat ada di luar transaksi.
──────────────────────────────────────────────────────────────

Bagaimana ini menyelesaikan masalah lost update:

Skenario yang sama dengan optimistic locking:
──────────────────────────────────────────────────────────────
  Baris: id=42, stock=10, version=7

  Waktu  │ Request A (beli 3)         │ Request B (beli 5)
  ───────┼────────────────────────────┼──────────────────────────
  T1     │ SELECT stock, version      │
         │ → stock=10, version=7      │
  T2     │                            │ SELECT stock, version
         │                            │ → stock=10, version=7
  T3     │ new_stock = 10-3 = 7       │
  T4     │                            │ new_stock = 10-5 = 5
  T5     │ UPDATE ... WHERE version=7 │
         │ → rows_affected=1 ✓        │
         │ → version sekarang = 8     │
  T6     │                            │ UPDATE ... WHERE version=7
         │                            │ → rows_affected=0 ✗ (version sudah 8!)
         │                            │ → KONFLIK TERDETEKSI
  ───────┴────────────────────────────┴──────────────────────────
  Hasil: Request A berhasil. Request B gagal dan harus retry.
  Setelah retry Request B: baca stock=7, version=8, tulis stock=2, version=9.
  Stok akhir = 2. Benar.
──────────────────────────────────────────────────────────────

Kesalahan Fatal: FOR UPDATE di Dalam Optimistic Locking #

Kesalahan paling umum yang ditemukan di production adalah menganggap sudah mengimplementasikan optimistic locking, padahal sebenarnya melakukan pessimistic locking yang jauh lebih mahal karena dibalut dengan logika yang panjang.

-- ANTI-PATTERN: ini BUKAN optimistic locking
BEGIN;
SELECT id, stock, version FROM products WHERE id = 42 FOR UPDATE;
-- ↑ Baris sekarang terkunci. Tidak ada yang bisa baca atau tulis baris ini.

-- Lakukan logika bisnis panjang di sini (sering terjadi di praktik nyata):
-- - Validasi ke service inventory (100ms)
-- - Cek promo yang aktif (50ms)
-- - Hitung diskon (20ms)
-- - Logging ke audit table (30ms)
-- Total: ~200ms dengan row terkunci

UPDATE products
SET stock = stock - 3, version = version + 1
WHERE id = 42 AND version = 3;  -- version check tidak berguna di sini!
COMMIT;
-- Baru sekarang lock dilepas

Mengapa ini sangat bermasalah:

Dampak FOR UPDATE dengan logika berat di dalam transaksi:
──────────────────────────────────────────────────────────────
  Row products id=42 terkunci selama ~200ms per request.
  Jika ada 100 concurrent request untuk produk yang sama:

  Request 1: lock dipegang 200ms
  Request 2-100: antri menunggu

  Total waktu tunggu Request 100:
    99 × 200ms = ~20 detik hanya untuk mendapat lock!

  Dampak berantai:
    → Connection pool database habis (semua connection menunggu lock)
    → Request baru: "connection timeout"
    → Thread pool aplikasi habis
    → Seluruh sistem tidak responsif

  Ironisnya, version check di WHERE tidak ada gunanya karena
  FOR UPDATE sudah memastikan tidak ada update lain yang bisa
  lolos — pessimistic locking sudah mengambil alih.
──────────────────────────────────────────────────────────────
SELECT ... FOR UPDATE di dalam transaksi yang mengandung operasi lambat (API call eksternal, loop besar, validasi kompleks) adalah resep untuk deadlock dan system outage. Jika kamu melihat pola ini di codebase, ini adalah prioritas tinggi untuk diperbaiki.

Implementasi yang Benar di Go #

Berikut implementasi optimistic locking yang lengkap untuk kasus pembelian produk, dengan retry logic yang aman menggunakan exponential backoff:

// ErrConflict dikembalikan saat version check gagal
var ErrConflict = errors.New("data has been modified by another request")
var ErrMaxRetry = errors.New("max retry attempts reached")

type Product struct {
    ID      int64
    Name    string
    Stock   int
    Version int64
}

// BuyProduct mengurangi stok produk dengan optimistic locking
func BuyProduct(ctx context.Context, db *sql.DB, productID int64, qty int) error {
    const maxRetries = 3

    for attempt := 0; attempt < maxRetries; attempt++ {
        err := attemptBuy(ctx, db, productID, qty)
        if err == nil {
            return nil  // sukses
        }
        if !errors.Is(err, ErrConflict) {
            return err  // error lain, tidak perlu retry
        }

        // Konflik versi — tunggu sebentar lalu retry
        if attempt < maxRetries-1 {
            backoff := time.Duration(1<<uint(attempt)) * 50 * time.Millisecond
            // Tambahkan jitter untuk menghindari thundering herd
            jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
            select {
            case <-time.After(backoff + jitter):
            case <-ctx.Done():
                return ctx.Err()
            }
        }
    }

    return ErrMaxRetry
}

func attemptBuy(ctx context.Context, db *sql.DB, productID int64, qty int) error {
    // LANGKAH 1: Read tanpa lock, tanpa transaksi
    var product Product
    err := db.QueryRowContext(ctx,
        "SELECT id, name, stock, version FROM products WHERE id = ?",
        productID,
    ).Scan(&product.ID, &product.Name, &product.Stock, &product.Version)
    if err != nil {
        return fmt.Errorf("read product: %w", err)
    }

    // LANGKAH 2: Logic bisnis di application layer (di luar transaksi DB)
    if product.Stock < qty {
        return fmt.Errorf("stok tidak cukup: tersedia %d, diminta %d",
            product.Stock, qty)
    }
    newStock := product.Stock - qty

    // Simulasi: validasi tambahan yang mungkin memakan waktu
    // (di luar transaksi database — KUNCI PENTING)
    if err := validatePurchasePolicy(ctx, product, qty); err != nil {
        return err
    }

    // LANGKAH 3: Write dengan version check — singkat dan atomic
    result, err := db.ExecContext(ctx, `
        UPDATE products
        SET stock   = ?,
            version = version + 1
        WHERE id      = ?
          AND version = ?
          AND stock   >= ?
    `, newStock, productID, product.Version, qty)
    if err != nil {
        return fmt.Errorf("update product: %w", err)
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return fmt.Errorf("check rows affected: %w", err)
    }

    if rowsAffected == 0 {
        // Version tidak cocok atau stok sudah berubah — konflik
        return ErrConflict
    }

    return nil
}

// validatePurchasePolicy — contoh logika yang sengaja di luar transaksi
func validatePurchasePolicy(ctx context.Context, p Product, qty int) error {
    // Misalnya: cek apakah user boleh beli lebih dari limit harian
    // Ini bisa memakan 50-200ms — TIDAK BOLEH ada di dalam transaksi DB
    return nil
}

Perhatikan kondisi ekstra AND stock >= ? di UPDATE — ini adalah guard tambahan agar tidak ada race condition antara check stok di langkah 2 dan update di langkah 3, meskipun version sama.

Retry dengan Exponential Backoff #

Pola retry dengan exponential backoff + jitter:
──────────────────────────────────────────────────────────────
  Attempt 1: gagal karena konflik
  Tunggu: 50ms + random(0-25ms)  → misal 63ms

  Attempt 2: gagal karena konflik
  Tunggu: 100ms + random(0-50ms) → misal 137ms

  Attempt 3: sukses (atau return ErrMaxRetry)

  Mengapa jitter penting:
    Tanpa jitter: semua retry dari banyak concurrent request
    akan mencoba pada waktu yang sama → thundering herd
    → konflik terus berulang

    Dengan jitter: retry tersebar di waktu yang berbeda
    → kemungkinan konflik berkurang drastis
──────────────────────────────────────────────────────────────

Version Column vs Updated_at: Memilih yang Tepat #

Ada dua pendekatan umum untuk kolom “version” dalam optimistic locking, masing-masing dengan trade-off yang berbeda.

Pendekatan 1: Integer Version Column #

-- Skema
version BIGINT UNSIGNED NOT NULL DEFAULT 1

-- Update
SET version = version + 1
WHERE id = ? AND version = ?

-- Kelebihan:
--   ✓ Deterministik — mudah debug, mudah di-assert dalam test
--   ✓ Tidak bergantung pada presisi timestamp
--   ✓ Selalu increment, tidak pernah ada "collision" karena clock skew

-- Kekurangan:
--   ✗ Perlu kolom tambahan di schema
--   ✗ Harus diupdate eksplisit di setiap UPDATE query

Pendekatan 2: Updated_at Timestamp #

-- Skema (MySQL)
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
           ON UPDATE CURRENT_TIMESTAMP(6)  -- microsecond precision

-- Update
SET status = ?
WHERE id = ? AND updated_at = ?

-- Kelebihan:
--   ✓ Informasi useful secara independen (kapan data terakhir diubah)
--   ✓ Otomatis diupdate tanpa perlu disebut eksplisit di SET

-- Kekurangan:
--   ✗ Bergantung pada presisi clock — gunakan microsecond (6), bukan detik
--   ✗ Di sistem terdistribusi, clock skew antar node bisa menyebabkan masalah
--   ✗ Dua update berurutan sangat cepat bisa mendapat timestamp yang sama
-- ANTI-PATTERN: updated_at dengan presisi detik saja
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
           ON UPDATE CURRENT_TIMESTAMP
-- → Dua update dalam detik yang sama punya timestamp identik
-- → Version check tidak mendeteksi konflik!

-- BENAR: gunakan microsecond precision
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
           ON UPDATE CURRENT_TIMESTAMP(6)
-- → Resolusi 1 microsecond, jauh lebih aman untuk version check

Rekomendasi: gunakan integer version untuk sistem baru. Lebih deterministik, tidak ada ketergantungan pada clock, dan lebih mudah di-test.


Kasus Nyata: Update Stok dengan Multiple Produk #

Ketika satu operasi perlu mengupdate beberapa baris sekaligus (misalnya order yang berisi beberapa produk), optimistic locking perlu ditangani dengan hati-hati untuk menghindari partial update.

type OrderItem struct {
    ProductID int64
    Qty       int
}

// ReserveStock mengurangi stok untuk semua item dalam satu order
func ReserveStock(ctx context.Context, db *sql.DB, items []OrderItem) error {
    const maxRetries = 3

    for attempt := 0; attempt < maxRetries; attempt++ {
        err := attemptReserve(ctx, db, items)
        if err == nil {
            return nil
        }
        if !errors.Is(err, ErrConflict) {
            return err
        }
        // Jika konflik di salah satu produk, retry seluruh operasi
        backoff := time.Duration(1<<uint(attempt)) * 100 * time.Millisecond
        time.Sleep(backoff)
    }
    return ErrMaxRetry
}

func attemptReserve(ctx context.Context, db *sql.DB, items []OrderItem) error {
    // Langkah 1: Baca semua produk yang dibutuhkan — tanpa lock
    type ProductSnapshot struct {
        ID      int64
        Stock   int
        Version int64
    }

    productIDs := make([]int64, len(items))
    for i, item := range items {
        productIDs[i] = item.ProductID
    }

    // Baca semua produk dalam satu query
    snapshots := make(map[int64]ProductSnapshot)
    rows, err := db.QueryContext(ctx,
        "SELECT id, stock, version FROM products WHERE id IN (?)",
        productIDs...,
    )
    // ... scan rows ke snapshots map

    // Langkah 2: Validasi stok semua produk (di luar transaksi)
    for _, item := range items {
        snap := snapshots[item.ProductID]
        if snap.Stock < item.Qty {
            return fmt.Errorf("stok produk %d tidak cukup", item.ProductID)
        }
    }

    // Langkah 3: Update semua produk dalam satu transaksi — singkat
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer tx.Rollback()

    for _, item := range items {
        snap := snapshots[item.ProductID]
        newStock := snap.Stock - item.Qty

        result, err := tx.ExecContext(ctx, `
            UPDATE products
            SET stock = ?, version = version + 1
            WHERE id = ? AND version = ?
        `, newStock, item.ProductID, snap.Version)
        if err != nil {
            return err
        }

        affected, _ := result.RowsAffected()
        if affected == 0 {
            // Konflik di salah satu produk — rollback semua
            return ErrConflict
        }
    }

    return tx.Commit()
}
Saat mengupdate beberapa baris dalam satu transaksi dengan optimistic locking, selalu urutkan update berdasarkan primary key (misalnya ORDER BY product_id ASC) untuk menghindari deadlock. Dua transaksi yang mengupdate baris yang sama tapi dalam urutan berbeda bisa saling menunggu satu sama lain.

Optimistic vs Pessimistic Locking: Kapan Memilih yang Mana #

Perbandingan komprehensif:
──────────────────────────────────────────────────────────────────────
  Aspek                  │ Optimistic Locking  │ Pessimistic Locking
──────────────────────────────────────────────────────────────────────
  Cara kerja             │ Baca bebas,         │ Lock saat baca,
                         │ check saat tulis    │ lepas setelah tulis
  Lock database          │ Tidak ada           │ Ada selama transaksi
  Blocking reader lain   │ Tidak               │ Ya (tergantung level)
  Cocok untuk            │ Konflik jarang      │ Konflik sering
  Throughput read-heavy  │ Sangat tinggi       │ Terbatas oleh lock
  Penanganan konflik     │ Retry di aplikasi   │ Otomatis menunggu
  Risiko deadlock        │ Rendah              │ Lebih tinggi
  Kompleksitas aplikasi  │ Lebih tinggi        │ Lebih rendah
  Contoh use case        │ Edit profil user,   │ Transfer saldo bank,
                         │ update metadata     │ pemesanan tiket seat
──────────────────────────────────────────────────────────────────────

Panduan memilih:

  Gunakan Optimistic Locking jika:
    ✓ Probabilitas konflik rendah (< 10% request akan konflik)
    ✓ Read jauh lebih sering dari write
    ✓ Retry bisa dilakukan tanpa mengganggu user experience
    ✓ Operasi bisnis tidak melibatkan sumber eksternal yang tidak idempotent
    ✓ Data bisa "stale" sejenak tanpa konsekuensi serius

  Gunakan Pessimistic Locking (FOR UPDATE) jika:
    ✓ Konflik sangat sering terjadi
    ✓ Data harus konsisten secara kuat (strong consistency)
    ✓ Retry tidak bisa dilakukan (misalnya pembayaran real-time)
    ✓ Transaksi singkat dan terprediksi (tidak ada I/O eksternal)
    ✓ Urutan operasi harus deterministik (FIFO)

Anti-Pattern yang Harus Dihindari #

// ✗ Anti-pattern 1: FOR UPDATE + logika berat dalam satu transaksi
tx.Begin()
row := tx.QueryRow("SELECT ... FOR UPDATE WHERE id = ?", id)
callExternalAPI()           // 200ms — row masih terkunci!
sendEmailNotification()     // 100ms — row masih terkunci!
tx.Exec("UPDATE ...")
tx.Commit()
// ✓ Solusi: pindahkan semua logika ke luar transaksi, transaksi hanya untuk write

// ✗ Anti-pattern 2: retry tanpa backoff — thundering herd
for {
    err := attemptUpdate(ctx)
    if err == nil { break }
    if !errors.Is(err, ErrConflict) { return err }
    // Langsung retry tanpa jeda — semua goroutine retry bersamaan
    // → kontensi makin tinggi, konflik makin sering
}
// ✓ Solusi: exponential backoff + jitter seperti implementasi di atas

// ✗ Anti-pattern 3: update tanpa version check (sama sekali tidak pakai optimistic locking)
db.Exec("UPDATE products SET stock = ? WHERE id = ?", newStock, id)
// → Lost update bisa terjadi
// ✓ Solusi: selalu sertakan AND version = ? dan cek rows_affected

// ✗ Anti-pattern 4: version check tapi ignore rows_affected
result, _ := db.Exec("UPDATE products SET stock = ?, version = version + 1 WHERE id = ? AND version = ?",
    newStock, id, version)
// Tidak cek rows_affected — tidak tahu apakah update berhasil atau konflik
// ✓ Solusi: selalu baca RowsAffected() dan handle 0 sebagai konflik

// ✗ Anti-pattern 5: retry tak terbatas tanpa max attempt
attempts := 0
for {
    err := attemptUpdate()
    if err == nil { break }
    attempts++
    // Tidak ada batas — bisa loop selamanya jika konflik terus terjadi
}
// ✓ Solusi: tentukan max retry yang wajar (3-5 kali), return error jika habis

Checklist Implementasi Optimistic Locking #

SCHEMA:
  □ Ada kolom version (INT) atau updated_at (DATETIME(6))?
  □ Kolom version punya DEFAULT yang tepat (1 untuk INT, CURRENT_TIMESTAMP untuk datetime)?
  □ Kolom version diupdate di SEMUA query UPDATE yang relevan?

QUERY:
  □ READ dilakukan tanpa FOR UPDATE, di luar transaksi?
  □ Logika bisnis yang lambat ada di luar transaksi database?
  □ UPDATE menyertakan AND version = ? dalam WHERE clause?
  □ UPDATE mengincrement version (SET version = version + 1)?
  □ rows_affected selalu dicek setelah UPDATE?
  □ rows_affected = 0 ditangani sebagai ErrConflict, bukan success?

RETRY LOGIC:
  □ Ada max retry yang jelas (3-5 kali)?
  □ Ada exponential backoff antara retry?
  □ Ada jitter untuk menghindari thundering herd?
  □ Context cancellation dihormati (ctx.Done()) saat menunggu retry?
  □ ErrMaxRetry dikembalikan ke caller jika semua retry gagal?

IDEMPOTENCY:
  □ Logika bisnis aman untuk dijalankan ulang (retry tidak menyebabkan duplikasi)?
  □ Side effect (email, notifikasi, charge payment) tidak terjadi sebelum update berhasil?

Ringkasan #

  • Optimistic locking bekerja dengan version column, bukan dengan lock database — baca data bebas tanpa lock, kerjakan logika bisnis di aplikasi, lalu verifikasi version saat menulis. Jika version berubah, tolak dan retry.
  • AND version = ? di WHERE adalah inti mekanismenyarows_affected = 0 berarti konflik terdeteksi. Selalu cek nilai ini — jangan abaikan hasilnya.
  • Logika bisnis yang lambat harus di luar transaksi database — transaksi hanya boleh berisi operasi write yang singkat. Letakkan validasi, API call, dan kalkulasi kompleks sebelum BEGIN.
  • SELECT FOR UPDATE + logika berat = pessimistic locking yang jauh lebih mahal — ini adalah kesalahan paling umum. Baris terkunci selama seluruh durasi logika, menyebabkan antrian panjang di sistem concurrent.
  • Retry dengan exponential backoff + jitter adalah bagian wajib — tanpa jitter, semua concurrent retry akan mencoba di waktu yang sama dan memperburuk kontensi. Batas max retry juga wajib ada.
  • Gunakan integer version, bukan updated_at TIMESTAMP biasa — timestamp dengan presisi detik tidak cukup untuk mendeteksi konflik yang terjadi dalam milidetik. Jika tetap pakai timestamp, gunakan DATETIME(6) dengan presisi microsecond.
  • Urutkan update multi-row berdasarkan primary key — untuk menghindari deadlock saat dua transaksi mengupdate set baris yang sama dalam urutan yang berbeda.
  • Optimistic locking tidak cocok untuk konflik yang sering — jika lebih dari 10-20% request berakhir dengan konflik dan retry, pertimbangkan pessimistic locking atau arsitektur queue-based untuk operasi tersebut.

← Sebelumnya: Pagination   Berikutnya: N+1 Effect →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact