【編者按】本文作者為 Xinyu Liu演怎,文章的第一部分重點(diǎn)概述了 Redis 方方面面的特性悠汽。在第二部分菠赚,將介紹詳細(xì)的用例租副。文章系國內(nèi) ITOM 管理平臺 OneAPM 編譯呈現(xiàn)。
把 Redis 當(dāng)作數(shù)據(jù)庫的用例
現(xiàn)在我們來看看在服務(wù)器端 Java 企業(yè)版系統(tǒng)中把 Redis 當(dāng)作數(shù)據(jù)庫的各種用法吧僵腺。無論用例的簡繁玄帕,Redis 都能幫助用戶優(yōu)化性能、處理能力和延遲想邦,讓常規(guī) Java 企業(yè)版技術(shù)棧望而卻步裤纹。
1. 全局唯一增量計(jì)數(shù)器
我們先從一個(gè)相對簡單的用例開始吧:一個(gè)增量計(jì)數(shù)器,可顯示某網(wǎng)站受到多少次點(diǎn)擊丧没。Spring Data Redis 有兩個(gè)適用于這一實(shí)用程序的類:RedisAtomicInteger
和 RedisAtomicLong
鹰椒。和 Java 并發(fā)包中的 AtomicInteger
和 AtomicLong
不同的是,這些 Spring 類能在多個(gè) JVM 中發(fā)揮作用呕童。
列表 3:全局唯一增量計(jì)數(shù)器
RedisAtomicLong counter =
new RedisAtomicLong("UNIQUE_COUNTER_NAME", redisTemplate.getConnectionFactory());
Long myCounter = counter.incrementAndGet();// return the incremented value
請注意整型溢出并謹(jǐn)記漆际,在這兩個(gè)類上進(jìn)行操作需要付出相對較高的代價(jià)。
2. 全局悲觀鎖
時(shí)不時(shí)的夺饲,用戶就得應(yīng)對服務(wù)器集群的爭用奸汇。假設(shè)你從一個(gè)服務(wù)器集群運(yùn)行一個(gè)預(yù)定作業(yè)。在沒有全局鎖的情況下往声,集群中的節(jié)點(diǎn)會發(fā)起冗余作業(yè)實(shí)例擂找。假設(shè)某個(gè)聊天室分區(qū)可容納 50 人。如果聊天室已滿浩销,就需要?jiǎng)?chuàng)建新的聊天室實(shí)例來容納另外 50 人贯涎。
如果檢測到聊天室已滿但沒有全局鎖,集群中的各個(gè)節(jié)點(diǎn)就會創(chuàng)建自有的聊天室實(shí)例慢洋,為整個(gè)系統(tǒng)帶來不可預(yù)知的因素塘雳。列表 4 介紹了應(yīng)當(dāng)如何充分利用 SETNX(SET if Not eXists:如果不存在,則設(shè)置)這一 Redis 命令來執(zhí)行全局悲觀鎖普筹。
列表4:全局悲觀鎖
public String aquirePessimisticLockWithTimeout(String lockName, int acquireTimeout, int lockTimeout) {
if (StringUtils.isBlank(lockName) || lockTimeout <= 0)
return null;
final String lockKey = lockName;
String identifier = UUID.randomUUID().toString();
Calendar atoCal = Calendar.getInstance();
atoCal.add(Calendar.SECOND, acquireTimeout);
Date atoTime = atoCal.getTime();
while (true) {
// try to acquire the lock
if (redisTemplate.execute(new RedisCallback<Boolean>() { @Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
return connection.setNX(
redisTemplate.getStringSerializer().serialize(lockKey), redisTemplate.getStringSerializer().serialize(identifier));
}
})) { // successfully acquired the lock, set expiration of the lock
redisTemplate.execute(new RedisCallback<Boolean>() { @Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
return connection.expire(redisTemplate
.getStringSerializer().serialize(lockKey),
lockTimeout);
}
});
return identifier;
} else { // fail to acquire the lock
// set expiration of the lock in case ttl is not set yet. if (null == redisTemplate.execute(new RedisCallback<Long>() { @Override
public Long
doInRedis(RedisConnection connection)
throws DataAccessException
{
return connection.ttl(redisTemplate
.getStringSerializer().serialize(lockKey));
}
})) { // set expiration of the lock
redisTemplate.execute(new RedisCallback<Boolean>()
{
@Override
public Boolean
doInRedis(RedisConnection connection) throws DataAccessException {
return connection.expire(redisTemplate
.getStringSerializer().serialize(lockKey),
lockTimeout);
}
});
} if (acquireTimeout < 0) // no wait
return null;
else {
try {
Thread.sleep(100l); // wait 100 milliseconds before retry
} catch (InterruptedException ex) {
}
} if (new Date().after(atoTime)) break;
}
} return null;
}
public void
releasePessimisticLockWithTimeout(String lockName, String identifier) { if (StringUtils.isBlank(lockName) || StringUtils.isBlank(identifier)) return;
final String lockKey = lockName;
redisTemplate.execute(new RedisCallback<Void>() { @Override
public Void doInRedis(RedisConnection connection) throws DataAccessException {
byte[] ctn = connection.get(redisTemplate
.getStringSerializer().serialize(lockKey)); if(ctn!=null && identifier.equals(redisTemplate.getStringSerializer().deserialize(ctn)))
connection.del(redisTemplate.getStringSerializer().serialize(lockKey)); return null;
}
});
}
如果使用關(guān)系數(shù)據(jù)庫败明,一旦最先生成鎖的程序意外退出,鎖就可能永遠(yuǎn)得不到釋放太防。Redis 的 EXPIRE
設(shè)置可確保在任何情況下釋放鎖妻顶。
3. 位屏蔽(Bit Mask)
假設(shè) web 客戶端需要輪詢一臺 web 服務(wù)器,針對某個(gè)數(shù)據(jù)庫中的多個(gè)表查詢客戶指定更新內(nèi)容。如果盲目地查詢所有相應(yīng)的表以尋找潛在更新盈包,成本較高。為了避免這一做法醇王,可以嘗試在 Redis 中給每個(gè)客戶端保存一個(gè)整型作為臟指標(biāo)呢燥,整型的每個(gè)數(shù)位表示一個(gè)表。該表中存在客戶所需更新時(shí)寓娩,設(shè)置數(shù)位叛氨。輪詢期間,不會觸發(fā)對表的查詢棘伴,除非設(shè)置了相應(yīng)數(shù)位寞埠。就獲取并將這樣的位屏蔽設(shè)置為 STRING
而言,Redis 非常高效焊夸。
4. 排行榜(Leaderboard)
Redis 的 ZSET
數(shù)據(jù)結(jié)構(gòu)為游戲玩家排行榜提供了簡潔的解決方案仁连。ZSET
的工作方式有些類似于 Java 中的 PriorityQueue
,各個(gè)對象均為經(jīng)過排序的數(shù)據(jù)結(jié)構(gòu)阱穗,井井有條饭冬。可以按照分?jǐn)?shù)排出游戲玩家在排行榜上的位置揪阶。Redis 的 ZSET
定義了一份內(nèi)容豐富的命令列表昌抠,支持靈活有效的查詢。例如鲁僚,ZRANGE(包括 ZREVRANGE)可返回有序集內(nèi)的指定范圍要素炊苫。
你可以使用這一命令列出排行榜前 100 名玩家。ZRANGEBYSCORE 返回指定分?jǐn)?shù)范圍內(nèi)的要素(例如列出得分為 1000 至 2000 之間的玩家)冰沙,ZRNK 則返回有序集內(nèi)的要素的排名侨艾,諸如此類。
5. 布隆(Bloom)過濾器
布隆過濾器 (Bloom filter) 是一種空間利用率較高的概率數(shù)據(jù)結(jié)構(gòu)拓挥,用來測試某元素是否某個(gè)集的一員蒋畜。可能會出現(xiàn)誤報(bào)匹配撞叽,但不會漏報(bào)姻成。查詢可返回“可能在集內(nèi)”或“肯定不在集內(nèi)”。
就在線服務(wù)和離線服務(wù)包括大數(shù)據(jù)分析等方面愿棋,布隆過濾器數(shù)據(jù)結(jié)構(gòu)都能派上很多用場科展。Facebook 利用布隆過濾器進(jìn)行輸入提示搜索,為用戶輸入的查詢提取朋友和朋友的朋友糠雨。Apache HBase 則利用布隆過濾器過濾掉不包含特殊行或列的 HFile 塊磁盤讀取才睹,使讀取速度得到明顯提升。Bitly 用布隆過濾器來避免將用戶重定向到惡意網(wǎng)站,而 Quara 則在訂閱后端執(zhí)行了一個(gè)切分的布隆過濾器琅攘,用來過濾掉之前查看過的內(nèi)容垮庐。在我自己的項(xiàng)目里,我用布隆過濾器追蹤用戶對各個(gè)主題的投票情況坞琴。
借助出色的速度和處理能力哨查,Redis 極好地融合了布隆過濾器。搜索 GitHub剧辐,就能發(fā)現(xiàn)很多 Redis 布隆過濾器項(xiàng)目寒亥,其中一些還支持可調(diào)諧精度。
6. 高效的全局通知:發(fā)布/訂閱渠道
Redis 發(fā)布/訂閱渠道的工作方式類似于一個(gè)扇出消息傳遞系統(tǒng)荧关,或 JMS 語義中的一個(gè)主題溉奕。JMS 主題和 Redis 發(fā)布/訂閱渠道的一個(gè)區(qū)別是,通過 Redis 發(fā)布的消息并不持久忍啤。消息被推送給所有相連的客戶端后加勤,Redis 上就會刪除這一消息。換句話說同波,訂閱者必須一直在線才能接收新消息胸竞。Redis 發(fā)布/訂閱渠道的典型用例包括實(shí)時(shí)配置分布、簡單的聊天服務(wù)器等参萄。
在 web 服務(wù)器集群中卫枝,每個(gè)節(jié)點(diǎn)都可以是 Redis 發(fā)布/訂閱渠道的一個(gè)訂閱者。發(fā)布到渠道上的消息也會被即時(shí)推送到所有相連節(jié)點(diǎn)讹挎。這一消息可以是某種配置更改校赤,也可以是針對所有在線用戶的全局通知。和恒定輪詢相比筒溃,這種推送溝通模式顯然極為高效马篮。
Redis 性能優(yōu)化
Redis 非常強(qiáng)大,但也可以從整體上和根據(jù)特定編程場景做出進(jìn)一步優(yōu)化怜奖』氩猓可以考慮以下技巧。
存活時(shí)間
所有 Redis 數(shù)據(jù)結(jié)構(gòu)都具備存活時(shí)間 (TTL) 屬性歪玲。當(dāng)你設(shè)置這一屬性時(shí)迁央,數(shù)據(jù)結(jié)構(gòu)會在過期后自動刪除。充分利用這一功能滥崩,可以讓 Redis 保持較低的內(nèi)存損耗岖圈。
管道技術(shù)
在一條請求中向 Redis 發(fā)送多個(gè)命令,這種方法叫做管道技術(shù)钙皮。這一技術(shù)節(jié)省了網(wǎng)絡(luò)往返的成本蜂科,這一點(diǎn)非常重要顽决,因?yàn)榫W(wǎng)絡(luò)延遲可能比 Redis 延遲要高上好幾個(gè)量級。但這里存在一個(gè)陷阱:管道中的 Redis 命令列表必須預(yù)先確定导匣,并且應(yīng)當(dāng)彼此獨(dú)立才菠。如果一個(gè)命令的參數(shù)是由先前命令的結(jié)果計(jì)算得出,管道技術(shù)就不起作用贡定。列表 5 給出了 Redis 管道技術(shù)的一個(gè)示例赋访。
列表 5:管道技術(shù)
@Override
public List<LeaderboardEntry> fetchLeaderboard(String key, String... playerIds) {
final List<LeaderboardEntry> entries = new ArrayList<>();
redisTemplate.executePipelined(new RedisCallback<Object>() { // enable Redis Pipeline
@Override
public Object doInRedis(RedisConnection connection) throws DataAccessException {
for(String playerId : playerIds) {
Long rank = connection.zRevRank(key.getBytes(), playerId.getBytes());
Double score = connection.zScore(key.getBytes(), playerId.getBytes());
LeaderboardEntry entry = new LeaderboardEntry(playerId,
score!=null?score.intValue():-1, rank!=null?rank.intValue():-1);
entries.add(entry);
}
return null;
}
});
return entries;
}
副本集和切分
Redis 支持主從副本配置。和 MongoDB 一樣厕氨,副本集也是不對稱的进每,因?yàn)閺墓?jié)點(diǎn)是只讀的汹粤,以便共享讀取工作量命斧。我在文章開頭提到過,也可以執(zhí)行切分來橫向擴(kuò)展 Redis 的處理能力和存儲容量嘱兼。事實(shí)上国葬,Redis 非常強(qiáng)大,據(jù)亞馬遜公司的內(nèi)部基準(zhǔn)顯示芹壕,類型 r3.4xlarge 的一個(gè) EC2 實(shí)例每秒可輕松處理 100000 次請求汇四。傳說還有把每秒 700000 次請求作為基準(zhǔn)的。對于中小型應(yīng)用程序踢涌,通常無需考慮 Redis 切分通孽。(請參見這篇非常出色的文章《運(yùn)行中的 Redis》,進(jìn)一步了解 Redis 的性能優(yōu)化和切分睁壁。)
Redis 中的事務(wù)
Redis 并不像關(guān)系數(shù)據(jù)庫管理系統(tǒng)那樣能支持全面的 ACID 事務(wù)背苦,但其自有的事務(wù)也非常有效。從本質(zhì)上來說潘明,Redis 事務(wù)是管道行剂、樂觀鎖、確定提交和回滾的結(jié)合钳降。其思想是執(zhí)行一個(gè)管道中的一個(gè)命令列表厚宰,然后觀察某一關(guān)鍵記錄的潛在更新(樂觀鎖)。根據(jù)所觀察的記錄是否會被另一個(gè)進(jìn)程更新遂填,該命令列表或整體確定提交铲觉,或完全回滾。
下面以某個(gè)拍賣網(wǎng)站上的賣方庫存為例吓坚。買方試圖從賣方處購買某件商品時(shí)备燃,你負(fù)責(zé)觀察 Redis 事務(wù)內(nèi)的賣方庫存變化。同時(shí)凌唬,你要從同一個(gè)庫存中刪除此商品并齐。事務(wù)關(guān)閉前漏麦,如果庫存被一個(gè)以上進(jìn)程觸及(例如,如果兩個(gè)買方同時(shí)購買了同一件商品)况褪,事務(wù)將回滾撕贞,否則事務(wù)會確定提交〔舛猓回滾后可開始重試捏膨。
Spring Data Redis 中的事務(wù)陷阱
我在 Spring 的 RedisTemplate
類 redisTemplate.setEnableTransactionSupport(true)
; 中啟用 Redis 事務(wù)時(shí)得到一個(gè)慘痛的教訓(xùn):Redis 會在運(yùn)行幾天后開始返回垃圾數(shù)據(jù),導(dǎo)致數(shù)據(jù)嚴(yán)重?fù)p壞食侮。StackOverflow 上也報(bào)道了類似情況号涯。
在運(yùn)行一個(gè) monitor
命令后,我的團(tuán)隊(duì)發(fā)現(xiàn)锯七,在進(jìn)行 Redis 操作或 RedisCallback
后链快,Spring 并沒有自動關(guān)閉 Redis 連接,而事實(shí)上它是應(yīng)該關(guān)閉的眉尸。如果再次使用未關(guān)閉的連接域蜗,可能會從意想不到的 Redis 密鑰返回垃圾數(shù)據(jù)。有意思的是噪猾,如果在 RedisTemplate
中把事務(wù)支持設(shè)為 false霉祸,這一問題就不會出現(xiàn)了。
我們發(fā)現(xiàn)袱蜡,我們可以先在 Spring 語境里配置一個(gè) PlatformTransactionManager
(例如 DataSourceTransactionManager
)丝蹭,然后再用 @Transactional
注釋來聲明 Redis 事務(wù)的范圍,讓 Spring 自動關(guān)閉 Redis 連接坪蚁。
根據(jù)這一經(jīng)驗(yàn)奔穿,我們相信,在 Spring 語境里配置兩個(gè)單獨(dú)的 RedisTemplate
是很好的做法:其中一個(gè) RedisTemplates 的事務(wù)設(shè)為 false迅细,用于大多數(shù) Redis 操作巫橄,另一個(gè) RedisTemplates 的事務(wù)已激活,僅用于 Redis 事務(wù)茵典。當(dāng)然必須要聲明 PlatformTransactionManager
和 @Transactional
湘换,以防返回垃圾數(shù)值。
另外统阿,我們還發(fā)現(xiàn)了 Redis 事務(wù)和關(guān)系數(shù)據(jù)庫事務(wù)(在本例中彩倚,即 JDBC)相結(jié)合的不利之處》銎剑混合型事務(wù)的表現(xiàn)和預(yù)想的不太一樣帆离。
結(jié)論
我希望通過這篇文章向其他 Java 企業(yè)開發(fā)師介紹 Redis 的強(qiáng)大之處,尤其是將 Redis 用作遠(yuǎn)程數(shù)據(jù)緩存和用于易揮發(fā)數(shù)據(jù)時(shí)结澄。在這里我介紹了 Redis 的六個(gè)有效用例哥谷,分享了一些性能優(yōu)化技巧岸夯,還說明了我的 Glu Mobile 團(tuán)隊(duì)怎樣解決了 Spring Data Redis 事務(wù)配置不當(dāng)造成的垃圾數(shù)據(jù)問題。我希望這篇文章能夠激發(fā)你對 Redis NoSQL 的好奇心们妥,讓你能夠受到啟發(fā)猜扮,在自己的 Java 企業(yè)版系統(tǒng)里創(chuàng)造出一番天地。
本文系 OneAPM 工程師編譯整理监婶。OneAPM 能為您提供端到端的 Java 應(yīng)用性能解決方案旅赢,我們支持所有常見的 Java 框架及應(yīng)用服務(wù)器,助您快速發(fā)現(xiàn)系統(tǒng)瓶頸惑惶,定位異常根本原因煮盼。分鐘級部署,即刻體驗(yàn)带污,Java 監(jiān)控從來沒有如此簡單僵控。想閱讀更多技術(shù)文章,請?jiān)L問 OneAPM 官方技術(shù)博客刮刑。
本文轉(zhuǎn)自 OneAPM 官方博客