Java岗大厂面试百日冲刺【Day54】— Redis4 (日积月累,每日三题)
大家好,我是陈哈哈,北漂五年。相信大家和我一样,
都有一个大厂梦
,作为一名资深Java选手,深知面试重要性,接下来我准备用100天时间,基于Java岗面试中的高频面试题,以每日3题
的形式,带你过一遍热门面试题及恰如其分的解答。
一路走来,随着问题加深,发现不会的也愈来愈多。但底气着实足了不少,相信不少朋友和我一样,日积月累才是最有效的学习方式!想起高三时一个同学的座右铭:只有沉下去,才能浮上来。
共勉(juan)。
这是由蒋玉树先生所拍摄的照片《小店》
在四川凉山,母亲临时有事,让自己的女儿帮忙看店
突然天上下起了雪,在这个只有一面墙的小店里
女孩儿一边搓着手,一边欣赏着美景
这是我们之前文章结尾《一张照片背后的故事》模块的其中一张,今天看到还是挺扎心的,决定这个栏目重新打开,继续分享给大家。
@TOC
本栏目Java开发岗高频面试题主要出自以下各技术栈:Java基础知识
、集合容器
、并发编程
、JVM
、Spring全家桶
、MyBatis等ORMapping框架
、MySQL数据库
、Redis缓存
、RabbitMQ消息队列
、Linux操作技巧
等。
面试题1:分布式id生成方案有哪些?
分布式系统中我们会对一些数据量大的业务进行分拆,如:用户表,订单表。因为数据量巨大一张表无法承接,就会对其进行分库分表。但一旦涉及到分库分表,就会引申出分布式系统中唯一主键ID的生成问题。
唯一ID的特性:
- 全局唯一
- 趋势有序,方便进行时间先后判断
- 高可用
- 信息安全,ID虽然趋势有序,但是不可以被看出规则,免得被爬,例如爬取你项目的商品URL列表是有序的https://xxxx.xxx/1-10000,有手就能爬
延伸:基于MAC地址生成UUID的算法造成的MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。
常见的方案有:UUID,数据库主键自增,Redis自增ID,雪花算法。
描述 | 优点 | 缺点 | |
---|---|---|---|
UUID | UUID是通用唯一标识码的缩写,其目的是让分布式系统中的所有元素都有唯一的辨识信息,而不需要通过中央控制器来指定唯一标识。生成算法的核心思想是结合机器的网卡、当地时间、一个随记数来生成UUID。 | 1. 降低全局节点的压力,使主键生成速度更快,因为是本机生成,也没有性能问题; 2. 生成全球唯一的ID,迁移数据容易; |
1. UUID占用16个字符,空间占用多; 2. 无序,数据写入IO随机性很大,索引效率下降; 3. 可读性差 |
数据库主键自增 | MySQL数据库设置主键自增即可自动增长 | 1. INT和BIGINT类型占用空间较小; 2. 主键自动增长,IO写入连续性好; 3. 数字类型查询速度优于字符串 |
1. 并发性能不高,受限于数据库性能,依赖数据库查询; 2. 分库分表,需要改造,复杂; 3. 自增:数据和数据量泄露 |
Redis自增 | Redis计数器,原子性自增 | 不依赖于数据库,且性能优于数据库。 | 1. 需要考虑Reids宕机数据丢失等问题; 2. 自增出现的数据量泄露问题 |
雪花算法(snowflake) | 分布式ID的经典解决方案,twitter开源的分布式ID生成算法,核心思想是:一个Long类型的ID,其中41bit作为毫秒数,10bit作为机器码,12bit作为毫秒内序列号。 | 不依赖外部组件,高性能,低延迟,按时间有序,一般不会造成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来做区分)
课间休息,进入《一张照片背后的故事》环节
张良善在1986年,成为一名西藏运输兵,每条西藏公路上都有他的身影
1992年,张良善怀孕的妻子,重病住院
领导了解情况后批准他回家,可当时西藏的将士们缺衣少食
如果不在恶劣天气来临前运输完物资,将士们很可能有生命危险
他选择留下来,继续为将士们运输物资
但当任务结束后,妻子却因难产而死
最后他亲手雕刻了妻子的墓碑,并跪在坟前痛哭
面试题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
这四个命令来实现。
- setnx:命令表示
如果key不存在,就会执行set命令,若是key已经存在,不会执行任何操作
。 - getset:将key设置为给定的value值,并返回原来的旧value值,若是key不存在就会返回返回nil 。
- expire:
设置key生存时间
,当当前时间超出了给定的时间,就会自动删除key。 - del:删除key,它可以删除多个key,语法如下:DEL key [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实现排他锁流程:
三、共享锁
共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。
定义锁
:通过Zookeeper上的数据节点来表示一个锁,是一个类似于 /lockpath/[hostname]-请求类型-序号 的临时顺序节点获取锁
:客户端通过调用 create 方法创建表示锁的临时顺序节点,如果是读请求,则创建 /lockpath/[hostname]-R-序号 节点,如果是写请求则创建 /lockpath/[hostname]-W-序号节点
判断读写顺序
:大概分为几个步骤- 创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听
- 确定自己的节点序号在所有子节点中的顺序
- 对于读请求:1. 如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑 2. 如果有比自己序号小的子节点有写请求,那么等待
- 对于写请求,如果自己不是序号最小的节点,那么等待
- 接收到Watcher通知后,重复步骤1)
释放锁
:与排他锁逻辑一致
基于Zookeeper实现共享锁流程:
三种方案总的来看:
性能:
缓存 > Zookeeper >= 数据库。
可靠性:
Zookeeper > 缓存 > 数据库
每日小结
今天我们复习了Redis中常考的几个问题,你做到心中有数了么?对了,如果你的朋友也在准备面试,请将这个系列扔给他,如果他认真对待,肯定会感谢你的!!
好了,今天就到这里,学废了的同学,记得在评论区留言:打卡 + 日期。
,给同学们以激励。
- 点赞
- 收藏
- 关注作者
评论(0)