springboot 基于数据库的乐观锁实现

何谓悲观锁与乐观锁

  • 悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

  • 乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

两种锁的使用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

乐观锁常见的两种实现方式

  1. 版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

举一个简单的例子:

更新帐号余额,有如下表:


springboot 基于数据库的乐观锁实现


springboot 基于数据库的乐观锁实现

线程1:扣款操作,读取id为100的用户,当前版本为1;

线程2:充值操作,读取id为100的用户,当前版本为1;

线程2:执行更新操作,update t_account set money = money + 10,version=version+1 where id = 100 and version = 1。执行成功此时数据库中id为100的帐号信息如下:


springboot 基于数据库的乐观锁实现

此时当前账户的version已经被更新成2了。

线程1:执行更新操作,update t_account set money = money - 10,version = version + 1 where id= 1,执行失败了。当线程1在执行此更新操作的时候version字段已经变成了2,所以更新失败了。

通过这种机制来保证了数据的安全。

  1. CAS算法

compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 进行比较的值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试

  • 乐观锁的缺点

ABA 问题是乐观锁一个常见的问题

  • ABA 问题

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。

JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。


接下来通过基于数据库版本号的方式来实现乐观锁。

环境:springboot2.3.6.RELEASE + spring data jpa

  • 配置

pom.xml依赖包

<dependency> 			<groupId>org.springframework.boot</groupId> 			<artifactId>spring-boot-starter-data-jpa</artifactId> 		</dependency> 		<dependency> 			<groupId>org.springframework.boot</groupId> 			<artifactId>spring-boot-starter-web</artifactId> 		</dependency> 		<dependency> 			<groupId>org.mybatis.spring.boot</groupId> 			<artifactId>mybatis-spring-boot-starter</artifactId> 			<version>2.1.4</version> 		</dependency>  		<dependency> 			<groupId>mysql</groupId> 			<artifactId>mysql-connector-java</artifactId> 			<scope>runtime</scope> 		</dependency>

application.yml

spring:   datasource:     driverClassName: com.mysql.cj.jdbc.Driver     url: jdbc:mysql://localhost:3306/testjpa?serverTimezone=GMT%2B8     username: root     password: xxxxxx     type: com.zaxxer.hikari.HikariDataSource     hikari:       minimumIdle: 10       maximumPoolSize: 200       autoCommit: true       idleTimeout: 30000       poolName: MasterDatabookHikariCP       maxLifetime: 1800000       connectionTimeout: 30000       connectionTestQuery: SELECT 1 --- spring:   jpa:     generateDdl: false     hibernate:       ddlAuto: update     openInView: true     show-sql: true
  • 实体对象
@Entity @Table(name = "t_account") public class Account {     @Id     private Long id;     private String userId;     private BigDecimal money;     @Version     private Integer version ; }

注意这里的version字段加了@Version注解,以实现乐观锁。

  • Service
@Service public class AccountService {      @Resource     private AccountDAO accountDAO ;          /**      * 	扣款操作      * 	@param id       * 	@param money      */     @Transactional     public Account deduction(Long id, BigDecimal money) {     	Account account = accountDAO.findById(id).orElse(null) ;     	try { 			TimeUnit.SECONDS.sleep(3) ; 		} catch (InterruptedException e) {}     	if (account != null) {     		account.setMoney(account.getMoney().subtract(money)) ;     		return accountDAO.saveAndFlush(account) ;     	}     	return null ;     }          /**      *  充值操作      * 	@param id      * 	@param money      * 	@return      */     @Transactional     public Account recharge(Long id, BigDecimal money) {     	Account account = accountDAO.findById(id).orElse(null) ;     	if (account != null) {     		account.setMoney(account.getMoney().add(money)) ;     		return accountDAO.saveAndFlush(account) ;     	}     	return null ;     }      }

扣款操作deduction方法做了睡眠操作,为了模拟效果。这里你不能使用getOne方法获取Account对象,getOne方法返回的是代理对象,只有你真正去用的时候才去数据库中做查询。

源码:


springboot 基于数据库的乐观锁实现

这里的getReference方法:EntityManager会创建一个新的实体,但是不会立即访问数据库来加载持久状态,而是在第一次访问某个属性的时候才加载。此外,getReference()方法不返回null,如果数据库找不到相应的实体,这个方法会抛出javax.persistence.EntityNotFoundException。

  • 测试
@SpringBootTest @RunWith(SpringRunner.class) public class SpringBootLockRetryApplicationTests {  	@Resource 	private AccountService accountService ; 	private CountDownLatch cdl = new CountDownLatch(2) ; 	 	@Test 	public void testMoneyOperator() { 		Thread t1 = new Thread(() -> { 			accountService.deduction(100L, BigDecimal.valueOf(10)) ; 			cdl.countDown() ; 		}) ; 		Thread t2 = new Thread(() -> { 			accountService.recharge(100L, BigDecimal.valueOf(10)) ; 			cdl.countDown() ; 		}) ; 		t1.start() ; 		t2.start() ; 		try { 			cdl.await(); 		} catch (InterruptedException e) { 			e.printStackTrace(); 		}  	}  }

运行结果:


springboot 基于数据库的乐观锁实现

注意这里的sql语句和上面所说的基于版本号实现的乐观锁一样更新时需要更新版本号和比对当前数据库中的版本号和当前持有的版本是否相同。

接下来通过AOP来处理这种乐观锁的异常,这里通过AOP拦截有特定注解的方法进行重试。

自定义需要重试的注解类:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RetryProcess { 	 	int value() default 3 ; 	 }

默认重试3次,这里可以自定义重试的测试。

修改service类:

这里模拟的是扣款操作发生异常,那这里需要修改扣款的操作添加注解及事务注解不能在这里添加,需要放到AOP类的Around方法上。

/**      * 	扣款操作      * 	@param id       * 	@param money      */     @RetryProcess     public Account deduction(Long id, BigDecimal money) {     	Account account = accountDAO.findById(id).orElse(null) ;     	try { 			TimeUnit.SECONDS.sleep(3) ; 		} catch (InterruptedException e) {}     	if (account != null) {     		account.setMoney(account.getMoney().subtract(money)) ;     		return accountDAO.saveAndFlush(account) ;     	}     	return null ;     }

AOP注解类:

@Component @Aspect public class RetryAspect { 	private int max_retry_times = 3 ; 	private static Logger logger = LoggerFactory.getLogger(RetryAspect.class) ; 	 	@Pointcut("@annotation(com.pack.annotation.RetryProcess)") 	public void retry() {}  	 	@Around("retry()") 	@Transactional 	public Object arround(ProceedingJoinPoint pjp) throws Throwable { 		MethodSignature msig = (MethodSignature) pjp.getSignature(); 		Class<?>[] parameterTypes = msig.getMethod().getParameterTypes(); 		Method method = pjp.getTarget().getClass().getMethod(pjp.getSignature().getName(), parameterTypes); 		this.max_retry_times = method.getAnnotation(RetryProcess.class).value() ; 		 		int attempts = 0 ; 		Object result = null ;         do {             attempts++;             try {             	result = pjp.proceed();             	return result ;             } catch (Exception e) {             	e.printStackTrace() ;                 if(e instanceof ObjectOptimisticLockingFailureException ||                         e instanceof StaleObjectStateException) {                 	logger.info("retrying....times:{}", attempts);                     if(attempts > max_retry_times) {                     	logger.info("retry excceed the max times..");                         throw e;                     }                 }             }         } while (attempts < max_retry_times); 		return result ; 	} 	 }

注意这里的arround方法添加了@Transactional 如果不添加到这里会发生如下错误:

2020-12-08 15:33:07.568  INFO 19340 --- [       Thread-3] com.pack.aop.retry.RetryAspect           : retrying....times:3 Exception in thread "Thread-3" org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-only 	at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:752) 	at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:711) 	at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:633) 	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:386) 	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:118) 	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) 	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:749) 	at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:95) 	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) 	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:749) 	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:691) 	at com.pack.service.AccountService$$EnhancerBySpringCGLIB$$934e6b25.deduction(<generated>) 	at com.pack.SpringBootLockRetryApplicationTests.lambda$0(SpringBootLockRetryApplicationTests.java:26) 	at java.lang.Thread.run(Thread.java:745) 

意思是:之前的事务发生了错误并且将事务设置了 rollback-only了,但是这个异常并没有被抛出导致执行到最后执行了commit,所以会出现这个错误。

再次测试:

Hibernate: select account0_.id as id1_0_0_, account0_.money as money2_0_0_, account0_.user_id as user_id3_0_0_, account0_.version as version4_0_0_ from t_account account0_ where account0_.id=? Hibernate: select account0_.id as id1_0_0_, account0_.money as money2_0_0_, account0_.user_id as user_id3_0_0_, account0_.version as version4_0_0_ from t_account account0_ where account0_.id=? Hibernate: select account0_.id as id1_0_0_, account0_.money as money2_0_0_, account0_.user_id as user_id3_0_0_, account0_.version as version4_0_0_ from t_account account0_ where account0_.id=? Hibernate: update t_account set money=?, version=? where id=? and version=? Hibernate: select account0_.id as id1_0_0_, account0_.money as money2_0_0_, account0_.user_id as user_id3_0_0_, account0_.version as version4_0_0_ from t_account account0_ where account0_.id=? org.springframework.orm.ObjectOptimisticLockingFailureException: Object of class [com.pack.domain.Account] with identifier [100]: optimistic locking failed; nested exception is org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.pack.domain.Account#100] 	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:337) 	at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:255) 	at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:528) 	at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:212) 	at com.sun.proxy.$Proxy95.saveAndFlush(Unknown Source) 	at com.pack.service.AccountService.deduction(AccountService.java:34) 	at org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint.proceed(MethodInvocationProceedingJoinPoint.java:88) 	at com.pack.aop.retry.RetryAspect.arround(RetryAspect.java:41) 	at java.lang.Thread.run(Thread.java:745) Caused by: org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.pack.domain.Account#100] 	at org.hibernate.event.internal.DefaultMergeEventListener.entityIsDetached(DefaultMergeEventListener.java:341) 	at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:172) 	at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:70) 	at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:102) 	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:118) 	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:186) 	at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:139) 	... 31 more 2020-12-08 15:43:47.975  INFO 12412 --- [       Thread-3] com.pack.aop.retry.RetryAspect           : retrying....times:1 Hibernate: select account0_.id as id1_0_0_, account0_.money as money2_0_0_, account0_.user_id as user_id3_0_0_, account0_.version as version4_0_0_ from t_account account0_ where account0_.id=? Hibernate: select account0_.id as id1_0_0_, account0_.money as money2_0_0_, account0_.user_id as user_id3_0_0_, account0_.version as version4_0_0_ from t_account account0_ where account0_.id=? Hibernate: update t_account set money=?, version=? where id=? and version=? 2020-12-08 15:43:51.022  INFO 12412 --- [extShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default' 2020-12-08 15:43:51.030  INFO 12412 --- [extShutdownHook] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor' 2020-12-08 15:43:51.031  INFO 12412 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : MasterDatabookHikariCP - Shutdown initiated... 2020-12-08 15:43:51.053  INFO 12412 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : MasterDatabookHikariCP - Shutdown completed.

控制台输出了:com.pack.aop.retry.RetryAspect : retrying....times:1

重试了一次成功了。查看数据:


springboot 基于数据库的乐观锁实现

钱没有发生变化(扣减 都是10元),版本变成了3。

您可能还会对下面的文章感兴趣: