Redis 实战
Redis 实战
缓存
缓存是 Redis 最常见的应用场景。
Redis 有多种数据类型,以及丰富的操作命令,并且有着高性能、高可用的特性,非常适合用于分布式缓存。
缓存应用的基本原理,请参考 缓存基本原理 第四 ~ 第六节内容。
BitMap 和 BloomFilter
Redis 除了 5 种基本数据类型外,还支持 BitMap 和 BloomFilter(即布隆过滤器,可以通过 Redis Module 支持)。
BitMap 和 BloomFilter 都可以用于解决缓存穿透问题。要点在于:过滤一些不可能存在的数据。
什么是缓存穿透,可以参考:缓存基本原理
小数据量可以用 BitMap,大数据量可以用布隆过滤器。
分布式锁
使用 Redis 作为分布式锁,基本要点如下:
- 互斥性 - 使用
setnx
抢占锁。 - 避免永远不释放锁 - 使用
expire
加一个过期时间,避免一直不释放锁,导致阻塞。 - 原子性 - setnx 和 expire 必须合并为一个原子指令,避免 setnx 后,机器崩溃,没来得及设置 expire,从而导致锁永不释放。
更多分布式锁的实现方式及细节,请参考:分布式锁基本原理
根据 Redis 的特性,在实际应用中,存在一些应用小技巧。
keys 和 scan
使用 keys
指令可以扫出指定模式的 key 列表。
如果这个 redis 正在给线上的业务提供服务,那使用 keys
指令会有什么问题?
首先,Redis 是单线程的。keys
指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。
这个时候可以使用 scan
指令,scan
指令可以无阻塞的提取出指定模式的 key 列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys
指令长。
不过,增量式迭代命令也不是没有缺点的: 举个例子, 使用 SMEMBERS
命令可以返回集合键当前包含的所有元素, 但是对于 SCAN
这类增量式迭代命令来说, 因为在对键进行增量式迭代的过程中, 键可能会被修改, 所以增量式迭代命令只能对被返回的元素提供有限的保证 。
大 Key 如何处理
什么是 Redis 大 key?
大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。
一般而言,下面这两种情况被称为大 key:
- String 类型的值大于 10 KB;
- Hash、List、Set、ZSet 类型的元素的个数超过 5000 个;
大 key 会造成什么问题?
大 key 会带来以下四种影响:
- 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
- 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
- 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
- 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多,QPS 也会比较大。
如何找到大 key ?
1、redis-cli –bigkeys 查找大 key
可以通过 redis-cli –bigkeys 命令查找大 key:
1 | redis-cli -h 127.0.0.1 -p 6379 -a "password" -- bigkeys |
使用的时候注意事项:
- 最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点;
- 如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。
该方式的不足之处:
- 这个方法只能返回每种类型中最大的那个 bigkey,无法得到大小排在前 N 位的 bigkey;
- 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大;
2、使用 SCAN 命令查找大 key
使用 SCAN 命令对数据库扫描,然后用 TYPE 命令获取返回的每一个 key 的类型。
对于 String 类型,可以直接使用 STRLEN 命令获取字符串的长度,也就是占用的内存空间字节数。
对于集合类型来说,有两种方法可以获得它占用的内存大小:
- 如果能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。List 类型:
LLEN
命令;Hash 类型:HLEN
命令;Set 类型:SCARD
命令;Sorted Set 类型:ZCARD
命令; - 如果不能提前知道写入集合的元素大小,可以使用
MEMORY USAGE
命令(需要 Redis 4.0 及以上版本),查询一个键值对占用的内存空间。
3、使用 RdbTools 工具查找大 key
使用 RdbTools 第三方开源工具,可以用来解析 Redis 快照(RDB)文件,找到其中的大 key。
比如,下面这条命令,将大于 10 kb 的 key 输出到一个表格文件。
1 | rdb dump.rdb -c memory --bytes 10240 -f redis.csv |
如何删除大 key?
删除操作的本质是要释放键值对占用的内存空间,不要小瞧内存的释放过程。
释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。
所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。
因此,删除大 key 这一个动作,我们要小心。具体要怎么做呢?这里给出两种方法:
- 分批次删除
- 异步删除(Redis 4.0 版本以上)
1、分批次删除
对于删除大 Hash,使用 hscan
命令,每次获取 100 个字段,再用 hdel
命令,每次删除 1 个字段。
Python 代码:
1 | def del_large_hash(): |
对于删除大 List,通过 ltrim
命令,每次删除少量元素。
Python 代码:
1 | def del_large_list(): |
对于删除大 Set,使用 sscan
命令,每次扫描集合中 100 个元素,再用 srem
命令每次删除一个键。
Python 代码:
1 | def del_large_set(): |
对于删除大 ZSet,使用 zremrangebyrank
命令,每次删除 top 100 个元素。
Python 代码:
1 | def del_large_sortedset(): |
2、异步删除
从 Redis 4.0 版本开始,可以采用异步删除法,用 unlink 命令代替 del 来删除。
这样 Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。
除了主动调用 unlink 命令实现异步删除之外,我们还可以通过配置参数,达到某些条件的时候自动进行异步删除。
主要有 4 种场景,默认都是关闭的:
1 | lazyfree-lazy-eviction no |
它们代表的含义如下:
- lazyfree-lazy-eviction:表示当 Redis 运行内存超过 maxmeory 时,是否开启 lazy free 机制删除;
- lazyfree-lazy-expire:表示设置了过期时间的键值,当过期之后是否开启 lazy free 机制删除;
- lazyfree-lazy-server-del:有些指令在处理已存在的键时,会带有一个隐式的 del 键的操作,比如 rename 命令,当目标键已存在,Redis 会先删除目标键,如果这些目标键是一个 big key,就会造成阻塞删除的问题,此配置表示在这种场景中是否开启 lazy free 机制删除;
- slave-lazy-flush:针对 slave (从节点) 进行全量数据同步,slave 在加载 master 的 RDB 文件前,会运行 flushall 来清理自己的数据,它表示此时是否开启 lazy free 机制删除。
建议开启其中的 lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-server-del 等配置,这样就可以有效的提高主线程的执行效率。
最受欢迎文章
选出最受欢迎文章,需要支持对文章进行评分。
对文章进行投票
(1)使用 HASH 存储文章
使用 HASH
类型存储文章信息。其中:key 是文章 ID;field 是文章的属性 key;value 是属性对应值。
操作:
- 存储文章信息 - 使用
HSET
或HMGET
命令 - 查询文章信息 - 使用
HGETALL
命令 - 添加投票 - 使用
HINCRBY
命令
(2)使用 ZSET
针对不同维度集合排序
使用 ZSET
类型分别存储按照时间排序和按照评分排序的文章 ID 集合。
操作:
- 添加记录 - 使用
ZADD
命令 - 添加分数 - 使用
ZINCRBY
命令 - 取出多篇文章 - 使用
ZREVRANGE
命令
(3)为了防止重复投票,使用 SET
类型记录每篇文章 ID 对应的投票集合。
操作:
- 添加投票者 - 使用
SADD
命令 - 设置有效期 - 使用
EXPIRE
命令
(4)假设 user:115423 给 article:100408 投票,分别需要高更新评分排序集合以及投票集合。
当需要对一篇文章投票时,程序需要用 ZSCORE 命令检查记录文章发布时间的有序集合,判断文章的发布时间是否超过投票有效期(比如:一星期)。
1 | public void articleVote(Jedis conn, String user, String article) { |
发布并获取文章
发布文章:
- 添加文章 - 使用
INCR
命令计算新的文章 ID,填充文章信息,然后用HSET
命令或HMSET
命令写入到HASH
结构中。 - 将文章作者 ID 添加到投票名单 - 使用
SADD
命令添加到代表投票名单的SET
结构中。 - 设置投票有效期 - 使用
EXPIRE
命令设置投票有效期。
1 | public String postArticle(Jedis conn, String user, String title, String link) { |
分页查询最受欢迎文章:
使用 ZINTERSTORE
命令根据页码、每页记录数、排序号,根据评分值从大到小分页查出文章 ID 列表。
1 | public List<Map<String, String>> getArticles(Jedis conn, int page, String order) { |
对文章进行分组
如果文章需要分组,功能需要分为两块:
- 记录文章属于哪个群组
- 负责取出群组里的文章
将文章添加、删除群组:
1 | public void addRemoveGroups(Jedis conn, String articleId, String[] toAdd, String[] toRemove) { |
取出群组里的文章:
- 通过对存储群组文章的集合和存储文章评分的有序集合执行
ZINTERSTORE
命令,可以得到按照文章评分排序的群组文章。 - 通过对存储群组文章的集合和存储文章发布时间的有序集合执行
ZINTERSTORE
命令,可以得到按照文章发布时间排序的群组文章。
1 | public List<Map<String, String>> getGroupArticles(Jedis conn, String group, int page, String order) { |
管理令牌
网站一般会以 Cookie、Session、令牌这类信息存储用户身份信息。
可以将 Cookie/Session/令牌 和用户的映射关系存储在 HASH
结构。
下面以令牌来举例。
查询令牌
1 | public String checkToken(Jedis conn, String token) { |
更新令牌
- 用户每次访问页面,可以记录下令牌和当前时间戳的映射关系,存入一个
ZSET
结构中,以便分析用户是否活跃,继而可以周期性清理最老的令牌,统计当前在线用户数等行为。 - 用户如果正在浏览商品,可以记录到用户最近浏览过的商品有序集合中(集合可以限定数量,超过数量进行裁剪),存入到一个
ZSET
结构中,以便分析用户最近可能感兴趣的商品,以便推荐商品。
1 | public void updateToken(Jedis conn, String token, String user, String item) { |
清理令牌
上一节提到,更新令牌时,将令牌和当前时间戳的映射关系,存入一个 ZSET
结构中。所以可以通过排序得知哪些令牌最老。如果没有清理操作,更新令牌占用的内存会不断膨胀,直到导致机器宕机。
比如:最多允许存储 1000 万条令牌信息,周期性检查,一旦发现记录数超出 1000 万条,将 ZSET 从新到老排序,将超出 1000 万条的记录清除。
1 | public static class CleanSessionsThread extends Thread { |