Redis入门指南_注释笔记

简介

SQL型和NoSQL型的区别

SQL:适合存储结构化数据,如用户的帐号、地址。

  • 这些数据通常需要做结构化查询
  • 这些数据的规模、增长的速度通常是可以预期的
  • 事务性、一致性

NoSQL:适合存储非结构化数据,如文章、评论、微博。

  • 这些数据通常用于模糊处理,如全文搜索、机器学习
  • 这些数据是海量的,而且增长的速度是难以预期的,
  • 根据数据的特点,NoSQL 数据库通常具有无限(至少接近)伸缩性
  • 按key获取数据效率很高,但是对 join 或其他结构化查询的支持就比较差

Redis和Memcached

准备

安装

1
2
3
4
5
6
wget http://download.redis.io/redis-stable.tar.gz
tar xzf redis-stable.tar.gz
cd redis-stable
make
make install # 可执行文件复制到/usr/local/bin目录内
[make test]

启动和停止

Redis 的组件介绍

1
2
3
4
5
6
redis-server        # Redis服务器
redis-cli # Redis命令行客户端
redis-benchmark # Redis性能测试工具
redis-check-aof # AOP文件修复工具
redis-check-dump # RDB文件检查工具
redis-sentinel # Sentinel服务器

启动

直接启动

1
redis-server [--port 6380] # 默认6379

通过初始化脚本启动

复制源目录 utils 文件夹下的初始化脚本:

1
sudo cp redis_init_script /etc/init.d/redis_6379

修改配置文件的 REDISPORT 为同样的端口号,然后创建需要的目录:

1
2
sudo mkdir /etc/redis
sudo mkdir /var/redis/6379

复制配置文件模板到刚出案件的目录:

1
sudo cp /opt/redis-stable/redis.conf /etc/redis/6379.conf

修改配置文件:
修改配置文件:

设置 Redis 随系统自启:

1
sudo update-rc.d redis_6379 defaults

如果报错:insserv: warning: script ‘redis6379′ missing LSB tags and overrides,就在 /etc/init.d/redis_6379 文件中添加头:

1
2
3
4
5
6
7
8
9
### BEGIN INIT INFO
# Provides: redis6379
# Required-Start: $local_fs $network
# Required-Stop: $local_fs
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
# Short-Description: redis6379
# Description: penavico redis 6379
### END INIT INFO

启动 Redis 守护进程:

1
sudo service redis_6379 start

停止

优雅的停止:redis-cli SHUTDOWN
或者:kill PID

Redis 命令行客户端

发送命令

1
redis-cli [-h 127.0.0.1] [-p 6379] [-a password]

测试联通性:

1
redis-cli ping

命令返回值

返回值的5种类型:

  1. 状态回复
    返回命令状态信息,ping/set。

  2. 错误回复
    以(error)开头,后面跟上错误信息。比如执行一个不存在的命令

  3. 整数回复
    以(integer)开头,如递增键值的 INCR 命令,返回递增后的键值。

  4. 字符串回复
    以双引号包裹,请求一个字符串类型键的键值或一个其他类型键中的某个元素时返回。如果不存在时得到一个空结果(nil)

  5. 多行字符串回复
    请求一个非字符串类型键的元素列表时返回,每行以一个序号开头。如 keys *

配置

指定配置文件启动 server:

1
redis-server /path/to/redis.conf [--loglevel warning]

启动参数传递的同名配置会覆盖配置文件中相应的参数。

不重启 redis 修改部分配置:

1
config set loglevel warning

获取 redis 当前的配置情况:

1
config get loglevel

redis.conf 部分配置说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
bind 127.0.0.1  //默认绑定主机地址
port 6379 //默认端口
timeout 0 //当客户端闲置多久后关闭连接,0代表没有启动该选项
loglevel notice //日志的记录级别
// debug (很详细的信息 ,适合开发和测试)
// verbose (包含不太有用的信息)
// notice (适合生产环境)
// warning (警告信息)
logfile "" //日志的记录方式,默认为标准输出
databases 16 //默认数据库数量为16个,编号从0到1

save <seconds> <changed> //多少sec至少有多少个changed才将其同步到磁盘中的数据文件里
save 900 1 //900秒(15分钟)至少有1次
save 300 10 //300秒(5分钟)至少有10次
save 60 10000 //60秒(1分钟)至少有10000次

rdbcompression yes //存储本地数据库是否启用LZF压缩,默认yes
dbfilename dump.rdb //指定本地数据库文件名,默认为dump.rdb
dir ./ //指定本地数据库的存放目录,默认是当前目录

允许外部访问:
注释掉 bind,修改 protected-mode 为 no。

设置访问密码:

1
requirepass yourpassword

多数据库

一个 redis 实例提供了多个用来存储数据的字典,就像一个关系数据库实例中可以创建多个数据库。
redis 不支持自定义数据库的名字,每个数据库都是以一个从0开始的递增数字命名,redis 默认支持16个数据库。可以通过修改配置 databases 来修改这一数字。
通过 select 命令更换数据库。(默认建立连接后选择0号数据库)
redis 也不支持为每个数据库设置不同的访问密码。
多个数据库之间并不是完全隔离的,flushall 命令可以清空一个redis实例中所有数据库中的数据。所以这些数据库更像是一种命名空间,可以使用0号存储生产数据,使用1号存储测试数据。但不适宜存储不同应用程序的数据。
一个空 redis 实例占用的内存只有1MB

入门

热身

  1. 获得符合规则的键名列表

    1
    2
    3
    4
    keys ?
    keys *
    keys a[b-d]
    keys \?

    keys 会遍历 redis 中的所有键,键数量较多时会影响性能。

  2. 判断一个键是否存在

    1
    exist bar
  3. 删除键

    1
    del key

    del 本身不支持通配符,可以通过执行 redis-cli DEL 'redis-cli KEYS "user:*"' 实现通配符效果。

  4. 获得键值的数据类型

    1
    2
    3
    4
    5
    6
    7
    type key

    set foo 1
    type foo //string

    lpush bar 1
    type bar //list

字符串类型

命令

一个字符串类型键允许存储的数据的最大容量是512MB
列表类型是以列表的形式组织字符串,而集合类型是以集合的形式组织字符串。

  1. 赋制取值

    1
    2
    set key value
    get key
  2. 递增数字

    1
    incr key

    包括 incr 在内的所有 redis 命令都是原子操作,无论多少个客户端同时连接,都不会出现“竞态”。

实践

  1. 文章访问量统计
    每篇文章存一个 post:文章ID:page.view 来记录访问量,使用 incr 递增。
    Redis 命名比较好的实践是用“对象类型:对象ID:对象属性”来命名一个健,如使用健 user:1:friends 来存储ID为1的用户的好友列表。对于多个单词则推荐使用“.”分隔。

  2. 生成自增ID
    对每一类对象使用 objects:count(如users:count)的键来存储当前类型对象的数量

  3. 存储文章数据

命令拾遗

  1. 增加指定的整数

    1
    2
    incrby key increment
    incrby bar 3
  2. 减少指定的整数

    1
    2
    decr key
    decr key decrement
  3. 增加指定浮点数

    1
    incrbyfloat key increment
  4. 向尾部追加值

    1
    append key value
  5. 获取字符串长度

    1
    2
    3
    4
    strlen key

    set key 你好
    strlen key //返回6,中文的UTF-8编码长度一般都是3
  6. 同时获得/设置多个键值

    1
    2
    mget key [key ...]
    mset key value [key value ...]
  7. 位操作

    1
    2
    3
    4
    getbit key offset
    setbit key offset value
    bitcount key [start] [end]
    bitop operation destkey key [key ...]

    利用位操作命令可以非常紧凑地存储布尔值。比如如果网站的每个用户都有一个递增的整数ID,如果使用一个字符串类型键配合位操作来记录每个用户的性别(用户ID作为索引,二进制位值1和0表示男性和女性),那么记录100万个用户的性别只需占用100KB多的空间,而且由于 GETBIT 和 SETBIT 的时间复杂度都是O(1),所以读取二进制位值性能很高。

散列类型

介绍

散列类型适合存储对象,字段值只能是字符串。除了散列类型,其他数据类型同样不支持数据类型嵌套。
不同于关系型数据库,所有记录(数据表)不一定拥有相同的属性,完全可以自由地为任何键增减字段而不影响其他键。

命令

  1. 赋值与取值

    1
    2
    3
    4
    5
    hset key field value
    hget key field
    hmset key field value [field value ...]
    hmget key field [field ...]
    hgetall key

    hset 不区分插入和更新操作,字段存在则更新,返回0,不存在则插入,返回1。

  2. 判断字段是否存在

    1
    hexists key field
  3. 当字段不存在时赋值

    1
    hsetnx key field value

    与 hset 的区别是,当字段已存在时不执行任何操作

  4. 增加数字

    1
    2
    hincrby key field increment
    hincrby person score 60
  5. 删除字段

    1
    hdel key field [field ...]

    返回被删除字段个数

实践

存储文章缩略名,伪代码:

1
2
3
4
5
6
id = incr posts:count
hsetnx slug.to.id slug id
hmset post:id title title content content ....

id = hget slug.to.id slug
hgetall post:id

命令拾遗

  1. 只获取字段名或字段值

    1
    2
    hkeys key
    hvals key
  2. 获得字段数量

    1
    hlen key

列表类型

介绍

redis 的列表类型内部使用双向链表实现的。一个列表类型键最多能容纳2的32次方-1个元素,与散列类型键最多能容纳的字段数量相同。

命令

  1. 向列表两端增加元素

    1
    2
    lpush key value [value ...]     //向列表左边增加元素,返回列表的长度
    rpush key value [value ...]
  2. 从列表两端弹出元素

    1
    2
    lpop key
    rpop key

    把列表当做栈,则使用 lpush 和 lpop 或 rpush 和 rpop。
    把列表当做队列,则搭配使用 lpush 和 rpop 或 rpush 和 lpop。

  3. 获取列表中元素的个数

    1
    llen key

    时间复杂度为O(1)。

  4. 获得列表片段

    1
    lrange key start stop

    lrange 获取时不像 lpop 一样删除该片段,返回的值包含最右边的元素。支持负索引,表示从右边开始计算。

  5. 删除列表中指定的值

    1
    lrem key count value

    返回实际删除的个数。
    count > 0时,lrem 从列表左边开始删除前 count 个值为 value 的元素。
    count < 0时,lrem 从右边删除前 |count| 个值为 value 的元素。
    count = 0时,lrem 命令会删除所有值为 value 的元素。

实践

  1. 存储文章ID列表

    发布文章时使用 lpush 把新ID加入列表中,删除文章时也要把列表中的ID删除。就可以使用 lrange 命令来实现文章的分页显示了。但更适合存储文章ID的方式是使用有序集合类型。

  2. 存储评论列表

    如果评论不允许修改,且一般获取评论都是获取全部。所以可以使用列表来存储,使用列表类型键 post:文章ID:comments 来存储某个文章的所有评论。读取评论同样使用 lrange 即可。

命令拾遗

  1. 获得/设置指定索引的元素值

    1
    2
    lindex key index
    lset key index value
  2. 只保留列表指定片段

    1
    ltrim key start end

    ltrim常和lpush命令一起使用限制列表中元素的数量,比如只保留最近的100条日志。

    1
    2
    lpush logs newlog
    ltrim logs 0 99
  3. 向列表中插入元素

    1
    2
    3
    linsert key key BEFORE|AFTER pivot value

    linsert numbers before 7 3
  4. 将元素从一个列表转到另一个列表

    1
    rpoplpush source destination

    先对source执行rpop,然后将返回值lpush进destination,然后返回这个值。
    source和destination相同时,此命令会不断地将队尾的元素移到队首。整个过程是原子的。
    利用这个特性可以实现一个循环队列,可以无缝地加入新元素,或新的处理者。

集合类型

介绍

一个集合类型(set)键可以最多存储2的32次方-1个字符串。在 redis 中集合是用值为空的散列表实现的,所以这些操作的时间复杂度都是O(1)。

命令

  1. 增加/删除元素

    1
    2
    sadd key member [member ...]
    srem key member [member ...]

    不存在则创建,存在则忽略。
    返回本次成功加入/删除的元素数量(不包括已存在的元素)。

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

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

    1
    sismember key member
  4. 集合间运算

    1
    2
    3
    sdiff key [key ...]
    sinter key [key ...]
    sunion key [key ...]

实践

  1. 存储文章标签

    1
    2
    sadd post:12:tags 技术文章 redis
    smembers post:12:tags
  2. 通过标签搜索文章
    为每个标签使用一个名为 tag:标签:posts 的集合类型存储该标签下的文章ID列表。获取属于多个标签的文章时,只需对这些键的集合进行 sinter 运算即可。

命令拾遗

  1. 获得集合中元素个数

    1
    scard key
  2. 进行集合运算并将结果存储

    1
    2
    3
    sdiffstore destination key [key ...]
    sinterstore destination key [key ...]
    sunionstore destination key [key ...]

    此类命令常用于需要进行多步集合运算的场景中

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

    1
    srandmember key [count]

    count 为正数时,随机从集合中获得 count 个不重复的元素。如果 count 的值大于集合中的元素个数,则返回集合中的全部元素。
    count 为负数时,随机从集合中获得 |count| 个元素,这些元素有可能重复。如果 |count| 的值大于集合中的元素个数,则返回 |count| 个元素,这些元素有可能重复。
    srandmember 获得的结果并不够随机,原因:
    srandmember

  4. 从集合中弹出一个元素

    1
    spop key

    从集合中随机选择一个元素弹出(并删除)。

有序集合类型

介绍

sorted set,为每个元素都关联了一个分数,所以可以获得分数最高(最低)的前N个元素,可以获得指定分数范围内的元素等。

  1. 列表类型是通过链表实现的,获取靠近两端的数据速度极快,而当元素增多后,访问中间数据的速度会较慢,所以它更加适合实现如“新鲜事”或“日志”这样很少访问中间元素的应用。
  2. 有序集合类型是使用散列表和跳跃表(Skip list)实现的,所以即使读取位于中间部分的数据速度也很快(时间复杂度是O(logN))。
  3. 列表中不能简单地调整某个元素的位置,但是有序集合可以(通过更改这个元素的分数)。
  4. 有序集合要比列表类型更耗费内存。

命令

  1. 增加元素

    1
    zadd key score member [score member ...]

    元素不存在则新增,存在则更新分数。score可以是整数,浮点数,+/-inf等。

  2. 获得元素的分数

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

    1
    2
    zrange key start stop [withscores]
    zrevrange key start stop [withscores]

    按分数的范围正向/反向获取元素[是否携带分数],分数相同时按照字典顺序排序(中文按照UTF-8编码后的字母顺序)。start/stop 可以为正数、负数(从后往前数)、无限大。

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

    1
    2
    zrangebyscore key min max [withscores] [limit offset count]
    zrevrangebyscore key max min [withscores] [limit offset count]

    根据分数范围返回元素,如果不希望返回端点值,可以在分数前面加上“(”

    1
    zrangebyscore scoreboard 80 (100
  5. 增加某个元素的分数

    1
    zincrby key increment member

    increment可以是正数/负数,如果member不存在,redis会先建立它并且设置分数为0,后再执行操作。

实践

  1. 实现按点击量排序

    1
    2
    3
    4
    5
    zincrby posts:page.view 1 文章ID

    zrevrange posts:page.view start end
    foreach ids
    hgetall post:id
  2. 改进按时间排序

    在支持更改发布时间,又想通过发布时间对文章进行排序的博客系统中,可以使用有序列表实现。元素的分数是文章发布的Unix时间戳。

命令拾遗

  1. 获得集合中元素的数量

    1
    zcard key
  2. 获得指定分数范围内的元素数量

    1
    2
    3
    zcount key min max

    zcount scoreboard (89 +inf
  3. 删除一个或多个元素

    1
    zrem key member [member ...]
  4. 按照排名范围删除元素

    1
    2
    3
    4
    5
    zremrangebyrank key start stop

    zadd test 1 a 2 b 3 c 4 d 5 f 6 e
    zremrangebyrank test 2 3
    zrange test 0 -1
  5. 按照分数范围删除元素

    1
    zremrangebyscore key min max
  6. 获得元素的排名

    1
    2
    zrank key member
    zrevrank key member
  7. 计算有序集合的交集

    1
    zinterstore destination numkeys key [key ...] [weights weight [weight ...]] [aggregate sum|min|max]

    numkeys,key的个数。
    weights,计算时被乘上的权重。
    aggregate,最终值的计算方式。
    zunionstore 同上。

进阶

事务

概述

redis 的事务是一组命令的集合,一个事务中的命令要么都执行,要么都不执行。也可避免被其他客户端插队执行命令。

1
2
3
4
5
multi
命令1
命令2
...
exec

错误处理

  1. 语法错误
    事务中的命令,只要有一个命令有语法错误,执行 Exec 命令后 Redis 就会直接返回错误,连语法正确的命令也不会执行。

  2. 运行错误
    redis 不支持回滚(rollback),如果事务中有命令出现了运行错误,事务里其他的命令依然会继续执行。

watch命令介绍

watch 命令可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行。监控一直持续到 Exec 命令(事务中的命令是在Exec之后才执行的,所以在 Multi 命令后可以修改watch监控的键值)。Exec 执行后会取消对所有键的监控。或者执行 unwatch 命令来取消监控。
由于 watch 命令的作用只是当被监控的健值被修改后阻止之后一个事务的执行,而不能保证其他客户端不修改这一健值,所以我们需要在 Exec 执行失败后重新执行整个函数。

过期时间

命令介绍

1
expire key seconds

单位是秒。

1
TTL key

返回一个键剩余有效期(单位是秒),当键不存在时会返回-2,当键没有设置过期时间(即永久存在,这是默认情况)时返回-1。

pexpire/pttl key,时间单位是毫秒。其他和上述命令一样。
persist key,将键恢复称永久的。重新执行赋值,比如set,也会清除键的过期时间。

键时间到期并自动删除,并不会被 watch 命令认为该键被改变。

实现访问频率限制

使用名为 rate.limiting:用户IP 的字符串类型键,创建后设置过期时间,然后每次用户访问则 incr 一次。大于某个值则返回超过限制。
因为创建和设置过期时间是两个命令,所以最好放在一个事务内完成。

实现缓存

实际开发中会发现很难为缓存键设置合理的过期时间,为此可以限制 Redis 能够使用的最大内存并让 Redis 按照一定的规则淘汰不需要的缓存键这种方式在只将 Redis 用作缓存系统时非常实用。

具体的设置方法为:修改配置文件的 maxmemory 参数,限制 Redis 最大可用内存大小(单位是字节),当超出了这个限制时 Redis 会依据 maxmemory-policy 参数指定的策略来删除不需要的键直到 Redis 占用的内存小于指定内存。

具体规则:
maxmemory

LRU(LeastRecentlyused)算法即“最近最少使用”,即当需要空间时这些键是可以被删除的。
实际上 redis 并不会准确地将整个教据库中最久未被使用的键删除。而是每次从数据库中随机取3个键并删除这3个键中最久未被使用的键。删除过期时间最接近的键也一样。“3”这个数字可以通过配置文件的 maxmemory-samples 参数设里。

排序

有序集合的集合操作

redis 没有类似于 zinter 的命令,可以自己实现:

1
2
3
4
5
multi
zinterstore tempKey ...
zrange tempKey ...
del tempKey ...
exec

sort命令

可以操作列表、集合、有序集合类型键进行排序。
redis 对集合类型存储对象的Id的情况进行了特殊优化,元素的排列是有序的。

指定 ALPHA 参数,按照字典顺序排列。
指定 DESC 参数,倒序排序。
支持 LIMIT 参数,来进行分页。

by参数

有时候单纯对ID排序意义不大,更多时候按照对象的某个属性排序,可以使用by参数。

1
2
sort tag:ruby:postIds by post:*->time desc
sort sortlist by items:* desc

遍历Id,然后使用元素值替换参键件中第一个“”,并获取该值对元素排序,当键的元素值相同时,对键本身进行排序。
参考键可以是字符串类型键或者是散列类型键的某个字段(
的必须放在->前面)。

当参考键不包含“*”时,不执行排序操作。可以使用这个特性,借助 sort 命令和常量键名,获得与元素相关的数据。
当某个元素的参考键不存在时,会取默认值为0。

get参数

get 的作用是使 sort 命令的返回结果不再是元素自身的值,而是 get 参数中指定的键值。

1
sort tag:ruby:postIds by post:*->time desc get post:*->title [get ...]

get #,会返回元素本身

store 参数

保存结果,保存后的键类型为列表类型,如果键已经存在则会覆盖。store 参数常用来结合 expire 命令缓存排序结果。

性能优化

sort 命令的时间复杂度是O(n+mlog(m)),n表示元素个数,m表示要返回的元素个数。

  1. 尽可能减少待排序键中元素的数量。
  2. 使用 limit 参数只获取需要的数据。
  3. 如果要排序的数据数量较大,尽可能使用 store 参数将结果缓存起来。

消息通知

使用Redis实现任务队列

1
2
brpop key, timeout
brpop key, timeout

brpop 命令和 rpop 命令相似,唯一的区别是当列表中没有元素时 BRPOP 命令会一直阻塞住连接,直到有新元素加入。返回两个值,分别是键名和元素值。

优先级队列

多重任务使用同一个队列的时候,可以设置不同的优先级。

1
brpop queue:confirmation.email queue.notification.email 0

按照从左到右的顺序,在左边队列有元素可获得情况下,优先获取左边的队列的元素。

“发布/订阅”模式

1
2
3
publish channel message

publish channel.1 hi

发布消息,返回接收到这条消息的订阅者数量。发出去的消息不会被持久化。

1
2
3
subscribe channel [channel ...]

subscribe channel.1

订阅消息,可以同时订阅多个频道。

收到的发布消息有三种类型,每种类型的回复都包含3个值。

订阅消息

按照规则订阅

psubscribe,支持glob风格通配符格式。

1
psubscribe channel.?*

psubscribe

管道

客户端和 Redis 使用TCP协议链接,发送命令返回结果都需要经过网络传输,这两部分的总耗时称为往返时延。
大致来说到本地回环地址(loopbackaddress)的往返时延在数量级上相当于 Redis 处理一条简单命令的时间。如果执行较多的命令,每个命令的往返时延累加起来对性能还是有一定影响的。
Redis 的底层通信协议对管道(pipelining)提供了支持。通过管道可以一次性发送多条命令并在执行完后一次性将结果返回,从而实现降低往返时延影响的目的。

节省空间

精简键名和键值,多使用缩写。

内部编码优化

键中元素很少时,Redis 会采用一种更为紧凑但性能稍差(获取元素的时间复杂度为O(n))的内部编码方式。内部编码方式的选择对于开发者来说是透明的,当键中元素变多时 Redis 会自动将该键的内部编码方式转换成散列表。
使用 object encoding key 来查看编码方式,执行结果如图:

encoding

实践

.Net Core与redis

客户端参考:https://www.cnblogs.com/yilezhu/p/9947905.html

python与redis

安装 redis-py,pip install redis

1
2
3
4
import redis

r = redis.StrictRedis(host='127.0.0.1', password='123123')
print(r.hgetall('post:1'))

简便用法

  1. HMSET/HGETALL

    1
    2
    3
    r.hmset('people', {'name': 'bob'})
    people = r.hgetall('people')
    print(people)
  2. 事务和管道

    事务:

    1
    2
    3
    4
    5
    6
    pipe = r.pipeline()
    pipe.set('foo', 'bar')
    pipe.get('foo')

    result = pipe.execute()
    print(result)

    管道:

    1
    pipe = r.pipeline(transaction=False)

    支持链式调用:

    1
    print(r.pipeline().set('foo','bar').get('foo').execute())

实践:在线的好友

有时网站本来就要记录全站用户的最后访问时间,这时就可以直接利 数据获得最后一次访问发生在10分钟内的用户列表(即在线用户)。

最直接的方法就是将上面存储在线用户列表的 online_users 变量存入 Redis 的一个集合类型的键中然后和用户的好友列表取交集。然而这种方法需要在服务端 和客户端传输数据,如果在线用户多的话会有较大的网络开销,而且这种方法也不能通过 Redis 的事务功能实现原子操作。为了解决这些问题,我们希望实现一个方法将 ZRANGEBYSCORE 命令的结果直接存入一个新键中而不返回到客户端。思路如下:

在线好友

脚本

概览

Redis 的脚本功能,允许开发者使用 Lua 语言编写脚本传到 Redis 中执行。在 Lua 脚本中可以调用大部分的 Redis 命令,类似于“存储过程”。

好处:

  1. 减少网络开销,只需发送一个请求即可。
  2. 原子操作。事务可以完成的所有功能都可以用脚本来实现。
  3. 复用。客户端发送的脚本会永久存储在 Redis 中,供以后复用。

Lua语言

Lua,在葡萄牙语中是“月亮”的意思。

Lua语法

  1. 数据类型

    Lua是一个动态类型语言,常用数据类型:

    Lua常用数据类型

  2. 变量

    Lua 的变量分为全局变量和局部变量。全局变量无需声明就可以直接使用,默认值是 nil。如:

    1
    2
    3
    a = 1       -- 为全局变量a赋值
    print(b) -- 无需声明即可使用,默认值是nil
    a = nil -- 删除全局变量a的方法是将其赋值为nil。全局变量没有声明和未声明之分,只有非nil和nil的区别

    在 Redis 脚本中不能使用全局变量,只允许使用局部变量以防止脚本之间相互影响。
    声明局部变量的方法为 local 变量名,就像这样:

    1
    2
    3
    local c     --声明一个局部变量c,默认值是nil
    local d = 1 --声明一个局部变量d并赋值为1
    local e,f --可以同时声明多个局部变量

    声明一个存储函数的局部变量的方法为:

    1
    2
    3
    local say_hi = function()
    print 'hi'
    end

    变量名必须是非数字开头,只能包含字母、数字和下划线,区分大小写。不能与保留字相同。

  3. 注释

    单行注释 :--
    多行注释:

    1
    2
    3
    -- [[
    多行
    ]]
  4. 赋值

    Lua 支持多重赋值。

    1
    2
    3
    local a,b = 1,2
    local c,d = 1,2,3 -- 3被舍弃了
    local e,f = 1 -- f的值是nil

    执行多重赋值时,Lua 会先计算所有表达式的值。比如:

    1
    2
    3
    local a = {1,2,3}
    local i = 1
    i, a[i] = i+1,5

    计算所有表达式的值:i, a[1] = 2,5。所以最后结果是i为2,a为 {5,2,3}。

  5. 操作符

    • 数学操作符,如果是字符串会自动转换成数字
    • 比较操作符
    • 逻辑操作符,not and or

      Lua逻辑操作符

    • 连接操作符:..,用来连接两个字符串。比如:

      1
      print('The price is ' .. 25)
    • 取长度操作符

      1
      print(#'hello')   --5
  6. if语句

    1
    2
    3
    4
    5
    6
    7
    if 条件 then
    语句
    elseif 条件 then
    语句
    else
    语句
    end

    Lua 中只有 nil 和 false 才是假,其他值(包括空字符串和0),都被认为是真。所以直接调用

    1
    2
    if redis.call('exists','key') then .
    ...

    则调用结果永远为真,需要这样写:

    1
    2
    if redis.call('exists','key')==1 then
    ...

    Lua 可以省略’;’,也并不强制要求缩进。

  7. 循环语句

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    while 条件 do
    语句
    end

    repeat
    语句
    until 条件

    for 变量 = 初值,终值 [,步长] do
    语句
    end

    步长省略默认为1。

  8. 表类型

    1
    2
    3
    4
    5
    6
    7
    8
    a = {}
    a['field'] = 'value'
    print(a.field)

    people = {
    name = 'Bolb',
    age = 29
    }

    索引为整数时,表和传统的数组一样:

    1
    2
    3
    a = {}
    a[1] = 'Bob'
    a[2] = 'Jeff'

    Lua规定数组的索引从1开始。

    遍历表:

    1
    2
    3
    4
    for i,v in ipairs(a) do
    print(i)
    print(v)
    end

    ipairs 是内置函数,实现类似迭代器的功能。

    1
    2
    3
    4
    for i =1, #a do
    print(i)
    print(a[i])
    end

    #a的作用是获取表a的长度。

    1
    2
    3
    4
    for i,v in pairs(people) do
    print(i)
    print(v)
    end

    pairs 与 ipairs 的区别在于前者会遍历所有值不为 nil 的索引,而后者只会从索引1开始递增遍历到最后一个值不为nil的整数索引。

  9. 函数

    1
    2
    3
    local square = function (num)
    return num * num
    end

    简化:

    1
    2
    3
    local function square (num)
    return num * num
    end

    这段代码会被转换为:

    1
    2
    3
    4
    local square
    square = function (num)
    return num * num
    end

    递归,实参/形参,解包:

    Lua函数

标准库

1
2
3
4
5
Base    --提供了一些基础函数
String --提供了用于字符串操作的函数
Table --提供了用于表操作的函数
Math --提供了数学计算函数
Debug --提供了用于调试的函数

文档:http://www.lua.org/manual/5.3/

  1. String库

    可以通过字符串类型的变量以 OOP 的形式访问,如 string.len(var) 可以写成 var:len()

    获取长度:

    1
    string.len(str)

    转换大小写:

    1
    2
    string.lower(str)
    string.upper(str)

    截取:

    1
    string.sub(str,start [, end])

    end 为负数则从后面开始计算。

  2. Table库

    Table 库中的大部分函数处理的都是数组形式的表。

    将数组转换为字符串:

    1
    2
    3
    table.concat(table [, sep [, i [, j ]]])

    table.concat({1, 2, 3}, ',' ,2)

    向数组中插入元素:

    1
    table.insert(table, [pos,] value)

    默认插入到尾部。

    1
    2
    3
    4
    a = {1, 2, 4}
    table.insert(a, 3, 3)
    table.insert(a, 5) --
    print(table.concat(a, ',')) --1,2,3,4,5

    从数组中弹出一个元素:

    1
    table.remove(table [,pos])

    默认从尾部弹出。

  3. Math库

    如果参数是字符串会自动尝试转换成数字:
    LuaMath

其他库

Redis 还通过 cjson 库和 cmsgpack 库提供了对 JSON 和 MessagePack 的支持。Redis 自动加载了这两个库,在脚本中可以分别通过 cjson 和 cmsgpack 两个全局变量来访问对应的库。

1
2
3
4
5
6
7
8
9
10
local people = {
name = 'Bob',
aqe = 29
}

local json_people_str = cjson.encode(people)
local json_people_obj = cjson.decode(people)

local msgpack_people_str = cmsgpack.pack(people)
local msqpack_people_obj = cmsgpack.unpack(people)

Redis与Lua

在脚本中调用Redis命令

1
2
redis.call('set', 'foo', 'bar')
local v = redis.call('get', 'foo')

redis.call 函数会将 Redis 的5种返回类型转换成对应的Lua数据类型:

整数回复 -》 数字类型
字符串回复 -》 字符串类型
多行字符串回复 -》 表类型(数组形式)
状态回复 -》 表类型(只有一个ok字段)
错误回复 -》 表类型(只有一个err字段)

空结果比较特殊,对应Lua的false。
Redis 还提供了 redio.pcall 函数,功能与 redis.call 相同,唯一的区别是当命令执行出错时 redis.pcall 会记录错误并继续执行,而 redis.call 会直接返回错误,不会继续执行。

从脚本中返回值

同样 Redis 会自动将脚本返回值的 Lua 数据类型转换成 Redis 的返回值类型。转换关系与上节的规则相反。
Lua 的 false 比较特殊,会被转换成空结果。

脚本相关命令

  1. EVAL命令

    1
    2
    3
    4
    eval 脚本内容 key参数的数量 [key ...] [arg ...]

    eval "return redis.call('set',KEYS[1], ARGV[1])" 1 foo bar
    get foo # bar

    EVAL 命令依据第二个参数将后面的所有参数分别存入脚本中 KEYS 和 ARGV 两个表类型的全局变量.当脚本不需要任何参数时也不能省略这个参数(设为0).

  2. EVALSHA命令

    Redis 在执行 EVAL 命令时会计算脚本的 SHA1 摘要并记录在脚本缓存中,执行 EVALSHA 命令时 Redis 会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了则执行脚本,否则会返回错误,以此来节省带宽。
    很多编程语言的 Redis 客户端执行 EVAL 命令时,都会代替开发者执行 EVALSHA 命令,失败了才会执行 EVAL 命令。

    应用实例

    获得并删除有序集合中分数最小的元素,有序集合没有弹出操作,可以通过脚本实现。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    import redis

    r = redis.StrictRedis(host='', password='')

    lua = """
    local element = redis.call('ZRANGE',KEYS[1],0,0)[1]
    if element:
    redis.call('ZREM',KEYS[1],element)
    end
    return element
    """

    ztop = r.register_script(lua)

    print(ztop(keys=['zset']))

深入脚本

KEYS与ARGV

向脚本传递的参数分为 KEYS 和 ARGV 两类,为了兼容集群,在脚本中使用的键名最好使用 KEYS 参数传递的键名。

脚本兼容集群

沙盒与随机数

Redis 中使用脚本的限制:

  1. 禁止使用Lua标准库中与文件或系统调用相关的函数
  2. 禁用脚本的全局变量保证每个脚本都是相对隔离的

为了保证服务器的安全和不依赖外界条件,独立性。

Redis 还对脚本中的随机数和会产生随机结果的命令进行了特殊的处理:
Lua随机数

其他脚本相关命令

一般由客户端封装起来,开发者很少使用到。

  1. 将脚本加入缓存:script load
    加入缓存而不执行,script load "return 1"。返回脚本的SHA1摘要。

  2. 判断脚本是否已经被缓存:script exists
    查找1个或多个脚本的SHA1摘要是否被缓存。script exists e0e1... abd4...

  3. 清空脚本缓存:script flush
    Redis将脚本的SHA1摘要加入到缓存后会永久保留,使用script flush命令清空脚本缓存。

  4. 强制终止当前脚本的执行:script kill

原子性和执行时间

Redis 的脚本执行是原子的,执行期间不会执行其他命令。Redis 提供了 lua-time-limit 参数限制脚本的最长运行时间,默认为5秒钟。脚本运行时间超过这一默认为5秒钟。当脚本运行时间超过这一限制后,Redis 将开始接受其他命令但不会执行(以确保脚本的原子性),而是会返回“Busy”错误。

此时,如果脚本没有修改数据的话,可以通过 script kill 终止当前脚本的运行。如果有修改数据的话,可以使用 shutdown nosave 命令强行终止 redis。
shutdown nosave 和 shutdown 的区别在于前者将不会进行持久化操作,发生在上一次快照后的数据库修改都将丢失。

持久化

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

RDB方式

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

  • 根据配置规则进行自动快照
  • 用户执行 save 或 bgsave 命令
  • 执行 flushall 命令
  • 执行复制(replication)时

根据配置规则进行快照

配置规则由两个参数构成:时间窗口M和改动的键的个数N。每当时间M内被改动的键的个数大于N时,即符合自动快照条件。

1
2
3
save 900 1
save 300 10
save 60 10000

第一行的意思是15分钟(900秒)内有一个或一个以上的键被更改则进行快照。多个条件之间是或的关系。

用户执行save或bgsave命令

  1. save
    同步阻塞其他所有请求执行快照操作,导致 redis 较长时间不响应,避免在生产环境使用。

  2. bgsave
    在后台异步地进行快照操作,继续相应其他请求。执行后立即返回 ok,然后通过 lastsave 命令获取最近一次成功执行快照的时间(unix时间戳)。

fluahsall

redis 会清除数据库中所有数据。不论是否满足自动快照条件,只有自动快照条件不为空,redis 就会执行一次快照操作。当没有定义自动快照条件时,flushall 不会进行快照。

执行复制时

主从模式中,redis 会在复制初始化时进行自动快照。

快照原理

redis 默认将快照文件存储在 redis 当前进程工作目录中的 dump.rdb 文件中,可以通过配置 dir 和 dbfilename 指定快照文件的路径和名称。

快照的过程如下:

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

写时复制(copy-on-write):

copy-on-write

备份与加载rdb:

备份与加载rdb

AOF方式

以纯文本的形式记录了 Redis 执行的写命令,可见 AOF 文件的内容正是 Redis 客户端向 Redis 发送的原始通信协议的内容。
每当达到一定条件时 Redis 就会自动重写(压缩重复命令)AOF 文件,这个条件可以在配置文件中设置:

1
2
auto-aof-rewrite-percentaqe 100
auto-aof-rewrite-min-size 64mb

auto-aof-rewrite-percentaqe 参数的意义是当目前的 AOF 文件大小超过上一次重写时的 AOF 文件大小的百分之多少时会再次进行重写,如果之前没有重写过,则以启动时的AOF文件大小为依据。
auto-aof-rewrite-min-size参数限制了允许重写的最小 AOF 文件大小。

还可以主动使用 BGREWRITEAOF 命令手动执行 AOF 重写。在启动 Redis 时,会逐个执行AOF文件中的命令来将以硬盘中的数据载入到内存中,速度相较RDB会慢一些。

同步硬盘数据

操作系统的缓存同步:事实上,由于操作系统的缓存机制,数据并没有真正地写入硬盘,而是进入了系统的硬盘缓存。在默认情况下系统每30秒会执行一次同步操作,以便将硬盘缓存中的内容真正地写入硬盘。一般来讲启用AOF持久化的应用都无法容忍这样的损失,这就需要 Redis 在写入 AOF 文件后主动要求系统将缓存内容同步到硬盘中。

1
2
3
# appendfsync always
appendfsync everysec
# appendfsync no

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

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

集群

复制

replication

配置

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

启动从实例时指定主实例节点:

1
redis-server --port 6380 --repllicaof 127.0.0.1 6379

或运行时切换主实例:

1
slaveof 127.0.0.1 6379

查看节点的信息:

1
info replication

从数据库默认只读,可以修改 slave-read-only 为 no 使之可写,但是不建议。
停止当前节点的同步,并可转换为主节点:

1
replicaof no one

原理

Redis 实现复制的过程:

复制1
复制2
复制3
复制4
复制5

图结构

从数据库自己也可以作为主数据库存在。

读写分离与一致性

在常见的场景中,读的频率大于写。可以建立一主多从的结构来满足需求,通过复制功能建立多个从数据库节点,主数据库只进行写操作,而从数据库负责读操作。

从数据库持久化

为了提高性能,可以通过复制功能建立一个(或若干个)从数据库,并在从数据库中启用持久化,同时在主数据库禁用持久化。

当从数据库崩溃重启后主数据库会自动将数据同步过来,所以无需担心数据丢失。
当主数据库崩溃时,需要严格按照以下两步进行:

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

注意:当开启复制且主数据库关闭持久化功能时,一定不要使用 Supervisor 以及类似的进程管理工具令主数据库崩清后自动重启.或直接手动重新启动。因为当主数据库重新启动后,因为没有开启持久化功能,所以数据库中所有数据都被清空,这时从数据库依然会从主数据库中接收数据,使得所有从数据库也被清空!

更自动化的方案:哨兵

无硬盘复制

基于RDB方式的复制具有以下缺点:

  1. 当主数据库禁用RDB快照时(即删除了所有的配置文件中的 save 语句),如果执行了复制初始化操作,Redis 依然会生成RDB快照,所以下次启动后主数据库会以该快照恢复数据。因为复制发生的时间不能确定,这使得恢复的数据可能是任何时间点的。

  2. 因为复制初始化时需要在硬盘中创建RDB快照文件,所以如果硬盘性能很慢(如网络硬盘)时这一过程会对性能产生影响。

开启无硬盘复制选项:

1
repl-disless-sync yes

在与从数据库进行复制初始化时将不会将快照内容存储到硬盘上,而是直接通过网络发送给从数据库,避免了硬盘的性能瓶颈。

增量复制

当主从数据库链接断开后,从数据库会发送 SYNC 命令来重新进行一次完成复制操作,即使断开期间数据库的变化很小(甚至没有)。
Redis 2.8版之后实现了主从断线重连的情况下的增量复制。

增量复制基于以下3点实现:

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

2.8版之后,从数据库不再发送 SYNC 命令,取而代之的是发送 PSYNC,格式为:
PSYNC 主数据库的运行ID 断开前最新的命令偏移量

主数据库收到 PSYNC 命令后,会执行以下判断决定此次重连是否可以执行增量复制:

  1. 首先主数据库会判断从数据库传送来的运行ID是否和自己的运行ID相同。以此确保从数据库之前确实是和自己同步的,以免从数据库拿到错误的数据。
  2. 然后判断从数据库最后同步成功的命令偏移量是否在积压队列中,执行增量复制,并将积压队列中相应的命令发送给从数据库。

如果不满足增量复制的条件,主数据库会进行一次全部同步。

积压队列,本质上是一个固定长度的循环队列。通过 repl-backlog-size 选项调整积压队列的大小,默认为1MB。积压队列越大,其允许的主从数据库断线的时间就越长。所以估算积压队列的大小只需要估算主从数据库断线的时间中主数据库可能执行的命令的大小即可。

repl-backlog-ttl,即当所有从数据库与主数据库断开连接后,经过多久时间可以释放积压队列的内存空间。默认1小时。

哨兵

Redis2.8中提供了哨兵工具实现自动化系统监控和故障恢复功能。

什么是哨兵

哨兵是一个独立的进程,作用是监控 Redis 系统的运行状况,包括两个功能:

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

哨兵架构

马上上手

建立一个配置文件,如 sentinel.conf,内容为:

1
2
sentinel monitor mymaster 127.0.0.1 6379 1
sentinel auth-pass mymaster password

mymaster 表示要监控的主数据库的名字,最后的1表示最低通过票数。接下来启动 Sentinel 进程。

1
redis-sentinel /etc/redis/sentinel.conf

配置哨兵监控一个系统时,只需要配置监控主数据库即可,哨兵会自动发现所有该主数据库的从数据库。

哨兵输出内容详解:

1
2
3
4
5
6
7
8
+slave  #表示新发现了从数据库
+sdown #表示哨兵主观认为主数据库停止服务了
+odown #表示哨兵客观认为主数据库停止服务了
+try-failover #表示哨兵开始进行故障恢复
+failover-end #表示哨兵完成故障恢复,期间包括Leader选举,备选从数据库的选择
+switch-master #表示主数据库切换
-sdown #表示实例恢复服务
+convert-to-slave #表示将实例设置为从数据库

停止服务的实例有可能会在之后恢复服务,当实例停止服务后,哨兵会更新该实例的信息(如主数据库停止之后,则保留设置它为从数据库),使得当其重新加入后可以按照当前信息继续对外提供服务。

实现原理

因为考虑到故障恢复后当前监控的系统的主数据库的地址和端口会产生变化,所以哨兵提供了命令可以通过主数据库的名字获取当前系统的主数据库的地址和端口号。

一个哨兵节点可以同时监控多个 Redis 主从系统,只需要提供多个 sentinel monitor 配置即可,例如:

1
2
sentinel monitor mymaster 127.0.0.1 63792
sentinel monitor othermaster 192.168.1.36 3804

同时多个哨兵节点也可以同时监控同一个 Redis 主从系统,配置文件中还可以定义其他监控相关的参数,每个配置选项都包含主数据库的名字使得监控不同主数据库时可以使用不同的配置参数。例如:

1
2
sentinel down-after-milliseconds mymaster 60000
sentinel down-after-milliseconds othermaster 10000

哨兵启动后,会与要监控的主数据库建立两条连接,这两个连接的建立方式与普通的Redis客户端无异。
其中一条连接用来订阅该主数据的 sentinel:hello 频道以获取其他同样监控该数据库的哨兵节点的信息,因为客户端的连接进入订阅模式时就不能再执行其他命令了,所以这时哨兵会使用另外一条连接来发送命令:

  1. 每10秒哨兵会向主数据库和从数据库发送 INFO 命令。
  2. 每2秒哨兵会向主数据库和从数据库的 sentinel:hello 频道发送自己的信息。
  3. 每1秒哨兵会向主数据库、从数据库和其他哨兵节点发送PING命令。

这3个操作贯穿哨兵进程的整个生命周期中,非常重要。

首先,发送 INFO 命令使得哨兵可以获得当前数据库的相关信息(包括运行ID、复制信息即从节点信息等)从而实现新节点的自动发现。而后对每个从数据库同样建立两个连接,在此之后,哨兵会每10秒定时向已知的所有主从数据库发送 INFO 命令来获取信息更新并进行相应操作,比如对新增的从数据库建立连接并加入监控列表,对主从数据库的角色变化(由故障恢复操作引起)进行信息更新等。

接下来哨兵向主从数据库的 sentinel:hello 频道发送信息来与同样监控该数据库的哨兵分享自己的信息。发送的消息内容为:

<地址>,<端口>,<运行ID>,<配置版本>,<主数据库的名字>,<主数据库的地址>,<主数据库的端口>,<主数据库的配置版本>

可以看到消息包括的哨兵的基本信息,以及其监控的主数据库的信息。
前文介绍过,哨兵会订阅每个其监控的数据库的 sentinel:hello 频道,所以当其他哨兵收到消息后,会判断发消息的哨兵是不是新发现的哨兵。如果是则将其加入已发现的哨兵列表中并创建一个到其的连接(与数据库不同,哨兵与哨兵之间只会创建一条连接用来发送PING命令,而不需要创建另外一条连接来订阅频道,因为哨兵只需要订阅数据库的频道即可实现自动发现其他哨兵)。同时哨兵会判断信息中主数据库的配置版本,如果该版本比当前记录的主数据库的版本高,则更新主数据库的数据。

实现了自动发现从数据库和其他哨兵节点后,哨兵要做的就是定时监控这些数据库和节点有没有停止服务。这是通过每隔一定时间向这些节点发送PING命令实现的。时间间隔与 down-after-milliseconds 选项有关,down-after-milliseconds 小于1秒时,哨兵会每隔指定的时间发送一次PING命令,大于1秒时,哨兵会每隔1秒发送一次PING命令。

当超过 down-after-milliseconds 选项指定时间后,如果被PING的数据库或节点仍然未进行回复,则哨兵认为其主观下线(subjectively down)。如果该节点是主数据库,则哨兵会进一步判断是否需要对其进行故障恢复:哨兵发送 sentinel is-master-down-by-addr 命令询问其他哨兵节点以了解他们是否也认为该主数据库主观下线,如果达到指定数量时,哨兵会认为其客观下线(objectively down),并选举领头的哨兵节点对主从系统发起故障恢复。这个指定数量即为quorum参数。例如:

1
sentinel monitor mymaster 127.0.0.1 6379 2

该配置表示只有当至少两个 Sentinel 节点(包括当前节点)认为该主数据库主观下线时,当前哨兵节点才会认为该主数据库客观下线。进行接下来的选举领头哨兵步骤。故障恢复需要由领头的哨兵来完成,这样可以保证同一时间只有一个哨兵节点来执行故障恢复。选举使用Raft算法,具体过程(https://raft.github.io)如下:

  1. 发现主数据库客观下线的哨兵节点(下面称作A)向每个哨兵节点发送命令,要求对方选自己成为领头哨兵。
  2. 如果目标哨兵节点没有选过其他人,则会同意将A设置成领头哨兵。
  3. 如果A发现有超过半数且超过 quorum 参数值的哨兵节点同意选自己成为领头哨兵,则A成功成为领头哨兵。
  4. 当有多个哨兵节点同时参选领头哨兵,则会出现没有任何节点当选的可能。此时每个参选节点将等待一个随机时间重新发起参选请求,进行下一轮选举,直到选举成功。

选出领头哨兵后,领头哨兵将会开始对主数据库进行故障恢复。过程具体如下:

首先领头哨兵将从停止服务的主数据库的从数据库中挑选一个来充当新的主数据库。挑选的依据如下:

  1. 所有在线的从数据库中,选择优先级最高的从数据库。优先级可以通过 slave-priority 选项来设置。
  2. 如果有多个最高优先级的从数据库,则复制的命令偏移量越大(即复制越完整)越优先。
  3. 如果以上条件都一样,则选择运行ID较小的从数据库。

选出一个从数据库后,领头哨兵将向从数据库发送 slaveof no one 命令使其升格为主数据库。而后领头哨兵向其他从数据库发送 slaveof 命令来使其成为新主数据库的从数据库。最后一步则是更新内部的记录,将已经停止服务的旧的主数据库更新为新的主数据库的从数据库,使得当其恢复服务时自动以从数据库的身份继续服务。

集群

即使使用哨兵,此时的Redis集群的每个数据库依然存有集群中的所有数据,从而导致集群的总数据存储量受限于可用存储内存最小的数据库节点,形成木桶效应。

Redis 3.0版的一大特性就是支持集群,集群的特点在于拥有和单机实例同样的性能,同时在网络分区后能够提供一定的可访问性以及对主数据库故障恢复的支持。另外集群支持几乎所有的单机实例支持的命令。
集群也存在一些限制,对于涉及多键的命令(如MGET),如果每个键都位于同一个节点中,则可以正常支持,否则会提示错误。
除此之外还有一个限制是只能使用默认的0号数据库,如果执行 select 切换数据库则会提示错误。

哨兵与集群是两个独立的功能,但从特性来看哨兵可以视为集群的子集,当不需要数据分片或者已经在客户端进行分片的场景下哨兵就足够使用了,但如果需要进行水平扩容,则集群是一个非常好的选择。

配置集群

只需要将每个数据库节点的 cluster-enabled 配置选项打开即可。每个集群中至少需要3个主数据库才能正常运行。

集群会将当前节点记录的集群状态持久化地存储在指定文件中,这个文件默认为当前工作目录下的 nodes.conf 文件。每个节点对应的文件必须不同,否则会造成启动失败,所以启动节点时要注意最后为每个节点使用不同的工作目录,或者通过 cluster-config-file 选项修改持久化文件的名称。

判断集群是否正常启用:

1
info cluster

每个节点启动后都是完全独立的,需要将它们加入同一个集群里。
redis-trib.rb 可以非常方便地完成这一任务。运行前需要在服务器上安装Ruby程序,redis-trib.rb 依赖于 gem 包 redis,可以执行 gem install redis 来安装。

使用 redis-trib.rb 来初始化集群,只需要执行:

1
/path/to/redis-trib.rb create --replicas 1 127.0.0.1:6380 127.0.0.1:6381 127.0.0.1:6382 127.0.0.1:6383 127.0.0.1 6384 127.0.0.1 6385

–replicas 1 表示每个主数据库拥有的从数据库个数为1,所以整个集群共有3(6/2)个主数据库以及3个从数据库。

运行后的输出内容包括集群具体的分配方案:

创建集群

首先 redis-trib.rb 会以客户端的形式尝试连接所有的节点,并发送PING命令以确定节点能够正常服务。如果有任何节点无法连接,则创建失败。同时发送INFO命令获取每个节点的运行ID以及是否开启了集群功能(即clusterenabled为1)。

准备就绪后集群会向每个节点发送 CLUSTER MEET 命令,格式为 CLUSTER MEET ip port,这个命令用来告诉当前节点指定ip和port上在运行的节点也是集群的一部分,从而使得6个节点最终可以归入一个集群。

然后 redis-trib.rb 会分配主从数据库节点,分配的原则是尽量保证每个主数据库运行在不同的IP地址上,同时每个从数据库和主数据库均不运行在同一IP地址上,以保证系统的容灾能力。分配结果如下:

1
2
3
4
5
6
7
Using 3 masters:
127.0.0.1:6380
127.0.0.1:6381
127.0.0.1:6382
Adding replica 127.0.0.1:6383 to 127.0.0.1:6380
Adding replica 127.0.0.1:6384 to 127.0.0.1:6381
Adding replica 127.0.0.1:6385 to 127.0.0.1:6382

分配完成后,会为每个主数据库分配插槽,分配插槽的过程其实就是分配哪些键归哪些节点负责,之后对每个要成为子数据库的节点发送 CLUSTER REPLICATE 主数据库的运行ID 来将当前节点转换成从数据库并复制指定运行ID的节点(主数据库)。

此时整个集群的过程即创建完成,使用 Redis 命令行客户端连接任意一个节点执行 CLUSTER NODES 可以获得集群中的所有节点信息。

节点的增加

加入新的节点,也使用 CLUSTER MEET 命令实现。

只需要向新节点(以下记作A)发送如下命令即可:

1
CLUSTER MEET ip port

ip和port是集群中任意一个节点的地址和端口号,A接收到命令后,会与该地址和端口号的节点B进行握手,使B将A认作当前集群中的一员。握手成功后,B会使用Gossip(分布式系统中常用的一种通信协议)协议将节点A的信息通知给集群中的每一个节点。所以即使集群中有多个节点,也只需要选择MEET其中任意一个节点,即可使新节点最终加入整个集群中。

插槽的分配

新的节点加入集群后有两种选择:要么使用 CLUSTER REPLICATE 命令复制每个主数据库来以从数据库的形式运行,要么向集群申请分配插槽(slot)来以主数据库的形式运行。

在一个集群中,所有的键会被分配给16384个插槽,而每个主数据库会负责处理其中的一部分插槽。在创建集群时的输出中:

1
** 127.0.0.1:6380 slots:0-5460 (5461 slots) master

代表6380负责0~5460这部分的插槽。
Redis 分配插槽时并不一定必须连续分配,可以任意分配几个插槽给任意节点。

插槽与键的关系,Redis 将每个键的键名的有效部分使用CRC16算法计算出散列值,然后取对16384的余数。这样每个键都可以分配到16384个插槽中,对应指定的一个节点中。键名的有效部分是指:

  1. 如果键名包含 { 符号,且在 { 符号后面存在}符号,并且 { 和 } 之间有至少一个字符,则有效部分是指 { 和 } 之间的内容。
  2. 如果不满足上一条规则,那么整个键名为有效部分。

前面说如果命令涉及多个键(如MGET),只有当所有键都位于同一个节点时Redis才能正常支持。利用键的分配规则,可以将所有相关的键的有效部分设置成同样的值使得相关键都能分配到同一个节点以支持多键操作。比如,{user102}:first.name{user102}:last.name 会被分配到同一个节点,所以可以使用 MGET {user102}:first.name {user102}:last.name 来同时获取两个键的值。

如何将插槽分配给指定节点呢?

插槽的分配分为如下几种情况:

  1. 插槽之前没有被分配过,现在想分配给指定节点。
  2. 插槽之前被分配过,现在想移动到指定节点。

第一种情况在指定节点使用 CLUSTER ADDSLOTS 命令来实现,redis-trib.rb 也是通过该命令在创建集群时为新节点分配插槽的。

1
CLUSTER ADDSLOTS slot1 [slot2] …[slotN]

如果指定插槽已经分配过了,则会提示:(error) ERR Slot 100 is already busy。
可以通过命令 CLUSTER SLOTS 来查看插槽的分配情况,返回4行数据,1,2行为插槽的开始结束号,3,4行为主从数据库信息。

第二种情况:

1
/path/to/redis-trib.rb reshard 127.0.0.1:6380

执行重新分片命令,6380是集群中任意一个节点的地址和端口。

How many slots do you want to move(from 1 to 16384)?
想要迁移多少个插槽,输入数字后回车。

What is the receiving node ID?
可以通过 CLUSTER NODES 命令获取6381的运行ID,输入并回车。接着最后一步是询问从哪个节点移出插槽,我们输入6380对应的运行ID按回车,然后再输入done再按回车确认即可。

不借助redis-trib.rb手工进行重新分片:

1
CLUSTER SETSLOT 插槽号 NODE 新节点的运行ID

SETSLOT 迁移插槽时,并不会把插槽中的键一起迁移。为此需要手动获取插槽中存在哪些键,然后将所有键迁移到新节点后,再迁移插槽,否则会造成对客户端来说键“丢失了”。

1
2
3
CLUSTER GETKEYSINSLOT 插槽号 要返回的键的数量

MIGRATE 目标节点地址 目标节点端口 键名 数据库号码 超时时间 [COPY] [REPLACE]

COPY 表示不将键从当前数据库中删除而是复制,REPLACE 表示如果存在同名键,则覆盖。集群模式中数据库号码只能为0。

为了避免迁移过程中相关键的临时“丢失”现象,Redis 提供了如下两个命令来实现集群不下线的情况下迁移数据:

1
2
3
CLUSTER SETSLOT 插槽号 MIGRATING 新节点的运行 ID

CLUSTER SETSLOT 插槽号 IMPORTING 原节点的运行ID

进行迁移时,假设要把0号插槽从A迁移到B,此时 redis-trib.rb 会依次执行如下操作:

  1. 在B执行CLUSTER SETSLOT 0 IMPORTING A。
  2. 在A执行CLUSTER SETSLOT 0 MIGRATING B。
  3. 执行CLUSTER GETKEYSINSLOT 0获取0号插槽的键列表。
  4. 对第3步获取的每个键执行MIGRATE命令,将其从A迁移到B。
  5. 执行CLUSTER SETSLOT 0 NODE B来完成迁移。

执行完前两步后,当客户端向A请求插槽0中的键时,如果键存在(即尚未被迁移),则正常处理,如果不存在,则返回一个ASK跳转请求,告诉客户端这个键在B里,客户端接收到后,首先向B发送 ASKING 命令,然后再重新发送之前的命令。相反,当客户端向B请求插槽0中的键时,如果前面执行了ASKING命令,则返回键值内容,否则返回MOVED跳转请求。这样一来可以在数据库迁移时自动从正确的节点获取到相应的键值,避免了键在迁移过程中临时“丢失”的问题。

获取与插槽对应的节点

当客户端向集群中的任意一个节点发送命令后,该节点会判断相应的键是否在当前节点中,如果键在该节点中,则会像单机实例一样正常处理该命令:如果键不在该节点中,就会返回一个 MOVE 重定向请求,告诉客户端这个键目前由哪个节点负责,然后客户端再将同样的请求向目标节点重新发送一次以获得结果。

1
redis-cli -c -p 6380

-c支持自动重定向。

集群的命令重定向增加了请求次数,所以客户端应该缓存插槽的路由信息,每次命令将均只发向正确的几点,来尽量减少重定向的次数。

故障恢复

集群中每个节点每隔1秒钟就会随机选择5个节点,然后选择其中最久没有响应的节点发送PING命令。如果目标节点没有响应回复,则发起命令的节点会认为目标节点疑似下线(PFAIL),与哨兵的主观下线类似,过程具体为:

  1. 一旦节点A认为节点B是疑似下线状态,就会在集群中传播该消息,所有其他节点收到消息后都会记录下这一信息。
  2. 当集群中的某一节点C收集到半数以上的节点认为B是疑似下线的状态时,就会将B标记为下线(FAIL),并且向集群中的其他节点传播该消息,从而使得B在整个集群中下线。

在集群中,当一个主数据库下线时,就会出现一部分插槽无法写入的问题。这时如果该主数据库拥有至少一个从数据库,集群就进行故障恢复操作来将其中一个从数据库转变成主数据库(Raft算法)来保证集群的完整。如果一个至少负责一个插槽的主数据库下线且没有相应的从数据库可以进行故障恢复,则整个集群默认会进入下线状态无法继续工作。可以修改配置cluster-require-full-coverage为no(默认为yes),使集群仍能正常工作。

管理

1
bind 127.0.0.1

只允许本机应用连接Redis。

1
requirepass ****

设置密码。

配置 Redis 复制时如果主数据设置了密码,从数据库的配置文件中通过 masterauth 参数设置主数据库的密码。

重命名命令,保护关键命令:

1
rename-command flushall oyfekdjsm412jdssajt9dkslertl

以保证只有自己的应用可以使用该命令。

1
rename-command FLUSHALL ""

设置为空直接禁用某个命令。

通信协议

Redis 支持两种通信协议,一种是二进制安全的统一请求协议,另一种是比较直观的便于在 telnet 程序中输入的简单协议。两种协议只是命令的格式有区别,命令返回值的格式是一样的。

简单协议

简单协议适合在 telnet 程序中和Redis通信。

1
telnet 127.0.0.1 6379

redis-cli 中的返回格式都是经过封装的,真正的返回格式如下:

  1. 错误回复
    错误回复(error reply)以-开头,并在后面跟上错误信息,最后以\r\n结尾:
    -ERR unknown command 'ERRORCOMMAND'\r\n
  2. 状态回复
    状态回复(status reply)以+开头,并在后面跟上状态信息,最后以\r\n结尾:
    +OK\r\n
  3. 整数回复
    整数回复(integer reply)以:开头,并在后面跟上数字,最后以\r\n结尾:
    :3\r\n
  4. 字符串回复
    字符串回复(bulk reply)以$开头,并在后面跟上字符串的长度,并以\r\n分隔,接着是字符串的内容和\r\n:
    $3\r\nbar\r\n
    如果返回值是空结果nil,则会返回$-1以和空字符串相区别。
  5. 多行字符串回复
    多行字符串回复(multi-bulk-reply)以*开头,并在后面跟上字符串回复的组数,并以\r\n分隔。接着后面跟的就是字符串回复的具体内容了:*3\r\n$1\r\n3\r\n$1\r\n2\r\n$1\r\n1\r\n

统一请求协议

命令格式和多行字符串回复的格式很类似。

SET foo bar 的统一请求协议写法是:*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
Redis的AOF文件和主从复制时主数据库向从数据库发送的内容都使用了统一请求协议。如果要开发一个和Redis直接通信的客户端,推荐使用此协议。

管理工具

redis-cli

实用命令:

  1. 耗时命令日志

    当命令的执行时间超过 slowlog-log-slower-than 配置的时间(单位是微秒,1000000微秒=1秒。默认是10000。设置为0纪录所有命令,设置为负数时关闭日志)时,Redis会将该命令的执行时间等信息加入耗时命令日志。耗时命令日志存储在内存中 slowlog-max-len 参数限制纪录的条数。

    SLOWLOG GET 获取当前的耗时命令日志,每条日志都由以下4个部分组成:

    1. 该日志唯一ID
    2. 该命令执行的Unix时间
    3. 该命令的耗时时间,单位是微秒
    4. 命令及其参数
  2. 命令监控

    MONITOR 命令监控 Redis 执行的所有命令,非常影响 Redis 的性能,一个客户端使用 MONITOR 命令会降低 Redis 将近一半的负载能力。

    redis-faina,Instagram 团队开发的基于 MONITOR 命令的 Redis 查询分析程序。输入值为一段时间的 MONITOR 命令执行结果。

    1
    redis-cli MONITOR | head -n <要分析的命令数>  | ./redis-faina.py

附录

Redis命令属性

Redis 的不同命令拥有不同的属性,如是否是只读命令,是否是管理员命令等,一个命令可以拥有多个属性。在一些特殊情况下不同属性的命令会有不同的表现。

  • REDIS_CMD_WRITE

    拥有 REDIS_CMD_WRITE 属性的命令的表现是会修改 Redis 数据库的数据。一个只读的从数据库会拒绝执行拥有REDIS_CMD_WRITE属性的命令。另外在Lua脚本中执行了拥有 REDIS_CMD_RANDOM 的命令后,不可以再执行拥有 REDIS_CMD_WRITE 属性的命令,否则会提示错误。

  • REDIS_CMD_DENYOOM

    拥有 REDIS_CMD_DENYOOM 属性的命令有可能增加 Redis 占用的存储空间,显然拥有该属性的命令都拥有 REDIS_CMD_WRITE 属性,但反之则不然。如 DEL 命令。
    当数据库占用的空间达到了配置文件中 maxmemory 参数指定的值且根据 maxmemory-policy 参数的空间释放规则无法释放空间时,Redis 会拒绝执行拥有REDIS_CMD_DENYOOM 属性的命令。

    拥有 REDIS_CMD_DENYOOM 属性的命令每次调用时不一定都会使数据库的占用空间增大,只是有可能而已。例如,SET 命令当新值长度小于旧值时反而会减少数据库的占用空间.但无论如何,当数据库占用空间超过限制时,Redis 都会拒绝执行,而不会分析其实际上是不是会真的增加空间占用。

  • REDIS_CMD_NOSCRIPT

    拥有 REDIS_CMD_NOSCRIPT 属性的命令无法在 Redis 脚本中执行。

    提示 EVAL 和 EVALSHA 命令也拥有该属性,所以在脚本中无法调用这两个命令,即不能在脚本中调用脚本。

  • REDIS_CMD_RANDOM

    当一个脚本执行了拥有 REDIS_CMD_RANDOM 属性的命令后,就不能执行拥有 REDIS_CMD_WRITE 属性的命令了。

  • REDIS_CMD_SORT_FOR_SCRIPT

    拥有 REDIS_CMD_SORT_FOR_SCRIPT 属性的命令会产生随机结果,在脚本中调用这些命令时 Redis 会对结果进行排序。

  • REDIS_CMD_LOADING

    当 Redis 正在启动时(将数据从硬盘载入到内存中),Redis 只会执行拥有 REDIS_CMD_LOADING 属性的命令。

欢迎打赏