Redis特性

1. Redis发布订阅

Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。

Redis 客户端可以订阅任意数量的频道。比如这里有一个频道Channel1,有三个客户端订阅了这个频道,Client1、Client2、Client3。当通过publish命令发送消息给频道Channel1时,这个时候订阅了这个频道的3个客户端就会收到此消息。

# 操作实例
# 客户端1 & 客户端2 & 客户端3
127.0.0.1:6379> subscribe Channel1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "Channel1"
3) (integer) 1

# 客户端4
127.0.0.1:6379> publish Channel1 "this is a message"
(integer) 3

# 客户端1 & 客户端2 & 客户端3
1) "message"
2) "Channel1"
3) "this is a message"

2. Redis事务

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

  • 批量操作在发送 EXEC 命令前被放入队列缓存。

  • 收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行。

  • 在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中。

一个事务从开始到执行会经历以下三个阶段:

  • 开始事务。

  • 命令入队。

  • 执行事务。

单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以Redis 事务的执行并不是原子性的

事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。

# 操作实例
127.0.0.1:6379> get age
"28"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> exec
1) (integer) 29
2) (integer) 30
127.0.0.1:6379> get age
"30"
127.0.0.1:6379> multi 
OK
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> discard
OK
127.0.0.1:6379> get age
"30"

很多情况下事务是配合watch使用的,watch可以监听某些key,当执行事务的时候如果发现被监听的key发生了改变,那么整个事务都不会执行。可以看下面的例子,开始事务之前对name1进行了监听,一旦发现其他客户端修改了name1的值,那么这个事务不会被执行。底层采用的是类似CAS的乐观锁实现的,也通过添加version的方法避免了ABA问题。可以验证一下,当客户端2再将name1的值设定为n11后,客户端1的事务也不会执行,虽然name1的值没有改变,但是系统也认为它发生了变动。

# 客户端1
127.0.0.1:6379> set name1 n1
OK
127.0.0.1:6379> set name2 n2
OK
127.0.0.1:6379> watch name1
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name1 n11
QUEUED
127.0.0.1:6379> set name2 n22
QUEUED

# 客户端2
127.0.0.1:6379> set name1 n12
OK

# 客户端1
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get name1
"n12"
127.0.0.1:6379> get name2
"n2"

3. Redis流水线技术pipeline

用户在项目中比如java项目每执行一次redis操作都要进行一次通信,因为redis的读写速度非常快,那么客户端耗费的时间只要集中在了网络请求上,有的时候我们一次要进行多次读写,那么多次的网络请求会降低我们程序的执行效率,有没有一种方式可以将多次redis操作封装起来进行一次通信一起返回呢?这就是pipeline流水线技术。听起来是redis事务类似,那么它俩的区别是什么?使用redis事务可以进行批量执行任务,但是事务命令是有系统开销的,因为它会检查对应的锁和序列化命令(一般配合watch操作),有的时候我们希望没有任何附加条件的场景下去使用队列批量执行一系列的命令,从而提高系统性能,这就是Redis的流水线技术的优势。

因为流水线技术属于一种协议,没法在客户端演示,下面通过spring的RedisTemplate操作,顺便演示一下和普通读写,事务读写的性能对比:

@Autowired
private RedisTemplate redisTemplate;

......

// 普通读写
long start = System.currentTimeMillis();
for(int i = 0; i < 100000; i++) {
    int j = i + 1;
    redisTemplate.opsForValue().set("key1-" + j, "value" + j);
    redisTemplate.opsForValue().get("key1-" + j);
}
long end = System.currentTimeMillis();
logger.debug("普通读写10万条数据需要:" + (end - start));

// 流水线读写
SessionCallback callback = new SessionCallback() {
    @Override
    public Object execute(RedisOperations operations) throws DataAccessException {
        for(int i = 0; i < 100000; i++) {
            int j = i + 1;
            operations.boundValueOps("key2-" + j).set("value" + j);
            operations.boundValueOps("key2-" + j).get();
        }
        return null;
    }
};
start  = System.currentTimeMillis();
List resList = redisTemplate.executePipelined(callback);
end = System.currentTimeMillis();
logger.debug("流水线读写10万条数据需要:" + (end - start));

// 事务读写
SessionCallback callback2 = new SessionCallback() {
    @Override
    public Object execute(RedisOperations operations) throws DataAccessException {
        operations.multi(); 
        for(int i = 0; i < 100000; i++) {
            int j = i + 1;
            operations.boundValueOps("key3-" + j).set("value" + j);
            operations.boundValueOps("key3-" + j).get();
        }
        operations.exec();
        return null;
    }
};
start  = System.currentTimeMillis();
redisTemplate.execute(callback2);
end = System.currentTimeMillis();
logger.debug("事务读写10万条数据需要:" + (end - start));

运行程序查看日志可以看到通过流水线,普通,事务读写10万条数据的时间分别是:603,10784,721ms。可以看出流水线读写的速度是最快的,是普通读写的十几倍。

2018-10-11 16:26:12.789 DEBUG com.timeline.controllers.CalalogController.queryCatalog(CalalogController.java:66) http-apr-8080-exec-1 [msg]普通读写10万条数据需要:10784
2018-10-11 16:26:13.393 DEBUG com.timeline.controllers.CalalogController.queryCatalog(CalalogController.java:83) http-apr-8080-exec-1 [msg]流水线读写10万条数据需要:603
2018-10-11 16:26:14.115 DEBUG com.timeline.controllers.CalalogController.queryCatalog(CalalogController.java:102) http-apr-8080-exec-1 [msg]事务读写10万条数据需要:721

4. Redis客户端连接

Redis 通过监听一个 TCP 端口或者 Unix socket 的方式来接收来自客户端的连接,当一个连接建立后,Redis 内部会进行以下一些操作:

  • 首先,客户端 socket 会被设置为非阻塞模式,因为 Redis 在网络事件处理上采用的是非阻塞多路复用模型;

  • 然后为这个 socket 设置 TCP_NODELAY 属性,禁用 Nagle 算法;

  • 然后创建一个可读的文件事件用于监听这个客户端 socket 的数据发送;

得到最大连接数:

127.0.0.1:6379> config get maxclients
1) "maxclients"
2) "10000"

5. Redis管道技术

Redis是一种基于客户端-服务端模型以及请求/响应协议的TCP服务。这意味着通常情况下一个请求会遵循以下步骤:

  • 客户端向服务端发送一个查询请求,并监听Socket返回,通常是以阻塞模式,等待服务端响应。

  • 服务端处理命令,并将结果返回给客户端。

Redis 管道技术可以在服务端未响应时,客户端可以继续向服务端发送请求,并最终一次性读取所有服务端的响应。管道技术最显著的优势是提高了 redis 服务的性能。

6. Redis分区

分区是分割数据到多个Redis实例的处理过程,因此每个实例只保存key的一个子集。

分区的优势

  • 通过利用多台计算机内存的和值,允许我们构造更大的数据库。

  • 通过多核和多台计算机,允许我们扩展计算能力;通过多台计算机和网络适配器,允许我们扩展网络带宽。

分区的不足

redis的一些特性在分区方面表现的不是很好:

  • 涉及多个key的操作通常是不被支持的。举例来说,当两个set映射到不同的redis实例上时,你就不能对这两个set执行交集操作。

  • 涉及多个key的redis事务不能使用。

  • 当使用分区时,数据处理较为复杂,比如你需要处理多个rdb/aof文件,并且从多个实例和主机备份持久化文件。

  • 增加或删除容量也比较复杂。redis集群大多数支持在运行时增加、删除节点的透明数据平衡的能力,但是类似于客户端分区、代理等其他系统则不支持这项特性。然而,一种叫做presharding的技术对此是有帮助的。

分区类型

Redis 有两种类型分区。 假设有4个Redis实例 R0,R1,R2,R3,和类似user:1,user:2这样的表示用户的多个key,对既定的key有多种不同方式来选择这个key存放在哪个实例中。也就是说,有不同的系统来映射某个key到某个Redis服务。

范围分区

最简单的分区方式是按范围分区,就是映射一定范围的对象到特定的Redis实例。

比如,ID从0到10000的用户会保存到实例R0,ID从10001到 20000的用户会保存到R1,以此类推。

这种方式是可行的,并且在实际中使用,不足就是要有一个区间范围到实例的映射表。这个表要被管理,同时还需要各 种对象的映射表,通常对Redis来说并非是好的方法。

哈希分区

另外一种分区方法是hash分区。这对任何key都适用,也无需是object_name:这种形式,像下面描述的一样简单:

  • 用一个hash函数将key转换为一个数字,比如使用crc32 hash函数。对key foobar执行crc32(foobar)会输出类似93024922的整数。

  • 对这个整数取模,将其转化为0-3之间的数字,就可以将这个整数映射到4个Redis实例中的一个了。93024922 % 4 = 2,就是说key foobar应该被存到R2实例中。注意:取模操作是取除的余数,通常在多种编程语言中用%操作符实现。

7. 垃圾回收和超时命令

超时命令主要用于redis的垃圾回收,我们可以给key设置一个超时时间,超过这个时间key就标记为超时。下面看一下操作实例:

# 操作实例
127.0.0.1:6379> set key value
OK
127.0.0.1:6379> ttl key(查看超时时间,-1:没有超时时间;-2:已经超时)
(integer) -1
127.0.0.1:6379> expire key 100(设置超时时间,以秒为单位)
(integer) 1
127.0.0.1:6379> ttl key
(integer) 97
127.0.0.1:6379> persist key(取消超时时间)
(integer) 1
127.0.0.1:6379> ttl key
(integer) -1
127.0.0.1:6379> Pexpireat key 1539259110000(设置超时时间点,毫秒为单位的uninx的时间戳)
(integer) 1
127.0.0.1:6379> ttl key
(integer) 19

那redis设置的过期时间存在哪里呢?redisDb结构中expires字典保存了数据库中所有键的过期时间,我们称这个字典为过期字典:

  • 过期字典的键是一个指针,这个指针指向键空间中的某个键对象(即某个数据库键);

  • 过期字典的value是一个long long类型的整数,保存了所指向的数据库键的过期时间--一个毫秒精度的unix时间戳;

Redis是基于内存而运行的数据集合,也存在对内存垃圾的回收和管理问题。对于redis而言,del命令可以删除一些键值对,与此同时,当内存运行空间满了之后,它还会按照回收机制去自动回收一些键值对,但是当垃圾回收的时候又有可能因为执行垃圾回收而引发系统停顿,因此选择适当的回收机制和时间将有利于提升系统的性能。

Redis的回收机制:

  • no-enviction(驱逐):(默认策略)禁止驱逐数据,即当内存使用达到最大阈值的时候,所有申请内存的命令都会报错。

  • allkeys-lru:从数据集中挑选最近最少使用的数据淘汰

  • allkeys-random:从数据集中任意选择数据淘汰

  • volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰

  • volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰

  • volatile-random:从已设置过期时间的数据集中任意选择数据淘汰

处理过期key的策略

如果key超时了,redis不会回收key的存储空间,只会标识哪些键值对超时了。redis提供两种方式回收超时的键值对:

  • 立即回收:在设置键的过期时间的同时,创建一个定时器(timer),让定时器在键的过期时间来临时,立即执行对键的删除操作。(耗忙时CPU)

  • 惰性回收:当一个超时的键,被再次用get命令访问时,将触发Redis将其从内存中清空。(耗内存)

  • 定期回收:定期删除是设置一个时间间隔,每个时间段都会检测是否有过期键,如果有执行删除操作。(折中)

定时回收可以完全回收超时的键值对,缺点就是如果这些键值对比较多,则redis运行的时间比较长,从而导致停顿,所以一般设计者会选择在没有业务发生的时刻触发redis的定时回收。惰性回收的优势是可以指定回收的键值对,缺点就是需要执行一个莫名的get操作,或者在某些时候我们也难以判断哪些键值对已经超时。

8. 支持Lua语言

由于redis的命令计算能力并不算很强大,使用Lua语言在很大程序上就弥补了这个不足。执行Lua语言是原子性的额,也就是说Redis执行Lua的时候不会被中断。这就有助于redis对于并发数据一致性的支持。

redis支持两种方式运行脚本:

  • 直接输入Lua语言的程序代码;

  • 将Lua语言编写成文件。

实际应用中,一些简单的脚本可以采用第一种方式,对于有一定逻辑的一般采用第二种方式,而对于简单脚本的,Redis支持缓存脚本,通过SHA-1算法对脚本签名,然后把SHA-1标识返回回来,只要通过这个标识运行就可以了。

Lua语言是对redis能力的一个重要扩展。

参考:

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

《JavaEE互联网轻量级框架整个开发SSM+redis》

https://zhuanlan.zhihu.com/p/81195864

Last updated