Java
asynchronous programming
database performance
high performance applications
concurrency

What's the best way to asynchronously handle low-speed consumer database in high performance Java application

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

A high-performance Java service can process requests far faster than a slow downstream database can commit records. If every request waits on direct writes, latency rises and throughput collapses under load spikes. The right design is an asynchronous pipeline with bounded buffering, explicit backpressure, and controlled batch writes.

Separate Request Handling from Database Writes

The first design rule is to decouple ingestion from persistence. Request threads should validate input, create a lightweight event, and enqueue it quickly. Dedicated consumer workers then drain that queue and write to the database.

Use a bounded queue, not an unbounded one. A bounded queue forces predictable behavior during overload.

java
1import java.util.concurrent.ArrayBlockingQueue;
2import java.util.concurrent.BlockingQueue;
3
4public final class EventQueue {
5    private final BlockingQueue<OrderEvent> queue = new ArrayBlockingQueue<>(20_000);
6
7    public boolean tryPublish(OrderEvent event) {
8        return queue.offer(event);
9    }
10
11    public BlockingQueue<OrderEvent> backingQueue() {
12        return queue;
13    }
14
15    public record OrderEvent(String orderId, long createdAtMillis, int amountCents) {}
16}

This pattern keeps request threads responsive while giving you a central place to manage throughput.

Define Backpressure Policy Up Front

When the queue is full, your system must choose behavior intentionally. Common options are block producer, reject request, drop non-critical event, or spill to durable broker. The correct choice depends on business criticality.

Example policy mapping:

  • payment events: reject with retryable response if queue is saturated
  • analytics events: drop and increment metric
  • audit events: route to durable stream for guaranteed persistence

A simple non-blocking controller example:

java
1public final class EventIngestionService {
2    private final EventQueue eventQueue;
3
4    public EventIngestionService(EventQueue eventQueue) {
5        this.eventQueue = eventQueue;
6    }
7
8    public IngestResult ingest(EventQueue.OrderEvent event) {
9        boolean accepted = eventQueue.tryPublish(event);
10        if (!accepted) {
11            return new IngestResult(false, "QUEUE_FULL");
12        }
13        return new IngestResult(true, "ACCEPTED");
14    }
15
16    public record IngestResult(boolean accepted, String code) {}
17}

The important part is that overload behavior is explicit and observable.

Batch Writes to Improve Database Efficiency

Writing one row per transaction wastes network round trips and lock overhead. Batch inserts with periodic flush deliver much better throughput.

java
1import javax.sql.DataSource;
2import java.sql.Connection;
3import java.sql.PreparedStatement;
4import java.util.ArrayList;
5import java.util.List;
6import java.util.concurrent.BlockingQueue;
7import java.util.concurrent.TimeUnit;
8
9public final class BatchDbWriter implements Runnable {
10    private final BlockingQueue<EventQueue.OrderEvent> queue;
11    private final DataSource dataSource;
12    private volatile boolean running = true;
13
14    public BatchDbWriter(BlockingQueue<EventQueue.OrderEvent> queue, DataSource dataSource) {
15        this.queue = queue;
16        this.dataSource = dataSource;
17    }
18
19    @Override
20    public void run() {
21        List<EventQueue.OrderEvent> batch = new ArrayList<>(500);
22        long lastFlushNanos = System.nanoTime();
23
24        while (running || !queue.isEmpty()) {
25            try {
26                EventQueue.OrderEvent event = queue.poll(20, TimeUnit.MILLISECONDS);
27                if (event != null) {
28                    batch.add(event);
29                }
30
31                boolean sizeReady = batch.size() >= 500;
32                boolean timeReady = System.nanoTime() - lastFlushNanos >= 50_000_000L;
33
34                if (!batch.isEmpty() && (sizeReady || timeReady)) {
35                    writeBatch(batch);
36                    batch.clear();
37                    lastFlushNanos = System.nanoTime();
38                }
39            } catch (Exception ex) {
40                ex.printStackTrace();
41            }
42        }
43    }
44
45    public void shutdown() {
46        running = false;
47    }
48
49    private void writeBatch(List<EventQueue.OrderEvent> batch) throws Exception {
50        try (Connection conn = dataSource.getConnection()) {
51            conn.setAutoCommit(false);
52            try (PreparedStatement ps = conn.prepareStatement(
53                    "INSERT INTO orders_async(order_id, created_at, amount_cents) VALUES (?, ?, ?)")) {
54                for (EventQueue.OrderEvent e : batch) {
55                    ps.setString(1, e.orderId());
56                    ps.setLong(2, e.createdAtMillis());
57                    ps.setInt(3, e.amountCents());
58                    ps.addBatch();
59                }
60                ps.executeBatch();
61                conn.commit();
62            }
63        }
64    }
65}

Size-based plus time-based flushing gives good balance between throughput and latency.

Add Retry and Idempotency

Database writes can fail for transient reasons such as connection resets. Retrying blindly can create duplicate rows unless operations are idempotent.

Use a unique business key and upsert semantics where possible.

sql
1CREATE TABLE orders_async (
2  order_id VARCHAR(64) PRIMARY KEY,
3  created_at BIGINT NOT NULL,
4  amount_cents INT NOT NULL
5);

With this key in place, a retry can safely update or ignore duplicates depending on SQL dialect and business requirements.

Monitor Queue Health and Write Latency

Asynchronous systems fail silently without metrics. Track at least:

  • queue depth
  • enqueue rejection rate
  • batch size distribution
  • database write latency and error rate
  • consumer lag over time

These metrics tell you whether to tune batch size, add consumers, or scale the database. Without them, overload will surface only as user-facing timeouts.

Common Pitfalls

  • Using an unbounded queue that hides overload until heap memory is exhausted.
  • Running one transaction per event, which removes most performance gains from asynchronous design.
  • Retrying failed writes without idempotency protection, causing duplicate records.
  • Ignoring backpressure policy and letting callers block unpredictably.
  • Skipping operational metrics, which makes it hard to diagnose lag and saturation.

Summary

  • Decouple request handling from database writes with a bounded queue.
  • Define overload behavior explicitly for each event type.
  • Batch writes by size and time to improve throughput.
  • Build idempotent persistence so retries are safe.
  • Monitor queue and database metrics continuously to keep latency stable.

Course illustration
Course illustration

All Rights Reserved.