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
Tryrequest is sent but delayed by network lag.- Coordinator times out, sends
Cancelto the service. - Service receives
Cancel, finds noTryrecord, performs an empty rollback (State -> Cancelled). - The delayed
Tryrequest 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;
}