系统学习 Redis

2,081 阅读24分钟

未完待续,持续更新中ing

本文阐述内容概览

为什么要用Redis

因为随着互联网的发展,传统的关系型数据库,例如Mysql,已经不能适用所有的业务场景,比如秒杀的库存扣减,重点页面的访问流量高峰等这种大流量请求都很容易把数据库打崩,所以引入了缓存中间件,通过内存缓存,加快请求响应速度,降低数据库的压力,常见的有Redis和Memcache,由于Redis具有更丰富的数据类型和功能,最终选择Redis

为什么Redis比关系型数据库快

  1. 完全基于内存,绝大部分请求是纯粹的内存操作,数据在内存中以类似HashMap的方式存储,HashMap的优点就是查找和操作的时间复杂度都是O(1),官方数据是可以达到100000+QPS(不过同时要考虑网卡带宽问题,假设KEY平均1KB大小,10万QPS每秒流量大约100M/s,通常一个千兆网卡支持1000M/8 = 125M/s的流量)
  2. 数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的
  3. 采用单线程,避免了不必要的上下文切换和竞争条件,也不考虑锁的问题,自然也不会出现可能出现死锁导致的性能损耗
  4. 采用多路I/O复用模型,非阻塞I/O
  5. 它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM机制,因为一般的系统调用系统函数,会浪费一定的时间去移动和请求

竞品分析


对比Memcache

两者的区别

  • Redis拥有更多的数据结构,支持更丰富的数据操作
  • Redis原生支持集群模式,从3.X版本开始,支持cluster模式,Memcached没有原生的集群模式,需要依靠客户端来实现集群中分片写入数据
  • Redis支持持久化
  • 性能对比
    • 存储小数据时,平均到每一个核上,Redis性能更高
    • 存储100K以上的大数据时,Memcache性能更高
    • Redis只使用单核,单实例可以达到10万QPS,Memcache可以使用多核,单实例可以达到几十万QPS,不过Redis可以通过开多个实例的方式来使用多核

数据结构


总览表格

数据类型 应用场景
String 常规缓存,计数器,id生成器,状态标志,共享session,分布式锁
Hash 购物车,存储结构化数据(不支持嵌套),存储对象
List 消息队列,最新列表,非实时排行
Set 好友/关注/粉丝/感兴趣的人集合,随机展示,黑白名单,抽奖
SortedSet 实时排行榜,带权重队列

String

最常用的一种数据类型,普通的key/value存储都可以归为此类

  • 适用场景
    • 常规缓存
    • 计数器
    • id生成器
    • 状态标志
    • 共享用户Session
    • 分布式锁
      • set distributed_lock 1 ex 300 nx,一条命令设置锁以及过期时间,这样可以避免加上锁,但是写入过期时间失败后造成死锁

Hash

类似于map的一种结构,用于存储结构化的数据,比如一个对象(不支持对象嵌套),每个 hash 可以存储 232 - 1 键值对(40多亿)

  • 适用场景
    • 购物车
      • 以用户id为key,商品id为field,商品数量为value,恰好构成了购物车的3个要素
    • 存储对象
      • hash类型的(key, field, value)的结构与对象的(对象id, 属性, 值)的结构相似,可以用来存储对象
      • string + json也是存储对象的一种方式,当对象的某个属性不是基本类型或字符串时,使用hash类型就必须手动进行复杂序列化,如果多个字段同时需要序列化,序列化工作就太繁琐了,不如直接用string + json的方式存储商品信息来的简单

List

list是一个双向链表结构,可以用来存储一组数据,按照插入顺序排序,每个列表最多可以存储 232 - 1 个元素(40多亿),从这个列表的前端和后端取数据效率非常高

  • 适用场景
    • 消息队列
      • 使用rpush 生产消息,lpop消费消息(或者反过来,lpush和rpop)实现队列的功能,如果lpop没有消息的时候,要适当sleep后重试,也可以使用blpop替换lpop,这样在没有消息的时候,它会阻塞住直到消息到来,不用sleep
      • 如果需要生产一次消息消费多次,可以换成pub/sub主体订阅者模式
      • 但是实际生产中建议使用更专业的消息系统,例如Kafka、NSQ、RocketMQ
    • 最新列表
      • list类型的lpush命令和lrange命令能实现最新列表的功能,每次通过lpush命令往列表里插入新的元素,然后通过lrange命令读取最新的元素列表,如朋友圈的点赞列表、评论列表
    • 非实时排行榜数据
      • 理论上来说排行榜,这种带有权重排序的列表适合使用SortedSet存储,但是如果是非实时排行榜,例如定期刷新的排行榜,那么可以使用程序定时计算排行数据,然后放进list存储,好处是占用的内存小几倍,当数据量比较大的时候就需要考虑该因素了

Set

特点是无序集合,会自动去重,最多可以存储40多亿元素

  • 适用场景
    • 好友/关注/粉丝/感兴趣的人集合,可以计算交集、并集、差集
      1. sinter命令可以获得A和B两个用户的共同好友
      2. sismember命令可以判断A是否是B的好友
      3. scard命令可以获取好友数量
      4. 关注时,smove命令可以将B从A的粉丝集合转移到A的好友集合
      5. 好友推荐时,根据tag求交集,大于某个阈值就可以推荐
      • 注意点
        • 如果你用的是Redis Cluster集群,对于sinter、smove这种操作多个key的命令,要求这两个key必须存储在同一个slot(槽位)中,否则会报出 (error) CROSSSLOT Keys in request don't hash to the same slot 错误
        • Redis Cluster一共有16384个slot,每个key都是通过哈希算法CRC16(key)获取数值哈希,再模16384来定位slot的。要使得两个key处于同一slot,除了两个key一模一样,还有没有别的方法呢
        • Redis提供了一种Hash Tag的功能,在key中使用{}括起key中的一部分,在进行 CRC16(key) mod 16384 的过程中,只会对{}内的字符串计算,例如friend_set:{123456}和fans_set:{123456},分别表示用户123456的好友集合和粉丝集合,在定位slot时,只对{}内的123456进行计算,所以这两个集合肯定是在同一个slot内的,当用户123456关注某个粉丝时,就可以通过smove命令将这个粉丝从用户123456的粉丝集合移动到好友集合。相比于通过srem命令先将这个粉丝从粉丝集合中删除,再通过sadd命令将这个粉丝加到好友集合,smove命令的优势是它是原子性的,不会出现这个粉丝从粉丝集合中被删除,却没有加到好友集合的情况。然而,对于通过sinter获取共同好友而言,Hash Tag则无能为力,例如,要用sinter去获取用户123456和456789两个用户的共同好友,除非我们将key定义为{friend_set}:123456和{friend_set}:456789,否则不能保证两个key会处于同一个slot,但是如果真这样做的话,所有用户的好友集合都会堆积在同一个slot中,数据分布会严重不均匀,不可取,所以,在实战中使用Redis Cluster时,sinter这个命令其实是不适合作用于两个不同用户对应的集合的(同理其它操作多个key的命令)
      • 随机展示
        • set类型适合存放所有需要展示的内容,而srandmember命令则可以从中随机获取几个
      • 黑名单/白名单
        • 经常有业务出于安全性方面的考虑,需要设置用户黑名单、ip黑名单、设备黑名单等,set类型适合存储这些黑名单数据,sismember命令可用于判断用户、ip、设备是否处于黑名单之中
      • 抽奖
        • 使用SRANDMEMBER随机取出一个元素

SortedSet

排序的Set,去重,可以根据分数对集合成员进行排序,可以做一个有序且不重复的集合列表

  • 适用场景
    • 排行榜
      • 有序集合经典使用场景。
    • 带权重队列
      • 可以按照score排序来获取队列成员,优先处理权重高的任务

功能


HyperLogLog

Redis 2.8.9 版本引入

主要用作大数据量基数去重计算,使用较小的内存,获得一个较准确的近似值(标准误差0.81%)

Redis统计每日UV基数分析

场景1: 假设预计有1 亿用户,5千万独立

数据类型 单个数据大小 存储数据量 所需内存 精确度
set 32位(假设uid用的是整型) 50,000,000 32位 * 50,000,000 = 200 MB 精确值
BitMap 1位 100,000,000 1 位 * 100,000,000 = 12.5 MB 精确值
HyperLogLog / 100,000,000 不超过12KB 近似值(标准误差0.81%)

场景2:1 亿用户,10万独立

数据类型 单个数据大小 存储数据量 所需内存 精确度
set 32位(假设uid用的是整型) 1,000,000 32位 * 1,000,000 = 4 MB 精确值
BitMap 1位 100,000,000 1 位 * 100,000,000 = 12.5 MB 精确值
HyperLogLog / 100,000,000 不超过12KB 近似值(标准误差0.81%)

总结

  • 需要精确值,基数少时使用set,否则使用bitmap统计
  • 不需要精确值时,可以使用HyperLogLog

详细内容可以查看 Redis HyperLogLog介绍及应用

Bitmap(位图)

Redis 2.20 版本引入

可以应用于精确统计大量数据并且数据值只有是或者否两种情况,比如活跃用户,用户签到,用户在线状态等场景,相比于传统使用set统计节约大量内存

详细内容可以查看 Redis Bitmap介绍及应用

geospatial(地理位置)

Redis 3.2 版本引入

主要用作地图位置相关计算

详细内容可以查看 Redis 地理空间(geospatial)介绍及应用

pub/sub(发布与订阅)

实现发布与订阅功能,类似于消息系统,相比于list实现的简单队列,可以做到生产一次消息,消费多次

详细内容可以查看 Redis pub/sub(发布与订阅)的介绍及应用

pipeline

pipeline的好处是可以将多次IO往返的时间缩减为一次,前提是pipeline执行的指令之间没有因果相关性

事务

详细内容可以查看 Redis 事务的介绍及应用

lua脚本

Redis 从2.6.0开始支持lua脚本

时间复杂度: 取决于脚本的复杂度

特点

  • 减少网络开销
    • 可以将多个请求通过脚本的形式一次发送,减少网络传传输时间
  • 原子操作
    • redis会将整个脚本作为一个整体执行,中间不会被其他命令插入, 不用担心并发操作带来的影响,可以应用到比如秒杀扣库存这种场景
  • 复用
    • 客户端发送的脚本会永久存在redis中,这样,其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑

更多内容,需要查看可以点击 Redis Lua脚本的介绍及应用


持久化


RDB

原理是fork和cow,fork是指redis通过创建子进程来进行RDB操作,cow指的是copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来

RDB是对Redis中的数据执行周期性的持久化

相当于全量备份,适合做冷备份

AOF

对每条写入命令作为日志,以append-only的模式写入一个日志文件中,因为这个模式是只追加的方式,所以没有任何磁盘寻址的开销,所以很快,类似于mysql中的binlog

相当于增量备份,适合做热备


实现原理


Redis内存模型及应用

Redis 单key 最大value为512M

详细请查看文章 [转载]可能是目前最详细的Redis内存模型及应用解读


应用问题


缓存穿透、缓存击穿、缓存雪崩区别及解决方案

缓存穿透

指查询一个缓存和数据库都没有的数据

正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存

例如我们数据库的id都是从1开始自增的,如果传入的参数为-1或者特别大不存在的数据,就会每次都去查询数据库,而每次查询都是空,每次又都不会进行缓存。假如有恶意攻击,就可以利用这个漏洞,对数据库造成压力,甚至压垮数据库。即便是采用UUID,也是很容易找到一个不存在的KEY,进行攻击。

解决方案

第一,做严格的参数检查,过滤明显不合法的请求

第二,对空值结果也进行缓存,避免重复查询数据库,可以考虑减小空值缓存的时间,例如30秒、60秒

缓存击穿

指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞

解决方案

第一、设置热点数据用不过期,数据更新时更新缓存 第二、使用多级缓存策略,结合本地缓存,避免失效的瞬间有持续大并发请求到redis缓存层 第三、加上互斥锁(分布式锁),保证只有一个请求穿透到数据库,重建缓存,其他请求都暂时不返回数据,等待缓存重建好,重新请求就可以了,不过这种方案可能对线上服务有影响

缓存雪崩

是指在某一个时间段,缓存集中过期失效,所有请求都打到了数据库

举例,电商双11,一批商品信息都是缓存到零点失效,当同一时间缓存失效并且这时有大量用户涌入,会导致大量请求打到数据库,然后重建缓存,数据库必然扛不住,然后挂掉

解决方案

第一、把每个缓存KEY的失效时间加一个随机值,避免缓存同时失效 第二、也可以考虑和解决缓存击穿一样的方法,热点数据永不过期,更新数据时更新缓存

针对上述可能发生的情况,我们应该如何应对

  • 事前:Redis高可用,主从+哨兵,Redis Cluster,避免全盘崩溃
  • 事中:本地缓存 + Hystrix限流 + 降级,避免Mysql被打死
  • 事后:Redis持久化 RDB + AOF,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据

限流和降级其实是一种保证稳定性很好的方案,确保每秒只有多少请求可以通过,有多少请求可以正常提供服务,这样数据库绝对不会挂掉,只要数据库不挂掉,对于用户来说,极端情况下,可能只要多尝试几次就能达到目的

秒杀系统设计

电商活动中经常会出现秒杀活动,会遇到如下问题

  1. 高并发
  2. 恶意请求 - 可能有恶意请求,导致服务器过载,需要拦截
  3. 链接暴露 - 如果抢购请求地址提前暴露,可能会被人利用程序模拟请求来抢购,影响公平性
  4. 超卖
  5. 数据库压力

解决方案

在思考具体问题解决方案之前,我们可以先考虑下最坏的情况,就是如果我们所做的方案最终没有抗住压力,导致秒杀服务崩溃,那么也不要影响到其他服务

即服务隔离,为秒杀系统部署单独的服务,单独的缓存,存储资源,使其影响范围最小

下面我们针对具体的问题讨论解决方案

针对高并发

一、静态化

将前端的静态资源放入CDN,减少服务器的压力

二、评估活动流量,以及系统服务能力,即QPS

首先我们需要评估我们设计的方案是否可以抗住活动的请求量

  1. 评估活动流量
  • 关于这一点,通常我们只有通过以往相关活动的统计数据,本次营销的投入来估算本次可能的流量
  1. 评估秒杀系统的负载能力
  • 正常情况下秒杀系统通过Redis服务超过99%的流量,真正请求到存储层的请求是极少数,可以忽略,所以我们这里主要评估Redis服务的负载能力
  • 如何计算Redis集群负载能力
    1. 计算单实例Redis QPS(可以通过压测来获取)
    • 因为Redis是单线程,通常我们服务器是多核的,在实际应用中,例如一个4核服务器,我们通常可以将2个核绑定到一个Redis实例上,同时开2个实例,这样保证请求可以平均分配到每个核上,同时避免Redis将Cpu资源占满
    1. 计算总QPS
    • 可以简单使用,单实例QPS * 实例数量 * 缓冲系数(比如系数0.8,只使用80%资源,避免把系统打死)
    • 同时需要计算最大QPS时各服务器上所有实例产生的流量,避免把网卡和网络带宽打爆
    • 综合得出当前集群的QPS,同时可以评估出扩容一个标准服务器可以增加的吞吐量
    1. 预估场景流量,结合当前集群负载能力,考虑是否需要扩容

三、准备好降级,限流,熔断方案

当实际情况超过预估流量时,要有应急预案,保证至少能服务部分用户数,不会导致服务完全挂掉,然后通过扩容或者排队来解决多余的用户请求

  • 限流
    • 前端限流
      • 按钮控制
        1. 秒杀前置灰,避免秒杀活动还没有开始,就有大量请求打挂服务器
        2. 秒杀时,点击之后也置灰,避免暴击后造成大量并发请求打挂服务器
    • 后端限流
      • 一但后端返回库存已经卖光,前端直接秒杀结束,后端也不在处理多余请求
  • 降级
    • 可以通过设置降级开关,关闭
  • 熔断
    • 当限流,降级后服务依然扛不住,可以考虑停止解析请求,直接返回预先准备好的静态页面(类似于活动已结束之类) 三、资源静态化 将前端的静态资源放入CDN,减少服务器的压力

针对恶意请求

  1. 可以通过网关这一层,分析IP和请求设备标识来拦截恶意请求
  2. 如果网关没有做,服务端也可以根据这些条件来限制访问频率

针对链接暴露

把URL动态化,使得所有人都无法事先获取抢购链接,通过秒杀连接加盐来避免提前暴露秒杀连接,被程序自动请求

流程如下

  1. 秒杀页面未到开启时间请求时不返回秒杀连接
  2. 到达开启时间后,返回秒杀连接,秒杀连接包含有效时间加上根据参数和有效时间以及密钥计算的盐(可以使用不同接口返回盐值)
  3. 请求到达服务端后,服务端首先校验盐的正确性,然后判断时间的有效性通过后才能进行秒杀

针对超卖

利用Redis服务的原子性,串行化操作,避免超卖,具体方案如下

  1. 使用list,将库存数量作为元素数量放进list,例如100个库存,可以放100个1进list,每次请求使用lpop,如果取到元素说明有库存
  2. 使用INCR 每次请求进来执行一次,然后和库存总数做对比,如果超过库存总数说明抢完了(但是这个方案有个问题在于如果用户退货不方便归还库存)
  3. 使用Lua脚本,通过组合使用多个命令,高效完成CAS(check and set)扣减库存,等到0了后,后面的都return false,失败后修改活动结束开关,挡住后面所有的请求

数据库

抢购成功后,将后续下单流程放进消息系统异步处理,避免并发到数据库,导致数据库挂掉

scan命令的应用

使用keys命令可以找出某些固定模式的键,但是keys命令会导致线程阻塞一段时间,而redis是单线程的,所以可能导致线上服务停顿,直到指令执行完毕

这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以,整体所花费的时间会比直接用keys指令长

而且使用增量式迭代命令只能对呗返回元素提供有限的保证,因为可能在对键进行增量式迭代的过程中,键可能被修改

并发请求带来的数据问题

A、B、C三个系统,同时是去操作Redis的同一个KEY,如果因为某些原因,例如网络抖动,导致操作顺序发生了变化,数据就会错乱

解决方案 一、避免并发请求,可以使用分布式锁,拿到锁才允许操作,在操作期间拒绝其他并发请求的操作 二、根据数据库数据的更新时间和缓存数据的更新时间做对比,避免老数据覆盖新数据

缓存与数据库双写如何保证数据一致性

如果要求强一致,那么可以考虑将读写请求串行化,但是会导致系统的吞吐量大幅度降低

经典KV、DB读写模式

最经典的缓存加数据库读写模式

  • 先读缓存,如果没有,就读数据库,然后取出数据放入缓存,返回响应
  • 更新的时候,先更新数据库,然后删除缓存(或者更新缓存)

是删除缓存还是更新缓存取决于具体的场景

原则上来说,如果是频繁访问的热点数据,为了避免缓存击穿,那么使用更新缓存的方式,如果是更新频繁,访问频率不高的数据,可以采用懒加载思想,删除缓存,避免频繁的更新缓存确没有使用


Key失效机制


定期删除

惰性删除

就是说我等你来查询时,我看看你过期沒,过期了就删除不返回数据给你,没过期就给你数据

内存淘汰机制

  • noeviction: 返回错误当内存限制达到并且客户端尝试执行会让更多内存被使用的命令(大部分的写入指令,但DEL和一些其他的例外)
  • allkeys-lru: 尝试回收最少使用的键(LRU),使得新添加的数据有空间存放
  • volatile-lru: 尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放
  • allkeys-random: 回收随机的键使得新添加的数据有空间存放
  • volatile-random: 回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键
  • volatile-ttl: 回收在过期集合的键,并且优先回收存货时间(TTL)较短的键,使得新添加的数据有空间存放

如果没有键满足回收的前提条件,策略volatile-lru, volatile-random以及volatile-ttl就和noeviction差不多

Redis为什么不使用真实的LRU

在LRU实现的理论中,我们希望的是,在旧键中的第一半将会过期。Redis的LRU算法这是概率的过期旧的键。

Redis3.0比Redis2.8的算法要好,使用10各采样大小的时候Redis3.0的近似值已经非常接近理论的性能

LRU只是预测键将如何被访问的模型

如果你的数据访问模式非常接近幂定律,大部分的访问将集中在一个键的集合中,LRU的近视算法将处理的很好


淘汰策略



redis线程模型


Redis内部使用文件事件处理器,这个文件事件处理器是单线程的,所以Redis才叫做单线程的模型。它采用IO多路复用机制同时监听多个Socket,根据Socket上的事件来选择对应的事件处理器进行处理。

文件事件处理器的结构包含4个部分

  • 多个Scoket
  • IO多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器、命令请求处理器、命令恢复处理器)

多个Socket可能会并发产生不同的操作,每个操作对应不同的文件事件,但是IO多路复用程序会监听多个Socket,会将Socket产生的时间放入队列中排队,时间爱你分派起每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理


redis集群


哨兵

哨兵+主从保证集群高可用

哨兵组件的主要功能

  • 集群监控:负责监控 Redis master 和 slave进程是否正常工作
  • 消息通知:如果某个Redis实例有故障,那么哨兵负责发送消息作为报警通知管理员
  • 故障转移:如果master node挂掉了,会自动转移到slave node上
  • 配置中心:如果故障转移发生了,通知client新的master地址

为什么要用主从架构

分离读写,同时方便扩容

如何同步数据

启动一台slave的时候,他会发送一个psync命令给master,如果是这个slave第一次连接到master,会触发一个全量复制。master会启动一个线程,生成RDB快照,还会把新的写请求缓存在内存中,RDB文件生成后,master会将这个RDB发送给slave,slave拿到之后先写进本地的磁盘,然后加载进内存,然后master把内存里面缓存的新命令都发送给slave

传输过程中有网络问题,会自动重连,连接之后会把缺少的数据补上

Redis Sentinal

着眼于高可用,在master宕机时会主动将slave提升为master,继续提供服务

Redis Cluster

着眼于扩展性,在单个redis内存不足时,使用Cluster进行分片存储

Redis cluster支持N个Redis master node,每个master node都可以挂载多个slave node

这样整个Redis集群就可以横向扩容了


4.0、5.0新特性


参考资料

Redis官方文档

Redis:HyperLogLog使用与应用场景