读《Redis入门指南》第2版 李子骅(hua)记录。

数据类型

1. 集合类型(Set)

在Redis内部是使用值为空的散列表(hash table)实现的,所以这些操作的时间复杂度都是O(1)。

1.1 特性

  • 一个集合类型的键可以存储至多232-1个字符串。
  • 集合中的元素无序
  • 集合中的元素唯一

    1.2 常用操作

  • 向集合中加入或删除操作。

  • 判断某个元素是否存在。
  • 多个集合类型键之间还可以进行并集、交集和差集运算。

    1.3 命令

    1. 增加或删除元素

    SADD key member [member …]

    • 来向集合中增加一个或多个元素,如果键不存在则会自动创建;
    • 要加入的元素已经存在于集合中就会忽略这个元素;
    • 返回值:是成功加入的元素数量(忽略的元素不计算在内)
      1. redis> SADD letters a
      2. (integer) 1
      3. redis> SADD letters a b c
      4. (integer) 2

SREM key member [member …]

  • 从集合中删除一个或多个元素;
  • 返回值:删除成功的个数;

    1. redis> SREM letters c d
    2. (integer) 1

    2. 获得集合中的所有元素

    SMEMBERS key

  • 返回值:集合中的所有元素

    1. redis> SMEMBERS letters
    2. 1) "b"
    3. 2) "a"

    3. 判断元素是否在集合中

    SISMEMBER key member

  • 时间复杂度为O(1);

  • 返回值:
    • 值存在时 SISMEMBER命令返回1
    • 值不存在或键不存在时返回0
      1. redis> SISMEMBER letters a
      2. (integer) 1
      3. redis> SISMEMBER letters d
      4. (integer) 0

      4. 集合间运算

  1. 差集运算

SDIFF key [key „]

  1. - 集合A与集合B的差集表示为AB,代表所有属于A且不属于B的元素构成的集合,即AB ={x | xAxB}。
  2. - 支持同时传入多个键
  3. - 计算顺序:是先计算 setA - setB,再计算结果与 setC的差集

image.png

  1. redis> SADD setA 1 2 3
  2. (integer) 3
  3. redis> SADD setB 2 3 4
  4. (integer) 3
  5. redis> SDIFF setA setB
  6. 1) "1"
  7. redis> SDIFF setB setA
  8. 1) "4"
  9. // 支持传入多个键
  10. redis> SADD setC 2 3
  11. (integer) 2
  12. redis> SDIFF setA setB setC
  13. 1) "1"
  1. 交集运算

SINTER key [key „]

  1. - 集合A与集合B的交集表示为A B 代表所有属于A且属于B的元素构成的集合,即A B ={x | x Ax B}

image.png
image.png

  1. redis> SINTER setA setB
  2. 1) "2"
  3. 2) "3"
  4. // 支持同时传入多个键
  5. redis> SINTER setA setB setC
  6. 1) "2"
  7. 2) "3"
  1. 并集运算

SUNION key [key „]

  1. - 集合A与集合B的并集表示为AB 代表所有属于A或属于B的元素构成的集合(如图3-15所示)即AB ={x | xAx B}

image.png
image.png

  1. redis> SUNION setA setB
  2. 1) "1"
  3. 2) "2"
  4. 3) "3"
  5. 4) "4"
  6. // 支持同时传入多个键
  7. redis> SUNION setA setB setC
  8. 1) "1"
  9. 2) "2"
  10. 3) "3"
  11. 4) "4"

5. 获得集合中元素个数

SCARD key

  1. redis> SMEMBERS letters
  2. 1) "b"
  3. 2) "a"
  4. redis> SCARD letters
  5. (integer) 2

6. 进行集合运算并将结果存储

SDIFFSTORE destination key [key …]
SINTERSTORE destination key [key …]
SUNIONSTORE destination key [key …]

  • SDIFFSTORE命令和SDIFF命令功能一样,唯一的区别就是前者不会直接返回运算结果, 而是将结果存储在destination键中。
  • 常用于需要进行多步集合运算的场景中,如需要先计算差集再将结果 和其他键计算交集;
  • SINTERSTORE和SUNIONSTORE命令与之类似

    7. 随机获得集合中的元素

    SRANDMEMBER key [count]
    1. redis> SRANDMEMBER
    2. letters "a"
    3. redis> SRANDMEMBER
    4. letters "b"
    5. redis> SRANDMEMBER
    6. letters "a"

可以传递count参数来一次随机获得多个元素,根据count的正负不同,具体表现也不同:

  1. 当count为正数时,SRANDMEMBER会随机从集合里获得count个不重复的元素。 如果count的值大于集合中的元素个数,则SRANDMEMBER会返回集合中的全部元素。
  2. 当count为负数时,SRANDMEMBER会随机从集合里获得|count|个的元素,这些元 素有可能相同。 ```bash // 为了示例,我们先在letters集合中加入两个元素: redis> SADD letters c d (integer) 2

// 目前 letters 集合中共有“a”、“b”、“c”、“d”4 个元素,下面使用不同的参数对SRANDMEMBER命令进行测试: redis> SRANDMEMBER letters 2 1) “a” 2) “c” redis> SRANDMEMBER letters 2 1) “a” 2) “b” redis> SRANDMEMBER letters 100 1) “b” 2) “a” 3) “c” 4) “d” redis> SRANDMEMBER letters -2 1) “b” 2) “b” redis> SRANDMEMBER letters -10 1) “b” 2) “b” 3) “c” 4) “c” 5) “b” 6) “a” 7) “b” 8) “d” 9) “b” 10) “b”

  1. 可以发现 SRANDMEMBER 命令返回的数据似乎并不是非常的随机,从 SRANDMEMBER letters -10这个结果中可以很明显地看出这个问题(b 元素出现的次数相对较多 )。<br />出现这种情况是由集合类型采用的存储结构(散列表)造成的。散列表使用散列函数将元素映射到不同的存储位置(桶)上以实现O(1)时间复杂度的元素查找。<br />举个例子,当使用散列表存储元素b时,使用散列函数计算出b的散列值是0,所以将b存入编号为0的桶(bucket)中,下次要查找b时就可以用同样的散列函数再次计算b的散列值并直接到相 应的桶中找到 b。<br />当两个不同的元素的散列值相同时会出现冲突,Redis 使用拉链法来解决冲突,即将散列值冲突的元素以链表的形式存入同一桶中,查找元素时先找到元素对应的桶, 然后再从桶中的链表中找到对应的元素。使用SRANDMEMBER命令从集合中获得一个随机 元素时,Redis首先会从所有桶中随机选择一个桶,然后再从桶中的所有元素中随机选择一个 元素,所以元素所在的桶中的元素数量越少,其被随机选中的可能性就越大。<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/22365594/1646145456978-a9a57f93-028b-4b04-89d0-339eddde4295.png#clientId=u01336e64-802c-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=609&id=u6939e34d&margin=%5Bobject%20Object%5D&name=image.png&originHeight=761&originWidth=981&originalType=binary&ratio=1&rotation=0&showTitle=false&size=106746&status=done&style=none&taskId=u55a33b42-a297-4805-a032-9baf3663ebc&title=&width=784.8)
  2. <a name="bUc8P"></a>
  3. #### 8. 从集合中弹出一个元素
  4. `SPOP key`<br />由于集合类型的元素是无序的,所以 SPOP命令会从 集合中随机选择一个元素弹出。
  5. ```bash
  6. redis> SPOP letters
  7. "b"
  8. redis> SMEMBERS letters
  9. 1) "a"
  10. 2) "c"
  11. 3) "d"

2. 有序集合类型

2.1 特性

  • 在集合类型的基础上有序集合类型为集合中的每个元素都关联了一个分数。
  • 集合中每 个元素都是不同的,但是它们的分数却可以相同。

2.2 有序集合类型和列表的区分

  1. 有序集合类型在某些方面和列表类型有些相似
  • 二者都是有序的。
  • 二者都可以获得某一范围的元素。
  1. 二者有着很大的区别,这使得它们的应用场景也是不同的:
  • 列表类型是通过链表实现的,获取靠近两端的数据速度极快,而当元素增多后, 访问中间数据的速度会较慢,所以它更加适合实现如“新鲜事”或“日志”这样很少访问中间元 素的应用。
  • 有序集合类型是使用散列表和跳跃表(Skip list)实现的,所以即使读取位于中间 部分的数据速度也很快(时间复杂度是O(log(N)))。
  • 列表中不能简单地调整某个元素的位置,但是有序集合可以(通过更改这个元素 的分数)。
  • 有序集合要比列表类型更耗费内存。

2.3 命令

1. 增加元素

ZADD key score member [score member …]

  • 向有序集合中加入一个元素和该元素的分数。
  • 元素已经存在则会 用新的分数替换原有的分数。
  • 返回值:是新加入到集合中的元素个数(不包含之前已经存在的元素)

假设我们用有序集合模拟计分板,现在要记录Tom、Peter和David三名运动员的分数 (分别是89分、67分和100分):

  1. redis> ZADD scoreboard 89 Tom 67 Peter 100 David
  2. (integer) 3
  1. 这时我们发现Peter的分数录入有误,实际的分数应该是76分,可以用ZADD命令修改 Peter的分数:
  1. redis> ZADD scoreboard 76 Peter
  2. (integer) 0

分数不仅可以是整数,还支持双精度浮点数,其中+inf-inf分别表示正无穷和负无穷。:

  1. redis> ZADD testboard 17E+307 a
  2. (integer) 1
  3. redis> ZADD testboard 1.5 b
  4. (integer) 1
  5. redis> ZADD testboard +inf c
  6. (integer) 1
  7. redis> ZADD testboard -inf d
  8. (integer) 1

2. 获得元素的分数

ZSCORE key member

  1. redis> ZSCORE scoreboard Tom
  2. "89"

3. 获得排名在某个范围的元素列表

ZRANGE key start stop [WITHSCORES]

  • 按照元素分数从小到大的顺序返回索引从 start到stop之间的所有元素 (包含两端的元素)。
  • 索引都是从0开始;
  • 负数代表从后向前查找(−1表示最后一个元素);
  • ZRANGE命令的时间复杂度为O(log n+m)(其中n为有序集合的基数,m为返回的元素 个数) ```bash redis> ZRANGE scoreboard 0 2 1) “Peter” 2) “Tom” 3) “David”

redis> ZRANGE scoreboard 1 -1 1) “Tom” 2) “David”

  1. 如果需要同时获得元素的分数的话可以在 `ZRANGE` 命令的尾部加上`WITHSCORES`参数,这时返回的数据格式就从“元素1, 元素2,···, 元素n”变为了`“元素1, 分数1, 元素2, 分数2,···, 元素n, 分数n”`
  2. ```bash
  3. redis> ZRANGE scoreboard 0 -1 WITHSCORES
  4. 1) "Peter"
  5. 2) "76"
  6. 3) "Tom"
  7. 4) "89"
  8. 5) "David"
  9. 6) "100"

如果两个元素的分数相同,Redis会按照字典顺序(即”0”<”9”<”A”<”Z”<”a”<”z”这样的顺 序)来进行排列。

如果元素的值是中文怎么处理呢?答案是取决于中文的编码方式,如使用UTF-8编码,依然按照字典顺序排列这些元素:

  1. redis> ZADD chineseName 0 马华 0 刘墉 0 司马光 0 赵哲
  2. (integer) 4
  3. redis> ZRANGE chineseName 0 -1
  4. 1) "\xe5\x88\x98\xe5\xa2\x89"
  5. 2) "\xe5\x8f\xb8\xe9\xa9\xac\xe5\x85\x89"
  6. 3) "\xe8\xb5\xb5\xe5\x93\xb2"
  7. 4) "\xe9\xa9\xac\xe5\x8d\x8e"
  1. <br />`ZREVRANGE key start stop [WITHSCORES] `:<br />ZREVRANGE命令和ZRANGE的唯一不同在于`ZREVRANGE`命令是按照元素分数从大到小的顺序给出结果的。

4. 获得指定分数范围的元素

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

  • 按照元素分数从小到大 的顺序返回分数在minmax之间(包含minmax)的元素;
  • minmax还支持无穷大,同ZADD命令一样,-inf+inf分别表示负无穷和正无穷;
  • WITHSCORES参数的用法与ZRANGE命令一样。
  • LIMIT offset count与 SQL 中的用法基本相同,即在获得的元素列表的基础上向后偏移offset个元素,并且只获取前count个元素。
  1. redis> ZRANGEBYSCORE scoreboard 80 100
  2. 1) "Tom"
  3. 2) "David"

如果希望分数范围不包含端点值,可以在分数前加上“(”符号。例如,希望返回”80分到 100分的数据,可以含80分,但不包含100分,则稍微修改一下上面的命令即可:

  1. redis> ZRANGEBYSCORE scoreboard 80 (100
  2. 1) "Tom"
  1. 比如你希望得到所有分数高于80分(不包含80分)的人的名单,但你却不知道最高分是多少 (虽然有些背离现实,但是为了叙述方便,这里假设可以获得的分数是无上限的),这时就 可以用上`+inf`了:
  1. redis> ZRANGEBYSCORE scoreboard (80 +inf
  2. 1) "Tom"
  3. 2) "David"

**LIMIT offset count**用法:
先向scoreboard键中再增加些元素:

  1. redis> ZADD scoreboard 56 Jerry 92 Wendy 67 Yvonne
  2. (integer) 3
  1. 现在scoreboard键中的所有元素为:
  1. redis> ZRANGE scoreboard 0 -1 WITHSCORES
  2. 1) "Jerry"
  3. 2) "56"
  4. 3) "Yvonne"
  5. 4) "67"
  6. 5) "Peter"
  7. 6) "76"
  8. 7) "Tom"
  9. 8) "89"
  10. 9) "Wendy"
  11. 10) "92"
  12. 11) "David"
  13. 12) "100"
  1. 想获得分数高于60分的从第二个人开始的3个人:
  1. redis> ZRANGEBYSCORE scoreboard 60 +inf LIMIT 1 3
  2. 1) "Peter"
  3. 2) "Tom"
  4. 3) "Wendy"
  1. 如果想获取分数低于或等于 100 分的前 3 个人怎么办呢?这时可以借助 `ZREVRANGEBYSCORE`命令实现。<br />`ZREVRANGEBYSCORE`命令不仅是按照元素分数从大往小的顺序给出结果的,而且它的`min``max`参数的顺序和`ZRANGEBYSCORE`命令是相反的。
  1. redis> ZREVRANGEBYSCORE scoreboard 100 0 LIMIT 0 3
  2. 1) "David"
  3. 2) "Wendy"
  4. 3) "Tom"

5. 增加某个元素的分数

ZINCRBY key increment member

  • 可以增加一个元素的分数;
  • 返回值:是更改后的分数;
  • increment也可以是个负数表示减分;
  • 如果指定的元素不存在,Redis 在执行命令前会先建立它并将它的分数赋为0再执行操作。 ```bash // 给 Jerry加 4分 redis> ZINCRBY scoreboard 4 Jerry “60”

// 给Jerry减4分 redis> ZINCRBY scoreboard -4 Jerry “56”

  1. <a name="OEbKz"></a>
  2. ### 2.4 常用实践
  3. <a name="Bwmo9"></a>
  4. #### 1. 实现按点击量排序
  5. 使用一个有序集合类型的键来实现按照文章的点击量排序。以文章的 ID 作为元素,以该文章的点击量作为该元素的分数。<br />每次用户访问一篇文章时,博客程序就通过`ZINCRBY posts:page.view 1 文章ID`更新访问量。
  6. 按照点击量的顺序显示文章列表时,有序集合的用法与列表的用法大同小异:
  7. ```bash
  8. $postsPerPage = 10
  9. $start = ($currentPage - 1) * $postsPerPage
  10. $end = $currentPage * $postsPerPage - 1
  11. $postsID = ZREVRANGE posts:page.view, $start,$end
  12. for each $id in $postsID
  13. $postData = HGETALL post:$id
  14. print 文章标题:$postData.title
  1. 想要获得某篇文章的访问量可以通过` ZSCORE posts:page.view 文章ID` 来实现。

2. 改进按时间排序

每次发布新文章时都将文章的ID加入到名为posts:list的列表类型键中来获得 按照时间顺序排列的文章列表,但是由于列表类型更改元素的顺序比较麻烦。
为了能够自由地更改文章发布时间,可以采用有序集合类型代替列表类型。自然地,元素仍然是文章的ID,而此时元素的分数则是文章发布的Unix时间(Unix时间指UTC时间1970年1月1日0时0分0秒起至现在的总秒数(不包括闰秒)。为什么是1970年呢?因为Unix在1970年左右诞生。)。
通过修改元素对应的分数就可以达到更改时间的目的。
另外借助 ZREVRANGEBYSCORE 命令还可以轻松获得指定时间范围的文章列表,借助这个功能可以实现类似WordPress的按月份查看文章的功能。

事务

1. 什么是事务

Redis中的事务(transaction)是一组命令的集合。事务同命令一样都是 Redis 的最小执行单位,一个事务中的命令要么都执行,要么都不执行。
事务的原理是先将属于一个事务的命令发送给Redis,然后再让Redis依次执行这些命令。

  1. redis> MULTI OK
  2. redis> SADD "user:1:following" 2
  3. QUEUED
  4. redis> SADD "user:2:followers" 1
  5. QUEUED
  6. redis> EXEC
  7. 1) (integer) 1
  8. 2) (integer) 1
  1. 首先使用`MULTI`命令告诉Redis:“下面我发给你的命令属于同一个事务,你先不要执行,而是把它们暂时存起来。”Redis回答:“OK。”<br />而后我们发送了两个`SADD`命令来实现关注和被关注操作,可以看到 Redis 遵守了承 诺,没有执行这些命令,而是返回`QUEUED`表示这两条命令已经进入等待执行的事务队列中 了。<br />当把所有要在同一个事务中执行的命令都发给 Redis 后,我们使用 `EXEC` 命令告诉Redis 将等待执行的事务队列中的所有命令(即刚才所有返回`QUEUED`的命令)按照发送顺序依次执行。`EXEC`命令的返回值就是这些命令的返回值组成的列表,返回值顺序和命令的顺序相同。

Redis保证一个事务中的所有命令要么都执行,要么都不执行。如果在发送EXEC命令前 客户端断线了,则 Redis 会清空事务队列,事务中的所有命令都不会执行。而一旦客户端发送了EXEC命令,所有的命令就都会被执行,即使此后客户端断线也没关系,因为Redis中已 经记录了所有要执行的命令。
除此之外,Redis 的事务还能保证一个事务内的命令依次执行而不被其他命令插入。试想客户端A需要执行几条命令,同时客户端B发送了一条命令,如果不使用事务,则客户端B 的命令可能会插入到客户端A的几条命令中执行。如果不希望发生这种情况,也可以使用事务。

2. 错误处理

如果一个事务中的某个命令执行出错,Redis 会怎样处理呢?

2.1 语法错误

语法错误指命令不存在或者命令参数的个数不对。

  1. redis> MULTI
  2. OK
  3. redis> SET key value
  4. QUEUED
  5. redis> SET key
  6. (error) ERR wrong number of arguments for 'set' command
  7. redis> ERRORCOMMAND key
  8. (error) ERR unknown command 'ERRORCOMMAND'
  9. redis> EXEC
  10. (error) EXECABORT Transaction discarded because of previous errors.

跟在MULTI命令后执行了3个命令:

  • 一个是正确的命令,成功地加入事务队列;
  • 其余两个命令都有语法错误。

而只要有一个命令有语法错误,执行 EXEC 命令后 Redis 就会直接返回错误,连语法正确的命令也不会执行。
版本差异:Redis 2.6.5之前的版本会忽略有语法错误的命令,然后执行事务中其他语法正确的命令。就此例而言,SET key value会被执行,EXEC命令会返回一个结果: 1) OK

2.2 运行错误

运行错误指在命令执行时出现的错误,比如使用散列类型的命令操作集合类型的键,这种错误在实际执行之前 Redis 是无法发现的,所以在事务里这样的命令是会被 Redis 接受并执行的。如果事务里的一条命令出现了运行错误,事务里其他的命令依然会继续执行(包括出错命令之后的命令)

  1. redis> MULTI
  2. OK
  3. redis> SET key 1
  4. QUEUED
  5. redis> SADD key 2
  6. QUEUED
  7. redis> SET key 3
  8. QUEUED
  9. redis> EXEC
  10. 1) OK
  11. 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
  12. 3) OK
  13. redis> GET key
  14. "3"

Redis的事务没有关系数据库事务提供的回滚(rollback)功能。为此开发者必须在事务执行出错后自己收拾剩下的摊子(将数据库复原回事务执行前的状态等)。
不过由于 Redis 不支持回滚功能,也使得 Redis 在事务上可以保持简洁和快速。另外回顾刚才提到的会导致事务执行失败的两种错误,其中语法错误完全可以在开发时找出并解决,另外如果能够很好地规划数据库(保证键名规范等)的使用,是不会出现如命令与数据类型不匹配这样的运行错误的。

3. watch命令

在一个事务中只有当所有命令都依次执行完后才能得到每个结果的返回 值,可是有些情况下需要先获得一条命令的返回值,然后再根据这个值执行下一条命令。
WATCH命令可以监控一个 或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行。监控一直持续到EXEC命令(事务中的命令是在 EXEC之后才执行的,所以在 MULTI命令后可以修改 WATCH监控的键值)。

  1. redis> SET key 1
  2. OK
  3. redis> WATCH key
  4. OK
  5. redis> SET key 2
  6. OK
  7. redis> MULTI
  8. OK
  9. redis> SET key 3
  10. QUEUED
  11. redis> EXEC
  12. (nil)
  13. redis> GET key
  14. "2"
  1. 上例中在执行 `WATCH`命令后、事务执行前修改了key的值(即 `SET key 2`),所以最后事务中的命令 `SET key 3`没有执行,`EXEC`命令返回空结果。<br />由于`WATCH`命令的作用只是当被监控的键值被修改后阻止之后一个事务的执行, 而不能保证其他客户端不修改这一键值,所以我们需要在`EXEC`执行失败后重新执行整个函数。<br />执行 `EXEC`命令后会取消对所有键的监控,如果不想执行事务中的命令也可以使用 `UNWATCH`命令来取消监控。

比如,我们要实现hsetxx函数,作用与HSETNX命令类似,只不过是仅当字段存在时才赋值。为了避免竞态条件我们使用事务来完成这一功能:

  1. def hsetxx($key, $field, $value)
  2. WATCH $key
  3. $isFieldExists = HEXISTS $key, $field
  4. if $isFieldExists is 1
  5. MULTI
  6. HSET $key, $field, $value
  7. EXEC
  8. else
  9. UNWATCH
  10. return $isFieldExists

在代码中会判断要赋值的字段是否存在,如果字段不存在的话就不执行事务中的命令, 但需要使用UNWATCH命令来保证下一个事务的执行不会受到影响。

过期时间

在实际的开发中经常会遇到一些有时效的数据,比如限时优惠活动、缓存或验证码等, 过了一定的时间就需要删除这些数据。在关系数据库中一般需要额外的一个字段记录到期时间,然后定期检测删除过期数据。而在Redis中可以使用EXPIRE命令设置一个键的过期时间,到时间后Redis会自动删除它。

1. 用法

1.1 设置过期时间

EXPIRE key seconds
其中:

  • 参数:
    • seconds参数表示键的过期时间,单位是秒。 ```bash redis> SET session:29e3d uid1314 OK

redis> EXPIRE session:29e3d 900 (integer) 1

  1. - 返回值:
  2. - 返回`1`表示设置成功,
  3. - 返回`0`则表示键不存在或设置失败。
  4. ```bash
  5. redis> DEL session:29e3d
  6. (integer) 1
  7. redis> EXPIRE session:29e3d 900
  8. (integer) 0

1.2 查询剩余有效时间

TTL key
如果想知道一个键还有多久的时间会被删除,可以使用TTL命令。

其中:

  • 返回值:
    • 是键的剩余时间(单位是秒)
    • 当键不存在时TTL命令会返回−2
    • 没有为键设置过期时间(即永久存在,这是建立一个键后的默认情况),返回−1

注意:
版本差异:

  1. - `2.6`版中,无论键不存在还是键没有过期时间都会返回`−1`
  2. - 直到`2.8`版后两种情况才会分别返回`−2``−1`两种结果。
  1. redis> SET foo bar
  2. OK
  3. redis> EXPIRE foo 20
  4. (integer) 1
  5. redis> TTL foo
  6. (integer) 15
  7. redis> TTL foo
  8. (integer) 7
  9. redis> TTL foo
  10. (integer) 2

1.3 取消键的过期时间

PERSIST key
取消键的过期时间设置(即将键恢复成永久的),则可以使用PERSIST命令。

其中:

  • 返回值
    • 过期时间被成功清除则返回1
    • 否则返回0(因为键不存在或键本来就是永久的) ```bash redis> SET foo bar OK

redis> EXPIRE foo 20 (integer) 1

redis> PERSIST foo (integer) 1

redis> TTL foo (integer) –1


除了`PERSIST`命令之外,使用`SET`或`GETSET`命令为键赋值也会同时清除键的过期时间:
```bash
redis> EXPIRE foo 20 
(integer) 1 

redis> SET foo bar 
OK

redis> TTL foo 
(integer) –1

使用EXPIRE命令会重新设置键的过期时间:

redis> SET foo bar 
OK

redis> EXPIRE foo 20 
(integer) 1 

redis> TTL foo 
(integer) 15 

redis> EXPIRE foo 20 
(integer) 1 

redis> TTL foo 
(integer) 17

其他只对键值进行操作的命令(如INCR、LPUSH、HSET、ZREM)均不会影响键的过期时间。

1.4 精确控制过期时间(毫秒哦)

EXPIRE命令的seconds参数必须是整数,所以最小单位是1秒
如果想要更精确的控制键的过期时间应该使用PEXPIRE命令。

PEXPIRE key time:设置过期时间为毫秒
PTTL key:获取毫秒单位的剩余时间

PEXPIRE命令与 EXPIRE的唯一区别是前者的时间单位是毫秒,即 PEXPIRE key 1000EXPIRE key 1 等价。对应地可以用PTTL命令以毫秒为单位返回键的剩余时间。

注意:
如果使用 WATCH命令监测了一个拥有过期时间的键,该键时间到期自动删除并不会被WATCH命令认为该键被改变。

两个相对不太常用的命令:EXPIREATPEXPIREAT
EXPIREAT命令与EXPIRE命令的差别在于前者使用Unix时间作为第二个参数表示键的过期时刻。PEXPIREAT命令与EXPIREAT命令的区别是前者的时间单位是毫秒。

redis> SET foo bar 
OK

redis> EXPIREAT foo 1351858600 
(integer) 1 

redis> TTL foo 
(integer) 142

redis> PEXPIREAT foo 1351858700000 
(integer) 1

2. 常用实践

2.1 限制用户单位时间的最大访问量

为了减轻服务器的压力,需要限制每个用户(以IP计)一段时间的最大访问量。

例如要限制每分钟每个用户最多只能访问100个页面,思路是对每个用户使用一个名为rate.limiting:用户IP的字符串类型键,每次用户访问则使用INCR命令递增该键的键值,如果递增后的值是1(第一次访问页面),则同时还要设置该键的过期时间为1分钟。这样每次用户访问页面时都读取该键的键值,如果超过了100就表明该用户的访问频率超过了限制,需要提示用户稍后访问。该键每分钟会自动被删除,所以下一分钟用户的访问次数又会重新计算,也就达到了限制访问频率的目的。

$isKeyExists = EXISTS rate.limiting:$IP 
if $isKeyExists is 1 
  $times = INCR rate.limiting:$IP 
  if $times > 100 
    print 访问频率超过了限制,请稍后再试。 
    exit 
else 
  INCR rate.limiting:$IP 
  EXPIRE $keyName, 60

这段代码存在一个不太明显的问题:假如程序执行完倒数第二行后突然因为某种原因退出了,没能够为该键设置过期时间,那么该键会永久存在,导致使用对应的IP的用户在管理员手动删除该键前最多只能访问100次博客,这是一个很严重的问题。 为了保证建立键和为键设置过期时间一起执行,可以使用上节学习的事务功能,修改后的代码如下:

$isKeyExists = EXISTS rate.limiting:$IP 
if $isKeyExists is 1
  $times = INCR rate.limiting:$IP 
  if $times > 100 
  print 访问频率超过了限制,请稍后再试。 
  exit 
else 
  MULTI 
  INCR rate.limiting:$IP 
  EXPIRE $keyName, 60 
  EXEC

2.2 更细粒度的限制措施

如果一个用户在一分钟的第一秒访问了一次博客,在同一分钟的最后一秒访问了9次,又在下一分钟的第一秒访问了10次,这样的访问是 可以通过现在的访问频率限制的,但实际上该用户在2秒内访问了19次博客,这与每个用户每分钟只能访问10次的限制差距较大。尽管这种情况比较极端,但是在一些场合中还是需要粒度更小的控制方案。
如果要精确地保证每分钟最多访问10次,需要记录下用户每次访问的时间。因此对每个用户,我们使用一个列表类型的键来记录他最近10次访问博客的时间。一旦键中的元素超过10个,就判断时间最早的元素距现在的时间是否小于1分钟。如果是则表示用户最近1分钟的访问次数超过了10次;如果不是就将现在的时间加入到列表中,同时把最早的元素删除。

$listLength = LLEN rate.limiting:$IP 
if $listLength < 10 
  LPUSH rate.limiting:$IP, now() 
else 
  $time = LINDEX rate.limiting:$IP, -1 
  if now() - $time < 60 
    print 访问频率超过了限制,请稍后再试。 
  else 
    LPUSH rate.limiting:$IP, now() 
    LTRIM rate.limiting:$IP, 0, 9

代码中now()的功能是获得当前的Unix 时间。由于需要记录每次访问的时间,所以当要限制“A时间最多访问B次”时,如果“B”的数值较大,此方法会占用较多的存储空间,实际使用时还需要开发者自己去权衡。除此之外该方法也会出现竞态条件,同样可以通过脚本功能避免。

2.3 实现缓存

为了提高网站的负载能力,常常需要将一些访问频率较高但是对CPU或IO资源消耗较大的操作的结果缓存起来,并希望让这些缓存过一段时间自动过期。比如教务网站要对全校所有学生的各个科目的成绩汇总排名,并在首页上显示前10名的学生姓名,由于计算过程较耗资源,所以可以将结果使用一个Redis的字符串键缓存起来。
由于学生成绩总在不断地变化,需要每隔两个小时就重新计算一次排名,这可以通过给键设置过期时间的方式实现。每次用户访问首页时程序先查询缓存键是否存在,如果存在则直接使用缓存的值;否则重新计 算排名并将计算结果赋值给该键并同时设置该键的过期时间为两个小时。
然而在一些场合中这种方法并不能满足需要。当服务器内存有限时,如果大量地使用缓 存键且过期时间设置得过长就会导致 Redis 占满内存;另一方面如果为了防止 Redis 占用内 存过大而将缓存键的过期时间设得太短,就可能导致缓存命中率过低并且大量内存白白地闲置。实际开发中会发现很难为缓存键设置合理的过期时间,为此可以限制 Redis 能够使用的最大内存,并让Redis按照一定的规则淘汰不需要的缓存键,这种方式在只将Redis用作缓存系统时非常实用。
具体的设置方法为:修改配置文件的maxmemory参数,限制Redis最大可用内存大小(单位是字节),当超出了这个限制时Redis会依据maxmemory-policy参数指定的策略来删 除不需要的键直到Redis占用的内存小于指定内存。

maxmemory-policy支持的规则:

  • noeviction:返回错误,不会删除任何键值
  • allkeys-lru:使用LRU算法删除最近最少使用的键值
  • volatile-lru:使用LRU算法从设置了过期时间的键集合中删除最近最少使用的键值
  • allkeys-random:从所有key随机删除
  • volatile-random:从设置了过期时间的键的集合中随机删除
  • volatile-ttl:从设置了过期时间的键中删除剩余时间最短的键
  • volatile-lfu:从配置了过期时间的键中删除使用频率最少的键
  • allkeys-lfu:从所有键中删除使用频率最少的键

排序

消息通知

管道

节省空间

1. 精简键名和和键值

精简键名和键值是最直观的减少内存占用的方式,如将键名very.important.person:20改成VIP:20
当然精简键名一定要把握好尺度,不能单纯为了节约空间而使用不易理解的键名(比如将VIP:20修改为V:20,这样既不易维护,还容易造成命名冲突)。
又比如一个存储用户性别的字符串类型键的取值是malefemale,我们可以将其修改成mf来为每条记录节约几个字节的空间(更好的方法是使用01来表示性别)。

2. 内部编码优化

有时候仅凭精简键名和键值所减少的空间并不足以满足需求,这时就需要根据Redis内部编码规则来节省更多的空间。
Redis为每种数据类型都提供了两种内部编码方式,以散列类型为例,散列类型是通过散列表实现的,这样就可以实现O(1)时间复杂度的查找、赋值操作,然而当键中元素很少的时候,O(1)的操作并不会比O(n)有明显的性能提高,所以这种情况下Redis会采用一种更为紧凑但性能稍差(获取元素的时间复杂度为O(n))的内部编码方 式。
内部编码方式的选择对于开发者来说是透明的,Redis会根据实际情况自动调整。当键中元素变多时Redis会自动将该键的内部编码方式转换成散列表。如果想查看一个键的内部编码方式可以使用OBJECT ENCODING命令。

redis> SET foo bar 
OK

redis> OBJECT ENCODING foo 
"raw"

Redis的每个键值都是使用一个redisObject结构体保存的,redisObject的定义如下:

typedef struct redisObject { 
  unsigned type:4; 
  unsigned notused:2; /* Not used */ 
  unsigned encoding:4; 
  unsigned lru:22; /* lru time (relative to server.lruclock) */ 
  int refcount; 
  void *ptr; 
} robj;

其中type字段表示的是键值的数据类型,取值可以是如下内容:

  • define REDIS_STRING 0

  • define REDIS_LIST 1

  • define REDIS_SET 2

  • define REDIS_ZSET 3

  • define REDIS_HASH 4

encoding字段表示的就是Redis键值的内部编码方式,取值可以是:

  • define REDIS_ENCODING_RAW 0 / Raw representation /

  • define REDIS_ENCODING_INT 1 / Encoded as integer /

  • define REDIS_ENCODING_HT 2 / Encoded as hash table /

  • define REDIS_ENCODING_ZIPMAP 3 / Encoded as zipmap /

  • define REDIS_ENCODING_LINKEDLIST 4 / Encoded as regular linked list /

  • define REDIS_ENCODING_ZIPLIST 5 / Encoded as ziplist /

  • define REDIS_ENCODING_INTSET 6 / Encoded as intset /

  • define REDIS_ENCODING_SKIPLIST 7 / Encoded as skiplist /

  • define REDIS_ENCODING_EMBSTR 8 / Embedded sds string encoding /

各个数据类型可能采用的内部编码方式以及相应的OBJECT ENCODING命令执行结果:
7166ef96c49d30d88d01656f8c8cf20.jpg

脚本

Redis在2.6版推出了脚本功能,允许开发者使用Lua语言编写脚本传到Redis中执行。在Lua脚本中可以调用大部分的Redis命令。
使用脚本的好处如下:

  1. 减少网络开销:一段代码最多需要向Redis发送5次请求,而使用脚本功能完成同样的操作只需要发送一个请求即可,减少了网络往返时延。
  2. 原子操作:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。换句话说在编写脚本的过程中无需担心会出现竞态条件,也就无需使用事务。事务可以完成的所有功能都可以用脚本来实现。
  3. 复用:客户端发送的脚本会永久存储在Redis中,这就意味着其他客户端(可以是其他语言开发的项目)可以复用这一脚本而不需要使用代码完成同样的逻辑。

示例:
因为无需考虑事务,使用Redis脚本实现访问频率限制非常简单。Lua代码如下:

local times = redis.call('incr', KEYS[1]) 
if times == 1 
then 
  -- KEYS[1]键刚创建,所以为其设置生存时间 
  redis.call('expire', KEYS[1], ARGV[1]) 
end 
if times > tonumber(ARGV[2]) 
then 
  return 0 
end
return 1

如何测试这个脚本呢?首先把这段代码存为ratelimiting.lua,然后在命名行中输入:redis-cli --eval /path/to/ratelimiting.lua rate.limiting:127.0.0.1 , 10 3
其中:

  • --eval参数是告诉redis-cli读取并运行后面的Lua脚本
  • /path/to/ratelimiting.lua是 ratelimiting.lua 文件的位置,后面跟着的是传给 Lua 脚本的参数。
    • “,”前的rate. limiting:127.0.0.1是要操作的键,可以在脚本中使用KEYS[1]获取,
    • “,”后面的103是参数, 在脚本中能够使用ARGV[1]ARGV[2]获得。

结合脚本的内容可知这行命令的作用就是将访问频率限制为每10秒最多3次,所以在终端中不断地运行此命令会发现当访问频率在10秒内小于或等于3次时返回1,否则返回0。
注意:
上面的命令中“,”两边的空格不能省略,否则会出错。

持久化

Redis的强劲性能很大程度上是由于其将所有数据都存储在了内存中,然而当Redis重启后,所有存储在内存中的数据就会丢失。
在一些情况下,我们会希望Redis在重启后能够保证数据不丢失,例如:

  1. 将Redis作为数据库使用时。这也是小白现在的情况。
  2. 将 Redis 作为缓存服务器,但缓存被穿透后会对性能造成较大影响,所有缓存同时失效会导致缓存雪崩,从而使服务无法响应。

这时我们希望Redis能将数据从内存中以某种形式同步到硬盘中,使得重启后可以根据硬盘中的记录恢复数据。这一过程就是持久化。
Redis支持两种方式的持久化,一种是RDB方式,另一种是AOF方式。前者会根据指定的规则“定时”将内存中的数据存储在硬盘上,而后者在每次执行命令后将命令本身记录下来。 两种持久化方式可以单独使用其中一种,但更多情况下是将二者结合使用。

1. RDB方式

RDB方式的持久化是通过快照(snapshotting)完成的,当符合一定条件时Redis会自动将内存中的所有数据生成一份副本并存储在硬盘上,这个过程即为“快照”。
Redis会在以下几种情况下对数据进行快照:

  • 根据配置规则进行自动快照;
  • 用户执行SAVEBGSAVE命令;
  • 执行FLUSHALL命令;
  • 执行复制(replication)时。

    1.1 进行快照的几种情况

    1.1.1 根据配置规则进行自动快照

    Redis允许用户自定义快照条件,当符合快照条件时,Redis会自动执行快照操作。
    进行 快照的条件可以由用户在配置文件中自定义,由两个参数构成:时间窗口M和改动的键的个数N。每当时间M内被更改的键的个数大于N时,即符合自动快照条件。
    例如Redis安装目录中包含的样例配置文件中预置的3个条件:

  • save 900 1

  • save 300 10
  • save 60 10000

每条快照条件占一行,并且以save参数开头。同时可以存在多个条件,条件之间是“或”的关系。就这个例子而言:

  • save 900 1的意思是在15分钟(900 秒)内有一个或一个以上的键被更改则进行快照。
  • 同理,save 300 10表示在300秒内至少有10个键被修改则进行快照。

    1.1.2 用户执行SAVE或BGSAVE命令

    除了让 Redis 自动进行快照外,当进行服务重启、手动迁移以及备份时我们也会需要手 动执行快照操作。Redis提供了两个命令来完成这一任务。

SAVE命令

当执行SAVE命令时,Redis同步地进行快照操作,在快照执行的过程中会阻塞所有来自客户端的请求。当数据库中的数据比较多时,这一过程会导致 Redis 较长时间不响应,所以要尽量避免在生产环境中使用这一命令。

BGSAVE命令

需要手动执行快照时推荐使用BGSAVE命令。BGSAVE命令可以在后台异步地进行快照操作,快照的同时服务器还可以继续响应来自客户端的请求。执行BGSAVE后Redis会立即返回OK表示开始执行快照操作,如果想知道快照是否完成,可以通过LASTSAVE命令获取最近一次成功执行快照的时间,返回结果是一个Unix时间戳,如:

redis> LASTSAVE 
(integer) 1423537869

执行自动快照时Redis采用的策略即是异步快照。

1.1.3 执行FLUSHALL命令

当执行FLUSHALL命令时,Redis会清除数据库中的所有数据。
需要注意的是,不论清空数据库的过程是否触发了自动快照条件,只要自动快照条件不为空,Redis就会执行一次快照操作。
例如,当定义的快照条件为当1秒内修改10000个键时进行自动快照,而当数据库里只有一个键时,执行FLUSHALL命令也会触发快照,即使这一过程实际上只有一个键被修改 了。当没有定义自动快照条件时,执行FLUSHALL则不会进行快照。

1.1.4 执行复制时

当设置了主从模式时,Redis会在复制初始化时进行自动快照。当使用复制操作时,即使没有定义自动快照条件,并且没有手动执行过快照操作,也会生成RDB快照文件。

1.2 快照原理

Redis默认会将快照文件存储在Redis当前进程的工作目录中的dump.rdb文件中,可以通过配置dirdbfilename两个参数分别指定快照文件的存储路径和文件名。

快照的过程如下:

  1. Redis使用fork函数复制一份当前进程(父进程)的副本(子进程);
  2. 父进程继续接收并处理客户端发来的命令,而子进程开始将内存中的数据写入硬盘中的临时文件;
  3. 当子进程写入完所有数据后会用该临时文件替换旧的RDB文件,至此一次快照操作完成。

注意:
在执行fork的时候操作系统(类 Unix 操作系统)会使用写时复制(copy -on- write)策略,即fork函数发生的一刻父子进程共享同一内存数据,当父进程要更改其中某片数据时(如执行一个写命令),操作系统会将该片数据复制一份以保证子进程的数据不受影响,所以新的RDB文件存储的是执行fork一刻的内存数据。

写时复制策略也保证了在fork的时刻虽然看上去生成了两份内存副本,但实际上内存的占用量并不会增加一倍。这就意味着当系统内存只有2GB,而Redis数据库的内存有1.5GB 时,执行 fork后内存使用量并不会增加到3 GB(超出物理内存)。
为此需要确保Linux系统允许应用程序申请超过可用内存(物理内存和交换分区)的空间,方法是在/etc/sy sctl.conf文件加入vm.overcommit_memory = 1,然后重启系统或者执行sysctl vm.overcommit_memory=1确保设置生效。
另外需要注意的是,当进行快照的过程中,如果写入操作较多,造成fork前后数据差异较大,是会使得内存使用量显著超过实际数据大小的,因为内存中不仅保存了当前的数据库数据,而且还保存着fork时刻的内存数据。进行内存用量估算时很容易忽略这一问题,造成内存用量超限。
通过上述过程可以发现Redis在进行快照的过程中不会修改RDB文件,只有快照结束后才会将旧的文件替换成新的,也就是说任何时候RDB文件都是完整的。这使得我们可以通过定时备份RDB文件来实现Redis数据库备份。
RDB 文件是经过压缩(可以配置rdbcompression参数以禁用压缩节省CPU占用)的二进制格式,所以占用的空间会小于内存中的数据大小,更加利于传输。
Redis启动后会读取RDB快照文件,将数据从硬盘载入到内存。根据数据量大小与结构和服务器性能不同,这个时间也不同。通常将一个记录1000万个字符串类型键、大小为1GB的快照文件载入到内存中需要花费20~30秒。
通过RDB方式实现持久化,一旦Redis异常退出,就会丢失最后一次快照以后更改的所有数据。这就需要开发者根据具体的应用场合,通过组合设置自动快照条件的方式来将可能发生的数据损失控制在能够接受的范围。例如,使用Redis存储缓存数据时,丢失最近几秒的数据或者丢失最近更新的几十个键并不会有很大的影响。如果数据相对重要,希望将损失降到 最小,则可以使用AOF方式进行持久化。

2. AOF方式

2.1 开启AOF

默认情况下Redis没有开启AOF(append only file)方式的持久化,可以通过appendonly参数启用:

appendonly yes

开启AOF持久化后每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入硬盘中的AOF文件。AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的,默 认的文件名是appendonly.aof,可以通过appendfilename参数修改:

appendfilename appendonly.aof

2.2 AOF的实现

AOF文件以纯文本的形式记录了Redis执行的写命令,例如在开启AOF持久化的情况下执行了如下4个命令:

SET foo 1 
SET foo 2 
SET foo 3 
GET foo

Redis会将前3条命令写入AOF文件中,此时AOF文件中的内容如下:

*2
$6
SELECT 
$10
*3
$3
set 
$3
foo 
$11
*3
$3
set 
$3
foo
$12
*3
$3
set 
$3
foo 
$13

可见 AOF文件的内容正是 Redis 客户端向 Redis 发送的原始通信协议的内容,从中可见 Redis确实只记录了前3条命令。然而这时有一个问题是前2条命令其实都是冗余的,因为这两条的执行结果会被第三条命令覆盖。
随着执行的命令越来越多,AOF文件的大小也会越来越大,即使内存中实际的数据可能并没有多少。很自然地,我们希望 Redis 可以自动优化AOF文件,就上例而言,就是将前两条无用的记录删除,只保留第三条。实际上Redis也正是这样做的,每当达到一定条件时Redis就会自动重写AOF文件,这个条件可以在配置文件中设置:

  • auto-aof-rewrite-percentage 100
  • auto-aof-rewrite-min-size 64mb

其中:
auto-aof-rewrite-percentage参数的意义是当目前的AOF文件大小超过上一次重写时的AOF文件大小的百分之多少时会再次进行重写,如果之前没有重写过,则以启动时的AOF文件大小为依据。
auto-aof-rewrite-min-size参数限制了允许重写的最小AOF文件大小,通常在AOF文件很小的情况下即使其中有很多冗余的命令我们也并不太关心。

除了让Redis自动执行重写外,我们还可以主动使用BGREWRITEAOF命令手动执行AOF重写。
上例中的AOF文件重写后的内容为:

*2
$6
SELECT 
$10
*3
$3
SET 
$3
foo 
$13

可见冗余的命令已经被删除了。重写的过程只和内存中的数据有关,和之前的AOF文件无关,这与RDB很相似,只不过二者的文件格式完全不同。
在启动时Redis会逐个执行AOF文件中的命令来将硬盘中的数据载入到内存中,载入的速度相较RDB会慢一些。

2.3 同步硬盘数据

虽然每次执行更改数据库内容的操作时,AOF都会将命令记录在AOF文件中,但是事实上,由于操作系统的缓存机制,数据并没有真正地写入硬盘,而是进入了系统的硬盘缓存。 在默认情况下系统每30秒会执行一次同步操作,以便将硬盘缓存中的内容真正地写入硬盘, 在这30秒的过程中如果系统异常退出则会导致硬盘缓存中的数据丢失。一般来讲启用AOF持久化的应用都无法容忍这样的损失,这就需要Redis在写入AOF文件后主动要求系统将缓存内容同步到硬盘中。

在 Redis中我们可以通过appendfsync参数设置同步的时机:

# appendfsync always 
appendfsync everysec 
# appendfsync no

其中:

  • 默认情况下Redis采用everysec规则,即每秒执行一次同步操作。
  • always表示每次执行写 入都会执行同步,这是最安全也是最慢的方式。
  • no表示不主动进行同步操作,而是完全交由操作系统来做(即每30秒一次),这是最快但最不安全的方式。

一般情况下使用默认值everysec就足够了,既兼顾了性能又保证了安全。
Redis 允许同时开启AOF和 RDB,既保证了数据安全又使得进行备份等操作十分容易。 此时重新启动Redis后Redis会使用AOF文件来恢复数据,因为AOF方式的持久化可能丢失的数据更少。

集群

一个小型项目,使用一台 Redis 服务器已经非常足够了,然而现实中的项目通常需要若干台Redis服务器的支持:
(1)从结构上,单个 Redis 服务器会发生单点故障,同时一台服务器需要承受所有的请求负载。这就需要为数据生成多个副本并分配在不同的服务器上;
(2)从容量上,单个 Redis 服务器的内存非常容易成为存储瓶颈,所以需要进行数据分片。

同时拥有多个 Redis 服务器后就会面临如何管理集群的问题,包括如何增加节点、故障恢复等操作。

1.1 复制

通过持久化功能,Redis保证了即使在服务器重启的情况下也不会损失(或少量损失)数据。但是由于数据是存储在一台服务器上的,如果这台服务器出现硬盘故障等问题,也会导致数据丢失。
为了避免单点故障,通常的做法是将数据库复制多个副本以部署在不同的服务 器上,这样即使有一台服务器出现故障,其他服务器依然可以继续提供服务。
为此, Redis 提供了复制(replication)功能,可以实现当一台数据库中的数据更新后,自动将更新的数据 同步到其他数据库上。

1.1.1 配置

在复制的概念中,数据库分为两类,一类是主数据库(master),另一类是从数据库(slave)。
主数据库可以进行读写操作,当写操作导致数据变化时会自动将数据同步给从数据库。而从数据库一般是只读的,并接受主数据库同步过来的数据。
一个主数据库可以拥有多个从数据库,而一个从数据库只能拥有一个主数据库。
image.png

配置方式

在 Redis 中使用复制功能非常容易,只需要在从数据库的配置文件中加入slaveof 主数据库地址 主数据库端口即可,主数据库无需进行任何配置。

操作实例

在一台服务器上启动两个 Redis 实例,监听不同端口,其中一个作为主数据库,另一个作为从数据
库。

  1. 首先我们不加任何参数来启动一个Redis实例作为主数据库,该实例默认监听6379端口:

    $ redis-server
    
  2. 然后加上slaveof参数启动另一个Redis实例作为从数据库,并 让其监听6380端口:

    $ redis-server --port 6380 --slaveof 127.0.0.1 6379
    

    此时在主数据库中的任何数据变化都会自动地同步到从数据库中。

  3. 我们打开 redis-cli实例A并连接到主数据库:

    $ redis-cli -p 6379
    
  4. 再打开redis-cli实例B并连接到从数据库:

    $ redis-cli -p 6380
    
  5. 这时我们使用INFO命令来分别在实例A和实例B中获取Replication节点的相关信息:

    redis A> INFO replication 
    role:master connected_slaves:1 
    slave0:ip=127.0.0.1,port=6380,state=online,offset=1,lag=1 
    master_repl_offset:1
    

    可以看到,实例A的角色(上面输出中的role)是master,即主数据库,同时已连接的从数据库(上面输出中的connected_slaves)的个数为1。

  6. 同样在实例B中获取相应的信息为:

    redis B> INFO replication 
    role:slave 
    master_host:127.0.0.1 
    master_port:6379
    

    这里可以看到,实例 B 的role是 slave,即从数据库,同时其主数据库的地址为 127.0.0.1,端口为 6379。

  7. 在实例A中使用SET命令设置一个键的值:

    redis A> SET foo bar 
    OK
    
  8. 此时在实例B中就可以获得该值了:

    redis B> GET foo 
    "bar"
    

    默认情况下,从数据库是只读的,如果直接修改从数据库的数据会出现错误:

    redis B> SET foo hi 
    (error) READONLY You can't write against a read only slave.
    

    可以通过设置从数据库的配置文件中的slave-read-onlyno以使从数据库可写,但是因为对从数据库的任何更改都不会同步给任何其他数据库,并且一旦主数据库中更新了对应的数据就会覆盖从数据库中的改动,所以通常的场景下不应该设置从数据库可写,以免导致易被忽略的潜在应用逻辑错误。

配置多台从数据库的方法也一样,在所有的从数据库的配置文件中都加上slaveof参数指向同一个主数据库即可。
除了通过配置文件或命令行参数设置slaveof参数,还可以在运行时使用 SLAVEOF命令修改:

redis> SLAVEOF 127.0.0.1 6379

如果该数据库已经是其他主数据库的从数据库了,SLAVEOF命令会停止和原来数据库的同步转而和新数据库同步。此外对于从数据库来说,还可以使用SLAVEOF NO ONE命令来使当前数据库停止接收其他数据库的同步并转换成为主数据库。

1.1.2 原理

Redis实现复制的过程:
当一个从数据库启动后,会向主数据库发送SYNC命令。同时主数据库接收到SYNC命令后会开始在后台保存快照(即RDB持久化的过程),并将保存快照期间接收到的命令缓存起来。
当快照完成后,Redis会将快照文件和所有缓存的命令发送给从数据库。从数据库收到后,会载入快照文件并执行收到的缓存的命令。以上过程称为复制初始化。
复制初始化结束后,主数据库每当收到写命令时就会将命令同步给从数据库,从而保证主从数据库数据一 致。
当主从数据库之间的连接断开重连后,Redis 2.6以及之前的版本会重新进行复制初始化 (即主数据库重新保存快照并传送给从数据库),即使从数据库可以仅有几条命令没有收到,主数据库也必须要将数据库里的所有数据重新传送给从数据库。这使得主从数据库断线 重连后的数据恢复过程效率很低下,在网络环境不好的时候这一问题尤其明显。Redis 2.8版的一个重要改进就是断线重连能够支持有条件的增量数据传输,当从数据库重新连接上主数据库后,主数据库只需要将断线期间执行的命令传送给从数据库,从而大大提高Redis复制的实用性。

由于Redis服务器使用TCP协议通信,所以我们可以使用telnet工具伪装成一个从数据库来与主数据库通信。首先在命令行中连接主数据库(默认端口为6379,假设目前没有任何从数据库连接):

$ telnet 127.0.0.1 6379 
Trying 127.0.0.1... 
Connected to localhost. 
Escape character is '^]'.

然后作为从数据库,我们先要发送PING命令确认主数据库是否可以连接:

PING 
+PONG

主数据库会回复+PONG。如果没有收到主数据库的回复,则向用户提示错误。如果主数据库需要密码才能连接,我们还要发送AUTH命令进行验证。而后向主数据库发送REPLCONF命令说明自己的端口号(这里随便选择了一个):

REPLCONF listening-port 6381 
+OK

这时就可以开始同步的过程了:向主数据库发送SYNC命令开始同步,此时主数据库发送回快照文件和缓存的命令。目前主数据库中只有一个foo键,所以收到的内容如下(快照文件是二进制格式,从第三行开始):

SYNC 
$29
REDIS0006?foobar?6_?"

从数据库会将收到的内容写入到硬盘上的临时文件中,当写入完成后从数据库会用该临时文件替换RDB快照文件(RDB快照文件的位置就是持久化时配置的位置,由dirdbfilename两个参数确定),之后的操作就和RDB持久化时启动恢复的过程一样了。
需要注意的是在同步的过程中从数据库并不会阻塞,而是可以继续处理客户端发来的命令。默认情况下,从数据库会用同步前的数据对命令进行响应。可以配置slave-serve-stale-data参数为no来使从数据库在同步完成前对所有命令(除了INFOSLAVEOF)都回复错误:“SYNC with master in progress. ”。
复制初始化阶段结束后,主数据库执行的任何会导致数据变化的命令都会异步地传送给从数据库,这一过程为复制同步阶段。同步的内容和Redis通信协议一 样,比如我们在主数据库中执行SET foo hi,通过telnet我们收到了:

*3
$3
set 
$3
foo 
$2
hi

复制同步阶段会贯穿整个主从同步过程的始终,直到主从关系终止为止。
在复制的过程中,快照无论在主数据库还是从数据库中都起了很大的作用,只要执行复制就会进行快照,即使我们关闭了RDB方式的持久化(通过删除所有save参数)。
Redis 2.8.18之后支持了无硬盘复制。 乐观复制Redis采用了乐观复制(optimistic replication)的复制策略,容忍在一定时间内主从数据库的内容是不同的,但是两者的数据会最终同步。
具体来说,Redis在主从数据库之间复制数据的过程本身是异步的,这意味着,主数据库执行完客户端请求的命令后会立即将命令在主数据库的执行结果返回给客户端,并异步地将命令同步给从数据库,而不会等待从数据库接收到该命令后再返回给客户端。
这一特性保证了启用复制后主数据库的性能不会受到影响,但另一方面也会产生一个主从数据库数据不一致的时间窗口,当主数据库执行了一 条写命令后,主数据库的数据已经发生的变动,然而在主数据库将该命令传送给从数据库之前,如果两个数据库之间的网络连接断开了,此时二者之间的数据就会是不一致的。
从这个角度来看,主数据库是无法得知某个命令最终同步给了多少个从数据库的,不过 Redis 提供了两个配置选项来限制只有当数据至少同步给指定数量的从数据库时,主数据库才是可写的:

  • min-slaves-to-write 3
  • min-slaves-max-lag 10

上面的配置中:
min-slaves-to-write表示只有当3个或3个以上的从数据库连接到主数据库时,主数据库才是可写的,否则会返回错误,例如:

redis> SET foo bar 
(error) NOREPLICAS Not enough good slaves to write.

min-slaves-max-lag表示允许从数据库最长失去连接的时间,如果从数据库最后与主数据库联系(即发送 REPLCONF ACK命令)的时间小于这个值,则认为从数据库还在保持与主数据库的连接。
举个例子,按上面的配置,主数据库假设与3个从数据库相连,其中一个从数据库上一次与主数据库联系是9 秒前,这时主数据库可以正常接受写入,一旦1秒过后这台从数据库依旧没有活动,则主数据库则认为目前连接的从数据库只有2个,从而拒绝写入。这一特性默认是关闭的,在分布式系统中,打开并合理配置该选项后可以降低主从架构中因为网络分区导致的数据不一致的问题。

1.1.3 图结构

从数据库不仅可以接收主数据库的同步数据,自己也可以同时作为主数据库存在,形成类似图的结构。
如下图所示,数据库A的数据会同步到B和C中,而B中的数据会同步到D和E中。向B中写入数据不会同步到A或C中,只会同步到D和E中。
image.png

1.1.4 读写分离与一致性

通过复制可以实现读写分离,以提高服务器的负载能力。
在常见的场景中(如电子商务网站),读的频率大于写,当单机的Redis无法应付大量的读请求时(尤其是较耗资源的请求,如SORT命令等)可以通过复制功能建立多个从数据库节点,主数据库只进行写操作, 而从数据库负责读操作。
这种一主多从的结构很适合读多写少的场景,而当单个的主数据库不能够满足需求时,就需要使用Redis 3.0推出的集群功能。

1.1.5 从数据库持久化

另一个相对耗时的操作是持久化,为了提高性能,可以通过复制功能建立一个(或若干个)从数据库,并在从数据库中启用持久化,同时在主数据库禁用持久化。
当从数据库崩溃重启后主数据库会自动将数据同步过来,所以无需担心数据丢失。
然而当主数据库崩溃时,情况就稍显复杂了。手工通过从数据库数据恢复主数据库数据时,需要严格按照以下两步进行。

  1. 在从数据库中使用SLAVEOF NO ONE命令将从数据库提升成主数据库继续服务。
  2. 启动之前崩溃的主数据库,然后使用SLAVEOF命令将其设置成新的主数据库的从数据库,即可将数据同步回来。

注意当开启复制且主数据库关闭持久化功能时,一定不要使用Supervisor以及类似的进程管理工具令主数据库崩溃后自动重启。
同样当主数据库所在的服务器因故关闭时,也要避免直接重新启动。这是因为当主数据库重新启动后,因为没有开启持久化功能,所以数据库中所有数据都被清空,这时从数据库依然会从主数据库中接收数据,使得所有从数据库也被清空,导致从数据库的持久化失去意义。
无论哪种情况,手工维护从数据库或主数据库的重启以及数据恢复都相对麻烦,好在Redis提供了一种自动化方案哨兵来实现这一过程,避免了手工维护的麻烦和容易出错的问题。

1.1.6 无硬盘复制

Redis复制的工作原理中,复制是基于RDB方式的持久化实现的,即主数据库端在后台保存 RDB 快照,从数据库端则接收并载入快照文件。这样的实现优点是可以 显著地简化逻辑,复用已有的代码,但是缺点也很明显。 (1)当主数据库禁用RDB快照时(即删除了所有的配置文件中的save语句),如果执行了复制初始化操作,Redis依然会生成RDB快照,所以下次启动后主数据库会以该快照恢复数 据。因为复制发生的时间不能确定,这使得恢复的数据可能是任何时间点的。
(2)因为复制初始化时需要在硬盘中创建RDB快照文件,所以如果硬盘性能很慢(如 网络硬盘)时这一过程会对性能产生影响。
举例来说,当使用 Redis 做缓存系统时,因为不需要持久化,所以服务器的硬盘读写速度可能较差。但是当该缓存系统使用一主多从的集群架构时,每次和从数据库同步,Redis都会执行一次快照,同时对硬盘进行读写,导致性能降低。因此从2.8.18版本开始,Redis引入了无硬盘复制选项,开启该选项时,Redis在与从数据库进行复制初始化时将不会将快照内容存储到硬盘上,而是直接通过网络发送给从数据库, 避免了硬盘的性能瓶颈。
目前无硬盘复制的功能还在试验阶段,可以在配置文件中使用如下配置来开启该功能:

repl-diskless-sync yes

1.1.7 增量复制

复制的原理中提到当主从数据库连接断开后,从数据库会发送SYNC命令来重新进行一次完整复制操作。这样即使断开期间数据库的变化很小(甚至没有),也需要将数据库中的所有数据重新快照并传送一次。
在正常的网络应用环境中,这种实现方式显然不太理想。Redis 2.8版相对2.6版的最重要的更新之一就是实现了主从断线重连的情况下的增量复制。

增量复制是基于如下3点实现的,这3点是实现增量复制的基础:

  1. 从数据库会存储主数据库的运行ID(run id)。每个Redis 运行实例均会拥有一个唯一的运行ID,每当实例重启后,就会自动生成一个新的运行ID。
  2. 在复制同步阶段,主数据库每将一个命令传送给从数据库时,都会同时把该命令存放到一个积压队列(backlog)中,并记录下当前积压队列中存放的命令的偏移量范围。
  3. 同时,从数据库接收到主数据库传来的命令时,会记录下该命令的偏移量。

在主从通信流程中,可以看到,当主从连接准备就绪后,从数据库会发送一条SYNC命令来告诉主数据库可以开始把所有数据同步过来 了。而2.8版之后,不再发送SYNC命令,取而代之的是发送PSYNC,格式为PSYNC主数据库的运行ID 断开前最新的命令偏移量
主数据库收到PSYNC命令后,会执行以下判断来决定此次重连是否可以执行增量复制:

  1. 首先主数据库会判断从数据库传送来的运行ID是否和自己的运行ID相同。这一步骤的意义在于确保从数据库之前确实是和自己同步的,以免从数据库拿到错误的数据(比如主数据库在断线期间重启过,会造成数据的不一致)。
  2. 然后判断从数据库最后同步成功的命令偏移量是否在积压队列中,如果在则可以执行增量复制,并将积压队列中相应的命令发送给从数据库。 如果此次重连不满足增量复制的条件,主数据库会进行一次全部同步(即与Redis 2.6的过程相同)。

大部分情况下,增量复制的过程对开发者来说是完全透明的,开发者不需要关心增量复制的具体细节。2.8版本的主数据库也可以正常地和旧版本的从数据库同步(通过接收SYNC命令),同样2.8版本的从数据库也可以与旧版本的主数据库同步(通过发送SYNC命 令)。
唯一需要开发者设置的就是积压队列的大小了。 积压队列在本质上是一个固定长度的循环队列,默认情况下积压队列的大小为1 MB, 可以通过配置文件的repl-backlog-size选项来调整。很容易理解的是,积压队列越大,其允许的主从数据库断线的时间就越长。
根据主从数据库之间的网络状态,设置一个合理的积压队列很重要。因为积压队列存储的内容是命令本身,如SET foo bar,所以估算积压队列的大小只需要估计主从数据库断线的时间中主数据库可能执行的命令的大小即可。
与积压队列相关的另一个配置选项是repl-backlog-ttl,即当所有从数据库与主数据库断开连接后,经过多久时间可以释放积压队列的内存空间。默认时间是1小时。

1.2 哨兵

在一个典型的一主多从的Redis系统中,从数据库在整个系统中起到了数据冗余备份和读写分离的作用。
当主数据库遇到异常中断服务后,开发者可以通过手动的方式选择一个从数据库来升格为主数据库,以使得系统能够继续 提供服务。然而整个过程相对麻烦且需要人工介入,难以实现自动化。
为此,Redis 2.8中提供了哨兵工具来实现自动化的系统监控和故障恢复功能。

注意:
Redis 2.6 版也提供了哨兵工具,但此时的哨兵是1.0版,存在非常多的问题,在任何情况下都不应该使用这个版本的哨兵。

1.2.1 什么是哨兵

哨兵的作用就是监控Redis系统的运行状况。它的功能包括以下两个:

  1. 监控主数据库和从数据库是否正常运行。
  2. 主数据库出现故障时自动将从数据库转换为主数据库。

哨兵是一个独立的进程。

一个典型的使用哨兵的Redis架构。虚线表示主从复制关系,实线表示哨兵的监控路径:
image.png

在一个一主多从的Redis系统中,可以使用多个哨兵进行监控任务以保证系统足够稳健,此时不仅哨兵会同时监控主数据库和从数据库,哨兵之间也会互相监控。
image.png

1.2.2 实操

首先按照建立起3个Redis实例,其中包括一个主数据库和两个从数据库。主数据库的端口为6379,两个从数据库的端口分别为63806381。我们使用Redis命令行客户端来获取复制状态,以保证复制配置正确。

  1. 首先是主数据库:

    redis 6379> INFO replication     
    # Replication 
    role:master 
    connected_slaves:2 
    slave0:ip=127.0.0.1,port=6380,state=online,offset=10125,lag=0 
    slave1:ip=127.0.0.1,port=6381,state=online,offset=10125,lag=1
    

    可见其连接了两个从数据库,配置正确。

  2. 然后用同样的方法查看两个从数据库的配置: ```bash redis 6380> INFO replication

    Replication

    role:slave master_host:127.0.0.1 master_port:6379

redis 6381> INFO replication

Replication

role:slave master_host:127.0.0.1 master_port:6379

当出现的信息如上时,即证明一主二从的复制配置已经成功了。

3. 接下来开始配置哨兵

建立一个配置文件,如`sentinel.conf`,内容为:
```bash
sentinel monitor mymaster 127.0.0.1 6379 1

其中mymaster表示要监控的主数据库的名字,可以自己定义一个。这个名字必须仅由大小写字母、数字和“.-_”这 3 个字符组成。
后两个参数表示主数据库的地址和端口号,这里我们要监控的是主数据库6379。
最后的1表示最低通过票数。

  1. 接下来执行来启动Sentinel进程,并将上述配置文件的路径传递给哨兵:
    $ redis-sentinel /path/to/sentinel.conf
    
    需要注意的是,配置哨兵监控一个系统时,只需要配置其监控主数据库即可,哨兵会自动发现所有复制该主数据库的从数据库。
    启动哨兵后,哨兵输出如下内容: ```bash [71835] 19 Feb 22:32:28.730 # Sentinel runid is e3290844c1a404699479771846b716c7fc830e80

[71835] 19 Feb 22:32:28.730 # +monitor master mymaster 127.0.0.1 6379 quorum 1

[71835] 19 Feb 22:33:09.997 *+slave slave 127.0.0.1:6380 127.0.0.1 6380 @ mymaster 127.0.0.1 6379

[71835] 19 Feb 22:33:30.068 *+slave slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6379

其中`+slave`表示新发现了从数据库,可见哨兵成功地发现了两个从数据库。现在哨兵已经在监控这3个Redis实例了。

5. 我们将主数据库(即运行在6379端口上的Redis实例)关闭 (杀死进程或使用 SHUTDOWN 命令)

等待指定时间后(可以配置,默认为 30 秒),哨兵会输出如下内容:
```bash
[71835] 19 Feb 22:36:03.780 # +sdown master mymaster 127.0.0.1 6379 
[71835] 19 Feb 22:36:03.780 # +odown master mymaster 127.0.0.1 6379 #quorum 1/1

其中+sdown表示哨兵主观认为主数据库停止服务了,而+odown则表示哨兵客观认为主数据库停止服务了。此时哨兵开始执行故障恢复, 即挑选一个从数据库,将其升格为主数据库。
同时输出如下内容:

[71835] 19 Feb 22:36:03.780 # +try -failover master mymaster 127.0.0.1 6379 
……
[71835] 19 Feb 22:36:05.913 # +failover-end master mymaster 127.0.0.1 6379 
[71835] 19 Feb 22:36:05.913 # +switch-master mymaster 127.0.0.1 6379 127.0.0.1 6380 
[71835] 19 Feb 22:36:05.914 *+slave slave 127.0.0.1:6381 127.0.0.1 6381 @ mymaster 127.0.0.1 6380 
[71835] 19 Feb 22:36:05.914 *+slave slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380

+try-failover表示哨兵开始进行故障恢复,+failover-end表示哨兵完成故障恢复,期间涉及的内容比较复杂,包括领头哨兵的选举、备选从数据库的选择等,此处只需要关注最后3条输出。
+switch-master表示主数据库从6379端口迁移到6380端口,即6380端口的从数据库被升格为主数据库,同时两个+slave则列出了新的主数据库的两个从数据库,端口分别为63816379
其中6379就是之前停止服务的主数据库,可见哨兵并没有彻底清除停止服务的实例的信息,这是因为停止服务的实例有可能会在之后的某个时间恢复服务,这时哨兵会让其重新加入进来,所以当实例停止服务后,哨兵会更新该实例的信息,使得当其重新加入后可以按照当前信息继续对外提供服务。
此例中6379端口的主数据库实例停止服务了,而6380端口的从数据库已经升格为主数据库,当6379端口的实例恢复服务后,会转变为6380端口实例的从数据库来运行,所以哨兵将6379端口实例的信息修改成了6380端口实例的从数据库。

  1. 故障恢复完成后,可以使用Redis命令行客户端重新检查63806381两个端口上的实例的复制信息: ```bash redis 6380> INFO replication

    Replication

    role:master connected_slaves:1 slave0:ip=127.0.0.1,port=6381,state=online,offset=270651,lag=1

redis 6381> INFO replication

Replication

role:slave master_host:127.0.0.1 master_port:6380

可以看到`6380`端口上的实例已经确实升格为主数据库了,同时`6381`端口上的实例是其从数据库。整个故障恢复过程就此完成。

7. 此时我们将6379端口上的实例重新启动

首先哨兵会监控到这一变化,并输出:
```bash
[71835] 19 Feb 23:46:14.573 # -sdown slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380 
[71835] 19 Feb 23:46:24.504 *+convert-to-slave slave 127.0.0.1:6379 127.0.0.1 6379 @ mymaster 127.0.0.1 6380

-sdown表示实例6379已经恢复服务了(与+sdown相反),同时+convert-to-slave表示将6379端口的实例设置为6380端口实例的从数据库。

  1. 这时使用Redis命令行客户端查看6379端口实例的复制信息

    redis 6379> INFO replication 
    # Replication 
    role:slave 
    master_host:127.0.0.1 
    master_port:6380
    
  2. 同时6380端口实例的复制信息为:

    redis 6380> INFO replication 
    # Replication 
    role:master 
    connected_slaves:2 
    slave0:ip=127.0.0.1,port=6381,state=online,offset=292948,lag=1 
    slave1:ip=127.0.0.1,port=6379,state=online,offset=292948,lag=1
    

    正如预期一样,6380端口实例的从数据库变为了两个,6379成功恢复服务。