Redis原理篇
1.發布 訂閱模式
1.1列表 的局限
前面我們說通過隊列的 rpush 和 lpop 可以實現消息隊列(隊尾進隊頭出),但是消費者需要不停地調用 lpop 查看 List 中是否有等待處理的消息(比如寫一個 while 循環)。為了減少通信的消耗,可以 sleep()一段時間再消費,但是會有兩個問題:
1、如果生產者生產消息的速度遠大於消費者消費消息的速度,List 會占用大量的內存。
2、消息的實時性降低。
list 還提供了一個阻塞的命令:blpop,沒有任何元素可以彈出的時候,連接會被阻塞。
blpop queue 5
基於 list 實現的消息隊列,不支持一對多的消息分發.
1.2發布訂閱 模式
除了通過 list 實現消息隊列之外,Redis 還提供了一組命令實現發布/訂閱模式。這種方式,發送者和接收者沒有直接關聯(實現了解耦),接收者也不需要持續嘗試獲取消息。
1.2.1訂閱 頻道
首先,我們有很多的頻道(channel),我們也可以把這個頻道理解成 queue。訂閱者可以訂閱一個或者多個頻道。消息的發布者(生產者)可以給指定的頻道發布消息。只要有消息到達了頻道,所有訂閱了這個頻道的訂閱者都會收到這條消息。
需要注意的注意是,發出去的消息不會被持久化,因為它已經從隊列里面移除了,所以消費者只能收到它開始訂閱這個頻道之后發布的消息。
下面我們來看一下發布訂閱命令的使用方法。
訂閱者訂閱頻道:可以一次訂閱多個,比如這個客戶端訂閱了 3 個頻道
subscribe channel-1 channel-2 channel-3
發布者可以向指定頻道發布消息(並不支持一次向多個頻道發送消息):
publish channel-1 2673
取消訂閱(不能在訂閱狀態下使用):
unsubscribe channel-1
1.2.2按規則(P P attern ) 訂閱頻道
支持?和占位符。?代表一個字符,代表 0 個或者多個字符。消費端 1,關注運動信息:
psubscribe *sport
消費端 2,關注所有新聞:
psubscribe news*
消費端 3,關注天氣新聞:
psubscribe news-weather
生產者,發布 3 條信息
publish news-sport yaoming
publish news-music jaychou
publish news-weather rain
import redis.clients.jedis.Jedis;
public class PublishTest {
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
jedis.publish("qingshan-123", "666");
jedis.publish("qingshan-abc", "pengyuyan");
}
}
2 Redis事務
先看官網
https://redis.io/topics/transactions/
http://redisdoc.com/topic/transaction.html
2.1 為什么要用事務
我們知道 Redis 的單個命令是原子性的(比如 get set mget mset),如果涉及到多個命令的時候,需要把多個命令作為一個不可分割的處理序列,就需要用到事務。
例如我們之前說的用 setnx 實現分布式鎖,我們先 set,然后設置對 key 設置 expire,防止 del 發生異常的時候鎖不會被釋放,業務處理完了以后再 del,這三個動作我們就希望它們作為一組命令執行。
Redis 的事務有兩個特點
1、按進入隊列的順序執行。
2、不會受到其他客戶端的請求的影響。
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 取消。
client 1 | client 2 |
---|---|
127.0.0.1:6379> set balance 1000 OK 127.0.0.1:6379> watch balance OK 127.0.0.1:6379> multi OK 127.0.0.1:6379> incrby balance 100 QUEUED |
|
127.0.0.1:6379> decrby balance 100 (integer) 900 |
|
127.0.0.1:6379> exec (nil) 127.0.0.1:6379> get balance "900" |
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 的這種事務機制來實現原子性,保證數據的一致。
3 Lua 腳本
Lua/ˈluə/是一種輕量級腳本語言,它是用 C 語言編寫的,跟數據的存儲過程有點類似。 使用 Lua 腳本來執行 Redis 命令的好處:
1、一次發送多個命令,減少網絡開銷。
2、Redis 會將整個腳本作為一個整體執行,不會被其他請求打斷,保持原子性。
3、對於復雜的組合命令,我們可以放在文件中,可以實現程序之間的命令集復用。
3.1 在 Redis 中 調用 Lua 腳本
使用 eval /ɪ'væl/ 方法,語法格式:
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 命令
redis> eval "return redis.call('set',KEYS[1],ARGV[1])" 1 sunda 2673
redis> get sunda
以上命令等價於 set sunda 2673
在 redis-cli 中直接寫 Lua 腳本不夠方便,也不能實現編輯和復用,通常我們會把腳本放在文件里面,然后執行這個文件。
3.2.2 在 Redis 中調用 Lua 腳本 文件中的命令 , 操作
創建 Lua 腳本文件:
cd /usr/local/soft/redis5.0.5/src
vim gupao.lua
Lua 腳本內容,先設置,再取值:
redis.call('set','sunda','lua666')
return redis.call('get','sunda')
在 Redis 客戶端中調用 Lua 腳本
cd /usr/local/soft/redis5.0.5/src
redis-cli --eval sunda.lua 0
得到返回值
[root@localhost src]# redis-cli --eval sunda.lua 0
"lua666"
3.2.3 案例 :對 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…]
- 多個參數之間用一個 空格 分割 。
package lua;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import util.ResourceUtil;
import java.util.Arrays;
public class LuaTest {
public static void main(String[] args) {
Jedis jedis = getJedisUtil();
jedis.eval("return redis.call('set',KEYS[1],ARGV[1])", 1,"test:lua:key","qingshan2673lua");
System.out.println(jedis.get("test:lua:key"));
for(int i=0; i<10; i++){
limit();
}
}
/**
* 10秒內限制訪問5次
*/
public static void limit(){
Jedis jedis = getJedisUtil();
// 只在第一次對key設置過期時間
String lua = "local num = redis.call('incr', KEYS[1])\n" +
"if tonumber(num) == 1 then\n" +
"\tredis.call('expire', KEYS[1], ARGV[1])\n" +
"\treturn 1\n" +
"elseif tonumber(num) > tonumber(ARGV[2]) then\n" +
"\treturn 0\n" +
"else \n" +
"\treturn 1\n" +
"end\n";
Object result = jedis.evalsha(jedis.scriptLoad(lua), Arrays.asList("localhost"), Arrays.asList("10", "5"));
System.out.println(result);
}
private static Jedis getJedisUtil() {
String ip = ResourceUtil.getKey("redis.host");
int port = Integer.valueOf(ResourceUtil.getKey("redis.port"));
String password = ResourceUtil.getKey("redis.password");
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
JedisPool pool = new JedisPool(jedisPoolConfig, ip, port, 10000, password);
return pool.getResource();
}
}
3.2.4 緩存 Lua腳本
為什么要 緩存
在腳本比較長的情況下,如果每次調用腳本都需要把整個腳本傳給 Redis 服務端,會產生比較大的網絡開銷。為了解決這個問題,Redis 提供了 EVALSHA 命令,允許開發者通過腳本內容的 SHA1 摘要來執行腳本。
如何緩存
Redis 在執行 script load 命令時會計算腳本的 SHA1 摘要並記錄在腳本緩存中,執行 EVALSHA 命令時 Redis 會根據提供的摘要從腳本緩存中查找對應的腳本內容,如果找到了則執行腳本,否則會返回錯誤:"NOSCRIPT No matching script. Please useEVAL."
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 不會進行持久化操作,意味着發生在上一次快照后的數據庫修改都會丟失。
4 Redis 為什么 這么快?
4.1 Redis 到底有多快
https://redis.io/topics/benchmarks
cd /usr/local/soft/redis-5.0.5/src
redis-benchmark -t set,lpush -n 100000 -q
結果(本地虛擬機):
SET: 51813.47 requests per second —— 每秒鍾處理 5 萬多次 set 請求
LPUSH: 51706.31 requests per second —— 每秒鍾處理 5 萬多次 lpush 請求
redis-benchmark -n 100000 -q script load "redis.call('set','foo','bar')"
結果(本地虛擬機):
script load redis.call('set','foo','bar'): 46816.48 requests per second —— 每秒鍾 46000 次 lua腳本調用
根據官方的數據,Redis 的 QPS 可以達到 10 萬左右(每秒請求數)。
4.2 Redis 為什么這么 快?
總結:1)純內存結構、2)單線程、3)多路復用
4.2.1內存
KV 結構的內存數據庫,時間復雜度 O(1)。
第二個,要實現這么高的並發性能,是不是要創建非常多的線程?恰恰相反,Redis 是單線程的。
4.2.2 單線程
單線程有什么好處呢?
1、沒有創建線程、銷毀線程帶來的消耗
2、避免了上線文切換導致的 CPU 消耗
3、避免了線程之間帶來的競爭問題,例如加鎖釋放鎖死鎖等等
4.2.3 異步 非阻塞
異步非阻塞 I/O,多路復用處理並發連接
4.3 Redis 為什么是單線程
不是白白浪費了 CPU 的資源嗎?
https://redis.io/topics/faq#redis-is-single-threaded-how-can-i-exploit-multiple-cpu--cores
因為單線程已經夠用了,CPU 不是 redis 的瓶頸。Redis 的瓶頸最有可能是機器內存或者網絡帶寬。既然單線程容易實現,而且 CPU 不會成為瓶頸,那就順理成章地采用單線程的方案了
4.4 單線程為什么 這么快?
因為 Redis 是基於內存的操作,我們先從內存開始說起。
4.4.1虛擬存儲器 ( 虛擬 內存 l Vitual Memory )
名詞解釋:主存:內存;輔存:磁盤(硬盤)
計算機主存(內存)可看作一個由 M 個連續的字節大小的單元組成的數組,每個字節有一個唯一的地址,這個地址叫做物理地址(PA)。早期的計算機中,如果 CPU 需要內存,使用物理尋址,直接訪問主存儲器
這種方式有幾個弊端:
1、在多用戶多任務操作系統中,所有的進程共享主存,如果每個進程都獨占一塊物理地址空間,主存很快就會被用完。我們希望在不同的時刻,不同的進程可以共用同一塊物理地址空間。
2、如果所有進程都是直接訪問物理內存,那么一個進程就可以修改其他進程的內存數據,導致物理地址空間被破壞,程序運行就會出現異常。
為了解決這些問題,我們就想了一個辦法,在 CPU 和主存之間增加一個中間層。CPU不再使用物理地址訪問,而是訪問一個虛擬地址,由這個中間層把地址轉換成物理地址,最終獲得數據。這個中間層就叫做虛擬存儲器(Virtual Memory)。具體的操作如下所示:
在每一個進程開始創建的時候,都會分配一段虛擬地址,然后通過虛擬地址和物理地址的映射來獲取真實數據,這樣進程就不會直接接觸到物理地址,甚至不知道自己調用的哪塊物理地址的數據。
目前,大多數操作系統都使用了虛擬內存,如 Windows 系統的虛擬內存、Linux 系統的交換空間等等。Windows 的虛擬內存(pagefile.sys)是磁盤空間的一部分。
在 32 位的系統上,虛擬地址空間大小是 2^32bit=4G。在 64 位系統上,最大虛擬地址空間大小是多少?是不是 2^64bit=1024*1014TB=1024PB=16EB?實際上沒有用到 64 位,因為用不到這么大的空間,而且會造成很大的系統開銷。Linux 一般用低48 位來表示虛擬地址空間,也就是 2^48bit=256T。
cat /proc/cpuinfo
address sizes : 40 bits physical, 48 bits virtual
實際的物理內存可能遠遠小於虛擬內存的大小。
總結:引入虛擬內存,可以提供更大的地址空間,並且地址空間是連續的,使得程序編寫、鏈接更加簡單。並且可以對物理內存進行隔離,不同的進程操作互不影響。還可以通過把同一塊物理內存映射到不同的虛擬地址空間實現內存共享。
4.4.2 用戶空間 和內核空間
為了避免用戶進程直接操作內核,保證內核安全,操作系統將虛擬內存划分為兩部分,一部分是內核空間(Kernel-space)/ˈkɜːnl /,一部分是用戶空間(User-space)
內核是操作系統的核心,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的權限。
內核空間中存放的是內核代碼和數據,而進程的用戶空間中存放的是用戶程序的代碼和數據。不管是內核空間還是用戶空間,它們都處於虛擬空間中,都是對物理地址的映射。
在 Linux 系統中, 內核進程和用戶進程所占的虛擬內存比例是 1:3。
當進程運行在內核空間時就處於內核態,而進程運行在用戶空間時則處於用戶態。
進程在內核空間以執行任意命令,調用系統的一切資源;在用戶空間只能執行簡單的運算,不能直接調用系統資源,必須通過系統接口(又稱 system call),才能向內核發出指令。
top 命令:
us 代表 CPU 消耗在 User space 的時間百分比;
sy 代表 CPU 消耗在 Kernel space 的時間百分比。
4.4.3進程切換(上下文 切換 )
多任務操作系統是怎么實現運行遠大於 CPU 數量的任務個數的?當然,這些任務實際上並不是真的在同時運行,而是因為系統通過時間片分片算法,在很短的時間內,將CPU 輪流分配給它們,造成多任務同時運行的錯覺。
為了控制進程的執行,內核必須有能力掛起正在 CPU 上運行的進程,並恢復以前掛起的某個進程的執行。這種行為被稱為進程切換。
什么叫上下文?
在每個任務運行前,CPU 都需要知道任務從哪里加載、又從哪里開始運行,也就是說,需要系統事先幫它設置好 CPU 寄存器和程序計數器(Program Counter),這個叫做CPU 的上下文。
而這些保存下來的上下文,會存儲在系統內核中,並在任務重新調度執行時再次加載進來。這樣就能保證任務原來的狀態不受影響,讓任務看起來還是連續運行。
在切換上下文的時候,需要完成一系列的工作,這是一個很消耗資源的操作。
4.4.4 進程 的阻塞
正在運行的進程由於提出系統服務請求(如 I/O 操作),但因為某種原因未得到操作系統的立即響應,該進程只能把自己變成阻塞狀態,等待相應的事件出現后才被喚醒。進程在阻塞狀態不占用 CPU 資源。
4.4.5 文件 描述符
Linux 系統將所有設備都當作文件來處理,而 Linux 用文件描述符來標識每個文件對象。
文件描述符(File Descriptor)是內核為了高效管理已被打開的文件所創建的索引,用於指向被打開的文件,所有執行 I/O 操作的系統調用都通過文件描述符;文件描述符是一個簡單的非負整數,用以表明每個被進程打開的文件。
Linux 系統里面有三個標准文件描述符。
0:標准輸入(鍵盤);1:標准輸出(顯示器);2:標准錯誤輸出(顯示器)。
4.4.6 傳統 I/O 數據拷貝
以讀操作為例:
當應用程序執行 read 系統調用讀取文件描述符(FD)的時候,如果這塊數據已經存在於用戶進程的頁內存中,就直接從內存中讀取數據。如果數據不存在,則先將數據從磁盤加載數據到內核緩沖區中,再從內核緩沖區拷貝到用戶進程的頁內存中。(兩次拷貝,兩次 user 和 kernel 的上下文切換)。
I/O 的阻塞到底阻塞在哪里?
4.4.7 Blocking I/O
當使用 read 或 write 對某個文件描述符進行過讀寫時,如果當前 FD 不可讀,系統就不會對其他的操作做出響應。從設備復制數據到內核緩沖區是阻塞的,從內核緩沖區拷貝到用戶空間,也是阻塞的,直到 copy complete,內核返回結果,用戶進程才解除block 的狀態。
為了解決阻塞的問題,我們有幾個思路。
1、在服務端創建多個線程或者使用線程池,但是在高並發的情況下需要的線程會很多,系統無法承受,而且創建和釋放線程都需要消耗資源。
2、由請求方定期輪詢,在數據准備完畢后再從內核緩存緩沖區復制數據到用戶空間(非阻塞式 I/O),這種方式會存在一定的延遲。
能不能用一個線程處理多個客戶端請求?
4.4.8 I/O 多路 復用 ( I/O Multiplexing )
I/O 指的是網絡 I/O。
多路指的是多個 TCP 連接(Socket 或 Channel)。
復用指的是復用一個或多個線程。
它的基本原理就是不再由應用程序自己監視連接,而是由內核替應用程序監視文件描述符。
客戶端在操作的時候,會產生具有不同事件類型的 socket。在服務端,I/O 多路復用程序(I/O Multiplexing Module)會把消息放入隊列中,然后通過文件事件分派器(Fileevent Dispatcher),轉發到不同的事件處理器中。
多路復用有很多的實現,以 select 為例,當用戶進程調用了多路復用器,進程會被阻塞。內核會監視多路復用器負責的所有 socket,當任何一個 socket 的數據准備好了,多路復用器就會返回。這時候用戶進程再調用 read 操作,把數據從內核緩沖區拷貝到用戶空間。
所以,I/O 多路復用的特點是通過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒(readable)狀態,select()函數就可以返回。
Redis 的多路復用, 提供了 select, epoll, evport, kqueue 幾種選擇,在編譯的時候來選擇一種。源碼 ae.c
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif
evport 是 Solaris 系統內核提供支持的;
epoll 是 LINUX 系統內核提供支持的;
kqueue 是 Mac 系統提供支持的;
select 是 POSIX 提供的,一般的操作系統都有支撐(保底方案);
源碼 ae_epoll.c、ae_select.c、ae_kqueue.c、ae_evport.c
5內存回收
Reids 所有的數據都是存儲在內存中的,在某些情況下需要對占用的內存空間進行回收。內存回收主要分為兩類,一類是 key 過期,一類是內存使用達到上限(max_memory)觸發內存淘汰。
5.1 過期策略
要實現 key 過期,我們有幾種思路。
5.1.1 定時過期(主動 淘汰 )
每個設置過期時間的 key 都需要創建一個定時器,到過期時間就會立即清除。該策略可以立即清除過期的數據,對內存很友好;但是會占用大量的 CPU 資源去處理過期的數據,從而影響緩存的響應時間和吞吐量。
5.1.2惰性過期(被動 淘汰 )
只有當訪問一個 key 時,才會判斷該 key 是否已過期,過期則清除。該策略可以最大化地節省 CPU 資源,卻對內存非常不友好。極端情況可能出現大量的過期 key 沒有再次被訪問,從而不會被清除,占用大量內存。
例如 String,在 getCommand 里面會調用 expireIfNeeded
server.c expireIfNeeded(redisDb *db, robj *key)
第二種情況,每次寫入 key 時,發現內存不夠,調用 activeExpireCycle 釋放一部分內存。
expire.c activeExpireCycle(int type)
5.1.3定期過期
typedef struct redisDb {
dict *dict; /* 所有的鍵值對 */
dict *expires; /* 設置了過期時間的鍵值對 */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
每隔一定的時間,會掃描一定數量的數據庫的 expires 字典中一定數量的 key,並清除其中已過期的 key。該策略是前兩者的一個折中方案。通過調整定時掃描的時間間隔和每次掃描的限定耗時,可以在不同情況下使得 CPU 和內存資源達到最優的平衡效果。
Redis 中同時使用了惰性過期和定期過期兩種過期策略.
5.2淘汰 策略
Redis 的內存淘汰策略,是指當內存使用達到最大內存極限時,需要使用淘汰算法來決定清理掉哪些數據,以保證新數據的存入。
5.2.1 最大 內存設置
redis.conf 參數配置:
# maxmemory <bytes>
如果不設置 maxmemory 或者設置為 0,64 位系統不限制內存,32 位系統最多使
用 3GB 內存。
動態修改:
redis> config set maxmemory 2Gg
5.2.2淘汰 策略
https://redis.io/topics/lru-cache
redis.conf
# maxmemory-policy noeviction
# volatile-lru -> Evict using approximated LRU among the keys with an expire set.
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU among the keys with an expire set.
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key among the ones with an expire set.
# allkeys-random -> Remove a random key, any key.
# volatile-ttl -> Remove the key with the nearest expire time (minor TTL)
# noeviction -> Don't evict anything, just return an error on write operations.
先從算法來看:
LRU,Least Recently Used:最近最少使用。判斷最近被使用的時間,目前最遠的數據優先被淘汰。
LFU,Least Frequently Used,最不常用,4.0 版本新增。random,隨機刪除。
策略 | 含義 |
---|---|
volatile-lru | 根據 LRU 算法刪除設置了超時屬性(expire)的鍵,直到騰出足夠內存為止。如果沒有 可刪除的鍵對象,回退到 noeviction 策略。 |
allkeys-lru | 根據 LRU 算法刪除鍵,不管數據有沒有設置超時屬性,直到騰出足夠內存為止。 |
volatile-lfu | 在帶有過期時間的鍵中選擇最不常用的。 |
allkeys-lfu | 在所有的鍵中選擇最不常用的,不管數據有沒有設置超時屬性。 |
volatile-random | 在帶有過期時間的鍵中隨機選擇。 |
allkeys-random | 隨機刪除所有鍵,直到騰出足夠內存為止。 |
volatile-ttl | 根據鍵值對象的 ttl 屬性,刪除最近將要過期數據。如果沒有,回退到 noeviction 策略。 |
noeviction | 默認策略,不會刪除任何數據,拒絕所有寫入操作並返回客戶端錯誤信息(error)OOM command not allowed when used memory,此時 Redis 只響應讀操作 |
如果沒有符合前提條件的 key 被淘汰,那么 volatile-lru、volatile-random 、volatile-ttl 相當於 noeviction(不做內存回收)。
** 動態修改淘汰策略:**
redis> config set maxmemory-policy volatile-lru
建議使用 volatile-lru,在保證正常服務的情況下,優先刪除最近最少使用的 key。
5.2.3 LRU 淘汰原理
問題:如果基於傳統 LRU 算法實現 Redis LRU 會有什么問題?
需要額外的數據結構存儲,消耗內存。
Redis LRU 對傳統的 LRU 算法進行了改良,通過隨機采樣來調整算法的精度。
如果淘汰策略是 LRU,則根據配置的采樣值 maxmemory_samples(默認是 5 個),隨機從數據庫中選擇 m 個 key, 淘汰其中熱度最低的 key 對應的緩存數據。所以采樣參數m配置的數值越大, 就越能精確的查找到待淘汰的緩存數據,但是也消耗更多的CPU計算,執行效率降低。
問題:如何找出熱度最低的數據?
Redis 中所有對象結構都有一個 lru 字段, 且使用了 unsigned 的低 24 位,這個字段用來記錄對象的熱度。對象被創建時會記錄 lru 值。在被訪問的時候也會更新 lru 的值。但是不是獲取系統當前的時間戳,而是設置為全局變量 server.lruclock 的值。
源碼:server.h
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
server.lruclock 的值怎么來的?
Redis 中 有 個 定 時 處 理 的 函 數 serverCron , 默 認 每 100 毫 秒 調 用 函 數updateCachedTime 更新一次全局變量的 server.lruclock 的值,它記錄的是當前 unix時間戳。
源碼:server.c
void updateCachedTime(void) {
time_t unixtime = time(NULL);
atomicSet(server.unixtime,unixtime);
server.mstime = mstime();
struct tm tm;
localtime_r(&server.unixtime,&tm);
server.daylight_active = tm.tm_isdst;
}
問題:為什么不獲取精確的時間而是放在全局變量中?不會有延遲的問題嗎?
這樣函數 lookupKey 中更新數據的 lru 熱度值時,就不用每次調用系統函數 time,可以提高執行效率。
OK,當對象里面已經有了 LRU 字段的值,就可以評估對象的熱度了。
函數 estimateObjectIdleTime 評估指定對象的 lru 熱度,思想就是對象的 lru 值和全局的 server.lruclock 的差值越大(越久沒有得到更新), 該對象熱度越低。
源碼 evict.c
/* Given an object returns the min number of milliseconds the object was never
* requested, using an approximated LRU algorithm. */
unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
if (lruclock >= o->lru) {
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
LRU_CLOCK_RESOLUTION;
}
}
server.lruclock 只有 24 位,按秒為單位來表示才能存儲 194 天。當超過 24bit 能表示的最大時間的時候,它會從頭開始計算。
server.h
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */
在這種情況下,可能會出現對象的 lru 大於 server.lruclock 的情況,如果這種情況出現那么就兩個相加而不是相減來求最久的 key。
為什么不用常規的哈希表+雙向鏈表的方式實現?需要額外的數據結構,消耗資源。而 Redis LRU 算法在 sample 為 10 的情況下,已經能接近傳統 LRU 算法了。
https://redis.io/topics/lru-cache
問題:除了消耗資源之外,傳統 LRU 還有什么問題?
如圖,假設 A 在 10 秒內被訪問了 5 次,而 B 在 10 秒內被訪問了 3 次。因為 B 最后一次被訪問的時間比 A 要晚,在同等的情況下,A 反而先被回收。
問題:要實現基於訪問頻率的淘汰機制,怎么做?
5.2.4LFU
server.h
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
當這 24 bits 用作 LFU 時,其被分為兩部分:
高 16 位用來記錄訪問時間(單位為分鍾,ldt,last decrement time)
低 8 位用來記錄訪問頻率,簡稱 counter(logc,logistic counter)
counter 是用基於概率的對數計數器實現的,8 位可以表示百萬次的訪問頻率對象被讀寫的時候,lfu 的值會被更新
db.c——lookupKey
void updateLFU(robj *val) {
unsigned long counter = LFUDecrAndReturn(val);
counter = LFULogIncr(counter);
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}
增長的速率由,lfu-log-factor 越大,counter 增長的越慢
redis.conf 配置文件
# lfu-log-factor 10
如果計數器只會遞增不會遞減,也不能體現對象的熱度。沒有被訪問的時候,計數器怎么遞減呢?
減少的值由衰減因子 lfu-decay-time(分鍾)來控制,如果值是 1 的話,N 分鍾沒有訪問就要減少 N。
redis.conf 配置文件
# lfu-decay-time 1
6 持久化 機制
https://redis.io/topics/persistence
Redis 速度快,很大一部分原因是因為它所有的數據都存儲在內存中。如果斷電或者宕機,都會導致內存中的數據丟失。為了實現重啟后數據不丟失,Redis 提供了兩種持久化的方案,一種是 RDB 快照(Redis DataBase),一種是 AOF(Append Only File)
6.1 RDB
RDB 是 Redis 默認的持久化方案。當滿足一定條件的時候,會把當前內存中的數據寫入磁盤,生成一個快照文件 dump.rdb。Redis 重啟會通過加載 dump.rdb 文件恢復數據。
什么時候寫入 rdb 文件?
6.1.1 RDB 觸發
6.1.1.1 自動觸發
a)配置規則觸發。
redis.conf, SNAPSHOTTING,其中定義了觸發把數據保存到磁盤的觸發頻率。
如果不需要 RDB 方案,注釋 save 或者配置成空字符串""。
save 900 1 # 900 秒內至少有一個 key 被修改(包括添加)
save 300 10 # 400 秒內至少有 10 個 key 被修改
save 60 10000 # 60 秒內至少有 10000 個 key 被修改
注意上面的配置是不沖突的,只要滿足任意一個都會觸發。
RDB 文件位置和目錄:
# 文件路徑,
dir ./
# 文件名稱
dbfilename dump.rdb
# 是否是 LZF 壓縮 rdb 文件
rdbcompression yes
# 開啟數據校驗
rdbchecksum yes
參數 | 說明 |
---|---|
dir | rdb 文件默認在啟動目錄下(相對路徑) config get dir 獲取 |
dbfilename | 文件名稱 |
rdbcompression | 開啟壓縮可以節省存儲空間,但是會消耗一些 CPU 的計算時間,默認開啟 |
rdbchecksum | 使用 CRC64 算法來進行數據校驗,但是這樣做會增加大約 10%的性能消耗,如果希望獲取到最大的性能提升,可以關閉此功能 |
問題:為什么停止 Redis 服務的時候沒有 save,重啟數據還在?
RDB 還有兩種觸發方式:
b)shutdown 觸發,保證服務器正常關閉。
c)flushall,RDB 文件是空的,沒什么意義(刪掉 dump.rdb 演示一下)。
6.1.1.2 手動觸發
如果我們需要重啟服務或者遷移數據,這個時候就需要手動觸 RDB 快照保存。Redis提供了兩條命令:
a)save
save 在生成快照的時候會阻塞當前 Redis 服務器, Redis 不能處理其他命令。如果內存中的數據比較多,會造成 Redis 長時間的阻塞。生產環境不建議使用這個命令。為了解決這個問題,Redis 提供了第二種方式。
b)bgsave
執行 bgsave 時,Redis 會在后台異步進行快照操作,快照同時還可以響應客戶端求。
具體操作是 Redis 進程執行 fork 操作創建子進程(copy-on-write),RDB 持久化過程由子進程負責,完成后自動結束。它不會記錄 fork 之后后續的命令。阻塞只發生在fork 階段,一般時間很短。
用 lastsave 命令可以查看最近一次成功生成快照的時間。
6.1.2 RDB 數據的 恢復
6.1.3.1 shutdown 持久化
添加鍵值
redis> set k1 1
redis> set k2 2
redis> set k3 3
redis> set k4 4
redis> set k5 5
停服務器,觸發 save
redis> shutdown
備份 dump.rdb 文件
cp dump.rdb dump.rdb.bak
啟動服務器
/usr/local/soft/redis-5.0.5/src/redis-server /usr/local/soft/redis-5.0.5/redis.conf
數據都在:
redis> keys *
6.1.3.2 模擬數據丟失
模擬數據丟失,觸發 save
redis> flushall
停服務器
redis> shutdown
啟動服務器
/usr/local/soft/redis-5.0.5/src/redis-server /usr/local/soft/redis-5.0.5/redis.conf
啥都沒有:
redis> keys *
6.1.3.2 通過備份文件恢復數據
停服務器
redis> shutdown
重命名備份文件
mv dump.rdb.bak dump.rdb
啟動服務器
/usr/local/soft/redis-5.0.5/src/redis-server /usr/local/soft/redis-5.0.5/redis.conf
查看數據
redis> keys *
6.1.3 RDB 文件 的優勢和劣勢
一、優勢
1.RDB 是一個非常緊湊(compact)的文件,它保存了 redis 在某個時間點上的數據集。這種文件非常適合用於進行備份和災難恢復。
2.生成 RDB 文件的時候,redis 主進程會 fork()一個子進程來處理所有保存工作,主進程不需要進行任何磁盤 IO 操作。
3.RDB 在恢復大數據集時的速度比 AOF 的恢復速度要快。
二、劣勢
1、RDB 方式數據沒辦法做到實時持久化/秒級持久化。因為 bgsave 每次運行都要執行 fork 操作創建子進程,頻繁執行成本過高。
2、在一定間隔時間做一次備份,所以如果 redis 意外 down 掉的話,就會丟失最后一次快照之后的所有修改(數據有丟失)。
如果數據相對來說比較重要,希望將損失降到最小,則可以使用 AOF 方式進行持久化。
6.2 AOF
Append Only File
AOF:Redis 默認不開啟。AOF 采用日志的形式來記錄每個寫操作,並追加到文件中。開啟后,執行更改 Redis 數據的命令時,就會把命令寫入到 AOF 文件中。
Redis 重啟時會根據日志文件的內容把寫指令從前到后執行一次以完成數據的恢復工作。
6.2.1F AOF 配置
配置文件 redis.conf
# 開關
appendonly no
# 文件名
appendfilename "appendonly.aof"
參數 | 說明 |
---|---|
appendonly | Redis 默認只開啟 RDB 持久化,開啟 AOF 需要修改為 yes |
appendfilename "appendonly.aof" | 路徑也是通過 dir 參數配置 config get di |
AOF 文件的內容(vim 查看):
問題:數據都是實時持久化到磁盤嗎?
由於操作系統的緩存機制,AOF 數據並沒有真正地寫入硬盤,而是進入了系統的硬盤緩存。什么時候把緩沖區的內容寫入到 AOF 文件?
參數 | 說明 |
---|---|
appendfsync everysec | AOF 持久化策略(硬盤緩存到磁盤),默認 everysec * no 表示不執行 fsync,由操作系統保證數據同步到磁盤,速度最快,但是不太安全; * always 表示每次寫入都執行 fsync,以保證數據同步到磁盤,效率很低; * everysec 表示每秒執行一次 fsync,可能會導致丟失這 1s 數據。通常選擇 everysec ,兼顧安全性和效率。 |
問題:文件越來越大,怎么辦?
由於 AOF 持久化是 Redis 不斷將寫命令記錄到 AOF 文件中,隨着 Redis 不斷的進行,AOF 的文件會越來越大,文件越大,占用服務器內存越大以及 AOF 恢復要求時間越長。
例如 set gupao 666,執行 1000 次,結果都是 gupao=666。
為了解決這個問題,Redis 新增了重寫機制,當 AOF 文件的大小超過所設定的閾值時,Redis 就會啟動 AOF 文件的內容壓縮,只保留可以恢復數據的最小指令集。
可以使用命令 bgrewriteaof 來重寫。
AOF 文件重寫並不是對原文件進行重新整理,而是直接讀取服務器現有的鍵值對,然后用一條命令去代替之前記錄這個鍵值對的多條命令,生成一個新的文件后去替換原來的 AOF 文件。
# 重寫觸發機制
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
參數 | 說明 |
---|---|
auto-aof-rewrite-percentage | 默認值為 100。aof 自動重寫配置,當目前 aof 文件大小超過上一次重寫的 aof 文件大小的百分之多少進行重寫,即當 aof 文件增長到一定大小的時候,Redis 能夠調用 bgrewriteaof對日志文件進行重寫。當前 AOF 文件大小是上次日志重寫得到 AOF 文件大小的二倍(設置為 100)時,自動啟動新的日志重寫過程 |
auto-aof-rewrite-min-size | 默認 64M。設置允許重寫的最小 aof 文件大小,避免了達到約定百分比但尺寸仍然很小的情況還要重寫 |
問題:重寫過程中,AOF 文件被更改了怎么辦?
參數 | 說明 |
---|---|
no-appendfsync-on-rewrite | 在 aof 重寫或者寫入 rdb 文件的時候,會執行大量 IO,此時對於 everysec 和 always 的 aof模式來說,執行 fsync 會造成阻塞過長時間,no-appendfsync-on-rewrite 字段設置為默認設置為 no。如果對延遲要求很高的應用,這個字段可以設置為 yes,否則還是設置為 no,這樣對持久化特性來說這是更安全的選擇。設置為 yes 表示 rewrite 期間對新寫操作不 fsync,暫時存在內存中,等 rewrite 完成后再寫入,默認為 no,建議修改為 yes。Linux 的默認 fsync策略是 30 秒。可能丟失 30 秒數據 |
aof-load-truncated | aof 文件可能在尾部是不完整的,當 redis 啟動的時候,aof 文件的數據被載入內存。重啟可能發生在 redis所在的主機操作系統宕機后,尤其在ext4文件系統沒有加上data=ordered選項,出現這種現象。redis 宕機或者異常終止不會造成尾部不完整現象,可以選擇讓 redis退出,或者導入盡可能多的數據。如果選擇的是 yes,當截斷的 aof 文件被導入的時候,會自動發布一個 log 給客戶端然后 load。如果是 no,用戶必須手動 redis-check-aof 修復 AOF文件才可以。默認值為 yes。 |
6.2.2AOF 數據 恢復
重啟 Redis 之后就會進行 AOF 文件的恢復。
6.2.3AOF 優勢 與劣勢
優點:
1、AOF 持久化的方法提供了多種的同步頻率,即使使用默認的同步頻率每秒同步一次,Redis 最多也就丟失 1 秒的數據而已。
缺點:
1、對於具有相同數據的的 Redis,AOF 文件通常會比 RDF 文件體積更大(RDB存的是數據快照)。
2、雖然 AOF 提供了多種同步的頻率,默認情況下,每秒同步一次的頻率也具有較高的性能。在高並發的情況下,RDB 比 AOF 具好更好的性能保證。
6.3 兩種方案比較
那么對於 AOF 和 RDB 兩種持久化方式,我們應該如何選擇呢?
如果可以忍受一小段時間內數據的丟失,毫無疑問使用 RDB 是最好的,定時生成RDB 快照(snapshot)非常便於進行數據庫備份, 並且 RDB 恢復數據集的速度也要比 AOF 恢復的速度要快。
否則就使用 AOF 重寫。但是一般情況下建議不要單獨使用某一種持久化機制,而是應該兩種一起用,在這種情況下,當 redis 重啟的時候會優先載入 AOF 文件來恢復原始的數據,因為在通常情況下 AOF 文件保存的數據集要比 RDB 文件保存的數據集要完整。
公眾號
如果大家想要實時關注我更新的文章以及分享的干貨的話,可以關注我的公眾號。