16.3.5. Distributed Locks
Table of Contents
Why Distributed Locks Are Needed
When your backend runs on a single server, you can often rely on in-process synchronization tools like mutexes or locks provided by the programming language.
In a distributed system, you usually have:
- Multiple application instances
- Possibly multiple worker processes
- Shared resources, such as:
- A database row
- A file
- A user’s balance
- A cache key
If two instances try to modify the same resource at the same time, you can get:
- Double processing (for example, two workers send the same email twice)
- Corrupted data (for example, two workers change a bank balance incorrectly)
- Race conditions that are hard to reproduce
A distributed lock is a mechanism that lets multiple nodes coordinate access to a shared resource by ensuring only one node holds the lock at a time.
Common use cases:
- Ensure a cron job or scheduled task runs only once across the whole cluster
- Ensure only one worker processes a specific job or user account at a time
- Protect a shared counter or inventory stock from being oversold
Redis is a common choice for distributed locks because:
- It is fast
- It supports
SETwith options that help implement locks - It is often already present in your stack for caching or sessions
Key idea: A distributed lock must be mutual exclusive, have a timeout, and be safely released only by its owner.
Basic Locking with Redis
The simplest way to think about a lock in Redis is:
- The lock is a key, for example
lock:order:123 - The presence of the key means the lock is held
- The absence of the key means the lock is free
Naive Implementation
A naive lock:
- Try to create a key if it does not exist.
- If successful, you hold the lock.
- When you are done, delete the key.
In Redis, you might try:
SETNX lock:report:daily "locked"In Python (pseudocode):
acquired = redis.setnx("lock:report:daily", "locked")
if acquired:
# Do the job
...
redis.delete("lock:report:daily")
else:
# Someone else is already running it
pass
SETNX means SET if Not eXists. It returns true if the key was created.
This shows the basic idea, but it has several important problems.
Pitfalls of Naive Locking
Problem 1: Locks Without Expiration
If you use SETNX directly and never set an expiry, then:
- If the process crashes
- Or the application is killed
- Or the machine loses power
The key will stay in Redis forever.
Result: The lock is never released and no one else can acquire it. This is called a deadlock in this context.
Example
- Worker A acquires lock with
SETNX lock:report:daily - Worker A crashes before
DEL lock:report:daily - Worker B will always fail to acquire the lock
- The daily report will never run again
Problem 2: Releasing Someone Else’s Lock
Consider this flow:
- Process A acquires the lock.
- Process A takes a long time, longer than expected.
- Another process B might try a workaround and delete the lock key (or use some bug or retry logic).
- Process B now thinks it has the lock and starts working, while process A is still working, which breaks mutual exclusion.
Even simpler, if multiple processes use DEL blindly, any process can delete any lock held by someone else.
We need a way to ensure only the owner of a lock can release it.
Problem 3: Race Conditions Around Timeouts
You might think: "I will set an expiration on the lock":
SETNX lock:report:daily "locked"
EXPIRE lock:report:daily 60But there is a race here:
- You run
SETNXand get the lock. - Before you call
EXPIRE, the process crashes. - No expiration is set, so the lock becomes permanent again.
You need a single atomic command that both acquires the lock and sets the TTL.
Implementing Safe Locks with SET, NX, and PX
Redis provides a more powerful SET command with options:
SET key value [NX|XX] [EX seconds|PX milliseconds]NXmeans "only set if key does not exist" (similar toSETNX)EXorPXsets an expiration time
This allows an atomic operation:
SET lock:report:daily my-unique-id NX EX 60This means:
- Create
lock:report:dailywith valuemy-unique-id - Only if the key does not already exist
- And set it to expire in 60 seconds
If Redis returns OK, you acquired the lock. If it returns nil (or your client returns False), the lock is held by someone else.
Rule: Always use SET key value NX EX ttl or SET key value NX PX ttl for acquiring locks. This guarantees the key creation and expiration are atomic.
Using a Unique Value
Why not just write "locked" as the value?
Because we want to:
- Know who owns the lock
- Prevent other processes from deleting our lock accidentally
The unique value:
- Could be a random string or UUID
- Or could include process id plus a random part
Example in Python:
import uuid
lock_key = "lock:report:daily"
lock_value = str(uuid.uuid4()) # random unique ID
acquired = redis_client.set(lock_key, lock_value, nx=True, ex=60)
if acquired:
# We hold the lock
...
else:
# Someone else holds it
...Releasing Locks Safely with Lua Scripts
We have a lock value that identifies the owner. Now we must only delete the lock if we are still the owner.
Naive unlock:
redis_client.delete(lock_key)This is unsafe because:
- The lock might have expired and been acquired by someone else.
- You might be deleting another process's lock.
Correct unlock pattern:
- Read the current value of the lock key.
- Compare it with your unique value.
- If it matches, delete the key.
- If not, do nothing.
If you do this as separate commands:
if redis_client.get(lock_key) == lock_value:
redis_client.delete(lock_key)There is a race condition:
- Between
GETandDEL, the lock might expire and be acquired by someone else. - You may then delete a lock you no longer own.
To avoid this, use a Lua script that runs atomically in Redis.
Typical Unlock Lua Script
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
endUse it from Python:
import uuid
lock_key = "lock:report:daily"
lock_value = str(uuid.uuid4())
# Try to acquire
acquired = redis_client.set(lock_key, lock_value, nx=True, ex=60)
if not acquired:
print("Could not acquire lock.")
else:
try:
# Do protected work
print("Lock acquired, running job")
# ... your critical section ...
finally:
# Release safely
unlock_script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
"""
redis_client.eval(unlock_script, 1, lock_key, lock_value)Important points:
lock_valueis unique per lock owner.- Only the owner that set the key with that value can delete it.
EVALruns the script atomically on the Redis server.
Rule: Always release locks with a compare-and-delete operation, not with a blind DEL.
Example: Single-Instance Job with Redis Lock
Scenario: You have 5 application servers, and each runs a scheduled task every minute to process a daily report. You want only one of them to actually run the job.
Pseudocode:
import uuid
import time
LOCK_KEY = "lock:report:daily"
LOCK_TTL = 120 # seconds
def acquire_lock(redis_client, key, ttl):
value = str(uuid.uuid4())
acquired = redis_client.set(key, value, nx=True, ex=ttl)
if acquired:
return value
return None
def release_lock(redis_client, key, value):
script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
"""
redis_client.eval(script, 1, key, value)
def run_daily_job(redis_client):
lock_value = acquire_lock(redis_client, LOCK_KEY, LOCK_TTL)
if not lock_value:
print("Another instance is already running the job.")
return
try:
print("Running daily job...")
time.sleep(10) # simulate work
print("Job done.")
finally:
release_lock(redis_client, LOCK_KEY, lock_value)
print("Lock released.")
If you run run_daily_job on multiple instances at the same time, only one will print "Running daily job...". Others will print "Another instance is already running the job."
Lock TTL and Auto Expiration
A lock must have a timeout to avoid permanent deadlocks.
Choosing TTL
You should pick a TTL that:
- Is longer than the expected duration of the critical section
- But not so long that failures block progress for too long
For example:
- If your job usually finishes in 10 seconds, you might set TTL to 60 seconds.
- If your job usually finishes in 60 seconds, you might set TTL to 300 seconds.
Table of example TTL choices:
| Job Type | Typical Duration | Suggested TTL |
|---|---|---|
| Small cache refresh | < 1 second | 5 seconds |
| Short background task | ~5 seconds | 30 seconds |
| Long report generation | ~60 seconds | 300 seconds |
| Nightly batch processing | ~5 minutes | 1200 seconds |
Rule: Always give locks a TTL. Never create locks without expiration in a distributed system.
What if the Job Exceeds TTL?
If your job takes longer than the lock's TTL:
- The lock will expire while you are still running.
- Another instance may acquire the lock and start the same work.
- You lose mutual exclusion.
This can be a problem in some use cases, for example payment processing.
Solutions:
- Overestimate the TTL generously.
- Or renew the lock periodically while you hold it (more advanced).
- Or design your critical section so that a bit of overlap is not catastrophic, and use idempotency on the underlying operations.
A simple extension strategy:
- While you have the lock, start a background thread that runs every few seconds and extends the TTL as long as it still owns the lock.
- This is often called lock refreshing or lock extension.
Leases and Lock Renewal
Instead of a plain lock, you can think of a lease: a lock that you hold for a limited time, which you can renew.
Basic lease logic:
- You acquire the lock with a TTL, for example 30 seconds.
- While you are working, every 10 or 20 seconds you:
- Check if the lock is still owned by you.
- If yes, extend the TTL by another 30 seconds.
- If the process crashes, no one will renew the lock and it will expire.
This reduces the risk that a very long running task loses the lock prematurely, while still giving a bound on how long the system can be stuck if the node fails.
Pseudo implementation idea:
import threading
import time
def renew_lock_periodically(redis_client, key, value, ttl, interval, stop_event):
while not stop_event.is_set():
time.sleep(interval)
# Renew only if we still own the lock
renew_script = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
"""
redis_client.eval(renew_script, 1, key, value, int(ttl * 1000))
def do_work_with_lease(redis_client):
lock_key = "lock:long:task"
ttl = 30
lock_value = str(uuid.uuid4())
acquired = redis_client.set(lock_key, lock_value, nx=True, ex=ttl)
if not acquired:
print("Could not acquire lock")
return
stop_event = threading.Event()
renewer = threading.Thread(
target=renew_lock_periodically,
args=(redis_client, lock_key, lock_value, ttl, 10, stop_event),
)
renewer.start()
try:
# Long running work
time.sleep(120)
finally:
stop_event.set()
renewer.join()
release_lock(redis_client, lock_key, lock_value)This strategy is more advanced and must be used carefully. It also assumes a relatively stable connection to Redis.
Using Redlock (Advanced Concept)
You might hear about Redlock, an algorithm proposed for distributed locks with Redis instances.
Conceptually:
- Run multiple independent Redis servers (for example 5)
- Acquire the same lock key in a majority of instances (for example 3 out of 5)
- Use timeouts and timing constraints to decide if you successfully acquired the lock
The goal is to keep locks safe even if some Redis instances fail or are partitioned.
However:
- Redlock has been the subject of debate among experts.
- For many typical web backends, especially when using a single Redis instance or a Redis cluster as a single logical service, simpler single-instance locking is enough.
- Redlock is outside the scope of basic backend development and should only be considered when you really need it and understand the tradeoffs.
For this course, focus on:
- Single Redis instance or managed Redis service
- Safe single-instance lock with TTL, unique value, and Lua unlock script
Common Patterns and Practical Tips
Pattern: Per-Resource Locks
You can create lock keys that are tied to specific resources:
lock:user:123to protect operations on user 123lock:order:456to protect operations on order 456lock:inventory:product:789to protect inventory changes for product 789
When you process a request that modifies such a resource:
- Build the lock key from the resource ID.
- Acquire the lock.
- Perform the operation.
- Release the lock.
Example: prevent two workers from updating the same order status at the same time.
def process_order(order_id):
lock_key = f"lock:order:{order_id}"
lock_value = str(uuid.uuid4())
acquired = redis_client.set(lock_key, lock_value, nx=True, ex=30)
if not acquired:
raise Exception("Order is being processed by another worker")
try:
# Load order from database
# Validate and update status
# Save changes
pass
finally:
release_lock(redis_client, lock_key, lock_value)Pattern: Try Once vs Wait and Retry
When trying to get a lock, you can:
- Try once
- If it fails, return an error or skip the operation.
- Wait and retry
- Keep trying for a limited time to get the lock.
Example of wait and retry:
import time
def acquire_lock_with_retry(redis_client, key, ttl, wait_timeout=5, retry_interval=0.1):
value = str(uuid.uuid4())
end_time = time.time() + wait_timeout
while time.time() < end_time:
if redis_client.set(key, value, nx=True, ex=ttl):
return value
time.sleep(retry_interval)
return NoneUse cases:
- For a user facing API, you might prefer "try once" and return a clear error.
- For background workers, you might use "wait and retry" to reduce failed jobs.
Pattern: Idempotency with Locks
Locks reduce concurrency, but they do not remove all possible double work. For example:
- If the lock TTL expires just before you finish, another process may also do some work.
To reduce harm:
- Design operations to be idempotent, which means performing them multiple times has the same effect as performing them once.
- For example, instead of "add 10 to balance", you might "set balance to computed value from transactions".
- Or use transaction IDs and only apply each ID once.
Locks and idempotency are often used together for robustness.
Limitations and Failure Scenarios
Even with a correct lock implementation, some limitations remain:
- Clock assumptions
- Many lock algorithms assume some bound on delays and that TTL is larger than worst case processing plus drift.
- Redis availability
- If Redis is down, you may not be able to acquire or release locks.
- Decide if your system should fail closed (block operations) or fail open (proceed without locks) in that case.
- Network partitions
- If the network splits, some nodes may not reach Redis and see outdated lock state.
- In high consistency scenarios, this can be critical.
For a basic backend:
- Use Redis locks for practical race condition prevention.
- Be aware they are not mathematically perfect.
- Combine with good data design and database constraints where necessary.
Example:
- You can protect inventory updates with Redis locks, and also use a database
CHECKor a transaction that prevents inventory from going negative. - The database remains the final authority.
Summary
- Distributed locks coordinate access to shared resources across multiple nodes.
- Redis supports locks through atomic
SET key value NX EX ttloperations. - Use a unique lock value to identify the lock owner.
- Always set a TTL on locks to avoid deadlocks.
- Release locks safely by comparing the stored value and deleting the key only if you still own it, usually with a Lua script.
- You can implement single-instance jobs, per-resource locks, and retry strategies with Redis.
- For long tasks, consider lock renewal or use generous TTLs.
- Redis locks are practical tools for many backend scenarios, but must be combined with good data design and, where needed, database level guarantees.
Views: 8
KAHIBARO