分布式系统 – 分布式锁

背景

一般我们在开发中经常使用Java的synchronized 和 ReentrantLock 来对多线程环境中控制对资源的并发访问,随着分布式的发展,本地加锁已经不能满足我们复杂场景的需要,于是有了分布式锁。

我们在日常的系统开发中经常会碰到一些比较棘手的场景问题。比如:

  • 数据的正确性:使用分布式锁可以避免破坏数据的正确性,如果两个节点在同一条数据上操作,又比如多个节点对同一次定时任务进行处理,都有可能导致数据的不正确性。
  • 功能的效率:使用分布式锁还可以避免不同节点重复相同的工作,比如用户付款后的短信处理,通过分布式锁避免发出多条短信的情况。

本文从基础理论知识开始介绍,再到分布式锁的实现。

什么是锁

在单进程系统中,当存在多线程可以同时改变某个变量时,就需要对变量或者代码块做同步,使其在修改这种变量时能够线性执行消除并发。

而同步的本质是通过锁来实现的。为了实现多线程在同一时刻同一个代码块只能有一个线程可执行,就需要做个标记,这个标记每个线程都能看到,标记可以理解为锁。

Java 中 synchronized 是在对象头设置标记, Lock 接口实现类基本上只是对一个volitile 修饰的int 变量 的可见性和原子修改。

分布式系统 - 分布式锁

什么是分布式

分布式CAP理论告诉我们:

任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。

目前大多数大型网站和应用都是分布式的,分布式场景中的数据一致性一直是一个比较重要的话题。在互联网领域绝大数的场景里,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证最终一致性。

分布式场景

我们为了保证数据的最终一致性,需要很多的技术方式来支撑,比如分布式锁、分布式事务。

  • 分布式与单机的情况最大的不同在于其不是多线程而是多进程。
  • 多线程可以共享堆内存,通过内存标记存储位置。进程之间需要将标记存储在一个所有进程都能看到的地方。

分布式锁

我们确定了在分布式场景的数据一致性,需要分布式锁来支撑,分布式有哪些特点呢:

  • 互斥性:互斥是锁的基本特征,同一时刻只能被一个线程持有。
  • 可重入性:一个线程在持有锁的情况可以再次获取这个锁。
  • 锁超时:通过超时释放,可以避免死锁,防止不必要的线程等待和资的浪费。
  • 阻塞性:未获取到锁可以阻塞等待获取锁,也可以在没有获取到锁直接快速返回获取锁失败。
  • 高性能和高可用:加锁和释放锁过程性能开销尽可能低,同时也要保证高可用防止分布式锁意外失效。
分布式系统 - 分布式锁

分布式锁实现

基于数据库与Zookeeper都能实现分布式锁,但不满足本文的高性能,本文着重针对Redis锁的三种实现方案进行对比。

1.Redis 单节点

Redis单节点的分布式锁主要利用Redis 的 setnx命令。

  • 加锁:SETNX key value,当键不存在时,对键进行设置操作并返回成功,否则返回识别。Key是锁的唯一表示,一般按业务进行区分命名。
  • 解锁:DEL key,通过删除键值对释放锁。
  • 锁超时:EXPIRE key timeout,设置key超时时间,以保证锁即使没有被显示释放,也能在一定时间后自动释放,避免资源永久被占用。

1.1 SETNX和EXPIRE 原子性

这里有个问题,就是加锁和设置过期时间不是原子操作,有两个方法:

  1. 在Redis 2.8之前我们需要使用Lua脚本来实现。
  2. 在Redis 2.8之后,Redis支持 nx 和 ex 操作是同一原子操作。
set key value ex 5 nx

1.2 锁误解除

如果线程A成功获取了锁,并设置过期时间5秒,但线程A执行时间超过5秒,锁过期自动释放,若此时B线程获取到了锁,随后A执行完成后,线程A使用DEL命令来释放锁。

可以通过value来标识当前线程的锁,在删除之前验证key对应的value释放当前线程持有的。通过lua脚本来验证和解锁操作。

SET key uuid NX EX 30
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
    then return redis.call('del', KEYS[1])
else return 0
end

1.3 阻塞性

目前Reidis 单节点分布式的实现方式,都是立即返回结果的,暂不支持线程阻塞等待解锁。

2.RedLock

单节点分布式锁最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况。

正因为如此,Redis官方提出一种权威的基于Redis实现分布式锁的方式,名词就叫做RedLock。

这种方式比原先的单节点的方法更加安全,它可以确保以下特性:

  • 互斥性:互斥访问,即永远只有一个client能拿到锁。
  • 容错性:只要大部分Redis节点存活就可以正常提供服务。

Redlock算法

在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。

为了取到锁,客户端应该执行以下操作:

  • 获取当前Unix时间,以毫秒为单位。
  • 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
  • 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
  • 如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
  • 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁

尽量少用Redlock

Redlock 实在不是一个好的选择,对于需求性能的分布式锁应用它太重了且成本高;对于需求正确性的应用来说它不够安全。

如果你的应用只需要高性能的分布式锁不要求多高的正确性,那么单节点 Redis 够了;如果你的应用想要确保正确性,那么不建议 Redlock,建议使用一个合适的一致性协调系统,例如 Zookeeper,且保证存在 fencing token。

3.Redisson

Redisson与Jedis类似都是Redis的客户端,相比Jedis功能简单。Jedis简单使用阻塞I/O和Redis交互,Redisson通过Netty支持非阻塞I/O。

分布式系统 - 分布式锁

加锁核心代码

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    internalLockLeaseTime = unit.toMillis(leaseTime);

    return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return nil; " +
                    "end; " +
                    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return nil; " +
                    "end; " +
                    "return redis.call('pttl', KEYS[1]);",
            Collections.singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

这里就是底层的调用栈了,直接操作命令,整合成lua脚本后,调用netty的工具类跟redis进行通信,从而实现获取锁的功能。

这段脚本命令还是有点意思的,简单解读一下:

  • 先用exists key命令判断是否锁是否被占据了,没有的话就用hset命令写入,key为锁的名称,field为“客户端唯一ID:线程ID”,value为1;
  • 锁被占据了,判断是否是当前线程占据的,是的话value值加1;
  • 锁不是被当前线程占据,返回锁剩下的过期时长;

命令的逻辑并不复杂,用了redis的Hash结构存储数据,如果发现当前线程已经持有锁了,就用hincrby命令将value值加1,value的值将决定释放锁的时候调用解锁命令的次数,达到实现锁的可重入性效果。

分布式系统 - 分布式锁

Redisson RedLock

以上就是Redisson分布式锁的加锁原理,就是用lua脚本整合基本的set命令实现锁的功能,这也是很多Redis分布式锁工具的设计原理。除此之外,Redisson还支持用”RedLock算法”来实现锁的效果,这个工具类就是RedissonRedLock

RedLock能一定程度上能有效防止Redis实例单点故障的问题,但并不完全可靠,不管是哪种设计,光靠Redis本身都是无法保证锁的强一致性的。

选型方案对比

方案 优点 缺点
Redis单节点 实现简单,易于理解 不支持阻塞,需要自己实现;在redis集群下会出现锁丢失的问题。
Redlock 解决Redis单点故障问题 Redlock本质上是建立在一个同步模型
Redisson 基于非阻塞Netty模型;官方分布式锁方案,支持阻塞;采用官方RedLock集群化方案,支持Redis集群 工具包有一定复杂度,需要熟悉底层逻辑才能很好的使用
Curator Zookeeper 官方分布式锁方案,支持阻塞;安全性高,ZK可以持久化且实时监听持有锁的客户端的状态 Zookeeper的强一致性需要同步所有集群节点,有性能损耗

结语

如果要分布式锁强一致性,可以采用 Zookeeper 的分布式锁, 因为它底层的 ZAB协议(原子广播协议),天然满足CP,但这就意味着性能的下降。

项目没有强依赖 ZK,选择使用 Redis 就可以,没必要引入一个新的组件增加项目复杂度。

鱼和熊掌不可兼得,性能和一致性方面也往往如此,Redis强大的性能和使用的方便足以满足日常的分布式锁需求,如果业务场景对锁的安全隐患无法忍受的话,最保底的方式就是在业务层做幂等处理。

附录

  1. Distributed Locks with Redis (https://redis.io/docs/reference/patterns/distributed-locks/)

  2. 基于Redis的分布式锁到底安全吗 (http://zhangtielei.com/posts/blog-redlock-reasoning.html)

  3. 通过 Redlock 实现分布式锁(https://blog.brickgao.com/2018/05/06/distributed-lock-with-redlock/)

  4. Redisson分布式锁原理(https://jishuin.proginn.com/p/763bfbd3c6a5)

原文始发于微信公众号(程序猿阿南):分布式系统 – 分布式锁

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/22300.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!