分布式锁的应用场景与分布式锁实现(一):传统锁处理并发及传统锁的问题

分布式锁

代码已同步至GitCode:https://gitcode.net/ruozhuliufeng/distributed-project.git

​ 在应用开发中,特别是Web工程开发,通常都是并发编程,不是多进程就是多线程。这种场景下极其容易出现线程并发性问题,此时不得不使用锁来解决问题。在多线程高并发场景下,为了保证资源的线程安全问题,jdk为我们提供了synchronized关键字和ReentrantLock可重入锁,但是它们只能提供一个工程内的线程安全。在分布式集群、微服务、云原生横行的当下,如何保证不同进程、不同服务、不同机器的线程安全问题,JDK并没有给我们提供既有的解决方案。此时,我们就必须借助于相关技术手动实现了。目前主流的实现有以下方式:

  • 基于MySQL关系型实现
  • 基于Redis非关系型数据实现
  • 基于Zookeeper/etcd实现

问题引入

从减库存说起

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

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

场景:

​ 商品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才对。

环境准备

  • 数据库:MySQL 5.7
  • JAVA版本:1.8
  • 工程构建工具:Maven
  • 框架:SpringBoot、SpringMVC、MyBatis-Plus、SpringDataRedis
  • 开发工具:IDEA
  • 缓存服务:Redis
  • 负载均衡工具:Nginx
  • 接口与压测工具:Jmeter
创建基础数据表
  • 创建数据库表:db_stock
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;
  • 新增测试数据
INSERT INTO `distributed_lock`.`db_stock` (`id`, `product_code`, `stock_code`, `count`) VALUES (1, '1001', '001', 5000);
创建分布式锁demo工程
  • 使用IDEA新建SpringBoot项目,本次测试项目名:distributed-lock
  • 更新pom.xml文件,新增相关依赖
    <dependencies>
        <!-- Spring -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <!--springboot默认使用内置tomcat,需要手动排除然后引入undertow(各方面性能更好,更稳定) -->
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-tomcat</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-undertow</artifactId>
        </dependency>
        <!-- MySQL -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.28</version>
        </dependency>
        <!-- MyBatis Plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>
        <!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
    </dependencies>
  • 创建application.yml文件,配置项目信息
server:
  # 端口
  port: 8001
spring:
  # 数据库
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/distributed_lock?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
  # Redis配置
  redis:
    host: localhost
    database: 0
    port: 6379
  • 启动类新增Mapper包扫描
@SpringBootApplication
@MapperScan("tech.msop.distributed.lock.mapper")
public class DistributedLockApplication {
    public static void main(String[] args) {
        SpringApplication.run(DistributedLockApplication.class, args);
    }
}
  • 新增实体类:StockEntity
/**
 * 库存信息实体
 */
@Data
@TableName("db_stock")
public class StockEntity {
    /**
     * 主键ID
     */
    @TableId(value = "id", type = IdType.AUTO)
    private Integer id;
    /**
     * 商品编号
     */
    private String productCode;
    /**
     * 仓库编号
     */
    private String stockCode;
    /**
     * 库存量
     */
    private Integer count=5000;
}
  • 新增Mapper接口:StockMapper
public interface StockMapper extends BaseMapper<StockEntity> {
}
  • 新增Service服务:StockService

    • IStockService
    public interface IStockService extends IService<StockEntity> {
    }
    
    • StockServiceImpl
    @Service
    public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
            implements IStockService {
    }
    
  • 新增控制器:StockController

@RequestMapping("/stock")
@RestController
@RequiredArgsConstructor
public class StockController {
    private final IStockService stockService;

}
  • 基础项目结构如下:

image-20230526141726828

简单实现减库存
  • 修改StockController
package tech.msop.distributed.lock.controller;

import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tech.msop.distributed.lock.service.IStockService;

/**
 * 库存 控制器
 */
@RequestMapping("/stock")
@RestController
@RequiredArgsConstructor
public class StockController {
    private final IStockService stockService;

    /**
     * 减库存
     * @return
     */
    @GetMapping("/check/lock")
    public String checkAndLock(){
        stockService.checkAndLock();
        return "验证库存并锁库存成功";
    }

    /**
     * 库存重置
     */
    @GetMapping("/reset")
    public void reset(){
        stockService.reset();
    }

}

  • 修改StockService
package tech.msop.distributed.lock.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;

/**
 * 库存服务实现类
 */
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
        implements IStockService {
    private StockEntity stock = new StockEntity();
    /**
     * 减库存
     */
    @Override
    public void checkAndLock() {
        stock.setCount(stock.getCount() - 1);
        log.info("库存余量:{}",stock.getCount());
//        // 先查询库存是否充足
//        StockEntity stock = this.getById(1);
//        // 再减1个库存
//        if (stock != null && stock.getCount() >0 ){
//            stock.setCount(stock.getCount() - 1);
//            this.updateById(stock);
//        }
    }

    /**
     * 重置库存数量
     */
    @Override
    public void reset() {
        this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);
    }
}

  • 修改StockMapper
package tech.msop.distributed.lock.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import tech.msop.distributed.lock.entity.StockEntity;

public interface StockMapper extends BaseMapper<StockEntity> {

    void reset(@Param("count") Integer defaultStockCount);
}

  • 修改StockMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="tech.msop.distributed.lock.mapper.StockMapper">

    <update id="reset">
        update db_stock
        set count = #{count}
    </update>
</mapper>

  • 接口调用并测试

image-20230526143845817

  • 查看控制台

image-20230526153856480

​ 使用接口一次一次调用时,每访问一次,库存量减1,没有任何问题。

简单演示超卖现象

​ 使用Jmeter压力测试工具,高并发下压测一下。恢复库存数为5000,添加线程组:并发100循环50次,即5000次请求。

image-20230526145321738

image-20230526145423703

​ 给线程组添加HTTP Request请求

image-20230526145503242

​ 添加测试接口与请求路径

image-20230526145553926

​ 选择想要的测试报表,这里选择聚和报告:

image-20230526145703422

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

image-20230526154103065

  • Label 取样器别名,如果勾选Include Group Name,则会添加线程组的名称作为前缀
  • # Samples 取样器运行测试
  • Average 请求(事务)的平均响应时间
  • Median 中位数
  • 90%Line 90%用户响应时间
  • 95%Line 95%用户响应时间
  • 99%Line 99%用户响应时间
  • Min 最小响应时间
  • Max 最大响应时间
  • Error 错误率
  • Throughput 吞吐率
  • Received KB/sec 每秒收到的千字节
  • Sent KB/sec 每秒发送的千字节

​ 测试结果:请求总数5000次,平均请求时间54ms,中位数(50%)请求在26ms内完成的,错误率0%,每秒钟平均吞吐率1396.3次。

​ 查看数据库剩余库存数:461

image-20230526154206692

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

传统锁处理

JVM本地锁处理
使用JVM锁:synchronized关键字
package tech.msop.distributed.lock.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;

/**
 * 库存服务实现类
 */
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
        implements IStockService {
    private StockEntity stock = new StockEntity();
    /**
     * 减库存
     */
    @Override
    public synchronized void checkAndLock() {
        stock.setCount(stock.getCount() - 1);
        log.info("库存余量:{}",stock.getCount());
    }
}

​ Jmeter压测测试报告:

image-20230526154726384

​ 库存余量:0

image-20230526154740075

使用JVM锁:ReetrantLock
package tech.msop.distributed.lock.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;

import java.util.concurrent.locks.ReentrantLock;

/**
 * 库存服务实现类
 */
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
        implements IStockService {
    private StockEntity stock = new StockEntity();
    private ReentrantLock lock = new ReentrantLock();
    /**
     * 减库存
     */
    @Override
    public synchronized void checkAndLock() {
        lock.lock();
        try{
            stock.setCount(stock.getCount() - 1);
            log.info("库存余量:{}",stock.getCount());
        }finally {
            lock.unlock();
        }
    }
}

​ Jmeter压测测试报告:

image-20230526155231872

​ 库存余量:0

image-20230526155250238

原理

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

image-20230526155531198

JVM本地锁失效场景之:多例模式

​ Service添加多例模式注解,并进行压力测试

package tech.msop.distributed.lock.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;

import java.util.concurrent.locks.ReentrantLock;

/**
 * 库存服务实现类 <br/>
 * 注意:@Scope的proxyMode,若为Spring原生,使用的是JDK代理,proxyMode应为INTERFACES,<br/>
 * SpringBoot 2.x起,使用的是CGlib代理,proxyMode为TARGET_CLASS
 */
@Service
@Slf4j
@Scope(value = "prototype",proxyMode = ScopedProxyMode.TARGET_CLASS)
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
        implements IStockService {
    /**
     * 减库存
     */
    @Override
    public synchronized void checkAndLock() {
        try {
            // 先查询库存是否充足
            StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));
            // 再减1个库存
            if (stock != null && stock.getCount() > 0) {
                stock.setCount(stock.getCount() - 1);
                this.updateById(stock);
            }
        } finally {
        }
    }

    /**
     * 重置库存数量
     */
    @Override
    public void reset() {
        this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);
    }
}

​ 查看数据库余量:4846

image-20230526161327499

​ JVM本地锁已失效

JVM本地锁失效场景之:事务

更新库存余量为5000

​ 请求方法添加事务注解,并进行压力测试

package tech.msop.distributed.lock.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;

import java.util.concurrent.locks.ReentrantLock;

/**
 * 库存服务实现类
 */
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
        implements IStockService {
    /**
     * 减库存
     * 添加事务注解
     */
    @Override
    @Transactional
    public synchronized void checkAndLock() {
        try {
            // 先查询库存是否充足
            StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));
            // 再减1个库存
            if (stock != null && stock.getCount() > 0) {
                stock.setCount(stock.getCount() - 1);
                this.updateById(stock);
            }
        } finally {
        }
    }

    /**
     * 重置库存数量
     */
    @Override
    public void reset() {
        this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);
    }
}

​ 查看数据库库存余量:14

image-20230526161909083

​ JVM本地锁已失效

JVM本地锁失效场景之:集群部署

修改库存余量为5000

​ 复制启动类,并命名为DistributedLockApplication2,修改启动类的端口号为8002

image-20230526162704582

​ 启动复制的服务:

image-20230526162731725

​ 编辑Nginx的配置文件nginx.conf文件,实现负载均衡

worker_processes  1;

events {
    worker_connections  1024;
}


http {
    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  D:/Program/Nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;
	upstream distributedLock{
		server localhost:8001;
		server localhost:8002;
	}
	server{
		listen  80;
		server_name localhost;
		location / {
			proxy_pass http://distributedLock;
		}
	}
    include D:/Program/Nginx/conf/conf.d/*.conf;

}

​ 启动Nginx,修改Jmeter的HTTP请求,端口修改为80,并再次进行压力测试,查看数据库余量:2012

image-20230526163601519

​ JVM本地锁机制已失效

单SQL语句处理

在更新数量时进行判断

可以解决JVM本地锁失效的场景

​ 更新服务代码:

package tech.msop.distributed.lock.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;

import java.util.concurrent.locks.ReentrantLock;

/**
 * 库存服务实现类 <br/>
 * 注意:@Scope的proxyMode,若为Spring原生,使用的是JDK代理,proxyMode应为INTERFACES,<br/>
 * SpringBoot 2.x起,使用的是CGlib代理,proxyMode为TARGET_CLASS
 */
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
        implements IStockService {
    /**
     * 减库存
     */
    @Override
    public void checkAndLock() {
        try {
            // 1.先查询库存是否充足
//            StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));
//            // 2.判断库存余量
//            if (stock != null && stock.getCount() > 0) {
//                stock.setCount(stock.getCount() - 1);
//                // 3.更新到数据库
//                this.updateById(stock);
//            }

            // update insert delete写操作本身就会加锁
            // 使用一条SQL语句完成减库存操作
            // update db_stock set count = count - 1 where product_code = '1001' and count >=1
            this.baseMapper.updateStock(1,"1001");
        } finally {
        }
    }

    /**
     * 重置库存数量
     */
    @Override
    public void reset() {
        this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);
    }
}

​ 更新Mapper代码:

package tech.msop.distributed.lock.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
import tech.msop.distributed.lock.entity.StockEntity;

public interface StockMapper extends BaseMapper<StockEntity> {

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

​ 进行压力测试,并查看数据库余量:0

image-20230526164754771

存在的问题
  • 锁范围的问题
  • 同一个商品可能有多条库存记录
  • 无法记录库存变化前后的状态
MySQL悲观锁

select … for update

在MySQL的InnoDB中,预设的Transaction 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

代码实现

​ 新增数据库数据:

image-20230526165048260

​ 修改服务类:

package tech.msop.distributed.lock.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;

import java.util.List;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 库存服务实现类 <br/>
 * 注意:@Scope的proxyMode,若为Spring原生,使用的是JDK代理,proxyMode应为INTERFACES,<br/>
 * SpringBoot 2.x起,使用的是CGlib代理,proxyMode为TARGET_CLASS
 */
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
        implements IStockService {
    /**
     * 减库存
     */
    @Override
    @Transactional
    public void checkAndLock() {
        // 1. 查询库存信息并锁定库存信息
        List<StockEntity> list = this.baseMapper.queryStock("1001");
        //      这里取第一个库存
        if (CollectionUtils.isEmpty(list)) {
            return;
        }
        StockEntity stock = list.get(0);
        // 2. 判断库存是否充足
        if (stock != null && stock.getCount() > 0) {
            stock.setCount(stock.getCount() - 1);
            // 3.更新到数据库
            this.updateById(stock);
        }
    }


    public void checkAndLock2() {
        try {
            // 1.先查询库存是否充足
//            StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));
//            // 2.判断库存余量
//            if (stock != null && stock.getCount() > 0) {
//                stock.setCount(stock.getCount() - 1);
//                // 3.更新到数据库
//                this.updateById(stock);
//            }

            // update insert delete写操作本身就会加锁
            // 使用一条SQL语句完成减库存操作
            // update db_stock set count = count - 1 where product_code = '1001' and count >=1
            this.baseMapper.updateStock(1, "1001");
        } finally {
        }
    }

    /**
     * 重置库存数量
     */
    @Override
    public void reset() {
        this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);
    }
}

​ 修改Mapper文件:

package tech.msop.distributed.lock.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import tech.msop.distributed.lock.entity.StockEntity;

import java.util.List;

public interface StockMapper extends BaseMapper<StockEntity> {

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

    @Select("select * from db_stock where product_code = #{productCode} for update")
    List<StockEntity> queryStock(@Param("productCode") String productCode);
}

​ 进行压力测试并查询数据库余量:0

image-20230526171152482

MySQL悲观锁中使用行级锁
  • 锁的查询或者更新条件必须是索引字段
  • 查询或者更新条件必须是具体值(如=、in,但like、!=条件均不可以,悲观锁仍是表级锁)
优缺点
  • 优点:
    • 解决同一个商品有多条库存记录同时更新的问题
    • 可以记录库存变化前后的状态
  • 缺点:
    • 性能问题
    • 死锁问题:对多条数据加锁时,加锁顺序要一致
    • 库存操作要统一:select … for update 普通的select
MySQL乐观锁

借助时间戳/version版本号/CAS机制实现

  • CAS:Compare And Swap(Set),比较并交换
    • 变量K 旧值A 新值B
    • 如用户更新密码,输入旧密码 A 与新密码 B,根据用户名 K 判断用户密码与旧密码是否一致,若一致,更新为新密码,否则放弃本次修改
  • 每次更新时,更新库存时同时更新新的时间戳/版本号,并判断时间戳/版本号是否与查询时的数据一致

​ 数据库表新增字段version

ALTER TABLE `distributed_lock`.`db_stock` 
ADD COLUMN `version` int(11) NULL DEFAULT 0 COMMENT '版本号' AFTER `count`;

​ 实体类同步新增字段:version

   /**
     * 版本号
     */
    private Integer version;

​ 改造Service服务

package tech.msop.distributed.lock.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;

import java.util.List;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 库存服务实现类 <br/>
 * 注意:@Scope的proxyMode,若为Spring原生,使用的是JDK代理,proxyMode应为INTERFACES,<br/>
 * SpringBoot 2.x起,使用的是CGlib代理,proxyMode为TARGET_CLASS
 */
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
        implements IStockService {
    /**
     * 减库存
     * 乐观锁不要使用事务注解
     */
    @Override
//    @Transactional
    public void checkAndLock() {
        // 1. 查询库存信息
        List<StockEntity> list = this.list(new QueryWrapper<StockEntity>().eq("product_code","1001"));
        //      这里取第一个库存
        if (CollectionUtils.isEmpty(list)) {
            return;
        }
        StockEntity stock = list.get(0);
        // 2. 判断库存是否充足
        if (stock != null && stock.getCount() > 0) {
            // 3.更新到数据库
            stock.setCount(stock.getCount() - 1);
            // 更新版本号,在原版本号的基础上加1
            Integer version = stock.getVersion();
            stock.setVersion(version + 1);
            // 判断是否更新成功,更新失败则递归调用,直至保证更新成功
            // true 表示更新行数不为null且大于等于1,false 表示更新失败
            boolean result = this.update(stock,new UpdateWrapper<StockEntity>().eq("id",stock.getId()).eq("version",version));
            if (!result){
                // 避免栈内存溢出
                try{
                    Thread.sleep(20);
                }catch (InterruptedException e){
                    e.printStackTrace();
                }
                this.checkAndLock();
            }
        }
    }

}

​ 使用Jmeter进行压力测试,并查询数据库余量:0

image-20230527134958119

注意:

  • 若需要递归调用确保数据更新成功,不要使用事务注解
    • MDL(更新、删除、新增)语句会自动加锁,重复调用可能会导致阻塞
  • 若需要递归调用确保数据更新成功,需要线程休眠一段时间,避免栈内存溢出
缺点
  • 高并发情况下,性能极低
  • ABA问题
    • 用户1查询数据X=A
    • 用户2更新数据X=B
    • 用户3更新数据X=C
    • 用户4更新数据X=A
    • 用户1更新数据时判断X是否等于A,若相同,更新X=S
      • 虽然X仍然等于A,但数据变更过
  • 读写分离情况下导致乐观锁不可靠
    • 写数据到主服务器,从服务器读取数据
MySQL锁总结
  • 性能:单SQL>悲观锁>JVM锁>乐观锁
  • 如果追求极致性能、业务场景简单并且不需要记录数据前后变化的情况下
    • 优先选择:单SQL
  • 如果写并发量较低(多读),争论不是很激烈的情况:
    • 优先选择:乐观锁
  • 如果写并发量较高,一般会经常冲突,此时选择乐观锁的话,会导致业务代码不间断的重试
    • 优先选择:悲观锁
  • 不推荐JVM本地锁
Redis乐观锁
更新Redis中的库存

​ 在Redis中新增库存:

$ set stock 5000

​ 更新StockService服务

package tech.msop.distributed.lock.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Scope;
import org.springframework.context.annotation.ScopedProxyMode;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import tech.msop.distributed.lock.constants.StockConstant;
import tech.msop.distributed.lock.entity.StockEntity;
import tech.msop.distributed.lock.mapper.StockMapper;
import tech.msop.distributed.lock.service.IStockService;

import java.util.List;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 库存服务实现类 <br/>
 */
@Service
@Slf4j
public class StockServiceImpl extends ServiceImpl<StockMapper, StockEntity>
        implements IStockService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 减库存
     */
    @Override
    public void checkAndLock() {
        // 1. 查询库存信息
        String stock = redisTemplate.opsForValue().get("stock");
        // 2. 判断库存是否充足
        if (stock != null && stock.length() != 0){
            Integer st = Integer.valueOf(stock);
            if (st > 0){
                // 3.更新到数据库
                redisTemplate.opsForValue().set("stock",String.valueOf(--st));
            }
        }
    }


    public void checkAndLock2() {
        try {
            // 1.先查询库存是否充足
//            StockEntity stock = this.getOne(new QueryWrapper<StockEntity>().eq("product_code", "1001"));
//            // 2.判断库存余量
//            if (stock != null && stock.getCount() > 0) {
//                stock.setCount(stock.getCount() - 1);
//                // 3.更新到数据库
//                this.updateById(stock);
//            }

            // update insert delete写操作本身就会加锁
            // 使用一条SQL语句完成减库存操作
            // update db_stock set count = count - 1 where product_code = '1001' and count >=1
            this.baseMapper.updateStock(1, "1001");
        } finally {
        }
    }

    /**
     * 重置库存数量
     */
    @Override
    public void reset() {
        this.baseMapper.reset(StockConstant.DEFAULT_STOCK_COUNT);
    }
}

​ 使用Jmeter进行压力测试,并查询库存余量

image-20230527141527673

Redis乐观锁

watch:可以监控一个或多个key的值,如果在事务执行(exec)之前,key的值发生拜年话,则取消事务执行

multi:开启事务

exec:执行事务

​ 利用Redis监听+事务

$ watch stock
$ multi
$ set stock 5000
$ exec

​ 如果执行过程中,stock的值没有被其他链接改变,则执行成功

image-20230527141934683

​ 如果执行过程中stock的值被改变,则执行失败

image-20230527142107031

​ 更新StockService

 /**
     * 减库存
     */
    @Override
    public void checkAndLock() {
        redisTemplate.execute(new SessionCallback<Object>() {

            @Override
            public <K, V> Object execute(@NotNull RedisOperations<K, V> operations) throws DataAccessException {
                // watch
                operations.watch((K) "stock");
                // 1. 查询库存信息
                String stock = (String) operations.opsForValue().get("stock");
                // 2. 判断库存是否充足
                if (stock != null && stock.length() != 0){
                    Integer st = Integer.valueOf(stock);
                    if (st > 0){
                        // multi
                        operations.multi();
                        // 3.更新到数据库
                        operations.opsForValue().set((K) "stock", (V) String.valueOf(--st));
                        // exec 执行事务
                        List<Object> exec = operations.exec();
                        // 如果执行事务的返回结果集为空,则代表减库存失败,重试
                        if (exec ==null || exec.size() == 0){
                            try {
                                Thread.sleep(40);
                            } catch (InterruptedException e) {
                                throw new RuntimeException(e);
                            }
                            checkAndLock();
                        }
                        return exec;
                    }
                }
                return null;
            }
        });

    }

​ 使用Jmeter进行压力测试并查询库存余量:0

image-20230527143816559

缺点
  • 性能问题
  • 由于运行机器的性能问题,可能导致连接数不够用

分布式锁

跨进程、跨服务、跨服务器

分布式锁的应用场景:

  • 超卖现象(NoSQL)
  • 缓存击穿

分布式锁的实现方式:

  • 基于Redis实现
  • 基于Zookeeper/etcd实现
  • 基于MySQL实现
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ruozhuliufeng

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值