ShiningDan的博客

Redis原理与应用实践

本文是我在学习 Redis 的时候做的总结,关于原理以及应用实践相关。

文章很多参考《Redis 深度历险:核心原理与应用实践 | 掘金小册》,对里面的内容作了一些个人的总结。原系列内容丰富,大家可以购买,多多支持正版~

使用 Redis

1
2
3
4
5
6
# 拉取 redis 镜像
> docker pull redis
# 运行 redis 容器
> docker run --name myredis -d -p6379:6379 redis
# 执行容器中的 redis-cli,可以直接使用命令行操作 redis
> docker exec -it myredis redis-cli

基础介绍

数据类型

字符串

除了一些常用的字符串操作命令(get、set、append、strlen、setrange 等等)以外,还有一些需要注意的命令:

  1. incr/incrby/decr/decrby:对字符串转换成数值类型进行计算,增加/减少数值

列表(list)

本质是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)

在列表元素较少的情况下会使用一块连续的内存存储,这个结构是 ziplist。当数据量比较多的时候会改成 quicklist。也就是将多个 ziplist 使用双向指针串起来使用。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。

字典(hashmap)

内部实现结构上同 Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞时,就会将碰撞的元素使用链表串接起来。

Redis 的字典的值只能是字符串

set (集合)

Redis 的集合相当于 Java 语言里面的 HashSet,它内部的键值对是无序的唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值NULL。

zset (有序集合)

类似于 Java 的 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以给每个 value 赋予一个 score,代表这个 value 的排序权重。它的内部实现用的是一种叫做「跳跃列表」的数据结构。

容器型数据结构的通用规则

list/set/hash/zset 这四种数据结构是容器型数据结构,它们共享下面两条通用规则:

  1. create if not exists
  2. drop if no elements

过期时间

Redis 所有的数据结构都可以设置过期时间。需要注意的是过期是以对象为单位,比如一个 hash 结构的过期是整个 hash 对象的过期,而不是其中的某个子 key。

应用

消息队列

用 Rabbitmq 和 Kafka 作为消息队列中间件,流程非常复杂,对于那些只有一组消费者的消息队列,使用 Redis 就可以非常轻松的搞定。

可持久化的消息队列

Redis5.0 最重要的特性 Stream,是一个新的强大的支持多播的可持久化的消息队列,借鉴了 Kafka 的设计。

Redis Stream 有一个消息链表,将所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。消息是持久化的

每个 Stream 都可以挂多个消费组,每个消费组会有个游标last_delivered_id在 Stream 数组之上往前移动,表示当前消费组已经消费到哪条消息了。每个消费组 (Consumer Group) 的状态都是独立的,相互不受影响。也就是说同一份 Stream 内部的消息会被每个消费组都消费到。同一个消费组 (Consumer Group) 可以挂接多个消费者 (Consumer),这些消费者之间是竞争关系。

实现原理

Redis 的 list(列表) 数据结构常用来作为异步消息队列使用,使用 rpush/lpush 操作入队列,使用 lpoprpop 来出队列。

阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。消息的延迟几乎为零。用 blpop/brpop 替代前面的 lpop/rpop,可以用来解决队列空的时候,浪费资源的空轮询。

延时队列

场景

  • 下单之后如果三十分钟之内没有付款就自动取消订单。
  • 订餐通知:下单成功后60s之后给用户发送短信通知。
  • 业务执行失败之后隔10分钟重试一次
  • 业务执行失败之后隔10分钟重试一次

等,在一定时间后才进行触发的任务。

延时队列有多种实现方案,可以参考 你真的了解延时队列吗(一)你真的了解延时队列吗(二) 等。

实现原理

延时队列可以通过 Redis 的 zset(有序列表) 来实现。我们将消息序列化成一个字符串作为 zset 的 value,这个消息的到期处理时间作为 score。因为有多个线程,所以需要考虑并发争抢任务,确保任务不能被多次执行。使用 zrem 方法来获得到期的任务。

因为使用到期时间作为 score,在有序列表中,所有的信息在存入的时候都按照 score 依据时间顺序排好序。如果是将所有的消息存放到普通数据库中,则每次获得到期消息都需要遍历一遍数据库中所有的数据,而 Redis zset 方法只需要取出 topN 即可。

Redis 的 zrem 方法是多线程多进程争抢任务的关键,它的返回值决定了当前实例有没有抢到任务,因为 loop 方法可能会被多个线程、多个进程调用,同一个任务可能会被多个进程线程抢到,通过 zrem 来决定唯一的属主。

优点

  1. 时间复杂度低:遍历数据库时间复杂度为 O(N),zset 时间复杂度为 O(lg(N))
  2. 相比于所有服务器进行轮询,该方法可以保证一个消息只被一个线程程进行处理

分布式锁

实现原理

加锁:

使用 setnx(set if not exists) 指令,只允许被一个客户端占。先来先占,用完了,再调用 del 指令释放。

如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放:

锁加上一个过期时间,比如 5s

最终方案:

1
2
3
4
> set lock:codehole true ex 5 nx
OK
... do something critical ...
> del lock:codehole

锁冲突处理

客户端在处理请求时加锁没加成功怎么办。一般有 3 种策略来处理加锁失败:

直接抛出异常,通知用户稍后重试;
sleep 一会再重试;
将请求转移至延时队列,过一会再试;

直接抛出特定类型的异常

比较适合由用户直接发起的请求,本质上是对当前请求的放弃,由用户决定是否重新发起新的请求。

sleep

sleep 会阻塞当前的消息处理线程,会导致队列的后续消息处理出现延迟。如果碰撞的比较频繁或者队列里消息比较多,sleep 可能并不合适。

消息队列

这种方式比较适合异步消息处理,将当前冲突的请求扔到另一个队列延后处理以避开冲突。

统计 PV/UV

PV 那非常好办,给每个网页一个独立的 Redis 计数器就可以了,这个计数器的 key 后缀加上当天的日期。这样来一个请求,incrby 一次,最终就可以统计出所有的 PV 数据。

UV 不一样,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求每一个网页请求都需要带上用户的 ID,无论是登陆用户还是未登陆用户都需要一个唯一 ID 来标识。可以为每一个页面一个独立的 set 集合来存储所有当天访问过此页面的用户 ID。如果你的页面访问量非常大,比如一个爆款页面几千万的 UV,你需要一个很大的 set 集合来统计,这就非常浪费空间

Redis 提供了 HyperLogLog 数据结构就是用来解决这种统计问题的。HyperLogLog 提供不精确的去重计数方案,标准误差是 0.81%

原理

HyperLogLog 实现原理可以自己搜索,大致是:给定一系列的随机整数,我们记录下低位连续零位的最大长度 k,通过这个 k 值可以估算出随机数的数量。

大量数据去重

比如我们在使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容。如果历史记录存储在关系数据库里,去重就需要频繁地对数据库进行 exists 查询,当系统并发量很高时,数据库是很难扛住压力的。你可能又想到了缓存,但是如此多的历史记录全部缓存起来,那得浪费多大存储空间啊。

布隆过滤器 (Bloom Filter)专门用来解决这种去重问题的。它在起到去重的同时,在空间上还能节省 90% 以上,只是稍微有那么点不精确,也就是有一定的误判概率。

布隆过滤器可以理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某个对象是否存在时,它可能会误判。布隆过滤器对于已经见过的元素肯定不会误判,它只会误判那些没见过的元素。

原理

向布隆过滤器中添加 key 时,会使用多个 hash 函数对 key 进行 hash 算得一个整数索引值然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。

向布隆过滤器询问 key 是否存在时,跟 add 一样,也会把 hash 的几个位置都算出来,看看位数组中这几个位置是否都为 1,只要有一个位为 0,那么说明布隆过滤器中这个 key 不存在。

简单限流

场景

系统要限定用户的某个行为在指定的时间里只能允许发生 N 次

用户的发帖、回复、点赞等行为都要严格受控,一般要严格限定某行为在规定时间内允许的次数,超过了次数那就是非法行为。

实现原理

使用滑动时间窗口,zset 数据结构通过 score 来圈出这个时间窗口来。

整体思路就是:每一个行为到来时,都维护一次时间窗口。将时间窗口外的记录全部清理掉,只保留窗口内的记录。zset 集合中只有 score 值非常重要,value 值没有特别的意义,只需要保证它是唯一的就可以了。

地理位置计算

Redis 在 3.2 版本以后增加了地理位置 GEO 模块,意味着我们可以使用 Redis 来实现摩拜单车「附近的 Mobike」、美团和饿了么「附近的餐馆」这样的功能了。

如果是使用普通数据库:

1
select id from positions where x0-r < x < x0+r and y0-r < y < y0+r

数据库查询性能毕竟有限,如果「附近的人」查询请求非常多,在高并发场合,这可能并不是一个很好的方案。

实现原理

业界比较通用的地理位置距离排序算法是 GeoHash 算法,Redis 也使用 GeoHash 算法。使用的是二刀法,编码之后,每个地图元素的坐标都将变成一个整数,通过这个整数可以还原出元素的坐标,整数越长,还原出来的坐标值的损失程度就越小。编码之后,每个地图元素的坐标都将变成一个整数,通过这个整数可以还原出元素的坐标,整数越长,还原出来的坐标值的损失程度就越小。通过 zset 的 score 排序就可以得到坐标附近的其它元素

存储 Boolean 数据

开发过程中,会有一些 bool 型数据需要存取,比如用户一年的签到记录,签了是 1,没签是 0,要记录 365 天。如果使用普通的 key/value,每个用户要记录 365 个,当用户上亿的时候,需要的存储空间是惊人的。

Redis 提供了位图数据结构,这样每天的签到记录只占据一个位,365 天就是 365 个位,46 个字节 (一个稍长一点的字符串) 就可以完全容纳下,这就大大节约了存储空间。

可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作 getbit/setbit 等将 byte 数组看成「位数组」来处理。

查找 key

如何从海量的 key 中找出满足特定前缀的 key 列表来?

Redis 提供了一个简单暴力的指令 keys 用来列出所有满足特定正则字符串规则的 key。但是有很明显的两个缺点

  1. 没有 offset、limit 参数
  2. keys 算法是遍历算法,复杂度是 O(n)

Redis 为了解决这个问题,它在 2.8 版本中加入了指令——scan

  1. 复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程
  2. 提供 limit 参数,limit 不是限定返回结果的数量,而是限定服务器单次遍历的字典槽位数量(约等于)
  3. 提供模式匹配功能
  4. 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数
  5. 返回的结果可能会有重复,需要客户端去重复
  6. 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的
  7. 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零

实现原理

Redis 所有的 key 都存储在一个很大的 HashMap 中,是一维数组 + 二维链表结构。该命令遍历的是 HashMap 的一维数组(槽位)。

资源回收策略

当实际内存超出 maxmemory 时,Redis 提供了几种可选策略 (maxmemory-policy) 来让用户自己决定该如何腾出新的空间以继续提供读写服务。

  • noeviction 不会继续服务写请求 (DEL 请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。
  • volatile-lru 尝试淘汰设置了过期时间的 key,最少使用的 key 优先被淘汰。没有设置过期时间的 key 不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。
  • volatile-ttl 跟上面一样,除了淘汰的策略不是 LRU,而是 key 的剩余寿命 ttl 的值,ttl 越小越优先被淘汰。
  • volatile-random 跟上面一样,不过淘汰的 key 是过期 key 集合中随机的 key。
  • allkeys-lru 区别于 volatile-lru,这个策略要淘汰的 key 对象是全体的 key 集合,而不只是过期的 key 集合。这意味着没有设置过期时间的 key 也会被淘汰。
  • allkeys-random 跟上面一样,不过淘汰的策略是随机的 key。

懒惰删除

删除指令 del 会直接释放对象的内存,大部分情况下,这个指令非常快,没有明显延迟。不过如果删除的 key 是一个非常大的对象,比如一个包含了千万元素的 hash,那么删除操作就会导致单线程卡顿。

Redis 为了解决这个卡顿问题,在 4.0 版本引入了 unlink 指令,它能对删除操作进行懒处理,丢给后台线程来异步回收内存。

Redis Lua 脚本

Redis 为这样的用户场景提供了 lua 脚本支持,用户可以向服务器发送 lua 脚本来执行自定义动作,获取脚本的响应数据。Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。

参考