Arsitektur Reporting ERP: Read Model Penyelamat Database OLTP

Foto oleh ThisIsEngineering

Foto oleh ThisIsEngineering
Tiket support yang mengajari saya pelajaran ini berbunyi: simpan invoice butuh 30 detik setiap pagi sekitar jam 8. Penyebabnya sama sekali bukan di kode invoice. Penyebabnya dashboard eksekutif — enam widget, masing-masing menjalankan agregasi lima tabel atas setahun transaksi, di-refresh setiap kali manajer membuka halaman sambil ngopi pagi. Instance PostgreSQL yang sama yang memproses posting invoice digilas query GROUP BY.
Inilah masalah arsitektur paling mendasar di reporting ERP: kerja transaksional dan kerja analitis punya pola akses berlawanan, dan database yang di-tuning untuk satu pasti biasa-biasa saja di yang lain. Solusinya bukan server lebih besar. Solusinya read model — salinan data yang sudah dihitung dan dibentuk sesuai query, yang dipukul dashboard alih-alih tabel live. Berikut arsitektur yang saya pakai di NestJS dan PostgreSQL, dari materialized view pertama sampai tahu kapan Anda melampauinya.
Beban kerja OLTP — posting invoice, mengeluarkan stok, approve PO — menyentuh segelintir baris, menuntut latensi milidetik, dan berjalan ribuan kali sehari. Query analitis memindai jutaan baris, men-join separuh skema, dan mengagregasi berbulan-bulan data. Menjalankan keduanya di satu database menyakitkan dalam tiga cara spesifik:
Insting pertama adalah menambah index untuk laporan. Itu membuat pembacaan sedikit lebih cepat sambil membuat setiap penulisan transaksional lebih lambat — tiap index ekstra adalah biaya write amplification yang dibayar di setiap INSERT. Setelah beberapa ronde, Anda punya skema OLTP berkostum OLAP, buruk di kedua pekerjaan.
Anda tidak butuh data warehouse di hari pertama. Ada tangga pemisahan, dan tiap anak tangga membeli ruang napas dengan biayanya sendiri. Naik hanya saat pengukuran bilang Anda butuh anak tangga berikutnya:
| Tahap | Teknik | Kesegaran data | Naik ke sini saat |
|---|---|---|---|
| 1. DB sama, tabel terpisah | Materialized view atau tabel ringkasan yang di-refresh terjadwal | Menit sampai jam | Query dashboard muncul di slow-query log; latensi OLTP masih baik. |
| 2. Read replica | Replika streaming yang melayani semua pembacaan reporting | Tertinggal hitungan detik | Refresh-nya sendiri membebani primary, atau query ad-hoc dari finance tidak terprediksi. |
| 3. Read store berbasis event | Event aplikasi diproyeksikan ke tabel khusus (gaya CQRS) | Mendekati real-time | Widget butuh angka live (penjualan hari ini, approval terbuka) dan siklus refresh view terlalu lambat. |
| 4. Warehouse khusus | ClickHouse, BigQuery, atau sejenis dengan ETL malam atau streaming | Batch atau streaming | Histori multi-tahun, join lintas sistem, atau SQL analis yang tidak boleh mendekati production. |
Untuk mayoritas ERP custom yang melayani puluhan sampai ratusan user, tahap satu cukup untuk bertahun-tahun — kalau dibangun dengan sengaja. Materialized view di PostgreSQL menyimpan hasil query sebagai tabel fisik yang bisa di-index; dokumentasinya menunjukkan percepatan empat orde magnitudo saat materialized view ber-index menggantikan komputasi berulang. Ini bentuk nyatanya, widget AR aging:
-- Read model for the AR aging dashboard widget
CREATE MATERIALIZED VIEW mv_ar_aging AS
SELECT
c.id AS customer_id,
c.name AS customer_name,
SUM(i.open_amount) FILTER (WHERE age_days(i) <= 30)
AS bucket_current,
SUM(i.open_amount) FILTER (WHERE age_days(i) BETWEEN 31 AND 60)
AS bucket_31_60,
SUM(i.open_amount) FILTER (WHERE age_days(i) BETWEEN 61 AND 90)
AS bucket_61_90,
SUM(i.open_amount) FILTER (WHERE age_days(i) > 90)
AS bucket_over_90,
now() AS refreshed_at
FROM ar_invoice i
JOIN customer c ON c.id = i.customer_id
WHERE i.status = 'OPEN'
GROUP BY c.id, c.name;
-- Unique index makes CONCURRENTLY possible
CREATE UNIQUE INDEX ON mv_ar_aging (customer_id);
-- Refresh WITHOUT blocking dashboard readers
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_ar_aging;Dua detail di cuplikan itu yang mengangkat beban berat. Unique index-lah yang memungkinkan REFRESH MATERIALIZED VIEW CONCURRENTLY, yang membangun ulang view tanpa mengambil exclusive lock yang akan membekukan semua dashboard di tengah refresh. Dan kolom refreshed_at menampilkan kesegaran data ke UI — kenapa itu penting dibahas di bawah.
Pro tip: jadwalkan refresh dari lapisan aplikasi — cron job NestJS atau queue BullMQ — bukan dari dalam database. Penjadwalan level aplikasi memberi Anda logging, retry, alert saat gagal, dan tempat alami untuk melewati refresh selama closing akhir bulan saat sisi OLTP butuh setiap tetes I/O.
Kesalahan pertama saya: membuat satu view raksasa per halaman laporan. View seharusnya dibentuk seperti widget, bukan halaman — satu materialized view per pertanyaan, masing-masing kecil, bisa di-refresh independen, dan dimiliki konsumen bernama. Saat ringkasan penjualan rusak, Anda me-refresh atau membangun ulang satu view, bukan seluruh dunia.
Ini pemikiran CQRS yang diterapkan di level database, dan panduan arsitektur Microsoft mengatakannya gamblang: read model melayani query dan tidak membawa logika bisnis, sementara write model menjaga konsistensi. Empat aturan menjaga read model ERP tetap jujur:
Denormalisasi tanpa malu
Salin nama customer, deskripsi item, dan label kategori ke dalam view. Dashboard tidak boleh join balik ke tabel OLTP panas — itu menghadirkan kembali beban yang sedang Anda hindari.
Bentuk baris seperti grafiknya
Kalau widget menampilkan dua belas bar bulanan, read model punya dua belas baris. Agregasi milik proses refresh, tidak pernah milik jalur request.
Versikan view Anda
Migrasikan skema view seperti tabel: mv_sales_summary_v2 deploy berdampingan dengan v1, API berpindah setelah terverifikasi, v1 dihapus satu rilis kemudian. Definisi ulang di tempat saat beban tinggi adalah generator outage.
Satu penulis per model
Setiap read model ditulis oleh tepat satu job refresh atau projector. Dua penulis balapan di satu tabel ringkasan menghasilkan angka yang tidak bisa dijelaskan siapa pun di rapat.
Setiap read model pada dasarnya basi — pertanyaannya cuma seberapa basi, dan apakah user tahu. Di ERP ini sensitif: supervisor gudang yang mengecek stok sebelum menjanjikan pengiriman butuh kebenaran live; direktur yang meninjau tren pendapatan bulanan tidak peduli sepuluh menit terakhir. Jadi anggaran kesegaran harus datang dari user, widget per widget, bukan dari default engineering.
Selalu tampilkan timestamp refresh di setiap widget dashboard yang dilayani read model. Cara tercepat menghancurkan kepercayaan pada ERP baru adalah manajer membandingkan angka dashboard dengan layar transaksi live, melihat selisih, dan menyimpulkan sistemnya rusak. Angka yang sama dengan label per jam 7:30 pagi ini bukan bug — itu spesifikasi.
Default saya, dikalibrasi bertahun-tahun oleh tim finance yang memberi tahu apa yang benar-benar mereka cek: widget operasional (approval terbuka, penerimaan hari ini, stok menipis) datang dari query live ke tabel OLTP ber-index, sengaja dibuat mungil. Widget taktis (aging, penjualan bulanan, serapan budget) refresh tiap 15 sampai 60 menit. Widget strategis (tren tahunan, analisis margin) refresh tiap malam. Belum pernah ada yang komplain grafik tren year-over-year berumur empat jam.
Kalau dashboard ERP Anda sudah menyakiti production, ini urutan yang saya ikuti — sudah menyelamatkan lebih dari satu deployment tanpa rewrite:
Beban reporting bukan masalah optimasi di dalam database OLTP Anda — ia beban kerja berbeda yang layak mendapat struktur datanya sendiri. Read model memberinya struktur itu dengan biaya yang Anda kendalikan: beberapa tabel ringkasan, jadwal refresh, dan timestamp yang terlihat. Setiap anak tangga setelahnya adalah respons terukur terhadap beban nyata, bukan arsitektur mengawang.
Dashboard dari tiket invoice-30-detik itu hari ini berjalan di atas sebelas materialized view, di-refresh tiap 30 menit, di instance database yang sama. Invoice tersimpan dalam 200 milidetik jam 8 pagi. Pemisahan, bukan hardware, jawabannya — dan biasanya memang begitu.
Sumber dan bacaan lanjutan