LaravelDatabaseTransaction

Transaksi Database dan Deadlock di Laravel: Panduan Lengkap untuk Aplikasi Andal

Oleh Aditya Nursyahbani5 menit baca69
Bagikan:
Transaksi Database dan Deadlock di Laravel: Panduan Lengkap untuk Aplikasi Andal

Database consistency is one of the most important aspects of modern web applications. Whether you are building a financial platform, e-commerce system, healthcare application, or enterprise dashboard, handling concurrent database operations safely is critical.

In Laravel, database transactions help ensure that multiple operations either succeed together or fail together. But as your application scales, you may also encounter deadlocks, race conditions, and inconsistent data.

In this guide, we will explore:

  • What database transactions are
  • How transactions work in Laravel
  • Common transaction mistakes
  • How deadlocks happen
  • Strategies to avoid deadlocks
  • Retry mechanisms
  • Performance optimization tips
  • Real-world Laravel examples

What Is a Database Transaction?

A database transaction is a group of database operations executed as a single unit.

A transaction follows the ACID principles:

  • Atomicity β†’ all operations succeed or fail together
  • Consistency β†’ database remains valid
  • Isolation β†’ transactions do not interfere incorrectly
  • Durability β†’ committed changes are permanent

Example:

When a user purchases a product:

  1. Reduce product stock
  2. Create order record
  3. Save payment transaction
  4. Generate invoice

If one operation fails, everything should rollback.

Without transactions, partial data can corrupt your system.


Basic Laravel Transaction Example

Laravel provides a clean transaction API.

php
use Illuminate\Support\Facades\DB; DB::transaction(function () { Order::create([ 'user_id' => auth()->id(), 'total' => 250000, ]); Product::where('id', 1)->decrement('stock'); });

If any query fails, Laravel automatically rolls back the transaction.


Manual Transaction Control

Sometimes you need more control.

php
DB::beginTransaction(); try { $order = Order::create([ 'user_id' => auth()->id(), 'total' => 500000, ]); Payment::create([ 'order_id' => $order->id, 'status' => 'paid', ]); DB::commit(); } catch (\Throwable $e) { DB::rollBack(); report($e); throw $e; }

Manual transactions are useful when:

  • Complex exception handling is required
  • External services are involved
  • You need conditional commits
  • Multiple transaction scopes exist

Understanding Deadlocks

Deadlocks occur when two transactions wait for each other indefinitely.

Example scenario:

Transaction A:

  1. Locks row A
  2. Wants row B

Transaction B:

  1. Locks row B
  2. Wants row A

Both transactions become stuck.

The database detects this and kills one transaction.

Typical MySQL deadlock error:

text
SQLSTATE[40001]: Serialization failure: 1213 Deadlock found

Real Laravel Deadlock Example

Imagine a wallet transfer system.

php
DB::transaction(function () use ($sender, $receiver, $amount) { $senderWallet = Wallet::find($sender); $receiverWallet = Wallet::find($receiver); $senderWallet->decrement('balance', $amount); $receiverWallet->increment('balance', $amount); });

If two users transfer money simultaneously in opposite directions, deadlocks may occur.


Preventing Deadlocks in Laravel

1. Lock Rows in Consistent Order

Always access rows in the same order.

Bad:

php
$walletA = Wallet::find($sender); $walletB = Wallet::find($receiver);

Better:

php
$ids = collect([$sender, $receiver])->sort(); $wallets = Wallet::whereIn('id', $ids) ->lockForUpdate() ->get();

Consistent locking order significantly reduces deadlocks.


Using lockForUpdate()

Laravel supports pessimistic locking.

php
DB::transaction(function () { $product = Product::where('id', 1) ->lockForUpdate() ->first(); if ($product->stock > 0) { $product->decrement('stock'); } });

This prevents other transactions from modifying the row until the transaction finishes.

Useful for:

  • Inventory systems
  • Financial balances
  • Seat booking systems
  • Ticket reservations

Shared Locks with sharedLock()

Sometimes you only need read consistency.

php
$product = Product::where('id', 1) ->sharedLock() ->first();

This allows multiple readers while preventing writes.


Automatic Deadlock Retries

Laravel transactions support retry attempts.

php
DB::transaction(function () { // critical operation }, 5);

Laravel retries the transaction up to 5 times if deadlocks occur.

This is extremely useful for high-concurrency applications.


Keeping Transactions Small

Large transactions increase lock duration.

Avoid this:

php
DB::transaction(function () { // 1000 queries // external API calls // file uploads // slow operations });

Better:

php
// external API first $response = Http::post(...); DB::transaction(function () use ($response) { // only database operations });

Short transactions improve:

  • Performance
  • Concurrency
  • Scalability
  • Deadlock prevention

Avoid External Calls Inside Transactions

Never perform slow operations inside a transaction.

Bad:

php
DB::transaction(function () { Order::create([...]); Http::post('https://payment-gateway.com/pay'); });

If the external API is slow, database locks remain active.

Instead:

php
$order = DB::transaction(function () { return Order::create([...]); }); Http::post('https://payment-gateway.com/pay');

Queue Jobs After Commit

Laravel provides afterCommit support.

php
ProcessInvoice::dispatch($order) ->afterCommit();

This ensures jobs only run after successful transactions.

Without this, queued jobs may execute before data is committed.


Nested Transactions in Laravel

Laravel supports nested transactions using savepoints.

php
DB::transaction(function () { User::create([...]); DB::transaction(function () { Profile::create([...]); }); });

However, excessive nesting can complicate debugging.

Keep transaction architecture simple whenever possible.


Isolation Levels

Databases support different isolation levels.

Common levels:

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

MySQL default:

text
REPEATABLE READ

PostgreSQL default:

text
READ COMMITTED

Higher isolation improves consistency but reduces concurrency.


Detecting Transaction Bottlenecks

Common symptoms:

  • Slow queries
  • High database CPU
  • Frequent deadlocks
  • Lock wait timeout errors
  • API latency spikes

Useful MySQL commands:

sql
SHOW ENGINE INNODB STATUS;

Useful PostgreSQL monitoring:

sql
SELECT * FROM pg_locks;

Optimizing High-Concurrency Laravel Systems

Use Indexes Properly

Missing indexes increase lock scanning.

Example:

sql
CREATE INDEX orders_user_id_index ON orders(user_id);

Avoid Table Scans

This query is dangerous under heavy load:

php
Order::where('status', 'pending') ->update(['status' => 'processing']);

It may lock many rows.

Process records in chunks instead.

php
Order::where('status', 'pending') ->chunkById(100, function ($orders) { foreach ($orders as $order) { $order->update([ 'status' => 'processing' ]); } });

Atomic Updates in Laravel

Atomic queries reduce race conditions.

Instead of:

php
$product->stock -= 1; $product->save();

Use:

php
Product::where('id', $id) ->decrement('stock');

This executes safely at the database level.


Building a Safe Wallet Transfer System

Example production-ready pattern:

php
DB::transaction(function () use ($fromId, $toId, $amount) { $wallets = Wallet::whereIn('id', [$fromId, $toId]) ->orderBy('id') ->lockForUpdate() ->get() ->keyBy('id'); $sender = $wallets[$fromId]; $receiver = $wallets[$toId]; if ($sender->balance < $amount) { throw new Exception('Insufficient balance'); } $sender->decrement('balance', $amount); $receiver->increment('balance', $amount); Transaction::create([ 'from_wallet_id' => $fromId, 'to_wallet_id' => $toId, 'amount' => $amount, ]); }, 5);

This implementation:

  • Prevents race conditions
  • Prevents negative balances
  • Reduces deadlocks
  • Retries automatically
  • Maintains consistency

Common Laravel Transaction Mistakes

Using Transactions Everywhere

Not every operation needs a transaction.

Transactions introduce:

  • Lock overhead
  • Reduced concurrency
  • Longer query time

Use them only when consistency matters.


Long-Running Loops Inside Transactions

Bad:

php
DB::transaction(function () { foreach ($largeData as $item) { // slow processing } });

Process data outside transactions whenever possible.


Forgetting Retry Logic

Deadlocks are normal in high-concurrency systems.

Your application should handle them gracefully.


Database Choice Matters

MySQL

Strengths:

  • Excellent Laravel compatibility
  • Good transactional support
  • Mature ecosystem

Weaknesses:

  • Gap locks can surprise developers
  • Deadlocks under heavy writes

PostgreSQL

Strengths:

  • Better concurrency handling
  • Advanced locking
  • Strong consistency guarantees

Weaknesses:

  • Slightly more complex tuning

For enterprise Laravel systems, PostgreSQL is increasingly popular.


Final Thoughts

Laravel makes database transactions simple, but building reliable concurrent systems requires deeper understanding.

As your application grows:

  • Concurrency increases
  • Lock contention rises
  • Deadlocks become inevitable
  • Query efficiency matters more

The key principles are:

  • Keep transactions short
  • Lock rows consistently
  • Use atomic operations
  • Retry deadlocks automatically
  • Avoid unnecessary locks
  • Move slow operations outside transactions

Mastering transactions and deadlock prevention is essential for building scalable Laravel applications that remain stable under real-world traffic.