Translating the Japanese article body to English now, preserving all Markdown, code blocks, and internal links.
If you’re using optimistic locking with JPA, all you need to do is add @Version to your entity. Hibernate takes care of everything: generating the WHERE clause, incrementing the version, and throwing an exception on conflict.
But the moment you move to MyBatis, that convenience disappears. Since it’s a framework where you write SQL yourself, including the version in the WHERE clause, detecting conflicts, and throwing exceptions are all your responsibility. This article walks through implementing optimistic locking with a version column in MyBatis from scratch.
Note that this is different in purpose from pessimistic locking like SELECT FOR UPDATE, so we won’t cover that here.
What Is Optimistic Locking?
Optimistic locking operates on the assumption that “until the moment of update, there probably won’t be a conflict with someone else’s changes.” At the moment you actually perform the update, you check whether the version you read matches the current version, and if there’s a mismatch, you treat it as a conflict.
With JPA, the framework handles this check and increment behind the scenes, but with MyBatis, you need to write both directly into the SQL itself.
Comparison with JPA’s @Version
Putting this into a comparison table makes clear what you need to write yourself in MyBatis.
| Operation | JPA (@Version) | MyBatis |
|---|---|---|
| Add version condition to WHERE clause | Automatic | Write it yourself |
| Auto-increment version | Automatic | Write it in the SET clause |
| Detect zero rows updated | Automatically raises exception | Check affected row count yourself |
| Throwing an exception | OptimisticLockException | Use/extend a Spring standard exception |
You don’t need to dig into JPA’s internal implementation (Hibernate’s dirty checking mechanism). It’s enough to just grasp that “the scope of what’s automated differs.” If you’d like to learn more about the mechanism itself, see the JPA Optimistic Locking Guide.
Adding a version Column to the Table
First, let’s prepare the table side.
ALTER TABLE items
ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
When adding this to an existing table later, be sure to specify DEFAULT 0. If the version on existing rows is NULL, the WHERE clause comparison described below will always fail.
Writing the UPDATE Statement in Mapper XML
There are two key points: incrementing the version in the SET clause, and including the current version as a condition in the WHERE clause.
<update id="updateItem">
UPDATE items
SET name = #{name},
price = #{price},
version = version + 1
WHERE id = #{id}
AND version = #{version}
</update>
If you forget to write the version condition in the WHERE clause, it quietly becomes a plain UPDATE with no optimistic locking at all. It’s subtle, but it’s the easiest point to trip up on.
The caller passes along the version it retrieved before the update as a parameter, and receives the return value as the affected row count (int). The basic way to write a Mapper interface is covered in the MyBatis Mapper Implementation Guide, so here we’ll just look at the part relevant to optimistic locking.
public interface ItemMapper {
Item findById(Long id);
int updateItem(Item item);
}
You Can Do the Same Thing with Annotations
If you write it with @Update instead of XML, you just turn the SQL into a string as-is.
public interface ItemMapper {
@Update("""
UPDATE items
SET name = #{name}, price = #{price}, version = version + 1
WHERE id = #{id} AND version = #{version}
""")
int updateItem(Item item);
}
For short SQL, the annotation approach is easier to follow at a glance, but when you need to switch which columns to update with <if> or batch-update multiple records with <foreach>, the XML approach tends to keep things more readable. For a simple conditional update like the one in this article, the content is the same either way.
Defining a Custom Exception Class
One thing to watch out for here: Spring Framework already provides a standard exception for optimistic locking conflicts, org.springframework.dao.OptimisticLockingFailureException. If you create your own class with the same name, it will cause confusion down the line when someone imports the Spring standard one and the names collide.
So if you want to carry your own diagnostic information (the ID of the conflicting entity), it’s safer to extend this standard class and define it under a different name.
import org.springframework.dao.OptimisticLockingFailureException;
public class ItemVersionConflictException extends OptimisticLockingFailureException {
private final Long entityId;
public ItemVersionConflictException(Long entityId) {
super("A conflict occurred. id=" + entityId);
this.entityId = entityId;
}
public Long getEntityId() {
return entityId;
}
}
This way, generic handling like catch (OptimisticLockingFailureException e) still works as-is, and you can add your own entity ID information while staying within Spring’s standard exception hierarchy (DataAccessException).
Treating Zero Affected Rows as a Conflict
If the return value of updateItem is 1, it succeeded; if it’s 0, it means “someone else updated the row before you did, after you read it.”
int rows = itemMapper.updateItem(item);
if (rows == 0) {
throw new ItemVersionConflictException(item.getId());
}
However, a conflict isn’t the only possible cause of zero affected rows. Zero rows can also occur if the target row itself was deleted after you read it. For truly strict requirements, it’s more accurate to call findById to check whether the row still exists when the count is zero, and distinguish the cause from there.
If you forget this row-count check, the caller won’t notice anything even if the update fails, and processing will continue silently. This is the kind of bug that fails quietly, so it’s worth making a habit of implementing this check every time you write an UPDATE statement.
Calling from the Service Layer and Retrying
The basic retry strategy, once a conflict is detected, is to fetch the latest version and attempt the UPDATE again. There’s a pitfall here.
If you wrap the entire retry loop in a single @Transactional, then under MySQL’s (InnoDB) default REPEATABLE READ isolation level, ordinary SELECTs within the same transaction keep using the snapshot taken at the start. In other words, the second findById call in the loop won’t be able to see the new version committed by the conflicting party, and the WHERE clause comparison will keep failing indefinitely.
To avoid this, each retry attempt needs to run as its own independent transaction. Using TransactionTemplate with PROPAGATION_REQUIRES_NEW lets you start a new transaction (i.e., a new snapshot) for each attempt, while avoiding the AOP proxy issues that come with self-invocation.
@Service
public class ItemService {
private final ItemMapper itemMapper;
private final TransactionTemplate transactionTemplate;
public ItemService(ItemMapper itemMapper, PlatformTransactionManager txManager) {
this.itemMapper = itemMapper;
this.transactionTemplate = new TransactionTemplate(txManager);
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
public void updateItemWithRetry(Long id, String newName) {
int maxRetry = 3;
for (int attempt = 0; attempt < maxRetry; attempt++) {
try {
transactionTemplate.executeWithoutResult(status -> {
Item item = itemMapper.findById(id);
item.setName(newName);
if (itemMapper.updateItem(item) == 0) {
throw new ItemVersionConflictException(id);
}
});
return;
} catch (ItemVersionConflictException e) {
sleepBeforeRetry(attempt);
}
}
throw new ItemVersionConflictException(id);
}
private void sleepBeforeRetry(int attempt) {
try {
Thread.sleep(50L * (attempt + 1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
By starting a new transaction for each attempt, findById reads the latest committed data every time. The wait time is also gradually extended based on the attempt count, so even in cases where the conflict doesn’t resolve instantly, the other party’s commit has a better chance of completing before the next attempt.
For updates that simply overwrite with user input as-is, automatically retrying on conflict can end up clobbering someone else’s changes. It’s more realistic to limit automatic retries to operations where recomputation produces the same result regardless, such as “add to the fetched value.”
Notes on Retrying
If you don’t set a cap on the number of retries, tables with frequent conflicts can end up in an infinite loop. Once the limit is reached, the straightforward approach is to throw ItemVersionConflictException as-is up the stack, letting the caller or a ControllerAdvice prompt the user to try again.
If conflicts on the same record happen frequently, that’s also a sign worth reconsidering the design. You might consider options like splitting the update target into finer-grained columns, or switching from optimistic locking to pessimistic locking.
Summary
The pillars of implementing optimistic locking in MyBatis are these five: designing the version column, writing an UPDATE statement that includes both the WHERE and SET clauses, detecting conflicts via zero affected rows, an exception class that extends a Spring standard exception, and a retry strategy that’s mindful of transaction boundaries. What JPA handles with a single @Version annotation, you have to assemble by hand here — but each piece is simple as a mechanism.
The basic way to write Mappers is covered in detail in the MyBatis Mapper Implementation Guide, and the mechanics of JPA’s @Version are covered in the JPA Optimistic Locking Guide. If you’re unsure which to choose, the MyBatis vs JPA Comparison Guide may also help.