玩轉redis

忞翛發表於2021-07-14

本文總結了:
redis的一般使用場景
常見操作,及如何實現
如何在python中實現這些操作

redis是非關係型資料庫,NoSQL 不依賴業務邏輯方式儲存,而以簡單的key-value模式儲存。因此大大的增加了資料庫的擴充套件能力。
redis和Memcached類似,它支援儲存的value型別相對更多,包括string(字串)、list(連結串列)、set(集合)、zset(sorted set --有序集合)和hash(雜湊型別)。這些資料型別都支援push/pop、add/remove及取交集並集和差集及更豐富的操作,而且這些操作都是原子性的。在此基礎上,Redis支援各種不同方式的排序。與memcached一樣,為了保證效率,資料都是快取在記憶體中。區別的是Redis會週期性的把更新的資料寫入磁碟或者把修改操作寫入追加的記錄檔案。並且在此基礎上實現了master-slave(主從)同步。

redis有以下特點:

  • 不遵循SQL標準。
  • 不支援ACID。
  • 遠超於SQL的效能。

安裝

下載原始碼

開啟reids官網:點選這裡,下載最新穩定版本。

上傳到centos7

  1. 可以使用wget命令直接下載
  2. 也可以使用ftp,scp等命令上傳
  3. 還可以藉助xftp、Winscp、MobaXterm等工具上傳

解決依賴

  1. 檢查有無C 語言的編譯環境
    輸入命令:gcc --version,假如提示命令不存在的話,進行第2步:安裝gcc。
    檢查gcc的版本,如版本過低則建議先升級gcc。
  2. 安裝gcc
    yum install -y gcc

編譯安裝與啟動

  1. 解tar包
    把包放到/opt目錄下,使用tar -zxvf redis.xxx.tar.gz (檔名根據自己的來),解包。

  2. 在cd到redis裡面執行以下命令

    make
    
    make install
    

    假如提示“致命錯誤”,可以執行make distclean

  3. 安裝完成
    redis已經被安裝到/usr/local/bin目錄下

  4. 啟動
    方法一:直接輸入redis-sever在前臺使用,但是不推薦,
    方法二:

    • 將/opt/redis-xx檔案中的redis.conf檔案,複製一份配置檔案到/etc/cp redis.conf /etc/
    • 使用vim命令,將daemonize no改成daemonize yes,儲存退出。
    • 使用redis-server /etc/redis.conf啟動

設定自啟動

注意:要保證redis.conf中的守護程式daemonize no改成yes

  1. /opt/redis-xx/utils/redis_init_script複製到/etc/init.d/下,並改名為redisd:

    cp redis_init_script /etc/init.d/redisd
    
  2. 編輯redisd

    #!/bin/sh
    #
    # Simple Redis init.d script conceived to work on Linux systems
    # as it does use of the /proc filesystem.
    
    ### BEGIN INIT INFO
    # Provides:     redis_6379
    # Default-Start:        2 3 4 5
    # Default-Stop:         0 1 6
    # Short-Description:    Redis data structure server
    # Description:          Redis data structure server. See https://redis.io
    ### END INIT INFO
    
    REDISPORT=6379
    EXEC=/usr/local/bin/redis-server
    CLIEXEC=/usr/local/bin/redis-cli
    
    PIDFILE=/var/run/redis_${REDISPORT}.pid
    CONF="/etc/redis/${REDISPORT}.conf"
    
    case "$1" in
    	start)
    		if [ -f $PIDFILE ]
    		then
    				echo "$PIDFILE exists, process is already running or crashed"
    		else
    				echo "Starting Redis server..."
    				$EXEC $CONF
    		fi
    		;;
    	stop)
    		if [ ! -f $PIDFILE ]
    		then
    				echo "$PIDFILE does not exist, process is not running"
    		else
    				PID=$(cat $PIDFILE)
    				echo "Stopping ..."
    				$CLIEXEC -p $REDISPORT shutdown
    				while [ -x /proc/${PID} ]
    				do
    					echo "Waiting for Redis to shutdown ..."
    					sleep 1
    				done
    				echo "Redis stopped"
    		fi
    		;;
    	*)
    		echo "Please use start or stop as first argument"
    		;;
    esac
    

    需要修改三個配置EXEC、CLIEXEC、CONF,它們分別代表的意思是:
    EXEC:redis-server路徑
    CLIEXEC:redis-cli客戶端路徑
    CONF:conf配置檔案路徑

  3. 把redisd新增到chkconfig

chkconfig --add redisd

使用chkconfig --list命令檢視自啟動服務也可以使用systemctl is-enabled redisd.service命令檢視。
假如沒有自啟動,使用chkconfig redisd on開啟自啟動。

命令與配置

見我之前的部落格:redis資料型別、命令及配置檔案

redis釋出與訂閱

簡單實現

Redis 釋出訂閱 (pub/sub) 是一種訊息通訊模式:傳送者 (pub) 傳送訊息,訂閱者 (sub) 接收訊息。Redis 客戶端可以訂閱任意數量的頻道。

  1. 訂閱端
    通過執行命令SUBSCRIBE 頻道名 [頻道名] 監聽一個或多個頻道,當釋出者往頻道中釋出訊息時,客戶端就能收到訊息。
    客戶端訂閱頻道

  2. 釋出端
    開啟另一臺客戶端,往指定頻道中傳送訊息。publish channel1 message
    釋出訊息

例子

注:釋出的訊息沒有持久化,訂閱的客戶端只能收到訂閱後釋出的訊息。

使用python客戶端實現

此內容需要熟悉python的redis模組
主要是連線方式,運算元據的命令幾乎和redis的標準命令一模一樣
連線方式分:直接redis連線和連線池連線,詳見:使用python來操作redis用法詳解redis.Redis與redis.StrictRedis區別

  1. 訂閱端

    # subscribe.py
    
    import redis
    
    # 使用連線池,且自動decode
    pool = redis.ConnectionPool(host="192.168.43.128", port=6379, decode_responses=True)
    r = redis.StrictRedis(connection_pool=pool)
    
    # 第一步
    p = r.pubsub()
    
    # 第二步
    p.subscribe("channel1", "channel2")
    
    # 第三步
    for item in p.listen():
    	print(item)
    	# {'type': 'message', 'pattern': None, 'channel': 'channel1', 'data': '1234'}
    
    	print("正在監聽頻道:{}".format(item['channel']))
    	if item['type'] == 'message':
    		data = item['data']
    		print("頻道 {} 發來新訊息:{}".format(item['channel'], data))
    		if data == 'exit':
    			print(item['channel'], '釋出結束')
    			p.unsubscribe(item['channel'])
    			print("已取消訂閱")
    
    
  2. 釋出端

    # publish.py
    
    import redis
    
    pool = redis.ConnectionPool(host="192.168.43.128", port=6379, password=None)
    r = redis.StrictRedis(connection_pool=pool)
    
    while True:
    	inp = input("channel and message>>>").strip()
    	if not inp: continue
    	temp = inp.split(" ")
    	if len(temp) < 2:
    		print("formate error, formate:channel message}")
    		continue
    	channel, *inp = temp
    	msg = " ".join(inp)
    
    	# 和在redis-cli中的一樣
    	r.publish(channel=channel, message=msg)
    	if msg == "exit":
    		print("%s 結束髮布" % channel)
    
    

redis事務

Redis事務是一個單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端傳送來的命令請求所打斷
Redis事務的主要作用就是串聯多個命令防止別的命令插隊。

redis事務主要靠Multi、Exec、discard這三個命令完成
Multi:執行後,將之後輸入的命令都會依次進入命令佇列中
Exec:執行後,將之前的命令佇列中的命令依次執行
組隊的過程中可以通過discard來放棄組隊。

示意圖

python實現redis事務,在下文中。

事務的錯誤處理

事務的錯誤可能發生在Multi之後到Exec之前,或Exec之後。Redis對於這兩種發生錯誤的處理,是不一樣的,詳見下圖所示。
兩種情況

由此,我們可以得知,Redis事務的三特性:

  1. 單獨的隔離操作
    事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端傳送來的命令請求所打斷。
  2. 沒有隔離級別的概念
    佇列中的命令沒有提交之前都不會實際被執行,因為事務提交前任何指令都不會被實際執行
  3. 不保證原子性
    事務中如果有一條命令執行失敗,其後的命令仍然會被執行,沒有回滾

事務衝突

我們知道,redis在進行CURD運算元據時是採用單執行緒+IO多路複用的模式多個命令序列執行,但redis是支援多客戶端連線的,所以在處理連線時是多執行緒的,所以高併發的情況下,客戶端之間會存在資源競爭。當多個客戶端併發操作同一Key值時,就會產生類似於多執行緒操作的現象,造成事務衝突。

舉個例子:
假如餘額為10000,在某個時間段,有三個請求進來了:
一個請求想給金額減8000
一個請求想給金額減5000
一個請求想給金額減1000

那麼,它就有可能造成以下情況:
可能的情況

為此,我們需要對請求加上或者使用lua指令碼

悲觀鎖

悲觀鎖(Pessimistic Lock), 顧名思義,就是很悲觀,每次去拿資料的時候都認為別人會修改,所以每次在拿資料的時候都會上鎖,這樣別人想拿這個資料就會block直到它拿到鎖。傳統的關係型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。
示意圖

簡要流程:

  1. 當第一個請求到來,請求減8000
  2. 其它請求阻塞,直到第一個請求處理完
  3. 然後是第二個、第三個...

優點:對於開發者而言會十分簡單
缺點:使用悲觀鎖後,資料庫的效能有所下降,因為大量的執行緒都會被阻塞,而且需要有大量的恢復過程,需要進一步改變演算法以提高系統的併發能力。

樂觀鎖

樂觀鎖(Optimistic Lock), 顧名思義,就是很樂觀,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個資料,可以使用版本號等機制。樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量Redis就是利用這種check-and-set機制實現事務的
示意圖

實現
使用命令:

  1. WATCH key [key ...]
    multi之前執行,先執行watch key1 [key2],可以監視一個(或多個) key ,如果在事務執行之前這個(或這些) key 被其他命令所改動,那麼事務將被打斷
  2. UNWATCH
    取消 WATCH 命令對所有 key 的監視。
    如果在執行 WATCH 命令之後,EXEC 命令或DISCARD 命令先被執行了的話,那麼就不需要再執行UNWATCH 了。

watch打斷事務

優點:使用樂觀鎖有助於提高併發效能
缺點:

1.由於版本號衝突,樂觀鎖導致多次請求服務失敗的概率大大提高,在特定的場合,可能造成庫存遺留問題,可以使用"重入"來提高成功的概率,但相對複雜了,其效能也會隨著版本號衝突的概率提升而提升,並不穩定。
2. 大量的 SQL 被執行,對於資料庫的效能要求較高,容易引起資料庫效能的瓶頸。

使用python redis模組操作redis

from redis import WatchError
import redis


# 使用連線池,且自動decode
pool = redis.ConnectionPool(host="192.168.43.128", port=6379, decode_responses=True)
r = redis.StrictRedis(connection_pool=pool)

num = 8000
# pipeline:def pipeline(self, transaction=True, shard_hint=None)
# 預設開啟事務
with r.pipeline() as pipe:
    try:
        pipe.watch('money')
        current_value = pipe.get('money')

        if int(current_value) >= num:
            next_value = int(current_value) - num
            # 開啟事務
            pipe.multi()
			
			# input("阻塞中....")
			# 可以去掉上面這行註釋,測試一下,watch的值被修改後,是否被打斷
			
            # 修改值
            pipe.set('money', next_value)
            pipe.execute()
            print("修改成功")
        else:
            print("餘額不足")
    except WatchError:
        print("被打斷了,操作失敗")

例項

以秒殺商品案例為例項,探討一下如何使用事務。

  1. 兩個key,儲存秒殺資料:

    • 商品庫存
      由於儲存剩餘的商品個數
      set sk:10001:qt 100
      
      # 10001表示的產品的id,qt表示庫存
      # 這樣就可以批量管理產品了
      
    • 秒殺者清單
      儲存已經成功秒殺的使用者id
      sadd sk:10001:user 1001
      
      # 10001表示的產品的id,user表示使用者,1001表示使用者id
      
  2. 寫一個flask應用,用於測試

    from flask import Flask, render_template, jsonify, request
    from redis import WatchError
    import redis
    import random
    import time
    
    app = Flask(__name__)
    
    pool = redis.ConnectionPool(host="192.168.43.128", port="6379", password=None)
    r = redis.StrictRedis(connection_pool=pool)
    
    
    @app.get("/")
    def index():
    	return render_template("index.html")
    
    
    @app.post("/seckill/")
    def sec_kill():
    	res = {
    		"status": 200,
    		"msg": "秒殺成功!"
    
    	}
    	# 拿到產品id
    	pd_id = request.form.get("pdid")
    	# 隨機生成一個使用者id,用於模擬
    	random.seed(time.time())
    	user_id = random.randint(1, 1000)
    
    	pd_key = "sk:%s:qt" % pd_id
    	user_key = "sk:%s:user" % pd_id
    	with r.pipeline() as pipe:
    
    		try:
    			pipe.watch(pd_key)
    			if not pipe.exists(pd_key):
    				# 不存在,則還沒開始
    				res["status"] = 400
    				res["msg"] = "秒殺還未開始!"
    				return jsonify(res)
    
    			# 已經開始
    			# 判斷秒殺是否結束
    
    			# 注意返回的是位元組,需要decode
    
    			pd_count = pipe.get(pd_key).decode()
    			pd_count = int(pd_count)  # 懶得進行異常處理了
    
    			if pd_count < 1:
    				res["status"] = 401
    				res["msg"] = "秒殺已經結束了!"
    				return jsonify(res)
    			# 判斷使用者是否在集合中
    
    			if pipe.sismember(user_key, user_id):
    				print(user_key)
    				res["status"] = 403
    				res["msg"] = "你已經秒殺成功了!"
    				return jsonify(res)
    
    			# 正式開始
    
    			pipe.multi()
    			pipe.decr(pd_key)
    			pipe.sadd(user_key, user_id)
    			pipe.execute()
    
    		except WatchError:
    			res["status"] = 500
    			res["msg"] = "伺服器繁忙,請稍後再試!"
    			return jsonify(res)
    
    	return jsonify(res)
    
    
    if __name__ == '__main__':
    	app.run()
    
    

    這是index.html檔案:

    <!DOCTYPE html>
    <html lang="en">
    <head>
    	
    	<title>一元秒殺專場</title>
    </head>
    <body>
    <div>產品10001 億元秒殺 <span onclick="sendSecKill('10001')" style="background-color: cadetblue">立刻搶購</span></div>
    <div id="showResponse"></div>
    
    <script>
    	function sendSecKill(pdid) {
    		// 使用原生js,傳送ajax請求
    		let url = "/seckill/";
    		let request;
    
    		if (window.XMLHttpRequest) {
    			request = new XMLHttpRequest();  // Chrome, mozilla
    		} else if (window.ActiveXObject) {
    			request = new ActiveXObject("Microsoft.XMLHTTP");  // IE
    		}
    		request.onreadystatechange = function () {
    			if (request.readyState === 4) {
    				let jsonObj = JSON.parse(request.responseText);  // 解析json資料
    				document.getElementById("showResponse").innerText = jsonObj.msg;  // 顯示在頁面上
    
    			}
    		}
    		request.open("POST", url, true);
    		request.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
    		// 傳送資料
    		request.send("pdid="+pdid);
    
    	}
    
    </script>
    </body>
    </html>
    
  3. 一個客戶端測試

     set sk:10001:qt 10
    
     # 設定10個庫存
    

    然後使用瀏覽器訪問。
    秒殺

  4. 高併發測試

    • 方案一:使用工具ab模擬測試
      點選ad命令壓力測試檢視。

    • 方案二:自己寫個小指令碼

      import requests
      import json
      from concurrent.futures import ThreadPoolExecutor
      
      
      def sk_func():
      	data = {
      		"pdid": "10001"
      	}
      
      	res = requests.post(url="http://127.0.0.1:5000/seckill/", data=data)
      	return res
      
      
      def sk_call_back(future):
      	res = future.result().text
      	print(json.loads(res))
      
      
      # 10個執行緒
      # 200個請求
      with ThreadPoolExecutor(10) as pool:
      	for _ in range(200):
      		task = pool.submit(sk_func)
      		task.add_done_callback(sk_call_back)
      
      

      示意圖

  5. 存在的問題
    當10個連線秒殺庫存為10的產品時,最後產品可能會剩餘。這就是庫存遺留的問題,如圖。這是因為有些連線同時請求修改資料,但watch的key的值發生了改變,所以庫存沒能減少。
    庫存遺留
    遇到這種問題,要麼重入,要麼讓使用者再次請求,要麼不使用樂觀鎖使用lua指令碼。

lua指令碼

Lua 是一個小巧的指令碼語言,Lua指令碼可以很容易的被C/C++ 程式碼呼叫,也可以反過來呼叫C/C++的函式,Lua並沒有提供強大的庫,一個完整的Lua直譯器不過200k,所以Lua不適合作為開發獨立應用程式的語言,而是作為嵌入式指令碼語言。lua語言比較簡單,感興趣的可以點選lua教程學習。

Lua指令碼在redis的作用:

  • LUA指令碼是類似redis事務,有一定的原子性,不會被其他命令插隊,可以完成一些redis事務性的操作。
  • 利用lua指令碼淘汰使用者,解決超賣問題。
  • 通過lua指令碼解決爭搶問題,實際上是redis 利用其單執行緒的特性,用任務佇列的方式解決多工併發問題,所以沒有庫存遺留問題。

注意:redis的lua指令碼功能,只有在Redis 2.6以上的版本才可以使用。

使用lua指令碼完成秒殺例項

python redis使用lua指令碼的方式:

import redis

r = redis.Redis("127.0.0.1")

lua = """
local key = KEYS[1]
local field = ARGV[1]
local timestamp_new = ARGV[2]

-- 做什麼都行
return 1;
"""

cmd = r.register_script(lua)

# 傳參並接受返回值
res = cmd(keys=['k1'], args=['a1', 123])
print(res)

正式開始:

  1. flask程式碼

    from flask import Flask, render_template, jsonify, request
    import redis
    import random
    import time
    
    app = Flask(__name__)
    
    pool = redis.ConnectionPool(host="192.168.43.128", port="6379", password=None)
    r = redis.StrictRedis(connection_pool=pool)
    
    
    @app.get("/")
    def index():
    	return render_template("index.html")
    
    
    @app.post("/seckill/")
    def sec_kill():
    	res = {
    		"status": 200,
    		"msg": "秒殺成功!"
    
    	}
    	# 拿到產品id
    	pd_id = request.form.get("pdid")
    	# 隨機生成一個使用者id,用於模擬
    	random.seed(time.time_ns())
    	user_id = random.randint(1, 1000)
    
    	with open("./secKill.lua", encoding="utf-8") as f:
    		cmd = r.register_script(f.read())
    		
    		# 傳參
    		code = cmd(keys=[user_id, pd_id])
    
    	# code是int,所以不需要資料轉換
    	if code == 3:
    		# 未開始
    		res["status"] = 400
    		res["msg"] = "秒殺還未開始!"
    	elif code == 2:
    		# 已經秒殺成功了
    		res["status"] = 403
    		res["msg"] = "你已經秒殺成功了!"
    	elif code == 1:
    		# 秒殺成功,預設即可
    		pass
    	elif code == 0:
    		# 秒殺結束
    		res["status"] = 401
    		res["msg"] = "秒殺已經結束了!"
    
    	else:
    		# 指令碼返回值錯誤了
    		res["status"] = 500
    		res["msg"] = "未知錯誤!"
    	return jsonify(res)
    
    
    if __name__ == '__main__':
    	app.run()
    
    
  2. lua指令碼

    -- ./secKill.lua
    local userid=KEYS[1];
    local prodid=KEYS[2];
    -- ..是字串拼接
    local qtkey="sk:"..prodid..":qt";
    local usersKey="sk:"..prodid..":user";
    local qtExists=redis.call("exists",qtkey);
    local userExists=redis.call("sismember",usersKey,userid);
    -- 已經秒殺未開始
    if tonumber(qtExists)==0 then
      return 3;
    end
    -- 已經秒殺成功
    if tonumber(userExists)==1 then
      return 2;
    end
    local num= redis.call("get" ,qtkey);
    -- 秒殺結束
    if tonumber(num)<=0 then
      return 0;
    else -- 秒殺成功
      redis.call("decr",qtkey);
      redis.call("sadd",usersKey,userid);
    end
    return 1;
    

    一些說明:
    local 宣告變數
    ..是lua拼接字串的語法
    if ... then .. end是判斷語句
    redis.call()是在呼叫redis的命令,第一個引數傳命令,其它的引數傳命令的引數
    tonumber型別轉換成number

  3. 效果
    使用指令碼測試,已經沒有了庫存遺留的問題了。
    效果圖

redis持久化

Redis 提供了2個不同形式的持久化方式。

  • RDB(Redis DataBase)
  • AOF(Append Of File)

RDB,簡而言之,就是在不同的時間點,將 redis 儲存的資料生成快照並儲存到磁碟等介質上;
AOF,則是換了一個角度來實現持久化,那就是將 redis 執行過的所有寫指令記錄下來,在下次 redis 重新啟動時,只要把這些寫指令從前到後再重複執行一遍,就可以實現資料恢復了。
其實 RDB 和 AOF 兩種方式也可以同時使用,在這種情況下,如果 redis 重啟的話,則會優先採用 AOF 方式來進行資料恢復,這是因為 AOF 方式的資料恢復完整度更高。
如果你沒有資料持久化的需求,也完全可以關閉 RDB 和 AOF 方式,這樣的話,redis 將變成一個純記憶體資料庫,就像 memcache 一樣。

官方推薦兩個都啟用。
如果對資料不敏感,可以選單獨用RDB。
不建議單獨用 AOF,因為可能會出現Bug。
如果只是做純記憶體快取,可以都不用。
若RDB檔案只用作後備用途,建議只在Slave上持久化RDB檔案,只保留save 900 1即可。

RDB

執行 rdb 持久化時, Redis會單獨建立(fork)一個子程式來進行持久化,會先將資料寫入到 一個臨時檔案中,待持久化過程都結束了,再用這個臨時檔案替換上次持久化好的檔案。 整個過程中,主程式是不進行任何IO操作的,這就確保了極高的效能 如果需要進行大規模資料的恢復,且對於資料恢復的完整性不是非常敏感,可以優先考慮使用RDB方式持久化。

優點:

  • rdb檔案體積比較小, 適合備份及傳輸
  • 效能會比 aof 好(aof 需要寫入日誌到檔案中)
  • rdb 恢復比 aof 要更快
  • 適合大規模的資料恢復

缺點:

  • 伺服器故障時會丟失最後一次備份之後的資料
  • Fork的時候,記憶體中的資料被克隆了一份,大致2倍的膨脹性需要考慮
  • 雖然Redis在fork時使用了寫時複製技術,但是如果資料龐大時還是比較消耗效能。

Fork的作用是複製一個與當前程式一樣的程式。新程式的所有資料(變數、環境變數、程式計數器等) 數值都和原程式一致,但是是一個全新的程式,並作為原程式的子程式
在Linux程式中,fork()會產生一個和父程式完全相同的子程式,但子程式在此後多會exec系統呼叫,出於效率考慮,Linux中引入了“寫時複製技術”。
一般情況父程式和子程式會共用同一段實體記憶體,只有程式空間的各段的內容要發生變化時,才會將父程式的內容複製一份給子程式。

rdb持久化流程

要使用redis的RDB持久化,有兩種方式。
一、使用配置檔案,自動執行。
二、在redis-cli中主動執行命令,進行持久化。

自動持久化

Redis要自動持久化,需要配置什麼時候執行持久化操作,以及在哪儲存rdb檔案。關於rdb的配置,主要有以下幾個:

  • save
    它有兩個引數,第一個引數是秒數,第二個是寫操作次數
    save 60 10000是指,在60秒內修改了10000次。
    save配置

  • dbfilename
    持久化後的rdb檔名,預設即可。
    dbfilename配置

  • dir
    rdb檔案的儲存路徑,預設為啟動Redis時的路徑,也可以修改成一個固定的路徑。
    dir配置

    此配置在AOF時也要用到,aof檔案的目錄也是這個

  • stop-writes-on-bgsave-error
    當Redis無法寫入磁碟的話,直接關掉Redis的寫操作。推薦yes。
    stop-writes-on-bgsave-error配置

  • rdbcompression
    對於儲存到磁碟中的快照,可以設定是否進行壓縮儲存。如果是的話,redis會採用LZF演算法進行壓縮。如果你不想消耗CPU來進行壓縮的話,可以設定為關閉此功能。推薦yes.

    rdbcompression配置

  • rdbchecksum
    在儲存快照後,還可以讓redis使用CRC64演算法來進行資料校驗,但是這樣做會增加大約10%的效能消耗,如果希望獲取到最大的效能提升,可以關閉此功能。推薦yes.
    rdbchecksum配置

配置完檔案後,
重啟redis,在規定的時間內修改資料,我們就會發現:在指定dir目錄下就會生成rdb檔案。(dir和rdb檔名都是上面配置過的)

rdb持久化示意圖

手動持久化

手動持久化,有兩個命令:

  • save
    只管儲存,其它不管,全部阻塞,不建議使用。
  • bgsave
    Redis會在後臺非同步進行快照操作, 快照同時還可以響應客戶端請求。

可通過lastsave命令獲取最後一次成功執行快照的時間
另外,執行flushall命令,也會產生rdb檔案,但裡面是空的,無意義

還原rdb資料

只要dir目錄下有rdb檔案,在redis啟動時,會自動讀取。

這裡的dir和rdb檔案,就是配置檔案中的dir和dbfilename

RDB核心函式

RDB 功能最核心的是rdbSaverdbLoad兩個函式, 前者用於生成 RDB 檔案到磁碟, 而後者則用於將 RDB 檔案中的資料重新載入到記憶體中。

rdbSave與rdbLoad

  • rdbSave
    rdbSave 函式負責將記憶體中的資料庫資料以 RDB 格式儲存到磁碟中,如果 RDB 檔案已存在,那麼新的 RDB 檔案將替換已有的 RDB 檔案。
    在儲存 RDB 檔案期間,主程式會被阻塞,直到儲存完成為止。
    SAVE 和 BGSAVE 兩個命令都會呼叫 rdbSave 函式,但它們呼叫的方式各有不同:
    SAVE 直接呼叫 rdbSave ,阻塞 Redis 主程式,直到儲存完成為止。在主程式阻塞期間,伺服器不能處理客戶端的任何請求。
    BGSAVE 則 fork 出一個子程式,子程式負責呼叫 rdbSave ,並在儲存完成之後向主程式傳送訊號,通知儲存已完成。因為 rdbSave 在子程式被呼叫,所以 Redis 伺服器在 BGSAVE 執行期間仍然可以繼續處理客戶端的請求。
  • rdbLoad
    當 Redis 伺服器啟動時, rdbLoad 函式就會被執行, 它讀取 RDB 檔案, 並將檔案中的資料庫資料載入到記憶體中。
    在載入期間, 伺服器每載入 1000 個鍵就處理一次所有已到達的請求,
    不過只有 PUBLISH 、 SUBSCRIBE 、 PSUBSCRIBE 、UNSUBSCRIBE 、 PUNSUBSCRIBE 五個命令的請求會被正確地處理, 其他命令一律返回錯誤。
    等到載入完成之後, 伺服器才會開始正常處理所有命令。

AOF

aof以日誌的形式來記錄每個操作(增量儲存),注意:讀操作是不會被儲存的, 只許追加檔案但不可以改寫檔案,redis啟動之初會讀取該檔案重新構建資料,換言之,redis 重啟的話就根據日誌檔案的內容將寫指令從前到後執行一次以完成資料的恢復工作。

優點:

  • 備份機制更穩健,丟失資料概率更低。
  • 可讀的日誌文字,通過操作AOF穩健,可以處理誤操作。
    缺點:
  • 比起RDB佔用更多的磁碟空間。
  • 恢復備份速度要慢。
  • 每次讀寫都同步的話,有一定的效能壓力。
  • 存在個別Bug,造成恢復不能。

AOF配置

  • appendonly
    是否開啟AOF,預設no
    appendonly配置
  • appendfilename
    aof檔案的檔名
    appendfilename配置
  • dir
    aof檔案的儲存路徑,預設為啟動Redis時的路徑,可以修改成一個固定的路徑。
    dir配置
  • appendfsync
    AOF的同步策略。,有三種策略:
    1. always
      始終同步,每次Redis的寫入都會立刻記入日誌;效能較差但資料完整性比較好。
    2. everysec
      每秒同步,每秒記入日誌一次,如果當機,本秒的資料可能丟失。
    3. no
      不主動進行同步,把同步時機交給作業系統。
      appendfsync配置

AOF恢復資料、修復aof檔案

  • 恢復資料
    1. 修改預設的appendonly no,改為yes
    2. 將有資料的aof檔案複製一份儲存到對應目錄(配置中的dir)
    3. 恢復:重啟redis,然後重新載入

假如aof檔案損壞,可以嘗試用以下方法修復:

  • 修復aof檔案
    1. 修改預設的appendonly no,改為yes
    2. 備份被寫壞的AOF檔案
    3. 通過/usr/local/bin/redis-check-aof--fix appendonly.aof進行修復aof檔案
    4. 恢復:重啟redis,然後重新載入

AOF Rewrite

AOF持久化流程如下:

  1. 客戶端的請求寫命令會被append追加到AOF緩衝區內;
  2. AOF緩衝區根據AOF持久化策略,將操作同步到磁碟的AOF檔案中;
  3. AOF檔案大小超過重寫策略或手動重寫時,會對AOF檔案rewrite重寫,壓縮AOF檔案容量;
  4. Redis服務重啟時,會重新load載入AOF檔案中的寫操作達到資料恢復的目的;
    aof持久化流程

什麼是重寫
AOF採用檔案追加方式,檔案會越來越大為避免出現此種情況,新增了重寫機制,當AOF檔案的大小超過所設定的閾值時,Redis就會啟動AOF檔案的內容壓縮, 只保留可以恢復資料的最小指令集。

既然當AOF檔案過大時,redis就會執行重寫操作,那麼aof檔案多大是過大呢?以及redis是如何實現重寫的呢?

何時重寫

先看看一些配置:

auto-aof-rewrite-percentage:設定重寫的基準值的百分數,預設100%
auto-aof-rewrite-min-size:設定重寫的基準值的大小,預設64MB
預設配置
no-appendfsync-on-rewrite
yes時,不寫入aof檔案只寫入快取,使用者請求不會阻塞,但是在這段時間如果當機會丟失這段時間的快取資料。(降低資料安全性,提高效能)
no時,把資料往磁碟裡刷,但是遇到重寫操作,可能會發生阻塞。(資料安全,但是效能降低)
詳見:Redis的AOF配置redis的no-appendfsync-on-rewrite引數

注意:系統載入時或者上次重寫完畢時,Redis會記錄此時AOF大小,設為base_size

有了以上配置和變數,我們就可以得出了重寫的條件:

  1. AOF檔案大小 >= base_size +base_size * auto-aof-rewrite-percentage (預設100%)
  2. AOF檔案大小 >= auto-aof-rewrite-min-size (預設64mb)
  3. 同時滿足這兩個條件時進行重寫

例如:檔案達到70MB開始重寫,降到50MB,下次什麼時候開始重寫? ==> 100MB

如何重寫

  1. bgrewriteaof觸發重寫
    判斷是否當前有bgsave或bgrewriteaof在執行,如果有,則等待該命令結束後再繼續執行。
  2. 主程式fork出子程式執行重寫操作,保證主程式不會阻塞。
  3. 子程式遍歷redis記憶體中資料到臨時檔案
    客戶端的寫請求同時寫入aof_buf緩衝區和aof_rewrite_buf重寫緩衝區保證原AOF檔案完整以及新AOF檔案生成期間的新的資料修改動作不會丟失。
  4. 子程式寫完新的AOF檔案後,向主程式發訊號,父程式更新統計資訊。
    主程式把aof_rewrite_buf中的資料寫入到新的AOF檔案。
  5. 使用新的AOF檔案覆蓋舊的AOF檔案,完成AOF重寫。
    重寫流程

也可手動執行bgrewriteaof命令,後臺完成Rewrite

只要硬碟許可,應該儘量減少AOF rewrite的頻率,AOF重寫的基礎大小預設值64M太小了,可以設到5G以上。

redis主從複製

Redis雖然讀取寫入的速度都特別快,但是也會產生讀壓力特別大的情況。為了分擔讀壓力,Redis支援主從複製。所謂的主從複製是指將一臺Redis伺服器的資料,複製到其他的Redis伺服器。前者稱為主節點(master),後者稱為從節點(slaver);資料的複製是單向的,只能由主節點到從節點。所以從節點不能進行寫操作。
示意圖
預設情況下,每臺Redis伺服器都是主節點;且一個主節點可以有多個從節點(或沒有從節點),但一個從節點只能有一個主節點。

優點:

  • 讀寫分離,效能擴充套件
  • 故障恢復,當主節點出現問題時,可以由從節點提供服務,實現快速的故障恢復

一般使用

一主多從

通過配置slaveof實現:slaveof ip 埠
下面使用一個虛擬機器啟動4個redis服務來演示(一主三從):
圖示

  1. 修改配置檔案
    將redis.conf中的daemonize設為yes
    註釋掉port、pidfile、logfile、rdbfilename、appendfilename
    然後再在redis.conf的目錄下使用vim命令,配置4個檔案

    # redis-6379.conf
    
    include ./redis.conf
    
    port 6379
    pidfile /var/run/redis_6379.pid
    dbfilename dump6379.rdb
    appendfilename appendonly6379.aof
    
    
    # redis-6380.conf
    
    include ./redis.conf
    
    port 6380
    pidfile /var/run/redis_6380.pid
    dbfilename dump6380.rdb
    appendfilename appendonly6380.aof
    
    slaveof 192.168.43.128 6379
    
    
    # redis-6381.conf
    
    include ./redis.conf
    
    port 6381
    pidfile /var/run/redis_6381.pid
    dbfilename dump6381.rdb
    appendfilename appendonly6381.aof
    
    slaveof 192.168.43.128 6379
    
    
    # redis-6382.conf
    
    include ./redis.conf
    
    port 6382
    pidfile /var/run/redis_6382.pid
    dbfilename dump6382.rdb
    appendfilename appendonly6382.aof
    
    slaveof 192.168.43.128 6379
    
    

    slaveof 指定的是master的ip和埠

  2. 啟動redis服務

    redis-server redis-6379.conf
    redis-server redis-6380.conf
    redis-server redis-6381.conf
    redis-server redis-6382.conf
    
  3. 檢視效果
    連線上6379埠的伺服器

    redis-cli -p 6379
    

    輸入info replication
    示意圖

    info replication 列印主從複製的相關資訊
    假如不是這個效果,請檢查防火牆

這種搭建方式的缺點:master掛掉後,slave不能繼續工作

6380從節點

薪火相傳

上一個Slave可以是下一個slave的Master,Slave同樣可以接收其他 slaves的連線和同步請求,那麼該slave作為了鏈條中下一個的master, 可以有效減輕master的寫壓力,去中心化降低風險。該模式的風險是一旦某個slave當機,後面的slave都沒法備份。
當使用slaveof命令時,會清除之前的資料,重新建立拷貝最新的。

薪火相傳

master當機後,任何slave都不可以寫資料
6380埠的主機可以使用slaveof no one命令,斷開Master
斷開後,6380埠的主機就可以寫資料,6381埠的主機可以讀取6380埠主機的資料
Master恢復後,6380埠的主機需要使用slaveof宣告自己的主機

哨兵模式

使用上述方式實現時在主節點當機後,整個redis都無法正常工作了。主要原因是redis在master掛掉後不知道選誰作為自己的主節點。所以只要我們在配置中配置好每個節點的優先順序,再啟動一個哨兵。當主節點當機後,哨兵根據優先順序,選一個優先順序高的節點當master,再通知其它節點應該把誰當作master,這樣就解決了主機當機的問題了。

注:原主節點恢復後,會成為slave,而不會重新成為master, 除非也會成為當前master再次當機,且它的優先順序最高。

確定優先順序

我們手動去配置哪個機器的優先順序是多少。需要用到的配置是slave-priority,它在選舉主機時使用,它的值是一個數,值越小,優先順序越高,。預設100。
下面我們再修改配置檔案:

[root@localhost myredis]# cat redis-*
# 6379
include ./redis.conf

port 6379
pidfile /var/run/redis_6379.pid
dbfilename dump6379.rdb
appendfilename appendonly6379.aof

slave-priority 100


# 6380
include ./redis.conf

port 6380
pidfile /var/run/redis_6380.pid
dbfilename dump6380.rdb
appendfilename appendonly6380.aof

slaveof 192.168.43.128 6379

slave-priority 100

# 6381

include ./redis.conf

port 6381
pidfile /var/run/redis_6381.pid
dbfilename dump6381.rdb
appendfilename appendonly6381.aof

slaveof 192.168.43.128 6379

slave-priority 100


# 6382

include ./redis.conf

port 6382
pidfile /var/run/redis_6382.pid
dbfilename dump6382.rdb
appendfilename appendonly6382.aof

slaveof 192.168.43.128 6379

slave-priority 100

配置哨兵

建立一個sentinel.conf檔案(名字絕不能錯)

sentinel ['sentɪn(ə)l] 為哨兵、守衛的意思

填寫以下內容:

# 守護程式設為no的話,就是前臺執行,可以檢視日誌輸出
daemonize yes
# sentinel  monitor 被監控的名稱  host  port  1
monitor mymaster 192.168.43.128 6379 1

monitor 為監控的意思
mymaster 是一個自定義的名字
192.168.43.128為master的ip
6379 為master的埠
1表示要有多少個哨兵認為主伺服器不可用的時候,才會進行failover操作

關於哨兵的其它配置,見此文:哨兵模式配置檔案中的全部配置

啟動並測試

  • 啟動哨兵

    redis-sentinel sentinel.conf
    

    redis-sentinel

  • 啟動redis-server

    redis-server redis-6379.conf
    redis-server redis-6380.conf
    redis-server redis-6381.conf
    redis-server redis-6382.conf
    

當master掛掉後,等10秒左右,哨兵會重新選出master並未新的master分配slave,此過程會修改原來redis和哨兵的配置檔案

假如想進一步學習,可以檢視此文Redis哨兵模式(sentinel)學習總結及部署記錄(主從複製、讀寫分離、主從切換)

優缺點

  • 優點

    1. 哨兵叢集,基於主從複製模式,所有的主從配置的優點,它都有。
    2. 主從可以切換,故障可以轉移,系統的可用性就會更好。
    3. 哨兵模式就是主從模式的升級版,從收到到自動,更加健壯。
  • 缺點

    1. Redis不好線上擴容,叢集容量一旦達到上限,線上擴容就會十分麻煩。
    2. 實現哨兵模式的配置比較麻煩,並且其中有很多選項。

主從複製原理

  1. Slave啟動成功連線到master後會傳送一個sync命令
  2. Master接到命令啟動後臺的存檔程式,同時收集所有接收到的用於修改資料集命令, 在後臺程式執行完畢之後,master將傳送整個資料檔案到slave,以完成一次完全同步。

示意圖

複製時用到了兩個概念:
全量複製與增量複製

  • 全量複製:slave服務在接收到資料庫檔案資料後,將其存檔並載入到記憶體中。
  • 增量複製:Master繼續將新的所有收集到的修改命令依次傳給slave,完成同步。但是隻要是重新連線master,全量複製將被自動執行。

redis叢集

叢集,即Redis Cluster,是Redis 3.0開始引入的分散式儲存方案。
叢集由多個節點(Node)組成,Redis的資料分佈在這些節點中。叢集中的節點分為主節點和從節點:只有主節點負責讀寫請求和叢集資訊的維護;從節點只進行主節點資料和狀態資訊的複製。

Redis叢集的作用有下面幾點:

  • 資料分割槽:突破單機的儲存限制,將資料分散到多個不同的節點儲存;
  • 負載均衡:每個主節點都可以處理讀寫請求,提高了併發能力;
  • 高可用:叢集有著和哨兵模式類似的故障轉移能力,提升叢集的穩定性;

例子

下面將以6個redis-server服務,組成3個一主一僕的叢集。
實現效果

  1. 建立配置檔案

    include ./redis.conf
    # redis.conf是原本的redis配置檔案
    
    daemonize yes
    port 6379
    pidfile "/var/run/redis_6379.pid"
    dbfilename "dump6379.rdb"
    appendfilename "appendonly6379.aof"
    protected-mode no
    save 3600 1
    
    
    # cluster
    
    # 是否開啟叢集模式
    cluster-enabled yes
    
    # 設定節點配置檔名,自動生成
    cluster-config-file nodes-6379.conf
    
    # 定節點失聯時間,超過該時間(毫秒),叢集自動進行主從切換。
    cluster-node-timeout 15000
    

    關鍵在於cluster-enabledcluster-config-filecluster-node-timeout這三個配置
    使用sed命令,替換部分內容,生成其它節點的配置檔案:

    cat redis-6379.conf | sed "s/6379/6380/g" > redis-6380.conf
    cat redis-6379.conf | sed "s/6379/6381/g" > redis-6381.conf
    cat redis-6379.conf | sed "s/6379/6382/g" > redis-6382.conf
    cat redis-6379.conf | sed "s/6379/6383/g" > redis-6383.conf
    cat redis-6379.conf | sed "s/6379/6384/g" > redis-6384.conf
    

    目錄

  2. 分別啟動6個redis-server
    效果

  3. 建立叢集
    注:這是redis5之後的方法,之前的版本需要安裝ruby環境,然後使用redis-trib.rb命令執行,可參考此文:使用Ruby指令碼搭建叢集

    使用redis-cli --cluster create xxx命令建立叢集

    redis-cli --cluster create --cluster-replicas 1 192.168.43.128:6379 192.168.43.128:6380 192.168.43.128:6381 192.168.43.128:6382 192.168.43.128:6383 192.168.43.128:6384
    
    # 建議使用真實IP
    

    --cluster-replicas表示每個主節點有幾個從節點
    redis-cli --cluster代替了之前的redis-trib.rb,我們無需安裝ruby環境即可直接使用它附帶的所有功能:建立叢集、增刪節點、槽遷移、完整性檢查、資料重平衡等等。

    假如報錯了: Either the node already knows other nodes (check with CLUSTER NODES) or contains some key in database 0.
    可以嘗試以下步驟:

    1. 關閉所有redis服務
    2. 刪除所有rdb、aof檔案以及剛生成的nodes-xxxx.conf
    3. 重啟redis-server
    4. 再次執行命令

    最後根據提示輸入“yes”即可完成搭建。
    示意圖

slots

現在我們已經搭建了一個叢集,redis叢集的一個特點就是去中心化,也就是說,無論你連線的是哪臺redis伺服器操作哪個key,它都能找到key對應的redis伺服器,然後操作。這一切都是Redis利用slot(插槽)自動完成的。
一個 Redis 叢集包含 16384 個插槽(hash slot), 資料庫中的每個鍵都屬於這 16384 個插槽的其中一個,叢集使用公式 CRC16(key) % 16384 來計算鍵 key 屬於哪個槽,其中 CRC16(key) 語句用於計算鍵 key 的 CRC16 校驗和 。叢集中的每個節點負責處理一部分插槽。
舉個例子, 如果一個叢集可以有主節點, 其中:
節點 A 負責處理 0 號至 5460 號插槽。
節點 B 負責處理 5461 號至 10922 號插槽。
節點 C 負責處理 10923 號至 16383 號插槽。

在客戶端使用cluster nodes命令可以檢視哪個主節點對應的插槽範圍。
cluster nodes

叢集命令

使用redis-cli -c 表明採用叢集策略連線,如redis-cli -c -h 192.168.43.128 -p 6379

叢集

  • cluster info
    列印叢集的資訊
  • cluster nodes
    列出叢集當前已知的所有節點( node),以及這些節點的相關資訊。

節點

  • cluster meet <ip> <port>
    將 ip 和 port 所指定的節點新增到叢集當中,讓它成為叢集的一份子。
  • cluster forget <node_id>
    從叢集中移除 node_id 指定的節點。
  • cluster replicate <master_node_id>
    將當前從節點設定為 node_id 指定的master節點的slave節點。只能針對slave節點操作。
  • cluster saveconfig
    將節點的配置檔案儲存到硬碟裡面。

槽(slot)

  • cluster addslots <slot> [slot ...]
    將一個或多個槽( slot)指派( assign)給當前節點。
  • cluster delslots <slot> [slot ...]
    移除一個或多個槽對當前節點的指派。
  • cluster flushslots
    移除指派給當前節點的所有槽,讓當前節點變成一個沒有指派任何槽的節點。
  • cluster setslot <slot> node <node_id>
    將槽 slot 指派給 node_id 指定的節點,如果槽已經指派給另一個節點,那麼先讓另一個節點刪除該槽>,然後再進行指派。
  • cluster setslot <slot> migrating <node_id>
    將本節點的槽 slot 遷移到 node_id 指定的節點中。
  • cluster setslot <slot> importing <node_id>
    從 node_id 指定的節點中匯入槽 slot 到本節點。
  • cluster setslot <slot> stable
    取消對槽 slot 的匯入( import)或者遷移( migrate)。

  • cluster keyslot <key>
    計算鍵 key 應該被放置在哪個槽上。
  • cluster countkeysinslot <slot>
    返回槽 slot 目前包含的鍵值對數量。
  • cluster getkeysinslot <slot> <count>
    返回 count 個 slot 槽中的鍵 。

叢集維護

這部分內容指的就是,在已經搭建好叢集情況下,對叢集的節點增刪改。
redis5之前的版本,操作看這裡:Redis Cluster日常操作命令梳理
之前說過從redis5開始,redis-cli --cluster代替了之前的redis-trib.rb。使用help命令:

詳細的解釋,及各個命令如何使用,見此文章:Redis 5.0 redis-cli --cluster help說明

[root@localhost myredis]# redis-cli --cluster help
Cluster Manager Commands:
  create         host1:port1 ... hostN:portN
                 --cluster-replicas <arg>
  check          host:port
                 --cluster-search-multiple-owners
  info           host:port
  fix            host:port
                 --cluster-search-multiple-owners
                 --cluster-fix-with-unreachable-masters
  reshard        host:port
                 --cluster-from <arg>
                 --cluster-to <arg>
                 --cluster-slots <arg>
                 --cluster-yes
                 --cluster-timeout <arg>
                 --cluster-pipeline <arg>
                 --cluster-replace
  rebalance      host:port
                 --cluster-weight <node1=w1...nodeN=wN>
                 --cluster-use-empty-masters
                 --cluster-timeout <arg>
                 --cluster-simulate
                 --cluster-pipeline <arg>
                 --cluster-threshold <arg>
                 --cluster-replace
  add-node       new_host:new_port existing_host:existing_port
                 --cluster-slave
                 --cluster-master-id <arg>
  del-node       host:port node_id
  call           host:port command arg arg .. arg
                 --cluster-only-masters
                 --cluster-only-replicas
  set-timeout    host:port milliseconds
  import         host:port
                 --cluster-from <arg>
                 --cluster-from-user <arg>
                 --cluster-from-pass <arg>
                 --cluster-from-askpass
                 --cluster-copy
                 --cluster-replace
  backup         host:port backup_directory
  help

For check, fix, reshard, del-node, set-timeout you can specify the host and port of any working node in the cluster.

Cluster Manager Options:
  --cluster-yes  Automatic yes to cluster commands prompts

[root@localhost myredis]#

新增節點

  1. 你得先有節點
    修改配置檔案:

    cat redis-6384.conf | sed "s/6384/6385/" > redis-6385.conf
    cat redis-6384.conf | sed "s/6384/6386/" > redis-6386.conf
    

    啟動服務:

    redis-server redis-6385.conf
    redis-server redis-6386.conf
    
    
  2. 新增主節點

    redis-cli --cluster add-node 192.168.43.128:6385  192.168.43.128:6379
    
    # 192.168.43.128:6385是新的節點
    # 192.168.43.128:6379是舊的節點,用於找你加入的是哪個叢集
    

    也可同時新增多個主節點

  3. 新增從節點

    redis-cli --cluster add-node  --cluster-slave --cluster-master-id 4f6782603f0d524361b68d3fb4945b38d6fa1dd3 192.168.43.128:6386 192.168.43.128:6382
    
    # --cluster-master-id是主節點id,通過cluster nodes可以檢視
    # 不指定時,隨機分配
    # 192.168.43.128:6386是新節點
    # 192.168.43.128:6382是舊節點,用於找你加入的是哪個叢集
    
    

    同樣可以新增多個從節點

  4. 重新分配slot
    新增加的主節點,是沒有slots的,所以需要我們分配。請仔細閱讀“#”後面的備註。

    # ip是用來確認是哪個叢集的
    redis-cli --cluster reshard  192.168.43.128:6386   
    
    How many slots do you want to move (from 1 to 16384)? 1000  # 設定slot數1000  
    What is the receiving node ID? 03ccad2ba5dd1e062464bc7590400441fafb63f2  # 輸入主節點id
    Please enter all the source node IDs.  
     Type 'all' to use all the nodes as source nodes for the hash slots.  
     Type 'done' once you entered all the source nodes IDs.  
    Source node #1:all   # 表示全部節點重新洗牌  
    Do you want to proceed with the proposed reshard plan (yes/no)? yes   # 確認重新分  
    

    可以把分配的過程理解成打撲克牌,all表示大家重新洗牌;輸入某個主節點的node id。而輸入done的話,會把slot抽出來給receiving node ID,相當於抽牌過程,不過在執行這個選項前會多一步:確認是抽誰的slot(見刪除節點那裡)。

修改節點

  • 改變從節點的master

     cluster replicate 9495c02bb1be1da198b529c9ff5e5a1728e545f6
    

    效果圖

    你操作的是當前從節點,所以要注意你當前連上的是哪個節點。

刪除節點

  1. 刪除主節點
    如果主節點有從節點,將從節點轉移到其他主節點
    在客戶端中,使用cluster replicate <node-id>命令,改變從節點的主節點。
    如果主節點有slot,去掉分配的slot,然後再刪除主節點

    redis-cli --cluster reshard  192.168.43.128:6386  # 取消分配的slot也是這個命令
    
    How many slots do you want to move (from 1 to 16384)? 1000 # 被刪除master的所有slot數量  
    What is the receiving node ID? bf8c3e87f457e9c60575e03333323dfff778bd95 # 接收slot的master  
    Please enter all the source node IDs.  
     Type 'all' to use all the nodes as source nodes for the hash slots.  
     Type 'done' once you entered all the source nodes IDs.  
    Source node #1:4f6782603f0d524361b68d3fb4945b38d6fa1dd3  # 被刪除master的node-id  
    Source node #2:done   
    
    Do you want to proceed with the proposed reshard plan (yes/no)? yes  # 取消slot後,reshard  
    
    

    刪除主節點

    redis-cli --cluster del-node 192.168.43.128:6385 4f6782603f0d524361b68d3fb4945b38d6fa1dd3
    
    # 192.168.43.128:6385表示哪個叢集
    # 4f6782603f0d524361b68d3fb4945b38d6fa1dd3是節點的id
    
  2. 刪除從節點、
    同樣使用redis-cli --cluster del-node命令。

    redis-cli --cluster del-node 192.168.163.132:6384 f6a6957421b80409106cb36be3c7ba41f3b603ff
    
    # 192.168.163.132:6384表示哪個叢集
    # f6a6957421b80409106cb36be3c7ba41f3b603ff是要刪的節點的id
    

故障修復

假如主節點當機了,那麼從節點將會在一段時間內(根據cluster-node-timeout配置決定,預設15秒)自動升為主節點。和哨兵模式一樣,原主節點恢復後會變成從機。

如果所有某一段插槽的主從節點都宕掉,redis服務是否還能繼續?答案是根據cluster-require-full-coverage的配置:

  1. 如果某一段插槽的主從都掛掉,而cluster-require-full-coverage 為yes ,那麼 ,整個叢集都掛掉。
  2. 如果某一段插槽的主從都掛掉,而cluster-require-full-coverage 為no ,那麼,該插槽資料全都不能使用,也無法儲存。

常見問題及解決

快取穿透

key對應的資料在資料來源並不存在,每次針對此key的請求從快取獲取不到,請求都會壓到資料來源,從而可能壓垮資料來源。
示意圖

原因
快取穿透的問題,肯定是再大併發情況下。依此為前提,我們分析快取穿透的原因如下:

  1. 惡意攻擊,猜測你的key命名方式,然後估計使用一個你快取中不會有的key進行訪問。
  2. 第一次資料訪問,這時快取中還沒有資料,則併發場景下,所有的請求都會壓到資料庫。
  3. 資料庫的資料也是空,這樣即使訪問了資料庫,也是獲取不到資料,那麼快取中肯定也沒有對應的資料。這樣也會導致穿透。

解決方法

  1. 對空值快取
    如果一個查詢返回的資料為空(不管是資料是否不存在),我們仍然把這個空結果(null)進行快取,設定空結果的過期時間會很短,最長不超過五分鐘
  2. 設定可訪問的名單(白名單)
    使用bitmaps型別定義一個可以訪問的名單,名單id作為bitmaps的偏移量,每次訪問和bitmap裡面的id進行比較,如果訪問id不在bitmaps裡面,進行攔截,不允許訪問。
  3. 採用布隆過濾器(Bloom Filter)
    布隆過濾器是1970年由布隆提出的。它實際上是一個很長的二進位制向量(點陣圖)和一系列隨機對映函式(雜湊函式)。
    布隆過濾器可以用於檢索一個元素是否在一個集合中。它的優點是空間效率和查詢時間都遠遠超過一般的演算法,缺點是有一定的誤識別率和刪除困難。
    將所有可能存在的資料雜湊到一個足夠大的bitmaps中,一個一定不存在的資料會被 這個bitmaps攔截掉,從而避免了對底層儲存系統的查詢壓力。
  4. 進行實時監控
    當發現Redis的命中率開始急速降低,需要排查訪問物件和訪問的資料,和運維人員配合,可以設定黑名單限制服務

快取擊穿

key對應的資料存在,但在redis中過期,此時若有大量併發請求過來,這些請求發現快取過期一般都會從後端DB載入資料並回設到快取,這個時候大併發的請求可能會瞬間把後端DB壓垮。當key可能會在某些時間點被超高併發地訪問時,就要考慮快取被“擊穿”的問題了。

示意圖

解決方法

  1. 預先設定熱門資料
    在redis高峰訪問之前,把一些熱門資料提前存入到redis裡面,加大這些熱門資料key的時長
  2. 實時調整
    現場監控哪些資料熱門,實時調整key的過期時長
  3. 使用鎖:
    分散式鎖,示意圖

快取穿透與快取擊穿的區別

擊穿就穿一層,只是穿了redis,資料庫有資料
穿透穿了兩層,redis和資料庫都沒資料
因此它們的應當方案有所區別

快取雪崩

快取雪崩是指在我們設定快取時採用了相同的過期時間,導致快取在某一時刻同時失效,請求全部轉發到DB,DB瞬時壓力過重雪崩。即多個key同時過期,使得後端DB忙不過來。

快取雪崩

解決方法

  1. 構建多級快取架構
    如:nginx快取 + redis快取 +其他快取(ehcache等)
  2. 使用鎖或佇列
    用加鎖或者佇列的方式保證來保證不會有大量的執行緒對資料庫一次性進行讀寫,從而避免失效時大量的併發請求落到底層儲存系統上。不適用高併發情況
  3. 設定過期標誌更新快取
    記錄快取資料是否過期(設定提前量),如果過期會觸發通知另外的執行緒在後臺去更新實際key的快取。
  4. 將快取失效時間分散開
    比如使用隨機數作為過期時間,這樣每一個快取的過期時間的重複率就會降低,就很難引發集體失效的事件。

分散式鎖

由於我們把單機部署成了分散式的叢集,由於分散式系統多執行緒、多程式並且分佈在不同機器上,這將使原單機部署情況下的併發控制鎖策略失效,所以我們需要解決資料安全的問題,這就是分散式鎖。

分散式鎖主流的實現方案:

  1. 基於資料庫實現分散式鎖
  2. 基於快取(Redis等)
  3. 基於Zookeeper
    每一種分散式鎖解決方案都有各自的優缺點:
  4. 效能:redis最高
  5. 可靠性:zookeeper最高
    這裡,我們就基於redis實現分散式鎖。

方案一

基於redis實現分散式鎖,一般的方式是通過設定一個key作為標識,當key存在時,則其它操作返回false,不存在時設定key,然後進行自己的操作。
key要滿足兩個條件:

  1. key要有過期時間,不然可造成全部都阻塞了
  2. key的值不能一樣,用於給在操作時判斷是否為自己的key,防止誤刪

直接說可能抽象,畫個圖就簡單了。

示意圖

我這是用python實現的,其它語言也大同小異,只要根據上面圖的邏輯就行了。

程式碼實現:

import uuid
import math
import time

from redis import WatchError


def acquire_lock_with_timeout(conn, lock_name, acquire_timeout=3, lock_timeout=2):
    """
    基於 Redis 實現的分散式鎖1

    :param conn: Redis 連線
    :param lock_name: 鎖的名稱
    :param acquire_timeout: 獲取鎖的超時時間,預設 3 秒
    :param lock_timeout: 鎖的超時時間,預設 2 秒
    :return:
    """

    # uuid做唯一標識
    identifier = str(uuid.uuid4())
    lock_name = f'lock:{lock_name}'
    lock_timeout = int(math.ceil(lock_timeout))  # 向上取整

    end = time.time() + acquire_timeout
    while time.time() < end:
        # 如果不存在這個鎖則加鎖並設定過期時間,避免死鎖
        if conn.set(lock_name, identifier, ex=lock_timeout, nx=True):
            # 需要返回唯一標識,刪除key時要用到
            return identifier

        time.sleep(0.001)
    # 獲取鎖超時了,返回False
    return False


def release_lock(conn, lock_name, identifier):
    """
    釋放鎖

    :param conn: Redis 連線
    :param lock_name: 鎖的名稱
    :param identifier: 鎖的標識
    :return:
    """
    # python中redis事務是通過pipeline的封裝實現的
    with conn.pipeline() as pipe:
        lock_name = 'lock:' + lock_name

        while True:
            try:
                # watch 鎖, multi 後如果該 key 被其他客戶端改變, 事務操作會丟擲 WatchError 異常
                pipe.watch(lock_name)
                current_id = pipe.get(lock_name)

                # 假如是位元組的話,就decode,這與建立Redis的引數有關
                current_id = current_id.decode('utf-8') if isinstance(current_id, bytes) else current_id
                if current_id and current_id == identifier:
                    # 事務開始
                    pipe.multi()
                    pipe.delete(lock_name)
                    pipe.execute()
                    return True

                pipe.unwatch()
                break
            except WatchError:
                pass
        return False


redis-py-cluster不支援MULTI/EXEC + WATCH的形式(見:Transactions and WATCH),所以上面的程式碼的conn為redis-py-cluster建立的話,會報錯。
想要不報錯的話,去掉事務pipe.multi()即可。
個人感覺還是下面這種方案好

方案二

該方案利用的是,lua指令碼在redis中的原子性,達到事務+watch的效果。

import uuid
import math
import time


def acquire_lock_with_timeout(conn, lock_name, acquire_timeout=3, lock_timeout=2):
    """
    基於 Redis 實現的分散式鎖2
    
    :param conn: Redis 連線
    :param lock_name: 鎖的名稱
    :param acquire_timeout: 獲取鎖的超時時間,預設 3 秒
    :param lock_timeout: 鎖的超時時間,預設 2 秒
    :return:
    """

    identifier = str(uuid.uuid4())
    lockname = f'lock:{lock_name}'
    lock_timeout = int(math.ceil(lock_timeout))

    end = time.time() + acquire_timeout

    while time.time() < end:
        # 如果不存在這個鎖則加鎖並設定過期時間,避免死鎖
        if conn.set(lockname, identifier, ex=lock_timeout, nx=True):
            return identifier

        time.sleep(0.001)

    return False


def release_lock(conn, lock_name, identifier):
    """
    釋放鎖
    
    :param conn: Redis 連線
    :param lockname: 鎖的名稱
    :param identifier: 鎖的標識
    :return:
    """
    unlock_script = """
    if redis.call("get",KEYS[1]) == ARGV[1] then
        return redis.call("del",KEYS[1])
    else
        return 0
    end
    """
    lock_name= f'lock:{lock_name}'
    unlock = conn.register_script(unlock_script)
    result = unlock(keys=[lock_name], args=[identifier])
    if result:
        return True
    else:
        return False

將以上程式碼寫成上下文協議,用起來更方便:

import uuid
import math
import time


class RedisLock:
    def __init__(self, conn, lock_name, acquire_timeout=3, lock_timeout=2):
        """
        基於 Redis 實現的分散式鎖2

        :param conn: Redis 連線
        :param lock_name: 鎖的名稱
        :param acquire_timeout: 獲取鎖的超時時間,預設 3 秒
        :param lock_timeout: 鎖的超時時間,預設 2 秒
        :return:
        """
        self.conn = conn
        self.lock_name = f'lock:{lock_name}'
        self.acquire_timeout = acquire_timeout
        self.lock_timeout = int(math.ceil(lock_timeout))  # 向上取整
        super().__init__()

    def __enter__(self):
        self.identifier = str(uuid.uuid4())

        end = time.time() + self.acquire_timeout
        while time.time() < end:
            # 如果不存在這個鎖則加鎖並設定過期時間,避免死鎖
            if self.conn.set(self.lock_name, self.identifier, ex=self.lock_timeout, nx=True):
                # 獲取鎖成功了
                return True

            time.sleep(0.001)
        # 獲取鎖超時了,失敗,返回False
        return False

    def __exit__(self, exc_type, exc_val, exc_tb):
        # 釋放鎖
        # 異常不處理,讓其自動上浮
        unlock_script = """
        if redis.call("get",KEYS[1]) == ARGV[1] then
            return redis.call("del",KEYS[1])
        else
            return 0
        end
        """
        unlock = self.conn.register_script(unlock_script)
        unlock(keys=[self.lock_name], args=[self.identifier])


使用:

我將之前秒殺的例子稍作修改了一下

from flask import Flask, render_template, jsonify, request
from rediscluster import RedisCluster
import random
import time

# 上面的分佈鎖
import Lock

app = Flask(__name__)

startup_nodes = [
    {"host": "192.168.43.128", "port": "6379"},
    {"host": "192.168.43.128", "port": "6381"},
    {"host": "192.168.43.128", "port": "6380"},
]

r = RedisCluster(startup_nodes=startup_nodes, decode_responses=True)


@app.get("/")
def index():
    return render_template("index.html")


@app.post("/seckill/")
def sec_kill():
    res = {
        "status": 200,
        "msg": "秒殺成功!"

    }
    # 拿到產品id
    pd_id = request.form.get("pdid")
    # 隨機生成一個使用者id,用於模擬
    random.seed(time.time_ns())
    user_id = random.randint(1, 1000)

    pd_key = "sk:%s:qt" % pd_id
    user_key = "sk:%s:user" % pd_id

    with Lock.RedisLock(conn=r, lock_name="secKill", acquire_timeout=1, lock_timeout=1) as is_get_lock:
        if is_get_lock:
            # 取得鎖了
            if not r.exists(pd_key):
                # 不存在,則還沒開始
                res["status"] = 400
                res["msg"] = "秒殺還未開始!"
                return jsonify(res)

            pd_count = int(r.get(pd_key))   # 懶得做異常處理了
            if pd_count < 1:
                res["status"] = 401
                res["msg"] = "秒殺已經結束了!"
                return jsonify(res)

            # 判斷使用者是否在集合中
            if r.sismember(user_key, user_id):
                res["status"] = 403
                res["msg"] = "你已經秒殺成功了!"
                return jsonify(res)
            # 產品數量-1
            # 並把使用者id新增進集合中
            r.decr(pd_key)
            r.sadd(user_key, user_id)
        else:
            res["status"] = 500
            res["msg"] = "伺服器繁忙,請稍後再試!"

    return jsonify(res)


if __name__ == '__main__':
    app.run()

參考:

redis的持久化(RDB&AOF的區別)
redis快取穿透怎麼解決
深入學習Redis(3):主從複製
Redis Cluster日常操作命令梳理
還有些忘記記錄下來了

相關文章