Distributed Locking dengan Redis Redlock di Node.js

Foto oleh Towfiqu barbhuiya on Unsplash
Redlock adalah algoritma penguncian terdistribusi untuk Redis, dijelaskan oleh pembuat Redis Salvatore Sanfilippo, yang mengakuisisi lock di mayoritas (kuorum) node Redis independen sehingga hanya satu proses pada satu waktu memasuki bagian kritis. Di Node.js pustaka node-redlock mengimplementasikannya. Ia biasanya dipakai untuk mencegah banyak instance aplikasi melakukan pekerjaan sama secara bersamaan.
Ini diperdebatkan. Martin Kleppmann berargumen Redlock tidak aman untuk correctness ketat saat ada jeda proses dan clock skew, dan Anda butuh fencing token; Sanfilippo membalas membelanya. Untuk lock efisiensi (menghindari kerja ganda) Redlock cukup, tetapi untuk correctness di mana lock tak boleh dilanggar, tambahkan fencing token atau pakai sistem yang menyediakannya.
Setiap lock Redlock punya TTL, sehingga jika pemegangnya crash lock kedaluwarsa otomatis dan sistem tidak deadlock. Jika pekerjaan Anda mungkin melebihi TTL, perpanjang (renew) lock secara berkala; helper using() milik node-redlock memperpanjang lock otomatis selama callback Anda berjalan. Jaga TTL lebih panjang dari perkiraan kerja tetapi cukup pendek untuk pulih cepat.
Jika Anda sudah menjalankan satu Postgres, advisory lock sesi atau transaksi, atau SELECT ... FOR UPDATE SKIP LOCKED, memberi mutual exclusion kuat dari satu sumber kebenaran tanpa infrastruktur tambahan. Redlock cocok ketika Redis adalah lapisan koordinasi bersama Anda atau Anda ingin lock independen dari database. Untuk aplikasi satu-database, lock Postgres biasanya lebih sederhana dan aman.
Ketika beberapa instance aplikasi atau worker bisa melakukan aksi sama secara bersamaan, seperti menerbitkan satu nomor invoice, memproses job tepat sekali, atau mencegah alokasi stok ganda. Jika satu worker, sebuah unique constraint database, atau idempotency key sudah mencegah duplikasi, Anda mungkin tidak butuh lock terdistribusi sama sekali.

Foto oleh Towfiqu barbhuiya on Unsplash
Ringkasan Utama
Distributed locking mencegah dua instance Node.js menyentuh resource yang sama sekaligus. Redis Redlock mengambil lock pada mayoritas master Redis independen dengan TTL dan perpanjangan otomatis. Tetapi lock saja bukan jaminan correctness: Martin Kleppmann menunjukkan fencing token wajib ada, dan advisory lock Postgres sering lebih cocok untuk pekerjaan di satu basis data.
Pertama kali saya menjalankan dua salinan service Node.js yang sama di belakang load balancer, sebuah job terjadwal menyala dua kali dalam detik yang sama. Kedua replika membaca invoice tertunda yang sama, keduanya memposting ke ledger, dan seorang pelanggan tertagih dua kali. Tidak ada yang salah pada kode saya di satu mesin. Bug itu hanya muncul karena kini ada dua mesin, dan tidak ada yang tahu satu sama lain sedang aktif.
Itulah masalah mutual exclusion multi-instance. Sebuah mutex atau flag di dalam proses melindungi satu proses saja; ia tidak berarti apa-apa lintas armada. Agar hanya satu worker bertindak pada satu waktu, Anda butuh lock yang hidup di luar setiap instance, di infrastruktur bersama yang mereka semua percaya. Redis adalah alat yang paling banyak dipakai tim, dan Redlock adalah algoritma yang Redis sendiri usulkan untuk melakukannya dengan aman.
Sebelum versi terdistribusi, ada baiknya membuat satu node Redis benar dulu, karena Redlock hanyalah pola ini yang diulang di beberapa node. Anda mengambil lock dengan satu perintah atomik: set sebuah key hanya jika belum ada, lampirkan nilai acak unik, dan beri masa kedaluwarsa. Nilai acak inilah bagian yang sering dilewati orang, dan justru bagian yang menjaga keamanan Anda.
Mengapa nilai acak penting: tanpanya, sebuah client yang pekerjaannya melewati masa kedaluwarsa bisa menghapus lock yang sudah sah diambil client kedua. Jadi pelepasan tidak pernah berupa delete biasa. Anda menjalankan compare-and-delete yang menghapus key hanya jika nilai tersimpannya masih sama dengan yang Anda tulis. Dokumentasi resmi menyatakannya sebagai skrip Lua, dan Redis 8.4 menambahkan perintah native untuk pemeriksaan yang sama.
SET resource_name my_random_value NX PX 30000
# Safe release (Redis < 8.4): compare value first, then delete
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
endSatu node adalah titik kegagalan tunggal, dan setup primary-replica tidak memperbaikinya karena replikasi Redis bersifat asinkron: sebuah primary bisa mengakui sebuah lock, crash sebelum tulisan sampai ke replika, lalu replika yang dipromosikan memberikan lock yang sama ke orang lain. Redlock menghindari ini dengan menjalankan beberapa master yang sepenuhnya independen tanpa replikasi di antaranya. Setup kanoniknya adalah lima node, dan client menelusurinya secara berurutan:
Aturan mayoritas inilah yang memberi toleransi kegagalan: selama lebih dari separuh master hidup, lock tetap berfungsi, dan client yang butuh waktu lebih lama dari TTL untuk mengumpulkan kuorum harus menganggap lock tidak valid. Masa berlaku efektif selalu TTL dikurangi waktu akuisisi dikurangi margin kecil untuk pergeseran jam, bukan TTL penuh yang Anda minta.
Di Node.js, pustaka node-redlock milik mike-marcacci mengimplementasikan semua ini. Anda memberinya sebuah array client, satu per master independen, plus opsi penyetelan: driftFactor untuk margin pergeseran jam, retryCount dan retryDelay dengan retryJitter untuk mendesinkronisasi client yang bersaing, dan automaticExtensionThreshold, yaitu milidetik masa berlaku tersisa sebelum pustaka memperbarui lock sendiri.
import Client from "ioredis";
import Redlock from "redlock";
// Independent masters — no replication between them
const redlock = new Redlock(
[new Client(6379), new Client(6380), new Client(6381)],
{
driftFactor: 0.01, // multiplied by ttl to derive drift
retryCount: 10,
retryDelay: 200, // ms between attempts
retryJitter: 200, // random ms to desynchronise clients
automaticExtensionThreshold: 500, // ms left before auto-extend
}
);
// using(): auto-extends while the routine runs, releases at the end
await redlock.using(["invoice:4821"], 5000, async (signal) => {
const invoice = await loadInvoice("4821");
// If an extension failed, stop before doing damage
if (signal.aborted) throw signal.error;
await postToLedger(invoice);
});
// Manual acquire / extend / release when you need the handle
let lock = await redlock.acquire(["invoice:4821"], 5000);
try {
lock = await lock.extend(5000); // returns a NEW Lock instance
} finally {
await lock.release();
}Titik masuk paling aman adalah using(): Anda memberikan resource, sebuah durasi, dan sebuah routine, lalu pustaka memegang lock persis selama fungsi Anda berjalan, memperpanjangnya di latar belakang sebelum kedaluwarsa dan melepasnya saat Anda kembali. Ia memberi routine Anda sebuah signal, dan Anda wajib memeriksa signal.aborted sebelum langkah apa pun yang tak bisa dibatalkan, karena perpanjangan yang gagal berarti Anda mungkin tak lagi memegang lock meski kode Anda masih berjalan.
Atur TTL sepanjang durasi terlama yang Anda toleransi bila pemegang yang crash memblokir resource, bukan sepanjang durasi job. TTL pendek dengan perpanjangan otomatis jauh lebih aman daripada TTL panjang, karena jika proses mati, lock cepat terbebas, sementara perpanjangan menjaga job panjang yang sehat tetap terlindungi. Jangan pernah berasumsi lock masih milik Anda hanya karena proses Anda masih hidup.
Pada 2016 Martin Kleppmann menerbitkan kritik yang wajib dibaca setiap pengguna Redlock. Inti argumennya adalah sebuah pembedaan: Anda mengunci demi efisiensi, di mana jalan ganda yang jarang hanya membuang usaha, atau demi correctness, di mana jalan ganda merusak data. Redlock, menurutnya, cocok untuk yang pertama dan tidak aman untuk yang kedua, dan alasannya adalah timing. Jeda garbage collection stop-the-world, penundaan jaringan yang panjang, atau lompatan jam dinding bisa membuat client yakin masih memegang lock yang TTL-nya sudah kedaluwarsa.
Solusinya adalah fencing token: setiap akuisisi mengembalikan angka yang hanya pernah membesar, client mengirimnya bersama setiap tulisan, dan resource terlindungi menolak tulisan mana pun yang membawa token lebih rendah dari yang tertinggi pernah dilihatnya. Itu mengubah tulisan terlambat dari pemegang basi menjadi penolakan tak berbahaya. Redlock tidak menghasilkan token semacam itu secara default, dan nilai acak uniknya tidak monotonik, jadi jika butuh correctness Anda harus menambahkan fencing sendiri. Dokumentasi Redis kini merekomendasikan tepat hal ini.
Gunakan Redlock hanya ketika resource yang Anda jaga hidup di luar basis data tunggal mana pun. Jika pekerjaan sudah terjadi di dalam Postgres, Postgres memberi Anda koordinasi gratis tanpa infrastruktur tambahan. Advisory lock adalah lock yang didefinisikan aplikasi, dilacak basis data tetapi tidak melekat pada baris mana pun; varian bercakupan transaksi otomatis terlepas saat transaksi berakhir, sehingga worker yang crash tak pernah meninggalkan lock tersangkut. Untuk menarik job dari tabel antrean, SKIP LOCKED membuat tiap worker mengklaim baris berbeda tanpa saling memblokir.
| Aspek | Redis Redlock | Advisory lock Postgres / SKIP LOCKED | SETNX satu node |
|---|---|---|---|
| Infrastruktur | Lima master Redis independen | Basis data yang sudah Anda jalankan | Satu node Redis |
| Toleransi kegagalan | Bertahan bila minoritas node gagal | Terikat pada ketersediaan basis data | Tidak ada, titik kegagalan tunggal |
| Correctness tanpa fencing | Tak terjamin saat jeda atau jam bergeser | Kuat, lock hidup bersama datanya | Lemah, hanya layak untuk efisiensi |
| Paling cocok | Resource tersebar lintas service | Pekerjaan sudah di dalam satu Postgres | Job satu instance non-kritis |
Advisory lock bercakupan transaksi dan SKIP LOCKED menutup sebagian besar koordinasi yang benar-benar saya butuhkan di backend Node.js, dan keduanya tak pernah menghadirkan datastore kedua yang mode kegagalannya harus saya pikirkan terpisah. Saya baru naik ke Redlock ketika hal yang dikunci benar-benar merentang sistem yang tak berbagi basis data.
Aturan saya sederhana. Jika lock demi efisiensi dan semuanya menyentuh satu Postgres, saya pakai advisory lock atau SKIP LOCKED. Jika resource merentang service, saya pakai node-redlock dengan TTL pendek dan using() untuk perpanjangan otomatis. Dan begitu correctness dipertaruhkan, saya berhenti memercayai lock itu sendiri dan menambahkan fencing token, persis seperti yang Kleppmann dan dokumentasi Redis sama-sama tegaskan. Sebuah lock mengurangi peluang tabrakan; sebuah fencing token itulah yang benar-benar membuat tulisan aman.