读「How to Manage a Redis Database」的一点随笔

前段时间在使用 DO 的时候发现他们把自己的部分文档整理成了一本"书”——「How To Manage a Redis Database eBook | DigitalOcean」,全书不长,将 redis 的一些常规操作和一点点细节分章节展示了一下,花了点时间看了之后还是感觉学到了一些之前没有特别关注的比较有意思的地方,本文便来简要记录一下这些点。

什么是 Redis 以及 Redis 支持的数据类型

我看那些人面试还问 Redis,Redis 有什么卵用?

——我校上一届某程序设计大赛大佬

Redis is an open source (BSD licensed), in-memory data structure store, used as a database, cache and message broker.

一般来说我们都会常规地把 Redis 理解为一个内存的 KV 数据库,许多时候我们见到它是在缓存里面,其次是一些消息队列的 broker(比如 celery),当然,除了这些常规用途之外甚至能见到一些遇事不决就直接全页面丢 Redis 企图从 Redis 输出来提升一些自己站点 “并发” 的奇怪操作。

关于 Redis 的性能相关我们后面来说,首先大概过一下 Redis 可以支持啥不同的数据类型,除了我们对于 KV 经常想到的 Key Value 全是 string 以外,Value 还可以是:

hashes, lists, sets, sorted sets with range queries, bitmaps, hyperloglogs, geospatial indexes with radius queries and streams

有点多哈~

对于我们最常用的 string 而言,Value 的最大容量是 500M,且「二进制安全」,所以无论是普通的 text,还是文本或者图片,都可以作为 string 存入。

Redis 中对于 Set 的操作

上面说到了,除了 string 以外,Redis 可以支持存入一个 Set(集合),比如我们可以这样来创建一个包含 4 个元素的 Set,Key 是 nova

127.0.0.1:6379> SADD nova kwok moe redis test
(integer) 4

127.0.0.1:6379> SMEMBERS nova
1) "test"
2) "redis"
3) "moe"
4) "kwok"

我们知道,对于栈来说,一般会有 POP 和 PUSH 两个方法,有意思的是,对于 Redis 的 Set,也有 POP 方法,用法是这样的:

127.0.0.1:6379> SPOP nova 2
1) "redis"
2) "test"

127.0.0.1:6379> SMEMBERS nova
1) "moe"
2) "kwok"

Redis 会随机地取两个值返回出来,并且删掉对应的值,如果后面没有指定那个 2 的话,只会随机取一个值出来(并删除)。

SPOP 并不适合用来做均匀分布的随机采样,背后使用的算法是 Knuth sampling 和 Floyd sampling。

说完了 SPOP 之后,你会发现它甚至还有一个类似的随机读取元素的方法,叫做 SMEMBERS,比如:

127.0.0.1:6379> SMEMBERS nova
1) "moe"
2) "kwok"

127.0.0.1:6379> SRANDMEMBER nova
"moe"

后面的参数甚至可以是负数,这样会随机取出来一个元素并且返回两次,像这样:

127.0.0.1:6379> SRANDMEMBER nova -2
1) "kwok"
2) "kwok"

这个值(的绝对值)甚至可以超过整个 Set 容量:

127.0.0.1:6379> SRANDMEMBER nova -5
1) "kwok"
2) "moe"
3) "kwok"
4) "kwok"
5) "kwok"

这是个什么需求呀.webp

不管了,这个功能先加上去,显得我们工作量很多(

——某不愿意透露姓名的名是 K 开头的男子

对于 SRANDMEMBER 官方对于它的分布是这样定义的:

The distribution of the returned elements is far from perfect when the number of elements in the set is small, this is due to the fact that we used an approximated random element function that does not really guarantees good distribution.

简单来说,如果集合太小,取样概率也不均匀。

如果希望进一步了解上述概率有多不均匀,可以参考 「Sampling characteristics of Redis sets」,以及 Redis 作者对此的回复 https://www.reddit.com/r/redis/comments/aro3lz/sampling_from_sets_in_redis/

附图一张:

Replica 机制

或者就是 Master Slave 模式,在 5.x 之后被称为 replica,之前都是 slave

如果你的单 Redis 实例由于某些原因导致性能不够的时候(Redis 并非 CPU 或者 IO 密集型应用),可以通过打开新起一个实例并设置为原有实例的 Replica 进行,方法很简单,如果在 redis-cli 中,使用:

REPLICAOF <Host> <Port>

4.x 版本是:

SLAVEOF <Host> <Port>

注意默认 Port 是 6379 ,不是 2379…

为了区分 “同步”(动词) 与 “同步/异步” 中的同步,以下 “同步”(动词) 改为 “传送”

当成功建立连接之后 Master 节点会定期向 Replica 节点传送在 Master 上执行的指令(类似 AOF 模式),如果 Replica 短暂掉线,重新恢复后会继续尝试传送缺失的数据,如果不能通过传送缺失数据恢复,Master 则会建立一个 snapshot 传送给 Replica 进行恢复(类似 RDB 模式)。

如果是这种 Master-Replica 传送的话,每次传送是异步进行的,即不能保证 Master 和 Replica 上数据强一致,此时 Redis “集群” 是一个 AP 系统,如果不希望异步传送的话,可以加上 WAIT,当然,这样也并不会把集群变成一个强一致 CP 系统。

Redis 如何持久化数据

如果只是作为业务前的一个串行的缓存的话,可能你并没有怎么考虑过持久化这个话题(大不了 Redis 挂了,缓存数据没了然后请求全部落到数据库上,数据库被打穿嘛),但是 Redis 的确有它独有的持久化方式,被称为——AOF(Append Only File) 和 RDB(Redis Database File).

  • RDB 方式是在「进行持久化操作的时候」将 Redis 的用户数据压缩存储起来,存储的是 Redis 中的数据内容,类似 MySQL 中 mysqldump 出来的数据
  • AOF 的方式是将用户的操作记录全部给记录下来,并在恢复的时候全部重放(replay)一遍,恢复到「进行持久化时候的状态」,相比较而言,这种方式产生的文件大小一般会大于 RDB 方式持久化的数据,且恢复起来比较慢,但是好处在于它是每条操作全部记录,不容易出现 RDB 持久化后进行了一些操作之后 Redis 崩溃而失去那个时间段的数据的问题

当然,你也可以 RDB 和 AOF 的方式都打开,但是重启 Redis 后,它会优先使用 AOF 方式的数据(因为更加有可能包含最后一条操作)。

要保存当前 Redis 实例中的数据的话,使用 save 就好了,会用 RDB 方式导出一个 .rdb 文件,这是个同步操作,由于 Redis 是单线程模型,这样的操作会 block 掉其他 client 的请求,所以如果不是为了关机 (跑路) 的话,建议使用 bgsave 来进行,此时 Redis 会 Fork 出来一个线程用来保存数据,父线程继续处理 Client 的请求。

如果要自动进行 RDB 持久化的话,通过修改 /etc/redis.conf 中对应字段:

save 900 1
save 300 10
save 60 10000
. . .
dbfilename "nextfile.rdb"

其中 900 1 表示,如果至少改了一个 key 的话,900 秒持久化一次,300 10 表示如果操作了 10 个 key 及以上的话,每 300 秒持久化一次,以此类推。

官方对于这两种不同的备份方式有更加详尽的介绍,可以参考:「Redis Persistence」一文。

Redis 是如何给 Key 过期的

我们知道,在 SET 一个 Key 的时候,有一个可选的选项,叫做 expire,例如:

SET nova "kwok expire in a minute" EX 60

那么这个 Key 会在 60 秒后过期,之后就再也 get 不到它了,那么 redis 是如何设计自己的 Key 过期机制的呢?

在官方文档「EXPIRE – Redis」中,我们可以知道,Redis 对于 Key 的过期有两种方式,一种是「被动方式,passive way」,一种是「主动方式,active way」, passive way 很好理解,当来了一个请求的时候判断一下 Key 是否过期了,如果过期了,就把 Key 删了,然后返回个 nil,仿佛这个 Key 没有存在过,但是如果只有这一种 expire 方式的话,一旦大量 key 长期没有被使用,Redis 就把内存吃完了,所以还有个 active way,原理如下:

每 1/10 秒内就执行一遍以下操作:

  1. 首先随机选择 20 个设置了 expire 参数的 key
  2. 把过期了的 key 给删了
  3. 如果删的 key 数量超过 25% 了,那么再回到第一步

对应的代码在 src/expire.c 下,函数如下:

void activeExpireCycle(int type)

从函数传入参数 type 配合注释来看,activeExpireCycle 有两种模式:

  • ACTIVE_EXPIRE_CYCLE_FAST,这种模式下 Expire Key 的原则是删过期 Key 的时间不超过定义的 EXPIRE_FAST_CYCLE_DURATION(目前的定义是 1 秒),如果一次删 Key 超过了 1 秒,那么不会运行下一次删除
  • ACTIVE_EXPIRE_CYCLE_SLOW,这个会通过 ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 来进行判断,这个目前的定义是 25(25% 的 CPU 占用率)

相关的常量定义如下,如果希望最简单地修改 expire 逻辑的话,可以通过直接修改这些值并重新编译来完成。

#define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* 每次找 20 个 Key. */
#define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. */
#define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. */
#define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which
                                                   we do extra efforts. */

对于上述步骤 3 来说,是通过一个 do while 循环实现:

do{
	// 清理 Key
} while (sampled == 0 ||
        (expired*100/sampled) > config_cycle_acceptable_stale);

检查 key 是否过期的函数:

int checkAlreadyExpired(long long when) {
    /* EXPIRE with negative TTL, or EXPIREAT with a timestamp into the past
     * should never be executed as a DEL when load the AOF or in the context
     * of a slave instance.
     *
     * Instead we add the already expired key to the database with expire time
     * (possibly in the past) and wait for an explicit DEL from the master. */
    return (when <= mstime() && !server.loading && !server.masterhost);
}

Transaction (Redis 的事务)(误

这里需要记住的是,Redis does not have any rollback mechanism at all - that isn’t what Redis’ transaction are about.

记住了?我们继续~

Redis 中我们可以提交一系列的操作,通过如下方式进行:

multi
set key_MeaningOfLife 1
incr key_MeaningOfLife
incrby key_MeaningOfLife 40get key_MeaningOfLife
exec

然后 Redis 会依次返回每次的操作结果。

但是这里要注意的是,除非有语法错误,否则 Transaction 并不会保证整个 Block 内是完全执行的,比如如下操作:

127.0.0.1:6379> get noo
(nil)
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set noo 146
QUEUED
127.0.0.1:6379> incrby noo "nova"
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) ERR value is not an integer or out of range
127.0.0.1:6379> get noo
"146"

上述操作中没有语法错误,但是尝试给 146 自增了一个 string,所以在那个指令上报错了,但是,set 指令依然是有效的,且会被执行(提交),又由于开头我们提到的,Redis 的 Transaction 中是没有回滚机制的,所以这个是一个和关系型数据库中同术语但是意义完全不同的一个操作,希望引起注意。

Redis 性能如何

「我就感觉到快.jpg」

「有缓存肯定就和 Nginx 一样快了」

Redis 究竟有多快?一般我们可以从 Troughtput(OPS) 和延迟两个角度来进行,对于前者,官方给的图如下:

此外我们可以在 「Redis 的性能幻想与残酷现实」 中看到,当 Data Size 增加的时候 Redis 的 Troughtput 会有一个较大的拐点:

所以许多问题并不是无脑丢缓存/Redis 就可解决的。

Redis 中有多少 Typo

最后:

➜  redis git:(unstable) git shortlog  | cat | grep "typo" | wc -l
232

(跑~

Reference

  1. SRANDMEMBER key [count]
  2. SPOP key [count]
  3. EXPIRE – Redis
  4. Redis Persistence
  5. Redis 的性能幻想与残酷现实
  6. Sampling characteristics of Redis sets

comments powered by Disqus