目录
前言
因为我们项目中需要在更新用户钱包时进行一些业务处理,在最开始的设计时采用了乐观锁形式更新用户钱包并且没有做自旋,最开始业务刚刚起步的时候没有什么问题并发更新钱包很少出现,基本上没有出现过更新失败问题,但是在后续业务增长乐观锁更新失败问题就开始多起来了,然后我的同事在某个业务里面更新用户钱包时进行了乐观锁更新失败后自旋,因为加了这个操作并且没有进行合理的测试就发布线上,导致该业务线上乐观锁更新失败后业务一直递归导致死循环栈溢出,具体原因会在下面详细分析。
这里数据库使用 MySQL
一、错误信息
这里最主要的信息就是 StackOverflowError,因为业务递归导致死循环出现栈溢出。
java.lang.StackOverflowError
at java.io.InputStream.<init>(InputStream.java:45)
at java.util.zip.ZipFile$ZipFileInputStream.<init>(ZipFile.java:711)
at java.util.zip.ZipFile.getInputStream(ZipFile.java:375)
at java.util.jar.JarFile.getInputStream(JarFile.java:448)
at sun.misc.URLClassPath$JarLoader$2.getInputStream(URLClassPath.java:989)
at sun.misc.Resource.cachedInputStream(Resource.java:77)
at sun.misc.Resource.getByteBuffer(Resource.java:160)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:455)
at java.net.URLClassLoader.access$100(URLClassLoader.java:74)
at java.net.URLClassLoader$1.run(URLClassLoader.java:369)
at java.net.URLClassLoader$1.run(URLClassLoader.java:363)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:362)
at java.lang.ClassLoader.loadClass(ClassLoader.java:418)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:355)
at java.lang.ClassLoader.loadClass(ClassLoader.java:351)
at com.baomidou.mybatisplus.core.override.MybatisMapperProxy.invoke(MybatisMapperProxy.java:92)
at com.sun.proxy.$Proxy74.selectOne(Unknown Source)
at com.baomidou.mybatisplus.extension.conditions.query.ChainQuery.one(ChainQuery.java:48)
at com.kerwin.example.dbacs.service.impl.CustomerWalletServiceImpl.selectCustomerWalletByCustomerId(CustomerWalletServiceImpl.java:66)
at com.kerwin.example.dbacs.service.impl.CustomerWalletServiceImpl.subAmount(CustomerWalletServiceImpl.java:47)
二、模拟业务表以及代码
2.1、业务表
CREATE TABLE `customer_wallet` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`customer_id` bigint(20) DEFAULT NULL COMMENT '客户ID',
`balance_amount` bigint(20) DEFAULT NULL COMMENT '剩余金额',
`create_time` bigint(20) DEFAULT NULL,
`update_time` bigint(20) DEFAULT NULL,
`version` bigint(20) DEFAULT '1' COMMENT '版本锁',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_customer_id` (`customer_id`)
) COMMENT='客户钱包信息';
INSERT INTO `customer_wallet`(`id`, `customer_id`, `balance_amount`, `create_time`, `update_time`, `version`) VALUES (1, 1, 100, 1714442400000, 1714442400000, 1);
CREATE TABLE `customer_wallet_detail` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`customer_wallet_id` bigint(20) DEFAULT NULL COMMENT '用户钱包ID',
`business_type` int(11) DEFAULT NULL COMMENT '业务类型:1-充值 2-消费',
`happen_amount` bigint(20) DEFAULT NULL COMMENT '发生金额',
`balance_amount` bigint(20) DEFAULT NULL COMMENT '可用余额',
`happen_time` bigint(20) DEFAULT NULL COMMENT '发生时间',
PRIMARY KEY (`id`) USING BTREE,
KEY `idx_customer_wallet_id` (`customer_wallet_id`)
) COMMENT='客户钱包明细';
2.2、业务实现核心代码
这里提供一个 Service 代码,里面有两个核心方法,添加金额、扣减金额,后续会使用这两个方法复现死循环情况。
@Slf4j
@Service
public class CustomerWalletServiceImpl extends ServiceImpl<CustomerWalletMapper, CustomerWallet> implements ICustomerWalletService {
@Autowired
private ICustomerWalletDetailService customerWalletDetailService;
/**
* 添加金额
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean addAmount(Long customerId, Long happenAmount) {
CustomerWallet customerWallet = selectCustomerWalletByCustomerId(customerId);
Long balanceAmount = customerWallet.getBalanceAmount() + happenAmount;
boolean update = updateWallet(customerWallet.getId(), balanceAmount, customerWallet.getVersion());
if(!update){
return addAmount(customerId,happenAmount);
}
addWalletDetail(customerWallet.getId(),1,happenAmount,balanceAmount);
return update;
}
/**
* 扣减金额
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean subAmount(Long customerId, Long happenAmount) {
CustomerWallet customerWallet = selectCustomerWalletByCustomerId(customerId);
Long balanceAmount = customerWallet.getBalanceAmount() - happenAmount;
if(balanceAmount < 0){
throw new RuntimeException("用户余额不足");
}
boolean update = updateWallet(customerWallet.getId(), balanceAmount, customerWallet.getVersion());
if(!update){
log.info("乐观锁更新失败,开始自旋");
return subAmount(customerId,happenAmount);
}
addWalletDetail(customerWallet.getId(),2,happenAmount,balanceAmount);
return update;
}
/**
* 根据用户ID查询用户钱包信息
*/
private CustomerWallet selectCustomerWalletByCustomerId(Long customerId) {
CustomerWallet customerWallet = lambdaQuery().eq(CustomerWallet::getCustomerId, customerId).one();
if (customerWallet == null) {
throw new RuntimeException("用户钱包不存在");
}
return customerWallet;
}
/**
* 更新钱包
*/
private boolean updateWallet(Long id, Long balanceAmount, Long version) {
boolean update = lambdaUpdate()
.eq(CustomerWallet::getId, id)
.eq(CustomerWallet::getVersion, version)
.set(CustomerWallet::getBalanceAmount, balanceAmount)
.set(CustomerWallet::getVersion, version + 1)
.update();
return update;
}
/**
* 添加钱包明细
*/
private boolean addWalletDetail(Long customerWalletId, Integer businessType, Long happenAmount, Long balanceAmount) {
CustomerWalletDetail customerWalletDetail = new CustomerWalletDetail();
customerWalletDetail.setCustomerWalletId(customerWalletId);
customerWalletDetail.setBusinessType(businessType);
customerWalletDetail.setHappenAmount(happenAmount);
customerWalletDetail.setBalanceAmount(balanceAmount);
customerWalletDetail.setHappenTime(System.currentTimeMillis());
return customerWalletDetailService.save(customerWalletDetail);
}
}
三、问题复现
这里有一个前提,数据库的事务隔离级别要是默认的REPEATABLE READ
(可重复读)不然是不会出现这个问题的。
# MySQL5.7查看全局事务隔离级别
SELECT @@SESSION.tx_isolation;
# MySQL8查看全局事务隔离级别
SELECT @@GLOBAL.transaction_isolation;
- 第一步:准备好一个测试入口,我这里采用
SpringBootTest
。
@RunWith(value = SpringRunner.class)
@SpringBootTest(classes = OnlineIssuesApplication.class,webEnvironment =SpringBootTest.WebEnvironment.RANDOM_PORT )
public class CustomerWalletTest {
@Resource
private ICustomerWalletService customerWalletService;
private Long customerId = 1L;
@Test
public void addAmount(){
boolean b = customerWalletService.addAmount(customerId, 100L);
System.out.println("增加金额结果="+b);
}
@Test
public void subAmount(){
boolean b = customerWalletService.subAmount(customerId, 100L);
System.out.println("扣减金额结果="+b);
}
}
- 第二步:这里我采用
subAmount
(扣减金额方法) 进行测试,在CustomerWalletServiceImpl
的subAmount
方法中打个断点进行模拟。
- 第三步:使用Debug运行测试类的
subAmount
方法,进入刚刚打好的断点,然后在数据库执行一次更新乐观锁verison
字段。
这里可以看到第一次查询的时候version=1
UPDATE `customer_wallet` SET balance_amount = balance_amount + 100, version = version + 1 WHERE id = 1;
在断点期间在数据库执行一次更新version
的操作,这样在乐观锁更新时则会失败。
- 第四步:放开断点让业务执行,就能看见死循环后栈溢出效果了
四、问题出现原因分析
其实只要观察一下日志就能发现问题,在自旋更新时每次获取到的version
都是1,查询到的数据和数据库中的数据不同,我们以及将数据库的数据version
值更新成了2,但是这里一直查询出来是1,下面开始分析。
前面有强调数据库的事务隔离级别要是默认的REPEATABLE READ
(可重复读),其实这个问题就是因为事务隔离级别导致的,因为在MySQL的REPEATABLE READ
(可重复读)事务隔离级别下,查询操作是进行的副本读,在一个事务下第一次执行查询时会产生一个ReadView
(一致性视图),在一个事务中后续执行查询只能读取到这个事务产生ReadView
之前提交的数据,也就是说第一次查询的时候就做了一个分界线,在一个事务中后续读取的数据一定是历史数据,可以简单理解成数据库缓存,想要详细了解需要研究一下MySQL的MVCC机制,但是在这里只要知道我们这个问题就是REPEATABLE READ
(可重复读)事务隔离级别导致的读取到的是历史数据,导致获取的乐观锁版本号一直为1,无法更新数据出现的死循环,并且因为执行了类似update ... where id=1
的操作还会对id=1
的数据加上数据库行锁,如果当前事务不提交则会一直持有行锁导致这行数据别的事务也无法更新。
五、问题解决方案
要想解决类似的问题有很多方法,可以根据实际业务需要调整。
5.1、修改事务隔离级别
通过上面问题出现原因分析中知道了是业务事务隔离级别REPEATABLE READ
(可重复读)导致读取到了历史数据,那么这里可以将事务级别调整成READ COMMITTED
(读已提交),READ COMMITTED
(读已提交)事务隔离级别会在每次查询的时候都生成一个新的ReadView
,保证了每次都能读取到已经提交的数据这样就能获取到最新提交的version
使得数据更新成功。
5.2、通过悲观锁实现业务(分布式锁、行锁、synchronized等)
不使用乐观锁通过加悲观锁的方式也能实现对应业务,使用加悲观锁来实现其实在某些场景下会更加合适,在处理业务之前先上锁在执行对应业务,比如我们的这里的subAmount
(扣减金额方法),可以在查询用户钱包的时候加上一个for update
,通过数据库行锁的形势直接锁定,确保只有一个线程在更新这一行数据这样也就不会存在其它问题了。