首页 游戏 软件 资讯 排行榜 专题
首页
业界动态
别再只会加事务了:用 Java 从 0 构建高并发抢票系统,彻底吃透死锁与隔离级别

别再只会加事务了:用 Java 从 0 构建高并发抢票系统,彻底吃透死锁与隔离级别

热心网友
76
转载
2026-04-22

别再只会用 @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 UPDATEFOR 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
);

说到底,大多数系统在高并发下崩溃,根源往往不是业务逻辑有多复杂,而是低估了并发的复杂性。我们容易陷入一个误区:以为事务能兜住所有底,但它其实只保证“同时失败”;以为数据库会自动处理好冲突,但它更多只是在忠实地记录冲突。构建真正可靠的高并发系统,其核心能力在于:即使面对混乱无序的竞争,也能通过严谨的设计,让最终结果保持绝对正确。

来源:https://www.51cto.com/article/841014.html
免责声明: 游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。

相关攻略

别再只会加事务了:用 Java 从 0 构建高并发抢票系统,彻底吃透死锁与隔离级别
业界动态
别再只会加事务了:用 Java 从 0 构建高并发抢票系统,彻底吃透死锁与隔离级别

别再只会用 @Transactional:它并不能防并发问题 很多Ja va开发者遇到抢座、秒杀这类场景,第一反应就是祭出@Transactional注解。代码写出来大概长这样: public class ReservationService { @Transactional public void

热心网友
04.22
openclaw接入小米大模型
AI
openclaw接入小米大模型

配置 好消息是,小米大模型现已通过OpenRouter平台开放接入。更贴心的是,新用户能享受为期一周的免费体验,这个福利窗口将在北京时间4月2日24:00关闭。如果你想尝试,现在正是时候。 如果你恰好刚刚部署了最新版本的软件,那么在初始配置流程中,会看到QQ机器人、飞书以及OpenRouter的配置

热心网友
04.22
交管12123网页版入口最新说明 交管12123官网在线登录方式
手机教程
交管12123网页版入口最新说明 交管12123官网在线登录方式

交管12123网页版:一个资深车主的登录与使用手记 如果你还在满世界搜索“交管12123网页版怎么登录”,那可得听我一句:别费劲了,入口其实非常明确,就是 www 122 gov cn。不过话说回来,这网页版和咱们熟悉的独立网站不太一样,它更像是一个“PC端延伸”——你必须先用手机APP完成实名认证

热心网友
04.21
铁路12306怎么用积分兑换车票 铁路12306积分兑换车票方法【指南】
手机教程
铁路12306怎么用积分兑换车票 铁路12306积分兑换车票方法【指南】

一、通过铁路12306手机APP兑换 说到用积分换票,手机APP绝对是咱们最顺手、最常用的工具。整个流程自己就能搞定,特别方便,无论是给自己换还是给亲友换(当然,亲友得提前添加好并且生效才行)。系统会自动帮你检查积分够不够、受让人能不能用,以及车次支不支持兑换,基本上不用自己操心。 来,咱们一步步看

热心网友
04.21
openclaw 安装memori插件
AI
openclaw 安装memori插件

深度解析:AI生成文本的人性化润色之道 引言:当AI遇见“人味儿” 你手头有一篇AI生成的文章——逻辑清晰、信息准确,但读起来总觉得少了点什么。没错,缺的就是那股“人味儿”。在信息爆炸的今天,纯粹的工具性文本已经很难抓住读者的注意力。如何在不改变事实内核的前提下,让AI文字拥有资深专家的温度与质

热心网友
04.21

最新APP

宝宝过生日
宝宝过生日
应用辅助 04-07
台球世界
台球世界
体育竞技 04-07
解绳子
解绳子
休闲益智 04-07
骑兵冲突
骑兵冲突
棋牌策略 04-07
三国真龙传
三国真龙传
角色扮演 04-07

热门推荐

腾讯:QQ 将全面接入微信小程序,建议开发者尽快迁移降低维护成本
手机教程
腾讯:QQ 将全面接入微信小程序,建议开发者尽快迁移降低维护成本

腾讯生态整合新动向:QQ全面接入微信小程序 7月1日,腾讯QQ小程序开发者平台发布了一项重要更新。核心内容是,为了帮助开发者降低双端开发与维护成本,QQ将全面接入微信小程序体系。这意味着,未来用户可以直接在QQ内搜索并打开微信小程序。 对于现有的存量QQ小程序,此次调整并未“一刀切”。它们目前仍可正

热心网友
04.22
天玑9600/9600 Pro双芯齐发:5GHz主频史无前例 硬刚高通骁龙8E6
手机教程
天玑9600/9600 Pro双芯齐发:5GHz主频史无前例 硬刚高通骁龙8E6

下半年芯片市场巅峰对决提前揭幕 今年下半年,全球芯片市场的战火将空前炽热。两位重量级选手——联发科与高通,已经准备好亮出各自的王牌。天玑9600系列与骁龙8E6系列,这两大迭代旗舰平台的正面交锋,注定会成为今年科技行业最值得关注的戏码。 双芯策略:精准卡位旗舰市场 有意思的是,联发科这次玩了个新花样

热心网友
04.22
微信好友申请为何能通过搜索qq号添加
手机教程
微信好友申请为何能通过搜索qq号添加

在当今数字化社交的时代,微信已成为人们日常沟通交流的重要工具。不少人都发现,微信好友申请居然可以通过搜索 qq 号来添加,这背后有着诸多有趣的原因和便利之处。 一、社交关系的延续与拓展 要知道,微信与QQ同属腾讯旗下,两者之间存在着千丝万缕的联系。很多用户的社交关系其实根植于QQ时代,那些好友列表里

热心网友
04.22
高德地图如何更改定位
手机教程
高德地图如何更改定位

高德地图如何更改定位?三种方法详解及注意事项 无论是日常通勤、外出旅行还是朋友相聚,高德地图已经成了我们依赖的“导航神器”,精准定位和路线规划是其核心功能。不过,现实场景有时会有点特殊——比如,你可能需要模拟一个位置来测试应用,或者在某个游戏中“签到”,又或者只是想和朋友开个无伤大雅的玩笑。这个时候

热心网友
04.22
巧学宝app如何绑定手机号
手机教程
巧学宝app如何绑定手机号

巧学宝App绑定手机号全程指南 在巧学宝App上完成手机号绑定,是解锁其完整功能的关键一步。这个看似简单的操作,能为你后续的学习之旅带来不少实实在在的便利。那么,该如何快速搞定呢?下面这张流程图,能帮你一眼看清完整的操作路径。 第一步:进入个人中心 首先,打开你的巧学宝App。进入主界面后,注意力可

热心网友
04.22