可爱静

记录生活、学习和工作

0%

可重入锁解决超卖问题

概念

  1. 超卖问题:在秒杀系统设计中,超卖是一个经典、常见的问题,任何商品都会有数量上限,如何避免成功下订单买到商品的人数不超过商品数量的上限,这是每个抢购活动都要面临的难点。

  2. 可重入锁:一种支持重进入的锁机制。重进入是指一个线程在持有锁的情况下,可以再次获取相同的锁而不会被阻塞。避免了死锁的发生,同时也提高了代码的简洁性和可读性。支持公平性设置,使得等待时间最长的线程优先获取锁。

ReentrantLock

Spring框架提供了一个可重入锁的实现,即ReentrantLock。这是一个标准的Java并发工具类,Spring对其进行了封装,使其更易于在Spring管理的bean中使用。

下面是一个使用Spring的ReentrantLock的简单例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.concurrent.locks.ReentrantLock;

@Service
public class LockService {

private final ReentrantLock lock = new ReentrantLock();

@Async
public void doSomething() {
lock.lock();
try {
// 关键代码块
} finally {
lock.unlock();
}
}
}

模拟场景

场景为:你在网吧上网,发现机子时间快结束了,你随便找了一部精彩的电影看,路过的兄弟都觉得你看的这部电影很精彩,纷纷给你的机器续时长,站在你后面一起欣赏。

假设每次续费会增加一个小时,有100个兄弟续费。

实现

为了模拟并发充值问题,设计以下表格用于测试。

1
2
3
4
5
CREATE TABLE `device` (
`id` bigint(20) NOT NULL COMMENT '设备ID',
`use_times` int(10) DEFAULT '0' COMMENT '使用时长',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

数据库

出现超卖问题的代码

业务代码
控制层

请求接口后数据库变化

update方法就是查询和更新业务

1
2
3
4
5
6
7
8
@Override
public void updateUseTime(Long id) {
Device device = deviceDao.selectById(id);
LambdaUpdateWrapper<Device> deviceLambdaUpdateWrapper = new LambdaUpdateWrapper<>();
deviceLambdaUpdateWrapper.eq(Device::getId, id);
deviceLambdaUpdateWrapper.set(Device::getUseTimes, device.getUseTimes()+1);
deviceDao.update(deviceLambdaUpdateWrapper);
}

使用ReentrantLock解决超卖问题

业务层加入ReentrantLock代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Lock lock = new ReentrantLock();

@Override
public void updateUseTimeByLock(Long id) {
lock.lock();
try {
Device device = deviceDao.selectById(id);
LambdaUpdateWrapper<Device> deviceLambdaUpdateWrapper = new LambdaUpdateWrapper<>();
deviceLambdaUpdateWrapper.eq(Device::getId, id);
deviceLambdaUpdateWrapper.set(Device::getUseTimes, device.getUseTimes()+1);
deviceDao.update(deviceLambdaUpdateWrapper);
} catch (Exception e){
log.error("updateUseTimeByLock error", e);
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}

数据库查看执行结果
无超卖问题

事务导致超卖问题

业务代码
服务层

执行结果
执行结果

解决事务超卖问题

为什么加入事务就导致锁失效了呢?

答案是事务边界问题

使用@Transactional 注解来管理事务,但锁的获取和释放并没有放在事务边界之内。这意味着如果在事务提交之前锁就被释放了,其他线程可能在当前事务结束之前修改相同的数据,这会导致数据不一致

事务边界

如果你在这些新线程中进行数据库操作,它们将运行在各自独立的事务中(如果配置了事务的话),或者根本没有事务管理。这可能会导致数据不一致,因为原始线程的事务可能会回滚,而新线程中的操作可能已经提交了。

因此,如果你需要在有@Transactional注解的方法中进行多线程操作,并且希望这些操作在同一个事务中进行,你需要手动管理这些线程的事务边界,或者重新考虑你的设计,以确保事务的一致性和完整性。

缩小事务边界

业务层调整
缩小业务边界

执行结果
执行结果

总结

处理并发和超卖问题时,理解并合理运用锁机制和事务管理至关重要。

通过将锁操作置于事务边界内,可以有效防止数据不一致,确保系统的稳定性和可靠性。

解决方案概述

  • 乐观锁:通过版本号或时间戳检查数据是否已被其他事务修改,适用于读多写少的场景。

  • 悲观锁:预先锁定数据直至事务完成,适合写操作频繁或数据竞争激烈的场景。

  • 分布式锁:如Redisson,确保分布式系统中数据的一致性,适用于跨节点的数据同步。

  • 代码级锁:利用synchronized或ReentrantLock等机制,控制线程间的访问顺序,防止并发冲突

事务边界的重要性

  • 关键点:确保锁的获取和释放严格位于事务边界内,避免数据在事务未完成前被其他线程修改。

  • 实践:使用try-finally结构包裹锁的获取和释放,确保即使发生异常,锁也能正确释放,维护数据完整性。