Redis

介绍

Redis 是使用 c 语言开发的一个高性能键值数据库。
Redis 可以通过一些键值类型来存储数据。
键值类型:

  • String 字符类型
  • map 散列类型
  • list 列表类型
  • set 集合类型
  • sortedset 有序集合类型

Redis 使用起来就像是 JS 中的 Map 数据结构(但 key 只能是字符串,value 要符合支持的数据结构)。在业务中,我们可以用 Redis 来存储用户的鉴权 token,用户带着 token 访问时,可以快速从 redis 取出并对比。

适用场景

  • 缓存(数据查询、短连接、新闻内容、商品内容等等)。(最多使用)
  • 分布式集群架构中的 session 分离。
  • 聊天室的在线好友列表。
  • 任务队列。(秒杀、抢购、12306 等等)
  • 应用排行榜。
  • 网站访问统计。
  • 数据过期处理(可以精确到毫秒)

安装(macOS)

1
brew install redis

启动

1
2
3
4
5
6
7
8
9
10
// 前台启动redis服务器
redis-server
// server后台启动
redis-server --daemonize yes
// launch后台启动
brew services start redis
// 关闭后台启动
brew services stop redis
// 查看信息
brew services info redis

连接 Redis

启动 Redis 服务后,连接 redis 数据库:

1
2
3
4
// Redis命令行客户端
redis-cli
// 连接远程服务
redis-cli -h host -p port -a password

查看 Redis 状态

1
ps -ef | grep -i redis

停止 Redis

防止中止进程导致数据丢失,应该使用shutdown命令停止 Redis.

1
2
// 此方法关闭 redis-server启动的进程
redis-cli shutdown

另一种方法是杀进程,进程号通过查看 Redis 状态得知.

1
kill -9 进程号

通用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 返回所有KEY
KEYS *
# 获取key的类型
TYPE my*
# 查询某个key是否存在
EXISTS key [key...]
# 将key改名为newkey
RENAME key newkey
# 从当前数据库随机返回(非删除)一个key
RANDOMKEY
# 清空当前数据库内容
FLUSHDB
# 清空所有数据库内容
FLUSHALL
# 将当前数据库的key移动的指定db
MOVE key db

设置过期时间

1
2
3
4
5
6
7
8
# 为指定key设置过期时间,当key过期就删除
EXPIRE key seconds
# 以毫秒为单位
PEXPIRE key milliseconds
# 通过时间戳设置
EXPIREAT key timessamp
# 以毫秒为单位
PEXPIREAT key milliseconds-timessamp

获取过期时间

1
2
3
4
# 返回指定key的剩余时间(秒)
TTL key
# 毫秒单位
PTTL key

多数据库

默认分配 16 个数据库,数字 0-15.默认使用 0 数据库.数据库相对独立,可以配置参数databases修改个数.

切换数据库[SELECT]

1
SELECT 1

知识体系

多个数据库并不是完全独立.如果要不同应用存储建议开多个 Redis 实例.
image.png

数据结构

Redis 数据库支持五种数据类型。

  • 字符串(string)
  • 哈希(hash)
  • 列表(list)
  • 集合(set)
  • 有序集合(sorted set)
  • 位图 ( Bitmaps )
  • 基数统计 ( HyperLogLogs )

5 种基础数据类型

String 字符串

String 是一组字节。在 Redis 数据库中,字符串是二进制安全的。
这意味着它们具有已知长度,并且不受任何特殊终止字符的影响。
redis 的 string 可以包含任何数据。如数字,字符串,jpg 图片或者序列化的对象。
可以在一个字符串中存储最多 512 M 的内容。
使用 SET 命令在 name 键中存储字符串 redis.com.cn,然后使用 GET 命令查询 name。

1
2
3
4
SET name "redis.com.cn"
OK
GET name
"redis.com.cn"

命令使用

命令 简述 使用
GET 获取存储在给定键中的值 GET name
SET 设置存储在给定键中的值 SET name value
DEL 删除存储在给定键中的值 DEL name
INCR 将键存储的值加 1 INCR key
DECR 将键存储的值减 1 DECR key
INCRBY 将键存储的值加上整数 INCRBY key amount
DECRBY 将键存储的值减去整数 DECRBY key amount

命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
127.0.0.1:6379> set hello world
OK
127.0.0.1:6379> get hello
"world"
127.0.0.1:6379> del hello
(integer) 1
127.0.0.1:6379> get hello
(nil)
127.0.0.1:6379> set counter 2
OK
127.0.0.1:6379> get counter
"2"
127.0.0.1:6379> incr counter
(integer) 3
127.0.0.1:6379> get counter
"3"
127.0.0.1:6379> incrby counter 100
(integer) 103
127.0.0.1:6379> get counter
"103"
127.0.0.1:6379> decr counter
(integer) 102
127.0.0.1:6379> get counter
"102"

实战场景

  • 缓存: 经典使用场景,把常用信息,字符串,图片或者视频等信息放到 redis 中,redis 作为缓存层,mysql 做持久化层,降低 mysql 的读写压力。
  • 计数器:redis 是单线程模型,一个命令执行完才会执行下一个,同时数据可以一步落地到其他的数据源。
  • session:常见方案 spring session + redis 实现 session 共享,

List 列表

Redis中的List其实就是链表(Redis用双端链表实现List)。
使用List结构,我们可以轻松地实现最新消息排队功能(比如新浪微博的TimeLine)。 List的另一个应用就是消息队列,可以利用List的 PUSH 操作,将任务存放在List中,然后工作线程再用 POP 操作将任务取出进行执行。

命令使用

命令 简述 使用
RPUSH 将给定值推入到列表右端 RPUSH key value
LPUSH 将给定值推入到列表左端 LPUSH key value
RPOP 从列表的右端弹出一个值,并返回被弹出的值 RPOP key
LPOP 从列表的左端弹出一个值,并返回被弹出的值 LPOP key
LRANGE 获取列表在给定范围上的所有值 LRANGE key 0 -1
LINDEX 通过索引获取列表中的元素。你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。 LINDEX key index

使用列表的技巧

  • lpush+lpop=Stack(栈)
  • lpush+rpop=Queue(队列)
  • lpush+ltrim=Capped Collection(有限集合)
  • lpush+brpop=Message Queue(消息队列)

命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
127.0.0.1:6379> lpush mylist 1 2 ll ls mem
(integer) 5
127.0.0.1:6379> lrange mylist 0 -1
1) "mem"
2) "ls"
3) "ll"
4) "2"
5) "1"
127.0.0.1:6379> lindex mylist -1
"1"
127.0.0.1:6379> lindex mylist 10 # index不在 mylist 的区间范围内
(nil)

实战场景

  • 微博 TimeLine: 有人发布微博,用 lpush 加入时间轴,展示新的列表信息。
  • 消息队列

Set 集合

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。
Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。
  • 图例

image.png

命令使用

命令 简述 使用
SADD 向集合添加一个或多个成员 SADD key value
SCARD 获取集合的成员数 SCARD key
SMEMBERS 返回集合中的所有成员 SMEMBERS key member
SISMEMBER 判断 member 元素是否是集合 key 的成员 SISMEMBER key member

其它一些集合操作,请参考这里https://www.runoob.com/redis/redis-sets.html

命令执行

1
2
3
4
5
6
7
8
9
127.0.0.1:6379> sadd myset hao hao1 xiaohao hao
(integer) 3
127.0.0.1:6379> smembers myset
1) "xiaohao"
2) "hao1"
3) "hao"
127.0.0.1:6379> sismember myset hao
(integer) 1

实战场景

  • 标签(tag),给用户添加标签,或者用户给消息添加标签,这样有同一标签或者类似标签的可以给推荐关注的事或者关注的人。
  • 点赞,或点踩,收藏等,可以放到 set 中实现

Hash 散列

Redis hash 是一个 string 类型的 field(字段) 和 value(值) 的映射表,hash 特别适合用于存储对象。
哈希是键值对的集合。在 Redis 中,哈希是字符串字段和字符串值之间的映射。因此,它们适合表示对象。
  • 图例

image.png

命令使用

命令 简述 使用
HSET 添加键值对 HSET hash-key sub-key1 value1
HGET 获取指定散列键的值 HGET hash-key key1
HGETALL 获取散列中包含的所有键值对 HGETALL hash-key
HDEL 如果给定键存在于散列中,那么就移除这个键 HDEL hash-key sub-key1

命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
127.0.0.1:6379> hset user name1 hao
(integer) 1
127.0.0.1:6379> hset user email1 hao@163.com
(integer) 1
127.0.0.1:6379> hgetall user
1) "name1"
2) "hao"
3) "email1"
4) "hao@163.com"
127.0.0.1:6379> hget user user
(nil)
127.0.0.1:6379> hget user name1
"hao"
127.0.0.1:6379> hset user name2 xiaohao
(integer) 1
127.0.0.1:6379> hset user email2 xiaohao@163.com
(integer) 1
127.0.0.1:6379> hgetall user
1) "name1"
2) "hao"
3) "email1"
4) "hao@163.com"
5) "name2"
6) "xiaohao"
7) "email2"
8) "xiaohao@163.com"

实战场景

  • 缓存: 能直观,相比 string 更节省空间,的维护缓存信息,如用户信息,视频信息等。

Zset 有序集合

Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。
有序集合的成员是唯一的, 但分数(score)却可以重复。有序集合是通过两种数据结构实现:
  1. 压缩列表(ziplist): ziplist 是为了提高存储效率而设计的一种特殊编码的双向链表。它可以存储字符串或者整数,存储整数时是采用整数的二进制而不是字符串形式存储。它能在 O(1)的时间复杂度下完成 list 两端的 push 和 pop 操作。但是因为每次操作都需要重新分配 ziplist 的内存,所以实际复杂度和 ziplist 的内存使用量相关
  2. 跳跃表(zSkiplist): 跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这是采用跳跃表的主要原因。跳跃表的复杂度是 O(log(n))。
  • 图例

image.png

命令使用

命令 简述 使用
ZADD 将一个带有给定分值的成员添加到有序集合里面 ZADD zset-key 178 member1
ZRANGE 根据元素在有序集合中所处的位置,从有序集合中获取多个元素 ZRANGE zset-key 0-1 withccores
ZREM 如果给定元素成员存在于有序集合中,那么就移除这个元素 ZREM zset-key member1

更多命令请参考这里 https://www.runoob.com/redis/redis-sorted-sets.html

命令执行

1
2
3
4
5
6
7
127.0.0.1:6379> zadd myscoreset 100 hao 90 xiaohao
(integer) 2
127.0.0.1:6379> ZRANGE myscoreset 0 -1
1) "xiaohao"
2) "hao"
127.0.0.1:6379> ZSCORE myscoreset hao
"100"

实战场景

  • 排行榜:有序集合经典使用场景。例如小说视频等网站需要对用户上传的小说视频做排行榜,榜单可以按照用户关注数,更新时间,字数等打分,做排行。

3 种特殊类型

HyperLogLogs(基数统计)

Redis 2.8.9 版本更新了 Hyperloglog 数据结构!
  • 什么是基数?

举个例子,A = {1, 2, 3, 4, 5}, B = {3, 5, 6, 7, 9};那么基数(不重复的元素)= 1, 2, 4, 6, 7, 9; (允许容错,即可以接受一定误差)

  • HyperLogLogs 基数统计用来解决什么问题

这个结构可以非常省内存的去统计各种计数,比如注册 IP 数、每日访问 IP 数、页面实时 UV、在线用户数,共同好友数等。

  • 它的优势体现在哪

一个大型的网站,每天 IP 比如有 100 万,粗算一个 IP 消耗 15 字节,那么 100 万个 IP 就是 15M。而 HyperLogLog 在 Redis 中每个键占用的内容都是 12K,理论存储近似接近 2^64 个值,不管存储的内容是什么,它一个基于基数估算的算法,只能比较准确的估算出基数,可以使用少量固定的内存去存储并识别集合中的唯一元素。而且这个估算的基数并不一定准确,是一个带有 0.81% 标准错误的近似值(对于可以接受一定容错的业务场景,比如 IP 数统计,UV 等,是可以忽略不计的)。

  • 相关命令使用
1
2
3
4
5
6
7
8
9
10
11
12
13
127.0.0.1:6379> pfadd key1 a b c d e f g h i	# 创建第一组元素
(integer) 1
127.0.0.1:6379> pfcount key1 # 统计元素的基数数量
(integer) 9
127.0.0.1:6379> pfadd key2 c j k l m e g a # 创建第二组元素
(integer) 1
127.0.0.1:6379> pfcount key2
(integer) 8
127.0.0.1:6379> pfmerge key3 key1 key2 # 合并两组:key1 key2 -> key3 并集
OK
127.0.0.1:6379> pfcount key3
(integer) 13

Bitmap (位存储)

Bitmap 即位图数据结构,都是操作二进制位来进行记录,只有0 和 1 两个状态。
  • 用来解决什么问题

比如:统计用户信息,活跃,不活跃! 登录,未登录! 打卡,不打卡! 两个状态的,都可以使用 Bitmaps
如果存储一年的打卡状态需要多少内存呢? 365 天 = 365 bit 1 字节 = 8bit 46 个字节左右!

  • 相关命令使用

使用 bitmap 来记录 周一到周日的打卡! 周一:1 周二:0 周三:0 周四:1 ……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
127.0.0.1:6379> setbit sign 0 1
(integer) 0
127.0.0.1:6379> setbit sign 1 1
(integer) 0
127.0.0.1:6379> setbit sign 2 0
(integer) 0
127.0.0.1:6379> setbit sign 3 1
(integer) 0
127.0.0.1:6379> setbit sign 4 0
(integer) 0
127.0.0.1:6379> setbit sign 5 0
(integer) 0
127.0.0.1:6379> setbit sign 6 1
(integer) 0

查看某一天是否有打卡!

1
2
3
4
127.0.0.1:6379> getbit sign 3
(integer) 1
127.0.0.1:6379> getbit sign 5
(integer) 0

统计操作,统计 打卡的天数!

1
2
127.0.0.1:6379> bitcount sign # 统计这周的打卡记录,就可以看到是否有全勤!
(integer) 3

geospatial (地理位置)

Redis 的 Geo 在 Redis 3.2 版本就推出了! 这个功能可以推算地理位置的信息: 两地之间的距离, 方圆几里的人

geoadd(添加地理位置)

规则
两级无法直接添加,我们一般会下载城市数据(这个网址可以查询 GEO: http://www.jsons.cn/lngcode)!

  • 有效的经度从-180 度到 180 度。
  • 有效的纬度从-85.05112878 度到 85.05112878 度。
1
2
3
# 当坐标位置超出上述指定范围时,该命令将会返回一个错误。
127.0.0.1:6379> geoadd china:city 39.90 116.40 beijin
(error) ERR invalid longitude,latitude pair 39.900000,116.400000

geopos(获取指定的成员的经度和纬度)

1
2
3
4
5
127.0.0.1:6379> geopos china:city taiyuan manjing
1) 1) "112.54999905824661255"
1) "37.86000073876942196"
2) 1) "118.75999957323074341"
1) "32.03999960287850968"

获得当前定位, 一定是一个坐标值!

geodist

如果不存在, 返回空

单位如下

  • m
  • km
  • mi 英里
  • ft 英尺
1
2
3
4
127.0.0.1:6379> geodist china:city taiyuan shenyang m
"1026439.1070"
127.0.0.1:6379> geodist china:city taiyuan shenyang km
"1026.4391"

georadius

附近的人 ==> 获得所有附近的人的地址, 定位, 通过半径来查询

获得指定数量的人

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
127.0.0.1:6379> georadius china:city 110 30 1000 km			以 100,30 这个坐标为中心, 寻找半径为1000km的城市
1) "xian"
2) "hangzhou"
3) "manjing"
4) "taiyuan"
127.0.0.1:6379> georadius china:city 110 30 500 km
1) "xian"
127.0.0.1:6379> georadius china:city 110 30 500 km withdist
1) 1) "xian"
2) "483.8340"
127.0.0.1:6379> georadius china:city 110 30 1000 km withcoord withdist count 2
1) 1) "xian"
2) "483.8340"
3) 1) "108.96000176668167114"
2) "34.25999964418929977"
2) 1) "manjing"
2) "864.9816"
3) 1) "118.75999957323074341"
2) "32.03999960287850968"

参数 key 经度 纬度 半径 单位 [显示结果的经度和纬度] [显示结果的距离] [显示的结果的数量]

georadiusbymember

显示与指定成员一定半径范围内的其他成员

1
2
3
4
5
6
7
8
9
10
11
12
13
127.0.0.1:6379> georadiusbymember china:city taiyuan 1000 km
1) "manjing"
2) "taiyuan"
3) "xian"
127.0.0.1:6379> georadiusbymember china:city taiyuan 1000 km withcoord withdist count 2
1) 1) "taiyuan"
2) "0.0000"
3) 1) "112.54999905824661255"
2) "37.86000073876942196"
2) 1) "xian"
2) "514.2264"
3) 1) "108.96000176668167114"
2) "34.25999964418929977"

参数与 georadius 一样

geohash(较少使用)

该命令返回 11 个字符的 hash 字符串

1
2
3
127.0.0.1:6379> geohash china:city taiyuan shenyang
1) "ww8p3hhqmp0"
2) "wxrvb9qyxk0"

将二维的经纬度转换为一维的字符串, 如果两个字符串越接近, 则距离越近

底层

geo 底层的实现原理实际上就是 Zset, 我们可以通过 Zset 命令来操作 geo

1
2
127.0.0.1:6379> type china:city
zset

查看全部元素 删除指定的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
127.0.0.1:6379> zrange china:city 0 -1 withscores
1) "xian"
2) "4040115445396757"
3) "hangzhou"
4) "4054133997236782"
5) "manjing"
6) "4066006694128997"
7) "taiyuan"
8) "4068216047500484"
9) "shenyang"
1) "4072519231994779"
2) "shengzhen"
3) "4154606886655324"
127.0.0.1:6379> zrem china:city manjing
(integer) 1
127.0.0.1:6379> zrange china:city 0 -1
1) "xian"
2) "hangzhou"
3) "taiyuan"
4) "shenyang"
5) "shengzhen"

Stream 消息队列

为什么会设计 Stream

Redis5.0 中还增加了一个数据结构Stream,从字面上看是流类型,但其实从功能上看,应该是Redis对消息队列(MQ,Message Queue)的完善实现。
用过Redis做消息队列的都了解,基于Reids的消息队列实现有很多种,例如:
  • PUB/SUB,订阅/发布模式
    • 但是发布订阅模式是无法持久化的,如果出现网络断开、Redis 宕机等,消息就会被丢弃;
  • 基于List LPUSH+BRPOP 或者 基于 Sorted-Set的实现
    • 支持了持久化,但是不支持多播,分组消费等

为什么上面的结构无法满足广泛的 MQ 场景? 这里便引出一个核心的问题:如果我们期望设计一种数据结构来实现消息队列,最重要的就是要理解设计一个消息队列需要考虑什么?初步的我们很容易想到

  • 消息的生产
  • 消息的消费
    • 单播和多播(多对多)
    • 阻塞和非阻塞读取
  • 消息有序性
  • 消息的持久化

其它还要考虑啥嗯?借助美团技术团队的一篇文章,消息队列设计精要(opens new window) 中的图
image.png
我们不妨看看 Redis 考虑了哪些设计

  • 消息 ID 的序列化生成
  • 消息遍历
  • 消息的阻塞和非阻塞读取
  • 消息的分组消费
  • 未完成消息的处理
  • 消息队列监控
  • 这也是我们需要理解Stream的点,但是结合上面的图,我们也应该理解Redis Stream也是一种超轻量MQ并没有完全实现消息队列所有设计要点,这决定着它适用的场景。
    详细内容建议看这篇[Stream详细用法](https://pdai.tech/md/db/nosql-redis/db-redis-data-type-stream.html)

事务

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis 事务相关命令和使用

MULTI 、 EXEC 、 DISCARD 和 WATCH 是 Redis 事务相关的命令。
  • MULTI :开启事务,redis 会将后续的命令逐个放入队列中,然后使用 EXEC 命令来原子化执行这个命令系列。
  • EXEC:执行事务中的所有操作命令。
  • DISCARD:取消事务,放弃执行事务块中的所有命令。
  • WATCH:监视一个或多个 key,如果事务在执行前,这个 key(或多个 key)被其他命令修改,则事务被中断,不会执行事务中的任何命令。
  • UNWATCH:取消 WATCH 对所有 key 的监视。

标准的事务执行

给 k1、k2 分别赋值,在事务中修改 k1、k2,执行事务后,查看 k1、k2 值都被修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 11
QUEUED
127.0.0.1:6379> set k2 22
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) OK
127.0.0.1:6379> get k1
"11"
127.0.0.1:6379> get k2
"22"
127.0.0.1:6379>

事务取消

1
2
3
4
5
6
7
8
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 33
QUEUED
127.0.0.1:6379> set k2 34
QUEUED
127.0.0.1:6379> DISCARD
OK

事务出现错误的处理

  • 语法错误(编译器错误)

在开启事务后,修改 k1 值为 11,k2 值为 22,但 k2 语法错误,最终导致事务提交失败,k1、k2 保留原值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 11
QUEUED
127.0.0.1:6379> sets k2 22
(error) ERR unknown command `sets`, with args beginning with: `k2`, `22`,
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> get k2
"v2"
127.0.0.1:6379>
  • Redis 类型错误(运行时错误)

在开启事务后,修改 k1 值为 11,k2 值为 22,但将 k2 的类型作为 List,在运行时检测类型错误,最终导致事务提交失败,此时事务并没有回滚,而是跳过错误命令继续执行, 结果 k1 值改变、k2 保留原值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k1 v2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 11
QUEUED
127.0.0.1:6379> lpush k2 22
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> get k1
"11"
127.0.0.1:6379> get k2
"v2"
127.0.0.1:6379>

WATCH 命令

WATCH 命令可以为 Redis 事务提供 check-and-set (CAS)行为。

  • CAS? 乐观锁?Redis 官方的例子帮你理解

被 WATCH 的键会被监视,并会发觉这些键是否被改动过了。 如果有至少一个被监视的键在 EXEC 执行之前被修改了, 那么整个事务都会被取消, EXEC 返回 nil-reply 来表示事务已经失败。
举个例子, 假设我们需要原子性地为某个值进行增 1 操作(假设 INCR 不存在)。
首先我们可能会这样做:

1
2
3
val = GET mykey
val = val + 1
SET mykey $val

上面的这个实现在只有一个客户端的时候可以执行得很好。 但是, 当多个客户端同时对同一个键进行这样的操作时, 就会产生竞争条件。举个例子, 如果客户端 A 和 B 都读取了键原来的值, 比如 10 , 那么两个客户端都会将键的值设为 11 , 但正确的结果应该是 12 才对。
有了 WATCH ,我们就可以轻松地解决这类问题了:

1
2
3
4
5
6
WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

使用上面的代码, 如果在 WATCH 执行之后, EXEC 执行之前, 有其他客户端修改了 mykey 的值, 那么当前客户端的事务就会失败。 程序需要做的, 就是不断重试这个操作, 直到没有发生碰撞为止。
这种形式的锁被称作乐观锁, 它是一种非常强大的锁机制。 并且因为大多数情况下, 不同的客户端会访问不同的键, 碰撞的情况一般都很少, 所以通常并不需要进行重试。

  • watch 是如何监视实现的呢

Redis 使用 WATCH 命令来决定事务是继续执行还是回滚,那就需要在 MULTI 之前使用 WATCH 来监控某些键值对,然后使用 MULTI 命令来开启事务,执行对数据结构操作的各种命令,此时这些命令入队列。
当使用 EXEC 执行事务时,首先会比对 WATCH 所监控的键值对,如果没发生改变,它会执行事务队列中的命令,提交事务;如果发生变化,将不会执行事务中的任何命令,同时事务回滚。当然无论是否回滚,Redis 都会取消执行事务前的 WATCH 命令。

  • watch 命令实现监视

在事务开始前用 WATCH 监控 k1,之后修改 k1 为 11,说明事务开始前 k1 值被改变,MULTI 开始事务,修改 k1 值为 12,k2 为 22,执行 EXEC,发回 nil,说明事务回滚;查看下 k1、k2 的值都没有被事务中的命令所改变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> WATCH k1
OK
127.0.0.1:6379> set k1 11
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 12
QUEUED
127.0.0.1:6379> set k2 22
QUEUED
127.0.0.1:6379> EXEC
(nil)
127.0.0.1:6379> get k1
"11"
127.0.0.1:6379> get k2
"v2"
127.0.0.1:6379>
  • unwatch 取消监视
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
127.0.0.1:6379> set k1 v1
OK
127.0.0.1:6379> set k2 v2
OK
127.0.0.1:6379> WATCH k1
OK
127.0.0.1:6379> set k1 11
OK
127.0.0.1:6379> UNWATCH
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set k1 12
QUEUED
127.0.0.1:6379> set k2 22
QUEUED
127.0.0.1:6379> exec
1) OK
2) OK
127.0.0.1:6379> get k1
"12"
127.0.0.1:6379> get k2
"22"
127.0.0.1:6379>

为什么 Redis 不支持回滚?

如果你有使用关系式数据库的经验, 那么 “Redis 在事务失败时不进行回滚,而是继续执行余下的命令”这种做法可能会让你觉得有点奇怪。
以下是这种做法的优点:

  • Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
  • 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

有种观点认为 Redis 处理事务的做法会产生 bug , 然而需要注意的是, 在通常情况下, 回滚并不能解决编程错误带来的问题。 举个例子, 如果你本来想通过 INCR 命令将键的值加上 1 , 却不小心加上了 2 , 又或者对错误类型的键执行了 INCR , 回滚是没有办法处理这些情况的。

如何理解 Redis 与事务的 ACID?

一般来说,事务有四个性质称为ACID,分别是原子性,一致性,隔离性和持久性。

持久化

  • 为什么需要持久化

Redis 是个基于内存的数据库。那服务一旦宕机,内存中的数据将全部丢失。通常的解决方案是从后端数据库恢复这些数据,但后端数据库有性能瓶颈,如果是大数据量的恢复,1、会对数据库带来巨大的压力,2、数据库的性能不如 Redis。导致程序响应慢。所以对 Redis 来说,实现数据的持久化,避免从后端数据库中恢复数据,是至关重要的。

RDB 持久化

RDB 就是 Redis DataBase 的缩写,中文名为快照/内存快照,RDB 持久化是把当前进程数据生成快照保存到磁盘上的过程,由于是某一时刻的快照,那么快照中的值要早于或者等于内存中的值。

触发方式

触发 rdb 持久化的方式有 2 种,分别是手动触发自动触发

手动触发

手动触发分别对应 save 和 bgsave 命令

自动触发

在以下 4 种情况时会自动触发

  • redis.conf 中配置 save m n,即在 m 秒内有 n 次修改时,自动触发 bgsave 生成 rdb 文件;
  • 主从复制时,从节点要从主节点进行全量复制时也会触发 bgsave 操作,生成当时的快照发送到从节点;
  • 执行 debug reload 命令重新加载 redis 时也会触发 bgsave 操作;
  • 默认情况下执行 shutdown 命令时,如果没有开启 aof 持久化,那么也会触发 bgsave 操作;

redis.conf 中配置 RDB

快照周期:内存快照虽然可以通过技术人员手动执行 SAVE 或 BGSAVE 命令来进行,但生产环境下多数情况都会设置其周期性执行条件。

  • Redis 中默认的周期新设置
1
2
3
4
5
6
7
8
9
10
# 周期性执行条件的设置格式为
save <seconds> <changes>

# 默认的设置为:
save 900 1
save 300 10
save 60 10000

# 以下设置方式为关闭RDB快照功能
save ""

以上三项默认信息设置代表的意义是:

  • 如果 900 秒内有 1 条 Key 信息发生变化,则进行快照;
  • 如果 300 秒内有 10 条 Key 信息发生变化,则进行快照;
  • 如果 60 秒内有 10000 条 Key 信息发生变化,则进行快照。读者可以按照这个规则,根据自己的实际请求压力进行设置调整。
  • 其它相关配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 文件名称
dbfilename dump.rdb

# 文件保存路径
dir /home/work/app/redis/data/

# 如果持久化出错,主进程是否停止写入
stop-writes-on-bgsave-error yes

# 是否压缩
rdbcompression yes

# 导入时是否检查
rdbchecksum yes

dbfilename: RDB 文件在磁盘上的名称。
dir: RDB 文件的存储路径。默认设置为“./”,也就是 Redis 服务的主目录。
stop-writes-on-bgsave-error:上文提到的在快照进行过程中,主进程照样可以接受客户端的任何写操作的特性,是指在快照操作正常的情况下。如果快照操作出现异常(例如操作系统用户权限不够、磁盘空间写满等等)时,Redis 就会禁止写操作。这个特性的主要目的是使运维人员在第一时间就发现 Redis 的运行错误,并进行解决。一些特定的场景下,您可能需要对这个特性进行配置,这时就可以调整这个参数项。该参数项默认情况下值为 yes,如果要关闭这个特性,指定即使出现快照错误 Redis 一样允许写操作,则可以将该值更改为 no。
rdbcompression:该属性将在字符串类型的数据被快照到磁盘文件时,启用 LZF 压缩算法。Redis 官方的建议是请保持该选项设置为 yes,因为“it’s almost always a win”。
rdbchecksum:从 RDB 快照功能的 version 5 版本开始,一个 64 位的 CRC 冗余校验编码会被放置在 RDB 文件的末尾,以便对整个 RDB 文件的完整性进行验证。这个功能大概会多损失 10%左右的性能,但获得了更高的数据可靠性。所以如果您的 Redis 服务需要追求极致的性能,就可以将这个选项设置为 no。

RDB 优缺点

  • 优点
    • RDB 文件是某个时间节点的快照,默认使用 LZF 算法进行压缩,压缩后的文件体积远远小于内存大小,适用于备份、全量复制等场景;
    • Redis 加载 RDB 文件恢复数据要远远快于 AOF 方式;
  • 缺点
    • RDB 方式实时性不够,无法做到秒级的持久化;
    • 每次调用 bgsave 都需要 fork 子进程,fork 子进程属于重量级操作,频繁执行成本较高;
    • RDB 文件是二进制的,没有可读性,AOF 文件在了解其结构的情况下可以手动修改或者补全;
    • 版本兼容 RDB 文件问题;

针对 RDB 不适合实时持久化的问题,Redis 提供了 AOF 持久化方式来解决

AOF 持久化

Redis 是“写后”日志,Redis 先执行命令,把数据写入内存,然后才记录日志。日志里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存。PS: 大多数的数据库采用的是写前日志(WAL),例如 MySQL,通过写前日志和两阶段提交,实现数据和逻辑的一致性。
而 AOF 日志采用写后日志,即先写内存,后写日志
为什么采用写后日志
Redis 要求高性能,采用写日志有两方面好处:

  • 避免额外的检查开销:Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。
  • 不会阻塞当前的写操作

但这种方式存在潜在风险:

  • 如果命令执行完成,写日志之前宕机了,会丢失数据。
  • 主线程写磁盘压力大,导致写盘慢,阻塞后续操作。

如何实现 AOF

AOF 日志记录 Redis 的每个写命令,步骤分为:命令追加(append)、文件写入(write)和文件同步(sync)。

  • 命令追加 当 AOF 持久化功能打开了,服务器在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器的 aof_buf 缓冲区。
  • 文件写入和同步 关于何时将 aof_buf 缓冲区的内容写入 AOF 文件中,Redis 提供了三种写回策略

Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

  • 三种写回策略的优缺点

上面的三种写回策略体现了一个重要原则:trade-off,取舍,指在性能和可靠性保证之间做取舍。

redis.conf 中配置 AOF

默认情况下,Redis 是没有开启 AOF 的,可以通过配置 redis.conf 文件来开启 AOF 持久化,关于 AOF 的配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# appendonly参数开启AOF持久化
appendonly no

# AOF持久化的文件名,默认是appendonly.aof
appendfilename "appendonly.aof"

# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的
dir ./

# 同步策略
# appendfsync always
appendfsync everysec
# appendfsync no

# aof重写期间是否同步
no-appendfsync-on-rewrite no

# 重写触发配置
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# 加载aof出错如何处理
aof-load-truncated yes

# 文件重写策略
aof-rewrite-incremental-fsync yes

以下是 Redis 中关于 AOF 的主要配置信息:
appendonly:默认情况下 AOF 功能是关闭的,将该选项改为 yes 以便打开 Redis 的 AOF 功能。
appendfilename:这个参数项很好理解了,就是 AOF 文件的名字。
appendfsync:这个参数项是 AOF 功能最重要的设置项之一,主要用于设置“真正执行”操作命令向 AOF 文件中同步的策略。
什么叫“真正执行”呢?还记得 Linux 操作系统对磁盘设备的操作方式吗? 为了保证操作系统中 I/O 队列的操作效率,应用程序提交的 I/O 操作请求一般是被放置在 linux Page Cache 中的,然后再由 Linux 操作系统中的策略自行决定正在写到磁盘上的时机。而 Redis 中有一个 fsync()函数,可以将 Page Cache 中待写的数据真正写入到物理设备上,而缺点是频繁调用这个 fsync()函数干预操作系统的既定策略,可能导致 I/O 卡顿的现象频繁 。

与上节对应,appendfsync参数项可以设置三个值,分别是:always、everysec、no,默认的值为everysec。
**no-appendfsync-on-rewrite**:always和everysec的设置会使真正的I/O操作高频度的出现,甚至会出现长时间的卡顿情况,这个问题出现在操作系统层面上,所有靠工作在操作系统之上的Redis是没法解决的。为了尽量缓解这个情况,Redis提供了这个设置项,保证在完成fsync函数调用时,不会将这段时间内发生的命令操作放入操作系统的Page Cache(这段时间Redis还在接受客户端的各种写操作命令)。 **auto-aof-rewrite-percentage**:上文说到在生产环境下,技术人员不可能随时随地使用“BGREWRITEAOF”命令去重写AOF文件。所以更多时候我们需要依靠Redis中对AOF文件的自动重写策略。Redis中对触发自动重写AOF文件的操作提供了两个设置:auto-aof-rewrite-percentage表示如果当前AOF文件的大小超过了上次重写后AOF文件的百分之多少后,就再次开始重写AOF文件。例如该参数值的默认设置值为100,意思就是如果AOF文件的大小超过上次AOF文件重写后的1倍,就启动重写操作。 **auto-aof-rewrite-min-size**:参考auto-aof-rewrite-percentage选项的介绍,auto-aof-rewrite-min-size设置项表示启动AOF文件重写操作的AOF文件最小大小。如果AOF文件大小低于这个值,则不会触发重写操作。注意,auto-aof-rewrite-percentage和auto-aof-rewrite-min-size只是用来控制Redis中自动对AOF文件进行重写的情况,如果是技术人员手动调用“BGREWRITEAOF”命令,则不受这两个限制条件左右.

从持久化中恢复数据

其实想要从这些文件中恢复数据,只需要重新启动 Redis 即可.

加载步骤

  • redis 重启时判断是否开启 aof,如果开启了 aof,那么就优先加载 aof 文件;
  • 如果 aof 存在,那么就去加载 aof 文件,加载成功的话 redis 重启成功,如果 aof 文件加载失败,那么会打印日志表示启动失败,此时可以去修复 aof 文件后重新启动;
  • 若 aof 文件不存在,那么 redis 就会转而去加载 rdb 文件,如果 rdb 文件不存在,redis 直接启动成功;
  • 如果 rdb 文件存在就会去加载 rdb 文件恢复数据,如加载失败则打印日志提示启动失败,如加载成功,那么 redis 重启成功,且使用 rdb 文件恢复数据;

那么为什么会优先加载 AOF 呢?因为 AOF 保存的数据更完整,通过上面的分析我们知道 AOF 基本上最多损失 1s 的数据。

Node 中使用 Redis

推荐模块ioredis.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const ioredis = require("ioredis");

// 建立连接
const redis = new ioredis({
port: 6379,
host: "127.0.0.1",
});
// 操作Redis数据库
// redis.set('far', 'bar', (err, ret) => {
// if(err) {
// console.log('写入失败', err);
// }
// console.log('写入成功', ret);
// })

// 支持promise
redis
.get("far")
.then((ret) => {
console.log(ret);
})
.catch((err) => {
console.log("获取失败", err);
});

使用 pipeline 管道

存在多条命令时,使用pipeline可以提高效率.使用exec结束 pipeline 就会给数据库发送请求.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async function main() {
try {
const pipeline = redis.pipeline();
for (let i = 0; i < 100; i++) {
pipeline.set(`${i}-foo`, i);
}
// 结束命令就执行
await pipeline.exec();
} catch (error) {
console.log(error);
}
}

// 甚至能够链式调用命令
redis
.pipeline()
.set("foo", "bar")
.del("cc")
.exec(function (err, results) {});

// `exec` 也是返回的promise
var promise = redis.pipeline().set("foo", "bar").get("foo").exec();
promise.then(function (result) {
// result === [[null, 'OK'], [null, 'bar']]
});

每个链式命令也可以有一个回调,当命令得到回复时将调用它

1
2
3
4
5
6
7
8
9
redis
.pipeline()
.set("foo", "bar")
.get("foo", function (err, result) {
// result === 'bar'
})
.exec(function (err, result) {
// result[1][1] === 'bar'
});

除了单独向管道队列添加命令外,还可以将一组命令和参数传递给构造函数:

1
2
3
4
5
6
7
8
redis
.pipeline([
["set", "foo", "bar"],
["get", "foo"],
])
.exec(function () {
/* ... */
});

查看管道中有多少命令

1
redis.pipeline().set("foo", "bar").get("foo").length; // 2

事务

调用multi时会自动创建pipeline.

1
2
3
4
5
6
7
8
async function main() {
try {
const ret = await redis.multi().set("Jack", 100).set("Tom", 200).exec();
console.log(ret);
} catch (error) {
console.log(error);
}
}

错误调试

showFriendlyErrorStack: 设置为 true 会在报错时提供错误信息.建议只在调试时开启.