Join 1-N punya efek samping yang sudah mulai kita kenali: satu pelanggan dengan tiga pesanan menghasilkan tiga baris hasil join, dan pesanan berisi dua item menggandakan dirinya dua kali. Efek samping ini normal pada join — tetapi normal bukan berarti aman. Ketika baris berganda masuk ke SUM, angka laporan membengkak tanpa error apa pun; ini salah satu sumber angka salah paling klasik di dunia SQL. Chapter penutup section ini membedah gejalanya dari akar, lalu membangun tiga pertahanan berlapis: agregasi lebih dulu di subquery sebelum join, DISTINCT untuk pertanyaan keberadaan, dan EXISTS sebagai pola alternatif dari section subqueries yang akan datang.
Menyiapkan Tiga Tabel Latihan #
Kita pakai kembali rantai tiga tabel pelanggan → pesanan → detail_pesanan dari chapter multiple joins — sebarannya memang dirancang kaya pola duplikasi: Budi memiliki tiga pesanan, empat pesanan memuat dua item, dan produk Gula Aren dibeli Budi di dua pesanan berbeda:
DROP DATABASE IF EXISTS belajar_join;
CREATE DATABASE belajar_join;
USE belajar_join;
CREATE TABLE pelanggan (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(50) NOT NULL,
kota VARCHAR(30) NOT NULL
);
CREATE TABLE pesanan (
id INT AUTO_INCREMENT PRIMARY KEY,
pelanggan_id INT NOT NULL,
tanggal DATE NOT NULL,
total DECIMAL(12,2) NOT NULL,
FOREIGN KEY (pelanggan_id) REFERENCES pelanggan(id)
);
CREATE TABLE detail_pesanan (
id INT AUTO_INCREMENT PRIMARY KEY,
pesanan_id INT NOT NULL,
produk VARCHAR(40) NOT NULL,
jumlah INT NOT NULL,
harga DECIMAL(10,2) NOT NULL,
FOREIGN KEY (pesanan_id) REFERENCES pesanan(id)
);
INSERT INTO pelanggan (nama, kota) VALUES
('Budi Santoso', 'Jakarta'),
('Siti Rahayu', 'Bandung'),
('Andi Wijaya', 'Surabaya'),
('Rina Melati', 'Yogyakarta'),
('Dewi Lestari', 'Bandung'),
('Tono Hartono', 'Medan'),
('Sari Puspita', 'Jakarta');
INSERT INTO pesanan (pelanggan_id, tanggal, total) VALUES
(1, '2026-01-05', 170000.00),
(2, '2026-01-07', 50000.00),
(1, '2026-01-10', 62000.00),
(1, '2026-01-12', 15000.00),
(3, '2026-01-15', 137000.00),
(2, '2026-01-18', 85000.00),
(4, '2026-01-08', 125000.00);
INSERT INTO detail_pesanan (pesanan_id, produk, jumlah, harga) VALUES
(1, 'Kopi Gayo', 2, 55000.00),
(1, 'Gula Aren', 4, 15000.00),
(2, 'Teh Melati', 2, 25000.00),
(3, 'Kopi Toraja', 1, 62000.00),
(4, 'Gula Aren', 1, 15000.00),
(5, 'Kopi Toraja', 1, 62000.00),
(5, 'Teh Melati', 3, 25000.00),
(6, 'Kopi Gayo', 1, 55000.00),
(6, 'Gula Aren', 2, 15000.00),
(7, 'Kopi Gayo', 2, 55000.00),
(7, 'Gula Aren', 1, 15000.00);Output yang Dihasilkan:
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Database changed
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
Query OK, 7 rows affected (0.00 sec)
Records: 7 Duplicates: 0 Warnings: 0
Query OK, 7 rows affected (0.00 sec)
Records: 7 Duplicates: 0 Warnings: 0
Query OK, 11 rows affected (0.00 sec)
Records: 11 Duplicates: 0 Warnings: 0Angka acuannya jelas: tujuh pesanan berjumlah total 644000.00, dan setiap nilai pesanan.total konsisten dengan penjumlahan jumlah × harga di detailnya. Kejujuran angka acuan inilah yang akan kita uji satu per satu terhadap berbagai bentuk query.
Duplikasi Baris pada Join 1-N #
Gejala paling dasar: satu pelanggan, banyak pesanan. Query berikut mencatat seluruh pesanan seorang pelanggan:
SELECT p.nama, pn.tanggal, pn.total
FROM pelanggan p
INNER JOIN pesanan pn ON p.id = pn.pelanggan_id
WHERE p.nama = 'Budi Santoso'
ORDER BY pn.id;Output yang Dihasilkan:
+--------------+------------+-----------+
| nama | tanggal | total |
+--------------+------------+-----------+
| Budi Santoso | 2026-01-05 | 170000.00 |
| Budi Santoso | 2026-01-10 | 62000.00 |
| Budi Santoso | 2026-01-12 | 15000.00 |
+--------------+------------+-----------+
3 rows in set (0.00 sec)Tiga baris tampil untuk satu orang — Budi memang memiliki tiga pesanan, dan join 1-N menduplikasi baris pelanggan sekali per pesanan. Duplikasi seperti ini benar untuk laporan “daftar pesanan beserta pemiliknya”: setiap baris memang satu pesanan nyata. Masalah baru muncul ketika pertanyaan berubah arah: “berapa total belanja Budi?” Kalau kita menjumlahkan kolom pn.total pada join bertingkat yang turun sampai detail, setiap baris pesanan sudah tergandakan — dan itulah kecelakaan berikutnya.
SUM Melonjong: Jebakan Klasik #
Pertanyaan sesederhana “berapa total nilai seluruh pesanan?” memuat jebakan yang menelan banyak laporan produksi. Patokan benarnya dulu, dihitung langsung dari satu tabel:
SELECT SUM(pn.total) AS total_benar FROM pesanan pn;Output yang Dihasilkan:
+-------------+
| total_benar |
+-------------+
| 644000.00 |
+-------------+
1 row in set (0.00 sec)Nilai benarnya 644000.00. Sekarang kita lahap join dua tabel — pesanan bergabung dengan detail_pesanan — lalu jumlahkan angka yang sama dan detailnya berdampingan:
SELECT SUM(pn.total) AS total_pesanan, SUM(d.jumlah * d.harga) AS total_detail
FROM pesanan pn
INNER JOIN detail_pesanan d ON d.pesanan_id = pn.id;Output yang Dihasilkan:
+---------------+--------------+
| total_pesanan | total_detail |
+---------------+--------------+
| 1161000.00 | 644000.00 |
+---------------+--------------+
1 row in set (0.00 sec)Dua angka di satu baris hasil kini saling bertengkar: SUM(pn.total) melaporkan 1161000.00 — hampir dua kali lipat nilai sebenarnya — sedangkan SUM(d.jumlah * d.harga) masih benar 644000.00. Mari kita bedah mengapa keduanya berbeda nasib:
- Join menggandakan baris pesanan: pesanan nomor 1 memiliki dua item, sehingga setelah join baris
pn.total = 170000.00muncul dua kali. Pesanan 5, 6, dan 7 juga dua item; totalnya pesanan bernilai ganda dua sebanyak empat baris (pesanan 1, 5, 6, 7) dan satu item sisanya normal. SUM(pn.total)menjumlahkan baris ganda:170000 + 170000 + 50000 + 62000 + 15000 + 137000 + 137000 + 85000 + 85000 + 125000 + 125000 = 1161000. Setiap duplikasi ikut terjumlah — angka melebih-lebihkan tanpa satu pun error muncul.SUM(d.jumlah * d.harga)selamat: setiap baris detail hanya muncul satu kali dalam join, sehingga penjumlahan item tidak terpengaruh. Aturan praktisnya: jumlahkan kolom dari tabel sisi “banyak” terakhir, atau pastikan satu baris sumber tidak tergandakan.
Jebakan ini persis pola kasus nyata orders vs order_items: laporan omzet dari join SUM(orders.amount) melebihi pembukuan karena setiap order terhitung sejumlah item-nya. Karena tidak ada error yang muncul, angka salah ini bisa hidup berbulan-bulan di dashboard sebelum audit menemukannya.
Solusi Utama: Agregat Lebih Dulu di Subquery #
Solusi yang benar untuk laporan per-pesanan dari data detail: runtuhkan sisi banyak menjadi satu baris per kunci lebih dulu, baru join. Subquery di FROM (disebut derived table) men-agregasi detail_pesanan menjadi satu baris per pesanan_id:
SELECT pn.id, pn.total, d.subtotal_detail
FROM pesanan pn
INNER JOIN (
SELECT pesanan_id, SUM(jumlah * harga) AS subtotal_detail
FROM detail_pesanan
GROUP BY pesanan_id
) d ON d.pesanan_id = pn.id
ORDER BY pn.id;Output yang Dihasilkan:
+----+-----------+-----------------+
| id | total | subtotal_detail |
+----+-----------+-----------------+
| 1 | 170000.00 | 170000.00 |
| 2 | 50000.00 | 50000.00 |
| 3 | 62000.00 | 62000.00 |
| 4 | 15000.00 | 15000.00 |
| 5 | 137000.00 | 137000.00 |
| 6 | 85000.00 | 85000.00 |
| 7 | 125000.00 | 125000.00 |
+----+-----------+-----------------+
7 rows in set (0.00 sec)Kini setiap pesanan tampil tepat satu baris: subquery SELECT pesanan_id, SUM(jumlah * harga) ... GROUP BY pesanan_id merangkum sebelas baris detail menjadi tujuh baris — satu per pesanan — sebelum join terjadi, sehingga tabel pesanan tidak pernah tergandakan. Nilai pn.total dan subtotal_detail berjajar di setiap baris dan cocok semuanya, sekalian membuktikan konsistensi data kita. Penjumlahan ulang di atas hasil bersih ini kini aman: SUM(pn.total) akan kembali menghasilkan 644000.00 karena tidak ada baris ganda lagi. Pola yang sama berlaku untuk laporan per pelanggan — agregasi pesanan per pelanggan dulu, baru digabung ke pelanggan:
SELECT p.nama, r.total_belanja
FROM pelanggan p
INNER JOIN (
SELECT pelanggan_id, SUM(total) AS total_belanja
FROM pesanan
GROUP BY pelanggan_id
) r ON r.pelanggan_id = p.id
ORDER BY r.total_belanja DESC;Output yang Dihasilkan:
+--------------+---------------+
| nama | total_belanja |
+--------------+---------------+
| Budi Santoso | 247000.00 |
| Andi Wijaya | 137000.00 |
| Siti Rahayu | 135000.00 |
| Rina Melati | 125000.00 |
+--------------+---------------+
4 rows in set (0.00 sec)Empat pelanggan pembeli tampil dengan total yang benar — Budi 247000.00 (170000 + 62000 + 15000), Andi 137000.00, Siti 135000.00, Rina 125000.00. Derived table r datang sudah berbentuk satu-baris-per-pelanggan, sehingga join ke pelanggan bebas duplikasi. Kita baru mencicipi subquery di sini; section khusus subqueries akan membedahnya jauh lebih dalam.
DISTINCT: Kapan Sah, Kapan Menutup Masalah #
Pertanyaan “pelanggan mana yang pernah membeli Gula Aren?” adalah pertanyaan keberadaan — jawabannya daftar nama, sekali disebut cukup. Join ke detail memproduksi empat baris karena Budi membeli produk itu di dua pesanan berbeda:
SELECT p.nama
FROM pelanggan p
INNER JOIN pesanan pn ON p.id = pn.pelanggan_id
INNER JOIN detail_pesanan d ON d.pesanan_id = pn.id
WHERE d.produk = 'Gula Aren'
ORDER BY p.nama;Output yang Dihasilkan:
+--------------+
| nama |
+--------------+
| Budi Santoso |
| Budi Santoso |
| Rina Melati |
| Siti Rahayu |
+--------------+
4 rows in set (0.00 sec)Budi Santoso tampil dua kali. DISTINCT tepat untuk kasus ini — pertanyaannya memang soal “ada siapa saja”, bukan “berapa kali”:
SELECT DISTINCT p.nama
FROM pelanggan p
INNER JOIN pesanan pn ON p.id = pn.pelanggan_id
INNER JOIN detail_pesanan d ON d.pesanan_id = pn.id
WHERE d.produk = 'Gula Aren'
ORDER BY p.nama;Output yang Dihasilkan:
+--------------+
| nama |
+--------------+
| Budi Santoso |
| Rina Melati |
| Siti Rahayu |
+--------------+
3 rows in set (0.00 sec)Hasilnya tiga nama bersih. Batas kegunaannya harus dipahami tegas: DISTINCT melebur baris hasil yang identik, dan karenanya hanya menyelamatkan laporan daftar-nama. Ia tidak menyelamatkan agregat — SUM(DISTINCT pn.total) justru bencana lain, karena dua pesanan bernilai sama akan dihitung satu kali, dan angka jadi kurang. Ia juga menyembunyikan gejala: bila kita menaruh DISTINCT hanya karena “hasil join kok duplikat semua?”, kadang duplikasi itu memang wajar (1-N), kadang menandakan ON yang keliru — DISTINCT merapikan tampilan tanpa memperbaiki akar masalahnya.
EXISTS: Alternatif Tanpa Duplikasi #
Pola ketiga menjawab pertanyaan keberadaan yang sama tanpa menyentuh duplikasi join sama sekali: EXISTS menguji tiap pelanggan dengan subquery kecil, dan hasil uji berupa benar/salah — tidak ada baris yang digandakan di hasil utama:
SELECT p.nama
FROM pelanggan p
WHERE EXISTS (
SELECT 1
FROM pesanan pn
INNER JOIN detail_pesanan d ON d.pesanan_id = pn.id
WHERE pn.pelanggan_id = p.id AND d.produk = 'Gula Aren'
)
ORDER BY p.nama;Output yang Dihasilkan:
+--------------+
| nama |
+--------------+
| Budi Santoso |
| Rina Melati |
| Siti Rahayu |
+--------------+
3 rows in set (0.00 sec)Tiga nama yang sama dengan versi DISTINCT muncul tanpa satu pun DISTINCT. Kondisi pn.pelanggan_id = p.id di dalam subquery menyambungkan pengujian ke baris pelanggan yang sedang dievaluasi — subquery begini disebut correlated subquery. MySQL cukup menemukan satu baris pembelian Gula Aren milik pelanggan untuk menyatakan EXISTS benar; banyaknya pembelian tidak lagi berpengaruh. Kita sengaja menutup di sini karena EXISTS dan keluarga subquery adalah materi penuh section berikutnya — cukup catat mulai sekarang: pertanyaan keberadaan punya dua jawaban, JOIN + DISTINCT dan EXISTS.
Membersihkan Database Latihan #
DROP DATABASE belajar_join;Output yang Dihasilkan:
Query OK, 3 rows affected (0.00 sec)Referensi #
- JOIN Clause — MySQL 8.4 Reference Manual
- Aggregate Function Descriptions — MySQL 8.4 Reference Manual
- Subqueries — MySQL 8.4 Reference Manual
- Subqueries with EXISTS or NOT EXISTS — MySQL 8.4 Reference Manual
Duplikasi join kini terpetakan tuntas: baris 1-N yang berganda wajar untuk daftar, SUM melonjong ketika baris ganda ikut terjumlah, agregat-di-subquery meruntuhkan sisi banyak sebelum join, DISTINCT mengurus pertanyaan keberadaan, dan EXISTS mengujinya tanpa menyentuh duplikasi. Perjalanan joins kita tuntas — dari relasi antartabel hingga strategi merapikan hasilnya. Berikutnya kita turun ke lapisan query yang lebih dalam: subqueries, kemampuan menyarangkan satu query di dalam query lain.