天天看点

redission分布式锁实现_彻底理解分布式锁原理并附上常用的分布式锁实现

一:概念

锁的目的就是对资源的一种并发控制;

当有多个使用者对一个资源进行使用的时候,为了保证避免对资源的使用冲突,必然会出现一种串行控制操作。

比如酒店的房间门锁,当你入住的时候,你需要先申请锁(的钥匙),如果锁(的钥匙)已经被其他人拿走,那么你将不能使用该房间资源;如果你申请到锁(的钥匙)进入房间,那么再有别人想申请进入则不被允许;当你释放锁(的钥匙,即办理退房)的时候,则其他人可以再次申请锁。

对于Java来说synchronized,Lock等都是为了解决该问题而出现的。

那么分布式锁,则在此基础上鉴于分布式系统而实现的在系统间进行互斥访问共享资源的一种方式。

二:特点

  • 互斥性:同一时刻只能有一个线程持有锁
  • 可重入性:同一节点上的同一个线程如果获取了锁之后能够再次获取锁
  • 锁超时:和J.U.C中的锁一样支持锁超时,防止死锁
  • 分布式:加锁和解锁需要高效,同时也需要保证高可用,防止分布式锁失效
  • 异常处理:具备阻塞和非阻塞性:能够及时从阻塞状态中被唤醒

三:分布式锁的实现方式

3.1 基于数据库

适用场景:

一般适用于资源不存在数据库,否则使用for update行锁或条件乐观锁(version)判断就可以满足基本的锁需求。

优点:

操作简单,容易理解,可以自定义时间戳,重入次数等

缺点:

性能开销大,对于高并发,高性能的系统难以容忍。没有失效时间需要考虑锁超时情况。单点需要考虑主从备份等。

3.1.1 基于mysql实现

1. 创建lock表(id, resource, node, count,create_time,update_time); insert/delete方式

2. 加锁,解锁如下;

redission分布式锁实现_彻底理解分布式锁原理并附上常用的分布式锁实现
@Component
public class MysqlDistributedLock implements DistributedLock {

    @Resource
    private JdbcTemplate jdbcTemplate;


    @Override
    public boolean tryLock(Duration timeout) {
        long start = Instant.now().toEpochMilli();
        while (true) {
            Lock lock = LockHolder.get();
            if (mysqlLock(lock.getResource(), lock.getNode())) {
                return true;
            }
            long end = Instant.now().toEpochMilli();
            if (end - start > timeout.toMillis()) {
                return false;
            }
            LockSupport.parkNanos(500 * 1000);
        }
    }


    @Override
    public boolean unlock() {
        Lock lock = LockHolder.get();
        return mysqlUnLock(lock.getResource(), lock.getNode());
    }

    @Transactional
    public boolean mysqlLock(String resource, String holderInfo) {
        String sql = " select * from lock where resource=" + resource + " for update ";
        Lock lock = jdbcTemplate.queryForObject(sql, Lock.class);
        if (null == lock) {
            String insertSql = " insert into lock (resource,node,count) values (" + resource + "," + holderInfo + ",1)";
            jdbcTemplate.execute(insertSql);
            return true;
        }
        if (holderInfo.equals(lock.getNode())) {
            String incCountSql = " update lock set count = count+1 where resource=" + resource + " and node=" + holderInfo;
            return jdbcTemplate.update(incCountSql) > 0;
        } else {
            return false;
        }
    }

    @Transactional
    public boolean mysqlUnLock(String resource, String node) {
        String sql = " select * from lock where resource=" + resource + " for update ";
        Lock lock = jdbcTemplate.queryForObject(sql, Lock.class);
        if (null == lock) { //maybe schedule job clean expired lock
            return true;
        }
        if (lock.getCount() > 1) {
            String incCountSql = " update lock set count = count-1 where resource=" + resource + " and node=" + node;
            return jdbcTemplate.update(incCountSql) > 0;
        } else {
            String delSql = " delete from lock where resource=" + resource + " and node=" + node;
            return jdbcTemplate.update(delSql) > 0;
        }
    }

}           

3.2 基于分布式缓存

优点:

非阻塞,天然分布式,高性能

缺点:

需要良好的分布式逻辑,否则容易造成死锁;并且成本较高。

3.2.1 基于Redis

3.2.1.1 自定义set方法:

简单,好实现,更多逻辑自己实现。

redission分布式锁实现_彻底理解分布式锁原理并附上常用的分布式锁实现
@Component
public class RedisDistributedLock implements DistributedLock {

    @Resource
    private RedisTemplate redisTemplate;
    private long TTL = 60;//second

    @Override
    public boolean tryLock(Duration timeout) {
        long start = Instant.now().toEpochMilli();
        Lock lock = LockHolder.get();
        while (true) {
            long end = Instant.now().toEpochMilli();
            if (end - start > timeout.toMillis()) {
                return false;
            }
            Boolean save = redisTemplate.opsForValue().setIfAbsent(lock.getResource(), lock.getNode(), Duration.ofSeconds(TTL));
            if (null != save && save) {
                return true;
            } else {
                LockSupport.parkNanos(500 * 1000);
            }
        }
    }


    /*
   #unlock.lua
   if redis.call('get', KEYS[1]) == ARGV[1] then
       redis.call('pexpire', KEYS[1], ARGV[2])
       return 1
   end
   return 0
    */
    @Override
    public boolean unlock() {
        Lock lock = LockHolder.get();
        DefaultRedisScript<Boolean> holdScript = new DefaultRedisScript<>();
        holdScript.setLocation(new ClassPathResource("lua/unlock.lua"));
        holdScript.setResultType(Boolean.class);
        Boolean result = (Boolean) redisTemplate.execute(holdScript, Collections.singletonList(lock.getResource()), lock.getNode(), 0l);
        return result.booleanValue();
    }
}           

3.2.1.2 使用Redission

1. 使用lua脚本+hashmap

2. 已有实现,方便使用

3. 有公平锁的实现,对于公平锁其利用了list结构和hashset结构分别用来保存我们排队的节点

/*
    redission.lock==>
    1. 使用hash对每个锁key(资源节点信息)进行赋值value(锁的次数)。实现可重入的加锁方式(对value进行加1操作)
    2. 如果加锁失败,判断是否超时,如果超时则返回false。
    3. 如果加锁失败,没有超时,那么需要在redisson_lock__channel+lockName的channel上进行订阅,用于订阅解锁消息,然后一直阻塞直到超时,或者有解锁消息。
    4. 重试步骤1,2,3,直到最后获取到锁,或者某一步获取锁超时。


        if (redis.call('exists', KEYS[1]) == 0) then
            redis.call('hset', KEYS[1], ARGV[2], 1);
            redis.call('pexpire', KEYS[1], ARGV[1]);
            return nil;
        end;
        if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
            redis.call('hincrby', KEYS[1], ARGV[2], 1);
            redis.call('pexpire', KEYS[1], ARGV[1]);
            return nil;
        end;
        return redis.call('pttl', KEYS[1]);
     */

    /*
    redission.unlock==>
    1. 通过lua脚本进行解锁,如果是可重入锁,只是减1。如果是非加锁线程解锁,那么解锁失败。
    2. 解锁成功需要在redisson_lock__channel+lockName的channel发布解锁消息,以便等待该锁的线程进行加锁

        if (redis.call('exists', KEYS[1]) == 0) then
            redis.call('publish', KEYS[2], ARGV[1]);
            return 1;
        end;
        if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
            return nil;
        end;
        local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
        if (counter > 0) then
            redis.call('pexpire', KEYS[1], ARGV[2]);
            return 0;
        else
            redis.call('del', KEYS[1]);
            redis.call('publish', KEYS[2], ARGV[1]);
            return 1;
        end;
        return nil;
     */           

3.2.1.3 使用RedLock实现

  1. 对于安全要求严格的话可以使用redlock
  2. 增加分布式锁的高可用,但是也增加了成本和复杂度
1. 部署多master的redis集群(比如5个) 
2. 依次循环对多个master进行加锁请求,加锁过程类似Redission 
3. 如果出现加锁失败的情况,则总计失败的master数是否小于(n+1)/2,是则多数成功(5个中3个加锁成功)表示加锁成功,否则加锁失败 
4. 此外,轮询加锁过程中需要判断总用时,如果超时,则认为失败 
5. 如果最终加锁成功,则OK,否则需要轮询对5个master都进行解锁(如果是网络问题,可能redisServer加锁成功,而client收到超时)           

3.2.2 基于Memcached 同redis

3.3 基于一致性协调器

优点:天然分布式,强一致性;可避免死锁的情况。

缺点:相比于缓存,性能较弱。

3.3.1 基于zookeeper

可以直接使用zookeeper第三方库Curator客户端,这个客户端中封装了一个可重入的锁服务。

原理是创建临时有序的节点,不用判断锁超时,因为zk挂了,临时节点自然就消失了;

优点:自由定制读写锁,公平锁等场景

redission分布式锁实现_彻底理解分布式锁原理并附上常用的分布式锁实现
@Component
public class ZKDistributedLock implements DistributedLock {


    @Value("${curator.host}")
    private String host;
    private CuratorFramework client;
    private static final String PATH = "/curator/lock/";

    @Override
    public boolean tryLock(Duration timeout) throws Exception {
        InterProcessMutex mutex = new InterProcessMutex(client, PATH + LockHolder.get().getResource());
        return mutex.acquire(timeout.getNano(), TimeUnit.NANOSECONDS);
    }


    @Override
    public boolean unlock() throws Exception {
        InterProcessMutex mutex = new InterProcessMutex(client, PATH + LockHolder.get().getResource());
        mutex.release();
        return true;
    }

    @PostConstruct
    public void init() {
        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
        client = CuratorFrameworkFactory.newClient(host, retryPolicy);
        client.start();
    }

    @PreDestroy
    public void destroy() {
        if (null != client)
            client.close();
    }
}           

3.3.2 基于etcd

@Component
public class EtcdDistributedLock implements DistributedLock {

    @Value("${etcd.endpoint}")
    private String endpoint;

    private Client client; // etcd客户端
    private Lock lockClient; // etcd分布式锁客户端
    private Lease leaseClient; // etcd租约客户端
    private long TTL = 60;//second


    @Override
    public boolean tryLock(Duration timeout) throws Exception {
        priv.penuel.simple.lock.Lock lock = LockHolder.get();
        long leaseId = Long.valueOf(lock.getNode());
        if (leaseId == 0) {
            leaseId = leaseClient.grant(TTL).get().getID();
            lock.setNode(String.valueOf(leaseId));
            LockHolder.set(lock);
        }
        //在指定租约上获取lock
        lockClient.lock(ByteSequence.from(LockHolder.get().getResource().getBytes()), leaseId).get(timeout.toMillis(), TimeUnit.MILLISECONDS);
        //如果该租约已经过期,则获取失败
        long ttl = leaseClient.timeToLive(leaseId, LeaseOption.DEFAULT).get(1, TimeUnit.SECONDS).getTTl();
        if (ttl > 0) {
            return true;
        } else {
            return false;
        }
    }


    @Override
    public boolean unlock() throws Exception {
        long leaseId = Long.valueOf(LockHolder.get().getNode());
        //试图释放锁
        lockClient.unlock(ByteSequence.from(LockHolder.get().getResource().getBytes())).get();
        leaseClient.revoke(leaseId).get(1, TimeUnit.SECONDS);

        return false;
    }


    @PostConstruct
    public void init() {
        this.client = Client.builder().endpoints(endpoint).build();
        this.lockClient = client.getLockClient();
        this.leaseClient = client.getLeaseClient();
    }

}           

3.3.3 基于Chubby

Google Chubby是以paxos为基础的一致性实现,其目的和zookeeper不同,仅仅是一个面向松耦合的高可用分布式锁服务。

场景:GFS使用chubby来实现对GFS Master服务器的选举。

实现:采用Martin所说的自增序列方案解决分布式不安全的问题

特点:

  1. 分布式锁和序列:解决分布式锁安全问题
  2. CheckSequencer():调用chubby API检查序列号是否有效
  3. 访问资源服务器,判断分布式锁服务端的序列号和client的序列号大小
  4. lock-delay:client如果因为网络抖动或阻塞,chubby并不会立即释放锁,而是在一定时间(1min)内阻止其他client获取这个锁
  5. 事件通知机制(发布与订阅)client可以向chubby注册事件通知
  6. 缓存机制 chubbyClient通过租期机制保证了本地缓存和chubby的强一致性
  7. 心跳机制 通过TCP连接使用心跳保持会话
  8. paxos协议 同zookeeper

四:矛盾点

背景:分布式系统不可能同时保证 一致性(Consistency)、 可用性(Availability) 和 分区容忍性(Partition tolerance)。

那么一个合理的分布式锁是应该需要满足CP模型的。

为什么说应该呢?因为现实业务场景中,对于各类业务的需求不同,则实现不同;可能用户系统只需要AP,金融系统需要CP,所以具体则根据场景而定。

来看看我们上面的各类实现模型:

mysql,Redis是AP模型 ; zookeeper/etcd 是CP模型

五:分布式锁的问题

矛盾点所引发的问题包括但不限于:

  1. 分布式锁只是同一自然时间段的互斥,不同时间段不保证
  2. 如果业务需要处理两个不同时间段的互斥锁,需要自己实现逻辑
  3. 锁没有按照预期续租
  4. 因为网络,GC,瞬时时间等问题,不能正常续租的锁,则会被过期
  5. 提供分布式锁的服务中断、不可用
  6. redis集群,master挂了,主从切换中; zk,etcd leader挂了,选举过程中
  7. raft日志数据同步发生错误或者不一致的情况

尽管有这么多分布式锁的实现,但是现实往往是残酷的,有些极端的问题仍然难以解决,想完全做到十全十美的解决方案是没有的。参考文章

先引用RedLock引发的讨论:

我们可以通过分布式系统大师Martin Kleppmann和Redis之父antirez关于分布式锁的讨论,便可了解其复杂性。 antirez实现了一种相对安全的分布式锁RedLock,然后Martin发布文章则认为RedLock并不能做到安全的分布式锁机制;而antirez则立即发文对其进行了反驳。

1. 需要给lock设置一个过期时间,防止redis down机或者网络分割的时候,无法释放而造成死锁状态;所以这个过期时间设置多久合适呢? 
2. set lock的value(holderInfo)对象是必须的,且不能为固定值,否则会出现clientA获取锁后,阻塞了很久,过期时间到了,自动释放;而此时clientB又获取了锁,此时clientA恢复过来,就会释放掉clientB的锁; 
3. 获取锁和释放锁需要是原子操作,需要用lua执行保证原子性,否则会出现同上clientA释放clientB的锁; 
4. 之前3个问题,需要开发人员特别注意也可避免;但是如果出现failover问题(从master上获取锁后,还没同步给slave,master就挂了,slave取代master后,锁出现问题),单节点的redis分布式锁是无法解决该安全问题;而RedLock则为此诞生。            

5.2 RedLock的问题

1. RedLock的具体实现上面介绍过了;

2. 如果出现了STW,网络超时,或者机器时钟跳跃;则都会出现超时问题

总结上面的问题:客户端长期阻塞导致锁过期问题; 长期阻塞的原因包括但不限于网络超时(波动), 客户端暂停(GC-STW),应用阻塞,时钟错误(Redis机器时钟跳跃)等;

如下图出现的问题:

redission分布式锁实现_彻底理解分布式锁原理并附上常用的分布式锁实现

对于该问题Martin给出的解决方案为:fencing token一个单调递增的数字(如图)

redission分布式锁实现_彻底理解分布式锁原理并附上常用的分布式锁实现

client1获取锁的时候,分布式锁server返回fencingToken=33,此时若出现超时,阻塞等异常情况导致client1的锁过期;client2获取锁的时候fencingToken=34;而此时client1恢复后,无论是延期还是释放锁,给出fencingToken=33,则会被分布式锁server拒掉。

而antirez反驳认为,如果你能保证分布式锁的递增token,那么便不需要锁了,直接使用CAS乐观锁便可解决资源共享问题;如果增加了fencingToken机制,由于原子性和锁的复杂性则更加难处理。

继续阅读