Redis实现分布式锁

noah2021

Redis|2023-2-20|最后更新: 2024-11-6|
type
status
date
slug
summary
tags
category
icon
password

普通的 Redis 锁

下面只是单机 Redis 对锁机制的简单运用,可以分为加锁、解锁和设置过期时间这三个基本功能。

加锁

就像之前学的并发编程一样,既然一个锁想要实现互斥性,就必须通过加锁来解决。那么常用的命令就是 setnx(set if not exist)。这里的 key 一般是按业务来命名,比如商城系统的库存(inventory)。
如果返回1则说明 key 不存在加锁成功,返回0则说明 key 已经被别的客户端加锁成功且未解锁,表示加锁失败。

解锁

有加锁就必须有解锁,否则某个客户端长期占用锁那么系统就仿佛变成单线程了,达不到我们使用锁的目的。当获取锁的线程执行完任务后,可以使用 del 命令来释放锁。

超时释放

为了防止有的客户端忘记释放锁或者在执行任务时挂掉来不及释放锁造成不必要的等待和资源浪费,Redis 增添了超时释放机制来防止资源被一个客户端独占。同时超时释放也可以用于多个场景,比如购物超时未付款自动取消订单、优惠券过期等等。执行超时释放的命令是 expire。

实现分布锁

分布式锁,顾名思义,就是分布式项目开发中用到的锁,可以用来控制分布式系统之间同步访问共享资源。
思路是:在整个系统提供一个全局唯一的获取锁的中间件,然后每个系统在需要加锁时,都去问这个中间件拿到一把锁,这样不同的系统拿到的就可以认为是同一把锁。至于这个中间件,可以是Redis、 Zookeeper,也可以是数据库。
一般来说,分布式锁需要满足的特性有这么几点:
  1. 互斥性:在任何时刻,对于同一条数据,只有一台客户端可以获取到分布式锁;
  1. 防止锁超时:如果客户端没有主动释放锁,服务器会在一段时间之后自动释放锁,防止客户端宕机或 者网络不可达时产生死锁;
  1. 可重入性:类似 AQS 的思想,Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。

优缺点

选用 Redis 实现分布锁的优缺点如下:
优点:
Redis 锁实现简单,理解逻辑简单,性能好,可以支撑高并发的获取、释放锁操作。
缺点:
  1. Redis 容易单点故障,集群部署并不是强一致性的,锁的不够健壮
  1. key 的过期时间设置多少不明确,只能根据实际情况调整
  1. 需要自己不断去尝试获取锁,比较消耗性能

加锁和超时释放如何保证原子性

当我们使用加锁 + 超时释放来实现锁机制时,可能会在两者之间出现差错。那么如何保证这两个命令的原子性就变的很重要。这里 Redis 帮我们实现了,由于Redis 命令天然具有原子性,通过将两个命令合并就实现了原子性。
  • EX seconds : 将键的过期时间设置为 seconds 秒。
  • PX milliseconds : 将键的过期时间设置为 milliseconds 毫秒。
  • NX : 只在键不存在时, 才对键进行设置操作。
  • XX : 只在键已经存在时, 才对键进行设置操作。
也有简化版本的命令如下:
执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value
执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value

如何保证释放的是自己的锁

如果某个线程设置的 key 过期时间是10秒,而它的业务执行时间是15秒。当第10秒后锁过期,然后另一个线程前来获取锁,成功获取并设置 key 过期时间为10秒,而在第一个线程业务执行完成后释放锁释放的却是第二个线程的锁。这会导致错误解锁和超时并发两个问题。
解决方法:
在解锁时加一个判断来验证释放的是自己的锁,在设置 key 的时候将 value 设置为一个唯一值 ( 可以是当前机器时间、随机值、UUID、或者机器号+线程号的组合 )

验证和解锁如何保证原子性

假设现在是线程 A 在执行当前的动作。如果线程 A 取值之后,删除操作之前,key 正好过期了,那么锁就自动释放了。这时又被另外一个线程 B 获取了锁,那么在删除操作时,就会把线程 B 的锁给删除掉。这时候就要借助 Lua 脚本了,由于 Lua 脚本的原子性,在 Redis 执行该脚本的过程中,其他客户端的命令都需要等待该 Lua 脚本执行完才能执行。
带入原来的代码

超时释放时间过短导致并发

如果某个线程设置的 key 过期时间是10秒,而它的业务执行时间是15秒。当第10秒后锁过期,然后另一个线程前来获取锁,成功获取并执行任务。可以看到在第10到第15秒到过程中会有两个线程同时执行任务,这违背了分布式锁的互斥性。
解决方案:
  1. 确保 key 的过期时间要大于业务执行时间
  1. 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。
Redisson 就有第二种方式的实现。获取锁成功就会开启一个定时任务,定时任务会定期检查去续期,该定时调度每次调用距离锁过期的时间差是 internalLockLeaseTime / 3 也就10秒。默认情况下,加锁的时间是30秒.如果加锁的业务没有执行完,那么到 30 - 10 = 20 秒的时候,就会进行一次续期,把锁重置成30秒

等待锁释放

上述命令执行都是立即返回的,如果客户端可以等待锁释放就无法使用。应用场景比如:抢火车票排队
  1. 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率
  1. 使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息

Redlock

在集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,这个新的节点内部没有这个锁,所以当另一个客户端过来请求加锁时,立即就批准了。这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。Redlock 就是来解决上述问题,在使用 Redlock 之前对主节点申请锁成功后就可以向 client 返回获取分布式锁成功,但事实证明这样的锁并不安全,因为它没有保证主从节点的强一致性。它针对的是具有 N 个独立节点的集群,保证了分布式场景下的互斥性(永远只有一个客户端获得锁)、高可用性(只要半数以上节点存活就可以获得锁)和避免死锁,但是没有保证锁的正确性(太依赖机器系统时钟,可能会有人为修改)。
Redlock 是一种算法,Redlock 也就是 Redis Distributed Lock,可用实现多节点 Redis 的分布式锁。
Redlock 官方推荐的 Redisson 完成了对 Redlock 算法封装。
假设有集群有5个节点,这五个节点之间相互独立没有主从关系。加锁时,它向全部节点发送 set 指令,只要过半节点 set 成功,那就认为加锁成功。失败释放锁时,需要向所有节点发送 del 指令。Redlock 按以下步骤执行来获取锁:
  1. 获取当前时间戳
  1. client 尝试按照顺序使用相同的 key,value 获取所有 Redis 服务的锁,在获取锁的过程中的获取时间比锁过期时间短很多,这是为了不要过长时间等待已经关闭的 Redis 服务。并且试着获取下一个 Redis 实例。比如:TTL 为5秒,设置获取锁最多用1秒,所以如果一秒内无法获取锁,就放弃获取这个锁,从而尝试获取下个锁
  1. client 通过获取所有能获取的锁后的时间减去第一步的时间,当且仅当从半数以上的 Redis 节点获取到锁,并且当使用的时间小于锁失效时间时,锁才算获取成功
  1. 如果成功获取锁,则锁的真正有效时间是 TTL 减去第三步的时间差的时间;比如:TTL 是5s,获取所有锁用了2秒,则真正锁有效时间为3秒 ( 其实应该再减去时钟漂移 );
  1. 如果客户端由于某些原因获取锁失败,便会开始解锁所有 Redis 实例;因为可能已经获取了小于3个锁,必须释放,否则影响其他 client 获取锁
notion image
如果你了解 MySQL 主从机制的话,看到这里你会很熟悉,MySQL 主从节点是如何保证强一致性的呢?优先保证从库的 relay log 中继日志更新落盘的时间比主库成功刷脏返回给 client 事务已提交的时间早 。这里的 Redlock 其实也是这个思想
Loading...