有一个公共厕所只有一个坑位,为了控制正在使用的人不被使用,旁边设置了个门房,并给厕所上了密码锁,只有拿到单次使用密码的人才可以使用。
如果多人(多台机器,多个线程等)操作一个单元,那么结果就可能不是谁都愿意看到的。这是同步问题,放在分布式中也一样,解决方案就是:有一个尽可能小的单线程中间变量作为是否可以该变量的标记。
谁拿到这把钥匙,谁就可以开启箱子。
原理或步骤:
问题:
延迟消息+添加使用者字段:
特点:
仅限于有事务数据库引擎,如果Mysql的Innodb。
利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add成功,也就意味着线程得到了锁。
类似Mysql,超时机制需要自己设置
setnx命令:如果有key有数据,就不设置,返回0;没有数据就赋值,返回1。单线程
超时机制:expire命令,设置超时命令
问题:
解决:
合并命令 jedis版本:2.9.0
jedis.set(lockKey, value(requestId), “NX”, “PX”, expireTime)
value:设置当前请求的requestID,用作解锁的依据
expireTime 过期时间
缺点:可重入 实现困难
解锁:也需要合并命令
| // 判断加锁与解锁是不是同一个客户端 if (requestId.equals(jedis.get(lockKey))) { // 若在此时,过期自动删除,新客户端已获取锁,则会误解锁 jedis.del(lockKey); } |
解决:使用lua脚本,合并命令
完整代码&逻辑:
- 逻辑:
- 下面我们假设锁的key为“ lock ”,hashKey是当前线程的id:“ threadId ”,锁自动释放时间假设为20
- 获取锁的步骤:
- 1、判断lock是否存在 EXISTS lock
- 2、不存在,则自己获取锁,记录重入层数为1.
- 2、存在,说明有人获取锁了,下面判断是不是自己的锁,即判断当前线程id作为hashKey是否存在:HEXISTS lock threadId
- 3、不存在,说明锁已经有了,且不是自己获取的,锁获取失败.
- 3、存在,说明是自己获取的锁,重入次数+1: HINCRBY lock threadId 1 ,最后更新锁自动释放时间, EXPIRE lock 20
-
- 释放锁的步骤:
- 1、判断当前线程id作为hashKey是否存在: HEXISTS lock threadId
- 2、不存在,说明锁已经失效,不用管了
- 2、存在,说明锁还在,重入次数减1: HINCRBY lock threadId -1 ,
- 3、获取新的重入次数,判断重入次数是否为0,为0说明锁全部释放,删除key: DEL lock
- 获取锁的脚本(注释删掉,不然运行报错)
- local key = KEYS[1]; -- 第1个参数,锁的key
- local threadId = ARGV[1]; -- 第2个参数,线程唯一标识
- local releaseTime = ARGV[2]; -- 第3个参数,锁的自动释放时间
-
- if(redis.call('exists', key) == 0) then -- 判断锁是否已存在
- redis.call('hset', key, threadId, '1'); -- 不存在, 则获取锁
- redis.call('expire', key, releaseTime); -- 设置有效期
- return 1; -- 返回结果
- end;
-
- if(redis.call('hexists', key, threadId) == 1) then -- 锁已经存在,判断threadId是否是自己
- redis.call('hincrby', key, threadId, '1'); -- 如果是自己,则重入次数+1
- redis.call('expire', key, releaseTime); -- 设置有效期
- return 1; -- 返回结果
- end;
- return 0; -- 代码走到这里,说明获取锁的不是自己,获取锁失败
- 释放锁的脚本(注释删掉,不然运行报错)
- local key = KEYS[1]; -- 第1个参数,锁的key
- local threadId = ARGV[1]; -- 第2个参数,线程唯一标识
-
- if (redis.call('HEXISTS', key, threadId) == 0) then -- 判断当前锁是否还是被自己持有
- return nil; -- 如果已经不是自己,则直接返回
- end;
- local count = redis.call('HINCRBY', key, threadId, -1); -- 是自己的锁,则重入次数-1
-
- if (count == 0) then -- 判断是否重入次数是否已经为0
- redis.call('DEL', key); -- 等于0说明可以释放锁,直接删除
- return nil;
- end;
-
- 完整代码
- import java.util.Collections;
- import java.util.UUID;
- import org.springframework.core.io.ClassPathResource;
- import org.springframework.data.redis.core.StringRedisTemplate;
- import org.springframework.data.redis.core.script.DefaultRedisScript;
- import org.springframework.scripting.support.ResourceScriptSource;
-
- /**
- * Redis可重入锁
- */
- public class RedisLock {
-
- private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
- private static final DefaultRedisScript
LOCK_SCRIPT; - private static final DefaultRedisScript
- static {
- // 加载释放锁的脚本
- LOCK_SCRIPT = new DefaultRedisScript<>();
- LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock.lua")));
- LOCK_SCRIPT.setResultType(Long.class);
-
- // 加载释放锁的脚本
- UNLOCK_SCRIPT = new DefaultRedisScript<>();
- UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlock.lua")));
- }
- /**
- * 获取锁
- * @param lockName 锁名称
- * @param releaseTime 超时时间(单位:秒)
- * @return key 解锁标识
- */
- public static String tryLock(String lockName,String releaseTime) {
- // 存入的线程信息的前缀,防止与其它JVM中线程信息冲突
- String key = UUID.randomUUID().toString();
-
- // 执行脚本
- Long result = redisTemplate.execute(
- LOCK_SCRIPT,
- Collections.singletonList(lockName),
- key + Thread.currentThread().getId(), releaseTime);
-
- // 判断结果
- if(result != null && result.intValue() == 1) {
- return key;
- }else {
- return null;
- }
- }
- /**
- * 释放锁
- * @param lockName 锁名称
- * @param key 解锁标识
- */
- public static void unlock(String lockName,String key) {
- // 执行脚本
- redisTemplate.execute(
- UNLOCK_SCRIPT,
- Collections.singletonList(lockName),
- key + Thread.currentThread().getId(), null);
- }
- }
基础概念:
大致思想即为:
每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
重点问题属性:
锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。
使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。
其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)
[ˈtʃʌbi] adj.胖乎乎的;圆胖的;丰满的
参考:Google Chubby(中文版)_左罗CTO的技术博客_51CTO博客
上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)
缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库
共享锁(S锁):
允许多个事务对于同一数据可以共享一把锁,都能访问到数据,
阻止其它事务对于同一数据获取排它锁。
排它锁(X锁)
允许事务删除或者更新一行数据,
阻止其它事务对于同一数据获取其它锁,包括共享锁和排它锁。
select 语句默认不获取任何锁,所以是可以读被其它事务持有排它锁的数据的!
行级锁?表级锁?
select * from table_name where ... for update;
for update 仅适用于InnoDB,并且必须开启事务,在begin与commit之间才生效。
InnoDB 默认是行级锁,当有明确指定的主键/索引时候,是行级锁,否则是表级锁。
假设表 user,存在有id跟name字段,id是主键,有5条数据。
SELECT * FROM user WHERE id = 1 FOR UPDATE; (行锁)
SELECT * FROM user WHERE name = 'segon' FOR UPDATE; (表锁)
SELECT * FROM user WHERE id = -1 FOR UPDATE; (没有数据,无锁)
文档参考:
END