当前位置: 首页 > 后端技术 > Java

吐血记录生产环境账户脏读问题及解决方法,超详细

时间:2023-04-01 15:29:59 Java

账户脏读问题及解决方法,超详细账户脏读最近上线,使用redission锁定账户,在锁定后,读取账户amount,计算账户金额的加减,然后将计算出的金额存入数据库。1.粗略代码@OverridepublicHandleBalanceResulthandleBalanceAndGiven(longaccountId,BigDecimalhandleBalance,BigIntegerhandleGiven){try{RLocklock=redissonClient.getLock(LOCK_ACCOUNT+accountId);if(lock.tryLock(3*1000,3*1000,TimeUnit.MILLISECONDS)){账户账户=accountRepository.findOne(accountId);account.setBalance(account.getBalance.add(handleBalance));//积分account.setGiven(account.getGiven.add(handleGiven));帐户.保存(帐户);返回trans(account)}catch(InterruptedExceptione){e.printStackTrace();thrownewAccountException("账户操作失败");}finally{if(lock.isHeldByCurrentThread()){lock.unlock();}}}2.问题分析问题产生的原因是账户操作过程中使用了交易。由于事务传播机制,事务是在执行完其他方法后提交的。如果去掉交易的话,我们的业务系统中的用户是很多的。时不时总会出现这样的问题。3、验证上面的代码如果去掉事务功能是没有问题的,但是去掉事务之后,考虑回滚的问题就比较麻烦了。相比之下,不使用分布式锁也可以使用事务,所以不需要考虑事务回滚的问题。当事务中有更新某条记录的特性时,会锁住一行,其他事务无法进入,只能在事务执行完成后执行。下面是验证方法。创建账户表,插入一些数据SETNAMESutf8mb4;SETFOREIGN_KEY_CHECKS=0;----------------------------表结构对于帐户------------------------------如果存在`account`则删除表;创建表`account`(`id`int(11)NOTNULLAUTO_INCREMENT,`name`varchar(255)CHARACTERSETutf8COLLATEutf8_general_ciNULLDEFAULTNULL,`balance`int(11)NULLDEFAULTNULL,PRIMARYKEY(`id`)使用BTREE)ENGINE=InnoDBAUTO_INCREMENT=CHARACTERSET=utf8COLLATE=utf8_general_ciROW_FORMAT=Dynamic;--------------------------------账户记录-------------------------------INSERTINTO`account`VALUES(1,'zhangsan',100);INSERTINTO`account`VALUES(2,'lisi',200);INSERTINTO`account`VALUES(3,'wangwu',300);SETFOREIGN_KEY_CHECKS=1;在Navicat上打开两个窗口,代码如下#Window1startTRANSACTION;updateaccountsetbalance=200whereid=1#window2startTRANSACTION;updateaccountsetbalance=300whereid=1窗口1执行完成后,窗口2会等待窗口1的事务执行完成,然后在窗口1执行COMMIT;或回滚;回滚后,可以执行窗口2。注意where的条件是加锁的,是加到条件的索引中的。如果条件没有索引,则对整个表进行索引。4.解决验证问题交易有锁机制。在事务中,where条件的索引被锁定,只有在其他事务完成后,才能执行相同的where条件事务。然后就可以使用版本号机制了。每执行一条sql语句,都会有一个版本的sql语句。执行一次,就会有另一个版本。当用以前的版本更新另一个sql语句时,更新会失败。例如下面的语句#IamatransactionstartTRANSACTION;从ID=1的账户中选择余额、版本;#这里是1001updateaccountsetbalance=200whereid=1andversion=1;#执行其他的,时间比较短长一点,5s左右,反正足够完成下面的执行#------COMMIT;#我是另一个窗口的交易startTRANSACTION;selectbalance,versionfromaccountwhereid=1;#上面一行加锁更新加锁,读取时正常读取,这里是1001##{version}是上面语句得到的版本,因为上面没有加锁,所以得到的version=1,之前执行下面的语句updateaccountsetbalance=300whereid=1andversion=#{version};#这个地方会被锁定COMMIT;具体代码,注意这是一个User实体,更新user中的信息,与上面sqlUserDaoimportcn.amoqi.springbootjpagradle.entity.User;importorg.springframework.data.jpa.repository.JpaRepository无关;导入org.springframework.data.jpa.repository.Modifying;导入org.springframework.data.jpa.repository.Query;导入org.springframework.stereotype.Repository;导入org.springframework.transaction.annotation.Transactional;导入java。数学.BigDecimal;导入portjava.util.List;@RepositorypublicinterfaceUserDaoextendsJpaRepository{ListfindAll();@Query("updateUsersetamount=?1,version=version+1whereid=?2andversion=?3")@Modifying@TransactionalintupdateAmountById(BigDecimalbigDecimal,Longid,Integerversion);}VersionException公共类VersionException扩展RuntimeException{publicVersionException(Stringmessage){super(message);}}@ServicepublicclassUserService{@AutowiredUserDaouserDao;publicUserupdate(){OptionaloptionalUser=userDao.findById(1432670992815230978L);if(optionalUser.isPresent()){System.out.println("版本是:"+optionalUser.get().getVersion());inti=userDao.updateAmountById(optionalUser.get().getAmount().add(BigDecimal.TEN),1432670992815230978L,1);if(i==0){//抛出异常,返回给前端页面thrownewVersionException("充值/支付失败");}optionalUser=userDao.findById(1432670992815230978L);}用户user=optionalUser.get();返回用户;,遇到事务锁,返回错误给用户,但是可能会频繁出现支付失败,那么有什么办法可以处理呢?当然,也可以加工。失败后可以重试。重试一定次数或一定时间后,将异常信息记录给管理员,管理员可以灵活处理。我们参考spring的重试框架spring-retry,它使用的是springboot,springboot已经集成了retry,所以不需要输入版本号gradleimplementation'org.springframework.retry:spring-retry'mavenorg.springframework.retryspring-retry处理方法RetryService@Service@EnableRetrypublicclassRetryService{@AutowiredUserDaouserDao;//delay:指定延迟后重试//multiplier:指定延迟的倍数,如delay=2000,multiplier=1.5,第二次第一次重试到第一次执行的间隔:2秒;第三次重试与第二次重试的间隔:3秒;第四次重试和第三次重试之间的间隔:4.5秒。..@Retryable(value={VersionException.class},maxAttempts=3,backoff=@Backoff(delay=2000,multiplier=1.5))publicUserupdate(LonguserId,BigDecimalhandleAmount){OptionaloptionalUser=userDao.findById(用户身份);if(optionalUser.isPresent()){System.out.println("版本是:"+optionalUser.get().getVersion());inti=userDao.updateAmountById(optionalUser.get().getAmount().add(handleAmount),userId,optionalUser.get().getVersion());if(i==0){thrownewVersionException("并发异常");}optionalUser=userDao.findById(1432670992815230978L);}用户user=optionalUser.get();返回用户;}//当重试达到指定次数时,会回调注解的方法,在该方法中可以进行日志处理。@RecoverpublicUserrecover(VersionExceptione,LonguserId,BigDecimalhandleAmount){System.out.println("回调方法执行完毕,可以将日志记录到数据库中!!!");//记录日志到数据库或调用其他方法System.out.println("userId:"+userId+"handleAmount:"+handleAmount);抛出新的RuntimeException("111111");}}注意:一定要加上@EnableRetry注解。@Recover方法的返回值类型必须跟@Retryable注解的返回类型一致,比如方法中的User类型。一名四年工作经验的程序员,目前从事物流行业,拥有自己的破烂小网站amoqi.cn。欢迎大家关注公众号【CoderQi】,一起交流JAVA知识,包括但不限于SpringBoot+微服务,以及七七免费发放JAVA学习过程中的工具、面试资料和专业书籍,以及个人联系方式也可以添加,见下方工具栏上的公众号。