Mastering SpringBoot Concurrency: Pessimistic vs Optimistic Locks Explained
SpringBoot’s @Transactional ensures single‑transaction atomicity, but under high concurrency multiple transactions can still corrupt data; this article dissects why, demonstrates overselling scenarios, and provides detailed implementations of pessimistic (row/table locks) and optimistic (version/timestamp) locking with code, performance tests, and a comprehensive comparison guide.
Why @Transactional alone cannot prevent concurrent modifications
SpringBoot’s declarative ( @Transactional) and programmatic ( TransactionTemplate) transaction mechanisms guarantee atomicity and consistency only within a single transaction. When multiple transactions read and update the same row concurrently, the ACID guarantees of a single transaction do not prevent lost updates, leading to overselling, duplicate deductions, or inconsistent order states.
Overselling example
Initial stock = 10. Two threads (A and B) each call a service method annotated with @Transactional that reads the stock, checks it, and then decrements it without any locking.
@Service
public class StockService {
@Autowired
private StockMapper stockMapper;
@Transactional(rollbackFor = Exception.class)
public void decreaseStock(Long productId) {
// 1. Query current stock
Stock stock = stockMapper.selectByProductId(productId);
if (stock == null || stock.getStock() <= 0) {
throw new BusinessException("库存不足");
}
// 2. Decrease stock
int rows = stockMapper.decreaseStock(productId);
if (rows == 0) {
throw new BusinessException("库存扣减失败");
}
}
}Both threads start a transaction and read the stock (10) under the default READ COMMITTED isolation level.
Thread A updates the stock to 9 and waits for commit.
Thread B, having read the same old value (10), also updates the stock to 9 and waits for commit.
Both commits succeed; the final stock is 9 instead of the expected 8, i.e., an oversell.
Root causes
Isolation level limitation : READ COMMITTED prevents dirty reads but does not prevent non‑repeatable reads or lost updates when multiple transactions read the same row and then update based on the stale value.
Non‑atomicity across transactions : The sequence “read → update” is atomic only inside a single transaction. When several transactions execute these steps concurrently, the read and update are not a single atomic operation, allowing race conditions.
Even upgrading to REPEATABLE READ does not eliminate the problem because it only guarantees repeatable reads within the same transaction.
Lock concepts: pessimistic vs optimistic
Pessimistic lock : Assume conflicts will happen, acquire a lock before the operation, and block other transactions until the lock is released.
Optimistic lock : Assume conflicts are rare, perform the operation without a lock, and verify at commit time that the data has not been changed by another transaction.
Pessimistic lock
Locks the data row (or table) in the database so that other transactions cannot modify it until the current transaction finishes.
Advantages: high safety; concurrent conflicts are prevented outright.
Disadvantages: blocks other transactions, reducing throughput; coarse‑grained locks (e.g., table locks) can become a bottleneck.
Implementation relies on the database’s native lock mechanisms (row lock, table lock).
Implementation 1: Row lock (SELECT … FOR UPDATE)
// Mapper interface
public interface StockMapper {
// Query with row lock
Stock selectByProductIdForUpdate(@Param("productId") Long productId);
// Decrease stock
int decreaseStock(@Param("productId") Long productId);
}
// Mapper XML
<select id="selectByProductIdForUpdate" resultType="com.example.concurrency.entity.Stock">
SELECT id, product_id, stock FROM stock
WHERE product_id = #{productId}
FOR UPDATE
</select>
<update id="decreaseStock">
UPDATE stock SET stock = stock - 1
WHERE product_id = #{productId} AND stock > 0
</update>
// Service layer (transaction + row lock)
@Service
@Slf4j
public class StockService {
@Autowired
private StockMapper stockMapper;
@Transactional(rollbackFor = Exception.class)
public void decreaseStockWithPessimisticLock(Long productId) {
Stock stock = stockMapper.selectByProductIdForUpdate(productId);
if (stock == null || stock.getStock() <= 0) {
throw new BusinessException("库存不足");
}
int rows = stockMapper.decreaseStock(productId);
if (rows == 0) {
throw new BusinessException("库存扣减失败");
}
log.info("库存扣减成功,商品ID:{},剩余库存:{}", productId, stock.getStock() - 1);
}
}
// Controller (simple test endpoint)
@RestController
@RequestMapping("/stock")
public class StockController {
@Autowired
private StockService stockService;
@PostMapping("/decrease/{productId}")
public ResultVO decreaseStock(@PathVariable Long productId) {
try {
stockService.decreaseStockWithPessimisticLock(productId);
return ResultVO.success("库存扣减成功");
} catch (BusinessException e) {
return ResultVO.fail(e.getMessage());
} catch (Exception e) {
log.error("库存扣减异常", e);
return ResultVO.fail("系统异常,请稍后重试");
}
}
}Key notes:
The SELECT … FOR UPDATE must be executed inside a transaction; otherwise the lock is released immediately.
The query should use an indexed column (e.g., product_id) to avoid escalation to a table lock.
Lock release occurs automatically when the transaction commits or rolls back.
Implementation 2: Table lock (LOCK TABLE … WRITE)
@Service
public class StockService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private StockMapper stockMapper;
@Transactional(rollbackFor = Exception.class)
public void decreaseStockWithTableLock(Long productId) {
try {
// Acquire table lock
jdbcTemplate.execute("LOCK TABLE stock WRITE");
// Query stock
Stock stock = stockMapper.selectByProductId(productId);
if (stock == null || stock.getStock() <= 0) {
throw new BusinessException("库存不足");
}
// Decrease stock
int rows = stockMapper.decreaseStock(productId);
if (rows == 0) {
throw new BusinessException("库存扣减失败");
}
} finally {
// Release lock
jdbcTemplate.execute("UNLOCK TABLES");
}
}
}Drawback: locking the whole table severely degrades concurrency; many concurrent requests will be queued, leading to long response times or timeouts.
Implementation 3: Spring Data JPA @Lock
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT s FROM Stock s WHERE s.productId = :productId")
Optional<Stock> findByProductIdWithLock(@Param("productId") Long productId);
}
@Service
public class StockService {
@Autowired
private StockRepository stockRepository;
@Transactional(rollbackFor = Exception.class)
public void decreaseStockWithJpaLock(Long productId) {
Stock stock = stockRepository.findByProductIdWithLock(productId)
.orElseThrow(() -> new BusinessException("商品不存在"));
if (stock.getStock() <= 0) {
throw new BusinessException("库存不足");
}
stock.setStock(stock.getStock() - 1);
stockRepository.save(stock);
}
}Use PESSIMISTIC_READ for shared read locks when only reading.
Optimistic lock
Does not rely on database lock mechanisms. A version identifier (integer version or timestamp) is added to the row and checked before committing.
Advantages: no blocking, high throughput, simple implementation for read‑heavy workloads.
Disadvantages: possible ABA problem, retries when conflicts are frequent.
Implementation 1: Version column
-- Add version column
ALTER TABLE `stock` ADD COLUMN `version` int NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁用)';
public interface StockMapper {
Stock selectByProductId(@Param("productId") Long productId);
int decreaseStockWithVersion(@Param("productId") Long productId, @Param("version") Integer version);
}
<select id="selectByProductId" resultType="com.example.concurrency.entity.Stock">
SELECT id, product_id, stock, version FROM stock WHERE product_id = #{productId}
</select>
<update id="decreaseStockWithVersion">
UPDATE stock SET stock = stock - 1, version = version + 1
WHERE product_id = #{productId} AND stock > 0 AND version = #{version}
</update>
@Service
@Slf4j
public class StockService {
@Autowired
private StockMapper stockMapper;
@Transactional(rollbackFor = Exception.class)
public void decreaseStockWithOptimisticLock(Long productId) {
int retry = 3;
while (retry > 0) {
Stock stock = stockMapper.selectByProductId(productId);
if (stock == null || stock.getStock() <= 0) {
throw new BusinessException("库存不足");
}
int rows = stockMapper.decreaseStockWithVersion(productId, stock.getVersion());
if (rows > 0) {
log.info("库存扣减成功,商品ID:{},剩余库存:{},版本号:{}",
productId, stock.getStock() - 1, stock.getVersion() + 1);
return;
}
retry--;
log.warn("库存扣减失败,重试次数:{},商品ID:{}", retry, productId);
try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
throw new BusinessException("库存扣减失败,请稍后重试");
}
}Implementation 2: Timestamp column
<update id="decreaseStockWithTimestamp">
UPDATE stock SET stock = stock - 1, update_time = CURRENT_TIMESTAMP
WHERE product_id = #{productId} AND stock > 0 AND update_time = #{updateTime}
</update>Drawback: MySQL timestamps have second‑level precision; concurrent updates within the same second may be mis‑detected, making this approach unsuitable for high‑frequency scenarios.
ABA problem
Thread A reads stock=10, version=1. Thread B changes the row to stock=9, version=2 and then back to stock=10, version=3. When Thread A attempts its update, the data value matches the original read, but the row has been modified twice, which a simple version check cannot detect.
Solution 1: Combine version number with timestamp (double check).
Solution 2: Use an AtomicReference in memory for non‑database optimistic locks (rarely used in persistence layers).
Comparison of pessimistic vs optimistic locks
Design philosophy : pessimistic assumes conflicts will happen and locks early; optimistic assumes conflicts are rare and validates at commit.
Underlying dependency : pessimistic relies on database lock mechanisms (row/table lock); optimistic relies on manual version/timestamp fields.
Concurrency performance : pessimistic locks block other transactions, lowering throughput; optimistic locks allow concurrent execution and only incur retry cost on conflict.
Data safety : both provide high safety, but optimistic may suffer from ABA issues.
Implementation complexity : pessimistic is low (add FOR UPDATE); optimistic is medium (add version field and retry logic).
Lock granularity : pessimistic can be row or table; optimistic has no lock, per‑row version control.
Suitable scenarios : pessimistic for write‑heavy, high‑conflict cases (order placement, payment deduction); optimistic for read‑dominant, low‑conflict cases (product detail queries, occasional inventory updates).
Common issues : pessimistic may encounter deadlock, lock escalation, and blocking timeouts; optimistic may encounter ABA problems and retry failures under high contention.
Performance overhead : pessimistic incurs lock acquisition/release and thread‑switch overhead; optimistic incurs retry overhead when conflicts occur.
SpringBoot integration difficulty : pessimistic requires only SQL changes; optimistic requires schema changes and additional business logic.
Selection principle
Choose the lock strategy based on the write‑read ratio and the probability of concurrent conflicts: use pessimistic locks for write‑intensive, high‑conflict situations; use optimistic locks for read‑dominant, low‑conflict situations.
Conclusion
Multiple‑transaction concurrency control is essential for enterprise‑grade high‑concurrency projects. Transactions guarantee atomicity inside a single request, while locks guarantee consistency across concurrent requests. Neither can replace the other; they complement each other. Pessimistic locks provide safety at the cost of throughput, whereas optimistic locks offer higher performance but require version control and possible retry logic.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
