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

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

热心网友 时间: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。

同类文章
更多
什么是RPA?为什么用RPA?RPA如何工作?

什么是RPA?为什么用RPA?RPA如何工作?

什么是RPA 简单来说,RPA是一种在商业逻辑与规则控制下,用来精简和优化流程的自动化系统。我们常把它比作一位不知疲倦的“数字员工”,专门用来高效处理那些重复性强、规则明确的任务。想一想后台办公室的场景:许多具备平均知识水平的员工,每天不得不花费大量时间在冗长、乏味且令人厌倦的例行程序上。RPA工具

时间:2026-04-22 22:40
不破不立,让RPA像Excel一样方便易用

不破不立,让RPA像Excel一样方便易用

RPA:从“专家可用”到“人人可用”,一道亟待跨越的鸿沟 提到RPA(机器人流程自动化),很多人的第一印象是“非侵入式”和“高效”。确实,这项技术能在不改造原有系统的前提下,为企业实现流程自动化,单凭这一点就赢得了大量青睐。但它的魅力远不止于此。 它的可扩展性和灵活性,让它能够适配千行百业的数字化转

时间:2026-04-22 22:40
RPA技术在营销业务中的应用案例

RPA技术在营销业务中的应用案例

RPA技术在营销业务中的应用案例 (1)智能停电全流程机器人 公变用户的停电流程,过去是个典型的“磨人”活。每天要重复登录好几个系统,处理异常派单,还得不停地和现场人员电话沟通,手动核对、搜索各种信息。这一套组合拳打下来,不仅耗费大量人力,更头疼的是,一旦遇到人员流动或者手一抖出了操作误差,公变停电

时间:2026-04-22 22:40
RPA技术的概念、优势和技术架构

RPA技术的概念、优势和技术架构

概念 说起机器人流程自动化(RPA),它其实是一种利用“软件机器人”来代劳那些高度重复性工作的技术。简单理解,它就是在你电脑里运行的一个程序,或者说一个虚拟的“数字员工”。它的核心任务,就是模拟人类与计算机的交互方式,把那些繁琐、复杂又量大的事务性工作承接过来,从而在降低人力成本的同时,大幅提升整体

时间:2026-04-22 22:39
基于RPA的财务共享服务中心资金管理系统框架

基于RPA的财务共享服务中心资金管理系统框架

(一)RPA是什么 RPA,也就是机器人流程自动化,是近年来在人工智能浪潮下兴起的一门自动化技术。简单说,它就像一个不知疲倦的“数字员工”,能够通过预设好的程序,模拟并执行我们人类在电脑上的各种操作。无论是登录系统、复制粘贴数据,还是核对报表,它都能一丝不苟地完成。 它的优势非常突出:可以按照设定7

时间:2026-04-22 22:39
热门专题
更多
刀塔传奇破解版无限钻石下载大全 刀塔传奇破解版无限钻石下载大全
洛克王国正式正版手游下载安装大全 洛克王国正式正版手游下载安装大全
思美人手游下载专区 思美人手游下载专区
好玩的阿拉德之怒游戏下载合集 好玩的阿拉德之怒游戏下载合集
不思议迷宫手游下载合集 不思议迷宫手游下载合集
百宝袋汉化组游戏最新合集 百宝袋汉化组游戏最新合集
jsk游戏合集30款游戏大全 jsk游戏合集30款游戏大全
宾果消消消原版下载大全 宾果消消消原版下载大全
  • 日榜
  • 周榜
  • 月榜
热门教程
更多
  • 游戏攻略
  • 安卓教程
  • 苹果教程
  • 电脑教程