Redis常見知識彙總(三)

目錄

八、分佈式鎖

8.1、分佈式鎖

8.2、redis

1、加鎖

2、解鎖

3、實現

8.3、redisson

1、可重入鎖

2、獲取鎖實例

3、加鎖

4、解鎖

九、常見緩存問題與解決方法

9.1、緩存處理流程

9.2、緩存穿透

9.3、緩存擊穿

9.4、緩存雪崩

9.5緩存雙寫一致性

9.5.1、先更新數據庫,再更新緩存

9.5.2、先刪除緩存,再更新數據庫

9.5.3、先更新數據庫,再刪除緩存

9.5.4、緩存的不適用場景



八、分佈式鎖

在Java中,關於鎖我想大家都很熟悉。在併發編程中,我們通過鎖,來避免由於競爭而造成的數據不一致問題。通常,我們以synchronized 、Lock來使用它。

但是Java中的鎖,只能保證在同一個JVM進程內中執行。如果在分佈式集羣環境下呢?

8.1、分佈式鎖

分佈式鎖,是一種思想,它的實現方式有很多。比如,我們將沙灘當做分佈式鎖的組件,那麼它看起來應該是這樣的:

  • 加鎖

在沙灘上踩一腳,留下自己的腳印,就對應了加鎖操作。其他進程或者線程,看到沙灘上已經有腳印,證明鎖已被別人持有,則等待。

  • 解鎖

把腳印從沙灘上抹去,就是解鎖的過程。

  • 鎖超時

爲了避免死鎖,我們可以設置一陣風,在單位時間後颳起,將腳印自動抹去。

分佈式鎖的實現有很多,比如基於數據庫、memcached、Redis、系統文件、zookeeper等。它們的核心的理念跟上面的過程大致相同。

8.2、redis

我們先來看如何通過單節點Redis實現一個簡單的分佈式鎖。

1、加鎖

加鎖實際上就是在redis中,給Key鍵設置一個值,爲避免死鎖,並給定一個過期時間。

SET lock_key random_value NX PX 5000

值得注意的是:
random_value 是客戶端生成的唯一的字符串。
NX 代表只在鍵不存在時,纔對鍵進行設置操作。
PX 5000 設置鍵的過期時間爲5000毫秒。

這樣,如果上面的命令執行成功,則證明客戶端獲取到了鎖。

2、解鎖

解鎖的過程就是將Key鍵刪除。但也不能亂刪,不能說客戶端1的請求將客戶端2的鎖給刪除掉。這時候random_value的作用就體現出來。

爲了保證解鎖操作的原子性,我們用LUA腳本完成這一操作。先判斷當前鎖的字符串是否與傳入的值相等,是的話就刪除Key,解鎖成功。

if redis.call('get',KEYS[1]) == ARGV[1] then 
   return redis.call('del',KEYS[1]) 
else
   return 0 
end

3、實現

首先,我們在pom文件中,引入Jedis。在這裏,筆者用的是最新版本,注意由於版本的不同,API可能有所差異。

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.0.1</version>
</dependency>

加鎖的過程很簡單,就是通過SET指令來設置值,成功則返回;否則就循環等待,在timeout時間內仍未獲取到鎖,則獲取失敗。

@Service
public class RedisLock {

    Logger logger = LoggerFactory.getLogger(this.getClass());

    private String lock_key = "redis_lock"; //鎖鍵

    protected long internalLockLeaseTime = 30000;//鎖過期時間

    private long timeout = 999999; //獲取鎖的超時時間

    
    //SET命令的參數 
    SetParams params = SetParams.setParams().nx().px(internalLockLeaseTime);

    @Autowired
    JedisPool jedisPool;

    
    /**
     * 加鎖
     * @param id
     * @return
     */
    public boolean lock(String id){
        Jedis jedis = jedisPool.getResource();
        Long start = System.currentTimeMillis();
        try{
            for(;;){
                //SET命令返回OK ,則證明獲取鎖成功
                String lock = jedis.set(lock_key, id, params);
                if("OK".equals(lock)){
                    return true;
                }
                //否則循環等待,在timeout時間內仍未獲取到鎖,則獲取失敗
                long l = System.currentTimeMillis() - start;
                if (l>=timeout) {
                    return false;
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }finally {
            jedis.close();
        }
    }
}

解鎖我們通過jedis.eval來執行一段LUA就可以。將鎖的Key鍵和生成的字符串當做參數傳進來。

    /**
     * 解鎖
     * @param id
     * @return
     */
    public boolean unlock(String id){
        Jedis jedis = jedisPool.getResource();
        String script =
                "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                        "   return redis.call('del',KEYS[1]) " +
                        "else" +
                        "   return 0 " +
                        "end";
        try {
            Object result = jedis.eval(script, Collections.singletonList(lock_key), 
                                    Collections.singletonList(id));
            if("1".equals(result.toString())){
                return true;
            }
            return false;
        }finally {
            jedis.close();
        }
    }

最後,我們可以在多線程環境下測試一下。我們開啓1000個線程,對count進行累加。調用的時候,關鍵是唯一字符串的生成。這裏,筆者使用的是Snowflake算法。

@Controller
public class IndexController {

    @Autowired
    RedisLock redisLock;
    
    int count = 0;
    
    @RequestMapping("/index")
    @ResponseBody
    public String index() throws InterruptedException {

        int clientcount =1000;
        CountDownLatch countDownLatch = new CountDownLatch(clientcount);

        ExecutorService executorService = Executors.newFixedThreadPool(clientcount);
        long start = System.currentTimeMillis();
        for (int i = 0;i<clientcount;i++){
            executorService.execute(() -> {
            
                //通過Snowflake算法獲取唯一的ID字符串
                String id = IdUtil.getId();
                try {
                    redisLock.lock(id);
                    count++;
                }finally {
                    redisLock.unlock(id);
                }
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();
        logger.info("執行線程數:{},總耗時:{},count數爲:{}",clientcount,end-start,count);
        return "Hello";
    }
}

至此,單節點Redis的分佈式鎖的實現就已經完成了。比較簡單,但是問題也比較大,最重要的一點是,鎖不具有可重入性。

可參考如何優雅地用Redis實現分佈式鎖一步步講解

http://www.redis.cn/articles/20181020004.html

8.3、redisson

Redisson是架設在Redis基礎上的一個Java駐內存數據網格(In-Memory Data Grid)。充分的利用了Redis鍵值數據庫提供的一系列優勢,基於Java實用工具包中常用接口,爲使用者提供了一系列具有分佈式特性的常用工具類。使得原本作爲協調單機多線程併發程序的工具包獲得了協調分佈式多機多線程併發系統的能力,大大降低了設計和研發大規模分佈式系統的難度。同時結合各富特色的分佈式服務,更進一步簡化了分佈式環境中程序相互之間的協作。

相對於Jedis而言,Redisson強大的一批。當然了,隨之而來的就是它的複雜性。它裏面也實現了分佈式鎖,而且包含多種類型的鎖,更多請參閱分佈式鎖和同步器

1、可重入鎖

上面我們自己實現的Redis分佈式鎖,其實不具有可重入性。那麼下面我們先來看看Redisson中如何調用可重入鎖。

在這裏,筆者使用的是它的最新版本,3.10.1。

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.10.1</version>
</dependency>

首先,通過配置獲取RedissonClient客戶端的實例,然後getLock獲取鎖的實例,進行操作即可。

public static void main(String[] args) {

    Config config = new Config();
    config.useSingleServer().setAddress("redis://127.0.0.1:6379");
    config.useSingleServer().setPassword("redis1234");
    
    final RedissonClient client = Redisson.create(config);  
    RLock lock = client.getLock("lock1");
    
    try{
        lock.lock();
    }finally{
        lock.unlock();
    }
}

2、獲取鎖實例

我們先來看RLock lock = client.getLock("lock1"); 這句代碼就是爲了獲取鎖的實例,然後我們可以看到它返回的是一個RedissonLock對象。

public RLock getLock(String name) {
    return new RedissonLock(connectionManager.getCommandExecutor(), name);
}

RedissonLock構造方法中,主要初始化一些屬性。

public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
    super(commandExecutor, name);
    //命令執行器
    this.commandExecutor = commandExecutor;
    //UUID字符串
    this.id = commandExecutor.getConnectionManager().getId();
    //內部鎖過期時間
    this.internalLockLeaseTime = commandExecutor.
                getConnectionManager().getCfg().getLockWatchdogTimeout();
    this.entryName = id + ":" + name;
}

3、加鎖

當我們調用lock方法,定位到lockInterruptibly。在這裏,完成了加鎖的邏輯。

public void lockInterruptibly(long leaseTime, TimeUnit unit) throws InterruptedException {
    
    //當前線程ID
    long threadId = Thread.currentThread().getId();
    //嘗試獲取鎖
    Long ttl = tryAcquire(leaseTime, unit, threadId);
    // 如果ttl爲空,則證明獲取鎖成功
    if (ttl == null) {
        return;
    }
    //如果獲取鎖失敗,則訂閱到對應這個鎖的channel
    RFuture<RedissonLockEntry> future = subscribe(threadId);
    commandExecutor.syncSubscription(future);

    try {
        while (true) {
            //再次嘗試獲取鎖
            ttl = tryAcquire(leaseTime, unit, threadId);
            //ttl爲空,說明成功獲取鎖,返回
            if (ttl == null) {
                break;
            }
            //ttl大於0 則等待ttl時間後繼續嘗試獲取
            if (ttl >= 0) {
                getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
            } else {
                getEntry(threadId).getLatch().acquire();
            }
        }
    } finally {
        //取消對channel的訂閱
        unsubscribe(future, threadId);
    }
    //get(lockAsync(leaseTime, unit));
}

如上代碼,就是加鎖的全過程。先調用tryAcquire來獲取鎖,如果返回值ttl爲空,則證明加鎖成功,返回;如果不爲空,則證明加鎖失敗。這時候,它會訂閱這個鎖的Channel,等待鎖釋放的消息,然後重新嘗試獲取鎖。流程如下:

 

獲取鎖

獲取鎖的過程是怎樣的呢?接下來就要看tryAcquire方法。在這裏,它有兩種處理方式,一種是帶有過期時間的鎖,一種是不帶過期時間的鎖。

private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) {

    //如果帶有過期時間,則按照普通方式獲取鎖
    if (leaseTime != -1) {
        return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
    }
    
    //先按照30秒的過期時間來執行獲取鎖的方法
    RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
        commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
        TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
        
    //如果還持有這個鎖,則開啓定時任務不斷刷新該鎖的過期時間
    ttlRemainingFuture.addListener(new FutureListener<Long>() {
        @Override
        public void operationComplete(Future<Long> future) throws Exception {
            if (!future.isSuccess()) {
                return;
            }

            Long ttlRemaining = future.getNow();
            // lock acquired
            if (ttlRemaining == null) {
                scheduleExpirationRenewal(threadId);
            }
        }
    });
    return ttlRemainingFuture;
}

接着往下看,tryLockInnerAsync方法是真正執行獲取鎖的邏輯,它是一段LUA腳本代碼。在這裏,它使用的是hash數據結構。

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit,     
                            long threadId, RedisStrictCommand<T> command) {

        //過期時間
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  //如果鎖不存在,則通過hset設置它的值,並設置過期時間
                  "if (redis.call('exists', KEYS[1]) == 0) then " +
                      "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  //如果鎖已存在,並且鎖的是當前線程,則通過hincrby給數值遞增1
                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  //如果鎖已存在,但並非本線程,則返回過期時間ttl
                  "return redis.call('pttl', KEYS[1]);",
        Collections.<Object>singletonList(getName()), 
                internalLockLeaseTime, getLockName(threadId));
    }

這段LUA代碼看起來並不複雜,有三個判斷:

  • 通過exists判斷,如果鎖不存在,則設置值和過期時間,加鎖成功
  • 通過hexists判斷,如果鎖已存在,並且鎖的是當前線程,則證明是重入鎖,加鎖成功
  • 如果鎖已存在,但鎖的不是當前線程,則證明有其他線程持有鎖。返回當前鎖的過期時間,加鎖失敗

加鎖成功後,在redis的內存數據中,就有一條hash結構的數據。Key爲鎖的名稱;field爲隨機字符串+線程ID;值爲1。如果同一線程多次調用lock方法,值遞增1。

127.0.0.1:6379> hgetall lock1
1) "b5ae0be4-5623-45a5-8faa-ab7eb167ce87:1"
2) "1"

4、解鎖

我們通過調用unlock方法來解鎖。

public RFuture<Void> unlockAsync(final long threadId) {
    final RPromise<Void> result = new RedissonPromise<Void>();
    
    //解鎖方法
    RFuture<Boolean> future = unlockInnerAsync(threadId);

    future.addListener(new FutureListener<Boolean>() {
        @Override
        public void operationComplete(Future<Boolean> future) throws Exception {
            if (!future.isSuccess()) {
                cancelExpirationRenewal(threadId);
                result.tryFailure(future.cause());
                return;
            }
            //獲取返回值
            Boolean opStatus = future.getNow();
            //如果返回空,則證明解鎖的線程和當前鎖不是同一個線程,拋出異常
            if (opStatus == null) {
                IllegalMonitorStateException cause = 
                    new IllegalMonitorStateException("
                        attempt to unlock lock, not locked by current thread by node id: "
                        + id + " thread-id: " + threadId);
                result.tryFailure(cause);
                return;
            }
            //解鎖成功,取消刷新過期時間的那個定時任務
            if (opStatus) {
                cancelExpirationRenewal(null);
            }
            result.trySuccess(null);
        }
    });

    return result;
}

然後我們再看unlockInnerAsync方法。這裏也是一段LUA腳本代碼。

protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, EVAL,
    
            //如果鎖已經不存在, 發佈鎖釋放的消息
            "if (redis.call('exists', KEYS[1]) == 0) then " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; " +
            "end;" +
            //如果釋放鎖的線程和已存在鎖的線程不是同一個線程,返回null
            "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                "return nil;" +
            "end; " +
            //通過hincrby遞減1的方式,釋放一次鎖
            //若剩餘次數大於0 ,則刷新過期時間
            "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
            "if (counter > 0) then " +
                "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                "return 0; " +
            //否則證明鎖已經釋放,刪除key併發布鎖釋放的消息
            "else " +
                "redis.call('del', KEYS[1]); " +
                "redis.call('publish', KEYS[2], ARGV[1]); " +
                "return 1; "+
            "end; " +
            "return nil;",
    Arrays.<Object>asList(getName(), getChannelName()), 
        LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));

}

如上代碼,就是釋放鎖的邏輯。同樣的,它也是有三個判斷:

  • 如果鎖已經不存在,通過publish發佈鎖釋放的消息,解鎖成功

  • 如果解鎖的線程和當前鎖的線程不是同一個,解鎖失敗,拋出異常

  • 通過hincrby遞減1,先釋放一次鎖。若剩餘次數還大於0,則證明當前鎖是重入鎖,刷新過期時間;若剩餘次數小於0,刪除key併發布鎖釋放的消息,解鎖成功

至此,Redisson中的可重入鎖的邏輯,就分析完了。但值得注意的是,上面的兩種實現方式都是針對單機Redis實例而進行的。如果我們有多個Redis實例,請參閱Redlock算法。該算法的具體內容,請參考http://redis.cn/topics/distlock.html



 

九、常見緩存問題與解決方法

9.1、緩存處理流程

      前臺請求,後臺先從緩存中取數據,取到直接返回結果,取不到時從數據庫中取,數據庫取到更新緩存,並返回結果,數據庫也沒取到,那直接返回空結果。

      

 

9.2、緩存穿透

       描述:

       緩存穿透是指緩存和數據庫中都沒有的數據,而用戶不斷髮起請求,如發起爲id爲“-1”的數據或id爲特別大不存在的數據。這時的用戶很可能是攻擊者,攻擊會導致數據庫壓力過大。

      解決方案:

接口層增加校驗,如用戶鑑權校驗,id做基礎校驗,id<=0的直接攔截;
從緩存取不到的數據,在數據庫中也沒有取到,這時也可以將key-value對寫爲key-null,緩存有效時間可以設置短點,如30秒(設置太長會導致正常情況也沒法使用)。這樣可以防止攻擊用戶反覆用同一個id暴力攻擊
 

9.3、緩存擊穿

      描述:

      緩存擊穿是指緩存中沒有但數據庫中有的數據(一般是緩存時間到期),這時由於併發用戶特別多,同時讀緩存沒讀到數據,又同時去數據庫去取數據,引起數據庫壓力瞬間增大,造成過大壓力

      解決方案:

設置熱點數據永遠不過期。
加互斥鎖,互斥鎖參考代碼如下:
         

 

          說明:

          1)緩存中有數據,直接走上述代碼13行後就返回結果了

         2)緩存中沒有數據,第1個進入的線程,獲取鎖並從數據庫去取數據,沒釋放鎖之前,其他並行進入的線程會等待100ms,再重新去緩存取數據。這樣就防止都去數據庫重複取數據,重複往緩存中更新數據情況出現。

          3)當然這是簡化處理,理論上如果能根據key值加鎖就更好了,就是線程A從數據庫取key1的數據並不妨礙線程B取key2的數據,上面代碼明顯做不到這點。

 

9.4、緩存雪崩

      描述:

      緩存雪崩是指緩存中數據大批量到過期時間,而查詢數據量巨大,引起數據庫壓力過大甚至down機。和緩存擊穿不同的是,        緩存擊穿指併發查同一條數據,緩存雪崩是不同數據都過期了,很多數據都查不到從而查數據庫。

     解決方案:緩存數據的過期時間設置隨機,防止同一時間大量數據過期現象發生。
如果緩存數據庫是分佈式部署,將熱點數據均勻分佈在不同搞得緩存數據庫中。
設置熱點數據永遠不過期。

9.5緩存雙寫一致性

緩存由於其高併發和高性能的特性,已經在項目中被廣泛使用。一般讀緩存流程如下:

 

緩存更新的爭議

主要是解決併發情況下緩存讀與寫的一致性和時效性
一 緩存不能讀到髒數據
二 緩存可能會讀到過期數據,但要在可容忍時間內實現最總一致
三 這個可容忍時間儘可能的小

基本核心套路(當然有很多變種,但主要思想如此):

  • 先更新數據庫,再更新緩存
  • 先刪除緩存,再更新數據庫
  • 先更新數據庫,再刪除緩存
  • 爲什麼沒有先更新緩存,再更新數據庫這種策略(ram、rom)

9.5.1、先更新數據庫,再更新緩存

問題:同時有請求A和請求B進行更新操作,可能出現
(1)線程A更新了數據庫 key = 1 value = 2
(2)線程B更新了數據庫 key = 1 value = 3
(3)線程B更新了緩存 key = 1 value = 3
(4)線程A更新了緩存 key = 1 value = 2

這就出現請求A更新緩存應該比請求B更新緩存早纔對,但是因爲網絡等原因,B卻比A更早更新了緩存。這就導致了髒數據

方法:

  • 更新緩存數據時判是否最新(如增加版本號)
  • 只有比緩存中的新纔可更新,否則不管(版本號大於當前版本號纔可更新)

9.5.2、先刪除緩存,再更新數據庫

問題:同時有一個請求A進行更新操作,一個請求B進行查詢操作。可能出現:
(1)請求A進行寫操作(key = 1 value = 2),先刪除緩存 key = 1 value = 1
(2)請求B查詢發現緩存不存在
(3)請求B去數據庫查詢得到舊值 key = 1 value = 1
(4)請求B將舊值寫入緩存 key = 1 value = 1
(5)請求A將新值寫入數據庫key = 1 value = 2

如果不採用給緩存設置過期時間策略,該數據永遠都是髒數據

方法:

  • 先刪除緩存,再更新數據庫,再回寫緩存(出現套路一的情況)
  • 先刪除緩存,再更新數據庫,再刪緩存(雙刪,第二次刪可異步)

數據庫讀寫分離,本地緩存都不再適用,數據一致性需要用到遠程數據同步

9.5.3、先更新數據庫,再刪除緩存

有兩篇參考:《Cache-Aside pattern》、《Scaling Memcache at Facebook》
主要的思路就是先更新數據庫,再刪緩存的策略。

問題:一個請求A做查詢操作,一個請求B做更新操作,可能情況

(1)緩存剛好失效
(2)請求A查詢數據庫,得一箇舊值
(3)請求B將新值寫入數據庫
(4)請求B刪除緩存
(5)請求A將查到的舊值寫入緩存

如果發生上述情況,確實是會發生髒數據。然而,發生這種情況的概率又有多少呢?
我們知道一般來說讀比寫快很多,這一情形很難出現。

二、三套路的共性問題:刪緩存失敗了怎麼辦?

方案一

 

方案缺點:

  • 引入中間件,增加複雜度
  • 對業務線代碼造成大量的侵入

方案二

啓動一個訂閱程序去訂閱數據庫的binlog,獲得需要操作的數據。在應用程序中,另起一段程序,獲得這個訂閱程序傳來的信息,進行刪除緩存操作。

 

mysql中有現成的中間件叫canal,可以完成訂閱binlog日誌的功能。
另我團的DataBus更強大。

9.5.4、緩存的不適用場景

  • 寫多:如果新增、修改、刪除比較多,則需慎用緩存,因爲緩存會頻繁的更新會很浪費性能。
  • 大Key/Value:大到會影響GC、swap,最好別用。



作者:jiangmo
鏈接:https://www.jianshu.com/p/0275ecca2438
來源:簡書
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

參考文獻:

https://www.jianshu.com/p/47fd7f86c848

https://blog.csdn.net/kongtiao5/article/details/82771694

https://www.jianshu.com/p/0275ecca2438

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章