分布式锁概念
什么是锁?
在单进程的系统中, 当存在多个线程可以同时改变某个变量 (可变共享变量) 时, 就需要对变量或代码块做同步, 使其在修改这种变量时能够线性执行, 以防止并发修改变量带来不可控的结果.
同步的本质是通过锁来实现的. 为了实现多个线程在一个时刻同一个代码块只能有一个线程可执行, 那么需要在某个地方做个标记, 这个标记必须每个线程都能看到, 当标记不存在时可以设置该标记, 其余后续线程发现已经有标记了则等待拥有标记的线程结束同步代码块取消标记后再去尝试设置标记. 这个标记可以理解为锁.
不同地方实现锁的方式也不一样, 只要能满足所有线程都能看得到标记即可. 如 Java 中 synchronize 是在对象头设置标记, Lock 接口的实现类基本上都只是某一个 volitile 修饰的 int 型变量其保证每个线程都能拥有对该 int 的可见性和原子修改, Linux 内核中也是利用互斥量或信号量等内存数据做标记.
除了利用内存数据做锁其实任何互斥的都能做锁(只考虑互斥情况), 如流水表中流水号与时间结合做幂等校验可以看作是一个不会释放的锁, 或者使用某个文件是否存在作为锁等. 只需要满足在对标记进行修改能保证原子性和内存可见性即可.
什么是分布式锁?
分布式锁是控制分布式系统同步访问共享资源的一种方式.
分布式锁应该有什么特性?
1, 在分布式系统环境下, 一个方法在同一时间只能被一个机器的一个线程执行; 2, 高可用的获取锁与释放锁; 3, 高性能的获取锁与释放锁; 4, 具备可重入特性; 5, 具备锁失效机制, 防止死锁; 6, 具备非阻塞锁特性, 即没有获取到锁将直接返回获取锁失败.
分布式锁的几种实现方式
目前分布式锁的实现方式主要采用以下三种:
基于数据库实现分布式锁
基于缓存 (Redis 等) 实现分布式锁
基于 Zookeeper 实现分布式锁
尽管有这三种方案, 但是不同的业务也要根据自己的情况进行选型, 他们之间没有最好只有更适合!
基于数据库实现分布式锁:
基于数据库的实现方式的核心思想是: 在数据库中创建一个表, 表中包含方法名等字段, 并在方法名字段上创建唯一索引, 想要执行某个方法, 就使用这个方法名向表中插入数据, 成功插入则获取锁, 执行完成后删除对应的行数据释放锁.
1. 创建一个表:
- >DROP TABLE IF EXISTS `method_lock`;
- CREATE TABLE `method_lock` (
- `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
- `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
- `desc` varchar(255) NOT NULL COMMENT '备注信息',
- `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,PRIMARY KEY (`id`),
- UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
- ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法'
2. 如果要执行某个方法, 则使用这个方法名向数据库总插入数据:
>INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的 methodName');
因为我们对 method_name 做了唯一性约束, 这里如果有多个请求同时提交到数据库的话, 数据库会保证只有一个操作可以成功, 那么我们就可以认为操作成功的那个线程获得了该方法的锁, 可以执行方法体内容.
3. 成功插入则获取锁, 执行完成后删除对应的行数据释放锁:
>delete from method_lock where method_name ='methodName';</pre>
注意: 这只是使用基于数据库的一种方法, 使用数据库实现分布式锁还有很多其他的玩法!
使用基于数据库的这种实现方式很简单, 但是对于分布式锁应该具备的条件来说, 它有一些问题需要解决及优化:
1, 因为是基于数据库实现的, 数据库的可用性和性能将直接影响分布式锁的可用性及性能, 所以, 数据库需要双机部署, 数据同步, 主备切换;
2, 不具备可重入的特性, 因为同一个线程在释放锁之前, 行数据一直存在, 无法再次成功插入数据, 所以, 需要在表中新增一列, 用于记录当前获取到锁的机器和线程信息, 在再次获取锁的时候, 先查询表中机器和线程信息是否和当前机器和线程相同, 若相同则直接获取锁;
3, 没有锁失效机制, 因为有可能出现成功插入数据后, 服务器宕机了, 对应的数据没有被删除, 当服务恢复后一直获取不到锁, 所以, 需要在表中新增一列, 用于记录失效时间, 并且需要有定时任务清除这些失效的数据;
4, 不具备阻塞锁特性, 获取不到锁直接返回失败, 所以需要优化获取逻辑, 循环多次去获取.
5, 在实施的过程中会遇到各种不同的问题, 为了解决这些问题, 实现方式将会越来越复杂; 依赖数据库需要一定的资源开销, 性能问题需要考虑.
基于 Redis 实现分布式锁:
1, 选用 Redis 实现分布式锁原因:
(1)Redis 有很高的性能; (2)Redis 命令对此支持较好, 实现起来比较方便
2, 实现思想:
(1)获取锁的时候, 使用 setnx 加锁, 并使用 expire 命令为锁添加一个超时时间, 超过该时间则自动释放锁, 锁的 value 值为一个随机生成的 UUID, 通过此在释放锁的时候进行判断.
(2)获取锁的时候还设置一个获取的超时时间, 若超过这个时间则放弃获取锁.
(3)释放锁的时候, 通过 UUID 判断是不是该锁, 若是该锁, 则执行 delete 进行锁释放.
3, 使用命令介绍:
- SETNX:
- SETNX key val:# 当且仅当 key 不存在时, set 一个 key 为 val 的字符串, 返回 1; 若 key 存在, 则什么都不做, 返回 0.</pre>
- EXPIRE:
- expire key timeout:# 为 key 设置一个超时时间, 单位为 second, 超过这个时间锁会自动释放, 避免死锁.</pre>
- DELETE:
- delete key:# 删除 key</pre>
如果在 setnx 和 expire 之间服务器进程突然挂掉了, 可能是因为机器掉电或者是被人为杀掉的, 就会导致 expire 得不到执行, 也会造成死锁. 所以可以使用以下指令使得 setnx 和 expire 在同一条指令中执行:
>set lock:codehole value ex 5 nx</pre>
4, 实现代码:
- // 可重入锁
- public class RedisWithReentrantLock {
- private ThreadLocal<Map<String, Integer>> lockers = new ThreadLocal<>();
- private Jedis jedis;
- public RedisWithReentrantLock(Jedis jedis) {
- this.jedis = jedis;
- }
- private boolean _lock(String key) {
- return jedis.set(key, "","nx","ex", 5L) != null;
- }
- private void _unlock(String key) {
- jedis.del(key);
- }
- private Map<String, Integer> currentLockers() {
- Map<String, Integer> refs = lockers.get();
- if (refs != null) {
- return refs;
- }
- lockers.set(new HashMap<>());
- return lockers.get();
- }
- public boolean lock(String key) {
- Map<String, Integer> refs = currentLockers();
- Integer refCnt = refs.get(key);
- if (refCnt != null) {
- refs.put(key, refCnt + 1);
- return true;
- }
- boolean ok = this._lock(key);
- if (!ok) {
- return false;
- }
- refs.put(key, 1);
- return true;
- }
- public boolean unlock(String key) {
- Map<String, Integer> refs = currentLockers();
- Integer refCnt = refs.get(key);
- if (refCnt == null) {
- return false;
- }
- refCnt -= 1;
- if (refCnt> 0) {
- refs.put(key, refCnt);
- } else {
- refs.remove(key);
- this._unlock(key);
- }
- return true;
- }
- public static void main(String[] args) {
- Jedis jedis = new Jedis();
- RedisWithReentrantLock Redis = new RedisWithReentrantLock(jedis);
- System.out.println(Redis.lock("codehole"));
- System.out.println(Redis.lock("codehole"));
- System.out.println(Redis.unlock("codehole"));
- System.out.println(Redis.unlock("codehole"));
- }
- }
- /**
- * 分布式锁的简单实现代码
- * Created by liuyang on 2017/4/20.
- */
- public class DistributedLock {
- private final JedisPool jedisPool;
- public DistributedLock(JedisPool jedisPool) {
- this.jedisPool = jedisPool;
- }
- /**
- * 加锁
- * @param lockName 锁的 key
- * @param acquireTimeout 获取超时时间
- * @param timeout 锁的超时时间
- * @return 锁标识
- */
- public String lockWithTimeout(String lockName, long acquireTimeout, long timeout) {
- Jedis conn = null;
- String retIdentifier = null;
- try {
- // 获取连接
- conn = jedisPool.getResource();
- // 随机生成一个 value
- String identifier = UUID.randomUUID().toString();
- // 锁名, 即 key 值
- String lockKey = "lock:" + lockName;
- // 超时时间, 上锁后超过此时间则自动释放锁
- int lockExpire = (int) (timeout / 1000);
- // 获取锁的超时时间, 超过这个时间则放弃获取锁
- long end = System.currentTimeMillis() + acquireTimeout;
- while (System.currentTimeMillis() <end) {
- if (jedis.set(key, "","nx","ex", 5L) != null) {
- retIdentifier = identifier;
- return retIdentifier;
- }
- try {
- Thread.sleep(10);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
- }
- } catch (JedisException e) {
- e.printStackTrace();
- } finally {
- if (conn != null) {
- conn.close();
- }
- }
- return retIdentifier;
- }
- /**
- * 释放锁
- * @param lockName 锁的 key
- * @param identifier 释放锁的标识
- * @return
- */
- public boolean releaseLock(String lockName, String identifier) {
- Jedis conn = null;
- String lockKey = "lock:" + lockName;
- boolean retFlag = false;
- try {
- conn = jedisPool.getResource();
- while (true) {
- // 监视 lock, 准备开始事务
- conn.watch(lockKey);
- // 通过前面返回的 value 值判断是不是该锁, 若是该锁, 则删除, 释放锁
- if (identifier.equals(conn.get(lockKey))) {
- Transaction transaction = conn.multi();
- transaction.del(lockKey);
- List<Object> results = transaction.exec();
- if (results == null) {
- continue;
- }
- retFlag = true;
- }
- conn.unwatch();
- break;
- }
- } catch (JedisException e) {
- e.printStackTrace();
- } finally {
- if (conn != null) {
- conn.close();
- }
- }
- return retFlag;
- }
- }
使用这种方式实现分布式锁在集群模式下会有一定的问题, 比如在 Sentinel 集群中, 主节点挂掉时, 从节点会取而代之, 客户端上却并没有明显感知. 原先第一个客户端在主节点中申请成功了一把锁, 但是这把锁还没有来得及同步到从节点, 主节点突然挂掉了. 然后从节点变成了主节点, 这个新的节点内部没有这个锁, 所以当另一个客户端过来请求加锁时, 立即就批准了. 这样就会导致系统中同样一把锁被两个客户端同时持有, 不安全性由此产生.
为了解决这个问题, Antirez 发明了 Redlock 算法, 加锁时, 它会向过半节点发送 set(key, value, nx=True, ex=xxx) 指令, 只要过半节点 set 成功, 那就认为加锁成功. 释放锁时, 需要向所有节点发送 del 指令. 不过 Redlock 算法还需要考虑出错重试, 时钟漂移等很多细节问题, 同时因为 Redlock 需要向多个节点进行读写, 意味着相比单实例 Redis 性能会下降一些.
基于 zookeeper 实现的分布式锁:
在使用 zookeeper 实现分布式锁的之前, 需要先了解 zookeeper 的两个特性, 第一个是 zookeeper 的节点类型, 第二就是 zookeeper 的 watch 机制:
zookeeper 的节点类型:
PERSISTENT 持久化节点
PERSISTENT_SEQUENTIAL 顺序自动编号持久化节点, 这种节点会根据当前已存在的节点数自动加 1
EPHEMERAL 临时节点, 客户端 session 超时这类节点就会被自动删除
EPHEMERAL_SEQUENTIAL 临时自动编号节点
zookeeper 的 watch 机制:
Znode 发生变化 (Znode 本身的增加, 删除, 修改, 以及子 Znode 的变化) 可以通过 Watch 机制通知到客户端. 那么要实现 Watch, 就必须实现 org.apache.zookeeper.Watcher 接口, 并且将实现类的对象传入到可以 Watch 的方法中. Zookeeper 中所有读操作 (getData(),getChildren(),exists()) 都可以设置 Watch 选项. Watch 事件具有 one-time trigger(一次性触发)的特性, 如果 Watch 监视的 Znode 有变化, 那么就会通知设置该 Watch 的客户端.
zookeeper 实现排他锁:
定义锁:
在通常的 java 并发编程中, 有两种常见的方式可以用来定义锁, 分别是 synchronized 机制和 JDK5 提供的 ReetrantLock. 然而, 在 zookeeper 中, 没有类似于这样的 API 可以直接使用, 而是通过 Zookeeper 上的数据节点来表示一个锁, 例如 / exclusive_lock/lock 节点就可以定义为一个锁.
获取锁:
在需要获取排他锁的时候, 所有的客户端都会试图通过调用 create()接口, 在 / exclusive_lock 节点下创建临时子节点 / exclusive_lock/lock.zookeeper 会保证在所有客户端中, 最终只有一个客户端能够创建成功, 那么就可以认为该客户端获得了锁. 同时, 所有没有获得锁的客户端就需要到 / exclusive_lock 节点上注册一个子节点变更的 Watcher 监听, 以便实时监听到 lock 节点的变更情况.
释放锁:
在定义锁部分, 我们已经提到,/exclusive_lock/lock 是一个临时节点, 因此在以下两种情况下, 都有可能释放锁.
1. 当前获取锁的客户端发生宕机, 那么 Zookeeper 上的这个临时节点就会被移除.
2. 正常执行完业务逻辑之后, 客户端就会主动将自己创建的临时节点删除
无论在什么情况下移除了 lock 节点, Zookeeper 都会通知所有在 / exclusive_lock 节点上注册了子节点变更 Watcher 监听的客户端. 这些客户端在接收到通知后, 再次重新发起分布式锁获取, 即重复 "获取锁" 的过程:
image.PNG
zookeeper 实现共享锁:
定义锁:
和排他锁一样, 同样是通过 zookeeper 上的数据节点来表示一个锁, 是一个类似于 "/shared_lock/[hostname]- 请求类型 - 序号" 的临时顺序节点, 例如 / shared_lock/192.168.0.1-R-000000001, 那么这个节点就代表了一个共享锁.
获取锁:
1. 客户端调用 create()方法创建一个类似于 "/shared_lock/[hostname]- 请求类型 - 序号" 的临时顺序节点.
2. 客户端调用 getChildren()接口来获取所有已经创建的子节点列表.
3. 确定自己的节点序号在所有子节点中的顺序
对于读请求:
如果没有比自己序号小的子节点, 或是所有比自己序号小的 子节点都是读请求, 那么表明已经成功获取到了共享锁, 同时开始执行读取逻辑.
如果比自己序号小的子节点中有写请求, 那么就需要进入等待. 向比自己序号小的最后一个写请求节点注册 Watcher 监听.
对于写请求:
如果自己不是序号最小的节点, 那么就需要进入等待. 向比自己序号小的最后一个节点注册 Watcher 监听
4. 等待 Watcher 通知, 继续进入步骤 2
释放锁:
在定义锁部分, 我们已经提到,/exclusive_lock/lock 是一个临时节点, 因此在以下两种情况下, 都有可能释放锁.
1. 当前获取锁的客户端发生宕机, 那么 Zookeeper 上的这个临时节点就会被移除.
2. 正常执行完业务逻辑之后, 客户端就会主动将自己创建的临时节点删除
image.PNG
常用的分布式锁组件:
mykit-lock
mykit 架构中独立出来的 mykit-lock 组件, 旨在提供高并发架构下分布式系统的分布式锁架构.
GitHub 地址: https://github.com/sunshinelyz/mykit-lock
参考资料:
从 PAXOS 到 ZOOKEEPER 分布式一致性原理和实践
掘金小册: Redis 深度探险: 核心原理与应用实践
来源: http://www.jianshu.com/p/492ae7bb7f68