分布式锁

一、引题

1、什么是分布式锁?

  • 分布式CAP
    理论提到任何一个系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition
    tolerance)三者的,同一时刻只能满足两个,在这种情况下分布式锁就出现了,分布式锁就是用来解决数据一致性问题的。
  • 保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在单体应用中,控制并发处理的接口有:synchronized、JUC包的一些实现(ReentrantLock),但在分布式环境中,这些API就没有用武之地了。
  • 分布式与单机情况下最大的不同在于其不是多线程而是多进程。
    多线程由于可以共享堆内存,因此可以简单的采取内存作为标记存储位置。而进程之间甚至可能都不在同一台物理机上,因此需要将标记存储在一个所有进程都能看到的地方。
  • 分布式环境下,应用是多实例部署的,很多实例甚至都不在同一台机器上,这将使原本单机部署情况下的并发控制锁策略失效,单纯的Java
    API并不能提供分布式锁的能力。 这时候解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问。这个时候分布式锁就诞生了。
  • 与单机模式下的锁不同,分布式锁不仅需要保证进程可见,还需要考虑进程与锁之间的网络问题(分布式情况下之所以问题变得复杂,主要就是需要考虑到网络的延时和不可靠)。
    分布式锁还是可以将标记存在内存,只是该内存不是某个进程分配的内存而是公共内存如Redis、Memcache。

分布式锁:

就是在分布式环境下,用来解决多实例对数据访问一致性的一种技术方案。
2、使用场景
只要涉及到多个实例进程对同一份数据进行修改等操作都会需要用到分布式锁。

  • CRM新增客户、捡回客户
  • 全局计数器
  • 秒杀抢购下单
  • 更新缓存
  • 减少库存
  • 金额修改
  • 分布式定时任务的执行

二、分布式锁具备哪些特性

  • 在分布式环境下同一时刻只能被单个线程获取执行
  • 高可用的获取锁与释放锁
  • 高性能的获取锁与释放锁
  • 可重入特性,意思是已经获得锁的线程在执行过程中不需要再次获得锁
  • 具备锁失效机制(异常或者超时自动删除),防止死锁
  • 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败

三、实现方式

目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。
分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。
在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”即可,只要这个最终时间是在用户可以接受的范围内即可。
在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。
基于文件、DB的分布式锁会遇到各式各样的问题,性能也经常是瓶颈。
1、基于Redis(Tair)
选用Redis实现分布式锁原因

  • Redis有很高的性能
  • Redis命令对此支持较好,实现起来比较方便

set命令
SET resource_name my_random_value NX PX max-lock-time:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
delete:delete key:删除key
在使用Redis实现分布式锁的时候,主要就会使用到这两个命令。
具体的Redis分布式锁实现有:

  • Redission
  • dlock(百度)

原理

  • 获取锁的时候,使用set加锁,并为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过该值在释放锁的时候进行判断。
  • 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
  • 释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放(保证释放的是同一个客户端机器获得的锁)。

优点

  • 实现简单
  • 理解逻辑简单
  • 性能好(缓存)

缺点

  • Redis容易单点故障,需要集群部署,构建分布式Redis
  • key 的过期时间设置多少不明确,只能根据实际情况调整。

存在的问题
setnx命令设置完key-value后,还没来得及使用expire命令设置过期时间,当前线程挂掉了,会导致当前线程设置的key一直有效,后续线程无法正常通过setnx获取锁,造成死锁
解决:该问题是因为两个命令是分开执行并且不具备原子特性,如果能将这两个命令合二为一就可以解决问题了。在Redis2.6.12版本中实现了这个功能,Redis为set命令增加了一系列选项,可以通过SET resource_name my_random_value NX PX max-lock-time来获取分布式锁,这个命令仅在不存在key(resource_name)的时候才能被执行成功(NX选项),并且这个key有一个max-lock-time秒的自动失效时间(PX属性)。这个key的值是“my_random_value”,它是一个随机值,这个值在所有的机器中必须是唯一的,用于安全释放锁。
在分布式环境下,线程A通过这种实现方式获取到了锁,但是在获取到锁之后,执行被阻塞了,导致该锁失效,此时线程B获取到该锁,之后线程A恢复执行,执行完成后释放该锁,直接使用del命令,将会把线程B的锁也释放掉,而此时线程B还没执行完,将会导致不可预知的问题
解决:释放锁的时候,只有key存在并且存储的“my_random_value”值和指定的值一样才执行del命令
为了实现高可用,将会选择主从复制机制,但是主从复制机制是异步的,会出现数据不同步的问题,可能导致多个机器的多个线程获取到同一个锁。
解决:不采用主从复制,使用RedLock算法,这里引用网上一段关于RedLock算法的描述。
在Redis的分布式环境中,假设有5个Redis master,这些节点完全互相独立,不存在主从复制或者其他集群协调机制。为了取到锁,客户端应该执行以下操作:
获取当前Unix时间,以毫秒为单位
依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例
客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功
如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)
如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)
使用一(dlock)

Lock lock = dLockGenerator.gen("stat_dlock_lockType_" + entry.getKey(), "stat_dlock_target" + entry.getKey(), 3, TimeUnit.SECONDS);
try {
    lock.lock();
    logger.info("redis锁获取成功,准备处理数据...");
    // do();
} catch(Exception e) {
    e.printStackTrace();
    logger.error("redis锁操作失败, {}", e.getMessage());
} finally {
    lock.unlock();
    logger.info("redis锁已释放...");
}

使用二(Tair)
使用com.taobao.tair.TairManager接口的put()、invalid()方法。
mdb有可能丢、且invalid时不保证跨机房一致性,所以这个锁肯定需要用ldb来实现的。

public class CommonLocker {
    private static final Logger logger = LoggerFactory.getLogger(CommonLocker.class);
    @Resource
    private TairManager ldbTairManager;
    private static final short NAMESPACE = 1310;
    private static CommonLocker locker;
    public void init() {
        if (locker != null) return;
        synchronized (CommonLocker.class) {
            if (locker == null)
                locker = this;
        }
    }
    public static Lock newLock(String format, Object... argArray) {
        FormattingTuple ft = MessageFormatter.arrayFormat(format, argArray);
        return newLock(ft.getMessage());
    }
    public static Lock newLock(String strKey) {
        String key = "_tl_" + strKey;
        return new TairLock(key, CommonConfig.lock_default_timeout);
    }
    public static Lock newLock(String strKey, int timeout) {
        String key = "_tl_" + strKey;
        return new TairLock(key, timeout);
    }
    private static class TairLock implements Lock {
        private String lockKey;
        private boolean gotLock = false;
        private int retryGet = 0;
        private int retryPut = 0;
        private int timeout;
        public TairLock(String key, int timeout) {
            this.lockKey = tokey(key);
            this.timeout = timeout;
        }
        public boolean tryLock() {
            return tryLock(timeout);
        }
        /**
         * need finally do unlock
         *
         * @return
         */
        public boolean tryLock(int timeout) {
            Result<DataEntry> result = locker.ldbTairManager.get(NAMESPACE, lockKey);
            while (retryGet++ < CommonConfig.lock_get_max_retry &&
                    (result == null || ResultCode.CONNERROR.equals(result.getRc()) || ResultCode.TIMEOUT.equals(result.getRc()) || ResultCode.UNKNOW.equals(result.getRc()))) {
                // 重试一次
                result = locker.ldbTairManager.get(NAMESPACE, lockKey);
            }
            if (ResultCode.DATANOTEXSITS.equals(result.getRc())) { // lock is free
                // 已验证version 2表示为空,若不是为空,则返回version error
                ResultCode code = locker.ldbTairManager.put(NAMESPACE, lockKey, locker.getValue(), 2, timeout);
                if (ResultCode.SUCCESS.equals(code)) {
                    gotLock = true;
                    return true;
                } else if (retryPut++ < CommonConfig.lock_put_max_retry &&
                        (code == null || ResultCode.CONNERROR.equals(result.getRc()) || ResultCode.TIMEOUT.equals(result.getRc()) || ResultCode.UNKNOW.equals(result.getRc()))) {
                    return tryLock(timeout);
                }
            } else if (result.getValue() != null && locker.getValue().equals(result.getValue().getValue())) {
                // 【注意】其实这里线程复用时,ThreadName有相同风险,可以改为uuid逻辑,复用锁传入uuid。
                // 若是自己的锁,自己继续用
                gotLock = true;
                return true;
            }
            // 到这里表示没有拿到锁
            return false;
        }
        public void unlock() {
            if (gotLock) {
                ResultCode invalidCode = locker.ldbTairManager.invalid(NAMESPACE, lockKey);
                gotLock = false;
            }
        }
        public void lock() {
            throw new NotImplementedException();
        }
        public void lockInterruptibly() throws InterruptedException {
            throw new NotImplementedException();
        }
        public boolean tryLock(long l, TimeUnit timeUnit) throws InterruptedException {
            throw new NotImplementedException();
        }
        public Condition newCondition() {
            throw new NotImplementedException();
        }
    }
    // 【注意】其实这里线程复用时,ThreadName有相同风险,可以改为uuid逻辑,复用锁传入uuid。
    private String getValue() {
        return getHostname() + ":" + Thread.currentThread().getName();
    }
    /**
     * 获得机器名
     *
     * @return
     */
    public static String getHostname() {
        try {
            return InetAddress.getLocalHost().getHostName();
        } catch (UnknownHostException e) {
            return "[unknown]";
        }
    }
    public void setLdbTairManager(TairManager ldbTairManager) {
        this.ldbTairManager = ldbTairManager;
    }
}
  • lock之后,程序发布或者进程crash,trylock就永远false了 每次发布,总发现有些异常数据,拿不到锁,不能继续向前走;
    仔细分析,原来tair的lock,一直没能释放; 要解决这个问题,可以先不管原因,无脑的给tair
    put加上超时时间就行,这样业务至少可以自行恢复。 但是,这个超时时间需要仔细考虑把握,需要在业务承受范围之内。

注: 像程序发布、进程crash这种情况,是无可避免的让锁没机会释放。还有其他可能性,大多是bug了。。

  • tair存在让人苦恼的超时问题,即使千分之1,本业务有时也不能容忍 超时请重试;
    锁应该是能够经得起复查的(类似偏向锁):A拿到的锁,没有unlock之前,无论A重试检查多少次,都是A的!
    既然用的是ldb缓存,它是key-value结构的,前面version控制等,都只用到了key。 这里,我们可以从tair
    value里做文章:让value包含机器ip+线程name,trylock内先get value做检查

  • 若是get超时怎么办? 超时无法避免,还是要靠重试!(前提是逻辑可以重试)

if (result == null || ResultCode.CONNERROR.equals(result.getRc()) || ResultCode.TIMEOUT.equals(result.getRc()) || ResultCode.UNKNOW.equals(result.getRc())) // get timeout retry case
    result = locker.ldbTairManager.get(NAMESPACE, key);
if (result == null || ResultCode.CONNERROR.equals(result.getRc()) || ResultCode.TIMEOUT.equals(result.getRc()) || ResultCode.UNKNOW.equals(result.getRc())){ // 还是超时,则留下日志痕迹
    logger.error("ldb tair get timeout. key:{}.",key);
    return false;
}

进一步的,我们还可以对get/put的retry做次数控制;
真实线上的情况,一般一次retry就能解决问题,次数多了,反而可能导致雪崩,需要慎重;

  • 多一次Get的性能影响
    若是锁已经被持有,那么get之后,麻烦发现被持有,直接返回失败;这时,并不会再次put,开销是一样的;(甚至get的开销,比put要小,至少不会占用put的限流阈值)
    若是没人持有锁,确实这时get有些浪费的,但是为了锁可以复查这个特性(可重试)、为了能解决超时这个问题,我认为还是值得的。在实际场景中,开发者自己可以评估是否需要。比如:拿前面的uuid的样例API讲,若不需要这个特性时,就不传入uuid,那么实现代码里,可以自动降级为只有一个put的锁实现;

批量锁
批量锁,主要注意拿锁的顺序和释放锁相反。

if(trylock("A") && trylock("B") && trylock("C")){
try{
    // do something
}finally{  // 注意这里的顺序要反过来
    unlock("C");
    unlock("B");
    unlock("A");
}
}

2、基于ZooKeeper临时顺序节点
ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。

核心

  • 对于ZK来说,实现分布式锁的核心是:临时顺序节点
  • 关于ZK的节点种类暂且不表,只要知道有一种节点叫做临时顺序节点就行了
  • 临时
    临时表示在客户端创建某节点后,如果客户端经过一段时间跟服务端之间失去了心跳,说明客户端已经掉线了,那么这个节点就会被自动删除(这一点跟 Redis key 的过期时间类似)
  • 顺序
    顺序的意思是在一个node下面生成的子节点是按顺序的,每个子节点都有一个唯一编号,并且这个编号是按顺序自增的。

原理

  • 临时顺序节点再加上 ZK 的监听机制就可以实现分布式锁了
  • 创建一个目录mylock
  • 线程A想获取锁就在mylock目录下创建临时顺序节点
  • 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁
  • 线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点
  • 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

实现
Curator
它是Apache开源的一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。
优点

  • ZK本身是集群部署,避免了单机故障
  • 顺序节点,不用考虑过期时间设置问题
  • 具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。

缺点

  • 实现较为复杂
  • 非缓存机制,大量频繁创建删除节点会影响 ZK 集群性能

3、数据库锁
基于数据库的分布式锁个人觉得性能不是很好,在高并发的情况下对数据库服务器的压力过大,会影响业务,不建议使用,不过从学习的角度,还是有必要了解一下具体的实现方式。
基于数据库的分布式锁的实现大致有两种方式,这里的数据库我们以 MySQL 为例。两种方案的实现都需要一个额外的表,并且要有一个唯一索引字段。
a、阻塞式语句(排他锁/悲观锁)
for update

public boolean lock(){
    connection.setAutoCommit(false);
    while(true){
        try {
                result = select * from methodLock where method_name = xxx for update;
            if(result == null) {
                return true;
            }
        } catch (Exception e) {
            }
            sleep(1000);
        }
    return false;
}

我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

public void unlock(){
    connection.commit();  // 手动提交事务
}
  • 在实施的时候,需要关闭事务的自动提交,然后执行SQL获得锁
  • 如果获得锁成功,执行下面的业务逻辑,如果没有获取到锁,则会阻塞,一直等待
  • 业务执行结束后,手动提交事务
  • 如果程序在执行提交事务失败,异常或者服务宕机后,数据库会自动释放锁,以免导致死锁
  • 这里有个问题就是如果在高并发的情况下,很多线程都没有获得锁,都在阻塞等待,这样会导致数据库的服务器压力过大,会影响数据库的服务。这个是要注意的,这也是我不建议的地方,容易出现瓶颈,毕竟没有缓存高效。

b、非阻塞式语句(基于表记录的增删)

表method_lock
字段:id、method_name、desc、update_time、expire_time、ip
insert into method_lock
delete from method_lock where method_name = "methodName"
  • 核心思想:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,插入失败则未获得到锁。执行完成后删除对应的行数据释放锁。
    因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
  • 成功获得的锁后就可以执行业务逻辑,在执行完业务逻辑后就可以删除插入的记录。
  • 这种方案存在的问题是无法设置锁的失效时间,需要其他手段来清理超时数据,为了支持可重入,需要将主机和服务的信息一起保存。

c、乐观锁
CAS原理:Compare And Swap
ABA问题
乐观锁假设认为数据一般情况下不会造成冲突,只有在进行数据的提交更新时,才会检测数据的冲突情况,如果发现冲突了,则返回错误信息
在表中添加一个版本号控制字段version,默认为0,每次线程获取锁,执行相关操作,version增加1

-- 添加版本号控制字段
ALTER TABLE table ADD COLUMN version INT DEFAULT '0' NOT NULL AFTER t_bonus;
-- 线程1查询,当前left_count为1,则有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
-- 线程2查询,当前left_count为1,有记录,当前版本号为1234
select left_count, version from t_bonus where id = 10001 and left_count > 0
-- 线程1,更新完成后当前的version为1235,update状态为1,更新成功
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234
-- 线程2,更新由于当前的version为1235,udpate状态为0,更新失败,再针对相关业务做异常处理
update t_bonus set version = 1235, left_count = left_count-1 where id = 10001 and version = 1234

d、性能对比
悲观锁实现方式是独占数据,其它线程需要等待,不会出现修改的冲突,能够保证数据的一致性,但是依赖数据库的实现,且在线程较多时出现等待造成效率降低的问题。一般情况下,对于数据很敏感且读取频率较低的场景,可以采用悲观锁的方式
乐观锁可以多线程同时读取数据,若出现冲突,也可以依赖上层逻辑修改,能够保证高并发下的读取,适用于读取频率很高而修改频率较少的场景
e、优点
容易理解和实现,但是细节要注意
f、缺点及优化点
高并发的情况下性能不好,阻塞式的情况下很多链接不释放会拖垮数据库服务
数据库的行锁会因为 MySQL 的查询优化而失效
因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换
不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁
没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据
不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取
在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。
4、总结
上面的三种实现方式,没有在所有场合都是完美的,所以,应根据不同的应用场景选择最适合的实现方式。
在分布式环境中,对资源进行上锁有时候是很重要的,比如抢购某一资源,这时候使用分布式锁就可以很好地控制资源。
当然,在具体使用中,还需要考虑很多因素,比如超时时间的选取,获取锁时间的选取对并发量都有很大的影响,上述实现的分布式锁也只是一种简单的实现,主要是一种思想。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值