騰訊面試之言淺意深 Redis
本文轉載自微信公眾號「碼農私房話」,作者Liew。轉載本文請聯系碼農私房話公眾號。
redis 與 memcached 的區別
數據類型:memcached 只支持 string,而 redis 還支持列表 list、集合 set、hash、有序集合 sortedSet、位圖 bitMap、HyperLogLog 以及 Stream(redis 5.0 版本新特性)。
主從備份:redis 支持主從的模式應用,主數據庫有多個副本,使得可以擴展數據庫讀取能力,且具有高可用性。
數據存儲:memcached 與 redis 都支持數據存儲在內存中,除此之外, redis 還支持將數據保存在磁盤中。
持久化:memcached 的數據只保存在內存,宕機后數據將丟失,而 redis 可利用持久化機制將數據保留在磁盤上,用于歸檔或恢復。
事務:redis 支持事務,可將一組命令原子操作地執行。
發布/訂閱:redis 支持具有模式匹配的發布/訂閱消息功能。
Lua 腳本:redis 允許執行事務性 Lua 腳本,幫助提高性能并簡化應用代碼。
地理空間支持:redis 具有專用命令,可以處理大規模實時地理空間數據,例如查找兩個元素(人或地方)之間的距離以及查找點的給定距離內的所有元素。
線程模型:redis 使用的是單線程模型,而 memcached 使用的是多線程架構,可以利用多個核心及擴大計算能力來處理更多操作,在一定程度上性能會比 redis 優秀。
多語言客戶端支持:redis 和 memcached 都支持多種語言客戶端,包括 Java、Python、Php、C、C ++、Go等。
redis 有哪些數據類型
字符串 String、列表 list、集合 set、有序集合 sortedSet、哈希 hash、位圖 bitMap 、Stream 流(redis 5.0 版本新特性)、HyperLogLog。
redis 的使用場景有哪些?
熱點數據緩存:對于系統中常用且不常更新的數據可加載到 redis ,提升性能。
分布式鎖:結合 setexnx命令實現或者直接用 Redisson 的功能。
排行榜:使用 Sorted Sets 輕松實現游戲排行榜。
隊列:redis 的 list 底層是鏈表,但同時也可用于隊列,使用 lpush 、brpop 命令操作隊列。
計數器:控制一個手機號一天限制發送 5 條短信,或用于庫存扣減,保證不超發。
布隆過濾器:快速準確判斷 10 萬個號碼是否在 10 億個號碼庫里或者請求 IP 地址是否在 10 億 的黑名單庫。
GeoHash:實現美團外賣或餓了么「附近的商家」功能,或者計算兩個人之間的距離。
BitMap:使用位圖可實現用戶類似近 7 天簽到功能或某時間范圍內用戶的登錄狀態。
延遲操作:使用 SortSet 實現延遲隊列,例如訂單在 30 分鐘內未支付則自動取消并發送短信。
好友關系及點贊:set 集合可用于記錄文章的點贊、閱讀數;用 zinterstore 查詢共同好友、zset 實現好友關系。
分布式限流:基于令牌桶算法,利用 Lua 腳本實現分布式限流,Spring Cloud Gateway中的限流就是典型例子。
redis 線程模型
在 redis 6 版本前均采用的是單線程模型,基于 Reactor 模式開發了網絡事件處理器,redis 在處理客戶端的請求時,包括請求命令的獲取、解析、執行、內容返回等都由一個順序串行的主線程處理,這就是所謂的“單線程”。
但在 redis 6 版本后便正式引入多線程模型,隨著越來越復雜的業務場景,需要更高的 QPS,常用的解決方案是對數據分區并采用更多的服務器,但該方案缺點是 投入的成本高,維護的 redis 服務器多。
在redis 執行期間,網絡的讀寫及系統調用占用了大部分 CPU 時間,而瓶頸主要在于網絡的 IO 消耗,因此優化的主要有以下原因:
- 充分利用服務器 CPU 資源,而目前 redis 主線程只能利用一個核。
- 多線程任務可以分攤 redis 同步 IO 讀寫負荷,例如:memcached。
redis 為何選擇單線程網絡模型?
- 使用單線程模型能帶來更好的可維護性,方便開發與調試。
- 使用單線程模型也能并發的處理客戶端的請求。
- 避免頻繁的 CPU 上下文切換開銷及多線程帶來的安全同步。
- redis 服務中運行的絕大多數操作的性能瓶頸都不是 CPU。
從官方中給出的解析可以看到官方認為 redis 的大多數命令操作性能瓶頸并不在 CPU,主要受限于內存和網絡。
- It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.
因此,第三點起到決定性的因素,另外兩點是使用單線程帶來的好處。
redis 為什么這么”快“?
1、完全基于內存,絕大部分請求是純粹的內存操作。
2、采用單線程,避免了不必要的上下文切換和競爭條件,但同時也無法利用多核的優勢。
3、使用多路 I/O 復用模型,實現高吞吐的 IO 操作。
4、數據結構簡單,大多數讀/寫操作為 O(n) 或 O(log(N))。
使用 redis 的隊列存在什么問題 ?
1、存在消息丟失的可能性。
2、生產速度與消費速度不匹配引起消息堆積,將會導致 redis 內存耗盡。
3、隊列中的消息不允許重復消費。
redis 如何實現分布式鎖 ?
實現思想大致如下:
1、使用 setnx、setex 命令把當前獲取鎖的請求信息(鎖的 key、線程 id等)保存到 redis,同時記錄同個線程獲取鎖的次數(實現可重入功能)。
2、釋放瑣時,為避免誤刪,需判斷當前操作的線程是否與加鎖的是同一個,若是同一個則 del 對應鎖的 key 即可。
涉及到多個命令執行,需把獲取鎖、釋放鎖的邏輯放在 lua 腳本中保證原子性,具體可參考 Redisson 分布式鎖的實現:
獲取鎖邏輯代碼:
- if (redis.call('exists', KEYS[1]) == 0) then
- redis.call('hset', KEYS[1], ARGV[2], 1);
- redis.call('pexpire', KEYS[1], ARGV[1]);
- return nil;
- end;
- if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
- redis.call('hincrby', KEYS[1], ARGV[2], 1);
- redis.call('pexpire', KEYS[1], ARGV[1]);
- return nil;
- end;
- return redis.call('pttl', KEYS[1]);
釋放鎖邏輯代碼:
- if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
- return nil;
- end;
- local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
- if (counter > 0) then
- redis.call('pexpire', KEYS[1], ARGV[2]);
- return 0;
- else
- redis.call('del', KEYS[1]);
- redis.call('publish', KEYS[2], ARGV[1]);
- return 1;
- end;
- return nil;
如何解決業務端未執行完邏輯,但鎖已過期?
可參考 Redisson 框架處理鎖租期的方案,在每次獲取鎖時判斷 leaseTime 是否為 -1 ,若是則在獲取鎖成功后新建一個線程專門檢測對應鎖的 key 是否過期,過期的話則調用 lua 腳本更新 key 的過期時間。
- private RFuture<Boolean> tryAcquireOnceAsync(long leaseTime, TimeUnit unit, long threadId) {
- if (leaseTime != -1) {
- return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
- }
- RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
- ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
- if (e != null) {
- return;
- }
- // lock acquired
- if (ttlRemaining) {
- scheduleExpirationRenewal(threadId);
- }
- });
- return ttlRemainingFuture;
- }
scheduleExpirationRenewal() 方法具體調用邏輯如下:
- private void renewExpiration() {
- ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
- if (ee == null) {
- return;
- }
- Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
- @Override
- public void run(Timeout timeout) throws Exception {
- ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
- if (ent == null) {
- return;
- }
- Long threadId = ent.getFirstThreadId();
- if (threadId == null) {
- return;
- }
- RFuture<Boolean> future = renewExpirationAsync(threadId);
- future.onComplete((res, e) -> {
- if (e != null) {
- log.error("Can't update lock " + getName() + " expiration", e);
- return;
- }
- // reschedule itself
- renewExpiration();
- });
- }
- }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
- ee.setTimeout(task);
- }
通過代碼發現每次鎖續期完成后又會重新創建新線程刷新租期。
redis lua 實現分布式鎖存在的問題
1、對設置有租約時間的客戶端,當長時間阻塞將導致鎖失效。
2、當 redis master 發生故障時,某 slave 升級為新 master,但鎖信息未同步到新的 master ,導致其他請求能獲取鎖。
什么是 RedLock
對于 redis 主從漂移時,將導致鎖失效的問題,redis 作者提出 RedLock 的算法:假設 redis 的部署模式是 redis cluster,總共有 3個 master 節點,加鎖的時候,它會向多半節點發送 setex mykey myvalue 命令,只要過半節點成功,才算加鎖成功。同樣當釋放鎖的時候需要向所有節點發送 del 命令,感興趣的可以閱讀 Redisson 源碼實現。
使用 RedLock 雖解決了 master 故障帶來的同步問題,但它需要更多的 redis 實例資源,同時性能也會有一定的折損。
講講緩存穿透、擊穿、雪崩
緩存穿透:指緩存和數據庫中都沒有的數據,而用戶或攻擊者不斷發起請求,如發起為 userId 為負數或不存在的數據,將導致數據庫壓力過大,解決方案:
1、對參數值做有效性校驗、用戶鑒權等。
2、對緩存與數據庫中都不存在的數據,可映射其 userId -> null 到緩存中,并根據業務場景設置過期時間,防止攻擊者的暴力攻擊。
緩存擊穿:指緩存中沒有數據,但數據庫中有數據,一般是未做熱加載或緩存過期導致,在某一刻由于并發查詢同一條數據的請求特別多,讀緩存無數據,因此同時去數據庫查詢數據,引起數據庫壓力瞬間增大,解決方案:
1、設置熱點數據緩存過期時間更長或永久。
2、當查詢緩存無數據時,使用互斥鎖控制只允許一個線程 A 查詢數據庫,其余請求線程等待線程 A 加載數據到緩存,如Guava Cache在查詢緩存無數據時,只允許一個線程加載。
緩存雪崩:指緩存中大批量數據到過期時間,同時查詢數據量巨大,引起數據庫壓力過大甚至宕機。與緩存擊穿不同的是,緩存擊穿是圍繞并發查詢同一條數據,而緩存雪崩是不同數據都過期了,很多數據都查不到從而查數據庫,解決方案:
1、設置緩存數據的過期時間隨機,防止同一時間內大量數據發生過期。
2、設置熱點數據過期時間更長或永久。
談談緩存和數據庫一致性問題
常見的緩存與數據庫操作順序有幾種方式:
先寫緩存,再更新 DB:
- 如果第一步更新緩存失敗,直接返回,無影響。
- 如果緩存寫成功,更新 DB 失敗,此時若不清除緩存中已寫入的數據,則會造成數據不一致(緩存中是新值,DB 中是舊值)。如果增加清除緩存的邏輯,那么清除操作又失敗了該如何處理?
先更新 DB,再寫緩存:
- 如果更新 DB 失敗,直接返回,無影響。
- 如果更新 DB 成功,緩存寫入失敗則會造成數據不一致(即 DB 中是新值,緩存中是舊值),如果重試寫入緩存,那重試也失敗該如何處理?
先刪除緩存,再更新 DB:
- 如果刪除緩存失敗,直接返回,無影響。
- 如果刪除緩存成功,更新 DB 失敗,則會造成后續請求未命中緩存,則從數據庫中回查數據。
先更新 DB,再刪除緩存:
- 如果更新 DB 失敗,直接返回,無影響。
- 如果更新 DB 成功,刪除緩存失敗則會造成數據不一致(DB 中是新值,緩存中是舊值)。
該問題本質上就是一個分布式數據一致性問題,在不要求強一致性的場景下,保證最終一致性即可,在更新完數據庫后,通過 Canal 訂閱 MySQL 的 binlog 日志使緩存失效,若操作緩存失敗,把緩存信息放至 MQ 重試。
對數據要求強一致性或無法接收臟數據,最簡單的方式是不使用緩存,直接走數據庫。
redis 持久化方式哪些?
RDB,全稱 Redis Database,在指定的時間間隔內將內存中的數據集以快照的方式寫入磁盤,實際操作過程是 fork 一個子進程,先將數據集寫入臨時文件,寫入成功后,再替換之前的文件,用二進制壓縮存儲,在恢復數據時將快照文件直接讀到內存里。
優點:
- RDB 快照是壓縮后的二進制文件,文件的大小會很小,比較適合使用全量復制與備份的場景。
- 相比于 AOF 機制,如果數據集很大,RDB 的恢復效率會更高。
缺點:
- 如果想保證數據的高可用性,即最大限度的避免數據丟失,那么 RDB 不是一個很好的選擇,因為系統一旦在定時持久化之前出現宕機現象,沒有來得及寫入磁盤的數據都將丟失。
- 由于每次生成 RDB 快照都需要 fork 子進程生成全量數據的快照,占用 CPU 與磁盤資源,不適合于頻繁執行。
- 兼容問題,不同版本的 redis 生成的快照可能不兼容。
AOF,全稱為 Append Only File,將操作命令與數據以格式化的方式追加到操作日志文件的尾部,在 append 操作返回后(已經寫入到文件或者即將寫入),才進行實際的數據變更,日志文件保存了歷史所有的操作過程,當 redis server 需要恢復數據時,可直接重放該日志文件,即可還原所有的操作過程。
在 redis 中提供每秒同步、每次修改同步、不同步3 種同步策略。實際每秒同步是異步完成的,其效率高,一旦系統出現宕機,則這一秒內修改的數據將會丟失。
每次修改同步,即每次發生的數據變化都會被立即記錄到磁盤中,可想而至,這種同步方式效率是最低的。
優點:
- AOP 機制提供更高的數據安全性,即數據持久性。
- AOF 持久化方式包含一個格式清晰、易于理解的日志內容,用于記錄所有的修改操作。
- 對于寫入了一半數據后出現了系統崩潰的現象,redis 能通過 redis-check-aof 工具幫助解決數據一致性的問題。
- 當日志文件過大時,redis 會啟動 rewrite 機制,可以刪除其中的某些命令。
缺點:
- 對相同數量的數據而言,AOF 文件通常要大于 RDB 文件,AOF 的恢復數據的速度要比 RDB 效率低。
- 根據同步策略的不同,AOF 在運行效率上通常會慢于 RDB,但每秒同步策略的效率是比較高的,禁用同步策略的效率和 RDB 效率類似。
對于選擇哪種持久化方式,可根據系統能否接受部分性能的犧牲,通過 AOF 方式換取更高的數據一致性,或者禁用 RDB 備份換取更高的性能,待請求量或流量少的時間點再定時執行 save 命令做快照備份,但目前生產環境接觸的更多都是二者結合使用的。
redis 部署方式有哪些
單機模式,即只有一個 redis 實例,所有的服務都連接到該實例上,該模式不適用于生產環境,若 redis 實例發生宕機或內存不足等,將導致所有服務都受影響。
哨兵模式,redis 官方推薦的高可用性方案,在 master 宕機后,redis 本身不具備自動主備切換的功能,而 redis-sentinel 是一個獨立運行的進程,它能監控多個 master-slave 集群,發現 master 宕機后能自動選舉新的 master。
集群模式,隨著業務和數據量劇增,已達到單節點性能瓶頸,垂直擴容受機器限制,水平擴容涉及對業務的影響,及數據遷移時存在數據丟失的風險。
因此在 redis 3.0 推出 cluster 分布式集群方案,當遇到單節點內存、并發、流量瓶頸時,可采用cluster 方案實現負載均衡,該方案主要解決分片問題,把整個數據按照規則分成多個子集存儲在多個不同 redis 節點上,每個節點各自負責整個數據的一部分。
redis 為何使用哈希槽而沒用一致性 hash
redis 集群沒有直接使用一致性哈希,而是使用哈希槽,不同點就是對于哈希空間的定義,一致性哈希的空間是一個圓環,節點分布是基于圓環的,無法很好的控制數據分布,可能會產生數據傾斜問題。
而 redis 的槽位空間是自定義分配的,可以自定義大小,自定義位置的。redis 集群包含了 16384 個哈希槽,每個 Key 經過 CRC16 算法計算后會落在一個具體的槽位上,而槽位具體在哪個機器上是用戶自己根據自己機器的情況配置的,機器硬盤小的可以分配少一點槽位,硬盤大的可以分配多一點。
另外在容錯性和擴展性上與一致性哈希一樣,都是轉移受影響的數據。而哈希槽本質上是對槽位的轉移,把故障節點負責的槽位轉移到其他正常的節點上,擴展節點也是一樣,把其他節點上的槽位轉移到新的節點上。
談談數據遷移時,客戶端訪問數據的流程
當數據遷移過程中,新舊節點對應的槽都存在部分數據,客戶端首先嘗試訪問舊的節點,如果對應的數據在舊節點里,舊節點正常處理。
如果不在舊節點,則可能在新節點或者不存在。當客戶端訪問舊節點不存在時,會向客戶端返回 ASK 或者 MOVED 重定向指令,其中 MOVED 是永久轉向信號,ASK 則表示只需要這一次操作做轉向。
需要注意的是,客戶端查詢新節點時,需要先發一條 ASKING 命令,否則這個請求命令會被帶有 IMPORTING 狀態的槽新節點拒絕執行。
對于客戶端,收到 MOVED 時,需要更新 slot 映射信息,當收到 ASK 時,則需要向新節點發 ASKING 命令并重新執行操作命令。
redis 過期數據清除機制
被動刪除:當操作讀/寫一個已過期的 key 時,會觸發惰性刪除策略,直接刪除過期 key 并且返回NIL。
主動刪除:由于惰性刪除策略無法保證冷數據被及時刪掉,因此 redis 會定期主動淘汰清除已過期的 key。
redis 內存淘汰策略
當前已用內存超過 redis 配置的 maxmemory 限定時,會觸發主動清理策略,策略如下:
- noeviction :不進行數據淘汰,當緩存被寫滿后,Redis不提供服務直接返回錯誤。
- volatile-random :在設置過期時間的鍵值對中隨機刪除。
- volatile-ttl :在設置過期時間的鍵值對,基于過期時間的先后進行刪除,越早過期的越先被刪除。
- volatile-lru:基于LRU(Least Recently Used) 算法篩選設置了過期時間的鍵值對, 最近最少使用的原則篩選數據。
- volatile-lfu:使用 LFU( Least Frequently Used ) 算法選擇設置了過期時間的鍵值對, 使用頻率最少的原則篩選數據
- allkeys-random:從所有鍵值對中隨機選擇并刪除數據。
- allkeys-lru:使用 LRU 算法在所有數據中進行篩選。
- allkeys-lfu:使用 LFU 算法在所有數據中進行篩選。
線上 redis 實例內存不足,該如何處理
這是在面試騰訊音樂時被問到的問題,考察個人應急問題處理能力,首先第一要素是解決問題,即線上擴容,不能影響用戶功能使用,但線上擴容只是解燃眉之急,后面數據增加后不可能繼續擴容,畢竟成本擺在那里,因此可使用 redis cluster 方案把數據均衡的分布存儲在不同的 redis 實例中,解決 redis 單實例存儲過高的問題,但使用 redis cluster 方案也會引入一定的問題,例如某些命令不能在 cluster 下執行,增加數據遷移復雜度等。