Topik wawancara representatif

Mendesain API Create-Order yang Idempoten

BackendSulit
Tim Redaksi Offer.ccDipublikasikan Diperbarui

Pertanyaan

Rancang kontrak idempotensi untuk POST /orders: klien harus dapat mencoba kembali setelah timeout, duplikat konkuren hanya boleh membuat satu pesanan, penggunaan kembali key dengan parameter berbeda harus ditolak, dan desain harus menangani respons yang hilang setelah commit, operasi yang sedang berlangsung, serta efek samping downstream.

Masalah dan Cakupan

Rancang kontrak idempotensi untuk POST /orders. Klien seluler dapat mengalami timeout pada jaringan yang tidak stabil dan mencoba kembali secara otomatis. Gateway juga dapat memutar ulang (replay) permintaan setelah koneksinya terputus. Baik satu operasi logis tiba sekali atau beberapa kali, server hanya boleh membuat satu pesanan. Setelah permintaan berhasil, percobaan ulang yang identik harus memutar ulang hasil pertama; server harus menolak idempotency key yang sama jika membawa parameter pesanan yang berbeda.

Asumsikan pemanggil mengirimkan pengenal acak untuk satu operasi logis dalam Idempotency-Key, dan server menggunakan PostgreSQL. Penulisan pesanan dan event tertunda dapat berbagi satu transaksi database lokal. Sistem pembayaran dan inventaris eksternal tidak dapat berpartisipasi dalam transaksi tersebut. API ini bersifat multi-tenant, dan responsnya dapat berisi data pesanan yang memerlukan otorisasi.

Cakupan mencakup deduplikasi permintaan, race condition konkuren, batasan transaksi, pemutaran ulang hasil, retensi, dan efek samping downstream. Replikasi database lintas-wilayah, state machine domain pembayaran itu sendiri, dan pemilihan message-broker berada di luar cakupan. Ini adalah pertanyaan backend karena tugas utamanya adalah mengubah semantik API menjadi batasan database, pemulihan kegagalan, dan alur permintaan yang dapat diobservasi, alih-alih merancang sistem multi-komponen yang luas.

Apa yang Dievaluasi Pewawancara

Sinyal pertama adalah apakah kandidat memisahkan identitas percobaan ulang (retry identity) dari identitas bisnis (business identity). Idempotency key mengidentifikasi satu operasi dari sudut pandang pemanggil. Ini tidak menggantikan autentikasi, isolasi tenant, atau key pesanan tingkat domain. Server harus mencakupkannya setidaknya berdasarkan (tenant_id, endpoint, idempotency_key). Pencarian hanya berdasarkan key mentah dapat memungkinkan dua tenant yang kebetulan memilih string yang sama membaca hasil satu sama lain.

Sinyal kedua adalah apakah kebenaran konkurensi bertumpu pada batasan keunikan (uniqueness constraint) dan bukan pada timing aplikasi. SELECT yang diikuti oleh INSERT memungkinkan dua permintaan sama-sama tidak melihat adanya baris dan keduanya bertindak sebagai permintaan pertama. Indeks unik dengan INSERT ... ON CONFLICT memungkinkan database memilih satu pemilik. Perbandingan juga harus menyertakan fingerprint permintaan; jika tidak, penggunaan kembali key lama secara tidak sengaja dapat mengembalikan pesanan lama sebagai hasil dari permintaan pesanan yang berbeda.

Sinyal ketiga adalah batasan commit yang presisi. Pesanan, hasil idempotensi, dan outbox event harus di-commit bersama-sama. Kegagalan sebelum commit akan me-rollback ketiganya. Jika commit berhasil tetapi respons HTTP hilang, percobaan ulang akan membaca catatan yang sudah selesai dan memutarnya ulang. Panggilan pembayaran dan inventaris terjadi di luar transaksi melalui konsumen outbox, yang meneruskan pengenal idempotensi turunan ke downstream.

Jawaban yang kuat juga menentukan retensi, respons terhadap duplikat konkuren, kegagalan mana yang disimpan, bagaimana operasi yang berjalan lama dipulihkan, serta bagaimana konflik key dan pekerjaan yang macet dipantau. “Gunakan lock Redis” membiarkan masa berlaku lock, crash proses, pemutaran ulang hasil, dan race condition setelah commit database tidak terjawab.

Pertanyaan untuk Diklarifikasi Sebelum Menjawab

  • Apa yang dianggap sebagai duplikat? Di sini, tenant, endpoint, dan idempotency key yang sama mengidentifikasi satu operasi. Jika domain sudah memiliki checkout_id yang tidak dapat digunakan kembali, tabel pesanan juga harus memiliki batasan keunikan independen padanya.
  • Bisakah pemanggil memilih ID sumber daya? Jika pemanggil memiliki ID pesanan yang stabil, PUT /orders/{clientOrderId} adalah alternatif karena HTTP mendefinisikan PUT sebagai idempoten. POST dengan idempotency key cocok untuk ID pesanan yang dibuat oleh server.
  • Apakah pembuatan pesanan sepenuhnya merupakan pekerjaan database? Jika muat dalam satu transaksi pendek, gunakan desain transaksi tunggal. Alur kerja yang membutuhkan waktu puluhan detik memerlukan catatan IN_PROGRESS yang terlihat, lease, dan takeover token alih-alih transaksi database yang ditahan lama.
  • Seberapa tepat pemutaran ulang harus dilakukan? Desain ini menyimpan status HTTP pertama dan body respons yang aman untuk diputar ulang. Jika respons berisi tanda tangan berumur pendek atau field dinamis (live fields), simpan ID sumber daya dan rekonstruksi respons di bawah kontrak yang eksplisit.
  • Berapa lama key disimpan? Retensi harus mencakup jendela percobaan ulang terpanjang dari klien atau jendela pemutaran ulang offline. Jika duplikasi harus tetap mustahil terjadi setelah jendela tersebut, tambahkan key domain permanen; TTL cache yang lebih lama bukanlah pengganti batasan bisnis.
  • Kegagalan mana yang harus diingat? Validasi yang gagal sebelum kepemilikan diperoleh tidak disimpan. Penolakan bisnis deterministik di dalam transaksi dapat disimpan dan diputar ulang. Kegagalan infrastruktur yang me-rollback transaksi tidak meninggalkan hasil yang selesai, sehingga klien dapat mencoba kembali.

Jawaban 30 Detik

“Saya akan membatasi cakupan setiap idempotency key ke tenant dan POST /orders, lalu membuat fingerprint dari parameter pesanan yang dinormalisasi. Batasan unik pada (tenant_id, endpoint, idempotency_key) menengahi permintaan konkuren. Permintaan pertama menulis catatan idempotensi, pesanan, dan outbox event dalam satu transaksi, lalu menyimpan status dan body respons sebelum commit. Percobaan ulang dengan fingerprint yang sama akan memutar ulang hasil tersebut; fingerprint yang berbeda mengembalikan konflik. Oleh karena itu, respons yang hilang setelah commit tidak dapat membuat pesanan lain. Pembayaran dan inventaris berjalan secara asinkron dari outbox dengan idempotency key turunan. Saya akan mempertahankan catatan permintaan selama jendela percobaan ulang maksimum dan menggunakan batasan keunikan domain pesanan yang terpisah untuk deduplikasi permanen.”

Pembahasan Mendalam Langkah demi Langkah

Mulailah dengan protokol. Pemanggil yang terautentikasi menghasilkan key dengan entropi tinggi untuk satu niat “buat pesanan” dan menggunakannya kembali untuk setiap percobaan ulang. Pesanan baru membutuhkan key baru. Key tidak boleh berisi alamat email, nomor telepon, atau data sensitif lainnya. Server membatasi panjang dan rangkaian karakternya, tetapi key yang tampak acak bukanlah kredensial otorisasi. POST secara default tidak memiliki semantik idempoten seperti PUT; percobaan ulang yang aman berasal dari kontrak aplikasi ini.

Bangun fingerprint permintaan dari field bisnis yang divalidasi server, bukan dari byte JSON mentah. Urutan field, spasi yang tidak signifikan, dan nilai default tidak boleh memberikan fingerprint yang berbeda untuk permintaan yang sama. Kecualikan trace ID, timestamp, dan field non-bisnis lainnya. Input praktisnya adalah pengkodean stabil dari DTO yang dinormalisasi, versi endpoint, dan setiap field yang memengaruhi hasil pesanan. Lakukan hash pada pengkodean tersebut. Digest ini mendeteksi penyalahgunaan key; ini bukan tanda tangan atau mekanisme autentikasi.

Skema minimal ini mengekspresikan batasan yang diperlukan; SQL ini bersifat ilustratif:

sql
CREATE TABLE idempotency_requests (
  tenant_id text NOT NULL,
  endpoint text NOT NULL,
  idempotency_key text NOT NULL,
  request_fingerprint text NOT NULL,
  response_status integer,
  response_body jsonb,
  resource_id uuid,
  created_at timestamptz NOT NULL,
  expires_at timestamptz NOT NULL,
  PRIMARY KEY (tenant_id, endpoint, idempotency_key)
);

Jalur normal untuk transaksi pendek adalah:

  1. Di luar transaksi, lakukan autentikasi, validasi format key dan permintaan, serta hitung fingerprint.
  2. Mulai transaksi dan coba klaim key dengan INSERT ... ON CONFLICT DO NOTHING RETURNING ....
  3. Permintaan yang menyisipkan baris adalah pemiliknya. Permintaan ini membuat pesanan, menambahkan outbox event, memperbarui baris idempotensi dengan status, body respons, dan ID pesanan, lalu melakukan commit sekali.
  4. Permintaan yang tidak menyisipkan akan membaca baris yang ada. Fingerprint yang berbeda mengembalikan 409 idempotency_key_reused. Fingerprint yang sama mengembalikan status dan body pertama yang disimpan, secara opsional disertai penanda pemutaran ulang yang ditentukan API.
  5. Kirim respons HTTP hanya setelah commit. Jika koneksi terputus setelah commit, permintaan berikutnya mengikuti langkah 4 alih-alih membuat pesanan.

Indeks unik PostgreSQL membuat operasi insert konkuren untuk key yang sama menunggu dan kemudian menyelesaikannya menjadi satu insert atau satu konflik. Batasi waktu tunggu tersebut. Jika transaksi pertama selesai dengan cepat, permintaan kedua dapat membaca dan memutar ulang hasilnya. Jika batas waktu tunggu tercapai, kembalikan hasil idempotency_in_progress yang eksplisit beserta panduan percobaan ulang. Jangan gunakan SELECT tingkat aplikasi sebelum memutuskan untuk melakukan insert, dan jangan pernah menimpa fingerprint atau hasil yang disimpan saat terjadi konflik.

Invarian utamanya adalah bahwa setiap kali baris pesanan terlihat, hasil idempotensi dan outbox event-nya juga terlihat; ketika transaksi di-rollback, tidak ada satu pun dari mereka yang terlihat. Kasus-kasus kegagalan mengikuti hal ini secara langsung. Crash proses sebelum pembuatan pesanan akan di-rollback, sehingga percobaan ulang dapat bersaing kembali. Respons yang hilang setelah pesanan di-commit akan diputar ulang. Key yang sama dengan parameter berbeda tidak pernah dieksekusi. Dari dua permintaan identik yang tiba bersamaan, hanya satu yang dapat melewati batasan keunikan dan menyelesaikan penulisan. Penolakan domain deterministik seperti kupon kedaluwarsa dapat disimpan sebagai respons error yang stabil di dalam transaksi. Kegagalan yang belum di-commit seperti koneksi database yang terputus tidak disimpan.

Jika pembuatan pesanan tidak dapat diselesaikan dalam transaksi pendek, gunakan two-phase state machine. Transaksi pendek pertama meng-commit IN_PROGRESS, lease_expires_at, dan attempt_token yang meningkat secara monoton. API mengembalikan 202, atau permintaan lain dengan key tersebut membaca endpoint status. Setelah masa berlaku lease habis, worker baru menggunakan compare-and-swap untuk mengklaim token yang lebih besar. Setiap pembaruan penyelesaian memverifikasi token tersebut, yang mencegah worker lama menimpa hasil yang lebih baru setelah ia melanjutkan pekerjaannya. Pesanan tetap memerlukan batasan keunikan pada ID operasi bisnis, dan efek samping tetap menggunakan outbox atau idempotency key downstream. Baris status dengan TTL tetapi tanpa takeover token memungkinkan worker lama dan baru berjalan bersamaan; hal ini hanya menunda race condition duplikat.

Panggilan pembayaran, inventaris, dan notifikasi eksternal tidak termasuk di dalam transaksi database lokal. Pemilik menulis baris outbox OrderCreated bersama dengan pesanan, dan konsumen mengirimkannya setelah commit. Konsumen melakukan deduplikasi berdasarkan ID event. Panggilan ke API pembayaran atau inventaris menggunakan key stabil yang diturunkan dari ID pesanan dan jenis operasi. Pengiriman pesan setidaknya sekali (at-least-once delivery) kemudian tidak menjadi penagihan setidaknya sekali (at-least-once charging). Jika layanan downstream tidak memiliki antarmuka yang idempoten, gunakan state machine lokal, kueri rekonsiliasi, atau kompensasi manual; outbox saja tidak menghilangkan duplikat eksternal.

Retensi mengikuti kontrak percobaan ulang. Implementasi publik Stripe mengizinkan penghapusan key setelah periode retensi. Hal tersebut menunjukkan bahwa key dapat memiliki siklus hidup, tetapi durasinya tidak universal. Atur expires_at untuk mencakup antrean offline seluler terpanjang, percobaan ulang gateway, dan jendela pemutaran ulang manual. Menggunakan kembali key lama setelah pembersihan adalah operasi baru. Jika satu checkout_id harus menghasilkan paling banyak satu pesanan selamanya, tempatkan batasan unik permanen pada orders sehingga tetap memblokir duplikat setelah catatan idempotensi hilang.

Redis dapat menyimpan cache hasil yang telah selesai, tetapi tidak boleh menjadi satu-satunya batasan kebenaran (correctness boundary). Setelah SET NX berhasil, proses dapat mengalami crash baik sebelum atau sesudah commit pesanan, dan TTL lock dapat kedaluwarsa sebelum permintaan yang lambat selesai. Cache juga tidak dapat meng-commit pesanan dan outbox secara atomik. Biarkan batasan keunikan database menentukan “dibuat paling banyak satu kali.” Gunakan cache hanya untuk mengurangi pembacaan pada pemutaran ulang yang sering terjadi (hot replays), dengan catatan database sebagai sumber kebenaran (source of truth).

Pengujian membutuhkan lebih dari sekadar dua permintaan sekuensial. Cakup setidaknya kasus-kasus berikut: 50 permintaan konkuren dengan satu key menghasilkan satu pesanan dan satu outbox event; key yang sama dengan parameter berbeda mengembalikan konflik; pemutusan paksa setelah commit tetapi sebelum respons tetap memutar ulang pesanan asli; key yang sama dapat mencoba lagi setelah rollback transaksi; perilaku setelah key kedaluwarsa cocok dengan kontrak; dua tenant yang menggunakan key mentah yang sama tetap terisolasi; dan pengiriman duplikat ke konsumen tidak menduplikasi tagihan. Pantau klaim baru, pemutaran ulang, konflik parameter, timeout tunggu konkuren, operasi tertunda yang paling lama, rollback transaksi, lag outbox, dan pembersihan key yang kedaluwarsa. Log tidak boleh menyertakan body permintaan sensitif yang terkait dengan suatu key.

Contoh Jawaban yang Kuat

“Saya akan mendefinisikan satu idempotency key sebagai satu niat pembuatan untuk satu tenant pada POST /orders. Klien menghasilkannya pada panggilan pertama dan menyimpannya untuk percobaan ulang saat timeout. Server membuat fingerprint dari field pesanan yang telah divalidasi bersama dengan versi API. Key dan fingerprint yang sama dapat diputar ulang; key yang sama dengan fingerprint berbeda adalah konflik.

Di PostgreSQL, saya akan menjadikan (tenant_id, endpoint, idempotency_key) sebagai primary key. Setelah validasi permintaan, setiap panggilan bersaing untuk mendapatkan kepemilikan dengan INSERT ... ON CONFLICT DO NOTHING. Pemenang membuat pesanan, menulis baris outbox, dan menyimpan status pertama serta body respons dalam satu transaksi pendek sebelum melakukan commit. Yang kalah tidak dapat membuat pesanan lain. Ia menunggu transaksi pertama, membaca catatan tersebut, dan mengembalikan hasil asli untuk fingerprint yang cocok. Jika batas tunggu internal tercapai, ia memberi tahu pemanggil bahwa operasi masih berlangsung. Dengan demikian, crash setelah commit tetapi sebelum respons HTTP dipulihkan sebagai pesanan yang sama saat dicoba kembali.

Saya tidak akan memanggil pembayaran atau inventaris secara sinkron di dalam transaksi tersebut. Konsumen outbox mengirimkan tindakan tersebut dan meneruskan key yang diturunkan dari ID pesanan ke setiap operasi downstream. Catatan idempotensi hanya ada selama jendela percobaan ulang maksimum, jadi jika sesi checkout tidak boleh membuat pesanan kedua, checkout_id juga mendapatkan batasan unik permanen pada pesanan.

Saya akan menyelesaikannya dengan uji konkurensi, pemutusan koneksi, dan injeksi kegagalan pesan duplikat (duplicate-message fault injection). Saya akan menguji jumlah pesanan, hasil idempotensi, dan outbox event daripada hanya memeriksa HTTP 200. Di lingkungan produksi, saya akan memantau tingkat pemutaran ulang, konflik parameter pada key yang sama, timeout tunggu konkuren, rollback transaksi, dan lag outbox.”

Kesalahan Umum

  • Memeriksa keberadaan baris, lalu menyisipkan pesanan → dua permintaan dapat sama-sama tidak melihat adanya baris dan keduanya menulis → biarkan batasan keunikan database dan penanganan konflik atomik memilih pemiliknya.
  • Hanya mencari berdasarkan idempotency key → tenant dapat bertabrakan atau bahkan menerima respons tenant lain → batasi cakupan berdasarkan tenant dan endpoint yang terautentikasi, serta lakukan otorisasi ulang saat pemutaran ulang.
  • Mengembalikan hasil lama tanpa membandingkan parameter → penggunaan kembali key secara tidak sengaja membuat pesanan lama tampak seperti pesanan baru → simpan fingerprint permintaan yang dinormalisasi dan tolak jika tidak cocok.
  • Meng-commit pesanan, lalu menulis hasil idempotensi → crash di antara penulisan tersebut meninggalkan pesanan tanpa catatan deduplikasi → commit pesanan, hasil, dan outbox dalam satu transaksi database.
  • Memanggil layanan pembayaran di dalam transaksi database → timeout jaringan memperpanjang durasi lock, dan pembayaran yang berhasil tidak dapat di-rollback secara lokal dan atomik → tulis entri outbox dalam transaksi dan gunakan idempotensi downstream di luarnya.
  • Hanya menggunakan Redis lock dengan TTL → setelah crash atau kedaluwarsa, lock tidak dapat mengungkapkan apakah pesanan telah di-commit atau memutar ulang hasilnya → gunakan batasan database untuk kebenaran dan cache hanya untuk akselerasi.
  • Menyimpan cache untuk setiap error → permintaan yang diperbaiki masih dapat menerima error validasi lama, sementara kegagalan sementara menjadi mustahil untuk dicoba kembali → jangan simpan kegagalan validasi sebelum kepemilikan diperoleh; bedakan hasil domain yang stabil dari kegagalan yang tidak di-commit.
  • Menjanjikan deduplikasi permanen setelah menghapus catatan permintaan → key yang kedaluwarsa diperlakukan sebagai operasi baru → publikasikan jendela retensi dan gunakan unique key domain untuk invarian permanen.

Pertanyaan Lanjutan dan Jawaban

Lanjutan 1: Apa yang harus diterima oleh permintaan kedua saat permintaan pertama belum di-commit?

Dengan desain transaksi pendek, konflik indeks unik membuat operasi insert kedua menunggu. Batasi waktu tunggu database dan eksekusi endpoint di bawah batas waktu timeout klien. Jika transaksi pertama di-commit tepat waktu, permintaan kedua membaca dan memutarnya ulang. Pada batas waktu, kembalikan hasil idempotency_in_progress yang dapat dikenali beserta waktu percobaan ulang alih-alih membuat pesanan. Tugas yang panjang menggunakan status IN_PROGRESS yang di-commit dan endpoint status sehingga koneksi HTTP tidak terus terpakai.

Lanjutan 2: Apa yang terjadi jika layanan mengalami crash setelah commit tetapi sebelum menulis respons HTTP?

Itulah mengapa hasilnya disimpan. Pesanan, outbox event, dan snapshot respons telah di-commit bersama-sama. Percobaan ulang akan mengalami konflik pada unique key, memverifikasi fingerprint, dan mengembalikan status serta body yang disimpan. Sekadar boolean “processed” tidak akan memberi tahu server pesanan mana yang harus dikembalikan, dan pemanggil tidak dapat membedakan keberhasilan dari hasil yang tidak diketahui.

Lanjutan 3: Bagaimana jika idempotency key yang sama membawa jumlah (amount) yang berbeda?

Kembalikan 409 idempotency_key_reused, catat metrik konflik, dan jangan lakukan pembuatan pesanan. Jangan pernah menimpa catatan lama dengan parameter baru atau secara diam-diam mengembalikan pesanan lama. Fingerprint harus mencakup jumlah, mata uang, item, field referensi pengiriman, dan setiap field lain yang mengubah hasil bisnis, ditambah versi API atau kanonikalisasi.

Lanjutan 4: Worker yang berjalan lama berhenti sejenak, worker lain mengambil alih lease-nya yang kedaluwarsa, dan worker lama melanjutkan pekerjaannya. Apa yang mencegah penulisan data basi (stale writes)?

Tingkatkan attempt_token pada setiap pengambilalihan. Perubahan status pesanan, penulisan penyelesaian, dan tugas downstream yang dapat dikontrol membawa token tersebut dan hanya memperbarui jika cocok dengan nilai saat ini. Penulisan worker lama gagal setelah ia melanjutkan pekerjaannya, sehingga ia membaca status baru dan keluar. Jika sistem downstream tidak dapat memverifikasi token, sistem tersebut tetap memerlukan API idempoten yang menggunakan key ID operasi bisnis yang stabil atau proses rekonsiliasi dan kompensasi. Lease saja tidak dapat menarik kembali efek samping eksternal yang telah dikirim.

Lanjutan 5: Mengapa tidak hanya menggunakan batasan unik pada orders.checkout_id?

Batasan tersebut mencegah pesanan kedua dan merupakan perlindungan permanen yang sangat baik. Namun, batasan tersebut tidak menentukan apakah parameter yang berbeda di bawah satu idempotency key mengalami konflik, status apa yang dikembalikan oleh permintaan pertama, bagaimana merespons saat pekerjaan berlangsung secara konkuren, atau apa yang harus dilakukan untuk pemanggil tanpa checkout_id. Catatan idempotensi menyediakan protokol tingkat permintaan dan pemutaran ulang hasil; unique key bisnis melindungi invarian domain. Keduanya beroperasi pada lapisan yang berbeda.

Sumber publik

Pertanyaan terkait