别再只会加事务了:用 Java 从 0 构建高并发抢票系统,彻底吃透死锁与隔离级别
别再只会用 @Transactional:它并不能防并发问题
很多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。问题出在哪?其实不是代码语法错了,而是对数据库在并发下的行为理解得还不够透。
别再忽略 MVCC:你读到的可能是“过去的数据”
这里有个关键认知需要刷新:无论是PostgreSQL还是MySQL的InnoDB引擎,默认都使用MVCC(多版本并发控制)。这意味着什么?简单说,一个普通的SELECT语句默认是不加锁的,你读到的是事务开始时的某个“快照”,而不是数据库当前最新的状态。尤其在READ COMMITTED隔离级别下,每条语句看到的快照都可能不同。事务保证了原子性(要么全成功,要么全失败),但它可不保证你读到的数据是最新的。
并发问题复现

上图清晰地展示了并发场景下,两个事务如何因为读到旧的“可用”状态,导致超卖。你以为的“查询-判断-更新”安全流程,在并发下不堪一击。
别再忽略真正解决方案:悲观锁(行锁)
要解决这类“先查后改”的并发竞争,最直接有效的方法就是使用数据库的行级锁。思路很明确:在查询座位状态的那一刻,就直接把目标行锁住,让其他事务排队等待,从源头上杜绝冲突。
正确写法(JPA + SQL)
具体到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);
}
}
别再忽略三个关键细节(决定系统生死)
(1)必须排序加锁(否则必死锁)
注意代码里的.sorted()和SQL里的ORDER BY id。这可不是为了好看,而是避免死锁的生命线。死锁的本质就是多个事务以不同的顺序请求锁资源,形成了循环等待。强制所有事务都按相同的顺序(比如ID升序)加锁,就能从根本上打破这个循环。
图片
(2)优先使用 FOR NO KEY UPDATE
在PostgreSQL中,FOR UPDATE和FOR NO KEY UPDATE有细微但重要的区别。后者锁的粒度更小,它阻止其他事务修改该行,但允许其他事务以FOR KEY SHARE的方式读取(这通常不影响外键引用)。在“只修改状态字段,不修改主键或唯一索引字段”的场景下,使用FOR NO KEY UPDATE可以提高并发度。
(3)事务必须极短
锁的持有时间直接决定系统的吞吐量。因此,被@Transactional包裹的方法里,应该只包含最核心的数据库操作:加锁查询、业务校验、执行更新。像HTTP调用、RPC、支付接口这些耗时操作,务必在释放锁(即事务提交)之后再执行。否则,锁长时间不释放,系统很快就会陷入瓶颈。
别再只会悲观锁:乐观锁同样重要
悲观锁是“先锁再改”,适合冲突频繁的场景。但如果冲突不那么频繁,乐观锁“先改再验”的模式往往性能更高。
方式一:version 字段
利用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、性能极致。缺点是需要处理部分更新成功的情况,通常需要引入补偿逻辑(如释放已锁定的座位)。
别再混淆隔离级别:它解决的是另一类问题
隔离级别主要解决“读”的一致性问题,而不是“写”的并发冲突。
READ COMMITTED(默认)
每条语句看到的是最新已提交的数据。这可能导致“不可重复读”和“幻读”,在复杂的业务逻辑中产生错误。
REPEATABLE READ
整个事务期间看到的数据快照是一致的。它能防止同一行数据的更新冲突,但对于“幻读”(范围查询中新增的行),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减少连接开销。
事务精简:再次强调,事务内绝不进行外部调用。
别再写 Demo:一份可上线的 Ja va 实现
将上述所有要点整合,一个相对完整的服务层实现如下:
@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
);
说到底,大多数系统在高并发下崩溃,根源往往不是业务逻辑有多复杂,而是低估了并发的复杂性。我们容易陷入一个误区:以为事务能兜住所有底,但它其实只保证“同时失败”;以为数据库会自动处理好冲突,但它更多只是在忠实地记录冲突。构建真正可靠的高并发系统,其核心能力在于:即使面对混乱无序的竞争,也能通过严谨的设计,让最终结果保持绝对正确。
相关攻略
别再只会用 @Transactional:它并不能防并发问题 很多Ja va开发者遇到抢座、秒杀这类场景,第一反应就是祭出@Transactional注解。代码写出来大概长这样: public class ReservationService { @Transactional public void
配置 好消息是,小米大模型现已通过OpenRouter平台开放接入。更贴心的是,新用户能享受为期一周的免费体验,这个福利窗口将在北京时间4月2日24:00关闭。如果你想尝试,现在正是时候。 如果你恰好刚刚部署了最新版本的软件,那么在初始配置流程中,会看到QQ机器人、飞书以及OpenRouter的配置
交管12123网页版:一个资深车主的登录与使用手记 如果你还在满世界搜索“交管12123网页版怎么登录”,那可得听我一句:别费劲了,入口其实非常明确,就是 www 122 gov cn。不过话说回来,这网页版和咱们熟悉的独立网站不太一样,它更像是一个“PC端延伸”——你必须先用手机APP完成实名认证
一、通过铁路12306手机APP兑换 说到用积分换票,手机APP绝对是咱们最顺手、最常用的工具。整个流程自己就能搞定,特别方便,无论是给自己换还是给亲友换(当然,亲友得提前添加好并且生效才行)。系统会自动帮你检查积分够不够、受让人能不能用,以及车次支不支持兑换,基本上不用自己操心。 来,咱们一步步看
深度解析:AI生成文本的人性化润色之道 引言:当AI遇见“人味儿” 你手头有一篇AI生成的文章——逻辑清晰、信息准确,但读起来总觉得少了点什么。没错,缺的就是那股“人味儿”。在信息爆炸的今天,纯粹的工具性文本已经很难抓住读者的注意力。如何在不改变事实内核的前提下,让AI文字拥有资深专家的温度与质
热门专题
热门推荐
腾讯生态整合新动向:QQ全面接入微信小程序 7月1日,腾讯QQ小程序开发者平台发布了一项重要更新。核心内容是,为了帮助开发者降低双端开发与维护成本,QQ将全面接入微信小程序体系。这意味着,未来用户可以直接在QQ内搜索并打开微信小程序。 对于现有的存量QQ小程序,此次调整并未“一刀切”。它们目前仍可正
下半年芯片市场巅峰对决提前揭幕 今年下半年,全球芯片市场的战火将空前炽热。两位重量级选手——联发科与高通,已经准备好亮出各自的王牌。天玑9600系列与骁龙8E6系列,这两大迭代旗舰平台的正面交锋,注定会成为今年科技行业最值得关注的戏码。 双芯策略:精准卡位旗舰市场 有意思的是,联发科这次玩了个新花样
在当今数字化社交的时代,微信已成为人们日常沟通交流的重要工具。不少人都发现,微信好友申请居然可以通过搜索 qq 号来添加,这背后有着诸多有趣的原因和便利之处。 一、社交关系的延续与拓展 要知道,微信与QQ同属腾讯旗下,两者之间存在着千丝万缕的联系。很多用户的社交关系其实根植于QQ时代,那些好友列表里
高德地图如何更改定位?三种方法详解及注意事项 无论是日常通勤、外出旅行还是朋友相聚,高德地图已经成了我们依赖的“导航神器”,精准定位和路线规划是其核心功能。不过,现实场景有时会有点特殊——比如,你可能需要模拟一个位置来测试应用,或者在某个游戏中“签到”,又或者只是想和朋友开个无伤大雅的玩笑。这个时候
巧学宝App绑定手机号全程指南 在巧学宝App上完成手机号绑定,是解锁其完整功能的关键一步。这个看似简单的操作,能为你后续的学习之旅带来不少实实在在的便利。那么,该如何快速搞定呢?下面这张流程图,能帮你一眼看清完整的操作路径。 第一步:进入个人中心 首先,打开你的巧学宝App。进入主界面后,注意力可





