面试题1:分布式id生成方案有哪些?
分布式系统中我们会对一些数据量大的业务进行分拆,如:用户表,订单表。因为数据量巨大一张表无法承接,就会对其进行分库分表。但一旦涉及到分库分表,就会引申出分布式系统中唯一主键ID的生成问题。
唯一ID的特性:
- 全局唯一
- 趋势有序,方便进行时间先后判断
- 高可用
- 信息安全,ID虽然趋势有序,但是不可以被看出规则,免得被爬,例如爬取你项目的商品URL列表是有序的https://xxxx.xxx/1-10000,有手就能爬
延伸:基于MAC地址生成UUID的算法造成的MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
常见的方案有:UUID,数据库主键自增,Redis自增ID,雪花算法。
描述 | 优点 | 缺点 |
---|
追问1:雪花算法生成的ID由哪些部分组成?
如上图的所示,Snowflake 算法由下面几部分组成:
- 1位符号位:
由于 long 类型在 java 中带符号的,最高位为符号位,正数为 0,负数为 1,且实际系统中所使用的ID一般都是正数,所以最高位为 0。
- 41位时间戳(毫秒级):
需要注意的是此处的 41 位时间戳并非存储当前时间的时间戳,而是存储时间戳的差值(当前时间戳 - 起始时间戳),这里的起始时间戳一般是ID生成器开始使用的时间戳,由程序来指定,所以41位毫秒时间戳最多可以使用 (1 << 41) / (1000x60x60x24x365) = 69年。
- 10位数据机器位:
包括5位数据标识位和5位机器标识位,这10位决定了分布式系统中最多可以部署 1 << 10 = 1024 s个节点。超过这个数量,生成的ID就有可能会冲突。
- 12位毫秒内的序列:
这 12 位计数支持每个节点每毫秒(同一台机器,同一时刻)最多生成 1 << 12 = 4096个ID加起来刚好64位,为一个Long型。
SnowFlake算法实现了:
- 生成的id按时间趋势递增
- 唯一,整个分布式系统内不会产生重复id(datacenterId和workerId来做区分)
- 更多试题链接
面试题2:分布式锁在项目中有哪些应用场景
说使用场景前我们要知道为什么用分布式锁,正所谓“从哪来,到哪去”。在从前用户体量小时,单机就可以满足用户请求数,虽然有一定的并发度,但通过简单的锁机制就可以控制资源获取。随着业务体量增大,并发度单机抗不住了,为满足业务高可用开始使用集群,集群间的并发事务毕竟不能通过各台的单机锁实现,各玩儿各的还不乱套了,于是就出现了分布式锁。
分布式锁是协调集群中多应用之间的共享资源的获取的一种方式,可以说它是一种约束、规则
。
那么分布式锁应该满足什么条件呢?也就是它应该具备怎样的约束、规则?
锁的互斥性
可重入
高效的加锁和解锁
阻塞、公平
面试题3:分布式锁有哪些解决方案
分布式锁在面试中问到的常见实现方式有以下三种: 数据库分布式锁
、 Redis实现分布式锁
、 ZooKeeper实现分布式锁
。
3-1、数据库分布式锁
该方案利用了主键惟一的特点,若多个请求同时提交到数据库,基于ACID特性,能保证只有一个线程能够操做成功。
如果是使用悲观锁(排它锁) select ... where ... for update
的加锁方式,由于其复杂的加锁和解锁、事务等一系列消耗性能的操作,满足不了多少并发。
而乐观锁是基于 版本号控制
的方式实现,类似于 CAS(Compare And Swap 比较并替换)
锁,它认为操作的过程并不会存在并发的情况,只有在update version的时候才会去比较。
乐观锁实现方式还是存在很多问题的,一个是 并发性能问题
,再者 不可重入
以及 没有失效时间的功能
、 非公平锁
,只要当前的库表中已经存在该信息,执行插入就会失败。
当然也有相对的解决方案:比如针对 不可重复
的问题,可以通过增加字段保存当前线程的信息以及可重复的次数,只要判断是当前线程,可重复的次数就会+1,每次执行释放锁就会-1,直到为0。
再比如针对 非公平锁
可以增加一个中间表的形式,作为一个排队队列,竞争的线程都会按照时间存储于这个中间表,当要某个线程尝试获取某个方法的锁的时候,检查中间表中是否已经存在等待的队列来解决。每次只要获取中间表中最小的时间的锁,也能实现公平的排队等候效果。
3-2、基于redis实现分布式锁
Redis实现分布式锁的方式有多种,可以使用 setnx
、 getset
、 expire
、 del
这四个命令来实现。
如果key不存在,就会执行set命令,若是key已经存在,不会执行任何操作
设置key生存时间
加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间。
SET lock_key random_value NX PX 10000
值得注意的是:
- random_value 是客户端生成的唯一的字符串。
- NX 代表只在键不存在时,才对键进行设置操作。
- PX 10000 设置键的过期时间为10000毫秒。
说到Redis分布式锁,目前基本都是直接引入了Redisson框架,做了很好的封装,非常简便,看看下面的代码片段感受一下:
Rlock rlock = redisson.getLock("myLock");
rlock.lock();
rlock.unlock();
是不是感觉很简单,因为多线程竞争共享资源的复杂的过程它在底层都帮你实现了,屏蔽了这些复杂的过程,而你也就成为了优秀的API调用者。
当然,面试中大概率会考察其实现原理,下面通过Redisson的架构图,看看这个开源框架对Redis分布式锁的实现原理;
3-2-1、加锁机制
- 线程去获取锁,获取成功:执行lua脚本,保存数据到redis数据库。(lua脚本实现了业务执行的
原子性
,语法简单好上手) - 线程去获取锁,获取失败:一直通过while自旋尝试获取锁,获取成功后,执行lua脚本,保存数据到redis数据库。
3-2-2、watch dog自动延期机制
在一个分布式环境下,假如一个线程获得锁后,突然服务器宕机了,那么在一定时间(过期时间)后这个锁要自动释放,如果不设置默认是30s,这个过期时间的目的是防止死锁的发生。
而实际开发中会有这种情况,比如过期时间设了10s,但由于网络延迟或接口本身慢查询等原因,导致请求时间超过10s还没结束(注意,这里并未宕机,不应该释放锁),但在10s时锁被释放,其他线程进来也操作该数据,就可能造成数据不一致问题。
所以这个时候看门狗就出现了,它的作用就是线程1业务还没有执行完,时间就过了,线程1还想持有锁的话,就会启动一个 watch dog
后台线程,不断的延长锁key的生存时间。
值得注意的是,正常这个看门狗线程是不启动的,还有就是这个看门狗启动后对整体性能也会有一定影响,所以不建议开启 watch dog
。
3-2-3、为啥要用lua脚本呢?
这个不用多说,主要是如果你的业务逻辑复杂的话,通过封装在lua脚本中一起发送给redis,像存储过程,因为redis是单线程的,这样就保证这段复杂业务逻辑执行的原子性。
3-2-4、可重入锁机制
可重入锁是指一个锁在被一个线程持有后,在该线程未释放锁前的任何时间内,只要再次访问被该锁锁住的函数区都可以再次进入对应的锁区域。可重入锁有一个可重入度的概念,即每次重新进入一次该锁的锁住的区域都会递增可重入度,每次退出一个该锁锁住的区域都会递减可重入度,最终释放全部锁后,可重入度为0。
可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,如果没有可重入锁的支持,在第二次尝试获得锁时将会进入死锁状态。
Redisson的可重入锁机制和Hash类型有关,比如下面是一个key值:
127.0.0.1:6379> HGETALL myLock
1) "285475da-9152-4c83-822a-67ee2f116a79:52"
2) "2"
Hash类型相当于我们java的 <key,<key1,value>>
,Hash数据类型的key值包含了当前线程信息,hash 结构的 key 是锁的名称,key1 是客户端 ID,value 是该客户端加锁的次数。
- 缺点
- 获取锁是非阻塞
- 非公平锁,不支持须要公平锁的场景
- redis主从存在延迟,在master宕机发生主从切换时,可能会致使锁失效
3-3、基于zookeeper实现分布式锁
基于以上两种实现方式,有了基于zookeeper实现分布式锁的方案。由于zookeeper有以下特点:
- 维护了一个有层次的数据节点,类似文件系统。
- 有以下数据节点:临时节点、持久节点、临时有序节点(分布式锁实现基于的数据节点)、持久有序节点。
- zookeeper可以和client客户端通过心跳的机制保持长连接,如果客户端链接zookeeper创建了一个临时节点,那么这个客户端与zookeeper断开连接后会自动删除。
- zookeeper的节点上可以注册上用户事件(自定义),如果节点数据删除等事件都可以触发自定义事件。
- zookeeper保持了统一视图,各服务对于状态信息获取满足一致性。
Zookeeper的每一个节点,都是一个天然的顺序发号器。在每一个节点下面创建子节点时,只要选择的创建类型是有序(EPHEMERAL_SEQUENTIAL 临时有序或者PERSISTENT_SEQUENTIAL 永久有序)类型,那么,新的子节点后面,会加上一个次序编号。这个次序编号,是上一个生成的次序编号加一
- 排他锁(非公平锁)
排他锁核心是保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。
Zookeeper的强一致性特性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即Zookeeper将会保证客户端无法重复创建一个已经存在的数据节点。可以利用Zookeeper这个特性,实现排他锁。
- 定义锁:通过Zookeeper上的数据节点来表示一个锁
- 获取锁:客户端通过调用 create 方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况
- 释放锁:以下两种情况都可以让锁释放
– 当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除
– 正常执行完业务逻辑,客户端主动删除自己创建的临时节点
基于Zookeeper实现排他锁流程:
三、共享锁
共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。
定义锁
获取锁
判断读写顺序
:大概分为几个步骤- 创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听
- 确定自己的节点序号在所有子节点中的顺序
- 对于读请求:1. 如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑 2. 如果有比自己序号小的子节点有写请求,那么等待
- 对于写请求,如果自己不是序号最小的节点,那么等待
- 接收到Watcher通知后,重复步骤1)