Performance & Scalability — Microservices Interview
Target: Senior Engineer · Engineering Lead · Pre-Architect Focus: Bottleneck diagnosis, auto-scaling, latency optimization, caching
Q: Your system shows high latency only during peak hours. How do you identify the bottleneck?
Why interviewers ask this: Production latency issues are complex. Tests your ability to methodically diagnose across layers (network, service, database, JVM).
Answer
Diagnosis pyramid (test from top down):
Network latency (1-10ms)?
↓ DNS, TCP handshake, TLS
Service latency (10-100ms)?
↓ Handler logic, serialization
Database latency (50-500ms)?
↓ Query time, locks, I/O
JVM overhead (5-50ms)?
↓ GC pauses, thread contention
Tools & metrics:
| Layer | Tool | Metric |
|---|---|---|
| End-to-end | Distributed tracing (Jaeger) | p50, p95, p99 latency per service |
| Database | Slow query log, EXPLAIN PLAN | Query time, lock waits |
| JVM | -XX:+PrintGCDetails |
GC pause duration, frequency |
| System | top, iostat, netstat |
CPU, memory, disk I/O, network |
| Thread pool | Spring Boot Actuator | Active threads, queue depth |
Spring Boot diagnostic code:
@RestController
public class DiagnosticController {
@GetMapping("/api/orders/{id}")
public Order getOrder(@PathVariable String id) {
long start = System.nanoTime();
try {
// Database call — measure separately
long dbStart = System.nanoTime();
Order order = orderRepository.findById(id).orElseThrow();
long dbTime = System.nanoTime() - dbStart;
log.info("Order lookup: {}ms", dbTime / 1_000_000);
return order;
} finally {
long total = System.nanoTime() - start;
log.info("Total latency: {}ms", total / 1_000_000);
}
}
}
Peak hours diagnosis checklist:
- [ ] Distributed trace shows which service is slow
- [ ] Database slow query log identifies problematic queries
- [ ]
jstat -gcshows if GC pauses spike during load - [ ] Thread pool metrics show saturation (queue_depth > 0)
- [ ] Network latency within expected range (< 50ms)
Q: You notice uneven load distribution across instances. What could be wrong?
Answer
Load balancer issues:
| Problem | Sign | Fix |
|---|---|---|
| Sticky sessions misconfigured | Some instances get 80% traffic | Remove session affinity or use shared session store (Redis) |
| Health check failing | Healthy instance marked down | Verify /health endpoint is working |
| Round-robin only | No awareness of instance load | Switch to least-connections or weighted algorithm |
| DNS caching | Requests go to old instance | Reduce DNS TTL, use service discovery |
| Colocation | Instances on same physical host | Check infrastructure layout, spread replicas |
Kubernetes load balancing example:
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
type: ClusterIP
sessionAffinity: None # Disable sticky sessions
sessionAffinityConfig:
clientIP:
timeoutSeconds: 10800
ports:
- port: 80
targetPort: 8080
loadBalancerAlgorithm: leastconn # Use least-connections
Monitoring:
Per-instance metrics:
- Instance A: 50% CPU, 8K req/sec
- Instance B: 25% CPU, 4K req/sec ← Uneven!
- Instance C: 75% CPU, 12K req/sec
Action: Check if Instance B is slow, remove from pool, rebalance
Q: A database becomes the bottleneck. How do you optimize?
Answer
Optimization hierarchy:
1. Query optimization
- Add indexes, use EXPLAIN
- Avoid N+1 queries
- Batch operations
2. Caching
- Redis for hot data
- Cache-aside pattern
- Invalidation strategy
3. Read replicas
- Offload reads to read-only followers
- Trade consistency for throughput
4. Sharding
- Partition by tenant or key
- Requires app-level routing
Query optimization checklist:
-- BEFORE (slow):
SELECT o.* FROM orders o
WHERE o.customer_id = ?;
-- No index → table scan
-- AFTER (fast):
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
-- Now uses index → O(log N)
-- EXPLAIN shows:
Index Seek (good) vs Table Scan (bad)
Caching pattern:
@Cacheable(value = "products", key = "#productId")
public Product getProduct(String productId) {
// Only called if cache miss
return productRepository.findById(productId).orElseThrow();
}
@CacheEvict(value = "products", key = "#productId")
public void updateProduct(String productId, Product update) {
productRepository.save(update);
}
Read replica routing:
@Repository
public class OrderRepository {
// Write to primary
public Order save(Order order) {
return primaryDataSource.save(order);
}
// Read from replica
public Optional<Order> findById(String id) {
return replicaDataSource.findById(id);
}
}
Q: A sudden traffic spike crashes services. How do you scale and stabilize?
Answer
Multi-layer response:
Spike detected (CPU > 80%, errors rising)?
├─ Immediate (< 1 sec)
│ ├─ Rate limiting: reject new requests
│ ├─ Load shedding: drop low-priority traffic
│ └─ Circuit breaker: stop calling failing services
├─ Short-term (10-60 sec)
│ ├─ Auto-scaling: spin up new pods
│ ├─ Message queue: buffer requests
│ └─ Cache: serve stale data
└─ Long-term (> 1 min)
├─ Database optimization
├─ Code profiling & optimization
└─ Infrastructure changes
Kubernetes auto-scaling:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Percent
value: 100 # Double replicas
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 50 # Reduce by 50%
periodSeconds: 60
Diagram — Complete Scaling Architecture
graph LR
Traffic["Traffic Spike\n100x normal"]
RateLimit["Rate Limiter\n· Reject excess"]
Queue["Message Queue\n· Buffer requests"]
HPA["HPA\n· Scale 3→20 pods"]
Cache["Cache\n· Serve stale data"]
DB["Database\n· Read replicas"]
Traffic -->|Phase 1: Block| RateLimit
Traffic -->|Phase 2: Queue| Queue
HPA -->|Phase 3: Scale| HPA
Cache -->|Phase 4: Degrade| Cache
DB -->|Phase 5: Distribute| DB
style RateLimit fill:#ff6b6b
style Queue fill:#ffe066
style HPA fill:#51cf66
style Cache fill:#4ecdc4
Caching
Q: What caching strategies exist for microservices, and how do you choose between them?
Why interviewers ask this: Caching is one of the most impactful performance levers, but the wrong strategy causes stale data bugs, cache stampedes, or wasted memory. Tests understanding of cache write patterns and their trade-offs.
Answer
Three write strategies:
| Strategy | Write Flow | Read Flow | Consistency | Complexity |
|---|---|---|---|---|
| Cache-Aside (Lazy) | App writes DB, invalidates cache | Miss → load from DB, populate cache | Eventual | Low |
| Write-Through | App writes cache AND DB synchronously | Always cache hit (after first write) | Strong | Medium |
| Write-Behind (Write-Back) | App writes cache only; cache flushes to DB async | Cache hit | Eventual (risk of loss on crash) | High |
Cache-Aside — most common pattern in microservices:
@Service
public class ProductService {
@Autowired private ProductRepository repo;
@Autowired private RedisTemplate<String, Product> redis;
private static final Duration TTL = Duration.ofMinutes(10);
public Product getProduct(String id) {
String key = "product:" + id;
// 1. Check cache
Product cached = redis.opsForValue().get(key);
if (cached != null) return cached;
// 2. Cache miss — load from DB
Product product = repo.findById(id).orElseThrow();
// 3. Populate cache with TTL
redis.opsForValue().set(key, product, TTL);
return product;
}
public void updateProduct(String id, Product updated) {
repo.save(updated);
redis.delete("product:" + id); // Invalidate — next read reloads from DB
}
}
Spring @Cacheable (declarative cache-aside):
@Service
public class OrderService {
@Cacheable(value = "orders", key = "#id",
condition = "#id != null",
unless = "#result == null")
public Order getOrder(String id) {
return orderRepository.findById(id).orElseThrow();
}
@CacheEvict(value = "orders", key = "#order.id")
public Order updateOrder(Order order) {
return orderRepository.save(order);
}
}
Cache stampede (thundering herd) — a critical production problem:
Problem:
Key expires. 500 concurrent threads all miss cache simultaneously.
All 500 hit the database at once → DB overload → cascade failure.
Solutions:
1. Probabilistic early expiration: refresh before TTL expires (with low probability)
2. Mutex/lock: first thread acquires lock, loads from DB, others wait for cache
3. Stale-while-revalidate: serve stale data, refresh in background
// Mutex pattern — only one thread reloads on miss
public Product getProductWithMutex(String id) {
String key = "product:" + id;
String lockKey = "lock:product:" + id;
Product cached = redis.opsForValue().get(key);
if (cached != null) return cached;
// Acquire distributed lock (SET NX EX)
Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
try {
Product product = repo.findById(id).orElseThrow();
redis.opsForValue().set(key, product, Duration.ofMinutes(10));
return product;
} finally {
redis.delete(lockKey);
}
} else {
// Another thread is loading — wait briefly and retry
Thread.sleep(50);
return getProductWithMutex(id);
}
}
CDN and edge caching:
| Cache Layer | Where | What to Cache | TTL |
|---|---|---|---|
| Browser cache | Client | Static assets (JS, CSS, images) | 1 year (with content hash in URL) |
| CDN (CloudFront, Fastly) | Edge PoP | Public API responses, static assets | Minutes to hours |
| API Gateway cache | Gateway | Authenticated responses per user | Seconds to minutes |
| Application cache (Redis) | App tier | DB query results, computed aggregates | Minutes to hours |
| Database query cache | DB layer | Row data, often best left to app tier | Auto-managed |
Cache eviction policies (Redis):
LRU (Least Recently Used) — evict oldest-accessed items (general purpose)
LFU (Least Frequently Used) — evict least-used items (better for skewed access)
TTL / volatile-lru — evict expired keys first, then LRU
allkeys-lru — evict any key (when no TTL set)
graph LR
Client["Client"]
CDN["CDN · Edge Cache"]
GW["API Gateway Cache"]
Redis["Redis · App Cache"]
DB["Database"]
Client -->|1 · Request| CDN
CDN -->|HIT: serve| Client
CDN -->|MISS| GW
GW -->|HIT: serve| CDN
GW -->|MISS| Redis
Redis -->|HIT: serve| GW
Redis -->|MISS| DB
DB -->|Load · populate cache| Redis
style CDN fill:#4ecdc4
style Redis fill:#ffe066
style DB fill:#ff6b6b
Architect Insight
Cache invalidation is famously hard. Prefer short TTLs + cache-aside over explicit invalidation for most microservices. Only use write-through or write-behind when you can afford the complexity and truly need strong consistency between cache and DB. For public read-heavy APIs (product catalog, pricing), multi-layer caching (CDN + Redis) can reduce DB load by 95%+.
High-Performance Data Management
Q: What are the differences between horizontal and vertical scaling? When do you choose each?
Why interviewers ask this: Tests practical scaling judgment — not just knowing the definitions but knowing the limits and cost profiles of each approach.
Answer
| Vertical Scaling (Scale Up) | Horizontal Scaling (Scale Out) | |
|---|---|---|
| Definition | Add more CPU/RAM/disk to one machine | Add more instances behind a load balancer |
| Limit | Hardware ceiling (largest instance type) | Virtually unlimited |
| Cost | Expensive at high end, no linear pricing | Roughly linear cost |
| Downtime | Often requires restart | Zero downtime (rolling) |
| Failure risk | Single point of failure | Distributed — partial failures tolerated |
| Stateful apps | Easier — no shared state issue | Harder — session/state must be externalised |
Decision guide:
Vertical (scale up) when:
- Single-threaded workloads that can't parallelize (legacy batch jobs)
- Stateful databases that are hard to shard (start here first)
- Quick short-term fix while designing horizontal scaling
Horizontal (scale out) when:
- Stateless services (REST APIs, workers) — default choice
- Handling burst traffic (scale to N, back to 1)
- High availability requirement — no single point of failure
- Cost efficiency at scale
graph LR
VS["Vertical Scale\nBigger Machine"]
HS["Horizontal Scale\nMore Machines"]
LB["Load Balancer"]
I1["Instance 1"]
I2["Instance 2"]
I3["Instance 3"]
VS -->|Single node limit| VS
HS --> LB
LB --> I1
LB --> I2
LB --> I3
Architect Insight
For microservices, design all stateless services for horizontal scaling from day one — externalise all state (sessions to Redis, files to S3, config to Config Server). Vertical scaling is a database strategy until you're ready to shard.
Q: How do you design database partitioning and sharding for a high-traffic microservice?
Why interviewers ask this: A single database node is always the bottleneck at scale. Tests understanding of data distribution strategies, trade-offs, and consistency implications.
Answer
Partitioning splits a single database table into smaller pieces. Two types:
| Type | How | Use Case |
|---|---|---|
| Horizontal (row-based) | Rows split across partitions by key range or hash | Time-series data, user data by ID range |
| Vertical (column-based) | Columns split — hot columns in one table, cold in another | Large BLOB columns, audit fields |
Sharding takes horizontal partitioning further — data is distributed across separate database nodes:
Strategies:
1. Range-based: user IDs 1–1M → Shard A, 1M–2M → Shard B
✅ Simple queries on ranges ❌ Hot shards if traffic skewed to recent data
2. Hash-based: shard = hash(userId) % N
✅ Even distribution ❌ Range queries require all shards
3. Directory-based: lookup table maps key → shard
✅ Flexible rebalancing ❌ Lookup table is a bottleneck/SPOF
Spring Boot with sharding (routing datasource):
public class ShardRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
String userId = ShardContext.getUserId();
int shard = Math.abs(userId.hashCode()) % 4;
return "shard-" + shard;
}
}
// Usage
@Transactional
public Order findOrder(String userId, String orderId) {
ShardContext.setUserId(userId); // Route to correct shard
return orderRepository.findById(orderId).orElseThrow();
}
Challenges to address at interview:
| Challenge | Solution |
|---|---|
| Cross-shard joins | Denormalize, or use scatter-gather queries |
| Cross-shard transactions | Saga pattern — no distributed 2PC across shards |
| Shard rebalancing | Consistent hashing minimises data movement on resize |
| Hot shards | Add salt to key, or use virtual nodes (consistent hashing) |
Q: How do you approach index optimization for slow database queries?
Why interviewers ask this: Query performance is the most common production bottleneck. Tests systematic diagnosis and knowledge of index internals.
Answer
Diagnosis first — always run EXPLAIN ANALYZE:
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 'cust-123'
AND status = 'PENDING'
ORDER BY created_at DESC;
-- Look for:
-- Seq Scan → no index being used
-- High rows_removed → high filter cost, composite index needed
-- Sort → missing index on ORDER BY column
Index selection rules:
| Scenario | Index Type | Rationale |
|---|---|---|
| Single column equality filter | B-tree | Default, most queries |
Multi-column filter (WHERE a AND b) |
Composite index (a, b) |
Put most selective column first |
Range + equality (WHERE status=X AND date>Y) |
Composite (status, date) |
Equality columns first |
| Full-text search | Full-text / GIN index | LIKE '%term%' can't use B-tree |
| JSON fields (PostgreSQL) | GIN on JSONB | Fast containment queries |
| Low-cardinality columns alone | Avoid B-tree | status with 3 values — table scan faster |
Index anti-patterns to call out:
-- ❌ Function on indexed column kills the index
SELECT * FROM orders WHERE LOWER(email) = 'user@example.com';
-- ✅ Fix: functional index
CREATE INDEX idx_orders_email_lower ON orders (LOWER(email));
-- ❌ SELECT * forces full table read even with index
SELECT * FROM orders WHERE customer_id = 'cust-123';
-- ✅ Fix: covering index includes all needed columns
CREATE INDEX idx_orders_customer_covering
ON orders (customer_id) INCLUDE (status, created_at, amount);
Architect Insight
Indexes speed up reads at the cost of write overhead and storage. For write-heavy tables, benchmark before adding indexes. In microservices, favour denormalisation and read models (CQRS) over complex multi-join queries with many indexes.
Q: How do you model data in NoSQL? What are the key differences from relational modeling?
Why interviewers ask this: NoSQL modeling is fundamentally different — most candidates transpose relational thinking and get poor performance. Tests access-pattern-first design.
Answer
Core principle: In SQL you model data, then write queries. In NoSQL you model access patterns, then design the schema around them.
Relational vs NoSQL comparison:
| Relational (SQL) | NoSQL (DynamoDB/Cassandra) | |
|---|---|---|
| Model around | Data relationships | Access patterns |
| Joins | ✅ Server-side joins | ❌ Must pre-join (denormalise) |
| Normalization | 3NF — eliminate redundancy | Deliberate duplication for query speed |
| Schema | Fixed schema | Flexible / schema-per-item |
| Query flexibility | Any ad-hoc query | Only pre-designed access paths |
DynamoDB single-table design example (orders + items):
Access patterns:
1. Get order by orderId
2. Get all items for an order
3. Get all orders for a customer
Table design (single-table):
PK | SK | Attributes
CUSTOMER#cust1 | ORDER#ord123 | {status, total, createdAt}
ORDER#ord123 | ITEM#item001 | {productId, qty, price}
ORDER#ord123 | ITEM#item002 | {productId, qty, price}
GSI: SK = ORDER#ord123 → fetch entire order with all items in one query
// Spring Data DynamoDB — query by access pattern
@DynamoDBTable(tableName = "orders")
public class OrderItem {
@DynamoDBHashKey String pk; // "ORDER#ord123"
@DynamoDBRangeKey String sk; // "ITEM#item001"
String productId;
int quantity;
}
// Get all items for order in one call
List<OrderItem> items = dynamoDBMapper.query(OrderItem.class,
new DynamoDBQueryExpression<OrderItem>()
.withHashKeyValues(new OrderItem("ORDER#ord123", null))
.withKeyConditionExpression("pk = :pk")
.withExpressionAttributeValues(Map.of(":pk", new AttributeValue("ORDER#ord123")))
);
Common Mistake
Don't model NoSQL tables the way you'd model relational tables. A common mistake is creating separate tables for each entity and then "joining" in application code. This results in N+1 query problems and defeats NoSQL's purpose. Design for your top 5 access patterns first.