深入理解Redis

125 阅读20分钟

Redis

beforeInter——技术发展

解决功能性问题——完成最基本的CRUD

解决扩展性问题——通过符合某种规范/避免复杂的写法,将对程序的扩展简化,不必回回更改源码(甚至大改)

解决性能的问题——当用户量不断增加,需要解决性能不足问题

技术架构发展

  1. Web 1.0

  1. Web 2.0

    由于移动端接入,导致访问量剧增,导致CPU及内存压力,以及数据库IO压力

    解决CPU和内存的压力:

    分布式集群 + 反向代理|负载均衡

    分布式的一个问题是:关键数据要在多个服务器中同步,例如明确客户端身份的Session

    做法:用NoSQL存储到内存中

    解决IO压力:

    常规的均衡数据库IO的操作——分表分库,读写分离

    但带来的问题是:会破坏一定的业务逻辑来换取性能

    做法:

    1. 本地缓存,减少IO数据库的次数
    2. 增加缓存数据库:将热点数据另存入一个库中

  • NoSQL

    • not SQL:表示是非关系型数据库,即没有固定SQL语法和限制,简简单单KV存储;表与表(数据和数据)之间也没有明显关系(via foreign key)

    • not only SQL

    • 类型

      1. Redis k-v形式

      2. Document 用JSON保存内容(每个JSON内格式不限)

      3. Graph Neo4j 图类型数据库——用图形刻画节点间关系(适合于:社会关系——好友推荐,公告交通网络,地图及网络拓扑)

![](file:///C:/Users/吴松林/AppData/Roaming/marktext/images/2023-08-06-11-03-16-image.png?msec=1700532521388)
  • 查询语法

    不同类型语法各异

  • ACID

    NoSQL基本无法满足对事务的支持,不像关系型数据库,各个产品底层都支持事务

    只能满足BASE:

  • 适用场景

    1. 对数据高并发的读写
    2. 海量数据的读写
    3. 对数据高扩展性
  • 不适用场景

    1. 需要事务支持
    2. 基于SQL的结构化查询存储,处理复杂的关系
  • SQL

    Structured结构化:

    • 存储结构:数据用一张二维表存储,表中每个字段类型有限,范数限制
    • 在此添加信息也要符合之前的结构
区别汇总

  • 扩展性

    垂直:关系型数据库没有设计多台机器负载均衡。传统中增强扩展性主要是增强单个服务器的性能。(但分表分库分片+中间件可以实现)

    水平:通过配置集群负载均衡来提高性能

Redis

Remote Dictionary Server 远程词典服务器

基本属性:

  1. Redis默认有16个数据库,下标从0开始,初始时默认使用0号数据库
  2. 使用命令select 来切换数据库,例如 select 8
  3. Redis统一密码管理——所有库使用同样密码
  4. dbsize 用于查看当前数据库的key的数量
  5. flushdb 清空当前数据库
  6. flushall 通杀全部库
redis对比memcache
  1. memcache 支持的数据类型比较单一

  2. memcache 不支持持久化操作,只能在内存中存储

  3. 核心技术不同

    memcache 使用多线程+锁的机制,从串行模式升级,提高效率

    Redis使用单线程+多路IO复用,效率更高

    单线程:代理和数据库的连接是单线程

    多路IO复用:表示Redis用单个线程监听多个客户端的连接/IO事件,并在不同事件之间进行切换/处理。

    实现机制简述:

    1. select 早期机制,用一个进程轮询多个文件扫描符
    2. epoll
    3. kqueue

Redis 命令行工具

  1. redis-cli:自带的命令行客户端(client)

    redis-cli [optins] [commonds]

    options:

    • -h 127.0.0.1 : 指定要连接的redis节点的IP地址 默认也就是本地
    • -p 6379 :指定端口 默认也就是6379
    • -a 指定redis的访问密码

commonds:直接写操作命令(一般不用)

输入ping

回复pong

用于测试

  1. redis-server 启动redis服务器

    此处还可以设置配置文件,确保是后台允许

    docker设置的自启动将此屏蔽

图形化界面

RDM

Redis 命令

数据结构

key-value类型数据

k一般都是字符串

v则很有学问——八种+

  1. String 字符串
  2. Hash 哈希表存储另外数据结构
  3. List 列表
  4. Set 集合
  5. SortedSet 排序集合
  6. GEO 地理坐标
  7. BitMap 位图
  8. HyperLog

数据结构的使用场景:

还有特殊的,如消息队列等

命令行中可以查看帮助

@后加要查看的类型

help [options] 用于查看具体某个指令的用法

通用命令

  1. keys pattern 查key

    pattern是类似正则的表达式

    成也正则,败也正则——模糊的性能开销太大(估计要全匹配一次),而redis单线程(BIO)。

    但如果redis是集群模式,有主有从,则可以在从数据中乱搞

    据说redis分片是将数据分担到分布式服务器中,每个服务器拥有不同的数据,而每个服务器又有集群,主从服务器之间拥有相同数据

    keys * 查看所有key

  2. DEL key 删除某个k-v

    返回值是删除的行数

    unlink key 非阻塞式异步删除——仅将keys从keysspace元数据中删除,真正的删除会在后续异步操作

  3. EXISTS 判断k是否存在

    返回值 1 表示存在 0 表示不存在

  4. EXPIRE 给一个key设置有效期,有效期后key会被删除(默认单位是秒)

    这是基于内存的考虑

    expire k1 10 将k1设置为10秒

  5. TTL 查看一个k的有效期

    TTL k 变成-2,则表示该k消失

    TTL k 是 -1 ,表示永不删除——如果没有设置EXPIRE,则为默认 -1


String类型的命令

String类型是二进制安全的,意味着Redis中的string可以包含任何数据,比如jpg图片,或者序列号的对象...

常规存储中,如果以字符串形式存储可能会受限于特定的字符串编码,在客户端发送到服务器 | 字符串-二进制-字符串过程中,编码错误可能导致二进制数据出现问题

而Redis的做法是:不对二进制数据进行任何解析,来什么存什么,在返回相同数据后由客户端相同的编码方式进行解析

理解:常见IDE的操作:输入“hello \t world \n”,IDE会将其中的转义都转了,这就是二进制不安全,因为服务器对传递的二进制进行了分析,返回分析后的模式。

Redis二进制安全 - 等不到的口琴 - 博客园 (cnblogs.com)

而二进制安全的意思是,不会对存入的字节做任何处理,你怎么存我怎么返回,如果你用utf-8编码,存一个  字,占 3 个字节,用GBK编码,占两个字节。存一个 10 占用 2 个字节,存 100 要占 3 个字节。 Redis就 用了二进制安全的技术,不将数据进行任何转换,以二进制的方式来什么存什么。为此,同一个 Redis 服务的客户端要统一编码格式,不然数据的读和存会有不一致的问题。

Redis出于节省空间考虑,对不同类型的String进行不同处理

or say 本身多种基本数据结构对外都包装成String,但底层处理不同

  1. 如果value的字符串是int,redis按照二进制编码(范围更大),可自增自减
  2. 如果value的字符串是flout,redis按照float编码,可自增自减
  3. 如果value的字符串是普通字符串,则按字符串模式编码(utf-8/ASCII)...

字符串类型的最大空间不能超过512m,(value不大于512)

操作:

image20230822103008240

  • msetnx:是一个原子性操作,有一个失败则全部失败

String底层数据结构——简单动态字符串(Simple Dynamic String SDS)内部结构实现类似于ArrayList,采用动态扩容方式减少不必要的内存分配。

Redis 原子操作

不会被线程调度打断的操作

在Redis中由于是单线程,于是任何一条命令都是原子操作,因为线程中断只能发生于指令之间。

但在多线程中,命令存在被打断的可能,不会被其他线程切的操作即为原子操作。

引理:

Java中的原子性分析:

关键逻辑:Java中的一条指令底层是由几条原子性指令组合的,各个原子性指令如果被其他线程插足会有什么影响。

List列表类型命令

底层:

  • List是单键多值结构

  • 会按照插入顺序排序,也可以向头/尾添加元素

  • 底层是一个双线链表

    实质上是链表+列表/数组的形式(hashmap的转置)

    而每个节点并不是一个value,而是一个压缩列表

命令:

Set

命令:

底层数据结构:

哈希表实现字典

Hash类型

表示k-v中v是哈希表

适合表示对象类型/JSON嵌套类型的数据

且此数据结构支持套娃——能够完全支持对象的表示

命令:

底层数据结构:

Hash类型的实现数据结构有两种

  • 当field-value长度较短/个数较少时,使用ziplist
  • 否则使用hashtable

怎么实现套娃——简化结构 | 退化结构

Redis将value中的map转化成(序列化)字符串作为真实的value,解读时再解析出需要的格式。

外部哈希的值字段可以存储其他数据类型(如嵌套的哈希)的序列化字符串,但外部哈希自身通常不需要被序列化为字符串。外部哈希的键和值在 Redis 内部都以二进制形式存储,Redis 会负责管理这些数据的内存布局和编码方式。

有序集合 Zset(sorted set)

Zset中存储没有重复的字符串集合(set),但区别在于,每个成员都关联一个评分(score),评分从低到高排序集合中的成员。

集合中成员是唯一的,但评分是可以重复的。

命令:

SortedSet 底层数据结构:

底层使用了两个数据结构:

  1. Hash:

    field是本体,value是score

  2. 跳跃表:

    基础链表+辅助链表 完成查找

    哈希表和跳跃表同时存在,每一步crud都要回鹘哈希表和跳跃表,尤其要保证跳跃表中crud之后任然有序

    跳跃表查找的逻辑:

    小二分法——通过先检查远端某个值的大小来二分出左右

    但主要因为是链表,不太能够直接获取中间位置的值,顾用二分法比较有局限。

Bitmaps 位图

  • 位图本身不是一种数据结构,实际上是字符串,但特殊的格式可以让其进行位运算。

命令:

功能:

性能比较:

  • 位图更适合大规模的布尔状态,及能够节省内存,同时能进行高效的位操作
  • 如果用集合实现,key为身份,value是bool,由于set底层用hashmap,默认空间就会导致额外的空间浪费。

HyperLogLog

场景描述:

  • 业务需要统计访问网站的具体用户的数量,而能简单得到的是每次对网站的访问,需要根据用户对访问次数进行筛选 —— 完成去重、计算等工作。

  • 解决方案:

    1. 数据存储在Mysql中,用distinct count 计算不重复的个数
    2. 数据存储在Redis中,使用hash,set天然去重,或用bitmaps位操作筛选

以上解决方案的问题:随着数据量增大导致占用空间过大

HyperLogLog:

是一种用于估计集合基数的概率性数据结构

能够在非常小的内存消耗下,近似完成基数估计

是在精确度和内存消耗之间的权衡,适用于那些对精确性要求不是非常高的场景。

命令:

Geospatial

是一种用于处理地理空间信息的数据类型。拥有一组执行地理空间操作的命令。

命令:

Redis中的发布与订阅

发布订阅(pub/sub)是一种消息通信模式:

发布者(pub)发布消息,订阅者(sub)接收消息

Redis客户端可以订阅任意数量的频道

语法:

订阅:

发布:

发布成功:

接收:

Jedis

目的是用Java程序远程操作Redis,具体方法是建立IP+端口的连接,传递命令

类似JDBC,感觉都有RPC的意思

语法:

Jedis的了解和使用、Jedis使用Redis事务_jedis 事务_茂桑的博客-CSDN博客

实例:

用redis+Jedis实现收集验证码

我的想法:用一个hash结构,key是用户,value的哈希表中存储次数、每次的验证码...但是问题是:过期只能整个k-v过期,除非用逻辑实现列表/哈希表中去除某个value

于是第二个思路是:k-v仅存储当前的验证码,直接设置过期时间。将次数验证放到Java中执行。

地道的做法:

将hash结构分成多个k-v存入redis,k的结构变为user拼接field | 这就是为什么redis流行拼接【:】

user+"count" 表示次数验证,user+"code" 表示验证码,如此一来所有信息都在库中。

问题:

每个验证码的key都是user+“code”,则必须保证第二次的验证码是在第一次验证码过期后才会入库 —— 在前端设置发送验证码间隔时间。

SpringBoot 整合 Redis

不依赖Jedis,而是StringRedisTemplate。

Redis事务

Redis事务包括一系列命令,在事务的执行过程中,不会被其他客户端发送的命令请求所打断。

理解:Redis通过多路IO复用将多个用户的命令集合成单线程执行,事务操作面临多个用户的命令,实际也就是多线程操作。

基本命令:

  1. Multi 开启组队阶段

  2. Exec 开启执行组队中的命令

  3. discard 放弃组队

事务的错误处理:

  • 当组队时一条命令产生错误

    全部事务不会执行

    linux中每个命令会进行检测,但jedis等执行编译一体时,无法预测是否出错

  • 当执行时一条命令产生错误

    错误的命令不会执行,其他命令依旧执行

    相当于事务的作用只是将一系列命令统一语法检测,过了检测后加锁,分别依次执行

Redis对事务冲突的加锁方案

场景:

多个用户读到存款都是基础数值,试图分别对此数值进行操作,但规定每个用户的操作必须参考之前一个用户操作的结果(余额够不够)

方案一:悲观锁

  • 保守的并发控制策略,每次拿取数据时都会悲观地认为别人会修改。于是加锁的程度很大,防止必然到来的篡改。

  • 运用在传统SQL数据库中,行锁、表锁、读锁、写锁。

  • 流程:一个线程获取锁,执行操作,释放锁。过程中其他线程block

方案二:乐观锁

  • 乐观地认为每次操作别人不会更改,先放心地读,如果改了再说。

  • 乐观锁适用于多读的应用类型,可以提高吞吐量

  • 流程:读的线程会读取到当前的版本号,写的线程会先对比当下的版本号和手里拿的版本号是否相同(有无被其他线程先写过),如果版本号相同,写完后更改版本号,如果版本号不同,无法执行。

具体运用

  • 乐观锁

    通过将版本号绑定到某个变量,需要得到实时版本号的事务监听此变量,来实现唯一修改


    WATCH key

    在执行multi之前,先执行watch key1 [key2] 就可以让此multi事务将他一个或多个key

    如果在事务exec执行之前,key被其他命令所改动,那么exec将不被执行(discard)。

  • unwatch 取消对某key的监视

Redis事务的三大特性

  1. 单独的隔离操作

    事务中的所有命令都会序列化,按顺序执行。事务在执行的过程中,不会被其他客户端发送的命令所打断。

  2. 没有隔离级别的概念

    MySQL中,事务的隔离级别和程序的并发性反相关

    隔离级别越高——事务受其他事务的影响越小——但也牺牲了并发量

    • 隔离级别:

      • 读未提交:

        某个事务对数据的修改无论是否提交,都被其他事务当做真实数据读取

        并发性能很高,但问题很明显:脏读、幻读、不可重复读

      • 读已提交:

        常规理解

      • 可重复读:

        一个事务内的读取不受其他事务影响

      • 串行化:单线程依次执行

而redis中通过乐观锁的实现,类似MVCC,即读未提交增强版

  1. 不保证事务的原子性

    事务中如果一条命令执行失败(语法正确),其他命令任然会执行

miu sa!

...

Redis 持久化

Redis 提供了两种不同形式的持久化方案

  • RDB(Redis DataBase)
  • AOF(Append of File)
  1. RDB(核心:子线程——异步通信)

在指定的时间间隔内,将内存中的数据集快照写入磁盘

底层实现:

  • Redis会单独创建一个子进程(fork)来处理持久化。

    子进程将数据些写入到一个临时文件中,待持久化过程结束后,再用临时文件替换上次持久化好的文件。整个过程中,主进程不进行任何IO操作,保证性能

    写时复制技术(fork)

    即在主线程执行用户逻辑过程中,将数据持久化到某个文件中

    技术要点:Redis复制了与当前进程一样的一个进程,获取到原进程的所有数据(变量,环境变量,程序计数器...),作为其子进程。

  • image20230828100339160

优点:

  • 适合大规模的数据恢复
  • 对数据完整性和一致性要求不高的更适合使用(最后会丢失)
  • 节约磁盘空间
  • 恢复的速度快

缺点:

  • 写是复制技术需要两个线程,导致内存占用达到两倍。

    尤其当频繁写入时,每次都产生一个副本?感觉内存消耗巨高

  • 由于RDB的持久化是间隔时间进行的,导致最后一片区的数据可能无法成功写入(写之前宕机了)。

RDB文件包含Redis服务器中的数据、键值对、过期时间等信息快照

  • 这表示RDB每次写入.rdb文件时,都会覆盖之前的数据,通过全盘扫描redis服务器获取最新数据,以此覆盖旧信息

    这就表示更新太频繁没必要,更新太慢会有最后一把无法及时更新的弊端。

  1. AOF(核心:单线程同步通信)

    以日志的形式来积累每个写操作(增量保存),将Redis执行过的所有写指令记录下来,只需追加文件,但不可改写文件,redis启动之初会读取改文件重新构建数据——会将日志中的指令全部执行一遍来完成数据恢复。

    Redis启动会默认读取RDB和AOF的持久化文件,但当二者都启动时系统默认读取AOF的数据。

    AOF的执行过程

    三种模式:appendfsync:XXX

    1. always:

      每当一句指令执行就用主线程同步写入.aof文件,收到同步确认之前,用户端类似阻塞(其实是线程忙写入去了)

    2. everysec:

      每一秒写一次,也要求同步确认(表明AOF也存在缓存区)

    3. no:

      写是写,不不要求同步确认

三种模式是数据持久性和redis性能的平衡

AOF文件过大时的操作:

重写:

  • 也是先写临时文件然后rename
  • redis也通过fork一个子线程来重写文件,将可以合并的各种繁琐命令聚合——只关注最终结果,不关注过程

主从

主机、从机

实现的功能为:从主机写入的kv,可以在从机读取到

Redis集群

redis集群实现对redis的水平扩容,当启动 n 个redis阶段,整个系统将数据分布式地存储到 n 个节点上,每隔节点存储总量的1/n。

Redis集群的目标是将数据分片,每隔分片分布在不同的Redis节点上,从而实现负载均衡

服务器集群

由于主要作用并非存储数据,而是运算。

通常指将多个独立的服务器或计算机节点组合,也是为了负载均衡,提高性能

Redis问题以及解决方式

  • 缓存穿透

    当某时刻应用服务器访问量很大,但redis并没有起效,导致服务器依然次次访问数据库

    可能的情况:

    1. 服务器每次都试图查询数据库并将结果保存在内存中,但当查不到时自然无法保存到redis,反而白白执行了每次的查库操作。

    解决方案:

    1. 对空值进行缓存

      当一个查询的结果为空,为了防止其在此多次查询,可以将此查询对应一个null进行缓存。

      问题:

      • 当查询是多变的,此方法很耗内存且没什么用
    2. 设置可访问的名单(白名单)

      使用bitmaps位图定义一个可访问的名单,每次访问都要通过bitmap比较以此,如果不在bitmap中,则进行拦截

    3. 布隆过滤器

      对bitmap位图优化性能,但降低准确度

  • 缓存击穿

    当对某一个在缓存中不存在的数据进行超高并发量的访问时,这些访问发现redis中没有,就都向数据库进行访问了

    可能的情况:

    1. 热点数据刚刚过期就迎来高并发访问
    2. 黑客恶意攻击某一不在缓存中的数据

解决方案:

  1. 预先设置热门数据

  2. 实时调整,线程监控哪些数据热门,调整其key的过期时间

  3. 使用锁:

    由于问题来自并发,使用锁一个个来自然可以

    给第一个访问的加锁,第一个把数据放入内存后就解锁,感觉应该可以⑧

  • 缓存雪崩

    当在极少时间内,缓存中大量key过期,导致查询突然涌到数据库

    可能的情况:

    1. 多个key有相同的超时时间

    解决方案:

    1. 构建多级缓存结果(上游nginx配缓存,下游再配其他缓存)
    2. 使用锁 / 队列
    3. 设置过期标志更新缓存
    4. 让缓存失效时间分散