Java 分布式架构(2)分布式锁

527 阅读6分钟

一、使用场景

  1. 系统是一个分布式系统,或者集群系统,Java的锁已经锁不住了。
  2. 操作共享资源,比如库里唯一的用户数据。
  3. 同步访问,即多个线程同时操作共享资源(文件、网络)。

二、解决方案

(1)基于Redis

介绍

基于Redis的分布式锁,指的是基于Redis做的扩展开发。主要实现是使用Redis命令:setnx key value ex 10sRedisson 以及 watch dog(看门狗,防止程序没执行完释放Redis锁)。

命令 SETNX 是【set if not exists(如果不存在,则set)】的缩写,其格式为:setnx key value 将 key 的值设置为 value,当且仅当 key 不存在。若给定的 key 已存在,则 setnx 不做任何操作,操作失败。

  • 加锁:set key value nx ex 10s
  • 释放锁:delete key

产生死锁的情况及解决方法

  1. 加锁后没有释放锁:需要释放锁的操作,如 delete key
  2. 加锁后程序还没有执行释放锁,程序挂了:需要用到 redis key 的过期机制。

具体使用方式

假设有两个服务A、B都希望获得锁,执行过程大致如下:

  1. 服务A为了获得锁,向Redis发起了如下命令 SET productId:lock 0xx9p03001 NX EX 30000,其中的 productId 由自己定义,可以是与本次业务有关的id,value是一串随机值,必须保证全局唯一, NX 指的是当且仅当key在Redis中不存在时,返回执行成功,否则执行失败。EX 30000 指的是在30秒后,key将被自动删除。执行命令后返回成功,表明服务成功的获得了锁。

  2. 服务B为了获得锁,想Redis发起同样的命令 SET productId:lock 0000111 NX EX 30000,由于Redis内存已经存在同名key,且未过期,因此命令执行失败,服务B未能获得锁。服务B进入循环等待状态,比如每隔1秒(自行设置)向Redis发起请求,直到执行成功并获得锁。

  3. 服务A的业务代码执行时长超过了30秒,导致key超时,因此Redis自动删除了key,此时服务B再次发送命令执行成功,假设本次请求中的value值为0000111。此时需要服务A对key进行续期,使用watch dog。

  4. 服务A执行完毕,为了释放锁,服务A会主动向Redis发起删除key的请求。注意,在删除key之前一定要判断服务A持有的value与Redis内存储的value是否一致。比如当前场景下,Redis的锁早就不是服务A持有的那一把了,而是由服务2创建,如果冒然使用服务A持有的key删除锁,则会误将服务B的锁释放掉。此外由于删除锁时涉及到一系列判断逻辑,因此一般使用lua脚本,具体如下:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

(2)基于Zookeeper

基于Zookeeper。根据顺序节点、临时节点特性。

介绍

  • 顺序节点特性: 使用 Zookeeper 的顺序节点特性,假如我们在 /lock/ 目录下创建3个节点, ZK 集群会按照发起创建的顺序来创建节点,节点分别为 /lock/0000000001、/lock/0000000002、/lock/0000000003,最后一位是递增的,节点名由 ZK 来完成。

  • 临时节点特性: 临时节点由某个客户端创建,当客户端与 ZK 集群断开连接,则该节点自动被删除。

具体实现

  1. 客户端A调用 create() 方法创建名为 /业务ID/lock-的临时顺序节点。
  2. 客户端A调用 getChildren("业务ID") 方法来获取所有已经创建的子节点。
  3. 客户端获取到所有子节点 path 之后,如果发现自己在步骤1中创建的节点是使所有节点中序号最小的,就是看自己创建的序列号是否排第一,如果排第一则认为这个客户端获得了锁,在他前面没有其他的客户端拿到锁。
  4. 如果创建的节点不是所有节点中序号最小的,那么则监视比自己创建的节点序列号小的所有节点中需要最大的一个,进入等待。直到下次监视的子节点变更的时候,在进行节点的获取,判断是否获取锁。

(3)Redis和Zookeeper的区别

  • Redis:
  1. Redis只保证最终一致性,副本间的数据复制是异步进行(Set是写,Get是读,Redis集群一般是读写分离架构,存在主从同步延迟情况),主从切换之后可能有部分数据没有复制过去【丢失锁】情况,所以强调一致性要求的业务不推荐使用Redis,推荐使用ZK。
  2. Redis集群各方法的响应时间均为最低。随着并发量和业务数量的提升,其响应速度会有明显上升(公网集群影响因素偏大),但是极限QPS(吞吐量)可以达到最大且基本无异常。
  • Zookeeper:
  1. 使用Zookeeper集群,锁原理是使用Zookeeper的临时顺序节点,临时顺序节点的生命周期在客户端与集群的会话结束时结束。因此如果某个客户端节点存在网络问题,与Zookeeper断开连接,会话超时同样会导致锁被错误的释放(导致被其他线程错误地持有),因此Zookeeper也无法保证完全一致。
  2. ZK具有较好的稳定性:响应时间抖动很小,没有出现异常。但是随着并发量和业务数量的提升,其响应时间和qps会明显下降。主要是因为为了保证强一致性,Zookeeper同步节点是需要时间的。
  • 总结:
  1. Zookeeper每次进行锁操作前都要创建若干节点,完成后要释放节点,会浪费很多时间;
  2. Redis只是简单的数据操作,没有上述问题。

(3)基于数据库

基于数据库,比如MySQL。主键或者唯一索引的唯一性。

在MySQL中创建一张表,设置一个主键或者 UNIQUE KEY 这个key就是要锁的key,所以同一个key在mysql表中只能插入一次,这样对锁的就交给了数据库,处理同一个key数据库保证了只能有一个节点插入成功,其他节点都会插入失败。

DB分布式锁的实现:通过主键id 或者 唯一索引 的唯一性进行加锁,说白了就是加锁的形式是向一张表中插入一条数据,该条数据的id就是一把分布式锁,例如当一次请求插入了一条id为1的数据,其他想要进行插入数据的请求必须要等第一次请求执行完之后删除这条id为1的数据才能继续插入,实现了分布式锁的功能。

这样 lock 和 unlock 的思路就很简单,伪代码如下:

def lock:
    exec sql: insert into table (`order_id`) values (xxx)
    if result == true:
        return true
    else:
        retrun false
        
def unlock:
    exec sql: delete from table where `order_id` = 'xxx'