Hands-on Preventing Database Race Conditions with Redis
Building simple concurrent ticket bookings system with Redis locks, Go, and load testing k6
Have you ever tried booking concert tickets online during a big sale? If you have, you might have experienced that nerve-wracking moment when you click “Buy” and pray you don’t get an error message. As a backend developer, I recently faced the challenge of building a reliable ticket booking system, and I want to share what I learned about preventing duplicate orders.
This problem is more common than you might think. From e-commerce inventory management to airline reservation systems, any application that deals with limited resources needs to handle multiple users trying to access the same data simultaneously. Getting this wrong can lead to oversold events, angry customers, and potential revenue loss. In fact, a major ticketing platform recently made headlines when it accidentally oversold thousands of tickets for a popular concert, leading to a PR nightmare and costly refunds.
The good news is that we can prevent these issues using distributed locking with Redis. In this article, we’ll explore how to build a robust ticket booking system that can handle high-concurrency situations without breaking a sweat. We’ll use Go, Redis, and PostgreSQL, but the concepts apply to any stack you’re working with.
Understanding the Challenge
Let’s use a real-world analogy that everyone can relate to. Imagine you’re at a coffee shop during rush hour. There are only two muffins left, and three customers try to order them simultaneously. Without a proper system, the cashiers might each think there are enough muffins and accept all three orders — leading to one disappointed customer.
This is exactly what happens in our digital systems when we don’t handle concurrent requests properly. In database terms, we call this a “race condition” — when multiple processes compete to modify the same data simultaneously.
The Problem: Race Conditions
A race condition occurs when multiple processes try to access and modify the same data simultaneously. Let’s break this down with a simple example:
Imagine our ticket system works like this:
- Check if tickets are available
- Create an order
- Reduce available tickets
- Process payment
Without proper protection, here’s what could happen when two users (let’s call them Alice and Bob) try to book the last ticket at the same time:
Time Alice Bob
0.001 Checks: 1 ticket available Checks: 1 ticket available
0.002 Creates order Creates order
0.003 Reduces count to 0 Reduces count to -1
0.004 Processes payment Processes payment
Both Alice and Bob think they got the last ticket! This is a race condition in action.
Solution: Distributed Locking with Redis
Instead of relying solely on database transactions, we’ll use Redis as a distributed lock. Think of it like putting a “hold” sign on a ticket while someone is in the process of buying it. Here’s how it works:
- When a user tries to book tickets, we first acquire a lock in Redis
- If we get the lock, we proceed with the booking
- If we can’t get the lock, we tell the user to try again
- After the booking is complete (or fails), we release the lock
Implementation and Testing
Before we dive into the code, let’s look at how major ticketing systems handle this:
- Queue Systems: Companies like Ticketmaster use virtual waiting rooms
- Temporary Holds: When you select a ticket, it’s held for a few minutes
- Distributed Locking: Multiple servers coordinate to prevent conflicts
For our example, we’ll implement a simpler but effective solution using Redis as a distributed lock.
I’ve created a simple ticket booking API using Go and Redis to demonstrate both the problem and solution. Let’s look at two implementations and test them under load.
Simple Golang API Booking Ticket
Run this sql query in database concert_example
. I’m using PostgreSQL
-- Create necessary tables for the ticket booking system
CREATE TABLE events (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
event_date TIMESTAMP WITH TIME ZONE NOT NULL,
total_tickets INTEGER NOT NULL,
available_tickets INTEGER NOT NULL,
ticket_price DECIMAL(10,2) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE bookings (
id SERIAL PRIMARY KEY,
event_id INTEGER REFERENCES events(id),
user_email VARCHAR(255) NOT NULL,
quantity INTEGER NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
booking_status VARCHAR(50) NOT NULL DEFAULT 'pending', -- pending, confirmed, failed
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create indexes for better performance
CREATE INDEX idx_bookings_event_id ON bookings(event_id);
CREATE INDEX idx_bookings_user_email ON bookings(user_email);
CREATE INDEX idx_bookings_status ON bookings(booking_status);
-- Insert sample event data
INSERT INTO events (name, description, event_date, total_tickets, available_tickets, ticket_price)
VALUES
('Taylor Swift Concert',
'The Eras Tour - Live in Concert',
'2024-12-01 19:00:00+00',
1000,
10,
299.99),
('Ed Sheeran World Tour',
'Mathematics Tour - Live in Concert',
'2024-11-15 20:00:00+00',
800,
10,
199.99),
('Coldplay Concert',
'Music of the Spheres World Tour',
'2024-10-30 19:30:00+00',
1200,
10,
249.99);
-- Create a function to update available tickets
CREATE OR REPLACE FUNCTION update_available_tickets()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE events
SET available_tickets = available_tickets - NEW.quantity
WHERE id = NEW.event_id;
ELSIF TG_OP = 'UPDATE' THEN
IF OLD.booking_status != NEW.booking_status AND NEW.booking_status = 'failed' THEN
UPDATE events
SET available_tickets = available_tickets + OLD.quantity
WHERE id = OLD.event_id;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create trigger for booking status changes
CREATE TRIGGER booking_status_change
AFTER INSERT OR UPDATE ON bookings
FOR EACH ROW
EXECUTE FUNCTION update_available_tickets();
Create golang project and copy this code
// main.go
package main
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
_ "github.com/lib/pq"
)
type Event struct {
ID int `json:"id"`
Name string `json:"name"`
AvailableTickets int `json:"available_tickets"`
TicketPrice float64 `json:"ticket_price"`
EventDate time.Time `json:"event_date"`
}
type BookingRequest struct {
EventID int `json:"event_id"`
UserEmail string `json:"user_email"`
Quantity int `json:"quantity"`
}
type TicketSystem struct {
db *sql.DB
redisClient *redis.Client
}
// Initialize the ticket system
func NewTicketSystem() (*TicketSystem, error) {
connStr := "postgresql://postgres:postgres@localhost:5432/concert_example?sslmode=disable"
db, err := sql.Open("postgres", connStr)
if err != nil {
return nil, fmt.Errorf("error connecting to database: %v", err)
}
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
return &TicketSystem{
db: db,
redisClient: rdb,
}, nil
}
// UNSAFE VERSION - Demonstrates race condition problems
// This version doesn't use distributed locking and can lead to overselling
func (ts *TicketSystem) BookTicketUnsafe(c *gin.Context) {
var req BookingRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
// Start transaction
tx, err := ts.db.BeginTx(context.Background(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Transaction start failed"})
return
}
defer tx.Rollback()
// Check ticket availability - BUT this check isn't properly protected!
var availableTickets int
var ticketPrice float64
err = tx.QueryRow(`
SELECT available_tickets, ticket_price
FROM events
WHERE id = $1`, req.EventID).Scan(&availableTickets, &ticketPrice)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
return
}
// Simulate some processing time to make race conditions more likely
time.Sleep(100 * time.Millisecond)
if availableTickets < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Not enough tickets available"})
return
}
// Calculate total amount
totalAmount := ticketPrice * float64(req.Quantity)
// Create booking - This might create duplicate bookings!
_, err = tx.Exec(`
INSERT INTO bookings (event_id, user_email, quantity, total_amount, booking_status)
VALUES ($1, $2, $3, $4, 'confirmed')`,
req.EventID, req.UserEmail, req.Quantity, totalAmount)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Booking creation failed"})
return
}
// Commit transaction
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Transaction commit failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Booking confirmed (UNSAFE VERSION - may have race conditions)",
"total_amount": totalAmount,
"remaining_tickets": availableTickets - req.Quantity,
})
}
// SAFE VERSION - Uses proper distributed locking
// This version prevents race conditions and overselling
func (ts *TicketSystem) BookTicketSafe(c *gin.Context) {
var req BookingRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
ctx := context.Background()
// Attempt to acquire distributed lock
lockKey := fmt.Sprintf("event_lock:%d", req.EventID)
locked, err := ts.redisClient.SetNX(ctx, lockKey, "locked", 5*time.Second).Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Lock acquisition failed"})
return
}
if !locked {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "System is busy, please try again"})
return
}
defer ts.redisClient.Del(ctx, lockKey)
// Start transaction with proper isolation level
tx, err := ts.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Transaction start failed"})
return
}
defer tx.Rollback()
// Check ticket availability with FOR UPDATE lock
var availableTickets int
var ticketPrice float64
err = tx.QueryRow(`
SELECT available_tickets, ticket_price
FROM events
WHERE id = $1
FOR UPDATE`, req.EventID).Scan(&availableTickets, &ticketPrice)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Event not found"})
return
}
// Simulate same processing time as unsafe version
time.Sleep(100 * time.Millisecond)
if availableTickets < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Not enough tickets available"})
return
}
// Calculate total amount
totalAmount := ticketPrice * float64(req.Quantity)
// Create booking with proper locking
_, err = tx.Exec(`
INSERT INTO bookings (event_id, user_email, quantity, total_amount, booking_status)
VALUES ($1, $2, $3, $4, 'confirmed')`,
req.EventID, req.UserEmail, req.Quantity, totalAmount)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Booking creation failed"})
return
}
// Commit transaction
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Transaction commit failed"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Booking confirmed",
"total_amount": totalAmount,
"remaining_tickets": availableTickets - req.Quantity,
})
}
func main() {
ts, err := NewTicketSystem()
if err != nil {
log.Fatal(err)
}
r := gin.Default()
// Two different endpoints to demonstrate the difference
r.POST("/book/unsafe", ts.BookTicketUnsafe) // Demonstrates problems with race conditions
r.POST("/book/safe", ts.BookTicketSafe) // Shows proper way to handle concurrent bookings
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
log.Fatal(r.Run(":8080"))
}
Our API has two endpoints:
/book/unsafe
: Books tickets without any locking mechanism/book/safe
: Uses Redis for distributed locking
The code manages a simple ticket inventory and simulates a booking process. The key difference is that the safe version uses Redis to ensure only one booking can happen at a time.
Unsafe Endpoint
- Uses basic database transactions but no distributed locking
- Doesn’t use
FOR UPDATE
in the SELECT query - Can lead to race conditions and overselling
- Simulates processing time to make race conditions more likely
- Doesn’t handle concurrent access properly
Safe Endpoint
- Uses Redis for distributed locking
- Uses proper database transaction isolation level
- Uses
FOR UPDATE
lock in the SELECT query - Prevents overselling by properly handling concurrent access
- Returns 429 status when the system is busy
Create testing with k6
If you want to test safe endpoint, please uncomment it
// booking_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
// Simple test configuration with 20 virtual users
export const options = {
vus: 20, // 20 virtual users
duration: '20s', // Duration of the test
};
// Test scenario
export default function () {
// Create test data
const payload = JSON.stringify({
event_id: 1,
user_email: `@example.com">user${__VU}@example.com`, // __VU gives us the current virtual user number
quantity: 1
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
// -- Test unsafe endpoint
const unsafeResponse = http.post('http://localhost:8080/book/unsafe', payload, params);
check(unsafeResponse, {
'unsafe booking successful': (r) => r.status === 200,
});
sleep(1); // Wait 1 second between requests
// -- Test safe endpoint
// const safeResponse = http.post('http://localhost:8080/book/safe', payload, params);
// check(safeResponse, {
// 'safe booking successful': (r) => r.status === 200 || r.status === 429,
// });
// sleep(1); // Wait 1 second between requests
// Log any errors
if (unsafeResponse.status !== 200) {
console.log(`Unsafe booking failed: ${unsafeResponse.body}`);
}
// if (safeResponse.status !== 200 && safeResponse.status !== 429) {
// console.log(`Safe booking failed: ${safeResponse.body}`);
// }
}
Initial Data
There are only 10 available tickets in Taylor Swift Concert, we will simulate 20 concurrent order with k6 testing.
Running test unsafe booking
After running testing unsafe endpoint, all of request bypass quota of 10 ticket. So, now available tickets become minus -10. This is wrong!
Running test safe booking
If you want to test safe endpoint, please uncomment in booking_test.js
Don’t forget to set available_tickets
back to 10.
Conclusion
Building a reliable ticket booking system taught me several important lessons:
- Always test your system under concurrent load
- Use proper locking mechanisms when dealing with finite resources
- Sometimes it’s better to tell a user to try again than to risk data inconsistency
- Real-world systems need multiple layers of protection
While our example used Redis for locking, remember that larger systems might need additional mechanisms like queues and temporary holds. The key is understanding your specific requirements and choosing the right tools for the job.
The code examples in this article are simplified for learning purposes. In a production system, you’d also need to handle:
- Transaction rollbacks
- Lock timeouts
- Payment processing
- Error reporting
- Monitoring and alerts
Remember: in ticket booking systems, it’s better to occasionally tell a user “please try again” than to deal with the aftermath of duplicate orders and oversold inventory.
If you’re interested in diving deeper into system design and backend development, be sure to follow me for more insights, tips, and practical examples. Together, we can explore the intricacies of creating efficient systems, optimizing database performance, and mastering the tools that drive modern applications. Join me on this journey to enhance your skills and stay updated on the latest trends in the tech world! 🚀
Read the design system in bahasa on iniakunhuda.com