Distributed Locking with Redis
Ever tried booking the last ticket for a hot new movie, only to find out someone else got it too? This happens when multiple people try to access a resource like a ticket (seat to be more specific) at the same time. In tech talk, we call this a problem of handling ‘concurrent processes’.
In this blog post, we’ll talk about a concept called ‘Distributed Locking’. This ensures only one request (in our example a user) can access a resource at a time, avoiding confusion and mistakes.
We’ll look at how Redis helps with achieving distributed locking. Redis serves as an in-memory data structure store, giving your application access to a high-performance persistence environment that allows for quick storing, retrieving, and manipulating of data structures.
We’ll use the example of a movie booking app to explain this. Imagine if two people tried to book the last seat at the same time. Without distributed locking, both could get a booking confirmation. Oops!
We’ll see how to implement a distributed locking strategy with Redis to stop this from happening, making sure only one booking goes through. This keeps things fair and smooth for everyone using the app.
Ready to learn more? Let’s dive in and see how Redis can help make your app better and your users happier.
Distributed Locking
Distributed locking is a way to control who gets to use shared stuff in a network of computers, called a distributed system. This method makes sure that when many processes want to use the same thing at the same time, they don’t mess things up.
Think about several transactions trying to change the same data in a database. Without control, they could mess up the data. But a distributed lock stops this. It lets only one transaction make changes at a time. Others have to wait their turn. This way, we keep everything organized and avoid mistakes.
Why Redis?
Redis stands out as an excellent choice for implementing distributed locks because of its performance and feature set. Its in-memory data structure store offers extremely fast read and writes operations, which is crucial when managing locks in real-time.
Here’s a simplified comparison of Redis vs etcd which looks at some of the features required for an ideal distributed locking mechanism:
Both Redis and etcd are powerful tools, and they each have their strengths and weaknesses. The best tool for you will depend on your specific use case and requirements. Since movie booking services require low latency response times, we are going with Redis for now.
Building a movie ticket booking app
Inital Building Blocks
Next, we’ll use FastAPI to create some ticket booking endpoints. This will help us show how Distributed Locking works in action.
But before we jump into that, let’s get our Python project ready. Here’s what we need in our requirements.txt
file:
requirements.txt
fastapi
uvicorn
redis
python-dotenv
We’re going to set up data models for both Movie and Bookings, which will store the film details and seat arrangements. FastAPI pairs well with pydantic, a tool that speeds up our service development with type hinting. Plus, it automatically generates API documentation for us, making our work much easier.
models.py
from typing import Dict
from pydantic import BaseModel
class Movie(BaseModel):
name: str
id: str
seat_map: Dict[str, bool] # Dictionary to track seat availability
class Booking(BaseModel):
movie: str
seat_id: str
Great! Now that we’ve set up the data model, it’s time to create the Redis client. We’ll use this for establishing locks and saving our bookings.
redis_client.py
import os
import redis
# Get Redis connection details from environment variables
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
# Connect to Redis
redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT)
# Constants
LOCK_EXPIRATION_TIME = 300 # 5 minutes
You can tweak the LOCK_EXPIRATION_TIME to control how long a resource is kept off-limits to others. After a period of 5 minutes, Redis will clear the lock from its memory, making the resource available for use again.
Next, we’re going to put together some helper functions for Redis. These functions will be our tools for setting and removing locks on a specific resource. For our movie seat example, we’ll use a unique ‘seat_id’ to identify which seat we’re locking.
utils.py
from fastapi import HTTPException
from redis_client import redis_client, LOCK_EXPIRATION_TIME
def lock_seat(seat: str) -> None:
# Generate the key for the seat lock
key = f"seat:{seat}"
# Try to acquire the lock
acquired = redis_client.set(key, "locked", ex=LOCK_EXPIRATION_TIME, nx=True)
if not acquired:
raise HTTPException(status_code=409, detail="Seat already locked")
def unlock_seat(seat: str) -> None:
# Generate the key for the seat lock
key = f"seat:{seat}"
# Release the lock
redis_client.delete(key)
The lock_seat
function scans for existing locks associated with a particular seat_id
. If it doesn't find any, it moves forward and sets a lock, which lasts for a 5-minute duration, as we've set up in the redis_client.py
file.
The unlock_seat
function simply removes the lock on a given resource.
Bootstrap Redis with initial data
With our helper functions and data models set up and ready to go, it’s time to populate some initial data. We’ll do this by utilizing the on startup event decorator provided by FastAPI.
main.py
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from typing import Dict
from models import Movie, Booking
from redis_client import redis_client
from utils import lock_seat, unlock_seat
app = FastAPI(name="Movie Booking Service", version="1.0.0")
movie = Movie(id="avengers_endgame", name="Avengers Endgame",
seat_map={"A1": True, "A2": True, "B1": True, "B2": True})
@app.on_event("startup")
async def startup_event():
# Set initial seat availability
for seat in movie.seat_map.keys():
booking_key = f"booking:{seat}"
if not redis_client.exists(booking_key):
redis_client.hset(f"movie:{movie.id}", seat, "True")
The startup_event method is triggered on startup of the FastAPI application and adds seat level availability status to Redis. Here we are using hset instead of set to leverage the Redis Hashes, which is an efficient way to store complex data structures like key value pairs.
Building Endpoints for Booking
In the diagram below, I’ve outlined the flow of our ticket booking process. We’re going to focus on constructing three key endpoints:
- Fetch Booking Availability
- Initiate Booking
- Confirm Booking
main.py
@app.get("/api/v1/movies/availability")
def get_seat_availability() -> Dict[str, bool]:
# Retrieve all movie seat availability from Redis
movies = redis_client.keys("movie:*")
seat_availability = {}
# Iterate through each movie and extract seat availability
for movie in movies:
movie_data = redis_client.hgetall(movie)
seat_map = {}
# Check booking and lock details for each seat
for seat, availability in movie_data.items():
seat = seat.decode("utf-8")
booking_key = f"booking:{seat}"
lock_key = f"seat:{seat}"
if redis_client.exists(booking_key) or redis_client.exists(lock_key):
seat_map[seat] = False # Seat is booked or locked
else:
seat_map[seat] = True # Seat is available
seat_availability = seat_map
return seat_availability
The get_seat_availability method fetches all the movie seat data stored in Redis, then checks if each seat is booked or locked. If the seat is either booked or locked, it is marked as unavailable (False); otherwise, it is available (True). The endpoint finally returns a map of seat availability.
main.py
@app.post("/api/v1/movies/book/{seat_id}")
async def book_seat(seat_id: str):
booking_key = f"booking:{seat_id}"
lock_key = f"seat:{seat_id}"
if redis_client.exists(booking_key) or redis_client.exists(lock_key):
raise HTTPException(status_code=409, detail="Seat already booked / locked")
lock_seat(seat_id)
return JSONResponse(status_code=200, content={"message": "Seat locked"})
In the code snippet above, we are defining an API endpoint for booking a movie seat. Upon receiving a request, it first checks whether the requested seat is either already booked or currently locked by another process.
If either condition is true, an HTTP exception is thrown. If not, it proceeds to lock the seat and sends a response indicating that the seat has been successfully locked.
Note: Typically, it’s a good idea to keep booking details in a long-term storage system, such as an SQL or NoSQL database. However, to keep things simple in our blog, we’ll use Redis for two purposes: storing booking information and acting as our distributed locking store.
main.py
@app.post("/api/v1/movies/book/{seat_id}/action/{action}")
async def confirm_booking(seat_id: str, action: str):
if action == "success":
movie_data = redis_client.hgetall(f"movie:{movie.id}")
# Update seat availability in movie data
movie_data[seat_id] = "False"
# Save updated movie data to Redis
redis_client.hmset(f"movie:{movie.id}", movie_data)
# Save booking details and unlock the seat
booking = Booking(movie=movie.id, seat_id=seat_id)
booking_key = f"booking:{seat_id}"
redis_client.hmset(booking_key, booking.dict())
unlock_seat(seat_id)
return JSONResponse(status_code=200, content={"message": "Booking successful. Lock released"})
elif action == "failure":
# Unlock the seat
unlock_seat(seat_id)
return JSONResponse(status_code=200, content={"message": "Booking failed. Lock released"})
else:
raise HTTPException(status_code=400, detail="Invalid action")
The process_booking method defines an API endpoint for confirming a movie seat. When a booking is successful, it updates the seat availability in the movie data stored in Redis, saves the booking details, and releases the lock on the seat. If the booking fails, it simply releases the lock. Any other action results in an error.
Final Steps for running the service
Hurray ! we have all the endpoints designed and ready to test the distributed locking functionality.
Let’s create a Dockerfile & docker-compose.yml files to run the application and test the endpoints.
Dockerfile
FROM python:3.9-alpine
WORKDIR /app
COPY requirements.txt .
RUN apk add --no-cache gcc musl-dev \
&& pip install --no-cache-dir -r requirements.txt \
&& apk del gcc musl-dev
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
docker-compose.yml
version: "3.8"
services:
redis:
image: redis:latest
ports:
- "6379:6379"
volumes:
- redis_data:/data
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:8000"
depends_on:
- redis
environment:
- REDIS_HOST=redis
- REDIS_PORT=6379
volumes:
redis_data:
The docker-compose file has a Redis container with persistant volume as well as our FastAPI based booking application.
The below command can be used to start the Redis & booking-app containers.
docker compose up -d
Once the containers are ip and running, you should be able to access the FastAPI Swagger documentation at this URL: http://0.0.0.0:8000/docs
By this time, the FastAPI server would have added the initial movie data to Redis Store. Select the first endpoint /api/v1/movies/availability
upon clicking the Try it out & Execute buttons, you should be able to get the response as shown below
Start booking process
Start booking for one of the seats using the seat_id A1/A2/B1/B2 in the endpoint /api/v1/movies/book/{seat_id}
This will create a lock on given seat_id A1 in redis store. Try fetching the /api/v1/movies/availability
endpoint again now and it should show False flag for seat A1.
Let’s look at Redis as well to make sure a lock is created for seat A1. The TTL ensures the lock is removed after 5 minutes and the seat is freed up for booking again.
Confirm booking
To ensure the seat is not available for any other user, we need to confirm the booking. This stores the booking details in redis and removes the lock.
Calling the endpoint /api/v1/movies/book/{seat_id}/action/{action}
with seat_id=A1 and action=success as parameters, the booking is confirmed and stored in Redis.
Let’s look at Redis to confirm the booking object is created and lock is removed.
The movie:avengers_endgame Hash is updated with the status False for seat A1 post adding the Booking object.
Try using the /api/v1/movies/availability
endpoint again and it should show False flag for seat A1 as the seat is booked now instead of a having a lock.
Closing Thoughts
In conclusion, distributed locking with Redis proves to be an invaluable technique for managing shared resources and ensuring data integrity in distributed systems.
The blog discussed a very simple implementation of the Distributed Locking with Redis, but when it comes to a production systems, it requires careful consideration of factors such as fault tolerance, scalability, and handling edge cases like network partitions and lock timeouts. Always ensure to thoroughly evaluate and address these aspects to create a robust and reliable distributed locking solution.
The code for this blogpost can be accessed here in my Github Repository :
https://github.com/saivarunk/distributed-systems-blog/tree/main/movie-booking
If you enjoyed exploring distributed locking with Redis, stay tuned for more insightful content to enhance your understanding of distributed systems.