Skip to main content
BlogBackend

Saga Pattern di NestJS: Transaksi Terdistribusi Tanpa 2PC

Cara mengoordinasikan transaksi lintas layanan di NestJS menggunakan saga pattern, orkestrasi vs koreografi, aksi kompensasi, dan desain langkah yang idempoten.

Matthews Wong

Software Engineer

Juli 202611 menit baca
Saga Pattern di NestJS: Transaksi Terdistribusi Tanpa 2PC

Foto oleh RLJ Photography NYC on flickr

Ringkasan Utama

Saga pattern mengganti satu transaksi database lintas layanan dengan rangkaian transaksi lokal, masing-masing dipasangkan dengan aksi kompensasi untuk saat langkah berikutnya gagal. Di NestJS, decorator Saga pada modul CQRS mengimplementasikan ini sebagai orchestrator yang bereaksi terhadap event, dan handler kompensasi yang idempoten adalah yang membuat pesan yang dikirim ulang tetap aman.

Begitu alur checkout menyentuh tiga layanan, layanan pesanan, layanan pembayaran, dan layanan inventaris, satu transaksi database tunggal berhenti menjadi pilihan. Setiap layanan memiliki database sendiri, sehingga tidak ada koneksi bersama yang bisa dibungkus dalam BEGIN dan COMMIT. Two-phase commit menjanjikan atomisitas lintas node, tetapi caranya adalah menahan lock lintas jaringan sementara setiap peserta menunggu koordinator memberi sinyal lanjut, dan jika koordinator itu tidak tersedia di tengah proses voting, seluruh peserta bisa terblokir tanpa batas waktu.

Saga pattern menyelesaikan masalah yang sama dengan trade-off berbeda. Alih-alih satu transaksi atomik, saga adalah rangkaian transaksi lokal, masing-masing langsung di-commit di layanannya sendiri, dengan aksi kompensasi yang didefinisikan untuk setiap langkah jika langkah berikutnya gagal. Artikel ini membahas implementasi saga di NestJS menggunakan modul CQRS resmi, membandingkan orkestrasi dan koreografi, serta aturan idempotensi yang menjaga kompensasi tetap aman ketika pesan dikirim ulang.

Mengapa two-phase commit tidak cocok untuk microservices

Two-phase commit (2PC) bekerja baik di dalam satu cluster database karena koordinator dan peserta berbagi koneksi cepat dan andal serta domain kegagalan yang sama. Ketika tersebar di layanan yang di-deploy secara independen dengan database masing-masing, tiga masalah muncul dengan cepat.

  • Blocking: jika koordinator crash setelah peserta memberi suara setuju tetapi sebelum pesan commit dikirim, setiap peserta menahan lock-nya hingga koordinator pulih, membekukan permintaan lain yang menyentuh baris data yang sama.
  • Coupling: setiap layanan dalam transaksi harus mendukung protokol yang sama dan bisa dijangkau secara sinkron pada saat yang bersamaan, yang justru meniadakan tujuan men-deploy layanan secara independen.
  • Availability: 2PC menukar ketersediaan demi konsistensi dengan cara yang tidak sesuai dengan bagaimana kebanyakan tim produk ingin alur checkout atau pendaftaran mereka berperilaku saat terjadi gangguan parsial.

Jika sebuah alur kerja benar-benar butuh jaminan ACID yang ketat antara dua data, tanyakan dulu apakah kedua data itu seharusnya berada di layanan dan database yang sama. Memisahkannya ke layanan berbeda lalu merakit kembali transaksi terdistribusi biasanya menandakan batas layanan yang salah, bukan kekurangan pustaka 2PC.

Orkestrasi vs koreografi

Ada dua cara menyusun alur kontrol sebuah saga. Pada koreografi, setiap layanan mendengarkan event yang dipublikasikan layanan lain dan memutuskan sendiri langkah berikutnya, tanpa koordinator pusat. Pada orkestrasi, satu komponen orchestrator menyimpan urutan langkah beserta kompensasinya, mengirim perintah ke setiap layanan secara berurutan dan bereaksi terhadap hasilnya. Keduanya valid; pilihan yang tepat bergantung pada berapa banyak layanan yang terlibat dan seberapa besar kebutuhan tim akan visibilitas alur tersebut.

AspekKoreografiOrkestrasi
Lokasi logika sagaTersebar di setiap layanan yang terlibatTerpusat dalam satu kelas orchestrator
Debugging alur yang gagalPerlu menelusuri event lintas layananSatu tempat untuk memeriksa langkah dan state saat ini
Paling cocok untukDua atau tiga peserta, alur sederhanaEmpat langkah atau lebih, atau langkah yang bercabang

Mengimplementasikan saga terorkestrasi dengan NestJS CQRS

NestJS menyediakan modul CQRS dengan decorator Saga yang dibuat khusus untuk kasus ini. Metode saga menerima stream observable dari setiap event yang dikirim lewat event bus, menyaringnya dengan operator ofType, lalu memetakan event yang cocok menjadi command baru yang otomatis dikirim oleh command bus. Ini menjaga logika orkestrasi tetap deklaratif: bereaksi terhadap sebuah event, memancarkan command berikutnya, atau memancarkan command kompensasi ketika ada langkah sebelumnya yang melaporkan kegagalan.

// order-saga.ts
import { ICommand, ofType, Saga } from '@nestjs/cqrs'
import { Injectable } from '@nestjs/common'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { OrderCreatedEvent } from './events/order-created.event'
import { ReserveInventoryCommand } from './commands/reserve-inventory.command'
import { InventoryReservationFailedEvent } from './events/inventory-reservation-failed.event'
import { CancelOrderCommand } from './commands/cancel-order.command'

@Injectable()
export class OrderSaga {
  @Saga()
  orderCreated = (events$: Observable<ICommand>): Observable<ICommand> => {
    return events$.pipe(
      ofType(OrderCreatedEvent),
      map((event) => new ReserveInventoryCommand(event.orderId, event.items)),
    )
  }

  // Compensating step: undo the order if a downstream step fails
  @Saga()
  inventoryReservationFailed = (events$: Observable<ICommand>): Observable<ICommand> => {
    return events$.pipe(
      ofType(InventoryReservationFailedEvent),
      map((event) => new CancelOrderCommand(event.orderId, event.reason)),
    )
  }
}

Setiap command handler dalam rangkaian ini menjalankan satu transaksi lokal dan mempublikasikan event sukses yang direspons saga berikutnya, atau event gagal yang memicu kompensasi yang sesuai. Orchestrator itu sendiri, kelas OrderSaga, tidak pernah berbicara langsung ke database layanan lain. Ia hanya mengenal bentuk event yang masuk dan command yang keluar, sehingga tetap mudah diuji tanpa harus menjalankan setiap layanan hilir.

Saga bukan rollback. Setelah layanan inventaris benar-benar mengurangi stok dan meng-commit perubahan itu, tidak ada pembatalan otomatis. Command kompensasi harus menjalankan transaksi lokal yang setara dan berlawanan, menambah kembali stok, dan kompensasi itu sendiri bisa gagal, jadi rencanakan mekanisme retry dan jalur dead-letter sejak awal.

Merancang aksi kompensasi dengan benar

Aksi kompensasi bukan sekadar kebalikan dari aksi maju; ia harus membalikkan efek bisnisnya dengan memperhitungkan apa pun yang terjadi di antaranya. Empat aturan berikut menjaga kompensasi tetap andal dalam praktik.

  1. Kompensasi harus berlawanan secara semantik, bukan sekadar berlawanan secara struktural: membatalkan reservasi tidak sama dengan menghapus catatan reservasi jika layanan hilir sudah membaca keberadaannya untuk mengambil keputusan sendiri.
  2. Kompensasi harus bisa diulang, karena pesan yang memicu kompensasi bisa dikirim ulang setelah gangguan jaringan, restart consumer, atau jaminan pengiriman at-least-once yang terpicu dua kali.
  3. Kompensasi harus tetap toleran meski langkah maju belum pernah terjadi, karena kegagalan yang memicu kompensasi bisa saja tiba sebelum event sukses dari langkah maju itu sendiri, tergantung urutan pesan.
  4. Kompensasi harus dicatat, bukan terus dicoba ulang tanpa henti secara diam-diam, sehingga operator bisa melihat saga mana yang macet dalam state kompensasi sebagian dan melakukan intervensi manual jika retry otomatis sudah habis.
// cancel-order.handler.ts
@CommandHandler(CancelOrderCommand)
export class CancelOrderHandler implements ICommandHandler<CancelOrderCommand> {
  constructor(
    private readonly orders: OrderRepository,
    private readonly ledger: ProcessedStepLedger, // dedupe table keyed by (sagaId, step)
  ) {}

  async execute(command: CancelOrderCommand): Promise<void> {
    const { orderId, reason } = command

    // Idempotency check — a redelivered event must not double-compensate
    const alreadyHandled = await this.ledger.wasProcessed(orderId, 'cancel-order')
    if (alreadyHandled) return

    const order = await this.orders.findById(orderId)
    if (order.status === 'cancelled') return // already terminal, safe no-op

    order.cancel(reason)
    await this.orders.save(order)
    await this.ledger.markProcessed(orderId, 'cancel-order')
  }
}

Idempotensi: properti yang membuat retry aman

Message broker mayoritas lebih memilih pengiriman at-least-once dibanding exactly-once, karena pengiriman exactly-once lintas gangguan jaringan sebenarnya tidak bisa dicapai tanpa kerja sama dari penerima. Artinya, setiap command handler dalam saga, baik langkah maju maupun kompensasi, pada akhirnya akan dipanggil dua kali untuk event logis yang sama. Solusinya bukan deduplikasi di sisi broker; melainkan merancang setiap handler agar penerapannya dua kali menghasilkan state akhir yang sama seperti diterapkan satu kali.

  • Periksa state entitas saat ini sebelum bertindak: jika sebuah pesanan sudah berstatus dibatalkan, CancelOrderCommand kedua seharusnya menjadi no-op, bukan efek samping pembatalan kedua.
  • Lacak identitas langkah yang sudah diproses dalam tabel ledger kecil berkunci id saga dan nama langkah, lalu lewati eksekusi jika baris yang cocok sudah ada.
  • Buat efek samping seperti mengirim email konfirmasi bersyarat pada flag pertama-kali dari pemeriksaan ledger, bukan pada kedatangan command itu sendiri, karena kedatangan tidak berarti kebaruan.

Handler yang idempoten punya manfaat tambahan di luar keamanan: mereka membuat pengembangan lokal jadi sederhana, karena memutar ulang sebuah event dua kali saat debugging tidak pernah merusak data uji.

Kapan saga benar-benar layak digunakan

Saga menambah biaya operasional yang nyata: lebih banyak tipe event, tabel ledger untuk idempotensi, dan model mental yang mencakup banyak layanan alih-alih hidup dalam satu fungsi. Biaya itu terbayar ketika sebuah proses bisnis memang melintasi batas layanan dan butuh konsistensi eventual dengan jalur pemulihan yang jelas, seperti pemenuhan pesanan yang menyentuh pembayaran, inventaris, dan pengiriman. Saga menjadi alat yang salah ketika data sebenarnya milik satu layanan; perbaikan yang tepat biasanya menyatukan kepemilikan, bukan menambahkan saga di atas batas yang sudah salah.

Mulailah dengan orkestrasi bahkan untuk saga dua langkah. Biayanya hanya satu kelas kecil di awal dan terbayar sejak langkah ketiga ditambahkan, karena rantai event implisit pada koreografi lebih sulit diperluas dibanding ditulis ulang dari awal.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa itu saga pattern dalam sistem terdistribusi?

Saga pattern mengoordinasikan sebuah transaksi bisnis yang melintasi banyak layanan dengan memecahnya menjadi rangkaian transaksi lokal, masing-masing di-commit di layanannya sendiri. Jika langkah berikutnya gagal, saga menjalankan aksi kompensasi untuk membatalkan efek dari langkah-langkah yang sudah berhasil, alih-alih mengandalkan satu commit atomik lintas layanan.

Mengapa tidak menggunakan two-phase commit saja untuk transaksi terdistribusi?

Two-phase commit mengharuskan setiap peserta menahan lock sementara koordinator mengumpulkan suara, dan jika koordinator gagal di tengah proses voting, lock tersebut bisa memblokir tanpa batas waktu. Ini juga memaksa setiap layanan yang terlibat harus bisa dijangkau secara sinkron pada saat yang sama, yang bertentangan dengan cara kerja microservices yang di-deploy secara independen.

Bagaimana cara mengimplementasikan saga di NestJS?

Modul CQRS NestJS menyediakan decorator Saga yang mendengarkan stream observable dari event pada event bus, menyaringnya dengan operator ofType, lalu memetakan event yang cocok menjadi command yang otomatis dikirim oleh command bus. Setiap command handler menjalankan satu transaksi lokal dan memancarkan event sukses atau gagal, yang direspons oleh kelas saga yang sama untuk langkah berikutnya atau aksi kompensasi.

Apa perbedaan orkestrasi dan koreografi dalam saga?

Orkestrasi menggunakan satu komponen pusat yang menyimpan urutan langkah dan mengirim command ke setiap layanan secara berurutan, sehingga ada satu tempat untuk memeriksa state saga. Koreografi tidak memiliki koordinator pusat; setiap layanan bereaksi terhadap event dari layanan lain secara independen, yang cocok untuk dua atau tiga peserta tetapi makin sulit ditelusuri seiring bertambahnya langkah.

Bisakah aksi kompensasi dalam saga gagal?

Bisa, dan ini harus direncanakan sejak awal. Aksi kompensasi adalah transaksi lokal itu sendiri yang bisa gagal karena masalah jaringan atau konflik aturan bisnis, sehingga perlu penanganan retry dan idempotensi yang sama seperti langkah maju, ditambah jalur dead-letter dan monitoring agar operator bisa melakukan intervensi jika retry otomatis sudah habis.