How the Saga Pattern Resolves Distributed Transaction Issues: Methods and Real-World Example

How the Saga Pattern Resolves Distributed Transaction Issues: Methods and Real-World Example

Distributed systems often involve complex transactions that span multiple services. In such scenarios, maintaining data consistency becomes challenging. A traditional approach, such as two-phase commit, might lead to tight coupling between servic...

1. Understanding the Problem: The Complexity of Distributed Transactions

Image

Distributed transactions involve multiple microservices, where each service performs a part of a transaction. For instance, an e-commerce platform might involve services like payment, inventory, and order management. These services need to work together to complete a transaction. However, what happens if one of these services fails?

1.1 A Real-World Scenario

Imagine an e-commerce application where the following steps occur during an order placement:

  • Step 1: Deduct payment from the customer’s account.
  • Step 2: Reduce the item count in the inventory.
  • Step 3: Create an order in the order management system.

Image

If the inventory service fails after the payment is deducted but before the order is created, the system ends up in an inconsistent state. The customer is charged, but no order is placed.

1.2 Traditional Solutions and Their Limitations

To handle such failures, one might consider using a distributed transaction with a two-phase commit protocol. However, this introduces several issues:

  • High Latency: Each service must lock resources during the transaction, leading to increased latency.
  • Reduced Availability: If any service fails, the entire transaction is rolled back, reducing the overall system availability.
  • Tight Coupling: Services become tightly coupled, making it harder to scale or modify individual services.

2. How the Saga Pattern Solves the Problem

In distributed systems, transactions often span multiple microservices. Ensuring that all services either complete successfully or none at all is challenging. The traditional way of handling this—using distributed transactions with two-phase commit—can be problematic due to issues like high latency, tight coupling, and reduced availability.

Image

The Saga pattern offers a more flexible approach. Instead of attempting to execute a transaction as a single unit, the Saga pattern breaks down the transaction into smaller, isolated steps that can be performed independently. Each step is a local transaction that updates the database and then triggers the next step. If a step fails, the system performs compensating actions to undo the changes made by previous steps, ensuring that the system can return to a consistent state.

2.1 What is the Saga Pattern?

The Saga pattern is essentially a sequence of smaller transactions that are executed one after the other. Here’s how it works:

  • Local Transactions: Each service involved in the transaction performs its own local transaction. For instance, in an order processing system, one service might handle payment, another inventory, and yet another the order record.
  • Event or Message Publishing: After a service completes its local transaction, it publishes an event or sends a message indicating the successful completion of that step. For example, after the payment is processed, the payment service might publish a "PaymentCompleted" event.
  • Triggering the Next Step: The next service in the sequence listens for the event and, upon receiving it, proceeds with its local transaction. This continues until all steps in the transaction are completed.
  • Compensating Actions: If any step fails, compensating actions are invoked. These actions are designed to reverse the changes made by the previous steps. For instance, if the inventory reduction fails after payment, a compensating action would refund the payment.

2.2 Types of Sagas

There are two main ways to implement the Saga pattern: Choreography and Orchestration.

2.2.1 Choreography Saga

In a Choreography Saga, there is no central coordinator. Instead, each service involved in the Saga listens for events and decides when to act based on the outcome of previous steps. This approach is decentralized and allows services to operate independently. Here’s how it works:

  • Event-Based Coordination: Each service is responsible for handling the events that are relevant to it. For example, after the payment service processes a payment, it emits a "PaymentCompleted" event. The inventory service listens for this event and, when it receives it, deducts the item count.
  • Decentralized Control: Since there is no central coordinator, each service must know what to do next based on the events it receives. This gives the system more flexibility but requires careful planning to ensure that all services understand the correct sequence of operations.
  • Compensating Actions: If a service detects that something went wrong, it can emit a failure event, which other services listen for to trigger compensating actions. For example, if the inventory service cannot update the inventory, it might emit an "InventoryUpdateFailed" event, which the payment service listens for to trigger a refund.

Advantages of Choreography:

  • Loose Coupling: Services are loosely coupled, which makes it easier to scale and modify individual services.
  • Resilience: Since each service acts independently, the system can be more resilient to failures in individual services.

Challenges of Choreography:

  • Complexity: As the number of services grows, managing and understanding the flow of events can become complex.
  • Lack of Central Control: Without a central coordinator, it can be harder to monitor and debug the overall transaction flow.

2.2.2 Orchestration Saga

In an Orchestration Saga, a central orchestrator controls the flow of the transaction. The orchestrator determines the sequence of steps and handles the communication between services. Here’s how it works:

  • Centralized Control: The orchestrator sends commands to each service in sequence. For example, the orchestrator might first instruct the payment service to process a payment. Once that’s done, it tells the inventory service to update the inventory, and so on.
  • Sequential Execution: Each service performs its task only when instructed by the orchestrator, ensuring that the steps occur in the correct order.
  • Compensation Logic: The orchestrator is also responsible for initiating compensating actions if something goes wrong. For example, if the inventory update fails, the orchestrator can command the payment service to refund the payment.

Advantages of Orchestration:

  • Centralized Control: With a single orchestrator, it’s easier to monitor, manage, and debug the transaction flow.
  • Simpler Logic: Since the orchestrator handles the flow, individual services don’t need to be aware of the overall transaction sequence.

Challenges of Orchestration:

  • Single Point of Failure: The orchestrator can become a bottleneck or single point of failure if not designed for high availability.
  • Tight Coupling to the Orchestrator: Services are dependent on the orchestrator, which can make the system less flexible compared to choreography.

3. Implementing the Simple Orchestration Saga Pattern: A Step-by-Step Guide

Let's consider the e-commerce scenario and implement it using the Saga pattern.

In our coffee purchasing scenario, each service represents a local transaction. The Coffee Service acts as the orchestrator of this saga, coordinating the other services to complete the purchase.

Here's a breakdown of how the saga might work:

  • Customer places an order: The customer places an order through the Order Service.
  • Coffee Service initiates the saga: The Coffee Service receives the order and initiates the saga.
  • Order Service creates an order: The Order Service creates a new order and persists it.
  • Billing Service calculates the cost: The Billing Service calculates the total cost of the order and creates a billing record.
  • Payment Service processes the payment: The Payment Service processes the payment.
  • Coffee Service updates order status: Once the payment is successful, the Coffee Service updates the order status to "completed".

Image

3.1 Transaction entity

Image

In my implementation of the saga, each SagaItemBuilder represents a step in our distributed transaction flow. The ActionBuilder defines the actions to be performed, including the main action and the rollback action that will be executed if an error occurs. The ActionBuilder encapsulates three pieces of information:

component: The bean instance where the method to be invoked resides.

method: The name of the method to be called.

args: The arguments to be passed to the method.

ActionBuilder

public class ActionBuilder {
private Object component;
private String method;
private Object[] args;

public static ActionBuilder builder() {
return new ActionBuilder();
}

public ActionBuilder component(Object component) {
this.component = component;
return this;
}

public ActionBuilder method(String method) {
this.method = method;
return this;
}

public ActionBuilder args(Object... args) {
this.args = args;
return this;
}

public Object getComponent() { return component; }
public String getMethod() { return method; }
public Object[] getArgs() { return args; }
}

SagaItemBuilder

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class SagaItemBuilder {
private ActionBuilder action;
private Map<Class<? extends Exception>, ActionBuilder> onBehaviour;

public static SagaItemBuilder builder() {
return new SagaItemBuilder();
}

public SagaItemBuilder action(ActionBuilder action) {
this.action = action;
return this;
}

public SagaItemBuilder onBehaviour(Class<? extends Exception> exception, ActionBuilder action) {
if (Objects.isNull(onBehaviour)) onBehaviour = new HashMap<>();
onBehaviour.put(exception, action);
return this;
}

public ActionBuilder getAction() {
return action;
}

public Map<Class<? extends Exception>, ActionBuilder> getBehaviour() {
return onBehaviour;
}
}

Scenarios

import java.util.ArrayList;
import java.util.List;

public class Scenarios {
List<SagaItemBuilder> scenarios;

public static Scenarios builder() {
return new Scenarios();
}

public Scenarios scenario(SagaItemBuilder sagaItemBuilder) {
if (scenarios == null) scenarios = new ArrayList<>();
scenarios.add(sagaItemBuilder);
return this;
}

public List<SagaItemBuilder> getScenario() {
return scenarios;
}
}

Bellow is how can I commit the distribute transaction.

package com.example.demo.saga;

import com.example.demo.saga.exception.CanNotRollbackException;
import com.example.demo.saga.exception.RollBackException;
import com.example.demo.saga.pojo.ActionBuilder;
import com.example.demo.saga.pojo.SagaItemBuilder;
import com.example.demo.saga.pojo.Scenarios;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;
import java.util.Map;
import java.util.Set;

@Component
public class DTC {

public boolean commit(Scenarios scenarios) throws Exception {
validate(scenarios);
for (int i = 0; i < scenarios.getScenario().size(); i++) {
SagaItemBuilder scenario = scenarios.getScenario().get(i);
ActionBuilder action = scenario.getAction();
Object bean = action.getComponent();
String method = action.getMethod();
Object[] args = action.getArgs();

try {
invoke(bean, method, args);
} catch (Exception e) {
rollback(scenarios, i, e);
return false;
}
}
return true;
}

private void rollback(Scenarios scenarios, Integer failStep, Exception currentStepFailException) {
for (int i = failStep; i >= 0; i--) {
SagaItemBuilder scenario = scenarios.getScenario().get(i);
Map<Class<? extends Exception>, ActionBuilder> behaviours = scenario.getBehaviour();
Set<Class<? extends Exception>> exceptions = behaviours.keySet();
ActionBuilder actionWhenException = null;

if (failStep == i) {
for(Class<? extends Exception> exception: exceptions) {
if (exception.isInstance(currentStepFailException)) {
actionWhenException = behaviours.get(exception);
}
}
if (actionWhenException == null) actionWhenException = behaviours.get(RollBackException.class);
} else {
actionWhenException = behaviours.get(RollBackException.class);
}

Object bean = actionWhenException.getComponent();
String method = actionWhenException.getMethod();
Object[] args = actionWhenException.getArgs();
try {
invoke(bean, method, args);
} catch (Exception e) {
throw new CanNotRollbackException("Error in %s belong to %s. Can not rollback transaction".formatted(method, bean.getClass()));
}
}
}

private void validate(Scenarios scenarios) throws Exception {
for (int i = 0; i < scenarios.getScenario().size(); i++) {
SagaItemBuilder scenario = scenarios.getScenario().get(i);
ActionBuilder action = scenario.getAction();
if (action.getComponent() == null) throw new Exception("Missing bean in scenario");
if (action.getMethod() == null) throw new Exception("Missing method in scenario");

Map<Class<? extends Exception>, ActionBuilder> behaviours = scenario.getBehaviour();
Set<Class<? extends Exception>> exceptions = behaviours.keySet();
if (exceptions.contains(null)) throw new Exception("Exception can not be null in scenario has method %s, bean %s " .formatted(action.getMethod(), action.getComponent().getClass()));
if (!exceptions.contains(RollBackException.class)) throw new Exception("Missing default RollBackException in scenario has method %s, bean %s " .formatted(action.getMethod(), action.getComponent().getClass()));
}
}

public String invoke(Object bean, String methodName, Object... args) throws Exception {
try {
Class<?>[] paramTypes = new Class[args.length];
for (int i = 0; i < args.length; i++) {
paramTypes[i] = parameterType(args[i]);
}
Method method = bean.getClass().getDeclaredMethod(methodName, paramTypes);
Object result = method.invoke(bean, args);
return result != null ? result.toString() : null;
} catch (Exception e) {
throw e;
}
}

private static Class<?> parameterType (Object o) {
if (o instanceof Integer) {
return int.class;
} else if (o instanceof Boolean) {
return boolean.class;
} else if (o instanceof Double) {
return double.class;
} else if (o instanceof Float) {
return float.class;
} else if (o instanceof Long) {
return long.class;
} else if (o instanceof Short) {
return short.class;
} else if (o instanceof Byte) {
return byte.class;
} else if (o instanceof Character) {
return char.class;
} else {
return o.getClass();
}
}
}

3.2 Using it

I have 3 services that call to external service: BillingService, OrderService, PaymentService.

OrderService

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class OrderService {

public String prepareOrder(String name, int number) {
System.out.println("Prepare order for %s with order id %d ".formatted(name, number));
return "Prepare order for %s with order id %d ".formatted(name, number);
}

public void Rollback_prepareOrder_NullPointException() {
System.out.println("Rollback prepareOrder because NullPointException");
}

public void Rollback_prepareOrder_RollBackException() {
System.out.println("Rollback prepareOrder because RollBackException");
}
}

BillingService

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class BillingService {

public String prepareBilling(String name, int number) {
System.out.println("Prepare billing for %s with order id %d ".formatted(name, number));
return "Prepare billing for %s with order id %d ".formatted(name, number);
}

public String createBilling(String name, int number) {
System.out.println("Create billing for %s with order id %d ".formatted(name, number));
return "Create billing for %s with order id %d ".formatted(name, number);
}

public void Rollback_prepareBilling_NullPointException() {
System.out.println("Rollback prepareBilling because NullPointException");
}

public void Rollback_prepareBilling_ArrayIndexOutOfBoundsException() {
System.out.println("Rollback prepareBilling because ArrayIndexOutOfBoundsException");
}

public void Rollback_prepareBilling_RollBackException() {
System.out.println("Rollback prepareBilling because RollBackException");
}

public void Rollback_createBilling_NullPointException() {
System.out.println("Rollback createBilling because NullPointException");
}

public void Rollback_createBilling_ArrayIndexOutOfBoundsException() {
System.out.println("Rollback createBilling because ArrayIndexOutOfBoundsException");
}

public void Rollback_createBilling_RollBackException() {
System.out.println("Rollback createBilling because RollBackException");
}
}

PaymentService

package com.example.demo.service;

import org.springframework.stereotype.Service;

@Service
public class PaymentService {

public String createPayment() {
System.out.println("Create payment");
return "Create payment";
}

public void Rollback_createPayment_NullPointException() {
System.out.println("Rollback createPayment because NullPointException");
}

public void Rollback_createPayment_RollBackException() {
System.out.println("Rollback createPayment because RollBackException");
}
}

And in Coffee Service, I implement it as follows, I create a scenario and then commit it.

package com.example.demo.service;

import com.example.demo.saga.DTC;
import com.example.demo.saga.exception.RollBackException;
import com.example.demo.saga.pojo.ActionBuilder;
import com.example.demo.saga.pojo.SagaItemBuilder;
import com.example.demo.saga.pojo.Scenarios;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class CoffeeService {

@Autowired
private OrderService orderService;

@Autowired
private BillingService billingService;

@Autowired
private PaymentService paymentService;

@Autowired
private DTC dtc;

public String test() throws Exception {
Scenarios scenarios = Scenarios.builder()
.scenario(
SagaItemBuilder.builder()
.action(ActionBuilder.builder().component(orderService).method("prepareOrder").args("tuanh.net", 123))
.onBehaviour(NullPointerException.class, ActionBuilder.builder().component(orderService).method("Rollback_prepareOrder_NullPointException").args())
.onBehaviour(RollBackException.class, ActionBuilder.builder().component(orderService).method("Rollback_prepareOrder_RollBackException").args())
).scenario(
SagaItemBuilder.builder()
.action(ActionBuilder.builder().component(billingService).method("prepareBilling").args("tuanh.net", 123))
.onBehaviour(NullPointerException.class, ActionBuilder.builder().component(billingService).method("Rollback_prepareBilling_NullPointException").args())
.onBehaviour(RollBackException.class, ActionBuilder.builder().component(billingService).method("Rollback_prepareBilling_RollBackException").args())
).scenario(
SagaItemBuilder.builder()
.action(ActionBuilder.builder().component(billingService).method("createBilling").args("tuanh.net", 123))
.onBehaviour(NullPointerException.class, ActionBuilder.builder().component(billingService).method("Rollback_createBilling_ArrayIndexOutOfBoundsException").args())
.onBehaviour(RollBackException.class, ActionBuilder.builder().component(billingService).method("Rollback_createBilling_RollBackException").args())
).scenario(
SagaItemBuilder.builder()
.action(ActionBuilder.builder().component(paymentService).method("createPayment").args())
.onBehaviour(NullPointerException.class, ActionBuilder.builder().component(paymentService).method("Rollback_createPayment_NullPointException").args())
.onBehaviour(RollBackException.class, ActionBuilder.builder().component(paymentService).method("Rollback_createPayment_RollBackException").args())
);
dtc.commit(scenarios);
return "ok";
}
}

3.3 Result

When i make a exception in create billing.

public String createBilling(String name, int number) {
throw new NullPointerException();
}

Result

2024-08-24T14:21:45.445+07:00  INFO 19736 --- [demo] [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path '/'
2024-08-24T14:21:45.450+07:00 INFO 19736 --- [demo] [ main] com.example.demo.DemoApplication : Started DemoApplication in 1.052 seconds (process running for 1.498)
2024-08-24T14:21:47.756+07:00 INFO 19736 --- [demo] [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2024-08-24T14:21:47.756+07:00 INFO 19736 --- [demo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2024-08-24T14:21:47.757+07:00 INFO 19736 --- [demo] [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
Prepare order for tuanh.net with order id 123
Prepare billing for tuanh.net with order id 123
Rollback createBilling because RollBackException
Rollback prepareBilling because RollBackException
Rollback prepareOrder because RollBackException

Check out my GitHub Repository

4. Conclusion

In summary, the Saga pattern provides a robust solution for managing distributed transactions by breaking them down into smaller, manageable steps. The choice between Choreography and Orchestration depends on the specific needs and architecture of your system. Choreography offers loose coupling and resilience, while Orchestration provides centralized control and easier monitoring. By carefully designing your system with the Saga pattern, you can achieve consistency, availability, and flexibility in your distributed microservices architecture.

Feel free to comment below if you have any questions or need further clarification on implementing the Saga pattern in your system!

Read more at : How the Saga Pattern Resolves Distributed Transaction Issues: Methods and Real-World Example