W
e
l
c
o
m
e
: )

分布式锁

一、多线程下的单机锁问题

我们用一段代码来模拟多个线程抢票,票一共50000张,五个线程各自抢10000张。

public class Code02_ConcurrentQuestion {
    public static void main(String[] args) throws InterruptedException {
        Ticket ticket = new Ticket();
        for (int i=0;i<5;i++){
            new Thread(() -> {
                for (int j=0;j<10000;j++){
                    ticket.buy();
                }
            }).start();
        }
        Thread.sleep(5000);
        System.out.println("剩余票数:"+ticket.getNumber());
    }

    public static class Ticket{
        private int number = 50000;

        //模拟买票
        public void buy(){
            if (number > 0){
                number--;
            }
        }

        //剩余票数
        public int getNumber(){
            return this.number;
        }
    }
}

最终执行结果如下:

剩余票数:36522

之所以执行结果并不是0,是因为number--这条操作并不是原子性的,导致在多线程下会有并发问题。我们一般会采用加锁或者使用原子类来解决这个问题:

//使用synchronized 
public synchronized void buy(){
            if (number > 0){
                number--;
            }
        }
//使用Reentrantlock
private static final ReentrantLock lock = new ReentrantLock();
public void buy(){
    lock.lock();
    try{
        if (number > 0){
            number--;
        }
    }finally {
        lock.unlock();
    }
}

//使用原子类
private AtomicInteger number = new AtomicInteger(50000);

//模拟买票
public void buy() {
    if (number.get() > 0){
        number.decrementAndGet();
    }
}

二、为什么要有分布式锁

就拿上面那个模拟抢票的代码来说,如果只是单机部署的话,可以通过加锁和使用原子类的方式来解决这个票数和预期对不上的问题,但是在现实生产环境中,可能会将这个抢票的服务进行集群部署,上面的这两种只能保证单机环境下没有并发问题,但是在分布式集群环境下,多个服务器上的抢票服务可以同时进行,此时你加不加锁都没有意义。因此,才出现了分布式锁,分布式锁常用的有三种方式:

  • Mysql 加锁语句;
  • redis 分布式锁
  • zookeeper分布式锁

三、Mysql实现分布式锁

Mysql实现分布式锁是基于Mysql的排他锁,利用InnoDB引擎的事务机制和SELECT ... FOR UPDATE语句来实现的,这种方式需要手动开启和提交事务,一般不建议使用,除非是项目不允许引入其它中间件如redis和zookeeper。

3.1 实现示例

CREATE TABLE `distributed_lock` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `lock_key` varchar(100) NOT NULL COMMENT '锁的唯一标识',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_lock_key` (`lock_key`) -- ⚠️ 必须有索引
) ENGINE=InnoDB;
// 1. 关闭自动提交,开启事务
connection.setAutoCommit(false);

try {
    // 2. 执行带排他锁的查询(如果该行记录被其他事务加锁,当前线程会在此阻塞等待)
    // 建议先在表中预置一条 lock_key = 'my_resource_lock' 的记录
    String sql = "SELECT * FROM distributed_lock WHERE lock_key = 'my_resource_lock' FOR UPDATE";
    executeQuery(sql);
  
    // 3. 执行你的核心业务逻辑(例如:扣减库存、生成订单等)
    doBusinessLogic();
  
    // 4. 提交事务,自动释放排他锁
    connection.commit();
} catch (Exception e) {
    // 发生异常回滚事务,同样会释放锁
    connection.rollback();
} finally {
    // 恢复自动提交并释放连接
    connection.setAutoCommit(true);
}

3.2 优缺点分析

  • 性能瓶颈:Mysql数据库的加锁和释放锁涉及到磁盘的I/O,性能上相比redis基于内存操作来说,远远不如;
  • 连接占用:FOR UPDATE会长时间的占用连接池资源,如果你的这部分业务逻辑执行时间过长,那很有可能会耗尽连接池资源,影响其他业务的执行;
  • 死锁风险:在复杂的业务场景下,可能涉及到多个事务对相同资源的获取,如果获取的顺序没有设计好,可能出现死锁,比如事务A需要先后持有资源1和资源2,而事务B需要先后持有资源2和资源1,那如果刚好事务A拿到了资源1,事务B拿到了资源2,这个时候它们都想要对方手上的资源,如果都不选择放手的话,就会死锁;

3.3 如何预防和化解死锁

对于死锁风险问题,我们可以通过如下几种策略来规避:

  • 统一加锁顺序:获取资源可以必须按照资源ID升序获取,比如说:资源1 资源2 ...;
  • 设置锁等待超时:Mysql提供了 innodb_lock_wait_timeout参数来设置锁等待超时,默认50秒,生产环境可以根据情况设置成5-10s左右,超过这个阈值的话,Mysql会回滚当前事务并抛出异常;
  • 缩短事务持有时间:把与事务无关的那部分业务逻辑从事务中摘出去,尽量只保留真正执行数据库操作的那部分逻辑,减少事务持有锁的时间;
  • 使用索引:对于where后面的字段,一定要设置索引,如果可以的话使用唯一索引,不使用索引的话,Mysql没办法精准定位,只能从表的第一行开始往下一行一行找(全表扫描),为了保证事务的隔离性,防止在事务进行的时候表记录被修改,InnoDB会给扫描的每一行加上排他锁,不管这条记录是否符合条件,先加上再说,然后再去判断是否符合条件,如果全表扫描的行数很多的话(几百万条),可能会触发锁升级机制,将行锁升级为表锁,那别的事务就全部被阻塞在外面,只能等到当前事务提交。有索引的话,Mysql可以精准定位到具体的行,只锁住对应的行,就不会把其它事务给阻塞住;

四、Redis实现分布式锁

通过Redis来实现分布式锁的原理其实就是让多个服务去设置同一个key,谁设置成功了,谁就成功的拿到了锁,其它服务只能等待该服务删除释放锁。其中有几个核心问题需要注意:

  • 排他锁:我们正常使用 set命令设置的key是没有“排他性”的,谁来都可以设置,如何让这个key变成一个“排他锁”呢;
  • 死锁问题:但凡涉及到锁这个东西,死锁问题就必然是绕不开的,如果设置key的那个服务挂掉了,没有释放锁,其它服务拿不到锁死锁了怎么办;
  • 释放锁:拿到锁执行完业务需要释放锁,怎么释放,以及怎么保证释放锁的就是那个拿锁的;

4.1 准备工作

在使用Redis去实现分布式锁之前,我们需要做一些准备工作,首先是需要安装好redis以及nginx,这里我采用docker容器的方式进行安装:

  • 安装redis
docker run -d \
  --name redis \
  --restart always \
  -p 6379:6379 \
  redis \
  redis-server --requirepass "123456" --appendonly yes
  • 安装nginx
#拉取镜像
docker pull nginx
#准备宿主机的挂载目录(我是在/mydata下面执行的这个命令)
mkdir -p nginx/conf nginx/conf.d nginx/html nginx/logs
#临时启动一个名为 nginx-temp 的容器
docker run --name nginx-temp -d nginx
#把容器里的默认配置文件复制到宿主机的 conf 目录
docker cp nginx-temp:/etc/nginx/nginx.conf nginx/conf/
docker cp nginx-temp:/etc/nginx/conf.d/default.conf nginx/conf.d/
#复制完就可以把临时容器删掉了
docker rm -f nginx-temp
#正式创建容器,这里的8001端口是用来给服务做代理的,也可以选择使用默认的80端口,不加这个
docker run -d \
--name my-nginx \
-p 80:80 \
-p 8001:8001 \
-v $(pwd)/nginx/conf/nginx.conf:/etc/nginx/nginx.conf \
-v $(pwd)/nginx/conf.d:/etc/nginx/conf.d \
-v $(pwd)/nginx/html:/usr/share/nginx/html \
-v $(pwd)/nginx/logs:/var/log/nginx \
--restart=always \
nginx
  • 安装Mysql
docker run -d \
  --name mysql \
  --restart always \
  -p 3306:3306 \
  -e MYSQL_ROOT_PASSWORD=123456 \
  mysql:5.7

安装之后,我们需要使用mysql客户端连接上我们的mysql,数据库表内容如下:

DROP TABLE IF EXISTS `shop_goods`;
CREATE TABLE `shop_goods` (
  `goods_id` bigint(50) NOT NULL AUTO_INCREMENT,
  `goods_name` varchar(255) DEFAULT NULL COMMENT '商品名称',
  `goods_number` int(11) DEFAULT NULL COMMENT '商品库存',
  `goods_price` decimal(10,2) DEFAULT NULL COMMENT '商品价格',
  `goods_desc` varchar(255) DEFAULT NULL COMMENT '商品描述',
  `add_time` timestamp NULL DEFAULT NULL COMMENT '添加时间',
  PRIMARY KEY (`goods_id`) USING BTREE,
  KEY `index-n-gn` (`goods_name`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;

-- ----------------------------
-- Records of shop_goods
-- ----------------------------
INSERT INTO `shop_goods` VALUES ('13', '华为 HUAWEI Mate 40 RS', '100', '11898.00', '保时捷设计麒麟9000芯片 超感知徕卡电影五摄 8GB+256GB陶瓷黑5G全网通手机', '2021-05-08 10:28:58');
INSERT INTO `shop_goods` VALUES ('14', '诺基亚 新款105', '1000', '119.00', '移动2G老人机 学生手机 备用功能机 蓝色(新105单卡) 直板按键 长待机', '2021-05-08 10:31:46');

安装完毕之后,我们先去创建用于测试的SpringBoot项目,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>3.5.14</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.hzp</groupId>
    <artifactId>redis-dist-lock</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>redis-dist-lock</name>
    <description>redis-dist-lock</description>
    <properties>
        <java.version>17</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!--mybatis-->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.14</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <!-- Spring Boot Redis Starter,版本由父工程自动管理 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!-- 连接池依赖:必须添加,否则 application.yml 中的 lettuce.pool 配置不会生效 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

application.yml配置文件内容如下:

server:
  port: 8004
spring:
  application:
    name: redis-dist-lock
  datasource:
    url: jdbc:mysql://安装mysql的ip:3306/库名?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver

  data:
    redis:
      host: 安装redis的ip
      port: 6379
      password: 123456
      database: 1
      timeout: 2000ms # 连接超时时间(注意单位是毫秒)
      lettuce:
        pool:
          max-active: 16   # 连接池最大活跃数
          max-idle: 8      # 最大空闲连接数
          min-idle: 4      # 最小空闲连接数
          max-wait: 1000ms # 获取连接的最大等待时间

mybatis-plus:
  mapper-locations: classpath:mapper/**/*.xml
  configuration:
    map-underscore-to-camel-case: true # 开启驼峰命名映射
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境打印SQL

项目结构如下:

image.png

//ShopGoods
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.util.Date;

@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("shop_goods")
public class ShopGoods {

    @TableId
    private Long goodsId;

    private String goodsName;

    private Integer goodsNumber;

    private BigDecimal goodsPrice;

    private String goodsDesc;

    private Date addTime;
}

//ShopGoodsMapper
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.hzp.redisdistlock.model.ShopGoods;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface ShopGoodsMapper extends BaseMapper<ShopGoods> {
}

//ShopGoodsService
import com.hzp.redisdistlock.dao.ShopGoodsMapper;
import com.hzp.redisdistlock.model.ShopGoods;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class ShopGoodsService {

    private static final Logger logger = LoggerFactory.getLogger(ShopGoodsService.class);

    @Autowired
    private ShopGoodsMapper shopGoodsMapper;

    /**
     * 扣减商品库存
     * 这里简单扣减库存即可,不搞复杂的业务逻辑
     * @param goodsId
     */
    public void updateGoods(long goodsId){
        try{
            //根据id查询对应商品
            ShopGoods shopGoods = shopGoodsMapper.selectById(goodsId);
            //库存--
            shopGoods.setGoodsNumber(shopGoods.getGoodsNumber()-1);
            //更新库存
            shopGoodsMapper.updateById(shopGoods);
        }catch (Exception e){
            logger.error("更新库存失败!!!",e);
        }
    }
}
//ShopGoodsController
import com.hzp.redisdistlock.service.ShopGoodsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/goods")
public class ShopGoodsController {

    @Autowired
    private ShopGoodsService shopGoodsService;

    @GetMapping("/updateGoods/{goodsId}")
    public String updateGoods(@PathVariable("goodsId") Long goodsId){
        shopGoodsService.updateGoods(goodsId);
        return "success";
    }
}

接下来就是启动项目,先测下接口通不通再说,这里我们是通过将这个项目分别在 800480058006这几个端口同时启动来模拟集群部署的环境。如果是使用IDEA编写代码的话,可以在Edit Configurations里面进行配置,参考如下:

image.png

image.png

其它两个可以直接拷贝一下配置,改下端口就行,然后就是把三个项目启动起来:

image.png

项目启动之后就是这个样子,没有报错的话接下来我们需要去nginx那里修改配置文件,让nginx帮我们给这三个服务做负载均衡,我这里没有直接改 nginx.conf文件,而是在 conf.d下新建了一个 distLock.conf文件,文件内容如下:

#文件路径: /mydata/nginx/conf.d/distLock.conf
#文件内容:
upstream dist_lock_cluster {
    # 这里填入本地启动的那几个 springboot 服务地址
    server 服务ip:8004;
    server 服务ip:8005;
    server 服务ip:8006;
}

server {
    listen       8001;
    server_name  localhost;

    location / {
        # 将请求转发给上面定义的 upstream 组
        proxy_pass http://dist_lock_cluster;

        # 必加的请求头设置,让你的 SpringBoot 能获取到真实的客户端 IP
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

注意:nginx.conf文件和 conf.d下的conf文件可以理解成教室和书桌的关系,一个教室内可以有很多张书桌,它们都被容纳在一间教室内,nginx在启动的时候,会把 conf.d下的conf文件内容囊括进 nginx.conf文件中,这样的设计是为了便于管理不同应用的配置,将它们隔离开来,类似于模块化的设计思想。在 nginx.conf文件内有这样一行配置:

#这个目录在创建容器的时候和/mydata/nginx/conf.d关联了
include /etc/nginx/conf.d/*.conf;

ok,nginx配置修改完毕,我们需要重新加载一下配置:

#验证有没有语法错误
docker exec -it my-nginx nginx -t
#reload
docker exec -it my-nginx nginx -s reload

接下来,我们可以访问:http://${nginx部署的IP地址}:8001/goods/updateGoods/13

查看数据库表里的商品库存数据是否修改成功

image.png

接口通的话,说明项目环境搭成功了,接下来我们使用压测工具Jmeter来模拟多个请求访问时商品库存更新会不会有问题,电脑上没Jmeter的话可以去 https://jmeter.apache.org/download_jmeter.cgi下载一个

image.png

zip文件下载下来解压之后,我们需要进入 bin目录下,修改 jmeter.properties:

#Preferred GUI language. Comment out to use the JVM default locale's language.
#language=en
#因为界面默认是英文的,我们把它换成中文
language=zh_CN

配置修改完毕,我们双击 bin/jmeter.bat文件进入操作界面:

image.png

我们在左侧的测试计划那里右键点击,按照下图所示创建线程组:

image.png

创建线程组之后在右侧按照下图所示填写内容:

image.png

然后在线程组上右键如下图所示创建http请求:

image.png

image.png

接着,我们在线程组上右键创建结果树,如下图所示:

image.png

最后,我们点击最上面的那个绿色的三角形执行,然后查看结果树,看这100个请求是否正常执行:

image.png

image.png

可以看到,请求已经正常发送了,我们查看数据库表中的库存数据的更新情况:

image.png

我这里原来库存是4900,但是现在执行100次请求之后只扣减了92次,说明存在并发问题,接下来我们就来利用Redis去实现分布式锁,避免出现这种并发问题。

4.2 分布式锁实现

使用Redis作为分布式锁的实现,首先我们需要新增 RedisConfigRedisDistLock两个文件,如下:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * Redis配置类
 */
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }

    @Bean
    public RedisScript<Long> releaseLockScript(){
        //释放锁的lua脚本,确保释放锁的就是持有锁的那个线程
        String script =
                "if redis.call('get',KEYS[1])==ARGV[1] then\n" +
                        "        return redis.call('del', KEYS[1])\n" +
                        "    else return 0 end";
        return new DefaultRedisScript<>(script,Long.class);
    }
}
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * Redis分布式锁
 */
@Component
public class RedisDistLock implements Lock{

    private static final Logger logger = LoggerFactory.getLogger(RedisDistLock.class);

    private static final int LOCK_TIME = 5*1000;//锁的失效时间

    private static final String RS_DISTLOCK_NS =  "tdln:";//锁key的前缀

    //释放锁的lua脚本,确保释放锁的就是持有锁的那个线程
    private static final String RELEASE_LOCK_LUA =
            "if redis.call('get',KEYS[1])==ARGV[1] then\n" +
                    "        return redis.call('del', KEYS[1])\n" +
                    "    else return 0 end";

    //保存每个线程独有的id
    private ThreadLocal<String> lockerId = new ThreadLocal<>();

    //加锁成功,就会把抢到锁的线程进行保存
    @Setter
    private Thread ownerThread;//当前持有分布式锁的线程

    @Getter
    @Setter
    private String lockName = "lock";//锁的名称,默认值为lock

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private RedisScript<Long> releaseLockScript;


    @Override
    public void lock() {
        while (!tryLock()){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        throw new UnsupportedOperationException("不支持可打断的方式获取锁");
    }

    @Override
    public boolean tryLock() {
        Thread current = Thread.currentThread();
        if(ownerThread != null){//有线程持有锁
            //判断是不是当前线程持有锁
            if (current == ownerThread){
                return true;//重入
            }
            return false;//不是当前线程持有锁,byebye
        }
        try{
            String id = UUID.randomUUID().toString();
            //本地抢锁
            synchronized (this){
                if(ownerThread == null &&
                        Boolean.TRUE.equals(redisTemplate.opsForValue()
                                .setIfAbsent(RS_DISTLOCK_NS + lockName, id, Duration.ofMillis(LOCK_TIME)))){
                    //拿锁成功
                    lockerId.set(id);
                    setOwnerThread(current);//将当前线程设为owner
                    return true;
                }
                return false;
            }
        }catch (Exception e){
            throw new RuntimeException("分布式锁尝试加锁失败!");
        }
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        throw new UnsupportedOperationException("不支持等待尝试获取锁!");
    }

    @Override
    public void unlock() {
        if (ownerThread != Thread.currentThread()){
            throw new RuntimeException("当前线程不是持有锁的线程,不能释放锁!");
        }
        try{
            List<String> keys = Collections.singletonList(RS_DISTLOCK_NS + lockName);
            Long result = (Long) redisTemplate.execute(releaseLockScript, keys, lockerId.get());
            if(result != null && result.longValue() != 0L){
                logger.info("分布式锁释放成功!");
            }else {
                logger.error("分布式锁释放失败!");
            }
        }catch (Exception e){
            throw new RuntimeException("分布式锁释放失败!");
        }finally {
            lockerId.remove();
            setOwnerThread(null);
            logger.info("分布式锁释放完成!");
        }
    }

    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException("不支持等待通知操作!");
    }
}

接下来我们只需要在业务代码中需要使用到分布式锁的地方引入即可:

public void updateGoods(long goodsId){
    try{
        redisDistLock.lock();
        //根据id查询对应商品
        ShopGoods shopGoods = shopGoodsMapper.selectById(goodsId);
        //库存--
        shopGoods.setGoodsNumber(shopGoods.getGoodsNumber()-1);
        //更新库存
        shopGoodsMapper.updateById(shopGoods);
    }catch (Exception e){
        logger.error("更新库存失败!!!",e);
    }finally {
        redisDistLock.unlock();
    }
}

最后就是重启项目,和上面一样用Jmeter再测试一次,看商品库存的更新是否和预期一致即可!

4.3 引入看门狗

在上面的这个分布式锁的实现中,我们发现锁的失效时间是固定的,那么如果业务的执行时间不好评估或者说业务由于某些原因执行慢了一些,就会导致业务还没跑完,redis分布式锁就过期失效了,那么此时集群中的其它服务就可以去拿锁,这样一个没释放锁一个就已经拿到锁了,这就存在问题了。所以我们可以通过引入看门狗方案,它是一个守护线程,可以帮我们在锁快到期之前去redis做一个续期的工作,这样就不会出现业务没跑完,锁就过期失效了的问题。

我这里新增了三个类:

//RedisDistLockWithDog类
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.Setter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.DelayQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * Redis分布式锁-看门狗
 */
@Component
public class RedisDistLockWithDog implements Lock{

    private static final Logger logger = LoggerFactory.getLogger(RedisDistLockWithDog.class);

    private static final int LOCK_TIME = 1*1000;//锁的失效时间

    private static final String RS_DISTLOCK_NS =  "tdln:";//锁key的前缀

    //保存每个线程独有的id
    private ThreadLocal<String> lockerId = new ThreadLocal<>();

    //加锁成功,就会把抢到锁的线程进行保存
    @Setter
    private Thread ownerThread;//当前持有分布式锁的线程

    @Getter
    @Setter
    private String lockName = "lock";//锁的名称,默认值为lock

    @Autowired
    private RedisTemplate redisTemplate;

    @Autowired
    private RedisScript<Long> releaseLockScript;

    @Autowired
    private RedisScript<Long> delayLockScript;

    //看门狗线程
    private Thread expireThread;

    //延迟队列
    private static final DelayQueue<ItemVO<LockItem>> delayDog = new DelayQueue<>();

    private class ExpireTask implements Runnable{
        @Override
        public void run() {
            logger.info("准备启动看门狗线程");
            while (!Thread.currentThread().isInterrupted()){
                try {
                    //只有到续期时间了这里才能拿到元素
                    LockItem lockItem = delayDog.take().getData();
                    Long result = (Long) redisTemplate.execute(delayLockScript, Collections.singletonList(RS_DISTLOCK_NS + lockItem.getKey()), lockItem.getValue(), LOCK_TIME);
                    if (result.longValue() != 0L){
                        delayDog.add(new ItemVO<>(LOCK_TIME,new LockItem(lockItem.getKey(),lockItem.getValue())));
                        logger.info("锁已续期");
                    }else {
                        logger.info("锁已释放,无需续期");
                    }
                } catch (InterruptedException e) {
                    logger.error("看门狗线程被中断");
                    break;
                }catch (Exception e){
                    throw new RuntimeException("锁续期失败!",e);
                }
            }
            logger.info("准备关闭看门狗线程");
        }
    }

    @Override
    public void lock() {
        while (!tryLock()){
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        throw new UnsupportedOperationException("不支持可打断的方式获取锁");
    }

    @Override
    public boolean tryLock() {
        Thread current = Thread.currentThread();
        if(ownerThread != null){//有线程持有锁
            //判断是不是当前线程持有锁
            if (current == ownerThread){
                return true;//重入
            }
            return false;//不是当前线程持有锁,byebye
        }
        try{
            String id = UUID.randomUUID().toString();
            //本地抢锁
            synchronized (this){
                if(ownerThread == null &&
                        Boolean.TRUE.equals(redisTemplate.opsForValue()
                                .setIfAbsent(RS_DISTLOCK_NS + lockName, id, Duration.ofMillis(LOCK_TIME)))){
                    //拿锁成功
                    lockerId.set(id);
                    setOwnerThread(current);//将当前线程设为owner
                    //开启看门狗线程
                    if (expireThread == null){
                        expireThread = new Thread(new ExpireTask(),"expireThread");
                        expireThread.setDaemon(true);
                        expireThread.start();
                    }
                    //将当前拿锁线程的相关信息封装扔到延迟队列中
                    delayDog.add(new ItemVO<>(LOCK_TIME,new LockItem(lockName,id)));
                    return true;
                }
                return false;
            }
        }catch (Exception e){
            throw new RuntimeException("分布式锁尝试加锁失败!");
        }
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
        throw new UnsupportedOperationException("不支持等待尝试获取锁!");
    }

    @Override
    public void unlock() {
        if (ownerThread != Thread.currentThread()){
            throw new RuntimeException("当前线程不是持有锁的线程,不能释放锁!");
        }
        try{
            List<String> keys = Collections.singletonList(RS_DISTLOCK_NS + lockName);
            Long result = (Long) redisTemplate.execute(releaseLockScript, keys, lockerId.get());
            if(result != null && result.longValue() != 0L){
                logger.info("分布式锁释放成功!");
            }else {
                logger.error("分布式锁释放失败!");
            }
        }catch (Exception e){
            throw new RuntimeException("分布式锁释放失败!");
        }finally {
            lockerId.remove();
            setOwnerThread(null);
            logger.info("分布式锁释放完成!");
        }
    }

    @PreDestroy
    public void closeExpireThread(){
        if (expireThread != null){
            expireThread.interrupt();
        }
    }

    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException("不支持等待通知操作!");
    }
}

//ItemVO类
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

/**
 *扔到延迟队列里边的元素
 */
public class ItemVO<T> implements Delayed {

    /**
     * 续期时间,会比正常过期时间早一些
     */
    private long activeTime;
    /**
     * 业务数据
     */
    private T data;

    public ItemVO(long expireTime,T data){
        super();
        this.activeTime = expireTime+System.currentTimeMillis()-100;//提前100ms续期
        this.data = data;
    }

    public T getData() {
        return data;
    }

    /**
     * 返回元素距离续期时间的剩余时长
     * @param unit the time unit
     * @return
     */
    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(this.activeTime - System.currentTimeMillis(), unit);
    }

    /**
     * 延迟队列中元素排序
     * @param o the object to be compared.
     * @return
     */
    @Override
    public int compareTo(Delayed o) {
        long d = getDelay(TimeUnit.MILLISECONDS) - o.getDelay(TimeUnit.MILLISECONDS);
        if (d == 0){
            return 0;
        }else {
            if (d<0){
                return -1;
            }else {
                return 1;
            }
        }
    }
}

//LockItem类
/**
 * Redis锁的key-value
 */
public class LockItem {
    private String key;
    private String value;

    public LockItem(String key, String value) {
        this.key = key;
        this.value = value;
    }

    public String getKey() {
        return key;
    }

    public String getValue() {
        return value;
    }
}

使用的话和上面一样:

@Autowired
private RedisDistLockWithDog redisDistLockWithDog;

public void updateGoods(long goodsId){
    try{
        redisDistLockWithDog.lock();
        //这里是为了触发续期操作
        Thread.sleep(3000);
        //根据id查询对应商品
        ShopGoods shopGoods = shopGoodsMapper.selectById(goodsId);
        //库存--
        shopGoods.setGoodsNumber(shopGoods.getGoodsNumber()-1);
        //更新库存
        shopGoodsMapper.updateById(shopGoods);
    }catch (Exception e){
        logger.error("更新库存失败!!!",e);
    }finally {
        redisDistLockWithDog.unlock();
    }
}

4.4 Redisson

Redisson 是什么

Redisson 是一个架设在 Redis 基础上的 Java 驻内存数据网格(In-Memory Data Grid),也是 Redis 官方推荐的 Java 客户端 。它基于 NIO 的 Netty 框架,不仅提供了与 Redis 交互的基础功能,更重要的是封装了一系列具有分布式特性的常用工具类(如分布式锁分布式集合分布式服务等)。

相比于传统的 Jedis 和 Lettuce,Redisson 最大的优势在于它将单机多线程并发编程的工具包扩展为了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。

上面这一大段简单来说就是:Redisson是官方给我们封装好的java客户端,有了它,我们不需要自己编码去实现分布式锁和看门狗这些,Redisson已经帮我们封装好了,我们只需要引入相关的依赖,做一些配置就行了。

Redisson官方文档

4.4.1 Redisson分布式锁

注意:我这里已经把之前的 RedisConfigRedisDistLockRedisDistLockWithDog这些类先注释掉了!

首先,我们需要修改 pom.xml

        <!-- Spring Boot Redis Starter,版本由父工程自动管理 -->
<!--        <dependency>-->
<!--            <groupId>org.springframework.boot</groupId>-->
<!--            <artifactId>spring-boot-starter-data-redis</artifactId>-->
<!--        </dependency>-->

        <!-- 连接池依赖:必须添加,否则 application.yml 中的 lettuce.pool 配置不会生效 -->
<!--        <dependency>-->
<!--            <groupId>org.apache.commons</groupId>-->
<!--            <artifactId>commons-pool2</artifactId>-->
<!--        </dependency>-->
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.27.0</version>
        </dependency>

然后是 application.yml

spring:
  data:
    redis:
      host: redis的地址
      port: 6379
      password: 123456
      database: 1
      timeout: 2000ms # 连接超时时间(注意单位是毫秒)
#      lettuce:
#        pool:
#          max-active: 16   # 连接池最大活跃数
#          max-idle: 8      # 最大空闲连接数
#          min-idle: 4      # 最小空闲连接数
#          max-wait: 1000ms # 获取连接的最大等待时间

最后就可以直接在业务代码中使用了:

@Autowired
private RedissonClient redissonClient;

/**
 * 扣减商品库存
 * 这里简单扣减库存即可,不搞复杂的业务逻辑
 * @param goodsId
 */
public void updateGoods(long goodsId){
    //获取分布式锁对象
    RLock lock = redissonClient.getLock("RD_LOCK");
    try{
        //尝试加锁,最多等待10秒,30秒后锁过期
        if (lock.tryLock(10,30, TimeUnit.SECONDS)){
            //根据id查询对应商品
            ShopGoods shopGoods = shopGoodsMapper.selectById(goodsId);
            //库存--
            shopGoods.setGoodsNumber(shopGoods.getGoodsNumber()-1);
            //更新库存
            shopGoodsMapper.updateById(shopGoods);
        }
    }catch (Exception e){
        logger.error("更新库存失败!!!",e);
    }finally {
        //释放锁
        if (lock.isHeldByCurrentThread()){
            lock.unlock();
        }
    }
}

五、Zookeeper实现分布式锁

5.1 实现原理

Zookeeper实现分布式锁的原理是基于它的两大特性:临时顺序节点Watcher监听机制

具体的工作流程如下:

  • 创建临时顺序节点:当客户端请求加锁时,会在 ZooKeeper 指定的锁目录(如 /locks)下创建一个临时顺序子节点。ZooKeeper 会自动为该节点分配一个全局递增的唯一序号(例如 lock-0000000001)。因为它是“临时”的,如果客户端宕机或网络断开导致会话失效,该节点会被自动删除,从而天然避免了死锁问题。
  • 获取所有子节点并排序:节点创建成功后,客户端会获取该锁目录下的所有子节点列表,并按照序号从小到大进行排序。
  • 判断是否获取锁:客户端判断自己创建的节点是否是当前序列中序号最小的节点:
    • 如果是:说明成功获取到了锁,可以开始执行业务逻辑。
    • 如果不是 :说明锁已被其他客户端占用,进入等待状态。
  • 注册 Watcher 监听(链式监听):如果没有获取到锁,客户端不会盲目地轮询,也不会去监听整个父目录的子节点变化(这会导致严重的“羊群效应”,即每次释放锁都会唤醒所有等待者,但只有一个能成功),而是 仅仅使用 Watcher 监听比自己序号小的前一个节点的删除事件(NodeDeleted)
  • 收到通知与重试:当前面持有锁的客户端执行完业务并主动删除其节点(或因宕机会话失效被系统自动删除)时,ZooKeeper 服务端会向正在监听的下一个客户端发送事件通知。该客户端被唤醒后,回到第 2 步重新获取子节点列表并判断自己是否成为了最小节点,如此循环,直到成功获取锁。

5.2 优势与特点

  • 高安全性与防死锁 :基于临时节点的生命周期绑定 Session 会话,一旦客户端崩溃,节点自动销毁,锁自动释放。
  • 公平性 :严格按照节点创建的先后顺序(序号大小)来分配锁,先到先得,避免了线程饥饿问题。
  • 避免羊群效应 :通过“只监听前一个节点”的链式触发机制,极大地减少了无效的网络通知和性能开销。

5.3 具体实现

5.3.1 安装zookeeper

#创建zookeeper的挂载目录
mkdir -p /mydata/zookeeper/{conf,data,logs}
#在conf目录下创建zoo.cfg配置文件,并写入如下内容
echo "tickTime=2000
initLimit=10
syncLimit=5
dataDir=/data
dataLogDir=/datalog
clientPort=2181
maxClientCnxns=60
autopurge.snapRetainCount=3
autopurge.purgeInterval=1" > zoo.cfg
#创建容器并运行
docker run -d \
  --name zookeeper \
  --restart=always \
  -p 2181:2181 \
  -v /mydata/zookeeper/data:/data \
  -v /mydata/zookeeper/logs:/datalog \
  -v /mydata/zookeeper/conf/zoo.cfg:/conf/zoo.cfg \
  zookeeper:latest
#查看容器是否正常运行
docker ps | grep zookeeper

5.3.2 代码实现

引入相关依赖,curator已经帮我们封装好了节点和监听相关的细节,我们只需要做好配置即可使用。

  • 自动重试机制 Curator 内置 ExponentialBackoffRetry 处理网络抖动
  • 防羊群效应 InterProcessMutex 默认采用 链式监听 (只监听前一节点),避免全量唤醒
  • 简化异常处理 封装了节点创建、排序、Watcher 注册等复杂逻辑,开发者只需关注业务
  • 可重入支持 原生 API 需自行维护重入计数,Curator 直接提供 InterProcessMutex
<!-- Curator 5.x(必须 5.0+ 才兼容 Spring Boot 3) -->
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.5.0</version> <!-- 必须与 recipes 版本一致 -->
</dependency>
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-recipes</artifactId>
    <version>5.5.0</version> <!-- 必须与 framework 版本一致 -->
</dependency>

<!-- ZooKeeper 客户端(Curator 5.x 已内置,但显式声明更安全) -->
<dependency>
    <groupId>org.apache.zookeeper</groupId>
    <artifactId>zookeeper</artifactId>
    <version>3.8.4</version> <!-- 与 Curator 5.5.0 兼容 -->
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <scope>provided</scope>
</dependency>

application.yml文件中添加如下配置:

zookeeper:
  connect-string: 127.0.0.1:2181  # 多节点用逗号分隔,如: 192.168.1.10:2181,192.168.1.11:2181
  session-timeout: 30000           # 会话超时时间(毫秒),必须 > 业务最大执行时间
  connection-timeout: 15000         # 连接超时时间(毫秒)
  namespace: distributed-locks      # 可选:隔离不同环境的锁(避免冲突)

新增 ZookeeperConfig配置类:

import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * zookeeper配置类
 */
@Configuration
public class ZookeeperConfig {

    @ConfigurationProperties(prefix = "zookeeper")
    @Bean
    public ZookeeperProperties zookeeperProperties() {
        return new ZookeeperProperties();
    }

    @Bean(initMethod = "start", destroyMethod = "close")
    public CuratorFramework curatorFramework(ZookeeperProperties props) {
        return CuratorFrameworkFactory.builder()
                .connectString(props.getConnectString())
                .sessionTimeoutMs(props.getSessionTimeout())
                .connectionTimeoutMs(props.getConnectionTimeout())
                .retryPolicy(new ExponentialBackoffRetry(1000, 3)) // 初始重试间隔1秒,最多重试3次
                .namespace(props.getNamespace()) // 隔离命名空间
                .build();
    }

    public static class ZookeeperProperties {
        private String connectString;
        private int sessionTimeout = 30000;
        private int connectionTimeout = 15000;
        private String namespace = ""; // 默认无命名空间

        // Getters & Setters
        public String getConnectString() { return connectString; }
        public void setConnectString(String connectString) { this.connectString = connectString; }
        public int getSessionTimeout() { return sessionTimeout; }
        public void setSessionTimeout(int sessionTimeout) { this.sessionTimeout = sessionTimeout; }
        public int getConnectionTimeout() { return connectionTimeout; }
        public void setConnectionTimeout(int connectionTimeout) { this.connectionTimeout = connectionTimeout; }
        public String getNamespace() { return namespace; }
        public void setNamespace(String namespace) { this.namespace = namespace; }
    }
}

新增 ZkDistLock类:

import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.locks.InterProcessMutex;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class ZkDistLock {
    private final CuratorFramework curatorClient;

    public ZkDistLock(CuratorFramework curatorClient){
        this.curatorClient = curatorClient;
    }

    /**
     * 获取锁
     * @param lockPath 锁目录
     * @param waitTime 等待时间
     * @param unit 时间单位
     * @return
     */
    public InterProcessMutex acquireLock(String lockPath, long waitTime, TimeUnit unit){
        try {
            InterProcessMutex lock = new InterProcessMutex(curatorClient, lockPath);
            if (lock.acquire(waitTime,unit)){
                return lock;//成功拿锁
            }
        } catch (Exception e) {
            throw new RuntimeException("获取锁失败!!");
        }
        return null;
    }

    /**
     * 释放锁
     * @param lock
     */
    public void releaseLock(InterProcessMutex lock){
        try{
            if (lock != null && lock.isAcquiredInThisProcess()){//只有拿锁的线程可以释放锁
                lock.release();
            }
        } catch (Exception e) {
            System.out.println("释放锁失败!");
        }
    }
}

在业务代码中使用:

@Autowired
private ZkDistLock zkDistLock;

/**
 * 扣减商品库存
 * 这里简单扣减库存即可,不搞复杂的业务逻辑
 * @param goodsId
 */
public void updateGoods(long goodsId){
    InterProcessMutex lock = null;
    try{
        //获取分布式锁对象
        lock = zkDistLock.acquireLock("/locks", 10, TimeUnit.SECONDS);
        if (lock != null){
            //根据id查询对应商品
            ShopGoods shopGoods = shopGoodsMapper.selectById(goodsId);
            //库存--
            shopGoods.setGoodsNumber(shopGoods.getGoodsNumber()-1);
            //更新库存
            shopGoodsMapper.updateById(shopGoods);
        }else {
            logger.error("没有拿到分布式锁");
        }
    }catch (Exception e){
        logger.error("更新库存失败!!!",e);
    }finally {
        //释放锁
        zkDistLock.releaseLock(lock);
    }
}
posted @ 2026-06-09 08:13  寒月静无光  阅读(8)  评论(0)    收藏  举报