PostgreSQL #
PostgreSQL adalah database open-source paling kaya fitur yang tersedia saat ini — dan pilihan utama untuk aplikasi Ruby on Rails modern. Berbeda dari MySQL yang mengutamakan kecepatan, PostgreSQL mengutamakan kepatuhan standar SQL, keandalan ACID yang ketat, dan fitur-fitur canggih yang tidak ada di database lain: tipe data JSONB yang bisa di-query dan di-index, array native, UUID sebagai primary key, full-text search bawaan, LISTEN/NOTIFY untuk event-driven architecture, dan COPY untuk bulk import yang sangat cepat. Gem pg adalah driver PostgreSQL untuk Ruby yang matang, cepat, dan mendukung semua fitur PostgreSQL modern. Artikel ini membahas segalanya dari koneksi dasar hingga fitur-fitur unik PostgreSQL yang membuat ia pilihan terbaik untuk aplikasi Ruby.
Instalasi #
# Ubuntu / Debian — install library PostgreSQL
sudo apt install libpq-dev
# macOS dengan Homebrew
brew install libpq
brew link --force libpq # agar gem pg bisa menemukannya
# CentOS / RHEL / Fedora
sudo dnf install postgresql-devel
# Install gem
gem install pg
# Gemfile
gem 'pg', '~> 1.5' # driver PostgreSQL
gem 'sequel', '~> 5.75' # query builder opsional
Koneksi dan Connection Pool #
require 'pg'
# Koneksi dasar
conn = PG.connect(
host: "localhost",
port: 5432,
dbname: "toko_db",
user: "appuser",
password: ENV["PG_PASSWORD"],
connect_timeout: 10,
sslmode: "require" # "disable", "allow", "prefer", "require", "verify-full"
)
puts "PostgreSQL #{conn.server_version}"
conn.close
# Koneksi via URL (lebih ringkas)
conn = PG.connect(ENV["DATABASE_URL"])
# DATABASE_URL=postgresql://appuser:password@localhost:5432/toko_db
# Dengan blok — auto-close
PG.connect(dbname: "toko_db", user: "appuser", password: ENV["PG_PASSWORD"]) do |conn|
hasil = conn.exec("SELECT version()")
puts hasil.first["version"]
end
# Connection Pool dengan connection_pool gem
require 'connection_pool'
POOL = ConnectionPool.new(size: 10, timeout: 5) do
PG.connect(
host: ENV.fetch("PG_HOST", "localhost"),
dbname: ENV.fetch("PG_DATABASE", "toko_db"),
user: ENV.fetch("PG_USER", "appuser"),
password: ENV["PG_PASSWORD"]
)
end
POOL.with { |conn| conn.exec("SELECT 1") }
Query Dasar #
SELECT dengan exec dan exec_params #
require 'pg'
conn = PG.connect(dbname: "toko_db", user: "appuser", password: ENV["PG_PASSWORD"])
# exec — query tanpa parameter (hati-hati SQL injection!)
hasil = conn.exec("SELECT * FROM produk LIMIT 10")
# Iterasi hasil
hasil.each do |baris|
puts "#{baris['id']}: #{baris['nama']} - Rp #{baris['harga']}"
end
# Akses seperti Hash
puts hasil.first["nama"] # => "Laptop"
puts hasil.first["harga"] # => "15000000" (String! perlu konversi)
puts hasil.first["harga"].to_i # => 15000000
# exec_params — parameterized query (CARA YANG BENAR)
# PostgreSQL menggunakan $1, $2, ... sebagai placeholder
hasil = conn.exec_params(
"SELECT * FROM produk WHERE harga < $1 AND aktif = $2",
[5_000_000, true]
)
hasil.each { |b| puts "#{b['nama']}: #{b['harga']}" }
# Metadata
puts hasil.ntuples # jumlah baris
puts hasil.nfields # jumlah kolom
puts hasil.fields.inspect # ["id", "nama", "harga", "stok", "aktif"]
# Tipe konversi — pg mengembalikan semua nilai sebagai String
# Gunakan type map untuk konversi otomatis
conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn)
hasil = conn.exec_params("SELECT id, harga, aktif FROM produk WHERE id = $1", [1])
baris = hasil.first
puts baris["id"].class # => Integer (bukan String!)
puts baris["harga"].class # => BigDecimal
puts baris["aktif"].class # => TrueClass / FalseClass
CRUD Lengkap dengan Parameterized Query #
class ProdukPostgresRepository
def initialize(conn)
@conn = conn
# Aktifkan type mapping untuk konversi otomatis
@conn.type_map_for_results = PG::BasicTypeMapForResults.new(@conn)
@conn.type_map_for_queries = PG::BasicTypeMapForQueries.new(@conn)
end
# CREATE — gunakan RETURNING untuk dapat baris yang baru diinsert
def buat(nama:, harga:, stok:, kategori_id:, deskripsi: nil)
hasil = @conn.exec_params(
<<~SQL,
INSERT INTO produk (nama, harga, stok, kategori_id, deskripsi, aktif, created_at)
VALUES ($1, $2, $3, $4, $5, true, NOW())
RETURNING *
SQL
[nama, harga, stok, kategori_id, deskripsi]
)
hasil.first
end
# READ
def temukan(id)
hasil = @conn.exec_params(
"SELECT p.*, k.nama AS kategori_nama FROM produk p
LEFT JOIN kategori k ON p.kategori_id = k.id
WHERE p.id = $1",
[id]
)
hasil.first
end
def cari_semua(aktif: true, limit: 50, offset: 0, urut: "created_at DESC")
# Sanitasi kolom urut — jangan pakai parameter untuk ORDER BY
kolom_aman = %w[nama harga stok created_at].include?(urut.split.first) ? urut : "created_at DESC"
hasil = @conn.exec_params(
"SELECT * FROM produk WHERE aktif = $1 ORDER BY #{kolom_aman} LIMIT $2 OFFSET $3",
[aktif, limit, offset]
)
hasil.to_a
end
def cari_teks(kata_kunci)
# Full-text search PostgreSQL
hasil = @conn.exec_params(
<<~SQL,
SELECT *, ts_rank(to_tsvector('indonesian', nama || ' ' || COALESCE(deskripsi, '')),
plainto_tsquery('indonesian', $1)) AS rank
FROM produk
WHERE to_tsvector('indonesian', nama || ' ' || COALESCE(deskripsi, ''))
@@ plainto_tsquery('indonesian', $1)
AND aktif = true
ORDER BY rank DESC
LIMIT 20
SQL
[kata_kunci]
)
hasil.to_a
end
# UPDATE
def perbarui(id, **atribut)
return false if atribut.empty?
kolom_valid = %i[nama harga stok deskripsi aktif]
atribut = atribut.slice(*kolom_valid)
return false if atribut.empty?
set_clause = atribut.keys.each_with_index.map { |k, i| "#{k} = $#{i + 2}" }.join(", ")
nilai = [id] + atribut.values
# Geser index karena $1 untuk id
set_clause = atribut.keys.each_with_index.map { |k, i| "#{k} = $#{i + 1}" }.join(", ")
nilai = atribut.values + [id]
hasil = @conn.exec_params(
"UPDATE produk SET #{set_clause}, updated_at = NOW() WHERE id = $#{nilai.length} RETURNING *",
nilai
)
hasil.first
end
# DELETE soft
def nonaktifkan(id)
hasil = @conn.exec_params(
"UPDATE produk SET aktif = false, updated_at = NOW() WHERE id = $1 RETURNING id",
[id]
)
hasil.ntuples > 0
end
end
Transaksi #
# Transaksi dasar
conn.transaction do |c|
c.exec_params(
"UPDATE rekening SET saldo = saldo - $1 WHERE id = $2",
[500_000, 1]
)
c.exec_params(
"UPDATE rekening SET saldo = saldo + $1 WHERE id = $2",
[500_000, 2]
)
# Jika ada exception → ROLLBACK otomatis
# Jika sukses → COMMIT otomatis
end
# Savepoint — transaksi bertingkat
conn.transaction do |c|
c.exec("SAVEPOINT sp_awal")
begin
c.exec_params("INSERT INTO audit_log (aksi) VALUES ($1)", ["transaksi_dimulai"])
c.exec("SAVEPOINT sp_operasi")
# Operasi berisiko
c.exec_params("UPDATE produk SET stok = stok - $1 WHERE id = $2", [1, produk_id])
c.exec("RELEASE SAVEPOINT sp_operasi")
rescue PG::Error => e
c.exec("ROLLBACK TO SAVEPOINT sp_operasi")
raise e
end
end
# Isolation level — penting untuk concurrency
conn.transaction(isolation: :serializable) do |c|
# Serializable mencegah phantom read, non-repeatable read, dirty read
# Lebih lambat tapi paling aman untuk operasi finansial
saldo = c.exec_params("SELECT saldo FROM rekening WHERE id = $1 FOR UPDATE", [1]).first["saldo"].to_i
raise "Saldo tidak cukup" if saldo < 500_000
c.exec_params("UPDATE rekening SET saldo = saldo - $1 WHERE id = $2", [500_000, 1])
end
Tipe Data PostgreSQL yang Unik #
PostgreSQL punya tipe data yang tidak ada di database lain — ini salah satu alasan utama ia dipilih untuk aplikasi modern:
JSONB — JSON yang Bisa Di-index dan Di-query #
# Simpan dokumen JSON
conn.exec_params(
"INSERT INTO produk_detail (produk_id, spesifikasi) VALUES ($1, $2::jsonb)",
[1, JSON.generate({ prosesor: "Intel i7", ram: "16GB", storage: "512GB SSD" })]
)
# Query di dalam JSONB
hasil = conn.exec(
"SELECT nama, spesifikasi->>'prosesor' AS prosesor
FROM produk_detail pd
JOIN produk p ON p.id = pd.produk_id
WHERE spesifikasi->>'ram' = '16GB'"
)
# Operator JSONB
# -> mengembalikan JSON, ->> mengembalikan teks
# @> cek apakah berisi (containment)
# ? cek apakah key ada
hasil = conn.exec(
"SELECT * FROM produk_detail
WHERE spesifikasi @> '{\"ram\": \"16GB\"}'::jsonb"
)
# ActiveRecord dengan JSONB
class ProdukDetail < ApplicationRecord
# Kolom spesifikasi adalah jsonb
store_accessor :spesifikasi, :prosesor, :ram, :storage
scope :dengan_ram, ->(ram) {
where("spesifikasi->>'ram' = ?", ram)
}
end
detail = ProdukDetail.new
detail.prosesor = "Intel i7"
detail.ram = "16GB"
detail.save!
ProdukDetail.dengan_ram("16GB")
Array — Kolom Array Native #
# Buat tabel dengan kolom array
conn.exec(
"CREATE TABLE IF NOT EXISTS artikel (
id SERIAL PRIMARY KEY,
judul TEXT NOT NULL,
tag TEXT[], -- array teks
skor INTEGER[] -- array integer
)"
)
# Insert dengan array
conn.exec_params(
"INSERT INTO artikel (judul, tag) VALUES ($1, $2)",
["Ruby Tips", ["ruby", "pemrograman", "tips"]]
)
# Query dengan array operator
# ANY — cek apakah nilai ada dalam array
hasil = conn.exec(
"SELECT * FROM artikel WHERE 'ruby' = ANY(tag)"
)
# @> — array containment
hasil = conn.exec(
"SELECT * FROM artikel WHERE tag @> ARRAY['ruby', 'tips']"
)
# ActiveRecord dengan array
class Artikel < ApplicationRecord
# tag adalah TEXT[] di PostgreSQL
def self.dengan_tag(tag)
where("? = ANY(tag)", tag)
end
def tambah_tag(tag_baru)
update(tag: (tag + [tag_baru]).uniq)
end
end
artikel = Artikel.create!(judul: "Belajar Ruby", tag: ["ruby", "dasar"])
Artikel.dengan_tag("ruby")
UUID — Primary Key yang Lebih Aman #
# Aktifkan extension pgcrypto atau uuid-ossp
conn.exec("CREATE EXTENSION IF NOT EXISTS pgcrypto")
# Buat tabel dengan UUID primary key
conn.exec(
"CREATE TABLE IF NOT EXISTS sesi (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id INTEGER NOT NULL,
token TEXT NOT NULL,
expired_at TIMESTAMPTZ NOT NULL
)"
)
# Insert — id diisi otomatis
hasil = conn.exec_params(
"INSERT INTO sesi (user_id, token, expired_at)
VALUES ($1, $2, NOW() + INTERVAL '7 days')
RETURNING id",
[1, SecureRandom.hex(32)]
)
puts hasil.first["id"] # => "550e8400-e29b-41d4-a716-446655440000"
# ActiveRecord dengan UUID
class Sesi < ApplicationRecord
# config/initializers/generators.rb:
# config.generators { |g| g.orm :active_record, primary_key_type: :uuid }
# atau di model:
self.primary_key = :id # tipe uuid di migrasi
end
hstore — Key-Value Store #
# Aktifkan extension hstore
conn.exec("CREATE EXTENSION IF NOT EXISTS hstore")
# Buat tabel dengan kolom hstore
conn.exec(
"CREATE TABLE IF NOT EXISTS konfigurasi (
id SERIAL PRIMARY KEY,
nama TEXT NOT NULL,
setting hstore
)"
)
# Insert hstore
conn.exec_params(
"INSERT INTO konfigurasi (nama, setting) VALUES ($1, $2::hstore)",
["email", "host => smtp.gmail.com, port => 587, tls => true"]
)
# Query hstore
conn.exec("SELECT setting->'host' AS smtp_host FROM konfigurasi WHERE nama = 'email'")
# ActiveRecord dengan hstore
class Konfigurasi < ApplicationRecord
store_accessor :setting, :host, :port, :tls
end
COPY — Bulk Import/Export Ultra-Cepat #
COPY adalah cara tercepat untuk memasukkan atau mengekspor data dalam jumlah besar:
# COPY FROM — import CSV ke PostgreSQL
conn.copy_data("COPY produk (nama, harga, stok, kategori_id) FROM STDIN WITH CSV") do
File.foreach("produk.csv") do |baris|
conn.put_copy_data(baris)
end
end
puts "Import selesai"
# COPY TO — export data ke CSV
File.open("export_produk.csv", "w") do |f|
conn.copy_data("COPY (SELECT nama, harga, stok FROM produk WHERE aktif = true) TO STDOUT WITH CSV HEADER") do
while (data = conn.get_copy_data)
f.write(data)
end
end
end
# Benchmark perbandingan
# INSERT satu per satu: 10.000 baris ≈ 10 detik
# INSERT batch: 10.000 baris ≈ 1 detik
# COPY: 10.000 baris ≈ 0.1 detik (100x lebih cepat dari INSERT biasa!)
LISTEN/NOTIFY — Real-time Event #
PostgreSQL punya mekanisme pub/sub bawaan yang bisa digunakan untuk notifikasi real-time:
require 'pg'
# PUBLISHER — kirim notifikasi dari satu koneksi
def kirim_notifikasi(conn, channel, payload = nil)
if payload
conn.exec_params("NOTIFY #{conn.escape_identifier(channel)}, $1", [payload])
else
conn.exec("NOTIFY #{conn.escape_identifier(channel)}")
end
end
# Database trigger untuk auto-notify saat data berubah
conn.exec(<<~SQL)
CREATE OR REPLACE FUNCTION notify_produk_berubah()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('produk_berubah',
json_build_object(
'aksi', TG_OP,
'id', NEW.id,
'nama', NEW.nama,
'updated', NEW.updated_at
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_produk_berubah ON produk;
CREATE TRIGGER trg_produk_berubah
AFTER INSERT OR UPDATE ON produk
FOR EACH ROW EXECUTE FUNCTION notify_produk_berubah();
SQL
# SUBSCRIBER — dengarkan notifikasi di thread terpisah
Thread.new do
listener = PG.connect(dbname: "toko_db", user: "appuser", password: ENV["PG_PASSWORD"])
listener.exec("LISTEN produk_berubah")
puts "Mendengarkan perubahan produk..."
loop do
listener.wait_for_notify(10) do |channel, pid, payload|
data = JSON.parse(payload)
puts "Produk berubah: #{data['aksi']} - #{data['nama']} (ID: #{data['id']})"
# Invalidasi cache, broadcast ke WebSocket, dst
end
end
ensure
listener.exec("UNLISTEN produk_berubah")
listener.close
end
# Test — update produk dan lihat notifikasi muncul
sleep 1
conn.exec_params("UPDATE produk SET harga = harga + 1000 WHERE id = $1", [1])
conn.exec("COMMIT")
sleep 2
ActiveRecord dengan PostgreSQL di Rails #
# config/database.yml
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
username: <%= ENV["PG_USER"] || "postgres" %>
password: <%= ENV["PG_PASSWORD"] %>
host: <%= ENV["PG_HOST"] || "localhost" %>
port: <%= ENV["PG_PORT"] || 5432 %>
timeout: 5000
# Opsi koneksi PostgreSQL
variables:
statement_timeout: 10000 # timeout query 10 detik
lock_timeout: 5000 # timeout lock 5 detik
development:
<<: *default
database: toko_development
test:
<<: *default
database: toko_test
production:
<<: *default
url: <%= ENV["DATABASE_URL"] %>
# Migrasi dengan fitur PostgreSQL
class BuatTabelProduk < ActiveRecord::Migration[7.1]
def change
enable_extension "pgcrypto" # untuk gen_random_uuid()
enable_extension "unaccent" # untuk full-text search tanpa aksen
create_table :produk, id: :uuid, default: "gen_random_uuid()" do |t|
t.string :nama, null: false, limit: 200
t.text :deskripsi
t.decimal :harga, null: false, precision: 15, scale: 2
t.integer :stok, null: false, default: 0
t.boolean :aktif, null: false, default: true
t.references :kategori, null: false, foreign_key: true, type: :uuid
t.text :tag, array: true, default: [] # Array
t.jsonb :metadata, default: {} # JSONB
t.tsvector :search_vector # Full-text search
t.timestamps
end
# Standard indexes
add_index :produk, :aktif
add_index :produk, :harga
add_index :produk, [:kategori_id, :aktif]
# GIN index untuk array dan JSONB — jauh lebih cepat dari B-tree untuk ini
add_index :produk, :tag, using: :gin
add_index :produk, :metadata, using: :gin
# GiST atau GIN untuk full-text search
add_index :produk, :search_vector, using: :gin
# Partial index — hanya index baris yang aktif
add_index :produk, :harga, where: "aktif = true", name: "idx_produk_harga_aktif"
end
end
# Model dengan fitur PostgreSQL
class Produk < ApplicationRecord
belongs_to :kategori
# Scope untuk array
scope :dengan_tag, ->(tag) { where("? = ANY(tag)", tag) }
scope :dengan_semua_tag, ->(tags) { where("tag @> ARRAY[?]::text[]", tags) }
# Scope untuk JSONB
scope :dengan_warna, ->(warna) {
where("metadata @> ?", { warna: warna }.to_json)
}
# Full-text search
scope :cari, ->(kata) {
where(
"to_tsvector('indonesian', nama || ' ' || COALESCE(deskripsi, '')) @@ plainto_tsquery('indonesian', ?)",
kata
)
.order(
Arel.sql("ts_rank(to_tsvector('indonesian', nama || ' ' || COALESCE(deskripsi, '')), plainto_tsquery('indonesian', #{connection.quote(kata)})) DESC")
)
}
before_save :perbarui_search_vector
private
def perbarui_search_vector
self.search_vector = Produk.connection.execute(
"SELECT to_tsvector('indonesian', #{Produk.connection.quote(nama + ' ' + deskripsi.to_s)})"
).first["to_tsvector"]
end
end
# Penggunaan
Produk.dengan_tag("laptop")
Produk.dengan_semua_tag(["laptop", "gaming"])
Produk.dengan_warna("hitam")
Produk.cari("laptop gaming ringan")
EXPLAIN ANALYZE — Debug Query Lambat #
# Analisis performa query
def explain(conn, query, *params)
explain_sql = "EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) #{query}"
hasil = conn.exec_params(explain_sql, params)
hasil.each { |b| puts b["QUERY PLAN"] }
end
explain(conn,
"SELECT * FROM produk WHERE kategori_id = $1 AND aktif = true ORDER BY harga",
2
)
# Output contoh:
# Sort (cost=156.23..162.34 rows=2445 width=...) (actual time=2.345..2.567 rows=234)
# Sort Key: harga
# Sort Method: quicksort Memory: 45kB
# -> Bitmap Heap Scan on produk (cost=48.23..124.56 rows=2445 width=...)
# Recheck Cond: (kategori_id = 2)
# Filter: aktif
# Heap Blocks: exact=89
# -> Bitmap Index Scan on idx_produk_kategori (cost=0.00..47.62)
# Index Cond: (kategori_id = 2)
# Planning Time: 0.456 ms
# Execution Time: 3.123 ms
# Di Rails — gunakan bullet gem untuk N+1, dan pg_query gem untuk parsing EXPLAIN
Sequel dengan PostgreSQL #
require 'sequel'
DB = Sequel.connect(
adapter: "postgres",
host: "localhost",
database: "toko_db",
user: "appuser",
password: ENV["PG_PASSWORD"],
max_connections: 10
)
# Query standar
DB[:produk].all
DB[:produk].where(aktif: true).order(Sequel.desc(:created_at)).limit(10).all
# PostgreSQL-specific dengan Sequel
# JSONB
DB[:produk_detail].where(
Sequel.pg_json_op(:metadata).get_text("warna") => "hitam"
).all
# Array containment
DB[:produk].where(
Sequel.pg_array(:tag).contains(["ruby", "programming"])
).all
# Full-text search
DB[:produk].where(
Sequel.lit("to_tsvector('indonesian', nama) @@ plainto_tsquery('indonesian', ?)", "laptop")
).all
# UPSERT (INSERT ON CONFLICT)
DB[:produk].insert_conflict(
target: :sku,
update: { harga: Sequel[:excluded][:harga], updated_at: Sequel::CURRENT_TIMESTAMP }
).insert(sku: "SKU001", nama: "Laptop Pro", harga: 15_000_000)
# Transaksi
DB.transaction(isolation: :serializable) do
DB[:rekening].where(id: 1).update(saldo: Sequel[:saldo] - 500_000)
DB[:rekening].where(id: 2).update(saldo: Sequel[:saldo] + 500_000)
end
Tipe Data PostgreSQL ↔ Ruby #
PostgreSQL Ruby (pg gem + type_map) Keterangan
────────────────────────────────────────────────────────────────
INTEGER / BIGINT Integer Bilangan bulat
SMALLINT Integer
NUMERIC / DECIMAL BigDecimal Presisi tinggi
REAL / FLOAT8 Float Floating point
BOOLEAN true / false Boolean native
TEXT / VARCHAR String
CHAR(n) String Fixed-length
BYTEA String (encoding: BINARY) Data biner
UUID String UUID string
DATE Date Hanya tanggal
TIMESTAMP Time Waktu lokal
TIMESTAMPTZ Time (dengan offset) Waktu dengan TZ
INTERVAL String / PG::Interval Durasi waktu
JSON String JSON mentah
JSONB String → parse ke Hash JSONB terindeks
TEXT[] Array of String Array native
INTEGER[] Array of Integer
HSTORE Hash Key-value store
TSVECTOR String Full-text vector
POINT / LINE PG::Point, etc. Tipe geometri
INET / CIDR String Alamat IP / network
Ringkasan #
exec_paramsbukanexec— selalu gunakanexec_paramsdengan placeholder$1, $2, ...untuk input apapun yang tidak kamu kontrol; ini mencegah SQL injection dan lebih efisien karena PostgreSQL bisa me-cache execution plan.RETURNING *untuk data setelah INSERT/UPDATE — PostgreSQL mendukungRETURNINGyang mengembalikan baris yang baru dimodifikasi; jauh lebih bersih dari SELECT terpisah setelah INSERT.- Type mapping untuk konversi otomatis —
conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn)agar integer, boolean, dan float tidak perlu dikonversi manual dari String.- JSONB untuk data semi-terstruktur — simpan atribut yang bervariasi per record sebagai JSONB; bisa di-index dengan GIN dan di-query dengan operator
->,->>,@>.- Array native lebih baik dari tabel junction untuk data sederhana —
tag TEXT[]lebih cepat untuk array kecil yang tidak perlu relasi kompleks.COPYuntuk bulk import — 100x lebih cepat dari INSERT satu per satu untuk ribuan baris; gunakan untuk seed data, migrasi, atau import CSV.LISTEN/NOTIFYuntuk event real-time — kombinasikan dengan trigger database untuk invalidasi cache atau broadcast ke WebSocket tanpa perlu Redis Pub/Sub untuk kasus sederhana.- GIN index untuk JSONB, array, dan full-text search — B-tree tidak efektif untuk tipe data ini; gunakan
add_index :tabel, :kolom, using: :gin.- Partial index untuk kolom yang sering difilter —
WHERE aktif = truepada index mengurangi ukuran index secara drastis jika sebagian besar data difilter.EXPLAIN ANALYZEsebelum deploy — analisis execution plan untuk query yang kamu tulis; cariSeq Scanpada tabel besar sebagai sinyal butuh index.