Interview prep
Distributed Lock β Redis Implementation
Implementasi distributed lock dengan Redis di Go: SET NX, TTL, fencing token, Redlock β untuk mencegah double-spending dan double-processing di sistem terdistribusi.
Distributed Lock β Redis
Masalah
Di sistem dengan multiple server instances, request yang sama bisa masuk ke instance berbeda:
Instance 1: Process payment TX-123 β charge $100
Instance 2: Process payment TX-123 β charge $100 (DUPLICATE!)Keduanya gak tahu satu sama lain. Butuh mekanisme lock yang bekerja lintas instance.
Redis SET NX β Basic Implementation
package lock
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var ErrLockNotAcquired = errors.New("lock not acquired")
type RedisLock struct {
client *redis.Client
key string
token string // unique token mencegah accidental release
ttl time.Duration
}
func NewRedisLock(client *redis.Client, key string, ttl time.Duration) *RedisLock {
return &RedisLock{
client: client,
key: fmt.Sprintf("lock:%s", key),
token: generateToken(),
ttl: ttl,
}
}
func (l *RedisLock) Acquire(ctx context.Context) error {
// SET key token NX EX ttl
// NX = only set if Not eXists
// EX = set expiry in seconds
ok, err := l.client.SetNX(ctx, l.key, l.token, l.ttl).Result()
if err != nil {
return fmt.Errorf("acquire lock %s: %w", l.key, err)
}
if !ok {
return ErrLockNotAcquired
}
return nil
}
// Retry dengan backoff
func (l *RedisLock) AcquireWithRetry(ctx context.Context, maxRetries int, backoff time.Duration) error {
for i := 0; i < maxRetries; i++ {
err := l.Acquire(ctx)
if err == nil {
return nil
}
if !errors.Is(err, ErrLockNotAcquired) {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
backoff *= 2 // exponential backoff
}
}
return ErrLockNotAcquired
}
func (l *RedisLock) Release(ctx context.Context) error {
// SAFE RELEASE: hanya delete kalau token match
// Mencegah release lock yang di-acquire instance lain (kasus TTL expired)
script := `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`
result, err := l.client.Eval(ctx, script, []string{l.key}, l.token).Result()
if err != nil {
return fmt.Errorf("release lock %s: %w", l.key, err)
}
if result.(int64) == 0 {
return errors.New("lock token mismatch β lock was taken by another instance")
}
return nil
}
func generateToken() string {
b := make([]byte, 16)
rand.Read(b)
return hex.EncodeToString(b)
}Usage di Payment Handler
func (h *PaymentHandler) ProcessWithLock(ctx context.Context, paymentID string) error {
lock := NewRedisLock(h.redis, paymentID, 30*time.Second)
if err := lock.Acquire(ctx); err != nil {
return fmt.Errorf("process payment %s: %w", paymentID, err)
}
defer lock.Release(ctx)
// Check if already processed (double-check pattern)
processed, err := h.isProcessed(ctx, paymentID)
if err != nil {
return err
}
if processed {
return nil // idempotent
}
// Actually process
return h.doPayment(ctx, paymentID)
}Fencing Token β Mencegah Race Condition
Problem: Lock expired karena processing lambat, instance lain acquire lock baru.
Timeline:
T0: Instance-A acquires lock (TTL 30s)
T32: Instance-A still processing (slow DB query)
T31: Lock expires
T32: Instance-B acquires new lock
T33: Instance-B starts processing SAME payment
T35: Instance-A finishes, releases lock (BUT IT'S INSTANCE-B'S LOCK NOW!)
T36: Instance-A writes result β CORRUPTION!Solusi: Fencing Token
type FencingLock struct {
client *redis.Client
key string
token string
fencingToken int64 // monotonic increasing
}
func (l *FencingLock) Acquire(ctx context.Context) error {
script := `
local token = redis.call("INCR", KEYS[1] .. ":fencing")
local ok = redis.call("SET", KEYS[1], ARGV[1], "NX", "EX", ARGV[2])
if ok then
return token
else
return 0
end
`
result, err := l.client.Eval(ctx, script,
[]string{l.key}, l.token, int(l.ttl.Seconds())).Result()
if err != nil {
return err
}
l.fencingToken = result.(int64)
if l.fencingToken == 0 {
return ErrLockNotAcquired
}
return nil
}
// Storage service checks fencing token before writing
func (s *StorageService) WriteWithFencing(ctx context.Context, data Data, fencingToken int64) error {
return s.db.ExecContext(ctx, `
UPDATE payments
SET status = $1, updated_at = NOW()
WHERE id = $2 AND fencing_token < $3
`, data.Status, data.ID, fencingToken)
// Hanya write kalau fencing_token kita LEBIH BESAR
// Mencegah stale write dari lock holder sebelumnya
}Redlock β Multi-Redis untuk High Availability
Single Redis bisa fail. Redlock algorithm (dari Redis creator) pakai multiple Redis instances.
type RedLock struct {
clients []*redis.Client // minimal 5 instances (odd number)
quorum int // majority: (N/2)+1
}
func (r *RedLock) Acquire(ctx context.Context, key string, ttl time.Duration) (*RedLock, error) {
token := generateToken()
acquired := 0
start := time.Now()
for _, client := range r.clients {
ok, err := client.SetNX(ctx, key, token, ttl).Result()
if err == nil && ok {
acquired++
}
}
elapsed := time.Since(start)
// Valid kalau:
// 1. Acquired di majority (quorum)
// 2. Elapsed time < TTL (kalau terlalu lama, TTL udah hampir expired)
if acquired < r.quorum || elapsed > ttl {
// Release yang udah ke-acquire
for _, client := range r.clients {
r.releaseOne(ctx, client, key, token)
}
return nil, ErrLockNotAcquired
}
return &RedLock{...}, nil
}Trade-off Redlock:
- Pros: Tahan terhadap single-node Redis failure
- Cons: Lebih lambat (N network calls), lebih kompleks
- Alternatif: etcd, ZooKeeper, Consul (built-in distributed consensus)
Interview Talking Points
"Redis vs etcd untuk distributed lock?"
- Redis: lebih cepat, lebih simpel, bagus buat short-lived lock (<30 detik)
- etcd: leader election built-in, lebih strict consistency, bagus buat long-lived lock
- Pilih Redis untuk rate limiting + idempotency, etcd untuk leader election
"Apa kelemahan Redis lock?"
- Single point of failure (kalau Redis down β gak bisa acquire lock)
- Clock drift bisa bikin TTL gak akurat
- Gak ada fencing token by default
"Kapan gak perlu distributed lock?"
- Kalau pake database unique constraint:
INSERT ... ON CONFLICT DO NOTHING - Kalau pake message queue dengan exactly-once delivery
- Kalau sistem single-instance (monolith) β cukup
sync.Mutex
Edit on GitHub
Last updated on