先说个真实世界的问题:超卖或超订难题

想象一下,你正在使用 Spring Boot 构建一个票务预订系统电子商务系统。假设:

  • • 你的库存中有 100 张演唱会门票或 50 件某个热门商品

  • • 你的应用程序已部署,并且有 1000 个用户正试图同时购买这些门票或商品(例如在618或者双十一秒杀活动等场景下)。

你可能写了一个类似这样的 REST 端点来进行购买操作:

@PostMapping("/purchase")
public void purchaseProduct(Long productId, int qty) {
    // 1. 读取产品信息(假设这是数据库操作)
    Product product = productRepository.findById(productId).orElseThrow(() -> new RuntimeException("产品不存在"));
    // 2. 检查库存
    if (product.getQuantity() >= qty) {
        // 3. 如果库存充足,则扣减库存
        product.setQuantity(product.getQuantity() - qty);
        productRepository.save(product); // 4. 保存更新后的产品信息
        System.out.println("用户 " + Thread.currentThread().getName() + " 购买成功 " + qty + " 件,剩余库存 " + product.getQuantity());
    } else {
        System.out.println("用户 " + Thread.currentThread().getName() + " 购买失败,库存不足,需要 " + qty + ",实际 " + product.getQuantity());
        throw new RuntimeException("库存不足");
    }
}

现在问题来了:
⚠️ 尽管上面的逻辑看起来似乎没什么问题,但在高并发情况下,两个或更多用户可能会在几乎同一时刻读取到产品库存(比如都读到还有10件),都判断库存充足,然后所有这些用户的请求都执行了扣减库存的操作(比如每个用户都尝试购买8件)。即使你在扣减前检查了可用库存,这依然会导致超订或超卖(比如卖出去了16件,但其实只有10件)。

发生这种情况是因为并发的读取和写入之间没有有效的同步机制 —— 从而导致了竞争条件 (race condition)


为什么乐观锁 (Optimistic Locking) 可能不够用?

你可能会尝试使用带有 @Version 注解的乐观锁机制,它在大多数并发不那么激烈的场景下表现良好。但是:

  • • 在高并发系统中,乐观锁会导致频繁的更新失败和重试

  • • 如果 500 个用户在 2 秒内同时请求更新同一份库存数据,几乎所有并发的更新尝试都会因为版本冲突而抛出 OptimisticLockException,迫使系统频繁重试这些操作,或者直接让大量用户操作失败。


解决方案:悲观锁 (PESSIMISTIC LOCKING)

这是什么?
悲观锁是一种并发控制策略,它假设在并发系统中冲突的发生是大概率事件。因此,当一个事务(线程)读取数据并准备更新时,它会立即在数据库层面锁定相关的行,从而阻止其他事务(线程)对这些行进行修改(某些情况下甚至阻止读取),直到当前事务完成。

你可以把它想象成你进入一个洗手间并锁上了门:在你出来之前,其他任何人都别想使用这个隔间。

它在什么情况下有用?

  • • 在那些数据一致性至关重要的系统中(例如,涉及资金、票务、库存管理等)。

  • • 在高并发场景下,当乐观锁导致重试频繁失败时。

  • • 当你完全无法承受竞争条件(哪怕是偶尔发生一次)所带来的后果时。


“为什么不用 Java 的 synchronized 或 ReentrantLock 呢?”
(尽早解答这个初学者常问的问题)

❓ 为什么不直接使用 Java 语言层面提供的 synchronized 关键字或 ReentrantLock 这些锁机制呢?
因为那些 Java 锁只在单个 JVM 内部有效。当你的应用程序为了扩展性而部署到多个实例或容器上时,JVM 级别的锁就无法跨实例同步了,依然会产生并发问题。你需要的是数据库级别的锁定机制,而悲观锁提供的正是这种能力。
(这澄清了初级开发者常见的一个困惑。)


Spring Boot 悲观锁 —— 一步步实现

让我们来构建它:

1. 实体类 (Entity Class)
首先,我们有一个简单的 Product 实体:

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
// Getters, Setters, Constructors 可以用 Lombok 简化

@Entity
public class Product {
    @Id
    private Long id;
    private String name;
    private int quantity; // 库存数量

    // 构造函数、getter 和 setter 此处省略
    // 请确保有无参构造函数供 JPA 使用,以及必要的 getter/setter
    public Product() {}

    public Product(Long id, String name, int quantity) {
        this.id = id;
        this.name = name;
        this.quantity = quantity;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
}

2. 使用锁注解的 Repository 接口

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
import org.springframework.data.repository.query.Param;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint; // 注意是 jakarta.persistence

public interface ProductRepository extends JpaRepository<Product, Long> {

    // 使用 @Lock 注解来指定悲观写锁
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    // 也可以直接在 @Query 中使用 "FOR UPDATE" (取决于数据库方言)
    // 但 @Lock 更符合 JPA 规范
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Product findByIdWithLock(@Param("id") Long id);

    // 如果需要为锁操作设置超时:
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
        // 设置JPA锁超时属性 (单位是毫秒)
        // "jakarta.persistence.lock.timeout" 是标准属性名 (JPA 2.1+)
        // "javax.persistence.lock.timeout" 是旧版JPA的属性名
        @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000") // 3 秒超时
    })
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Product findByIdWithLockAndTimeout(@Param("id") Long id);
}

@Lock(LockModeType.PESSIMISTIC_WRITE) 是这里的核心
这会让 JPA (Hibernate 作为实现) 在执行查询时,生成类似下面这样的 SQL(具体语法可能因数据库方言而略有不同):

SELECT * FROM product WHERE id = ? FOR UPDATE;

FOR UPDATE 子句会在数据库层面锁定被查询到的行,直到当前事务提交 (commit) 或回滚 (rollback)
其他试图更新或以写锁方式读取这些被锁定行的事务(线程)将会被阻塞 (blocked),即它们必须等待锁被释放后才能继续执行。

悲观锁可能会导致死锁 (deadlocks) 或长时间的等待。因此,为锁操作设置一个合理的超时时间非常重要。上面的第二个方法演示了如何使用 @QueryHints 在 JPA 中为悲观锁设置超时:
💡 这能确保如果锁在3秒内没有被成功获取,就会抛出一个异常(通常是 PessimisticLockException 或 LockTimeoutException)。这有助于避免线程被无限期挂起,从而提高系统的弹性和健壮性。

3. 优雅地处理锁超时异常
你需要捕获并优雅地处理可能因锁超时而抛出的 PessimisticLockException (或其父类 LockTimeoutException)。

示例如下 (通常在调用 Service 的地方,如 Controller 层):

// 在 Controller 或调用端
// try {
//     productService.purchaseProduct(productId, qty);
//     // return ResponseEntity.ok("购买成功");
// } catch (PessimisticLockException | LockTimeoutException ex) { // 捕获悲观锁或锁超时异常
//     // log.warn("获取产品锁超时或发生悲观锁冲突: {}", ex.getMessage());
//     // 优雅地处理,比如提示用户稍后再试,或内部进行有限次数的重试
//     throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "系统繁忙,请稍后再试"); // 返回 503
// } catch (RuntimeException ex) { // 处理其他业务异常,如库存不足
//     // log.error("购买失败: {}", ex.getMessage());
//     throw new ResponseStatusException(HttpStatus.BAD_REQUEST, ex.getMessage());
// }

解释:
⚠ 特别是在高流量的场景下,务必优雅地处理 PessimisticLockException 或 LockTimeoutException。避免将底层异常直接暴露给用户。

4. 服务层逻辑 (Service Layer Logic)

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; // 引入事务注解

@Service
public class ProductService {

    @Autowired
    private ProductRepository productRepository;

    // 整个方法标记为事务性的
    @Transactional
    public void purchaseProduct(Long productId, int qtyToPurchase) {
        // 1. 使用带锁的方法查询产品,此时数据库会对该行加锁
        Product product = productRepository.findByIdWithLockAndTimeout(productId);
        // 如果锁超时,这里会抛出 LockTimeoutException (或PessimisticLockException)

        if (product == null) { // 虽然 findByIdWithLock 通常配置为找不到则抛异常或返回null
            throw new RuntimeException("产品 " + productId + " 不存在");
        }

        // 2. 检查库存
        if (product.getQuantity() >= qtyToPurchase) {
            // 3. 扣减库存
            product.setQuantity(product.getQuantity() - qtyToPurchase);
            // 4. 保存产品
            // 在 @Transactional 方法中,JPA 实体状态的变更会在事务提交时自动同步到数据库,
            // 所以显式的 productRepository.save(product) 调用有时可以省略(取决于JPA配置和具体操作)。
            // 但为了明确起见,或者如果 product 是 new 出来的,通常还是会调用 save。
            // 此处假设 product 是从数据库加载的受管实体,修改后会自动更新。
            // productRepository.save(product); // 如果需要显式保存或触发乐观锁版本更新,则调用
            System.out.println(Thread.currentThread().getName() + ": 购买 " + qtyToPurchase + " 件成功,产品 " + productId + " 剩余库存 " + product.getQuantity());
        } else {
            System.out.println(Thread.currentThread().getName() + ": 购买 " + qtyToPurchase + " 件失败,产品 " + productId + " 库存不足,仅剩 " + product.getQuantity());
            throw new RuntimeException("产品 " + productId + " 库存不足");
        }
        // 5. 当此 @Transactional 方法结束(无异常抛出)时,事务会提交,数据库锁会自动释放。
        // 如果中途抛出未捕获的 RuntimeException (或其他配置为回滚的异常),事务会回滚,锁也会释放。
    }
}

将 purchaseProduct 方法标记为 @Transactional 能确保整个操作(查询、检查、更新)都发生在一个原子性的工作单元内。
数据库会持有这个行锁,直到包含 findByIdWithLockAndTimeout 调用的那个事务结束(即事务被提交或回滚)时,锁才会被释放。


幕后原理 —— 分步执行流程 (UNDER THE HOOD — STEP BY STEP EXECUTION)

当多个线程并发调用 purchaseProduct 方法时,后台大致会发生以下情况:

  1. 1. 线程1 调用 purchaseProduct(1L, 1) (假设产品ID为1,购买数量为1)。

  2. 2. productRepository.findByIdWithLockAndTimeout(1L) 执行,向数据库发送类似:
    SELECT * FROM product WHERE id = 1 FOR UPDATE; -- (并可能带有锁超时参数)
  3. 3. 数据库为 product 表中 id = 1 的行加上排他锁线程1 成功获取到锁

  4. 4. 线程1 继续执行,检查库存(假设当前库存为10)。库存充足。

  5. 5. 线程1 执行 product.setQuantity(9),将产品数量更新为9。

  6. 6. 与此同时,线程2 尝试调用 purchaseProduct(1L, 1)

  7. 7. productRepository.findByIdWithLockAndTimeout(1L) 在线程2中执行,也尝试向数据库发送:
    SELECT * FROM product WHERE id = 1 FOR UPDATE;
  8. 8. 但是,由于 id = 1 的行已经被线程1锁定,所以线程2 的这个查询会被阻塞——它必须等待线程1释放锁。

  9. 9. 线程1 的 @Transactional 方法执行完毕,事务提交。数据库释放了 id = 1 行上的锁。

  10. 10. 线程2 现在获取到了锁,它的 SELECT ... FOR UPDATE 查询得以继续执行,并读取到已被线程1更新后的数据(例如,库存数量为9)。

  11. 11. 线程2 基于更新后的库存(9件)安全地继续执行其购买逻辑。

通过这种方式,对共享资源(产品库存)的临界区代码的访问被串行化 (serializes) 了,从而解决了高并发场景下的数据不一致问题(如超卖)。

图片

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐