分布式锁的几种实现方式~ 转

目前几乎很多大型网站及应用都是分布式部署的,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发处理相关的API,但是这些API在分布式场景中就无能为力了。也就是说单纯的Java Api并不能提供分布式锁的能力。所以针对分布式锁的实现目前有多种方案。

针对分布式锁的实现,目前比较常用的有以下几种方案:

基于数据库实现分布式锁 基于缓存(redis,memcached,tair)实现分布式锁 基于Zookeeper实现分布式锁

在分析这几种实现方案之前我们先来想一下,我们需要的分布式锁应该是怎么样的?(这里以方法锁为例,资源锁同理)

可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。 这把锁要是一把可重入锁(避免死锁) 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条) 有高可用的获取锁和释放锁功能 获取锁和释放锁的性能要好

基于数据库实现分布式锁

基于数据库表

要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。

当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

创建这样一张数据库表:

CREATE TABLE `methodLock` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
  `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

当我们想要锁住某个方法时,执行以下SQL:

insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)

因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:

delete from methodLock where method_name ='method_name'

上面这种简单的实现有以下几个问题:

1、这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。 2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。 3、这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。 4、这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

当然,我们也可以有其他方式解决上面的问题。

  • 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
  • 非阻塞的?搞一个while循环,直到insert成功再返回成功。
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

基于数据库排他锁

除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。

我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于MySql的InnoDB引擎,可以使用以下方法来实现加锁操作:

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;
}

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁(这里再多提一句,InnoDB引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给method_name添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

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

public void unlock(){
    connection.commit();
}

通过connection.commit()操作来释放锁。

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

  • 阻塞锁? for update语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
  • 锁定之后服务宕机,无法释放?使用这种方式,服务宕机之后数据库会自己把锁释放掉。

但是还是无法直接解决数据库单点和可重入问题。

这里还可能存在另外一个问题,虽然我们对method_name 使用了唯一索引,并且显示使用for update来使用行级锁。但是,MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。

还有一个问题,就是我们要使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆

乐观锁

乐观锁假设认为数据一般情况下不会造成冲突,只有在进行数据的提交更新时,才会检测数据的冲突情况,如果发现冲突了,则返回错误信息

实现方式:

时间戳(timestamp)记录机制实现:给数据库表增加一个时间戳字段类型的字段,当读取数据时,将timestamp字段的值一同读出,数据每更新一次,timestamp也同步更新。当对数据做提交更新操作时,检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,若相等,则更新,否则认为是失效数据。

若出现更新冲突,则需要上层逻辑修改,启动重试机制

同样也可以使用version的方式。

性能对比

(1) 悲观锁实现方式是独占数据,其它线程需要等待,不会出现修改的冲突,能够保证数据的一致性,但是依赖数据库的实现,且在线程较多时出现等待造成效率降低的问题。一般情况下,对于数据很敏感且读取频率较低的场景,可以采用悲观锁的方式

(2) 乐观锁可以多线程同时读取数据,若出现冲突,也可以依赖上层逻辑修改,能够保证高并发下的读取,适用于读取频率很高而修改频率较少的场景

(3) 由于库存回写数据属于敏感数据且读取频率适中,所以建议使用悲观锁优化

总结

总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。

数据库实现分布式锁的优点

直接借助数据库,容易理解。

数据库实现分布式锁的缺点

会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。

操作数据库需要一定的开销,性能问题需要考虑。

使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。

基于redis的分布式锁

相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的,可以解决单点问题。

首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

互斥性。在任意时刻,只有一个客户端能持有锁。 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

可以看到,我们加锁就一行代码:jedis.set(String key, String value, String nxxx, String expx, int time),这个set()方法一共有五个形参:

第一个为key,我们使用key来当锁,因为key是唯一的。

第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。

第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;

第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。

第五个为time,与第四个参数相呼应,代表key的过期时间

总的来说,执行上面的set()方法就只会导致两种结果:

1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。 2. 已有锁存在,不做任何操作。

心细的童鞋就会发现了,我们的加锁代码满足我们可靠性里描述的三个条件。首先,set()加入了NX参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。由于我们只考虑Redis单机部署的场景,所以容错性我们暂不考虑。

错误实例:

使用jedis.setnx()jedis.expire()组合实现加锁

public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {    
    Long result = jedis.setnx(lockKey, requestId);    
    if (result == 1) {    
        // 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁    
        jedis.expire(lockKey, expireTime);    
    }      
}

setnx()方法作用就是SET IF NOT EXIST,expire()方法就是给锁加一个过期时间。乍一看好像和前面的set()方法结果一样,然而由于这是两条Redis命令,不具有原子性,如果程序在执行完setnx()之后突然崩溃,导致锁没有设置过期时间。那么将会发生死锁。网上之所以有人这样实现,是因为低版本的jedis并不支持多参数的set()方法。

解锁:

首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)

总结:

可以使用缓存来代替数据库来实现分布式锁,这个可以提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如redis的setnx方法等。并且,这些缓存服务也都提供了对数据的过期自动删除的支持,可以直接设置超时时间来控制锁的释放。

使用缓存实现分布式锁的优点

性能好,实现起来较为方便。

使用缓存实现分布式锁的缺点

通过超时时间来控制锁的失效时间并不是十分的靠谱。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏软件工程师成长笔记

centOS7安装MySQL流程介绍

1、隐藏密码 如:MySQL 连接远程数据库(192.168.5.116),端口“3306”,用户名为“root”,密码“123456”(这是我乱写的地址哈)...

1902
来自专栏小特工作室

Navi.Soft31.代码生成器(含下载地址)

1系统简介 1.1功能简述 在Net软件开发过程中,大部分时间都是在编写代码,并且都是重复和冗杂的代码.比如:要实现在数据库中10个表的增删改查功能,大部分代码...

2159
来自专栏北京马哥教育

SQLite这么娇小可爱,不多了解点都不行啊

简介 SQLite,是一款轻型的数据库,是遵守ACID的关系型数据库管理系统。它的设计目标是嵌入式的,目前Android和iOS的设备内置的都是SQLite数...

3648
来自专栏小特工作室

Navi.Soft31.代码生成器(含下载地址)

1系统简介 1.1功能简述 在Net软件开发过程中,大部分时间都是在编写代码,并且都是重复和冗杂的代码.比如:要实现在数据库中10个表的增删改查功能,大部分代码...

2018
来自专栏云计算教程系列

如何在Ubuntu 16.04上安装和使用PostgreSQL

关系数据库管理系统是许多网站和应用程序的关键组件。它们提供了一种存储,组织和访问信息的结构化方法。

6161
来自专栏Linux运维学习之路

Linux中MySQL配置文件my.cnf参数优化

MySQL参数优化这东西不好好研究还是比较难懂的,其实不光是MySQL,大部分程序的参数优化,是很复杂的。MySQL的参数优化也不例外,对于不同的需求,还有硬件...

6018
来自专栏王磊的博客

golang操作文件

1、读取文件信息: /* 读取文件信息 */ func readFile(path string) string { fi, err := os.Open(p...

2745
来自专栏Laoqi's Linux运维专列

MySQL性能调优my.cnf详解

MySQL 5.6版本适合在1GB内存VPS上的my.cnf配置文件 [client] port = 3306 socket = /tmp/mysql....

5126
来自专栏一枝花算不算浪漫

Redis的介绍及使用实例.

3739
来自专栏散尽浮华

Mysql优化系列(0)--总结性梳理

对于一个网站来说,在运行很长一段时间后,数据库瓶颈问题会越来越暴露出来。作为运维人员,对数据库做必要的优化十分重要! 下面总结以往查阅到的以及自己工作中的一些优...

2178

扫码关注云+社区

领取腾讯云代金券