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

最近在线上遇到账户脏读被覆盖的情况,账户使用的是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<User,Long> {
    List<User> 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<User> 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

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

处理方法RetryService

@Service
@EnableRetry
public class RetryService {
    @Autowired
    UserDao userDao;

    //delay:指定延迟后重试
    //multiplier:指定延迟的倍数,比如delay=2000,multiplier=1.5时,第二次重试与第一次执行间隔:2秒;第三次重试与第二次重试间隔:3秒;第四次重试与第三次重试间隔:4.5秒。。。
    @Retryable(value = {VersionException.class},maxAttempts = 3, backoff = @Backoff(delay = 2000, multiplier = 1.5))
    public User update(Long userId,BigDecimal handleAmount){
        Optional<User> 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学习过程中的工具、面试资料和专业书籍等免费放送,也可以加个人联系方式,见公众号下方工具栏上。
在这里插入图片描述

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

我是刘奇奇

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值