扣款引发的思考

前言:

扣款这个场景在日常开发场景不是很常见,但是在面试中以该场景发散的面试题却不计其数。主要是扣款场景比较敏感,对于安全性要求很高,就像老是面试问你个秒杀(对性能要求高)一样。最近就遇到了一个扣款的场景,遇到了一些问题,有些思考记录下来。 因为系统的保密性,这里用自己写的代码描述问题。 扣款业务数据: 这里模拟扣款动作核心的两个表,账户和资金流水。当业务需要扣款时,扣除账户余额,同时更新资金流水。**

一:环境搭建

主要使用:Mysql,SpringBoot,MyBatis

1.数据库

资金账户表

CREATE TABLE `my_capital_account` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `current_balance` bigint NOT NULL COMMENT '余额',
  `account_name` varchar(64) NOT NULL DEFAULT '' COMMENT '账户名称',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 

资金流水表

CREATE TABLE `my_capital_log` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `account_id` bigint NOT NULL COMMENT '账户id',
  `change_amount` bigint NOT NULL COMMENT '变更金额(可为负数)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

资金流水表中记录资金账户id,两个表属于1对多的关系

账户基础数据:
在这里插入图片描述
资金流水暂时为空。

说明

每次执行完成,都会将账号余额恢复为以上状态
资金计算应该使用BigDecimal,这里为了简化,直接用的long

2.基础代码

Entity

  • 资金账号实体类
package com.example.demo;

import lombok.Data;

@Data
public class MyCapitalAccount {
    private Long id;
    private String accountName;
    private Long currentBalance;
}
  • 资金流水实体类
package com.example.demo;

import lombok.Data;

@Data
public class MyCapitalLog {
    private Long id;
    private Long accountId;
    private Long changeAmount;
}
  • Mapper

资金流水DAO

package com.example.demo;

import org.apache.ibatis.annotations.*;

@Mapper
public interface MyCapitalDAO {
    @Results(id = "myCapitalAccount", value = {
            @Result(property = "id", column = "id"),
            @Result(property = "accountName", column = "account_name"),
            @Result(property = "currentBalance", column = "current_balance"),
    })
    @Select(value = "SELECT * FROM my_capital_account WHERE id=#{id}")
    MyCapitalAccount getById(Long id);

    @Update(value = "UPDATE my_capital_account set current_balance=#{banlance} WHERE id=#{id}")
    int coverAccountBalance(@Param("id") Long id, @Param("banlance") Long banlance);
}

资金流水DAO

package com.example.demo;

import org.apache.ibatis.annotations.*;

@Mapper
public interface MyCapitalLogDAO {
    @Results(id = "myCapitalLog", value = {
            @Result(property = "id", column = "id"),
            @Result(property = "accountId", column = "account_id"),
            @Result(property = "changeAmount", column = "change_amount"),
    })
    @Select(value = "SELECT * FROM my_capital_log WHERE id=#{id}")
    MyCapitalLog getById(Long id);

    @Insert(value = "INSERT INTO `my_capital_log`(`account_id`, `change_amount`) VALUES (#{accountId}, #{changeAmount});")
    int insert(MyCapitalLog log);
}
  • Service
package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class CapitalAccountService {
   @Autowired
   private MyCapitalDAO myCapitalDAO;
   @Autowired
   private MyCapitalLogDAO myCapitalLogDAO;

   public MyCapitalAccount getAccountById(Long id) {
       return myCapitalDAO.getById(id);
   }

   /**
    * 修改账户金额
    *
    * @param id
    * @param changeAmount
    * @return
    */
   public boolean coverAccount(Long id, Long changeAmount) {
       if (changeAmount == 0) {
           return true;
       }
       Long nowBalance = myCapitalAccountDb.getCurrentBalance() + changeAmount;
       int i = myCapitalDAO.coverAccountBalance(id, nowBalance);
       MyCapitalLog myCapitalLog = new MyCapitalLog();
       myCapitalLog.setAccountId(id);
       myCapitalLog.setChangeAmount(changeAmount);
       int insert = myCapitalLogDAO.insert(myCapitalLog);
       if (i <= 0 && insert <= 0) {
           //任何一步没有成功更新,抛出异常回滚
           throw new IllegalStateException("操作资金失败");
       }
       return true;
   }

}
  • Controller
package com.example.demo;

import com.sun.istack.internal.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("my/test")
@Validated
public class MyTestController {
   @Autowired
   CapitalAccountService capitalAccountService;

   @GetMapping("getById")
   @ResponseBody
   public MyCapitalAccount test(@NotNull Long id) {
       return capitalAccountService.getAccountById(id);
   }


   @GetMapping("coverAccount")
   @ResponseBody
   public boolean coverAccount(@NotNull Long id, @NotNull Long changeAmount) {
       return capitalAccountService.coverAccount(id, changeAmount);
   }

}

3.相关配置

spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/mytest?useUnicode=true&character_set_server=utf8mb4&autoReconnect=true&useSSL=false&verifyServerCertificate=false&allowPublicKeyRetrieval=true&zeroDateTimeBehavior=CONVERT_TO_NULL
spring.datasource.hikari.maximum-pool-size=128
spring.datasource.hikari.minimum-idle=10


logging.level.root=info
server.compression.enabled=true
#mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

二:场景及问题

场景1

用户有充值和提现需求,需要给某个账号加钱或减钱。

这里给张三加10块
在这里插入图片描述
成功了。

问题1:超扣问题

代码会出现超扣问题:
这个就不演示了,账户的金额是不能为负数的

解决方案
1.内存判断(本文例子默认使用的方式)
 public boolean coverAccount(Long id, Long changeAmount) {
        if (changeAmount == 0) {
            return true;
        }
          MyCapitalAccount accountDbOut = myCapitalDAO.getById(id);
        if (changeAmount < 0 && accountDbOut.getCurrentBalance() + changeAmount  < 0) {
            throw new RuntimeException("余额不足");
        }
        Long nowBalance = myCapitalAccountDb.getCurrentBalance() + changeAmount;
        int i = myCapitalDAO.coverAccountBalance(id, nowBalance);
        MyCapitalLog myCapitalLog = new MyCapitalLog();
        myCapitalLog.setAccountId(id);
        myCapitalLog.setChangeAmount(changeAmount);
        int insert = myCapitalLogDAO.insert(myCapitalLog);
        if (i <= 0 && insert <= 0) {
            //任何一步没有成功更新,抛出异常回滚
            throw new IllegalStateException("操作资金失败");
        }
        return true;
    }
2.使用数据库限制

在这里插入图片描述
把资金余额的类型设置为无符号,当尝试将资金变为负数时
在这里插入图片描述

问题2:第二类丢失更新

多线程更新出现上一次的更新被覆盖的情况。(两类丢失更新

问题复现

准备两个线程,为了100%复现,使用idea多线程调试(不懂这个的点这里)。
每个线程扣款600元。


    @GetMapping("multi/thread/coverAccount")
    @ResponseBody
    public boolean multiThreadCoverAccount() {
        ExecutorService executor = Executors.newCachedThreadPool();
        Long id = 1L;
        Long changeAmount = 600L;
        for (int i = 0; i < 2; i++) {
            executor.submit(() -> capitalAccountService.coverAccount(id, changeAmount));
        }
        return true;
    }
结果

在这里插入图片描述
在这里插入图片描述
1200的钱,却只有600入账

解决方案
1:乐观锁

数据库使用乐观锁,为防止ABA问题,增加version字段作为乐观锁判断依据。(演示略)

优点:不损失代码性能,又安全
缺点:碰撞就会导致需要重试,对于ToB的系统,性能需求并不突出,成功率要求更高

2:悲观锁
(1)数据库悲观锁 for update(不推荐)

i. for update锁必须要在事务中才能生效
ii. for update锁的查询语句必须要应用有效索引,否则会出现行锁变表锁,严重影响性能。
iii. for update 行锁只针对主库生效,也就是说,这个语句的存在,将限制你的系统使用从库,如果使用,必须强制将for update相关的语句指定到主库查询,否则for update行锁将失效。

(2).java悲观锁(推荐)

使用分布式锁,锁定账号id
修改下更新账号的代码如下(注意:本地lock模拟,现实场景应该用分布式锁锁住account的id
这里有个double check,第一次的check可以预过滤,在没拿到锁之前如果就已经余额不足了,那就没必要拿锁浪费时间。这样可以节约性能(这里是个伏笔)

   public boolean coverAccount(Long id, Long changeAmount) {
        if (changeAmount == 0) {
            return true;
        }
        //超扣解决方案
        MyCapitalAccount accountDbOut = myCapitalDAO.getById(id);
        if (changeAmount < 0 && accountDbOut.getCurrentBalance() + changeAmount  < 0) {
            throw new RuntimeException("余额不足");
        }
		//并发解决方案
        LOCK.lock();
        try {
            //double check
            MyCapitalAccount myCapitalAccountDb = myCapitalDAO.getById(id);
            if (changeAmount < 0 && myCapitalAccountDb.getCurrentBalance() + changeAmount  < 0) {
                throw new RuntimeException("余额不足");
            }
            Long nowBalance = myCapitalAccountDb.getCurrentBalance() + changeAmount;
            int i = myCapitalDAO.coverAccountBalance(id, nowBalance);
            MyCapitalLog myCapitalLog = new MyCapitalLog();
            myCapitalLog.setAccountId(id);
            myCapitalLog.setChangeAmount(changeAmount);
            int insert = myCapitalLogDAO.insert(myCapitalLog);
            if (i <= 0 && insert <= 0) {
                //任何一步没有成功更新,抛出异常回滚
                throw new IllegalStateException("操作资金失败");
            }
        } finally {
            LOCK.unlock();
        }

        return true;
    }

100个线程,每个线程加10元结果:
在这里插入图片描述

(3).数据库读写锁(推荐)

使用UPDATE my_capital_account set current_balance=current_balance+#{changeAmount} WHERE id=#{id}
这种语法,会开启数据库写锁,,读将使用当前读,写入过程是串行(其实和java悲观锁一样,如果开启事务粒度会变大)网上有说这种方式是无法保证事务性的,这篇文章有详细介绍。针对这种情况我们测试一下。
新增方法:

public boolean updateAccountBalance(Long id, Long changeAmount) {
        if (changeAmount == 0) {
            return true;
        }
        //超扣解决方案
        MyCapitalAccount accountDbOut = myCapitalDAO.getById(id);
        if (changeAmount < 0 && accountDbOut.getCurrentBalance() + changeAmount  < 0) {
            throw new RuntimeException("余额不足");
        }
        //并发解决方案
        LOCK.lock();
        try {
            MyCapitalAccount myCapitalAccountDb = myCapitalDAO.getById(id);
            if (changeAmount < 0 && myCapitalAccountDb.getCurrentBalance() + changeAmount  < 0) {
                throw new RuntimeException("余额不足");
            }
            int i = myCapitalDAO.updateAccountBalance(id, changeAmount);
            MyCapitalLog myCapitalLog = new MyCapitalLog();
            myCapitalLog.setAccountId(id);
            myCapitalLog.setChangeAmount(changeAmount);
            int insert = myCapitalLogDAO.insert(myCapitalLog);
            if (i <= 0 && insert <= 0) {
                //任何一步没有成功更新,抛出异常回滚
                throw new IllegalStateException("操作资金失败");
            }

        } finally {
            LOCK.unlock();
        }
        return true;
    }
 @Update(value = "UPDATE my_capital_account set current_balance=current_balance+#{changeAmount} WHERE id=#{id}")
    int updateAccountBalance(@Param("id") Long id, @Param("changeAmount") Long changeAmount);

使用100个线程每次增加10元。

 @GetMapping("multi/thread/updateAccount")
    @ResponseBody
    public boolean testUpdate() {
        ExecutorService executor = Executors.newCachedThreadPool();
        Long id = 2L;
        Long changeAmount = 10L;
        int size = 100;
        CountDownLatch countDownLatch = new CountDownLatch(size);
        for (int i = 0; i < size; i++) {
            executor.submit(() -> {
                countDownLatch.countDown();
                try {
            		countDownLatch.await(20, TimeUnit.SECONDS);
       			 } catch (InterruptedException e) {
           			 e.printStackTrace();
        		}
                capitalAccountService.updateAccountBalance(id, changeAmount);
            });
        }
        return true;
    }

连续实验了9次(这里用的是id为2的数据):
在这里插入图片描述
金额从1000增加到了10000。

问题3:资金与资金流水的记录无法保证原子性。

问题复现
  public boolean coverAccount(Long id, Long changeAmount) {
        if (changeAmount == 0) {
            return true;
        }
        MyCapitalAccount accountDbOut = myCapitalDAO.getById(id);
        if (changeAmount < 0 && accountDbOut.getCurrentBalance() + changeAmount < 0) {
            System.out.println("外部判断余额不足");
            throw new RuntimeException("余额不足");
        }

        LOCK.lock();
        try {
            //double check
            MyCapitalAccount myCapitalAccountDb = myCapitalDAO.getById(id);
            if (changeAmount < 0 && myCapitalAccountDb.getCurrentBalance() + changeAmount < 0) {
                System.out.println("内部判断余额不足");
                throw new RuntimeException("余额不足");
            }
            Long nowBalance = myCapitalAccountDb.getCurrentBalance() + changeAmount;
            int i = myCapitalDAO.coverAccountBalance(id, nowBalance);
            MyCapitalLog myCapitalLog = new MyCapitalLog();
            myCapitalLog.setAccountId(id);
            myCapitalLog.setChangeAmount(changeAmount);
           	int test=1/0;
            int insert = myCapitalLogDAO.insert(myCapitalLog);
            if (i <= 0 && insert <= 0) {
                //任何一步没有成功更新,抛出异常回滚
                throw new IllegalStateException("操作资金失败");
            }
        } finally {
            LOCK.unlock();
        }

        return true;
    }

在记录log前加手动制造异常。
再次给张三账户加10元

结果

在这里插入图片描述
在这里插入图片描述

账户金额扣除,但是资金流水没有记录。

解决方案

增加事务注解,保证两个表操作的原子性(这里选择Java悲观锁方案作为例子)

@Transactional
public boolean coverAccount(Long id, Long changeAmount) {
        if (changeAmount == 0) {
            return true;
        }
        MyCapitalAccount accountDbOut = myCapitalDAO.getById(id);
        if (changeAmount < 0 && accountDbOut.getCurrentBalance() + changeAmount < 0) {
            System.out.println("外部判断余额不足");
            throw new RuntimeException("余额不足");
        }

        LOCK.lock();
        try {
            //double check
            MyCapitalAccount myCapitalAccountDb = myCapitalDAO.getById(id);
            if (changeAmount < 0 && myCapitalAccountDb.getCurrentBalance() + changeAmount < 0) {
                System.out.println("内部判断余额不足");
                throw new RuntimeException("余额不足");
            }
            Long nowBalance = myCapitalAccountDb.getCurrentBalance() + changeAmount;
            int i = myCapitalDAO.coverAccountBalance(id, nowBalance);
            MyCapitalLog myCapitalLog = new MyCapitalLog();
            myCapitalLog.setAccountId(id);
            myCapitalLog.setChangeAmount(changeAmount);
            int insert = myCapitalLogDAO.insert(myCapitalLog);
            if (i <= 0 && insert <= 0) {
                //任何一步没有成功更新,抛出异常回滚
                throw new IllegalStateException("操作资金失败");
            }
        } finally {
            LOCK.unlock();
        }

        return true;
    } 

再次给张三加10元

日志与金额同时失败(这个比较简单就不贴图了)

-------------------------------我是分割线--------------------------------------------
好了,现在整理下我们一共遇到了3个问题及目前的解决方案

序号问题描述解决方案
1超扣问题账户金额不能为负数增加内存资金判断
2并发安全问题同时访问会出现上一次更新被覆盖增加分布式锁
3原子性问题资金余额和资金流水必须满足A加入事务

现在的demo是这样的:

 	public static final ReentrantLock LOCK = new ReentrantLock();
	@Transactional
    public boolean coverAccount(Long id, Long changeAmount) {
        if (changeAmount == 0) {
            return true;
        }
        MyCapitalAccount accountDbOut = myCapitalDAO.getById(id);
        if (changeAmount < 0 && accountDbOut.getCurrentBalance() + changeAmount < 0) {
            System.out.println("外部判断余额不足");
            throw new RuntimeException("余额不足");
        }

        LOCK.lock();
        try {
            //double check
            MyCapitalAccount myCapitalAccountDb = myCapitalDAO.getById(id);
            if (changeAmount < 0 && myCapitalAccountDb.getCurrentBalance() + changeAmount < 0) {
                System.out.println("内部判断余额不足");
                throw new RuntimeException("余额不足");
            }
            Long nowBalance = myCapitalAccountDb.getCurrentBalance() + changeAmount;
            int i = myCapitalDAO.coverAccountBalance(id, nowBalance);
            MyCapitalLog myCapitalLog = new MyCapitalLog();
            myCapitalLog.setAccountId(id);
            myCapitalLog.setChangeAmount(changeAmount);
            int insert = myCapitalLogDAO.insert(myCapitalLog);
            if (i <= 0 && insert <= 0) {
                //任何一步没有成功更新,抛出异常回滚
                throw new IllegalStateException("操作资金失败");
            }
        } finally {
            LOCK.unlock();
        }

        return true;
    }

数据初始化,再次启动100个线程跑下扣款

 @GetMapping("multi/thread/coverAccount")
    @ResponseBody
    public boolean multiThreadCoverAccount() {
        ExecutorService executor = Executors.newCachedThreadPool();
        Long id = 1L;
        Long changeAmount = -10L;
        for (int i = 0; i < 100; i++) {
            executor.submit(() -> capitalAccountService.coverAccount(id, changeAmount));
        }
        return true;
    }

结果:

在这里插入图片描述
再次执行:
在这里插入图片描述
出现第二类丢失更新了,当把事务关闭后,再次测试。
在这里插入图片描述
没有问题,现在问题找到了

问题4:开启事务后,第二类丢失更新重新出现

回顾:之前的问题2更新覆盖。是因为并发修改问题,导致覆盖。这里明明已经加入了分布式锁,为什么还是会有并发问题呢?
执行下现在的更新代码,看下mybatis的log日志
在这里插入图片描述
mybatis的一级缓存生效了,导致最新的数据不可见(事实上根本原因不是这个,埋个雷),mybatis一级缓存是sqlSession级别,同一个查询第二次将使用一级缓存(具体一级缓存自行百度)。这里开启了事务,所以sqlSession一定是同一个。

抽丝剥茧
1.尝试控制事务中仅一次查询,排除sqlSession的二级缓存干扰

既然怀疑是sqlSession的问题,直接去除分布式锁前事务后的查询
double check的逻辑去掉,查询只有一次,而且在分布式锁内,就不会出现一级缓存的问题了。这里再次循环100次,每次减去10元,为了模拟并发,使用countDownLatch拦截所有线程,开启事务后同时执行。

 @GetMapping("multi/thread/coverAccount")
    @ResponseBody
    public boolean multiThreadCoverAccount() {
        ExecutorService executor = Executors.newCachedThreadPool();
        Long id = 1L;
        Long changeAmount = -10L;
        int size = 100;
        CountDownLatch countDownLatch = new CountDownLatch(size);
        for (int i = 0; i < size; i++) {
            int num = i;
            executor.submit(() -> {
                countDownLatch.countDown();
                try {
                    countDownLatch.await(20, TimeUnit.SECONDS);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                capitalAccountService.coverAccount(id, changeAmount, num);
            });
        }

        return true;
    }
  @Transactional
    public boolean coverAccount(Long id, Long changeAmount, int num) {
        if (changeAmount == 0) {
            return true;
        }
        System.out.println("编号" + num + "正在执行");
        LOCK.lock();
        try {
            MyCapitalAccount myCapitalAccountDb = myCapitalDAO.getById(id);
            if (changeAmount < 0 && myCapitalAccountDb.getCurrentBalance() + changeAmount < 0) {
                System.out.println("内部判断余额不足");
                throw new RuntimeException("余额不足");
            }
            Long nowBalance = myCapitalAccountDb.getCurrentBalance() + changeAmount;
            int i = myCapitalDAO.coverAccountBalance(id, nowBalance);
            MyCapitalLog myCapitalLog = new MyCapitalLog();
            myCapitalLog.setAccountId(id);
            myCapitalLog.setChangeAmount(changeAmount);
            int insert = myCapitalLogDAO.insert(myCapitalLog);
            if (i <= 0 && insert <= 0) {
                //任何一步没有成功更新,抛出异常回滚
                throw new IllegalStateException("操作资金失败");
            }
            System.out.println(nowBalance + "元");
        } finally {
            LOCK.unlock();
        }
        return true;
    }

结果:
![在这里插入图片描述](https://img-blog.csdnimg.cn/20200910173146410.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzMxNDU3NjY1,size_16,color_FFFFFF,t_70#pic_center![在这里插入图片描述](https://img-blog.csdnimg.cn/20201107121604205.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzMxNDU3NjY1,size_16,color_FFFFFF,t_70#pic_center

在这里插入图片描述
从日志中看出,当前都是从DB中查询的,但是仍存在第二类丢失更新(-_-!!)。而且应该扣除1000的,这里只扣除了500.分析日志可以看到,每次都是两两重复,为什么上了锁,还会读到相同的数据呢?,问题就在于这个锁的释放是在事务生效前!!
我们都知道,Spring的事务是基于切面实现的,当切面方法执行完成后,才会去commit事务,此时方法中的finally释放分布式锁的代码已经执行完成!在锁释放后,事务提交前,这个锁还是没有锁住,产生了并发修改的问题。

我们画一个时序表格来分析下这个问题

时间事务A事务B
t1开启事务
t2开启事务
t3获取分布式锁获取分布式锁
t4获取到分布式锁未获取到分布式锁
t5执行查询并修改数据分布式锁阻塞中
t6释放分布式锁分布式锁阻塞中
t7获取到分布式锁
t8执行查询
t9提交事务(真正生效)修改数据
t10提交事务(真正生效)

可以重点关注t8-t9,只要事务B的查询操作(t8)早于事务A提交生效(t9),并发修改成立从而导致第二类丢失更新。
对于提交事务(真正生效),这个过程涉及远程mysql调用,和本地代码执行速率不是一个量级的,那么以上情况可以肯定是普遍存在的。

所以:
分布式锁不可以放在事务中,因为释放锁在事务提交前执行,间隙一旦别的事务读取到数据,满足并发修改条件,从而引发第二类丢失更新

2.分布式锁前移到业务层

这里为了性能,预检查下余额:
业务代码

 @GetMapping("multi/thread/coverAccount")
    @ResponseBody
    public boolean multiThreadCoverAccount1() {
        //模拟业务多次调用
        ExecutorService executor = Executors.newCachedThreadPool();
        Long id = 1L;
        Long changeAmount = -10L;
        int size = 100;
        CountDownLatch countDownLatch = new CountDownLatch(size);
        for (int i = 0; i < size; i++) {
            executor.submit(() -> {
                countDownLatch.countDown();
                try {
                    countDownLatch.await(20, TimeUnit.SECONDS);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                doCoverAccount(id,changeAmount);
            });
        }

        return true;
    }
    
    private void doCoverAccount(Long id,Long changeAmount){
        //预校验
        MyCapitalAccount accountDbOut = capitalAccountService.getAccountById(id);
        if (changeAmount < 0 && accountDbOut.getCurrentBalance() + changeAmount < 0) {
            throw new RuntimeException("余额不足");
        }
        LOCK.lock();
        try {
            capitalAccountService.coverAccount(id, changeAmount);
        }finally {
            LOCK.unlock();
        }
    }
   @Transactional
    public boolean coverAccount(Long id, Long changeAmount) {
        if (changeAmount == 0) {
            return true;
        }
        MyCapitalAccount myCapitalAccountDb = myCapitalDAO.getById(id);
         if (changeAmount < 0 && myCapitalAccountDb.getCurrentBalance() + changeAmount < 0) {
            System.out.println("内部判断余额不足");
            throw new RuntimeException("余额不足");
        }
        Long nowBalance = myCapitalAccountDb.getCurrentBalance() + changeAmount;
        int i = myCapitalDAO.coverAccountBalance(id, nowBalance);
        MyCapitalLog myCapitalLog = new MyCapitalLog();
        myCapitalLog.setAccountId(id);
        myCapitalLog.setChangeAmount(changeAmount);
        int insert = myCapitalLogDAO.insert(myCapitalLog);
        if (i <= 0 && insert <= 0) {
            //任何一步没有成功更新,抛出异常回滚
            throw new IllegalStateException("操作资金失败");
        }
        return true;
    }

这里给原始值设置为990

在这里插入图片描述

执行以上代码结果:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

好了问题得到得到解决

总结

如果系统只是存在这种简单的单人账户操作场景,使用以上的方法都可以,需要注意的就是是用悲观锁的时候一定要在事务外使用就可以

场景2:多账户发薪操作

公司需要给员工发薪水,发钱时,员工增加a,b,c…元,公司扣除a+b+c…元

根据上面的问题及解决方案,尝试一版实现这个需求。
需求分析:和上面场景不太一样的是,发薪这一行为应是事务的,所以需要在上层增加事务。

 @GetMapping("doTest")
    @ResponseBody
    public boolean payEmployee() {
    	//模拟业务并发执行
        ExecutorService executor = Executors.newCachedThreadPool();
        int size = 100;
        CountDownLatch countDownLatch = new CountDownLatch(size);
        for (int i = 0; i < size; i++) {
            executor.submit(() -> {
                countDownLatch.countDown();
                try {
                    countDownLatch.await(20, TimeUnit.SECONDS);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                payService.payMoney();
            });
        }

        return true;
    }

新写的服务PayService

 public static final ReentrantLock LOCK_ZHANG_SAN = new ReentrantLock();
    public static final ReentrantLock LOCK_LI_SI = new ReentrantLock();
    public static final ReentrantLock LOCK_COMPANY = new ReentrantLock();



    @Transactional
    public void payMoney(){
        doCoverAccount(1L, 10L);
        doCoverAccount(2L, 15L);
        doCoverAccount(3L, -25L);
    }

    private void doCoverAccount(Long id,Long changeAmount){
        ReentrantLock lock = null;
        if (id==1L){
            lock = LOCK_ZHANG_SAN;
        }else if (id==2L){
            lock = LOCK_LI_SI;
        }else if (id==3L){
            lock = LOCK_COMPANY;
        }
        //预校验
        MyCapitalAccount accountDbOut = capitalAccountService.getAccountById(id);
        if (changeAmount < 0 && accountDbOut.getCurrentBalance() + changeAmount < 0) {
            throw new RuntimeException("余额不足");
        }
        lock.lock();
        try {
            capitalAccountService.coverAccount(id, changeAmount);
        }finally {
            lock.unlock();
        }
    }

实际的执行方法没有任何变动

    @Transactional
    public boolean coverAccount(Long id, Long changeAmount) {
        if (changeAmount == 0) {
            return true;
        }
        MyCapitalAccount myCapitalAccountDb = myCapitalDAO.getById(id);
        if (changeAmount < 0 && myCapitalAccountDb.getCurrentBalance() + changeAmount < 0) {
            System.out.println("内部判断余额不足");
            throw new RuntimeException("余额不足");
        }
        Long nowBalance = myCapitalAccountDb.getCurrentBalance() + changeAmount;
        int i = myCapitalDAO.coverAccountBalance(id, nowBalance);
        MyCapitalLog myCapitalLog = new MyCapitalLog();
        myCapitalLog.setAccountId(id);
        myCapitalLog.setChangeAmount(changeAmount);
        int insert = myCapitalLogDAO.insert(myCapitalLog);
        if (i <= 0 && insert <= 0) {
            //任何一步没有成功更新,抛出异常回滚
            throw new IllegalStateException("操作资金失败");
        }
        return true;
    }

现在初始化下数据为:
在这里插入图片描述

执行100次,应该张三为:1000+1000=2000 李四为:1000+1500=2500 某某公司为:20000-1000-1500=17500
执行代码:
在这里插入图片描述

完全不正确,且每次执行都不一样,再仔细观察代码:

在这里插入图片描述

这还是锁放到了事务中,这是之前讨论过的问题。而且这个问题更严重,因为多条数据释放锁后并不会马上提交,而是所有人都成功后一起提交,这就造成了数据没有提交前,其他事务又读到了旧的数据。

选择使用分布式的悲观锁,现在陷入了很复杂的两难瓶颈,不加事务,无法保证批量的数据是原子的,加了事务,又会出现以上问题。有的人会说,那就事务提交完成后,再统一解锁呗。那又要考虑死锁问题。。。。变的异常复杂!!!! 而且,这里还存在事务不可见的问题等等。

全文总结

强烈不推荐使用分布式锁来实现以上场景的账户资金变更功能,扩展性差不说,复杂度极高。

剩下mysql乐观锁和mysql行锁可供选择

事实上,当mysql行锁+事务后,直接就是天然的数据库层的分布式锁,根本不需要乐观锁。因为更新过程没有快照读。在mysql层面,单账户的数据为一行,更新的时候上锁,快照读取数据,更新完成后释放行锁,整个过程就是串行的。不会出现读到的数据和实际去更新的数据不一样的问题。完美解决了并发问题。系统中如果需要获取最新的数据,在行锁后直接获取即可,此时快照读=当前读,因为行锁在自己事务手上,数据不可能变动的。

而如果用乐观锁,依然会开启这个行锁,天然好用的行锁不用,用内存快照读,然后再乐观锁拦截,徒增碰撞的可能,这不就舍近求远了嘛。
所以在开启事务的系统内部,推荐使用行锁+当前读的方式解决这一类问题。

另外:无论使用快照读+乐观锁也好,还是使用行锁+当前读也好,事务下都会开启行锁,这个时候就要注意,批量任务的数据库行锁死锁问题

例子:A事务要更新a,b行,B事务要更新b,a行

A事务开启,a更新完了(事务没提交),此时A事务持有a的行锁,准备取b的行锁
B事务开启,b更新完了(事务没提交),此时B事务持有b的行锁,准备取a的行锁

由于事务没有提交,行锁是不会释放的,这个时候就会产生数据库死锁。

解决方案:批量更新前,数据按统一标准排序,比如使用accountId排序。数据流方向一致了,就不会相互掣肘,出现死锁了。

如果你的系统没有开启事务,那么分布式锁方案和乐观锁方案又和上面所述完全不一样了。具体的就不聊了。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值