阅读 153

分布式之锁机制

原文链接: mp.weixin.qq.com

什么是锁?

锁这个词在日常生活中是非常普遍存在的实体,例如:车锁、门锁、小金库保险锁等等。在计算机科学领域里,也是有一种锁的概念,无论是现实世界还是计算机世界,锁的意义是近似的,为了阻挡事情的发生而采取的一种措施。

言归正传,维基百科给出得定义:是在执行多线程时用于强行限制资源访问的同步机制,即用于在并发控制中保证对互斥要求的满足。

从上述定义中可以看出在单进程单线程情况下,锁就显得没有意义了。所以锁是存在于多线程之中,Java中进行同步机制关键词Synchronized,轻量级xxxLock。

锁必须满足以下特征:

1、多线程中

2、强限制资源访问

3、并发与互斥

分布式下的锁?

在当今互联网世界,大规模服务分布式环境之中,不得不去面对的一个问题是多进程之间资源限制访问问题,普通的Java同步机制无法解决,此场景下的锁机制就需要通过外部手段进行有效管控,否则会出现各种不一致问题。

理想的分布式锁特征:

1、保证在分布式部署的集群中,同一个方法在同一时刻只能被一台机器上的一个线程执行。

2、可重入锁(避免死锁)

3、阻塞锁和公平锁(根据业务需求考虑要不要这条)

4、有高可用的获取锁和释放锁功能

5、获取锁和释放锁的性能要好

如何实现分布式锁?

1)基于数据库

》基于悲观锁:

1.1)库表主键的唯一性或者唯一索引

缺点:

  • 锁强依赖数据库的可用性,数据库单点问题。(解决单点问题、双库同步、故障切换)

  • 锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。(无失效时间、定时任务清理库记录)

  • 锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。没有获得锁的线程没有排队队列机制,要想再次获得锁就要再次触发获得锁操作。(失败重试机制)

  • 锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在。(线程信息持久化对比)锁 是非公平锁,所有等待锁的线程凭运气去争夺锁。( 等待 线程信息持久化先到达先获得锁)

  • 在 MySQL 数据库中采用主键冲突防重,在大并发情况下有可能会造成锁表现象。(应用程序生成主键防重)

以上缺点可以看出来,此种方式下并非理想情况下的分布式锁

1.2)库的排他锁

Sql查询末尾加 for update 关键字修饰,数据库会启用排他锁

(而Innodb引擎中,查询通过索引检索是才是行级锁,其他是表级锁)在此整个过程其他线程无法再增加排他锁。commit后才能释放锁。 排他锁是阻塞的。

缺点:这种锁是粗放的、无法解决库单点和不可重入问题、占据连接不利于连接池

》基于乐观锁:

字段版本号进行更新

业务表冗余版本号字段,比对版本号进行更新

缺点:功能侵入业务表,频繁操作库开销大

2)基于缓存

典型的redis,网上通用的解决方案 

基于 redis 的 setnx()、expire() 方法做分布式锁

setnx()

setnx 的含义就是 SET if Not Exists,其主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。

expire()

expire 设置过期时间,要注意的是 setnx 命令不能设置 key 的超时时间,只能通过 expire() 来对 key 设置。

使用步骤

1、setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功

2、expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。

3、执行完业务代码后,可以通过 delete 命令删除 key。

这个方案其实是可以解决日常工作中的需求的,但从技术方案的探讨上来说,可能还有一些可以完善的地方。比如,如果在第一步 setnx 执行成功后,在 expire() 命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题,所以如果要对其进行完善的话,可以使用 redis 的 setnx()、get() 和 getset() 方法来实现分布式锁。

基于 redis 的 setnx()、get()、getset()方法做分布式锁

这个方案的背景主要是在 setnx() 和 expire() 的方案上针对可能存在的死锁问题,做了一些优化。

getset()

这个命令主要有两个参数 getset(key,newValue)。该方法是原子的,对 key 设置 newValue 这个值,并且返回 key 原来的旧值。假设 key 原来是不存在的,那么多次执行这个命令,会出现下边的效果:

  1. getset(key, "value1") 返回 null 此时 key 的值会被设置为 value1

  2. getset(key, "value2") 返回 value1 此时 key 的值会被设置为 value2

  3. 依次类推!

使用步骤

  1. setnx(lockkey, 当前时间+过期超时时间),如果返回 1,则获取锁成功;如果返回 0 则没有获取到锁,转向 2。

  2. get(lockkey) 获取值 oldExpireTime ,并将这个 value 值与当前的系统时间进行比较,如果小于当前系统时间,则认为这个锁已经超时,可以允许别的请求重新获取,转向 3。

  3. 计算 newExpireTime = 当前时间+过期超时时间,然后 getset(lockkey, newExpireTime) 会返回当前 lockkey 的值currentExpireTime。

  4. 判断 currentExpireTime 与 oldExpireTime 是否相等,如果相等,说明当前 getset 设置成功,获取到了锁。如果不相等,说明这个锁又被别的请求获取走了,那么当前请求可以直接返回失败,或者继续重试。

  5. 在获取到锁之后,当前线程可以开始自己的业务处理,当处理完毕后,比较自己的处理时间和对于锁设置的超时时间,如果小于锁设置的超时时间,则直接执行 delete 释放锁;如果大于锁设置的超时时间,则不需要再锁进行处理。

import com.test.cache.redis.RedisService;import com.test.utils.SpringUtils;//redis分布式锁public final class RedisLockUtil {    private static final int defaultExpire = 60;    private RedisLockUtil() {        //    }    /**     * 加锁     * @param key redis key     * @param expire 过期时间,单位秒     * @return true:加锁成功,false,加锁失败     */    public static boolean lock(String key, int expire) {        RedisService redisService = SpringUtils.getBean(RedisService.class);        long status = redisService.setnx(key, "1");        if(status == 1) {            redisService.expire(key, expire);            return true;        }        return false;    }    public static boolean lock(String key) {        return lock2(key, defaultExpire);    }    /**     * 加锁     * @param key redis key     * @param expire 过期时间,单位秒     * @return true:加锁成功,false,加锁失败     */    public static boolean lock2(String key, int expire) {        RedisService redisService = SpringUtils.getBean(RedisService.class);        long value = System.currentTimeMillis() + expire;        long status = redisService.setnx(key, String.valueOf(value));        if(status == 1) {            return true;        }        long oldExpireTime = Long.parseLong(redisService.get(key, "0"));        if(oldExpireTime < System.currentTimeMillis()) {            //超时            long newExpireTime = System.currentTimeMillis() + expire;            long currentExpireTime = Long.parseLong(redisService.getSet(key, String.valueOf(newExpireTime)));            if(currentExpireTime == oldExpireTime) {                return true;            }        }        return false;    }    public static void unLock1(String key) {        RedisService redisService = SpringUtils.getBean(RedisService.class);        redisService.del(key);    }    public static void unLock2(String key) {            RedisService redisService = SpringUtils.getBean(RedisService.class);            long oldExpireTime = Long.parseLong(redisService.get(key, "0"));           if(oldExpireTime > System.currentTimeMillis()) {                    redisService.del(key);            }   }}public void drawRedPacket(long userId) {    String key = "draw.redpacket.userid:" + userId;    boolean lock = RedisLockUtil.lock2(key, 60);    if(lock) {        try {            //领取操作        } finally {            //释放锁            RedisLockUtil.unLock(key);        }    } else {        new RuntimeException("重复领取奖励");    }}复制代码

缺点:失效时间无法把握,失效时间过短:未执行完毕提前失效就会产生并发问题,如果失效时间过长:其他线程就会浪费白等情况。

3)基于Zookeeper

zookeeper是一个简单的文件系统,包括四种节点。

zookeeper 锁相关基础知识

  • zk 一般由多个节点构成(单数),采用 zab 一致性协议。因此可以将 zk 看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务。

  • zk 的数据以目录树的形式,每个目录称为 znode, znode 中可存储数据(一般不超过 1M),还可以在其中增加子节点。

  • 子节点有三种类型。序列化节点,每在该节点下增加一个节点自动给该节点的名称上自增。临时节点,一旦创建这个 znode 的客户端与服务器失去联系,这个 znode 也将自动删除。最后就是普通节点。

  • Watch 机制,client 可以监控每个节点的变化,当产生变化会给 client 产生一个事件。

zk 基本锁

  • 原理:利用临时节点与 watch 机制。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch/lock 节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后能自动上锁的节点自动删除即取消锁。

  • 缺点:所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。

zk 锁优化

  • 原理:上锁改为创建临时有序节点,每个上锁的节点均能创建节点成功,只是其序号不同。只有序号最小的可以拥有锁,如果这个节点序号不是最小的则 watch 序号比本身小的前一个节点 (公平锁)。

  • 步骤:

  • 在/lock 节点下创建一个有序临时节点 (EPHEMERAL_SEQUENTIAL)。

  • 判断创建的节点序号是否最小,如果是最小则获取锁成功。不是则取锁失败,然后 watch 序号比本身小的前一个节点。

  • 当取锁失败,设置 watch 后则等待 watch 事件到来后,再次判断是否序号最小。

  • 取锁成功则执行代码,最后释放锁(删除该节点)。

import java.io.IOException;import java.util.ArrayList;import java.util.Collections;import java.util.List;import java.util.concurrent.CountDownLatch;import java.util.concurrent.TimeUnit;import java.util.concurrent.locks.Condition;import java.util.concurrent.locks.Lock;import org.apache.zookeeper.CreateMode;import org.apache.zookeeper.KeeperException;import org.apache.zookeeper.WatchedEvent;import org.apache.zookeeper.Watcher;import org.apache.zookeeper.ZooDefs;import org.apache.zookeeper.ZooKeeper;import org.apache.zookeeper.data.Stat;public class DistributedLock implements Lock, Watcher{    private ZooKeeper zk;    private String root = "/locks";//根    private String lockName;//竞争资源的标志    private String waitNode;//等待前一个锁    private String myZnode;//当前锁    private CountDownLatch latch;//计数器    private int sessionTimeout = 30000;    private List<Exception> exception = new ArrayList<Exception>();    /**     * 创建分布式锁,使用前请确认config配置的zookeeper服务可用     * @param config 127.0.0.1:2181     * @param lockName 竞争资源标志,lockName中不能包含单词lock     */    public DistributedLock(String config, String lockName){        this.lockName = lockName;        // 创建一个与服务器的连接        try {            zk = new ZooKeeper(config, sessionTimeout, this);            Stat stat = zk.exists(root, false);            if(stat == null){                // 创建根节点                zk.create(root, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT);            }        } catch (IOException e) {            exception.add(e);        } catch (KeeperException e) {            exception.add(e);        } catch (InterruptedException e) {            exception.add(e);        }    }    /**     * zookeeper节点的监视器     */    public void process(WatchedEvent event) {        if(this.latch != null) {            this.latch.countDown();        }    }    public void lock() {        if(exception.size() > 0){            throw new LockException(exception.get(0));        }        try {            if(this.tryLock()){                System.out.println("Thread " + Thread.currentThread().getId() + " " +myZnode + " get lock true");                return;            }            else{                waitForLock(waitNode, sessionTimeout);//等待锁            }        } catch (KeeperException e) {            throw new LockException(e);        } catch (InterruptedException e) {            throw new LockException(e);        }    }    public boolean tryLock() {        try {            String splitStr = "_lock_";            if(lockName.contains(splitStr))                throw new LockException("lockName can not contains \\u000B");            //创建临时子节点            myZnode = zk.create(root + "/" + lockName + splitStr, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.EPHEMERAL_SEQUENTIAL);            System.out.println(myZnode + " is created ");            //取出所有子节点            List<String> subNodes = zk.getChildren(root, false);            //取出所有lockName的锁            List<String> lockObjNodes = new ArrayList<String>();            for (String node : subNodes) {                String _node = node.split(splitStr)[0];                if(_node.equals(lockName)){                    lockObjNodes.add(node);                }            }            Collections.sort(lockObjNodes);            System.out.println(myZnode + "==" + lockObjNodes.get(0));            if(myZnode.equals(root+"/"+lockObjNodes.get(0))){                //如果是最小的节点,则表示取得锁                return true;            }            //如果不是最小的节点,找到比自己小1的节点            String subMyZnode = myZnode.substring(myZnode.lastIndexOf("/") + 1);            waitNode = lockObjNodes.get(Collections.binarySearch(lockObjNodes, subMyZnode) - 1);        } catch (KeeperException e) {            throw new LockException(e);        } catch (InterruptedException e) {            throw new LockException(e);        }        return false;    }    public boolean tryLock(long time, TimeUnit unit) {        try {            if(this.tryLock()){                return true;            }            return waitForLock(waitNode,time);        } catch (Exception e) {            e.printStackTrace();        }        return false;    }    private boolean waitForLock(String lower, long waitTime) throws InterruptedException, KeeperException {        Stat stat = zk.exists(root + "/" + lower,true);        //判断比自己小一个数的节点是否存在,如果不存在则无需等待锁,同时注册监听        if(stat != null){            System.out.println("Thread " + Thread.currentThread().getId() + " waiting for " + root + "/" + lower);            this.latch = new CountDownLatch(1);            this.latch.await(waitTime, TimeUnit.MILLISECONDS);            this.latch = null;        }        return true;    }    public void unlock() {        try {            System.out.println("unlock " + myZnode);            zk.delete(myZnode,-1);            myZnode = null;            zk.close();        } catch (InterruptedException e) {            e.printStackTrace();        } catch (KeeperException e) {            e.printStackTrace();        }    }    public void lockInterruptibly() throws InterruptedException {        this.lock();    }    public Condition newCondition() {        return null;    }    public class LockException extends RuntimeException {        private static final long serialVersionUID = 1L;        public LockException(String e){            super(e);        }        public LockException(Exception e){            super(e);        }    }}复制代码

有效解决非阻塞、不可重入问题以及锁无法释放问题

缺点:性能无法媲美缓存,节点操作开销,节点操作只能在Leader上执行,并同步Follower

基于Consul

基于Consul的分布式锁主要利用Key/Value存储API中的acquire和release操作来实现。acquire和release操作是类似Check-And-Set的操作:

  • acquire操作只有当锁不存在持有者时才会返回true,并且set设置的Value值,同时执行操作的session会持有对该Key的锁,否则就返回false

  • release操作则是使用指定的session来释放某个Key的锁,如果指定的session无效,那么会返回false,否则就会set设置Value值,并返回true

具体实现中主要使用了这几个Key/Value的API:

  • create session:https://www.consul.io/api/session.html#session_create

  • delete session:https://www.consul.io/api/session.html#delete-session

  • KV acquire/release:https://www.consul.io/api/kv.html#create-update-key

public class Lock {     private static final String prefix = "lock/";  // 同步锁参数前缀     private ConsulClient consulClient;    private String sessionName;    private String sessionId = null;    private String lockKey;     /**     *     * @param consulClient     * @param sessionName   同步锁的session名称     * @param lockKey       同步锁在consul的KV存储中的Key路径,会自动增加prefix前缀,方便归类查询     */    public Lock(ConsulClient consulClient, String sessionName, String lockKey) {        this.consulClient = consulClient;        this.sessionName = sessionName;        this.lockKey = prefix + lockKey;    }     /**     * 获取同步锁     *     * @param block     是否阻塞,直到获取到锁为止     * @return     */    public Boolean lock(boolean block) {        if (sessionId != null) {            throw new RuntimeException(sessionId + " - Already locked!");        }sessionId = createSession(sessionName);        while(true) {            PutParams putParams = new PutParams();            putParams.setAcquireSession(sessionId);            if(consulClient.setKVValue(lockKey, "lock:" + LocalDateTime.now(), putParams).getValue()) {                return true;            } else if(block) {                continue;            } else {                return false;            }        }    }     /**     * 释放同步锁     *     * @return     */    public Boolean unlock() {        PutParams putParams = new PutParams();        putParams.setReleaseSession(sessionId);        boolean result = consulClient.setKVValue(lockKey, "unlock:" + LocalDateTime.now(), putParams).getValue();        consulClient.sessionDestroy(sessionId, null);        return result;    }     /**     * 创建session     * @param sessionName     * @return     */    private String createSession(String sessionName) {        NewSession newSession = new NewSession();        newSession.setName(sessionName);        return consulClient.sessionCreate(newSession, null).getValue();    }}复制代码

缺点:锁的粒度控制把握、服务的可靠性、释放锁失败控制手段