Handling Empty Rollbacks and Suspended Transactions in TCC-Based Distributed Systems

Understanding Empty Rollbacks

An empty rollback occurs when the Cancel operation of a TCC (Try-Confirm-Cancel) transaction runs, but the corresponding Try phase was never successfully executed. This results in an attempt to compensate for resources that were never reserved.

Scenario Example

Consider a scenario where a coordinator dispatches a Try request to a stock service to reserve inventory. If a network partition occurs or the service is temporarily down, the Try operation might not be processed. The coordinator, assuming the Try failed, triggers a Cancel command. Since no inventory was actually reserved, executing the cancellation logic leads to an empty rollback.

Mitigation Strategy

The solution involves maintaining a persistent log of transacsion states. Before executing the Cancel logic, the service checks if a Try operation was recorded.

Transaction Log Schema:

CREATE TABLE tx_activity_log (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    global_tx_id VARCHAR(64) NOT NULL,
    branch_tx_id VARCHAR(64) NOT NULL,
    action_state TINYINT NOT NULL COMMENT '0:Init 1:Reserved 2:Cancelled 3:Committed',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY idx_global_branch (global_tx_id, branch_tx_id)
);

Pseudo-code Implementation:

public boolean reserveInventory(String globalTxId, String branchTxId, Long productId, int quantity) {
    TransactionLog log = fetchLog(globalTxId, branchTxId);
    // Prevent suspension: if already cancelled, reject Try
    if (log != null && log.getState() == 2) {
        return false;
    }
    // Idempotency check
    if (log != null && (log.getState() == 1 || log.getState() == 3)) {
        return true;
    }

    Connection dbConn = acquireConnection();
    try {
        dbConn.setAutoCommit(false);
        // Business logic: Freeze stock
        freezeStock(productId, quantity, dbConn);
        // Record state
        saveLog(globalTxId, branchTxId, 1, dbConn);
        dbConn.commit();
        return true;
    } catch (Exception ex) {
        dbConn.rollback();
        return false;
    }
}

public boolean compensateInventory(String globalTxId, String branchTxId) {
    TransactionLog log = fetchLog(globalTxId, branchTxId);

    // Handle Empty Rollback: No Try recorded
    if (log == null || log.getState() == 0) {
        // Log this as a successful cancellation to prevent future empty rollbacks
        saveLog(globalTxId, branchTxId, 2, acquireConnection());
        return true;
    }
    // Idempotency: Already cancelled
    if (log.getState() == 2) {
        return true;
    }

    Connection dbConn = acquireConnection();
    try {
        dbConn.setAutoCommit(false);
        // Business logic: Release frozen stock
        releaseStock(productId, dbConn);
        updateLogState(globalTxId, branchTxId, 2, dbConn);
        dbConn.commit();
        return true;
    } catch (Exception ex) {
        dbConn.rollback();
        return false;
    }
}

Understanding Suspended Transactions

Suspension is a race condition where the Cancel command arrives and executes (an empty rollback) before the delayed Try request eventually lands. If the late Try executes, it reserves resources that will never be released becuase the cancellation has already happened.

Scenario Example

  1. Try request is sent but delayed by network lag.
  2. Coordinator times out, sends Cancel to the service.
  3. Service receives Cancel, finds no Try record, performs an empty rollback (State -> Cancelled).
  4. The delayed Try request finally arrives. If processed, it freezes stock permanently.

Mitigation Strategy

The defense against suspension is implemented in the Try phase. The service must check if a Cancel has already been logged for that transaction ID. If so, it must refuse to execute the Try.

Enhanced Reservation Logic:

public boolean reserveInventorySafe(String globalTxId, String branchTxId, Long productId, int quantity) {
    TransactionLog log = fetchLog(globalTxId, branchTxId);
    
    // Anti-Suspension Check
    if (log != null && log.getState() == 2) {
        // Cancel already happened, rejecting Try to prevent suspension
        return false;
    }
    
    if (log != null && log.getState() == 1) {
        return true; // Already reserved
    }

    // Proceed with normal reservation...
    return doReserve(globalTxId, branchTxId, productId, quantity);
}

Comparison of Issues

Issue Root Cause Prevention Mechanism
Empty Rollback Cancel runs without Try running Cancel checks for Try record; if missing, return success immediately
Suspension Try runs after Cancel Try checks for Cancel record; if found, reject execution
Idempotency Duplicate requests Check transaction state before processing any phase

Framework Support

Libraries like Seata handle these edge cases automatically. By annotating methods, the framwork manages the state table and performs the necessary checks.

@TwoPhaseBusinessAction(name = "inventoryAction", commitMethod = "commit", rollbackMethod = "rollback")
public boolean prepare(@BusinessActionContextParameter(paramName = "productId") Long productId) {
    // Try logic
    return true;
}

public boolean commit(BusinessActionContext context) {
    // Confirm logic
    return true;
}

public boolean rollback(BusinessActionContext context) {
    // Cancel logic
    return true;
}

Tags: Distributed Transactions TCC Pattern microservices Data Consistency Transaction Management

Posted on Thu, 27 Aug 2026 16:18:08 +0000 by zeus1