账户脏读问题以及解决方案,超详细
最近在线上遇到账户脏读被覆盖的情况,账户使用的是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 = {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");
}
}
注意点:
- 一定要加入
@EnableRetry
注解 @Recover
方法的返回值类型一定要跟@Retryable
注解的返回类型相同,如方法里的User类型
结语
码字不易,希望能多多支持。一名四年工作经验的程序猿,目前从事物流行业的工作,有自己的小破网站amoqi.cn。欢迎大家关注公众号【CoderQi】,一起来交流JAVA知识,包括但不限于SpringBoot+微服务,更有奇奇JAVA学习过程中的工具、面试资料和专业书籍等免费放送,也可以加个人联系方式,见公众号下方工具栏上。