Database Transaction, Indexing, dan Query Optimization di Laravel

Tiga topik ini sering dipelajari terpisah-pisah, padahal ketiganya bicara soal hal yang sama: membuat operasi database kamu benar, aman, dan cepat.

Transaction menjawab: “Bagaimana kalau operasi di tengah jalan gagal?” Indexing menjawab: “Kenapa query ini lambat padahal datanya tidak banyak-banyak amat?” Query Optimization menjawab: “Bagaimana mengambil data sebanyak yang dibutuhkan — tidak lebih, tidak kurang?”

Kita bahas ketiganya satu per satu, dari fondasi sampai praktik nyata.


🔒 Bagian 1 — Database Transaction

Analoginya

Bayangin kamu transfer uang ke teman lewat internet banking. Prosesnya ada dua langkah: saldo kamu dikurangi, lalu saldo temanmu ditambah.

Sekarang bayangin di tengah proses itu — setelah saldo kamu dikurangi tapi sebelum saldo temanmu ditambah — koneksi internet putus. Apa yang terjadi?

Tanpa transaction: uangmu hilang, tapi temanmu tidak menerima apapun.

Dengan transaction: karena langkah kedua gagal, langkah pertama juga dibatalkan. Saldo kamu kembali seperti semula. Tidak ada yang hilang.

Itulah Transaction — sekelompok operasi yang berhasil semuanya atau gagal semuanya. Tidak ada setengah-setengah.


Cara 1 — DB::transaction() (yang paling bersih)

Ini cara yang paling direkomendasikan untuk sebagian besar kasus. Laravel otomatis melakukan commit kalau semua berjalan lancar, dan rollback kalau ada exception.

use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($request) {
    $order = Order::create([
        'user_id' => auth()->id(),
        'total'   => $request->total,
    ]);

    Payment::create([
        'order_id' => $order->id,
        'amount'   => $request->total,
        'status'   => 'pending',
    ]);

    // Kurangi stok produk
    $request->product->decrement('stock', $request->qty);

    // Kalau baris mana pun di atas melempar exception,
    // semua operasi di atas otomatis dibatalkan
});

📌 Kalau ada exception di dalam closure, Laravel memanggil rollback secara otomatis lalu melempar ulang exception-nya. Kamu tidak perlu nulis try-catch sama sekali.


Cara 2 — Manual Transaction (untuk kontrol penuh)

Kadang kamu butuh logika yang lebih granular — misalnya menangani jenis exception yang berbeda secara berbeda, atau melakukan sesuatu sebelum rollback.

DB::beginTransaction();

try {
    $order = Order::create([
        'user_id' => auth()->id(),
        'total'   => $request->total,
    ]);

    Payment::create([
        'order_id' => $order->id,
        'amount'   => $request->total,
    ]);

    $request->product->decrement('stock', $request->qty);

    DB::commit(); // Semua berhasil — simpan permanen

} catch (\Throwable $e) {
    DB::rollBack(); // Ada yang gagal — batalkan semua

    // Log error, kirim notifikasi, atau tangani secara spesifik
    \Log::error('Order gagal dibuat', ['error' => $e->getMessage()]);

    throw $e; // Lempar ulang supaya ditangani di layer atas
}

📌 Selalu gunakan \Throwable bukan \Exception di catch — Throwable menangkap semua error termasuk PHP Fatal Error, sedangkan Exception hanya menangkap exception yang di-throw secara eksplisit.


Yang Sering Dilupakan — Transaction Bukan Berarti Aman dari Segalanya

Transaction melindungi konsistensi data — tapi tidak melindungi dari race condition ketika dua request datang bersamaan.

Contoh nyata: dua pengguna memesan produk terakhir (stok = 1) di waktu yang hampir bersamaan. Keduanya membaca stok = 1, keduanya lolos validasi, keduanya masuk transaction. Hasilnya: stok jadi -1.

Solusinya adalah pessimistic locking — kunci row yang sedang diproses supaya request lain harus menunggu:

DB::transaction(function () use ($productId, $qty) {
    // lockForUpdate() menambahkan FOR UPDATE ke query
    // Row ini dikunci sampai transaction selesai
    $product = Product::lockForUpdate()->findOrFail($productId);

    if ($product->stock < $qty) {
        throw new \Exception('Stok tidak mencukupi');
    }

    $product->decrement('stock', $qty);
    Order::create([...]);
});

📇 Bagian 2 — Indexing

Analoginya

Bayangin kamu punya buku telepon setebal 1.000 halaman dan mau cari nomor “Budi Santoso”.

Tanpa index: kamu baca dari halaman pertama satu per satu sampai ketemu. Ini yang disebut full table scan.

Dengan index: buku telepon diurutkan alfabetis. Kamu langsung buka ke huruf B, cari Budi, selesai dalam hitungan detik.

Index di database bekerja persis seperti itu — struktur data terpisah yang memungkinkan database melompat langsung ke baris yang relevan tanpa membaca seluruh tabel.


Index Dasar — Kolom yang Sering di-WHERE

// Di migration
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('email')->unique(); // otomatis punya index
    $table->string('username');
    $table->timestamps();

    $table->index('username'); // tambahkan index manual
});

Atau tambahkan ke tabel yang sudah ada:

Schema::table('users', function (Blueprint $table) {
    $table->index('username');
});

Kolom yang hampir selalu perlu diindex:

  • Kolom yang sering masuk WHEREstatus, email, username
  • Kolom yang sering di-ORDER BYcreated_at
  • Foreign key — user_id, order_id (Laravel tidak otomatis index ini sejak versi terbaru, kecuali foreignId())

Composite Index — Untuk Query Multi-Kolom

Ini yang paling sering diabaikan, padahal dampaknya besar.

Kalau query kamu sering pakai kombinasi dua kolom sekaligus:

// Query yang sering dijalankan:
Order::where('company_id', $id)->where('status', 'pending')->get();

Membuat dua index terpisah (company_id dan status) tidak optimal — database masih harus menggabungkan hasilnya.

Yang tepat adalah satu composite index:

$table->index(['company_id', 'status']);
// MySQL sekarang bisa langsung loncat ke baris yang tepat

📌 Urutan kolom dalam composite index itu penting. Taruh kolom dengan cardinality tinggi (nilai uniknya banyak) di depan. Dan index ini hanya berguna kalau query menyertakan kolom pertama — WHERE company_id = ? bisa pakai index ini, tapi WHERE status = ? saja tidak bisa.


Jangan Index Semua Kolom

Index bukan gratis — setiap kali ada INSERT, UPDATE, atau DELETE, semua index yang terkait harus diperbarui juga. Terlalu banyak index justru memperlambat operasi tulis.

Kolom yang sebaiknya tidak diindex:

  • Kolom yang nilainya sering berubah — password, token, updated_at
  • Kolom dengan sedikit nilai unik — kolom boolean is_active (hanya 0 atau 1, tidak efisien)
  • Kolom teks panjang — description, body, notes

Verifikasi dengan EXPLAIN

Sebelum berasumsi index kamu sudah dipakai, verifikasi dulu:

// Di Tinker atau controller sementara
$query = Order::where('company_id', 1)->where('status', 'pending');
dd(DB::select('EXPLAIN ' . $query->toSql(), $query->getBindings()));

Perhatikan kolom type di output — kalau nilainya ALL, artinya full table scan. Kalau ref atau range, artinya index dipakai.


⚡ Bagian 3 — Query Optimization

Setelah N+1 dibereskan dengan eager loading dan index sudah benar, masih ada level optimasi berikutnya — menulis query yang hanya mengambil apa yang benar-benar dibutuhkan.


Ambil Kolom Spesifik, Bukan SELECT *

SELECT * mengambil semua kolom — termasuk yang tidak kamu pakai sama sekali. Untuk tabel dengan banyak kolom atau kolom teks panjang, ini membuang bandwidth dan memori.

// ❌ Ambil semua kolom — termasuk content artikel yang panjang
$posts = Post::all();

// ✅ Ambil hanya yang dibutuhkan untuk daftar artikel
$posts = Post::select('id', 'title', 'slug', 'created_at')->get();

// Atau dengan with() sekaligus
$posts = Post::select('id', 'title', 'user_id')
             ->with('user:id,name') // user juga hanya ambil id dan name
             ->get();

📌 Ketika pakai with() + select(), pastikan foreign key (user_id) tetap ada di select — tanpa itu, Eloquent tidak bisa menghubungkan relasinya.


pluck() — Kalau Hanya Butuh Satu Kolom

Kalau kamu hanya butuh nilai satu kolom sebagai array, pluck() jauh lebih efisien dari get():

// ❌ Ambil semua kolom hanya untuk diambil name-nya
$names = User::all()->pluck('name');

// ✅ Query langsung SELECT name FROM users
$names = User::pluck('name');

// Dengan key → value
$users = User::pluck('name', 'id');
// Hasil: [1 => 'Budi', 2 => 'Sari', ...]

withCount() — Hitung Relasi Tanpa Muat Datanya

Sering butuh menampilkan jumlah komentar per post tapi tidak butuh isi komentarnya? Jangan muat semua komentar hanya untuk dihitung.

// ❌ Muat semua komentar hanya untuk count
$posts = Post::with('comments')->get();
foreach ($posts as $post) {
    echo $post->comments->count(); // sudah termuat semua datanya
}

// ✅ Hanya tambahkan kolom hitungan — tidak muat data komentar
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
    echo $post->comments_count; // satu integer, bukan koleksi
}

// Bisa dikombinasikan
$posts = Post::withCount(['comments', 'likes'])->get();

Di balik layar, withCount() menambahkan subquery COUNT(*) — jauh lebih ringan dari memuat semua data relasi.


whereHas() — Filter Berdasarkan Kondisi Relasi

Untuk mengambil data yang punya relasi tertentu dengan kondisi spesifik:

// Post yang punya minimal satu komentar yang disetujui
$posts = Post::whereHas('comments', function ($query) {
    $query->where('is_approved', true);
})->get();

// User yang punya lebih dari 5 post
$users = User::whereHas('posts', function ($query) {
    // filter kondisi di sini
}, '>', 5)->get();

// Kebalikannya — post yang tidak punya komentar sama sekali
$posts = Post::doesntHave('comments')->get();

Cache — Untuk Query yang Hasilnya Jarang Berubah

Kalau kamu punya query yang berat tapi hasilnya jarang berubah (misalnya daftar kategori, konfigurasi aplikasi, statistik harian), simpan hasilnya di cache — jangan hit database setiap request.

use Illuminate\Support\Facades\Cache;

// Ambil dari cache kalau ada, jalankan query kalau tidak ada
$categories = Cache::remember('categories', now()->addHours(6), function () {
    return Category::where('is_active', true)
                   ->orderBy('sort_order')
                   ->get();
});

Ketika data berubah, invalidasi cache-nya:

// Di Observer atau setelah operasi update
Cache::forget('categories');

// Atau lebih aman: hapus dan isi ulang sekaligus
Cache::put('categories', Category::where('is_active', true)->get(), now()->addHours(6));

📌 Tiga pertanyaan sebelum pasang cache: Apa yang mahal? Key apa yang dipakai? Kapan harus expired atau diinvalidasi? Kalau belum tahu jawabannya — jangan cache dulu.


🗺️ Gambaran Besar — Urutan Optimasi yang Benar

Jangan loncat langsung ke cache kalau masalah dasarnya belum beres.

Urutan yang benar:

1. Bereskan N+1 dulu
   └── Pakai with(), load()

2. Pastikan index sudah benar
   └── Terutama kolom WHERE dan foreign key
   └── Composite index untuk query multi-kolom

3. Ambil hanya yang dibutuhkan
   └── select() kolom spesifik
   └── pluck() untuk satu kolom
   └── withCount() untuk agregasi
   └── whereHas() untuk filter relasi

4. Pakai transaction untuk operasi multi-langkah
   └── DB::transaction() untuk kasus umum
   └── lockForUpdate() untuk cegah race condition

5. Cache kalau query berat dan data jarang berubah
   └── Cache::remember() dengan TTL yang masuk akal
   └── Invalidasi cache saat data diupdate

📊 Ringkasan Cepat

TeknikMasalah yang Diselesaikan
DB::transaction()Konsistensi — semua atau tidak sama sekali
lockForUpdate()Race condition pada data yang diperebutkan
index()Query lambat karena full table scan
index(['col1', 'col2'])Query multi-kolom yang lambat
select('col1', 'col2')Terlalu banyak data dibawa ke memori
pluck('col')Hanya butuh satu kolom sebagai array
withCount('relation')Hitung relasi tanpa muat seluruh datanya
whereHas('relation')Filter berdasarkan kondisi di relasi
Cache::remember()Query berat yang hasilnya jarang berubah

💡 Ingat!

Dari semua teknik di atas, ada satu yang paling sering diabaikan dan paling mudah dilupakan: composite index.

Banyak developer sudah rajin index kolom satu-satu, tapi query yang pakai kombinasi dua kolom tetap lambat karena tidak ada composite index-nya. Setiap kali kamu nulis where()->where() di query yang sering dieksekusi, tanyakan: “Apakah dua kolom ini punya composite index?”

Dan untuk transaction — satu aturan sederhana yang cukup: kalau satu operasi gagal dan itu berarti operasi lain tidak boleh jadi, pakai transaction. Tidak perlu overthinking kapan harus pakai, cukup tanyakan pertanyaan itu.