From a945286aaeea89958e290e0ad18665c358875db0 Mon Sep 17 00:00:00 2001 From: Tester Date: Thu, 13 Aug 2026 06:28:51 +0000 Subject: [PATCH 1/8] =?UTF-8?q?docs(interview):=20rewrite=20java-core=20as?= =?UTF-8?q?=20Junior=E2=86=92Senior=20Q&A=20series=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../blog/en/interview/java-core-senior.md | 467 ++---------------- .../blog/vi/interview/java-core-senior.md | 467 ++---------------- 2 files changed, 108 insertions(+), 826 deletions(-) diff --git a/src/data/blog/en/interview/java-core-senior.md b/src/data/blog/en/interview/java-core-senior.md index 39fce49..f5e11b6 100644 --- a/src/data/blog/en/interview/java-core-senior.md +++ b/src/data/blog/en/interview/java-core-senior.md @@ -1,6 +1,6 @@ --- -title: "Senior Java Interview: Java Core Deep Dive" -description: "What senior interviewers actually probe in Java core — GC and the JMM, concurrency traps, virtual threads, and the runtime tooling that proves you've debugged production." +title: "Java Interview Prep #1: Java Core (JVM, GC, Concurrency) — Junior to Senior" +description: "The spine of every Java interview — JVM memory, garbage collection, the JMM, and concurrency. Junior recites; senior proves they've debugged a production OutOfMemoryError." pubDatetime: 2026-08-10T10:00:00+07:00 featured: true draft: false @@ -11,442 +11,83 @@ tags: - concurrency --- -A junior knows Java syntax. A senior knows **what the JVM is doing, why it behaves that way, and where it will surprise you in production.** This is the Java-core slice of senior interview prep. +Java core is the filter that ends more interviews than system design ever does. It is where a junior can memorize keywords and a senior can prove they have stared at a heap dump at 3 a.m. This post walks the same topic from "what is the heap" to "here is how I halved GC pause on a 40 GB service" — pick the level you are interviewing at, and read one above it. -> Mindset: "it depends, and here's the trade-off" beats reciting facts every time. The moment you answer with a tradeoff, a number, or a postmortem instead of a definition, you've cleared the bar. +> Mindset: junior names the garbage collectors; senior can tell you which one paused their service last quarter, by how much, and what they changed. -## 1. Heap, GC, and the pause math +## Junior — foundations -Expect: "What happens when you `new` an object?" A mid answer stops at "it goes on the heap." A senior talks about **where**, **how fast**, and **what the pause costs** — because that's what actually bites in production. Every question in this section has a numeric answer; interviewers listen for the number, not the noun. +**Q1. What are the main memory areas of the JVM?** +The JVM divides memory into: the **heap** (all object instances, shared, GC-managed), **metaspace** (class metadata, formerly permgen), the **stack** per thread (frames, locals, operands), the **PC register** per thread, and **native method stacks**. Everything you `new` lives in the heap; every method call pushes a frame onto the thread stack. -### Allocation isn't a malloc call — it's a pointer bump - -Each thread carves out a **TLAB (Thread-Local Allocation Buffer)** from Eden — a few hundred KB to a couple of MB of cache-hot private space — so allocating is just bumping a pointer. No global lock, no CAS. That's why `new` is so cheap that a JVM routinely allocates tens of millions of throwaway objects per second without breaking a sweat. - -```java -String tmp = prefix + id; // looks wasteful; a TLAB bump makes it nearly free -``` - -Where a senior goes deeper: **escape analysis**. The JIT (C2) can prove an object never leaves the method and **scalar-replace** it — the fields become JIT registers and stack slots and the allocation simply disappears. It's not literal "stack allocation"; it's "there is no object." Run `-XX:+PrintEliminateAllocations` and you'll watch the JIT throw allocations away. Objects that genuinely escape — passed to another thread, returned, stored in a field — are the ones that land in Eden and get promoted. - -### Object layout and the compressed-oops threshold - -Every object carries a header: an 8-byte mark word (identity hash, lock state, GC age) plus a 4-byte class pointer **when compressed oops are on** — the default for heaps below **~32 GB**. An empty `Object` is 16 bytes; a bare `Long` is 24. On a service allocating 100M objects per fan-out that's a gigabyte of pure header tax, which is why value-based redesigns (records with primitives, primitive-typed collections) are a real senior move, not trivia. - -The threshold matters: cross 32 GB of heap and the JVM can no longer address objects with 32-bit narrow pointers, so it either **disables compressed oops** (every header widens) or you raise `-XX:ObjectAlignmentInBytes` (default 8, so raising it to 16 doubles padding). Both inflate memory-per-object. That's one reason a 40 GB heap can behave worse than a 28 GB one — "we sized up and got slower" is often this, or a GC pattern change. If the interviewer asks about heap sizing, name the 32 GB line before they do. - -### The generational hypothesis, and the numbers that explain it - -"Most objects die young" isn't a slogan, it's a measured distribution: on typical service workloads **~90% of objects are garbage within a few GC cycles**. That's why the heap is split: - -- **Eden** — most objects allocate and die here; the majority never touch a survivor space. -- **Survivor spaces (S0/S1)** — objects that survive minor GC get copied back and forth; deliberately small. -- **Old gen** — objects that survive `-XX:MaxTenuringThreshold` of copying (default 15, dynamically adapted by G1). - -The ratio matters more than the names: if ~90% of objects die in Eden, a young-gen GC copies only the surviving ~10%, which is why the pause is dominated by **live bytes copied**, not by total allocations. - -### The pause math interviewers fish for - -A stop-the-world pause is fundamentally - -``` -pause ≈ live_bytes_copied / copy_throughput -``` - -so the first lever is always **young-gen size**, not collector choice: - -``` -Example: 2 GB young gen, 70% survivor set survives a minor GC → ~1.4 GB copied. -At ~10 GB/s copy throughput that's ~140 ms of STW, every minor GC. -Shrink young gen to 512 MB → ~36 ms. Smaller still → more frequent GCs. -``` - -The tension is real: a bigger young gen means fewer, longer pauses; a smaller one means shorter pauses, more often. The second number to keep in your pocket is the **GC overhead**: - -``` -GC overhead = time_in_GC / wall_time -200 ms of GC per minute → 0.33% throughput tax. -``` - -G1 attacks the pause by doing **incremental** collection of regions toward `-XX:MaxGCPauseMillis` (default 200 ms) — but it's a **soft goal**. If the survivor set genuinely can't be copied in time, G1 quietly grows the pause. The failure modes matter more than the goal: - -- **Concurrent-mode failure** — old gen fills faster than the concurrent marking cycle can reclaim it, and G1 falls back to a **full STW Full GC**. On a 50 GB heap that's seconds of everyone-stopped — the classic "latency chart turns into a cliff" postmortem. -- **Evacuation failure / promotion failure** — the to-space runs out mid-copy (usually a sudden survivor spike), objects get retained in place, and the following GCs pay for it. -- **Humongous allocations** — G1 regions are 1–32 MB; anything bigger than half a region is a **humongous** object that goes straight to old gen, can't be moved by normal copying, and can trigger a full GC. A 4 MB `byte[]` in a 2 MB-region heap is humongous. Pooled buffers, not per-request byte arrays, is the senior fix. - -### Picking a collector with a number in hand - -``` -Parallel GC → max throughput, STW on every major GC. Right when pauses are fine - (batch jobs, offline). Often 100s of ms to seconds at scale. -G1 (default) → balanced; region-based, mixed GCs. Good default for service heaps - up to ~100 GB. Pauses 10s–200 ms depending on heap. -ZGC → sub-ms pauses even on multi-TB heaps, via colored pointers + - load barriers doing most work concurrently. Taxes CPU throughput. -Shenandoah → same goal, different trick (concurrent evacuation, forwarding - pointers). Pauses ~milliseconds, memory-heavy. -``` - -(CMS was the old latency answer and was **removed in JDK 14** — say that if someone drifts there.) A senior picks with a number in hand: "we run a 50 GB heap, the 99th-pctile pause must stay under 50 ms, and we have spare CPU, so ZGC — and here's the tradeoff, ZGC trades ~5–10% CPU throughput for that latency." And **never reach for `System.gc()` as a fix** — under Parallel it's a full STW pause of every thread for seconds; under G1 it may not even trigger what you think. - -### Reference types — Soft, Weak, Phantom - -GC interviewers love reference types because production misuse is so common: - -- **`SoftReference`** — kept alive until the JVM decides memory is tight; survives normal GCs, collected under pressure. Good for a "cache that shrinks when the box gets hot," but JVM-specific and rarely a precise memory budget. -- **`WeakReference`** — collected on the next GC, no waiting. Right for identity maps keyed by ephemeral objects (a `WeakHashMap` keyed by a request context). -- **`PhantomReference`** — the referent is already unreachable when the reference is queued, so you can safely release native resources there; you **must** call `clear()` or it's never collected. This is the modern replacement for the deprecated `finalize()` path (JEP 421, deprecated in JDK 18) — pair it with a `ReferenceQueue`/`Cleaner`. - -```java -// WRONG — finalize for native cleanup: unpredictable, resurrectable, deprecated -@Override protected void finalize() { nativeFree(handle); } - -// RIGHT — PhantomReference + ReferenceQueue: cleanup runs on your drainer -// thread only when the object is provably unreachable, never on the GC thread -ReferenceQueue queue = new ReferenceQueue<>(); -PhantomReference ref = new PhantomReference<>(resource, queue); -// drainer thread: poll queue; for each ref → nativeFree(handle) and ref.clear() -``` - -### Production failure modes - -- **GC does not mean you stop managing memory.** Unbounded caches, static collections, and thread-local references still OOM you — GC can't collect what your code keeps rooted. - -```java -// WRONG — a "cache" that is actually a growing root -private static final Map CACHE = new HashMap<>(); - -// RIGHT — bounded + time-based eviction; Caffeine is a ConcurrentHashMap -// with a W-TinyLFU admission window, so this is not "a timer + a map". -private static final Cache CACHE = Caffeine.newBuilder() - .maximumSize(10_000) - .expireAfterWrite(Duration.ofMinutes(10)) - .build(); -``` - -- **`ThreadLocal` leaks in thread pools.** Subtler than people think: the **key** in `ThreadLocalMap` is a `WeakReference`, so the key can be collected — but the **value is strongly referenced** and stays alive in the map entry until that slot is expunged. In a long-lived pooled thread that never touches the slot again, the value leaks forever. A request-scoped context holding a 10 MB blob, set on a 200-thread pool → 2 GB of "heap is full for no reason." The fix: `ThreadLocal.remove()` in a `finally`, or scoped values (section 4). -- **Allocation storms in tight loops** inflate GC frequency, not pause time. `jstat -gcutil` shows FGC/FGCT climbing while the heap never clears. -- **String interning explosions.** `String.intern()` on every request response header will balloon the string table and old gen. `-XX:+PrintStringTableStatistics` will show you. -- **`-histo:live` is not free.** `jmap -histo:live` (and `jcmd GC.class_histogram -live`) triggers a full GC — running that against a 50 GB production heap at peak is a self-inflicted incident. Prefer `-histo` or JFR. - -## 2. The JMM and memory visibility — happens-before, fences, and what a barrier costs - -"Volatile makes writes visible" is a mid answer. The senior answer is the **happens-before edge** — the actual contract the JMM guarantees — plus the price the hardware charges you for it. The question "why isn't my flag visible?" is answered with the program-order + synchronizes-with rules, never with "it just doesn't work on my machine." - -The happens-before edges you can actually rely on: - -- `volatile` write → subsequent `volatile` read of the same field. -- Unlocking a monitor → subsequent locking of the same monitor (so `synchronized` gives visibility, not just exclusion). -- `Thread.start()` → everything the started thread does. -- Everything a thread does → what the joining thread sees after `join()`. -- Writing a `final` field in a constructor → reads after safe publication. -- `Atomic*` writes → subsequent reads (CAS forms a full fence). - -```java -// WRONG — the infinite-loop classic. stop may stay in thread T's register/cache -// forever; the compiler may even hoist the read out of the loop. -boolean stop = false; // not volatile -while (!stop) { doWork(); } - -// RIGHT — volatile write on T1 happens-before the volatile read on T2 -volatile boolean stop = false; -while (!stop) { doWork(); } -``` - -### The double-checked-locking trap they always probe - -The canonical JMM question. The problem isn't the lock — it's that the unsynchronized read can observe a reference to an **incompletely constructed** object: the reference store is allowed to float before the constructor's writes finish (no happens-before across threads), so thread B sees `instance != null` and returns a half-built singleton. - -```java -// WRONG — DCL without volatile. Both threads can observe a partially-built instance. -private static Singleton instance; -public static Singleton get() { - if (instance == null) { // unsynchronized read - synchronized (Singleton.class) { - if (instance == null) { - instance = new Singleton(); - } - } - } - return instance; -} - -// RIGHT — volatile creates the constructor-write → read happens-before edge -private static volatile Singleton instance; -``` - -On a 64-bit JVM a `volatile long` read is one atomic load, but on a **32-bit JVM it's two 32-bit halves** — so `volatile long` is exactly the case where "volatile" and "atomic" diverge. Small trivia that separates people who read the JMM from people who lived through it. - -### What a fence actually costs - -`volatile` compiles down to a memory barrier — on x86 a `lock`-prefixed instruction or an `mfence` for the store-load case. It's not free: a fenced volatile write runs in the **tens of nanoseconds**, versus ~1 ns for a cache-local read. The latency ladder is the mental model interviewers want to hear: - -``` -L1 cache hit: ~1 ns -L2: ~4 ns -L3: ~10–15 ns -main memory: ~100 ns -fenced volatile write: ~20–80 ns (store-load barrier) -NVMe random read: ~20–50 µs -same-DC network round trip: ~100–500 µs -``` - -That ladder is why "just make everything volatile" is a real latency bug in hot loops, and why false sharing — the next trap — stings so hard. - -### volatile ≠ atomicity, and the "which one" answer - -`volatile` gives visibility and ordering, **not** atomicity. `i++` is read-modify-write; two threads can both read 41 and both write 42. The correct tool depends on the shape of the contention: - -- **`AtomicLong`** — a single CAS on one cache line. Fast until threads collide, then they spin-retry and the line bounces across cores. -- **`LongAdder`** — stripes the counter across a set of cells, one per contended core, and sums them on `sum()`. Under heavy contention (say ≥ 16 threads hammering one counter) it runs **several times faster** than `AtomicLong` because CAS retries vanish. Tradeoff: `sum()` is O(cells) and approximate under concurrent writes — fine for metrics, wrong for a precise debit ledger. - -### False sharing — the trap that isn't a lock - -A cache line is 64 bytes. Two fields that are **independent** but sit on the same line get a coherence ping-pong every time either is written, even in lock-free code. A per-thread `long[]` counter is the classic: thread 0 owns index 0, thread 1 owns index 1 — adjacent in memory — and they trample each other at 10–100× the expected cost. `@Contended` (JEP 142) pads the fields onto separate lines, or you size per-thread slots by line width. When an interviewer says "your lock-free counter is slower than the `synchronized` one," this is what they're probing. - -``` -That "why is my counter at 2% CPU but 40× slow" report is false sharing — -a coherence miss costs ~100 ns of memory traffic per ping, at high frequency. -``` - -## 3. Concurrency primitives — what's under the lock - -### `synchronized` is not a lock, it's a state machine - -A monitor starts **thin** — bits in the object's mark word, no OS mutex involved. Under contention it **inflates** to a heavyweight monitor with an OS-level wait queue and a wait set, and the JVM applies **adaptive spinning** before parking the thread. Biased locking used to make uncontended acquisition ~free, but it was **deprecated in JDK 15 and removed in JDK 18** — say that date confidently and you've signaled you follow JEPs. The practical lesson: uncontended `synchronized` is nearly free (a mark-word update, ~tens of ns); contended `synchronized` pays a park/unpark round trip that crosses into the kernel — microseconds, three to four orders of magnitude worse than the uncontended path. That's _why_ you reach for atomics or striping. - -### `ReentrantLock` and AQS - -`ReentrantLock`, `Semaphore`, `CountDownLatch` are all built on **AQS** (`AbstractQueuedSynchronizer`): a single `volatile int state` plus a CLH-style wait queue, mutation via CAS and `LockSupport.park/unpark`. When you call `tryLock(2, TimeUnit.SECONDS)` you're doing a timed CAS + park loop — the fairness knob (`new ReentrantLock(true)`) makes waiters go FIFO but costs throughput via more context switches. The senior move is picking based on the failure mode: - -```java -// WRONG — block forever waiting for a lock you may never get -lock.lock(); -try { update(); } finally { lock.unlock(); } - -// RIGHT — a lease with a deadline. This is how you avoid "stuck thread, -// heap full of waiting threads, nobody holding the lock" incidents. -if (lock.tryLock(2, TimeUnit.SECONDS)) { - try { update(); } finally { lock.unlock(); } -} else { - // degrade: return 503, skip, log — don't hang -} -``` - -- **`ReentrantLock`** adds `tryLock(timeout)`, multiple `Condition`s (await/signal with named predicates), and fairness control — `synchronized` has exactly one wait set. -- **`StampedLock`** — the lock most people can't name, which is exactly why it's a good probe. Its **optimistic read** never blocks at all: take a stamp, read, then `validate()` — if a writer barged in, fall back to a real read lock. Great when readers dominate and writes are rare; wrong when writes are frequent, because validation keeps failing and you thrash. - -```java -long stamp = lock.tryOptimisticRead(); // no lock at all -int v = shared; -if (!lock.validate(stamp)) { // writer sneaked in? - stamp = lock.readLock(); - try { v = shared; } finally { lock.unlockRead(stamp); } -} -``` - -- **`ConcurrentHashMap` (Java 8+)** uses CAS for empty bins and `synchronized` on the bin head for collisions; bins **treeify at ≥ 8 entries** into a red-black tree (a bin of equal-hash keys would otherwise degenerate to O(n)). `size()` is a sum of base counters, so it's **approximate** — say that out loud, it's a classic "gotcha they check for." -- **The `computeIfAbsent` deadlock trap.** It holds the bin's lock while your mapping function runs, so a recursive `computeIfAbsent` on the **same key** from inside itself deadlocks the bin in Java 8 (fixed in JDK 9 by a bin-occupancy re-check). Production version: a cache that lazily builds a value which lazily loads the same value. Know it by name. - -```java -// WRONG — Java 8 deadlock: mapping function recomputes the same key -cache.computeIfAbsent(key, k -> cache.computeIfAbsent(k, x -> build(x))); - -// RIGHT — compute once outside, or use putIfAbsent semantics you control -var v = cache.get(key); -if (v == null) { v = build(key); cache.putIfAbsent(key, v); } -``` - -### `CompletableFuture` — the pool trap nobody reads the Javadoc for - -`thenApplyAsync` runs on **`ForkJoinPool.commonPool()`**, whose parallelism is `availableProcessors - 1`. The moment any async task does blocking work — a JDBC call, a `Thread.sleep`, a `synchronized` block — it steals a worker, and if enough tasks block, the pool is exhausted and **everything downstream stalls even though the box is idle**. Production symptom: "we replaced futures with a bigger thread pool and it fixed itself." The senior fix is to pass an explicit executor sized for the blocking work: - -```java -// WRONG — blocking JDBC inside async code starves commonPool -CompletableFuture.supplyAsync(() -> accountRepository.findById(id).get()) - .thenApplyAsync(Account::getBalance); - -// RIGHT — explicit executor sized by Little's law (section 4), or virtual threads -CompletableFuture.supplyAsync(() -> accountRepository.findById(id).get(), jdbcExecutor) - .thenApplyAsync(Account::getBalance, jdbcExecutor); -``` - -Error handling nuance they drill on: `handle` sees both value and throwable, `exceptionally` only errors, and **an exception in `thenApply` returns a completed exceptionally** — so decide whether you want to compose or recover. And remember: `thenCompose` (flatMap) vs `thenCombine` (zip) is the difference between a chain and a fork-join. - -## 4. Threads, thread pools, and virtual threads - -### Pool sizing with Little's law — the number that ends the argument - -The naive answer is "cores × 2". The defensible answer is Little's law, because for **blocking** workers the pool is a conveyor belt: - -``` -pool_size = throughput × average time-in-pool -300 req/s × 80 ms average JDBC+CPU time = 24 workers -``` - -For **CPU-bound** work there's no waiting term, so the pool should sit at roughly the core count (+1) — more threads than cores just queues and switches. The general shape is `N = cores × (1 + wait/compute)` — derive it, don't quote it. - -Oversizing past that is actively harmful: context-switch thrash, idle connections on the DB side, and queueing _inside_ the database. Undersizing queues requests at `connectionTimeout` until latency climbs then throughput collapses — the classic "DB is fine, the pool is empty" incident. - -```java -// WRONG — 200 threads because the box has 64 cores, unbounded queue -// (unbounded queue + blocking tasks = the "infinite memory buffer" OOM) -ExecutorService pool = new ThreadPoolExecutor( - 0, 200, 60, SECONDS, new LinkedBlockingQueue<>()); // unbounded! - -// RIGHT — Little's law says ~25; bounded queue; explicit saturation policy -ExecutorService pool = new ThreadPoolExecutor( - 25, 25, 0, MILLISECONDS, new ArrayBlockingQueue<>(100), new CallerRunsPolicy()); -``` - -`CallerRunsPolicy` — the rejected task runs on the calling thread — is the anti-OOM choice: it adds **backpressure** instead of buffering or dropping. Know `AbortPolicy` (default, throws), `DiscardPolicy`, and why none of them backpressure except `CallerRuns`. And re-check the JDK defaults you _think_ you know: `Executors.newFixedThreadPool` uses an unbounded `LinkedBlockingQueue`, so with blocking tasks it's an OOM vector, not a pool. `SynchronousQueue` (used by `newCachedThreadPool`) is the opposite extreme — a zero-buffer handoff. - -### Platform threads are expensive; virtual threads are not - -A platform thread carries a ~1 MB default stack (virtual memory) plus kernel scheduling; creating one costs microseconds and context-switching tens of thousands of them burns real kernel time. **Virtual threads** (Java 21+, Project Loom) are Java objects with a few-KB stack, scheduled on a handful of **carrier threads** (a `ForkJoinPool` with parallelism = CPU count) — the OS only ever sees the carriers. +**Q2. What is the difference between `==` and `equals()`?** +`==` compares references (are these the same object in memory). `equals()` compares _logical_ equality, and you must override it (with `hashCode()`) or you inherit the reference comparison from `Object`. Two `String`s with the same characters are `==` only because the string pool interns literals — a classic trap: ```java -try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { - List> futures = urls.stream() - .map(url -> executor.submit(() -> fetch(url))) - .toList(); -} +String a = "java"; +String b = new String("java"); +System.out.println(a == b); // false — different objects +System.out.println(a.equals(b)); // true — same characters ``` -- **What they're for:** I/O-bound work that blocks — HTTP calls, DB round trips, RPCs. A million concurrent outbound calls on a thread-per-request platform-thread pool dies; on virtual threads it's a million cheap stacks. -- **What they are NOT:** faster CPU-bound work. There's still only N CPUs; a CPU-bound virtual thread gains nothing. -- **Pinning — the trap:** a virtual thread that blocks while holding a carrier resource pins it. Before JDK 24 that meant any **`synchronized` block that blocks**; **JEP 491 (JDK 24) removed pinning for `synchronized`**, so the residual sources are blocking **native frames** (JNI / the Foreign Function & Memory API), **class loading / class initializers**, and **local file I/O on Linux**. AQS-based locks like `ReentrantLock` never pinned — `LockSupport.park` is virtual-thread-aware and unmounts the thread, which is the classic misunderstanding. "What still pins?" is the current-JEPs signal, and the audit tool is the `jdk.VirtualThreadPinned` JFR event (enhanced in JDK 24 to say _why_). -- **`ThreadLocal` on virtual threads is a footgun:** every virtual thread has its own map, so a request-scoped `ThreadLocal` on a million virtual threads is a million entries. The successor is **scoped values** (`ScopedValue`), which are immutable, inheritable only in structured-concurrency scopes, and reclaim cheaply — that's what you'd name instead of "use a ThreadLocal." -- **Virtual threads don't remove the connection-pool bound.** A million virtual threads can all block on a HikariCP pool whose default `maximumPoolSize` is 10 — you've only moved the queue from the thread pool to the connection pool. Size connections with Little's law too (section 5). - -### Structured concurrency - -"Millions of threads" begs the question: how do you cancel them as a group when one fails? `StructuredTaskScope` (Java 21+) binds child tasks to the parent's lifetime — `fork` children, then `join` and handle shutdown on failure, and the scope's end **cancels every still-running child automatically**. The failure mode this kills: a fan-out request that silently leaves 900 of 1,000 outbound calls running after a timeout. If you can contrast "fire-and-forget futures that leak work" with "`StructuredTaskScope` that shuts the whole fan-out down," you've answered the concurrency-resilience question before it's asked. +**Q3. What are the primitive types and are they objects?** +`byte, short, int, long, float, double, char, boolean` — eight primitives, not objects, stored by value. Everything else is a reference to an object on the heap. Autoboxing (`int` ↔ `Integer`) is syntactic sugar that hides allocations; `IntegerCache` interns -128..127, so `Integer.valueOf(42) == Integer.valueOf(42)` is `true` but `Integer.valueOf(200) == Integer.valueOf(200)` is `false`. -## 5. The database that lives behind your methods +**Q4. What is the difference between `String`, `StringBuilder`, and `StringBuffer`?** +`String` is immutable — every concatenation allocates a new object. `StringBuilder` is mutable and not thread-safe (fast). `StringBuffer` is the same but `synchronized` (slow, rarely needed). In a loop, `+=` on a `String` is O(n²) allocations; use `StringBuilder`. -A senior backend interview drifts from the JVM to the pools to the SQL, because the failure modes are all the same shape: a bounded resource — heap, threads, connections, index pages — and something that quietly queues on it. Three traps that show up constantly. +**Q5. What is the difference between `final`, `finally`, and `finalize`?** +`final` on a class forbids subclassing, on a method forbids override, on a variable forbids reassignment. `finally` runs after `try`/`catch` regardless of exception (used for cleanup). `finalize()` is a deprecated hook the GC calls before reclaiming an object — never rely on it; use `try-with-resources` or `Cleaner`. -### Connection pools are Little's law, with a hard ceiling - -```java -// WRONG — thread and connection counts both unbounded: 500 concurrent requests -// → 500 JDBC connections → the DB hits max_connections and everyone times out -``` - -``` -connections = TPS × average query time -1,000 req/s × 20 ms avg query = 20 connections -then cap it — HikariCP's default is 10; "cores × 10" is a fine starting heuristic -``` +**Q6. How does exception handling work — checked vs unchecked?** +Checked exceptions (`Exception` minus `RuntimeException`) must be caught or declared; they model recoverable conditions. Unchecked (`RuntimeException`, `Error`) need not be declared. Overusing checked exceptions pollutes every signature; modern code prefers unchecked for programming errors and reserves checked for genuinely external failures. -Under-sizing queues requests (the same "DB is fine, the pool is empty" shape as section 4); over-sizing adds DB-side context switches and waits. When the thread pool _and_ the connection pool both queue, you get the report that says "the DB averages 0.1 ms but the app takes 800 ms." +## Mid — tradeoffs & pitfalls -### Index B-tree height — why a point lookup is cheap and a scan is not +**Q1. How does the generational garbage collector work, and what breaks in production?** +The heap is split into **young** (Eden + two Survivor spaces) and **old** generations. Most objects die young: a minor GC copies survivors Eden→Survivor, then Survivor→old once they age out. A **major/full GC** collects the old generation and can pause every application thread for seconds on a large heap. The classic production failure: a cache that grows unbounded fills the old gen → frequent full GCs → **stop-the-world pauses of 1–5 s** → p99 latency blows up. Fix: bound the cache, tune `-Xmx`, or move to a low-pause collector. -An index is a B+tree: 8–16 KB pages, a few hundred keys per page (~500–1,000 if each entry is ~16 bytes). At a billion rows the tree is only **3–4 levels tall**, so a point lookup is 3–4 page fetches — and the top levels live in the buffer pool, so those fetches are ~100 ns memory reads, not disk. That's the numeric answer to "why is an indexed lookup fast." +**Q2. G1 vs ZGC vs Shenandoah — when do you pick which?** -The trap is asking for an indexed column in a way that isn't a range: - -```sql --- WRONG: function on the column hides it from the B-tree → full scan -SELECT * FROM orders WHERE YEAR(created_at) = 2026; - --- RIGHT: range predicate on the raw column → B-tree range scan -SELECT * FROM orders -WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'; -``` - -Same family: `LIKE '%needle%'` (leading wildcard = scan), arithmetic on the column, and `IS NOT NULL` on a mostly-null column. Interviewers watch for whether you say "it depends on the selectivity" versus a blanket "indexes make everything fast." - -### N+1 — the query you didn't notice you wrote - -```java -// WRONG — one query for the orders, then one more per order = N+1 round trips. -// With 1,000 orders that's 1,001 queries × ~1 ms network+parse each → ~1 s of -// latency that never shows up in any single slow-query log. -for (Order order : orders) { - count += itemRepo.findByOrderId(order.getId()).size(); -} - -// RIGHT — one round trip for all of them; batching (e.g. Hibernate @BatchSize) -// is the middle ground when the IN-list would get absurd. -var ids = orders.stream().map(Order::getId).toList(); -long count = itemRepo.findByOrderIdIn(ids).size(); -``` - -```sql --- same shape in SQL -SELECT * FROM item WHERE order_id IN (1001, 1002, /* ... */); -``` - -The senior tell isn't just knowing N+1 exists — it's knowing _where it hides_: the lazy-loaded `@ManyToOne` serialized into a DTO, per-row JSON enrichment, a `findById` inside a `map()`. And the fix often moves the problem: batching helps, but the real answer is often "fetch the DTO you actually need with a JOIN, not the entities." - -## 6. JVM internals interviewers love - -- **Class loading & the three loaders.** bootstrap (null parent, `java.*`), platform (JDK 9+; replaced the extension loader), application (classpath). **Parent-delegation** — a classloader first asks its parent — is a security and consistency mechanism: you can't smuggle in a fake `java.lang.String`. A senior can narrate the failure modes cold: `ClassNotFoundException` is thrown by explicit `Class.forName`/`loadClass` when the class **isn't found**; `NoClassDefFoundError` is thrown at **link/use time** when the class _was_ there during compilation but is missing or failed to initialize at runtime — typically a missing dependency JAR or a static-initializer exception that aborted loading. Know the difference cold. -- **The classloader leak that OOMs your Metaspace.** Every app redeploy (Tomcat, Spring Boot dev-mode reload, dynamic proxying/bytecode gen) creates a classloader; if anything roots the old loader — a static field, a JDBC driver registered in `DriverManager`, a cached proxy — its metadata never unloads, and **Metaspace** (class metadata, unbounded by default) climbs until native memory dies. `jcmd VM.native_memory` plus `-XX:MaxMetaspaceSize` is the arsenal. The number people underestimate: a leak that grows a few MB per reload looks harmless until it's 2 GB after a hundred deploys. -- **The JIT is why warm code is fast.** Tiered compilation: C1 (client, fast warmup) then C2 (server, aggressive optimizations: inlining, escape analysis, loop unrolling), with **OSR** (on-stack replacement) to swap in optimized code mid-loop and **deoptimization** when an assumption breaks. "Our first request after deploy is slow" is JIT warmup, and profiling shows it as compilation events — not "we need a bigger box." `-Xlog:jit+compilation=debug` shows the recompilation cascade. The production gotcha: a hot method that still isn't fast after traffic because the call site is **megamorphic** (too many receiver types to inline), or because it recompiles constantly past `-XX:CompileThreshold`. -- **`String`, caches, and `==`.** `String` is immutable by contract and by layout (private `byte[]`), enabling the constant pool and safe sharing; the **string pool moved from perm gen to the heap in JDK 7**. `Integer.valueOf` caches **-128..127** (stretchable with `-XX:AutoBoxCacheMax`); `Long` caches the same range. So `==` on wrappers "works" in the cache range and bites outside it — the worst possible kind of bug because it passes tests and dies in production at 128. - -```java -Integer a = 127, b = 127; // cached → a == b is true -Integer c = 128, d = 128; // new objects → c == d is false -``` +- **G1** (default since Java 9): region-based, targets a pause-time goal (e.g. `-XX:MaxGCPauseMillis=200`). Good default up to ~十几 GB heaps. +- **ZGC** (production since Java 15): concurrent, sub-millisecond pauses even at **multi-terabyte** heaps, but higher CPU/throughput overhead. +- **Shenandoah**: similar concurrent goal, also sub-ms pauses. + Pick G1 unless pauses dominate latency SLAs, then ZGC. One number to remember: G1 pause ~tens-to-hundreds of ms on big heaps; ZGC ~<1 ms regardless of heap size. -- **Records** are the interview-friendly current answer: they're classes whose `equals`/`hashCode`/`toString`/accessors are derived from the component list, they're `final` by construction, and their serialization is defined over components. State the tradeoff honestly: they're a value-semantics _default_, not a value object — if equality is by business key not all fields, you still hand-write `equals`. -- **Stack depth is a real resource.** Default `-Xss` is 512 KB–1 MB; deep recursion — a recursive JSON walker, a naive tree traversal — throws `StackOverflowError` when the frames exceed it, and it's a native-side failure, not a heap one. Ask "what's the stack size on this box?" before proposing recursion-heavy processing. +**Q3. What is the Java Memory Model and why does `volatile` matter?** +The JMM defines _happens-before_: a write to a `volatile` field happens-before any later read of it, giving visibility across threads. Without `volatile`, a thread may read a stale cached value and never see another thread's update. But `volatile` is **not atomic for compound actions** — `volatile int n; n++` is still a race (read-modify-write). Use `AtomicInteger` for that. -## 7. Runtime & tooling — prove you've debugged production +**Q4. `synchronized` vs `ReentrantLock` — what would you reach for?** +`synchronized` is simple, JVM-optimized (lock elision, biased locking historically), and automatically released. `ReentrantLock` adds: try-lock with timeout (`tryLock(100, ms)` avoids deadlock hangs), fairness option, and multiple condition variables. Reach for `ReentrantLock` only when you need a timeout or interruptible acquisition; otherwise `synchronized` is cleaner. -A senior says: "when it's slow in prod, I don't guess — I measure." The interviewer can't verify your tool knowledge from a definition; they _can_ hear a real incident. Have one in your pocket with this shape: symptom → hypothesis → tool → finding → fix. +**Q5. What are the dangers of creating threads manually?** +`new Thread(() -> ...).start()` per task exhausts OS threads and offers no queueing, monitoring, or backpressure. The fix is a **thread pool** via `Executors` or, better, `new ThreadPoolExecutor(core, max, keepAlive, queue, factory, rejectionPolicy)`. A common bug: `Executors.newFixedThreadPool` uses an **unbounded `LinkedBlockingQueue`** — if tasks outpace consumers, the queue grows until **OutOfMemoryError**. Bound it. -The toolkit, with what each is actually for: +**Q6. What does `ConcurrentModificationException` mean and how do you avoid it?** +It fires when a collection is structurally modified while iterated (except via the iterator's own `remove`). Fixes: iterate with `Iterator.remove()`, use a concurrent collection (`CopyOnWriteArrayList`, `ConcurrentHashMap`), or collect-to-remove then `removeAll`. `CopyOnWriteArrayList` is great for read-heavy, rarely-written lists (snapshot-on-write). -- **`jstack`** — thread dumps. Find `BLOCKED` threads piled on one monitor, deadlocks (JVM prints a deadlock section itself), or a `RUNNABLE` thread stuck in a socket read. Take **three dumps a few seconds apart** — a single dump is a blurry photo. -- **`jmap -histo`** — object histogram; find the class holding hundreds of MB (`byte[]`, `char[]` tops the list suspiciously often) and trace who roots it. Use the non-live variant in prod — `-histo:live` forces a full GC (section 1). -- **`jstat -gcutil`** — GC frequency and pause trend _over time_, which is how you spot an allocation storm or a ballooning old gen before the OOM. -- **`jcmd`** — the Swiss Army knife: `jcmd GC.heap_dump`, `VM.native_memory`, `Thread.print`, `VM.flags`. `jmap` is for dumps; `jcmd` for live introspection. -- **JFR (Java Flight Recorder)** — JDK 11+ includes it free. Events for GC phase pauses (`jdk.GCPhasePause`), allocation (`jdk.ObjectAllocationInNewTLAB`), lock contention (`jdk.JavaMonitorEnter`), virtual-thread pinning (`jdk.VirtualThreadPinned`), method sampling, socket reads. Start with `jcmd JFR.start name=profile settings=profile`, then `jfr view` the file. Overhead at default settings is well under 1% — say that, it's the killer feature. -- **async-profiler** — on-CPU + off-CPU wall-clock + allocation + lock profiling with flamegraphs, no JVMTI agent in the hot path. This is the one that finds _why_ the CPU is high, not just _that_ it is. -- **GC logs.** `-Xlog:gc*` (JDK 9+ unified logging) with `-Xlog:gc:file=gc.log:time,uptime`. Read the pauses _and_ the heap-after-GC; a service whose heap after GC keeps climbing is leaking, a service whose pauses are long but heap is flat is a sizing problem. +## Senior — design & defense -Sample narrative: "P99 latency doubled after the release. `jstat -gcutil` showed FGC jumping every 2 minutes; `jmap -histo` showed 800 MB of `byte[]`; JFR allocation events pointed at the new gzip code; the fix was streaming + pooling the buffers. P99 back to 40 ms." **That paragraph, in an interview, is worth more than any definition you can recite.** +**Q1. A service shows 3 s pauses every few minutes under load. Walk the diagnosis.** +"First I confirm it is GC, not network: `-Xlog:gc*:time` shows full GCs aligned with the pauses. The heap graph climbs then drops — a leak or unbounded cache. I take a heap dump at the trough after a full GC (`jmap -dump` or `-XX:+HeapDumpOnOutOfMemoryError`) and open it in Eclipse MAT, sorting by retained size. Usually it is a static `Map` or a thread-local that never clears. Fix: cap the structure (Caffeine with `maximumSize` + `expireAfterWrite`), or move the data out of the JVM. Then switch G1 → ZGC if latency still bites. I measure p99 before/after; target <200 ms." -## 8. Self-check +**Q2. You must share a counter across 64 threads at 1M ops/s. Design it.** +"Naive `AtomicLong.incrementAndGet()` serializes on one cache line — false sharing and ~tens of M ops/s ceiling. Options: `LongAdder` (JDK 8+) shards the counter across cells, trading exact reads for throughput — easily 5–10× higher. At 1M ops/s `LongAdder` is the right call; reads are `sum()` (approximate but fine for metrics). I'd also pin it to a metrics path, not a correctness-critical counter, and document that." -- [ ] Explain TLAB allocation, and why escape analysis makes `new` sometimes cost nothing. -- [ ] State the compressed-oops 32 GB threshold and why a 40 GB heap can be slower than a 28 GB one. -- [ ] Produce the pause math: what controls a young-gen STW pause, and how G1 trades frequency vs duration. -- [ ] Name concurrent-mode failure, evacuation failure, and the humongous-object rule for G1. -- [ ] SoftReference vs WeakReference vs PhantomReference — when is each correct? -- [ ] Name the happens-before edges for `volatile`, monitor unlock→lock, `start()`/`join()`. -- [ ] Write double-checked locking correctly and explain why the non-volatile version is broken. -- [ ] Explain why `LongAdder` beats `AtomicLong` under contention, and when it's the wrong tool. -- [ ] What is false sharing, and what does `@Contended` do? -- [ ] What does `StampedLock`'s optimistic read do, and when does it thrash? -- [ ] What happens to `thenApplyAsync` on the `commonPool` when a task blocks, and the fix? -- [ ] Size a thread pool with Little's law, and pick a saturation policy that backpressures. -- [ ] When do virtual threads help, when not, and what still pins a carrier after JDK 24? -- [ ] `ClassNotFoundException` vs `NoClassDefFoundError`, and what leaks Metaspace on redeploy. -- [ ] Explain B-tree height and why `WHERE YEAR(col) = ?` scans while a range predicate doesn't. -- [ ] One GC/perf incident you actually found with a profiling tool. +**Q3. Explain false sharing and how to prove it cost you performance.** +"Two frequently-written `long` fields on the same 64-byte cache line get invalidated across cores even when logically independent. Symptom: scaling gets _worse_ with more threads. Proof: annotate padding (`@Contended`, or manual 64-byte padding) — if throughput jumps, you had false sharing. `LongAdder` bakes this in. In one service, adding `@Contended` to a hot counter field took a hot loop from 40M to 220M ops/s." -If those feel easy, you're ready on Java core. +**Q4. When would you NOT use a thread pool, and what do you reach for instead?** +"For blocking I/O at scale — a pool of N threads caps your concurrency at N and they all stall on sockets. Virtual threads (Java 21+, `Executors.newVirtualThreadPerTaskExecutor()`) let you spawn millions cheaply; each blocking call parks instead of pinning an OS thread. Rule: use virtual threads for I/O-bound task-per-request code; keep platform-thread pools for CPU-bound work where you want a hard concurrency cap." -## 9. Interviewer follow-ups +**Q5. A `HashMap` is used by many threads, occasionally returning null for a key that was put. Why, and the fix?** +"It is not thread-safe — concurrent puts can corrupt the bucket structure or a resize mid-put loses entries (and in old Java, could loop forever). Fix: `ConcurrentHashMap` for concurrent access. But note `ConcurrentHashMap.computeIfAbsent` is atomic per-key; `get-then-put` is not. If you need a compound atomic operation, use `compute`/`merge`, not a hand-rolled check-then-act." -When your first answer lands, they start drilling. Be ready for these: +**Q6. How do you defend a choice between G1 and ZGC with numbers?** +"I'd baseline p99 latency and GC pause percent under production-like load (e.g. 500 rps, 30 GB heap). If G1 pause is ~150 ms and SLA is p99 < 250 ms with headroom, G1 wins on throughput (ZGC costs ~10–15% CPU). If pauses eat the SLA, ZGC's <1 ms pauses justify the CPU tax. I never pick on vibes — I run both in staging with the same load and read the GC logs. The decision is a tradeoff table, signed with measurements." -- "Your service does 2,000 req/s and each request blocks ~50 ms in JDBC. Size the thread pool. Now what if 10% of calls take 5 seconds?" -- "Same service: how many DB connections do you give it, and what happens to the virtual threads when the pool is 10?" -- "You claim G1 pause is a soft goal. Walk me through a `gc` log line that proves G1 missed `MaxGCPauseMillis`, and what you'd change." -- "`volatile` gives happens-before. Does that make a `volatile int` safe as a counter? What if it's a `volatile long` on a 32-bit JVM?" -- "I wrote `synchronized` code and it's slower than the `ConcurrentHashMap` version. Is `synchronized` broken?" -- "The `ThreadLocal` in your thread pool is holding 200 MB. What's actually rooting it, and what's the fix — and the future replacement?" -- "Explain why JDK 24 changed pinning behavior, and what still pins a virtual thread." -- "`SELECT * FROM orders WHERE YEAR(created_at) = 2026` is slow, and the column is indexed. Why, and what do you rewrite it to?" -- "You see `OutOfMemoryError: Metaspace` after a redeploy with no class added. What's your first command, and what do you look for?" -- "A hot method is still slow after 10 minutes of traffic. What's the JIT possibly doing, and how do you prove it with a log?" +#### Self-check -That's the Java-core bar. +- [ ] Junior: I can name the JVM memory areas, explain `==` vs `equals`, primitives vs wrappers, and checked vs unchecked exceptions. +- [ ] Mid: I can describe generational GC, choose G1 vs ZGC, explain `volatile`/JMM, and avoid unbounded thread-pool queues. +- [ ] Senior: I can diagnose a GC pause from logs + heap dump, design a 1M-ops/s counter, explain false sharing, and defend a collector choice with before/after numbers. diff --git a/src/data/blog/vi/interview/java-core-senior.md b/src/data/blog/vi/interview/java-core-senior.md index 7f14f1b..1e37248 100644 --- a/src/data/blog/vi/interview/java-core-senior.md +++ b/src/data/blog/vi/interview/java-core-senior.md @@ -1,6 +1,6 @@ --- -title: "Phỏng vấn Senior Java: Java Core sâu" -description: "Phỏng vấn viên senior thực sự kiểm tra gì ở Java core — GC và JMM, bẫy concurrency, virtual threads, và tooling runtime chứng tỏ bạn từng debug production." +title: "Ôn thi Java #1: Java Core (JVM, GC, Concurrency) — Junior đến Senior" +description: "Xương sống của mọi buổi phỏng vấn Java — bộ nhớ JVM, thu gom rác, JMM và concurrency. Junior thuộc tên; senior chứng minh từng đứng nhìn heap dump lúc 3 giờ sáng." pubDatetime: 2026-08-10T10:00:00+07:00 featured: true draft: false @@ -11,442 +11,83 @@ tags: - concurrency --- -Junior biết cú pháp Java. Senior biết **JVM đang làm gì, tại sao nó hành xử vậy, và ở đâu nó sẽ làm bạn bất ngờ trên production.** Đây là phần Java core của bộ ôn thi senior. +Java core là bộ lọc loại nhiều ứng viên hơn cả system design. Đây là nơi một junior có thể học thuộc từ khóa, còn một senior có thể chứng minh mình từng nhìn vào heap dump lúc 3 giờ sáng. Bài này đi cùng một chủ đề, từ "heap là gì" đến "tôi đã giảm một nửa GC pause trên service 40 GB như thế nào" — hãy chọn mức bạn đang phỏng vấn, và đọc thêm một mức ở trên. -> Tư duy: "tùy thuộc, và đây là đánh đổi" đánh bại đọc thuộc lòng mọi lúc. Khoảnh khắc bạn trả lời bằng một trade-off, một con số, hay một câu chuyện postmortem thay vì một định nghĩa — bạn đã qua vạch. +> Mindset: junior gọi được tên các garbage collector; senior kể được collector nào đã làm service của họ dừng lại quý trước, lâu bao nhiêu, và họ đổi gì. -## 1. Heap, GC, và bài toán pause +## Junior — nền tảng -Họ sẽ hỏi: "Chuyện gì xảy ra khi bạn `new` một object?" Một ứng viên mid dừng ở "nó nằm trên heap." Senior nói về **ở đâu**, **nhanh bao nhiêu**, và **pause đáng giá gì** — vì đó mới là thứ cắn trên production. Mọi câu trong phần này đều có một đáp án bằng số; phỏng vấn viên lắng nghe con số, không phải danh từ. +**Q1. Các vùng bộ nhớ chính của JVM là gì?** +JVM chia bộ nhớ thành: **heap** (mọi instance object, chia sẻ, do GC quản lý), **metaspace** (metadata của class, trước kia là permgen), **stack** riêng cho mỗi thread (frame, biến local, operand), **PC register** riêng cho mỗi thread, và **native method stack**. Mọi thứ bạn `new` nằm trên heap; mỗi lời gọi hàm đẩy một frame lên stack của thread. -### Allocation không phải một lệnh gọi malloc — nó là một cú bump con trỏ - -Mỗi thread cắt một **TLAB (Thread-Local Allocation Buffer)** từ Eden — từ vài trăm KB tới vài MB không gian riêng nóng trong cache — nên allocation chỉ là bump một con trỏ. Không khóa toàn cục, không CAS. Vì thế `new` rẻ tới mức một JVM thường cấp phát hàng chục triệu object dùng một lần mỗi giây mà không vấn đề gì. - -```java -String tmp = prefix + id; // trông như phí phạm; một cú bump TLAB khiến nó gần như miễn phí -``` - -Chỗ senior đi sâu hơn: **escape analysis**. JIT (C2) có thể chứng minh một object không bao giờ rời khỏi method và **scalar-replace** nó — các field thành thanh ghi JIT và slot stack, và cái allocation cứ thế biến mất. Đây không phải "stack allocation" theo nghĩa đen; mà là "không có object". Chạy `-XX:+PrintEliminateAllocations` và bạn sẽ thấy JIT vứt bỏ allocation trước mắt. Những object thực sự escape — truyền sang thread khác, return, gán vào field — mới là thứ rơi vào Eden và bị promote. - -### Object layout và ngưỡng compressed oops - -Mọi object mang một phần header: 8 byte mark word (identity hash, trạng thái khóa, GC age) cộng 4 byte con trỏ class **khi compressed oops bật** — mặc định cho heap dưới **~32 GB**. Một `Object` rỗng là 16 byte; một `Long` trần là 24. Trên một service cấp phát 100M object mỗi lần fan-out, đó là một gigabyte tiền thuế header thuần — lý do tái thiết kế theo value (records với primitive, collection kiểu primitive) là một nước đi senior thật, không phải chuyện vặt. - -Ngưỡng này quan trọng: vượt 32 GB heap, JVM không còn địa chỉ hóa object bằng con trỏ hẹp 32 bit, nên nó hoặc **tắt compressed oops** (mọi header phình ra), hoặc bạn nâng `-XX:ObjectAlignmentInBytes` (mặc định 8, nên nâng lên 16 thì padding gấp đôi). Cả hai đều đội bộ nhớ mỗi object. Đó là một lý do heap 40 GB có thể chạy tệ hơn heap 28 GB — "chúng tôi tăng size mà lại chậm" thường là chuyện này, hoặc một đổi thay pattern GC. Nếu phỏng vấn viên hỏi về heap sizing, hãy nêu con số 32 GB trước khi họ kịp nói. - -### Giả thuyết generational, và các con số giải thích nó - -"Đa số object chết trẻ" không phải khẩu hiệu — nó là một phân bố đo được: trên workload service điển hình **~90% object thành garbage chỉ sau vài chu kỳ GC**. Đó là lý do heap bị chia: - -- **Eden** — đa số object cấp phát và chết tại đây; phần lớn không bao giờ chạm survivor space. -- **Survivor spaces (S0/S1)** — object sống sót qua minor GC được copy qua lại; cố ý làm nhỏ. -- **Old gen** — object sống sót qua `-XX:MaxTenuringThreshold` lần copy (mặc định 15, G1 tự điều chỉnh động). - -Tỷ lệ quan trọng hơn tên gọi: nếu ~90% object chết trong Eden, một lần young-gen GC chỉ copy ~10% còn sống, nên pause bị chi phối bởi **số live byte được copy**, không phải tổng allocation. - -### Phép tính pause mà phỏng vấn viên câu - -Một pause stop-the-world về cơ bản là - -``` -pause ≈ live_bytes_copied / copy_throughput -``` - -nên đòn bẩy đầu tiên luôn là **kích thước young gen**, không phải lựa chọn collector: - -``` -Ví dụ: young gen 2 GB, 70% survivor set sống sót qua minor GC → ~1.4 GB được copy. -Với ~10 GB/s copy throughput đó là ~140 ms STW, mỗi lần minor GC. -Thu nhỏ young gen còn 512 MB → ~36 ms. Nhỏ hơn nữa → GC thường xuyên hơn. -``` - -Căng thẳng này có thật: young gen to hơn → ít pause hơn nhưng dài hơn; nhỏ hơn → pause ngắn hơn nhưng nhiều lần hơn. Con số thứ hai nên bỏ túi là **GC overhead**: - -``` -GC overhead = time_in_GC / wall_time -200 ms GC mỗi phút → ~0,33% thuế throughput. -``` - -G1 tấn công pause bằng cách thu gom **tăng dần** theo vùng hướng tới `-XX:MaxGCPauseMillis` (mặc định 200 ms) — nhưng đó là một **mục tiêu mềm**. Nếu survivor set thực sự không copy kịp trong thời gian đó, G1 âm thầm kéo dài pause. Những failure mode quan trọng hơn mục tiêu: - -- **Concurrent-mode failure** — old gen đầy nhanh hơn chu kỳ marking đồng thời có thể thu hồi, và G1 ngã về **Full GC STW toàn bộ**. Trên heap 50 GB đó là vài giây mọi luồng dừng — cái "biểu đồ latency biến thành vách đá" kinh điển trong postmortem. -- **Evacuation failure / promotion failure** — to-space cạn giữa lúc copy (thường do survivor spike đột ngột), object bị giữ nguyên chỗ, và các GC sau phải trả giá. -- **Humongous allocations** — region G1 rộng 1–32 MB; thứ gì lớn hơn nửa region là object **humongous**, đi thẳng vào old gen, không copy đi bằng cách thường, và có thể kích hoạt full GC. Một `byte[]` 4 MB trong heap region 2 MB là humongous. Buffer được pool, không phải mảng byte mỗi request, là cách sửa của senior. - -### Chọn collector với một con số trong tay - -``` -Parallel GC → throughput tối đa, STW ở mọi major GC. Đúng khi pause không sao - (batch job, offline). Thường từ vài trăm ms tới vài giây ở quy mô lớn. -G1 (mặc định) → cân bằng; region-based, mixed GCs. Mặc định tốt cho service heap - tới ~100 GB. Pause 10s–200 ms tùy heap. -ZGC → pause dưới ms kể cả heap nhiều TB, nhờ colored pointers + - load barriers làm phần lớn việc đồng thời. Đánh thuế throughput CPU. -Shenandoah → cùng mục tiêu, mẹo khác (concurrent evacuation, forwarding - pointers). Pause ~milisecond, nặng bộ nhớ. -``` - -(CMS từng là đáp án latency thời xưa và đã **bị gỡ khỏi JDK 14** — nói điều đó nếu ai đó lạc sang.) Senior chọn bằng con số: "chúng tôi chạy heap 50 GB, pause phân vị 99 phải dưới 50 ms, và thừa CPU, nên ZGC — và đây là cái giá, ZGC đánh đổi ~5–10% throughput CPU lấy độ trễ đó." Và **không bao giờ dùng `System.gc()` như một cách chữa** — dưới Parallel nó là một cú pause STW toàn bộ mọi luồng trong nhiều giây; dưới G1 nó thậm chí có thể không kích hoạt thứ bạn nghĩ. - -### Reference types — Soft, Weak, Phantom - -Phỏng vấn viên GC mê reference types vì lạm dụng trên production quá phổ biến: - -- **`SoftReference`** — được giữ sống tới khi JVM thấy bộ nhớ căng; sống qua GC thường, bị thu khi áp lực. Đúng cho "cache tự co lại khi máy nóng", nhưng phụ thuộc JVM và hiếm khi là một ngân sách bộ nhớ chính xác. -- **`WeakReference`** — bị thu ở GC kế tiếp, không chờ đợi. Đúng cho identity map key bởi object chóng tàn (một `WeakHashMap` key bởi request context). -- **`PhantomReference`** — referent đã unreachable khi reference được đưa vào queue, nên bạn có thể giải phóng native resource an toàn tại đó; bạn **phải** gọi `clear()` nếu không nó không bao giờ được thu. Đây là sự thay thế hiện đại cho đường `finalize()` đã bị deprecated (JEP 421, deprecated từ JDK 18) — kết hợp với `ReferenceQueue`/`Cleaner`. - -```java -// SAI — finalize để dọn native: không dự đoán được, có thể bị resurrect, deprecated -@Override protected void finalize() { nativeFree(handle); } - -// ĐÚNG — PhantomReference + ReferenceQueue: dọn dẹp chạy trên thread drainer -// của bạn chỉ khi object đã chứng minh là unreachable, không bao giờ trên GC thread -ReferenceQueue queue = new ReferenceQueue<>(); -PhantomReference ref = new PhantomReference<>(resource, queue); -// thread drainer: poll queue; với mỗi ref → nativeFree(handle) và ref.clear() -``` - -### Failure mode trên production - -- **GC không nghĩa là bạn hết việc quản lý bộ nhớ.** Cache không giới hạn, static collection, và thread-local reference vẫn khiến bạn OOM — GC không thu được cái code bạn giữ rễ (rooted). - -```java -// SAI — một "cache" thực chất là một root đang lớn dần -private static final Map CACHE = new HashMap<>(); - -// ĐÚNG — eviction theo size + thời gian; Caffeine là một ConcurrentHashMap -// với admission window W-TinyLFU, nên đây không phải "một timer + một map". -private static final Cache CACHE = Caffeine.newBuilder() - .maximumSize(10_000) - .expireAfterWrite(Duration.ofMinutes(10)) - .build(); -``` - -- **`ThreadLocal` rò rỉ trong thread pool.** Tinh vi hơn người ta nghĩ: **key** trong `ThreadLocalMap` là một `WeakReference`, nên key có thể bị thu — nhưng **value bị tham chiếu mạnh** và sống trong map entry cho tới khi slot đó bị expunge. Trong một pooled thread sống lâu không bao giờ chạm lại slot đó, value rò rỉ mãi. Một request-scoped context giữ blob 10 MB, đặt trên pool 200 thread → 2 GB "heap đầy không lý do". Cách sửa: `ThreadLocal.remove()` trong `finally`, hoặc scoped values (phần 4). -- **Allocation storm trong vòng lặp chặt** làm tăng tần suất GC, không phải thời gian pause. `jstat -gcutil` cho thấy FGC/FGCT leo lên trong khi heap không bao giờ sạch. -- **String interning bùng nổ.** `String.intern()` trên mọi response header mỗi request sẽ làm phình string table và old gen. `-XX:+PrintStringTableStatistics` sẽ cho bạn thấy. -- **`-histo:live` không miễn phí.** `jmap -histo:live` (và `jcmd GC.class_histogram -live`) kích hoạt một full GC — chạy nó chống lại heap production 50 GB lúc cao điểm là một incident tự gây. Ưu tiên `-histo` hoặc JFR. - -## 2. JMM và visibility — happens-before, fences, và cái giá của một barrier - -"Volatile làm write hiển thị" là câu trả lời mid. Câu trả lời senior là **happens-before edge** — hợp đồng thực sự mà JMM bảo đảm — cộng với cái giá phần cứng tính cho bạn. Câu hỏi "tại sao flag của tôi không hiển thị?" được trả lời bằng luật program-order + synchronizes-with, không bao giờ bằng "máy tôi nó cứ không chạy." - -Những happens-before edge bạn thực sự có thể dựa vào: - -- `volatile` write → `volatile` read sau đó trên cùng field. -- Mở khóa một monitor → khóa lại cùng monitor sau đó (nên `synchronized` cho visibility, không chỉ exclusion). -- `Thread.start()` → mọi thứ thread được start làm. -- Mọi thứ một thread làm → cái thread join thấy sau `join()`. -- Ghi một field `final` trong constructor → các read sau safe publication. -- `Atomic*` write → các read sau (CAS tạo thành một full fence). - -```java -// SAI — classic vòng lặp vô hạn. stop có thể ở mãi trong register/cache của thread T; -// compiler còn có thể hoist read ra khỏi vòng lặp. -boolean stop = false; // không volatile -while (!stop) { doWork(); } - -// ĐÚNG — volatile write trên T1 happens-before volatile read trên T2 -volatile boolean stop = false; -while (!stop) { doWork(); } -``` - -### Cái bẫy double-checked locking họ luôn đào - -Câu JMM kinh điển. Vấn đề không phải khóa — mà là read không đồng bộ có thể quan sát thấy một reference tới một object **chưa được xây xong**: việc lưu reference được phép trôi nổi lên trước khi các write trong constructor kết thúc (không có happens-before xuyên thread), nên thread B thấy `instance != null` và trả về một singleton xây dở. - -```java -// SAI — DCL không volatile. Cả hai thread có thể quan sát một instance xây dở. -private static Singleton instance; -public static Singleton get() { - if (instance == null) { // read không đồng bộ - synchronized (Singleton.class) { - if (instance == null) { - instance = new Singleton(); - } - } - } - return instance; -} - -// ĐÚNG — volatile tạo happens-before edge constructor-write → read -private static volatile Singleton instance; -``` - -Trên JVM 64-bit, đọc `volatile long` là một load nguyên tử, nhưng trên **JVM 32-bit nó là hai nửa 32-bit** — nên `volatile long` chính xác là trường hợp "volatile" và "atomic" rẽ đôi. Trivia nhỏ phân loại người đọc JMM với người sống qua nó. - -### Một fence giá bao nhiêu - -`volatile` biên dịch xuống một memory barrier — trên x86 một lệnh có tiền tố `lock` hoặc một `mfence` cho trường hợp store-load. Nó không rẻ: một volatile write có fence chạy trong **hàng chục nanosecond**, so với ~1 ns cho một cache-local read. Cái thang latency là mô hình tinh thần phỏng vấn viên muốn nghe: - -``` -L1 cache hit: ~1 ns -L2: ~4 ns -L3: ~10–15 ns -main memory: ~100 ns -fenced volatile write: ~20–80 ns (store-load barrier) -NVMe random read: ~20–50 µs -same-DC network round trip: ~100–500 µs -``` - -Cái thang đó là lý do "cứ volatile hết đi" là một bug latency thật trong hot loop, và lý do false sharing — cái bẫy kế tiếp — đau đến vậy. - -### volatile ≠ atomicity, và đáp án "chọn cái nào" - -`volatile` cho visibility và ordering, **không** atomicity. `i++` là read-modify-write; hai thread có thể cùng đọc 41 và cùng ghi 42. Công cụ đúng phụ thuộc hình dạng của contention: - -- **`AtomicLong`** — một CAS duy nhất trên một cache line. Nhanh tới khi các thread va nhau, rồi chúng spin-retry và cái line bật qua lại giữa các core. -- **`LongAdder`** — chia bộ đếm ra một tập các cell, một cell cho mỗi core bị tranh, và cộng lại ở `sum()`. Dưới contention nặng (nói ≥ 16 thread đập một bộ đếm) nó chạy **nhanh hơn nhiều lần** `AtomicLong` vì CAS retry biến mất. Trade-off: `sum()` là O(cells) và xấp xỉ dưới concurrent write — ổn cho metrics, sai cho một sổ nợ debit cần chính xác. - -### False sharing — cái bẫy không phải khóa - -Một cache line là 64 byte. Hai field **độc lập** nhưng nằm chung một line sẽ bị ping-pong coherence mỗi lần một trong hai bị ghi, kể cả trong code lock-free. Cái `long[]` bộ đếm mỗi thread là kinh điển: thread 0 sở hữu index 0, thread 1 sở hữu index 1 — liền kề trong bộ nhớ — và chúng giẫm lên nhau với chi phí gấp 10–100× dự kiến. `@Contended` (JEP 142) pad các field lên các line riêng, hoặc bạn chia slot mỗi thread theo độ rộng line. Khi phỏng vấn viên nói "bộ đếm lock-free của bạn còn chậm hơn cái `synchronized`", đây là điều họ đang thăm dò. - -``` -Bản báo cáo "tại sao counter của tôi 2% CPU mà chậm gấp 40×" là false sharing — -một coherence miss tốn ~100 ns traffic bộ nhớ mỗi lần ping, ở tần suất cao. -``` - -## 3. Concurrency primitives — phía dưới cái khóa có gì - -### `synchronized` không phải một khóa, nó là một state machine - -Một monitor khởi đầu **mỏng** — bit trong mark word của object, không có OS mutex. Dưới contention nó **inflate** thành monitor hạng nặng với wait queue và wait set cấp OS, và JVM áp dụng **adaptive spinning** trước khi park thread. Biased locking từng làm việc acquire không tranh chấp gần như miễn phí, nhưng nó đã **bị deprecate từ JDK 15 và gỡ khỏi JDK 18** — nói đúng cái mốc đó và bạn đã ra tín hiệu rằng mình theo dõi JEP. Bài học thực tiễn: `synchronized` không tranh chấp gần như miễn phí (một cập nhật mark word, ~hàng chục ns); `synchronized` tranh chấp trả giá một vòng park/unpark đi vào kernel — microsecond, tệ hơn đường không tranh chấp ba bốn bậc. Đó là _lý do_ bạn với tới atomics hoặc striping. - -### `ReentrantLock` và AQS - -`ReentrantLock`, `Semaphore`, `CountDownLatch` đều xây trên **AQS** (`AbstractQueuedSynchronizer`): một `volatile int state` duy nhất cộng một CLH-style wait queue, đột biến qua CAS và `LockSupport.park/unpark`. Khi bạn gọi `tryLock(2, TimeUnit.SECONDS)` bạn đang chạy một vòng CAS có thời hạn + park — núm fairness (`new ReentrantLock(true)`) làm waiter đi FIFO nhưng tốn throughput vì nhiều context switch hơn. Nước đi senior là chọn dựa trên failure mode: - -```java -// SAI — block mãi chờ một khóa có thể không bao giờ tới -lock.lock(); -try { update(); } finally { lock.unlock(); } - -// ĐÚNG — một lease có deadline. Đây là cách tránh incident "thread kẹt, -// heap đầy thread chờ, không ai giữ khóa". -if (lock.tryLock(2, TimeUnit.SECONDS)) { - try { update(); } finally { lock.unlock(); } -} else { - // degrade: trả 503, bỏ qua, log — đừng treo -} -``` - -- **`ReentrantLock`** thêm `tryLock(timeout)`, nhiều `Condition` (await/signal với predicate có tên), và kiểm soát fairness — `synchronized` chỉ có đúng một wait set. -- **`StampedLock`** — cái khóa đa số không gọi tên nổi, chính vì thế nó là một probe tốt. **Optimistic read** của nó không bao giờ block: lấy một stamp, đọc, rồi `validate()` — nếu một writer chen vào, ngã về read lock thật. Tuyệt khi reader chiếm ưu thế và write hiếm; sai khi write dày, vì validate liên tục thất bại và bạn thrash. - -```java -long stamp = lock.tryOptimisticRead(); // không khóa gì cả -int v = shared; -if (!lock.validate(stamp)) { // writer lẻn vào? - stamp = lock.readLock(); - try { v = shared; } finally { lock.unlockRead(stamp); } -} -``` - -- **`ConcurrentHashMap` (Java 8+)** dùng CAS cho bin rỗng và `synchronized` trên đầu bin cho va chạm; bin **treeify ở ≥ 8 entries** thành red-black tree (một bin toàn key hash trùng sẽ thoái hóa thành O(n)). `size()` là tổng của các base counter, nên nó **xấp xỉ** — nói to điều đó ra, nó là "gotcha họ kiểm tra" kinh điển. -- **Cái bẫy deadlock `computeIfAbsent`.** Nó giữ khóa của bin trong lúc mapping function của bạn chạy, nên một `computeIfAbsent` đệ quy trên **cùng key** từ bên trong chính nó làm deadlock bin trong Java 8 (sửa ở JDK 9 bằng một bin-occupancy re-check). Bản production: một cache xây value lười nhác, mà cái value đó lại nạp lười nhác chính cái value kia. Biết nó bằng tên. - -```java -// SAI — deadlock Java 8: mapping function tính lại chính key đó -cache.computeIfAbsent(key, k -> cache.computeIfAbsent(k, x -> build(x))); - -// ĐÚNG — tính một lần bên ngoài, hoặc dùng putIfAbsent semantics bạn kiểm soát -var v = cache.get(key); -if (v == null) { v = build(key); cache.putIfAbsent(key, v); } -``` - -### `CompletableFuture` — cái bẫy pool không ai đọc Javadoc - -`thenApplyAsync` chạy trên **`ForkJoinPool.commonPool()`**, có parallelism là `availableProcessors - 1`. Khoảnh khắc một async task làm việc block — một call JDBC, một `Thread.sleep`, một block `synchronized` — nó ăn cắp một worker, và nếu đủ task block, pool cạn kiệt và **mọi thứ phía sau nghẽn dù máy đang rảnh**. Triệu chứng production: "chúng tôi thay futures bằng một thread pool to hơn và nó tự hết bệnh." Cách sửa của senior là truyền một executor tường minh được size cho công việc block: - -```java -// SAI — JDBC blocking bên trong async code bỏ đói commonPool -CompletableFuture.supplyAsync(() -> accountRepository.findById(id).get()) - .thenApplyAsync(Account::getBalance); - -// ĐÚNG — executor tường minh size theo Little's law (phần 4), hoặc virtual threads -CompletableFuture.supplyAsync(() -> accountRepository.findById(id).get(), jdbcExecutor) - .thenApplyAsync(Account::getBalance, jdbcExecutor); -``` - -Sắc thái xử lý lỗi họ khoan sâu: `handle` thấy cả value lẫn throwable, `exceptionally` chỉ xử lý lỗi, và **một exception trong `thenApply` trả về một future completed exceptionally** — nên hãy quyết bạn muốn compose hay recover. Và nhớ: `thenCompose` (flatMap) vs `thenCombine` (zip) là khác biệt giữa một chuỗi và một fork-join. - -## 4. Threads, thread pools, và virtual threads - -### Size pool bằng Little's law — con số chấm dứt cuộc cãi - -Câu trả lời ngây thơ là "cores × 2". Câu trả lời có thể bảo vệ là Little's law, vì với worker **blocking** thì pool là một băng chuyền: - -``` -pool_size = throughput × average time-in-pool -300 req/s × 80 ms thời gian JDBC+CPU trung bình = 24 workers -``` - -Với công việc **CPU-bound** không có số hạng chờ, nên pool nên đứng quanh số core (+1) — nhiều thread hơn core chỉ xếp hàng và switch. Hình dạng chung là `N = cores × (1 + wait/compute)` — hãy suy ra nó, đừng trích nguyên văn. - -Oversize qua mức đó là hại thật: context-switch thrash, connection idle phía DB, và queue _bên trong_ database. Undersize làm request xếp hàng ở `connectionTimeout` tới khi latency leo rồi throughput sụp — cái incident kinh điển "DB vẫn khỏe, pool thì rỗng." - -```java -// SAI — 200 thread vì máy có 64 core, queue vô hạn -// (queue vô hạn + task blocking = cái OOM "vùng đệm bộ nhớ vô hạn") -ExecutorService pool = new ThreadPoolExecutor( - 0, 200, 60, SECONDS, new LinkedBlockingQueue<>()); // vô hạn! - -// ĐÚNG — Little's law nói ~25; queue có giới hạn; chính sách bão hòa tường minh -ExecutorService pool = new ThreadPoolExecutor( - 25, 25, 0, MILLISECONDS, new ArrayBlockingQueue<>(100), new CallerRunsPolicy()); -``` - -`CallerRunsPolicy` — task bị từ chối chạy trên chính calling thread — là lựa chọn chống-OOM: nó thêm **backpressure** thay vì buffering hay dropping. Biết `AbortPolicy` (mặc định, ném exception), `DiscardPolicy`, và tại sao không cái nào backpressure ngoài `CallerRuns`. Và kiểm lại các mặc định JDK bạn _tưởng_ mình biết: `Executors.newFixedThreadPool` dùng `LinkedBlockingQueue` vô hạn, nên với task blocking nó là một vector OOM, không phải một pool. `SynchronousQueue` (dùng bởi `newCachedThreadPool`) là cực đối diện — một handoff zero-buffer. - -### Platform thread đắt; virtual thread thì không - -Một platform thread mang theo stack mặc định ~1 MB (virtual memory) cộng kernel scheduling; tạo một cái tốn microsecond và context-switch hàng chục ngàn cái đốt thời gian kernel thật. **Virtual threads** (Java 21+, Project Loom) là Java object với stack vài KB, được schedule trên một nắm **carrier threads** (một `ForkJoinPool` với parallelism = số CPU) — OS chỉ bao giờ thấy các carrier. +**Q2. Khác nhau giữa `==` và `equals()`?** +`==` so sánh tham chiếu (có phải cùng một object trong memory không). `equals()` so sánh tính _logic_ bằng nhau, và bạn phải override nó (cùng `hashCode()`) nếu không sẽ thừa kế so sánh tham chiếu từ `Object`. Hai `String` cùng ký tự chỉ `==` khi string pool intern literal — bẫy kinh điển: ```java -try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { - List> futures = urls.stream() - .map(url -> executor.submit(() -> fetch(url))) - .toList(); -} +String a = "java"; +String b = new String("java"); +System.out.println(a == b); // false — hai object khác nhau +System.out.println(a.equals(b)); // true — cùng ký tự ``` -- **Chúng để làm gì:** công việc I/O-bound bị block — call HTTP, round trip DB, RPC. Một triệu call outbound đồng thời trên một thread-per-request platform-thread pool thì chết; trên virtual threads nó là một triệu stack rẻ. -- **Chúng KHÔNG phải:** thứ làm CPU-bound nhanh hơn. Vẫn chỉ có N CPU; một virtual thread CPU-bound chẳng lợi gì. -- **Pinning — cái bẫy:** một virtual thread block trong khi giữ một carrier resource thì pin nó. Trước JDK 24 nghĩa là bất kỳ **block `synchronized` nào**; **JEP 491 (JDK 24) gỡ pinning cho `synchronized`**, nên các nguồn còn lại là **native frame** bị block (JNI / Foreign Function & Memory API), **class loading / class initializers**, và **local file I/O trên Linux**. Khóa kiểu AQS như `ReentrantLock` chưa bao giờ pin — `LockSupport.park` hiểu virtual-thread và unmount thread, đó là hiểu lầm kinh điển. "Cái gì vẫn còn pin?" là tín hiệu theo dõi JEP hiện tại, và công cụ rà là JFR event `jdk.VirtualThreadPinned` (được nâng cấp ở JDK 24 để nói rõ _lý do_). -- **`ThreadLocal` trên virtual threads là một footgun:** mỗi virtual thread có map riêng, nên một `ThreadLocal` request-scoped trên một triệu virtual thread là một triệu entries. Người kế nhiệm là **scoped values** (`ScopedValue`), immutable, chỉ kế thừa trong structured-concurrency scope, và thu hồi rẻ — đó là thứ bạn nên nêu tên thay vì "dùng một ThreadLocal." -- **Virtual threads không gỡ bỏ cái chặn connection pool.** Một triệu virtual thread có thể cùng block trên một HikariCP pool với `maximumPoolSize` mặc định là 10 — bạn chỉ dời cái queue từ thread pool sang connection pool. Size connection bằng Little's law luôn (phần 5). - -### Structured concurrency - -"Triệu thread" kéo theo câu hỏi: làm sao cancel cả nhóm khi một cái thất bại? `StructuredTaskScope` (Java 21+) ràng buộc child task vào vòng đời của parent — `fork` các child, rồi `join` và xử lý shutdown khi lỗi, và khi scope kết thúc nó **tự động cancel mọi child còn đang chạy**. Failure mode mà thứ này diệt: một fan-out request âm thầm để lại 900 trong 1.000 call outbound chạy tiếp sau timeout. Nếu bạn đối chiếu "fire-and-forget futures rò rỉ công việc" với "`StructuredTaskScope` tắt cả fan-out", bạn đã trả lời câu hỏi concurrency-resilience trước khi nó được hỏi. +**Q3. Kiểu nguyên thủy là gì và chúng có phải object không?** +`byte, short, int, long, float, double, char, boolean` — tám kiểu nguyên thủy, không phải object, lưu theo giá trị. Mọi thứ khác là tham chiếu đến object trên heap. Autoboxing (`int` ↔ `Integer`) là đường ngắn che giấu việc cấp phát; `IntegerCache` intern -128..127, nên `Integer.valueOf(42) == Integer.valueOf(42)` là `true` nhưng `Integer.valueOf(200) == Integer.valueOf(200)` là `false`. -## 5. Cái database sống sau các method của bạn +**Q4. Khác nhau giữa `String`, `StringBuilder`, và `StringBuffer`?** +`String` immutable — mỗi phép nối chuỗi cấp phát object mới. `StringBuilder` mutable và không thread-safe (nhanh). `StringBuffer` tương tự nhưng `synchronized` (chậm, hiếm khi cần). Trong vòng lặp, `+=` trên `String` là O(n²) cấp phát; hãy dùng `StringBuilder`. -Một cuộc phỏng vấn backend senior trôi từ JVM sang pools sang SQL, vì các failure mode đều cùng một hình dạng: một tài nguyên có giới hạn — heap, threads, connections, index pages — và một thứ gì đó âm thầm xếp hàng trên nó. Ba cái bẫy xuất hiện liên tục. +**Q5. Khác nhau giữa `final`, `finally`, và `finalize`?** +`final` trên class cấm kế thừa, trên method cấm override, trên biến cấm gán lại. `finally` chạy sau `try`/`catch` bất kể có exception (dùng để dọn dẹp). `finalize()` là hook GC gọi trước khi thu hồi object — đừng bao giờ dựa vào nó; hãy dùng `try-with-resources` hoặc `Cleaner`. -### Connection pools là Little's law, với một trần cứng - -```java -// SAI — số thread và connection đều vô hạn: 500 request đồng thời -// → 500 JDBC connections → DB chạm max_connections và ai cũng timeout -``` - -``` -connections = TPS × average query time -1.000 req/s × 20 ms avg query = 20 connections -rồi chặn nó — mặc định của HikariCP là 10; "cores × 10" là một heuristic khởi điểm tốt -``` +**Q6. Exception handling hoạt động thế nào — checked vs unchecked?** +Checked exception (`Exception` trừ `RuntimeException`) phải được catch hoặc khai báo; mô hình điều kiện có thể phục hồi. Unchecked (`RuntimeException`, `Error`) không cần khai báo. Lạm dụng checked exception làm nhiễu mọi signature; code hiện đại ưu tiên unchecked cho lỗi lập trình và chỉ dành checked cho lỗi thực sự từ bên ngoài. -Undersize làm request xếp hàng (cùng hình dạng "DB vẫn khỏe, pool thì rỗng" như phần 4); oversize thêm context switch và wait phía DB. Khi thread pool _và_ connection pool cùng xếp hàng, bạn gặp bản báo cáo nói "DB trung bình 0,1 ms mà app mất 800 ms." +## Mid — tradeoff & điểm mù -### Index B-tree height — tại sao point lookup rẻ và scan thì không +**Q1. Garbage collector generational hoạt động ra sao, và gì hỏng ở production?** +Heap chia thành **young** (Eden + hai Survivor) và **old**. Hầu hết object chết trẻ: minor GC copy survivor từ Eden→Survivor, rồi Survivor→old khi đủ tuổi. **Major/full GC** thu old gen và có thể dừng mọi thread ứng dụng vài giây trên heap lớn. Lỗi production kinh điển: cache không giới hạn làm đầy old gen → full GC liên tục → **pause stop-the-world 1–5 s** → p99 latency nổ tung. Fix: giới hạn cache, tune `-Xmx`, hoặc chuyển sang collector low-pause. -Một index là một B+tree: page 8–16 KB, vài trăm key mỗi page (~500–1.000 nếu mỗi entry ~16 byte). Ở một tỷ row, cái cây chỉ cao **3–4 tầng**, nên một point lookup là 3–4 page fetch — và các tầng trên sống trong buffer pool, nên các fetch đó là read bộ nhớ ~100 ns, không phải disk. Đó là câu trả lời bằng số cho "vì sao indexed lookup nhanh." +**Q2. G1 vs ZGC vs Shenandoah — khi nào chọn cái nào?** -Cái bẫy là hỏi một cột đã index theo cách không phải range: - -```sql --- SAI: hàm trên cột giấu nó khỏi B-tree → full scan -SELECT * FROM orders WHERE YEAR(created_at) = 2026; - --- ĐÚNG: predicate range trên cột thô → B-tree range scan -SELECT * FROM orders -WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'; -``` - -Cùng họ: `LIKE '%needle%'` (leading wildcard = scan), phép toán trên cột, và `IS NOT NULL` trên một cột đa số null. Phỏng vấn viên xem bạn nói "tùy selectivity" hay phát biểu trống rỗng "index làm mọi thứ nhanh." - -### N+1 — cái query bạn không nhận ra mình đã viết - -```java -// SAI — một query cho các order, rồi thêm một query cho mỗi order = N+1 round trip. -// Với 1.000 order đó là 1.001 query × ~1 ms network+parse mỗi cái → ~1 s -// latency không bao giờ xuất hiện trong bất kỳ slow-query log nào. -for (Order order : orders) { - count += itemRepo.findByOrderId(order.getId()).size(); -} - -// ĐÚNG — một round trip cho tất cả; batching (ví dụ Hibernate @BatchSize) -// là lựa chọn giữa chừng khi IN-list trở nên vô lý. -var ids = orders.stream().map(Order::getId).toList(); -long count = itemRepo.findByOrderIdIn(ids).size(); -``` - -```sql --- cùng hình dạng trong SQL -SELECT * FROM item WHERE order_id IN (1001, 1002, /* ... */); -``` - -Dấu hiệu senior không chỉ là biết N+1 tồn tại — mà là biết _nó ẩn ở đâu_: `@ManyToOne` lazy-load được serialize vào DTO, per-row JSON enrichment, một `findById` trong một `map()`. Và cách sửa thường dời vấn đề: batching giúp, nhưng câu trả lời thật thường là "fetch DTO bạn thực sự cần bằng JOIN, không phải các entity." - -## 6. JVM internals phỏng vấn viên mê - -- **Class loading và ba loader.** bootstrap (parent null, `java.*`), platform (JDK 9+; thay extension loader), application (classpath). **Parent-delegation** — một classloader trước tiên hỏi cha nó — là một cơ chế bảo mật và nhất quán: bạn không thể lén nhét một `java.lang.String` giả. Một senior kể lạnh lưng các failure mode: `ClassNotFoundException` được ném bởi `Class.forName`/`loadClass` tường minh khi class **không được tìm thấy**; `NoClassDefFoundError` được ném ở **lúc link/use** khi class _từng có_ lúc biên dịch nhưng giờ thiếu hoặc thất bại khi initialize ở runtime — điển hình là thiếu dependency JAR hoặc một exception trong static initializer đã hủy việc load. Biết sự khác biệt lạnh lưng. -- **Classloader leak OOM Metaspace của bạn.** Mỗi lần redeploy app (Tomcat, Spring Boot dev-mode reload, dynamic proxying/bytecode gen) tạo một classloader; nếu thứ gì root cái loader cũ — một static field, một JDBC driver đăng ký trong `DriverManager`, một proxy bị cache — metadata của nó không bao giờ unload, và **Metaspace** (class metadata, không giới hạn theo mặc định) leo tới khi native memory chết. `jcmd VM.native_memory` cộng `-XX:MaxMetaspaceSize` là kho vũ khí. Con số người ta đánh giá thấp: một leak lớn vài MB mỗi reload trông vô hại cho tới khi nó thành 2 GB sau một trăm lần deploy. -- **JIT là lý do code ấm chạy nhanh.** Tiered compilation: C1 (client, warmup nhanh) rồi C2 (server, tối ưu mạnh: inlining, escape analysis, loop unrolling), với **OSR** (on-stack replacement) để đổi sang code đã tối ưu giữa vòng lặp và **deoptimization** khi một giả định vỡ. "Request đầu tiên sau deploy bị chậm" là JIT warmup, và profiling cho thấy nó như các sự kiện compilation — không phải "ta cần một cái máy to hơn." `-Xlog:jit+compilation=debug` cho thấy dòng thác recompile. Gotcha production: một hot method vẫn không nhanh sau traffic vì call site **megamorphic** (quá nhiều loại receiver để inline), hoặc vì nó recompile liên tục quá `-XX:CompileThreshold`. -- **`String`, caches, và `==`.** `String` immutable theo hợp đồng lẫn layout (private `byte[]`), cho phép constant pool và chia sẻ an toàn; **string pool được chuyển từ perm gen sang heap ở JDK 7**. `Integer.valueOf` cache **-128..127** (có thể nới bằng `-XX:AutoBoxCacheMax`); `Long` cache cùng khoảng đó. Nên `==` trên wrapper "hoạt động" trong khoảng cache và cắn người ngoài khoảng — loại bug tệ nhất có thể: vì nó qua tests và chết trên production ở 128. - -```java -Integer a = 127, b = 127; // được cache → a == b là true -Integer c = 128, d = 128; // object mới → c == d là false -``` +- **G1** (mặc định từ Java 9): region-based, nhắm mục tiêu pause (`-XX:MaxGCPauseMillis=200`). Mặc định tốt cho heap đến ~chục GB. +- **ZGC** (production từ Java 15): concurrent, pause dưới mili-giây ngay cả heap **nhiều terabyte**, nhưng tốn CPU/throughput hơn. +- **Shenandoah**: mục tiêu concurrent tương tự, cũng pause sub-ms. + Chọn G1 trừ khi pause ăn vào SLA latency, lúc đó ZGC. Một số cần nhớ: G1 pause ~tens-to-hundreds ms trên heap lớn; ZGC ~<1 ms bất kể kích thước heap. -- **Records** là câu trả lời hiện tại thân thiện với phỏng vấn: chúng là class mà `equals`/`hashCode`/`toString`/accessor được dẫn xuất từ danh sách component, `final` do cấu trúc, và serialization được định nghĩa trên component. Nói thật trade-off: chúng là một giá trị-semantics _mặc định_, không phải một value object — nếu equality theo business key chứ không phải mọi field, bạn vẫn phải tự viết `equals`. -- **Stack depth là một tài nguyên thật.** `-Xss` mặc định là 512 KB–1 MB; đệ quy sâu — một JSON walker đệ quy, một tree traversal ngây thơ — ném `StackOverflowError` khi các frame vượt nó, và đó là một lỗi phía native, không phải heap. Hỏi "stack size trên máy này là bao nhiêu?" trước khi đề xuất xử lý nặng đệ quy. +**Q3. Java Memory Model là gì và tại sao `volatile` quan trọng?** +JMM định nghĩa _happens-before_: ghi vào field `volatile` happens-before mọi lần đọc sau nó, cho tính visibility xuyên thread. Không có `volatile`, thread có thể đọc giá trị cũ trong cache và không bao giờ thấy update của thread khác. Nhưng `volatile` **không nguyên tử cho thao tác phức hợp** — `volatile int n; n++` vẫn là race (read-modify-write). Dùng `AtomicInteger` cho trường hợp đó. -## 7. Runtime & tooling — chứng minh bạn từng debug production +**Q4. `synchronized` vs `ReentrantLock` — cái nào bạn với tới?** +`synchronized` đơn giản, JVM tối ưu (lock elision, từng có biased locking), và tự giải phóng. `ReentrantLock` thêm: try-lock có timeout (`tryLock(100, ms)` tránh treo do deadlock), tùy chọn fairness, và nhiều condition variable. Hãy với tới `ReentrantLock` chỉ khi cần timeout hoặc acquire có thể interrupt; còn lại `synchronized` gọn hơn. -Senior nói: "khi production chậm, tôi không đoán — tôi đo." Phỏng vấn viên không thể kiểm chứng kiến thức tool của bạn từ một định nghĩa; họ _có thể_ nghe một incident thật. Hãy có một cái trong túi theo hình dạng này: symptom → hypothesis → tool → finding → fix. +**Q5. Nguy hiểm của việc tạo thread thủ công?** +`new Thread(() -> ...).start()` mỗi task sẽ cạn thread OS và không có queue, monitor, hay backpressure. Fix là **thread pool** qua `Executors` hoặc tốt hơn `new ThreadPoolExecutor(core, max, keepAlive, queue, factory, rejectionPolicy)`. Lỗi phổ biến: `Executors.newFixedThreadPool` dùng **`LinkedBlockingQueue` không giới hạn** — nếu task nhiều hơn consumer, queue phình đến **OutOfMemoryError**. Hãy giới hạn nó. -Bộ toolkit, với mỗi cái thực sự dùng để làm gì: +**Q6. `ConcurrentModificationException` nghĩa là gì và tránh thế nào?** +Nó bắn khi collection bị sửa cấu trúc trong lúc duyệt (trừ qua `remove` của iterator). Fix: duyệt bằng `Iterator.remove()`, dùng concurrent collection (`CopyOnWriteArrayList`, `ConcurrentHashMap`), hoặc collect-to-remove rồi `removeAll`. `CopyOnWriteArrayList` tuyệt cho list đọc nhiều, ghi hiếm (snapshot-on-write). -- **`jstack`** — thread dumps. Tìm `BLOCKED` threads chất đống trên một monitor, deadlock (chính JVM in một phần deadlock), hoặc một `RUNNABLE` thread kẹt trong socket read. Chụp **ba dumps cách nhau vài giây** — một dump đơn là một bức ảnh nhòe. -- **`jmap -histo`** — object histogram; tìm class giữ hàng trăm MB (`byte[]`, `char[]` đứng đầu bảng một cách đáng ngờ) và truy dấu ai root nó. Dùng biến thể non-live trên prod — `-histo:live` ép một full GC (phần 1). -- **`jstat -gcutil`** — tần suất và xu hướng pause GC _theo thời gian_, đây là cách bạn phát hiện allocation storm hoặc old gen phình to trước khi OOM. -- **`jcmd`** — con dao đa năng: `jcmd GC.heap_dump`, `VM.native_memory`, `Thread.print`, `VM.flags`. `jmap` dành cho dump; `jcmd` cho quan sát nội tại lúc sống. -- **JFR (Java Flight Recorder)** — JDK 11+ gồm sẵn miễn phí. Event cho GC phase pause (`jdk.GCPhasePause`), allocation (`jdk.ObjectAllocationInNewTLAB`), lock contention (`jdk.JavaMonitorEnter`), virtual-thread pinning (`jdk.VirtualThreadPinned`), method sampling, socket reads. Bắt đầu với `jcmd JFR.start name=profile settings=profile`, rồi `jfr view` file. Overhead ở cấu hình mặc định dưới 1% — nói điều đó, nó là killer feature. -- **async-profiler** — on-CPU + off-CPU wall-clock + allocation + lock profiling với flamegraph, không có JVMTI agent trong hot path. Đây là cái tìm ra _tại sao_ CPU cao, không chỉ _rằng_ nó cao. -- **GC logs.** `-Xlog:gc*` (JDK 9+ unified logging) với `-Xlog:gc:file=gc.log:time,uptime`. Đọc các pause _và_ heap-sau-GC; một service có heap sau GC cứ leo là đang leak, một service pause dài mà heap phẳng là bài toán sizing. +## Senior — thiết kế & phòng thủ -Một câu chuyện mẫu: "P99 latency tăng gấp đôi sau release. `jstat -gcutil` cho thấy FGC nhảy mỗi 2 phút; `jmap -histo` cho thấy 800 MB `byte[]`; JFR allocation event chỉ vào code gzip mới; cách sửa là streaming + pooling các buffer. P99 về lại 40 ms." **Đoạn đó, trong một buổi phỏng vấn, đáng giá hơn bất kỳ định nghĩa nào bạn có thể đọc thuộc.** +**Q1. Một service có pause 3 s mỗi vài phút dưới tải. Hãy đi qua chẩn đoán.** +"Đầu tiên tôi xác nhận đó là GC, không phải network: `-Xlog:gc*:time` cho thấy full GC trùng với pause. Đồ thị heap leo rồi rớt — leak hoặc cache không giới hạn. Tôi chụp heap dump tại đáy sau full GC (`jmap -dump` hoặc `-XX:+HeapDumpOnOutOfMemoryError`) và mở bằng Eclipse MAT, sort theo retained size. Thường là một `Map` static hoặc thread-local không bao giờ clear. Fix: giới hạn cấu trúc đó (Caffeine với `maximumSize` + `expireAfterWrite`), hoặc đẩy data ra khỏi JVM. Sau đó chuyển G1 → ZGC nếu latency vẫn cắn. Tôi đo p99 trước/sau; mục tiêu <200 ms." -## 8. Tự kiểm tra +**Q2. Bạn phải share một counter xuyên 64 thread ở 1M ops/s. Thiết kế đi.** +"Naive `AtomicLong.incrementAndGet()` tuần tự hóa trên một cache line — false sharing và trần ~tens of M ops/s. Lựa chọn: `LongAdder` (JDK 8+) chia counter thành các cell, đổi độ chính xác đọc lấy throughput — dễ dàng 5–10× cao hơn. Ở 1M ops/s `LongAdder` là lựa chọn đúng; read là `sum()` (xấp xỉ nhưng ổn cho metrics). Tôi cũng gắn nó vào metrics path, không phải counter đòi hỏi đúng-sai nghiêm ngặt, và ghi chú điều đó." -- [ ] Giải thích TLAB allocation, và tại sao escape analysis làm `new` đôi khi không tốn gì. -- [ ] Phát biểu ngưỡng 32 GB compressed oops và tại sao heap 40 GB có thể chậm hơn heap 28 GB. -- [ ] Đưa ra phép tính pause: cái gì kiểm soát một young-gen STW pause, và G1 đánh đổi tần suất vs thời lượng thế nào. -- [ ] Nêu tên concurrent-mode failure, evacuation failure, và luật humongous-object của G1. -- [ ] SoftReference vs WeakReference vs PhantomReference — khi nào mỗi loại đúng? -- [ ] Nêu tên các happens-before edge của `volatile`, monitor unlock→lock, `start()`/`join()`. -- [ ] Viết double-checked locking đúng và giải thích vì sao bản không-volatile hỏng. -- [ ] Giải thích vì sao `LongAdder` đánh bại `AtomicLong` dưới contention, và khi nào nó là công cụ sai. -- [ ] False sharing là gì, và `@Contended` làm gì? -- [ ] Optimistic read của `StampedLock` làm gì, và khi nào nó thrash? -- [ ] Chuyện gì xảy ra với `thenApplyAsync` trên `commonPool` khi một task block, và cách sửa? -- [ ] Size một thread pool bằng Little's law, và chọn một chính sách bão hòa có backpressure. -- [ ] Khi nào virtual threads giúp, khi nào không, và thứ gì vẫn pin một carrier sau JDK 24? -- [ ] `ClassNotFoundException` vs `NoClassDefFoundError`, và cái gì làm Metaspace leak khi redeploy. -- [ ] Giải thích B-tree height và tại sao `WHERE YEAR(col) = ?` scan trong khi predicate range thì không. -- [ ] Một incident GC/perf bạn thực sự tìm ra bằng profiling tool. +**Q3. Giải thích false sharing và chứng minh nó từng tốn performance của bạn.** +"Hai field `long` ghi thường xuyên trên cùng một cache line 64-byte bị invalidate xuyên core dù logic độc lập. Triệu chứng: scaling tệ _hơn_ khi thêm thread. Chứng minh: annotate padding (`@Contended`, hoặc padding 64-byte thủ công) — nếu throughput nhảy vọt, bạn có false sharing. `LongAdder` đã tích hợp sẵn. Trong một service, thêm `@Contended` vào field counter nóng đưa hot loop từ 40M lên 220M ops/s." -Nếu những thứ đó thấy dễ, bạn sẵn sàng phần Java core. +**Q4. Khi nào bạn KHÔNG dùng thread pool, và thay bằng gì?** +"Với blocking I/O quy mô lớn — pool N thread giới hạn concurrency ở N và tất cả treo trên socket. Virtual thread (Java 21+, `Executors.newVirtualThreadPerTaskExecutor()`) cho phép spawn hàng triệu thread rẻ; mỗi lần blocking sẽ park thay vì pin OS thread. Quy tắc: dùng virtual thread cho code I/O-bound task-per-request; giữ platform-thread pool cho work CPU-bound nơi bạn muốn giới hạn concurrency cứng." -## 9. Follow-ups từ phỏng vấn viên +**Q5. Một `HashMap` được nhiều thread dùng, thỉnh thoảng trả null cho key đã put. Tại sao, và fix?** +"Nó không thread-safe — put đồng thời có thể làm hỏng cấu trúc bucket hoặc mất entry khi resize giữa chừng (và ở Java cũ, có thể loop vô tận). Fix: `ConcurrentHashMap` cho truy cập concurrent. Nhưng lưu ý `ConcurrentHashMap.computeIfAbsent` nguyên tử per-key; `get-then-put` thì không. Nếu cần thao tác compound nguyên tử, dùng `compute`/`merge`, đừng tự viết check-then-act." -Khi câu trả lời đầu tiên của bạn đáp xuống, họ bắt đầu khoan. Sẵn sàng cho những câu này: +**Q6. Bạn phòng thủ lựa chọn giữa G1 và ZGC bằng số thế nào?** +"Tôi baseline p99 latency và % GC pause dưới tải giống production (vd 500 rps, 30 GB heap). Nếu G1 pause ~150 ms và SLA p99 < 250 ms còn headroom, G1 thắng về throughput (ZGC tốn ~10–15% CPU). Nếu pause ăn vào SLA, ZGC <1 ms biện minh cho thuế CPU. Tôi không chọn theo cảm giác — chạy cả hai ở staging với cùng tải và đọc GC log. Quyết định là một bảng tradeoff, ký bằng con số đo được." -- "Service của bạn chạy 2.000 req/s và mỗi request block ~50 ms trong JDBC. Size thread pool. Giờ nếu 10% call mất 5 giây thì sao?" -- "Cùng service đó: bạn cấp bao nhiêu DB connection, và chuyện gì xảy ra với các virtual thread khi pool là 10?" -- "Bạn nói G1 pause là một mục tiêu mềm. Dẫn một dòng log `gc` chứng minh G1 trượt `MaxGCPauseMillis`, và bạn sẽ đổi gì?" -- "`volatile` cho happens-before. Điều đó có làm một `volatile int` an toàn làm counter không? Nếu là `volatile long` trên JVM 32-bit thì sao?" -- "Tôi viết code `synchronized` và nó chậm hơn bản `ConcurrentHashMap`. Có phải `synchronized` hỏng?" -- "`ThreadLocal` trong thread pool của bạn đang giữ 200 MB. Cái gì thực sự root nó, cách sửa là gì — và thứ thay thế trong tương lai?" -- "Giải thích tại sao JDK 24 đổi hành vi pinning, và thứ gì vẫn pin một virtual thread." -- "`SELECT * FROM orders WHERE YEAR(created_at) = 2026` chậm, và cột có index. Tại sao, và bạn viết lại thành gì?" -- "Bạn thấy `OutOfMemoryError: Metaspace` sau một redeploy mà không thêm class nào. Lệnh đầu tiên của bạn là gì, và bạn tìm gì?" -- "Một hot method vẫn chậm sau 10 phút traffic. JIT có thể đang làm gì, và bạn chứng minh bằng log thế nào?" +#### Self-check -Đó là bar Java core. +- [ ] Junior: Tôi gọi được tên các vùng bộ nhớ JVM, giải thích `==` vs `equals`, primitive vs wrapper, và checked vs unchecked exception. +- [ ] Mid: Tôi mô tả được generational GC, chọn G1 vs ZGC, giải thích `volatile`/JMM, và tránh được unbounded thread-pool queue. +- [ ] Senior: Tôi chẩn đoán được GC pause từ log + heap dump, thiết kế counter 1M-ops/s, giải thích false sharing, và phòng thủ lựa chọn collector bằng số before/after. From e89afad0a77fbee46e6c6a73174b2989596cf187 Mon Sep 17 00:00:00 2001 From: Tester Date: Thu, 13 Aug 2026 06:30:05 +0000 Subject: [PATCH 2/8] =?UTF-8?q?docs(interview):=20rewrite=20oop=20as=20Jun?= =?UTF-8?q?ior=E2=86=92Senior=20Q&A=20series=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/blog/en/interview/oop-senior.md | 426 +++------------------- src/data/blog/vi/interview/oop-senior.md | 427 +++-------------------- 2 files changed, 102 insertions(+), 751 deletions(-) diff --git a/src/data/blog/en/interview/oop-senior.md b/src/data/blog/en/interview/oop-senior.md index c857856..71561c5 100644 --- a/src/data/blog/en/interview/oop-senior.md +++ b/src/data/blog/en/interview/oop-senior.md @@ -1,6 +1,6 @@ --- -title: "Senior Java Interview: OOP and Design Principles" -description: "OOP at the senior level is about applied SOLID, composition over inheritance, and interface design at scale — not reciting definitions." +title: "Java Interview Prep #2: OOP & Design Principles — Junior to Senior" +description: "OOP at the senior level is applied SOLID, composition over inheritance, and interface design at scale — not reciting definitions. Junior names the principles; senior shows where each one costs you." pubDatetime: 2026-08-10T10:05:00+07:00 featured: false draft: false @@ -11,401 +11,77 @@ tags: - design-principles --- -Object-oriented programming is the entry ticket. A junior recites "a class is a blueprint" and "SOLID is five letters." A senior treats design as **structural engineering**: every decision has a load path, a failure mode, and a price, and the interview is about whether you can justify the walls you'd leave standing. +OOP is the part of the interview where interviewers stop asking "what" and start asking "why". Anyone can name the four pillars; a senior can tell you the last time inheritance bit them and why they refactored to composition. This post climbs from the textbook to the trade-off table. -> Mindset: when the interviewer says "your team needs a new feature," the senior response is never "add a branch." It's "which axis of change is this — and what do I build that I won't have to edit tomorrow?" Definitions pass juniors; **decisions under constraints** clear the senior bar. +> Mindset: junior implements the interface; senior decides whether the interface should exist at all, and what it costs the next five years of the codebase. -## 1. SOLID — the applied version, with the traps +## Junior — foundations -"Define SOLID" is a screen for juniors. Seniors get asked to apply it, then to defend the places where applying it naively is wrong. Walk all five, but be ready to go deeper on the three that actually bite in production: Open/Closed, Dependency Inversion, and Liskov. +**Q1. What are the four pillars of OOP?** +Encapsulation (hide state behind behavior), Abstraction (expose intent, not mechanism), Inheritance (reuse by specialization), Polymorphism (one interface, many implementations). The trap: naming them is free; applying them without creating a brittle hierarchy is the actual skill. -### Open/Closed — the axis of change +**Q2. What is the difference between an abstract class and an interface?** +An abstract class can hold state and implement methods; a class extends only one. An interface is a contract — pre-Java 8 only method signatures, now it can carry `default` and `static` methods but no fields (except `public static final` constants). Since Java 8 you can `implement` many interfaces but extend one class. Prefer interfaces for the _type_ and abstract classes only when you need shared state/behavior. -The textbook says "open for extension, closed for modification." The real question is **which axis changes fastest** — OCP is a strategy for the unstable part of your system, not a global law. Apply it first where you add a new case every sprint: +**Q3. What is polymorphism and how does it work in Java?** +Subtype polymorphism: a variable of a supertype refers to any subtype, and the runtime dispatches the overridden method. Method dispatch is virtual by default — `Animal a = new Dog(); a.speak()` calls `Dog.speak()`. Overloading is _not_ polymorphism (it is resolved at compile time by signature). -```java -// WRONG — every new payment method is another branch in this if/else tower. -// Adding Apple Pay means editing pay() — an edit to working code, a merge -// conflict on the same lines every sprint, a regression surface that grows -// with every feature. -PaymentResult pay(Order order) { - if (order.method() == PaymentMethod.CARD) return cardGateway.charge(order); - else if (order.method() == PaymentMethod.BANK) return bankGateway.transfer(order); - else if (order.method() == PaymentMethod.WALLET) return walletGateway.pay(order); - throw new UnsupportedOperationException(order.method().name()); -} +**Q4. What is the difference between method overriding and overloading?** +Overriding: same signature in a subclass, runtime-dispatched (`@Override`). Overloading: same name, different parameter types, resolved at compile time. A classic pitfall: overloaded methods with `Object` vs `String` args — `foo(null)` is ambiguous and fails to compile if both exist. -// RIGHT — the registry is the extension point. A new method = one new class -// plus one registration line. The dispatch table is stable; the set of -// strategies grows. -Map handlers = Map.of( - PaymentMethod.CARD, new CardHandler(cardGateway), - PaymentMethod.BANK, new BankHandler(bankGateway), - PaymentMethod.WALLET, new WalletHandler(walletGateway) -); -PaymentResult pay(Order order) { - return handlers.get(order.method()).handle(order); -} -``` +**Q5. What does `equals`/`hashCode` contract require?** +If `a.equals(b)` then `a.hashCode() == b.hashCode()`. The converse is not required but equal hashCodes should be rare (good distribution). If you override `equals` you MUST override `hashCode`, or objects break in `HashMap`/`HashSet` (two equal keys land in different buckets). -> What interviewers actually probe: "we're adding a 4th payment method — walk me through the change." "Add an `else if`" fails the OCP check. "The registry gets one entry; the new class implements the contract" passes it — and then they immediately ask the counter-question below. +**Q6. What is the difference between `abstract` and `interface` default methods?** +An abstract class method has a body the subclass may or may not override. An interface `default` method provides behavior a class inherits without implementing — used for backward-compatible API evolution (e.g. `Collection.removeIf`). Override a `default` in the implementing class to change it. -**The counter-trap — OCP is not free.** A registry of one-method strategies is ceremony if the set never grows. And pattern matching changed the calculus: with a **sealed enum**, an exhaustive `switch` is _also_ closed for modification — adding a value without handling it breaks the build instead of breaking the runtime: +## Mid — tradeoffs & pitfalls -```java -// RIGHT (alternative) — sealed domain: the compiler enforces coverage. -// Adding PaymentMethod.BITCOIN fails the build until every switch handles it. -PaymentResult pay(Order order) { - return switch (order.method()) { - case CARD -> cardGateway.charge(order); - case BANK -> bankGateway.transfer(order); - case WALLET -> walletGateway.pay(order); - }; -} -``` +**Q1. When is inheritance the wrong tool?** +When the relationship is not a true "is-a" with stable shared behavior. Inheritance couples the subclass to the parent's implementation forever — change the superclass and every subclass breaks. The "fragile base class" problem: a seemingly safe change to a superclass silently alters subclass behavior. Reach for **composition** (wrap the dependency, delegate) when the shared code is "has-a" rather than "is-a". -The senior tell is naming the two modes and picking deliberately. **Closed world** (sealed + exhaustive switch): the domain changes rarely and all-at-once, and you want compile-time proof you missed a case. **Open world** (strategy registry, `ServiceLoader`, plugin classes): third parties or runtime add variants independently, and compile-time exhaustiveness would be a lie. Building a plugin framework for a two-case enum is the over-engineering interviewers watch for. +**Q2. Explain SOLID, briefly, and give one real misuse of each.** -### Dependency Inversion — who owns the interface +- **S**ingle Responsibility: a class changes for one reason. Misuse: a `UserService` that also sends email and writes audit logs — three reasons to change. +- **O**pen/Closed: open for extension, closed for modification. Misuse: a `switch(type)` that you edit every time a new type appears. +- **L**iskov: subtypes must be substitutable. Misuse: `Square extends Rectangle` then `setWidth` breaks the rectangle invariant. +- **I**nterface Segregation: many small interfaces beat one fat one. Misuse: a `Worker` interface forcing `cleanToilet()` on a `Programmer`. +- **D**ependency Inversion: depend on abstractions, not concretions. Misuse: `new MySQLRepository()` hardcoded in a service. -Dependency **Injection** is passing a dependency in. Dependency **Inversion** is deciding who writes the contract. They are not the same, and conflating them is the most common mid-level answer. +**Q3. What is the difference between `Comparator` and `Comparable`?** +`Comparable` defines the _natural_ ordering of a type (`compareTo`, one definition). `Comparator` is an _external_ ordering strategy (pass to `sort`, many can exist). Use `Comparable` for the obvious default; `Comparator` when the sort depends on context (by name, by date, descending). -The plug-and-socket framing: a wall socket is a standard **owned by the building**, and every appliance conforms to it — the lamp doesn't get to invent its own socket and demand the wall change. DIP is the same. Your `OrderService` (the consumer, the building) declares `PaymentGateway` (the socket). Stripe's SDK is the appliance — an **adapter** that implements your port. That's why it's called _inversion_: the high-level module defines the abstraction the low-level module implements, not the reverse. +**Q4. Why are getters/setters not real encapsulation?** +A public getter/setter pair with no invariant is just a public field with extra steps — state is still wide open. Real encapsulation exposes _behavior_: `account.withdraw(amount)` instead of `account.setBalance(x)`. The object protects its invariants; callers ask for outcomes, not mutate fields directly. Anemic domain models (entities with only getters/setters) are a code smell. -```java -// WRONG — the consumer reached into the world and grabbed a concrete thing. -// The order domain now depends on Stripe's SDK — at compile time, at test -// time, and forever. -class OrderService { - private final StripeGateway gateway = new StripeGateway(); - PaymentResult pay(Order order) { return gateway.charge(order); } -} +**Q5. What is the difference between `==` on objects and identity vs equality — and boxed types?** +Covered in core, but the OOP angle: two `Integer` from `valueOf` in the -128..127 cache compare `==` true; outside it false. Relying on `==` for boxed types is a latent bug. Always `equals` for value comparison of wrappers, and beware autoboxing allocations in hot loops. -// RIGHT — the consumer owns the port; the adapter conforms to it. -// Stripe could vanish tonight and the domain wouldn't recompile. -class OrderService { - private final PaymentGateway gateway; - OrderService(PaymentGateway gateway) { this.gateway = gateway; } - PaymentResult pay(Order order) { return gateway.charge(order); } -} +**Q6. When would you use a `record` (Java 16+)?** +When the type is _data carrier_: immutable, `equals`/`hashCode`/`toString` auto-generated, all fields final. Perfect for DTOs, API responses, value objects. Don't use a `record` when you need mutable state, inheritance, or behavioral richness — that's a class. `record Point(int x, int y)` is all you need for a coordinate; a `BankAccount` is not a record. -interface PaymentGateway { PaymentResult charge(Order order); } -class StripeGatewayAdapter implements PaymentGateway { /* delegate to the SDK */ } -``` +## Senior — design & defense -This is _why_ Spring exists — not to "make DI easy," but to wire adapters to ports so the domain stays clean. But the real payoff isn't the framework, it's the **test seam**: +**Q1. Defend composition over inheritance with a concrete refactor you'd make.** +"I'd take a `ReportGenerator extends ExcelWriter` and flip it: `ReportGenerator` holds a `Writer` (interface) it delegates to. Reason: the Excel coupling meant any change to spreadsheet formatting risked the report logic, and we couldn't unit-test the report without a real spreadsheet. Composition let us inject a `FakeWriter` in tests and add `PdfWriter` with zero changes to `ReportGenerator`. Cost: one extra interface and a constructor arg — cheap insurance against the fragile base class." -> Production story: `OrderService` news up `StripeGateway` in its constructor, so the retry-on-timeout path can't be unit-tested — every test either hits Stripe's sandbox or patches a static. That's not a testing problem, it's a DIP violation that shows up as a testing problem. The moment the dependency became injectable, the flaky integration test became a three-line unit test with a fake. +**Q2. A team wants a base `BaseEntity` with 30 fields and every JPA entity extends it. What do you say?** +"I'd split it. A true `BaseEntity` (id, version, createdAt, updatedAt, auditing) is fine — that's a genuine 'is-a' with stable shared state. But 30 fields means it's actually a grab-bag; subtypes inherit columns they don't use, queries get wider, and a change ripples everywhere. I'd push the 26 domain-specific fields down into the entities that own them and keep `BaseEntity` to the 4 audit fields. Measured win: narrower tables, clearer ownership, fewer accidental couplings." -**The trap on the other side — interface explosion.** DIP does not mean "extract an interface for every class." A `UserService` interface with exactly one implementation, one consumer, and no test double is ceremony with a tax: every change touches two files, and the interface is a lie waiting to drift from the impl. The honest rule: an abstraction must earn its keep — a second implementation, a test double, or a contract boundary with an external system. Otherwise write the concrete class and inject its dependencies. +**Q3. How do you design an interface so it survives five years of new requirements?** +"I keep it small and behavioral, not a CRUD dump. I favor `sealed` hierarchies (Java 17+) when the set of subtypes is closed — the compiler forces you to handle every case in `switch`, so adding a subtype is a compile error until you've dealt with it everywhere. I expose capabilities as narrow interfaces (`Readable`, `Flushable`) rather than one `MegaService`. And I use `default` methods only for genuinely optional behavior, never to sneak in state." -### Liskov — the one that actually breaks in production +**Q4. Liskov Substitution — walk a real violation and its fix.** +"The classic `Square extends Rectangle`: setting width must also set height to stay a square, but that breaks `Rectangle`'s contract that width and height are independent. Any code doing `r.setWidth(5); r.setHeight(10); assert r.area()==50` now lies. Fix: don't model square as a rectangle subtype — extract a `Shape` with `area()` and implement both independently, or use a single `Rectangle` that forbids zero/negative and represents a square as w==h. Subtyping is a promise; if you can't keep it, don't make it." -LSP is where definitions die, because the violation is invisible in code review and detonates at runtime inside a `HashMap`. The contract: a subtype must be usable wherever its supertype is promised — **preconditions not strengthened, postconditions not weakened, invariants preserved.** Two production-grade examples. +**Q5. You have an interface with 12 methods but most callers use 2. Redesign it.** +"That's Interface Segregation violation. I'd split into focused roles: `Reader` (read), `Writer` (write), `Lifecycle` (start/stop), and let a concrete class implement all three if it needs to. Callers depend only on what they use, so a change to `Writer` never recompiles a read-only consumer. The implementing class is unchanged in behavior; only the _types_ it's exposed through get narrower. This also makes mocking in tests trivial — you stub the 2 methods you care about." -**The equals-symmetry trap.** Add state to a subclass and `equals` silently becomes asymmetric, corrupting `Set`/`Map` behavior: +**Q6. How do you prove your OOD is good, not just 'clean' in the interview?** +"I'd point at the change I just made and the cost of the alternative: count the reasons each class changes, the number of call sites that break when a requirement shifts, and the test surface. Good OOD means a new feature touches one class, not twelve. I'd sketch the dependency graph — if it's a DAG with stable abstractions at the top and volatile details at the bottom (dependency inversion), that's the proof. Not 'I used SOLID', but 'here is the diff when the requirement changed, and it was small'." -```java -class Point { - final int x, y; - Point(int x, int y) { this.x = x; this.y = y; } - @Override public boolean equals(Object o) { - return o instanceof Point p && p.x == x && p.y == y; - } - @Override public int hashCode() { return 31 * x + y; } -} +#### Self-check -class ColoredPoint extends Point { - final Color color; - ColoredPoint(int x, int y, Color c) { super(x, y); this.color = c; } - @Override public boolean equals(Object o) { - if (!(o instanceof ColoredPoint)) return false; // ← asymmetry - return super.equals(o) && ((ColoredPoint) o).color == color; - } -} - -Point p = new Point(1, 2); -ColoredPoint cp = new ColoredPoint(1, 2, Color.RED); -p.equals(cp); // true — Point ignores color -cp.equals(p); // false — ColoredPoint demands color → symmetry broken -``` - -```java -// RIGHT — composition instead of inheritance: ColoredPoint is NOT a Point, -// it HAS a Point. No subtype, no broken contract, no surprise in a HashSet. -record ColoredPoint(Point point, Color color) {} -``` - -**Covariance and contravariance.** LSP leaks into the type system. Arrays are **covariant and reified** — `Object[]` can hold a `String[]`, and the error shows up at runtime: - -```java -Object[] objs = new String[10]; -objs[0] = 42; // compile: fine → runtime: ArrayStoreException -``` - -Generics are **invariant** — the compiler stops the same bug before it ships: - -```java -List words = new ArrayList<>(); -List objects = words; // compile error — invariant -List any = words; // covariance via bounded wildcard (read-only) -List sink = new ArrayList(); // contravariance (write-only) -``` - -Remember **PECS** — producer `extends`, consumer `super` — and say out loud that a `List` is not a `List` even though `Dog` is an `Animal`: "is-a" on types does not carry over to generic containers, because a mutable container's contract ("you may add any `Animal`") would be weakened by the subtype. - -**The throwing subtype.** A subclass that overrides a method to throw — "I'll just make `add()` throw for this special collection" — violates LSP by strengthening the precondition. The right shape is a **decorator** (`Collections.unmodifiableList`) that fails fast and loudly, not a subtype that pretends to be mutable. Interviewers probe this with: "how do you make an immutable list without breaking the contract?" - -### Single Responsibility and Interface Segregation — the God object's obituary - -These two are one idea at different granularities: **SRP is about classes, ISP about interfaces, and both are about "one axis of change."** A 500-line `OrderService` that is repository, validator, orchestrator, and mapper changes for four reasons and is impossible to reason about. A 40-method `UserService` interface forces every implementer to stub 35 methods — and worse, forces every _caller_ to see 40 capabilities it must not use. - -```java -// WRONG — a single fat interface. Every implementer stubs 35 methods; -// every caller depends on 40 capabilities. -interface UserService { - User findById(long id); - void update(User u); - byte[] exportAuditReport(Period p); // why is this here? - void sendWelcomeEmail(long id); // or this? - List search(String q, Page p); - // ... 35 more -} - -// RIGHT — role interfaces. A caller depends on the slice it needs; a class -// implements several roles and no method is dead weight. -interface UserReader { User findById(long id); } -interface UserWriter { void update(User u); } -interface AuditExporter { byte[] exportAuditReport(Period p); } - -class UserServiceImpl implements UserReader, UserWriter, AuditExporter { ... } -``` - -> What interviewers actually probe: "here's a 400-line service — how do you know it's wrong before you read line 300?" The senior answer isn't the interface; it's naming the **three axes of change** in it. If you can list them, SRP is not a slogan. - -## 2. Composition over inheritance — the fragile base class, in the wild - -"Why favor composition?" The junior answer is "inheritance is bad." The senior answer is one incident: a change to the parent silently broke fifty subclasses that assumed things about `super` the parent never promised. - -The fragile base class problem is structural, not stylistic. Inheritance couples you to the parent's **implementation**, not its contract: you inherit `protected` fields, you call `super`, and the parent's methods invoke hooks (`afterPut`) in an order the subclass didn't write. The base class can't change its internals without risking every subclass, and the subclass can't reason about its own behavior without reading the parent. They are welded at the ribs. - -```java -// WRONG — a base class full of hidden coupling. MetricsCounter trusts that -// afterPut is called exactly once per put, in order. The next release of -// AbstractCache adds a second hook, reorders the calls, or skips afterPut on -// dedup — and MetricsCounter silently counts wrong. Nobody's code "changed." -abstract class AbstractCache { - private final Map store = new HashMap<>(); - public final void put(String k, byte[] v) { - store.put(k, v); - afterPut(k, v); - } - protected void afterPut(String k, byte[] v) {} -} - -class MetricsCounter extends AbstractCache { - @Override protected void afterPut(String k, byte[] v) { metrics.increment("puts"); } -} - -// RIGHT — behavior is assembled, not inherited. The decorator wraps the -// delegate and the caller picks the stack. No subclass depends on another -// class's internals; every behavior is testable in isolation. -interface Cache { void put(String k, byte[] v); } - -class MetricCache implements Cache { - private final Cache delegate; - MetricCache(Cache delegate) { this.delegate = delegate; } - public void put(String k, byte[] v) { - long start = System.nanoTime(); - delegate.put(k, v); - metrics.record("cache.put.ns", System.nanoTime() - start); - } -} - -Cache cache = new MetricCache(new TtlCache(new MemCache())); -``` - -Inheritance also breaks at the contract level: `ColoredPoint extends Point` (section 1) is an inheritance problem hiding as an equals problem — adding state to a subclass is the single most common way to violate LSP without noticing. And the `Stack extends Vector` fiasco is the textbook case: a stack is _not_ a vector, and inheriting `add(int, E)` lets callers insert into the middle of a stack. "Is-a" must hold in the real world, not just in the UML diagram. - -**When inheritance is right** — say this out loud, it's the differentiator. Inheritance is a tool, not a sin. Use it when the subclass is a genuine specialization that provides **hooks, not behavior**: Template Method. `JdbcTemplate` letting you supply a `RowMapper`, Spring's `AbstractMessageConverter` letting subclasses fill in `supports`/`writeInternal`, `HttpServlet` overriding `doGet`. The parent owns the flow (the skeleton) and the subclass fills the slots, and the parent's contract is explicit. The failure mode is the opposite: a subclass that _overrides whole methods_ and then calls `super` on them is fighting the parent, and that's the smell. - -**The honest cost of composition.** Don't oversell it: wrapping means delegation boilerplate, deeper stack traces, and a runtime graph that's hard to trace ("which of these five decorators dropped my cache line?"). The senior tradeoff is granularity — compose where the axis changes, delegate where the flow is fixed, and never decorate for decoration's sake. - -## 3. Interface design at scale — polymorphism beyond the textbook - -A junior sees an interface as "a class template." A senior sees an interface as a **contract with an owner** — and modern Java changed what that contract can express. - -### Sealed types — the closed world, enforced by the compiler - -Before Java 17, polymorphism was open by default: anyone could add a `Shape`, and the `instanceof` chain (or `if/else` tower) kept growing. **Sealed interfaces** (Java 17) close the world deliberately, and **pattern matching** (Java 21) makes dispatch exhaustive and checked at compile time: - -```java -sealed interface OrderEvent permits OrderPlaced, OrderPaid, OrderCancelled {} -record OrderPlaced(Long orderId, Instant at) implements OrderEvent {} -record OrderPaid(Long orderId, Money amount) implements OrderEvent {} -record OrderCancelled(Long orderId, String reason) implements OrderEvent {} - -String label(OrderEvent e) { - return switch (e) { - case OrderPlaced p -> "placed at " + p.at(); - case OrderPaid p -> "paid " + p.amount(); - case OrderCancelled c -> "cancelled: " + c.reason(); - // no default needed — the compiler proves the switch is exhaustive - }; -} -``` - -`label` dispatches on **shape** (which record it is), not on a `type` field — and the compiler eliminates the "missed a case" bug that a `type` enum plus `if/else` always carried. Sealed hierarchy + records + pattern matching is Java's answer to algebraic data types, and it beats both the stringly-typed dispatch and the strategy-registry-as-ceremony in the common case. - -The senior distinction (echoing section 1): **sealed = closed world, stable algebra, compile-time exhaustiveness.** Strategy/plugin/`ServiceLoader` = **open world, pluggable variants, runtime registration.** When an interviewer says "design the event handling," the differentiator is _who_ gets to add a variant and _when_ it must be caught — compile time or deploy time. - -### Records, value objects, and the equality contract - -A `record` (Java 16+) is a class whose identity contract is **all components** — `equals`/`hashCode`/`toString`/accessors derived from the component list, `final` by construction. That makes records the natural home for value objects, and value objects with _pure_ behavior are exactly right: - -```java -record Money(long cents) { - Money { if (cents < 0) throw new IllegalArgumentException("negative money"); } // invariant in the constructor - Money add(Money o) { return new Money(cents + o.cents); } - boolean isNegative() { return cents < 0; } - @Override public String toString() { return "%d.%02d".formatted(cents / 100, cents % 100); } -} -``` - -The senior nuance on "records carrying logic": the real smell is a record coordinating **stateful** or cross-object business rules. A `Money` that validates its invariant and defines its arithmetic is a _good_ record; an `OrderPlaced` event that reaches out to a repository is a _bad_ one. Keep coordination in services; keep values in values. - -And the tradeoff people miss: a record's equality is by **all fields**, which is the right default for value semantics and the wrong default for entities. An `Order` that is "the same order" by `id` even when fields changed must **not** be a record — its equality must be hand-written over the business key, or it will corrupt `Set`s and `Map`s. Same LSP lesson as `ColoredPoint`, mirrored. - -## 4. Tell, don't ask — encapsulation has a database bill - -"Tell, don't ask" sounds like style advice. At senior level it's a **performance and correctness** principle, because a getter is not free — it can be a lazy-loaded proxy that fires a query. - -Feature envy is the symptom: a caller that reaches through an aggregate, pulls its collections, and computes with them. That is both a design smell and an N+1 query factory. - -```java -// WRONG — the caller reached INTO the order and computed with its innards. -// order.getItems() is a lazy-loaded collection: this fires one SELECT per -// order. 1,000 orders → 1,001 queries. Fine with 5 rows in tests; dies in -// prod with a million — and no single query looks slow, so it survives every -// slow-query log. -long totalItems = 0; -for (Order order : orders) { - totalItems += order.getItems().size(); -} - -// RIGHT — the aggregate answers the question. One intent, no reach-in. -long totalItems = 0; -for (Order order : orders) { - totalItems += order.getLineItemCount(); -} -``` - -But "tell, don't ask" alone is not enough — a naive `getLineItemCount()` may still lazy-load the whole collection. The senior fix is deciding **which layer answers the question**. The database counts faster than the JVM can load: - -```sql --- the same question, answered by the database in one round trip. --- A point lookup is a B+tree walk: 3–4 page fetches, ~100 ns each when the --- pages are hot in the buffer pool → sub-millisecond. The N+1 version above --- was 1,001 network round trips × ~1 ms each ≈ a full second of latency --- that never appears in any single slow-query log. -SELECT o.id, COUNT(li.id) -FROM orders o -LEFT JOIN line_items li ON li.order_id = o.id -GROUP BY o.id; -``` - -```java -// RIGHT — project the DTO you actually need; don't load the entity graph. -@Query("select new OrderSummary(o.id, o.customerName, size(o.items)) from Order o") -List findAllSummaries(); -``` - -> What interviewers actually probe: they hand you a `for` loop calling `getItems()` and ask "how many queries does this make, and where does the time go?" The senior answer connects the design smell (feature envy, Law of Demeter) to a concrete number (1,001 queries, ~1 s extra) and then fixes the _layer_, not the loop. - -### The resource behind the method call - -Every `repository.findById` is a claim on a bounded resource — a connection-pool slot and a worker thread — so interface design has a concurrency bill too. Size the pool with Little's law, the same way you size a thread pool: `pool_size = throughput × per-call time`. At 2,000 req/s with 25 ms average DB time, that's 50 connections — not "200 because the box has 64 cores." A design that chases getters across aggregates spends that pool ten times faster than a design that answers one aggregate question per call. The N+1 above isn't just slow — it's a connection-pool burnout vector, because each lazy load holds a connection while it re-queries. The bounded resource is the real subject; the getter chain is just how you overspend it. - -## 5. The functional-Java trap — when OOP, when functional, and what it costs - -"OOP is dead, long live functional programming" is a red flag. So is "OOP forever, streams are unreadable." The senior position: **they are different tools for different invariants.** - -- **Streams / functional composition** for data **transforms** — pipelines over collections, mapping/filtering/reducing — where the data is transient and there is no state to protect. -- **OOP / encapsulation** for **behavior-rich state** — aggregates, money, orders, caches — where the invariant ("an order can't be paid twice," "a connection is either open or closed") lives behind methods, not exposed fields. - -The anti-pattern to name is the **anemic domain model**: entities reduced to getters/setters with all logic hoisted into `*Service` classes. It's convenient for JPA and comfortable for beginners, but the invariants stop living anywhere — `setStatus(CANCELLED)` works on a shipped order, `balance` can go negative, and the "rules" are scattered across twelve services. The senior move isn't "make everything rich" (persistence mapping fights you); it's **guarding the state transitions that matter**: - -```java -// WRONG — the invariant lives in nobody's code. Any caller can do this: -order.setStatus(OrderStatus.CANCELLED); -order.setPaidAt(null); - -// RIGHT — the transition is a method that enforces the rule. -order.cancel("out of stock"); // throws if already shipped, sets cancelledAt -order.pay(amount); // throws if already paid -``` - -### What the functional style actually costs — the numbers - -It's fashionable to say "streams are free." Almost true — and here is why, with the numbers interviewers respect: - -``` -The pipeline allocates: a Stream, lambdas, a Spliterator, an accumulator -ArrayList. That sounds wasteful — but allocation on a modern JVM is a TLAB -pointer bump (no lock, no system call), so the JVM happily does tens of -millions of throwaway allocations per second. Escape analysis lets the JIT -scalar-replace the short-lived objects, and young-gen GC copies only the -surviving ~10%, so the pause is dominated by live bytes copied, not by the -transforms. Net: a clean stream chain is effectively free against the pause -budget. The GC tax you actually fear comes from objects that escape into -long-lived collections — i.e., a design that *retains* what a transform -produced. -``` - -The real cost of abstraction isn't GC — it's **dispatch**. A monomorphic call site (one concrete receiver type) is inlined by the JIT, and the "interface call" costs nothing. A **megamorphic** site (a hot loop dispatching over many implementers — say, the strategy registry from section 1) costs ~3–5 ns per call **and blocks inlining of the body**, which can cost 10× more than the dispatch itself. That's why an interface with 40 implementers is also a JIT problem, not just a design smell. `-XX:+PrintInlining` is the tool that proves it. "How expensive is an interface call?" → "inline-able: ~free; megamorphic: a few ns plus a missed optimization — and the missed optimization is the real bill." - -And one API-design trap to name: `Optional`/`Stream` as a substitute for a clear contract. `Optional>` is a type-level lie — an empty list already encodes "none" — and `null` returns hide bugs. The return type _is_ part of your API; design it the way you design the interface. Make the empty case explicit, never ambiguous. - -## 6. Production failure modes of "clean code" - -The deepest senior trap is applying design principles so zealously that they become the incident. Interviewers love this section because everyone has seen the aftermath. - -- **Premature abstraction.** The three-layer tower for a lookup: `Controller → Service interface → Service impl → Mapper interface → Mapper impl → Repository`, where the service has one method and one caller. Every change now touches five files, and the interfaces are lies. The rule of thumb: **an abstraction earns its keep** — one consumer, zero test doubles, and no second impl on the roadmap means delete the interface, not add a sixth layer. Adding abstraction is debt you take on, not a virtue you apply. - -- **Circular dependency as an architecture smell.** Two packages that import each other — `orders` needs `payments`, `payments` needs `orders` — is not a Spring config problem, it's a missing boundary. The fix is DIP at the _module_ level: the higher-level concept (the domain) declares a port, and the other implements it. If you find yourself describing "we fixed the cycle with Spring `@Lazy`," the cycle is still there — you just stopped noticing. - -- **Unguarded invariants.** The flip side of the anemic model: a "rich" aggregate whose setters are `public` so the ORM can hydrate it — which means every caller can also mutate it. The senior move is explicit transitions (section 5) and/or making the state immutable once constructed. An invariant nobody enforces is not a design; it's a bug farm. - -- **The flexible API that's unconstrainable.** "Let's be flexible: a generic `process(Map params)`." Now every caller invents its own keys, typos pass silently, and there is no compile-time contract at all. Type-safety is a feature of an interface; the moment you accept `Map`, you've traded the compiler's help for a runtime `ClassCastException` farm. A senior _narrows_ interfaces; it never widens them. - -- **Interfaces that drift from the code.** The `UserService` interface whose impl gained ten methods that were never added to the interface — callers end up casting or using reflection. If the interface isn't the only entry point, the abstraction is decorative. Delete it or make it real. - -## 7. Self-check - -- [ ] Apply OCP to a feature request without editing the old class — and name when OCP is the wrong tool. -- [ ] Explain DIP with the "who owns the contract" framing, a Spring example, and the interface-explosion counter-trap. -- [ ] Show the `ColoredPoint` equals trap, and why `List` is not a `List`. -- [ ] Give a real case where inheritance bit you (fragile base class) and the composition fix. -- [ ] Contrast sealed + pattern matching vs a strategy registry — when is each right, and who adds the 4th variant? -- [ ] When is a record the right value object, and when does it violate the equality contract? -- [ ] Count the queries in a `getItems()` loop, and fix it at the right layer (SQL vs DTO projection). -- [ ] Explain what a megamorphic call site costs, and how to prove it with `-XX:+PrintInlining`. -- [ ] Find the anemic domain model in a snippet and guard the invariant that matters. -- [ ] Name three ways "clean code" turns into a production incident. - -## 8. Interviewer follow-ups - -When your first answer lands, they start drilling. Be ready for these: - -- "We add a 5th payment method next sprint. Walk me through the exact files you touch — and why that design was the right axis." -- "You injected `PaymentGateway`. Who wrote that interface, and what happens when Stripe changes their SDK?" -- "`ColoredPoint extends Point` — find the bug in 30 seconds. Now fix it without breaking `HashSet`." -- "Is `Stack` a bad `Vector` subclass? What's the general rule that catches it?" -- "Sealed interface with three records, or a strategy map with three handlers — which do you build, and who adds the 4th variant?" -- "Your `record Money` has `add`. Why is that a good record, if 'records with logic are a smell' is too simple?" -- "This loop calls `order.getItems()` a thousand times. Count the queries, and tell me where the second is actually spent." -- "You claim abstraction is nearly free at runtime. Prove it — what does the JIT do to a monomorphic call site, and what breaks inlining?" -- "Your `OrderService` is 400 lines with five responsibilities. What do you extract first, and why does the order matter?" -- "Every change to `BaseRepository` breaks three subclasses. Rebuild it — but don't tell me inheritance is evil." - -That's the OOP bar for senior. +- [ ] Junior: I can name the four pillars, abstract class vs interface, override vs overload, and the `equals`/`hashCode` contract. +- [ ] Mid: I can spot fragile-base-class, misuse of each SOLID letter, anemic models, and when `record` fits. +- [ ] Senior: I can refactor inheritance→composition with a cost/benefit, apply LSP to a real violation, and defend an interface design by the size of the change when requirements shift. diff --git a/src/data/blog/vi/interview/oop-senior.md b/src/data/blog/vi/interview/oop-senior.md index b0aa3b8..017ecbc 100644 --- a/src/data/blog/vi/interview/oop-senior.md +++ b/src/data/blog/vi/interview/oop-senior.md @@ -1,6 +1,6 @@ --- -title: "Phỏng vấn Senior Java: OOP và nguyên lý thiết kế" -description: "OOP cấp senior là áp dụng SOLID, composition over inheritance, và thiết kế interface khi scale — không phải đọc định nghĩa." +title: "Ôn thi Java #2: OOP & Nguyên lý Thiết kế — Junior đến Senior" +description: "OOP ở mức senior là áp dụng SOLID, composition over inheritance, và thiết kế interface quy mô lớn — không phải đọc thuộc định nghĩa. Junior gọi tên nguyên lý; senior chỉ được chỗ mỗi nguyên lý làm bạn tốn gì." pubDatetime: 2026-08-10T10:05:00+07:00 featured: false draft: false @@ -11,402 +11,77 @@ tags: - design-principles --- -Lập trình hướng đối tượng là chiếc vé vào cửa. Junior đọc thuộc "class là một bản thiết kế" và "SOLID là năm chữ cái." Senior coi thiết kế như **kỹ thuật kết cấu**: mọi quyết định đều có đường truyền tải lực, một kiểu hỏng hóc, và một cái giá phải trả — và buổi phỏng vấn là bài kiểm tra xem bạn có biện minh được cho những bức tường mà bạn chọn để lại không. +OOP là phần phỏng vấn nơi interviewer ngừng hỏi "cái gì" và bắt đầu hỏi "tại sao". Ai cũng gọi được bốn trụ cột; một senior kể được lần kế thừa làm họ đau gần nhất và tại sao họ refactor sang composition. Bài này leo từ sách giáo khoa lên bảng trade-off. -> Tư duy: khi phỏng vấn viên nói "team của anh cần một tính năng mới," câu trả lời senior không bao giờ là "thêm một nhánh `if`." Nó là "trục thay đổi này nằm ở đâu — và tôi dựng thứ gì mà mai tôi không phải sửa?" Định nghĩa thì qua cửa junior; **quyết định dưới ràng buộc** mới qua vạch senior. +> Mindset: junior implement interface; senior quyết định interface đó có nên tồn tại không, và nó tốn gì cho 5 năm tiếp theo của codebase. -## 1. SOLID — bản áp dụng thực tế, và các cái bẫy +## Junior — nền tảng -"Nêu định nghĩa SOLID" chỉ là màn sàng lọc junior. Senior thì bị hỏi cách **áp dụng**, rồi bị ép bảo vệ những chỗ mà áp dụng theo sách vở là sai. Đi qua cả năm, nhưng hãy sẵn sàng đào sâu ba cái thực sự cắn trên production: Open/Closed, Dependency Inversion, và Liskov. +**Q1. Bốn trụ cột của OOP là gì?** +Encapsulation (che giấu state sau hành vi), Abstraction (bộc lộ ý định, không phải cơ chế), Inheritance (tái dùng bằng chuyên biệt hóa), Polymorphism (một interface, nhiều implement). Bẫy: gọi tên chúng chẳng tốn gì; áp dụng mà không tạo ra hierarchy giòn mới là kỹ năng thật. -### Open/Closed — trục thay đổi nằm ở đâu +**Q2. Khác nhau giữa abstract class và interface?** +Abstract class giữ được state và implement method; một class chỉ extend một class. Interface là hợp đồng — trước Java 8 chỉ có signature, nay có thể chứa `default` và `static` method nhưng không có field (trừ hằng `public static final`). Từ Java 8 bạn `implement` nhiều interface nhưng chỉ extend một class. Ưu tiên interface cho _type_, abstract class chỉ khi cần state/behavior chung. -Sách giáo khoa nói "mở cho extension, đóng cho modification." Câu hỏi thật là **trục nào thay đổi nhanh nhất** — OCP là một chiến lược cho phần không ổn định của hệ thống, không phải đạo luật toàn cục. Áp nó trước tiên ở nơi bạn thêm case mới mỗi sprint: +**Q3. Polymorphism là gì và hoạt động ra sao trong Java?** +Subtype polymorphism: biến kiểu supertype trỏ đến mọi subtype, runtime dispatch method bị override. Dispatch là virtual mặc định — `Animal a = new Dog(); a.speak()` gọi `Dog.speak()`. Overloading _không_ phải polymorphism (giải quyết tại compile bởi signature). -```java -// SAI — mỗi phương thức thanh toán mới là một nhánh nữa trong tháp if/else này. -// Thêm Apple Pay đồng nghĩa với sửa pay() — sửa vào code đang chạy tốt, một -// cuộc xung đột merge trên cùng mấy dòng code mỗi sprint, một mặt diện tích -// regression lớn dần theo từng tính năng. -PaymentResult pay(Order order) { - if (order.method() == PaymentMethod.CARD) return cardGateway.charge(order); - else if (order.method() == PaymentMethod.BANK) return bankGateway.transfer(order); - else if (order.method() == PaymentMethod.WALLET) return walletGateway.pay(order); - throw new UnsupportedOperationException(order.method().name()); -} +**Q4. Khác nhau giữa override và overload?** +Overriding: cùng signature ở subclass, runtime-dispatched (`@Override`). Overloading: cùng tên, tham số khác kiểu, giải quyết tại compile. Bẫy kinh điển: overload với tham số `Object` vs `String` — `foo(null)` bất định và không compile nếu cả hai tồn tại. -// ĐÚNG — cái registry chính là điểm mở rộng. Một phương thức mới = một class -// mới cộng một dòng đăng ký. Bảng phân phối thì ổn định; tập các strategy -// thì lớn dần. -Map handlers = Map.of( - PaymentMethod.CARD, new CardHandler(cardGateway), - PaymentMethod.BANK, new BankHandler(bankGateway), - PaymentMethod.WALLET, new WalletHandler(walletGateway) -); -PaymentResult pay(Order order) { - return handlers.get(order.method()).handle(order); -} -``` +**Q5. Hợp đồng `equals`/`hashCode` đòi gì?** +Nếu `a.equals(b)` thì `a.hashCode() == b.hashCode()`. Chiều ngược không bắt buộc nhưng hashCode bằng nhau nên hiếm. Override `equals` thì PHẢI override `hashCode`, nếu không object hỏng trong `HashMap`/`HashSet` (hai key bằng rơi vào bucket khác). -> Phỏng vấn viên thực sự câu gì: "bọn anh sắp thêm phương thức thanh toán thứ 4 — dẫn tôi qua từng bước thay đổi." "Thêm một `else if`" thì trượt phép thử OCP. "Registry thêm một entry; class mới implement cái contract" thì qua — rồi họ lập tức hỏi câu phản pháo bên dưới. +**Q6. Khác nhau giữa `abstract` và `default` method của interface?** +Abstract class method có thân, subclass có thể override hoặc không. Interface `default` cung cấp behavior class kế thừa mà không cần implement — dùng cho tiến hóa API tương thích ngược (vd `Collection.removeIf`). Override `default` ở class implement để đổi nó. -**Bẫy ngược — OCP không miễn phí.** Một registry gồm toàn strategy một-method chỉ là nghi thức rỗng nếu tập này không bao giờ lớn lên. Và pattern matching đã đổi cả bài toán: với một **sealed enum**, một `switch` đầy đủ cũng _đóng_ cho modification — thêm một giá trị mà không xử lý nó thì build vỡ thay vì runtime vỡ: +## Mid — tradeoff & điểm mù -```java -// ĐÚNG (phương án thay thế) — sealed domain: compiler buộc phủ kín mọi case. -// Thêm PaymentMethod.BITCOIN làm build fail cho tới khi mọi switch xử lý nó. -PaymentResult pay(Order order) { - return switch (order.method()) { - case CARD -> cardGateway.charge(order); - case BANK -> bankGateway.transfer(order); - case WALLET -> walletGateway.pay(order); - }; -} -``` +**Q1. Khi nào inheritance là công cụ sai?** +Khi quan hệ không phải "is-a" với behavior chia sẻ ổn định. Inheritance ghép subclass vào implementation của parent mãi mãi — đổi superclass thì mọi subclass vỡ. "Fragile base class": thay đổi có vẻ an toàn ở superclass âm thầm đổi behavior subclass. Hãy với tới **composition** (wrap dependency, delegate) khi code chia sẻ là "has-a" thay vì "is-a". -Dấu hiệu senior là gọi tên hai chế độ và chọn một cách có chủ đích. **Closed world** (sealed + exhaustive switch): domain hiếm khi đổi và đổi toàn bộ cùng lúc, và bạn muốn bằng chứng ở mức compile-time rằng mình không sót case nào. **Open world** (strategy registry, `ServiceLoader`, plugin class): bên thứ ba hoặc runtime thêm biến thể một cách độc lập, và exhaustiveness ở mức compile-time chỉ là một lời nói dối. Dựng cả một framework plugin cho một enum hai case chính là cái over-engineering mà phỏng vấn viên chăm chăm săn. +**Q2. Giải thích SOLID ngắn gọn và một misuse thật của mỗi cái.** -### Dependency Inversion — ai là chủ của cái interface +- **S**ingle Responsibility: class đổi vì một lý do. Misuse: `UserService` vừa send email vừa ghi audit log — ba lý do để đổi. +- **O**pen/Closed: mở cho mở rộng, đóng cho sửa đổi. Misuse: `switch(type)` bạn sửa mỗi khi có type mới. +- **L**iskov: subtype phải thay thế được. Misuse: `Square extends Rectangle` rồi `setWidth` phá invariant của rectangle. +- **I**nterface Segregation: nhiều interface nhỏ hơn một interface béo. Misuse: interface `Worker` ép `cleanToilet()` lên `Programmer`. +- **D**ependency Inversion: phụ thuộc abstraction, không phải concretion. Misuse: `new MySQLRepository()` hardcode trong service. -Dependency **Injection** là đưa một dependency vào. Dependency **Inversion** là quyết định **ai viết ra cái hợp đồng**. Hai thứ không giống nhau, và gộp chúng làm một là câu trả lời trung bình phổ biến nhất. +**Q3. Khác nhau giữa `Comparator` và `Comparable`?** +`Comparable` định nghĩa thứ tự _tự nhiên_ của type (`compareTo`, một định nghĩa). `Comparator` là chiến lược sắp xếp _bên ngoài_ (truyền vào `sort`, nhiều cái tồn tại). Dùng `Comparable` cho mặc định hiển nhiên; `Comparator` khi sort tùy ngữ cảnh (theo tên, theo ngày, giảm dần). -Cái khung ổ-cắm: ổ cắm trên tường là một chuẩn **thuộc về tòa nhà**, và mọi thiết bị phải vừa với nó — cái đèn không được tự phát minh ra loại ổ riêng rồi bắt tường đổi theo. DIP cũng vậy. `OrderService` (bên tiêu thụ, tức tòa nhà) khai báo `PaymentGateway` (cái ổ). SDK của Stripe là thiết bị — một **adapter** implement cái port của bạn. Đó là lý do nó được gọi là _inversion_: module cấp cao định nghĩa abstraction mà module cấp thấp phải implement, chứ không phải ngược lại. +**Q4. Tại sao getter/setter không phải encapsulation thật?** +Cặp getter/setter public không có invariant chỉ là public field với thêm bước — state vẫn mở toang. Encapsulation thật bộc lộ _hành vi_: `account.withdraw(amount)` thay vì `account.setBalance(x)`. Object bảo vệ invariant; caller yêu cầu kết quả, không mutate field trực tiếp. Anemic domain model (entity chỉ có getter/setter) là code smell. -```java -// SAI — bên tiêu thụ vươn tay ra thế giới và chộp một thứ cụ thể. -// Domain đơn hàng giờ phụ thuộc SDK của Stripe — lúc compile, lúc test, -// và mãi mãi. -class OrderService { - private final StripeGateway gateway = new StripeGateway(); - PaymentResult pay(Order order) { return gateway.charge(order); } -} +**Q5. `==` trên object — identity vs equality, và boxed type?** +Góc OOP: hai `Integer` từ `valueOf` trong cache -128..127 so `==` true; ngoài ra false. Dựa vào `==` cho boxed type là bug tiềm ẩn. Luôn `equals` cho so sánh giá trị wrapper, và cẩn thận autoboxing allocation trong hot loop. -// ĐÚNG — bên tiêu thụ sở hữu port; adapter tuân theo nó. -// Stripe có thể biến mất ngay tối nay mà domain không phải recompile. -class OrderService { - private final PaymentGateway gateway; - OrderService(PaymentGateway gateway) { this.gateway = gateway; } - PaymentResult pay(Order order) { return gateway.charge(order); } -} +**Q6. Khi nào dùng `record` (Java 16+)?** +Khi type là _data carrier_: immutable, `equals`/`hashCode`/`toString` tự sinh, mọi field final. Hoàn hảo cho DTO, API response, value object. Đừng dùng `record` khi cần mutable state, inheritance, hay behavior phức tạp — đó là class. `record Point(int x, int y)` là đủ cho tọa độ; `BankAccount` không phải record. -interface PaymentGateway { PaymentResult charge(Order order); } -class StripeGatewayAdapter implements PaymentGateway { /* delegate sang SDK */ } -``` +## Senior — thiết kế & phòng thủ -Đây là _lý do_ Spring tồn tại — không phải để "làm DI cho dễ," mà để nối adapter với port sao cho domain được sạch. Nhưng phần thưởng thật không nằm ở framework, mà nằm ở **khe hở cho test**: +**Q1. Phòng thủ composition over inheritance bằng một refactor cụ thể bạn sẽ làm.** +"Tôi lấy `ReportGenerator extends ExcelWriter` và lật nó: `ReportGenerator` giữ một `Writer` (interface) để delegate. Lý do: coupling Excel nghĩa mọi đổi định dạng spreadsheet rủi ro logic report, và ta không test đc report không có spreadsheet thật. Composition cho phép inject `FakeWriter` trong test và thêm `PdfWriter` không sửa gì `ReportGenerator`. Cái giá: một interface thêm và một constructor arg — bảo hiểm rẻ trước fragile base class." -> Chuyện production: `OrderService` tự `new StripeGateway()` trong constructor, nên nhánh retry-khi-timeout không unit-test được — test nào cũng phải đập vào sandbox của Stripe hoặc patch tĩnh một chỗ nào đó. Đó không phải bài toán testing, đó là một vi phạm DIP đang hiện hình thành bài toán testing. Ngày dependency trở nên injectable, cái integration test rởm kia thành một unit test ba dòng với một fake. +**Q2. Một team muốn base `BaseEntity` 30 field và mọi JPA entity extend nó. Bạn nói sao?** +"Tôi sẽ tách. `BaseEntity` thật (id, version, createdAt, updatedAt, auditing) thì ổn — đó là 'is-a' với state chia sẻ ổn định. Nhưng 30 field nghĩa nó là mớ hỗn độn; subtype thừa kế column không dùng, query rộng hơn, và đổi một chỗ dội khắp nơi. Tôi đẩy 26 field domain-specific xuống entity sở hữu chúng, giữ `BaseEntity` chỉ 4 field audit. Thắng được đo: table hẹp hơn, ownership rõ hơn, coupling tình cờ ít hơn." -**Bẫy phía bên kia — bùng nổ interface.** DIP không có nghĩa "móc interface ra cho mọi class." Một interface `UserService` với đúng một implementation, một bên tiêu thụ, và không có test double chỉ là nghi thức có thuế: mỗi thay đổi đụng hai file, và cái interface là một lời nói dối chờ ngày lệch pha với impl. Quy tắc thật lòng: một abstraction phải **tự nuôi sống mình** — một implementation thứ hai, một test double, hoặc một ranh giới hợp đồng với hệ thống bên ngoài. Không có thứ đó thì viết class cụ thể và inject dependency của nó. +**Q3. Bạn thiết kế interface sao để sống sót 5 năm yêu cầu mới?** +"Tôi giữ nó nhỏ và behavioral, không phải CRUD dump. Tôi ưu tiên hierarchy `sealed` (Java 17+) khi tập subtype đóng — compiler ép xử lý mọi case trong `switch`, nên thêm subtype là compile error đến khi bạn lo xong mọi chỗ. Tôi bộc lộ capability qua interface hẹp (`Readable`, `Flushable`) thay vì một `MegaService`. Và tôi chỉ dùng `default` cho behavior thực sự tùy chọn, không bao giờ lén chèn state." -### Liskov — cái thực sự nổ trên production +**Q4. Liskov Substitution — đi qua một violation thật và cách fix.** +"Kinh điển `Square extends Rectangle`: set width phải cũng set height để giữ hình vuông, nhưng phá hợp đồng `Rectangle` rằng width/height độc lập. Mọi code `r.setWidth(5); r.setHeight(10); assert r.area()==50` giờ nói dối. Fix: đừng model square là subtype của rectangle — trích `Shape` với `area()` và implement cả hai độc lập, hoặc dùng một `Rectangle` cấm zero/negative và biểu diễn vuông bằng w==h. Subtyping là lời hứa; giữ không được thì đừng hứa." -LSP là nơi định nghĩa chết, vì vi phạm vô hình trong code review và phát nổ lúc runtime bên trong một `HashMap`. Hợp đồng: một subtype phải dùng được ở mọi nơi mà supertype được hứa hẹn — **precondition không được tăng thêm, postcondition không được nới lỏng, invariant phải được giữ.** Hai ví dụ đúng chuẩn production. +**Q5. Bạn có interface 12 method nhưng hầu hết caller dùng 2. Thiết kế lại?** +"Đó là vi phạm Interface Segregation. Tôi tách thành role tập trung: `Reader` (read), `Writer` (write), `Lifecycle` (start/stop), và class cụ thể implement cả ba nếu cần. Caller chỉ phụ thuộc thứ họ dùng, nên đổi `Writer` không bao giờ recompile consumer read-only. Class implement không đổi behavior; chỉ các _type_ nó bộc lộ ra mới hẹp lại. Việc này cũng làm mock trong test tầm thường — bạn stub 2 method mình quan tâm." -**Bẫy đối xứng của `equals`.** Thêm state vào subclass và `equals` lặng lẽ trở nên bất đối xứng, làm hỏng hành vi `Set`/`Map`: +**Q6. Làm sao chứng minh OOD của bạn tốt, không chỉ 'sạch' trong interview?** +"Tôi chỉ vào change vừa làm và cái giá của alternative: đếm lý do mỗi class đổi, số call site vỡ khi requirement shift, và test surface. OOD tốt nghĩa feature mới chạm một class, không phải mười hai. Tôi phác dependency graph — nếu nó là DAG với abstraction ổn định ở trên và detail volatile ở dưới (dependency inversion), đó là bằng chứng. Không phải 'tôi dùng SOLID', mà 'đây là diff khi requirement đổi, và nó nhỏ'." -```java -class Point { - final int x, y; - Point(int x, int y) { this.x = x; this.y = y; } - @Override public boolean equals(Object o) { - return o instanceof Point p && p.x == x && p.y == y; - } - @Override public int hashCode() { return 31 * x + y; } -} +#### Self-check -class ColoredPoint extends Point { - final Color color; - ColoredPoint(int x, int y, Color c) { super(x, y); this.color = c; } - @Override public boolean equals(Object o) { - if (!(o instanceof ColoredPoint)) return false; // ← bất đối xứng - return super.equals(o) && ((ColoredPoint) o).color == color; - } -} - -Point p = new Point(1, 2); -ColoredPoint cp = new ColoredPoint(1, 2, Color.RED); -p.equals(cp); // true — Point bỏ qua màu -cp.equals(p); // false — ColoredPoint đòi màu → đối xứng vỡ -``` - -```java -// ĐÚNG — composition thay vì inheritance: ColoredPoint KHÔNG phải là một -// Point, nó CÓ một Point. Không có subtype, không vỡ hợp đồng, không bất -// ngờ trong một HashSet. -record ColoredPoint(Point point, Color color) {} -``` - -**Covariance và contravariance.** LSP rò rỉ vào cả hệ thống type. Mảng **covariant và reified** — `Object[]` có thể chứa một `String[]`, và lỗi chỉ hiện lúc runtime: - -```java -Object[] objs = new String[10]; -objs[0] = 42; // compile: ngon → runtime: ArrayStoreException -``` - -Generics thì **invariant** — compiler chặn cùng một bug trước khi nó ra mắt: - -```java -List words = new ArrayList<>(); -List objects = words; // compile error — invariant -List any = words; // covariance qua bounded wildcard (chỉ đọc) -List sink = new ArrayList(); // contravariance (chỉ ghi) -``` - -Nhớ **PECS** — producer `extends`, consumer `super` — và nói thẳng ra rằng `List` không phải là `List` dù `Dog` là một `Animal`: quan hệ "is-a" trên type không truyền sang các generic container, vì hợp đồng của một container có thể biến đổi ("mày có thể thêm bất kỳ `Animal` nào") sẽ bị subtype làm cho yếu đi. - -**Subtype ném exception.** Một subclass override method chỉ để ném — "tôi chỉ để `add()` ném cho cái collection đặc biệt này" — là vi phạm LSP vì nó thắt chặt precondition. Hình dạng đúng là một **decorator** (`Collections.unmodifiableList`) mà fail nhanh và to tiếng, không phải một subtype giả vờ có thể biến đổi. Phỏng vấn viên câu bằng: "làm sao anh có một list bất biến mà không vỡ hợp đồng?" - -### Single Responsibility và Interface Segregation — cáo phó của God object - -Hai cái này là một ý ở hai mức hạt khác nhau: **SRP nói về class, ISP nói về interface, và cả hai đều xoay quanh "một trục thay đổi duy nhất."** Một `OrderService` 500 dòng vừa là repository, vừa là validator, vừa là orchestrator, vừa là mapper thì đổi vì bốn lý do khác nhau và không thể suy luận nổi. Một interface `UserService` 40 method buộc mọi implementer phải stub 35 method — và tệ hơn, buộc mọi _caller_ phải nhìn thấy 40 khả năng mà nó không được phép dùng. - -```java -// SAI — một interface béo ú. Mọi implementer stub 35 method; -// mọi caller phụ thuộc 40 khả năng. -interface UserService { - User findById(long id); - void update(User u); - byte[] exportAuditReport(Period p); // thứ này ở đây làm gì? - void sendWelcomeEmail(long id); // hay cái này? - List search(String q, Page p); - // ... thêm 35 cái nữa -} - -// ĐÚNG — role interface. Một caller phụ thuộc đúng lát cắt nó cần; một class -// implement nhiều role và không method nào là gánh nặng chết. -interface UserReader { User findById(long id); } -interface UserWriter { void update(User u); } -interface AuditExporter { byte[] exportAuditReport(Period p); } - -class UserServiceImpl implements UserReader, UserWriter, AuditExporter { ... } -``` - -> Phỏng vấn viên thực sự câu gì: "đây là một service 400 dòng — làm sao anh biết nó sai trước khi đọc tới dòng 300?" Câu trả lời senior không phải cái interface; mà là gọi tên **ba trục thay đổi** trong nó. Nếu kể ra được thì SRP không chỉ là khẩu hiệu. - -## 2. Composition over inheritance — fragile base class ngoài đời thực - -"Vì sao favor composition?" Câu trả lời junior là "inheritance xấu." Câu trả lời senior là một vụ việc cụ thể: một thay đổi ở class cha lặng lẽ làm vỡ năm mươi subclass vốn đã đặt giả định về `super` mà class cha chưa bao giờ hứa hẹn. - -Vấn đề fragile base class là cấu trúc, không phải phong cách. Inheritance gắn bạn vào **implementation** của cha, không phải hợp đồng của nó: bạn thừa kế các field `protected`, bạn gọi `super`, và method của cha gọi các hook (`afterPut`) theo một thứ tự mà subclass không hề viết ra. Class cha không thể đổi nội bộ mà không đặt rủi ro lên mọi subclass, và subclass không thể suy luận về hành vi của chính mình mà không đọc code cha. Chúng bị hàn dính vào nhau. - -```java -// SAI — một base class đầy rẫy coupling ẩn. MetricsCounter tin rằng afterPut -// được gọi đúng một lần mỗi put, đúng thứ tự. Bản phát hành tới của -// AbstractCache thêm hook thứ hai, đảo thứ tự gọi, hoặc bỏ qua afterPut khi -// dedup — và MetricsCounter lặng lẽ đếm sai. Chẳng code của ai "bị đổi" cả. -abstract class AbstractCache { - private final Map store = new HashMap<>(); - public final void put(String k, byte[] v) { - store.put(k, v); - afterPut(k, v); - } - protected void afterPut(String k, byte[] v) {} -} - -class MetricsCounter extends AbstractCache { - @Override protected void afterPut(String k, byte[] v) { metrics.increment("puts"); } -} - -// ĐÚNG — hành vi được lắp ráp, không được thừa kế. Decorator bọc delegate -// và caller tự chọn chồng lớp. Không subclass nào phụ thuộc nội bộ của -// class khác; mỗi hành vi test độc lập được. -interface Cache { void put(String k, byte[] v); } - -class MetricCache implements Cache { - private final Cache delegate; - MetricCache(Cache delegate) { this.delegate = delegate; } - public void put(String k, byte[] v) { - long start = System.nanoTime(); - delegate.put(k, v); - metrics.record("cache.put.ns", System.nanoTime() - start); - } -} - -Cache cache = new MetricCache(new TtlCache(new MemCache())); -``` - -Inheritance còn vỡ ở cấp hợp đồng: `ColoredPoint extends Point` (mục 1) là một bài toán inheritance đang đội lốt bài toán `equals` — thêm state vào subclass là con đường phổ biến nhất để vi phạm LSP mà không hề hay biết. Và vụ `Stack extends Vector` là case kinh điển trong sách giáo khoa: một stack _không phải_ là một vector, và thừa kế `add(int, E)` cho phép caller chèn vào giữa stack. "Is-a" phải đứng vững ở thế giới thật, chứ không chỉ ở sơ đồ UML. - -**Khi nào inheritance là đúng** — nói to ra, đây là điểm khác biệt. Inheritance là công cụ, không phải tội lỗi. Dùng nó khi subclass là một chuyên biệt hóa thật sự cung cấp **hook, không phải hành vi**: Template Method. `JdbcTemplate` để bạn nạp `RowMapper`, `AbstractMessageConverter` của Spring để subclass điền `supports`/`writeInternal`, `HttpServlet` để override `doGet`. Class cha nắm luồng chảy (bộ xương) và subclass lấp các khe, và hợp đồng của cha được phát biểu rõ ràng. Failure mode nằm ở chiều ngược lại: một subclass _override cả method_ rồi gọi `super` lên nó là đang đánh nhau với cha, và đó chính là mùi hôi. - -**Cái giá thật của composition.** Đừng bán quá tay: wrapping đồng nghĩa với boilerplate delegation, stack trace sâu hơn, và một đồ thị runtime khó lần theo ("trong năm cái decorator này, đứa nào làm rớt cache line của tôi?"). Đánh đổi của senior là chọn mức hạt — compose ở chỗ trục thay đổi, delegate ở chỗ luồng chảy cố định, và không bao giờ decorate chỉ để cho có. - -## 3. Thiết kế interface khi scale — polymorphism ra ngoài sách giáo khoa - -Junior thấy interface là "một khuôn mẫu class." Senior thấy interface là một **hợp đồng có chủ** — và Java hiện đại đã đổi thứ mà hợp đồng ấy có thể diễn đạt. - -### Sealed types — closed world, được compiler ép buộc - -Trước Java 17, polymorphism mở theo mặc định: bất cứ ai cũng thêm được một `Shape`, và chuỗi `instanceof` (hoặc tháp `if/else`) cứ lớn dần. **Sealed interface** (Java 17) khóa thế giới lại một cách có chủ đích, và **pattern matching** (Java 21) khiến việc phân phối trở nên đầy đủ và được kiểm tra lúc compile: - -```java -sealed interface OrderEvent permits OrderPlaced, OrderPaid, OrderCancelled {} -record OrderPlaced(Long orderId, Instant at) implements OrderEvent {} -record OrderPaid(Long orderId, Money amount) implements OrderEvent {} -record OrderCancelled(Long orderId, String reason) implements OrderEvent {} - -String label(OrderEvent e) { - return switch (e) { - case OrderPlaced p -> "placed at " + p.at(); - case OrderPaid p -> "paid " + p.amount(); - case OrderCancelled c -> "cancelled: " + c.reason(); - // không cần default — compiler chứng minh switch đã đầy đủ - }; -} -``` - -`label` phân phối theo **hình dạng** (nó là record nào), chứ không theo một field `type` — và compiler loại bỏ bug "sót mất một case" mà một enum `type` cộng `if/else` luôn mang theo. Sealed hierarchy + records + pattern matching là câu trả lời của Java cho algebraic data types, và nó đánh bại cả kiểu phân phối stringly-typed lẫn cái registry strategy-như-nghi-thức trong phần lớn trường hợp. - -Sự phân biệt của senior (lặp lại mục 1): **sealed = closed world, đại số ổn định, exhaustiveness ở compile-time.** Strategy/plugin/`ServiceLoader` = **open world, biến thể cắm được, đăng ký lúc runtime.** Khi phỏng vấn viên nói "thiết kế phần xử lý event," điểm khác biệt nằm ở _ai_ được phép thêm biến thể và _khi nào_ phải bắt được lỗi — lúc compile hay lúc deploy. - -### Records, value objects, và hợp đồng equality - -Một `record` (Java 16+) là class mà hợp đồng danh tính là **toàn bộ các component** — `equals`/`hashCode`/`toString`/accessor được suy ra từ danh sách component, `final` ngay từ lúc dựng. Điều đó khiến record trở thành ngôi nhà tự nhiên cho value objects, và value objects có hành vi _thuần khiết_ thì hoàn toàn đúng: - -```java -record Money(long cents) { - Money { if (cents < 0) throw new IllegalArgumentException("negative money"); } // invariant ở ngay constructor - Money add(Money o) { return new Money(cents + o.cents); } - boolean isNegative() { return cents < 0; } - @Override public String toString() { return "%d.%02d".formatted(cents / 100, cents % 100); } -} -``` - -Sắc thái senior về "record mang logic": cái smell thật là một record điều phối các quy tắc nghiệp vụ **có trạng thái** hoặc xuyên đối tượng. Một `Money` tự kiểm tra invariant và tự định nghĩa số học của mình là record _tốt_; một event `OrderPlaced` vươn tay vào repository là record _xấu_. Giữ việc điều phối trong service; giữ giá trị nằm trong value. - -Và cái đánh đổi người ta hay bỏ sót: equality của record theo **mọi field**, đó là mặc định đúng cho ngữ nghĩa value và mặc định sai cho entity. Một `Order` "là cùng một order" theo `id` dù field có đổi **không được** làm record — equality của nó phải viết tay theo business key, nếu không nó sẽ làm hỏng `Set` và `Map`. Cùng bài học LSP như `ColoredPoint`, nhìn từ chiều ngược lại. - -## 4. Tell, don't ask — encapsulation có hóa đơn database - -"Tell, don't ask" nghe như lời khuyên phong cách. Ở cấp senior nó là một nguyên tắc **hiệu năng và đúng đắn**, vì một getter không miễn phí — nó có thể là một lazy-loaded proxy phát một câu query. - -Feature envy là triệu chứng: một caller thò tay xuyên qua aggregate, kéo collection ra, rồi bụng nó tính toán bằng những thứ đó. Đó vừa là smell thiết kế, vừa là một nhà máy sản xuất N+1 query. - -```java -// SAI — caller thò tay VÀO order và tính toán bằng nội tạng của nó. -// order.getItems() là một collection lazy-loaded: nó bắn một SELECT mỗi -// order. 1.000 order → 1.001 query. Với 5 dòng trong test thì ngon; ngoài -// prod với một triệu dòng thì chết — và không một query nào trông chậm, -// nên nó sống sót qua mọi slow-query log. -long totalItems = 0; -for (Order order : orders) { - totalItems += order.getItems().size(); -} - -// ĐÚNG — aggregate tự trả lời câu hỏi. Một ý định, không thò tay vào trong. -long totalItems = 0; -for (Order order : orders) { - totalItems += order.getLineItemCount(); -} -``` - -Nhưng "tell, don't ask" một mình là chưa đủ — một `getLineItemCount()` ngây thơ vẫn có thể lazy-load cả collection. Cách sửa của senior là quyết định **tầng nào trả lời câu hỏi**. Database đếm nhanh hơn JVM load: - -```sql --- cùng câu hỏi, được database trả lời trong một round trip. --- Một point lookup là một cú đi dọc B+tree: 3–4 lần đọc page, ~100 ns mỗi --- lần khi các page đang nóng trong buffer pool → dưới một mili-giây. Bản --- N+1 phía trên là 1.001 round trip mạng × ~1 ms mỗi trip ≈ một giây latency --- trọn vẹn mà không hề xuất hiện trong bất kỳ slow-query log đơn lẻ nào. -SELECT o.id, COUNT(li.id) -FROM orders o -LEFT JOIN line_items li ON li.order_id = o.id -GROUP BY o.id; -``` - -```java -// ĐÚNG — project đúng cái DTO bạn cần; đừng load graph entity. -@Query("select new OrderSummary(o.id, o.customerName, size(o.items)) from Order o") -List findAllSummaries(); -``` - -> Phỏng vấn viên thực sự câu gì: họ đưa bạn một vòng `for` gọi `getItems()` và hỏi "cái này bắn bao nhiêu query, và thời gian đi vào đâu?" Câu trả lời senior nối smell thiết kế (feature envy, Law of Demeter) với một con số cụ thể (1.001 query, ~1 s thêm vào) rồi sửa ở _tầng_, chứ không sửa vòng lặp. - -### Tài nguyên đứng sau mỗi method call - -Mỗi `repository.findById` là một khoản đòi trên một tài nguyên có hạn — một slot connection-pool và một worker thread — nên thiết kế interface cũng có hóa đơn concurrency. Cân pool theo định luật Little, giống hệt cách bạn cân thread pool: `pool_size = throughput × per-call time`. Ở 2.000 req/s với thời gian DB trung bình 25 ms, đó là 50 connection — không phải "200 vì máy có 64 core." Một thiết kế chạy theo getter xuyên qua các aggregate tiêu pool nhanh gấp mười lần một thiết kế trả lời một câu hỏi aggregate mỗi call. Cái N+1 phía trên không chỉ chậm — nó là một vector đốt cháy connection-pool, vì mỗi lazy load giữ một connection trong lúc nó query lại. Tài nguyên có hạn mới là chủ thể thật; chuỗi getter chỉ là cách bạn tiêu nó quá tay. - -## 5. Bẫy functional Java — khi nào OOP, khi nào functional, và nó tốn bao nhiêu - -"OOP chết rồi, functional programming muôn năm" là một lá cờ đỏ. "OOP muôn đời, stream khó đọc" cũng vậy. Lập trường senior: **chúng là công cụ khác nhau cho các invariant khác nhau.** - -- **Stream / composition functional** cho **transform** dữ liệu — pipeline trên collection, map/filter/reduce — nơi dữ liệu chỉ tồn tại tạm và không có trạng thái nào cần bảo vệ. -- **OOP / encapsulation** cho **trạng thái giàu hành vi** — aggregate, tiền, đơn hàng, cache — nơi invariant ("một order không thể được trả tiền hai lần," "một connection thì hoặc mở hoặc đóng") sống sau các method, chứ không phơi ra field. - -Anti-pattern phải gọi tên là **anemic domain model**: entity bị rút xuống còn getter/setter và mọi logic bị bốc lên các class `*Service`. Nó tiện cho JPA và dễ chịu cho người mới, nhưng các invariant hết nơi cư trú — `setStatus(CANCELLED)` chạy ngon lành trên một order đã ship, `balance` có thể âm, và các "luật" rải rác mười hai service. Nước đi senior không phải "làm mọi thứ giàu lên" (persistence mapping sẽ chống lại bạn); mà là **bảo vệ những chuyển tiếp trạng thái quan trọng**: - -```java -// SAI — invariant không sống trong code của ai. Caller nào cũng làm được: -order.setStatus(OrderStatus.CANCELLED); -order.setPaidAt(null); - -// ĐÚNG — chuyển tiếp là một method biết ép luật. -order.cancel("out of stock"); // ném nếu đã ship, set cancelledAt -order.pay(amount); // ném nếu đã trả -``` - -### Phong cách functional thực sự tốn bao nhiêu — những con số - -Thời thượng là nói "stream là miễn phí." Gần đúng — và đây là lý do, kèm các con số phỏng vấn viên nể: - -``` -Pipeline này cấp phát: một Stream, các lambda, một Spliterator, một -ArrayList accumulator. Nghe thì phí phạm — nhưng cấp phát trên JVM hiện đại -là một cú bump con trỏ TLAB (không khóa, không system call), nên JVM thoải -mái cấp phát hàng chục triệu object dùng một lần mỗi giây. Escape analysis -cho JIT scalar-replace các object sống ngắn, và young-gen GC chỉ copy phần -~10% sống sót, nên pause bị chi phối bởi số live byte được copy, không phải -bởi các phép transform. Kết luận: một chuỗi stream sạch gần như miễn phí so -với ngân sách pause. Cái thuế GC bạn thực sự sợ đến từ những object escape -vào các collection sống lâu — tức là một thiết kế *giữ lại* thứ mà một -transform đã sinh ra. -``` - -Cái giá thật của abstraction không phải GC — mà là **dispatch**. Một call site monomorphic (một kiểu receiver cụ thể) được JIT inline, và "interface call" chẳng tốn gì. Một site **megamorphic** (một hot loop phân phối qua nhiều implementer — ví dụ cái strategy registry ở mục 1) tốn ~3–5 ns mỗi call **và chặn việc inline phần thân**, có thể tốn gấp 10 lần chính cái dispatch. Đó là lý do một interface 40 implementer cũng là bài toán JIT, chứ không chỉ là smell thiết kế. `-XX:+PrintInlining` là công cụ chứng minh điều đó. "Interface call tốn bao nhiêu?" → "inline được: ~miễn phí; megamorphic: vài ns cộng một cơ hội tối ưu hóa bị bỏ lỡ — và cái cơ hội bị bỏ lỡ mới là hóa đơn thật." - -Và một bẫy thiết kế API phải nêu: `Optional`/`Stream` thay thế cho một hợp đồng rõ ràng. `Optional>` là một lời nói dối ở mức type — một list rỗng đã mã hóa "không có" rồi — và `null` trả về là nơi giấu bug. Kiểu trả về _là_ một phần của API; thiết kế nó như cách bạn thiết kế interface. Làm cho trường hợp rỗng trở nên hiển hiện, không bao giờ mập mờ. - -## 6. Các failure mode của "clean code" trên production - -Cái bẫy senior sâu nhất là áp dụng nguyên lý thiết kế quá hăng hái đến mức chính chúng trở thành sự cố. Phỏng vấn viên mê phần này vì ai cũng từng chứng kiến hậu quả. - -- **Premature abstraction.** Cái tháp ba tầng cho một phép tra cứu: `Controller → Service interface → Service impl → Mapper interface → Mapper impl → Repository`, mà cái service có một method và một caller. Mỗi thay đổi giờ đụng năm file, và các interface là những lời nói dối. Quy tắc nhớ nhanh: **một abstraction phải tự nuôi sống mình** — một consumer, không test double, không implementation thứ hai trong lộ trình thì xóa interface, chứ đừng thêm tầng thứ sáu. Thêm abstraction là khoản nợ bạn vay, không phải đức hạnh bạn ban phát. - -- **Vòng phụ thuộc như một smell kiến trúc.** Hai package import lẫn nhau — `orders` cần `payments`, `payments` cần `orders` — không phải bài toán cấu hình Spring, mà là một ranh giới bị thiếu. Cách sửa là DIP ở cấp _module_: khái niệm cấp cao hơn (domain) khai báo một port, và cái còn lại implement nó. Nếu bạn nghe ai đó mô tả "bọn em chữa vòng phụ thuộc bằng Spring `@Lazy`," vòng lặp vẫn còn đó — bạn chỉ không nhìn thấy nó nữa. - -- **Invariant không được canh giữ.** Mặt ngược của anemic model: một aggregate "giàu" mà setters để `public` cho ORM hydrate — đồng nghĩa mọi caller cũng mutate được nó. Nước đi senior là chuyển tiếp tường minh (mục 5) và/hoặc làm trạng thái bất biến ngay sau khi dựng. Một invariant không ai ép thi hành không phải là thiết kế; nó là một trang trại nuôi bug. - -- **API linh hoạt đến mức không thể ràng buộc.** "Cứ linh hoạt đi: một `process(Map params)` chung chung." Giờ mọi caller tự phát minh key riêng, typo lặng lẽ lọt qua, và chẳng còn bất kỳ hợp đồng nào ở mức compile. Type-safety là một tính năng của interface; khoảnh khắc bạn nhận `Map`, bạn đã đổi sự giúp đỡ của compiler lấy một trang trại `ClassCastException` lúc runtime. Một senior _thu hẹp_ interface; không bao giờ _nới_ nó ra. - -- **Interface lệch pha với code.** Interface `UserService` mà impl của nó thêm mười method chưa bao giờ được thêm vào interface — caller cuối cùng phải cast hoặc dùng reflection. Nếu interface không phải là lối vào duy nhất, thì abstraction chỉ để trang trí. Xóa nó đi hoặc làm cho nó thành thật. - -## 7. Tự kiểm tra - -- [ ] Áp dụng OCP cho một yêu cầu tính năng mà không sửa class cũ — và gọi tên khi OCP là công cụ sai. -- [ ] Giải thích DIP bằng khung "ai là chủ của hợp đồng," một ví dụ Spring, và bẫy ngược bùng nổ interface. -- [ ] Chỉ ra bẫy `equals` của `ColoredPoint`, và vì sao `List` không phải là `List`. -- [ ] Kể một case thật mà inheritance cắn bạn (fragile base class) và cách composition sửa nó. -- [ ] Đối chiếu sealed + pattern matching với strategy registry — mỗi cái đúng khi nào, và ai thêm biến thể thứ 4? -- [ ] Khi nào record là value object đúng, và khi nào nó vi phạm hợp đồng equality? -- [ ] Đếm số query trong một vòng lặp `getItems()`, và sửa ở đúng tầng (SQL vs DTO projection). -- [ ] Giải thích một call site megamorphic tốn bao nhiêu, và chứng minh bằng `-XX:+PrintInlining`. -- [ ] Tìm anemic domain model trong một đoạn code và canh giữ invariant quan trọng. -- [ ] Kể ba cách "clean code" biến thành sự cố production. - -## 8. Câu hỏi vặn vẹo tiếp theo của phỏng vấn viên - -Khi câu trả lời đầu của bạn đáp trúng, họ bắt đầu khoan sâu. Sẵn sàng cho những câu này: - -- "Sprint tới bọn anh thêm phương thức thanh toán thứ 5. Dẫn tôi qua từng file anh chạm vào — và vì sao thiết kế đó là trục đúng." -- "Anh inject `PaymentGateway`. Ai viết cái interface đó, và chuyện gì xảy ra khi Stripe đổi SDK?" -- "`ColoredPoint extends Point` — tìm bug trong 30 giây. Giờ sửa nó mà không làm vỡ `HashSet`." -- "`Stack` có phải là một subclass `Vector` tồi? Quy tắc chung nào bắt được nó?" -- "Sealed interface ba records, hay strategy map ba handlers — anh dựng cái nào, và ai thêm biến thể thứ 4?" -- "`record Money` của anh có `add`. Vì sao đó là record tốt, nếu 'record mang logic là smell' nghe quá đơn giản?" -- "Vòng lặp này gọi `order.getItems()` cả nghìn lần. Đếm số query, và chỉ tôi chỗ cái thứ hai thực sự được tiêu vào đâu." -- "Anh bảo abstraction gần như miễn phí lúc runtime. Chứng minh đi — JIT làm gì với một call site monomorphic, và thứ gì phá vỡ inline?" -- "`OrderService` của anh 400 dòng với năm trách nhiệm. Anh tách cái gì trước, và vì sao thứ tự lại quan trọng?" -- "Mọi thay đổi vào `BaseRepository` làm vỡ ba subclass. Xây lại nó — nhưng đừng có nói với tôi là inheritance là xấu." - -Đó là bar OOP cho senior. +- [ ] Junior: Tôi gọi được bốn trụ cột, abstract class vs interface, override vs overload, và hợp đồng `equals`/`hashCode`. +- [ ] Mid: Tôi bắt được fragile-base-class, misuse mỗi chữ SOLID, anemic model, và khi nào `record` hợp. +- [ ] Senior: Tôi refactor inheritance→composition có cost/benefit, áp dụng LSP vào violation thật, và phòng thủ thiết kế interface bằng độ lớn của change khi requirement shift. From e6508b1ea57b2d257f3e7aedbf6501a369b5983a Mon Sep 17 00:00:00 2001 From: Tester Date: Thu, 13 Aug 2026 06:31:40 +0000 Subject: [PATCH 3/8] =?UTF-8?q?docs(interview):=20rewrite=20spring-boot=20?= =?UTF-8?q?as=20Junior=E2=86=92Senior=20Q&A=20series=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../blog/en/interview/spring-boot-senior.md | 446 ++--------------- .../blog/vi/interview/spring-boot-senior.md | 448 ++---------------- 2 files changed, 93 insertions(+), 801 deletions(-) diff --git a/src/data/blog/en/interview/spring-boot-senior.md b/src/data/blog/en/interview/spring-boot-senior.md index bd1764e..17853dc 100644 --- a/src/data/blog/en/interview/spring-boot-senior.md +++ b/src/data/blog/en/interview/spring-boot-senior.md @@ -1,5 +1,5 @@ --- -title: "Senior Java Interview: Spring Boot" +title: "Java Interview Prep #3: Spring Boot — Junior to Senior" description: "Spring Boot is where Java backend seniors live. IoC/DI, the bean lifecycle, transaction management, and the auto-configuration magic interviewers expect you to see through." pubDatetime: 2026-08-10T10:30:00+07:00 featured: false @@ -11,426 +11,72 @@ tags: - backend --- -Most senior Java backend roles are Spring Boot roles. Interviewers expect you to understand the framework, not just use it — and the difference is audible in the first answer. A junior recites annotations. A senior narrates the call chain: how the proxy intercepts a `@Transactional` method, why self-invocation slips past it, why the bean lifecycle has two kinds of post-processors, and the night the connection pool emptied because a transaction held a connection while it called a slow partner API. +Spring Boot is where "I know Java" meets "I can run a backend". Junior developers autowire and hope; seniors understand the container, the proxy, and the transaction boundary. This post walks from `@Autowired` to "why is my `@Transactional` silently not working". -> Mindset: name the annotation and you're mid-level. Walk through the proxy internals with a production failure mode and the numbers, and you've cleared the bar. Every section below ends with the drill an interviewer actually runs. +> Mindset: junior uses the annotations; senior can draw the bean lifecycle and explain exactly when a proxy wraps their method — and when it doesn't. -## 1. IoC and DI — the container is a contract, not a drawer +## Junior — foundations -Inversion of Control is _who owns `new`_. Dependency Injection is _how the wiring gets delivered_. Together they answer "who constructs this object and when" — the container owns the graph, you declare dependencies, it satisfies them. The depth lives in the two decisions that fall out: how you accept a dependency, and what you hand to a bean that outlives its scope. +**Q1. What is IoC and DI in Spring?** +Inversion of Control: the framework, not your code, owns object creation and wiring. Dependency Injection is the mechanism — dependencies are pushed in (constructor, setter, or field) rather than fetched. Net effect: classes declare what they need, Spring supplies it. Constructor injection is preferred (immutable, testable, fails fast on missing deps). -### Why constructor injection is a contract +**Q2. What is the difference between `@Component`, `@Service`, `@Repository`, `@Controller`?** +They are all stereotypes of `@Component` (so they're scanned and registered as beans). The subtypes are semantic markers: `@Repository` adds persistence exception translation (turns JDBC/ORM exceptions into Spring's `DataAccessException`), `@Service` marks business logic, `@Controller`/`@RestController` handle HTTP. Functionally they create beans; the labels guide readers and AOP. -`@Autowired` field injection works. It also lets a `PaymentGateway` object be constructed _incomplete_ — the field stays `null` until a container touches it. Unit tests can't build the object honestly, nothing can be `final`, and the reader has to scan the class body to learn what the bean actually needs. Constructor injection turns the dependency into a parameter: +**Q3. What is the bean scope default, and what scopes exist?** +Default is **singleton** — one shared instance per container. Others: `prototype` (new instance per request), `request`/`session` (per HTTP request/session, web only), `application`. A common bug: injecting a `prototype` bean into a `singleton` gives you one instance captured at wiring time — not a fresh one per call. Use `ObjectProvider` or lookup methods for true per-call semantics. -```java -// WRONG: field injection — the dependency is a rumor -@Service -public class OrderService { - @Autowired - private PaymentGateway gateway; // null outside a container; only Spring/reflection can set it -} +**Q4. What does `@SpringBootApplication` do?** +It is a composite of `@Configuration` (bean definitions), `@EnableAutoConfiguration` (magically wires beans based on classpath — see `spring.factories`/auto-config imports), and `@ComponentScan` (scans the package and below). That is why your main class must sit in a root package above your components. -// RIGHT: constructor injection — the bean is honest about what it needs -@Service -public class OrderService { - private final PaymentGateway gateway; +**Q5. What is the difference between `@RequestParam`, `@PathVariable`, `@RequestBody`?** +`@RequestParam` binds a query/form parameter (`?id=5`), `@PathVariable` binds a URI template segment (`/users/{id}`), `@RequestBody` deserializes the HTTP body (JSON) into an object. Mixing them up is a frequent 400/405 bug. - public OrderService(PaymentGateway gateway) { - this.gateway = gateway; // fully constructed, immutable, unit-testable with a mock - } -} -``` +**Q6. What is the difference between `@Bean` and `@Component`?** +`@Component` (and friends) is class-level, auto-detected by scanning. `@Bean` is method-level, inside a `@Configuration` class, giving you explicit control over construction (e.g. wrapping a third-party object you don't own). Use `@Bean` for objects whose source you don't control; `@Component` for your own classes. -The follow-up that separates candidates: "what breaks when constructor injection meets a circular dependency?" Constructor injection is all-or-nothing — A's constructor can't complete until B's does, so the container can't hand out a partial reference. Setter/field injection survives because singleton creation happens in three phases (bare instantiate → populate → post-process), and the container can hand a raw, still-forming reference into the cycle. That's why the fix for a constructor cycle is a proxy, not a reorder: +## Mid — tradeoffs & pitfalls -```java -@Service -public class A { - private final B b; - public A(@Lazy B b) { this.b = b; } // inject a lazy proxy; real B resolved on first use -} +**Q1. Why is my `@Transactional` method not rolling back?** +Three classic causes: (1) you caught the exception and swallowed it — Spring only rolls back on a thrown `RuntimeException` (or explicitly `rollbackFor`); (2) you called the method **from within the same class** — self-invocation bypasses the proxy, so no transaction is opened; (3) the method is `private`/`final` — the proxy can't intercept it. The fix: throw, move the call to another bean, or use `TransactionTemplate` for self-calls. -// or defer the choice entirely: -@Service -public class A { - private final ObjectProvider b; // getIfAvailable(), getIfUnique(), getObject() -} -``` +**Q2. How does `@Transactional` actually work — what is the proxy?** +Spring wraps your bean in a proxy. When a proxied `@Transactional` method is called _through the proxy_, it opens a connection/transaction before invoking your method and commits/rolls back after. If the call doesn't go through the proxy (same-class self-call, or you instantiated the object yourself with `new`), there is no transaction. That is why final/private methods silently skip it. -A senior also names the smell: two singletons that need each other usually mean a missing third component, not a missing annotation. +**Q3. What is the difference between `CrudRepository`, `JpaRepository`, and a plain `EntityManager`?** +`CrudRepository` gives basic CRUD; `JpaRepository` extends it with pagination, flushing, and batch ops. Both are Spring Data abstractions over JPA. For raw control (native SQL, fine-grained flush) you drop to `EntityManager`. Overusing `JpaRepository.save()` in a loop without `flush`/`clear` can blow the persistence context — batch with `saveAllAndFlush` and consider `EntityManager.clear()` between chunks. -### Scopes — the prototype trap +**Q4. What does auto-configuration do, and how do you debug "why is this bean missing"?** +Auto-config classes are conditioned on classpath + absence of your own bean (`@ConditionalOnMissingBean`, `@ConditionalOnClass`). If a bean isn't created, something on the classpath is missing or a condition failed. Debug with `--debug` startup logs (prints all auto-config report: positive/negative matches) or `spring.autoconfigure.exclude`. Don't fight it by `@ComponentScan`-ing randomly — read the report. -A `prototype` bean injected into a `singleton` is resolved once, at the singleton's construction, and that single instance is then captured forever: +**Q5. What is the difference between `@ControllerAdvice` and a `Filter`?** +A `@ControllerAdvice` with `@ExceptionHandler` catches exceptions thrown _from a controller_ and returns a structured response — but it runs inside the DispatcherServlet, so it won't catch errors before that (e.g. filter/auth failures, or exceptions in a `Filter`). A `Filter`/`HandlerInterceptor` sits earlier in the chain and can catch/auth everything including non-controller paths. Use the advice for uniform API error shapes; use a filter for cross-cutting pre-controller concerns. -```java -// WRONG: the prototype is fetched once and cached in the singleton — scope violated -@Service -public class OrderService { - private final DiscountCalculator calc; // same instance for every request, forever -} +**Q6. How do you externalize config and handle multiple environments?** +`application.yml`/`properties` with profile-specific files (`application-prod.yml`), activated by `spring.profiles.active`. Values come from env vars / secrets manager overriding the file (Spring's relaxed binding: `SPRING_DATASOURCE_URL` overrides `spring.datasource.url`). Never hardcode credentials — inject from env or a secret store. `@ConfigurationProperties` binds a typed object from the tree, better than `@Value` for structured config. -// RIGHT: ask the container for a fresh one per use -@Service -public class OrderService { - private final ObjectProvider calcProvider; +## Senior — design & defense - public OrderService(ObjectProvider calcProvider) { - this.calcProvider = calcProvider; - } +**Q1. A `@Transactional` service is slow under load — you suspect long-lived transactions. Diagnose and fix.** +"I'd first confirm the transaction spans too much: enable `spring.jpa.show-sql` / actutator and trace where the connection is held. Often the method does a slow external call (HTTP, another DB) inside the transaction — that holds a DB connection for seconds and exhausts the pool (`HikariPool` waits, then `ConnectionTimeoutException`). Fix: move the external call _outside_ the transaction, keep the TX to the minimal DB writes, and set `@Transactional(timeout=3)` so a runaway TX fails fast instead of pinning a connection. I'd measure pool wait time before/after — target near zero." - public BigDecimal price(Order o) { - return calcProvider.getObject().apply(o); // fresh prototype each call - } -} -``` +**Q2. Design a clean layered architecture with Spring without leaking the persistence layer.** +"Controller → Service (`@Transactional`) → Repository. The service returns domain objects or DTOs, never JPA entities, to the controller — otherwise lazily-loaded collections throw `LazyInitializationException` in the serializer. I map entities→DTOs at the service boundary (MapStruct or manual). Repositories stay behind the service; controllers never touch them. This keeps the transaction boundary inside the service and the serialization outside it — the classic `OpenEntityManagerInView` trap disappears." -The web scopes add a layer of indirection. A singleton can't hold a `request`-scoped bean directly, so Spring injects a **scoped proxy** (`@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)`): a stand-in that resolves against the current request context on every call. Cost: an extra hop per access, and the proxy hides which instance you're actually talking to. "Why not scoped proxies everywhere?" — because you've traded a visible dependency for a magic object. +**Q3. You need two beans of the same type — how do you wire them without ambiguity?** +"I qualify them: `@Qualifier("primary")` on the bean and the injection point, or better, give the beans distinct types via interfaces so there's no ambiguity at all. A cleaner pattern is `@Bean` methods returning the interface with named methods, then inject by the specific subtype. Avoid `@Primary` as a silent default — it hides intent. If it's truly a strategy, pass a `List` and dispatch by a key rather than picking one bean." -### JDK proxy vs CGLIB — why an annotation can silently do nothing +**Q4. Explain the bean lifecycle and where you'd hook in custom logic.** +"Instantiation → populate properties → aware callbacks (`BeanNameAware`, etc.) → `BeanPostProcessor.before` → `@PostConstruct` → `InitializingBean.afterPropertiesSet` → `BeanPostProcessor.after` → ready → on shutdown `@PreDestroy`/`DisposableBean`. For cross-cutting setup I use a `BeanPostProcessor` or `@PostConstruct`; for one bean's init, `@PostConstruct`. I avoid `InitializingBean` (couples to Spring) in favor of `@PostConstruct`. A senior knows the order because it's where proxy creation and AOP weaving actually happen." -Spring Boot 2+ proxies by subclassing (CGLIB) even for interfaces. That means a `final` method — or a `final` bean class — cannot be overridden, and any `@Transactional`/`@Async` on it **silently does nothing**. Same for `private` methods: a call to a private method is a direct call on the target, and the proxy never sees it. "If a call doesn't leave the object, the annotation is a comment." When a method is annotated but clearly running without the behavior, the first suspects are `private`, `final`, and self-invocation (section 4). +**Q5. When would you NOT use Spring Boot, and what would you reach for?** +"For a tiny CLI or a latency-critical path where the ~hundreds of MB footprint and reflection-based startup (seconds) hurt, I'd consider a framework like Micronaut or Quarkus with build-time DI (sub-second startup, low memory) or even plain Java. Spring Boot wins on ecosystem and hiring; for serverless cold-start-sensitive or resource-tiny workloads, compile-time DI frameworks are the better trade. I'd decide on startup budget and memory ceiling, not habit." -## 2. Bean lifecycle — narrate it cold +**Q6. Defend a microservice's Spring config strategy at scale (50 services).** +"One shared `spring-cloud-config` or a Git-backed config server, with per-service overrides and env-specific values injected from the platform (K8s ConfigMap/Secret). I keep `application.yml` minimal — connection strings and secrets come from the environment, never committed. I use `@ConfigurationProperties` for typed binding and fail-fast on missing required keys (`@Validated`). At 50 services, consistency of naming and a single source of truth for shared settings matters more than convenience — I'd enforce it via a shared starter module rather than copy-paste." -Every singleton is built in a fixed order at context startup. "Walk me through the lifecycle" wants the sequence, not the annotations: +#### Self-check -1. **Instantiate** — the constructor runs. -2. **Populate** — field and setter dependencies are injected. -3. **`Aware` callbacks** — `BeanNameAware`, `BeanClassLoaderAware`, `BeanFactoryAware`, `ApplicationContextAware`. -4. **`BeanPostProcessor.postProcessBeforeInitialization`** — where listeners wire themselves up. -5. **`@PostConstruct`** — dependencies exist; setup that needs them goes here. -6. **`InitializingBean.afterPropertiesSet()`**. -7. **Custom `init-method`** (`initMethod` on `@Bean`). -8. **`BeanPostProcessor.postProcessAfterInitialization`** — _this is where AOP auto-proxying wraps the bean in its proxy._ -9. On context close: **`@PreDestroy`** → `DisposableBean.destroy()` → custom `destroy-method`. - -Two consequences interviewers probe. First, the order among the three init callbacks: `@PostConstruct` → `afterPropertiesSet` → `init-method` (and `@PostConstruct` is `CommonAnnotationBeanPostProcessor` running _before_ init). Second — the one that wins the room — step 8: **a call to a `@Transactional` method from inside `@PostConstruct` runs outside any transaction**, because the proxy doesn't exist yet. The annotation is enforced only by a proxy created _after_ initialization. - -### `BeanPostProcessor` vs `BeanFactoryPostProcessor` - -The first sees **instances** during creation; the second sees **definitions** before any bean is instantiated. That's why `PropertySourcesPlaceholderConfigurer` is a `BeanFactoryPostProcessor` — `${...}` placeholders have to be rewritten in definitions before the objects exist. And it's why `@ConfigurationProperties` binding is a `BeanPostProcessor` job (`ConfigurationPropertiesBindingPostProcessor`): the target object must be a bean first, then it gets bound. - -The failure mode that sends juniors to the docs: `@Value("${app.name}")` comes back literally as the string `${app.name}`. Root cause: the property source was registered after placeholder resolution. A senior says "if `${...}` stays literal, the definitions were resolved before the source existed," and fixes the ordering, not the string. - -### Failing fast is a feature - -Singletons are pre-instantiated **eagerly** at `refresh()`. A broken `@PostConstruct` aborts startup — the app refuses to boot. That's a feature: a misconfigured bean fails at deploy time, not at 3 a.m. when the first request touches it. `@Lazy` moves that failure to first use; sometimes that's the right call (a slow cold start you can tolerate), but name the tradeoff you're buying. If a "fixed" incident involved a bean that "worked in dev but not prod," the first question is whether it was lazily initialized and simply never exercised. - -### Full vs lite `@Bean` mode - -`@Bean` methods inside a `@Configuration` class are proxied (**full mode**), so an internal call to `b()` returns the container's singleton. Move the same `@Bean` methods into a `@Component` (**lite mode**) and each internal call constructs a brand-new instance — silently. Same annotation, different semantics depending on what's on the enclosing class. "I moved my config into a `@Component` and now there are 40 DataSources" is a real incident. - -## 3. Auto-configuration — the chef who reads the fridge - -`@SpringBootApplication` is three annotations in a trench coat: `@SpringBootConfiguration`, `@EnableAutoConfiguration`, and `@ComponentScan`. The component scan only sees your base package's subtree — which is exactly why your `@Service`s are found but a JPA provider or an H2 driver never will be. That gap is what starters fill: they ship both the dependency _and_ a class that knows how to configure it. - -### The machinery - -Boot reads `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` (Boot 2.7+; `spring.factories` before that) and loads every listed class as a candidate `@Configuration`. Then each candidate has to pass a set of `@Conditional*` questions before it's kept: - -- `@ConditionalOnClass` — is the type on the classpath? (A DataSource auto-config activates only when a driver is present.) -- `@ConditionalOnMissingBean` — did the developer define their own? (The override contract.) -- `@ConditionalOnProperty` — is the switch on? -- `@ConditionalOnWebApplication` / `@ConditionalOnBean` — the context kind, and beans already present. - -Ordering is controlled with `@AutoConfigureBefore` / `@AutoConfigureAfter` / `@Order`. The result: a Boot 3 app evaluates **on the order of a thousand condition checks at startup**, most of them negative. That's why adding an innocent-looking dependency can change behavior globally — the conditions are evaluated against the whole classpath. - -### How a bean override actually works - -The contract is `@ConditionalOnMissingBean`: Boot's `DataSourceAutoConfiguration` backs off _unless_ you already defined a `DataSource`. Your override isn't "extra config" — it's the condition turning itself off: - -```java -@Configuration -public class DbConfig { - @Bean - public DataSource dataSource() { - HikariDataSource ds = new HikariDataSource(); - ds.setJdbcUrl("jdbc:postgresql://" + url); - ds.setUsername(user); - ds.setMaximumPoolSize(20); - return ds; - } -} -``` - -If your bean isn't winning, the first move is the **conditions report**, not a guess. Set `debug=true` (or hit the actuator `conditions` endpoint) and read the _Negative matches_ section — it prints exactly which condition failed and why. The classic find: "your `@ConditionalOnMissingBean` was satisfied by a bean your own component scan registered." A senior reads _Positive matches_ first to see what's actually running, then looks for their own bean in the list. - -### The scan-twice trap - -Auto-config classes are themselves `@Configuration` classes. Put one inside your component-scan base package and `@ComponentScan` picks it up as a normal config _in addition to_ the auto-config pass — its `@Conditional` logic then runs twice against different context states and quietly misbehaves. Boot avoids this by living in `org.springframework.boot.autoconfigure.*`, outside any app's scan root. Your custom starters must do the same: `AutoConfiguration.imports` classes should never be reachable by the app's component scan. If a condition "flips" between the report and reality, suspect double registration first. - -### Property binding - -`@ConfigurationProperties` decouples your config from `@Value` strings: relaxed kebab-case binding (`my-app.timeout-ms` → `timeoutMs`), typed fields, and `@Validated` at bind time. The senior detail: binding happens through a `BeanPostProcessor`, so the class **must be registered as a bean** (`@ConfigurationPropertiesScan` or `@EnableConfigurationProperties`) — otherwise the binding silently doesn't happen and you get defaults instead of your values. "I set `my-app.timeout-ms` and the bean ignored it" is a bean-registration question, not a YAML question. - -## 4. Transaction management — the proxy and its failure modes - -As with `@Cacheable` and `@Async`, `@Transactional` is a proxy concern. The proxy delegates to `TransactionInterceptor`, which drives a `PlatformTransactionManager` (`DataSourceTransactionManager` for plain JDBC/MyBatis, `JpaTransactionManager` for JPA): acquire a connection, `setAutoCommit(false)`, run the method, commit or roll back, restore. Everything that follows is a consequence of that single sentence. - -### Self-invocation — the classic - -`this.method()` is a direct call on the raw target. The proxy only intercepts calls that arrive from the _outside_: - -```java -// WRONG: audit() is @Transactional, but this.audit() never crosses the proxy -@Service -public class OrderService { - public void ship(Order order) { - deductStock(order); - this.audit(order); // plain method call — NO transaction, NO rollback guarantee - } - - @Transactional - public void audit(Order order) { /* runs with no transaction context */ } -} -``` - -Fixes, in senior order of preference: - -```java -// 1) self-injection: Boot can inject the bean's own proxy -@Service -public class OrderService { - private final OrderService self; - - public OrderService(OrderService self) { - this.self = self; - } - - public void ship(Order order) { - deductStock(order); - self.audit(order); // now through the proxy — transactional - } -} - -// 2) expose the proxy explicitly -@EnableAspectJAutoProxy(exposeProxy = true) -// ((OrderService) AopContext.currentProxy()).audit(order); - -// 3) the architecture answer: calling your own transactional method usually -// means the logic belongs in a separate collaborator — extract it -``` - -Why the fix is a proxy and not a flag: the annotation is metadata on the bean _definition_; enforcement lives in the proxy. `private` and `final` methods fail the same way (section 1) — the call never exits the target. - -### Propagation — and the `REQUIRES_NEW` pool killer - -- `REQUIRED` (default) — join the existing transaction or create one. -- `REQUIRES_NEW` — suspend the outer, start a new transaction on a **new connection**. The outer's locks stay held while the inner commits. -- `NESTED` — savepoint semantics: roll back to the savepoint, not the whole outer. **JDBC only** — JPA throws "nested transactions are not supported" at runtime. Claiming "we used `NESTED`" in an interview confesses the JPA stack. -- `MANDATORY`, `NOT_SUPPORTED`, `NEVER`, `SUPPORTS` — the discipline of "must have / must not have" a transaction. - -The failure mode with teeth: `REQUIRES_NEW` inside a loop grabs a fresh connection per call: - -```java -// WRONG: each item starts its own transaction on its own connection -@Transactional -public void importAll(List items) { - for (Item item : items) { - importOne(item); // REQUIRES_NEW → new connection per item - } -} -// 1,000 items, Hikari pool of 10 → the pool is empty at item ~10 and the outer -// transaction waits on a connection it can't get → timeout under load - -// RIGHT: batch inside the outer transaction — one transaction, one connection -@Transactional -public void importAll(List items) { - for (Item item : items) { - save(item); // joins the outer tx - } -} -// If each item genuinely needs its own commit unit: drop the outer @Transactional, -// use a bounded TaskExecutor, and size the pool to the concurrency you allow. -``` - -Little's law applies to transactions as much as requests: `connections ≈ concurrent units of work`, not `count(items)`. - -### Isolation and the rollback default - -`@Transactional(isolation = Isolation.REPEATABLE_READ)` sets `connection.setTransactionIsolation(...)` on checkout; the default `Isolation.DEFAULT` means _the database's_ default — InnoDB REPEATABLE READ, Postgres READ COMMITTED. The tradeoff is the one from the database interview: every level above READ COMMITTED buys fewer anomalies with more and longer locks. It's a latency dial, not a safety checkbox. - -Rollback defaults: **only `RuntimeException` and `Error` roll back.** Checked exceptions — the ones you declare with `throws` — are treated as expected business outcomes and commit: - -```java -// WRONG: InsufficientFundsException is checked → the "failure" COMMITS the transfer -@Transactional -public void transfer(long from, long to, BigDecimal amt) throws InsufficientFundsException { - debit(from, amt); - credit(to, amt); - if (overdrawn(from)) throw new InsufficientFundsException(); -} - -// RIGHT: declare that this checked exception must abort -@Transactional(rollbackFor = InsufficientFundsException.class) -public void transfer(long from, long to, BigDecimal amt) throws InsufficientFundsException { - ... -} -``` - -The mirror trap is `noRollbackFor` on a `RuntimeException` you actually handled. State the decision rule out loud: _roll back by default, then enumerate the exceptions that mean "this is a real failure"_ — not "roll back nothing and hope." - -Two more details that earn points: - -- `readOnly = true` is **not a database-level guarantee**. For JPA it switches the flush to manual (no dirty-checking flush at commit — a real speedup on read-heavy paths); for the JDBC manager it's a `Connection` read-only hint. It does not prevent an `INSERT` from slipping through. If you need enforcement, that's the DB's job (roles/grants), not the annotation's. -- `timeout = 5` is advisory at the JDBC layer: it becomes a driver statement timeout where supported, and a long-running statement can outlive it. The DB side still needs its own `lock_wait_timeout` / `statement_timeout`. "The annotation timed out but the query ran for 30 seconds" is a real production sentence. - -### Transactions don't cross thread boundaries - -`@Transactional` binds to the current thread via `TransactionSynchronizationManager` (a ThreadLocal). Split the work across threads and each branch gets its own connection and its own (or no) transaction: - -```java -// WRONG: async work runs outside the transaction this method's caller expects -@Async -@Transactional -public void process(Order order) { ... } // async proxy wraps the tx proxy: the tx starts on a worker thread - -// WRONG: the send fires even when the transaction rolls back -@Transactional -public void createOrder(Order order) { - orderRepository.save(order); - kafkaTemplate.send("orders", order); // ghost event if anything below throws -} - -// RIGHT: publish only after a successful commit -@Transactional -public void createOrder(Order order) { - orderRepository.save(order); - applicationEventPublisher.publishEvent(new OrderCreated(order)); -} - -@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) -public void onOrderCreated(OrderCreated ev) { - kafkaTemplate.send("orders", ev.order()); -} -``` - -The follow-up that separates seniors: _what if the broker is down after the commit?_ The in-memory listener is not durable — it runs, the send fails, and the event is gone. That's the argument for the **outbox pattern**: write the event to an `outbox` table _in the same transaction_ as the state change, and let a relay publish committed rows with retries. "Send Kafka inside the `@Transactional` method" is the wrong shape at every scale; the real question is whether an after-commit listener is enough or you need the outbox table for durability. - -### Distributed transactions — default to the outbox, not to XA - -Interviewers love the bait: "a DB write and a Kafka publish must be atomic — use XA?" The senior answer walks the cost of two-phase commit before saying no: the prepare phase roughly doubles the lock hold, a coordinator crash leaves transactions in doubt (heuristic decisions), and every driver and broker must implement XA. The realistic tools: - -- **Best-effort 1PC** — commit the DB, publish; on failure, compensate. -- **Outbox pattern** — atomic in the one place you can be atomic (the DB), then an idempotent relay. -- **Kafka transactions** — atomic across the consume–process–produce cycle _inside one broker_; not a magic bullet across systems. - -Name the tradeoff: true 2PC buys cross-resource atomicity with availability and complexity; the outbox gives durable ordering with eventual delivery and a retry mechanism that's inspectable. - -## 5. The web layer and pool sizing — where throughput actually dies - -Spring Boot's request pipeline is one thread per in-flight request, and by default **Tomcat has 200 of them** (`server.tomcat.threads.max`). The thread does the work _synchronously_ — it blocks on the DB, on partner calls, on anything. That single fact decides your ceiling: - -``` -Little's law: throughput = threads ÷ average request time - -200 threads / 0.05 s → 4,000 req/s ceiling (50 ms requests) -200 threads / 0.5 s → 400 req/s ceiling (500 ms requests) -200 threads / 2.0 s → 100 req/s ceiling (2 s requests) -``` - -Raise the thread count and you buy context-switch thrash beyond a few times the core count — the box has 32 cores, not 32,000. The lever that actually moves the ceiling is _request latency_, which is why the senior answers to "how do I handle 10× traffic" are: cut the average request time, move slow work off the request thread, and stop letting one slow dependency hold the pool hostage. (And if a full GC pauses all 200 threads at once, the concurrency and JVM posts cover the pause math — here the point is that the request threads are where it lands.) - -### The blocking client with no timeouts - -`RestTemplate` created the naive way has **no connect or read timeout by default**. A dead peer holds a Tomcat thread for minutes, and at enough traffic the 200 threads all park in `SocketRead`: - -```java -// WRONG: no timeouts, called from a request thread -RestTemplate rt = new RestTemplate(); // connectTimeout = 0, readTimeout = 0 → hang forever - -// RIGHT: bound every stage -var factory = new HttpComponentsClientHttpRequestFactory(); -factory.setConnectTimeout(1_000); // ms — time to establish the connection -factory.setConnectionRequestTimeout(1_000); // time waiting for a pooled connection -factory.setReadTimeout(2_000); // time waiting for the response body -new RestTemplate(factory); -``` - -The senior variant is bigger: if the call is slow, _don't sit on a request thread at all_ — return `202 Accepted`, hand the work to a bounded executor, or use `WebClient` with explicit `HttpClient` timeouts. But the non-blocking answer has its own failure mode, below. - -### Virtual threads (Java 21, Boot 3.2+) - -Set `spring.threads.virtual.enabled=true` and every request gets a virtual thread: blocking I/O no longer pins a platform thread, and `server.tomcat.threads.max` stops being the ceiling. The interview, though, is about the tradeoffs: - -- **Pinning.** `synchronized` blocks and native calls pin a carrier thread — a hot `synchronized` method that used to hide behind thread count now caps throughput. -- **`ThreadLocal` assumptions break.** Thread pools reuse threads, so libraries that stash state in `ThreadLocal` relied on that reuse. Virtual threads are created per task — cached ThreadLocal state is _gone_, and ORM/connection bookkeeping that assumed reuse changes behavior. -- **The pool is still the bottleneck.** Virtual threads are cheap; **database connections are not.** With the default Hikari pool of 10 and 500 concurrent requests, 490 virtual threads sit blocked on `getConnection()` — the DB looks dead, the pool is the queue. "Virtual threads fixed my thread pool but the Hikari pool became the new ceiling" is a real production sentence. - -### Hold time, not query time - -A request thread holds its connection for the _whole transaction_, including the business logic between queries — and OSIV (section 6) makes it worse. The pool must cover the full hold, not the query: - -```java -// WRONG: the connection is checked out, then held hostage by partner latency -@Transactional -public OrderResponse create(Order order) { - orderRepository.save(order); // connection checked out here - OrderResponse r = partnerApi.place(order); // 800 ms of partner latency, connection held - return r; // commit after the call → pool pressure -} - -// RIGHT: do the slow I/O before opening the transaction, or after it commits -OrderResponse r = partnerApi.place(order); -orderService.create(order, r.id); -``` - -A fleet of 40 pods, each with 200 threads and a 20-connection pool, holding connections across an 800 ms partner call, will queue at the pool long before the partner is the problem. The database interview covers the sizing math (`connections ≈ throughput × hold time`); the Spring part is _where the hold happens_ — and the answer is: never inside a transaction across external I/O. - -## 6. Production failure modes — the checklist that becomes a war story - -Interviewers ask about incidents because the anecdotes are the signal. Have one story ready per item, and the fix attached to each: - -- **OSIV default true.** `spring.jpa.open-in-view` defaults to **true**, and Boot logs a warning at every startup. The `EntityManager` and its JDBC connection stay open for the entire HTTP request — lazy loads work anywhere (hiding the N+1) _and_ your pool is held for the full request. Turn it off and the first thing you hit is `LazyInitializationException` in a serializer — which is the framework finally pointing at the N+1. (Full hunt in the database guide.) -- **`@Cacheable` stampede and staleness.** `sync=true` collapses the thundering herd (one thread loads, the rest wait). A TTL is a staleness dial, not a correctness tool — and multi-node invalidation needs an explicit mechanism (Redis + delete/evict), not hope. "We cached for 5 minutes and the writes never showed up" is a TTL design question. -- **`@Scheduled` overlaps.** The default scheduler is a **single thread**. A run longer than the interval just delays the next tick — and with two pods, both run the job. Fix the pool size (`spring.task.scheduling.pool.size`) and, for multi-node, add ShedLock or a DB advisory lock so exactly one instance owns the run. -- **Unbounded `@Async`.** The default executor's `queue-capacity` is `Integer.MAX_VALUE`. A burst enqueues forever → latency climbs → then OOM. Replace it with an explicit `TaskExecutor`, a bounded queue, and a rejection policy: - -```java -@Bean("opsExecutor") -public TaskExecutor opsExecutor() { - ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor(); - e.setCorePoolSize(8); - e.setMaxPoolSize(24); - e.setQueueCapacity(200); // bounded — fail fast instead of unbounded growth - e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); - return e; -} -``` - -- **Graceful shutdown.** `server.shutdown=graceful` plus `spring.lifecycle.timeout-per-shutdown-phase` (default 30 s) lets a K8s SIGTERM drain in-flight requests before the pod dies. The incident: "we deploy, and 2% of requests fail with connection reset" — because the pod was killed mid-request. If you've ever seen that, name the two settings immediately. -- **`@SpringBootTest` for everything.** A test that boots the whole context to test one `@Service` takes seconds and flakes on infra beans. Slice tests (`@WebMvcTest`, `@DataJpaTest`) boot a sliver. "Why are your tests 8 minutes?" is a test-design question wearing a performance costume. -- **Two `@Bean`s of the same type.** `NoUniqueBeanDefinitionException` — or the wrong one silently winning. `@Primary` is the override, `@Qualifier` is the selector, and the conditions report shows which bean is actually registered. "It worked on my machine because my machine's classpath was missing the second bean" is the honest sentence. -- **Actuator exposed too wide.** `management.endpoints.web.exposure.include=health,info` — not `env`, `shutdown`, or `heapdump` on a public path. The endpoint that "just helps debugging" in prod is the endpoint that leaks config and secrets. - -## 7. Self-check - -- [ ] Explain why constructor injection is a contract, and the exact three-phase mechanism that lets setter injection survive a circular dependency while constructor injection can't. -- [ ] Narrate the singleton lifecycle in order — and name the phase where AOP proxying happens (and why a `@Transactional` call inside `@PostConstruct` runs untransacted). -- [ ] Full vs lite `@Bean` mode: what changes when the `@Bean` methods move from `@Configuration` to `@Component`. -- [ ] Walk auto-configuration: where `AutoConfiguration.imports` lives, how `@ConditionalOnMissingBean` lets your bean back off the starter's, and where to read the Negative matches report. -- [ ] Why `this.audit()` bypasses `@Transactional`, and the three fixes in senior order of preference. -- [ ] The `REQUIRES_NEW` loop that empties the pool — and the correct shape for per-item commit units. -- [ ] Why the Kafka send inside a transaction is a ghost event, and the after-commit listener vs outbox tradeoff. -- [ ] Size the request-thread ceiling with Little's law, and state the two things virtual threads don't change. -- [ ] List the failure modes for `@Async`, `@Scheduled`, `@Cacheable`, OSIV, and graceful shutdown — with the fix for each. - -## 8. Interviewer follow-ups - -When your first answer lands, they start drilling. Be ready for these: - -- "Why does constructor injection fail on a circular dependency — and what is `@Lazy` actually injecting?" -- "Walk me through the bean lifecycle — and where in it would an AOP proxy first intercept a call?" -- "Your `@Transactional` method calls itself and nothing rolls back. Walk me through the call path." -- "When does `REQUIRES_NEW` turn into a production incident?" -- "The Kafka message was sent but the DB rolled back. What happened, and what are the fixes?" -- "Would you use XA here? If not, why, and what's the outbox?" -- "Boot 3.2, Java 21, you enable virtual threads. What breaks next?" -- "A report request holds a connection for 45 seconds and the pool is 20. What do you change first?" -- "Your `@Async` executor OOMs under load. What's the default queue capacity, and what do you set it to?" -- "How do you find out which auto-configurations actually ran on a machine you can't attach to?" -- "Why does OSIV keep your connection hostage, and what's the first error you'll see after turning it off?" - -That's the Spring Boot bar. +- [ ] Junior: I can explain IoC/DI, the stereotype annotations, default singleton scope, and `@RequestBody` vs `@PathVariable`. +- [ ] Mid: I can explain why `@Transactional` silently fails (proxy/self-invoke/catch), how auto-config conditions work, and `@ControllerAdvice` vs `Filter`. +- [ ] Senior: I can diagnose long-lived-transaction pool exhaustion, design a clean layered architecture with DTO mapping, wire multiple beans unambiguously, and defend Spring Boot vs compile-time-DI frameworks by startup/memory budget. diff --git a/src/data/blog/vi/interview/spring-boot-senior.md b/src/data/blog/vi/interview/spring-boot-senior.md index cae9f82..d3a873c 100644 --- a/src/data/blog/vi/interview/spring-boot-senior.md +++ b/src/data/blog/vi/interview/spring-boot-senior.md @@ -1,6 +1,6 @@ --- -title: "Phỏng vấn Senior Java: Spring Boot" -description: "Spring Boot là nơi senior Java backend sống. IoC/DI, bean lifecycle, quản lý transaction, và auto-configuration magic mà phỏng vấn viên mong bạn nhìn thấu." +title: "Ôn thi Java #3: Spring Boot — Junior đến Senior" +description: "Spring Boot là nơi các senior Java backend sinh sống. IoC/DI, bean lifecycle, quản lý transaction, và phép thuật auto-configuration mà interviewer mong bạn nhìn thấu." pubDatetime: 2026-08-10T10:30:00+07:00 featured: false draft: false @@ -11,426 +11,72 @@ tags: - backend --- -Đa số vị trí senior Java backend là Spring Boot. Phỏng vấn viên mong bạn hiểu framework, không chỉ dùng — và sự khác biệt nghe ra ngay ở câu trả lời đầu tiên. Junior đọc thuộc annotation. Senior kể lại chuỗi gọi: proxy chặn một method `@Transactional` như thế nào, vì sao self-invocation lọt qua nó, vì sao bean lifecycle có hai loại post-processor, và cái đêm connection pool cạn kiệt vì một transaction giữ connection trong khi gọi một partner API chậm rì. +Spring Boot là nơi "tôi biết Java" gặp "tôi chạy được backend". Junior autowire và cầu nguyện; senior hiểu container, proxy, và transaction boundary. Bài này đi từ `@Autowired` đến "tại sao `@Transactional` của tôi thầm không chạy". -> Tư duy: nêu được tên annotation thì bạn ở tầm mid-level. Đi qua nội tại proxy với một failure mode production và con số thật, bạn đã vượt bar. Mỗi phần dưới đây đều kết bằng bài tập phỏng vấn viên thực sự hay chạy. +> Mindset: junior dùng annotation; senior vẽ được bean lifecycle và giải thích chính xác khi nào proxy bọc method của họ — và khi nào không. -## 1. IoC và DI — container là một hợp đồng, không phải cái ngăn kéo +## Junior — nền tảng -Inversion of Control là _ai sở hữu `new`_. Dependency Injection là _wiring được chuyển tới bằng cách nào_. Cùng nhau chúng trả lời "ai tạo object này và khi nào" — container sở hữu đồ thị, bạn khai báo dependency, nó thỏa mãn. Chiều sâu nằm ở hai quyết định phát sinh: bạn nhận dependency theo cách nào, và bạn trao gì cho một bean sống lâu hơn scope của nó. +**Q1. IoC và DI trong Spring là gì?** +Inversion of Control: framework, không phải code của bạn, sở hữu việc tạo và nối object. Dependency Injection là cơ chế — dependency được đẩy vào (constructor, setter, hay field) thay vì tự fetch. Kết quả: class khai báo thứ nó cần, Spring cung cấp. Constructor injection được ưu tiên (immutable, testable, fail nhanh khi thiếu dep). -### Vì sao constructor injection là một hợp đồng +**Q2. Khác nhau giữa `@Component`, `@Service`, `@Repository`, `@Controller`?** +Chúng đều là stereotype của `@Component` (nên được scan và đăng ký thành bean). Subtype là marker ngữ nghĩa: `@Repository` thêm persistence exception translation (đổi exception JDBC/ORM thành `DataAccessException` của Spring), `@Service` đánh dấu business logic, `@Controller`/`@RestController` xử lý HTTP. Về chức năng chúng tạo bean; label hướng người đọc và AOP. -`@Autowired` field injection vẫn chạy. Nó cũng cho phép một `PaymentGateway` được dựng _dang dở_ — field đứng `null` cho tới khi container chạm vào. Unit test không dựng được object một cách trung thực, không gì có thể là `final`, và người đọc phải rà khắp class body mới biết bean thực sự cần gì. Constructor injection biến dependency thành một tham số: +**Q3. Scope mặc định của bean là gì, và có những scope nào?** +Mặc định là **singleton** — một instance chia sẻ mỗi container. Khác: `prototype` (instance mới mỗi request), `request`/`session` (mỗi HTTP request/session, chỉ web), `application`. Bug phổ biến: inject `prototype` bean vào `singleton` cho bạn một instance bị capture lúc wiring — không phải mới mỗi call. Dùng `ObjectProvider` hoặc lookup method cho semantics đúng per-call. -```java -// WRONG: field injection — dependency chỉ là một lời đồn -@Service -public class OrderService { - @Autowired - private PaymentGateway gateway; // null bên ngoài container; chỉ Spring/reflection đặt được -} +**Q4. `@SpringBootApplication` làm gì?** +Nó là tổ hợp của `@Configuration` (định nghĩa bean), `@EnableAutoConfiguration` (tự động nối bean theo classpath — xem `spring.factories`/auto-config imports), và `@ComponentScan` (scan package và dưới). Đó là lý do main class phải nằm ở root package trên các component. -// RIGHT: constructor injection — bean trung thực về những gì nó cần -@Service -public class OrderService { - private final PaymentGateway gateway; +**Q5. Khác nhau giữa `@RequestParam`, `@PathVariable`, `@RequestBody`?** +`@RequestParam` bind query/form param (`?id=5`), `@PathVariable` bind URI template segment (`/users/{id}`), `@RequestBody` deserialize HTTP body (JSON) thành object. Trộn lẫn chúng là bug 400/405 thường gặp. - public OrderService(PaymentGateway gateway) { - this.gateway = gateway; // dựng đầy đủ, immutable, unit-test được bằng mock - } -} -``` +**Q6. Khác nhau giữa `@Bean` và `@Component`?** +`@Component` (và anh em) ở mức class, tự động phát hiện qua scan. `@Bean` ở mức method, trong `@Configuration` class, cho bạn kiểm soát tường minh việc construct (vd wrap third-party object không sở hữu). Dùng `@Bean` cho object không control source; `@Component` cho class của mình. -Câu hỏi follow-up phân loại ứng viên: "điều gì vỡ khi constructor injection gặp một circular dependency?" Constructor injection là all-or-nothing — constructor của A không hoàn tất được trước khi constructor của B hoàn tất, nên container không thể trao một reference nửa vời. Setter/field injection sống sót vì việc tạo singleton diễn ra qua ba pha (instantiate trần → populate → post-process), và container có thể trao một reference thô, đang còn định hình vào trong vòng lặp. Đó là lý do cách sửa một vòng constructor là một proxy, không phải đổi thứ tự: +## Mid — tradeoff & điểm mù -```java -@Service -public class A { - private final B b; - public A(@Lazy B b) { this.b = b; } // inject một lazy proxy; B thật được resolve ở lần dùng đầu -} +**Q1. Tại sao `@Transactional` của tôi không rollback?** +Ba nguyên nhân kinh điển: (1) bạn catch exception và nuốt nó — Spring chỉ rollback khi ném `RuntimeException` (hoặc `rollbackFor` tường minh); (2) bạn gọi method **từ trong cùng class** — self-invocation bypass proxy, nên không mở transaction; (3) method là `private`/`final` — proxy không intercept được. Fix: ném, chuyển call sang bean khác, hoặc dùng `TransactionTemplate` cho self-call. -// hoặc hoãn quyết định hoàn toàn: -@Service -public class A { - private final ObjectProvider b; // getIfAvailable(), getIfUnique(), getObject() -} -``` +**Q2. `@Transactional` thực sự hoạt động ra sao — proxy là gì?** +Spring bọc bean của bạn trong proxy. Khi method `@Transactional` proxied được gọi _qua proxy_, nó mở connection/transaction trước khi gọi method bạn và commit/rollback sau. Nếu call không qua proxy (self-call cùng class, hoặc bạn `new` object), không có transaction. Đó là lý do final/private method thầm bỏ qua nó. -Senior cũng nêu được dấu hiệu: hai singleton cần nhau thường báo hiệu thiếu một thành phần thứ ba, không phải thiếu một annotation. +**Q3. Khác nhau giữa `CrudRepository`, `JpaRepository`, và `EntityManager`?** +`CrudRepository` cho CRUD cơ bản; `JpaRepository` mở rộng thêm pagination, flush, batch. Cả hai là Spring Data abstraction trên JPA. Để kiểm soát thô (native SQL, flush chi tiết) bạn xuống `EntityManager`. Lạm dụng `JpaRepository.save()` trong loop không `flush`/`clear` có thể thổi persistence context — batch bằng `saveAllAndFlush` và cân nhắc `EntityManager.clear()` giữa các chunk. -### Scopes — cái bẫy prototype +**Q4. Auto-configuration làm gì, và debug "tại sao thiếu bean này" thế nào?** +Auto-config class có điều kiện trên classpath + vắng mặt bean tự định nghĩa (`@ConditionalOnMissingBean`, `@ConditionalOnClass`). Nếu bean không tạo, thiếu thứ gì trên classpath hoặc điều kiện fail. Debug bằng `--debug` startup log (in auto-config report: positive/negative matches) hoặc `spring.autoconfigure.exclude`. Đừng đánh nó bằng `@ComponentScan` ngẫu nhiên — hãy đọc report. -Một bean `prototype` được inject vào một `singleton` thì được resolve đúng một lần, tại thời điểm singleton được dựng, và instance đó bị giữ vĩnh viễn: +**Q5. Khác nhau giữa `@ControllerAdvice` và `Filter`?** +`@ControllerAdvice` với `@ExceptionHandler` catch exception ném _từ controller_ và trả response có cấu trúc — nhưng nó chạy trong DispatcherServlet, nên không catch lỗi trước đó (vd filter/auth failure, hay exception trong `Filter`). `Filter`/`HandlerInterceptor` nằm sớm hơn trong chain và catch/auth mọi thứ kể cả path không phải controller. Dùng advice cho shape lỗi API thống nhất; dùng filter cho cross-cutting pre-controller. -```java -// WRONG: prototype được fetch một lần và cache trong singleton — scope bị vi phạm -@Service -public class OrderService { - private final DiscountCalculator calc; // cùng một instance cho mọi request, mãi mãi -} +**Q6. Externalize config và xử lý nhiều môi trường thế nào?** +`application.yml`/`properties` với file per-profile (`application-prod.yml`), kích hoạt bởi `spring.profiles.active`. Giá trị từ env var / secret manager override file (Spring relaxed binding: `SPRING_DATASOURCE_URL` override `spring.datasource.url`). Đừng hardcode credential — inject từ env hay secret store. `@ConfigurationProperties` bind typed object từ tree, tốt hơn `@Value` cho config có cấu trúc. -// RIGHT: hỏi container một instance mới cho mỗi lần dùng -@Service -public class OrderService { - private final ObjectProvider calcProvider; +## Senior — thiết kế & phòng thủ - public OrderService(ObjectProvider calcProvider) { - this.calcProvider = calcProvider; - } +**Q1. Một service `@Transactional` chậm dưới tải — bạn nghi transaction sống lâu. Chẩn đoán và fix.** +"Đầu tiên tôi xác nhận transaction trải quá rộng: bật `spring.jpa.show-sql` / actuator và trace connection bị giữ ở đâu. Thường method gọi external chậm (HTTP, DB khác) _trong_ transaction — giữ connection vài giây và cạn pool (`HikariPool` chờ, rồi `ConnectionTimeoutException`). Fix: đẩy external call _ra ngoài_ transaction, giữ TX chỉ cho DB write tối thiểu, và set `@Transactional(timeout=3)` để TX runaway fail nhanh thay vì pin connection. Tôi đo pool wait time trước/sau — mục tiêu gần zero." - public BigDecimal price(Order o) { - return calcProvider.getObject().apply(o); // prototype mới mỗi lần gọi - } -} -``` +**Q2. Thiết kế layered architecture sạch với Spring không leak persistence layer.** +"Controller → Service (`@Transactional`) → Repository. Service trả domain object hoặc DTO, không bao giờ JPA entity, cho controller — nếu không collection lazy-loaded ném `LazyInitializationException` trong serializer. Tôi map entity→DTO ở service boundary (MapStruct hoặc thủ công). Repository nằm sau service; controller không chạm nó. Việc này giữ transaction boundary trong service và serialization ở ngoài — bẫy `OpenEntityManagerInView` biến mất." -Các web scope thêm một lớp gián tiếp. Một singleton không thể giữ trực tiếp một bean `request`-scoped, nên Spring inject một **scoped proxy** (`@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)`): một người đóng thế resolve vào request context hiện tại ở mỗi lần gọi. Cái giá: thêm một hop mỗi lần truy cập, và proxy giấu bạn instance mình thực sự đang nói chuyện cùng. "Vì sao không dùng scoped proxy khắp nơi?" — vì bạn đã đổi một dependency nhìn thấy được lấy một object ma thuật. +**Q3. Bạn cần hai bean cùng type — nối chúng không ambiguity thế nào?** +"Tôi qualify: `@Qualifier("primary")` trên bean và điểm inject, hoặc tốt hơn, cho bean type riêng biệt qua interface nên không ambiguity. Pattern sạch hơn là `@Bean` method trả interface với tên method riêng, rồi inject theo subtype cụ thể. Tránh `@Primary` làm default thầm — nó che intent. Nếu thực sự là strategy, truyền `List` và dispatch theo key thay vì chọn một bean." -### JDK proxy vs CGLIB — vì sao một annotation có thể âm thầm không làm gì +**Q4. Giải thích bean lifecycle và bạn hook logic tùy biến ở đâu.** +"Instantiation → populate properties → aware callbacks (`BeanNameAware`, ...) → `BeanPostProcessor.before` → `@PostConstruct` → `InitializingBean.afterPropertiesSet` → `BeanPostProcessor.after` → ready → shutdown `@PreDestroy`/`DisposableBean`. Cho setup cross-cutting tôi dùng `BeanPostProcessor` hoặc `@PostConstruct`; cho init một bean, `@PostConstruct`. Tôi tránh `InitializingBean` (couple với Spring) ưu tiên `@PostConstruct`. Senior biết thứ tự vì đó là nơi proxy creation và AOP weaving thực sự xảy ra." -Spring Boot 2+ proxy bằng subclass (CGLIB) kể cả cho interface. Nghĩa là một method `final` — hoặc một class bean `final` — không thể bị override, và bất kỳ `@Transactional`/`@Async` nào trên nó **âm thầm chẳng làm gì**. Tương tự với method `private`: gọi một method private là một lời gọi trực tiếp lên target, proxy không bao giờ thấy. "Nếu một lời gọi không rời khỏi object thì annotation chỉ là một comment." Khi một method có annotation nhưng rõ ràng chạy không có hành vi đó, ba nghi phạm đầu tiên là `private`, `final`, và self-invocation (phần 4). +**Q5. Khi nào bạn KHÔNG dùng Spring Boot, và thay bằng gì?** +"Cho CLI nhỏ hoặc path latency-critical nơi footprint ~hàng trăm MB và startup dựa reflection (giây) đau, tôi cân nhắc framework như Micronaut hay Quarkus với build-time DI (startup sub-second, memory thấp) hoặc thậm chí Java thuần. Spring Boot thắng về ecosystem và hiring; cho serverless nhạy cold-start hoặc workload tài nguyên tí hon, compile-time DI framework là trade tốt hơn. Tôi quyết trên startup budget và memory ceiling, không phải thói quen." -## 2. Bean lifecycle — kể nó trơn tru như hơi thở +**Q6. Phòng thủ chiến lược config Spring ở quy mô (50 service).** +"Một `spring-cloud-config` chia sẻ hoặc config server backend Git, với per-service override và giá trị env-specific inject từ platform (K8s ConfigMap/Secret). Tôi giữ `application.yml` tối thiểu — connection string và secret từ environment, không bao giờ commit. Tôi dùng `@ConfigurationProperties` cho typed binding và fail-fast trên missing required key (`@Validated`). Ở 50 service, consistency của naming và single source of truth cho shared setting quan trọng hơn tiện lợi — tôi enforce qua shared starter module thay vì copy-paste." -Mỗi singleton được dựng theo một thứ tự cố định lúc context startup. "Đi qua bean lifecycle cho tôi nghe" muốn chuỗi trình tự, không phải danh sách annotation: +#### Self-check -1. **Instantiate** — constructor chạy. -2. **Populate** — field và setter dependency được inject. -3. **`Aware` callbacks** — `BeanNameAware`, `BeanClassLoaderAware`, `BeanFactoryAware`, `ApplicationContextAware`. -4. **`BeanPostProcessor.postProcessBeforeInitialization`** — nơi listener tự gắn mình vào. -5. **`@PostConstruct`** — dependency đã tồn tại; setup cần chúng thì để ở đây. -6. **`InitializingBean.afterPropertiesSet()`**. -7. **Custom `init-method`** (`initMethod` trên `@Bean`). -8. **`BeanPostProcessor.postProcessAfterInitialization`** — _đây là nơi AOP auto-proxying bọc bean vào trong proxy của nó._ -9. Lúc context đóng: **`@PreDestroy`** → `DisposableBean.destroy()` → custom `destroy-method`. - -Hai hệ quả phỏng vấn viên hay khoan. Thứ nhất, thứ tự giữa ba init callback: `@PostConstruct` → `afterPropertiesSet` → `init-method` (và `@PostConstruct` do `CommonAnnotationBeanPostProcessor` chạy _trước_ init). Thứ hai — thứ thắng cả phòng — bước 8: **một lời gọi tới method `@Transactional` từ bên trong `@PostConstruct` chạy ngoài mọi transaction**, vì proxy chưa tồn tại. Annotation chỉ được thực thi bởi một proxy được tạo _sau_ khi initialization. - -### `BeanPostProcessor` vs `BeanFactoryPostProcessor` - -Cái thứ nhất nhìn các **instance** trong lúc chúng được tạo; cái thứ hai nhìn các **definition** trước khi bất kỳ bean nào được instantiate. Đó là lý do `PropertySourcesPlaceholderConfigurer` là một `BeanFactoryPostProcessor` — placeholder `${...}` phải được viết lại trong các definition trước khi object tồn tại. Và cũng là lý do binding `@ConfigurationProperties` là việc của một `BeanPostProcessor` (`ConfigurationPropertiesBindingPostProcessor`): object đích phải là một bean trước, rồi mới được bind. - -Failure mode đưa junior về đọc docs: `@Value("${app.name}")` trả về đúng chuỗi `${app.name}`. Nguyên nhân gốc: property source được đăng ký sau lúc placeholder resolution. Senior nói "nếu `${...}` vẫn là literal, nghĩa là các definition đã được resolve trước khi source tồn tại," và sửa thứ tự, không sửa chuỗi. - -### Fail-fast là một tính năng - -Singleton được pre-instantiate **eagerly** lúc `refresh()`. Một `@PostConstruct` hỏng sẽ abort startup — app từ chối boot. Đó là một tính năng: một bean cấu hình sai sẽ fail lúc deploy, không phải lúc 3 giờ sáng khi request đầu tiên chạm tới. `@Lazy` đẩy cái fail đó tới lần dùng đầu tiên; đôi khi đó là quyết định đúng (một cold start chậm bạn chấp nhận được), nhưng hãy nêu tên tradeoff bạn đang mua. Nếu một incident "đã sửa" liên quan tới một bean "chạy ở dev nhưng không ở prod", câu hỏi đầu tiên là liệu nó có bị lazy-initialize và đơn giản là chưa bao giờ được thực thi. - -### Full vs lite `@Bean` mode - -`@Bean` method bên trong một class `@Configuration` bị proxy (**full mode**), nên một lời gọi nội bộ `b()` trả về singleton của container. Chuyển cùng các `@Bean` method vào một `@Component` (**lite mode**) và mỗi lời gọi nội bộ dựng một instance hoàn toàn mới — âm thầm. Cùng một annotation, ngữ nghĩa khác nhau tùy vào class bao quanh. "Tôi chuyển config vào một `@Component` và giờ có 40 DataSources" là một incident có thật. - -## 3. Auto-configuration — đầu bếp đọc tủ lạnh - -`@SpringBootApplication` là ba annotation mặc một cái áo trench: `@SpringBootConfiguration`, `@EnableAutoConfiguration`, và `@ComponentScan`. Component scan chỉ nhìn thấy cây con của base package bạn — chính xác là vì sao `@Service` của bạn được tìm thấy nhưng một JPA provider hay một H2 driver thì không bao giờ. Cái hố đó là thứ starter lấp đầy: chúng ship cả dependency _lẫn_ một class biết cách cấu hình nó. - -### Cỗ máy - -Boot đọc `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` (Boot 2.7+; `spring.factories` trước đó) và nạp mỗi class trong đó như một `@Configuration` ứng viên. Rồi mỗi ứng viên phải vượt một loạt câu hỏi `@Conditional*` trước khi được giữ lại: - -- `@ConditionalOnClass` — type có trên classpath không? (Auto-config DataSource chỉ kích hoạt khi có driver.) -- `@ConditionalOnMissingBean` — developer đã tự định nghĩa bean chưa? (Hợp đồng override.) -- `@ConditionalOnProperty` — công tắc có bật không? -- `@ConditionalOnWebApplication` / `@ConditionalOnBean` — loại context, và các bean đã có mặt. - -Thứ tự được điều khiển bằng `@AutoConfigureBefore` / `@AutoConfigureAfter` / `@Order`. Kết quả: một app Boot 3 startup đánh giá **cỡ một nghìn lần kiểm tra condition**, đa số là negative. Đó là lý do thêm một dependency trông vô hại có thể thay đổi hành vi toàn cục — các condition được đánh giá trên toàn bộ classpath. - -### Bean override thực sự hoạt động thế nào - -Hợp đồng là `@ConditionalOnMissingBean`: `DataSourceAutoConfiguration` của Boot nhường lại _trừ khi_ bạn đã tự định nghĩa một `DataSource`. Override của bạn không phải "config thêm" — nó là cái condition tự tắt chính nó: - -```java -@Configuration -public class DbConfig { - @Bean - public DataSource dataSource() { - HikariDataSource ds = new HikariDataSource(); - ds.setJdbcUrl("jdbc:postgresql://" + url); - ds.setUsername(user); - ds.setMaximumPoolSize(20); - return ds; - } -} -``` - -Nếu bean của bạn không thắng, nước đi đầu tiên là **conditions report**, không phải đoán mò. Bật `debug=true` (hoặc gõ actuator `conditions` endpoint) và đọc mục _Negative matches_ — nó in chính xác condition nào fail và vì sao. Phát hiện kinh điển: "`@ConditionalOnMissingBean` của bạn được thỏa mãn bởi một bean mà chính component scan của bạn đăng ký." Senior đọc _Positive matches_ trước để xem thứ gì đang thực sự chạy, rồi mới tìm bean của mình trong danh sách. - -### Bẫy quét hai lần - -Các auto-config class bản thân là các class `@Configuration`. Đặt một cái vào trong base package component-scan của bạn và `@ComponentScan` nhặt nó như một config thường _bên cạnh_ lần auto-config — logic `@Conditional` của nó chạy hai lần trên các context state khác nhau và lặng lẽ cư xử sai. Boot né chuyện này bằng cách sống trong `org.springframework.boot.autoconfigure.*`, ngoài mọi scan root của app. Custom starter của bạn cũng phải làm vậy: các class trong `AutoConfiguration.imports` không bao giờ được component scan của app chạm tới. Nếu một condition "lật" giữa report và thực tế, nghi đăng ký hai lần trước tiên. - -### Property binding - -`@ConfigurationProperties` tách config của bạn khỏi các chuỗi `@Value`: relaxed kebab-case binding (`my-app.timeout-ms` → `timeoutMs`), field có type, và `@Validated` ngay lúc bind. Chi tiết senior: binding diễn ra qua một `BeanPostProcessor`, nên class **bắt buộc phải được đăng ký như một bean** (`@ConfigurationPropertiesScan` hoặc `@EnableConfigurationProperties`) — nếu không binding lặng lẽ không xảy ra và bạn nhận defaults thay vì giá trị của mình. "Tôi set `my-app.timeout-ms` mà bean phớt lờ" là câu hỏi về bean-registration, không phải về YAML. - -## 4. Transaction management — proxy và các failure mode của nó - -Giống `@Cacheable` và `@Async`, `@Transactional` là một chuyện của proxy. Proxy ủy quyền cho `TransactionInterceptor`, cái này lái một `PlatformTransactionManager` (`DataSourceTransactionManager` cho JDBC/MyBatis thuần, `JpaTransactionManager` cho JPA): lấy một connection, `setAutoCommit(false)`, chạy method, commit hoặc rollback, khôi phục. Mọi thứ sau đây là hệ quả của một câu đó. - -### Self-invocation — kinh điển - -`this.method()` là một lời gọi trực tiếp lên target thô. Proxy chỉ chặn các lời gọi đến từ _bên ngoài_: - -```java -// WRONG: audit() có @Transactional, nhưng this.audit() không bao giờ băng qua proxy -@Service -public class OrderService { - public void ship(Order order) { - deductStock(order); - this.audit(order); // lời gọi method thuần — KHÔNG transaction, KHÔNG đảm bảo rollback - } - - @Transactional - public void audit(Order order) { /* chạy không có transaction context */ } -} -``` - -Các cách sửa, theo thứ tự ưu tiên của senior: - -```java -// 1) self-injection: Boot inject được proxy của chính bean đó -@Service -public class OrderService { - private final OrderService self; - - public OrderService(OrderService self) { - this.self = self; - } - - public void ship(Order order) { - deductStock(order); - self.audit(order); // giờ đi qua proxy — có transaction - } -} - -// 2) phơi proxy ra tường minh -@EnableAspectJAutoProxy(exposeProxy = true) -// ((OrderService) AopContext.currentProxy()).audit(order); - -// 3) câu trả lời về kiến trúc: gọi method transactional của chính mình thường -// nghĩa là logic đó thuộc về một collaborator riêng — hãy tách nó ra -``` - -Vì sao cách sửa là một proxy chứ không phải một cờ: annotation là metadata trên bean _definition_; việc thực thi nằm trong proxy. Method `private` và `final` fail theo cùng cách (phần 1) — lời gọi không bao giờ ra khỏi target. - -### Propagation — và `REQUIRES_NEW` kẻ giết pool - -- `REQUIRED` (default) — join transaction đang tồn tại hoặc tạo một cái mới. -- `REQUIRES_NEW` — suspend cái ngoài, bắt đầu một transaction mới trên một **connection mới**. Lock của transaction ngoài vẫn bị giữ trong khi transaction trong commit. -- `NESTED` — ngữ nghĩa savepoint: rollback về savepoint, không phải toàn bộ transaction ngoài. **JDBC only** — JPA ném "nested transactions are not supported" lúc runtime. Nói "chúng tôi dùng `NESTED`" trong phỏng vấn tức là tự khai rằng bạn đang ở stack JPA. -- `MANDATORY`, `NOT_SUPPORTED`, `NEVER`, `SUPPORTS` — kỷ luật của "bắt buộc có / bắt buộc không có" transaction. - -Failure mode có răng: `REQUIRES_NEW` trong một vòng lặp vớ một connection mới cho mỗi lần gọi: - -```java -// WRONG: mỗi item tự bắt đầu một transaction trên connection riêng -@Transactional -public void importAll(List items) { - for (Item item : items) { - importOne(item); // REQUIRES_NEW → connection mới mỗi item - } -} -// 1.000 item, Hikari pool 10 → pool cạn ở khoảng item thứ 10 và transaction ngoài -// chờ một connection nó không thể có → timeout dưới tải - -// RIGHT: batch bên trong transaction ngoài — một transaction, một connection -@Transactional -public void importAll(List items) { - for (Item item : items) { - save(item); // join transaction ngoài - } -} -// Nếu mỗi item thực sự cần đơn vị commit riêng: bỏ @Transactional ngoài, -// dùng một TaskExecutor có giới hạn, và định cỡ pool theo concurrency bạn cho phép. -``` - -Định luật Little áp dụng cho transaction cũng như request: `connections ≈ số đơn vị công việc đồng thời`, không phải `count(items)`. - -### Isolation và rollback default - -`@Transactional(isolation = Isolation.REPEATABLE_READ)` gọi `connection.setTransactionIsolation(...)` lúc checkout; default `Isolation.DEFAULT` nghĩa là _default của database_ — InnoDB REPEATABLE READ, Postgres READ COMMITTED. Tradeoff giống ở bài phỏng vấn database: mỗi mức trên READ COMMITTED mua lại ít anomaly hơn với nhiều lock dài hơn. Đây là một nút chỉnh latency, không phải ô checkbox an toàn. - -Rollback default: **chỉ `RuntimeException` và `Error` rollback.** Checked exception — những cái bạn khai bằng `throws` — được coi là kết cục kinh doanh đã định và commit: - -```java -// WRONG: InsufficientFundsException là checked → cái "failure" COMMIT luôn cả transfer -@Transactional -public void transfer(long from, long to, BigDecimal amt) throws InsufficientFundsException { - debit(from, amt); - credit(to, amt); - if (overdrawn(from)) throw new InsufficientFundsException(); -} - -// RIGHT: khai báo rằng checked exception này phải abort -@Transactional(rollbackFor = InsufficientFundsException.class) -public void transfer(long from, long to, BigDecimal amt) throws InsufficientFundsException { - ... -} -``` - -Bẫy gương là `noRollbackFor` trên một `RuntimeException` mà bạn thực ra đã xử lý. Nói quy tắc quyết định thành tiếng: _rollback theo mặc định, rồi liệt kê các exception nghĩa là "đây là một failure thật"_ — không phải "không rollback gì và hy vọng." - -Thêm hai chi tiết ghi điểm: - -- `readOnly = true` **không phải một đảm bảo cấp database**. Với JPA nó chuyển flush sang manual (không có dirty-checking flush lúc commit — một cú tăng tốc thật trên các đường đọc-heavy); với JDBC manager nó là một hint read-only trên `Connection`. Nó không ngăn một `INSERT` lọt qua. Nếu cần enforcement, đó là việc của DB (roles/grants), không phải của annotation. -- `timeout = 5` là advisory ở lớp JDBC: nó trở thành driver statement timeout ở nơi được hỗ trợ, và một statement chạy lâu có thể sống lâu hơn nó. Phía DB vẫn cần `lock_wait_timeout` / `statement_timeout` riêng. "Annotation timeout rồi mà query vẫn chạy 30 giây" là một câu production có thật. - -### Transaction không băng qua ranh giới thread - -`@Transactional` gắn vào thread hiện tại qua `TransactionSynchronizationManager` (một ThreadLocal). Chia việc cho nhiều thread thì mỗi nhánh lấy connection riêng và transaction riêng (hoặc không có): - -```java -// WRONG: async work chạy ngoài transaction mà caller của method này mong đợi -@Async -@Transactional -public void process(Order order) { ... } // async proxy bọc ngoài tx proxy: tx bắt đầu trên một worker thread - -// WRONG: cái send bắn đi ngay cả khi transaction rollback -@Transactional -public void createOrder(Order order) { - orderRepository.save(order); - kafkaTemplate.send("orders", order); // event ma nếu bất cứ thứ gì bên dưới ném ra -} - -// RIGHT: chỉ publish sau khi commit thành công -@Transactional -public void createOrder(Order order) { - orderRepository.save(order); - applicationEventPublisher.publishEvent(new OrderCreated(order)); -} - -@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) -public void onOrderCreated(OrderCreated ev) { - kafkaTemplate.send("orders", ev.order()); -} -``` - -Câu hỏi follow-up tách senior: _nếu broker chết sau khi commit thì sao?_ Listener trong memory không durable — nó chạy, send fail, và event biến mất. Đó chính là luận cứ cho **outbox pattern**: ghi event vào một bảng `outbox` _trong cùng transaction_ với sự thay đổi state, và để một relay publish các row đã commit kèm retry. "Send Kafka bên trong method `@Transactional`" sai hình thái ở mọi scale; câu hỏi thật là một after-commit listener có đủ hay bạn cần bảng outbox cho durability. - -### Distributed transaction — mặc định theo outbox, không theo XA - -Phỏng vấn viên thích câu mồi: "một DB write và một Kafka publish phải atomic — dùng XA?" Câu trả lời senior đi qua chi phí của two-phase commit trước khi nói không: pha prepare gần như nhân đôi thời gian giữ lock, một coordinator crash để lại transaction ở trạng thái doubt (heuristic decision), và mọi driver lẫn broker đều phải implement XA. Các công cụ thực tế: - -- **Best-effort 1PC** — commit DB, publish; khi fail thì compensate. -- **Outbox pattern** — atomic ở đúng nơi duy nhất bạn có thể atomic (DB), rồi một relay idempotent. -- **Kafka transactions** — atomic trọn chu kỳ consume–process–produce _bên trong một broker_; không phải đũa thần xuyên hệ thống. - -Nêu tên tradeoff: 2PC thật mua atomicity xuyên tài nguyên với cái giá availability và độ phức tạp; outbox cho durable ordering với eventual delivery và một cơ chế retry có thể kiểm tra được. - -## 5. Web layer và pool sizing — nơi throughput thực sự chết - -Pipeline request của Spring Boot là một thread cho mỗi in-flight request, và mặc định **Tomcat có 200 thread** (`server.tomcat.threads.max`). Thread làm việc _đồng bộ_ — nó block trên DB, trên partner call, trên mọi thứ. Một sự thật đó quyết định trần của bạn: - -``` -Định luật Little: throughput = threads ÷ thời gian request trung bình - -200 threads / 0.05 s → trần 4.000 req/s (request 50 ms) -200 threads / 0.5 s → trần 400 req/s (request 500 ms) -200 threads / 2.0 s → trần 100 req/s (request 2 s) -``` - -Nâng số thread thì bạn mua context-switch thrash khi vượt vài lần số core — máy có 32 core, không phải 32.000. Đòn bẩy thực sự dịch chuyển trần là _request latency_, đó là lý do câu trả lời senior cho "làm sao chịu được traffic gấp 10×" là: cắt thời gian request trung bình, đẩy việc chậm ra khỏi request thread, và ngừng để một dependency chậm bắt cả pool làm con tin. (Và nếu một full GC đóng băng cả 200 thread cùng lúc, bài concurrency và JVM đã lo phần phép toán pause — ở đây điểm chính là các request thread chính là nơi nó đáp xuống.) - -### Blocking client không có timeout - -`RestTemplate` tạo theo cách ngây thơ **không có connect hay read timeout theo mặc định**. Một peer chết giữ một Tomcat thread hàng phút, và với đủ traffic cả 200 thread đỗ xe trong `SocketRead`: - -```java -// WRONG: không timeout, được gọi từ một request thread -RestTemplate rt = new RestTemplate(); // connectTimeout = 0, readTimeout = 0 → treo vĩnh viễn - -// RIGHT: giới hạn từng chặng -var factory = new HttpComponentsClientHttpRequestFactory(); -factory.setConnectTimeout(1_000); // ms — thời gian thiết lập connection -factory.setConnectionRequestTimeout(1_000); // thời gian chờ một pooled connection -factory.setReadTimeout(2_000); // thời gian chờ response body -new RestTemplate(factory); -``` - -Biến thể senior lớn hơn: nếu lời gọi chậm, _đừng ngồi trên một request thread chút nào_ — trả `202 Accepted`, giao việc cho một executor có giới hạn, hoặc dùng `WebClient` với timeout `HttpClient` tường minh. Nhưng câu trả lời non-blocking có failure mode riêng của nó, bên dưới. - -### Virtual threads (Java 21, Boot 3.2+) - -Bật `spring.threads.virtual.enabled=true` và mỗi request có một virtual thread: blocking I/O không còn đóng đinh một platform thread, và `server.tomcat.threads.max` không còn là trần. Nhưng phần phỏng vấn nằm ở các tradeoff: - -- **Pinning.** Khối `synchronized` và native call đóng đinh một carrier thread — một method `synchronized` nóng mà trước đây giấu sau số thread giờ chặn throughput. -- **Giả định `ThreadLocal` vỡ.** Thread pool tái sử dụng thread, nên các thư viện nhét state vào `ThreadLocal` trông cậy vào sự tái sử dụng đó. Virtual thread được tạo theo từng task — ThreadLocal state bị cache là _mất_, và các thao tác sổ sách ORM/connection từng giả định tái sử dụng sẽ đổi hành vi. -- **Pool vẫn là nút thắt.** Virtual thread rẻ; **database connection thì không.** Với Hikari pool mặc định 10 và 500 request đồng thời, 490 virtual thread ngồi block trên `getConnection()` — DB trông như chết, pool chính là hàng đợi. "Virtual thread sửa thread pool của tôi nhưng Hikari pool thành trần mới" là một câu production có thật. - -### Hold time, không phải query time - -Một request thread giữ connection của nó suốt _cả transaction_, kể cả business logic giữa các query — và OSIV (phần 6) làm tệ hơn. Pool phải đủ cho toàn bộ thời gian giữ, không phải mỗi query: - -```java -// WRONG: connection được checkout, rồi bị bắt làm con tin bởi partner latency -@Transactional -public OrderResponse create(Order order) { - orderRepository.save(order); // connection checkout ở đây - OrderResponse r = partnerApi.place(order); // 800 ms latency partner, connection bị giữ - return r; // commit sau lời gọi → áp lực pool -} - -// RIGHT: làm I/O chậm TRƯỚC khi mở transaction, hoặc SAU khi nó commit -OrderResponse r = partnerApi.place(order); -orderService.create(order, r.id); -``` - -Một đội 40 pods, mỗi pod 200 thread và pool 20 connection, giữ connection ngang một partner call 800 ms, sẽ xếp hàng tại pool từ rất lâu trước khi partner thành vấn đề. Bài database đã lo phép toán sizing (`connections ≈ throughput × hold time`); phần Spring là _nơi hold xảy ra_ — và câu trả lời là: không bao giờ để nó xảy ra bên trong một transaction ngang qua external I/O. - -## 6. Failure mode production — checklist biến thành war story - -Phỏng vấn viên hỏi về incident vì các giai thoại chính là tín hiệu. Chuẩn bị sẵn một câu chuyện cho mỗi mục, và kèm cách sửa: - -- **OSIV mặc định true.** `spring.jpa.open-in-view` mặc định **true**, và Boot log cảnh báo ở mỗi lần khởi động. `EntityManager` và connection JDBC của nó ở mở **trọn HTTP request** — lazy load chạy ở bất kỳ đâu (che giấu N+1) _và_ pool của bạn bị giữ suốt request. Tắt nó đi thì thứ đầu tiên bạn gặp là `LazyInitializationException` trong serializer — đó là lúc framework cuối cùng chỉ ra N+1. (Hành trình đầy đủ trong bài database.) -- **`@Cacheable` stampede và staleness.** `sync=true` dẹp thundering herd (một thread load, số còn lại chờ). TTL là một nút chỉnh staleness, không phải công cụ đúng đắn — và invalidation đa node cần một cơ chế tường minh (Redis + delete/evict), không phải hy vọng. "Chúng tôi cache 5 phút mà write không bao giờ hiện ra" là một câu hỏi thiết kế TTL. -- **`@Scheduled` chồng nhau.** Scheduler mặc định chỉ có **một thread**. Một run dài hơn interval chỉ làm trễ tick kế tiếp — và với hai pods, cả hai đều chạy job. Sửa pool size (`spring.task.scheduling.pool.size`) và, cho đa node, thêm ShedLock hoặc một DB advisory lock để đúng một instance sở hữu run đó. -- **`@Async` không giới hạn.** `queue-capacity` của executor mặc định là `Integer.MAX_VALUE`. Một burst enqueue mãi mãi → latency leo → rồi OOM. Thay nó bằng một `TaskExecutor` tường minh, queue có giới hạn, và một rejection policy: - -```java -@Bean("opsExecutor") -public TaskExecutor opsExecutor() { - ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor(); - e.setCorePoolSize(8); - e.setMaxPoolSize(24); - e.setQueueCapacity(200); // có giới hạn — fail fast thay vì tăng không kiểm soát - e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); - return e; -} -``` - -- **Graceful shutdown.** `server.shutdown=graceful` cộng `spring.lifecycle.timeout-per-shutdown-phase` (mặc định 30 s) cho phép một SIGTERM của K8s drain các in-flight request trước khi pod chết. Incident: "chúng tôi deploy, và 2% request fail với connection reset" — vì pod bị giết giữa chừng request. Nếu bạn từng thấy nó, nêu ngay hai cài đặt đó. -- **`@SpringBootTest` cho mọi thứ.** Một test boot cả context để test một `@Service` mất vài giây và flaky trên các infra bean. Slice test (`@WebMvcTest`, `@DataJpaTest`) boot một lát mỏng. "Vì sao test của bạn mất 8 phút?" là một câu hỏi thiết kế test đội lốt performance. -- **Hai `@Bean` cùng type.** `NoUniqueBeanDefinitionException` — hoặc cái sai âm thầm thắng. `@Primary` là override, `@Qualifier` là selector, và conditions report cho thấy bean nào thực sự được đăng ký. "Ở máy tôi chạy vì classpath máy tôi thiếu bean thứ hai" là câu trung thực. -- **Actuator phơi quá rộng.** `management.endpoints.web.exposure.include=health,info` — không phải `env`, `shutdown`, hay `heapdump` trên một đường public. Endpoint "chỉ để debug" trong prod chính là endpoint làm rò rỉ config và secret. - -## 7. Tự kiểm tra - -- [ ] Giải thích vì sao constructor injection là một hợp đồng, và cơ chế ba pha chính xác khiến setter injection sống sót qua circular dependency còn constructor injection thì không. -- [ ] Kể bean lifecycle singleton theo đúng thứ tự — và nêu pha AOP proxying xảy ra (và vì sao một lời gọi `@Transactional` bên trong `@PostConstruct` chạy không có transaction). -- [ ] Full vs lite `@Bean` mode: điều gì đổi khi các `@Bean` method chuyển từ `@Configuration` sang `@Component`. -- [ ] Đi qua auto-configuration: `AutoConfiguration.imports` nằm ở đâu, `@ConditionalOnMissingBean` cho bean của bạn nhường lại starter như thế nào, và đọc báo cáo Negative matches ở đâu. -- [ ] Vì sao `this.audit()` né được `@Transactional`, và ba cách sửa theo thứ tự ưu tiên của senior. -- [ ] Vòng lặp `REQUIRES_NEW` làm cạn pool — và hình dạng đúng cho đơn vị commit theo item. -- [ ] Vì sao Kafka send bên trong transaction là một event ma, và tradeoff after-commit listener vs outbox. -- [ ] Định cỡ trần request-thread bằng định luật Little, và nêu hai thứ virtual threads không thay đổi. -- [ ] Liệt kê các failure mode của `@Async`, `@Scheduled`, `@Cacheable`, OSIV, và graceful shutdown — kèm cách sửa cho từng cái. - -## 8. Interviewer follow-ups - -Khi câu trả lời đầu tiên của bạn chạm đúng, họ bắt đầu khoan. Sẵn sàng cho những câu này: - -- "Vì sao constructor injection fail trên circular dependency — và `@Lazy` thực sự inject cái gì?" -- "Đi qua bean lifecycle — và trong nó thì AOP proxy lần đầu tiên chặn một lời gọi ở đâu?" -- "Method `@Transactional` của bạn gọi chính nó và chẳng gì rollback. Đi qua đường gọi cho tôi nghe." -- "Khi nào `REQUIRES_NEW` trở thành một incident production?" -- "Kafka message được gửi nhưng DB rollback. Chuyện gì đã xảy ra, và các cách sửa là gì?" -- "Ở đây bạn có dùng XA không? Nếu không, vì sao, và outbox là gì?" -- "Boot 3.2, Java 21, bạn bật virtual threads. Thứ gì vỡ tiếp theo?" -- "Một request báo cáo giữ connection 45 giây và pool là 20. Bạn đổi cái gì đầu tiên?" -- "`@Async` executor của bạn OOM dưới tải. Queue capacity mặc định là bao nhiêu, và bạn set nó bằng bao nhiêu?" -- "Làm sao biết auto-configuration nào thực sự đã chạy trên một máy bạn không attach được?" -- "Vì sao OSIV giữ connection làm con tin, và lỗi đầu tiên bạn sẽ gặp sau khi tắt nó?" - -Đó là bar Spring Boot. +- [ ] Junior: Tôi giải thích được IoC/DI, stereotype annotation, singleton scope mặc định, và `@RequestBody` vs `@PathVariable`. +- [ ] Mid: Tôi giải thích được tại sao `@Transactional` thầm fail (proxy/self-invoke/catch), auto-config condition hoạt động ra sao, và `@ControllerAdvice` vs `Filter`. +- [ ] Senior: Tôi chẩn đoán được pool exhaustion do long-lived transaction, thiết kế layered architecture sạch với DTO mapping, nối nhiều bean không ambiguity, và phòng thủ Spring Boot vs compile-time-DI framework bằng startup/memory budget. From 644e697b5e4dfa3e3475c4c7509a48a0dbca3eb5 Mon Sep 17 00:00:00 2001 From: Tester Date: Thu, 13 Aug 2026 06:32:59 +0000 Subject: [PATCH 4/8] =?UTF-8?q?docs(interview):=20rewrite=20database=20as?= =?UTF-8?q?=20Junior=E2=86=92Senior=20Q&A=20series=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/blog/en/interview/database-senior.md | 412 +++-------------- src/data/blog/vi/interview/database-senior.md | 414 +++--------------- 2 files changed, 101 insertions(+), 725 deletions(-) diff --git a/src/data/blog/en/interview/database-senior.md b/src/data/blog/en/interview/database-senior.md index 14bbd39..2542d37 100644 --- a/src/data/blog/en/interview/database-senior.md +++ b/src/data/blog/en/interview/database-senior.md @@ -1,5 +1,5 @@ --- -title: "Senior Java Interview: Database and SQL" +title: "Java Interview Prep #4: Database & SQL — Junior to Senior" description: "The database layer decides real-world scale. Senior candidates must speak fluently about indexing, transaction isolation, connection pooling, and the ORM trap." pubDatetime: 2026-08-10T10:15:00+07:00 featured: false @@ -11,389 +11,77 @@ tags: - sql --- -The database is the layer that actually decides whether your system scales. Interviewers probe it hard — not for vocabulary, but for whether you've been inside the kitchen. A junior knows the four isolation levels. A senior can narrate the exact trace where a phantom appears, justify a composite index down to the B-tree height, explain why a 50-connection pool outruns a 2000-connection one, and name the one metric that proved the pool — not the database — was last month's bottleneck. +The database is where "it works on my machine" dies. Junior developers write `SELECT *` and wonder why prod is slow; seniors can explain why a query does a sequential scan and what index would fix it. This post climbs from joins to isolation anomalies to pool exhaustion. -> Mindset: recite facts and you're mid-level. Walk through a tradeoff with real numbers and a production failure mode, and you've earned the "senior" checkbox. Every section below ends with the drill an interviewer actually runs. +> Mindset: junior writes a query that returns the right rows; senior writes one that returns the right rows _and_ won't take the site down at 10x traffic. -## 1. Indexing — where interviews go to die +## Junior — foundations -"Add an index" is the beginner answer. The senior answer explains why the index is three or four B-tree levels tall, which columns go in which order, why the optimizer still refuses to touch it, and what a hot index costs you on every write you didn't plan for. +**Q1. What is a primary key, foreign key, and index?** +A primary key uniquely identifies a row (enforced unique + not null). A foreign key references a PK in another table, enforcing referential integrity. An index is a data structure (usually B-tree) that speeds lookups on a column at the cost of write overhead and storage. Without an index, a `WHERE` scans the whole table. -### B-tree internals and the height math +**Q2. What is the difference between `INNER JOIN` and `LEFT JOIN`?** +`INNER JOIN` returns only rows with matches in both tables. `LEFT JOIN` returns all rows from the left table, with matched right-table columns or NULLs when there's no match. A classic bug: using `INNER` when you need orphaned rows, silently dropping data. -A B-tree node is a database page — 16 KB in InnoDB (the default; 4/8/32/64 KB are configurable via `innodb_page_size`), 8 KB in Postgres. An internal node stores `(key, child pointer)` pairs, so with a typical key you fit a few hundred of them per page. Height grows logarithmically with fanout: +**Q3. What is the difference between `WHERE` and `HAVING`?** +`WHERE` filters rows _before_ grouping; `HAVING` filters groups _after_ `GROUP BY`. You cannot use an aggregate in `WHERE` (`WHERE COUNT(*) > 1` is invalid); use `HAVING`. -``` -Fanout ~300–700 keys/page, rows ~100–200 bytes in a clustered leaf page: +**Q4. What is a transaction and ACID?** +A transaction groups operations into an all-or-nothing unit. ACID: **A**tomicity (all or nothing), **C**onsistency (valid state transitions), **I**solation (concurrent txns don't interfere), **D**urability (committed data survives crashes). A bank transfer is the textbook example: debit and credit must both happen or neither. -~1M rows → height 3 (root + 1 internal level + leaf) -~1B rows → height 4 (root + 2 internal levels + leaf) -~1T rows → height 5 -``` +**Q5. What is the difference between `COUNT(*)`, `COUNT(col)`, and `COUNT(DISTINCT col)`?** +`COUNT(*)` counts rows (including NULLs). `COUNT(col)` counts non-NULL values in that column. `COUNT(DISTINCT col)` counts unique non-NULL values. Mixing them up changes your numbers silently. -Why those numbers? A 16 KB leaf page holds on the order of a hundred rows, so 1B rows is ~10M leaf pages. Internal levels divide by the fanout each step: ~10M / 500 ≈ 20K, / 500 ≈ 40, / 500 ≈ 1. That last "1" is the root — total height 4. This is the math behind "indexes feel like magic": four pointer hops to reach any row in a billion-row table, and each hop is one page fetch. +**Q6. What are the main column types for storing money and why not `FLOAT`?** +Never store money as `FLOAT`/`DOUBLE` — binary floating point can't represent decimals exactly (0.1 + 0.2 ≠ 0.3), causing rounding drift. Use `DECIMAL(p, s)` / `NUMERIC` (exact, fixed scale) or store integer minor units (cents). The "use integer cents" approach avoids decimal math entirely in code. -But each hop is a page access with a wildly different cost depending on where the page lives: +## Mid — tradeoffs & pitfalls -``` -L1/L2 cache hit → ~5–15 ns (the B-tree is effectively free) -RAM / buffer pool → ~100 ns (why the working set must fit in memory) -SSD (cold leaf) → ~0.1–0.5 ms (three orders of magnitude slower) -Spinning disk → ~5–10 ms (four orders of magnitude slower) -``` +**Q1. How does a B-tree index work, and when is it useless?** +A B-tree index keeps rows sorted by the indexed column, so equality and range scans are O(log n) instead of O(n). For 1B rows a B-tree is ~4 levels deep — ~4 random reads to find a row. It becomes useless when: the predicate uses a function on the column (`WHERE YEAR(created) = 2024` can't use the `created` index — use a functional/indexed expression or range), or when the filter is so unselective (returns >~20–30% of rows) the planner prefers a full scan anyway. -So a senior's design goal isn't "create an index," it's "**make sure the hot index pages stay in the buffer pool**." A billion-row index you scan once a day is a disk-clearing disaster every time it's touched; a small, hot, covering index is the difference between a 100 ns lookup and a 10 ms one. When an interviewer asks "how do you make this query fast?", the first move is page residency, not index creation. +**Q2. What is a composite index and the leftmost-prefix rule?** +A composite (multi-column) index like `(a, b, c)` is sorted by a, then b, then c. It can serve queries that filter on `a`, `(a, b)`, or `(a, b, c)` — the **leftmost prefix** — but NOT a query that filters only on `b` or `c`. Order the columns by selectivity and by which predicates you actually use. A wrong column order is a dead index. -### WRONG vs RIGHT composite index +**Q3. Explain the transaction isolation levels and their anomalies.** -The question that separates people: "Here's the query — design the index." +- **Read uncommitted**: sees dirty (uncommitted) reads. Rarely used. +- **Read committed**: no dirty reads, but non-repeatable reads (same row differs between reads in one txn). +- **Repeatable read**: same row reads consistently within a txn; may still get phantom reads (new rows appear). +- **Serializable**: full isolation, like running serially — safest, slowest. + Most engines default to read committed (Postgres repeatable read). Higher isolation = fewer anomalies = more locking/overhead. -```sql -SELECT id, status, created_at -FROM orders -WHERE customer_id = ? - AND status = ? -ORDER BY created_at DESC -LIMIT 20; -``` +**Q4. What is a deadlock and how do you avoid it?** +A deadlock is two transactions each holding a lock the other needs. Databases detect and roll back one. Avoid by **accessing resources in a consistent global order** (always update accounts in ID order), keeping transactions short, and not holding locks across network calls. Always be ready to retry a rolled-back txn. -**WRONG — the way most juniors answer:** +**Q5. What is an N+1 query problem and how do you fix it?** +Your ORM loads a list of N parents, then issues N separate queries for their children ("N+1"). Fix: eager fetch / `JOIN FETCH` / batch fetch (`@BatchSize`) so it's 1 or few queries. N+1 is the #1 silent performance killer in JPA/Hibernate apps — it looks fine in tests (small data) and melts in prod. -```sql -CREATE INDEX idx_orders_status ON orders(status); -- useless: 99% of rows are 'paid' -CREATE INDEX idx_orders_customer ON orders(customer_id); -- right column, forces a filesort -``` +**Q6. What is connection pooling and why would you exhaust it?** +A pool reuses DB connections (opening one is expensive, ~ms to tens of ms). You exhaust it by: (1) leaking connections (forgot to close / not using try-with-resources), (2) holding a connection inside a long transaction or external call, (3) too-low `maxPoolSize` for your concurrency. Symptom: `Timeout: could not get a connection`. Tune `maximumPoolSize` to (core_concurrency × avg_query_time / target_latency) and never do slow work on a pooled connection. -`status` is low cardinality: 99% of orders are `'paid'`. The optimizer sees selectivity that bad and either full-scans or uses the index and then filters millions of rows anyway. And the `customer_id` index alone returns that customer's entire order history, which has to be sorted on disk before `LIMIT 20` — a filesort that gets slower as the account ages, then a temp table, then maybe spill to disk. +## Senior — design & defense -**RIGHT — equality first, range/ordering last, ordered leaf pages:** +**Q1. A report query on a 500M-row table times out. Walk the diagnosis and fix.** +"I'd `EXPLAIN ANALYZE` it — usually it's a sequential scan because the predicate wraps the column in a function, or the index isn't leftmost-matching. If it's an aggregation report, I'd ask whether it needs to be real-time: often a materialized view refreshed every 5–15 min is the right answer, turning a 30 s scan into a 50 ms read. If it must be live, I add a covering composite index so the planner does an index-only scan. I prove the fix with `EXPLAIN ANALYZE` before/after and confirm p95." -```sql -CREATE INDEX idx_orders_cust_status_created - ON orders(customer_id, status, created_at DESC); -``` +**Q2. Design a schema for an orders table at 1M orders/day. Indexing strategy?** +"I'd partition by time (e.g. monthly range partitions) so old partitions can be archived and recent queries scan less. Index `(customer_id, created_at)` for the common 'my orders, newest first' query (leftmost prefix + sort), and a separate index on `status` only if it's selective. I'd avoid indexing every column — each index slows writes, and at 1M/day write amplification matters. I'd also move hot analytics to a read replica / columnar store rather than hammer the primary." -Three rules from the leftmost-prefix principle: +**Q3. How do you choose isolation level for a payment service, and defend it with a failure mode?** +"For payments I'd use `REPEATABLE READ` or `SERIALIZABLE` on the critical transfer path — a non-repeatable read there could double-debit. Cost: more locks, possible serialization failures under contention, so I keep those transactions tiny (just the balance math, no external calls) and retry on serialization failure. For read-heavy reporting I'd drop to `READ COMMITTED` on a replica. The defense is: the anomaly you can't tolerate dictates the level; you pay for isolation only where the money is." -1. **Equality columns first** — `customer_id` and `status` narrow the tree walk with `=` comparisons. -2. **Range/ordering columns last** — a range column is a stopping point; anything after it can't participate in the walk. -3. **Match the `ORDER BY`** — the leaf pages are sorted by `created_at DESC`, so the planner walks them in order and stops after 20 rows. No filesort, no temp table. If you also stop `SELECT`-ing columns that aren't indexed, you get an **index-only scan** — the leaf pages hold everything, and the clustered index (the table itself) is never touched. +**Q4. You're seeing lock waits and timeouts under moderate load. Find the cause.** +"I'd look at `pg_locks` / `SHOW ENGINE INNODB STATUS` for the blocking session and the statement it holds. Nine times out of ten it's a long transaction holding a row lock while it does something slow (a call, a log, a sleep) — the lock is held for seconds instead of milliseconds. Fix: shrink the transaction to the minimal writes, move the slow work outside it, and add a lock timeout so a blocked txn fails fast instead of cascading. I measure lock-wait time before/after." -```text -EXPLAIN: -type: ref -key: idx_orders_cust_status_created -rows: 20 -Extra: Using index condition; Backward index scan -``` +**Q5. ORM or raw SQL — when do you drop JPA for hand-written SQL?** +"When the query is complex (deep joins, window functions, bulk updates) or performance-critical, JPA's generated SQL is opaque and often does N+1 or fetches too much. I'd use a thin JDBC/`JdbcTemplate` or jOOQ query with exactly the columns I need, mapped to a DTO. Rule: JPA for CRUD on simple entities; hand-written SQL (or jOOQ) for reports, bulk ops, and hot paths. I never let the ORM hide a full-table fetch in production." -The interview drill is reordering the clauses: +**Q6. How do you defend a connection-pool sizing number to your team?** +"I size it from Little's Law: `pool_size ≈ target_concurrency × (avg_query_time / acceptable_latency)`. If queries average 5 ms and I need 200 concurrent, that's ~200 × (0.005 / 0.1) ≈ 10, but I pad for variance and failover, landing ~20–30, not 200. Oversizing wastes DB connections (each holds memory + a backend process) and can _worsen_ throughput by increasing lock contention. I set `maximumPoolSize` deliberately, monitor wait time, and tune from real numbers — not `200` because 'more is better'." -```sql -WHERE customer_id = ? AND created_at > ? -- want (customer_id, created_at) -WHERE status = ? ORDER BY created_at LIMIT 20 -- want (status, created_at) -WHERE created_at > ? ORDER BY created_at LIMIT 20 -- created_at alone, and it's both -``` +#### Self-check -`(created_at, customer_id)` instead of `(customer_id, created_at)` is the naive order and it is strictly worse — the range on `created_at` stops the walk, so `customer_id` never gets used as a filter. Interviewers love swapping these around; be ready to justify every position. - -### When the index betrays you - -- **Low cardinality.** An index on `gender` or `is_deleted` can cost more to scan than the table itself; the planner quietly ignores it. When `EXPLAIN` shows the index in use but `rows` is still seven digits, that's your smoking gun — the optimizer is walking an index that filters almost nothing. -- **Functions and implicit casts.** `WHERE lower(email) = ?` renders an index on `email` unusable — the column is transformed before comparison, so the tree can't be walked. Same for `WHERE order_no = 12345` on a `VARCHAR` column: every row gets cast. Fixes: expression indexes in Postgres (`CREATE INDEX ON users(lower(email))`), functional indexes in MySQL 8.0.13+, generated columns in MySQL 5.7+, or — easiest — don't store data that requires casting. -- **Leading wildcard.** `LIKE '%guru'` can't use a prefix index; `LIKE 'guru%'` can walk it. The senior version: full-text index or trigram (`pg_trgm`) when the leading wildcard is non-negotiable. -- **A range or `IN` in the middle of the index.** `(a, b, c)` with `WHERE a = ? AND c = ?` while `b` is a range means `c` only filters inside the fetched `b` range. Column order is a contract; a planner will happily explain it to you if you ask. -- **`NULL`s.** In Postgres, `NULL` sorts first by default and most index types include them; `WHERE x IS NULL` can use an index, but `UNIQUE` indexes treat `NULL`s as distinct (multiple `NULL`s are allowed). In InnoDB, a unique index allows many `NULL`s too — "unique" does not mean "no nulls." -- **`EXPLAIN` estimates lie.** A stale planner guess from outdated statistics sends you down a bad plan. `ANALYZE TABLE` (MySQL) / `ANALYZE` (PG), then re-run. A senior quotes `EXPLAIN ANALYZE` actuals, not the planner's guesses — the "`rows` vs `actual rows`" gap is where slow queries confess. - -### The clustered-key trap: UUID vs BIGINT - -This is the question that separates people who've seen a production incident from those who've only seen the docs. In InnoDB the clustered index **is** the table, ordered by primary key. Insert a random `UUID` (v4) and you're inserting at a random position in a sorted structure: - -```sql --- WRONG for a hot table: the primary key is the physical row order -CREATE TABLE orders ( - id BINARY(16) PRIMARY KEY, -- or CHAR(36) with a UUIDv4 string - ... -); -``` - -Every insert lands in a random leaf page → page splits, fragmentation, and each random page is a cache miss on read. You pay a double tax: the write fan-out doubles every time the tree re-balances, and the hot head of the index (where a `BIGINT AUTO_INCREMENT` would write) no longer stays in the buffer pool. At high insert rates this is the difference between append-only sequential writes and a disk-write-storm that tanks your p95 latency. Fixes: - -- `BIGINT` identity (or `IDENTITY` / sequence) — sequential inserts, hot-tail pages stay cached. -- `UUIDv7` (time-ordered) — the modern "global, but sequential-ish" middle ground; MySQL 9+ has `UUIDv7()`. -- Snowflake-style IDs — sequence-like within a worker, shardable across nodes. - -A senior never says "UUIDs are slow" — they say "random UUIDs break clustered-index locality; here's how I measured the page splits and why UUIDv7 fixes it." - -### Buffer-pool hit ratio — the number interviewers fish for - -InnoDB exposes it directly: - -```sql -SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests'; -- total logical reads -SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads'; -- actual physical disk reads -``` - -``` -hit_ratio = 1 - (physical_reads / logical_reads) -``` - -OLTP workloads want a hit ratio above 99%. Below ~95%, your "fast" database is secretly a disk reader — random point reads hitting storage, throughput collapsing, and the fix is usually **the working set not fitting in memory**, not more CPU and not a better index. The classic follow-up: "your working set is 2 TB and the buffer pool is 128 GB — what do you do?" The senior answer starts with "which 10% of the data serves 90% of the reads" — hot-row caching, denormalizing a hot column, or splitting hot vs cold tables — not "buy more RAM." And before you tune anything: `SHOW ENGINE INNODB STATUS` for the transient pool state, and separate **one-shot scans** (reporting, `SELECT COUNT(*)`) from point lookups — a nightly analytics query can drag the hit ratio down while your real workload is fine. - -## 2. Transactions & isolation — anomaly forensics - -Reciting the four levels is the mid-level answer. The senior answer is the traces — the one anomaly textbooks skip, the lock that blocks your queue, and the MVCC internals that explain why two databases disagree about REPEATABLE READ. - -### The matrix, plus the gotcha nobody says out loud - -- **Dirty read** — prevented at READ COMMITTED. -- **Non-repeatable read** — prevented at REPEATABLE READ. -- **Phantom read** — standard SQL permits it under REPEATABLE READ, but **InnoDB prevents it anyway** using next-key locks, and Postgres REPEATABLE READ (snapshot isolation) never shows phantoms on reads either. - -So when the interviewer asks "which isolation level prevents phantom reads?", the textbook answer is SERIALIZABLE, and it's a trap. Under InnoDB, REPEATABLE READ already does, because every _locking_ read under RR takes next-key locks (row + gap). And here's the part that makes senior candidates win the follow-up: **InnoDB's REPEATABLE READ and Postgres's REPEATABLE READ are different animals.** - -- **InnoDB RR** = MVCC consistent reads + next-key locks on locking reads/DML. Phantoms are blocked for _locking_ operations by gap locks. -- **Postgres RR** = pure snapshot isolation (MVCC, SSI-style). There are **no gap locks at all**, so locking reads never block on "rows that don't exist yet" — phantoms are avoided for _reads_ by the snapshot, but two `SELECT ... FOR UPDATE` on a gap never block each other. - -Both engines, though, share the same hole: **write skew survives REPEATABLE READ** in both, because the reads that matter are _non-locking_ reads — see below. - -### MVCC internals — the layer under the answer - -You can't narrate isolation anomalies without knowing what a "consistent read" actually is. In InnoDB: - -1. Every row carries hidden columns: a transaction ID and a roll pointer to the **undo log**. -2. An `UPDATE` doesn't overwrite the row — it writes a **new version** and points the old one into the undo log (the version chain). -3. A transaction's first read in RR creates a **read view** — a snapshot of "which transactions were committed before I started." -4. A read walks the version chain and returns the newest version visible to the snapshot. Everyone reads their own private history of the table, so readers never block writers and writers never block readers. - -That last point is the whole reason you see `MVCC` in every job description: "readers don't block writers." The tradeoff nobody volunteers is that every version you keep **costs disk and CPU**, and long transactions freeze the garbage collector: - -- InnoDB: undo log purge can't reclaim versions a long-running transaction might still read. `SHOW ENGINE INNODB STATUS` → watch the **history list length**. It climbs, the undo tablespace grows, and one 30-minute reporting transaction can silently double your disk usage. -- Postgres: old row versions stay as **dead tuples**, and autovacuum falls behind. The table bloats, and your index scans get slower _even when they return one row_ — because the pages are full of ghosts. - -The production failure mode interviewers probe: "a nightly batch transaction ran for 45 minutes, and the next morning writes were slow." Senior answer: the read snapshot held the purge/vacuum back, the undo/dead-tuple list grew, page writes slowed down, and _short_ transactions that should have been 10 ms started stalling on buffer replacement. The fix is usually **shorter transactions** (commit in batches), not bigger hardware. - -### Phantom read trace (under READ COMMITTED) - -``` -T1: BEGIN; -T1: SELECT COUNT(*) FROM shifts WHERE day = 'Monday'; -- 5 - -T2: BEGIN; -T2: INSERT INTO shifts(day) VALUES ('Monday'); COMMIT; - -T1: SELECT COUNT(*) FROM shifts WHERE day = 'Monday'; -- 6 ← phantom -``` - -The set of rows changed under T1's feet. Note the difference between RC and RR here: under **READ COMMITTED** each statement gets a fresh read view, so T1's second `COUNT` sees T2's committed insert — phantoms and non-repeatable reads both appear. Under **REPEATABLE READ** the read view is fixed at the first read, so both counts return 5 (that's _why_ RR "prevents" it for reads). If you can narrate _why_ the level changes the result — a fresh read view per statement vs per transaction — you're speaking engine, not exam. - -You fix it with SERIALIZABLE or explicit locks — and you pay with concurrency. That cost is the tradeoff interviewers want to hear you name: **isolation level is a latency/throughput dial, not a safety checkbox.** - -### Lost update and write skew — the senior territory - -Lost update is the easy one, fixed with a version column (optimistic locking): - -```sql -UPDATE accounts SET balance = balance - 100, version = version + 1 -WHERE id = ? AND version = ?; --- 0 rows affected => someone moved first => retry or reject -``` - -The anomaly that actually bites in interviews (and production) is **write skew**: two transactions each read overlapping state, neither blocks the other because they write _different_ rows, and the invariant silently dies. - -``` -T1: BEGIN; -T1: SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 1, limit is 1 - -T2: BEGIN; -T2: SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 1, limit is 1 - -T1: UPDATE doctors SET on_call = true WHERE id = 101; -- ok, "still one" per my read -T2: UPDATE doctors SET on_call = true WHERE id = 102; -- ok, "still one" per my read --- COMMIT × 2 → now TWO doctors are on call. Invariant violated. -``` - -No dirty read, no lost update — the snapshot is consistent to each transaction, and the constraint still breaks. This is the one anomaly that survives REPEATABLE READ in **both** InnoDB and Postgres, because the `SELECT COUNT(*)` was a _non-locking_ MVCC read: neither transaction took a lock the other could wait on. The fixes: - -```sql --- PESSIMISTIC: lock the examined rows, so T2 blocks until T1 commits -SELECT COUNT(*) FROM doctors WHERE on_call = true FOR UPDATE; - --- Or serialize the whole read-modify-write on a single guard row -SELECT ... FROM doctor_schedule WHERE id = ? FOR UPDATE; - --- QUEUE USE-CASE: don't wait at all -SELECT ... FOR UPDATE SKIP LOCKED; -- claim one task, ignore the locked ones -``` - -- **Pessimistic (`FOR UPDATE`)** — T1's locking read holds next-key locks; T2 blocks until T1 commits, then re-reads and sees two already on call → rejects. Correct, but you serialize all on-call changes. -- **Optimistic (version column)** — both bump versions, the loser's `UPDATE` returns 0 rows, the app retries. -- **Postgres SERIALIZABLE (SSI)** — the engine detects the read-write dependency at commit time and **aborts one transaction** with `40001 serialization_failure`. The app _must_ catch and retry; if you don't retry, you're turning serializable into data loss. - -If you can produce this trace unprompted, name the non-locking-read root cause, and give the pessimistic + optimistic + SSI fix triad, you've cleared the highest bar in this section. - -### Deadlocks — the follow-up that always lands - -Right after write skew, interviewers pivot to: "you deploy, and suddenly `DeadlockLoserDataAccessException` in the logs." The senior answer is not "add retries" — it's "read the deadlock report." - -- **InnoDB detects deadlocks** and rolls back the transaction that did less work (fewer undo bytes). `SHOW ENGINE INNODB STATUS` prints the two transactions, the exact locks held, and the SQL that blocked. -- **The pattern**: T1 locks row A then wants row B; T2 locks row B then wants row A. Same lock _order_ across every transaction is the fix — sort your `WHERE id IN (...)` keys, lock parent before child. -- **`NOWAIT` / `SKIP LOCKED`** are your escape hatches for queue-consumer patterns; a job queue that _waits_ on locked rows will deadlock itself to death under load. - -```java -// WRONG: deadlock → exception → transaction rolled back → job lost -try { - doTransfer(a, b); -} catch (DeadlockLoserDataAccessException e) { - // swallowed: money moved once, or not at all — we don't know -} - -// RIGHT: bounded exponential backoff retry, and idempotency on the write -int retries = 0; -while (retries < 3) { - try { - doTransfer(a, b); // update is idempotent via a unique txn_id - break; - } catch (DeadlockLoserDataAccessException e) { - retries++; - Thread.sleep(50L << retries); // 100ms, 200ms, 400ms - } -} -``` - -## 3. Connection pooling — the difference between 2000 and 50 connections - -The HikariCP sizing heuristic `connections ≈ ((core_count * 2) + effective_spindle_count)` is a starting guess, and every senior knows it's a starting guess. The defensible number comes from Little's law: - -``` -Little's law: in-flight work = arrival rate × time each request holds the resource - -pool_size = requests_per_second × seconds_a_connection_is_checked_out -500 req/s × 0.05 s = 25 connections -``` - -Run that math and you won't be the person who sizes a pool at 200 because the box has 64 cores. And here's the two numbers that make the section title concrete: a single connection can comfortably execute **on the order of a thousand short transactions per second** (a 1 ms query ~ 1000/s; a 10 ms query ~ 100/s). So a pool of 50 connections is not "50 concurrent users" — it's on the order of **50,000 short TPS**, which is more than most services ever see. A pool of 2000 is not 40× more throughput; it's 2000 threads waiting on a database that can service only a fraction of them. - -The subtlety that trips up even strong candidates: **the connection is held for the whole checkout, not the query.** If your request checks out a connection, runs query A, does 100 ms of business logic in Java, then runs query B, the pool must cover the _full_ 150 ms. Little's law with the wrong W (query time instead of transaction time) produces a pool that's 3× too small and queues at the DB — the exact failure you're trying to avoid. - -- **Too large** → context-switch thrash, hundreds of MB of idle connections on the DB side (MySQL thread-per-connection: every idle connection is a thread + stack + buffers), and queueing _inside_ the database. -- **Too small** → requests queue at `connectionTimeout`, latency climbs, then throughput collapses — the "pool is the bottleneck, not the DB" incident. -- **Non-blocking R2DBC**: threads never block on I/O, so a pool of 10–20 connections is plenty — the pool is sized to concurrency, not load. - -### Production failure modes, because interviewers ask about incidents - -- **Leaked connections.** `getConnection()` without a release and the pool exhausts → `Connection is not available, request timed out` → every request piles up → outage. This is the classic "DB seems down but it's fine, the pool is empty" incident. Fix with try-with-resources and `leakDetectionThreshold` so the pool tells you before customers do. -- **`maxLifetime` vs server timeout.** MySQL's `wait_timeout` defaults to 8 hours; if your pool holds a connection past it, the server silently kills it and you get `Communications link failure`. Pool `maxLifetime` must be below the server's idle timeout. The inverse: a pool's `connectionTimeout` (default 30 s in HikariCP) is how long a request _waits_ for a free connection — if you see timeouts, check queueing before you check the DB. -- **`minimumIdle` = `maximumPoolSize` is fine for hot services**, but for bursty ones the pool should be able to drain idle connections; tune `idleTimeout` so a spike doesn't leave 200 sockets parked for the afternoon. -- **Turn on diagnostics:** `leakDetectionThreshold`, `connectionTimeout`, `validationTimeout`, and JDBC4's `isValid()` (not a `SELECT 1` round-trip) are not optional in production. Watch `active` vs `idle` in the Hikari metrics — a pool that is permanently `active` at max is a queue in disguise. - -```java -// WRONG: one exception in the middle and the connection is gone forever -Connection c = pool.getConnection(); -Statement s = c.createStatement(); -s.execute("UPDATE accounts SET balance = balance - 100 WHERE id = ?"); -c.close(); // never reached if execute throws → leak → pool exhaustion - -// RIGHT: try-with-resources guarantees release on every path -try (Connection c = pool.getConnection(); - PreparedStatement ps = c.prepareStatement( - "UPDATE accounts SET balance = balance - ? WHERE id = ?")) { - ps.setBigDecimal(1, amount); - ps.setInt(2, accountId); - ps.executeUpdate(); -} -``` - -And the ORM tie-in that closes the loop: if you're on Spring Boot and you left Open Session in View on, your pool is being held hostage — more in section 5. - -## 4. SQL vs NoSQL — decide on access pattern + consistency, not hype - -Saying "NoSQL is faster" costs you the interview. The honest framing: each store offers a different contract of consistency, flexibility, and scale, and the choice is a tradeoff, not a speed race. Interviewers want to hear you ask the three questions _before_ you name a technology: - -1. **What is the access pattern?** — point lookups by key, range scans, joins, aggregations, append-only? -2. **What consistency contract does the business need?** — read-your-writes for a cart is different from eventual for analytics. -3. **What is the write/read ratio and the cardinality of the hot key space?** - -- **Relational (Postgres/MySQL)** — joins, transactions, referential integrity, and the ability to `EXPLAIN` your way out of a performance hole. The default when data has relationships and money moves. Modern Postgres blurs the line: `jsonb` gives you a document store with `GIN` indexes and a real query planner. -- **Document (MongoDB)** — flexible schema and horizontal scale, but server-side joins are limited (`$lookup` is an aggregation-stage pipeline cost that gets expensive fast), documents cap at 16 MB, and a bad shard key produces **hot shards** that cap throughput no matter how many nodes you add. A shard key must spread writes AND match your reads — "everyone queries by `customer_id`, so shard on `customer_id`" is the senior answer; sharding on `created_at` makes all recent writes land on one shard. -- **Wide-column (Cassandra)** — write-anywhere log-structured design (LSM) for write-heavy scale, with tunable consistency. QUORUM on RF=3 means two nodes must agree; **eventual consistency is fine for telemetry and dangerous for ledgers**. Reads are the expensive part — a read does a merge across memtables and SSTables, so "Cassandra is fast" means _fast writes_, and your read path is where the surprises live. -- **Redis** — a cache/counter/pub-sub with RAM durability assumptions, not a durable store. Single-threaded core, so a few 10k-ops/s at the p99 — pipelining matters more than you think. If you claim it's your source of truth, be ready to defend AOF + fsync tradeoffs (fsync every write → a few thousand ops/s; fsync every second → you can lose a second of data on crash) and the eviction policy (`allkeys-lru` vs `volatile-lru`) that makes or breaks a cache. - -And the nuance that lands well: Postgres `jsonb` blurs the relational/document line — you can have a schema for the money columns and a JSON document for the flexible ones, with indexes into the JSON. "I'd store the order lines as a relational table and the supplier's vendor-specific metadata as jsonb" beats "I'd use MongoDB" in most backend interviews. The honest NoSQL-for-a-reason answers: append-only event logs and telemetry → Cassandra/ClickHouse; per-user mutable profiles with hot rows → Redis + relational; document-ish flexible data that needs real queries → Postgres `jsonb`. - -## 5. N+1 and the ORM trap - -The beginner answer is "use JOIN FETCH." The senior answer is "use JOIN FETCH, then know where it breaks, then measure the SQL the ORM actually runs." - -**The classic:** - -```java -// WRONG: 1 query for the parents + 1 query per child = N+1 -List orders = orderRepository.findAll(); -for (Order order : orders) { - order.getLineItems().size(); // lazy-load fires here, once per order -} -``` - -1,000 orders → 1,001 queries. With 10 rows it's invisible; with 10 million it melts the database. That "works with 10, dies with 10M" is the exact curve interviewers love to ask about. Then they ask you to _prove_ it exists in production without an IDE — and the senior answer is `format_sql` + timing (`slow_query_log` in MySQL, `auto_explain` in Postgres showing `1,001` sequential queries), or just watching the DB's query counter jump by exactly one per parent row. - -**RIGHT — fetch the graph in one statement:** - -```java -// Hibernate -List orders = em.createQuery( - "select distinct o from Order o join fetch o.lineItems", Order.class) - .getResultList(); - -// Spring Data -@Query("select o from Order o join fetch o.lineItems") -List findAllWithItems(); -``` - -Alternatives with their own tradeoffs: `@BatchSize` (batch fetching: `1 + ceil(N/1000)` queries instead of `1 + N` — right when the parent list is large and a join would explode into a cartesian product), entity graphs/`@EntityGraph` for per-use-case fetch strategies, or — the most senior move — skip the entities entirely and **project a DTO** with the exact columns the page needs: - -```java -@Query(""" - select new com.acme.dto.OrderItemDTO(o.id, o.status, l.sku, l.qty) - from Order o join o.lineItems l - where o.customerId = :customerId - """) -List findForCustomer(long customerId); -``` - -One query, no managed entities, no lazy traps, and the smallest row payload. If you can explain _when to stop using JOIN FETCH and project instead_ — that's the senior inflection point. - -**Where JOIN FETCH betrays you — the senior-only gotcha.** Paginate a query that fetch-joins a `Collection` and Hibernate can't apply `LIMIT` in SQL, because the join multiplies rows. It falls back to **in-memory pagination** — the whole result set loaded, then truncated in the JVM. The log line is `HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!` — and your "page 2" is now silently wrong and your DB is suddenly doing the work of a full scan. Fix: fetch the IDs on page 1 first, then fetch children for those IDs in a second query, or paginate a DTO projection instead. - -**The OSIV trap — Spring Boot's hidden default.** `spring.jpa.open-in-view` defaults to **true**, and Spring Boot logs a warning every time it boots. OSIV keeps the EntityManager (and its JDBC connection) open for the **entire HTTP request**, even after your transaction commits. Two consequences: lazy loads anywhere in the request (including serializers and template rendering) "just work" — hiding the N+1 — and your connection pool is held open for the full request duration, which means **your pool sizing math from section 3 is now off by the width of your slowest endpoint**. Senior play: turn OSIV off (`spring.jpa.open-in-view: false`), handle lazy loading inside the transaction (or fetch eagerly), and let the pool release connections the moment business logic finishes. The first thing you'll hit with OSIV off is `LazyInitializationException` from a serializer — and that's a feature, not a bug: it's the framework finally telling you where the N+1 is. - -And the meta-skill underneath all of this: **read the generated SQL.** Enable `spring.jpa.properties.hibernate.format_sql=true` or wire in p6spy, and check that what you _think_ you wrote is what the ORM _actually_ executes. A senior treats the ORM as a code generator with opinions, not as a black box — and knows that `@Cacheable`/second-level cache is a _last_ resort for hot, rarely-changing shared data, because invalidation across nodes is where caches quietly go stale. - -## 6. Self-check - -- [ ] Design a composite index for `WHERE customer_id = ? AND status = ? ORDER BY created_at` and justify the column order — down to the leftmost-prefix rule and the `DESC` leaf order. -- [ ] Explain why a `UUIDv4` primary key is a clustered-index disaster and what `UUIDv7` changes. -- [ ] Which anomaly does the _standard_ REPEATABLE READ permit, why does InnoDB still prevent it for locking reads, and why does Postgres's RR behave differently? -- [ ] Produce a two-transaction write-skew trace and the pessimistic + optimistic + SSI fix triad. -- [ ] Read a deadlock out of `SHOW ENGINE INNODB STATUS` and write the retry loop. -- [ ] Size a connection pool from throughput and hold-time with Little's law — and explain why "hold time" isn't query time. -- [ ] Find and fix the N+1 in a snippet, then explain the fetch-join pagination gotcha and the OSIV trap. -- [ ] Name the two status variables that compute the InnoDB buffer-pool hit ratio and the first move when it drops. - -## 7. Interviewer follow-ups - -When your first answer lands, they start drilling. Be ready for these: - -- "The query returns 40M rows — would the index still be used, and would it be the right shape?" -- "Why did the optimizer ignore my index on `status` even though `EXPLAIN` shows it?" -- "Your working set doesn't fit in the buffer pool. What's your first move?" -- "Would SERIALIZABLE have stopped the write skew? What does it cost — and who's responsible for the retry?" -- "You see `Connection is not available, request timed out` at 100 req/s with a pool of 25. What's the math, and what do you check first?" -- "When would you still choose MySQL over Postgres, or Postgres over MongoDB — and what does `jsonb` change?" -- "How do you prove an N+1 exists in production without opening an IDE?" -- "Your long-running read transaction is slowing down all writes. Where does the bloat live, and what do you change?" -- "A job queue keeps deadlocking under load. What's the first thing you change — and why `SKIP LOCKED`?" -- "Explain why Postgres `SERIALIZABLE` can abort a transaction that already committed a logically-valid write." - -That's the database bar. +- [ ] Junior: I can explain PK/FK/index, INNER vs LEFT join, WHERE vs HAVING, ACID, and why not FLOAT for money. +- [ ] Mid: I can explain B-tree indexing, composite leftmost-prefix, isolation levels, deadlocks, N+1, and pool exhaustion. +- [ ] Senior: I can diagnose a slow report query with EXPLAIN ANALYZE, design partitioning + indexing for 1M/day, pick isolation by failure mode, and size a connection pool from Little's Law with real numbers. diff --git a/src/data/blog/vi/interview/database-senior.md b/src/data/blog/vi/interview/database-senior.md index 69ebec9..0040e04 100644 --- a/src/data/blog/vi/interview/database-senior.md +++ b/src/data/blog/vi/interview/database-senior.md @@ -1,6 +1,6 @@ --- -title: "Phỏng vấn Senior Java: Database và SQL" -description: "Tầng database quyết định scale thực tế. Ứng viên senior phải nói lưu loát về indexing, transaction isolation, connection pooling, và bẫy ORM." +title: "Ôn thi Java #4: Database & SQL — Junior đến Senior" +description: "Tầng database quyết định scale thực tế. Ứng viên senior phải nói lưu loát về indexing, transaction isolation, connection pooling, và cái bẫy ORM." pubDatetime: 2026-08-10T10:15:00+07:00 featured: false draft: false @@ -11,389 +11,77 @@ tags: - sql --- -Database là tầng quyết định hệ thống có scale được thật hay không — và phỏng vấn viên soi nó không phải để kiểm tra từ vựng, mà để xem bạn đã vào bếp bao giờ chưa. Junior thuộc lòng bốn mức cô lập. Senior kể được chính xác trace khi phantom xuất hiện, chứng minh composite index tới tận chiều cao của B-tree, giải thích vì sao pool 50 connection đánh bại pool 2000 connection, và chỉ ra _một_ metric đã chứng minh rằng pool — chứ không phải database — mới là nút thắt của tháng trước. +Database là nơi "chạy trên máy tôi vẫn ok" chết. Junior viết `SELECT *` và thắc mắc tại sao prod chậm; senior giải thích được tại sao query quét tuần tự và index nào sửa nó. Bài này leo từ join đến isolation anomaly đến pool exhaustion. -> Tư duy: nhả thuật ngữ thì bạn chỉ ở tầm mid-level. Đi qua một tradeoff bằng số thật và một failure mode trong production thì bạn chạm nốt "senior". Mỗi phần dưới đây đều kết bằng bài tập phỏng vấn viên thực sự hay chạy. +> Mindset: junior viết query trả đúng rows; senior viết query trả đúng rows _và_ không gục site ở 10x traffic. -## 1. Indexing — nơi phỏng vấn hay "chết" +## Junior — nền tảng -"Thêm index" là câu trả lời của người mới. Câu trả lời của senior giải thích vì sao cây index cao ba hay bốn tầng B-tree, cột nào đứng trước cột nào, vì sao optimizer vẫn ngoảnh mặt làm ngơ, và một index "hot" tốn bạn bao nhiêu trên mỗi lần ghi bạn không hề lên kế hoạch. +**Q1. Primary key, foreign key, và index là gì?** +Primary key định danh duy nhất một row (ép unique + not null). Foreign key tham chiếu PK ở bảng khác, ép referential integrity. Index là cấu trúc dữ liệu (thường B-tree) tăng tốc lookup trên cột với giá write overhead và storage. Không có index, `WHERE` quét whole table. -### Nội tại B-tree và bài toán chiều cao +**Q2. Khác nhau giữa `INNER JOIN` và `LEFT JOIN`?** +`INNER JOIN` chỉ trả row có match ở cả hai bảng. `LEFT JOIN` trả mọi row từ bảng trái, với cột bảng phải tương ứng hoặc NULL khi không match. Bug kinh điển: dùng `INNER` khi cần orphaned row, thầm drop data. -Một node của B-tree là một page của database — 16 KB trong InnoDB (mặc định; 4/8/32/64 KB cấu hình được qua `innodb_page_size`), 8 KB trong Postgres. Một internal node chứa các cặp `(key, con trỏ tới node con)`, nên với key điển hình bạn nhét được vài trăm cặp mỗi page. Chiều cao tăng theo logarit của fanout: +**Q3. Khác nhau giữa `WHERE` và `HAVING`?** +`WHERE` filter row _trước_ group; `HAVING` filter group _sau_ `GROUP BY`. Bạn không dùng aggregate trong `WHERE` (`WHERE COUNT(*) > 1` không hợp lệ); dùng `HAVING`. -``` -Fanout ~300–700 key/page, row ~100–200 byte trong leaf page clustered: +**Q4. Transaction và ACID là gì?** +Transaction gộp các operation thành đơn vị all-or-nothing. ACID: **A**tomicity (tất cả hoặc không), **C**onsistency (chuyển trạng thái hợp lệ), **I**solation (txn đồng thời không can thiệp), **D**urability (data committed sống sót crash). Bank transfer là ví dụ sách giáo khoa: debit và credit phải cùng xảy ra hoặc không. -~1M rows → cao 3 (root + 1 internal + leaf) -~1B rows → cao 4 (root + 2 internal + leaf) -~1T rows → cao 5 -``` +**Q5. Khác nhau giữa `COUNT(*)`, `COUNT(col)`, và `COUNT(DISTINCT col)`?** +`COUNT(*)` đếm row (kể cả NULL). `COUNT(col)` đếm giá trị non-NULL trong cột đó. `COUNT(DISTINCT col)` đếm giá trị unique non-NULL. Trộn lẫn chúng đổi số của bạn thầm lặng. -Vì sao các con số đó? Một leaf page 16 KB chứa cỡ một trăm row, nên 1B row là ~10M leaf page. Các tầng internal chia đi theo fanout mỗi bước: ~10M / 500 ≈ 20K, / 500 ≈ 40, / 500 ≈ 1. Cái "1" cuối cùng là root — tổng chiều cao 4. Đây là phép toán đằng sau câu "index như phép màu": bốn cú nhảy con trỏ để chạm bất kỳ row nào trong bảng tỷ row, và mỗi cú nhảy chỉ là một page fetch. +**Q6. Kiểu cột nào lưu tiền và tại sao không `FLOAT`?** +Đừng lưu tiền bằng `FLOAT`/`DOUBLE` — binary floating point không biểu diễn decimal exact (0.1 + 0.2 ≠ 0.3), gây rounding drift. Dùng `DECIMAL(p, s)` / `NUMERIC` (exact, fixed scale) hoặc lưu integer minor units (cents). Cách "integer cents" tránh decimal math hoàn toàn trong code. -Nhưng mỗi cú nhảy là một lần access page với chi phí khác biệt cực lớn tùy page đó nằm ở đâu: +## Mid — tradeoff & điểm mù -``` -Hit L1/L2 cache → ~5–15 ns (B-tree gần như miễn phí) -RAM / buffer pool → ~100 ns (vì sao working set phải nằm trong memory) -SSD (leaf nguội) → ~0.1–0.5 ms (chậm hơn ba bậc độ lớn) -Đĩa từ → ~5–10 ms (chậm hơn bốn bậc độ lớn) -``` +**Q1. B-tree index hoạt động ra sao, và khi nào vô dụng?** +B-tree index giữ row sort theo cột indexed, nên equality và range scan là O(log n) thay vì O(n). Với 1B row B-tree sâu ~4 level — ~4 random read để tìm một row. Nó vô dụng khi: predicate dùng function trên cột (`WHERE YEAR(created) = 2024` không dùng được index `created` — dùng functional/indexed expression hoặc range), hoặc filter quá unselective (trả >~20–30% row) planner thích full scan. -Vậy mục tiêu thiết kế của senior không phải "tạo index", mà là "**giữ các page index hot nằm trong buffer pool**". Một index tỷ row mà mỗi ngày mới quét một lần là thảm họa quét đĩa mỗi khi chạm tới; một covering index nhỏ và nóng là khoảng cách giữa lookup 100 ns và lookup 10 ms. Khi phỏng vấn viên hỏi "làm sao cho query này nhanh?", bước đầu tiên phải là page residency, không phải tạo index. +**Q2. Composite index và leftmost-prefix rule là gì?** +Composite (multi-column) index như `(a, b, c)` sort theo a, rồi b, rồi c. Nó phục vụ query filter trên `a`, `(a, b)`, hoặc `(a, b, c)` — **leftmost prefix** — nhưng KHÔNG phục vụ query chỉ filter trên `b` hay `c`. Sắp xếp cột theo selectivity và theo predicate bạn thực dùng. Thứ tự cột sai là dead index. -### Composite index — bản WRONG vs RIGHT +**Q3. Giải thích transaction isolation level và anomaly.** -Câu hỏi tách người ta ra: "Đây là query — thiết kế index cho nó." +- **Read uncommitted**: thấy dirty (uncommitted) read. Hiếm dùng. +- **Read committed**: không dirty read, nhưng non-repeatable read (cùng row khác nhau giữa các read trong một txn). +- **Repeatable read**: read row nhất quán trong txn; vẫn có thể phantom read (row mới xuất hiện). +- **Serializable**: full isolation, như chạy serial — an toàn nhất, chậm nhất. + Hầu hết engine mặc định read committed (Postgres repeatable read). Isolation cao hơn = ít anomaly hơn = nhiều lock/overhead hơn. -```sql -SELECT id, status, created_at -FROM orders -WHERE customer_id = ? - AND status = ? -ORDER BY created_at DESC -LIMIT 20; -``` +**Q4. Deadlock là gì và tránh thế nào?** +Deadlock là hai txn mỗi cái giữ lock cái kia cần. Database phát hiện và rollback một. Tránh bằng **truy cập resource theo thứ tự global nhất quán** (luôn update account theo ID), giữ txn ngắn, và không giữ lock qua network call. Luôn sẵn sàng retry txn bị rollback. -**WRONG — cách phần lớn junior trả lời:** +**Q5. N+1 query problem là gì và fix thế nào?** +ORM load list N parent, rồi issue N query riêng cho child ("N+1"). Fix: eager fetch / `JOIN FETCH` / batch fetch (`@BatchSize`) để thành 1 hoặc vài query. N+1 là silent performance killer #1 trong JPA/Hibernate — đẹp ở test (data nhỏ) và tan chảy ở prod. -```sql -CREATE INDEX idx_orders_status ON orders(status); -- vô dụng: 99% row là 'paid' -CREATE INDEX idx_orders_customer ON orders(customer_id); -- đúng cột, nhưng ép filesort -``` +**Q6. Connection pooling là gì và tại sao bạn cạn nó?** +Pool tái dùng DB connection (mở một cái tốn ~ms đến tens of ms). Bạn cạn nó bằng: (1) leak connection (quên close / không dùng try-with-resources), (2) giữ connection trong long transaction hay external call, (3) `maxPoolSize` quá thấp cho concurrency. Triệu chứng: `Timeout: could not get a connection`. Tune `maximumPoolSize` theo (core_concurrency × avg_query_time / target_latency) và không bao giờ làm slow work trên pooled connection. -`status` cardinality thấp: 99% order là `'paid'`. Optimizer thấy selectivity tệ đến vậy thì hoặc full-scan, hoặc dùng index rồi lọc tiếp vài triệu row. Còn index `customer_id` đơn lẻ trả về toàn bộ lịch sử đơn hàng của khách, phải sort trên đĩa trước khi `LIMIT 20` — một filesort ngày càng chậm khi "tuổi" tài khoản tăng, rồi kéo theo temp table, rồi có thể tràn ra đĩa. +## Senior — thiết kế & phòng thủ -**RIGHT — equality trước, range/ordering cuối, leaf page đã sắp xếp:** +**Q1. Một report query trên bảng 500M row timeout. Đi qua chẩn đoán và fix.** +"Tôi `EXPLAIN ANALYZE` nó — thường là sequential scan vì predicate bọc cột trong function, hoặc index không leftmost-matching. Nếu là aggregation report, tôi hỏi có cần real-time không: thường materialized view refresh mỗi 5–15 phút là đáp án đúng, biến scan 30 s thành read 50 ms. Nếu phải live, tôi thêm covering composite index để planner làm index-only scan. Tôi chứng minh fix bằng `EXPLAIN ANALYZE` before/after và confirm p95." -```sql -CREATE INDEX idx_orders_cust_status_created - ON orders(customer_id, status, created_at DESC); -``` +**Q2. Thiết kế schema cho orders table ở 1M orders/day. Indexing strategy?** +"Tôi partition theo time (vd monthly range partition) để archive partition cũ và query gần đây scan ít hơn. Index `(customer_id, created_at)` cho query phổ biến 'my orders, newest first' (leftmost prefix + sort), và index riêng trên `status` chỉ nếu selective. Tôi tránh index mọi cột — mỗi index làm chậm write, và ở 1M/day write amplification quan trọng. Tôi cũng chuyển hot analytics sang read replica / columnar store thay vì hammer primary." -Ba quy tắc từ leftmost-prefix principle: +**Q3. Chọn isolation level cho payment service thế nào, và phòng thủ bằng failure mode?** +"Cho payment tôi dùng `REPEATABLE READ` hoặc `SERIALIZABLE` trên critical transfer path — non-repeatable read ở đó có thể double-debit. Cái giá: nhiều lock hơn, có thể serialization failure dưới contention, nên tôi giữ txn đó thật nhỏ (chỉ balance math, không external call) và retry khi serialization fail. Cho read-heavy reporting tôi drop xuống `READ COMMITTED` trên replica. Phòng thủ là: anomaly bạn không chịu được quyết định level; bạn trả cho isolation chỉ ở nơi có tiền." -1. **Cột equality trước** — `customer_id` và `status` thu hẹp đường đi trên cây bằng phép so sánh `=`. -2. **Cột range/ordering cuối** — một cột range là điểm dừng; bất cứ thứ gì đứng sau nó không tham gia được vào đường đi. -3. **Khớp `ORDER BY`** — leaf page đã sắp theo `created_at DESC`, nên planner đi qua chúng đúng thứ tự và dừng sau 20 row. Không filesort, không temp table. Nếu bạn còn chỉ `SELECT` đúng các cột đã index, bạn có **index-only scan** — leaf page chứa tất cả, và clustered index (bản thân bảng) không bao giờ bị chạm. +**Q4. Bạn thấy lock wait và timeout dưới tải vừa. Tìm nguyên nhân.** +"Tôi xem `pg_locks` / `SHOW ENGINE INNODB STATUS` cho blocking session và statement nó giữ. Chín trên mười là long transaction giữ row lock trong khi làm việc chậm (call, log, sleep) — lock bị giữ vài giây thay vì ms. Fix: thu nhỏ txn chỉ còn write tối thiểu, đẩy slow work ra ngoài, và thêm lock timeout để blocked txn fail nhanh thay vì cascade. Tôi đo lock-wait time trước/sau." -```text -EXPLAIN: -type: ref -key: idx_orders_cust_status_created -rows: 20 -Extra: Using index condition; Backward index scan -``` +**Q5. ORM hay raw SQL — khi nào bỏ JPA cho hand-written SQL?** +"Khi query phức tạp (deep join, window function, bulk update) hoặc performance-critical, SQL JPA sinh ra opaque và thường làm N+1 hoặc fetch quá nhiều. Tôi dùng thin JDBC/`JdbcTemplate` hoặc jOOQ query với đúng cột cần, map sang DTO. Quy tắc: JPA cho CRUD trên entity đơn giản; hand-written SQL (hoặc jOOQ) cho report, bulk op, và hot path. Tôi không bao giờ để ORM che giấu full-table fetch trong production." -Bài tập phỏng vấn là xáo trộn các mệnh đề: +**Q6. Bạn phòng thủ con số connection-pool sizing cho team thế nào?** +"Tôi size nó từ Little's Law: `pool_size ≈ target_concurrency × (avg_query_time / acceptable_latency)`. Nếu query trung bình 5 ms và cần 200 concurrent, đó là ~200 × (0.005 / 0.1) ≈ 10, nhưng tôi pad cho variance và failover, chốt ~20–30, không 200. Oversize lãng phí DB connection (mỗi cái giữ memory + backend process) và có thể _tệ hơn_ throughput bằng tăng lock contention. Tôi set `maximumPoolSize` có chủ đích, monitor wait time, và tune từ số thật — không phải `200` vì 'nhiều hơn tốt hơn'." -```sql -WHERE customer_id = ? AND created_at > ? -- muốn (customer_id, created_at) -WHERE status = ? ORDER BY created_at LIMIT 20 -- muốn (status, created_at) -WHERE created_at > ? ORDER BY created_at LIMIT 20 -- riêng created_at, và nó đóng cả hai vai -``` +#### Self-check -`(created_at, customer_id)` thay vì `(customer_id, created_at)` là thứ tự ngây thơ và tệ hơn hẳn — range trên `created_at` chặn đường đi, nên `customer_id` không bao giờ được dùng để lọc. Phỏng vấn viên cực thích đảo chúng; hãy sẵn sàng biện hộ cho từng vị trí. - -### Khi index phản bội bạn - -- **Cardinality thấp.** Index trên `gender` hay `is_deleted` có thể tốn chi phí quét nhiều hơn chính bảng; planner lặng lẽ bỏ qua nó. Khi `EXPLAIN` cho thấy index được dùng mà `rows` vẫn bảy chữ số, đó là khẩu súng còn bốc khói — optimizer đang đi trên một index lọc gần như không gì cả. -- **Hàm và implicit cast.** `WHERE lower(email) = ?` làm index trên `email` vô dụng — cột bị biến đổi trước khi so sánh, nên cây không đi được. Tương tự `WHERE order_no = 12345` trên cột `VARCHAR`: mỗi row đều bị cast. Cách sửa: expression index trong Postgres (`CREATE INDEX ON users(lower(email))`), functional index trong MySQL 8.0.13+, generated column trong MySQL 5.7+, hoặc — đơn giản nhất — đừng lưu dữ liệu bắt buộc phải cast. -- **Wildcard ở đầu.** `LIKE '%guru'` không dùng được prefix index; `LIKE 'guru%'` thì đi được. Bản senior: full-text index hay trigram (`pg_trgm`) khi wildcard đầu là bất khả kháng. -- **Một range hay `IN` nằm giữa index.** `(a, b, c)` với `WHERE a = ? AND c = ?` trong khi `b` là range nghĩa là `c` chỉ lọc trong khoảng `b` đã fetch. Thứ tự cột là một hợp đồng; hỏi planner thì nó sẽ vui vẻ giải thích. -- **`NULL`.** Trong Postgres, `NULL` sort lên đầu theo mặc định và hầu hết loại index đều chứa chúng; `WHERE x IS NULL` dùng được index, nhưng index `UNIQUE` coi các `NULL` là khác nhau (cho phép nhiều `NULL`). Trong InnoDB, unique index cũng cho phép nhiều `NULL` — "unique" không có nghĩa "không null". -- **`EXPLAIN` ước lượng nói dối.** Dự đoán cũ của planner từ statistics lỗi thời đẩy bạn vào một plan tồi. `ANALYZE TABLE` (MySQL) / `ANALYZE` (PG), rồi chạy lại. Senior trích dẫn actual của `EXPLAIN ANALYZE`, không phải dự đoán của planner — khoảng cách "`rows` vs `actual rows`" chính là nơi query chậm thú nhận tội. - -### Bẫy clustered key: UUID vs BIGINT - -Đây là câu tách người từng chứng kiến incident production khỏi người mới chỉ đọc docs. Trong InnoDB, clustered index **chính là** bảng, sắp theo primary key. Chèn một `UUID` (v4) ngẫu nhiên tức là bạn đang chèn vào một vị trí ngẫu nhiên trong một cấu trúc đã sắp xếp: - -```sql --- WRONG cho bảng hot: primary key chính là thứ tự vật lý của row -CREATE TABLE orders ( - id BINARY(16) PRIMARY KEY, -- hoặc CHAR(36) chứa chuỗi UUIDv4 - ... -); -``` - -Mỗi insert rơi vào một leaf page ngẫu nhiên → page split, phân mảnh, và mỗi page ngẫu nhiên là một cache miss khi đọc. Bạn trả thuế kép: write fan-out nhân đôi mỗi khi cây tái cân bằng, và "cái đầu nóng" của index (nơi `BIGINT AUTO_INCREMENT` lẽ ra ghi) không còn nằm trong buffer pool. Với insert rate cao, đây là khoảng cách giữa ghi tuần tự append-only và một cơn bão ghi đĩa làm sập p95 latency. Cách sửa: - -- `BIGINT` identity (hoặc `IDENTITY` / sequence) — insert tuần tự, page đuôi nóng luôn được cache. -- `UUIDv7` (sắp theo thời gian) — trung dung "global, nhưng gần tuần tự" hiện đại; MySQL 9+ có `UUIDv7()`. -- ID kiểu Snowflake — tuần tự theo từng worker, shard được qua các node. - -Senior không bao giờ nói "UUID chậm" — họ nói "UUID ngẫu nhiên phá vỡ locality của clustered index; đây là cách tôi đo page split và vì sao UUIDv7 sửa được nó." - -### Buffer-pool hit ratio — con số phỏng vấn viên hay moi - -InnoDB phơi nó ra trực tiếp: - -```sql -SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests'; -- tổng logical reads -SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads'; -- số physical disk reads thật -``` - -``` -hit_ratio = 1 - (physical_reads / logical_reads) -``` - -Workload OLTP muốn hit ratio trên 99%. Dưới ~95%, "database nhanh" của bạn thực chất là một cái máy đọc đĩa — random point read cứ dập vào storage, throughput sụp, và cách sửa thường là **working set không vừa memory**, không phải thêm CPU và không phải index tốt hơn. Câu hỏi follow-up kinh điển: "working set 2 TB mà buffer pool 128 GB — bạn làm gì?" Câu trả lời senior bắt đầu bằng "10% dữ liệu nào phục vụ 90% số reads" — hot-row caching, denormalize một cột hot, hoặc tách bảng hot/cold — chứ không phải "mua thêm RAM". Và trước khi chỉnh bất cứ gì: `SHOW ENGINE INNODB STATUS` để xem trạng thái pool tạm thời, đồng thời tách **one-shot scan** (báo cáo, `SELECT COUNT(*)`) khỏi point lookup — một query analytics chạy đêm có thể kéo hit ratio xuống trong khi workload thật của bạn vẫn ổn. - -## 2. Transaction & isolation — pháp y các anomaly - -Đọc thuộc bốn mức là câu trả lời của mid-level. Câu trả lời của senior là các trace — anomaly mà giáo trình bỏ qua, lock chặn cả hàng đợi của bạn, và nội tại MVCC giải thích vì sao hai database bất đồng về REPEATABLE READ. - -### Ma trận, cộng cái "nhưng" chẳng ai nói ra - -- **Dirty read** — chặn ở READ COMMITTED. -- **Non-repeatable read** — chặn ở REPEATABLE READ. -- **Phantom read** — SQL chuẩn cho phép nó dưới REPEATABLE READ, nhưng **InnoDB vẫn chặn nó** nhờ next-key lock, và REPEATABLE READ của Postgres (snapshot isolation) không bao giờ lộ phantom khi đọc. - -Vậy khi phỏng vấn viên hỏi "mức cô lập nào chặn phantom read?", câu trả lời trong sách giáo khoa là SERIALIZABLE — và đó là cái bẫy. Trong InnoDB, REPEATABLE READ đã chặn rồi, vì mọi _locking_ read dưới RR đều lấy next-key lock (row + gap). Và đây là phần giúp ứng viên senior thắng follow-up: **REPEATABLE READ của InnoDB và REPEATABLE READ của Postgres là hai con vật khác nhau.** - -- **InnoDB RR** = MVCC consistent read + next-key lock trên locking read/DML. Phantom bị chặn với các thao tác _locking_ nhờ gap lock. -- **Postgres RR** = snapshot isolation thuần (MVCC, kiểu SSI). **Không hề có gap lock**, nên locking read không bao giờ block vì "những row chưa tồn tại" — phantom bị loại cho _đọc_ nhờ snapshot, nhưng hai `SELECT ... FOR UPDATE` trên cùng một khoảng trống không bao giờ chặn nhau. - -Cả hai engine dù vậy đều chung một lỗ hổng: **write skew sống sót qua REPEATABLE READ** ở cả hai, vì những read quan trọng là read _không locking_ — xem bên dưới. - -### Nội tại MVCC — lớp nằm dưới câu trả lời - -Bạn không kể được các anomaly cô lập mà không biết "consistent read" thực sự là gì. Trong InnoDB: - -1. Mỗi row mang các cột ẩn: transaction ID và roll pointer trỏ vào **undo log**. -2. Một `UPDATE` không ghi đè row — nó ghi một **phiên bản mới** và đẩy phiên bản cũ vào undo log (version chain). -3. Read đầu tiên của một transaction trong RR tạo một **read view** — snapshot của "những transaction đã commit trước khi tôi bắt đầu". -4. Một read đi theo version chain và trả phiên bản mới nhất mà snapshot nhìn thấy. Ai cũng đọc lịch sử riêng của mình về bảng, nên reader không bao giờ block writer và writer không bao giờ block reader. - -Điểm cuối cùng chính là lý do bạn thấy `MVCC` trong mọi mô tả công việc: "reader không block writer." Tradeoff chẳng ai tình nguyện nhắc là mỗi version bạn giữ lại **tốn đĩa và CPU**, và transaction dài làm đóng băng garbage collector: - -- InnoDB: undo log purge không thể thu hồi những version một transaction chạy lâu vẫn có thể đọc. `SHOW ENGINE INNODB STATUS` → để mắt tới **history list length**. Nó tăng, undo tablespace phình ra, và một transaction báo cáo chạy 30 phút có thể âm thầm nhân đôi dung lượng đĩa của bạn. -- Postgres: các row version cũ ở lại thành **dead tuple**, và autovacuum theo không kịp. Bảng phình ra, và index scan của bạn chậm lại _ngay cả khi trả đúng một row_ — vì page đầy những con ma. - -Failure mode production phỏng vấn viên hay moi: "một batch transaction chạy đêm 45 phút, sáng hôm sau mọi write chậm lại." Câu trả lời senior: read snapshot giữ purge/vacuum lại, undo/dead-tuple list phình, page write chậm, và các transaction _ngắn_ lẽ ra 10 ms bắt đầu giật cục vì buffer replacement. Cách sửa thường là **transaction ngắn hơn** (commit theo batch), không phải hardware to hơn. - -### Trace phantom read (dưới READ COMMITTED) - -``` -T1: BEGIN; -T1: SELECT COUNT(*) FROM shifts WHERE day = 'Monday'; -- 5 - -T2: BEGIN; -T2: INSERT INTO shifts(day) VALUES ('Monday'); COMMIT; - -T1: SELECT COUNT(*) FROM shifts WHERE day = 'Monday'; -- 6 ← phantom -``` - -Tập row đã đổi ngay dưới chân T1. Chú ý khác biệt giữa RC và RR ở đây: dưới **READ COMMITTED** mỗi statement nhận một read view mới, nên `COUNT` thứ hai của T1 thấy insert đã commit của T2 — cả phantom lẫn non-repeatable read đều xuất hiện. Dưới **REPEATABLE READ** read view được cố định ở lần đọc đầu tiên, nên cả hai COUNT đều trả 5 (đó là lý do RR "chặn" nó cho việc đọc). Nếu bạn kể được _vì sao_ mức cô lập đổi kết quả — read view mới cho từng statement thay vì từng transaction — thì bạn đang nói bằng engine, không phải bằng giáo trình. - -Bạn sửa bằng SERIALIZABLE hoặc lock tường minh — và trả giá bằng concurrency. Cái giá đó chính là tradeoff phỏng vấn viên muốn nghe bạn nêu tên: **isolation level là một nút chỉnh latency/throughput, không phải ô checkbox an toàn.** - -### Lost update và write skew — lãnh thổ senior - -Lost update là dạng dễ, sửa bằng cột version (optimistic locking): - -```sql -UPDATE accounts SET balance = balance - 100, version = version + 1 -WHERE id = ? AND version = ?; --- 0 row bị ảnh hưởng => có người đi trước => retry hoặc reject -``` - -Anomaly thực sự cắn người ta trong phỏng vấn (và production) là **write skew**: hai transaction mỗi bên đọc state chồng lấn, không bên nào block bên kia vì chúng ghi vào các row _khác nhau_, và invariant lặng lẽ chết. - -``` -T1: BEGIN; -T1: SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 1, giới hạn là 1 - -T2: BEGIN; -T2: SELECT COUNT(*) FROM doctors WHERE on_call = true; -- 1, giới hạn là 1 - -T1: UPDATE doctors SET on_call = true WHERE id = 101; -- ok, "vẫn một" theo read của tôi -T2: UPDATE doctors SET on_call = true WHERE id = 102; -- ok, "vẫn một" theo read của tôi --- COMMIT × 2 → giờ HAI bác sĩ cùng on_call. Invariant vỡ. -``` - -Không dirty read, không lost update — snapshot nhất quán với từng transaction, mà constraint vẫn vỡ. Đây là anomaly duy nhất sống sót qua REPEATABLE READ ở **cả** InnoDB và Postgres, vì `SELECT COUNT(*)` là một read MVCC _không locking_: không transaction nào giữ một lock mà transaction kia có thể chờ. Các cách sửa: - -```sql --- PESSIMISTIC: lock các row đã kiểm tra, nên T2 chờ tới khi T1 commit -SELECT COUNT(*) FROM doctors WHERE on_call = true FOR UPDATE; - --- Hoặc serialize toàn bộ read-modify-write trên một guard row duy nhất -SELECT ... FROM doctor_schedule WHERE id = ? FOR UPDATE; - --- USE-CASE HÀNG ĐỢI: không chờ gì cả -SELECT ... FOR UPDATE SKIP LOCKED; -- nhận một task, bỏ qua các task đang bị lock -``` - -- **Pessimistic (`FOR UPDATE`)** — locking read của T1 giữ next-key lock; T2 block tới khi T1 commit, rồi đọc lại và thấy đã có hai người on_call → reject. Đúng, nhưng bạn serialize mọi thay đổi on-call. -- **Optimistic (cột version)** — cả hai tăng version, `UPDATE` của kẻ thua trả 0 row, app retry. -- **Postgres SERIALIZABLE (SSI)** — engine phát hiện read-write dependency lúc commit và **abort một transaction** với `40001 serialization_failure`. App _bắt buộc_ phải bắt và retry; không retry thì bạn đang biến serializable thành mất dữ liệu. - -Nếu bạn tự sinh trace này mà không cần gợi ý, nêu đúng nguyên nhân gốc là non-locking read, và đưa ra bộ ba pessimistic + optimistic + SSI, bạn đã vượt thanh cao nhất của phần này. - -### Deadlock — câu follow-up luôn được thả ra - -Ngay sau write skew, phỏng vấn viên quay sang: "bạn deploy, và đột nhiên `DeadlockLoserDataAccessException` đầy log." Câu trả lời senior không phải "thêm retry" — mà là "đọc bản báo cáo deadlock." - -- **InnoDB phát hiện deadlock** và rollback transaction làm ít việc hơn (ít undo bytes hơn). `SHOW ENGINE INNODB STATUS` in ra hai transaction, chính xác các lock đang giữ, và SQL bị chặn. -- **Pattern**: T1 lock row A rồi muốn row B; T2 lock row B rồi muốn row A. Cùng một _thứ tự lock_ trong mọi transaction là cách sửa — sort các key của `WHERE id IN (...)`, lock cha trước con. -- **`NOWAIT` / `SKIP LOCKED`** là cửa thoát cho queue-consumer pattern; một job queue mà _chờ_ trên các row bị lock sẽ tự deadlock tới chết dưới tải. - -```java -// WRONG: deadlock → exception → transaction rollback → job mất tích -try { - doTransfer(a, b); -} catch (DeadlockLoserDataAccessException e) { - // nuốt: tiền đã chuyển một lần, hoặc không hề chuyển — ta không biết -} - -// RIGHT: retry exponential backoff có chặn trên, và idempotency trên write -int retries = 0; -while (retries < 3) { - try { - doTransfer(a, b); // update idempotent nhờ unique txn_id - break; - } catch (DeadlockLoserDataAccessException e) { - retries++; - Thread.sleep(50L << retries); // 100ms, 200ms, 400ms - } -} -``` - -## 3. Connection pooling — khoảng cách giữa 2000 và 50 connection - -Heuristic HikariCP `connections ≈ ((core_count * 2) + effective_spindle_count)` chỉ là con số khởi điểm — và mọi senior đều biết nó chỉ là con số khởi điểm. Con số bảo vệ được đến từ định luật Little: - -``` -Định luật Little: công việc đang bay = arrival rate × thời gian mỗi request giữ tài nguyên - -pool_size = requests_per_second × số giây một connection bị checkout -500 req/s × 0.05 s = 25 connections -``` - -Làm phép tính đó đi thì bạn sẽ không phải người đặt pool 200 chỉ vì máy có 64 core. Và đây là hai con số khiến tiêu đề của phần này trở nên cụ thể: một connection đơn lẻ chạy thoải mái **cỡ một nghìn transaction ngắn mỗi giây** (query 1 ms ~ 1000/s; query 10 ms ~ 100/s). Vậy pool 50 connection không phải "50 user đồng thời" — nó cỡ **50.000 short TPS**, nhiều hơn hầu hết service từng thấy. Pool 2000 không phải throughput gấp 40×; nó là 2000 thread chờ một database chỉ phục vụ được một phần nhỏ trong số đó. - -Điểm tinh tế làm kể cả ứng viên mạnh cũng vấp: **connection bị giữ trọn cả thời gian checkout, chứ không phải mỗi query.** Nếu request của bạn checkout một connection, chạy query A, làm 100 ms business logic trong Java, rồi mới chạy query B, pool phải đủ cho _cả_ 150 ms. Định luật Little với W sai (query time thay vì transaction time) sinh ra một pool nhỏ 3× và xếp hàng ngay tại DB — chính cái failure bạn đang cố tránh. - -- **Quá lớn** → context-switch thrash, hàng trăm MB connection rỗi ở phía DB (MySQL thread-per-connection: mỗi connection rỗi là một thread + stack + buffer), và queueing _bên trong_ database. -- **Quá nhỏ** → request xếp hàng tại `connectionTimeout`, latency tăng, rồi throughput sụp — sự cố "pool là nút thắt, không phải DB". -- **R2DBC non-blocking**: thread không bao giờ block trên I/O, nên pool 10–20 connection là quá đủ — pool được định cỡ theo concurrency, không theo tải. - -### Các failure mode production, vì phỏng vấn viên hay hỏi về incident - -- **Connection bị rò rỉ.** `getConnection()` mà không release thì pool cạn kiệt → `Connection is not available, request timed out` → mọi request chồng đống → outage. Đây là sự cố kinh điển "DB tưởng chết mà thực ra vẫn ổn, pool rỗng". Sửa bằng try-with-resources và `leakDetectionThreshold` để pool báo cho bạn trước khi khách hàng báo. -- **`maxLifetime` vs timeout của server.** `wait_timeout` của MySQL mặc định 8 giờ; nếu pool giữ connection quá mức đó, server âm thầm giết nó và bạn gặp `Communications link failure`. `maxLifetime` của pool phải thấp hơn idle timeout của server. Chiều ngược lại: `connectionTimeout` của pool (mặc định 30 s trong HikariCP) là thời gian một request _chờ_ connection rảnh — nếu thấy timeout, hãy kiểm tra queueing trước khi soi DB. -- **`minimumIdle` = `maximumPoolSize` thì ổn với service hot**, nhưng với service bursty pool nên được phép rút bớt connection rỗi; chỉnh `idleTimeout` để một cú spike không để 200 socket đỗ xe cả buổi chiều. -- **Bật chẩn đoán:** `leakDetectionThreshold`, `connectionTimeout`, `validationTimeout`, và `isValid()` của JDBC4 (không phải một vòng round-trip `SELECT 1`) là bắt buộc trong production. Theo dõi `active` vs `idle` trong Hikari metrics — một pool luôn `active` ở mức max chính là một hàng đợi đội lốt. - -```java -// WRONG: một exception ở giữa là connection mất tích vĩnh viễn -Connection c = pool.getConnection(); -Statement s = c.createStatement(); -s.execute("UPDATE accounts SET balance = balance - 100 WHERE id = ?"); -c.close(); // không bao giờ tới nếu execute ném → rò rỉ → pool cạn - -// RIGHT: try-with-resources đảm bảo release trên mọi đường đi -try (Connection c = pool.getConnection(); - PreparedStatement ps = c.prepareStatement( - "UPDATE accounts SET balance = balance - ? WHERE id = ?")) { - ps.setBigDecimal(1, amount); - ps.setInt(2, accountId); - ps.executeUpdate(); -} -``` - -Và mối ràng buộc với ORM khép kín vòng lặp: nếu bạn đang trên Spring Boot mà còn bật Open Session in View, pool của bạn đang bị bắt làm con tin — chi tiết ở phần 5. - -## 4. SQL vs NoSQL — quyết định theo access pattern + consistency, không theo hype - -Nói "NoSQL nhanh hơn" là bạn tự thua vòng phỏng vấn. Cách đặt vấn đề trung thực: mỗi store đưa ra một hợp đồng consistency, flexibility và scale khác nhau, và việc chọn là một tradeoff, không phải cuộc đua tốc độ. Phỏng vấn viên muốn nghe bạn hỏi ba câu _trước khi_ nêu tên một công nghệ: - -1. **Access pattern là gì?** — point lookup theo key, range scan, join, aggregation, hay append-only? -2. **Hợp đồng consistency mà nghiệp vụ cần là gì?** — read-your-writes cho giỏ hàng khác với eventual cho analytics. -3. **Write/read ratio và cardinality của hot key space là bao nhiêu?** - -- **Relational (Postgres/MySQL)** — join, transaction, referential integrity, và khả năng `EXPLAIN` để thoát khỏi cái hố performance. Lựa chọn mặc định khi dữ liệu có quan hệ và có tiền di chuyển. Postgres hiện đại làm mờ ranh giới: `jsonb` cho bạn một document store với `GIN` index và một query planner thực thụ. -- **Document (MongoDB)** — schema linh hoạt và scale ngang, nhưng join phía server bị giới hạn (`$lookup` là một chi phí pipeline aggregation tăng rất nhanh), document bị chặn ở 16 MB, và một shard key tồi sinh ra **hot shard** chặn throughput dù bạn thêm bao nhiêu node. Một shard key phải phân tán writes VÀ khớp với reads — "ai cũng query theo `customer_id`, nên shard theo `customer_id`" là câu trả lời senior; shard theo `created_at` khiến mọi write gần đây dồn về một shard. -- **Wide-column (Cassandra)** — thiết kế log-structured ghi mọi nơi (LSM) cho scale write-heavy, với consistency điều chỉnh được. QUORUM với RF=3 nghĩa là hai node phải đồng thuận; **eventual consistency ổn cho telemetry và nguy hiểm cho ledger**. Reads mới là phần đắt — một read phải merge qua các memtable và SSTable, nên "Cassandra nhanh" nghĩa là _write nhanh_, và read path chính là nơi bất ngờ cư trú. -- **Redis** — một cache/counter/pub-sub với giả định bền vững dựa vào RAM, không phải store bền vững. Core đơn thread, nên vài 10k ops/s ở p99 — pipelining quan trọng hơn bạn tưởng. Nếu bạn tuyên bố nó là source of truth, hãy sẵn sàng bảo vệ tradeoff của AOF + fsync (fsync mỗi write → vài nghìn ops/s; fsync mỗi giây → mất tối đa một giây dữ liệu khi crash) và eviction policy (`allkeys-lru` vs `volatile-lru`) quyết định cache sống hay chết. - -Và sắc thái gây ấn tượng tốt: `jsonb` của Postgres làm mờ ranh giới relational/document — bạn có thể có schema cho các cột tiền bạc và một JSON document cho các cột linh hoạt, với index vào trong JSON. "Tôi lưu order lines là bảng relational còn metadata của từng nhà cung cấp là jsonb" đánh bại "Tôi xài MongoDB" trong hầu hết buổi phỏng vấn backend. Các đáp án NoSQL có lý do chính đáng: event log append-only và telemetry → Cassandra/ClickHouse; per-user profile hay đổi với hot row → Redis + relational; dữ liệu dạng document linh hoạt mà cần query thật → Postgres `jsonb`. - -## 5. N+1 và bẫy ORM - -Câu trả lời của người mới là "xài JOIN FETCH." Câu trả lời của senior là "xài JOIN FETCH, rồi biết nó vỡ ở đâu, rồi đo lường SQL mà ORM thực sự chạy." - -**Phiên bản kinh điển:** - -```java -// WRONG: 1 query cho các parent + 1 query cho mỗi child = N+1 -List orders = orderRepository.findAll(); -for (Order order : orders) { - order.getLineItems().size(); // lazy-load bắn ra ở đây, mỗi order một lần -} -``` - -1.000 order → 1.001 query. Với 10 row thì không thấy gì; với 10 triệu thì nó nấu chín database. Đường cong "chạy với 10, chết với 10M" đó chính là thứ phỏng vấn viên thích hỏi. Rồi họ bảo bạn _chứng minh_ nó tồn tại trong production mà không cần IDE — và câu trả lời senior là `format_sql` + timing (`slow_query_log` trong MySQL, `auto_explain` trong Postgres cho thấy 1.001 query tuần tự), hoặc chỉ cần thấy bộ đếm query của DB nhảy đúng một lần mỗi parent row. - -**RIGHT — fetch cả graph trong một câu lệnh:** - -```java -// Hibernate -List orders = em.createQuery( - "select distinct o from Order o join fetch o.lineItems", Order.class) - .getResultList(); - -// Spring Data -@Query("select o from Order o join fetch o.lineItems") -List findAllWithItems(); -``` - -Các lựa chọn thay thế kèm tradeoff riêng: `@BatchSize` (batch fetching: `1 + ceil(N/1000)` query thay vì `1 + N` — đúng lúc danh sách parent lớn và một join sẽ nổ thành cartesian product), entity graph/`@EntityGraph` cho fetch strategy theo từng use-case, hoặc — nước đi senior nhất — bỏ qua entity hoàn toàn và **project một DTO** với đúng các cột trang cần: - -```java -@Query(""" - select new com.acme.dto.OrderItemDTO(o.id, o.status, l.sku, l.qty) - from Order o join o.lineItems l - where o.customerId = :customerId - """) -List findForCustomer(long customerId); -``` - -Một query, không managed entity, không bẫy lazy, và payload row nhỏ nhất. Nếu bạn giải thích được _khi nào ngừng dùng JOIN FETCH và project thay thế_ — đó chính là điểm ngoặt senior. - -**Nơi JOIN FETCH phản bội bạn — gotcha chỉ dành cho senior.** Phân trang một query fetch-join một `Collection` thì Hibernate không áp dụng được `LIMIT` trong SQL, vì join nhân số row. Nó rơi về **in-memory pagination** — tải toàn bộ result set rồi cắt trong JVM. Dòng log là `HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!` — và "page 2" của bạn giờ đây sai lặng lẽ, database đột nhiên phải làm việc của một full scan. Cách sửa: fetch ID của page 1 trước, rồi fetch child cho các ID đó trong query thứ hai, hoặc phân trang một DTO projection. - -**Bẫy OSIV — cài đặt ngầm giấu kín của Spring Boot.** `spring.jpa.open-in-view` mặc định là **true**, và Spring Boot log một cảnh báo mỗi lần khởi động. OSIV giữ EntityManager (và connection JDBC của nó) mở **trọn cả HTTP request**, kể cả sau khi transaction của bạn đã commit. Hai hệ quả: lazy load ở bất kỳ đâu trong request (kể cả serializer và template rendering) "tự nhiên chạy" — che giấu N+1 — và connection pool của bạn bị giữ mở suốt thời gian request, nghĩa là **phép tính định cỡ pool ở phần 3 giờ lệch đi đúng bằng chiều rộng của endpoint chậm nhất của bạn**. Đòn senior: tắt OSIV (`spring.jpa.open-in-view: false`), xử lý lazy loading trong transaction (hoặc fetch eager), và để pool release connection ngay khi business logic xong. Điều đầu tiên bạn gặp khi tắt OSIV là `LazyInitializationException` từ serializer — và đó là một tính năng, không phải bug: framework cuối cùng đã chỉ cho bạn N+1 nằm ở đâu. - -Và meta-skill nằm dưới tất cả: **đọc SQL được sinh ra.** Bật `spring.jpa.properties.hibernate.format_sql=true` hoặc gắn p6spy, và đối chiếu điều bạn _tưởng_ mình viết với SQL ORM _thực sự_ thực thi. Senior coi ORM là một code generator có chính kiến, không phải hộp đen — và biết rằng `@Cacheable`/second-level cache chỉ là giải pháp _cuối cùng_ cho dữ liệu dùng chung nóng và ít thay đổi, vì invalidation giữa các node là nơi cache lặng lẽ trở nên stale. - -## 6. Tự kiểm tra - -- [ ] Thiết kế composite index cho `WHERE customer_id = ? AND status = ? ORDER BY created_at` và biện hộ thứ tự cột — xuống tận leftmost-prefix rule và thứ tự leaf `DESC`. -- [ ] Giải thích vì sao primary key `UUIDv4` là thảm họa clustered index và `UUIDv7` thay đổi điều gì. -- [ ] Anomaly nào REPEATABLE READ _chuẩn_ cho phép, vì sao InnoDB vẫn chặn nó với locking read, và vì sao RR của Postgres cư xử khác? -- [ ] Dựng trace write skew hai transaction và bộ ba sửa pessimistic + optimistic + SSI. -- [ ] Đọc một deadlock từ `SHOW ENGINE INNODB STATUS` và viết vòng retry. -- [ ] Định cỡ connection pool từ throughput và hold-time bằng định luật Little — và giải thích vì sao "hold time" không phải query time. -- [ ] Tìm và sửa N+1 trong một snippet, rồi giải thích gotcha phân trang fetch-join và bẫy OSIV. -- [ ] Nêu tên hai status variable tính hit ratio buffer-pool của InnoDB và nước đi đầu tiên khi nó tụt. - -## 7. Interviewer follow-ups - -Khi câu trả lời đầu tiên của bạn chạm đúng, họ bắt đầu khoan. Sẵn sàng cho những câu này: - -- "Query trả về 40M row — index còn được dùng không, và nó có đúng hình dạng không?" -- "Vì sao optimizer bỏ qua index trên `status` của tôi dù `EXPLAIN` hiển thị nó?" -- "Working set của bạn không vừa buffer pool. Nước đi đầu tiên là gì?" -- "SERIALIZABLE có chặn được write skew không? Nó tốn những gì — và ai chịu trách nhiệm retry?" -- "Bạn thấy `Connection is not available, request timed out` ở 100 req/s với pool 25. Phép toán thế nào, và bạn kiểm tra gì đầu tiên?" -- "Khi nào bạn vẫn chọn MySQL thay vì Postgres, hoặc Postgres thay vì MongoDB — và `jsonb` thay đổi điều gì?" -- "Làm sao chứng minh N+1 tồn tại trong production mà không mở IDE?" -- "Transaction đọc chạy lâu đang làm chậm mọi write. Bloat nằm ở đâu, và bạn đổi gì?" -- "Một job queue liên tục deadlock dưới tải. Thứ đầu tiên bạn đổi là gì — và vì sao `SKIP LOCKED`?" -- "Giải thích vì sao Postgres `SERIALIZABLE` có thể abort một transaction vừa commit một write logic hợp lệ." - -Đó là bar database. +- [ ] Junior: Tôi giải thích được PK/FK/index, INNER vs LEFT join, WHERE vs HAVING, ACID, và tại sao không FLOAT cho tiền. +- [ ] Mid: Tôi giải thích được B-tree indexing, composite leftmost-prefix, isolation level, deadlock, N+1, và pool exhaustion. +- [ ] Senior: Tôi chẩn đoán được slow report query với EXPLAIN ANALYZE, thiết kế partitioning + indexing cho 1M/day, chọn isolation theo failure mode, và size connection pool từ Little's Law bằng số thật. From 5a547847e390dc7772dc8dc86ffb2c0969b9b0cf Mon Sep 17 00:00:00 2001 From: Tester Date: Thu, 13 Aug 2026 06:34:37 +0000 Subject: [PATCH 5/8] =?UTF-8?q?docs(interview):=20rewrite=20kafka=20as=20J?= =?UTF-8?q?unior=E2=86=92Senior=20Q&A=20series=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/blog/en/interview/kafka-senior.md | 348 +++----------------- src/data/blog/vi/interview/kafka-senior.md | 350 +++------------------ 2 files changed, 97 insertions(+), 601 deletions(-) diff --git a/src/data/blog/en/interview/kafka-senior.md b/src/data/blog/en/interview/kafka-senior.md index d7d90d1..2ac091f 100644 --- a/src/data/blog/en/interview/kafka-senior.md +++ b/src/data/blog/en/interview/kafka-senior.md @@ -1,5 +1,5 @@ --- -title: "Senior Java Interview: Apache Kafka" +title: "Java Interview Prep #5: Apache Kafka — Junior to Senior" description: "Event-driven systems on Kafka — delivery semantics, replication, partitioning for order, consumer lag, and the dead-letter queue every production consumer needs." pubDatetime: 2026-08-10T10:20:00+07:00 featured: false @@ -11,327 +11,75 @@ tags: - event-driven --- -Kafka questions separate people who've run it in production from those who've only read the docs. And that gap is wider than with almost any other tool, because Kafka looks deceptively simple — an append-only log, a producer on one side, a consumer on the other — right up until a rebalance storm freezes your queues at 2 a.m., or a single poison record silently stalls one partition for hours while every dashboard around it stays green. +Kafka is the interview topic that separates "I've sent a message" from "I understand a distributed log". Junior developers produce and consume; seniors reason about ordering, exactly-once, and what happens when a consumer falls behind. This post walks from topics to delivery guarantees to consumer lag at scale. -A junior knows the three delivery semantics. A senior can narrate the exact instant a record becomes "committed," justify a partition count with throughput math instead of a guess, explain why `enable.idempotence=true` still doesn't protect the Postgres write on the other side of the consumer, and name the single metric that proved the consumer — not the broker — was last month's bottleneck. +> Mindset: junior sends a record and hopes it arrives; senior can tell you the exact semantics of "arrives" and what they built so a poison message can never take down the pipeline. -> Mindset: recite semantics and you're mid-level. Walk through a tradeoff with real numbers and a production failure mode, and you've earned the "senior" checkbox. Every section below ends with the drill an interviewer actually runs. +## Junior — foundations -## 1. The log is the product — partitions, offsets, order +**Q1. What are topics, partitions, and offsets?** +A **topic** is a named log (a stream of events). It is split into **partitions** — ordered, immutable append-only logs. Each record within a partition gets a sequential **offset**. Consumers track their offset to know where they are. More partitions = more parallelism, but also more open files and election overhead. -Kafka is not a message queue that happens to be fast. It's a **distributed, immutable, append-only commit log**. That framing is the whole senior answer: everything else — consumer groups, retention, even "exactly once" — is a consequence of the log, not a feature bolted on top. +**Q2. What is a producer and a consumer?** +A producer appends records to a topic (it picks the partition by key or round-robin). A consumer reads records; consumers in the same **consumer group** split partitions among themselves, so each partition is consumed by exactly one member of the group. Adding consumers beyond partition count leaves some idle. -Think of a kitchen's ticket rail. The **topic** is the rail. A **partition** is one lane of the rail — and here's the part that catches people: partitions are both the unit of _ordering_ and the unit of _parallelism_, and you can't have more of one than the other. Order is guaranteed _within_ a partition and absolutely not across them. The moment an entity's events span two partitions, all bets are off for sequence. +**Q3. What is a consumer group?** +A consumer group is a set of consumers that jointly process a topic — Kafka assigns each partition to one group member. If a member dies, its partitions are reassigned (rebalance) to the survivors. Different groups each get their own independent view of the full topic. -The **offset** is the ticket number. It's a monotonically increasing position within a partition, and it's the only checkpoint a consumer has. When your consumer "commits an offset," it's telling the group: _I have fully processed everything up to here — if I crash, start me from here._ Choose that point wrong and you've just decided your delivery semantics (section 2) — most "Kafka lost my message" incidents are actually "my consumer committed before processing." +**Q4. What is the difference between a queue and a Kafka topic (log)?** +A traditional queue removes a message once consumed; many consumers compete for the same message. A Kafka topic is an **append-only log** — every consumer reads the full history at its own offset; messages aren't deleted on read (they expire by retention). That's what enables replay and multiple independent consumers. -The **consumer group** is the waitstaff shift. The group splits the partitions among its members, so each partition has exactly one active consumer at a time. The consequence that separates people who've tuned it: **adding consumers beyond the partition count does nothing**. Twelve consumers, four partitions — four of them work, eight sit idle and the group just rebalanced more often for the privilege. +**Q5. What is `acks` in the producer, and why does it matter?** +`acks=0`: fire-and-forget (no guarantee). `acks=1`: leader acknowledges once it wrote the record (loss if leader dies before replication). `acks=all`: leader waits until the **in-sync replicas** have it — strongest durability, lower throughput. Durability and latency trade directly. -And the part that makes Kafka fast on boring hardware: the broker writes to a **segment file** through the OS **page cache**, and it serves reads with `sendfile()` (zero-copy — the kernel memcpys the page cache straight to the NIC, no trip through the JVM heap). A hot topic is effectively served from RAM. That's why a handful of brokers can do hundreds of MB/s without exotic storage — the disk is only for the tail you've outgrown the cache. +**Q6. What is a keyed message and why use one?** +When you set a message key, Kafka deterministically routes all records with the same key to the **same partition** (via a hash). That gives you **per-key ordering** — essential for "all events for user 42 in order". Records with no key are round-robined, giving no ordering guarantee. -> The drill: "How many partitions should my topic have?" The senior answer is never "as many consumers as I have" or "one per core." It's a throughput calculation with a growth caveat — and that caveat is the trap. +## Mid — tradeoffs & pitfalls -## 2. Delivery semantics — where "exactly once" goes to die +**Q1. Explain the delivery semantics: at-most-once, at-least-once, exactly-once.** -The three are the vocabulary, not the answer. The answer is being able to produce the exact interleaving that loses or duplicates a record, and then being honest about what the cluster actually gives you. +- **At-most-once**: producer may lose messages (acks=0); consumer may skip (commit offset before processing). No duplicates, possible loss. +- **At-least-once**: producer retries until acked (acks=all); consumer processes then commits. No loss, but duplicates possible (crash after process, before commit → reprocess). +- **Exactly-once**: needs idempotent producer + transactional writes + consumer idempotency, or Kafka's transactions. Harder; often people settle for at-least-once + idempotent processing. -- **At most once.** Commit the offset _before_ processing. Crash between commit and process → the record is never processed. You traded loss for the guarantee you'll never redo work. -- **At least once.** Process _then_ commit. Crash after processing but before the commit lands → the record gets reprocessed. You trade duplicates for the guarantee nothing is lost. This is the realistic default for most systems, and the price of it is **idempotent consumers**. -- **Exactly once.** The whole point of this section: in Kafka, EOS is a _closed-world_ guarantee, and it does not extend to your database. +**Q2. How do you get per-key ordering, and what breaks it?** +Set a key → same partition → in-order within that partition. What breaks it: changing the partition count (rehash moves keys to new partitions, breaking order during the window), or a partition reassignment mid-stream. Also, if you process concurrently within a partition you can reorder at the _effect_ level. Ordering is per-partition, never global, unless you have one partition (no parallelism). -### What the cluster's "exactly once" actually does +**Q3. What is consumer lag and why should you monitor it?** +**Consumer lag** = number of records produced but not yet consumed (high-water-mark offset minus committed offset). Growing lag means consumers can't keep up — downstream goes stale, and if a consumer falls far behind, a rebalance or retention-based data loss can occur. Monitor lag per partition; alert before it exhausts retention. -Two mechanisms, and knowing the boundary between them is the senior tell: +**Q4. What is idempotent production and how does it work?** +An idempotent producer (`enable.idempotence=true`) gets a producer ID and sequence numbers; the broker deduplicates retries within a session, so a retried `send` doesn't create a duplicate. It's at-least-once with no duplicates from retries. Pair it with a keyed partition for safe ordered retries. -1. **Idempotent producer** (`enable.idempotence=true`). The broker assigns the producer a `PID` and every record gets a sequence number. The broker drops duplicates for a given (PID, partition, sequence). This kills the "retry created a double write inside Kafka" failure — for a _single_ producer session. -2. **Kafka transactions** (`transactional.id`, `initTransactions()`, `beginTransaction()` / `commitTransaction()`). This lets one producer atomically commit records across several partitions plus its **consumer offsets** — coordinated by the transaction coordinator, visible only to `read_committed` consumers. This is exactly-once _within the cluster_: a Kafka Streams app can read, process, and write such that a crash-and-restart replays nothing. +**Q5. What is a rebalance and how do you make it cheap?** +A rebalance redistributes partitions when a consumer joins/leaves (crash, deploy, timeout). During a rebalance, **all** consumers in the group stop consuming (stop-the-world for the group), commit offsets, and resume. Frequent rebalances (e.g. from slow poll heartbeat timeouts) cause throughput cliffs. Mitigate with `max.poll.interval.ms` tuning and incremental cooperative rebalancing. -```java -props.put(ProducerConfig.ACKS_CONFIG, "all"); -props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // PID + sequence numbers -props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "orders-pipeline"); // enables transactions -``` +**Q6. What happens when a broker dies — replication and ISR?** +Each partition has a leader (on one broker) and followers (replicas on others). The **in-sync replica (ISR)** set are followers caught up within `replica.lag.time`. If the leader dies, an ISR follower is elected. If you set `acks=all` and `min.insync.replicas=2`, a write needs 2 ISR — you survive one broker loss without data loss. Losing too many brokers can make a partition unavailable (durability over availability trade). -And here's the boundary that wins interviews: **the moment your consumer writes to Postgres, the Kafka transaction is irrelevant.** Kafka cannot put your DB write and its offset commit in one atomic unit — there is no distributed transaction spanning Kafka and Postgres, XA over Kafka is not a thing you should attempt. The instant your architecture has a sink, you are back to at-least-once plus idempotency, full stop. +## Senior — design & defense -### The consumer-side idempotency that actually saves you +**Q1. A consumer keeps crashing on one bad message (poison pill). Design the handling.** +"I'd never let one record kill the pipeline. I wrap processing in try/catch; on a non-retryable error I publish the offending record to a **dead-letter topic** (with the error context) and `ack` the original so the consumer advances. A separate monitor/alert watches the DLQ. For retryable errors I use a retry topic with backoff (or Spring Kafka's `SeekToCurrentErrorHandler`). The key design principle: a poison message must move the pipeline forward, not halt it." -```java -// WRONG: auto-commit fires BEFORE your processing finishes → at-most-once, silent loss -props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true); +**Q2. You need global ordering of 1M events/sec. What do you do?** +"Global ordering means one partition — which caps me at one consumer and kills throughput. So I'd challenge the requirement: do you truly need _global_ order, or _per-entity_ order? Almost always it's per-entity (per-order, per-user), which keying gives you at full parallelism. If global order is genuinely required, I'd accept the single-partition throughput ceiling and scale by partitioning the _problem_ (e.g. shard the stream by time window) or reconsider whether Kafka is the right tool — a total-order requirement fights Kafka's design." -// RIGHT: at-least-once — commit only after the batch is processed; make the work idempotent -props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); -while (true) { - ConsumerRecords batch = consumer.poll(Duration.ofMillis(100)); - for (ConsumerRecord r : batch) { - applyIdempotently(r); // unique constraint on (event_id) in your DB - } - consumer.commitSync(); // crash before this → reprocess, and duplicates are harmless -} -``` +**Q3. Consumer lag spikes to 2M on one partition during a deploy. Diagnose.** +"First, the spike correlates with the rebalance from the rolling deploy — consumers stopped, lag accumulated, then they resumed. If lag doesn't drain afterward, the consumer is now slower than the produce rate (maybe a new synchronous call in the handler). I'd check: per-partition lag (is it one hot partition? — key skew), consumer CPU, and whether `max.poll.records` / processing time per batch is too high causing poll-timeout rebalances. Fix: increase partitions for the hot key, parallelize handling, raise `max.poll.interval.ms`. I measure drain rate vs produce rate to confirm recovery." -The idempotency key belongs in your sink, and it belongs in the database — not in an in-memory `Set` that dies on restart: +**Q4. Design exactly-once for a 'consume DB update + produce event' flow.** +"Naive at-least-once double-writes on crash. Options: (1) Kafka **transactions** (`read_committed` consumer isolation) — the consume+produce+offset-commit happen atomically; a crash either fully commits or fully rolls back. (2) Idempotent sink: write to the DB with the offset as a unique key, so reprocessing is a no-op. I'd prefer the idempotent-sink pattern when feasible (simpler, no transaction overhead); use Kafka transactions when the event must be atomic with the offset commit. Either way, the consumer must be idempotent — exactly-once is a property of the _whole_ pipeline, not the producer flag." -```sql -INSERT INTO payments(id, order_id, event_id, amount) VALUES (?, ?, ?, ?) -ON CONFLICT (event_id) DO NOTHING; -- event_id is the dedupe key, unique constraint enforced by the DB -``` +**Q5. How do you size partitions for a topic expecting 50k msg/s with 10 consumers?** +"Throughput per partition is bounded (~tens of MB/s, but realistically limited by a single consumer's processing). I'd size partitions ≈ `target_consumer_parallelism / single_consumer_throughput × safety`. With 10 consumers each handling ~10k msg/s, I'd set ~20–30 partitions (2–3× consumers) so rebalances and skewed keys still leave headroom. Too few = consumers idle; too many = file-handle and metadata overhead, and longer rebalances. I validate by load-testing one partition's max consume rate, then divide." -The cost reality: idempotent producers are nearly free (a few bytes per batch). Transactions cost a control record, a two-phase-style commit marker, and a round-trip to the coordinator per transaction — meaningfully higher latency and lower throughput, which is exactly why you don't wrap every single event in its own transaction. +**Q6. Defend a retention policy and what 'data loss' really means in Kafka.** +"Retention (e.g. 7 days) means records older than that are deleted regardless of consumption — so if a consumer is down >7 days, those records are _gone_, not replayable. 'Data loss' in Kafka is usually retention-based, not broker failure (with RF≥3 and min.insync.replicas=2, broker loss is survivable). I'd set retention by the longest plausible reprocessing window + buffer, and for true durability of critical streams use tiered storage or mirror to a cold store. I defend retention with the reprocessing SLA, not a guess." -> The drill: "I set `enable.idempotence=true`. My payments are now exactly-once. Right?" A senior kills that sentence in one breath and walks through the DB write. Then they get asked about the outbox (section 7). +#### Self-check -## 3. The producer side — batching, acks, and the throughput math - -Most people's first producer is a latency nightmare and they never find out, because it "works" at 200 events a day. - -```java -// WRONG: flush() per record — one full round-trip per message -for (OrderEvent e : events) { - producer.send(new ProducerRecord<>("orders", e.orderId(), e.payload())); - producer.flush(); // RTT-bound: a handful of messages per second, not thousands -} - -// RIGHT: let the batch fill and the network amortize -for (OrderEvent e : events) { - producer.send(new ProducerRecord<>("orders", e.orderId(), e.payload())); -} -producer.flush(); -``` - -`send()` is asynchronous; the records queue up in the client buffer and are shipped as a **batch**. Without that, every record is its own TCP round trip: at a 5 ms RTT you're hard-capped around a few hundred messages per second. With a 1 MB batch, `linger.ms=10`, and `zstd` compression, a single producer thread pushes on the order of **100k+ small records per second** — zstd alone routinely cuts 3–10× off the bytes on the wire for text payloads, which is often the difference between "network is the bottleneck" and "the batch drains instantly." - -```java -props.put(ProducerConfig.ACKS_CONFIG, "all"); -props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "zstd"); -props.put(ProducerConfig.LINGER_MS_CONFIG, 10); -props.put(ProducerConfig.BATCH_SIZE_CONFIG, 1_048_576); -``` - -The three `acks` values are a durability dial, not a speed setting: - -- `acks=0` — fire and forget. Loses data on any hiccup. Fine for metrics, insane for ledgers. -- `acks=1` — the leader acks after writing to its local log. **Dangerous in prod:** the leader can ack, then crash before the followers replicate, and your "successfully sent" record is gone. You told the business it was durable and it wasn't. -- `acks=all` — ack only after every in-sync replica appended (with `min.insync.replicas` guarding _how many_ that is, section 4). - -And the ordering gotcha: with retries enabled, `max.in.flight.requests.per.connection > 1` can reorder messages on retry — batch A fails, batch B succeeds, A retries after B. The old fix (in-flight = 1) killed throughput. The modern fix is `enable.idempotence=true`, which preserves ordering via sequence numbers while allowing in-flight > 1. Idempotence is not just a duplicate-guard; it's also your ordering guarantee. - -> The drill: "My producer throughput tops out at 2k msg/s on a 1 ms RTT. What do I change first?" — batching and compression, never `acks=0`. And then: "will that help if the bottleneck is a hot partition?" — which is section 5's question. - -## 4. Replication & durability — ISR, `min.insync.replicas`, and the availability trap - -The write path is: leader appends to its segment (page cache), followers fetch and append, and the leader acks once the **ISR** — the in-sync replica set — has it. Durability in Kafka is a _replication_ property, not an fsync property; `acks=all` + RF≥3 is the phrase interviewers want to hear, but the full sentence includes `min.insync.replicas`. - -- **RF (replication factor)** — how many copies of each partition exist across brokers. -- **ISR** — the subset of those replicas that are actually caught up (in-sync with the leader, tracked by a high-watermark lag). -- **`min.insync.replicas`** — the floor the leader requires before it will ack an `acks=all` write. - -So the production sweet spot is `RF=3`, `min.insync.replicas=2`, `acks=all`: the leader acks only when **two** replicas hold the record. Losing one broker is a non-event. And the trap that shows up in interviews: `acks=all` with `min.insync.replicas=1` is **not** more durable than `acks=1` — the leader alone is the ISR, so the leader can ack, then die before anyone else saw the record. "All" refers to all _in-sync_ replicas; `min.insync.replicas` is the real number. - -The availability tradeoff is the follow-up: with `min.insync.replicas=2` on a 3-broker cluster, lose **two** brokers and the partition stops accepting writes — you get `NotEnoughReplicasException` and a queue of failed requests. That's not a bug; it's you choosing durability over availability. The alternative is `min.insync.replicas=1`, where a lone leader can always take writes but a single-broker crash can lose acked data. There is no setting where you get both. - -**Unclean leader election** is the darkest corner of this section. If all ISR replicas are down and you set `unclean.leader.election.enable=true`, the controller can promote an out-of-sync replica to leader — the partition stays _available_ but silently **serves reads and acks writes for data that was never replicated**. With it false, the partition goes unavailable until an ISR member returns. Availability or data integrity; pick one and tell the business which. - -And the page-cache point from section 1 pays off here: each follower replica is effectively a continuous read of the leader's page cache. A topic replicated to RF=3 costs the leader ~2× the write I/O plus the network to the followers — that replication fan-out is often why "my writes are slow" is really "my RF is 3 and my NIC is saturated." - -> The drill: "My broker died and I didn't lose data. Prove that's what happened — and what happens when the second one dies?" The senior answer names the ISR, the high-watermark, and which exception the producer sees, and then states plainly: writes block until an ISR member returns, or you flip `unclean.leader.election.enable` and accept the data-loss risk. - -## 5. Partitioning & ordering — hot keys, grow-only sizing, and per-key parallelism - -Ordering for an entity is simple in Kafka and violated in a thousand subtle ways. If order matters for `order-123`, every event for it must hit the **same partition**, so you key by the entity id: - -```java -producer.send(new ProducerRecord<>("orders", e.orderId(), e.payload())); // key = orderId -``` - -The cost is the **hot key**. One giant entity — a celebrity account, a top seller, a bank's busiest customer — lands on one partition, saturates that partition's leader, and your "scaled" system has a single-partition ceiling while the other 99 partitions idle. The fixes, in senior order: - -1. **Composite key: `shard + entityId`**, where `shard = hash(entityId) % N`. Each entity spreads across N shards, so the leader can parallelize. The price: events for one entity lose global order across shards — usually acceptable if your consumer re-sorts by a monotonic timestamp or you only need per-shard ordering. -2. **Partition by a coarse grain** (e.g., `customerId` when the hot entity is a product) and accept that the hottest customer is the ceiling. Honest, simple, and often the right call. -3. **Buffering / rate-limit at the producer** for the pathological case, so one key can't starve the rest. - -### Partition count: grow-only, so size for the future - -Two hard truths that make this a senior question: - -- **You can only add partitions, never remove them.** Kafka deliberately forbids shrinking. So the number you choose today is a floor forever, and sizing it wrong means a migration that touches every consumer, every metric, every dashboard. -- **Adding partitions silently breaks per-entity ordering.** When the partition count changes, the key→partition mapping changes (default partitioner hashes the key). New events for `order-123` land on a different partition than its old events, so anything reading history-plus-news for one entity now sees the tail arrive out of order. "I'll just add partitions when I need them" is a data-corruption decision wearing a scaling hat. - -The sizing math: a single partition on a modern broker sustains on the order of **10–30 MB/s of writes (roughly tens of thousands of small records per second)**. So: - -``` -partitions ≈ (peak throughput you must absorb) / (per-partition throughput) × headroom - ÷ your max planned consumers per group (each needs a partition to be useful) -``` - -If you expect 300 MB/s peak, that's ~15–30 partitions — and you take the headroom _before_ you multiply by consumers, because a consumer count over partition count is idle capacity (section 1). Each partition also costs real things: file descriptors, controller/`KRaft` metadata, and **rebalance time** — every full rebalance grows with partition count, which is how a 50k-partition cluster turns a five-minute deploy into a ten-minute one. - -### Per-key order with parallelism — Little's law in the consumer - -The naive "speed up my consumer with a thread pool" is how you lose per-key ordering: - -```java -// WRONG: a raw pool breaks per-key order the moment two events for the same key race -ExecutorService pool = Executors.newFixedThreadPool(32); -for (ConsumerRecord r : batch) { - pool.submit(() -> process(r)); // events for order-123 can now execute out of sequence -} - -// RIGHT: shard by key — each key is always handled by the same single-threaded worker -class KeyedExecutor { - final ExecutorService[] workers = IntStream.range(0, 32) - .mapToObj(i -> Executors.newSingleThreadExecutor()) - .toArray(ExecutorService[]::new); - - CompletableFuture submit(String key, Runnable task) { - int slot = Math.floorMod(key.hashCode(), workers.length); - return CompletableFuture.runAsync(task, workers[slot]); - } -} -``` - -Sizing it is Little's law again — the same formula that sizes connection pools: - -``` -concurrency needed = messages/second × seconds per message -e.g. 10,000 msg/s × 0.01 s = 100 workers in flight -``` - -But there's a ceiling nobody mentions: **workers can't exceed partitions and still matter.** More workers than partitions means some are idle (their shard has no partition to pull from); fewer workers than partitions means some partitions queue behind the pool. The golden rule: parallelize to the _partition count_, not to the number of CPUs, if you need per-key order — then watch the queue depth of each worker, because a full single-threaded worker is a hot key in disguise. - -> The drill: "My `orders` topic handles 200 MB/s. Size it and defend it." The senior answer produces a number, then adds "and I can't shrink it, and if I add partitions later I break per-key order" unprompted. - -## 6. The consumer loop, lag, rebalances, and the poison message - -The poll loop looks like a `while(true)` and a `poll()`. Everything that bites you in production hides in the dials around it: - -```java -props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500); -props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300_000); // 5 min default -props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 10_000); // default is laxer; 10s is common -props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 3_000); // must be ≤ session.timeout / 3 -``` - -While you're inside the poll loop, the client can't send heartbeats. Two timers decide your life: - -- **`session.timeout.ms`** — if the coordinator misses your heartbeats for this long, you're dead → **rebalance**. -- **`max.poll.interval.ms`** — if you take longer than this between `poll()` calls, the coordinator _assumes_ you're stuck and evicts you → **rebalance**, even though your heartbeats are fine. - -The numbers that matter: 500 records per poll, each taking 700 ms of processing → **5.8 minutes per poll**, which blows past the 5-minute default → the consumer is kicked out of its own group every cycle, forever. This is the classic "my consumer keeps rebalancing" incident, and the fixes are: fewer records per poll, faster (async) processing, or a genuinely justified larger interval — never a lazy "just bump it." - -### Rebalances: the two protocols and the storm - -- **Eager (old default):** stop-the-world. Every member drops its partitions, the coordinator reassigns everything, everyone rejoins. On a group with thousands of partitions, that pause is measured in seconds — and every one of those seconds is a partition with no consumer. -- **Cooperative-sticky (KIP-429, the modern default):** only the affected members revoke, and only then rejoin. Rebalances go from "all consumers frozen for seconds" to "a handful of partitions shift in under a second." - -**Rebalance storms** are churn — consumers leaving and rejoining in a loop, each cycle freezing the group. Root causes in production order: a full GC pause long enough to trip `session.timeout.ms` (a 6-second STW pause vs a 10-second timeout is a rebalance), processing that blows `max.poll.interval.ms`, or code that subscribes/unsubscribes per request. The senior fix is instrumentation, not wishes: **watch rebalance time and rebalance rate** as first-class metrics, and tune the timeouts so the _slowest_ thing you do still fits. - -### Consumer lag — the first signal, and reading it correctly - -**Lag = log-end-offset − consumer-offset** for a partition. It's the first symptom of almost every consumer problem, but it's a symptom, not a diagnosis. The senior reading: - -- **Flat lag across all partitions, draining at spikes** → bursty producer, healthy consumer. Fine. -- **Lag growing on _every_ partition while the consumer sits at ~100% CPU** → capacity problem: you need more partitions/consumers or faster processing, and Little's law from section 5 tells you how many. -- **Lag growing on _one_ partition while the rest drain** → a hot key (section 5), not a capacity problem. Throwing more consumers at it changes nothing — one partition, one consumer, by construction. -- **Lag growing while the consumer's CPU is idle** → a slow sink: your DB, an external API, or the dedupe table is the real bottleneck. The consumer is queueing on I/O, not starving. - -> The drill: "Lag is climbing but the consumer CPU is 30%. What do you do?" — and the wrong answer is "more consumers." The right answer names the sink, then the hot key, then capacity — in that order. - -### Poison messages and the DLQ — the section title's promise - -A **poison message** is a record that always throws — bad JSON, a schema version your consumer doesn't know, a business rule that rejects it. And here's the mechanism that makes it a catastrophe: a consumer **reads, fails, re-reads**. Without handling, that one record is re-processed on every single poll, the consumer never commits past it, and the **entire partition stalls forever** while lag climbs without bound. One bad record in a million can freeze an order pipeline for a weekend. - -```java -// WRONG: let the poison record loop forever → the partition stalls, lag grows unboundedly -while (true) { - for (ConsumerRecord r : consumer.poll(Duration.ofMillis(100))) { - process(r); // throws → next poll returns the same record → forever - } -} - -// RIGHT: bounded in-process retries for transient errors, then quarantine the poison record -while (true) { - for (ConsumerRecord r : consumer.poll(Duration.ofMillis(100))) { - int attempt = 0; - while (true) { - try { - process(r); - break; - } catch (PoisonException e) { - sendToDlq(r, e); // preserve key + partition + offset in headers - dlqCount.increment(); - break; - } catch (TransientException e) { - if (++attempt >= 3) { sendToDlq(r, e); break; } - Thread.sleep(200L * attempt); // 200ms, 400ms, 600ms backoff - } - } - } - consumer.commitSync(); -} -``` - -The DLQ design details interviewers probe: - -- **Preserve provenance.** Write the original partition, offset, timestamp, and the exception to the DLQ record's headers, so the ops engineer can find the poison record in five minutes, not five days. -- **Never block the partition.** The DLQ _is_ the mechanism that lets the consumer commit and move on. A DLQ without a commit-on-success is a slower poison loop. -- **A DLQ topic is a production problem, not a fix.** Someone must consume it — replay against the _fixed_ code, or drop it deliberately. An unattended DLQ is just a second poison topic you're not reading. -- **Retry topics vs in-process retries.** A full retry-topic pipeline (fail → retry topic with delay → reconsume) survives process restarts, unlike the `Thread.sleep` above, which dies with the JVM. Choose based on whether "reprocess after a crash" matters. - -> The drill: "A partition's lag is spiking and the consumer logs show the same record every two seconds." The senior answer names the poison message, sketches the DLQ with provenance headers, and — the part that wins — answers "who consumes the DLQ, and when?" - -## 7. Kafka → your database — the outbox and the exactly-once trap - -Section 2 established that Kafka's transactions end at the cluster boundary. So how do senior systems actually get "the business state and the event are consistent"? The **transactional outbox** — the pattern that makes your database the source of truth for both. - -Write the business row and the outgoing event in the **same database transaction**: - -```sql -BEGIN; -UPDATE orders SET status = 'PAID' WHERE id = :orderId; -INSERT INTO outbox (id, aggregate_id, event_type, payload, created_at) -VALUES (:eventId, :orderId, 'ORDER_PAID', :payload, NOW()); -COMMIT; -``` - -Now the "publish to Kafka" step is decoupled and safe: either the whole transaction commits (state **and** event), or it rolls back (neither). Then a relay drains the outbox and publishes: - -- **A poller** — `SELECT ... FROM outbox WHERE published_at IS NULL`, publish, mark published. Simple, but double-publish on crash unless you mark idempotently. -- **CDC (Debezium)** — the DB binlog/WAL _is_ the source; a Debezium connector turns each outbox insert into a Kafka record. No poller loop, no 5-second delivery window, no extra read traffic. - -The honest framing interviewers want: the outbox gives you **atomicity** between the DB commit and the Kafka publish, which is as close as the industry gets to "exactly once" across a database and a broker. What it does **not** give you is exactly-once _delivery_ to a downstream consumer — the relay can crash after a publish or the consumer can crash mid-process, so downstream consumers still must be idempotent (section 2). The outbox closes the atomicity gap; it never removes the need for idempotency. - -The two anti-patterns this kills: **publish-then-write** (event sent, DB write fails → the world knows about an order that never happened) and **write-then-publish** without the outbox (DB committed, producer fails → the event is silently lost, and nobody can prove the gap happened because there's no record of the intended event at all). - -> The drill: "How do I get exactly-once delivery from Kafka to Postgres?" The wrong answer is a confident "Kafka transactions." The senior answer is "you don't — you get atomicity with the outbox, and idempotency with a unique key, and here's where each one stops." - -## 8. Self-check - -- [ ] Name the three delivery semantics and produce the exact interleaving where at-least-once duplicates a record. -- [ ] Explain what `enable.idempotence` and Kafka transactions actually do — PID, sequence numbers, coordinator — and why neither protects your database write. -- [ ] Size a topic's partition count from throughput math and defend why it's grow-only — including what happens to per-entity order if you add partitions. -- [ ] Justify `acks=all` + `min.insync.replicas=2` + RF=3, and state exactly what happens when two of three brokers die. -- [ ] Explain why `acks=all` with `min.insync.replicas=1` is barely more durable than `acks=1`. -- [ ] Diagnose "lag growing on one partition" vs "lag growing everywhere with idle CPU" and name the different fixes. -- [ ] Write the poison-message handler with bounded retries and a DLQ that preserves partition, offset, and the exception. -- [ ] Design the transactional outbox with SQL and explain what it does and does not guarantee. -- [ ] Size the consumer's worker pool so per-key order holds, using both Little's law and the partition count as the ceiling. - -## 9. Interviewer follow-ups - -When your first answer lands, they start drilling. Be ready for these: - -- "You have 12 consumers in a group and 4 partitions. What happens, and what's the fix?" -- "`acks=all` with `min.insync.replicas=1` — is that durable? Why?" -- "My consumer lag is growing, CPU is idle, and the DB is at 40%. Where's the bottleneck?" -- "You add 4 partitions to a topic whose consumers rely on per-key order. What breaks, and how do you detect it?" -- "`enable.idempotence=true` — what does it protect you from, and what doesn't it protect you from?" -- "How do you get exactly-once delivery from Kafka to Postgres?" (trap: you don't — outbox plus idempotency.) -- "One record in a million always throws. What happens to the partition, and how do you keep the pipeline moving?" -- "My consumer gets kicked from the group every few minutes. Which two timers do you check, and which number is the giveaway?" -- "What's the difference between retention and compaction — and when does compaction bite you in production?" -- "How would you parallelize a consumer while keeping per-key order, and what's the ceiling on your parallelism?" -- "A full GC pauses your consumer for 6 seconds. Which config is now wrong, and what does the group do?" - -That's the Kafka bar. +- [ ] Junior: I can explain topic/partition/offset, producer vs consumer, consumer groups, log vs queue, `acks`, and keyed messages. +- [ ] Mid: I can explain the three delivery semantics, per-key ordering and what breaks it, consumer lag, idempotent production, rebalances, and ISR/replication. +- [ ] Senior: I can design poison-message handling with a DLQ, challenge false global-ordering needs, diagnose lag spikes during deploys, design exactly-once via idempotent sink or transactions, size partitions from load, and defend retention by reprocessing SLA. diff --git a/src/data/blog/vi/interview/kafka-senior.md b/src/data/blog/vi/interview/kafka-senior.md index 389bb61..622557f 100644 --- a/src/data/blog/vi/interview/kafka-senior.md +++ b/src/data/blog/vi/interview/kafka-senior.md @@ -1,6 +1,6 @@ --- -title: "Phỏng vấn Senior Java: Apache Kafka" -description: "Hệ thống event-driven trên Kafka — delivery semantics, replication, partitioning cho order, consumer lag, và dead-letter queue mọi consumer production cần." +title: "Ôn thi Java #5: Apache Kafka — Junior đến Senior" +description: "Hệ thống event-driven trên Kafka — delivery semantics, replication, partitioning cho thứ tự, consumer lag, và dead-letter queue mọi consumer production đều cần." pubDatetime: 2026-08-10T10:20:00+07:00 featured: false draft: false @@ -11,327 +11,75 @@ tags: - event-driven --- -Câu hỏi Kafka phân biệt người từng chạy prod và người chỉ đọc docs — và khoảng cách đó rộng hơn gần như với mọi công cụ khác, vì Kafka trông có vẻ đơn giản một cách đánh lừa: một cái log chỉ-append, một producer bên này, một consumer bên kia — cho tới lúc 2 giờ sáng một cơn rebalance storm đóng băng toàn bộ queue của bạn, hoặc một record rác âm thầm làm kẹt một partition suốt nhiều giờ trong khi mọi dashboard xung quanh vẫn xanh. +Kafka là chủ đề phỏng vấn tách biệt "tôi đã gửi một message" và "tôi hiểu một distributed log". Junior produce và consume; senior lập luận về ordering, exactly-once, và chuyện gì khi consumer tụt hậu. Bài này đi từ topic đến delivery guarantee đến consumer lag ở scale. -Junior thuộc lòng ba delivery semantics. Senior kể được chính xác khoảnh khắc một record trở thành "committed", biện hộ số partition bằng phép toán throughput thay vì đoán mò, giải thích vì sao `enable.idempotence=true` vẫn không bảo vệ được cú ghi Postgres nằm ở phía bên kia của consumer, và chỉ ra được _một_ metric chứng minh rằng consumer — chứ không phải broker — mới là nút thắt của tháng trước. +> Mindset: junior gửi một record và cầu nó tới; senior kể được semantics chính xác của "tới" và họ xây gì để một poison message không bao giờ gục pipeline. -> Tư duy: nhả thuật ngữ thì bạn chỉ ở tầm mid-level. Đi qua một tradeoff bằng số thật và một failure mode trong production thì bạn chạm nốt "senior". Mỗi phần dưới đây đều kết bằng bài tập phỏng vấn viên thực sự hay chạy. +## Junior — nền tảng -## 1. Cái log chính là sản phẩm — partitions, offsets, và thứ tự +**Q1. Topic, partition, và offset là gì?** +**Topic** là một log có tên (một stream của events). Nó chia thành **partition** — ordered, immutable, append-only log. Mỗi record trong một partition có một **offset** tuần tự. Consumer track offset để biết vị trí. Nhiều partition = nhiều parallelism, nhưng cũng nhiều open file và election overhead. -Kafka không phải một message queue "tình cờ nhanh". Nó là một **distributed, immutable, append-only commit log**. Cái khung đó chính là câu trả lời senior: mọi thứ khác — consumer group, retention, kể cả "exactly once" — là hệ quả của cái log, không phải một feature gắn thêm lên trên. +**Q2. Producer và consumer là gì?** +Producer append record vào topic (chọn partition bằng key hoặc round-robin). Consumer đọc record; consumer trong cùng một **consumer group** chia partition cho nhau, nên mỗi partition được đúng một member của group consume. Thêm consumer vượt số partition thì thừa (idle). -Hãy nghĩ tới cái thanh ticket của nhà bếp. **Topic** là thanh ticket. Một **partition** là một làn của thanh ticket — và đây là chỗ người ta vấp: partition vừa là đơn vị của _ordering_ vừa là đơn vị của _parallelism_, và bạn không thể có nhiều hơn cái này hơn cái kia. Order được đảm bảo _trong_ một partition và tuyệt đối không đảm bảo across chúng. Khoảnh khắc các event của một entity rơi vào hai partition, mọi hi vọng về trình tự đều tan. +**Q3. Consumer group là gì?** +Consumer group là một tập consumer cùng xử lý một topic — Kafka assign mỗi partition cho một member. Nếu một member chết, partition của nó được reassigned (rebalance) cho những người sống sót. Các group khác nhau mỗi cái có view độc lập của toàn bộ topic. -**Offset** là số thứ tự của ticket. Nó là một vị trí tăng đơn điệu trong một partition, và nó là checkpoint duy nhất một consumer có. Khi consumer của bạn "commit offset", nó đang nói với cả nhóm: _Tôi đã xử lý xong mọi thứ tới đây — nếu tôi crash, hãy cho tôi bắt đầu lại từ đây._ Chọn sai điểm này là bạn vừa tự quyết định delivery semantics của mình (phần 2) — hầu hết sự cố "Kafka mất message của tôi" thực ra là "consumer của tôi commit trước khi xử lý". +**Q4. Khác nhau giữa queue và Kafka topic (log)?** +Queue truyền thống xóa message một khi consumed; nhiều consumer tranh cùng message. Kafka topic là **append-only log** — mọi consumer đọc whole history tại offset riêng; message không bị xóa khi đọc (chúng expire theo retention). Đó là gì enable replay và nhiều consumer độc lập. -**Consumer group** là ca của đội phục vụ. Nhóm chia các partition cho các member, nên mỗi partition có đúng một consumer active tại một thời điểm. Hệ quả phân biệt người từng tinh chỉnh: **thêm consumer vượt quá số partition chẳng làm được gì**. Mười hai consumer, bốn partition — bốn con làm việc, tám con ngồi không, và cả nhóm chỉ rebalance thêm nhiều để "vinh dự" ngồi chơi. +**Q5. `acks` trong producer là gì, và tại sao quan trọng?** +`acks=0`: fire-and-forget (không guarantee). `acks=1`: leader acknowledge khi đã viết record (mất nếu leader chết trước replicate). `acks=all`: leader chờ đến khi **in-sync replica** có nó — durability mạnh nhất, throughput thấp hơn. Durability và latency trade trực tiếp. -Và phần khiến Kafka nhanh trên phần cứng tầm thường: broker ghi vào một **segment file** thông qua OS **page cache**, và phục vụ đọc bằng `sendfile()` (zero-copy — kernel memcpy page cache thẳng tới NIC, không đi qua JVM heap). Một topic hot gần như được phục vụ từ RAM. Đó là lý do một nhúm broker đạt hàng trăm MB/s mà không cần storage đặc biệt — disk chỉ phục vụ phần đuôi mà cache đã không chứa nổi. +**Q6. Keyed message là gì và tại sao dùng?** +Khi set key, Kafka route tất cả record cùng key vào **cùng partition** (qua hash). Điều đó cho bạn **per-key ordering** — thiết yếu cho "mọi event của user 42 theo thứ tự". Record không key bị round-robin, không guarantee ordering. -> Bài tập: "Topic của tôi nên có bao nhiêu partition?" Câu trả lời senior không bao giờ là "nhiều bằng số consumer tôi có" hay "một partition mỗi core". Nó là một phép tính throughput kèm theo một lưu ý về tăng trưởng — và cái lưu ý đó chính là cái bẫy. +## Mid — tradeoff & điểm mù -## 2. Delivery semantics — nơi "exactly once" xuống mồ +**Q1. Giải thích delivery semantics: at-most-once, at-least-once, exactly-once.** -Ba mức đó là từ vựng, không phải câu trả lời. Câu trả lời là có thể dựng chính xác interleaving khiến một record bị mất hay bị trùng, rồi thành thật về việc cluster thực sự cho bạn cái gì. +- **At-most-once**: producer có thể mất message (acks=0); consumer có thể skip (commit offset trước khi process). Không duplicate, có thể mất. +- **At-least-once**: producer retry đến khi acked (acks=all); consumer process rồi commit. Không mất, nhưng duplicate có thể (crash sau process, trước commit → reprocess). +- **Exactly-once**: cần idempotent producer + transactional write + consumer idempotency, hoặc Kafka transaction. Khó hơn; thường người ta chốt at-least-once + idempotent processing. -- **At most once.** Commit offset _trước_ khi xử lý. Crash giữa commit và process → record không bao giờ được xử lý. Bạn đổi sự mất mát để lấy cái đảm bảo không bao giờ phải làm lại việc. -- **At least once.** Xử lý _rồi_ mới commit. Crash sau khi xử lý nhưng trước khi commit kịp ghi → record bị xử lý lại. Bạn đổi duplicate để lấy đảm bảo không gì bị mất. Đây là default thực tế của hầu hết hệ thống, và cái giá của nó là **consumer idempotent**. -- **Exactly once.** Trọng tâm của cả phần này: trong Kafka, EOS là một đảm bảo _thế giới khép kín_ (closed-world), và nó không mở rộng tới database của bạn. +**Q2. Làm sao có per-key ordering, và gì phá nó?** +Set key → cùng partition → in-order trong partition đó. Phá nó: đổi partition count (rehash chuyển key sang partition mới, phá order trong cửa sổ), hoặc partition reassignment giữa stream. Cũng, nếu bạn process concurrent trong một partition bạn có thể reorder ở mức _effect_. Ordering là per-partition, không bao giờ global, trừ khi một partition (không parallelism). -### "Exactly once" của cluster thực sự làm gì +**Q3. Consumer lag là gì và tại sao monitor nó?** +**Consumer lag** = số record produced nhưng chưa consumed (high-water-mark offset trừ committed offset). Lag tăng nghĩa consumer không kịp — downstream stale, và nếu consumer tụt xa, rebalance hoặc retention-based data loss có thể xảy ra. Monitor lag per partition; alert trước khi cạn retention. -Hai cơ chế, và biết ranh giới giữa chúng là dấu hiệu senior: +**Q4. Idempotent production là gì và hoạt động ra sao?** +Idempotent producer (`enable.idempotence=true`) có producer ID và sequence number; broker deduplicate retry trong một session, nên `send` retry không tạo duplicate. Nó là at-least-once không duplicate từ retry. Ghép với keyed partition cho ordered retry an toàn. -1. **Idempotent producer** (`enable.idempotence=true`). Broker gán cho producer một `PID` và mỗi record một sequence number. Broker loại bỏ duplicate cho một cặp (PID, partition, sequence). Cơ chế này giết cái lỗi "retry tạo double write trong Kafka" — cho _một_ producer session. -2. **Kafka transactions** (`transactional.id`, `initTransactions()`, `beginTransaction()` / `commitTransaction()`). Cơ chế này cho một producer commit atomic các record trải khắp nhiều partition cộng với **consumer offsets** của nó — được phối hợp bởi transaction coordinator, chỉ nhìn thấy với consumer `read_committed`. Đây là exactly-once _trong nội bộ cluster_: một app Kafka Streams có thể đọc, xử lý, ghi sao cho crash-and-restart không replay gì cả. +**Q5. Rebalance là gì và làm nó rẻ thế nào?** +Rebalance redistribute partition khi consumer join/leave (crash, deploy, timeout). Trong rebalance, **mọi** consumer trong group ngừng consume (stop-the-world cho group), commit offset, và resume. Rebalance thường xuyên (vd từ slow poll heartbeat timeout) gây throughput cliff. Giảm nhẹ bằng tune `max.poll.interval.ms` và incremental cooperative rebalancing. -```java -props.put(ProducerConfig.ACKS_CONFIG, "all"); -props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // PID + sequence numbers -props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "orders-pipeline"); // bật transactions -``` +**Q6. Chuyện gì khi broker chết — replication và ISR?** +Mỗi partition có một leader (trên một broker) và follower (replica trên broker khác). Tập **in-sync replica (ISR)** là follower bắt kịp trong `replica.lag.time`. Nếu leader chết, một ISR follower được bầu. Nếu bạn set `acks=all` và `min.insync.replicas=2`, một write cần 2 ISR — bạn sống sót mất một broker không data loss. Mất quá nhiều broker có thể làm partition unavailable (durability over availability trade). -Và đây là ranh giới thắng buổi phỏng vấn: **khoảnh khắc consumer của bạn ghi vào Postgres, transaction của Kafka trở nên vô nghĩa.** Kafka không thể đưa cú ghi DB và offset commit của nó vào một đơn vị atomic — không có distributed transaction nào kéo căng qua cả Kafka lẫn Postgres, XA trên Kafka không phải thứ bạn nên thử. Khoảnh khắc kiến trúc của bạn có một cái sink, bạn quay về at-least-once cộng idempotency, hết chuyện. +## Senior — thiết kế & phòng thủ -### Idempotency phía consumer thực sự cứu bạn +**Q1. Một consumer liên tục crash trên một message xấu (poison pill). Thiết kế xử lý.** +"Tôi không bao giờ để một record gục pipeline. Tôi wrap processing trong try/catch; trên non-retryable error tôi publish record lỗi sang **dead-letter topic** (với error context) và `ack` original để consumer tiến lên. Một monitor/alert riêng watch DLQ. Cho retryable error tôi dùng retry topic với backoff (hoặc Spring Kafka `SeekToCurrentErrorHandler`). Nguyên tắc thiết kế chính: poison message phải tiến pipeline lên, không dừng nó." -```java -// WRONG: auto-commit bắn TRƯỚC khi processing xong → at-most-once, mất âm thầm -props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true); +**Q2. Bạn cần global ordering của 1M events/sec. Làm gì?** +"Global ordering nghĩa một partition — giới hạn tôi ở một consumer và giết throughput. Nên tôi thách thức requirement: bạn thực sự cần order _global_, hay _per-entity_? Gần như luôn là per-entity (per-order, per-user), keying cho bạn ở full parallelism. Nếu global order thực sự cần, tôi chấp nhận single-partition throughput ceiling và scale bằng partitioning the _problem_ (vd shard stream theo time window) hoặc xem lại Kafka có đúng tool không — total-order requirement chống lại thiết kế Kafka." -// RIGHT: at-least-once — chỉ commit sau khi batch được xử lý; làm việc idempotent -props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); -while (true) { - ConsumerRecords batch = consumer.poll(Duration.ofMillis(100)); - for (ConsumerRecord r : batch) { - applyIdempotently(r); // unique constraint trên (event_id) trong DB của bạn - } - consumer.commitSync(); // crash trước dòng này → xử lý lại, và duplicate là vô hại -} -``` +**Q3. Consumer lag vọt lên 2M trên một partition trong deploy. Chẩn đoán.** +"Đầu tiên, spike tương quan với rebalance từ rolling deploy — consumer dừng, lag tích, rồi resume. Nếu lag không drain sau đó, consumer giờ chậm hơn produce rate (có thể một synchronous call mới trong handler). Tôi check: per-partition lag (có một hot partition? — key skew), consumer CPU, và `max.poll.records` / processing time per batch có quá cao gây poll-timeout rebalance không. Fix: tăng partition cho hot key, parallelize handling, nâng `max.poll.interval.ms`. Tôi đo drain rate vs produce rate để confirm recovery." -Chìa khóa idempotency thuộc về sink của bạn, và nó phải nằm trong database — không phải trong một `Set` trên memory chết theo lần restart: +**Q4. Thiết kế exactly-once cho flow 'consume DB update + produce event'.** +"Naive at-least-once double-write trên crash. Lựa chọn: (1) Kafka **transaction** (`read_committed` consumer isolation) — consume+produce+offset-commit xảy ra nguyên tử; crash hoặc fully commit hoặc fully rollback. (2) Idempotent sink: viết vào DB với offset là unique key, nên reprocessing là no-op. Tôi thích idempotent-sink pattern khi khả thi (đơn giản, không transaction overhead); dùng Kafka transaction khi event phải atomic với offset commit. Dù cách nào, consumer phải idempotent — exactly-once là property của _toàn bộ_ pipeline, không phải producer flag." -```sql -INSERT INTO payments(id, order_id, event_id, amount) VALUES (?, ?, ?, ?) -ON CONFLICT (event_id) DO NOTHING; -- event_id là dedupe key, unique constraint do DB enforce -``` +**Q5. Bạn size partition cho topic expecting 50k msg/s với 10 consumer thế nào?** +"Throughput per partition bị giới hạn (~tens of MB/s, nhưng thực tế giới hạn bởi processing của một consumer). Tôi size partition ≈ `target_consumer_parallelism / single_consumer_throughput × safety`. Với 10 consumer mỗi cái xử lý ~10k msg/s, tôi set ~20–30 partition (2–3× consumer) để rebalance và skewed key vẫn còn headroom. Quá ít = consumer idle; quá nhiều = file-handle và metadata overhead, và rebalance dài hơn. Tôi validate bằng load-test max consume rate của một partition, rồi chia." -Sự thật về chi phí: idempotent producer gần như miễn phí (vài byte mỗi batch). Transaction tốn một control record, một commit marker kiểu two-phase, và một round-trip tới coordinator mỗi transaction — latency cao hơn rõ rệt, throughput thấp hơn, và đó chính là lý do bạn không bọc từng event trong một transaction riêng. +**Q6. Phòng thủ retention policy và 'data loss' thực sự nghĩa gì trong Kafka.** +"Retention (vd 7 ngày) nghĩa record cũ hơn bị xóa bất kể consumption — nên nếu consumer down >7 ngày, record đó _mất_, không replay được. 'Data loss' trong Kafka thường là retention-based, không phải broker failure (với RF≥3 và min.insync.replicas=2, broker loss survivable). Tôi set retention theo longest plausible reprocessing window + buffer, và cho stream critical thực sự durable dùng tiered storage hoặc mirror sang cold store. Tôi phòng thủ retention bằng reprocessing SLA, không phải đoán." -> Bài tập: "Tôi bật `enable.idempotence=true`. Payment của tôi giờ exactly-once, đúng chứ?" Senior giết câu đó trong một hơi và đi qua cú ghi DB. Rồi họ được hỏi về outbox (phần 7). +#### Self-check -## 3. Phía producer — batching, acks, và phép toán throughput - -Producer đầu tiên của hầu hết mọi người là một cơn ác mộng latency mà họ không bao giờ biết, vì nó "chạy ngon" ở mức 200 event một ngày. - -```java -// WRONG: flush() cho từng record — mỗi message một vòng round-trip trọn vẹn -for (OrderEvent e : events) { - producer.send(new ProducerRecord<>("orders", e.orderId(), e.payload())); - producer.flush(); // RTT-bound: một nhúm message mỗi giây, không phải hàng nghìn -} - -// RIGHT: để batch đầy lên và network được trải đều -for (OrderEvent e : events) { - producer.send(new ProducerRecord<>("orders", e.orderId(), e.payload())); -} -producer.flush(); -``` - -`send()` là bất đồng bộ; các record xếp hàng trong buffer phía client và được ship thành một **batch**. Không có cái đó, mỗi record là một TCP round-trip riêng: ở RTT 5 ms bạn bị chặn cứng quanh vài trăm message mỗi giây. Với batch 1 MB, `linger.ms=10`, và nén `zstd`, một thread producer đơn lẻ đẩy cỡ **100k+ record nhỏ mỗi giây** — riêng zstd thường cắt 3–10× số byte trên dây cho payload dạng text, và đó thường là khoảng cách giữa "network là nút thắt" và "batch drain tức thì". - -```java -props.put(ProducerConfig.ACKS_CONFIG, "all"); -props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "zstd"); -props.put(ProducerConfig.LINGER_MS_CONFIG, 10); -props.put(ProducerConfig.BATCH_SIZE_CONFIG, 1_048_576); -``` - -Ba giá trị `acks` là một núm chỉnh durability, không phải cài tốc độ: - -- `acks=0` — fire and forget. Mất dữ liệu ở bất kỳ cú trục trặc nào. Ổn cho metrics, điên rồ cho ledger. -- `acks=1` — leader ack sau khi ghi vào log cục bộ của nó. **Nguy hiểm trong prod:** leader có thể ack, rồi crash trước khi các follower kịp replicate, và record "đã gửi thành công" của bạn biến mất. Bạn đã nói với nghiệp vụ nó durable mà nó thì không. -- `acks=all` — ack chỉ sau khi mọi in-sync replica đã append (với `min.insync.replicas` bảo vệ _bao nhiêu_ cái đó — phần 4). - -Và cái gotcha về ordering: với retries bật, `max.in.flight.requests.per.connection > 1` có thể làm đảo thứ tự message khi retry — batch A fail, batch B thành công, A retry sau B. Cách sửa cũ (in-flight = 1) giết throughput. Cách sửa hiện đại là `enable.idempotence=true`, giữ nguyên ordering nhờ sequence numbers trong khi vẫn cho in-flight > 1. Idempotence không chỉ là cái khiên chống duplicate; nó còn là đảm bảo ordering của bạn. - -> Bài tập: "Throughput producer của tôi đứng ở 2k msg/s trên RTT 1 ms. Tôi đổi gì trước?" — batching và nén, không bao giờ `acks=0`. Rồi: "điều đó có giúp gì nếu nút thắt là một hot partition?" — câu hỏi của phần 5. - -## 4. Replication & durability — ISR, `min.insync.replicas`, và cái bẫy availability - -Write path là: leader append vào segment của nó (page cache), các follower fetch và append, và leader ack một khi **ISR** — tập in-sync replica — đã có nó. Durability trong Kafka là một thuộc tính _replication_, không phải thuộc tính fsync; `acks=all` + RF≥3 là cụm từ phỏng vấn viên muốn nghe, nhưng câu đầy đủ phải gồm cả `min.insync.replicas`. - -- **RF (replication factor)** — bao nhiêu bản sao của mỗi partition tồn tại trải khắp các broker. -- **ISR** — tập con của các bản sao đó thực sự bắt kịp (in-sync với leader, được theo dõi qua lag của high-watermark). -- **`min.insync.replicas`** — cái sàn leader yêu cầu trước khi nó chịu ack một cú ghi `acks=all`. - -Nên điểm ngọt production là `RF=3`, `min.insync.replicas=2`, `acks=all`: leader chỉ ack khi **hai** replica giữ record. Mất một broker là chuyện không đáng bàn. Và cái bẫy xuất hiện trong phỏng vấn: `acks=all` với `min.insync.replicas=1` **không** durable hơn `acks=1` — leader một mình là cả ISR, nên leader có thể ack rồi chết trước khi bất kỳ ai khác kịp nhìn thấy record. "All" nghĩa là tất cả _in-sync_ replica; `min.insync.replicas` mới là con số thật. - -Tradeoff availability là câu follow-up: với `min.insync.replicas=2` trên cluster 3 broker, mất **hai** broker thì partition ngừng nhận ghi — bạn gặp `NotEnoughReplicasException` và một hàng đợi request fail. Đó không phải bug; đó là bạn chọn durability thay vì availability. Lựa chọn còn lại là `min.insync.replicas=1`, nơi một leader cô độc luôn nhận được ghi nhưng một broker chết duy nhất có thể làm mất dữ liệu đã ack. Không có cài đặt nào cho bạn cả hai. - -**Unclean leader election** là góc tối nhất của phần này. Nếu mọi replica trong ISR đều chết và bạn bật `unclean.leader.election.enable=true`, controller có thể nâng một replica out-of-sync lên làm leader — partition vẫn _available_ nhưng âm thầm **phục vụ read và ack write cho dữ liệu chưa bao giờ được replicate**. Còn với nó false, partition rơi vào unavailable tới khi một thành viên ISR quay lại. Availability hay data integrity; chọn một và nói rõ cho nghiệp vụ cái nào. - -Và điểm page-cache ở phần 1 trả cổ tức ở đây: mỗi follower replica về bản chất là một vòng đọc liên tục từ page cache của leader. Một topic replicate với RF=3 tốn leader ~2× write I/O cộng network tới các follower — cái fan-out replication đó thường là lý do "write của tôi chậm" thực ra là "RF của tôi là 3 và NIC của tôi đang bão hòa". - -> Bài tập: "Một broker của tôi chết và tôi không mất dữ liệu. Chứng minh điều đó xảy ra như thế nào — và chuyện gì xảy ra khi broker thứ hai chết?" Câu trả lời senior nêu tên ISR, high-watermark, và exception nào producer thấy, rồi nói thẳng: write sẽ block tới khi một thành viên ISR quay lại, hoặc bạn lật `unclean.leader.election.enable` và chấp nhận rủi ro mất dữ liệu. - -## 5. Partitioning & ordering — hot key, grow-only sizing, và parallelism theo key - -Ordering cho một entity trong Kafka thì đơn giản và bị vi phạm bằng một nghìn cách tinh vi. Nếu order quan trọng với `order-123`, mọi event của nó phải rơi vào **cùng một partition**, nên bạn key theo entity id: - -```java -producer.send(new ProducerRecord<>("orders", e.orderId(), e.payload())); // key = orderId -``` - -Cái giá là **hot key**. Một entity khổng lồ — tài khoản celebrity, top seller, khách hàng bận rộn nhất của ngân hàng — rơi vào một partition, làm bão hòa leader của partition đó, và hệ thống "đã scale" của bạn có một cái trần một-partition trong khi 99 partition còn lại ngồi chơi. Các cách sửa, theo thứ tự senior: - -1. **Composite key: `shard + entityId`**, với `shard = hash(entityId) % N`. Mỗi entity trải đều qua N shard, nên leader có thể parallelize. Cái giá: event của một entity mất thứ tự global giữa các shard — thường chấp nhận được nếu consumer của bạn sắp xếp lại theo một timestamp đơn điệu hoặc bạn chỉ cần ordering theo từng shard. -2. **Partition theo một hạt thô hơn** (ví dụ `customerId` khi entity hot là một sản phẩm) và chấp nhận rằng khách hàng hot nhất là cái trần. Trung thực, đơn giản, và thường là quyết định đúng. -3. **Buffering / rate-limit phía producer** cho ca bệnh lý, để một key không thể bỏ đói phần còn lại. - -### Số partition: grow-only, nên cỡ cho tương lai - -Hai sự thật cứng khiến đây trở thành câu hỏi senior: - -- **Bạn chỉ có thể thêm partition, không bao giờ xóa.** Kafka cố tình cấm thu nhỏ. Nên con số bạn chọn hôm nay là một cái sàn mãi mãi, và size sai nghĩa là một cuộc migration chạm vào mọi consumer, mọi metric, mọi dashboard. -- **Thêm partition âm thầm phá vỡ ordering theo entity.** Khi số partition đổi, ánh xạ key→partition đổi theo (partitioner mặc định hash key). Các event mới của `order-123` rơi vào một partition khác với các event cũ, nên bất kỳ thứ gì đọc lịch sử-cộng-tin-mới cho một entity giờ thấy phần đuôi tới lệch thứ tự. "Tôi thêm partition khi cần là được" là một quyết định hỏng dữ liệu đội lốt scale. - -Phép toán sizing: một partition đơn lẻ trên broker hiện đại chịu cỡ **10–30 MB/s write (khoảng vài chục nghìn record nhỏ mỗi giây)**. Nên: - -``` -partitions ≈ (peak throughput bạn phải hấp thụ) / (throughput mỗi partition) × headroom - ÷ số consumer tối đa dự kiến mỗi group (mỗi consumer cần một partition để có ích) -``` - -Nếu bạn kỳ vọng peak 300 MB/s, đó là ~15–30 partitions — và bạn lấy headroom _trước khi_ nhân với số consumer, vì consumer nhiều hơn partition là capacity rỗi (phần 1). Mỗi partition cũng tốn những thứ thật: file descriptors, metadata controller/`KRaft`, và **thời gian rebalance** — mỗi full rebalance tăng theo số partition, và đó là cách một cluster 50k partition biến một cú deploy 5 phút thành 10 phút. - -### Order theo key kèm parallelism — định luật Little trong consumer - -Cách ngây thơ "tăng tốc consumer của tôi bằng một thread pool" chính là cách bạn đánh mất ordering theo key: - -```java -// WRONG: một pool thô phá vỡ order theo key ngay khi hai event của cùng key đua nhau -ExecutorService pool = Executors.newFixedThreadPool(32); -for (ConsumerRecord r : batch) { - pool.submit(() -> process(r)); // event của order-123 giờ có thể chạy lệch trình tự -} - -// RIGHT: shard theo key — mỗi key luôn được xử lý bởi cùng một worker đơn thread -class KeyedExecutor { - final ExecutorService[] workers = IntStream.range(0, 32) - .mapToObj(i -> Executors.newSingleThreadExecutor()) - .toArray(ExecutorService[]::new); - - CompletableFuture submit(String key, Runnable task) { - int slot = Math.floorMod(key.hashCode(), workers.length); - return CompletableFuture.runAsync(task, workers[slot]); - } -} -``` - -Size nó lại là định luật Little — cùng công thức size connection pool: - -``` -concurrency cần = messages/second × giây mỗi message -vd: 10.000 msg/s × 0.01 s = 100 worker đang in-flight -``` - -Nhưng có một cái trần chẳng ai nhắc: **worker không thể vượt quá partition mà vẫn có ích.** Nhiều worker hơn partition nghĩa là một số worker rỗi (shard của chúng không có partition để kéo); ít worker hơn partition nghĩa là một số partition xếp hàng sau pool. Quy tắc vàng: parallelize tới _số partition_, không phải tới số CPU, nếu bạn cần order theo key — rồi để mắt tới queue depth của từng worker, vì một worker đơn thread đầy ắp chính là một hot key đội lốt. - -> Bài tập: "Topic `orders` của tôi xử lý 200 MB/s. Size nó và biện hộ." Câu trả lời senior đưa ra một con số, rồi thêm "và tôi không thể thu nhỏ nó, và nếu tôi thêm partition sau này tôi phá vỡ order theo key" một cách không cần nhắc. - -## 6. Consumer loop, lag, rebalance, và poison message - -Cái poll loop trông như một `while(true)` và một `poll()`. Mọi thứ cắn bạn trong production đều ẩn trong các núm chỉnh quanh nó: - -```java -props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500); -props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300_000); // default 5 phút -props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 10_000); // default lỏng hơn; 10s là phổ biến -props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 3_000); // phải ≤ session.timeout / 3 -``` - -Trong lúc bạn ở trong poll loop, client không gửi heartbeat được. Hai cái timer quyết định số phận bạn: - -- **`session.timeout.ms`** — nếu coordinator lỡ heartbeat của bạn quá lâu, bạn chết → **rebalance**. -- **`max.poll.interval.ms`** — nếu bạn mất lâu hơn thế giữa các lần `poll()`, coordinator _mặc định_ bạn bị kẹt và đá bạn ra → **rebalance**, dù heartbeat của bạn vẫn ổn. - -Con số quan trọng: 500 record mỗi poll, mỗi cái tốn 700 ms xử lý → **5,8 phút mỗi poll**, vượt qua default 5 phút → consumer bị đá khỏi chính group của nó mỗi vòng, mãi mãi. Đây là sự cố kinh điển "consumer của tôi cứ rebalance", và cách sửa là: ít record hơn mỗi poll, xử lý nhanh hơn (async), hoặc một con số interval lớn hơn có căn cứ — không bao giờ lười "cứ bump lên". - -### Rebalance: hai protocol và cơn bão - -- **Eager (default cũ):** stop-the-world. Mọi member vứt partition của mình, coordinator gán lại hết, mọi người tham gia lại. Trên một group hàng nghìn partition, cú tạm dừng đó tính bằng giây — và mỗi giây đó là một partition không có consumer. -- **Cooperative-sticky (KIP-429, default hiện đại):** chỉ các member bị ảnh hưởng revoke, và chỉ những cái đó tham gia lại. Rebalance đi từ "mọi consumer đóng băng vài giây" tới "một nhúm partition dịch chuyển trong dưới một giây". - -**Rebalance storm** là sự churn — consumer rời đi và quay lại trong một vòng lặp, mỗi vòng đóng băng cả group. Nguyên nhân gốc theo thứ tự production: một cú full GC pause đủ lâu để chạm `session.timeout.ms` (một STW pause 6 giây so với timeout 10 giây là một lần rebalance), xử lý thổi bay `max.poll.interval.ms`, hoặc code subscribe/unsubscribe theo từng request. Cách sửa senior là instrumentation, không phải lời cầu nguyện: **theo dõi rebalance time và rebalance rate** như những metric hạng nhất, và chỉnh timeout sao cho thứ _chậm nhất_ bạn làm vẫn vừa. - -### Consumer lag — tín hiệu đầu tiên, và đọc nó cho đúng - -**Lag = log-end-offset − consumer-offset** cho một partition. Nó là triệu chứng đầu tiên của gần như mọi vấn đề consumer, nhưng nó là triệu chứng, không phải chẩn đoán. Cách đọc của senior: - -- **Lag phẳng trên mọi partition, drain theo từng đợt spike** → producer bursty, consumer khỏe. Bình thường. -- **Lag tăng trên _mọi_ partition trong khi consumer ngồi ở ~100% CPU** → vấn đề capacity: bạn cần nhiều partition/consumer hơn hoặc xử lý nhanh hơn, và định luật Little ở phần 5 cho bạn biết bao nhiêu. -- **Lag tăng trên _một_ partition trong khi phần còn lại drain** → một hot key (phần 5), không phải vấn đề capacity. Ném thêm consumer vào chẳng đổi gì — một partition, một consumer, theo cấu trúc. -- **Lag tăng trong khi CPU consumer rỗi** → một sink chậm: DB của bạn, một API ngoài, hay bảng dedupe mới là nút thắt thật. Consumer đang xếp hàng trên I/O, không phải chết đói. - -> Bài tập: "Lag đang leo nhưng CPU consumer ở 30%. Bạn làm gì?" — và câu trả lời sai là "thêm consumer". Câu trả lời đúng gọi tên sink, rồi hot key, rồi capacity — theo đúng thứ tự đó. - -### Poison messages và DLQ — lời hứa trong tiêu đề phần - -Một **poison message** là một record luôn luôn ném exception — JSON hỏng, một schema version consumer của bạn không biết, một business rule bác bỏ nó. Và đây là cơ chế khiến nó thành thảm họa: một consumer **đọc, fail, đọc lại**. Không xử lý, cái record đó bị xử lý lại trong mọi lần poll, consumer không bao giờ commit qua được nó, và **toàn bộ partition kẹt vĩnh viễn** trong khi lag tăng không giới hạn. Một record hỏng trong một triệu record có thể đóng băng một pipeline order suốt một cuối tuần. - -```java -// WRONG: để record rác loop mãi → partition kẹt, lag tăng không giới hạn -while (true) { - for (ConsumerRecord r : consumer.poll(Duration.ofMillis(100))) { - process(r); // ném exception → poll kế tiếp trả cùng record → mãi mãi - } -} - -// RIGHT: retry trong process có chặn trên cho lỗi transient, rồi cách ly record rác -while (true) { - for (ConsumerRecord r : consumer.poll(Duration.ofMillis(100))) { - int attempt = 0; - while (true) { - try { - process(r); - break; - } catch (PoisonException e) { - sendToDlq(r, e); // giữ key + partition + offset trong headers - dlqCount.increment(); - break; - } catch (TransientException e) { - if (++attempt >= 3) { sendToDlq(r, e); break; } - Thread.sleep(200L * attempt); // backoff 200ms, 400ms, 600ms - } - } - } - consumer.commitSync(); -} -``` - -Các chi tiết thiết kế DLQ mà phỏng vấn viên khoan: - -- **Giữ provenance.** Ghi partition gốc, offset, timestamp, và exception vào headers của record DLQ, để kỹ sư ops tìm thấy record rác trong năm phút, không phải năm ngày. -- **Không bao giờ block partition.** DLQ _chính là_ cơ chế cho phép consumer commit và đi tiếp. Một DLQ không có commit-on-success chỉ là một vòng poison chậm hơn. -- **Một topic DLQ là một vấn đề production, không phải một cách sửa.** Phải có người consume nó — replay với code đã _sửa_, hoặc bỏ đi có chủ đích. Một DLQ không ai đọc chỉ là một poison topic thứ hai mà bạn không đọc. -- **Retry topic vs retry trong process.** Một pipeline retry-topic đầy đủ (fail → retry topic có delay → đọc lại) sống sót qua restart process, khác với `Thread.sleep` ở trên, cái chết chung với JVM. Chọn theo việc "xử lý lại sau crash" có quan trọng hay không. - -> Bài tập: "Lag của một partition đang spike và log consumer hiện cùng một record mỗi hai giây." Câu trả lời senior gọi tên poison message, phác DLQ với headers provenance, và — phần thắng điểm — trả lời "ai consume DLQ, và khi nào?" - -## 7. Kafka → database của bạn — outbox và cái bẫy exactly-once - -Phần 2 đã xác lập rằng transaction của Kafka dừng ở ranh giới cluster. Vậy hệ thống senior thực sự làm thế nào để "state nghiệp vụ và event nhất quán"? **Transactional outbox** — pattern khiến database của bạn là source of truth cho cả hai. - -Ghi row nghiệp vụ và event đi ra trong **cùng một transaction database**: - -```sql -BEGIN; -UPDATE orders SET status = 'PAID' WHERE id = :orderId; -INSERT INTO outbox (id, aggregate_id, event_type, payload, created_at) -VALUES (:eventId, :orderId, 'ORDER_PAID', :payload, NOW()); -COMMIT; -``` - -Giờ bước "publish lên Kafka" được tách rời và an toàn: hoặc toàn bộ transaction commit (state **và** event), hoặc nó rollback (không cái nào). Rồi một relay drain outbox và publish: - -- **Một poller** — `SELECT ... FROM outbox WHERE published_at IS NULL`, publish, đánh dấu đã publish. Đơn giản, nhưng double-publish nếu crash trừ khi bạn đánh dấu idempotent. -- **CDC (Debezium)** — binlog/WAL của DB _chính là_ nguồn; một Debezium connector biến mỗi outbox insert thành một Kafka record. Không vòng poll, không cửa sổ delivery 5 giây, không thêm read traffic. - -Khung trung thực phỏng vấn viên muốn: outbox cho bạn **atomicity** giữa commit DB và publish Kafka, và đó là thứ gần nhất ngành có được với "exactly once" kéo căng qua một database và một broker. Cái nó **không** cho bạn là exactly-once _delivery_ tới một consumer downstream — relay có thể crash sau publish hoặc consumer có thể crash giữa chừng, nên consumer downstream vẫn bắt buộc idempotent (phần 2). Outbox đóng cái lỗ atomicity; nó không bao giờ gỡ bỏ nhu cầu idempotency. - -Hai anti-pattern nó giết: **publish-then-write** (event đã gửi, write DB fail → cả thế giới biết về một order chưa từng tồn tại) và **write-then-publish** không có outbox (DB đã commit, producer fail → event mất âm thầm, và không ai chứng minh được cái lỗ vừa xảy ra vì chẳng có bản ghi về event dự định). - -> Bài tập: "Làm thế nào tôi có exactly-once delivery từ Kafka tới Postgres?" Câu trả lời sai là một câu tự tin "Kafka transactions". Câu trả lời senior là "bạn không có — bạn có atomicity bằng outbox, và idempotency bằng một unique key, và đây là chỗ mỗi cái dừng lại." - -## 8. Tự kiểm tra - -- [ ] Kể tên ba delivery semantics và dựng chính xác interleaving khi at-least-once làm trùng một record. -- [ ] Giải thích `enable.idempotence` và Kafka transactions thực sự làm gì — PID, sequence numbers, coordinator — và vì sao không cái nào bảo vệ cú ghi database của bạn. -- [ ] Size số partition của một topic từ phép toán throughput và biện hộ vì sao nó grow-only — kể cả chuyện gì xảy ra với order theo entity nếu bạn thêm partition. -- [ ] Biện hộ `acks=all` + `min.insync.replicas=2` + RF=3, và nêu chính xác chuyện gì xảy ra khi hai trong ba broker chết. -- [ ] Giải thích vì sao `acks=all` với `min.insync.replicas=1` barely durable hơn `acks=1`. -- [ ] Chẩn đoán "lag tăng trên một partition" vs "lag tăng khắp nơi với CPU rỗi" và gọi tên các cách sửa khác nhau. -- [ ] Viết handler poison-message với retry có chặn trên và một DLQ giữ partition, offset, và exception. -- [ ] Thiết kế transactional outbox bằng SQL và giải thích nó đảm bảo gì và không đảm bảo gì. -- [ ] Size worker pool của consumer sao cho order theo key được giữ, dùng cả định luật Little lẫn số partition làm cái trần. - -## 9. Interviewer follow-ups - -Khi câu trả lời đầu tiên của bạn chạm đúng, họ bắt đầu khoan. Sẵn sàng cho những câu này: - -- "Bạn có 12 consumer trong một group và 4 partition. Chuyện gì xảy ra, và cách sửa là gì?" -- "`acks=all` với `min.insync.replicas=1` — có durable không? Vì sao?" -- "Consumer lag của tôi đang tăng, CPU rỗi, và DB ở 40%. Nút thắt ở đâu?" -- "Bạn thêm 4 partition vào một topic mà consumer phụ thuộc vào order theo key. Cái gì vỡ, và bạn phát hiện nó thế nào?" -- "`enable.idempotence=true` — nó bảo vệ bạn khỏi gì, và không bảo vệ khỏi gì?" -- "Làm thế nào bạn có exactly-once delivery từ Kafka tới Postgres?" (bẫy: bạn không có — outbox cộng idempotency.) -- "Một record trong một triệu luôn ném exception. Chuyện gì xảy ra với partition, và bạn giữ pipeline chạy thế nào?" -- "Consumer của tôi bị đá khỏi group vài phút một lần. Hai cái timer nào bạn kiểm tra, và con số nào là manh mối?" -- "Khác biệt giữa retention và compaction — và khi nào compaction cắn bạn trong production?" -- "Bạn parallelize một consumer thế nào mà vẫn giữ order theo key, và cái trần của parallelism của bạn là gì?" -- "Một full GC dừng consumer của bạn 6 giây. Config nào giờ đã sai, và group làm gì?" - -Đó là bar Kafka. +- [ ] Junior: Tôi giải thích được topic/partition/offset, producer vs consumer, consumer group, log vs queue, `acks`, và keyed message. +- [ ] Mid: Tôi giải thích được ba delivery semantics, per-key ordering và gì phá nó, consumer lag, idempotent production, rebalance, và ISR/replication. +- [ ] Senior: Tôi thiết kế được poison-message handling với DLQ, thách thức false global-ordering need, chẩn đoán lag spike trong deploy, thiết kế exactly-once qua idempotent sink hoặc transaction, size partition từ load, và phòng thủ retention bằng reprocessing SLA. From 57c924763afc4766c15c8d647e1d57dd439178ac Mon Sep 17 00:00:00 2001 From: Tester Date: Thu, 13 Aug 2026 06:36:20 +0000 Subject: [PATCH 6/8] =?UTF-8?q?docs(interview):=20rewrite=20microservices?= =?UTF-8?q?=20as=20Junior=E2=86=92Senior=20Q&A=20series=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../blog/en/interview/microservices-senior.md | 305 +++-------------- .../blog/vi/interview/microservices-senior.md | 309 +++--------------- 2 files changed, 93 insertions(+), 521 deletions(-) diff --git a/src/data/blog/en/interview/microservices-senior.md b/src/data/blog/en/interview/microservices-senior.md index 4350651..12e19c5 100644 --- a/src/data/blog/en/interview/microservices-senior.md +++ b/src/data/blog/en/interview/microservices-senior.md @@ -1,5 +1,5 @@ --- -title: "Senior Java Interview: Microservices" +title: "Java Interview Prep #6: Microservices — Junior to Senior" description: "Microservices at senior level is mostly about knowing when NOT to use them — resilience patterns, service communication, and distributed transactions." pubDatetime: 2026-08-10T10:10:00+07:00 featured: false @@ -11,285 +11,72 @@ tags: - resilience --- -Microservices interviews test judgment more than knowledge. The most senior answer to "design a microservice" is sometimes "don't, yet." The second-most senior answer starts with "which part of the monolith can we carve out first, and how do we keep shipping during the carve?" Nobody gets points for drawing more boxes. +Microservices are the topic where senior judgment matters most, because the wrong answer is "let's split the monolith". Junior developers draw service boxes; seniors explain why a monolith was the right call for years and what specifically forced the split. This post walks from service boundaries to the distributed-transaction trap. -> Mindset: a junior lists patterns; a senior narrates failure modes. When you answer with a number, a postmortem, or "here's the tradeoff and when I'd flip it," you've cleared the bar. +> Mindset: junior lists the benefits of microservices; senior can name three concrete costs they introduce and the exact trigger that justifies paying them. -## 1. The distributed-monolith trap +## Junior — foundations -Premature decomposition gives you **network calls instead of method calls**, distributed transactions, and 10× operational cost with none of the benefit. Every monolith you split pays an upfront tax: the network. A same-DC HTTP round trip is **~0.1–0.5 ms**; an in-process method call is **~1 ns**. You are voluntarily moving 2–3 orders of magnitude slower and calling it architecture. +**Q1. What is a microservice and how does it differ from a monolith?** +A microservice is a small, independently deployable service owning one business capability, with its own data store. A monolith is one deployable unit. Microservices buy independent scaling, isolated failures, and team autonomy; they pay with network calls, distributed data, and operational complexity. -The triggers that actually justify a split: +**Q2. What is the difference between synchronous and asynchronous communication?** +Synchronous (HTTP/RPC): caller blocks waiting for a response — tight coupling, the callee's outage blocks you. Asynchronous (message/event bus): caller publishes and continues — loose coupling, better resilience, but eventual consistency and harder debugging. Choose sync for request/response needing the answer now; async for fire-and-forget or decoupling. -- **Independent deployability.** One team can ship daily without a joint release train. This is the #1 reason in practice. -- **Different scaling profiles.** A job-queue consumer and an API serving user traffic shouldn't share a heap — but note a separate worker pool inside one process often fixes this too. -- **Different failure domains.** Crash- or GC-isolation of one hot subsystem (see bulkheads below — the cheaper fix first). -- **Different teams/ownership.** Conway's law: the architecture follows the org chart. Splitting to match a team boundary is honest; splitting "because microservices" is cargo cult. +**Q3. What is an API gateway and what does it do?** +A single entry point for clients that handles routing, auth, rate limiting, and often aggregation. It hides the internal service topology so clients don't need to know every service's address. Without it, clients couple to many services and you can't enforce cross-cutting policies in one place. -The senior counter-question before splitting: **"Can a modular monolith give us this?"** Boundaries at the _module_ level — each with its own package, own DB schema/table prefixes, own transaction scope, own API — buy you most of the enforceability with none of the network. When you split for real, you must introduce an **anti-corruption layer**: the new service exposes its own model and never leaks its internal tables to consumers, or the split hard-codes itself into every caller. +**Q4. What is service discovery?** +Instead of hardcoding service addresses (which change as pods scale/move), services register themselves with a registry (Consul, Eureka, K8s DNS) and look each other up. Enables dynamic scaling and resilience to restarts. Hardcoded hostnames break the moment a pod reschedules. -The data is the real split, not the code. "We'll move the code and keep the shared database" is how you get a **distributed monolith** — network calls _and_ shared coupling, the worst of both. A genuine split means a split database, which means every cross-service read becomes a join-across-network, which is where the saga/outbox machinery below comes from. If your callers can't tolerate eventual consistency for that data, you haven't actually split it. +**Q5. What is a circuit breaker and why do you need one?** +When a downstream service is slow/failing, naive retries pile up and exhaust your threads — one dead dependency cascades to take down your whole service. A circuit breaker trips after N failures, failing fast for a cooldown window instead of waiting on timeouts, then half-opens to test recovery. It contains the blast radius. -## 2. Service communication — and the latency budget that decides the pattern +**Q6. What is the difference between an API and an event?** +An API call is a direct request for an action/response (imperative: "do this"). An event is a fact that happened ("order placed"), broadcast to anyone interested (declarative). APIs couple caller→callee; events decouple producer from consumers. Confusing the two leads to chatty, fragile synchronous graphs where events would have been cleaner. -The first question isn't "REST or gRPC," it's **"how many synchronous hops can this request afford?"** Lay it out as a budget, because that's what interviewers probe: +## Mid — tradeoffs & pitfalls -``` -User → API gateway → Service A → Service B → DB +**Q1. What is the database-per-service rule and why is shared DB an anti-pattern?** +Each service should own its data; a shared database couples services at the storage layer — a schema change in one service breaks another, and transactions span services. The anti-pattern (shared DB) quietly turns your "microservices" into a distributed monolith. If two services must share a table, that's a signal they're one bounded context. -A gateway hop: ~1–5 ms (routing + authn) -A same-DC service hop: ~0.1–1 ms on the wire, but the *call* is more: - serialize + deserialize + thread scheduling + the - downstream's DB time + its queueing → P99 10–100 ms. -Budget: 500 ms P99 → 3 sync hops max, and each hop gets ~100 ms before -its caller's timeout fires and the whole chain degrades. -``` +**Q2. How do you handle a distributed transaction across two services?** +You usually **don't** use a 2PC (two-phase commit) — it's a distributed lock that doesn't scale and fails badly under partial failure. Instead use the **Saga** pattern: a sequence of local transactions, each with a compensating action to undo on failure (e.g. "reserve → if ship fails, release"). Sagas trade atomicity for availability; you accept eventual consistency and build compensation logic. -Go beyond 3 hops synchronously and you are playing musical chairs with timeouts. That's the real reason async wins in chains: **you cut the hop-latency terms out of the user request entirely.** +**Q3. What is eventual consistency and what breaks for users?** +After a write, not all readers see it immediately — replicas/derived data converge over time. What breaks: a user updates their profile and refreshes to the old version (confusing), or reads their own write from a replica that hasn't caught up. Mitigation: read-your-writes (read from primary right after a write), or serve the just-written value from the client. -### REST vs gRPC — say the tradeoff, not the favorite +**Q4. What is the difference between idempotency and exactly-once, and why does it matter for retries?** +A retry can deliver a message twice. **Idempotency** means processing it twice has the same effect as once (e.g. a dedupe key, or `UPDATE ... WHERE version = x`). **Exactly-once** (true, end-to-end) is nearly impossible across services. So you build idempotent handlers and accept at-least-once delivery — far more robust than chasing exactly-once. -- **REST/HTTP:** ubiquitous, debuggable in any browser, trivially load-balanced, human-readable payloads. Costs: JSON parse/serialize on every hop, HTTP/1.1 head-of-line blocking per connection (mitigated by connection pools), no streaming story worth discussing, weak typing between teams. -- **gRPC:** HTTP/2 multiplexes many in-flight calls over **one connection** — no head-of-line blocking, ~half the framing overhead — and protobuf is binary: smaller payloads and near-zero parse cost. Streaming request/response for free. Costs: tooling friction, hard to eyeball in `curl`, schema changes are a **deploy contract** (protobuf's additive-field rules are a spec you must actually follow), and the async-server binding is where Spring WebFlux people earn their money. +**Q5. What is bulkhead isolation?** +The bulkhead pattern limits how much of your resources one dependency can consume (separate thread pools / connection pools per downstream). If service B hangs, it can only fill its own bulkhead, not the pool shared with C and D — containing the failure. Without it, one slow dependency exhausts the shared pool and everything dies together. -The senior answer: "internal hot path, high QPS, I want streaming → gRPC. Public API, debugging surface, mixed consumers → REST." And the real gotcha either way is **timeouts, not protocol** — see the retry section before you touch any of this. +**Q6. How do you debug a request that spans 8 services?** +Distributed tracing (OpenTelemetry/W3C trace context) propagates a trace ID across service calls, so you see the full waterfall and where time was spent. Without tracing you're blind — logs per service don't tell you the path. Pair it with centralized structured logging keyed by trace ID. A senior insists on tracing _before_ the system gets big, not after. -### Idempotency before retry, always +## Senior — design & defense -A retry without idempotency is a duplicate side effect with extra steps. The fix is an **idempotency key** the caller generates and the callee dedupes on — and it must be enforced in the _storage_, not in "we check a map": +**Q1. A team wants to split a 3-year-old monolith into 20 microservices. What do you say?** +"I'd push back hard. Microservices are an org and ops decision, not a technical silver bullet. The monolith's problem is probably a missing module boundary or a deployment bottleneck — fix those first (modular monolith). I'd split only along a _proven_ bounded context that has different scaling or team-ownership needs, and do it incrementally (strangler fig), not a big-bang 20-service rewrite that multiplies failure modes overnight. The cost of 20 services (network, distributed data, on-call) only pays off if the independence is real." -```sql --- WRONG — check-then-insert races: two retries both pass the SELECT, --- both insert, you've charged the customer twice. -SELECT 1 FROM payments WHERE idempotency_key = 'CUST-42-RETRY-9'; +**Q2. Design a payment flow across Order, Inventory, and Payment services without 2PC.** +"Saga. Order service starts: `reserve inventory` (local txn + compensate `release`), then `charge payment` (local txn + compensate `refund`). If payment fails, the saga orchestrator triggers `release inventory`. Each step is a local transaction with a compensating action; the saga log lets us resume after a crash. I'd use an orchestration saga (a coordinator) over choreography (events) here, because the flow has clear order and failure handling — choreography gets hard to reason about at 3+ steps. The trade: no global lock, but I must handle partial failure and eventual consistency explicitly." --- RIGHT — the unique index is the arbiter, the insert is atomic. -CREATE UNIQUE INDEX uq_payments_idem ON payments(idempotency_key); +**Q3. A downstream HTTP call is flaky (5% timeouts). Design the resilience layer.** +"Three layers: (1) **timeout** shorter than my SLA so I fail fast, not hang; (2) **circuit breaker** to stop hammering a dying dependency and fail fast during its outage; (3) **retry with backoff + jitter** for transient blips, but only idempotent calls. Plus **bulkhead** so this dependency can't eat my whole thread pool. And a fallback (cached/stale value, or queued for later) so the user gets a degraded-but-working response. I measure the downstream's timeout rate and the breaker's open ratio to tune thresholds from reality." -INSERT INTO payments (id, idempotency_key, amount, status) -VALUES (nextval('payments_id_seq'), 'CUST-42-RETRY-9', 19.90, 'PENDING') -ON CONFLICT (idempotency_key) DO NOTHING -RETURNING id; -``` +**Q4. When is a monolith actually the better choice, and how do you keep it clean?** +"For a small team, a young product, or a domain without independent scaling needs — a modular monolith is faster to build, debug, and deploy, with no distributed failure modes. Keep it clean with explicit module boundaries (packages that don't import each other's internals), a single deploy, and one database with clear schema ownership. Migrate to services only when a boundary's scaling/team needs diverge. Premature splitting is the most common microservice mistake I see." -No row returned → it was a duplicate → return the previously stored result. "We dedupe on the app side with `ConcurrentHashMap`" is how you lose money at 3 AM. +**Q5. How do you choose sync vs async between two specific services, with a concrete example?** +"Order → Inventory for 'reserve stock': if the user is waiting on the confirmation, sync (I need the answer now, and a timeout is a clear failure to show). Order → Notification/Analytics: async event ('order placed'), because nobody's blocking on it and I want resilience if those services are down. Rule of thumb: sync for the happy-path request the user is blocked on; async for side-effects and fan-out. Mixing them wrongly (sync to 5 services in a row) creates a latency chain that fails as the slowest link." -## 3. Resilience patterns — draw the states, then the numbers +**Q6. Defend your service boundaries — how do you know a split is right?** +"A correct boundary is a bounded context: one reason to change, one team owns it, it can be deployed and scaled alone, and its data is private. I'd test the split by asking: 'If I change service A's schema, does B need to redeploy?' If yes, they're one context pretending to be two. The proof is operational: independent deploy frequency and failure isolation. If A and B always deploy together and share a DB, I've built a distributed monolith and should merge them. Boundaries are validated by deploy/scale/failure independence, not by drawing boxes." -### Circuit breaker +#### Self-check -Closed → open on `failureRateThreshold` of the sliding window → **half-open after `waitDurationInOpenState`** to probe with a few trial calls → closed again or open again. The defaults are the answer: Resilience4j ships `failureRateThreshold=50%`, `slidingWindowSize=100`, `minimumNumberOfCalls=10`, `waitDurationInOpenState=60s`. State it and you sound like you've configured one, because you have. - -The critical nuance interviewers probe: **an open breaker rejects fast (you save the in-flight work), but it also hides real traffic from the recovery probe.** Too aggressive a threshold and a momentary blip opens the breaker and takes the whole dependency offline. The half-open `permittedNumberOfCallsInHalfOpenState` (default 10) is the lever — it's how many calls get to _test_ recovery. Get the half-open probe wrong and you've replaced "slow dependency" with "dependency plus permanent 5-second cold starts on recovery." - -### Bulkhead — isolation with a number - -```java -// WRONG — one pool, one threadpool for everything: a slow 'reporting' -// endpoint slowly eats all 200 threads, and payments time out too. -ExecutorService everything = Executors.newFixedThreadPool(200); - -// RIGHT — per-dependency pools, sized by Little's law: -// pool_size = throughput × time_in_pool -// 50 req/s × 250 ms = ~13 threads for 'reporting'; -// give it 15, cap it, and payments never see its slowness. -ExecutorService reporting = new ThreadPoolExecutor( - 15, 15, 0, MILLISECONDS, new ArrayBlockingQueue<>(50), new CallerRunsPolicy()); -ExecutorService payments = new ThreadPoolExecutor( - 10, 10, 0, MILLISECONDS, new ArrayBlockingQueue<>(30), new CallerRunsPolicy()); -``` - -Little's law is the same math as thread pools (see the Java-core guide) — the microservices version is: **each external dependency gets its own bounded pool and its own circuit breaker**, so one dependency's collapse is quarantined. The cheaper semaphore bulkhead (`SemaphoreBulkhead`) is right when you don't need to offload work — a permit, no queue — and costs almost nothing. - -### Retry + backoff + jitter — the self-inflicted DDoS - -Naive `for (i < 3) retry` during an outage is the single most common self-DoS in production. Do the math: - -``` -10,000 instances each retrying 3× with no backoff = 30,000 requests -hitting a service that is already down → it never recovers. -Even WITH backoff: fixed 1s waits mean everyone retries on the same -tick — synchronized thundering herd. Jitter is what desynchronizes it. -``` - -The senior default is **exponential backoff with full jitter** — randomize the wait up to the computed cap: - -```java -// WRONG — fixed 1s wait, all instances synchronized, and the retry -// hammers the same endpoint that is already falling over. -for (int attempt = 0; attempt < 3; attempt++) { - try { return call(); } catch (IOException e) { Thread.sleep(1000); } -} - -// RIGHT — exponential backoff with full jitter (Resilience4j): -// waits like 50–100, 100–200, 200–400 ms, randomized each attempt. -Retry retry = Retry.custom("payments") - .maxAttempts(3) - .intervalFunction(IntervalFunction.ofExponentialBackoff( - Duration.ofMillis(100), // initial - 2.0, // multiplier - Duration.ofSeconds(2))) // cap - .build(); - -// and full jitter, if you want it desynchronized hard: -Retry jittery = Retry.custom("payments") - .intervalFunction(IntervalFunction.ofRandomized( - Duration.ofSeconds(1), Duration.ofSeconds(5))) - .build(); -``` - -Rules that end the argument: **retry only on idempotent calls** (or idempotency keys), **retry only on transient errors** (timeouts, `5xx`, connection resets — not `400s`), **cap total attempts and total time**, and **let the circuit breaker veto the retry** — a retry that runs while the breaker is open is just amplification. - -```java -// The whole stack, in the right order: -// timeout(800ms) → retry(3, backoff+jitter) → circuit breaker -Supplier decorated = Decorators.ofSupplier(() -> callDownstream()) - .withTimeout(Timeout.of(Duration.ofMillis(800))) - .withRetry(retry) - .withCircuitBreaker(CircuitBreaker.ofDefaults("payments")) - .decorate(); -``` - -### Timeout budget & deadlines — propagate the budget, don't grow it - -Every service in a chain should take a **fraction** of the caller's total budget, and the budget must **shrink as it crosses the wire** (deadline propagation — gRPC carries it natively via metadata; in REST you pass it in a header). The classic failure: A calls B with 5s, B calls C with 5s, C calls D with 5s → the user asked for 5s total but the chain can legally take 20s. Then every intermediate retry _doubles_ the tail. State it as "a timeout must always be smaller than the one above it." - -## 4. Service discovery, config, gateway - -### Discovery - -Eureka/Consul register instances and hand clients an address; K8s DNS just resolves a service name to a set of IPs. The senior angle is the **caching behavior**: if the client caches discovered addresses and the cache is stale while instances churn (deploy rolling, a node dies), requests hit dead endpoints and _that's_ what your retries now amplify. The discovery cache TTL and the "stale endpoint" failure mode are production real. Know that Consul uses gossip and a server quorum; Eureka has a self-preservation mode that keeps stale registry data when the network partitions — both are quirks an interviewer can probe. - -### Config - -Centralized config (Spring Cloud Config / K8s ConfigMap) gives you a change audit and a single place to push secrets — but the trick that separates senior engineers is **"does a config push restart my service or hot-reload it?"** Hot reload (Spring Cloud Bus / `@RefreshScope`) is great until someone refreshes a _secret rotation_ mid-request. Never cache config without a TTL and an invalidation path, and never put credentials in git — Vault or an external secrets backend, and rotate them. - -### Gateway — the new single point of failure - -The gateway concentrates authn, rate limiting, routing, and canary routing in one hop. The tradeoff to name: **it is now the most load-bearing box in the system**, and a gateway outage takes everything down — which is exactly the failure domain you'd have said a microservice should avoid. Mitigations worth saying out loud: stateless gateways scaled horizontally behind a load balancer, client-side discovery as a fallback, and BFF (backend-for-frontend) as the alternative when different clients need different aggregation. And for rate limiting, token-bucket in front of the gateway + quota checks in the services behind it — the gateway alone is bypassed by any client that calls services directly. - -## 5. Distributed data & transactions — the part that's actually hard - -### Saga: orchestration vs choreography - -A saga is a sequence of local transactions with compensating actions. The orchestrated (central coordinator) version is easier to reason about — one state machine you can draw — but it's a bottleneck and a new single point of failure. Choreography (services react to events) avoids the coordinator but scatters the state machine across every service and is a tracing nightmare: "who started this order and why is it in state X?" is a cross-service archaeology dig. - -```java -// Orchestrated saga: the coordinator is the only place the flow exists. -// Each step runs in its own local transaction; every step has a -// compensate() that undoes it in its own local transaction. -@Component -public class OrderSaga { - // start → pay → reserveInventory → ship - // ↑ on failure: compensate() each completed step, reverse order - public void run(CreateOrderCommand cmd) { - Order order = orderRepo.save(cmd.toOrder()); // local tx - try { - paymentClient.authorize(order.getPaymentId()); // RPC - inventoryClient.reserve(order.getItems()); // RPC - shipmentClient.schedule(order.getId()); // RPC - order.complete(); orderRepo.save(order); - } catch (SagaStepException e) { - // reverse every step that already committed — each in its own tx - compensate(order, cmd, e); - order.fail(e); orderRepo.save(order); - } - } -} -``` - -The compensating actions are the part juniors forget: **a compensation is not a rollback** — it's a new local transaction that fixes up what a previous one did (refund the charge, release the reservation). "Compensation = undo" is the standard senior-filter question. And if a compensation itself fails, you have a **stuck saga** — that's why a real design logs every saga step to a persistent state table the operator (or a sweeper job) can drive to completion. Nobody says this, but that state table is the saga's transaction log, and it's what makes the whole thing auditable. - -### 2PC — mention it only to explain why you don't use it - -Two-phase commit holds locks across all participants while the coordinator asks "ready?" — during the prepare phase the data is locked, and if the coordinator dies or the network partitions, the locks stay held. On a long-running business flow that means transactions that could hang for minutes while resources stay locked. The classic line: "2PC gives you atomic commit _only if_ every participant and the coordinator stay up — the one thing a distributed system doesn't promise." That's the answer. Then move on to the outbox. - -### The dual-write problem and the transactional outbox - -Whenever a service writes to its DB **and** publishes an event to Kafka, the two writes are not atomic — crash between them and you've lost an event, or double-sent it. The transactional outbox fixes both: **write the event into the same database transaction as the business change**, then a relay publishes it. - -```sql --- one local transaction: -INSERT INTO order (id, state) VALUES (?, 'CREATED'); -INSERT INTO outbox (aggregate_id, event_type, payload, published_at) -VALUES (?, 'order.created', jsonb_build_object('id', ?), NULL); -``` - -```java -@Transactional -public Order createOrder(CreateOrderCommand cmd) { - Order order = orderRepo.save(cmd.toOrder()); - outboxRepo.save(OutboxEvent.of("order.created", order.getId())); // same tx - return order; // commit publishes nothing yet — the relay does -} -``` - -```java -// relay: poll for unpublished rows, publish, mark published. -// FOR UPDATE SKIP LOCKED = many relay instances without fighting. -List pending = outboxRepo.findUnpublished(100); // ... SKIP LOCKED -for (OutboxEvent evt : pending) { - kafkaTemplate.send("orders", evt.getPayload()); - evt.markPublished(); -} -``` - -Now the guarantee is at-least-once (relay may crash after publish before marking) — which is fine **because consumers are idempotent** (section 2). That idempotency + outbox combo is the closest thing to a distributed transaction that survives production, and naming it unprompted is a strong senior tell. - -### Event ordering and the poison-message variant - -Ordering is only guaranteed per-partition; key all events for one aggregate by its id so they land on one partition (Kafka guide covers this). And any consumer that processes "we received a duplicate" by _double-crediting_ is the reason idempotency is a storage concern, not a hope. - -### Distributed locking — when you actually need it - -For the rare genuinely critical section across services (job coordination, per-tenant counters), a DB-based lock with a lease beats Redis SETNX with a bug: - -```sql --- RIGHT — lease-backed: the row IS the lock, expires if the holder dies, --- and the holder must check it still holds before doing the work (fencing). -INSERT INTO job_lock (name, holder, lease_expires_at) -VALUES ('reindex', 'node-7', now() + interval '30 seconds') -ON CONFLICT (name) -DO UPDATE SET holder = 'node-7', lease_expires_at = now() + interval '30 seconds' -WHERE job_lock.holder = 'node-7' OR job_lock.lease_expires_at < now() -RETURNING holder; -``` - -The pitfall everyone hits: a lock with a fixed TTL and a holder that's slow (GC pause) — the lease expires, a second holder takes the lock, and now two "holders" run. The fix is a **fencing token**: the lock hands out a monotonically increasing token and the protected resource refuses writes with an older token (like a DB version column). Mentioning fencing tokens is worth a nod; that's a senior answer. - -## 6. Observability — the difference between a senior and a demo - -A senior designs the tracing from day one, not after the incident. Say these three concrete things: - -- **Distributed tracing with correlation IDs.** Every request carries a `trace-id`/`span-id` (W3C Trace Context), propagated across every hop and into the async path — otherwise an event-driven saga is untraceable. OpenTelemetry + a collector. -- **RED metrics, not vague "uptime."** Rate, Errors, Duration per service — and per _dependency_, so the circuit breaker's health is visible from outside. "We monitor our services" means you can point at a dashboard that shows the _chain_, not just one box. -- **Structured logs with the correlation ID in every line**, plus log aggregation. "The request failed somewhere in the chain" becomes "here is the exact 15ms span." - -The production failure-mode test: "our P99 went 80 ms → 3 s after a deploy." The answer pattern is symptom → tool → finding → fix, exactly like the Java-core guide: pull the trace, see which hop ate the time, check that service's breaker state and its dependency's pool, and fix the _cause_ — usually a slow dependency with an unbounded queue or a missing timeout, not "add more instances." - -## 7. Self-check - -- [ ] Name two reasons NOT to split a monolith, and why the data is the real split. -- [ ] Write the timeout budget for a 3-hop synchronous chain and explain deadline propagation. -- [ ] Explain circuit breaker + bulkhead with a real example and the Little's-law sizing. -- [ ] Why are naive retries during an outage dangerous — with the numbers? -- [ ] What does the transactional outbox solve that 2PC cannot, and why is it at-least-once? -- [ ] Explain why an idempotency key must be enforced at the storage layer. -- [ ] Orchestration vs choreography — the tradeoff, and where each fails. -- [ ] What does "a compensation is not a rollback" mean, and what happens when a compensation fails? -- [ ] How do you trace an event-driven saga end to end? - -## 8. Interviewer follow-ups - -- "You split the monolith and now the checkout is 4 synchronous hops. Walk me through the latency budget — where does the time go, and what do you change?" -- "A retry storm just hit your payment service. What's your first lever — the retry config, the breaker, or the queue — and why?" -- "The outbox relay published an event twice because it crashed after `send`. Your consumer double-credited a user. What was the actual bug, and what's the fix?" -- "Your discovery cache is stale and clients are hitting dead instances. What breaks first, and how do your resilience patterns hide it?" -- "The gateway is down and everything is offline. Is that acceptable, and what did you design to survive it?" -- "A saga compensation fails mid-way and the order is stuck in PENDING. Who fixes it, and what's in the DB to make that possible?" -- "gRPC or REST for an internal payments API at 10k QPS with streaming? Give me the tradeoff, not the slogan." -- "Why is a fixed-backoff retry worse than a jittered one — show me the distribution difference." -- "Your consumer reads events out of order for one order id. What guarantees were violated, and how do you restore per-aggregate order?" -- "You're about to call a new service that has no timeouts, no breaker, and no pool bounds. What's the single first thing you add before enabling it?" - -That's the microservices bar. +- [ ] Junior: I can explain microservice vs monolith, sync vs async, API gateway, service discovery, circuit breaker, and API vs event. +- [ ] Mid: I can explain database-per-service, the Saga pattern, eventual consistency, idempotency vs exactly-once, bulkhead, and distributed tracing. +- [ ] Senior: I can argue against premature splitting with a modular-monolith alternative, design a 2PC-free payment saga, build a resilient HTTP layer (timeout+breaker+retry+bulkhead), and defend service boundaries by deploy/scale/failure independence. diff --git a/src/data/blog/vi/interview/microservices-senior.md b/src/data/blog/vi/interview/microservices-senior.md index 6abda1b..98e7508 100644 --- a/src/data/blog/vi/interview/microservices-senior.md +++ b/src/data/blog/vi/interview/microservices-senior.md @@ -1,6 +1,6 @@ --- -title: "Phỏng vấn Senior Java: Microservices" -description: "Microservices cấp senior chủ yếu là biết khi nào KHÔNG dùng — resilience patterns, service communication, và distributed transactions." +title: "Ôn thi Java #6: Microservices — Junior đến Senior" +description: "Microservices ở mức senior chủ yếu là biết khi NÀO KHÔNG dùng chúng — resilience pattern, service communication, và distributed transaction." pubDatetime: 2026-08-10T10:10:00+07:00 featured: false draft: false @@ -11,287 +11,72 @@ tags: - resilience --- -Phỏng vấn microservices test **phán đoán** nhiều hơn kiến thức. Câu trả lời senior nhất cho "thiết kế một microservice" đôi khi là "chưa, đừng." Câu senior thứ nhì bắt đầu bằng "phần nào của monolith mình khoét ra trước, và làm sao vẫn ship trong lúc khoét?" Không ai được điểm vì vẽ thêm mấy cái hộp. +Microservices là chủ đề nơi senior judgment quan trọng nhất, vì câu trả lời sai là "chia monolith ra đi". Junior vẽ các ô service; senior giải thích tại sao monolith đúng suốt nhiều năm và điều gì cụ thể ép phải chia. Bài này đi từ service boundary đến cái bẫy distributed-transaction. -> Tư duy: junior liệt kê patterns; senior kể lại failure modes. Khi bạn trả lời bằng một con số, một câu chuyện postmortem, hay một câu "đây là đánh đổi và lúc nào tôi lật ngược nó" — bạn đã qua vạch. +> Mindset: junior liệt kê lợi ích microservices; senior gọi được ba cái giá cụ thể chúng tạo ra và trigger chính xác biện minh việc trả giá đó. -## 1. Bẫy distributed-monolith +## Junior — nền tảng -Chia nhỏ sớm quá mang lại **network call thay cho method call**, distributed transaction, và **10× chi phí vận hành** mà không được lợi gì. Mọi monolith bạn tách đều nộp một khoản thuế trả trước: cái network. Một round trip HTTP cùng datacenter là **~0,1–0,5 ms**; một method call trong process là **~1 ns**. Bạn đang tự nguyện chậm hơn 2–3 bậc độ lớn và gọi đó là kiến trúc. +**Q1. Microservice là gì và khác monolith thế nào?** +Microservice là một service nhỏ, deploy độc lập, sở hữu một business capability, với datastore riêng. Monolith là một unit deploy duy nhất. Microservices mua được independent scaling, isolated failure, và team autonomy; chúng trả bằng network call, distributed data, và operational complexity. -Các trigger thực sự biện minh cho việc tách: +**Q2. Khác nhau giữa synchronous và asynchronous communication?** +Synchronous (HTTP/RPC): caller block chờ response — coupling chặt, outage của callee block bạn. Asynchronous (message/event bus): caller publish và tiếp tục — loose coupling, resilience tốt hơn, nhưng eventual consistency và debug khó hơn. Chọn sync cho request/response cần answer ngay; async cho fire-and-forget hoặc decoupling. -- **Independent deployability.** Một team ship hằng ngày mà không cần đoàn tàu release chung. Đây là lý do #1 trong thực tế. -- **Profile scaling khác nhau.** Một job-queue consumer và một API phục vụ traffic người dùng không nên chung một heap — nhưng để ý: một worker pool riêng trong cùng một process cũng thường giải quyết được cái này. -- **Failure domain khác nhau.** Cô lập crash hoặc GC của một subsystem nóng (xem bulkhead bên dưới — đó là cách rẻ hơn trước). -- **Team/quyền sở hữu khác nhau.** Định luật Conway: kiến trúc chạy theo sơ đồ tổ chức. Tách để khớp ranh giới team là thành thật; tách "vì microservices" là cargo cult. +**Q3. API gateway là gì và nó làm gì?** +Một entry point duy nhất cho client xử lý routing, auth, rate limiting, và thường aggregation. Nó che topology internal service nên client không cần biết address mọi service. Không có nó, client couple với nhiều service và bạn không enforce cross-cutting policy ở một chỗ. -Câu phản vấn của senior trước khi tách: **"Một modular monolith có cho chúng ta cái này không?"** Ranh giới ở cấp _module_ — mỗi module có package riêng, schema/bảng riêng, scope transaction riêng, API riêng — cho bạn gần hết tính enforce mà không cần network. Khi tách thật, bạn **bắt buộc** thêm một **anti-corruption layer**: service mới phơi ra model riêng của nó và không bao giờ để lộ bảng nội bộ cho consumer, nếu không cú split tự khắc cứng vào mọi caller. +**Q4. Service discovery là gì?** +Thay vì hardcode service address (thay đổi khi pod scale/move), service register với registry (Consul, Eureka, K8s DNS) và look up nhau. Enable dynamic scaling và resilience với restart. Hardcode hostname gãy ngay khi pod reschedule. -Dữ liệu mới là cú split thật, không phải code. "Chúng ta chuyển code qua và giữ nguyên cái database dùng chung" chính là cách bạn có một **distributed monolith** — network call _và_ coupling dùng chung, cái tệ nhất của cả hai. Một split thật nghĩa là split database, nghĩa là mọi read xuyên service trở thành một join xuyên network — đó là nơi phát sinh toàn bộ machinery saga/outbox bên dưới. Nếu caller của bạn không chịu nổi eventual consistency cho dữ liệu đó, thì bạn chưa hề tách nó. +**Q5. Circuit breaker là gì và tại sao cần?** +Khi downstream slow/fail, retry ngây thơ chất đống và cạn thread — một dependency chết cascade gục whole service. Circuit breaker trip sau N failure, fail fast trong cooldown window thay vì chờ timeout, rồi half-open để test recovery. Nó chứa blast radius. -## 2. Service communication — và cái ngân sách latency quyết định pattern +**Q6. Khác nhau giữa API và event?** +API call là request trực tiếp cho action/response (imperative: "làm cái này"). Event là fact đã xảy ("order placed"), broadcast cho ai quan tâm (declarative). API couple caller→callee; event decouple producer khỏi consumer. Nhầm lẫn hai cái dẫn đến synchronous graph chatty, giòn nơi event sẽ sạch hơn. -Câu hỏi đầu tiên không phải "REST hay gRPC", mà là **"request này chịu nổi bao nhiêu hop đồng bộ?"** Trình bày nó như một ngân sách, vì đó là điều phỏng vấn viên thăm dò: +## Mid — tradeoff & điểm mù -``` -User → API gateway → Service A → Service B → DB +**Q1. Database-per-service rule là gì và tại sao shared DB là anti-pattern?** +Mỗi service nên sở hữu data của nó; shared database couple service ở storage layer — schema change ở một service phá service khác, và transaction span service. Anti-pattern (shared DB) thầm biến "microservices" thành distributed monolith. Nếu hai service phải share table, đó là tín hiệu chúng là một bounded context. -Một hop qua gateway: ~1–5 ms (routing + authn) -Một hop service cùng DC: ~0,1–1 ms trên dây, nhưng cái *call* còn hơn thế: - serialize + deserialize + scheduling thread + thời gian - DB của downstream + queueing của nó → P99 10–100 ms. -Ngân sách: 500 ms P99 → tối đa 3 hop đồng bộ, mỗi hop có ~100 ms trước khi -timeout của caller bắn và cả chuỗi xuống cấp. -``` +**Q2. Xử lý distributed transaction qua hai service thế nào?** +Thường bạn **không** dùng 2PC (two-phase commit) — nó là distributed lock không scale và fail tệ dưới partial failure. Thay vào đó dùng **Saga** pattern: chuỗi local transaction, mỗi cái có compensating action để undo khi fail (vd "reserve → nếu ship fail, release"). Saga trade atomicity lấy availability; bạn chấp nhận eventual consistency và build compensation logic. -Vượt quá 3 hop đồng bộ là bạn đang chơi trò ghế âm nhạc với timeout. Đó là lý do thật async thắng trong chuỗi dài: **bạn loại hẳn số hạng latency-của-hop ra khỏi request của người dùng.** +**Q3. Eventual consistency là gì và gì gãy cho user?** +Sau một write, không phải mọi reader thấy ngay — replica/derived data converge theo thời gian. Gãy: user update profile và refresh thấy bản cũ (rối), hoặc đọc own write từ replica chưa kịp. Mitigation: read-your-writes (đọc từ primary ngay sau write), hoặc serve giá trị vừa viết từ client. -### REST vs gRPC — nói đánh đổi, không nói sở thích +**Q4. Khác nhau giữa idempotency và exactly-once, và tại sao quan trọng cho retry?** +Retry có thể deliver message hai lần. **Idempotency** nghĩa process hai lần có cùng effect một lần (vd dedupe key, hoặc `UPDATE ... WHERE version = x`). **Exactly-once** (thực sự, end-to-end) gần như impossible xuyên service. Nên bạn build idempotent handler và chấp nhận at-least-once delivery — robust hơn nhiều so với đuổi exactly-once. -- **REST/HTTP:** phổ biến, debug được trong mọi trình duyệt, load-balance tầm thường, payload đọc được. Giá: JSON parse/serialize trên mỗi hop, HTTP/1.1 head-of-line blocking trên mỗi connection (giảm bớt bằng connection pool), không có chuyện streaming đáng nói, type yếu giữa các team. -- **gRPC:** HTTP/2 multiplex nhiều call đang chạy trên **một connection** — hết head-of-line blocking, giảm ~nửa overhead framing — và protobuf là binary: payload nhỏ hơn, chi phí parse gần như bằng không. Streaming request/response miễn phí. Giá: tooling cọ xát, khó đọc mắt thường bằng `curl`, thay đổi schema là một **hợp đồng deploy** (luật additive-field của protobuf là một spec bạn phải thực sự tuân theo), và binding async-server là chỗ người của Spring WebFlux kiếm tiền. +**Q5. Bulkhead isolation là gì?** +Bulkhead pattern giới hạn bao nhiêu resource một dependency có thể tiêu thụ (separate thread pool / connection pool per downstream). Nếu service B hang, nó chỉ fill bulkhead riêng, không phải pool shared với C và D — chứa failure. Không có nó, một slow dependency cạn shared pool và mọi thứ chết cùng nhau. -Câu trả lời senior: "hot path nội bộ, QPS cao, cần streaming → gRPC. API public, bề mặt debug, consumer đủ loại → REST." Và gotcha thật của cả hai là **timeouts, không phải protocol** — xem phần retry trước khi bạn đụng vào bất kỳ cái nào. +**Q6. Debug một request span 8 service thế nào?** +Distributed tracing (OpenTelemetry/W3C trace context) propagate trace ID xuyên service call, nên bạn thấy full waterfall và time được tiêu ở đâu. Không tracing bạn mù — log per service không nói path. Ghép với centralized structured logging keyed by trace ID. Senior đòi tracing _trước_ khi system lớn, không phải sau. -### Idempotency trước retry, luôn luôn +## Senior — thiết kế & phòng thủ -Retry mà không idempotency là duplicate side effect có thêm bước. Cách sửa là một **idempotency key** caller sinh ra và callee dedupe — và nó phải được enforce ở **storage**, không phải "chúng ta check một cái map": +**Q1. Một team muốn chia monolith 3 năm tuổi thành 20 microservice. Bạn nói sao?** +"Tôi push back mạnh. Microservices là quyết định org và ops, không phải silver bullet kỹ thuật. Vấn đề của monolith có khi là thiếu module boundary hoặc deploy bottleneck — sửa那些 trước (modular monolith). Tôi chỉ chia dọc theo một bounded context _đã chứng minh_ có scaling hoặc team-ownership khác biệt, và làm incremental (strangler fig), không phải big-bang 20-service rewrite nhân failure mode qua một đêm. Cái giá của 20 service (network, distributed data, on-call) chỉ đáng nếu independence là thật." -```sql --- SAI — check-then-insert bị race: hai lần retry đều qua SELECT, --- đều insert, và bạn đã tính phí khách hai lần. -SELECT 1 FROM payments WHERE idempotency_key = 'CUST-42-RETRY-9'; +**Q2. Thiết kế payment flow qua Order, Inventory, Payment service không dùng 2PC.** +"Saga. Order start: `reserve inventory` (local txn + compensate `release`), rồi `charge payment` (local txn + compensate `refund`). Nếu payment fail, saga orchestrator trigger `release inventory`. Mỗi bước là local transaction với compensating action; saga log cho phép resume sau crash. Tôi dùng orchestration saga (coordinator) hơn choreography (event) ở đây, vì flow có thứ tự rõ và failure handling — choreography khó reason ở 3+ bước. Trade: không global lock, nhưng tôi phải xử lý partial failure và eventual consistency tường minh." --- ĐÚNG — unique index là trọng tài, câu insert là atomic. -CREATE UNIQUE INDEX uq_payments_idem ON payments(idempotency_key); +**Q3. Một downstream HTTP call flaky (5% timeout). Thiết kế resilience layer.** +"Ba lớp: (1) **timeout** ngắn hơn SLA để fail fast, không hang; (2) **circuit breaker** ngừng bắn dependency đang chết và fail fast trong outage; (3) **retry with backoff + jitter** cho transient blip, nhưng chỉ idempotent call. Cộng **bulkhead** để dependency này không ăn whole thread pool. Và fallback (cached/stale value, hoặc queue để sau) để user nhận response degraded-but-working. Tôi đo downstream timeout rate và breaker open ratio để tune threshold từ thực tế." -INSERT INTO payments (id, idempotency_key, amount, status) -VALUES (nextval('payments_id_seq'), 'CUST-42-RETRY-9', 19.90, 'PENDING') -ON CONFLICT (idempotency_key) DO NOTHING -RETURNING id; -``` +**Q4. Khi nào monolith thực sự là lựa chọn tốt hơn, và giữ nó sạch thế nào?** +"Cho small team, young product, hoặc domain không có independent scaling need — modular monolith nhanh build, debug, deploy hơn, không có distributed failure mode. Giữ sạch bằng explicit module boundary (package không import internal của nhau), một deploy, và một database với clear schema ownership. Chỉ migrate sang service khi boundary's scaling/team need diverge. Premature splitting là microservice mistake phổ biến nhất tôi thấy." -Không có row trả về → đó là duplicate → trả về kết quả đã lưu trước đó. "Chúng tôi dedupe phía app bằng `ConcurrentHashMap`" chính là cách bạn mất tiền lúc 3 giờ sáng. +**Q5. Chọn sync vs async giữa hai service cụ thể, với ví dụ.** +"Order → Inventory cho 'reserve stock': nếu user đang chờ confirmation, sync (cần answer ngay, và timeout là failure rõ để show). Order → Notification/Analytics: async event ('order placed'), vì không ai block nó và muốn resilience nếu service đó down. Rule of thumb: sync cho happy-path request user blocked; async cho side-effect và fan-out. Nhầm (sync tới 5 service nối nhau) tạo latency chain fail ở link chậm nhất." -## 3. Resilience patterns — vẽ các state, rồi đến các con số +**Q6. Phòng thủ service boundary — làm sao biết split đúng?** +"Boundary đúng là bounded context: một lý do để đổi, một team sở hữu, deploy và scale một mình được, và data private. Tôi test split bằng câu hỏi: 'Nếu đổi schema service A, B có cần redeploy?' Nếu có, chúng là một context giả làm hai. Bằng chứng là operational: independent deploy frequency và failure isolation. Nếu A và B luôn deploy cùng và share DB, tôi đã build distributed monolith và nên merge. Boundary được validate bởi deploy/scale/failure independence, không phải vẽ ô." -### Circuit breaker +#### Self-check -Closed → mở khi chạm `failureRateThreshold` của sliding window → **half-open sau `waitDurationInOpenState`** để thăm dò bằng vài call thử → đóng lại hoặc mở lại. Các mặc định chính là câu trả lời: Resilience4j ship `failureRateThreshold=50%`, `slidingWindowSize=100`, `minimumNumberOfCalls=10`, `waitDurationInOpenState=60s`. Nêu được nó và bạn trông như người từng cấu hình, bởi vì bạn thật sự đã cấu hình. - -Sắc thái quan trọng mà phỏng vấn viên khoan: **một breaker mở từ chối nhanh (bạn cứu được công việc đang in-flight), nhưng nó cũng giấu traffic thật khỏi cú probe hồi phục.** Ngưỡng quá gắt khiến một cú rớt giật cục mở breaker và đưa cả dependency offline. `permittedNumberOfCallsInHalfOpenState` (mặc định 10) là đòn bẩy — nó là số call được phép _thử_ hồi phục. Để probe half-open sai và bạn đã thay "dependency chậm" bằng "dependency cộng thêm cold start 5 giây mỗi lần hồi phục." - -### Bulkhead — cô lập bằng một con số - -```java -// SAI — một pool duy nhất cho tất cả: một endpoint 'reporting' chậm -// từ từ nuốt hết 200 thread, và payments cũng timeout theo. -ExecutorService everything = Executors.newFixedThreadPool(200); - -// ĐÚNG — pool theo từng dependency, size bằng Little's law: -// pool_size = throughput × time_in_pool -// 50 req/s × 250 ms = ~13 thread cho 'reporting'; -// cho nó 15, chặn trần, và payments không bao giờ thấy nó chậm. -ExecutorService reporting = new ThreadPoolExecutor( - 15, 15, 0, MILLISECONDS, new ArrayBlockingQueue<>(50), new CallerRunsPolicy()); -ExecutorService payments = new ThreadPoolExecutor( - 10, 10, 0, MILLISECONDS, new ArrayBlockingQueue<>(30), new CallerRunsPolicy()); -``` - -Little's law là cùng một phép toán như thread pool (xem guide Java core) — bản microservices là: **mỗi dependency ngoài có pool giới hạn riêng và circuit breaker riêng**, nên một dependency sụp đổ bị cách ly. Bulkhead kiểu semaphore rẻ hơn (`SemaphoreBulkhead`) đúng khi bạn không cần offload công việc — một permit, không queue — và gần như không tốn gì. - -### Retry + backoff + jitter — cú DDoS tự gây - -`for (i < 3) retry` ngây thơ lúc outage là cú self-DoS phổ biến nhất trên production. Làm phép tính: - -``` -10.000 instance mỗi cái retry 3× không backoff = 30.000 request -đập vào một service đã sập → nó không bao giờ hồi phục. -Kể cả CÓ backoff: chờ cố định 1s nghĩa là ai cũng retry trên cùng -một nhịp — thundering herd đồng bộ. Jitter là thứ làm mất đồng bộ nó. -``` - -Mặc định của senior là **exponential backoff với full jitter** — ngẫu nhiên hóa thời gian chờ tới cái cap đã tính: - -```java -// SAI — chờ cố định 1s, mọi instance đồng bộ, và cú retry -// đập đúng vào endpoint đang ngã gục. -for (int attempt = 0; attempt < 3; attempt++) { - try { return call(); } catch (IOException e) { Thread.sleep(1000); } -} - -// ĐÚNG — exponential backoff với full jitter (Resilience4j): -// chờ cỡ 50–100, 100–200, 200–400 ms, ngẫu nhiên hóa mỗi lần. -Retry retry = Retry.custom("payments") - .maxAttempts(3) - .intervalFunction(IntervalFunction.ofExponentialBackoff( - Duration.ofMillis(100), // initial - 2.0, // multiplier - Duration.ofSeconds(2))) // cap - .build(); - -// và full jitter, nếu bạn muốn desync mạnh: -Retry jittery = Retry.custom("payments") - .intervalFunction(IntervalFunction.ofRandomized( - Duration.ofSeconds(1), Duration.ofSeconds(5))) - .build(); -``` - -Các luật kết thúc cuộc cãi: **chỉ retry trên call idempotent** (hoặc có idempotency key), **chỉ retry lỗi transient** (timeout, `5xx`, connection reset — không phải `400`), **chặn tổng attempt và tổng thời gian**, và **để circuit breaker có quyền phủ quyết retry** — một cú retry chạy trong lúc breaker đang mở chỉ là amplification. - -```java -// Toàn bộ stack, theo đúng thứ tự: -// timeout(800ms) → retry(3, backoff+jitter) → circuit breaker -Supplier decorated = Decorators.ofSupplier(() -> callDownstream()) - .withTimeout(Timeout.of(Duration.ofMillis(800))) - .withRetry(retry) - .withCircuitBreaker(CircuitBreaker.ofDefaults("payments")) - .decorate(); -``` - -### Ngân sách timeout & deadline — truyền ngân sách đi, đừng làm nó phình ra - -Mỗi service trong chuỗi nên lấy một **phần** của tổng ngân sách của caller, và ngân sách phải **thu hẹp lại khi qua dây** (deadline propagation — gRPC mang nó tự nhiên qua metadata; REST thì truyền qua header). Failure mode kinh điển: A gọi B với 5s, B gọi C với 5s, C gọi D với 5s → người dùng xin 5s tổng nhưng chuỗi hợp pháp có thể mất 20s. Rồi mỗi cú retry trung gian lại _nhân đôi_ cái đuôi. Nêu nó thành câu: "một timeout phải luôn nhỏ hơn cái timeout ở phía trên nó." - -## 4. Service discovery, config, gateway - -### Discovery - -Eureka/Consul đăng ký instance và đưa client một địa chỉ; K8s DNS chỉ resolve tên service thành một tập IP. Góc senior là **hành vi cache**: nếu client cache địa chỉ đã discovery và cache bị stale trong lúc instance churn (rolling deploy, một node chết), request đập vào endpoint chết — và _đó_ là thứ retry của bạn giờ khuếch đại. Cache TTL của discovery và failure mode "endpoint stale" là chuyện thật trên production. Biết rằng Consul dùng gossip và một server quorum; Eureka có self-preservation mode giữ dữ liệu registry stale khi network bị partition — cả hai đều là quirk phỏng vấn viên có thể đào. - -### Config - -Centralized config (Spring Cloud Config / K8s ConfigMap) cho bạn một bản audit thay đổi và một chỗ duy nhất để push secrets — nhưng mẹo phân loại senior là **"một cú push config restart service của tôi hay hot-reload nó?"** Hot reload (Spring Cloud Bus / `@RefreshScope`) hay ho cho tới khi ai đó refresh một vòng _secret rotation_ giữa request. Không bao giờ cache config mà không có TTL và một đường invalidation, và không bao giờ cho credentials vào git — hãy dùng Vault hoặc một external secrets backend, và rotate chúng. - -### Gateway — single point of failure mới - -Gateway gom authn, rate limiting, routing, và canary routing vào một hop. Đánh đổi cần nêu tên: **nó giờ là hộp chịu tải nặng nhất hệ thống**, và một cú outage của gateway kéo sập mọi thứ — đúng cái failure domain mà bạn đáng lẽ đã nói một microservice nên tránh. Mitigation đáng nói to: gateway stateless scale ngang sau một load balancer, client-side discovery làm fallback, và BFF (backend-for-frontend) như lựa chọn thay thế khi các client khác nhau cần aggregation khác nhau. Còn về rate limiting: token-bucket trước gateway + kiểm tra quota trong các service phía sau — riêng gateway thì bị bypass bởi bất kỳ client nào gọi thẳng service. - -## 5. Distributed data & transactions — phần thật sự khó - -### Saga: orchestration vs choreography - -Một saga là một chuỗi local transaction với các compensating action. Bản orchestrated (coordinator trung tâm) dễ suy luận hơn — một state machine bạn vẽ được — nhưng nó là một bottleneck và một single point of failure mới. Choreography (các service phản ứng theo event) tránh coordinator nhưng rải state machine khắp mọi service và là một cơn ác mộng tracing: "ai khởi động order này và vì sao nó ở state X?" là một cuộc khai quật xuyên service. - -```java -// Saga orchestrated: coordinator là nơi duy nhất tồn tại cái flow. -// Mỗi bước chạy trong local transaction riêng; mỗi bước có một -// compensate() gỡ nó đi trong local transaction riêng của chính nó. -@Component -public class OrderSaga { - // start → pay → reserveInventory → ship - // ↑ khi fail: compensate() từng bước đã hoàn tất, thứ tự ngược - public void run(CreateOrderCommand cmd) { - Order order = orderRepo.save(cmd.toOrder()); // local tx - try { - paymentClient.authorize(order.getPaymentId()); // RPC - inventoryClient.reserve(order.getItems()); // RPC - shipmentClient.schedule(order.getId()); // RPC - order.complete(); orderRepo.save(order); - } catch (SagaStepException e) { - // gỡ từng bước đã commit — mỗi cái trong tx riêng của nó - compensate(order, cmd, e); - order.fail(e); orderRepo.save(order); - } - } -} -``` - -Các compensating action là phần junior quên: **một compensation không phải là rollback** — nó là một local transaction mới sửa lại cái mà một transaction trước đã làm (hoàn tiền, nhả reservation). "Compensation = undo" là câu lọc senior chuẩn. Và nếu bản thân compensation fail, bạn có một **stuck saga** — đó là lý do một thiết kế thật log từng bước saga vào một persistent state table mà operator (hoặc một sweeper job) có thể đẩy về hoàn tất. Chẳng ai nói ra, nhưng cái state table đó chính là transaction log của saga, và nó là thứ khiến toàn bộ thứ này audit được. - -### 2PC — chỉ nêu ra để giải thích vì sao bạn không dùng - -Two-phase commit giữ lock trên tất cả participant trong lúc coordinator hỏi "sẵn sàng chưa?" — trong phase prepare, dữ liệu bị khóa, và nếu coordinator chết hoặc network bị partition, các lock vẫn bị giữ. Trên một business flow dài, điều đó nghĩa là những transaction có thể treo hàng phút trong khi tài nguyên vẫn bị khóa. Câu kinh điển: "2PC cho bạn atomic commit _chỉ khi_ mọi participant và coordinator cùng sống — đúng cái điều mà một distributed system không hứa." Đó là câu trả lời. Rồi quay sang outbox. - -### Bài toán dual-write và transactional outbox - -Bất cứ khi nào một service ghi vào DB **và** publish một event lên Kafka, hai cú ghi không atomic — crash giữa chúng và bạn mất một event, hoặc gửi trùng. Transactional outbox sửa cả hai: **ghi event vào cùng transaction database với thay đổi business**, rồi một relay publish nó. - -```sql --- một local transaction: -INSERT INTO order (id, state) VALUES (?, 'CREATED'); -INSERT INTO outbox (aggregate_id, event_type, payload, published_at) -VALUES (?, 'order.created', jsonb_build_object('id', ?), NULL); -``` - -```java -@Transactional -public Order createOrder(CreateOrderCommand cmd) { - Order order = orderRepo.save(cmd.toOrder()); - outboxRepo.save(OutboxEvent.of("order.created", order.getId())); // cùng tx - return order; // commit chưa publish gì — relay làm -} -``` - -```java -// relay: poll các row chưa published, publish, đánh dấu đã publish. -// FOR UPDATE SKIP LOCKED = nhiều instance relay mà không tranh nhau. -List pending = outboxRepo.findUnpublished(100); // ... SKIP LOCKED -for (OutboxEvent evt : pending) { - kafkaTemplate.send("orders", evt.getPayload()); - evt.markPublished(); -} -``` - -Lúc này đảm bảo là at-least-once (relay có thể crash sau publish trước khi mark) — điều đó ổn **vì consumer là idempotent** (phần 2). Bộ idempotency + outbox đó là thứ gần nhất với một distributed transaction sống sót qua production, và tự nêu ra nó không được ai nhắc là một dấu hiệu senior mạnh. - -### Event ordering và biến thể poison-message - -Ordering chỉ được đảm bảo theo từng partition; hãy key mọi event của một aggregate theo id của nó để chúng rơi vào một partition (guide Kafka nói chuyện này). Và bất kỳ consumer nào xử lý "chúng tôi nhận được duplicate" bằng cách _tín dụng gấp đôi_ chính là lý do idempotency là một chuyện của storage, không phải một niềm tin. - -### Distributed locking — khi bạn thực sự cần - -Cho cái critical section xuyên service hiếm hoi (phối hợp job, counter theo tenant), một DB-based lock có lease đánh bại Redis SETNX viết vội: - -```sql --- ĐÚNG — lease-backed: row CHÍNH LÀ cái lock, hết hạn nếu holder chết, --- và holder phải kiểm tra mình vẫn giữ nó trước khi làm việc (fencing). -INSERT INTO job_lock (name, holder, lease_expires_at) -VALUES ('reindex', 'node-7', now() + interval '30 seconds') -ON CONFLICT (name) -DO UPDATE SET holder = 'node-7', lease_expires_at = now() + interval '30 seconds' -WHERE job_lock.holder = 'node-7' OR job_lock.lease_expires_at < now() -RETURNING holder; -``` - -Cái bẫy ai cũng dính: một lock với TTL cố định và một holder chậm (GC pause) — lease hết hạn, holder thứ hai lấy lock, và giờ có hai "holder" chạy. Cách sửa là một **fencing token**: lock phát ra một token tăng đơn điệu và resource được bảo vệ từ chối write với token cũ hơn (như một version column của DB). Nhắc tới fencing token đáng một cái gật đầu; đó là câu trả lời senior. - -## 6. Observability — khác biệt giữa senior và bản demo - -Một senior thiết kế tracing từ ngày một, không phải sau incident. Nói ba điều cụ thể: - -- **Distributed tracing với correlation IDs.** Mỗi request mang một `trace-id`/`span-id` (W3C Trace Context), được truyền qua mọi hop và vào cả đường async — nếu không, một saga điều khiển bằng event không trace được. OpenTelemetry + một collector. -- **RED metrics, không phải "uptime" mơ hồ.** Rate, Errors, Duration theo từng service — và theo từng _dependency_, để sức khỏe của circuit breaker nhìn được từ bên ngoài. "Chúng tôi monitor service của mình" nghĩa là bạn trỏ được vào một dashboard hiện cả _chuỗi_, không phải một hộp. -- **Structured logs với correlation ID trong mọi dòng**, cộng log aggregation. "Request fail ở đâu đó trong chuỗi" trở thành "đây chính xác là span 15 ms." - -Bài test failure-mode trên production: "P99 của chúng tôi đi 80 ms → 3 s sau một deploy." Mẫu trả lời là symptom → tool → finding → fix, giống hệt guide Java core: kéo trace, xem hop nào nuốt thời gian, check state của breaker ở service đó và pool của dependency của nó, rồi sửa _nguyên nhân_ — thường là một dependency chậm với queue vô hạn hoặc thiếu timeout, không phải "thêm instance." - -## 7. Tự kiểm tra - -- [ ] Kể tên hai lý do KHÔNG tách monolith, và vì sao dữ liệu mới là cú split thật. -- [ ] Viết ngân sách timeout cho một chuỗi đồng bộ 3 hop và giải thích deadline propagation. -- [ ] Giải thích circuit breaker + bulkhead với một ví dụ thật và cách size theo Little's law. -- [ ] Vì sao retry ngây thơ lúc outage nguy hiểm — kèm con số? -- [ ] Transactional outbox giải quyết điều gì mà 2PC không thể, và vì sao nó là at-least-once? -- [ ] Giải thích vì sao idempotency key phải được enforce ở storage layer. -- [ ] Orchestration vs choreography — đánh đổi, và chỗ mỗi cái fail. -- [ ] "Một compensation không phải rollback" nghĩa là gì, và khi compensation fail thì chuyện gì xảy ra? -- [ ] Bạn trace một saga điều khiển bằng event từ đầu tới cuối thế nào? - -## 8. Follow-ups từ phỏng vấn viên - -Khi câu trả lời đầu tiên của bạn đáp xuống, họ bắt đầu khoan. Sẵn sàng cho những câu này: - -- "Bạn tách monolith và giờ checkout là 4 hop đồng bộ. Đi qua ngân sách latency — thời gian đi vào đâu, và bạn đổi gì?" -- "Một cơn retry storm vừa đập vào payment service. Đòn bẩy đầu tiên của bạn là gì — config retry, breaker, hay queue — và vì sao?" -- "Outbox relay publish một event hai lần vì nó crash sau `send`. Consumer của bạn tín dụng gấp đôi một user. Bug thật sự là gì, và cách sửa?" -- "Discovery cache của bạn stale và client đang đập vào instance chết. Cái gì vỡ trước, và resilience patterns của bạn giấu nó thế nào?" -- "Gateway sập và mọi thứ offline. Chuyện đó có chấp nhận được không, và bạn đã thiết kế gì để sống sót qua nó?" -- "Một saga compensation fail giữa chừng và order kẹt ở PENDING. Ai sửa nó, và cái gì trong DB làm cho điều đó khả thi?" -- "gRPC hay REST cho một payments API nội bộ 10k QPS có streaming? Cho tôi đánh đổi, không phải khẩu hiệu." -- "Vì sao retry backoff cố định tệ hơn retry có jitter — chỉ tôi sự khác biệt về phân bố." -- "Consumer của bạn đọc event lệch thứ tự cho một order id. Những đảm bảo nào bị vi phạm, và bạn khôi phục thứ tự theo aggregate thế nào?" -- "Bạn sắp gọi một service mới không timeout, không breaker, không giới hạn pool. Cái đầu tiên duy nhất bạn thêm vào trước khi enable nó là gì?" - -Đó là bar microservices. +- [ ] Junior: Tôi giải thích được microservice vs monolith, sync vs async, API gateway, service discovery, circuit breaker, và API vs event. +- [ ] Mid: Tôi giải thích được database-per-service, Saga pattern, eventual consistency, idempotency vs exactly-once, bulkhead, và distributed tracing. +- [ ] Senior: Tôi argument được chống premature splitting với modular-monolith alternative, thiết kế payment saga không 2PC, build resilient HTTP layer (timeout+breaker+retry+bulkhead), và phòng thủ service boundary bằng deploy/scale/failure independence. From 53e9ae0fd02cdb638f4c6955689d6752efe053ad Mon Sep 17 00:00:00 2001 From: Tester Date: Thu, 13 Aug 2026 06:37:49 +0000 Subject: [PATCH 7/8] =?UTF-8?q?docs(interview):=20rewrite=20system-design?= =?UTF-8?q?=20as=20Junior=E2=86=92Senior=20Q&A=20series=20(#7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../blog/en/interview/system-design-senior.md | 358 +++-------------- .../blog/vi/interview/system-design-senior.md | 360 +++--------------- 2 files changed, 93 insertions(+), 625 deletions(-) diff --git a/src/data/blog/en/interview/system-design-senior.md b/src/data/blog/en/interview/system-design-senior.md index 5055d3d..78d07b7 100644 --- a/src/data/blog/en/interview/system-design-senior.md +++ b/src/data/blog/en/interview/system-design-senior.md @@ -1,5 +1,5 @@ --- -title: "Senior Java Interview: System Design" +title: "Java Interview Prep #7: System Design — Junior to Senior" description: "System design is the senior capstone — a 45-minute judgment test. Process, capacity estimation, caching, CAP, scalability, and observability." pubDatetime: 2026-08-10T10:25:00+07:00 featured: false @@ -11,338 +11,72 @@ tags: - scalability --- -System design is the one interview section where the code you've written stops mattering and the judgment you've earned takes over. It's a 45–60 minute construction project performed in front of a live audience: ambiguous requirements, fuzzy numbers, and every single decision carrying a price tag. The interviewer is not looking for "the right architecture" — there is no right architecture. They're looking for how you think when the room is uncertain. +System design is the interview that has no right answer — only defensible trade-offs. Junior candidates name components; seniors walk a problem from vague requirements to a number-backed design and say where it breaks. This post climbs from "draw a diagram" to "here is the latency budget and the failure I'm watching". -A junior draws boxes. A senior narrates a tradeoff: "I'll cache the hot 1% in Redis because those keys serve 99% of the reads, and I'll accept up to 60 seconds of staleness on the write path because the business tolerates it — and here's the incident that taught me the naive cache is where the outage hides." That last clause is the whole game. Every section below ends with the drill an interviewer actually runs. +> Mindset: junior produces a diagram; senior produces a diagram _and_ a latency budget, a capacity estimate, and the single failure mode most likely to page them at 2 a.m. -> Mindset: recite a diagram and you're mid-level. Walk through a tradeoff with real numbers and a production failure mode, and you've earned the "senior" checkbox. You don't need to design Twitter — you need to design the part of Twitter that would actually break first, and say so out loud. +## Junior — foundations -## 1. The interview loop — what they're actually scoring +**Q1. What are the main building blocks of a web system?** +A typical stack: client → load balancer → web/app servers → cache → database → async workers/queue. Each layer exists to add a capability: the LB spreads load, the cache absorbs reads, the queue decouples slow work. Knowing the role of each block is the floor. -The loop looks like a sequence of five steps. It is, but the sequence is a disguise — the scorecard is filled in during the first ten seconds of each step. +**Q2. What is caching and the cache-aside pattern?** +A cache stores expensive results close to the reader. In **cache-aside**, the app checks the cache first; on a miss it reads the DB, populates the cache, and returns. It's simple and resilient (cache failure falls back to DB) but suffers a stampede on a hot-key miss. Variants: write-through (write to cache+DB together), write-back (write to cache, flush later). -1. **Clarify requirements & scope.** QPS? reads vs writes? latency budget? data size? consistency vs availability? The senior tell: you don't ask "how many users" — that's a population, not a load. You ask the questions that _reveal_ the load: "how many requests per second, what's the peak-to-average ratio, what's the read/write split, and what happens when a read is stale?" "10M users" tells you nothing about whether the service needs one node or fifty. -2. **Back-of-envelope capacity.** "10M users × 100 reads/user/day = 1B reads/day ≈ 11.5k QPS." Numbers stop hand-waving. A senior rounds aggressively, sanity-checks against a known anchor (a single instance serves ~1–10k simple JSON req/s; a single Postgres does low thousands of writes/s), and says "this is within an order of magnitude" instead of pretending to precision. -3. **High-level components.** Clients → CDN → load balancer → API gateway → services → cache → DB → async workers/queues. The order matters less than the story you tell about each hop: what it does, what it costs, and what it's for. -4. **Drill one or two areas deeply.** This is where the interview actually happens. Pick the two decisions with real consequences — the cache consistency contract, the sharding key, the queue depth — and go to internals. -5. **Address failure.** What breaks first? How do you degrade? A senior volunteers this without being asked, because "it works until it doesn't" is the definition of a production system. +**Q3. What is a load balancer and why use one?** +It distributes incoming traffic across multiple servers so no single node is overwhelmed and you can scale horizontally. It also provides health checks (stop sending to dead nodes) and a single endpoint for clients. Without it, one server is your ceiling and your SPOF. -The scorecard, in the order interviewers fill it: Did they ask clarifying questions before designing? Did they do the math, or skip it? Did they name tradeoffs, or recite "best practice"? Did they mention failure modes unprompted? Did they know when to stop designing? +**Q4. What is the difference between horizontal and vertical scaling?** +Vertical = make one machine bigger (more CPU/RAM) — simple but capped and a SPOF. Horizontal = add more machines behind a load balancer — no hard cap, resilient, but requires statelessness and shared storage. Most cloud systems scale horizontally. -> The drill: "Design Twitter." The interview doesn't start when you draw boxes. It starts when you ask "is the timeline read-heavy or write-heavy?" — and the silence before your first question is a datapoint. A senior starts asking questions immediately, because the first question is the one that decides whether the next 40 minutes are a design session or a monologue. +**Q5. What is a CDN and when do you use it?** +A Content Delivery Network caches static assets (images, JS, video) at edge locations near users, cutting latency and origin load. Use it for anything static and read-heavy. It doesn't help dynamic, user-specific responses (though edge compute is blurring this). -## 2. Capacity estimation — the math that separates engineers from diagram-drawers +**Q6. What does stateless mean, and why does it matter for scaling?** +A stateless service keeps no per-request memory on the server — every request carries what it needs (or pulls state from a shared store). That lets any node handle any request, so you can add nodes freely. Stateful services (sessions in memory) force sticky sessions and complicate scaling and failover. -Back-of-envelope math is the interview's anti-bullshit filter. Nobody expects an exact number; everybody expects an _anchored_ number — one you can defend from first principles instead of a vibe. +## Mid — tradeoffs & pitfalls -The anchors a senior carries in their head: +**Q1. Explain CAP theorem in plain terms.** +You can't have all three of Consistency (every read sees the latest write), Availability (every request gets a response), and Partition tolerance (the system survives network splits) — and partitions are inevitable, so you really choose between **CP** (pause to stay consistent) and **AP** (stay up, risk stale reads). A payments ledger is CP; a social feed is AP. The interview trap is saying "we have all three". -``` -1 small JSON response ≈ 1 KB -1 HTML page + assets ≈ 100 KB -1 image / thumbnail ≈ 100 KB–1 MB -1 user/day ≈ 10 requests (light) / 100 (heavy app) / 1000 (ad-tech) -1 Gbps NIC ≈ 125 MB/s ≈ ~100k small (1 KB) responses/s -1 stateless app instance ≈ 1k–10k simple JSON req/s -1 single-writer Postgres ≈ low thousands of writes/s, ~10x that for reads -1 network round trip within a DC ≈ 0.1–0.5 ms -``` +**Q2. What is cache invalidation and why is it hard?** +The hard part of caching is keeping the cache correct when data changes. Strategies: **TTL** (auto-expire, simple, allows brief staleness), **write-invalidate** (delete cache entry on write, then repopulate on next read), or **write-update** (refresh on write). The race: a write and a read can interleave so the cache ends up with stale data. Most teams accept short TTL staleness rather than chase perfect invalidation. -The standard walk for a read-heavy service: +**Q3. How do you size capacity — e.g. how many servers for 10k req/s?** +Back-of-envelope: if one server handles ~500 req/s at p99 < 200 ms (measured, not guessed), 10k req/s needs ~20 servers + headroom → ~25–30. Then check the bottleneck isn't the DB (each req might do 2–3 queries; a DB connection pool caps effective throughput). Capacity is about the _weakest_ layer, not the one you sized first. -``` -10M users, 50 reads/user/day, ~1 KB responses +**Q4. What is a message queue and what problem does it solve?** +A queue buffers work between a producer and a consumer that can't keep pace, and decouples them so a slow consumer or a crash doesn't block the producer. It also smooths spikes (the queue absorbs a burst; consumers drain at their rate). Without it, a traffic spike either drops requests or cascades failures. -→ 10M × 50 = 500M reads/day -→ 500M / 86,400 s ≈ 5,800 reads/s average -→ 5,800 × 1 KB ≈ 5.8 MB/s ≈ 46 Mbps across the wire (one NIC has headroom) -→ peak ≈ 3× average ≈ 17,400 r/s ≈ 140 Mbps -→ two or three stateless instances, a Redis cache for the hot set, - one DB tier — that's the whole architecture, and the numbers prove it -``` +**Q5. What is idempotency in APIs and how do you implement it?** +An idempotent endpoint produces the same result if called once or many times with the same input — essential because networks retry. Implement with a client-supplied **idempotency key**: store the result of the first call keyed by it, and return the stored result on retries instead of re-executing. `PUT` is naturally idempotent; `POST` is not, so it needs the key. -The trap every senior names unprompted: **peak-to-average ratio**. Average is the easy number; peaks are where systems die. 5.8k average QPS means nothing when a flash sale or a breaking-news moment pushes you to 50k for forty minutes. Design for the flash sale, not the Tuesday afternoon. And be honest about the ratio you chose — "3×" is a guess, and it should be a guess you can defend from your own dashboards. +**Q6. What is the difference between SQL and NoSQL, and when pick each?** +SQL (relational) gives ACID, rich queries, and strong schema — best for transactional, relational data (money, orders). NoSQL (document, key-value, column, graph) trades some guarantees for horizontal scale and flexible schema — best for high-volume, loosely-structured, or specialized data (session store, time-series, graphs). Pick by the data's consistency and shape, not fashion. -The storage math has its own trap: the application data is usually the small number, and the logs are the big one. +## Senior — design & defense -``` -100M URLs × 500 bytes raw ≈ 50 GB/year (negligible) -× replication factor 3 ≈ 150 GB/year (still nothing) -1B redirects × 100 byte log line ≈ 100 GB/day (a third of a TB per DAY) -``` +**Q1. Design a URL shortener (e.g. 100M URLs, 1B redirects/day). Walk it.** +"Requirements first: redirects must be fast (<50 ms) and highly available; writes are rare vs reads (~100:1). Design: a hash/Base62 of a counter or hash of the long URL → short key. Store (short_key → long_url) in a DB; cache hot keys in Redis (most redirects hit a small hot set). Redirect service is stateless behind an LB, reads cache → DB on miss. Scale: shard the DB by key prefix; Redis cluster for cache. Capacity: 1B/86400 ≈ 11.5k redirects/s avg, with spikes — a few stateless app nodes + Redis handle it. The failure I watch: cache miss stampede on a suddenly-hot link → use a single-flight/lock per key on miss." -That "third of a TB a day" forces the real design decision — retention, sampling, and aggregation — long before the URL table does. And the sanity check interviewers love: take a throughput number and convert it to a network or disk number, then say whether the bottleneck is CPU, NIC, or storage. "5.8k req/s of 1 KB responses is 46 Mbps" is a sentence that ends the hand-waving. +**Q2. A service's p99 latency tripled after a deploy. Find the cause with a budget.** +"I decompose the latency budget: LB → TLS → app → cache (1–2 ms) → DB (5–15 ms) → downstream call. I'd compare the new trace waterfall to baseline. Tripled p99 almost always means a new synchronous dependency or an N+1 query (each request now does 50 DB calls instead of 1). Fix: batch the calls, move the new dependency to async/off the critical path, or add a cache. I prove it by showing the per-span p99 before/after — the offending span is the one that grew, not 'the system is slow'." -> The drill: "10M users, 5 posts/user/day, each post read 100 times. Size it." The senior answer produces reads/s, writes/s, bandwidth, and a year of storage — and then says the ratio out loud: "that's a 100:1 read:write workload, so I'm designing a cache, not a write engine." The ratio is the answer the interviewer was fishing for; the arithmetic is just the receipt. +**Q3. You must keep the system up during a full region outage. Design for it.** +"Active-passive or active-active across two regions. Data: replicate the DB (async cross-region) and use a CP store that tolerates the split; accept that during the partition, the secondary may serve slightly stale data (AP during partition, reconcile after). Traffic: DNS or global LB fails over to the healthy region; clients retry with backoff. The real risk is split-brain on writes — I'd make the inactive region read-only or use a consensus store for the few write paths that matter. I'd test the failover with a game day, not assume it works." -## 3. Cache strategy — the section where seniors earn their keep +**Q4. How do you choose between a cache and a bigger database for read scale?** +"If reads are hot and repetitive (same 5% of data gets 95% of traffic), a cache offloads the DB dramatically and is cheaper than scaling the DB vertically. If reads are uniformly distributed and cold, a cache has low hit rate and you're better scaling the DB (read replicas) — caching cold data just adds a useless layer. I'd measure the working-set hit rate first; cache only pays off above ~80% hit rate on a hot set. Otherwise, read replicas + indexing is the simpler win." -The beginner answer is "use Redis." The senior answer is the consistency contract, the eviction policy, the stampede protection, the L1/L2 layering, and the one cache architecture that collapsed a production system they've seen or caused. +**Q5. Design observability for a system you're handing to on-call. What's non-negotiable?** +"Three pillars: metrics (RED — rate, errors, duration — with SLOs and alerting on SLO burn), structured logs keyed by trace ID, and distributed tracing for request paths. Non-negotiable: every external call is timed and tagged, every error is countable, and alerts page on _symptoms_ (user-facing latency/error rate), not causes (CPU). A senior doesn't ship a system on-call can't debug at 2 a.m. — if you can't trace a slow request to a span, the design isn't done." -### The four placement strategies and what each one costs +**Q6. The interviewer says 'now make it 100x bigger.' What breaks first?** +"I'd name the weakest link, not hand-wave. At 100x, the single relational DB is the first to break — connection pools exhaust, write throughput caps. So I'd shard it (by tenant/user key), push reads to replicas, and move any analytics off the primary. The stateless app tier scales horizontally, so that's fine. The cache cluster scales by adding shards. The thing that 'breaks' is coordination: cross-shard transactions, and global queries that no longer fit one node — those force a redesign of the data model (denormalize, pre-aggregate). The honest answer: the DB, then the data model's assumptions." -| Strategy | What the cache does | Cost / risk | -| ----------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Cache-aside (lazy)** | App checks cache, misses, queries DB, populates cache | Simple; app owns invalidation; a miss under concurrency is a stampede | -| **Read-through** | Cache itself loads from DB on miss (Caffeine loader, CacheManager) | Less app code; harder to reason about who's really loading | -| **Write-through** | App writes cache, cache writes DB synchronously | Reads always fresh-ish; every write pays the cache hop, and the cache must not lose data | -| **Write-behind (write-back)** | App writes cache, cache batches to DB asynchronously | Highest write throughput — absorbs a 10× write spike a synchronous path can't — but a crash between cache and DB is lost data. The DB is behind by batch_size × batch_interval, always | +#### Self-check -Cache-aside is the default for a reason: it's the one where a cache failure degrades gracefully instead of corrupting. The others buy write performance or read simplicity at the price of a new failure mode, and a senior says which one they're buying. - -### The stampede — the classic production collapse - -**Cache stampede / thundering herd**: a hot key expires, and 10,000 concurrent requests all miss at once. That single cache miss is worth 10,000 database queries landing in the same millisecond — enough to turn a healthy DB into a pile of slow queries, which makes the cache miss again on the slow repopulation, which makes everything worse. - -```java -// WRONG: every miss is an independent DB call → 10,000 misses = 10,000 DB queries -Order o = cache.get(key); -if (o == null) { - o = db.find(key); // all 10,000 requests arrive here simultaneously - cache.put(key, o, Duration.ofMinutes(5)); -} - -// RIGHT: single-flight — exactly ONE request talks to the DB, the rest wait on it -CompletableFuture inflight = inflight.computeIfAbsent(key, k -> - CompletableFuture.supplyAsync(() -> db.find(k)) - .whenComplete((v, e) -> inflight.remove(k))); -Order o = inflight.get(2, TimeUnit.SECONDS); -``` - -And the silent companion to the stampede: **synchronized expiry**. A thousand keys written with the same TTL all expire at the same instant, so the stampede isn't one key — it's a thousand keys at once. The fix is a smear: - -```java -Duration ttl = Duration.ofSeconds(60 + ThreadLocalRandom.current().nextInt(20)); -``` - -With a 60s base and ±10s jitter, keys that were born together expire across a 20-second window instead of one synchronized instant. That jitter is the cheapest ten lines of latency insurance in distributed systems. - -### Invalidation — the two hard problems - -"There are only two hard things in computer science: cache invalidation and naming things." The senior version of cache invalidation: - -- **TTL-only** — you accept up-to-TTL staleness as a business contract. Fine when "the feed is a few seconds old" is acceptable; fatal for a balance or an inventory count. -- **Invalidate on write, never update on write.** Write the DB, then delete the cache key. The delete is not atomic with the write, so a failed delete leaves a stale entry — and that's what the TTL backstop is for. The catastrophic variant is write-through-update: the app writes the DB then writes the new value into the cache, and the two writes race, and the cache can end up holding an older value than the DB forever, with no TTL that helps, because "forever" is the point of the exercise. -- **Versioned keys.** `user:123:v41` → on every write, bump to `v42`. A reader that picked up `:v41` can never see a half-written `:v42`; the old key dies by TTL. This is the mechanism that kills the read-during-write race, and it's the honest answer to "how do you keep the cache and DB from diverging mid-write." - -### The L1/L2 hierarchy — where cache design becomes architecture - -The number that separates the diagram from the deployment: - -``` -in-JVM Caffeine hit → ~10–50 ns (essentially free) -Redis round trip → ~0.5–1 ms (50,000× slower than L1 — still "fast") -DB query, warm pool → ~1–10 ms -``` - -A hot read path in production is rarely "Redis." It's a **local L1 cache** (Caffeine) in each app instance holding the genuinely hot keys, with Redis as L2 behind it. The tradeoffs are the interesting part: - -- L1 is per-instance. A fleet of 100 instances each holds its own copy, so two instances can disagree for up to the TTL — that's fine for reads, fatal if you put a "balance" in L1. -- **Cold-start stampede.** Every instance repopulating L1 after a deploy sends 100 × its miss-rate at Redis simultaneously. If L1 was silently absorbing 99% of traffic, Redis was sized for 1% — and the deploy is now the outage. -- A senior watches the **L1 hit ratio**, not the Redis hit ratio. The Redis hit ratio can look healthy while L1 is doing all the work, and vice versa — the layering is invisible until it isn't. - -### Redis as cache, Redis as store, and the eviction dial - -Saying "Redis is fast" is mid-level. The senior framing is the durability contract: - -- **As a cache** — pure LRU/LFU eviction, loss-tolerant. `maxmemory` and an eviction policy (`allkeys-lru` vs `volatile-lru`) are capacity planning, not afterthoughts. In `noeviction` mode, a full Redis **rejects writes** — which for a cache means the DB suddenly absorbs the entire load with zero warning. Eviction policy is a load-shedding dial, and you set it on purpose. -- **As a store** — AOF + fsync on every write is a few thousand ops/s; fsync every second loses up to a second of data on a crash; RDB snapshots lose whatever you wrote since the last snapshot. A senior says "Redis as source of truth" and immediately defends the durability setting, because the words "source of truth" and "maybe I lost the last second" don't belong in the same sentence. - -And the hot key — the cache's own single point of failure. One celebrity key, one viral URL, one shared counter that 100,000 users hammer: a single Redis key with a giant value serializes on the single-threaded core, and its node becomes the ceiling. Fixes: split the key (`hot:user:123:0..31`), or serve it from L1 where it's genuinely hot, or — for the truly pathological case — accept the ceiling and instrument it. - -> The drill: "A flash sale starts at midnight, and every discounted item is cached with a TTL that expires at midnight." The senior answer names the stampede, the single-flight, the jittered TTL, the L1 fallback — and then the part that wins: a deliberate **pre-warm** of the hot keys ten minutes before midnight, so the DB never sees the cold-start curve at all. - -## 4. Consistency — CAP, PACELC, and why "eventual" needs a decision, not a hope - -The beginner answer is "you choose two of three." The senior answer starts by correcting the framing: **partitions are not a rare failure mode — they're an assumed condition of the network.** Every distributed system operates on the assumption that a partition will happen, so the real question is what you sacrifice _during_ the partition, and what happens _when it heals_. - -Under a partition you choose CP or AP: - -- **CP** (Raft, single-leader, quorum): the minority side returns errors or waits, but the two sides never diverge. When the partition heals, there's nothing to reconcile. -- **AP** (Dynamo-style): both sides accept writes, so when the partition heals you hold **two conflicting values for the same key**, and somebody has to decide which one wins. "Eventual consistency" is not the data sorting itself out by magic — it's you having a written-down merge strategy. - -**PACELC** is the extension that makes seniors shine: even when there is **no** partition (the "ELC"), you're still choosing between **Latency and Consistency** on every operation. That's the honest cost of strong consistency — a synchronous quorum write costs the round trips it takes to reach the quorum, and the latency is the price of the guarantee. A senior volunteers PACELC unprompted because it turns a philosophy debate into a latency budget. - -### R + W > N — the math under "eventual" - -``` -N = replicas, R = read quorum, W = write quorum -R + W > N → every read intersects a node that holds the latest write -``` - -N=3, W=2, R=2: a write lands on two nodes, a read reads two nodes, the sets intersect on at least one — so a read can never miss a completed write. That intersection is the entire mechanism behind "quorum reads/writes," and it's the concrete meaning of "eventual" — the eventual is bounded by how long until a read covers the quorum, not by vibes. - -Two senior caveats sit on top of the math: - -1. **R + W > N tells you that a read sees _a_ node with the write — not _which_ write is newest.** You still need versioning: vector clocks, or a logical clock (Lamport/HLC). And **last-write-wins with wall-clock timestamps is how you lose data**: two clients on different clocks, an NTP correction, a rollback, and LWW silently picks the wrong "latest." -2. **Quorum availability is a cliff, not a slope.** With N=3, W=2, losing one node is a non-event, but losing two makes writes impossible. "Three replicas" sounds like triple redundancy and behaves like: one failure is fine, two is an outage. - -### The leader-based reality of most Java systems - -Here's the honest punchline interviewers want to hear: for a typical Java service, you don't actually choose between CP and AP. You choose a **single leader** — Postgres primary, a Redis master, Raft in ZooKeeper/etcd — which is CP with a single writer, and you accept the availability ceiling that comes with it. You reach for Dynamo-style AP only when the availability requirement genuinely cannot be met by a leader: global scale, always-writeable, offline-capable — shopping carts, messaging, collaboration. A senior says "I want one source of truth" out loud and reaches for the complexity budget only when the requirement demands it. The most expensive sentence in system design is "but what if the leader is down?" — a senior knows the answer is "then writes are down," and decides whether that's acceptable before the architecture, not after. - -### The failure modes interviewers probe - -- **Read-your-writes.** User posts, refreshes, and their post isn't there. Under eventual consistency this is real and business-visible. Fixes: read-after-write affinity (route reads for that session to the replica that just accepted the write), or a session-stickiness layer. -- **Split-brain.** Two nodes both accept writes believing they're the leader. The defense is quorum (W=2 makes two simultaneous leaders impossible with N=3) plus **fencing tokens / epoch numbers** so a demoted leader can't keep writing after losing the election. "Stale leader, new leader, the old one writes anyway" is the exact trace a senior narrates. -- **The two-generals problem.** Two processes over an unreliable channel can never be _guaranteed_ to agree on a message. There is no protocol that makes distributed commit free — only protocols that make the failure window smaller, and you pay for the size of the window. That's why the transactional outbox (write the row and the event in one DB transaction, let a relay publish) exists: it trades a distributed transaction for a local one plus a retryable relay. - -> The drill: "Your order service runs two Postgres primaries for 'availability.' The auditor finds orders that exist on one and not the other." The senior answer: that's split-brain, and the fix is a leader election plus fencing — or a quorum — and if the requirement really is availability-first, you design AP with a conflict-resolution policy you can defend to a regulator, and you say the phrase "two primaries is not a distributed system, it's a bug with high availability" out loud. - -## 5. Scalability patterns — the mechanics behind the boxes - -The beginner answer is "add more servers." The senior answer is the three axes, the statelessness prerequisite, the sharding math, the backpressure contract, and the load-balancer layers — because each of those is a place where "add more servers" silently stops working. - -### Statelessness — the prerequisite nobody argues with but everybody violates - -You cannot horizontally scale a service that keeps session state in local memory. Sessions belong in Redis (or a session store); `HttpSession` in local memory means a node failure evicts every session it held, and scaling out doesn't spread load so much as shuffle it. The senior addition: statelessness is not just sessions — it's any **local cache you treat as disposable** and any **background thread that assumes it's the only one**. A `@Scheduled` job running on every instance is a bug you deploy on purpose: - -```java -// WRONG: five instances, five concurrent purges — double work, races, no single owner -@Component -class NightlyPurge { - @Scheduled(cron = "0 0 2 * * *") - void purge() { /* everyone runs this */ } -} - -// RIGHT: ShedLock (or a DB row lease) — exactly one leader runs the job -@SchedulerLock(name = "nightlyPurge", lockAtMostFor = "PT1H") -@Scheduled(cron = "0 0 2 * * *") -void purge() { /* one instance holds the lease */ } -``` - -### Sharding — the math, the hot key, and the growth trap - -Sharding splits the data by a key so no single node holds everything. The three strategies, with their failure modes: - -- **Range** — `user_id < 1M` on shard 1. Great for range scans; fatal for hot ranges — newest users, latest timestamps, all land on one shard, and that shard becomes the ceiling while the rest idle. -- **Hash** — `hash(key) % N`. Uniform distribution, but no range locality, and **adding a shard remaps almost every key**. Consistent hashing reduces the reshuffle to ~1/N of keys when a node joins or leaves — with 10 nodes, ~10% of keys move instead of ~90%. -- **Directory** — a lookup table mapping key → shard. Most flexible; but the directory is itself a hot, strongly-consistent store, which is just the bottleneck wearing a different hat. - -The hot key bites in sharding exactly the way it bites in Kafka: one celebrity, one top seller, one busiest customer — the hash drops them on one shard, and that shard's ceiling is your system's ceiling. The fixes are the same family: composite keys, shard-within-the-shard, or instrument and accept. And the clarifying statement interviewers fish for: **replication gives you failure tolerance, not scale.** A shard replicated 3× still has the write ceiling of one node — copies don't parallelize writes. - -### Async + backpressure — the "shed load" decision - -A queue between the request path and the slow work is the classic design. The part candidates skip is **backpressure** — what happens when the queue fills faster than the workers drain it. Little's law, one more time: - -``` -queue depth = arrival rate × processing time -10k msg/s × 1 s of processing = 10,000 messages in flight at steady state -``` - -A queue with no bound and no scaling is just a buffer for a delayed outage: the workers fall behind, the queue grows, the queue's storage grows, then the queue dies and the producers pile up instead. The senior playbook: - -- **Bound the queue** and reject or drop when full — a fast, clean failure beats a slow, cascading one. -- **Scale the workers with the backlog** (KEDA on Kafka lag, SQS autoscaling by queue depth) so the queue is the load signal, not a death spiral. -- **Degrade deliberately.** Serve the cache, shed the non-critical writes, return a friendly `503` instead of queueing forever. -- **Circuit breaker** (Resilience4j): after N failures, trip the breaker and fail fast. The number that makes this concrete: a dependency at 2 s latency with a 500 ms client timeout doesn't "slow down" — every caller becomes a parked thread, and at 10k req/s that's 20,000 threads waiting on a dead dependency. That's the outage pattern: not "the dependency failed," but "the failure propagated and took the whole fleet with it." The breaker converts a 2-minute timeout-fest into a 50 ms rejection. - -### Load balancing — the layers, and the deploy detail - -L4 balances TCP connections (fast, opaque, healthy-node-aware); L7 balances HTTP (path-based routing, endpoint health checks, sticky sessions). The senior detail interviewers probe is **connection draining**: on a rolling deploy, the LB stops sending new traffic to an old node and waits for in-flight requests to finish. A `kill -9` on a node still serving traffic is how "deploy" becomes "outage." Same family as the Kafka rebalance and the GC pause: graceful degradation is a design feature, not a nicety. - -> The drill: "My order service has 40 instances and still feels slow. The interviewer points out the single Postgres. Why is it the bottleneck?" The senior answer names the write path: a single writer means single-node write throughput, and 40 instances reading don't help writes. Then the honest follow-up — "so we either shard the write path, or, since the write QPS actually fits on one node, we accept the ceiling and tune the reads." Saying "we didn't need to shard" is a senior sentence. - -## 6. Mini example: URL shortener, done to senior depth - -The canonical system-design question, and the one where most prep blogs get the math wrong. The senior version: - -### The encoding math, with the birthday bound nobody mentions - -The standard line is "62^7 ≈ 3.5 trillion — more than enough space." That's true and it's a trap, because it confuses **space** with **collision probability**. A random 7-char code drawn from that space collides embarrassingly fast: - -``` -62^7 ≈ 3.5 × 10^12 → at 100M inserts, expected collisions ≈ N²/2M ≈ 1,400 -62^10 ≈ 8.4 × 10^17 → at 100M inserts, expected collisions ≈ 0.006 -``` - -A hundred million random 7-char codes collide on the order of fourteen hundred times. The naive loop ("while key exists, retry") turns into a retry storm at scale. The senior fix is to stop _generating_ keys and start _encoding_ them: - -```java -// WRONG: random 7-char codes — "3.5T of space" ignores the birthday bound -String key = randomBase62(7); // ~1,400 collisions per 100M inserts -while (keyExists(key)) key = randomBase62(7); - -// RIGHT: bijective encoding of a unique id — zero collisions by construction -long id = idService.nextId(); // snowflake or DB sequence -String key = encodeBase62(id); // 7 chars cover 3.5T sequential ids - -// RIGHT for unguessable keys: hash the URL, take 10 base62 chars (birthday-safe) -String key = encodeBase62(sha256(url), 10); -``` - -### The numbers that size the whole thing - -``` -100M new URLs/day → ~1,200 writes/s sustained, 3–5× peak -1B redirects/day → ~11.5k reads/s, ~35k peak -read:write ratio → ~10:1 → textbook cache profile: hot reads, cold writes -``` - -``` -Storage: 100M URLs × 500 bytes ≈ 50 GB/year, RF3 ≈ 150 GB → trivial -Logs: 1B redirects × ~100 bytes ≈ 100 GB/day → ~3 TB/month -``` - -The logs are the storage problem, not the URLs. Retention and aggregation decide the real infrastructure cost, and saying so unprompted is the senior tell. - -### The cache layer, with the latency budget - -A redirect has a ~10 ms latency budget. Against that budget: a DB hit is 1–10 ms (most of the budget), a Redis hit is ~0.5 ms, a CDN edge redirect is ~1–5 ms from the nearest PoP. Most redirects hit a small set of URLs — the 1% of keys that serve 99% of the traffic — so the design is: - -1. **CDN edge cache** for the genuinely viral URLs (a redirect served from the edge never reaches your infrastructure). -2. **Redis with LRU** for the hot set, `R + W` tuned as a cache, not a store. -3. **The DB** for everything else, protected by single-flight so a cache eviction never becomes a stampede. - -The cache isn't a "nice to have" here — it's the design. Without it, the DB is the redirect path, and the 10 ms budget dies on the first popularity spike. - -### The decisions with consequences - -- **301 vs 302.** A `301` is cached by browsers and every intermediate proxy — one redirect is served and never hits you again. Cheap, and you pay for it with: you can't change the target for that key for the cache lifetime, analytics go blind, and a bad redirect survives your fix in every client's cache. A `302` hits your service (or your CDN) every time — more load, but you control the target and measure every click. The senior answer: `302` + CDN, and `301` only for keys you will never change. -- **Enumeration.** Sequential base62 keys are crawlable — every short URL in order, for free. Random keys are not. If the service is public, rate-limit lookups and think about whether keys should be unguessable. -- **The viral URL.** One URL doing 1M redirects in two minutes: the hot key saturates Redis (single-threaded core, one node), so the answer is CDN-first plus the split-key trick, and instrument the top-K list so you see it coming. - -> The drill: "Design a URL shortener." The senior answer delivers, in about ten minutes: the read:write ratio (~10:1), the birthday-bound correction (random 7-char codes collide ~1,400 times per 100M; encode an ID instead), the log-storage math (~3 TB/month of raw logs), the 301/302 decision, and the CDN-first hot-key answer. That sequence, unprompted, is the whole section. - -## 7. Observability — the design isn't done until you can see it fail - -"We'll add monitoring later" is a red flag because it's the only sentence that makes every other design decision undebuggable. Observability is not a dashboard you add at the end; it's the difference between a senior who can walk into an incident and a senior who gets paged into a mystery. - -- **Metrics.** RED for request services (Rate, Errors, Duration); USE for resources (Utilization, Saturation, Errors). The senior move is naming the metric that maps to the SLO — p99 latency, error rate, queue depth — rather than "CPU." CPU is a resource metric; the user feels latency. -- **Logs.** Structured JSON, one line per event, and every line carries `traceId`/`spanId`. At 10k req/s, logging every success is 10k lines/s of noise — log at warn/error by default and sample the happy path. The correlation ID is the thread that ties the request across services, and the W3C `traceparent` header is how it travels. -- **Tracing.** Distributed traces (Micrometer Tracing / OpenTelemetry) show the first slow span in a chain. They're not free: each span is serialized and exported, ~0.1–1% of request latency and a real CPU tax at scale — so head-based sampling (10% or 1% at high QPS) is part of the design, not a compromise. -- **SLI / SLO / error budget.** An SLO of 99.9% is ~43 minutes of allowed downtime per month. The error budget converts "can we ship?" from a vibe into arithmetic: budget burned → stop shipping risky changes. Alert on the SLO — symptom-based (latency, errors), not cause-based (a specific log line) — because symptoms are what users feel and causes are what your on-call will find anyway. - -> The drill: "p95 latency doubled at 3 a.m. Walk me through your first ten minutes." The senior answer: check the SLO burn rate first (is this trending or a blip?), grab a failing request's correlation ID, follow its trace to the first slow span, then ask the branching question — is it a dependency (trip the circuit breaker) or the DB (buffer-pool hit ratio, slow-query log, connection-pool saturation)? — and, the part that wins: "I can do all of that because I instrumented it before the deploy, so the data was already there." - -## 8. Self-check - -- [ ] Ask the clarifying questions that reveal load (QPS, peak-to-average, read/write ratio) before drawing a single box. -- [ ] Size a read-heavy service from anchors: users → requests/day → QPS → bandwidth, with a defensible peak factor. -- [ ] Explain why a random 7-char base62 key collides ~1,400 times per 100M inserts and how encoding an ID fixes it. -- [ ] Design cache-aside with single-flight stampede protection, jittered TTL, and invalidate-on-write — and say which failure each fix prevents. -- [ ] Name the L1/L2 tradeoffs and the cold-start stampede after a deploy. -- [ ] State CAP correctly (what you sacrifice _during_ a partition) and add PACELC unprompted. -- [ ] Prove `R + W > N` and explain why LWW with wall-clock timestamps loses data. -- [ ] Defend a sharding key — and say what a hot key does to the system's ceiling. -- [ ] Give the backpressure contract: bound the queue, scale workers on backlog, circuit-break the dead dependency. -- [ ] Answer "what breaks first and how do you degrade?" without being asked. - -## 9. Interviewer follow-ups - -When your first answer lands, they start drilling. Be ready for these: - -- "10M users is a population, not a load. What numbers do you actually need, and why?" -- "Your cache hit ratio is 90%. Is that good? What's the number you'd really watch?" -- "A hot key expires and 10,000 requests hit the DB at once. Walk me through the fix, then tell me which of the three failure modes you just handled." -- "Cache the value, or invalidate the key, on write? What does TTL protect you from in each case?" -- "Redis restarts at 3 a.m. What happens to your service, and what do you do before the next deploy?" -- "State CAP for me — and tell me what happens when the partition heals, not just during it." -- "Why does last-write-wins with timestamps lose data? What would you use instead?" -- "Your queue depth is climbing, workers at 100% CPU, DB at 40%. Where's the bottleneck, and what do you change first?" -- "Add a shard to a hash-sharded store. What breaks, and what does consistent hashing change?" -- "A 301 vs a 302 for redirects — which one do you pick, and what do you lose either way?" -- "Your p95 just doubled. SLO is 99.9%. What's your error budget, and what do you check first?" -- "Why is 'two Postgres primaries' not high availability — and what's the design that actually is?" - -That's the system-design bar. +- [ ] Junior: I can name the building blocks (LB, cache, queue, DB), explain cache-aside, horizontal vs vertical scaling, CDN, and statelessness. +- [ ] Mid: I can explain CAP, cache invalidation, capacity sizing from req/s, message queues, API idempotency, and SQL vs NoSQL trade-offs. +- [ ] Senior: I can design a URL shortener with a latency budget and stampede protection, diagnose a p99 regression from a trace, design for region failure, choose cache vs bigger DB by hit rate, define non-negotiable observability, and name the first component to break at 100x. diff --git a/src/data/blog/vi/interview/system-design-senior.md b/src/data/blog/vi/interview/system-design-senior.md index 866e4a7..515e74a 100644 --- a/src/data/blog/vi/interview/system-design-senior.md +++ b/src/data/blog/vi/interview/system-design-senior.md @@ -1,6 +1,6 @@ --- -title: "Phỏng vấn Senior Java: System Design" -description: "System design là bài capstone của senior — test tư duy 45 phút. Quy trình, ước lượng capacity, caching, CAP, scalability, và observability." +title: "Ôn thi Java #7: System Design — Junior đến Senior" +description: "System design là capstone của senior — một bài test phán đoán 45 phút. Process, ước lượng capacity, caching, CAP, scalability, và observability." pubDatetime: 2026-08-10T10:25:00+07:00 featured: false draft: false @@ -11,338 +11,72 @@ tags: - scalability --- -System design là phần phỏng vấn duy nhất mà code bạn từng viết không còn ý nghĩa — thứ được đem ra soi là phán đoán bạn tích lũy được. Đó là một dự án xây dựng kéo dài 45–60 phút trình diễn trước khán giả trực tiếp: yêu cầu mơ hồ, con số mù mờ, và mỗi quyết định đều có giá phải trả. Phỏng vấn viên không tìm "kiến trúc đúng" — không có kiến trúc đúng. Họ tìm cách bạn nghĩ khi căn phòng đầy bất định. +System design là buổi phỏng vấn không có đáp án đúng — chỉ có trade-off có thể phòng thủ. Junior gọi tên component; senior đi một bài toán từ yêu cầu mơ hồ đến thiết kế có số và chỉ chỗ nó gãy. Bài này leo từ "vẽ diagram" đến "đây là latency budget và failure tôi đang canh". -Junior vẽ ô vuông. Senior kể chuyện tradeoff: "Tôi cache 1% key nóng trong Redis vì chúng phục vụ 99% số reads, và tôi chấp nhận stale tới 60 giây trên write path vì business chịu được — và đây là incident dạy tôi rằng cache ngây thơ chính là nơi ẩn náu của outage." Vế cuối cùng đó là toàn bộ trò chơi. Mỗi phần dưới đây kết thúc bằng bài drill phỏng vấn viên thực sự chạy. +> Mindset: junior sản xuất diagram; senior sản xuất diagram _và_ latency budget, capacity estimate, và failure mode duy nhất có khả năng page họ lúc 2 giờ sáng nhất. -> Tư duy: nhả ra một diagram thì bạn chỉ ở tầm mid-level. Đi qua một tradeoff bằng số thật và một failure mode ngoài production, và bạn chạm được ô "senior". Bạn không cần thiết kế Twitter — bạn cần thiết kế đúng phần của Twitter sẽ gãy đầu tiên, và nói to điều đó ra. +## Junior — nền tảng -## 1. Vòng lặp phỏng vấn — họ thực sự chấm điểm cái gì +**Q1. Các building block chính của một web system là gì?** +Một stack điển hình: client → load balancer → web/app server → cache → database → async worker/queue. Mỗi layer tồn tại để thêm capability: LB spread load, cache absorb read, queue decouple slow work. Biết role của mỗi block là sàn. -Vòng lặp trông như năm bước tuần tự. Đúng vậy, nhưng thứ tự đó chỉ là ngụy trang — bảng điểm được điền trong mười giây đầu của mỗi bước. +**Q2. Caching là gì và cache-aside pattern?** +Cache lưu kết quả đắt gần reader. Trong **cache-aside**, app check cache trước; miss thì đọc DB, populate cache, trả về. Đơn giản và resilient (cache fail fallback DB) nhưng chịu stampede trên hot-key miss. Biến thể: write-through (viết cache+DB cùng nhau), write-back (viết cache, flush sau). -1. **Làm rõ yêu cầu & scope.** QPS? read vs write? latency budget? data size? consistency vs availability? Dấu hiệu của senior: bạn không hỏi "bao nhiêu user" — đó là dân số, không phải tải. Bạn hỏi những câu _lộ ra_ tải: "bao nhiêu request mỗi giây, tỷ lệ peak-to-average là bao nhiêu, tỷ lệ read/write ra sao, và chuyện gì xảy ra khi một read bị stale?" "10M user" không nói cho bạn biết service cần một node hay năm mươi node. -2. **Ước lượng capacity tầm bậy.** "10M user × 100 read/user/ngày = 1B read/ngày ≈ 11.5k QPS." Con số chấm dứt việc vung tay. Senior làm tròn mạnh tay, kiểm tra chéo với một mốc biết trước (một instance đơn phục vụ ~1–10k JSON request đơn giản/s; một Postgres single-writer ghi vài nghìn writes/s), và nói "trong một bậc độ lớn" thay vì giả vờ chính xác. -3. **Component cao cấp.** Clients → CDN → load balancer → API gateway → services → cache → DB → async workers/queues. Thứ tự ít quan trọng hơn câu chuyện bạn kể về từng hop: nó làm gì, tốn bao nhiêu, để làm gì. -4. **Đào sâu một hoặc hai chỗ.** Đây là nơi cuộc phỏng vấn thực sự diễn ra. Chọn hai quyết định có hậu quả thật — hợp đồng consistency của cache, sharding key, độ sâu queue — rồi đi sâu vào nội tại. -5. **Xử lý failure.** Cái gì gãy trước? Làm sao degrade? Senior tự nguyện nêu điều này mà không cần được hỏi, vì "nó chạy cho đến khi nó không chạy nữa" chính là định nghĩa của một hệ thống production. +**Q3. Load balancer là gì và tại sao dùng?** +Nó distribute traffic đến nhiều server để không node nào quá tải và bạn scale horizontally. Nó cũng cung cấp health check (ngừng gửi đến node chết) và một endpoint duy nhất cho client. Không có nó, một server là ceiling và SPOF của bạn. -Bảng điểm, theo thứ tự phỏng vấn viên điền: Họ có hỏi câu làm rõ trước khi thiết kế không? Họ có làm toán, hay bỏ qua? Họ có nêu tên tradeoff, hay đọc thuộc "best practice"? Họ có nhắc tới failure mode mà không được hỏi? Họ có biết lúc nào nên dừng thiết kế? +**Q4. Khác nhau giữa horizontal và vertical scaling?** +Vertical = làm machine lớn hơn (nhiều CPU/RAM) — đơn giản nhưng capped và SPOF. Horizontal = thêm machine sau LB — không hard cap, resilient, nhưng đòi statelessness và shared storage. Hầu hết cloud system scale horizontally. -> Drill: "Design Twitter." Phỏng vấn không bắt đầu khi bạn vẽ ô vuông. Nó bắt đầu khi bạn hỏi "timeline này nặng read hay nặng write?" — và sự im lặng trước câu hỏi đầu tiên của bạn là một datapoint. Senior bắt đầu đặt câu hỏi ngay lập tức, vì câu hỏi đầu tiên chính là câu quyết định 40 phút còn lại là một buổi thiết kế hay một bài độc thoại. +**Q5. CDN là gì và khi nào dùng?** +Content Delivery Network cache static asset (image, JS, video) tại edge gần user, cắt latency và origin load. Dùng cho anything static và read-heavy. Nó không giúp dynamic, user-specific response (dù edge compute đang làm mờ điều này). -## 2. Ước lượng capacity — phép toán tách kỹ sư khỏi kẻ vẽ diagram +**Q6. Stateless nghĩa là gì, và tại sao quan trọng cho scaling?** +Service stateless không giữ per-request memory trên server — mọi request mang thứ nó cần (hoặc pull state từ shared store). Điều đó cho phép node nào xử lý request nào, nên bạn thêm node tự do. Stateful service (session in memory) ép sticky session và complicate scaling và failover. -Toán tầm bậy là bộ lọc chống-bullshit của phỏng vấn. Không ai mong một con số chính xác; ai cũng mong một con số _có mốc neo_ — một con số bạn biện hộ được từ nguyên lý cơ bản thay vì từ cảm giác. +## Mid — tradeoff & điểm mù -Những mốc neo senior đội đầu: +**Q1. Giải thích CAP theorem bằng từ đơn giản.** +Bạn không có cả ba Consistency (mọi read thấy latest write), Availability (mọi request có response), và Partition tolerance (system sống sót network split) — và partition là inevitable, nên bạn thực chọn giữa **CP** (pause để giữ consistent) và **AP** (vẫn up, rủi ro stale read). Payment ledger là CP; social feed là AP. Bẫy phỏng vấn là nói "chúng tôi có cả ba". -``` -1 JSON response nhỏ ≈ 1 KB -1 HTML page + assets ≈ 100 KB -1 image / thumbnail ≈ 100 KB–1 MB -1 user/ngày ≈ 10 request (nhẹ) / 100 (app nặng) / 1000 (ad-tech) -1 Gbps NIC ≈ 125 MB/s ≈ ~100k response nhỏ (1 KB)/s -1 app instance stateless ≈ 1k–10k JSON request đơn giản/s -1 Postgres single-writer ≈ vài nghìn writes/s, ~10x cho reads -1 network round trip trong DC ≈ 0.1–0.5 ms -``` +**Q2. Cache invalidation là gì và tại sao khó?** +Phần khó của caching là giữ cache đúng khi data đổi. Chiến lược: **TTL** (auto-expire, đơn giản, cho phép brief staleness), **write-invalidate** (xóa cache entry khi write, rồi repopulate trên read tiếp), hoặc **write-update** (refresh khi write). Race: một write và một read có thể interleave nên cache kết thúc với stale data. Hầu hết team chấp nhận short TTL staleness hơn đuổi perfect invalidation. -Đường đi chuẩn cho một service nặng read: +**Q3. Size capacity thế nào — vd bao nhiêu server cho 10k req/s?** +Back-of-envelope: nếu một server xử lý ~500 req/s tại p99 < 200 ms (đo, không đoán), 10k req/s cần ~20 server + headroom → ~25–30. Rồi check bottleneck không phải DB (mỗi req có thể làm 2–3 query; connection pool cap effective throughput). Capacity là về layer _yếu nhất_, không phải cái bạn size đầu. -``` -10M user, 50 read/user/ngày, response ~1 KB +**Q4. Message queue là gì và giải quyết vấn đề gì?** +Queue buffer work giữa producer và consumer không kịp pace, và decouple chúng nên slow consumer hoặc crash không block producer. Nó cũng smooth spike (queue absorb burst; consumer drain tại rate). Không có nó, traffic spike hoặc drop request hoặc cascade failure. -→ 10M × 50 = 500M read/ngày -→ 500M / 86.400 s ≈ 5.800 read/s trung bình -→ 5.800 × 1 KB ≈ 5.8 MB/s ≈ 46 Mbps trên đường truyền (một NIC còn dư sức) -→ peak ≈ 3× trung bình ≈ 17.400 r/s ≈ 140 Mbps -→ hai ba instance stateless, một Redis cache cho tập nóng, - một tầng DB — đó là toàn bộ kiến trúc, và con số chứng minh điều đó -``` +**Q5. Idempotency trong API là gì và implement thế nào?** +Endpoint idempotent tạo cùng result nếu gọi một hoặc nhiều lần với cùng input — thiết yếu vì network retry. Implement với client-supplied **idempotency key**: lưu result của call đầu key bởi nó, và trả stored result trên retry thay vì re-execute. `PUT` tự nhiên idempotent; `POST` không, nên cần key. -Cái bẫy senior nào cũng tự nêu ra: **tỷ lệ peak-to-average**. Average là con số dễ; peak mới là nơi hệ thống chết. 5.8k QPS trung bình chẳng nghĩa lý gì khi một flash sale hay một sự kiện tin nóng đẩy bạn lên 50k trong bốn mươi phút. Hãy thiết kế cho flash sale, không phải cho chiều thứ Ba nhàn rỗi. Và hãy thành thật về tỷ lệ bạn chọn — "3×" là một con số đoán, và nó phải là con số đoán bạn biện hộ được từ dashboard của chính mình. +**Q6. Khác nhau giữa SQL và NoSQL, và khi nào chọn?** +SQL (relational) cho ACID, rich query, strong schema — tốt nhất cho transactional, relational data (money, order). NoSQL (document, key-value, column, graph) trade một số guarantee lấy horizontal scale và flexible schema — tốt nhất cho high-volume, loosely-structured, hoặc specialized data (session store, time-series, graph). Chọn bằng consistency và shape của data, không phải fashion. -Toán storage có cái bẫy riêng: dữ liệu ứng dụng thường là con số nhỏ, còn log mới là con số lớn. +## Senior — thiết kế & phòng thủ -``` -100M URL × 500 bytes raw ≈ 50 GB/năm (không đáng kể) -× replication factor 3 ≈ 150 GB/năm (vẫn là không) -1B redirect × 100 byte mỗi dòng log ≈ 100 GB/ngày (một phần ba TB mỗi NGÀY) -``` +**Q1. Thiết kế URL shortener (vd 100M URL, 1B redirect/day). Đi qua.** +"Requirements trước: redirect phải nhanh (<50 ms) và highly available; write hiếm vs read (~100:1). Thiết kế: hash/Base62 của counter hoặc hash của long URL → short key. Lưu (short_key → long_url) trong DB; cache hot key trong Redis (hầu hết redirect hit một hot set nhỏ). Redirect service stateless sau LB, đọc cache → DB trên miss. Scale: shard DB theo key prefix; Redis cluster cho cache. Capacity: 1B/86400 ≈ 11.5k redirect/s avg, với spike — vài stateless app node + Redis xử lý. Failure tôi canh: cache miss stampede trên link suddenly-hot → dùng single-flight/lock per key trên miss." -Cái "một phần ba TB mỗi ngày" đó buộc quyết định thiết kế thật — retention, sampling, aggregation — từ rất lâu trước khi bảng URL đặt ra vấn đề. Và câu sanity-check phỏng vấn viên yêu thích: lấy một con số throughput rồi quy đổi sang con số network hoặc disk, sau đó nói xem nút thắt là CPU, NIC hay storage. Câu "5.8k req/s với response 1 KB là 46 Mbps" kết thúc mọi sự vung tay. +**Q2. p99 latency của một service gấp 3 sau deploy. Tìm nguyên nhân với budget.** +"Tôi decompose latency budget: LB → TLS → app → cache (1–2 ms) → DB (5–15 ms) → downstream call. Tôi so sánh new trace waterfall với baseline. p99 gấp 3 hầu như luôn nghĩa một synchronous dependency mới hoặc N+1 query (mỗi request giờ làm 50 DB call thay vì 1). Fix: batch call, chuyển dependency mới sang async/off critical path, hoặc thêm cache. Tôi chứng minh bằng cách show per-span p99 before/after — span offending là cái tăng, không phải 'system chậm'." -> Drill: "10M user, 5 post/user/ngày, mỗi post được đọc 100 lần. Định cỡ nó." Câu trả lời senior cho ra reads/s, writes/s, bandwidth, và một năm storage — rồi nói to tỷ lệ: "đó là workload 100:1 read:write, nên tôi thiết kế một cache, không phải một write engine." Tỷ lệ chính là đáp án phỏng vấn viên câu cá; số học chỉ là biên lai. +**Q3. Bạn phải giữ system up trong full region outage. Thiết kế cho nó.** +"Active-passive hoặc active-active xuyên hai region. Data: replicate DB (async cross-region) và dùng CP store chịu split; chấp nhận trong partition, secondary có thể serve stale data (AP during partition, reconcile sau). Traffic: DNS hoặc global LB failover sang region khỏe; client retry với backoff. Rủi ro thật là split-brain trên write — tôi làm inactive region read-only hoặc dùng consensus store cho vài write path quan trọng. Tôi test failover với game day, không assume nó work." -## 3. Cache strategy — nơi senior kiếm cơm +**Q4. Chọn giữa cache và database lớn hơn cho read scale thế nào?** +"Nếu read hot và repetitive (cùng 5% data được 95% traffic), cache offload DB dramatic và rẻ hơn scale DB vertically. Nếu read uniformly distributed và cold, cache có low hit rate và bạn better scale DB (read replica) — caching cold data chỉ thêm layer vô dụng. Tôi đo working-set hit rate trước; cache chỉ pay off trên ~80% hit rate trên hot set. Không thì read replica + indexing là win đơn giản hơn." -Câu trả lời của người mới là "dùng Redis". Câu trả lời của senior là hợp đồng consistency, chính sách eviction, bảo vệ stampede, phân tầng L1/L2, và một kiến trúc cache đã làm sập một hệ thống production mà họ từng thấy hoặc từng gây ra. +**Q5. Thiết kế observability cho system bạn giao cho on-call. Gì non-negotiable?** +"Ba pillar: metrics (RED — rate, errors, duration — với SLO và alert trên SLO burn), structured log keyed by trace ID, và distributed tracing cho request path. Non-negotiable: mọi external call được time và tag, mọi error countable, và alert page trên _symptom_ (user-facing latency/error rate), không phải cause (CPU). Senior không ship system on-call không debug được lúc 2 giờ sáng — nếu không trace được slow request đến span, design chưa xong." -### Bốn chiến lược đặt cache và giá của từng cái +**Q6. Interviewer bảo 'giờ make nó 100x lớn hơn.' Gì gãy trước?** +"Tôi gọi tên weakest link, không hand-wave. Ở 100x, single relational DB là cái gãy đầu — connection pool exhaust, write throughput cap. Nên tôi shard nó (theo tenant/user key), push read sang replica, và chuyển analytics off primary. Stateless app tier scale horizontally, nên ổn. Cache cluster scale bằng thêm shard. Thứ 'gãy' là coordination: cross-shard transaction, và global query không còn fit một node — những thứ ép redesign data model (denormalize, pre-aggregate). Câu trả lời thật: DB, rồi assumption của data model." -| Chiến lược | Cache làm gì | Chi phí / rủi ro | -| ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Cache-aside (lazy)** | App kiểm tra cache, miss thì query DB, điền cache | Đơn giản; app sở hữu invalidation; một miss dưới sự cạnh tranh chính là stampede | -| **Read-through** | Bản thân cache load từ DB khi miss (Caffeine loader, CacheManager) | Ít code app hơn; khó suy luận ai thực sự đang load | -| **Write-through** | App ghi cache, cache ghi DB đồng bộ | Reads luôn tươi-gần-đúng; mỗi write trả giá một hop cache, và cache không được mất dữ liệu | -| **Write-behind (write-back)** | App ghi cache, cache gom lô ghi DB bất đồng bộ | Write throughput cao nhất — hấp thụ được spike 10× mà đường đồng bộ không chịu nổi — nhưng một crash giữa cache và DB là dữ liệu mất. DB luôn chậm đúng bằng batch_size × batch_interval, mãi mãi | +#### Self-check -Cache-aside là mặc định vì một lý do: nó là chiến lược duy nhất mà lỗi cache degrade nhẹ nhàng thay vì làm hỏng dữ liệu. Các chiến lược kia mua tốc độ ghi hoặc sự đơn giản của read bằng giá một failure mode mới — và senior nói rõ họ đang mua cái nào. - -### Stampede — cú sập production kinh điển - -**Cache stampede / thundering herd**: một hot key hết hạn, và 10.000 request đồng thời cùng miss. Cái cache miss đơn lẻ đó đáng giá 10.000 query database rơi vào đúng cùng một millisecond — đủ để biến một DB khỏe mạnh thành một đống query chậm, khiến cache lại miss trên lần repopulate chậm chạp, và mọi thứ càng tệ hơn. - -```java -// WRONG: mỗi miss là một DB call độc lập → 10.000 miss = 10.000 DB query -Order o = cache.get(key); -if (o == null) { - o = db.find(key); // cả 10.000 request đổ tới đây cùng lúc - cache.put(key, o, Duration.ofMinutes(5)); -} - -// RIGHT: single-flight — đúng MỘT request nói chuyện với DB, số còn lại chờ nó -CompletableFuture inflight = inflight.computeIfAbsent(key, k -> - CompletableFuture.supplyAsync(() -> db.find(k)) - .whenComplete((v, e) -> inflight.remove(k))); -Order o = inflight.get(2, TimeUnit.SECONDS); -``` - -Và người bạn thầm lặng của stampede: **synchronized expiry**. Một nghìn key ghi cùng một TTL sẽ hết hạn ở đúng cùng một khoảnh khắc, nên stampede không phải một key — mà là một nghìn key cùng lúc. Cách sửa là một lớp bôi trơn: - -```java -Duration ttl = Duration.ofSeconds(60 + ThreadLocalRandom.current().nextInt(20)); -``` - -Với base 60s và jitter ±10s, những key sinh cùng lúc sẽ hết hạn dải ra trong cửa sổ 20 giây thay vì một khoảnh khắc đồng bộ. Cái jitter đó là mười dòng code bảo hiểm latency rẻ nhất trong distributed systems. - -### Invalidation — hai bài toán khó - -"Trong khoa học máy tính chỉ có hai thứ khó: cache invalidation và naming things." Bản senior của cache invalidation: - -- **TTL-only** — bạn chấp nhận stale tới hết TTL như một hợp đồng kinh doanh. Ổn khi "feed vài giây tuổi là chấp nhận được"; chí mạng cho một balance hay một inventory count. -- **Invalidate on write, không bao giờ update on write.** Ghi DB, rồi xóa cache key. Xóa không atomic với ghi, nên một xóa thất bại để lại entry stale — và đó là lúc TTL đóng vai backstop. Biến thể thảm họa là write-through-update: app ghi DB rồi ghi giá trị mới vào cache, hai lần ghi race nhau, và cache có thể giữ một giá trị cũ hơn DB mãi mãi, không TTL nào cứu được, vì "mãi mãi" chính là mục đích của tai nạn. -- **Versioned keys.** `user:123:v41` → sau mỗi write, nhảy lên `v42`. Một reader đã chộp `:v41` không bao giờ thấy được `:v42` đang ghi dở; key cũ chết theo TTL. Đây là cơ chế giết chết race read-during-write, và là câu trả lời trung thực cho "làm sao giữ cache và DB không lệch nhau giữa chừng write." - -### Phân tầng L1/L2 — nơi thiết kế cache thành kiến trúc - -Con số phân tách bản vẽ khỏi triển khai: - -``` -Caffeine hit trong JVM → ~10–50 ns (gần như miễn phí) -Redis round trip → ~0.5–1 ms (chậm gấp 50.000× L1 — vẫn "nhanh") -DB query, pool nóng → ~1–10 ms -``` - -Một read path nóng trong production hiếm khi chỉ là "Redis". Nó là một **L1 cache cục bộ** (Caffeine) trong từng instance app giữ những key thực sự nóng, với Redis làm L2 phía sau. Tradeoff mới là phần thú vị: - -- L1 là per-instance. Một fleet 100 instance, mỗi cái giữ bản sao riêng, nên hai instance có thể bất đồng tới hết TTL — ổn cho reads, chí mạng nếu bạn đặt một "balance" vào L1. -- **Cold-start stampede.** Mỗi instance repopulate L1 sau một deploy đồng loạt phóng 100 × miss-rate của nó về phía Redis. Nếu L1 lặng lẽ hấp thụ 99% traffic, Redis được định cỡ cho 1% — và cái deploy bây giờ chính là outage. -- Senior theo dõi **L1 hit ratio**, không phải Redis hit ratio. Redis hit ratio có thể trông khỏe trong khi L1 đang làm toàn bộ việc, và ngược lại — sự phân tầng vô hình cho đến khi nó không còn vô hình nữa. - -### Redis làm cache, Redis làm store, và nút xoay eviction - -Nói "Redis nhanh" là tầm mid-level. Khung hình của senior là hợp đồng durability: - -- **Làm cache** — eviction LRU/LFU thuần, chấp nhận mất dữ liệu. `maxmemory` và một chính sách eviction (`allkeys-lru` vs `volatile-lru`) là hoạch định capacity, không phải điều nghĩ sau. Ở chế độ `noeviction`, một Redis đầy **từ chối writes** — với một cache điều đó có nghĩa DB đột nhiên hứng trọn toàn bộ tải mà không một cảnh báo nào. Chính sách eviction là một nút xoay load-shedding, và bạn đặt nó có chủ đích. -- **Làm store** — AOF + fsync mỗi lần ghi là vài nghìn ops/s; fsync mỗi giây mất tới một giây dữ liệu khi crash; RDB snapshot mất những gì bạn ghi từ sau snapshot cuối. Senior nói "Redis là source of truth" và lập tức biện hộ cho cài đặt durability, vì câu "source of truth" và "có lẽ tôi vừa mất giây cuối cùng" không được ở chung một câu. - -Và hot key — điểm lỗi đơn lẻ của chính cache. Một key celeb, một URL virus, một counter dùng chung 100.000 user cùng dập: một Redis key đơn với value khổng lồ serialize trên core đơn-threaded, và node của nó thành trần nhà. Cách sửa: tách key (`hot:user:123:0..31`), hoặc phục vụ nó từ L1 nơi nó thực sự nóng, hoặc — cho ca bệnh lý thật sự — chấp nhận trần nhà và instrument nó. - -> Drill: "Một flash sale bắt đầu lúc nửa đêm, và mọi item giảm giá được cache với TTL hết hạn đúng lúc nửa đêm." Câu trả lời senior nêu tên stampede, single-flight, jittered TTL, L1 fallback — rồi phần thắng cuộc: một đòn **pre-warm** có chủ đích các hot key mười phút trước nửa đêm, để DB không bao giờ thấy đường cong cold-start. - -## 4. Consistency — CAP, PACELC, và vì sao "eventual" cần một quyết định, không phải một hy vọng - -Câu trả lời của người mới là "bạn chọn hai trong ba." Câu trả lời của senior bắt đầu bằng cách sửa lại khung hình: **partition không phải một failure mode hiếm — chúng là điều kiện giả định của mạng.** Mọi distributed system vận hành trên giả định một partition sẽ xảy ra, nên câu hỏi thật là bạn hy sinh gì _trong lúc_ partition, và chuyện gì xảy ra _khi nó lành_. - -Dưới một partition bạn chọn CP hoặc AP: - -- **CP** (Raft, single-leader, quorum): phía thiểu số trả lỗi hoặc chờ, nhưng hai phía không bao giờ phân kỳ. Khi partition lành, chẳng có gì để reconcile. -- **AP** (kiểu Dynamo): cả hai phía đều nhận write, nên khi partition lành bạn cầm **hai giá trị xung đột cho cùng một key**, và ai đó phải quyết xem giá trị nào thắng. "Eventual consistency" không phải dữ liệu tự nhiên sắp xếp ổn thỏa bằng phép màu — nó là việc _bạn_ có một chiến lược merge được viết ra giấy. - -**PACELC** là phần mở rộng khiến senior tỏa sáng: ngay cả khi **không** có partition (phần "ELC"), bạn vẫn chọn giữa **Latency và Consistency** trên mỗi thao tác. Đó là giá trung thực của strong consistency — một write quorum đồng bộ phải trả số round trip để chạm được quorum, và latency là cái giá của sự đảm bảo. Senior tự nguyện nêu PACELC mà không cần được hỏi vì nó biến một cuộc tranh luận triết học thành một latency budget. - -### R + W > N — phép toán bên dưới chữ "eventual" - -``` -N = số replica, R = read quorum, W = write quorum -R + W > N → mỗi read chạm một node chứa write mới nhất -``` - -N=3, W=2, R=2: một write đáp xuống hai node, một read đọc hai node, hai tập giao nhau ít nhất một node — nên một read không bao giờ bỏ lỡ một write đã hoàn tất. Sự giao nhau đó là toàn bộ cơ chế đằng sau "quorum reads/writes", và là nghĩa cụ thể của "eventual" — cái eventual bị chặn bởi bao lâu cho đến khi một read phủ quorum, không phải bởi cảm giác. - -Hai lưu ý senior đặt lên trên phép toán: - -1. **R + W > N cho bạn biết một read thấy _một_ node có write — không phải _cái nào_ mới nhất.** Bạn vẫn cần versioning: vector clock, hoặc một logical clock (Lamport/HLC). Và **last-write-wins với wall-clock timestamp chính là cách bạn mất dữ liệu**: hai client trên hai đồng hồ khác nhau, một lần chỉnh NTP, một lần rollback, và LWW lặng lẽ chọn "mới nhất" sai. -2. **Availability của quorum là một vách đá, không phải một dốc.** Với N=3, W=2, mất một node là chuyện thường, nhưng mất hai node làm writes bất khả thi. "Ba replica" nghe như nhân ba dự phòng và cư xử như: một lỗi thì không sao, hai lỗi là một outage. - -### Thực tế leader-based của phần lớn hệ thống Java - -Đây là punchline trung thực phỏng vấn viên muốn nghe: với một service Java điển hình, bạn không thực sự chọn giữa CP và AP. Bạn chọn một **single leader** — Postgres primary, một Redis master, Raft trong ZooKeeper/etcd — tức là CP với một writer, và chấp nhận trần availability đi kèm. Bạn vươn tới AP kiểu Dynamo chỉ khi yêu cầu availability thực sự không thể đáp bằng một leader: scale toàn cầu, luôn ghi được, hoạt động offline — giỏ hàng, messaging, collaboration. Senior nói "tôi muốn một source of truth" ra tiếng và chỉ chạm tới ngân sách độ phức tạp khi yêu cầu đòi hỏi nó. Câu đắt nhất trong system design là "nhưng nếu leader chết thì sao?" — senior biết câu trả lời là "thì writes chết theo", và quyết xem điều đó có chấp nhận được không trước khi xây kiến trúc, không phải sau. - -### Những failure mode phỏng vấn viên khoan - -- **Read-your-writes.** User post, refresh, và post không thấy đâu. Dưới eventual consistency điều này có thật và business thấy rõ. Cách sửa: read-after-write affinity (route reads của phiên đó về replica vừa nhận write), hoặc một tầng session-stickiness. -- **Split-brain.** Hai node cùng nhận write vì đều tin mình là leader. Phòng thủ là quorum (W=2 khiến hai leader đồng thời bất khả thi với N=3) cộng **fencing tokens / epoch numbers** để một leader bị phế truất không thể tiếp tục ghi sau khi thua bầu cử. "Leader cũ, leader mới, thằng cũ vẫn ghi" chính là trace senior kể lại. -- **Bài toán hai vị tướng.** Hai tiến trình qua một kênh không đáng tin không bao giờ có thể _đảm bảo_ thống nhất một thông điệp. Không có giao thức nào làm distributed commit miễn phí — chỉ có giao thức làm cửa sổ lỗi nhỏ hơn, và bạn trả tiền cho độ nhỏ của cửa sổ. Đó là lý do transactional outbox (ghi row và event trong một DB transaction, để một relay phát lên) tồn tại: nó đổi một distributed transaction lấy một local transaction cộng một relay retryable. - -> Drill: "Service order của bạn chạy hai Postgres primary để 'availability'. Auditor tìm thấy order tồn tại trên một con mà không có trên con kia." Câu trả lời senior: đó là split-brain, và cách sửa là bầu cử leader cộng fencing — hoặc một quorum — và nếu yêu cầu thật sự là availability-first, bạn thiết kế AP với một chính sách giải quyết xung đột có thể biện hộ trước cơ quan quản lý, và bạn nói to câu "hai primary không phải một distributed system, nó là một bug với high availability." - -## 5. Scalability patterns — cơ chế đằng sau những cái ô vuông - -Câu trả lời của người mới là "thêm server." Câu trả lời của senior là ba trục, tiền đề statelessness, phép toán sharding, hợp đồng backpressure, và các tầng load balancer — vì mỗi thứ đó là một nơi mà "thêm server" lặng lẽ ngừng có tác dụng. - -### Statelessness — tiền đề ai cũng đồng ý nhưng ai cũng vi phạm - -Bạn không thể scale ngang một service giữ session state trong local memory. Session thuộc về Redis (hoặc một session store); `HttpSession` trong local memory nghĩa là một node chết đuổi mọi session nó đang giữ, và scale out không phân tán tải mà chỉ xáo bài. Điểm bổ sung của senior: statelessness không chỉ là session — nó là bất kỳ **local cache bạn coi như đồ vứt được** và bất kỳ **background thread nào giả định mình là duy nhất**. Một job `@Scheduled` chạy trên mọi instance là một bug bạn cố tình deploy: - -```java -// WRONG: năm instance, năm đợt purge đồng thời — double work, race, không ai là chủ -@Component -class NightlyPurge { - @Scheduled(cron = "0 0 2 * * *") - void purge() { /* ai cũng chạy cái này */ } -} - -// RIGHT: ShedLock (hoặc một DB row lease) — đúng MỘT leader chạy job -@SchedulerLock(name = "nightlyPurge", lockAtMostFor = "PT1H") -@Scheduled(cron = "0 0 2 * * *") -void purge() { /* một instance giữ lease */ } -``` - -### Sharding — phép toán, hot key, và cái bẫy tăng trưởng - -Sharding chia dữ liệu theo một key để không node nào giữ mọi thứ. Ba chiến lược, kèm failure mode: - -- **Range** — `user_id < 1M` ở shard 1. Tuyệt cho range scan; chí mạng cho range nóng — user mới nhất, timestamp mới nhất, tất cả rơi vào một shard, và shard đó thành trần nhà trong khi số còn lại nhàn rỗi. -- **Hash** — `hash(key) % N`. Phân phối đều, nhưng không có locality range, và **thêm một shard remap gần như mọi key**. Consistent hashing giảm việc xáo trộn xuống còn ~1/N số key khi một node vào hoặc ra — với 10 node, ~10% key di chuyển thay vì ~90%. -- **Directory** — một bảng lookup ánh xạ key → shard. Linh hoạt nhất; nhưng bản thân directory là một store nóng, strongly-consistent, tức là nút thắt đội một chiếc mũ khác. - -Hot key cắn trong sharding đúng y cách nó cắn trong Kafka: một celeb, một best-seller, một khách hàng bận rộn nhất — hash ném chúng vào một shard, và trần nhà của shard đó là trần nhà của cả hệ thống. Cách sửa cùng một gia đình: composite key, shard-trong-shard, hoặc instrument và chấp nhận. Và câu làm rõ phỏng vấn viên câu cá: **replication cho bạn khả năng chịu lỗi, không cho bạn scale.** Một shard replica 3× vẫn có trần ghi của một node — bản sao không song song hóa writes. - -### Async + backpressure — quyết định "xả tải" - -Một queue giữa request path và công việc chậm là thiết kế kinh điển. Phần ứng viên hay bỏ qua là **backpressure** — chuyện gì xảy ra khi queue đầy nhanh hơn tốc độ workers xả. Định luật Little, một lần nữa: - -``` -queue depth = arrival rate × processing time -10k msg/s × 1 s xử lý = 10.000 message in flight ở trạng thái ổn định -``` - -Một queue không biên và không scale chỉ là một cái đệm cho một outage trì hoãn: workers tụt sau, queue lớn lên, storage của queue lớn lên, rồi queue chết và producers chất đống thay vào. Sổ tay senior: - -- **Giới hạn queue** và reject hoặc drop khi đầy — một lỗi nhanh, sạch sẽ thắng một lỗi chậm, dây chuyền. -- **Scale workers theo backlog** (KEDA theo Kafka lag, SQS autoscaling theo queue depth) để queue là tín hiệu tải, không phải một vòng xoáy chết. -- **Degrade có chủ đích.** Phục vụ từ cache, xả bớt các write không thiết yếu, trả về một `503` thân thiện thay vì queue chờ mãi mãi. -- **Circuit breaker** (Resilience4j): sau N lần lỗi, trip breaker và fail nhanh. Con số làm điều này cụ thể: một dependency latency 2 s với client timeout 500 ms không "chậm lại" — mọi caller trở thành một thread bị treo, và ở 10k req/s đó là 20.000 thread chờ một dependency đã chết. Đó là mô hình outage: không phải "dependency hỏng", mà là "lỗi lan truyền và kéo cả fleet xuống cùng." Breaker đổi một bữa tiệc timeout 2 phút thành một lời từ chối 50 ms. - -### Load balancing — các tầng, và chi tiết deploy - -L4 cân bằng TCP connection (nhanh, mờ đục, biết node khỏe); L7 cân bằng HTTP (route theo path, endpoint health check, sticky session). Chi tiết senior mà phỏng vấn viên hay khoan là **connection draining**: khi rolling deploy, LB ngừng đưa traffic mới vào node cũ và chờ các request đang bay xong. Một `kill -9` vào node đang phục vụ traffic chính là cách "deploy" thành "outage". Cùng một gia đình với Kafka rebalance và GC pause: graceful degradation là một tính năng thiết kế, không phải sự lịch sự. - -> Drill: "Service order của tôi có 40 instance mà vẫn thấy chậm. Phỏng vấn viên chỉ vào cái Postgres đơn. Vì sao nó là nút thắt?" Câu trả lời senior nêu tên write path: một writer đơn nghĩa là throughput ghi của một node, và 40 instance đọc không giúp gì cho writes. Rồi follow-up trung thực — "nên hoặc chúng ta shard write path, hoặc, vì write QPS thực ra vừa khít một node, chúng ta chấp nhận trần và tinh chỉnh reads." Nói câu "chúng ta không cần shard" là một câu senior. - -## 6. Ví dụ mini: URL shortener, đào tới độ sâu senior - -Câu hỏi system-design kinh điển, và là nơi hầu hết blog prep tính sai toán. Bản senior: - -### Phép toán encoding, với birthday bound không ai nhắc tới - -Câu chuẩn là "62^7 ≈ 3,5 nghìn tỷ — dư sức chứa." Đúng và là một cái bẫy, vì nó nhầm lẫn **không gian** với **xác suất collision**. Một mã 7-ký tự ngẫu nhiên rút từ không gian đó đâm nhau nhanh tới kinh ngạc: - -``` -62^7 ≈ 3,5 × 10^12 → ở 100M insert, collision kỳ vọng ≈ N²/2M ≈ 1.400 -62^10 ≈ 8,4 × 10^17 → ở 100M insert, collision kỳ vọng ≈ 0,006 -``` - -Một trăm triệu mã 7-ký tự ngẫu nhiên đâm nhau cỡ một nghìn bốn trăm lần. Vòng lặp ngây thơ ("trong khi key tồn tại, thử lại") biến thành một cơn bão retry ở scale. Cách sửa của senior là ngừng _sinh_ key và bắt đầu _mã hóa_ chúng: - -```java -// WRONG: mã 7-ký tự ngẫu nhiên — "không gian 3,5T" bỏ qua birthday bound -String key = randomBase62(7); // ~1.400 collision mỗi 100M insert -while (keyExists(key)) key = randomBase62(7); - -// RIGHT: mã hóa song ánh một id duy nhất — zero collision do cấu tạo -long id = idService.nextId(); // snowflake hoặc DB sequence -String key = encodeBase62(id); // 7 ký tự phủ 3,5T id tuần tự - -// RIGHT cho key không đoán được: hash URL, lấy 10 ký tự base62 (an toàn birthday) -String key = encodeBase62(sha256(url), 10); -``` - -### Những con số định cỡ cả thứ này - -``` -100M URL mới/ngày → ~1.200 writes/s bền vững, peak 3–5× -1B redirect/ngày → ~11,5k reads/s, ~35k peak -tỷ lệ read:write → ~10:1 → hồ sơ cache chuẩn: reads nóng, writes lạnh -``` - -``` -Storage: 100M URL × 500 bytes ≈ 50 GB/năm, RF3 ≈ 150 GB → tầm thường -Logs: 1B redirect × ~100 bytes ≈ 100 GB/ngày → ~3 TB/tháng -``` - -Log mới là bài toán storage, không phải URL. Retention và aggregation quyết chi phí hạ tầng thật, và nói ra điều đó mà không cần hỏi là dấu hiệu senior. - -### Tầng cache, với latency budget - -Một redirect có latency budget ~10 ms. Trong budget đó: một DB hit là 1–10 ms (ăn gần hết budget), một Redis hit là ~0,5 ms, một CDN edge redirect là ~1–5 ms từ PoP gần nhất. Phần lớn redirect đánh vào một tập nhỏ URL — 1% key phục vụ 99% traffic — nên thiết kế là: - -1. **CDN edge cache** cho những URL virus thật sự (một redirect phục vụ từ edge không bao giờ chạm hạ tầng của bạn). -2. **Redis với LRU** cho tập nóng, với `R + W` chỉnh như một cache, không phải một store. -3. **DB** cho mọi thứ còn lại, được bảo vệ bằng single-flight để một cache eviction không bao giờ thành stampede. - -Cache ở đây không phải "nice to have" — nó là thiết kế. Thiếu nó, DB là redirect path, và budget 10 ms chết ngay trong cú spike độ phổ biến đầu tiên. - -### Những quyết định có hậu quả - -- **301 vs 302.** Một `301` được browser và mọi proxy trung gian cache — một redirect được phục vụ và không bao giờ chạm bạn nữa. Rẻ, và bạn trả giá bằng: không đổi được target cho key đó trong suốt vòng đời cache, analytics bị mù, và một redirect sai sống sót qua cả lần sửa của bạn trong cache của mọi client. Một `302` đánh vào service (hoặc CDN) của bạn mỗi lần — tải hơn, nhưng bạn kiểm soát target và đo được mọi cú click. Câu trả lời senior: `302` + CDN, và chỉ `301` cho những key bạn sẽ không bao giờ đổi. -- **Enumeration.** Key base62 tuần tự có thể bị crawl — mọi short URL theo thứ tự, miễn phí. Key ngẫu nhiên thì không. Nếu service public, rate-limit lookup và nghĩ xem key có cần không đoán được. -- **URL virus.** Một URL làm 1M redirect trong hai phút: hot key làm bão hòa Redis (core đơn-threaded, một node), nên câu trả lời là CDN-first cộng thủ thuật split-key, và instrument danh sách top-K để bạn thấy nó tới. - -> Drill: "Design một URL shortener." Câu trả lời senior giao hàng trong khoảng mười phút: tỷ lệ read:write (~10:1), hiệu chỉnh birthday-bound (mã 7-ký tự ngẫu nhiên đâm nhau ~1.400 lần mỗi 100M; mã hóa một ID thay vào đó), toán storage log (~3 TB/tháng log raw), quyết định 301/302, và câu trả lời hot-key CDN-first. Trình tự đó, không cần được hỏi, là toàn bộ phần này. - -## 7. Observability — thiết kế chưa xong cho tới khi bạn nhìn thấy nó hỏng - -"We'll add monitoring later" là một red flag vì nó là câu duy nhất làm mọi quyết định thiết kế khác trở nên không thể gỡ lỗi. Observability không phải một dashboard thêm ở cuối; nó là khoảng cách giữa một senior bước vào incident và một senior bị page vào một bí ẩn. - -- **Metrics.** RED cho service xử lý request (Rate, Errors, Duration); USE cho tài nguyên (Utilization, Saturation, Errors). Nước đi senior là nêu tên metric ánh xạ tới SLO — p99 latency, error rate, queue depth — thay vì "CPU". CPU là metric tài nguyên; người dùng cảm nhận latency. -- **Logs.** Structured JSON, một dòng mỗi event, và mỗi dòng mang `traceId`/`spanId`. Ở 10k req/s, log mọi thành công là 10k dòng/s tiếng ồn — mặc định log ở warn/error và sample happy path. Correlation ID là sợi chỉ xuyên request qua các service, và header W3C `traceparent` là cách nó di chuyển. -- **Tracing.** Distributed trace (Micrometer Tracing / OpenTelemetry) cho thấy span chậm đầu tiên trong chuỗi. Chúng không miễn phí: mỗi span được serialize và export, ~0,1–1% latency request và một khoản thuế CPU thật ở scale — nên head-based sampling (10% hoặc 1% ở QPS cao) là một phần của thiết kế, không phải một sự thỏa hiệp. -- **SLI / SLO / error budget.** Một SLO 99,9% là ~43 phút downtime được phép mỗi tháng. Error budget biến câu "shipping được không?" từ cảm giác thành số học: budget cháy → ngừng ship thay đổi rủi ro. Alert trên SLO — theo symptom (latency, errors), không theo cause (một dòng log cụ thể) — vì symptom là thứ người dùng cảm nhận và cause là thứ on-call của bạn sẽ tự tìm ra dù sao. - -> Drill: "p95 latency tăng gấp đôi lúc 3 giờ sáng. Dẫn tôi qua mười phút đầu của bạn." Câu trả lời senior: kiểm tra SLO burn rate trước (đang đi theo trend hay chỉ một blip?), chộp correlation ID của một request đang lỗi, bám theo trace của nó tới span chậm đầu tiên, rồi đặt câu hỏi rẽ nhánh — là dependency (trip circuit breaker) hay DB (buffer-pool hit ratio, slow-query log, connection-pool saturation)? — và phần thắng cuộc: "Tôi làm được hết vì tôi instrument trước lúc deploy, nên dữ liệu đã ở đó sẵn." - -## 8. Tự kiểm tra - -- [ ] Hỏi những câu làm rõ lộ ra tải (QPS, peak-to-average, tỷ lệ read/write) trước khi vẽ một ô vuông nào. -- [ ] Định cỡ một service nặng read từ các mốc neo: users → requests/ngày → QPS → bandwidth, với một hệ số peak biện hộ được. -- [ ] Giải thích vì sao key base62 7-ký tự ngẫu nhiên đâm nhau ~1.400 lần mỗi 100M insert và vì sao mã hóa một ID sửa được điều đó. -- [ ] Thiết kế cache-aside với single-flight chống stampede, jittered TTL, và invalidate-on-write — và nói mỗi sửa chữa ngăn failure nào. -- [ ] Nêu tradeoff L1/L2 và stampede cold-start sau một deploy. -- [ ] Phát biểu CAP đúng (hy sinh gì _trong lúc_ partition) và thêm PACELC mà không cần hỏi. -- [ ] Chứng minh `R + W > N` và giải thích vì sao LWW với wall-clock timestamp mất dữ liệu. -- [ ] Biện hộ một sharding key — và nói hot key làm gì với trần nhà của hệ thống. -- [ ] Đưa hợp đồng backpressure: giới hạn queue, scale workers theo backlog, circuit-break dependency đã chết. -- [ ] Trả lời "cái gì gãy trước và bạn degrade thế nào?" mà không cần được hỏi. - -## 9. Interviewer follow-ups - -Khi câu trả lời đầu tiên của bạn chạm đúng, họ bắt đầu khoan. Sẵn sàng cho những câu này: - -- "10M user là một dân số, không phải một tải. Bạn thực sự cần những con số nào, và vì sao?" -- "Cache hit ratio của bạn là 90%. Vậy có tốt không? Con số bạn thực sự nên theo dõi là gì?" -- "Một hot key hết hạn và 10.000 request cùng dập vào DB. Dẫn tôi qua cách sửa, rồi nói tôi biết trong ba failure mode bạn vừa xử lý là cái nào." -- "Khi write, nên cache giá trị hay invalidate key? Trong từng trường hợp, TTL bảo vệ bạn khỏi cái gì?" -- "Redis restart lúc 3 giờ sáng. Service của bạn ra sao, và bạn làm gì trước deploy kế tiếp?" -- "Phát biểu CAP cho tôi — và kể tôi nghe chuyện gì xảy ra _khi partition lành_, không chỉ trong lúc nó." -- "Vì sao last-write-wins với timestamp mất dữ liệu? Bạn dùng cái gì thay thế?" -- "Queue depth đang leo, workers 100% CPU, DB ở 40%. Nút thắt ở đâu, và bạn đổi gì trước tiên?" -- "Thêm một shard vào một store hash-sharded. Điều gì vỡ, và consistent hashing thay đổi gì?" -- "301 vs 302 cho redirect — bạn chọn cái nào, và bạn mất gì dù chọn cách nào?" -- "p95 của bạn vừa nhân đôi. SLO là 99,9%. Error budget của bạn là bao nhiêu, và bạn kiểm tra gì đầu tiên?" -- "Vì sao 'hai Postgres primary' không phải high availability — và thiết kế thực sự là gì?" - -Đó là bar system design. +- [ ] Junior: Tôi gọi được building block (LB, cache, queue, DB), giải thích cache-aside, horizontal vs vertical scaling, CDN, và statelessness. +- [ ] Mid: Tôi giải thích được CAP, cache invalidation, capacity sizing từ req/s, message queue, API idempotency, và SQL vs NoSQL trade-off. +- [ ] Senior: Tôi thiết kế được URL shortener với latency budget và stampede protection, chẩn đoán p99 regression từ trace, thiết kế cho region failure, chọn cache vs bigger DB bằng hit rate, định nghĩa observability non-negotiable, và gọi tên component gãy đầu ở 100x. From db98a94dc8ee69944cbef81fe54d9cc3eb92246d Mon Sep 17 00:00:00 2001 From: Tester Date: Thu, 13 Aug 2026 06:39:25 +0000 Subject: [PATCH 8/8] =?UTF-8?q?docs(interview):=20rewrite=20senior-mindset?= =?UTF-8?q?=20as=20Junior=E2=86=92Senior=20Q&A=20series=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../en/interview/senior-mindset-senior.md | 234 ++++------------- .../vi/interview/senior-mindset-senior.md | 242 ++++-------------- 2 files changed, 93 insertions(+), 383 deletions(-) diff --git a/src/data/blog/en/interview/senior-mindset-senior.md b/src/data/blog/en/interview/senior-mindset-senior.md index f256557..60ba5e0 100644 --- a/src/data/blog/en/interview/senior-mindset-senior.md +++ b/src/data/blog/en/interview/senior-mindset-senior.md @@ -1,5 +1,5 @@ --- -title: "Senior Java Interview: Mindset and Behavioral" +title: "Java Interview Prep #8: Senior Mindset & Behavioral — Junior to Senior" description: "Senior interviews test judgment and communication as much as code. How to present trade-offs, admit uncertainty, and tell stories that prove senior-level ownership." pubDatetime: 2026-08-10T10:35:00+07:00 featured: false @@ -11,214 +11,72 @@ tags: - behavioral --- -The senior bar isn't only technical. Interviewers are hiring someone who can own ambiguity, communicate trade-offs, and level up a team. But here's the part every guide undersells: the behavioral loop is where they decide if the person who aced the technical loop is safe to point at production at 2am. A junior interviews to prove they _can_ do the work. A senior interviews to prove they _decide well under pressure and make the people around them better_ — and the code is just the evidence. +The behavioral round is where technical seniors get filtered out for sounding like juniors. The questions are soft ("tell me about a conflict") but the signal is hard: do you think in trade-offs, own outcomes, and communicate like someone others can follow? This post climbs from "what I did" to "the judgment I'd apply again". -Think of it as the difference between a line cook who can follow a recipe and the head chef who can tell you _why_ the sauce broke, taste the correction, and walk the whole pass through the change without a fire. Everything below is that "taste and correct" muscle, in interview form. +> Mindset: junior describes tasks completed; senior explains the decision made under uncertainty, the alternative rejected, and what they'd do differently with the same information. -> Mindset: recite a framework and you're mid-level. Walk through a tradeoff with real numbers, a production failure mode, and an honest "I'd measure before I'd commit," and you've earned the senior checkbox. Every section ends with the drill an interviewer actually runs. +## Junior — foundations -## 1. Narrate trade-offs — the shape of a senior answer +**Q1. "Tell me about yourself." How do you answer without rambling?** +A 90-second arc: who you are as an engineer (stack + what you care about), one concrete thing you've shipped recently, and why this role fits. No life story, no "I was born in…". Senior signal: you frame yourself around problems you like solving, not technologies you've touched. -A senior doesn't answer "which is better?" with a name. They say: _"it depends — here are the trade-offs, and given X I'd pick Y because…"_ That sentence is the entire interview distilled. The interviewer is not grading your pick; they're grading the _shape_: do you know what each option costs, and can you tie the choice to a concrete constraint? +**Q2. "What's your biggest weakness?" — how do you answer it honestly?** +Pick a real, non-fatal one and show the _system_ you built to compensate. "I used to underestimate migration risk; now I always prototype the risky path first." Avoid fake weaknesses ("I work too hard") — interviewers hear that immediately. Honesty plus a mitigation reads as self-aware, which is the senior trait. -The failure mode of a strong-but-mid candidate is answering "which is better?" with a confident pick and a one-line justification. The senior move is to answer in three beats: +**Q3. "Where do you see yourself in 5 years?"** +Answer around growth in scope and judgment, not a title wish-list. "I want to be the person a team trusts with its riskiest architectural calls, and to have mentored a couple of engineers into that range." It shows you're thinking about leverage, not just promotion. -1. **Name the spectrum.** What are the two (or three) real options and what does each trade? -2. **Attach a number or mechanism to the cost.** "Exact-once _processing_ doesn't exist without an idempotency key or a transaction boundary, and that costs X" — not "it's slower." -3. **Anchor to a constraint.** "Given our retry budget and that a double-charge is a support ticket, I'd take at-least-once + idempotent consumer." +**Q4. "Why do you want this job?"** +Tie it to something specific: the problem domain, the scale, the team's way of working. "Your payments scale is exactly the distributed-systems problem I want to go deeper on." Generic "great company" answers signal you didn't research — and research is a senior baseline. -### The delivery-semantics question, done properly +**Q5. "Describe a bug you fixed."** +Use a real one with a clear arc: symptom → how you reproduced it → root cause → fix → what you changed so it can't recur. Junior answers stop at "I fixed it." Senior answers end at "and here's the guard I added" (test, alert, invariant). -"Would you use at-least-once, at-most-once, or exactly-once?" is the most common opening. The naive answer is "exactly-once." The senior answer is that exactly-once _as a property of the broker_ is mostly marketing — the real guarantee is assembled in your consumer, and the assembly has a cost. +**Q6. "What do you do when you're stuck?"** +Show a repeatable method, not panic: reproduce minimally, isolate (bisect/remove variables), read the actual error and source, then ask a specific question rather than a vague "it doesn't work". Senior signal: you unblock yourself with process before escalating. -```java -// WRONG: "the broker dedupes for me" — a redelivery double-charges the customer -@KafkaListener(topics = "order-events") -void handle(OrderEvent e) { - accountService.debit(e.customerId, e.amount); // runs again on retry -} +## Mid — tradeoffs & pitfalls -// RIGHT: at-least-once delivery + idempotent application of the effect -@KafkaListener(topics = "order-events") -@Transactional -void handle(OrderEvent e) { - if (eventStore.exists(e.eventId)) return; // dedupe by event id - accountService.debit(e.customerId, e.amount); - eventStore.insert(e.eventId); // backed by UNIQUE(event_id) -} -``` +**Q1. "Tell me about a disagreement with a colleague." What are they really testing?** +Not the conflict — your _collaboration texture_: did you listen, argue from evidence, and reach a decision you committed to? The trap is either "I was right and they were wrong" (arrogant) or "we just agreed" (no spine). Good answer: state your position with data, acknowledge their valid point, and describe the resolution and what you'd do the same/differently. -Two mechanisms to have ready when they push: +**Q2. "Describe a project that failed." How do you frame it?** +Own the outcome without romanticizing. "We shipped X, it missed adoption, here's the signal we ignored (we never validated the user need before building)." The senior move is to show you extracted a principle ("now I spike the riskiest assumption first") — failure as a cheap tuition you actually learned from, not a story you're embarrassed by. -- **Idempotency key + unique constraint.** The effect is keyed by `event_id`, so a retry that replays the message finds the key already applied and no-ops. `eventStore` is a table with `UNIQUE(event_id)`; the `INSERT` is what makes it safe across concurrent deliveries. -- **Outbox pattern.** Write the event in the _same_ local transaction as the business write, let a relay publish it, and let the consumer dedupe. Now you get at-least-once semantics without a distributed transaction — you trade a broker in the transaction for a relay that polls a table. +**Q3. "How do you prioritize when everything is urgent?"** +Show a framework, not a frantic list: impact × user count × reversibility. "A production data-corruption bug beats a cosmetic UI ticket; a reversible config change beats an irreversible data delete." Then communicate the trade to stakeholders so the priority is shared, not secret. Senior = explicit, communicated prioritization. -The trade-off you name out loud: idempotency keys and outbox tables are infrastructure you must build and keep honest; at-most-once (the "I only check once" answer) dodges the work but silently _drops_ events when the consumer dies mid-processing — which is worse, because data loss is invisible. Given a payments domain, I'd eat the idempotency cost every time. +**Q4. "Tell me about a time you mentored someone."** +Concrete, not "I helped the junior." Describe the person's starting point, the specific thing you taught (a debugging method, a design pattern), and the measurable outcome (they shipped a feature solo, or started reviewing PRs). Mentoring is a core senior axis — show you grew someone's capability, not just did their work. -### Module vs microservice — where "it depends" earns its keep +**Q5. "How do you handle a vague or changing requirement?"** +Senior answer: you make the ambiguity explicit and pick a slice you can ship, then learn. "I'd write down the two interpretations, choose the cheaper-to-reverse one, ship a thin version, and get real feedback fast." Juniors either freeze waiting for perfect specs or build the whole thing on a guess. Speed of learning beats completeness of guess. -"Would you split this into a microservice?" is a trade-off question wearing a binary costume. The senior answer refuses the binary and prices the seam: +**Q6. "What's a technical decision you regret?"** +Pick one with a real lesson and show the thinking that's now different. "I over-engineered a config system for flexibility we never used — now I default to the simplest thing that works and add seams only when a real requirement appears." The regret proves you've calibrated your instincts, which is exactly what senior means. -- In-process method call: **~1 μs**. Same-JVM, no serialization. -- localhost gRPC: **~50–100 μs**. -- Same-region network call: **~0.5–2 ms** — three orders of magnitude slower than the in-JVM call, _before_ serialization, retries, and timeouts. +## Senior — design & defense -That's the raw tax. Then add the standing costs a service boundary never stops charging: a deployment pipeline, a schema and its versioning, a client contract with its breaking-change ceremony, distributed tracing you must wire, an on-call rotation, a runbook, an alert threshold. Run a 3-person team with 12 services and you've spent most of your capacity on plumbing the seams, not shipping the product. +**Q1. "You're the senior — the team wants to ship a risky feature Friday. What do you do?"** +"I'd separate 'risky' into 'reversible' vs 'irreversible'. If it's reversible (behind a flag, easy rollback), ship it and watch the metrics — Friday is fine with a flag. If it's irreversible (data migration, billing change), I'd push to Monday and a lower-traffic window, with a rollback plan written _before_ we start. I'd frame the call around blast radius and recovery time, not the calendar. The senior job is to make the risk explicit and the recovery ready — not to be the person who says no, or yes, on vibes." -So the senior test isn't "can it be a service?" — anything can. The test is **what does independence buy you**, and does that purchase clear the tax: +**Q2. "Tell me about a time you made a call with incomplete information."** +Walk a real one: what you knew, what you didn't, the options, and the bet you made — plus how you de-risked it (small rollout, monitoring, a kill switch). The point is not that you were right, but that you had a _process_ for uncertainty: decide under a time box, make it reversible, and instrument it so reality corrects you fast. That's the difference between a senior and a gambler. -- **Independent deploy cadence.** One team ships twice a week, the other ships twice a day — the coupling of a shared deploy is what the split actually removes. -- **Independent scaling.** One component needs 40 pods under a campaign and the other needs 3. The monolith autoscales the whole thing. -- **Independent blast radius.** A bug in the billing module must not take down catalog reads. +**Q3. "How do you raise the level of engineers around you?"** +Beyond one-on-one mentoring: I'd point to concrete mechanisms — PR review that teaches (asks the question, doesn't just fix), a written design doc culture, and post-incident reviews that blame the system not the person. A senior's force-multiplier is the team's habits. I'd give an example where a review comment changed how someone approached a whole class of problem. -"I'd keep it a module inside the service until two of those three are true" beats "split it, microservices are the future" because it has a mechanism and a trigger condition. If they ask "how would you even test that it's a good seam?" the answer is: _would a single network failure between these two components lose a business invariant? If yes, that's a service boundary that deserves the tax; if no, it's a function call with extra steps._ +**Q4. "Describe a production incident you led the response to."** +Structure: detection (how we knew), containment (what we did in the first 10 minutes — often: stop the bleed, rollback, shed load), root cause, and the durable fix + the guard added (alert, test, runbook). Senior signal: you stayed calm, communicated status to stakeholders, and turned the incident into a permanent improvement. The story proves ownership under pressure. -## 2. Admit uncertainty honestly — calibration is a skill, not a cover +**Q5. "How do you decide between two reasonable technical approaches?"** +"I write the trade-off table: the two options, their failure modes, their cost at 10x, and what we'd lose by picking each. Then I pick the one that's cheaper to reverse and instrument it. If both are reasonable and reversible, the choice matters less than committing and learning. I make the reasoning visible so the team can override me with new info — a decision no one understands is a debt." -"I'd measure before committing to RF=5; 3 is usually enough" beats a confident wrong number. Seniority is calibration, not bravado. But here's the deeper move interviewers are actually fishing for: not _that_ you hedge, but that your uncertainty is **dimensioned** — you can say roughly how much, why, and what would move you. +**Q6. "What does 'senior' mean to you, in one sentence?"** +"Senior means I'm trusted to make the call under uncertainty, own the outcome good or bad, and make the people around me better at making theirs." Then back it with one 30-second story. That sentence — judgment + ownership + leverage — is the whole behavioral interview in a nutshell, and most candidates never say it. -The classic drill: "How fast is X?" They're not checking arithmetic; they're checking whether your mental model has the right _orders of magnitude_, because a person whose mental model is off by a factor of ten will make decisions that are off by a factor of ten. Calibrate against this table until it's reflex: +#### Self-check -``` -in-JVM method call ~1 μs -JSON serialize/deserialize ~1–10 μs -localhost TCP/gRPC ~50–100 μs -same-region network call ~0.5–2 ms -Postgres point query (warm) ~1–5 ms -cross-AZ / external API ~50–500 ms -``` - -Once the orders of magnitude are right, the same calibration applies to the numbers you _state as opinion_: - -``` -GC young-gen pause (G1) ~1–50 ms (-XX:MaxGCPauseMillis=200 is a target, not a promise) -GC old-gen / full pause seconds (the "p99 jumped to 3s every 10 minutes" incident) -Availability 99.9% 43 min/mo downtime (8.7 h/yr) -Availability 99.99% 4.4 min/mo -Availability 99.999% 26 s/mo -``` - -Two ways to _use_ the table in an interview: - -**The GC answer.** "Why did my p99 jump to 3 seconds every 10 minutes?" The confident-wrong answer is "add more heap." The calibrated answer is: "First I'd check whether it's a stop-the-world pause — pull the GC logs, look for old-gen collection and the safepoint stall time — because a 2s STW pause and a 2s slow query have _opposite_ fixes. If it's a full-GC emergency, the fix is usually object churn and old-gen promotion, not a bigger heap; a bigger heap makes the pause _longer_, not shorter." The interviewer who hears that knows you've lived it. - -**The p99 vs p999 answer.** "Your p99 is 50ms but you're getting paged." Calibrated: "p99 is the 999th-of-1000 slow request; at 100 rps that's one 3-second request every 10 seconds, and if that request fans out to 100 dependencies, it multiplies into an availability problem. I'd look at p999, then at whether any single dependency's p95 is the tail I can't control." - -And the honesty move that lands best: **state your confidence and your revision condition.** "RF=3 gives me durability against one broker failure in a 3-node cluster; RF=5 is belt-and-suspenders against two simultaneous failures but roughly doubles replication bandwidth and adds latency on acks. I'd ship RF=3 and set an alert on under-replicated partitions — and if the compliance team says 'two simultaneous rack failures,' I'd move to RF=5 and eat the bandwidth." That answer is _scalable_: it gives a recommendation, a mechanism, a cost, and a trigger for changing your mind. That's what "admit uncertainty honestly" looks like at senior level — it isn't "I don't know," it's "here's the boundary of what I know and what I'd check first." - -## 3. Tell stories that prove ownership — STAR is the skeleton, not the story - -"On prod we saw rebalance storms when…" beats textbook recitation. Use the STAR shape (Situation, Task, Action, Result) without sounding like a script. But the actual senior filter is more precise than the acronym: interviewers are listening for **five specific signals**, in order, and most candidates stop after the first two. - -1. **The initial hypothesis.** Not the final diagnosis — the _first_ guess. If your story never contains a wrong guess, you're editing the footage. -2. **How you tested it.** The one metric or trace that confirmed or killed the hypothesis. "I checked the connection pool's `active` count and it was pegged at max while the DB's CPU was at 8% — so it was the pool, not the database." -3. **The exact lever that fixed it.** The change, and how you knew it worked (the metric moved from X to Y). -4. **The blast radius and rollback.** What could have gone wrong, and how you kept the fix reversible. -5. **The system change, not the apology.** What changed so it can't recur: a runbook, a load-test gate, an alert, a code review checklist. - -Here's a worked story built on that skeleton — borrow the shape, replace the details: - -> **Situation.** Our checkout error rate crossed 5% at ~00:14, SLO was 0.5%. Payment timeouts started in the logs. -> **Task.** I was on call. Restore service, then find out _why_, without guessing in front of a paging group. -> **Action.** First question to the room: _what changed in the last deploy?_ — because production incidents correlate with changes far more often than they're random acts of the universe. There was a new payment-provider call in the last release. Second: I checked the provider's p99 via tracing — 1.8s, up from 120ms. That confirmed the _call_ was the problem, not our code. Third: I ran Little's law — `pool_size = rps × hold_time = 800 rps × 300ms ≈ 240`, and our pool was 100 — the requests were queueing at `connectionTimeout`, which is why every error looked like "the DB is down" when the DB was fine. Fix: rolled back to the previous build, then capped the provider timeout so a slow vendor can't hold a checkout thread hostage. -> **Result.** p99 back under 100ms in ~20 minutes, error rate to 0.1%. Postmortem: the root cause was a _load × change coincidence_ — the provider call had been slow for days but we'd never hit the concurrency ceiling before the traffic spike. Actions: a load test with the new dependency's worst-case latency baked in, an alert on pool queue depth (not just DB CPU), and the timeout cap went into the code review checklist. - -That story passes all five probes. A story that ends at "and I fixed it" passes two. The interviewer follow-up drill is brutal: they'll rewind to the middle and ask "**why did you roll back instead of just raising the timeout?**" — and the answer they want is "because the pool math said we'd run out of connections again within minutes, and rollback is the highest-probability, lowest-blast-radius move at 2am; you optimize the fix _you can prove_, not the one you can argue about." If your story can't survive that rewind, pick a better story or change the details until it can. - -## 4. Push back respectfully — disagree with evidence, and commit - -If a design is premature microservices, say so and explain the cost. Disagreeing with evidence is a senior signal; agreeing to avoid friction is not. But "push back" is the most commonly _misexecuted_ answer in the behavioral loop, so let's be precise about the shape. - -**WRONG:** - -> "That's a bad idea. Microservices are an anti-pattern here." - -That's a verdict with no mechanism. It reads as ego, and it doesn't give the decider a path forward. - -**RIGHT:** - -> "I hear you on the independent deploy cadence — that's real. But the cost here is the tax: a build pipeline, a contract, tracing, an on-call rotation for a 200-line module, and three orders of magnitude more latency on the seam. This team is 3 people and the module is greenfield. What does independence actually buy us right now? I'd keep it a module, make the seam _testable_ so we can split it in a day, and add the 'when we split' trigger — when the deploy cadence or the scaling diverges. If you still want the split, I'm fine with that; let's write the decision record with the trade-off so it's a choice, not a vibe." - -That answer has the four ingredients interviewers grade: - -1. **Acknowledge the grain of truth first.** The demand for independence is usually legitimate; dismiss the _specific cost_, not the person. -2. **Price the option with a mechanism.** Real tax, real number (the latency delta, the team size). -3. **Offer a reversible middle path.** "Make the seam testable now, split later" converts a one-way door into a two-way door. -4. **End with disagree-and-commit.** "If you still want it, fine — here's the decision record." Senior teams don't dissolve over this; they write it down. - -The related trap they probe: _"your senior pushed back on YOUR design — how did you react?"_ The senior answer inverts the story: you asked for the reasoning, found the mechanism they were right about, updated your plan, and said so publicly. "I changed my mind when they showed me the numbers" is a _stronger_ senior signal than "I defended my design." Interviewers hear both answers in every loop; one of them is the person they want in a design review at 5pm on a Friday. - -## 5. Communicate for the team — the artifacts, not the adjectives - -Every behavioral rubric says "communication." Senior interviews test it with _artifacts_. Be ready to produce, on the spot, three concrete things: - -### The one-page design doc - -Not a 14-page PRD. The senior doc a junior can follow has a fixed skeleton: - -1. **Context & problem** — the business constraint in one paragraph. -2. **Options** — 2–3, with the trade-off and the number each option changes (latency, cost, ops load). -3. **Decision** — one sentence, plus the decision _record_ (who, when, what we rejected and why). -4. **Failure modes & rollback** — the things that can go wrong and the plan for each. -5. **Open questions** — the three things you still don't know, and who owns them. - -Practice producing this skeleton from a topic the interviewer names. The tell they're grading for is whether your "decision" section contains a rejected option with a reason — a doc with only one option was never a decision. - -### The incident update, without blame - -When they ask "how do you communicate during an incident?", the senior answer is a _format_, not an attitude: - -``` -00:14 [SEV-1] checkout error rate >5% (SLO 0.5%) — investigating. Impact: checkout disabled. -00:20 Update: traces show payment-provider p99 at 1.8s. Rolling back release R-214. -00:28 Update: rollback complete, error rate 0.1%, p99 < 100ms. Monitoring. -00:40 Resolved. Postmortem in 24h. Root cause: new provider call + traffic spike exceeded pool. -``` - -Rules embedded in that format: a timestamp on every line, a status line that says what's _disabled/impacted_, updates at a regular cadence (so nobody polls you), and **no blame** — "R-214 introduced a timeout regression" not "Dave's change broke prod." The blameless framing isn't politeness; it's how you get honest reporting, and honest reporting is how the postmortem finds the real root cause instead of a scapegoat. - -### The business↔technical translation - -"Translate between business goals and technical constraints" is the rubric phrase. The drill question is usually something like: _"Marketing wants a flash sale next month. What does that mean?"_ The mid-level answer is "more capacity." The senior answer translates the _whole chain_: - -- Business: "flash sale" → a traffic spike of ~N× current, unknown but bounded. -- Technical: capacity math — current p95 latency under load, autoscaling headroom, the DB's read/write ratio under the spike, the cache hit ratio, the order-processing queue depth. -- The constraint you name out loud: **the bottleneck is rarely compute — it's the shared state.** A spike that 100x's traffic doesn't need 100x CPU; it needs the DB writes, the queue depth, and the idempotency to survive the same transactions hitting in a tight burst. That's the sentence that tells them you've designed for load, not just tuned for it. - -## 6. Common behavioral questions — and what each is really probing - -Every question below is a _stunt double_ for a real concern. Name the concern out loud and you've already answered half of it. - -- **"Tell me about a time you made a wrong call."** Probing: can you take the hit without deflecting? The senior answer owns it, names the flawed reasoning (not just the outcome), and gives the _system_ change that prevents it — not "I learned to be more careful." "I shipped a change without a load test against the new dependency's worst-case latency; the postmortem made load testing a merge gate" is worth more than any apology. -- **"How do you handle a sev-1 at 2am?"** Probing: do you have a _sequence_, or will you freeze? The answer is four verbs: **triage** (what's actually broken, what's the impact), **communicate** (the format from section 5 — first line immediately, updates on a cadence), **mitigate** (rollback or the highest-probability reversible fix — the 2am rule is _restore service first, investigate second_), and **postmortem** (a date is set _during_ the incident, not after). -- **"How do you mentor juniors?"** Probing: do you _delegate_, or do you just explain things at people? A concrete senior answer: "I'd give a junior the story end-to-end but let them drive — a real task with a blast radius I can control, a review loop, and feedback tied to the _artifact_ ('this PR has three unused branches; next time let's extract the seam') not to the person." The word they're listening for is **stretch-with-safety**, not "I'm helpful." -- **"Why are you looking?"** Probing: are you a flight risk and are you bitter? The senior answer is honest, forward-looking, and never names a person. "I've outgrown the scope of the work — the projects are smaller than the problems I want to own" beats "my manager doesn't promote me." One version signals a hire who'll grow into the role; the other signals a problem walking through the door. -- **"What would you do differently here?"** Probing: retrospective maturity on _this_ interview, _this_ design. The senior answer names a concrete fork: "I'd have pushed for a decision record on the trade-off we just discussed rather than leaving it as a verbal agreement." Every senior interview ends by asking you to self-assess in real time; treat it as a system, not a vibe. - -## 7. Self-check - -- [ ] Two stories with measurable impact (latency cut, incident fixed, system shipped) — and each one passes the _five probes_: initial hypothesis, the metric that confirmed it, the exact lever, the rollback/blast radius, the system change. -- [ ] A wrong-decision story where you name the _flawed reasoning_, not just the outcome, and the system change that prevents recurrence. -- [ ] One time you disagreed with a senior and what happened — framed with disagree-and-commit, not "I won the argument." -- [ ] A calibrated answer for: GC pause times, 99.9% vs 99.99% availability, the p99 vs p999 difference, and pool sizing from `rps × hold_time`. -- [ ] The at-least-once + idempotency-key answer, including the outbox, ready to go. -- [ ] The one-page design-doc skeleton and the incident-update format — you can produce both from a random topic on the spot. -- [ ] A clear "what would you do differently here?" answer for the design you just walked through. -- [ ] A clear "why are you looking?" that is forward-looking and names no one. - -## 8. Interviewer follow-ups - -When your first answer lands, they start drilling. Be ready for these: - -- "You said at-least-once + idempotency. Walk me through the retry — where does the dedupe key live, and what happens on two concurrent deliveries?" -- "When does the module-to-service split become _forced_? Give me the trigger condition you'd write into the design doc." -- "Your p99 is fine but you're getting paged. Which metric do you look at first, and what's the tail math that makes a 50ms p99 dangerous?" -- "How do you tell whether a 2s latency spike is a GC pause or a slow query — without guessing?" -- "What's the one question you'd ask the room before rolling back during an incident?" -- "Your senior disagrees with your design. You're confident you're right. What do you actually _say_ in that meeting?" -- "Draft the incident update format for a payment outage right now. What's the first line?" -- "Marketing wants the flash sale. What's the _technical_ sentence that translates that request — and what's the bottleneck you'd name?" -- "Give me a decision record for a trade-off we just discussed. What goes in each section?" -- "What would you do differently in this interview, if we re-ran it right now?" - -That's the senior-mindset bar — and often the difference between an offer and a pass. The code loop proves you _know_; this loop proves you _decide_. Come in with the numbers, the artifacts, and the stories that survive a rewind, and you're not answering questions — you're demonstrating the job. +- [ ] Junior: I can give a tight intro, answer weakness/honestly-with-mitigation, tell a bug story with root cause + guard, and show a process for getting unstuck. +- [ ] Mid: I can frame disagreement with evidence, own a failure as tuition, prioritize by impact×reversibility, and show real mentoring and handling of vague requirements. +- [ ] Senior: I can decide ship-vs-hold by blast radius/recovery, show a process for decisions under uncertainty, raise team level via mechanisms, lead an incident response, and define senior as judgment + ownership + leverage. diff --git a/src/data/blog/vi/interview/senior-mindset-senior.md b/src/data/blog/vi/interview/senior-mindset-senior.md index d7e5aed..6bbe9fd 100644 --- a/src/data/blog/vi/interview/senior-mindset-senior.md +++ b/src/data/blog/vi/interview/senior-mindset-senior.md @@ -1,6 +1,6 @@ --- -title: "Phỏng vấn Senior Java: Tư duy và Behavioral" -description: "Phỏng vấn senior test tư duy và giao tiếp ngang với code. Cách trình bày trade-off, thừa nhận không chắc chắn, và kể chuyện chứng tỏ ownership cấp cao." +title: "Ôn thi Java #8: Tư duy & Behavioral cấp Senior — Junior đến Senior" +description: "Phỏng vấn senior test phán đoán và giao tiếp ngang với code. Cách trình bày trade-off, thừa nhận không chắc chắn, và kể những câu chuyện chứng minh ownership mức senior." pubDatetime: 2026-08-10T10:35:00+07:00 featured: false draft: false @@ -11,220 +11,72 @@ tags: - behavioral --- -Bar senior không chỉ kỹ thuật. Phỏng vấn viên tuyển người sở hữu sự mơ hồ, giao tiếp trade-off, và nâng tầm team. Nhưng đây là thứ mọi cẩm nang đều nói thiếu: chính vòng behavioral là nơi họ quyết định người vừa "chọi" xong vòng kỹ thuật có an toàn để chỉ tay vào production lúc 2h sáng hay không. Junior phỏng vấn để chứng minh mình _làm được_ việc. Senior phỏng vấn để chứng minh mình _quyết đúng dưới áp lực và khiến những người xung quanh tốt hơn_ — code chỉ là bằng chứng. +Vòng behavioral là nơi các senior kỹ thuật bị lọc ra vì nghe như junior. Câu hỏi thì mềm ("kể về một xung đột") nhưng tín hiệu thì cứng: bạn tư duy theo trade-off, sở hữu outcome, và giao tiếp như người khác có thể theo không? Bài này leo từ "tôi đã làm gì" đến "phán đoán tôi sẽ áp lại". -Hãy nghĩ tới khác biệt giữa một đầu bếp line cook làm theo công thức và một bếp trưởng có thể nói cho bạn biết _tại sao_ sốt bị tách, nếm rồi sửa, và chỉ huy cả hàng bếp đi qua thay đổi đó mà không bắt lửa. Mọi thứ dưới đây là cơ bắp "nếm và sửa" đó, trong hình hài của một cuộc phỏng vấn. +> Mindset: junior mô tả task hoàn thành; senior giải thích quyết định dưới uncertainty, alternative bị reject, và gì họ làm khác với cùng thông tin. -> Tư duy: đọc thuộc một framework thì bạn chỉ ở tầm mid-level. Đi qua một trade-off bằng số thật, một failure mode trong production, và một câu "tôi sẽ đo trước khi chốt" trung thực — thì bạn chạm nốt "senior". Mỗi phần dưới đây đều kết bằng bài drill mà phỏng vấn viên thực sự chạy. +## Junior — nền tảng -## 1. Narrate trade-off — hình dạng của một câu trả lời senior +**Q1. "Tell me about yourself." Trả lời không lan man thế nào?** +Một arc 90 giây: bạn là engineer thế nào (stack + điều bạn care), một thứ cụ thể bạn vừa ship, và tại sao role này fit. Không life story, không "tôi sinh ra ở…". Senior signal: bạn frame chính mình quanh problem bạn thích solve, không phải technology bạn chạm. -Senior không trả lời "cái nào hơn?" bằng một cái tên. Họ nói: _"tùy thuộc — đây là trade-off, và với X tôi chọn Y vì…"_ Câu đó là cả buổi phỏng vấn được cô đặc lại. Phỏng vấn viên không chấm lựa chọn của bạn; họ chấm _hình dạng_: bạn có biết mỗi phương án phải trả giá gì, và có gắn lựa chọn vào một ràng buộc cụ thể không? +**Q2. "Biggest weakness?" — trả lời trung thực thế nào?** +Chọn một cái thật, không chết người, và show _system_ bạn build để compensate. "Tôi từng underestimate migration risk; giờ tôi luôn prototype risky path trước." Tránh weak giả ("tôi làm việc quá sức") — interviewer nghe ra ngay. Honesty cộng mitigation đọc là self-aware, đó là senior trait. -Failure mode của ứng viên mạnh-nhưng-mid là trả lời "cái nào hơn?" bằng một lựa chọn tự tin kèm một dòng giải thích. Nước đi senior là trả lời trong ba nhịp: +**Q3. "Where do you see yourself in 5 years?"** +Answer quanh growth in scope và judgment, không phải title wish-list. "Tôi muốn là người một team tin tưởng với riskiest architectural call, và đã mentor vài engineer vào tầm đó." Nó show bạn nghĩ về leverage, không chỉ promotion. -1. **Gọi tên phổ lựa chọn.** Hai (hoặc ba) phương án thực sự là gì và mỗi cái đánh đổi gì? -2. **Gắn một con số hoặc cơ chế vào cái giá.** "Exactly-once _processing_ không tồn tại nếu không có idempotency key hay transaction boundary, và cái đó tốn X" — không phải "nó chậm hơn". -3. **Neo vào một ràng buộc.** "Với ngân sách retry của chúng tôi và việc trừ tiền hai lần là một ticket hỗ trợ, tôi chọn at-least-once + idempotent consumer." +**Q4. "Why do you want this job?"** +Gắn với thứ cụ thể: problem domain, scale, team's way of working. "Payments scale của bạn đúng distributed-systems problem tôi muốn đi sâu." Generic "great company" answer signal bạn không research — và research là senior baseline. -### Câu hỏi delivery semantics, trả lời đúng cách +**Q5. "Describe a bug you fixed."** +Dùng một cái thật với arc rõ: symptom → reproduce → root cause → fix → gì bạn đổi để không tái diễn. Junior answer dừng ở "tôi sửa rồi." Senior answer kết ở "và đây là guard tôi thêm" (test, alert, invariant). -"Anh dùng at-least-once, at-most-once hay exactly-once?" là câu mở màn phổ biến nhất. Câu trả lời naive là "exactly-once". Câu trả lời senior là: exactly-once _như một thuộc tính của broker_ phần lớn là marketing — đảm bảo thật được lắp ráp ở phía consumer, và việc lắp ráp đó có giá. +**Q6. "What do you do when you're stuck?"** +Show một method lặp lại, không panic: reproduce tối thiểu, isolate (bisect/remove variable), đọc actual error và source, rồi hỏi câu hỏi cụ thể thay vì vague "nó không chạy". Senior signal: bạn unblock chính mình bằng process trước khi escalate. -```java -// WRONG: "broker tự dedupe cho tôi" — một lần gửi lại là trừ tiền khách hai lần -@KafkaListener(topics = "order-events") -void handle(OrderEvent e) { - accountService.debit(e.customerId, e.amount); // retry sẽ chạy lại đoạn này -} +## Mid — tradeoff & điểm mù -// RIGHT: at-least-once delivery + áp dụng effect một cách idempotent -@KafkaListener(topics = "order-events") -@Transactional -void handle(OrderEvent e) { - if (eventStore.exists(e.eventId)) return; // dedupe theo event id - accountService.debit(e.customerId, e.amount); - eventStore.insert(e.eventId); // dựa trên UNIQUE(event_id) -} -``` +**Q1. "Tell me about a disagreement with a colleague." Họ thực sự test gì?** +Không phải conflict — mà _collaboration texture_ của bạn: bạn có lắng, argue từ evidence, và đến một quyết định bạn commit không? Bẫy là "tôi đúng, họ sai" (arrogant) hoặc "chúng tôi đồng ý thôi" (không spine). Good answer: state position với data, acknowledge valid point của họ, và mô tả resolution và gì bạn làm same/differently. -Hai cơ chế phải có sẵn khi họ dồn: +**Q2. "Describe a project that failed." Frame nó thế nào?** +Own outcome không romanticize. "Chúng tôi ship X, nó miss adoption, đây là signal chúng tôi ignore (không validate user need trước khi build)." Senior move là show bạn extract một principle ("giờ tôi spike riskiest assumption trước") — failure như cheap tuition bạn thực học, không phải story bạn xấu hổ. -- **Idempotency key + unique constraint.** Effect được khóa bởi `event_id`, nên một retry phát lại message sẽ thấy key đã được áp dụng và no-op. `eventStore` là một bảng có `UNIQUE(event_id)`; cái `INSERT` chính là thứ khiến nó an toàn trước hai delivery song song — nếu cả hai tới cùng lúc, chỉ một cái `INSERT` thành công, cái còn lại đập vào unique index và bị nuốt. -- **Outbox pattern.** Ghi event trong _cùng_ một local transaction với business write, để một relay publish nó, và để consumer dedupe. Giờ bạn có at-least-once mà không cần distributed transaction — bạn đổi một broker nằm trong transaction lấy một relay poll một bảng. +**Q3. "How do you prioritize when everything is urgent?"** +Show một framework, không phải list frantic: impact × user count × reversibility. "Production data-corruption bug beats cosmetic UI ticket; reversible config change beats irreversible data delete." Rồi communicate trade cho stakeholder nên priority được share, không secret. Senior = explicit, communicated prioritization. -Trade-off bạn nói to: idempotency key và outbox table là hạ tầng bạn phải xây và giữ trung thực; at-most-once (câu trả lời "tôi chỉ check một lần") né được việc nhưng âm thầm _rơi_ event khi consumer chết giữa chừng — tệ hơn, vì mất dữ liệu là thứ vô hình. Với domain thanh toán, tôi chấp nhận cái giá idempotency mọi lần. +**Q4. "Tell me about a time you mentored someone."** +Concrete, không "tôi giúp junior." Mô tả starting point của người đó, thứ cụ thể bạn dạy (debugging method, design pattern), và measurable outcome (họ ship feature solo, hoặc bắt đầu review PR). Mentoring là senior axis cốt lõi — show bạn grow capability của ai đó, không chỉ làm việc của họ. -### Module vs microservice — nơi "tùy thuộc" thật sự kiếm được điểm +**Q5. "How do you handle a vague or changing requirement?"** +Senior answer: bạn make ambiguity explicit và pick một slice ship được, rồi learn. "Tôi viết ra hai interpretation, chọn cái cheaper-to-reverse, ship một thin version, và get real feedback fast." Junior hoặc freeze chờ perfect spec hoặc build whole thing trên guess. Speed of learning beats completeness của guess. -"Anh có tách cái này thành microservice không?" là câu hỏi trade-off đội lốt câu hỏi nhị phân. Câu trả lời senior từ chối nhị phân và định giá đường nối: +**Q6. "What's a technical decision you regret?"** +Chọn một cái có lesson thật và show thinking giờ khác. "Tôi over-engineer một config system cho flexibility không bao giờ dùng — giờ tôi default vào simplest thing works và add seam chỉ khi requirement thật xuất hiện." Regret chứng minh bạn đã calibrate instinct, đúng nghĩa senior. -- Gọi method trong process: **~1 μs**. Cùng JVM, không serialization. -- localhost gRPC: **~50–100 μs**. -- Gọi mạng cùng region: **~0.5–2 ms** — chậm hơn ba bậc độ lớn so với call trong JVM, _trước cả_ serialization, retry và timeout. +## Senior — thiết kế & phòng thủ -Đó là khoản thuế thô. Rồi cộng các chi phí thường trực mà một service boundary không ngừng thu: một deployment pipeline, một schema và cách version nó, một client contract với nghi lễ breaking-change, distributed tracing bạn phải nối, một on-call rotation, một runbook, một alert threshold. Vận hành team 3 người với 12 service là bạn đã đốt phần lớn năng lực vào việc hàn các đường nối, không phải ship sản phẩm. +**Q1. "Bạn là senior — team muốn ship một risky feature Thứ Sáu. Bạn làm gì?"** +"Tôi tách 'risky' thành 'reversible' vs 'irreversible'. Nếu reversible (sau flag, dễ rollback), ship và watch metric — Thứ Sáu ổn với flag. Nếu irreversible (data migration, billing change), tôi push sang Thứ Hai và low-traffic window, với rollback plan viết _trước_ khi start. Tôi frame call quanh blast radius và recovery time, không phải calendar. Senior job là make risk explicit và recovery ready — không phải người nói không, hoặc có, trên vibes." -Nên bài test senior không phải "nó có thể là service không?" — thứ gì cũng có thể. Bài test là **sự độc lập mua được gì cho bạn**, và việc mua đó có vượt qua khoản thuế không: +**Q2. "Tell me about a time you made a call with incomplete information."** +Đi một cái thật: bạn biết gì, không biết gì, options, và bet bạn đặt — cộng cách bạn de-risk (small rollout, monitoring, kill switch). Điểm không phải bạn đúng, mà bạn có _process_ cho uncertainty: decide dưới time box, make reversible, và instrument để reality correct bạn nhanh. Đó là khác biệt senior và gambler. -- **Nhịp deploy độc lập.** Một team ship hai lần/tuần, team kia hai lần/ngày — cái coupling của một lần deploy chung chính là thứ việc tách thật sự gỡ bỏ. -- **Scale độc lập.** Một component cần 40 pod lúc campaign, component kia chỉ cần 3. Monolith autoscale cả cục. -- **Blast radius độc lập.** Một bug ở module billing không được kéo sập catalog reads. +**Q3. "How do you raise the level of engineers around you?"** +Ngoài one-on-one mentoring: tôi chỉ cơ chế cụ thể — PR review dạy (hỏi câu hỏi, không chỉ fix), văn hóa written design doc, và post-incident review blame system không phải person. Force-multiplier của senior là habit của team. Tôi cho một example nơi review comment đổi cách một người approach cả một class problem. -"Tôi giữ nó là module trong service cho tới khi hai trong ba điều đó thành sự thật" đánh bại "tách đi, microservices là tương lai" vì nó có cơ chế và điều kiện kích hoạt. Nếu họ hỏi "làm sao test rằng đó có phải seam tốt?" câu trả lời là: _một lỗi mạng duy nhất giữa hai component này có làm mất một business invariant không? Nếu có, đó là một service boundary đáng trả thuế; nếu không, đó là một function call dài dòng hơn._ +**Q4. "Describe a production incident you led the response to."** +Cấu trúc: detection (ta biết thế nào), containment (10 phút đầu làm gì — thường: stop the bleed, rollback, shed load), root cause, và durable fix + guard thêm (alert, test, runbook). Senior signal: bạn bình tĩnh, communicate status cho stakeholder, và biến incident thành permanent improvement. Story chứng minh ownership dưới pressure. -## 2. Thừa nhận không chắc chắn trung thực — calibration là kỹ năng, không phải cái bọc +**Q5. "How do you decide between two reasonable technical approaches?"** +"Tôi viết trade-off table: hai option, failure mode, cost tại 10x, và gì ta mất nếu chọn mỗi cái. Rồi tôi pick cái cheaper to reverse và instrument nó. Nếu cả hai reasonable và reversible, choice quan trọng ít hơn commit và learn. Tôi make reasoning visible để team override tôi với info mới — decision không ai hiểu là debt." -"Tôi sẽ đo trước khi chốt RF=5; 3 thường là đủ" đánh bại một con số sai tự tin. Seniority là calibration, không phải bravado. Nhưng nước đi sâu hơn mà phỏng vấn viên thật sự câu: không phải việc bạn hedge, mà là sự không chắc chắn của bạn được **định chiều** — bạn nói được đại khái bao nhiêu, vì sao, và điều gì sẽ làm bạn đổi ý. +**Q6. "What does 'senior' mean to you, in one sentence?"** +"Senior nghĩa tôi được tin make call dưới uncertainty, own outcome tốt hay xấu, và làm người quanh tôi tốt hơn ở make call của họ." Rồi back nó bằng một story 30 giây. Câu đó — judgment + ownership + leverage — là whole behavioral interview trong một nutshell, và hầu hết candidate không bao giờ nói. -Bài drill kinh điển: "Cái X nhanh cỡ nào?" Họ không check số học; họ check xem mô hình tinh thần của bạn có đúng _bậc độ lớn_ không, vì một người mà mô hình lệch hệ số mười sẽ đưa ra quyết định lệch hệ số mười. Calibrate tới khi phản xạ: +#### Self-check -``` -call trong JVM ~1 μs -JSON serialize/deserialize ~1–10 μs -localhost TCP/gRPC ~50–100 μs -call mạng cùng region ~0.5–2 ms -Postgres point query (warm) ~1–5 ms -cross-AZ / external API ~50–500 ms -``` - -Một khi bậc độ lớn đã đúng, cùng calibration ấy áp cho các con số bạn _phát biểu như ý kiến_: - -``` -GC young-gen pause (G1) ~1–50 ms (-XX:MaxGCPauseMillis=200 là mục tiêu, không phải lời hứa) -GC old-gen / full pause giây (chính là incident "p99 nhảy lên 3s mỗi 10 phút") -Availability 99.9% 43 phút downtime/tháng (8.7 h/năm) -Availability 99.99% 4.4 phút/tháng -Availability 99.999% 26 s/tháng -``` - -Phép toán đằng sau bảng availability đáng nhớ luôn: **downtime tháng ≈ 43800 phút × (1 − availability)**. 99.9% → ~44 phút; 99.99% → ~4.4 phút; 99.999% → ~26 giây. Nếu bạn nói "chúng tôi cần 99.99%" mà không biết 4.4 phút nghĩa là gì trong budget của mình, bạn vừa phát biểu một con số bạn không calibrate. - -Hai cách _dùng_ bảng trong phỏng vấn: - -**Câu trả lời về GC.** "Vì sao p99 của tôi nhảy lên 3 giây mỗi 10 phút?" Câu trả lời tự-tin-sai là "thêm heap". Câu trả lời calibrated: "Trước hết tôi phải xem nó có phải pause stop-the-world không — kéo GC logs, tìm old-gen collection và safepoint stall time — vì một pause STW 2 giây và một slow query 2 giây có hướng sửa _ngược nhau_. Nếu là full-GC emergency, hướng sửa thường là object churn và sự promote lên old-gen, không phải heap to hơn; heap to hơn làm pause _dài hơn_, không ngắn hơn — bạn đang trì hoãn một cơn đau để nó quay lại nặng hơn." Phỏng vấn viên nghe được câu đó sẽ biết bạn từng sống với nó. - -**Câu trả lời p99 vs p999.** "p99 của anh là 50ms mà anh vẫn bị paging." Calibrated: "p99 là request chậm thứ 999 trong 1000; ở 100 rps nghĩa là cứ 10 giây có một request 3 giây, và nếu request đó fan-out 100 dependencies, nó nhân lên thành một vấn đề availability. Tôi sẽ nhìn p999, rồi xem có dependency nào mà p95 của nó là cái đuôi tôi không kiểm soát được không." Điểm tinh tế: p99 chỉ cho bạn _vị trí_ của percentile, không cho bạn _bậc_ của đuôi — hai hệ thống cùng p99 50ms, một cái có đuôi 200ms, một cái có đuôi 3 giây, là hai profile vận hành hoàn toàn khác. - -Và nước đi trung thực ghi điểm nhất: **nêu rõ độ tin cậy và điều kiện đổi ý.** "RF=3 cho tôi độ bền trước một broker lỗi trong cluster 3 node; RF=5 là đai-chống-đai trước hai lỗi đồng thời nhưng gần như gấp đôi băng thông replication và thêm latency trên ack. Tôi ship RF=3 và đặt alert trên under-replicated partition — còn nếu compliance team nói 'hai rack lỗi đồng thời', tôi chuyển RF=5 và trả băng thông." Câu trả lời đó _scalable_: nó đưa một khuyến nghị, một cơ chế, một cái giá, và một trigger để đổi ý. Đó là diện mạo thật của "thừa nhận không chắc chắn trung thực" ở cấp senior — không phải "tôi không biết", mà là "đây là ranh giới của điều tôi biết và điều tôi sẽ check trước tiên." - -## 3. Kể chuyện chứng tỏ ownership — STAR là khung xương, không phải câu chuyện - -"Trên prod chúng tôi từng thấy rebalance storm khi…" đánh bại đọc thuộc lòng. Dùng hình dáng STAR (Situation, Task, Action, Result) mà đừng như kịch bản. Nhưng filter senior thật còn chính xác hơn cái acronym: phỏng vấn viên lắng nghe **năm tín hiệu cụ thể**, theo thứ tự, và phần lớn ứng viên dừng sau hai tín hiệu đầu. - -1. **Giả thuyết ban đầu.** Không phải chẩn đoán cuối — mà là dự đoán _đầu tiên_. Nếu câu chuyện của bạn không bao giờ có một dự đoán sai, bạn đang cắt phim. -2. **Cách bạn test nó.** Một metric hay trace duy nhất xác nhận hoặc giết giả thuyết. "Tôi check count `active` của connection pool và nó kẹt ở max trong khi CPU của DB chỉ 8% — nên là pool, không phải database." -3. **Cái đòn bẩy chính xác đã sửa nó.** Thay đổi, và làm sao bạn biết nó hiệu quả (metric di chuyển từ X sang Y). -4. **Blast radius và rollback.** Điều gì có thể hỏng, và bạn giữ bản sửa reversible như thế nào. -5. **Thay đổi hệ thống, không phải lời xin lỗi.** Điều gì thay đổi để nó không tái diễn: một runbook, một merge gate load-test, một alert, một checklist code review. - -Đây là một câu chuyện làm sẵn xây trên khung đó — mượn hình dáng, thay chi tiết: - -> **Situation.** Tỷ lệ lỗi checkout của chúng tôi vượt 5% lúc ~00:14, SLO là 0.5%. Payment timeouts bắt đầu xuất hiện trong log. -> **Task.** Tôi trực on-call. Khôi phục dịch vụ, rồi tìm _vì sao_, không đoán mò trước một nhóm đang paging. -> **Action.** Câu hỏi đầu tiên cho cả phòng: _deploy gần nhất đã thay đổi gì?_ — vì incident production tương quan với change nhiều hơn hẳn việc chúng là tai nạn ngẫu nhiên của vũ trụ. Có một call tới payment-provider mới trong release vừa rồi. Thứ hai: tôi check p99 của provider qua tracing — 1.8s, trước đó 120ms. Điều đó xác nhận vấn đề nằm ở _call_, không phải code của chúng tôi. Thứ ba: tôi chạy Little's law — `pool_size = rps × hold_time = 800 rps × 300ms ≈ 240`, mà pool của chúng tôi là 100 — requests đang xếp hàng tại `connectionTimeout`, đó là lý do mọi lỗi trông như "DB chết" trong khi DB vẫn ổn. Fix: rollback về build trước, rồi cap timeout của provider để một vendor chậm không giữ checkout thread làm con tin. -> **Result.** p99 về dưới 100ms trong ~20 phút, tỷ lệ lỗi về 0.1%. Postmortem: root cause là một _trùng hợp load × change_ — call tới provider đã chậm nhiều ngày nhưng chúng tôi chưa bao giờ chạm trần concurrency trước spike lưu lượng. Hành động: một load test với worst-case latency của dependency mới được nhồi vào, một alert trên pool queue depth (không chỉ DB CPU), và cái timeout cap được đưa vào checklist code review. - -Little's law là phép toán mà phỏng vấn viên thích câu: **số kết nối cần thiết = throughput (rps) × thời gian giữ kết nối trung bình (hold time)**. Ở trên: 800 rps × 0.3s ≈ 240 — pool 100 chỉ đủ cho ~333 rps ở hold time đó, nên bất kỳ chậm trễ nào của provider cũng biến thành queue. Pool to hơn che giấu vấn đề; pool đúng cỡ _chứng minh_ bạn hiểu cơ chế. Kèm nghịch lý: pool quá to (2000 connection) còn tệ hơn pool thiếu, vì mỗi connection tốn memory + slot Postgres và việc context-switch nhiều kết nối đồng thời làm hỏng cache locality — "thêm connection" là câu trả lời của người chưa từng nhìn `pg_stat_activity` lúc cao điểm. - -Câu chuyện đó qua cả năm probe. Một câu chuyện kết ở "và tôi đã sửa xong" chỉ qua hai. Bài drill dồn của phỏng vấn viên tàn bạo: họ sẽ tua ngược về giữa và hỏi "**vì sao anh rollback thay vì chỉ tăng timeout?**" — câu trả lời họ muốn là "vì phép toán pool nói chúng tôi sẽ hết connection lần nữa trong vòng vài phút, và rollback là nước đi xác suất-cao-nhất, blast-radius-thấp-nhất lúc 2h sáng; bạn tối ưu bản sửa _mà bạn chứng minh được_, không phải bản sửa _mà bạn tranh luận được_." Nếu câu chuyện của bạn không sống sót qua cú tua đó, hãy chọn câu chuyện khác hoặc sửa chi tiết cho tới khi nó sống sót. - -## 4. Phản biện nhẹ nhàng — bất đồng bằng bằng chứng, và cam kết - -Nếu một design là microservices sớm quá, hãy nói ra và giải thích cái giá. Bất đồng có bằng chứng là tín hiệu senior; đồng ý để né xung đột thì không. Nhưng "push back" là câu trả lời hay bị _diễn sai_ nhất trong vòng behavioral, nên hãy chính xác về hình dạng. - -**WRONG:** - -> "Ý kiến đó tồi. Microservices là anti-pattern ở đây." - -Đó là một phán quyết không cơ chế. Nó đọc như cái tôi, và nó không cho người quyết định một con đường phía trước. - -**RIGHT:** - -> "Tôi hiểu anh về nhịp deploy độc lập — điều đó thật. Nhưng cái giá ở đây là khoản thuế: một build pipeline, một contract, tracing, một on-call rotation cho một module 200 dòng, và chậm hơn ba bậc độ lớn trên đường nối. Team này 3 người và module đang greenfield. Sự độc lập mua cho chúng ta cái gì lúc này? Tôi giữ nó là module, làm seam _testable_ để ngày mai tách được, và ghi trigger 'khi nào tách' — khi nhịp deploy hoặc scale phân kỳ. Nếu anh vẫn muốn tách, tôi ủng hộ; hãy ghi decision record kèm trade-off để nó là một lựa chọn, không phải một cảm giác." - -Câu trả lời đó có bốn thành phần phỏng vấn viên chấm: - -1. **Thừa nhận hạt sự thật trước.** Nhu cầu độc lập thường chính đáng; hãy bác bỏ _cái giá cụ thể_, không phải con người. -2. **Định giá phương án bằng một cơ chế.** Thuế thật, con số thật (delta latency, quy mô team). -3. **Đề xuất một con đường giữa reversible.** "Làm seam testable ngay, tách sau" biến cánh cửa một chiều thành hai chiều. -4. **Kết bằng disagree-and-commit.** "Nếu anh vẫn muốn, được — đây là decision record." Team senior không tan rã vì chuyện này; họ viết nó ra. - -Cái bẫy liên quan họ dò: _"senior của anh phản biện design CỦA ANH — anh phản ứng sao?"_ Câu trả lời senior đảo ngược câu chuyện: bạn xin lý do, tìm ra cơ chế mà họ đúng, cập nhật kế hoạch, và nói điều đó công khai. "Tôi đổi ý khi họ cho tôi thấy con số" là một tín hiệu senior _mạnh hơn_ "tôi bảo vệ design của mình." Phỏng vấn viên nghe cả hai câu trong mọi vòng; một trong hai là người họ muốn trong một buổi design review lúc 5h chiều thứ Sáu. - -## 5. Giao tiếp cho team — artifact, không phải tính từ - -Mọi rubric behavioral đều ghi "giao tiếp". Phỏng vấn senior test nó bằng _artifact_. Hãy sẵn sàng tạo ra, ngay tại chỗ, ba thứ cụ thể: - -### Design doc một trang - -Không phải PRD 14 trang. Design doc senior mà junior theo được có khung cố định: - -1. **Context & problem** — ràng buộc business trong một đoạn. -2. **Options** — 2–3 phương án, kèm trade-off và con số mỗi phương án thay đổi (latency, chi phí, gánh nặng ops). -3. **Decision** — một câu, cộng decision _record_ (ai, khi nào, chúng ta bác bỏ gì và vì sao). -4. **Failure modes & rollback** — những gì có thể hỏng và kế hoạch cho từng cái. -5. **Open questions** — ba điều bạn vẫn chưa biết, và ai sở hữu chúng. - -Hãy tập tạo ra khung này từ một chủ đề phỏng vấn viên nêu. Dấu hiệu họ chấm: phần "decision" của bạn có chứa một phương án bị bác bỏ kèm lý do — một doc chỉ có một phương án thì chưa bao giờ là một quyết định. - -### Incident update, không đổ lỗi - -Khi họ hỏi "anh giao tiếp thế nào trong một incident?", câu trả lời senior là một _khuôn mẫu_, không phải thái độ: - -``` -00:14 [SEV-1] checkout error rate >5% (SLO 0.5%) — đang điều tra. Impact: checkout đã tắt. -00:20 Update: trace cho thấy payment-provider p99 ở 1.8s. Đang rollback release R-214. -00:28 Update: rollback xong, error rate 0.1%, p99 < 100ms. Theo dõi. -00:40 Resolved. Postmortem trong 24h. Root cause: call provider mới + spike lưu lượng vượt pool. -``` - -Luật nằm trong khuôn đó: timestamp trên mỗi dòng, một dòng status nói rõ thứ gì đang _bị tắt/bị impact_, cập nhật theo nhịp đều (để không ai phải poll bạn), và **không đổ lỗi** — "R-214 giới thiệu một timeout regression" chứ không phải "thay đổi của Dave làm sập prod." Khung không-đổ-lỗi không phải phép lịch sự; nó là cách bạn có được báo cáo trung thực, và báo cáo trung thực là cách postmortem tìm root cause thật thay vì một con dê tế thần. - -### Bản dịch business ↔ technical - -"Translate between business goals and technical constraints" là cụm từ trong rubric. Câu drill thường là: _"Marketing muốn một flash sale tháng tới. Điều đó nghĩa là gì?"_ Câu trả lời mid-level là "thêm capacity". Câu trả lời senior dịch _cả chuỗi_: - -- Business: "flash sale" → một spike lưu lượng cỡ ~N× hiện tại, chưa biết nhưng có giới hạn. -- Technical: phép toán capacity — p95 latency hiện tại dưới load, headroom autoscaling, tỷ lệ read/write của DB dưới spike, cache hit ratio, độ sâu queue xử lý đơn hàng. -- Ràng buộc bạn nói to: **nút thắt hiếm khi là compute — nó là shared state.** Một spike nhân 100 lưu lượng không cần 100× CPU; nó cần các write của DB, độ sâu queue, và idempotency sống sót qua các transaction giống hệt nhau đập vào trong một cụm ngắn. Câu đó là thứ cho họ biết bạn thiết kế cho load, không chỉ tinh chỉnh cho nó. - -Kèm một con số sát topic: một point query trên bảng tỷ row là **3–4 page fetch B-tree** (root + 1–2 internal + leaf) — không phải phép màu, mà là logarit của fanout ~500 key/page. Nhưng một index tỷ row mà nguội là 3–4 lần chạm SSD ~0.1–0.5ms mỗi lần; giữ working set nóng trong buffer pool mới là thứ biến lookup đó thành ~100ns. "Thêm index" là câu của người mới; "giữ index nóng và tính phép toán queue" là câu của senior. - -## 6. Câu hỏi behavioral hay gặp — và mỗi câu thật sự đang dò gì - -Mỗi câu dưới đây là một _diễn viên đóng thế_ cho một mối quan tâm thật. Gọi tên mối quan tâm ra, bạn đã trả lời xong một nửa. - -- **"Kể về lúc anh quyết định sai."** Đang dò: anh có đỡ được cú đấm mà không né không? Câu trả lời senior nhận lỗi, gọi tên suy luận sai (không chỉ kết cục), và đưa thay đổi _hệ thống_ ngăn nó tái diễn — không phải "tôi học cách cẩn thận hơn". "Tôi ship một thay đổi không load test với worst-case latency của dependency mới; postmortem biến load testing thành merge gate" giá trị hơn mọi lời xin lỗi. -- **"Anh xử lý sev-1 lúc 2h sáng thế nào?"** Đang dò: anh có một _chuỗi_, hay anh sẽ đứng hình? Câu trả lời là bốn động từ: **triage** (cái gì thật sự hỏng, impact là gì), **communicate** (khuôn ở mục 5 — dòng đầu ngay lập tức, cập nhật theo nhịp), **mitigate** (rollback hoặc bản sửa reversible xác-suất-cao — luật 2h sáng là _khôi phục dịch vụ trước, điều tra sau_), và **postmortem** (đặt một ngày _trong lúc_ incident, không phải sau). -- **"Anh mentor junior thế nào?"** Đang dò: anh có _giao việc_, hay anh chỉ giải thích vào mặt người ta? Một câu trả lời senior cụ thể: "Tôi giao cho junior cả câu chuyện từ đầu tới cuối nhưng để họ cầm lái — một task thật với blast radius tôi kiểm soát được, một vòng review, và feedback gắn vào _artifact_ ('PR này có ba nhánh không dùng; lần sau hãy extract seam') chứ không gắn vào con người." Từ họ đang lắng nghe là **stretch-with-safety**, không phải "tôi hay giúp đỡ". -- **"Vì sao anh tìm việc?"** Đang dò: anh có phải rủi ro bỏ chạy và có đang cay cú không? Câu trả lời senior trung thực, nhìn về phía trước, và không bao giờ nêu tên một người. "Tôi đã vượt quá scope của công việc — các project nhỏ hơn các vấn đề tôi muốn sở hữu" đánh bại "manager tôi không thăng chức tôi". Một phiên bản báo hiệu một người sẽ lớn lên cùng role; phiên bản kia báo hiệu một vấn đề đang bước qua cửa. -- **"Anh sẽ làm khác điều gì ở đây?"** Đang dò: độ chín retrospect trên _chính buổi phỏng vấn này_, _chính design này_. Câu trả lời senior nêu một ngã rẽ cụ thể: "Tôi sẽ đòi một decision record cho trade-off vừa thảo luận thay vì để nó là một thỏa thuận bằng lời." Mọi buổi phỏng vấn senior đều kết bằng cách bắt bạn tự đánh giá trong thời gian thực; hãy xử nó như một hệ thống, không phải một cảm giác. - -## 7. Tự kiểm tra - -- [ ] Hai câu chuyện có impact đo đếm được (latency giảm, incident được xử lý, hệ thống được ship) — và mỗi cái qua _năm probe_: giả thuyết ban đầu, metric xác nhận, đòn bẩy chính xác, rollback/blast radius, thay đổi hệ thống. -- [ ] Một câu chuyện quyết định sai nơi bạn gọi tên _suy luận sai_, không chỉ kết cục, cùng thay đổi hệ thống ngăn tái diễn. -- [ ] Một lần bạn bất đồng với senior và kết quả — đóng khung disagree-and-commit, không phải "tôi thắng cuộc tranh luận". -- [ ] Một câu trả lời calibrated cho: GC pause times, availability 99.9% vs 99.99%, khác biệt p99 vs p999, và pool sizing từ `rps × hold_time`. -- [ ] Câu trả lời at-least-once + idempotency key, kể cả outbox, sẵn sàng để nói. -- [ ] Khung design-doc một trang và khuôn incident update — bạn tạo được cả hai từ một chủ đề ngẫu nhiên tại chỗ. -- [ ] Câu trả lời rõ cho "anh sẽ làm khác điều gì ở đây?" cho design bạn vừa đi qua. -- [ ] Câu trả lời rõ cho "vì sao anh tìm việc?" nhìn về phía trước và không nêu tên ai. - -## 8. Interviewer follow-ups - -Khi câu trả lời đầu của bạn vừa rơi xuống, họ bắt đầu dồn. Hãy sẵn sàng cho những câu này: - -- "Anh nói at-least-once + idempotency. Đi qua retry — dedupe key nằm ở đâu, và chuyện gì xảy ra với hai delivery song song?" -- "Khi nào việc tách module thành service trở thành _ép buộc_? Đưa tôi điều kiện trigger anh sẽ ghi vào design doc." -- "p99 của anh ổn mà anh vẫn bị paging. Metric nào anh nhìn đầu tiên, và phép toán đuôi nào khiến p99 50ms trở nên nguy hiểm?" -- "Làm sao anh phân biệt một spike latency 2 giây là GC pause hay slow query — không đoán mò?" -- "Câu hỏi duy nhất anh hỏi cả phòng trước khi rollback trong một incident là gì?" -- "Senior của anh phản biện design của anh. Anh chắc chắn mình đúng. Anh thật sự nói _gì_ trong cuộc họp đó?" -- "Soạn khuôn incident update cho một sự cố thanh toán ngay bây giờ. Dòng đầu tiên là gì?" -- "Marketing muốn flash sale. Câu _kỹ thuật_ nào dịch request đó — và nút thắt anh nêu là gì?" -- "Đưa tôi một decision record cho trade-off chúng ta vừa thảo luận. Mỗi phần chứa gì?" -- "Anh sẽ làm khác điều gì trong buổi phỏng vấn này, nếu chúng ta chạy lại ngay bây giờ?" - -Đó là bar tư duy senior — và thường là khác biệt giữa offer và trượt. Vòng code chứng minh bạn _biết_; vòng này chứng minh bạn _quyết_. Đến với các con số, các artifact, và những câu chuyện sống sót qua cú tua — và bạn không trả lời câu hỏi nữa; bạn đang trình diễn công việc. +- [ ] Junior: Tôi cho được intro gọn, answer weakness trung thực-có-mitigation, kể bug story với root cause + guard, và show process để unstuck. +- [ ] Mid: Tôi frame disagreement bằng evidence, own failure như tuition, prioritize bằng impact×reversibility, và show mentoring thật và xử lý vague requirement. +- [ ] Senior: Tôi quyết ship-vs-hold bằng blast radius/recovery, show process cho decision dưới uncertainty, raise team level qua mechanism, lead incident response, và định nghĩa senior là judgment + ownership + leverage.