实现基于 Mysql、Redis 等常用中间件的分布式锁

一.项目背景

​ 该项目初次是在一个开源的Quarkus项目中集合分布式锁功能。后我将他拆分出来形成了一个基于SpringBoot的项目。

代码仓库:Huahuoao/huahuo-lock: 基于redis和mysql的可重入高可用分布式锁实现 (github.com)

1.项目基本需求

1.Redis实现分布式锁

Redis可以用作分布式锁的原因有以下几点:

  1. 高性能和低延迟:Redis是一个内存数据库,具有快速读写速度和低延迟的特点。这使得在Redis中实现分布式锁可以获得较高的性能,并且不会对系统的响应时间产生显著影响。
  2. 原子操作 :Redis提供了一组原子操作,如SETNX(设置键不存在时才设置值)和EXPIRE(设置过期时间),这些操作可以保证在并发环境下对锁的获取和释放是原子的。可以使用Lua脚本保证原子性

2.Mysql实现分布式锁

  1. 类似于Redis的机制,对于一把锁设置一个唯一的id,利用主键唯一原则可以保证锁的互斥性。

  2. select xxxx for update; 可以很好的保证查询更改的原子性

  3. 利用Quarkus的事务注解,可以保证加锁,解锁整个事务的原子性

  4. 对于锁的过期机制 ,没有redis那种原生的过期时间,但是可以通过懒加载机制,对每次锁操作之前进行一次时间校验。

需要保证

  • 锁可重入:需要标记线程,对同一线程的锁进行重入处理。
  • 锁失效机制:配备过期时间,以及解锁。
  • 防止死锁: 获取锁失败,或者运行过程中解锁失败,有自动解锁机制,避免死锁。
  • 具备非阻塞锁特性:尝试多次获取锁之后自动退出争抢,避免阻塞。

二.技术方法及其可行性

1.Redis相关

  • Redis可以使用Lua脚本把一系列操作变为原子操作

具体参考 REDIS|EVAL

  • Redis客户端Redission有非常成熟的方案参考。

redission代码仓库

2.Mysql相关

  • 乐观锁
  • 懒加载思想
  • Quarkus事务
  • IOC容器的反射机制

三.项目实现细节梳理

一、Redis可重入锁(社区已实现)

核心流程图

  • tryLock
  • unLock

特性实现

具备可重入特性;具备锁失效机制,防止死锁;具备非阻塞锁特性。

  • 可重入:采用value记录锁的数量,每次解锁-1
  • 锁失效:设置了redis数据的过期时间
  • 一次lock对应一次unlock,防止死锁
  • trylock会循环若干次获取锁,若获取失败,自动退出争抢。保证了非阻塞锁的特性
  • 为了保证原子性,对redis的操作均采用lua脚本实现

分布式锁生命周期

二、Redis可重入读写锁

写锁为排他锁,读锁允许多线程访问。但是读写锁相互排斥,同一时间检测读写锁只能有一种锁存在。

思路分析

读写锁(Read-Write Lock)是一种多线程同步机制,用于管理对共享资源的并发访问。它可以同时支持多个读操作,但在写操作时只能有一个线程进行访问。读写锁的目的是提高并发性能,允许多个线程同时读取共享资源,从而实现读写分离,减少了对资源的互斥访问。

  1. 假设创建一个读写锁,设置key为**"taskId"**

  2. 那么在读写锁处理的时候,可以把这个key添加前缀

  3. 读锁 "read-taskId" , 写锁 "write-taskId"

  4. 这样在读写锁判定的时候可以很方便的来查找redis数据中之间是否存在读/写锁。 (保证读写互斥性)

举例(伪代码):

java 复制代码
new lock("task1","read"); // 创建一个key为read-task1的读锁
  1. 那么在操作redis的时候,就可以把 "read-task1"作为key来创建一个hash结构,代表创建了一个读锁。

  2. 同样的,"write-task1"就是这个任务的写锁。因此在创建读/写锁之前先判断write-task1/read-task1是否存在。如果存在则返回创建失败,开始自旋

1.读锁

读锁需要考虑的问题以及解决思路

问题:1 读锁与读锁不互相排斥,并且都支持重入

​ 2 读锁与写锁互斥

解决思路:

​ 同名读锁之间共享一个hash结构,用field区分,并且可以分别重入。解锁的时候要判断一下如果hash结构中没有元素了,就把hash删除。如果field value = 0 ,就把这个field删除。

tryLock()核心代码

java 复制代码
   if (mode.equals("read")) {
            String command = "if redis.call('exists', KEYS[2]) == 1 then "
                    + "    return nil; "   // 如果存在写锁,直接加锁失败。
                    + "else "
                    + "    if redis.call('exists', KEYS[1]) == 0 then "   // 判断指定的key是否存在
                    + "        redis.call('HSET', KEYS[1], ARGV[2], 1) "   // 不存在新增key,value为hash结构
                    + "        redis.call('PEXPIRE', KEYS[1], ARGV[1]) "  // 设置过期时间
                    + "    else "
               // key存在说明已经有读锁创建了,接下来判断这个锁是重入锁,还是其他线程的读锁
                    + "        if redis.call('HEXISTS', KEYS[1], ARGV[2]) == 1 then "      
                    + "            redis.call('HINCRBY', KEYS[1], ARGV[2], 1) "  // hash中指定键的值+1
                    + "            redis.call('PEXPIRE', KEYS[1], ARGV[1]) "  // 重置过期时间
                    + "            return 1; "
                    + "        else "                       // 不是重入锁,创建新锁
                    + "            redis.call('HSET', KEYS[1], ARGV[2], 1) "
                    + "            redis.call('PEXPIRE', KEYS[1], ARGV[1]) "
                    + "        end "
                    + "    end "
                    + "    return 1; "                 // 直接返回1,表示加锁成功
                    + "end";
            //list传入 key分为两种 第一种 id 第二种 id-mode。一起创建一起删除

            list.add(command);
            list.add("2"); // keyNum
            list.add("read-" + key); //hash键   KEYS[1]
            list.add("write-" + key); //写锁的key KEYS[2]
            list.add(timeout.toString()); //ARGV[1]
            list.add(value); // 锁的内容 也就是hash的名字 ARGV[2]

unLock()核心代码

java 复制代码
   if (mode.equals("read")) {
            String command = "if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then " +
                    "    return nil; " +  // 判断当前客户端之前是否已获取到锁,若没有直接返回null
                    "end; " +
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1); " + // 锁重入次数-1
                    "if (counter > 0) then " + // 若锁尚未完全释放,需要重置过期时间
                    "    redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "    return 0; " +  // 返回0表示锁未完全释放
                    "else " +
                    "    redis.call('hdel', KEYS[1], ARGV[2]); " +  //如果hash长度还大于0说明还有读锁在里面
                    "    if redis.call('hlen', KEYS[1]) > 0 then " +
                    "        return 0; " +
                    "    end; " +
                    "    return 1; " +   // 返回1表示锁已完全释放
                    "end; " +
                    "return nil;";
            List<String> list = new ArrayList<>();
            list.add(command);
            list.add("1"); // keyNum
            list.add("read-" + key); //hash键   KEYS[1]
            list.add(timeout.toString()); //ARGV[1]
            list.add(value); // 锁的内容 也就是hash的名字 ARGV[2]
            Response result = redisClient.eval(list);

2.写锁

写锁比读锁逻辑稍微简单一点,主要和之前的可重入锁逻辑类似,最前面加一条判断现在是否有读锁存在就可以了,保证和读锁的互斥性。

tryLock()核心代码

java 复制代码
 else if (mode.equals("write")) {
            String command = "if redis.call('exists', KEYS[2]) == 1 then "
                    + "    return 1; " + // 如果存在读锁,直接加锁失败。
                    "end; " +
                    "if (redis.call('exists', KEYS[1]) == 0) then " +  //判断指定的key是否存在
                    "    redis.call('hset', KEYS[1], ARGV[2], 1); " +  //新增key,value为hash结构
                    "    redis.call('pexpire', KEYS[1], ARGV[1]); " + //设置过期时间
                    "    return nil; " +  //直接返回null,表示加锁成功
                    "end; " +
                    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +  //判断hash中是否存在指定的建
                    "    redis.call('hincrby', KEYS[1], ARGV[2], 1); " + //hash中指定键的值+1
                    "    redis.call('pexpire', KEYS[1], ARGV[1]); " +   //重置过期时间
                    "    return nil; " +     //返回null,表示加锁成功
                    "end; " +
                    "return redis.call('pttl', KEYS[1]);"; //返回key的剩余过期时间,表示加锁失败

            list.add(command);
            list.add("2"); // keyNum
            list.add("write-" + key); //hash键   KEYS[1]
            list.add("read-" + key); //读锁的key KEYS[2]
            list.add(timeout.toString()); //ARGV[1]
            list.add(value); // 锁的内容 也就是hash的名字 ARGV[2]

unLock()核心代码

java 复制代码
private void releaseLock(String key, String value, Integer timeout, String mode) {
        if (mode.equals("read")) {
            String command = "if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then " +
                    "    return nil; " +  // 判断当前客户端之前是否已获取到锁,若没有直接返回null
                    "end; " +
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1); " + // 锁重入次数-1
                    "if (counter > 0) then " +  // 若锁尚未完全释放,需要重置过期时间
                    "    redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "    return 0; " + // 返回0表示锁未完全释放
                    "else " +
                    "    redis.call('hdel', KEYS[1], ARGV[2]); " + //如果hash长度还大于0说明还有读锁在里面
                    "    if redis.call('hlen', KEYS[1]) > 0 then " +
                    "        return 0; " +
                    "    end; " +
                    "    return 1; " + // 返回1表示锁已完全释放
                    "end; " +
                    "return nil;";
            List<String> list = new ArrayList<>();
            list.add(command);
            list.add("1"); // keyNum
            list.add("read-" + key); //hash键   KEYS[1]
            list.add(timeout.toString()); //ARGV[1]
            list.add(value); // 锁的内容 也就是hash的名字 ARGV[2]

三、Mysql可重入锁

1.整体程序逻辑



2.数据表设计

sql 复制代码
create table distributed_lock
(
    id              bigint auto_increment //主键
        primary key,
    lock_key        varchar(255) null, //锁的内容
    lock_num        int          null, //锁的重入次数 默认1
    expiration_time datetime     null, //锁的过期时间
    task_name       varchar(255) null, //任务名称,独立区分
    created_at      datetime     null, //以下继承BaseEntity
    updated_at      datetime     null,
    version         int          null,  //版本号,用来做乐观锁,避免资源争抢导致的并发问题
    constraint id
        unique (id)
);

3.难点攻破

  1. 为了保证mysql的原子性,必须使用Qurkus的事务处理机制,但是new lock( ) 出来的对象不被框架管理,无法使用框架的事务处理机制,于是实现了一个Service层,但是new出来的对象也无法@Inject,所以就用到了IOC的反射机制,并且在new lock( )的时候采用构造函数获取到这个被框架管理的Service。于是这个Service就拥有了使用事务的能力,通过lock对象传递参数(并且在这个方法加上一层同步类级锁来保证线程安全)。全部交给Service来处理,并且使用事务包裹,同时配合采用了Quarkus自带的@Version(乐观锁)处理机制。
java 复制代码
 public MysqlReentrantLock(String keyName, String lockValue, Integer timeout) {
        this.keyName = keyName;
        this.lockValue = lockValue;
        this.timeout = timeout * 1000;
        this.service = Arc.container().instance(MysqlLockService.class).get();
    } //构造函数,通过Arc注入
  1. mysql没有原生的数据过期策略,于是借用了懒加载的思路来实现过期策略,当需要操作锁的时候进数据库查询是否过期。尽可能的不影响整体系统的性能。

4.关键代码

java 复制代码
 //尝试获取锁
@Transactional
    public boolean tryLock(String id, String uuid) throws InterruptedException {
        DistributedLockEntity lock = this.getLock(id);
        System.out.println("当前操作线程===>" + Thread.currentThread().getName());
        if (lock == null) { //  如果没有这个锁,那就创建
            this.newLock(id, uuid);
            return true;
        } else if (lock != null) {  //如果锁存在就要判断锁有没有过期
            boolean expired = this.isExpired(lock.getExpirationTime());
            if (expired) { //如果过期
                this.deleteLock(id); //删除这个锁
                this.newLock(id, uuid); //创建新锁
                      return true;
            } else { //锁没过期
                if (uuid.equals(lock.getLockKey())) { //是否为重入锁
                    this.updateNum(lock.getLockNum() + 1, lock.getTaskName());
                    return true;
                } else {
                    return false;
                }
            }
        }
        return false;
    }
java 复制代码
@Transactional
    public int unlock(String taskName, String uuid) {
        DistributedLockEntity lock = repository.getLock(taskName,uuid);
        return notHoldLock(lock);
    }

    private int notHoldLock(DistributedLockEntity lock) {
        if (lock == null) {
            return 2; // 异常:该线程没有锁或进程不匹配
        } else {
            if (this.isExpired(lock.getExpirationTime()) || lock.getLockNum() == 1) {
                repository.deleteLock(lock.getId());
            } else {
                lock.setLockNum(lock.getLockNum() - 1);
                repository.updateLockNum(lock);
                return 0; // 减少重入成功
            }
        }
        return 1; // 解锁成功
    }

    @Transactional
    public int unlock(String taskName, String uuid,String mode) {
        DistributedLockEntity lock = repository.getLock(mode+"-"+taskName,uuid);
        return notHoldLock(lock);
    }

5.设计架构

四、Mysql实现分布式读写锁

1.整体逻辑架构

2.介绍

本质上与普通分布式锁没有大的差别,依然采用分布式锁设计思路。只是在trylock之后需要判断一下数据库是否存在读/写锁,来保证读写互斥。写锁也会与自己互斥。读锁可以创建新锁。

3.关键代码逻辑

java 复制代码
 @Transactional
    public boolean tryLock(String taskName, String uuid, String mode) throws InterruptedException {
        if (mode.equals("read")) {   //如果是读锁
            DistributedLockEntity writeLock = this.getLock("write-" + taskName);
            if (writeLock != null) { //如果写锁不为空
                boolean expired = this.isExpired(writeLock.getExpirationTime());
                if (expired) {
                    this.deleteLock(writeLock.getId());
                }//写锁过期删了
                else {
                    return false;
                }
            }

            DistributedLockEntity lock = this.getLock("read-" + taskName, uuid);  //获取读锁,如果获取到就是重入锁,不是就创建新锁
            if (lock != null) {
                //判断过期
                if (this.isExpired(lock.getExpirationTime())) {
                    this.deleteLock(lock.getId());  //过期删掉
                } else { //没过期就重入
                    lock.setLockNum(lock.getLockNum() + 1);
                    lock.setExpirationTime(LocalDateTime.now().plusSeconds(applicationProperties.getLockExpireTime()));
                    repository.updateLockAndTime(lock);
                    return true;
                }
            } else {
                this.newLock("read-" + taskName, uuid);
                return true;
            }
        } else if (mode.equals("write")) {  //进入写锁逻辑
            List<DistributedLockEntity> writeLocks = this.getLockReadWrite("read-" + taskName); //判断是否有读锁
            if (writeLocks != null) {
                for (DistributedLockEntity writeLock : writeLocks) {  //遍历获取到读读锁
                    if (this.isExpired(writeLock.getExpirationTime())) {
                        this.deleteLock(writeLock.getId());  //如果过期就删掉
                    } else {
                        return false; //没过期就false,但凡有一个没过期,都会失败。
                    }
                }
            }
            //这边就是没有读锁了
            DistributedLockEntity lock = this.getLock("write-" + taskName);  //获取写锁
            if (lock != null) { //获取到了就要判断过期
                if (this.isExpired(lock.getExpirationTime())) {
                    this.deleteLock(lock.getId());
                    this.newLock("write-" + taskName, uuid);
                    return true;
                } else {
                    //判断是否重入
                    if (uuid.equals(lock.getLockKey())) {
                        //重入
                        lock.setLockNum(lock.getLockNum() + 1);
                        lock.setExpirationTime(LocalDateTime.now().plusSeconds(applicationProperties.getLockExpireTime()));
                        repository.updateLockAndTime(lock);
                        return true;
                    }
                    return false;
                }
            } else {
                this.newLock("write-" + taskName, uuid);
                return true;
            }
        } else {
            throw new RuntimeException("Lock mode is incorrect, expected value is \"read\" or \"write\"");
        }
        return false;
    }

简单测试

java 复制代码
/*
 * Copyright (c) 2022 Institute of Software Chinese Academy of Sciences (ISCAS)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */


package xyz.eulix.platform.services.lock;

import io.quarkus.test.junit.QuarkusTest;
import org.jboss.logging.Logger;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import xyz.eulix.platform.services.lock.service.MysqlLockService;

import javax.inject.Inject;

@QuarkusTest
public class DistributedLockTest {
    private static final Logger LOG = Logger.getLogger("app.log");

    @Inject
    DistributedLockFactory lockFactory;

    @Test
    void testRedisReentrantLock() throws InterruptedException {
        String keyName = "RedisReentrantLock";
        DistributedLock lock = lockFactory.newRedisReentrantLock(keyName);
        // 加锁
        Boolean isLocked = lock.tryLock();
        if (isLocked) {
            LOG.infov("acquire lock success, keyName:{0}", keyName);
            try {
                if(lock.tryLock()){
                    // 这里写需要处理业务的业务代码
                    LOG.infov("reentrant lock success, keyName:{0}", keyName);
                    LOG.info("do something.");
                    Thread.sleep(3000);
                }
            } finally {
                // 释放锁
                lock.unlock();
                lock.unlock();
                LOG.infov("release lock success, keyName:{0}", keyName);
            }
        } else {
            LOG.infov("acquire lock fail, keyName:{0}", keyName);
        }
        Assertions.assertTrue(isLocked);
    }



    @Test
    void testRedisReadWriteLock() throws InterruptedException {
        String keyName = "RedisReadWriteLock";
        DistributedLock lock = lockFactory.newRedisReadWriteLock(keyName,"write");
        // 加锁
        Boolean isLocked = lock.tryLock();
        if (isLocked) {
            LOG.infov("acquire lock success, keyName:{0}", keyName);
            try {
                if(lock.tryLock()){
                    // 这里写需要处理业务的业务代码
                    LOG.infov("reentrant lock success, keyName:{0}", keyName);
                    LOG.info("do something.");
                    Thread.sleep(3000);
                }
            } finally {
                // 释放锁
                lock.unlock();
                lock.unlock();
                LOG.infov("release lock success, keyName:{0}", keyName);
            }
        } else {
            LOG.infov("acquire lock fail, keyName:{0}", keyName);
        }
        Assertions.assertTrue(isLocked);
    }
    @Test
    void testMysqlReentrantLock() throws InterruptedException {
        String keyName = "MysqlReentrantLock";
        DistributedLock lock = lockFactory.newMysqlReentrantLock(keyName);
        // 加锁
        Boolean isLocked = lock.tryLock();
        if (isLocked) {
            LOG.infov("acquire lock success, keyName:{0}", keyName);
            try {
                if(lock.tryLock()){
                    // 这里写需要处理业务的业务代码
                    LOG.infov("reentrant lock success, keyName:{0}", keyName);
                    LOG.info("do something.");
                    Thread.sleep(3000);
                }
            } finally {
                // 释放锁
                lock.unlock();
                lock.unlock();
                LOG.infov("release lock success, keyName:{0}", keyName);
            }
        } else {
            LOG.infov("acquire lock fail, keyName:{0}", keyName);
        }
        Assertions.assertTrue(isLocked);
    }
    @Test
    void testMysqlReadWriteLock() throws InterruptedException {
        String keyName = "MysqlReadWriteLock";
        DistributedLock lock = lockFactory.newMysqlReadWriteLock(keyName,"write");
        // 加锁
        Boolean isLocked = lock.tryLock();
        if (isLocked) {
            LOG.infov("acquire lock success, keyName:{0}", keyName);
            try {
                if(lock.tryLock()){
                    // 这里写需要处理业务的业务代码
                    LOG.infov("reentrant lock success, keyName:{0}", keyName);
                    LOG.info("do something.");
                    Thread.sleep(3000);
                }
            } finally {
                // 释放锁
                lock.unlock();
                lock.unlock();
                LOG.infov("release lock success, keyName:{0}", keyName);
            }
        } else {
            LOG.infov("acquire lock fail, keyName:{0}", keyName);
        }
        Assertions.assertTrue(isLocked);
    }
}

测试环境:macbookAir m1,8g内存

测试结果:均满足issue要求,所有锁可以保证本地线程安全以及分布式环境下线程安全。在双主机,各开20条线程的环境下测试。没有发生死锁,阻塞等情况。效率良好

锁效率时间表(参考)

采用24条线程同时运行的情况下。分别加锁解锁,通过原子类来计算平均时间。

因为在测试类中,**初次加锁,需要与redis和mysql建立数据库连接,所以耗时相对较长。如果在持有数据库连接的情况下,可以参考重入锁的耗时。**相对而言,redis分布式锁效率会高于mysql分布式锁。

锁分类 平均加锁时间(初次加锁) 平均加锁时间(重入锁) 平均解锁时间
Redis分布式锁 68ms 4ms 4ms
Redis读锁 65ms 3ms 3ms
Redis写锁 63ms 3ms 3ms
Mysql分布式锁 170ms 45ms 19ms
Mysql读锁 180ms 50ms 13ms
Mysql写锁 174ms 36ms 12ms
java 复制代码
@Test
    void lockEfficiencyTest() throws InterruptedException { //6ms
        AtomicLong lockTime = new AtomicLong();
        AtomicLong unLockTime = new AtomicLong();
        for (int i = 0; i < 24; i++) {
            new Thread(() -> {
                DistributedLock lock =  lockFactory.newRedisReadWriteLock(String.valueOf(UUID.randomUUID()),"write");;
                try {
                    long t1 = System.currentTimeMillis();
                   lock.tryLock();
                    //lock.tryLock();
                    lockTime.addAndGet(System.currentTimeMillis() - t1);
                } catch (Exception e) {
                } finally {
                    long t2 = System.currentTimeMillis();
                  // lock.unlock();
                   lock.unlock();
                    unLockTime.addAndGet(System.currentTimeMillis() - t2);
                }
            }).start();
        }
        Thread.sleep(2000);
        System.out.println("加锁平均用时: "+lockTime.longValue()/24+"ms");
        System.out.println("解锁平均用时: "+unLockTime.longValue()/24+"ms");
    }
相关推荐
向前看-7 小时前
验证码机制
前端·后端
超爱吃士力架9 小时前
邀请逻辑
java·linux·后端
AskHarries11 小时前
Spring Cloud OpenFeign快速入门demo
spring boot·后端
isolusion11 小时前
Springboot的创建方式
java·spring boot·后端
zjw_rp12 小时前
Spring-AOP
java·后端·spring·spring-aop
TodoCoder12 小时前
【编程思想】CopyOnWrite是如何解决高并发场景中的读写瓶颈?
java·后端·面试
凌虚13 小时前
Kubernetes APF(API 优先级和公平调度)简介
后端·程序员·kubernetes
机器之心14 小时前
图学习新突破:一个统一框架连接空域和频域
人工智能·后端
.生产的驴14 小时前
SpringBoot 对接第三方登录 手机号登录 手机号验证 微信小程序登录 结合Redis SaToken
java·spring boot·redis·后端·缓存·微信小程序·maven
顽疲15 小时前
springboot vue 会员收银系统 含源码 开发流程
vue.js·spring boot·后端