别再只会用 @Transactional:它并不能防并发问题 很多Ja va开发者遇到抢座、秒杀这类场景,第
很多Ja va开发者遇到抢座、秒杀这类场景,第一反应就是祭出@Transactional注解。代码写出来大概长这样:
免费影视、动漫、音乐、游戏、小说资源长期稳定更新! 👉 点此立即查看 👈
public class ReservationService {
@Transactional
public void reserve(List seatIds, Long userId) {
List seats = seatRepository.findAllById(seatIds);
for (Seat seat : seats) {
if (!"a vailable".equals(seat.getStatus())) {
throw new RuntimeException("Seat not a vailable");
}
}
for (Seat seat : seats) {
seat.setStatus("reserved");
seat.setReservedBy(userId);
seat.setReservedUntil(LocalDateTime.now().plusMinutes(10));
}
seatRepository.sa veAll(seats);
}
}
单线程测试,一切完美。可一旦上线,面对30万用户同时刷新页面抢8万个座位的真实场景,问题就全暴露了:同一个座位被卖了两次、接口超时、日志里刷屏的deadlock detected。问题出在哪?其实不是代码语法错了,而是对数据库在并发下的行为理解得还不够透。
这里有个关键认知需要刷新:无论是PostgreSQL还是MySQL的InnoDB引擎,默认都使用MVCC(多版本并发控制)。这意味着什么?简单说,一个普通的SELECT语句默认是不加锁的,你读到的是事务开始时的某个“快照”,而不是数据库当前最新的状态。尤其在READ COMMITTED隔离级别下,每条语句看到的快照都可能不同。事务保证了原子性(要么全成功,要么全失败),但它可不保证你读到的数据是最新的。

上图清晰地展示了并发场景下,两个事务如何因为读到旧的“可用”状态,导致超卖。你以为的“查询-判断-更新”安全流程,在并发下不堪一击。
要解决这类“先查后改”的并发竞争,最直接有效的方法就是使用数据库的行级锁。思路很明确:在查询座位状态的那一刻,就直接把目标行锁住,让其他事务排队等待,从源头上杜绝冲突。
具体到Ja va(Spring Data JPA)中,可以这样实现:
@Repository
public interface SeatRepository extends JpaRepository {
@Query(value = """
SELECT * FROM seats
WHERE id IN (:ids)
ORDER BY id
FOR NO KEY UPDATE
""", nativeQuery = true)
List lockSeats(@Param("ids") List ids);
}
@Service
public class ReservationService {
private static final int HOLD_MINUTES = 10;
@Transactional
public void reserve(List seatIds, Long userId) {
List sortedIds = seatIds.stream()
.distinct()
.sorted()
.toList();
List seats = seatRepository.lockSeats(sortedIds);
if (seats.size() != sortedIds.size()) {
throw new RuntimeException("Seat not found");
}
for (Seat seat : seats) {
if (!"a vailable".equals(seat.getStatus())) {
throw new RuntimeException("Seat already taken");
}
}
LocalDateTime expireTime = LocalDateTime.now().plusMinutes(HOLD_MINUTES);
seatRepository.batchUpdateReserve(sortedIds, userId, expireTime);
}
}
注意代码里的.sorted()和SQL里的ORDER BY id。这可不是为了好看,而是避免死锁的生命线。死锁的本质就是多个事务以不同的顺序请求锁资源,形成了循环等待。强制所有事务都按相同的顺序(比如ID升序)加锁,就能从根本上打破这个循环。
图片
在PostgreSQL中,FOR UPDATE和FOR NO KEY UPDATE有细微但重要的区别。后者锁的粒度更小,它阻止其他事务修改该行,但允许其他事务以FOR KEY SHARE的方式读取(这通常不影响外键引用)。在“只修改状态字段,不修改主键或唯一索引字段”的场景下,使用FOR NO KEY UPDATE可以提高并发度。
锁的持有时间直接决定系统的吞吐量。因此,被@Transactional包裹的方法里,应该只包含最核心的数据库操作:加锁查询、业务校验、执行更新。像HTTP调用、RPC、支付接口这些耗时操作,务必在释放锁(即事务提交)之后再执行。否则,锁长时间不释放,系统很快就会陷入瓶颈。
悲观锁是“先锁再改”,适合冲突频繁的场景。但如果冲突不那么频繁,乐观锁“先改再验”的模式往往性能更高。
利用JPA的@Version注解实现乐观锁:
@Entity
public class Seat {
@Id
private Long id;
@Version
private Integer version;
private String status;
}
// 更新时
seat.setStatus("reserved");
seatRepository.sa ve(seat); // JPA会自动在UPDATE语句中带上 version = ? 条件
如果更新时发现版本号对不上,JPA会抛出OptimisticLockException,这时业务层进行重试或提示即可。
直接使用一条UPDATE语句,以状态作为更新条件,这是性能最高的方式:
@Modifying
@Query("""
UPDATE Seat s SET s.status = 'reserved',
s.reservedBy = :userId,
s.reservedUntil = :expireTime
WHERE s.id IN :ids AND s.status = 'a vailable'
""")
int updateA vailableSeats(...);
业务逻辑判断:
int updated = repository.updateA vailableSeats(ids, userId, expireTime);
if (updated != ids.size()) {
throw new RuntimeException("部分座位已被占用");
}
这种方式优点是无锁、单条SQL、性能极致。缺点是需要处理部分更新成功的情况,通常需要引入补偿逻辑(如释放已锁定的座位)。
隔离级别主要解决“读”的一致性问题,而不是“写”的并发冲突。
每条语句看到的是最新已提交的数据。这可能导致“不可重复读”和“幻读”,在复杂的业务逻辑中产生错误。
整个事务期间看到的数据快照是一致的。它能防止同一行数据的更新冲突,但对于“幻读”(范围查询中新增的行),InnoDB通过间隙锁在一定程度上解决,PostgreSQL则可能无法完全避免。
将隔离级别设为SERIALIZABLE是最严格的,它通过强制事务串行化执行来避免所有并发问题,但代价是性能最低,且事务可能因冲突而回滚,必须配套重试机制。
@Transactional(isolation = Isolation.SERIALIZABLE)
一个典型的错误写法是:
SELECT * FROM seats WHERE id IN (...) FOR UPDATE
问题在于,数据库对IN (...)子句中的ID加锁顺序是不确定的。如果两个事务传入的ID列表顺序不同,就极有可能形成死锁。正确的写法前面已经强调:务必加上ORDER BY id。
SELECT * FROM seats WHERE id IN (...) ORDER BY id FOR NO KEY UPDATE

数据库锁只是最后一道防线。一个健壮的高并发系统,需要多层架构共同保障:
读写分离:将查询流量导向只读副本,减轻主库压力。
Redis限流:在网关或应用层对用户请求进行限流和排队,避免流量洪峰直接冲击数据库。
连接池优化:使用HikariCP等高效连接池,对于PostgreSQL可配合PgBouncer减少连接开销。
事务精简:再次强调,事务内绝不进行外部调用。
将上述所有要点整合,一个相对完整的服务层实现如下:
@Service
public class ReservationService {
private static final int MAX_SEATS = 6;
private static final int HOLD_MINUTES = 10;
@Transactional
public ReservationResponse reserve(List seatIds, Long userId) {
List ids = seatIds.stream()
.distinct()
.sorted()
.toList();
if (ids.isEmpty() || ids.size() > MAX_SEATS) {
throw new IllegalArgumentException("Invalid seat count");
}
List seats = seatRepository.lockSeats(ids);
if (seats.size() != ids.size()) {
throw new RuntimeException("Seat not found");
}
for (Seat seat : seats) {
if (!"a vailable".equals(seat.getStatus())) {
throw new RuntimeException("Seat already taken");
}
}
LocalDateTime expire = LocalDateTime.now().plusMinutes(HOLD_MINUTES);
seatRepository.batchUpdateReserve(ids, userId, expire);
return new ReservationResponse(ids, expire);
}
}
清晰的代码组织有助于长期维护:
/src/main/ja va/com/icoderoad/
reservation/ # 应用服务层
domain/ # 领域模型与仓储接口
infrastructure/# 基础设施(持久化实现等)
对应的表结构建议:
CREATE TABLE seats (
id BIGSERIAL PRIMARY KEY,
event_id BIGINT NOT NULL,
section VARCHAR(10),
row_label VARCHAR(5),
number INT,
status VARCHAR(20) DEFAULT 'a vailable',
reserved_by BIGINT,
reserved_until TIMESTAMP,
version INT DEFAULT 0
);
说到底,大多数系统在高并发下崩溃,根源往往不是业务逻辑有多复杂,而是低估了并发的复杂性。我们容易陷入一个误区:以为事务能兜住所有底,但它其实只保证“同时失败”;以为数据库会自动处理好冲突,但它更多只是在忠实地记录冲突。构建真正可靠的高并发系统,其核心能力在于:即使面对混乱无序的竞争,也能通过严谨的设计,让最终结果保持绝对正确。
菜鸟下载发布此文仅为传递信息,不代表菜鸟下载认同其观点或证实其描述。