当前位置: 首页 > news >正文

java 乐观锁的实现和注意细节

文章目录

  • 1. 前言
    • 乐观锁 vs. 悲观锁:基本概念对比
    • 使用场景及优势简述
  • 2. 基于版本号的乐观锁实现
    • 代码示例
    • 注意事项
  • 3. 基于CAS机制的乐观锁实现
    • 核心思想
    • 代码示例
      • 关键点说明
  • 4. 框架中的乐观锁实践
    • MyBatis中基于版本号的乐观锁实现
      • 示例代码
    • JPA(Hibernate)中的乐观锁
      • @Version 注解关键点与底层原理
      • 示例代码
  • 5. 乐观锁使用中的注意细节
    • 并发冲突后的重试机制与失败处理
    • 事务管理中的注意事项
    • 数据持久化时的并发一致性保障
    • 适用场景的取舍:当冲突过于频繁时的风险分析
  • 实际应用中的问题
    • 是否可以用状态机(比如:待支付 支付中 支付完成 支付异常)作为版本号
    • mysql乐观锁是否一定需要和事务同时出现才有效果
    • 没有事务支持使用版本号逻辑 mysql乐观锁会出现什么问题

1. 前言

在现代并发编程中,如何有效处理数据争抢和共享资源的访问是一项很关键的问题。常用的两种策略是乐观锁和悲观锁,它们虽然目标相同——确保数据的一致性和完整性——但采用的方法却截然不同。

乐观锁 vs. 悲观锁:基本概念对比

  • 悲观锁

    • 思想:对每次操作前都假设可能发生数据冲突,因此在操作时先加锁,确保资源不会被其他线程/进程同时修改。
    • 特点:
      • 适用于写操作较多、数据冲突较频繁的场景。
      • 实现方式通常依赖数据库锁(如行锁、表锁)或使用Java中的synchronized关键字。
    • 类比:就像在办公室会议室中安排发言,每个人在发言前都得先预定会议室,确保不会有人同时使用。
  • 乐观锁

    • 思想:假设数据在大部分情况下不会发生冲突,因此直接进行操作,在提交更新时再进行版本验证(如基于版本号或CAS判断)。
    • 特点:
      • 适用于读取操作远多于写入操作,因为数据修改的冲突几率较低。
      • 不需要在每次操作前对数据进行加锁,性能一般更好,但在冲突时需要重试操作。
    • 类比:就像大家在共享一个白板上标注信息,尽管同时编辑的情况可能存在,但大多数人不会同时做出修改,当检测到冲突时大家回头进行调整。

使用场景及优势简述

  • 使用场景:

    • 数据库场景:
      • 悲观锁更适用于那些写操作频繁、业务逻辑要求严格的场景,如银行转账系统。
      • 乐观锁适合于读操作较多、写操作相对较少的场景,如商品库存查看、博客评论系统。
    • 内存并发场景:
      • 悲观锁用在要求线程安全且数据操作冲突率高的情况,可能采用ReentrantLocksynchronized
      • 乐观锁通过CAS等机制保证数据修改的正确性,适合于低冲突或容错性较好的系统设计。
  • 优势比较:

    • 乐观锁:
      • 提升性能:避免频繁加锁和解锁带来的性能损耗。
      • 高度并发:在多数操作不冲突的情况下,多线程能高效执行。
    • 悲观锁:
      • 数据安全:即使高并发情况下,也能确保数据绝对不会出现脏读或不一致的情况。
      • 实现简单:逻辑上比较直观,容易理解。

2. 基于版本号的乐观锁实现

在开发中,我们通常会在数据表中增加一个版本号字段(version)或者在内存中的共享对象设置一个版本属性。工作流程大致如下:

  1. 读取数据时,同时获取当前版本号。
  2. 在更新数据时,带上之前读取到的版本号作为条件,执行类似下面的更新操作:
    • SQL 更新语句示例(假设数据表中有一个version字段):
      UPDATE product
      SET stock = ?, version = version + 1
      WHERE id = ? AND version = ?
      
    • 如果更新成功,说明在读取到更新之间数据未被修改;如果没有更新数据(影响行数为0),说明中途数据被别人修改过,通常程序会捕获这种情况,再进行相应的重试或异常处理。

这种方式保证了在更新操作时,如果其他事务已经修改了数据(导致版本号发生变化),当前事务将无法继续更新,避免数据冲突。

代码示例

设计一个简单的仓储类,通过版本号检查更新库存信息:

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import javax.sql.DataSource;public class ProductRepository {private DataSource dataSource;// 构造函数注入数据源public ProductRepository(DataSource dataSource) {this.dataSource = dataSource;}/*** 获取指定产品的当前版本号*/public int getCurrentVersion(int productId) throws SQLException {String selectSQL = "SELECT version FROM product WHERE id = ?";try (Connection conn = dataSource.getConnection();PreparedStatement ps = conn.prepareStatement(selectSQL)) {ps.setInt(1, productId);try (ResultSet rs = ps.executeQuery()) {if (rs.next()) {return rs.getInt("version");} else {throw new SQLException("Product with id " + productId + " not found");}}}}/*** 更新产品的库存,并利用乐观锁实现数据一致性控制*/public boolean updateProductStock(int productId, int newStock, int currentVersion) throws SQLException {String updateSQL = "UPDATE product SET stock = ?, version = version + 1 WHERE id = ? AND version = ?";try (Connection conn = dataSource.getConnection();PreparedStatement ps = conn.prepareStatement(updateSQL)) {ps.setInt(1, newStock);ps.setInt(2, productId);ps.setInt(3, currentVersion);int affectedRows = ps.executeUpdate();return affectedRows > 0;}}
}

示例中,我们首先通过 getCurrentVersion 方法获取当前产品的版本号,然后在更新库存时带上该版本号作为条件。如果有其它事务在更新时修改了该记录(导致版本号已发生改变),则 updateProductStock 执行时不会更新任何记录,返回的 affectedRows 为 0,从而达到乐观锁控制数据并发冲突的目的。

下面是一个使用上述方法的简单示例:

public class Application {public static void main(String[] args) {// 注意:此处数据源的初始化逻辑需要根据具体环境配置DataSource dataSource = DataSourceFactory.getDataSource(); // 假设有一个工厂类提供 DataSourceProductRepository repository = new ProductRepository(dataSource);int productId = 1; // 假设操作id为1的产品try {// 读取产品当前版本号int currentVersion = repository.getCurrentVersion(productId);// 尝试更新库存boolean success = repository.updateProductStock(productId, 100, currentVersion);if (success) {System.out.println("更新成功,库存已调整,新版本号已同步。");} else {System.out.println("更新失败,数据可能已被其他操作修改,请准备重试操作。");}} catch (SQLException e) {e.printStackTrace();}}
}

注意事项

  • 数据版本一致性问题
    要确保所有对该数据的更新操作都使用版本号作为条件,否则可能破坏数据的一致性。如果遗漏了版本号条件,则可能出现部分操作修改成功、部分操作失败的情况,最终导致数据状态不符合预期。

  • 高并发下可能出现的冲突处理
    在高并发环境下,多线程或多事务可能同时读取相同的版本号并尝试更新,只有一个线程能够更新成功。一般需要在业务层面对失败的更新进行重试、记录日志或者返回给用户友好的消息。重试机制需要注意避免死循环或长时间等待,确保系统对并发冲突有有效的容错处理策略。

3. 基于CAS机制的乐观锁实现

核心思想

CAS是一种硬件级别的原子操作,在Java中常通过java.util.concurrent.atomic包来实现。CAS操作的基本步骤是:

  1. 读取内存中的某个变量的当前值。
  2. 与预期值(旧值)进行比较。
  3. 如果内存中的值与预期值相等,则将该值更新为新值;否则,说明在比较期间该变量已被其他线程修改,更新操作失败。

这种原子性操作通常被封装在循环内,不断重试直至更新成功。在Java中,示例如下:

import java.util.concurrent.atomic.AtomicInteger;public class OptimisticLockExample {// 初始化版本号为0private AtomicInteger version = new AtomicInteger(0);public boolean updateVersion() {int currentVersion = version.get();// 假设新版本号为旧值+1int newVersion = currentVersion + 1;// 尝试CAS操作return version.compareAndSet(currentVersion, newVersion);}public static void main(String[] args) {OptimisticLockExample example = new OptimisticLockExample();if (example.updateVersion()) {System.out.println("更新成功,当前版本为:" + example.version.get());} else {System.out.println("更新失败,需采取重试机制。");}}
}

在这个例子中:

  • 我们使用了AtomicInteger来存储版本号,实现原子性更新。
  • 调用compareAndSet方法:它会比较当前的值与预期的值,若相同则更新为新值,返回true;否则返回false,表示其他线程在此期间已做了修改。

通过CAS算法,我们可以在无需加锁的情况下实现对共享数据的安全更新,这正是乐观锁在高并发场景下能提高性能的关键点。

代码示例

下面给出一个简单的示例,演示如何使用AtomicInteger实现CAS机制来对一个共享变量做安全更新:

import java.util.concurrent.atomic.AtomicInteger;public class CASExample {// 使用AtomicInteger来管理共享变量private AtomicInteger counter = new AtomicInteger(0);/*** 利用CAS循环机制更新counter的值* @param newValue 期望设置的新值* @return true表示更新成功,false表示更新过程中遇到问题(在本示例中,永远会重试直到成功)*/public boolean updateCounter(int newValue) {while (true) {// 1. 获取当前值int currentValue = counter.get();// 2. 执行比较并尝试更新if (counter.compareAndSet(currentValue, newValue)) {// 如果当前值与期望值一致,则更新成功并退出System.out.println("更新成功:" + currentValue + " -> " + newValue);return true;} else {// 输出调试信息,说明竞争导致更新失败、正在重试System.out.println("CAS更新失败,当前值已变为:" + counter.get() + ",准备重试。");}// 可选:可以在此添加退避策略,避免长时间自旋带来的CPU飙高问题}}public static void main(String[] args) {CASExample example = new CASExample();// 模拟多线程下的并发更新情况Thread t1 = new Thread(() -> {example.updateCounter(1);});Thread t2 = new Thread(() -> {example.updateCounter(2);});t1.start();t2.start();}
}

关键点说明

  • 依赖包

    • 本示例依赖于 java.util.concurrent.atomic.AtomicInteger,无需额外的第三方包。
  • CAS循环机制

    • 在 updateCounter 方法中,通过一个无限循环不断尝试调用 compareAndSet。当操作失败(说明其他线程已经修改了该变量)时,会再次获取最新值并重试。
  • 自旋锁与性能问题

    • 如果多个线程竞争激烈,CAS循环可能会导致大量的自旋重试,进而消耗较多CPU资源。在实际生产环境中,可以加入退避策略(例如在每次失败后休眠一段动态调整的时间),以减少自旋带来的性能损耗。

4. 框架中的乐观锁实践

在实际开发中,很多持久化框架都内置了乐观锁实现的支持,下面介绍 MyBatis 和 JPA(Hibernate)中的实践方式。


MyBatis中基于版本号的乐观锁实现

MyBatis 通常通过在 Mapper XML 中编写带有版本号条件的更新语句来实现乐观锁。在使用时,需要注意以下配置点:

  1. 数据库表需要定义一个版本号字段(如 version)。
  2. 实体类对应数据库记录时,需要映射该 version 字段。
  3. 编写更新 SQL 时,应在 WHERE 条件中加入 version 字段的判断,即:
    • 更新时要求数据库记录的版本号与传入的版本一致,更新成功后版本号加一。
  4. 如果更新返回受影响行数为 0,则说明记录可能被其他事务修改,需要做相应的处理,比如重试或告警。

示例代码

假设有一个 Product 实体,其中包含 id、stock、version 等字段。下面是 MyBatis 的 Mapper XML 配置示例:

– ProductMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapperPUBLIC "-//mybatis.org//DTD Mapper 3.0//EN""http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.mapper.ProductMapper"><!-- 根据ID查询产品(包含版本号) --><select id="selectProductById" resultType="com.example.entity.Product">SELECT id, stock, versionFROM productWHERE id = #{id}</select><!-- 基于版本号的乐观锁更新 --><update id="updateProductStock" parameterType="com.example.entity.Product">UPDATE productSET stock = #{stock},version = version + 1WHERE id = #{id} AND version = #{version}</update></mapper>

在 Java 中调用时,可以写一个 Service 层方法:

package com.example.service;import com.example.entity.Product;
import com.example.mapper.ProductMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;@Service
public class ProductService {@Autowiredprivate ProductMapper productMapper;@Transactionalpublic boolean updateStock(int productId, int newStock) {// 查询当前记录Product product = productMapper.selectProductById(productId);if (product == null) {throw new RuntimeException("产品不存在");}// 设置新的库存值product.setStock(newStock);// 执行更新;更新返回值为受影响行数int affectedRows = productMapper.updateProductStock(product);return affectedRows > 0;}
}

这样在更新时,如果其它事务已经修改了记录(导致版本号不一致),则本次更新不会影响到任何记录,服务层可据此判断是否需要重试或返回错误信息。


JPA(Hibernate)中的乐观锁

JPA 提供了内置的乐观锁支持,开发人员只需在实体中通过注解 @Version 标记一个版本字段即可。Hibernate 在底层自动通过 SQL’s UPDATE 的 WHERE 子句检查版本号,从而实现乐观锁机制。

@Version 注解关键点与底层原理

  1. 定义版本号字段

    • 在实体类中添加一个字段,比如 Integer、Long 或 Timestamp 类型,并加上 @Version 注解。
    • 当实体被更新时,Hibernate 会在更新语句中加入类似 “WHERE id = ? AND version = ?” 的判断,如果版本不匹配,则更新返回 0 行数据,进而抛出 OptimisticLockException 异常。
  2. 底层原理

    • 每次更新操作前,Hibernate 读取实体当前的版本号。
    • 执行更新时,将版本号作为条件,如果数据库记录中的版本与传入一致,则更新成功;同时 Hibernate 会自动将版本号递增。
    • 如果更新失败,说明数据版本与预期不符,通常会抛出异常,提示乐观锁冲突。

示例代码

下面是一个简单的 JPA 实体示例:

package com.example.entity;import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Version;
import javax.persistence.Table;@Entity
@Table(name = "product")
public class Product {@Idprivate Integer id;private Integer stock;// 使用@Version注解标记版本字段@Versionprivate Integer version;// getter和setter省略public Integer getId() {return id;}public void setId(Integer id) {this.id = id;}public Integer getStock() {return stock;}public void setStock(Integer stock) {this.stock = stock;}public Integer getVersion() {return version;}public void setVersion(Integer version) {this.version = version;}
}

在 Spring Data JPA 或纯 JPA 中,只需正常调用 save 方法即可,底层会自动管理版本号更新:

package com.example.service;import com.example.entity.Product;
import com.example.repository.ProductRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;@Service
public class ProductService {@Autowiredprivate ProductRepository productRepository;@Transactionalpublic void updateProductStock(Integer productId, Integer newStock) {Product product = productRepository.findById(productId).orElseThrow(() -> new RuntimeException("产品不存在"));product.setStock(newStock);// 调用save方法时,Hibernate会生成:// UPDATE product SET stock = ?, version = version + 1 WHERE id = ? AND version = ?productRepository.save(product);}
}

如果在并发环境下存在版本冲突,Hibernate 会抛出 OptimisticLockException,这时可以捕获异常,并进行相应的重试或错误提示处理。

5. 乐观锁使用中的注意细节

在使用乐观锁时,开发者需要注意多个细节以保障系统的数据一致性、性能和用户体验。下面对几个关键点进行详细说明:


并发冲突后的重试机制与失败处理

  1. 重试机制设计:

    • 当乐观锁检测到版本不匹配时,通常需要在业务层实现重试逻辑。重试机制可以是简单的固定次数重试,也可以采用指数退避(Exponential Back-off)机制,来避免频繁并发写操作时的性能瓶颈。
    • 重试次数不应过多,否则可能对系统性能和响应时间产生负面影响;一般会定义一定的最大重试次数,当超过这个次数,应该认为当前操作失败并反馈给用户或上层调用者。
  2. 失败处理:

    • 如果重试多次后仍不能成功更新数据,则应捕捉到乐观锁异常,并根据业务逻辑决定是否需要回滚、通知用户或记录日志用于后续分析。
    • 在一些业务场景中,可能允许部分更新失败以确保系统能继续运转,此时就需要对失败结果进行合理的补偿或告警机制。

事务管理中的注意事项

  1. 事务边界:

    • 乐观锁通常依托于事务机制来确保数据在一次完整操作中的一致性,因此务必保证更新操作在同一事务内完成。
    • 应确保查询、业务逻辑判断和更新操作处在同一事务内,避免在事务提交前数据已经被其他线程修改。
  2. 隔离级别:

    • 虽然乐观锁利用版本号机制避免并发冲突,但事务的隔离级别依然起到关键作用。例如,在读已提交(Read Committed)或可重复读(Repeatable Read)的级别下,能较好地配合乐观锁,而在更低的隔离级别下,可能会导致不可预期的问题。
    • 设计事务时要充分考虑并发冲突与数据脏读之间的平衡,必要时调优隔离级别或明确设置事务传播行为,确保数据正确更新。

数据持久化时的并发一致性保障

  1. 数据一致性策略:

    • 乐观锁依靠版本号确保操作前后数据的一致性。当版本号不匹配时,更新操作被拒绝,从而避免数据覆盖、丢失等问题。
    • 在持久化层面,要确保版本字段正确映射到数据库,并在数据更新时递增版本号,这样才能有效地防止并发更新冲突。
  2. 并发一致性保障:

    • 应该考虑到分布式环境中数据复制、缓存失效等可能影响一致性的问题,可能需要结合分布式事务或缓存失效策略一起使用。
    • 对于读取操作,如果需要更高的一致性保证,可能需要配合脏检查操作或在适当的场景中禁用缓存。

适用场景的取舍:当冲突过于频繁时的风险分析

  1. 乐观锁的适用场景:

    • 适合读操作多、写操作少的场景,因为乐观锁并不像悲观锁那样在数据访问前就加锁,而是在更新时检查版本号。
    • 在用户操作较分散、冲突概率较低的环境(例如电商系统中的库存更新)尤为适用。
  2. 冲突过于频繁的风险:

    • 如果并发写操作非常频繁,乐观锁会经常检测到版本冲突,从而触发大量重试或失败处理,这会导致系统响应变慢、资源耗损(如CPU占用增高)以及用户体验下降。
    • 此时,需要评估是否采用悲观锁机制、数据分片或引入其他协调机制来降低冲突概率,或者通过调优重试策略和失败处理方式来适应高并发场景。
  3. 风险取舍:

    • 当冲突频繁发生时,系统应根据业务特点在一致性、可用性和响应速度之间做出权衡。一方面,严格的数据一致性是保证业务正确运行的前提;另一方面,过高的并发冲突率会导致系统瓶颈。
    • 此外,还可以考虑将部分写操作改为幂等设计,利用消息队列或事件驱动架构来缓解直接高并发写入数据库造成的冲突。

实际应用中的问题

是否可以用状态机(比如:待支付 支付中 支付完成 支付异常)作为版本号

不建议使用状态机中的业务状态(如“待支付”、“支付中”、“支付完成”、“支付异常”)来充当乐观锁的版本号。原因主要如下:

  1. 分离关注点原则

    • 状态机中的状态主要描述业务流程和状态转变,而版本号主要用于并发控制和数据一致性验证。混用两者容易导致职责不清,降低代码的可维护性和理解度。
  2. 单调性与可靠性

    • 乐观锁依赖版本号具有单调递增或连续变化的特性,以便在每次更新时能够准确检测到数据是否在一段时间内被其他事务修改。业务状态通常是离散且有限的,不具备严格的单调性,有可能出现多次转换到同一状态的情况,从而无法作为有效的版本控制标识。
  3. 易引入风险

    • 使用业务状态作为版本号,在进行业务流程设计时可能会存在状态合理性校验和版本冲突检测混在一起的问题。如果业务状态发生错误(例如业务流程状态转换本身设计不当或出现异常),就可能误判冲突,甚至引发数据更新错误。
  4. 异常处理和可扩展性

    • 乐观锁中的版本号通常是由数据库或框架自动管理的,不容易人为干预。而业务状态需要根据业务逻辑进行转换,如果重试或错误处理逻辑和版本号机制混淆后,会使得系统异常处理更加复杂,并增加未来扩展或变更时出错的风险。

mysql乐观锁是否一定需要和事务同时出现才有效果

乐观锁本质上是一种数据版本控制的机制,用于检测并发更新时数据是否发生变化。它通常依赖于数据库的原子更新操作,而事务正是保证这类更新操作原子性的一种手段。

  1. 如果没有事务支持,每个更新操作可能无法在单一的、原子性的环境中完成,导致在多线程或多进程并发场景下数据状态难以准确检测和更新。一旦版本检测和更新操作不是原子执行,其他并发操作可能会介入修改,从而破坏乐观锁的检测机制。

  2. 在实际的 ORM(如JPA/Hibernate)框架中,乐观锁往往依赖于事务作为基本单元,只有在事务边界内才能确保版本号检查和更新操作的原子性,从而有效避免数据冲突。

没有事务支持使用版本号逻辑 mysql乐观锁会出现什么问题

下面给出一个简单的示例,说明如果没有事务支持时如何导致乐观锁版本检查失效,从而引发并发更新数据不一致的问题。假设有个订单实体 Order,属性中包含 version 字段用以进行乐观锁检查,下面代码模拟两个线程并发对同一个订单进行修改的情况。

【问题场景说明】

  1. 初始订单数据:
    • id = 1
    • status = “待支付”
    • version = 1

  2. 同时有两个线程(线程A、线程B)同时查询到该订单(均获得 version=1)。

  3. 两个线程开始各自的业务处理:
    • 线程A处理完业务后,检查订单版本为1,准备更新订单状态为"支付中",并将版本号更新为2。
    • 线程B处理完业务后,同样检查订单版本为1,准备更新订单状态为"支付完成",并将版本号更新为2。

  4. 如果没有事务的支持,这两个操作往往不在原子操作中完成,可能出现如下的 race condition:
    • 线程A更新增加 version 后,线程B由于仍检测到 version 为1(或由于两个线程并发,互相干扰),导致两次更新并发写入,最坏情况,线程B覆盖了线程A的更新,导致数据“脏写”。

相关文章:

  • Linux系统的CentOS7发行版安装MySQL80
  • 【笔记】结合 Conda任意创建和配置不同 Python 版本的双轨隔离的 Poetry 虚拟环境
  • 2025HNCTF - Crypto
  • 模块缝合-把A模块换成B模块(没写完)
  • 从零开始学Flink:揭开实时计算的神秘面纱
  • Spring Boot + Flink + FlinkCDC 实现 MySQL 同步到 MySQL
  • 浏览器兼容-polyfill-本地服务-优化
  • 解决transformers.adapters import AdapterConfig 报错的问题
  • Flink CDC 中 StartupOptions 模式详解
  • Flink CDC —部署模式
  • 分布式锁实战:Redisson vs. Redis 原生指令的性能对比
  • UDP 与 TCP 的区别是什么?
  • Cilium动手实验室: 精通之旅---12.Cilium Egress Gateway - Lab
  • Linux Docker的简介
  • 基于Python学习《Head First设计模式》第九章 迭代器和组合模式
  • K8S认证|CKS题库+答案| 7. Dockerfile 检测
  • SpringCloud2025+SpringBoot3.5.0+gateway+webflux子服务路由报503
  • Linux知识回顾总结----进程状态
  • 湖北理元理律师事务所实务手记:个人债务管理的理性突围
  • Java线程工厂:定制线程的利器
  • 上海做网站建设/郑州网站推广公司
  • 网站运行费用预算/谷歌搜索为什么用不了
  • 郑州金水区网站建设/百度云账号登录
  • 品牌网站建设策划/最新天气预报最新消息
  • 山东中恒建设集团网站/公司网站建设哪个好
  • 核酸第三方检测机构/南宁seo手段