logo
1_kLUTL3u7Dy0QylqYCoC9hQ.gif

Idempotency in Microservices with loadbalancer

  • Author: Trần Trung
  • Published On: 22 Apr 2025
  • Category: System Design

Have you ever encountered a situation where a customer was charged twice for the same order? Or the system created two identical records just because the user accidentally clicked the "Submit" button twice or because the request timed out and was retried? 🤯 These are painful problems, especially in the Microservices world, where services communicate with each other over the network - an inherently unreliable environment.

Today, we'll explore an extremely important concept that helps solve this problem: Idempotency , and how to handle it effectively even when you're using a Load Balancer .

1. What is Idempotency? It sounds "dangerous" but it's very simple! 🤔

In simple terms, an operation is called idempotent if performing it multiple times gives the same result as performing it just once .

  • Press the elevator call button: Idempotent 👍
  • Set the air conditioner temperature to 20°C: Idempotent 👍
  • Top up 100k to your phone (each time is a new transaction): NOT Idempotent 👎

In API:

  • GET, PUT, DELETE: Usually designed to be Idempotent .
  • POST: Usually NOT Idempotent (e.g. creating a new resource).

2. Why Does Idempotency Survive in Microservices? 🏥

Microservices architecture is based on network communication (HTTP, message queue...). Network is unreliable (errors, timeouts, latency). When service A calls service B:

  • A sends request to B.
  • B processed successfully.
  • Response from B to A has network error.
  • A doesn't know if B has processed it yet -> A retry request.
  • If B does not have Idempotency mechanism -> B reprocesses -> Duplicate data! 😱

=> Idempotency ensures that retry requests are SAFE and do not cause unwanted side effects.

3. Popular Idempotency Assurance Strategies 🛡️

The most popular and powerful approach is to use an Idempotency Key :

  • The client generates a unique identifier (Idempotency-Key, usually UUID) for each attempt to perform the operation.
  • The client sends this key with the request (usually in the HTTP Header).
  • The server stores the keys of successfully processed requests (and results) for a period of time.
  • When receiving new request:
    • Check if the key has been processed?
    • If so: Return the saved result.
    • If not: Process the transaction, save the key + result, return the new result.

4. New Challenges: Idempotency and Load Balancer ⚖️ Problems and Solutions

Everything seems fine until you deploy your service with multiple instances (replicas) behind a Load Balancer for increased load capacity and High Availability.

What is the problem?

  • Client sends Request 1 with Idempotency-Key: KEY_A -> Load Balancer -> Instance 1 .
  • Instance 1 successfully processes, saving KEY_A and the response to its local storage .
  • Client timed out, no response received. Client retry same Request 1 with Idempotency-Key: KEY_A.
  • The Load Balancer (using a round-robin algorithm for example) this time redirects the request to Instance 2 .
  • Instance 2 has no idea that KEY_A was processed by Instance 1! It does not have this information in its local memory.
  • Instance 2 will re-execute all business logic => Duplicate data still happens! 😭

Solution: Shared State!

To solve this problem, information about the processed Idempotency-Keys (and corresponding responses) cannot reside in the local memory of each instance. It needs to be stored in a central, shared state that is accessible to all instances.

There are two common ways to create this shared state:

  • a) Use Distributed Cache (Example: Redis, Memcached):
    • How it works: All service instances will read/write the Idempotency Key information to the same Redis (or Memcached) cluster.
    • Advantages: Very fast access speed, suitable for frequent key checking. Redis provides atomic commands such as SETNX (SET if Not eXists) or SET key value EX ttl NX which are extremely useful for checking and marking keys being processed safely, avoiding race conditions between instances, and can automatically delete expired keys (TTL).
    • Disadvantages: Need to set up and manage a separate cache system. Cache data may not be 100% stable like database (depending on configuration).
  • b) Using Shared Database Table:
    • How it works: Create a separate table in the database (that all instances connect to) to store idempotency_key, response_data, status (PROCESSING, COMPLETED), timestamp...
    • Advantages: Take advantage of existing database, persistent data. Easier to manage if you are familiar with DB. Can use UNIQUE constraint on idempotency_key column so that DB automatically prevents inserting duplicate keys.
    • Disadvantages: Access speed is usually slower than cache. Need to handle concurrency carefully when multiple instances try to write/read a key (use database transactions, locking or rely on UNIQUE constraint error handling).

Choosing between Cache and Database depends on:

  • Your existing infrastructure.
  • Performance requirements (Cache is usually faster).
  • Data persistence requirements (DBs are usually more persistent).
  • The level of complexity you are willing to manage.

5. Idempotency Example with Spring Boot (Running Behind Load Balancer) ☕

The Controller and Service code basically doesn't change much from the previous example, but the key point lies in the implementation of ProcessedRequestStore.

@RestController
@RequestMapping("/payments")
public class PaymentController {
    @Autowired
    private PaymentService paymentService;
    // Implementation use shared state (Redis/DB)
    @Autowired
    private ProcessedRequestStore processedRequestStore;
    @PostMapping
    public ResponseEntity processPayment(
            @RequestHeader("Idempotency-Key") String idempotencyKey,
            @RequestBody PaymentRequest request) {
        // 1. Check shared store (Redis/DB) xem key đã xử lý chưa
        Optional<PaymentResponse> cachedResponse = processedRequestStore.findResponse(idempotencyKey);
        if (cachedResponse.isPresent()) {
            System.out.println("Idempotency key found, returning cached response for key: " + idempotencyKey);
            return ResponseEntity.ok(cachedResponse.get());
        }
        // 2. Mark as processing in shared store (atomic operation!)
        if (!processedRequestStore.markAsProcessing(idempotencyKey)) {
             System.out.println("Conflict: Idempotency key is currently being processed: " + idempotencyKey);
             return ResponseEntity.status(HttpStatus.CONFLICT).body(null);
        }
        PaymentResponse response;
        try {
            // 3. Logic 
            System.out.println("Processing new request for key: " + idempotencyKey);
            response = paymentService.executePayment(request);
            // 4. Save to shared store
            processedRequestStore.saveResponse(idempotencyKey, response);
            System.out.println("Saved response for key: " + idempotencyKey);
        } catch (Exception e) {
             processedRequestStore.clearProcessingMark(idempotencyKey); // Clear mark if error
             System.err.println("Error processing key " + idempotencyKey + ": " + e.getMessage());
             throw e;
        } finally {
             // processedRequestStore.clearProcessingMark(idempotencyKey);
        }
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
}
// --- Interface ProcessedRequestStore  ---
interface ProcessedRequestStore {
    Optional findResponse(String idempotencyKey);
    void saveResponse(String idempotencyKey, PaymentResponse response);
    boolean markAsProcessing(String idempotencyKey);
    void clearProcessingMark(String idempotencyKey);
}
// --- (Spring Data Redis) ---
@Component("redisProcessedRequestStore")
public class RedisProcessedRequestStore implements ProcessedRequestStore {
    @Autowired
    private StringRedisTemplate redisTemplate; 
    private static final String RESPONSE_KEY_PREFIX = "idempotency:response:";
    private static final String PROCESSING_MARK_PREFIX = "idempotency:processing:";
    private static final Duration RESPONSE_TTL = Duration.ofHours(24); 
    private static final Duration PROCESSING_TTL = Duration.ofSeconds(30); 
    private final ObjectMapper objectMapper = new ObjectMapper(); // serialize/deserialize response
    @Override
    public Optional<PaymentResponse> findResponse(String idempotencyKey) {
        String jsonResponse = redisTemplate.opsForValue().get(RESPONSE_KEY_PREFIX + idempotencyKey);
        if (jsonResponse != null) {
            try {
                return Optional.of(objectMapper.readValue(jsonResponse, PaymentResponse.class));
            } catch (JsonProcessingException e) {
                // Handle error deserializing
                System.err.println("Error deserializing cached response for key " + idempotencyKey);
                return Optional.empty();
            }
        }
        return Optional.empty();
    }
    @Override
    public void saveResponse(String idempotencyKey, PaymentResponse response) {
        try {
            String jsonResponse = objectMapper.writeValueAsString(response);
            redisTemplate.opsForValue().set(RESPONSE_KEY_PREFIX + idempotencyKey, jsonResponse, RESPONSE_TTL);
            redisTemplate.delete(PROCESSING_MARK_PREFIX + idempotencyKey);
        } catch (JsonProcessingException e) {
            // Handle error serializing
             System.err.println("Error serializing response for key " + idempotencyKey);
        }
    }
    @Override
    public boolean markAsProcessing(String idempotencyKey) {
        // SET key value EX seconds NX (Atomic operation)
        Boolean success = redisTemplate.opsForValue().setIfAbsent(
            PROCESSING_MARK_PREFIX + idempotencyKey,
            "processing", 
            PROCESSING_TTL
        );
        return Boolean.TRUE.equals(success);
    }
    @Override
    public void clearProcessingMark(String idempotencyKey) {
         redisTemplate.delete(PROCESSING_MARK_PREFIX + idempotencyKey);
    }
}
// --- PaymentService và DTOs (Giữ nguyên) ---

Key points in the Redis example:

  • Use StringRedisTemplate to interact with Redis.
  • Divide the keys into 2 types: response:* to store the final result and processing:* to mark processing.
  • Use setIfAbsent (SETNX equivalent to TTL) for markAsProcessing. This is an atomic operation , ensuring only one instance succeeds in marking the key being processed at a time, effectively eliminating race conditions.
  • Use TTL (Time To Live) for both key types to help Redis automatically clean up and avoid memory overflow.

6. Other Notes (Remain the same):

  • Idempotency Key origin (client generated, unique).
  • Key's scope (by user, context...).
  • Concurrency handling is paramount.

Conclude:

Idempotency is the foundation for reliable microservice systems. When you have a Load Balancer, using shared state (via Distributed Cache or Shared Database) to manage Idempotency Keys is a must. This way, you can confidently retry requests without fear of causing duplicate data, making the system more stable and consistent. Happy coding!

  • Share On: