欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Redis——进阶篇

程序员文章站 2022-07-15 11:09:26
...

发布订阅模式

列表list使用发布订阅模式的局限性

之前说可以通过队列的rpush和lpop可以实现消息队列,但是消费者需要不停地调用lpop查看list中是否有等待处理的消息。为了减少通信消耗,可以sleep()一段时间再调用lpop,如此会有两个问题:

  1. 如果生产者生产消息的速度远大于消费者消费信息的速度,List会占用大量的内存。
  2. 消息的实时性降低。

改变思路,List提供一个阻塞式的出栈命令:blpop,没有任何元素可以弹出的时候,连接会被阻塞。

基于此方式实现的订阅发布,不支持一对多的消息分发。

发布订阅模式

除了通过List实现消息队列之外,Redis还提供了一组命令实现pub/sub模式。

这种方式,发送者和接收者没有直接关联,接收者也不需要持续尝试获取消息。

订阅频道

首先,我们有很多的频道(chennel),我们也可以把这写频道理解为queue。订阅者可以订阅一个或多个频道。消息的发布者可以给指定的频道发布消息。只要有消息到达了频道,所有订阅了这个频道的订阅者都会收到这条消息。

需要注意的是,发出去的消息不会被持久化,因为它已经从队列里面移除了,所以消费者只能收到它开始订阅这个频道之后发布的消息。

下面可以看一下发布订阅命令的使用方法。

订阅者订阅:可以一次订阅多个,比如订阅3个频道

subscribe channel-1 channel-2 channel-3

发布者可以向指定频道发布消息(并不支持一次向多个频道发送消息)

publish channel-1 kingTest

当然也提供了取消订阅命令(不能在订阅状态下使用)

unsubscribe channel-1

根据规则(Pattern)订阅频道

支持?和*占位符。?代表一个字符,*代表0个或多个字符。

消费端一,关注新闻:

psubscribe news*

生产者,发布消息

publish news-weather rain
publish news-sport NBA
publish news-music song

可以看到发布的消息都是存在news开头的信息,消费端都将获取这些消息。

Redis事务

为什么要使用事务

我们知道Redis的单个命令是原子性的,如果涉及到多个命令的时候,需要把多个命令作为一个不可分割的处理序列,就需要用到事务。

例如我们之前说的用setnx实现分布式锁,我们先set然后设置expire,防止del发生异常的时候锁不会被释放,业务处理完了以后再del,这三个动作我们就希望他们作为一组命令执行。

Redis的事务有两个特点:

  1. 按进入队列的顺序执行;
  2. 不会受到其他客户端的请求的影响。

Redis的事务涉及到四个命令:MULTI(开启事务),EXEC(执行事务),DISCARD(取消事务),WATCH(监视)

事务的用法

转账场景

A给B各有1000元,A需要给B转账500元。那么A账户少了500元,B账户多了500元。这一系列操作必须保证原子性。

set A 1000
set B 1000

multi
decrby A 500
incrby B 500
exec

get A
get B

通过multi的命令开启事务。事务不能嵌套,多个multi命令效果一样。

multi执行后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当exec命令被调用时,所有队列中的命令才会被执行。

通过exec的命令执行事务。如果没有执行exec,所有的命令都不会被执行。

如果中途不想执行事务了呢?可以调用discard可以清空事务队列,放弃执行。

multi
set k1 1
set k2 2
set k3 3
discard

watch命令

在Redis中还提供了一个watch命令。

它可以为Redis事务提供CAS乐观锁行为(Check and Set / Compare and Swap),也就是多个线程更新变量的时候,会跟原值做比较,只有它没有被其他线程修改的情况下,才更新成新的值。

我们可以用watch监视一个或多个Key,如果开启事务之后,至少有一个被监视key键在exec执行之前被修改了,那么整个事务都会被取消(key提前过期除外)。可以用unwatch取消。

# client1
set balance 1000
watch balance
multi
incrby balance 100

# client2
decrby balance 100

# client1
exec
get balance

事务可能遇到的问题

我们将事务执行遇到的问题分成两种,一种是在执行exec之前发生错误,一种是在执行exec之后发生错误。

在执行exec之前发生错误

入队的命令存在语法错误,包括参数数量,参数名等等(编译器错误)。

在这种情况下事务会被拒绝执行,也就是队列中所有的命令都不会得到执行。

在执行exec之后发生错误

比如类型错误,对String使用了hash的命令,这是一种运行时错误。

这种情况,发现一个问题,错误发生之前到multi命令之后的命令,是执行成功的。也就是说在这种发生了运行时异常的情况下,只有错误的命令没有被执行,但是其他命令没有收到影响。

这显然不符合我们对原子性的定义,也就是我们没办法用Redis这种事务机制来实现原子性,保证数据的一致性。

Lua脚本

Lua是一种轻量级脚本语言,它是用C语言编写的,跟数据的存储过程有点类似。使用Lua脚本来执行Redis命令的好处:

  1. 一次发送多个命令,减少网络开销。
  2. Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。
  3. 对于复杂的组合命令,我们可以放在文件中,可以实现程序之间的命令集复用。

在Redis中调用Lua脚本

使用eval方法,语法格式:

eval lua-script key-num [key1 key2 key3 ....] [value1 value2 value3 ....]
  • eval代表执行Lua语言的命令
  • lua-script代表Lua语言脚本内容
  • key-num表示参数中有多少个key,需要注意的是Redis中key是从1开始的,如果没有key的参数,那么写0.
  • [key1 key2 key3...]是key作为参数传递给Lua语言,也可以不填,但是需要和key-num的个数对应
  • [value1 value2 value3...]这些参数传递给Lua语言,他们是可填可不填,与key1...对应。

示例,返回一个字符串,0个参数

eval "return 'Hello World'" 0

在Lua脚本中调用Redis命令

使用redis.call(command,key [param1,param2...])进行操作。语法格式:

eval "redis.call('set',KEYS[1],ARGV[1])" 1 lua-key lua-value
  • command是命令,包括set,get,del等
  • key是被操作的键
  • param1,param2...代表给key的参数。

注意与Java不同的是,定义只有形参,调用只有实参。

Lua是在调用时用key表示形参,argv表示参数值(实参)

设置键值对

在Redis中调用Lua脚本执行Redis命令

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 test 2673
get test

在redis-cli中直接写Lua脚本不够方便,也不能实现编辑和复用,通常我们会把脚本放在文件里面,然后执行这个文件。

在Redis中调用Lua脚本文件中的命令

创建Lua脚本文件:

cd /home/soft/redis5.0.5/src
vim king.lua

Lua脚本内容,先设置,再取值:

redis.call('set','king','niubi')
return redis.call('get','king')

在Redis客户端中调用Lua脚本

redis-cli --eval king.lua 0

缓存Lua脚本

为什么要缓存

在脚本比较长的情况下,如果每次调用脚本都需要把整个脚本给Redis服务端,会产生比较大的网络开销。为了解决这个问题,Redis提供了EVALSHA命令,允许开发者通过脚本内容的SHA1摘要来执行脚本。

如何缓存

Redis在执行script load命令时会计算脚本SHA1摘要并记录在脚本缓存中,执行EVALSHA命令时Redis会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了则执行脚本,否则会返回错误:"NOSCRIPT No matching script. Please use EVAL."

Redis——进阶篇

自乘案例

Redis有incrby这样的自增命令,但是没有自乘,比如乘以3,乘以4等。

我们可以写一个自乘运算,让它乘以后面的参数:

local curVal = redis.call("get", KEYS[1])
if curVal == false then
    curVal = 0
else
    curVal = tonumber(curVal)
end
curVal = curVal * tonumber(ARGV[1])
redis.call("set", KEYS[1], curVal)
return curVal

把这个脚本变成单行,语句之间使用分号隔开

local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal

script load '命令'

script load 'local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal'

此时会返回一个SHA1摘要ID:"be4f93d8a5379e5e5b768a74e77c8a4eb0434441"

调用:

set num 2
evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 num 6

脚本超时

Redis的指令执行本身时单线程的,这个线程还要执行客户端的Lua脚本,如果Lua脚本执行超时或者陷入了死循环,是不是没有办法为客户端提供服务了呢?

为了方式某个脚本执行时间过长导致Redis无法提供服务,Redis提供了lua-time-limit参数限制脚本的最长运行时间,默认为5秒。

redis.conf配置文件中 :lua-time-limit 5000

当脚本运行时间超过这一限制后,Redis将开始接受其他命令但不会执行(以确保脚本原子性,因为此时脚本并没有被终止),而是会返回“BUSY”错误。

Redis提供了一个script kill命令来终止脚本的执行。新开一个客户端,执行:

script kill

如果当前执行的Lua脚本对Redis的数据进行了修改(set,del等),那么通过script kill命令是不能终止脚本运行的。

要保证脚本运行的原子性,如果脚本执行了一部分终止,那就违背了脚本原子性的要求。最终要保证脚本要么都执行,要么都不执行。

遇到这种情况,脚本干不掉,只能通过shutdown nosave命令来强行终止redis。

Redis为什么会这么快

Redis到底有多快?

官方提供测试用例,命令如下

cd /home/soft/redis-5.0.5/src
redis-benchmark -t set,lpush -n 100000 -q

执行结果

Redis——进阶篇

????吧。每秒处理10w+次set请求和lphsh请求。

redis-benchmark -n 100000 -q script load "redis.call('set','foo','bar')"

Redis——进阶篇

 每秒10.7w次lua脚本调用。

Redis——进阶篇

不禁想问为什么这么快?

总结:

  1. 纯内存结构
  2. 单线程
  3. 多路复用

内存

KV结构的内存数据库,时间复杂度O(1)。

单线程

单线程有什么好处

  1. 没有创建线程、销毁线程带来的消耗
  2. 避免了上线文切换导致的CPU消耗
  3. 避免了线程之间带来的竞争问题,例如加锁释放锁死锁等问题

异步非阻塞

异步非阻塞I/O,多路复用处理并发连接。

Redis为什么是单线程的

因为,单线程已经完全够用了,CPU不是Redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那么顺理成章了

单线程为什么这么快

因为Redis是基于内存的操作,我们先从内存开始说起。

虚拟存储器

名词解释:主存:内存;辅存:硬盘

计算机主存可以看作一个由M个连续的字节大小的单元组成的数组,每个字节由一个唯一的地址,这个地址叫做物理地址(PA)。早期计算机中,如果CPU需要内存,使用物理寻址,直接访问主存储器。

Redis——进阶篇

这种方式有几个弊端:

  1. 在多用户多