Advertisement

纠结!分布式锁到底用Redis好还是ZooKeeper好?

阅读量:

前言

1. 什么是分布式锁

分布式锁是一种用于协调分布在不同系统的共享资源访问同步的方式。为了统一协调各个系统的操作,在共享一个或多个资源时,则必须采用互斥机制以防止相互干扰并保证一致性;此时,在访问这些资源的过程中,则必须采用分布式锁作为解决方案。

2. 为什么要使用分布式锁

为了确保一个方法或属性在高并发情况下的同一时刻只能由单一线程独占执行,在基于单体架构的单机部署环境中,可以通过Java提供的并发处理API(如ReentrantLock或Synchronized)来实现互斥控制。

在通常的单机环境中(如本地应用),Java提供了诸多处理并发问题的相关API。然而,在面对业务需求增长时(如大规模数据处理或实时响应要求),原本基于单一服务器架构运行的应用被迁移或升级为基于分布式集群系统的架构后(即运行于多线程、多进程且分布于不同机器的环境),原有的基于单机环境下的并发控制锁策略不再适用(因为这些策略无法应对分布式的复杂场景),单纯依靠Java API也无法实现对分布式系统的互斥能力支持。因此,在这种情况下(即面临高并发和大规模数据处理的需求时),必须采用跨 JVM 的互斥机制来实现对共享资源的有效控制与访问管理。这就是为什么分布式锁设计产生的背景原因!

举个例子:

由机器A与机器B组成的分布式集群中运行着两台服务器上的程序配置完全一致的应用实例,并行处理框架确保了该集群具有高度的可靠性。

A机器与B机器各自都有一个定时任务,在每晚两点整时都需要执行该定时任务;不过需要注意的是该定时任务仅能运行一次;否则就会出现错误;因此在实际运行过程中A机器与B机器必须争执锁权;获得锁权者将负责执行该特定的任务;而未能获得锁权的一方则无法进行操作。

3. 锁的处理

单个应用中使用锁: (单进程多线程)

synchronize

分布式锁控制分布式系统之间同步访问资源的一种方式

分布式锁是控制分布式系统之间同步同问共享资源的一种方式

4. 分布式锁的实现

基于数据的乐观锁实现分布式锁

基于zookeeper临时节点的分布式锁

基于redis的分布式锁

关于Redis的知识点总结了一个思维导图分享给大家

Redis.jpg

5. Redis的分布式锁

获取锁:

在set命令组中, 多种参数设置手段可以用来调节命令功能。以下是set命令可选参数的基本结构

复制代码
    redis 127.0.0.1:6379>SET KEY VALUE [EX seconds] [PX milliseconds] [NX|XX]
    
    - EX seconds  设置指定的到期时间(单位为秒)
    - PX milliseconds 设置指定的到期时间(单位毫秒)
    - NX: 仅在键不存在时设置键
    - XX: 只有在键已存在时设置

方式1: 推介

复制代码
    ```
    private static final String LOCK_SUCCESS = "OK";
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
    
    public static boolean getLock(JedisCluster jedisCluster, String lockKey, String requestId, int expireTime) {
    // NX: 保证互斥性
    String result = jedisCluster.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
    if (LOCK_SUCCESS.equals(result)) {
        return true;
    }
    return false;
    }
    ```

方式2:

复制代码
    public static boolean getLock(String lockKey,String requestId,int expireTime) {
     Long result = jedis.setnx(lockKey, requestId);
     if(result == 1) {
         jedis.expire(lockKey, expireTime);
         return true;
     }
     return false;
     }

特别提示:建议采用方案一。具体原因在于,在方案二中涉及的setnx与expire两个独立操作并不构成一个统一的操作。因此当出现setnx故障时,将导致系统陷入死锁状态。基于此分析结果我们应当优先选择方案一作为解决方案。

释放锁:

方式1: del命令实现

复制代码
    public static void releaseLock(String lockKey,String requestId) {
    if (requestId.equals(jedis.get(lockKey))) {
        jedis.del(lockKey);
    }
    }

方式2: redis+lua脚本实现 推荐

复制代码
    public static boolean releaseLock(String lockKey, String requestId) {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return
    redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockKey),
    Collections.singletonList(requestId));
        if (result.equals(1L)) {
            return true;
    }
        return false;
    }

6. zookeeper的分布式锁

下面是zookeeper知识点图谱分享给大家

zookeeper脑图.png

6.1 zookeeper实现分布式锁的原理

理解了锁的原理后,就会发现,Zookeeper 天生就是一副分布式锁的胚子。

首先,Zookeeper的每一个节点,都是一个天然的顺序发号器。

每当在一个节点下方生成子节点时,在选择了有序创建类型(EPHEMERAL_SEQUENTIAL 临时有序或PERSISTENT_SEQUENTIAL 永久有序)的情况下,在新子节点后添加一个顺序编号。这个顺序编号等于前一个生成顺序号加一

比如,在创建一个用于发布消息的锁节点' /test/lock '之后,则可以通过其父节点创建相同前缀 ' /test/lock/seq-' 的子节点;假定所有这些子节点均具有相同的前缀 ' /test/lock/seq-' ,则在创建每个子节点时,请将其定义为有序类型;若第一个被创建的子节点,则生成的时间戳为 ' /test/lock/seq-000000000' ;而随后生成的后续子节点则依次带有时间戳 ' /test/lock/seq-123456789' ,依此类推

image.png

其次,Zookeeper节点的递增性,可以规定节点编号最小的那个获得锁。

一种基于Zookeeper实现的分布式锁机制中,默认情况下每条线程都需要生成一个父节点,并建议这条父节点最好选择持久化类型的(PERSISTENT)对象。随后,在这个父节点下会为每条试图获取锁的线程生成一个子顺序编号节点。这些子顺序编号遵循递增特性,在这种情况下排在最前面的那个子顺序编号线程优先获取锁。因此,在每条线程试图占用资源之前都需要先检查自身所处的位置是否是最前位的子顺序编号位置;如果是,则完成对资源的获取;否则将被其他处于较高优先级的位置所阻塞。

第三,Zookeeper的节点监听机制,可以保障占有锁的方式有序而且高效。

在抢占锁之前,在抢占锁之前

Zookeeper的节点监听机制实现了高度的可靠性与效率,在消息传播方面表现出了极强的稳定性。每当某个特定事件发生时,系统会自动触发相关节点进行响应。具体而言,每当一个Znode检测到指定消息时(如linsten或watch),它会关注其前驱节点的状态变化。当发现前导节点被移除,则需重新评估自身是否符合条件并获取锁。

为什么说Zookeeper的节点监听机制,可以说是非常完美呢?

一条连续不断的一气呵成的首尾相接流程,在后端持续监控前端动态的情况下就不会担心出现中断的情况吗?例如,在分布式系统中(由于网络延迟或其他原因),如果前一个节点未能被成功删除,则后端节点就会一直处于等待状态。

其核心机制设计旨在保障后续参与者的正常操作流程:一方面实现对被删除事件的有效监控,并在此基础上获取相应的锁资源;另一方面则通过灵活的数据结构管理原则来优化资源利用率及系统的响应速度。具体而言,在构建取号队列时应优先采用临时型队列而非固定式队列这不仅有助于提升系统的实时响应能力还能有效降低潜在的数据丢失风险进而保障系统的稳定运行状态

说Zookeeper的节点监听机制,是非常完美的。还有一个原因。

Zookeeper采用了首尾相连的方式,并通过后端监听前端的变化。这种设计从而能够有效规避‘羊群效应’。所谓的‘羊群效应’即为每个节点故障时整个网络的所有节点都会进行监控,并随后作出响应。为了减轻服务器的压力,在出现单个节点故障时系统会自动部署临时顺序节点,在此之后只有紧随其后的那个节点会作出相应反应

6.2 zookeeper实现分布式锁的示例

zookeeper是通过临时节点来实现分布式锁.

复制代码
    import org.apache.curator.RetryPolicy;
    import org.apache.curator.framework.CuratorFramework;
    import org.apache.curator.framework.CuratorFrameworkFactory;
    import org.apache.curator.framework.recipes.locks.InterProcessMutex;
    import org.apache.curator.retry.ExponentialBackoffRetry;
    import org.junit.Before;
    import org.junit.Test;
    
    /** * @ClassName ZookeeperLock
     * @Description TODO
     * @Author lingxiangxiang
     * @Date 2:57 PM
     * @Version 1.0
     **/
    public class ZookeeperLock {
    // 定义共享资源
    private static int NUMBER = 10;
    
    private static void printNumber() {
        // 业务逻辑: 秒杀
        System.out.println("*********业务方法开始************\n");
        System.out.println("当前的值: " + NUMBER);
        NUMBER--;
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("*********业务方法结束************\n");
    
    }
    
    // 这里使用@Test会报错
    public static void main(String[] args) {
        // 定义重试的侧策略 1000 等待的时间(毫秒) 10 重试的次数
        RetryPolicy policy = new ExponentialBackoffRetry(1000, 10);
    
        // 定义zookeeper的客户端
        CuratorFramework client = CuratorFrameworkFactory.builder()
                .connectString("10.231.128.95:2181,10.231.128.96:2181,10.231.128.97:2181")
                .retryPolicy(policy)
                .build();
        // 启动客户端
        client.start();
    
        // 在zookeeper中定义一把锁
        final InterProcessMutex lock = new InterProcessMutex(client, "/mylock");
    
        //启动是个线程
        for (int i = 0; i <10; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        // 请求得到的锁
                        lock.acquire();
                        printNumber();
                    } catch (Exception e) {
                        e.printStackTrace();
                    } finally {
                        // 释放锁, 还锁
                        try {
                            lock.release();
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                    }
                }
            }).start();
        }
    
    }
    }

7. 基于数据的分布式锁

在讨论分布式锁时往往会先摒弃基于数据库的方案, 自然认为这种方案不够"先进".从性能上讲,基于数据库的方案确实不尽如人意,具体比较如下:缓存系统>Zookeeper、etcd>数据库.还有人指出基于数据库的方案存在诸多问题,可靠性有待商榷.而数据库类方案可能不适用于频繁操作的情景.

让我们来深入认识一下基于数据库(MySQL)的方案。具体而言,则分为三类:基于表记录的方式、采用乐观锁策略以及采用悲观锁策略。

7.1 基于表记录

要实现分布式锁这一目标的话,最直接的方法通常是直接创建一张锁表,并通过操作该表中的数据来完成这一过程。当我们需要获取一把锁时,则可以在该表中插入一条新的记录,并且当我们要释放一把锁时,则需要从该表中删除对应的记录。

为了更好的演示,我们先创建一张数据库表,参考如下:

复制代码
    CREATE TABLE `database_lock` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `resource` int NOT NULL COMMENT '锁定的资源',
    `description` varchar(1024) NOT NULL DEFAULT "" COMMENT '描述',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uiq_idx_resource` (`resource`) 
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据库分布式锁表';

获得锁

我们可以插入一条数据:

INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

由于在数据库locking表database_lock中定义了resource作为唯一索引,在这种情况下向该数据库提交其他请求会导致错误并无法完成操作。只有当一个请求能够被成功执行并完成插入操作后才能释放锁

删除锁

INSERT INTO database_lock(resource, description) VALUES (1, 'lock');

这种实现方式非常的简单,但是需要注意以下几点:

这种锁不具备失效期限,在释放过程中出现操作失误会导致锁定记录持续存在于数据库中。这一缺陷同样较为容易解决,请注意可以通过定期任务来清除锁定记录。

这种锁的可靠性直接与数据库相关。为确保系统稳定性,请预先配置备份库以避免单点故障情况的发生,并进一步优化数据库架构以提升系统的整体稳定性和安全性。
非阻塞机制下无需重复操作即可获取锁权限。由于插入数据失败时会立即报错,在此情况下无法实现即时锁获取功能。
如果采用阻塞式机制,则建议采用for循环或while循环等方式进行处理直至完成插入操作后再返回结果。

这种类型的锁是非可重入式的。其原因在于同一个线程在未释放所拥有的锁定资源时无法再次重新获取锁定资源;这是因为由于数据库中已存在一份相同的记录。若要实现可重入式锁定机制,则可以通过向数据库表中添加特定字段来实现这一目标;例如记录获取锁定所需的相关主机标识符、线程标识符等信息。当再次请求获取锁定资源时,在取得该资源之前会先执行查询操作;若当前所涉及的相关主机标识符与线程标识符等已存在于数据库中,则可以将相应的锁定权限分配给该请求主体。

7.2 乐观锁

按照一般常识,“系统通常认为,在大多数情况下,数据更新不会导致冲突”。只有当数据库提交更新操作时,“才会对数据进行冲突检测”。如果检测结果与预期不符,“则会返回失败信息”。

image.png

乐观锁的主要实现手段是基于数据版本(version)这一概念所建立起来的记录机制。所谓数据版本号,则是指对数据进行编号标识的一种方式,在采用数据库表来管理数据_version 的方案中,则是通过在数据库表中增加一个专门的 "version"字段来进行管理。具体而言,在这一管理流程中,在每次的数据更新操作之前或之后(在此阶段),都需要向系统提供当前的数据及其对应的 version字段信息;随后会对 version字段进行比对分析——如果其 version字段值保持不变,则可顺利完成此次操作步骤;而当 version字段值存在差异时,则会导致操作失败。

为了更好地理解数据库乐观锁在实际项目中的应用,在学习过程中我们通常会引用一些经典案例来辅助讲解这一技术原理。每个在线商店在其运营过程中都需要管理商品库存,在这种情况下如果只有一个客户下单系统会自动记录这个订单;然而一旦出现多线程访问问题就可能出现不可预测的结果:例如一个电商平台会在处理订单时对商品数量进行递减操作(即每完成一次销售订单就将对应的商品库存数量减少1)。单个用户的下单行为不会影响到系统的稳定性;但在多线程环境下可能会导致数据一致性问题进而引发业务逻辑错误或其他潜在问题

线程不安全操作

后期会补

例如两个用户同时下单购买同一商品在数据库层面的实际操作应为库存减量2的操作然而由于高并发场景的存在第一个用户购买完成后的数据读取当前库存并执行减1操作由于此操作尚未彻底完成导致后续出现异常情况

针对上述问题而言,在数据库设计中乐观锁同样能确保线程安全的一般情况下,我们也都会采取这样的方式。

从商品表中选择数量为'小本子'产品的数量;将'小本子'产品数量减少1

这段SQL由多个语句组成,在获取当前库存数量后对该数值执行减一操作以更新库存信息。在高并发场景下,这条语句可能导致同一商品在被两人购买后库存数量从3减少到2的情况从而出现多卖现象。数据库如何实现乐观锁机制呢?

首先建立一个名为version的字段用作版本标识符每次的操作都会与之相关联

选取goods_num及version字段 FROM 品类表 WHERE 品名与'小本子'匹配; 更新品类表中各项数值 SET 品数=品数-1, 版号=自增后的版本号 WHERE 该记录的品名是'小本子' 并且其当前版号等于查询结果。

实际上可以通过使用update_at字段(updated_at)同样地实现乐观锁类似地采用version字段的方式

7.3 悲观锁

除了可以利用增删操作外管理数据库表中的数据记录以外,在查询语句后追加‘FOR UPDATE’关键字也能有效实现分布式锁机制。该查询会向数据库表施加悲观锁机制(也称为排他锁机制),一旦某条记录被施加了悲观锁机制,则该记录所在的行将无法被其他线程进行更新操作。

悲观锁(pessimistic lock),与乐观lock(optimistic lock)相反,在这种机制中,默认会基于最坏情况的假设,并认为在大部分情况下数据更新可能会导致冲突

当我们在进行MySQL InnoDB操作时,在使用悲观锁的同时需要特别注意其应用层级。在对MySQL InnoDB进行加锁操作时,仅当明确地指定主键(或相关索引)的情况下才会执行行锁定(仅锁定所选数据),否则该系统会实施表锁定(将整个数据表内容进行封锁)。

当采用悲观锁机制时,在不发生事务 commit 的情况下必须确保MySQL数据库停止其默认自动提交功能(参见以下示例)。由于MySQL系统默认启用autocommit机制,在进行一次更新操作后立即会将该操作的结果提交到主存储库。

mysql> SET AUTOCOMMIT = 0; Query OK, 0 rows affected (0.00 sec)

通过使用FOR UPDATE关键字获取事务锁定后即可启动相关的业务流程,在完成该操作后需调用COMMIT方法以释放锁定状态

我们建议参考前面的database_lock表以说明其使用方法。假设有线程A需获取数据库锁并执行相关操作,则其具体流程如下:第一步,线程A应获取数据库锁表中的锁资源;第二步,在取得锁后需执行相应的数据库操作;第三步,在完成上述操作后需释放所获锁资源。

STEP1-获取锁:SELECT * FROM database_lock WHERE id=1 FOR UPDATE;。

STEP2-执行业务逻辑。

STEP3-释放锁:COMMIT。

最后

程序媛小琬

我这边归纳整理了以下各类资源:包括Redis及ZK的相关技术文档、Spring全家桶系列软件的学习资料以及Java系统的全面学习材料(涵盖核心知识点、面试重点与最新互联网真题等)。这些优质的学习资源均为电子版形式提供,并附带详细的解析与实践指导。如有兴趣的朋友可以通过订阅后关注微信公众号【程序媛小琬

全部评论 (0)

还没有任何评论哟~