Most candidates know Spring Boot. Very few can explain Kafka ordering guarantees, Redis Redlock, execution plan analysis, and isolation levels from memory. This week is your edge.
| Concept | What It Means | Interview Depth |
|---|---|---|
| ISR (In-Sync Replicas) | Set of replicas fully caught up to leader | Message only committed when all ISR acks. ISR shrinks if replica falls behind by replica.lag.time.max.ms |
| acks=all | Producer waits for all ISR to ack | Strongest durability. With ISR=1 (only leader), still just one ack. Always set min.insync.replicas=2 |
| Partition | Ordered, immutable log of messages | Unit of parallelism. More partitions = more consumers = more throughput. But more partitions = more overhead |
| Offset | Monotonically increasing message ID within a partition | Consumer controls its own offset. Can replay by resetting offset. Stored in __consumer_offsets topic |
| Consumer Group | N consumers sharing topic partition load | Each partition assigned to exactly one consumer in group. Max parallelism = num partitions |
| Log Compaction | Kafka retains only latest value per key | Used for changelog/event sourcing. Payment status updates — latest state per paymentId retained |
What happens to a Kafka partition when its leader broker dies?
unclean.leader.election.enable=true (risk: data loss)unclean.leader.election.enable=false — prefer unavailability over data lossAll events for ACC-001 land on Partition-0 (key=accountId) → processed in exact order
How does Kafka guarantee ordering? How do you maintain ordering in a high-throughput payment system with retries enabled?
Kafka ordering is per-partition, not per-topic. Guaranteed only within a single partition.
key=accountId or key=paymentId → same key always routes to same partition via hashCode() % numPartitionsmax.in.flight.requests.per.connection=1 (but kills throughput)enable.idempotence=true assigns sequence numbers. Broker deduplicates + reorders. Allows max.in.flight=5 safelySetting retries=MAX_INT without enable.idempotence=true causes re-ordering under network failures. For a payment INITIATED→SETTLED event sequence, this is catastrophic — settlement could be processed before initiation.
// ✅ Ordering-safe producer for payment events props.put("key.serializer", "StringSerializer"); props.put("enable.idempotence", "true"); // dedup + reorder at broker props.put("acks", "all"); // all ISR must ack props.put("retries", Integer.MAX_VALUE); // retry forever props.put("max.in.flight.requests.per.connection", 5); // safe with idempotence props.put("delivery.timeout.ms", 120000); // Send with key = accountId → guaranteed same partition ProducerRecord<String, PaymentEvent> record = new ProducerRecord<>( "payments", payment.getAccountId(), // KEY — determines partition event // VALUE ); producer.send(record, (meta, ex) -> { if (ex != null) alerting.trigger("kafka-send-failure", ex); });
| Semantic | When | Risk | Use in Payments? | How to Achieve |
|---|---|---|---|---|
| At-Most-Once | Commit offset before processing | Data loss on crash | Never | Auto-commit before consuming |
| At-Least-Once | Commit offset after processing | Duplicate processing on retry | With idempotent consumers | Disable auto-commit, manual ack after DB write |
| Exactly-Once | Atomic: process + commit offset | No loss, no duplicates | Ideal for payment debit/credit | Kafka Transactions API |
Consumer must set isolation.level=read_committed — only sees committed transactions
@KafkaListener(topics = "payments-raw") @Transactional("kafkaTransactionManager") public void process(ConsumerRecord<String, PaymentEvent> record) { // 1. Enrich event PaymentEvent enriched = enrichmentService.enrich(record.value()); // 2. Write to DB (within same Spring @Transactional) paymentRepo.save(enriched); // 3. Produce to next topic — same Kafka transaction kafkaTemplate.send("payments-processed", record.key(), enriched); // Kafka TX Manager commits: offset + output message atomically // If anything fails → abort → consumer re-processes from last committed offset } // application.yml — exactly-once config // spring.kafka.producer.transaction-id-prefix: payment-proc- // spring.kafka.consumer.isolation-level: read_committed // spring.kafka.producer.acks: all // spring.kafka.producer.enable-idempotence: true
| Scenario | What Happens | Impact |
|---|---|---|
| Consumer joins group | Rebalance triggered — all partitions reassigned | Brief pause in consumption during rebalance |
| Consumer crashes | Missed heartbeat → group coordinator triggers rebalance | Partitions reassigned to healthy consumers |
| More consumers than partitions | Extra consumers sit idle | No benefit — max parallelism = partition count |
| Slow consumer | Lag grows → alert triggers | Backpressure, OOM risk if lag unbounded |
| max.poll.interval.ms exceeded | Consumer presumed dead → rebalance | Common when processing is slow — tune the interval or reduce batch size |
How would you handle a poison pill message (one that always causes the consumer to crash)?
payments.DLT — preserve it for analysis without blocking main topic@RetryableTopic: handles retry + DLT automatically with exponential backoff. Retries go to payments-retry-0, payments-retry-1… final fail → DLT@RetryableTopic( attempts = "4", backoff = @Backoff(delay = 1000, multiplier = 2.0, maxDelay = 30000), dltStrategy = DltStrategy.FAIL_ON_ERROR, topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE, autoCreateTopics = "true" ) @KafkaListener(topics = "payments") public void process(PaymentEvent event) { paymentProcessor.handle(event); // retried: payments-retry-0 → -1 → -2 → DLT } @DltHandler public void handleDlt(PaymentEvent event, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) { log.error("DLT received from {}: {}", topic, event); alerting.page("PAYMENT_DLT", event.getPaymentId()); dltRepo.save(new FailedPayment(event, topic)); // persist for ops team }
| Problem | What Happens | Solution |
|---|---|---|
| Cache Stampede | Popular key expires → 1000 requests all miss cache simultaneously → all hit DB → DB collapses | Mutex lock on miss (only one thread populates), or probabilistic early expiry (refresh before expiry) |
| Cache Penetration | Requests for non-existent keys (e.g. invalid paymentId) bypass cache → hit DB every time | Cache null values with short TTL, or Bloom filter at edge to reject impossible keys |
| Cache Avalanche | Many keys expire at same time → mass DB requests → DB overload | Jittered TTL: TTL = base + random(0, variance), stagger expiry, Redis persistence warmup |
| Cache Inconsistency | DB updated but cache not invalidated → stale reads | Use Cache-aside with write-on-update, versioned cache keys, or event-driven invalidation via Kafka |
A payment's balance endpoint is getting 50,000 RPS during peak hours. When the Redis TTL expires, your Oracle DB gets a thundering herd. How do you solve this?
TTL = 300 + random(0, 30)What is the Redlock algorithm? Why is single-node Redis lock insufficient for HA?
Single-node problem: if Redis master dies after granting lock but before replicating to replica, replica promotes → two processes think they hold the lock.
Redlock: acquire lock on N independent Redis nodes (typically 5). Lock is valid only if acquired on majority (≥3) within TTL. No single node failure breaks the guarantee.
Practical: Use Redisson library — implements Redlock correctly with watchdog (auto-extends TTL while lock holder is alive).
Even Redlock has edge cases under GC pauses and clock drift. For financial systems requiring fencing tokens (e.g. DB row version checks), use SELECT FOR UPDATE (optimistic/pessimistic DB locking) as the final safety net alongside Redis locking.
@Service public class PaymentLockService { @Autowired private RedissonClient redisson; public void processWithLock(String paymentId) throws InterruptedException { RLock lock = redisson.getLock("payment:lock:" + paymentId); boolean acquired = lock.tryLock(100, 30_000, TimeUnit.MILLISECONDS); // tryLock(waitTime, leaseTime, timeUnit) // leaseTime=30s auto-expires even if process crashes if (!acquired) { throw new DuplicatePaymentException("Payment " + paymentId + " in progress"); } try { processPayment(paymentId); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); // ALWAYS release in finally } } } }
| Index Type | When to Use | Example in Payments | Watch Out |
|---|---|---|---|
| B-Tree (default) | Equality + range queries, < > BETWEEN | account_id, payment_date | Not for low-cardinality (status) |
| Bitmap | Low cardinality columns, DW queries | payment_status (5 values) | Row lock issues — bad for OLTP writes |
| Composite | Multi-column WHERE clauses | (account_id, payment_date) | Column order critical — leading edge rule |
| Function-Based | Queries on UPPER(), TRUNC(), expressions | UPPER(beneficiary_name) | Must match exact function in query |
| Covering Index | All query columns in index — no table access | Index on (account_id, amount, status) | Larger index; worth it for hot queries |
You have a composite index on (account_id, payment_date, status). Which queries use it and which don't?
Composite index is used left-to-right. It stops at the first missing or range column.
| Query WHERE clause | Index Used? | Why |
|---|---|---|
account_id = 'X' | Yes | Hits leading column |
account_id = 'X' AND payment_date = DATE | Yes (both columns) | Both leading columns used |
account_id = 'X' AND payment_date > DATE | Partial — stops at range | status not used after range on date |
payment_date = DATE | No | Skips leading column account_id |
account_id = 'X' AND status = 'SETTLED' | Partial | Skips date; Oracle may index skip scan |
Oracle 9i+ supports Index Skip Scan — can use composite index even if leading column skipped, but only if leading column has low cardinality. Verify with EXPLAIN PLAN.
vs. the BAD plan:
A payment query was running in 50ms but after 3 months with 10M more rows it now takes 45 seconds. What's your systematic investigation?
EXPLAIN PLAN FOR <query> + SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY). Look for FULL TABLE SCAN on large tables.SELECT last_analyzed FROM user_tables WHERE table_name='PAYMENTS'. If old → EXEC DBMS_STATS.GATHER_TABLE_STATS('SCHEMA','PAYMENTS')WHERE account_id = 12345 on VARCHAR2 column → full scan. Must match data types.UPPER(col), TRUNC(date_col), or leading wildcard LIKE '%abc'? These skip B-tree indexes.ALTER INDEX idx_name REBUILD ONLINESELECT * FROM V$LOCK l JOIN V$SESSION s ON l.SID=s.SIDCURSOR_SHARING=FORCE or hints.-- 1. Capture execution plan with actual statistics EXPLAIN PLAN FOR SELECT p.payment_id, p.amount, a.account_name FROM payments p JOIN accounts a ON p.account_id = a.account_id WHERE p.account_id = 'ACC-001' AND p.payment_date >= TRUNC(SYSDATE) - 30 AND p.status = 'SETTLED'; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(null, null, 'ALLSTATS LAST')); -- 2. Refresh statistics EXEC DBMS_STATS.GATHER_TABLE_STATS( ownname => 'PAYMENTS_SCHEMA', tabname => 'PAYMENTS', estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE, method_opt => 'FOR ALL INDEXED COLUMNS SIZE AUTO', cascade => TRUE ); -- 3. Optimal composite index for this query pattern -- Column order: equality first (account_id), then range (payment_date), then IN (status) CREATE INDEX idx_pay_account_date_status ON payments (account_id, payment_date, status) TABLESPACE indx_ts ONLINE; -- no table lock in production! -- 4. Covering index — avoids table access entirely CREATE INDEX idx_pay_covering ON payments (account_id, payment_date, status, payment_id, amount) ONLINE;
| Level | Dirty Read | Non-Repeatable | Phantom Read | Oracle Default? | Use In Payments |
|---|---|---|---|---|---|
| READ UNCOMMITTED | Yes | Yes | Yes | No | Never — reads uncommitted garbage |
| READ COMMITTED | No | Yes | Yes | ✓ Oracle default | Standard for most payment reads |
| REPEATABLE READ | No | No | Yes | Not native Oracle (use FOR UPDATE) | Consistent reporting windows |
| SERIALIZABLE | No | No | No | No (explicit opt-in) | Balance transfers, reconciliation |
Under READ_COMMITTED: T1 reads different values within same transaction. Fixed by REPEATABLE_READ or SERIALIZABLE.
Two simultaneous payment requests try to withdraw £500 from an account with £600 balance. Both pass the balance check. How do you prevent this at the DB level?
Classic lost update / race condition. Three approaches, choose based on contention:
SELECT balance FROM accounts WHERE id=? FOR UPDATE — acquires row lock. Second transaction waits. Prevents any interleaving. Best for high-contention accounts.version column. UPDATE accounts SET balance=?, version=version+1 WHERE id=? AND version=? — if 0 rows updated, someone else changed it → retry. Best for low-contention.In production payments: use pessimistic locking (SELECT FOR UPDATE) for balance debit operations. Speed matters less than correctness for money movement.
// Pessimistic locking — for balance debit @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT a FROM Account a WHERE a.id = :id") Optional<Account> findByIdForUpdate(@Param("id") Long id); // Usage @Transactional public void debit(Long accountId, BigDecimal amount) { Account acc = repo.findByIdForUpdate(accountId) .orElseThrow(() -> new AccountNotFoundException(accountId)); // Row locked — no concurrent debit possible until commit acc.debit(amount); // validates and updates balance repo.save(acc); } // Optimistic locking — for account metadata updates @Entity public class AccountPreferences { @Version private Long version; // JPA auto-checks on update // If version mismatch → OptimisticLockException → @Retryable } // HikariCP connection pool — tuning for payments // spring.datasource.hikari.maximum-pool-size=20 // spring.datasource.hikari.minimum-idle=5 // spring.datasource.hikari.connection-timeout=30000 ← fail fast, don't queue forever // spring.datasource.hikari.idle-timeout=600000 // spring.datasource.hikari.max-lifetime=1800000
What is the difference between a Kafka topic and a queue (e.g. ActiveMQ)?
Queue: message consumed by one consumer → deleted. Kafka topic: message retained on disk for configurable period. Multiple independent consumer groups can read the same message. Kafka is a durable, replayable log — fundamental architectural difference. Enables event sourcing, audit replay, new consumer groups processing historical data.
How do you design a Kafka topic for 1 million payment events per second?
linger.ms=5, batch.size=65536 — batch messages before sendcompression.type=lz4 — reduces network I/O dramaticallyfetch.min.bytes, fetch.max.wait.ms — batched pollingWhat is Kafka Log Compaction? When would you use it for payments?
Log compaction retains only the latest value per key — older records with the same key are deleted during compaction. Useful for payment status topic: key=paymentId, value=latest status. Any consumer joining later gets current state without replaying full history. Used for account balance snapshots, configuration change topics.
What is consumer lag and how do you monitor and alert on it?
Consumer lag = (latest partition offset) - (consumer's committed offset) = number of unprocessed messages. Monitored via: kafka-consumer-groups.sh --describe, Kafka JMX metrics, Prometheus + kafka-exporter, Grafana dashboard. Alert when lag exceeds threshold (e.g. >10,000 for payments). Growing lag = consumer bottleneck → scale consumers up to partition count, or optimise processing, or batch DB writes.
Difference between Kafka and database change data capture (CDC)? When use CDC over Kafka producer?
CDC (Debezium + Kafka Connect) reads DB transaction log and publishes every change event. No code changes needed — captures ALL DB changes including those from batch jobs, legacy apps, DBA scripts. Use CDC when: can't modify existing apps, need dual-write consistency, or migrating from monolith. Use producer when you control the write path and want rich event payloads.
How is Redis single-threaded yet so fast?
Redis 6+ uses I/O threads for network I/O but single thread for command execution — eliminates lock overhead entirely. Speed comes from: all data in RAM (microsecond access), simple data structures, epoll/kqueue async I/O, no serialisation overhead for most operations. 100,000+ ops/sec on commodity hardware.
Redis persistence — RDB vs AOF. Which for payments?
fsync=everysec — max 1 second data loss acceptable for cache. For distributed lock keys: must use TTL as safety net anyway.How would you implement rate limiting for payment API at 1000 req/min per user using Redis?
INCR rate:userId:minute → check <= 1000 → EXPIRE 60s. Simple but burst at window boundary.ZADD new request, ZREMRANGEBYSCORE old entries, ZCARD to count. More accurate, slightly more overhead.What is an N+1 query problem? How do you detect and fix it in Spring Data JPA?
Loading N accounts and for each lazily loading payments: 1 query for accounts + N queries for payments = N+1 queries. Detected via SQL logging or Hibernate statistics. Fix: @Query("SELECT a FROM Account a JOIN FETCH a.payments WHERE...") — single JOIN query. Or @EntityGraph. Or @BatchSize(size=50) for secondary fetch. Spring JPA + lazy loading is the #1 cause of slow payment reports.
What is a connection pool exhaustion? How do you diagnose it?
All pool connections in use — new threads wait. Symptoms: HikariPool-1 - Connection is not available, request timed out after 30000ms in logs. Causes: long-running transactions, missing connection release (forgotten close), too many threads. Diagnose: SELECT * FROM V$SESSION WHERE WAIT_CLASS='User I/O'. Fix: tune maximum-pool-size, reduce transaction scope, add @Transactional only where needed, alert on pool saturation metric.
Explain MVCC (Multi-Version Concurrency Control) in Oracle.
Oracle doesn't use read locks. Instead it maintains multiple versions of data via undo segments. A reading transaction sees a consistent snapshot as-of its query start time — even if another transaction is updating the same rows. This is why Oracle's READ_COMMITTED doesn't block readers — writers never block readers, readers never block writers. Undo segments store old versions. Long-running queries can get ORA-01555 (snapshot too old) if undo is recycled before query completes.