目標
1、學習 Redis 的一些高級特性,包括發布訂閱、事務、Lua 腳本
1、發布訂閱模式
1.1列表的局限
前面我們說通過隊列的 rpush 和 lpop 可以實現消息隊列(隊尾進隊頭出),但是消費者需要不停地調用 lpop 查看 List 中是否有等待處理的消息(比如寫一個 while 循環)。
為了減少通信的消耗,可以 sleep()一段時間再消費,但是會有兩個問題:
- 如果生產者生產消息的速度遠大于消費者消費消息的速度,List 會占用大量的內存。
- 消息的實時性降低
list 還提供了一個阻塞的命令:blpop,沒有任何元素可以彈出的時候,連接會被阻塞。blpop queue 5
,阻塞5秒。
基于 list 實現的消息隊列,不支持一對多的消息分發。
1.2發布訂閱模式
除了通過 list 實現消息隊列之外,Redis 還提供了一組命令實現發布/訂閱模式。
這種方式,發送者和接收者沒有直接關聯(實現了解耦),接收者也不需要持續嘗試獲取消息。
1.2.1 訂閱頻道
可以訂閱一個或者多個頻道。消息的發布者(生產者)可以給指定的頻道發布消息。只要有消息到達了頻道,所有訂閱了這個頻道的訂閱者都會收到這條消息。
需要注意的注意是,發出去的消息不會被持久化,因為它已經從隊列里面移除了,所以消費者只能收到它開始訂閱這個頻道之后發布的消息。
下面我們來看一下發布訂閱命令的使用方法。
訂閱者訂閱頻道:可以一次訂閱多個,比如這個客戶端訂閱了 3 個頻道。
subscribe channel-1 channel-2 channel-3
發布者可以向指定頻道發布消息(并不支持一次向多個頻道發送消息):
publish channel-1 2673
取消訂閱(不能在訂閱狀態下使用):
unsubscribe channel-1
1.2.2 按規則(Pattern) 訂閱頻道
支持?和占位符。?代表一個字符,代表 0 個或者多個字符。
消費端 1,關注運動信息:
psubscribe *sport
消費端 2,關注所有新聞:
psubscribe news*
消費端 3,關注天氣新聞:
psubscribe news-weather
生產者,發布 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 事務
2.1 為什么要用事務
我們知道 Redis 的單個命令是原子性的(比如 get set mget mset),如果涉及到多個命令的時候,需要把多個命令作為一個不可分割的處理序列,就需要用到事務。
例如我們之前說的用 setnx 實現分布式鎖,我們先 set,然后設置對 key 設置 expire,防止 del 發生異常的時候鎖不會被釋放,業務處理完了以后再 del,這三個動作我們就希望它們作為一組命令執行。
Redis 的事務有兩個特點:
- 按進入隊列的順序執行。
- 不會受到其他客戶端的請求的影響。
Redis 的事務涉及到四個命令:multi(開啟事務),exec(執行事務),discard(取消事務),watch(監視)
2.2 事務的用法
案例場景:tom 和 mic 各有 1000 元,tom 需要向 mic 轉賬 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"
通過 multi 的命令開啟事務。事務不能嵌套,多個 multi 命令效果一樣。
multi 執行后,客戶端可以繼續向服務器發送任意多條命令, 這些命令不會立即被執行, 而是被放到一個隊列中, 當 exec 命令被調用時, 所有隊列中的命令才會被執行。
通過 exec 的命令執行事務。如果沒有執行 exec,所有的命令都不會被執行。如果中途不想執行事務了,怎么辦?可以調用 discard 可以清空事務隊列,放棄執行。
multi
set k1 1
set k2 2
set k3 3
discard
2.3 watch 命令
在 Redis 中還提供了一個 watch 命令。
它可以為 Redis 事務提供 CAS 樂觀鎖行為(Check and Set / Compare and Swap),也就是多個線程更新變量的時候,會跟原值做比較,只有它沒有被其他線程修改的情況下,才更新成新的值。
我們可以用 watch 監視一個或者多個 key,如果開啟事務之后,至少有一個被監視key 鍵在 exec 執行之前被修改了, 那么整個事務都會被取消(key 提前過期除外)。可以用 unwatch 取消。
2.4 事務可能遇到的問題
我們把事務執行遇到的問題分成兩種,一種是在執行 exec 之前發生錯誤,一種是在執行 exec 之后發生錯誤。
2.4.1 在執行 exec 之前發生錯誤
比如:入隊的命令存在語法錯誤,包括參數數量,參數名等等(編譯器錯誤)。
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.
在這種情況下事務會被拒絕執行,也就是隊列中所有的命令都不會得到執行。
2.4.2 在執行 exec 之后發生錯誤
比如,類型錯誤,比如對 String 使用了 Hash 的命令,這是一種運行時錯誤。
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"
最后我們發現 set k1 1 的命令是成功的,也就是在這種發生了運行時異常的情況下,只有錯誤的命令沒有被執行,但是其他命令沒有受到影響。
這個顯然不符合我們對原子性的定義,也就是我們沒辦法用 Redis 的這種事務機制來實現原子性,保證數據的一致。
思考(作業):
為什么在一個事務中存在錯誤,Redis 不回滾?
3 Lua 腳本
Lua是一種輕量級腳本語言,它是用 C 語言編寫的,跟數據的存儲過程有點類似。 使用 Lua 腳本來執行 Redis 命令的好處:
- 一次發送多個命令,減少網絡開銷。
- Redis 會將整個腳本作為一個整體執行,不會被其他請求打斷,保持原子性。
- 對于復雜的組合命令,我們可以放在文件中,可以實現程序之間的命令集復用。
3.1 在 Redis 中調用 Lua 腳本
使用 eval 方法,語法格式:
redis> 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 語言, 它們是可填可不填的。
示例,返回一個字符串,0 個參數:
redis> eval "return 'Hello World'" 0
3.2 在 Lua 腳本中調用 Redis 命令
使用 redis.call(command, key [param1, param2…])進行操作。語法格式:
redis> 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 表示參數值(實參)。
3.2.1 設置鍵值對
在 Redis 中調用 Lua 腳本執行 Redis 命令
以上命令等價于 set gupao 2673
在 redis-cli 中直接寫 Lua 腳本不夠方便,也不能實現編輯和復用,通常我們會把腳本放在文件里面,然后執行這個文件。
3.2.2 在 Redis 中調用 Lua 腳本文件中的命令, 操作 Redis
創建 Lua 腳本文件:
cd /usr/local/soft/redis5.0.5/src
vim gupao.lua
Lua 腳本內容,先設置,再取值:
redis.call('set','gupao','lua666')
return redis.call('get','gupao')
在 Redis 客戶端中調用 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 案例: 對 IP 進行限流
需求:某個IP,在 X 秒內只能訪問 Y 次。
設計思路:用 key 記錄 IP,用 value 記錄訪問次數。拿到 IP 以后,對 IP+1。如果是第一次訪問,對 key 設置過期時間(參數 1)。否則判斷次數,超過限定的次數(參數 2),返回 0。如果沒有超過次數則返回 1。超過時間,key 過期之后,可以再次訪問。KEY[1]是 IP, ARGV[1]是過期時間 X,ARGV[2]是限制訪問的次數 Y。
-- ip_limit.lua
-- IP 限流, 對某個 IP 頻率進行限制 , 6 秒鐘訪問 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 秒鐘內限制訪問 10 次,調用測試(連續調用 10 次):
./redis-cli --eval "ip_limit.lua" app:ip:limit:192.168.8.111 , 6 10
? app:ip:limit:192.168.8.111 是 key 值 ,后面是參數值,中間要加上一個空格 和
一個逗號,再加上一個 空格 。
即:./redis-cli –eval [lua 腳本] [key…]空格,空格[args…]
? 多個參數之間用一個 空格 分割
3.2.4 緩存 Lua 腳本
為什么要緩存
在腳本比較長的情況下,如果每次調用腳本都需要把整個腳本傳給 Redis 服務端,會產生比較大的網絡開銷。為了解決這個問題,Redis 提供了 EVALSHA 命令,允許開發者通過腳本內容的 SHA1 摘要來執行腳本。
如何緩存
Redis 在執行 script load 命令時會計算腳本的 SHA1 摘要并記錄在腳本緩存中,執行 EVALSHA 命令時 Redis 會根據提供的摘要從腳本緩存中查找對應的腳本內容,如果找到了則執行腳本,否則會返回錯誤:"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 這樣的自增命令,但是沒有自乘,比如乘以 3,乘以 5。
我們可以寫一個自乘的運算,讓它乘以后面的參數:
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 '命令'
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"
調用:
127.0.0.1:6379> set num 2
OK
127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 num 6
(integer) 12
3.2.5 腳本超時
Redis 的指令執行本身是單線程的,這個線程還要執行客戶端的 Lua 腳本,如果 Lua腳本執行超時或者陷入了死循環,是不是沒有辦法為客戶端提供服務了呢?
eval 'while(true) do end' 0
為 了防 止 某個 腳本 執 行時 間 過長 導 致 Redis 無 法提 供 服務 , Redis 提 供 了lua-time-limit 參數限制腳本的最長運行時間,默認為 5 秒鐘。
lua-time-limit 5000(redis.conf 配置文件中)
當腳本運行時間超過這一限制后,Redis 將開始接受其他命令但不會執行(以確保腳本的原子性,因為此時腳本并沒有被終止),而是會返回“BUSY”錯誤。
Redis 提供了一個 script kill 的命令來中止腳本的執行。新開一個客戶端:
script kill
如果當前執行的 Lua 腳本對 Redis 的數據進行了修改(SET、DEL 等),那么通過script kill 命令是不能終止腳本運行的。
127.0.0.1:6379> eval "redis.call('set','gupao','666') while true do end" 0
因為要保證腳本運行的原子性,如果腳本執行了一部分終止,那就違背了腳本原子性的要求。最終要保證腳本要么都執行,要么都不執行。
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.
遇到這種情況,只能通過 shutdown nosave 命令來強行終止 redis。
shutdown nosave 和 shutdown 的區別在于 shutdown nosave 不會進行持久化操作,意味著發生在上一次快照后的數據庫修改都會丟失。
總結:如果我們有一些特殊的需求,可以用 Lua 來實現,但是要注意那些耗時的操作。