• Redis最佳实践及核心原理


    Redis最佳实践以及原理剖析

    背景

    最近开始总结redis相关知识,虽然网上也有挺多资料的,自己也看过不少,但毕竟是别人的,只看还是太空洞了。于是就自己总结一番,计划有两部分,本篇关于redis基本原理以及常见适用场景,后面会有剖析源码。

    Redis作为一款十年前(2010)诞生的no-sql数据库,讲道理还是比较年轻的。因为是基于内存的,所以速度还是相当快的。常用于对一些访问频率比较高的数据做缓存,或者利用redis数据结构特点处理一些特定业务场景。

    常见数据类型

    redis提供了5大基本数据类型,以及额外三种扩展数据类型。

    注:redis是no-sql数据库,所有数据类型都至少有一个key

    首先是五大基本数据类型,基本类型应该全部都要求熟悉。

    • String类型
    image-20211125162740127

    此类型是使用最多,且最简单的,一个key对应一个value。

    常用命令有以下:

    中括号内代表实际输入的值

    命令 说明
    set [key] [value] 设置指定key的值(如果有则覆盖)
    get [key] 获取指定key的值
    getset [key] 设置指定key的值,并返回旧值
    setnx [key] [value] 只有当指定key不存在时才设置值
    del [key] 删除指定key以及值
    • Hash类型
    image-20211125163927027

    其实就是string类型的套娃,你可以看作是一个key的value由很多个string类型组成。

    • list类型

    跟hash不同的是,它一个key直接对应了多个value,按照插入顺序保存,并且支持索引来操作。

    image-20211125164154183
    • 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四个值的状态,那么就可以这样存:

    image-20211125170401506

    由于bit占用空间非常小,(8bit = 1byte ,1024byte = 1kb),理论上表示100亿个值的状态也占用差不多1g的内存。

    一个面试必问的经典问题:redis为什么那么快?

    1. 基于内存

    虽然redis进行查询修改的操作都是单线程,当架不住人家都是在内存里面操作,所以它天生就可以很快。

    1. 采用了多路复用的io模型

    io多路复用具体我会在后面的源码篇将。简单说下大概原理:

    我们知道redis的读写操作都是基于内存的,所以它的瓶颈并不在磁盘io(内存也可以看作是一种磁盘)这里,而它本身也没有什么计算复杂的逻辑,所以肯定也不会在cpu这里。

    最后只剩下网络请求了,由于网络请求相比于内存操作慢很多,所以redis最大的性能瓶颈其实是在建立连接和返回响应这里,导致可能redis在很长时间里会处于等待网络请求中。

    其实这里就跟cpu到缓存到内存再到磁盘的原理差不多,缓存和内存的出现就是因为磁盘、内存的速度远远跟不上cpu的速度,所以缓存就成了它们之间的中间,以至于不会让cpu一直等待内存或者磁盘的操作结束,从而让cpu效率更高。

    多路复用机制有着异曲同工之妙,它其实也是在内存中有一个队列,专门存放客户端的请求,并且redis不会一直来轮询这些请求那一个到达了,而是基于select/epoll提供的时间回调机制,只有队列里有请求真正到达后,就立马执行。这样一来,在高并发下,队列里就会有源源不断的请求存放,redis就会一直处于运行状态,不会傻傻的等到请求真正到达后才去执行。

    redis的持久化

    redis主要包括rdbaof两种持久化方式,它们各有优劣,也谈不上谁好谁坏,只有最合适。

    RDB持久化

    rdb就是通过配置的某种策略,将所有数据保存到磁盘文件,这个文件是那一刻的全量数据,并且是以二进制形式保存,理论上恢复时效率最高。

    redis还提供了两种进行rdb快照持久化的操作。

    1. save命令

    我们在任何时刻都以直接输入save命令来进行rdb持久化,不过此刻会阻塞后面所有的客户端请求。

    1. bgsave命令

    redis会fork一个子线程来进行rdb持久化,这里借助了操作系统提供的写时复制操作,大概就是:当此时主线程进行的是读操作,那么子线程就直接写入到文件,如果主线程进行写操作,那么子线程会将那一块数据复制一份,再写入文件。

    注意:子线程进行写入文件时,是可以共享读取主线程内存数据的,内存数据它是一块一块读,然后写入,如果读到某一块,发现主线程在进行写操作,它就会直接复制一份,然后再慢慢写入文件,可能你会觉得复制一份有点多余,但是如果不复制,你的数据就是乱的(可能会被主线程不停修改),因为进行文件io操作远比内存慢。可能你还会觉得,在主线程修改完后,会回过头来再将当时被子线程复制的数据也修改吗?答案是不会,因为没必要,我们只是备份某一刻的数据,如果再修改,那就没完没了了(因为这个数据可能会一直变,你也一直改吗?不就成为实时备份了)

    AOF持久化

    有点类似数据库的redolog,aof模式下的持久化存到文件里面的其实就是每一个命令,我们可以设置策略来进行触发,主要有三种模式:

    1. appendfsync always: s每次有新命令追加到 AOF 文件时就执行一次 fsync ,非常慢,也非常安全。
    2. appendfsync everysec: 每秒 fsync 一次,足够快,并且在故障时只会丢失 1 秒钟的数据。
    3. appendfsync no: 从不 fsync ,将数据交给操作系统来处理。更快,也更不安全的选择。

    额外:混合持久化方式(since4.0)

    此模式是基于aof的

    由于aof是保存的全量操作,在恢复的时候会很慢。混合方式就是利用了rdb的迅速恢复。它会在进行持久化时,首先将那一刻进行rdb全量持久化,然后将后续操作以aof形式追加,这样一来既兼顾了恢复速度,又兼顾了数据完整性。

    redis的常见应用场景

    各种缓存服务

    适合查询比较频繁且不易变动的数据

    关于缓存服务的一些问题

    1. 缓存击穿

      指缓存失效,导致大量请求直接打入数据中。

      解决方案:利用锁实现缓存过期后只有一个线程请求数据库并写入到缓存,其他未获取到锁的线程可以尝试自旋或者是直接休眠x毫秒(x毫秒取决于具体业务查询时间)然后再尝试获取缓存。

      ps:自选还是休眠效率高实际取决于并发竞争程度,可根据实际场景测试。理论上并发没那么高可以选择自旋,并发较高选择休眠。

    2. 缓存雪崩(缓存击穿的特例)

      缓存雪崩指同一时间大量缓存同时过期,导致大量数据查询直接进入数据库,从而使数据不堪重负宕机。

      解决方案:设置缓存时间不过期或者将缓存时间设置为随机过期时间

    3. 缓存穿透

      指查询缓存和数据库都没有的数据,属于非正常请求。

      解决方案:限制ip短时间内查询接口的频率;将数据库不存在的数据也添加到缓存

    缓存更新策略

    常见的缓存更新策略有以下:

    1. 先更新数据库,再删除缓存

      这是目前最常见也是最简单的方式,通常来说没啥问题,但还是有小概率发生数据不一致的情况:线程a查询缓存没有,就查询数据库,再准备更新缓存时,线程b已经修改了数据并成功更新了缓存,这时线程a就更新了旧数据的缓存。

      ps:其实对于一般项目来说可以说几乎不会发生这种情况,因为发生的概率还是很低的,需要同时满足:1. 读取缓存为空 2. 同时有修改的操作 3. 读操作比修改操作更慢且修改操作的写入缓存早于读操作写入缓存

    2. 先更新数据库,再更新缓存

      其实理论上这种方式才是正常操作,但是业内几乎没人这么做。主要有两点原因:1是你更新了缓存也不一定会被访问到,删除了相当于起到懒加载的效果,同时也一定程度节省了内存开销 2是有些数据结构在redis修改的代价要高,为考虑性能,直接删除更好

    3. 缓存代理

      其实就是把缓存当作主要数据库,直接对缓存进行修改查询操作,然后同步或异步更新到数据库

    理论上,只要使用到缓存,基本是不可能保证缓存数据与数据库绝对的一致性的,不过我门可以通过一定策略来减轻数据不一致效果。

    比如:延迟双删

    简单来说就是更新数据库前删除缓存,更新数据库后延迟一定时间再删除缓存,这样只能说是能降低一定几率出现数据不一致的情况,但是因为多了一步延迟删除的操作,高并发下对吞吐量有一定影响。

    毫秒级响应判断亿级用户签到情况

    首先来看一个业务场景:系统中日活越有1亿用户,我们需要统计这些用户每天的签到情况。

    这时候我们就可以运用bitmap这一数据结构来实现。

    将日期作为key,然后将用户id存入bitmap。判断某一天某个用户签到与否,只需根据日期key来判断用户id value是否存在就行。

    简单抽奖系统

    我们可以利用redis集合的sRandMember命令(随机返回一个集合中的元素)或者sPop命令(随机删除并返回删除的元素),例如,我们可以设置1到100数字到集合中,然后定义小于等于10为中奖,再利用上述命令判断取出的元素是否为中奖。

    进阶:高可用架构

    主从架构

    image-20211222112017603

    虽然redis单机并发性能也很高,当时当我们的业务发展规模起来后,单机性能可能也达不到,或者如果redis突然宕机,我们也束手无策。

    所以,主从模式就诞生了。他的模式大体为上图所示。

    核心是:主库负责写操作和同步从库,而从库负责读操作

    做到了读写分离,性能提升。即使从库突然宕机,我们也还有其他从库。

    主库和从库是怎么样同步数据的?

    1. 全量同步

      一般发生在初始阶段,由从库发送同步命令到主库,主库通过rdb方式向从库同步数据。此时主库依然可以正常接受请求,并将命令缓存到replication buffer中,当rdb同步完成再将命令发送给从库同步。

      一般来说,只要第一次主从连接后,就会进行一次全量同步数据,基本是只要后续没有出现断开连接的现象, 后续都是通过同步命令的方式进行同步数据。

      replication buffer本质就是一个记录写命令的缓存区,理论上在主库上会有多个,取决于与之相连的从库数量;不仅如此,每有一个与之相连的客户端,都会存在一个replication buffer

    2. 增量同步

      通常来说,在主库与从库失去连接后,重连后一般会重新进行全量同步,不过在redis2.8开始,支持了增量同步,可以在同步过程中断开重连后接着同步,无需重新全量同步。

      这里需要注意【增量同步】只针对已经完整进行过一次【全量同步】的情况下才会触发。如果正在进行【全量同步】,断开连接重新连接后,依然需要重新进行【全量同步】

      增量同步实现原理:

      redis有一个repl_backlog_buffer缓冲区,是一个环形结构,可以保存写请求命令。用偏移量来定位主库和从库已经执行的命令的位置。

      img

      如图,刚开始主库和从库一般都是一起的,在从库断开连接后,主库偏移量可能在不断增加,等下次从库连接,就可以只同步从库与主库偏移量之间的命令了。

      不过很明显这里有一个问题,如果从库断开连接时间比较长,因为是一个环,可能就会覆盖之前的记录。

      所以需要我们综合评估设置环的大小来尽可能避免这种情况。

    对于repl_backlog_buffer的理解。本质其实跟replication buffer一样,都是缓存写入命令的,它会同时和replication buffer一起生成,不过它在整个主库中只有一份。它的结构相当于一个环,并且记录着主库已经执行命令的偏移量。值得一提的是,从库同步主库命令的偏移量由从库自己记录。

    关于repl_backlog_bufferreplication buffer的区别可见下图:

    img

    主从复制风暴问题

    看起来主从复制模式很完美,但当我们的从节点很多时,主节点需要向每一个从节点同步数据,这在高并发下会极大的影响性能,就有违我们的初衷了。

    不过,由于redis是支持同时是主库和从库的,我们可以使用主从联级模式来分担主库同步的压力。

    img

    哨兵架构

    可能你会发现,上述我们讨论的架构仿佛在默认主库就一定稳定的情况下,倘若我们主库宕机了呢,好像就变得群龙无首了。

    所谓有需求就有解决策略,所以,我们的哨兵模式就诞生了。它是专门解决主从架构中主库宕机的情况。

    哨兵架构整体流程如何?

    简单来说,哨兵的工作就是监控选主通知

    1. 监控

      哨兵负责定期向主库发送心跳检测来判断主库时候宕机。通常是同时存在多个哨兵,来防止因网络误差情况下的“误判”。只要有多数哨兵都认为主库挂机,所有才会真的认为它挂机。

    2. 选主

      当主库挂机后,哨兵集群就开始重新选主了,它会有一个打分机制,分为三次打分来综合评分。在打分前,我们会有一个海选,初筛掉一批明显不合格的从库。比如,那些已经宕机的从库,会被剔除掉;还有一些可能当时在线,但是之前已经掉线许多次的从库,我们有理由相信它接下来可能还会继续掉线。

      海选完了,我们就会对剩下的从库进行打分了。

      • 第一次打分。

        我们可以给每个从库设置slave-priority(越小优先级越高),然后会根据优先级程度进行打分。

      • 第二次打分。

        根据每个从库的数据同步情况来打分。在主从同步过程中,每个从库都会维护一个偏移量来表示与主库同步的命令进度(上文有详细介绍),偏移量是递增的,谁大就代表谁数据越接近主库。

      • 第三次打分。

        每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。为什么呢?因为从库的id也是递增的,越小就代表越早连接,也就是存活时间最长,也就最稳定。

      1. 通知

        主库选出后,哨兵将通知各个从库和已经连接的客户端。

  • 相关阅读:
    FZU 2150 Fire Game
    POJ 3414 Pots
    POJ 3087 Shuffle'm Up
    POJ 3126 Prime Path
    POJ 1426 Find The Multiple
    POJ 3278 Catch That Cow
    字符数组
    HDU 1238 Substing
    欧几里德和扩展欧几里德详解 以及例题CodeForces 7C
    Codeforces 591B Rebranding
  • 原文地址:https://www.cnblogs.com/lovelylm/p/15600984.html
Copyright © 2020-2023  润新知