目標(biāo)
1骑素、學(xué)習(xí) Redis 的一些高級(jí)特性,包括發(fā)布訂閱刚夺、事務(wù)砂豌、Lua 腳本
1、發(fā)布訂閱模式
1.1列表的局限
前面我們說(shuō)通過(guò)隊(duì)列的 rpush 和 lpop 可以實(shí)現(xiàn)消息隊(duì)列(隊(duì)尾進(jìn)隊(duì)頭出)光督,但是消費(fèi)者需要不停地調(diào)用 lpop 查看 List 中是否有等待處理的消息(比如寫一個(gè) while 循環(huán))。
為了減少通信的消耗塔粒,可以 sleep()一段時(shí)間再消費(fèi)结借,但是會(huì)有兩個(gè)問(wèn)題:
- 如果生產(chǎn)者生產(chǎn)消息的速度遠(yuǎn)大于消費(fèi)者消費(fèi)消息的速度,List 會(huì)占用大量的內(nèi)存卒茬。
- 消息的實(shí)時(shí)性降低
list 還提供了一個(gè)阻塞的命令:blpop船老,沒(méi)有任何元素可以彈出的時(shí)候,連接會(huì)被阻塞圃酵。blpop queue 5
柳畔,阻塞5秒。
基于 list 實(shí)現(xiàn)的消息隊(duì)列郭赐,不支持一對(duì)多的消息分發(fā)薪韩。
1.2發(fā)布訂閱模式
除了通過(guò) list 實(shí)現(xiàn)消息隊(duì)列之外,Redis 還提供了一組命令實(shí)現(xiàn)發(fā)布/訂閱模式捌锭。
這種方式俘陷,發(fā)送者和接收者沒(méi)有直接關(guān)聯(lián)(實(shí)現(xiàn)了解耦),接收者也不需要持續(xù)嘗試獲取消息观谦。
1.2.1 訂閱頻道
可以訂閱一個(gè)或者多個(gè)頻道拉盾。消息的發(fā)布者(生產(chǎn)者)可以給指定的頻道發(fā)布消息。只要有消息到達(dá)了頻道豁状,所有訂閱了這個(gè)頻道的訂閱者都會(huì)收到這條消息捉偏。
需要注意的注意是倒得,發(fā)出去的消息不會(huì)被持久化,因?yàn)樗呀?jīng)從隊(duì)列里面移除了夭禽,所以消費(fèi)者只能收到它開始訂閱這個(gè)頻道之后發(fā)布的消息霞掺。
下面我們來(lái)看一下發(fā)布訂閱命令的使用方法。
訂閱者訂閱頻道:可以一次訂閱多個(gè)驻粟,比如這個(gè)客戶端訂閱了 3 個(gè)頻道根悼。
subscribe channel-1 channel-2 channel-3
發(fā)布者可以向指定頻道發(fā)布消息(并不支持一次向多個(gè)頻道發(fā)送消息):
publish channel-1 2673
取消訂閱(不能在訂閱狀態(tài)下使用):
unsubscribe channel-1
1.2.2 按規(guī)則(Pattern) 訂閱頻道
支持?和占位符。?代表一個(gè)字符蜀撑,代表 0 個(gè)或者多個(gè)字符挤巡。
消費(fèi)端 1,關(guān)注運(yùn)動(dòng)信息:
psubscribe *sport
消費(fèi)端 2酷麦,關(guān)注所有新聞:
psubscribe news*
消費(fèi)端 3矿卑,關(guān)注天氣新聞:
psubscribe news-weather
生產(chǎn)者,發(fā)布 3 條信息
publish news-sport yaoming
publish news-music jaychou
publish news-weather rain
java 代碼
package pubsub;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub;
public class PublishSubscribe {
public static void main(String[] args) {
Jedis jedis = new Jedis("192.168.0.224", 6379);
jedis.subscribe(new Subscriber(),"channel");
}
}
class Subscriber extends JedisPubSub{
@Override
public void onMessage(String channel, String message) {
System.out.println("接收到的消息:"+message);
}
@Override
public void onSubscribe(String channel, int subscribedChannels) {
System.out.println("onSubscribe---channel:"+channel+",subscribedChannels:"+subscribedChannels);
}
@Override
public void onPUnsubscribe(String pattern, int subscribedChannels) {
System.out.println("onPUnsubscribe---pattern:"+pattern+",subscribedChannels:"+subscribedChannels);
}
@Override
public void onPSubscribe(String pattern, int subscribedChannels) {
System.out.println("onPSubscribe---pattern:"+pattern+",subscribedChannels:"+subscribedChannels);
}
@Override
public void unsubscribe(String... channels) {
super.unsubscribe(channels);
}
}
package pubsub;
import redis.clients.jedis.Jedis;
public class PublishTest {
public static void main(String[] args) {
Jedis jedis = new Jedis("192.168.0.224", 6379);
jedis.publish("channel","你好呀");
jedis.close();
}
}
2 沃饶、Redis 事務(wù)
2.1 為什么要用事務(wù)
我們知道 Redis 的單個(gè)命令是原子性的(比如 get set mget mset)母廷,如果涉及到多個(gè)命令的時(shí)候,需要把多個(gè)命令作為一個(gè)不可分割的處理序列糊肤,就需要用到事務(wù)琴昆。
例如我們之前說(shuō)的用 setnx 實(shí)現(xiàn)分布式鎖,我們先 set馆揉,然后設(shè)置對(duì) key 設(shè)置 expire业舍,防止 del 發(fā)生異常的時(shí)候鎖不會(huì)被釋放,業(yè)務(wù)處理完了以后再 del升酣,這三個(gè)動(dòng)作我們就希望它們作為一組命令執(zhí)行舷暮。
Redis 的事務(wù)有兩個(gè)特點(diǎn):
- 按進(jìn)入隊(duì)列的順序執(zhí)行。
- 不會(huì)受到其他客戶端的請(qǐng)求的影響噩茄。
Redis 的事務(wù)涉及到四個(gè)命令:multi(開啟事務(wù))下面,exec(執(zhí)行事務(wù)),discard(取消事務(wù))绩聘,watch(監(jiān)視)
2.2 事務(wù)的用法
案例場(chǎng)景:tom 和 mic 各有 1000 元沥割,tom 需要向 mic 轉(zhuǎn)賬 100 元。
tom 的賬戶余額減少 100 元君纫,mic 的賬戶余額增加 100 元驯遇。
127.0.0.1:6379> set tom 1000
OK
127.0.0.1:6379> set mic 1000
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> decrby tom 100
QUEUED
127.0.0.1:6379> incrby mic 100
QUEUED
127.0.0.1:6379> exec
1) (integer) 900
2) (integer) 1100
127.0.0.1:6379> get tom
"900"
127.0.0.1:6379> get mic
"1100"
通過(guò) multi 的命令開啟事務(wù)。事務(wù)不能嵌套蓄髓,多個(gè) multi 命令效果一樣叉庐。
multi 執(zhí)行后,客戶端可以繼續(xù)向服務(wù)器發(fā)送任意多條命令会喝, 這些命令不會(huì)立即被執(zhí)行陡叠, 而是被放到一個(gè)隊(duì)列中玩郊, 當(dāng) exec 命令被調(diào)用時(shí), 所有隊(duì)列中的命令才會(huì)被執(zhí)行枉阵。
通過(guò) exec 的命令執(zhí)行事務(wù)译红。如果沒(méi)有執(zhí)行 exec,所有的命令都不會(huì)被執(zhí)行兴溜。如果中途不想執(zhí)行事務(wù)了侦厚,怎么辦?可以調(diào)用 discard 可以清空事務(wù)隊(duì)列拙徽,放棄執(zhí)行刨沦。
multi
set k1 1
set k2 2
set k3 3
discard
2.3 watch 命令
在 Redis 中還提供了一個(gè) watch 命令。
它可以為 Redis 事務(wù)提供 CAS 樂(lè)觀鎖行為(Check and Set / Compare and Swap)膘怕,也就是多個(gè)線程更新變量的時(shí)候想诅,會(huì)跟原值做比較,只有它沒(méi)有被其他線程修改的情況下岛心,才更新成新的值来破。
我們可以用 watch 監(jiān)視一個(gè)或者多個(gè) key,如果開啟事務(wù)之后忘古,至少有一個(gè)被監(jiān)視key 鍵在 exec 執(zhí)行之前被修改了徘禁, 那么整個(gè)事務(wù)都會(huì)被取消(key 提前過(guò)期除外)∷杩埃可以用 unwatch 取消晌坤。
2.4 事務(wù)可能遇到的問(wèn)題
我們把事務(wù)執(zhí)行遇到的問(wèn)題分成兩種,一種是在執(zhí)行 exec 之前發(fā)生錯(cuò)誤旦袋,一種是在執(zhí)行 exec 之后發(fā)生錯(cuò)誤。
2.4.1 在執(zhí)行 exec 之前發(fā)生錯(cuò)誤
比如:入隊(duì)的命令存在語(yǔ)法錯(cuò)誤它改,包括參數(shù)數(shù)量疤孕,參數(shù)名等等(編譯器錯(cuò)誤)。
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set gupao 666
QUEUED
127.0.0.1:6379> hset qingshan 2673
(error) ERR wrong number of arguments for 'hset' command
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
在這種情況下事務(wù)會(huì)被拒絕執(zhí)行央拖,也就是隊(duì)列中所有的命令都不會(huì)得到執(zhí)行祭阀。
2.4.2 在執(zhí)行 exec 之后發(fā)生錯(cuò)誤
比如,類型錯(cuò)誤鲜戒,比如對(duì) String 使用了 Hash 的命令专控,這是一種運(yùn)行時(shí)錯(cuò)誤。
127.0.0.1:6379> flushall
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set k1 1
QUEUED
127.0.0.1:6379> hset k1 a b
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> get k1
"1"
最后我們發(fā)現(xiàn) set k1 1 的命令是成功的遏餐,也就是在這種發(fā)生了運(yùn)行時(shí)異常的情況下伦腐,只有錯(cuò)誤的命令沒(méi)有被執(zhí)行,但是其他命令沒(méi)有受到影響失都。
這個(gè)顯然不符合我們對(duì)原子性的定義柏蘑,也就是我們沒(méi)辦法用 Redis 的這種事務(wù)機(jī)制來(lái)實(shí)現(xiàn)原子性幸冻,保證數(shù)據(jù)的一致。
思考(作業(yè)):
為什么在一個(gè)事務(wù)中存在錯(cuò)誤咳焚,Redis 不回滾洽损?
3 Lua 腳本
Lua是一種輕量級(jí)腳本語(yǔ)言,它是用 C 語(yǔ)言編寫的革半,跟數(shù)據(jù)的存儲(chǔ)過(guò)程有點(diǎn)類似碑定。 使用 Lua 腳本來(lái)執(zhí)行 Redis 命令的好處:
- 一次發(fā)送多個(gè)命令,減少網(wǎng)絡(luò)開銷又官。
- Redis 會(huì)將整個(gè)腳本作為一個(gè)整體執(zhí)行延刘,不會(huì)被其他請(qǐng)求打斷,保持原子性赏胚。
- 對(duì)于復(fù)雜的組合命令访娶,我們可以放在文件中,可以實(shí)現(xiàn)程序之間的命令集復(fù)用觉阅。
3.1 在 Redis 中調(diào)用 Lua 腳本
使用 eval 方法崖疤,語(yǔ)法格式:
redis> eval lua-script key-num [key1 key2 key3 ....] [value1 value2 value3 ....]
? eval 代表執(zhí)行 Lua 語(yǔ)言的命令。
? lua-script 代表 Lua 語(yǔ)言腳本內(nèi)容典勇。
? key-num 表示參數(shù)中有多少個(gè) key劫哼, 需要注意的是 Redis 中 key 是從 1 開始的, 如果沒(méi)有 key 的參數(shù)割笙, 那么寫 0权烧。
? [key1 key2 key3…]是 key 作為參數(shù)傳遞給 Lua 語(yǔ)言, 也可以不填伤溉, 但是需要和 key-num 的個(gè)數(shù)對(duì)應(yīng)起來(lái)般码。
? [value1 value2 value3 ….]這些參數(shù)傳遞給 Lua 語(yǔ)言, 它們是可填可不填的乱顾。
示例板祝,返回一個(gè)字符串,0 個(gè)參數(shù):
redis> eval "return 'Hello World'" 0
3.2 在 Lua 腳本中調(diào)用 Redis 命令
使用 redis.call(command, key [param1, param2…])進(jìn)行操作走净。語(yǔ)法格式:
redis> eval "redis.call('set',KEYS[1],ARGV[1])" 1 lua-key lua-value
? command 是命令券时, 包括 set、 get伏伯、 del 等橘洞。
? key 是被操作的鍵。
? param1,param2…代表給 key 的參數(shù)说搅。
注意跟 Java 不一樣炸枣,定義只有形參,調(diào)用只有實(shí)參。
Lua 是在調(diào)用時(shí)用 key 表示形參抛虏,argv 表示參數(shù)值(實(shí)參)博其。
3.2.1 設(shè)置鍵值對(duì)
在 Redis 中調(diào)用 Lua 腳本執(zhí)行 Redis 命令
以上命令等價(jià)于 set gupao 2673
在 redis-cli 中直接寫 Lua 腳本不夠方便,也不能實(shí)現(xiàn)編輯和復(fù)用迂猴,通常我們會(huì)把腳本放在文件里面慕淡,然后執(zhí)行這個(gè)文件。
3.2.2 在 Redis 中調(diào)用 Lua 腳本文件中的命令沸毁, 操作 Redis
創(chuàng)建 Lua 腳本文件:
cd /usr/local/soft/redis5.0.5/src
vim gupao.lua
Lua 腳本內(nèi)容峰髓,先設(shè)置,再取值:
redis.call('set','gupao','lua666')
return redis.call('get','gupao')
在 Redis 客戶端中調(diào)用 Lua 腳本
cd /usr/local/soft/redis5.0.5/src
redis-cli --eval gupao.lua 0
得到返回值:
[root@localhost src]# redis-cli --eval gupao.lua 0
"lua666"
3.2.3 案例: 對(duì) IP 進(jìn)行限流
需求:某個(gè)IP息尺,在 X 秒內(nèi)只能訪問(wèn) Y 次携兵。
設(shè)計(jì)思路:用 key 記錄 IP,用 value 記錄訪問(wèn)次數(shù)搂誉。拿到 IP 以后徐紧,對(duì) IP+1。如果是第一次訪問(wèn)炭懊,對(duì) key 設(shè)置過(guò)期時(shí)間(參數(shù) 1)并级。否則判斷次數(shù),超過(guò)限定的次數(shù)(參數(shù) 2)侮腹,返回 0嘲碧。如果沒(méi)有超過(guò)次數(shù)則返回 1。超過(guò)時(shí)間父阻,key 過(guò)期之后愈涩,可以再次訪問(wèn)。KEY[1]是 IP加矛, ARGV[1]是過(guò)期時(shí)間 X履婉,ARGV[2]是限制訪問(wèn)的次數(shù) Y。
-- ip_limit.lua
-- IP 限流斟览, 對(duì)某個(gè) IP 頻率進(jìn)行限制 谐鼎, 6 秒鐘訪問(wèn) 10 次
local num=redis.call('incr',KEYS[1])
if tonumber(num)==1 then
redis.call('expire',KEYS[1],ARGV[1])
return 1
elseif tonumber(num)>tonumber(ARGV[2]) then
return 0
else
return 1
end
6 秒鐘內(nèi)限制訪問(wèn) 10 次,調(diào)用測(cè)試(連續(xù)調(diào)用 10 次):
./redis-cli --eval "ip_limit.lua" app:ip:limit:192.168.8.111 , 6 10
? app:ip:limit:192.168.8.111 是 key 值 趣惠,后面是參數(shù)值,中間要加上一個(gè)空格 和
一個(gè)逗號(hào)身害,再加上一個(gè) 空格 味悄。
即:./redis-cli –eval [lua 腳本] [key…]空格,空格[args…]
? 多個(gè)參數(shù)之間用一個(gè) 空格 分割
3.2.4 緩存 Lua 腳本
為什么要緩存
在腳本比較長(zhǎng)的情況下,如果每次調(diào)用腳本都需要把整個(gè)腳本傳給 Redis 服務(wù)端塌鸯,會(huì)產(chǎn)生比較大的網(wǎng)絡(luò)開銷侍瑟。為了解決這個(gè)問(wèn)題,Redis 提供了 EVALSHA 命令,允許開發(fā)者通過(guò)腳本內(nèi)容的 SHA1 摘要來(lái)執(zhí)行腳本涨颜。
如何緩存
Redis 在執(zhí)行 script load 命令時(shí)會(huì)計(jì)算腳本的 SHA1 摘要并記錄在腳本緩存中费韭,執(zhí)行 EVALSHA 命令時(shí) Redis 會(huì)根據(jù)提供的摘要從腳本緩存中查找對(duì)應(yīng)的腳本內(nèi)容,如果找到了則執(zhí)行腳本庭瑰,否則會(huì)返回錯(cuò)誤:"NOSCRIPT No matching script. Please use EVAL."
127.0.0.1:6379> script load "return 'Hello World'"
"470877a599ac74fbfda41caa908de682c5fc7d4b"
127.0.0.1:6379> evalsha "470877a599ac74fbfda41caa908de682c5fc7d4b" 0
"Hello World"
自乘案例
Redis 有 incrby 這樣的自增命令星持,但是沒(méi)有自乘,比如乘以 3弹灭,乘以 5督暂。
我們可以寫一個(gè)自乘的運(yùn)算,讓它乘以后面的參數(shù):
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
把這個(gè)腳本變成單行穷吮,語(yǔ)句之間使用分號(hào)隔開
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 '命令'
127.0.0.1:6379> 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'
"be4f93d8a5379e5e5b768a74e77c8a4eb0434441"
調(diào)用:
127.0.0.1:6379> set num 2
OK
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 num 6
(integer) 12
3.2.5 腳本超時(shí)
Redis 的指令執(zhí)行本身是單線程的逻翁,這個(gè)線程還要執(zhí)行客戶端的 Lua 腳本,如果 Lua腳本執(zhí)行超時(shí)或者陷入了死循環(huán)捡鱼,是不是沒(méi)有辦法為客戶端提供服務(wù)了呢八回?
eval 'while(true) do end' 0
為 了防 止 某個(gè) 腳本 執(zhí) 行時(shí) 間 過(guò)長(zhǎng) 導(dǎo) 致 Redis 無(wú) 法提 供 服務(wù) , Redis 提 供 了lua-time-limit 參數(shù)限制腳本的最長(zhǎng)運(yùn)行時(shí)間驾诈,默認(rèn)為 5 秒鐘缠诅。
lua-time-limit 5000(redis.conf 配置文件中)
當(dāng)腳本運(yùn)行時(shí)間超過(guò)這一限制后,Redis 將開始接受其他命令但不會(huì)執(zhí)行(以確保腳本的原子性翘鸭,因?yàn)榇藭r(shí)腳本并沒(méi)有被終止)滴铅,而是會(huì)返回“BUSY”錯(cuò)誤。
Redis 提供了一個(gè) script kill 的命令來(lái)中止腳本的執(zhí)行就乓。新開一個(gè)客戶端:
script kill
如果當(dāng)前執(zhí)行的 Lua 腳本對(duì) Redis 的數(shù)據(jù)進(jìn)行了修改(SET汉匙、DEL 等),那么通過(guò)script kill 命令是不能終止腳本運(yùn)行的生蚁。
127.0.0.1:6379> eval "redis.call('set','gupao','666') while true do end" 0
因?yàn)橐WC腳本運(yùn)行的原子性噩翠,如果腳本執(zhí)行了一部分終止,那就違背了腳本原子性的要求邦投。最終要保證腳本要么都執(zhí)行伤锚,要么都不執(zhí)行。
127.0.0.1:6379> script kill
(error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.
遇到這種情況志衣,只能通過(guò) shutdown nosave 命令來(lái)強(qiáng)行終止 redis屯援。
shutdown nosave 和 shutdown 的區(qū)別在于 shutdown nosave 不會(huì)進(jìn)行持久化操作,意味著發(fā)生在上一次快照后的數(shù)據(jù)庫(kù)修改都會(huì)丟失念脯。
總結(jié):如果我們有一些特殊的需求狞洋,可以用 Lua 來(lái)實(shí)現(xiàn),但是要注意那些耗時(shí)的操作绿店。