Redis 客戶端
客戶端通信原理
客戶端和服務器通過TCP 連接來進行數(shù)據(jù)交互掖肋, 服務器默認的端口號為6379 词身。
客戶端和服務器發(fā)送的命令或數(shù)據(jù)一律以\r\n (CRLF 回車+換行)結(jié)尾闯捎。
客戶端跟Redis 之間使用一種特殊的編碼格式(在AOF 文件里面我們看到了)习劫,叫做Redis Serialization Protocol (Redis 序列化協(xié)議)虽风。特點:容易實現(xiàn)棒口、解析快、可讀性強辜膝∥耷#客戶端發(fā)給服務端的消息需要經(jīng)過編碼,服務端收到之后會按約定進行解碼厂抖,反之亦然茎毁。
基于此,我們可以自己實現(xiàn)一個Redis 客戶端忱辅。
1七蜘、建立Socket 連接
2、OutputStream 寫入數(shù)據(jù)(發(fā)送到服務端)
3墙懂、InputStream 讀取數(shù)據(jù)(從服務端接口)
基于這種協(xié)議橡卤,我們可以用Java 實現(xiàn)所有的Redis 操作命令。當然损搬,我們不需要這么做碧库,因為已經(jīng)有很多比較成熟的Java 客戶端柜与,實現(xiàn)了完整的功能和高級特性,并且提供了良好的性能嵌灰。
https://redis.io/clients#java
官網(wǎng)推薦的Java 客戶端有3 個Jedis弄匕,Redisson 和Luttuce。
Spring 連接Redis 用的是什么沽瞭?RedisConnectionFactory 接口支持多種實現(xiàn)粘茄,例如: JedisConnectionFactory 、JredisConnectionFactory 秕脓、LettuceConnectionFactory柒瓣、SrpConnectionFactory。
Jedis
Jedis 是我們最熟悉和最常用的客戶端吠架。輕量芙贫,簡潔,便于集成和改造傍药。
Jedis 多個線程使用一個連接的時候線程不安全磺平。可以使用連接池拐辽,為每個請求創(chuàng)建不同的連接拣挪,基于Apache common pool 實現(xiàn)。跟數(shù)據(jù)庫一樣俱诸,可以設置最大連接數(shù)等參數(shù)菠劝。Jedis 中有多種連接池的子類。
public static void main(String[] args) {
JedisPool pool = new JedisPool(ip, port);
Jedis jedis = jedisPool.getResource();
//
}
Jedis 有4 種工作模式:單節(jié)點睁搭、分片赶诊、哨兵、集群园骆。
3 種請求模式:Client舔痪、Pipeline、事務锌唾。
Client 模式就是客戶端發(fā)送一個命令锄码,阻塞等待服務端執(zhí)行,然后讀取返回結(jié)果晌涕。
Pipeline 模式是一次性發(fā)送多個命令滋捶,最后一次取回所有的返回結(jié)果,這種模式通過減少網(wǎng)絡的往返時間和io 讀寫次數(shù)渐排,大幅度提高通信性能炬太。
第三種是事務模式灸蟆。Transaction 模式即開啟Redis 的事務管理驯耻,事務模式開啟后亲族,所有的命令(除了exec,discard可缚,multi 和watch)到達服務端以后不會立即執(zhí)行霎迫,會進入一個等待隊列。
Sentinel 獲取連接原理
問題:Jedis 連接Sentinel 的時候帘靡,我們配置的是全部哨兵的地址知给。Sentinel 是如何返回可用的master 地址的呢?
在構(gòu)造方法中:
pool = new JedisSentinelPool(masterName, sentinels);
調(diào)用了:
HostAndPort master = initSentinels(sentinels, masterName);
查看:
private HostAndPort initSentinels(Set<String> sentinels, final String masterName) {
HostAndPort master = null;
boolean sentinelAvailable = false;
log.info("Trying to find master from available Sentinels...");
// 有多個sentinels,遍歷這些個sentinels
for (String sentinel : sentinels) {
// host:port 表示的sentinel 地址轉(zhuǎn)化為一個HostAndPort 對象描姚。
final HostAndPort hap = HostAndPort.parseString(sentinel);
log.fine("Connecting to Sentinel " + hap);
Jedis jedis = null;
try {
// 連接到sentinel
jedis = new Jedis(hap.getHost(), hap.getPort());
// 根據(jù)masterName 得到master 的地址涩赢,返回一個list,host= list[0], port =// list[1]
List<String> masterAddr = jedis.sentinelGetMasterAddrByName(masterName);
// connected to sentinel...
sentinelAvailable = true;
if (masterAddr == null || masterAddr.size() != 2) {
log.warning("Can not get master addr, master name: " + masterName + ". Sentinel: " + hap
+ ".");
continue;
}
// 如果在任何一個sentinel 中找到了master轩勘,不再遍歷sentinels
master = toHostAndPort(masterAddr);
log.fine("Found Redis master at " + master);
break;
} catch (JedisException e) {
// resolves #1036, it should handle JedisException there's another chance
// of raising JedisDataException
log.warning("Cannot get master address from sentinel running @ " + hap + ". Reason: " + e
+ ". Trying next one.");
} finally {
if (jedis != null) {
jedis.close();
}
}
}
// 到這里筒扒,如果master 為null,則說明有兩種情況绊寻,一種是所有的sentinels 節(jié)點都down 掉了花墩,一種是master 節(jié)點沒有被存活的sentinels 監(jiān)控到
if (master == null) {
if (sentinelAvailable) {
// can connect to sentinel, but master name seems to not
// monitored
throw new JedisException("Can connect to sentinel, but " + masterName
+ " seems to be not monitored...");
} else {
throw new JedisConnectionException("All sentinels down, cannot determine where is "
+ masterName + " master is running...");
}
}
// 如果走到這里,說明找到了master 的地址
log.info("Redis master running at " + master + ", starting Sentinel listeners...");
// 啟動對每個sentinels 的監(jiān)聽為每個sentinel 都啟動了一個監(jiān)聽者MasterListener澄步。MasterListener 本身是一個線程冰蘑,它會去訂閱sentinel 上關(guān)于master 節(jié)點地址改變的消息。
for (String sentinel : sentinels) {
final HostAndPort hap = HostAndPort.parseString(sentinel);
MasterListener masterListener = new MasterListener(masterName, hap.getHost(), hap.getPort());
// whether MasterListener threads are alive or not, process can be stopped
masterListener.setDaemon(true);
masterListeners.add(masterListener);
masterListener.start();
}
return master;
}
Cluster 獲取連接原理
問題:使用Jedis 連接Cluster 的時候村缸,我們只需要連接到任意一個或者多個redisgroup 中的實例地址祠肥,那我們是怎么獲取到需要操作的Redis Master 實例的?
關(guān)鍵問題:在于如何存儲slot 和Redis 連接池的關(guān)系梯皿。
1搪柑、程序啟動初始化集群環(huán)境,讀取配置文件中的節(jié)點配置索烹,無論是主從工碾,無論多少個,只拿第一個百姓,獲取redis 連接實例(后面有個break)渊额。
// redis.clients.jedis.JedisClusterConnectionHandler#initializeSlotsCache
private void initializeSlotsCache(Set<HostAndPort> startNodes, GenericObjectPoolConfig poolConfig, String password) {
for (HostAndPort hostAndPort : startNodes) {
// 獲取一個Jedis 實例
Jedis jedis = new Jedis(hostAndPort.getHost(), hostAndPort.getPort());
if (password != null) {
jedis.auth(password);
}
try {
// 獲取Redis 節(jié)點和Slot 虛擬槽
cache.discoverClusterNodesAndSlots(jedis);
// 直接跳出循環(huán)
break;
} catch (JedisConnectionException e) {
// try next nodes
} finally {
if (jedis != null) {
jedis.close();
}
}
}
...
}
2、用獲取的redis 連接實例執(zhí)行clusterSlots ()方法垒拢,實際執(zhí)行redis 服務端clusterslots 命令旬迹,獲取虛擬槽信息。
該集合的基本信息為[long, long, List, List], 第一求类,二個元素是該節(jié)點負責槽點的起始位置奔垦,第三個元素是主節(jié)點信息,第四個元素為主節(jié)點對應的從節(jié)點信息尸疆。該list 的基本信息為[string,int,string],第一個為host 信息椿猎,第二個為port 信息惶岭,第三個為唯一id。
3犯眠、獲取有關(guān)節(jié)點的槽點信息后按灶,調(diào)用getAssignedSlotArray(slotinfo)來獲取所有的槽點值芦疏。
4惹悄、再獲取主節(jié)點的地址信息,調(diào)用generateHostAndPort(hostInfo)方法医清,生成一個HostAndPort 對象量蕊。
5铺罢、再根據(jù)節(jié)點地址信息來設置節(jié)點對應的JedisPool , 即設置Map<String,JedisPool> nodes 的值残炮。
接下來判斷若此時節(jié)點信息為主節(jié)點信息時畏铆,則調(diào)用assignSlotsToNodes 方法,設置每個槽點值對應的連接池吉殃,即設置Map<Integer, JedisPool> slots 的值辞居。
// redis.clients.jedis.JedisClusterInfoCache#discoverClusterNodesAndSlots
public void discoverClusterNodesAndSlots(Jedis jedis) {
w.lock();
try {
reset();
// 獲取節(jié)點集合
List<Object> slots = jedis.clusterSlots();
// 遍歷3 個master 節(jié)點
for (Object slotInfoObj : slots) {
// slotInfo 槽開始,槽結(jié)束蛋勺,主瓦灶,從
// {[0,5460,7291,7294],[5461,10922,7292,7295],[10923,16383,7293,7296]}
List<Object> slotInfo = (List<Object>) slotInfoObj;
// 如果<=2,代表沒有分配slot
if (slotInfo.size() <= MASTER_NODE_INDEX) {
continue;
}
// 獲取分配到當前master 節(jié)點的數(shù)據(jù)槽抱完,例如7291 節(jié)點的{0,1,2,3……5460}
List<Integer> slotNums = getAssignedSlotArray(slotInfo);
// hostInfos
int size = slotInfo.size(); // size 是4贼陶,槽最小最大,主巧娱,從
// 第3 位和第4 位是主從端口的信息
for (int i = MASTER_NODE_INDEX; i < size; i++) {
List<Object> hostInfos = (List<Object>) slotInfo.get(i);
if (hostInfos.size() <= 0) {
continue;
}
// 根據(jù)IP 端口生成HostAndPort 實例
HostAndPort targetNode = generateHostAndPort(hostInfos);
// 據(jù)HostAndPort 解析出ip:port 的key 值碉怔,再根據(jù)key 從緩存中查詢對應的jedisPool 實例。如果沒有jedisPool實例禁添,就創(chuàng)建JedisPool 實例撮胧,最后放入緩存中。nodeKey 和nodePool 的關(guān)系
setupNodeIfNotExist(targetNode);
// 把slot 和jedisPool 緩存起來(16384 個)老翘,key 是slot 下標芹啥,value 是連接池
if (i == MASTER_NODE_INDEX) {
assignSlotsToNode(slotNums, targetNode);
}
}
}
} finally {
w.unlock();
}
}
從集群環(huán)境存取值:
1、把key 作為參數(shù)铺峭,執(zhí)行CRC16 算法墓怀,獲取key 對應的slot 值。
2卫键、通過該slot 值傀履,去slots 的map 集合中獲取jedisPool 實例。
3莉炉、通過jedisPool 實例獲取jedis 實例钓账,最終完成redis 數(shù)據(jù)存取工作碴犬。
pipeline
慢在哪里?
Redis 使用的是客戶端/服務器(C/S)模型和請求/響應協(xié)議的TCP 服務器官扣。這意味著通常情況下一個請求會遵循以下步驟:
- 客戶端向服務端發(fā)送一個查詢請求,并監(jiān)聽Socket 返回羞福,通常是以阻塞模式惕蹄,等待服務端響應。
- 服務端處理命令治专,并將結(jié)果返回給客戶端卖陵。
Redis 客戶端與Redis 服務器之間使用TCP 協(xié)議進行連接,一個客戶端可以通過一個socket 連接發(fā)起多個請求命令张峰。每個請求命令發(fā)出后client 通常會阻塞并等待redis服務器處理泪蔫,redis 處理完請求命令后會將結(jié)果通過響應報文返回給client,因此當執(zhí)行多條命令的時候都需要等待上一條命令執(zhí)行完畢才能執(zhí)行喘批。執(zhí)行過程如圖:
Redis 本身提供了一些批量操作命令撩荣,比如mget,mset饶深,可以減少通信的時間餐曹,但是大部分命令是不支持multi 操作的,例如hash 就沒有敌厘。
由于通信會有網(wǎng)絡延遲台猴,假如client 和server 之間的包傳輸時間需要10 毫秒,一次交互就是20 毫秒(RTT:Round Trip Time)俱两。這樣的話饱狂,client 1 秒鐘也只能也只能發(fā)送50 個命令。這顯然沒有充分利用Redis 的處理能力宪彩。另外一個休讳,Redis 服務端執(zhí)行I/O 的次數(shù)過多。
- Pipeline 管道
https://redis.io/topics/pipelining
那我們能不能像數(shù)據(jù)庫的batch 操作一樣尿孔,把一組命令組裝在一起發(fā)送給Redis 服務端執(zhí)行衍腥,然后一次性獲得返回結(jié)果呢?這個就是Pipeline 的作用纳猫。Pipeline 通過一個隊列把所有的命令緩存起來婆咸,然后把多個命令在一次連接中發(fā)送給服務器。
要實現(xiàn)Pipeline芜辕,既要服務端的支持尚骄,也要客戶端的支持。對于服務端來說侵续,需要能夠處理客戶端通過一個TCP 連接發(fā)來的多個命令倔丈,并且逐個地執(zhí)行命令一起返回憨闰。
對于客戶端來說,要把多個命令緩存起來需五,達到一定的條件就發(fā)送出去鹉动,最后才處理Redis 的應答(這里也要注意對客戶端內(nèi)存的消耗)。
jedis-pipeline 的client-buffer 限制:8192bytes宏邮,客戶端堆積的命令超過8192bytes 時泽示,會發(fā)送給服務端。
源碼:redis.clients.util.RedisOutputStream.java
public RedisOutputStream(final OutputStream out) {
this(out, 8192);
}
pipeline 對于命令條數(shù)沒有限制蜜氨,但是命令可能會受限于TCP 包大小械筛。
如果Jedis 發(fā)送了一組命令,而發(fā)送請求還沒有結(jié)束飒炎,Redis 響應的結(jié)果會放在接收緩沖區(qū)埋哟。如果接收緩沖區(qū)滿了,jedis 會通知redis win=0郎汪,此時redis 不會再發(fā)送結(jié)果給jedis 端赤赊,轉(zhuǎn)而把響應結(jié)果保存在Redis 服務端的輸出緩沖區(qū)中。
輸出緩沖區(qū)的配置:redis.conf
#client-output-buffer-limit <class> <hard limit> <soft limit> <soft seconds>
client-output-buffer-limit normal 0 0 0
client-output-buffer-limit replica 256mb 64mb 60
client-output-buffer-limit pubsub 32mb 8mb 60
- class:
客戶端類型煞赢,分為三種砍鸠。a)normal:普通客戶端;b)slave:slave 客戶端耕驰,用于復制爷辱;c)pubsub:發(fā)布訂閱客戶端 - hard limit
如果客戶端使用的輸出緩沖區(qū)大于<hard limit>,客戶端會被立即關(guān)閉朦肘,0 代表不限制 - soft limit soft seconds
如果客戶端使用的輸出緩沖區(qū)超過了<soft limit>并且持續(xù)了<soft limit>秒饭弓,客戶端會被立即關(guān)閉
每個客戶端使用的輸出緩沖區(qū)的大小可以用client list 命令查看
redis> client list
id=5 addr=192.168.8.1:10859 fd=8 name= age=5 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=5 qbuf-free=32763 obl=16380 oll=227 omem=4654408 events=rw cmd=set
obl : 輸出緩沖區(qū)的長度(字節(jié)為單位, 0 表示沒有分配輸出緩沖區(qū))
oll : 輸出列表包含的對象數(shù)量(當輸出緩沖區(qū)沒有剩余空間時媒抠,命令回復會以字符串對象的形式被入隊到這個隊列里)
omem : 輸出緩沖區(qū)和輸出列表占用的內(nèi)存總量
Pipeline 適用于什么場景呢弟断?
如果某些操作需要馬上得到Redis 操作是否成功的結(jié)果,這種場景就不適合趴生。
有些場景阀趴,例如批量寫入數(shù)據(jù),對于結(jié)果的實時性和成功性要求不高苍匆,就可以用Pipeline刘急。
Jedis 實現(xiàn)分布式鎖
原文地址:https://redis.io/topics/distlock
中文地址:http://redis.cn/topics/distlock.html
分布式鎖的基本特性或者要求:
1、互斥性:只有一個客戶端能夠持有鎖浸踩。
2叔汁、不會產(chǎn)生死鎖:即使持有鎖的客戶端崩潰,也能保證后續(xù)其他客戶端可以獲取鎖。
3据块、只有持有這把鎖的客戶端才能解鎖码邻。
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
// set 支持多個參數(shù)NX(not exist) XX(exist) EX(seconds) PX(million seconds)
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
參數(shù)解讀:
1、lockKey 是Redis key 的名稱另假,也就是誰添加成功這個key 代表誰獲取鎖成功像屋。
2、requestId 是客戶端的ID(設置成value)边篮,如果我們要保證只有加鎖的客戶端才能釋放鎖己莺,就必須獲得客戶端的ID(保證第3 點)。
3苟耻、SET_IF_NOT_EXIST 是我們的命令里面加上NX(保證第1 點)篇恒。
4扶檐、SET_WITH_EXPIRE_TIME凶杖,PX 代表以毫秒為單位設置key 的過期時間(保證第2 點)。expireTime 是自動釋放鎖的時間款筑,比如5000 代表5 秒智蝠。
釋放鎖,直接刪除key 來釋放鎖可以嗎奈梳?就像這樣:
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}
沒有對客戶端requestId 進行判斷杈湾,可能會釋放其他客戶端持有的鎖。
先判斷后刪除呢攘须?
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
// 判斷加鎖與解鎖是不是同一個客戶端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此時漆撞,這把鎖突然不是這個客戶端的,則會誤解鎖
jedis.del(lockKey);
}
}
如果在釋放鎖的時候于宙,這把鎖已經(jīng)不屬于這個客戶端(例如已經(jīng)過期浮驳,并且被別的客戶端獲取鎖成功了),那就會出現(xiàn)釋放了其他客戶端的鎖的情況捞魁。
所以我們把判斷客戶端是否相等和刪除key 的操作放在Lua 腳本里面執(zhí)行至会。
public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
}
Luttece
與Jedis 相比,Lettuce 則完全克服了其線程不安全的缺點:Lettuce 是一個可伸縮的線程安全的Redis 客戶端谱俭,支持同步奉件、異步和響應式模式(Reactive)。多個線程可以共享一個連接實例昆著,而不必擔心多線程并發(fā)問題县貌。
它基于Netty 框架構(gòu)建,支持Redis 的高級功能凑懂,如Pipeline窃这、發(fā)布訂閱,事務、Sentinel杭攻,集群祟敛,支持連接池。
Lettuce 是Spring Boot 2.x 默認的客戶端兆解,替換了Jedis馆铁。集成之后我們不需要單獨使用它,直接調(diào)用Spring 的RedisTemplate 操作锅睛,連接和創(chuàng)建和關(guān)閉也不需要我們操心埠巨。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
Redisson
https://redisson.org/
https://github.com/redisson/redisson/wiki/目錄
Redisson 是一個在Redis 的基礎(chǔ)上實現(xiàn)的Java 駐內(nèi)存數(shù)據(jù)網(wǎng)格(In-MemoryData Grid),提供了分布式和可擴展的Java 數(shù)據(jù)結(jié)構(gòu)现拒。
基于Netty 實現(xiàn)辣垒,采用非阻塞IO,性能高
支持異步請求
支持連接池印蔬、pipeline勋桶、LUA Scripting、Redis Sentinel侥猬、Redis Cluster
不支持事務例驹,官方建議以LUA Scripting 代替事務
主從、哨兵退唠、集群都支持鹃锈。Spring 也可以配置和注入RedissonClient。
在Redisson 里面提供了更加簡單的分布式鎖的實現(xiàn)瞧预。
加鎖
public static void main(String[] args) throws InterruptedException {
RLock rLock=redissonClient.getLock("updateAccount");
// 最多等待100 秒屎债、上鎖10s 以后自動解鎖
if(rLock.tryLock(100,10, TimeUnit.SECONDS)){
System.out.println("獲取鎖成功");
}
// do something
rLock.unlock();
}
在獲得RLock 之后,只需要一個tryLock 方法垢油,里面有3 個參數(shù):
1盆驹、watiTime:獲取鎖的最大等待時間,超過這個時間不再嘗試獲取鎖
2秸苗、leaseTime:如果沒有調(diào)用unlock召娜,超過了這個時間會自動釋放鎖
3、TimeUnit:釋放時間的單位
Redisson 的分布式鎖是怎么實現(xiàn)的呢惊楼?
在加鎖的時候玖瘸,在Redis 寫入了一個HASH,key 是鎖名稱檀咙,field 是線程名稱雅倒,value 是1(表示鎖的重入次數(shù))。
源碼:
tryLock()——tryAcquire()——tryAcquireAsync()——tryLockInnerAsync()
最終也是調(diào)用了一段Lua 腳本弧可。里面有一個參數(shù)蔑匣,兩個參數(shù)的值。
// KEYS[1] 鎖名稱updateAccount
// ARGV[1] key 過期時間10000ms
// ARGV[2] 線程名稱
// 鎖名稱不存在
if (redis.call('exists', KEYS[1]) == 0) then
// 創(chuàng)建一個hash,key=鎖名稱裁良,field=線程名凿将,value=1
redis.call('hset', KEYS[1], ARGV[2], 1);
// 設置hash 的過期時間
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 鎖名稱存在,判斷是否當前線程持有的鎖
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
// 如果是价脾,value+1牧抵,代表重入次數(shù)+1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
// 重新獲得鎖,需要重新設置Key 的過期時間
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 鎖存在侨把,但是不是當前線程持有犀变,返回過期時間(毫秒)
return redis.call('pttl', KEYS[1]);
釋放鎖,源碼:
unlock——unlockInnerAsync
// KEYS[1] 鎖的名稱updateAccount
// KEYS[2] 頻道名稱redisson_lock__channel:{updateAccount}
// ARGV[1] 釋放鎖的消息0
// ARGV[2] 鎖釋放時間10000
// ARGV[3] 線程名稱
// 鎖不存在(過期或者已經(jīng)釋放了)
if (redis.call('exists', KEYS[1]) == 0) then
// 發(fā)布鎖已經(jīng)釋放的消息
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
// 鎖存在秋柄,但是不是當前線程加的鎖
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
// 鎖存在获枝,是當前線程加的鎖
// 重入次數(shù)-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
// -1 后大于0,說明這個線程持有這把鎖還有其他的任務需要執(zhí)行
if (counter > 0) then
// 重新設置鎖的過期時間
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
// -1 之后等于0骇笔,現(xiàn)在可以刪除鎖了
redis.call('del', KEYS[1]);
// 刪除之后發(fā)布釋放鎖的消息
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
// 其他情況返回nil
return nil;
這個是Redisson 里面分布式鎖的實現(xiàn)省店,我們在調(diào)用的時候非常簡單。
Redisson 跟Jedis 定位不同蜘拉,它不是一個單純的Redis 客戶端萨西,而是基于Redis 實現(xiàn)的分布式的服務有鹿,如果有需要用到一些分布式的數(shù)據(jù)結(jié)構(gòu)旭旭,比如我們還可以基于Redisson 的分布式隊列實現(xiàn)分布式事務,就可以引入Redisson 的依賴實現(xiàn)葱跋。
數(shù)據(jù)一致性
緩存使用場景
針對讀多寫少的高并發(fā)場景持寄,我們可以使用緩存來提升查詢速度。
當我們使用Redis 作為緩存的時候娱俺,一般流程是這樣的:
1稍味、如果數(shù)據(jù)在Redis 存在,應用就可以直接從Redis 拿到數(shù)據(jù)荠卷,不用訪問數(shù)據(jù)庫模庐。
2、如果Redis 里面沒有油宜,先到數(shù)據(jù)庫查詢掂碱,然后寫入到Redis,再返回給應用慎冤。
一致性問題的定義
因為這些數(shù)據(jù)是很少修改的疼燥,所以在絕大部分的情況下可以命中緩存。但是蚁堤,一旦被緩存的數(shù)據(jù)發(fā)生變化的時候醉者,我們既要操作數(shù)據(jù)庫的數(shù)據(jù),也要操作Redis 的數(shù)據(jù),
所以問題來了∏思矗現(xiàn)在我們有兩種選擇:
1立磁、先操作Redis 的數(shù)據(jù)再操作數(shù)據(jù)庫的數(shù)據(jù)
2、先操作數(shù)據(jù)庫的數(shù)據(jù)再操作Redis 的數(shù)據(jù)
到底選哪一種剥槐?
首先需要明確的是息罗,不管選擇哪一種方案, 我們肯定是希望兩個操作要么都成功才沧,要么都一個都不成功迈喉。不然就會發(fā)生Redis 跟數(shù)據(jù)庫的數(shù)據(jù)不一致的問題。
但是温圆,Redis 的數(shù)據(jù)和數(shù)據(jù)庫的數(shù)據(jù)是不可能通過事務達到統(tǒng)一的挨摸,我們只能根據(jù)相應的場景和所需要付出的代價來采取一些措施降低數(shù)據(jù)不一致的問題出現(xiàn)的概率,在數(shù)據(jù)一致性和性能之間取得一個權(quán)衡岁歉。
對于數(shù)據(jù)庫的實時性一致性要求不是特別高的場合得运,比如T+1 的報表,可以采用定時任務查詢數(shù)據(jù)庫數(shù)據(jù)同步到Redis 的方案锅移。
由于我們是以數(shù)據(jù)庫的數(shù)據(jù)為準的班巩,所以給緩存設置一個過期時間,是保證最終一致性的解決方案辽狈。
方案選擇
Redis:刪除還是更新宙暇?
這里我們先要補充一點,當存儲的數(shù)據(jù)發(fā)生變化备绽,Redis 的數(shù)據(jù)也要更新的時候券坞,我們有兩種方案,一種就是直接更新肺素,調(diào)用set恨锚;還有一種是直接刪除緩存,讓應用在下次查詢的時候重新寫入倍靡。
這兩種方案怎么選擇呢猴伶?這里我們主要考慮更新緩存的代價。
更新緩存之前塌西,是不是要經(jīng)過其他表的查詢他挎、接口調(diào)用、計算才能得到最新的數(shù)據(jù)雨让,而不是直接從數(shù)據(jù)庫拿到的值雇盖。如果是的話,建議直接刪除緩存栖忠,這種方案更加簡單崔挖,而且避免了數(shù)據(jù)庫的數(shù)據(jù)和緩存不一致的情況贸街。在一般情況下,我們也推薦使用刪除的方案狸相。
這一點明確之后薛匪,現(xiàn)在我們就剩一個問題:
1、到底是先更新數(shù)據(jù)庫脓鹃,再刪除緩存
2逸尖、還是先刪除緩存,再更新數(shù)據(jù)庫
我們先看第一種方案瘸右。
先更新數(shù)據(jù)庫娇跟,再刪除緩存
正常情況:
更新數(shù)據(jù)庫,成功太颤。
刪除緩存苞俘,成功。
異常情況:
1龄章、更新數(shù)據(jù)庫失敗吃谣,程序捕獲異常,不會走到下一步做裙,所以數(shù)據(jù)不會出現(xiàn)不一致岗憋。
2、更新數(shù)據(jù)庫成功锚贱,刪除緩存失敗仔戈。數(shù)據(jù)庫是新數(shù)據(jù),緩存是舊數(shù)據(jù)惋鸥,發(fā)生了不一致的情況杂穷。
這種問題怎么解決呢悍缠?我們可以提供一個重試的機制卦绣。
比如:如果刪除緩存失敗,我們捕獲這個異常飞蚓,把需要刪除的key 發(fā)送到消息隊列滤港。
讓后自己創(chuàng)建一個消費者消費,嘗試再次刪除這個key趴拧。
這種方式有個缺點溅漾,會對業(yè)務代碼造成入侵。
所以我們又有了第二種方案(異步更新緩存):
因為更新數(shù)據(jù)庫時會往binlog 寫入日志著榴,所以我們可以通過一個服務來監(jiān)聽binlog
的變化(比如阿里的canal)添履,然后在客戶端完成刪除key 的操作。如果刪除失敗的話脑又,
再發(fā)送到消息隊列暮胧。
總之锐借,對于后刪除緩存失敗的情況,我們的做法是不斷地重試刪除往衷,直到成功钞翔。
無論是重試還是異步刪除,都是最終一致性的思想席舍。
先刪除緩存布轿,再更新數(shù)據(jù)庫
正常情況:
刪除緩存,成功来颤。
更新數(shù)據(jù)庫汰扭,成功。
異常情況:
1福铅、刪除緩存东且,程序捕獲異常,不會走到下一步本讥,所以數(shù)據(jù)不會出現(xiàn)不一致珊泳。
2、刪除緩存成功拷沸,更新數(shù)據(jù)庫失敗色查。因為以數(shù)據(jù)庫的數(shù)據(jù)為準,所以不存在數(shù)據(jù)不一致的情況撞芍。
看起來好像沒問題秧了,但是如果有程序并發(fā)操作的情況下:
1)線程A 需要更新數(shù)據(jù),首先刪除了Redis 緩存
2)線程B 查詢數(shù)據(jù)序无,發(fā)現(xiàn)緩存不存在验毡,到數(shù)據(jù)庫查詢舊值,寫入Redis帝嗡,返回
3)線程A 更新了數(shù)據(jù)庫
這個時候晶通,Redis 是舊的值,數(shù)據(jù)庫是新的值哟玷,發(fā)生了數(shù)據(jù)不一致的情況狮辽。
那問題就變成了:能不能讓對同一條數(shù)據(jù)的訪問串行化呢?代碼肯定保證不了巢寡,因為有多個線程喉脖,即使做了任務隊列也可能有多個服務實例。數(shù)據(jù)庫也保證不了抑月,因為會有多個數(shù)據(jù)庫的連接树叽。只有一個數(shù)據(jù)庫只提供一個連接的情況下,才能保證讀寫的操作是串行的谦絮,或者我們把所有的讀寫請求放到同一個內(nèi)存隊列當中题诵,但是這種情況吞吐量太低了须误。
所以我們有一種延時雙刪的策略,在寫入數(shù)據(jù)之后仇轻,再刪除一次緩存京痢。
A 線程:
1)刪除緩存
2)更新數(shù)據(jù)庫
3)休眠500ms(這個時間,依據(jù)讀取數(shù)據(jù)的耗時而定)
4)再次刪除緩存
偽代碼:
public void write(String key,Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(500);
redis.delKey(key);
}
高并發(fā)問題
在Redis 存儲的所有數(shù)據(jù)中篷店,有一部分是被頻繁訪問的祭椰。有兩種情況可能會導致熱點問題的產(chǎn)生,一個是用戶集中訪問的數(shù)據(jù)疲陕,比如搶購的商品方淤,明星結(jié)婚和明星出軌的微博。還有一種就是在數(shù)據(jù)進行分片的情況下蹄殃,負載不均衡携茂,超過了單個服務器的承受能力。熱點問題可能引起緩存服務的不可用诅岩,最終造成壓力堆積到數(shù)據(jù)庫讳苦。
出于存儲和流量優(yōu)化的角度,我們必須要找到這些熱點數(shù)據(jù)吩谦。
熱點數(shù)據(jù)發(fā)現(xiàn)
除了自動的緩存淘汰機制之外鸳谜,怎么找出那些訪問頻率高的key 呢?或者說式廷,我們可以在哪里記錄key 被訪問的情況呢咐扭?
- 客戶端
第一個當然是在客戶端了,比如我們可不可以在所有調(diào)用了get滑废、set 方法的地方蝗肪,加上key 的計數(shù)。但是這樣的話蠕趁,每一個地方都要修改薛闪,重復的代碼也多。如果我們用的是Jedis 的客戶端妻导,我們可以在Jedis 的Connection 類的sendCommand()里面逛绵,用一個HashMap 進行key 的計數(shù)。
但是這種方式有幾個問題:
1倔韭、不知道要存多少個key,可能會發(fā)生內(nèi)存泄露的問題瓢对。
2寿酌、會對客戶端的代碼造成入侵。
3硕蛹、只能統(tǒng)計當前客戶端的熱點key醇疼。 - 代理層
第二種方式就是在代理端實現(xiàn)硕并,比如TwemProxy 或者Codis,但是不是所有的項目都使用了代理的架構(gòu)秧荆。 - 服務端
第三種就是在服務端統(tǒng)計倔毙,Redis 有一個monitor 的命令,可以監(jiān)控到所有Redis執(zhí)行的命令
jedis.monitor(new JedisMonitor() {
@Override
public void onCommand(String command) {
System.out.println("#monitor: " + command);
}
});
Facebook 的開源項目redis-faina(https://github.com/facebookarchive/redis-faina.git)就是基于這個原理實現(xiàn)的乙濒。
它是一個python 腳本陕赃,可以分析monitor 的數(shù)據(jù)。
這種方法也會有兩個問題:
1)monitor 命令在高并發(fā)的場景下颁股,會影響性能么库,所以不適合長時間使用。
2)只能統(tǒng)計一個Redis 節(jié)點的熱點key甘有。
- 機器層面
還有一種方法就是機器層面的诉儒,通過對TCP 協(xié)議進行抓包,也有一些開源的方案亏掀,比如ELK 的packetbeat 插件忱反。
當我們發(fā)現(xiàn)了熱點key 之后,我們來看下熱點數(shù)據(jù)在高并發(fā)的場景下可能會出現(xiàn)的問題滤愕,以及怎么去解決缭受。
緩存雪崩
緩存雪崩就是Redis 的大量熱點數(shù)據(jù)同時過期(失效),因為設置了相同的過期時間该互,剛好這個時候Redis 請求的并發(fā)量又很大米者,就會導致所有的請求落到數(shù)據(jù)庫。
- 緩存雪崩的解決方案
1)加互斥鎖或者使用隊列宇智,針對同一個key 只允許一個線程到數(shù)據(jù)庫查詢
2)緩存定時預先更新蔓搞,避免同時失效
3)通過加隨機數(shù),使key 在不同的時間過期
4)緩存永不過期
緩存穿透
我們已經(jīng)知道了Redis 使用的場景了随橘。在緩存存在和緩存不存在的情況下的什么情況我們都了解了喂分。
還有一種情況,數(shù)據(jù)在數(shù)據(jù)庫和Redis 里面都不存在机蔗,可能是一次條件錯誤的查詢蒲祈。
在這種情況下,因為數(shù)據(jù)庫值不存在萝嘁,所以肯定不會寫入Redis梆掸,那么下一次查詢相同的key 的時候,肯定還是會再到數(shù)據(jù)庫查一次牙言。那么這種循環(huán)查詢數(shù)據(jù)庫中不存在的值酸钦,并且每次使用的是相同的key 的情況,我們有沒有什么辦法避免應用到數(shù)據(jù)庫查詢呢咱枉?
(1)緩存空數(shù)據(jù)(2)緩存特殊字符串卑硫,比如&&
我們可以在數(shù)據(jù)庫緩存一個空字符串徒恋,或者緩存一個特殊的字符串,那么在應用里面拿到這個特殊字符串的時候欢伏,就知道數(shù)據(jù)庫沒有值了入挣,也沒有必要再到數(shù)據(jù)庫查詢了。
但是這里需要設置一個過期時間硝拧,不然的話數(shù)據(jù)庫已經(jīng)新增了這一條記錄径筏,應用也還是拿不到值。
這個是應用重復查詢同一個不存在的值的情況河爹,如果應用每一次查詢的不存在的值是不一樣的呢匠璧?即使你每次都緩存特殊字符串也沒用,因為它的值不一樣咸这,比如我們的用戶系統(tǒng)登錄的場景夷恍,如果是惡意的請求,它每次都生成了一個符合ID 規(guī)則的賬號媳维,但是這個賬號在我們的數(shù)據(jù)庫是不存在的酿雪,那Redis 就完全失去了作用。
這種因為每次查詢的值都不存在導致的Redis 失效的情況侄刽,我們就把它叫做緩存穿透指黎。這個問題我們應該怎么去解決呢?
經(jīng)典面試題
其實它也是一個通用的問題州丹,關(guān)鍵就在于我們怎么知道請求的key 在我們的數(shù)據(jù)庫里面是否存在醋安,如果數(shù)據(jù)量特別大的話,我們怎么去快速判斷墓毒。
這也是一個非常經(jīng)典的面試題:
如何在海量元素中(例如10 億無序吓揪、不定長、不重復)快速判斷一個元素是否存在所计?
如果是緩存穿透的這個問題柠辞,我們要避免到數(shù)據(jù)庫查詢不存的數(shù)據(jù),肯定要把這10億放在別的地方主胧。這些數(shù)據(jù)在Redis 里面也是沒有的叭首,為了加快檢索速度,我們要把數(shù)據(jù)放到內(nèi)存里面來判斷踪栋,問題來了:
如果我們直接把這些元素的值放到基本的數(shù)據(jù)結(jié)構(gòu)(List焙格、Map、Tree)里面己英,比如一個元素1 字節(jié)的字段间螟,10 億的數(shù)據(jù)大概需要900G 的內(nèi)存空間,這個對于普通的服務器來說是承受不了的损肛。
所以厢破,我們存儲這幾十億個元素,不能直接存值治拿,我們應該找到一種最簡單的最節(jié)省空間的數(shù)據(jù)結(jié)構(gòu)摩泪,用來標記這個元素有沒有出現(xiàn)。
這個東西我們就把它叫做位圖劫谅,他是一個有序的數(shù)組见坑,只有兩個值,0 和1捏检。0 代表不存在荞驴,1 代表存在。
那我們怎么用這個數(shù)組里面的有序的位置來標記這10 億個元素是否存在呢贯城?我們是不是必須要有一個映射方法熊楼,把元素映射到一個下標位置上?
對于這個映射方法能犯,我們有幾個基本的要求:
1)因為我們的值長度是不固定的鲫骗,我希望不同長度的輸入,可以得到固定長度的輸出踩晶。
2)轉(zhuǎn)換成下標的時候执泰,我希望他在我的這個有序數(shù)組里面是分布均勻的,不然的話全部擠到一對去了渡蜻,我也沒法判斷到底哪個元素存了术吝,哪個元素沒存。
這個就是哈希函數(shù)茸苇,比如MD5排苍、SHA-1 等等這些都是常見的哈希算法。
比如税弃,這6 個元素纪岁,我們經(jīng)過哈希函數(shù)和位運算,得到了相應的下標则果。
哈希碰撞
這個時候幔翰,Tom 和Mic 經(jīng)過計算得到的哈希值是一樣的,那么再經(jīng)過位運算得到的下標肯定是一樣的西壮,我們把這種情況叫做哈希沖突或者哈希碰撞遗增。
如果發(fā)生了哈希碰撞,這個時候?qū)τ谖覀兊娜萜鞔嬷悼隙ㄊ怯杏绊懙目钋啵覀兛梢酝ㄟ^哪些方式去降低哈希碰撞的概率呢做修?
第一種就是擴大維數(shù)組的長度或者說位圖容量。因為我們的函數(shù)是分布均勻的,所以饰及,位圖容量越大蔗坯,在同一個位置發(fā)生哈希碰撞的概率就越小。
是不是位圖容量越大越好呢燎含?不管存多少個元素宾濒,都創(chuàng)建一個幾萬億大小的位圖,
可以嗎屏箍?當然不行绘梦,因為越大的位圖容量,意味著越多的內(nèi)存消耗赴魁,所以我們要創(chuàng)建一個合適大小的位圖容量卸奉。
除了擴大位圖容量,我們還有什么降低哈希碰撞概率的方法呢颖御?
如果兩個元素經(jīng)過一次哈希計算榄棵,得到的相同下標的概率比較高,我可以不可以計算多次呢郎嫁? 原來我只用一個哈希函數(shù)秉继,現(xiàn)在我對于每一個要存儲的元素都用多個哈希函數(shù)計算,這樣每次計算出來的下標都相同的概率就小得多了泽铛。
同樣的尚辑,我們能不能引入很多個哈希函數(shù)呢?比如都計算100 次盔腔,都可以嗎渊胸?當然也會有問題拂酣,第一個就是它會填滿位圖的更多空間,第二個是計算是需要消耗時間的。
所以總的來說晌姚,我們既要節(jié)省空間纹坐,又要很高的計算效率轧飞,就必須在位圖容量和函數(shù)個數(shù)之間找到一個最佳的平衡密任。
比如說:我們存放100 萬個元素,到底需要多大的位圖容量愕够,需要多少個哈希函數(shù)呢走贪?
布隆過濾器原理
當然,這個事情早就有人研究過了惑芭,在1970 年的時候坠狡,有一個叫做布隆的前輩對于判斷海量元素中元素是否存在的問題進行了研究,也就是到底需要多大的位圖容量和多少個哈希函數(shù)遂跟,它發(fā)表了一篇論文逃沿,提出的這個容器就叫做布隆過濾器婴渡。
我們來看一下布隆過濾器的工作原理。
首先凯亮,布隆過濾器的本質(zhì)就是我們剛才分析的边臼,一個位數(shù)組,和若干個哈希函數(shù)触幼。
集合里面有3 個元素硼瓣,要把它存到布隆過濾器里面去究飞,應該怎么做置谦?首先是a 元素,這里我們用3 次計算亿傅。b媒峡、c 元素也一樣。
元素已經(jīng)存進去之后葵擎,現(xiàn)在我要來判斷一個元素在這個容器里面是否存在谅阿,就要使用同樣的三個函數(shù)進行計算。
比如d 元素酬滤,我用第一個函數(shù)f1 計算签餐,發(fā)現(xiàn)這個位置上是1,沒問題盯串。第二個位置也是1氯檐,第三個位置也是1 。
如果經(jīng)過三次計算得到的下標位置值都是1体捏,這種情況下冠摄,能不能確定d 元素一定在這個容器里面呢? 實際上是不能的几缭。比如這張圖里面河泳,這三個位置分別是把a,b,c 存進去的時候置成1 的,所以即使d 元素之前沒有存進去年栓,也會得到三個1拆挥,判斷返回true。
所以某抓,這個是布隆過濾器的一個很重要的特性纸兔,因為哈希碰撞不可避免,所以它會存在一定的誤判率搪缨。這種把本來不存在布隆過濾器中的元素誤判為存在的情況食拜,我們把它叫做假陽性(False Positive Probability,F(xiàn)PP)副编。
我們再來看另一個元素负甸,e 元素。我們要判斷它在容器里面是否存在,一樣地要用這三個函數(shù)去計算呻待。第一個位置是1打月,第二個位置是1,第三個位置是0蚕捉。
e 元素是不是一定不在這個容器里面呢奏篙? 可以確定一定不存在。如果說當時已經(jīng)把e 元素存到布隆過濾器里面去了迫淹,那么這三個位置肯定都是1秘通,不可能出現(xiàn)0。
總結(jié):布隆過濾器的特點:
從容器的角度來說:
1敛熬、如果布隆過濾器判斷元素在集合中存在肺稀,不一定存在
2、如果布隆過濾器判斷不存在应民,一定不存在
從元素的角度來說:
3话原、如果元素實際存在,布隆過濾器一定判斷存在
4诲锹、如果元素實際不存在繁仁,布隆過濾器可能判斷存在
利用,第二個特性归园,我們是不是就能解決持續(xù)從數(shù)據(jù)庫查詢不存在的值的問題黄虱?
谷歌的Guava 里面就提供了一個現(xiàn)成的布隆過濾器。
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>21.0</version>
</dependency>
創(chuàng)建布隆過濾器:
BloomFilter<String> bf = BloomFilter.create(
Funnels.stringFunnel(Charsets.UTF_8), insertions);
布隆過濾器提供的存放元素的方法是put()蔓倍。
布隆過濾器提供的判斷元素是否存在的方法是mightContain()悬钳。
if (bf.mightContain(data)) {
if (sets.contains(data)) {
// 判斷存在實際存在的時候,命中
right++;
continue;
}
// 判斷存在卻不存在的時候偶翅,錯誤
wrong++;
}
布隆過濾器把誤判率默認設置為0.03默勾,也可以在創(chuàng)建的時候指定。
public static <T> BloomFilter<T> create(Funnel<? super T> funnel, long expectedInsertions) {
return create(funnel, expectedInsertions, 0.03D);
}
位圖的容量是基于元素個數(shù)和誤判率計算出來的
long numBits = optimalNumOfBits(expectedInsertions, fpp);
根據(jù)位數(shù)組的大小聚谁,我們進一步計算出了哈希函數(shù)的個數(shù)母剥。
int numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, numBits);
存儲100 萬個元素只占用了0.87M 的內(nèi)存,生成了5 個哈希函數(shù)形导。
https://hur.st/bloomfilter/?n=1000000&p=0.03&m=&k=
布隆過濾器在項目中的使用
布隆過濾器的工作位置:
因為要判斷數(shù)據(jù)庫的值是否存在环疼,所以第一步是加載數(shù)據(jù)庫所有的數(shù)據(jù)。在去Redis查詢之前朵耕,先在布隆過濾器查詢炫隶,如果bf 說沒有,那數(shù)據(jù)庫肯定沒有阎曹,也不用去查了伪阶。
如果bf 說有煞檩,才走之前的流程。
布隆過濾器的其他應用場景
布隆過濾器解決的問題是什么栅贴?如何在海量元素中快速判斷一個元素是否存在斟湃。所以除了解決緩存穿透的問題之外,我們還有很多其他的用途檐薯。
比如爬數(shù)據(jù)的爬蟲凝赛,爬過的url 我們不需要重復爬,那么在幾十億的url 里面坛缕,怎么判斷一個url 是不是已經(jīng)爬過了墓猎?
還有我們的郵箱服務器,發(fā)送垃圾郵件的賬號我們把它們叫做spamer祷膳,在這么多的郵箱賬號里陶衅,怎么判斷一個賬號是不是spamer 等等一些場景,我們都可以用到布隆過濾器直晨。
——學自咕泡學院