How to Use @GlobalTransactional Annotation in Apache Seata
I spent hours debugging why my distributed transactions weren’t rolling back correctly. I had sprinkled @GlobalTransactional annotations across all my microservices, thinking more annotations meant better transaction safety. I was wrong.
The issue? I fundamentally misunderstood how Seata’s @GlobalTransactional annotation works. Let me show you what I learned the hard way.
The Problem: My Transaction Wasn’t Distributed
I was building an order management system with three services: OrderService, InventoryService, and BillingService. When an order failed in BillingService, I expected the inventory deduction in InventoryService to roll back too. It didn’t.
@PostMapping("/orders")@GlobalTransactionalpublic void createOrder(OrderRequest request) { orderRepository.save(request.toOrder()); inventoryService.deductStock(request.getProductId(), request.getQuantity()); billingService.chargeCustomer(request.getCustomerId(), request.getAmount());}@GlobalTransactional // WRONG: This breaks XID propagationpublic void deductStock(Long productId, int quantity) { Product product = productRepository.findById(productId); product.setStock(product.getStock() - quantity); productRepository.save(product);}@GlobalTransactional // WRONG: This breaks XID propagationpublic void chargeCustomer(Long customerId, BigDecimal amount) { // Payment logic here throw new PaymentException("Insufficient funds"); // This should roll back everything}When BillingService threw an exception, only the billing transaction rolled back. The inventory deduction stayed committed. Why?
Understanding @GlobalTransactional vs @Transactional
I realized I needed to understand the difference between these two annotations:
| Aspect | @Transactional | @GlobalTransactional |
|---|---|---|
| Scope | Single database, single service | Multiple databases, multiple services |
| Coordinator | Spring Transaction Manager | Seata Transaction Coordinator (TC) |
| Transaction ID | Local transaction ID | Global XID |
| Propagation | Within same service | Across services via XID |
The key insight: @GlobalTransactional marks the start of a distributed transaction, not every participant.
How XID Propagation Works
Seata uses an XID (Global Transaction ID) that propagates across services:
┌─────────────────┐│ OrderService │ @GlobalTransactional (Creates XID: 192.168.1.1:8091:12345)│ XID: 12345 │└────────┬────────┘ │ HTTP Header: Seata-XID: 192.168.1.1:8091:12345 ▼┌─────────────────┐│ InventoryService│ No annotation needed, joins via XID│ XID: 12345 │└────────┬────────┘ │ HTTP Header: Seata-XID: 192.168.1.1:8091:12345 ▼┌─────────────────┐│ BillingService │ No annotation needed, joins via XID│ XID: 12345 │└─────────────────┘When I added @GlobalTransactional to every service, each service tried to create its own global transaction instead of joining the existing one.
The Correct Implementation
I removed the redundant annotations and ensured XID propagation:
@RestControllerpublic class OrderController {
private final OrderService orderService;
@PostMapping("/orders") @GlobalTransactional(rollbackFor = Exception.class, timeoutMills = 30000) public Result<Order> createOrder(@RequestBody OrderRequest request) { // This is the transaction origin - only place needing @GlobalTransactional return orderService.createOrder(request); }}@Servicepublic class OrderService {
private final OrderRepository orderRepository; private final InventoryServiceClient inventoryClient; private final BillingServiceClient billingClient;
public Result<Order> createOrder(OrderRequest request) { // Step 1: Create order Order order = orderRepository.save(request.toOrder());
// Step 2: Deduct inventory (XID propagates automatically via Feign/RestTemplate) inventoryClient.deductStock(request.getProductId(), request.getQuantity());
// Step 3: Charge customer (XID propagates automatically) billingClient.chargeCustomer(request.getCustomerId(), request.getAmount());
return Result.success(order); }}@Servicepublic class InventoryService {
private final ProductRepository productRepository;
// NO @GlobalTransactional - joins the global transaction via XID public void deductStock(Long productId, int quantity) { Product product = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(productId));
if (product.getStock() < quantity) { throw new InsufficientStockException(productId, quantity); }
product.setStock(product.getStock() - quantity); productRepository.save(product); }}@Servicepublic class BillingService {
private final PaymentGateway paymentGateway;
// NO @GlobalTransactional - joins the global transaction via XID public void chargeCustomer(Long customerId, BigDecimal amount) { PaymentResult result = paymentGateway.charge(customerId, amount);
if (!result.isSuccess()) { throw new PaymentException(result.getErrorMessage()); // This exception triggers rollback across all services } }}Configuring XID Propagation
For XID to propagate between services, I needed to configure interceptors:
For Feign Clients
@Configurationpublic class SeataFeignConfig {
@Bean public RequestInterceptor seataFeignInterceptor() { return template -> { String xid = RootContext.getXID(); if (StringUtils.isNotBlank(xid)) { template.header(RootContext.KEY_XID, xid); } }; }}For RestTemplate
@Configurationpublic class SeataRestTemplateConfig {
@Bean public RestTemplate restTemplate() { RestTemplate restTemplate = new RestTemplate();
restTemplate.getInterceptors().add((request, body, execution) -> { String xid = RootContext.getXID(); if (StringUtils.isNotBlank(xid)) { request.getHeaders().add(RootContext.KEY_XID, xid); } return execution.execute(request, body); });
return restTemplate; }}Annotation Parameters
The @GlobalTransactional annotation supports several configuration options:
// Basic usage@GlobalTransactionalpublic void simpleMethod() { // Uses default timeout and rollback behavior}
// With rollback configuration@GlobalTransactional(rollbackFor = {MyException.class, AnotherException.class})public void withRollback() { // Rolls back for specified exceptions and their subclasses}
// With timeout@GlobalTransactional(timeoutMills = 60000)public void withTimeout() { // Times out after 60 seconds}
// Combined configuration@GlobalTransactional( rollbackFor = Exception.class, timeoutMills = 30000, name = "order-creation-transaction")public void fullyConfigured() { // Complete configuration}Common Mistakes I Made
Mistake 1: Annotating Every Service
// WRONG: Creates multiple independent global transactions@GlobalTransactionalpublic void orderService() { }
@GlobalTransactional // This starts a NEW transaction!public void inventoryService() { }The fix: Only annotate the entry point.
Mistake 2: Forgetting the undo_log Table
For AT mode, each database needs an undo_log table:
CREATE TABLE `undo_log` ( `id` bigint NOT NULL AUTO_INCREMENT, `branch_id` bigint NOT NULL, `xid` varchar(100) NOT NULL, `context` varchar(128) NOT NULL, `rollback_info` longblob NOT NULL, `log_status` int NOT NULL, `log_created` datetime NOT NULL, `log_modified` datetime NOT NULL, `ext` varchar(100) DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;I forgot to create this table in my InventoryService database. The result? No rollback records were saved, and rollbacks silently failed.
Mistake 3: Not Handling Propagation Correctly
When making internal method calls within the same class, XID doesn’t propagate through proxies:
@Servicepublic class OrderService {
@GlobalTransactional public void processOrder() { // This call doesn't go through proxy - XID may not propagate internalMethod(); }
public void internalMethod() { // Might not be in global transaction context }}The fix: Use self-injection or extract to another service.
Debugging Tips
I found these techniques helpful for debugging:
// Check if currently in a global transactionif (RootContext.inGlobalTransaction()) { String xid = RootContext.getXID(); log.info("Current XID: {}", xid);}
// Check transaction statusGlobalTransactionContext.reload(RootContext.getXID()) .getStatus(); // Active, Committed, or Rollbacked# Enable Seata debug logginglogging: level: io.seata: DEBUGWhen to Use Each Annotation
I created this decision tree:
Does your transaction span multiple services/databases?├─ Yes → Use @GlobalTransactional at the entry point│ └─ Ensure XID propagation via interceptors│└─ No → Use @Transactional └─ Standard Spring transaction managementKey Takeaways
- Single annotation: Only annotate the method that starts the distributed transaction
- XID propagation: Ensure interceptors propagate XID across services
- undo_log table: Required for AT mode rollback
- Exception handling: Configure
rollbackForappropriately - Timeout: Set reasonable timeouts for long-running transactions
Understanding these points transformed my distributed transaction handling from unreliable to robust. The key insight was recognizing that @GlobalTransactional is about transaction boundaries, not about annotating every participant.
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