Image on Table #

Menyimpan gambar langsung di dalam database adalah salah satu keputusan arsitektur yang paling sering diambil dengan alasan kemudahan — dan paling sering disesali saat sistem sudah besar. Pada skala kecil, menyimpan gambar sebagai BLOB atau Base64 di kolom database terasa praktis: tidak perlu konfigurasi storage eksternal, tidak perlu memikirkan sinkronisasi, semua data ada di satu tempat. Tapi database tidak dirancang untuk menangani data biner besar. Setiap query yang menyentuh tabel berisi BLOB akan membaca seluruh payload biner tersebut, backup membengkak berkali-kali lipat, replikasi menjadi lambat, dan CDN tidak bisa dipakai sama sekali. Artikel ini membahas mengapa pendekatan ini bermasalah secara fundamental, apa perbedaan dampak antara BLOB dan Base64, dan bagaimana arsitektur yang benar untuk menangani file di production.

Dua Cara Salah yang Paling Umum #

Sebelum membahas solusi, penting untuk memahami dua varian penyimpanan gambar di database yang paling sering ditemukan — beserta perbedaan dampaknya terhadap performa.

BLOB: Data Biner Mentah #

BLOB (Binary Large Object) adalah tipe kolom yang menyimpan data biner secara langsung. Di MySQL tersedia empat varian berdasarkan kapasitas maksimumnya:

-- Empat varian BLOB di MySQL:
TINYBLOB   -- maksimum 255 byte
BLOB       -- maksimum 65 KB
MEDIUMBLOB -- maksimum 16 MB  ← paling sering dipakai untuk gambar
LONGBLOB   -- maksimum 4 GB

-- Contoh tabel yang menyimpan gambar sebagai BLOB
CREATE TABLE product_images (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    product_id BIGINT UNSIGNED NOT NULL,
    data       MEDIUMBLOB NOT NULL,   -- ✗ gambar disimpan di sini
    mime_type  VARCHAR(50) NOT NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
);

-- Query untuk mengambil gambar
SELECT data, mime_type FROM product_images WHERE product_id = 42;
-- → Setiap query membaca payload biner penuh, bisa 2–5 MB per baris

Base64: Pemborosan yang Berlapis #

Base64 adalah encoding yang mengubah data biner menjadi teks ASCII — sering dipilih karena “lebih mudah disimpan di kolom TEXT atau JSON”. Masalahnya, Base64 menambahkan overhead ukuran sekitar 33% dibanding data aslinya, dan masalah fundamental penyimpanan gambar di database tetap ada bahkan menjadi lebih buruk.

-- Contoh tabel yang menyimpan gambar sebagai Base64
CREATE TABLE user_avatars (
    user_id    BIGINT UNSIGNED NOT NULL,
    image_b64  LONGTEXT NOT NULL,  -- ✗ string Base64 bisa sangat panjang
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (user_id)
);

-- Gambar 1 MB di disk = ~1.33 MB sebagai Base64 di database
-- Gambar 3 MB di disk = ~4 MB sebagai Base64 di database
-- Plus overhead encoding/decoding setiap baca dan tulis
Perbandingan BLOB vs Base64 sebagai metode penyimpanan gambar di DB:
──────────────────────────────────────────────────────────────────────
  Aspek                │ BLOB           │ Base64
──────────────────────────────────────────────────────────────────────
  Ukuran di storage    │ Sama dengan    │ ~33% lebih besar dari aslinya
                       │ file asli      │
  Overhead encoding    │ Tidak ada      │ Ada — encode saat tulis,
                       │                │ decode saat baca
  Kompatibilitas JSON  │ Tidak langsung │ Bisa disimpan di JSON field
  Indexing             │ Tidak bisa     │ Tidak bisa (terlalu besar)
  Kompresi database    │ Bisa diaktifkan│ Kurang efektif (Base64 sudah
                       │                │ mengembangkan data)
  Dampak ke query      │ Buruk          │ Lebih buruk
──────────────────────────────────────────────────────────────────────
  Kesimpulan: keduanya salah. Base64 hanya menambah masalah di atas
  masalah yang sudah ada di BLOB.

Mengapa Ini Merusak Performa Database #

Masalah penyimpanan gambar di database bukan sekadar soal “memori penuh” atau “disk penuh”. Dampaknya jauh lebih dalam dan menyentuh cara kerja fundamental database engine.

Query yang Tidak Berdosa Ikut Terdampak #

Ini adalah dampak yang paling tidak terduga. Bayangkan kamu hanya ingin mengambil nama dan harga produk — tapi tabelnya menyimpan gambar di kolom yang sama:

-- Tabel products dengan kolom BLOB untuk gambar
CREATE TABLE products (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    name        VARCHAR(255) NOT NULL,
    price       DECIMAL(15, 2) NOT NULL,
    stock       INT UNSIGNED NOT NULL,
    image_data  MEDIUMBLOB,  -- gambar disimpan di sini, rata-rata 2 MB
    PRIMARY KEY (id),
    INDEX idx_products_name (name)
);

-- Query "sederhana" yang sebenarnya sangat mahal
SELECT id, name, price FROM products WHERE name LIKE 'Laptop%';

Meskipun query hanya meminta id, name, dan price, database engine di MySQL (InnoDB) menggunakan clustered index — artinya seluruh data baris tersimpan bersama di B-tree index. Saat membaca baris untuk mendapatkan name dan price, engine harus menavigasi ke halaman data yang juga berisi image_data. Untuk tabel besar, ini berarti:

Dampak BLOB di clustered index (InnoDB):
──────────────────────────────────────────────────────────────
  Tanpa BLOB:
    Row size rata-rata: ~200 byte
    1 halaman InnoDB (16 KB) muat: ~80 baris
    Untuk scan 10.000 baris: ~125 halaman dibaca

  Dengan BLOB 2 MB per baris:
    Row size rata-rata: ~2 MB (data besar disimpan off-page)
    Off-page pointer di setiap baris: overhead tambahan
    Untuk scan 10.000 baris: database harus mengikuti pointer
    off-page untuk setiap baris → I/O berlipat ganda

  Efek bersih: query yang sama berjalan 5–20× lebih lambat
  tergantung seberapa sering BLOB di-akses oleh engine
──────────────────────────────────────────────────────────────

Buffer Pool Terpolusi #

Database menggunakan buffer pool (area memori RAM) untuk menyimpan halaman data yang sering diakses — semacam cache internal. Ketika gambar disimpan di database, halaman BLOB ikut masuk ke buffer pool dan mengusir data yang lebih berguna (index, baris data kecil) keluar dari cache:

Dampak BLOB terhadap buffer pool:
──────────────────────────────────────────────────────────────
  Buffer pool size: 4 GB (konfigurasi umum)

  Tanpa BLOB:
    4 GB bisa menyimpan sekitar 250 ribu halaman (16 KB/halaman)
    → Index dan data sering diakses tetap di RAM
    → Cache hit rate tinggi → query cepat

  Dengan BLOB (rata-rata 2 MB per baris, 10 ribu produk):
    10.000 produk × 2 MB = ~20 GB data BLOB
    Jauh melebihi buffer pool 4 GB
    BLOB terus-menerus masuk dan keluar dari buffer pool
    → Mengusir index dan data kecil yang lebih berguna
    → Cache hit rate anjlok → database banyak baca dari disk
    → I/O disk melonjak → seluruh sistem melambat
──────────────────────────────────────────────────────────────

Replikasi dan Backup Membengkak #

Di arsitektur dengan read replica, setiap perubahan data direplikasi dari primary ke replica melalui binlog. Ketika gambar disimpan di database, setiap operasi INSERT atau UPDATE pada baris dengan BLOB ikut direplikasi dengan payload penuh:

Dampak BLOB terhadap replikasi:
──────────────────────────────────────────────────────────────
  Upload 100 gambar per hari, rata-rata 2 MB per gambar:
    → 200 MB/hari traffic replikasi hanya dari gambar
    → 6 GB/bulan hanya dari BLOB
    → Replica lag meningkat → baca dari replica bisa stale
    → Jika ada 3 replica: 18 GB/bulan traffic replikasi

  Backup harian database:
    Tanpa BLOB:  50 MB (data terstruktur saja)
    Dengan BLOB: 50 MB + akumulasi semua gambar
    Setelah 1 tahun: backup bisa mencapai puluhan GB
    → Restore saat disaster recovery: berjam-jam lebih lama
    → Window downtime saat insiden menjadi jauh lebih panjang
──────────────────────────────────────────────────────────────
Backup database yang berisi BLOB bukan hanya lambat — ia menciptakan risiko operasional nyata. Saat insiden production, setiap menit downtime punya biaya. Restore database yang berisi puluhan GB gambar bisa mengubah insiden 30 menit menjadi insiden 4 jam.

CDN Tidak Bisa Dipakai #

Ini adalah dampak yang sering diabaikan tapi sangat signifikan untuk user experience. CDN bekerja dengan cara menyimpan file di server edge yang dekat dengan user — tapi CDN hanya bisa meng-cache file yang punya URL statis yang bisa di-hit langsung. Jika gambar disimpan di database dan dikembalikan melalui API endpoint, CDN tidak bisa meng-cache hasilnya secara efektif:

Alur delivery gambar dari database (tidak optimal):
──────────────────────────────────────────────────────────────
  User → API Server → Database query → Decode BLOB/Base64
       → Return binary response

  Masalah:
    ✗ Setiap request baru menekan database
    ✗ Tidak bisa pakai CDN karena response dari endpoint dinamis
    ✗ Tidak bisa memanfaatkan browser cache dengan ETag/Last-Modified
    ✗ Tidak ada range request (partial download untuk video/PDF besar)
    ✗ Tidak bisa resize on-the-fly (WebP, thumbnail, dsb.)
    ✗ Bandwidth server terkuras untuk melayani file biner

Alur delivery gambar dari object storage + CDN (optimal):
──────────────────────────────────────────────────────────────
  User → CDN Edge → (cache hit) → File langsung dari edge
       → (cache miss) → Object Storage → CDN cache → User

  Manfaat:
    ✓ Database tidak pernah terlibat dalam delivery gambar
    ✓ CDN cache di edge server dekat user → latency sangat rendah
    ✓ ETag dan Cache-Control bisa dikonfigurasi
    ✓ Range request tersupport untuk media besar
    ✓ Transformasi gambar (resize, format) bisa dilakukan di CDN layer

Arsitektur yang Benar: Object Storage + CDN #

Solusi fundamental adalah memisahkan tanggung jawab: database menyimpan metadata gambar (URL, ukuran, tipe), object storage menyimpan file-nya, dan CDN yang melayani pengirimannya ke user.

Schema Database yang Benar #

-- ANTI-PATTERN: gambar disimpan di database
CREATE TABLE products (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    name       VARCHAR(255) NOT NULL,
    image_data MEDIUMBLOB,      -- ✗ jangan simpan file di sini
    PRIMARY KEY (id)
);

-- BENAR: database hanya menyimpan metadata dan URL
CREATE TABLE products (
    id         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    name       VARCHAR(255) NOT NULL,
    price      DECIMAL(15, 2) NOT NULL,
    PRIMARY KEY (id)
);

CREATE TABLE product_images (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    product_id  BIGINT UNSIGNED NOT NULL,
    storage_key VARCHAR(500) NOT NULL,    -- path di object storage
    cdn_url     VARCHAR(500) NOT NULL,    -- URL publik via CDN
    mime_type   VARCHAR(100) NOT NULL,
    size_bytes  BIGINT UNSIGNED NOT NULL,
    width_px    INT UNSIGNED,
    height_px   INT UNSIGNED,
    is_primary  BOOLEAN NOT NULL DEFAULT FALSE,
    created_at  TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_product_images_product_id (product_id),
    FOREIGN KEY (product_id) REFERENCES products(id)
) ENGINE=InnoDB;

-- Contoh data yang tersimpan
-- storage_key: "products/42/images/hero-20260418-abc123.webp"
-- cdn_url:     "https://cdn.example.com/products/42/images/hero-20260418-abc123.webp"
-- size_bytes:  245760  (240 KB setelah kompresi)
-- mime_type:   "image/webp"

Alur Upload dengan Pre-Signed URL #

Alur terbaik adalah memastikan file besar tidak pernah melewati server aplikasi — client upload langsung ke object storage menggunakan URL sementara yang dibuat oleh backend.

Alur upload dengan pre-signed URL:
──────────────────────────────────────────────────────────────
  1. Client → POST /api/products/42/images/upload-url
             (request izin upload, kirim filename dan mime-type)
     │
     ▼
  2. Backend → Generate pre-signed URL ke S3/GCS
             (URL berlaku 15 menit, spesifik untuk satu file)
     │
     ▼
  3. Backend → Response ke client
             {
               "upload_url": "https://s3.amazonaws.com/bucket/...",
               "storage_key": "products/42/images/hero-abc123.webp",
               "expires_in": 900
             }
     │
     ▼
  4. Client → PUT langsung ke upload_url (ke S3, bukan ke backend)
             (payload file biner dikirim ke S3, bukan melewati server)
     │
     ▼
  5. Client → POST /api/products/42/images/confirm
             { "storage_key": "products/42/images/hero-abc123.webp" }
     │
     ▼
  6. Backend → Verifikasi file di S3 (cek exist + ukuran)
             → Simpan metadata ke database
             → Generate CDN URL
             → Response dengan data gambar lengkap
──────────────────────────────────────────────────────────────
  Manfaat: file besar tidak pernah melewati server aplikasi
  → Backend tetap ringan meskipun user upload file besar

Implementasi di Go (Backend) #

// Handler: generate pre-signed URL untuk upload langsung ke S3
func (h *ImageHandler) GenerateUploadURL(c *fiber.Ctx) error {
    productID := c.Params("product_id")
    
    var req struct {
        Filename string `json:"filename"`
        MimeType string `json:"mime_type"`
        SizeBytes int64 `json:"size_bytes"`
    }
    if err := c.BodyParser(&req); err != nil {
        return c.Status(400).JSON(fiber.Map{"error": "invalid request"})
    }

    // Validasi tipe file yang diizinkan
    allowedMimes := map[string]bool{
        "image/jpeg": true,
        "image/png":  true,
        "image/webp": true,
    }
    if !allowedMimes[req.MimeType] {
        return c.Status(400).JSON(fiber.Map{"error": "unsupported file type"})
    }

    // Generate storage key yang unik
    ext := filepath.Ext(req.Filename)
    storageKey := fmt.Sprintf(
        "products/%s/images/%s%s",
        productID,
        uuid.New().String(),
        ext,
    )

    // Generate pre-signed URL (berlaku 15 menit)
    presigner := s3.NewPresignClient(h.s3Client)
    presignResult, err := presigner.PresignPutObject(c.Context(),
        &s3.PutObjectInput{
            Bucket:      aws.String(h.bucket),
            Key:         aws.String(storageKey),
            ContentType: aws.String(req.MimeType),
        },
        s3.WithPresignExpires(15*time.Minute),
    )
    if err != nil {
        return c.Status(500).JSON(fiber.Map{"error": "failed to generate upload url"})
    }

    return c.JSON(fiber.Map{
        "upload_url":  presignResult.URL,
        "storage_key": storageKey,
        "expires_in":  900,
    })
}

// Handler: konfirmasi setelah upload selesai, simpan metadata ke database
func (h *ImageHandler) ConfirmUpload(c *fiber.Ctx) error {
    productID, _ := strconv.ParseInt(c.Params("product_id"), 10, 64)
    
    var req struct {
        StorageKey string `json:"storage_key"`
    }
    c.BodyParser(&req)

    // Verifikasi file benar-benar ada di S3
    headResult, err := h.s3Client.HeadObject(c.Context(), &s3.HeadObjectInput{
        Bucket: aws.String(h.bucket),
        Key:    aws.String(req.StorageKey),
    })
    if err != nil {
        return c.Status(400).JSON(fiber.Map{"error": "file not found in storage"})
    }

    // Simpan metadata ke database (bukan file-nya)
    cdnURL := fmt.Sprintf("https://cdn.example.com/%s", req.StorageKey)
    _, err = h.db.ExecContext(c.Context(), `
        INSERT INTO product_images
            (product_id, storage_key, cdn_url, mime_type, size_bytes)
        VALUES (?, ?, ?, ?, ?)
    `, productID, req.StorageKey, cdnURL,
       aws.ToString(headResult.ContentType),
       aws.ToInt64(headResult.ContentLength))
    
    if err != nil {
        return c.Status(500).JSON(fiber.Map{"error": "failed to save metadata"})
    }

    return c.JSON(fiber.Map{
        "cdn_url":     cdnURL,
        "storage_key": req.StorageKey,
        "size_bytes":  aws.ToInt64(headResult.ContentLength),
    })
}

Konfigurasi CDN dan Cache Headers #

Setelah file ada di object storage, konfigurasikan CDN untuk delivery yang optimal:

# Contoh konfigurasi Nginx sebagai CDN proxy ke object storage
location /media/ {
    proxy_pass https://your-bucket.s3.amazonaws.com/;
    
    # Cache di CDN edge selama 30 hari
    proxy_cache_valid 200 30d;
    
    # Instruksi cache ke browser
    add_header Cache-Control "public, max-age=2592000, immutable";
    
    # ETag untuk conditional request
    add_header ETag $upstream_http_etag;
    
    # Izinkan range request untuk file besar
    proxy_set_header Range $http_range;
}

Penanganan Hapus dan Konsistensi Data #

Satu aspek yang sering terlupakan: ketika baris di database dihapus, file di object storage tidak otomatis ikut terhapus. Perlu mekanisme yang memastikan keduanya tetap konsisten.

-- ANTI-PATTERN: hapus metadata tanpa hapus file di storage
DELETE FROM product_images WHERE id = 123;
-- → File masih ada di S3, biaya storage terus berjalan
-- → Link lama mungkin masih bisa diakses

-- BENAR: soft delete + async cleanup worker
ALTER TABLE product_images
ADD COLUMN deleted_at TIMESTAMP NULL DEFAULT NULL,
ADD INDEX idx_product_images_deleted_at (deleted_at);

-- Soft delete: tandai sebagai dihapus
UPDATE product_images
SET deleted_at = NOW()
WHERE id = 123;

-- Worker yang berjalan periodik: hapus file dari S3
-- kemudian hard delete dari database
SELECT id, storage_key
FROM product_images
WHERE deleted_at IS NOT NULL
  AND deleted_at < NOW() - INTERVAL 1 HOUR;
-- → Untuk setiap baris: hapus dari S3, lalu DELETE dari DB
// Worker untuk membersihkan file yang sudah di-soft-delete
func (w *CleanupWorker) Run(ctx context.Context) {
    ticker := time.NewTicker(1 * time.Hour)
    for {
        select {
        case <-ticker.C:
            w.cleanupDeletedFiles(ctx)
        case <-ctx.Done():
            return
        }
    }
}

func (w *CleanupWorker) cleanupDeletedFiles(ctx context.Context) {
    rows, err := w.db.QueryContext(ctx, `
        SELECT id, storage_key
        FROM product_images
        WHERE deleted_at IS NOT NULL
          AND deleted_at < NOW() - INTERVAL 1 HOUR
        LIMIT 100
    `)
    if err != nil {
        return
    }
    defer rows.Close()

    for rows.Next() {
        var id int64
        var storageKey string
        rows.Scan(&id, &storageKey)

        // Hapus dari S3 dulu
        _, err := w.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
            Bucket: aws.String(w.bucket),
            Key:    aws.String(storageKey),
        })
        if err != nil {
            continue  // skip, coba lagi nanti
        }

        // Setelah berhasil hapus dari S3, baru hard delete dari DB
        w.db.ExecContext(ctx,
            "DELETE FROM product_images WHERE id = ?", id)
    }
}

Kapan BLOB Masih Dapat Diterima #

Ada kondisi terbatas di mana menyimpan binary data di database masih bisa dipertimbangkan. Penting untuk jujur tentang batasannya:

Kondisi di mana BLOB masih dapat diterima:
──────────────────────────────────────────────────────────────
  ✓ File sangat kecil (ikon, thumbnail < 50 KB)
    → Row size tidak signifikan membebani buffer pool
    → Traffic replikasi tetap kecil

  ✓ Tool internal dengan traffic sangat rendah
    → < 100 request per hari, tidak perlu scale
    → Kemudahan operasional lebih penting dari performa

  ✓ Data yang harus atomis dengan transaksi database
    → Dokumen terenkripsi yang harus konsisten dengan data lain
    → Tidak boleh ada kondisi file ada tapi metadata tidak ada

  ✓ Prototyping / MVP yang belum disentuh user nyata
    → Dengan rencana refactor ke object storage sebelum launch

Kondisi di mana BLOB tidak bisa dibenarkan:
──────────────────────────────────────────────────────────────
  ✗ Gambar produk, avatar user, foto profil
  ✗ Dokumen PDF, file spreadsheet
  ✗ Audio atau video dalam bentuk apapun
  ✗ Sistem yang akan punya lebih dari 10 ribu file
  ✗ Sistem yang membutuhkan CDN atau delivery yang cepat
  ✗ Sistem yang ada SLA performa atau uptime
Jangan gunakan Base64 sebagai alternatif BLOB meskipun untuk file kecil. Base64 hanya menambah ukuran ~33% dan overhead encoding/decoding tanpa manfaat apapun dibanding BLOB. Jika memang harus simpan di database, pakai BLOB. Tapi pertimbangkan kembali apakah memang perlu.

Anti-Pattern yang Harus Dihindari #

-- ✗ Anti-pattern 1: simpan gambar sebagai BLOB di tabel transaksional
CREATE TABLE orders (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    user_id      BIGINT UNSIGNED NOT NULL,
    total        DECIMAL(15, 2) NOT NULL,
    receipt_scan MEDIUMBLOB,  -- scan kwitansi disimpan di tabel orders
    PRIMARY KEY (id)
);
-- ✓ Solusi: upload ke object storage, simpan URL-nya saja
-- receipt_scan_url VARCHAR(500) -- URL ke file di S3

-- ✗ Anti-pattern 2: simpan Base64 di kolom JSON
INSERT INTO users (id, profile)
VALUES (1, '{"name":"Budi","avatar":"data:image/jpeg;base64,/9j/4AAQ..."}');
-- ✓ Solusi: simpan URL avatar, bukan konten gambarnya
-- VALUES (1, '{"name":"Budi","avatar_url":"https://cdn.example.com/avatars/1.webp"}')

-- ✗ Anti-pattern 3: serve gambar dari database via API endpoint
-- GET /api/products/42/image → SELECT image_data FROM products WHERE id = 42
-- ✓ Solusi: kembalikan CDN URL dari database, redirect atau langsung pakai URL-nya
-- GET /api/products/42 → { ..., "image_url": "https://cdn.example.com/..." }

-- ✗ Anti-pattern 4: tidak ada soft delete, hapus database row tanpa cleanup S3
DELETE FROM product_images WHERE product_id = 42;
-- → File di S3 tidak terhapus, biaya storage jalan terus, file bisa diakses via URL lama
-- ✓ Solusi: soft delete + cleanup worker seperti yang dijelaskan di atas

-- ✗ Anti-pattern 5: simpan multiple resolusi gambar sebagai BLOB terpisah
INSERT INTO product_images (product_id, size, data) VALUES
    (42, 'thumbnail', [BLOB 50KB]),
    (42, 'medium', [BLOB 300KB]),
    (42, 'original', [BLOB 2MB]);
-- ✓ Solusi: simpan original di object storage, transformasi dilakukan on-the-fly via CDN
-- (Cloudflare Images, Cloudinary, atau Imgix bisa resize sesuai parameter URL)

Checklist Arsitektur File di Production #

SCHEMA DATABASE:
  □ Tidak ada kolom BLOB atau MEDIUMBLOB di tabel produksi?
  □ Tidak ada kolom TEXT yang menyimpan Base64?
  □ Semua referensi gambar disimpan sebagai URL atau storage key?
  □ Ada tabel metadata gambar yang terpisah dari tabel utama?
  □ Ada kolom deleted_at untuk soft delete?

UPLOAD FLOW:
  □ File tidak pernah melewati server aplikasi (pakai pre-signed URL)?
  □ Ada validasi tipe file dan ukuran maksimum?
  □ Ada konfirmasi upload sebelum metadata disimpan ke database?
  □ Storage key unik dan tidak bisa ditebak?

DELIVERY:
  □ Gambar di-serve via CDN, bukan langsung dari object storage?
  □ Cache-Control header dikonfigurasi dengan tepat?
  □ URL gambar yang dikembalikan API sudah berupa CDN URL?
  □ Ada fallback jika CDN tidak tersedia?

CLEANUP:
  □ Ada mekanisme soft delete?
  □ Ada cleanup worker yang menghapus file dari object storage?
  □ Worker berjalan setelah delay (untuk menghindari race condition)?
  □ Cleanup error di-log dan ada retry mechanism?

Ringkasan #

  • Database bukan tempat menyimpan file — database dioptimalkan untuk data terstruktur, query, dan transaksi. BLOB dan Base64 melawan desain fundamental ini dan merusak performa secara sistemik.
  • Base64 lebih buruk dari BLOB — selain semua masalah BLOB, Base64 menambah ~33% ukuran data dan overhead encoding/decoding setiap baca dan tulis, tanpa manfaat apapun.
  • BLOB mencemari buffer pool — gambar yang masuk ke cache database mengusir index dan data kecil yang jauh lebih berguna, menurunkan cache hit rate dan memperberat I/O disk.
  • Pre-signed URL adalah pola upload yang benar — client upload langsung ke object storage, server hanya menyimpan metadata. File besar tidak pernah melewati server aplikasi.
  • Database hanya menyimpan metadata dan URLstorage_key, cdn_url, mime_type, size_bytes adalah data yang perlu disimpan di database, bukan konten file-nya.
  • CDN adalah komponen wajib untuk delivery gambar — object storage + CDN memberikan cache global, latency rendah, ETag, range request, dan transformasi gambar on-the-fly yang tidak bisa didapat dari database.
  • Soft delete + cleanup worker untuk konsistensi — jangan hapus metadata database sebelum file di object storage berhasil dihapus. Worker async yang berjalan dengan delay menghindari race condition.
  • Aturan emas: database untuk data, object storage untuk file, CDN untuk delivery — setiap komponen digunakan sesuai fungsinya, sistem menjadi lebih cepat, lebih murah, dan lebih mudah di-scale.

← Sebelumnya: RAND()   Berikutnya: SQL Function Overuse →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact