大数据技术栈速览之:Redis

Redis是干嘛用的?

Redis可以用作持久化的存储吗?

那Redis怎么进行持久化操作呢?

Redis的内存淘汰机制有哪些?

除了分布式锁、消息队列...我们还可以用Redis做哪些事情?分别利用了Redis的哪个指令?

-----------------------------------

Redis闲谈:构建知识图谱

REmote DIctionary Server(Redis) 是一个由Salvatore Sanfilippo写的key-value存储系统。

Redis是一个开源的使用ANSI 、C语言编写、遵守BSD协议、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。

Redis优点

速度快

Redis单机qps(每秒的并发)可以达到110000次/s,写的速度是81000次/s。那么,Redis为什么这么快呢?

  • 绝大部分请求是纯粹的内存操作,非常快速;

  • 使用了很多查找操作都特别快的数据结构进行数据存储,Redis中的数据结构是专门设计的。如HashMap,查找、插入的时间复杂度都是O(1);

  • 采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁、释放锁操作,没有因为可能出现死锁而导致的性能消耗;

  • 用到了非阻塞I/O多路复用机制。

丰富的数据类型

Redis有5种常用的数据类型:String、List、Hash、set、zset,每种数据类型都有自己的用处。

原子性,支持事务

Redis支持事务,并且它的所有操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。

丰富的特性

Redis具有丰富的特性,比如可以用作分布式锁;可以持久化数据;可以用作消息队列、排行榜、计数器;还支持publish/subscribe、通知、key过期等等。当我们要用中间件来解决实际问题的时候,Redis总能发挥出自己的用处。

Redis和Memcache对比

1) 存储方式

  • Memcache把数据全部存在内存之中,断电后会挂掉,无法做到数据的持久化,且数据不能超过内存大小。

  • Redis有一部分数据存在硬盘上,可以做到数据的持久性。

2) 数据支持类型

  • Memcache对数据类型支持相对简单,只支持String类型的数据结构。

  • Redis有丰富的数据类型,包括:String、List、Hash、Set、Zset。

3) 使用的底层模型

  • 它们之间底层实现方式以及与客户端之间通信的应用协议不一样。

  • Redis直接自己构建了VM机制 ,因为一般的系统调用系统函数,会浪费一定的时间去移动和请求。

4) 存储值大小

  • Redis最大可以存储1GB,而memcache只有1MB。

Redis存在的问题及解决方案

缓存数据库的双写一致性的问题

问题:一致性的问题是分布式系统中很常见的问题。一致性一般分为两种:强一致性和最终一致性,当我们要满足强一致性的时候,Redis也无法做到完美无瑕,因为数据库和缓存双写,肯定会出现不一致的情况,Redis只能保证最终一致性。

解决:我们如何保证最终一致性呢?

  • 第一种方式是给缓存设置一定的过期时间,在缓存过期之后会自动查询数据库,保证数据库和缓存的一致性。

  • 如果不设置过期时间的话,我们首先要选取正确的更新策略:先更新数据库再删除缓存。但我们删除缓存的时候也可能出现某些问题,所以需要将要删除的缓存的key放到消息队列中去,不断重试,直到删除成功为止。

缓存雪崩问题

问题: 我们应该都在电影里看到过雪崩,开始很平静,然后一瞬间就开始崩塌,具有很强的毁灭性。这里也是一样的,我们执行代码的时候将很多缓存的实效时间设定成一样,接着这些缓存在同一时间都会实效,然后都会重新访问数据库更新数据,这样会导致数据库连接数过多、压力过大而崩溃。

解决:

  • 设置缓存过期时间的时候加一个随机值。

  • 设置双缓存,缓存1设置缓存时间,缓存2不设置,1过期后直接返回缓存2,并且启动一个进程去更新缓存1和2。

缓存穿透问题

问题: 缓存穿透是指一些非正常用户(黑客)故意去请求缓存中不存在的数据,导致所有的请求都集中到到数据库上,从而导致数据库连接异常。

解决:

  • 利用互斥锁。缓存失效的时候,不能直接访问数据库,而是要先获取到锁,才能去请求数据库。没得到锁,则休眠一段时间后重试。

  • 采用异步更新策略。无论key是否取到值,都直接返回。value值中维护一个缓存失效时间,缓存如果过期,异步起一个线程去读数据库,更新缓存。需要做缓存预热(项目启动前,先加载缓存)操作。

  • 提供一个能迅速判断请求是否有效的拦截机制。比如利用布隆过滤器,内部维护一系列合法有效的key,迅速判断出请求所携带的Key是否合法有效。如果不合法,则直接返回。

缓存的并发竞争问题

问题:

缓存并发竞争的问题,主要发生在多线程对某个key进行set的时候,这时会出现数据不一致的情况。

比如Redis中我们存着一个key为amount的值,它的value是100,两个线程同时都对value加100然后更新,正确的结果应该是变为300。但是两个线程拿到这个值的时候都是100,最后结果也就是200,这就导致了缓存的并发竞争问题。

解决

  • 如果多线程操作没有顺序要求的话,我们可以设置一个分布式锁,然后多个线程去争夺锁,谁先抢到锁谁就可以先执行。这个分布式锁可以用zookeeper或者Redis本身去实现。

  • 可以利用Redis的incr命令。

  • 当我们的多线程操作需要顺序的时候,我们可以设置一个消息队列,把需要的操作加到消息队列中去,严格按照队列的先后执行命令。

Redis的过期策略

Redis随着数据的增多,内存占用率会持续变高,我们以为一些键到达设置的删除时间就会被删除,但是时间到了,内存的占用率还是很高,这是为什么呢?

Redis采用的是定期删除惰性删除的内存淘汰机制。

定期删除

定期删除和定时删除是有区别的:

  • 定时删除是必须严格按照设定的时间去删除缓存,这就需要我们设置一个定时器去不断地轮询所有的key,判断是否需要进行删除。但是这样的话cpu的资源会被大幅度地占据,资源的利用率变低。所以我们选择采用定期删除,。

  • 定期删除是时间由我们定,我们可以每隔100ms进行检查,但还是不能检查所有的缓存,Redis还是会卡死,只能随机地去检查一部分缓存,但是这样会有一些缓存无法在规定时间内删除。这时惰性删除就派上用场了。

惰性删除

举个简单的例子:中学的时候,平时作业太多,根本做不完,老师说下节课要讲这个卷子,你们都做完了吧?其实有很多人没做完,所以需要在下节课之前赶紧补上。

惰性删除也是这个道理,我们的这个值按理说应该没了,但是它还在,当你要获取这个key的时候,发现这个key应该过期了,赶紧删了,然后返回一个'没有这个值,已经过期了!'。

现在我们有了定期删除 + 惰性删除的过期策略,就可以高枕无忧了吗?并不是这样的,如果这个key一直不访问,那么它会一直滞留,也是不合理的,这就需要我们的内存淘汰机制了。

 

Redis闲谈:Redis数据结构底层探秘

Redis的数据模型

用 键值对 name:"小明"来展示Redis的数据模型如下:

  • dictEntry: 在一些编程语言中,键值对的数据结构被称为字典,而在Redis中,会给每一个key-value键值对分配一个字典实体,就是“dicEntry”。dicEntry包含三部分: key的指针、val的指针、next指针,next指针指向下一个dicteEntry形成链表,这个next指针可以将多个哈希值相同的键值对链接在一起,通过链地址法来解决哈希冲突的问题

  • sds :Simple Dynamic String,简单动态字符串,存储字符串数据。

  • redisObject:Redis的5种常用类型都是以RedisObject来存储的,redisObject中的type字段指明了值的数据类型(也就是5种基本类型)。ptr字段指向对象所在的地址。

RedisObject对象很重要,Redis对象的类型内部编码内存回收共享对象等功能,都是基于RedisObject对象来实现的。

这样设计的好处是:可以针对不同的使用场景,对5种常用类型设置多种不同的数据结构实现,从而优化对象在不同场景下的使用效率。

Redis将jemalloc作为默认内存分配器,减小内存碎片。jemalloc在64位系统中,将内存空间划分为小、大、巨大三个范围;每个范围内又划分了许多小的内存块单位;当Redis存储数据时,会选择大小最合适的内存块进行存储。

Redis支持的数据结构

Redis支持的数据结构有哪些?

如果回答是String、List、Hash、Set、Zset就不对了,这5种是Redis的常用基本数据类型,每一种数据类型内部还包含着多种数据结构。

用encoding指令来看一个值的数据结构。比如:

127.0.0.1:6379> set name tom
OK
127.0.0.1:6379> object encoding name
"embstr"

此处设置了name值是tom,它的数据结构是embstr,下文介绍字符串时会详解说明。

127.0.0.1:6379> set age 18
OK
127.0.0.1:6379> object encoding age
"int"

如下表格总结Redis中所有的数据结构类型:

底层数据结构编码常量object encoding指令输出
整数类型REDIS_ENCODING_INT"int"
embstr字符串类型REDIS_ENCODING_EMBSTR"embstr"
简单动态字符串REDIS_ENCODING_RAW"raw"
字典类型REDIS_ENCODING_HT"hashtable"
双端链表REDIS_ENCODING_LINKEDLIST"linkedlist"
压缩列表REDIS_ENCODING_ZIPLIST"ziplist"
整数集合REDIS_ENCODING_INTSET"intset"
跳表和字典REDIS_ENCODING_SKIPLIST"skiplist"

补充说明

假如面试官问:Redis的数据类型有哪些?

回答:String、list、hash、set、zet

一般情况下这样回答是正确的,前文也提到Redis的数据类型确实是包含这5种,但细心的同学肯定发现了之前说的是“常用”的5种数据类型。其实,随着Redis的不断更新和完善,Redis的数据类型早已不止5种了。

登录Redis的官方网站打开官方的数据类型介绍(https://redis.io/topics/data-types-intro):

发现Redis支持的数据结构不止5种,而是8种,后三种类型分别是:

  • 位数组(或简称位图):使用特殊命令可以处理字符串值,如位数组:您可以设置和清除各个位,将所有位设置为1,查找第一个位或未设置位,等等。

  • HyperLogLogs:这是一个概率数据结构,用于估计集合的基数。不要害怕,它比看起来更简单。

  • Streams:仅附加的类似于地图的条目集合,提供抽象日志数据类型。

本文主要介绍5种常用的数据类型,上述三种以后再共同探索。

2.1 string字符串

字符串类型是Redis最常用的数据类型,在Redis中,字符串是可以修改的,在底层它是以字节数组的形式存在的。

Redis中的字符串被称为简单动态字符串「SDS」,这种结构很像Java中的ArrayList,其长度是动态可变的。

struct SDS<T> {
  T capacity; // 数组容量
  T len; // 数组长度
  byte[] content; // 数组内容
}

content[] 存储的是字符串的内容,capacity表示数组分配的长度,len表示字符串的实际长度。

字符串的编码类型有int、embstr和raw三种,如上表所示,那么这三种编码类型有什么不同呢?

  • int 编码:保存的是可以用 long 类型表示的整数值。

  • raw 编码:保存长度大于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。

  • embstr 编码:保存长度小于44字节的字符串(redis3.2版本之前是39字节,之后是44字节)。

设置一个值测试一下:

127.0.0.1:6379> set num 300
127.0.0.1:6379> object encoding num
"int"
127.0.0.1:6379> set key1 wealwaysbyhappyhahaha
OK
127.0.0.1:6379> object encoding key1
"embstr"
127.0.0.1:6379> set key2 hahahahahahahaahahahahahahahahahahahaha
OK
127.0.0.1:6379> strlen key2
(integer) 39
127.0.0.1:6379> object encoding key2
"embstr"
127.0.0.1:6379> set key2 hahahahahahahaahahahahahahahahahahahahahahaha
OK
127.0.0.1:6379> object encoding key2
"raw"
127.0.0.1:6379> strlen key2
(integer) 45

raw类型和embstr类型对比

embstr编码的结构:

raw编码的结构:

embstr和raw都是由redisObject和sds组成的。不同的是:embstr的redisObject和sds是连续的,只需要使用malloc分配一次内存;而raw需要为redisObject和sds分别分配内存,即需要分配两次内存。

所有相比较而言,embstr少分配一次内存,更方便。但embstr也有明显的缺点:如要增加长度,redisObject和sds都需要重新分配内存。

上文介绍了embstr和raw结构上的不同。重点来了~ 为什么会选择44作为两种编码的分界点?在3.2版本之前为什么是39?这两个值是怎么得出来的呢?

1) 计算RedisObject占用的字节大小

struct RedisObject {
    int4 type; // 4bits
    int4 encoding; // 4bits
    int24 lru; // 24bits
    int32 refcount; // 4bytes = 32bits
    void *ptr; // 8bytes,64-bit system
}
  • type: 不同的redis对象会有不同的数据类型(string、list、hash等),type记录类型,会用到4bits

  • encoding:存储编码形式,用4bits

  • lru:用24bits记录对象的LRU信息。

  • refcount:引用计数器,用到32bits

  • *ptr:指针指向对象的具体内容,需要64bits

计算: 4 + 4 + 24 + 32 + 64 = 128bits = 16bytes

第一步就完成了,RedisObject对象头信息会占用16字节的大小,这个大小通常是固定不变的.

2) sds占用字节大小计算

旧版本:

struct SDS {
    unsigned int capacity; // 4byte
    unsigned int len; // 4byte
    byte[] content; // 内联数组,长度为 capacity
}

这里的unsigned int 一个4字节,加起来是8字节.

内存分配器jemalloc分配的内存如果超出了64个字节就认为是一个大字符串,就会用到embstr编码。

前面提到 SDS 结构体中的 content 的字符串是以字节\0结尾的字符串,之所以多出这样一个字节,是为了便于直接使用 glibc 的字符串处理函数,以及为了便于字符串的调试打印输出。所以我们还要减去1字节 64byte - 16byte - 8byte - 1byte = 39byte

新版本:

struct SDS {
    int8 capacity; // 1byte
    int8 len; // 1byte
    int8 flags; // 1byte
    byte[] content; // 内联数组,长度为 capacity
}

这里unsigned int 变成了uint8_t、uint16_t.的形式,还加了一个char flags标识,总共只用了3个字节的大小。相当于优化了sds的内存使用,相应的用于存储字符串的内存就会变大。

然后进行计算:

64byte - 16byte -3byte -1byte = 44byte

总结:

所以,Redis 3.2版本之后embstr最大能容纳的字符串长度是44,之前是39。长度变化的原因是SDS中内存的优化。

2.2 List

Redis中List对象的底层是由quicklist(快速列表)实现的,快速列表支持从链表头和尾添加元素,并且可以获取指定位置的元素内容。

那么,快速列表的底层是如何实现的呢?为什么能够达到如此快的性能?

罗马不是一日建成的,quicklist也不是一日实现的,起初redis的list的底层是ziplist(压缩列表)或者是 linkedlist(双端列表)。先分别介绍这两种数据结构。

ziplist 压缩列表

当一个列表中只包含少量列表项,且是小整数值或长度比较短的字符串时,redis就使用ziplist(压缩列表)来做列表键的底层实现。

测试:

127.0.0.1:6379> rpush dotahero sf qop doom
(integer) 3
127.0.0.1:6379> object encoding dotahero
"ziplist"

此处使用老版本Redis进行测试,向dota英雄列表中加入了qop痛苦女王、sf影魔、doom末日使者三个英雄,数据结构编码使用的是ziplist。

压缩列表顾名思义是进行了压缩,每一个节点之间没有指针的指向,而是多个元素相邻,没有缝隙。所以 ziplist是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。具体结构相对比较复杂,大家有兴趣地话可以深入了解。

struct ziplist<T> {
    int32 zlbytes; // 整个压缩列表占用字节数
    int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength; // 元素个数
    T[] entries; // 元素内容列表,挨个挨个紧凑存储
    int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}

双端列表(linkedlist)

双端列表大家都很熟悉,这里的双端列表和java中的linkedlist很类似。

从图中可以看出Redis的linkedlist双端链表有以下特性:节点带有prev、next指针、head指针和tail指针,获取前置节点、后置节点、表头节点和表尾节点、获取长度的复杂度都是O(1)。

压缩列表占用内存少,但是是顺序型的数据结构,插入删除元素的操作比较复杂,所以压缩列表适合数据比较小的情况,当数据比较多的时候,双端列表的高效插入删除还是更好的选择

在Redis开发者的眼中,数据结构的选择,时间上、空间上都要达到极致,所以,他们将压缩列表和双端列表合二为一,创建了快速列表(quicklist)。和java中的hashmap一样,结合了数组和链表的优点。

快速列表(quicklist)

  • rpush: listAddNodeHead ---O(1)

  • lpush: listAddNodeTail ---O(1)

  • push:listInsertNode ---O(1)

  • index : listIndex ---O(N)

  • pop:ListFirst/listLast ---O(1)

  • llen:listLength ---O(N)

struct ziplist {
    ...
}
struct ziplist_compressed {
    int32 size;
    byte[] compressed_data;
}
struct quicklistNode {
    quicklistNode* prev;
    quicklistNode* next;
    ziplist* zl; // 指向压缩列表
    int32 size; // ziplist 的字节总数
    int16 count; // ziplist 中的元素数量
    int2 encoding; // 存储形式 2bit,原生字节数组还是 LZF 压缩存储
    ...
}
struct quicklist {
    quicklistNode* head;
    quicklistNode* tail;
    long count; // 元素总数
    int nodes; // ziplist 节点的个数
    int compressDepth; // LZF 算法压缩深度
    ...
}

quicklist 默认的压缩深度是 0,也就是不压缩。压缩的实际深度由配置参数list-compress-depth决定。为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。如果深度为 2,表示 quicklist 的首尾第一个 ziplist 以及首尾第二个 ziplist 都不压缩。

2.3 Hash

Hash数据类型的底层实现是ziplist(压缩列表)或字典(也称为hashtable或散列表)。这里压缩列表或者字典的选择,也是根据元素的数量大小决定的。

如图hset了三个键值对,每个值的字节数不超过64的时候,默认使用的数据结构是ziplist

当我们加入了字节数超过64的值的数据时,默认的数据结构已经成为了hashtable。

Hash对象只有同时满足下面两个条件时,才会使用ziplist(压缩列表):

  • 哈希中元素数量小于512个;

  • 哈希中所有键值对的键和值字符串长度都小于64字节。

压缩列表刚才已经了解了,hashtables类似于jdk1.7以前的hashmap。hashmap采用了链地址法的方法解决了哈希冲突的问题。想要深入了解的话可以参考之前写的一篇博客: hashmap你真的了解吗?(https://blog.csdn.net/qq_32519415/article/details/87006982)

Redis中的字典

Redis中的dict 结构内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的。但是在 dict 扩容缩容时,需要分配新的 hashtable,然后进行渐进式搬迁,这时两个 hashtable 存储的分别是旧的 hashtable 和新的 hashtable。待搬迁结束后,旧的 hashtable 被删除,新的 hashtable 取而代之。

2.4 Set

Set数据类型的底层可以是intset(整数集)或者是hashtable(散列表也叫哈希表)。

当数据都是整数并且数量不多时,使用intset作为底层数据结构;当有除整数以外的数据或者数据量增多时,使用hashtable作为底层数据结构。

127.0.0.1:6379> sadd myset 111 222 333
(integer) 3
127.0.0.1:6379> object encoding myset
"intset"
127.0.0.1:6379> sadd myset hahaha
(integer) 1
127.0.0.1:6379> object encoding myset
"hashtable"

inset的数据结构为:

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;

intset底层实现为有序、无重复数的数组。 intset的整数类型可以是16位的、32位的、64位的。如果数组里所有的整数都是16位长度的,新加入一个32位的整数,那么整个16的数组将升级成一个32位的数组。升级可以提升intset的灵活性,又可以节约内存,但不可逆。

2.5 Zset

Redis中的Zset,也叫做有序集合。它的底层是ziplist(压缩列表)或 skiplist(跳跃表)。

压缩列表前文已经介绍过了,同理是在元素数量比较少的时候使用。此处主要介绍跳跃列表。

跳表

跳跃列表,顾名思义是可以跳的,跳着查询自己想要查到的元素。大家可能对这种数据结构比较陌生,虽然平时接触的少,但它确实是一个各方面性能都很好的数据结构,可以支持快速的查询、插入、删除操作,开发难度也比红黑树要容易的多

为什么跳表有如此高的性能呢?它究竟是如何“跳”的呢?跳表利用了二分的思想,在数组中可以用二分法来快速进行查找,在链表中也是可以的。

举个例子,链表如下:

假设要找到10这个节点,需要一个一个去遍历,判断是不是要找的节点。那如何提高效率呢?mysql索引相信大家都很熟悉,可以提高效率,这里也可以使用索引。抽出一个索引层来:

这样只需要找到9然后再找10就可以了,大大节省了查找的时间。

还可以再抽出来一层索引,可以更好地节约时间:

这样基于链表的“二分查找”支持快速的插入、删除,时间复杂度都是O(logn)。

由于跳表的快速查找效率,以及实现的简单、易读。所以Redis放弃了红黑树而选择了更为简单的跳表。

Redis中的跳跃表:

typedef struct zskiplist {
     // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;
    // 表中节点的数量
    unsigned long length;
    // 表中层数最大的节点的层数
    int level;
 } zskiplist;
typedef struct zskiplistNode {
    // 成员对象
    robj *obj;
    // 分值
    double score;
     // 后退指针
    struct zskiplistNode *backward;
    // 层
    struct zskiplistLevel {
        // 前进指针
        struct zskiplistNode *forward;
         // 跨度---前进指针所指向节点与当前节点的距离
        unsigned int span;
    } level[];
} zskiplistNode;

zadd---zslinsert---平均O(logN), 最坏O(N)

zrem---zsldelete---平均O(logN), 最坏O(N)

zrank--zslGetRank---平均O(logN), 最坏O(N)

总结

以上大概介绍了Redis的5种常用数据类型的底层实现,希望大家结合源码和资料更深入地了解。

数据结构之美在Redis中体现得淋漓尽致,从String到压缩列表、快速列表、散列表、跳表,这些数据结构都适用在了不同的地方,各司其职。

不仅如此,Redis将这些数据结构加以升级、结合,将内存存储的效率性能达到了极致,正因为如此,Redis才能成为众多互联网公司不可缺少的高性能、秒级的key-value内存数据库。

Redis闲谈:锁的基本概念到Redis分布式锁实现

锁的基本了解

怎么解决呢?加个synchronized关键字。

解决了线程安全的问题,还要考虑到加锁之后的代码执行效率问题:缩短锁的持有时间;减少锁的粒度;锁分离(即:读写分离,把锁分成读锁和写锁,读的锁不需要阻塞,而写的锁要考虑并发问题。)

锁的种类

  • 公平锁:ReentrantLock

  • 非公平锁:Synchronized、ReentrantLock、cas

  • 悲观锁:Synchronized

  • 乐观锁:cas

  • 独享锁:Synchronized、ReentrantLock

  • 共享锁:Semaphore

另外,锁还可以按照偏向锁、轻量级锁、重量级锁来分类。

Redis分布式锁

分布式锁的概念。

上图所示是我们搭建的分布式环境,有三个购票项目,对应一个库存,每一个系统会有多个线程,和上文一样,对库存的修改操作加上锁,能不能保证这6个线程的线程安全呢?

当然是不能的,因为每一个购票系统都有各自的JVM进程,互相独立,所以加synchronized只能保证一个系统的线程安全,并不能保证分布式的线程安全。

所以需要对于三个系统都是公共的一个中间件来解决这个问题。

这里我们选择Redis来作为分布式锁,多个系统在Redis中set同一个key,只有key不存在的时候,才能设置成功,并且该key会对应其中一个系统的唯一标识,当该系统访问资源结束后,将key删除,则达到了释放锁的目的。

4.1 分布式锁需要注意哪些点

1)互斥性

在任意时刻只有一个客户端可以获取锁。

这个很容易理解,所有的系统中只能有一个系统持有锁。

2)防死锁

假如一个客户端在持有锁的时候崩溃了,没有释放锁,那么别的客户端无法获得锁,则会造成死锁,所以要保证客户端一定会释放锁。

Redis中我们可以设置锁的过期时间来保证不会发生死锁。

3)持锁人解锁

解铃还须系铃人,加锁和解锁必须是同一个客户端,客户端A的线程加的锁必须是客户端A的线程来解锁,客户端不能解开别的客户端的锁。

4)可重入

当一个客户端获取对象锁之后,这个客户端可以再次获取这个对象上的锁。

4.2 Redis分布式锁流程

Redis分布式锁的具体流程:

1)首先利用Redis缓存的性质在Redis中设置一个key-value形式的键值对,key就是锁的名称,然后客户端的多个线程去竞争锁,竞争成功的话将value设为客户端的唯一标识。

2)竞争到锁的客户端要做两件事:

  • 设置锁的有效时间 目的是防死锁 (非常关键)

需要根据业务需要,不断的压力测试来决定有效期的长短。

  • 分配客户端的唯一标识,目的是保证持锁人解锁(非常重要)

所以这里的value就设置成唯一标识(比如uuid)。

3)访问共享资源

4)释放锁,释放锁有两种方式,第一种是有效期结束后自动释放锁,第二种是先根据唯一标识判断自己是否有释放锁的权限,如果标识正确则释放锁

4.3 加锁和解锁

4.3.1 加锁

1)setnx命令加锁

set if not exists 我们会用到Redis的命令setnx,setnx的含义就是只有锁不存在的情况下才会设置成功。

2)设置锁的有效时间,防止死锁 expire

加锁需要两步操作,思考一下会有什么问题吗?

假如我们加锁完之后客户端突然挂了呢?那么这个锁就会成为一个没有有效期的锁,接着就可能发生死锁。虽然这种情况发生的概率很小,但是一旦出现问题会很严重,所以我们也要把这两步合为一步。

幸运的是,Redis3.0已经把这两个指令合在一起成为一个新的指令。

来看jedis的官方文档中的源码:

    public String set(String key, String value, String nxxx, String expx, long time) {
        this.checkIsInMultiOrPipeline();
        this.client.set(key, value, nxxx, expx, time);
        return this.client.getStatusCodeReply();
    }

这就是我们想要的!

4.3.2 解锁

  • 检查是否自己持有锁(判断唯一标识);

  • 删除锁。

解锁也是两步,同样也要保证解锁的原子性,把两步合为一步。

这就无法借助于Redis了,只能依靠Lua脚本来实现。

if Redis.call("get",key==argv[1])then
    return Redis.call("del",key)
else return 0 end

这就是一段判断是否自己持有锁并释放锁的Lua脚本。

为什么Lua脚本是原子性呢?因为Lua脚本是jedis用eval()函数执行的,如果执行则会全部执行完成。

Redis分布式锁代码实现

public class RedisDistributedLock implements Lock {

    //上下文,保存当前锁的持有人id
    private ThreadLocal<String> lockContext = new ThreadLocal<String>();

    //默认锁的超时时间
    private long time = 100;

    //可重入性
    private Thread ownerThread;

    public RedisDistributedLock() {
    }

    public void lock() {
        while (!tryLock()){
            try {
                Thread.sleep(100);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }
    }


    public boolean tryLock() {
        return tryLock(time,TimeUnit.MILLISECONDS);
    }


    public boolean tryLock(long time, TimeUnit unit){
        String id = UUID.randomUUID().toString(); //每一个锁的持有人都分配一个唯一的id
        Thread t = Thread.currentThread();
        Jedis jedis = new Jedis("127.0.0.1",6379);
        //只有锁不存在的时候加锁并设置锁的有效时间
        if("OK".equals(jedis.set("lock",id, "NX", "PX", unit.toMillis(time)))){
            //持有锁的人的id
            lockContext.set(id); ①
            //记录当前的线程
            setOwnerThread(t); ②
            return true;
        }else if(ownerThread == t){
            //因为锁是可重入的,所以需要判断当前线程已经持有锁的情况
            return true;
        }else {
            return false;
        }
    }

    private void setOwnerThread(Thread t){
        this.ownerThread = t;
    }

    public void unlock() {
        String script = null;
        try{
            Jedis jedis = new Jedis("127.0.0.1",6379);
            script = inputStream2String(getClass().getResourceAsStream("/Redis.Lua"));
            if(lockContext.get()==null){
                //没有人持有锁
                return;
            }
            //删除锁  ③
            jedis.eval(script, Arrays.asList("lock"), Arrays.asList(lockContext.get()));
            lockContext.remove();
        }catch (Exception e){
            e.printStackTrace();
        }
    }

    /**
     * 将InputStream转化成String
     * @param is
     * @return
     * @throws IOException
     */
    public String inputStream2String(InputStream is) throws IOException {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        int i = -1;
        while ((i = is.read()) != -1) {
            baos.write(i);
        }
        return baos.toString();
    }

    public void lockInterruptibly() throws InterruptedException {

    }

    public Condition newCondition() {
        return null;
    }
}
  • 用一个上下文全局变量来记录持有锁的人的uuid,解锁的时候需要将该uuid作为参数传入Lua脚本中,来判断是否可以解锁。

  • 要记录当前线程,来实现分布式锁的重入性,如果是当前线程持有锁的话,也属于加锁成功。

  • 用eval函数来执行Lua脚本,保证解锁时的原子性。

 

分布式锁的对比

6.1 基于数据库的分布式锁

1)实现方式

获取锁的时候插入一条数据,解锁时删除数据。

2)缺点

  • 数据库如果挂掉会导致业务系统不可用。

  • 无法设置过期时间,会造成死锁。

6.2 基于zookeeper的分布式锁

1)实现方式

加锁时在指定节点的目录下创建一个新节点,释放锁的时候删除这个临时节点。因为有心跳检测的存在,所以不会发生死锁,更加安全

2)缺点

性能一般,没有Redis高效。

所以:

  • 从性能角度: Redis > zookeeper > 数据库 

  • 从可靠性(安全)性角度: zookeeper > Redis > 数据库

 总结

多线程访问共享资源会出现线程安全问题。通过加锁的方式去解决线程安全的问题,性能会下降,需要通过:缩短锁的持有时间、减小锁的粒度、锁分离三种方式去优化锁。

分布式锁的4个特点:

  • 互斥性 

  • 防死锁 

  • 加锁人解锁 

  • 可重入性

用Redis实现了分布式锁,加锁的时候用到了Redis的命令去加锁,解锁的时候则借助了Lua脚本来保证原子性。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值