Redisson Lock + @Transactional Still Corrupted Data Under Load
A Production Incident: Added Redisson Distributed Lock, but Data Still Got Corrupted by Concurrency
A while back, I took on a requirement with a very simple scenario: users concurrently operating on a certain resource (such as claiming limited coupons, or frequently modifying a configuration). For this kind of anti-concurrency overselling scenario, any veteran Java developer can think of a combo with their eyes closed: Spring Boot + @Transactional + Redisson distributed lock.
I banged out the code smoothly, tested it locally with a single thread without issues, and directly pushed it to testing and then production. On the first day after going live, operations came over and said: "The backend data is wrong, why was the same resource deducted twice?"
I was shocked and quickly checked the logs. The more I looked, the more numb I felt: Although a distributed lock was added, under extremely high concurrency, the lock actually "failed"!
Today, let me review this pitfall that almost got me a performance rating of C.
Recreating the "Suicidal" Code at That Time
To make it intuitive, I simplified the business code from back then. The general logic was: query the remaining amount in the database -> check if it is sufficient -> deduct -> save.
Java
@Service
public class ResourceServiceImpl implements ResourceService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private ResourceMapper resourceMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public void consumeResource(String resourceId) {
String lockKey = "lock:resource:" + resourceId;
RLock lock = redissonClient.getLock(lockKey);
try {
// Try to acquire the lock, wait up to 3 seconds
if (lock.tryLock(3, TimeUnit.SECONDS)) {
// 1. Query current stock from database
Resource res = resourceMapper.selectById(resourceId);
if (res.getStock() > 0) {
// 2. Deduct
res.setStock(res.getStock() - 1);
resourceMapper.updateById(res);
} else {
throw new RuntimeException("Insufficient stock");
}
} else {
throw new RuntimeException("System busy, please retry");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 3. Release lock
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Take a look, everyone, doesn't this code look familiar? @Transactional ensures the transaction, try...finally ensures the lock is definitely released. No matter how you review this code, the logic seems flawless!
But once concurrency hit, the database inventory still went negative.
Finding the Culprit: The Time Gap Between Lock Release and Transaction Commit
After investigating for most of the day, comparing MySQL's binlog and Redis execution logs, I finally found the problem: It's Spring AOP's fault.
Recall the underlying principle of @Transactional. Spring implements transactions through AOP dynamic proxies, which is equivalent to wrapping a layer around your business method:
Java
// Pseudocode of Spring proxy class
public void proxyConsumeResource(String resourceId) {
// 1. Start database transaction
connection.setAutoCommit(false);
try {
// 2. Execute your real business logic (including locking, modifying data, releasing lock)
target.consumeResource(resourceId);
// 3. Commit transaction
connection.commit();
} catch (Exception e) {
connection.rollback();
}
}
Do you see the fatal problem?!
In my business code, the finally block executed lock.unlock(), at which point the distributed lock had already been released. However! At this moment, the target.consumeResource() method had just finished executing, but the Spring proxy class's connection.commit() had not yet executed!
That is to say, the lock was gone, but the data had not yet been persisted to disk.
At this moment, if another thread (Thread B) comes in concurrently:
- Thread B sees that there is no lock in Redis and successfully acquires the lock.
- Thread B queries the database. Because Thread A's transaction has not yet committed, Thread B still reads the old data!
- Thread B performs the deduction based on the old data.
- Thread A commits the transaction, and Thread B commits its transaction immediately after.
Perfect, a dirty write occurred, and the data was completely corrupted.
Fixing the Pitfall: Let the Lock Fly a Little Longer
After finding the cause, the solution is very simple. The core idea is only one: You must ensure the lock is released after the transaction commits.
Solution 1: Brute-force Split (Recommended)
Directly move the locking logic up to the controller layer, or wrap it in another service layer. Ensure the scope of the lock is larger than the scope of the transaction.
Java
@Service
public class ResourceLockService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private ResourceServiceImpl resourceService; // Inject the original transactional Service
public void safeConsume(String resourceId) {
String lockKey = "lock:resource:" + resourceId;
RLock lock = redissonClient.getLock(lockKey);
try {
if (lock.tryLock(3, TimeUnit.SECONDS)) {
// Call the transactional method. Since the transactional method is an independent proxy,
// when execution returns here, the transaction has already committed!
resourceService.consumeResource(resourceId);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
Note: Do not write these two methods in the same class and call them directly via this, as this will cause AOP to fail—a well-known issue.
Solution 2: Programmatic Transaction
If you don't want to write an extra layer of class, you can use TransactionTemplate to manually control the transaction boundary.
Java
// Execute after acquiring the lock:
transactionTemplate.execute(status -> {
// Query database, deduct, update
return null;
});
// After the transaction commits, enter finally to release the lock
A Pitfall Worth Mentioning: Redisson Watchdog Failure
Taking this opportunity, let me share another pitfall many people easily fall into when using Redisson. Some folks like to pass a leaseTime (lock expiration time) when locking:
Java
// Attempt to lock, wait 3 seconds, automatically release after locking for 10 seconds
lock.tryLock(3, 10, TimeUnit.SECONDS);
Once you explicitly pass in leaseTime, Redisson's WatchDog mechanism will be disabled! If your business logic execution time exceeds 10 seconds (for example, due to a Full GC or calling a very slow third-party interface), the lock will be automatically released, and other threads will sneak in.
The correct approach is, if you don't know exactly how long the business will execute, never pass leaseTime:
Java
// Only pass wait time, no leaseTime, watchdog mechanism is active and will automatically renew for you
lock.tryLock(3, TimeUnit.SECONDS);
Summary
In daily business development, when using @Transactional together with various locks (including local locks like synchronized and distributed locks), you must pay extra attention and map out their scope boundaries.
Sometimes it's not that the underlying components are bad, but purely that we haven't figured out the execution order of Spring AOP. I hope my painful lesson can help you all lose a few less hairs. What outrageous concurrency pitfalls have you encountered in production? Welcome to share in the comments~