Pola produksi
Upsert di kedua dialek, keyset pagination yang tetap waras di halaman 5000, menghapus sejuta baris tanpa menahan lock, dan urutan expand-migrate-contract untuk mengubah skema di bawah lalu lintas hidup.
Semua sejauh ini tentang mendapatkan jawaban yang benar. Materi ini tentang query yang benar di laptopmu dan menyebabkan insiden pada skala besar.
Upsert
“Sisipkan, atau perbarui kalau sudah ada.” Kedua mesin punya dan tidak ada yang menuliskannya sama.
-- PostgreSQLINSERT INTO product (sku, name, cents)VALUES ('A-1', 'Widget', 1200)ON CONFLICT (sku) DO UPDATE SET name = EXCLUDED.name, cents = EXCLUDED.centsWHERE product.cents <> EXCLUDED.cents; -- lewati penulisan yang tidak mengubah apa pun
-- MySQLINSERT INTO product (sku, name, cents)VALUES ('A-1', 'Widget', 1200)ON DUPLICATE KEY UPDATE name = VALUES(name), -- atau: name = new.name (8.0.20+) cents = VALUES(cents);Hal yang penting:
ON CONFLICTmenyebut constraint yang sedang diselesaikannya, jadi kamu tahu aturan keunikan mana yang kamu maksud.ON DUPLICATE KEYbereaksi pada sembarang constraint unik, dan itu jadi ambigu begitu sebuah tabel punya dua.WHEREpadaDO UPDATEPostgreSQL melewati penulisan baris yang tidak berubah. Itu menghemat satu versi baris, satu pembaruan index, dan satu catatan WAL per baris tak berubah — yang pada sinkronisasi besar adalah sebagian besar pekerjaannya.INSERT IGNOREdi MySQL danON CONFLICT DO NOTHINGdi PostgreSQL tidak setara:INSERT IGNOREmenurunkan setiap error jadi warning, termasuk error tipe dan pemotongan, bukan hanya duplikat yang kamu maksud.
Penyemaian konten situs ini persis pola ini, dengan satu tambahan yang pantas dicontek:
WHERE origin = 'seed' pada bagian update-nya, sehingga pengimpor hanya bisa mengubah
baris yang dia tulis sendiri dan tidak pernah menimpa sesuatu yang disunting orang.
Keyset pagination
OFFSET makin lambat di setiap halaman, karena mesinnya menghasilkan lalu membuang baris
yang dilewati. Keyset pagination — disebut juga cursor atau seek pagination — meminta “20
berikutnya setelah titik ini” dan tetap datar.
-- Halaman 1SELECT id, placed_at FROM "order"ORDER BY placed_at DESC, id DESCLIMIT 20;
-- Halaman 2: kirimkan kembali baris terakhir halaman 1SELECT id, placed_at FROM "order"WHERE (placed_at, id) < (:last_placed_at, :last_id) -- perbandingan barisORDER BY placed_at DESC, id DESCLIMIT 20;Perbandingan baris — (a, b) < (x, y) — didukung kedua mesin dan justru alat yang tepat:
dia membandingkan secara leksikografis, jadi artinya “timestamp lebih awal, atau timestamp
sama dan id lebih kecil”. Menulisnya sendiri sebagai placed_at < :t OR (placed_at = :t AND id < :i) berarti sama dan lebih mudah salah.
Dengan index pada (placed_at DESC, id DESC) ini membaca 20 baris di halaman 1 dan 20
baris di halaman 5000. Pertukarannya nyata dan pantas dinyatakan: kamu kehilangan
kemampuan melompat ke nomor halaman sembarang. Untuk feed atau API itu bukan kerugian;
untuk tabel dengan tombol nomor halaman, itu keputusan produk.
Menghapus atau memperbarui banyak baris
Satu DELETE atas sepuluh juta baris menahan lock selama seluruh durasinya, menghasilkan
satu transaksi raksasa, dan tidak bisa dihentikan tanpa kehilangan seluruh pekerjaannya.
Lakukan dalam kelompok, masing-masing transaksinya sendiri.
-- PostgreSQL: mengembalikan id membuat kemajuan loop-nya bisa diamatiWITH doomed AS ( SELECT id FROM "order" WHERE placed_at < '2020-01-01' ORDER BY id LIMIT 5000)DELETE FROM "order" WHERE id IN (SELECT id FROM doomed);
-- MySQLDELETE FROM `order` WHERE placed_at < '2020-01-01' ORDER BY id LIMIT 5000;Ulangi sampai nol baris terpengaruh. Buat kelompoknya cukup kecil sehingga setiap transaksi selesai jauh di bawah satu detik, dan beri jeda antar kelompok supaya replikasi bisa menyusul.
Kalau kamu menghapus sebagian besar isi tabel, menyalin yang selamat ke tabel baru lalu menukar namanya biasanya lebih cepat daripada pengelompokan apa pun, dan dia tidak meninggalkan bloat.
Mengubah skema di bawah lalu lintas hidup
Aturannya: jangan pernah membuat satu perubahan yang sekaligus menulis data dan menghalangi pembaca. Belah dia jadi langkah-langkah yang masing-masing aman untuk dihentikan setelahnya. Ini sering disebut expand / migrate / contract.
Menambahkan kolom NOT NULL dengan nilai bawaan, kalau dilakukan salah, menulis ulang
seluruh tabel sambil menahan lock eksklusif. Kalau dilakukan sebagai urutan:
- Expand — tambahkan kolomnya, boleh NULL, tanpa nilai bawaan. Cepat di kedua mesin.
- Backfill — isi nilainya dalam kelompok, seperti di atas.
- Constrain — tambahkan
NOT NULLdan nilai bawaannya. - Contract — begitu tidak ada kode yang membacanya, hapus kolom lamanya.
Di antara setiap langkah aplikasinya tetap jalan, dengan kode lama dan baru sama-sama bisa berjalan. Itulah yang membuat deploy-nya tidak bergantung pada migrasinya.
Kekhususan mesin:
| PostgreSQL | MySQL | |
|---|---|---|
| Menambah kolom boleh NULL | instan | instan (ALGORITHM=INSTANT, 8.0.12+) |
| Menambah kolom dengan nilai bawaan | instan sejak 11 | instan di 8.0.12+ untuk sebagian besar kasus |
| Membangun index tanpa menghalangi penulisan | CREATE INDEX CONCURRENTLY |
ALTER TABLE ... ADD INDEX, ALGORITHM=INPLACE, LOCK=NONE |
| Memvalidasi constraint tanpa lock panjang | ADD CONSTRAINT ... NOT VALID, lalu VALIDATE CONSTRAINT |
tidak ada padanannya; pakai alat bantu |
| Penulisan ulang berat | tetap mengunci | gh-ost atau pt-online-schema-change |
CREATE INDEX CONCURRENTLY tidak bisa berjalan di dalam transaksi, dan kalau gagal dia
meninggalkan index tidak valid yang harus kamu hapus sendiri. Periksa
pg_index.indisvalid setelah setiap pembangunan concurrent yang error.
Menanyai JSON
-- PostgreSQL, jsonbSELECT payload->>'status' AS status, -- ->> menghasilkan teks payload->'items'->0->>'sku' AS first_skuFROM eventWHERE payload @> '{"status":"paid"}'; -- containment, bisa di-index GIN
CREATE INDEX ix_event_payload ON event USING gin (payload);
-- MySQLSELECT payload->>'$.status' AS status, payload->>'$.items[0].sku' AS first_skuFROM eventWHERE payload->>'$.status' = 'paid';
-- Hanya bisa di-index melalui generated columnALTER TABLE event ADD COLUMN status VARCHAR(32) AS (payload->>'$.status') STORED, ADD INDEX ix_event_status (status);@> PostgreSQL dengan index GIN mencari di dalam dokumennya. MySQL tidak punya padanan
index seluruh dokumen, jadi setiap field yang kamu saring butuh generated column-nya
sendiri — dan itu petunjuk bagus untuk bertanya apakah dia seharusnya sejak awal berupa
kolom.
Query N+1
Bukan fitur SQL, tapi bug kinerja paling umum di aplikasi yang memakainya. Mengambil sebuah daftar lalu satu query per baris mengubah 1 permintaan jadi 201.
-- Yang dilakukan ORM tanpa sengajaSELECT * FROM "order" WHERE customer_id = 7; -- 200 barisSELECT * FROM order_item WHERE order_id = 1; -- lalu 200 query lagi...
-- Yang seharusnya dia lakukanSELECT * FROM order_item WHERE order_id = ANY(:ids); -- PostgreSQLSELECT * FROM order_item WHERE order_id IN (...); -- MySQLSetiap ORM punya perbaikannya — eager loading, JOIN FETCH, includes,
select_related. Keahliannya ada pada menyadarinya: catat jumlah query per permintaan,
dan pasang peringatan padanya. Jumlah yang membesar seiring ukuran himpunan hasil adalah
tanda tangannya.
Yang perlu dibawa pulang
ON CONFLICTdi PostgreSQL,ON DUPLICATE KEY UPDATEdi MySQL. HindariINSERT IGNORE— dia menyembunyikan error yang tidak berkaitan.- Keyset pagination dengan perbandingan baris tetap datar;
OFFSETtidak. - Kelompokkan penghapusan dan pembaruan besar, satu transaksi masing-masing.
- Expand, backfill, constrain, contract — setiap langkah aman untuk dihentikan setelahnya.
CREATE INDEX CONCURRENTLYbisa meninggalkan index tidak valid; periksa setelah gagal.- JSON bisa ditanyai di keduanya, tapi hanya PostgreSQL yang meng-index seluruh dokumen.
- Pantau jumlah query per permintaan, bukan hanya durasi tiap query.
Diskusi
Memuat komentar…