Database Transactions & Pessimistic Locking trong Laravel

· 6 min read

Concurrency là một thách thức trong hệ thống production. Khi nhiều requests sửa đổi cùng một dữ liệu đồng thời, bạn có nguy cơ gặp race conditions và data corruption. Laravel cung cấp các công cụ mạnh mẽ để quản lý điều này: transactions và pessimistic locking.

Database Transactions

Một transaction đảm bảo một nhóm các operations hoặc tất cả thành công hoặc tất cả thất bại một cách atomic.

use Illuminate\Support\Facades\DB;

DB::transaction(function () {
    $user = User::find(1);
    $user->decrement('balance', 100);

    Invoice::create([
        'user_id' => $user->id,
        'amount' => 100,
    ]);
    
    // Nếu bất cứ điều gì throw exception, tất cả các thay đổi được rolled back
});

Xử lý Transaction Failures

try {
    DB::transaction(function () {
        // Database operations
    });
} catch (Exception $e) {
    Log::error('Transaction failed: ' . $e->getMessage());
    // Handle failure gracefully
}

Savepoints

Cho các nested transactions, sử dụng savepoints:

DB::transaction(function () {
    User::find(1)->increment('points', 10);

    DB::transaction(function () {
        // This is a savepoint
        Order::create(['user_id' => 1]);
    }, attempts: 1);
});

Pessimistic Locking

Ngăn chặn race conditions bằng cách lock rows:

$user = User::where('id', 1)
    ->lockForUpdate()  // Exclusive lock
    ->first();

$user->decrement('balance', 100);
$user->save();

Row được lock cho đến khi transaction commits.

Shared Lock

Cho phép người đọc khác nhưng ngăn chặn updates:

$user = User::where('id', 1)
    ->sharedLock()  // Read lock
    ->first();

// Một request khác có thể đọc row này với sharedLock()
// Nhưng không thể lockForUpdate()

Ví dụ thực tế: Transfer Funds

public function transferFunds(int $fromUserId, int $toUserId, float $amount): void
{
    DB::transaction(function () use ($fromUserId, $toUserId, $amount) {
        // Lock cả hai rows theo thứ tự consistent để ngăn deadlocks
        $sender = User::whereIn('id', [$fromUserId, $toUserId])
            ->orderBy('id')
            ->lockForUpdate()
            ->get()
            ->firstWhere('id', $fromUserId);

        $receiver = User::whereIn('id', [$fromUserId, $toUserId])
            ->orderBy('id')
            ->lockForUpdate()
            ->get()
            ->firstWhere('id', $toUserId);

        if ($sender->balance < $amount) {
            throw new InsufficientFundsException();
        }

        $sender->decrement('balance', $amount);
        $receiver->increment('balance', $amount);

        Transaction::create([
            'from_user_id' => $fromUserId,
            'to_user_id' => $toUserId,
            'amount' => $amount,
        ]);
    });
}

Optimistic Locking với Versioning

Thay vì locking, sử dụng version columns để phát hiện conflicts:

Schema::create('posts', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->longText('content');
    $table->integer('version')->default(0);
    $table->timestamps();
});
class Post extends Model
{
    protected $attributes = [
        'version' => 0,
    ];

    public function updateContent(string $content): void
    {
        $this->update([
            'content' => $content,
            'version' => DB::raw('version + 1'),
        ]);
    }

    // Trong controller hoặc service
    public function saveChanges(int $postId, string $content, int $knownVersion): void
    {
        $updated = DB::transaction(function () use ($postId, $content, $knownVersion) {
            return DB::table('posts')
                ->where('id', $postId)
                ->where('version', $knownVersion)
                ->update([
                    'content' => $content,
                    'version' => $knownVersion + 1,
                ]);
        });

        if (!$updated) {
            throw new OptimisticLockException('Post was modified since you last read it.');
        }
    }
}

Named Locks

Sử dụng named locks cho các distributed systems:

DB::transaction(function () {
    DB::select('SELECT GET_LOCK(?, 30)', ['inventory_lock']);
    
    try {
        // Safe section
        Inventory::where('sku', 'ABC123')->decrement('stock');
    } finally {
        DB::select('SELECT RELEASE_LOCK(?)', ['inventory_lock']);
    }
});

Deadlock Handling

Khi nhiều transactions lock resources theo thứ tự khác nhau, deadlocks xảy ra:

$maxAttempts = 3;
$attempt = 0;

while ($attempt < $maxAttempts) {
    try {
        DB::transaction(function () {
            // Lock operations
        });
        break;
    } catch (QueryException $e) {
        if ($e->getCode() === 'HY000' && strpos($e->getMessage(), 'Deadlock') !== false) {
            $attempt++;
            if ($attempt >= $maxAttempts) {
                throw new DeadlockException('Max retry attempts exceeded');
            }
            sleep(random_int(1, 3)); // Back off với random delay
        } else {
            throw;
        }
    }
}

Hoặc sử dụng một helper class:

namespace App\Database;

use Closure;
use Illuminate\Database\QueryException;

class TransactionHelper
{
    public static function withDeadlockHandling(Closure $callback, int $maxAttempts = 3): mixed
    {
        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            try {
                return DB::transaction($callback);
            } catch (QueryException $e) {
                if ($attempt === $maxAttempts || !self::isDeadlock($e)) {
                    throw;
                }
                
                usleep(random_int(1000, 3000000)); // 1-3 seconds
            }
        }
    }

    private static function isDeadlock(QueryException $e): bool
    {
        $message = $e->getMessage();
        return str_contains($message, 'Deadlock') || 
               str_contains($message, 'deadlock') ||
               $e->getCode() === '40P01'; // PostgreSQL deadlock code
    }
}

Usage:

TransactionHelper::withDeadlockHandling(function () {
    // Concurrent-safe operation
});

Common Locking Patterns

Inventory Management

public function deductInventory(string $sku, int $quantity): void
{
    DB::transaction(function () use ($sku, $quantity) {
        $inventory = Inventory::where('sku', $sku)
            ->lockForUpdate()
            ->first();

        if ($inventory->available_stock < $quantity) {
            throw new OutOfStockException();
        }

        $inventory->decrement('available_stock', $quantity);
    });
}

Race Condition Prevention

public function claimReward(User $user): void
{
    DB::transaction(function () use ($user) {
        $user = User::where('id', $user->id)
            ->lockForUpdate()
            ->first();

        if ($user->daily_claim_count >= 3) {
            throw new DailyLimitExceededException();
        }

        $user->increment('daily_claim_count');
        $user->increment('points', 100);
    });
}

Best Practices

  1. Keep transactions short - Minimize lock duration
  2. Lock consistently - Luôn lock theo cùng một thứ tự để ngăn deadlocks
  3. Use shared locks for reads - Cho phép concurrent reads
  4. Consider optimistic locking - Cho các scenarios mostly-read
  5. Log deadlocks - Monitor và analyze patterns
  6. Handle exceptions - Retry strategies quan trọng
  7. Test concurrency - Sử dụng load testing tools

Transaction Isolation Levels

Các isolation levels khác nhau cung cấp các guarantees khác nhau:

// Dirty reads possible (risky)
DB::connection()->statement('SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED');

// Prevents dirty reads
DB::connection()->statement('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');

// Prevents dirty and non-repeatable reads
DB::connection()->statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ');

// Strongest - serializable
DB::connection()->statement('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');

Tóm tắt

Xử lý concurrent operations một cách chính xác là quan trọng:

  • Transactions - Atomicity ở database level
  • Pessimistic Locking - Lock rows để ngăn chặn concurrent modifications
  • Optimistic Locking - Version columns để phát hiện conflicts
  • Deadlock Handling - Retry với backoff
  • Isolation Levels - Các guarantees khác nhau cho các nhu cầu khác nhau

Chọn chiến lược đúng cho use case của bạn. Hầu hết các ứng dụng hưởng lợi từ pessimistic locking cho các critical sections và optimistic locking cho các operations read-heavy.

Bình luận