前言
系统一旦分布式了之后,通信、缓存、消息、事务、锁、配置、日志、监控、会话等等各种原来单块系统场景下很容易解决的问题,都会变得很复杂,需要引入大量外部的技术。
本文对分布式基础知识点做出一定的总结,适合想要了解分布式或者刚开始接触分布式的程序员阅读。
分布式系列文章:
架构系列——分布式事务
一、分布式锁及其特征
在我们的日常开发中,一个进程中当多线程的去竞争某一资源的时候,我们通常会用一把锁来保证只有一个线程获取到资源。如加上synchronize关键字或着Lock的实现类等操作。
那么,如果是多个进程相互竞争一个资源,如何保证资源只会被一个操作者持有呢?这个时候就用到了分布式锁。
分布式锁的特征:
二、分布式锁的三种实现方式
2.1 基于数据库实现分布式锁
有三种方式实现:
2.1.1 乐观锁——基于表主键唯一做分布式锁
利用主键唯一的特性,如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,当方法执行完毕之后,想要释放锁的话,删除这条数据库记录即可。
2.1.2 乐观锁——基于表字段版本号做分布式锁
这个策略源于 mysql 的 mvcc 机制,使用这个策略其实本身没有什么问题,唯一的问题就是对数据表侵入较大,我们要为每个表设计一个版本号字段,然后写一条判断 sql 每次进行判断,增加了数据库操作的次数,在高并发的要求下,对数据库连接的开销也是无法忍受的。
2.1.3 悲观锁——基于数据库排他锁做分布式锁
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁 (注意: InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给要执行的方法字段名添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。)。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排他锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,通过connection.commit()操作来释放锁。
但是还是无法直接解决数据库单点和可重入问题。
还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。
2.2 基于缓存(Redis等)实现分布式锁
2.2.1 加锁过程
1、使用 Setnx 命令保证互斥性。
2、需要设置锁的过期时间,避免死锁。
3、Setnx 和设置过期时间需要保持原子性,避免在设置 Setnx 成功之后在设置过期时间客户端崩溃导致死锁。
4、加锁的 Value 值为一个唯一标示。可以采用 UUID 作为唯一标示。加锁成功后需要把唯一标示返回给 Client 来用进行解锁操作。
2.2.2 解锁过程
1、需要拿加锁成功的唯一标示要进行解锁,从而保证加锁和解锁的是同一个客户端。
2、解锁操作需要比较唯一标识是否相等,相等再执行删除操作。这 2 个操作可以采用 Lua 脚本方式使 2 个命令的原子性。
2.2.3 死锁问题
设置键过期时间,超过这个时间即给key删除掉。
2.2.4 锁过期问题
假如服务A加锁成功,并且设置10s过期时间,但由于业务复杂,执行时间过长,10s内还没执行完,此时锁已经被redis自动释放掉了。此时服务B就重新获取到了该锁,服务B开始执行他的业务,服务A在执行到第12s的时候执行完了,那么服务A会去释放锁,则此时释放的却是服务B刚获取到的锁。
这会有两个问题:
1. 锁过期;
2. 释放其他服务锁。
我们可以这样做,在锁将要过期的时候,如果服务还没有处理完业务,那么将这个锁再续一段时间。比如设置key在10s后过期,那么再开启一个守护线程,在第8s的时候检测服务是否处理完,如果没有,则将这个key再续10s后过期。
在Redisson(Redis SDK客户端)中,就已经帮我们实现了这个功能,这个自动续时的我们称其为”看门狗”。
2.2.5 释放其他服务的锁的问题
每个服务在设置value的时候,带上自己服务的唯一标识,如UUID,或者一些业务上的独特标识。这样在删除key的时候,只删除自己服务之前添加的key就可以了。
如果需要先查看锁是否是自己服务添加的,需要先get取出来判断,然后再进行del。这样的话就无法保证原子性了。
我们可以通过Lua脚本,将这两个操作合并成一个操作,就可以保证其原子性了。
2.2.6 redis宕机问题
这个时候就得引入redis集群了。
但是涉及到redis集群,就会有新的问题出现,假设是主从集群,且主从数据并不是强一致性。当主节点宕机后,主节点的数据还未来得及同步到从节点,进行主从切换后,新的主节点并没有老的主节点的全部数据,这就会导致刚写入到老的主节点的锁在新的主节点并没有,其他服务来获取锁时还是会加锁成功。此时则会有2个服务都可以操作公共资源,此时的分布式锁则是不安全的。
redis的作者也想到这个问题,于是他发明了RedLock。
要实现RedLock,需要至少5个实例(官方推荐),且每个实例都是master,不需要从库和哨兵。
1、客户端先获取当前时间戳T1;
2、客户端依次向5个master实例发起加锁命令,且每个请求都会设置超时时间(毫秒级,注意:不是锁的超时时间),如果某一个master实例由于网络等原因导致加锁失败,则立即想下一个master实例申请加锁;
3、当客户端加锁成功的请求大于等于3个时,且再次获取当前时间戳T2,
当时间戳T2 - 时间戳T1 < 锁的过期时间。则客户端加锁成功,否则失败;
4、加锁成功,开始操作公共资源,进行后续业务操作;
5、加锁失败,向所有redis节点发送锁释放命令。
这样的话分布式锁就真的没问题了嘛?
1、得5个redis实例,成本大大增加;
2、可以通过上面的流程感受到,这个RedLock锁太重了;
3、主从切换这种场景绝大多数的时候不会碰到,偶尔碰到的话,保证最终的兜底操作我觉得也没啥问题。
4、分布式系统中的NPC问题;
2.2.7 NPC问题
分布式系统最大的敌人可能就是NPC了,在这里它是Network Delay, Process Pause, Clock Drift的首字母缩写。我们先看看具体的NPC问题是什么:
Network Delay,网络延迟。虽然网络在多数情况下工作的还可以,虽然TCP保证传输顺序和不会丢失,但它无法消除网络延迟问题。
Process Pause,进程暂停。有很多种原因可以导致进程暂停:比如编程语言中的GC(垃圾回收机制)会暂停所有正在运行的线程;再比如,我们有时会暂停云服务器,从而可以在不重启的情况下将云服务器从一台主机迁移到另一台主机。我们无法确定性预测进程暂停的时长,你以为持续几百毫秒已经很长了,但实际上持续数分钟之久进程暂停并不罕见。
Clock Drift,时钟漂移。现实生活中我们通常认为时间是平稳流逝,单调递增的,但在计算机中不是。计算机使用时钟硬件计时,通常是石英钟,计时精度有限,同时受机器温度影响。为了在一定程度上同步网络上多个机器之间的时间,通常使用NTP协议将本地设备的时间与专门的时间服务器对齐,这样做的一个直接结果是设备的本地时间可能会突然向前或向后跳跃。
2.3 基于Zookeeper实现分布式锁
一个ZooKeeper分布式锁,首先需要创建一个父节点,尽量是持久节点(PERSISTENT类型),然后每个要获得锁的线程,都在这个节点下创建个临时顺序节点。由于ZK节点,是按照创建的次序,依次递增的。
为了确保公平,可以简单的规定:编号最小的那个节点,表示获得了锁。所以,每个线程在尝试占用锁之前,首先判断自己是排号是不是当前最小,如果是,则获取锁,也就是谁先创建的节点,谁获取锁。
ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。基于ZooKeeper实现分布式锁的步骤如下:
(1)创建一个目录mylock;
(2)线程A想获取锁就在mylock目录下创建临时顺序节点;
(3)获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
(4)线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
(5)线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。
这里推荐一个Apache的开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。
优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。
缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。
参考文献:
[1].Redis实现分布式锁
[2].Redis实现分布式锁的7种方案,及正确使用姿势!
[3].分布式锁看这篇就够了
[4].分布式锁概述
[5].一文搞懂 Redis 分布式锁
[6].Zookeeper分布式锁