Case Study: jeden produkt, dwóch i więcej chętnych... w tej samej chwili.
W tym tekście postaram się przybliżyć jeden z problemów e-commerce oraz możliwe jego rozwiązania – a mianowicie: mamy ostatni produkt, dwóch chętnych, którzy (o zgrozo!) dokonali zakupu w tym samym momencie.
Jak to w programowaniu, informatyce i ogólnie życiu bywa: jeden problem może mieć wiele różnorakich rozwiązań - tak samo jest w tym przypadku. Oczywiście sytuacja jest czysto teoretyczna, mało prawdopodobna – ale nie, nie możliwa... a dobry algorytm powinien być odporny także na takie wyjątki.
Najprostsze rozwiązanie – a zarazem niestety w tym wypadku najgorsze, bo podane na tzw. ‘wyścig’ (czyli to co chcemy uniknąć - jest zwykły update w bazie danych.
Przykład w MySQL / MariaDB:
UPDATE products
SET stock = stock - 1
WHERE id = 123 AND stock > 0;
Założenie jest banalne w swojej prostocie:
- Jeżeli
stock
> 0 – operacja się powiedzie - Jeżeli
stock
= 0 – operacja zakupu się nie powiedzie
Problem z tym rozwiązaniem jest oczywisty: przy dużym ruchu (przy założeniu idealnej latencji itd.) dwóch użytkowników może dostać stock = 0
i kupić produkt jednocześnie... a co potem się stanie? Jak cała machina pójdzie w ruch? Zwłaszcza jak mamy zintegrowane w całym procesie zakupowym systemy typu ERP i inne? To już ciężko przewidzieć... a znalezienie tego błędu i jego naprawa też może nie być proste.
Możliwe rozwiązanie 1: jeżeli posiadamy tabele rezerwacji zamówień (tj. w bazie danych każda 1 sztuka 1 produktu jest osobnym rekordem)
Możemy wykorzystać mechanizm unikalnego klucza - przykładowe zapytanie w MySQL / MariaDB z wykorzystaniem IGNORE
INSERT IGNORE INTO orders (user_id, product_id) VALUES (1, 123);
Oczywiście z kluczem unikalnym dla pola product_id
ALTER TABLE orders ADD UNIQUE (product_id);
Mamy w tym wypadku dwie możliwości:
- Jeżeli produkt został już zarezerwowany – zapytanie zostanie zignorowane
- Jeżeli nie - zamówienie zostanie utworzone
Jest to proste rozwiązanie, jednak wymaga osobnej tabeli i rozbicia poszczególnych wystąpień tego samego produktu na sztuki – a przy dużych ilościach produktu to nie ma większego sensu (zwłaszcza, że w czasie będzie problem narastać i będzie to odbijać się na wydajności samej bazy danych / tabeli + dochodzi kwestia archiwalnych zamówień, produktów... lepiej nie).
Możliwe rozwiązanie 2 - obsługa transakcji
Rozwiązanie to sprawdzi się jedynie, kiedy używamy silnika InnoDB
na naszej bazie danych – ale za to jest także stosunkowo proste i bezpieczne.
Przykład w MySQL / MariaDB:
START TRANSACTION;
-- Pobranie produktu z blokadą (blokuje wiersz do końca transakcji)
SELECT stock FROM products WHERE id = 123 FOR UPDATE;
-- Sprawdzenie dostępności i aktualizacja
UPDATE products
SET stock = stock - 1
WHERE id = 123 AND stock > 0;
COMMIT;
Przykład dla PostgreSQL:
BEGIN;
-- Pobranie produktu z blokadą na poziomie wiersza
SELECT id, stock FROM products WHERE id = 123 FOR UPDATE;
-- Sprawdzenie, czy produkt jest dostępny
UPDATE products
SET stock = stock - 1
WHERE id = 123 AND stock > 0;
-- Sprawdzenie, czy operacja była skuteczna
SELECT stock FROM products WHERE id = 123;
COMMIT;
Jak to działa? Ano prosto – wszystko jest obrane w jedną transakcję złożoną z dwóch zapytań do bazy danych. W tym wypadku wiersz: SELECT ... FOR UPDATE
blokuje cały wiersz tabeli aż do zakończenia owej transakcji. Zatem w przypadku, kiedy, dwóch użytkowników jednocześnie wykona operację zakupu to:
- Pierwszy użytkownik zablokuje wiersz
- Drugi będzie musiał poczekać
- Gdy pierwszy dokończy transakcję - drugi sprawdzi
stock
i nie dojdzie do zakończenia transakcji.
Wadą tego rozwiązania jest niestety fakt, że może powodować kolejki – ale jest to dość proste rozwiązanie do implementacji w istniejących systemach. Na pewno najbardziej bezpieczne – ale nie najbardziej wydajne. Zastosowanie znajdzie z powodzeniem w standardowych e-commercowych systemach, gdzie nie ma bardzo wielkiego ruchu (marketplace’y w czarne czwartki raczej odpadają - ale z doświadczenia wiem, że wtedy raczej lubi siadać ogólnie infrastruktura i same usługi niż jeżeli dochodzi do takich incydentów).
Możliwe rozwiązanie 3 – Flash-sale
Rozwiązanie dobre dla dużego ruchu i mniej rozbudowanej infrastuktury – opiera się na Redis czy Memcached. Redis jest tak powszechny obecnie jak MySQL / MariaDB więc to nie jest duży problem.
Na czym polega to rozwiązanie? Wykorzystanie Redisa do tymczasowej rezerwacji produktu – zanim sama transakcja trafi do bazy danych. Oto przykład:
Użytkownik próbuje kupić produkt:
SETNX "reserved_product_123" user_id
- Jeżeli operacja się udała - użytkownik ma X sekund na dokończenie zakupu.
- Jeżeli nie – produkt jest zajęty, nie dojdzie do zakupu.
Zalety i wady tego rozwiązania:
- ✅ Bardzo szybkie, idealne dla systemów o dużym ruchu (e-commerce z milionami użytkowników) - Redis w końcu działa w pamięci.
- ✅ Można dodać TTL (czas rezerwacji) - ustalając np. 30 sekundowy zakres czasu na dokończenie transakcji
- ❌ Redis w końcu działa w pamięci... co przy awarii implikuje utratę danych
- ❌ Potrzebne są osobne mechanizmy do synchronizacji z docelową bazą danych
Jednak jest to rozwiązanie dość szybkie, które powinno bez większych problemów dać sobie z obsługą nawet bardzo dużego ruchu. Pożądane także, kiedy chcemy mieć krótkotrwałe rezerwacje produktu przed samym zakupem.
Możliwe rozwiązanie 4 – system kolejowy (zastosowanie Event-Driven Architekture)
Założenie oparte na kolejkach (RabbitMQ, Kafka, od biedy Redis) - gdzie problem równoczesnego zakupu ostatniej sztuki produktu jest rozwiązywany przez samą kolejkę. Podejście w stylu: ‘First Come, First Serve’
Przykład działania:
- Klient wysyła żądanie zakupu – zdarzenie jest wrzucane na kolejkę
- Konsumer konsumuje pierwszy event z kolejki i go przetwarza
- Każde zamówienie jest obsługiwane sekwencyjne jeden po drugim.
Oczywiście w przypadku istnienia jednej instancji konsumera na tą daną kolejkę - jeżeli zechcemy ‘zoptymalizować’ ten proces (np. aby odciążyć tą kolejkę z zalegających eventów), dokładając kolejną instancję tego konsumera na tą kolejkę... To problem może powrócić
Zalety i wady takiego podejścia:
- ✅ Gwarancja kolejności wykonywanych zdarzeń - tylko jeden użytkownik może zdobyć produkt jako pierwszy
- ✅ Dobrze działa w architekturze rozproszonej opartej na mikro-serwisach
- ❌ Potrzeba dedykowanego serwera / usługi pod Rabbita / Kafke
Jest to jednak dobre rozwiązanie dla systemów, gdzie wydajność i kolejność zachodzących operacji jest kluczowa – np. systemy rezerwacji biletów.
Są także możliwe inne rozwiązania - choć z nimi nie miałem styczności w swojej pracy. Takim rozwiązaniem jest np. oparcie transakcji na blockchain’ach - choć to bardziej czysto teoretyczne rozwiązanie, możliwe raczej przy NFT i pokrewnych niż e-commerce. W przypadku baz NoSQL można także zastosować mechanizm transakcji – z tego co mi wiadomo Firestore (Google Firebase) daję możliwość obsługi transakcji.
Szybko i rzetelnie podsumowując:
- Najlepszym i najbezpieczniejszym rozwiązaniem dla e-commerce o niewielkim ruchu i rozmiarach jest rozwiązanie problemu oparte na transakcjach na bazie danych.
- Bardzo dobrym rozwiązaniem jest oparcie procesu o system kolejkowy - gwarantuje nam to kolejność wykonywania zdarzeń w zależności od wejścia danego eventu na kolejkę.