Дізнайтеся, як знаходити race conditions у Laravel + MongoDB застосунках і виправляти їх за допомогою атомарних операцій. Практичний приклад — оформлення замовлення в e‑commerce — покаже, чому патерн Eloquent «read‑modify‑write» ламається під навантаженням.
Перед початком бажано мати:
Опціонально, але корисно:
$inc, $set) у LaravelУявіть: ви реалізували flash sale для магазину. На локалі все працює, тести проходять. У production — через кілька хвилин після старту розпродажу — лунають тикети: клієнтів списали двічі, баланси гаманців стали від’ємними, а продано більше товару, ніж було на складі.
Найдивніше: логів із помилками немає. Усі операції повернули успішний результат, але дані в базі — неконсистентні.
Це і є race conditions — баги, які ховаються під час розробки і вилізають лише при реальній конкуренції. Покажу, як їх помічати, розуміти і вирішувати за допомогою атомарних операцій MongoDB у Laravel.
Перш ніж перейти до проблеми, налаштуємо проект Laravel для роботи з MongoDB.
Сконфігуруйте файл .env:
DB_CONNECTION=mongodb
DB_HOST=127.0.0.1
DB_PORT=27017
DB_DATABASE=ecommerce
DB_USERNAME=
DB_PASSWORD=
Додайте з’єднання MongoDB у config/database.php:
'connections' => [
'mongodb' => [
'driver' => 'mongodb',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', 27017),
'database' => env('DB_DATABASE', 'ecommerce'),
'username' => env('DB_USERNAME', ''),
'password' => env('DB_PASSWORD', ''),
'options' => [
'database' => env('DB_AUTHENTICATION_DATABASE', 'admin'),
],
],
],
Зробимо просту систему checkout з трьома сутностями: wallets (баланси користувачів), products (інвентар) та orders.
Згенеруйте моделі:
php artisan make:model Wallet
php artisan make:model Product
php artisan make:model Order
Ось модель Wallet:
<?php
namespace App\Models;
use MongoDB\Laravel\Eloquent\Model;
class Wallet extends Model
{
protected $connection = 'mongodb';
protected $collection = 'wallets';
protected $fillable = [
'user_id',
'balance'
];
protected $casts = [
'balance' => 'decimal:2'
];
}
Модель Product:
<?php
namespace App\Models;
use MongoDB\Laravel\Eloquent\Model;
class Product extends Model
{
protected $connection = 'mongodb';
protected $collection = 'products';
protected $fillable = [
'name',
'price',
'stock'
];
protected $casts = [
'price' => 'decimal:2',
'stock' => 'integer'
];
}
І модель Order:
<?php
namespace App\Models;
use MongoDB\Laravel\Eloquent\Model;
class Order extends Model
{
protected $connection = 'mongodb';
protected $collection = 'orders';
protected $fillable = [
'user_id',
'product_id',
'quantity',
'amount',
'status'
];
protected $casts = [
'amount' => 'decimal:2',
'quantity' => 'integer'
];
}
Створимо контролер checkout. Це імплементація, що здається коректною, але провалиться під навантаженням:
php artisan make:controller CheckoutController
<?php
namespace App\Http\Controllers;
use App\Models\Wallet;
use App\Models\Product;
use App\Models\Order;
use Illuminate\Http\Request;
class CheckoutController extends Controller
{
public function checkout(Request $request)
{
$validated = $request->validate([
'user_id' => 'required|string',
'product_id' => 'required|string',
'quantity' => 'required|integer|min:1',
]);
$userId = $validated['user_id'];
$productId = $validated['product_id'];
$quantity = $validated['quantity'];
// Step 1: Get the user's wallet
$wallet = Wallet::where('user_id', $userId)->firstOrFail();
// Step 2: Get the product
$product = Product::findOrFail($productId);
$amount = $product->price * $quantity;
// Step 3: Check if user has enough funds
if ($wallet->balance < $amount) {
return response()->json([
'error' => 'Insufficient funds'
], 400);
}
// Step 4: Check if enough stock
if ($product->stock < $quantity) {
return response()->json([
'error' => 'Insufficient stock'
], 400);
}
// Step 5: Deduct from wallet
$wallet->balance -= $amount;
$wallet->save();
// Step 6: Create the order
$order = Order::create([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $amount,
'status' => 'completed'
]);
// Step 7: Update inventory
$product->stock -= $quantity;
$product->save();
return response()->json([
'success' => true,
'order' => $order
]);
}
}
Додайте маршрут у routes/api.php:
Route::post('/checkout', [CheckoutController::class, 'checkout']);
Код виглядає чистим та логічним: "перевірити — потім виконати". Якщо запустити раз у браузері або Postman — все працює:
То у чому ж проблема?
Проблема проявляється, коли одночасно надходять кілька запитів до одних даних. Створимо feature‑тест, який її виявить.
Створіть новий тест:
php artisan make:test CheckoutConcurrencyTest
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\Wallet;
use App\Models\Product;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
class CheckoutConcurrencyTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
// Clean up collections before each test
Wallet::truncate();
Product::truncate();
Order::truncate();
}
public function test_concurrent_checkout_reveals_race_condition()
{
// Create a product with only 1 item in stock
$product = Product::create([
'name' => 'Limited Edition Sneakers',
'price' => 80.00,
'stock' => 1
]);
// Create 10 users, each with $100
for ($i = 0; $i < 10; $i++) {
Wallet::create([
'user_id' => "user-{$i}",
'balance' => 100.00
]);
}
// Simulate 10 users trying to buy the last item simultaneously
$responses = [];
$promises = [];
for ($i = 0; $i < 10; $i++) {
$promises[] = $this->postJson('/api/checkout', [
'user_id' => "user-{$i}",
'product_id' => $product->id,
'quantity' => 1
]);
}
// Wait for all requests to complete
foreach ($promises as $response) {
$responses[] = $response;
}
// Check the results
$product->refresh();
$orderCount = Order::where('product_id', $product->id)->count();
dump('=== RACE CONDITION RESULTS ===');
dump('Stock remaining: ' . $product->stock); // Expected: 0, Actual: negative!
dump('Orders created: ' . $orderCount); // Expected: 1, Actual: 10!
// Check a sample user's wallet
$wallet = Wallet::where('user_id', 'user-1')->first();
dump('User-1 balance: ' . $wallet->balance); // Could be anything!
// These assertions will FAIL, proving the race condition
$this->assertGreaterThan(1, $orderCount,
'Race condition detected: Multiple orders created for 1 item in stock');
}
}
Запустіть тест:
php artisan test --filter=test_concurrent_checkout_reveals_race_condition
Результати шокують:
=== RACE CONDITION RESULTS ===
Stock remaining: -9
Orders created: 10
User-1 balance: 20.00
Ми створили 10 замовлень на 1 товар. Інвентар став негативним. Декількох користувачів списали кошти, хоча товара вистачило лише на одного. Усе це — без жодної помилки в логах.
Прослідкуймо за двома паралельними запитами. Коротко:
Time Request A (user-1) Request B (user-2) Database
---- ------------------ ------------------ --------
t1 Read wallet: $100 balance: $100
t2 Read wallet: $100 balance: $100
t3 Read stock: 1 stock: 1
t4 Read stock: 1 stock: 1
t5 Check: $100 >= $80 ✓
t6 Check: 1 >= 1 ✓
t7 Check: $100 >= $80 ✓
t8 Check: 1 >= 1 ✓
t9 Calculate: $100 - $80 = $20
t10 Save wallet: $20 balance: $20
t11 Calculate: $100 - $80 = $20
t12 Save wallet: $20 balance: $20 ❌
t13 Calculate: 1 - 1 = 0
t14 Save stock: 0 stock: 0
t15 Calculate: 1 - 1 = 0
t16 Save stock: 0 stock: 0 ❌
Обидва запити прочитали один і той самий стан раніше, ніж хтось записав зміни. Вони:
Запит B використав застарілі дані, і його запис стер результат A. Це і є race condition — коли правильність програми залежить від порядку й часу паралельних операцій.
Можна подумати: «Додамо умовну перевірку у запит оновлення!» Спробуємо з where():
public function checkout(Request $request)
{
// ... validation ...
$wallet = Wallet::where('user_id', $userId)->first();
$product = Product::find($productId);
$amount = $product->price * $quantity;
// Add a condition to ensure balance is sufficient at update time
$walletUpdated = Wallet::where('user_id', $userId)
->where('balance', '>=', $amount)
->update(['balance' => $wallet->balance - $amount]);
if ($walletUpdated === 0) {
return response()->json(['error' => 'Insufficient funds'], 400);
}
// Similarly for stock
$stockUpdated = Product::where('_id', $productId)
->where('stock', '>=', $quantity)
->update(['stock' => $product->stock - $quantity]);
if ($stockUpdated === 0) {
// Refund the wallet
Wallet::where('user_id', $userId)
->update(['balance' => $wallet->balance]);
return response()->json(['error' => 'Out of stock'], 400);
}
// Create order...
}
Це здається безпечнішим — перевірка прямо при оновленні. Але якщо прогнати тест конкуренції, помилки залишаться. Чому?
Проблема в тому, що ми усе ще рахуємо нове значення на основі застарілих даних:
$wallet = Wallet::where('user_id', $userId)->first();
// ↓
// Time passes here. Other requests might update the wallet.
// ↓
Wallet::where('user_id', $userId)
->where('balance', '>=', $amount)
->update(['balance' => $wallet->balance - $amount]); // ← Using old data!
Умовний where('balance', '>=', $amount) допомагає, але ми все одно використовуємо в додатку зчитане раніше значення. За цей проміжок інший запит міг змінити balance.
Цей патерн має назву: read‑modify‑write (RMW). Це одна з найпоширеніших помилок при роботі з будь‑якою БД під конкурентним навантаженням.
Ключова думка: навіщо взагалі читати wallet?
MongoDB може виконувати обчислення прямо в базі атомарно, без нашого попереднього читання. Це усуває вікно race condition.
MongoDB має атомарні оператори — $inc, $set, $push та інші. Пакет MongoDB для Laravel дає доступ до них через метод raw().
Перепишемо checkout з використанням атомарних операцій:
<?php
namespace App\Http\Controllers;
use App\Models\Wallet;
use App\Models\Product;
use App\Models\Order;
use Illuminate\Http\Request;
use MongoDB\BSON\ObjectId;
class CheckoutController extends Controller
{
public function checkout(Request $request)
{
$validated = $request->validate([
'user_id' => 'required|string',
'product_id' => 'required|string',
'quantity' => 'required|integer|min:1',
]);
$userId = $validated['user_id'];
$productId = $validated['product_id'];
$quantity = $validated['quantity'];
// Get product price (read-only, doesn't need atomicity)
$product = Product::findOrFail($productId);
$amount = $product->price * $quantity;
// ATOMIC OPERATION: Debit wallet
// This performs the calculation inside MongoDB
$walletResult = Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
[
'user_id' => $userId,
'balance' => ['$gte' => $amount] // Only update if balance >= amount
],
[
'$inc' => ['balance' => -$amount] // Atomically decrement
]
);
});
if ($walletResult->getModifiedCount() === 0) {
return response()->json([
'error' => 'Insufficient funds'
], 400);
}
// ATOMIC OPERATION: Decrease inventory
$inventoryResult = Product::raw(function ($collection) use ($productId, $quantity) {
return $collection->updateOne(
[
'_id' => new ObjectId($productId),
'stock' => ['$gte' => $quantity] // Only update if stock >= quantity
],
[
'$inc' => ['stock' => -$quantity] // Atomically decrement
]
);
});
if ($inventoryResult->getModifiedCount() === 0) {
// Out of stock! Rollback the wallet debit
Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
['user_id' => $userId],
['$inc' => ['balance' => $amount]] // Refund
);
});
return response()->json([
'error' => 'Insufficient stock'
], 400);
}
// Create the order (no atomicity needed for insert)
$order = Order::create([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'amount' => $amount,
'status' => 'completed'
]);
return response()->json([
'success' => true,
'order' => $order
]);
}
}
Що робить цю операцію атомарною:
Оператор $inc:
['$inc' => ['balance' => -$amount]]
Говорить MongoDB: «Знайди документ і відніми $amount від поля balance — зроби це однією, неділимою операцією, не читаючи попереднє значення».
Умовне оновлення:
[
'user_id' => $userId,
'balance' => ['$gte' => $amount]
]
Це гарантує, що оновлення відбудеться тільки якщо балансу вистачає. Якщо між обчисленням і оновленням інший запит витратив кошти, getModifiedCount() поверне 0 — ми знаємо, що оновлення не пройшло.
Механізм відкату:
if ($inventoryResult->getModifiedCount() === 0) {
Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
['user_id' => $userId],
['$inc' => ['balance' => $amount]]
);
});
}
Якщо списали гроші, але інвентар виявився відсутнім, ми атомарно повертаємо суму назад.
Перевіримо, що воно працює. Оновимо наш тест:
public function test_atomic_operations_prevent_race_conditions()
{
// Create test data
$product = Product::create([
'name' => 'Limited Edition Sneakers',
'price' => 80.00,
'stock' => 1
]);
for ($i = 0; $i < 10; $i++) {
Wallet::create([
'user_id' => "user-{$i}",
'balance' => 100.00
]);
}
// 10 concurrent checkout attempts
$responses = [];
for ($i = 0; $i < 10; $i++) {
$responses[] = $this->postJson('/api/checkout', [
'user_id' => "user-{$i}",
'product_id' => $product->id,
'quantity' => 1
]);
}
// Count successes and failures
$successCount = collect($responses)
->filter(fn($r) => $r->status() === 200)
->count();
$failureCount = collect($responses)
->filter(fn($r) => $r->status() === 400)
->count();
// Check the results
$product->refresh();
$orderCount = Order::where('product_id', $product->id)->count();
dump('=== ATOMIC OPERATIONS RESULTS ===');
dump('Successful checkouts: ' . $successCount);
dump('Failed checkouts: ' . $failureCount);
dump('Stock remaining: ' . $product->stock);
dump('Orders created: ' . $orderCount);
// Assertions
$this->assertEquals(1, $successCount, 'Exactly 1 checkout should succeed');
$this->assertEquals(9, $failureCount, '9 checkouts should fail');
$this->assertEquals(0, $product->stock, 'Stock should be 0');
$this->assertEquals(1, $orderCount, 'Exactly 1 order should exist');
// Verify the winning user's wallet
$orders = Order::all();
$winningUserId = $orders[0]->user_id;
$winningWallet = Wallet::where('user_id', $winningUserId)->first();
$this->assertEquals(20.00, $winningWallet->balance, 'Winner should have $20 left');
// Verify all losing users still have $100
$losingWallets = Wallet::where('user_id', '!=', $winningUserId)->get();
foreach ($losingWallets as $wallet) {
$this->assertEquals(100.00, $wallet->balance,
"User {$wallet->user_id} should still have $100");
}
}
Запустіть тест:
php artisan test --filter=test_atomic_operations_prevent_race_conditions
Результат:
=== ATOMIC OPERATIONS RESULTS ===
Successful checkouts: 1
Failed checkouts: 9
Stock remaining: 0
Orders created: 1
PASS Tests\Feature\CheckoutConcurrencyTest
✓ atomic operations prevent race conditions
Ідеально — race condition зникла. Один користувач отримав товар, дев’ятьам повернули помилку «Insufficient stock», баланси вірні.
$inc підходить для нашого кейсу, але MongoDB має й інші оператори для різних задач:
$set — встановити значення поля:
Product::raw(function ($collection) use ($productId) {
return $collection->updateOne(
['_id' => new ObjectId($productId)],
['$set' => ['featured' => true, 'updated_at' => new UTCDateTime()]]
);
});
$push — додати в масив:
Order::raw(function ($collection) use ($orderId, $comment) {
return $collection->updateOne(
['_id' => new ObjectId($orderId)],
['$push' => ['comments' => $comment]]
);
});
$pull — прибрати з масиву:
User::raw(function ($collection) use ($userId, $itemId) {
return $collection->updateOne(
['_id' => new ObjectId($userId)],
['$pull' => ['wishlist' => $itemId]]
);
});
$mul — помножити значення поля:
Product::raw(function ($collection) use ($productId) {
return $collection->updateOne(
['_id' => new ObjectId($productId)],
['$mul' => ['price' => 1.1]] // 10% price increase
);
});
$min та $max — оновлювати тільки якщо нове значення менше/більше:
Product::raw(function ($collection) use ($productId, $newPrice) {
return $collection->updateOne(
['_id' => new ObjectId($productId)],
['$min' => ['lowest_price' => $newPrice]] // Only update if newPrice is lower
);
});
Ці оператори — інструменти для побудови операцій без race conditions у MongoDB.
Порівняємо традиційний Eloquent‑підхід і атомарні оновлення:
public function test_performance_comparison()
{
// Setup: 100 products and 100 users
for ($i = 0; $i < 100; $i++) {
Product::create([
'name' => "Product {$i}",
'price' => 50.00,
'stock' => 100
]);
Wallet::create([
'user_id' => "user-{$i}",
'balance' => 1000.00
]);
}
// Test 1: Eloquent read-modify-write (without race conditions for fair comparison)
$startEloquent = microtime(true);
for ($i = 0; $i < 100; $i++) {
$wallet = Wallet::where('user_id', "user-{$i}")->first();
$wallet->balance -= 50;
$wallet->save();
}
$eloquentTime = (microtime(true) - $startEloquent) * 1000;
// Reset wallets
Wallet::raw(function ($collection) {
return $collection->updateMany(
[],
['$set' => ['balance' => 1000.00]]
);
});
// Test 2: Atomic operations
$startAtomic = microtime(true);
for ($i = 0; $i < 100; $i++) {
Wallet::raw(function ($collection) use ($i) {
return $collection->updateOne(
['user_id' => "user-{$i}"],
['$inc' => ['balance' => -50]]
);
});
}
$atomicTime = (microtime(true) - $startAtomic) * 1000;
dump("Eloquent: {$eloquentTime}ms");
dump("Atomic: {$atomicTime}ms");
dump("Improvement: " . round(($eloquentTime - $atomicTime) / $eloquentTime * 100, 1) . "%");
$this->assertLessThan($eloquentTime, $atomicTime,
'Atomic operations should be faster');
}
Типові результати:
Eloquent: 245ms
Atomic: 156ms
Improvement: 36.3%
Атомарні операції не лише безпечніші — вони часто швидші, бо прибирають потрібність читати поточне значення.
Підсумовуючи, кілька правил:
1. Використовуйте атомарні операції для числових змін при конкуренції:
// ✅ GOOD: Atomic
Wallet::raw(fn($c) => $c->updateOne(
['user_id' => $userId],
['$inc' => ['balance' => -$amount]]
));
// ❌ BAD: Read-modify-write
$wallet = Wallet::where('user_id', $userId)->first();
$wallet->balance -= $amount;
$wallet->save();
2. Завжди перевіряйте getModifiedCount(), щоб підтвердити успіх:
$result = Wallet::raw(function ($collection) use ($userId, $amount) {
return $collection->updateOne(
['user_id' => $userId, 'balance' => ['$gte' => $amount]],
['$inc' => ['balance' => -$amount]]
);
});
if ($result->getModifiedCount() === 0) {
// Handle failure - either insufficient balance or user doesn't exist
}
3. Використовуйте умовні оновлення для бізнес‑правил:
// Only decrement if stock is available
$result = Product::raw(function ($collection) use ($productId, $quantity) {
return $collection->updateOne(
[
'_id' => new ObjectId($productId),
'stock' => ['$gte' => $quantity],
'status' => 'active' // Additional business rule
],
['$inc' => ['stock' => -$quantity]]
);
});
4. Реалізуйте механізми відкату:
// If subsequent operation fails, rollback the first
if ($inventoryResult->getModifiedCount() === 0) {
Wallet::raw(fn($c) => $c->updateOne(
['user_id' => $userId],
['$inc' => ['balance' => $amount]] // Refund
));
}
5. Знайте, коли Eloquent підходить:
// ✅ GOOD: Single insert, no race condition possible
Order::create([
'user_id' => $userId,
'amount' => $amount
]);
// ✅ GOOD: Read-only operation
$product = Product::find($productId);
Коротке дерево рішень:
Використовуйте Eloquent, коли:
Використовуйте атомарні операції, коли:
$inc та інші виконують обчислення в базі як однорідну операціюraw() для атомарних операцій — пакет MongoDB для Laravel дає доступ до повного набору операторів через raw()getModifiedCount() — 0 означає, що умовне оновлення не пройшло (наприклад, недостатньо коштів)Eloquent використовує патерн read‑modify‑write: зчитує документ, змінює його в PHP і зберігає назад — це створює вікно race condition. Атомарні операції (наприклад, $inc) змінюють документ без попереднього читання, роблячи операцію неділимою. Використовуйте Eloquent, коли гонки не є проблемою; атомарні операції — для конкурентних сценаріїв.
Створіть feature‑тест, що відправляє багато одночасних запитів до одного endpoint. У циклі надішліть 10–50 POST‑запитів на одну й ту саму ресурсну операцію (наприклад, купити останню одиницю товару). Після завершення перевірте консистентність: має бути саме одне замовлення, без негативних балансів, коректний інвентар. Запускайте ці тести через php artisan test.
Метод raw() обходить систему відношень Eloquent, тому роботу з відношеннями доведеться робити вручну. Ви можете використовувати Eloquent для отримання суміжних даних до або після атомарної операції. Загалом — атомарні операції для критичних числових оновлень, Eloquent — для решти логіки.
Окрім $inc, є: $set, $unset, $push, $pull, $mul, $min, $max, $currentDate та багато інших. Усі доступні через raw(). Деталі — у документації MongoDB.
Використовуйте Eloquent для вставок, читань, оновлень полів, де нове значення не залежить від старого, а також коли важлива зручність моделей, подій, аксесорів і відношень. Використовуйте raw() для гарантії консистентності під конкуренцією — фінанси, інвентар, лічильники. Eloquent читабельніший, тож віддавайте йому перевагу, якщо атомарність не потрібна.
Ми вирішили проблему гонок для операцій над одним документом за допомогою атомарних оновлень. Але що робити, коли checkout має атомарно змінити декілька документів — wallet, inventory і створити order — і при будь‑якій помилці все треба відкотити?
У наступній статті — "Building Transaction‑Safe Multi‑Document Operations in Laravel MongoDB" — розглянемо ACID‑транзакції MongoDB для багатодокументних операцій і коли атомарних операторів замало.
Ресурси
Ви хочете навчитися, як інтегрувати Google OAuth у вашому проекті Laravel, використовуючи Socialite? Дізнайтеся, як налаштувати доступ до сервісів Google, таких як Календар, у нашій сьогоднішній статті
Чи стикалися ви з помилкою «SQLSTATE[HY000] [2002] Connection refused» під час налаштування GitHub Actions для вашого додатку на Laravel? У нашій статті ми розглянемо три поширені причини цієї помилки та надамо рішення для їх усунення. Читайте далі, щоб дізнатися, як ваш CI/CD потік може працювати бездоганно!
PHP 8.5 обіцяє безліч нових можливостей, таких як оператор Pipe, функції `array_first()` та `array_last()`, а також нове розширення URI. Чи готові ви дізнатися, як ці функції можуть спростити вашу розробку? Читайте далі, щоб дізнатися більше про ці захоплюючі нововведення