第一章_从减库存聊起

在多线程高并发场景下,为了保证资源的线程安全问题, jdk 为我们提供了 synchronized 关键字和ReentrantLock 可重入锁,但是它们只能保证一个 jvm 内的线程安全。在分布式集群、微服务、云原生横行的当下,如何保证不同进程、不同服务、不同机器的线程安全问题, jdk 并没有给我们提供既有的 解决方案。此时,我们就必须借助于相关技术手动实现了。目前主流的实现有三种方式:

  1.  基于mysql关系型实现
  2.  基于redis非关系型数据实现
  3.  基于zookeeper实现

从减库存聊起

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

场景

商品 S 库存余量为 5 时,用户 A 和 B 同时来购买一个商品 S ,此时查询库存数都为 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 才对。

环境准备

建表语句:

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=2 DEFAULT CHARSET=utf8;

表中数据如下:

1001商品在001仓库有5000件库存。

创建分布式锁demo工程

第一章_从减库存聊起_第1张图片

创建好之后

第一章_从减库存聊起_第2张图片

pom.xml 如下



    4.0.0
 
    
        org.springframework.boot
        spring-boot-starter-parent
        2.6.5
         
    
 
    com.lzx
    distributed_lock
    1.0
 
    
        1.8
        8
        8
    
 
    
        
            org.springframework.boot
            spring-boot-starter-web
        
 
        
            mysql
            mysql-connector-java
        
 
        
            com.baomidou
            mybatis-plus-boot-starter
            3.4.0
        
 
        
            org.projectlombok
            lombok
            1.18.16
        
 
        
            org.springframework.boot
            spring-boot-starter-data-redis
        
 
        
            org.springframework.boot
            spring-boot-devtools
        
 
        
            org.springframework.boot
            spring-boot-starter-test
            test
            
                
                    org.junit.vintage
                    junit-vintage-engine
                
            
        
    
 
    
        
            
                org.springframework.boot
                spring-boot-maven-plugin
            
        
    
 

 application.yml 配置文件

server:
  port: 6000
 
spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://172.16.116.100:3306/test
    username: root
    password: root
  redis:
    host: 172.16.116.100

 DistributedLockApplication 启动类

@SpringBootApplication
@MapperScan("com.lzx.distributedlock.mapper")
public class DistributedLockApplication {
 
    public static void main(String[] args) {
       SpringApplication.run(DistributedLockApplication.class, args);
    }
 
}

Stock 实体类

@Data
@TableName("db_stock")
public class Stock {
 
    @TableId
    private Long id;
 
    private String productCode;
 
    private String stockCode;
 
    private Integer count;
 
}

StockMapper 接口

public interface StockMapper extends BaseMapper {
 
}

简单实现减库存

接下来代码实操一下

第一章_从减库存聊起_第3张图片

StockController

@RestController
public class StockController {
 
   @Autowired
   private StockService stockService;
 
   @GetMapping("/check/lock")
   public String checkAndLock(){
       this.stockService.checkAndLock();
       return "验库存并锁库存成功!";
   }
 
}

StockService

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

测试

第一章_从减库存聊起_第4张图片

查看数据库

第一章_从减库存聊起_第5张图片

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

演示超卖现象

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

第一章_从减库存聊起_第6张图片

第一章_从减库存聊起_第7张图片

给线程组添加HTTP Request请求

第一章_从减库存聊起_第8张图片

填写测试接口路径如下

第一章_从减库存聊起_第9张图片

再选择你想要的测试报表,例如这里选择聚合报告 

第一章_从减库存聊起_第10张图片

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

第一章_从减库存聊起_第11张图片

测试结果:请求总数 5000 次,平均请求时间 202ms ,中位数( 50% )请求是在 173ms 内完成的, 90%请求是在 344ms 内完成的,最小耗时 12ms ,最大耗时 1125ms ,错误率 0% ,每秒钟平均 473.8 次。

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

第一章_从减库存聊起_第12张图片

此时如果还有人来下单,就会出现超卖现象(别人购买成功,而无货可发)。

jvm 锁问题演示

添加 jvm 锁

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

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    public synchronized void checkAndLock() {
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectById(1L);

        // 再减库存
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }

    }

}

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

查看 mysql 数据库

第一章_从减库存聊起_第13张图片

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

原理

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

第一章_从减库存聊起_第14张图片

事务问题

修改库存扣减方法,在上面添加事务注解试试

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    @Transactional(rollbackFor = Exception.class)
    public synchronized void checkAndLock() {
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectById(1L);

        // 再减库存
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }

    }

}

使用jmeter压力测试结果

又出现了问题

原因分析

由于springboot事务是基于aop实现的,mysql的默认事务隔离级别为RR,可重复读,假设当前有两个线程同时并发

第一章_从减库存聊起_第15张图片

最终导致库存更新出现问题

下面修改事务隔离级别为读未提交

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    @Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_UNCOMMITTED)
    public synchronized void checkAndLock() {
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectById(1L);

        // 再减库存
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }

    }

}

使用jmeter压力测试结果

并没有发生超卖现象,解决了问题;但是一般不会这么做,因为在真实的库存扣减中通常伴随着订单等多表的操作,不单单只是库存表,可能会引起其他的问题。

多例问题

/**
 * Scope注解添加了一个proxyMode的属性,有两个值ScopedProxyMode.INTERFACES和ScopedProxyMode.TARGET_CLASS,前一个表示Service是一个接口,后一个表示Service是一个类
 */
@Service
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    public synchronized void checkAndLock() {
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectById(1L);

        // 再减库存
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }

    }

}

使用jmeter压力测试结果

多例模式下,库存扣减也出现了问题,这与下面的多服务问题有些类似 。

多服务问题

使用 jvm 锁在单工程单服务情况下确实没有问题,但是在集群情况下会怎样?
接下启动多个服务并使用 nginx 负载均衡,结构如下

第一章_从减库存聊起_第16张图片

启动三个服务(端口号分别8000 8100 8200),如下

第一章_从减库存聊起_第17张图片

安装配置nginx 

基于安装 nginx

#拉取镜像
docker pull nginx:latest
 
#创建nginx对应资源、日志及配置目录
mkdir -p /opt/nginx/logs /opt/nginx/conf /opt/nginx/html
 
#先在conf目录下创建nginx.conf文件,配置内容参照下方
#再运行容器
docker run -d -p 80:80 --name nginx -v /opt/nginx/html:/usr/share/nginx/html -v /opt/nginx/conf/nginx.conf:/etc/nginx/nginx.conf -v
/opt/nginx/logs:/var/log/nginx nginx

nginx.conf 配置如下

user nginx;
worker_processes 1;

error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;

    default_type application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request"'
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for"';

    access_log /var/log/nginx/access.log main;

    sendfile on;
    #tcp_nopush on;

    keepalive_timeout 65;

    #gzip on;

    #include /etc/nginx/conf.d/*.conf;

    upstream distributed {
       server 172.16.116.10:8000;
       server 172.16.116.10:8100;
       server 172.16.116.10:8200;
    }

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

在浏览器中测试:172.16.116.100是nginx服务器地址

经过测试,通过nginx访问服务正常

压力测试 

注意:先把数据库库存量还原到 5000 。

参照之前的测试用例,再创建一个新的测试组:参数跟之前一样。

第一章_从减库存聊起_第18张图片

配置nginx的地址及 服务的访问路径如下

第一章_从减库存聊起_第19张图片

测试结果:性能只是略有提升

数据库库存剩余量如下 

又出现了并发问题,即出现了超卖现象

mysql 锁演示 

除了使用 jvm 锁之外,还可以使用数据锁: 悲观锁 或者 乐观锁。
悲观锁:在读取数据时锁住那几行,其他对这几行的更新需要等到悲观锁结束时才能继续 。
乐观所:读取数据时不锁,更新时检查是否数据已经被更新过,如果是则取消当前更新,一般在悲观锁的等待时间过长而不能接受时我们才会选择乐观锁。

悲观锁

在select的时候就会加锁,采用先加锁后处理的模式,虽然保证了数据处理的安全性,但也会阻塞其他线程的写操作。悲观锁适用于写多读少的场景,因为拿不到锁的线程,会将线程挂起,交出CPU资源,可以把CPU给其他线程使用,提高了CPU的利用率。

乐观锁

在select的时候不会加锁,是基于程序实现的,所以不会存在死锁的情况。
适用于读多写少的场景(写的并发量相对不高),可以提高系统的吞吐量。
因为如果写多的话,乐观锁会有很大机率更新失败,需要不断的自旋执行查找和更新操作。
自旋的时候会一直占用CPU,会耗费大量的CPU资源。

锁分类

悲观锁:具有强烈的独占和排他特性,在整个数据处理过程中,将数据处于锁定状态。适合于写比较多,会阻塞读操作。

乐观锁:采取了更加宽松的加锁机制,大多是基于数据版本( Version )及时间戳来实现。适合于读比较多,不会阻塞读。

独占锁、互斥锁、排他锁:保证在任一时刻,只能被一个线程独占排他持有。synchronized、ReentrantLock。

共享锁:可同时被多个线程共享持有。CountDownLatch到计数器、Semaphore信号量。

可重入锁:又名递归锁。同一个线程在外层方法获取锁的时候,在进入内层方法时会自动获取锁。

不可重入锁:不可重入锁不可递归调用,递归调用就发生死锁,不可重入锁也叫自旋锁。

公平锁:有优先级的锁,先来先得,谁先申请锁就先获取到锁。

非公平锁:无优先级的锁,后来者也有机会先获取到锁。

自旋锁:当线程尝试获取锁失败时(锁已经被其它线程占用了),无限循环重试尝试获取锁。

阻塞锁:当线程尝试获取锁失败时,线程进入阻塞状态,直到接收信号后被唤醒。在竞争激烈情况下,性能较高。

读锁:共享锁。

写锁:独占排他锁。

偏向锁:一直被一个线程所访问,那么该线程会自动获取锁。

轻量级锁(CAS):当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁:当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候(10次),还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。

以上其实是synchronized的锁升级过程。

表级锁:对整张表加锁,加锁快开销小,不会出现死锁,但并发度低,会增加锁冲突的概率。

行级锁:是mysql粒度最小的锁,只针对操作行,可大大减少锁冲突概率,并发度高,但加锁慢,开销大,会出现死锁。

具体锁实现

jvm

ReentrantLock悲观的独占的可重入的可公平可不公平锁。

synchronized悲观的独占的可重入的非公平锁

无锁 --> 偏向锁(同一个线程再次获取锁) --> 轻量级锁(自旋) --> 重量级锁

ReentrantLock + synchronized(必要条件)

  1. 单个jvm实例单机
  2. 必须单例
  3. 与事务并存问题(上面说到的事务问题)

总之,不适合于保证数据库数据可靠性。

mysql

select ... for update:悲观的独占的

select ... lock in share mode

  1. 直接更新时判断。在更新中判断库存是否大于0 update table set surplus = (surplus - buyQuantity) where id = 1 and (surplus - buyQuantity) > 0;解决jvm锁多例模式锁失效问题及事务共存问题,锁范围控制:条件字段必须创建索引;查询条件必须具体的值,同一个商品有多个库存时,无法解决。无法记录库存变化前后的状态。
  2. 悲观锁:select ... for update,库存操作要统一:不能有的操作是select ... for update 而有的操作是普通的select。 具有死锁风险:多条记录时,加锁顺序要一致,  阻塞及性能问题。
  3. 乐观锁:version 或者 时间戳(CAS思想),ABA问题, 失败需要重试,高并发情况下性能不高, 读写分离情况下导致乐观锁不可靠。

mysql 悲观锁实现

在 MySQL的InnoDB中,预设的Tansaction isolation level 为 REPEATABLE READ(可重读)

在 SELECT 的读取锁定主要分为两种方式

SELECT ... LOCK IN SHARE MODE(共享锁)

SELECT ... FOR UPDATE (悲观锁)

这两种方式在事务 (Transaction) 进行当中 SELECT 到同一个数据表时,都必须等待其它事务数据被提交(Commit) 后才会执行。 而主要的不同在于LOCK IN SHARE MODE在有一方事务要Update 同一个表单时很容易造成死锁。简单的说,如果 SELECT 后面若要 UPDATE 同一个表单,最好使用 SELECT ... FOR UPDATE 。

代码实现

改造 StockService

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    @Transactional(rollbackFor = Exception.class)
    public void checkAndLock() {
        // 先查询库存是否充足
        Stock stock = this.stockMapper.selectStockForUpdate(1L);

        // 再减库存
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            this.stockMapper.updateById(stock);
        }

    }

}

在 StockeMapper 中定义 selectStockForUpdate 方法

public interface StockMapper extends BaseMapper {
 
    public Stock selectStockForUpdate(Long id);
 
}

在 StockMapper.xml 中定义对应的配置




 
   
 

压力测试

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

mysql 数据库存

mysql 乐观锁实现

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

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

给 db_stock 表添加 version 字段

对应也需要给Stock实体类添加version属性

Stock实体类

@Data
@TableName("db_stock")
public class Stock {
 
    @TableId
    private Long id;
 
    private String productCode;
 
    private String stockCode;
 
    private Integer count;
 
    private Long version;

}

代码实现

@Service
public class StockService {

    @Autowired
    private StockMapper stockMapper;

    public void checkAndLock() {
        // 先查询库存是否充足
        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()
                    .eq("id", stock.getId())
                    .eq("version", version)) == 0) {
                checkAndLock();
            }
        }

    }

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

修改测试参数如下

第一章_从减库存聊起_第20张图片

测试结果如下 

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

mysql 锁缺陷

在数据库集群情况下会导致数据库锁失效,并且很多数据库集群的中间件压根就不支持悲观锁。例如: mycat 在读写分离的场景下可能会导致乐观锁不可靠。这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。这就需要引入分布式锁来解决问题。

你可能感兴趣的:(#,分布式锁,jvm,java,开发语言,分布式)