05.15 深入淺出分佈式緩存的通用方法

楊彪,螞蟻金服技術專家,《分佈式服務架構:原理、設計與實戰》和《可伸縮服務架構:框架與中間件》作者。近10年互聯網和遊戲行業工作經驗,曾在酷我音樂盒、人人遊戲和掌趣科技等上市公司擔任核心研發職位,做過日活躍用戶量達千萬的項目,也做過多款月流水千萬以上的遊戲。

筆者所在的多家互聯網公司大量使用了緩存,對分佈式緩存的應用可謂遍地開花;筆者曾供職的一家社交媒體網站,號稱是世界上使用緩存最多的公司。毋庸置疑,緩存幫助我們解決了很多性能問題,甚至幫助我們解決了一些併發問題。

一、緩存編程的具體方法

各種分佈式緩存如Redis,都提供了不同語言的客戶端API,我們可以使用這些API直接訪問緩存,也可以通過註解等方法使用緩存。

1、編程法

編程法指通過編程的方式直接訪問緩存,偽代碼如下:

String userKey = ...;

User user = (User)cacheService.getObject(userKey)

if (user == null) {

User user = (User)userDBService.getUser(userKey)

if (user != null)

cacheService.setObject(userKey, user);

}

return user;

這種方法實現起來簡單,但是每次使用時都得敲入類似上面這樣的一段代碼,很繁瑣。可以將這部分內容抽象成一個框架,請參考下文。

2、Spring注入法

spring-data-redis項目實現了注入法,通過Bean注入就可以直接使用Spring的緩存模板提供的方法。

(spring-data-redis項目鏈接:https://projects.spring.io/spring-data-redis)

首先,引入spring-data-redis包:

<dependencies>

<dependency>

<groupid>org.springframework.data/<groupid>

<artifactid>spring-data-redis/<artifactid>

<version>2.0.2.RELEASE/<version>

然後在Spring環境下進行如下配置:

<bean>

class="org.springframework.data.redis.connection.jedis.JedisConnection Factory"

p:use-pool="true"/>

<bean>

class="org.springframework.data.redis.core.RedisTemplate"

p:connection-factory-ref="jedisConnFactory"/>

再通過Spring環境注入使用的服務中:

public class UserLinkService{

// 注入Redis的模板

@Autowired

private RedisTemplate<string> template;/<string>

// 把模板當作ListOperations接口類型注入,也可以當作Value、Set、Zset、HashOperations接口類型注入

@Resource(name="redisTemplate")

private ListOperations<string> listOps;/<string>

public void addLink(String userId, URL url) {

//使用注入的接口類型

listOps.leftPush(userId, url.toExternalForm());

//直接使用模板

redisTemplate.boundListOps(userId).leftPush(url.toExternalForm());

}

}

3、註解法

spring-data-redis項目實現了註解法,通過註解就可以在一個方法內部使用緩存,緩存操作都是透明的,我們不再需要重複寫上面的一段代碼。

(spring-data-redis項目鏈接:https://projects.spring.io/spring-data-redis)

首先,引入相應的依賴包:

<dependency> /<dependency>

<groupid>org.springframework.data/<groupid>

<artifactid>spring-data-redis/<artifactid>

<version>1.6.0.RELEASE/<version>

<dependency> /<dependency>

<groupid>redis.clients/<groupid>

<artifactid>jedis/<artifactid>

<version>2.7.3/<version>

然後,通過一個配置Bean配置Redis連接信息,這個配置Bean會通過Spring環境下的Bean掃描載入:

package com.robert.cache.redis;

import org.springframework.cache.CacheManager;

import org.springframework.cache.annotation.CachingConfigurerSupport;

import org.springframework.cache.annotation.EnableCaching;

import org.springframework.context.annotation.Bean;

import org.springframework.context.annotation.Configuration;

import org.springframework.data.redis.cache.RedisCacheManager;

import org.springframework.data.redis.connection.RedisConnectionFactory;

import org.springframework.data.redis.connection.jedis.JedisConnection Factory;

import org.springframework.data.redis.core.RedisTemplate;

@Configuration

@EnableCaching

public class RedisCacheAnnotationConfig extends CachingConfigurerSupport {

@Bean

public JedisConnectionFactory redisConnectionFactory() {

JedisConnectionFactory redisConnectionFactory = new JedisConnection Factory();

redisConnectionFactory.setHostName("127.0.0.1");

redisConnectionFactory.setPort(6379);

return redisConnectionFactory;

}

@Bean

public RedisTemplate<string> redisTemplate(RedisConnectionFactory cf) { /<string>

RedisTemplate<string> redisTemplate = new RedisTemplate<string>(); /<string>/<string>

redisTemplate.setConnectionFactory(cf);

return redisTemplate;

}

@Bean

public CacheManager cacheManager(RedisTemplate redisTemplate) {

RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);

// 這是默認的過期時間,默認為不過期(0)

cacheManager.setDefaultExpiration(3000);

return cacheManager;

}

}

再在Spring環境下載入這些配置:

<component-scan>

最後,我們就可以通過註解來使用Redis緩存了,這樣我們的代碼就簡單得多了:

@Cacheable("user")

public User getUser(String userId) {

logger.debug("userId=?, user=?", userId, user);

return this.userService.getUser(userId);

}

二、應用層訪問緩存的模式

應用層訪問分佈式緩存的服務架構模式分為:雙讀雙寫、異步更新和串聯模式。

1、雙讀雙寫

雙讀雙寫的架構如圖1所示:

深入淺出分佈式緩存的通用方法

(圖1)

這是我們最常用的緩存服務架構模式。對於讀操作,我們先讀緩存,如果緩存不存在這份數據,則再讀數據庫,讀取數據庫後再回寫緩存;對於寫操作,我們先寫數據庫,再寫緩存。

這種方式實現起來簡單,但是對應用層不透明,應用層需要處理讀寫順序的邏輯,可參考本文的第一部分。

2、異步更新

異步更新的架構如圖2所示:

深入淺出分佈式緩存的通用方法

(圖2)

在異步更新的方式中,應用層只讀寫緩存,在這種情況下,全量數據會被保存在緩存中,並且不設置緩存系統的過期時間,由異步的更新服務將數據庫裡變更的或者新增的數據更新到緩存中。

也有通過MySQL的Binlog將MySQL中的更新操作推送到緩存的實現方法,這種方法和異步更新如出一轍,以Facebook的方案(可參考論文Scaling Memcache at Facebook)為例,如圖3所示:

(Scaling Memcache at Facebook鏈接:

https://cs.uwaterloo.ca/~brecht/courses/854-Emerging-2014/readings/key-value/fb-memcached-nsdi-2013.pdf)

深入淺出分佈式緩存的通用方法

(圖3)

這種方法實現起來稍微複雜,增加了更新服務。這裡的更新服務需要定時調度任務的設計,我們將在之後的內容涉及。這需要更多的開發和運維成本,在設計異步服務時要充分保證異步服務的可用性,要有完善的監控和報警,否則緩存數據將和數據庫不一致。但是在這種模式下性能最好,因為應用層讀緩存即可,不需要讀取數據庫。

3、串聯模式

串聯模式的架構如圖4所示:

深入淺出分佈式緩存的通用方法

(圖4)

在這種串聯模式下,應用直接在緩存上進行讀寫操作,緩存作為代理層,根據需要和配置與數據庫進行讀寫操作。

在微服務的設置中並不推薦採用這種服務的串聯模式,因為它在應用和數據庫中間增加了一層代理層,需要設計和維護這多出的一層,還要保證高可用性,成本較高,但是這種模式有著特殊的場景,比如我們需要在代理層開啟緩存加速,例如Varnish等。

三、分佈式緩存分片的三種模式

在互聯網行業裡,我們做的都是用戶端的產品,有調查稱中國有6億互聯網用戶,這麼多的用戶會給互聯網應用帶來了海量的請求,這也需要存儲海量的數據。因此,單機的緩存滿足不了對海量數據緩存的需求。我們通常通過多個緩存節點來緩存大量的臨時數據,來加速緩存的存取速度。

例如:可以把微博的粉絲關係存儲在緩存中,在獲取某個用戶有權限看見的微博時,我們就可以使用這些粉絲關係。粉絲關係的數據量非常大,一個大V用戶可能有幾千萬或者上億的關注量,可想而知,我們需要多大的內存才能夠存儲這麼多的粉絲關係。

在通用的解決方案中,我們會對這些大數據量進行切片,數據被分成大小相等的分片,一個緩存節點負責存儲其中的多個分片。分片通常有三種實現方式,包括客戶端分片、代理分片和集群分片。

1、客戶端分片

對緩存進行客戶端分片的方案如圖5所示:

深入淺出分佈式緩存的通用方法

(圖5)

客戶端分片通過應用層直接操作分片邏輯,分片規則需要在同一個應用的多個節點間保存,每個應用層都嵌入一個操作切片的邏輯實現,一般通過依賴Jar包來實現。筆者曾工作過的幾家大的互聯網公司都有內部的緩存分片的實現,多數是採用在應用層直接實現的方式,應用層分片的性能更好,實現簡單,有問題時容易定位和修復。

這種解決方案的性能很好,實現起來比較簡單,適合快速上線,而且切分邏輯是自己開發的,如果在生產上出了問題,則都比較容易解決;但是它侵入了業務邏輯的實現,會讓緩存服務器保持的應用程序連接比較多,這要看應用服務器池的節點數量,需要提前進行容量評估。

2、代理分片

對緩存進行代理分片的方案如圖6所示:

深入淺出分佈式緩存的通用方法

(圖6)

代理分片就是在應用層和緩存服務器中間增加一個代理層,把分片的路由規則配置在代理層,代理層對外提供與緩存服務器兼容的接口給應用層,應用層的開發人員不用關心分片規則,只需關心業務邏輯的實現,待業務邏輯實現以後,在代理層配置路由規則即可。

這種方案的好處是讓應用層開發人員專注於業務邏輯的實現,把緩存分片的配置留給代理層做,具體可以由運維人員來實施;缺點是增加了代理層。

儘管代理層是輕量級的轉發協議,但是畢竟要實現緩存協議的解析,並通過分片的路由規則來路由請求,對每個緩存操作都增加了一層代理網絡傳輸,對性能是有影響的,對增加的代理層也需要進行維護,也有硬件成本,還要有能夠解決Bug的技術專家,成本很高。流行的Codis框架就是代理分片的典型實現。

3、集群分片

緩存自身提供的集群分片方案如圖7所示:

深入淺出分佈式緩存的通用方法

(圖7)

有的緩存自身提供了集群功能,集群可以實現分片和高可用特性,我們只需要把它們當成一個由多個緩存服務器節點組成的大緩存機器來使用即可,分片和高可用等對應用層是透明的,由運維人員配置即可使用,典型的就是Redis 3.0提供的Cluster。

四、分佈式緩存的遷移方案

處理分佈式緩存遷移是比較困難的,通常我們將其分為平滑遷移和停機遷移。這裡講解通用的遷移方案,擴容實際上是遷移的一種特殊案例,我們在下面學習的方案全部適用。我們會在講解該方案的過程中,以擴容為例來說明相應的步驟和實現細節。

1、平滑遷移

平滑遷移適合對可用性要求較高的場景。例如:線上的交易服務對緩存依賴較大,不能忍受停機帶來的業務損失,也沒有交易的低峰期,我們對此只能採用平滑遷移的方式。

平滑遷移使用的是雙寫方案,方案分成4個步驟:雙寫、遷移歷史數據、切讀、下線雙寫

這種方式還有一個變種,就是不需要遷移老數據:在第1步中雙寫後,在一定的時間裡通過新規則對新緩存進行寫入,新緩存已經有了足夠的數據,這樣我們就不用再遷移舊數據,直接進入第3步即可。

首先,假設我們的應用現在使用了具有兩個分片的緩存集群,通過關鍵字哈希的方式進行路由,如圖8所示:

深入淺出分佈式緩存的通用方法

(圖8)

因為兩個分片已經不能滿足緩存容量的需求,所以現在需要擴容到4個分片,達到原來兩倍的緩存總大小,因此我們需要遷移。

遷移的具體過程如下:

  • 第1步,雙寫

按照新規則和舊規則同時往新緩存和舊緩存中寫數據,如圖9所示:

深入淺出分佈式緩存的通用方法

(圖9)

這裡,我們仍然按照舊的規則,也就是關鍵字哈希除以2取餘來路由分片,同時按照新的規則,也就是關鍵字哈希除以4取餘來路由到新的4個分片上,來完成數據的雙寫。

這個步驟有優化的空間,因為是在成倍擴容的場景下,所以我們不需要準備4個全新的分片。新規則中前兩個分片的數據,其實是舊規則中兩個分片數據的子集,並且規則一致,所以我們可以重用前兩個分片,也就是說一共需要兩個新的分片,用來處理關鍵字哈希取餘後為2和3的情況;使用舊的緩存分片來處理關鍵字哈希取餘後0和1的情況即可。如圖10所示:

深入淺出分佈式緩存的通用方法

(圖10)

  • 第2步,遷移歷史數據

把舊緩存集群中的歷史數據讀取出來,按照新的規則寫到新的緩存集群中,如圖11所示:

深入淺出分佈式緩存的通用方法

(圖11)

在這個過程中,我們需要遷移歷史數據,在遷移的過程中可能需要遷移工具,這也需要一部分開發工作量。在遷移後,我們還需要對遷移的數據進行驗證,表明我們的數據遷移成功。

在某些應用場景下,緩存數據並不是應用強依賴的,在緩存裡獲取不到數據,可以回源到數據庫獲取,因此在這種場景下通過容量評估,數據庫可以承受回源導致的壓力增加,就可以避免遷移舊數據。在另一種場景下,緩存數據一般是具有時效性的,應用在雙寫期間不斷向新的集群中寫入新數據,歷史數據會逐漸過時,並被從舊的集群中刪除,在一定的時間流逝後,在新的集群中自然就有了最新的數據,也就不再需要遷移歷史數據了,但是這需要進行評估和驗證。

  • 第3步,切讀

把應用層所有的讀操作路由到新的緩存集群上,如圖12所示:

深入淺出分佈式緩存的通用方法

(圖12)

這一步把應用中讀取的操作的緩存數據源轉換成新的緩存集群,這時應用的讀寫操作已經完全發生在新的數據庫集群上了。這一步一般不需要上線代碼,我們會在一開始上雙寫時就實現開關邏輯,這裡只需要將讀的開關切換到新的集群即可。

  • 第4步,下線雙寫

把寫入舊的集群的邏輯下線,如圖13所示:

深入淺出分佈式緩存的通用方法

(圖13)

這一步通常是在雙寫和切讀後驗證沒有任何問題,並保證數據一致性的情況下,才把這部分代碼下線的。同時可以把舊的分片下線,如果是擴容的場景,並且重用了舊的分片1和分片2,則還可以清理分片1和分片2中的冗餘數據。

2、停機遷移

停機遷移的方法比較簡單,通常分為停止應用、遷移歷史數據、更改應用的數據源、啟動應用這4個步驟,如圖14所示:

深入淺出分佈式緩存的通用方法

(圖14)

具體的遷移步驟如下:

  • 停機應用,先將應用停止服務

  • 遷移歷史數據,按照新的規則把歷史數據遷移到新的緩存集群中

  • 更改應用的數據源配置,指向新的緩存集群

  • 重新啟動應用

這種方式的好處是實現比較簡單、高效,能夠有效避免數據的不一致,但是需要由業務方評估影響,一般在晚上交易量比較小或者非核心服務的場景下比較適用。

3、一致性哈希

實際上,Redis的客戶端Jedis本身實現了基於一致性哈希的客戶端路由框架,這種框架的好處是便於動態擴容,當一致性哈希中的節點的負載較高時,我們可以動態地插入更多的節點,來減少已存節點的壓力。

一致性哈希算法是在1997年由麻省理工學院的Karger等人在解決分佈式緩存問題時提出的一種方案,設計目標是解決網絡中的熱點問題,後來在分佈式系統中也得到了廣泛應用。研究過Redis和Memcache緩存的人一般都瞭解一致性哈希算法,他們都在客戶端實現了一致性哈希。

一致性哈希的邏輯如圖15所示:

深入淺出分佈式緩存的通用方法

(圖15)

在收到訪問一個主鍵的請求後,可通過下面的流程尋找這個主鍵的存儲節點:

  • 求出Redis服務器(節點)的哈希值,並將其配置到0-232的圓(Continuum)上

  • 採用同樣的方法求出存儲數據的鍵的哈希值,並映射到相同的圓上

  • 從數據映射到的位置開始順時針查找,找到的第1臺服務器就是將數據保存的位置

  • 如果在尋找的過程中超過232仍然找不到節點,就會保存到第1臺服務器上

在擴容的場景下添加一臺服務器節點5時,只有在圓上增加服務器的位置到逆時針方向的第一臺服務器上的鍵會受到影響,如圖16所示:

深入淺出分佈式緩存的通用方法

(圖16)

我們看到,在節點3和節點4之間增加了節點5,影響範圍是節點3到節點5之間的數據,而並不影響其他節點的數據。因此,這為緩存的擴容提供了便利性,當緩存壓力增加且緩存容量不夠時,我們通常可以通過在線增加節點的方式來完成擴容。

五、緩存穿透、併發和雪崩

緩存穿透、緩存併發和緩存雪崩是常見的由於併發量大而導致的緩存問題,本節講解其產生原因和解決方案。

緩存穿透通常是由惡意攻擊或者無意造成的;緩存併發是由設計不足造成的;緩存雪崩是由緩存同時失效造成的。三種問題都比較典型,也是難以防範和解決的。本節給出通用的解決方案,以供在緩存設計的過程中參考和使用。

1、緩存穿透

緩存穿透指的是使用不存在的key進行大量的高併發查詢,這導致緩存無法命中,每次請求都要穿透到後端數據庫系統進行查詢,使數據庫壓力過大,甚至使數據庫服務被壓死。

我們通常將空值緩存起來,再次接收到同樣的查詢請求時,若命中緩存並且值為空,就會直接返回,不會透傳到數據庫,避免緩存穿透。當然,有時惡意襲擊者可以猜到我們使用了這種方案,每次都會使用不同的參數來查詢,這就需要我們對輸入的參數進行過濾。

例如:如果我們使用ID進行查詢,則可以對ID的格式進行分析,如果不符合產生ID的規則,就直接拒絕,或者在ID上放入時間信息,根據時間信息判斷ID是否合法,或者是否是我們曾經生成的ID,這樣可以攔截一定的無效請求。

當然,每個設計人員都應該對服務的可用性和健壯性負責,應該建設健壯的服務,讓我們的服務像不倒翁一樣。因此,我們需要對服務設計限流和熔斷等功能。

2、緩存併發

緩存併發的問題通常發生在高併發的場景下,當一個緩存key過期時,因為訪問這個緩存key的請求量較大,多個請求同時發現緩存過期,因此多個請求會同時訪問數據庫來查詢最新數據,並且回寫緩存,這樣會造成應用和數據庫的負載增加,性能降低,由於併發較高,甚至會導致數據庫被壓死。

我們通常有3種方式來解決這個問題:

  • 分佈式鎖

    使用分佈式鎖,保證對於每個key同時只有一個線程去查詢後端服務,其他線程沒有獲得分佈式鎖的權限,因此只需要等待即可。這種方式將高併發的壓力轉移到了分佈式鎖,因此對分佈式鎖的考驗很大。

  • 本地鎖

    與分佈式鎖類似,我們通過本地鎖的方式來限制只有一個線程去數據庫中查詢數據,而其他線程只需等待,等前面的線程查詢到數據後再訪問緩存。但是,這種方法只能限制一個服務節點只有一個線程去數據庫中查詢,如果一個服務有多個節點,則還會有多個數據庫查詢操作,也就是說在節點數量較多的情況下並沒有完全解決緩存併發的問題。

  • 軟過期

    軟過期指對緩存中的數據設置失效時間,就是不使用緩存服務提供的過期時間,而是業務層在數據中存儲過期時間信息,由業務程序判斷是否過期並更新,在發現了數據即將過期時,將緩存的時效延長,程序可以派遣一個線程去數據庫中獲取最新的數據,其他線程這時看到延長了的過期時間,就會繼續使用舊數據,等派遣的線程獲取最新數據後再更新緩存。

也可以通過異步更新服務來更新設置軟過期的緩存,這樣應用層就不用關心緩存併發的問題了。

3、緩存雪崩

緩存雪崩指緩存服務器重啟或者大量緩存集中在某一個時間段內失效,給後端數據庫造成瞬時的負載升高的壓力,甚至壓垮數據庫的情況。

通常的解決辦法是對不同的數據使用不同的失效時間,甚至對相同的數據、不同的請求使用不同的失效時間。例如,我們要緩存user數據,會對每個用戶的數據設置不同的緩存過期時間,可以定義一個基礎時間,假設10秒,然後加上一個兩秒以內的隨機數,過期時間為10~12秒,就會避免緩存雪崩。

六、緩存對事務的支持

在使用Redis緩存的業務場景時經常會有這樣的需求:要求遞減一個變量,如果遞減後變量小於等於0,則返回一個標誌;如果成功,則返回剩餘的值,類似於數據庫事務的實現。

在實現中需要注意服務器端的多線程問題及客戶端的多線程問題。在服務器端可以利用服務器單線程執行LUA腳本來保證,或者通過WATCH、EXEC、DISCARD、EXEC來保證。

在Redis中支持LUA腳本,由於Redis使用單線程實現,因此我們首先給出LUA腳本的實現方案。在如下代碼中,我們看到變量被遞減,並判斷是否將小於0的操作放到LUA腳本里,利用Redis的單線程執行的特性完成這個原子遞減的操作:

/**

* Implemented by LUA. Minus a key by a value, then return the left value.

* If the left value is less than 0, return -1; if error, return -1.

*

* @param key

* the key of the redis variable.

* @param value

* the value to minus off.

* @return the value left after minus. If it is less than 0, return -1; if

* error, return -1.

*/

public long decrByUntil0Lua(String key, long value) {

// If any error, return -1.

if (value <= 0)

return -1;

// The logic is implemented in LUA>

// which is single thread in one server.

String>

+ " if ARGV[1] - leftvalue > 0 then return nil; else "

+ " return redis.call('decrby', KEYS[1], ARGV[1]); end; ";

Long leftValue = (Long) jedis.eval(script, 1, key, "" + value);

// If the left value is less than 0, return -1.

if (leftValue == null)

return -1;

return leftValue;

}

還可以通過Redis對事務的支持方法watch和multi來實現,類似於一個CAS方法的實現,如果對熱數據有競爭,則會返回失敗,然後重試直到成功:

/**

* Implemented by CAS. Minus a key by a value, then return the left value.

* If the left value is less than 0, return -1; if error, return -1.

*

* No synchronization, because redis client is not shared among multiple

* threads.

*

* @param key

* the key of the redis variable.

* @param value

* the value to minus off.

* @return the value left after minus. If it is less than 0, return -1; if

* error, return -1.

*/

public long decrByUntil0Cas(String key, long value) {

// If any error, return -1.

if (value <= 0)

return -1;

// Start the CAS operations.

jedis.watch(key);

// Start the transation.

Transaction tx = jedis.multi();

// Decide if the left value is less than 0, if no, terminate the

// transation, return -1;

String curr = tx.get(key).get();

if (Long.valueOf(curr) - value < 0) {

tx.discard();

return -1;

}

// Minus the key by the value

tx.decrBy(key, value);

// Execute the transaction and then handle the result

List<object> result = tx.exec();/<object>

// If error, return -1;

if (result == null || result.isEmpty()) {

return -1;

}

// Extract the first result

for (Object rt : result) {

return Long.valueOf(rt.toString());

}

// The program never comes here.

return -1;

}

/<bean>

/<bean>


分享到:


相關文章: