Redis 高階應用

劣技砖猿發表於2024-05-25

生成全域性唯一 ID

全域性唯一 ID 需要滿足以下要求:

  • 唯一性:在分散式環境中,要全域性唯一
  • 高可用:在高併發情況下保證可用性
  • 高效能:在高併發情況下生成 ID 的速度必須要快,不能花費太長時間
  • 遞增性:要確保整體遞增的,以便於資料庫建立索引
  • 安全性:ID 的規律性不能太明顯,以免資訊洩露

從上面的要求可以看出,全域性 ID 生成器的條件還是比較苛刻的,而 Redis 恰巧可以滿足以上要求。
Redis 本身就是就是以效能著稱,因此完全符合高效能的要求,其次使用 Redis 的 incr 命令可以保證遞增性,配合相應的分散式 ID 生成演算法便可以實現唯一性和安全性,Redis 可以透過哨兵、主從等叢集方案來保證可用性。因此 Redis 是一個不錯的選擇。
下面我們就寫一個簡單的示例,來讓大家感受一下,實際工作中大家可以根據需要進行調整:

@Component
public class IDUtil{
	//開始時間戳(單位:秒) 2000-01-01 00:00:00
	private static final long START_TIMESTAMP = 946656000L;
	//Spring Data Redis 提供的 Redis 操作模板
	@Resource
	private StringRedisTemplate stringRedisTemplate;
	/**
     * 獲取 ID   格式:時間戳+序列號
     * @param keyPrefix Redis 序列號字首
     * @return 生成的 ID
     */
	public long getNextId(String keyPrefix){
		//獲取當前時間戳
		LocalDateTime now = LocalDateTime.now();
		long nowTimestamp = now.toEpochSecond(ZoneOffset.UTC);
		//獲取 ID 時間戳
		long timestamp = nowSecond - START_TIMESTAMP;
		
		//獲取當前日期
		String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd"));
		//生成 key
		String key = "incr:" + keyPrefix + ":" + date;
		//獲取序列號
		long count = stringRedisTemplate.opsForValue().increment(key);
		//生成 ID 並返回
		return timestamp << 32 | count;
	}
}

分散式鎖

在 JVM 內部會有一個鎖監視器來控制執行緒間的互斥,但在分散式的環境下會有多臺機器部署同樣的服務,也就是說每臺機器都會有自己的鎖監視器。而 JVM 的鎖監視器只能保證自己內部執行緒的安全執行,並不能保證不同機器間的執行緒安全執行,因此也很難避免高併發帶來的執行緒安全問題。因此就需要分散式鎖來保證整個叢集的執行緒的安全,而分散式鎖需要滿足 5 點要求:多程序可見、互斥性、高可用、高效能、安全性
其中核心要求就是多程序之間互斥,而滿足這一點的方式有很多,最常見的有三種:mysql、Redis、Zookeeper。
image
透過對比我們發現,其中 Redis 的效果最理想,所以下面就用 Redis 來實現一個簡單的分散式鎖。

public class DistributedLockUtil {
    //分散式鎖字首
    private static final String KEY_PREFIX = "distributed:lock:";
    //業務名
    private String business;
    //分散式鎖的值
    private String value;
    //Spring Data Redis 提供的 Redis 操作模板
    private StringRedisTemplate stringRedisTemplate;
    //私有化無參構造
    private DistributedLockUtil(){}
    //有參構造
    public DistributedLockUtil(String business,StringRedisTemplate stringRedisTemplate){
        this.business = business;
        this.stringRedisTemplate = stringRedisTemplate;
        this.value = UUID.randomUUID().toString();
    }
    /**
     * 嘗試獲取鎖
     * @param timeout 超時時間(單位:秒)
     * @return 鎖是否獲取成功
     */
    public boolean tryLock(long timeout){
        //生成分散式鎖的 key
        StringBuffer keyBuffer = new StringBuffer(KEY_PREFIX);
        keyBuffer.append(business);
        Boolean success = stringRedisTemplate.opsForValue().setIsAbsent(keyBuffer.toString(),value,timeout, TimeUnit.SECONDS);
        //返回結果  注意:為了防止自動拆箱時出現空指標,所以這裡用了 equals 判斷
        return Boolean.TRUE.equals(success);
    }

    /**
     * 釋放鎖(不安全版)
     */
    public void unLock(){
        //生成分散式鎖的 key
        StringBuffer keyBuffer = new StringBuffer(KEY_PREFIX);
        keyBuffer.append(business);
        //獲取分散式鎖的值
        String redisValue = stringRedisTemplate.opsForValue().get(keyBuffer.toString());
        //判斷值是否一致,防止誤刪
        if (value.equals(redisValue)) {
            //當程式碼執行到這裡時,如果 JVM 恰巧執行了垃圾回收(雖然機率極低),就會導致所有執行緒阻塞等待,因此這裡仍然會有執行緒安全的問題
            stringRedisTemplate.delete(keyBuffer.toString());
        }
    }

    /**
     * 透過指令碼釋放鎖(徹底解決執行緒安全問題)
     */
    public void unLockWithScript(){
        //載入 lua 指令碼,實際工作中我們可以將指令碼設定為常量,並在靜態程式碼塊中初始化(指令碼內容在下文)
        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setLocation(new ClassPathResource("unlock.lua"));
        script.setResultType(Long.class);
        //生成分散式鎖的 key
        StringBuffer keyBuffer = new StringBuffer(KEY_PREFIX);
        keyBuffer.append(business);
        //呼叫 lua 指令碼釋放鎖
        stringRedisTemplate.execute(script,
                Collections.singletonList(keyBuffer.toString()),
                value);
    }
}

lua 指令碼內容如下:

-- 判斷值是否一致,防止誤刪
if(redis.call('get',KEYS[1]) == VRGV[1]) then
	-- 判斷透過,釋放鎖
	return redis.call('del',KEYS[1])
end
-- 判斷不透過,返回 0
return 0

雖然透過 lua 指令碼解決了執行緒不安全的問題,但是仍然存在以下問題:

  • 不可重入:同一個執行緒無法多次獲取同一把鎖
  • 不可重試:獲取鎖只能嘗試一次,失敗就返回 false,沒有重試機制
  • 超時釋放:鎖超時釋放雖然可以避免死鎖,但如果業務執行耗時較長,也會導致鎖釋放,存在安全隱患
  • 主從一致性:如果 Redis 提供了主從叢集,主從同步存在延遲,當主機當機時,如果從機還沒來得及同步主機的鎖資料,則會出現鎖失效。

要解決以上問題也非常簡單,只需要利用 Redis 的 hash 結構記錄執行緒標識和重入次數就可以解決不可重入的問題。利用訊號量和 PubSub 功能實現等待、喚醒,獲取鎖失敗的重試機制即可解決不可重試的問題。而超時釋放的問題則可以透過獲取鎖時為鎖新增一個定時任務(俗稱看門狗),定期重新整理鎖的超時時間即可。至於主從一致性問題,我們只需要利用多個獨立的 Redis 節點(非主從),必須在所有節點都獲取重入鎖,才算獲取鎖成功。

image

有的人可能說了,雖然說起來簡單,但真正實現起來也不是很容易呀。對於這種問題,大家不用擔心,俗話說得好想要看的更遠,需要站在巨人的肩膀上。對於上述的需求,早就有了成熟的開源方案 Redisson ,我們直接拿來用就可以了,無需重複造輪子,具體使用方法可以檢視官方文件

輕量化訊息佇列

雖然市面上有很多優秀的訊息中介軟體如 RocketMQ、Kafka 等,但對於應用場景較為簡單,只需要簡單的訊息傳遞,比如任務排程、簡單的通知系統等,不需要複雜的訊息路由、事務支援的業務來說,用那些專門的訊息中介軟體成本就顯得過高。因此我們就可以使用 Redis 來做訊息佇列。
Redis 提供了三種不同的方式來實現訊息佇列:

  • list 結構:可以使用 list 來模擬訊息佇列,可以使用 BRPOP 或 BLPOP 命令來實現類似 JVM 阻塞佇列的訊息佇列。
  • PubSub:基於釋出/訂閱的訊息模型,但不支援資料持久化,且訊息堆積有上限,超出時資料丟失。
  • Stream:Redis 5.0 新增的資料型別,可以實現一個功能非常完善的訊息佇列,也是我們實現訊息佇列的首選。

image

下面我就採用 Redis 的 Stream 實現一個簡單的案例來讓大家感受一下,實際工作中大家可以根據需要進行調整:

public class RedisQueueUtil{
	//Spring Data Redis 提供的 Redis 操作模板
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 獲取訊息佇列中的資料,執行該方法前,一定要確保消費者組已經建立
     * @param queueName 佇列名
     * @param groupName 消費者組名
     * @param consumerName 消費者名
     * @param type 返回值型別
     * @return 訊息佇列中的資料
     */
    public <T> T getQueueData(String queueName, String groupName, String consumerName, Class<T> type){
        while (true){
            try {
                //獲取訊息佇列中的資訊
                List<MapRecord<String,Object,Object>> list = stringRedisTemplate.opsForStream().read(
                        Consumer.from(groupName,consumerName),
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        StreamOffset.create(queueName, ReadOffset.lastConsumed())
                );
                //判斷訊息是否獲取成功
                if (list == null || list.isEmpty()){
                    //如果獲取失敗,說明沒有訊息,繼續下一次迴圈
                    continue;
                }
                //如果獲取成功,則解析訊息中的資料
                MapRecord<String,Object,Object> record = list.get(0);
                Map<Object,Object> values = record.getValue();
                String jsonString = JSON.toJSONString(values);
                T result = JSON.parseObject(jsonString, type);
                // ACK
                stringRedisTemplate.opsForStream().acknowledge(queueName,groupName,record.getId());
                //返回結果
                return result;
            }catch (Exception e){
                while (true){
                    try {
                        //獲取 pending-list 佇列中的資訊
                        List<MapRecord<String,Object,Object>> list = stringRedisTemplate.opsForStream().read(
                                Consumer.from(groupName,consumerName),
                                StreamReadOptions.empty().count(1)),
                                StreamOffset.create(queueName,ReadOffset.from("0")
                        );
                        //判斷訊息是否獲取成功
                        if (list == null || list.isEmpty()){
                            //如果獲取失敗,說明 pending-list 沒有異常訊息,結束迴圈
                            break;
                        }
                        //如果獲取成功,則解析訊息中的資料
                        MapRecord<String,Object,Object> record = list.get(0);
                        Map<Object,Object> values = record.getValue();
                        String jsonString = JSON.toJSONString(values);
                        T result = JSON.parseObject(jsonString, type);
                        // ACK
                        stringRedisTemplate.opsForStream().acknowledge(queueName,groupName,record.getId());
                        //返回結果
                        return result;
                    }catch (Exception ex){
                        log.error("處理 pending-list 訂單異常",ex);
                        try {
                            Thread.sleep(50);
                        }catch (InterruptedException err){
                            err.printStackTrace();
                        }
                    }
                }
            }
        }
    }

    /**
     * 向訊息佇列中傳送資料
     * @param queueName 訊息佇列名
     * @param map 要傳送資料的集合
     */
    public void sendQueueData(String queueName, Map<String,Object> map){
        StringBuilder builder = new StringBuilder("redis.call('xadd','");
        builder.append(queueName).append("','*','");
        Set<String> keys = map.keySet();
        for(String key:keys){
            builder.append(key).append("','").append(map.get(key)).append("','");
        }
        String script = builder.substring(0, builder.length() - 2);
        script += ")";
        stringRedisTemplate.execute(new DefaultRedisScript<Long>(script,Long.class),Collections.emptyList());
    }
}

相關文章