Seluruh query JSON yang kita tulis sejauh ini berbagi satu kelemahan struktural: MySQL harus membaca dan menulusuri dokumen baris demi baris — jalur lambat tanpa index yang disebut computed path. Pada tabel ratusan ribu produk, WHERE atribut->>'$.harga' > 30000 memerlukan dokumen setiap baris hanya untuk tahu lolos atau tidak. MySQL menyediakan jembatan yang elegan untuk masalah ini: generated column — kolom yang nilainya dihitung dari ekspresi, dan ekspresinya boleh membaca path JSON. Kolom hasilnya bisa diberi index seperti kolom biasa, sehingga penyaringan nilai di dalam dokumen turut menikmati kecepatan B-tree. Pada chapter ini kita membuat kolom generated dari $.harga, membandingkan mode VIRTUAL dan STORED, membuktikan index-nya bekerja lewat EXPLAIN, lalu mencatat batasannya.
Membuat Kolom Generated dari Path JSON #
Skema latihan tetap produk(id, nama, atribut) di database belajar_json, kini dengan satu kolom tambahan: harga dideklarasikan GENERATED ALWAYS AS (atribut->>'$.harga') STORED. Kata kunci GENERATED ALWAYS AS diikuti ekspresi perhitungan, dan STORED memerintahkan hasilnya disimpan ke disk bersama barisnya:
DROP DATABASE IF EXISTS belajar_json;
CREATE DATABASE belajar_json;
USE belajar_json;Output yang Dihasilkan:
Query OK, 0 rows affected, 1 warning (0.00 sec)
Query OK, 1 row affected (0.00 sec)
Database changedCREATE TABLE produk (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
atribut JSON NOT NULL,
harga INT GENERATED ALWAYS AS (atribut->>'$.harga') STORED
);
INSERT INTO produk (nama, atribut) VALUES
('Kopi Gayo 250g', '{"warna": "coklat", "berat": 250, "harga": 55000, "tag": ["arabika", "toraja"]}'),
('Mug Keramik', '{"warna": "putih", "berat": 350, "harga": 45000, "tag": ["keramik", "microwave"]}'),
('Teh Melati 100g', '{"berat": 100, "harga": 25000, "tag": ["teh", "melati"]}'),
('Susu Kental 375g', '{"berat": 375, "tag": ["susu", "kondensasi"]}');
SELECT id, nama, harga FROM produk ORDER BY id;Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
Query OK, 4 rows affected (0.00 sec)
Records: 4 Duplicates: 0 Warnings: 0
+----+------------------+-------+
| id | nama | harga |
+----+------------------+-------+
| 1 | Kopi Gayo 250g | 55000 |
| 2 | Mug Keramik | 45000 |
| 3 | Teh Melati 100g | 25000 |
| 4 | Susu Kental 375g | NULL |
+----+------------------+-------+
4 rows in set (0.00 sec)Mari kita bedah mekanismenya. Ekspresi atribut->>'$.harga' dibaca sekali sebagai resep tetap oleh server: saat setiap baris di-INSERT, server mengevaluasi resep itu terhadap dokumen yang baru masuk, lalu menyimpan hasilnya sebagai nilai kolom harga bertipe INT. Tiga produk pertama menyumbang angka 55000, 45000, dan 25000; Susu Kental yang dokumennya tidak memiliki kunci harga menyimpan NULL — kolom generated mewarisi perilaku path yang tak ada, bukan galat. Nilai kolom tidak bisa diisi manual: INSERT hanya menyebut kolom asli, dan upaya UPDATE langsung ke harga akan ditolak karena kolom tersebut murni hasil hitung.
VIRTUAL versus STORED #
MySQL menawarkan dua mode kolom generated yang membedakan kapan ekspresi dievaluasi. STORED mengevaluasi saat baris ditulis lalu menyimpan hasilnya di disk; VIRTUAL — bawaan jika kata kunci mode tidak ditulis — mengevaluasi saat kolom dibaca tanpa menyimpan apa pun:
| Aspek | VIRTUAL | STORED |
|---|---|---|
| Dievaluasi saat | Baris dibaca | Baris ditulis atau diubah |
| Menempati ruang penyimpanan | Tidak | Ya, per baris |
Biaya tulis (INSERT/UPDATE) | Murah | Sedikit lebih mahal |
| Biaya baca berulang | Dihitung ulang tiap kali | Langsung dari disk |
| Bisa diberi index | Ya | Ya |
Mari kita buktikan mode VIRTUAL menghasilkan nilai yang sama persis. Tabel kedua memakai ekspresi identik tanpa kata STORED, sehingga kolomnya virtual:
CREATE TABLE produk_v (
id INT AUTO_INCREMENT PRIMARY KEY,
nama VARCHAR(100) NOT NULL,
atribut JSON NOT NULL,
harga INT GENERATED ALWAYS AS (atribut->>'$.harga') VIRTUAL
);
INSERT INTO produk_v (nama, atribut) VALUES
('Kopi Gayo 250g', '{"berat": 250, "harga": 55000}'),
('Teh Melati 100g', '{"berat": 100, "harga": 25000}');
SELECT id, nama, harga FROM produk_v ORDER BY id;Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
Query OK, 2 rows affected (0.00 sec)
Records: 2 Duplicates: 0 Warnings: 0
+----+-----------------+-------+
| id | nama | harga |
+----+-----------------+-------+
| 1 | Kopi Gayo 250g | 55000 |
| 2 | Teh Melati 100g | 25000 |
+----+-----------------+-------+
2 rows in set (0.00 sec)Nilai kolom harga tampil sama seperti pada tabel STORED — perbedaannya hanya lokasi kerja: tabel virtual menghitung ulang atribut->>'$.harga' setiap kali SELECT menyebut kolom tersebut, sementara tabel stored tinggal membaca nilai yang sudah tersimpan. Untuk data yang jarang dibaca atau sering berubah, virtual menghemat ruang; untuk kolom yang dibaca pada hampir setiap query, stored menghemat CPU. Khusus kolom generated dari JSON, perhatikan pula bahwa UPDATE pada atribut membuat mode STORED menghitung ulang kolomnya — biaya kecil yang jarang terasa, namun terbayar pada tabel yang dokumennya gemuk.
Index pada Kolom Generated #
Kolom generated tidak harus lahir bersama tabelnya: ALTER TABLE ... ADD COLUMN ... GENERATED ALWAYS AS ... dapat menambahkannya pada tabel yang sudah berisi data, dan pilihan mode ikut menentukan biayanya — kolom VIRTUAL ditambahkan tanpa menulis ulang baris, sedangkan kolom STORED menulis ulang seluruh tabel untuk mengisi nilainya.
Inilah tujuan utama kita: kolom generated adalah kolom sungguhan, sehingga CREATE INDEX bekerja padanya sebagaimana kolom biasa:
CREATE INDEX idx_harga ON produk (harga);Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
Records: 0 Duplicates: 0 Warnings: 0SHOW INDEX FROM produk;Output yang Dihasilkan:
+--------+------------+-----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+--------+------------+-----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| produk | 0 | PRIMARY | 1 | id | A | 4 | NULL | NULL | | BTREE | | | YES | NULL |
| produk | 1 | idx_harga | 1 | harga | A | 4 | NULL | NULL | YES | BTREE | | | YES | NULL |
+--------+------------+-----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.00 sec)Daftar index kini memuat dua entri: PRIMARY pada kolom id dan idx_harga pada kolom harga — keduanya bertipe BTREE. Kolom Null pada baris idx_harga bernilai YES karena Susu Kental menyimpan NULL, dan index B-tree tetap mencatat nilai NULL itu sebagai entri tersendiri. Query penyaring kini bisa ditulis memakai kolom generated seperti kolom mana pun:
SELECT id, nama, harga FROM produk WHERE harga > 30000 ORDER BY harga DESC;Output yang Dihasilkan:
+----+----------------+-------+
| id | nama | harga |
+----+----------------+-------+
| 1 | Kopi Gayo 250g | 55000 |
| 2 | Mug Keramik | 45000 |
+----+----------------+-------+
2 rows in set (0.00 sec)Dua produk dengan harga di atas 30000 tampil terurut menurun. Pertanyaan pentingnya: apakah query tadi benar-benar memakai index, bukan membaca seluruh tabel? EXPLAIN menjawabnya:
EXPLAIN SELECT id, nama FROM produk WHERE harga > 30000;Output yang Dihasilkan:
+----+-------------+--------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------+
| 1 | SIMPLE | produk | NULL | range | idx_harga | idx_harga | 5 | NULL | 2 | 100.00 | Using index condition |
+----+-------------+--------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)Kolom type bernilai range — bukan ALL (full scan) — dan kolom key menyebut idx_harga sebagai index terpilih. MySQL membuka index B-tree lalu melompat langsung ke rentang harga di atas 30000, tanpa menyentuh dokumen JSON baris yang tidak lolos. Pada tabel empat baris efeknya tak terasa; pada tabel sejuta baris, selisihnya adalah membaca ratusan entri index versus mengevaluasi satu juta dokumen.
Query yang Masih Menulis Path JSON #
Bagian yang paling sering mengejutkan: optimizer MySQL mengenali padanan ekspresi. Query yang tetap menulis path JSON mentah dapat memakai index kolom generated selama ekspresinya cocok dengan resep kolom tersebut:
EXPLAIN SELECT id, nama FROM produk WHERE atribut->>'$.harga' > 30000;Output yang Dihasilkan:
+----+-------------+--------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+--------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------+
| 1 | SIMPLE | produk | NULL | range | idx_harga | idx_harga | 5 | NULL | 2 | 100.00 | Using index condition |
+----+-------------+--------+------------+-------+---------------+-----------+---------+------+------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)Hasil EXPLAIN identik dengan versi kolom biasa: type range, key idx_harga. Optimizer menulis ulang ekspresi atribut->>'$.harga' menjadi kolom generated harga sebelum memilih rencana eksekusi, sehingga kecepatan index tetap didapat meski aplikasi lama menyimpan query versi JSON. Penulisan ulang ini mengikat pada kecocokan ekspresi persis — path yang sama, operator yang sama (-> juga dikenali untuk padanan tanpa unquote). Ekspresi yang menyimpang sedikit saja, misalnya JSON_EXTRACT(atribut, '$.tag[0]') untuk kolom yang diresepkan dari $.harga, tidak akan dipetakan dan kembali ke jalur lambat.
Batasan Kolom Generated #
Ekspresi kolom generated wajib deterministik — hasilnya harus bisa dihitung ulang kapan pun dengan hasil sama dari masukan yang sama. Fungsi acak jelas gagal syarat itu, dan server menolaknya saat tabel dibuat:
-- @error
CREATE TABLE produk_acak (
id INT PRIMARY KEY,
atribut JSON,
poin INT GENERATED ALWAYS AS (RAND()) VIRTUAL
);Output yang Dihasilkan:
Query OK, 0 rows affected (0.00 sec)
ERROR 3763 (HY000): Expression of generated column 'poin' contains a disallowed function: rand.
ByeERROR 3763 menyebut nama fungsi terlarang rand secara eksplisit. Aturan yang sama melarang fungsi waktu seperti NOW() dan fungsi yang bergantung pada data sesi. Untuk kolom dari JSON, praktik yang berlaku sederhana: ekspresi berupa pembacaan path konstan seperti atribut->>'$.harga' atau JSON_EXTRACT(atribut, '$.berat') aman dan dikenali optimizer; ekspresi yang menggabungkan banyak path, memakai wildcard, atau membungkus fungsi kompleks tetap sah selama deterministik, namun kehilangan bonus penulisan ulang otomatis ke index. Batasan lain yang wajib diingat: satu kolom generated menangkap satu path — dokumen dengan struktur berbeda per baris tetap butuh kolom berbeda untuk tiap nilai pentingnya.
Membersihkan Database Latihan #
Latihan kolom generated selesai dan database latihan kita jatuhkan:
DROP DATABASE belajar_json;Output yang Dihasilkan:
Query OK, 2 rows affected (0.00 sec)Referensi #
- CREATE TABLE and Generated Columns — MySQL 8.4 Reference Manual
- CREATE INDEX Statement — MySQL 8.4 Reference Manual
- The JSON Data Type — MySQL 8.4 Reference Manual
Kolom generated telah menjembatani JSON dan index: nilai di dalam dokumen kini bisa disaring secepat kolom biasa, dengan mode VIRTUAL atau STORED sesuai kebutuhan, dan optimizer yang bahkan menerjemahkan query JSON lama ke index baru. Kecepatan hanyalah satu sisi keputusan desain. Chapter penutup section ini menimbang pertanyaan yang lebih besar: kapan JSON memang pilihan yang tepat, dan kapan kolom relasional klasik — lengkap dengan relasi, tipe ketat, dan normalisasi — tetap jawaban terbaik.