参考&鸣谢
分布式最佳实践:分布式锁
几种分布式锁的实现方式
分布式锁的几种实现方式~
在传统的单体服务中,我们经常会遇到多线程对于单一资源的抢占导致的线程安全问题以及对数据库数据操作的一致性问题,如果是在单体系统中,我们可以很方便的使用编程语言提供的锁以及数据库事务来解决这些问题。
一旦单体系统转为分布式架构,那么本地事务和线程锁就无法满足跨进程的锁效果;分布式锁则是用于进程间同步访问共享资源的一种方式,通过全局共享来实现全局锁的效果,保证数据的一致性。
总的来说,在分布式系统中,当我们期望一个操作(一个请求、一个方法、一个数据库操作…)在整个系统中同一时间只能有一个线程执行,那我们就需要用到分布式锁; 抽象来看就是两个场景:
分布式锁应该具备的特性:
先去干,能不能干,能不能干成先不管,这就是乐观心态。在开发过程中,乐观锁用的非常多,比如典型的 CAS ;在不加锁的情况下保证数据的一致性。
使用方式也很简单,只需要在表中添加一个版本号的字段,每次对数据进行修改的时候,通过版本来确定是否能够更新 update xx set version = OLD_VERSION+1 where id = ID and version = OLD_VERSION
, 如果更新不成功,客户端可以选择是否重试。当然,需要加上索引。
可见这种方式的优势其实很明显,不加锁,使用简单。但也有一些局限性
先自我审查自己能不能干,能不能干成,如果答案是no,那么就等着(阻塞)或先溜(返回),这就是悲观心态。悲观锁在 access token 模式更加适用。
使用方式同样很好理解(这只是基于数据库的悲观锁的一种实现方式)
// 1. 创建资源锁表
CREATE TABLE `resource_lock` (`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',`lock_name` varchar(64) NOT NULL COMMENT '锁名',`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`),UNIQUE KEY `uidx_method_name` (`lock_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;//2. 获取锁(插入成本表示获取到锁
INSERT INTO resource_lock (lock_name) VALUES ('lockName');//3. 释放锁
delete from resource_lock where lock_name ='lockName';
复制代码
乍一看好像很简单,如果程序一直保证正确执行,这种方式好像也行,但没如果… 对于一个分布式系统,服务宕机是会出现的,所以还需要考虑一些新的可能发生的问题
这么一分析…为了确保悲观锁的功能完整性,实现也会越来越复杂…
除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。
我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于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,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆
什么是CP架构和AP架构?
我们知道一个分布式系统不可能同时满足数据一致性(consistency)、服务可用性(availability)、分区容错性(partition-tolerance)。
现实情况下,我们面对的是一个不可靠的网络、有一定概率宕机的设备,这两个因素都会导致Partition,因而分布式系统实现中 P 是一个必须项,而不是可选项。
对于分布式系统工程实践,CAP理论更合适的描述是:在满足分区容错的前提下,没有算法能同时满足数据一致性和服务可用性。
因此,我们需要在C和A之间进行取舍:
- CP架构(刚性事务):如果要满足数据的强一致性,就必须在一个服务数据库锁定的同时,对分布式服务下的其他服务数据资源同时锁定。等待全部服务处理完业务,才可以释放资源。此时如果有其他请求想要操作被锁定的资源就会被阻塞,这样就是满足了CP。达到了强一致性和弱可用性。
- AP架构(柔性事务):如果要满足服务的的强可用性,每个服务就可以各自独立执行本地事务,而无需相互锁定其他服务的资源。在各个服务的事务尚未完全处理完毕时,如果去访问数据库,可能会遇到各个节点数据不一致的情况。然后我们还需要一些措施,使得经过一段时间后,各个节点的数据最终达到一致性。这样就是满足了AP。达到了弱一致性(最终一致性)和强可用性。
既然想到存储用缓存来做,那必然想到的第一个就是 Redis 了,Redis 也很给力,可以很好的支撑分布式锁的能力,提供了比较好用的命令
setnx
: 当且仅当 key 不存在,将 key 的值设为 value ,并返回1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。expire
: 为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。大致的流程如下图
setnx("lock",UUID)
尝试获取到锁,Redis 的实现保证了只会有一个 client 成功,假如 client A 运气好成功了expire("lock",10)
如果程序能够正常走,好像也没什么问题…但我们知道分布式架构中,网络是不可靠的,如果在设置过期时间前 client B 挂掉咯,那就 GG 了,因为没有设置过期时间,那就成死锁了… 就像下面这样
所以我们需要保证 setnx
和 expire
的原子性。在 Redis 2.6.12 之后增强了 setnx
命令,可以同时设置过期时间,从而保证原子性。
解决了死锁问题,再来看看过期时间的问题,我们如何判断我们应该设置多长时间的过期时间?
其实我们想要到达一种效果,如果能够自动续期,锁快要过期了,但是业务操作还没有处理完,就自动对锁进行续期。Java 中的 Redisson 客户端就通过 watch dog 机制(守护线程)来支持这个功能。
通过 Redisson 客户端获取锁时会创建一个守护线程,通过守护线程来定期 check 过期时间,如果业务逻辑还在运行,那么就会续时。如果程序宕机,那么守护线程也会一起挂掉,redis 中的锁也将不会再次续时,最后过期。从而自动实现续期且不会出现死锁的问题。
简单回顾一下,我们解决了
在单机模式下看起来已经没什么问题了。而在生产环境下一般都会是集群模式,比如哨兵模式。得益于 Redis 的 AP 架构,选择了可用性,使得其性能非常好,但也正是因为AP架构,可能会导致数据丢失的情况。
当然,这个是非常极端的情况下会出现的问题;虽然 Redis 之父 Antirez 提出来了分布式锁的一种 「健壮」 的实现算法 RedLock,但依旧还是会有新的问题,比如节点奔溃重启、时钟跳跃…
总的来看,基于 Redis 实现分布式锁是很常用的,性能也比较高,满足绝大部分业务场景,如果我们能够接受非常极端情况下带来的锁丢失问题,Redis 分布式锁是个很好的选择。
Zookeeper 是一种提供「分布式服务协调」的中心化服务,是以 Paxos 算法为基础实现的。Zookeeper 采用的是 CP 架构,选择了强一致性,这也就意味着不会像 Redis 那样出现数据丢失的情况(主从切换时),但为了实现强一致性,那么性能肯定是要比 Redis 差一些。
使用 Zookeeper 来实现分布式锁是比较简单的
如果 client 宕机,那么 session 就会结束,临时节点也会自动删除,其它 client 就可以创建 lock
节点。
session 的维护是依赖于 client 的定时心跳来维护的,也就是说,如果 client 没有及时的给 Zookeeper 发送心跳检查,那么 Zookeeper 就会认为这个 session 已经过期了,就会删除调临时节点。比如出现长时间的 GC 或者长时间的网络延迟,都可能会导致临时节点被删除的可能。
对于 Zookeeper 来说,实现分布式锁从使用者角度来看比较简单,不需要考虑太多的东西,比如过期时间的设置。但维护成本会比较高,性能相对 Redis 也会差一些,以及可能会出现长时间失联导致的节点数据丢失的问题。
上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)
缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库
上一篇:sqlmap 结构与原理
下一篇:进程创建的基本过程