1、传统锁回顾(Jvm本地锁,MySQL悲观锁、乐观锁)

目录

1.1 从减库存聊起

多线程并发安全问题最典型的代表就是超卖现象

库存在并发量较大情况下很容易发生超卖现象,一旦发生超卖现象,就会出现多成交了订单而发不了货的情况。

场景:商品S库存余量为5时,用户A和B同时来购买一个商品,此时查询库存数都为5,库存充足则开始减库存

用户A:update db_stock set stock = stock - 1 where id = 1

用户B:update db_stock set stock = stock - 1 where id = 1

并发情况下,更新后的结果可能是4,而实际的最终库存量应该是3才对 !!

1.2 环境准备

建表语句:

sql 复制代码
CREATE TABLE `db_stock` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `product_code` varchar(255) DEFAULT NULL COMMENT '商品编号',
  `stock_code` varchar(255) DEFAULT NULL COMMENT '仓库编号',
  `count` int(11) DEFAULT NULL COMMENT '库存量',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

表中数据如下:

创建分布式锁demo工程:

目录结构

pom.xml

xml 复制代码
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.46</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.3.4</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

application.yml配置文件:

java 复制代码
server.port=10010
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://192.168.239.11:3306/atguigu_distributed_lock
spring.datasource.username=root
spring.datasource.password=houchen

DistributedLockApplication启动类:

java 复制代码
@SpringBootApplication
@MapperScan("com.atguigu.distributed.lock.mapper")
public class DistributedLockApplication {

    public static void main(String[] args) {
        SpringApplication.run(DistributedLockApplication.class, args);
    }

}

Stock实体类:

java 复制代码
@Data
@TableName("db_stock")
public class Stock {

    @TableId
    private Long id;

    private String productCode;

    private String stockCode;

    private Integer count;
}

StockMapper接口:

java 复制代码
public interface StockMapper extends BaseMapper<Stock> {
}

1.3 简单实现减库存

java 复制代码
@RestController
public class StockController {

    @Autowired
    private StockService stockService;

    @GetMapping("stock/deduct")
    public String deduct(){
        this.stockService.deduct();
        return "hello stock deduct!!";
    }

}

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    public void  deduct(){
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectById(1L);
        // 再减库存
        if (stock != null && stock.getCount() > 0){
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }
    }
}

测试:

查看数据库:

在浏览器中一个一个访问时,每访问一次,库存量减1,没有任何问题。

1.4 演示超卖现象

使用jmeter压力测试工具,高并发下压测一下,添加线程组:并发100循环50次,即5000次请求。

启动测试,查看压力测试报告:

  • Label 取样器别名,如果勾选Include group name ,则会添加线程组的名称作为前缀
  • Samples 取样器运行次数

  • Average 请求(事务)的平均响应时间
  • Median 中位数
  • 90% Line 90%用户响应时间
  • 95% Line 90%用户响应时间
  • 99% Line 90%用户响应时间
  • Min 最小响应时间
  • Max 最大响应时间
  • Error 错误率
  • Throughput 吞吐率
  • Received KB/sec 每秒收到的千字节
  • Sent KB/sec 每秒收到的千字节

查看mysql数据库剩余库存数:还有4818

1.5 jvm锁

使用jvm锁(synchronized关键字或者ReetrantLock)试试:

java 复制代码
 /**
     *  使用jvm锁来解决超卖问题
     */
    public synchronized void deduct() {
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectById(1L);
        // 再减库存
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }
    }

重启tomcat服务,再次使用jmeter压力测试,效果如下:

可以看到,加锁之后,吞吐量减少了一倍多!

查看mysql数据库:

并没有发生超卖现象,完美解决。

原理

添加synchronized关键字之后,同一时刻只有一个请求能够获取到锁,并减库存。此时,所有请求只会one-by-one执行下去,也就不会发生超卖现象

1.6 三种情况导致Jvm本地锁失效

1、多例模式下,Jvm本地锁失效

原理:StockService有多个对象,不同的对象持有不同的锁,所以还是会有多个线程进入到 临界区

演示:

java 复制代码
@Service
@Scope(value = "prototype",proxyMode = ScopedProxyMode.TARGET_CLASS)
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    /**
     *  使用jvm锁来解决超卖问题
     */
    public synchronized void deduct() {
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectById(1L);
        // 再减库存
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }
    }
}

重启tomcat服务,再次使用jmeter压力测试,查看数据库,发现库存确实没有减到 0 ,发生超卖

2、Spring的事务导致Jvm本地锁失效

在加锁的地方加上 @Transactional 注解

java 复制代码
 @Transactional
    public synchronized void deduct() {
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectById(1L);
        // 再减库存
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }
    }

重启tomcat服务,再次使用jmeter压力测试,查看数据库,发现库存确实没有减到 0 ,发生超卖

造成超卖的原因:

Spring事务默认的隔离级别是可重复读

解决办法

扩大锁的范围,将开启事务,提交事务也包括在锁的代码块中

java 复制代码
 @GetMapping("stock/deduct")
    public String deduct(){
        synchronized (this) {
            this.stockService.deduct();
        }
        return "hello stock deduct!!";
    }

3、集群部署导致Jvm本地锁失效

使用jvm锁在单工程单服务情况下确实没有问题,但是在集群情况下会怎样?

接下启动多个服务并使用nginx负载均衡

1)启动两个服务(端口号分别10010 10086),如下:

2)配置nginx 负载均衡

java 复制代码
#user  nobody;
worker_processes  1;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
	
	upstream distributed {
		server localhost:10010;
		server localhost:10086;
	}

    server {
        listen       80;
        server_name  localhost;
		location / {
			proxy_pass http://distributed;
		}
    }
}

3)在post中测试:http://localhost/stock/deduct (其中80是nginx的监听端口)

请求正常,说明nginx负载均衡起作用了

4) Jmeter压力测试

注意

查看数据库,库存不为0,表示多服务时,Jvm锁失效

5) 原因

每个服务都有自己的本地锁,所以无法锁住临界区,导致多线程的安全问题

1.7 mysql锁演示

除了使用jvm锁之外,还可以使用mysql自带的锁:悲观锁 或者 乐观锁

1.7.1、一个sql

sql 复制代码
update db_stock set count = count - 1 where product_code = '1001' and count >= #{count}
java 复制代码
public void deduct() {
        this.stockMapper.updateStock("1001", 1);
    }
    
 public interface StockMapper extends BaseMapper<Stock> {
    @Update("update db_stock set count = count - #{count} where product_code = #{productCode} and count >= #{count}")
    int updateStock(@Param("productCode") String productCode, @Param("count") Integer count);
}

这种方式可以解决上述Jvm锁失效的三个问题

缺点:

1、确定好锁范围

当使用的是表锁时,会导致系统的吞吐量直线下降

​ 什么情况下会使用行级锁

​ 1)锁的查询或者更新条件必须是索引字段

​ 2) 查询或者更新条件必须是具体值

2、一件商品多个仓库问题无法处理

3、无法记录仓库变化前后的状态

1.7.2、悲观锁

sql 复制代码
SELECT ... FOR UPDATE                     (悲观锁)

代码实现

改造StockService: 添加事务注解,去掉synchronized关键词

java 复制代码
@Transactional
    public void deduct() {
        Stock stocks = this.stockMapper.queryStockForUpdate("1001");
        if (stocks != null && stocks.getCount() > 0) {
            stocks.setCount(stocks.getCount() - 1);
            this.stockMapper.updateById(stocks);
        }
    }

在StockeMapper中定义selectStockForUpdate方法:

java 复制代码
public interface StockMapper extends BaseMapper<Stock> {


    @Update("update db_stock set count = count - #{count} where product_code = #{productCode} and count >= #{count}")
    int updateStock(@Param("productCode") String productCode, @Param("count") Integer count);

    @Select("select * from db_stock where product_code = #{productCode} for update")
    Stock queryStockForUpdate(@Param("productCode") String productCode);
}

压力测试

注意:测试之前,需要把库存量改成5000。压测数据如下:比jvm锁性能高很多

mysql数据库存:

【注意】使用MySQL乐观锁时,也需要注意锁的粒度,尽量使用行级锁,否则系统吞吐量会降低

1.7.3、乐观锁

乐观锁是相对悲观锁而言,乐观锁假设认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则重试。

使用数据版本(Version)记录机制实现,这是乐观锁最常用的实现 方式。一般是通过为数据库表增加一个数字类型的 "version" 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新。

给db_stock表添加version字段:

改造 StockService

java 复制代码
  /**
     *  使用MySQL乐观锁来解决库存超卖问题
     */
    public void deduct() {
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectById(1L);
        // 再减库存
        if (stock != null && stock.getCount() > 0){
            // 获取版本号
            Long version = stock.getVersion();

            stock.setCount(stock.getCount() - 1);
            // 每次更新 版本号 + 1
            stock.setVersion(stock.getVersion() + 1);
            // 更新之前先判断是否是之前查询的那个版本,如果不是重试
            if (this.stockMapper.update(stock, new UpdateWrapper<Stock>().eq("id", stock.getId()).eq("version", version)) == 0) {
                deduct();
            }
        }
    }

重启后使用jmeter压力测试工具结果如下:

并发度比较低,说明乐观锁在并发量越大的情况下,性能越低(因为需要大量的重试);并发量越小,性能越高。

乐观锁存在的问题

  • 高并发情况下,性能较低
  • ABA问题
  • 读写分离的情况下,可能会导致乐观锁不可靠

1.7.4、mysql锁总结

性能:一个sql > 悲观锁 > jvm锁 > 乐观锁

  • 如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下。

​ 优先选择:一个sql

  • 如果写并发量较低(多读),争抢不是很激烈的情况下优先选择:乐观锁

  • 如果写并发量较高,一般会经常冲突,此时选择乐观锁的话,会导致业务代码不间断的重试。

​ 优先选择:mysql悲观锁

  • 不推荐jvm本地锁。

1.8 redis乐观锁

1.8.1 引入redis

见我的博客 https://blog.csdn.net/hc1285653662/article/details/127564372 中的SpringDataRedis客户端

改造StockService

java 复制代码
  /**
     * 为了提高请求响应的速度,将库存放在redis中进行操作
     */
    public void deduct() {
        // 先查询库存是否充足
        String stockStr = redisTemplate.opsForValue().get("stock:" + "1001");
        Long stock = Long.parseLong(stockStr);
        if (stock != null && stock > 0) {
            redisTemplate.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
        }
    }

演示redis库存超卖

设置redis库存为 5000

jmeter启动测试,可以看到并发比无锁时候的mysql库存要高

查询redis库存,发现剩余库存不为0,所以发生超卖现象

1.8.2 redis乐观锁原理

使用watch命令监视某个key,如果在监视的过程中该key被某个客户端修改后,那么自身对于key的修改将会失败

1.8.3 redis乐观锁解决超卖问题

改造StockService

java 复制代码
/**
     * 为了提高请求响应的速度,将库存放在redis中进行操作
     */
    public void deduct() {
        // 监听 stock:1001
        redisTemplate.execute(new SessionCallback<Object>() {
            @Override
            public Object execute(RedisOperations operations) throws DataAccessException {
                operations.watch("stock:" + "1001");
                String stockStr = (String) operations.opsForValue().get("stock:" + "1001");
                Long stock = Long.parseLong(stockStr);
                if (stock != null && stock > 0) {
                    operations.multi();
                    operations.opsForValue().set("stock:" + "1001", String.valueOf(stock - 1));
                    List exec = operations.exec();
                    // 如果减库存失败,代表key别其他客户端修改了,则进行重试
                    if (exec == null || exec.size() == 0) {
                        try {
                            Thread.sleep(50);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        deduct();
                    }
                    return exec;
                }
                return null;
            }
        });
    }

查看测试结果:发现并发很低(可能因为我redis部署在阿里云上的docker里,网络开销导致并发很低),但是确实解决超卖问题

1.8.4 redis乐观锁的缺点

  • 性能问题
相关推荐
东阳马生架构4 天前
分布式锁—6.Redisson的同步器组件
分布式锁
东阳马生架构4 天前
分布式锁—5.Redisson的读写锁一
分布式·分布式锁·redisson
东阳马生架构5 天前
分布式锁—5.Redisson的读写锁
分布式锁
东阳马生架构5 天前
分布式锁—4.Redisson的联锁和红锁二
分布式锁·redisson
东阳马生架构6 天前
分布式锁—4.Redisson的联锁和红锁
分布式锁
东阳马生架构6 天前
分布式锁—3.Redisson的公平锁一
分布式·分布式锁·redisson
东阳马生架构7 天前
分布式锁—3.Redisson的公平锁
分布式锁
试着奔跑的菜鸟7 天前
经验分享:用一张表解决并发冲突!数据库事务锁的核心实现逻辑
java·经验分享·高并发·分布式锁