写点什么

JPA 乐观锁改悲观锁遇到的一些问题与思考

  • 2024-08-01
    福建
  • 本文字数:3226 字

    阅读完需:约 11 分钟

原理知识


JPA 的 @version 是通过在 SQL 语句上做手脚来实现乐观锁的

UPDATE table_name SET updated_column = new_value, version = new_version WHERE id = entity_id AND version = old_version
复制代码


这个"Compare And Set"操作必须放到数据库层,数据库层能够保证"Compare And Set"的原子性(update 语句的原子性)


如果这个"Compare And Set"操作放在应用层,则无法保证原子性,即可能 version 比较成功了,但等到实际更新的时候,数据库的 version 已被修改。


这时候就会出现错误修改的情况


需求


解决此类报错,让事务能够正常完成


处理——重试


既然是乐观锁报错,那就是修改冲突了,那就自动重试就好了


案例代码


修改前

@Servicepublic class ProductService {            @Autowired     private ProductRepository productRepository;
@Transactional public void updateProductPrice(Long productId, Double newPrice) { Product product = productRepository.findById(productId).orElseThrow(()->new RuntimeException("Product not found") product.setPrice(newPrice); productRepository.save(product); } }
复制代码


修改后


增加一个 withRetry 的方法,对于需要保证修改成功的地方(比如用户在 UI 页面上的操作),可以调用此方法。

@Servicepublic class ProductService {            @Autowired     private ProductRepository productRepository;
public void updateProductPriceWithRetry(Long productId, Double newPrice) { boolean updated = false; //一直重试直到成功 while(!updated) { try { updateProductPrice(productId, newPrice); updated = true; } catch (OpitimisticLockingFailureException e) {           System.out.println("updateProductPrice lock error, retrying...") } }    }
@Transactional public void updateProductPrice(Long productId, Double newPrice) { Product product = productRepository.findById(productId).orElseThrow(()->new RuntimeException("Product not found") product.setPrice(newPrice); productRepository.save(product); } }
复制代码


依赖乐观锁带来的问题——高并发带来高冲突


上面的重试能够解决乐观锁报错,并让业务操作能够正常完成。但是却加重了数据库的负担。


另外乐观锁也有自己的问题:


业务层将事务修改直接提交给数据库,让乐观锁机制保障数据一致性


这时候并发越高,修改的冲突就更多,就有更多的无效提交,数据库压力就越大


高冲突的应对方式——引入悲观锁


解决高冲突的方式,就是在业务层引入悲观锁。


在业务操作之前,先获得锁。


一方面减少提交到数据库的并发事务量,另一方面也能减少业务层的 CPU 开销(获得锁后才执行业务代码)

@Servicepublic class ProductService {            @Autowired     private ProductRepository productRepository;
public void someComplicateOperationWithLock(Object params) { //该业务涉及到的几个对象修改,需要获得该对象的锁 //key=类前缀+对象id List<String> keys = Arrays.asList(....); //RedisLockUtil为分布式锁,可自行封装(可基于redisson实现) //获得锁之后才开始执行任务代码,然后在任务执行结束释放锁 RedisLockUtil.runWithLock(keys, retryTime, retryLockTimeout, ()->someComplicateOperation(params)}): }
@Transactional public void someComplicateOperation(Object params) { ..... } }
复制代码


遇到的坑


正常在获得锁之后,需要重新加载最新的数据,这样修改的时候才不会冲突。(前一个锁获得者可能修改了数据)


但是,JPA 有持久化上下文,有一层缓存。如果在获得锁之前就将对象捞了出来,等获得锁之后重新捞还会得到缓存内的数据,而非数据库最新数据。


这样的话,即使用了悲观锁,事务提交的时候还是会出现冲突。


案例:

@Servicepublic class ProductService {            @Autowired     private ProductRepository productRepository;
public void someComplicateOperationWithLock(Object params) { //获得锁之前先查询了一次,此次查询数据将缓存在持久化上下文中 String productId = xxxx; Product product = productRepository.findById(productId).orElseThrow(()->throw new RuntimeException("Product not found")); //该业务涉及到的几个对象修改,需要获得该对象的锁 //key=类前缀+对象id List<String> keys = Arrays.asList(....); //RedisLockUtil为分布式锁,可自行封装 //获得锁之后才开始执行任务代码,然后在任务执行结束释放锁 RedisLockUtil.runWithLock(keys, retryTime, retryLockTimeout, ()->someComplicateOperation(params)}): }
@Transactional public void someComplicateOperation(Object params) { ..... //取到缓存内的旧数据 Product product = productRepository.findById(productId).orElseThrow(()->throw new RuntimeException("Product not found")); .... } }
复制代码


应对方式——refresh


在悲观锁范围内,首次加载 entity 数据的时候,使用 refresh 方法,强制从 DB 捞取最新数据。

@Servicepublic class ProductService {            @Autowired     private ProductRepository productRepository;
public void someComplicateOperationWithLock(Object params) { //获得锁之前先查询了一次,此次查询数据将缓存在持久化上下文中 String productId = xxxx; Product product = productRepository.findById(productId).orElseThrow(()->throw new RuntimeException("Product not found")); //该业务涉及到的几个对象修改,需要获得该对象的锁 //key=类前缀+对象id List<String> keys = Arrays.asList(....); //RedisLockUtil为分布式锁,可自行封装 //获得锁之后才开始执行任务代码,然后在任务执行结束释放锁 RedisLockUtil.runWithLock(keys, retryTime, retryLockTimeout, ()->someComplicateOperation(params)}): }
@Transactional public void someComplicateOperation(Object params) { ..... //取到缓存内的旧数据 Product product = productRepository.findById(productId).orElseThrow(()->throw new RuntimeException("Product not found")); //使用refresh方法,强制从数据库捞取最新数据,并更新到持久化上下文中 EntityManager entityManager = SpringUtil.getBean(EntityManager.class) product = entityManager.refresh(product); .... } }
复制代码


总结


此项目采用乐观锁+悲观锁混合方式,用悲观锁限制并发修改,用乐观锁做最基本的一致性保护。


关于一致性保护


对于一些简单的应用,写并发不高,事务+乐观锁就足够了


  • entity 里面加一个 @version 字段

  • 业务方法加上 @Transactional


这样代码最简单。


只有当写并发高的时候,或根据业务推断可能出现高并发写操作的时候,才需考虑引入悲观锁机制。 

(代码越复杂越容易出问题,越难维护)


文章转载自:猫毛·波拿巴

原文链接:https://www.cnblogs.com/longfurcat/p/18334599

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
JPA乐观锁改悲观锁遇到的一些问题与思考_数据库_不在线第一只蜗牛_InfoQ写作社区