Redis 是一個內存型「數據庫」,除存儲以外,它還有許多強大的命令,使之遠遠超出了數據庫的定義,因此官方稱之爲「data structure store」,數據結構存儲系統。 經過 Redis 提供的指令,咱們能夠實現緩存、消息隊列、事件通知、排行榜、庫存管理、分佈式鎖等功能。node
Redis 核心是單進程單線程服務,經過 epoll、select 等實現了 IO 多路複用,能夠併發處理網絡事件。redis
Redis 提供瞭如下幾種典型的數據結構算法
Redis 實現了名爲 SDS(Simple Dynamic String) 的字符串類型,與 C 字符串區別:spring
Redis 實現了雙向無環鏈表,並使用此數據結構實現了 list。sql
Redis 實現了符合自身使用場景的 HashMap,即數組加鏈表的實現。此數據結構實現了 Redis 中的 Hash、Set 數據類型。特色以下:數據庫
跳躍表經過給鏈表分層,實現了平均 O(logN),最壞 O(N) 的時間複雜度。Redis 使用該數據結構實現了 Sorted Set 數據類型。另外 Sorted Set 中還須要使用 HashTable 來實現 O(1) 的查詢。json
整數集合,即只保存整數的集合。Redis 使用該數據結構實現了 Set。數組
壓縮列表。壓縮列表是一種犧牲性能節約空間的數據結構,相比鏈表,它節約了指針的空間,Redis 將它做爲 List、Hash、Sorted Set 的實現,並使用 hash-max-ziplist-entries(512)、hash-max-ziplist-value(64)、list-max-ziplist-size(8 Kb)、zset-max-ziplist-entries(128)、zset-max-ziplist-value(64) 配置來決定是否使用 ziplist。緩存
不管是內存型的數據庫仍是關係型數據庫,宕機、停電後數據沒法恢復都是不可接受的。Redis 有兩種備份數據的方式:安全
即 Append-Only-File,當開啓備份時,Redis 會建立出一個默認名稱爲 appendonly.aof 的文件。並將內存中全部數據以命令的形式寫入文件中,後續執行新的操做數據的命令時,會放入緩衝區中定時寫入文件(appendfsync 不爲 always 時)。 在 redis.conf 中用如下參數配置 AOF 策略:
appendonly yes/no 是否開啓 AOF 模式
appendfilename appendonly.aof
appendfsync always/everysec/no #寫入磁盤時機,always 表示每次都會同步到磁盤,因爲是同步操做,性能降低嚴重。everysec 表示每秒刷盤。no 表示只放入緩存區中,由操做系統指定刷盤時機(Linux 通常是 30 秒) 複製代碼
當我執行了如下命令時:
set liuzhiguo 123 set liuzhiguo abc set liuzhiguo 456 set liuzhiguo 1231 ex 30 複製代碼
AOF 文件長這樣:
*2 消息行數
$6 第一條消息長度 SELECT 消息內容 $1 第二條消息長度 0 消息內容 *3 $3 set $9 liuzhiguo $3 123 *3 $3 set $9 liuzhiguo $3 abc *3 $3 set $9 liuzhiguo $3 456 *3 $3 set $9 liuzhiguo $4 1231 *3 $9 PEXPIREAT $9 liuzhiguo $13 1544420872751 複製代碼
能夠看出 AOF 模式是直接將命令寫入文件中,因此在恢復數據時,Redis 會逐條執行命令來恢復數據。因此 AOF 模式恢復數據的效率並不高,並且當重複對一個 key 進行操做時,也須要執行全部操做命令。 針對同一數據重複操做的問題,Redis 提供了 AOF 重寫的功能,即丟棄原有的 appendonly.aof 文件,從新將內存中的數據做爲命令寫入文件中。
即 Redis DataBase,此持久化模式默認開啓。 開始備份時,Redis 會 fork 出一個子進程(bgsave),建立默認名爲 dump.rdb 的二進制文件,逐個對內存中的數據進行備份。每次備份時都會拋棄原有的 RDB 文件,從新將數據全量備份。 對於備份的時機,在 redis.conf 有如下選項來觸發備份:
save 900 1 900 秒內有 1 次變更
save 300 10 300 秒內有 10 次變更
save 60 10000 60 秒內有 10000 次變更
複製代碼
RDB 因爲體積和自然的指令壓縮能力,恢復數據速度要大大快於 AOF。可是由於每次只能全量備份,資源消耗比 AOF 大,不如 AOF 靈活。而且由於備份時機的不肯定性,數據完整不如 AOF。
Redis 在 4.0 以後提出了 RDB-AOF 混合模式持久化,能夠在 redis.conf 中經過 aof-use-rdb-preamble 選項開啓。 此模式下,全量備份、重寫 AOF 時會使用 RDB 格式,隨後執行命令仍是以 AOF 的格式追加到文件中。
這樣一來,恢復數據時性能比單純 AOF 強,全量備份比 AOF 快,備份體積比 AOF 小,部分備份性能比 RDB 高。
Redis 經過哨兵(Sentinel)與複製的方式實現了高可用
經過在 redis.conf 文件中配置「slaveof ip port」或給運行中的 redis 節點執行命令「slaveof ip port」,便可使得該節點成爲某個 redis 實例的從節點。
從節點(slave)啓動時會向主節點(master)發送 sync 指令,主節點使用 bgsave 方法生成 RDB 文件,並創建緩衝區記錄寫命令。RDB 文件生成會即發送給從節點,從節點開始載入 RDB 文件,此動做同步執行。 從節點完成載入後,主服務器會將緩衝區的記錄發送給從服務器,此後主節點每當有執行命令時,都會傳播給從節點一份。
斷線重連後,從節點再次上線時會向主節點發送 psync 命令執行部分重同步,主節點會將此期間的命令發送給從節點執行。爲實現此功能,主從節點維護了「複製偏移量」。
使用 info 能夠查看複製的狀態:
# Replication role:master connected_slaves:1 slave0:ip=127.0.0.1,port=6380,state=online,offset=280,lag=0 // 從節點信息 master_replid:6088224db78515c7c2cbef387fb90cefd459f0d5 master_repl_offset:280 // 主節點偏移量 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:1 repl_backlog_histlen:280 # Replication role:slave master_host:127.0.0.1 master_port:6379 master_link_status:up master_last_io_seconds_ago:1 // 與主節點 1 秒前同步 master_sync_in_progress:0 // 是否在進行 sync 同步 slave_repl_offset:280 // 從節點偏移量 slave_priority:100 slave_read_only:1 connected_slaves:0 master_replid:6088224db78515c7c2cbef387fb90cefd459f0d5 master_replid2:0000000000000000000000000000000000000000 master_repl_offset:280 second_repl_offset:-1 repl_backlog_active:1 repl_backlog_size:1048576 repl_backlog_first_byte_offset:1 repl_backlog_histlen:280 複製代碼
爲實現高可用,只有複製是不夠的,還須要主節點服務不可用後,從節點能自動補位。 Redis 經過 Sentinel 來實現節點監控與協調,Sentinel 是一個特殊的 Redis 節點,須要啓動時指定參數 --sentinel 和 sentinel.conf 配置文件,並在配置文件中指定主節點的 ip、host。 Sentinel 啓動後會向主節點發送 info 命令,獲取到相應的從節點信息,並與從節點創建鏈接。 當主節點不響應時,Sentinel 會等待至配置中指定的 timeout 時間,隨後將從節點提高爲主節點。主節點再次啓動時,Sentinel 會向主節點發送 slaveof 命令,要求其成爲從節點。
Sentinel 自己一樣支持高可用,多個 Sentinel 會向每一個主從節點 publish 本身的信息,以此來得知其餘 Sentinel 的存在並創建鏈接。多個 Sentinel 共存時,對主從節點狀態、身份的共識會有更復雜的協調過程,這就是另一個漫長的故事了。
對 Sentinel 的詳細介紹,能夠見:https://redis.io/topics/sentinel ,以及參考《Redis 設計與實現(第二版)》.
Redis 由於是內存型數據庫,在存儲空間上容易捉襟見肘,因而產生了許多擴容方案。
如 ShardedJedis,經過在客戶端對 key 進行 hash,再分給指定的節點。 優勢:無需改動 Redis 便可擴容 缺點:只能擴容一次,沒法平滑升級
如 Twemproxy。代理層接收客戶端的請求,代理到對應的 Redis 節點上,一般也是使用一致性 hash 來分片。並因爲代理層能夠統一配置或讀取同一數據源,作到可拓展代理層。 優勢:客戶端無需關心 Redis 服務狀態,也無需分片。 缺點:難以擴容。
Redis 本身實現的集羣,可實現無痛擴容,平滑遷移。啓動集羣模式須要在配置文件中配置:
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 1500
複製代碼
集羣模式下,會建立出 16384 個槽,並給集羣中每一個節點分配本身的槽數,槽必須被所有指定才能工做,一個節點最低指定一個槽。因此 Redis 集羣理論上最大是 16384 個節點。
當須要添加/獲取某個 key 時,經過 crc16(key) & 16384 獲得這個 key 應在的槽,隨後找出這個槽所在的節點,若是節點是本身直接執行,不然會返回給客戶端對應的節點的 ip + port。
Redis 集羣是去中心化的,彼此之間狀態同步靠 gossip 協議通訊,集羣的消息有如下幾種類型:
因爲去中心化和通訊機制,Redis Cluster 選擇了最終一致性和基本可用。例如當加入新節點時(meet),只有邀請節點和被邀請節點知道這件事,其他節點要等待 ping 消息一層一層擴散。除了 Fail 是當即全網通知的,其餘諸如新節點、節點重上線、從節點選舉成爲主節點、槽變化等,都須要等待被通知到。
所以,因爲 gossip 協議,Redis Cluster 對服務器時間的要求較高,不然時間戳不許確會影響節點判斷消息的有效性。另外節點數量增多後的網絡開銷也會對服務器產生壓力。所以官方推薦最大節點數爲 1000。對於 Redis 集羣的運維,能夠參考 優酷藍鯨近千節點的 Redis 集羣運維經驗總結。
優勢:
缺點:
Redis 經過 Cluster 解決了擴容以後,客戶端該怎麼使用呢? 如 JedisCluster,每次請求前會拉取節點的 cluster info 來計算應該到哪一個節點請求,並須要對錯誤節點返回的 ASK 消息作相應的處理。由此產生的問題是
對問題 1,解決辦法是客戶端緩存集羣狀態。對問題 2,JedisCluster 支持配置多個節點,拉取節點信息時會隨機選擇某節點以分攤壓力。對問題 2 的處理方式,須要將 Redis 節點信息同步到客戶端配置中,產生了耦合。
另外的問題是,集羣狀態下是不支持 mget、mset 等須要跨節點執行的命令。該問題的解決方案是加一層 Proxy,推薦 優酷土豆的Redis服務平臺化之路,其使用 Nginx + Redis Cluster 的思路使人讚歎,並用請求聚合的方式實現了跨節點執行命令的問題。
阿里雲提供的 Redis 服務一樣實現了集羣模式下的跨節點命令,採用代理 + 分片服務器 + 分片配置服務器(極可能是 zookeeper),可是沒有使用 Redis Cluster 機制,而是本身實現的「分片」,保留了 slot。阿里雲的 Redis 好處是集羣版無需客戶端作兼容,能夠當成單機 Redis 使用,出了問題方便甩鍋。
在等待 Redis 出官方集羣方案以前,人們火燒眉毛想要集羣版的 Redis,一些不滿於現狀以及不滿於 Redis Cluster 實現的人們開始對 Redis 進行改造。前面提到的阿里雲 Redis 也屬於魔改後的 Redis。
Codis 幾乎是最知名的第三方 Redis,對 Redis 進行了大量改造。 其架構爲 zookeeper + proxy + server-group(master + slave),並提供了控制檯以即可視化運維。
經過 zookeeper 記錄可用的 proxy 節點,再使用 Codis 開發組基於 Jedis 修改的 Jodis 客戶端到 zookeeper 中尋找可用的 proxy 節點進行調用。若是使用的是 jedis 或其餘客戶端,則只能到鏈接一個 proxy,或者想辦法鏈接到 zookeeper 獲取節點,再進行輪詢調用。
Codis 支持彈性擴容,分片方式與 Redis Cluster 相似,經過 crc32(key) % 1024
分紅 1024 個槽,每臺實例保存對應槽的數據。
LedisDB 和 SSDB 很是類似,都是用 LevelDB 底層,從新實現了 Redis,或者說只實現了 Redis 協議。經過多線程 + 硬盤的方式,實現了和單機 Redis 類似的 QPS 性能,並能夠很大程度上對容量進行擴容。 LedisDB/SSDB 與 Redis 的關係,至關於 TiDB 與 MySQL 的關係。
缺點是出了容量上的成本優點,其餘沒有任何優點。
Redis 提供 watch
、multi
、exec
等方法實現樂觀鎖事務。使用事務的流程以下:
若是 multi ~ exec 之間 key1/key2 被其餘客戶端修改過,exec 時會返回 nil, set key1 value一、set key2 value2 均不會執行。 Redis 會保存一個 watch_keys 字典,結構爲: client -> keys、is_dirty。Redis 在處理每個會修改數據的命令時,會檢查 watch_keys 是否存在該 key,若是有,則修改 is_dirty 爲 true。
執行事務的客戶端在執行 exec 時,會檢查 is_dirty 字段,若是發現爲 false,全部的積累的指令會直接丟棄不執行。
事務在 Redis 中的使用場景很少,併發量大的狀況下須要反覆重試,大部分狀況下有更好的使用方式:
Redis 提供了對 Lua 腳本的支持,原子性執行一系列指令,並能夠寫代碼作邏輯判斷。 例如須要大量插入數據的場景:
for i=1,10000000,1 do local num = math.random(1000000,999999999); redis.call("set",num,i) end 複製代碼
執行一千萬條命令在本機大概用了 12 秒,QPS 83w。 Redis 在執行 Lua 腳本時是單線程,沒法處理其餘請求,這也是 Redis 原子性的緣由。下面是搶紅包時利用該特性實現的 Lua 腳本:
// 該腳本傳入 4 個參數 // KEYS[1] = 未領取的紅包列表 key // KEYS[2] = 已領取的紅包列表 key // KEYS[3] = 紅包已領取人ID列表 key // KEYS[4] = 領取人ID // 檢查領取人是否在已領取列表內 if redis.call('hexists', KEYS[3], KEYS[4]) ~= 0 then return nil else // 取出一個未領取的紅包 local redEnvelop = redis.call('rpop', KEYS[1]); if redEnvelop then // 紅包中的 receiver 填入領取人 ID local x = cjson.decode(redEnvelop); x['receiver'] = KEYS[4]; local re = cjson.encode(x); // 領取人放入已領取人ID列表,將紅包放入已領取紅包列表 redis.call('hset', KEYS[3], KEYS[4], KEYS[4]); redis.call('lpush', KEYS[2], re); // 給相應的 key 續期 if redis.call('llen', KEYS[2]) == 1 then redis.call('expire', KEYS[2], 172800); end if redis.call('hlen', KEYS[3]) == 1 then redis.call('expire', KEYS[3], 172800); end return re; end end return nil 複製代碼
須要注意的是,因爲 Lua 腳本只能在單個 Redis 實例執行,因此在集羣狀態下執行 Lua 時,Redis 會對要執行的 key 進行檢查。爲了保證全部 key 必定在某一臺機器上,Redis 限制了全部 key 都必須在同一個 slot 內才行。
因此針對紅包的場景,對 Lua 中傳入的 key 作了xxx{redpacketId}
的處理,以保證全部 key 落在一個 slot 上。
Redis 支持使用管道批量執行命令,再統一返回,減小往返次數,一般用於批量插入數據,批量獲取數據。
緩存是 Redis 最多見的場景。一般緩存的過程爲:
不推薦更新數據時同時更新到緩存,由於可能併發更新致使髒數據。見 爲何 Facebook 刪除緩存而不是更新緩存? 以及 Scaling Memcache at Facebook,其中提到「We choose to delete cached data instead of updating it because deletes are idempotent」。 但刪除緩存並非徹底不會致使髒數據,只是機率會相對小不少。
查詢時可能會須要相似 where id in (xx,yy,zz) 的狀況,這時查詢緩存可使用 mget
同時查詢多個 key,能夠大大提升效率。下面是 benchmark 數據:
get 81833.06 requests per second
mget 10 73475.39 requests per second 734,753
mget 20 64226.07 requests per second 642,260
mget 30 59559.26 requests per second 1,786,770 99% < 1 milliseconds
mget 50 48995.59 requests per second 2,449,750 99% < 1.5 milliseconds
mget 100 29214.14 requests per second 2,921,414 99% < 2.5 milliseconds
mget 200 16730.80 requests per second 3,346,000 99% < 3 milliseconds
mget 500 7222.30 requests per second 3,611,150 99% < 9 milliseconds
複製代碼
根據總獲取數據個數、平均響應時間,一般認爲 mget 數量控制在 100 如下是比較均衡的。
按每次 mget 100 與 get 相比,性能至關於提升了 35 倍。再加上跨機器調用往返的時間消耗,實際狀況性能提高極可能 100 倍以上。
Redis 能夠經過 SET key randomValue NX EX 30
給某個 key 賦值,並同時判斷 key 是否存在,以及給定過時時間。過時時間要根據業務變化。
釋放鎖能夠直接 del 掉這個 key。可是 del 是有風險的:
例如 A 獲取到鎖,過時時間 30 秒。由於某些緣由 30 秒沒能處理完請求,B 過來也獲取到了鎖。此時 A 處理完執行釋放鎖的操做,就會釋放掉 B 所持有的鎖。
爲了不這個問題,須要判斷 value 是否是 set 時的 value,若是是才執行 del 操做。爲了讓這兩條命令原子性執行,須要使用到 lua 腳本:
- KEYS[1] 爲 鎖名稱,ARGV[1] 爲鎖內容, 即 set 時的 randomValue if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end 複製代碼
另外在 spring-data-redis 的實現中,是沒有 set nx ex
的,因此須要找到 Jedis 或者 Lettuce 調用原生方法。
Redis 另外一個值得稱道的命令就是自增了,其提供了 incr/incrby/incrbyfloat(string)、hincrby/hincr(hash)、zincrby(zset)方法供不一樣數據類型使用。
經過這些命令能夠實現對庫存的扣減,記錄接口訪問頻次,記錄一篇文章的點贊數、評論數、轉發數,搶紅包扣減數量等。
利用 zset 有序列表,好比要計算用戶積分排行榜:
利用 list 的 lpush
(Left Push) 和 brpop
(Blocked Right Pop) 接口能夠實現消息隊列功能:
lpush
到隊列中。brpop
監聽隊列並取出數據進行消費。在消費的過程當中能夠經過配置線程池,根據業務狀況決定消費速率。
好比秒殺、搶紅包時,庫存數據須要異步入庫。但僅僅異步入庫也是不夠的,並不會減小對數據庫操做的次數。這時候可能須要將 100 次請求壓縮成一次請求,只取最後的數據落庫。
此類需求則能夠用 zset + list 實現,咱們須要幾個東西:
這也是紅包中的異步更新的實現方式。在搶羣紅包時,若是每次都更新數據庫中的數據,勢必會增長響應時間。使用這種更新方式的話,只在最後一次搶紅包的 30s 後更新,30s 以內發生的數據更新,都只會合併爲 1 條。
Redis 提供了publish
、subscribe
等命令實現了廣播功能,publish 時能夠將消息通知到某個頻道(channel),此時 subscribe 了這個頻道的節點均能收到消息。
經過這個機制咱們能作到對全節點的事件通知。
好比在積分系統中會將全部活動、抽獎、簽到、搖錢樹等數據庫配置數據放入 JVM 緩存中,以便得到最高的性能。 爲了更新數據,一開始是每分鐘到數據庫更新一次。但問題是每臺實例更新的時機都不一樣,致使請求到 A 實例的數據,與 B 實例上的不一樣。隨後將定時任務的配置改爲了每分鐘的第 0 秒執行,則很大程度上改善了問題。
可是輪詢的方式仍然不夠優雅,絕大部分時候取得的配置並無變化,是無用的請求。更新配置的時機應該是配置發生了變動纔對。
這時就可使用 Redis 廣播,每當數據庫數據發生變化時,經過廣播通知全部節點更新數據,或者乾脆將要更新的數據放入廣播中。
Redis 雖然性能強悍,可是因爲單線程的特性,一旦產生慢查詢,會將全部操做都阻塞住。因此使用上仍須要注意會踩哪些坑。Redis 提供了 slowlog get 查看慢查詢。
keys *
keys 命令的時間複雜度是 O(n),n 是 Redis 中全部鍵的數量,這個是最多見的性能最差的命令。通常線上都把這個命令 block 掉(在配置中加 rename-command KEYS "")
大 key。一個 key 裏存儲的數據越多,一般性能越差,好比對超大的 List 進行 lindex 和 lrange。另外大 value 在集羣數據遷移時會阻塞可能致使 fail over。甚至在刪除時也會阻塞,例如刪除一個 1kw 數據量的 set,須要耗時 5s。或者在集羣中大 key 會致使集羣內存分配不均勻。因此在使用時須要避免在一個 key 中放入過多數據。
bgrewriteaof
、bgsave
,重寫 aof 文件及備份 RDB 文件時,會 fork 出子進程和內存,此期間是阻塞的,取決於 Redis 內存大小和機器性能。因此許多企業的作法是主節點上關閉 aof 和 rdb,只在從節點上備份。
積分系統中存在一個進貢的任務,邀請人可得到被邀請人作任務的獎勵,並在天天凌晨入帳。
對於這個任務,咱們作的第一步優化就是天天將得到了進貢獎勵的用戶,保存在 set 裏,經過 sscan 遍歷須要進貢的用戶,執行任務。以此避免了掃庫,保證每次取得的 userId 都是確切有效的。問題在於萬一子彈短信火了,set 中的 userId 會原來越多,也就遇到了大 key 的問題,須要將 set 拆分爲多個 set。
拆分的思路和 Redis 集羣分片相似,經過 hash(userId) % count 的方式,獲得 0 ~ count 之間的分片數,將其加到本來的 key 上,過程以下:
例如保存一篇文章的點贊數、轉發數、評論數時,既能夠保存爲 3 個 value,即 article:like、article:repost、article:comment。也能夠保存爲一個 hash 對象,key 爲 article,hashKey 爲 like、repost、comment。
好處:
使用 value :
# lua for i=1,1000000,1 do redis.call("set","article:like:"..i,1) redis.call("set","article:repost:"..i,1) redis.call("set","article:comment:"..i,1) end # memory used_memory:226568704 used_memory_human:216.07M used_memory_rss:282144768 used_memory_rss_human:269.07M 複製代碼
使用 hash :
# lua for i=1,1000000,1 do redis.call("HMSET","article:"..i, "like", 1, "repost", 1, "comment", 1) end # memory used_memory:121402896 used_memory_human:115.78M used_memory_rss:132640768 used_memory_rss_human:126.50M 複製代碼
value 幾乎多使用了一倍內存。緣由是 hash 類型這時會選擇 ziplist 數據結構實現。