Redis 客戶端
客戶端通信原理
客戶端和服務(wù)器通過TCP 連接來進(jìn)行數(shù)據(jù)交互, 服務(wù)器默認(rèn)的端口號為6379 。
客戶端和服務(wù)器發(fā)送的命令或數(shù)據(jù)一律以\r\n (CRLF 回車+換行)結(jié)尾。
客戶端跟Redis 之間使用一種特殊的編碼格式(在AOF 文件里面我們看到了),叫做Redis Serialization Protocol (Redis 序列化協(xié)議)。特點:容易實現(xiàn)、解析快、可讀性強??蛻舳税l(fā)給服務(wù)端的消息需要經(jīng)過編碼,服務(wù)端收到之后會按約定進(jìn)行解碼,反之亦然。
基于此,我們可以自己實現(xiàn)一個Redis 客戶端。
1、建立Socket 連接
2、OutputStream 寫入數(shù)據(jù)(發(fā)送到服務(wù)端)
3、InputStream 讀取數(shù)據(jù)(從服務(wù)端接口)
基于這種協(xié)議,我們可以用Java 實現(xiàn)所有的Redis 操作命令。當(dāng)然,我們不需要這么做,因為已經(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 多個線程使用一個連接的時候線程不安全??梢允褂眠B接池,為每個請求創(chuàng)建不同的連接,基于Apache common pool 實現(xiàn)。跟數(shù)據(jù)庫一樣,可以設(shè)置最大連接數(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、事務(wù)。
Client 模式就是客戶端發(fā)送一個命令,阻塞等待服務(wù)端執(zhí)行,然后讀取返回結(jié)果。
Pipeline 模式是一次性發(fā)送多個命令,最后一次取回所有的返回結(jié)果,這種模式通過減少網(wǎng)絡(luò)的往返時間和io 讀寫次數(shù),大幅度提高通信性能。
第三種是事務(wù)模式。Transaction 模式即開啟Redis 的事務(wù)管理,事務(wù)模式開啟后,所有的命令(除了exec,discard,multi 和watch)到達(dá)服務(wù)端以后不會立即執(zhí)行,會進(jìn)入一個等待隊列。
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)聽者M(jìn)asterListener。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 服務(wù)端clusterslots 命令,獲取虛擬槽信息。
該集合的基本信息為[long, long, List, List], 第一,二個元素是該節(jié)點負(fù)責(zé)槽點的起始位置,第三個元素是主節(jié)點信息,第四個元素為主節(jié)點對應(yīng)的從節(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é)點地址信息來設(shè)置節(jié)點對應(yīng)的JedisPool , 即設(shè)置Map<String,JedisPool> nodes 的值。
接下來判斷若此時節(jié)點信息為主節(jié)點信息時,則調(diào)用assignSlotsToNodes 方法,設(shè)置每個槽點值對應(yīng)的連接池,即設(shè)置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;
}
// 獲取分配到當(dāng)前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 從緩存中查詢對應(yīng)的jedisPool 實例。如果沒有jedisPool實例,就創(chuàng)建JedisPool 實例,最后放入緩存中。nodeKey 和nodePool 的關(guān)系
setupNodeIfNotExist(targetNode);
// 把slot 和jedisPool 緩存起來(16384 個),key 是slot 下標(biāo),value 是連接池
if (i == MASTER_NODE_INDEX) {
assignSlotsToNode(slotNums, targetNode);
}
}
}
} finally {
w.unlock();
}
}
從集群環(huán)境存取值:
1、把key 作為參數(shù),執(zhí)行CRC16 算法,獲取key 對應(yīng)的slot 值。
2、通過該slot 值,去slots 的map 集合中獲取jedisPool 實例。
3、通過jedisPool 實例獲取jedis 實例,最終完成redis 數(shù)據(jù)存取工作。
pipeline
慢在哪里?
Redis 使用的是客戶端/服務(wù)器(C/S)模型和請求/響應(yīng)協(xié)議的TCP 服務(wù)器。這意味著通常情況下一個請求會遵循以下步驟:
- 客戶端向服務(wù)端發(fā)送一個查詢請求,并監(jiān)聽Socket 返回,通常是以阻塞模式,等待服務(wù)端響應(yīng)。
- 服務(wù)端處理命令,并將結(jié)果返回給客戶端。
Redis 客戶端與Redis 服務(wù)器之間使用TCP 協(xié)議進(jìn)行連接,一個客戶端可以通過一個socket 連接發(fā)起多個請求命令。每個請求命令發(fā)出后client 通常會阻塞并等待redis服務(wù)器處理,redis 處理完請求命令后會將結(jié)果通過響應(yīng)報文返回給client,因此當(dāng)執(zhí)行多條命令的時候都需要等待上一條命令執(zhí)行完畢才能執(zhí)行。執(zhí)行過程如圖:
Redis 本身提供了一些批量操作命令,比如mget,mset,可以減少通信的時間,但是大部分命令是不支持multi 操作的,例如hash 就沒有。
由于通信會有網(wǎng)絡(luò)延遲,假如client 和server 之間的包傳輸時間需要10 毫秒,一次交互就是20 毫秒(RTT:Round Trip Time)。這樣的話,client 1 秒鐘也只能也只能發(fā)送50 個命令。這顯然沒有充分利用Redis 的處理能力。另外一個,Redis 服務(wù)端執(zhí)行I/O 的次數(shù)過多。
- Pipeline 管道
https://redis.io/topics/pipelining
那我們能不能像數(shù)據(jù)庫的batch 操作一樣,把一組命令組裝在一起發(fā)送給Redis 服務(wù)端執(zhí)行,然后一次性獲得返回結(jié)果呢?這個就是Pipeline 的作用。Pipeline 通過一個隊列把所有的命令緩存起來,然后把多個命令在一次連接中發(fā)送給服務(wù)器。
image.png
要實現(xiàn)Pipeline,既要服務(wù)端的支持,也要客戶端的支持。對于服務(wù)端來說,需要能夠處理客戶端通過一個TCP 連接發(fā)來的多個命令,并且逐個地執(zhí)行命令一起返回。
對于客戶端來說,要把多個命令緩存起來,達(dá)到一定的條件就發(fā)送出去,最后才處理Redis 的應(yīng)答(這里也要注意對客戶端內(nèi)存的消耗)。
jedis-pipeline 的client-buffer 限制:8192bytes,客戶端堆積的命令超過8192bytes 時,會發(fā)送給服務(wù)端。
源碼:redis.clients.util.RedisOutputStream.java
public RedisOutputStream(final OutputStream out) {
this(out, 8192);
}
pipeline 對于命令條數(shù)沒有限制,但是命令可能會受限于TCP 包大小。
如果Jedis 發(fā)送了一組命令,而發(fā)送請求還沒有結(jié)束,Redis 響應(yīng)的結(jié)果會放在接收緩沖區(qū)。如果接收緩沖區(qū)滿了,jedis 會通知redis win=0,此時redis 不會再發(fā)送結(jié)果給jedis 端,轉(zhuǎn)而把響應(yīng)結(jié)果保存在Redis 服務(wù)端的輸出緩沖區(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 客戶端,用于復(fù)制;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ù)量(當(dāng)輸出緩沖區(qū)沒有剩余空間時,命令回復(fù)會以字符串對象的形式被入隊到這個隊列里)
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(設(shè)置成value),如果我們要保證只有加鎖的客戶端才能釋放鎖,就必須獲得客戶端的ID(保證第3 點)。
3、SET_IF_NOT_EXIST 是我們的命令里面加上NX(保證第1 點)。
4、SET_WITH_EXPIRE_TIME,PX 代表以毫秒為單位設(shè)置key 的過期時間(保證第2 點)。expireTime 是自動釋放鎖的時間,比如5000 代表5 秒。
釋放鎖,直接刪除key 來釋放鎖可以嗎?就像這樣:
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}
沒有對客戶端requestId 進(jìn)行判斷,可能會釋放其他客戶端持有的鎖。
先判斷后刪除呢?
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 客戶端,支持同步、異步和響應(yīng)式模式(Reactive)。多個線程可以共享一個連接實例,而不必?fù)?dān)心多線程并發(fā)問題。
它基于Netty 框架構(gòu)建,支持Redis 的高級功能,如Pipeline、發(fā)布訂閱,事務(wù)、Sentinel,集群,支持連接池。
Lettuce 是Spring Boot 2.x 默認(rèn)的客戶端,替換了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),提供了分布式和可擴(kuò)展的Java 數(shù)據(jù)結(jié)構(gòu)。
基于Netty 實現(xiàn),采用非阻塞IO,性能高
支持異步請求
支持連接池、pipeline、LUA Scripting、Redis Sentinel、Redis Cluster
不支持事務(wù),官方建議以LUA Scripting 代替事務(wù)
主從、哨兵、集群都支持。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);
// 設(shè)置hash 的過期時間
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 鎖名稱存在,判斷是否當(dāng)前線程持有的鎖
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
// 如果是,value+1,代表重入次數(shù)+1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
// 重新獲得鎖,需要重新設(shè)置Key 的過期時間
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
// 鎖存在,但是不是當(dāng)前線程持有,返回過期時間(毫秒)
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;
// 鎖存在,但是不是當(dāng)前線程加的鎖
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
// 鎖存在,是當(dāng)前線程加的鎖
// 重入次數(shù)-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
// -1 后大于0,說明這個線程持有這把鎖還有其他的任務(wù)需要執(zhí)行
if (counter > 0) then
// 重新設(shè)置鎖的過期時間
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)的分布式的服務(wù),如果有需要用到一些分布式的數(shù)據(jù)結(jié)構(gòu),比如我們還可以基于Redisson 的分布式隊列實現(xiàn)分布式事務(wù),就可以引入Redisson 的依賴實現(xiàn)。
數(shù)據(jù)一致性
緩存使用場景
針對讀多寫少的高并發(fā)場景,我們可以使用緩存來提升查詢速度。
當(dāng)我們使用Redis 作為緩存的時候,一般流程是這樣的:
1、如果數(shù)據(jù)在Redis 存在,應(yīng)用就可以直接從Redis 拿到數(shù)據(jù),不用訪問數(shù)據(jù)庫。
2、如果Redis 里面沒有,先到數(shù)據(jù)庫查詢,然后寫入到Redis,再返回給應(yīng)用。
一致性問題的定義
因為這些數(shù)據(jù)是很少修改的,所以在絕大部分的情況下可以命中緩存。但是,一旦被緩存的數(shù)據(jù)發(fā)生變化的時候,我們既要操作數(shù)據(jù)庫的數(shù)據(jù),也要操作Redis 的數(shù)據(jù),
所以問題來了?,F(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ù)是不可能通過事務(wù)達(dá)到統(tǒng)一的,我們只能根據(jù)相應(yīng)的場景和所需要付出的代價來采取一些措施降低數(shù)據(jù)不一致的問題出現(xiàn)的概率,在數(shù)據(jù)一致性和性能之間取得一個權(quán)衡。
對于數(shù)據(jù)庫的實時性一致性要求不是特別高的場合,比如T+1 的報表,可以采用定時任務(wù)查詢數(shù)據(jù)庫數(shù)據(jù)同步到Redis 的方案。
由于我們是以數(shù)據(jù)庫的數(shù)據(jù)為準(zhǔn)的,所以給緩存設(shè)置一個過期時間,是保證最終一致性的解決方案。
方案選擇
Redis:刪除還是更新?
這里我們先要補充一點,當(dāng)存儲的數(shù)據(jù)發(fā)生變化,Redis 的數(shù)據(jù)也要更新的時候,我們有兩種方案,一種就是直接更新,調(diào)用set;還有一種是直接刪除緩存,讓應(yīng)用在下次查詢的時候重新寫入。
這兩種方案怎么選擇呢?這里我們主要考慮更新緩存的代價。
更新緩存之前,是不是要經(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ā)生了不一致的情況。
這種問題怎么解決呢?我們可以提供一個重試的機(jī)制。
比如:如果刪除緩存失敗,我們捕獲這個異常,把需要刪除的key 發(fā)送到消息隊列。
讓后自己創(chuàng)建一個消費者消費,嘗試再次刪除這個key。
這種方式有個缺點,會對業(yè)務(wù)代碼造成入侵。
所以我們又有了第二種方案(異步更新緩存):
因為更新數(shù)據(jù)庫時會往binlog 寫入日志,所以我們可以通過一個服務(wù)來監(jiān)聽binlog
的變化(比如阿里的canal),然后在客戶端完成刪除key 的操作。如果刪除失敗的話,
再發(fā)送到消息隊列。
總之,對于后刪除緩存失敗的情況,我們的做法是不斷地重試刪除,直到成功。
無論是重試還是異步刪除,都是最終一致性的思想。
先刪除緩存,再更新數(shù)據(jù)庫
正常情況:
刪除緩存,成功。
更新數(shù)據(jù)庫,成功。
異常情況:
1、刪除緩存,程序捕獲異常,不會走到下一步,所以數(shù)據(jù)不會出現(xiàn)不一致。
2、刪除緩存成功,更新數(shù)據(jù)庫失敗。因為以數(shù)據(jù)庫的數(shù)據(jù)為準(zhǔn),所以不存在數(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ù)的訪問串行化呢?代碼肯定保證不了,因為有多個線程,即使做了任務(wù)隊列也可能有多個服務(wù)實例。數(shù)據(jù)庫也保證不了,因為會有多個數(shù)據(jù)庫的連接。只有一個數(shù)據(jù)庫只提供一個連接的情況下,才能保證讀寫的操作是串行的,或者我們把所有的讀寫請求放到同一個內(nèi)存隊列當(dāng)中,但是這種情況吞吐量太低了。
所以我們有一種延時雙刪的策略,在寫入數(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ù)中,有一部分是被頻繁訪問的。有兩種情況可能會導(dǎo)致熱點問題的產(chǎn)生,一個是用戶集中訪問的數(shù)據(jù),比如搶購的商品,明星結(jié)婚和明星出軌的微博。還有一種就是在數(shù)據(jù)進(jìn)行分片的情況下,負(fù)載不均衡,超過了單個服務(wù)器的承受能力。熱點問題可能引起緩存服務(wù)的不可用,最終造成壓力堆積到數(shù)據(jù)庫。
出于存儲和流量優(yōu)化的角度,我們必須要找到這些熱點數(shù)據(jù)。
熱點數(shù)據(jù)發(fā)現(xiàn)
除了自動的緩存淘汰機(jī)制之外,怎么找出那些訪問頻率高的key 呢?或者說,我們可以在哪里記錄key 被訪問的情況呢?
- 客戶端
第一個當(dāng)然是在客戶端了,比如我們可不可以在所有調(diào)用了get、set 方法的地方,加上key 的計數(shù)。但是這樣的話,每一個地方都要修改,重復(fù)的代碼也多。如果我們用的是Jedis 的客戶端,我們可以在Jedis 的Connection 類的sendCommand()里面,用一個HashMap 進(jìn)行key 的計數(shù)。
但是這種方式有幾個問題:
1、不知道要存多少個key,可能會發(fā)生內(nèi)存泄露的問題。
2、會對客戶端的代碼造成入侵。
3、只能統(tǒng)計當(dāng)前客戶端的熱點key。 - 代理層
第二種方式就是在代理端實現(xiàn),比如TwemProxy 或者Codis,但是不是所有的項目都使用了代理的架構(gòu)。 - 服務(wù)端
第三種就是在服務(wù)端統(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。
- 機(jī)器層面
還有一種方法就是機(jī)器層面的,通過對TCP 協(xié)議進(jìn)行抓包,也有一些開源的方案,比如ELK 的packetbeat 插件。
當(dāng)我們發(fā)現(xiàn)了熱點key 之后,我們來看下熱點數(shù)據(jù)在高并發(fā)的場景下可能會出現(xiàn)的問題,以及怎么去解決。
緩存雪崩
緩存雪崩就是Redis 的大量熱點數(shù)據(jù)同時過期(失效),因為設(shè)置了相同的過期時間,剛好這個時候Redis 請求的并發(fā)量又很大,就會導(dǎo)致所有的請求落到數(shù)據(jù)庫。
- 緩存雪崩的解決方案
1)加互斥鎖或者使用隊列,針對同一個key 只允許一個線程到數(shù)據(jù)庫查詢
2)緩存定時預(yù)先更新,避免同時失效
3)通過加隨機(jī)數(shù),使key 在不同的時間過期
4)緩存永不過期
緩存穿透
我們已經(jīng)知道了Redis 使用的場景了。在緩存存在和緩存不存在的情況下的什么情況我們都了解了。
還有一種情況,數(shù)據(jù)在數(shù)據(jù)庫和Redis 里面都不存在,可能是一次條件錯誤的查詢。
在這種情況下,因為數(shù)據(jù)庫值不存在,所以肯定不會寫入Redis,那么下一次查詢相同的key 的時候,肯定還是會再到數(shù)據(jù)庫查一次。那么這種循環(huán)查詢數(shù)據(jù)庫中不存在的值,并且每次使用的是相同的key 的情況,我們有沒有什么辦法避免應(yīng)用到數(shù)據(jù)庫查詢呢?
(1)緩存空數(shù)據(jù)(2)緩存特殊字符串,比如&&
我們可以在數(shù)據(jù)庫緩存一個空字符串,或者緩存一個特殊的字符串,那么在應(yīng)用里面拿到這個特殊字符串的時候,就知道數(shù)據(jù)庫沒有值了,也沒有必要再到數(shù)據(jù)庫查詢了。
但是這里需要設(shè)置一個過期時間,不然的話數(shù)據(jù)庫已經(jīng)新增了這一條記錄,應(yīng)用也還是拿不到值。
這個是應(yīng)用重復(fù)查詢同一個不存在的值的情況,如果應(yīng)用每一次查詢的不存在的值是不一樣的呢?即使你每次都緩存特殊字符串也沒用,因為它的值不一樣,比如我們的用戶系統(tǒng)登錄的場景,如果是惡意的請求,它每次都生成了一個符合ID 規(guī)則的賬號,但是這個賬號在我們的數(shù)據(jù)庫是不存在的,那Redis 就完全失去了作用。
這種因為每次查詢的值都不存在導(dǎo)致的Redis 失效的情況,我們就把它叫做緩存穿透。這個問題我們應(yīng)該怎么去解決呢?
經(jīng)典面試題
其實它也是一個通用的問題,關(guān)鍵就在于我們怎么知道請求的key 在我們的數(shù)據(jù)庫里面是否存在,如果數(shù)據(jù)量特別大的話,我們怎么去快速判斷。
這也是一個非常經(jīng)典的面試題:
如何在海量元素中(例如10 億無序、不定長、不重復(fù))快速判斷一個元素是否存在?
如果是緩存穿透的這個問題,我們要避免到數(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)存空間,這個對于普通的服務(wù)器來說是承受不了的。
所以,我們存儲這幾十億個元素,不能直接存值,我們應(yīng)該找到一種最簡單的最節(jié)省空間的數(shù)據(jù)結(jié)構(gòu),用來標(biāo)記這個元素有沒有出現(xiàn)。
這個東西我們就把它叫做位圖,他是一個有序的數(shù)組,只有兩個值,0 和1。0 代表不存在,1 代表存在。
那我們怎么用這個數(shù)組里面的有序的位置來標(biāo)記這10 億個元素是否存在呢?我們是不是必須要有一個映射方法,把元素映射到一個下標(biāo)位置上?
對于這個映射方法,我們有幾個基本的要求:
1)因為我們的值長度是不固定的,我希望不同長度的輸入,可以得到固定長度的輸出。
2)轉(zhuǎn)換成下標(biāo)的時候,我希望他在我的這個有序數(shù)組里面是分布均勻的,不然的話全部擠到一對去了,我也沒法判斷到底哪個元素存了,哪個元素沒存。
這個就是哈希函數(shù),比如MD5、SHA-1 等等這些都是常見的哈希算法。
比如,這6 個元素,我們經(jīng)過哈希函數(shù)和位運算,得到了相應(yīng)的下標(biāo)。
哈希碰撞
這個時候,Tom 和Mic 經(jīng)過計算得到的哈希值是一樣的,那么再經(jīng)過位運算得到的下標(biāo)肯定是一樣的,我們把這種情況叫做哈希沖突或者哈希碰撞。
如果發(fā)生了哈希碰撞,這個時候?qū)τ谖覀兊娜萜鞔嬷悼隙ㄊ怯杏绊懙?,我們可以通過哪些方式去降低哈希碰撞的概率呢?
第一種就是擴(kuò)大維數(shù)組的長度或者說位圖容量。因為我們的函數(shù)是分布均勻的,所以,位圖容量越大,在同一個位置發(fā)生哈希碰撞的概率就越小。
是不是位圖容量越大越好呢?不管存多少個元素,都創(chuàng)建一個幾萬億大小的位圖,
可以嗎?當(dāng)然不行,因為越大的位圖容量,意味著越多的內(nèi)存消耗,所以我們要創(chuàng)建一個合適大小的位圖容量。
除了擴(kuò)大位圖容量,我們還有什么降低哈希碰撞概率的方法呢?
如果兩個元素經(jīng)過一次哈希計算,得到的相同下標(biāo)的概率比較高,我可以不可以計算多次呢? 原來我只用一個哈希函數(shù),現(xiàn)在我對于每一個要存儲的元素都用多個哈希函數(shù)計算,這樣每次計算出來的下標(biāo)都相同的概率就小得多了。
同樣的,我們能不能引入很多個哈希函數(shù)呢?比如都計算100 次,都可以嗎?當(dāng)然也會有問題,第一個就是它會填滿位圖的更多空間,第二個是計算是需要消耗時間的。
所以總的來說,我們既要節(jié)省空間,又要很高的計算效率,就必須在位圖容量和函數(shù)個數(shù)之間找到一個最佳的平衡。
比如說:我們存放100 萬個元素,到底需要多大的位圖容量,需要多少個哈希函數(shù)呢?
布隆過濾器原理
當(dāng)然,這個事情早就有人研究過了,在1970 年的時候,有一個叫做布隆的前輩對于判斷海量元素中元素是否存在的問題進(jìn)行了研究,也就是到底需要多大的位圖容量和多少個哈希函數(shù),它發(fā)表了一篇論文,提出的這個容器就叫做布隆過濾器。
我們來看一下布隆過濾器的工作原理。
首先,布隆過濾器的本質(zhì)就是我們剛才分析的,一個位數(shù)組,和若干個哈希函數(shù)。
集合里面有3 個元素,要把它存到布隆過濾器里面去,應(yīng)該怎么做?首先是a 元素,這里我們用3 次計算。b、c 元素也一樣。
元素已經(jīng)存進(jìn)去之后,現(xiàn)在我要來判斷一個元素在這個容器里面是否存在,就要使用同樣的三個函數(shù)進(jìn)行計算。
比如d 元素,我用第一個函數(shù)f1 計算,發(fā)現(xiàn)這個位置上是1,沒問題。第二個位置也是1,第三個位置也是1 。
如果經(jīng)過三次計算得到的下標(biāo)位置值都是1,這種情況下,能不能確定d 元素一定在這個容器里面呢? 實際上是不能的。比如這張圖里面,這三個位置分別是把a,b,c 存進(jìn)去的時候置成1 的,所以即使d 元素之前沒有存進(jìn)去,也會得到三個1,判斷返回true。
所以,這個是布隆過濾器的一個很重要的特性,因為哈希碰撞不可避免,所以它會存在一定的誤判率。這種把本來不存在布隆過濾器中的元素誤判為存在的情況,我們把它叫做假陽性(False Positive Probability,F(xiàn)PP)。
我們再來看另一個元素,e 元素。我們要判斷它在容器里面是否存在,一樣地要用這三個函數(shù)去計算。第一個位置是1,第二個位置是1,第三個位置是0。
e 元素是不是一定不在這個容器里面呢? 可以確定一定不存在。如果說當(dāng)時已經(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++;
}
布隆過濾器把誤判率默認(rèn)設(shè)置為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ù)組的大小,我們進(jìn)一步計算出了哈希函數(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 說有,才走之前的流程。
布隆過濾器的其他應(yīng)用場景
布隆過濾器解決的問題是什么?如何在海量元素中快速判斷一個元素是否存在。所以除了解決緩存穿透的問題之外,我們還有很多其他的用途。
比如爬數(shù)據(jù)的爬蟲,爬過的url 我們不需要重復(fù)爬,那么在幾十億的url 里面,怎么判斷一個url 是不是已經(jīng)爬過了?
還有我們的郵箱服務(wù)器,發(fā)送垃圾郵件的賬號我們把它們叫做spamer,在這么多的郵箱賬號里,怎么判斷一個賬號是不是spamer 等等一些場景,我們都可以用到布隆過濾器。
——學(xué)自咕泡學(xué)院