Skip to main content
System design langkah demi langkah · Pelajaran 4 dari 7 · 14 menit

Belajar Konsistensi Sistem Terdistribusi: ACID, CAP, dan Saga

Belajar konsistensi sistem terdistribusi: ACID dan isolation di satu node, CAP dan PACELC, two-phase commit versus saga, serta lease dengan fencing token.

Oleh Matthews WongDitinjau pada

Poin utama

Konsistensi di sistem terdistribusi adalah janji tentang apa yang dikembalikan sebuah read dan write mana yang bertahan begitu data berada di lebih dari satu tempat. Di satu node, transaksi ACID dan isolation level menjaganya. Di antara replika, CAP dan PACELC menukarnya dengan availability dan latency. Di antara service, two-phase commit menjaganya dengan memblokir, dan saga dengan kompensasi.

Setelah pelajaran ini, Anda bisa

  • Membedakan C pada ACID dari C pada CAP, dan memilih perbaikan untuk race read-then-write.
  • Memperkirakan apa yang dilakukan two-phase commit saat coordinator-nya mati, dan apa yang dilakukan saga saat satu langkah gagal.
  • Menjelaskan kenapa lease butuh fencing token sebelum bisa melindungi sebuah write.
Lihat cara kerjanya

Ikuti diagramnya langkah demi langkah

Tekan Putar, atau melangkah dengan panah. Setiap langkah juga ditulis di bawah diagram.

Two-phase commit memblokir, saga melakukan kompensasi

Satu checkout menulis stok dan pembayaran dengan dua cara: pada two-phase commit coordinator mati setelah kedua database memberi suara ya, jadi lock-nya tetap terpasang dan order berikutnya menunggu; pada saga setiap langkah langsung commit, pembayaran yang ditolak memicu kompensasi, dan order di tengahnya melihat stok yang kemudian dikembalikan.

Langkah 1 dari 7. Satu checkout menulis ke dua tempat, stok dan pembayaran. Atas: two-phase commit yang dijalankan coordinator. Bawah: saga yang dijalankan orchestrator. Order yang sama, dua protokol.

Tombol: ← → langkah · Home / End · K putar atau jeda

  1. Satu checkout menulis ke dua tempat, stok dan pembayaran. Atas: two-phase commit yang dijalankan coordinator. Bawah: saga yang dijalankan orchestrator. Order yang sama, dua protokol.

  2. Fase satu: coordinator meminta kedua database bersiap. Masing-masing membuat perubahannya durable, tetap memegang lock baris, memberi suara ya, lalu menunggu keputusan.

    BEGIN; UPDATE stock SET qty = qty - 6 WHERE item = 7; PREPARE TRANSACTION 'checkout-42';
  3. Coordinator crash sebelum mengirim keputusan. Kedua database sudah memberi suara ya, tetapi tidak ada yang boleh commit atau abort sendiri: yang lain mungkin diberi tahu sebaliknya.

    SELECT gid, prepared FROM pg_prepared_xacts;Tebak langkah berikutnya Coordinator mati setelah voting. Apa yang terjadi pada order berikutnya? (Menunggu di belakang lock baris · Lock kedaluwarsa, jadi order berhasil · Membaca stok lama lalu lolos)
  4. Jadi lock tetap terpasang. Order berikutnya untuk barang yang sama mengantre di belakang baris stok sampai coordinator kembali. Prepared transaction tidak pernah kedaluwarsa sendiri.

    UPDATE stock SET qty = qty - 5 WHERE item = 7; -- blocks on the prepared transaction's row lock
  5. Saga langsung meng-commit setiap langkah. Stok memesan 6 unit lalu commit, sisa 4. Lalu pembayaran ditolak. Tidak ada lock yang ditahan selama kedua panggilan itu.

    INSERT INTO reservations (id, item, qty, status) VALUES (42, 7, 6, 'held');
  6. Sementara itu order lain butuh 5. Tidak ada yang terkunci, jadi langsung dijawab, tetapi ia hanya melihat sisa 4 dan ditolak. Saga tidak memberi isolation.

    Tebak langkah berikutnya Pembayaran ditolak. Bagaimana saga membatalkan pemesanan stok? (Database me-rollback langkah pertama · Transaksi baru mengembalikan unitnya · Menunggu keputusan dari coordinator)
  7. Orchestrator menjalankan kompensasi: transaksi lokal baru yang mengembalikan 6 unit. Stok kembali 10, tetapi order yang ditolak tadi melihat keadaan yang tidak pernah benar-benar terjadi.

    -- a new local transaction; running it twice changes nothing UPDATE reservations SET status = 'released' WHERE id = 42 AND status = 'held';

Apa arti konsistensi di dalam satu database?

Di satu node, transaksi adalah satuan konsistensi, dan ACID menamai empat jaminannya. Atomic: semuanya terjadi atau tidak sama sekali. Consistent: transaksi memindahkan database dari satu keadaan valid ke keadaan valid lain, dan valid berarti aturan yang Anda deklarasikan, seperti CHECK, UNIQUE, dan foreign key. Isolated: transaksi yang berjalan bersamaan tidak melihat pekerjaan setengah jadi milik yang lain. Durable: begitu di-commit, data bertahan dari crash. C adalah yang paling berbeda, karena database hanya menegakkan invariant yang benar-benar Anda tuliskan.

Baca selengkapnya (1)

Isolation adalah tempat sebagian besar bug bersembunyi. Level default PostgreSQL, Read Committed, memberi setiap statement snapshot yang diambil saat statement itu mulai, jadi dua statement dalam satu transaksi bisa melihat data yang berbeda. Baca saldo, kurangi di kode aplikasi, lalu tulis hasilnya, maka dua request yang berjalan bersamaan diam-diam kehilangan satu update. Lakukan perhitungannya di dalam satu UPDATE, atau kunci barisnya dulu dengan SELECT FOR UPDATE. Repeatable Read dan Serializable mencegah lebih banyak anomali, tetapi aplikasi wajib mengulang transaksi setiap kali PostgreSQL melaporkan serialization failure, SQLSTATE 40001.

Kenapa CAP dan PACELC memaksa trade-off begitu data direplikasi?

C pada CAP adalah janji yang berbeda: setiap read menerima write terbaru atau error, seolah hanya ada satu salinan. Availability berarti setiap request ke node yang sehat mendapat jawaban yang bukan error. Saat network partition memisahkan replika, Anda tidak bisa mendapat keduanya. Sistem CP menolak sebagian request di sisi minoritas daripada mengembalikan data basi; sistem AP tetap menjawab di kedua sisi lalu merekonsiliasi belakangan. Partition tidak bisa dihindari di jaringan nyata, jadi ringkasan pilih dua dari tiga itu keliru: pilihannya baru terasa saat partition terjadi.

Baca selengkapnya (1)

PACELC menambahkan bagian yang Anda hadapi setiap hari: else, saat jaringan sehat, Anda menukar latency dengan konsistensi, karena menunggu konfirmasi replika memakan minimal satu round trip per write. Sistem yang memilih kecepatan bersifat eventually consistent: menurut Werner Vogels, jika tidak ada update baru, pada akhirnya semua akses mengembalikan nilai terakhir. Itu janji tentang di mana replika berakhir, bukan apa yang dilihat pembaca di tengah jalan. Salah membaca itu murah; salah memutuskan tidak. Saldo, unit stok terakhir saat checkout, dan username yang unik butuh satu pengecekan yang strongly consistent tepat saat keputusan diambil.

Bagaimana two-phase commit dan saga menjaga beberapa service tetap sejalan?

Two-phase commit menyerahkan kendali ke coordinator. Pada fase prepare, setiap participant membuat perubahannya durable, tetap memegang lock, dan memberi suara; pada fase commit, coordinator hanya meng-commit jika semua suara ya. Kelemahannya adalah blocking: participant yang sudah memberi suara ya wajib menunggu keputusan, dan jika coordinator tidak pernah kembali, lock-nya tertahan tanpa batas. PostgreSQL mendukung sisi participant lewat PREPARE TRANSACTION, yang statusnya disimpan di disk dan bisa diselesaikan dari session mana pun, tetapi dokumentasinya menyebut perintah ini ditujukan untuk transaction manager eksternal, bukan aplikasi, dan memperingatkan bahwa prepared transaction tetap memegang lock-nya serta mengganggu VACUUM.

Baca selengkapnya (1)

Saga mengganti satu transaksi besar dengan rangkaian transaksi lokal, yang masing-masing langsung di-commit. Saat satu langkah gagal, saga menjalankan compensating transaction untuk membatalkan langkah sebelumnya, entah diarahkan oleh orchestrator atau dipicu event dalam choreography. Kompensasi adalah transaksi baru yang bergerak maju, seperti refund atau pengembalian stok, jadi harus aman dijalankan dua kali dan harus diulang sampai berhasil. Harganya adalah isolation: transaksi lain melihat keadaan setengah jadi, sehingga tim menambahkan countermeasure seperti status PENDING pada baris. Sebelum memilih salah satunya, tanyakan apakah satu database bisa menampung semua datanya, karena satu transaksi lokal mengalahkan keduanya.

Kenapa lease membutuhkan fencing token?

Sebagian pekerjaan harus berjalan di tepat satu node: job malam, database primary, atau proses yang membagi tugas. Lease memilih node itu: lock dengan masa berlaku yang terus diperbarui pemegangnya, sehingga pemegang yang crash pada akhirnya kehilangan lock tersebut. Jebakannya ada di jeda antara mengecek lease dan mengerjakan tugasnya. Contoh dari Martin Kleppmann adalah client yang terhenti lama karena garbage collection, bangun setelah lease-nya habis, lalu menulis seolah tidak terjadi apa-apa. Mengecek masa berlaku tepat sebelum menulis tidak menolong, karena jeda itu bisa terjadi di antara pengecekan dan penulisan.

Baca selengkapnya (1)

Solusinya adalah fencing token: angka yang naik setiap kali lock diberikan. Pemegang lock mengirim angka itu di setiap write, dan resource yang dilindungi menolak write yang token-nya lebih kecil dari yang pernah dilihatnya. Pengecekan itu harus ada di resource, dalam statement yang sama dengan write-nya, bukan di kode leader. Kleppmann menarik garisnya dengan jelas: lock yang hanya mencegah kerja ganda boleh longgar, tetapi lock untuk kebenaran data butuh token. Nomor term di Raft melakukan tugas yang sama di dalam cluster konsensus, dan tulisannya punya diagram langkah demi langkah sendiri.

Cara situs ini melakukannya

Konfigurasi nyata di balik pelajaran ini

Kutipan dari repositori yang membangun dan men-deploy situs ini, apa adanya.

.github/workflows/deploy.yml:189-196
    # The one group that must NEVER cancel in progress. This step SSHes to the
    # VPS and runs `docker compose pull && up -d`; interrupting it can leave the
    # box between images. Concurrent deploys queue and run one at a time, which
    # is the original intent — it is only the build chain above that should be
    # throwing away superseded work.
    concurrency:
      group: deploy-vps
      cancel-in-progress: false

Situs ini tidak punya database terdistribusi, tetapi deploy-nya punya masalah shared state dalam skala kecil: beberapa push ingin mengubah satu server. Concurrency group adalah lock yang dipegang GitHub, jadi paling banyak satu deploy berjalan pada satu waktu, dan cancel-in-progress: false berarti deploy yang sedang berjalan tidak pernah diputus, karena pull dan up -d bukan satu langkah atomik. GitHub hanya menyimpan satu run yang pending dan membatalkan pending lama saat yang lebih baru datang. Aturan last-writer-wins itu aman di sini hanya karena setiap deploy menulis seluruh keadaan yang diinginkan, bukan penambahan.

.github/workflows/deploy.yml:20-22
# So: builds cancel, deploys never do. A cancelled build also cancels its own
# deploy before it can start (deploy declares `needs: build-and-push`), so
# there is no path to a half-applied deploy.

Ide yang sama dengan yang dipakai saga: letakkan pekerjaan yang bisa gagal atau ditinggalkan sebelum langkah yang tidak aman untuk diputus. Verifikasi dan build image boleh dibatalkan kapan saja, karena belum ada yang berubah di luar runner. Langkah yang menyentuh server baru mulai setelah semua langkah sebelumnya berhasil, dan sejak itu tidak pernah dibatalkan.

Yang sering rusak

Kesalahan yang benar-benar membuang waktu

  • Baca, hitung, tulis di Read Committed

    Mengambil nilai, mengubahnya di kode aplikasi, lalu menulis hasilnya akan kehilangan update saat ada konkurensi, tanpa error apa pun. Pindahkan perhitungannya ke dalam UPDATE (balance = balance - 30, dengan constraint CHECK di belakangnya) atau kunci barisnya dengan SELECT FOR UPDATE.

  • Prepared transaction yang tidak pernah diselesaikan

    Prepared transaction sengaja dibuat bertahan melewati restart, jadi tidak ada yang membuatnya kedaluwarsa. Lock barisnya memblokir semua penulis dan menghambat VACUUM. Jika Anda mengaktifkan max_prepared_transactions, harus ada yang bertanggung jawab atas pg_prepared_xacts dan memberi alert saat ada baris yang sudah lama di sana.

  • Kompensasi yang tidak aman diulang

    Kompensasi diulang sampai berhasil, sering kali di tengah gangguan yang sama yang membuat langkahnya gagal. Pengembalian stok yang ditulis qty = qty + 6 akan menambah unit dua kali saat diulang. Jadikan perubahan status yang tidak cocok dengan apa pun pada percobaan kedua, misalnya status = 'released' WHERE status = 'held'.

  • Last-write-wins pada angka yang harus pas

    Last-write-wins tetap konvergen, jadi semua replika sepakat, sementara diam-diam membuang salah satu dari dua write yang bersamaan. Pada stok 10 dengan 3 terjual di satu replika dan 4 di replika lain, ia menyimpan satu sisi saja dan melaporkan 7 atau 6, bukan 3. Pakai hanya jika kehilangan salah satu dari dua perubahan memang tidak masalah.

  • Lease yang dicek oleh leader, bukan oleh resource

    Jika hanya leader yang mengecek lease-nya, leader yang sempat terhenti lalu bangun terlambat tetap menulis, dan sesaat ada dua node yang bertindak sebagai leader. Kirim fencing token di setiap write dan biarkan storage menolak token yang sudah basi.

Two-phase commit atau saga: apa yang diberikan dan dikorbankan
AtomicitySemua commit atau tidak sama sekali, diputuskan coordinatorTiap langkah commit sendiri; keseluruhannya dibatalkan lewat kompensasi
IsolationLock menyembunyikan keadaan setengah jadi sampai ada keputusanTidak ada: pihak lain melihat keadaan di tengah
Saat pengendalinya gagalParticipant menunggu sambil memegang lockTidak ada yang terkunci; saga dilanjutkan atau dikompensasi
Setiap participant wajibMendukung prepare, seperti PostgreSQL atau resource XAPunya transaksi lokal dan undo yang aman
Paling cocokBeberapa database yang Anda kelola sendiri, transaksi singkatAlur lintas service atau vendor, seperti payment gateway
Coba sendiri

Lakukan di komputer Anda sendiri

Anda hanya butuh Docker. Jalankan PostgreSQL dengan prepared transaction aktif, perankan participant yang coordinator-nya tidak pernah kembali, lalu lihat penulis kedua tertahan di belakangnya.

  1. Jalankan PostgreSQL 18 dengan prepared transaction dinyalakan (nilai default-nya, 0, mematikannya), lalu buat satu baris stok.

    docker run --rm -d --name pg2pc -e POSTGRES_PASSWORD=lab \
      postgres:18 -c max_prepared_transactions=10
    
    docker exec pg2pc psql -U postgres \
      -c "CREATE TABLE stock (item int PRIMARY KEY, qty int NOT NULL CHECK (qty >= 0))" \
      -c "INSERT INTO stock VALUES (7, 10)"
  2. Jadilah participant: ubah barisnya, beri suara ya dengan PREPARE TRANSACTION, lalu biarkan session berakhir tanpa keputusan.

    printf "BEGIN;\nUPDATE stock SET qty = qty - 6 WHERE item = 7;\nPREPARE TRANSACTION 'checkout-42';\n" \
      | docker exec -i pg2pc psql -U postgres
  3. Jadilah order berikutnya. Dengan lock_timeout 3 detik, UPDATE menyerah dengan pesan "canceling statement due to lock timeout" alih-alih menunggu selamanya.

    docker exec pg2pc psql -U postgres \
      -c "SET lock_timeout = '3s'" \
      -c "UPDATE stock SET qty = qty - 5 WHERE item = 7"
  4. Restart server-nya: prepared transaction itu masih tercantum. Selesaikan dari session baru, dan UPDATE yang tadi terblokir sekarang berhasil.

    docker restart pg2pc
    docker exec pg2pc psql -U postgres -c "SELECT gid, prepared FROM pg_prepared_xacts"
    docker exec pg2pc psql -U postgres -c "ROLLBACK PREPARED 'checkout-42'"
    docker exec pg2pc psql -U postgres -c "UPDATE stock SET qty = qty - 5 WHERE item = 7 RETURNING qty"
    docker rm -f pg2pc
Kuis singkat

Tiga pertanyaan sebelum lanjut

Pilih jawaban untuk melihat kenapa benar atau salah. Tidak ada yang disimpan atau dinilai, jadi tebak saja dengan bebas.

  1. 1.Dua kasir menjual dari saldo yang sama di detik yang sama. Kode Anda menjalankan SELECT balance, mengurangi di TypeScript, lalu UPDATE dengan hasilnya, di isolation level default PostgreSQL. Apa yang terjadi?
  2. 2.Dalam two-phase commit, coordinator crash setelah kedua database memberi suara ya dan sebelum keputusan dikirim. Apa yang dilakukan kedua database?
  3. 3.Leader yang memegang lease 15 detik terhenti selama 20 detik, lalu bangun dan menulis. Apa yang bisa diandalkan untuk menghentikan write basi itu?
Pikirkan dulu

Jawab dalam hati, lalu cek

  1. 1.Apakah C pada ACID sama artinya dengan C pada CAP?Lihat jawabannya

    Jawaban:Tidak. Konsistensi pada ACID berarti transaksi menjaga aturan yang Anda deklarasikan, seperti constraint CHECK dan UNIQUE. Konsistensi pada CAP berarti setiap read melihat write terbaru atau error, seolah hanya ada satu salinan.

  2. 2.Kenapa participant yang sudah memberi suara ya tidak bisa langsung rollback saat coordinator diam?Lihat jawabannya

    Jawaban:Karena participant lain mungkin sudah diberi tahu untuk commit. Rollback sendirian bisa membuat separuh transaksi ter-commit, padahal justru itu yang ingin dicegah two-phase commit. Jadi ia menunggu, dan lock-nya ikut tertahan.

  3. 3.Saga sudah memesan stok, lalu pembayaran gagal. Kenapa perbaikannya disebut kompensasi, bukan rollback?Lihat jawabannya

    Jawaban:Pemesanan itu sudah di-commit dan mungkin sudah dilihat pihak lain, jadi tidak ada lagi yang bisa di-rollback. Saga menjalankan transaksi lokal baru yang mengembalikan stoknya, dan transaksi itu harus aman diulang.

  4. 4.Deploy situs ini membiarkan run pending yang lebih baru membatalkan yang lebih lama. Kenapa itu aman di sini?Lihat jawabannya

    Jawaban:Setiap deploy menulis seluruh keadaan yang diinginkan, yaitu image terbaru, bukan perubahan di atas deploy sebelumnya. Melewatkan satu keadaan penuh di tengah tidak menghilangkan apa pun, itulah sebabnya last-writer-wins aman untuknya dan keliru untuk sebuah counter.

Baca modulnya

Semua artikel di modul ini, berurutan

Pelajaran di atas adalah petanya. Setiap artikel membahas satu bagiannya secara mendalam, lengkap dengan contoh.

  1. ACID vs BASE: Model Konsistensi Database dan Kapan PentingACID vs BASE dengan contoh Postgres: apa yang dijaga tiap huruf ACID, kenapa BASE hanya plesetan bukan standar, dan cara memilih per fitur di praktik.
  2. Transaction Isolation Level di PostgreSQL: Penjelasan LengkapApa itu transaction isolation level database dan mana yang dipakai. READ COMMITTED, REPEATABLE READ, SERIALIZABLE di PostgreSQL dengan output dua sesi asli dan retry loop.
  3. Teorema CAP Dijelaskan: Dampaknya pada Pilihan DatabaseApa isi teorema CAP sebenarnya, kenapa partition memaksa kita memilih, apa tambahan PACELC, dan bagaimana PostgreSQL, MongoDB dan Redis menyikapinya.
  4. Eventual Consistency Explained: Kapan Data Stale Boleh?Apa jaminan eventual consistency, bagaimana replica konvergen lewat LWW, version vector dan CRDT, serta fitur mana yang boleh membaca data stale dan mana yang tidak.
  5. Two-Phase Commit vs Saga: Distributed Transaction DibandingkanTwo-phase commit vs saga untuk distributed transaction: bagaimana 2PC memblokir dan menahan lock, apa yang ditawarkan PREPARE TRANSACTION, dan kapan saga lebih cocok.
  6. Leader Election di Distributed Systems: Lease dan FencingCara kerja leader election di distributed systems: lease Postgres dengan fencing epoch, alternatif Redis dan etcd, Lease Kubernetes, dan split-brain.
  7. Algoritma Konsensus Raft: Penjelasan Election dan LogCara kerja algoritma konsensus Raft: term dan role, leader election dengan timeout acak, log replication dan commit index, hitungan quorum untuk 3, 5, 7 node, safety, serta pemakaian di etcd dan Consul.Diagram animasi