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

账户脏读问题以及解决方案,超详细

最近在线上遇到账户脏读被覆盖的情况,账户使用的是redission进行加锁,在加锁之后读取账户金额,对账户金额进行加或者减的计算,然后把计算金额保存到数据库。
1、大致代码
@Override public HandleBalanceResult handleBalanceAndGiven(long accountId, BigDecimal handleBalance, BigInteger handleGiven){ try { RLock lock = redissonClient.getLock(LOCK_ACCOUNT + accountId); if (lock.tryLock(3 * 1000, 3 * 1000, TimeUnit.MILLISECONDS)) { Account account = accountRepository.findOne(accountId); account.setBalance(account.getBalance.add(handleBalance)); //积分 account.setGiven(account.getGiven.add(handleGiven)); account.save(account); return trans(account) } catch (InterruptedException e) { e.printStackTrace(); throw new AccountException("账户操作失败"); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }

2、问题分析 出现问题原因是账户操作的过程中使用了事务,因为事务传播机制,导致事务在执行完其他方法后进行提交,如果是去掉事务,我们的业务系统用户比较多,每隔一段时间总会出现这样一个问题。
3、验证 上面代码如果去掉事务功能倒是没有问题,但是去掉事务后就要考虑回滚的问题比较麻烦,相对的是可以不用分布式锁,而使用事务,就不用考虑事务回滚的问题了。事务中有特性对某一条记录更新时,会锁一行,其他事务进不来,只能等待该事务执行完成之后才能执行。下面是验证方法。
创建account表,插入部分数据
SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- Table structure for account -- ---------------------------- DROP TABLE IF EXISTS `account`; CREATE TABLE `account`( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL, `balance` int(11) NULL DEFAULT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic; -- ---------------------------- -- Records of account -- ---------------------------- INSERT INTO `account` VALUES (1, 'zhangsan', 100); INSERT INTO `account` VALUES (2, 'lisi', 200); INSERT INTO `account` VALUES (3, 'wangwu', 300); SET FOREIGN_KEY_CHECKS = 1;

在Navicat上开启两个窗口,代码如下
# 窗口1 start TRANSACTION; update account set balance = 200 where id = 1 # 窗口2 start TRANSACTION; update account set balance = 300 where id = 1

当执行完成窗口1后,窗口2就会等待窗口1的事务执行完成,之后再窗口1执行 COMMIT; 或者 ROLLBACK; 回滚后,窗口2才能执行完毕,注意是对where的条件加锁,且是加在条件的索引上的,如果条件没有索引,会对整张表加索引。
4、解决问题 验证事务有锁的机制,在事务里针对where条件的索引加锁,同一个where条件事务一个执行完成之后另外一个才能执行执行。那么可以使用版本号机制,每执行一次sql语句,sql语句会有一个版本,当被执行了一次会有另一个版本,当另一sql语句拿着之前的版本进行更新的时候就更新不成功,例如如下语句
# 我是一个事务 start TRANSACTION; select balance,version from account where id = 1; #此处获取的是 100 1 update account set balance = 200 where id = 1 and version = 1; # 执行其他,时间较长,大约5s,反正够下面执行完成的 # ------- COMMIT; #我是另外窗口的一个事务 start TRANSACTION; select balance,version from account where id = 1; #上面这一行被锁了是update被锁了,读的时候正常读,此处获取的是 100 1 # #{version}为上方语句获取到的version,因为上方没有锁,所以获取到的是version=1,下方语句执行前 update account set balance = 300 where id = 1 and version = #{version}; # 这个地方就会被锁住 COMMIT;

具体代码,注意这是个User实体,更新user里面的信息,和上面sql没有关系
UserDao
import cn.amoqi.springbootjpagradle.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.List; @Repository public interface UserDao extends JpaRepository { List findAll(); @Query("update User set amount=?1 ,version = version+1 where id=?2 and version=?3") @Modifying @Transactional int updateAmountById(BigDecimal bigDecimal, Long id,Integer version); }

VersionException
public class VersionException extends RuntimeException{ public VersionException(String message) { super(message); } }

@Service public class UserService { @Autowired UserDao userDao; public User update(){ Optional optionalUser = userDao.findById(1432670992815230978L); if(optionalUser.isPresent()){ System.out.println("version is:"+optionalUser.get().getVersion()); int i = userDao.updateAmountById(optionalUser.get().getAmount().add(BigDecimal.TEN), 1432670992815230978L,1); if(i == 0){ //抛出异常,返回给前端页面 throw new VersionException("充值/付款失败"); } optionalUser = userDao.findById(1432670992815230978L); } User user = optionalUser.get(); return user; } }

5、优化处理 【吐血记录生产环境账户脏读问题以及解决方案,超详细】上面的代码可以处理可以防止脏读的问题,遇到事务锁的时候返回给用户错误,但是可能会发生经常付款失败的情况,那我们有什么方法可以处理吗?
当然是可以处理,可以在失败后来进行重试,在重试一定次数或者时间后,记录异常信息给管理员,管理员再进行灵活处理。
我们引用spring的重试框架 spring-retry,框架使用的是springboot,springboot内部已经集成retry,所以不用输入版本号
gradle
implementation 'org.springframework.retry:spring-retry'

maven
org.springframework.retry spring-retry

处理方法RetryService
@Service @EnableRetry public class RetryService { @Autowired UserDao userDao; //delay:指定延迟后重试 //multiplier:指定延迟的倍数,比如delay=2000,multiplier=1.5时,第二次重试与第一次执行间隔:2秒;第三次重试与第二次重试间隔:3秒;第四次重试与第三次重试间隔:4.5秒。。。 @Retryable(value = https://www.it610.com/article/{VersionException.class},maxAttempts = 3, backoff = @Backoff(delay = 2000, multiplier = 1.5)) public User update(Long userId,BigDecimal handleAmount){ Optional optionalUser = userDao.findById(userId); if(optionalUser.isPresent()){ System.out.println("version is:"+optionalUser.get().getVersion()); int i = userDao.updateAmountById(optionalUser.get().getAmount().add(handleAmount),userId ,optionalUser.get().getVersion()); if(i == 0){ throw new VersionException("产生并发异常"); } optionalUser = userDao.findById(1432670992815230978L); } User user = optionalUser.get(); return user; }//当重试到达指定次数时,被注解的方法将被回调,可以在该方法中进行日志处理。 @Recover public User recover(VersionException e,Long userId,BigDecimal handleAmount) { System.out.println("回调方法执行,可以记录日志到数据库!!!!"); //记日志到数据库 或者调用其余的方法 System.out.println("userId:"+userId+"handleAmount:"+handleAmount); throw new RuntimeException("111111"); } }

注意点:
  1. 一定要加入@EnableRetry注解
  2. @Recover方法的返回值类型一定要跟 @Retryable注解的返回类型相同,如方法里的User类型
结语 码字不易,希望能多多支持。一名四年工作经验的程序猿,目前从事物流行业的工作,有自己的小破网站amoqi.cn。欢迎大家关注公众号【CoderQi】,一起来交流JAVA知识,包括但不限于SpringBoot+微服务,更有奇奇JAVA学习过程中的工具、面试资料和专业书籍等免费放送,也可以加个人联系方式,见公众号下方工具栏上。

    推荐阅读