How to Propagate XID Between Spring Boot Services with Apache Seata
Problem
When I implemented distributed transactions with Apache Seata in our microservices architecture, I encountered a silent failure. Service A started a @GlobalTransactional, called Service B via HTTP, but Service B ran in a separate transaction context instead of joining the global transaction.
The worst part? No error message. The transaction just silently failed to rollback across services when something went wrong.
Service A: Started global transaction, XID=192.168.1.100:8091:123456789Service A: Calling Service B via HTTP...Service B: Processing order...Service B: Local transaction committed successfullyService A: Exception occurred! Rolling back...Service A: Global transaction rolled backService B: Data NOT rolled back! Still exists in database!Why didn’t Service B participate in the global transaction?
Environment
- Spring Boot 3.2.x
- Apache Seata 2.0.x
- Java 17
- RestClient (Spring 6.1+)
Root Cause
After hours of debugging, I discovered that Seata propagates the distributed transaction context via the XID (Transaction ID) passed in HTTP headers. Without the XID header, each service runs in isolation.
┌─────────────┐ ┌─────────────┐│ Service A │ │ Service B ││ │ │ ││ XID: abc │ ─── HTTP Call ───▶ │ XID: null ││ │ (No XID header) │ ││ │ │ │└─────────────┘ └─────────────┘
Service B has no idea it should join the global transaction!The XID needs to be passed in the HTTP header named TX_XID (defined as RootContext.KEY_XID). I needed two things:
- Outbound: Add XID to outgoing HTTP requests
- Inbound: Extract XID from incoming HTTP requests and bind to RootContext
First Attempt: Add XID to Outgoing Requests
I created a ClientHttpRequestInterceptor to add the XID header to all outgoing HTTP requests:
import io.seata.core.context.RootContext;import org.springframework.http.HttpRequest;import org.springframework.http.client.ClientHttpRequestExecution;import org.springframework.http.client.ClientHttpRequestInterceptor;import org.springframework.http.client.ClientHttpResponse;import org.springframework.util.StringUtils;
import java.io.IOException;
public class SeataXidClientInterceptor implements ClientHttpRequestInterceptor {
@Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
String xid = RootContext.getXID(); if (StringUtils.hasText(xid)) { request.getHeaders().add(RootContext.KEY_XID, xid); }
return execution.execute(request, body); }}Then I registered the interceptor with RestClient:
import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.client.RestClient;
@Configurationpublic class RestClientConfig {
@Bean public RestClient restClient() { return RestClient.builder() .requestInterceptor(new SeataXidClientInterceptor()) .build(); }}I tested the outbound propagation:
Service A: Started global transaction, XID=192.168.1.100:8091:123456789Service A: HTTP request header TX_XID=192.168.1.100:8091:123456789Service A: Calling Service B...The XID is now being sent! But Service B still wasn’t joining the transaction. The incoming request wasn’t being processed correctly.
Second Attempt: Extract XID on Incoming Requests
Service B needs to extract the XID from the incoming HTTP request and bind it to RootContext. I created a servlet Filter:
import io.seata.core.context.RootContext;import jakarta.servlet.Filter;import jakarta.servlet.FilterChain;import jakarta.servlet.ServletException;import jakarta.servlet.ServletRequest;import jakarta.servlet.ServletResponse;import jakarta.servlet.http.HttpServletRequest;import org.springframework.core.Ordered;import org.springframework.core.annotation.Order;import org.springframework.stereotype.Component;import org.springframework.util.StringUtils;
import java.io.IOException;
@Component@Order(Ordered.HIGHEST_PRECEDENCE)public class SeataXidFilter implements Filter {
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) req; String xid = httpRequest.getHeader(RootContext.KEY_XID);
boolean bound = false; if (StringUtils.hasText(xid) && !xid.equals(RootContext.getXID())) { RootContext.bind(xid); bound = true; }
try { chain.doFilter(req, res); } finally { if (bound) { RootContext.unbind(); } } }}I deployed Service B with the filter and tested again:
Service A: Started global transaction, XID=192.168.1.100:8091:123456789Service A: Calling Service B...Service B: Received XID header=192.168.1.100:8091:123456789Service B: XID bound to RootContextService B: Processing order...Service B: Database operation within global transactionService A: Exception occurred! Rolling back...Service A: Global transaction rolled backService B: Transaction rolled back successfully!Both services now participate in the same global transaction.
Why Two-Way Propagation?
I wondered why both outbound and inbound propagation were necessary. Here’s what I learned:
Outbound Interceptor Inbound Filter ────────────────── ───────────────
Service A ┌─────────────────┐ Service B┌──────────────┐ │ Add XID header │ ┌──────────────────────┐│ │ │ to request │ │ ││ RootContext │ ───┤─────────────────┼───▶│ Extract XID header ││ XID: abc │ │ │ │ ││ │ │ │ │ Bind to RootContext │└──────────────┘ └─────────────────┘ │ XID: abc │ └──────────────────────┘
Without either piece, the transaction context is lost.Outbound Interceptor:
- Reads XID from current thread’s
RootContext - Adds it to HTTP request headers
- Ensures XID travels with the request
Inbound Filter:
- Reads XID from HTTP request headers
- Binds it to current thread’s
RootContext - Ensures downstream operations run in the same transaction
What About RestTemplate and WebClient?
If you’re using RestTemplate instead of RestClient, Spring Cloud Alibaba Seata provides automatic propagation. But I learned this only works if you have the Spring Cloud Alibaba dependency:
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-seata</artifactId></dependency>For RestClient and WebClient, manual configuration is still required even with Spring Cloud Alibaba.
Here’s the configuration for WebClient:
import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.reactive.function.client.WebClient;
@Configurationpublic class WebClientConfig {
@Bean public WebClient webClient() { return WebClient.builder() .filter((request, next) -> { String xid = RootContext.getXID(); if (StringUtils.hasText(xid)) { request = ClientRequest.from(request) .header(RootContext.KEY_XID, xid) .build(); } return next.exchange(request); }) .build(); }}Common Pitfalls
Pitfall 1: Wrong Filter Order
// WRONG: Filter runs after transaction starts@Componentpublic class SeataXidFilter implements Filter { // This runs too late!}Always use @Order(Ordered.HIGHEST_PRECEDENCE) to ensure the filter runs first.
Pitfall 2: Forgetting to Unbind
// WRONG: XID stays bound to threadif (StringUtils.hasText(xid)) { RootContext.bind(xid);}chain.doFilter(req, res);// Forgot to unbind! Thread pool reuse causes issues.Always unbind in the finally block to prevent memory leaks in thread pools.
Pitfall 3: Async Calls
For async calls with @Async or CompletableFuture, the XID doesn’t automatically propagate to new threads:
@Servicepublic class AsyncService {
@Async public void asyncOperation() { // XID is null here! // Need manual propagation }}You need to manually capture and pass the XID:
@Servicepublic class AsyncService {
@Async public void asyncOperation(String xid) { RootContext.bind(xid); try { // Now XID is available } finally { RootContext.unbind(); } }}
// CallerString xid = RootContext.getXID();asyncService.asyncOperation(xid);Summary
XID propagation is essential for Seata distributed transactions across microservices. The solution requires two parts:
- Outbound: Implement
ClientHttpRequestInterceptorto add XID to outgoing HTTP requests - Inbound: Implement servlet
Filterto extract and bind XID from incoming HTTP requests
Spring Cloud Alibaba Seata handles this automatically for RestTemplate, but RestClient and WebClient require manual setup. Always use HIGHEST_PRECEDENCE for the filter order and remember to unbind XID in the finally block.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments