多个线程执行事务时,我们可以设定一个统一的资源,只有拿到这个才有资格继续执行,这个资源就是锁。单体架构下,锁存在共享内存中,这样所有线程都可以访问到。分布式环境下,多机器之间没有共享内存,如何实现分布式锁?
分布式锁的首要目标就是找到所有机器都认可的资源,让不同机器上的线程都有机会争抢。
常见的方式利用MySQL数据库、Redis、ZooKeeper

分布式应当具备以下条件

  • 异常或者超时自动删除,避免死锁
  • 互斥性,只有一个客户端能够持有锁
  • 分布式环境下高性能、高可用、容错机制

这三点在redis官网讲述分布式锁也有类似表达
redis.cn Redis分布式锁

按照我们的思路和设计方案,算法只需具备3个特性就可以实现一个最低保障的分布式锁。

  1. 安全属性(Safety property): 独享(相互排斥)。在任意一个时刻,只有一个客户端持有锁。
  2. 活性A(Liveness property A): 无死锁。即便持有锁的客户端崩溃(crashed)或者网络被分裂(gets partitioned),锁仍然可以被获取。
  3. 活性B(Liveness property B): 容错。 只要大部分Redis节点都活着,客户端就可以获取和释放锁.

Redis实现分布式锁

主要利用的是redis的几个命令

  1. setnx
  2. set if not exist
  3. set key value
  4. 原子性操作,当且仅当 key 不存在,将 key 的值设为 value ,并返回1;
  5. 若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0
  6. expire
  7. expire key time
  8. 设置过期时间
  9. get
  10. 查询key
  11. set
  12. set key value
  13. 修改key值为value,如果key存在,则将key对应的vlaue值修改为value放回ok;如果不存在,则添加key,值为value
  14. del
  15. del key [key...]
  16. 删除key,支持一次删除多个key。返回删除成功的个数。
  17. 例1,age存在,则del age会删除age并返回1;
  18. 例2,name存在,age不存在,贼del age放回0;del name age返回1

基于以上底层逻辑,可以设计一个简单的分布式锁

基于Redis的简单分布式锁

  1. lock

setnx操作,上锁;如果返回1,表示上锁成功;否则,失败;

  1. expire

设置超时时间,防止过长时间持有锁,执行事务过程中宕机等问题导致死锁

  1. 执行操作
  2. unlock

del key,删除key,释放锁

  1. if(setnx(key,1) == 1){
  2. // 1
  3. expire(key,30
  4. try {
  5. do something ......
  6. }catch(){
  7. }
  8. finally {
  9. // 2
  10. del(key)
  11. }
  12. }

但是此方案有重大隐患:

  • 原子操作问题
  • 过期时间
  • 是否正确解锁

    原子操作问题

  1. 由于setnx 和 expire两个操作不具备原子性,极端情况下,setnx执行了,在执行expire前宕机(代码1处),将产生死锁

解决方式:
redis2.6后,改进了set命令

set key value [EX seconds][PX milliseconds][NX|XX]
EX seconds:设置失效时长,单位秒
PX milliseconds:设置失效时长,单位毫秒
NX:key不存在时设置value,成功返回oK,失败返回(ni1) 如果key存在,参数加nx,将失败
XX:key存在时设置va1ue,成功返回oK,失败返回(ni1)


127.0.0.1:6379> set num 3 EX 100 nx // 如果num不存在,就新增值并设置值3,100s后过期
OK
127.0.0.1:6379> ttl num      // 查看过期时间
(integer) 83
127.0.0.1:6379> get num
"3"
127.0.0.1:6379> set num 4 nx // 如果num不存在,就设置num值为4
(nil)                                                 // 失败
127.0.0.1:6379> set num 4 xx 如果num存在,就设置num值为4
OK                                                     // 成功
127.0.0.1:6379> get num
"4"
127.0.0.1:6379> ttl num  // 由于16行的语句,没有设置过期时间,num,就一直有效
(integer) -1

过期时间与是否正确解锁

  1. 由于设置了过期时间,可能出现,线程A执行还没结束,锁就自动过期了,之后线程B拿到锁,接着线程A执行结束释放锁,此时释放的就是线程B的锁

解决方式:
取线程的唯一标识与锁绑定,解锁时需要解锁线程与锁对应才可解锁

加锁时将key对应的value值设为线程id,释放锁时判断此id是否为当前线程id

加锁:
String threadId = Thread.currentThread().getId()
    set(key,threadId ,30,NX)
    doSomething.....

    解锁:
    if(threadId .equals(redisClient.get(key))){
    del(key)
    }

// 解锁时,判断和删除也不是原子操作,可以引入lua脚本,将两个操作放入一个lua脚本中,实现原子性
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
    else
        return 0
end

问:如果储存锁的redis实例挂掉了怎么办?
使用多台机器存储锁数据,采用RedLock(红锁)算法。
注意,这里说的多台机器不是指集群,这些机器彼此毫无关联,使用「大多数」机制,超过半数的机器确认获取到锁,才是真正的获取到锁。

设置过期时间的Redis分布式锁

Redisson实现的分布式锁

github.com/redisson/redisson/wiki/8.分布式锁和同步器
redis 官方关于分布式锁的讲解(redlock)

MySQL

ZooKeeper实现分布式锁

参考

Java分布式锁三种实现方案