Topik wawancara representatif

Wawancara Backend: Bagaimana Cara Anda Menyelesaikan Masalah Dual-Write Database dan Message Broker?

BackendSulit
Tim Redaksi Offer.ccDipublikasikan Diperbarui

Pertanyaan

Sebuah order service menulis pesanan ke PostgreSQL dan memublikasikan OrderCreated ke sebuah message broker. Tanpa distributed two-phase commit, bagaimana Anda memastikan bahwa pesanan yang di-rollback tidak memancarkan event, pesanan yang di-commit pada akhirnya memancarkan setidaknya satu event, dan crash pada relay atau consumer tidak menduplikasi efek bisnis? Jelaskan pula aspek pengurutan, operasional, dan verifikasinya.

Konteks Soal dan Penerapan

Sebuah order service harus melakukan dua hal untuk satu perintah:

  1. menulis pesanan ke PostgreSQL; dan
  2. memublikasikan event OrderCreated ke sebuah message broker.

Kontrak bisnisnya lebih ketat daripada sekadar "coba kedua pemanggilan." Jika transaksi database melakukan rollback, tidak boleh ada event yang mendeskripsikan pesanan yang tidak ada tersebut. Jika transaksi melakukan commit, intensi untuk memublikasikan harus bertahan dari crash proses dan pada akhirnya sampai ke broker. Event untuk pesanan yang sama harus tetap berurutan, sementara pengurutan global tidak diperlukan. Relay dan consumer dapat mengalami crash sewaktu-waktu, dan broker dapat mengirim ulang pesan. Distributed two-phase commit tidak tersedia.

Ini adalah masalah transactional outbox. Masalah ini muncul setiap kali satu permintaan mengubah state transaksional dan harus secara andal memicu pekerjaan di sistem lain: reservasi inventaris, penagihan, pengindeksan pencarian, email, webhook, atau analitik. Tujuannya bukanlah pengiriman exactly-once yang ajaib. Tujuannya adalah mengidentifikasi batasan atomik yang tersedia, membuat intensi lintas batas menjadi durable, dan membuat upaya coba lagi (retry) aman.

Apa yang Dinilai oleh Pewawancara

Sinyal pertama adalah penalaran jendela kegagalan (failure-window reasoning). "Tulis ke database, lalu publikasikan" akan kehilangan event jika proses mengalami crash setelah commit. "Publikasikan, lalu commit" mengekspos event bahkan jika database kemudian melakukan rollback. Callback in-memory setelah commit juga akan hilang bersama proses yang crash. Jawaban yang kuat menyebutkan jendela-jendela kegagalan ini sebelum mengusulkan sebuah pola.

Sinyal kedua adalah jaminan yang tepat. Baris bisnis dan baris outbox dapat di-commit secara atomik dalam satu transaksi database lokal. Publikasi ke broker dilakukan setelahnya. Hal ini menjamin intensi event yang durable untuk setiap mutasi yang di-commit; ini tidak menjadikan database dan broker sebagai satu transaksi tunggal, dan tidak menjanjikan pengiriman exactly-once.

Sinyal ketiga adalah keamanan retry secara end-to-end. Jika broker menerima event dan relay mengalami crash sebelum mencatat keberhasilan, event akan dipublikasikan lagi. Oleh karena itu, relay menyediakan publikasi at-least-once, dan setiap consumer harus membuat efek bisnisnya menjadi idempoten. Jawaban yang baik juga membedakan efek database lokal milik consumer dari efek samping eksternal seperti menagih kartu kredit.

Sinyal terakhir adalah pengurutan dan operabilitas: nomor urut (sequence) per agregat, partition key, kepemilikan relay secara konkuren, poison event, kebijakan retry, pembersihan (cleanup), retensi replay, metrik lag, dan pengujian injeksi kesalahan (fault-injection). Menyebutkan pola tanpa batasan-batasan ini dianggap tidak lengkap.

Pertanyaan untuk Diklarifikasi Sebelum Menjawab

  • Jaminan apa yang diwajibkan? Apakah publikasi at-least-once dengan efek bisnis exactly-once

sudah cukup, atau apakah konfirmasi sinkron diperlukan sebelum merespons pemanggil?

  • Mutasi dan event mana yang saling terkait? Satu mutasi pesanan dapat menghasilkan satu event, atau satu

transaksi dapat menghasilkan beberapa event yang memerlukan nomor urut berurutan per pesanan.

  • Pengurutan apa yang diperlukan? Desain ini mengasumsikan pengurutan per pesanan, bukan satu urutan total

global di seluruh pesanan.

  • Apa yang dapat dijamin oleh broker? Tanyakan tentang acknowledgement, pengiriman ulang, pengurutan partisi,

retensi, dan idempotensi produser. Tidak ada satu pun dari hal tersebut yang dengan sendirinya menghilangkan celah serah terima database-ke-broker.

  • Seberapa cepat suatu event harus muncul? Target latensi memengaruhi interval polling, beban database,

dan apakah change data capture dapat dijustifikasi.

  • Apa yang dilakukan oleh consumer? Pembaruan database lokal dapat berbagi transaksi dengan baris inbox;

pembayaran eksternal atau email memerlukan idempotency key downstream atau serah terima durable lainnya.

  • Berapa lama replay harus tetap dimungkinkan? Pembersihan record outbox dan deduplikasi consumer harus

menjaga batas waktu (horizon) retry dan replay yang diperlukan.

  • Bisakah kedua sumber daya berpartisipasi dalam two-phase commit? Soal menyatakan tidak. Jika sistem nyata benar-benar

memerlukan atomisitas sinkron lintas sumber daya dan kedua sumber daya mendukungnya, ketersediaan dan biaya coupling-nya tetap harus dievaluasi alih-alih menyatakannya mustahil secara universal.

Kerangka Jawaban 30 Detik

"Saya akan menulis pesanan dan event outbox yang immutable dalam transaksi PostgreSQL yang sama. Sebuah relay terpisah akan mengklaim baris outbox yang sudah di-commit, memublikasikannya, dan menandainya terbit hanya setelah menerima acknowledgement dari broker. Jika relay mati sebelum publikasi, baris tersebut tetap berstatus pending; jika mati setelah broker menerima namun sebelum penandaan status, relay akan memublikasikan ulang, sehingga pengiriman bersifat at-least-once. Setiap event memiliki ID yang stabil, dan consumer memasukkan ID tersebut ke dalam tabel deduplikasi dalam transaksi yang sama dengan pembaruan bisnisnya. Saya akan mengalokasikan nomor urut per pesanan, menggunakan ID pesanan sebagai partition key pada broker, mencegah event berikutnya mendahului event pending yang lebih awal, dan memantau usia pending tertua. Kemudian saya akan menginjeksikan crash pada setiap batasan commit, publikasi, acknowledgement, dan consumer untuk memverifikasi invariannya."

Pembahasan Mendalam Langkah demi Langkah

Mulailah dengan membuktikan mengapa urutan pemanggilan yang biasa dilakukan gagal. Dalam alur database-terlebih-dahulu, database dapat melakukan commit pada waktu T1 dan proses dapat terhenti sebelum broker menerima pada waktu T2; pesanan ada tetapi tidak ada event. Mengulang permintaan HTTP bukanlah perbaikan yang tuntas karena klien mungkin tidak mencoba lagi, dan retry dapat menduplikasi pesanan kecuali jika perintah itu sendiri bersifat idempoten. Dalam alur broker-terlebih-dahulu, consumer dapat melihat event sebelum transaksi pesanan gagal. Membalik urutan pemanggilan hanya membalik inkonsistensinya.

Pindahkan intensi durable ke dalam satu batasan atomik yang dimiliki layanan. Dalam satu transaksi PostgreSQL tunggal, validasi perintah, ubah pesanan, alokasikan nomor urut berikutnya untuk pesanan tersebut, dan masukkan baris outbox yang immutable. Entah kedua baris di-commit atau keduanya dibatalkan. Skema yang representatif adalah:

sql
CREATE TABLE outbox_events (
  event_id uuid PRIMARY KEY,
  aggregate_type text NOT NULL,
  aggregate_id text NOT NULL,
  aggregate_sequence bigint NOT NULL,
  event_type text NOT NULL,
  schema_version integer NOT NULL,
  payload jsonb NOT NULL,
  occurred_at timestamptz NOT NULL DEFAULT now(),
  available_at timestamptz NOT NULL DEFAULT now(),
  claimed_by text,
  claim_until timestamptz,
  published_at timestamptz,
  attempt_count integer NOT NULL DEFAULT 0,
  last_error text,
  UNIQUE (aggregate_type, aggregate_id, aggregate_sequence)
);

CREATE INDEX outbox_dispatch_idx
ON outbox_events (available_at, occurred_at)
WHERE published_at IS NULL;

event_id tetap stabil melalui setiap retry. schema_version membuat evolusi payload menjadi eksplisit. Urutan agregat yang unik mencegah dua event menempati posisi logis yang sama. Nomor urut harus dialokasikan di bawah transaksi dan aturan penguncian yang sama dengan agregat; timestamp atau urutan pemrosesan relay bukanlah pengganti yang aman. Jika satu transaksi memancarkan beberapa event, alokasikan nilai urutan yang berurutan sesuai urutan yang diinginkan.

Polling relay harus mengklaim batch kecil dalam transaksi singkat. Relay dapat memilih baris dengan FOR UPDATE SKIP LOCKED, memperbarui claimed_by dan claim_until, lalu melakukan commit; lease yang dipersistensikan mencegah worker lain memproses baris yang sama secara sengaja setelah row lock dilepas. Relay harus memublikasikan di luar database lock yang ditahan lama dan menandai baris telah diterbitkan hanya setelah broker memberikan acknowledgement. Kedaluwarsanya lease memungkinkan pemulihan ketika worker mati. Backoff dan available_at mencegah tujuan yang gagal menciptakan tight retry loop. Membiarkan transaksi database tetap terbuka selama publikasi jaringan meningkatkan perebutan sumber daya (contention) dan tetap tidak menciptakan atomisitas dengan broker.

Ada celah acknowledgement yang tidak dapat dihindari. Broker dapat menerima event E secara durable, setelah itu relay dapat mengalami crash sebelum menyetel published_at. Saat pemulihan, E akan dipublikasikan lagi. Menandainya sebelum publikasi akan menciptakan celah sebaliknya yang menghilangkan data (lossy). Oleh karena itu, relay harus memilih sisi yang aman—kemungkinan terjadinya duplikasi—dan consumer harus melakukan deduplikasi.

Bagi consumer yang efek bisnisnya berada di dalam database, simpan ID event yang telah diproses di dalam transaksi yang sama:

sql
CREATE TABLE processed_events (
  consumer_name text NOT NULL,
  event_id uuid NOT NULL,
  processed_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer_name, event_id)
);

Consumer memulai transaksi dan menggunakan INSERT ... ON CONFLICT DO NOTHING RETURNING untuk (consumer_name, event_id). Consumer menerapkan perubahan bisnis hanya jika operasi insert mengembalikan baris, lalu melakukan commit. Tidak ada baris yang dikembalikan berarti event sudah pernah diterapkan, sehingga pesan duplikat dapat di-acknowledge tanpa mengulangi perubahan tersebut. Menulis baris deduplikasi dalam satu transaksi dan efek bisnis di transaksi lain hanya akan menciptakan masalah dual-write yang baru. Record deduplikasi juga harus dipertahankan setidaknya selama event lama masih mungkin untuk di-replay.

Inbox lokal ini tidak mencakup efek eksternal non-transaksional secara atomik. Untuk API pembayaran, berikan event_id sebagai idempotency key penyedia layanan. Jika sistem tujuan tidak mendukung idempotensi, buatlah pola command/outbox durable lain ditambah rekonsiliasi, atau terima risiko duplikasi yang terdokumentasi. Peringatan yang sama berlaku untuk email, webhook, dan pemanggilan irreversible lainnya.

Pengurutan harus cocok dengan batasan bisnis. Tetapkan nomor urut yang monoton per pesanan, jangan biarkan urutan k + 1 mendahului k yang belum dipublikasikan, dan gunakan aggregate_id sebagai partition key broker. Kueri klaim dapat memilih hanya nomor urut terkecil yang belum dipublikasikan untuk setiap agregat, atau kepemilikan relay dapat dipartisi berdasarkan hash stabil dari aggregate_id; pilihan mana pun harus memastikan adanya satu jalur publikasi yang terurut per agregat. Consumer dapat menolak, menampung (buffer), atau merekonsiliasi celah urutan sesuai kebutuhan domain. Mengharuskan satu urutan global akan menserialisasi pesanan yang tidak saling terkait dan menurunkan ketersediaan tanpa memberikan manfaat bagi invarian per pesanan.

Polling adalah relay portabel paling sederhana dan membuat kepemilikan terlihat jelas di dalam database aplikasi, namun intervalnya menukar latensi dengan beban kueri. Indeks pending, batch kecil, lease, dan pembersihan yang dibatasi menjadi penting seiring meningkatnya volume. Change data capture (CDC) dapat membaca log database (tailing) dan meneruskan baris outbox yang dimasukkan dengan tekanan polling yang lebih rendah dan sering kali latensi yang lebih rendah. CDC menambahkan offset konektor, retensi log database, deployment, dan pemulihan ke dalam batasan operasional. Mengambil perubahan sembarang tabel bisnis juga mengekspos mutasi penyimpanan alih-alih domain event yang disengaja; outbox eksplisit menjaga kontrak tetap stabil.

Operasional melengkapi desain ini. Pantau jumlah baris pending, usia baris pending tertua, throughput dan kegagalan pengiriman, jumlah percobaan, lease yang kedaluwarsa, latensi acknowledgement broker, jumlah deduplikasi consumer, event yang dikarantina, dan pertumbuhan tabel. Arsipkan atau hapus baris yang telah diterbitkan dalam batch yang dibatasi hanya setelah batas waktu replay dan audit terlewati. Poison event memerlukan kebijakan yang matang: coba lagi, karantina, atau perbaiki. Melewatinya dapat melanggar pengurutan per pesanan, sehingga event-event selanjutnya untuk agregat tersebut tidak boleh dilanjutkan secara diam-diam.

Verifikasi harus menargetkan batasan-batasan sistem, bukan hanya jalur sukses (happy path). Injeksikan kegagalan sebelum commit database, setelah commit tetapi sebelum respons, selama klaim relay, sebelum publikasi, setelah penerimaan broker tetapi sebelum published_at, setelah commit bisnis consumer tetapi sebelum acknowledgement, dan selama pembersihan. Pengujian harus menetapkan empat invarian:

  1. setiap mutasi bisnis yang di-commit memiliki tepat satu intensi outbox yang durable;
  2. setiap mutasi yang di-rollback tidak memiliki intensi outbox;
  3. setiap intensi durable pada akhirnya dipublikasikan setidaknya sekali setelah pemulihan; dan
  4. pengiriman duplikat hanya menerapkan efek bisnis yang terlihat oleh consumer sebanyak satu kali.

Hentikan juga relay dalam waktu yang cukup lama untuk membangun backlog, mulai ulang, dan verifikasi pemulihan lag, urutan per pesanan, batas beban database, dan perilaku peringatan (alert). Ujilah poison event, evolusi versi payload, replay event lama, dan pembersihan di sekitar batas retensi.

Contoh Jawaban Berkualitas Tinggi

"Database dan broker tidak berbagi atomic commit, jadi saya pertama-tama akan menjadikan intensi event sebagai bagian dari transaksi database. Baris pesanan dan baris outbox yang immutable di-commit secara bersamaan. Jika transaksi di-rollback, tidak ada baris yang tersimpan. Jika transaksi di-commit dan proses mati seketika, proses lain masih dapat melihat baris outbox tersebut.

Sebuah relay mengklaim baris-baris pending dengan transaksi database singkat dan lease yang dapat kedaluwarsa, memublikasikannya, dan menyetel published_at hanya setelah menerima acknowledgement dari broker. Saya tidak akan menahan database lock saat menunggu respons jaringan. Masih ada celah crash setelah broker menerima dan sebelum status diperbarui, sehingga relay dapat memublikasikan duplikat. Itu adalah bias kegagalan (failure bias) yang benar: pesan duplikat dapat dipulihkan, sedangkan event yang hilang tidak bisa.

Setiap event memiliki UUID yang stabil. Consumer database memasukkan UUID tersebut ke dalam tabel yang diberi key berupa nama consumer dalam transaksi yang sama dengan pembaruan bisnisnya. Event duplikat akan mengalami konflik dan menjadi no-op. Jika consumer memanggil penyedia pembayaran atau email, ia harus meneruskan UUID event sebagai idempotency key atau menggunakan handoff durable lainnya, karena transaksi deduplikasi lokal tidak dapat mencakup efek jarak jauh tersebut.

Untuk pengurutan, saya mengalokasikan nomor urut di bawah transaksi pesanan, memublikasikan dengan ID pesanan sebagai partition key, dan memblokir nomor urut yang lebih baru agar tidak mendahului event pending sebelumnya untuk pesanan tersebut. Saya tidak menerapkan pengurutan global. Saya akan mulai dengan polling kecuali jika target latensi dan beban membenarkan penggunaan CDC, kemudian memantau usia pending tertua, retry, klaim kedaluwarsa, tingkat duplikasi, poison event, dan pertumbuhan tabel.

Terakhir, saya akan menghentikan paksa (kill) proses di setiap batasan. Hasil yang diwajibkan adalah: rollback tidak menghasilkan intensi, commit selalu meninggalkan intensi, pemulihan memublikasikan setiap intensi setidaknya sekali, dan pengiriman duplikat hanya mengubah state consumer satu kali. Outbox menyelesaikan serah terima yang andal; idempotensi permintaan, idempotensi consumer, evolusi skema, dan rekonsiliasi tetap menjadi bagian eksplisit dari sistem."

Kesalahan Umum

  • Memanggil database dan broker secara berurutan → salah satu panggilan dapat berhasil secara sepihak → Commit mutasi bisnis dan intensi event dalam satu transaksi database lokal.
  • Memanggil in-memory publisher setelah commit → crash akan menghilangkan callback beserta state-nya → Persistensikan intensi sebelum mengembalikan respons.
  • Mengklaim bahwa outbox memberikan pengiriman exactly-once → celah antara broker-menerima/status-belum-tercatat menciptakan duplikasi → Sebutkan publikasi at-least-once dan rancang efek bisnis exactly-once.
  • Menandai baris telah dipublikasikan sebelum menerima acknowledgement broker → crash dapat menghilangkan event secara permanen → Catat keberhasilan hanya setelah ada acknowledgement dan toleransi publikasi ulang.
  • Menulis status deduplikasi secara terpisah dari efek consumer → consumer menciptakan kembali celah dual-write yang sama → Tempatkan keduanya dalam satu transaksi lokal.
  • Memperlakukan inbox lokal sebagai proteksi untuk penagihan eksternal → efek eksternal tidak dapat bergabung dalam transaksi → Gunakan downstream idempotency key, serah terima durable, dan rekonsiliasi.
  • Menggunakan timestamp sebagai pengurutan → perilaku jam dan konkurensi tidak mengalokasikan posisi kausal yang unik → Alokasikan nomor urut per agregat secara transaksional dan gunakan agregat sebagai partition key.
  • Menjalankan banyak poller tanpa klaim atau lease → para worker akan saling berebut pada baris yang sama secara sengaja → Gunakan klaim singkat, masa kedaluwarsa, batch kecil, dan indeks baris pending.
  • Menghapus baris yang diterbitkan dan baris deduplikasi secara langsung → retry yang tertunda dan proses replay dapat mengulangi efek lama → Atur pembersihan berdasarkan batas waktu replay dan audit yang terdokumentasi.
  • Hanya menguji publikasi yang sukses → jaminan desain berada pada skenario jendela crash → Injeksikan kegagalan sebelum dan sesudah setiap batasan durable serta pastikan pemenuhan invarian.

Pertanyaan Lanjutan dan Tanggapan

Lanjutan 1: Bagaimana jika tujuannya adalah API eksternal, bukan broker?

Transaksi sumber masih dapat menulis perintah outbox. Sebuah worker memanggil API dengan event_id sebagai idempotency key dan mencatat responsnya. Kondisi timeout bersifat ambigu—layanan jarak jauh mungkin telah menyelesaikan panggilan tersebut—sehingga lakukan retry hanya dengan key yang sama. Jika API tidak menyediakan idempotensi maupun status operasi yang dapat di-kueri, efek exactly-once tidak dapat dijamin; tambahkan rekonsiliasi atau ungkapkan risiko duplikasi dalam kontrak bisnis.

Lanjutan 2: Bagaimana jika alur kerja mencakup beberapa layanan dan database?

Outbox secara andal memublikasikan transisi state lokal dari setiap layanan; pola ini tidak melakukan commit atomik pada seluruh alur kerja multi-layanan. Modelkan alur kerja sebagai saga dengan langkah-langkah maju yang eksplisit, idempotensi, state yang dipersistensikan, dan compensating action. Setiap langkah saga dapat menggunakan transaksi lokalnya sendiri ditambah outbox. Definisikan apa yang terjadi jika kompensasi juga gagal, alih-alih menggambarkannya sebagai rollback di semua database.

Lanjutan 3: Bagaimana jika pewawancara mewajibkan urutan event global yang ketat?

Klarifikasi mengapa agregat-agregat yang independen memerlukan satu urutan yang sama dan berapa banyak throughput atau ketersediaan yang dapat dikorbankan untuk itu. Satu sequencer tunggal atau satu partisi broker dapat menetapkan total order, tetapi hal itu menjadi bottleneck untuk serialisasi dan titik kegagalan (failure bottleneck). Sebagian besar alur kerja pesanan hanya memerlukan urutan kausal dalam satu pesanan, yang dapat dipenuhi secara lebih murah melalui nomor urut agregat transaksional dan partition key agregat.

Lanjutan 4: Bagaimana desain ini pulih dari pemadaman (outage) konektor CDC?

Baris-baris outbox yang telah di-commit tetap menjadi source of truth. Buat alert pada metrik connector lag dan sisa kapasitas retensi log database, simpan offset konektor secara durable, dan uji proses restart dari offset terakhir yang di-acknowledge. Database harus mempertahankan log cukup lama untuk memenuhi target durasi outage; jika tidak, diperlukan snapshot atau backfill terkontrol. Deduplikasi pada consumer membuat proses replay rentang data yang tumpang tindih menjadi aman.

Lanjutan 5: Kapan Anda sebaiknya menghindari penggunaan transactional outbox?

Gunakan desain yang lebih sederhana ketika event secara eksplisit bersifat best-effort, seperti telemetri yang tidak kritis, atau ketika sistem downstream dapat melakukan polling ke source of truth secara aman dan target latensi memungkinkan. Jika kedua sumber daya benar-benar mendukung two-phase commit dan atomisitas sinkron adalah sebuah keharusan, evaluasi opsi tersebut beserta biaya coupling dan ketersediaannya. Event sourcing adalah alternatif lain, namun hal itu mengubah model source-of-truth dan tidak boleh diperkenalkan hanya demi menghindari satu masalah serah terima data.

Sumber publik

Pertanyaan terkait