Redis基础

Redis基础

1. Overview

  • 为什么需要Redis

    • key value内存数据库
    • 支持丰富的数据结构,
    • 性能非常高,可以支持很高的TPS
  • 在使用Redis过程中可能遇到的一些问题

    • CPU使用方面的问题
      • 数据结构的复杂度
      • 跨CPU核的访问
    • 内存使用方面
      • 主从同步和AOF的内存竞争
    • 存储持久化方面
      • SSD上做快照的性能抖动
    • 网络通信方面
      • 多实例时的异常网络丢包
  • 如何进行学习 — 需要系统化

    • 从应用维度和系统维度进行研究

    • 分别看其在以下三个方面的表现

      • 高性能

        • 线程模型
        • 数据结构
        • 持久化
        • 网络框架
      • 高可靠

        • 主从复制
        • 哨兵机制
      • 高可扩展性

        • 数据分片

        • 负载均衡

          Redis框架

2. Redis数据结构

2.1 如何构建一个键值数据库

  • 目标
    • 创建一个叫做SimpleKV的数据库
  • 几个需要思考的问题
    • 问题
      • 里面会存什么样的数据 (数据模型)
      • 需要对数据做什么样的操作 (操作接口)
    • 为什么需要思考这种问题?
      • 这影响到你认为这个数据库到底能做什么
      • 譬如如果支持集合,那么对于存储用户信息的一个关系型数据库,我们也可以将用户Id作为Key,剩余信息作为一个集合存储到我们的键值数据库当中
      • 接口的定义确定了我们希望使用这个数据库做什么,是简单的get, put操作,还是说相对复杂的聚合型的操作

2.1.1 可以存哪些数据?

  • 基本数据类型 Key - Value
  • 希望Value能够支持复杂类型
    • memcache只支持String
    • Redis支持String, HashMap, 列表,集合等
      • 值得注意的点是不同的数据结构在实际使用的时候会有在性能,空间效率等方面的差异,从而导致不同的value操作之间也会存在差异

2.1.2 可以对数据做什么操作?

  • PUT/ SET
    • 新写入或者更新一个KV对
  • GET
    • 根据KEY读取相应的VALUE值
  • DELETE
    • 根据KEY删除整个KV对
  • SCAN
    • 根据一段Key的范围返回相应的value值
  • Tips
    • 当一个键值数据库的value类型多样的时候,也需要包含相应的操作接口的

2.1.3 数据库存储位置

  • 可选方案
    • 内存
      • 读写非常快
      • 访问速度在百ns级别
      • 潜在风险是一旦断电,所有的数据都会丢失
    • 外存
      • 可以避免数据的丢失,但是受限于磁盘的慢速读写(几个ms)键值数据库的整体性能会被拉低
  • 考量的因素
    • 主要应用场景
      • 缓存场景
        • 需要能够快速访问但允许丢失 — 可以采用内存保存键值数据
        • memcache 和Redis都属于内存键值数据库

2.1.4 数据库基本组件

  • 一个基本的内部结构需要包括
    • 访问框架
      • 动态库访问
      • 网络访问框架
    • 操作模块
      • 上述的一系列操作 DELETE/PUT/SCAN etc
    • 索引模块
    • 存储模块

SimpleKV 内部架构

  • 采用什么访问模式?

    • 通过函数库调用的方式供外部应用使用
      • 比如图片当中的libsimplekv.so 就是通过动态链接库的形式链接到我们的程序当中,来提供键值存储功能
    • 通过网络框架以Socket通信的形式对外提供键值对操作
      • 系统设计上的问题 — 单线程,多线程还是多个进程来进行交互?IO模型的选择
        • 网络连接的处理
        • 网络请求的解析
        • 数据存取的处理
  • 如何定位键值对的位置?

    • 需要依赖于键值数据库的索引模块
      • 让键值数据库能够根据key找到相应value的存储位置,进而执行操作
        • 索引类型
          • 哈希表
          • B+树
          • 字典树
        • Redis选用的是哈希表,是因为保存在内存中,内存的高性能随机访问特性可以很好地与哈希表O(1)的操作复杂度匹配
          • 关于Redis值得注意的是它的value支持多种类型,当我们通过索引找到一个key对应的value后,仍然需要从value的复杂结构中进一步找到我们实际需要的数据
          • Redis采用一些高效索引结构作为某些value类型的底层数据结构,可以为Redis实现高性能访问提供良好的支撑
  • 不同操作的具体逻辑是?

    • 对于GET/SCAN 操作而言,根据value的存储位置返回value的值即可
    • 对于PUT操作,需要为新的键值对分配内存空间
    • 对于DELETE操作,需要删除键值对,并释放相应的内存空间,这个过程由分配器完成
  • 如何实现重启后快速提供服务?

    • 采用内存分配器glibc的malloc和free
      • 但是键值对因为通常大小不一,glibc分配器在处理随机大小的内存块分配时表现会不太好。一旦保存的键值对数据规模过大,就可能造成较为严重的内存碎片的问题
    • 持久化功能
      • 采用文件形式,将键值数据通过调用本地文件系统的操作接口保存在磁盘上
        • 需要考虑什么时候,什么间隔来做从内存到文件的键值数据的保存工作
      • 也可以每一个键值对都进行持久化
        • 坏处是因为每次都要写到磁盘里面,性能会受到很大影响
      • 可以周期性的将内存中的键值数据保存到文件当中,这样就可以避免频繁写盘操作的性能影响
        • 潜在的风险就是数据仍然有可能丢失

SimpleKV vs Redis

  • SimpleKV和Redis的对比
    • Redis通过网络访问,可以作为一个基础性的网络服务来进行访问
    • value类型丰富,就带来了更多的操作接口
      • 面向列表的LPush/ LPop
      • 面向集合的SADD
    • Redis持久化模块支持日志(AOF)和快照(RDB)两种模式
    • Redis支持高可靠集群和高可扩展集群

2.2 Redis的慢操作们

  • Redis的快
    • Redis在接收到一个键值对操作后,能够以微秒级别的速度找到数据,并且快速完成操作
    • 快速的原因
      • 内存数据库
        • 所有操作都在内存上完成
        • 内存本身访问速度非常快
      • 数据结构
        • 键值对是按照一定的数据结构来组织的
        • 这是Redis实现高速的基础

2.2.1 Redis数据类型和底层数据结构

  • Redis 键值对中值的数据类型以及对应的底层实现
    • String
      • 简单动态字符串
    • List
      • 双向链表
      • 压缩列表
    • Hash
      • 压缩列表
      • 哈希表
    • Set
      • 整数数组
      • 压缩列表
    • Sorted Set
      • 压缩列表
      • 跳表

2.2.2 键和值用什么结构来组织?

  • 使用一个哈希表来保存所有的键值对
  • 一个哈希表即为一个数组
  • 数组的每个元素称为一个哈希桶
  • 每个哈希桶中保存了键值对的数据
  • 哈希桶中的元素保存的并不是值本身,而是指向具体值的指针
  • 哈希表的好处是我们可以用O(1)的时间复杂度来快速查找键值对
    • 只需要计算键的哈希值,就可以知道它所对应的哈希桶的位置,然后就可以访问相应的entry原色
    • 而且查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系

2.2.3 哈希表操作会变慢?

  • 因为哈希表的冲突问题和rehash可能带来的操作阻塞

  • 哈希冲突在写入了大量数据之后,是不可避免的

    • 两个key的哈希值和哈希桶计算对应关系的时候,正好落到了同一个哈希桶当中
  • Redis的解决方案

    • 链式哈希

      • 同一个哈希桶的多个元素用一个链表来保存,它们之间依次使用指针连接

        哈希冲突的解决

      • 存在的问题

        • 哈希冲突链上的元素只能通过指针逐一查找再操作
        • 如果冲突链太长,会导致这个链上的元素查找耗时变长,效率降低
      • 解决方案

        • 对哈希表做rehash操作
          • 增加现有的哈希桶数量
          • 让逐渐增多的entry元素能在更多的桶之间分散保存
          • 减少单个桶的元素数量
          • 从而减少单个桶中的冲突
      • 解决方案的具体实施

        • Redis默认使用两个全局哈希表(原理)

          • 刚插入数据的时候,默认使用哈希表1
          • 随着数据增多,触发并开始执行rehash
            • 给哈希表2分配更大的空间,一般为当前哈希表1的两倍大小
            • 将哈希表1中的数据重新映射并拷贝到哈希表2当中
            • 释放哈希表1的空间
        • 渐进式rehash

          • 原因

            • 上述的表之间数据的重新映射会涉及到大量的数据拷贝
            • 一次性将哈希表1中的数据都迁移完,会造成Redis线程阻塞,无法服务其他请求
          • 具体方式 — 渐进式rehash

            • 在进行拷贝数据的时候,仍然正常处理客户端请求

            • 每处理一个请求,就从哈希表1中的第一个索引位置开始,将这个索引位置上的所有entries拷贝到哈希表2当中

            • 同理,处理下一个请求的时候,再顺带着拷贝哈希表1中的下一个索引位置的entries

            • 渐进式哈希

              渐进式哈希

2.2.4 集合数据的操作效率

  • 和String类型不同,一个集合类型的值,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合当中再做增删改查

  • 底层数据结构的分析

    • 哈希表

    • 整数数组

      • 插入删除效率比较低 O(N)
      • 访问效率高 O(1)
    • 双向链表

      • 插入删除O(1)
      • 访问O(N)
    • 压缩列表

      • 类似一个数组
        • 每一个元素保存一个数据
        • 表头有三个字段
          • zlbytes
            • 列表长度
          • zltail
            • 列表尾的偏移量
          • zllen
            • 列表中entry的个数
        • 表尾
          • zlend
            • 表示列表的结束
    • 跳表

      • 在链表上加多级索引来加快查询速度

        跳表

3. Redis的IO模型

为什么单线程的Redis那么快?

这里的单线程主要指Redis的网络IO和键值对读写是由一个线程来完成的,而Redis的其他功能,比如持久化,异步删除,集群数据同步等,是由额外的线程执行的。

3.1 为什么要使用单线程?

  • 使用多线程的开销

    • 使用多线程,一定程度上可以增加系统的吞吐率/ 拓展性

    • 但是值得注意的是多线程本身有开销,并不是线程增多吞吐率会线性增长的。达到了某个线程数之后,系统吞吐率的增长就会开始迟缓了,有时甚至会出现下降的情况

      吞吐率随着线程数增长的变化

    • 出现这种情况的原因在于

      • 系统中通常会存在被多线程同时访问的共享资源 — 比如一个共享的数据结构
      • 当有多个线程要修改这个共享资源的时候,为了保证共享资源的正确性,就需要有额外的机制进行保证。这会带来额外的开销
  • Redis采用多线程就是希望能够避免这种共享资源,放锁的情况

    • 而且CPU往往不是Redis的瓶颈,瓶颈很可能是机器内存或者网络带宽

3.2 单线程Redis快的原因

3.2.1 基本IO模型和阻塞点

以前面的SimpleKV为例,为了处理一个Get请求,数据库需要:

  1. 监听客户端请求(bind/ listen)
  2. 和客户端建立连接 (accept)
  3. 从socket中读取请求(recv)
  4. 解析客户端发送请求(parse)
  5. 根据请求类型读取键值数据(get)
  6. 从客户端返回结果,即向socket中写回数据(send)

Get请求处理示意图

在上述的整个过程当中,如果Redis监听到客户端请求,但没有成功建立连接的时候,会阻塞在accept函数上,导致其他的客户端无法建立连接。这种基本IO模型效率会非常低,因为是阻塞式的,任何一个请求出现了任何一个问题,都会导致其他的请求无法成功完成。

3.2.2 非阻塞模式

Socket网络模型的非阻塞模式体现在不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。

这样子可以实现非阻塞,值得注意的是我们需要一些机制来监听套接字,有数据到达的时候再通知数据库线程

3.2.3 基于多路复用的高性能I/O模型

  • 为什么使用I/O多路复用这种技术

    • 解决单线程下阻塞操作的问题
  • 如何实现的

    • select epoll方法同时监控多个文件描述符FD的读写情况,当某些FD可读/ 可写的时候,该方法就会返回可读/ 写的FD个数

https://draveness.me/redis-io-multiplexing/

https://cloud.tencent.com/developer/article/1639569

https://blog.csdn.net/u014590757/article/details/79860766

  • 一个线程处理多个IO流 — select / epoll机制

    epoll机制

    • 允许内核中,同时存在多个监听套接字和已连接套接字

    • 内核会一直监听这些套接字上的连接请求或数据请求

    • 一旦有请求到达,就会交给Redis线程处理

      多路复用全程

  • select/ epoll 一旦检测到FD上有请求到达,就会触发相应的事件

    • 事件会被放到一个事件队列,Redis单线程对该事件队列不断进行处理

3.3 单线程处理的性能瓶颈

1、任意一个请求在server中一旦发生耗时,都会影响整个server的性能,也就是说后面的请求都要等前面这个耗时请求处理完成,自己才能被处理到。耗时的操作包括以下几种:

a、操作bigkey:写入一个bigkey在分配内存时需要消耗更多的时间,同样,删除bigkey释放内存同样会产生耗时;

b、使用复杂度过高的命令:例如SORT/SUNION/ZUNIONSTORE,或者O(N)命令,但是N很大,例如lrange key 0 -1一次查询全量数据;

c、大量key集中过期:Redis的过期机制也是在主线程中执行的,大量key集中过期会导致处理一个请求时,耗时都在删除过期key,耗时变长;

d、淘汰策略:淘汰策略也是在主线程执行的,当内存超过Redis内存上限后,每次写入都需要淘汰一些key,也会造成耗时变长;

e、AOF刷盘开启always机制:每次写入都需要把这个操作刷到磁盘,写磁盘的速度远比写内存慢,会拖慢Redis的性能;

f、主从全量同步生成RDB:虽然采用fork子进程生成数据快照,但fork这一瞬间也是会阻塞整个线程的,实例越大,阻塞时间越久;

2、并发量非常大时,单线程读写客户端IO数据存在性能瓶颈,虽然采用IO多路复用机制,但是读写客户端数据依旧是同步IO,只能单线程依次读取客户端的数据,无法利用到CPU多核。

针对问题1,一方面需要业务人员去规避,一方面Redis在4.0推出了lazy-free机制,把bigkey释放内存的耗时操作放在了异步线程中执行,降低对主线程的影响。

针对问题2,Redis在6.0推出了多线程,可以在高并发场景下利用CPU多核多线程读写客户端数据,进一步提升server性能,当然,只是针对客户端的读写是并行的,每个命令的真正操作依旧是单线程的。

Reference

HiKV: A Hybrid Index Key-Value Store for DRAM-NVM Memory Systems


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 stone2paul@gmail.com

文章标题:Redis基础

文章字数:4.3k

本文作者:Leilei Chen

发布时间:2020-12-20, 11:39:32

最后更新:2021-01-02, 15:07:46

原始链接:https://www.llchen60.com/Redis%E5%9F%BA%E7%A1%80/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录
×

喜欢就点赞,疼爱就打赏