Distributed Locking with Redis and Redlock
Implement distributed locks with Redis for mutual exclusion across processes, using SET NX with TTL and the Redlock algorithm for reliability
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Distributed Locking with Redis and Redlock
Distributed locks ensure only one process can access a resource at a time across multiple server instances. Redis makes this possible with SET key value NX PX ttl — an atomic operation that sets a key only if it does not exist, with an expiration. This implementation provides a safe distributed lock with automatic release, lock renewal, and the Redlock algorithm for multi-node reliability.
When to Use This
- Cron jobs or scheduled tasks that must run on only one instance
- Updating shared resources where concurrent writes cause corruption
- Rate-limited external API calls where only one process should call at a time
- Leader election for a temporary single-leader task
Prerequisites
- Python 3.10+
redispackage (pip install redis)
Solution
1. Install Dependencies
pip install redis
2. Implement a Safe Distributed Lock
import time
import uuid
import logging
from redis import Redis
logger = logging.getLogger(__name__)
RELEASE_LOCK_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
class DistributedLock:
def __init__(self, redis_client: Redis, lock_name: str, ttl: int = 30):
self.redis = redis_client
self.lock_name = f"lock:{lock_name}"
self.ttl = ttl
self.token = str(uuid.uuid4())
self._acquired = False
self._release_script = redis_client.register_script(RELEASE_LOCK_SCRIPT)
def acquire(self, timeout: float = 10.0, retry_interval: float = 0.1) -> bool:
"""Try to acquire the lock, retrying until timeout.
Args:
timeout: Maximum time to wait in seconds.
retry_interval: Time between retries in seconds.
Returns:
True if lock was acquired, False if timed out.
"""
deadline = time.time() + timeout
while time.time() < deadline:
acquired = self.redis.set(
self.lock_name,
self.token,
nx=True,
px=self.ttl * 1000,
)
if acquired:
self._acquired = True
logger.info("Lock acquired: %s", self.lock_name)
return True
time.sleep(retry_interval)
logger.warning("Lock acquisition timed out: %s", self.lock_name)
return False
def release(self) -> bool:
"""Release the lock if we still own it.
Returns:
True if lock was released, False if we did not own it.
"""
if not self._acquired:
return False
result = self._release_script(
keys=[self.lock_name],
args=[self.token],
)
self._acquired = False
if result:
logger.info("Lock released: %s", self.lock_name)
return True
else:
logger.warning("Lock already expired or stolen: %s", self.lock_name)
return False
def renew(self, ttl: int | None = None) -> bool:
"""Extend the lock's TTL if we still own it.
Args:
ttl: New TTL in seconds. Defaults to original TTL.
Returns:
True if renewed, False if lock was lost.
"""
if not self._acquired:
return False
script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
"""
result = self.redis.eval(
script, 1, self.lock_name, self.token, (ttl or self.ttl) * 1000
)
return bool(result)
def __enter__(self):
if not self.acquire():
raise TimeoutError(f"Could not acquire lock: {self.lock_name}")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.release()
return False
3. Use the Lock
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
# As a context manager
def process_job(job_id: str):
with DistributedLock(r, f"job:{job_id}", ttl=30) as lock:
# Only one process executes this at a time
job = fetch_job(job_id)
result = execute_job(job)
save_result(job_id, result)
# Lock auto-releases on exit
# Manual acquire/release
lock = DistributedLock(r, "cron:cleanup", ttl=60)
if lock.acquire(timeout=5):
try:
run_cleanup()
finally:
lock.release()
else:
print("Another instance is running cleanup")
4. Lock Renewal for Long Tasks
import threading
class RenewableLock(DistributedLock):
def __init__(self, redis_client: Redis, lock_name: str, ttl: int = 30):
super().__init__(redis_client, lock_name, ttl)
self._renewal_thread: threading.Thread | None = None
self._stop_renewal = threading.Event()
def acquire(self, timeout: float = 10.0, retry_interval: float = 0.1) -> bool:
acquired = super().acquire(timeout, retry_interval)
if acquired:
self._start_renewal()
return acquired
def _start_renewal(self):
self._stop_renewal.clear()
self._renewal_thread = threading.Thread(
target=self._renewal_loop, daemon=True
)
self._renewal_thread.start()
def _renewal_loop(self):
interval = self.ttl * 0.3 # Renew at 30% of TTL
while not self._stop_renewal.wait(interval):
if not self.renew():
logger.error("Lock lost during renewal: %s", self.lock_name)
break
def release(self) -> bool:
self._stop_renewal.set()
if self._renewal_thread:
self._renewal_thread.join(timeout=5)
return super().release()
5. Redlock Algorithm (Multi-Node)
For higher reliability, use multiple Redis instances. The lock is acquired if a majority of instances grant it:
import time
import uuid
class Redlock:
def __init__(self, redis_nodes: list[Redis], retry_count: int = 3, retry_delay: float = 0.2):
self.nodes = redis_nodes
self.retry_count = retry_count
self.retry_delay = retry_delay
self.quorum = len(redis_nodes) // 2 + 1
def acquire(self, lock_name: str, ttl: int = 30) -> str | None:
"""Acquire a lock across multiple Redis instances.
Returns:
Lock token if acquired, None if failed.
"""
token = str(uuid.uuid4())
lock_key = f"lock:{lock_name}"
for attempt in range(self.retry_count):
start = time.time()
granted = 0
for node in self.nodes:
try:
if node.set(lock_key, token, nx=True, px=ttl * 1000):
granted += 1
except Exception as e:
logger.warning("Redis node error: %s", e)
elapsed = (time.time() - start) * 1000
if granted >= self.quorum and elapsed < ttl * 1000:
return token
# Failed — clean up any partial locks
self._release_all(lock_key, token)
time.sleep(self.retry_delay)
return None
def release(self, lock_name: str, token: str) -> None:
lock_key = f"lock:{lock_name}"
self._release_all(lock_key, token)
def _release_all(self, lock_key: str, token: str) -> None:
for node in self.nodes:
try:
node.eval(
RELEASE_LOCK_SCRIPT, 1, lock_key, token
)
except Exception:
pass
How It Works
SET NX PXatomically sets a key only if it does not exist, with a TTL in milliseconds. This is the core of the lock — if the key exists, another process holds the lock.- Token-based ownership — each lock holder generates a unique UUID token. When releasing, a Lua script checks that the token matches before deleting, preventing a process from releasing a lock it no longer owns.
- TTL ensures the lock auto-expires if the holder crashes or becomes unresponsive. Without it, a crashed process would hold the lock forever.
- Renewal extends the TTL periodically, allowing long-running tasks to hold the lock safely. A background thread renews at 30% of the TTL interval.
- Redlock acquires the lock on multiple independent Redis instances. If a majority grant the lock, it is considered acquired. This survives a single Redis node failure.
Variants
Fair Lock with Queue
def acquire_fair_lock(redis_client: Redis, lock_name: str, ttl: int = 30) -> str | None:
"""Acquire lock with FIFO ordering using a sorted set queue."""
token = str(uuid.uuid4())
queue_key = f"lock_queue:{lock_name}"
lock_key = f"lock:{lock_name}"
# Add to queue with timestamp
score = time.time()
redis_client.zadd(queue_key, {token: score})
# Wait until we are first in queue and lock is free
while True:
first = redis_client.zrange(queue_key, 0, 0, withscores=True)
if first and first[0][0] == token:
if redis_client.set(lock_key, token, nx=True, px=ttl * 1000):
redis_client.zrem(queue_key, token)
return token
time.sleep(0.1)
Lock with Fencing Token
def acquire_with_fencing(redis_client: Redis, lock_name: str, ttl: int = 30) -> tuple[str, int] | None:
"""Acquire lock and return a fencing token for ordering."""
token = str(uuid.uuid4())
lock_key = f"lock:{lock_name}"
counter_key = f"lock_counter:{lock_name}"
if redis_client.set(lock_key, token, nx=True, px=ttl * 1000):
fencing = redis_client.incr(counter_key)
return token, fencing
return None
# The fencing token prevents stale lock holders from corrupting state
# Storage layer rejects writes with fencing tokens lower than the last seen
Best Practices
-
For a deeper guide, see Complete Guide to Python Asyncio.
-
Always set a TTL — prevents deadlocks if a process crashes while holding the lock
-
Use a unique token per lock holder — prevents accidentally releasing another process’s lock
-
Release locks in a
finallyblock — ensures release even if an exception occurs -
Use Redlock for critical sections — single-instance Redis is a single point of failure for locks
Common Mistakes
- Not using a Lua script for release —
get+delis not atomic; another process could acquire the lock between the check and delete - Setting TTL too short — if the task takes longer than the TTL, the lock expires and another process starts concurrently
- Not handling lock acquisition failure — if
acquirereturnsFalse, the code proceeds anyway, defeating the purpose of the lock - Using
DELdirectly — deletes the lock regardless of ownership, potentially removing another process’s lock
FAQ
Q: Is Redlock safe? A: Redlock is debated in the distributed systems community. For most applications, it is sufficient. For strict correctness requirements (e.g., financial transactions), use a consensus system like etcd or Zookeeper.
Q: What TTL should I use? A: Set it to the maximum expected task duration plus a safety margin (2x). Use renewal for tasks with unpredictable duration.
Q: Can I use Redis Cluster for locks?
A: Yes, but use hash tags (lock:{job_id}) to ensure the lock key and any related keys are on the same shard.
Q: What happens if the lock holder pauses (GC pause)? A: The TTL may expire during the pause, allowing another process to acquire the lock. Use fencing tokens to prevent stale holders from corrupting state.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Implement the Cache-Aside Pattern with Redis
Use the cache-aside pattern to read and write data through Redis, handling cache misses, stale reads, and write-through invalidation
RecipeRate Limiting with Redis Token Bucket Algorithm
Implement a distributed token bucket rate limiter using Redis atomic operations for API throttling across multiple server instances
PatternDistributed Lock Pattern
Coordinate mutually exclusive access to shared resources across distributed nodes using a consensus-based lock service, preventing race conditions in scaled-out systems.
GuideComplete Guide to Redis Caching Strategies
Master Redis caching with cache-aside, read-through, write-through, write-behind, and refresh-ahead patterns. Covers eviction policies, TTL tuning, serialization, and production operations.
GuideComplete Guide to Python Asyncio
Master asynchronous Python programming with asyncio. Covers coroutines, tasks, event loops, async/await, gather, semaphores, queues, HTTP clients, websockets, and debugging async code.