文章目录
一、管道 Pipeline
有了管道技术,可以加速 Redis 的存取速率。不过 管道本身并不是 Redis 服务器直接提供的技术,这个技术本质上是由客户端提供的,跟服务器没有什么直接的关系。下面我们来对这块儿做一个深入的探究。
1、Redis 的消息交互
当我们使用客户端对 Redis 进行一次操作时,如下图所示,客户端将请求传送给服务器,服务器处理完毕后,再将响应回复给客户端。这要花费一个网络数据包来回的时间。
如果连续执行多条指令,那就会花费多个网络数据包来回的时间。如下图所示。
回到客户端代码层面,客户端是经历了 写-读-写-读 四个操作才完整地执行了两条指令。
现在如果我们调整读写顺序,改成 写-写-读-读 ,这两个指令同样可以正常完成。
两个连续的写操作 和 两个连续的读操作 总共只会花费一次网络来回,就好比连续的 write 合并的,连续的 read 也合并了一样。
这便是管道操作的本质,服务器根本没有任何区别对待,还是收到一条消息,执行一条消息,回复一条消息的正常的流程。客户端通过对管道中的指令列表改变读写顺序就可以大幅节省 IO 时间。管道中指令越多,效果越好。
2、管道压力测试
接下来我们实践一下管道的力量。
Redis 自带了一个压力测试工具 redis-benchmark,使用这个工具就可以进行管道测试。
首先我们对一个普通的 set 指令进行压测,QPS 大约 4w/s。
> redis-benchmark -t set -q
我们加入管道选项 -P
参数,它表示单个管道内并行的请求数量,看下面 P=2,QPS 达到了 9w/s。
> redis-benchmark -t set -P 2 -q
再看看 P=3,QPS 达到了 13 w/s。
但如果再继续提升 P 参数,到 二十多的时候,会发现 QPS 已经上不去了。这是因为 这里 CPU 处理能力已经达到了瓶颈,Redis 的单线程 CPU 已经飙到了 100%,所以无法再继续提升了。
3、深入理解管道本质
接下来我们深入分析一个请求交互的流程,真实的情况是它很复杂,因为要经过网络协议栈,这个就得深入内核了。
上图就是一个完整的请求交互流程图,NIC 是网络接口控制器。接下来用文字来仔细描述一遍:
(1)客户端进程调用 write 将消息写到操作系统内核为套接字分配的发送缓冲 send
buffer。
(2)客户端操作系统内核 将发送缓冲的内容发送到网卡,网卡硬件将数据通过「网际路由」送到服务器的网卡。
(3)服务器操作系统内核将网卡的数据放到内核为套接字分配的接收缓冲 recv buffer。
(4)服务器进程调用 read 从接收缓冲中取出消息进行处理。
(5)服务器进程调用 write 将响应消息写到内核为套接字分配的发送缓冲 send buffer。
(6)服务器操作系统内核将发送缓冲的内容发送到网卡,网卡硬件将数据通过「网际路由」送到客户端的网卡。
(7)客户端操作系统内核将网卡的数据放到内核为套接字分配的接收缓冲 recv buffer。
(8)客户端进程调用 read 从接收缓冲中取出消息返回给上层业务逻辑进行处理。
(9)结束。
其中步骤 5~8 和 1~4 是一样的,只不过方向是反过来的,一个是请求,一个是响应。
我们开始以为 write 操作是要等到 对方收到消息 才会返回,但实际上不是这样的。write 操作只负责将数据 写到 本地操作系统内核的发送缓冲 然后就返回了。剩下的事交给操作系统内核 异步 将数据送到目标机器。但是如果发送缓冲满了,那么就需要等待缓冲空出空闲空间来,这个就是写操作 IO 操作的真正耗时。
我们开始以为 read 操作是从目标机器拉取数据,但实际上不是这样的。read 操作只负责将数据从 本地操作系统内核的接收缓冲 中取出来就了事了。但是如果缓冲是空的,那么就需要等待数据到来,这个就是读操作 IO 操作的真正耗时。
所以对于 value = redis.get(key)
这样一个简单的请求来说,write 操作几乎没有耗时,直接写到发送缓冲就返回,而 read 就会比较耗时了,因为它要等待消息经过网络路由到目标机器处理后的响应消息,再回送到当前的内核读缓冲才可以返回。这才是一个网络来回的真正开销。
而对于管道来说,连续的 write 操作根本就没有耗时,之后第一个 read 操作会等待一个网络的来回开销,然后所有的响应消息 就都已经回送到内核的读缓冲 了,后续的 read 操作直接就可以从缓冲拿到结果,瞬间就返回了。这就是管道的本质了,它并不是服务器的什么特性,而是客户端通过改变了读写的顺序带来的性能的巨大提升。
二、事务
为了确保连续多个操作的原子性,一个成熟的数据库通常都会有事务支持,Redis 也不例外。Redis 的事务使用非常简单,不同于关系数据库,我们无须理解那么多复杂的事务模型,就可以直接使用。不过也正是因为这种简单性,它的事务模型很不严格,这要求我们不能像使用关系数据库的事务一样来使用 Redis。
1、Redis 事务的基本使用
每个事务的操作都有 begin、commit 和 rollback,begin 指示事务的开始,commit 指示事务的提交,rollback 指示事务的回滚。它大致的形式如下。
begin();
try {
command1();
command2();
....
commit();
} catch(Exception e) {
rollback();
}
Redis 在形式上看起来也差不多,分别是 multi/exec/discard。multi 指示事务的开始,exec 指示事务的执行,discard 指示事务的丢弃。
可以看到,以上的指令演示了一个完整的事务过程,所有的指令在 exec 之前不执行,而是缓存在服务器的一个事务队列里,服务器一旦收到 exec 指令,才开始执行整个事务队列,执行完毕后 一次性返回所有指令的运行结果。因为 Redis 单线程特性,它不用担心自己在执行队列的时候被其他指令打断,可以保证它们能得到原子性执行。上图显示了以上事务过程完整的交互效果。QUEUED 是一个简单字符串,同 OK 是一个形式,它表示指令已经被服务器缓存到队列里了。
2、原子性
事务的原子性是指要么事务全部成功,要么全部失败,那么 Redis 事务执行是原子性的么?下面我们来看一个特别的例子。
上面的例子是 事务执行到中间遇到失败了,因为我们不能对一个字符串进行数学运算,事务在遇到指令执行失败后,后面的指令还继续执行,所以 poorman 的值能继续得到设置。这样就可以看到 ,Redis 的事务根本不能算「原子性」,而仅仅是满足了事务的「隔离性」,“隔离性”本身是说,保证其它的状态转换不会影响到本次状态转换。
3、discard 丢弃
Redis 为事务提供了一个 discard 指令,用于丢弃事务缓存队列中的所有指令,在 exec 执行之前。
可以看到,执行了 discard 之后,队列中的所有指令都没执行,就好像 multi 和 discard 之间的所有指令从未发生过一样。
4、优化:管道
上面的 Redis 事务在发送每个指令到事务缓存队列时都要经过一次网络读写,当一个事务内部的指令较多时,需要的网络 IO 时间也会线性增长。所以通常 Redis 的客户端在执行事务时都会结合 pipeline 一起使用,这样可以将多次 IO 操作压缩为单次 IO 操作。比如我之前的文章里就有过用 pipeline 的例子。
5、Watch
考虑到一个业务场景,Redis 存储了我们的账户余额数据,它是一个整数。现在有两个并发的客户端要对账户余额进行修改操作,这个修改不是一个简单的 incrby 指令,而是要对余额乘以一个倍数。Redis 可没有提供 multiplyby 这样的指令。我们需要先取出余额然后在内存里乘以倍数,再将结果写回 Redis。
这就会出现并发问题,因为有多个客户端会并发进行操作。我们可以通过 Redis 的 分布式锁 来避免冲突,这是一个很好的解决方案。分布式锁是一种悲观锁,那能不能使用乐观锁的方式来解决冲突呢?
Redis 提供了这种 watch 的机制,它就是一种乐观锁。有了 watch 我们又多了一种可以用来解决并发修改的方法。 watch 的使用方式如下:
while True:
do_watch()
commands()
multi()
send_commands()
try:
exec()
break
except WatchError:
continue
watch 会在事务开始之前盯住 1 个或多个关键变量,当事务执行时,也就是服务器收到了 exec 指令要顺序执行缓存的事务队列时,Redis 会检查关键变量自 watch 之后,是否被修改了 (包括当前事务所在的客户端)。如果关键变量被人动过了,exec 指令就会返回 null ,回复告知客户端事务执行失败,这个时候客户端一般会选择重试。
可以看到,当服务器给 exec 指令返回一个 null(nil) 回复时,客户端知道了事务执行是失败的,通常客户端(py)都会抛出一个 WatchError 这种错误,不过也有些语言 (jedis) 不会抛出异常,而是通过在 exec 方法里返回一个 null,这样客户端需要检查一下返回结果是否为 null (nil)来确定事务是否执行失败。
- 注意事项
Redis 禁止在 multi 和 exec 之间执行 watch 指令,而必须在 multi 之前做好盯住关键变量,否则会出错。
💎 代码:下面用 Java 语言实现对余额的加倍。
import java.util.List;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
public class TransactionDemo {
public static void main(String[] args) {
Jedis jedis = new Jedis();
String userId = "abc";
String key = keyFor(userId);
// 初始化
jedis.setnx(key, String.valueOf(5));
System.out.println(doubleAccount(jedis, userId));
jedis.close();
}
public static int doubleAccount(Jedis jedis, String userId) {
String key = keyFor(userId);
// 如果事务被打断了,会进行重试
while (true) {
jedis.watch(key);
int value = Integer.parseInt(jedis.get(key));
// 余额加倍
value *= 2;
Transaction tx = jedis.multi();
tx.set(key, String.valueOf(value));
List<Object> res = tx.exec();
if (res != null) {
// 成功了
break;
}
}
// 重新获取余额
return Integer.parseInt(jedis.get(key));
}
public static String keyFor(String userId) {
return String.format("account_{}", userId);
}
}
运行结果:
6、为什么 Redis 的事务不能支持回滚
Redis 在事务失败时不进行回滚,而是继续执行余下的命令。这样做的好处:
(1)Redis 命令只会因为错误的语法而失败,并且这些问题不能在入队时发现,或是 命令用在了错误类型的键上面,也就是说,从实用性的角度来说,失败的命令是由变成错误造成的,而 这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
(2)不需要对回滚进行支持,所以 Redis 的内部可以保存简单且快速。
通常情况下,回滚并不能解决编程错误带来的问题。比如,本来想通过 INCR 命令将键的值加 1,却不小心加了 2,又或者 对错误类型的键执行了 INCR,回滚是没办法处理这种情况的。鉴于没有任何机制能避免程序员自己造成的错误,并且 这类错误通常不会出现在生产环境中,所以 Redis 选择了更简单更快捷的 无回滚方式 来处理事务。
三、PubSub
之前讲了 Redis 消息队列的使用方法,但是没有提到 Redis 消息队列的不足之处,那就是 它不支持消息的多播机制。
1、消息多播
消息多播 允许 生产者生产一次消息,中间件负责将消息复制到多个消息队列,每个消息队列由相应的消费组进行消费。它是分布式系统常用的一种解耦方式,用于将多个消费组的逻辑进行拆分。支持了消息多播,多个消费组的逻辑就可以放到不同的子系统中。
如果是普通的消息队列,就得将多个不同的消费组逻辑串接起来放在一个子系统中,进行连续消费。
2、Java 使用 PubSub
为了支持消息多播,Redis 不能再依赖于那 5 种基本数据类型了。它单独使用了一个模块来支持消息多播,这个模块的名字叫着 PubSub,也就是 PublisherSubscriber,发布者订阅者模型 。
💎 代码:使用 Java 语言来演示一下 PubSub 如何使用。
发布者 和 订阅者 都是使用线程的形式。还需要一个 消息监听类,继承并重写 JedisPubSub 的一些方法:
(1)消息监听类 MsgListener
import redis.clients.jedis.JedisPubSub;
// 建立消息监听类,重写 JedisPubSub 的一些相关方法
public class MsgListener extends JedisPubSub{
public MsgListener(){}
// 收到消息会调用
@Override
public void onMessage(String channel, String message) {
System.out.println(String.format("收到消息成功!channel:%s, message: %s", channel, message));
this.unsubscribe();
}
// 订阅频道会调用
@Override
public void onSubscribe(String channel, int subscribedChannels) {
System.out.println(String.format("订阅频道成功!channel:%s, subscribedChannels %d",
channel, subscribedChannels));
}
// 取消订阅会调用
@Override
public void onUnsubscribe(String channel, int subscribedChannels) {
System.out.println(String.format("取消订阅频道! channel: %s, subscribedChannels: %d",
channel, subscribedChannels));
}
}
(2)建立发布者,通过频道 mychannel 发布消息:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
// 建立发布者,通过频道(mychannel)发布消息
public class Publisher extends Thread{
private final JedisPool jedisPool;
public Publisher(JedisPool jedisPool) {
this.jedisPool = jedisPool;
}
@Override
public void run(){
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
//连接池中取出一个连接
Jedis jedis = jedisPool.getResource();
while (true) {
String line;
try {
line = reader.readLine();
if (!"quit".equals(line)) {
//从通过mychannel 频道发布消息
jedis.publish("mychannel", line);
System.out.println(String.format("发布消息成功!channel:%s,message:%s", "mychannel", line));
} else {
break;
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
(3)订阅者
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
// 建立订阅者,订阅者去订阅频道 mychannel
public class Subscriber extends Thread {
private final JedisPool jedisPool;
private final MsgListener msgListener = new MsgListener();
private final String channel = "mychannel";
public Subscriber(JedisPool jedisPool) {
super("Subscriber");
this.jedisPool = jedisPool;
}
@Override
public void run() {
Jedis jedis = null;
try {
// 取出一个连接
jedis = jedisPool.getResource();
//通过 subscribe 订阅,参数是 订阅者 和 频道名
jedis.subscribe(msgListener, channel);
//注意:subscribe是一个阻塞的方法,在取消订阅该频道前,会一直阻塞在这,无法执行后续的代码
//这里在msgListener的onMessage方法里面收到消息后,调用了this.unsubscribe();来取消订阅,才会继续执行
System.out.println("继续执行后续代码。。。");
} catch (Exception e) {
System.out.println(String.format("subsrcibe channel error, %s", e));
} finally {
if (jedis != null) {
jedis.close();
}
}
}
}
运行结果:
Redis PubSub 的生产者和消费者是不同的连接,也就是上面这个例子实际上使用了两个 Redis 的连接。这是必须的,因为 Redis 不允许连接在 subscribe 等待消息时还要进行其它的操作。
(在生产环境中,我们很少将生产者和消费者放在同一个线程里。)
3、Redis 实现发布订阅
首先,需要打开两个客户端。
(1)客户端 1 使用 subscribe 命令订阅通道 test:
(2)客户端 2 使用 publish 命令发送消息:
(3)这时客户端就自动收到消息:
至此,使用redis简单的搭建一个发布订阅服务就完成了。
如果想要订阅多个主题,那就 subscribe 多个名称。
这样,生产者向这三个主题发布的消息,这个消费者都可以接收到。
4、PubSub 缺点
PubSub 的生产者传递过来一个消息,Redis 会直接找到相应的消费者传递过去。如果一个消费者都没有,那么消息直接丢弃。如果开始有三个消费者,一个消费者突然挂掉了,生产者会继续发送消息,另外两个消费者可以持续收到消息。但是挂掉的消费者重新连上的时候,这断连期间生产者发送的消息,对于这个消费者来说就是彻底丢失了。
如果 Redis 停机重启,PubSub 的消息是不会持久化的,毕竟 Redis 宕机就相当于一个消费者都没有,所有的消息直接被丢弃。
正是因为 PubSub 有这些缺点,它几乎找不到合适的应用场景。所以 Redis 的作者单独开启了一个项目 Disque 专门用来做多播消息队列。该项目目前没有成熟,一直长期处于 Beta 版本,但是相应的客户端 sdk 已经非常丰富了,就待 Redis 作者临门一脚发布一个 Release 版本。
近期 Redis5.0 新增了 Stream 数据结构,这个功能给 Redis 带来了持久化消息队列,见下一篇博客~ 。从此 PubSub 可以消失了,Disqueue 估计也永远发不出它的 Release 版本了。
回头复习完 Spring 可以试试在 Spring 中使用 PubSub :https://www.cnblogs.com/onlymate/p/9524960.html