我们学习过很多的技术,但根据技术的功能可以分为以下几种:
功能型技术:解决基础的功能,没有它们根本完成不了项目。例如 JavaSE,HTML 等。
扩展型技术:没有这些技术依然可以开发项目,但有了它们可以简化开发流程。例如:Spring、MyBatis 等。
性能型技术:没有这些技术依然可以开发项目,但为了提高项目的性能,不得不使用这些技术。例如:Redis、MQ 等。
Web 1.0 时代
在早期,项目基本是这种架构,那时候上网的人不是特别多,因此这种架构能够承担得起压力。
Web 2.0 时代
到了后来,上网的人越来越多,还出现了很多手机端的用户,这个时候服务器和数据库根本承受不住这么大的访问量。
出现的问题:服务器要处理大量的数据,导致CPU及内存压力过大,数据库要频繁写入,导致IO压力过大。
因此在 Web 2.0 时代通过 NoSQL 解决这两个问题。
1、解决 CPU 和内存问题
要解决 CPU 和内存的压力,我们最容易想到的就是增加多台服务器,然后通过 Nginx 技术均衡负载到各个服务器,通俗得讲就是假如你的公司一个人干活干不过来,这时候就需要雇多个人来干活,那么我们需要尽量均衡的分配任务给每个人。
但这个方案会带来另一个问题:如何实现共享 Session ?我们知道,Session 存储在服务器中,那么当一个用户1访问服务器 A 时,将 Session 信息存储在服务器 A 中,那么下次,他发起请求被分到 服务器 B 处理,这个时候 B 并没有用户的 Session。
解决方案一:使用 cookie 存储。
因为 cookie 是存在客户端的,可以解决。但弊端是数据存储在客户端是不安全的。
解决方案二:服务器之间进行 Session 复制。
以上面的例子,但服务器 A 有了用户1的 Session,就复制(同步)给其他服务器,但这样会使得 Session 数据冗余(同个 Session 有多份)。
解决方案三:使用单独的服务器存储(存储在 NoSQL 中) Session 信息,当请求到达时,先查看 Session 服务器是否有该用户的 Session 。好处:使用 NoSQL 不用进行 IO 操作,数据存到内存中,读取速度更快。
2、解决 IO 压力
解决方案一:水平、垂直切分数据库、读写分离等方案,目的是降低数据库的读写压力。但弊端是这样做会破坏一定的业务逻辑。
解决方案二:在服务器和数据库之间加一层缓存数据库,降低读的压力。
NoSQL 数据库是指非关系型数据库,存储时以键值对存储,不遵循 SQL 标准,不支持 ACID,性能远超 SQL 。
NoSQL 是一类数据库的总称,包括 Redis 、 Memeache 、 MongoDB 等。
Memcache 是比较早期的 NoSQL 数据库,数据存储在内存中,不支持持久化,数据以键值对存储,但类型单一(只支持 String )。
Redis 数据同样是存储在内存中,但支持持久化(用于备份和恢复),支持多种数据结构(string、list、set、hash、zset 等)。
1、行式数据库
每一行存储在一个数据结构中。
2、列式数据库
每一列存储在一个数据结构中。
3、图关系型数据库
用于存储社交关系网络、地图、交通信息等等。
引入了 NoSQL 的概念之后,我们来了解一下什么是 Redis ?
1、配合关系型数据库做高速缓存
存储热点数据,降低关系型数据库的 IO 次数
分布式架构中的共享 Session
2、利用 Redis 多样的数据结构做特殊的功能
利用 zset 的有序性做排行榜(top n)
利用排序功能展示最新数据
利用 expire 设置过期时间存储手机验证码
利用原子性实现计数器
利用 set 的不可重复性做去重
利用 list 模拟队列
利用 pub/sub 模式实现订阅消息系统
1、打开 Redis 的官网
Redis 官网
2、点击右上角的「Download」
3、可以看到最新的版本
4、在这个页面往下滑动(可以看到最新的稳定版本的链接)
5、再往下滑动有较早的版本
6、选择自己合适的版本就可以下载xx.tar.gz
压缩文件。
官方只提供 Linux 版本的 Redis,想要下载 Windows 版本可以通过到微软的专门开发组织那里下载,地址如下:https://github.com/MicrosoftArchive/redis
如果目前是在 Windows 系统下操作,我们可以先连接远程 Linux 服务器(也可以通过连接虚拟机等),接着将压缩文件上传到服务器的任意位置(只是一个压缩包,放哪里都可以)。假设我们现在把压缩包放在/opt
下。
1、检查是否有 C 语言的编译环境
gcc --version
2、如果提示没有找到命令,那么说明没有安装,这个时候就需要安装 C 语言的编译环境(需要连网)(安装完成后可以使用1中的命令检查是否安装完成)
yum install gcc
3、解压压缩文件
tar -zxvf 压缩文件名
4、解压完成后会生成一个redis-版本号
的文件夹,进入这个文件夹
cd redis-版本号
5、因为刚刚下载好了 C 的编译环境,现在可以用 make 命令进行编译成 c 文件啦~(如果报错的话,先执行 make distclean 再执行 make 命令)
make
6、进行安装(安装成功后,默认会安装到/usr/local/bin
路径下)
make install
7、进入该目录
cd /usr/local/bin
8、显示文件列表(可以看到有各种 redis 文件,表示安装成功)
ll
缺点:一旦关闭窗口,Redis 就会关闭
启动方式:
redis-server
启动后默认端口:6379
退出:直接按 Ctrl + C
优点:关闭窗口,Redis 仍然是启动状态
1、进入到解压后的文件夹中,可以看到有一个redis.conf
文件
cd redis-版本号
2、将该文件复制到etc
目录下
cp redis.conf /etc/redis.conf
3、来到ect
目录下
cd /ect
4、修改etc
目录下的redis.conf
文件,把daemonize no
改成yes
,表示支持后台启动
进入该文件
vi redis.conf
定位
/daem
进入编辑模式进行修改
i
修改后按 esc 进入命令行模式
保存并退出
:wq
5、进入到/usr/local/bin
路径下,启动(注意:是etc 下的 conf ,因为我们刚刚修改过)
redis-server /etc/redis.conf
6、启动后可以查看 redis 进程
ps -ef | grep redis
可以看到已经启动了,并且默认的端口是 6379
7、客户端启动 Redis
redis-cli
8、关闭客户端
exit
9、关闭 Redis 服务
方式一:在客户端状态下
shutdown
方式二:不在客户端状态下
# 查找到进程号
ps -ef | grep redis
# 直接kill掉进程
kill -9 进程号
1、Redis 默认有几个数据库?
Redis 默认有16个数据库,下标从 0 开始递增,初始默认使用的是 0 号库,可以使用select 库号
来切换数据库,并且所有库的密码相同。
# 客户端启动 redis
redis-cli
# 修改当前使用的库为1 号库
select 1
2、Redis 的单线程+多路 IO 复用技术
串行:类似羊肉串,一个一个的吃
多线程+锁:多个线程并发处理,通过对每个操作加锁。(memcached)
单线程+多路 IO 复用:(redis)
查看当前库中的所有 key
keys *
判断某个 key 是否存在,返回1表示存在,0表示不存在
exists 键名
判断某个 key 是什么类型(返回 string等等)
type 键名
删除指定的某个 key
del 键名
根据 value 选择非阻塞删除(效果同上,可以删除键值对,但是是先将 key 先从 keyspace 元数据中删除,真正的删除会在后续异步操作)
unlink 键名
为给定的 key (该键值对已经存在)设置过期时间为10秒钟
expire 键名 10
查看指定的 key 还有多少秒过期,-1表示永不过期,-2 表示已经过期
ttl 键名
切换数据库
select 数据库编号
查看当前数据库的key数量
dbsize
清空当前库
flushdb
清空所有库
flushall
格式:key value
一个 key 对应一个 value,value 的最大值为 512 M,String 类型是二进制安全的,意味着可以存储 jpg 格式的图片或者序列化对象。
特点:单键单值,单值部分为 String
添加键值对(键已经存在时会更新值得内容,不存在则会创建键值对)
set 键名 值
按照键查找值,返回值
get 键名
在某个键值对的值后面追加(例如有个键值对 k1 5,追加 append k1 7,该键值对就会变成 k1 57)
append 键名 追加值
根据键返回值的长度
strlen 键名
添加键值对(键不存在时会创建键值对,存在则什么都不做)
setnx 键名 值
将 key 中存储的数字值 + 1
incr 键名
将 key 中存储的数字值 - 1
decr 键名
将 key 中存储的数字值 + 步长
incrby 键名 步长
将 key 中存储的数字值 - 步长
decrby 键名 步长
注意:由于 Redis 是单线程的,所以 Redis 单命令的操作具有原子性(注意:这里的原子性和事务的原子性不是一个概念),因此 Redis 的 incrby 命令自增时是原子性的,对比 Java 中的 i++ 操作,在多线程情况下不具备原子性(经典案例:对于变量 a = 0,线程1执行a+=100,线程2执行a+=100,结果a的值<=100)。
同时设置多个键值对
mset 键1 值1 键2 值2...
根据多个键获取多个值
mget 键1 键2 键3...
同时设置多个键值对(只有该键值对不存在时才会设置。并且具有原子性,一个创建失败那就全部失败)
msetnx 键1 值1 键2 值2...
获取某个键值对的值的一部分
例如:set name lucymary
getrange name 0 3 会获得 lucy
getrange 键名 开始索引(包括) 结束索引(包括)
设置指定位置的值
例如:set name lucymary
setrange name 3 abc
get name
获得 lucabcry
setrange 键名 起始位置索引 值
设置键值对,并赋予过期时间(与之前不同,现在时一步操作),过期时间单位是秒
setex 键名 过期时间 值
根据键获取值,并修改值(此时会返回旧值,然后更新新值,之后再执行 get 就会返回新值)
getset 键名 值
特点:单键多值 ,多值部分为一个有序可重复列表 List
底层:双向链表,因此,List 具有双向链表的特点,例如:首尾操作效率高,按索引插入效率低等。
1、当 List 中数据量较少的时候,这些数据会存储在一块连续的内存中,我们把这块连续的内存叫做 压缩列表 ZipList
2、当 List 中的数据量越来越多的时候,我们将各个 ZipList 用双向链表的形式存储起来,这种结构叫做 快速链表 QuickList。
根据键向列表左边插入一个或多个值
lpush 值1 值2...
根据键向列表右边插入一个或多个值
rpush 值1 值2...
根据键从列表左边吐出一个或多个值
lpop 值1 值2...
根据键从列表右边吐出一个或多个值
rpop 值1 值2...
从键1列表的右边吐出一个值,插入到键2的左边
rpoplpush 键1 键2
按照索引获取一个键中的元素(从左到右)
lrange 键名 0 -1 表示取出整个list
lrange 键名 开始索引 结束索引
按照索引获取某个键对应列表的该下标元素
index 键名 索引
获取列表的长度
llen 键名
在某个值的前面插入新值
linsert 键名 before 值 新值
在某个值的后面插入新值
linsert 键名 after 值 新值
删除列表左边的 n 个该值
lrem 键名 n 值名
将列表下标为 该索引 的值替换为新值
lset 键名 索引 新值
注意:所有操作 list 的过程中,如果某个 list 的值的个数已经为0,那么这个键值对就不存在了
特点:单键多值 ,多值部分为一个无序不可重复集合 Set
底层:value 为 固定值的哈希表,因此,Set 具有 哈希表的特点,例如:添加,删除,查找的效率都是 O(1)。
添加一个或多个值到一个 set 中(由于不可重复的特点,已经存在的元素则会忽略)
sadd 键名 值1 值2 值3...
取出一个 set 中的所有值
smembers 键名
判断 set 中是否含有指定的值,有返回1,没有返回0
sismember 键名 值
返回 Set 中元素个数
scard 键名
删除集合中的指定元素
srem 键名 值1 值2...
随机从集合中吐出一个值(会删除)
spop 键名
随机从集合中取出 n 个值(不会删除)
srandmember 键名 n
把集合1中的某个值移动到另一个集合2(移出的那个集合1会删除掉该值)
smove 键1 键2 值
返回两个集合的交集元素
sinter 键1 键2
返回两个集合的并集元素
sunion 键1 键2
返回两个集合的差集元素(即键1中有的,但键2 中没有的)
sdiff 键1 键2
特点:单键多值 ,多值部分为一个 Map 结构,因此,整个 key - value 类似 Java 中的 Map
底层:value 为 固定值的哈希表,因此,Set 具有 哈希表的特点,例如:添加,删除,查找的效率都是 O(1)。
当 field - value 长度较短且个数较少时,会使用 ZipList(压缩列表);否则,会使用 HashTable(哈希表)。
Hash 类型的一个键值对如下图,一个键中的一个字段叫做 field(领域)。
key - field1 value1
field2 value2
field3 value3
给一个键添加一个 field 并赋值(已经存在该领域则会更新值)
hset 键名 领域名 值
给一个键添加一个 field 并赋值(已经存在该领域则不会执行)
hsetnx 键名 领域名 值
取出一个键中的一个领域
hget 键名 领域名
给一个键批量添加 field
hmset 键名 领域名1 值1 领域名2 值2...
查看某个 领域 是否存在于某个键中
hexists 键名 领域名
列出一个键中的所有领域
hkeys 键名
列出一个键中的所有值
hvals 键名
为指定键指定领域的值加上指定增量
hincrby 键名 领域名 增量
==特点:单键多值,多值部分为一个有序不可重复集合,有序是通过维护一个 score (分数)来实现,根据分数的大小进行排序。==所以,Zset 适合做排行版功能。
底层的数据结构: Hash + 跳跃表 实现。
前面学到,Hash 的 field 是不重复的,正好对应 Zset 的值,而 field 对应的 值,正好对应 Zset 的分数。而跳跃表主要用于实现排序和根据分数范围获取元素等功能。
普通有序链表:
跳跃表:
结论:已排序链表中,跳跃表查找效率高于普通链表。
将一个或多个元素及其分数添加到一个 key 中
zadd 键名 分数1 值1 分数2 值2...
返回一个 key 中,下标在 开始索引 到 结束索引 之间的值(按照从小到大排序,可以在后面加上withscores
会显示他们的分数)
zrange 键名 开始索引 结束索引
返回一个 key 中,下标在 开始索引 到 结束索引 之间的值(按照从大到小排序,可以在后面加上withscores
会显示他们的分数)
zrevrange 键名 开始索引 结束索引
返回一个 key 中,分数在 指定的较小分数 和 指定的较大分数 之间的元素,并按照分数从小到大排序(包括分数为 指定的较小分数 和 指定的较大分数 的值)
zrangebyscore 键名 指定的较小分数 指定的较大分数
返回一个 key 中,分数在 指定的较大分数 和 指定的较小分数 之间的元素,并按照分数从大到小排序(包括分数为 指定的较大分数 和 指定的较小分数 的值)
zrevrangebyscore 键名 指定的较大分数 指定的较小分数
为指定键的指定值的分数加上增量
zincrby 键名 增量 值
删除这个 key 下指定值的元素
zrem 键名 值
统计这个 key 中,指定分数区间内的元素个数
zcount 键名 指定分数较小值 指定分数较大值
返回这个值在这个 key 中的排名(从 0 开始)
zrank 键名 值
场景:如何利用 zset 实现一个文章的访问量排行榜? 答:可以使用
zrevrange
命令。
频道:类似生活中的电视台
发布者:负责向频道发送消息
订阅者:可以提前订阅某个频道,当频道有消息时,会收到消息。
1、订阅者1订阅频道1
2、发布者1发布消息到频道1
3、订阅者收到频道1的消息
操作方法:
1、打开两个 Redis 客户端(模拟订阅者和发布者)
2、订阅者订阅频道 channel1
suescribe channel1
3、发布者发布消息 hello 到 channel1
publish channel1 hello
4、订阅者收到消息
Bitmaps 本身不是一种数据类型,实际上它是字符串(即 key - value 形式存储),但是它可以对字符串的位进行操作。可以把Bitmaps理解为把存进去的数据转换为二进制存储,这样每个位上就要么是0
,要么是1
。如下图:
使用场景:
1、统计某个用户是否访问过此网站
2、签到功能
缺点:例如我现在的用户 id 是1万,如果我将 id 作为偏移量进行存储,且我只有一个用户时,前面的九千多个位存着 0 实际上时浪费的,这个时候可以选择不用 Bitmaps ,或者将 id 减少 9999 ,然后再存储。
在第一次初始化 Bitmaps 时,假如偏移量非常大,那么整个初始化过程执行会比较慢,可能会造成 Redis 的阻塞。
设置 Bitmaps 中某个偏移量的值为 0 或 1。(偏移量可以理解为数组下标,从 0 开始)。
setbit 键名 偏移量 0或1
获取 Bitmaps 中某个偏移量的值(返回 0 或 1)
getbit 键名 指定偏移量的值
统计 Bitmaps 被设置为 1 的 bit 数,可以指定统计区间(注意:统计区间是按照字节来算的,从 0 开始)
bitcount 键名 指定开始字节 指定结束字节
当有一组数据只需要存储两种状态(例如:签到、是否访问、男女…),且数据量很大,并且数据量比较密集时,用 Bitmaps 存储效率高于 set;其余情况 set 效率高于 Bitmaps。
例如:假如一个网站的用户巨多,并且要统计是否访问过这个网站,看起来可以使用 Bitmaps 记录每天的访问情况,但如果这个网站日活跃量不高,比如 1000 万 的用户量一天只有10万人访问,那么这个用 id 作为偏移量的存储方式需要存储大量的 0 数据,空间效率其实很低。
基数: 给定一组数据,将这组数据去重后剩下的值的个数叫做基数。
HyperLogLog 可以用于统计一组数据的基数的个数,一般可用的场景就是去重。
使用场景:
1、比如统计该网站的访问量,那同个 ip 的多次访问肯定不能算作多次,所以可以使用HyperLogLog 存储
2、统计某篇文章被访问次数,理由同上
3、给定 40 亿个数据(含重复),返回不重复的数据有多少个。
对比其它解决方案:
1、如果数据存储在 MySQL 表中,可以使用 distinct count 计算个数;
2、如果存在 Redis 中,可以使用 set 存储,然后返回数量;也可以使用 bitmaps 存储(同偏移量只会设置为 1 ),然后返回 1 的数量。
但使用 HyperLogLog 的优点是:当数据量非常大的时候,计算基数的空间总是固定的,并且很小。在 Redis 中,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算出 2^64个不同元素的基数,而 set 则数据量越多消耗的内存就越多。
但缺点是: HyperLogLog 只会根据输入的值来计算基数,本身不会存储元素,所以不能像 set 一样,返回各个元素。(比如上面的场景,如果还要返回 40 亿 个数据去重后的各个元素,那 HyperLogLog 做不到)。
添加一个元素到 HyperLogLog 中
pfadd 键名 值
计算一个或多个键(多个时返回合在一起的结果)的基数值
pfcount 键名1 键名2 ...
将一个或多个键(来源键)合并后存储在另一个键(目标键)中
pfmerge 目标键 来源键1 来源键2...
简介:主要是用于存储地理信息(即经纬度)
添加一个地理位置(经度 纬度 地点)
例如(以 china 为键): geoadd china 121.47 31.23 shanghai
geoadd 经度值 纬度值 地点
获取指定地区的坐标值
geopos 键名 地点
获取两个位置之间的直线距离(默认为米)
geodist 键名 地点1 地点2 km/m..
以给定的经纬度为中心,找到半径在指定值内的地点(可用于查找附近的人)
georadius 键名 经度值 纬度值 距离 单位
Redis 事务在执行的过程中,不会被其它客户端发送过来的命令所打断。主要作用是串联多个命令防止别的命令插队。
三个主要命令及两个主要过程:
multi
输入这个命令后的命令将会被依次放到一个队列中,但不会执行,直到输入exec
命令后,才根据队列依次开始执行各个命令。另外,在输入multi
到输入exec
之间的过程叫做组队阶段,输入exec
到命令执行完毕的过程叫做执行阶段。组队阶段可以使用discard
命令用于放弃组队。
组队及执行演示案例:
组队过程中放弃组队演示案例:
组队过程中出现错误的情况:(组队过程中如果某个命令出现了错误,执行时整个的所有队列都不会执行,都会被取消)
执行过程中出现错误的情况:(如果组队的过程没有出错,但是在执行的过程中某个命令出现了错误,那么只有出错的命令不会被执行,其它命令都会执行。)
每次去拿数据都会上锁,直到操作完成才会释放锁,这个过程别人拿不了数据。
每次去拿数据不上锁,如果有更新数据时发现现在的版本号和数据库中的版本号不一致,则不更新;如果发现版本号一致,则会更新数据,并更新版本号。
Redis 是单线程的,并且单个操作具有原子性,因此执行单个操作的时候并不会有线程安全的问题。但是当我们并发地执行多个事务地时候,我们就需要考虑对操作地数据进行上锁。
Redis 中加锁是通过 watch
命令。在执行multi
之前,先执行watch 键名1 键名2 ..
对一个或者多个键进行监视,如果在事务执行之前这个(或这些)key 被改动过,那么事务将会被打断。(实际上就是乐观锁,即操作前进行监视,执行时判断这段时间是否被其它命令修改,如果被修改,则打断当前操作。)
实操演示:
1、先打开两个 redis 客户端
客户端1:
客户端2:
2、在客户端1 监视键 balance 并开启事务
3、在客户端2 监视键 balance 并开启事务
4、在客户端1 中对 balance 加 10 并执行
发现可以正常执行成功,因为从监视到执行这个过程中这个值并没有被修改(一直都是 100)
4、在客户端2 中对 balance 加 20 并执行
发现返回空(nil,即空),表示执行失败,并没有执行,因为从监视到执行这个过程中这个值已经被客户端1修改了(从100修改成110,即对应乐观锁中的版本已经不一致的概念,所以放弃当前事务的操作。)
参数是否合法(包括非空等)
服务是否开始
操作是否合法(包括当前操作的用户是否有操作的权限、是否重复提交的判断等等)
服务是否结束
服务进行
Redis 准备好以下数据
如果我们正常写代码,不考虑并发的情况下,是没有问题的。
但在并发情况下,会出现两个问题:
1、连接超时问题 :这是因为并发请求同时到达 Redis,导致 Redis 超时,只需要配置好连接池即可解决。
2、超卖问题,即可能出现库存为负数的情况 :这个时候需要将一系列操作变成一个事务,并在事务开始前对库存加锁(即监视),即可解决。
优化后代码如下:
代码参考自 https://zhangc233.github.io/2021/05/02/Redis/#%E8%B6%85%E5%8D%96%E9%97%AE%E9%A2%98
public class SecKill_redis {
public static void main(String[] args) {
Jedis jedis =new Jedis("192.168.44.168",6379);
System.out.println(jedis.ping());
jedis.close();
}
//秒杀过程
public static boolean doSecKill(String uid,String prodid) throws IOException {
//1 uid和prodid非空判断
if(uid == null || prodid == null) {
return false;
}
//2 连接redis
//Jedis jedis = new Jedis("192.168.44.168",6379);
//通过连接池得到jedis对象
JedisPool jedisPoolInstance = JedisPoolUtil.getJedisPoolInstance();
Jedis jedis = jedisPoolInstance.getResource();
//3 拼接key
// 3.1 库存key
String kcKey = "sk:"+prodid+":qt";
// 3.2 秒杀成功用户key
String userKey = "sk:"+prodid+":user";
//监视库存
jedis.watch(kcKey);
//4 获取库存,如果库存null,秒杀还没有开始
String kc = jedis.get(kcKey);
if(kc == null) {
System.out.println("秒杀还没有开始,请等待");
jedis.close();
return false;
}
// 5 判断用户是否重复秒杀操作
if(jedis.sismember(userKey, uid)) {
System.out.println("已经秒杀成功了,不能重复秒杀");
jedis.close();
return false;
}
//6 判断如果商品数量,库存数量小于1,秒杀结束
if(Integer.parseInt(kc)<=0) {
System.out.println("秒杀已经结束了");
jedis.close();
return false;
}
//7 秒杀过程
//使用事务
Transaction multi = jedis.multi();
//组队操作
multi.decr(kcKey);
multi.sadd(userKey,uid);
//执行
List<Object> results = multi.exec();
if(results == null || results.size()==0) {
System.out.println("秒杀失败了....");
jedis.close();
return false;
}
//7.1 库存-1
//jedis.decr(kcKey);
//7.2 把秒杀成功用户添加清单里面
//jedis.sadd(userKey,uid);
System.out.println("秒杀成功了..");
jedis.close();
return true;
}
}
以上示例解决了 Redis 事务在并发情况下的"超卖"问题,但实际上上面的代码还会出现一个问题:当并发数比较高时,会出现滞销现象。
滞销问题出现原因:Redis 的锁是乐观锁,是通过版本号来判断是否可以操作 key,那么假如现在有 2000 个并发请求都读取到了版本为 v1.0 的 key1,其中有一个请求修改了 key1,并更新版本为 v1.1 ,这时候其它的请求想要修改,发现版本不一致(v1.1 != v1.0),那么这些事务都会被取消,最后,2000 个请求只是让库存减少了 1 ,但库存明明有很多,出现了滞销。
解决方法:我们知道,问题的根本是 Redis 使用的是乐观锁的机制,那么只能通过 lua 脚本来实现。
Redis 的特点之一就是支持持久化。Redis 持久化的方式主要有两种,分别是 RDB 和 AOF。Redis 的数据是存在内存中的,持久化技术可以让内存中的数据存到硬盘中。
在指定的时间间隔内将内存的数据集快照写入磁盘。
时间间隔:例如指定每隔 10 分钟持久化一次,就可能在10:00
和10:10
进行两次持久化。
数据集快照:以当前时间点为界,在这之前的数据。
Redis 会单独创建一个子进程(fork)来进行持久化,会将数据先写入到一个临时文件中,等持久化过程结束了,再用这个临时文件替换上次持久化好的文件,整个过程中,主进程是不进行任何 IO 操作的,这种通过替换的过程叫做“写时复制技术”。
Redis 默认开启 RDB 持久化。
打开 Redis 的配置文件——redis.conf
,可以搜索到以下几个配置信息
以下配置默认开启:
# 表示持久化后数据存储再 dump.rdb 文件中。
dbfilename dump.rdb
# 表示 dump.rdb 存储在启动目录的当前目录(一般即 bin 目录下)
dir ./
# 表示当 Redis 无法写入磁盘时,直接关掉 Redis 的写操作,推荐为 yes
stop-writes-on-bgsave-error yes
# 表示是否对快照文件进行压缩存储,是的话 Redis 会采用 LZF 算法进行压缩
rdbcompression yes
# 表示是否使用 CRC64 算法对快照数据校验其合法性,从而判断是否进行持久化,
# 开启后会有大约 10% 的性能损耗,但我们推荐还是开启
rdbchecksum yes
以下配置默认不开启:
# 以第一个配置为例,表示 15 分钟(900 秒)内数据被修改了1次则会触发持久化。
# 如果同时配置了规则,则表示只要触发一个就会开始持久化。
save 900 1
save 300 10
save 60 10000
save 默认不开启,默认是开启了 bgsave 。
两者对比:
save
:save 只管保存,其它不管,全部阻塞,手动保存,不建议,默认不开启。
bgsave
: Redis 会在后台异步进行快照操作,快照的同时还可以响应客户端请求,默认开启。
备份的作用:当我们的 Redis 正在使用时,会不断地使用 RDB 进行持久化,此时如果 Redis 突然挂掉了,存储在内存中地数据就会消失,但是持久化的数据还在,此时,等待 Redis 重新启动后,会自动加载 dump.rdb 文件的数据到内存中,从而达到数据的恢复。
以日志的形式来记录每个写操作(增量保存),将 Redis 执行过的所有写指令记录下来(读操作不记录),只许追加文件但不可以改写文件,Redis 启动之初会读取该文件重新构建数据,换言之,Redis 重启的话就根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
客户端请求中的写命令会被 append 追加到 AOF 缓冲区内,AOF 缓冲区根据 AOF 持久化策略(always,everysec,no)将操作 sync 同步到磁盘的 AOF 文件中,AOF 文件大小超过重写策略或者手动重写时,会对 AOF 文件 rewrite 重写,压缩 AOF文件容量,Redis 服务重启时,会重新 加载 AOF 文件中的写操作达到数据恢复的目的。
Redis 默认不开启 AOF 持久化,但 RDB 和 AOF 同时开启时,会使用 AOF 持久化。
打开 Redis 的配置文件——redis.conf
,可以搜索到以下几个配置信息
# 配置文件中该配置默认为 no ,表示不开启 AOF 持久化,我们将其改为 yes 即可开启 AOF。
appendonly no
# 表示持久化后存到哪个文件,默认在启动路径下生成该文件,和 RDB 路径一致。
appendfilename “appendonly.aof”
修改配置文件并重启 Redis 即可看到 appendonly.aof
文件
AOF 的数据恢复和 RDB 差不多,在 Redis 挂掉之后,AOF 文件还在,此时等待 Redis 重启后会 自动将 AOF 文件中的命令执行一遍,从而达到恢复数据。
但 AOF 除了恢复数据外,还支持修复数据。
修复数据是指当 Redis 想通过 AOF 恢复数据的时候,发现 AOF 文件中的数据有损坏(被修改过等),那么 Redis 会报错,这个时候我们可以通过修复工具把文件修复完整,再次恢复,即可成功。
实际演示:
1、查看 AOF 文件
vi appendonly.aof
发现文件内容都是我们可以看得懂的。(实际上只是把每个写数据记录下来)
2、修改文件内容最后一行为 “hello”(此时模拟了文件损坏)
3、关闭服务后重启(Redis 显示连接拒绝,因为 Redis 试图通过 AOF 进行恢复数据,发现 AOF 文件损坏)
4、检查 AOF 文件哪里异常并询问是否修复
在 AOF 文件所在的目录里面有一个 redis-check-aof ,执行下面命令。
# 该命令可以检查 AOF 文件哪里异常并循环是否修复
redis-check-aof --fix appendonly.aof
输入 y 表示开始修复
修复成功
重启 Redis 发现一切正常,AOF 文件也修复过来了,并且数据也恢复到内存了。
AOF 还可以设置同步的频率,可以直接在redis.conf
中修改以下设置:
AOF 还可以设置 Rewrite 压缩:
由于 AOF 是采用文件追加方式,为了避免文件越来越大,新增加了重写机制,当 AOF 文件的大小超过设定好的某个阈值时, Redis 就会启动 AOF 文件的内容压缩。。
简单的说:
set a1
set a2
可以合并成
set a1 a2
从而达到了压缩文件的效果。
重写的原理:
AOF 会 fork 出一条新进程来将文件重写(也是先写到临时文件最后再 rename),redis 4.0 版本后的重写,事实上就是把 rdb 的快照,以二进制的形式附在 aof 头部,作为已有的历史数据,替换掉原来的流水账操作。
1、官方推荐两个都启用。
2、如果对数据不敏感(允许丢失的可能),可以选择单独用 RDB。
3、不建议单独用 AOF,因为可能会出现 Bug。
4、如果只是单纯做内存缓存,可以都不使用。
一、什么是主从复制?
**主机(也叫 Master)数据更新后根据配置和策略,自动同步到从机(也叫 Slave)**的机制,叫做主从复制。其中,主机主要复制写入操作,从机主要负责读取操作。
二、为什么要使用主从复制(主从复制的作用)?
我们之前演示的都是单机的 Redis 服务,而单机 Redis 的缺点无非就两个:一是随着用户请求的不断增多,单机很难承受住大量的请求;二是单机的 Redis 安全性不高,一旦机器出现故障挂掉,服务就直接停止了。因此,总结作用如下:
1、读写分离,性能扩展
2、容灾快速恢复
主从复制的模式如下:一主多从,主负责写,从负责读,主被写入数据时复制(同步)到从。
说明:一般来说上线的项目中,主从机是对应着不同的服务器,因为这才达到增加性能即容灾的效果,但这里演示过程采用同服务器不同客户端演示(本质是一样的)。
1、搭建三个 Redis 服务器并启动,连接上各自的客户端。
2、查看 三台Redis 服务器主从复制相关信息
info replication
可以看到每一台服务器都显示目前是主机(Master),且没有携带任何从机,表明各自是相互独立的。
3、配置主从关系(一主两从)
在想要成为从机的两台机器上面执行(配从不配主)
slaveof 主机IP地址 主机Redis端口号
4、重新查看三个机子的主从信息
主机:显示是主机,有两个从机,并且显示从机信息
从机:显示是从机,有一个主机,并且显示主机信息。
5、测试
演示一:
在主机中添加数据
从机中查询,可以查询到数据
演示二:
在从机中添加数据
从机会报错,显示“从机是只读的,不能写”
演示一:
手动关闭一台从服务器,模拟从服务器挂掉的场景。
查看主服务器的主从复制信息,发现只有一台从服务器(显示活着的)
主服务器添加数据。
活着的从服务器查询,可以查询到主服务器添加的数据(这是当然的啦~)
手动开启挂掉的从服务器
查看从服务器的主从复制信息,发现他是个主服务器,并没有从服务器(说明它是独立的)
将该复活的服务器手动配置成主服务器的从服务器。
查询主服务器的主从复制信息,发现有两个从服务器。
查询该从服务器,发现可以查看到主服务器添加的数据。
特点:slave 挂掉之后重新启动是独立的,手动配置成原来主服务器的从服务器,数据是全量恢复的。
演示二:
手动关闭主服务器,模拟主服务器挂掉的场景。
查看从服务器的主从复制信息,发现从服务器还是显示有一个主服务器,且为原来那台主服务器。(大哥挂掉了还是认你是大哥,且不会主动上位)
主服务器重新启动
发现主服务器的主从复制信息中还是有两个从服务器,即原来那两个。(大哥复活了,还是带着原来的小弟)
问题所在:当从服务器很多很多时,一主对应多从会让这台主的压力很大,每次都要同步多台从,因此可以做成类似层级结构的 主 - 副主 - 从 ....
的层级关系。
演示一:
准备三台 Redis 服务器(1号、2号、3号)
配置3号为2号的从
配置2号为1号的从
查看3号的主只有2
查看2号的主只有1
查看1号的从只有2
从而环境了主1的压力
前面我们演示到,当主服务器挂掉时,从服务器是不会篡位的(还是认他为大哥),那么在实际开发中,这不就缺少了主机了吗,项目肯定跑不了了。这时候我们可以手动将一台从变成主,这就是反客为主。
演示一:
当主服务器挂掉时(可以手动关闭模拟效果)
选择一台想要成为主服务器的从服务器,执行命令slaveof on one
此时该从服务器就会变成新的主服务器
主从复制的过程:
1、当从服务器第一次成功连接到主服务器时,从服务器会发送一个 sync
命令给主服务器。
2、主服务器接受到命令后会启动后台的存盘进程,在后台进程存盘完毕后,主服务器将会传送整个 rdb 数据文件给从服务器。
3、从服务器接受到 rdb 文件后,会将其存盘并加载到内存中。(该过程也叫做全量复制)
4、后续,主服务器继续将新的所有收集到的修改命令依次传给从服务器,完成同步。(该过程也叫做增量复制)
5、从服务器每当重新连接主服务器都会完成全量复制。
哨兵模式:当主机挂掉时,不需要手动去把一个从机变成主机,Redis 会帮我们根据某些策略帮我们选择一台从机变成主机。
哨兵:用于监视主机是否挂掉,并负责选择一个从代替主,本质也是一个 Redis 服务。
如何搭建哨兵?
1、自定义的 /myredis 目录下新建 sentinel.conf 文件,名字不能出错。
2、配置文件写上sentinel monitor mymaster 主机IP 主机端口 1
。其中第一个单词表示我是哨兵,第二个单词表示负责监视,第三个单词表示监视主机,第四个第五个表示监视哪个主机,最后 1 表示最多需要多少个哨兵同意,才能把从替换为主,也可以配置其它数字。
3、启动哨兵
演示一:
搭建好一主二从的环境
搭建哨兵
手动让主机挂掉
哨兵会监控到信息,并显示在控制台上
哨兵会选择一台从变成主
而挂掉那台原主服务器重启后会自动变成新主服务器的从服务器。
主从模式的缺点:当写操作是发生在主服务器时,主服务器同步到从服务器再快也会有延迟,特别是系统繁忙或者从服务器很多时,延迟会更加严重。
先看策略一:优先级在 redis.conf 中默认为slave-priority 100
,值越小优先级越高
再看策略二:偏移量越大是指获得原来主机数据最全的
最后看策略三:每个 Redis 示例启动后都会随机生成一个 40 位的 runid。
问题一:如果我们是采用前面一主多从的架构的话,写入的操作只在一个 Redis 服务器中进行,如果写入的请求很大时,只用一台 Redis 来写不满足要求。
问题二:如果我们是采用前面一主多从的架构的话,但是我们这台写入的服务器已经写满了,容量已经不够了。
基于以上两个问题,简述如下:
1、容量不够
2、并发写操作很多
我们的解决方法是:采用 Redis 集群(即配置多个一主多从)。
那么这样又有一个问题,集群的话那会有多台服务器负责写入操作,那么该如何均匀的把请求分配给多台服务器呢?
在以前,是这样解决的:因为我们基本上搭建集群都是每个主机负责一个业务,因此我们可以搭建一个代理服务器(及其从机),然后根据请求的不同,转发到对应的服务器中,这种方式叫做代理主机。
在Redis 3.0后,Redis 引入了无中心化集群配置,它是这样解决的: 因为我们基本上搭建集群都是每个主机负责一个业务,但请求到达时,我们不去判断该请求是对应哪个 Redis 主机,而是先随机到一个主机,然后判断请求和主机是否匹配,匹配则处理,不匹配则转发给其它主机,即每台主机都是一个入口,这种方式没有中心,因此叫做无中心化集群。
Redis 有 16384 个插槽,每个键对应一个插槽,然后每个主机分配一部分的插槽,插入键时根据公式计算插入哪个主机。
在一个 Redis 集群中,当一个主机挂掉后,它的从机会自动变成主机(不需要哨兵),且当挂掉的原主机重启后会自动成为新主机的从机。
在一个 Redis 集群中,当一个主机以及它的从机全部挂掉后,会怎么样呢?
两种情况:
当 redis.conf 配置中的cluster-require-full-coverge yes
时,那么整个集群都会挂掉。
当 redis.conf 配置中的cluster-require-full-coverge no
时,那么当前节点(包括主从)都不能使用,也无法存储。
**描述:**当出现大量并发请求同时访问 Redis 中不存在的数据,导致请求到达数据库,从而让数据库崩溃的情况,通常是非法请求。
解决方法:
1、非法参数校验。事先设置好参数范围,发现参数不符合直接返回
2、对空值做缓存。当请求第一次到达 DB 时,发现没有该数据,那么客户传过来的值作为 key,null 作为 value 存到 Redis 中,下次还是这个请求直接在 Redis 层面就可以返回 null。
3、设置可以访问的白名单。将名单 id 存储到 bitmaps 中,校验请求的合法性。
4、采用布隆过滤器(Bloom Filter),效率高,缺点是有一定的误判率和删除困难。
5、实时监控。当发现 Redis 的缓存急速降低,就得沟通运维人员设置黑名单。
描述:当有大量的访问在访问某个 key 时,突然这个 key 失效了,导致大量的请求到达 DB。
解决方法:
1、预先设置热门数据:当某个 key 的访问高峰是可以被预测的时候,在高峰来临前先将热门数据存储到 Redis 中,并加长热门数据的 key 时长。
2、实时调整:现场监控哪些 key 是热门的,实时增加 key 的过期时长。
描述:在极少的时间内,大量的缓存失效(过期),大量的请求到达 DB。
解决方法:
1、设置过期标志更新缓存。例如:我的这些键都是十点过期,那么设置一个键在9.50过期,当这个键过期时,触发另外的线程在后台更新这批 key 的缓存。
2、将缓存失效时间分散开。我们可以在原失效时间上加一个随机值,这样就不会导致所 key 集体失效的事件发生。
原本单机部署时,锁是单机的,可以实现,但搭建了集群后,在多线程的情况下,将使原单机部署情况下的并发控制锁策略失效,这个时候就需要用到分布式锁。
分布式锁实现方案:
1、基于数据库实现分布式锁
2、基于缓存(Redis 等)
3、基于 Zookeeper
使用 setnx 命令实现。
上锁:
setnx aaa bbb
释放锁
del aaa
1、如果锁一直没有释放怎么办?
上锁时加上过期时间
setnx aaa bbb
expire aaa 10
2、上锁之后突然出现异常,无法设置过期时间怎么办?
上锁时同时设置过期时间(一条命令完成)
set aaa bbb nx ex 12
3、锁之间的冲突问题
问题如下:假设有 a 、b 两个服务器,操作如下,
① a 先操作,给某个数据加锁,并设置过期时间。
② a 执行一系列操作过程中,出现卡顿。
③ 过期时间到了,锁被释放(即 key 过期被删除了)
④ 此时 b 拿到了锁,上锁,设置过期时间
⑤ b 进行具体操作
⑥ 此时 a 卡顿结束,执行释放锁(此时锁已经在 b 那里了)
⑦ b的锁被a释放了,而操作还没结束呢。。(这就是问题所在)
使用 UUID 防止误删,即只能删除自己的锁,而不能删除别人的锁。这个 UUID 就是一个服务器唯一的标识,将这个 UUID 存到 value 中,当某个服务器要删除锁时,先查看这个 key 对应的 value 中的 UUID 是否是自己的,如果不是,则表示此时的锁是别人的,不能删除。
4、UUID判断 和 删除锁 之间的原子性问题
我们考虑第 3 中的情况是否还有问题呢?实际上是有的。
假如说服务器 1 现在锁还没有过期,也没有释放,但想要释放,判断之后,发现UUID是自己的,说明可以释放锁,但此时突然锁过期时间到了,失效了,并且服务器 2 拿走了锁,并修改了UUID,可此时原来的服务器还是会删除锁,还是会出现原来的问题,这个极端问题就是因为没有考虑到操作的原子性。
这种情况只能通过 lua 脚本来解决,因为 lua 脚本在执行过程中,别人是不可以打断的,因此需要在 Redis 中嵌入 lua 脚本。
Redis 版本 5 之前,Redis 的安全规则只有通过密码等来控制,但在 Redis 6 中加入了 ACL 命令来控制用户进行更细分的权限控制。包括:
展示用户权限列表:
acl list
其中,Redis 自带有一个名字叫做 default 的用户,该用户默认开启,且不需要密码就可以操作,并且可以操作所有命令、所有 key,而且我们的操作就是默认使用这个用户。
查看当前用户
acl whoami
创建用户
acl setuser 用户名
创建用户,并设置用户的信息()
acl setuser 用户名 是否开启 >密码 可操作的键 可操作的命令
切换用户
auth 用户名 密码
之前老版本 Redis 想要搭建集群需要单独安装 ruby 环境,Redis 5 将 redis-trib.rb 的功能集成到 redis-cli。另外官方 redis-benchmark 工具开始支持 cluster 模式了,通过多线程的方式对多个分片进行压测。