Mini-challenge: P99 spike và collision DoS của ingest
Lab khép module: một endpoint ingest P99 gấp nhiều lần P50 và thỉnh thoảng CPU 100%. Diagnose resize storm của ArrayList/HashMap và hash-collision DoS, rồi fix.
Ba bài trước mổ từng cấu trúc riêng lẻ: ArrayList grow 1.5x, HashMap bucket/chaining/resize, treeify chống collision DoS. Lab này ghép cả ba vào một sự cố production duy nhất — nơi cùng lúc hai triệu chứng khác nhau cùng đến từ cách bạn khởi tạo collection.
Bạn nhận on-call một service MetricsIngest. Nó có một endpoint /ingest nhận một batch metric sample (mảng JSON), gom sample theo tên metric, trả về tổng hợp. Hai than phiền từ dashboard:
- P99 của
/ingestgấp khoảng 6 lần P50 ngay cả khi traffic bình thường — batch nào cũng chậm bất thường ở đuôi phân phối. - Thỉnh thoảng CPU cháy 100% trên vài batch, và lạ ở chỗ những batch đó nhỏ hơn batch bình thường — ít sample hơn nhưng tốn nhiều CPU hơn.
Nhiệm vụ: tìm ra vì sao, rồi fix. Cả hai triệu chứng đều nằm trong nội thất ArrayList/HashMap bạn vừa học.
🎯 Đề bài
Setup
Service gom mỗi batch theo hai bước, đúng như một handler thật:
- Parse: đọc
sampleCountsample từ request vào mộtList. - Aggregate: gom sample theo
metricNamevào mộtMaprồi cộngvaluemỗi nhóm.
Production: batch điển hình khoảng 50.000 sample, khoảng 2.000 tên metric phân biệt. Cả hai collection trong starter đều khởi tạo mặc định — không hint capacity.
Yêu cầu
-
Reproduce P99 spike: chạy benchmark trong starter, đọc P50 và P99. Giải thích chính xác hai chỗ nào trong một batch gây ra chi phí đuôi — mỗi chỗ liên quan tới một cấu trúc, và đếm số lần realloc thật sự cho batch 50.000 sample / 2.000 tên.
-
Fix resize storm: pre-size cả
List(parse) lẫnMap(aggregate) để loại bỏ mọi grow/resize trong đường xử lý batch. VớiMap, phải tính đúng capacity qua load factor — truyền thẳng số phần tử là bẫy. -
Reproduce collision DoS: dùng bộ tên metric được chế sao cho tất cả cùng
hashCode. Chạy một batch nhỏ (ví dụ 5.000 sample) chỉ gồm các tên collide này. Quan sát thời gian aggregate so với batch thường cùng cỡ, và giải thích vì sao ít phần tử lại tốn nhiều CPU hơn — kể cả sau khi bucket đã treeify. -
Fix collision DoS: thêm tầng phòng thủ đúng chỗ. Xác định rõ tầng nào chặn được tấn công và tầng nào (treeify) chỉ giảm thiệt hại.
Cách phân biệt hai triệu chứng
Trước khi sửa, tách bạch hai lỗi bằng một câu hỏi duy nhất: batch gây sự cố to hơn hay nhỏ hơn bình thường?
flowchart TD
A["P99 spike / CPU 100%"] --> B{"batch nho hon binh thuong?"}
B -->|"Sai (batch to)"| C["Resize storm: JFR thay Arrays.copyOf + HashMap.resize"]
B -->|"Dung (batch nho)"| D["Collision DoS: thread RUNNABLE o putVal / TreeNode.find"]
C --> E["Fix: pre-size ArrayList + HashMap"]
D --> F["Fix: gioi han input + rate limit; treeify chi la luoi cuoi"]📦 Concept dùng trong bài
Mỗi yêu cầu map ngược về một learning outcome của module:
| Concept | Bài | Dùng ở đây |
|---|---|---|
ArrayList.grow() 1.5x + pre-sizing | bài 01 | Đếm grow event của parse list, fix bằng capacity hint |
| Bucket/chaining/resize + threshold 0.75 | bài 02 | Đếm resize của aggregate map, pre-size qua load factor |
| Treeify + hash-collision DoS | bài 02b | Chẩn đoán batch collide, chọn tầng phòng thủ |
▶️ Starter code
MetricsIngestApp.java — một file, chạy được ngay:
import java.util.*;
public class MetricsIngestApp {
public record Sample(String metricName, long timestamp, double value) { }
public static class BatchAggregator {
// Group samples by metric name, then sum value per metric.
public Map<String, Double> aggregate(List<Sample> batch) {
// Bucket each sample under its metric name.
Map<String, List<Sample>> byMetric = new HashMap<>();
for (Sample s : batch) {
byMetric.computeIfAbsent(s.metricName(), k -> new ArrayList<>()).add(s);
}
Map<String, Double> result = new HashMap<>();
for (Map.Entry<String, List<Sample>> e : byMetric.entrySet()) {
double sum = 0;
for (Sample s : e.getValue()) sum += s.value();
result.put(e.getKey(), sum);
}
return result;
}
}
// Simulates JSON parsing: reads sampleCount samples into a List.
static List<Sample> parseBatch(int sampleCount, int distinctNames) {
List<Sample> batch = new ArrayList<>(); // Parse: accumulate samples into the batch list.
long now = System.currentTimeMillis();
for (int i = 0; i < sampleCount; i++) {
batch.add(new Sample("metric-" + (i % distinctNames), now, i));
}
return batch;
}
public static void main(String[] args) {
int batches = 200, sampleCount = 50_000, distinctNames = 2_000;
long[] lat = new long[batches];
for (int b = 0; b < batches; b++) {
long t0 = System.nanoTime();
List<Sample> batch = parseBatch(sampleCount, distinctNames); // parse step
new BatchAggregator().aggregate(batch); // aggregate step
lat[b] = System.nanoTime() - t0;
}
Arrays.sort(lat);
System.out.printf("p50 = %.2f ms, p99 = %.2f ms%n",
lat[batches / 2] / 1e6, lat[(int) (batches * 0.99)] / 1e6);
}
}
Chạy:
javac MetricsIngestApp.java
java -XX:+UseParallelGC MetricsIngestApp
# Muon nhin resize storm bang JFR:
# java -XX:StartFlightRecording=duration=30s,filename=ingest.jfr MetricsIngestApp
Để reproduce collision DoS (yêu cầu 3), bạn cần một hàm sinh tên metric cùng hashCode — tự viết trước khi xem gợi ý. Tự làm từng yêu cầu trước khi mở phần Lời giải.
💡 Gợi ý
Định hướng cho resize storm (yêu cầu 1-2)
- Trong một batch,
parseBatchxây mộtListvàaggregatexây mộtMap. Cả hai bắt đầu từ capacity mặc định. Mỗi cái grow theo luật nào?ArrayList: capacity mới = capacity cũ + (capacity cũ >> 1). Bắt đầu từ đâu sauaddđầu tiên?HashMap:threshold = capacity × loadFactor; resize khisizevượtthreshold; capacity luôn là power-of-2. Bắt đầu từ đâu?
- Tự chạy chuỗi hai luật trên bằng giấy hoặc một vòng
forngắn: với 50.000 phần tử thìArrayListrealloc bao nhiêu lần? Với 2.000 key phân biệt thìHashMapresize bao nhiêu lần? (Đừng đoán — sinh chuỗi ra.) - Mỗi lần realloc/resize làm gì với dữ liệu đang có? Đó là chỗ chi phí đuôi P99 đến từ đâu.
- Pre-size: nếu biết trước N, khởi tạo capacity bao nhiêu để 0 grow?
ArrayList: capacity ban đầu ≥ N.HashMap: vì resize ởN > capacity × loadFactor, cầncapacity ≥ N / loadFactor. Có API JDK nào làm sẵn phép chia này không?
Định hướng cho collision DoS (yêu cầu 3-4)
- Vì sao một batch nhỏ hơn lại tốn nhiều CPU hơn? Cái gì trong
putValbiến thành tuyến tính khi mọi key vào cùng một bucket? - Chế key cùng
hashCode:hashCodecủa chuỗi hai ký tự làs[0]*31 + s[1]. Có cặp chuỗi hai ký tự nào cho cùng tổng đó không (ký tự đầu chênh 1, ký tự sau bù lại)? Nếu ghép các cặp đó lại thìhashCodecủa chuỗi ghép có còn bằng nhau không? - Với hash tử tế, xác suất một bucket dồn tới 8 entry theo phân phối Poisson (tham số ~0.5) là cực nhỏ. Vậy khi bạn thấy chain dài 8, điều đó nói gì về input?
- Treeify hạ worst case của lookup từ tuyến tính xuống mức nào? Mức đó với vài nghìn key vẫn là bao nhiêu bước so với happy path 1 bước? Treeify có chặn được tấn công, hay chỉ giảm thiệt hại?
- Nếu bạn phụ trách endpoint: viết ra hai tầng phòng thủ đặt TRƯỚC khi request chạm
HashMap. Tầng nào rẻ nhất và chặn từ cửa?
✅ Lời giải
Yêu cầu 1 — hai nguồn chi phí đuôi, đếm chính xác
Một batch chạm hai resize storm độc lập:
ArrayList trong parseBatch (50.000 sample). Sau add đầu tiên capacity là DEFAULT_CAPACITY = 10, rồi mỗi lần đầy nhân 1.5x: 10 → 15 → 22 → 33 → 49 → 73 → ... → 1851 → 2776 → ... → 47427 → 71140. Để chứa 50.000 phần tử cần 22 lần grow (capacity cuối 71140). Mỗi grow gọi Arrays.copyOf copy toàn bộ phần tử đang có — công việc O(n) dồn vào đúng lần add chạm trần.
HashMap trong aggregate (2.000 key phân biệt). Capacity mặc định 16, threshold = 16 × 0.75 = 12. Mỗi lần size vượt threshold thì nhân đôi: 16 → 32 → 64 → ... → 2048 → 4096. Để chứa 2.000 key (threshold phải ≥ 2.000 → capacity 4096, threshold 3072) cần 8 lần resize, mỗi lần rehash toàn bộ entry hiện có.
P99 spike là các batch xui: lần add hoặc put chạm trần capacity gánh trọn chi phí copy/rehash. Amortized vẫn O(1) mỗi thao tác, nhưng người dùng ở đuôi phân phối cảm nhận từng lần realloc — đúng như bài 01 và bài 02 đã cảnh báo.
Yêu cầu 2 — pre-size để 0 grow/resize
public Map<String, Double> aggregate(List<Sample> batch, int distinctNameHint) {
// capacity = tableSizeFor(ceil(distinctNameHint / 0.75)); 0 resize during batch
Map<String, List<Sample>> byMetric = HashMap.newHashMap(distinctNameHint);
for (Sample s : batch) {
byMetric.computeIfAbsent(s.metricName(), k -> new ArrayList<>()).add(s);
}
Map<String, Double> result = HashMap.newHashMap(byMetric.size());
for (Map.Entry<String, List<Sample>> e : byMetric.entrySet()) {
double sum = 0;
for (Sample s : e.getValue()) sum += s.value();
result.put(e.getKey(), sum);
}
return result;
}
static List<Sample> parseBatch(int sampleCount, int distinctNames) {
List<Sample> batch = new ArrayList<>(sampleCount); // pre-sized: 0 grow
long now = System.currentTimeMillis();
for (int i = 0; i < sampleCount; i++) {
batch.add(new Sample("metric-" + (i % distinctNames), now, i));
}
return batch;
}
Hai điểm chính:
new ArrayList<>(sampleCount)cấp đúng backing array một lần — 0 grow thay vì 22.HashMap.newHashMap(2000)(Java 19+) tínhceil(2000 / 0.75) = 2667rồi làm tròn lên power-of-2 là 4096 — threshold 3072 vượt 2.000, không resize lần nào. Truyền thẳngnew HashMap<>(2000)là bẫy: constructor làm tròn 2000 lên power-of-2 là 2048, threshold = 2048 × 0.75 = 1536 → vẫn resize một lần ở entry thứ 1.537 (nhân đôi lên 4096). Java cũ hơn thì viết taynew HashMap<>((int)(2000 / 0.75 + 1))— cho 2667, cũng làm tròn lên 4096.
Sau fix, P99 tụt sát P50 vì đuôi phân phối không còn gánh realloc.
Yêu cầu 3 — collision DoS, và vì sao batch nhỏ lại đắt
Chế tên metric cùng hashCode: "Aa" và "BB" đều có hashCode = 2112. Ghép chuỗi giữ nguyên collision, nên n cặp cho 2^n chuỗi phân biệt cùng một hashCode.
// Every returned string has the SAME String.hashCode() -> one bucket.
static List<String> collidingNames(int count) {
String[] seeds = { "Aa", "BB" }; // both hash to 2112
List<String> names = new ArrayList<>(List.of(""));
while (names.size() < count) {
List<String> next = new ArrayList<>(names.size() * 2);
for (String base : names)
for (String seed : seeds)
next.add(base + seed);
names = next;
}
return new ArrayList<>(names.subList(0, count));
}
Một batch chỉ 5.000 sample nhưng tất cả metricName lấy từ collidingNames đều rơi vào một bucket. computeIfAbsent phải walk cả chain để biết key đã tồn tại chưa — mỗi put duyệt tới hàng nghìn node. Batch nhỏ hơn batch thường (50.000) nhưng tổng số phép so sánh lớn hơn nhiều bậc: đây là hash-collision DoS. Với hash tử tế, xác suất một bucket dồn 8 entry theo Poisson (tham số ~0.5) chỉ khoảng 1 trên 17 triệu — thấy chain dài 8 gần như chắc chắn là input bị lạm dụng.
Treeify (Java 8+) chuyển bucket quá 8 entry thành red-black tree, hạ lookup từ O(n) xuống O(log n). Nhưng với vài nghìn key trong một bucket, O(log n) vẫn là khoảng 11–12 bước so với 1 bước của happy path — và attacker vẫn ép map dựng cây liên tục. Treeify giảm thiệt hại, không chặn tấn công.
Xác nhận bằng thread dump khi CPU cháy: các thread xử lý request đều RUNNABLE, stack dồn vào HashMap.putVal (hoặc TreeNode.putTreeVal/find nếu đã treeify) — CPU cháy trong code "có ích" nhưng mỗi thao tác tốn hàng nghìn bước. Khác hẳn GC (CPU ở GC thread) hay lock contention (thread BLOCKED, CPU thấp).
Yêu cầu 4 — phòng thủ đúng tầng
static final int MAX_SAMPLES = 100_000;
static final int MAX_DISTINCT_NAMES = 10_000;
public static Map<String, Double> handleIngest(List<Sample> batch) {
// Layer 1: cap input size BEFORE building any map -- cheapest, blocks at the door
if (batch.size() > MAX_SAMPLES) {
throw new IllegalArgumentException("batch too large: " + batch.size());
}
Map<String, Double> result = new BatchAggregator()
.aggregate(batch, Math.min(batch.size(), MAX_DISTINCT_NAMES));
// Layer 1b: reject batches with an implausible number of distinct names
if (result.size() > MAX_DISTINCT_NAMES) {
throw new IllegalArgumentException("too many distinct metrics");
}
return result;
}
Defense-in-depth theo thứ tự hiệu quả (từ bài 02b):
- Giới hạn input trước khi parse — cap số sample và số key phân biệt. Chặn từ cửa, rẻ nhất.
- Rate limiting theo IP/user cho endpoint nhận key tùy ý.
- Treeify (Java 8+) — lưới an toàn tự động, không config, nhưng chỉ là tầng cuối.
Không tầng nào một mình đủ, và treeify không thay được validate input.
🎓 Mở rộng
Mức 1 — đo grow bằng JFR thay vì đếm tay. Chạy -XX:StartFlightRecording=duration=30s,filename=ingest.jfr, mở bằng JMC, xem tab Memory → Allocation: hot allocation site sẽ là Arrays.copyOf (ArrayList grow) và HashMap.resize/resize. Sau khi pre-size, hai site này biến mất.
Mức 2 — Collectors.groupingBy có pre-size không? Viết lại aggregate bằng batch.stream().collect(Collectors.groupingBy(Sample::metricName)) và đọc source: nó dùng HashMap::new mặc định (không hint), nên vẫn resize storm. Muốn pre-size phải dùng overload groupingBy(classifier, HashMap::new, downstream) với supplier tự cấp — hoặc groupingBy không cứu được P99.
Mức 3 — vì sao ConcurrentHashMap không miễn nhiễm collision DoS? ConcurrentHashMap cũng chaining + treeify theo cùng ngưỡng. Nó chống data race, không chống hash abuse — cùng batch collide vẫn dồn một bucket. Phòng thủ vẫn là validate input, không phải đổi map.
✨ Điều bạn vừa làm được
Hoàn thành lab này, bạn đã:
- Chẩn đoán P99 spike và truy ra hai nguồn resize storm độc lập trong một batch —
ArrayListgrow vàHashMapresize — rồi đếm chính xác số lần realloc. - Pre-size đúng cách:
ArrayListtheo N,HashMapqua load factor (HashMap.newHashMap), loại bỏ mọi grow trong đường xử lý. - Reproduce hash-collision DoS bằng key chế cùng
hashCode, và giải thích vì sao batch nhỏ lại đắt kể cả sau treeify. - Chọn tầng phòng thủ đúng chỗ: giới hạn input + rate limiting chặn tấn công; treeify chỉ là lưới cuối.
Đây là ba mảnh của module ghép lại thành một kỹ năng debug production duy nhất — từ triệu chứng ở dashboard tới root cause trong nội thất collection.
Bài tiếp theo: Tổng kết module — Collections Internals
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên