Дізнайтеся, як знаходити race conditions у Laravel + MongoDB застосунках і виправляти їх за допомогою атомарних операцій. Практичний приклад — оформлення замовлення в e‑commerce — покаже, чому патерн Eloquent «read‑modify‑write» ламається під навантаженням.
Передумови
Перед початком бажано мати:
- Знайомство зі структурою Laravel (routing, controllers, Eloquent ORM)
- PHP 8.3 або вище на машині розробника
- Composer для керування залежностями
- MongoDB-сервер — локально або безкоштовний MongoDB Atlas
- Базові поняття MongoDB — документи, колекції, CRUD-операції
- Командний рядок — вміння запускати artisan і composer
- Досвід тестування — базові знання PHPUnit та тестових можливостей Laravel
Опціонально, але корисно:
- Розуміння HTTP-запитів та REST API
- Досвід роботи з конкурентністю
- Знання JavaScript/frontend-фреймворків (для повноцінних прикладів у статті)
Чого ви навчитеся
- Відтворювати race conditions у Laravel через feature‑тести
- Чому Eloquent‑овий патерн read‑modify‑write не витримує конкуренції
- Як використовувати атомарні оператори MongoDB (
$inc,$set) у Laravel - Стратегії тестування конкурентних операцій перед деплоєм у production
Вступ
Уявіть: ви реалізували flash sale для магазину. На локалі все працює, тести проходять. У production — через кілька хвилин після старту розпродажу — лунають тикети: клієнтів списали двічі, баланси гаманців стали від’ємними, а продано більше товару, ніж було на складі.
Найдивніше: логів із помилками немає. Усі операції повернули успішний результат, але дані в базі — неконсистентні.
Це і є race conditions — баги, які ховаються під час розробки і вилізають лише при реальній конкуренції. Покажу, як їх помічати, розуміти і вирішувати за допомогою атомарних операцій MongoDB у Laravel.
Налаштування Laravel з MongoDB
Перш ніж перейти до проблеми, налаштуємо проект 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'),
],
],
],
Створюємо моделі для e‑commerce
Зробимо просту систему 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 ❌
Обидва запити прочитали один і той самий стан раніше, ніж хтось записав зміни. Вони:
- прочитали баланс $100
- прочитали stock = 1
- пройшли валідації
- поінтерпольвали нові значення на основі застарілих даних
- перезаписали одне одного
Запит 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). Це одна з найпоширеніших помилок при роботі з будь‑якою БД під конкурентним навантаженням.
Правильне рішення: атомарні операції MongoDB
Ключова думка: навіщо взагалі читати 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», баланси вірні.
Інші атомарні оператори MongoDB
$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 vs атомарні операції
Порівняємо традиційний 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%
Атомарні операції не лише безпечніші — вони часто швидші, бо прибирають потрібність читати поточне значення.
Кращі практики для атомарних операцій у Laravel
Підсумовуючи, кілька правил:
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, а коли — атомарні операції
Коротке дерево рішень:
Використовуйте Eloquent, коли:
- Створюєте нові записи (inserts)
- Читаєте дані (selects)
- Оновлюєте поле, нове значення не залежить від старого
- Впевнені, що одночасно модифікує документ лише один запит
- Операція некритична (наприклад, оновлення last_seen_at)
Використовуйте атомарні операції, коли:
- Інкрементуєте/декрементуєте числові значення (лічильники, баланси, інвентар)
- Нове значення обчислюється від поточного
- Кілька користувачів можуть одночасно змінювати один документ
- Фінансові операції або критичні дані
- Потрібна гарантія консистентності без блокувань
Висновки
- Race conditions невидимі під час розробки — вони проявляються тільки при конкурентному навантаженні, тому їх важко знайти без тестів
- Read‑modify‑write — антипатерн — зчитування значення, обчислення в додатку і запис назад створює вікно для гонок
- Атомарні оператори MongoDB усувають гонки —
$incта інші виконують обчислення в базі як однорідну операцію - Завжди тестуйте конкурентні запити — імітуйте одночасні запити, щоб впевнитися в консистентності даних
- Використовуйте Laravel
raw()для атомарних операцій — пакет MongoDB для Laravel дає доступ до повного набору операторів черезraw() - Перевіряйте
getModifiedCount()— 0 означає, що умовне оновлення не пройшло (наприклад, недостатньо коштів) - Атомарні операції часто швидші — вони прибирають додаткові мережеві запити на читання
FAQ
Чим відрізняються оновлення через Eloquent і атомарні операції в MongoDB?
Eloquent використовує патерн read‑modify‑write: зчитує документ, змінює його в PHP і зберігає назад — це створює вікно race condition. Атомарні операції (наприклад, $inc) змінюють документ без попереднього читання, роблячи операцію неділимою. Використовуйте Eloquent, коли гонки не є проблемою; атомарні операції — для конкурентних сценаріїв.
Як тестувати race conditions у Laravel + MongoDB?
Створіть feature‑тест, що відправляє багато одночасних запитів до одного endpoint. У циклі надішліть 10–50 POST‑запитів на одну й ту саму ресурсну операцію (наприклад, купити останню одиницю товару). Після завершення перевірте консистентність: має бути саме одне замовлення, без негативних балансів, коректний інвентар. Запускайте ці тести через php artisan test.
Чи можна комбінувати атомарні операції з Eloquent‑відношеннями?
Метод raw() обходить систему відношень Eloquent, тому роботу з відношеннями доведеться робити вручну. Ви можете використовувати Eloquent для отримання суміжних даних до або після атомарної операції. Загалом — атомарні операції для критичних числових оновлень, Eloquent — для решти логіки.
Які ще атомарні оператори MongoDB доступні в Laravel?
Окрім $inc, є: $set, $unset, $push, $pull, $mul, $min, $max, $currentDate та багато інших. Усі доступні через raw(). Деталі — у документації MongoDB.
Коли краще використовувати Eloquent замість raw MongoDB операцій?
Використовуйте Eloquent для вставок, читань, оновлень полів, де нове значення не залежить від старого, а також коли важлива зручність моделей, подій, аксесорів і відношень. Використовуйте raw() для гарантії консистентності під конкуренцією — фінанси, інвентар, лічильники. Eloquent читабельніший, тож віддавайте йому перевагу, якщо атомарність не потрібна.
Що далі
Ми вирішили проблему гонок для операцій над одним документом за допомогою атомарних оновлень. Але що робити, коли checkout має атомарно змінити декілька документів — wallet, inventory і створити order — і при будь‑якій помилці все треба відкотити?
У наступній статті — "Building Transaction‑Safe Multi‑Document Operations in Laravel MongoDB" — розглянемо ACID‑транзакції MongoDB для багатодокументних операцій і коли атомарних операторів замало.
Ресурси