干货 | 分布式锁的几种实现

本文详细介绍了分布式锁的实现方式,包括基于数据库的悲观锁和乐观锁,以及Zookeeper和Redis的分布式锁实现。数据库中,可以利用forupdate的悲观锁或唯一索引约束实现;Zookeeper通过临时有序节点达成分布式锁;Redis则依赖SET命令和Lua脚本。每种方式都有其优缺点,实际应用中需根据场景选择。
摘要由CSDN通过智能技术生成

为了解决多线程并发场景下的资源占用问题,引入了锁的概念,使用锁可以保证一个资源在同一时刻只能被一个线程访问。随着业务的高速发展,业务系统会快速迭代拆分成多个子服务,同时,为了应对大流量,同一个子服务又会部署多个实例,部署在不同的机器上,单进程中已经被解决的并发问题又会重新出现,而分布式锁就是解决这些问题的有效方案。

分布式锁的实现通常会选择一个存储系统作为全局状态存储,依赖这个系统提供的对象存储原子化的排他性操作,来实现分布式锁的全局排他性。实现分布式锁的方式有很多,根据不同的业务场景选择合适的分布式锁:

  • 数据库

  • Redis

  • Zookeeper

  • b337236c03a3b31550f38dccb608e2c4.png

这篇文章我们将详细介绍一个每一种分布式锁的实现方式。

数据库实现分布式锁

数据库本身的特性决定了它本身就是一个强一致性的系统,有很多特性可以用来实现分布式锁,如唯一索引约束、for update等。

  • 基于for update的悲观锁

这种锁主要是利用InnoDB引擎提供的排他锁,在执行事务操作时,对于包含for update子句的SQL,MySQL会对查询结果集中的每一行都加一个排他锁,其他线程在更新或者删除这些结果的时候都会被阻塞。

利用这个机制,我们可以很容易就实现分布式锁,在获取锁的时候开启事务,成功获取到锁就可以执行业务逻辑,在执行完业务逻辑后,完成事务就可以释放锁。

这种方式实现虽然简单,但是不支持可重入,同时这种实现是阻塞的,锁占用期间会一直占用数据库连接,在高并发场景下很容易消耗完数据库连接池,影响其他业务,因此指定这种方式即可,实际的场景中不推荐这种方式。

  • 基于唯一索引约束的实现

如果表设置了唯一索引,只有在第一次插入的时候会成功,后边的插入操作都会失败,我们创建一个order_lock表,其中resource_key为唯一键,表示一个需要抢占的资源,应用在获取锁的时候,会往这张表里插入一条resource_key为该资源的key的记录,插入成功就认为获取到了锁,删除这条记录就是释放锁,如果插入失败,就不断重试,直到插入成功或超过指定的超时时间,抛出异常。

为了避免操作失败等原因导致锁记录没有正确被删除,还需要定时清理过期的锁记录,以避免出现死锁,具体实现:

CREATE TABLE `order_lock` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `resource_key` varchar(64) NOT NULL COMMENT '锁定的资源 Key, 表示一个需要占用的资源'
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_resource_key` (`resource_key`)  USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='基于唯一索引约束的锁';

如果按照上边的实现实现起来还是不支持可重入,为了实现可重入要改造一下,将锁持有的主机,线程等信息记录在信息里,在获取锁的时候先判断锁记录的相关信息是否与当前主机、线程是否一致,如果一致就认为已经获取了锁。还有一个缺点就是高并发下,这种插入会造成大量死锁,影响数据库的稳定,进而拖垮其他业务的运行。

  • 基于CAS的乐观锁

CAS是CPU支持的一个指令级的操作,即在更新数据前先比较该数据当前值是否等于期望值,如果相等,就将其设置为更新的值,否则就不设置,该指令通过用来是实现乐观锁.

CREATE TABLE `order_lock` (
  `id` int(4) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `resource_key` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的资源 Key, 表示一个需要占用的资源',
  `version` int(4) NOT NULL DEFAULT '' COMMENT '版本号'
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_resource_key` (`resource_key `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='乐观锁实现';

代码实现:

do {
    val old_version = (select version from _lock where resource_key = '{resource_key_1}');
    // 通过 CAS 更新 version, 一次事务仅又一个进程可以成功
    bool success = (update _lock set version = '{new_version}' where resource_key = '{resource_key_1}' and version = '{old_version}'") 
    if (success) {
        // 获取锁成功, 直接返回
        return;
    }
    // 获取失败,重试
} while(true);

乐观锁认为数据的更新在大部分情况下都不会产生冲突,所以只在更新操作时进行冲突检测,适合多读的场景,可以增加系统的吞吐量。

基于Zookeeper的分布式锁

Apache ZooKeeper 致力于开发和维护一个开源服务器,该服务器支持高度可靠的分布式协调。其设计目标是通过简单易用的接口封装复杂且易出错的分布式一致性服务。Zookeeper有很多特性可以用来实现分布式锁,这里介绍的是基于临时有序ZNode的分布式锁实现方案。

Zookeeper使用层级目录的结构来组织存储节点,每个节点称为ZNode,默认情况下,每个Znode可以存储最多1MB的数据,同时每个ZNode下可以包含多个ZNode。看一下ZNode存储模型的代码实例:

|- [/lock]
 |     |- [/lock/lock_001]
 |     └- [/lock/lock_002]
 └- [/node1]
 ...

Zookeeper使用不同的参数可以创建不同类型的ZNode节点:

  1. 持久节点

  • CreatMode为PERSISTENT时,创建普通持久节点,存储在该节点上的数据会永久存储在Zookeeper上。

  • reatMode为PERSISTENT_SEQUENTIAL,创建有序持久节点,存储在该节点上的数据同样是持久化的,和普通持久节点相比,有序节点的节点名称会自动加一个全局单调递增的序号。

  1. 临时节点

  • CreatMode为EPHEMERAL时,创建出来的节点为普通临时节点,临时节点在一个连接Session有效期内是活跃的,当链接的Session过期后,这个Session创建的临时有序节点就会被删除

  • CreatMode为EPHEMERAL_SEQUENTIAL时,创建出来的节点为有序临时节点,和普通临时节点一样,节点及其存储的数据不是持久的,同时,每创建一个新的有序节点,该节点的名称会自动加一个全局单调递增的序号。

利用临时有序节点的全局单调递增,过期会自动删除的特性,我们就可以构建一个可靠的分布式锁,基本原理有以下几点:

  1. 创建一个持久化节点作为父节点,代表一把分布式锁实例。

  2. 一个线程要想持有这把锁时,在该节点下创建一个临时有序节点

  3. 检查新建的临时节点,如果该节点为父节点下所有子节点中序号最小的时,表示加锁成功

  4. 如果当前节点不是最小节点,则需要持续检查节点是否为最小,直到获取锁或者超时,可以通过Zookeeper的Watch机制,当前节点的上一个序号的节点设置一个监听,一直阻塞直到收到上一个节点的删除事件,再重新比较节点的序号,看是否可以获得锁。

  5. 在完成需同步协调的业务逻辑后,可以通过手动删除临时节点的方式释放锁

  6. 如果获得锁的进程因某些原因挂掉,这个临时节点会在Session超时后自动删除,也就自动释放锁了。

上边这种实现的分布式锁是阻塞公平锁的,对于实际使用还是不够的,这个方案是不支持可重入,最简单的实现可重入的方法是,再=在获取锁的线程中维护一个锁标记和计数器,每次加锁的时候判断当前线程是否已经获取了这把锁,如果获取了锁就只将计数器加一,释放锁的时候将计数器减一,如果计数器归零,就释放锁,调用Zookeeper的客户端删除对应的临时节点。

上边介绍的是原理,实际使用的过程中,还有很多边界条件需要考虑,一般复杂的问题都会有封装开箱即用的工具,Apache Curator就为我们提供了开箱即用、工作可靠、特性丰富的基于Zookeeper的分布式锁实现。

基于Redis的分布式锁

Redis在我们的业务系统中通常是作为缓存使用,其实Redis的原子操作、支持原子化执行Lua脚本这些特性也可以快速实现分布式锁。

基本思路:

  1. 基于SET key value [expiration EX seconds | PX milliseconds] [NX|XX]复合命令实现加锁操作,具体实现:

public static boolean tryLock(String lockKey, String uid, int expireTimeSec) {
    String result = jedis.set(lockKey, uid, "NX", "EX", expireTimeSec);
    return "OK".equals(result);
}
  1. 基于Lua脚本完成原子化的删除操作,实现可靠的解锁操作:

public static boolean unlock(String lockKey, String uid) {
    String script = 
            "if redis.call('get', KEYS[1]) == ARGV[1] " + 
            "then " +
                "return redis.call('del', KEYS[1]) " + 
            "else " + 
                "return 0 " +
            "end";
    return jedis.eval(script, Lists.newArrayList(lockKey), Lists.newArrayList(uid)).equals(1L);
}

删除操作使用Lua脚本是因为Redis没有提供原子性的del操作,使用del实现释放锁的逻辑时,需要先判断是否持有锁,再进行删除,这个过程不是原子性的存在误删除的风险,而Lua脚本是整体来执行的,在执行的过程中不会插入其他命令,可以实现原子性的删除操作。

上边的Redis分布式锁的实现就是很常见的实现,这种方案也有很多的缺点,首先就是单点问题,其次是需要先预估超时时间,锁到期后不会自动续租,如果业务执行时间超过了设置的超时时间,或者出现网络阻塞等问题使业务逻辑长时间阻塞,都会导致锁机制失效。

为了解决单点问题,Redis的作者提出了一种解决方案:Redlock(红锁),Redlock依赖多个Master节点(官方推荐大于5个),Master之间都彼此独立。Redlock的实现原理:

  1. 加锁过程:

  • 获取节点当前时间。

  • 一次获取所有节点的锁,每个节点加锁的超时时间都依次减去前面节点加锁所耗的时间总和。

  • 如果在超时时间内没有完成所有节点的加锁操作,就任务加锁失败。增加这个超时时间的约束主要是为了保证获取的锁始终是有效的。

  • 判断是否加锁成功,如果成功获取了超过半数的节点的锁,则任务加锁成功,否则加锁失败,释放锁。只要获取到大多数节点的锁,就能保证锁的正常工作、

  1. 释放锁

  • 需要释放所有节点上的锁,因为加锁过程中虽然只能成功获取了大多数节点的锁,并不能代表失败节点没有实际加锁。

基于Redis的分布式锁,也有开箱即用的开源实现,Redisson支持多种类型的锁和同步量,实际项目中使用较多。

总结

本文主要介绍了几种分布式锁的实现原理,我们平时项目中可能使用Apache Curator和Redisson比较多,但是各种分布式锁的实现方式也需要清除,根据实际情况选择合适的实现方式。

### 回答1: Spark Streaming 和 Flink 都是流处理框架,但在一些方面有所不同。 1. 数据处理模型 Spark Streaming 基于批处理模型,将流数据分成一批批进行处理。而 Flink 则是基于流处理模型,可以实时处理数据流。 2. 窗口处理 Spark Streaming 的窗口处理是基于时间的,即将一段时间内的数据作为一个窗口进行处理。而 Flink 的窗口处理可以基于时间和数据量,可以更加灵活地进行窗口处理。 3. 状态管理 Spark Streaming 的状态管理是基于 RDD 的,需要将状态存储在内存中。而 Flink 的状态管理是基于内存和磁盘的,可以更加灵活地管理状态。 4. 容错性 Flink 的容错性比 Spark Streaming 更加强大,可以在节点故障时快速恢复,而 Spark Streaming 则需要重新计算整个批次的数据。 总的来说,Flink 在流处理方面更加强大和灵活,而 Spark Streaming 则更适合批处理和数据仓库等场景。 ### 回答2: Spark Streaming 和 Flink 都是流处理框架,它们都支持低延迟的流处理和高吞吐量的批处理。但是,它们在处理数据流的方式和性能上有许多不同之处。下面是它们的详细比较: 1. 处理模型 Spark Streaming 采用离散化流处理模型(DPM),将长周期的数据流划分为离散化的小批量,每个批次的数据被存储在 RDD 中进行处理,因此 Spark Streaming 具有较好的容错性和可靠性。而 Flink 采用连续流处理模型(CPM),能够在其流处理过程中进行事件时间处理和状态管理,因此 Flink 更适合处理需要精确时间戳和状态管理的应用场景。 2. 数据延迟 Spark Streaming 在处理数据流时会有一定的延迟,主要是由于对数据进行缓存和离散化处理的原因。而 Flink 的数据延迟比 Spark Streaming 更低,因为 Flink 的数据处理和计算过程是实时进行的,不需要缓存和离散化处理。 3. 机器资源和负载均衡 Spark Streaming 采用了 Spark 的机器资源调度和负载均衡机制,它们之间具有相同的容错和资源管理特性。而 Flink 使用 Yarn 和 Mesos 等分布式计算框架进行机器资源调度和负载均衡,因此 Flink 在大规模集群上的性能表现更好。 4. 数据窗口处理 Spark Streaming 提供了滑动、翻转和窗口操作等灵活的数据窗口处理功能,可以使用户更好地控制数据处理的逻辑。而 Flink 也提供了滚动窗口和滑动窗口处理功能,但相对于 Spark Streaming 更加灵活,可以在事件时间和处理时间上进行窗口处理,并且支持增量聚合和全量聚合两种方式。 5. 集成生态系统 Spark Streaming 作为 Apache Spark 的一部分,可以充分利用 Spark 的分布式计算和批处理生态系统,并且支持许多不同类型的数据源,包括Kafka、Flume和HDFS等。而 Flink 提供了完整的流处理生态系统,包括流SQL查询、流机器学习和流图形处理等功能,能够灵活地适应不同的业务场景。 总之,Spark Streaming 和 Flink 都是出色的流处理框架,在不同的场景下都能够发挥出很好的性能。选择哪种框架取决于实际需求和业务场景。 ### 回答3: Spark Streaming和Flink都是流处理引擎,但它们的设计和实现方式有所不同。在下面的对比中,我们将比较这两种流处理引擎的主要特点和差异。 1. 处理模型 Spark Streaming采用离散流处理模型,即将数据按时间间隔分割成一批一批数据进行处理。这种方式可以使得Spark Streaming具有高吞吐量和低延迟,但也会导致数据处理的粒度比较粗,难以应对大量实时事件的高吞吐量。 相比之下,Flink采用连续流处理模型,即数据的处理是连续的、实时的。与Spark Streaming不同,Flink的流处理引擎能够应对各种不同的实时场景。Flink的实时流处理能力更强,因此在某些特定的场景下,它的性能可能比Spark Streaming更好。 2. 窗口计算 Spark Streaming内置了许多的窗口计算支持,如滑动窗口、滚动窗口,但支持的窗口计算的灵活性较低,只适合于一些简单的窗口计算。而Flink的窗口计算支持非常灵活,可以支持任意窗口大小或滑动跨度。 3. 数据库支持 在处理大数据时,存储和读取数据是非常重要的。Spark Streaming通常使用HDFS作为其数据存储底层的系统。而Flink支持许多不同的数据存储形式,包括HDFS,以及许多其他开源和商业的数据存储,如Kafka、Cassandra和Elasticsearch等。 4. 处理性能 Spark Streaming的性能比Flink慢一些,尤其是在特定的情况下,例如在处理高吞吐量的数据时,在某些情况下可能受制于分批处理的架构。Flink通过其流处理模型和不同的调度器和优化器来支持更高效的实时数据处理。 5. 生态系统 Spark有着庞大的生态系统,具有成熟的ML库、图处理库、SQL框架等等。而Flink的生态系统相对较小,但它正在不断地发展壮大。 6. 规模性 Spark Streaming适用于规模小且不太复杂的项目。而Flink可扩展性更好,适用于更大、更复杂的项目。Flink也可以处理无限制的数据流。 综上所述,Spark Streaming和Flink都是流处理引擎,它们有各自的优缺点。在选择使用哪一个流处理引擎时,需要根据实际业务场景和需求进行选择。如果你的业务场景较为复杂,需要处理海量数据并且需要比较灵活的窗口计算支持,那么Flink可能是更好的选择;如果你只需要简单的流处理和一些通用的窗口计算,Spark Streaming是更为简单的选择。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

故里学Java

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

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

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

打赏作者

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

抵扣说明:

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

余额充值