Redis最佳实践以及原理剖析
背景
最近开始总结redis相关知识,虽然网上也有挺多资料的,自己也看过不少,但毕竟是别人的,只看还是太空洞了。于是就自己总结一番,计划有两部分,本篇关于redis基本原理以及常见适用场景,后面会有剖析源码。
Redis作为一款十年前(2010)诞生的no-sql数据库,讲道理还是比较年轻的。因为是基于内存的,所以速度还是相当快的。常用于对一些访问频率比较高的数据做缓存,或者利用redis数据结构特点处理一些特定业务场景。
常见数据类型
redis提供了5大基本数据类型,以及额外三种扩展数据类型。
注:redis是no-sql数据库,所有数据类型都至少有一个key
首先是五大基本数据类型,基本类型应该全部都要求熟悉。
- String类型
此类型是使用最多,且最简单的,一个key对应一个value。
常用命令有以下:
中括号内代表实际输入的值
命令 | 说明 |
---|---|
set [key] [value] | 设置指定key的值(如果有则覆盖) |
get [key] | 获取指定key的值 |
getset [key] | 设置指定key的值,并返回旧值 |
setnx [key] [value] | 只有当指定key不存在时才设置值 |
del [key] | 删除指定key以及值 |
- Hash类型
其实就是string类型的套娃,你可以看作是一个key的value由很多个string类型组成。
- list类型
跟hash不同的是,它一个key直接对应了多个value,按照插入顺序保存,并且支持索引来操作。
- set类型
与list唯一不同的是,它对应的value无法重复,且是无序的。可以利用它来做一些集合相关的操作。
命令参考:set操作命令
- zset类型
其实我更愿意理解它为hash的变种,只不过它的子key只能为数字,且value不能重复,zset可以自动根据你的子key数字来排序。它的功能与set类似。
一些额外数据类型
- geo(了解)
主要用于存储地理位置信息,并对存储的信息进行操作
- stream(了解)
主要用于消息队列(MQ,Message Queue),Redis 本身是有一个 Redis 发布订阅 (pub/sub) 来实现消息队列的功能,但它有个缺点就是消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃。
简单来说发布订阅 (pub/sub) 可以分发消息,但无法记录历史消息。
而 Redis Stream 提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
- bitmap(重点)
通过位来存储,表示某个数字是否存在与其中。
听起来可能有点一脸懵逼,其实就是通过1或0来表示某个值的状态,它们占用只有一个bit。
比如:你想表示1、2、4、6四个值的状态,那么就可以这样存:
由于bit占用空间非常小,(8bit = 1byte ,1024byte = 1kb),理论上表示100亿个值的状态也占用差不多1g的内存。
一个面试必问的经典问题:redis为什么那么快?
- 基于内存
虽然redis进行查询和修改的操作都是单线程,当架不住人家都是在内存里面操作,所以它天生就可以很快。
- 采用了多路复用的io模型
io多路复用具体我会在后面的源码篇将。简单说下大概原理:
我们知道redis的读写操作都是基于内存的,所以它的瓶颈并不在磁盘io(内存也可以看作是一种磁盘)这里,而它本身也没有什么计算复杂的逻辑,所以肯定也不会在cpu这里。
最后只剩下网络请求了,由于网络请求相比于内存操作慢很多,所以redis最大的性能瓶颈其实是在建立连接和返回响应这里,导致可能redis在很长时间里会处于等待网络请求中。
其实这里就跟cpu到缓存到内存再到磁盘的原理差不多,缓存和内存的出现就是因为磁盘、内存的速度远远跟不上cpu的速度,所以缓存就成了它们之间的中间,以至于不会让cpu一直等待内存或者磁盘的操作结束,从而让cpu效率更高。
多路复用机制有着异曲同工之妙,它其实也是在内存中有一个队列,专门存放客户端的请求,并且redis不会一直来轮询这些请求那一个到达了,而是基于select/epoll提供的时间回调机制,只有队列里有请求真正到达后,就立马执行。这样一来,在高并发下,队列里就会有源源不断的请求存放,redis就会一直处于运行状态,不会傻傻的等到请求真正到达后才去执行。
redis的持久化
redis主要包括rdb和aof两种持久化方式,它们各有优劣,也谈不上谁好谁坏,只有最合适。
RDB持久化
rdb就是通过配置的某种策略,将所有数据保存到磁盘文件,这个文件是那一刻的全量数据,并且是以二进制形式保存,理论上恢复时效率最高。
redis还提供了两种进行rdb快照持久化的操作。
- save命令
我们在任何时刻都以直接输入save命令来进行rdb持久化,不过此刻会阻塞后面所有的客户端请求。
- bgsave命令
redis会fork一个子线程来进行rdb持久化,这里借助了操作系统提供的写时复制操作,大概就是:当此时主线程进行的是读操作,那么子线程就直接写入到文件,如果主线程进行写操作,那么子线程会将那一块数据复制一份,再写入文件。
注意:子线程进行写入文件时,是可以共享读取主线程内存数据的,内存数据它是一块一块读,然后写入,如果读到某一块,发现主线程在进行写操作,它就会直接复制一份,然后再慢慢写入文件,可能你会觉得复制一份有点多余,但是如果不复制,你的数据就是乱的(可能会被主线程不停修改),因为进行文件io操作远比内存慢。可能你还会觉得,在主线程修改完后,会回过头来再将当时被子线程复制的数据也修改吗?答案是不会,因为没必要,我们只是备份某一刻的数据,如果再修改,那就没完没了了(因为这个数据可能会一直变,你也一直改吗?不就成为实时备份了)
AOF持久化
有点类似数据库的redolog,aof模式下的持久化存到文件里面的其实就是每一个命令,我们可以设置策略来进行触发,主要有三种模式:
- appendfsync always: s每次有新命令追加到 AOF 文件时就执行一次 fsync ,非常慢,也非常安全。
- appendfsync everysec: 每秒 fsync 一次,足够快,并且在故障时只会丢失 1 秒钟的数据。
- appendfsync no: 从不 fsync ,将数据交给操作系统来处理。更快,也更不安全的选择。
额外:混合持久化方式(since4.0)
此模式是基于aof的
由于aof是保存的全量操作,在恢复的时候会很慢。混合方式就是利用了rdb的迅速恢复。它会在进行持久化时,首先将那一刻进行rdb全量持久化,然后将后续操作以aof形式追加,这样一来既兼顾了恢复速度,又兼顾了数据完整性。
redis的常见应用场景
各种缓存服务
适合查询比较频繁且不易变动的数据
关于缓存服务的一些问题
-
缓存击穿
指缓存失效,导致大量请求直接打入数据中。
解决方案:利用锁实现缓存过期后只有一个线程请求数据库并写入到缓存,其他未获取到锁的线程可以尝试自旋或者是直接休眠x毫秒(x毫秒取决于具体业务查询时间)然后再尝试获取缓存。
ps:自选还是休眠效率高实际取决于并发竞争程度,可根据实际场景测试。理论上并发没那么高可以选择自旋,并发较高选择休眠。
-
缓存雪崩(缓存击穿的特例)
缓存雪崩指同一时间大量缓存同时过期,导致大量数据查询直接进入数据库,从而使数据不堪重负宕机。
解决方案:设置缓存时间不过期或者将缓存时间设置为随机过期时间
-
缓存穿透
指查询缓存和数据库都没有的数据,属于非正常请求。
解决方案:限制ip短时间内查询接口的频率;将数据库不存在的数据也添加到缓存
缓存更新策略
常见的缓存更新策略有以下:
-
先更新数据库,再删除缓存
这是目前最常见也是最简单的方式,通常来说没啥问题,但还是有小概率发生数据不一致的情况:线程a查询缓存没有,就查询数据库,再准备更新缓存时,线程b已经修改了数据并成功更新了缓存,这时线程a就更新了旧数据的缓存。
ps:其实对于一般项目来说可以说几乎不会发生这种情况,因为发生的概率还是很低的,需要同时满足:1. 读取缓存为空 2. 同时有修改的操作 3. 读操作比修改操作更慢且修改操作的写入缓存早于读操作写入缓存
-
先更新数据库,再更新缓存
其实理论上这种方式才是正常操作,但是业内几乎没人这么做。主要有两点原因:1是你更新了缓存也不一定会被访问到,删除了相当于起到懒加载的效果,同时也一定程度节省了内存开销 2是有些数据结构在redis修改的代价要高,为考虑性能,直接删除更好
-
缓存代理
其实就是把缓存当作主要数据库,直接对缓存进行修改查询操作,然后同步或异步更新到数据库
理论上,只要使用到缓存,基本是不可能保证缓存数据与数据库绝对的一致性的,不过我门可以通过一定策略来减轻数据不一致效果。
比如:延迟双删
简单来说就是更新数据库前删除缓存,更新数据库后延迟一定时间再删除缓存,这样只能说是能降低一定几率出现数据不一致的情况,但是因为多了一步延迟删除的操作,高并发下对吞吐量有一定影响。
毫秒级响应判断亿级用户签到情况
首先来看一个业务场景:系统中日活越有1亿用户,我们需要统计这些用户每天的签到情况。
这时候我们就可以运用bitmap这一数据结构来实现。
将日期作为key,然后将用户id存入bitmap。判断某一天某个用户签到与否,只需根据日期key来判断用户id value是否存在就行。
简单抽奖系统
我们可以利用redis集合的sRandMember命令(随机返回一个集合中的元素)或者sPop命令(随机删除并返回删除的元素),例如,我们可以设置1到100数字到集合中,然后定义小于等于10为中奖,再利用上述命令判断取出的元素是否为中奖。
进阶:高可用架构
主从架构
虽然redis单机并发性能也很高,当时当我们的业务发展规模起来后,单机性能可能也达不到,或者如果redis突然宕机,我们也束手无策。
所以,主从模式就诞生了。他的模式大体为上图所示。
核心是:主库负责写操作和同步从库,而从库负责读操作。
做到了读写分离,性能提升。即使从库突然宕机,我们也还有其他从库。
主库和从库是怎么样同步数据的?
-
全量同步
一般发生在初始阶段,由从库发送同步命令到主库,主库通过rdb方式向从库同步数据。此时主库依然可以正常接受请求,并将命令缓存到
replication buffer
中,当rdb同步完成再将命令发送给从库同步。一般来说,只要第一次主从连接后,就会进行一次全量同步数据,基本是只要后续没有出现断开连接的现象, 后续都是通过同步命令的方式进行同步数据。
replication buffer
本质就是一个记录写命令的缓存区,理论上在主库上会有多个,取决于与之相连的从库数量;不仅如此,每有一个与之相连的客户端,都会存在一个replication buffer
。 -
增量同步
通常来说,在主库与从库失去连接后,重连后一般会重新进行全量同步,不过在redis2.8开始,支持了增量同步,可以在同步过程中断开重连后接着同步,无需重新全量同步。
这里需要注意【增量同步】只针对已经完整进行过一次【全量同步】的情况下才会触发。如果正在进行【全量同步】,断开连接重新连接后,依然需要重新进行【全量同步】
增量同步实现原理:
redis有一个
repl_backlog_buffer
缓冲区,是一个环形结构,可以保存写请求命令。用偏移量来定位主库和从库已经执行的命令的位置。如图,刚开始主库和从库一般都是一起的,在从库断开连接后,主库偏移量可能在不断增加,等下次从库连接,就可以只同步从库与主库偏移量之间的命令了。
不过很明显这里有一个问题,如果从库断开连接时间比较长,因为是一个环,可能就会覆盖之前的记录。
所以需要我们综合评估设置环的大小来尽可能避免这种情况。
对于
repl_backlog_buffer
的理解。本质其实跟replication buffer
一样,都是缓存写入命令的,它会同时和replication buffer
一起生成,不过它在整个主库中只有一份。它的结构相当于一个环,并且记录着主库已经执行命令的偏移量。值得一提的是,从库同步主库命令的偏移量由从库自己记录。
关于repl_backlog_buffer
和replication buffer
的区别可见下图:
主从复制风暴问题
看起来主从复制模式很完美,但当我们的从节点很多时,主节点需要向每一个从节点同步数据,这在高并发下会极大的影响性能,就有违我们的初衷了。
不过,由于redis是支持同时是主库和从库的,我们可以使用主从联级模式来分担主库同步的压力。
哨兵架构
可能你会发现,上述我们讨论的架构仿佛在默认主库就一定稳定的情况下,倘若我们主库宕机了呢,好像就变得群龙无首了。
所谓有需求就有解决策略,所以,我们的哨兵模式就诞生了。它是专门解决主从架构中主库宕机的情况。
哨兵架构整体流程如何?
简单来说,哨兵的工作就是监控➡选主➡通知
-
监控
哨兵负责定期向主库发送心跳检测来判断主库时候宕机。通常是同时存在多个哨兵,来防止因网络误差情况下的“误判”。只要有多数哨兵都认为主库挂机,所有才会真的认为它挂机。
-
选主
当主库挂机后,哨兵集群就开始重新选主了,它会有一个打分机制,分为三次打分来综合评分。在打分前,我们会有一个海选,初筛掉一批明显不合格的从库。比如,那些已经宕机的从库,会被剔除掉;还有一些可能当时在线,但是之前已经掉线许多次的从库,我们有理由相信它接下来可能还会继续掉线。
海选完了,我们就会对剩下的从库进行打分了。
-
第一次打分。
我们可以给每个从库设置
slave-priority
(越小优先级越高),然后会根据优先级程度进行打分。 -
第二次打分。
根据每个从库的数据同步情况来打分。在主从同步过程中,每个从库都会维护一个偏移量来表示与主库同步的命令进度(上文有详细介绍),偏移量是递增的,谁大就代表谁数据越接近主库。
-
第三次打分。
每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。为什么呢?因为从库的id也是递增的,越小就代表越早连接,也就是存活时间最长,也就最稳定。
-
通知
主库选出后,哨兵将通知各个从库和已经连接的客户端。
-