热乎的分布式锁解决方案

广度成就多维视角,深度利于快速定位。                  -- 微微一笑

引言

上一篇《Redis分布式锁使用及问题解决》我们通过场景切入,主要介绍了Redis分布式锁在并发场景下的使用。这一章从多种常用分布式锁的解决方案入手,深入探讨原理及应用场景。在技术广度上加入一些思考。主要内容如下:

分布式锁特性

分布式锁作为一种数据一致性同步机制,在设计和使用时需要具备哪特性来确保其可靠性和有效性?

  • 原子性(Atomicity) : 分布式锁的获取和释放操作应该是原子的,确保在任何时刻只有一个节点能够成功获取锁,避免竞态条件。
  • 可靠性(Reliability) : 分布式锁应该在各种异常情况下都能够可靠地工作,包括网络分区、节点故障等。即使在某个节点出现问题时,其他节点依然能够正常使用锁。
  • 互斥性(Mutual Exclusion) : 分布式锁应该确保在任何时刻只有一个节点能够持有锁,其他节点必须等待或者尝试重新获取锁。
  • 超时机制(Timeout) : 分布式锁通常应该支持超时机制,防止死锁情况的发生。如果一个节点获取锁后在一定时间内没有释放锁,系统应该能够自动释放锁。
  • 可重入性(Reentrancy) : 有些分布式锁允许同一个节点多次获取同一把锁,即可重入性。这对于某些场景(比如递归调用)可能是有用的。

常见分布式锁解决方案

在分布式系统中,我们需要通过分布式锁控制多节点并发导致的数据一致问题。常见的分布式锁解决方案如下,我们通过详细的介绍,清晰的对比,简易的理解方式为读者打开新的视角。

分布式锁一: 数据库乐观锁(CAS思想)

先来大致了解下悲观锁乐观锁的区别:

在分布式系统中悲观锁易造成性能瓶颈。这里只介绍乐观锁的使用。还是先前的场景,用一张商品库存表,说明下如何理解数据库乐观锁。内容如下:

sql 复制代码
mysql> select * from product_stock;
+----+------------+-------+---------+
| id | product_id | stock | version |
+----+------------+-------+---------+
|  1 |          1 |   100 |       1 |
+----+------------+-------+---------+
1 row in set (0.00 sec)

多线程环境下利用version版本号的机制,执行扣库存操作,基本流程如下:

流程描述:

  • 事务A执行
sql 复制代码
START TRANSACTION;

-- 查询当前版本号:1
SELECT version FROM product_stock WHERE product_id = 1;

-- 执行操作,更新版本号
UPDATE product_stock SET stock = stock - 1, version = version + 1 WHERE product_id = 1 AND version = <当前查询到的版本号>;

-- 提交事务
COMMIT;
  • 事务B执行
sql 复制代码
START TRANSACTION;

-- 查询当前版本号:1
SELECT version FROM product_stock WHERE product_id = 1;

-- 执行操作,更新版本号,此处A执行完version=2,更新失败
UPDATE product_stock SET stock = stock - 1, version = version + 1 WHERE product_id = 1 AND version = <当前查询到的版本号>;

-- 提交事务
COMMIT;

由此可见,乐观锁的方式能有效解决并发安全问题,适用于一些读多写少的场景,尤其是对于短事务、容忍性好的应用场景,它能够提高系统的并发性和吞吐量。但是并发量大的时候会导致大量的update失败。

分布式锁二: RedisTemplate: setNX expire

关于使用和常见问题,在上一篇《Redis分布式锁使用及问题解决》已经详细介绍,在使用中,我们曾经埋雷:redis分布式锁过期时间如何设置?接下来我们就聊聊Redission一些原理和使用中的问题。

分布式锁三: Redission框架

我们就上一篇中扣库存的案例,使用Redission框架再次升级。搭建一个简易版的单机环境,详细步骤:

  1. 添加 Maven 依赖:
xml 复制代码
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.17.5</version> <!-- 最新版本请参考官方文档 -->
</dependency>
  1. 配置Redission
kotlin 复制代码
@Configuration
public class RedisConfig {

    @Value("${config.redis.host}")
    private String host;
    @Value("${config.redis.port}")
    private String port;
    @Value("${config.redis.password}")
    private String password;

    @Bean
    public Redisson redisson() {
        // 此为单机模式
        Config config = new Config();
        config.useSingleServer()
                .setAddress("redis://"+host+":"+port)
                .setPassword(password)
                .setDatabase(1);
        return (Redisson) Redisson.create(config);
    }
}
  1. 业务逻辑
ini 复制代码
@RequestMapping("/redissonLock/reduceStock")
    public String reduceStock() {

        String lockKey = "product_01";

        RLock lock = redisson.getLock(lockKey);

        String msg = "";
        try {
            //加锁
            lock.lock();
            int stock = Integer.parseInt(redisTemplate.opsForValue().get("stock"));
            if (stock > 0) {
                int realStock = stock - 1;
                redisTemplate.opsForValue().set("stock", realStock + "");
                msg = "减库存成功,剩余:" + realStock;
                System.out.println(msg);
            } else {
                msg = "减库存失败,库存不足";
                System.out.println(msg);
            }
        } finally {
  
            //释放锁
            lock.unlock();
        }
        return msg;
    }
  1. 测试结果

Pod:8080

Pod:8081

  1. 结果分析

显然,Redission框架确实解决了分布式环境并发产生的数据问题。我们提炼出核心逻辑:

csharp 复制代码
    // 创建分布式锁
        RLock lock = redisson.getLock("myLock");

        try {
            // 尝试获取锁
            lock.lock();

            // 业务逻辑
            System.out.println("Business logic inside the lock.");

        } finally {
            // 释放锁
            lock.unlock();
        }

非常简单易用。但世间蛮多简单的事情其实蛮复杂。正如:哪有什么岁月静好,只因为有人为你负重前行。 那么强大的背后是怎样的支撑?

我们先看下大致的流程,稍后通过源码深入原理:

流程描述:

- 客户端A

1、获取到锁;

2、判断是否设置过期时间,如果未指定锁的持续时间,则使用内部默认的持续时间

3、fork后台线程,定时任务续期;

4、执行完成,释放锁

- 客户端B

1、获取锁失败,因为锁被A持有;

2、while循环,以自旋方式不断尝试获取锁;

3、等待A释放锁,直到获取锁,否则继续步骤 2;

下面通过核心深入源码,详细了解上述流程,并思考几个问题.

这段代码是 Redisson 中用于尝试获取分布式锁的核心业务逻辑。

scss 复制代码
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
  
    RFuture<Long> ttlRemainingFuture;
    //如果指定了加锁时间,加锁使用指定时间
    if (leaseTime != -1) {
        ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    } else {
        // 如果未指定锁的持续时间,则使用内部默认的持续时间
        ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
                TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
    }

    // 异步回调,处理锁获取结果
    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
        //省略部分代码...
        // 锁获取成功
        if (ttlRemaining == null) {
            if (leaseTime != -1) {
                // 更新内部锁持续时间
                internalLockLeaseTime = unit.toMillis(leaseTime);
            } else {
                // 启动定时任务,定期续约锁的过期时间
                scheduleExpirationRenewal(threadId);
            }
        }
    });
    return ttlRemainingFuture;
}

1)加锁逻辑详细说明tryLockInnerAsync

arduino 复制代码
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
            // 使用 Lua 脚本执行原子性操作获取锁
            "if (redis.call('exists', KEYS[1]) == 0) then " + // 如果锁不存在
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 将线程ID作为哈希字段,并递增其值
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " + // 设置锁的过期时间
                    "return nil; " + // 返回 nil 表示锁获取成功
                    "end; " +
                    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + // 如果线程ID已存在
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + // 递增线程ID的值
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " + // 设置锁的过期时间
                    "return nil; " + // 返回 nil 表示锁获取成功
                    "end; " +
                    "return redis.call('pttl', KEYS[1]);",// 返回锁的剩余过期时间
            Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}

标注:

在这段 Lua 脚本中,KEYS[1]、ARGV[1] 和 ARGV[2] 是 Lua 脚本中的参数,对应 evalWriteAsync 方法中传递的参数。

  • KEYS[1]: 代表第一个参数,对应 Collections.singletonList(getRawName()),即 Redis 锁的名称,也就是键值(key)。
  • ARGV[1]: 代表第一个额外参数,对应 unit.toMillis(leaseTime),即以毫秒为单位的锁的持续时间。
  • ARGV[2]: 代表第二个额外参数,对应 getLockName(threadId),即获取锁的线程ID。

2)锁续期:scheduleExpirationRenewal(threadId).方法调用链路:scheduleExpirationRenewal --> renewExpiration。提供简化代码:

scss 复制代码
//开启一个定时任务执行续期逻辑
    private void renewExpiration() {
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                //省略部分代码...
  
                // 异步执行续约操作
                RFuture<Boolean> future = renewExpirationAsync(threadId);
                future.onComplete((res, e) -> {
  
                    //省略部分代码...
                    
                    if (res) {
                        // 如果续约成功,重新调自身执行续约操作
                        renewExpiration();
                    } else {
                        cancelExpirationRenewal(null);
                    }
                });
            }
            // 注意:跟踪代码发现:这里定时任务:lockWatchdogTimeout 的1/3 = 10S 时间去执行
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
        
        ee.setTimeout(task);
    }

3)几个细节

  • 加锁逻辑中的参数
scss 复制代码
Collections.singletonList(getRawName()) = LockKey

getLockName(threadId) ==  UUID:threadId
  • WatchDog默认时间30s,每隔:lockWatchdogTimeout/3 = 10s执行一次定时续期。
ini 复制代码
 private long lockWatchdogTimeout = 30 * 1000;
  
 internalLockLeaseTime / 3
  • 异步思想:通过Future异步线程回调,主线程执行业务逻辑,后台线程执行续期逻辑,减少阻塞

就上述流程,我们做个小总结盘下几个问题:

Q1:如何保证加锁操作原子性?

使用Lua脚本(天生原子)执行加锁逻辑,Redis-Server端自己维护。例如,上述代码中使用Lua脚本执行判断+加锁+超时设置。

kotlin 复制代码
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return 1; " +
                        "end; " +
                        "return 0;

Q2:如何自动续期?

获取锁成功后,利用看门狗机制每隔10s中执行一次续期。

java 复制代码
 private void renewExpiration() {
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                // 异步执行续约操作
                future.onComplete((res, e) -> {
                    if (res) {
                        // 如果续约成功,回调自身执行续约操作
                        renewExpiration();
                    }
                });
            }
            // 定时任务:lockWatchdogTimeout 的1/3 = 10S 时间去执行
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    }

Q3:可重入如何理解?

先看下可重入锁的定义,我们知道synchronized 关键字在 Java 中天生支持可重入锁。

简单的示例代码如下:

csharp 复制代码
  public class ReentrantLockDemo {

    public static void main(String[] args) {
        // 创建一个示例对象
        ReentrantObject reentrantObject = new ReentrantObject();

        // 启动一个线程
        new Thread(() -> {
            try {
                reentrantObject.performTask();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}
  
  
class ReentrantObject {
    // 定义一个可重入锁
    private final Object lock = new Object();

    public void performTask() throws InterruptedException {
        // 第一次获取锁
        synchronized (lock) {
            System.out.println(Thread.currentThread().getName() + " 第一次获取锁");

            // 在持有锁的情况下再次获取锁
            synchronized (lock) {
                System.out.println(Thread.currentThread().getName() + " 第二次获取锁");
                // 执行任务,模拟工作
                Thread.sleep(1000);
            }
        }

        // 释放第一次获取的锁
        System.out.println(Thread.currentThread().getName() + " 第一次释放锁");
    }
}

测试结果:

当一个线程持有一个对象的锁时,它可以再次获取相同对象的锁,而不会被阻塞。可重入锁会维护一个持有锁的计数器。当线程第一次获取锁时,计数器值加一;当线程释放锁时,计数器值减一。只有当计数器值为零时,表示锁被完全释放,其他线程才有机会获取锁。

那么在分布式环境中,如何实现可重入?

在 Redisson 的源码中,可重入锁的实现主要涉及到 Redis 的 Lua 脚本和底层的 Redis 命令。(上面已介绍,看看是那段,哈哈~~~)

简单概括一下 Redisson 中可重入锁的主要实现步骤:

1、创建锁对象: 当用户请求创建一个可重入锁时,Redisson 会在 Redis 中创建一个相应的数据结构表示该锁。这个数据结构通常是一个哈希表。

2、线程标识: 每个请求锁的线程都有一个唯一的标识符。Redisson 使用线程 ID 来标识不同的线程。

3、计数器: 在 Redisson 中,可重入锁的计数器用于记录某个线程持有锁的次数。当线程首次请求锁时,计数器被设置为 1。每次重入请求都会增加计数器。

4、Lua 脚本: Redisson 使用 Lua 脚本来实现原子性的获取锁和释放锁的操作。Lua 脚本可以确保这两个操作是原子的,从而避免了并发问题。

5、获取锁: 获取锁时,Lua 脚本会检查当前线程是否已经持有锁。如果是,则增加计数器。如果不是,则尝试获取锁。获取锁的过程包括判断是否锁已经被其他线程持有,如果没有,则设置锁的所有者和计数器。

6、释放锁: 释放锁时,Lua 脚本会检查当前线程是否持有锁。如果是,则减少计数器。如果计数器减为 0,表示锁可以被释放。释放锁的过程包括判断是否锁的所有者是当前线程,如果是,则释放锁。

Q4:Redis主从架构中锁失效问题?

如,生产环境redis集群主节点宕机,如下场景:

  • Client-A在master节点获取锁成功。还没有把获取锁的信息同步到 slave 的时候,master 宕机。
  • slave 被选举为新 master,这时候没有线程A获取锁的数据。
  • Client-B 就能成功的获得客户端A持有的锁,违背分布式锁互斥性。

为保证数据强一致性,通常有些使用Zookeeper实现分布式锁

分布式锁四: Zookeeper + Curator实现分布式锁

标注:Curator是Zookeeper一个客户端,类似于Redission和Redis的关系。

主要利用Zookeeper的节点唯一路径去实现.其主要原理:

逻辑参考ZK原理和网络文章:juejin.cn/post/703859...

详细描述:

(1)线程到 /locks 路径下面创建一个带序号的临时节点。

(2)判断自己创建的这个节点是不是/locks路径下序号最小的节点,如果是,则获取锁;如果不是最小节点,则监听自己的前一个节点。

(3)获取到锁后,执行业务逻辑,然后删除自己创建的节点。监听它的后一个节点收到通知后,执行步骤(2)

使用ZooKeeper和Curator实现分布式锁的基本步骤:

1、引入Curator依赖

xml 复制代码
<dependency>
    <groupId>org.apache.curator</groupId>
    <artifactId>curator-framework</artifactId>
    <version>5.1.0</version> <!-- 请检查最新版本 -->
</dependency>

2、创建Curator客户端: 创建一个CuratorFramework实例来连接到ZooKeeper服务器

ini 复制代码
CuratorFramework client = CuratorFrameworkFactory.newClient("your_zookeeper_connection_string", new ExponentialBackoffRetry(1000, 3));
client.start();

3、使用分布式锁: Curator提供了InterProcessMutex类来实现分布式锁

csharp 复制代码
InterProcessMutex lock = new InterProcessMutex(client, "/locks/path");

try {
    // 获取锁
    lock.acquire();

    // 执行其它
} catch (Exception e) {
    // 处理异常
} finally {
    // 释放锁
    lock.release();
}

在上述代码中,"/locks/path"是ZooKeeper中的节点路径,用于存储锁信息

4、处理锁超时: 在获取锁时,你可以选择传递超时时间。

csharp 复制代码
  //这样可以避免死锁,如果在指定时间内无法获取锁,可以选择执行其他逻辑。
  if (lock.acquire(5, TimeUnit.SECONDS)) {
    try {
       // 执行其它
    } finally {
        // 释放锁
        lock.release();
    }
} else {
    // 获取锁超时的处理逻辑
}

5、关闭Curator客户端: 在应用程序关闭时,确保关闭CuratorFramework客户端

go 复制代码
client.close();

总结

综上,对各种分布式锁的使用我们给出如下建议:

  1. 数据库乐观锁
  • 优点

    • 通过版本号或时间戳等字段的比对,实现相对简单
    • 支持跨进程,适用于分布式环境,可以跨多个数据库实例使用
  • 缺点

    • 性能: 由于需要比对版本号或时间戳,并发量大会导致大量update失败
    • 死锁: 如果应用层未正确处理死锁,可能出现死锁问题。
  1. Redis的setNX+EXPIRE
  • 优点

    • Redis的原子性操作,实现简单而高效
    • 支持分布式: 可以轻松在分布式环境中使用
  • 缺点

    • 单点故障
    • 当锁的持有者因某种原因宕机,锁可能长时间无法释放
  1. Redisson
  • 优点
  • 支持可重入锁、公平锁、读写锁等
  1. ZooKeeper
  • 优点

    • 强一致性,适用于对一致性要求较高的场景。
    • 高可用性,支持主从架构,半数以上选举机制保证在主节点失效时仍能提供服务。
  • 缺点

    • 性能相对较低: 相比于Redis等内存数据库,ZooKeeper的性能可能相对较低。CP原理。性能和一致性只能取其一。
    • 复杂性: 部署和维护ZooKeeper集群相对复杂。

值得注意的是:他们都有共性的问题,就是 死锁: 所有分布式锁的实现都需要注意死锁的问题,即锁被永久性地占用。

结尾

感知生活:一半有用,一半有趣。 感谢耐心的你阅读到最后。希望本篇文章对你有帮助,也欢迎你加入我们,公众号【码易有道】。一起做长期且正确的事情!!!

相关推荐
MinIO官方账号1 小时前
从 HDFS 迁移到 MinIO 企业对象存储
人工智能·分布式·postgresql·架构·开源
丁总学Java2 小时前
maxwell 输出消息到 kafka
分布式·kafka·maxwell
喜欢猪猪3 小时前
深度解析ElasticSearch:构建高效搜索与分析的基石原创
分布式
蘑菇蘑菇不会开花~4 小时前
分布式Redis(14)哈希槽
redis·分布式·哈希算法
问道飞鱼6 小时前
分布式中间件-Pika一个高效的分布式缓存组件
分布式·缓存·中间件
小宋10217 小时前
玩转RabbitMQ声明队列交换机、消息转换器
服务器·分布式·rabbitmq
懒洋洋的华36912 小时前
消息队列-Kafka(概念篇)
分布式·中间件·kafka
March€13 小时前
分布式事务的基本实现
分布式
DieSnowK14 小时前
[Redis][环境配置]详细讲解
数据库·redis·分布式·缓存·环境配置·新手向·详细讲解
Lill_bin15 小时前
深入理解ElasticSearch集群:架构、高可用性与数据一致性
大数据·分布式·elasticsearch·搜索引擎·zookeeper·架构·全文检索