多个线程执行事务时,我们可以设定一个统一的资源,只有拿到这个才有资格继续执行,这个资源就是锁。单体架构下,锁存在共享内存中,这样所有线程都可以访问到。分布式环境下,多机器之间没有共享内存,如何实现分布式锁?
分布式锁的首要目标就是找到所有机器都认可的资源,让不同机器上的线程都有机会争抢。
常见的方式利用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. 1age存在,则del age会删除age并返回1
  18. 2name存在,age不存在,贼del age放回0del name age返回1

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

基于Redis的简单分布式锁

  1. lock

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

  1. expire

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

  1. 执行操作
  2. unlock

del key,删除key,释放锁

  1. ifsetnxkey1 == 1){
  2. // 1
  3. expirekey30
  4. try {
  5. do something ......
  6. }catch(){
  7. }
  8. finally {
  9. // 2
  10. delkey
  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分布式锁三种实现方案