Bulk CUD Operation #
Hampir semua aplikasi punya kebutuhan operasi data dalam jumlah besar: impor ribuan baris dari file Excel, sinkronisasi data dari API eksternal, update status ratusan ribu order sekaligus, atau pembersihan data lama secara periodik. Cara paling naif untuk menangani ini adalah loop di application layer — satu INSERT atau UPDATE per baris, diulang ribuan kali. Di dataset kecil ini tidak terasa bermasalah. Di production dengan ratusan ribu baris, ini bisa menghabiskan menit penuh, menahan lock pada baris-baris yang dimodifikasi, menyebabkan replication lag yang mempengaruhi read replica, dan mengganggu query OLTP yang berjalan bersamaan. Bulk CUD adalah tentang mengurangi overhead per-operasi dengan mengirim lebih banyak pekerjaan dalam satu statement atau satu transaksi — tapi dengan cara yang tidak merusak ketersediaan sistem. Artikel ini membahas strategi terpisah untuk bulk INSERT, UPDATE, dan DELETE, lengkap dengan implementasi Go dan penanganan partial failure.
Mengapa Operasi Per-Row Sangat Mahal #
Setiap operasi CUD yang berdiri sendiri menanggung biaya tetap yang sama, terlepas dari seberapa sedikit data yang dimodifikasi:
Biaya per-operasi untuk satu INSERT tunggal:
──────────────────────────────────────────────────────────────
1. Parse dan validasi SQL
2. Akuisisi row lock
3. Tulis ke data page (mungkin butuh allocate page baru)
4. Update SEMUA index yang relevan (B-Tree rebalancing)
5. Tulis ke WAL/redo log (dengan fsync jika durability = full)
6. Update MVCC metadata (PostgreSQL)
7. Commit → fsync WAL ke disk
8. Lepas lock
9. Return acknowledgement ke client (network roundtrip)
──────────────────────────────────────────────────────────────
Untuk 10.000 INSERT satu per satu:
Langkah 1–9 diulang 10.000 kali
fsync WAL: 10.000 kali → bisa 10–30 detik hanya dari I/O
Untuk 10.000 INSERT dalam satu multi-row statement:
Langkah 1–9 terjadi SEKALI
fsync WAL: 1 kali
Total: ratusan milidetik
──────────────────────────────────────────────────────────────
Perbedaan ini bisa mencapai 100× atau lebih tergantung konfigurasi innodb_flush_log_at_trx_commit dan ukuran data.
Bulk CREATE: Multi-Row INSERT #
Sintaks Multi-Row dan Perbedaannya #
-- ANTI-PATTERN: satu INSERT per baris — N roundtrip, N commit, N WAL fsync
INSERT INTO products (name, price, stock) VALUES ('Laptop', 9500000, 10);
INSERT INTO products (name, price, stock) VALUES ('Mouse', 150000, 50);
INSERT INTO products (name, price, stock) VALUES ('Keyboard', 350000, 30);
-- ... diulang 10.000 kali
-- BENAR: multi-row INSERT — 1 roundtrip, 1 commit, 1 WAL fsync
INSERT INTO products (name, price, stock) VALUES
('Laptop', 9500000, 10),
('Mouse', 150000, 50),
('Keyboard', 350000, 30),
-- ... hingga ratusan atau ribuan baris per statement
('Monitor', 4500000, 15);
INSERT dengan ON DUPLICATE KEY untuk Upsert #
Saat mengimpor data yang mungkin sudah ada (misalnya sinkronisasi dari sistem lain), gunakan ON DUPLICATE KEY UPDATE untuk menggabungkan insert dan update dalam satu operasi:
-- Upsert: insert jika belum ada, update jika sudah ada
INSERT INTO products (sku, name, price, stock, updated_at)
VALUES
('SKU-001', 'Laptop Pro', 9500000, 10, NOW()),
('SKU-002', 'Mouse Wireless', 150000, 50, NOW()),
('SKU-003', 'Keyboard Mekanikal', 350000, 30, NOW())
ON DUPLICATE KEY UPDATE
name = VALUES(name),
price = VALUES(price),
stock = VALUES(stock),
updated_at = VALUES(updated_at);
-- Jika sku sudah ada: update kolom-kolom tersebut
-- Jika sku belum ada: insert baris baru
-- Semua dalam satu roundtrip
Implementasi Bulk INSERT di Go dengan Chunking #
Untuk dataset besar (> 10.000 baris), jangan kirim semua dalam satu statement — ini bisa membuat query terlalu panjang dan menahan lock terlalu lama. Chunk data menjadi batch yang lebih kecil:
type Product struct {
SKU string
Name string
Price int64
Stock int
}
// BulkUpsertProducts melakukan upsert dalam batch untuk menghindari
// lock berkepanjangan dan query yang terlalu besar
func BulkUpsertProducts(ctx context.Context, db *sql.DB, products []Product) error {
const batchSize = 500 // tuning berdasarkan ukuran row dan memory
for i := 0; i < len(products); i += batchSize {
end := i + batchSize
if end > len(products) {
end = len(products)
}
batch := products[i:end]
if err := upsertBatch(ctx, db, batch); err != nil {
return fmt.Errorf("batch %d-%d gagal: %w", i, end, err)
}
// Throttle antar batch: beri napas ke database dan replica
// Sesuaikan dengan beban sistem
if end < len(products) {
time.Sleep(10 * time.Millisecond)
}
}
return nil
}
func upsertBatch(ctx context.Context, db *sql.DB, products []Product) error {
// Bangun query multi-row secara dinamis
valueStrings := make([]string, len(products))
valueArgs := make([]interface{}, 0, len(products)*4)
for i, p := range products {
valueStrings[i] = "(?, ?, ?, ?)"
valueArgs = append(valueArgs, p.SKU, p.Name, p.Price, p.Stock)
}
query := fmt.Sprintf(`
INSERT INTO products (sku, name, price, stock)
VALUES %s
ON DUPLICATE KEY UPDATE
name = VALUES(name),
price = VALUES(price),
stock = VALUES(stock)
`, strings.Join(valueStrings, ","))
_, err := db.ExecContext(ctx, query, valueArgs...)
return err
}
LOAD DATA INFILE untuk Volume Sangat Besar #
Untuk impor file CSV dengan jutaan baris, LOAD DATA INFILE (MySQL) atau COPY FROM (PostgreSQL) adalah cara tercepat karena melewati banyak overhead SQL parsing:
-- MySQL: impor dari file CSV langsung ke tabel
LOAD DATA LOCAL INFILE '/tmp/products.csv'
INTO TABLE products
FIELDS TERMINATED BY ','
ENCLOSED BY '"'
LINES TERMINATED BY '\n'
IGNORE 1 ROWS -- skip header
(sku, name, price, stock);
-- Untuk PostgreSQL:
COPY products (sku, name, price, stock)
FROM '/tmp/products.csv'
WITH (FORMAT CSV, HEADER true, DELIMITER ',');
Perbandingan kecepatan impor 1 juta baris:
──────────────────────────────────────────────────────────────
INSERT satu per satu: ~30 menit
Multi-row INSERT (500/batch): ~2 menit
LOAD DATA INFILE / COPY: ~15 detik
──────────────────────────────────────────────────────────────
LOAD DATA/COPY jauh lebih cepat karena:
- Parsing minimal (format binary/CSV, bukan SQL)
- Index update bisa ditunda dan dilakukan sekali di akhir
- Tidak ada roundtrip per baris
Bulk UPDATE: Strategi Berdasarkan Skenario #
Bulk UPDATE adalah yang paling beragam kasusnya. Strateginya berbeda tergantung apakah semua baris diupdate dengan nilai yang sama, nilai berbeda per baris, atau hanya baris yang memenuhi kondisi tertentu.
Update Nilai Sama untuk Banyak Baris #
-- ANTI-PATTERN: update satu per satu
UPDATE orders SET status = 'expired' WHERE id = 1001;
UPDATE orders SET status = 'expired' WHERE id = 1002;
UPDATE orders SET status = 'expired' WHERE id = 1003;
-- ... N query untuk N order
-- BENAR: WHERE IN — satu query, satu lock acquisition
UPDATE orders
SET status = 'expired', updated_at = NOW()
WHERE id IN (1001, 1002, 1003, ...)
AND status = 'pending'; -- guard kondisi untuk safety
Update Nilai Berbeda per Baris dengan CASE #
-- ANTI-PATTERN: update per baris dengan nilai berbeda
UPDATE products SET price = 9500000 WHERE id = 1;
UPDATE products SET price = 150000 WHERE id = 2;
UPDATE products SET price = 350000 WHERE id = 3;
-- BENAR: CASE WHEN untuk update massal dengan nilai berbeda
UPDATE products
SET price = CASE id
WHEN 1 THEN 9500000
WHEN 2 THEN 150000
WHEN 3 THEN 350000
ELSE price -- ← penting: jangan ubah baris lain
END,
updated_at = NOW()
WHERE id IN (1, 2, 3); -- ← batasi scope dengan WHERE
Implementasi Go untuk Bulk UPDATE dengan Nilai Berbeda #
type PriceUpdate struct {
ProductID int64
NewPrice int64
}
func BulkUpdatePrices(ctx context.Context, db *sql.DB, updates []PriceUpdate) error {
if len(updates) == 0 {
return nil
}
const batchSize = 300
for i := 0; i < len(updates); i += batchSize {
end := i + batchSize
if end > len(updates) {
end = len(updates)
}
batch := updates[i:end]
if err := updatePricesBatch(ctx, db, batch); err != nil {
return fmt.Errorf("price update batch %d-%d gagal: %w", i, end, err)
}
time.Sleep(5 * time.Millisecond) // throttle
}
return nil
}
func updatePricesBatch(ctx context.Context, db *sql.DB, updates []PriceUpdate) error {
// Bangun CASE WHEN secara dinamis
caseExpr := strings.Builder{}
caseExpr.WriteString("CASE id ")
ids := make([]interface{}, 0, len(updates))
args := make([]interface{}, 0, len(updates)+len(updates))
for _, u := range updates {
caseExpr.WriteString("WHEN ? THEN ? ")
args = append(args, u.ProductID, u.NewPrice)
ids = append(ids, u.ProductID)
}
caseExpr.WriteString("ELSE price END")
// Buat placeholder untuk IN clause
placeholders := strings.Repeat("?,", len(ids)-1) + "?"
allArgs := append(args, ids...)
query := fmt.Sprintf(`
UPDATE products
SET price = %s, updated_at = NOW()
WHERE id IN (%s)
`, caseExpr.String(), placeholders)
result, err := db.ExecContext(ctx, query, allArgs...)
if err != nil {
return err
}
affected, _ := result.RowsAffected()
if affected != int64(len(updates)) {
// Beberapa ID tidak ditemukan — log warning tapi tidak error
// Tergantung business requirement
log.Warn("some products not found during bulk price update",
"expected", len(updates), "actual", affected)
}
return nil
}
Bulk UPDATE dengan Tabel Sementara (untuk Jumlah Sangat Besar) #
Untuk update dengan nilai berbeda per baris dalam jumlah sangat besar (> 50.000 baris), CASE WHEN bisa menjadi terlalu panjang. Alternatifnya adalah menggunakan tabel sementara:
-- Langkah 1: buat dan isi tabel sementara
CREATE TEMPORARY TABLE temp_price_updates (
product_id BIGINT NOT NULL,
new_price BIGINT NOT NULL,
PRIMARY KEY (product_id)
);
INSERT INTO temp_price_updates (product_id, new_price) VALUES
(1, 9500000), (2, 150000), (3, 350000), ...;
-- Langkah 2: UPDATE via JOIN ke tabel sementara
UPDATE products p
JOIN temp_price_updates t ON p.id = t.product_id
SET p.price = t.new_price, p.updated_at = NOW();
-- Langkah 3: bersihkan tabel sementara (opsional, otomatis saat session berakhir)
DROP TEMPORARY TABLE IF EXISTS temp_price_updates;
Bulk DELETE: Aman dan Reversible #
Bulk DELETE adalah operasi yang paling berisiko karena tidak bisa di-undo. Strategi yang baik harus mempertimbangkan keamanan data, dampak ke index, dan kemampuan rollback.
Soft Delete sebagai Safety Net #
-- ANTI-PATTERN: hard delete langsung — tidak bisa di-undo
DELETE FROM orders WHERE created_at < '2024-01-01';
-- BENAR — Langkah 1: soft delete dulu (tandai sebagai dihapus)
UPDATE orders
SET deleted_at = NOW()
WHERE created_at < '2024-01-01'
AND deleted_at IS NULL;
-- Verifikasi jumlah yang akan dihapus sebelum hard delete
SELECT COUNT(*) FROM orders
WHERE deleted_at IS NOT NULL
AND deleted_at < NOW() - INTERVAL 7 DAY;
-- Langkah 2: archive ke tabel historis sebelum hard delete
INSERT INTO orders_archive
SELECT * FROM orders
WHERE deleted_at IS NOT NULL
AND deleted_at < NOW() - INTERVAL 7 DAY;
-- Langkah 3: hard delete setelah diverifikasi dan diarsipkan
DELETE FROM orders
WHERE deleted_at IS NOT NULL
AND deleted_at < NOW() - INTERVAL 7 DAY;
Chunked DELETE untuk Menghindari Lock Panjang #
DELETE besar dalam satu transaksi bisa menahan lock pada banyak baris sekaligus, memblokir query lain yang butuh mengakses baris-baris tersebut. Chunked DELETE membagi operasi menjadi batch kecil:
// ChunkedDelete menghapus data lama dalam batch kecil untuk meminimalkan lock
func ChunkedDeleteOldOrders(ctx context.Context, db *sql.DB, olderThan time.Time) (int64, error) {
const chunkSize = 1000
var totalDeleted int64
for {
// Hapus maksimum chunkSize baris per iterasi
result, err := db.ExecContext(ctx, `
DELETE FROM orders
WHERE created_at < ?
AND status IN ('completed', 'cancelled')
AND deleted_at IS NOT NULL
LIMIT ?
`, olderThan, chunkSize)
if err != nil {
return totalDeleted, fmt.Errorf("chunk delete gagal: %w", err)
}
affected, _ := result.RowsAffected()
totalDeleted += affected
if affected == 0 {
break // Tidak ada lagi yang perlu dihapus
}
// Log progress untuk operasi yang lama
log.Info("delete progress", "deleted_so_far", totalDeleted)
// Jeda antar chunk — beri napas ke database dan replica
select {
case <-ctx.Done():
return totalDeleted, ctx.Err() // Hormati cancellation
case <-time.After(50 * time.Millisecond):
}
}
return totalDeleted, nil
}
Jangan pernah menjalankanDELETE FROM tabletanpaWHEREclause atauLIMITuntuk tabel production yang besar. Satu query DELETE tanpa batasan bisa menahan lock seluruh tabel selama menit hingga jam, memblokir semua operasi read dan write. Selalu gunakan chunked DELETE denganLIMITdan jeda antar batch.
Dampak Bulk CUD ke Sistem: Yang Harus Dipantau #
Replication Lag #
Bulk CUD menghasilkan volume besar di binlog/WAL yang harus direplikasi ke read replica. Tanpa throttling, replica bisa tertinggal jauh dari primary:
Dampak bulk INSERT 1 juta baris ke replikasi:
──────────────────────────────────────────────────────────────
Primary:
INSERT selesai dalam 30 detik
Binlog berukuran: ~500 MB
Read replica:
Harus memutar ulang 500 MB operasi binlog
Bisa membutuhkan 2-5 menit
Selama itu: query ke replica membaca data lama
Dampak ke aplikasi:
Cache yang di-invalidate tapi baca dari replica
→ Mendapat data lama → stale data issue
──────────────────────────────────────────────────────────────
Pantau replication lag sebelum dan selama operasi bulk:
-- MySQL: cek replication lag di replica
SHOW SLAVE STATUS\G
-- Lihat: Seconds_Behind_Master
-- PostgreSQL: cek dari primary
SELECT
client_addr,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
(sent_lsn - replay_lsn) AS replication_lag_bytes
FROM pg_stat_replication;
Index Maintenance selama Bulk Write #
Setiap baris yang diinsert atau diupdate memerlukan update di semua index yang ada di tabel tersebut. Untuk tabel dengan banyak index, ini bisa menjadi bottleneck:
-- Lihat berapa index yang ada di tabel target
SHOW INDEX FROM orders;
-- Jika ada 8 index: setiap INSERT mengupdate 8 B-Tree
-- Untuk bulk import besar di maintenance window:
-- Langkah 1: drop index non-primary
ALTER TABLE products DROP INDEX idx_products_name;
ALTER TABLE products DROP INDEX idx_products_category_price;
-- Langkah 2: insert data (jauh lebih cepat tanpa index maintenance)
INSERT INTO products ...; -- atau LOAD DATA INFILE
-- Langkah 3: rebuild index setelah insert selesai
ALTER TABLE products ADD INDEX idx_products_name (name);
ALTER TABLE products ADD INDEX idx_products_category_price (category_id, price);
-- Catatan: langkah ini hanya cocok untuk maintenance window
-- Jangan lakukan di saat traffic aktif karena tabel tidak punya index sementara
Statistik Table yang Usang setelah Bulk DELETE #
Setelah menghapus banyak baris, statistik yang digunakan query planner bisa menjadi tidak akurat, menyebabkan planner memilih execution plan yang salah:
-- Setelah bulk DELETE, update statistik:
-- MySQL
ANALYZE TABLE orders;
-- PostgreSQL
ANALYZE orders;
VACUUM ANALYZE orders; -- juga bersihkan dead tuples
-- Pantau efeknya:
EXPLAIN SELECT * FROM orders WHERE status = 'pending' AND created_at > '2026-01-01';
-- Cek apakah rows estimate sekarang lebih akurat
Idempotency dalam Bulk CUD #
Operasi bulk yang dijalankan berulang (retry setelah gagal, job yang berjalan dua kali) harus menghasilkan state yang sama — tidak boleh ada duplikasi atau state yang tidak konsisten.
-- ANTI-PATTERN: INSERT yang tidak idempotent
INSERT INTO order_logs (order_id, status, created_at)
VALUES (?, 'processed', NOW());
-- Jika dijalankan dua kali: dua baris identik di order_logs
-- BENAR: idempotent dengan ON DUPLICATE KEY DO NOTHING
INSERT INTO order_logs (order_id, status, processed_at)
VALUES (?, 'processed', NOW())
ON DUPLICATE KEY UPDATE processed_at = processed_at;
-- Jika sudah ada: no-op
-- Membutuhkan UNIQUE constraint di (order_id, status)
-- Atau dengan PostgreSQL:
INSERT INTO order_logs (order_id, status, processed_at)
VALUES (?, 'processed', NOW())
ON CONFLICT (order_id, status) DO NOTHING;
// Pola idempotent untuk bulk job yang bisa di-retry:
type OrderProcessResult struct {
OrderID int64
Processed bool
Error error
}
func ProcessOrdersBatch(ctx context.Context, db *sql.DB, orderIDs []int64) []OrderProcessResult {
results := make([]OrderProcessResult, len(orderIDs))
// Cek mana yang sudah diproses sebelumnya
processed, _ := getAlreadyProcessed(ctx, db, orderIDs)
processedSet := make(map[int64]bool)
for _, id := range processed {
processedSet[id] = true
}
// Filter hanya yang belum diproses
var toProcess []int64
for _, id := range orderIDs {
if !processedSet[id] {
toProcess = append(toProcess, id)
}
}
// Proses hanya yang belum
if len(toProcess) > 0 {
_ = bulkMarkProcessed(ctx, db, toProcess)
}
// Build result — idempotent: yang sudah diproses dianggap sukses
for i, id := range orderIDs {
results[i] = OrderProcessResult{
OrderID: id,
Processed: true, // baik yang baru maupun yang sudah ada
}
}
return results
}
Anti-Pattern yang Harus Dihindari #
-- ✗ Anti-pattern 1: INSERT satu per satu dalam loop — N roundtrip, N WAL fsync
-- (di Go)
for _, product := range products {
db.ExecContext(ctx, "INSERT INTO products VALUES (?, ?, ?)",
product.Name, product.Price, product.Stock)
}
-- ✓ Solusi: multi-row INSERT dengan batch 500 baris
-- ✗ Anti-pattern 2: UPDATE tanpa LIMIT atau WHERE yang membatasi scope
UPDATE orders SET status = 'reviewed';
-- Ini mengupdate SEMUA order, menahan lock seluruh tabel
-- ✓ Solusi: UPDATE ... WHERE ... LIMIT N dengan chunking
-- ✗ Anti-pattern 3: bulk DELETE tanpa archive
DELETE FROM logs WHERE created_at < '2025-01-01';
-- Data hilang permanen, tidak ada recovery
-- ✓ Solusi: soft delete → archive ke tabel history → hard delete
-- ✗ Anti-pattern 4: bulk CUD di jam peak traffic
-- Job nightly yang terjadwal pukul 19:00 saat user paling banyak aktif
-- ✓ Solusi: jadwalkan di jam sepi (02:00-05:00), atau gunakan throttling
-- ✗ Anti-pattern 5: satu transaksi untuk seluruh batch besar
tx.Begin()
for i := 0; i < 1000000; i++ {
tx.Exec("INSERT ...") // 1 juta baris dalam satu transaksi
}
tx.Commit()
-- Lock dipegang selama seluruh 1 juta INSERT
-- ✓ Solusi: satu transaksi per batch (500-1000 baris)
-- ✗ Anti-pattern 6: bulk operation tanpa monitoring replication lag
-- Job selesai, tapi read replica masih 5 menit tertinggal
-- Query ke replica mengembalikan data lama
-- ✓ Solusi: pantau replication lag, tambahkan jeda jika lag melebihi threshold
Checklist Bulk CUD yang Aman #
PERSIAPAN SEBELUM OPERASI:
□ Sudah ada backup terbaru (atau snapshot) dari data yang akan dimodifikasi?
□ Operasi dijadwalkan di luar jam peak traffic?
□ Ada mekanisme rollback jika operasi gagal di tengah jalan?
□ Batch size sudah ditentukan (rekomendasi: 500-1000 untuk INSERT, 100-500 untuk DELETE)?
□ Ada throttle antar batch untuk menjaga replication lag?
UNTUK BULK INSERT:
□ Menggunakan multi-row INSERT, bukan satu INSERT per baris?
□ ON DUPLICATE KEY / ON CONFLICT dipakai untuk upsert yang idempotent?
□ Batch size tidak terlalu besar (max ~1000 baris per statement)?
UNTUK BULK UPDATE:
□ WHERE clause membatasi scope dengan jelas (bukan UPDATE semua baris)?
□ Menggunakan IN clause atau CASE WHEN, bukan loop per baris?
□ Kolom yang diupdate hanya yang benar-benar berubah?
UNTUK BULK DELETE:
□ Ada soft delete sebelum hard delete?
□ Ada archiving sebelum hard delete untuk data yang perlu disimpan?
□ Menggunakan chunked DELETE dengan LIMIT, bukan DELETE tanpa LIMIT?
SETELAH OPERASI:
□ Statistik tabel diupdate jika ada DELETE besar (ANALYZE)?
□ Replication lag kembali ke normal?
□ Query planner masih menggunakan execution plan yang benar (EXPLAIN)?
Ringkasan #
- Satu INSERT satu per satu = N WAL fsync — setiap INSERT tunggal memerlukan flush log ke disk untuk durability. Multi-row INSERT melakukan ini sekali untuk ratusan baris. Perbedaan throughput bisa 100× atau lebih.
- Batch size yang tepat adalah kunci — terlalu kecil (10 baris) tidak efisien, terlalu besar (100.000 baris) menahan lock terlalu lama. Angka 500-1000 baris per batch adalah titik awal yang baik untuk kebanyakan kasus.
- Throttle antar batch melindungi replication lag — jeda 10-50ms antar batch memberi replica waktu untuk mengejar, mencegah read replica tertinggal jauh dan menyajikan data stale.
- Bulk DELETE wajib melalui soft delete + archive — hard delete langsung tidak bisa di-undo. Soft delete dulu, verifikasi, archive jika perlu, baru hard delete dengan chunking.
- CASE WHEN untuk update nilai berbeda per baris — lebih efisien dari loop per baris karena hanya satu roundtrip dan satu lock acquisition. Untuk jumlah sangat besar (>50.000), gunakan tabel sementara + UPDATE JOIN.
- Idempotency adalah wajib untuk bulk job — job yang bisa di-retry harus menghasilkan state yang sama meski dijalankan dua kali. Gunakan ON DUPLICATE KEY DO NOTHING atau ON CONFLICT DO NOTHING.
- Pantau replication lag selama dan setelah operasi — bulk CUD yang besar bisa membuat read replica tertinggal menit hingga jam, menyebabkan query ke replica mengembalikan data lama.
- Jalankan ANALYZE setelah bulk DELETE besar — statistik query planner bisa menjadi tidak akurat setelah banyak baris dihapus, menyebabkan planner memilih execution plan yang salah.
← Sebelumnya: Database Roundtrip Berikutnya: Full-Text Index →