:::info 在Java中,关于锁我想大家都很熟悉。在并发编程中,我们通过锁,来避免由于竞争而造成的数据不一致问题。通常,我们以synchronized 、Lock来使用它。
但是Java中的锁,只能保证在同一个JVM进程内中执行。如果在分布式集群环境下呢? :::

分布式锁

顾名思义,就是在分布式环境下的锁。
分布式锁是控制分布式系统之间同步访问共享资源的一种方式。
在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,这个时候,便需要使用到分布式锁。

分布式理论基础 - CAP理论

其实本质就是在特定的场景下,将并行的场景,变成串行,就是分布式锁的奥义所在。

使用场景

一般情况下,我们使用分布式锁主要有两个场景:

  1. 避免不同节点重复相同的工作:比如用户执行了某个操作有可能不同节点会发送多封邮件;
  2. 避免破坏数据的正确性:如果两个节点在同一条数据上同时进行操作,可能会造成数据错误或不一致的情况出现;

    特性

    首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  3. 互斥性。在任意时刻,只有一个客户端能持有锁。

  4. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  5. 具有容错性。只要大部分的节点正常运行,客户端就可以加锁和解锁。
  6. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

    常见几种实现方式

  • 基于数据库实现的分布式锁
  • 基于Redis实现的分布式锁
  • 基于ZooKeeper实现的分布式锁
  • 基于Etcd实现的分布式锁

本篇笔记将详细介绍如何正确地实现Redis分布式锁。

原理

redis中有一条指令非常有意思,它叫做 setnx
它的语法是这样的。

  1. setnx key value
  1. 127.0.0.1:6379> setnx lock1 1
  2. (integer) 1
  3. 127.0.0.1:6379> setnx lock1 2
  4. (integer) 0
  5. 127.0.0.1:6379> get lock1
  6. "1"
  7. 127.0.0.1:6379> del lock1
  8. (integer) 1
  9. 127.0.0.1:6379> setnx lock1 2
  10. (integer) 1
  11. 127.0.0.1:6379> get lock1
  12. "2"

当redis中不存在key值为“lock”的时候,可以设置成功;当存在key值时,设置失败。
我们的锁过程可以这样来操作:

  • setnx lock 锁值
  • 处理业务逻辑
  • 释放锁 del lock

这个就是redis做分布式锁的最基本的原理。

优化一

当然只是单纯的使用setnx命令去做,会有很多问题,此时我们一一优化。

如果setnx lock 1 加锁成功,这个时候系统因为其他原因,挂掉了,就永远无法执行del lock了。
要避免这种情况,怎么办呢?给锁一个过期时间。

  1. 127.0.0.1:6379> setnx lock1 2
  2. (integer) 1
  3. 127.0.0.1:6379> EXPIRE lock1 10
  4. (integer) 1
  5. 127.0.0.1:6379> ttl lock1
  6. (integer) 8

这样无论系统是否宕机,都会在10秒后释放锁。看似很美好,虽然setnx lock 1 与 expire lock 10之间的时间间隙非常小,但仍然有风险,加入系统执行完 setnx lock 1 后,宕机了,并没有执行 过期指令 expire lock 10,再次产生了一把无法解开的锁,“死锁”。
这时候引入了一个概念,叫做原子操作。即这两条指令需要在一个原子操作内执行完成。

  1. set key value [expiration EX seconds|PX milliseconds] [NX|XX]
  2. EX seconds:设置失效时长,单位秒
  3. PX milliseconds:设置失效时长,单位毫秒
  4. NXkey不存在时设置value,成功返回OK,失败返回(nil)
  5. XXkey存在时设置value,成功返回OK,失败返回(nil)
  1. 127.0.0.1:6379> set lock1 1 ex 10 nx
  2. OK
  3. 127.0.0.1:6379> ttl lock1
  4. (integer) 8
  5. 127.0.0.1:6379> get lock1
  6. "1"

:::info Redissetnx命令是当key不存在时设置key,但setnx不能同时完成expire设置失效时长,不能保证setnxexpire的原子性。我们可以使用set命令完成setnxexpire的操作,并且这种操作是原子操作。 :::

优化二

当然有,试想一下,之前代码set lock 1 ex 10 nx,设置过期时间是10秒,那么这个10秒是否可靠呢?显然不可靠。
我们加锁的过程是 加锁—-执行业务代码—-释放锁
加入业务代码的执行时间超过10秒呢?是不是业务代码还没有执行完,锁就已经释放了。放在购票场景中,第一位旅客还没有完成购票,第二位旅客就开始购票。显然不合理。怎么办呢?
这里我们需要估计业务代码的执行时间,加入预估出来的时间是10秒,可以在业务代码中开辟一个“续命”的操作。

  • 加锁 set lock 1 ex 10 nx
    • 每过3秒,向持有锁的线程确认,然后把该锁的时间重新设置为 10秒
  • 执行业务代码
  • 释放锁 del lock

这里的续命时间间隔 = 过期时间 10S / 3
这样设置比较合理,可以防止一次续命失败。

优化三

我们一直在用 set lock 1 ex 10 nx 来加锁,用del lock 来释放锁。
我们需要明确知道,释放的锁,是自己加上的。
可以set lock uuid ex 10 nx 然后在释放的锁的时候再去校验uuid是不是自己刚才的,来解决该问题。

当然在我们实际生产过程中,不是去直接使用命令去操作redis,我们通过java客户端在代码里面去操作实现,这里推荐使用redisson这个客户端,而且现在redisson也提供了redisson-spring-boot-starter 他里面把spring-boot-starter-data-redisredisson集成在了一起,非常的方便。