Data Consistency in Microservice Architecture

Suleyman Yildirim
5 min readDec 10, 2024

--

This blog post explores database-level solutions and higher-level design patterns for managing transactions and ensuring data consistency effectively.

Photo by Joshua Sortino on Unsplash

Challenges in Microservice Transaction Management

  1. Distributed Nature: Services manage their own data and communicate over networks, making traditional transaction mechanisms insufficient.
  2. Asynchronous Processing: Many workflows are event-driven and must handle eventual consistency.
  3. Scalability: Centralized transaction management, such as Two-Phase Commit (2PC), can become a bottleneck in high-traffic systems.
  4. Failure Handling: Systems must ensure data integrity even in partial failures.

To address these challenges, both database-level mechanisms and architectural patterns are essential.

Database-Level Solutions

Databases provide built-in mechanisms to maintain data consistency within their scope. These solutions are highly reliable but limited to a single database instance. Here’s how popular databases like PostgreSQL, DynamoDB, and Oracle handle consistency:

PostgreSQL

Row-Level Locking:

  • Use SELECT FOR UPDATE to lock rows and prevent concurrent modifications.
BEGIN;
SELECT * FROM products WHERE product_id = 1 FOR UPDATE;
UPDATE products SET stock = stock - 1 WHERE product_id = 1;
COMMIT;

Serializable Transactions:

  • Guarantees complete isolation by detecting conflicts and rolling back conflicting transactions.

Constraints and Triggers:

  • Enforce business rules directly in the database, e.g., preventing stock levels from dropping below zero.

DynamoDB

Conditional Writes:

  • Updates are executed only if a condition (e.g., stock availability) is met.

Example:

dynamodb.update_item(
TableName='Products',
Key={'ProductID': {'N': '1'}},
UpdateExpression="SET Stock = Stock - :dec",
ConditionExpression="Stock >= :dec",
ExpressionAttributeValues={':dec': {'N': '1'}}
)python

Transactional APIs:

  • TransactWriteItems ensures atomic updates across multiple items.

Oracle

Row-Level Locking and ACID Transactions:

  • Similar to PostgreSQL, Oracle uses SELECT FOR UPDATE for locking and offers robust ACID compliance.

Locking Mechanisms:

  • Exclusive and shared locks ensure no conflicts during concurrent operations.

When Database-Level Solutions Fall Short

While databases are excellent for local consistency, they are insufficient for distributed systems where multiple services or databases interact. For example, in an e-commerce system, an inventory service might deduct stock while a payment service processes a transaction, requiring coordination.

Distributed System Patterns for Data Consistency

1. Saga Pattern

The Saga pattern is used to manage distributed transactions in microservices. It divides a large transaction into smaller, isolated steps, each of which is handled by a service. If a step fails, a compensating transaction is executed to undo the changes made by previous steps.

Types:

  • Orchestration: A central orchestrator manages the transaction flow.
  • Choreography: Each service reacts to events emitted by others, with no central control.

Example (Orchestration):

Consider an e-commerce system with three services: Order, Payment, and Inventory.

  1. Place an Order: Deduct stock and process payment.
  2. Compensation on Failure: If payment fails, cancel the order and restore stock.

Example:

// Saga Orchestrator Service
public void executeSaga(Order order) {
try {
inventoryService.deductStock(order.getProductId(), order.getQuantity());
paymentService.processPayment(order.getPaymentInfo());
orderService.completeOrder(order.getId());
} catch (Exception e) {
orderService.cancelOrder(order.getId());
inventoryService.restoreStock(order.getProductId(), order.getQuantity());
}
}

2. Event Sourcing

Event Sourcing records changes to the application state as a sequence of immutable events, allowing reconstruction of the state by replaying events. It’s useful for systems requiring audit trails or state recovery.

Example:

An Account service logs all transactions (debits and credits) as events:

  1. AccountCreated
  2. MoneyDeposited
  3. MoneyWithdrawn

Example:

// Event Store
// Event Store
public class EventStore {
private List<Event> events = new ArrayList<>();
public void save(Event event) {
events.add(event);
}
public List<Event> getAllEvents() {
return events;
}
}

// Replaying events
Account replay(List<Event> events) {
Account account = new Account();
for (Event event : events) {
account.apply(event); // Apply changes
}
return account;
}

3. Outbox Pattern

he Outbox Pattern ensures consistency between the database and messages sent to a message broker by storing messages in the database as part of the transaction and later processing them asynchronously.

Example:

When placing an order:

  1. Write the order and a message to the outbox table in the same transaction.
  2. A background worker sends the message to a message broker like Kafka.

Example:

-- Save Order and Outbox message in one transaction
BEGIN;
INSERT INTO orders (id, status) VALUES (1, 'PENDING');
INSERT INTO outbox (id, event_type, payload) VALUES (1, 'OrderCreated', '{"orderId": 1}');
COMMIT;

A background worker publishes these messages asynchronously.

public void processOutboxMessages() {
List<Message> messages = outboxRepository.getUnprocessedMessages();
for (Message message : messages) {
messageBroker.send(message.getPayload());
outboxRepository.markAsProcessed(message.getId());
}
}

4. Locking Mechanisms

Used to ensure strict consistency in single-database systems:

Pessimistic Locking: Pessimistic locking prevents concurrent transactions from accessing a resource by locking it until the transaction is completed.

A row is locked explicitly to ensure no other transaction can read or modify it until the lock is released.

PostgreSQL with Pessimistic Locking:

BEGIN;
SELECT * FROM products WHERE product_id = 1 FOR UPDATE;
UPDATE products SET stock = stock - 1 WHERE product_id = 1;
COMMIT;Optimistic Locking: Detects conflicts using a version field and retries the transaction.

Pessimistic Locking with JPA:

@Service
public class ProductService {

@PersistenceContext
private EntityManager entityManager;

@Transactional
public void updateStockWithPessimisticLock(Long productId, int quantity) {
Product product = entityManager.find(Product.class, productId, LockModeType.PESSIMISTIC_WRITE);
if (product.getStock() < quantity) {
throw new IllegalArgumentException("Insufficient stock");
}
product.setStock(product.getStock() - quantity);
entityManager.merge(product);
}
}

Optimistic Locking

Optimistic locking allows transactions to proceed without locking the resource immediately. Instead, it uses a version field to detect conflicts. If another transaction modifies the resource during the process, the operation fails, prompting a retry.

Each resource has a version field that increments with every update. Before committing changes, the transaction checks whether the version has changed.

Optimistic locking with Postgresql: https://reintech.io/blog/implementing-optimistic-locking-postgresql

Optimistic Locking in JPA:

@Entity
public class Product {
@Id
private Long id;
private Integer stock;
@Version
private Long version; // Used for conflict detection
}

@Service
public class ProductService {

@Autowired
private ProductRepository productRepository;

@Transactional
public void updateStockWithOptimisticLock(Long productId, int quantity) {
Product product = productRepository.findById(productId).orElseThrow(() -> new RuntimeException("Product not found"));
if (product.getStock() < quantity) {
throw new IllegalArgumentException("Insufficient stock");
}
product.setStock(product.getStock() - quantity);
productRepository.save(product); // Fails if the version has changed
}
}

Combining Solutions

A hybrid approach often provides the best results:

  1. Use database-level mechanisms for local consistency within services.
  2. Apply Saga or Outbox patterns for workflows spanning multiple services.
  3. Leverage Event Sourcing for complex systems requiring auditability and state reconstruction.

Conclusion

Achieving data consistency in microservice architectures requires a mix of database mechanisms and distributed patterns. By combining the strengths of each approach, you can design scalable, reliable systems that maintain consistency without sacrificing performance.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

No responses yet

Write a response