分布式.分布式锁

缘起

有一个公共厕所只有一个坑位,为了控制正在使用的人不被使用,旁边设置了个门房,并给厕所上了密码锁,只有拿到单次使用密码的人才可以使用。

  1. 只有一个坑位,所以同一时刻只能有一个人使用
  2. 使用前需要跟门房要一个临时单次密码
  3. 使用完后需要给门房说一声才可以生成新密码
  4. 但有些人用完就走了,所以门房就规定5min使用过期时间,如果超过时间就可以生成新密码,其他人也就可使用了
  5. 这就可能有问题,如果一个人确实使用时间超过5min,那么新进去的人会干扰他的使用。但如果5min内,一个人出去了又拉肚子需要重复使用,那么这是否算第二次使用
  6. 如果是这个人没有带卫生纸,是否按照二次使用获取新密码呢?
  7. 如果厕所正在使用,后面来的人是等待还是直接找其他厕所呢需要根据实际情况设定
  8. 如果等待,是否需要设置一个最长等待时间呢
  9. 如果等待,等待的人是否需要排队,下一个的时候是抢占呢,还是按照来的迟早获取
  10. 获取新密码的时间要尽可能短,尽可能减少非使用厕所的损耗
  11. 尽可能一直有人值守门房,否则拿不到密码也没法使用厕所

如果多人(多台机器,多个线程等)操作一个单元,那么结果就可能不是谁都愿意看到的。这是同步问题,放在分布式中也一样,解决方案就是:有一个尽可能小的单线程中间变量作为是否可以该变量的标记。

谁拿到这把钥匙,谁就可以开启箱子。

分布式锁要求

  1. 互斥:进程外同一刻时间,只能有一个客户端访问 (必选)
  2. 死锁:访问可能中断,锁需要有一定过期时间(必选)
  3. 在过期时间和使用时间做好平衡,时间太长*并发=常数
  4. 锁尽可能高可用,高性能
  5. 阻塞和非阻塞特性:对后续来的客户是直接打发回去呢,还是等待一定时间,还没有获取到就打发回去
  6. 等待的客户是否需要排队,还是看运气抢占
  7. 如果同一个多次要密码,是否给生成,还是等待(可重入问题)
  8. 解铃还须系铃人:防止别人等不住说我用不了,自己去使用;当然门房设置的超时机制除外

实现方案1:数据库

原理或步骤:

  1. 开启事务
  2. 使用数据库select lock from table1 where id = 0 for update; 给改行添加排它锁,事务没有提交之前,其他事务不能更新
  3. 判断lock==0,0没有锁,1已锁
  4. 更新数据lock=1
  5. 提交事务
  1. 业务操作
  1. 开启事务
  2. 使用数据库select lock from table1 where id = 0 for update; 给改行添加排它锁,事务没有提交之前,其他事务不能更新
  3. 判断lock==1,0没有锁,1已锁
  4. 更新数据lock=0
  5. 提交事务

问题:

  1. 锁的使用者没法得到
  2. 没有超时清理机制,可能导致死锁

延迟消息+添加使用者字段:

  1. 更新lock=0的时候,先添加延迟清理消息,并添加使用者
  2. 延迟消息,如果user没有匹配,就丢弃
  3. 如果业务操作超时,延迟消息执行解锁,新客户锁后,超时的业务操作客户解锁时需要判断user:update table1 set lock=0 where id = 0 and user = 当前线程ID;

特点:

仅限于有事务数据库引擎,如果Mysql的Innodb。

方案2:缓存Memcached

利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add成功,也就意味着线程得到了锁。

类似Mysql,超时机制需要自己设置

方案3:Redis

setnx命令:如果有key有数据,就不设置,返回0;没有数据就赋值,返回1。单线程

超时机制:expire命令,设置超时命令

问题:

  1. 没法先设置超时expire,然后在setnx。(先发送延迟消息,然后更新数据库)
  2. 先设置setnx,如果没有设置expire就会死锁

解决:

合并命令 jedis版本:2.9.0

jedis.set(lockKey, value(requestId), “NX”, “PX”, expireTime)

value:设置当前请求的requestID,用作解锁的依据

expireTime 过期时间

缺点:可重入 实现困难

  1. set并设置时间返回0
  2. 过期,清理
  3. get并判断value是否为当前requestID
  4. 获取到数据为空,返回false
  5. 获取到数据为其他客户端加锁,返回false
  6. 如果是返回true,已获取锁;否则返回false
  7. 场景:可重复场景类似两层AOP都加锁,根据此次线程IP所谓requestId判断,第二次获取锁就可以直接拿到,否则获取同一个锁可能死锁。

解锁:也需要合并命令

    // 判断加锁与解锁是不是同一个客户端

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<Long> LOCK_SCRIPT;
    private static final DefaultRedisScript<Object> UNLOCK_SCRIPT;
    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);
    }
}

方案4:Zookeeper

基础概念:

  1. Zookeeper类似一颗树结构
  2. 节点类型:持久节点,持久顺序节点,临时节点,临时顺序节点
  3. 顺序:按照add的顺序给node添加序号;临时:客户端断了就删除了
  4. 分布式锁就使用临时顺序节点

大致思想即为:

每个客户端对某个方法加锁时,在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客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)

Chubby

[ˈtʃʌbi] adj.胖乎乎的;圆胖的;丰满的

参考:Google Chubby(中文版)_左罗CTO的技术博客_51CTO博客

方案选型

上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。

从理解的难易程度角度(从低到高)

数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)

Zookeeper >= 缓存 > 数据库

从性能角度(从高到低)

缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)

Zookeeper > 缓存 > 数据库

难点

  1. 可重入:业务上同一个客户端可以多次获取锁,并成功返回
  2. 超时和业务处理时间的平衡
  3. 客户端连接检查,解决网络抖动问题
  4. 加锁和解锁操作原子性

Mysql数据库锁

共享锁(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; (没有数据,无锁)


文档参考:

这才是真正的分布式锁_w3cschool

分布式锁的几种解决方案 - xuwc - 博客园

END

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Redis分布式锁是一种常用的分布式系统中实现互斥访问的机制。其底层原理可以通过以下几个步骤来介绍: 1. 获取:客户端通过执行SET命令尝试在Redis中设置一个特定的键值对作为。如果该键不存在,则表示获取到了,可以执行后续操作。如果该键已经存在,则表示已经被其他客户端持有,需要等待或者进行重试。 2. 设置过期时间:为了避免被持有后无法释放的情况,需要为设置一个过期时间。客户端在获取到之后,通过执行EXPIRE命令为设置一个合适的过期时间。这样即使持有的客户端发生故障或者意外退出,也会在一定时间后自动释放。 3. 释放:当客户端完成了对共享资源的操作后,需要释放。客户端通过执行DEL命令来删除对应的键值对,从而释放供其他客户端使用。 需要注意的是,Redis分布式锁的实现需要考虑以下几个问题: 1. 竞争:多个客户端同时尝试获取时可能会发生竞争。为了避免多个客户端同时获取到,可以使用SETNX命令来保证只有一个客户端能够成功获取到。 2. 误释放:如果客户端在获取到之后发生故障或者意外退出,可能无法正常释放。为了解决这个问题,可以为设置一个合适的过期时间,确保即使持有的客户端发生故障,也会在一定时间后自动释放。 3. 重入:某些场景下,同一个客户端可能需要多次获取同一个。为了支持的重入,可以为每个客户端维护一个计数器,记录该客户端获取的次数,并在释放时递减计数器。只有当计数器为0时,才真正释放

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

闲猫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值