Redis——进阶篇
发布订阅模式
列表list使用发布订阅模式的局限性
之前说可以通过队列的rpush和lpop可以实现消息队列,但是消费者需要不停地调用lpop查看list中是否有等待处理的消息。为了减少通信消耗,可以sleep()一段时间再调用lpop,如此会有两个问题:
- 如果生产者生产消息的速度远大于消费者消费信息的速度,List会占用大量的内存。
- 消息的实时性降低。
改变思路,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的事务有两个特点:
- 按进入队列的顺序执行;
- 不会受到其他客户端的请求的影响。
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命令的好处:
- 一次发送多个命令,减少网络开销。
- Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。
- 对于复杂的组合命令,我们可以放在文件中,可以实现程序之间的命令集复用。
在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有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
执行结果
????吧。每秒处理10w+次set请求和lphsh请求。
redis-benchmark -n 100000 -q script load "redis.call('set','foo','bar')"
每秒10.7w次lua脚本调用。
不禁想问为什么这么快?
总结:
- 纯内存结构
- 单线程
- 多路复用
内存
KV结构的内存数据库,时间复杂度O(1)。
单线程
单线程有什么好处
- 没有创建线程、销毁线程带来的消耗
- 避免了上线文切换导致的CPU消耗
- 避免了线程之间带来的竞争问题,例如加锁释放锁死锁等问题
异步非阻塞
异步非阻塞I/O,多路复用处理并发连接。
Redis为什么是单线程的
因为,单线程已经完全够用了,CPU不是Redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那么顺理成章了
单线程为什么这么快
因为Redis是基于内存的操作,我们先从内存开始说起。
虚拟存储器
名词解释:主存:内存;辅存:硬盘
计算机主存可以看作一个由M个连续的字节大小的单元组成的数组,每个字节由一个唯一的地址,这个地址叫做物理地址(PA)。早期计算机中,如果CPU需要内存,使用物理寻址,直接访问主存储器。
这种方式有几个弊端:
- 在多用户多
上一篇: POJ 1789 Truck History G++
下一篇: Metasploit 提权篇