Skip to content

Split View: 분산 락(Distributed Lock) 패턴 비교: Redis Redlock vs ZooKeeper vs etcd — 정합성과 가용성의 트레이드오프

✨ Learn with Quiz
|

분산 락(Distributed Lock) 패턴 비교: Redis Redlock vs ZooKeeper vs etcd — 정합성과 가용성의 트레이드오프

분산 락 패턴 비교

들어가며

분산 시스템에서 여러 프로세스가 동일한 자원에 동시에 접근하면 데이터 정합성이 깨진다. 재고 차감, 결제 처리, 파일 쓰기 등 **상호 배제(mutual exclusion)**가 필요한 작업은 반드시 하나의 프로세스만 수행해야 한다. 단일 서버라면 뮤텍스나 세마포어로 해결할 수 있지만, 여러 서버에 분산된 프로세스 사이에서는 **분산 락(Distributed Lock)**이 필요하다.

분산 락의 용도는 두 가지로 구분된다:

  • 효율성(Efficiency): 같은 작업을 중복 수행하지 않기 위한 목적. 락이 간헐적으로 실패해도 비용만 낭비될 뿐 데이터가 손상되지 않는다.
  • 정합성(Correctness): 동시 접근으로 인한 데이터 손상을 방지하기 위한 목적. 락이 실패하면 데이터가 오염되므로 훨씬 엄격한 보장이 필요하다.

효율성 목적이라면 Redis 단일 인스턴스 락으로도 충분하다. 그러나 정합성 목적이라면 Fencing Token이 반드시 동반되어야 하며, 이 차이가 Redis Redlock 논쟁의 핵심이다.

Redis 단일 인스턴스 락

가장 간단한 분산 락은 Redis의 SET NX PX 명령을 사용하는 방식이다. NX(Not eXists) 옵션으로 키가 없을 때만 설정하고, PX로 밀리초 단위 만료 시간을 지정한다.

기본 구현

import redis
import uuid
import time


class RedisSimpleLock:
    """Redis 단일 인스턴스 기반 분산 락"""

    def __init__(self, client: redis.Redis, resource: str, ttl_ms: int = 10000):
        self.client = client
        self.resource = resource
        self.ttl_ms = ttl_ms
        self.lock_value = str(uuid.uuid4())  # 소유권 식별자

    def acquire(self, retry_count: int = 3, retry_delay_ms: int = 200) -> bool:
        """락 획득 시도. 실패 시 재시도."""
        for attempt in range(retry_count):
            result = self.client.set(
                self.resource,
                self.lock_value,
                nx=True,
                px=self.ttl_ms
            )
            if result:
                return True
            time.sleep(retry_delay_ms / 1000.0)
        return False

    def release(self) -> bool:
        """소유권 검증 후 락 해제 (Lua script 사용)"""
        lua_script = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
        """
        result = self.client.eval(lua_script, 1, self.resource, self.lock_value)
        return result == 1


# 사용 예시
client = redis.Redis(host="localhost", port=6379)
lock = RedisSimpleLock(client, "order:12345:lock", ttl_ms=5000)

if lock.acquire():
    try:
        # 임계 영역 작업 수행
        print("락 획득 성공, 작업 수행 중...")
    finally:
        lock.release()
else:
    print("락 획득 실패")

Lua Script 기반 안전한 락 해제

락 해제 시 반드시 소유권을 검증해야 한다. GET과 DEL을 별도 명령으로 실행하면 그 사이에 다른 클라이언트가 락을 획득할 수 있다. Lua script는 Redis에서 원자적으로 실행되므로 이 문제를 방지한다.

-- safe_unlock.lua
-- 소유권 검증 후 안전하게 락 해제
-- KEYS[1]: 락 키
-- ARGV[1]: 소유자 식별 값
-- 반환: 1 (성공), 0 (소유권 불일치)

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

단일 인스턴스 방식의 한계는 명확하다. Redis 마스터가 장애를 일으키면 락 정보가 유실된다. 레플리카로 페일오버되더라도 비동기 복제 특성상 락 키가 복제되기 전에 마스터가 다운되면 두 클라이언트가 동시에 같은 락을 획득하는 상황이 발생한다.

Redlock 알고리즘

Redis 창시자 Salvatore Sanfilippo(antirez)는 단일 인스턴스의 한계를 극복하기 위해 Redlock 알고리즘을 제안했다. 핵심 아이디어는 N개(일반적으로 5개)의 독립적인 Redis 마스터 노드에서 과반수 합의를 얻는 것이다.

알고리즘 3단계

  1. 획득 단계: 현재 시각을 기록한 뒤, 모든 N개 노드에 순차적으로 SET NX PX 명령을 보낸다. 각 노드에 대한 타임아웃은 전체 TTL보다 훨씬 짧게 설정한다.
  2. 유효성 검증: 과반수(N/2 + 1) 이상의 노드에서 락을 획득했고, 전체 소요 시간이 TTL보다 짧으면 락 획득 성공이다. 유효 잔여 시간은 TTL - 소요시간이다.
  3. 해제 단계: 모든 N개 노드에 무조건 해제 명령을 보낸다. 획득에 실패한 노드에도 해제를 보내어 부분적으로 설정된 키를 정리한다.

Redlock Python 구현

import redis
import uuid
import time
from typing import List, Optional, Tuple


class Redlock:
    """Redlock 분산 락 알고리즘 구현"""

    CLOCK_DRIFT_FACTOR = 0.01  # 클럭 드리프트 보정 계수
    RETRY_DELAY_MS = 200
    RETRY_COUNT = 3

    def __init__(self, nodes: List[dict], ttl_ms: int = 10000):
        self.nodes = [
            redis.Redis(host=n["host"], port=n["port"], socket_timeout=0.1)
            for n in nodes
        ]
        self.quorum = len(self.nodes) // 2 + 1
        self.ttl_ms = ttl_ms

    def _acquire_single(self, client: redis.Redis, resource: str,
                        value: str) -> bool:
        try:
            return bool(client.set(resource, value, nx=True, px=self.ttl_ms))
        except redis.RedisError:
            return False

    def _release_single(self, client: redis.Redis, resource: str,
                        value: str) -> None:
        lua = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
        """
        try:
            client.eval(lua, 1, resource, value)
        except redis.RedisError:
            pass

    def acquire(self, resource: str) -> Optional[Tuple[str, float]]:
        """락 획득. 성공 시 (lock_value, validity_time) 반환"""
        for _ in range(self.RETRY_COUNT):
            lock_value = str(uuid.uuid4())
            start_time = time.monotonic()
            acquired_count = 0

            # Step 1: 모든 노드에 락 획득 시도
            for client in self.nodes:
                if self._acquire_single(client, resource, lock_value):
                    acquired_count += 1

            # Step 2: 유효성 검증
            elapsed_ms = (time.monotonic() - start_time) * 1000
            drift = self.ttl_ms * self.CLOCK_DRIFT_FACTOR + 2
            validity_time = self.ttl_ms - elapsed_ms - drift

            if acquired_count >= self.quorum and validity_time > 0:
                return (lock_value, validity_time)

            # 실패 시 모든 노드에서 해제
            for client in self.nodes:
                self._release_single(client, resource, lock_value)

            time.sleep(self.RETRY_DELAY_MS / 1000.0)

        return None

    def release(self, resource: str, lock_value: str) -> None:
        """모든 노드에서 락 해제"""
        for client in self.nodes:
            self._release_single(client, resource, lock_value)


# 사용 예시
nodes = [
    {"host": "redis1.example.com", "port": 6379},
    {"host": "redis2.example.com", "port": 6379},
    {"host": "redis3.example.com", "port": 6379},
    {"host": "redis4.example.com", "port": 6379},
    {"host": "redis5.example.com", "port": 6379},
]

redlock = Redlock(nodes, ttl_ms=10000)
result = redlock.acquire("payment:order:99999")

if result:
    lock_value, validity_ms = result
    try:
        print(f"락 획득 성공. 유효 시간: {validity_ms:.0f}ms")
        # 임계 영역 작업 수행
    finally:
        redlock.release("payment:order:99999", lock_value)

이 구현에서 time.monotonic()을 사용하는 것이 중요하다. 시스템 시각(time.time())은 NTP 보정으로 뒤로 갈 수 있지만, 단조 시계는 항상 전진한다. 또한 CLOCK_DRIFT_FACTOR로 노드 간 클럭 드리프트를 보정한다.

Redlock 비판: Kleppmann vs Antirez 논쟁

2016년 Martin Kleppmann은 "How to do distributed locking"이라는 글에서 Redlock 알고리즘의 근본적인 문제를 지적했다. 이 논쟁은 분산 시스템 커뮤니티에서 가장 유명한 기술 토론 중 하나가 되었다.

Kleppmann의 핵심 비판

1. 타이밍 가정의 위험성

Redlock은 프로세스 중단 없이 빠르게 완료된다는 타이밍 가정에 의존한다. 하지만 현실에서는 다음 상황이 발생한다:

  • 클라이언트 A가 5개 노드 중 3개에서 락 획득 성공
  • GC(Garbage Collection) pause가 발생하여 수십 초 동안 프로세스가 멈춤
  • 그 사이 TTL이 만료되어 락이 해제됨
  • 클라이언트 B가 같은 락을 획득하여 작업 수행
  • 클라이언트 A가 GC에서 복귀하여 (자신이 여전히 락을 보유한 줄 알고) 작업 수행
  • 두 클라이언트가 동시에 임계 영역 작업을 수행하면서 데이터 손상 발생

2. Fencing Token 부재

Kleppmann은 안전한 분산 락에는 반드시 Fencing Token이 필요하다고 주장했다. Fencing Token은 단조 증가하는 번호로, 자원(예: 데이터베이스)에 접근할 때 함께 전달한다. 자원 측에서 이전보다 낮은 토큰의 요청을 거부하면, 만료된 락 소유자의 지연된 쓰기를 차단할 수 있다. Redlock은 이러한 Fencing Token을 생성하는 메커니즘이 없다.

3. 네트워크 지연과 클럭 점프

NTP 동기화 실패나 VM 마이그레이션으로 시스템 클럭이 갑자기 점프하면, TTL 계산이 무효화된다. Redlock은 노드 간 클럭이 대체로 동기화되어 있다고 가정하지만, 이는 비동기 분산 시스템에서 보장할 수 없다.

Antirez의 반론

Salvatore Sanfilippo는 "Is Redlock safe?"라는 글에서 다음과 같이 반론했다:

  • GC pause 시나리오는 Redlock에 국한된 문제가 아니라 모든 분산 락 시스템에 적용됨
  • 합리적인 운영 환경에서는 클럭 드리프트가 제한적이며, CLOCK_DRIFT_FACTOR로 충분히 보정 가능
  • Fencing Token은 자원 측의 지원이 필요하므로, 자원이 이를 지원한다면 그 자체로 동시성 제어가 가능할 수 있음

비교표: Redlock 찬반 논점 정리

논점Kleppmann (비판)Antirez (옹호)
타이밍 가정비동기 시스템에서 타이밍 가정은 위험합리적 운영 환경에서 충분히 유효
GC pause락 유효 기간 중 프로세스 중단 가능모든 분산 시스템에 공통된 문제
Fencing TokenRedlock이 생성 불가, 반드시 필요자원 측 지원이 전제되면 락 자체가 불필요할 수 있음
클럭 동기화NTP 장애 시 클럭 점프 위험드리프트 보정 계수로 대응 가능
권장 용도효율성 목적에만 사용, 정합성에는 부적합대부분의 실전 시나리오에서 충분히 안전

필자의 견해로는, 정합성이 중요한 경우 Redlock 단독 사용은 권장하지 않는다. Fencing Token을 지원하는 스토리지와 함께 사용하거나, ZooKeeper나 etcd처럼 합의 기반 시스템을 선택하는 것이 더 안전하다.

ZooKeeper 분산 락

Apache ZooKeeper는 분산 시스템의 코디네이션을 위해 설계된 전용 서비스다. Zab(ZooKeeper Atomic Broadcast) 프로토콜을 통해 **선형화 가능성(Linearizability)**을 보장하며, 분산 락 구현에 필요한 프리미티브를 기본 제공한다.

임시 순차 노드 패턴

ZooKeeper의 분산 락은 **임시 순차 노드(Ephemeral Sequential Znode)**를 활용한다:

  1. 클라이언트가 /locks/resource-name/lock- 경로에 임시 순차 노드를 생성한다.
  2. ZooKeeper가 자동으로 순차 번호를 부여한다 (예: lock-0000000001).
  3. 자신이 생성한 노드가 가장 작은 번호이면 락을 획득한다.
  4. 그렇지 않으면 바로 앞 번호의 노드에 Watch를 설정하고 대기한다.
  5. 클라이언트 세션이 종료되면 임시 노드가 자동 삭제되어 락이 해제된다.

바로 앞 노드에만 Watch를 설정하는 것이 핵심이다. 만약 모든 대기자가 가장 작은 노드를 Watch하면, 락 해제 시 모든 대기자에게 알림이 전송되는 **Herd Effect(우르르 몰림 효과)**가 발생한다.

ZooKeeper 분산 락 Python 구현

from kazoo.client import KazooClient
from kazoo.recipe.lock import Lock
import logging

logging.basicConfig(level=logging.INFO)


class ZooKeeperDistributedLock:
    """ZooKeeper 기반 분산 락 (Kazoo 라이브러리 활용)"""

    def __init__(self, hosts: str, lock_path: str):
        self.zk = KazooClient(hosts=hosts)
        self.zk.start()
        self.lock = Lock(self.zk, lock_path)
        self.lock_path = lock_path

    def acquire(self, timeout: float = 30.0) -> bool:
        """락 획득. timeout 초 내에 획득하지 못하면 False 반환"""
        try:
            return self.lock.acquire(timeout=timeout)
        except Exception as e:
            logging.error(f"락 획득 실패: {e}")
            return False

    def release(self) -> None:
        """락 해제"""
        try:
            self.lock.release()
        except Exception as e:
            logging.error(f"락 해제 실패: {e}")

    def get_fencing_token(self) -> int:
        """zxid를 Fencing Token으로 활용"""
        data, stat = self.zk.get(self.lock_path)
        return stat.czxid  # 생성 시 트랜잭션 ID (단조 증가)

    def close(self) -> None:
        self.zk.stop()
        self.zk.close()


# 사용 예시
zk_lock = ZooKeeperDistributedLock(
    hosts="zk1:2181,zk2:2181,zk3:2181",
    lock_path="/locks/payment/order-99999"
)

if zk_lock.acquire(timeout=10.0):
    try:
        fencing_token = zk_lock.get_fencing_token()
        logging.info(f"락 획득, fencing token: {fencing_token}")
        # fencing_token과 함께 스토리지에 쓰기
        # storage.write(data, fencing_token=fencing_token)
    finally:
        zk_lock.release()

zk_lock.close()

ZooKeeper의 강점은 **zxid(ZooKeeper Transaction ID)**를 Fencing Token으로 활용할 수 있다는 점이다. zxid는 전역적으로 단조 증가하므로, 스토리지 측에서 이전 zxid의 쓰기를 거부하면 만료된 락 소유자의 지연된 쓰기를 안전하게 차단할 수 있다.

Read-Write Lock 레시피

ZooKeeper는 배타적 락뿐 아니라 Read-Write Lock도 지원한다. 읽기 락 노드는 read- 접두사, 쓰기 락 노드는 write- 접두사를 사용한다. 읽기 락은 앞에 쓰기 노드가 없으면 획득 가능하고, 쓰기 락은 자신이 가장 작은 번호일 때만 획득 가능하다. 이를 통해 읽기 동시성을 높이면서도 쓰기 상호 배제를 보장한다.

etcd 분산 락

etcd는 Kubernetes의 상태 저장소로 널리 알려진 분산 키-값 스토어다. Raft 합의 알고리즘을 기반으로 **강한 일관성(Strong Consistency)**을 보장하며, 분산 락 구현에 적합한 Lease와 Revision 메커니즘을 제공한다.

Lease 기반 TTL 관리

etcd의 Lease는 TTL이 설정된 임시 토큰이다. 키-값 쌍을 Lease에 연결하면, Lease가 만료될 때 해당 키도 함께 삭제된다. 클라이언트는 주기적으로 KeepAlive를 호출하여 Lease를 갱신하고, 클라이언트 장애 시 갱신이 중단되어 자동으로 락이 해제된다.

Revision 번호로 Fencing Token 자동 생성

etcd의 모든 키 변경에는 전역적으로 단조 증가하는 Revision 번호가 부여된다. 이 Revision을 Fencing Token으로 직접 활용할 수 있다는 것이 etcd 분산 락의 큰 장점이다. 별도의 토큰 생성 메커니즘이 필요하지 않다.

etcd 분산 락 Python 구현

import etcd3
import threading
import logging
from typing import Optional, Tuple

logging.basicConfig(level=logging.INFO)


class EtcdDistributedLock:
    """etcd Lease 기반 분산 락"""

    def __init__(self, host: str = "localhost", port: int = 2379,
                 ttl: int = 10):
        self.client = etcd3.client(host=host, port=port)
        self.ttl = ttl
        self.lease: Optional[etcd3.Lease] = None
        self._keepalive_thread: Optional[threading.Thread] = None
        self._stop_keepalive = threading.Event()

    def acquire(self, lock_key: str,
                timeout: float = 30.0) -> Optional[Tuple[int, int]]:
        """
        락 획득. 성공 시 (revision, lease_id) 반환.
        revision을 Fencing Token으로 사용 가능.
        """
        self.lease = self.client.lease(self.ttl)
        self.lock_key = lock_key

        # 트랜잭션으로 원자적 락 획득
        # 키가 존재하지 않을 때만 생성 (Compare-And-Swap)
        success, responses = self.client.transaction(
            compare=[
                self.client.transactions.create(lock_key) == 0
            ],
            success=[
                self.client.transactions.put(
                    lock_key, "locked", lease=self.lease
                )
            ],
            failure=[]
        )

        if success:
            # Revision을 Fencing Token으로 사용
            revision = responses[0].header.revision
            self._start_keepalive()
            logging.info(
                f"락 획득 성공. revision(fencing token): {revision}"
            )
            return (revision, self.lease.id)

        logging.warning("락 획득 실패: 이미 다른 클라이언트가 보유 중")
        self.lease.revoke()
        return None

    def _start_keepalive(self) -> None:
        """Lease 자동 갱신 스레드 시작"""
        self._stop_keepalive.clear()

        def keepalive_loop():
            while not self._stop_keepalive.is_set():
                try:
                    self.lease.refresh()
                except Exception as e:
                    logging.error(f"Lease 갱신 실패: {e}")
                    break
                self._stop_keepalive.wait(timeout=self.ttl / 3.0)

        self._keepalive_thread = threading.Thread(
            target=keepalive_loop, daemon=True
        )
        self._keepalive_thread.start()

    def release(self) -> None:
        """락 해제"""
        self._stop_keepalive.set()
        if self.lease:
            try:
                self.lease.revoke()
                logging.info("락 해제 완료")
            except Exception as e:
                logging.error(f"락 해제 실패: {e}")

    def close(self) -> None:
        self.release()
        self.client.close()


# 사용 예시
lock = EtcdDistributedLock(host="etcd1.example.com", ttl=15)
result = lock.acquire("locks/payment/order-99999")

if result:
    fencing_token, lease_id = result
    try:
        # fencing_token(revision)과 함께 스토리지에 쓰기
        logging.info(f"작업 수행 중, fencing token: {fencing_token}")
    finally:
        lock.release()

Jepsen 테스트 결과와 주의사항

Jepsen의 etcd 3.4.3 테스트에서는 etcd의 락이 특정 네트워크 파티션 시나리오에서 안전하지 않을 수 있음이 확인되었다. 특히 리더 변경 중 Lease 갱신이 지연되면, 클라이언트가 여전히 락을 보유한 줄 알지만 실제로는 Lease가 만료되어 다른 클라이언트가 락을 획득할 수 있다. 따라서 etcd 락도 Fencing Token과 함께 사용하는 것이 필수적이다.

3종 비교 분석

핵심 비교표

항목Redis RedlockZooKeeperetcd
합의 알고리즘없음 (독립 노드 과반수)Zab (Atomic Broadcast)Raft
일관성 모델최종 일관성 기반 근사치선형화 가능 (Linearizable)선형화 가능 (Linearizable)
Fencing Token미지원zxid 활용 가능Revision 활용 가능
장애 허용N/2 노드 장애까지N/2 노드 장애까지N/2 노드 장애까지
락 해제 메커니즘TTL 만료세션 만료 + 임시 노드 삭제Lease 만료
성능 (획득 지연)매우 빠름 (1~5ms)보통 (5~20ms)보통 (5~15ms)
초당 처리량높음 (10K+ ops/s)보통 (1~5K ops/s)보통 (2~8K ops/s)
운영 복잡도낮음높음 (별도 앙상블 운영)보통 (K8s 환경이면 이미 존재)
Watch/알림Pub/Sub (비보장)Watch (순서 보장)Watch (Revision 기반)
클라이언트 생태계매우 풍부풍부풍부 (특히 Go 생태계)

Use Case별 선택 가이드

Redis Redlock을 선택하는 경우:

  • 효율성 목적의 중복 작업 방지 (캐시 워밍, 배치 작업 등)
  • 밀리초 단위의 빠른 락 획득이 필요한 경우
  • Redis를 이미 사용 중이어서 추가 인프라를 도입하고 싶지 않은 경우
  • 간헐적인 이중 실행이 허용되는 시나리오

ZooKeeper를 선택하는 경우:

  • 정합성이 가장 중요하며 Fencing Token이 필수인 경우
  • 리더 선출, 설정 관리 등 다양한 코디네이션이 필요한 경우
  • Hadoop, Kafka 등 ZooKeeper 의존 시스템을 이미 운영 중인 경우
  • Read-Write Lock 등 복잡한 락 패턴이 필요한 경우

etcd를 선택하는 경우:

  • Kubernetes 환경에서 이미 etcd를 운영 중인 경우
  • Fencing Token(Revision)을 간편하게 활용하고 싶은 경우
  • gRPC 기반 API와 Go 생태계를 선호하는 경우
  • 비교적 적은 운영 부담으로 강한 일관성을 원하는 경우

비용-복잡도 매트릭스

기준Redis RedlockZooKeeperetcd
인프라 비용낮음 (Redis 재활용)높음 (전용 클러스터)보통 (K8s에 포함 가능)
학습 곡선낮음높음보통
정합성 보장약함강함강함
디버깅 용이성높음보통보통
커뮤니티 지원매우 활발성숙성장 중

실패 사례와 복구 절차

사례 1: TTL 만료 중 GC Pause로 이중 락 획득

시나리오:

시간 ------>

클라이언트 A: [락 획득] --- [GC pause 시작] -------------------- [GC 복귀, 쓰기 시도]
                                              TTL 만료
클라이언트 B:                          [락 획득] --- [쓰기 완료] --- [쓰기 완료]

결과: A와 B 모두 쓰기 수행 -> 데이터 손상

이 시나리오는 Redis Redlock뿐 아니라 모든 TTL 기반 락에서 발생할 수 있다. 방어 패턴은 Fencing Token을 활용하는 것이다:

class FencingAwareStorage:
    """Fencing Token을 검증하는 스토리지 래퍼"""

    def __init__(self):
        self.last_token = 0
        self._lock = threading.Lock()

    def write(self, data: dict, fencing_token: int) -> bool:
        """fencing_token이 이전 값보다 클 때만 쓰기 허용"""
        with self._lock:
            if fencing_token <= self.last_token:
                logging.warning(
                    f"거부: token {fencing_token} <= "
                    f"last {self.last_token}"
                )
                return False
            self.last_token = fencing_token
            # 실제 쓰기 수행
            self._do_write(data)
            logging.info(
                f"쓰기 성공: token {fencing_token}"
            )
            return True

    def _do_write(self, data: dict) -> None:
        # 실제 스토리지 쓰기 로직
        pass

사례 2: ZooKeeper 세션 만료로 락 소실

시나리오:

네트워크 파티션으로 ZooKeeper 클라이언트와 앙상블 간 연결이 끊기면, 세션 타임아웃 후 임시 노드가 삭제되어 락이 해제된다. 이때 클라이언트는 여전히 작업을 수행 중일 수 있다.

방어 패턴:

from kazoo.client import KazooState


def connection_listener(state):
    """ZooKeeper 연결 상태 모니터링"""
    if state == KazooState.SUSPENDED:
        # 연결 일시 중단: 진행 중인 작업 일시 정지
        logging.warning("ZK 연결 중단 - 작업 일시 정지")
        pause_current_operations()
    elif state == KazooState.LOST:
        # 세션 만료: 락 소실 확정, 작업 즉시 중단
        logging.error("ZK 세션 만료 - 락 소실, 작업 중단")
        abort_current_operations()
    elif state == KazooState.CONNECTED:
        # 재연결 성공: 락 재획득 시도
        logging.info("ZK 재연결 - 락 재획득 시도")
        reacquire_lock()


zk = KazooClient(hosts="zk1:2181,zk2:2181,zk3:2181")
zk.add_listener(connection_listener)
zk.start()

복구 절차 체크리스트

  1. 즉시 대응: 락 소실 감지 시 현재 작업 즉시 중단
  2. 상태 검증: 부분 완료된 작업의 데이터 정합성 확인
  3. 멱등성 설계: 재시도 시 동일 결과를 보장하도록 작업을 멱등하게 설계
  4. 보상 트랜잭션: 부분 완료 상태를 원래대로 되돌리는 보상 로직 실행
  5. 알림 발송: 운영팀에 이중 실행 가능성 알림

운영 시 주의사항

락 그래뉼러리티와 범위 설계

락의 범위는 가능한 한 좁게 설계해야 한다. 넓은 범위의 락은 경합을 증가시키고 처리량을 떨어뜨린다.

나쁜 예:  /locks/orders           (모든 주문에 대한 단일 락)
보통:     /locks/orders/user-123  (사용자 단위 락)
좋은 예:  /locks/orders/99999     (개별 주문 단위 락)

데드락 탐지와 타임아웃 전략

분산 환경에서는 두 프로세스가 서로 상대방의 락을 기다리는 데드락이 발생할 수 있다. 이를 방지하기 위한 전략은 다음과 같다:

  • 고정 순서 획득: 여러 자원의 락이 필요한 경우, 항상 동일한 순서로 획득
  • 타임아웃 설정: 모든 락 획득에 타임아웃을 설정하여 무한 대기 방지
  • 락 계층화: 상위 자원부터 하위 자원 순서로 락 획득

모니터링 메트릭

분산 락 운영 시 반드시 수집해야 하는 메트릭은 다음과 같다:

메트릭설명위험 임계값
lock_acquisition_time_p99락 획득 지연 99퍼센타일TTL의 50% 초과 시
lock_contention_rate락 경합률 (실패/전체)30% 초과 시
lock_hold_duration_p99락 보유 시간 99퍼센타일TTL의 80% 초과 시
lock_timeout_rate타임아웃 발생률5% 초과 시
fencing_token_reject_rateFencing Token 거부율0보다 큰 경우 즉시 조사

fencing_token_reject_rate가 0보다 크다면 이중 실행이 발생했다는 의미이므로, 이 메트릭은 가장 긴급하게 대응해야 한다.

결론

분산 락은 단순한 API 호출이 아니라, 정합성과 가용성 사이의 트레이드오프를 이해하고 설계하는 아키텍처 결정이다.

  • 효율성 목적이라면 Redis 단일 인스턴스 락이면 충분하다. 간단하고 빠르다.
  • 효율성 목적이지만 Redis 단일 장애점이 걱정된다면 Redlock을 고려한다. 다만 정합성 보장은 제한적이다.
  • 정합성이 중요하다면 ZooKeeper 또는 etcd를 선택하고, 반드시 Fencing Token과 함께 사용한다.
  • 어떤 구현을 선택하든 Fencing Token + 멱등성 설계 + 모니터링이 완전한 분산 락의 3대 요소다.

완벽한 분산 락은 존재하지 않는다. 중요한 것은 자신의 시스템이 요구하는 보장 수준을 명확히 하고, 그에 맞는 구현을 선택하며, 실패 시나리오에 대한 방어 전략을 갖추는 것이다.

참고자료

Distributed Lock Pattern Comparison: Redis Redlock vs ZooKeeper vs etcd — Consistency and Availability Trade-offs

Distributed Lock Pattern Comparison

Introduction

In distributed systems, when multiple processes simultaneously access the same resource, data consistency breaks. Operations requiring mutual exclusion such as inventory deduction, payment processing, and file writing must be performed by only one process. On a single server, mutexes or semaphores can solve this, but between processes distributed across multiple servers, a Distributed Lock is needed.

Distributed lock use cases fall into two categories:

  • Efficiency: To prevent duplicate execution of the same work. If a lock occasionally fails, only cost is wasted without data corruption.
  • Correctness: To prevent data corruption from concurrent access. If a lock fails, data is corrupted, requiring much stricter guarantees.

For efficiency purposes, a Redis single instance lock is sufficient. However, for correctness purposes, a Fencing Token must accompany the lock, and this difference is the core of the Redis Redlock debate.

Redis Single Instance Lock

The simplest distributed lock uses Redis's SET NX PX command. The NX (Not eXists) option sets the key only when it doesn't exist, and PX specifies expiration in milliseconds.

Basic Implementation

import redis
import uuid
import time


class RedisSimpleLock:
    """Redis single instance distributed lock"""

    def __init__(self, client: redis.Redis, resource: str, ttl_ms: int = 10000):
        self.client = client
        self.resource = resource
        self.ttl_ms = ttl_ms
        self.lock_value = str(uuid.uuid4())  # Ownership identifier

    def acquire(self, retry_count: int = 3, retry_delay_ms: int = 200) -> bool:
        """Attempt to acquire lock. Retry on failure."""
        for attempt in range(retry_count):
            result = self.client.set(
                self.resource,
                self.lock_value,
                nx=True,
                px=self.ttl_ms
            )
            if result:
                return True
            time.sleep(retry_delay_ms / 1000.0)
        return False

    def release(self) -> bool:
        """Release lock after ownership verification (using Lua script)"""
        lua_script = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
        """
        result = self.client.eval(lua_script, 1, self.resource, self.lock_value)
        return result == 1


# Usage example
client = redis.Redis(host="localhost", port=6379)
lock = RedisSimpleLock(client, "order:12345:lock", ttl_ms=5000)

if lock.acquire():
    try:
        # Perform critical section work
        print("Lock acquired, performing work...")
    finally:
        lock.release()
else:
    print("Failed to acquire lock")

Safe Lock Release with Lua Script

When releasing a lock, ownership must be verified. If GET and DEL are executed as separate commands, another client could acquire the lock between them. Lua scripts execute atomically in Redis, preventing this issue.

-- safe_unlock.lua
-- Safe lock release after ownership verification
-- KEYS[1]: Lock key
-- ARGV[1]: Owner identification value
-- Returns: 1 (success), 0 (ownership mismatch)

if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

The limitation of the single instance approach is clear. If the Redis master fails, lock information is lost. Even with failover to a replica, due to the asynchronous replication nature, if the master goes down before the lock key is replicated, two clients can simultaneously acquire the same lock.

Redlock Algorithm

Redis creator Salvatore Sanfilippo (antirez) proposed the Redlock algorithm to overcome single instance limitations. The core idea is to obtain majority consensus from N (typically 5) independent Redis master nodes.

Three-Step Algorithm

  1. Acquisition phase: Record the current time, then sequentially send SET NX PX commands to all N nodes. The timeout for each node is set much shorter than the total TTL.
  2. Validity check: If locks were acquired on a majority (N/2 + 1) or more nodes, and the total elapsed time is less than the TTL, the lock acquisition is successful. Valid remaining time is TTL - elapsed time.
  3. Release phase: Send release commands unconditionally to all N nodes. Release is sent even to nodes where acquisition failed to clean up partially set keys.

Redlock Python Implementation

import redis
import uuid
import time
from typing import List, Optional, Tuple


class Redlock:
    """Redlock distributed lock algorithm implementation"""

    CLOCK_DRIFT_FACTOR = 0.01  # Clock drift correction factor
    RETRY_DELAY_MS = 200
    RETRY_COUNT = 3

    def __init__(self, nodes: List[dict], ttl_ms: int = 10000):
        self.nodes = [
            redis.Redis(host=n["host"], port=n["port"], socket_timeout=0.1)
            for n in nodes
        ]
        self.quorum = len(self.nodes) // 2 + 1
        self.ttl_ms = ttl_ms

    def _acquire_single(self, client: redis.Redis, resource: str,
                        value: str) -> bool:
        try:
            return bool(client.set(resource, value, nx=True, px=self.ttl_ms))
        except redis.RedisError:
            return False

    def _release_single(self, client: redis.Redis, resource: str,
                        value: str) -> None:
        lua = """
        if redis.call("GET", KEYS[1]) == ARGV[1] then
            return redis.call("DEL", KEYS[1])
        else
            return 0
        end
        """
        try:
            client.eval(lua, 1, resource, value)
        except redis.RedisError:
            pass

    def acquire(self, resource: str) -> Optional[Tuple[str, float]]:
        """Acquire lock. Returns (lock_value, validity_time) on success"""
        for _ in range(self.RETRY_COUNT):
            lock_value = str(uuid.uuid4())
            start_time = time.monotonic()
            acquired_count = 0

            # Step 1: Attempt lock acquisition on all nodes
            for client in self.nodes:
                if self._acquire_single(client, resource, lock_value):
                    acquired_count += 1

            # Step 2: Validity check
            elapsed_ms = (time.monotonic() - start_time) * 1000
            drift = self.ttl_ms * self.CLOCK_DRIFT_FACTOR + 2
            validity_time = self.ttl_ms - elapsed_ms - drift

            if acquired_count >= self.quorum and validity_time > 0:
                return (lock_value, validity_time)

            # On failure, release on all nodes
            for client in self.nodes:
                self._release_single(client, resource, lock_value)

            time.sleep(self.RETRY_DELAY_MS / 1000.0)

        return None

    def release(self, resource: str, lock_value: str) -> None:
        """Release lock on all nodes"""
        for client in self.nodes:
            self._release_single(client, resource, lock_value)


# Usage example
nodes = [
    {"host": "redis1.example.com", "port": 6379},
    {"host": "redis2.example.com", "port": 6379},
    {"host": "redis3.example.com", "port": 6379},
    {"host": "redis4.example.com", "port": 6379},
    {"host": "redis5.example.com", "port": 6379},
]

redlock = Redlock(nodes, ttl_ms=10000)
result = redlock.acquire("payment:order:99999")

if result:
    lock_value, validity_ms = result
    try:
        print(f"Lock acquired. Valid for: {validity_ms:.0f}ms")
        # Perform critical section work
    finally:
        redlock.release("payment:order:99999", lock_value)

Using time.monotonic() is important here. System time (time.time()) can go backward due to NTP corrections, but a monotonic clock always moves forward. The CLOCK_DRIFT_FACTOR also compensates for clock drift between nodes.

Redlock Critique: Kleppmann vs Antirez Debate

In 2016, Martin Kleppmann identified fundamental problems with the Redlock algorithm in his article "How to do distributed locking." This debate became one of the most famous technical discussions in the distributed systems community.

Kleppmann's Core Critique

1. Timing Assumption Risks

Redlock relies on the timing assumption that processes complete quickly without interruption. But in reality:

  • Client A successfully acquires locks on 3 of 5 nodes
  • A GC (Garbage Collection) pause occurs, freezing the process for tens of seconds
  • Meanwhile, the TTL expires and the lock is released
  • Client B acquires the same lock and performs work
  • Client A returns from GC and (believing it still holds the lock) performs work
  • Both clients perform critical section work simultaneously, causing data corruption

2. Absence of Fencing Token

Kleppmann argued that safe distributed locks must have a Fencing Token. A Fencing Token is a monotonically increasing number transmitted along with resource access (e.g., database). When the resource side rejects requests with tokens lower than previously seen ones, delayed writes from expired lock holders can be safely blocked. Redlock lacks a mechanism to generate such Fencing Tokens.

3. Network Delay and Clock Jumps

If system clocks suddenly jump due to NTP synchronization failure or VM migration, TTL calculations become invalid. Redlock assumes that clocks between nodes are roughly synchronized, but this cannot be guaranteed in asynchronous distributed systems.

Antirez's Response

Salvatore Sanfilippo responded in his article "Is Redlock safe?" with the following counterarguments:

  • The GC pause scenario is not specific to Redlock but applies to all distributed lock systems
  • In reasonable operational environments, clock drift is limited and can be sufficiently corrected with CLOCK_DRIFT_FACTOR
  • Fencing Tokens require support from the resource side, and if the resource supports this, it could potentially handle concurrency control on its own

Comparison Table: Redlock Debate Summary

PointKleppmann (Critique)Antirez (Defense)
Timing assumptionsTiming assumptions are dangerous in async systemsSufficiently valid in reasonable operational environments
GC pauseProcess can be suspended during lock validityA common problem for all distributed systems
Fencing TokenRedlock cannot generate them, essentialIf resource supports them, the lock itself may be unnecessary
Clock syncClock jump risk during NTP failureAddressable with drift correction factor
Recommended useEfficiency only, unsuitable for correctnessSufficiently safe for most practical scenarios

In the author's view, standalone Redlock use is not recommended when correctness is important. It's safer to use it with storage that supports Fencing Tokens, or to choose consensus-based systems like ZooKeeper or etcd.

ZooKeeper Distributed Lock

Apache ZooKeeper is a dedicated service designed for distributed system coordination. Through the Zab (ZooKeeper Atomic Broadcast) protocol, it guarantees Linearizability and provides built-in primitives needed for distributed lock implementation.

Ephemeral Sequential Node Pattern

ZooKeeper's distributed lock uses Ephemeral Sequential Znodes:

  1. The client creates an ephemeral sequential node at the /locks/resource-name/lock- path.
  2. ZooKeeper automatically assigns a sequence number (e.g., lock-0000000001).
  3. If the created node has the smallest number, the lock is acquired.
  4. Otherwise, a Watch is set on the immediately preceding node and the client waits.
  5. When the client session ends, the ephemeral node is automatically deleted, releasing the lock.

Setting a Watch only on the immediately preceding node is key. If all waiters Watch the smallest node, a Herd Effect occurs when the lock is released, as notifications are sent to all waiters simultaneously.

ZooKeeper Distributed Lock Python Implementation

from kazoo.client import KazooClient
from kazoo.recipe.lock import Lock
import logging

logging.basicConfig(level=logging.INFO)


class ZooKeeperDistributedLock:
    """ZooKeeper-based distributed lock (using Kazoo library)"""

    def __init__(self, hosts: str, lock_path: str):
        self.zk = KazooClient(hosts=hosts)
        self.zk.start()
        self.lock = Lock(self.zk, lock_path)
        self.lock_path = lock_path

    def acquire(self, timeout: float = 30.0) -> bool:
        """Acquire lock. Returns False if not acquired within timeout seconds"""
        try:
            return self.lock.acquire(timeout=timeout)
        except Exception as e:
            logging.error(f"Lock acquisition failed: {e}")
            return False

    def release(self) -> None:
        """Release lock"""
        try:
            self.lock.release()
        except Exception as e:
            logging.error(f"Lock release failed: {e}")

    def get_fencing_token(self) -> int:
        """Use zxid as Fencing Token"""
        data, stat = self.zk.get(self.lock_path)
        return stat.czxid  # Creation transaction ID (monotonically increasing)

    def close(self) -> None:
        self.zk.stop()
        self.zk.close()


# Usage example
zk_lock = ZooKeeperDistributedLock(
    hosts="zk1:2181,zk2:2181,zk3:2181",
    lock_path="/locks/payment/order-99999"
)

if zk_lock.acquire(timeout=10.0):
    try:
        fencing_token = zk_lock.get_fencing_token()
        logging.info(f"Lock acquired, fencing token: {fencing_token}")
        # Write to storage with fencing_token
        # storage.write(data, fencing_token=fencing_token)
    finally:
        zk_lock.release()

zk_lock.close()

ZooKeeper's strength is that zxid (ZooKeeper Transaction ID) can be used as a Fencing Token. Since zxid is globally monotonically increasing, if the storage side rejects writes with previous zxids, delayed writes from expired lock holders can be safely blocked.

Read-Write Lock Recipe

ZooKeeper supports not only exclusive locks but also Read-Write Locks. Read lock nodes use the read- prefix, and write lock nodes use the write- prefix. A read lock can be acquired when there are no write nodes ahead, and a write lock can only be acquired when it has the smallest number. This allows higher read concurrency while ensuring write mutual exclusion.

etcd Distributed Lock

etcd is a distributed key-value store widely known as the state store for Kubernetes. Based on the Raft consensus algorithm, it guarantees Strong Consistency and provides Lease and Revision mechanisms suitable for distributed lock implementation.

Lease-based TTL Management

etcd's Lease is a temporary token with TTL. When a key-value pair is attached to a Lease, the key is also deleted when the Lease expires. The client periodically calls KeepAlive to renew the Lease, and if the client fails, renewal stops and the lock is automatically released.

Automatic Fencing Token Generation via Revision Numbers

Every key change in etcd is assigned a globally monotonically increasing Revision number. The ability to directly use this Revision as a Fencing Token is a major advantage of etcd distributed locks. No separate token generation mechanism is needed.

etcd Distributed Lock Python Implementation

import etcd3
import threading
import logging
from typing import Optional, Tuple

logging.basicConfig(level=logging.INFO)


class EtcdDistributedLock:
    """etcd Lease-based distributed lock"""

    def __init__(self, host: str = "localhost", port: int = 2379,
                 ttl: int = 10):
        self.client = etcd3.client(host=host, port=port)
        self.ttl = ttl
        self.lease: Optional[etcd3.Lease] = None
        self._keepalive_thread: Optional[threading.Thread] = None
        self._stop_keepalive = threading.Event()

    def acquire(self, lock_key: str,
                timeout: float = 30.0) -> Optional[Tuple[int, int]]:
        """
        Acquire lock. Returns (revision, lease_id) on success.
        Revision can be used as Fencing Token.
        """
        self.lease = self.client.lease(self.ttl)
        self.lock_key = lock_key

        # Atomic lock acquisition via transaction
        # Create only when key doesn't exist (Compare-And-Swap)
        success, responses = self.client.transaction(
            compare=[
                self.client.transactions.create(lock_key) == 0
            ],
            success=[
                self.client.transactions.put(
                    lock_key, "locked", lease=self.lease
                )
            ],
            failure=[]
        )

        if success:
            # Use Revision as Fencing Token
            revision = responses[0].header.revision
            self._start_keepalive()
            logging.info(
                f"Lock acquired. revision(fencing token): {revision}"
            )
            return (revision, self.lease.id)

        logging.warning("Lock acquisition failed: already held by another client")
        self.lease.revoke()
        return None

    def _start_keepalive(self) -> None:
        """Start Lease auto-renewal thread"""
        self._stop_keepalive.clear()

        def keepalive_loop():
            while not self._stop_keepalive.is_set():
                try:
                    self.lease.refresh()
                except Exception as e:
                    logging.error(f"Lease renewal failed: {e}")
                    break
                self._stop_keepalive.wait(timeout=self.ttl / 3.0)

        self._keepalive_thread = threading.Thread(
            target=keepalive_loop, daemon=True
        )
        self._keepalive_thread.start()

    def release(self) -> None:
        """Release lock"""
        self._stop_keepalive.set()
        if self.lease:
            try:
                self.lease.revoke()
                logging.info("Lock released")
            except Exception as e:
                logging.error(f"Lock release failed: {e}")

    def close(self) -> None:
        self.release()
        self.client.close()


# Usage example
lock = EtcdDistributedLock(host="etcd1.example.com", ttl=15)
result = lock.acquire("locks/payment/order-99999")

if result:
    fencing_token, lease_id = result
    try:
        # Write to storage with fencing_token (revision)
        logging.info(f"Performing work, fencing token: {fencing_token}")
    finally:
        lock.release()

Jepsen Test Results and Cautions

In the Jepsen etcd 3.4.3 test, it was confirmed that etcd locks may not be safe in certain network partition scenarios. Specifically, if Lease renewal is delayed during leader changes, the client may still believe it holds the lock while the Lease has actually expired and another client has acquired it. Therefore, etcd locks must also be used with Fencing Tokens.

Three-Way Comparison Analysis

Core Comparison Table

ItemRedis RedlockZooKeeperetcd
Consensus algorithmNone (independent node majority)Zab (Atomic Broadcast)Raft
Consistency modelEventual consistency approximationLinearizableLinearizable
Fencing TokenNot supportedzxid availableRevision available
Fault toleranceUp to N/2 node failuresUp to N/2 node failuresUp to N/2 node failures
Lock release mechanismTTL expirationSession expiry + ephemeral node deletionLease expiration
Performance (acquisition latency)Very fast (1-5ms)Medium (5-20ms)Medium (5-15ms)
ThroughputHigh (10K+ ops/s)Medium (1-5K ops/s)Medium (2-8K ops/s)
Operational complexityLowHigh (dedicated ensemble)Medium (already present in K8s)
Watch/notificationPub/Sub (no guarantee)Watch (ordered)Watch (Revision-based)
Client ecosystemVery richRichRich (especially Go ecosystem)

Selection Guide by Use Case

Choose Redis Redlock when:

  • Preventing duplicate work for efficiency (cache warming, batch jobs, etc.)
  • Fast millisecond-level lock acquisition is needed
  • Redis is already in use and you don't want to add infrastructure
  • Occasional double execution is acceptable

Choose ZooKeeper when:

  • Correctness is paramount and Fencing Token is essential
  • Various coordination needs like leader election, config management
  • Already operating ZooKeeper-dependent systems (Hadoop, Kafka, etc.)
  • Complex lock patterns like Read-Write Lock are needed

Choose etcd when:

  • Already operating etcd in a Kubernetes environment
  • Want to easily leverage Fencing Token (Revision)
  • Prefer gRPC-based API and Go ecosystem
  • Want strong consistency with relatively low operational burden

Cost-Complexity Matrix

CriteriaRedis RedlockZooKeeperetcd
Infrastructure costLow (reuse Redis)High (dedicated cluster)Medium (can be included in K8s)
Learning curveLowHighMedium
Consistency guaranteeWeakStrongStrong
Debugging easeHighMediumMedium
Community supportVery activeMatureGrowing

Failure Cases and Recovery Procedures

Case 1: Dual Lock Acquisition During GC Pause with TTL Expiration

Scenario:

Time ------>

Client A: [Lock acquired] --- [GC pause starts] -------------------- [GC resumes, attempts write]
                                              TTL expires
Client B:                          [Lock acquired] --- [Write complete] --- [Write complete]

Result: Both A and B write -> Data corruption

This scenario can occur not only with Redis Redlock but with all TTL-based locks. The defense pattern is to use Fencing Tokens:

class FencingAwareStorage:
    """Storage wrapper that validates Fencing Tokens"""

    def __init__(self):
        self.last_token = 0
        self._lock = threading.Lock()

    def write(self, data: dict, fencing_token: int) -> bool:
        """Allow writes only when fencing_token is greater than previous value"""
        with self._lock:
            if fencing_token <= self.last_token:
                logging.warning(
                    f"Rejected: token {fencing_token} <= "
                    f"last {self.last_token}"
                )
                return False
            self.last_token = fencing_token
            # Perform actual write
            self._do_write(data)
            logging.info(
                f"Write successful: token {fencing_token}"
            )
            return True

    def _do_write(self, data: dict) -> None:
        # Actual storage write logic
        pass

Case 2: Lock Loss Due to ZooKeeper Session Expiry

Scenario:

When the connection between ZooKeeper client and ensemble is severed due to network partition, after session timeout the ephemeral node is deleted and the lock is released. Meanwhile, the client may still be performing work.

Defense pattern:

from kazoo.client import KazooState


def connection_listener(state):
    """ZooKeeper connection state monitoring"""
    if state == KazooState.SUSPENDED:
        # Connection suspended: pause ongoing work
        logging.warning("ZK connection suspended - pausing operations")
        pause_current_operations()
    elif state == KazooState.LOST:
        # Session expired: lock loss confirmed, stop work immediately
        logging.error("ZK session expired - lock lost, aborting operations")
        abort_current_operations()
    elif state == KazooState.CONNECTED:
        # Reconnection successful: attempt lock reacquisition
        logging.info("ZK reconnected - attempting lock reacquisition")
        reacquire_lock()


zk = KazooClient(hosts="zk1:2181,zk2:2181,zk3:2181")
zk.add_listener(connection_listener)
zk.start()

Recovery Procedure Checklist

  1. Immediate response: Stop current work immediately upon detecting lock loss
  2. State verification: Check data consistency of partially completed work
  3. Idempotency design: Design work to be idempotent ensuring same results on retry
  4. Compensating transactions: Execute compensation logic to revert partially completed state
  5. Alert dispatch: Notify operations team of potential double execution

Operational Considerations

Lock Granularity and Scope Design

Lock scope should be designed as narrow as possible. Broad-scoped locks increase contention and reduce throughput.

Bad:     /locks/orders           (Single lock for all orders)
Medium:  /locks/orders/user-123  (Per-user lock)
Good:    /locks/orders/99999     (Per-order lock)

Deadlock Detection and Timeout Strategy

In distributed environments, deadlocks can occur when two processes wait for each other's locks. Prevention strategies include:

  • Fixed-order acquisition: When locks on multiple resources are needed, always acquire in the same order
  • Timeout setting: Set timeouts on all lock acquisitions to prevent infinite waiting
  • Lock hierarchies: Acquire locks from parent resources to child resources in order

Monitoring Metrics

Metrics that must be collected when operating distributed locks:

MetricDescriptionDanger Threshold
lock_acquisition_time_p99Lock acquisition latency P99Exceeds 50% of TTL
lock_contention_rateLock contention rate (fails/total)Exceeds 30%
lock_hold_duration_p99Lock hold duration P99Exceeds 80% of TTL
lock_timeout_rateTimeout occurrence rateExceeds 5%
fencing_token_reject_rateFencing Token rejection rateInvestigate immediately if > 0

If fencing_token_reject_rate is greater than 0, it means double execution has occurred, so this metric requires the most urgent response.

Conclusion

Distributed locks are not simple API calls but architecture decisions about understanding and designing the trade-off between consistency and availability.

  • For efficiency purposes, a Redis single instance lock is sufficient. Simple and fast.
  • For efficiency but concerned about Redis single point of failure, consider Redlock. However, consistency guarantees are limited.
  • When correctness is important, choose ZooKeeper or etcd and always use with Fencing Tokens.
  • Regardless of implementation, Fencing Token + idempotency design + monitoring are the three pillars of a complete distributed lock.

No perfect distributed lock exists. What matters is clearly defining the guarantee level your system requires, choosing the appropriate implementation, and having defense strategies for failure scenarios.

References