Ти коли-небудь намагався забронювати квитки на концерт онлайн під час великої розпродажі? Якщо так, ти, напевно, переживав той нервовий момент, коли натискаєш "Купити" і сподіваєшся, що не отримаєш повідомлення про помилку. Як бекенд-розробник, я нещодавно зіткнувся з викликом створення надійної системи бронювання квитків, і хочу поділитися тим, що я дізнався про запобігання дублювання замовлень.
Ця проблема трапляється частіше, ніж ти можеш подумати. Від управління інвентарем в електронній комерції до систем бронювання авіаквитків, будь-яка програма, яка працює з обмеженими ресурсами, повинна враховувати одночасний доступ кількох користувачів до тих самих даних. Невірна реалізація може призвести до перепродажу квитків, роздратованих клієнтів і потенційних фінансових втрат. Насправді, одна з великих платформ бронювання квитків нещодавно потрапила в новини, коли випадково перепродала тисячі квитків на популярний концерт, що призвело до публічного скандалу та витрат на повернення коштів.
Добра новина полягає в тому, що ми можемо уникнути цих проблем, використовуючи розподілене блокування за допомогою Redis. У цій статті ми розглянемо, як створити надійну систему бронювання квитків, яка зможе впоратися з ситуаціями високої конкуренції, не ламаючись. Ми будемо використовувати Go, Redis та PostgreSQL, але ці концепції застосовні до будь-якого стеку, з яким ти працюєш.
Розуміння проблеми
Давайте використаємо аналогію з реального світу, з якою може зустрітися кожен. Уяви, що ти в кав'ярні під час пік часу. Залишилося тільки два мафіни, а три клієнти намагаються замовити їх одночасно. Без належної системи касири можуть подумати, що мафінів достатньо, і прийняти всі три замовлення — в результаті один клієнт залишиться розчарованим.
Саме так і трапляється в наших цифрових системах, коли ми неправильно обробляємо одночасні запити. В термінах бази даних це називається "умовою гонки" — коли кілька процесів одночасно намагаються змінити ті самі дані.
Проблема: Умови гонки
Умова гонки виникає, коли кілька процесів намагаються одночасно отримати доступ і змінити ті самі дані. Давайте розглянемо це на простому прикладі:
Уявімо, що наша система бронювання квитків працює так:
- Перевіряє, чи є квитки
- Створює замовлення
- Зменшує кількість доступних квитків
- Обробляє платіж
Без належного захисту ось що може статися, коли два користувачі (назвемо їх Аліса та Боб) намагаються забронювати останній квиток одночасно:
Час Аліса Боб
0.001 Перевіряє: 1 квиток доступний Перевіряє: 1 квиток доступний
0.002 Створює замовлення Створює замовлення
0.003 Зменшує кількість до 0 Зменшує кількість до -1
0.004 Обробляє платіж Обробляє платіж
І Аліса, і Боб думають, що отримали останній квиток! Це і є умова гонки в дії.
Рішення: Розподілене блокування за допомогою Redis
Замість того, щоб покладатися лише на транзакції бази даних, ми використовуватимемо Redis як розподілений замок. Уяви, що це як повісити табличку "Заброньовано" на квиток, поки хтось його купує. Ось як це працює:
- Коли користувач намагається забронювати квитки, ми спочатку отримуємо замок у Redis
- Якщо замок отримано, ми продовжуємо процес бронювання
- Якщо замок не вдалося отримати, ми кажемо користувачу спробувати ще раз
- Після завершення (або невдачі) бронювання ми звільняємо замок
Схема потоку (mermaid)
Реалізація та тестування
Перед тим як перейти до коду, давайте подивимось, як основні системи бронювання квитків вирішують цю проблему:
- Черги: Компанії, такі як Ticketmaster, використовують віртуальні черги
- Тимчасове утримання: Коли ти вибираєш квиток, його утримують на кілька хвилин
- Розподілене блокування: Кілька серверів координують свої дії, щоб запобігти конфліктам
Для нашого прикладу ми реалізуємо більш просте, але ефективне рішення, використовуючи Redis як розподілений замок.
Я створив просте API для бронювання квитків за допомогою Go та Redis, щоб продемонструвати як проблему, так і рішення. Давайте подивимося на дві реалізації та протестуємо їх під навантаженням.
Проста API для бронювання квитків на Go
Запусти цей SQL запит в базі даних concert_example
.
Я використовую PostgreSQL
-- Створення необхідних таблиць для системи бронювання квитків
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 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 INTO events (name, description, event_date, total_tickets, available_tickets, ticket_price)
VALUES
('Концерт Тейлор Свіфт',
'Тур Ери - Живий концерт',
'2024-12-01 19:00:00+00',
1000,
10,
299.99),
('Світовий тур Еда Ширана',
'Тур Математика - Живий концерт',
'2024-11-15 20:00:00+00',
800,
10,
199.99),
('Концерт Coldplay',
'Музика світів Світовий тур',
'2024-10-30 19:30:00+00',
1200,
10,
249.99);
-- Створення функції для оновлення доступних квитків
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 booking_status_change
AFTER INSERT OR UPDATE ON bookings
FOR EACH ROW
EXECUTE FUNCTION update_available_tickets();
Створити проект Golang і скопіювати цей код
// 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
}
// Ініціалізація системи бронювання квитків
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("помилка підключення до бази даних: %v", err)
}
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
return &TicketSystem{
db: db,
redisClient: rdb,
}, nil
}
// НЕБЕЗПЕЧНА ВЕРСІЯ - Демонструє проблеми умов гонки
// Ця версія не використовує розподілене блокування і може призвести до перепродажу
func (ts *TicketSystem) BookTicketUnsafe(c *gin.Context) {
var req BookingRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Невірний запит"})
return
}
// Почати транзакцію
tx, err := ts.db.BeginTx(context.Background(), nil)
if err != nil {
Ви коли-небудь намагались забронювати квитки на концерт онлайн під час великої розпродажу? Якщо так, то, мабуть, ви переживали той нервуючий момент, коли натискаєте "Купити" і сподіваєтесь, що не отримаєте повідомлення про помилку. Як бекенд-розробник, я нещодавно зіткнувся з викликом створення надійної системи бронювання квитків, і хочу поділитися тим, що я навчився про запобігання дублювання замовлень.
Ця проблема зустрічається частіше, ніж ви думаєте. Від управління інвентарем в електронній комерції до систем бронювання авіаквитків, будь-яка програма, яка працює з обмеженими ресурсами, повинна враховувати кілька користувачів, які одночасно намагаються отримати доступ до тих самих даних. Якщо це зробити неправильно, можуть виникнути ситуації з перепродажем квитків, незадоволені користувачі та втрати доходів. Насправді, велика платформа для продажу квитків нещодавно потрапила в заголовки новин, коли випадково перепродала тисячі квитків на популярний концерт, що призвело до PR-кошмару і дорогих відшкодувань.
Добра новина в тому, що ми можемо уникнути цих проблем за допомогою розподіленого блокування за допомогою Redis. У цій статті ми розглянемо, як побудувати надійну систему бронювання квитків, яка зможе обробляти ситуації з високою конкуренцією, не створюючи додаткових проблем. Ми будемо використовувати Go, Redis і PostgreSQL, але ці концепції можна застосувати до будь-якого стеку, з яким ви працюєте.
## Розуміння проблеми
Давайте скористаємося реальним аналогієм, з яким кожен може пов'язати себе. Уявіть, що ви в кав'ярні під час години пік. Є тільки два мафіни, і троє клієнтів намагаються їх замовити одночасно. Без належної системи касири можуть подумати, що мафінів достатньо, і прийняти всі три замовлення — в результаті один клієнт залишиться без мафіна.
Це саме те, що трапляється в наших цифрових системах, коли ми неправильно обробляємо одночасні запити. У термінах бази даних ми називаємо це "умовою гонки" (race condition) — коли кілька процесів одночасно намагаються змінювати одні й ті ж дані.
## Проблема: Умови гонки (Race Conditions)
Умова гонки виникає, коли кілька процесів одночасно намагаються отримати доступ до одних і тих самих даних і змінити їх. Давайте розберемо це на простому прикладі:
Уявіть, що наша система бронювання працює таким чином:
1. Перевірити наявність квитків
2. Створити замовлення
3. Зменшити кількість доступних квитків
4. Обробити платіж
Без належного захисту ось що може статися, коли два користувачі (назвемо їх Аліса і Боб) намагаються забронювати останній квиток одночасно:
Час Аліса Боб
0.001 Перевірка: 1 квиток доступний Перевірка: 1 квиток доступний
0.002 Створює замовлення Створює замовлення
0.003 Зменшує кількість до 0 Зменшує кількість до -1
0.004 Обробляє платіж Обробляє платіж
```
І Аліса, і Боб вважають, що отримали останній квиток! Це і є умова гонки в дії.
Рішення: Розподілене блокування за допомогою Redis
Замість того, щоб покладатися лише на транзакції в базі даних, ми використовуємо Redis як розподілене блокування. Уявіть це як покласти табличку "Зарезервовано" на квиток, поки хтось його купує. Ось як це працює:
- Коли користувач намагається забронювати квитки, спочатку ми отримуємо блокування в Redis
- Якщо ми отримуємо блокування, продовжуємо бронювання
- Якщо не можемо отримати блокування, повідомляємо користувача спробувати ще раз
- Після того, як бронювання завершено (або не вдалося), ми звільняємо блокування
Діаграма потоку (mermaid)
Реалізація та тестування
Перш ніж приступити до коду, давайте подивимося, як великі системи бронювання квитків обробляють цю проблему:
- Чергові системи: Компанії, як Ticketmaster, використовують віртуальні кімнати очікування
- Тимчасові резерви: Коли ви вибираєте квиток, його резервують на кілька хвилин
- Розподілене блокування: Кілька серверів координуються для запобігання конфліктів
Для нашого прикладу ми реалізуємо простіше, але ефективне рішення, використовуючи Redis як розподілене блокування.
Я створив простий API для бронювання квитків за допомогою Go та Redis, щоб продемонструвати як проблему, так і рішення. Давайте подивимося на два варіанти реалізації та протестуємо їх під навантаженням.
Простий API для бронювання квитків на Go
Запустіть цей SQL-запит у базі даних concert_example
.
Я використовую PostgreSQL
-- Створення необхідних таблиць для системи бронювання квитків
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 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 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 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 booking_status_change
AFTER INSERT OR UPDATE ON bookings
FOR EACH ROW
EXECUTE FUNCTION update_available_tickets();
Створення проекту на Golang та копіювання цього коду
// 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
}
// Ініціалізація системи бронювання квитків
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("помилка при підключенні до бази даних: %v", err)
}
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
return &TicketSystem{
db: db,
redisClient: rdb,
}, nil
}
// НЕБЕЗПЕЧНА ВЕРСІЯ - Демонстрація проблем з умовами гонки
// Ця версія не використовує розподілене блокування і може призвести до перепродажу квитків
func (ts *TicketSystem) BookTicketUnsafe(c *gin.Context) {
var req BookingRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Невірний запит"})
return
}
// Початок транзакції
tx, err := ts.db.BeginTx(context.Background(), nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не вдалося розпочати транзакцію"})
return
}
defer tx.Rollback()
// Перевірка доступності квитків - але ця перевірка не належним чином захищена!
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": "Подію не знайдено"})
return
}
// Симулюємо деякий час обробки, щоб умови гонки стали більш ймовірними
time.Sleep(100 * time.Millisecond)
if availableTickets < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Недостатньо доступних квитків"})
return
}
// Обчислення загальної суми
totalAmount := ticketPrice * float64(req.Quantity)
// Створення бронювання - це може призвести до дублювання бронювань!
_, 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": "Не вдалося створити бронювання"})
return
}
// Підтвердження транзакції
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не вдалося підтвердити транзакцію"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Бронювання підтверджено (НЕБЕЗПЕЧНА ВЕРСІЯ - можуть бути умови гонки)",
"total_amount": totalAmount,
"remaining_tickets": availableTickets - req.Quantity,
})
}
// БЕЗПЕЧНА ВЕРСІЯ - Використовує належне розподілене блокування
// Ця версія запобігає умовам гонки та перепродажу квитків
func (ts *TicketSystem) BookTicketSafe(c *gin.Context) {
var req BookingRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Невірний запит"})
return
}
ctx := context.Background()
// Спроба отримати розподілене блокування
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": "Не вдалося отримати блокування"})
return
}
if !locked {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Система зайнята, спробуйте пізніше"})
return
}
defer ts.redisClient.Del(ctx, lockKey)
// Початок транзакції з належним рівнем ізоляції
tx, err := ts.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не вдалося розпочати транзакцію"})
return
}
defer tx.Rollback()
// Перевірка доступності квитків з блокуванням FOR UPDATE
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": "Подію не знайдено"})
return
}
// Симулюємо такий самий час обробки, як у небезпечній версії
time.Sleep(100 * time.Millisecond)
if availableTickets < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Недостатньо доступних квитків"})
return
}
// Обчислення загальної суми
totalAmount := ticketPrice * float64(req.Quantity)
// Створення бронювання з належним блокуванням
_, 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": "Не вдалося створити бронювання"})
return
}
// Підтвердження транзакції
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не вдалося підтвердити транзакцію"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не вдалося розпочати транзакцію"})
return
}
defer tx.Rollback()
// Перевірка доступності квитків - але ця перевірка не належним чином захищена!
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": "Подію не знайдено"})
return
}
// Симулюємо деякий час обробки, щоб умови гонки стали більш ймовірними
time.Sleep(100 * time.Millisecond)
if availableTickets < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Недостатньо доступних квитків"})
return
}
// Обчислення загальної суми
totalAmount := ticketPrice * float64(req.Quantity)
// Створення бронювання - це може призвести до дублювання бронювань!
_, 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": "Не вдалося створити бронювання"})
return
}
// Підтвердження транзакції
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не вдалося підтвердити транзакцію"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Бронювання підтверджено (НЕБЕЗПЕЧНА ВЕРСІЯ - можуть бути умови гонки)",
"total_amount": totalAmount,
"remaining_tickets": availableTickets - req.Quantity,
})
}
// БЕЗПЕЧНА ВЕРСІЯ - Використовує належне розподілене блокування
// Ця версія запобігає умовам гонки та перепродажу квитків
func (ts *TicketSystem) BookTicketSafe(c *gin.Context) {
var req BookingRequest
if err := c.BindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Невірний запит"})
return
}
ctx := context.Background()
// Спроба отримати розподілене блокування
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": "Не вдалося отримати блокування"})
return
}
if !locked {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Система зайнята, спробуйте пізніше"})
return
}
defer ts.redisClient.Del(ctx, lockKey)
// Початок транзакції з належним рівнем ізоляції
tx, err := ts.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не вдалося розпочати транзакцію"})
return
}
defer tx.Rollback()
// Перевірка доступності квитків з блокуванням FOR UPDATE
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": "Подію не знайдено"})
return
}
// Симулюємо такий самий час обробки, як у небезпечній версії
time.Sleep(100 * time.Millisecond)
if availableTickets < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Недостатньо доступних квитків"})
return
}
// Обчислення загальної суми
totalAmount := ticketPrice * float64(req.Quantity)
// Створення бронювання з належним блокуванням
_, 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": "Не вдалося створити бронювання"})
return
}
// Підтвердження транзакції
if err = tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Не вдалося підтвердити транзакцію"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Бронювання підтверджено",
"total_amount": totalAmount,
"remaining_tickets": availableTickets - req.Quantity,
})
}
func main() {
ts, err := NewTicketSystem()
if err != nil {
log.Fatal(err)
}
r := gin.Default()
// Два різних кінцевих точки для демонстрації різниці
r.POST("/book/unsafe", ts.BookTicketUnsafe) // Демонструє проблеми з умовами гонки
r.POST("/book/safe", ts.BookTicketSafe) // Показує правильний спосіб обробки одночасних бронювань
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
log.Fatal(r.Run(":8080"))
}
Наша API має два кінцеві точки:
/book/unsafe
: Бронює квитки без будь-якого механізму блокування/book/safe
: Використовує Redis для розподіленого блокування
Цей код керує простою інвентаризацією квитків і симулює процес бронювання.
The key difference is that the safe version uses Redis to ensure only one booking can happen at a time.
Небезпечний кінцевий пункт
- Використовує базові транзакції бази даних, але без розподіленого блокування
- Не використовує
FOR UPDATE
в SELECT запиті - Може призвести до умов гонки та перевищення ліміту продажу
- Симулює час обробки, щоб умови гонки були більш ймовірними
- Не обробляє одночасний доступ належним чином
Безпечний кінцевий пункт
- Використовує Redis для розподіленого блокування
- Використовує правильний рівень ізоляції транзакцій бази даних
- Використовує блокування
FOR UPDATE
в SELECT запиті - Запобігає перевищенню ліміту продажу, правильно обробляючи одночасний доступ
- Повертає статус 429, коли система перевантажена
Тестування за допомогою k6
Якщо ви хочете протестувати безпечний кінцевий пункт, будь ласка, розкоментуйте його
// booking_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
// Проста конфігурація тесту з 20 віртуальними користувачами
export const options = {
vus: 20, // 20 віртуальних користувачів
duration: '20s', // Тривалість тесту
};
// Сценарій тесту
export default function () {
// Створити тестові дані
const payload = JSON.stringify({
event_id: 1,
user_email: `@example.com">user${__VU}@example.com`, // __VU дає номер поточного віртуального користувача
quantity: 1
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
// -- Тестуємо небезпечний кінцевий пункт
const unsafeResponse = http.post('http://localhost:8080/book/unsafe', payload, params);
check(unsafeResponse, {
'Небезпечне бронювання успішне': (r) => r.status === 200,
});
sleep(1); // Чекаємо 1 секунду між запитами
// -- Тестуємо безпечний кінцевий пункт
// const safeResponse = http.post('http://localhost:8080/book/safe', payload, params);
// check(safeResponse, {
// 'Безпечне бронювання успішне': (r) => r.status === 200 || r.status === 429,
// });
// sleep(1); // Чекаємо 1 секунду між запитами
// Логуємо помилки
if (unsafeResponse.status !== 200) {
console.log(`Небезпечне бронювання не вдалося: ${unsafeResponse.body}`);
}
// if (safeResponse.status !== 200 && safeResponse.status !== 429) {
// console.log(`Безпечне бронювання не вдалося: ${safeResponse.body}`);
// }
}
Ініціальні дані
У нас є лише 10 доступних квитків на концерт Taylor Swift, ми симулюємо 20 одночасних замовлень за допомогою тестування k6.
Таблиця подій
Запуск тесту небезпечного бронювання
Після запуску тестування небезпечного кінцевого пункту всі запити перевищили квоту в 10 квитків. Тепер доступні квитки стають мінус -10. Це неправильно!
Графана k6 тестування небезпечного бронювання
Помилка! Усі бронювання зафіксовані понад квоту
Доступні квитки стали мінусовими, перевищено квоту
Запуск тесту безпечного бронювання
Якщо ви хочете протестувати безпечний кінцевий пункт, будь ласка, розкоментуйте його в booking_test.js
. Не забудьте повернути available_tickets
назад до 10.
Графана k6 тестування безпечного бронювання
Усі бронювання зафіксовані, як квоти квитків
Доступні квитки не перевищили квоти
Висновок
Створення надійної системи бронювання квитків навчило мене кількох важливих уроків:
- Завжди тестуйте вашу систему під навантаженням з одночасними запитами
- Використовуйте правильні механізми блокування при роботі з обмеженими ресурсами
- Іноді краще сказати користувачу спробувати ще раз, ніж ризикувати несумісністю даних
- Реальні системи потребують багатьох рівнів захисту
Хоча в нашому прикладі використовувався Redis для блокування, пам'ятайте, що більші системи можуть потребувати додаткових механізмів, таких як черги та тимчасові утримання.
The key difference is that the safe version uses Redis to ensure only one booking can happen at a time.
Небезпечний кінцевий пункт
- Використовує базові транзакції бази даних, але без розподіленого блокування
- Не використовує
FOR UPDATE
в SELECT запиті - Може призвести до умов гонки та перевищення ліміту продажу
- Симулює час обробки, щоб умови гонки були більш ймовірними
- Не обробляє одночасний доступ належним чином
Безпечний кінцевий пункт
- Використовує Redis для розподіленого блокування
- Використовує правильний рівень ізоляції транзакцій бази даних
- Використовує блокування
FOR UPDATE
в SELECT запиті - Запобігає перевищенню ліміту продажу, правильно обробляючи одночасний доступ
- Повертає статус 429, коли система перевантажена
Тестування за допомогою k6
Якщо ви хочете протестувати безпечний кінцевий пункт, будь ласка, розкоментуйте його
// booking_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
// Проста конфігурація тесту з 20 віртуальними користувачами
export const options = {
vus: 20, // 20 віртуальних користувачів
duration: '20s', // Тривалість тесту
};
// Сценарій тесту
export default function () {
// Створити тестові дані
const payload = JSON.stringify({
event_id: 1,
user_email: `@example.com">user${__VU}@example.com`, // __VU дає номер поточного віртуального користувача
quantity: 1
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
// -- Тестуємо небезпечний кінцевий пункт
const unsafeResponse = http.post('http://localhost:8080/book/unsafe', payload, params);
check(unsafeResponse, {
'Небезпечне бронювання успішне': (r) => r.status === 200,
});
sleep(1); // Чекаємо 1 секунду між запитами
// -- Тестуємо безпечний кінцевий пункт
// const safeResponse = http.post('http://localhost:8080/book/safe', payload, params);
// check(safeResponse, {
// 'Безпечне бронювання успішне': (r) => r.status === 200 || r.status === 429,
// });
// sleep(1); // Чекаємо 1 секунду між запитами
// Логуємо помилки
if (unsafeResponse.status !== 200) {
console.log(`Небезпечне бронювання не вдалося: ${unsafeResponse.body}`);
}
// if (safeResponse.status !== 200 && safeResponse.status !== 429) {
// console.log(`Безпечне бронювання не вдалося: ${safeResponse.body}`);
// }
}
Ініціальні дані
У нас є лише 10 доступних квитків на концерт Taylor Swift, ми симулюємо 20 одночасних замовлень за допомогою тестування k6.
Таблиця подій
Запуск тесту небезпечного бронювання
Після запуску тестування небезпечного кінцевого пункту всі запити перевищили квоту в 10 квитків. Тепер доступні квитки стають мінус -10. Це неправильно!
Графана k6 тестування небезпечного бронювання
Помилка! Усі бронювання зафіксовані понад квоту
Доступні квитки стали мінусовими, перевищено квоту
Запуск тесту безпечного бронювання
Якщо ви хочете протестувати безпечний кінцевий пункт, будь ласка, розкоментуйте його в booking_test.js
. Не забудьте повернути available_tickets
назад до 10.
Графана k6 тестування безпечного бронювання
Усі бронювання зафіксовані, як квоти квитків
Доступні квитки не перевищили квоти
Висновок
Створення надійної системи бронювання квитків навчило мене кількох важливих уроків:
- Завжди тестуйте вашу систему під навантаженням з одночасними запитами
- Використовуйте правильні механізми блокування при роботі з обмеженими ресурсами
- Іноді краще сказати користувачу спробувати ще раз, ніж ризикувати несумісністю даних
- Реальні системи потребують багатьох рівнів захисту
Хоча в нашому прикладі використовувався Redis для блокування, пам'ятайте, що більші системи можуть потребувати додаткових механізмів, таких як черги та тимчасові утримання.
The key difference is that the safe version uses Redis to ensure only one booking can happen at a time.
Небезпечний кінцевий пункт
- Використовує базові транзакції бази даних, але без розподіленого блокування
- Не використовує
FOR UPDATE
в SELECT запиті - Може призвести до умов гонки (race conditions) та перевищення квоти продажу
- Симулює час обробки, щоб умови гонки були більш ймовірними
- Не обробляє одночасний доступ належним чином
Безпечний кінцевий пункт
- Використовує Redis для розподіленого блокування
- Використовує правильний рівень ізоляції транзакцій бази даних
- Використовує блокування
FOR UPDATE
в SELECT запиті - Запобігає перевищенню квоти продажу, правильно обробляючи одночасний доступ
- Повертає статус 429, коли система перевантажена
Створення тестів за допомогою k6
Якщо ви хочете протестувати безпечний кінцевий пункт, будь ласка, розкоментуйте його
// booking_test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
// Проста конфігурація тесту з 20 віртуальними користувачами
export const options = {
vus: 20, // 20 віртуальних користувачів
duration: '20s', // Тривалість тесту
};
// Сценарій тесту
export default function () {
// Створити тестові дані
const payload = JSON.stringify({
event_id: 1,
user_email: `@example.com">user${__VU}@example.com`, // __VU дає номер поточного віртуального користувача
quantity: 1
});
const params = {
headers: {
'Content-Type': 'application/json',
},
};
// -- Тестуємо небезпечний кінцевий пункт
const unsafeResponse = http.post('http://localhost:8080/book/unsafe', payload, params);
check(unsafeResponse, {
'Небезпечне бронювання успішне': (r) => r.status === 200,
});
sleep(1); // Чекаємо 1 секунду між запитами
// -- Тестуємо безпечний кінцевий пункт
// const safeResponse = http.post('http://localhost:8080/book/safe', payload, params);
// check(safeResponse, {
// 'Безпечне бронювання успішне': (r) => r.status === 200 || r.status === 429,
// });
// sleep(1); // Чекаємо 1 секунду між запитами
// Логуємо помилки
if (unsafeResponse.status !== 200) {
console.log(`Небезпечне бронювання не вдалося: ${unsafeResponse.body}`);
}
// if (safeResponse.status !== 200 && safeResponse.status !== 429) {
// console.log(`Безпечне бронювання не вдалося: ${safeResponse.body}`);
// }
}
Ініціальні дані
У нас є лише 10 доступних квитків на концерт Taylor Swift, ми симулюємо 20 одночасних замовлень за допомогою тестування k6.
Таблиця подій
Запуск тесту небезпечного бронювання
Після запуску тестування небезпечного кінцевого пункту всі запити перевищили квоту в 10 квитків. Тепер доступні квитки стали мінусовими -10. Це неправильно!
Графана k6 тестування небезпечного бронювання
Помилка! Усі бронювання зафіксовані понад квоту
Доступні квитки стали мінусовими, перевищено квоту
Запуск тесту безпечного бронювання
Якщо ви хочете протестувати безпечний кінцевий пункт, будь ласка, розкоментуйте його в booking_test.js
. Не забудьте повернути available_tickets
назад до 10.
Графана k6 тестування безпечного бронювання
Усі бронювання зафіксовані, як квоти квитків
Доступні квитки не перевищили квоти
Висновок
Створення надійної системи бронювання квитків навчило мене кількох важливих уроків:
- Завжди тестуйте вашу систему під навантаженням з одночасними запитами
- Використовуйте правильні механізми блокування при роботі з обмеженими ресурсами
- Іноді краще сказати користувачу спробувати ще раз, ніж ризикувати несумісністю даних
- Реальні системи потребують багатьох рівнів захисту
Хоча в нашому прикладі використовувався Redis для блокування, пам'ятайте, що більші системи можуть потребувати додаткових механізмів, таких як черги та тимчасові утримання.
Ключовим є розуміння ваших конкретних вимог і вибір правильних інструментів для виконання задачі.
Приклади коду в цій статті спрощені для навчальних цілей. У продакшн-системі вам також потрібно враховувати:
- Відкат транзакцій
- Тайм-аути блокувань
- Обробка платежів
- Звітність про помилки
- Моніторинг і сповіщення
Пам'ятайте: в системах бронювання квитків краще інколи сказати користувачу "спробуйте ще раз", ніж мати справу з наслідками дублювання замовлень і перевищенням квоти.
Якщо ви зацікавлені у глибшому зануренні в проектування систем та розробку бекенду, обов'язково слідкуйте за мною, щоб отримувати більше інсайтів, порад і практичних прикладів. Разом ми можемо досліджувати тонкощі створення ефективних систем, оптимізації роботи баз даних і освоєння інструментів, які керують сучасними додатками. Приєднуйтесь до мене на цьому шляху, щоб покращити свої навички та бути в курсі останніх трендів у світі технологій! 🚀
Читати про проєктування систем на bahasa можна на iniakunhuda.com
Ключовим є розуміння ваших конкретних вимог і вибір правильних інструментів для виконання задачі.
Приклади коду в цій статті спрощені для навчальних цілей. У продакшн-системі вам також потрібно враховувати:
- Відкат транзакцій
- Тайм-аути блокувань
- Обробка платежів
- Звітність про помилки
- Моніторинг і сповіщення
Пам'ятайте: в системах бронювання квитків краще інколи сказати користувачу "спробуйте ще раз", ніж мати справу з наслідками дублювання замовлень і перевищенням квоти.
Якщо ви зацікавлені у глибшому зануренні в проектування систем та розробку бекенду, обов'язково слідкуйте за мною, щоб отримувати більше інсайтів, порад і практичних прикладів. Разом ми можемо досліджувати тонкощі створення ефективних систем, оптимізації роботи баз даних і освоєння інструментів, які керують сучасними додатками. Приєднуйтесь до мене на цьому шляху, щоб покращити свої навички та бути в курсі останніх трендів у світі технологій! 🚀
Читати про проєктування систем на bahasa можна на iniakunhuda.com
Перекладено з: Hands-on Preventing Database Race Conditions with Redis