本文節(jié)選自即將出版的《可伸縮服務(wù)架構(gòu):框架與中間件》一書,作者:李艷鵬、楊彪、李海亮、賈博巖、劉淏。
點擊文末原文鏈接可以直達(dá)《可伸縮服務(wù)架構(gòu):框架與中間件》書籍主頁。
一、緩存編程的具體方法
各種分布式緩存如Redis,都提供了不同語言的客戶端API,我們可以使用這些API直接訪問緩存,也可以通過注解等方法使用緩存。
編程法指通過編程的方式直接訪問緩存,偽代碼如下:
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;
這種方法實現(xiàn)起來簡單,但是每次使用時都得敲入類似上面這樣的一段代碼,很繁瑣。可以將這部分內(nèi)容抽象成一個框架,請參考下文。
spring-data-redis項目實現(xiàn)了注入法,通過Bean注入就可以直接使用Spring的緩存模板提供的方法。
(spring-data-redis項目鏈接:https://projects.spring.io/spring-data-redis)
首先,引入spring-data-redis包:
然后在Spring環(huán)境下進(jìn)行如下配置:
class='org.springframework.data.redis.connection.jedis.JedisConnection Factory'
p:use-pool='true'/>
class='org.springframework.data.redis.core.RedisTemplate'
p:connection-factory-ref='jedisConnFactory'/>
再通過Spring環(huán)境注入使用的服務(wù)中:
public class UserLinkService{
// 注入Redis的模板
@Autowired
private RedisTemplate
// 把模板當(dāng)作ListOperations接口類型注入,也可以當(dāng)作Value、Set、Zset、HashOperations接口類型注入
@Resource(name='redisTemplate')
private ListOperations
public void addLink(String userId, URL url) {
//使用注入的接口類型
listOps.leftPush(userId, url.toExternalForm());
//直接使用模板
redisTemplate.boundListOps(userId).leftPush(url.toExternalForm());
}
}
spring-data-redis項目實現(xiàn)了注解法,通過注解就可以在一個方法內(nèi)部使用緩存,緩存操作都是透明的,我們不再需要重復(fù)寫上面的一段代碼。
(spring-data-redis項目鏈接:https://projects.spring.io/spring-data-redis)
首先,引入相應(yīng)的依賴包:
然后,通過一個配置Bean配置Redis連接信息,這個配置Bean會通過Spring環(huán)境下的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
RedisTemplate
redisTemplate.setConnectionFactory(cf);
return redisTemplate;
}
@Bean
public CacheManager cacheManager(RedisTemplate redisTemplate) {
RedisCacheManager cacheManager = new RedisCacheManager(redisTemplate);
// 這是默認(rèn)的過期時間,默認(rèn)為不過期(0)
cacheManager.setDefaultExpiration(3000);
return cacheManager;
}
}
再在Spring環(huán)境下載入這些配置:
最后,我們就可以通過注解來使用Redis緩存了,這樣我們的代碼就簡單得多了:
@Cacheable('user')
public User getUser(String userId) {
logger.debug('userId=?, user=?', userId, user);
return this.userService.getUser(userId);
}
二、應(yīng)用層訪問緩存的模式
應(yīng)用層訪問分布式緩存的服務(wù)架構(gòu)模式分為:雙讀雙寫、異步更新和串聯(lián)模式。
雙讀雙寫的架構(gòu)如圖1所示:
(圖1)
這是我們最常用的緩存服務(wù)架構(gòu)模式。對于讀操作,我們先讀緩存,如果緩存不存在這份數(shù)據(jù),則再讀數(shù)據(jù)庫,讀取數(shù)據(jù)庫后再回寫緩存;對于寫操作,我們先寫數(shù)據(jù)庫,再寫緩存。
這種方式實現(xiàn)起來簡單,但是對應(yīng)用層不透明,應(yīng)用層需要處理讀寫順序的邏輯,可參考本文的第一部分。
異步更新的架構(gòu)如圖2所示:
(圖2)
在異步更新的方式中,應(yīng)用層只讀寫緩存,在這種情況下,全量數(shù)據(jù)會被保存在緩存中,并且不設(shè)置緩存系統(tǒng)的過期時間,由異步的更新服務(wù)將數(shù)據(jù)庫里變更的或者新增的數(shù)據(jù)更新到緩存中。
也有通過MySQL的Binlog將MySQL中的更新操作推送到緩存的實現(xiàn)方法,這種方法和異步更新如出一轍,以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)
這種方法實現(xiàn)起來稍微復(fù)雜,增加了更新服務(wù)。這里的更新服務(wù)需要定時調(diào)度任務(wù)的設(shè)計,我們將在之后的內(nèi)容涉及。這需要更多的開發(fā)和運維成本,在設(shè)計異步服務(wù)時要充分保證異步服務(wù)的可用性,要有完善的監(jiān)控和報警,否則緩存數(shù)據(jù)將和數(shù)據(jù)庫不一致。但是在這種模式下性能最好,因為應(yīng)用層讀緩存即可,不需要讀取數(shù)據(jù)庫。
串聯(lián)模式的架構(gòu)如圖4所示:
(圖4)
在這種串聯(lián)模式下,應(yīng)用直接在緩存上進(jìn)行讀寫操作,緩存作為代理層,根據(jù)需要和配置與數(shù)據(jù)庫進(jìn)行讀寫操作。
在微服務(wù)的設(shè)置中并不推薦采用這種服務(wù)的串聯(lián)模式,因為它在應(yīng)用和數(shù)據(jù)庫中間增加了一層代理層,需要設(shè)計和維護(hù)這多出的一層,還要保證高可用性,成本較高,但是這種模式有著特殊的場景,比如我們需要在代理層開啟緩存加速,例如Varnish等。
三、分布式緩存分片的三種模式
在互聯(lián)網(wǎng)行業(yè)里,我們做的都是用戶端的產(chǎn)品,有調(diào)查稱中國有6億互聯(lián)網(wǎng)用戶,這么多的用戶會給互聯(lián)網(wǎng)應(yīng)用帶來了海量的請求,這也需要存儲海量的數(shù)據(jù)。因此,單機(jī)的緩存滿足不了對海量數(shù)據(jù)緩存的需求。我們通常通過多個緩存節(jié)點來緩存大量的臨時數(shù)據(jù),來加速緩存的存取速度。
例如:可以把微博的粉絲關(guān)系存儲在緩存中,在獲取某個用戶有權(quán)限看見的微博時,我們就可以使用這些粉絲關(guān)系。粉絲關(guān)系的數(shù)據(jù)量非常大,一個大V用戶可能有幾千萬或者上億的關(guān)注量,可想而知,我們需要多大的內(nèi)存才能夠存儲這么多的粉絲關(guān)系。
在通用的解決方案中,我們會對這些大數(shù)據(jù)量進(jìn)行切片,數(shù)據(jù)被分成大小相等的分片,一個緩存節(jié)點負(fù)責(zé)存儲其中的多個分片。分片通常有三種實現(xiàn)方式,包括客戶端分片、代理分片和集群分片。
對緩存進(jìn)行客戶端分片的方案如圖5所示:
(圖5)
客戶端分片通過應(yīng)用層直接操作分片邏輯,分片規(guī)則需要在同一個應(yīng)用的多個節(jié)點間保存,每個應(yīng)用層都嵌入一個操作切片的邏輯實現(xiàn),一般通過依賴Jar包來實現(xiàn)。筆者曾工作過的幾家大的互聯(lián)網(wǎng)公司都有內(nèi)部的緩存分片的實現(xiàn),多數(shù)是采用在應(yīng)用層直接實現(xiàn)的方式,應(yīng)用層分片的性能更好,實現(xiàn)簡單,有問題時容易定位和修復(fù)。
這種解決方案的性能很好,實現(xiàn)起來比較簡單,適合快速上線,而且切分邏輯是自己開發(fā)的,如果在生產(chǎn)上出了問題,則都比較容易解決;但是它侵入了業(yè)務(wù)邏輯的實現(xiàn),會讓緩存服務(wù)器保持的應(yīng)用程序連接比較多,這要看應(yīng)用服務(wù)器池的節(jié)點數(shù)量,需要提前進(jìn)行容量評估。
對緩存進(jìn)行代理分片的方案如圖6所示:
(圖6)
代理分片就是在應(yīng)用層和緩存服務(wù)器中間增加一個代理層,把分片的路由規(guī)則配置在代理層,代理層對外提供與緩存服務(wù)器兼容的接口給應(yīng)用層,應(yīng)用層的開發(fā)人員不用關(guān)心分片規(guī)則,只需關(guān)心業(yè)務(wù)邏輯的實現(xiàn),待業(yè)務(wù)邏輯實現(xiàn)以后,在代理層配置路由規(guī)則即可。
這種方案的好處是讓應(yīng)用層開發(fā)人員專注于業(yè)務(wù)邏輯的實現(xiàn),把緩存分片的配置留給代理層做,具體可以由運維人員來實施;缺點是增加了代理層。
盡管代理層是輕量級的轉(zhuǎn)發(fā)協(xié)議,但是畢竟要實現(xiàn)緩存協(xié)議的解析,并通過分片的路由規(guī)則來路由請求,對每個緩存操作都增加了一層代理網(wǎng)絡(luò)傳輸,對性能是有影響的,對增加的代理層也需要進(jìn)行維護(hù),也有硬件成本,還要有能夠解決Bug的技術(shù)專家,成本很高。流行的Codis框架就是代理分片的典型實現(xiàn)。
緩存自身提供的集群分片方案如圖7所示:
(圖7)
有的緩存自身提供了集群功能,集群可以實現(xiàn)分片和高可用特性,我們只需要把它們當(dāng)成一個由多個緩存服務(wù)器節(jié)點組成的大緩存機(jī)器來使用即可,分片和高可用等對應(yīng)用層是透明的,由運維人員配置即可使用,典型的就是Redis 3.0提供的Cluster。
四、分布式緩存的遷移方案
處理分布式緩存遷移是比較困難的,通常我們將其分為平滑遷移和停機(jī)遷移。這里講解通用的遷移方案,擴(kuò)容實際上是遷移的一種特殊案例,我們在下面學(xué)習(xí)的方案全部適用。我們會在講解該方案的過程中,以擴(kuò)容為例來說明相應(yīng)的步驟和實現(xiàn)細(xì)節(jié)。
平滑遷移適合對可用性要求較高的場景。例如:線上的交易服務(wù)對緩存依賴較大,不能忍受停機(jī)帶來的業(yè)務(wù)損失,也沒有交易的低峰期,我們對此只能采用平滑遷移的方式。
平滑遷移使用的是雙寫方案,方案分成4個步驟:雙寫、遷移歷史數(shù)據(jù)、切讀、下線雙寫。
這種方式還有一個變種,就是不需要遷移老數(shù)據(jù):在第1步中雙寫后,在一定的時間里通過新規(guī)則對新緩存進(jìn)行寫入,新緩存已經(jīng)有了足夠的數(shù)據(jù),這樣我們就不用再遷移舊數(shù)據(jù),直接進(jìn)入第3步即可。
首先,假設(shè)我們的應(yīng)用現(xiàn)在使用了具有兩個分片的緩存集群,通過關(guān)鍵字哈希的方式進(jìn)行路由,如圖8所示:
(圖8)
因為兩個分片已經(jīng)不能滿足緩存容量的需求,所以現(xiàn)在需要擴(kuò)容到4個分片,達(dá)到原來兩倍的緩存總大小,因此我們需要遷移。
遷移的具體過程如下:
第1步,雙寫
按照新規(guī)則和舊規(guī)則同時往新緩存和舊緩存中寫數(shù)據(jù),如圖9所示:
(圖9)
這里,我們?nèi)匀话凑张f的規(guī)則,也就是關(guān)鍵字哈希除以2取余來路由分片,同時按照新的規(guī)則,也就是關(guān)鍵字哈希除以4取余來路由到新的4個分片上,來完成數(shù)據(jù)的雙寫。
這個步驟有優(yōu)化的空間,因為是在成倍擴(kuò)容的場景下,所以我們不需要準(zhǔn)備4個全新的分片。新規(guī)則中前兩個分片的數(shù)據(jù),其實是舊規(guī)則中兩個分片數(shù)據(jù)的子集,并且規(guī)則一致,所以我們可以重用前兩個分片,也就是說一共需要兩個新的分片,用來處理關(guān)鍵字哈希取余后為2和3的情況;使用舊的緩存分片來處理關(guān)鍵字哈希取余后0和1的情況即可。如圖10所示:
(圖10)
第2步,遷移歷史數(shù)據(jù)
把舊緩存集群中的歷史數(shù)據(jù)讀取出來,按照新的規(guī)則寫到新的緩存集群中,如圖11所示:
(圖11)
在這個過程中,我們需要遷移歷史數(shù)據(jù),在遷移的過程中可能需要遷移工具,這也需要一部分開發(fā)工作量。在遷移后,我們還需要對遷移的數(shù)據(jù)進(jìn)行驗證,表明我們的數(shù)據(jù)遷移成功。
在某些應(yīng)用場景下,緩存數(shù)據(jù)并不是應(yīng)用強(qiáng)依賴的,在緩存里獲取不到數(shù)據(jù),可以回源到數(shù)據(jù)庫獲取,因此在這種場景下通過容量評估,數(shù)據(jù)庫可以承受回源導(dǎo)致的壓力增加,就可以避免遷移舊數(shù)據(jù)。在另一種場景下,緩存數(shù)據(jù)一般是具有時效性的,應(yīng)用在雙寫期間不斷向新的集群中寫入新數(shù)據(jù),歷史數(shù)據(jù)會逐漸過時,并被從舊的集群中刪除,在一定的時間流逝后,在新的集群中自然就有了最新的數(shù)據(jù),也就不再需要遷移歷史數(shù)據(jù)了,但是這需要進(jìn)行評估和驗證。
第3步,切讀
把應(yīng)用層所有的讀操作路由到新的緩存集群上,如圖12所示:
(圖12)
這一步把應(yīng)用中讀取的操作的緩存數(shù)據(jù)源轉(zhuǎn)換成新的緩存集群,這時應(yīng)用的讀寫操作已經(jīng)完全發(fā)生在新的數(shù)據(jù)庫集群上了。這一步一般不需要上線代碼,我們會在一開始上雙寫時就實現(xiàn)開關(guān)邏輯,這里只需要將讀的開關(guān)切換到新的集群即可。
第4步,下線雙寫
把寫入舊的集群的邏輯下線,如圖13所示:
(圖13)
這一步通常是在雙寫和切讀后驗證沒有任何問題,并保證數(shù)據(jù)一致性的情況下,才把這部分代碼下線的。同時可以把舊的分片下線,如果是擴(kuò)容的場景,并且重用了舊的分片1和分片2,則還可以清理分片1和分片2中的冗余數(shù)據(jù)。
停機(jī)遷移的方法比較簡單,通常分為停止應(yīng)用、遷移歷史數(shù)據(jù)、更改應(yīng)用的數(shù)據(jù)源、啟動應(yīng)用這4個步驟,如圖14所示:
(圖14)
具體的遷移步驟如下:
停機(jī)應(yīng)用,先將應(yīng)用停止服務(wù)
遷移歷史數(shù)據(jù),按照新的規(guī)則把歷史數(shù)據(jù)遷移到新的緩存集群中
更改應(yīng)用的數(shù)據(jù)源配置,指向新的緩存集群
重新啟動應(yīng)用
這種方式的好處是實現(xiàn)比較簡單、高效,能夠有效避免數(shù)據(jù)的不一致,但是需要由業(yè)務(wù)方評估影響,一般在晚上交易量比較小或者非核心服務(wù)的場景下比較適用。
實際上,Redis的客戶端Jedis本身實現(xiàn)了基于一致性哈希的客戶端路由框架,這種框架的好處是便于動態(tài)擴(kuò)容,當(dāng)一致性哈希中的節(jié)點的負(fù)載較高時,我們可以動態(tài)地插入更多的節(jié)點,來減少已存節(jié)點的壓力。
一致性哈希算法是在1997年由麻省理工學(xué)院的Karger等人在解決分布式緩存問題時提出的一種方案,設(shè)計目標(biāo)是解決網(wǎng)絡(luò)中的熱點問題,后來在分布式系統(tǒng)中也得到了廣泛應(yīng)用。研究過Redis和Memcache緩存的人一般都了解一致性哈希算法,他們都在客戶端實現(xiàn)了一致性哈希。
一致性哈希的邏輯如圖15所示:
(圖15)
在收到訪問一個主鍵的請求后,可通過下面的流程尋找這個主鍵的存儲節(jié)點:
求出Redis服務(wù)器(節(jié)點)的哈希值,并將其配置到0-232的圓(Continuum)上
采用同樣的方法求出存儲數(shù)據(jù)的鍵的哈希值,并映射到相同的圓上
從數(shù)據(jù)映射到的位置開始順時針查找,找到的第1臺服務(wù)器就是將數(shù)據(jù)保存的位置
如果在尋找的過程中超過232仍然找不到節(jié)點,就會保存到第1臺服務(wù)器上
在擴(kuò)容的場景下添加一臺服務(wù)器節(jié)點5時,只有在圓上增加服務(wù)器的位置到逆時針方向的第一臺服務(wù)器上的鍵會受到影響,如圖16所示:
(圖16)
我們看到,在節(jié)點3和節(jié)點4之間增加了節(jié)點5,影響范圍是節(jié)點3到節(jié)點5之間的數(shù)據(jù),而并不影響其他節(jié)點的數(shù)據(jù)。因此,這為緩存的擴(kuò)容提供了便利性,當(dāng)緩存壓力增加且緩存容量不夠時,我們通??梢酝ㄟ^在線增加節(jié)點的方式來完成擴(kuò)容。
五、緩存穿透、并發(fā)和雪崩
緩存穿透、緩存并發(fā)和緩存雪崩是常見的由于并發(fā)量大而導(dǎo)致的緩存問題,本節(jié)講解其產(chǎn)生原因和解決方案。
緩存穿透通常是由惡意攻擊或者無意造成的;緩存并發(fā)是由設(shè)計不足造成的;緩存雪崩是由緩存同時失效造成的。三種問題都比較典型,也是難以防范和解決的。本節(jié)給出通用的解決方案,以供在緩存設(shè)計的過程中參考和使用。
緩存穿透指的是使用不存在的key進(jìn)行大量的高并發(fā)查詢,這導(dǎo)致緩存無法命中,每次請求都要穿透到后端數(shù)據(jù)庫系統(tǒng)進(jìn)行查詢,使數(shù)據(jù)庫壓力過大,甚至使數(shù)據(jù)庫服務(wù)被壓死。
我們通常將空值緩存起來,再次接收到同樣的查詢請求時,若命中緩存并且值為空,就會直接返回,不會透傳到數(shù)據(jù)庫,避免緩存穿透。當(dāng)然,有時惡意襲擊者可以猜到我們使用了這種方案,每次都會使用不同的參數(shù)來查詢,這就需要我們對輸入的參數(shù)進(jìn)行過濾。
例如:如果我們使用ID進(jìn)行查詢,則可以對ID的格式進(jìn)行分析,如果不符合產(chǎn)生ID的規(guī)則,就直接拒絕,或者在ID上放入時間信息,根據(jù)時間信息判斷ID是否合法,或者是否是我們曾經(jīng)生成的ID,這樣可以攔截一定的無效請求。
當(dāng)然,每個設(shè)計人員都應(yīng)該對服務(wù)的可用性和健壯性負(fù)責(zé),應(yīng)該建設(shè)健壯的服務(wù),讓我們的服務(wù)像不倒翁一樣。因此,我們需要對服務(wù)設(shè)計限流和熔斷等功能。
緩存并發(fā)的問題通常發(fā)生在高并發(fā)的場景下,當(dāng)一個緩存key過期時,因為訪問這個緩存key的請求量較大,多個請求同時發(fā)現(xiàn)緩存過期,因此多個請求會同時訪問數(shù)據(jù)庫來查詢最新數(shù)據(jù),并且回寫緩存,這樣會造成應(yīng)用和數(shù)據(jù)庫的負(fù)載增加,性能降低,由于并發(fā)較高,甚至?xí)?dǎo)致數(shù)據(jù)庫被壓死。
我們通常有3種方式來解決這個問題:
分布式鎖
使用分布式鎖,保證對于每個key同時只有一個線程去查詢后端服務(wù),其他線程沒有獲得分布式鎖的權(quán)限,因此只需要等待即可。這種方式將高并發(fā)的壓力轉(zhuǎn)移到了分布式鎖,因此對分布式鎖的考驗很大。
本地鎖
與分布式鎖類似,我們通過本地鎖的方式來限制只有一個線程去數(shù)據(jù)庫中查詢數(shù)據(jù),而其他線程只需等待,等前面的線程查詢到數(shù)據(jù)后再訪問緩存。但是,這種方法只能限制一個服務(wù)節(jié)點只有一個線程去數(shù)據(jù)庫中查詢,如果一個服務(wù)有多個節(jié)點,則還會有多個數(shù)據(jù)庫查詢操作,也就是說在節(jié)點數(shù)量較多的情況下并沒有完全解決緩存并發(fā)的問題。
軟過期
軟過期指對緩存中的數(shù)據(jù)設(shè)置失效時間,就是不使用緩存服務(wù)提供的過期時間,而是業(yè)務(wù)層在數(shù)據(jù)中存儲過期時間信息,由業(yè)務(wù)程序判斷是否過期并更新,在發(fā)現(xiàn)了數(shù)據(jù)即將過期時,將緩存的時效延長,程序可以派遣一個線程去數(shù)據(jù)庫中獲取最新的數(shù)據(jù),其他線程這時看到延長了的過期時間,就會繼續(xù)使用舊數(shù)據(jù),等派遣的線程獲取最新數(shù)據(jù)后再更新緩存。
也可以通過異步更新服務(wù)來更新設(shè)置軟過期的緩存,這樣應(yīng)用層就不用關(guān)心緩存并發(fā)的問題了。
緩存雪崩指緩存服務(wù)器重啟或者大量緩存集中在某一個時間段內(nèi)失效,給后端數(shù)據(jù)庫造成瞬時的負(fù)載升高的壓力,甚至壓垮數(shù)據(jù)庫的情況。
通常的解決辦法是對不同的數(shù)據(jù)使用不同的失效時間,甚至對相同的數(shù)據(jù)、不同的請求使用不同的失效時間。例如,我們要緩存user數(shù)據(jù),會對每個用戶的數(shù)據(jù)設(shè)置不同的緩存過期時間,可以定義一個基礎(chǔ)時間,假設(shè)10秒,然后加上一個兩秒以內(nèi)的隨機(jī)數(shù),過期時間為10~12秒,就會避免緩存雪崩。
六、緩存對事務(wù)的支持
在使用Redis緩存的業(yè)務(wù)場景時經(jīng)常會有這樣的需求:要求遞減一個變量,如果遞減后變量小于等于0,則返回一個標(biāo)志;如果成功,則返回剩余的值,類似于數(shù)據(jù)庫事務(wù)的實現(xiàn)。
在實現(xiàn)中需要注意服務(wù)器端的多線程問題及客戶端的多線程問題。在服務(wù)器端可以利用服務(wù)器單線程執(zhí)行LUA腳本來保證,或者通過WATCH、EXEC、DISCARD、EXEC來保證。
在Redis中支持LUA腳本,由于Redis使用單線程實現(xiàn),因此我們首先給出LUA腳本的實現(xiàn)方案。在如下代碼中,我們看到變量被遞減,并判斷是否將小于0的操作放到LUA腳本里,利用Redis的單線程執(zhí)行的特性完成這個原子遞減的操作:
/**
* 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 <=>=>
return -1;
// The logic is implemented in LUA script which is run in server thread,
// which is single thread in one server.
String script = ' local leftvalue = redis.call('get', KEYS[1]); '
+ ' 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對事務(wù)的支持方法watch和multi來實現(xiàn),類似于一個CAS方法的實現(xiàn),如果對熱數(shù)據(jù)有競爭,則會返回失敗,然后重試直到成功:
/**
* 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 <=>=>
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
// 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;
}