本文是我在学习 Redis 的时候做的总结,关于原理以及应用实践相关。
文章很多参考《Redis 深度历险:核心原理与应用实践 | 掘金小册》,对里面的内容作了一些个人的总结。原系列内容丰富,大家可以购买,多多支持正版~
使用 Redis
1 | # 拉取 redis 镜像 |
基础介绍
数据类型
字符串
除了一些常用的字符串操作命令(get、set、append、strlen、setrange 等等)以外,还有一些需要注意的命令:
- 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 这四种数据结构是容器型数据结构,它们共享下面两条通用规则:
- create if not exists
- 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
操作入队列,使用 lpop
和 rpop
来出队列。
阻塞读在队列没有数据的时候,会立即进入休眠状态,一旦数据到来,则立刻醒过来。消息的延迟几乎为零。用 blpop/brpop
替代前面的 lpop/rpop
,可以用来解决队列空的时候,浪费资源的空轮询。
延时队列
场景
- 下单之后如果三十分钟之内没有付款就自动取消订单。
- 订餐通知:下单成功后60s之后给用户发送短信通知。
- 业务执行失败之后隔10分钟重试一次
- 业务执行失败之后隔10分钟重试一次
等,在一定时间后才进行触发的任务。
延时队列有多种实现方案,可以参考 你真的了解延时队列吗(一),你真的了解延时队列吗(二) 等。
实现原理
延时队列可以通过 Redis 的 zset(有序列表) 来实现。我们将消息序列化成一个字符串作为 zset 的 value
,这个消息的到期处理时间作为 score
。因为有多个线程,所以需要考虑并发争抢任务,确保任务不能被多次执行。使用 zrem
方法来获得到期的任务。
因为使用到期时间作为 score,在有序列表中,所有的信息在存入的时候都按照 score 依据时间顺序排好序。如果是将所有的消息存放到普通数据库中,则每次获得到期消息都需要遍历一遍数据库中所有的数据,而 Redis zset 方法只需要取出 topN 即可。
Redis 的 zrem 方法是多线程多进程争抢任务的关键,它的返回值决定了当前实例有没有抢到任务,因为 loop 方法可能会被多个线程、多个进程调用,同一个任务可能会被多个进程线程抢到,通过 zrem 来决定唯一的属主。
优点
- 时间复杂度低:遍历数据库时间复杂度为 O(N),zset 时间复杂度为 O(lg(N))
- 相比于所有服务器进行轮询,该方法可以保证一个消息只被一个线程程进行处理
分布式锁
实现原理
加锁:
使用 setnx(set if not exists) 指令,只允许被一个客户端占。先来先占,用完了,再调用 del 指令释放。
如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放:
锁加上一个过期时间,比如 5s
最终方案:
1 | > set lock:codehole true ex 5 nx |
锁冲突处理
客户端在处理请求时加锁没加成功怎么办。一般有 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。但是有很明显的两个缺点:
- 没有 offset、limit 参数
- keys 算法是遍历算法,复杂度是 O(n)
Redis 为了解决这个问题,它在 2.8 版本中加入了指令——scan
- 复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程
- 提供 limit 参数,limit 不是限定返回结果的数量,而是限定服务器单次遍历的字典槽位数量(约等于)
- 提供模式匹配功能
- 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数
- 返回的结果可能会有重复,需要客户端去重复
- 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的
- 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零
实现原理
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 脚本在处理的过程中不会被任意其它请求打断。