玩轉redis

本文總結了:
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 //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="//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日常操作命令梳理
還有些忘記記錄下來了

Tags: