Spring默认@Transactional不满足ACID?并发更新丢失问题问询
Great question—this is a super common point of confusion when working with Spring transactions and concurrent updates, so let’s unpack it step by step.
First, let’s clear up the core misconception: Spring’s @Transactional does not lack isolation (the 'I' in ACID). The confusion here comes from how isolation levels work, and what they’re designed to prevent vs. what happens in your specific concurrent update scenario.
What’s Actually Happening in Your Example
Let’s start with your code snippet:
@Transactional public TheEntity updateEntity(TheEntity ent){ TheEntity storedEntity = loadEntity(ent.getId()); storedEntity.setData(ent.getData()); return saveEntity(storedEntity); }
When two threads run this method simultaneously for the same entity ID:
- Both threads spin up their own independent transactions.
- Both execute
loadEntity(ent.getId()), running aSELECTquery to fetch the entity’s current state from the database. - Thread A modifies its copy of the entity, calls
saveEntity(which runs anUPDATE), and commits its transaction. - Thread B, still holding the old state of the entity it loaded earlier, applies its own changes and runs an
UPDATE—completely overwriting Thread A’s modifications.
This is called a lost update, a classic concurrency pitfall. But why does this happen if transactions are supposed to be isolated?
Isolation Levels vs. Lost Updates
ACID’s isolation property refers to how much one transaction can see uncommitted changes from another. Spring’s @Transactional uses your database’s default isolation level out of the box:
- MySQL/MariaDB default:
REPEATABLE READ - PostgreSQL default:
READ COMMITTED
Neither of these default levels prevents lost updates. Isolation levels are designed to solve problems like:
- Dirty reads (seeing uncommitted changes from another transaction)
- Non-repeatable reads (getting different results from the same
SELECTin one transaction) - Phantom reads (seeing new rows added by another transaction mid-transaction)
Lost updates are a separate issue: both transactions read the same initial state, then write their changes independently. Default isolation rules don’t block this—they only control visibility of uncommitted data.
How to Fix This
To stop lost updates in this scenario, you have three reliable options:
1. Pessimistic Locking
Lock the database row when fetching the entity, so only one transaction can modify it at a time. Adjust your loadEntity method to use a SELECT ... FOR UPDATE query:
public TheEntity loadEntity(Long id) { return entityManager.createQuery("SELECT e FROM TheEntity e WHERE e.id = :id FOR UPDATE", TheEntity.class) .setParameter("id", id) .getSingleResult(); }
Now, Thread A will lock the row when it runs the query. Thread B will block until Thread A commits or rolls back, ensuring it loads the latest state after Thread A’s changes.
2. Optimistic Locking
Add a version field to your entity, and let the database check if the state changed since you loaded it. This is lighter for low-concurrency scenarios:
@Entity public class TheEntity { @Id private Long id; private String data; @Version // Triggers optimistic locking automatically private Integer version; // Getters and setters }
When you call saveEntity, JPA will add a WHERE version = :currentVersion clause to the UPDATE query. If Thread B tries to update after Thread A changed the version, the update affects 0 rows, and JPA throws an OptimisticLockingFailureException. You can catch this and retry the update with the latest state.
3. Use a Higher Isolation Level
Explicitly set the SERIALIZABLE isolation level—the strictest available—to force transactions to run in serial order:
@Transactional(isolation = Isolation.SERIALIZABLE) public TheEntity updateEntity(TheEntity ent){ // Same code as before }
Note this can hurt performance, as transactions queue instead of running concurrently. Use this only if optimistic/pessimistic locking isn’t feasible.
Wrapping Up
Spring’s @Transactional doesn’t skip the isolation part of ACID—it delegates isolation enforcement to your database, using its default level unless you specify otherwise. The lost update scenario you’re seeing isn’t a failure of isolation, but a limitation of default isolation levels when handling concurrent writes. By adding locking logic or adjusting the isolation level, you can ensure your transactions behave as expected.
内容的提问来源于stack exchange,提问作者saferJo

