分布式锁入门到精通

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)

具体详情及使用可参考 分布式锁和同步器

6、Zookeeper待完善

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值