前言:
扣款这个场景在日常开发场景不是很常见,但是在面试中以该场景发散的面试题却不计其数。主要是扣款场景比较敏感,对于安全性要求很高,就像老是面试问你个秒杀(对性能要求高)一样。最近就遇到了一个扣款的场景,遇到了一些问题,有些思考记录下来。 因为系统的保密性,这里用自己写的代码描述问题。 扣款业务数据: 这里模拟扣款动作核心的两个表,账户和资金流水。当业务需要扣款时,扣除账户余额,同时更新资金流水。**
一:环境搭建
主要使用: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;
}
结果:
从日志中看出,当前都是从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排序。数据流方向一致了,就不会相互掣肘,出现死锁了。
如果你的系统没有开启事务,那么分布式锁方案和乐观锁方案又和上面所述完全不一样了。具体的就不聊了。