Redis浅析

对Redis的概念和用法的简析,以及分布式数据库和缓存双写一致性分析

Posted by Haiming on August 27, 2019

参考资料: 为什么我们做分布式使用 Redis ? - 程序之心 丁仪的文章 - 知乎 https://zhuanlan.zhihu.com/p/50392209

http://www.runoob.com/redis/redis-tutorial.html

1. 为什么要使用redis

在项目之中使用redis,主要的考虑角度是性能和并发。

性能

由于在数据库之中查询数据是一项特别耗费资源的活动,因此遇到结果不会频繁变动的SQL,适合将结果直接放到缓存。

并发

在大并发的情况下,数据库直接处理所有请求会出现连接异常。这时候就使用Redis做一个缓冲操作,让请求先访问到Redis,再访问数据库。

使用Redis的常见问题

  • 缓存和数据库双写一致性问题
  • 缓存雪崩问题
  • 缓存击穿问题
  • 缓存的并发竞争问题

2. Redis简介

首先上定义:Redis是一个高性能的key-value数据库。 相比于其他产品,Redis有三个特点:

  • Redis支持数据的持久化,可以将内存之中数据保存在磁盘之中,重启可以加载再使用。
  • Redis不仅仅支持简单的key-value,还提供 list,set,zset,hash等结构存储。
  • Redis支持数据的备份,即master-slave格式的备份。

3. Redis优势

  • 性能高:读速度是110000次/s, 写速度是81000次/s
  • 原子: Redis所有操作均为原子性

Redis和其他key-value产品不同在哪? Redis可以将内存之中的数据不断存放在硬盘之中,我认为这一点设计极其精巧,因为内存的存取结构远比硬盘简单,而且在向硬盘上面追加数据的时候,是顺序写,可以最大限度的提高性能。

4.一些配置文件:

http://www.runoob.com/redis/redis-conf.html

  1. vm-page-size 32 : Redis swap 文件分成了很多的page,一个Object可以保存在多个page上面,但是一个page不可以被多个Object共享,这一项是根据存储的数据大小来设定的,若存储很多小对象,page大小最好设置32或者64bytes,存储很大的对象,可以用更大的page。
  2. vm-max-memory 0: 将所有大于 vm-max-memory 的数据写入虚拟内存,无论 vm-max-memory 设置多小。所有索引数据都是内存存储的,也就是说当vm-max-memory设置为0,就是所有value存储到磁盘。默认为0.
  3. maxmemory <bytes>: 指定Redis的最大内存限制。Redis在启动时候会将数据加载到内存之中,达到最大内存之后,Redis会先尝试清除已到期或者即将到期的key,在这种方法处理之后,仍然达到最大的内存限制,将无法使用写入操作。Redis新的vm机制,会将Key放到内存,Value放到swap区。

5. Redis数据类型

Redis数据库之中的key总是一个String Object 数据库的值可以为string,hash,list,set,zset(sorted set:有序集合)。

String

String是Redis最基本的类型,一个key对应一个value,string是二进制安全的,也就是说Redis的String可以包含任何数据,比如jpg图片或者序列化的对象。 String能存储的最大值是512MB

redis 127.0.0.1:6379> SET name "runoob"
OK
redis 127.0.0.1:6379> GET name
"runoob"

Hash

Redis Hash 是一个 key-value 对的集合。 Redis 是一个string类型的field和value的映射表,适用于存储对象

redis 127.0.0.1:6379> DEL runoob
redis 127.0.0.1:6379> HMSET runoob field1 "Hello" field2 "World"
"OK"
redis 127.0.0.1:6379> HGET runoob field1
"Hello"
redis 127.0.0.1:6379> HGET runoob field2
"World"

HMSET,HGET是用来设置键值对的命令。

List

Redis列表是简单的字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部(左边)或者尾部(右边)

redis 127.0.0.1:6379> DEL runoob
redis 127.0.0.1:6379> lpush runoob redis
(integer) 1
redis 127.0.0.1:6379> lpush runoob mongodb
(integer) 2
redis 127.0.0.1:6379> lpush runoob rabitmq
(integer) 3
redis 127.0.0.1:6379> lrange runoob 0 10
1) "rabitmq"
2) "mongodb"
3) "redis"
redis 127.0.0.1:6379>

lpush, lrange是用来存和取的命令。列表最多可以存储 $ 2^32-1 $ 个元素。

Set

Redis的Set是string类型的无序集合,集合是通过Hash表实现的,所以其添加,删除,查找的复杂度都是O(1)

redis 127.0.0.1:6379> DEL runoob
redis 127.0.0.1:6379> sadd runoob redis
(integer) 1
redis 127.0.0.1:6379> sadd runoob mongodb
(integer) 1
redis 127.0.0.1:6379> sadd runoob rabitmq
(integer) 1
redis 127.0.0.1:6379> sadd runoob rabitmq
(integer) 0
redis 127.0.0.1:6379> smembers runoob

1) "redis"
2) "rabitmq"
3) "mongodb"

上述的例子之中rabitmq添加了两次,但是根据唯一性,第二次插入的元素会被忽略, 所以直接返回0

zset(sorted set:有序集合)

Redis的zset与set不同的一点是,每一个元素都会关联一个double类型的分数。Redis通过这种double类型的分数来为集合之中的成员进行从小到大的排序。 zset也是set,所以其成员仍是唯一的,但是score可以重复。

zadd命令: 添加元素到集合。如果在集合之中元素本身存在,则更新对应的score。 zadd key score member

redis 127.0.0.1:6379> DEL runoob
redis 127.0.0.1:6379> zadd runoob 0 redis
(integer) 1
redis 127.0.0.1:6379> zadd runoob 0 mongodb
(integer) 1
redis 127.0.0.1:6379> zadd runoob 0 rabitmq
(integer) 1
redis 127.0.0.1:6379> zadd runoob 0 rabitmq
(integer) 0
redis 127.0.0.1:6379> > ZRANGEBYSCORE runoob 0 1000
1) "mongodb"
2) "rabitmq"
3) "redis"

注意上面命令之中的 ZRANGEBYSCORE runoob 0 1000 是对于score在0到1000的项按照升序排序。

6.Redis命令

在远程 Redis 服务执行命令:

redis-cli -h host -p port -a password

下面的命令是连接到主机为127.0.0.1,端口为6379,密码我为mypass的Redis服务的示例:

$redis-cli -h 127.0.0.1 -p 6379 -a "mypass"
redis 127.0.0.1:6379>
redis 127.0.0.1:6379> PING

PONG

7. Redis 基本类型

7.1 Redis Key

Redis key 命令的基本语法如下:

redis 127.0.0.1:6379> COMMAND KEY_NAME

示例:

redis 127.0.0.1:6379> SET runoobkey redis
OK
redis 127.0.0.1:6379> DEL runoobkey
(integer) 1

返回1表示操作成功,若未成功,则会返回0。

下面是Redis key操作的操作符,可以看出来其除了对于 Key 做CRUD的操作之外,还有一些操作符是对于key的过期时间进行设置的,这也同样表明了Redis作为缓存,需要不断刷新自己内部资源和对某些过期资源进行处理的特点。

序号 命令及描述
1 DEL key 该命令用于在 key 存在时删除 key。
2 DUMP key 序列化给定 key ,并返回被序列化的值。
3 EXISTS key 检查给定 key 是否存在。
4 EXPIRE key seconds 为给定 key 设置过期时间,以秒计。
5 EXPIREAT key timestamp EXPIREAT 的作用和 EXPIRE 类似,都用于为 key 设置过期时间。 不同在于 EXPIREAT 命令接受的时间参数是 UNIX 时间戳(unix timestamp)。
6 PEXPIRE key milliseconds 设置 key 的过期时间以毫秒计。
7 PEXPIREAT key milliseconds-timestamp 设置 key 过期时间的时间戳(unix timestamp) 以毫秒计
8 KEYS pattern 查找所有符合给定模式( pattern)的 key 。
9 MOVE key db 将当前数据库的 key 移动到给定的数据库 db 当中。
10 PERSIST key 移除 key 的过期时间,key 将持久保持。
11 PTTL key 以毫秒为单位返回 key 的剩余的过期时间。
12 TTL key 以秒为单位,返回给定 key 的剩余生存时间(TTL, time to live)。
13 RANDOMKEY 从当前数据库中随机返回一个 key 。
14 RENAME key newkey 修改 key 的名称
15 RENAMENX key newkey 仅当 newkey 不存在时,将 key 改名为 newkey 。
16 TYPE key 返回 key 所储存的值的类型。

7.2 Redis String

Redis String 类型用于管理Redis字符串值,基本语法如下:

redis 127.0.0.1:6379> COMMAND KEY_NAME

示例:

redis 127.0.0.1:6379> SET runoobkey redis
OK
redis 127.0.0.1:6379> GET runoobkey
"redis"

可以看到此处的第一个命令和上面的并无任何不同,其作用为:set一个key为 runoobkey 的String Object

下面是一些String Object相关的命令,可看到其本身除了基本的CRUD之外,还有针对于String 类型的操作符,类似于移位,截取片段,若是数字则数字+n等等,具有较强的灵活操作性。除此之外,还有些类似于 GETSET 的命令,在拿到值之后改变值,将CRUD之中的几种进行组合,非常灵活。

序号 命令及描述
1 SET key value 设置指定 key 的值
2 GET key 获取指定 key 的值。
3 GETRANGE key start end 返回 key 中字符串值的子字符
4 GETSET key value 将给定 key 的值设为 value ,并返回 key 的旧值(old value)。
5 GETBIT key offset 对 key 所储存的字符串值,获取指定偏移量上的位(bit)。
6 [MGET key1 key2..] 获取所有(一个或多个)给定 key 的值。
7 SETBIT key offset value 对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。
8 SETEX key seconds value 将值 value 关联到 key ,并将 key 的过期时间设为 seconds (以秒为单位)。
9 SETNX key value 只有在 key 不存在时设置 key 的值。
10 SETRANGE key offset value 用 value 参数覆写给定 key 所储存的字符串值,从偏移量 offset 开始。
11 STRLEN key 返回 key 所储存的字符串值的长度。
12 [MSET key value key value …] 同时设置一个或多个 key-value 对。
13 [MSETNX key value key value …] 同时设置一个或多个 key-value 对,当且仅当所有给定 key 都不存在。
14 PSETEX key milliseconds value 这个命令和 SETEX 命令相似,但它以毫秒为单位设置 key 的生存时间,而不是像 SETEX 命令那样,以秒为单位。
15 INCR key 将 key 中储存的数字值增一。
16 INCRBY key increment 将 key 所储存的值加上给定的增量值(increment) 。
17 INCRBYFLOAT key increment 将 key 所储存的值加上给定的浮点增量值(increment) 。
18 DECR key 将 key 中储存的数字值减一。
19 DECRBY key decrement key 所储存的值减去给定的减量值(decrement) 。
20 APPEND key value 如果 key 已经存在并且是一个字符串, APPEND 命令将指定的 value 追加到该 key 原来值(value)的末尾。

7.3 Redis Hash

Redis hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象。

Redis 中每个 hash 可以存储 2^32 - 1 键值对(40多亿)。

下面是一个例子:

127.0.0.1:6379>  HMSET runoobkey name "redis tutorial" description "redis basic commands for caching" likes 20 visitors 23000
OK
127.0.0.1:6379>  HGETALL runoobkey
1) "name"
2) "redis tutorial"
3) "description"
4) "redis basic commands for caching"
5) "likes"
6) "20"
7) "visitors"
8) "23000"

在这个例子之中,我遇到了以下几个问题:

  1. runoobkey' 之后的argument必须是2的倍数。也就是在这里,我清楚了提到的“是一个String类型的field和value的映射表”这句话的含义。上午的学习之中没有搞清楚到底Hash类型和String类型的差别在哪,现在看来,一个Hash类型之中所容纳的是很多的String类型的key-value对。
  2. 在第一次输入命令时候没有反应,想着重启大法好,直接重启 redis-cli 没有任何反应,最后连 redis-server 一起重启才正常运作。没有反应期间电脑各项指标全部正常,也没有任何的报错信息,在Google查找无反应原因也并未有自己相关的答案,因此只能求助重启大法了。

下面依旧是部分常用的Redis Hash命令,但是与之前不同的是,在Hash之中,Redis还增加了对于一个key-value对进行操作的命令,包括在给定字段之上进行值的加减等等。

序号 命令及描述
1 [HDEL key field1 field2] 删除一个或多个哈希表字段
2 HEXISTS key field 查看哈希表 key 中,指定的字段是否存在。
3 HGET key field 获取存储在哈希表中指定字段的值。
4 HGETALL key 获取在哈希表中指定 key 的所有字段和值
5 HINCRBY key field increment 为哈希表 key 中的指定字段的整数值加上增量 increment 。
6 HINCRBYFLOAT key field increment 为哈希表 key 中的指定字段的浮点数值加上增量 increment 。
7 HKEYS key 获取所有哈希表中的字段
8 HLEN key 获取哈希表中字段的数量
9 [HMGET key field1 field2] 获取所有给定字段的值
10 [HMSET key field1 value1 field2 value2 ] 同时将多个 field-value (域-值)对设置到哈希表 key 中。
11 HSET key field value 将哈希表 key 中的字段 field 的值设为 value 。
12 HSETNX key field value 只有在字段 field 不存在时,设置哈希表字段的值。
13 HVALS key 获取哈希表中所有值
14 HSCAN key cursor [MATCH pattern] [COUNT count] 迭代哈希表中的键值对。

7.4 Redis List

Redis List是最简单的字符串列表,按照插入顺序排序。可以添加一个元素到列表的头部或者尾部。

下面是代码示例:

redis 127.0.0.1:6379> LPUSH runoobkey redis
(integer) 1
redis 127.0.0.1:6379> LPUSH runoobkey mongodb
(integer) 2
redis 127.0.0.1:6379> LPUSH runoobkey mysql
(integer) 3
redis 127.0.0.1:6379> LRANGE runoobkey 0 10

1) "mysql"
2) "mongodb"
3) "redis"

Redis内置的部分List命令和 Java 之中的差不多,对于这种 List ,一般也就是 pop 和 push,Redis又额外增加了一些结合类型的功能,还有一些 List 属性功能,具体看下表:

序号 命令及描述
1 [BLPOP key1 key2 ] timeout 移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
2 [BRPOP key1 key2 ] timeout 移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
3 BRPOPLPUSH source destination timeout 从列表中弹出一个值,将弹出的元素插入到另外一个列表中并返回它; 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
4 LINDEX key index 通过索引获取列表中的元素
5 LINSERT key BEFORE|AFTER pivot value 在列表的元素前或者后插入元素
6 LLEN key 获取列表长度
7 LPOP key 移出并获取列表的第一个元素
8 [LPUSH key value1 value2] 将一个或多个值插入到列表头部
9 LPUSHX key value 将一个值插入到已存在的列表头部
10 LRANGE key start stop 获取列表指定范围内的元素
11 LREM key count value 移除列表元素
12 LSET key index value 通过索引设置列表元素的值
13 LTRIM key start stop 对一个列表进行修剪(trim),就是说,让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除。
14 RPOP key 移除列表的最后一个元素,返回值为移除的元素。
15 RPOPLPUSH source destination 移除列表的最后一个元素,并将该元素添加到另一个列表并返回
16 [RPUSH key value1 value2] 在列表中添加一个或多个值
17 RPUSHX key value 为已存在的列表添加值

7.5 Redis Set

与其他语言之中定义的集合相同,Redis之中的Set之中的元素也是唯一的。其实现方式是通过哈希表,因此添加,删除,查找的复杂度都为O(1)

下面是插入和查询几个元素的代码示例:

redis 127.0.0.1:6379> SADD runoobkey redis
(integer) 1
redis 127.0.0.1:6379> SADD runoobkey mongodb
(integer) 1
redis 127.0.0.1:6379> SADD runoobkey mysql
(integer) 1
redis 127.0.0.1:6379> SADD runoobkey mysql
(integer) 0
redis 127.0.0.1:6379> SMEMBERS runoobkey

1) "mysql"
2) "mongodb"
3) "redis"

Redis Set常用的语句表格在下方。可见其除了添加,删除等等 Set 的基本操作之外,还有其他的一些对几个集合之间做操作的语句,例如返回所有集合的差集,移动集合之中成员等等:

序号 命令及描述
1 [SADD key member1 member2] 向集合添加一个或多个成员
2 SCARD key 获取集合的成员数
3 [SDIFF key1 key2] 返回给定所有集合的差集
4 [SDIFFSTORE destination key1 key2] 返回给定所有集合的差集并存储在 destination 中
5 [SINTER key1 key2] 返回给定所有集合的交集
6 [SINTERSTORE destination key1 key2] 返回给定所有集合的交集并存储在 destination 中
7 SISMEMBER key member 判断 member 元素是否是集合 key 的成员
8 SMEMBERS key 返回集合中的所有成员
9 SMOVE source destination member 将 member 元素从 source 集合移动到 destination 集合
10 SPOP key 移除并返回集合中的一个随机元素
11 [SRANDMEMBER key count] 返回集合中一个或多个随机数
12 [SREM key member1 member2] 移除集合中一个或多个成员
13 [SUNION key1 key2] 返回所有给定集合的并集
14 [SUNIONSTORE destination key1 key2] 所有给定集合的并集存储在 destination 集合中
15 [SSCAN key cursor MATCH pattern] [COUNT count] 迭代集合中的元素

7.6 Redis sorted set

之前有略微提到过,Redis sorted set 和 Set 一样,不允许重复成员,但是在此之上,每一个元素都会关联一个double类型的分数(score)Redis 通过 score 来对集合之中的成员进行从大到小的排序。

在Redis sorted set 之中,其所有的成员是唯一的,但是其score可以重复。

在我下面的代码测试之中,可以看到如果各个元素的score是一样的,那么依然遵循插入顺序,同样score的元素遵循先到先插的规则:

127.0.0.1:6379> zadd run 1 redis
(integer) 1
127.0.0.1:6379> zadd run 3 redis3
(integer) 1
127.0.0.1:6379> zadd run 2 redis2
(integer) 1
127.0.0.1:6379> zrange run 0 10
1) "redis"
2) "redis2"
3) "redis3"
127.0.0.1:6379> zadd run 2 redis2du
(integer) 1
127.0.0.1:6379> zrange run 0 10
1) "redis"
2) "redis2"
3) "redis2du"
4) "redis3"
127.0.0.1:6379> zadd run 2 redis2dudu
(integer) 1
127.0.0.1:6379> zrange run 0 10
1) "redis"
2) "redis2"
3) "redis2du"
4) "redis2dudu"
5) "redis3"

下面是Redis sorted set之中的基本命令,可以看到在Redis set的基础之上,其又添加了一些在score上面进行操作的命令,例如查找对应score区间的set之中的元素,或者是返回指定成员的排名等等。

序号 命令及描述
1 [ZADD key score1 member1 score2 member2] 向有序集合添加一个或多个成员,或者更新已存在成员的分数
2 ZCARD key 获取有序集合的成员数
3 ZCOUNT key min max 计算在有序集合中指定区间分数的成员数
4 ZINCRBY key increment member 有序集合中对指定成员的分数加上增量 increment
5 [ZINTERSTORE destination numkeys key key …] 计算给定的一个或多个有序集的交集并将结果集存储在新的有序集合 key 中
6 ZLEXCOUNT key min max 在有序集合中计算指定字典区间内成员数量
7 [ZRANGE key start stop WITHSCORES] 通过索引区间返回有序集合成指定区间内的成员
8 [ZRANGEBYLEX key min max LIMIT offset count] 通过字典区间返回有序集合的成员
9 [ZRANGEBYSCORE key min max WITHSCORES] [LIMIT] 通过分数返回有序集合指定区间内的成员
10 ZRANK key member 返回有序集合中指定成员的索引
11 [ZREM key member member …] 移除有序集合中的一个或多个成员
12 ZREMRANGEBYLEX key min max 移除有序集合中给定的字典区间的所有成员
13 ZREMRANGEBYRANK key start stop 移除有序集合中给定的排名区间的所有成员
14 ZREMRANGEBYSCORE key min max 移除有序集合中给定的分数区间的所有成员
15 [ZREVRANGE key start stop WITHSCORES] 返回有序集中指定区间内的成员,通过索引,分数从高到底
16 [ZREVRANGEBYSCORE key max min WITHSCORES] 返回有序集中指定分数区间内的成员,分数从高到低排序
17 ZREVRANK key member 返回有序集合中指定成员的排名,有序集成员按分数值递减(从大到小)排序
18 ZSCORE key member 返回有序集中,成员的分数值
19 [ZUNIONSTORE destination numkeys key key …] 计算给定的一个或多个有序集的并集,并存储在新的 key 中
20 [ZSCAN key cursor MATCH pattern] [COUNT count] 迭代有序集合中的元素(包括元素成员和元素分值)

8. Redis 一些相关的操作

Redis 还有一些其他相关的操作,比如生成Log, 发布订阅,事务等等,下面就对这些相关的操作做一些简单的介绍。

8.1 Redis HyperLogLog

Redis添加了HyperLogLog结构,是用来做基数统计的算法。

基数:不重复元素的个数。

注意,此处的HyperLogLog只是用于做基数统计,即在一定误差允许的范围之内得到不重复元素的个数。

由于HyperLogLog只会根据输入元素来计算基数,而不会计算基数本身,所以不可以像集合那样,返回输入的各个元素。

HyperLogLog相对的优点是,在输入元素的数量或者提及非常大的情况之下,计算基数所需要的空间总是恒定的,并且这个恒定空间非常小。

下面是一个演示:

127.0.0.1:6379> pfadd runo "redis"
(integer) 1
127.0.0.1:6379> pfadd runo "mongodb"
(integer) 1
127.0.0.1:6379> pfadd runo "mysql"
(integer) 1
127.0.0.1:6379> pfcount runo
(integer) 3

下面是Redis HyperLogLog的基本命令,可以看出相对之前的那些数据结构,其常用命令明显偏少,我认为这个是因为其本身并不存储数据本身,所以没有办法做更精细的操作导致的:

序号 命令及描述
1 [PFADD key element element …] 添加指定元素到 HyperLogLog 中。
2 [PFCOUNT key key …] 返回给定 HyperLogLog 的基数估算值。
3 [PFMERGE destkey sourcekey sourcekey …] 将多个 HyperLogLog 合并为一个 HyperLogLog

8.2 Redis 发布订阅

我认为Redis的订阅是一种群发机制,发送者(pub)在某个channel之中发送消息,然后订阅者(sub)在某个频道订阅消息。

在下面的代码示例之中,我们可以看到这种机制是同步的,即当某个sub将channel订阅之后,就可以自动收到那个channel的所有消息,在开启另外一个Cli之后,可以向这个Channel publish 消息,所有的sub都能接受到消息。

Redis Cli 1:

redis 127.0.0.1:6379> SUBSCRIBE redisChat

Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "redisChat"
3) (integer) 1

新建一个Redis cli,并且在同一个channel发布信息,Redis cli 1就可以收到信息:

redis 127.0.0.1:6379> PUBLISH redisChat "Redis is a great caching technique"

(integer) 1

redis 127.0.0.1:6379> PUBLISH redisChat "Learn redis by runoob.com"

(integer) 1

# 订阅者的客户端会显示如下消息
1) "message"
2) "redisChat"
3) "Redis is a great caching technique"
1) "message"
2) "redisChat"
3) "Learn redis by runoob.com"

下面是Redis的某些订阅命令,主要是订阅,退订以及查询状态等等:

序号 命令及描述
1 [PSUBSCRIBE pattern pattern …] 订阅一个或多个符合给定模式的频道。
2 [PUBSUB subcommand argument [argument …]] 查看订阅与发布系统状态。
3 PUBLISH channel message 将信息发送到指定的频道。
4 [PUNSUBSCRIBE pattern [pattern …]] 退订所有给定模式的频道。
5 [SUBSCRIBE channel channel …] 订阅给定的一个或多个频道的信息。
6 [UNSUBSCRIBE channel [channel …]] 指退订给定的频道。

8.3 Redis 事务

Redis 事务可以一次性执行多个命令。并且其带有以下三个重要的保证:

  1. 批量操作在发送EXEC命令之前被放入队列缓存
  2. 收到EXEC命令之后进入事务执行,事务之中任意命令执行失败,其他的命令依旧被执行
  3. 在事务的执行过程之中,其他客户端提交的命令请求不会被插入到事务执行命令序列之中

根据以上几点,一个事务从开始到执行会经历一下三个阶段:

  1. 开始事务
  2. 命令入队
  3. 执行事务

下面是一个例子,先由MULTI开始一个事务,然后将多个命令入队,最后使用EXEC执行事务:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set book "bookname"
QUEUED
127.0.0.1:6379> get book
QUEUED
127.0.0.1:6379> sadd tag "set1" "set2" "set3"
QUEUED
127.0.0.1:6379> smembers tag
QUEUED
127.0.0.1:6379> exec
1) OK
2) "bookname"
3) (integer) 3
4) 1) "set1"
   2) "set3"
   3) "set2"

下面是关于Redis事务的一点说明:

单个Redis命令的执行是原子性的,但是Redis并没有在这个事务上面进行维持原子性的机制,因此Redis事务的执行不是原子性的。

事务可以理解成一个打包的批量执行脚本,中间的某条指令失败,并不会导致前面的命令回滚,也不会导致后面的命令不做。

下面是Redis事务的相关命令:

序号 命令及描述
1 DISCARD 取消事务,放弃执行事务块内的所有命令。
2 EXEC 执行所有事务块内的命令。
3 MULTI 标记一个事务块的开始。
4 UNWATCH 取消 WATCH 命令对所有 key 的监视。
5 [WATCH key key …] 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。

9. Redis的一些原理讲述

此处致谢:

https://www.cnblogs.com/rjzheng/p/9096228.html

https://my.oschina.net/xianggao/blog/541003

Redis在使用之中主要是四个问题:

  1. 缓存和数据库双写一致问题
  2. 缓存雪崩问题
  3. 缓存击穿问题
  4. 缓存的并发竞争问题

下面对于这几个问题,包括Redis的速度为什么这么快的原理进行一定的梳理。再次致谢上面提到的这篇博文。

9.1 单线程的Redis为什么这么快

Redis的速度这么快主要是因为以下三点:

  1. 纯内存操作
  2. 单线程操作,避免了infant的上下文切换。
  3. 采用了非阻塞 I/O多路复用机制

什么是I/O多路复用机制?

即将所有需要送达的socket事先按照socket的不同状态标注好,然后依次放在I/O多路复用程序之中。文件事件分配器将所有的socket按照其对应的类型进行分类,并且依次送到所需要的处理器之中。

下面这张图很形象:

image

相比于传统的每一个socket新建一个线程而言,这样的方式更节省所有的资源。

9.2 Redis和数据库双写一致性问题

9.2.1 CAP原理,强一致性与最终一致性

什么是CAP原理?

先上一段维基百科:

In theoretical computer science, the CAP theorem, also named Brewer’s theorem after computer scientist Eric Brewer, states that it is impossible for a distributed data store to simultaneously provide more than two out of the following three guarantees:[1][2][3]

  • Consistency: Every read receives the most recent write or an error
  • Availability: Every request receives a (non-error) response – without the guarantee that it contains the most recent write
  • Partition tolerance: The system continues to operate despite an arbitrary number of messages being dropped (or delayed) by the network between nodes

When a network partition failure happens should we decide to

  • Cancel the operation and thus decrease the availability but ensure consistency.
  • Proceed with the operation and thus provide availability but risk inconsistency.

In particular, the CAP theorem implies that in the presence of a network partition, one has to choose between consistency and availability. Note that consistency as defined in the CAP theorem is quite different from the consistency guaranteed in ACIDdatabase transactions[4].

简言之,对于分布式系统而言,最多只能同时满足以下三点之中的两点:

  1. Consistency(一致性):每次读取都能拿到最新的写入值或者报错
  2. Availability(可用性):每次请求都可以得到一个相应——但是不保证相应包含最近的写入值。
  3. Partition tolerance(分区耐受性):系统在任意数量的信息被节点之间的网络丢失之后依然可以提供服务。

对于一个分布式系统而言,第三点一定是不可以做取舍的了。那么如今的网络设计也只能在第一点和第二点之间进行权衡。

换言之,当一个网络错误发生的时候,不同的选择方向会造成不同的结果:

  • 取消操作,这样会增加一致性,但是降低可用性
  • 继续操作,提供可用性但是要冒一致性降低的险

什么是强一致性?

系统中的某个数据被成功更新之后,后续对该数据的读取操作都将得到更新之后的值。

什么是最终一致性?

感谢上面提到的blog。

最终一致性,可以分为客户端和服务端两个视角:

  • 从客户端而言,一致性之中最主要的是多并发访问的情况下,更新过的数据如何获取的问题。
  • 从服务端而言,是如何将更新复制分布到整个系统,以保证数据一致

一致性的产生主要是因为并发读写,因此在理解一致性的时候,不可以脱离并发读写的场景。

从客户端角度,对关系型数据库,要求经过一段时间之后,可以访问到更新之后的数据,则是最终一致性。

最终一致性根据更新数据后各进程访问到数据的时间和方式的不同,又可以分为:

  • 因果一致性:如果进程A通知进程B其已经更新了一个数据项,那么进程B的后续访问将返回更新后的值,且一次写入将保证取代前一次写入。与进程A无因果关系的进程C的访问遵守一般的最终一致性原则。
  • ”读己之所写“(read-your-writes)一致性:当进程A自己更新一个数据项之后,其总是可以访问到更新过的值,绝不会看到旧值。
  • “会话”(Session)一致性:其为上一个模型的实用版本,其将访问存储系统的进程放到会话的上下文之中,只要会话还存在,那么系统就可以保证“读己之所写”一致性。但是如果由于某些失败情形使会话终止,需要建立新的会话的时候,系统的保证不会延续到新的会话。
  • 单调(Monotonic)一致性:如果进程已经看到过数据对象的某个值,那么任何后续访问都不会返回那个值之前的值。
  • 单调写一致性:系统保证来自同一个进程的写操作顺序执行。
从服务端角度,如何尽快的将更新后的数据分布到整个系统,降低达到最终一致性的时间窗口,是要解决的难点。

对于分布式系统:

  • N——数据复制的份数
  • W——确认写入操作时所需征询的节点数W
  • R——执行读取操作时所需联系的节点数

如果W+R>N,写的节点和读的节点重叠,则是强一致性。例如对于典型的一主一备同步复制的关系型数据库,N=2,W=2,R=1,那么不管读的是主库还是备库的数据,都是一致的(其原因为同步复制,因此一致)。

如果W+R<=N,则是弱一致性。例如对于一主一备异步复制的关系型数据库,N=2,W=1,R=1,则如果读的是备库,就可能无法读取主库已经更新过的数据,因此是弱一致性。

对于分布式系统,为了保证高可用性,一般设置N>=3。不同的N,W,R组合,是在可用性和一致性之间取得一个平衡,以适应不同的应用场景。

  • 如果N=W,R=1,则任何一个写节点失败,都会导致写失败,因此可用性会降低。但是由于数据分布的N个节点是同步写入的,因此可以保证强一致性。
  • 如果N=R,W=1,那么只需要一个节点写入成功即可,写性能和可用性都比较高。虽然满足我们上面的这个结论,看起来应该是强一致性,但是读取其他节点的进程可能无法获得更新之后的数据(在最坏情况下只有一个节点更新了最新的数据),因此其实是弱一致性。这种情况下,如果W<(N+1)/2,并且写入的节点不重叠的话,会存在写冲突。

9.2.2 解决方法

简而言之,就是采取正确更新策略,先更新数据库,再删除缓存。其次,因为可能存在删除缓存失败的问题,提供一个补偿措施即可,例如使用消息队列。

下面是不同缓存更新的策略详细分析,如果不需要的同学可以直接跳过哈~~

9.2.3 数据库和缓存双写一致性方案解析

https://www.cnblogs.com/rjzheng/p/9041659.html

此处致谢上述博文。

首先,业界的缓存读取使用流程图如图所示:

image

所以大家对于读写缓存方面几乎没有疑问了,但是在更新缓存方面,对于更新数据库和更新缓存的操作顺序,还是有一些争议在。所以我上述提到的博文尝试着将数据库更新和缓存更新梳理了一下,下面是我个人的笔记。

此文由三部分组成:

  • 讲解缓存更新策略
  • 对每种策略进行缺点分析
  • 针对缺点给出改进方案

正文

从理论上而言,给缓存设置过期时间,是保证最终一致性的解决方案。这种方案下,我们对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说,如果数据库写成功,缓存更新失败,那么只要达到过期时间,后面的读请求自然会在数据库之中读取新的值,并且回填缓存。所以,接下来的思路不依赖于给缓存设置过期时间这个方案。

那么这里给出三种更新策略:

  1. 先更新数据库,再更新缓存
  2. 先删除缓存,再更新数据库
  3. 先更新数据库,再删除缓存

没有先更新缓存,再更新数据库是因为:缓存数据可以过期,但是数据库的信息必须绝对正确。数据是以数据库为准。

那么下面开始讨论:

一、先更新数据库,再更新缓存

这套方案是大家普遍反对的,原因如下:

原因一(线程安全角度):

同时有请求A和请求B进行更新操作,那么会出现:

(1) 线程A更新了数据库

(2) 线程B更新了数据库

(3) 线程B更新了缓存

(4) 线程A更新了缓存

按照原本的顺序,应该是请求A更新缓存比请求B更新缓存早才对,但是因为网络或者其他原因,B比A早更新了缓存,这就导致了脏数据。

原因二(业务场景角度):

有如下两点:

  1. 如果是一个写数据库场景较多,而读数据场景比较少的业务需求,那么采用这种方式就导致,数据还没读到,缓存就被频繁的更新(因为先更新数据库之后就更新缓存),很浪费性能。
  2. 如果写入数据库的值,并不是直接写入缓存的,而是要经过一系列的运算再写入缓存,那么每次写入数据库之后,都要再次计算写入缓存的值,无疑非常浪费性能。显然删除缓存更适合。

接下来就是争议比较大的,是先删除缓存,再更新数据库,还是先更新数据库,再删除缓存的问题。

二、先删缓存,再更新数据库

该方案会导致不一致的原因是:同时有一个请求A进行更新操作,另一个请求B进行查询操作,那么会出现以下情形:

  1. 请求A进行写操作,删除缓存
  2. 请求B查询发现缓存不存在
  3. 请求B查询数据库发现旧值
  4. 请求B讲旧值写入缓存
  5. 请求A将新值写入数据库

这种情况就会导致不一致的情形出现,而且,如果不给缓存采用设置过期时间策略,该数据则永远都是脏数据。

那么如何解决呢?采用延时双删策略

下面是伪代码:

public void write(String key,Object data){
        redis.delKey(key);
        db.updateData(data);
        Thread.sleep(1000);
        redis.delKey(key);
    }

转化为语言描述就是:

  1. 先淘汰缓存
  2. 再写数据库(这两步没有变化)
  3. 休眠一秒,再删除缓存

这种做法,可以把一秒钟之内生成的缓存脏数据,再次删除。

这个一秒是如何确定的呢?具体该休眠多久呢?

针对上面提到的脏数据的情形,读者应该自己评估项目的读数据的业务逻辑耗时,然后写数据里面的休眠时间则在读数据业务逻辑的耗时基础上,加几百 ms 即可,这样的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

注意:此处并没有说可以避免上面提到的旧值问题,即请求B读取到的还是原来的脏数据,只是在下一次请求的时候读取的数据是真实的。

如果是使用了mysql的读写分离框架怎么办?

在这种情况下,造成数据不一致的原因如下,还是两个请求,一个请求A进行更新操作,一个请求B进行查询操作。

  1. 请求A进行写操作,删除缓存
  2. 请求A将数据写入数据库了
  3. 请求B查询缓存发现,缓存没有值(缓存在第一步已经删除)
  4. 请求B去从库查询,此时还没有完成主从同步,因此查询到的是旧值。
  5. 请求B将旧值写入缓存
  6. 数据库完成主从同步,从库变为新值。

上述情形,就是主从不一致的原因。策略不变,如果还是采用双删延时擦略,那么睡眠时间修改为主从同步的延时基础上,再加几百 ms 。

采用这种同步淘汰策略,吞吐量降低怎么办?

将第二次删除作为异步的,直接起一个线程,进行异步删除。这样,写的请求就不用沉睡一段时间,再返回。这样做,加大吞吐量。

第二次删除,如果删除失败怎么办?

这个问题如果发生,那么会出现如下情形:

还是两个请求,一个请求A进行更新操作,一个请求B进行查询操作,为了方便,假设是单库:

  1. 请求A进行写操作,删除缓存、
  2. 请求B查询发现缓存不存在
  3. 请求B查询数据库得到旧值
  4. 请求B讲旧值写入缓存
  5. 请求A将新值写入数据库
  6. 请求A试图去删除请求B写入的缓存值,结果失败了

这也就是说,如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。

解决方法先看对第(3)种策略的解析

(3)先更新数据库,再删除缓存

先说一下,有人提出的一个缓存更新套路,名为《cache-Aside pattern》, 其中指出:

  • 失效:应用先从cache之中取数据,没有得到,则从数据库之中取数据,成功后,放到缓存中。
  • 命中: 应用程序从cache之中取数据,取到之后返回。
  • 更新:先把数据存到数据库之中,成功之后,再让缓存失效。

那么这种情况不存在并发问题么?答案肯定是否定的。

假设有这两个请求,一个请求A做查询操作,另一个请求B做更新操作,那么会有如下情况产生:

  1. 缓存刚好失效
  2. 请求A查询数据库,得到一个旧值
  3. 请求B将新值写入数据库
  4. 请求B删除缓存
  5. 请求A将查到的旧值写入缓存

如果按照上述情况发生,那么的确会产生脏数据。

发生这种情况有一个先天性条件,那就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使步骤(4)快于步骤(5)。可是,对于一个数据库而言,其本身就是读操作比写操作的速度更快(这也是读写分离的意义,读操作比较快,所耗费的资源更少),因此步骤(3)比步骤(2)耗时更短,这一情况很难出现。

但是如果非得要解决上面提出来的问题呢?

首先,给缓存设置有效时间是一种解决方案,其次采用策略二之中的异步延时删除策略,保证读请求完成之后,在进行删除操作。也就是让顺序变成如下所示:

  1. 缓存刚好失效
  2. 请求A查询数据库,得到一个旧值
  3. 请求A将查询到的旧值写入缓存
  4. 请求B将新值写入数据库
  5. 请求B删除缓存

还有其他造成不一致的原因么?

有,这也是缓存更新策略(2)和缓存更新策略(3)都存在的一个问题,如果删除缓存失败了怎么办?

比如一个写数据请求,写入数据库了,删缓存失败了,那么就会出现数据不一致的情况了。这也是缓存更新策略(2)之中留下的最后一个疑问。

如何解决?

提供一个保障的重试机制即可,这里有两套解决方案。

方案一:

如图所示:

image

流程如下所示:

  1. 更新数据库数据
  2. 尝试删除缓存,但是删除缓存失败
  3. 将需要删除的key发送到消息队列
  4. 自己消费信息,获得所需要删除的key
  5. 继续重试删除操作,直到删除成功

但是,该方案有一个缺点,那就是对业务线代码造成了大量的侵入。于是,有了方案二。在方案二之中,启动一个订阅程序去订阅数据库的 binlog, 获得需要操作的数据,在应用程序之中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。

方案二:

image

流程如上图所示:

  1. 更新数据库数据
  2. 数据库将操作信息写入binlog日志之中
  3. 订阅程序提取出需要的数据和key
  4. 另起一段非业务代码,获取该信息
  5. 尝试删除缓存操作,发现删除失败
  6. 将这些信息发送到消息队列
  7. 重新从消息队列之中获取该数据,重试操作

9.3 缓存雪崩和缓存穿透问题

一般而言,对于中小型传统企业,很难碰到这个问题,但是如果有大并发的项目,在几百万左右的话,这两个问题是要好好考虑的。

9.3.1 缓存穿透的解决方法

缓存穿透:

黑客故意去请求缓存之中根本不存在的数据,导致所有的流量都直接导到数据库上,造成数据库连接异常。

解决方案:

  1. 利用互斥锁,缓存失效的时候,先去获得锁,得到锁了之后再去请求数据库,没得到锁,则休眠一段时间之后重试。

  2. 采用异步更新策略,无论key是否取到值,都直接返回。比如查到了这个key是没有值的,那么会在缓存之中直接加入一个value是null的key-value对,在这个空的value之中放上缓存失效时间。缓存如果过期,那么异步起一个线程去读数据库,更新缓存。这种方法需要做缓存预热工作。

    我对这里有一个疑问:

    如果黑客直接使用大量不重复的缓存之中不存在的数据进行攻击怎么办?这种方法看起来在第一波攻击的时候,所有攻击数据会一口气打到数据库上,只有在之后使用相同数据攻击才会有拦截效果吧??在和同学讨论之后他也觉得不大有效……不大懂……

  3. 提供一个可以迅速判断请求是否有效的拦截机制,比如,利用布隆过滤器,内部维护一系列合法有效的key,迅速判断出所携带的Key是否合法有效,如果不合法则直接返回。

    布隆过滤器:布隆过滤器的原理是,当一个元素被加入集合时,通过K个散列函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。

缓存雪崩:

缓存同一时间大面积的失效,这时候又来了一波请求,结果请求全部连接到数据库上,从而导致数据库连接异常

解决方案:

  1. 给缓存失效的时间,加上一个随机值,避免集体失效。

  2. 使用互斥锁,在访问数据库的时候加锁,但是很明显该方案吞吐量下降了

  3. 双缓存:那么有两个缓存,缓存A和缓存B,缓存A的失效时间是20分钟,缓存B不设置任何失效时间,自己做缓存预热操作,然后细分为以下几个小点。

    • 从缓存A读数据库,有则直接返回
    • A没有数据,直接从B读数据,直接返回,并且异步启动一个更新线程
    • 更新线程同时更新缓存A和缓存B

    也就是说,这种方式使用两个缓存,然后在两个缓存都没有命中的情况下,启动一个异步线程来进行数据库和缓存的更新,更新线程更新缓存A和缓存B

9.4 如何解决Redis的并发竞争key问题

分析:

这个问题大致就是,同时有多个子系统去set一个key,那么会出现并发竞争的问题。

博主认为,不推荐使用Redis的事务机制,因为生产环境,基本都是Redis集群,做了数据分片操作,一个事务之中有涉及到多个Key操作的时候,这多个key不一定都存储在同一个Redis-server之上,因此Redis 的事务机制非常鸡肋

回答:

  1. 如果对这个key操作,不要求顺序:

    直接准备一个分布式锁,大家去抢锁,抢到锁就去做set即可。

  2. 如果对这个key操作,要求顺序:

    假设有一个key1,系统A要求将 key1设置为 value A, 系统 B 要求将 key1 设置为 value B,系统 C 需要将 key1 设置为 value C。

    期望将 key1 的 value 值按照 value A -> value B -> value C的顺序进行变化,那么在写入数据库的时候,我们要保存一个时间戳,假设时间戳如下:

    系统A key 1 {valueA  3:00}
    系统B key 1 {valueB  3:05}
    系统C key 1 {valueC  3:10}
    

    那么,假设系统B 先抢到锁,将 key1 设置为 {valueB 3:05},接下来系统 A 抢到锁,发现自己的 value A 的时间戳时间早于缓存之中的时间戳,那么就直接放弃,不再做 set 操作了。 以此类推。

    其他方法,包括利用队列,将 set 变成串行访问也可以。

10. 各种 MQ 的性能比较

此处引用 https://github.com/doocs/advanced-java/blob/master/docs/high-concurrency/why-mq.md 的文章

Kafka、ActiveMQ、RabbitMQ、RocketMQ 有什么优缺点?

特性 ActiveMQ RabbitMQ RocketMQ Kafka
单机吞吐量 万级,比 RocketMQ、Kafka 低一个数量级 同 ActiveMQ 10 万级,支撑高吞吐 10 万级,高吞吐,一般配合大数据类的系统来进行实时数据计算、日志采集等场景
topic 数量对吞吐量的影响     topic 可以达到几百/几千的级别,吞吐量会有较小幅度的下降,这是 RocketMQ 的一大优势,在同等机器下,可以支撑大量的 topic topic 从几十到几百个时候,吞吐量会大幅度下降,在同等机器下,Kafka 尽量保证 topic 数量不要过多,如果要支撑大规模的 topic,需要增加更多的机器资源
时效性 ms 级 微秒级,这是 RabbitMQ 的一大特点,延迟最低 ms 级 延迟在 ms 级以内
可用性 高,基于主从架构实现高可用 同 ActiveMQ 非常高,分布式架构 非常高,分布式,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用
消息可靠性 有较低的概率丢失数据 基本不丢 经过参数优化配置,可以做到 0 丢失 同 RocketMQ
功能支持 MQ 领域的功能极其完备 基于 erlang 开发,并发能力很强,性能极好,延时很低 MQ 功能较为完善,还是分布式的,扩展性好 功能较为简单,主要支持简单的 MQ 功能,在大数据领域的实时计算以及日志采集被大规模使用

综上的对比之后,有以下的建议:

一般的业务如果想要引进来 MQ,最早大家使用的是 ActiveMQ, 但是由于其没有经过大规模吞吐量的验证,现在使用的不多了。

后来开始使用 RabbitMQ,但是 erlang 对于 Java 工程师有一定的难度。但是开源且社区活跃。

RocketMQ 是阿里出品,目前越来越多的系统在使用。

总结之后,中小型公司,技术实力一般,技术挑战也不是特别高,使用 RabbitMQ 是最好的选择。大型公司,基础架构研发实力比较强的,用 RocketMQ 是更好的选择。

如果在大数据领域的实时计算,日志采集等等,用 Kafka 是业内标准的,绝对没问题。社区活跃度也很高,且为全世界这个领域的事实性典范。