1、环境准备
1.1 创建sql数据
CREATE TABLE `stock` (
`id` bigint NOT NULL,
`product_code` varchar(20) NOT NULL,
`warehouse` varchar(20) NOT NULL,
`count` int NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 ROW_FORMAT = Dynamic;
INSERT INTO `stock` VALUES (1, '1001', '北京', 2475);
1.2 创建springboot项目
1.2.1 pom.xml文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.3</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.coffee.lock</groupId>
<artifactId>lock-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>lock-demo</name>
<description>lock-demo</description>
<properties>
<java.version>1.8</java.version>
<mybatis-plus.version>3.5.2</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
1.2.1 application.properties
server.port=8080
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/stock_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf-8
spring.datasource.username=root
spring.datasource.password=root
1.2.3 实体类
@Data
@TableName("stock")
public class Stock {
private Integer id;
private String productCode;
private String warehouse;
private Integer count;
}
1.2.3 dao层
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.coffee.lock.entity.Stock;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface StockMapper extends BaseMapper<Stock> {
}
1.2.4 service及其实现类
import com.baomidou.mybatisplus.extension.service.IService;
import com.coffee.lock.entity.Stock;
public interface StockService extends IService<Stock> {
void deduct();
}
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.coffee.lock.mapper.StockMapper;
import com.coffee.lock.entity.Stock;
import com.coffee.lock.service.StockService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Objects;
@Service
public class StockServiceImpl extends ServiceImpl<StockMapper, Stock> implements StockService {
@Autowired
private StockMapper stockMapper;
@Override
public void deduct() {
Stock stock = this.stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_code", "1001"));
if (!Objects.isNull(stock) && stock.getCount() > 0) {
stock.setCount(stock.getCount() - 1);
this.stockMapper.updateById(stock);
}
}
}
1.2.5 controller
import com.coffee.lock.service.StockService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/stock")
public class StockController {
@Autowired
private StockService stockService;
@GetMapping("/deduct")
public String deduct(){
stockService.deduct();
return "stock deduct success!!";
}
}
1.2.6 主启动类
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LockDemoApplication {
public static void main(String[] args) {
SpringApplication.run(LockDemoApplication.class, args);
}
}
2、JVM本地锁
2.1 synchronized(改造StockServiceImpl#deduct())
@Override
public synchronized void deduct() {
Stock stock = this.stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_code", "1001"));
if (!Objects.isNull(stock) && stock.getCount() > 0) {
stock.setCount(stock.getCount() - 1);
this.stockMapper.updateById(stock);
}
}
2.2 ReentrantLock(改造StockServiceImpl#deduct())
private final ReentrantLock lock = new ReentrantLock();
@Override
public void deduct() {
lock.lock();
try {
Stock stock = this.stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_code", "1001"));
if (!Objects.isNull(stock) && stock.getCount() > 0) {
stock.setCount(stock.getCount() - 1);
this.stockMapper.updateById(stock);
}
} finally {
lock.unlock();
}
}
2.3 JVM锁失效的三种情况
2.3.1 多例模式
Spring的Bean默认都是单类的,在加了JVM锁的情况下,可以保证不会出现超卖现象,但是当是多例时,JVM本地锁就不能解决问题,改造StockServiceImpl,添加@Scope注解,代码如下:
@Service
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class StockServiceImpl extends ServiceImpl<StockMapper, Stock> implements StockService {
······
}
2.3.2 事务
改造StockServiceImpl#deduct(),添加@Transactional注解,代码如下:
@Override
@Transactional(rollbackFor = Exception.class)
public void deduct() {
lock.lock();
try {
Stock stock = this.stockMapper.selectOne(new QueryWrapper<Stock>().eq("product_code", "1001"));
if (!Objects.isNull(stock) && stock.getCount() > 0) {
stock.setCount(stock.getCount() - 1);
this.stockMapper.updateById(stock);
}
} finally {
lock.unlock();
}
}
在有@Transactional时,JVM本地锁有时会失效,解决方式可以调整事务的隔离级别为“读未提交”,不过在现实开发中并不可取。
@Transactional(rollbackFor = Exception.class,isolation = Isolation.READ_UNCOMMITTED)
2.3.3 集群部署
当实例部署到不同服务器时,JVM本地锁无法解决超卖问题
3、Mysql锁
3.1 SQL优化
在上面的减库存过程中,使用到了两条sql,因为并不是原子性,所以需要加分布式锁来保证原子性,将两条sql改造为一条sql也能够解决上述超卖问题,因为mysql的update insert delete这些操作本身就会加锁来保证原子性。代码改动如下:
@Mapper
public interface StockMapper extends BaseMapper<Stock> {
@Update("update stock set count = count - #{count} where product_code = #{productCode} and count >= #{count}")
int updateStock(@Param("productCode") String productCode, @Param("count") Integer count);
}
// StockServiceImpl#deduct()
@Override
public void deduct() {
this.stockMapper.updateStock("1001", 1);
}
问题点考虑:
- 锁的范围(表锁 or 行锁):查询或更新条件为索引字段并且为具体的数值时,行锁生效
- 同一商品存在多条库存记录
- 无法记录库存变化前后状态
3.2 悲观锁(selete ···· for update)
分别改造StockServiceImpl#deduct()和StockMapper添加对应语句,代码如下:
@Mapper
public interface StockMapper extends BaseMapper<Stock> {
@Select("SELECT * FROM stock WHERE product_code = #{productCode} FOR UPDATE")
List<Stock> queryStock(String productCode);
}
@Override
@Transactional
public void deduct() {
// 1、查询库存信息并锁定库存信息
List<Stock> stockList = this.stockMapper.queryStock("1001");
// 2、取第一个库存判断库存是否充足
Stock stock = stockList.get(0);
if (stock != null && stock.getCount() > 0){
// 3、扣减库存
stock.setCount(stock.getCount() - 1);
stockMapper.updateById(stock);
}
}
思考:
- 解决同一商品存在多条库存记录
- 解决无法记录库存变化前后状态
- 容易造成死锁问题(加锁顺序)
- 性能问题
3.3 乐观锁
乐观锁的实现一般需要借助时间戳、version版本号及CAS机制实现
改造StockServiceImpl#deduct()方法及实体类与对应数据库字段添加version字段,代码详情如下:
@Override
public void deduct() {
// 1、查询库存信息并锁定库存信息
List<Stock> stockList = this.stockMapper.selectList(new QueryWrapper<Stock>().eq("product_code", "1001"));
// 2、取第一个库存判断库存是否充足
Stock stock = stockList.get(0);
if (stock != null && stock.getCount() > 0) {
// 3、扣减库存
stock.setCount(stock.getCount() - 1);
Integer version = stock.getVersion();
stock.setVersion(stock.getVersion() + 1);
if (this.stockMapper.update(stock, new QueryWrapper<Stock>().eq("product_code", "1001").eq("version", version)) == 0) {
try {
// 防止栈溢出问题
TimeUnit.MILLISECONDS.sleep(20);
// 更新失败则进行重试
this.deduct();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
问题点考虑:
- 高并发情况下,性能极低(递归非常耗CPU)
- 存在ABA问题
- 读写分离的情况下导致乐观锁不可靠
3.4 总结
性能:一个sql > 悲观锁 > jvm锁 > 乐观锁
- 如果追求极致性能、业务场景简单并且不需要记录数据前后变化情况下,优先使用一个sql
- 如果写并发量较低(多读),争抢不是很激烈的情况下,优先使用乐观锁
- 如果写并发量较高,选择乐观锁会导致业务不断重试,优先使用悲观锁
- 不推荐使用jvm本地锁
4、Redis锁
4.1 安装redis
本地下载安装redis参考官网redis,这里使用docker方式进行安装,具体步骤如下:
// 拉取镜像
docker pull redis:7.0
// 启动redis容器实例
docker run --name redis -p 6379:6379 -d redis:7.0
// 进入redis容器
docker exec -it redis /bin/bash
// 使用客户端连接redis,并验证
root@482d445353b89:/data# redis-cli
127.0.0.1:6379>
4.2 代码改造
4.2.1 pom.xml添加redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
4.2.2 application.properties配置redis主机地址
spring.redis.host=127.0.0.1
4.2.3 StockServiceImpl#deduct()改造
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void deduct() {
// 1、查询库存信息
String stock = this.redisTemplate.opsForValue().get("stock");
// 2、判断库存是否充足
if (stock != null && stock.length() > 0) {
int stockNum = Integer.valueOf(stock);
if (stockNum > 0) {
// 3、扣减库存
this.redisTemplate.opsForValue().set("stock", String.valueOf(--stockNum));
}
}
}
启动springboot应用,使用jmeter进行压测发现存在超卖现象,原因在于通过redisTemplate去获取库存和减库存的操作不是原子性,解决方式:
- jvm本地锁机制(针对单例情况有效)
- redis乐观锁:watch(监控一个或多个key的值,在执行事务(exec)之前,key的值发生变化则取消事务执行), multi(开启事务), exec(执行事务)
- 分布式锁
4.3 redis乐观锁
改造StockServiceImpl#deduct(),具体代码如下:
@Override
public void deduct() {
// watch
this.redisTemplate.execute(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations redisOperations) throws DataAccessException {
redisOperations.watch("stock");
// 1、查询库存信息
String stock = redisOperations.opsForValue().get("stock").toString();
// 2、判断库存是否充足
if (stock != null && stock.length() > 0) {
int stockNum = Integer.parseInt(stock);
if (stockNum > 0) {
// multi
redisOperations.multi();
// 3、扣减库存
redisOperations.opsForValue().set("stock", String.valueOf(--stockNum));
// exec执行事务,如果执行结果集为null,则代表减库存失败,进行重试
List exec = redisOperations.exec();
if (exec == null || exec.size() == 0) {
try {
TimeUnit.MILLISECONDS.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
deduct();
}
return exec;
}
}
return null;
}
});
}
结论:不推荐使用redis乐观锁,性能极低
4.4 redis分布式锁
该方案可以解决跨进程、跨服务、跨服务器问题,主要应用场景(超卖现象、缓存击穿等)
4.4.1 分布式锁实现之setIfAbsent()
@Override
public void deduct() {
// 加锁,失败则重试
while (Boolean.FALSE.equals(this.redisTemplate.opsForValue().setIfAbsent("stock-lock", "1111"))) {
try {
TimeUnit.MILLISECONDS.sleep(20);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
// 1、查询库存信息
String stock = this.redisTemplate.opsForValue().get("stock");
// 2、判断库存是否充足
if (stock != null && stock.length() > 0) {
int stockNum = Integer.parseInt(stock);
if (stockNum > 0) {
// 3、扣减库存
this.redisTemplate.opsForValue().set("stock", String.valueOf(--stockNum));
}
}
} finally {
this.redisTemplate.delete("stock-lock");
}
}
为防止死锁发生,则可以给锁添加过期时间,即优化版代码如下:
@Override
public void deduct() {
// 加锁、重试
while (Boolean.FALSE.equals(this.redisTemplate.opsForValue().setIfAbsent("stock-locl", "1111", 30, TimeUnit.SECONDS))) {
······
}
try {
······
} finally {
this.redisTemplate.delete("stock-locl");
}
}
为防止锁误删,则可以给锁添加UUID,即优化版代码如下:
@Override
public void deduct() {
String uuid = UUID.randomUUID().toString();
// 加锁、重试
while (Boolean.FALSE.equals(this.redisTemplate.opsForValue().setIfAbsent("stock-lock", uuid, 30, TimeUnit.SECONDS))) {
······
}
try {
······
} finally {
// 防止误删
if (uuid.equals(this.redisTemplate.opsForValue().get("stock-lock"))) {
this.redisTemplate.delete("stock-lock");
}
}
}
问题考虑:锁的判断与删除不是原子性,解决方案使用lua脚本
4.4.2 lua脚本解决误删原子性问题
lua脚本:一次性发送多个指令给redis,redis单线程执行指令准说one-by-one规则。具体代码如下:
@Override
public void deduct() {
String uuid = UUID.randomUUID().toString();
// 加锁、重试
while (Boolean.FALSE.equals(this.redisTemplate.opsForValue().setIfAbsent("stock-lock", uuid, 30, TimeUnit.SECONDS))) {
······
}
try {
······
} finally {
String luaScript = "if redis.call('get',KEYS[1]) == ARGV[1] " +
"then " +
" return redis.call('del',KEYS[1]) " +
"else " +
" return 0 " +
"end";
// 防止误删
this.redisTemplate.execute(new DefaultRedisScript<>(luaScript, Boolean.class), Collections.singletonList("stock-lock"), uuid);
}
}
4.5 RedLock红锁算法
背景:在redis集群场景下,请求一个分布式锁的时候,成功了,但是这时候slave还没有复制我们的锁,master节点down掉了,我们的应用继续请求锁的时候,会从继任了master的原slave上申请,也会成功。所以引入了红锁的概念。红锁采用主节点过半机制,即获取锁或者释放锁成功的标志为:在过半的节点上操作成功。用Redis中的多个master实例,来获取锁,只有大多数实例获取到了锁,才算是获取成功。具体的红锁算法加锁分为以下五步:
1. 获取当前的时间(单位是毫秒)。
2. 使用相同的key和随机值在N个节点上请求锁。这里获取锁的尝试时间要远远小于锁的超时时间,防止某个master节点down了,我们还在不断的获取锁,而被阻塞过长的时间。
3. 只有在大多数节点上获取到了锁,而且总的获取时间小于锁的超时时间的情况下,认为锁获取成功了。
4. 如果锁获取成功了,锁的超时时间就是最初的锁超时时间减去获取锁的总耗时时间。
5. 如果锁获取失败了,不管是因为获取成功的节点的数目没有过半,还是因为获取锁的耗时超过了锁的释放时间,都会将已经设置了key的节点上的key删除。
5、Redisson
5.1 引入相关依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>${redisson.version}</version>
</dependency>
5.2 注入RedissonClient
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String redisHost;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 还可设置用户名、密码、连接池等信息
config.useSingleServer().setAddress("redis://" + redisHost + ":6379");
return Redisson.create(config);
}
}
5.3 改造StockServiceImpl#deduct()
@Autowired
private RedissonClient redissonClient;
@Override
public void deduct() {
RLock lock = redissonClient.getLock("stock-lock");
lock.lock(30, TimeUnit.SECONDS);
try {
// 1、查询库存信息
String stock = this.redisTemplate.opsForValue().get("stock");
// 2、判断库存是否充足
if (stock != null && stock.length() > 0) {
int stockNum = Integer.parseInt(stock);
if (stockNum > 0) {
// 3、扣减库存
this.redisTemplate.opsForValue().set("stock", String.valueOf(--stockNum));
}
}
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
5.4 常见Redisson锁
- 可重入锁(Reentrant Lock)
- 公平锁(Fair Lock)
- 联锁(MultiLock)
- 红锁(RedLock)
- 读写锁(ReadWriteLock)
- 信号量(Semaphore)
- 可过期性信号量(PermitExpirableSemaphore)
- 闭锁(CountDownLatch)
具体详情及使用可参考 分布式锁和同步器