Tampilan
Kenapa penting
Hampir setiap backend punya satu endpoint "dashboard" yang mengumpulkan data dari banyak tempat dan makin lambat seiring waktu. Mempercepatnya adalah tugas nyata yang sering diberikan kepada engineer: bukan dengan menebak, melainkan dengan siklus ukur → ubah satu hal → ukur ulang. Proyek mini ini menggabungkan semua yang kamu pelajari di modul M10 — CompletableFuture, virtual thread, caching dengan Redis, dan load test — menjadi satu alur kerja yang bisa kamu ceritakan di wawancara, lengkap dengan angka sebelum dan sesudah.
Konsep inti
Skenario: dashboard pelanggan
Toko online punya endpoint GET /api/dashboard/{userId} yang menampilkan tiga hal:
| Data | Sumber | Latensi khas |
|---|---|---|
| Pesanan terakhir | Query PostgreSQL (join beberapa tabel) | ~300 ms |
| Poin loyalitas | HTTP ke layanan loyalty | ~400 ms |
| Rekomendasi | HTTP ke layanan rekomendasi | ~500 ms |
Implementasi awal memanggil ketiganya berurutan, jadi satu request butuh sekitar 300 + 400 + 500 = 1.200 ms. Target (SLO) yang diminta: p95 < 300 ms pada 100 RPS dengan error rate < 1%.
Ketiga panggilan tidak saling bergantung — itu petunjuk pertama bahwa mereka bisa dijalankan bersamaan. Petunjuk kedua: data dashboard dibaca jauh lebih sering daripada berubah, cocok untuk cache.
Langkah 1: baseline dengan k6
Sebelum menyentuh kode, catat kondisi awal. Gunakan user ID acak dari rentang yang luas agar nanti cache tidak terlihat lebih bagus dari kenyataan.
javascript
import http from "k6/http";
import { check } from "k6";
export const options = {
scenarios: {
dashboard: {
executor: "constant-arrival-rate",
rate: 100,
timeUnit: "1s",
duration: "3m",
preAllocatedVUs: 50,
maxVUs: 300,
},
},
thresholds: {
http_req_duration: ["p(95)<300"],
http_req_failed: ["rate<0.01"],
},
};
export default function () {
const userId = Math.floor(Math.random() * 5000) + 1;
const res = http.get(`${__ENV.BASE_URL}/api/dashboard/${userId}`);
check(res, { "status 200": (r) => r.status === 200 });
}bash
docker run --rm -i --add-host=host.docker.internal:host-gateway \
-e BASE_URL=http://host.docker.internal:8080 \
grafana/k6 run - < dashboard-test.jsSimpan ringkasan k6 (p50, p95, p99, http_reqs, http_req_failed) ke file, misalnya results/01-baseline.txt. Jangan lupa warm-up: jalankan sekali run singkat dulu, atau abaikan menit pertama. Dengan model terbuka 100 RPS dan latensi 1,2 detik, Little's Law memberi tahu bakal ada ~120 request bersamaan — jika server memakai thread pool platform yang kecil, antrean akan membuat latensi jauh di atas 1,2 detik.
Langkah 2: paralelkan dengan CompletableFuture + virtual thread
Panggilan I/O yang independen dijalankan bersamaan, sehingga latensi mengikuti sumber paling lambat, bukan jumlahnya. Virtual thread cocok di sini karena pekerjaannya hampir seluruhnya menunggu I/O. Program berikut menyimulasikan ketiga sumber dengan Thread.sleep dan membandingkan kedua cara:
java
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class DashboardDemo {
record Dashboard(List<String> orders, int loyaltyPoints, List<String> recommendations) {}
// Simulasi tiga sumber lambat: DB pesanan, layanan poin, layanan rekomendasi
static List<String> fetchOrders(long userId) { sleep(300); return List.of("ORD-1", "ORD-2"); }
static int fetchPoints(long userId) { sleep(400); return 1250; }
static List<String> fetchRecommendations(long userId) { sleep(500); return List.of("SKU-9"); }
static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(e);
}
}
static Dashboard sequential(long userId) {
return new Dashboard(fetchOrders(userId), fetchPoints(userId), fetchRecommendations(userId));
}
static Dashboard parallel(long userId, ExecutorService executor) {
var orders = CompletableFuture.supplyAsync(() -> fetchOrders(userId), executor);
var points = CompletableFuture.supplyAsync(() -> fetchPoints(userId), executor);
var recs = CompletableFuture.supplyAsync(() -> fetchRecommendations(userId), executor)
.completeOnTimeout(List.of(), 350, TimeUnit.MILLISECONDS); // fallback bila lambat
return orders.thenCombine(points, (o, p) -> new Dashboard(o, p, List.of()))
.thenCombine(recs, (d, r) -> new Dashboard(d.orders(), d.loyaltyPoints(), r))
.join();
}
static long roundTo100(long ms) { return Math.round(ms / 100.0) * 100; }
public static void main(String[] args) {
long start = System.nanoTime();
Dashboard d1 = sequential(42L);
long seqMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("sequential ~" + roundTo100(seqMs) + " ms -> " + d1);
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
start = System.nanoTime();
Dashboard d2 = parallel(42L, executor);
long parMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("parallel ~" + roundTo100(parMs) + " ms -> " + d2);
}
}
}text
sequential ~1200 ms -> Dashboard[orders=[ORD-1, ORD-2], loyaltyPoints=1250, recommendations=[SKU-9]]
parallel ~400 ms -> Dashboard[orders=[ORD-1, ORD-2], loyaltyPoints=1250, recommendations=[]]Perhatikan dua keputusan desain. Pertama, rekomendasi adalah data "bagus kalau ada": dengan completeOnTimeout, dashboard tidak lagi menunggu sumber paling lambat, sehingga latensi turun ke ~400 ms (poin loyalitas) dan rekomendasi diganti daftar kosong. Pesanan dan poin tidak diberi fallback karena data itu wajib benar. Kedua, executor dikirim sebagai argumen — tanpa itu supplyAsync memakai ForkJoinPool.commonPool() yang kecil dan tidak cocok untuk I/O blocking.
Preview
Structured concurrency (StructuredTaskScope, JEP 505 di Java 25) menawarkan cara yang lebih rapi untuk pola "jalankan beberapa subtask, batalkan sisanya bila satu gagal". Di Java 25 statusnya masih preview, jadi proyek ini memakai CompletableFuture.
Langkah 3: versi Spring Boot
Di aplikasi nyata, executor menjadi bean dan service memanggil client sungguhan. Aktifkan juga virtual thread untuk request handling di Spring Boot dengan spring.threads.virtual.enabled=true.
java
// Potongan — Spring Boot 4, konfigurasi executor untuk panggilan I/O
@Configuration
public class ConcurrencyConfig {
@Bean
public ExecutorService ioExecutor() {
return Executors.newVirtualThreadPerTaskExecutor(); // Spring memanggil close() saat shutdown
}
}java
// Potongan — service agregasi dashboard
@Service
public class DashboardService {
private final OrderQueryRepository orders;
private final LoyaltyClient loyalty;
private final RecommendationClient recommendations;
private final ExecutorService ioExecutor;
public DashboardService(OrderQueryRepository orders, LoyaltyClient loyalty,
RecommendationClient recommendations, ExecutorService ioExecutor) {
this.orders = orders;
this.loyalty = loyalty;
this.recommendations = recommendations;
this.ioExecutor = ioExecutor;
}
@Cacheable(cacheNames = "dashboard", key = "#userId", sync = true)
public DashboardView getDashboard(long userId) {
var recentOrders = CompletableFuture.supplyAsync(() -> orders.findRecent(userId, 5), ioExecutor);
var points = CompletableFuture.supplyAsync(() -> loyalty.pointsOf(userId), ioExecutor);
var recs = CompletableFuture.supplyAsync(() -> recommendations.forUser(userId), ioExecutor)
.completeOnTimeout(List.of(), 250, TimeUnit.MILLISECONDS)
.exceptionally(ex -> List.of()); // layanan rekomendasi error -> tetap tampil
return new DashboardView(recentOrders.join(), points.join(), recs.join());
}
}DashboardView adalah record yang berisi DTO (bukan entity JPA), sehingga aman diserialisasi ke cache. Bila memakai serializer JDK bawaan, record dan isinya perlu implements Serializable; serializer JSON seperti yang dibahas di bab caching biasanya lebih praktis.
Langkah 4: cache Redis dengan TTL dan invalidasi
Paralelisasi membuat jalur lambat lebih cepat; cache membuat sebagian besar request tidak menempuh jalur itu sama sekali. Konfigurasi minimal (starter spring-boot-starter-data-redis dan spring-boot-starter-cache, plus @EnableCaching di class konfigurasi):
yaml
spring:
threads:
virtual:
enabled: true
cache:
type: redis
redis:
time-to-live: 60s # jaring pengaman bila invalidasi terlewat
data:
redis:
host: localhost
port: 6379TTL saja tidak cukup: tanpa invalidasi, pesanan yang baru dibuat baru muncul setelah 60 detik. Hapus entri dashboard user tersebut setelah transaksi pesanan commit, supaya request lain tidak sempat mengisi ulang cache dengan data lama:
java
// Potongan — invalidasi setelah commit
public record OrderPlacedEvent(long userId, String orderId) {}
@Component
public class DashboardCacheInvalidator {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@CacheEvict(cacheNames = "dashboard", key = "#event.userId")
public void onOrderPlaced(OrderPlacedEvent event) {
// Tidak perlu isi: @CacheEvict menghapus entri dashboard milik user ini
}
}OrderService cukup memanggil ApplicationEventPublisher.publishEvent(new OrderPlacedEvent(...)) di dalam method @Transactional. Atribut sync = true pada @Cacheable membuat request serentak untuk key yang sama menunggu satu pemuatan saja, mencegah cache stampede saat entri populer kedaluwarsa.
Langkah 5: ukur ulang dan analisis p95
Jalankan script k6 yang sama persis setelah setiap langkah, simpan hasilnya, lalu susun tabel perbandingan. Contoh bentuk tabel (angka ilustrasi — isi dengan hasil milikmu):
| Versi | p50 | p95 | Error | Catatan |
|---|---|---|---|---|
| 01 baseline berurutan | 1.250 ms | 2.900 ms | 2,1% | antre di thread pool server |
| 02 paralel + virtual thread | 410 ms | 520 ms | 0,2% | mengikuti sumber terlambat |
| 03 paralel + timeout rekomendasi | 300 ms | 410 ms | 0,1% | fallback rekomendasi kosong |
| 04 + cache Redis (hit 97%) | 3 ms | 120 ms | 0,0% | p95 kini di jalur hit |
Cara membaca p95 setelah cache: p95 adalah batas 5% request terlambat. Bila miss ratio lebih dari 5%, request di posisi p95 pasti termasuk miss, sehingga p95 hampir sama dengan latensi jalur tanpa cache — walaupun p50 turun drastis. Karena itu langkah paralelisasi tetap penting: ia menurunkan latensi setiap miss. Hit ratio bisa kamu lihat dari metrik cache Micrometer (cache.gets dengan tag result=hit/miss) bila statistik cache diaktifkan. Pola akses di k6 juga berpengaruh: 5.000 user acak dalam 3 menit memberi hit ratio yang lebih jujur daripada 10 user yang sama berulang-ulang.
Contoh di dunia backend
Gunakan checklist ini saat mengerjakan proyek, dan juga saat diminta mempercepat endpoint di tempat kerja:
- Target jelas — SLO tertulis (mis. p95 < 300 ms pada 100 RPS, error < 1%).
- Baseline tersimpan — script k6 di repo, hasil run pertama disimpan, warm-up dilakukan.
- Hipotesis dari data — JFR/metrik menunjukkan waktu habis di menunggu I/O, bukan CPU.
- Paralelkan yang independen — executor khusus I/O (virtual thread), bukan
commonPool. - Timeout di setiap panggilan remote — timeout di HTTP client, plus fallback hanya untuk data opsional.
- Cache yang tepat — key per user, TTL sebagai jaring pengaman,
@CacheEvictsetelah commit, DTO bukan entity. - Ukur ulang setiap langkah — script dan beban yang sama; catat p50, p95, p99, error, hit ratio.
- Cek efek samping — layanan loyalty kini menerima tiga kali lipat request bersamaan? Pool DB cukup? Memori Redis aman?
Latihan kode untuk proyek ini (kerangka DashboardService dengan sumber tiruan dan test JUnit untuk memverifikasi paralelisasi, fallback, dan invalidasi cache) menyusul di folder exercises/. Untuk sekarang, kamu bisa membangun versi sederhananya sendiri: jalankan DashboardDemo di atas, lalu ubah latensi sumber dan nilai timeout untuk melihat dampaknya.
Pertanyaan interview
1. Bagaimana kamu mempercepat endpoint yang memanggil tiga layanan lambat?
Pertama ukur baseline dan pastikan waktu memang habis menunggu ketiga panggilan. Karena independen, saya jalankan bersamaan dengan CompletableFuture di executor virtual thread sehingga latensi mengikuti panggilan terlambat. Setiap panggilan diberi timeout; data opsional diberi fallback. Lalu saya tambahkan cache dengan TTL dan invalidasi saat data berubah, dan membuktikan hasilnya dengan load test ulang yang sama.
2. Kenapa p95 tidak banyak turun padahal p50 turun drastis setelah ada cache?
Karena miss ratio masih di atas 5%. Request di posisi p95 adalah miss yang menempuh jalur lambat, jadi p95 mengikuti latensi tanpa cache. Solusinya menurunkan latensi jalur miss (paralelisasi, query lebih cepat) atau menaikkan hit ratio (TTL lebih panjang dengan invalidasi yang andal, pre-warming untuk key populer).
3. Kapan cache harus di-invalidate, dan kenapa setelah commit?
Saat data sumber yang memengaruhi entri cache berubah. Bila dihapus sebelum transaksi commit, request lain bisa membaca data lama dari database dan mengisi ulang cache sebelum commit selesai, sehingga data basi bertahan sampai TTL habis. Menghapus setelah commit (@TransactionalEventListener fase AFTER_COMMIT) menutup celah itu.
Jebakan umum
- Mengoptimasi tanpa baseline. Tanpa angka awal, klaim "sekarang lebih cepat" tidak bisa dibuktikan.
supplyAsynctanpa executor.ForkJoinPool.commonPool()berukuran sekitar jumlah core — panggilan I/O blocking membuatnya macet.- Mengira
completeOnTimeoutmembatalkan pekerjaan. Task aslinya tetap berjalan; pasang timeout juga di HTTP client dan JDBC. - Menyimpan entity JPA di cache. Lazy loading gagal setelah session tertutup dan serialisasi ikut membawa relasi; simpan DTO/record.
- Hanya mengandalkan TTL. Data basi selama TTL; tambahkan
@CacheEvictsaat data berubah, setelah commit. - Load test dengan sedikit key. Hit ratio mendekati 100% di test tetapi jauh lebih rendah di production.
Ringkasan
- Mulai dengan baseline k6 dan target SLO yang jelas; ukur ulang dengan script yang sama setelah setiap perubahan.
- Panggilan I/O independen diparalelkan dengan
CompletableFuturedi executor virtual thread: latensi mengikuti sumber terlambat. - Timeout untuk semua panggilan remote; fallback hanya untuk data opsional.
- Cache Redis dengan key per user, TTL sebagai jaring pengaman, dan
@CacheEvictsetelah commit. - p95 setelah cache ditentukan oleh miss ratio — percepat jalur miss, jangan hanya mengandalkan cache.