Redis原理篇

  • 2019 年 10 月 22 日
  • 筆記

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

1571738799199.png

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 thenn" +                  "tredis.call('expire', KEYS[1], ARGV[1])n" +                  "treturn 1n" +                  "elseif tonumber(num) > tonumber(ARGV[2]) thenn" +                  "treturn 0n" +                  "else n" +                  "treturn 1n" +                  "endn";          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腳本調用

1571740667128.png

根據官方的數據,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 需要記憶體,使用物理定址,直接訪問主存儲器

1571740872682.png

​ 這種方式有幾個弊端:
​ 1、在多用戶多任務作業系統中,所有的進程共享主存,如果每個進程都獨佔一塊物理地址空間,主存很快就會被用完。我們希望在不同的時刻,不同的進程可以共用同一塊物理地址空間。
​ 2、如果所有進程都是直接訪問物理記憶體,那麼一個進程就可以修改其他進程的記憶體數據,導致物理地址空間被破壞,程式運行就會出現異常。
​ 為了解決這些問題,我們就想了一個辦法,在 CPU 和主存之間增加一個中間層。CPU不再使用物理地址訪問,而是訪問一個虛擬地址,由這個中間層把地址轉換成物理地址,最終獲得數據。這個中間層就叫做虛擬存儲器(Virtual Memory)。具體的操作如下所示:

1571740912276.png

​ 在每一個進程開始創建的時候,都會分配一段虛擬地址,然後通過虛擬地址和物理地址的映射來獲取真實數據,這樣進程就不會直接接觸到物理地址,甚至不知道自己調用的哪塊物理地址的數據。

​ 目前,大多數作業系統都使用了虛擬記憶體,如 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)

1571741032646.png

​ 內核是作業系統的核心,獨立於普通的應用程式,可以訪問受保護的記憶體空間,也有訪問底層硬體設備的許可權。
​ 內核空間中存放的是內核程式碼和數據,而進程的用戶空間中存放的是用戶程式的程式碼和數據。不管是內核空間還是用戶空間,它們都處於虛擬空間中,都是對物理地址的映射。
​ 在 Linux 系統中, 內核進程和用戶進程所佔的虛擬記憶體比例是 1:3。

1571741057183.png

​ 當進程運行在內核空間時就處於內核態,而進程運行在用戶空間時則處於用戶態。
​ 進程在內核空間以執行任意命令,調用系統的一切資源;在用戶空間只能執行簡單的運算,不能直接調用系統資源,必須通過系統介面(又稱 system call),才能向內核發出指令。

top 命令:

1571741080693.png

us 代表 CPU 消耗在 User space 的時間百分比;
sy 代表 CPU 消耗在 Kernel space 的時間百分比。

4.4.3進程切換(上下文 切換 )

​ 多任務作業系統是怎麼實現運行遠大於 CPU 數量的任務個數的?當然,這些任務實際上並不是真的在同時運行,而是因為系統通過時間片分片演算法,在很短的時間內,將CPU 輪流分配給它們,造成多任務同時運行的錯覺。

1571741118942.png

​ 為了控制進程的執行,內核必須有能力掛起正在 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 的上下文切換)。

1571741231301.png

I/O 的阻塞到底阻塞在哪裡?

4.4.7 Blocking I/O

​ 當使用 read 或 write 對某個文件描述符進行過讀寫時,如果當前 FD 不可讀,系統就不會對其他的操作做出響應。從設備複製數據到內核緩衝區是阻塞的,從內核緩衝區拷貝到用戶空間,也是阻塞的,直到 copy complete,內核返回結果,用戶進程才解除block 的狀態。

1571741283905.png

​ 為了解決阻塞的問題,我們有幾個思路。
​ 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),轉發到不同的事件處理器中。

1571741382175.png

​ 多路復用有很多的實現,以 select 為例,當用戶進程調用了多路復用器,進程會被阻塞。內核會監視多路復用器負責的所有 socket,當任何一個 socket 的數據準備好了,多路復用器就會返回。這時候用戶進程再調用 read 操作,把數據從內核緩衝區拷貝到用戶空間。

1571741400617.png

​ 所以,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

1571742245181.png

問題:除了消耗資源之外,傳統 LRU 還有什麼問題?

​ 如圖,假設 A 在 10 秒內被訪問了 5 次,而 B 在 10 秒內被訪問了 3 次。因為 B 最後一次被訪問的時間比 A 要晚,在同等的情況下,A 反而先被回收。

1571742271159.png

問題:要實現基於訪問頻率的淘汰機制,怎麼做?

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 查看):

1571743251390.png

​ 問題:數據都是實時持久化到磁碟嗎?

​ 由於作業系統的快取機制,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 文件被更改了怎麼辦?

1571743478372.png

參數 說明
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 文件保存的數據集要完整。

公眾號

如果大家想要實時關注我更新的文章以及分享的乾貨的話,可以關注我的公眾號。
QQ圖片20191012084332.png