redis 是什么
为什么需要 redis
-
数据从单表,演进出了分库分表
-
mysql 从单机演进出了集群,适应
- 数据量不断增长
- 读写数据压力不断增加
-
存储的数据访问频率是不同的(数据分冷热)
- 热数据:经常被访问的数据
-
为了加快读取热数据的速度, 可以将热数据存储到内存中而不是硬盘上
-
读写场景
- 读:先从缓存中读取,如果读不到再去 mysql 中读
- 写:先写入 mysql,后台监听 binlog 的线程反解出对应的 redis 指令,修改 redis 状态
基本工作原理
- 数据从内存中读写
- 数据持久化保存到硬盘上防止服务器重启后数据丢失(可在重启后重放 aof 文件中的命令,重放完成后才会把 redis 服务器拉起来)
- 增量数据保存到 AOF(Append Only File) 文件
- 全量数据 RDB 文件:保存了当前 redis 实例的所有信息
- 整个启动过程:先去读 RDB 文件,读取到 RDB 文件后(dump.rdb),再去 AOF 文件中是否有 RDB 文件对应快照状态之后还没有执行的命令,如果有,就重放这些命令,然后拉起 redis 服务器,保证 redis 服务器状态和宕机之前或重启前的状态一致。
- 单线程处理所有操作命令
假设 GET key1 和 GET key2 几乎同时到达,那么 redis 服务器会按照严格的顺序执行这两个操作请求。
redis 应用案例
连续签到
用户每天有一次签到机会,如果断签,连续签到计数归 0; 连续签到的定义:每天必须在 23:59:59 前签到。
- Key:cc_uid_11xxxx(用户唯一 id)
- value: 252(连续签到天数,Incr 方法)
- expireAt:后天 0 点(timeout )
String 数据结构
数据结构: Simple Dynamic String 简单动态字符串
- 可以存储字符串、数字、二进制数据(二进制安全)
- 通常和 expir 配合使用
- 场景:存储计数、Session
消息通知
用 list 作为消息队列
- 使用场景:消息通知
例如文章更新时,将更新后的文章推送到 ES,用户就能搜索到最新的文章数据。
List 数据结构 Quicklist
QuickList 由一个双向链表和 listpack 实现
计数
一个用户有多项计数需求,可通过 hash 结构存储;
Hash 数据结构 dict
rehash: rehash 操作是将旧的哈希表中的数据,全部迁移到一个更大的哈希表中。数据量小的情况下,直接将数据从旧表迁移到新表的速度是比较快的,但是数据量非常大的场景下,例如存有上百万的 KV 时,迁移过程将会明显阻塞用户请求;
渐进式 rehash:为了避免迁移大量数据明显阻塞用户请求,可以使用渐进式 hash 方案。基本原理是:每次用户访问时都会迁移少量数据,将整个迁移过程平摊到所有访问请求过程中,等所有数据迁移完成后,再交换哈希表的指针。
4 排行榜
积分变化时,排名要实时变更。(zset 进行倒排序)
-
可以增加权重(分数、创建时间等)
-
结合 dict 后,可实现通过 key 操作跳表的功能
- 如何找 7
- 从顶层开始寻找,第一个找到的是 3,7 > 3,因此往右寻找,右侧没有节点了,去下一层寻找,然后找到 7;
- 如何找 1
- 从顶层开始寻找,第一个找到的是 3,1 < 3,因此需要往左找,因为左侧就是 head 节点,所以去下一层找,下一层的左边也是 head 节点,因此继续往下层去找,然后就可以找到 1。
Redis 使用的是跳跃表加 hash 的结构
除此之外,跳跃表节点是一个双向链表节点,因此可以支持数据倒排。
限流
- 要求 1s 内放行的请求为 N,超过 N 则禁止访问;(防止攻击者利用脚本攻击网站)
- 假设我们在一秒内仅允许一个用户请求进来,其他请求都禁止
- 我们可以拼接一个 key,前面部分可以是任意字符串,后面部分是当前的时间戳
- 当用户请求 redis 服务时,服务器根据用户请求到达时这一秒的时间戳拼接出 key,然后查看 redis 缓存中这个 key 的值是否超过限流阈值 N,如果超过,说明这一秒内放行的请求数已经到达限流的阈值,那么这个用户请求就会被禁止,否则的话,调用 Incr 函数将这一秒对应的 key 的值+1,这样就实现了限流。
6 分布式锁
并发场景下,要求一次只能有一个协程执行;协程执行完成后,其他等待中的协程才能继续执行。
可以使用 redis 的 setnx 实现,利用了两个特性:
- Redis 是单线程执行命令,无论多少用户并发请求到达,最终执行时都是串行执行的(严格保证执行顺序)。
- setnx 只有 key 未设置过才能执行成功,那么只要有一个协程设置成功了某个键,那么后续执行的判断条件都将为 false,全部执行失败。(setnx 之所以能够成立,是因为 redis 的执行线程是单线程)
为了避免某个协程长时间持有锁导致异常死锁,需要为锁设置有效期,到期自动释放
setNx 不是高可用的分布式锁实现(redlock),该实现存在的问题:
- 业务超时解锁,导致并发问题(一旦某个协程出现异常阻塞,在 redis 单线程执行模型下,所有其他协程都无法执行,因此需要给锁加上超时,一旦超时自动释放,让其他协程获取锁继续执行。但是还有一个问题:业务执行时间超过锁的超时时间,导致前一个业务释放了后一个业务持有的锁)
- redis 主备切换临界点问题(主备切换后,A 持有的锁还未同步到新的主节点时,B 可以在新的主节点获取到锁定同一资源的锁,违反了安全性)
- redis 集群脑裂,导致出现多个主节点(多个主可能都持有锁,一般解决办法是使用随机超时)
redis 使用注意事项
大 key
大 key 定义
String 类型:value 的字节数大于 10KB 即为大 key;(大 key 并不是 key 很大,而是对应的 value 很大)
Hash、Set、Zset、list 等复杂数据结构类型:元素个数大于 5000 个或总 value 字节数大于 10MB 即为大 key;
大 key 的缺点
- 读取成本高
- 容易导致慢查询(过期、删除)
- 主从复制异常,服务阻塞,无法响应正常请求
- 业务侧使用大 key:请求 redis 超时报错
消除大 Key 的方法
- 拆分
将一个大 key 拆分为多个部分,之前 key 对应的不再是 value 值,而是一个列表,解析这个,分别从列表中读取各个部分的 value 进行拼接得到 value。
- 压缩
将 value 压缩后写入 redis,读取时解压后再使用。
压缩算法可以是 gzip、snappy、lz4 等。
通常,一个压缩算法压缩率高,则解压耗时就比较长,如果存储的是 json 字符串,可以考虑使用 MessgaePack 序列化(对低于一个字节的数组进行了优化,不再浪费额外的 3 个字节)。
-
集合类结构 hash、list、set、zset
(1)拆分:可以用 hash 取余、位掩码的方式决定放在哪个 key 中 (2)区分冷热:如榜单列表场景使用 zset,只缓存前 10 页数据,后面的数据走数据库查询
热 Key
定义
用户访问一个 key 的 qps 特别高,导致 server 实例出现 cpu 负载突增或者不均的情况。
热 key 没有明确的标准,qps 超过 500 就有可能被识别为热 key。
解决热 key 的方法
- 设置 localcache
在访问 redis前,在业务服务侧设置 localcache,降低访问 redis 的 qps。
localcache 中缓存过期或者未命中,则从 redis 中将数据更新到 localcache。
golang 的 bigcache 就是这类 loclcache。
- 拆分
将热 key 复制写入多份,key 值不同,但是值相同,将 qps 分散到不同实例上,降低负载。代价:更新时需要同时更新多个 key,而且要保证这些数据的一致性。
- 使用 redis 代理的热 key 承载能力
字节跳动的 redis 访问代理就具备热 key 的承载能力,本质上是结合了“热 key 发现”、“localcache”两个功能。
客户端请求先发送给 proxy,proxy 将请求转发给 redis 服务器,redis 服务器返回 key 对应的 value 并返回给 proxy,proxy 统计每秒钟 key 的 qps,如果超过 500,就判定为热 key,进行 localcache,然后将请求 key 的 value 返回给客户端,当下次有客户端请求这个 key 的值时,proxy 直接从 localcache 中查询,立即返回给客户端。
慢查询场景
容易导致 redis 慢查询的操作
- 批量操作一次性传入过多的 k、v,如 mset、hmset、sadd、zadd 等 O(n) 操作(建议单批次传入的 k、v 不要超过 100,超过 100 后性能下降明显)。
- zset 大部分命令都是 O(log(n)),当大小超过 5k 以上时,简单的 zadd/zrem 也可能导致慢查询
- 操作的单个 value 过大,超过 10KB(避免使用大 key)
- 对大 key 的删除和设置过期时间的操作也可能导致慢查询,redis4.0 之前不支持异步删除 unlink,大 key 删除会阻塞 redis。
缓存穿透、缓存雪崩
定义
缓存穿透:(数据查询穿透了 redis,直接查询数据库)热点数据查询绕过缓存,直接查询数据库
缓存雪崩:大量缓存同时过期(相当于缓存层几乎失效)
危害
- 缓存穿透的危害
- 查询一个一定不存在的数据:通常不会缓存不存在的数据,这类查询请求都会直接打到 db,如果有系统 bug 或者人为攻击(使用脚本一秒钟访问一万次不存在的 key),容易导致 db 响应慢甚至宕机
- 缓存过期时:在高并发场景下,一个热 key 如果过期,会有大量请求同时穿透(击穿)至 db,容易影响 db 性能和稳定。同一时间有大量 key 集中过期时,也会导致大量请求落到 db 上,导致查询变慢,甚至出现 db 无法响应新的查询的现象。
如何减少缓存穿透
(1)缓存空值:如果一个不存在的 userID,这个 id 在缓存和数据库中都不存在,则可以缓存一个控制,下次再查这个不存在的 userId 时直接从缓存返回控制,而不需要去查询数据库了。 (2)布隆过滤器:使用 bloom fliter 算法来存储合法 key,得益于该算法超高的压缩率,只需要占用极小的空间就能存储大量的 key 值,而且它可以对不存在的 key 提供百分之百的保证。
如何避免缓存雪崩
(1)缓存空值:将缓存失效时间分散开,比如在原有的失效时间基础上增加一个随机值,这样大概率可以使不同 key 的过期时间分散。对于热点数据,过期时间应该尽量设置的长一点,冷门的数据可以相对设置的短一些。 (2)使用缓存集群:避免单机宕机造成的缓存雪崩(如果使用单机架构,那么其中一个实例宕机后,原本发送给它的请求全部转移到另一个单机实例上,也就意味着另一个实例所需要承受的请求压力骤增,如果这一个实例没有抗住宕机了,那么更大的请求压力将会到达下一个实例,就像滚雪球一样,请求压力越来越大,最终导致所有实例都失效)。